Merge branch '21644-flaky-test' main
authorTom Clegg <tom@curii.com>
Wed, 17 Apr 2024 15:37:11 +0000 (11:37 -0400)
committerTom Clegg <tom@curii.com>
Wed, 17 Apr 2024 15:37:11 +0000 (11:37 -0400)
fixes #21644

Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

2494 files changed:
.github/workflows/tests.yml [new file with mode: 0644]
.gitignore
.licenseignore
AUTHORS
apps/workbench/.gitignore [deleted file]
apps/workbench/Gemfile [deleted file]
apps/workbench/Gemfile.lock [deleted file]
apps/workbench/README.textile [deleted file]
apps/workbench/Rakefile [deleted file]
apps/workbench/app/assets/images/dax.png [deleted file]
apps/workbench/app/assets/images/mouse-move.gif [deleted file]
apps/workbench/app/assets/images/pipeline-running.gif [deleted file]
apps/workbench/app/assets/images/rails.png [deleted file]
apps/workbench/app/assets/images/spinner_32px.gif [deleted file]
apps/workbench/app/assets/images/trash-icon.png [deleted file]
apps/workbench/app/assets/javascripts/add_group.js [deleted file]
apps/workbench/app/assets/javascripts/add_repository.js [deleted file]
apps/workbench/app/assets/javascripts/ajax_error.js [deleted file]
apps/workbench/app/assets/javascripts/angular_shim.js [deleted file]
apps/workbench/app/assets/javascripts/application.js [deleted file]
apps/workbench/app/assets/javascripts/arvados_client.js [deleted file]
apps/workbench/app/assets/javascripts/bootstrap.js [deleted file]
apps/workbench/app/assets/javascripts/collections.js [deleted file]
apps/workbench/app/assets/javascripts/components/date.js [deleted file]
apps/workbench/app/assets/javascripts/components/edit_tags.js [deleted file]
apps/workbench/app/assets/javascripts/components/save_ui_state.js [deleted file]
apps/workbench/app/assets/javascripts/components/search.js [deleted file]
apps/workbench/app/assets/javascripts/components/sessions.js [deleted file]
apps/workbench/app/assets/javascripts/components/test.js [deleted file]
apps/workbench/app/assets/javascripts/dates.js [deleted file]
apps/workbench/app/assets/javascripts/edit_collection.js [deleted file]
apps/workbench/app/assets/javascripts/editable.js [deleted file]
apps/workbench/app/assets/javascripts/event_log.js [deleted file]
apps/workbench/app/assets/javascripts/filterable.js [deleted file]
apps/workbench/app/assets/javascripts/ilike_filters.js [deleted file]
apps/workbench/app/assets/javascripts/infinite_scroll.js [deleted file]
apps/workbench/app/assets/javascripts/job_log_graph.js [deleted file]
apps/workbench/app/assets/javascripts/jquery.number.min.js [deleted file]
apps/workbench/app/assets/javascripts/keep_disks.js [deleted file]
apps/workbench/app/assets/javascripts/link_to_remote.js [deleted file]
apps/workbench/app/assets/javascripts/list.js [deleted file]
apps/workbench/app/assets/javascripts/log_viewer.js [deleted file]
apps/workbench/app/assets/javascripts/mithril_mount.js [deleted file]
apps/workbench/app/assets/javascripts/modal_pager.js [deleted file]
apps/workbench/app/assets/javascripts/models/loader.js [deleted file]
apps/workbench/app/assets/javascripts/models/session_db.js [deleted file]
apps/workbench/app/assets/javascripts/permission_toggle.js [deleted file]
apps/workbench/app/assets/javascripts/pipeline_instances.js [deleted file]
apps/workbench/app/assets/javascripts/report_issue.js [deleted file]
apps/workbench/app/assets/javascripts/request_shell_access.js [deleted file]
apps/workbench/app/assets/javascripts/select_modal.js [deleted file]
apps/workbench/app/assets/javascripts/selection.js.erb [deleted file]
apps/workbench/app/assets/javascripts/sizing.js [deleted file]
apps/workbench/app/assets/javascripts/tab_panes.js [deleted file]
apps/workbench/app/assets/javascripts/upload_to_collection.js [deleted file]
apps/workbench/app/assets/javascripts/user_agreements.js [deleted file]
apps/workbench/app/assets/javascripts/users.js [deleted file]
apps/workbench/app/assets/javascripts/work_unit_component.js [deleted file]
apps/workbench/app/assets/javascripts/work_unit_log.js [deleted file]
apps/workbench/app/assets/stylesheets/api_client_authorizations.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/application.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/authorized_keys.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/badges.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/cards.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/collections.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/groups.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/humans.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/job_tasks.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/jobs.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/keep_disks.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/links.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/loading.css.scss.erb [deleted file]
apps/workbench/app/assets/stylesheets/log_viewer.scss [deleted file]
apps/workbench/app/assets/stylesheets/logs.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/nodes.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/pipeline_instances.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/pipeline_templates.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/projects.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/repositories.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/sb-admin.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/scaffolds.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/select_modal.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/sessions.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/specimens.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/traits.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/user_agreements.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/users.css.scss [deleted file]
apps/workbench/app/assets/stylesheets/virtual_machines.css.scss [deleted file]
apps/workbench/app/controllers/actions_controller.rb [deleted file]
apps/workbench/app/controllers/api_client_authorizations_controller.rb [deleted file]
apps/workbench/app/controllers/application_controller.rb [deleted file]
apps/workbench/app/controllers/authorized_keys_controller.rb [deleted file]
apps/workbench/app/controllers/collections_controller.rb [deleted file]
apps/workbench/app/controllers/container_requests_controller.rb [deleted file]
apps/workbench/app/controllers/containers_controller.rb [deleted file]
apps/workbench/app/controllers/groups_controller.rb [deleted file]
apps/workbench/app/controllers/humans_controller.rb [deleted file]
apps/workbench/app/controllers/job_tasks_controller.rb [deleted file]
apps/workbench/app/controllers/jobs_controller.rb [deleted file]
apps/workbench/app/controllers/keep_disks_controller.rb [deleted file]
apps/workbench/app/controllers/keep_services_controller.rb [deleted file]
apps/workbench/app/controllers/links_controller.rb [deleted file]
apps/workbench/app/controllers/logs_controller.rb [deleted file]
apps/workbench/app/controllers/management_controller.rb [deleted file]
apps/workbench/app/controllers/nodes_controller.rb [deleted file]
apps/workbench/app/controllers/pipeline_instances_controller.rb [deleted file]
apps/workbench/app/controllers/pipeline_templates_controller.rb [deleted file]
apps/workbench/app/controllers/projects_controller.rb [deleted file]
apps/workbench/app/controllers/repositories_controller.rb [deleted file]
apps/workbench/app/controllers/search_controller.rb [deleted file]
apps/workbench/app/controllers/sessions_controller.rb [deleted file]
apps/workbench/app/controllers/specimens_controller.rb [deleted file]
apps/workbench/app/controllers/status_controller.rb [deleted file]
apps/workbench/app/controllers/tests_controller.rb [deleted file]
apps/workbench/app/controllers/traits_controller.rb [deleted file]
apps/workbench/app/controllers/trash_items_controller.rb [deleted file]
apps/workbench/app/controllers/user_agreements_controller.rb [deleted file]
apps/workbench/app/controllers/users_controller.rb [deleted file]
apps/workbench/app/controllers/virtual_machines_controller.rb [deleted file]
apps/workbench/app/controllers/websocket_controller.rb [deleted file]
apps/workbench/app/controllers/work_unit_templates_controller.rb [deleted file]
apps/workbench/app/controllers/work_units_controller.rb [deleted file]
apps/workbench/app/controllers/workflows_controller.rb [deleted file]
apps/workbench/app/helpers/application_helper.rb [deleted file]
apps/workbench/app/helpers/arvados_api_client_helper.rb [deleted file]
apps/workbench/app/helpers/collections_helper.rb [deleted file]
apps/workbench/app/helpers/pipeline_components_helper.rb [deleted file]
apps/workbench/app/helpers/pipeline_instances_helper.rb [deleted file]
apps/workbench/app/helpers/provenance_helper.rb [deleted file]
apps/workbench/app/helpers/version_helper.rb [deleted file]
apps/workbench/app/mailers/issue_reporter.rb [deleted file]
apps/workbench/app/mailers/request_shell_access_reporter.rb [deleted file]
apps/workbench/app/models/.gitkeep [deleted file]
apps/workbench/app/models/api_client_authorization.rb [deleted file]
apps/workbench/app/models/arvados_api_client.rb [deleted file]
apps/workbench/app/models/arvados_base.rb [deleted file]
apps/workbench/app/models/arvados_resource_list.rb [deleted file]
apps/workbench/app/models/authorized_key.rb [deleted file]
apps/workbench/app/models/collection.rb [deleted file]
apps/workbench/app/models/container.rb [deleted file]
apps/workbench/app/models/container_request.rb [deleted file]
apps/workbench/app/models/container_work_unit.rb [deleted file]
apps/workbench/app/models/group.rb [deleted file]
apps/workbench/app/models/human.rb [deleted file]
apps/workbench/app/models/job.rb [deleted file]
apps/workbench/app/models/job_task.rb [deleted file]
apps/workbench/app/models/job_task_work_unit.rb [deleted file]
apps/workbench/app/models/job_work_unit.rb [deleted file]
apps/workbench/app/models/keep_disk.rb [deleted file]
apps/workbench/app/models/keep_service.rb [deleted file]
apps/workbench/app/models/link.rb [deleted file]
apps/workbench/app/models/log.rb [deleted file]
apps/workbench/app/models/node.rb [deleted file]
apps/workbench/app/models/pipeline_instance.rb [deleted file]
apps/workbench/app/models/pipeline_instance_work_unit.rb [deleted file]
apps/workbench/app/models/pipeline_template.rb [deleted file]
apps/workbench/app/models/proxy_work_unit.rb [deleted file]
apps/workbench/app/models/repository.rb [deleted file]
apps/workbench/app/models/specimen.rb [deleted file]
apps/workbench/app/models/trait.rb [deleted file]
apps/workbench/app/models/user.rb [deleted file]
apps/workbench/app/models/user_agreement.rb [deleted file]
apps/workbench/app/models/virtual_machine.rb [deleted file]
apps/workbench/app/models/work_unit.rb [deleted file]
apps/workbench/app/models/workflow.rb [deleted file]
apps/workbench/app/views/api_client_authorizations/_show_help.html.erb [deleted file]
apps/workbench/app/views/application/404.html.erb [deleted file]
apps/workbench/app/views/application/404.json.erb [deleted file]
apps/workbench/app/views/application/_arvados_attr_value.html.erb [deleted file]
apps/workbench/app/views/application/_arvados_object.html.erb [deleted file]
apps/workbench/app/views/application/_arvados_object_attr.html.erb [deleted file]
apps/workbench/app/views/application/_breadcrumb_page_name.html.erb [deleted file]
apps/workbench/app/views/application/_breadcrumbs.html.erb [deleted file]
apps/workbench/app/views/application/_browser_unsupported.html [deleted file]
apps/workbench/app/views/application/_choose.html.erb [deleted file]
apps/workbench/app/views/application/_choose.js.erb [deleted file]
apps/workbench/app/views/application/_choose_rows.html.erb [deleted file]
apps/workbench/app/views/application/_content.html.erb [deleted file]
apps/workbench/app/views/application/_content_layout.html.erb [deleted file]
apps/workbench/app/views/application/_create_new_object_button.html.erb [deleted file]
apps/workbench/app/views/application/_delete_object_button.html.erb [deleted file]
apps/workbench/app/views/application/_extra_tab_line_buttons.html.erb [deleted file]
apps/workbench/app/views/application/_index.html.erb [deleted file]
apps/workbench/app/views/application/_job_progress.html.erb [deleted file]
apps/workbench/app/views/application/_loading.html.erb [deleted file]
apps/workbench/app/views/application/_loading_modal.html.erb [deleted file]
apps/workbench/app/views/application/_name_and_description.html.erb [deleted file]
apps/workbench/app/views/application/_object_description.html.erb [deleted file]
apps/workbench/app/views/application/_object_name.html.erb [deleted file]
apps/workbench/app/views/application/_paging.html.erb [deleted file]
apps/workbench/app/views/application/_pipeline_progress.html.erb [deleted file]
apps/workbench/app/views/application/_pipeline_status_label.html.erb [deleted file]
apps/workbench/app/views/application/_projects_tree_menu.html.erb [deleted file]
apps/workbench/app/views/application/_report_error.html.erb [deleted file]
apps/workbench/app/views/application/_report_issue_popup.html.erb [deleted file]
apps/workbench/app/views/application/_selection_checkbox.html.erb [deleted file]
apps/workbench/app/views/application/_show_advanced.html.erb [deleted file]
apps/workbench/app/views/application/_show_advanced_api_response.html.erb [deleted file]
apps/workbench/app/views/application/_show_advanced_cli_example.html.erb [deleted file]
apps/workbench/app/views/application/_show_advanced_curl_example.html.erb [deleted file]
apps/workbench/app/views/application/_show_advanced_metadata.html.erb [deleted file]
apps/workbench/app/views/application/_show_advanced_python_example.html.erb [deleted file]
apps/workbench/app/views/application/_show_api.html.erb [deleted file]
apps/workbench/app/views/application/_show_attributes.html.erb [deleted file]
apps/workbench/app/views/application/_show_autoselect_text.html.erb [deleted file]
apps/workbench/app/views/application/_show_home_button.html.erb [deleted file]
apps/workbench/app/views/application/_show_object_button.html.erb [deleted file]
apps/workbench/app/views/application/_show_object_description_cell.html.erb [deleted file]
apps/workbench/app/views/application/_show_recent.html.erb [deleted file]
apps/workbench/app/views/application/_show_sharing.html.erb [deleted file]
apps/workbench/app/views/application/_show_star.html.erb [deleted file]
apps/workbench/app/views/application/_show_text_with_locators.html.erb [deleted file]
apps/workbench/app/views/application/_svg_div.html.erb [deleted file]
apps/workbench/app/views/application/_tab_line_buttons.html.erb [deleted file]
apps/workbench/app/views/application/_title_and_buttons.html.erb [deleted file]
apps/workbench/app/views/application/api_error.html.erb [deleted file]
apps/workbench/app/views/application/api_error.json.erb [deleted file]
apps/workbench/app/views/application/destroy.js.erb [deleted file]
apps/workbench/app/views/application/error.html.erb [deleted file]
apps/workbench/app/views/application/error.json.erb [deleted file]
apps/workbench/app/views/application/error.text.erb [deleted file]
apps/workbench/app/views/application/index.html.erb [deleted file]
apps/workbench/app/views/application/report_issue_popup.js.erb [deleted file]
apps/workbench/app/views/application/show.html.erb [deleted file]
apps/workbench/app/views/application/star.js.erb [deleted file]
apps/workbench/app/views/authorized_keys/create.js.erb [deleted file]
apps/workbench/app/views/authorized_keys/edit.html.erb [deleted file]
apps/workbench/app/views/collections/_choose.js.erb [deleted symlink]
apps/workbench/app/views/collections/_choose_rows.html.erb [deleted file]
apps/workbench/app/views/collections/_create_new_object_button.html.erb [deleted file]
apps/workbench/app/views/collections/_extra_tab_line_buttons.html.erb [deleted file]
apps/workbench/app/views/collections/_index_tbody.html.erb [deleted file]
apps/workbench/app/views/collections/_sharing_button.html.erb [deleted file]
apps/workbench/app/views/collections/_show_chooser_preview.html.erb [deleted file]
apps/workbench/app/views/collections/_show_files.html.erb [deleted file]
apps/workbench/app/views/collections/_show_provenance_graph.html.erb [deleted file]
apps/workbench/app/views/collections/_show_recent.html.erb [deleted file]
apps/workbench/app/views/collections/_show_source_summary.html.erb [deleted file]
apps/workbench/app/views/collections/_show_tags.html.erb [deleted file]
apps/workbench/app/views/collections/_show_upload.html.erb [deleted file]
apps/workbench/app/views/collections/_show_used_by.html.erb [deleted file]
apps/workbench/app/views/collections/graph.html.erb [deleted file]
apps/workbench/app/views/collections/hash_matches.html.erb [deleted file]
apps/workbench/app/views/collections/index.html.erb [deleted file]
apps/workbench/app/views/collections/index.js.erb [deleted file]
apps/workbench/app/views/collections/sharing_popup.js.erb [deleted file]
apps/workbench/app/views/collections/show.html.erb [deleted file]
apps/workbench/app/views/collections/show_file_links.html.erb [deleted file]
apps/workbench/app/views/container_requests/_extra_tab_line_buttons.html.erb [deleted file]
apps/workbench/app/views/container_requests/_name_and_description.html.erb [deleted file]
apps/workbench/app/views/container_requests/_show_inputs.html.erb [deleted file]
apps/workbench/app/views/container_requests/_show_log.html.erb [deleted file]
apps/workbench/app/views/container_requests/_show_object_description_cell.html.erb [deleted file]
apps/workbench/app/views/container_requests/_show_provenance.html.erb [deleted file]
apps/workbench/app/views/container_requests/_show_recent.html.erb [deleted file]
apps/workbench/app/views/container_requests/_show_recent_rows.html.erb [deleted file]
apps/workbench/app/views/container_requests/_show_status.html.erb [deleted file]
apps/workbench/app/views/container_requests/_state_label.html.erb [deleted file]
apps/workbench/app/views/container_requests/index.html.erb [deleted file]
apps/workbench/app/views/containers/_show_log.html.erb [deleted file]
apps/workbench/app/views/containers/_show_status.html.erb [deleted file]
apps/workbench/app/views/getting_started/_getting_started_popup.html.erb [deleted file]
apps/workbench/app/views/groups/_choose_rows.html.erb [deleted file]
apps/workbench/app/views/groups/_show_recent.html.erb [deleted file]
apps/workbench/app/views/issue_reporter/send_report.text.erb [deleted file]
apps/workbench/app/views/jobs/_create_new_object_button.html.erb [deleted file]
apps/workbench/app/views/jobs/_rerun_job_with_options_popup.html.erb [deleted file]
apps/workbench/app/views/jobs/_show_details.html.erb [deleted file]
apps/workbench/app/views/jobs/_show_job_buttons.html.erb [deleted file]
apps/workbench/app/views/jobs/_show_log.html.erb [deleted file]
apps/workbench/app/views/jobs/_show_object_description_cell.html.erb [deleted file]
apps/workbench/app/views/jobs/_show_provenance.html.erb [deleted file]
apps/workbench/app/views/jobs/_show_recent.html.erb [deleted file]
apps/workbench/app/views/jobs/_show_status.html.erb [deleted file]
apps/workbench/app/views/jobs/show.html.erb [deleted file]
apps/workbench/app/views/keep_disks/_content_layout.html.erb [deleted file]
apps/workbench/app/views/layouts/application.html.erb [deleted file]
apps/workbench/app/views/layouts/body.html.erb [deleted file]
apps/workbench/app/views/links/_breadcrumb_page_name.html.erb [deleted file]
apps/workbench/app/views/notifications/_collections_notification.html.erb [deleted file]
apps/workbench/app/views/notifications/_jobs_notification.html.erb [deleted file]
apps/workbench/app/views/notifications/_pipelines_notification.html.erb [deleted file]
apps/workbench/app/views/notifications/_ssh_key_notification.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_component_labels.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_running_component.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_compare.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_components.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_components_editable.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_components_json.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_components_running.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_graph.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_inputs.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_log.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_object_description_cell.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_recent.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_recent_rows.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/_show_tab_buttons.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/compare.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/index.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/show.html.erb [deleted file]
apps/workbench/app/views/pipeline_instances/show.js.erb [deleted file]
apps/workbench/app/views/pipeline_templates/_choose.js.erb [deleted symlink]
apps/workbench/app/views/pipeline_templates/_choose_rows.html.erb [deleted file]
apps/workbench/app/views/pipeline_templates/_show_attributes.html.erb [deleted file]
apps/workbench/app/views/pipeline_templates/_show_chooser_preview.html.erb [deleted file]
apps/workbench/app/views/pipeline_templates/_show_components.html.erb [deleted file]
apps/workbench/app/views/pipeline_templates/_show_pipelines.html.erb [deleted file]
apps/workbench/app/views/pipeline_templates/_show_recent.html.erb [deleted file]
apps/workbench/app/views/pipeline_templates/show.html.erb [deleted file]
apps/workbench/app/views/projects/_choose.html.erb [deleted file]
apps/workbench/app/views/projects/_choose.js.erb [deleted symlink]
apps/workbench/app/views/projects/_compute_node_status.html.erb [deleted file]
apps/workbench/app/views/projects/_compute_node_summary.html.erb [deleted file]
apps/workbench/app/views/projects/_container_summary.html.erb [deleted file]
apps/workbench/app/views/projects/_index_jobs_and_pipelines.html.erb [deleted file]
apps/workbench/app/views/projects/_index_projects.html.erb [deleted file]
apps/workbench/app/views/projects/_show_contents_rows.html.erb [deleted file]
apps/workbench/app/views/projects/_show_dashboard.html.erb [deleted file]
apps/workbench/app/views/projects/_show_data_collections.html.erb [deleted file]
apps/workbench/app/views/projects/_show_description.html.erb [deleted file]
apps/workbench/app/views/projects/_show_featured.html.erb [deleted file]
apps/workbench/app/views/projects/_show_other_objects.html.erb [deleted file]
apps/workbench/app/views/projects/_show_pipeline_templates.html.erb [deleted file]
apps/workbench/app/views/projects/_show_pipelines_and_processes.html.erb [deleted file]
apps/workbench/app/views/projects/_show_processes.html.erb [deleted file]
apps/workbench/app/views/projects/_show_subprojects.html.erb [deleted file]
apps/workbench/app/views/projects/_show_tab_contents.html.erb [deleted file]
apps/workbench/app/views/projects/_show_workflows.html.erb [deleted file]
apps/workbench/app/views/projects/index.html.erb [deleted file]
apps/workbench/app/views/projects/public.html.erb [deleted file]
apps/workbench/app/views/projects/remove_items.js.erb [deleted file]
apps/workbench/app/views/projects/show.html.erb [deleted file]
apps/workbench/app/views/projects/tab_counts.js.erb [deleted file]
apps/workbench/app/views/repositories/_add_repository_modal.html.erb [deleted file]
apps/workbench/app/views/repositories/_repository_breadcrumbs.html.erb [deleted file]
apps/workbench/app/views/repositories/_show_help.html.erb [deleted file]
apps/workbench/app/views/repositories/_show_repositories.html.erb [deleted file]
apps/workbench/app/views/repositories/_show_repositories_rows.html.erb [deleted file]
apps/workbench/app/views/repositories/show_blob.html.erb [deleted file]
apps/workbench/app/views/repositories/show_commit.html.erb [deleted file]
apps/workbench/app/views/repositories/show_tree.html.erb [deleted file]
apps/workbench/app/views/request_shell_access_reporter/send_request.text.erb [deleted file]
apps/workbench/app/views/search/_choose_rows.html.erb [deleted file]
apps/workbench/app/views/search/index.html [deleted file]
apps/workbench/app/views/sessions/index.html [deleted file]
apps/workbench/app/views/sessions/logged_out.html.erb [deleted file]
apps/workbench/app/views/tests/mithril.html [deleted file]
apps/workbench/app/views/trash_items/_create_new_object_button.html.erb [deleted file]
apps/workbench/app/views/trash_items/_show_trash_rows.html.erb [deleted file]
apps/workbench/app/views/trash_items/_show_trashed_collection_rows.html.erb [deleted symlink]
apps/workbench/app/views/trash_items/_show_trashed_collections.html.erb [deleted file]
apps/workbench/app/views/trash_items/_show_trashed_project_rows.html.erb [deleted symlink]
apps/workbench/app/views/trash_items/_show_trashed_projects.html.erb [deleted file]
apps/workbench/app/views/trash_items/_untrash_item.html.erb [deleted file]
apps/workbench/app/views/trash_items/index.html.erb [deleted file]
apps/workbench/app/views/trash_items/untrash_items.js.erb [deleted file]
apps/workbench/app/views/user_agreements/index.html.erb [deleted file]
apps/workbench/app/views/users/_add_group_modal.html.erb [deleted file]
apps/workbench/app/views/users/_add_ssh_key_popup.html.erb [deleted file]
apps/workbench/app/views/users/_choose_rows.html.erb [deleted file]
apps/workbench/app/views/users/_create_new_object_button.html.erb [deleted file]
apps/workbench/app/views/users/_current_token.html.erb [deleted file]
apps/workbench/app/views/users/_home.html.erb [deleted file]
apps/workbench/app/views/users/_setup_popup.html.erb [deleted file]
apps/workbench/app/views/users/_show_activity.html.erb [deleted file]
apps/workbench/app/views/users/_show_admin.html.erb [deleted file]
apps/workbench/app/views/users/_ssh_keys.html.erb [deleted file]
apps/workbench/app/views/users/_tables.html.erb [deleted file]
apps/workbench/app/views/users/_virtual_machines.html.erb [deleted file]
apps/workbench/app/views/users/activity.html.erb [deleted file]
apps/workbench/app/views/users/add_ssh_key.js.erb [deleted file]
apps/workbench/app/views/users/add_ssh_key_popup.js.erb [deleted file]
apps/workbench/app/views/users/current_token.html.erb [deleted file]
apps/workbench/app/views/users/home.html.erb [deleted file]
apps/workbench/app/views/users/home.js.erb [deleted file]
apps/workbench/app/views/users/inactive.html.erb [deleted file]
apps/workbench/app/views/users/link_account.html.erb [deleted file]
apps/workbench/app/views/users/profile.html.erb [deleted file]
apps/workbench/app/views/users/request_shell_access.js [deleted file]
apps/workbench/app/views/users/setup.js.erb [deleted file]
apps/workbench/app/views/users/setup_popup.js.erb [deleted file]
apps/workbench/app/views/users/ssh_keys.html.erb [deleted file]
apps/workbench/app/views/users/storage.html.erb [deleted file]
apps/workbench/app/views/users/virtual_machines.html.erb [deleted file]
apps/workbench/app/views/users/welcome.html.erb [deleted file]
apps/workbench/app/views/virtual_machines/_show_help.html.erb [deleted file]
apps/workbench/app/views/virtual_machines/webshell.html.erb [deleted file]
apps/workbench/app/views/websocket/index.html.erb [deleted file]
apps/workbench/app/views/work_units/_component_detail.html.erb [deleted file]
apps/workbench/app/views/work_units/_progress.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_all_processes.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_all_processes_rows.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_child.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_component.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_log.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_log_link.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_output.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_outputs.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_status.html.erb [deleted file]
apps/workbench/app/views/work_units/_show_table_data.html.erb [deleted file]
apps/workbench/app/views/work_units/index.html.erb [deleted file]
apps/workbench/app/views/workflows/_show_chooser_preview.html.erb [deleted file]
apps/workbench/app/views/workflows/_show_definition.html.erb [deleted file]
apps/workbench/app/views/workflows/_show_recent.html.erb [deleted file]
apps/workbench/app/views/workflows/show.html.erb [deleted file]
apps/workbench/bin/bundle [deleted file]
apps/workbench/bin/rails [deleted file]
apps/workbench/bin/rake [deleted file]
apps/workbench/bin/setup [deleted file]
apps/workbench/bin/update [deleted file]
apps/workbench/config.ru [deleted file]
apps/workbench/config/application.default.yml [deleted file]
apps/workbench/config/application.rb [deleted file]
apps/workbench/config/application.yml.example [deleted file]
apps/workbench/config/arvados_config.rb [deleted file]
apps/workbench/config/boot.rb [deleted file]
apps/workbench/config/cable.yml [deleted file]
apps/workbench/config/database.yml [deleted file]
apps/workbench/config/environment.rb [deleted file]
apps/workbench/config/environments/development.rb.example [deleted file]
apps/workbench/config/environments/production.rb.example [deleted file]
apps/workbench/config/environments/test.rb [deleted symlink]
apps/workbench/config/environments/test.rb.example [deleted file]
apps/workbench/config/initializers/actionview_xss_fix.rb [deleted file]
apps/workbench/config/initializers/application_controller_renderer.rb [deleted file]
apps/workbench/config/initializers/assets.rb [deleted file]
apps/workbench/config/initializers/backtrace_silencers.rb [deleted file]
apps/workbench/config/initializers/content_security_policy.rb [deleted file]
apps/workbench/config/initializers/cookies_serializer.rb [deleted file]
apps/workbench/config/initializers/filter_parameter_logging.rb [deleted file]
apps/workbench/config/initializers/inflections.rb [deleted file]
apps/workbench/config/initializers/lograge.rb [deleted file]
apps/workbench/config/initializers/mime_types.rb [deleted file]
apps/workbench/config/initializers/new_framework_defaults.rb [deleted file]
apps/workbench/config/initializers/new_framework_defaults_5_1.rb [deleted file]
apps/workbench/config/initializers/new_framework_defaults_5_2.rb [deleted file]
apps/workbench/config/initializers/rack_mini_profile.rb [deleted file]
apps/workbench/config/initializers/redcloth.rb [deleted file]
apps/workbench/config/initializers/reload_config.rb [deleted file]
apps/workbench/config/initializers/secret_token.rb.example [deleted file]
apps/workbench/config/initializers/session_store.rb [deleted file]
apps/workbench/config/initializers/time_format.rb [deleted file]
apps/workbench/config/initializers/validate_wb2_url_config.rb [deleted file]
apps/workbench/config/initializers/wrap_parameters.rb [deleted file]
apps/workbench/config/locales/en.bootstrap.yml [deleted file]
apps/workbench/config/locales/en.yml [deleted file]
apps/workbench/config/piwik.yml.example [deleted file]
apps/workbench/config/puma.rb [deleted file]
apps/workbench/config/routes.rb [deleted file]
apps/workbench/config/secrets.yml [deleted file]
apps/workbench/config/spring.rb [deleted file]
apps/workbench/db/schema.rb [deleted file]
apps/workbench/db/seeds.rb [deleted file]
apps/workbench/fpm-info.sh [deleted file]
apps/workbench/lib/app_version.rb [deleted file]
apps/workbench/lib/assets/.gitkeep [deleted file]
apps/workbench/lib/assets/javascripts/webshell/shell_in_a_box.js [deleted file]
apps/workbench/lib/config_loader.rb [deleted file]
apps/workbench/lib/config_validators.rb [deleted file]
apps/workbench/lib/tasks/.gitkeep [deleted file]
apps/workbench/lib/tasks/config.rake [deleted file]
apps/workbench/log/.gitkeep [deleted file]
apps/workbench/npm_packages [deleted file]
apps/workbench/public/404.html [deleted file]
apps/workbench/public/422.html [deleted file]
apps/workbench/public/500.html [deleted file]
apps/workbench/public/browser_unsupported.js [deleted file]
apps/workbench/public/d3.v3.min.js [deleted file]
apps/workbench/public/graph-example.html [deleted file]
apps/workbench/public/robots.txt [deleted file]
apps/workbench/public/vocabulary-example.json [deleted file]
apps/workbench/public/webshell/keyboard.html [deleted file]
apps/workbench/script/rails [deleted file]
apps/workbench/test/controllers/actions_controller_test.rb [deleted file]
apps/workbench/test/controllers/api_client_authorizations_controller_test.rb [deleted file]
apps/workbench/test/controllers/application_controller_test.rb [deleted file]
apps/workbench/test/controllers/authorized_keys_controller_test.rb [deleted file]
apps/workbench/test/controllers/collections_controller_test.rb [deleted file]
apps/workbench/test/controllers/container_requests_controller_test.rb [deleted file]
apps/workbench/test/controllers/containers_controller_test.rb [deleted file]
apps/workbench/test/controllers/disabled_api_test.rb [deleted file]
apps/workbench/test/controllers/groups_controller_test.rb [deleted file]
apps/workbench/test/controllers/humans_controller_test.rb [deleted file]
apps/workbench/test/controllers/job_tasks_controller_test.rb [deleted file]
apps/workbench/test/controllers/jobs_controller_test.rb [deleted file]
apps/workbench/test/controllers/keep_disks_controller_test.rb [deleted file]
apps/workbench/test/controllers/links_controller_test.rb [deleted file]
apps/workbench/test/controllers/logs_controller_test.rb [deleted file]
apps/workbench/test/controllers/management_controller_test.rb [deleted file]
apps/workbench/test/controllers/nodes_controller_test.rb [deleted file]
apps/workbench/test/controllers/pipeline_instances_controller_test.rb [deleted file]
apps/workbench/test/controllers/pipeline_templates_controller_test.rb [deleted file]
apps/workbench/test/controllers/projects_controller_test.rb [deleted file]
apps/workbench/test/controllers/repositories_controller_test.rb [deleted file]
apps/workbench/test/controllers/search_controller_test.rb [deleted file]
apps/workbench/test/controllers/sessions_controller_test.rb [deleted file]
apps/workbench/test/controllers/specimens_controller_test.rb [deleted file]
apps/workbench/test/controllers/traits_controller_test.rb [deleted file]
apps/workbench/test/controllers/trash_items_controller_test.rb [deleted file]
apps/workbench/test/controllers/user_agreements_controller_test.rb [deleted file]
apps/workbench/test/controllers/users_controller_test.rb [deleted file]
apps/workbench/test/controllers/virtual_machines_controller_test.rb [deleted file]
apps/workbench/test/controllers/work_units_controller_test.rb [deleted file]
apps/workbench/test/controllers/workflows_controller_test.rb [deleted file]
apps/workbench/test/diagnostics/container_request_test.rb [deleted file]
apps/workbench/test/diagnostics/pipeline_test.rb [deleted file]
apps/workbench/test/diagnostics_test_helper.rb [deleted file]
apps/workbench/test/fixtures/.gitkeep [deleted file]
apps/workbench/test/functional/.gitkeep [deleted file]
apps/workbench/test/helpers/collections_helper_test.rb [deleted file]
apps/workbench/test/helpers/download_helper.rb [deleted file]
apps/workbench/test/helpers/fake_websocket_helper.rb [deleted file]
apps/workbench/test/helpers/manifest_examples.rb [deleted symlink]
apps/workbench/test/helpers/pipeline_instances_helper_test.rb [deleted file]
apps/workbench/test/helpers/repository_stub_helper.rb [deleted file]
apps/workbench/test/helpers/search_helper_test.rb [deleted file]
apps/workbench/test/helpers/share_object_helper.rb [deleted file]
apps/workbench/test/helpers/time_block.rb [deleted symlink]
apps/workbench/test/integration/.gitkeep [deleted file]
apps/workbench/test/integration/ajax_errors_test.rb [deleted file]
apps/workbench/test/integration/anonymous_access_test.rb [deleted file]
apps/workbench/test/integration/application_layout_test.rb [deleted file]
apps/workbench/test/integration/browser_unsupported_test.rb [deleted file]
apps/workbench/test/integration/collection_upload_test.rb [deleted file]
apps/workbench/test/integration/collections_test.rb [deleted file]
apps/workbench/test/integration/container_requests_test.rb [deleted file]
apps/workbench/test/integration/download_test.rb [deleted file]
apps/workbench/test/integration/errors_test.rb [deleted file]
apps/workbench/test/integration/filterable_infinite_scroll_test.rb [deleted file]
apps/workbench/test/integration/integration_test_utils.rb [deleted file]
apps/workbench/test/integration/jobs_test.rb [deleted file]
apps/workbench/test/integration/link_account_test.rb [deleted file]
apps/workbench/test/integration/logins_test.rb [deleted file]
apps/workbench/test/integration/pipeline_instances_test.rb [deleted file]
apps/workbench/test/integration/pipeline_templates_test.rb [deleted file]
apps/workbench/test/integration/projects_test.rb [deleted file]
apps/workbench/test/integration/report_issue_test.rb [deleted file]
apps/workbench/test/integration/repositories_browse_test.rb [deleted file]
apps/workbench/test/integration/repositories_test.rb [deleted file]
apps/workbench/test/integration/search_box_test.rb [deleted file]
apps/workbench/test/integration/smoke_test.rb [deleted file]
apps/workbench/test/integration/trash_test.rb [deleted file]
apps/workbench/test/integration/user_agreements_test.rb [deleted file]
apps/workbench/test/integration/user_profile_test.rb [deleted file]
apps/workbench/test/integration/user_settings_menu_test.rb [deleted file]
apps/workbench/test/integration/users_test.rb [deleted file]
apps/workbench/test/integration/virtual_machines_test.rb [deleted file]
apps/workbench/test/integration/websockets_test.rb [deleted file]
apps/workbench/test/integration/work_units_test.rb [deleted file]
apps/workbench/test/integration_helper.rb [deleted file]
apps/workbench/test/integration_performance/collection_unit_test.rb [deleted file]
apps/workbench/test/integration_performance/collections_controller_test.rb [deleted file]
apps/workbench/test/integration_performance/collections_perf_test.rb [deleted file]
apps/workbench/test/mailers/.gitkeep [deleted file]
apps/workbench/test/models/.gitkeep [deleted file]
apps/workbench/test/performance/browsing_test.rb [deleted file]
apps/workbench/test/performance_test_helper.rb [deleted file]
apps/workbench/test/support/fake_websocket.js [deleted file]
apps/workbench/test/support/remove_file_api.js [deleted file]
apps/workbench/test/test_helper.rb [deleted file]
apps/workbench/test/unit/.gitkeep [deleted file]
apps/workbench/test/unit/arvados_api_client_test.rb [deleted file]
apps/workbench/test/unit/arvados_base_test.rb [deleted file]
apps/workbench/test/unit/arvados_resource_list_test.rb [deleted file]
apps/workbench/test/unit/collection_test.rb [deleted file]
apps/workbench/test/unit/disabled_api_test.rb [deleted file]
apps/workbench/test/unit/group_test.rb [deleted file]
apps/workbench/test/unit/helpers/api_client_authorizations_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/authorized_keys_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/collections_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/groups_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/humans_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/javascript_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/job_tasks_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/jobs_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/keep_disks_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/links_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/logs_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/nodes_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/pipeline_instances_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/pipeline_templates_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/projects_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/repositories_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/sessions_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/specimens_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/traits_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/user_agreements_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/users_helper_test.rb [deleted file]
apps/workbench/test/unit/helpers/virtual_machines_helper_test.rb [deleted file]
apps/workbench/test/unit/job_test.rb [deleted file]
apps/workbench/test/unit/link_test.rb [deleted file]
apps/workbench/test/unit/pipeline_instance_test.rb [deleted file]
apps/workbench/test/unit/repository_test.rb [deleted file]
apps/workbench/test/unit/user_test.rb [deleted file]
apps/workbench/test/unit/work_unit_test.rb [deleted file]
apps/workbench/vendor/assets/javascripts/.gitkeep [deleted file]
apps/workbench/vendor/assets/stylesheets/.gitkeep [deleted file]
apps/workbench/vendor/plugins/.gitkeep [deleted file]
build/README
build/build-dev-docker-jobs-image.sh
build/get-package-version.sh
build/package-build-dockerfiles/Makefile
build/package-build-dockerfiles/centos7/Dockerfile [deleted file]
build/package-build-dockerfiles/debian10/Dockerfile [deleted file]
build/package-build-dockerfiles/debian11/Dockerfile
build/package-build-dockerfiles/debian12/Dockerfile [new file with mode: 0644]
build/package-build-dockerfiles/rocky8/Dockerfile [new file with mode: 0644]
build/package-build-dockerfiles/ubuntu1804/Dockerfile [deleted file]
build/package-build-dockerfiles/ubuntu2004/Dockerfile
build/package-build-dockerfiles/ubuntu2204/Dockerfile [new file with mode: 0644]
build/package-build-dockerfiles/ubuntu2204/ports.list [new file with mode: 0644]
build/package-test-dockerfiles/Makefile
build/package-test-dockerfiles/centos7/Dockerfile [deleted file]
build/package-test-dockerfiles/centos7/localrepo.repo [deleted file]
build/package-test-dockerfiles/debian10/Dockerfile [deleted file]
build/package-test-dockerfiles/debian12/Dockerfile [new file with mode: 0644]
build/package-test-dockerfiles/rocky8/Dockerfile [new file with mode: 0644]
build/package-test-dockerfiles/rocky8/localrepo.repo [new file with mode: 0644]
build/package-test-dockerfiles/ubuntu1804/Dockerfile [deleted file]
build/package-test-dockerfiles/ubuntu1804/etc-apt-preferences.d-arvados [deleted file]
build/package-test-dockerfiles/ubuntu2204/Dockerfile [new file with mode: 0644]
build/package-testing/common-test-rails-server-package.sh
build/package-testing/deb-common-test-packages.sh
build/package-testing/rpm-common-test-packages.sh
build/package-testing/test-package-arvados-client.sh [new file with mode: 0755]
build/package-testing/test-package-python3-arvados-python-client.sh
build/package-testing/test-packages-debian12.sh [new symlink]
build/package-testing/test-packages-rocky8.sh [new symlink]
build/package-testing/test-packages-ubuntu2204.sh [new symlink]
build/pypkg_info.py [new file with mode: 0644]
build/rails-package-scripts/arvados-api-server.sh
build/rails-package-scripts/arvados-workbench.sh [deleted file]
build/rails-package-scripts/postinst.sh
build/run-build-docker-images.sh
build/run-build-packages-one-target.sh
build/run-build-packages.sh
build/run-build-test-packages-one-target.sh
build/run-library.sh
build/run-tests.sh
build/version-at-commit.sh
cmd/arvados-client/cmd.go
cmd/arvados-client/cmd_test.go
cmd/arvados-client/container_gateway.go
cmd/arvados-client/container_gateway_test.go
cmd/arvados-client/fpm-info.sh [new file with mode: 0644]
cmd/arvados-package/cmd.go
cmd/arvados-server/arvados-controller.service
cmd/arvados-server/arvados-dispatch-cloud.service
cmd/arvados-server/arvados-dispatch-lsf.service
cmd/arvados-server/arvados-git-httpd.service
cmd/arvados-server/arvados-health.service
cmd/arvados-server/arvados-ws.service
cmd/arvados-server/cmd.go
cmd/arvados-server/crunch-dispatch-slurm.service
cmd/arvados-server/keep-balance.service
cmd/arvados-server/keep-web.service
cmd/arvados-server/keepproxy.service
cmd/arvados-server/keepstore.service
doc/Gemfile.lock
doc/README.textile
doc/Rakefile
doc/_config.yml
doc/_includes/_container_runtime_constraints.liquid
doc/_includes/_download_installer.liquid
doc/_includes/_google_analytics.liquid [new file with mode: 0644]
doc/_includes/_hpc_max_gateway_tunnels.liquid [new file with mode: 0644]
doc/_includes/_install_ca_cert.liquid
doc/_includes/_install_compute_docker.liquid
doc/_includes/_install_packages.liquid
doc/_includes/_install_ruby_and_bundler.liquid
doc/_includes/_matomo_analytics.liquid [new file with mode: 0644]
doc/_includes/_multi_host_install_custom_certificates.liquid
doc/_includes/_ssh_addkey.liquid
doc/_includes/_ssl_config_multi.liquid
doc/_includes/_supportedlinux.liquid
doc/_includes/_terraform_datastorage_tfvars.liquid [new symlink]
doc/_includes/_terraform_services_tfvars.liquid [new symlink]
doc/_includes/_terraform_vpc_tfvars.liquid [new symlink]
doc/_includes/_tutorial_expectations.liquid
doc/_layouts/default.html.liquid
doc/admin/config-urls.html.textile.liquid
doc/admin/diagnostics.html.textile.liquid
doc/admin/inspect.html.textile.liquid [new file with mode: 0644]
doc/admin/keep-balance.html.textile.liquid
doc/admin/keep-faster-gc-s3.html.textile.liquid [new file with mode: 0644]
doc/admin/logs-table-management.html.textile.liquid
doc/admin/metrics.html.textile.liquid
doc/admin/restricting-upload-download.html.textile.liquid
doc/admin/scoped-tokens.html.textile.liquid
doc/admin/upgrading.html.textile.liquid
doc/admin/user-management-cli.html.textile.liquid
doc/admin/user-management.html.textile.liquid
doc/api/crunch-scripts.html.textile.liquid
doc/api/dispatch.html.textile.liquid
doc/api/index.html.textile.liquid
doc/api/keep-webdav.html.textile.liquid
doc/api/methods.html.textile.liquid
doc/api/methods/api_client_authorizations.html.textile.liquid
doc/api/methods/collections.html.textile.liquid
doc/api/methods/container_requests.html.textile.liquid
doc/api/methods/containers.html.textile.liquid
doc/api/methods/groups.html.textile.liquid
doc/api/methods/humans.html.textile.liquid
doc/api/methods/job_tasks.html.textile.liquid
doc/api/methods/jobs.html.textile.liquid
doc/api/methods/keep_disks.html.textile.liquid
doc/api/methods/nodes.html.textile.liquid
doc/api/methods/pipeline_instances.html.textile.liquid
doc/api/methods/pipeline_templates.html.textile.liquid
doc/api/methods/repositories.html.textile.liquid
doc/api/methods/specimens.html.textile.liquid
doc/api/methods/traits.html.textile.liquid
doc/api/properties.html.textile.liquid
doc/api/tokens.html.textile.liquid
doc/architecture/index.html.textile.liquid
doc/architecture/singularity.html.textile.liquid
doc/gen_api_method_docs.py [deleted file]
doc/gen_api_schema_docs.py [deleted file]
doc/images/add-new-collection-wb2.png [new file with mode: 0644]
doc/images/add-new-repository.png
doc/images/files-uploaded.png [deleted file]
doc/images/new-collection-modal-wb2.png [new file with mode: 0644]
doc/images/newly-created-collection-empty-wb2.png [new file with mode: 0644]
doc/images/repositories-panel.png
doc/images/shared-collection.png [deleted file]
doc/images/sharing-collection-url.png [new file with mode: 0644]
doc/images/ssh-adding-public-key.png
doc/images/switch-to-wb1.png [deleted file]
doc/images/switch-to-wb2.png [deleted file]
doc/images/trash-button-topnav.png [deleted file]
doc/images/trash-buttons.png [new file with mode: 0644]
doc/images/upload-data-progress-wb2.png [new file with mode: 0644]
doc/images/upload-data-prompt-with-files-wb2.png [new file with mode: 0644]
doc/images/upload-tab-in-new-collection.png [deleted file]
doc/images/upload-using-workbench.png [deleted file]
doc/images/vm-access-with-webshell.png
doc/images/wgs-tutorial/image1.png
doc/images/wgs-tutorial/image4.png
doc/images/wgs-tutorial/image5.png
doc/images/wgs-tutorial/image6.png
doc/images/wgs-tutorial/image7.png [new file with mode: 0644]
doc/images/wgs-tutorial/image8.png [new file with mode: 0644]
doc/images/workbench-dashboard.png [deleted file]
doc/images/workbench-first-page.png [new file with mode: 0644]
doc/images/workbench-move-selected.png [deleted file]
doc/images/workbench-move-wb2.png [new file with mode: 0644]
doc/install/configure-s3-object-storage.html.textile.liquid
doc/install/crunch2-cloud/install-compute-node.html.textile.liquid
doc/install/crunch2-cloud/install-dispatch-cloud.html.textile.liquid
doc/install/crunch2-lsf/install-dispatch.html.textile.liquid
doc/install/crunch2-slurm/install-dispatch.html.textile.liquid
doc/install/crunch2/install-compute-node-singularity.html.textile.liquid
doc/install/install-arv-git-httpd.html.textile.liquid
doc/install/install-keep-balance.html.textile.liquid
doc/install/install-keep-web.html.textile.liquid
doc/install/install-keepproxy.html.textile.liquid
doc/install/install-manual-prerequisites.html.textile.liquid
doc/install/install-postgresql.html.textile.liquid
doc/install/install-shell-server.html.textile.liquid
doc/install/install-webshell.html.textile.liquid
doc/install/install-workbench-app.html.textile.liquid [deleted file]
doc/install/install-workbench2-app.html.textile.liquid
doc/install/nginx.html.textile.liquid
doc/install/packages.html.textile.liquid
doc/install/salt-multi-host.html.textile.liquid
doc/install/salt-single-host.html.textile.liquid
doc/install/salt-vagrant.html.textile.liquid
doc/install/setup-login.html.textile.liquid
doc/install/workbench.html.textile.liquid
doc/pysdk_pdoc.py [new file with mode: 0755]
doc/sdk/cli/index.html.textile.liquid
doc/sdk/cli/install.html.textile.liquid
doc/sdk/cli/reference.html.textile.liquid
doc/sdk/cli/subcommands.html.textile.liquid
doc/sdk/fuse/install.html.textile.liquid [new file with mode: 0644]
doc/sdk/fuse/options.html.textile.liquid [new file with mode: 0644]
doc/sdk/index.html.textile.liquid
doc/sdk/java-v2/example.html.textile.liquid
doc/sdk/java-v2/index.html.textile.liquid
doc/sdk/java-v2/javadoc.html.textile.liquid
doc/sdk/python/api-client.html.textile.liquid
doc/sdk/python/arvados-cwl-runner.html.textile.liquid
doc/sdk/python/arvados-fuse.html.textile.liquid [deleted file]
doc/sdk/python/cookbook.html.textile.liquid
doc/sdk/python/python.html.textile.liquid
doc/sdk/python/sdk-python.html.textile.liquid
doc/user/cwl/costanalyzer.html.textile.liquid
doc/user/cwl/crunchstat-summary.html.textile.liquid
doc/user/cwl/cwl-extensions.html.textile.liquid
doc/user/cwl/cwl-run-options.html.textile.liquid
doc/user/cwl/cwl-runner.html.textile.liquid
doc/user/cwl/images/crunchstat-summary-html.png
doc/user/debugging/container-shell-access.html.textile.liquid
doc/user/getting_started/setup-cli.html.textile.liquid
doc/user/getting_started/ssh-access-unix.html.textile.liquid
doc/user/getting_started/vm-login-with-webshell.html.textile.liquid
doc/user/getting_started/workbench.html.textile.liquid
doc/user/reference/api-tokens.html.textile.liquid
doc/user/topics/arv-copy.html.textile.liquid
doc/user/topics/workbench-migration.html.textile.liquid
doc/user/tutorials/add-new-repository.html.textile.liquid
doc/user/tutorials/git-arvados-guide.html.textile.liquid
doc/user/tutorials/tutorial-keep-collection-lifecycle.html.textile.liquid
doc/user/tutorials/tutorial-keep-get.html.textile.liquid
doc/user/tutorials/tutorial-keep.html.textile.liquid
doc/user/tutorials/tutorial-projects.html.textile.liquid [new file with mode: 0644]
doc/user/tutorials/tutorial-workflow-workbench.html.textile.liquid
doc/user/tutorials/wgs-tutorial.html.textile.liquid
docker/jobs/Dockerfile
docker/jobs/apt.arvados.org-dev.list
docker/jobs/apt.arvados.org-stable.list
docker/jobs/apt.arvados.org-testing.list
go.mod
go.sum
lib/boot/cmd.go
lib/boot/helpers.go
lib/boot/rails_db.go
lib/boot/supervisor.go
lib/boot/workbench2.go
lib/cli/get.go
lib/cloud/azure/azure.go
lib/cloud/azure/azure_test.go
lib/cloud/cloudtest/cmd.go
lib/cloud/cloudtest/tester.go
lib/cloud/ec2/ec2.go
lib/cloud/ec2/ec2_test.go
lib/cloud/interfaces.go
lib/cloud/loopback/loopback.go
lib/cloud/loopback/loopback_test.go
lib/cmd/cmd.go
lib/cmd/parseflags.go
lib/config/cmd_test.go
lib/config/config.default.yml
lib/config/deprecated.go
lib/config/deprecated_test.go
lib/config/export.go
lib/config/load.go
lib/config/load_test.go
lib/controller/federation/collection_test.go [new file with mode: 0644]
lib/controller/federation/conn.go
lib/controller/federation/generate.go
lib/controller/federation/generated.go
lib/controller/federation/login_test.go
lib/controller/federation/logout_test.go [new file with mode: 0644]
lib/controller/federation/user_test.go
lib/controller/federation_test.go
lib/controller/handler.go
lib/controller/handler_test.go
lib/controller/integration_test.go
lib/controller/localdb/authorized_key.go [new file with mode: 0644]
lib/controller/localdb/authorized_key_test.go [new file with mode: 0644]
lib/controller/localdb/collection_test.go
lib/controller/localdb/container.go
lib/controller/localdb/container_gateway.go
lib/controller/localdb/container_gateway_test.go
lib/controller/localdb/container_request.go
lib/controller/localdb/container_test.go
lib/controller/localdb/localdb_test.go
lib/controller/localdb/login.go
lib/controller/localdb/login_ldap_docker_test.sh
lib/controller/localdb/login_oidc.go
lib/controller/localdb/login_oidc_test.go
lib/controller/localdb/login_test.go [new file with mode: 0644]
lib/controller/localdb/testdata/dsa.pub [new file with mode: 0644]
lib/controller/localdb/testdata/ecdsa-sk.pub [new file with mode: 0644]
lib/controller/localdb/testdata/ecdsa.pub [new file with mode: 0644]
lib/controller/localdb/testdata/ed25519-sk.pub [new file with mode: 0644]
lib/controller/localdb/testdata/ed25519.pub [new file with mode: 0644]
lib/controller/localdb/testdata/generate [new file with mode: 0755]
lib/controller/localdb/testdata/rsa.pub [new file with mode: 0644]
lib/controller/proxy.go
lib/controller/router/request.go
lib/controller/router/request_test.go
lib/controller/router/router.go
lib/controller/router/router_test.go
lib/controller/rpc/conn.go
lib/controller/rpc/conn_test.go
lib/crunchrun/cgroup.go
lib/crunchrun/cgroup_test.go
lib/crunchrun/container_gateway.go
lib/crunchrun/copier.go
lib/crunchrun/copier_test.go
lib/crunchrun/crunchrun.go
lib/crunchrun/crunchrun_test.go
lib/crunchrun/docker.go
lib/crunchrun/executor.go
lib/crunchrun/executor_test.go
lib/crunchrun/git_mount.go
lib/crunchrun/git_mount_test.go
lib/crunchrun/integration_test.go
lib/crunchrun/logging.go
lib/crunchrun/logging_test.go
lib/crunchrun/singularity.go
lib/crunchstat/command.go [new file with mode: 0644]
lib/crunchstat/crunchstat.go
lib/crunchstat/crunchstat_test.go
lib/crunchstat/testdata/debian10/proc/3288/cgroup [new file with mode: 0755]
lib/crunchstat/testdata/debian10/proc/3288/cpuset [new file with mode: 0755]
lib/crunchstat/testdata/debian10/proc/3288/net/dev [new file with mode: 0755]
lib/crunchstat/testdata/debian10/proc/cpuinfo [new file with mode: 0755]
lib/crunchstat/testdata/debian10/proc/mounts [new file with mode: 0755]
lib/crunchstat/testdata/debian10/proc/self/smaps [new file with mode: 0755]
lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/cpu.max [new file with mode: 0755]
lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/io.stat [new file with mode: 0755]
lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/cpu.stat [new file with mode: 0755]
lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/memory.current [new file with mode: 0755]
lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/memory.stat [new file with mode: 0755]
lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/memory.swap.current [new file with mode: 0755]
lib/crunchstat/testdata/debian11/proc/4153022/cgroup [new file with mode: 0755]
lib/crunchstat/testdata/debian11/proc/4153022/cpuset [new file with mode: 0755]
lib/crunchstat/testdata/debian11/proc/4153022/net/dev [new file with mode: 0755]
lib/crunchstat/testdata/debian11/proc/cpuinfo [new file with mode: 0644]
lib/crunchstat/testdata/debian11/proc/mounts [new file with mode: 0755]
lib/crunchstat/testdata/debian11/proc/self/smaps [new file with mode: 0755]
lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/cpu.max [new file with mode: 0755]
lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/cpuset.cpus.effective [new file with mode: 0755]
lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/io.stat [new file with mode: 0755]
lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/cpu.stat [new file with mode: 0755]
lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/memory.current [new file with mode: 0755]
lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/memory.stat [new file with mode: 0755]
lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/memory.swap.current [new file with mode: 0755]
lib/crunchstat/testdata/debian12/proc/1115883/cgroup [new file with mode: 0755]
lib/crunchstat/testdata/debian12/proc/1115883/cpuset [new file with mode: 0755]
lib/crunchstat/testdata/debian12/proc/1115883/net/dev [new file with mode: 0755]
lib/crunchstat/testdata/debian12/proc/cpuinfo [new file with mode: 0644]
lib/crunchstat/testdata/debian12/proc/mounts [new file with mode: 0755]
lib/crunchstat/testdata/debian12/proc/self/smaps [new file with mode: 0755]
lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/cpuset.cpus.effective [new file with mode: 0755]
lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/io.stat [new file with mode: 0755]
lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/cpu.max [new file with mode: 0755]
lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/cpu.stat [new file with mode: 0755]
lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.current [new file with mode: 0755]
lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.stat [new file with mode: 0755]
lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.swap.current [new file with mode: 0755]
lib/crunchstat/testdata/fakestat/cgroup.procs [deleted file]
lib/crunchstat/testdata/fakestat/memory.stat [deleted file]
lib/crunchstat/testdata/ubuntu1804/proc/2523/cgroup [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/proc/2523/cpuset [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/proc/2523/net/dev [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/proc/cpuinfo [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/proc/mounts [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/proc/self/smaps [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/blkio/user.slice/blkio.throttle.io_service_bytes [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/cpu,cpuacct/user.slice/cpuacct.stat [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/cpuset/cpuset.cpus [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/memory/user.slice/memory.stat [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/unified/user.slice/user-1000.slice/session-1.scope/cpu.stat [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/proc/1360/cgroup [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/proc/1360/cpuset [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/proc/1360/net/dev [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/proc/cpuinfo [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/proc/mounts [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/proc/self/smaps [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/blkio/user.slice/blkio.throttle.io_service_bytes [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/cpu,cpuacct/user.slice/cpuacct.stat [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/cpuset/cpuset.cpus [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/memory/user.slice/user-1000.slice/session-2.scope/memory.stat [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/unified/user.slice/user-1000.slice/session-2.scope/cpu.stat [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/proc/1967/cgroup [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/proc/1967/cpuset [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/proc/1967/net/dev [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/proc/cpuinfo [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/proc/mounts [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/proc/self/smaps [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/cpu.max [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/cpuset.cpus.effective [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/io.stat [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/cpu.stat [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/memory.current [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/memory.stat [new file with mode: 0755]
lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/memory.swap.current [new file with mode: 0755]
lib/diagnostics/cmd.go
lib/diagnostics/docker_image_test.go [new file with mode: 0644]
lib/dispatchcloud/cmd.go
lib/dispatchcloud/container/queue.go
lib/dispatchcloud/container/queue_test.go
lib/dispatchcloud/dispatcher.go
lib/dispatchcloud/dispatcher_test.go
lib/dispatchcloud/driver.go
lib/dispatchcloud/node_size.go
lib/dispatchcloud/node_size_test.go
lib/dispatchcloud/scheduler/interfaces.go
lib/dispatchcloud/scheduler/run_queue.go
lib/dispatchcloud/scheduler/run_queue_test.go
lib/dispatchcloud/scheduler/scheduler.go
lib/dispatchcloud/scheduler/sync_test.go
lib/dispatchcloud/sshexecutor/executor.go
lib/dispatchcloud/sshexecutor/executor_test.go
lib/dispatchcloud/test/queue.go
lib/dispatchcloud/test/stub_driver.go
lib/dispatchcloud/worker/pool.go
lib/dispatchcloud/worker/pool_test.go
lib/dispatchcloud/worker/runner.go
lib/dispatchcloud/worker/worker.go
lib/dispatchcloud/worker/worker_test.go
lib/install/arvados.service
lib/install/arvadostest_docker_build.sh
lib/install/deps.go
lib/install/deps_go_version_test.go
lib/install/deps_test.go
lib/install/example_from_scratch.sh
lib/install/init.go
lib/lsf/dispatch.go
lib/lsf/dispatch_test.go
lib/mount/command.go
lib/mount/fs.go
lib/mount/fs_test.go
lib/pam/docker_test.go
lib/pam/fpm-info.sh
lib/service/cmd.go
lib/service/cmd_test.go
lib/webdavfs/fs.go [new file with mode: 0644]
lib/webdavfs/fs_test.go [new file with mode: 0644]
sdk/R/DESCRIPTION
sdk/R/R/Arvados.R
sdk/R/R/ArvadosFile.R
sdk/R/R/ArvadosR.R
sdk/R/R/Collection.R
sdk/R/R/autoGenAPI.R
sdk/R/README.md
sdk/R/install_deps.R
sdk/R/man/Arvados.Rd
sdk/R/man/ArvadosFile.Rd
sdk/R/man/ArvadosR.Rd
sdk/R/man/Collection.Rd
sdk/R/man/Subcollection.Rd
sdk/R/run_test.R
sdk/R/tests/testthat/fakes/FakeRESTService.R
sdk/R/tests/testthat/test-Collection.R
sdk/cli/Gemfile
sdk/cli/arvados-cli.gemspec
sdk/cwl/arvados_cwl/__init__.py
sdk/cwl/arvados_cwl/arv-cwl-schema-v1.0.yml
sdk/cwl/arvados_cwl/arv-cwl-schema-v1.1.yml
sdk/cwl/arvados_cwl/arv-cwl-schema-v1.2.yml
sdk/cwl/arvados_cwl/arvcontainer.py
sdk/cwl/arvados_cwl/arvtool.py
sdk/cwl/arvados_cwl/arvworkflow.py
sdk/cwl/arvados_cwl/context.py
sdk/cwl/arvados_cwl/done.py
sdk/cwl/arvados_cwl/executor.py
sdk/cwl/arvados_cwl/http.py [deleted file]
sdk/cwl/arvados_cwl/pathmapper.py
sdk/cwl/arvados_cwl/runner.py
sdk/cwl/arvados_cwl/util.py
sdk/cwl/arvados_version.py
sdk/cwl/setup.py
sdk/cwl/test_with_arvbox.sh
sdk/cwl/tests/arvados-tests.sh
sdk/cwl/tests/arvados-tests.yml
sdk/cwl/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-arv-mount.txt [new file with mode: 0644]
sdk/cwl/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-crunchstat.txt [new file with mode: 0644]
sdk/cwl/tests/oom/19975-oom-mispelled.cwl [new file with mode: 0644]
sdk/cwl/tests/oom/19975-oom.cwl
sdk/cwl/tests/oom/19975-oom3.cwl
sdk/cwl/tests/test_container.py
sdk/cwl/tests/test_http.py [deleted file]
sdk/cwl/tests/test_submit.py
sdk/cwl/tests/test_util.py
sdk/cwl/tests/tool/submit_tool_map.cwl [new file with mode: 0644]
sdk/cwl/tests/wf/expect_upload_wrapper_map.cwl [new file with mode: 0644]
sdk/cwl/tests/wf/runseparate-wf.cwl [new file with mode: 0644]
sdk/cwl/tests/wf/submit_wf_map.cwl [new file with mode: 0644]
sdk/dev-jobs.dockerfile
sdk/go/arvados/api.go
sdk/go/arvados/authorized_key.go [new file with mode: 0644]
sdk/go/arvados/byte_size.go
sdk/go/arvados/byte_size_test.go
sdk/go/arvados/client.go
sdk/go/arvados/client_test.go
sdk/go/arvados/config.go
sdk/go/arvados/container.go
sdk/go/arvados/fs_base.go
sdk/go/arvados/fs_collection.go
sdk/go/arvados/fs_lookup.go
sdk/go/arvados/fs_project.go
sdk/go/arvados/fs_project_test.go
sdk/go/arvados/fs_site.go
sdk/go/arvados/fs_site_test.go
sdk/go/arvados/keep_cache.go [new file with mode: 0644]
sdk/go/arvados/keep_cache_test.go [new file with mode: 0644]
sdk/go/arvados/keep_service.go
sdk/go/arvados/limiter.go
sdk/go/arvados/limiter_test.go
sdk/go/arvados/log.go
sdk/go/arvados/tls_certs.go [new file with mode: 0644]
sdk/go/arvados/tls_certs_test.go [new file with mode: 0644]
sdk/go/arvados/tls_certs_test_showenv.go [new file with mode: 0644]
sdk/go/arvadosclient/arvadosclient.go
sdk/go/arvadosclient/arvadosclient_test.go
sdk/go/arvadostest/api.go
sdk/go/arvadostest/fixtures.go
sdk/go/arvadostest/keep_stub.go [new file with mode: 0644]
sdk/go/arvadostest/metrics.go [new file with mode: 0644]
sdk/go/arvadostest/oidc_provider.go
sdk/go/arvadostest/proxy.go
sdk/go/auth/auth.go
sdk/go/auth/handlers_test.go
sdk/go/httpserver/error.go
sdk/go/httpserver/request_limiter.go
sdk/go/httpserver/request_limiter_test.go
sdk/go/keepclient/block_cache.go [deleted file]
sdk/go/keepclient/collectionreader_test.go
sdk/go/keepclient/gateway_shim.go [new file with mode: 0644]
sdk/go/keepclient/hashcheck.go
sdk/go/keepclient/keepclient.go
sdk/go/keepclient/keepclient_test.go
sdk/go/keepclient/support.go
sdk/go/manifest/manifest.go
sdk/java-v2/src/main/java/org/arvados/client/api/client/BaseStandardApiClient.java
sdk/java-v2/src/main/java/org/arvados/client/api/client/CollectionsApiClient.java
sdk/java-v2/src/main/java/org/arvados/client/api/client/CountingFileRequestBody.java
sdk/java-v2/src/main/java/org/arvados/client/api/client/CountingRequestBody.java [new file with mode: 0644]
sdk/java-v2/src/main/java/org/arvados/client/api/client/CountingStreamRequestBody.java [new file with mode: 0644]
sdk/java-v2/src/main/java/org/arvados/client/api/client/KeepServerApiClient.java
sdk/java-v2/src/main/java/org/arvados/client/api/client/KeepWebApiClient.java
sdk/java-v2/src/main/java/org/arvados/client/api/model/CollectionReplaceFiles.java [new file with mode: 0644]
sdk/java-v2/src/main/java/org/arvados/client/api/model/argument/ListArgument.java
sdk/java-v2/src/main/java/org/arvados/client/config/ExternalConfigProvider.java
sdk/java-v2/src/main/java/org/arvados/client/facade/ArvadosFacade.java
sdk/java-v2/src/main/java/org/arvados/client/logic/keep/FileDownloader.java
sdk/java-v2/src/test/java/org/arvados/client/api/client/CollectionsApiClientTest.java
sdk/java-v2/src/test/java/org/arvados/client/api/client/KeepWebApiClientTest.java
sdk/java-v2/src/test/java/org/arvados/client/facade/ArvadosFacadeIntegrationTest.java
sdk/java-v2/src/test/java/org/arvados/client/logic/keep/FileDownloaderTest.java
sdk/python/MANIFEST.in
sdk/python/README.rst
sdk/python/arvados-v1-discovery.json [new file with mode: 0644]
sdk/python/arvados/__init__.py
sdk/python/arvados/_normalize_stream.py
sdk/python/arvados/_pycurlhelper.py [new file with mode: 0644]
sdk/python/arvados/api.py
sdk/python/arvados/arvfile.py
sdk/python/arvados/collection.py
sdk/python/arvados/commands/_util.py
sdk/python/arvados/commands/arv_copy.py
sdk/python/arvados/commands/federation_migrate.py
sdk/python/arvados/commands/get.py
sdk/python/arvados/commands/keepdocker.py
sdk/python/arvados/commands/ls.py
sdk/python/arvados/commands/migrate19.py
sdk/python/arvados/commands/put.py
sdk/python/arvados/commands/ws.py
sdk/python/arvados/config.py
sdk/python/arvados/crunch.py
sdk/python/arvados/diskcache.py
sdk/python/arvados/events.py
sdk/python/arvados/http_to_keep.py [new file with mode: 0644]
sdk/python/arvados/keep.py
sdk/python/arvados/logging.py [new file with mode: 0644]
sdk/python/arvados/retry.py
sdk/python/arvados/safeapi.py
sdk/python/arvados/stream.py
sdk/python/arvados/util.py
sdk/python/arvados_version.py
sdk/python/discovery2pydoc.py [new file with mode: 0755]
sdk/python/setup.py
sdk/python/tests/arvados_testutil.py
sdk/python/tests/data/hello-world-ManifestV2-OCILayout.tar [new file with mode: 0644]
sdk/python/tests/data/hello-world-ManifestV2.tar [new file with mode: 0644]
sdk/python/tests/data/hello-world-README.txt [new file with mode: 0644]
sdk/python/tests/fed-migrate/jenkins.sh
sdk/python/tests/nginx.conf
sdk/python/tests/run_test_server.py
sdk/python/tests/test_api.py
sdk/python/tests/test_arv_get.py
sdk/python/tests/test_arv_keepdocker.py
sdk/python/tests/test_arvfile.py
sdk/python/tests/test_cmd_util.py [new file with mode: 0644]
sdk/python/tests/test_collections.py
sdk/python/tests/test_events.py
sdk/python/tests/test_http.py [new file with mode: 0644]
sdk/python/tests/test_keep_client.py
sdk/python/tests/test_retry.py
sdk/python/tests/test_retry_job_helpers.py
sdk/python/tests/test_storage_classes.py [new file with mode: 0644]
sdk/python/tests/test_stream.py
sdk/python/tests/test_util.py
sdk/ruby-google-api-client/.gitignore [new file with mode: 0644]
sdk/ruby-google-api-client/.rspec [new file with mode: 0644]
sdk/ruby-google-api-client/.travis.yml [new file with mode: 0644]
sdk/ruby-google-api-client/.yardopts [new file with mode: 0644]
sdk/ruby-google-api-client/CHANGELOG.md [new file with mode: 0644]
sdk/ruby-google-api-client/CONTRIBUTING.md [new file with mode: 0644]
sdk/ruby-google-api-client/Gemfile [new file with mode: 0644]
sdk/ruby-google-api-client/LICENSE [new file with mode: 0644]
sdk/ruby-google-api-client/README.md [new file with mode: 0644]
sdk/ruby-google-api-client/Rakefile [new file with mode: 0644]
sdk/ruby-google-api-client/arvados-google-api-client.gemspec [new file with mode: 0644]
sdk/ruby-google-api-client/lib/cacerts.pem [new file with mode: 0644]
sdk/ruby-google-api-client/lib/compat/multi_json.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/auth/compute_service_account.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/auth/file_storage.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/auth/installed_app.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/auth/jwt_asserter.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/auth/key_utils.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/auth/pkcs12.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/auth/storage.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/auth/storages/file_store.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/auth/storages/redis_store.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/batch.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/charset.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/client_secrets.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/discovery.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/discovery/api.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/discovery/media.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/discovery/method.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/discovery/resource.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/discovery/schema.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/environment.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/errors.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/logging.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/media.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/railtie.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/reference.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/request.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/result.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/service.rb [new file with mode: 0755]
sdk/ruby-google-api-client/lib/google/api_client/service/batch.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/service/request.rb [new file with mode: 0755]
sdk/ruby-google-api-client/lib/google/api_client/service/resource.rb [new file with mode: 0755]
sdk/ruby-google-api-client/lib/google/api_client/service/result.rb [new file with mode: 0755]
sdk/ruby-google-api-client/lib/google/api_client/service/simple_file_store.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/service/stub_generator.rb [new file with mode: 0755]
sdk/ruby-google-api-client/lib/google/api_client/service_account.rb [new file with mode: 0644]
sdk/ruby-google-api-client/lib/google/api_client/version.rb [new file with mode: 0644]
sdk/ruby-google-api-client/rakelib/gem.rake [new file with mode: 0644]
sdk/ruby-google-api-client/rakelib/git.rake [new file with mode: 0644]
sdk/ruby-google-api-client/rakelib/metrics.rake [new file with mode: 0644]
sdk/ruby-google-api-client/rakelib/spec.rake [new file with mode: 0644]
sdk/ruby-google-api-client/rakelib/wiki.rake [new file with mode: 0644]
sdk/ruby-google-api-client/rakelib/yard.rake [new file with mode: 0644]
sdk/ruby-google-api-client/script/package [new file with mode: 0755]
sdk/ruby-google-api-client/script/release [new file with mode: 0755]
sdk/ruby-google-api-client/spec/fixtures/files/auth_stored_credentials.json [new file with mode: 0644]
sdk/ruby-google-api-client/spec/fixtures/files/client_secrets.json [new file with mode: 0644]
sdk/ruby-google-api-client/spec/fixtures/files/privatekey.p12 [new file with mode: 0644]
sdk/ruby-google-api-client/spec/fixtures/files/sample.txt [new file with mode: 0644]
sdk/ruby-google-api-client/spec/fixtures/files/secret.pem [new file with mode: 0644]
sdk/ruby-google-api-client/spec/fixtures/files/zoo.json [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/auth/storage_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/auth/storages/file_store_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/auth/storages/redis_store_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/batch_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/client_secrets_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/discovery_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/gzip_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/media_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/request_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/result_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/service_account_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/service_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client/simple_file_store_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/google/api_client_spec.rb [new file with mode: 0644]
sdk/ruby-google-api-client/spec/spec_helper.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/bin/yard-wiki [new file with mode: 0755]
sdk/ruby-google-api-client/yard/lib/yard-google-code.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/lib/yard/cli/wiki.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/lib/yard/rake/wikidoc_task.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/lib/yard/serializers/wiki_serializer.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/lib/yard/templates/helpers/wiki_helper.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/templates/default/class/setup.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/templates/default/docstring/setup.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/templates/default/method/setup.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/templates/default/method_details/setup.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/templates/default/module/setup.rb [new file with mode: 0644]
sdk/ruby-google-api-client/yard/templates/default/tags/setup.rb [new file with mode: 0644]
sdk/ruby/Gemfile
sdk/ruby/arvados.gemspec
sdk/ruby/lib/arvados.rb
sdk/ruby/test/sdk_fixtures.rb
sdk/ruby/test/test_keep_manifest.rb
sdk/ruby/test/test_request_id.rb
services/api/Gemfile
services/api/Gemfile.lock
services/api/app/assets/config/manifest.js [new file with mode: 0644]
services/api/app/controllers/application_controller.rb
services/api/app/controllers/arvados/v1/container_requests_controller.rb
services/api/app/controllers/arvados/v1/containers_controller.rb
services/api/app/controllers/arvados/v1/groups_controller.rb
services/api/app/controllers/arvados/v1/nodes_controller.rb
services/api/app/controllers/arvados/v1/schema_controller.rb
services/api/app/controllers/arvados/v1/users_controller.rb
services/api/app/controllers/database_controller.rb
services/api/app/controllers/static_controller.rb
services/api/app/controllers/user_sessions_controller.rb
services/api/app/middlewares/arvados_api_token.rb
services/api/app/models/api_client.rb
services/api/app/models/api_client_authorization.rb
services/api/app/models/arvados_model.rb
services/api/app/models/authorized_key.rb
services/api/app/models/collection.rb
services/api/app/models/container.rb
services/api/app/models/container_request.rb
services/api/app/models/group.rb
services/api/app/models/job.rb
services/api/app/models/keep_disk.rb
services/api/app/models/link.rb
services/api/app/models/node.rb
services/api/app/models/pipeline_instance.rb
services/api/app/models/user.rb
services/api/app/models/virtual_machine.rb
services/api/app/models/workflow.rb
services/api/app/views/admin_notifier/new_inactive_user.text.erb
services/api/app/views/admin_notifier/new_user.text.erb
services/api/app/views/user_notifier/account_is_setup.text.erb
services/api/bin/rails
services/api/bin/rake
services/api/bin/setup
services/api/config.ru
services/api/config/application.rb
services/api/config/arvados_config.rb
services/api/config/boot.rb
services/api/config/environment.rb
services/api/config/initializers/application_controller_renderer.rb
services/api/config/initializers/assets.rb
services/api/config/initializers/authorization.rb
services/api/config/initializers/backtrace_silencers.rb
services/api/config/initializers/clear_empty_content_type.rb [new file with mode: 0644]
services/api/config/initializers/content_security_policy.rb
services/api/config/initializers/cookies_serializer.rb
services/api/config/initializers/custom_types.rb
services/api/config/initializers/eventbus.rb [deleted file]
services/api/config/initializers/filter_parameter_logging.rb
services/api/config/initializers/inflections.rb
services/api/config/initializers/mime_types.rb
services/api/config/initializers/new_framework_defaults.rb [deleted file]
services/api/config/initializers/new_framework_defaults_5_2.rb [deleted file]
services/api/config/initializers/permissions_policy.rb [new file with mode: 0644]
services/api/config/initializers/reload_config.rb
services/api/config/initializers/request_id_middleware.rb
services/api/config/initializers/schema_discovery_cache.rb [deleted file]
services/api/config/initializers/wrap_parameters.rb
services/api/config/locales/en.yml
services/api/config/routes.rb
services/api/db/migrate/20130118002239_rename_metadata_attributes.rb
services/api/db/migrate/20150203180223_set_group_class_on_anonymous_group.rb
services/api/db/migrate/20150303210106_fix_collection_portable_data_hash_with_hinted_manifest.rb
services/api/db/migrate/20180917205609_recompute_file_names_index.rb
services/api/db/migrate/20220726034131_write_via_all_users.rb
services/api/db/migrate/20221219165512_dedup_permission_links.rb
services/api/db/migrate/20230421142716_add_name_index_to_collections_and_groups.rb [new file with mode: 0644]
services/api/db/migrate/20230503224107_priority_update_functions.rb [new file with mode: 0644]
services/api/db/migrate/20230815160000_jsonb_exists_functions.rb [new file with mode: 0644]
services/api/db/migrate/20230821000000_priority_update_fix.rb [new file with mode: 0644]
services/api/db/migrate/20230922000000_add_btree_name_index_to_collections_and_groups.rb [new file with mode: 0644]
services/api/db/migrate/20231013000000_compute_permission_index.rb [new file with mode: 0644]
services/api/db/structure.sql
services/api/fpm-info.sh
services/api/lib/app_version.rb
services/api/lib/can_be_an_owner.rb
services/api/lib/config_loader.rb
services/api/lib/current_api_client.rb
services/api/lib/db_current_time.rb
services/api/lib/enable_jobs_api.rb
services/api/lib/has_uuid.rb
services/api/lib/migrate_yaml_to_json.rb
services/api/lib/record_filters.rb
services/api/lib/serializers.rb
services/api/lib/simulate_job_log.rb [deleted file]
services/api/lib/tasks/manage_long_lived_tokens.rake
services/api/lib/tasks/replay_job_log.rake [deleted file]
services/api/lib/trashable.rb
services/api/lib/update_permissions.rb
services/api/lib/update_priorities.rb [new file with mode: 0644]
services/api/script/arvados-git-sync.rb
services/api/script/migrate-gitolite-to-uuid-storage.rb
services/api/test/fixtures/authorized_keys.yml
services/api/test/fixtures/collections.yml
services/api/test/fixtures/container_requests.yml
services/api/test/fixtures/containers.yml
services/api/test/fixtures/groups.yml
services/api/test/fixtures/job_tasks.yml
services/api/test/fixtures/jobs.yml
services/api/test/fixtures/keep_disks.yml
services/api/test/fixtures/links.yml
services/api/test/fixtures/logs.yml
services/api/test/fixtures/nodes.yml
services/api/test/fixtures/pipeline_instances.yml
services/api/test/fixtures/workflows.yml
services/api/test/functional/arvados/v1/api_client_authorizations_controller_test.rb
services/api/test/functional/arvados/v1/collections_controller_test.rb
services/api/test/functional/arvados/v1/container_requests_controller_test.rb
services/api/test/functional/arvados/v1/containers_controller_test.rb
services/api/test/functional/arvados/v1/filters_test.rb
services/api/test/functional/arvados/v1/groups_controller_test.rb
services/api/test/functional/arvados/v1/management_controller_test.rb
services/api/test/functional/arvados/v1/schema_controller_test.rb
services/api/test/functional/arvados/v1/users_controller_test.rb
services/api/test/functional/user_sessions_controller_test.rb
services/api/test/integration/api_client_authorizations_api_test.rb
services/api/test/integration/api_client_authorizations_scopes_test.rb
services/api/test/integration/cross_origin_test.rb
services/api/test/integration/discovery_document_test.rb [new file with mode: 0644]
services/api/test/integration/http_quirks_test.rb [new file with mode: 0644]
services/api/test/integration/remote_user_test.rb
services/api/test/integration/user_sessions_test.rb
services/api/test/integration/users_test.rb
services/api/test/test_helper.rb
services/api/test/unit/api_client_test.rb
services/api/test/unit/arvados_model_test.rb
services/api/test/unit/collection_test.rb
services/api/test/unit/container_request_test.rb
services/api/test/unit/container_test.rb
services/api/test/unit/create_superuser_token_test.rb
services/api/test/unit/group_test.rb
services/api/test/unit/link_test.rb
services/api/test/unit/log_test.rb
services/api/test/unit/owner_test.rb
services/api/test/unit/permission_test.rb
services/api/test/unit/repository_test.rb
services/api/test/unit/user_test.rb
services/api/test/unit/workflow_test.rb
services/crunch-dispatch-local/crunch-dispatch-local.service
services/crunch-dispatch-slurm/crunch-dispatch-slurm.go
services/crunchstat/.gitignore [deleted file]
services/crunchstat/crunchstat.go [deleted file]
services/crunchstat/crunchstat_test.go [deleted file]
services/dockercleaner/arvados-docker-cleaner.service
services/dockercleaner/arvados_docker/cleaner.py
services/dockercleaner/arvados_version.py
services/dockercleaner/setup.py
services/dockercleaner/tests/test_cleaner.py
services/fuse/README.rst
services/fuse/arvados_fuse/__init__.py
services/fuse/arvados_fuse/command.py
services/fuse/arvados_fuse/fresh.py
services/fuse/arvados_fuse/fusedir.py
services/fuse/arvados_fuse/fusefile.py
services/fuse/arvados_fuse/unmount.py
services/fuse/arvados_version.py
services/fuse/fpm-info.sh
services/fuse/setup.py
services/fuse/tests/integration_test.py
services/fuse/tests/mount_test_base.py
services/fuse/tests/test_command_args.py
services/fuse/tests/test_exec.py
services/fuse/tests/test_inodes.py
services/fuse/tests/test_mount.py
services/fuse/tests/test_mount_filters.py [new file with mode: 0644]
services/fuse/tests/test_retry.py
services/fuse/tests/test_unmount.py
services/keep-balance/balance.go
services/keep-balance/balance_run_test.go
services/keep-balance/balance_test.go
services/keep-balance/change_set.go
services/keep-balance/change_set_test.go
services/keep-balance/integration_test.go
services/keep-balance/main.go
services/keep-balance/metrics.go
services/keep-balance/server.go
services/keep-web/cache.go
services/keep-web/cache_test.go
services/keep-web/cadaver_test.go
services/keep-web/doc.go
services/keep-web/fpm-info.sh
services/keep-web/handler.go
services/keep-web/handler_test.go
services/keep-web/main.go
services/keep-web/metrics.go [new file with mode: 0644]
services/keep-web/s3.go
services/keep-web/s3_test.go
services/keep-web/server_test.go
services/keep-web/webdav.go [deleted file]
services/keep-web/webdav_test.go [deleted file]
services/keepproxy/keepproxy.go
services/keepproxy/keepproxy_test.go
services/keepstore/azure_blob_volume.go
services/keepstore/azure_blob_volume_test.go
services/keepstore/bufferpool.go
services/keepstore/bufferpool_test.go
services/keepstore/collision.go [deleted file]
services/keepstore/collision_test.go [deleted file]
services/keepstore/command.go
services/keepstore/command_test.go
services/keepstore/count.go
services/keepstore/gocheck_test.go [deleted file]
services/keepstore/handler_test.go [deleted file]
services/keepstore/handlers.go [deleted file]
services/keepstore/hashcheckwriter.go [new file with mode: 0644]
services/keepstore/keepstore.go
services/keepstore/keepstore_test.go [new file with mode: 0644]
services/keepstore/metrics.go
services/keepstore/metrics_test.go [new file with mode: 0644]
services/keepstore/mock_mutex_for_test.go [deleted file]
services/keepstore/mounts_test.go
services/keepstore/perms.go [deleted file]
services/keepstore/perms_test.go [deleted file]
services/keepstore/pipe_adapters.go [deleted file]
services/keepstore/proxy_remote.go [deleted file]
services/keepstore/proxy_remote_test.go
services/keepstore/pull_worker.go
services/keepstore/pull_worker_integration_test.go [deleted file]
services/keepstore/pull_worker_test.go
services/keepstore/putprogress.go [new file with mode: 0644]
services/keepstore/router.go [new file with mode: 0644]
services/keepstore/router_test.go [new file with mode: 0644]
services/keepstore/s3_volume.go
services/keepstore/s3_volume_test.go
services/keepstore/s3aws_volume.go [deleted file]
services/keepstore/s3aws_volume_test.go [deleted file]
services/keepstore/status_test.go [deleted file]
services/keepstore/streamwriterat.go [new file with mode: 0644]
services/keepstore/streamwriterat_test.go [new file with mode: 0644]
services/keepstore/trash_worker.go
services/keepstore/trash_worker_test.go
services/keepstore/unix_volume.go
services/keepstore/unix_volume_test.go
services/keepstore/volume.go
services/keepstore/volume_generic_test.go
services/keepstore/volume_test.go
services/keepstore/work_queue.go [deleted file]
services/keepstore/work_queue_test.go [deleted file]
services/login-sync/Gemfile
services/login-sync/arvados-login-sync.gemspec
services/login-sync/bin/arvados-login-sync
services/workbench2/.env [new file with mode: 0644]
services/workbench2/.gitignore [new file with mode: 0644]
services/workbench2/.npmrc [new file with mode: 0644]
services/workbench2/.yarn/releases/yarn-3.2.0.cjs [new file with mode: 0755]
services/workbench2/.yarnrc [new file with mode: 0644]
services/workbench2/.yarnrc.yml [new file with mode: 0644]
services/workbench2/AUTHORS [new file with mode: 0644]
services/workbench2/COPYING [new file with mode: 0644]
services/workbench2/Makefile [new file with mode: 0644]
services/workbench2/README.md [new file with mode: 0644]
services/workbench2/__mocks__/popper.js.js [new file with mode: 0644]
services/workbench2/agpl-3.0.txt [new file with mode: 0644]
services/workbench2/apache-2.0.txt [new file with mode: 0644]
services/workbench2/cc-by-sa-3.0.txt [new file with mode: 0644]
services/workbench2/cypress.config.ts [new file with mode: 0644]
services/workbench2/cypress/e2e/banner-tooltip.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/collection.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/create-workflow.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/delete-multiple-files.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/favorites.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/group-manage.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/login.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/multiselect-toolbar.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/page-not-found.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/process.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/project.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/search.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/sharing.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/side-panel.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/user-profile.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/virtual-machine-admin.cy.js [new file with mode: 0644]
services/workbench2/cypress/e2e/workflow.cy.js [new file with mode: 0644]
services/workbench2/cypress/fixtures/.gitkeep [moved from apps/workbench/app/mailers/.gitkeep with 100% similarity]
services/workbench2/cypress/fixtures/files/5mb.bin [new file with mode: 0644]
services/workbench2/cypress/fixtures/files/banner.html [new file with mode: 0644]
services/workbench2/cypress/fixtures/files/cat.png [new file with mode: 0644]
services/workbench2/cypress/fixtures/files/tooltips.txt [new file with mode: 0644]
services/workbench2/cypress/fixtures/webdav-propfind-outputs.xml [new file with mode: 0644]
services/workbench2/cypress/fixtures/workflow_directory_array.yaml [new file with mode: 0644]
services/workbench2/cypress/fixtures/workflow_with_array_fields.yaml [new file with mode: 0644]
services/workbench2/cypress/fixtures/workflow_with_default_array_fields.yaml [new file with mode: 0644]
services/workbench2/cypress/plugins/index.js [new file with mode: 0644]
services/workbench2/cypress/support/commands.js [new file with mode: 0644]
services/workbench2/cypress/support/e2e.js [new file with mode: 0644]
services/workbench2/cypress/support/index.d.ts [new file with mode: 0644]
services/workbench2/docker/Dockerfile [new file with mode: 0644]
services/workbench2/etc/arvados/workbench2/workbench2.example.json [new file with mode: 0644]
services/workbench2/package.json [new file with mode: 0644]
services/workbench2/public/arvados-logo-big.png [moved from apps/workbench/public/arvados-logo-big.png with 100% similarity]
services/workbench2/public/arvados_logo.png [new file with mode: 0644]
services/workbench2/public/favicon.ico [moved from apps/workbench/public/favicon.ico with 100% similarity]
services/workbench2/public/file-viewers-example.json [new file with mode: 0644]
services/workbench2/public/index.html [new file with mode: 0644]
services/workbench2/public/manifest.json [new file with mode: 0644]
services/workbench2/public/mui-start-icon.svg [new file with mode: 0644]
services/workbench2/public/webshell/README [moved from apps/workbench/public/webshell/README with 100% similarity]
services/workbench2/public/webshell/enabled.gif [moved from apps/workbench/public/webshell/enabled.gif with 100% similarity]
services/workbench2/public/webshell/index.html [new file with mode: 0644]
services/workbench2/public/webshell/keyboard.html [new file with mode: 0644]
services/workbench2/public/webshell/keyboard.png [moved from apps/workbench/public/webshell/keyboard.png with 100% similarity]
services/workbench2/public/webshell/shell_in_a_box.js [new file with mode: 0644]
services/workbench2/public/webshell/styles.css [moved from apps/workbench/lib/assets/stylesheets/webshell/styles.css with 100% similarity]
services/workbench2/src/common/app-info.ts [new file with mode: 0644]
services/workbench2/src/common/array-utils.ts [new file with mode: 0644]
services/workbench2/src/common/codes.ts [new file with mode: 0644]
services/workbench2/src/common/config.ts [new file with mode: 0644]
services/workbench2/src/common/custom-theme.ts [new file with mode: 0644]
services/workbench2/src/common/file.ts [new file with mode: 0644]
services/workbench2/src/common/formatters.test.ts [new file with mode: 0644]
services/workbench2/src/common/formatters.ts [new file with mode: 0644]
services/workbench2/src/common/frozen-resources.ts [new file with mode: 0644]
services/workbench2/src/common/getuser.ts [new file with mode: 0644]
services/workbench2/src/common/html-sanitize.ts [new file with mode: 0644]
services/workbench2/src/common/labels.ts [new file with mode: 0644]
services/workbench2/src/common/link-update-name.ts [new file with mode: 0644]
services/workbench2/src/common/objects.ts [new file with mode: 0644]
services/workbench2/src/common/plugintypes.ts [new file with mode: 0644]
services/workbench2/src/common/redirect-to.test.ts [new file with mode: 0644]
services/workbench2/src/common/redirect-to.ts [new file with mode: 0644]
services/workbench2/src/common/regexp.ts [new file with mode: 0644]
services/workbench2/src/common/service-provider.ts [new file with mode: 0644]
services/workbench2/src/common/unionize.ts [new file with mode: 0644]
services/workbench2/src/common/url.test.ts [new file with mode: 0644]
services/workbench2/src/common/url.ts [new file with mode: 0644]
services/workbench2/src/common/use-async-interval.test.tsx [new file with mode: 0644]
services/workbench2/src/common/use-async-interval.ts [new file with mode: 0644]
services/workbench2/src/common/webdav.test.ts [new file with mode: 0644]
services/workbench2/src/common/webdav.ts [new file with mode: 0644]
services/workbench2/src/common/xml.ts [new file with mode: 0644]
services/workbench2/src/components/autocomplete/autocomplete.tsx [new file with mode: 0644]
services/workbench2/src/components/breadcrumbs/breadcrumbs.test.tsx [new file with mode: 0644]
services/workbench2/src/components/breadcrumbs/breadcrumbs.tsx [new file with mode: 0644]
services/workbench2/src/components/checkbox-field/checkbox-field.tsx [new file with mode: 0644]
services/workbench2/src/components/chips-input/chips-input.tsx [new file with mode: 0644]
services/workbench2/src/components/chips/chips.tsx [new file with mode: 0644]
services/workbench2/src/components/code-snippet/code-snippet.tsx [new file with mode: 0644]
services/workbench2/src/components/code-snippet/virtual-code-snippet.tsx [new file with mode: 0644]
services/workbench2/src/components/collection-panel-files/collection-panel-files.tsx [new file with mode: 0644]
services/workbench2/src/components/column-selector/column-selector.test.tsx [new file with mode: 0644]
services/workbench2/src/components/column-selector/column-selector.tsx [new file with mode: 0644]
services/workbench2/src/components/confirmation-dialog/confirmation-dialog.tsx [new file with mode: 0644]
services/workbench2/src/components/context-menu/context-menu.test.tsx [new file with mode: 0644]
services/workbench2/src/components/context-menu/context-menu.tsx [new file with mode: 0644]
services/workbench2/src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx [new file with mode: 0644]
services/workbench2/src/components/copy-to-clipboard/copy-result-to-clipboard.ts [new file with mode: 0644]
services/workbench2/src/components/data-explorer/data-explorer.test.tsx [new file with mode: 0644]
services/workbench2/src/components/data-explorer/data-explorer.tsx [new file with mode: 0644]
services/workbench2/src/components/data-table-default-view/data-table-default-view.tsx [new file with mode: 0644]
services/workbench2/src/components/data-table-filters/data-table-filters-popover.test.tsx [new file with mode: 0644]
services/workbench2/src/components/data-table-filters/data-table-filters-popover.tsx [new file with mode: 0644]
services/workbench2/src/components/data-table-filters/data-table-filters-tree.tsx [new file with mode: 0644]
services/workbench2/src/components/data-table-filters/data-table-filters.tsx [new file with mode: 0644]
services/workbench2/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx [new file with mode: 0644]
services/workbench2/src/components/data-table/data-column.ts [new file with mode: 0644]
services/workbench2/src/components/data-table/data-table.test.tsx [new file with mode: 0644]
services/workbench2/src/components/data-table/data-table.tsx [new file with mode: 0644]
services/workbench2/src/components/default-code-snippet/default-code-snippet.tsx [new file with mode: 0644]
services/workbench2/src/components/default-code-snippet/default-virtual-code-snippet.tsx [new file with mode: 0644]
services/workbench2/src/components/default-view/default-view.tsx [new file with mode: 0644]
services/workbench2/src/components/details-attribute/details-attribute.tsx [new file with mode: 0644]
services/workbench2/src/components/dialog-actions/dialog-actions.tsx [new file with mode: 0644]
services/workbench2/src/components/dropdown-menu/dropdown-menu.test.tsx [new file with mode: 0644]
services/workbench2/src/components/dropdown-menu/dropdown-menu.tsx [new file with mode: 0644]
services/workbench2/src/components/file-tree/file-thumbnail.test.tsx [new file with mode: 0644]
services/workbench2/src/components/file-tree/file-thumbnail.tsx [new file with mode: 0644]
services/workbench2/src/components/file-tree/file-tree-data.ts [new file with mode: 0644]
services/workbench2/src/components/file-tree/file-tree-item.tsx [new file with mode: 0644]
services/workbench2/src/components/file-upload/file-upload.tsx [new file with mode: 0644]
services/workbench2/src/components/float-input/float-input.tsx [new file with mode: 0644]
services/workbench2/src/components/form-dialog/form-dialog.tsx [new file with mode: 0644]
services/workbench2/src/components/form-field/form-field.tsx [new file with mode: 0644]
services/workbench2/src/components/icon/icon.tsx [new file with mode: 0644]
services/workbench2/src/components/int-input/int-input.tsx [new file with mode: 0644]
services/workbench2/src/components/list-item-text-icon/list-item-text-icon.tsx [new file with mode: 0644]
services/workbench2/src/components/loading/inline-pulser.tsx [new file with mode: 0644]
services/workbench2/src/components/multi-panel-view/multi-panel-view.test.tsx [new file with mode: 0644]
services/workbench2/src/components/multi-panel-view/multi-panel-view.tsx [new file with mode: 0644]
services/workbench2/src/components/multiselect-toolbar/MultiselectToolbar.tsx [new file with mode: 0644]
services/workbench2/src/components/multiselect-toolbar/ms-kind-action-differentiator.ts [new file with mode: 0644]
services/workbench2/src/components/multiselect-toolbar/ms-toolbar-action-filters.ts [new file with mode: 0644]
services/workbench2/src/components/multiselect-toolbar/ms-toolbar-overflow-menu.tsx [new file with mode: 0644]
services/workbench2/src/components/multiselect-toolbar/ms-toolbar-overflow-wrapper.tsx [new file with mode: 0644]
services/workbench2/src/components/popover/helpers.ts [new file with mode: 0644]
services/workbench2/src/components/popover/popover.test.tsx [new file with mode: 0644]
services/workbench2/src/components/popover/popover.tsx [new file with mode: 0644]
services/workbench2/src/components/progress-button/progress-button.tsx [new file with mode: 0644]
services/workbench2/src/components/refresh-button/refresh-button.test.tsx [new file with mode: 0644]
services/workbench2/src/components/refresh-button/refresh-button.tsx [new file with mode: 0644]
services/workbench2/src/components/rich-text-editor-link/rich-text-editor-link.tsx [new file with mode: 0644]
services/workbench2/src/components/search-input/search-input.test.tsx [new file with mode: 0644]
services/workbench2/src/components/search-input/search-input.tsx [new file with mode: 0644]
services/workbench2/src/components/select-field/select-field.tsx [new file with mode: 0644]
services/workbench2/src/components/subprocess-filter/subprocess-filter.tsx [new file with mode: 0644]
services/workbench2/src/components/subprocess-progress-bar/subprocess-progress-bar.test.tsx [new file with mode: 0644]
services/workbench2/src/components/subprocess-progress-bar/subprocess-progress-bar.tsx [new file with mode: 0644]
services/workbench2/src/components/switch-field/switch-field.tsx [new file with mode: 0644]
services/workbench2/src/components/text-field/text-field.tsx [new file with mode: 0644]
services/workbench2/src/components/tree/tree.test.tsx [new file with mode: 0644]
services/workbench2/src/components/tree/tree.tsx [new file with mode: 0644]
services/workbench2/src/components/tree/virtual-tree.tsx [new file with mode: 0644]
services/workbench2/src/components/warning-collection/warning-collection.tsx [new file with mode: 0644]
services/workbench2/src/components/warning/warning.tsx [new file with mode: 0644]
services/workbench2/src/components/workflow-inputs-form/validators.ts [new file with mode: 0644]
services/workbench2/src/components/workflow-inputs-form/workflow-input.tsx [new file with mode: 0644]
services/workbench2/src/index.css [new file with mode: 0644]
services/workbench2/src/index.tsx [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/cmd.png [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/images/file_input.svg [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/images/file_output.svg [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/images/tool.svg [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/images/type_input.svg [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/images/type_output.svg [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/images/workflow.svg [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/styles/_variables.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/styles/style.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/styles/style.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/styles/theme.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/styles/theme.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/styles/themes/rabix-dark/_variables.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/styles/themes/rabix-dark/theme.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/assets/styles/themes/rabix-dark/theme.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/behaviors/edge-panning.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/graph/connectable.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/graph/edge.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/graph/graph-node.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/graph/io-port.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/graph/step-node.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/graph/template-parser.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/graph/workflow.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/index.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/arrange/arrange.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/deletion/deletion.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/edge-hover/edge-hover.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/node-move/node-move.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/plugin-base.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/plugin.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/port-drag/_variables.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/port-drag/port-drag.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/port-drag/style.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/port-drag/style.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.dark.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.dark.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/selection/_variables.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/selection/selection.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/selection/style.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/selection/style.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/selection/theme.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/selection/theme.dark.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/selection/theme.dark.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/selection/theme.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/validate/validate.css [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/validate/validate.scss [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/validate/validate.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/zoom/index.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/plugins/zoom/zoom.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/utils/dom-events.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/utils/dynamic-stylesheet.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/utils/event-hub.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/utils/geometry.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/utils/html-utils.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/utils/perf.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/utils/svg-dumper.ts [new file with mode: 0644]
services/workbench2/src/lib/cwl-svg/utils/svg-utils.ts [new file with mode: 0644]
services/workbench2/src/lib/resource-properties.test.ts [new file with mode: 0644]
services/workbench2/src/lib/resource-properties.ts [new file with mode: 0644]
services/workbench2/src/models/api-client-authorization.ts [new file with mode: 0644]
services/workbench2/src/models/client-authorization.ts [new file with mode: 0644]
services/workbench2/src/models/collection-file.ts [new file with mode: 0644]
services/workbench2/src/models/collection.ts [new file with mode: 0644]
services/workbench2/src/models/container-request.ts [new file with mode: 0644]
services/workbench2/src/models/container.ts [new file with mode: 0644]
services/workbench2/src/models/details.ts [new file with mode: 0644]
services/workbench2/src/models/empty.ts [new file with mode: 0644]
services/workbench2/src/models/file-viewers-config.ts [new file with mode: 0644]
services/workbench2/src/models/group.ts [new file with mode: 0644]
services/workbench2/src/models/keep-manifest.ts [new file with mode: 0644]
services/workbench2/src/models/keep-services.ts [new file with mode: 0644]
services/workbench2/src/models/link-account.ts [new file with mode: 0644]
services/workbench2/src/models/link.ts [new file with mode: 0644]
services/workbench2/src/models/log.ts [new file with mode: 0644]
services/workbench2/src/models/mount-types.ts [new file with mode: 0644]
services/workbench2/src/models/node.ts [new file with mode: 0644]
services/workbench2/src/models/object-types.ts [new file with mode: 0644]
services/workbench2/src/models/permission.ts [new file with mode: 0644]
services/workbench2/src/models/process.ts [new file with mode: 0644]
services/workbench2/src/models/project.ts [new file with mode: 0644]
services/workbench2/src/models/repositories.ts [new file with mode: 0644]
services/workbench2/src/models/resource.ts [new file with mode: 0644]
services/workbench2/src/models/runtime-constraints.ts [new file with mode: 0644]
services/workbench2/src/models/runtime-status.ts [new file with mode: 0644]
services/workbench2/src/models/scheduling-parameters.ts [new file with mode: 0644]
services/workbench2/src/models/search-bar.ts [new file with mode: 0644]
services/workbench2/src/models/session.ts [new file with mode: 0644]
services/workbench2/src/models/ssh-key.ts [new file with mode: 0644]
services/workbench2/src/models/tag.ts [new file with mode: 0644]
services/workbench2/src/models/test-utils.ts [new file with mode: 0644]
services/workbench2/src/models/tree.test.ts [new file with mode: 0644]
services/workbench2/src/models/tree.ts [new file with mode: 0644]
services/workbench2/src/models/user.test.ts [new file with mode: 0644]
services/workbench2/src/models/user.ts [new file with mode: 0644]
services/workbench2/src/models/virtual-machines.ts [new file with mode: 0644]
services/workbench2/src/models/vocabulary.test.ts [new file with mode: 0644]
services/workbench2/src/models/vocabulary.ts [new file with mode: 0644]
services/workbench2/src/models/workflow.ts [new file with mode: 0644]
services/workbench2/src/plugins.tsx [new file with mode: 0644]
services/workbench2/src/plugins/README.md [new file with mode: 0644]
services/workbench2/src/plugins/blank/index.tsx [new file with mode: 0644]
services/workbench2/src/plugins/example/exampleComponents.tsx [new file with mode: 0644]
services/workbench2/src/plugins/example/index.tsx [new file with mode: 0644]
services/workbench2/src/plugins/root-redirect/index.tsx [new file with mode: 0644]
services/workbench2/src/react-app-env.d.ts [new file with mode: 0644]
services/workbench2/src/routes/route-change-handlers.ts [new file with mode: 0644]
services/workbench2/src/routes/routes.ts [new file with mode: 0644]
services/workbench2/src/services/ancestors-service/ancestors-service.ts [new file with mode: 0644]
services/workbench2/src/services/api-client-authorization-service/api-client-authorization-service.test.ts [new file with mode: 0644]
services/workbench2/src/services/api-client-authorization-service/api-client-authorization-service.ts [new file with mode: 0644]
services/workbench2/src/services/api/api-actions.ts [new file with mode: 0644]
services/workbench2/src/services/api/filter-builder.test.ts [new file with mode: 0644]
services/workbench2/src/services/api/filter-builder.ts [new file with mode: 0644]
services/workbench2/src/services/api/order-builder.test.ts [new file with mode: 0644]
services/workbench2/src/services/api/order-builder.ts [new file with mode: 0644]
services/workbench2/src/services/api/url-builder.test.ts [new file with mode: 0644]
services/workbench2/src/services/api/url-builder.ts [new file with mode: 0644]
services/workbench2/src/services/auth-service/auth-service.ts [new file with mode: 0644]
services/workbench2/src/services/authorized-keys-service/authorized-keys-service.ts [new file with mode: 0644]
services/workbench2/src/services/collection-service/collection-service-files-response.test.ts [new file with mode: 0644]
services/workbench2/src/services/collection-service/collection-service-files-response.ts [new file with mode: 0644]
services/workbench2/src/services/collection-service/collection-service.test.ts [new file with mode: 0644]
services/workbench2/src/services/collection-service/collection-service.ts [new file with mode: 0644]
services/workbench2/src/services/common-service/common-resource-service.test.ts [new file with mode: 0644]
services/workbench2/src/services/common-service/common-resource-service.ts [new file with mode: 0644]
services/workbench2/src/services/common-service/common-service.test.ts [new file with mode: 0644]
services/workbench2/src/services/common-service/common-service.ts [new file with mode: 0644]
services/workbench2/src/services/common-service/trashable-resource-service.ts [new file with mode: 0644]
services/workbench2/src/services/container-request-service/container-request-service.ts [new file with mode: 0644]
services/workbench2/src/services/container-service/container-service.ts [new file with mode: 0644]
services/workbench2/src/services/favorite-service/favorite-service.test.ts [new file with mode: 0644]
services/workbench2/src/services/favorite-service/favorite-service.ts [new file with mode: 0644]
services/workbench2/src/services/file-viewers-config-service/file-viewers-config-service.ts [new file with mode: 0644]
services/workbench2/src/services/groups-service/groups-service.test.ts [new file with mode: 0644]
services/workbench2/src/services/groups-service/groups-service.ts [new file with mode: 0644]
services/workbench2/src/services/keep-service/keep-service.ts [new file with mode: 0644]
services/workbench2/src/services/link-account-service/link-account-service.ts [new file with mode: 0644]
services/workbench2/src/services/link-service/link-service.ts [new file with mode: 0644]
services/workbench2/src/services/log-service/log-service.test.ts [new file with mode: 0644]
services/workbench2/src/services/log-service/log-service.ts [new file with mode: 0644]
services/workbench2/src/services/permission-service/permission-service.ts [new file with mode: 0644]
services/workbench2/src/services/project-service/project-service.test.ts [new file with mode: 0644]
services/workbench2/src/services/project-service/project-service.ts [new file with mode: 0644]
services/workbench2/src/services/repositories-service/repositories-service.ts [new file with mode: 0644]
services/workbench2/src/services/search-service/search-service.ts [new file with mode: 0644]
services/workbench2/src/services/services.ts [new file with mode: 0644]
services/workbench2/src/services/tag-service/tag-service.ts [new file with mode: 0644]
services/workbench2/src/services/user-service/user-service.ts [new file with mode: 0644]
services/workbench2/src/services/virtual-machines-service/virtual-machines-service.ts [new file with mode: 0644]
services/workbench2/src/services/vocabulary-service/vocabulary-service.ts [new file with mode: 0644]
services/workbench2/src/services/workflow-service/workflow-service.ts [new file with mode: 0644]
services/workbench2/src/store/advanced-tab/advanced-tab.tsx [new file with mode: 0644]
services/workbench2/src/store/all-processes-panel/all-processes-panel-action.ts [new file with mode: 0644]
services/workbench2/src/store/all-processes-panel/all-processes-panel-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/api-client-authorizations/api-client-authorizations-actions.ts [new file with mode: 0644]
services/workbench2/src/store/api-client-authorizations/api-client-authorizations-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/app-info/app-info-actions.ts [new file with mode: 0644]
services/workbench2/src/store/app-info/app-info-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/auth/auth-action-session.ts [new file with mode: 0644]
services/workbench2/src/store/auth/auth-action-ssh.ts [new file with mode: 0644]
services/workbench2/src/store/auth/auth-action.test.ts [new file with mode: 0644]
services/workbench2/src/store/auth/auth-action.ts [new file with mode: 0644]
services/workbench2/src/store/auth/auth-middleware.test.ts [new file with mode: 0644]
services/workbench2/src/store/auth/auth-middleware.ts [new file with mode: 0644]
services/workbench2/src/store/auth/auth-reducer.test.ts [new file with mode: 0644]
services/workbench2/src/store/auth/auth-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/banner/banner-action.ts [new file with mode: 0644]
services/workbench2/src/store/banner/banner-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/breadcrumbs/breadcrumbs-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collection-panel/collection-panel-action.ts [new file with mode: 0644]
services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.test.ts [new file with mode: 0644]
services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts [new file with mode: 0644]
services/workbench2/src/store/collection-panel/collection-panel-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/collections-content-address-panel/collections-content-address-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collections/collection-copy-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collections/collection-create-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collections/collection-info-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collections/collection-move-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collections/collection-partial-copy-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collections/collection-partial-move-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collections/collection-update-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collections/collection-upload-actions.ts [new file with mode: 0644]
services/workbench2/src/store/collections/collection-version-actions.ts [new file with mode: 0644]
services/workbench2/src/store/context-menu/context-menu-actions.test.ts [new file with mode: 0644]
services/workbench2/src/store/context-menu/context-menu-actions.ts [new file with mode: 0644]
services/workbench2/src/store/context-menu/context-menu-filters.ts [new file with mode: 0644]
services/workbench2/src/store/context-menu/context-menu-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/copy-dialog/copy-dialog.ts [new file with mode: 0644]
services/workbench2/src/store/data-explorer/data-explorer-action.ts [new file with mode: 0644]
services/workbench2/src/store/data-explorer/data-explorer-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/data-explorer/data-explorer-middleware.test.ts [new file with mode: 0644]
services/workbench2/src/store/data-explorer/data-explorer-middleware.ts [new file with mode: 0644]
services/workbench2/src/store/data-explorer/data-explorer-reducer.test.tsx [new file with mode: 0644]
services/workbench2/src/store/data-explorer/data-explorer-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/details-panel/details-panel-action.ts [new file with mode: 0644]
services/workbench2/src/store/details-panel/details-panel-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/dialog/dialog-actions.ts [new file with mode: 0644]
services/workbench2/src/store/dialog/dialog-reducer.test.ts [new file with mode: 0644]
services/workbench2/src/store/dialog/dialog-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/dialog/with-dialog.ts [new file with mode: 0644]
services/workbench2/src/store/favorite-panel/favorite-panel-action.ts [new file with mode: 0644]
services/workbench2/src/store/favorite-panel/favorite-panel-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/favorites/favorites-actions.ts [new file with mode: 0644]
services/workbench2/src/store/favorites/favorites-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/file-selection/file-selection-actions.ts [new file with mode: 0644]
services/workbench2/src/store/file-uploader/file-uploader-actions.ts [new file with mode: 0644]
services/workbench2/src/store/file-uploader/file-uploader-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/file-viewers/file-viewers-actions.ts [new file with mode: 0644]
services/workbench2/src/store/file-viewers/file-viewers-selectors.ts [new file with mode: 0644]
services/workbench2/src/store/group-details-panel/group-details-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/group-details-panel/group-details-panel-members-middleware-service.test.js [new file with mode: 0644]
services/workbench2/src/store/group-details-panel/group-details-panel-members-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/group-details-panel/group-details-panel-permissions-middleware-service.test.js [new file with mode: 0644]
services/workbench2/src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/groups-panel/groups-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/groups-panel/groups-panel-middleware-service.test.ts [new file with mode: 0644]
services/workbench2/src/store/groups-panel/groups-panel-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/keep-services/keep-services-actions.ts [new file with mode: 0644]
services/workbench2/src/store/keep-services/keep-services-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/link-account-panel/link-account-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/link-account-panel/link-account-panel-reducer.test.ts [new file with mode: 0644]
services/workbench2/src/store/link-account-panel/link-account-panel-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/link-panel/link-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/link-panel/link-panel-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/move-to-dialog/move-to-dialog.ts [new file with mode: 0644]
services/workbench2/src/store/multiselect/multiselect-actions.tsx [new file with mode: 0644]
services/workbench2/src/store/multiselect/multiselect-reducer.tsx [new file with mode: 0644]
services/workbench2/src/store/navigation/navigation-action.ts [new file with mode: 0644]
services/workbench2/src/store/not-found-panel/not-found-panel-action.tsx [new file with mode: 0644]
services/workbench2/src/store/open-in-new-tab/open-in-new-tab.actions.ts [new file with mode: 0644]
services/workbench2/src/store/owner-name/owner-name-actions.ts [new file with mode: 0644]
services/workbench2/src/store/owner-name/owner-name-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/process-logs-panel/process-logs-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/process-logs-panel/process-logs-panel-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/process-logs-panel/process-logs-panel.ts [new file with mode: 0644]
services/workbench2/src/store/process-panel/process-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/process-panel/process-panel-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/process-panel/process-panel.ts [new file with mode: 0644]
services/workbench2/src/store/processes/process-copy-actions.test.ts [new file with mode: 0644]
services/workbench2/src/store/processes/process-copy-actions.ts [new file with mode: 0644]
services/workbench2/src/store/processes/process-input-actions.ts [new file with mode: 0644]
services/workbench2/src/store/processes/process-move-actions.ts [new file with mode: 0644]
services/workbench2/src/store/processes/process-update-actions.ts [new file with mode: 0644]
services/workbench2/src/store/processes/process.ts [new file with mode: 0644]
services/workbench2/src/store/processes/processes-actions.ts [new file with mode: 0644]
services/workbench2/src/store/processes/processes-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/progress-indicator/progress-indicator-actions.ts [new file with mode: 0644]
services/workbench2/src/store/progress-indicator/progress-indicator-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/progress-indicator/with-progress.ts [new file with mode: 0644]
services/workbench2/src/store/project-panel/project-panel-action-bind.ts [new file with mode: 0644]
services/workbench2/src/store/project-panel/project-panel-action.ts [new file with mode: 0644]
services/workbench2/src/store/project-panel/project-panel-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/project-tree-picker/project-tree-picker-actions.ts [new file with mode: 0644]
services/workbench2/src/store/projects/project-create-actions.ts [new file with mode: 0644]
services/workbench2/src/store/projects/project-lock-actions.ts [new file with mode: 0644]
services/workbench2/src/store/projects/project-move-actions.ts [new file with mode: 0644]
services/workbench2/src/store/projects/project-update-actions.ts [new file with mode: 0644]
services/workbench2/src/store/properties/properties-actions.ts [new file with mode: 0644]
services/workbench2/src/store/properties/properties-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/properties/properties.ts [new file with mode: 0644]
services/workbench2/src/store/public-favorites-panel/public-favorites-action.ts [new file with mode: 0644]
services/workbench2/src/store/public-favorites-panel/public-favorites-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/public-favorites/public-favorites-actions.ts [new file with mode: 0644]
services/workbench2/src/store/public-favorites/public-favorites-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/repositories/repositories-actions.ts [new file with mode: 0644]
services/workbench2/src/store/repositories/repositories-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/resource-type-filters/resource-type-filters.test.ts [new file with mode: 0644]
services/workbench2/src/store/resource-type-filters/resource-type-filters.ts [new file with mode: 0644]
services/workbench2/src/store/resources/resources-actions.ts [new file with mode: 0644]
services/workbench2/src/store/resources/resources-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/resources/resources.test.ts [new file with mode: 0644]
services/workbench2/src/store/resources/resources.ts [new file with mode: 0644]
services/workbench2/src/store/rich-text-editor-dialog/rich-text-editor-dialog-actions.tsx [new file with mode: 0644]
services/workbench2/src/store/run-process-panel/run-process-panel-actions.test.ts [new file with mode: 0644]
services/workbench2/src/store/run-process-panel/run-process-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/run-process-panel/run-process-panel-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/search-bar/search-bar-actions.test.ts [new file with mode: 0644]
services/workbench2/src/store/search-bar/search-bar-actions.ts [new file with mode: 0644]
services/workbench2/src/store/search-bar/search-bar-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/search-bar/search-bar-tree-actions.ts [new file with mode: 0644]
services/workbench2/src/store/search-bar/search-query/arv-parser.ts [new file with mode: 0644]
services/workbench2/src/store/search-bar/search-query/parser.ts [new file with mode: 0644]
services/workbench2/src/store/search-results-panel/search-results-middleware-service.test.ts [new file with mode: 0644]
services/workbench2/src/store/search-results-panel/search-results-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/search-results-panel/search-results-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/shared-with-me-panel/shared-with-me-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/shared-with-me-panel/shared-with-me-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/sharing-dialog/sharing-dialog-actions.ts [new file with mode: 0644]
services/workbench2/src/store/sharing-dialog/sharing-dialog-types.ts [new file with mode: 0644]
services/workbench2/src/store/side-panel-tree/side-panel-tree-actions.ts [new file with mode: 0644]
services/workbench2/src/store/side-panel/side-panel-action.ts [new file with mode: 0644]
services/workbench2/src/store/side-panel/side-panel-reducer.tsx [new file with mode: 0644]
services/workbench2/src/store/snackbar/snackbar-actions.ts [new file with mode: 0644]
services/workbench2/src/store/snackbar/snackbar-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/store.ts [new file with mode: 0644]
services/workbench2/src/store/subprocess-panel/subprocess-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/store/subprocess-panel/subprocess-panel-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/token-dialog/token-dialog-actions.tsx [new file with mode: 0644]
services/workbench2/src/store/tooltips/tooltips-middleware.ts [new file with mode: 0644]
services/workbench2/src/store/trash-panel/trash-panel-action.ts [new file with mode: 0644]
services/workbench2/src/store/trash-panel/trash-panel-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/trash/trash-actions.ts [new file with mode: 0644]
services/workbench2/src/store/tree-picker/picker-id.tsx [new file with mode: 0644]
services/workbench2/src/store/tree-picker/tree-picker-actions.test.ts [new file with mode: 0644]
services/workbench2/src/store/tree-picker/tree-picker-actions.ts [new file with mode: 0644]
services/workbench2/src/store/tree-picker/tree-picker-middleware.ts [new file with mode: 0644]
services/workbench2/src/store/tree-picker/tree-picker-reducer.test.ts [new file with mode: 0644]
services/workbench2/src/store/tree-picker/tree-picker-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/tree-picker/tree-picker.ts [new file with mode: 0644]
services/workbench2/src/store/user-profile/user-profile-actions.ts [new file with mode: 0644]
services/workbench2/src/store/user-profile/user-profile-groups-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/users/user-panel-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/users/users-actions.ts [new file with mode: 0644]
services/workbench2/src/store/virtual-machines/virtual-machines-actions.ts [new file with mode: 0644]
services/workbench2/src/store/virtual-machines/virtual-machines-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/vocabulary/vocabulary-actions.ts [new file with mode: 0644]
services/workbench2/src/store/vocabulary/vocabulary-selectors.ts [new file with mode: 0644]
services/workbench2/src/store/workbench/workbench-actions.ts [new file with mode: 0644]
services/workbench2/src/store/workflow-panel/workflow-middleware-service.ts [new file with mode: 0644]
services/workbench2/src/store/workflow-panel/workflow-panel-actions.test.ts [new file with mode: 0644]
services/workbench2/src/store/workflow-panel/workflow-panel-actions.ts [new file with mode: 0644]
services/workbench2/src/validators/is-float.tsx [new file with mode: 0644]
services/workbench2/src/validators/is-integer.tsx [new file with mode: 0644]
services/workbench2/src/validators/is-number.tsx [new file with mode: 0644]
services/workbench2/src/validators/is-remote-host.tsx [new file with mode: 0644]
services/workbench2/src/validators/is-rsa-key.test.tsx [new file with mode: 0644]
services/workbench2/src/validators/is-rsa-key.tsx [new file with mode: 0644]
services/workbench2/src/validators/max-length.tsx [new file with mode: 0644]
services/workbench2/src/validators/min-length.tsx [new file with mode: 0644]
services/workbench2/src/validators/min.tsx [new file with mode: 0644]
services/workbench2/src/validators/optional.tsx [new file with mode: 0644]
services/workbench2/src/validators/require.tsx [new file with mode: 0644]
services/workbench2/src/validators/valid-name.tsx [new file with mode: 0644]
services/workbench2/src/validators/validators.tsx [new file with mode: 0644]
services/workbench2/src/views-components/add-session/add-session.tsx [new file with mode: 0644]
services/workbench2/src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/advanced-tab-dialog/metadataTab.tsx [new file with mode: 0644]
services/workbench2/src/views-components/api-client-authorizations-dialog/attributes-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/api-client-authorizations-dialog/help-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/api-client-authorizations-dialog/remove-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/api-token/api-token.tsx [new file with mode: 0644]
services/workbench2/src/views-components/auto-logout/auto-logout.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/auto-logout/auto-logout.tsx [new file with mode: 0644]
services/workbench2/src/views-components/baner/banner.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/baner/banner.tsx [new file with mode: 0644]
services/workbench2/src/views-components/breadcrumbs/breadcrumbs.ts [new file with mode: 0644]
services/workbench2/src/views-components/collection-panel-files/collection-panel-files.ts [new file with mode: 0644]
services/workbench2/src/views-components/collection-properties/create-collection-properties-form.tsx [new file with mode: 0644]
services/workbench2/src/views-components/collection-properties/update-collection-properties-form.tsx [new file with mode: 0644]
services/workbench2/src/views-components/collections-dialog/restore-version-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/api-client-authorization-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/collection-action-set.test.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/collection-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/collection-files-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/collection-files-not-selected-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/favorite-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/group-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/group-member-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/keep-service-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/link-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/permission-edit-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/process-resource-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/project-action-set.test.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/project-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/project-admin-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/repository-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/resource-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/root-project-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/search-results-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/ssh-key-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/trash-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/trashed-collection-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/user-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/action-sets/workflow-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/collection-copy-to-clipboard-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/collection-file-viewer-action.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/collection-file-viewer-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/context-menu-divider.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/copy-to-clipboard-action.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/download-action.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/download-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/download-collection-file-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/favorite-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/file-viewer-action.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/file-viewer-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/file-viewer-actions.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/helpers.test.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/helpers.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/lock-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/public-favorite-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/actions/trash-action.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/context-menu-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/context-menu.tsx [new file with mode: 0644]
services/workbench2/src/views-components/context-menu/menu-item-sort.ts [new file with mode: 0644]
services/workbench2/src/views-components/data-explorer/data-explorer.tsx [new file with mode: 0644]
services/workbench2/src/views-components/data-explorer/renderers.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/data-explorer/renderers.tsx [new file with mode: 0644]
services/workbench2/src/views-components/data-explorer/with-resources.tsx [new file with mode: 0644]
services/workbench2/src/views-components/details-panel/collection-details.tsx [new file with mode: 0644]
services/workbench2/src/views-components/details-panel/details-data.tsx [new file with mode: 0644]
services/workbench2/src/views-components/details-panel/details-panel.tsx [new file with mode: 0644]
services/workbench2/src/views-components/details-panel/empty-details.tsx [new file with mode: 0644]
services/workbench2/src/views-components/details-panel/file-details.tsx [new file with mode: 0644]
services/workbench2/src/views-components/details-panel/process-details.tsx [new file with mode: 0644]
services/workbench2/src/views-components/details-panel/project-details.tsx [new file with mode: 0644]
services/workbench2/src/views-components/details-panel/workflow-details.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-copy/dialog-copy.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-copy/dialog-process-rerun.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-create/dialog-collection-create.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-create/dialog-project-create.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-create/dialog-repository-create.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-create/dialog-ssh-key-create.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-create/dialog-user-create.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/copy-collection-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/copy-process-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/create-collection-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/create-project-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/create-repository-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/create-ssh-key-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/create-user-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/files-upload-collection-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/move-collection-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/move-process-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/move-project-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/partial-copy-to-existing-collection-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/partial-copy-to-new-collection-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/partial-copy-to-separate-collections-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/partial-move-to-existing-collection-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/partial-move-to-new-collection-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/partial-move-to-separate-collections-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/update-collection-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/update-process-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-forms/update-project-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-move/dialog-collection-partial-move-to-new-collection.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-move/dialog-collection-partial-move-to-separate-collections.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-move/dialog-move-to.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-update/dialog-collection-update.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-update/dialog-process-update.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-update/dialog-project-update.tsx [new file with mode: 0644]
services/workbench2/src/views-components/dialog-upload/dialog-collection-files-upload.tsx [new file with mode: 0644]
services/workbench2/src/views-components/favorite-star/favorite-star.tsx [new file with mode: 0644]
services/workbench2/src/views-components/file-remove-dialog/file-remove-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/file-remove-dialog/multiple-files-remove-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/file-uploader/file-uploader.tsx [new file with mode: 0644]
services/workbench2/src/views-components/form-fields/collection-form-fields.tsx [new file with mode: 0644]
services/workbench2/src/views-components/form-fields/process-form-fields.tsx [new file with mode: 0644]
services/workbench2/src/views-components/form-fields/project-form-fields.tsx [new file with mode: 0644]
services/workbench2/src/views-components/form-fields/repository-form-fields.tsx [new file with mode: 0644]
services/workbench2/src/views-components/form-fields/resource-form-fields.tsx [new file with mode: 0644]
services/workbench2/src/views-components/form-fields/search-bar-form-fields.tsx [new file with mode: 0644]
services/workbench2/src/views-components/form-fields/ssh-key-form-fields.tsx [new file with mode: 0644]
services/workbench2/src/views-components/form-fields/user-form-fields.tsx [new file with mode: 0644]
services/workbench2/src/views-components/groups-dialog/attributes-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/groups-dialog/member-attributes-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/groups-dialog/member-remove-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/groups-dialog/remove-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/keep-services-dialog/attributes-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/keep-services-dialog/remove-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/links-dialog/attributes-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/links-dialog/remove-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/login-form/login-form.tsx [new file with mode: 0644]
services/workbench2/src/views-components/main-app-bar/account-menu.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/main-app-bar/account-menu.tsx [new file with mode: 0644]
services/workbench2/src/views-components/main-app-bar/admin-menu.tsx [new file with mode: 0644]
services/workbench2/src/views-components/main-app-bar/anonymous-menu.tsx [new file with mode: 0644]
services/workbench2/src/views-components/main-app-bar/help-menu.tsx [new file with mode: 0644]
services/workbench2/src/views-components/main-app-bar/main-app-bar.tsx [new file with mode: 0644]
services/workbench2/src/views-components/main-app-bar/notifications-menu.tsx [new file with mode: 0644]
services/workbench2/src/views-components/main-content-bar/main-content-bar.tsx [new file with mode: 0644]
services/workbench2/src/views-components/multiselect-toolbar/ms-collection-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/multiselect-toolbar/ms-menu-actions.ts [new file with mode: 0644]
services/workbench2/src/views-components/multiselect-toolbar/ms-process-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/multiselect-toolbar/ms-project-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/multiselect-toolbar/ms-workflow-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/not-found-dialog/not-found-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/process-input-dialog/process-input-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/process-remove-dialog/process-remove-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/process-runtime-status/process-runtime-status.tsx [new file with mode: 0644]
services/workbench2/src/views-components/project-properties/create-project-properties-form.tsx [new file with mode: 0644]
services/workbench2/src/views-components/project-properties/update-project-properties-form.tsx [new file with mode: 0644]
services/workbench2/src/views-components/projects-tree-picker/favorites-tree-picker.tsx [new file with mode: 0644]
services/workbench2/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx [new file with mode: 0644]
services/workbench2/src/views-components/projects-tree-picker/home-tree-picker.tsx [new file with mode: 0644]
services/workbench2/src/views-components/projects-tree-picker/projects-tree-picker.tsx [new file with mode: 0644]
services/workbench2/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx [new file with mode: 0644]
services/workbench2/src/views-components/projects-tree-picker/search-projects-picker.tsx [new file with mode: 0644]
services/workbench2/src/views-components/projects-tree-picker/shared-tree-picker.tsx [new file with mode: 0644]
services/workbench2/src/views-components/projects-tree-picker/tree-picker-field.tsx [new file with mode: 0644]
services/workbench2/src/views-components/remove-dialog/remove-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/rename-file-dialog/rename-file-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/repository-attributes-dialog/repository-attributes-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/repository-remove-dialog/repository-remove-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/resource-properties-form/property-chip.tsx [new file with mode: 0644]
services/workbench2/src/views-components/resource-properties-form/property-field-common.tsx [new file with mode: 0644]
services/workbench2/src/views-components/resource-properties-form/property-key-field.tsx [new file with mode: 0644]
services/workbench2/src/views-components/resource-properties-form/property-value-field.tsx [new file with mode: 0644]
services/workbench2/src/views-components/resource-properties-form/resource-properties-form.tsx [new file with mode: 0644]
services/workbench2/src/views-components/resource-properties/resource-properties-list.tsx [new file with mode: 0644]
services/workbench2/src/views-components/rich-text-editor-dialog/rich-text-editor-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/run-process-dialog/change-workflow-dialog.ts [new file with mode: 0644]
services/workbench2/src/views-components/search-bar/search-bar-advanced-properties-view.tsx [new file with mode: 0644]
services/workbench2/src/views-components/search-bar/search-bar-advanced-view.tsx [new file with mode: 0644]
services/workbench2/src/views-components/search-bar/search-bar-autocomplete-view.tsx [new file with mode: 0644]
services/workbench2/src/views-components/search-bar/search-bar-basic-view.tsx [new file with mode: 0644]
services/workbench2/src/views-components/search-bar/search-bar-recent-queries.tsx [new file with mode: 0644]
services/workbench2/src/views-components/search-bar/search-bar-save-queries.tsx [new file with mode: 0644]
services/workbench2/src/views-components/search-bar/search-bar-view.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/search-bar/search-bar-view.tsx [new file with mode: 0644]
services/workbench2/src/views-components/search-bar/search-bar.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/participant-select.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/permission-select.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/select-item.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-dialog-component.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-dialog-component.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-invitation-form-component.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-invitation-form.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-management-form-component.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-management-form.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-public-access-form-component.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-public-access-form.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-urls-component.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-urls-component.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/sharing-urls.tsx [new file with mode: 0644]
services/workbench2/src/views-components/sharing-dialog/visibility-level-select.tsx [new file with mode: 0644]
services/workbench2/src/views-components/side-panel-button/side-panel-button.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/side-panel-button/side-panel-button.tsx [new file with mode: 0644]
services/workbench2/src/views-components/side-panel-toggle/side-panel-toggle.tsx [new file with mode: 0644]
services/workbench2/src/views-components/side-panel-tree/side-panel-tree.tsx [new file with mode: 0644]
services/workbench2/src/views-components/side-panel/side-panel-collapsed.tsx [new file with mode: 0644]
services/workbench2/src/views-components/side-panel/side-panel.tsx [new file with mode: 0644]
services/workbench2/src/views-components/snackbar/snackbar.tsx [new file with mode: 0644]
services/workbench2/src/views-components/ssh-keys-dialog/attributes-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/ssh-keys-dialog/public-key-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/ssh-keys-dialog/remove-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/token-dialog/token-dialog.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/token-dialog/token-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/tree-picker/tree-picker.ts [new file with mode: 0644]
services/workbench2/src/views-components/user-dialog/activate-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/user-dialog/attributes-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/user-dialog/deactivate-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/user-dialog/setup-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/virtual-machines-dialog/add-login-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/virtual-machines-dialog/attributes-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/virtual-machines-dialog/group-array-input.tsx [new file with mode: 0644]
services/workbench2/src/views-components/virtual-machines-dialog/remove-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/virtual-machines-dialog/remove-login-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views-components/webdav-s3-dialog/webdav-s3-dialog.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/webdav-s3-dialog/webdav-s3-dialog.tsx [new file with mode: 0644]
services/workbench2/src/views/all-processes-panel/all-processes-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/api-client-authorization-panel/api-client-authorization-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/api-client-authorization-panel/api-client-authorization-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/collection-content-address-panel/collection-content-address-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/collection-panel/collection-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/favorite-panel/favorite-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/group-details-panel/group-details-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/groups-panel/groups-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/inactive-panel/inactive-panel.test.tsx [new file with mode: 0644]
services/workbench2/src/views/inactive-panel/inactive-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/instance-types-panel/instance-types-panel.test.tsx [new file with mode: 0644]
services/workbench2/src/views/instance-types-panel/instance-types-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/keep-service-panel/keep-service-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/keep-service-panel/keep-service-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/link-account-panel/link-account-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/link-account-panel/link-account-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/link-panel/link-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/link-panel/link-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/login-panel/login-panel.test.tsx [new file with mode: 0644]
services/workbench2/src/views/login-panel/login-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/main-panel/main-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/main-panel/main-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/not-found-panel/not-found-panel-root.test.tsx [new file with mode: 0644]
services/workbench2/src/views/not-found-panel/not-found-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/not-found-panel/not-found-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-cmd-card.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-details-attributes.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-details-card.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-io-card.test.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-io-card.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-log-card.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-log-code-snippet.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-log-form.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-output-collection-files.ts [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-resource-card.tsx [new file with mode: 0644]
services/workbench2/src/views/project-panel/project-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/public-favorites-panel/public-favorites-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/repositories-panel/repositories-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/boolean-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/directory-array-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/directory-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/enum-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/file-array-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/file-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/float-array-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/float-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/generic-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/int-array-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/int-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/project-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/string-array-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/inputs/string-input.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/run-process-advanced-form.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/run-process-basic-form.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/run-process-first-step.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/run-process-inputs-form.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/run-process-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/run-process-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/run-process-second-step.tsx [new file with mode: 0644]
services/workbench2/src/views/run-process-panel/workflow-preset-select.tsx [new file with mode: 0644]
services/workbench2/src/views/search-results-panel/search-results-panel-view.tsx [new file with mode: 0644]
services/workbench2/src/views/search-results-panel/search-results-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/shared-with-me-panel/shared-with-me-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/site-manager-panel/site-manager-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/site-manager-panel/site-manager-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/ssh-key-panel/ssh-key-admin-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/ssh-key-panel/ssh-key-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/ssh-key-panel/ssh-key-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/subprocess-panel/subprocess-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/subprocess-panel/subprocess-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/trash-panel/trash-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/user-panel/user-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/user-profile-panel/user-profile-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/user-profile-panel/user-profile-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/workbench/fed-login.tsx [new file with mode: 0644]
services/workbench2/src/views/workbench/workbench-loading-screen.tsx [new file with mode: 0644]
services/workbench2/src/views/workbench/workbench.test.tsx [new file with mode: 0644]
services/workbench2/src/views/workbench/workbench.tsx [new file with mode: 0644]
services/workbench2/src/views/workflow-panel/registered-workflow-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/workflow-panel/workflow-description-card.tsx [new file with mode: 0644]
services/workbench2/src/views/workflow-panel/workflow-graph.tsx [new file with mode: 0644]
services/workbench2/src/views/workflow-panel/workflow-panel-view.tsx [new file with mode: 0644]
services/workbench2/src/views/workflow-panel/workflow-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/workflow-panel/workflow-processes-panel-root.tsx [new file with mode: 0644]
services/workbench2/src/views/workflow-panel/workflow-processes-panel.tsx [new file with mode: 0644]
services/workbench2/src/websocket/resource-event-message.ts [new file with mode: 0644]
services/workbench2/src/websocket/websocket-service.ts [new file with mode: 0644]
services/workbench2/src/websocket/websocket.ts [new file with mode: 0644]
services/workbench2/tools/arvados_config.yml [new file with mode: 0644]
services/workbench2/tools/example-vocabulary.json [new file with mode: 0644]
services/workbench2/tools/run-integration-tests.sh [new file with mode: 0755]
services/workbench2/tsconfig.json [new file with mode: 0644]
services/workbench2/tsconfig.prod.json [new file with mode: 0644]
services/workbench2/tsconfig.test.json [new file with mode: 0644]
services/workbench2/tslint.json [new file with mode: 0644]
services/workbench2/typings/global.d.ts [new file with mode: 0644]
services/workbench2/typings/images.d.ts [new file with mode: 0644]
services/workbench2/version-at-commit.sh [new file with mode: 0755]
services/workbench2/yarn.lock [new file with mode: 0644]
services/ws/doc.go
services/ws/event.go
services/ws/event_source.go
services/ws/event_source_test.go
services/ws/handler.go
services/ws/permission.go
services/ws/permission_test.go
services/ws/service.go
services/ws/session_v0.go
services/ws/session_v0_test.go
tools/arvbox/bin/arvbox
tools/arvbox/lib/arvbox/docker/Dockerfile.base
tools/arvbox/lib/arvbox/docker/Dockerfile.demo
tools/arvbox/lib/arvbox/docker/Dockerfile.dev
tools/arvbox/lib/arvbox/docker/cluster-config.sh
tools/arvbox/lib/arvbox/docker/common.sh
tools/arvbox/lib/arvbox/docker/createusers.sh
tools/arvbox/lib/arvbox/docker/edit_users.py
tools/arvbox/lib/arvbox/docker/go-setup.sh
tools/arvbox/lib/arvbox/docker/service/doc/run-service
tools/arvbox/lib/arvbox/docker/service/nginx/run
tools/arvbox/lib/arvbox/docker/service/postgres/run-service
tools/arvbox/lib/arvbox/docker/service/ready/run-service
tools/arvbox/lib/arvbox/docker/service/sdk/run-service
tools/arvbox/lib/arvbox/docker/service/workbench/log/main/.gitstub [deleted file]
tools/arvbox/lib/arvbox/docker/service/workbench/log/run [deleted symlink]
tools/arvbox/lib/arvbox/docker/service/workbench/run [deleted file]
tools/arvbox/lib/arvbox/docker/service/workbench/run-service [deleted file]
tools/arvbox/lib/arvbox/docker/service/workbench2/run-service
tools/arvbox/lib/arvbox/docker/yml_override.py
tools/compute-images/arvados-images-aws.json
tools/compute-images/scripts/base.sh
tools/compute-images/scripts/usr-local-bin-ensure-encrypted-partitions-aws-ebs-autoscale.sh
tools/compute-images/scripts/usr-local-bin-ensure-encrypted-partitions.sh
tools/crunchstat-summary/arvados_version.py
tools/crunchstat-summary/crunchstat_summary/__init__.py
tools/crunchstat-summary/crunchstat_summary/command.py
tools/crunchstat-summary/crunchstat_summary/dygraphs.js
tools/crunchstat-summary/crunchstat_summary/reader.py
tools/crunchstat-summary/crunchstat_summary/summarizer.py
tools/crunchstat-summary/crunchstat_summary/webchart.py
tools/crunchstat-summary/setup.py
tools/crunchstat-summary/tests/container_9tee4-dz642-lymtndkpy39eibk.txt.gz.report
tools/crunchstat-summary/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-arv-mount.txt.gz.report
tools/crunchstat-summary/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-crunchstat.txt.gz
tools/crunchstat-summary/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-crunchstat.txt.gz.report
tools/crunchstat-summary/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y.txt.gz.report
tools/crunchstat-summary/tests/crunchstat_error_messages.txt
tools/crunchstat-summary/tests/logfile_20151204190335.txt.gz [deleted file]
tools/crunchstat-summary/tests/logfile_20151204190335.txt.gz.report [deleted file]
tools/crunchstat-summary/tests/logfile_20151210063411.txt.gz [deleted file]
tools/crunchstat-summary/tests/logfile_20151210063411.txt.gz.report [deleted file]
tools/crunchstat-summary/tests/logfile_20151210063439.txt.gz [deleted file]
tools/crunchstat-summary/tests/logfile_20151210063439.txt.gz.report [deleted file]
tools/crunchstat-summary/tests/test_examples.py
tools/keep-block-check/keep-block-check_test.go
tools/keep-exercise/keep-exercise.go
tools/keep-rsync/keep-rsync.go
tools/keep-rsync/keep-rsync_test.go
tools/salt-install/common.sh [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/certs/README.md
tools/salt-install/config_examples/multi_host/aws/dashboards/arvados_overview.json [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/dashboards/node-exporter-full_rev30.json [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/dashboards/postgresql_exporter.json [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/dashboards/ssl-certificate-monitor.json [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/arvados.sls
tools/salt-install/config_examples/multi_host/aws/pillars/grafana.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_balancer_configuration.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_controller_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_grafana_configuration.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_keepproxy_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_keepweb_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_prometheus_configuration.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_webshell_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_websocket_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_workbench2_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_workbench_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_api_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_balancer_configuration.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_collections_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_controller_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_download_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_grafana_configuration.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_keepproxy_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_keepweb_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_passenger.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_prometheus_configuration.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_snippets.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_webshell_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_websocket_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_workbench2_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/nginx_workbench_configuration.sls
tools/salt-install/config_examples/multi_host/aws/pillars/postgresql.sls
tools/salt-install/config_examples/multi_host/aws/pillars/prometheus_node_exporter.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/prometheus_pg_exporter.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/pillars/prometheus_server.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/states/custom_certs.sls
tools/salt-install/config_examples/multi_host/aws/states/grafana_admin_user.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/states/grafana_dashboards.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/states/grafana_datasource.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/states/host_entries.sls
tools/salt-install/config_examples/multi_host/aws/states/nginx_prometheus_configuration.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/states/prometheus_pg_exporter.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/states/workbench1_uninstall.sls [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/tofs/arvados/shell/config/files/default/shell-pam-shellinabox.tmpl.jinja [new file with mode: 0644]
tools/salt-install/config_examples/multi_host/aws/tofs/arvados/shell/config/files/default/shell-shellinabox.tmpl.jinja [new file with mode: 0644]
tools/salt-install/config_examples/single_host/multiple_hostnames/pillars/arvados.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/pillars/nginx_keepproxy_configuration.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/pillars/nginx_keepweb_configuration.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/pillars/nginx_webshell_configuration.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/pillars/nginx_websocket_configuration.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/pillars/nginx_workbench2_configuration.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/pillars/nginx_workbench_configuration.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/pillars/postgresql.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/states/custom_certs.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/states/snakeoil_certs.sls
tools/salt-install/config_examples/single_host/multiple_hostnames/states/workbench1_uninstall.sls [new file with mode: 0644]
tools/salt-install/config_examples/single_host/single_hostname/pillars/arvados.sls
tools/salt-install/config_examples/single_host/single_hostname/pillars/postgresql.sls
tools/salt-install/config_examples/single_host/single_hostname/states/custom_certs.sls
tools/salt-install/config_examples/single_host/single_hostname/states/snakeoil_certs.sls
tools/salt-install/config_examples/single_host/single_hostname/states/workbench1_uninstall.sls [new file with mode: 0644]
tools/salt-install/installer.sh
tools/salt-install/local.params.example.multiple_hosts
tools/salt-install/local.params.example.single_host_multiple_hostnames
tools/salt-install/local.params.example.single_host_single_hostname
tools/salt-install/local.params.secrets.example [new file with mode: 0644]
tools/salt-install/provision.sh
tools/salt-install/terraform/aws/data-storage/locals.tf
tools/salt-install/terraform/aws/data-storage/main.tf
tools/salt-install/terraform/aws/data-storage/outputs.tf
tools/salt-install/terraform/aws/services/data.tf
tools/salt-install/terraform/aws/services/locals.tf
tools/salt-install/terraform/aws/services/main.tf
tools/salt-install/terraform/aws/services/outputs.tf
tools/salt-install/terraform/aws/services/terraform.tfvars
tools/salt-install/terraform/aws/services/user_data.sh
tools/salt-install/terraform/aws/services/variables.tf
tools/salt-install/terraform/aws/vpc/locals.tf
tools/salt-install/terraform/aws/vpc/main.tf
tools/salt-install/terraform/aws/vpc/outputs.tf
tools/salt-install/terraform/aws/vpc/terraform.tfvars
tools/salt-install/terraform/aws/vpc/variables.tf
tools/user-activity/arvados_user_activity/main.py
tools/user-activity/arvados_version.py
tools/user-activity/setup.py

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644 (file)
index 0000000..f8224e4
--- /dev/null
@@ -0,0 +1,40 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+name: Arvados Tests
+
+on:
+  workflow_dispatch:
+  pull_request:
+    branches:
+      - main
+
+jobs:
+  workbench2:
+    name: Workbench2 Tests
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      - name: Setup buildx
+        uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb # v3.3.0
+      - name: Build wb2 test container
+        uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
+        with:
+          context: .
+          file: "services/workbench2/docker/Dockerfile"
+          tags: workbench2-test:latest
+          load: true
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+          push: false
+      - name: Run wb2 integration tests
+        uses: addnab/docker-run-action@4f65fabd2431ebc8d299f8e5a018d79a769ae185 # v3
+        with:
+          image: workbench2-test:latest
+          options: -v ${{github.workspace}}:/usr/src/arvados -w /usr/src/arvados/services/workbench2
+          run: |
+            yarn install
+            yarn test --no-watchAll --bail --ci || exit $?
+            tools/run-integration-tests.sh -a /usr/src/arvados
index c156018036e74a3582e922d97b610d450d5fbac9..557386b99cb296db1d960254f6e18b85754e5952 100644 (file)
@@ -12,6 +12,9 @@ docker/*/generated
 docker/config.yml
 doc/.site
 doc/sdk/python/arvados
+doc/sdk/python/arvados.html
+doc/sdk/python/index.html
+doc/sdk/python/search.js
 doc/sdk/R/arvados
 doc/sdk/java-v2/javadoc
 */vendor
index 3d24c4ee3aaaf3728ff71bb34a817f805d957648..1e1c12a53a79a2a46a0865bf863f8933691e3ba6 100644 (file)
@@ -1,19 +1,19 @@
 *agpl-3.0.html
 *agpl-3.0.txt
 apache-2.0.txt
-apps/workbench/app/assets/javascripts/list.js
-apps/workbench/public/webshell/*
 AUTHORS
 */bootstrap.css
 */bootstrap.js
 *bootstrap-theme.css
 build/package-test-dockerfiles/centos7/localrepo.repo
+build/package-test-dockerfiles/rocky8/localrepo.repo
 build/package-test-dockerfiles/ubuntu1604/etc-apt-preferences.d-arvados
 *by-sa-3.0.html
 *by-sa-3.0.txt
 *COPYING
 doc/fonts/*
 doc/_includes/_config_default_yml.liquid
+doc/_includes/_terraform_*_tfvars.liquid
 doc/user/cwl/federated/*
 doc/_includes/_federated_cwl.liquid
 */docker_image
@@ -53,6 +53,8 @@ sdk/cwl/tests/tool/blub.txt
 sdk/cwl/tests/19109-upload-secondary/*
 sdk/cwl/tests/federation/data/*
 sdk/cwl/tests/fake-keep-mount/fake_collection_dir/.arvados#collection
+sdk/cwl/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-arv-mount.txt
+sdk/cwl/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-crunchstat.txt
 sdk/go/manifest/testdata/*_manifest
 sdk/java/.classpath
 sdk/java/pom.xml
@@ -93,4 +95,43 @@ sdk/cwl/tests/wf/indir1/hello2.txt
 sdk/cwl/tests/chipseq/data/Genomes/*
 CITATION.cff
 SECURITY.md
-*/testdata/fakestat/*
+lib/crunchstat/testdata/*
+lib/controller/localdb/testdata/*.pub
+sdk/ruby-google-api-client/*
+services/api/bin/rails
+services/api/bin/rake
+services/api/bin/setup
+services/api/bin/yarn
+services/api/storage.yml
+services/api/test.rb.example
+services/api/config/boot.rb
+services/api/config/environment.rb
+services/api/config/initializers/application_controller_renderer.rb
+services/api/config/initializers/assets.rb
+services/api/config/initializers/backtrace_silencers.rb
+services/api/config/initializers/content_security_policy.rb
+services/api/config/initializers/cookies_serializer.rb
+services/api/config/initializers/filter_parameter_logging.rb
+services/api/config/initializers/mime_types.rb
+services/api/config/initializers/new_framework_defaults_*.rb
+services/api/config/initializers/permissions_policy.rb
+services/api/config/initializers/wrap_parameters.rb
+services/api/config/locales/en.yml
+services/api/config.ru
+services/workbench2/*.d.ts
+services/workbench2/*.css
+services/workbench2/*.scss
+services/workbench2/README.md
+services/workbench2/public/*
+services/workbench2/.yarnrc
+services/workbench2/.npmrc
+services/workbench2/src/lib/cwl-svg/*
+services/workbench2/tools/arvados_config.yml
+services/workbench2/cypress/fixtures/files/5mb.bin
+services/workbench2/cypress/fixtures/files/cat.png
+services/workbench2/cypress/fixtures/files/banner.html
+services/workbench2/cypress/fixtures/files/tooltips.txt
+services/workbench2/cypress/fixtures/webdav-propfind-outputs.xml
+services/workbench2/.yarn/releases/*
+services/workbench2/package.json
+services/workbench2/yarn.lock
diff --git a/AUTHORS b/AUTHORS
index fa9fa86d34efe9aae853ca24d717974eb8513edc..cb09dc67ae1030a77c4f1e2cd4538700d952f664 100644 (file)
--- a/AUTHORS
+++ b/AUTHORS
@@ -22,3 +22,4 @@ Curii Corporation <*@curii.com>
 Dante Tsang <dante@dantetsang.com>
 Codex Genetics Ltd <info@codexgenetics.com>
 Bruno P. Kinoshita <brunodepaulak@yahoo.com.br>
+George Chlipala <gchlip2@uic.edu>
diff --git a/apps/workbench/.gitignore b/apps/workbench/.gitignore
deleted file mode 100644 (file)
index fa42a32..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-# Ignore the default SQLite database.
-/db/*.sqlite3
-
-# Ignore all logfiles and tempfiles.
-/log/*.log
-/log/*.log.gz
-/tmp
-.byebug_history
-
-package-lock.json
-
-/config/.secret_token
-/config/initializers/secret_token.rb
-
-/public/assets
-
-/config/environments/development.rb
-/config/environments/production.rb
-/config/application.yml
-
-# Workbench doesn't need one anyway, so this shouldn't come up, but...
-/config/database.yml
-
-/config/piwik.yml
-
-# Capistrano files are coming from another repo
-/Capfile*
-/config/deploy*
-
-# Themes are coming from another repo
-/themes/*
-
-# This can be a symlink to ../../../doc/.site in dev setups
-/public/doc
-
-# SimpleCov reports
-/coverage
-
-# Dev/test SSL certificates
-/self-signed.key
-/self-signed.pem
-
-# Generated git-commit.version file
-/git-commit.version
-
-# npm-rails
-/node_modules
-/npm-debug.log
-
-# Generated when building distribution packages
-/package-build.version
diff --git a/apps/workbench/Gemfile b/apps/workbench/Gemfile
deleted file mode 100644 (file)
index 00dbad0..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-source 'https://rubygems.org'
-
-gem 'rails', '~> 5.2.0'
-gem 'arvados', '~> 2.1.5'
-
-gem 'activerecord-nulldb-adapter', git: 'https://github.com/arvados/nulldb'
-gem 'multi_json'
-gem 'oj'
-gem 'sass'
-gem 'mime-types'
-gem 'responders', '~> 2.0'
-
-# Pin sprockets to < 4.0 to avoid issues when upgrading rails to 5.2
-# See: https://github.com/rails/sprockets-rails/issues/443
-gem 'sprockets', '~> 3.0'
-
-# Gems used only for assets and not required
-# in production environments by default.
-group :assets do
-  gem 'sassc-rails'
-  gem 'uglifier', '~> 2.0'
-
-  # See https://github.com/rails/execjs#readme for more supported runtimes
-end
-
-group :development, :test, :performance do
-  gem 'byebug'
-  # Pinning launchy because 2.5 requires ruby >= 2.4, which arvbox currently
-  # doesn't have because of SSO.
-  gem 'launchy', '~> 2.4.0'
-end
-
-group :development do
-  gem 'ruby-debug-passenger'
-  gem 'rack-mini-profiler', require: false
-  gem 'flamegraph', require: false
-  #gem 'web-console', '~> 2.0'
-end
-
-group :test, :diagnostics, :performance do
-  gem 'minitest', '~> 5.10.3'
-  gem 'selenium-webdriver', '~> 3'
-  gem 'capybara', '~> 2.5.0'
-  gem 'poltergeist', '~> 1.5.1'
-  gem 'headless', '~> 1.0.2'
-end
-
-group :test, :performance do
-  gem 'rails-perftest'
-  gem 'ruby-prof'
-  gem 'rvm-capistrano'
-  # Note: "require: false" here tells bunder not to automatically
-  # 'require' the packages during application startup. Installation is
-  # still mandatory.
-  gem 'simplecov', '~> 0.7', require: false
-  gem 'simplecov-rcov', require: false
-  gem 'mocha', require: false
-  gem 'rails-controller-testing'
-end
-
-gem 'jquery-rails'
-gem 'bootstrap-sass', '~> 3.4.1'
-gem 'bootstrap-x-editable-rails'
-gem 'bootstrap-tab-history-rails'
-
-gem 'angularjs-rails', '~> 1.3.8'
-
-gem 'sshkey'
-
-# To use ActiveModel has_secure_password
-# gem 'bcrypt-ruby', '~> 3.0.0'
-
-# To use Jbuilder templates for JSON
-# gem 'jbuilder'
-
-# Use unicorn as the app server
-# gem 'unicorn'
-
-# Deploy with Capistrano
-# gem 'capistrano'
-
-gem 'passenger', :group => :production
-gem 'andand'
-gem 'RedCloth'
-
-gem 'piwik_analytics'
-gem 'httpclient', '~> 2.5'
-
-# This fork has Rails 4 compatible routes
-gem 'themes_for_rails', git: 'https://github.com/arvados/themes_for_rails'
-
-gem "deep_merge", :require => 'deep_merge/rails_compat'
-
-gem 'morrisjs-rails'
-gem 'raphael-rails'
-
-gem 'lograge'
-gem 'logstash-event'
-
-gem 'safe_yaml'
-
-gem 'npm-rails'
-
-# arvados-google-api-client and googleauth (and thus arvados) gems
-# depend on signet, but signet 0.12 is incompatible with ruby 2.3.
-gem 'signet', '< 0.12'
diff --git a/apps/workbench/Gemfile.lock b/apps/workbench/Gemfile.lock
deleted file mode 100644 (file)
index a22214a..0000000
+++ /dev/null
@@ -1,359 +0,0 @@
-GIT
-  remote: https://github.com/arvados/nulldb
-  revision: d8e0073b665acdd2537c5eb15178a60f02f4b413
-  specs:
-    activerecord-nulldb-adapter (0.3.9)
-      activerecord (>= 2.0.0)
-
-GIT
-  remote: https://github.com/arvados/themes_for_rails
-  revision: ddf6e592b3b6493ea0c2de7b5d3faa120ed35be0
-  specs:
-    themes_for_rails (0.5.1)
-      rails (>= 3.0.0)
-
-GEM
-  remote: https://rubygems.org/
-  specs:
-    RedCloth (4.3.2)
-    actioncable (5.2.8.1)
-      actionpack (= 5.2.8.1)
-      nio4r (~> 2.0)
-      websocket-driver (>= 0.6.1)
-    actionmailer (5.2.8.1)
-      actionpack (= 5.2.8.1)
-      actionview (= 5.2.8.1)
-      activejob (= 5.2.8.1)
-      mail (~> 2.5, >= 2.5.4)
-      rails-dom-testing (~> 2.0)
-    actionpack (5.2.8.1)
-      actionview (= 5.2.8.1)
-      activesupport (= 5.2.8.1)
-      rack (~> 2.0, >= 2.0.8)
-      rack-test (>= 0.6.3)
-      rails-dom-testing (~> 2.0)
-      rails-html-sanitizer (~> 1.0, >= 1.0.2)
-    actionview (5.2.8.1)
-      activesupport (= 5.2.8.1)
-      builder (~> 3.1)
-      erubi (~> 1.4)
-      rails-dom-testing (~> 2.0)
-      rails-html-sanitizer (~> 1.0, >= 1.0.3)
-    activejob (5.2.8.1)
-      activesupport (= 5.2.8.1)
-      globalid (>= 0.3.6)
-    activemodel (5.2.8.1)
-      activesupport (= 5.2.8.1)
-    activerecord (5.2.8.1)
-      activemodel (= 5.2.8.1)
-      activesupport (= 5.2.8.1)
-      arel (>= 9.0)
-    activestorage (5.2.8.1)
-      actionpack (= 5.2.8.1)
-      activerecord (= 5.2.8.1)
-      marcel (~> 1.0.0)
-    activesupport (5.2.8.1)
-      concurrent-ruby (~> 1.0, >= 1.0.2)
-      i18n (>= 0.7, < 2)
-      minitest (~> 5.1)
-      tzinfo (~> 1.1)
-    addressable (2.8.0)
-      public_suffix (>= 2.0.2, < 5.0)
-    andand (1.3.3)
-    angularjs-rails (1.3.15)
-    arel (9.0.0)
-    arvados (2.1.5)
-      activesupport (>= 3)
-      andand (~> 1.3, >= 1.3.3)
-      arvados-google-api-client (>= 0.7, < 0.8.9)
-      faraday (< 0.16)
-      i18n (~> 0)
-      json (>= 1.7.7, < 3)
-      jwt (>= 0.1.5, < 2)
-    arvados-google-api-client (0.8.7.4)
-      activesupport (>= 3.2, < 5.3)
-      addressable (~> 2.3)
-      autoparse (~> 0.3)
-      extlib (~> 0.9)
-      faraday (~> 0.9)
-      googleauth (~> 0.3)
-      launchy (~> 2.4)
-      multi_json (~> 1.10)
-      retriable (~> 1.4)
-      signet (~> 0.6)
-    autoparse (0.3.3)
-      addressable (>= 2.3.1)
-      extlib (>= 0.9.15)
-      multi_json (>= 1.0.0)
-    autoprefixer-rails (9.5.1.1)
-      execjs
-    bootstrap-sass (3.4.1)
-      autoprefixer-rails (>= 5.2.1)
-      sassc (>= 2.0.0)
-    bootstrap-tab-history-rails (0.1.0)
-      railties (>= 3.1)
-    bootstrap-x-editable-rails (1.5.1.1)
-      railties (>= 3.0)
-    builder (3.2.4)
-    byebug (11.0.1)
-    capistrano (2.15.9)
-      highline
-      net-scp (>= 1.0.0)
-      net-sftp (>= 2.0.0)
-      net-ssh (>= 2.0.14)
-      net-ssh-gateway (>= 1.1.0)
-    capybara (2.5.0)
-      mime-types (>= 1.16)
-      nokogiri (>= 1.3.3)
-      rack (>= 1.0.0)
-      rack-test (>= 0.5.4)
-      xpath (~> 2.0)
-    childprocess (0.9.0)
-      ffi (~> 1.0, >= 1.0.11)
-    cliver (0.3.2)
-    concurrent-ruby (1.1.10)
-    crass (1.0.6)
-    deep_merge (1.2.1)
-    docile (1.3.1)
-    erubi (1.10.0)
-    execjs (2.7.0)
-    extlib (0.9.16)
-    faraday (0.15.4)
-      multipart-post (>= 1.2, < 3)
-    ffi (1.10.0)
-    flamegraph (0.9.5)
-    globalid (1.0.0)
-      activesupport (>= 5.0)
-    googleauth (0.9.0)
-      faraday (~> 0.12)
-      jwt (>= 1.4, < 3.0)
-      memoist (~> 0.16)
-      multi_json (~> 1.11)
-      os (>= 0.9, < 2.0)
-      signet (~> 0.7)
-    headless (1.0.2)
-    highline (2.0.2)
-    httpclient (2.8.3)
-    i18n (0.9.5)
-      concurrent-ruby (~> 1.0)
-    jquery-rails (4.3.3)
-      rails-dom-testing (>= 1, < 3)
-      railties (>= 4.2.0)
-      thor (>= 0.14, < 2.0)
-    json (2.5.1)
-    jwt (1.5.6)
-    launchy (2.4.3)
-      addressable (~> 2.3)
-    lograge (0.10.0)
-      actionpack (>= 4)
-      activesupport (>= 4)
-      railties (>= 4)
-      request_store (~> 1.0)
-    logstash-event (1.2.02)
-    loofah (2.19.1)
-      crass (~> 1.0.2)
-      nokogiri (>= 1.5.9)
-    mail (2.7.1)
-      mini_mime (>= 0.1.1)
-    marcel (1.0.2)
-    memoist (0.16.2)
-    metaclass (0.0.4)
-    method_source (1.0.0)
-    mime-types (3.2.2)
-      mime-types-data (~> 3.2015)
-    mime-types-data (3.2019.0331)
-    mini_mime (1.1.2)
-    mini_portile2 (2.8.0)
-    minitest (5.10.3)
-    mocha (1.8.0)
-      metaclass (~> 0.0.1)
-    morrisjs-rails (0.5.1.2)
-      railties (> 3.1, < 6)
-    multi_json (1.15.0)
-    multipart-post (2.1.1)
-    net-scp (2.0.0)
-      net-ssh (>= 2.6.5, < 6.0.0)
-    net-sftp (2.1.2)
-      net-ssh (>= 2.6.5)
-    net-ssh (5.2.0)
-    net-ssh-gateway (2.0.0)
-      net-ssh (>= 4.0.0)
-    nio4r (2.5.8)
-    nokogiri (1.13.10)
-      mini_portile2 (~> 2.8.0)
-      racc (~> 1.4)
-    npm-rails (0.2.1)
-      rails (>= 3.2)
-    oj (3.7.12)
-    os (1.1.1)
-    passenger (6.0.15)
-      rack
-      rake (>= 0.8.1)
-    piwik_analytics (1.0.2)
-      actionpack
-      activesupport
-      rails (>= 3.0.0)
-    poltergeist (1.5.1)
-      capybara (~> 2.1)
-      cliver (~> 0.3.1)
-      multi_json (~> 1.0)
-      websocket-driver (>= 0.2.0)
-    public_suffix (4.0.6)
-    racc (1.6.1)
-    rack (2.2.4)
-    rack-mini-profiler (1.0.2)
-      rack (>= 1.2.0)
-    rack-test (2.0.2)
-      rack (>= 1.3)
-    rails (5.2.8.1)
-      actioncable (= 5.2.8.1)
-      actionmailer (= 5.2.8.1)
-      actionpack (= 5.2.8.1)
-      actionview (= 5.2.8.1)
-      activejob (= 5.2.8.1)
-      activemodel (= 5.2.8.1)
-      activerecord (= 5.2.8.1)
-      activestorage (= 5.2.8.1)
-      activesupport (= 5.2.8.1)
-      bundler (>= 1.3.0)
-      railties (= 5.2.8.1)
-      sprockets-rails (>= 2.0.0)
-    rails-controller-testing (1.0.4)
-      actionpack (>= 5.0.1.x)
-      actionview (>= 5.0.1.x)
-      activesupport (>= 5.0.1.x)
-    rails-dom-testing (2.0.3)
-      activesupport (>= 4.2.0)
-      nokogiri (>= 1.6)
-    rails-html-sanitizer (1.4.4)
-      loofah (~> 2.19, >= 2.19.1)
-    rails-perftest (0.0.7)
-    railties (5.2.8.1)
-      actionpack (= 5.2.8.1)
-      activesupport (= 5.2.8.1)
-      method_source
-      rake (>= 0.8.7)
-      thor (>= 0.19.0, < 2.0)
-    rake (13.0.6)
-    raphael-rails (2.1.2)
-    rb-fsevent (0.10.3)
-    rb-inotify (0.10.0)
-      ffi (~> 1.0)
-    request_store (1.4.1)
-      rack (>= 1.4)
-    responders (2.4.1)
-      actionpack (>= 4.2.0, < 6.0)
-      railties (>= 4.2.0, < 6.0)
-    retriable (1.4.1)
-    ruby-debug-passenger (0.2.0)
-    ruby-prof (0.17.0)
-    rubyzip (1.3.0)
-    rvm-capistrano (1.5.6)
-      capistrano (~> 2.15.4)
-    safe_yaml (1.0.5)
-    sass (3.7.4)
-      sass-listen (~> 4.0.0)
-    sass-listen (4.0.0)
-      rb-fsevent (~> 0.9, >= 0.9.4)
-      rb-inotify (~> 0.9, >= 0.9.7)
-    sassc (2.0.1)
-      ffi (~> 1.9)
-      rake
-    sassc-rails (2.1.0)
-      railties (>= 4.0.0)
-      sassc (>= 2.0)
-      sprockets (> 3.0)
-      sprockets-rails
-      tilt
-    selenium-webdriver (3.141.0)
-      childprocess (~> 0.5)
-      rubyzip (~> 1.2, >= 1.2.2)
-    signet (0.11.0)
-      addressable (~> 2.3)
-      faraday (~> 0.9)
-      jwt (>= 1.5, < 3.0)
-      multi_json (~> 1.10)
-    simplecov (0.16.1)
-      docile (~> 1.1)
-      json (>= 1.8, < 3)
-      simplecov-html (~> 0.10.0)
-    simplecov-html (0.10.2)
-    simplecov-rcov (0.2.3)
-      simplecov (>= 0.4.1)
-    sprockets (3.7.2)
-      concurrent-ruby (~> 1.0)
-      rack (> 1, < 3)
-    sprockets-rails (3.4.2)
-      actionpack (>= 5.2)
-      activesupport (>= 5.2)
-      sprockets (>= 3.0.0)
-    sshkey (2.0.0)
-    thor (1.2.1)
-    thread_safe (0.3.6)
-    tilt (2.0.9)
-    tzinfo (1.2.10)
-      thread_safe (~> 0.1)
-    uglifier (2.7.2)
-      execjs (>= 0.3.0)
-      json (>= 1.8.0)
-    websocket-driver (0.7.5)
-      websocket-extensions (>= 0.1.0)
-    websocket-extensions (0.1.5)
-    xpath (2.1.0)
-      nokogiri (~> 1.3)
-
-PLATFORMS
-  ruby
-
-DEPENDENCIES
-  RedCloth
-  activerecord-nulldb-adapter!
-  andand
-  angularjs-rails (~> 1.3.8)
-  arvados (~> 2.1.5)
-  bootstrap-sass (~> 3.4.1)
-  bootstrap-tab-history-rails
-  bootstrap-x-editable-rails
-  byebug
-  capybara (~> 2.5.0)
-  deep_merge
-  flamegraph
-  headless (~> 1.0.2)
-  httpclient (~> 2.5)
-  jquery-rails
-  launchy (~> 2.4.0)
-  lograge
-  logstash-event
-  mime-types
-  minitest (~> 5.10.3)
-  mocha
-  morrisjs-rails
-  multi_json
-  npm-rails
-  oj
-  passenger
-  piwik_analytics
-  poltergeist (~> 1.5.1)
-  rack-mini-profiler
-  rails (~> 5.2.0)
-  rails-controller-testing
-  rails-perftest
-  raphael-rails
-  responders (~> 2.0)
-  ruby-debug-passenger
-  ruby-prof
-  rvm-capistrano
-  safe_yaml
-  sass
-  sassc-rails
-  selenium-webdriver (~> 3)
-  signet (< 0.12)
-  simplecov (~> 0.7)
-  simplecov-rcov
-  sprockets (~> 3.0)
-  sshkey
-  themes_for_rails!
-  uglifier (~> 2.0)
-
-BUNDLED WITH
-   2.2.19
diff --git a/apps/workbench/README.textile b/apps/workbench/README.textile
deleted file mode 100644 (file)
index 18380ac..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-###. Copyright (C) The Arvados Authors. All rights reserved.
-....
-.... SPDX-License-Identifier: AGPL-3.0
-
-h1. Developing Workbench
-
-This document includes information to help developers who would like to contribute to Workbench.  If you just want to install it, please refer to our "Workbench installation guide":http://doc.arvados.org/install/install-workbench-app.html.
-
-h2. Running tests
-
-The Workbench application includes a series of integration tests.  When you run these, it starts the API server in a test environment, with all of its fixtures loaded, then tests Workbench by starting that server and making requests against it.
-
-In order for this to work, you must have Firefox installed (or Iceweasel, if you're running Debian), as well as the X Virtual Frame Buffer driver.
-
-<pre>
-$ sudo apt-get install iceweasel xvfb
-</pre>
-
-If you install the Workbench Bundle in deployment mode, you must also install the API server Bundle in deployment mode, and vice versa.  If your Bundle installs have mismatched modes, the integration tests will fail with "Gem not found" errors.
-
-h2. Writing tests
-
-Integration tests are written with Capybara, which drives a fully-featured Web browser to interact with Workbench exactly as a user would.
-
-If your test requires JavaScript support, your test method should start with the line @Capybara.current_driver = Capybara.javascript_driver@.  Otherwise, Capybara defaults to a simpler browser for speed.
-
-In most tests, you can directly call "Capybara's Session methods":http://rubydoc.info/github/jnicklas/capybara/Capybara/Session to drive the browser and check its state.  If you need finer-grained control, refer to the "full Capybara documentation":http://rubydoc.info/github/jnicklas/capybara/Capybara.
diff --git a/apps/workbench/Rakefile b/apps/workbench/Rakefile
deleted file mode 100644 (file)
index 037f901..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/env rake
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Add your own tasks in files placed in lib/tasks ending in .rake,
-# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
-
-require File.expand_path('../config/application', __FILE__)
-
-ArvadosWorkbench::Application.load_tasks
diff --git a/apps/workbench/app/assets/images/dax.png b/apps/workbench/app/assets/images/dax.png
deleted file mode 100644 (file)
index c511f0e..0000000
Binary files a/apps/workbench/app/assets/images/dax.png and /dev/null differ
diff --git a/apps/workbench/app/assets/images/mouse-move.gif b/apps/workbench/app/assets/images/mouse-move.gif
deleted file mode 100644 (file)
index 497b159..0000000
Binary files a/apps/workbench/app/assets/images/mouse-move.gif and /dev/null differ
diff --git a/apps/workbench/app/assets/images/pipeline-running.gif b/apps/workbench/app/assets/images/pipeline-running.gif
deleted file mode 100644 (file)
index 64e9009..0000000
Binary files a/apps/workbench/app/assets/images/pipeline-running.gif and /dev/null differ
diff --git a/apps/workbench/app/assets/images/rails.png b/apps/workbench/app/assets/images/rails.png
deleted file mode 100644 (file)
index d5edc04..0000000
Binary files a/apps/workbench/app/assets/images/rails.png and /dev/null differ
diff --git a/apps/workbench/app/assets/images/spinner_32px.gif b/apps/workbench/app/assets/images/spinner_32px.gif
deleted file mode 100644 (file)
index 3288d10..0000000
Binary files a/apps/workbench/app/assets/images/spinner_32px.gif and /dev/null differ
diff --git a/apps/workbench/app/assets/images/trash-icon.png b/apps/workbench/app/assets/images/trash-icon.png
deleted file mode 100644 (file)
index 5c26c24..0000000
Binary files a/apps/workbench/app/assets/images/trash-icon.png and /dev/null differ
diff --git a/apps/workbench/app/assets/javascripts/add_group.js b/apps/workbench/app/assets/javascripts/add_group.js
deleted file mode 100644 (file)
index 23de53d..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).on('shown.bs.modal', '#add-group-modal', function(event) {
-    // Disable the submit button on modal loading
-    $submit = $('#add-group-submit');
-    $submit.prop('disabled', true);
-
-    $('input[type=text]', event.target).val('');
-    $('#add-group-error', event.target).hide();
-}).on('input propertychange', '#group_name_input', function(event) {
-    group_name = $(event.target).val();
-    $submit = $('#add-group-submit');
-    $submit.prop('disabled', (group_name === null || group_name === ""));
-}).on('submit', '#add-group-form', function(event) {
-    var $form = $(event.target),
-    $submit = $(':submit', $form),
-    $error = $('#add-group-error', $form),
-    group_name = $('input[name="group_name_input"]', $form).val();
-
-    $submit.prop('disabled', true);
-
-    $error.hide();
-    $.ajax('/groups',
-           {method: 'POST',
-            dataType: 'json',
-            data: {group: {name: group_name, group_class: 'role'}},
-            context: $form}).
-        done(function(data, status, jqxhr) {
-            location.reload();
-        }).
-        fail(function(jqxhr, status, error) {
-            var errlist = jqxhr.responseJSON.errors;
-            var errmsg;
-            if (Array.isArray(errlist)) {
-                errmsg = errlist.join();
-            } else {
-                errmsg = ("The server returned an error when creating " +
-                          "this group (status " + jqxhr.status +
-                          ": " + errlist + ").");
-            }
-            $error.text(errmsg);
-            $error.show();
-            $submit.prop('disabled', false);
-        });
-    return false;
-});
diff --git a/apps/workbench/app/assets/javascripts/add_repository.js b/apps/workbench/app/assets/javascripts/add_repository.js
deleted file mode 100644 (file)
index efcd19d..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).on('shown.bs.modal', '#add-repository-modal', function(event) {
-    $('input[type=text]', event.target).val('');
-    $('#add-repository-error', event.target).hide();
-}).on('submit', '#add-repository-form', function(event) {
-    var $form = $(event.target),
-    $submit = $(':submit', $form),
-    $error = $('#add-repository-error', $form),
-    repo_owner_uuid = $('input[name="add_repo_owner_uuid"]', $form).val(),
-    repo_prefix = $('input[name="add_repo_prefix"]', $form).val(),
-    repo_basename = $('input[name="add_repo_basename"]', $form).val();
-
-    $submit.prop('disabled', true);
-    $error.hide();
-    $.ajax('/repositories',
-           {method: 'POST',
-            dataType: 'json',
-            data: {repository: {owner_uuid: repo_owner_uuid,
-                                name: repo_prefix + repo_basename}},
-            context: $form}).
-        done(function(data, status, jqxhr) {
-            location.reload();
-        }).
-        fail(function(jqxhr, status, error) {
-            var errlist = jqxhr.responseJSON.errors;
-            var errmsg;
-            if (Array.isArray(errlist)) {
-                errmsg = errlist.join();
-            } else {
-                errmsg = ("The server returned an error when making " +
-                          "this repository (status " + jqxhr.status +
-                          ": " + errlist + ").");
-            }
-            $error.text(errmsg);
-            $error.show();
-            $submit.prop('disabled', false);
-        });
-    return false;
-});
diff --git a/apps/workbench/app/assets/javascripts/ajax_error.js b/apps/workbench/app/assets/javascripts/ajax_error.js
deleted file mode 100644 (file)
index dd31cc6..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).on('ajax:error', function(e, xhr, status, error) {
-    var errorMessage = '' + status + ': ' + error;
-    // $btn is the element (button/link) that initiated the failed request.
-    var $btn = $(e.target);
-    // Populate some elements with the error text (e.g., a <p> in an alert div)
-    $($btn.attr('data-on-error-write')).text(errorMessage);
-    // Show some elements (e.g., an alert div)
-    $($btn.attr('data-on-error-show')).show();
-    // Hide some elements (e.g., a success/normal div)
-    $($btn.attr('data-on-error-hide')).hide();
-}).on('ajax:success', function(e) {
-    var $btn = $(e.target);
-    $($btn.attr('data-on-success-show')).show();
-    $($btn.attr('data-on-success-hide')).hide();
-});
diff --git a/apps/workbench/app/assets/javascripts/angular_shim.js b/apps/workbench/app/assets/javascripts/angular_shim.js
deleted file mode 100644 (file)
index 5da6728..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Compile any new HTML content that was loaded via jQuery.ajax().
-// Currently this only works for tabs, and only because they emit an
-// arv:pane:loaded event after updating the DOM.
-
-$(document).on('arv:pane:loaded', function(event, $updatedElement) {
-    if (angular && $updatedElement && angular.element($updatedElement).injector()) {
-        angular.element($updatedElement).injector().invoke([
-            '$compile', function($compile) {
-                var scope = angular.element($updatedElement).scope();
-                $compile($updatedElement)(scope);
-            }]);
-    }
-});
diff --git a/apps/workbench/app/assets/javascripts/application.js b/apps/workbench/app/assets/javascripts/application.js
deleted file mode 100644 (file)
index 1898128..0000000
+++ /dev/null
@@ -1,261 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-//
-// This is a manifest file that'll be compiled into application.js, which will include all the files
-// listed below.
-//
-// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
-// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path.
-//
-// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
-// the compiled file.
-//
-// WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD
-// GO AFTER THE REQUIRES BELOW.
-//
-//= require jquery
-//= require jquery_ujs
-//= require bootstrap
-//= require bootstrap/dropdown
-//= require bootstrap/tab
-//= require bootstrap/tooltip
-//= require bootstrap/popover
-//= require bootstrap/collapse
-//= require bootstrap/modal
-//= require bootstrap/button
-//= require bootstrap3-editable/bootstrap-editable
-//= require bootstrap-tab-history
-//= require angular
-//= require raphael
-//= require morris
-//= require jquery.number.min
-//= require npm-dependencies
-//= require mithril/stream/stream
-//= require awesomplete
-//= require jssha
-//= require_tree .
-
-Es6ObjectAssign.polyfill()
-window.m = Object.assign(window.Mithril, {stream: window.m.stream})
-
-jQuery(function($){
-    $(document).ajaxStart(function(){
-      $('.modal-with-loading-spinner .spinner').show();
-    }).ajaxStop(function(){
-      $('.modal-with-loading-spinner .spinner').hide();
-    });
-
-    $('[data-toggle=tooltip]').tooltip();
-
-    $('.expand-collapse-row').on('click', function(event) {
-        var targets = $('#' + $(this).attr('data-id'));
-        if (targets.css('display') == 'none') {
-            $(this).addClass('icon-minus-sign');
-            $(this).removeClass('icon-plus-sign');
-        } else {
-            $(this).addClass('icon-plus-sign');
-            $(this).removeClass('icon-minus-sign');
-        }
-        targets.fadeToggle(200);
-    });
-
-    var ajaxCount = 0;
-
-    $(document).
-        on('ajax:send', function(e, xhr) {
-            ajaxCount += 1;
-            if (ajaxCount == 1) {
-                $('.loading').fadeTo('fast', 1);
-            }
-        }).
-        on('ajax:complete', function(e, status) {
-            ajaxCount -= 1;
-            if (ajaxCount == 0) {
-                $('.loading').fadeOut('fast', 0);
-            }
-        }).
-        on('ajaxSend', function(e, xhr) {
-            // jQuery triggers 'ajaxSend' event when starting an ajax call, but
-            // rails-generated ajax triggers generate 'ajax:send'.  Workbench
-            // event listeners currently expect 'ajax:send', so trigger the
-            // rails event in response to the jQuery one.
-            $(document).trigger('ajax:send');
-        }).
-        on('ajaxComplete', function(e, xhr) {
-            // See comment above about ajaxSend/ajax:send
-            $(document).trigger('ajax:complete');
-        }).
-        on('click', '.removable-tag a', function(e) {
-            var tag_span = $(this).parents('[data-tag-link-uuid]').eq(0)
-            tag_span.fadeTo('fast', 0.2);
-            $.ajax('/links/' + tag_span.attr('data-tag-link-uuid'),
-                   {dataType: 'json',
-                    type: 'POST',
-                    data: { '_method': 'DELETE' },
-                    context: tag_span}).
-                done(function(data, status, jqxhr) {
-                    this.remove();
-                }).
-                fail(function(jqxhr, status, error) {
-                    this.addClass('label-danger').fadeTo('fast', '1');
-                });
-            return false;
-        }).
-        on('click', 'a.add-tag-button', function(e) {
-            var jqxhr;
-            var new_tag_uuid = 'new-tag-' + Math.random();
-            var tag_head_uuid = $(this).parents('tr').attr('data-object-uuid');
-            var new_tag = window.prompt("Add tag for collection "+
-                                    tag_head_uuid,
-                                    "");
-            if (new_tag == null)
-                return false;
-            var new_tag_span =
-                $('<span class="label label-info removable-tag"></span>').
-                attr('data-tag-link-uuid', new_tag_uuid).
-                text(new_tag).
-                css('opacity', '0.2').
-                append('&nbsp;<span class="removable-tag"><a title="Delete tag"><i class="fa fa-fw fa-trash-o"></i></a></span>');
-            $(this).
-                parent().
-                find('>span').
-                append(new_tag_span).
-                append(' ');
-            $.ajax($(this).attr('data-remote-href'),
-                           {dataType: 'json',
-                            type: $(this).attr('data-remote-method'),
-                            data: {
-                                'link[head_uuid]': tag_head_uuid,
-                                'link[link_class]': 'tag',
-                                'link[name]': new_tag
-                            },
-                            context: new_tag_span}).
-                done(function(data, status, jqxhr) {
-                    this.attr('data-tag-link-uuid', data.uuid).
-                        fadeTo('fast', '1');
-                }).
-                fail(function(jqxhr, status, error) {
-                    this.addClass('label-danger').fadeTo('fast', '1');
-                });
-            return false;
-        }).
-        on('click focusin', 'input.select-on-focus', function(event) {
-            event.target.select();
-        });
-
-    $(document).
-        on('ajax:complete ready', function() {
-            // See http://getbootstrap.com/javascript/#buttons
-            $('.btn').button();
-        }).
-        on('ready ajax:complete', function() {
-            $('[data-toggle~=tooltip]').tooltip({container:'body'});
-        }).
-        on('ready ajax:complete', function() {
-            // This makes the dialog close on Esc key, obviously.
-            $('.modal').attr('tabindex', '-1')
-        }).
-        on('ready', function() {
-            // Need this to trigger input validation/synchronization callbacks because some browsers
-            // auto-fill form fields (e.g., when navigating "back" to a page where some text
-            // had been entered in a search box) without triggering a change or input event.
-            $('input').each(function(el) {
-                $(el).trigger($.Event('input', {currentTarget: el}));
-            });
-        });
-
-    HeaderRowFixer = function(selector) {
-        this.duplicateTheadTr = function() {
-            $(selector).each(function() {
-                var the_table = this;
-                if ($('>tbody>tr:first>th', the_table).length > 0)
-                    return;
-                $('>tbody', the_table).
-                    prepend($('>thead>tr', the_table).
-                            clone().
-                            css('opacity', 0));
-            });
-        }
-        this.fixThead = function() {
-            $(selector).each(function() {
-                var widths = [];
-                $('> tbody > tr:eq(1) > td', this).each( function(i,v){
-                    widths.push($(v).width());
-                });
-                for(i=0;i<widths.length;i++) {
-                    $('thead th:eq('+i+')', this).width(widths[i]);
-                }
-            });
-        }
-    }
-
-    var fixer = new HeaderRowFixer('.table-fixed-header-row');
-    fixer.duplicateTheadTr();
-    fixer.fixThead();
-    $(window).resize(function(){
-        fixer.fixThead();
-    });
-    $(document).on('ajax:complete', function(e, status) {
-        fixer.duplicateTheadTr();
-        fixer.fixThead();
-    });
-
-    $(document).ready(function() {
-        /* When wiselinks is initialized, selection.js is not working. Since we want to stop
-           using selection.js in the near future, let's not initialize wiselinks for now. */
-
-        // window.wiselinks = new Wiselinks();
-
-        $(document).off('page:loading').on('page:loading', function(event, $target, render, url){
-            $("#page-wrapper").fadeOut(200);
-        });
-
-        $(document).off('page:redirected').on('page:redirected', function(event, $target, render, url){
-        });
-
-        $(document).off('page:always').on('page:always', function(event, xhr, settings){
-            $("#page-wrapper").fadeIn(200);
-        });
-
-        $(document).off('page:done').on('page:done', function(event, $target, status, url, data){
-        });
-
-        $(document).off('page:fail').on('page:fail', function(event, $target, status, url, error, code){
-        });
-    });
-
-    $(document).on('click', '.compute-detail', function(e) {
-        $(e.target).collapse('hide');
-    });
-
-    $(document).on('click', '.compute-node-summary', function(e) {
-        $(e.target.href).collapse('toggle');
-    });
-
-    $(document).on('click', '.force-cache-reload', function(e) {
-        history.replaceState( { nocache: true }, '' );
-    });
-});
-
-window.addEventListener("DOMContentLoaded", function(e) {
-    if(history.state) {
-        if(history.state.nocache) {
-            showLoadingModal();
-            history.replaceState( {}, '' );
-            location.reload(true);
-        }
-    }
-});
-
-function showLoadingModal() {
-    $('#loading-modal').modal('show');
-}
-
-function hideLoadingModal() {
-    $('#loading-modal').modal('hide');
-}
-
-function hasHTML5History() {
-    return !!(window.history && window.history.pushState);
-}
diff --git a/apps/workbench/app/assets/javascripts/arvados_client.js b/apps/workbench/app/assets/javascripts/arvados_client.js
deleted file mode 100644 (file)
index 3fe8968..0000000
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-angular.
-    module('Arvados', []).
-    service('ArvadosClient', ArvadosClient);
-
-ArvadosClient.$inject = ['arvadosApiToken', 'arvadosDiscoveryUri']
-function ArvadosClient(arvadosApiToken, arvadosDiscoveryUri) {
-    $.extend(this, {
-        apiPromise: apiPromise,
-        uniqueNameForManifest: uniqueNameForManifest
-    });
-    return this;
-    ////////////////////////////////
-
-    var promiseDiscovery;
-    var discoveryDoc;
-
-    function apiPromise(controller, action, params) {
-        // Start an API call. Return a promise that will resolve with
-        // the API response.
-        return getDiscoveryDoc().then(function() {
-            var meth = discoveryDoc.resources[controller].methods[action];
-            var data = $.extend({}, params, {_method: meth.httpMethod});
-            $.each(data, function(k, v) {
-                if (typeof(v) === 'object') {
-                    data[k] = JSON.stringify(v);
-                }
-            });
-            var path = meth.path.replace(/{(.*?)}/, function(_, key) {
-                var val = data[key];
-                delete data[key];
-                return encodeURIComponent(val);
-            });
-            return $.ajax({
-                url: discoveryDoc.baseUrl + path,
-                type: 'POST',
-                crossDomain: true,
-                dataType: 'json',
-                data: data,
-                headers: {
-                    Authorization: 'OAuth2 ' + arvadosApiToken
-                }
-            });
-        });
-    }
-
-    function uniqueNameForManifest(manifest, newStreamName, origName) {
-        // Return an (escaped) filename starting with (unescaped)
-        // origName that won't conflict with any existing names in the
-        // manifest if saved under newStreamName. newStreamName must
-        // be exactly as given in the manifest, e.g., "." or "./foo"
-        // or "./foo/bar".
-        //
-        // Example:
-        //
-        // uniqueNameForManifest('./foo [...] 0:0:bar\\040baz.txt\n', '.',
-        //                       'foo/bar baz.txt')
-        // =>
-        // 'foo/bar\\040baz\\040(1).txt'
-        var newName;
-        var nameStub = origName;
-        var suffixInt = null;
-        var ok = false;
-        var lineMatch, linesRe = /(\S+).*/gm;
-        var fileTokenMatch, fileTokensRe = / \d+:\d+:(\S+)/g;
-        while (!ok) {
-            ok = true;
-            // Add ' (N)' before the filename extension, if any.
-            newName = (!suffixInt ? nameStub :
-                       nameStub.replace(/(\.[^.]*)?$/, ' ('+suffixInt+')$1')).
-                replace(/ /g, '\\040');
-            while (ok && null !==
-                   (lineMatch = linesRe.exec(manifest))) {
-                // lineMatch is [theEntireLine, streamName]
-                while (ok && null !==
-                       (fileTokenMatch = fileTokensRe.exec(lineMatch[0]))) {
-                    // fileTokenMatch is [theEntireToken, fileName]
-                    if (lineMatch[1] + '/' + fileTokenMatch[1]
-                        ===
-                        newStreamName + '/' + newName) {
-                        ok = false;
-                    }
-                }
-            }
-            suffixInt = (suffixInt || 0) + 1;
-        }
-        return newName;
-    }
-
-    function getDiscoveryDoc() {
-        if (!promiseDiscovery) {
-            promiseDiscovery = $.ajax({
-                url: arvadosDiscoveryUri,
-                crossDomain: true
-            }).then(function(data, status, xhr) {
-                discoveryDoc = data;
-            });
-        }
-        return promiseDiscovery;
-    }
-}
diff --git a/apps/workbench/app/assets/javascripts/bootstrap.js b/apps/workbench/app/assets/javascripts/bootstrap.js
deleted file mode 100644 (file)
index e315ab5..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-(function() {
-  jQuery(function() {
-    $("a[rel=popover]").popover();
-    $(".tooltip").tooltip();
-    return $("a[rel=tooltip]").tooltip();
-  });
-}).call(this);
diff --git a/apps/workbench/app/assets/javascripts/collections.js b/apps/workbench/app/assets/javascripts/collections.js
deleted file mode 100644 (file)
index 0752e05..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-jQuery(function($){
-    $(document).on('click', '.toggle-persist button', function() {
-        var toggle_group = $(this).parents('[data-remote-href]').first();
-        var want_persist = !toggle_group.find('button').hasClass('active');
-        var want_state = want_persist ? 'persistent' : 'cache';
-        toggle_group.find('button').
-            toggleClass('active', want_persist).
-            html(want_persist ? 'Persistent' : 'Cache');
-        $.ajax(toggle_group.attr('data-remote-href'),
-               {dataType: 'json',
-                type: 'POST',
-                data: {
-                    value: want_state
-                },
-                context: {
-                    toggle_group: toggle_group,
-                    want_state: want_state,
-                    button: this
-                }
-               }).
-            done(function(data, status, jqxhr) {
-                var context = this;
-                // Remove "danger" status in case a previous action failed
-                $('.btn-danger', context.toggle_group).
-                    addClass('btn-info').
-                    removeClass('btn-danger');
-                // Update last-saved-state
-                context.toggle_group.
-                    attr('data-persistent-state', context.want_state);
-            }).
-            fail(function(jqxhr, status, error) {
-                var context = this;
-                var saved_state;
-                // Add a visual indication that something failed
-                $(context.button).
-                    addClass('btn-danger').
-                    removeClass('btn-info');
-                // Change to the last-saved-state
-                saved_state = context.toggle_group.attr('data-persistent-state');
-                $(context.button).
-                    toggleClass('active', saved_state == 'persistent').
-                    html(saved_state == 'persistent' ? 'Persistent' : 'Cache');
-
-                if (jqxhr.readyState == 0 || jqxhr.status == 0) {
-                    // Request cancelled due to page reload.
-                    // Displaying an alert would be rather annoying.
-                } else if (jqxhr.responseJSON && jqxhr.responseJSON.errors) {
-                    window.alert("Request failed: " +
-                                 jqxhr.responseJSON.errors.join("; "));
-                } else {
-                    window.alert("Request failed.");
-                }
-            });
-    });
-});
diff --git a/apps/workbench/app/assets/javascripts/components/date.js b/apps/workbench/app/assets/javascripts/components/date.js
deleted file mode 100644 (file)
index 62eacc3..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-window.LocalizedDateTime = {
-    view: function(vnode) {
-        return m('span', new Date(Date.parse(vnode.attrs.parse)).toLocaleString())
-    },
-}
diff --git a/apps/workbench/app/assets/javascripts/components/edit_tags.js b/apps/workbench/app/assets/javascripts/components/edit_tags.js
deleted file mode 100644 (file)
index 5e02279..0000000
+++ /dev/null
@@ -1,314 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-window.SimpleInput = {
-    view: function(vnode) {
-        return m('input.form-control', {
-            style: {
-                width: '100%',
-            },
-            type: 'text',
-            placeholder: 'Add ' + vnode.attrs.placeholder,
-            value: vnode.attrs.value,
-            onchange: function() {
-                if (this.value != '') {
-                    vnode.attrs.value(this.value)
-                }
-            },
-        }, vnode.attrs.value)
-    },
-}
-
-window.SelectOrAutocomplete = {
-    view: function(vnode) {
-        return m('input.form-control', {
-            style: {
-                width: '100%'
-            },
-            type: 'text',
-            value: vnode.attrs.value,
-            placeholder: (vnode.attrs.create ? 'Add or select ': 'Select ') + vnode.attrs.placeholder,
-        }, vnode.attrs.value)
-    },
-    oncreate: function(vnode) {
-        vnode.state.awesomplete = new Awesomplete(vnode.dom, {
-            list: vnode.attrs.options,
-            minChars: 0,
-            maxItems: 1000000,
-            autoFirst: true,
-            sort: false,
-        })
-        vnode.state.create = vnode.attrs.create
-        vnode.state.options = vnode.attrs.options
-        // Option is selected from the list.
-        $(vnode.dom).on('awesomplete-selectcomplete', function(event) {
-            vnode.attrs.value(this.value)
-        })
-        $(vnode.dom).on('change', function(event) {
-            if (!vnode.state.create && !(this.value in vnode.state.options)) {
-                this.value = vnode.attrs.value()
-            } else {
-                if (vnode.attrs.value() !== this.value) {
-                    vnode.attrs.value(this.value)
-                }
-            }
-        })
-        $(vnode.dom).on('focusin', function(event) {
-            if (this.value === '') {
-                vnode.state.awesomplete.evaluate()
-                vnode.state.awesomplete.open()
-            }
-        })
-    },
-    onupdate: function(vnode) {
-        vnode.state.awesomplete.list = vnode.attrs.options
-        vnode.state.create = vnode.attrs.create
-        vnode.state.options = vnode.attrs.options
-    },
-}
-
-window.TagEditorRow = {
-    view: function(vnode) {
-        var nameOpts = Object.keys(vnode.attrs.vocabulary().tags)
-        var valueOpts = []
-        var inputComponent = SelectOrAutocomplete
-        if (nameOpts.length === 0) {
-            // If there's not vocabulary defined, switch to a simple input field
-            inputComponent = SimpleInput
-        } else {
-            // Name options list
-            if (vnode.attrs.name() != '' && !(vnode.attrs.name() in vnode.attrs.vocabulary().tags)) {
-                nameOpts.push(vnode.attrs.name())
-            }
-            // Value options list
-            if (vnode.attrs.name() in vnode.attrs.vocabulary().tags &&
-                'values' in vnode.attrs.vocabulary().tags[vnode.attrs.name()]) {
-                    valueOpts = vnode.attrs.vocabulary().tags[vnode.attrs.name()].values
-            }
-        }
-        return m('tr', [
-            // Erase tag
-            m('td', [
-                vnode.attrs.editMode &&
-                m('div.text-center', m('a.btn.btn-default.btn-sm', {
-                    style: {
-                        align: 'center'
-                    },
-                    onclick: function(e) { vnode.attrs.removeTag() }
-                }, m('i.fa.fa-fw.fa-trash-o')))
-            ]),
-            // Tag key
-            m('td', [
-                vnode.attrs.editMode ?
-                m('div', {key: 'key'}, [
-                    m(inputComponent, {
-                        options: nameOpts,
-                        value: vnode.attrs.name,
-                        // Allow any tag name unless 'strict' is set to true.
-                        create: !vnode.attrs.vocabulary().strict,
-                        placeholder: 'key',
-                    })
-                ])
-                : vnode.attrs.name
-            ]),
-            // Tag value
-            m('td', [
-                vnode.attrs.editMode ?
-                m('div', {key: 'value'}, [
-                    m(inputComponent, {
-                        options: valueOpts,
-                        value: vnode.attrs.value,
-                        placeholder: 'value',
-                        // Allow any value on tags not listed on the vocabulary.
-                        // Allow any value on tags without values, or the ones
-                        // that aren't explicitly declared to be strict.
-                        create: !(vnode.attrs.name() in vnode.attrs.vocabulary().tags)
-                            || !vnode.attrs.vocabulary().tags[vnode.attrs.name()].values
-                            || vnode.attrs.vocabulary().tags[vnode.attrs.name()].values.length === 0
-                            || !vnode.attrs.vocabulary().tags[vnode.attrs.name()].strict,
-                    })
-                ])
-                : vnode.attrs.value
-            ])
-        ])
-    }
-}
-
-window.TagEditorTable = {
-    view: function(vnode) {
-        return m('table.table.table-condensed.table-justforlayout', [
-            m('colgroup', [
-                m('col', {width:'5%'}),
-                m('col', {width:'25%'}),
-                m('col', {width:'70%'}),
-            ]),
-            m('thead', [
-                m('tr', [
-                    m('th'),
-                    m('th', 'Key'),
-                    m('th', 'Value'),
-                ])
-            ]),
-            m('tbody', [
-                vnode.attrs.tags.length > 0
-                ? vnode.attrs.tags.map(function(tag, idx) {
-                    return m(TagEditorRow, {
-                        key: tag.rowKey,
-                        removeTag: function() {
-                            vnode.attrs.tags.splice(idx, 1)
-                            vnode.attrs.dirty(true)
-                        },
-                        editMode: vnode.attrs.editMode,
-                        name: tag.name,
-                        value: tag.value,
-                        vocabulary: vnode.attrs.vocabulary
-                    })
-                })
-                : m('tr', m('td[colspan=3]', m('center', 'Loading tags...')))
-            ]),
-        ])
-    }
-}
-
-var uniqueID = 1
-
-window.TagEditorApp = {
-    appendTag: function(vnode, name, value) {
-        var tag = {name: m.stream(name), value: m.stream(value), rowKey: uniqueID++}
-        vnode.state.tags.push(tag)
-        // Set dirty flag when any of name/value changes to non empty string
-        tag.name.map(function() { vnode.state.dirty(true) })
-        tag.value.map(function() { vnode.state.dirty(true) })
-        tag.name.map(m.redraw)
-    },
-    fixTag: function(vnode, tagName) {
-        // Recover tag if deleted, recover its value if modified
-        savedTagValue = vnode.state.saved_tags[tagName]
-        if (savedTagValue === undefined) {
-            return
-        }
-        found = false
-        vnode.state.tags.forEach(function(tag) {
-            if (tag.name == tagName) {
-                tag.value = vnode.state.saved_tags[tagName]
-                found = true
-            }
-        })
-        if (!found) {
-            vnode.state.tags.pop() // Remove the last empty row
-            vnode.state.appendTag(vnode, tagName, savedTagValue)
-        }
-    },
-    oninit: function(vnode) {
-        vnode.state.sessionDB = new SessionDB()
-        // Get vocabulary
-        vnode.state.vocabulary = m.stream({'strict':false, 'tags':{}})
-        var vocabularyTimestamp = parseInt(Date.now() / 300000) // Bust cache every 5 minutes
-        m.request('/vocabulary.json?v=' + vocabularyTimestamp).then(vnode.state.vocabulary)
-        vnode.state.editMode = vnode.attrs.targetEditable
-        vnode.state.tags = []
-        vnode.state.saved_tags = {}
-        vnode.state.dirty = m.stream(false)
-        vnode.state.dirty.map(m.redraw)
-        vnode.state.error = m.stream('')
-        vnode.state.objPath = 'arvados/v1/' + vnode.attrs.targetController + '/' + vnode.attrs.targetUuid
-        // Get tags
-        vnode.state.sessionDB.request(
-            vnode.state.sessionDB.loadLocal(),
-            'arvados/v1/' + vnode.attrs.targetController,
-            {
-                data: {
-                    filters: JSON.stringify([['uuid', '=', vnode.attrs.targetUuid]]),
-                    select: JSON.stringify(['properties'])
-                },
-            }).then(function(obj) {
-                if (obj.items.length == 1) {
-                    o = obj.items[0]
-                    Object.keys(o.properties).forEach(function(k) {
-                        vnode.state.appendTag(vnode, k, o.properties[k])
-                    })
-                    if (vnode.state.editMode) {
-                        vnode.state.appendTag(vnode, '', '')
-                    }
-                    // Data synced with server, so dirty state should be false
-                    vnode.state.dirty(false)
-                    vnode.state.saved_tags = o.properties
-                    // Add new tag row when the last one is completed
-                    vnode.state.dirty.map(function() {
-                        if (!vnode.state.editMode) { return }
-                        lastTag = vnode.state.tags.slice(-1).pop()
-                        if (lastTag === undefined || (lastTag.name() !== '' || lastTag.value() !== '')) {
-                            vnode.state.appendTag(vnode, '', '')
-                        }
-                    })
-                }
-            }
-        )
-    },
-    view: function(vnode) {
-        return [
-            vnode.state.editMode &&
-            m('div.pull-left', [
-                m('a.btn.btn-primary.btn-sm' + (vnode.state.dirty() ? '' : '.disabled'), {
-                    style: {
-                        margin: '10px 0px'
-                    },
-                    onclick: function(e) {
-                        var tags = {}
-                        vnode.state.tags.forEach(function(t) {
-                            // Only ignore tags with empty key
-                            if (t.name() != '') {
-                                tags[t.name()] = t.value()
-                            }
-                        })
-                        vnode.state.sessionDB.request(
-                            vnode.state.sessionDB.loadLocal(),
-                            vnode.state.objPath, {
-                                method: 'PUT',
-                                data: {properties: JSON.stringify(tags)}
-                            }
-                        ).then(function(v) {
-                            vnode.state.dirty(false)
-                            vnode.state.error('')
-                            vnode.state.saved_tags = tags
-                        }).catch(function(err) {
-                            if (err.errors !== undefined) {
-                                var re = /protected\ property/i
-                                var protected_props = []
-                                err.errors.forEach(function(error) {
-                                    if (re.test(error)) {
-                                        prop = error.split(':')[1].trim()
-                                        vnode.state.fixTag(vnode, prop)
-                                        protected_props.push(prop)
-                                    }
-                                })
-                                if (protected_props.length > 0) {
-                                    errMsg = "Protected properties cannot be updated: " + protected_props.join(', ')
-                                } else {
-                                    errMsg = errors.join(', ')
-                                }
-                            } else {
-                                errMsg = err
-                            }
-                            vnode.state.error(errMsg)
-                        })
-                    }
-                }, vnode.state.dirty() ? ' Save changes ' : ' Saved '),
-                m('span', {
-                    style: {
-                        color: '#ff0000',
-                        margin: '0px 10px'
-                    }
-                }, [ vnode.state.error() ])
-            ]),
-            // Tags table
-            m(TagEditorTable, {
-                editMode: vnode.state.editMode,
-                tags: vnode.state.tags,
-                vocabulary: vnode.state.vocabulary,
-                dirty: vnode.state.dirty
-            })
-        ]
-    },
-}
diff --git a/apps/workbench/app/assets/javascripts/components/save_ui_state.js b/apps/workbench/app/assets/javascripts/components/save_ui_state.js
deleted file mode 100644 (file)
index 3aece31..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// SaveUIState avoids losing scroll position due to navigation
-// events, and saves/restores other caller-specified UI state.
-//
-// It does not display any content itself: do not pass any children.
-//
-// Use of multiple SaveUIState components on the same page is not
-// (yet) supported.
-//
-// The problem being solved:
-//
-// Page 1 loads some content dynamically (e.g., via infinite scroll)
-// after the initial render. User scrolls down, clicks a link, and
-// lands on page 2. User clicks the Back button, and lands on page
-// 1. Page 1 renders its initial content while waiting for AJAX.
-//
-// But (without SaveUIState) the document body is small now, so the
-// browser resets scroll position to the top of the page. Even if we
-// end up displaying the same dynamic content, the user's place on the
-// page has been lost.
-//
-// SaveUIState fixes this by stashing the current body height when
-// navigating away from page 1. When navigating back, it restores the
-// body height even before the page has loaded, so the browser does
-// not reset the scroll position.
-//
-// SaveUIState also saves/restores arbitrary UI state (like text typed
-// in a search box) in response to navigation events.
-//
-// See CollectionsSearch for an example.
-//
-// Attributes:
-//
-// {getter-setter} currentState: the current UI state
-//
-// {any} defaultState: value to initialize currentState with, if
-// nothing is stashed in browser history.
-//
-// {boolean} forgetSavedHeight: the body height loaded from the
-// browser history (if any) is outdated; we should let the browser
-// determine the correct body height from the current page
-// content. Set this when dynamic content has been reset.
-//
-// {boolean} saveBodyHeight: save/restore body height as described
-// above.
-window.SaveUIState = {
-    saveState: function() {
-        var state = history.state || {}
-        state.bodyHeight = window.getComputedStyle(document.body)['height']
-        state.currentState = this.currentState()
-        history.replaceState(state, '')
-    },
-    oninit: function(vnode) {
-        vnode.state.currentState = vnode.attrs.currentState
-        var hstate = history.state || {}
-
-        if (vnode.attrs.saveBodyHeight && hstate.bodyHeight) {
-            document.body.style['min-height'] = hstate.bodyHeight
-            delete hstate.bodyHeight
-        }
-
-        if (hstate.currentState) {
-            vnode.attrs.currentState(hstate.currentState)
-            delete hstate.currentState
-        } else {
-            vnode.attrs.currentState(vnode.attrs.defaultState)
-        }
-
-        history.replaceState(hstate, '')
-    },
-    oncreate: function(vnode) {
-        vnode.state.saveState = vnode.state.saveState.bind(vnode.state)
-        window.addEventListener('beforeunload', vnode.state.saveState)
-        vnode.state.onupdate(vnode)
-    },
-    onupdate: function(vnode) {
-        if (vnode.attrs.saveBodyHeight && vnode.attrs.forgetSavedHeight) {
-            document.body.style['min-height'] = null
-        }
-    },
-    onremove: function(vnode) {
-        window.removeEventListener('beforeunload', vnode.state.saveState)
-    },
-    view: function(vnode) {
-        return null
-    },
-}
diff --git a/apps/workbench/app/assets/javascripts/components/search.js b/apps/workbench/app/assets/javascripts/components/search.js
deleted file mode 100644 (file)
index 83ed1a6..0000000
+++ /dev/null
@@ -1,218 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-window.SearchResultsTable = {
-    maybeLoadMore: function(dom) {
-        var loader = this.loader
-        if (loader.state != loader.READY)
-            // Can't start getting more items anyway: no point in
-            // checking anything else.
-            return
-        var contentRect = dom.getBoundingClientRect()
-        var scroller = window // TODO: use dom's nearest ancestor with scrollbars
-        if (contentRect.bottom < 2 * scroller.innerHeight) {
-            // We have less than 1 page worth of content available
-            // below the visible area. Load more.
-            loader.loadMore()
-            // Indicate loading is in progress.
-            window.requestAnimationFrame(m.redraw)
-        }
-    },
-    oncreate: function(vnode) {
-        vnode.state.maybeLoadMore = vnode.state.maybeLoadMore.bind(vnode.state, vnode.dom)
-        window.addEventListener('scroll', vnode.state.maybeLoadMore)
-        window.addEventListener('resize', vnode.state.maybeLoadMore)
-        vnode.state.timer = window.setInterval(vnode.state.maybeLoadMore, 200)
-        vnode.state.loader = vnode.attrs.loader
-        vnode.state.onupdate(vnode)
-    },
-    onupdate: function(vnode) {
-        vnode.state.loader = vnode.attrs.loader
-    },
-    onremove: function(vnode) {
-        window.clearInterval(vnode.state.timer)
-        window.removeEventListener('scroll', vnode.state.maybeLoadMore)
-        window.removeEventListener('resize', vnode.state.maybeLoadMore)
-    },
-    view: function(vnode) {
-        var loader = vnode.attrs.loader
-        var iconsMap = {
-            collections: m('i.fa.fa-fw.fa-archive'),
-            projects: m('i.fa.fa-fw.fa-folder'),
-        }
-        var db = new SessionDB()
-        var sessions = db.loadActive()
-        return m('table.table.table-condensed', [
-            m('thead', m('tr', [
-                m('th'),
-                m('th', 'uuid'),
-                m('th', 'name'),
-                m('th', 'last modified'),
-            ])),
-            m('tbody', [
-                loader.items().map(function(item) {
-                    var session = sessions[item.uuid.slice(0,5)]
-                    var tokenParam = ''
-                    // Add the salted token to search result links from federated
-                    // remote hosts.
-                    if (!session.isFromRails && session.token.indexOf('v2/') == 0) {
-                        tokenParam = session.token
-                    }
-                    return m('tr', [
-                        m('td', m('form', {
-                            action: item.workbenchBaseURL() + '/' + item.objectType.wb_path + '/' + item.uuid,
-                            method: 'GET'
-                        }, [
-                            tokenParam !== '' &&
-                                m('input[type=hidden][name=api_token]', {value: tokenParam}),
-                            item.workbenchBaseURL() &&
-                                m('button.btn.btn-xs.btn-default[type=submit]', {
-                                    'data-original-title': 'show '+item.objectType.description,
-                                    'data-placement': 'top',
-                                    'data-toggle': 'tooltip',
-                                    // Bootstrap's tooltip feature
-                                    oncreate: function(vnode) { $(vnode.dom).tooltip() },
-                                }, iconsMap[item.objectType.wb_path]),
-                        ])),
-                        m('td.arvados-uuid', item.uuid),
-                        m('td', item.name || '(unnamed)'),
-                        m('td', m(LocalizedDateTime, {parse: item.modified_at})),
-                    ])
-                }),
-            ]),
-            loader.state == loader.DONE ? null : m('tfoot', m('tr', [
-                m('th[colspan=4]', m('button.btn.btn-xs', {
-                    className: loader.state == loader.LOADING ? 'btn-default' : 'btn-primary',
-                    style: {
-                        display: 'block',
-                        width: '12em',
-                        marginLeft: 'auto',
-                        marginRight: 'auto',
-                    },
-                    disabled: loader.state == loader.LOADING,
-                    onclick: function() {
-                        loader.loadMore()
-                        return false
-                    },
-                }, loader.state == loader.LOADING ? '(loading)' : 'Load more')),
-            ])),
-        ])
-    },
-}
-
-window.Search = {
-    oninit: function(vnode) {
-        vnode.state.sessionDB = new SessionDB()
-        vnode.state.sessionDB.autoRedirectToHomeCluster('/search')
-        vnode.state.searchEntered = m.stream()
-        vnode.state.searchActive = m.stream()
-        // When searchActive changes (e.g., when restoring state
-        // after navigation), update the text field too.
-        vnode.state.searchActive.map(vnode.state.searchEntered)
-        // When searchActive changes, create a new loader that filters
-        // with the given search term.
-        vnode.state.searchActive.map(function(q) {
-            var sessions = vnode.state.sessionDB.loadActive()
-            vnode.state.loader = new MergingLoader({
-                children: Object.keys(sessions).map(function(key) {
-                    var session = sessions[key]
-                    var workbenchBaseURL = function() {
-                        return vnode.state.sessionDB.workbenchBaseURL(session)
-                    }
-                    var searchable_objects = [
-                        {
-                            wb_path: 'projects',
-                            api_path: 'arvados/v1/groups',
-                            filters: [['group_class', '=', 'project']],
-                            description: 'project',
-                        },
-                        {
-                            wb_path: 'projects',
-                            api_path: 'arvados/v1/groups',
-                            filters: [['group_class', '=', 'filter']],
-                            description: 'project',
-                        },
-                        {
-                            wb_path: 'collections',
-                            api_path: 'arvados/v1/collections',
-                            filters: [],
-                            description: 'collection',
-                        },
-                    ]
-                    return new MergingLoader({
-                        sessionKey: key,
-                        // For every session, search for every object type
-                        children: searchable_objects.map(function(obj_type) {
-                            return new MultipageLoader({
-                                sessionKey: key,
-                                loadFunc: function(filters) {
-                                    // Apply additional type dependant filters
-                                    filters = filters.concat(obj_type.filters).concat(ilike_filters(q))
-                                    return vnode.state.sessionDB.request(session, obj_type.api_path, {
-                                        data: {
-                                            filters: JSON.stringify(filters),
-                                            count: 'none',
-                                        },
-                                    }).then(function(resp) {
-                                        resp.items.map(function(item) {
-                                            item.workbenchBaseURL = workbenchBaseURL
-                                            item.objectType = obj_type
-                                        })
-                                        return resp
-                                    })
-                                },
-                            })
-                        }),
-                    })
-                }),
-            })
-        })
-    },
-    view: function(vnode) {
-        return m('form', {
-            onsubmit: function() {
-                vnode.state.searchActive(vnode.state.searchEntered())
-                vnode.state.forgetSavedHeight = true
-                return false
-            },
-        }, [
-            m(SaveUIState, {
-                defaultState: '',
-                currentState: vnode.state.searchActive,
-                forgetSavedHeight: vnode.state.forgetSavedHeight,
-                saveBodyHeight: true,
-            }),
-            vnode.state.loader && [
-                m('.row', [
-                    m('.col-md-6', [
-                        m('.input-group', [
-                            m('input#search.form-control[placeholder=Search collections and projects]', {
-                                oninput: m.withAttr('value', vnode.state.searchEntered),
-                                value: vnode.state.searchEntered(),
-                            }),
-                            m('.input-group-btn', [
-                                m('input.btn.btn-primary[type=submit][value="Search"]'),
-                            ]),
-                        ]),
-                    ]),
-                    m('.col-md-6', [
-                        'Searching sites: ',
-                        vnode.state.loader.children.length == 0
-                            ? m('span.label.label-xs.label-danger', 'none')
-                            : vnode.state.loader.children.map(function(child) {
-                                return [m('span.label.label-xs', {
-                                    className: child.state == child.LOADING ? 'label-warning' : 'label-success',
-                                }, child.sessionKey), ' ']
-                            }),
-                        ' ',
-                        m('a[href="/sessions"]', 'Add/remove sites'),
-                    ]),
-                ]),
-                m(SearchResultsTable, {
-                    loader: vnode.state.loader,
-                }),
-            ],
-        ])
-    },
-}
diff --git a/apps/workbench/app/assets/javascripts/components/sessions.js b/apps/workbench/app/assets/javascripts/components/sessions.js
deleted file mode 100644 (file)
index 04ca6ac..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).on('ready', function() {
-    var db = new SessionDB();
-    db.checkForNewToken();
-    db.fillMissingUUIDs();
-    db.autoLoadRemoteHosts();
-});
-
-window.SessionsTable = {
-    oninit: function(vnode) {
-        vnode.state.db = new SessionDB();
-        vnode.state.db.autoRedirectToHomeCluster('/sessions');
-        vnode.state.db.migrateNonFederatedSessions();
-        vnode.state.hostToAdd = m.stream('');
-        vnode.state.error = m.stream();
-        vnode.state.checking = m.stream();
-    },
-    view: function(vnode) {
-        var db = vnode.state.db;
-        var sessions = db.loadAll();
-        return m('.container', [
-            m('p', [
-                'You can log in to multiple Arvados sites here, then use the ',
-                m('a[href="/search"]', 'multi-site search'),
-                ' page to search collections and projects on all sites at once.'
-            ]),
-            m('table.table.table-condensed.table-hover', [
-                m('thead', m('tr', [
-                    m('th', 'status'),
-                    m('th', 'cluster ID'),
-                    m('th', 'username'),
-                    m('th', 'email'),
-                    m('th', 'actions'),
-                    m('th')
-                ])),
-                m('tbody', [
-                    Object.keys(sessions).map(function(uuidPrefix) {
-                        var session = sessions[uuidPrefix];
-                        return m('tr', [
-                            session.token && session.user ? [
-                                m('td', session.user.is_active ?
-                                    m('span.label.label-success', 'logged in') :
-                                    m('span.label.label-warning', 'inactive')),
-                                m('td', {title: session.baseURL}, uuidPrefix),
-                                m('td', session.user.username),
-                                m('td', session.user.email),
-                                m('td', session.isFromRails ? null : m('button.btn.btn-xs.btn-default', {
-                                    uuidPrefix: uuidPrefix,
-                                    onclick: m.withAttr('uuidPrefix', db.logout),
-                                }, session.listedHost ? 'Disable ':'Log out ', m('span.glyphicon.glyphicon-log-out')))
-                            ] : [
-                                m('td', m('span.label.label-default', 'logged out')),
-                                m('td', {title: session.baseURL}, uuidPrefix),
-                                m('td'),
-                                m('td'),
-                                m('td', m('a.btn.btn-xs.btn-primary', {
-                                    uuidPrefix: uuidPrefix,
-                                    onclick: db.login.bind(db, session.baseURL),
-                                }, session.listedHost ? 'Enable ':'Log in ', m('span.glyphicon.glyphicon-log-in')))
-                            ],
-                            m('td', (session.isFromRails || session.listedHost) ? null :
-                                m('button.btn.btn-xs.btn-default', {
-                                    uuidPrefix: uuidPrefix,
-                                    onclick: m.withAttr('uuidPrefix', db.trash),
-                                }, 'Remove ', m('span.glyphicon.glyphicon-trash'))
-                            ),
-                        ])
-                    }),
-                ]),
-            ]),
-            m('.row', m('.col-md-6', [
-                m('form', {
-                    onsubmit: function() {
-                        vnode.state.error(null)
-                        vnode.state.checking(true)
-                        db.findAPI(vnode.state.hostToAdd())
-                            .then(db.login)
-                            .catch(function() {
-                                vnode.state.error(true)
-                            })
-                            .then(vnode.state.checking.bind(null, null))
-                        return false
-                    },
-                }, [
-                    m('p', [
-                        'To add a remote Arvados site, paste the remote site\'s host here (see "ARVADOS_API_HOST" on the "current token" page).',
-                    ]),
-                    m('.input-group', { className: vnode.state.error() && 'has-error' }, [
-                        m('input.form-control[type=text][name=apiHost][placeholder="zzzzz.arvadosapi.com"]', {
-                            oninput: m.withAttr('value', vnode.state.hostToAdd),
-                        }),
-                        m('.input-group-btn', [
-                            m('input.btn.btn-primary[type=submit][value="Log in"]', {
-                                disabled: !vnode.state.hostToAdd(),
-                            }),
-                        ]),
-                    ]),
-                ]),
-                m('p'),
-                vnode.state.error() && m('p.alert.alert-danger', 'Request failed. Make sure this is a working API server address.'),
-                vnode.state.checking() && m('p.alert.alert-info', 'Checking...'),
-            ])),
-        ])
-    },
-}
diff --git a/apps/workbench/app/assets/javascripts/components/test.js b/apps/workbench/app/assets/javascripts/components/test.js
deleted file mode 100644 (file)
index 4893544..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-window.TestComponent = {
-    view: function(vnode) {
-        return m('div.mithril-test-component', [
-            m('p', {
-                onclick: m.withAttr('zzz', function(){}),
-            }, [
-                'mithril is working; rendered at t=',
-                (new Date()).getTime(),
-                'ms (click to re-render)',
-            ]),
-        ])
-    },
-}
diff --git a/apps/workbench/app/assets/javascripts/dates.js b/apps/workbench/app/assets/javascripts/dates.js
deleted file mode 100644 (file)
index ed5f284..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-jQuery(function($){
-$(document).on('ajax:complete arv:pane:loaded ready', function() {
-    $('[data-utc-date]').each(function(i, elm) {
-        // Try matching the date using a couple of different formats.
-        var v = $(elm).attr('data-utc-date').match(/(\d\d\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d) UTC/);
-        if (!v) {
-            v = $(elm).attr('data-utc-date').match(/(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)Z/);
-        }
-
-        if (v) {
-            // Create a new date object from the timestamp so the browser can
-            // render the date based on the locale/timezone.
-            var ts = new Date(Date.UTC(v[1], v[2]-1, v[3], v[4], v[5], v[6]));
-            if ($(elm).attr('data-utc-date-opts') && $(elm).attr('data-utc-date-opts').match(/noseconds/)) {
-                $(elm).text((ts.getHours() > 12 ? (ts.getHours()-12) : ts.getHours())
-                            + ":" + (ts.getMinutes() < 10 ? '0' : '') + ts.getMinutes()
-                            + (ts.getHours() >= 12 ? " PM " : " AM ")
-                            + ts.toLocaleDateString());
-            } else {
-                $(elm).text(ts.toLocaleTimeString() + " " + ts.toLocaleDateString());
-            }
-        }
-    });
-});
-});
diff --git a/apps/workbench/app/assets/javascripts/edit_collection.js b/apps/workbench/app/assets/javascripts/edit_collection.js
deleted file mode 100644 (file)
index 9220ac3..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// On loading of a collection, enable the "lock" button and
-// disable all file modification controls (upload, rename, delete)
-$(document).
-    ready(function(event) {
-        $(".btn-collection-file-control").addClass("disabled");
-        $(".btn-collection-rename-file-span").attr("title", "Unlock collection to rename file");
-        $(".btn-collection-remove-file-span").attr("title", "Unlock collection to remove file");
-        $(".btn-remove-selected-files").attr("title", "Unlock collection to remove selected files");
-        $(".tab-pane-Upload").addClass("disabled");
-        $(".tab-pane-Upload").attr("title", "Unlock collection to upload files");
-        $("#Upload-tab").attr("data-toggle", "disabled");
-    }).
-    on('click', '.lock-collection-btn', function(event) {
-        classes = $(event.target).attr('class')
-
-        if (classes.indexOf("fa-lock") != -1) {
-            // About to unlock; warn and get confirmation from user
-            if (confirm("Adding, renaming, and deleting files changes the portable data hash. Are you sure you want to unlock the collection?")) {
-                $(".lock-collection-btn").removeClass("fa-lock");
-                $(".lock-collection-btn").addClass("fa-unlock");
-                $(".lock-collection-btn").attr("title", "Lock collection to prevent editing files");
-                $(".btn-collection-rename-file-span").attr("title", "");
-                $(".btn-collection-remove-file-span").attr("title", "");
-                $(".btn-collection-file-control").removeClass("disabled");
-                $(".btn-remove-selected-files").attr("title", "");
-                $(".tab-pane-Upload").removeClass("disabled");
-                $(".tab-pane-Upload").attr("data-original-title", "");
-                $("#Upload-tab").attr("data-toggle", "tab");
-            } else {
-                // User clicked "no" and so do not unlock
-            }
-        } else {
-            // Lock it back
-            $(".lock-collection-btn").removeClass("fa-unlock");
-            $(".lock-collection-btn").addClass("fa-lock");
-            $(".lock-collection-btn").attr("title", "Unlock collection to edit files");
-            $(".btn-collection-rename-file-span").attr("title", "Unlock collection to rename file");
-            $(".btn-collection-remove-file-span").attr("title", "Unlock collection to remove file");
-            $(".btn-collection-file-control").addClass("disabled");
-            $(".btn-remove-selected-files").attr("title", "Unlock collection to remove selected files");
-            $(".tab-pane-Upload").addClass("disabled");
-            $(".tab-pane-Upload").attr("data-original-title", "Unlock collection to upload files");
-            $("#Upload-tab").attr("data-toggle", "disabled");
-        }
-    });
diff --git a/apps/workbench/app/assets/javascripts/editable.js b/apps/workbench/app/assets/javascripts/editable.js
deleted file mode 100644 (file)
index 939506c..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$.fn.editable.defaults.ajaxOptions = {type: 'post', dataType: 'json'};
-$.fn.editable.defaults.send = 'always';
-
-// Default for editing is popup.  I experimented with inline which is a little
-// nicer in that it shows up right under the mouse instead of nearby.  However,
-// the inline box is taller than the regular content, which causes the page
-// layout to shift unless we make the table rows tall, which leaves a lot of
-// wasted space when not editing.  Also inline can get cut off if the page is
-// too narrow, when the popup box will just move to do the right thing.
-//$.fn.editable.defaults.mode = 'inline';
-
-$.fn.editable.defaults.success = function (response, newValue) {
-    $(document).trigger('editable:success', [this, response, newValue]);
-};
-
-$.fn.editable.defaults.params = function (params) {
-    var a = {};
-    var key = params.pk.key;
-    a.id = $(this).attr('data-object-uuid') || params.pk.id;
-    a[key] = params.pk.defaults || {};
-    // Remove null values. Otherwise they get transmitted as empty
-    // strings in request params.
-    for (i in a[key]) {
-        if (a[key][i] == null)
-            delete a[key][i];
-    }
-    a[key][params.name] = params.value;
-    if (!a.id) {
-        a['_method'] = 'post';
-    } else {
-        a['_method'] = 'put';
-    }
-    return a;
-};
-
-$.fn.editable.defaults.validate = function (value) {
-    if (value == "***invalid***") {
-        return "Invalid selection";
-    }
-}
-
-$(document).
-    on('ready ajax:complete', function() {
-        $('.editable').
-            not('.editable-done-setup').
-            addClass('editable-done-setup').
-            editable({
-                success: function(response, newValue) {
-                    // If we just created a new object, stash its UUID
-                    // so we edit it next time instead of creating
-                    // another new object.
-                    if (!$(this).attr('data-object-uuid') && response.uuid) {
-                        $(this).attr('data-object-uuid', response.uuid);
-                    }
-                    if (response.href) {
-                        $(this).editable('option', 'url', response.href);
-                    }
-                    if ($(this).attr('data-name')) {
-                        var textileAttr = $(this).attr('data-name') + 'Textile';
-                        if (response[textileAttr]) {
-                            $(this).attr('data-textile', response[textileAttr]);
-                        }
-                    }
-                    return;
-                },
-                error: function(response, newValue) {
-                    var errlist = response.responseJSON.errors;
-                    var errmsg;
-                    if (Array.isArray(errlist)) {
-                        errmsg = errlist.join();
-                    } else {
-                        errmsg = ("The server returned an error when making " +
-                                  "this update (status " + response.status +
-                                  ": " + errlist + ").");
-                    }
-                    return errmsg;
-                }
-            }).
-            on('hidden', function(e, reason) {
-                // After saving a new attribute, update the same
-                // information if it appears elsewhere on the page.
-                if (reason != 'save') return;
-                var html = $(this).html();
-                if( $(this).attr('data-textile') ) {
-                    html = $(this).attr('data-textile');
-                    $(this).html(html);
-                }
-                var uuid = $(this).attr('data-object-uuid');
-                var attr = $(this).attr('data-name');
-                var edited = this;
-                if (uuid && attr) {
-                    $("[data-object-uuid='" + uuid + "']" +
-                      "[data-name='" + attr + "']").each(function() {
-                          if (this != edited)
-                              $(this).html(html);
-                      });
-                }
-            });
-    }).
-    on('ready ajax:complete', function() {
-        $("[data-toggle~='x-editable']").
-            not('.editable-done-setup').
-            addClass('editable-done-setup').
-            click(function(e) {
-                e.stopPropagation();
-                $($(this).attr('data-toggle-selector')).editable('toggle');
-            });
-    });
-
-$.fn.editabletypes.text.defaults.tpl = '<input type="text" name="editable-text">'
-
-$.fn.editableform.buttons = '\
-<button type="submit" class="btn btn-primary btn-sm editable-submit" \
-  id="editable-submit"><i class="glyphicon glyphicon-ok"></i></button>\
-<button type="button" class="btn btn-default btn-sm editable-cancel" \
-  id="editable-cancel"><i class="glyphicon glyphicon-remove"></i></button>\
-'
diff --git a/apps/workbench/app/assets/javascripts/event_log.js b/apps/workbench/app/assets/javascripts/event_log.js
deleted file mode 100644 (file)
index e576ba9..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-/*
- * This js establishes a websockets connection with the API Server.
- */
-
-/* Subscribe to websockets event log.  Do nothing if already connected. */
-function subscribeToEventLog () {
-    // if websockets are not supported by browser, do not subscribe for events
-    websocketsSupported = ('WebSocket' in window);
-    if (websocketsSupported == false) {
-        return;
-    }
-
-    // check if websocket connection is already stored on the window
-    event_log_disp = $(window).data("arv-websocket");
-    if (event_log_disp == null) {
-        // need to create new websocket and event log dispatcher
-        websocket_url = $('meta[name=arv-websocket-url]').attr("content");
-        if (websocket_url == null)
-            return;
-
-        event_log_disp = new WebSocket(websocket_url);
-
-        event_log_disp.onopen = onEventLogDispatcherOpen;
-        event_log_disp.onmessage = onEventLogDispatcherMessage;
-
-        // store websocket in window to allow reuse when multiple divs subscribe for events
-        $(window).data("arv-websocket", event_log_disp);
-    }
-}
-
-/* Send subscribe message to the websockets server.  Without any filters
-   arguments, this subscribes to all events */
-function onEventLogDispatcherOpen(event) {
-    this.send('{"method":"subscribe"}');
-}
-
-/* Trigger event for all applicable elements waiting for this event */
-function onEventLogDispatcherMessage(event) {
-    parsedData = JSON.parse(event.data);
-    object_uuid = parsedData.object_uuid;
-
-    if (!object_uuid) {
-        return;
-    }
-
-    // if there are any listeners for this object uuid or "all", trigger the event
-    matches = ".arv-log-event-listener[data-object-uuid=\"" + object_uuid + "\"],.arv-log-event-listener[data-object-uuids~=\"" + object_uuid + "\"],.arv-log-event-listener[data-object-uuid=\"all\"],.arv-log-event-listener[data-object-kind=\"" + parsedData.object_kind + "\"]";
-    $(matches).trigger('arv-log-event', parsedData);
-}
-
-/* Automatically connect if there are any elements on the page that want to
-   receive event log events. */
-$(document).on('ajax:complete ready', function() {
-    var a = $('.arv-log-event-listener');
-    if (a.length > 0) {
-        subscribeToEventLog();
-    }
-});
diff --git a/apps/workbench/app/assets/javascripts/filterable.js b/apps/workbench/app/assets/javascripts/filterable.js
deleted file mode 100644 (file)
index bf859c3..0000000
+++ /dev/null
@@ -1,203 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// filterable.js shows/hides content when the user operates
-// search/select widgets. For "infinite scroll" content, it passes the
-// filters to the server and retrieves new content. For other content,
-// it filters the existing DOM elements using jQuery show/hide.
-//
-// Usage:
-//
-// 1. Add the "filterable" class to each filterable content item.
-// Typically, each item is a 'tr' or a 'div class="row"'.
-//
-// <div id="results">
-//   <div class="filterable row">First row</div>
-//   <div class="filterable row">Second row</div>
-// </div>
-//
-// 2. Add the "filterable-control" class to each search/select widget.
-// Also add a data-filterable-target attribute with a jQuery selector
-// for an ancestor of the filterable items, i.e., the container in
-// which this widget should apply filtering.
-//
-// <input class="filterable-control" data-filterable-target="#results"
-//        type="text" />
-//
-// Supported widgets:
-//
-// <input type="text" ... />
-//
-// The input value is used as a regular expression. Rows with content
-// matching the regular expression are shown.
-//
-// <select ... data-filterable-attribute="data-example-attr">
-//  <option value="foo">Foo</option>
-//  <option value="">Show all</option>
-// </select>
-//
-// When the user selects the "Foo" option, rows with
-// data-example-attr="foo" are shown, and all others are hidden. When
-// the user selects the "Show all" option, all rows are shown.
-//
-// <input type="checkbox" data-on-value="{}" data-off-value="{}" ... />
-//
-// Merges on- or off-value with other params in query. Only works with
-// infinite-scroll.
-//
-// Notes:
-//
-// When multiple filterable-control widgets operate on the same
-// data-filterable-target, items must pass _all_ filters in order to
-// be shown.
-//
-// If one data-filterable-target is the parent of another
-// data-filterable-target, results are undefined. Don't do this.
-//
-// Combining "select" filterable-controls with infinite-scroll is not
-// yet supported.
-
-function updateFilterableQueryNow($target) {
-    var newquery = $target.data('filterable-query-new');
-    var params = $target.data('infinite-content-params-filterable') || {};
-    params.filters = ilike_filters(newquery);
-    $(".modal-dialog-preview-pane").html("");
-    $target.data('infinite-content-params-filterable', params);
-    $target.data('filterable-query', newquery);
-}
-
-$(document).
-    on('ready ajax:success', function() {
-        // Copy any initial input values into
-        // data-filterable-query[-new].
-        $('input[type=text].filterable-control').each(function() {
-            var $this = $(this);
-            var $target = $($this.attr('data-filterable-target'));
-            if ($target.data('filterable-query-new') === undefined) {
-                $target.data('filterable-query', $this.val());
-                $target.data('filterable-query-new', $this.val());
-                updateFilterableQueryNow($target);
-            }
-        });
-        $('[data-infinite-scroller]').on('refresh-content', '[data-filterable-query]', function(e) {
-            // If some other event causes a refresh-content event while there
-            // is a new query waiting to cooloff, we should use the new query
-            // right away -- otherwise we'd launch an extra ajax request that
-            // would have to be reloaded as soon as the cooloff period ends.
-            if (this != e.target)
-                return;
-            if ($(this).data('filterable-query') == $(this).data('filterable-query-new'))
-                return;
-            updateFilterableQueryNow($(this));
-        });
-    }).
-    on('change', 'input[type=checkbox].filterable-control', function(e) {
-        if (this != e.target) return;
-        var $target = $($(this).attr('data-filterable-target'));
-        var currentquery = $target.data('filterable-query');
-        if (currentquery === undefined) currentquery = '';
-        if ($target.is('[data-infinite-scroller]')) {
-            var datakey = 'infiniteContentParamsFrom'+this.id;
-            var whichvalue = $(this).is(':checked') ? 'on-value' : 'off-value';
-            if (JSON.stringify($target.data(datakey)) == JSON.stringify($(this).data(whichvalue)))
-                return;
-            $target.data(datakey, $(this).data(whichvalue));
-            updateFilterableQueryNow($target);
-            $target.trigger('refresh-content');
-        }
-    }).
-    on('paste keyup input', 'input[type=text].filterable-control', function(e) {
-        var regexp;
-        if (this != e.target) return;
-        var $target = $($(this).attr('data-filterable-target'));
-        var currentquery = $target.data('filterable-query');
-        if (currentquery === undefined) currentquery = '';
-        if ($target.is('[data-infinite-scroller]')) {
-            // We already know how to load content dynamically, so we
-            // can do all filtering on the server side.
-
-            if ($target.data('infinite-cooloff-timer') > 0) {
-                // Clear a stale refresh-after-delay timer.
-                clearTimeout($target.data('infinite-cooloff-timer'));
-            }
-            // Stash the new query string in the filterable container.
-            $target.data('filterable-query-new', $(this).val());
-            if (currentquery == $(this).val()) {
-                // Don't mess with existing results or queries in
-                // progress.
-                return;
-            }
-            $target.data('infinite-cooloff-timer', setTimeout(function() {
-                // If the user doesn't do any query-changing actions
-                // in the next 1/4 second (like type or erase
-                // characters in the search box), hide the stale
-                // content and ask the server for new results.
-                updateFilterableQueryNow($target);
-                $target.trigger('refresh-content');
-            }, 250));
-        } else {
-            // Target does not have infinite-scroll capability. Just
-            // filter the rows in the browser using a RegExp.
-            regexp = undefined;
-            try {
-                regexp = new RegExp($(this).val(), 'i');
-            } catch(e) {
-                if (e instanceof SyntaxError) {
-                    // Invalid/partial regexp. See 'has-error' below.
-                } else {
-                    throw e;
-                }
-            }
-            $target.
-                toggleClass('has-error', regexp === undefined).
-                addClass('filterable-container').
-                data('q', regexp).
-                trigger('refresh');
-        }
-    }).on('refresh', '.filterable-container', function() {
-        var $container = $(this);
-        var q = $(this).data('q');
-        var filters = $(this).data('filters');
-        $('.filterable', this).hide().filter(function() {
-            var $row = $(this);
-            var pass = true;
-            if (q && !$row.text().match(q))
-                return false;
-            if (filters) {
-                $.each(filters, function(filterby, val) {
-                    if (!val) return;
-                    if (!pass) return;
-                    pass = false;
-                    $.each(val.split(" "), function(i, e) {
-                        if ($row.attr(filterby) == e)
-                            pass = true;
-                    });
-                });
-            }
-            return pass;
-        }).show();
-
-        // Show/hide each section heading depending on whether any
-        // content rows are visible in that section.
-        $('.row[data-section-heading]', this).each(function(){
-            $(this).toggle($('.row.filterable[data-section-name="' +
-                             $(this).attr('data-section-name') +
-                             '"]:visible').length > 0);
-        });
-
-        // Load more content if the last result is showing.
-        $('.infinite-scroller').add(window).trigger('scroll');
-    }).on('change', 'select.filterable-control', function() {
-        var val = $(this).val();
-        var filterby = $(this).attr('data-filterable-attribute');
-        var $target = $($(this).attr('data-filterable-target')).
-            addClass('filterable-container');
-        var filters = $target.data('filters') || {};
-        filters[filterby] = val;
-        $target.
-            data('filters', filters).
-            trigger('refresh');
-    }).on('ajax:complete', function() {
-        $('.filterable-control').trigger('input');
-    });
diff --git a/apps/workbench/app/assets/javascripts/ilike_filters.js b/apps/workbench/app/assets/javascripts/ilike_filters.js
deleted file mode 100644 (file)
index 4f5cd48..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// ilike_filters() converts a user-entered search query to a list of
-// filters using the newly added (as of Arvados 1.5) trigram indexes. It returns
-// [] (empty list) if it can't come up with anything valid (e.g., q consists
-// entirely of punctuation).
-//
-// Examples:
-//
-// "foo"     => [["any", "ilike", "%foo%"]]
-// "foo.bar" => [["any", "ilike", "%foo.bar%"]]                         // "." is a word char in ilike queries
-// "foo/b-r" => [["any", "ilike", "%foo/b-r%"]]                         // "/" and "-", too
-// "foo_bar" => [["any", "ilike", "%foo\\_bar%"]                        // "_" should be escaped so it can be used as a literal
-// "foo bar" => [["any", "ilike", "%foo%"], ["any", "ilike", "%bar%"]]
-// "foo|bar" => [["any", "ilike", "%foo%"], ["any", "ilike", "%bar%"]]
-// " oo|bar" => [["any", "ilike", "%oo%"], ["any", "ilike", "%bar%"]]
-// ""        => []
-// " "       => []
-// null      => []
-window.ilike_filters = function(q) {
-    q = (q || '').replace(/[^-\w\.\/]+/g, ' ').trim().replace(/_/g, '\\_')
-    if (q == '')
-        return []
-    return q.split(" ").map(function(term) {
-        return ["any", "ilike", "%"+term+"%"]
-    })
-}
\ No newline at end of file
diff --git a/apps/workbench/app/assets/javascripts/infinite_scroll.js b/apps/workbench/app/assets/javascripts/infinite_scroll.js
deleted file mode 100644 (file)
index 3e63858..0000000
+++ /dev/null
@@ -1,309 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// infinite_scroll.js displays a tab's content using automatic scrolling
-// when the user scrolls to the bottom of the page and there is more data.
-//
-// Usage:
-//
-// 1. Adding infinite scrolling to a tab pane using "show" method
-//
-//  The steps below describe adding scrolling to the project#show action.
-//
-//  a. In the "app/views/projects/" folder add a file for your tab
-//      (ex: _show_jobs_and_pipelines.html.erb)
-//    In this file, add a div or tbody with data-infinite-scroller.
-//      Note: This page uses _show_tab_contents.html.erb so that
-//            several tabs can reuse this implementation.
-//    Also add the filters to be used for loading the tab content.
-//
-//  b. Add a file named "_show_contents_rows.html.erb" that loads
-//    the data (by invoking get_objects_and_names from the controller).
-//
-//  c. In the "app/controllers/projects_controller.rb,
-//    Update the show method to add a block for "params[:partial]"
-//      that loads the show_contents_rows partial.
-//    Optionally, add a "tab_counts" method that loads the total number
-//      of objects count to be displayed for this tab.
-//
-// 2. Adding infinite scrolling to the "Recent" tab in "index" page
-//  The steps below describe adding scrolling to the pipeline_instances index page.
-//
-//  a. In the "app/views/pipeline_instances/_show_recent.html.erb/" file
-//      add a div or tbody with data-infinite-scroller.
-//
-//  b. Add the partial "_show_recent_rows.html.erb" that displays the
-//      page contents on scroll using the @objects
-
-function maybe_load_more_content(event) {
-    var scroller = this;
-    var $container = $(event.data.container);
-    var src;                     // url for retrieving content
-    var scrollHeight;
-    var spinner, colspan;
-    var serial = Date.now();
-    var params;
-    scrollHeight = scroller.scrollHeight || $('body')[0].scrollHeight;
-    if ($(scroller).scrollTop() + $(scroller).height()
-        >
-        scrollHeight - 50)
-    {
-        if (!$container.attr('data-infinite-content-href0')) {
-            // Remember the first page source url, so we can refresh
-            // from page 1 later.
-            $container.attr('data-infinite-content-href0',
-                            $container.attr('data-infinite-content-href'));
-        }
-        src = $container.attr('data-infinite-content-href');
-        if (!src || !$container.is(':visible'))
-            // Finished
-            return;
-
-        // Don't start another request until this one finishes
-        $container.attr('data-infinite-content-href', null);
-        spinner = '<div class="spinner spinner-32px spinner-h-center"></div>';
-        if ($container.is('table,tbody,thead,tfoot')) {
-            // Hack to determine how many columns a new tr should have
-            // in order to reach full width.
-            colspan = $container.closest('table').
-                find('tr').eq(0).find('td,th').length;
-            if (colspan == 0)
-                colspan = '*';
-            spinner = ('<tr class="spinner"><td colspan="' + colspan + '">' +
-                       spinner +
-                       '</td></tr>');
-        }
-        $container.find(".spinner").detach();
-        $container.append(spinner);
-        $container.data('data-infinite-serial', serial);
-
-        if (src == $container.attr('data-infinite-content-href0')) {
-            // If we're loading the first page, collect filters from
-            // various sources.
-            params = mergeInfiniteContentParams($container);
-            $.each(params, function(k,v) {
-                if (v instanceof Object) {
-                    params[k] = JSON.stringify(v);
-                }
-            });
-        } else {
-            // If we're loading page >1, ignore other filtering
-            // mechanisms and just use the "next page" URI from the
-            // previous page's response. Aside from avoiding race
-            // conditions (where page 2 could have different filters
-            // than page 1), this allows the server to use filters in
-            // the "next page" URI to achieve paging. (To apply any
-            // new filters effectively, we need to load page 1 again
-            // anyway.)
-            params = {};
-        }
-
-        $.ajax(src,
-               {dataType: 'json',
-                type: 'GET',
-                data: params,
-                context: {container: $container, src: src, serial: serial}}).
-            fail(function(jqxhr, status, error) {
-                var $faildiv;
-                var $container = this.container;
-                if ($container.data('data-infinite-serial') != this.serial) {
-                    // A newer request is already in progress.
-                    return;
-                }
-                if (jqxhr.readyState == 0 || jqxhr.status == 0) {
-                    message = "Cancelled.";
-                } else if (jqxhr.responseJSON && jqxhr.responseJSON.errors) {
-                    message = jqxhr.responseJSON.errors.join("; ");
-                } else {
-                    message = "Request failed.";
-                }
-                // TODO: report the message to the user.
-                console.log(message);
-                $faildiv = $('<div />').
-                    attr('data-infinite-content-href', this.src).
-                    addClass('infinite-retry').
-                    append('<span class="fa fa-warning" /> Oops, request failed. <button class="btn btn-xs btn-primary">Retry</button>');
-                $container.find('div.spinner').replaceWith($faildiv);
-            }).
-            done(function(data, status, jqxhr) {
-                if ($container.data('data-infinite-serial') != this.serial) {
-                    // A newer request is already in progress.
-                    return;
-                }
-                $container.find(".spinner").detach();
-                $container.append(data.content);
-                $container.attr('data-infinite-content-href', data.next_page_href);
-                ping_all_scrollers();
-            });
-     }
-}
-
-function ping_all_scrollers() {
-    // Send a scroll event to all scroll listeners that might need
-    // updating. Adding infinite-scroller class to the window element
-    // doesn't work, so we add it explicitly here.
-    $('.infinite-scroller').add(window).trigger('scroll');
-}
-
-function mergeInfiniteContentParams($container) {
-    var params = {};
-    // Combine infiniteContentParams from multiple sources. This
-    // mechanism allows each of several components to set and
-    // update its own set of filters, without having to worry
-    // about stomping on some other component's filters.
-    //
-    // For example, filterable.js writes filters in
-    // infiniteContentParamsFilterable ("search for text foo")
-    // without worrying about clobbering the filters set up by the
-    // tab pane ("only show container requests and pipeline instances
-    // in this tab").
-    $.each($container.data(), function(datakey, datavalue) {
-        // Note: We attach these data to DOM elements using
-        // <element data-foo-bar="baz">. We store/retrieve them
-        // using $('element').data('foo-bar'), although
-        // .data('fooBar') would also work. The "all data" hash
-        // returned by $('element').data(), however, always has
-        // keys like 'fooBar'. In other words, where we have a
-        // choice, we stick with the 'foo-bar' style to be
-        // consistent with HTML. Here, our only option is
-        // 'fooBar'.
-        if (/^infiniteContentParams/.exec(datakey)) {
-            if (datavalue instanceof Object) {
-                $.each(datavalue, function(hkey, hvalue) {
-                    if (hvalue instanceof Array) {
-                        params[hkey] = (params[hkey] || []).
-                            concat(hvalue);
-                    } else if (hvalue instanceof Object) {
-                        $.extend(params[hkey], hvalue);
-                    } else {
-                        params[hkey] = hvalue;
-                    }
-                });
-            }
-        }
-    });
-    return params;
-}
-
-function setColumnSort( $container, $header, direction ) {
-    // $container should be the tbody or whatever has all the infinite table data attributes
-    // $header should be the th with a preset data-sort-order attribute
-    // direction should be "asc" or "desc"
-    // This function returns the order by clause for this column header as a string
-
-    // First reset all sort directions
-    $('th[data-sort-order]').removeData('sort-order-direction');
-    // set the current one
-    $header.data('sort-order-direction', direction);
-    // change the ordering parameter
-    var paramsAttr = 'infinite-content-params-' + $container.data('infinite-content-params-attr');
-    var params = $container.data(paramsAttr) || {};
-    params.order = $header.data('sort-order').split(",").join( ' ' + direction + ', ' ) + ' ' + direction;
-    $container.data(paramsAttr, params);
-    // show the correct icon next to the column header
-    $container.trigger('sort-icons');
-
-    return params.order;
-}
-
-$(document).
-    on('click', 'div.infinite-retry button', function() {
-        var $retry_div = $(this).closest('.infinite-retry');
-        var $container = $(this).closest('.infinite-scroller-ready')
-        $container.attr('data-infinite-content-href',
-                        $retry_div.attr('data-infinite-content-href'));
-        $retry_div.
-            replaceWith('<div class="spinner spinner-32px spinner-h-center" />');
-        ping_all_scrollers();
-    }).
-    on('refresh-content', '[data-infinite-scroller]', function() {
-        // Clear all rows, reset source href to initial state, and
-        // (if the container is visible) start loading content.
-        var first_page_href = $(this).attr('data-infinite-content-href0');
-        if (!first_page_href)
-            first_page_href = $(this).attr('data-infinite-content-href');
-        $(this).
-            html('').
-            attr('data-infinite-content-href', first_page_href);
-        ping_all_scrollers();
-    }).
-    on('ready ajax:complete', function() {
-        $('[data-infinite-scroller]').each(function() {
-            if ($(this).hasClass('infinite-scroller-ready'))
-                return;
-            $(this).addClass('infinite-scroller-ready');
-
-            // deal with sorting if there is any, and if it was set on this page for this tab already
-            if( $('th[data-sort-order]').length ) {
-                var tabId = $(this).closest('div.tab-pane').attr('id');
-                if( hasHTML5History() && history.state !== undefined && history.state !== null && history.state.order !== undefined && history.state.order[tabId] !== undefined ) {
-                    // we will use the list of one or more table columns associated with this header to find the right element
-                    // see sortable_columns as it is passed to render_pane in the various tab .erbs (e.g. _show_jobs_and_pipelines.html.erb)
-                    var strippedColumns = history.state.order[tabId].replace(/\s|\basc\b|\bdesc\b/g,'');
-                    var sortDirection = history.state.order[tabId].split(" ")[1].replace(/,/,'');
-                    $columnHeader = $(this).closest('table').find('[data-sort-order="'+ strippedColumns +'"]');
-                    setColumnSort( $(this), $columnHeader, sortDirection );
-                } else {
-                    // otherwise just reset the sort icons
-                    $(this).trigger('sort-icons');
-                }
-            }
-
-            // $scroller is the DOM element that hears "scroll"
-            // events: sometimes it's a div, sometimes it's
-            // window. Here, "this" is the DOM element containing the
-            // result rows. We pass it to maybe_load_more_content in
-            // event.data.
-            var $scroller = $($(this).attr('data-infinite-scroller'));
-            if (!$scroller.hasClass('smart-scroll') &&
-                'scroll' != $scroller.css('overflow-y'))
-                $scroller = $(window);
-            $scroller.
-                addClass('infinite-scroller').
-                on('scroll resize', { container: this }, maybe_load_more_content).
-                trigger('scroll');
-        });
-    }).
-    on('shown.bs.tab', 'a[data-toggle="tab"]', function(event) {
-        $(event.target.getAttribute('href') + ' [data-infinite-scroller]').
-            trigger('scroll');
-    }).
-    on('click', 'th[data-sort-order]', function() {
-        var direction = $(this).data('sort-order-direction');
-        // reverse the current direction, or do ascending if none
-        if( direction === undefined || direction === 'desc' ) {
-            direction = 'asc';
-        } else {
-            direction = 'desc';
-        }
-
-        var $container = $(this).closest('table').find('[data-infinite-content-params-attr]');
-
-        var order = setColumnSort( $container, $(this), direction );
-
-        // put it in the browser history state if browser allows it
-        if( hasHTML5History() ) {
-            var tabId = $(this).closest('div.tab-pane').attr('id');
-            var state =  history.state || {};
-            if( state.order === undefined ) {
-                state.order = {};
-            }
-            state.order[tabId] = order;
-            history.replaceState( state, null, null );
-        }
-
-        $container.trigger('refresh-content');
-    }).
-    on('sort-icons', function() {
-        // set or reset the icon next to each sortable column header according to the current direction attribute
-        $('th[data-sort-order]').each(function() {
-            $(this).find('i').remove();
-            var direction = $(this).data('sort-order-direction');
-            if( direction !== undefined ) {
-                $(this).append('<i class="fa fa-sort-' + direction + '"/>');
-            } else {
-                $(this).append('<i class="fa fa-sort"/>');
-            }
-        });
-    });
diff --git a/apps/workbench/app/assets/javascripts/job_log_graph.js b/apps/workbench/app/assets/javascripts/job_log_graph.js
deleted file mode 100644 (file)
index f47f4f1..0000000
+++ /dev/null
@@ -1,339 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-/* Assumes existence of:
-  window.jobGraphData = [];
-  window.jobGraphSeries = [];
-  window.jobGraphSortedSeries = [];
-  window.jobGraphMaxima = {};
- */
-function processLogLineForChart( logLine ) {
-    try {
-        var match = logLine.match(/^(\S+) (\S+) (\S+) (\S+) stderr crunchstat: (\S+) (.*)/);
-        if( !match ) {
-            match = logLine.match(/^((?:Sun|Mon|Tue|Wed|Thu|Fri|Sat) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{1,2} \d\d:\d\d:\d\d \d{4}) (\S+) (\S+) (\S+) stderr crunchstat: (\S+) (.*)/);
-            if( match ) {
-                match[1] = (new Date(match[1] + ' UTC')).toISOString().replace('Z','');
-            }
-        }
-        if( match ) {
-            var rawDetailData = '';
-            var datum = null;
-
-            // the timestamp comes first
-            var timestamp = match[1].replace('_','T') + 'Z';
-
-            // we are interested in "-- interval" recordings
-            var intervalMatch = match[6].match(/(.*) -- interval (.*)/);
-            if( intervalMatch ) {
-                var intervalData = intervalMatch[2].trim().split(' ');
-                var dt = parseFloat(intervalData[0]);
-                var dsum = 0.0;
-                for(var i=2; i < intervalData.length; i += 2 ) {
-                    dsum += parseFloat(intervalData[i]);
-                }
-                datum = dsum/dt;
-
-                if( datum < 0 ) {
-                    // not interested in negative deltas
-                    return;
-                }
-
-                rawDetailData = intervalMatch[2];
-
-                // for the series name use the task number (4th term) and then the first word after 'crunchstat:'
-                var series = 'T' + match[4] + '-' + match[5];
-
-                // special calculation for cpus
-                if( /-cpu$/.test(series) ) {
-                    // divide the stat by the number of cpus unless the time count is less than the interval length
-                    if( dsum.toFixed(1) > dt.toFixed(1) ) {
-                        var cpuCountMatch = intervalMatch[1].match(/(\d+) cpus/);
-                        if( cpuCountMatch ) {
-                            datum = datum / cpuCountMatch[1];
-                        }
-                    }
-                }
-
-                addJobGraphDatum( timestamp, datum, series, rawDetailData );
-            } else {
-                // we are also interested in memory ("mem") recordings
-                var memoryMatch = match[6].match(/(\d+) cache (\d+) swap (\d+) pgmajfault (\d+) rss/);
-                if( memoryMatch ) {
-                    rawDetailData = match[6];
-                    // one datapoint for rss and one for swap - only show the rawDetailData for rss
-                    addJobGraphDatum( timestamp, parseInt(memoryMatch[4]), 'T' + match[4] + "-rss", rawDetailData );
-                    addJobGraphDatum( timestamp, parseInt(memoryMatch[2]), 'T' + match[4] + "-swap", '' );
-                } else {
-                    // not interested
-                    return;
-                }
-            }
-
-            window.redraw = true;
-        }
-    } catch( err ) {
-        console.log( 'Ignoring error trying to process log line: ' + err);
-    }
-}
-
-function addJobGraphDatum(timestamp, datum, series, rawDetailData) {
-    // check for new series
-    if( $.inArray( series, jobGraphSeries ) < 0 ) {
-        var newIndex = jobGraphSeries.push(series) - 1;
-        jobGraphSortedSeries.push(newIndex);
-        jobGraphSortedSeries.sort( function(a,b) {
-            var matchA = jobGraphSeries[a].match(/^T(\d+)-(.*)/);
-            var matchB = jobGraphSeries[b].match(/^T(\d+)-(.*)/);
-            var termA = ('000000' + matchA[1]).slice(-6) + matchA[2];
-            var termB = ('000000' + matchB[1]).slice(-6) + matchB[2];
-            return termA > termB ? 1 : -1;
-        });
-        jobGraphMaxima[series] = null;
-        window.recreate = true;
-    }
-
-    if( datum !== 0 && ( jobGraphMaxima[series] === null || jobGraphMaxima[series] < datum ) ) {
-        if( isJobSeriesRescalable(series) ) {
-            // use old maximum to get a scale conversion
-            var scaleConversion = jobGraphMaxima[series]/datum;
-            // set new maximum and rescale the series
-            jobGraphMaxima[series] = datum;
-            rescaleJobGraphSeries( series, scaleConversion );
-        }
-    }
-
-    // scale
-    var scaledDatum = null;
-    if( isJobSeriesRescalable(series) && jobGraphMaxima[series] !== null && jobGraphMaxima[series] !== 0 ) {
-        scaledDatum = datum/jobGraphMaxima[series]
-    } else {
-        scaledDatum = datum;
-    }
-    // identify x axis point, searching from the end of the array (most recent)
-    var found = false;
-    for( var i = jobGraphData.length - 1; i >= 0; i-- ) {
-        if( jobGraphData[i]['t'] === timestamp ) {
-            found = true;
-            jobGraphData[i][series] = scaledDatum;
-            jobGraphData[i]['raw-'+series] = rawDetailData;
-            break;
-        } else if( jobGraphData[i]['t'] < timestamp  ) {
-            // we've gone far enough back in time and this data is supposed to be sorted
-            break;
-        }
-    }
-    // index counter from previous loop will have gone one too far, so add one
-    var insertAt = i+1;
-    if(!found) {
-        // create a new x point for this previously unrecorded timestamp
-        var entry = { 't': timestamp };
-        entry[series] = scaledDatum;
-        entry['raw-'+series] = rawDetailData;
-        jobGraphData.splice( insertAt, 0, entry );
-        var shifted = [];
-        // now let's see about "scrolling" the graph, dropping entries that are too old (>10 minutes)
-        while( jobGraphData.length > 0
-                 && (Date.parse( jobGraphData[0]['t'] ) + 10*60000 < Date.parse( jobGraphData[jobGraphData.length-1]['t'] )) ) {
-            shifted.push(jobGraphData.shift());
-        }
-        if( shifted.length > 0 ) {
-            // from those that we dropped, were any of them maxima? if so we need to rescale
-            jobGraphSeries.forEach( function(series) {
-                // test that every shifted entry in this series was either not a number (in which case we don't care)
-                // or else approximately (to 2 decimal places) smaller than the scaled maximum (i.e. 1),
-                // because otherwise we just scrolled off something that was a maximum point
-                // and so we need to recalculate a new maximum point by looking at all remaining displayed points in the series
-                if( isJobSeriesRescalable(series) && jobGraphMaxima[series] !== null
-                      && !shifted.every( function(e) { return( !$.isNumeric(e[series]) || e[series].toFixed(2) < 1.0 ) } ) ) {
-                    // check the remaining displayed points and find the new (scaled) maximum
-                    var seriesMax = null;
-                    jobGraphData.forEach( function(entry) {
-                        if( $.isNumeric(entry[series]) && (seriesMax === null || entry[series] > seriesMax)) {
-                            seriesMax = entry[series];
-                        }
-                    });
-                    if( seriesMax !== null && seriesMax !== 0 ) {
-                        // set new actual maximum using the new maximum as the conversion conversion and rescale the series
-                        jobGraphMaxima[series] *= seriesMax;
-                        var scaleConversion = 1/seriesMax;
-                        rescaleJobGraphSeries( series, scaleConversion );
-                    }
-                    else {
-                        // we no longer have any data points displaying for this series
-                        jobGraphMaxima[series] = null;
-                    }
-                }
-            });
-        }
-        // add a 10 minute old null data point to keep the chart honest if the oldest point is less than 9.9 minutes old
-        if( jobGraphData.length > 0 ) {
-            var earliestTimestamp = jobGraphData[0]['t'];
-            var mostRecentTimestamp = jobGraphData[jobGraphData.length-1]['t'];
-            if( (Date.parse( earliestTimestamp ) + 9.9*60000 > Date.parse( mostRecentTimestamp )) ) {
-                var tenMinutesBefore = (new Date(Date.parse( mostRecentTimestamp ) - 600*1000)).toISOString();
-                jobGraphData.unshift( { 't': tenMinutesBefore } );
-            }
-        }
-    }
-
-}
-
-function createJobGraph(elementName) {
-    delete jobGraph;
-    var emptyGraph = false;
-    if( jobGraphData.length === 0 ) {
-        // If there is no data we still want to show an empty graph,
-        // so add an empty datum and placeholder series to fool it
-        // into displaying itself.  Note that when finally a new
-        // series is added, the graph will be recreated anyway.
-        jobGraphData.push( {} );
-        jobGraphSeries.push( '' );
-        emptyGraph = true;
-    }
-    var graphteristics = {
-        element: elementName,
-        data: jobGraphData,
-        ymax: 1.0,
-        yLabelFormat: function () { return ''; },
-        xkey: 't',
-        ykeys: jobGraphSeries,
-        labels: jobGraphSeries,
-        resize: true,
-        hideHover: 'auto',
-        parseTime: true,
-        hoverCallback: function(index, options, content) {
-            var s = '';
-            for (var i=0; i < jobGraphSortedSeries.length; i++) {
-                var sortedIndex = jobGraphSortedSeries[i];
-                var series = options.ykeys[sortedIndex];
-                var datum = options.data[index][series];
-                var point = ''
-                point += "<div class='morris-hover-point' style='color: ";
-                point += options.lineColors[sortedIndex % options.lineColors.length];
-                point += "'>";
-                var labelMatch = options.labels[sortedIndex].match(/^T(\d+)-(.*)/);
-                point += 'Task ' + labelMatch[1] + ' ' + labelMatch[2];
-                point += ": ";
-                if ( datum !== undefined ) {
-                    if( isJobSeriesRescalable( series ) ) {
-                        datum *= jobGraphMaxima[series];
-                    }
-                    if( parseFloat(datum) !== 0 ) {
-                        if( /-cpu$/.test(series) ){
-                            datum = $.number(datum * 100, 1) + '%';
-                        } else if( datum < 10 ) {
-                            datum = $.number(datum, 2);
-                        } else {
-                            datum = $.number(datum);
-                        }
-                        if(options.data[index]['raw-'+series]) {
-                            datum += ' (' + options.data[index]['raw-'+series] + ')';
-                        }
-                    }
-                    point += datum;
-                } else {
-                    continue;
-                }
-                point += "</div> ";
-                s += point;
-            }
-            if (s === '') {
-                // No Y coordinates? This isn't a real data point,
-                // it's just the placeholder we use to make sure the
-                // graph can render when empty. Don't show a tooltip.
-                return '';
-            }
-            return ("<div class='morris-hover-row-label'>" +
-                    options.data[index][options.xkey] +
-                    "</div> " + s);
-        }
-    }
-    if( emptyGraph ) {
-        graphteristics['axes'] = false;
-        graphteristics['parseTime'] = false;
-        graphteristics['hideHover'] = 'always';
-    }
-    $('#' + elementName).html('');
-    window.jobGraph = Morris.Line( graphteristics );
-    if( emptyGraph ) {
-        jobGraphData = [];
-        jobGraphSeries = [];
-    }
-}
-
-function rescaleJobGraphSeries( series, scaleConversion ) {
-    if( isJobSeriesRescalable() ) {
-        $.each( jobGraphData, function( i, entry ) {
-            if( entry[series] !== null && entry[series] !== undefined ) {
-                entry[series] *= scaleConversion;
-            }
-        });
-    }
-}
-
-// that's right - we never do this for the 'cpu' series, which will always be between 0 and 1 anyway
-function isJobSeriesRescalable( series ) {
-    return !/-cpu$/.test(series);
-}
-
-function processLogEventForGraph(event, eventData) {
-    if( eventData.properties.text ) {
-        eventData.properties.text.split('\n').forEach( function( logLine ) {
-            processLogLineForChart( logLine );
-        } );
-    }
-}
-
-$(document).on('arv-log-event', '#log_graph_div', function(event, eventData) {
-    processLogEventForGraph(event, eventData);
-    if (!window.jobGraphShown) {
-        // Draw immediately, instead of waiting for the 5-second
-        // timer.
-        redrawIfNeeded.call(window, this);
-    }
-});
-
-function redrawIfNeeded(graph_div) {
-    if (!window.redraw) {
-        return;
-    }
-    window.redraw = false;
-
-    if (window.recreate) {
-        // Series have changed: we need to draw an entirely new graph.
-        // Running createJobGraph in a show() callback ensures the div
-        // is fully shown when morris uses it to size its svg element.
-        $(graph_div).show(0, createJobGraph.bind(window, $(graph_div).attr('id')));
-        window.jobGraphShown = true;
-        window.recreate = false;
-    } else {
-        window.jobGraph.setData(window.jobGraphData);
-    }
-}
-
-$(document).on('ready ajax:complete', function() {
-    $('#log_graph_div').not('.graph-is-setup').addClass('graph-is-setup').each( function( index, graph_div ) {
-        window.jobGraphShown = false;
-        window.jobGraphData = [];
-        window.jobGraphSeries = [];
-        window.jobGraphSortedSeries = [];
-        window.jobGraphMaxima = {};
-        window.recreate = false;
-        window.redraw = false;
-
-        $.get('/jobs/' + $(graph_div).data('object-uuid') + '/logs.json', function(data) {
-            data.forEach( function( entry ) {
-                processLogEventForGraph({}, entry);
-            });
-            // Update the graph now to show the recent data points
-            // received via /logs.json (along with any new data points
-            // we received via websockets while waiting for /logs.json
-            // to respond).
-            redrawIfNeeded(graph_div);
-        });
-
-        setInterval(redrawIfNeeded.bind(window, graph_div), 5000);
-    });
-});
diff --git a/apps/workbench/app/assets/javascripts/jquery.number.min.js b/apps/workbench/app/assets/javascripts/jquery.number.min.js
deleted file mode 100644 (file)
index 4fce02b..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-/*! jQuery number 2.1.5 (c) github.com/teamdf/jquery-number | opensource.teamdf.com/license */
-(function(e){"use strict";function t(e,t){if(this.createTextRange){var n=this.createTextRange();n.collapse(true);n.moveStart("character",e);n.moveEnd("character",t-e);n.select()}else if(this.setSelectionRange){this.focus();this.setSelectionRange(e,t)}}function n(e){var t=this.value.length;e=e.toLowerCase()=="start"?"Start":"End";if(document.selection){var n=document.selection.createRange(),r,i,s;r=n.duplicate();r.expand("textedit");r.setEndPoint("EndToEnd",n);i=r.text.length-n.text.length;s=i+n.text.length;return e=="Start"?i:s}else if(typeof this["selection"+e]!="undefined"){t=this["selection"+e]}return t}var r={codes:{46:127,188:44,109:45,190:46,191:47,192:96,220:92,222:39,221:93,219:91,173:45,187:61,186:59,189:45,110:46},shifts:{96:"~",49:"!",50:"@",51:"#",52:"$",53:"%",54:"^",55:"&",56:"*",57:"(",48:")",45:"_",61:"+",91:"{",93:"}",92:"|",59:":",39:'"',44:"<",46:">",47:"?"}};e.fn.number=function(i,s,o,u){u=typeof u==="undefined"?",":u;o=typeof o==="undefined"?".":o;s=typeof s==="undefined"?0:s;var a="\\u"+("0000"+o.charCodeAt(0).toString(16)).slice(-4),f=new RegExp("[^"+a+"0-9]","g"),l=new RegExp(a,"g");if(i===true){if(this.is("input:text")){return this.on({"keydown.format":function(i){var a=e(this),f=a.data("numFormat"),l=i.keyCode?i.keyCode:i.which,c="",h=n.apply(this,["start"]),p=n.apply(this,["end"]),d="",v=false;if(r.codes.hasOwnProperty(l)){l=r.codes[l]}if(!i.shiftKey&&l>=65&&l<=90){l+=32}else if(!i.shiftKey&&l>=69&&l<=105){l-=48}else if(i.shiftKey&&r.shifts.hasOwnProperty(l)){c=r.shifts[l]}if(c=="")c=String.fromCharCode(l);if(l!=8&&l!=45&&l!=127&&c!=o&&!c.match(/[0-9]/)){var m=i.keyCode?i.keyCode:i.which;if(m==46||m==8||m==127||m==9||m==27||m==13||(m==65||m==82||m==80||m==83||m==70||m==72||m==66||m==74||m==84||m==90||m==61||m==173||m==48)&&(i.ctrlKey||i.metaKey)===true||(m==86||m==67||m==88)&&(i.ctrlKey||i.metaKey)===true||m>=35&&m<=39||m>=112&&m<=123){return}i.preventDefault();return false}if(h==0&&p==this.value.length||a.val()==0){if(l==8){h=p=1;this.value="";f.init=s>0?-1:0;f.c=s>0?-(s+1):0;t.apply(this,[0,0])}else if(c==o){h=p=1;this.value="0"+o+(new Array(s+1)).join("0");f.init=s>0?1:0;f.c=s>0?-(s+1):0}else if(l==45){h=p=2;this.value="-0"+o+(new Array(s+1)).join("0");f.init=s>0?1:0;f.c=s>0?-(s+1):0;t.apply(this,[2,2])}else{f.init=s>0?-1:0;f.c=s>0?-s:0}}else{f.c=p-this.value.length}f.isPartialSelection=h==p?false:true;if(s>0&&c==o&&h==this.value.length-s-1){f.c++;f.init=Math.max(0,f.init);i.preventDefault();v=this.value.length+f.c}else if(l==45&&(h!=0||this.value.indexOf("-")==0)){i.preventDefault()}else if(c==o){f.init=Math.max(0,f.init);i.preventDefault()}else if(s>0&&l==127&&h==this.value.length-s-1){i.preventDefault()}else if(s>0&&l==8&&h==this.value.length-s){i.preventDefault();f.c--;v=this.value.length+f.c}else if(s>0&&l==127&&h>this.value.length-s-1){if(this.value==="")return;if(this.value.slice(h,h+1)!="0"){d=this.value.slice(0,h)+"0"+this.value.slice(h+1);a.val(d)}i.preventDefault();v=this.value.length+f.c}else if(s>0&&l==8&&h>this.value.length-s){if(this.value==="")return;if(this.value.slice(h-1,h)!="0"){d=this.value.slice(0,h-1)+"0"+this.value.slice(h);a.val(d)}i.preventDefault();f.c--;v=this.value.length+f.c}else if(l==127&&this.value.slice(h,h+1)==u){i.preventDefault()}else if(l==8&&this.value.slice(h-1,h)==u){i.preventDefault();f.c--;v=this.value.length+f.c}else if(s>0&&h==p&&this.value.length>s+1&&h>this.value.length-s-1&&isFinite(+c)&&!i.metaKey&&!i.ctrlKey&&!i.altKey&&c.length===1){if(p===this.value.length){d=this.value.slice(0,h-1)}else{d=this.value.slice(0,h)+this.value.slice(h+1)}this.value=d;v=h}if(v!==false){t.apply(this,[v,v])}a.data("numFormat",f)},"keyup.format":function(r){var i=e(this),o=i.data("numFormat"),u=r.keyCode?r.keyCode:r.which,a=n.apply(this,["start"]),f=n.apply(this,["end"]),l;if(a===0&&f===0&&(u===189||u===109)){i.val("-"+i.val());a=1;o.c=1-this.value.length;o.init=1;i.data("numFormat",o);l=this.value.length+o.c;t.apply(this,[l,l])}if(this.value===""||(u<48||u>57)&&(u<96||u>105)&&u!==8&&u!==46&&u!==110)return;i.val(i.val());if(s>0){if(o.init<1){a=this.value.length-s-(o.init<0?1:0);o.c=a-this.value.length;o.init=1;i.data("numFormat",o)}else if(a>this.value.length-s&&u!=8){o.c++;i.data("numFormat",o)}}if(u==46&&!o.isPartialSelection){o.c++;i.data("numFormat",o)}l=this.value.length+o.c;t.apply(this,[l,l])},"paste.format":function(t){var n=e(this),r=t.originalEvent,i=null;if(window.clipboardData&&window.clipboardData.getData){i=window.clipboardData.getData("Text")}else if(r.clipboardData&&r.clipboardData.getData){i=r.clipboardData.getData("text/plain")}n.val(i);t.preventDefault();return false}}).each(function(){var t=e(this).data("numFormat",{c:-(s+1),decimals:s,thousands_sep:u,dec_point:o,regex_dec_num:f,regex_dec:l,init:this.value.indexOf(".")?true:false});if(this.value==="")return;t.val(t.val())})}else{return this.each(function(){var t=e(this),n=+t.text().replace(f,"").replace(l,".");t.number(!isFinite(n)?0:+n,s,o,u)})}}return this.text(e.number.apply(window,arguments))};var i=null,s=null;if(e.isPlainObject(e.valHooks.text)){if(e.isFunction(e.valHooks.text.get))i=e.valHooks.text.get;if(e.isFunction(e.valHooks.text.set))s=e.valHooks.text.set}else{e.valHooks.text={}}e.valHooks.text.get=function(t){var n=e(t),r,s,o=n.data("numFormat");if(!o){if(e.isFunction(i)){return i(t)}else{return undefined}}else{if(t.value==="")return"";r=+t.value.replace(o.regex_dec_num,"").replace(o.regex_dec,".");return(t.value.indexOf("-")===0?"-":"")+(isFinite(r)?r:0)}};e.valHooks.text.set=function(t,n){var r=e(t),i=r.data("numFormat");if(!i){if(e.isFunction(s)){return s(t,n)}else{return undefined}}else{var o=e.number(n,i.decimals,i.dec_point,i.thousands_sep);return t.value=o}};e.number=function(e,t,n,r){r=typeof r==="undefined"?",":r;n=typeof n==="undefined"?".":n;t=!isFinite(+t)?0:Math.abs(t);var i="\\u"+("0000"+n.charCodeAt(0).toString(16)).slice(-4);var s="\\u"+("0000"+r.charCodeAt(0).toString(16)).slice(-4);e=(e+"").replace(".",n).replace(new RegExp(s,"g"),"").replace(new RegExp(i,"g"),".").replace(new RegExp("[^0-9+-Ee.]","g"),"");var o=!isFinite(+e)?0:+e,u="",a=function(e,t){var n=Math.pow(10,t);return""+Math.round(e*n)/n};u=(t?a(o,t):""+Math.round(o)).split(".");if(u[0].length>3){u[0]=u[0].replace(/\B(?=(?:\d{3})+(?!\d))/g,r)}if((u[1]||"").length<t){u[1]=u[1]||"";u[1]+=(new Array(t-u[1].length+1)).join("0")}return u.join(n)}})(jQuery)
diff --git a/apps/workbench/app/assets/javascripts/keep_disks.js b/apps/workbench/app/assets/javascripts/keep_disks.js
deleted file mode 100644 (file)
index b3fb6dc..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-(function() {
-  var cache_age_axis_label, cache_age_hover, cache_age_in_days, float_as_percentage;
-
-  cache_age_in_days = function(milliseconds_age) {
-    var ONE_DAY;
-    ONE_DAY = 1000 * 60 * 60 * 24;
-    return milliseconds_age / ONE_DAY;
-  };
-
-  cache_age_hover = function(milliseconds_age) {
-    return 'Cache age ' + cache_age_in_days(milliseconds_age).toFixed(1) + ' days.';
-  };
-
-  cache_age_axis_label = function(milliseconds_age) {
-    return cache_age_in_days(milliseconds_age).toFixed(0) + ' days';
-  };
-
-  float_as_percentage = function(proportion) {
-    return (proportion.toFixed(4) * 100) + '%';
-  };
-
-  $.renderHistogram = function(histogram_data) {
-    return Morris.Area({
-      element: 'cache-age-vs-disk-histogram',
-      pointSize: 0,
-      lineWidth: 0,
-      data: histogram_data,
-      xkey: 'age',
-      ykeys: ['persisted', 'cache'],
-      labels: ['Persisted Storage Disk Utilization', 'Cached Storage Disk Utilization'],
-      ymax: 1,
-      ymin: 0,
-      xLabelFormat: cache_age_axis_label,
-      yLabelFormat: float_as_percentage,
-      dateFormat: cache_age_hover
-    });
-  };
-
-}).call(this);
diff --git a/apps/workbench/app/assets/javascripts/link_to_remote.js b/apps/workbench/app/assets/javascripts/link_to_remote.js
deleted file mode 100644 (file)
index 8610ac6..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$.rails.href = function(element) {
-    if (element.is('a')) {
-        // data-remote=true links must put their remote targets in
-        // data-remote-href="..." instead of href="...".  This helps
-        // us avoid accidentally using the same href="..." in both the
-        // remote (Rails UJS) and non-remote (native browser) handlers
-        // -- which differ greatly in how they use that value -- and
-        // forgetting to test any non-remote cases like "open in new
-        // tab". If you really want copy-link-address/open-in-new-tab
-        // to work on a data-remote=true link, supply the
-        // copy-and-pastable URI in href in addition to the AJAX URI
-        // in data-remote-href.
-        //
-        // (Currently, the only places we make any remote links are
-        // link_to() in ApplicationHelper, which renames href="..." to
-        // data-remote-href="...", and select_modal, which builds a
-        // data-remote=true link on the client side.)
-        return element.data('remote-href');
-    } else {
-        // Normal rails-ujs behavior.
-        return element.attr('href');
-    }
-}
diff --git a/apps/workbench/app/assets/javascripts/list.js b/apps/workbench/app/assets/javascripts/list.js
deleted file mode 100644 (file)
index d8ea7ba..0000000
+++ /dev/null
@@ -1,1474 +0,0 @@
-;(function(){
-
-/**
- * Require the given path.
- *
- * @param {String} path
- * @return {Object} exports
- * @api public
- */
-
-function require(path, parent, orig) {
-  var resolved = require.resolve(path);
-
-  // lookup failed
-  if (null == resolved) {
-    orig = orig || path;
-    parent = parent || 'root';
-    var err = new Error('Failed to require "' + orig + '" from "' + parent + '"');
-    err.path = orig;
-    err.parent = parent;
-    err.require = true;
-    throw err;
-  }
-
-  var module = require.modules[resolved];
-
-  // perform real require()
-  // by invoking the module's
-  // registered function
-  if (!module._resolving && !module.exports) {
-    var mod = {};
-    mod.exports = {};
-    mod.client = mod.component = true;
-    module._resolving = true;
-    module.call(this, mod.exports, require.relative(resolved), mod);
-    delete module._resolving;
-    module.exports = mod.exports;
-  }
-
-  return module.exports;
-}
-
-/**
- * Registered modules.
- */
-
-require.modules = {};
-
-/**
- * Registered aliases.
- */
-
-require.aliases = {};
-
-/**
- * Resolve `path`.
- *
- * Lookup:
- *
- *   - PATH/index.js
- *   - PATH.js
- *   - PATH
- *
- * @param {String} path
- * @return {String} path or null
- * @api private
- */
-
-require.resolve = function(path) {
-  if (path.charAt(0) === '/') path = path.slice(1);
-
-  var paths = [
-    path,
-    path + '.js',
-    path + '.json',
-    path + '/index.js',
-    path + '/index.json'
-  ];
-
-  for (var i = 0; i < paths.length; i++) {
-    var path = paths[i];
-    if (require.modules.hasOwnProperty(path)) return path;
-    if (require.aliases.hasOwnProperty(path)) return require.aliases[path];
-  }
-};
-
-/**
- * Normalize `path` relative to the current path.
- *
- * @param {String} curr
- * @param {String} path
- * @return {String}
- * @api private
- */
-
-require.normalize = function(curr, path) {
-  var segs = [];
-
-  if ('.' != path.charAt(0)) return path;
-
-  curr = curr.split('/');
-  path = path.split('/');
-
-  for (var i = 0; i < path.length; ++i) {
-    if ('..' == path[i]) {
-      curr.pop();
-    } else if ('.' != path[i] && '' != path[i]) {
-      segs.push(path[i]);
-    }
-  }
-
-  return curr.concat(segs).join('/');
-};
-
-/**
- * Register module at `path` with callback `definition`.
- *
- * @param {String} path
- * @param {Function} definition
- * @api private
- */
-
-require.register = function(path, definition) {
-  require.modules[path] = definition;
-};
-
-/**
- * Alias a module definition.
- *
- * @param {String} from
- * @param {String} to
- * @api private
- */
-
-require.alias = function(from, to) {
-  if (!require.modules.hasOwnProperty(from)) {
-    throw new Error('Failed to alias "' + from + '", it does not exist');
-  }
-  require.aliases[to] = from;
-};
-
-/**
- * Return a require function relative to the `parent` path.
- *
- * @param {String} parent
- * @return {Function}
- * @api private
- */
-
-require.relative = function(parent) {
-  var p = require.normalize(parent, '..');
-
-  /**
-   * lastIndexOf helper.
-   */
-
-  function lastIndexOf(arr, obj) {
-    var i = arr.length;
-    while (i--) {
-      if (arr[i] === obj) return i;
-    }
-    return -1;
-  }
-
-  /**
-   * The relative require() itself.
-   */
-
-  function localRequire(path) {
-    var resolved = localRequire.resolve(path);
-    return require(resolved, parent, path);
-  }
-
-  /**
-   * Resolve relative to the parent.
-   */
-
-  localRequire.resolve = function(path) {
-    var c = path.charAt(0);
-    if ('/' == c) return path.slice(1);
-    if ('.' == c) return require.normalize(p, path);
-
-    // resolve deps by returning
-    // the dep in the nearest "deps"
-    // directory
-    var segs = parent.split('/');
-    var i = lastIndexOf(segs, 'deps') + 1;
-    if (!i) i = 0;
-    path = segs.slice(0, i + 1).join('/') + '/deps/' + path;
-    return path;
-  };
-
-  /**
-   * Check if module is defined at `path`.
-   */
-
-  localRequire.exists = function(path) {
-    return require.modules.hasOwnProperty(localRequire.resolve(path));
-  };
-
-  return localRequire;
-};
-require.register("component-classes/index.js", function(exports, require, module){
-/**
- * Module dependencies.
- */
-
-var index = require('indexof');
-
-/**
- * Whitespace regexp.
- */
-
-var re = /\s+/;
-
-/**
- * toString reference.
- */
-
-var toString = Object.prototype.toString;
-
-/**
- * Wrap `el` in a `ClassList`.
- *
- * @param {Element} el
- * @return {ClassList}
- * @api public
- */
-
-module.exports = function(el){
-  return new ClassList(el);
-};
-
-/**
- * Initialize a new ClassList for `el`.
- *
- * @param {Element} el
- * @api private
- */
-
-function ClassList(el) {
-  if (!el) throw new Error('A DOM element reference is required');
-  this.el = el;
-  this.list = el.classList;
-}
-
-/**
- * Add class `name` if not already present.
- *
- * @param {String} name
- * @return {ClassList}
- * @api public
- */
-
-ClassList.prototype.add = function(name){
-  // classList
-  if (this.list) {
-    this.list.add(name);
-    return this;
-  }
-
-  // fallback
-  var arr = this.array();
-  var i = index(arr, name);
-  if (!~i) arr.push(name);
-  this.el.className = arr.join(' ');
-  return this;
-};
-
-/**
- * Remove class `name` when present, or
- * pass a regular expression to remove
- * any which match.
- *
- * @param {String|RegExp} name
- * @return {ClassList}
- * @api public
- */
-
-ClassList.prototype.remove = function(name){
-  if ('[object RegExp]' == toString.call(name)) {
-    return this.removeMatching(name);
-  }
-
-  // classList
-  if (this.list) {
-    this.list.remove(name);
-    return this;
-  }
-
-  // fallback
-  var arr = this.array();
-  var i = index(arr, name);
-  if (~i) arr.splice(i, 1);
-  this.el.className = arr.join(' ');
-  return this;
-};
-
-/**
- * Remove all classes matching `re`.
- *
- * @param {RegExp} re
- * @return {ClassList}
- * @api private
- */
-
-ClassList.prototype.removeMatching = function(re){
-  var arr = this.array();
-  for (var i = 0; i < arr.length; i++) {
-    if (re.test(arr[i])) {
-      this.remove(arr[i]);
-    }
-  }
-  return this;
-};
-
-/**
- * Toggle class `name`, can force state via `force`.
- *
- * For browsers that support classList, but do not support `force` yet,
- * the mistake will be detected and corrected.
- *
- * @param {String} name
- * @param {Boolean} force
- * @return {ClassList}
- * @api public
- */
-
-ClassList.prototype.toggle = function(name, force){
-  // classList
-  if (this.list) {
-    if ("undefined" !== typeof force) {
-      if (force !== this.list.toggle(name, force)) {
-        this.list.toggle(name); // toggle again to correct
-      }
-    } else {
-      this.list.toggle(name);
-    }
-    return this;
-  }
-
-  // fallback
-  if ("undefined" !== typeof force) {
-    if (!force) {
-      this.remove(name);
-    } else {
-      this.add(name);
-    }
-  } else {
-    if (this.has(name)) {
-      this.remove(name);
-    } else {
-      this.add(name);
-    }
-  }
-
-  return this;
-};
-
-/**
- * Return an array of classes.
- *
- * @return {Array}
- * @api public
- */
-
-ClassList.prototype.array = function(){
-  var str = this.el.className.replace(/^\s+|\s+$/g, '');
-  var arr = str.split(re);
-  if ('' === arr[0]) arr.shift();
-  return arr;
-};
-
-/**
- * Check if class `name` is present.
- *
- * @param {String} name
- * @return {ClassList}
- * @api public
- */
-
-ClassList.prototype.has =
-ClassList.prototype.contains = function(name){
-  return this.list
-    ? this.list.contains(name)
-    : !! ~index(this.array(), name);
-};
-
-});
-require.register("segmentio-extend/index.js", function(exports, require, module){
-
-module.exports = function extend (object) {
-    // Takes an unlimited number of extenders.
-    var args = Array.prototype.slice.call(arguments, 1);
-
-    // For each extender, copy their properties on our object.
-    for (var i = 0, source; source = args[i]; i++) {
-        if (!source) continue;
-        for (var property in source) {
-            object[property] = source[property];
-        }
-    }
-
-    return object;
-};
-});
-require.register("component-indexof/index.js", function(exports, require, module){
-module.exports = function(arr, obj){
-  if (arr.indexOf) return arr.indexOf(obj);
-  for (var i = 0; i < arr.length; ++i) {
-    if (arr[i] === obj) return i;
-  }
-  return -1;
-};
-});
-require.register("component-event/index.js", function(exports, require, module){
-var bind = window.addEventListener ? 'addEventListener' : 'attachEvent',
-    unbind = window.removeEventListener ? 'removeEventListener' : 'detachEvent',
-    prefix = bind !== 'addEventListener' ? 'on' : '';
-
-/**
- * Bind `el` event `type` to `fn`.
- *
- * @param {Element} el
- * @param {String} type
- * @param {Function} fn
- * @param {Boolean} capture
- * @return {Function}
- * @api public
- */
-
-exports.bind = function(el, type, fn, capture){
-  el[bind](prefix + type, fn, capture || false);
-  return fn;
-};
-
-/**
- * Unbind `el` event `type`'s callback `fn`.
- *
- * @param {Element} el
- * @param {String} type
- * @param {Function} fn
- * @param {Boolean} capture
- * @return {Function}
- * @api public
- */
-
-exports.unbind = function(el, type, fn, capture){
-  el[unbind](prefix + type, fn, capture || false);
-  return fn;
-};
-});
-require.register("timoxley-to-array/index.js", function(exports, require, module){
-/**
- * Convert an array-like object into an `Array`.
- * If `collection` is already an `Array`, then will return a clone of `collection`.
- *
- * @param {Array | Mixed} collection An `Array` or array-like object to convert e.g. `arguments` or `NodeList`
- * @return {Array} Naive conversion of `collection` to a new `Array`.
- * @api public
- */
-
-module.exports = function toArray(collection) {
-  if (typeof collection === 'undefined') return []
-  if (collection === null) return [null]
-  if (collection === window) return [window]
-  if (typeof collection === 'string') return [collection]
-  if (isArray(collection)) return collection
-  if (typeof collection.length != 'number') return [collection]
-  if (typeof collection === 'function' && collection instanceof Function) return [collection]
-
-  var arr = []
-  for (var i = 0; i < collection.length; i++) {
-    if (Object.prototype.hasOwnProperty.call(collection, i) || i in collection) {
-      arr.push(collection[i])
-    }
-  }
-  if (!arr.length) return []
-  return arr
-}
-
-function isArray(arr) {
-  return Object.prototype.toString.call(arr) === "[object Array]";
-}
-
-});
-require.register("javve-events/index.js", function(exports, require, module){
-var events = require('event'),
-  toArray = require('to-array');
-
-/**
- * Bind `el` event `type` to `fn`.
- *
- * @param {Element} el, NodeList, HTMLCollection or Array
- * @param {String} type
- * @param {Function} fn
- * @param {Boolean} capture
- * @api public
- */
-
-exports.bind = function(el, type, fn, capture){
-  el = toArray(el);
-  for ( var i = 0; i < el.length; i++ ) {
-    events.bind(el[i], type, fn, capture);
-  }
-};
-
-/**
- * Unbind `el` event `type`'s callback `fn`.
- *
- * @param {Element} el, NodeList, HTMLCollection or Array
- * @param {String} type
- * @param {Function} fn
- * @param {Boolean} capture
- * @api public
- */
-
-exports.unbind = function(el, type, fn, capture){
-  el = toArray(el);
-  for ( var i = 0; i < el.length; i++ ) {
-    events.unbind(el[i], type, fn, capture);
-  }
-};
-
-});
-require.register("javve-get-by-class/index.js", function(exports, require, module){
-/**
- * Find all elements with class `className` inside `container`.
- * Use `single = true` to increase performance in older browsers
- * when only one element is needed.
- *
- * @param {String} className
- * @param {Element} container
- * @param {Boolean} single
- * @api public
- */
-
-module.exports = (function() {
-  if (document.getElementsByClassName) {
-    return function(container, className, single) {
-      if (single) {
-        return container.getElementsByClassName(className)[0];
-      } else {
-        return container.getElementsByClassName(className);
-      }
-    };
-  } else if (document.querySelector) {
-    return function(container, className, single) {
-      className = '.' + className;
-      if (single) {
-        return container.querySelector(className);
-      } else {
-        return container.querySelectorAll(className);
-      }
-    };
-  } else {
-    return function(container, className, single) {
-      var classElements = [],
-        tag = '*';
-      if (container == null) {
-        container = document;
-      }
-      var els = container.getElementsByTagName(tag);
-      var elsLen = els.length;
-      var pattern = new RegExp("(^|\\s)"+className+"(\\s|$)");
-      for (var i = 0, j = 0; i < elsLen; i++) {
-        if ( pattern.test(els[i].className) ) {
-          if (single) {
-            return els[i];
-          } else {
-            classElements[j] = els[i];
-            j++;
-          }
-        }
-      }
-      return classElements;
-    };
-  }
-})();
-
-});
-require.register("javve-get-attribute/index.js", function(exports, require, module){
-/**
- * Return the value for `attr` at `element`.
- *
- * @param {Element} el
- * @param {String} attr
- * @api public
- */
-
-module.exports = function(el, attr) {
-  var result = (el.getAttribute && el.getAttribute(attr)) || null;
-  if( !result ) {
-    var attrs = el.attributes;
-    var length = attrs.length;
-    for(var i = 0; i < length; i++) {
-      if (attr[i] !== undefined) {
-        if(attr[i].nodeName === attr) {
-          result = attr[i].nodeValue;
-        }
-      }
-    }
-  }
-  return result;
-}
-});
-require.register("javve-natural-sort/index.js", function(exports, require, module){
-/*
- * Natural Sort algorithm for Javascript - Version 0.7 - Released under MIT license
- * Author: Jim Palmer (based on chunking idea from Dave Koelle)
- */
-
-module.exports = function(a, b, options) {
-  var re = /(^-?[0-9]+(\.?[0-9]*)[df]?e?[0-9]?$|^0x[0-9a-f]+$|[0-9]+)/gi,
-    sre = /(^[ ]*|[ ]*$)/g,
-    dre = /(^([\w ]+,?[\w ]+)?[\w ]+,?[\w ]+\d+:\d+(:\d+)?[\w ]?|^\d{1,4}[\/\-]\d{1,4}[\/\-]\d{1,4}|^\w+, \w+ \d+, \d{4})/,
-    hre = /^0x[0-9a-f]+$/i,
-    ore = /^0/,
-    options = options || {},
-    i = function(s) { return options.insensitive && (''+s).toLowerCase() || ''+s },
-    // convert all to strings strip whitespace
-    x = i(a).replace(sre, '') || '',
-    y = i(b).replace(sre, '') || '',
-    // chunk/tokenize
-    xN = x.replace(re, '\0$1\0').replace(/\0$/,'').replace(/^\0/,'').split('\0'),
-    yN = y.replace(re, '\0$1\0').replace(/\0$/,'').replace(/^\0/,'').split('\0'),
-    // numeric, hex or date detection
-    xD = parseInt(x.match(hre)) || (xN.length != 1 && x.match(dre) && Date.parse(x)),
-    yD = parseInt(y.match(hre)) || xD && y.match(dre) && Date.parse(y) || null,
-    oFxNcL, oFyNcL,
-    mult = options.desc ? -1 : 1;
-  // first try and sort Hex codes or Dates
-  if (yD)
-    if ( xD < yD ) return -1 * mult;
-    else if ( xD > yD ) return 1 * mult;
-  // natural sorting through split numeric strings and default strings
-  for(var cLoc=0, numS=Math.max(xN.length, yN.length); cLoc < numS; cLoc++) {
-    // find floats not starting with '0', string or 0 if not defined (Clint Priest)
-    oFxNcL = !(xN[cLoc] || '').match(ore) && parseFloat(xN[cLoc]) || xN[cLoc] || 0;
-    oFyNcL = !(yN[cLoc] || '').match(ore) && parseFloat(yN[cLoc]) || yN[cLoc] || 0;
-    // handle numeric vs string comparison - number < string - (Kyle Adams)
-    if (isNaN(oFxNcL) !== isNaN(oFyNcL)) { return (isNaN(oFxNcL)) ? 1 : -1; }
-    // rely on string comparison if different types - i.e. '02' < 2 != '02' < '2'
-    else if (typeof oFxNcL !== typeof oFyNcL) {
-      oFxNcL += '';
-      oFyNcL += '';
-    }
-    if (oFxNcL < oFyNcL) return -1 * mult;
-    if (oFxNcL > oFyNcL) return 1 * mult;
-  }
-  return 0;
-};
-
-/*
-var defaultSort = getSortFunction();
-
-module.exports = function(a, b, options) {
-  if (arguments.length == 1) {
-    options = a;
-    return getSortFunction(options);
-  } else {
-    return defaultSort(a,b);
-  }
-}
-*/
-});
-require.register("javve-to-string/index.js", function(exports, require, module){
-module.exports = function(s) {
-    s = (s === undefined) ? "" : s;
-    s = (s === null) ? "" : s;
-    s = s.toString();
-    return s;
-};
-
-});
-require.register("component-type/index.js", function(exports, require, module){
-/**
- * toString ref.
- */
-
-var toString = Object.prototype.toString;
-
-/**
- * Return the type of `val`.
- *
- * @param {Mixed} val
- * @return {String}
- * @api public
- */
-
-module.exports = function(val){
-  switch (toString.call(val)) {
-    case '[object Date]': return 'date';
-    case '[object RegExp]': return 'regexp';
-    case '[object Arguments]': return 'arguments';
-    case '[object Array]': return 'array';
-    case '[object Error]': return 'error';
-  }
-
-  if (val === null) return 'null';
-  if (val === undefined) return 'undefined';
-  if (val !== val) return 'nan';
-  if (val && val.nodeType === 1) return 'element';
-
-  return typeof val.valueOf();
-};
-
-});
-require.register("list.js/index.js", function(exports, require, module){
-/*
-ListJS with beta 1.0.0
-By Jonny Strömberg (www.jonnystromberg.com, www.listjs.com)
-*/
-(function( window, undefined ) {
-"use strict";
-
-var document = window.document,
-    getByClass = require('get-by-class'),
-    extend = require('extend'),
-    indexOf = require('indexof');
-
-var List = function(id, options, values) {
-
-    var self = this,
-               init,
-        Item = require('./src/item')(self),
-        addAsync = require('./src/add-async')(self),
-        parse = require('./src/parse')(self);
-
-    init = {
-        start: function() {
-            self.listClass      = "list";
-            self.searchClass    = "search";
-            self.sortClass      = "sort";
-            self.page           = 200;
-            self.i              = 1;
-            self.items          = [];
-            self.visibleItems   = [];
-            self.matchingItems  = [];
-            self.searched       = false;
-            self.filtered       = false;
-            self.handlers       = { 'updated': [] };
-            self.plugins        = {};
-            self.helpers        = {
-                getByClass: getByClass,
-                extend: extend,
-                indexOf: indexOf
-            };
-
-            extend(self, options);
-
-            self.listContainer = (typeof(id) === 'string') ? document.getElementById(id) : id;
-            if (!self.listContainer) { return; }
-            self.list           = getByClass(self.listContainer, self.listClass, true);
-
-            self.templater      = require('./src/templater')(self);
-            self.search         = require('./src/search')(self);
-            self.filter         = require('./src/filter')(self);
-            self.sort           = require('./src/sort')(self);
-
-            this.items();
-            self.update();
-            this.plugins();
-        },
-        items: function() {
-            parse(self.list);
-            if (values !== undefined) {
-                self.add(values);
-            }
-        },
-        plugins: function() {
-            for (var i = 0; i < self.plugins.length; i++) {
-                var plugin = self.plugins[i];
-                self[plugin.name] = plugin;
-                plugin.init(self);
-            }
-        }
-    };
-
-
-    /*
-    * Add object to list
-    */
-    this.add = function(values, callback) {
-        if (callback) {
-            addAsync(values, callback);
-            return;
-        }
-        var added = [],
-            notCreate = false;
-        if (values[0] === undefined){
-            values = [values];
-        }
-        for (var i = 0, il = values.length; i < il; i++) {
-            var item = null;
-            if (values[i] instanceof Item) {
-                item = values[i];
-                item.reload();
-            } else {
-                notCreate = (self.items.length > self.page) ? true : false;
-                item = new Item(values[i], undefined, notCreate);
-            }
-            self.items.push(item);
-            added.push(item);
-        }
-        self.update();
-        return added;
-    };
-
-       this.show = function(i, page) {
-               this.i = i;
-               this.page = page;
-               self.update();
-        return self;
-       };
-
-    /* Removes object from list.
-    * Loops through the list and removes objects where
-    * property "valuename" === value
-    */
-    this.remove = function(valueName, value, options) {
-        var found = 0;
-        for (var i = 0, il = self.items.length; i < il; i++) {
-            if (self.items[i].values()[valueName] == value) {
-                self.templater.remove(self.items[i], options);
-                self.items.splice(i,1);
-                il--;
-                i--;
-                found++;
-            }
-        }
-        self.update();
-        return found;
-    };
-
-    /* Gets the objects in the list which
-    * property "valueName" === value
-    */
-    this.get = function(valueName, value) {
-        var matchedItems = [];
-        for (var i = 0, il = self.items.length; i < il; i++) {
-            var item = self.items[i];
-            if (item.values()[valueName] == value) {
-                matchedItems.push(item);
-            }
-        }
-        return matchedItems;
-    };
-
-    /*
-    * Get size of the list
-    */
-    this.size = function() {
-        return self.items.length;
-    };
-
-    /*
-    * Removes all items from the list
-    */
-    this.clear = function() {
-        self.templater.clear();
-        self.items = [];
-        return self;
-    };
-
-    this.on = function(event, callback) {
-        self.handlers[event].push(callback);
-        return self;
-    };
-
-    this.off = function(event, callback) {
-        var e = self.handlers[event];
-        var index = indexOf(e, callback);
-        if (index > -1) {
-            e.splice(index, 1);
-        }
-        return self;
-    };
-
-    this.trigger = function(event) {
-        var i = self.handlers[event].length;
-        while(i--) {
-            self.handlers[event][i](self);
-        }
-        return self;
-    };
-
-    this.reset = {
-        filter: function() {
-            var is = self.items,
-                il = is.length;
-            while (il--) {
-                is[il].filtered = false;
-            }
-            return self;
-        },
-        search: function() {
-            var is = self.items,
-                il = is.length;
-            while (il--) {
-                is[il].found = false;
-            }
-            return self;
-        }
-    };
-
-    this.update = function() {
-        var is = self.items,
-                       il = is.length;
-
-        self.visibleItems = [];
-        self.matchingItems = [];
-        self.templater.clear();
-        for (var i = 0; i < il; i++) {
-            if (is[i].matching() && ((self.matchingItems.length+1) >= self.i && self.visibleItems.length < self.page)) {
-                is[i].show();
-                self.visibleItems.push(is[i]);
-                self.matchingItems.push(is[i]);
-                       } else if (is[i].matching()) {
-                self.matchingItems.push(is[i]);
-                is[i].hide();
-                       } else {
-                is[i].hide();
-                       }
-        }
-        self.trigger('updated');
-        return self;
-    };
-
-    init.start();
-};
-
-module.exports = List;
-
-})(window);
-
-});
-require.register("list.js/src/search.js", function(exports, require, module){
-var events = require('events'),
-    getByClass = require('get-by-class'),
-    toString = require('to-string');
-
-module.exports = function(list) {
-    var item,
-        text,
-        columns,
-        searchString,
-        customSearch;
-
-    var prepare = {
-        resetList: function() {
-            list.i = 1;
-            list.templater.clear();
-            customSearch = undefined;
-        },
-        setOptions: function(args) {
-            if (args.length == 2 && args[1] instanceof Array) {
-                columns = args[1];
-            } else if (args.length == 2 && typeof(args[1]) == "function") {
-                customSearch = args[1];
-            } else if (args.length == 3) {
-                columns = args[1];
-                customSearch = args[2];
-            }
-        },
-        setColumns: function() {
-            columns = (columns === undefined) ? prepare.toArray(list.items[0].values()) : columns;
-        },
-        setSearchString: function(s) {
-            s = toString(s).toLowerCase();
-            s = s.replace(/[-[\]{}()*+?.,\\^$|#]/g, "\\$&"); // Escape regular expression characters
-            searchString = s;
-        },
-        toArray: function(values) {
-            var tmpColumn = [];
-            for (var name in values) {
-                tmpColumn.push(name);
-            }
-            return tmpColumn;
-        }
-    };
-    var search = {
-        list: function() {
-            for (var k = 0, kl = list.items.length; k < kl; k++) {
-                search.item(list.items[k]);
-            }
-        },
-        item: function(item) {
-            item.found = false;
-            for (var j = 0, jl = columns.length; j < jl; j++) {
-                if (search.values(item.values(), columns[j])) {
-                    item.found = true;
-                    return;
-                }
-            }
-        },
-        values: function(values, column) {
-            if (values.hasOwnProperty(column)) {
-                text = toString(values[column]).toLowerCase();
-                if ((searchString !== "") && (text.search(searchString) > -1)) {
-                    return true;
-                }
-            }
-            return false;
-        },
-        reset: function() {
-            list.reset.search();
-            list.searched = false;
-        }
-    };
-
-    var searchMethod = function(str) {
-        list.trigger('searchStart');
-
-        prepare.resetList();
-        prepare.setSearchString(str);
-        prepare.setOptions(arguments); // str, cols|searchFunction, searchFunction
-        prepare.setColumns();
-
-        if (searchString === "" ) {
-            search.reset();
-        } else {
-            list.searched = true;
-            if (customSearch) {
-                customSearch(searchString, columns);
-            } else {
-                search.list();
-            }
-        }
-
-        list.update();
-        list.trigger('searchComplete');
-        return list.visibleItems;
-    };
-
-    list.handlers.searchStart = list.handlers.searchStart || [];
-    list.handlers.searchComplete = list.handlers.searchComplete || [];
-
-    events.bind(getByClass(list.listContainer, list.searchClass), 'keyup', function(e) {
-        var target = e.target || e.srcElement, // IE have srcElement
-            alreadyCleared = (target.value === "" && !list.searched);
-        if (!alreadyCleared) { // If oninput already have resetted the list, do nothing
-            searchMethod(target.value);
-        }
-    });
-
-    // Used to detect click on HTML5 clear button
-    events.bind(getByClass(list.listContainer, list.searchClass), 'input', function(e) {
-        var target = e.target || e.srcElement;
-        if (target.value === "") {
-            searchMethod('');
-        }
-    });
-
-    list.helpers.toString = toString;
-    return searchMethod;
-};
-
-});
-require.register("list.js/src/sort.js", function(exports, require, module){
-var naturalSort = require('natural-sort'),
-    classes = require('classes'),
-    events = require('events'),
-    getByClass = require('get-by-class'),
-    getAttribute = require('get-attribute');
-
-module.exports = function(list) {
-    list.sortFunction = list.sortFunction || function(itemA, itemB, options) {
-        options.desc = options.order == "desc" ? true : false; // Natural sort uses this format
-        return naturalSort(itemA.values()[options.valueName], itemB.values()[options.valueName], options);
-    };
-
-    var buttons = {
-        els: undefined,
-        clear: function() {
-            for (var i = 0, il = buttons.els.length; i < il; i++) {
-                classes(buttons.els[i]).remove('asc');
-                classes(buttons.els[i]).remove('desc');
-            }
-        },
-        getOrder: function(btn) {
-            var predefinedOrder = getAttribute(btn, 'data-order');
-            if (predefinedOrder == "asc" || predefinedOrder == "desc") {
-                return predefinedOrder;
-            } else if (classes(btn).has('desc')) {
-                return "asc";
-            } else if (classes(btn).has('asc')) {
-                return "desc";
-            } else {
-                return "asc";
-            }
-        },
-        getInSensitive: function(btn, options) {
-            var insensitive = getAttribute(btn, 'data-insensitive');
-            if (insensitive === "true") {
-                options.insensitive = true;
-            } else {
-                options.insensitive = false;
-            }
-        },
-        setOrder: function(options) {
-            for (var i = 0, il = buttons.els.length; i < il; i++) {
-                var btn = buttons.els[i];
-                if (getAttribute(btn, 'data-sort') !== options.valueName) {
-                    continue;
-                }
-                var predefinedOrder = getAttribute(btn, 'data-order');
-                if (predefinedOrder == "asc" || predefinedOrder == "desc") {
-                    if (predefinedOrder == options.order) {
-                        classes(btn).add(options.order);
-                    }
-                } else {
-                    classes(btn).add(options.order);
-                }
-            }
-        }
-    };
-    var sort = function() {
-        list.trigger('sortStart');
-        options = {};
-
-        var target = arguments[0].currentTarget || arguments[0].srcElement || undefined;
-
-        if (target) {
-            options.valueName = getAttribute(target, 'data-sort');
-            buttons.getInSensitive(target, options);
-            options.order = buttons.getOrder(target);
-        } else {
-            options = arguments[1] || options;
-            options.valueName = arguments[0];
-            options.order = options.order || "asc";
-            options.insensitive = (typeof options.insensitive == "undefined") ? true : options.insensitive;
-        }
-        buttons.clear();
-        buttons.setOrder(options);
-
-        options.sortFunction = options.sortFunction || list.sortFunction;
-        list.items.sort(function(a, b) {
-            return options.sortFunction(a, b, options);
-        });
-        list.update();
-        list.trigger('sortComplete');
-    };
-
-    // Add handlers
-    list.handlers.sortStart = list.handlers.sortStart || [];
-    list.handlers.sortComplete = list.handlers.sortComplete || [];
-
-    buttons.els = getByClass(list.listContainer, list.sortClass);
-    events.bind(buttons.els, 'click', sort);
-    list.on('searchStart', buttons.clear);
-    list.on('filterStart', buttons.clear);
-
-    // Helpers
-    list.helpers.classes = classes;
-    list.helpers.naturalSort = naturalSort;
-    list.helpers.events = events;
-    list.helpers.getAttribute = getAttribute;
-
-    return sort;
-};
-
-});
-require.register("list.js/src/item.js", function(exports, require, module){
-module.exports = function(list) {
-    return function(initValues, element, notCreate) {
-        var item = this;
-
-        this._values = {};
-
-        this.found = false; // Show if list.searched == true and this.found == true
-        this.filtered = false;// Show if list.filtered == true and this.filtered == true
-
-        var init = function(initValues, element, notCreate) {
-            if (element === undefined) {
-                if (notCreate) {
-                    item.values(initValues, notCreate);
-                } else {
-                    item.values(initValues);
-                }
-            } else {
-                item.elm = element;
-                var values = list.templater.get(item, initValues);
-                item.values(values);
-            }
-        };
-        this.values = function(newValues, notCreate) {
-            if (newValues !== undefined) {
-                for(var name in newValues) {
-                    item._values[name] = newValues[name];
-                }
-                if (notCreate !== true) {
-                    list.templater.set(item, item.values());
-                }
-            } else {
-                return item._values;
-            }
-        };
-        this.show = function() {
-            list.templater.show(item);
-        };
-        this.hide = function() {
-            list.templater.hide(item);
-        };
-        this.matching = function() {
-            return (
-                (list.filtered && list.searched && item.found && item.filtered) ||
-                (list.filtered && !list.searched && item.filtered) ||
-                (!list.filtered && list.searched && item.found) ||
-                (!list.filtered && !list.searched)
-            );
-        };
-        this.visible = function() {
-            return (item.elm.parentNode == list.list) ? true : false;
-        };
-        init(initValues, element, notCreate);
-    };
-};
-
-});
-require.register("list.js/src/templater.js", function(exports, require, module){
-var getByClass = require('get-by-class');
-
-var Templater = function(list) {
-    var itemSource = getItemSource(list.item),
-        templater = this;
-
-    function getItemSource(item) {
-        if (item === undefined) {
-            var nodes = list.list.childNodes,
-                items = [];
-
-            for (var i = 0, il = nodes.length; i < il; i++) {
-                // Only textnodes have a data attribute
-                if (nodes[i].data === undefined) {
-                    return nodes[i];
-                }
-            }
-            return null;
-        } else if (item.indexOf("<") !== -1) { // Try create html element of list, do not work for tables!!
-            var div = document.createElement('div');
-            div.innerHTML = item;
-            return div.firstChild;
-        } else {
-            return document.getElementById(list.item);
-        }
-    }
-
-    /* Get values from element */
-    this.get = function(item, valueNames) {
-        templater.create(item);
-        var values = {};
-        for(var i = 0, il = valueNames.length; i < il; i++) {
-            var elm = getByClass(item.elm, valueNames[i], true);
-            values[valueNames[i]] = elm ? elm.innerHTML : "";
-        }
-        return values;
-    };
-
-    /* Sets values at element */
-    this.set = function(item, values) {
-        if (!templater.create(item)) {
-            for(var v in values) {
-                if (values.hasOwnProperty(v)) {
-                    // TODO speed up if possible
-                    var elm = getByClass(item.elm, v, true);
-                    if (elm) {
-                        /* src attribute for image tag & text for other tags */
-                        if (elm.tagName === "IMG" && values[v] !== "") {
-                            elm.src = values[v];
-                        } else {
-                            elm.innerHTML = values[v];
-                        }
-                    }
-                }
-            }
-        }
-    };
-
-    this.create = function(item) {
-        if (item.elm !== undefined) {
-            return false;
-        }
-        /* If item source does not exists, use the first item in list as
-        source for new items */
-        var newItem = itemSource.cloneNode(true);
-        newItem.removeAttribute('id');
-        item.elm = newItem;
-        templater.set(item, item.values());
-        return true;
-    };
-    this.remove = function(item) {
-        list.list.removeChild(item.elm);
-    };
-    this.show = function(item) {
-        templater.create(item);
-        list.list.appendChild(item.elm);
-    };
-    this.hide = function(item) {
-        if (item.elm !== undefined && item.elm.parentNode === list.list) {
-            list.list.removeChild(item.elm);
-        }
-    };
-    this.clear = function() {
-        /* .innerHTML = ''; fucks up IE */
-        if (list.list.hasChildNodes()) {
-            while (list.list.childNodes.length >= 1)
-            {
-                list.list.removeChild(list.list.firstChild);
-            }
-        }
-    };
-};
-
-module.exports = function(list) {
-    return new Templater(list);
-};
-
-});
-require.register("list.js/src/filter.js", function(exports, require, module){
-module.exports = function(list) {
-
-    // Add handlers
-    list.handlers.filterStart = list.handlers.filterStart || [];
-    list.handlers.filterComplete = list.handlers.filterComplete || [];
-
-    return function(filterFunction) {
-        list.trigger('filterStart');
-        list.i = 1; // Reset paging
-        list.reset.filter();
-        if (filterFunction === undefined) {
-            list.filtered = false;
-        } else {
-            list.filtered = true;
-            var is = list.items;
-            for (var i = 0, il = is.length; i < il; i++) {
-                var item = is[i];
-                if (filterFunction(item)) {
-                    item.filtered = true;
-                } else {
-                    item.filtered = false;
-                }
-            }
-        }
-        list.update();
-        list.trigger('filterComplete');
-        return list.visibleItems;
-    };
-};
-
-});
-require.register("list.js/src/add-async.js", function(exports, require, module){
-module.exports = function(list) {
-    return function(values, callback, items) {
-        var valuesToAdd = values.splice(0, 100);
-        items = items || [];
-        items = items.concat(list.add(valuesToAdd));
-        if (values.length > 0) {
-            setTimeout(function() {
-                addAsync(values, callback, items);
-            }, 10);
-        } else {
-            list.update();
-            callback(items);
-        }
-    };
-};
-});
-require.register("list.js/src/parse.js", function(exports, require, module){
-module.exports = function(list) {
-
-    var Item = require('./item')(list);
-
-    var getChildren = function(parent) {
-        var nodes = parent.childNodes,
-            items = [];
-        for (var i = 0, il = nodes.length; i < il; i++) {
-            // Only textnodes have a data attribute
-            if (nodes[i].data === undefined) {
-                items.push(nodes[i]);
-            }
-        }
-        return items;
-    };
-
-    var parse = function(itemElements, valueNames) {
-        for (var i = 0, il = itemElements.length; i < il; i++) {
-            list.items.push(new Item(valueNames, itemElements[i]));
-        }
-    };
-    var parseAsync = function(itemElements, valueNames) {
-        var itemsToIndex = itemElements.splice(0, 100); // TODO: If < 100 items, what happens in IE etc?
-        parse(itemsToIndex, valueNames);
-        if (itemElements.length > 0) {
-            setTimeout(function() {
-                init.items.indexAsync(itemElements, valueNames);
-            }, 10);
-        } else {
-            list.update();
-            // TODO: Add indexed callback
-        }
-    };
-
-    return function() {
-        var itemsToIndex = getChildren(list.list),
-            valueNames = list.valueNames;
-
-        if (list.indexAsync) {
-            parseAsync(itemsToIndex, valueNames);
-        } else {
-            parse(itemsToIndex, valueNames);
-        }
-    };
-};
-
-});
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-require.alias("component-classes/index.js", "list.js/deps/classes/index.js");
-require.alias("component-classes/index.js", "classes/index.js");
-require.alias("component-indexof/index.js", "component-classes/deps/indexof/index.js");
-
-require.alias("segmentio-extend/index.js", "list.js/deps/extend/index.js");
-require.alias("segmentio-extend/index.js", "extend/index.js");
-
-require.alias("component-indexof/index.js", "list.js/deps/indexof/index.js");
-require.alias("component-indexof/index.js", "indexof/index.js");
-
-require.alias("javve-events/index.js", "list.js/deps/events/index.js");
-require.alias("javve-events/index.js", "events/index.js");
-require.alias("component-event/index.js", "javve-events/deps/event/index.js");
-
-require.alias("timoxley-to-array/index.js", "javve-events/deps/to-array/index.js");
-
-require.alias("javve-get-by-class/index.js", "list.js/deps/get-by-class/index.js");
-require.alias("javve-get-by-class/index.js", "get-by-class/index.js");
-
-require.alias("javve-get-attribute/index.js", "list.js/deps/get-attribute/index.js");
-require.alias("javve-get-attribute/index.js", "get-attribute/index.js");
-
-require.alias("javve-natural-sort/index.js", "list.js/deps/natural-sort/index.js");
-require.alias("javve-natural-sort/index.js", "natural-sort/index.js");
-
-require.alias("javve-to-string/index.js", "list.js/deps/to-string/index.js");
-require.alias("javve-to-string/index.js", "list.js/deps/to-string/index.js");
-require.alias("javve-to-string/index.js", "to-string/index.js");
-require.alias("javve-to-string/index.js", "javve-to-string/index.js");
-require.alias("component-type/index.js", "list.js/deps/type/index.js");
-require.alias("component-type/index.js", "type/index.js");
-if (typeof exports == "object") {
-  module.exports = require("list.js");
-} else if (typeof define == "function" && define.amd) {
-  define(function(){ return require("list.js"); });
-} else {
-  this["List"] = require("list.js");
-}})();
\ No newline at end of file
diff --git a/apps/workbench/app/assets/javascripts/log_viewer.js b/apps/workbench/app/assets/javascripts/log_viewer.js
deleted file mode 100644 (file)
index b201ed7..0000000
+++ /dev/null
@@ -1,286 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-function newTaskState() {
-    return {"complete_count": 0,
-            "failure_count": 0,
-            "task_count": 0,
-            "incomplete_count": 0,
-            "nodes": []};
-}
-
-function addToLogViewer(logViewer, lines, taskState) {
-    var re = /((\d\d\d\d)-(\d\d)-(\d\d))_((\d\d):(\d\d):(\d\d)) ([a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}) (\d+) (\d+)? (.*)/;
-
-    var items = [];
-    var count = logViewer.items.length;
-    for (var a in lines) {
-        var v = lines[a].match(re);
-        if (v != null) {
-
-            var ts = new Date(Date.UTC(v[2], v[3]-1, v[4], v[6], v[7], v[8]));
-
-            v11 = v[11];
-            if (typeof v[11] === 'undefined') {
-                v11 = "";
-            } else {
-                v11 = Number(v11);
-            }
-
-            var message = v[12];
-            var type = "";
-            var node = "";
-            var slot = "";
-            if (v11 !== "") {
-                if (!taskState.hasOwnProperty(v11)) {
-                    taskState[v11] = {};
-                    taskState.task_count += 1;
-                }
-
-                if (/^stderr /.test(message)) {
-                    message = message.substr(7);
-                    if (/^crunchstat: /.test(message)) {
-                        type = "crunchstat";
-                        message = message.substr(12);
-                    } else if (/^srun: /.test(message) || /^slurmd/.test(message)) {
-                        type = "task-dispatch";
-                    } else {
-                        type = "task-print";
-                    }
-                } else {
-                    var m;
-                    if (m = /^success in (\d+) second/.exec(message)) {
-                        taskState[v11].outcome = "success";
-                        taskState[v11].runtime = Number(m[1]);
-                        taskState.complete_count += 1;
-                    }
-                    else if (m = /^failure \(\#\d+, (temporary|permanent)\) after (\d+) second/.exec(message)) {
-                        taskState[v11].outcome = "failure";
-                        taskState[v11].runtime = Number(m[2]);
-                        taskState.failure_count += 1;
-                        if (m[1] == "permanent") {
-                            taskState.incomplete_count += 1;
-                        }
-                    }
-                    else if (m = /^child \d+ started on ([^.]*)\.(\d+)/.exec(message)) {
-                        taskState[v11].node = m[1];
-                        taskState[v11].slot = m[2];
-                        if (taskState.nodes.indexOf(m[1], 0) == -1) {
-                            taskState.nodes.push(m[1]);
-                        }
-                        for (var i in items) {
-                            if (i > 0) {
-                                if (items[i].taskid === v11) {
-                                    items[i].node = m[1];
-                                    items[i].slot = m[2];
-                                }
-                            }
-                        }
-                    }
-                    type = "task-dispatch";
-                }
-                node = taskState[v11].node;
-                slot = taskState[v11].slot;
-            } else {
-                type = "crunch";
-            }
-
-            items.push({
-                id: count,
-                ts: ts,
-                timestamp: ts.toLocaleDateString() + " " + ts.toLocaleTimeString(),
-                taskid: v11,
-                node: node,
-                slot: slot,
-                message: message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
-                type: type
-            });
-            count += 1;
-        } else {
-            console.log("Did not parse line " + a + ": " + lines[a]);
-        }
-    }
-    logViewer.add(items);
-}
-
-function sortById(a, b, opt) {
-    a = a.values();
-    b = b.values();
-
-    if (a["id"] > b["id"]) {
-        return 1;
-    }
-    if (a["id"] < b["id"]) {
-        return -1;
-    }
-    return 0;
-}
-
-function sortByTask(a, b, opt) {
-    var aa = a.values();
-    var bb = b.values();
-
-    if (aa["taskid"] === "" && bb["taskid"] !== "") {
-        return -1;
-    }
-    if (aa["taskid"] !== "" && bb["taskid"] === "") {
-        return 1;
-    }
-
-    if (aa["taskid"] !== "" && bb["taskid"] !== "") {
-        if (aa["taskid"] > bb["taskid"]) {
-            return 1;
-        }
-        if (aa["taskid"] < bb["taskid"]) {
-            return -1;
-        }
-    }
-
-    return sortById(a, b, opt);
-}
-
-function sortByNode(a, b, opt) {
-    var aa = a.values();
-    var bb = b.values();
-
-    if (aa["node"] === "" && bb["node"] !== "") {
-        return -1;
-    }
-    if (aa["node"] !== "" && bb["node"] === "") {
-        return 1;
-    }
-
-    if (aa["node"] !== "" && bb["node"] !== "") {
-        if (aa["node"] > bb["node"]) {
-            return 1;
-        }
-        if (aa["node"] < bb["node"]) {
-            return -1;
-        }
-    }
-
-    if (aa["slot"] !== "" && bb["slot"] !== "") {
-        if (aa["slot"] > bb["slot"]) {
-            return 1;
-        }
-        if (aa["slot"] < bb["slot"]) {
-            return -1;
-        }
-    }
-
-    return sortById(a, b, opt);
-}
-
-
-function dumbPluralize(n, s, p) {
-    if (typeof p === 'undefined') {
-        p = "s";
-    }
-    if (n == 0 || n > 1) {
-        return n + " " + (s + p);
-    } else {
-        return n + " " + s;
-    }
-}
-
-function generateJobOverview(id, logViewer, taskState) {
-    var html = "";
-
-    if (logViewer.items.length > 2) {
-        var first = logViewer.items[1];
-        var last = logViewer.items[logViewer.items.length-1];
-        var duration = (last.values().ts.getTime() - first.values().ts.getTime()) / 1000;
-
-        var hours = 0;
-        var minutes = 0;
-        var seconds;
-
-        if (duration >= 3600) {
-            hours = Math.floor(duration / 3600);
-            duration -= (hours * 3600);
-        }
-        if (duration >= 60) {
-            minutes = Math.floor(duration / 60);
-            duration -= (minutes * 60);
-        }
-        seconds = duration;
-
-        var tcount = taskState.task_count;
-
-        html += "<p>";
-        html += "Started at " + first.values().timestamp + ".  ";
-        html += "Ran " + dumbPluralize(tcount, " task") + " over ";
-        if (hours > 0) {
-            html += dumbPluralize(hours, " hour");
-        }
-        if (minutes > 0) {
-            html += " " + dumbPluralize(minutes, " minute");
-        }
-        if (seconds > 0) {
-            html += " " + dumbPluralize(seconds, " second");
-        }
-
-        html += " using " + dumbPluralize(taskState.nodes.length, " node");
-
-        html += ".  " + dumbPluralize(taskState.complete_count, "task") + " completed";
-        html += ",  " + dumbPluralize(taskState.incomplete_count, "task") +  " incomplete";
-        html += " (" + dumbPluralize(taskState.failure_count, " failure") + ")";
-
-        html += ".  Finished at " + last.values().timestamp + ".";
-        html += "</p>";
-    } else {
-       html = "<p>Job log is empty or failed to load.</p>";
-    }
-
-    $(id).html(html);
-}
-
-function gotoPage(n, logViewer, page, id) {
-    if (n < 0) { return; }
-    if (n*page > logViewer.matchingItems.length) { return; }
-    logViewer.page_offset = n;
-    logViewer.show(n*page, page);
-}
-
-function updatePaging(id, logViewer, page) {
-    var p = "";
-    var i = logViewer.matchingItems.length;
-    var n;
-    for (n = 0; (n*page) < i; n += 1) {
-        if (n == logViewer.page_offset) {
-            p += "<span class='log-viewer-page-num'>" + (n+1) + "</span> ";
-        } else {
-            p += "<a href=\"#\" class='log-viewer-page-num log-viewer-page-" + n + "'>" + (n+1) + "</a> ";
-        }
-    }
-    $(id).html(p);
-    for (n = 0; (n*page) < i; n += 1) {
-        (function(n) {
-            $(".log-viewer-page-" + n).on("click", function() {
-                gotoPage(n, logViewer, page, id);
-                return false;
-            });
-        })(n);
-    }
-
-    if (logViewer.page_offset == 0) {
-        $(".log-viewer-page-up").addClass("text-muted");
-    } else {
-        $(".log-viewer-page-up").removeClass("text-muted");
-    }
-
-    if (logViewer.page_offset == (n-1)) {
-        $(".log-viewer-page-down").addClass("text-muted");
-    } else {
-        $(".log-viewer-page-down").removeClass("text-muted");
-    }
-}
-
-function nextPage(logViewer, page, id) {
-    gotoPage(logViewer.page_offset+1, logViewer, page, id);
-}
-
-function prevPage(logViewer, page, id) {
-    gotoPage(logViewer.page_offset-1, logViewer, page, id);
-}
diff --git a/apps/workbench/app/assets/javascripts/mithril_mount.js b/apps/workbench/app/assets/javascripts/mithril_mount.js
deleted file mode 100644 (file)
index 7995ffe..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).on('ready arv:pane:loaded', function() {
-    $('[data-mount-mithril]').each(function() {
-        var data = $(this).data()
-        m.mount(this, {view: function () {return m(window[data.mountMithril], data)}})
-    })
-})
diff --git a/apps/workbench/app/assets/javascripts/modal_pager.js b/apps/workbench/app/assets/javascripts/modal_pager.js
deleted file mode 100644 (file)
index ffa45ee..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Usage:
-//
-// 1. Add some buttons to your modal, one with class="pager-next" and
-// one with class="pager-prev".
-//
-// 2. Put multiple .modal-body sections in your modal.
-//
-// 3. Add a "pager-count" div where page count is shown.
-// For ex: "1 of 10" when showing first page of 10 pages.
-
-$(document).on('click', '.modal .pager-next', function() {
-    var $modal = $(this).parents('.modal');
-    $modal.data('page', ($modal.data('page') || 0) + 1).trigger('pager:render');
-    return false;
-}).on('click', '.modal .pager-prev', function() {
-    var $modal = $(this).parents('.modal');
-    $modal.data('page', ($modal.data('page') || 1) - 1).trigger('pager:render');
-    return false;
-}).on('ready ajax:success', function() {
-    $('.modal').trigger('pager:render');
-}).on('pager:render', '.modal', function() {
-    var $modal = $(this);
-    var page = $modal.data('page') || 0;
-    var $panes = $('.modal-body', $modal);
-    if (page >= $panes.length) {
-        // Somehow moved past end
-        page = $panes.length - 1;
-        $modal.data('page', page);
-    } else if (page < 0) {
-        page = 0;
-    }
-
-    var $pager_count = $('.pager-count', $modal);
-    $pager_count.text((page+1) + " of " + $panes.length);
-
-    var selected = $panes.hide().eq(page).show();
-    enableButton($('.pager-prev', $modal), page > 0);
-    enableButton($('.pager-next', $modal), page < $panes.length - 1);
-    function enableButton(btn, ok) {
-        btn.prop('disabled', !ok).
-            toggleClass('btn-primary', ok).
-            toggleClass('btn-default', !ok);
-    }
-});
diff --git a/apps/workbench/app/assets/javascripts/models/loader.js b/apps/workbench/app/assets/javascripts/models/loader.js
deleted file mode 100644 (file)
index 0b29de6..0000000
+++ /dev/null
@@ -1,159 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// MultipageLoader retrieves a multi-page result set from the
-// server. The constructor initiates the first page load.
-//
-// config.loadFunc is a function that accepts an array of
-// paging-related filters, and returns a promise for the API
-// response. loadFunc() must retrieve results in "modified_at desc"
-// order.
-//
-// state is:
-// * 'loading' if a network request is in progress;
-// * 'done' if there are no more items to load;
-// * 'ready' otherwise.
-//
-// items is a stream that resolves to an array of all items retrieved so far.
-//
-// loadMore() loads the next page, if any.
-window.MultipageLoader = function(config) {
-    var loader = this
-    Object.assign(loader, config, {
-        state: 'ready',
-        DONE: 'done',
-        LOADING: 'loading',
-        READY: 'ready',
-
-        items: m.stream([]),
-        thresholdItem: null,
-        loadMore: function() {
-            if (loader.state == loader.DONE || loader.state == loader.LOADING)
-                return
-            var filters = loader.thresholdItem ? [
-                ["modified_at", "<=", loader.thresholdItem.modified_at],
-                ["uuid", "!=", loader.thresholdItem.uuid],
-            ] : []
-            loader.state = loader.LOADING
-            loader.loadFunc(filters).then(function(resp) {
-                var items = loader.items()
-                Array.prototype.push.apply(items, resp.items)
-                if (resp.items.length == 0) {
-                    loader.state = loader.DONE
-                } else {
-                    loader.thresholdItem = resp.items[resp.items.length-1]
-                    loader.state = loader.READY
-                }
-                loader.items(items)
-            }).catch(function(err) {
-                loader.err = err
-                loader.state = loader.READY
-            })
-        },
-    })
-    loader.loadMore()
-}
-
-// MergingLoader merges results from multiple loaders (given in the
-// config.children array) into a single result set.
-//
-// new MergingLoader({children: [loader, loader, ...]})
-//
-// The children must retrieve results in "modified_at desc" order.
-window.MergingLoader = function(config) {
-    var loader = this
-    Object.assign(loader, config, {
-        // Sorted items ready to display, merged from all children.
-        items: m.stream([]),
-        state: 'ready',
-        DONE: 'done',
-        LOADING: 'loading',
-        READY: 'ready',
-        loadable: function() {
-            // Return an array of children that we could call
-            // loadMore() on. Update loader.state.
-            loader.state = loader.DONE
-            return loader.children.filter(function(child) {
-                if (child.state == child.DONE)
-                    return false
-                if (child.state == child.LOADING) {
-                    loader.state = loader.LOADING
-                    return false
-                }
-                if (loader.state == loader.DONE)
-                    loader.state = loader.READY
-                return true
-            })
-        },
-        loadMore: function() {
-            // Call loadMore() on children that have reached
-            // lowWaterMark.
-            loader.loadable().map(function(child) {
-                if (child.items().length - child.itemsDisplayed < loader.lowWaterMark) {
-                    loader.state = loader.LOADING
-                    child.loadMore()
-                }
-            })
-        },
-        mergeItems: function() {
-            // We want to avoid moving items around on the screen once
-            // they're displayed.
-            //
-            // To this end, here we find the last safely displayable
-            // item ("cutoff") by getting the last item from each
-            // unfinished child, and taking the topmost (most recent)
-            // one of those.
-            //
-            // (If we were to display an item below that cutoff, the
-            // next page of results from an unfinished child could
-            // include items that get inserted above the cutoff,
-            // causing the cutoff item to move down.)
-            var cutoff
-            var cutoffUnknown = false
-            loader.children.forEach(function(child) {
-                if (child.state == child.DONE)
-                    return
-                var items = child.items()
-                if (items.length == 0) {
-                    // No idea what's coming in the next page.
-                    cutoffUnknown = true
-                    return
-                }
-                var last = items[items.length-1].modified_at
-                if (!cutoff || cutoff < last)
-                    cutoff = last
-            })
-            if (cutoffUnknown)
-                return
-            var combined = []
-            loader.children.forEach(function(child) {
-                child.itemsDisplayed = 0
-                child.items().every(function(item) {
-                    if (cutoff && item.modified_at < cutoff)
-                        // Don't display this item or anything after
-                        // it (see "cutoff" comment above).
-                        return false
-                    combined.push(item)
-                    child.itemsDisplayed++
-                    return true // continue
-                })
-            })
-            loader.items(combined.sort(function(a, b) {
-                return a.modified_at < b.modified_at ? 1 : -1
-            }))
-        },
-        // Number of undisplayed items to keep on hand for each result
-        // set. When hitting "load more", if a result set already has
-        // this many additional results available, we don't bother
-        // fetching a new page. This is the _minimum_ number of rows
-        // that will be added to loader.items in each "load more"
-        // event (except for the case where all items are displayed).
-        lowWaterMark: 23,
-    })
-    var childrenReady = m.stream.merge(loader.children.map(function(child) {
-        return child.items
-    }))
-    childrenReady.map(loader.loadable)
-    childrenReady.map(loader.mergeItems)
-}
diff --git a/apps/workbench/app/assets/javascripts/models/session_db.js b/apps/workbench/app/assets/javascripts/models/session_db.js
deleted file mode 100644 (file)
index 70bd0a4..0000000
+++ /dev/null
@@ -1,357 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-window.SessionDB = function() {
-    var db = this;
-    Object.assign(db, {
-        discoveryCache: {},
-        tokenUUIDCache: null,
-        loadFromLocalStorage: function() {
-            try {
-                return JSON.parse(window.localStorage.getItem('sessions')) || {};
-            } catch(e) {}
-            return {};
-        },
-        loadAll: function() {
-            var all = db.loadFromLocalStorage();
-            if (window.defaultSession) {
-                window.defaultSession.isFromRails = true;
-                all[window.defaultSession.user.uuid.slice(0, 5)] = window.defaultSession;
-            }
-            return all;
-        },
-        loadActive: function() {
-            var sessions = db.loadAll();
-            Object.keys(sessions).forEach(function(key) {
-                if (!sessions[key].token || (sessions[key].user && !sessions[key].user.is_active)) {
-                    delete sessions[key];
-                }
-            });
-            return sessions;
-        },
-        loadLocal: function() {
-            var sessions = db.loadActive();
-            var s = false;
-            Object.keys(sessions).forEach(function(key) {
-                if (sessions[key].isFromRails) {
-                    s = sessions[key];
-                    return;
-                }
-            });
-            return s;
-        },
-        save: function(k, v) {
-            var sessions = db.loadAll();
-            sessions[k] = v;
-            Object.keys(sessions).forEach(function(key) {
-                if (sessions[key].isFromRails) {
-                    delete sessions[key];
-                }
-            });
-            window.localStorage.setItem('sessions', JSON.stringify(sessions));
-        },
-        trash: function(k) {
-            var sessions = db.loadAll();
-            delete sessions[k];
-            window.localStorage.setItem('sessions', JSON.stringify(sessions));
-        },
-        findAPI: function(url) {
-            // Given a Workbench or API host or URL, return a promise
-            // for the corresponding API server's base URL.  Typical
-            // use:
-            // sessionDB.findAPI('https://workbench.example/foo').then(sessionDB.login)
-            if (url.length === 5 && url.indexOf('.') < 0) {
-                url += '.arvadosapi.com';
-            }
-            if (url.indexOf('://') < 0) {
-                url = 'https://' + url;
-            }
-            url = new URL(url);
-            return m.request(url.origin + '/discovery/v1/apis/arvados/v1/rest').then(function() {
-                return url.origin + '/';
-            }).catch(function(err) {
-                // If url is a Workbench site (and isn't too old),
-                // /status.json will tell us its API host.
-                return m.request(url.origin + '/status.json').then(function(resp) {
-                    if (!resp.apiBaseURL) {
-                        throw 'no apiBaseURL in status response';
-                    }
-                    return resp.apiBaseURL;
-                });
-            });
-        },
-        login: function(baseURL, fallbackLogin) {
-            // Initiate login procedure with given API base URL (e.g.,
-            // "http://api.example/").
-            //
-            // Any page that has a button that invokes login() must
-            // also call checkForNewToken() on (at least) its first
-            // render. Otherwise, the login procedure can't be
-            // completed.
-            if (fallbackLogin === undefined) {
-                fallbackLogin = true;
-            }
-            var session = db.loadLocal();
-            var apiHostname = new URL(session.baseURL).hostname;
-            db.discoveryDoc(session).map(function(localDD) {
-                var uuidPrefix = localDD.uuidPrefix;
-                db.discoveryDoc({baseURL: baseURL}).map(function(dd) {
-                    if (uuidPrefix in dd.remoteHosts ||
-                        (dd.remoteHostsViaDNS && apiHostname.endsWith('.arvadosapi.com'))) {
-                        // Federated identity login via salted token
-                        db.saltedToken(dd.uuidPrefix).then(function(token) {
-                            m.request(baseURL+'arvados/v1/users/current', {
-                                headers: {
-                                    authorization: 'Bearer '+token
-                                }
-                            }).then(function(user) {
-                                // Federated login successful.
-                                var remoteSession = {
-                                    user: user,
-                                    baseURL: baseURL,
-                                    token: token,
-                                    listedHost: (dd.uuidPrefix in localDD.remoteHosts)
-                                };
-                                db.save(dd.uuidPrefix, remoteSession);
-                            }).catch(function(e) {
-                                if (dd.uuidPrefix in localDD.remoteHosts) {
-                                    // If the remote system is configured to allow federated
-                                    // logins from this cluster, but rejected the salted
-                                    // token, save as a logged out session anyways.
-                                    var remoteSession = {
-                                        baseURL: baseURL,
-                                        listedHost: true
-                                    };
-                                    db.save(dd.uuidPrefix, remoteSession);
-                                } else if (fallbackLogin) {
-                                    // Remote cluster not listed as a remote host and rejecting
-                                    // the salted token, try classic login.
-                                    db.loginClassic(baseURL);
-                                }
-                            });
-                        });
-                    } else if (fallbackLogin) {
-                        // Classic login will be used when the remote system doesn't list this
-                        // cluster as part of the federation.
-                        db.loginClassic(baseURL);
-                    }
-                });
-            });
-            return false;
-        },
-        loginClassic: function(baseURL) {
-            document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL));
-        },
-        logout: function(k) {
-            // Forget the token, but leave the other info in the db so
-            // the user can log in again without providing the login
-            // host again.
-            var sessions = db.loadAll();
-            delete sessions[k].token;
-            db.save(k, sessions[k]);
-        },
-        saltedToken: function(uuid_prefix) {
-            // Takes a cluster UUID prefix and returns a salted token to allow
-            // log into said cluster using federated identity.
-            var session = db.loadLocal();
-            return db.tokenUUID().then(function(token_uuid) {
-                var shaObj = new jsSHA("SHA-1", "TEXT");
-                var secret = session.token;
-                if (session.token.startsWith("v2/")) {
-                    secret = session.token.split("/")[2];
-                }
-                shaObj.setHMACKey(secret, "TEXT");
-                shaObj.update(uuid_prefix);
-                var hmac = shaObj.getHMAC("HEX");
-                return 'v2/' + token_uuid + '/' + hmac;
-            });
-        },
-        checkForNewToken: function() {
-            // If there's a token and baseURL in the location bar (i.e.,
-            // we just landed here after a successful login), save it and
-            // scrub the location bar.
-            if (document.location.search[0] != '?') { return; }
-            var params = {};
-            document.location.search.slice(1).split('&').forEach(function(kv) {
-                var e = kv.indexOf('=');
-                if (e < 0) {
-                    return;
-                }
-                params[decodeURIComponent(kv.slice(0, e))] = decodeURIComponent(kv.slice(e+1));
-            });
-            if (!params.baseURL || !params.api_token) {
-                // Have a query string, but it's not a login callback.
-                return;
-            }
-            params.token = params.api_token;
-            delete params.api_token;
-            db.save(params.baseURL, params);
-            history.replaceState({}, '', document.location.origin + document.location.pathname);
-        },
-        fillMissingUUIDs: function() {
-            var sessions = db.loadAll();
-            Object.keys(sessions).forEach(function(key) {
-                if (key.indexOf('://') < 0) {
-                    return;
-                }
-                // key is the baseURL placeholder. We need to get our user
-                // record to find out the cluster's real uuid prefix.
-                var session = sessions[key];
-                m.request(session.baseURL+'arvados/v1/users/current', {
-                    headers: {
-                        authorization: 'OAuth2 '+session.token
-                    }
-                }).then(function(user) {
-                    session.user = user;
-                    db.save(user.owner_uuid.slice(0, 5), session);
-                    db.trash(key);
-                });
-            });
-        },
-        // Return the Workbench base URL advertised by the session's
-        // API server, or a reasonable guess, or (if neither strategy
-        // works out) null.
-        workbenchBaseURL: function(session) {
-            var dd = db.discoveryDoc(session)();
-            if (!dd) {
-                // Don't fall back to guessing until we receive the discovery doc
-                return null;
-            }
-            if (dd.workbenchUrl) {
-                return dd.workbenchUrl;
-            }
-            // Guess workbench.{apihostport} is a Workbench... unless
-            // the host part of apihostport is an IPv4 or [IPv6]
-            // address.
-            if (!session.baseURL.match('://(\\[|\\d+\\.\\d+\\.\\d+\\.\\d+[:/])')) {
-                var wbUrl = session.baseURL.replace('://', '://workbench.');
-                // Remove the trailing slash, if it's there.
-                return wbUrl.slice(-1) === '/' ? wbUrl.slice(0, -1) : wbUrl;
-            }
-            return null;
-        },
-        // Return a m.stream that will get fulfilled with the
-        // discovery doc from a session's API server.
-        discoveryDoc: function(session) {
-            var cache = db.discoveryCache[session.baseURL];
-            if (!cache && session) {
-                db.discoveryCache[session.baseURL] = cache = m.stream();
-                var baseURL = session.baseURL;
-                if (baseURL[baseURL.length - 1] !== '/') {
-                    baseURL += '/';
-                }
-                m.request(baseURL+'discovery/v1/apis/arvados/v1/rest')
-                    .then(function (dd) {
-                        // Just in case we're talking with an old API server.
-                        dd.remoteHosts = dd.remoteHosts || {};
-                        if (dd.remoteHostsViaDNS === undefined) {
-                            dd.remoteHostsViaDNS = false;
-                        }
-                        return dd;
-                    })
-                    .then(cache);
-            }
-            return cache;
-        },
-        // Return a promise with the local session token's UUID from the API server.
-        tokenUUID: function() {
-            var cache = db.tokenUUIDCache;
-            if (!cache) {
-                var session = db.loadLocal();
-                if (session.token.startsWith("v2/")) {
-                    var uuid = session.token.split("/")[1]
-                    db.tokenUUIDCache = uuid;
-                    return new Promise(function(resolve, reject) {
-                        resolve(uuid);
-                    });
-                }
-                return db.request(session, 'arvados/v1/api_client_authorizations', {
-                    data: {
-                        filters: JSON.stringify([['api_token', '=', session.token]])
-                    }
-                }).then(function(resp) {
-                    var uuid = resp.items[0].uuid;
-                    db.tokenUUIDCache = uuid;
-                    return uuid;
-                });
-            } else {
-                return new Promise(function(resolve, reject) {
-                    resolve(cache);
-                });
-            }
-        },
-        request: function(session, path, opts) {
-            opts = opts || {};
-            opts.headers = opts.headers || {};
-            opts.headers.authorization = 'OAuth2 '+ session.token;
-            return m.request(session.baseURL + path, opts);
-        },
-        // Check non-federated remote active sessions if they should be migrated to
-        // a salted token.
-        migrateNonFederatedSessions: function() {
-            var sessions = db.loadActive();
-            Object.keys(sessions).forEach(function(uuidPrefix) {
-                session = sessions[uuidPrefix];
-                if (!session.isFromRails && session.token) {
-                    db.saltedToken(uuidPrefix).then(function(saltedToken) {
-                        if (session.token != saltedToken) {
-                            // Only try the federated login
-                            db.login(session.baseURL, false);
-                        }
-                    });
-                }
-            });
-        },
-        // If remoteHosts is populated on the local API discovery doc, try to
-        // add any listed missing session.
-        autoLoadRemoteHosts: function() {
-            var sessions = db.loadAll();
-            var doc = db.discoveryDoc(db.loadLocal());
-            if (doc === undefined) { return; }
-            doc.map(function(d) {
-                Object.keys(d.remoteHosts).forEach(function(uuidPrefix) {
-                    if (!(sessions[uuidPrefix])) {
-                        db.findAPI(d.remoteHosts[uuidPrefix]).then(function(baseURL) {
-                            db.login(baseURL, false);
-                        });
-                    }
-                });
-            });
-        },
-        // If the current logged in account is from a remote federated cluster,
-        // redirect the user to their home cluster's workbench.
-        // This is meant to avoid confusion when the user clicks through a search
-        // result on the home cluster's multi site search page, landing on the
-        // remote workbench and later trying to do another search by just clicking
-        // on the multi site search button instead of going back with the browser.
-        autoRedirectToHomeCluster: function(path) {
-            path = path || '/';
-            var session = db.loadLocal();
-            var userUUIDPrefix = session.user.uuid.slice(0, 5);
-            // If the current user is local to the cluster, do nothing.
-            if (userUUIDPrefix === session.user.owner_uuid.slice(0, 5)) {
-                return;
-            }
-            db.discoveryDoc(session).map(function (d) {
-                // Guess the remote host from the local discovery doc settings
-                var rHost = null;
-                if (d.remoteHosts[userUUIDPrefix]) {
-                    rHost = d.remoteHosts[userUUIDPrefix];
-                } else if (d.remoteHostsViaDNS) {
-                    rHost = userUUIDPrefix + '.arvadosapi.com';
-                } else {
-                    // This should not happen: having remote user whose uuid prefix
-                    // isn't listed on remoteHosts and dns mechanism is deactivated
-                    return;
-                }
-                // Get the remote cluster workbench url & redirect there.
-                db.findAPI(rHost).then(function (apiUrl) {
-                    db.discoveryDoc({baseURL: apiUrl}).map(function (d) {
-                        document.location = d.workbenchUrl + path;
-                    });
-                });
-            });
-        }
-    });
-};
diff --git a/apps/workbench/app/assets/javascripts/permission_toggle.js b/apps/workbench/app/assets/javascripts/permission_toggle.js
deleted file mode 100644 (file)
index 007a25b..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).
-    on('click', '[data-toggle-permission] input[type=checkbox]', function() {
-        var data = {};
-        var keys = ['data-permission-uuid',
-                    'data-permission-name',
-                    'data-permission-head',
-                    'data-permission-tail'];
-        var attr;
-        for(var i in keys) {
-            attr = keys[i];
-            data[attr] = $(this).closest('[' + attr + ']').attr(attr);
-            if (data[attr] === undefined) {
-                console.log(["Error: no " + attr + " established here.", this]);
-                return;
-            }
-        }
-        var is_checked = $(this).prop('checked');
-
-        if (is_checked) {
-            $.ajax('/links',
-                   {dataType: 'json',
-                    type: 'POST',
-                    data: {'link[tail_uuid]': data['data-permission-tail'],
-                           'link[head_uuid]': data['data-permission-head'],
-                           'link[link_class]': 'permission',
-                           'link[name]': data['data-permission-name']},
-                    context: this}).
-                fail(function(jqxhr, status, error) {
-                    $(this).prop('checked', false);
-                }).
-                done(function(data, status, jqxhr) {
-                    $(this).attr('data-permission-uuid', data['uuid']);
-                }).
-                always(function() {
-                    $(this).prop('disabled', false);
-                });
-        }
-        else {
-            $.ajax('/links/' + data['data-permission-uuid'],
-                   {dataType: 'json',
-                    type: 'POST',
-                    data: {'_method': 'DELETE'},
-                    context: this}).
-                fail(function(jqxhr, status, error) {
-                    $(this).prop('checked', true);
-                }).
-                done(function(data, status, jqxhr) {
-                    $(this).attr('data-permission-uuid', 'x');
-                }).
-                always(function() {
-                    $(this).prop('disabled', false);
-                });
-        }
-        $(this).prop('disabled', true);
-    });
diff --git a/apps/workbench/app/assets/javascripts/pipeline_instances.js b/apps/workbench/app/assets/javascripts/pipeline_instances.js
deleted file mode 100644 (file)
index 7570b2f..0000000
+++ /dev/null
@@ -1,124 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-function run_pipeline_button_state() {
-    var a = $('a.editable.required.editable-empty,input.form-control.required[value=""]');
-    if ((a.length > 0) || ($('.unreadable-inputs-present').length)) {
-        $(".run-pipeline-button").addClass("disabled");
-    }
-    else {
-        $(".run-pipeline-button").removeClass("disabled");
-    }
-}
-
-$(document).on('editable:success', function(event, tag, response, newValue) {
-    var $tag = $(tag);
-    if ($('.run-pipeline-button').length == 0)
-        return;
-    if ($tag.hasClass("required")) {
-        if (newValue && newValue.trim() != "") {
-            $tag.removeClass("editable-empty");
-            $tag.parent().css("background-color", "");
-            $tag.parent().prev().css("background-color", "");
-        }
-        else {
-            $tag.addClass("editable-empty");
-            $tag.parent().css("background-color", "#ffdddd");
-            $tag.parent().prev().css("background-color", "#ffdddd");
-        }
-    }
-    if ($tag.attr('data-name')) {
-        // Update other inputs representing the same piece of data
-        $('.editable[data-name="' + $tag.attr('data-name') + '"]').
-            editable('setValue', newValue);
-    }
-    run_pipeline_button_state();
-});
-
-$(document).on('ready ajax:complete', function() {
-    $('a.editable.required').each(function() {
-        var $tag = $(this);
-        if ($tag.hasClass("editable-empty")) {
-            $tag.parent().css("background-color", "#ffdddd");
-            $tag.parent().prev().css("background-color", "#ffdddd");
-        }
-        else {
-            $tag.parent().css("background-color", "");
-            $tag.parent().prev().css("background-color", "");
-        }
-    });
-    $('input.required').each(function() {
-        var $tag = $(this);
-        if ($tag.hasClass("unreadable-input")) {
-            $tag.parent().parent().css("background-color", "#ffdddd");
-            $tag.parent().parent().prev().css("background-color", "#ffdddd");
-        }
-        else {
-            $tag.parent().parent().css("background-color", "");
-            $tag.parent().parent().prev().css("background-color", "");
-        }
-    });
-    run_pipeline_button_state();
-});
-
-$(document).on('arv-log-event', '.arv-refresh-on-state-change', function(event, eventData) {
-    if (this != event.target) {
-        // Not interested in events sent to child nodes.
-        return;
-    }
-    if (eventData.event_type == "update" &&
-        eventData.properties.old_attributes.state != eventData.properties.new_attributes.state)
-    {
-        $(event.target).trigger('arv:pane:reload');
-    }
-});
-
-$(document).on('arv-log-event', '.arv-log-event-subscribe-to-pipeline-job-uuids', function(event, eventData){
-    if (this != event.target) {
-        // Not interested in events sent to child nodes.
-        return;
-    }
-    if (!((eventData.object_kind == 'arvados#pipelineInstance') &&
-          (eventData.event_type == "create" ||
-           eventData.event_type == "update") &&
-         eventData.properties &&
-         eventData.properties.new_attributes &&
-         eventData.properties.new_attributes.components)) {
-        return;
-    }
-    var objs = "";
-    var components = eventData.properties.new_attributes.components;
-    for (a in components) {
-        if (components[a].job && components[a].job.uuid) {
-            objs += " " + components[a].job.uuid;
-        }
-    }
-    $(event.target).attr("data-object-uuids", eventData.object_uuid + objs);
-});
-
-$(document).on('ready ajax:success', function() {
-    $('.arv-log-refresh-control').each(function() {
-        var uuids = $(this).attr('data-object-uuids');
-        var $pane = $(this).closest('[data-pane-content-url]');
-        $pane.attr('data-object-uuids', uuids);
-    });
-});
-
-// Set up all events for the pipeline instances compare button.
-(function() {
-    var compare_form = '#compare';
-    var compare_inputs = '#comparedInstances :checkbox[name="uuids[]"]';
-    var update_button = function(event) {
-        var $form = $(compare_form);
-        var $checked_inputs = $(compare_inputs).filter(':checked');
-        $(':submit', $form).prop('disabled', (($checked_inputs.length < 2) ||
-                                              ($checked_inputs.length > 3)));
-        $('input[name="uuids[]"]', $form).remove();
-        $form.append($checked_inputs.clone()
-                     .removeAttr('id').attr('type', 'hidden'));
-    };
-    $(document)
-        .on('ready ajax:success', compare_form, update_button)
-        .on('change', compare_inputs, update_button);
-})();
diff --git a/apps/workbench/app/assets/javascripts/report_issue.js b/apps/workbench/app/assets/javascripts/report_issue.js
deleted file mode 100644 (file)
index 0285693..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).
-  on('click', "#report-issue-submit", function(e){
-    $(this).html('Sending');
-    $(this).prop('disabled', true);
-    var $cancelButton = $('#report-issue-cancel');
-    if ($cancelButton) {
-      $cancelButton.html('Close');
-    }
-    $('div').remove('.modal-footer-status');
-
-    $.ajax('/report_issue', {
-        type: 'POST',
-        data: $(this).parents('form').serialize()
-    }).success(function(data, status, jqxhr) {
-        var $sendButton = $('#report-issue-submit');
-        $sendButton.html('Report sent');
-        $('div').remove('.modal-footer-status');
-        $('.modal-footer').append('<div><br/></div><div class="modal-footer-status alert alert-success"><p class="contain-align-left">Thanks for reporting this issue!</p></div>');
-    }).fail(function(jqxhr, status, error) {
-        var $sendButton = $('#report-issue-submit');
-        if ($sendButton && $sendButton.prop('disabled')) {
-          $('div').remove('.modal-footer-status');
-          $('.modal-footer').append('<div><br/></div><div class="modal-footer-status alert alert-danger"><p class="contain-align-left">We are sorry. We could not submit your report! We really want this to work, though -- please try again.</p></div>');
-          $sendButton.html('Send problem report');
-          $sendButton.prop('disabled', false);
-        }
-        var $cancelButton = $('#report-issue-cancel');
-        $cancelButton.html('Cancel');
-    });
-    return false;
-  });
diff --git a/apps/workbench/app/assets/javascripts/request_shell_access.js b/apps/workbench/app/assets/javascripts/request_shell_access.js
deleted file mode 100644 (file)
index eb4fbc3..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).on('ready ajax:success storage', function() {
-    // Update the "shell access requested" info box according to the
-    // current state of localStorage.
-    var msg = localStorage.getItem('request_shell_access');
-    var $noShellAccessDiv = $('#no_shell_access');
-    if ($noShellAccessDiv.length > 0) {
-        $('.alert-success p', $noShellAccessDiv).text(msg);
-        $('.alert-success', $noShellAccessDiv).toggle(!!msg);
-    }
-});
diff --git a/apps/workbench/app/assets/javascripts/select_modal.js b/apps/workbench/app/assets/javascripts/select_modal.js
deleted file mode 100644 (file)
index 19cf3cd..0000000
+++ /dev/null
@@ -1,185 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).on('click', '.selectable', function() {
-    var any;
-    var $this = $(this);
-    var $container = $(this).closest('.selectable-container');
-    if (!$container.hasClass('multiple')) {
-        $container.
-            find('.selectable').
-            removeClass('active');
-    }
-    $this.toggleClass('active');
-
-    if (!$this.hasClass('use-preview-selection')) {
-      any = ($container.
-           find('.selectable.active').length > 0)
-    }
-
-    if (!$container.hasClass('preview-selectable-container')) {
-      $this.
-        closest('.modal').
-        find('[data-enable-if-selection]').
-        prop('disabled', !any);
-
-      if ($this.hasClass('active')) {
-        var no_preview_available = '<div class="spinner-h-center spinner-v-center"><center>(No preview available)</center></div>';
-        if (!$this.attr('data-preview-href')) {
-            $(".modal-dialog-preview-pane").html(no_preview_available);
-            return;
-        }
-        $(".modal-dialog-preview-pane").html('<div class="spinner spinner-32px spinner-h-center spinner-v-center"></div>');
-        $.ajax($this.attr('data-preview-href'),
-               {dataType: "html"}).
-            done(function(data, status, jqxhr) {
-                $(".modal-dialog-preview-pane").html(data);
-            }).
-            fail(function(data, status, jqxhr) {
-                $(".modal-dialog-preview-pane").html(no_preview_available);
-            });
-      }
-    } else {
-      any = ($container.
-           find('.preview-selectable.active').length > 0)
-      $(this).
-          closest('.modal').
-          find('[data-enable-if-selection]').
-          prop('disabled', !any);
-    }
-
-}).on('click', '.modal button[data-action-href]', function() {
-    var selection = [];
-    var data = [];
-    var $modal = $(this).closest('.modal');
-    var http_method = $(this).attr('data-method').toUpperCase();
-    var action_data = $(this).data('action-data');
-    var action_data_from_params = $(this).data('action-data-from-params');
-    var selection_param = action_data.selection_param;
-    $modal.find('.modal-error').removeClass('hide').hide();
-
-    var $preview_selections = $modal.find('.preview-selectable.active');
-    if ($preview_selections.length > 0) {
-      data.push({name: selection_param, value: $preview_selections.first().attr('href')});
-    }
-
-    if (data.length == 0) {   // not using preview selection option
-      $modal.find('.selectable.active[data-object-uuid]').each(function() {
-        var val = $(this).attr('data-object-uuid');
-        data.push({name: selection_param, value: val});
-      });
-    }
-    $.each($.extend({}, action_data, action_data_from_params),
-           function(key, value) {
-               if (value instanceof Array && key[-1] != ']') {
-                   for (var i in value) {
-                       data.push({name: key + '[]', value: value[i]});
-                   }
-               } else {
-                   data.push({name: key, value: value});
-               }
-           });
-    if (http_method === 'PATCH') {
-        // Some user agents do not support HTTP PATCH (notably,
-        // phantomjs silently ignores our "data" and sends an empty
-        // request body) so we use POST instead, and supply a
-        // _method=PATCH param to tell Rails what we really want.
-        data.push({name: '_method', value: http_method});
-        http_method = 'POST';
-    }
-    $.ajax($(this).attr('data-action-href'),
-           {dataType: 'json',
-            type: http_method,
-            data: data,
-            traditional: false,
-            context: {modal: $modal, action_data: action_data}}).
-        fail(function(jqxhr, status, error) {
-            if (jqxhr.readyState == 0 || jqxhr.status == 0) {
-                message = "Cancelled."
-            } else if (jqxhr.responseJSON && jqxhr.responseJSON.errors) {
-                message = jqxhr.responseJSON.errors.join("; ");
-            } else {
-                message = "Request failed.";
-            }
-            this.modal.find('.modal-error').
-                html('<div class="alert alert-danger"></div>').
-                show().
-                children().text(message);
-        }).
-        done(function(data, status, jqxhr) {
-            var event_name = this.action_data.success;
-            this.modal.find('.modal-error').hide();
-            $(document).trigger(event_name!=null ? event_name : 'page-refresh',
-                                [data, status, jqxhr, this.action_data]);
-        });
-}).on('click', '.chooser-show-project', function() {
-    var params = {};
-    var project_uuid = $(this).attr('data-project-uuid');
-    $(this).attr('href', '#');  // Skip normal click handler
-    if (project_uuid) {
-        params = {'filters': [['owner_uuid',
-                               '=',
-                               project_uuid]],
-                  'project_uuid': project_uuid
-                 };
-    }
-    $(".modal-dialog-preview-pane").html("");
-    // Use current selection as dropdown button label
-    $(this).
-        closest('.dropdown-menu').
-        prev('button').
-        html($(this).text() + ' <span class="caret"></span>');
-    // Set (or unset) filter params and refresh filterable rows
-    $($(this).closest('[data-filterable-target]').attr('data-filterable-target')).
-        data('infinite-content-params-from-project-dropdown', params).
-        trigger('refresh-content');
-}).on('ready', function() {
-    $('form[data-search-modal] a').on('click', function() {
-        $(this).closest('form').submit();
-        return false;
-    });
-    $('form[data-search-modal]').on('submit', function() {
-        // Ask the server for a Search modal. When it arrives, copy
-        // the search string from the top nav input into the modal's
-        // search query field.
-        var $form = $(this);
-        var searchq = $form.find('input').val();
-        var is_a_uuid = /^([0-9a-f]{32}(\+\S+)?|[0-9a-z]{5}-[0-9a-z]{5}-[0-9a-z]{15})$/;
-        if (searchq.trim().match(is_a_uuid)) {
-            window.location = '/actions?uuid=' + encodeURIComponent(searchq.trim());
-            // Show the "loading" indicator. TODO: better page transition hook
-            $(document).trigger('ajax:send');
-            return false;
-        }
-        if ($form.find('a[data-remote]').length > 0) {
-            // A search dialog is already loading.
-            return false;
-        }
-        $('<a />').
-            attr('data-remote-href', $form.attr('data-search-modal')).
-            attr('data-remote', 'true').
-            attr('data-method', 'GET').
-            hide().
-            appendTo($form).
-            on('ajax:success', function(data, status, xhr) {
-                $('body > .modal-container input[type=text]').
-                    val($form.find('input').val()).
-                    focus();
-                $form.find('input').val('');
-            }).on('ajax:complete', function() {
-                $(this).detach();
-            }).
-            click();
-        return false;
-    });
-}).on('page-refresh', function(event, data, status, jqxhr, action_data) {
-    window.location.reload();
-}).on('tab-refresh', function(event, data, status, jqxhr, action_data) {
-    $(document).trigger('arv:pane:reload:all');
-    $('body > .modal-container .modal').modal('hide');
-}).on('redirect-to-created-object', function(event, data, status, jqxhr, action_data) {
-    window.location.href = data.href.replace(/^[^\/]*\/\/[^\/]*/, '');
-}).on('shown.bs.modal', 'body > .modal-container .modal', function() {
-    $('.focus-on-display', this).focus();
-});
diff --git a/apps/workbench/app/assets/javascripts/selection.js.erb b/apps/workbench/app/assets/javascripts/selection.js.erb
deleted file mode 100644 (file)
index e8f21ee..0000000
+++ /dev/null
@@ -1,111 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-//= require jquery
-//= require jquery_ujs
-
-/** Javascript for selection. */
-
-jQuery(function($){
-    $(document).
-        on('change', '.persistent-selection:checkbox', function(e) {
-            $(document).trigger('selections-updated');
-        });
-});
-
-function dispatch_selection_action() {
-    /* When the user clicks a selection action link, build a form to perform
-       the action on the selected data, and submit it.
-       This is based on handleMethod from rails-ujs, extended to add the
-       selections to the submitted form.
-       Copyright (c) 2007-2010 Contributors at http://github.com/rails/jquery-ujs/contributors
-       */
-    var $container = $(this);
-    if ($container.closest('.disabled').length) {
-        return false;
-    }
-    $container.closest('.dropdown-menu').dropdown('toggle');
-
-    var href = $container.data('href'),
-    method = $container.data('method') || 'GET',
-    paramName = $container.data('selection-param-name'),
-    csrfToken = $('meta[name=csrf-token]').attr('content'),
-    csrfParam = $('meta[name=csrf-param]').attr('content'),
-    form = $('<form method="post" action="' + href + '"></form>'),
-    metadataInput = ('<input name="_method" value="' + method +
-                     '" type="hidden" />');
-
-    if (csrfParam !== undefined && csrfToken !== undefined) {
-        metadataInput += ('<input type="hidden" name="' + csrfParam +
-                          '" value="' + csrfToken + '" />');
-    }
-    $container.
-        closest('.selection-action-container').
-        find(':checkbox:checked:visible').
-        each(function(index, elem) {
-            metadataInput += ('<input type="hidden" name="' + paramName +
-                              '" value="' + elem.value + '" />');
-        });
-
-    form.data('remote', $container.data('remote'));
-    form.hide().append(metadataInput).appendTo('body');
-    form.submit();
-    return false;
-}
-
-function enable_disable_selection_actions() {
-    var $container = $(this);
-    var $checked = $('.persistent-selection:checkbox:checked', $container);
-    var collection_lock_classes = $('.lock-collection-btn').attr('class')
-
-    $('[data-selection-action]', $container).
-        closest('div.btn-group-sm').
-        find('ul li').
-        toggleClass('disabled', ($checked.length == 0));
-    $('[data-selection-action=compare]', $container).
-        closest('li').
-        toggleClass('disabled',
-                    ($checked.filter('[value*=-d1hrv-]').length < 2) ||
-                    ($checked.not('[value*=-d1hrv-]').length > 0));
-    <% unless Group.copies_to_projects? %>
-        $('[data-selection-action=copy]', $container).
-            closest('li').
-            toggleClass('disabled',
-                        ($checked.filter('[value*=-j7d0g-]').length > 0) ||
-                        ($checked.length < 1));
-    <% end %>
-    $('[data-selection-action=combine-project-contents]', $container).
-        closest('li').
-        toggleClass('disabled',
-                    ($checked.filter('[value*=-4zz18-]').length < 1) ||
-                    ($checked.length != $checked.filter('[value*=-4zz18-]').length));
-    $('[data-selection-action=remove-selected-files]', $container).
-        closest('li').
-        toggleClass('disabled',
-                    ($checked.length < 0) ||
-                    !($checked.length > 0 && collection_lock_classes && collection_lock_classes.indexOf("fa-unlock") !=-1));
-    $('[data-selection-action=untrash-selected-items]', $container).
-        closest('li').
-        toggleClass('disabled',
-                    ($checked.length < 1));
-}
-
-$(document).
-    on('selections-updated', function() {
-        $('.selection-action-container').each(enable_disable_selection_actions);
-    }).
-    on('ready ajax:complete', function() {
-        $('[data-selection-action]').
-            off('click', dispatch_selection_action).
-            on('click', dispatch_selection_action);
-        $(this).trigger('selections-updated');
-    });
-
-function select_all_items() {
-  $(".arv-selectable-items :checkbox").filter(":visible").prop("checked", true).trigger("change");
-}
-
-function unselect_all_items() {
-  $(".arv-selectable-items :checkbox").filter(":visible").prop("checked", false).trigger("change");
-}
diff --git a/apps/workbench/app/assets/javascripts/sizing.js b/apps/workbench/app/assets/javascripts/sizing.js
deleted file mode 100644 (file)
index 569956f..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-function graph_zoom(divId, svgId, scale) {
-    var pg = document.getElementById(divId);
-    vcenter = (pg.scrollTop + (pg.scrollHeight - pg.scrollTopMax)/2.0) / pg.scrollHeight;
-    hcenter = (pg.scrollLeft + (pg.scrollWidth - pg.scrollLeftMax)/2.0) / pg.scrollWidth;
-    var g = document.getElementById(svgId);
-    g.setAttribute("height", parseFloat(g.getAttribute("height")) * scale);
-    g.setAttribute("width", parseFloat(g.getAttribute("width")) * scale);
-    pg.scrollTop = (vcenter * pg.scrollHeight) - (pg.scrollHeight - pg.scrollTopMax)/2.0;
-    pg.scrollLeft = (hcenter * pg.scrollWidth) - (pg.scrollWidth - pg.scrollLeftMax)/2.0;
-    smart_scroll_fixup();
-}
-
-function smart_scroll_fixup(s) {
-
-    if (s != null && s.type == 'shown.bs.tab') {
-        s = [s.target];
-    }
-    else {
-        s = $(".smart-scroll");
-    }
-
-    s.each(function(i, a) {
-        a = $(a);
-        var h = window.innerHeight - a.offset().top - a.attr("data-smart-scroll-padding-bottom");
-        height = String(h) + "px";
-        a.css('max-height', height);
-    });
-}
-
-$(window).on('load ready resize scroll ajax:complete', smart_scroll_fixup);
-$(document).on('shown.bs.tab', 'ul.nav-tabs > li > a', smart_scroll_fixup);
diff --git a/apps/workbench/app/assets/javascripts/tab_panes.js b/apps/workbench/app/assets/javascripts/tab_panes.js
deleted file mode 100644 (file)
index b19a277..0000000
+++ /dev/null
@@ -1,217 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Load tab panes on demand. See app/views/application/_content.html.erb
-
-// Fire when a tab is selected/clicked.
-$(document).on('shown.bs.tab', '[data-toggle="tab"]', function(event) {
-    // reload the pane (unless it's already loaded)
-    $($(event.target).attr('href')).
-        not('.pane-loaded').
-        trigger('arv:pane:reload');
-});
-
-// Ask a refreshable pane to reload via ajax.
-//
-// Target of this event is the DOM element to be updated. A reload
-// consists of an AJAX call to load the "data-pane-content-url" and
-// replace the content of the target element with the retrieved HTML.
-//
-// There are four CSS classes set on the element to indicate its state:
-// pane-loading, pane-stale, pane-loaded, pane-reload-pending
-//
-// There are five states based on the presence or absence of css classes:
-//
-// 1. Absence of any pane-* states means the pane is empty, and should
-// be loaded as soon as it becomes visible.
-//
-// 2. "pane-loading" means an AJAX call has been made to reload the
-// pane and we are waiting on a result.
-//
-// 3. "pane-loading pane-stale" means the pane is loading, but has
-// already been invalidated and should schedule a reload as soon as
-// possible after the current load completes. (This happens when there
-// is a cluster of events, where the reload is triggered by the first
-// event, but we want ensure that we eventually load the final
-// quiescent state).
-//
-// 4. "pane-loaded" means the pane is up to date.
-//
-// 5. "pane-loaded pane-reload-pending" means a reload is needed, and
-// has been scheduled, but has not started because the pane's
-// minimum-time-between-reloads throttle has not yet been reached.
-//
-$(document).on('arv:pane:reload', '[data-pane-content-url]', function(e) {
-    if (this != e.target) {
-        // An arv:pane:reload event was sent to an element (e.target)
-        // which happens to have an ancestor (this) matching the above
-        // '[data-pane-content-url]' selector. This happens because
-        // events bubble up the DOM on their way to document. However,
-        // here we only care about events delivered directly to _this_
-        // selected element (i.e., this==e.target), not ones delivered
-        // to its children. The event "e" is uninteresting here.
-        return;
-    }
-
-    // $pane, the event target, is an element whose content is to be
-    // replaced. Pseudoclasses on $pane (pane-loading, etc) encode the
-    // current loading state.
-    var $pane = $(this);
-
-    if ($pane.hasClass('pane-loading')) {
-        // Already loading, mark stale to schedule a reload after this one.
-        $pane.addClass('pane-stale');
-        return;
-    }
-
-    // The default throttle (mininum milliseconds between refreshes)
-    // can be overridden by an .arv-log-refresh-control element inside
-    // the pane -- or, failing that, the pane element itself -- with a
-    // data-load-throttle attribute. This allows the server to adjust
-    // the throttle depending on the pane content.
-    var throttle =
-        $pane.find('.arv-log-refresh-control').attr('data-load-throttle') ||
-        $pane.attr('data-load-throttle') ||
-        15000;
-    var now = (new Date()).getTime();
-    var loaded_at = $pane.attr('data-loaded-at');
-    var since_last_load = now - loaded_at;
-    if (loaded_at && (since_last_load < throttle)) {
-        if (!$pane.hasClass('pane-reload-pending')) {
-            $pane.addClass('pane-reload-pending');
-            setTimeout((function() {
-                $pane.trigger('arv:pane:reload');
-            }), throttle - since_last_load);
-        }
-        return;
-    }
-
-    // We know this doesn't have 'pane-loading' because we tested for it above
-    $pane.removeClass('pane-reload-pending');
-    $pane.removeClass('pane-loaded');
-    $pane.removeClass('pane-stale');
-
-    if (!$pane.hasClass('active') &&
-        $pane.parent().hasClass('tab-content')) {
-        // $pane is one of the content areas in a bootstrap tabs
-        // widget, and it isn't the currently selected tab. If and
-        // when the user does select the corresponding tab, it will
-        // get a shown.bs.tab event, which will invoke this reload
-        // function again (see handler above). For now, we just insert
-        // a spinner, which will be displayed while the new content is
-        // loading.
-        $pane.html('<div class="spinner spinner-32px spinner-h-center"></div>');
-        return;
-    }
-
-    $pane.addClass('pane-loading');
-
-    var content_url = $pane.attr('data-pane-content-url');
-    $.ajax(content_url, {dataType: 'html', type: 'GET', context: $pane}).
-        done(function(data, status, jqxhr) {
-            var $pane = this;
-            // Preserve collapsed state
-            var collapsable = {};
-            $(".collapse", this).each(function(i, c) {
-                collapsable[c.id] = $(c).hasClass('in');
-            });
-            var tmp = $(data);
-            $(".collapse", tmp).each(function(i, c) {
-                if (collapsable[c.id]) {
-                    $(c).addClass('in');
-                } else {
-                    $(c).removeClass('in');
-                }
-            });
-            $pane.html(tmp);
-            $pane.removeClass('pane-loading');
-            $pane.addClass('pane-loaded');
-            $pane.attr('data-loaded-at', (new Date()).getTime());
-            $pane.trigger('arv:pane:loaded', [$pane]);
-
-            if ($pane.hasClass('pane-stale')) {
-                $pane.trigger('arv:pane:reload');
-            }
-        }).fail(function(jqxhr, status, error) {
-            var $pane = this;
-            var errhtml;
-            var contentType = jqxhr.getResponseHeader('Content-Type');
-            if (jqxhr.readyState == 0 || jqxhr.status == 0) {
-                if ($pane.attr('data-loaded-at') > 0) {
-                    // Stale content is already present. Leave it
-                    // there while loading the next page.
-                    $pane.removeClass('pane-loading');
-                    $pane.addClass('pane-loaded');
-                    // ...but schedule another refresh (after a
-                    // throttle delay) in case the act of navigating
-                    // away gets cancelled itself, leaving this page
-                    // with content that we know is stale.
-                    $pane.addClass('pane-stale');
-                    $pane.attr('data-loaded-at', (new Date()).getTime());
-                    $pane.trigger('arv:pane:reload');
-                    return;
-                }
-                errhtml = "Cancelled.";
-            } else if (contentType && contentType.match(/\btext\/html\b/)) {
-                var $response = $(jqxhr.responseText);
-                var $wrapper = $('div#page-wrapper', $response);
-                if ($wrapper.length) {
-                    errhtml = $wrapper.html();
-                } else {
-                    errhtml = jqxhr.responseText;
-                }
-            } else {
-                errhtml = ("An error occurred: " +
-                           (jqxhr.responseText || status)).
-                    replace(/&/g, '&amp;').
-                    replace(/</g, '&lt;').
-                    replace(/>/g, '&gt;');
-            }
-            $pane.html('<div class="pane-error-display"><p>' +
-                      '<a href="#" class="btn btn-primary tab_reload">' +
-                      '<i class="fa fa-fw fa-refresh"></i> ' +
-                      'Reload tab</a></p><iframe style="width: 100%"></iframe></div>');
-            $('.tab_reload', $pane).click(function() {
-                $(this).
-                    html('<div class="spinner spinner-32px spinner-h-center"></div>').
-                    closest('.pane-loaded').
-                    attr('data-loaded-at', 0).
-                    trigger('arv:pane:reload');
-            });
-            // We want to render the error in an iframe, in order to
-            // avoid conflicts with the main page's element ids, etc.
-            // In order to do that dynamically, we have to set a
-            // timeout on the iframe window to load our HTML *after*
-            // the default source (e.g., about:blank) has loaded.
-            var iframe = $('iframe', $pane)[0];
-            iframe.contentWindow.setTimeout(function() {
-                $('body', iframe.contentDocument).html(errhtml);
-                iframe.height = iframe.contentDocument.body.scrollHeight + "px";
-            }, 1);
-            $pane.removeClass('pane-loading');
-            $pane.addClass('pane-loaded');
-        });
-});
-
-// Mark all panes as stale/dirty. Refresh any 'active' panes.
-$(document).on('arv:pane:reload:all', function() {
-    $('[data-pane-content-url]').trigger('arv:pane:reload');
-});
-
-$(document).on('arv-log-event', '.arv-refresh-on-log-event', function(event) {
-    if (this != event.target) {
-        // Not interested in events sent to child nodes.
-        return;
-    }
-    // Panes marked arv-refresh-on-log-event should be refreshed
-    $(event.target).trigger('arv:pane:reload');
-});
-
-// If there is a 'tab counts url' in the nav-tabs element then use it to get some javascript that will update them
-$(document).on('ready count-change', function() {
-    var tabCountsUrl = $('ul.nav-tabs').data('tab-counts-url');
-    if( tabCountsUrl && tabCountsUrl.length ) {
-        $.get( tabCountsUrl );
-    }
-});
diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
deleted file mode 100644 (file)
index d66be63..0000000
+++ /dev/null
@@ -1,494 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-var app = angular.module('Workbench', ['Arvados']);
-app.controller('UploadToCollection', UploadToCollection);
-app.directive('arvUuid', arvUuid);
-
-function arvUuid() {
-    // Copy the given uuid into the current $scope.
-    return {
-        restrict: 'A',
-        link: function(scope, element, attributes) {
-            scope.uuid = attributes.arvUuid;
-        }
-    };
-}
-
-UploadToCollection.$inject = ['$scope', '$filter', '$q', '$timeout',
-                              'ArvadosClient', 'arvadosApiToken'];
-function UploadToCollection($scope, $filter, $q, $timeout,
-                            ArvadosClient, arvadosApiToken) {
-    $.extend($scope, {
-        uploadQueue: [],
-        uploader: new QueueUploader(),
-        addFilesToQueue: function(files) {
-            // Angular binding doesn't work its usual magic for file
-            // inputs, so we need to $scope.$apply() this update.
-            $scope.$apply(function(){
-                var i, nItemsTodo;
-                // Add these new files after the items already waiting
-                // in the queue -- but before the items that are
-                // 'Done' and have therefore been pushed to the
-                // bottom.
-                for (nItemsTodo = 0;
-                     (nItemsTodo < $scope.uploadQueue.length &&
-                      $scope.uploadQueue[nItemsTodo].state !== 'Done'); ) {
-                    nItemsTodo++;
-                }
-                for (i=0; i<files.length; i++) {
-                    $scope.uploadQueue.splice(nItemsTodo+i, 0,
-                        new FileUploader(files[i]));
-                }
-            });
-        },
-        go: function() {
-            $scope.uploader.go();
-        },
-        stop: function() {
-            $scope.uploader.stop();
-        },
-        removeFileFromQueue: function(index) {
-            var wasRunning = $scope.uploader.running;
-            $scope.uploadQueue[index].stop();
-            $scope.uploadQueue.splice(index, 1);
-            if (wasRunning)
-                $scope.go();
-        },
-        countInStates: function(want_states) {
-            var found = 0;
-            $.each($scope.uploadQueue, function() {
-                if (want_states.indexOf(this.state) >= 0) {
-                    ++found;
-                }
-            });
-            return found;
-        }
-    });
-    ////////////////////////////////
-
-    var keepProxy;
-    var defaultErrorMessage = 'A network error occurred: either the server was unreachable, or there is a server configuration problem. Please check your browser debug console for a more specific error message (browser security features prevent us from showing the details here).';
-
-    function SliceReader(_slice) {
-        var that = this;
-        $.extend(this, {
-            go: go
-        });
-        ////////////////////////////////
-        var _deferred;
-        var _reader;
-        function go() {
-            // Return a promise, which will be resolved with the
-            // requested slice data.
-            _deferred = $.Deferred();
-            _reader = new FileReader();
-            _reader.onload = resolve;
-            _reader.onerror = _deferred.reject;
-            _reader.onprogress = _deferred.notify;
-            _reader.readAsArrayBuffer(_slice.blob);
-            return _deferred.promise();
-        }
-        function resolve() {
-            if (that._reader.result.length !== that._slice.size) {
-                // Sometimes we get an onload event even if the read
-                // did not return the desired number of bytes. We
-                // treat that as a fail.
-                _deferred.reject(
-                    null, "Read error",
-                    "Short read: wanted " + _slice.size +
-                        ", received " + _reader.result.length);
-                return;
-            }
-            return _deferred.resolve(_reader.result);
-        }
-    }
-
-    function SliceUploader(_label, _data, _dataSize) {
-        $.extend(this, {
-            go: go,
-            stop: stop
-        });
-        ////////////////////////////////
-        var that = this;
-        var _deferred;
-        var _failCount = 0;
-        var _failMax = 3;
-        var _jqxhr;
-        function go() {
-            // Send data to the Keep proxy. Retry a few times on
-            // fail. Return a promise that will get resolved with
-            // resolve(locator) when the block is accepted by the
-            // proxy.
-            _deferred = $.Deferred();
-            if (proxyUriBase().match(/^http:/) &&
-                window.location.origin.match(/^https:/)) {
-                // In this case, requests will fail, and no ajax
-                // success/fail handlers will be called (!), which
-                // will leave our status saying "uploading" and the
-                // user waiting for something to happen. Better to
-                // give up now.
-                _deferred.reject({
-                    textStatus: 'error',
-                    err: 'There is a server configuration problem. Proxy ' + proxyUriBase() + ' cannot be used from origin ' + window.location.origin + ' due to the browser\'s mixed-content (https/http) policy.'
-                });
-            } else {
-                goSend();
-            }
-            return _deferred.promise();
-        }
-        function stop() {
-            _failMax = 0;
-            _jqxhr.abort();
-            _deferred.reject({
-                textStatus: 'stopped',
-                err: 'interrupted at slice '+_label
-            });
-        }
-        function goSend() {
-            _jqxhr = $.ajax({
-                url: proxyUriBase(),
-                type: 'POST',
-                crossDomain: true,
-                headers: {
-                    'Authorization': 'OAuth2 '+arvadosApiToken,
-                    'Content-Type': 'application/octet-stream',
-                    'X-Keep-Desired-Replicas': '2'
-                },
-                xhr: function() {
-                    // Make an xhr that reports upload progress
-                    var xhr = $.ajaxSettings.xhr();
-                    if (xhr.upload) {
-                        xhr.upload.onprogress = onSendProgress;
-                    }
-                    return xhr;
-                },
-                processData: false,
-                data: _data
-            });
-            _jqxhr.then(onSendResolve, onSendReject);
-        }
-        function onSendProgress(xhrProgressEvent) {
-            _deferred.notify(xhrProgressEvent.loaded, _dataSize);
-        }
-        function onSendResolve(data, textStatus, jqxhr) {
-            _deferred.resolve(data, _dataSize);
-        }
-        function onSendReject(xhr, textStatus, err) {
-            if (++_failCount < _failMax) {
-                // TODO: nice to tell the user that retry is happening.
-                console.log('slice ' + _label + ': ' +
-                            textStatus + ', retry ' + _failCount);
-                goSend();
-            } else {
-                _deferred.reject(
-                    {xhr: xhr, textStatus: textStatus, err: err});
-            }
-        }
-        function proxyUriBase() {
-            return ((keepProxy.service_ssl_flag ? 'https' : 'http') +
-                    '://' + keepProxy.service_host + ':' +
-                    keepProxy.service_port + '/');
-        }
-    }
-
-    function FileUploader(file) {
-        $.extend(this, {
-            file: file,
-            locators: [],
-            progress: 0.0,
-            state: 'Queued',    // Queued, Uploading, Paused, Uploaded, Done
-            statistics: null,
-            go: go,
-            stop: stop          // User wants to stop.
-        });
-        ////////////////////////////////
-        var that = this;
-        var _currentUploader;
-        var _currentSlice;
-        var _deferred;
-        var _maxBlobSize = Math.pow(2,26);
-        var _bytesDone = 0;
-        var _queueTime = Date.now();
-        var _startTime;
-        var _startByte;
-        var _finishTime;
-        var _readPos = 0;       // number of bytes confirmed uploaded
-        function go() {
-            if (_deferred)
-                _deferred.reject({textStatus: 'restarted'});
-            _deferred = $.Deferred();
-            that.state = 'Uploading';
-            _startTime = Date.now();
-            _startByte = _readPos;
-            setProgress();
-            goSlice();
-            return _deferred.promise().always(function() { _deferred = null; });
-        }
-        function stop() {
-            if (_deferred) {
-                that.state = 'Paused';
-                _deferred.reject({textStatus: 'stopped', err: 'interrupted'});
-            }
-            if (_currentUploader) {
-                _currentUploader.stop();
-                _currentUploader = null;
-            }
-        }
-        function goSlice() {
-            // Ensure this._deferred gets resolved or rejected --
-            // either right here, or when a new promise arranged right
-            // here is fulfilled.
-            _currentSlice = nextSlice();
-            if (!_currentSlice) {
-                // All slices have been uploaded, but the work won't
-                // be truly Done until the target collection has been
-                // updated by the QueueUploader. This state is called:
-                that.state = 'Uploaded';
-                setProgress(_readPos);
-                _currentUploader = null;
-                _deferred.resolve([that]);
-                return;
-            }
-            _currentUploader = new SliceUploader(
-                _readPos.toString(),
-                _currentSlice.blob,
-                _currentSlice.size);
-            _currentUploader.go().then(
-                onUploaderResolve,
-                onUploaderReject,
-                onUploaderProgress);
-        }
-        function onUploaderResolve(locator, dataSize) {
-            var sizeHint = (''+locator).split('+')[1];
-            if (!locator || parseInt(sizeHint) !== dataSize) {
-                console.log("onUploaderResolve, but locator '" + locator +
-                            "' with size hint '" + sizeHint +
-                            "' does not look right for dataSize=" + dataSize);
-                return onUploaderReject({
-                    textStatus: "error",
-                    err: "Bad response from slice upload"
-                });
-            }
-            that.locators.push(locator);
-            _readPos += dataSize;
-            _currentUploader = null;
-            goSlice();
-        }
-        function onUploaderReject(reason) {
-            that.state = 'Paused';
-            setProgress(_readPos);
-            _currentUploader = null;
-            if (_deferred)
-                _deferred.reject(reason);
-        }
-        function onUploaderProgress(sliceDone, sliceSize) {
-            setProgress(_readPos + sliceDone);
-        }
-        function nextSlice() {
-            var size = Math.min(
-                _maxBlobSize,
-                that.file.size - _readPos);
-            setProgress(_readPos);
-            if (size === 0) {
-                return false;
-            }
-            var blob = that.file.slice(
-                _readPos, _readPos+size,
-                'application/octet-stream; charset=x-user-defined');
-            return {blob: blob, size: size};
-        }
-        function setProgress(bytesDone) {
-            var kBps;
-            if (that.file.size == 0)
-                that.progress = 100;
-            else
-                that.progress = Math.min(100, 100 * bytesDone / that.file.size);
-            if (bytesDone > _startByte) {
-                kBps = (bytesDone - _startByte) /
-                    (Date.now() - _startTime);
-                that.statistics = (
-                    '' + $filter('number')(bytesDone/1024, '0') + ' KiB ' +
-                        'at ~' + $filter('number')(kBps, '0') + ' KiB/s')
-                if (that.state === 'Paused') {
-                    that.statistics += ', paused';
-                } else if (that.state === 'Uploading') {
-                    that.statistics += ', ETA ' +
-                        $filter('date')(
-                            new Date(
-                                Date.now() + (that.file.size - bytesDone) / kBps),
-                            'shortTime')
-                }
-            } else {
-                that.statistics = that.state;
-            }
-            if (that.state === 'Uploaded') {
-                // 'Uploaded' gets reported as 'finished', which is a
-                // little misleading because the collection hasn't
-                // been updated yet. But FileUploader's portion of the
-                // work (and the time when it makes sense to show
-                // speed and ETA) is finished.
-                that.statistics += ', finished ' +
-                    $filter('date')(Date.now(), 'shortTime');
-                _finishTime = Date.now();
-            }
-            if (_deferred)
-                _deferred.notify();
-        }
-    }
-
-    function QueueUploader() {
-        $.extend(this, {
-            state: 'Idle',      // Idle, Running, Stopped, Failed
-            stateReason: null,
-            statusSuccess: null,
-            go: go,
-            stop: stop
-        });
-        ////////////////////////////////
-        var that = this;
-        var _deferred;          // the one we promise to go()'s caller
-        var _deferredAppend;    // tracks current appendToCollection
-        function go() {
-            if (_deferred) return _deferred.promise();
-            if (_deferredAppend) return _deferredAppend.promise();
-            _deferred = $.Deferred();
-            that.state = 'Running';
-            ArvadosClient.apiPromise(
-                'keep_services', 'list',
-                {filters: [['service_type','=','proxy']]}).
-                then(doQueueWithProxy);
-            onQueueProgress();
-            return _deferred.promise().always(function() { _deferred = null; });
-        }
-        function stop() {
-            that.state = 'Stopped';
-            if (_deferred) {
-                _deferred.reject({});
-            }
-            for (var i=0; i<$scope.uploadQueue.length; i++)
-                $scope.uploadQueue[i].stop();
-            onQueueProgress();
-        }
-        function doQueueWithProxy(data) {
-            keepProxy = data.items[0];
-            if (!keepProxy) {
-                that.state = 'Failed';
-                that.stateReason =
-                    'There seems to be no Keep proxy service available.';
-                _deferred.reject(null, 'error', that.stateReason);
-                return;
-            }
-            return doQueueWork();
-        }
-        function doQueueWork() {
-            // If anything is not Done, do it.
-            if ($scope.uploadQueue.length > 0 &&
-                $scope.uploadQueue[0].state !== 'Done') {
-                if (_deferred) {
-                    that.stateReason = null;
-                    return $scope.uploadQueue[0].go().
-                        then(appendToCollection, null, onQueueProgress).
-                        then(doQueueWork, onQueueReject);
-                } else {
-                    // Queue work has been stopped. Just update the
-                    // view.
-                    onQueueProgress();
-                    return;
-                }
-            }
-            // If everything is Done, resolve the promise and clean
-            // up. Note this can happen even after the _deferred
-            // promise has been rejected: specifically, when stop() is
-            // called too late to prevent completion of the last
-            // upload. In that case we want to update state to "Idle",
-            // rather than leave it at "Stopped".
-            onQueueResolve();
-        }
-        function onQueueReject(reason) {
-            if (!_deferred) {
-                // Outcome has already been decided (by stop()).
-                return;
-            }
-
-            that.state = 'Failed';
-            that.stateReason = (
-                (reason.textStatus || 'Error') +
-                    (reason.xhr && reason.xhr.options
-                     ? (' (from ' + reason.xhr.options.url + ')')
-                     : '') +
-                    ': ' +
-                    (reason.err || defaultErrorMessage));
-            if (reason.xhr && reason.xhr.responseText)
-                that.stateReason += ' -- ' + reason.xhr.responseText;
-            _deferred.reject(reason);
-            onQueueProgress();
-        }
-        function onQueueResolve() {
-            that.state = 'Idle';
-            that.stateReason = 'Done!';
-            if (_deferred)
-                _deferred.resolve();
-            onQueueProgress();
-        }
-        function onQueueProgress() {
-            // Ensure updates happen after FileUpload promise callbacks.
-            $timeout(function(){$scope.$apply();});
-        }
-        function appendToCollection(uploads) {
-            _deferredAppend = $.Deferred();
-            ArvadosClient.apiPromise(
-                'collections', 'get',
-                { uuid: $scope.uuid }).
-                then(function(collection) {
-                    var manifestText = '';
-                    $.each(uploads, function(_, upload) {
-                        var locators = upload.locators;
-                        if (locators.length === 0) {
-                            // Every stream must have at least one
-                            // data locator, even if it is zero bytes
-                            // long:
-                            locators = ['d41d8cd98f00b204e9800998ecf8427e+0'];
-                        }
-                        filename = ArvadosClient.uniqueNameForManifest(
-                            collection.manifest_text,
-                            '.', upload.file.name);
-                        collection.manifest_text += '. ' +
-                            locators.join(' ') +
-                            ' 0:' + upload.file.size.toString() + ':' +
-                            filename +
-                            '\n';
-                    });
-                    return ArvadosClient.apiPromise(
-                        'collections', 'update',
-                        { uuid: $scope.uuid,
-                          collection:
-                          { manifest_text:
-                            collection.manifest_text }
-                        });
-                }).
-                then(function() {
-                    // Mark the completed upload(s) as Done and push
-                    // them to the bottom of the queue.
-                    var i, qLen = $scope.uploadQueue.length;
-                    for (i=0; i<qLen; i++) {
-                        if (uploads.indexOf($scope.uploadQueue[i]) >= 0) {
-                            $scope.uploadQueue[i].state = 'Done';
-                            $scope.uploadQueue.push.apply(
-                                $scope.uploadQueue,
-                                $scope.uploadQueue.splice(i, 1));
-                            --i;
-                            --qLen;
-                        }
-                    }
-                }).
-                then(_deferredAppend.resolve,
-                     _deferredAppend.reject);
-            return _deferredAppend.promise().
-                always(function() {
-                    _deferredAppend = null;
-                });
-        }
-    }
-}
diff --git a/apps/workbench/app/assets/javascripts/user_agreements.js b/apps/workbench/app/assets/javascripts/user_agreements.js
deleted file mode 100644 (file)
index 7ce5342..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-function enable_okbutton() {
-    var $div = $('#open_user_agreement');
-    var allchecked = $('input[name="checked[]"]', $div).not(':checked').length == 0;
-    $('input[type=submit]', $div).prop('disabled', !allchecked);
-}
-$(document).on('click keyup input', '#open_user_agreement input', enable_okbutton);
-$(document).on('ready ajax:complete', enable_okbutton);
diff --git a/apps/workbench/app/assets/javascripts/users.js b/apps/workbench/app/assets/javascripts/users.js
deleted file mode 100644 (file)
index 565ea9c..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).
-    on('notifications:recount',
-       function() {
-           var menu = $('.notification-menu');
-           n = $('.notification', menu).not('.empty').length;
-           $('.notification-count', menu).html(n>0 ? n : '');
-       }).
-    on('ajax:success', 'form.new_authorized_key',
-       function(e, data, status, xhr) {
-           $(e.target).parents('.notification').eq(0).fadeOut('slow', function() {
-               $('<li class="alert alert-success daxalert">SSH key added.</li>').hide().replaceAll(this).fadeIn('slow');
-               $(document).trigger('notifications:recount');
-           });
-       }).
-    on('ajax:complete', 'form.new_authorized_key',
-       function(e, data, status, xhr) {
-           $($('input[name=disable_element]', e.target).val()).
-               fadeTo(200, 1.0);
-       }).
-    on('ajax:error', 'form.new_authorized_key',
-       function(e, xhr, status, error) {
-           var error_div;
-           response = $.parseJSON(xhr.responseText);
-           error_div = $(e.target).parent().find('div.ajax-errors');
-           if (error_div.length == 0) {
-               $(e.target).parent().append('<div class="alert alert-error ajax-errors"></div>');
-               error_div = $(e.target).parent().find('div.ajax-errors');
-           }
-           if (response.errors) {
-               error_div.html($('<p/>').text(response.errors).html());
-           } else {
-               error_div.html('<p>Sorry, request failed.</p>');
-           }
-           error_div.show();
-           $($('input[name=disable_element]', e.target).val()).
-               fadeTo(200, 1.0);
-       }).
-    on('click', 'form[data-remote] input[type=submit]',
-       function(e) {
-           $(e.target).parents('form').eq(0).parent().find('div.ajax-errors').html('').hide();
-           $($(e.target).
-             parents('form').
-             find('input[name=disable_element]').
-             val()).
-               fadeTo(200, 0.3);
-           return true;
-       });
diff --git a/apps/workbench/app/assets/javascripts/work_unit_component.js b/apps/workbench/app/assets/javascripts/work_unit_component.js
deleted file mode 100644 (file)
index a84a2e7..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).
-  on('click', '.component-detail-panel', function(event) {
-    var href = $($(event.target).attr('href'));
-    if ($(href).hasClass("in")) {
-      var content_div = href.find('.work-unit-component-detail-body');
-      content_div.html('<div class="spinner spinner-32px col-sm-1"></div>');
-      var content_url = href.attr('content-url');
-      var action_data = href.attr('action-data');
-      $.ajax(content_url, {dataType: 'html', type: 'POST', data: {action_data: action_data}}).
-        done(function(data, status, jqxhr) {
-          content_div.html(data);
-        }).fail(function(jqxhr, status, error) {
-          content_div.html(error);
-        });
-    }
-  });
diff --git a/apps/workbench/app/assets/javascripts/work_unit_log.js b/apps/workbench/app/assets/javascripts/work_unit_log.js
deleted file mode 100644 (file)
index c43bae0..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-$(document).on('arv-log-event', '.arv-log-event-handler-append-logs', function(event, eventData){
-    var wasatbottom, txt;
-    if (this != event.target) {
-        // Not interested in events sent to child nodes.
-        return;
-    }
-
-    if (!('properties' in eventData)) {
-        return;
-    }
-
-    txt = '';
-    if ('text' in eventData.properties &&
-       eventData.properties.text.length > 0) {
-        txt += eventData.properties.text;
-        if (txt.slice(txt.length-1) != "\n") {
-            txt += "\n";
-        }
-    }
-    if (eventData.event_type == 'update' &&
-        eventData.object_uuid.indexOf("-dz642-") == 5 &&
-        'old_attributes' in eventData.properties &&
-        'new_attributes' in eventData.properties) {
-        // Container update
-        if (eventData.properties.old_attributes.state != eventData.properties.new_attributes.state) {
-            var stamp = eventData.event_at + " ";
-            switch(eventData.properties.new_attributes.state) {
-            case "Queued":
-                txt += stamp + "Container "+eventData.object_uuid+" was returned to the queue\n";
-                break;
-            case "Locked":
-                txt += stamp + "Container "+eventData.object_uuid+" was taken from the queue by a dispatch process\n";
-                break;
-            case "Running":
-                txt += stamp + "Container "+eventData.object_uuid+" started\n";
-                break;
-            case "Complete":
-                txt += stamp + "Container "+eventData.object_uuid+" finished\n";
-                break;
-            case "Cancelled":
-                txt += stamp + "Container "+eventData.object_uuid+" was cancelled\n";
-                break;
-            default:
-                // Unknown state -- unexpected, might as well log it.
-                txt += stamp + "Container "+eventData.object_uuid+" changed state to " +
-                    eventData.properties.new_attributes.state + "\n";
-                break;
-            }
-        }
-    }
-
-    if (txt == '') {
-        return;
-    }
-
-    wasatbottom = (this.scrollTop + this.clientHeight >= this.scrollHeight);
-    if (eventData.prepend) {
-        $(this).prepend(txt);
-    } else {
-        $(this).append(txt);
-    }
-    if (wasatbottom) {
-        this.scrollTop = this.scrollHeight;
-    }
-});
diff --git a/apps/workbench/app/assets/stylesheets/api_client_authorizations.css.scss b/apps/workbench/app/assets/stylesheets/api_client_authorizations.css.scss
deleted file mode 100644 (file)
index ec87eb2..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the ApiClientAuthorizations controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/application.css.scss b/apps/workbench/app/assets/stylesheets/application.css.scss
deleted file mode 100644 (file)
index 1f21c39..0000000
+++ /dev/null
@@ -1,355 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-/*
- * This is a manifest file that'll be compiled into application.css, which will include all the files
- * listed below.
- *
- * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
- * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
- *
- * You're free to add application-wide styles to this file and they'll appear at the top of the
- * compiled file, but it's generally better to create a new file per style scope.
- *
- *= require_self
- *= require bootstrap3-editable/bootstrap-editable
- *= require morris
- *= require awesomplete
- *= require_tree .
- */
-
-@import "bootstrap-sprockets";
-@import "bootstrap";
-
-.contain-align-left {
-    text-align: left;
-}
-table.topalign>tbody>tr>td {
-    vertical-align: top;
-}
-table.topalign>thead>tr>td {
-    vertical-align: bottom;
-}
-tr.cell-valign-center>td {
-    vertical-align: middle;
-}
-tr.cell-noborder>td,tr.cell-noborder>th {
-    border: none;
-}
-table.table-justforlayout>tr>td,
-table.table-justforlayout>tr>th,
-table.table-justforlayout>thead>tr>td,
-table.table-justforlayout>thead>tr>th,
-table.table-justforlayout>tbody>tr>td,
-table.table-justforlayout>tbody>tr>th{
-    border: none;
-}
-table.table-justforlayout {
-    margin-bottom: 0;
-}
-.smaller-text {
-    font-size: .8em;
-}
-.deemphasize {
-    font-size: .8em;
-    color: #888;
-}
-.lighten {
-    color: #888;
-}
-.arvados-filename,
-.arvados-uuid {
-    font-size: .8em;
-    font-family: monospace;
-}
-table .data-size, .table .data-size {
-    text-align: right;
-}
-body .editable-empty {
-    color: #999;
-}
-body .editable-empty:hover {
-    color: #0088cc;
-}
-table.arv-index tbody td.arv-object-AuthorizedKey.arv-attr-public_key {
-    overflow-x: hidden;
-    max-width: 120px;
-}
-table.arv-index > thead > tr > th {
-    border-top: none;
-}
-table.table-fixedlayout {
-    white-space: nowrap;
-    table-layout: fixed;
-}
-table.table-fixedlayout td {
-    overflow: hidden;
-    overflow-x: hidden;
-    text-overflow: ellipsis;
-}
-table.table-smallcontent td {
-    font-size: 85%;
-}
-form input.search-mini {
-    padding: 0 6px;
-}
-form.small-form-margin {
-    margin-bottom: 2px;
-}
-.nowrap {
-    white-space: nowrap;
-}
-input.select-on-focus {
-    font-family: monospace;
-    background: inherit;
-    border: thin #ccc solid;
-    border-radius: .2em;
-    padding: .15em .5em;
-}
-input.select-on-focus:focus {
-    border-color: #9bf;
-}
-
-/* top nav */
-$top-nav-bg: #3c163d;
-$top-nav-bg-bottom: #260027;
-nav.navbar-fixed-top .navbar-brand {
-    color: #79537a;
-    letter-spacing: 0.4em;
-}
-nav.navbar-fixed-top {
-    background: $top-nav-bg;
-    background: linear-gradient(to bottom, $top-nav-bg 0%,$top-nav-bg-bottom 100%);
-}
-.navbar.breadcrumbs {
-    line-height: 50px;
-    border-radius: 0;
-    margin-bottom: 0;
-    border-right: 0;
-    border-left: 0;
-}
-.navbar.breadcrumbs .nav > li > a,
-.navbar.breadcrumbs .nav > li {
-    color: #000;
-}
-.navbar.breadcrumbs .nav > li.nav-separator > i {
-    color: #bbb;
-}
-.navbar.breadcrumbs .navbar-form {
-  margin-top: 0px;
-  margin-bottom: 0px;
-}
-.navbar.breadcrumbs .navbar-text {
-  margin-top: 0px;
-  margin-bottom: 0px;
-}
-
-nav.navbar-fixed-top .navbar-nav.navbar-right > li.open > a,
-nav.navbar-fixed-top .navbar-nav.navbar-right > li.open > a:focus,
-nav.navbar-fixed-top .navbar-nav.navbar-right > li.open > a:hover {
-    background: lighten($top-nav-bg, 5%);
-}
-nav.navbar-fixed-top .navbar-nav.navbar-right > li > a,
-nav.navbar-fixed-top .navbar-nav.navbar-right > li > a:focus,
-nav.navbar-fixed-top .navbar-nav.navbar-right > li > a:hover {
-    color: #fff;
-}
-
-.dax {
-    max-width: 10%;
-    margin-right: 1em;
-    float: left
-}
-
-.smart-scroll {
-    overflow: auto;
-    margin-bottom: -15px;
-}
-
-.infinite-scroller .fa-warning {
-    color: #800;
-}
-
-th[data-sort-order] {
-    cursor: pointer;
-}
-
-.inline-progress-container div.progress {
-    margin-bottom: 0;
-}
-
-.inline-progress-container {
-    width: 100%;
-    display:inline-block;
-}
-
-td.add-tag-button {
-    white-space: normal;
-}
-td.add-tag-button .add-tag-button {
-    margin-right: 4px;
-    opacity: 0.2;
-}
-td.add-tag-button .add-tag-button:hover {
-    opacity: 1;
-}
-span.removable-tag-container {
-    line-height: 1.6;
-}
-.label.removable-tag a {
-    color: #fff;
-    cursor: pointer;
-}
-
-li.notification {
-    padding: 10px;
-}
-
-td.trash-project-msg {
-    white-space: normal;
-}
-
-// See HeaderRowFixer in application.js
-table.table-fixed-header-row {
-    width: 100%;
-    border-spacing: 0px;
-    margin:0;
-}
-table.table-fixed-header-row thead {
-    position:fixed;
-    background: #fff;
-}
-table.table-fixed-header-row tbody {
-    position:relative;
-    top:1.5em;
-}
-
-.dropdown-menu {
-    max-height: 30em;
-    overflow-y: auto;
-}
-
-.dropdown-menu a {
-    cursor: pointer;
-}
-
-.row-fill-height, .row-fill-height>div[class*='col-'] {
-    display: flex;
-}
-.row-fill-height>div[class*='col-']>div {
-    width: 100%;
-}
-
-/* Show editable popover above side-nav */
-.editable-popup.popover {
-    z-index:1055;
-}
-
-/* Do not leave space for left-nav */
-div#wrapper {
-  padding-left: 0;
-}
-
-.arv-description-as-subtitle {
-  padding-bottom: 1em;
-}
-.arv-description-in-table {
-  height: 4em;
-  overflow-x: hidden;
-  overflow-y: hidden;
-}
-.arv-description-in-table:hover {
-  overflow-y: auto;
-}
-
-.btn.btn-nodecorate {
-  border: none;
-}
-svg text {
-    font-size: 6pt;
-}
-
-div.pane-content iframe {
-  width: 100%;
-  border: none;
-}
-span.editable-textile {
-  display: inline-block;
-}
-.text-overflow-ellipsis {
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-.time-label-divider {
-  font-size: 80%;
-  min-width: 1em;
-  padding: 0px 2px 0px 0px;
-}
-.task-summary-status {
-  font-size: 80%;
-}
-#page-wrapper > div > h2 {
-  margin-top: 0px;
-}
-
-.compute-summary-numbers td {
-  font-size: 150%;
-}
-
-.arv-log-refresh-control {
-  display: none;
-}
-
-/* Hide Angular content until Angular is ready */
-[ng\:cloak], [ng-cloak], .ng-cloak {
-    display: none !important;
-}
-
-/* tabs */
-ul.nav.nav-tabs {
-    font-size: 90%
-}
-
-.hover-dropdown:hover .dropdown-menu {
-  display: block;
-}
-
-.arv-description-as-subtitle .editable-inline,
-.arv-description-as-subtitle .editable-inline .form-group,
-.arv-description-as-subtitle .editable-inline .form-group .editable-input,
-.arv-description-as-subtitle .editable-inline .form-group .editable-input textarea,
-{
-    width: 98%!important;
-}
-
-/* Needed for awesomplete to play nice with bootstrap */
-div.awesomplete {
-    display: block;
-}
-/* Makes awesomplete listings to be scrollable */
-.awesomplete > ul {
-    max-height: 410px;
-    overflow-y: auto;
-}
-
-.dropdown-menu > li > form > button {
-    display: block;
-    padding: 3px 20px;
-    clear: both;
-    font-weight: normal;
-    line-height: 1.428571429;
-    color: #333333;
-    white-space: nowrap;
-    cursor: pointer;
-    text-decoration: none;
-    background: transparent;
-    border-style: none;
-}
-
-.dropdown-menu > li > form > button:hover {
-    text-decoration: none;
-    color: #262626;
-    background-color: #f5f5f5;
-}
diff --git a/apps/workbench/app/assets/stylesheets/authorized_keys.css.scss b/apps/workbench/app/assets/stylesheets/authorized_keys.css.scss
deleted file mode 100644 (file)
index 73cfd5b..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the AuthorizedKeys controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
-form .table input[type=text] {
-    width: 600px;
-}
-form .table textarea {
-    width: 600px;
-    height: 10em;
-}
diff --git a/apps/workbench/app/assets/stylesheets/badges.css.scss b/apps/workbench/app/assets/stylesheets/badges.css.scss
deleted file mode 100644 (file)
index ddaf5b9..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-/* Colors
- * Contextual variations of badges
- * Bootstrap 3.0 removed contexts for badges, we re-introduce them, based on what is done for labels
- */
-
-.badge.badge-error {
-  background-color: #b94a48;
-}
-
-.badge.badge-warning {
-  background-color: #f89406;
-}
-
-.badge.badge-success {
-  background-color: #468847;
-}
-
-.badge.badge-info {
-  background-color: #3a87ad;
-}
-
-.badge.badge-inverse {
-  background-color: #333333;
-}
-
-.badge.badge-alert {
-    background: red;
-}
diff --git a/apps/workbench/app/assets/stylesheets/cards.css.scss b/apps/workbench/app/assets/stylesheets/cards.css.scss
deleted file mode 100644 (file)
index 3cf29c5..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-.card {
-    padding-top: 20px;
-    margin: 10px 0 20px 0;
-    background-color: #ffffff;
-    border: 1px solid #d8d8d8;
-    border-top-width: 0;
-    border-bottom-width: 2px;
-    -webkit-border-radius: 3px;
-    -moz-border-radius: 3px;
-    border-radius: 3px;
-    -webkit-box-shadow: none;
-    -moz-box-shadow: none;
-    box-shadow: none;
-    -webkit-box-sizing: border-box;
-    -moz-box-sizing: border-box;
-    box-sizing: border-box;
-}
-.card.arvados-object {
-    position: relative;
-    display: inline-block;
-    width: 170px;
-    height: 175px;
-    padding-top: 0;
-    margin-left: 20px;
-    overflow: hidden;
-    vertical-align: top;
-}
-.card.arvados-object .card-top.green {
-    background-color: #53a93f;
-}
-.card.arvados-object .card-top.blue {
-    background-color: #427fed;
-}
-.card.arvados-object .card-top {
-    position: absolute;
-    top: 0;
-    left: 0;
-    display: inline-block;
-    width: 170px;
-    height: 25px;
-    background-color: #ffffff;
-}
-.card.arvados-object .card-info {
-    position: absolute;
-    top: 25px;
-    display: inline-block;
-    width: 100%;
-    height: 101px;
-    overflow: hidden;
-    background: #ffffff;
-    -webkit-box-sizing: border-box;
-    -moz-box-sizing: border-box;
-    box-sizing: border-box;
-}
-.card.arvados-object .card-info .title {
-    display: block;
-    margin: 8px 14px 0 14px;
-    overflow: hidden;
-    font-size: 16px;
-    font-weight: bold;
-    line-height: 18px;
-    color: #404040;
-}
-.card.arvados-object .card-info .desc {
-    display: block;
-    margin: 8px 14px 0 14px;
-    overflow: hidden;
-    font-size: 12px;
-    line-height: 16px;
-    color: #737373;
-    text-overflow: ellipsis;
-}
-.card.arvados-object .card-bottom {
-    position: absolute;
-    bottom: 0;
-    left: 0;
-    display: inline-block;
-    width: 100%;
-    padding: 10px 20px;
-    line-height: 29px;
-    text-align: center;
-    -webkit-box-sizing: border-box;
-    -moz-box-sizing: border-box;
-    box-sizing: border-box;
-}
diff --git a/apps/workbench/app/assets/stylesheets/collections.css.scss b/apps/workbench/app/assets/stylesheets/collections.css.scss
deleted file mode 100644 (file)
index c5cc699..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-/* Style for _show_files tree view. */
-
-ul#collection_files {
-  padding: 0 .5em;
-}
-
-ul.collection_files {
-  line-height: 2.5em;
-  list-style-type: none;
-  padding-left: 2.3em;
-}
-
-ul.collection_files li {
-  clear: both;
-}
-
-.collection_files_row {
-  padding: 1px;  /* Replaced by border for :hover */
-}
-
-.collection_files_row:hover {
-  background-color: #D9EDF7;
-  padding: 0px;
-  border: 1px solid #BCE8F1;
-  border-radius: 3px;
-}
-
-.collection_files_inline {
-  clear: both;
-  width: 80%;
-  margin: 0 3em;
-}
-
-.collection_files_inline img {
-  max-height: 15em;
-}
-
-.collection_files_name {
-  padding-left: .5em;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.collection_files_name i.fa-fw:first-child {
-  width: 1.6em;
-}
-
-/*
-  "active" and "inactive" colors are too similar for a toggle switch
-  in the default bootstrap theme.
-  */
-
-$inactive-bg: #5bc0de;
-$active-bg: #39b3d7;
-
-.btn-group.toggle-persist .btn {
-    width: 6em;
-}
-.btn-group.toggle-persist .btn-info {
-    background-color: lighten($inactive-bg, 15%);
-}
-
-.btn-group.toggle-persist .btn-info.active {
-    background-color: $active-bg;
-}
-
-.lock-collection-btn {
-    display: inline-block;
-    padding: .5em 2em;
-    margin: 0 1em;
-}
-
-.collection-tag-field * {
-  display: inline-block;
-}
diff --git a/apps/workbench/app/assets/stylesheets/groups.css.scss b/apps/workbench/app/assets/stylesheets/groups.css.scss
deleted file mode 100644 (file)
index 905e72a..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Groups controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/humans.css.scss b/apps/workbench/app/assets/stylesheets/humans.css.scss
deleted file mode 100644 (file)
index 29668c2..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Humans controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/job_tasks.css.scss b/apps/workbench/app/assets/stylesheets/job_tasks.css.scss
deleted file mode 100644 (file)
index 0d4d260..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the JobTasks controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/jobs.css.scss b/apps/workbench/app/assets/stylesheets/jobs.css.scss
deleted file mode 100644 (file)
index 9b1ea65..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-.arv-job-log-window {
-    height: 40em;
-    white-space: pre;
-    overflow: scroll;
-    background: black;
-    color: white;
-    font-family: monospace;
-    font-size: .8em;
-    border: 2px solid black;
-}
-
-.morris-hover-point {
-    text-align: left;
-    width: 100%;
-}
\ No newline at end of file
diff --git a/apps/workbench/app/assets/stylesheets/keep_disks.css.scss b/apps/workbench/app/assets/stylesheets/keep_disks.css.scss
deleted file mode 100644 (file)
index 0985d8c..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the KeepDisks controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
-
-/* Margin allows us some space between the table above. */
-div.graph {
-    margin-top: 20px;
-}
-div.graph h3, div.graph h4 {
-    text-align: center;
-}
diff --git a/apps/workbench/app/assets/stylesheets/links.css.scss b/apps/workbench/app/assets/stylesheets/links.css.scss
deleted file mode 100644 (file)
index cf4c4e7..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Links controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/loading.css.scss.erb b/apps/workbench/app/assets/stylesheets/loading.css.scss.erb
deleted file mode 100644 (file)
index ee6ca34..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-.loading {
-    opacity: 0;
-}
-
-.spinner {
-    /* placeholder for stuff like $.find('.spinner').detach() */
-}
-
-.spinner-32px {
-    background-image: url('<%= asset_path('spinner_32px.gif') %>');
-    background-repeat: no-repeat;
-    width: 32px;
-    height: 32px;
-}
-
-.spinner-h-center {
-    margin-left: auto;
-    margin-right: auto;
-}
-
-.spinner-v-center {
-    position: relative;
-    top: 45%;
-}
-
-.rotating {
-    color: #f00;
-    /* Chrome and Firefox, at least in Linux, render a horrible shaky
-       mess -- better not to bother.
-
-      animation-name: rotateThis;
-      animation-duration: 2s;
-      animation-iteration-count: infinite;
-      animation-timing-function: linear;
-      -moz-animation-name: rotateThis;
-      -moz-animation-duration: 2s;
-      -moz-animation-iteration-count: infinite;
-      -moz-animation-timing-function: linear;
-      -ms-animation-name: rotateThis;
-      -ms-animation-duration: 2s;
-      -ms-animation-iteration-count: infinite;
-      -ms-animation-timing-function: linear;
-      -webkit-animation-name: rotateThis;
-      -webkit-animation-duration: 2s;
-      -webkit-animation-iteration-count: infinite;
-      -webkit-animation-timing-function: linear;
-      */
-}
-
-@keyframes rotateThis {
-  from { transform: rotate( 0deg );   }
-  to   { transform: rotate( 360deg ); }
-}
-
-@-webkit-keyframes rotateThis {
-  from { -webkit-transform: rotate( 0deg );   }
-  to   { -webkit-transform: rotate( 360deg ); }
-}
-
-@-moz-keyframes rotateThis {
-  from { -moz-transform: rotate( 0deg );   }
-  to   { -moz-transform: rotate( 360deg ); }
-}
-
-@-ms-keyframes rotateThis {
-  from { -ms-transform: rotate( 0deg );   }
-  to   { -ms-transform: rotate( 360deg ); }
-}
diff --git a/apps/workbench/app/assets/stylesheets/log_viewer.scss b/apps/workbench/app/assets/stylesheets/log_viewer.scss
deleted file mode 100644 (file)
index c3fa8b9..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-.log-viewer-table {
- width: 100%;
- font-family: "Lucida Console", Monaco, monospace;
- font-size: 11px;
- table-layout: fixed;
- thead tr {
-   th {
-     padding-right: 1em;
-   }
-   th.id {
-     display: none;
-   }
-   th.timestamp {
-     width: 15em;
-   }
-   th.type {
-     width: 8em;
-   }
-   th.taskid {
-     width: 4em;
-   }
-   th.node {
-     width: 8em;
-   }
-   th.slot {
-     width: 3em;
-   }
-   th.message {
-     width: auto;
-   }
- }
- tbody tr {
-   vertical-align: top;
-   td {
-     padding-right: 1em;
-   }
-   td.id {
-     display: none;
-   }
-   td.taskid {
-     text-align: right;
-   }
-   td.slot {
-     text-align: right;
-   }
-   td.message {
-     word-wrap: break-word;
-   }
- }
-}
-
-.log-viewer-button {
-  width: 12em;
-}
-
-.log-viewer-paging-div {
-  font-size: 18px;
-  text-align: center;
-}
-
-.log-viewer-page-num {
-  padding-left: .3em;
-  padding-right: .3em;
-}
\ No newline at end of file
diff --git a/apps/workbench/app/assets/stylesheets/logs.css.scss b/apps/workbench/app/assets/stylesheets/logs.css.scss
deleted file mode 100644 (file)
index c8b22f9..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Logs controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/nodes.css.scss b/apps/workbench/app/assets/stylesheets/nodes.css.scss
deleted file mode 100644 (file)
index a7b0861..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Nodes controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/pipeline_instances.css.scss b/apps/workbench/app/assets/stylesheets/pipeline_instances.css.scss
deleted file mode 100644 (file)
index 135685c..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the PipelineInstances controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
-
-.pipeline-compare-headrow div {
-    padding-top: .5em;
-    padding-bottom: .5em;
-}
-.pipeline-compare-headrow:first-child {
-    border-bottom: 1px solid black;
-}
-.pipeline-compare-row .notnormal {
-    background: #ffffaa;
-}
-
-.pipeline_color_legend {
-    margin-top: 0.2em;
-    padding: 0.2em 1em;
-    border: 1px solid #000;
-}
-.pipeline_color_legend a {
-    color: #000;
-}
-
-.col-md-1.pipeline-instance-spacing {
-  padding: 0px;
-  margin: 0px;
-}
-
-.col-md-3.pipeline-instance-spacing > .progress {
-  padding: 0px;
-  margin: 0px;
-}
\ No newline at end of file
diff --git a/apps/workbench/app/assets/stylesheets/pipeline_templates.css.scss b/apps/workbench/app/assets/stylesheets/pipeline_templates.css.scss
deleted file mode 100644 (file)
index 329f0ed..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the PipelineTemplates controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
-
-.pipeline_color_legend {
-    padding-left: 1em;
-    padding-right: 1em;
-}
-
-table.pipeline-components-table {
-  width: 100%;
-  table-layout: fixed;
-  overflow: hidden;
-}
-
-table.pipeline-components-table thead th {
-  text-align: bottom;
-}
-table.pipeline-components-table div.progress {
-  margin-bottom: 0;
-}
-
-table.pipeline-components-table td {
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-td.required {
-  background: #ffdddd;
-}
diff --git a/apps/workbench/app/assets/stylesheets/projects.css.scss b/apps/workbench/app/assets/stylesheets/projects.css.scss
deleted file mode 100644 (file)
index 10c2ed0..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-.arv-project-list > .row {
-    padding-top: 5px;
-    padding-bottom: 5px;
-    padding-right: 1em;
-}
-.arv-project-list > .row.project:hover {
-    background: #d9edf7;
-}
-div.scroll-20em {
-    height: 20em;
-    overflow-y: scroll;
-}
-
-.compute-summary {
-    margin: 0.15em 0em 0.15em 0em;
-    display: inline-block;
-}
-
-.compute-summary-head {
-    margin-left: 0.3em;
-}
-
-.compute-detail {
-    border: 1px solid;
-    border-color: #DDD;
-    border-radius: 3px;
-    padding: 0.2em;
-    position: absolute;
-    z-index: 1;
-    background: white;
-}
-
-.compute-detail:hover {
-   cursor: pointer;
-}
-
-.compute-node-summary:hover {
-  cursor: pointer;
-}
-
-.compute-summary-numbers .panel {
-  margin-bottom: 0px;
-}
-
-.compute-summary-numbers table {
-  width: 100%;
-  td,th {
-    text-align: center;
-  }
-}
-
-.compute-summary-nodelist {
-  margin-bottom: 10px
-}
-
-.dashboard-panel-info-row {
-  padding: .5em;
-  border-radius: .3em;
-}
-
-.dashboard-panel-info-row:hover {
-  background-color: #D9EDF7;
-}
-
-.progress-bar.progress-bar-default {
-  background-color: #999;
-}
\ No newline at end of file
diff --git a/apps/workbench/app/assets/stylesheets/repositories.css.scss b/apps/workbench/app/assets/stylesheets/repositories.css.scss
deleted file mode 100644 (file)
index 1dd9a16..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Repositories controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/sb-admin.css.scss b/apps/workbench/app/assets/stylesheets/sb-admin.css.scss
deleted file mode 100644 (file)
index 9bae214..0000000
+++ /dev/null
@@ -1,164 +0,0 @@
-/* 
-Author: Start Bootstrap - http://startbootstrap.com
-'SB Admin' HTML Template by Start Bootstrap
-
-All Start Bootstrap themes are licensed under Apache 2.0. 
-For more info and more free Bootstrap 3 HTML themes, visit http://startbootstrap.com!
-*/
-
-/* ATTN: This is mobile first CSS - to update 786px and up screen width use the media query near the bottom of the document! */
-
-/* Global Styles */
-
-body {
-  margin-top: 50px;
-}
-
-#wrapper {
-  padding-left: 0;
-}
-
-#page-wrapper {
-  width: 100%;
-  padding: 5px 15px;
-}
-
-/* Nav Messages */
-
-.messages-dropdown .dropdown-menu .message-preview .avatar,
-.messages-dropdown .dropdown-menu .message-preview .name,
-.messages-dropdown .dropdown-menu .message-preview .message,
-.messages-dropdown .dropdown-menu .message-preview .time {
-  display: block;
-}
-
-.messages-dropdown .dropdown-menu .message-preview .avatar {
-  float: left;
-  margin-right: 15px;
-}
-
-.messages-dropdown .dropdown-menu .message-preview .name {
-  font-weight: bold;
-}
-
-.messages-dropdown .dropdown-menu .message-preview .message {
-  font-size: 12px;
-}
-
-.messages-dropdown .dropdown-menu .message-preview .time {
-  font-size: 12px;
-}
-
-
-/* Nav Announcements */
-
-.announcement-heading {
-  font-size: 50px;
-  margin: 0;
-}
-
-.announcement-text {
-  margin: 0;
-}
-
-/* Table Headers */
-
-table.tablesorter thead {
-  cursor: pointer;
-}
-
-table.tablesorter thead tr th:hover {
-  background-color: #f5f5f5;
-}
-
-/* Flot Chart Containers */
-
-.flot-chart {
-  display: block;
-  height: 400px;
-}
-
-.flot-chart-content {
-  width: 100%;
-  height: 100%;
-}
-
-/* Edit Below to Customize Widths > 768px */
-@media (min-width:768px) {
-
-  /* Wrappers */
-
-  #wrapper {
-        padding-left: 225px;
-  }
-
-  #page-wrapper {
-        padding: 15px 25px;
-  }
-
-  /* Side Nav */
-
-  .side-nav {
-        margin-left: -225px;
-        left: 225px;
-        width: 225px;
-        position: fixed;
-        top: 50px;
-        height: calc(100% - 50px);
-        border-radius: 0;
-        border: none;
-        background-color: #f8f8f8;
-        overflow-y: auto;
-        overflow-x: hidden; /* no left nav scroll bar */
-  }
-
-  /* Bootstrap Default Overrides - Customized Dropdowns for the Side Nav */
-
-  .side-nav>li.dropdown>ul.dropdown-menu {
-        position: relative;
-        min-width: 225px;
-        margin: 0;
-        padding: 0;
-        border: none;
-        border-radius: 0;
-        background-color: transparent;
-        box-shadow: none;
-        -webkit-box-shadow: none;
-  }
-
-  .side-nav>li.dropdown>ul.dropdown-menu>li>a {
-        color: #777777;
-        padding: 15px 15px 15px 25px;
-  }
-
-  .side-nav>li.dropdown>ul.dropdown-menu>li>a:hover,
-  .side-nav>li.dropdown>ul.dropdown-menu>li>a.active,
-  .side-nav>li.dropdown>ul.dropdown-menu>li>a:focus {
-        background-color: #ffffff;
-  }
-
-  .side-nav>li>a {
-        width: 225px;
-  }
-
-  .navbar-default .navbar-nav.side-nav>li>a:hover,
-  .navbar-default .navbar-nav.side-nav>li>a:focus {
-        background-color: #ffffff;
-  }
-
-  /* Nav Messages */
-
-  .messages-dropdown .dropdown-menu {
-        min-width: 300px;
-  }
-
-  .messages-dropdown .dropdown-menu li a {
-        white-space: normal;
-  }
-
-  .navbar-collapse {
-    padding-left: 15px !important;
-    padding-right: 15px !important;
-  }
-
-}
diff --git a/apps/workbench/app/assets/stylesheets/scaffolds.css.scss b/apps/workbench/app/assets/stylesheets/scaffolds.css.scss
deleted file mode 100644 (file)
index 23e0f76..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-/*
-  We don't want the default Rails CSS, so the rules are deleted. This
-  empty file is left here so Rails doesn't re-add it next time it
-  generates a scaffold.
-  */
diff --git a/apps/workbench/app/assets/stylesheets/select_modal.css.scss b/apps/workbench/app/assets/stylesheets/select_modal.css.scss
deleted file mode 100644 (file)
index bd7ff92..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-.selectable-container > .row {
-    padding-top: 5px;
-    padding-bottom: 5px;
-    padding-right: 1em;
-    color: #888;
-}
-.selectable-container > .row.selectable {
-    color: #000;
-}
-.selectable.active, .selectable:hover {
-    background: #d9edf7;
-    cursor: pointer;
-}
-.selectable.active,
-.selectable.active *,
-.selectable.active:hover,
-.selectable.active:hover * {
-    background: #428bca;
-    color: #fff;
-}
-.selectable-container > .row.class-separator {
-    background: #ddd;
-}
diff --git a/apps/workbench/app/assets/stylesheets/sessions.css.scss b/apps/workbench/app/assets/stylesheets/sessions.css.scss
deleted file mode 100644 (file)
index e08b086..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Sessions controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/specimens.css.scss b/apps/workbench/app/assets/stylesheets/specimens.css.scss
deleted file mode 100644 (file)
index 60d630c..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Specimens controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/traits.css.scss b/apps/workbench/app/assets/stylesheets/traits.css.scss
deleted file mode 100644 (file)
index 7d2f713..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Traits controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/user_agreements.css.scss b/apps/workbench/app/assets/stylesheets/user_agreements.css.scss
deleted file mode 100644 (file)
index d9eb5eb..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the user_agreements controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/users.css.scss b/apps/workbench/app/assets/stylesheets/users.css.scss
deleted file mode 100644 (file)
index a087ca3..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the Users controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/virtual_machines.css.scss b/apps/workbench/app/assets/stylesheets/virtual_machines.css.scss
deleted file mode 100644 (file)
index 4a94d45..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Place all the styles related to the VirtualMachines controller here.
-// They will automatically be included in application.css.
-// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/controllers/actions_controller.rb b/apps/workbench/app/controllers/actions_controller.rb
deleted file mode 100644 (file)
index 7b8c8ea..0000000
+++ /dev/null
@@ -1,257 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require "arvados/collection"
-require "app_version"
-
-class ActionsController < ApplicationController
-
-  # Skip require_thread_api_token if this is a show action
-  # for an object uuid that supports anonymous access.
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    'show' == ctrl.action_name and
-    params['uuid'] and
-    model_class.in?([Collection, Group, Job, PipelineInstance, PipelineTemplate])
-  }
-  skip_around_action :require_thread_api_token, only: [:report_issue_popup, :report_issue]
-  skip_before_action :check_user_agreements, only: [:report_issue_popup, :report_issue]
-
-  @@exposed_actions = {}
-  def self.expose_action method, &block
-    @@exposed_actions[method] = true
-    define_method method, block
-  end
-
-  def model_class
-    ArvadosBase::resource_class_for_uuid(params[:uuid])
-  end
-
-  def show
-    @object = model_class.andand.find(params[:uuid])
-    if @object.is_a? Link and
-        @object.link_class == 'name' and
-        ArvadosBase::resource_class_for_uuid(@object.head_uuid) == Collection
-      redirect_to collection_path(id: @object.uuid)
-    elsif @object.is_a?(Group) and (@object.group_class == 'project' or @object.group_class == 'filter')
-      redirect_to project_path(id: @object.uuid)
-    elsif @object
-      redirect_to @object
-    else
-      raise ActiveRecord::RecordNotFound
-    end
-  end
-
-  def post
-    params.keys.collect(&:to_sym).each do |param|
-      if @@exposed_actions[param]
-        return self.send(param)
-      end
-    end
-    redirect_back(fallback_location: root_path)
-  end
-
-  expose_action :copy_selections_into_project do
-    move_or_copy :copy
-  end
-
-  expose_action :move_selections_into_project do
-    move_or_copy :move
-  end
-
-  def move_or_copy action
-    uuids_to_add = params["selection"]
-    uuids_to_add = [ uuids_to_add ] unless uuids_to_add.is_a? Array
-    resource_classes = uuids_to_add.
-      collect { |x| ArvadosBase::resource_class_for_uuid(x) }.
-      uniq
-    resource_classes.each do |resource_class|
-      resource_class.filter([['uuid','in',uuids_to_add]]).each do |src|
-        if resource_class == Collection and not Collection.attribute_info.include?(:name)
-          dst = Link.new(owner_uuid: @object.uuid,
-                         tail_uuid: @object.uuid,
-                         head_uuid: src.uuid,
-                         link_class: 'name',
-                         name: src.uuid)
-        else
-          case action
-          when :copy
-            dst = src.dup
-            if dst.respond_to? :'name='
-              if dst.name
-                dst.name = "Copy of #{dst.name}"
-              else
-                dst.name = "Copy of unnamed #{dst.class_for_display.downcase}"
-              end
-            end
-            if resource_class == Collection
-              dst.manifest_text = Collection.select([:manifest_text]).where(uuid: src.uuid).with_count("none").first.manifest_text
-              # Fixes bug 19144: nullify some fields that are managed by keep-balance.
-              dst.storage_classes_confirmed = []
-              dst.storage_classes_confirmed_at = nil
-            end
-          when :move
-            dst = src
-          else
-            raise ArgumentError.new "Unsupported action #{action}"
-          end
-          dst.owner_uuid = @object.uuid
-          dst.tail_uuid = @object.uuid if dst.class == Link
-        end
-        begin
-          dst.save!
-        rescue
-          dst.name += " (#{Time.now.localtime})" if dst.respond_to? :name=
-          dst.save!
-        end
-      end
-    end
-    if (resource_classes == [Collection] and
-        @object.is_a? Group and
-        @object.group_class == 'project') or
-        @object.is_a? User
-      # In the common case where only collections are copied/moved
-      # into a project, it's polite to land on the collections tab on
-      # the destination project.
-      redirect_to project_url(@object.uuid, anchor: 'Data_collections')
-    else
-      # Otherwise just land on the default (Description) tab.
-      redirect_to @object
-    end
-  end
-
-  expose_action :combine_selected_files_into_collection do
-    uuids, source_paths = selected_collection_files params
-
-    new_coll = Arv::Collection.new
-    Collection.where(uuid: uuids.uniq).with_count("none").
-        select([:uuid, :manifest_text]).each do |coll|
-      src_coll = Arv::Collection.new(coll.manifest_text)
-      src_pathlist = source_paths[coll.uuid]
-      if src_pathlist.any?(&:blank?)
-        src_pathlist = src_coll.each_file_path
-        destdir = nil
-      else
-        destdir = "."
-      end
-      src_pathlist.each do |src_path|
-        src_path = src_path.sub(/^(\.\/|\/|)/, "./")
-        src_stream, _, basename = src_path.rpartition("/")
-        dst_stream = destdir || src_stream
-        # Generate a unique name by adding (1), (2), etc. to it.
-        # If the filename has a dot that's not at the beginning, insert the
-        # number just before that.  Otherwise, append the number to the name.
-        if match = basename.match(/[^\.]\./)
-          suffix_start = match.begin(0) + 1
-        else
-          suffix_start = basename.size
-        end
-        suffix_size = 0
-        dst_path = nil
-        loop.each_with_index do |_, try_count|
-          dst_path = "#{dst_stream}/#{basename}"
-          break unless new_coll.exist?(dst_path)
-          uniq_suffix = "(#{try_count + 1})"
-          basename[suffix_start, suffix_size] = uniq_suffix
-          suffix_size = uniq_suffix.size
-        end
-        new_coll.cp_r(src_path, dst_path, src_coll)
-      end
-    end
-
-    coll_attrs = {
-      manifest_text: new_coll.manifest_text,
-      name: "Collection created at #{Time.now.localtime}",
-    }
-    flash = {}
-
-    # set owner_uuid to current project, provided it is writable
-    action_data = Oj.safe_load(params['action_data'] || "{}")
-    if action_data['current_project_uuid'] and
-        current_project = Group.find?(action_data['current_project_uuid']) and
-        current_project.writable_by.andand.include?(current_user.uuid)
-      coll_attrs[:owner_uuid] = current_project.uuid
-      flash[:message] =
-        "Created new collection in the project #{current_project.name}."
-    else
-      flash[:message] = "Created new collection in your Home project."
-    end
-
-    newc = Collection.create!(coll_attrs)
-    source_paths.each_key do |src_uuid|
-      unless Link.create({
-                           tail_uuid: src_uuid,
-                           head_uuid: newc.uuid,
-                           link_class: "provenance",
-                           name: "provided",
-                         })
-        flash[:error] = "
-An error occurred when saving provenance information for this collection.
-You can try recreating the collection to get a copy with full provenance data."
-        break
-      end
-    end
-    redirect_to(newc, flash: flash)
-  end
-
-  def report_issue_popup
-    respond_to do |format|
-      format.js
-      format.html
-    end
-  end
-
-  def report_issue
-    logger.warn "report_issue: #{params.inspect}"
-
-    respond_to do |format|
-      IssueReporter.send_report(current_user, params).deliver
-      format.js {render body: nil}
-    end
-  end
-
-  # star / unstar the current project
-  def star
-    links = Link.where(owner_uuid: current_user.uuid,
-                       head_uuid: @object.uuid,
-                       link_class: 'star')
-
-    if params['status'] == 'create'
-      # create 'star' link if one does not already exist
-      if !links.andand.any?
-        dst = Link.new(owner_uuid: current_user.uuid,
-                       tail_uuid: current_user.uuid,
-                       head_uuid: @object.uuid,
-                       link_class: 'star',
-                       name: @object.uuid)
-        dst.save!
-      end
-    else # delete any existing 'star' links
-      if links.andand.any?
-        links.each do |link|
-          link.destroy
-        end
-      end
-    end
-
-    respond_to do |format|
-      format.js
-    end
-  end
-
-  protected
-
-  def derive_unique_filename filename, manifest_files
-    filename_parts = filename.split('.')
-    filename_part = filename_parts[0]
-    counter = 1
-    while true
-      return filename if !manifest_files.include? filename
-      filename_parts[0] = filename_part + "(" + counter.to_s + ")"
-      filename = filename_parts.join('.')
-      counter += 1
-    end
-  end
-
-end
diff --git a/apps/workbench/app/controllers/api_client_authorizations_controller.rb b/apps/workbench/app/controllers/api_client_authorizations_controller.rb
deleted file mode 100644 (file)
index c7ff560..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ApiClientAuthorizationsController < ApplicationController
-
-  def index_pane_list
-    %w(Recent Help)
-  end
-
-end
diff --git a/apps/workbench/app/controllers/application_controller.rb b/apps/workbench/app/controllers/application_controller.rb
deleted file mode 100644 (file)
index c2636bf..0000000
+++ /dev/null
@@ -1,1341 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ApplicationController < ActionController::Base
-  include ArvadosApiClientHelper
-  include ApplicationHelper
-
-  respond_to :html, :json, :js
-  protect_from_forgery
-
-  ERROR_ACTIONS = [:render_error, :render_not_found]
-
-  around_action :thread_clear
-  around_action :set_current_request_id
-  around_action :set_thread_api_token
-  # Methods that don't require login should
-  #   skip_around_action :require_thread_api_token
-  around_action :require_thread_api_token, except: ERROR_ACTIONS
-  before_action :ensure_arvados_api_exists, only: [:index, :show]
-  before_action :set_cache_buster
-  before_action :accept_uuid_as_id_param, except: ERROR_ACTIONS
-  before_action :check_user_agreements, except: ERROR_ACTIONS
-  before_action :check_user_profile, except: ERROR_ACTIONS
-  before_action :load_filters_and_paging_params, except: ERROR_ACTIONS
-  before_action :find_object_by_uuid, except: [:create, :index, :choose] + ERROR_ACTIONS
-  theme :select_theme
-
-  begin
-    rescue_from(ActiveRecord::RecordNotFound,
-                ActionController::RoutingError,
-                AbstractController::ActionNotFound,
-                with: :render_not_found)
-    rescue_from(Exception,
-                ActionController::UrlGenerationError,
-                with: :render_exception)
-  end
-
-  def set_cache_buster
-    response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate"
-    response.headers["Pragma"] = "no-cache"
-    response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
-  end
-
-  def unprocessable(message=nil)
-    @errors ||= []
-
-    @errors << message if message
-    render_error status: 422
-  end
-
-  def render_error(opts={})
-    # Helpers can rely on the presence of @errors to know they're
-    # being used in an error page.
-    @errors ||= []
-    opts[:status] ||= 500
-    respond_to do |f|
-      # json must come before html here, so it gets used as the
-      # default format when js is requested by the client. This lets
-      # ajax:error callback parse the response correctly, even though
-      # the browser can't.
-      f.json { render opts.merge(json: {success: false, errors: @errors}) }
-      f.html { render({action: 'error'}.merge(opts)) }
-      f.all { render({action: 'error', formats: 'text'}.merge(opts)) }
-    end
-  end
-
-  def render_exception(e)
-    logger.error e.inspect
-    logger.error e.backtrace.collect { |x| x + "\n" }.join('') if e.backtrace
-    err_opts = {status: 422}
-    if e.is_a?(ArvadosApiClient::ApiError)
-      err_opts.merge!(action: 'api_error', locals: {api_error: e})
-      @errors = e.api_response[:errors]
-    elsif @object.andand.errors.andand.full_messages.andand.any?
-      @errors = @object.errors.full_messages
-    else
-      @errors = [e.to_s]
-    end
-    # Make user information available on the error page, falling back to the
-    # session cache if the API server is unavailable.
-    begin
-      load_api_token(session[:arvados_api_token])
-    rescue ArvadosApiClient::ApiError
-      unless session[:user].nil?
-        begin
-          Thread.current[:user] = User.new(session[:user])
-        rescue ArvadosApiClient::ApiError
-          # This can happen if User's columns are unavailable.  Nothing to do.
-        end
-      end
-    end
-    # Preload projects trees for the template.  If that's not doable, set empty
-    # trees so error page rendering can proceed.  (It's easier to rescue the
-    # exception here than in a template.)
-    unless current_user.nil?
-      begin
-        my_starred_projects current_user, 'project'
-        build_my_wanted_projects_tree current_user
-      rescue ArvadosApiClient::ApiError
-        # Fall back to the default-setting code later.
-      end
-    end
-    @starred_projects ||= []
-    @my_wanted_projects_tree ||= []
-    render_error(err_opts)
-  end
-
-  def render_not_found(e=ActionController::RoutingError.new("Path not found"))
-    logger.error e.inspect
-    @errors = ["Path not found"]
-    set_thread_api_token do
-      self.render_error(action: '404', status: 404)
-    end
-  end
-
-  # params[:order]:
-  #
-  # The order can be left empty to allow it to default.
-  # Or it can be a comma separated list of real database column names, one per model.
-  # Column names should always be qualified by a table name and a direction is optional, defaulting to asc
-  # (e.g. "collections.name" or "collections.name desc").
-  # If a column name is specified, that table will be sorted by that column.
-  # If there are objects from different models that will be shown (such as in Pipelines and processes tab),
-  # then a sort column name can optionally be specified for each model, passed as an comma-separated list (e.g. "jobs.script, pipeline_instances.name")
-  # Currently only one sort column name and direction can be specified for each model.
-  def load_filters_and_paging_params
-    if params[:order].blank?
-      @order = 'created_at desc'
-    elsif params[:order].is_a? Array
-      @order = params[:order]
-    else
-      begin
-        @order = JSON.load(params[:order])
-      rescue
-        @order = params[:order].split(',')
-      end
-    end
-    @order = [@order] unless @order.is_a? Array
-
-    @limit ||= 200
-    if params[:limit]
-      @limit = params[:limit].to_i
-    end
-
-    @offset ||= 0
-    if params[:offset]
-      @offset = params[:offset].to_i
-    end
-
-    @filters ||= []
-    if params[:filters]
-      filters = params[:filters]
-      if filters.is_a? String
-        filters = Oj.safe_load filters
-      elsif filters.is_a? Array
-        filters = filters.collect do |filter|
-          if filter.is_a? String
-            # Accept filters[]=["foo","=","bar"]
-            Oj.safe_load filter
-          else
-            # Accept filters=[["foo","=","bar"]]
-            filter
-          end
-        end
-      end
-      # After this, params[:filters] can be trusted to be an array of arrays:
-      params[:filters] = filters
-      @filters += filters
-    end
-  end
-
-  def find_objects_for_index
-    @objects ||= model_class
-    @objects = @objects.filter(@filters).limit(@limit).offset(@offset).order(@order)
-    @objects.fetch_multiple_pages(false)
-  end
-
-  def render_index
-    respond_to do |f|
-      f.json {
-        if params[:partial]
-          @next_page_href = next_page_href(partial: params[:partial], filters: @filters.to_json)
-          render json: {
-            content: render_to_string(partial: "show_#{params[:partial]}",
-                                      formats: [:html]),
-            next_page_href: @next_page_href
-          }
-        else
-          render json: @objects
-        end
-      }
-      f.html {
-        if params[:tab_pane]
-          render_pane params[:tab_pane]
-        else
-          render
-        end
-      }
-      f.js { render }
-    end
-  end
-
-  helper_method :render_pane
-  def render_pane tab_pane, opts={}
-    render_opts = {
-      partial: 'show_' + tab_pane.downcase,
-      locals: {
-        comparable: self.respond_to?(:compare),
-        objects: @objects,
-        tab_pane: tab_pane
-      }.merge(opts[:locals] || {})
-    }
-    if opts[:to_string]
-      render_to_string render_opts
-    else
-      render render_opts
-    end
-  end
-
-  def ensure_arvados_api_exists
-    if model_class.is_a?(Class) && model_class < ArvadosBase && !model_class.api_exists?(params['action'].to_sym)
-      @errors = ["#{params['action']} method is not supported for #{params['controller']}"]
-      return render_error(status: 404)
-    end
-  end
-
-  def index
-    @objects = nil if !defined?(@objects)
-    find_objects_for_index if !@objects
-    render_index
-  end
-
-  helper_method :next_page_offset
-  def next_page_offset objects=nil
-    if !objects
-      objects = @objects
-    end
-    if objects.respond_to?(:result_offset) and
-        objects.respond_to?(:result_limit)
-      next_offset = objects.result_offset + objects.result_limit
-      if objects.respond_to?(:items_available) and (objects.items_available != nil) and (next_offset < objects.items_available)
-        next_offset
-      elsif @objects.results.size > 0 and (params[:count] == 'none' or
-           (params[:controller] == 'search' and params[:action] == 'choose'))
-        last_object_class = @objects.last.class
-        if params['last_object_class'].nil? or params['last_object_class'] == last_object_class.to_s
-          next_offset
-        else
-          @objects.select{|obj| obj.class == last_object_class}.size
-        end
-      else
-        nil
-      end
-    end
-  end
-
-  helper_method :next_page_href
-  def next_page_href with_params={}
-    if next_page_offset
-      url_for with_params.merge(offset: next_page_offset)
-    end
-  end
-
-  helper_method :next_page_filters
-  def next_page_filters nextpage_operator
-    next_page_filters = @filters.reject do |attr, op, val|
-      (attr == 'created_at' and op == nextpage_operator) or
-      (attr == 'uuid' and op == 'not in')
-    end
-
-    if @objects.any?
-      last_created_at = @objects.last.created_at
-
-      last_uuids = []
-      @objects.each do |obj|
-        last_uuids << obj.uuid if obj.created_at.eql?(last_created_at)
-      end
-
-      next_page_filters += [['created_at', nextpage_operator, last_created_at]]
-      next_page_filters += [['uuid', 'not in', last_uuids]]
-    end
-
-    next_page_filters
-  end
-
-  def show
-    if !@object
-      return render_not_found("object not found")
-    end
-    respond_to do |f|
-      f.json do
-        extra_attrs = { href: url_for(action: :show, id: @object) }
-        @object.textile_attributes.each do |textile_attr|
-          extra_attrs.merge!({ "#{textile_attr}Textile" => view_context.render_markup(@object.attributes[textile_attr]) })
-        end
-        render json: @object.attributes.merge(extra_attrs)
-      end
-      f.html {
-        if params['tab_pane']
-          render_pane(if params['tab_pane'].is_a? Hash then params['tab_pane']["name"] else params['tab_pane'] end)
-        elsif request.request_method.in? ['GET', 'HEAD']
-          render
-        else
-          redirect_to (params[:return_to] ||
-                       polymorphic_url(@object,
-                                       anchor: params[:redirect_to_anchor]))
-        end
-      }
-      f.js { render }
-    end
-  end
-
-  def redirect_to uri, *args
-    if request.xhr?
-      if not uri.is_a? String
-        uri = polymorphic_url(uri)
-      end
-      render json: {href: uri}
-    else
-      super
-    end
-  end
-
-  def choose
-    @objects = nil if !defined?(@objects)
-    params[:limit] ||= 40
-    respond_to do |f|
-      if params[:partial]
-        f.json {
-          find_objects_for_index if !@objects
-          render json: {
-            content: render_to_string(partial: "choose_rows.html",
-                                      formats: [:html]),
-            next_page_href: next_page_href(partial: params[:partial])
-          }
-        }
-      end
-      f.js {
-        find_objects_for_index if !@objects
-        render partial: 'choose', locals: {multiple: params[:multiple]}
-      }
-    end
-  end
-
-  def render_content
-    if !@object
-      return render_not_found("object not found")
-    end
-  end
-
-  def new
-    @object = model_class.new
-  end
-
-  def update
-    @updates ||= params[@object.resource_param_name.to_sym]
-    if @updates.is_a? ActionController::Parameters
-      @updates = @updates.to_unsafe_hash
-    end
-    @updates.keys.each do |attr|
-      if @object.send(attr).is_a? Hash
-        if @updates[attr].is_a? String
-          @updates[attr] = Oj.safe_load @updates[attr]
-        end
-        if params[:merge] || params["merge_#{attr}".to_sym]
-          # Merge provided Hash with current Hash, instead of
-          # replacing.
-          if @updates[attr].is_a? ActionController::Parameters
-            @updates[attr] = @updates[attr].to_unsafe_hash
-          end
-          @updates[attr] = @object.send(attr).with_indifferent_access.
-            deep_merge(@updates[attr].with_indifferent_access)
-        end
-      end
-    end
-    if @object.update_attributes @updates
-      show
-    else
-      self.render_error status: 422
-    end
-  end
-
-  def create
-    @new_resource_attrs ||= params[model_class.to_s.underscore.singularize]
-    @new_resource_attrs ||= {}
-    @new_resource_attrs.reject! { |k,v| k.to_s == 'uuid' }
-    @object ||= model_class.new @new_resource_attrs, params["options"]
-
-    if @object.save
-      show
-    else
-      render_error status: 422
-    end
-  end
-
-  # Clone the given object, merging any attribute values supplied as
-  # with a create action.
-  def copy
-    @new_resource_attrs ||= params[model_class.to_s.underscore.singularize]
-    @new_resource_attrs ||= {}
-    @object = @object.dup
-    @object.update_attributes @new_resource_attrs
-    if not @new_resource_attrs[:name] and @object.respond_to? :name
-      if @object.name and @object.name != ''
-        @object.name = "Copy of #{@object.name}"
-      else
-        @object.name = ""
-      end
-    end
-    @object.save!
-    show
-  end
-
-  def destroy
-    if @object.destroy
-      respond_to do |f|
-        f.json { render json: @object }
-        f.html {
-          if params[:return_to]
-            redirect_to(params[:return_to])
-          else
-            redirect_back(fallback_location: root_path)
-          end
-        }
-        f.js { render }
-      end
-    else
-      self.render_error status: 422
-    end
-  end
-
-  def current_user
-    Thread.current[:user]
-  end
-
-  def model_class
-    controller_name.classify.constantize
-  end
-
-  def breadcrumb_page_name
-    (@breadcrumb_page_name ||
-     (@object.friendly_link_name if @object.respond_to? :friendly_link_name) ||
-     action_name)
-  end
-
-  def index_pane_list
-    %w(Recent)
-  end
-
-  def show_pane_list
-    %w(Attributes Advanced)
-  end
-
-  def set_share_links
-    @user_is_manager = false
-    @share_links = []
-
-    if @object.uuid != current_user.andand.uuid
-      begin
-        @share_links = Link.permissions_for(@object)
-        @user_is_manager = true
-      rescue ArvadosApiClient::AccessForbiddenException,
-        ArvadosApiClient::NotFoundException
-      end
-    end
-  end
-
-  def share_with
-    if not params[:uuids].andand.any?
-      @errors = ["No user/group UUIDs specified to share with."]
-      return render_error(status: 422)
-    end
-    results = {"success" => [], "errors" => []}
-    params[:uuids].each do |shared_uuid|
-      begin
-        Link.create(tail_uuid: shared_uuid, link_class: "permission",
-                    name: "can_read", head_uuid: @object.uuid)
-      rescue ArvadosApiClient::ApiError => error
-        error_list = error.api_response.andand[:errors]
-        if error_list.andand.any?
-          results["errors"] += error_list.map { |e| "#{shared_uuid}: #{e}" }
-        else
-          error_code = error.api_status || "Bad status"
-          results["errors"] << "#{shared_uuid}: #{error_code} response"
-        end
-      else
-        results["success"] << shared_uuid
-      end
-    end
-    if results["errors"].empty?
-      results.delete("errors")
-      status = 200
-    else
-      status = 422
-    end
-    respond_to do |f|
-      f.json { render(json: results, status: status) }
-    end
-  end
-
-  helper_method :is_starred
-  def is_starred
-    links = Link.where(tail_uuid: current_user.uuid,
-               head_uuid: @object.uuid,
-               link_class: 'star').with_count("none")
-
-    return links.andand.any?
-  end
-
-  protected
-
-  helper_method :strip_token_from_path
-  def strip_token_from_path(path)
-    path.sub(/([\?&;])api_token=[^&;]*[&;]?/, '\1')
-  end
-
-  def redirect_to_login
-    if request.xhr? or request.format.json?
-      @errors = ['You are not logged in. Most likely your session has timed out and you need to log in again.']
-      render_error status: 401
-    elsif request.method.in? ['GET', 'HEAD']
-      redirect_to arvados_api_client.arvados_login_url(return_to: strip_token_from_path(request.url))
-    else
-      flash[:error] = "Either you are not logged in, or your session has timed out. I can't automatically log you in and re-attempt this request."
-      redirect_back(fallback_location: root_path)
-    end
-    false  # For convenience to return from callbacks
-  end
-
-  def using_specific_api_token(api_token, opts={})
-    start_values = {}
-    [:arvados_api_token, :user].each do |key|
-      start_values[key] = Thread.current[key]
-    end
-    if opts.fetch(:load_user, true)
-      load_api_token(api_token)
-    else
-      Thread.current[:arvados_api_token] = api_token
-      Thread.current[:user] = nil
-    end
-    begin
-      yield
-    ensure
-      start_values.each_key { |key| Thread.current[key] = start_values[key] }
-    end
-  end
-
-
-  def accept_uuid_as_id_param
-    if params[:id] and params[:id].match(/\D/)
-      params[:uuid] = params.delete :id
-    end
-  end
-
-  def find_object_by_uuid
-    begin
-      if not model_class
-        @object = nil
-      elsif params[:uuid].nil? or params[:uuid].empty?
-        @object = nil
-      elsif not params[:uuid].is_a?(String)
-        @object = model_class.where(uuid: params[:uuid]).first
-      elsif (model_class != Link and
-             resource_class_for_uuid(params[:uuid]) == Link)
-        @name_link = Link.find(params[:uuid])
-        @object = model_class.find(@name_link.head_uuid)
-      else
-        @object = model_class.find(params[:uuid])
-        load_preloaded_objects [@object]
-      end
-    rescue ArvadosApiClient::NotFoundException, ArvadosApiClient::NotLoggedInException, RuntimeError => error
-      if error.is_a?(RuntimeError) and (error.message !~ /^argument to find\(/)
-        raise
-      end
-      render_not_found(error)
-      return false
-    end
-  end
-
-  def thread_clear
-    load_api_token(nil)
-    Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
-    yield
-    Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
-  end
-
-  def set_current_request_id
-    response.headers['X-Request-Id'] =
-      Thread.current[:request_id] =
-      "req-" + Random::DEFAULT.rand(2**128).to_s(36)[0..19]
-    yield
-    Thread.current[:request_id] = nil
-  end
-
-  def append_info_to_payload(payload)
-    super
-    payload[:request_id] = response.headers['X-Request-Id']
-  end
-
-  # Set up the thread with the given API token and associated user object.
-  def load_api_token(new_token)
-    Thread.current[:arvados_api_token] = new_token
-    if new_token.nil?
-      Thread.current[:user] = nil
-    else
-      Thread.current[:user] = User.current
-    end
-  end
-
-  # If there's a valid api_token parameter, set up the session with that
-  # user's information.  Return true if the method redirects the request
-  # (usually a post-login redirect); false otherwise.
-  def setup_user_session
-    return false unless params[:api_token]
-    Thread.current[:arvados_api_token] = params[:api_token]
-    begin
-      user = User.current
-    rescue ArvadosApiClient::NotLoggedInException
-      false  # We may redirect to login, or not, based on the current action.
-    else
-      session[:arvados_api_token] = params[:api_token]
-      # If we later have trouble contacting the API server, we still want
-      # to be able to render basic user information in the UI--see
-      # render_exception above.  We store that in the session here.  This is
-      # not intended to be used as a general-purpose cache.  See #2891.
-      session[:user] = {
-        uuid: user.uuid,
-        email: user.email,
-        first_name: user.first_name,
-        last_name: user.last_name,
-        is_active: user.is_active,
-        is_admin: user.is_admin,
-        prefs: user.prefs
-      }
-
-      if !request.format.json? and request.method.in? ['GET', 'HEAD']
-        # Repeat this request with api_token in the (new) session
-        # cookie instead of the query string.  This prevents API
-        # tokens from appearing in (and being inadvisedly copied
-        # and pasted from) browser Location bars.
-        redirect_to strip_token_from_path(request.fullpath)
-        true
-      else
-        false
-      end
-    ensure
-      Thread.current[:arvados_api_token] = nil
-    end
-  end
-
-  # Save the session API token in thread-local storage, and yield.
-  # This method also takes care of session setup if the request
-  # provides a valid api_token parameter.
-  # If a token is unavailable or expired, the block is still run, with
-  # a nil token.
-  def set_thread_api_token
-    if Thread.current[:arvados_api_token]
-      yield   # An API token has already been found - pass it through.
-      return
-    elsif setup_user_session
-      return  # A new session was set up and received a response.
-    end
-
-    begin
-      load_api_token(session[:arvados_api_token])
-      yield
-    rescue ArvadosApiClient::NotLoggedInException
-      # If we got this error with a token, it must've expired.
-      # Retry the request without a token.
-      unless Thread.current[:arvados_api_token].nil?
-        load_api_token(nil)
-        yield
-      end
-    ensure
-      # Remove token in case this Thread is used for anything else.
-      load_api_token(nil)
-    end
-  end
-
-  # Redirect to login/welcome if client provided expired API token (or
-  # none at all)
-  def require_thread_api_token
-    if Thread.current[:arvados_api_token]
-      yield
-    elsif session[:arvados_api_token]
-      # Expired session. Clear it before refreshing login so that,
-      # if this login procedure fails, we end up showing the "please
-      # log in" page instead of getting stuck in a redirect loop.
-      session.delete :arvados_api_token
-      redirect_to_login
-    elsif request.xhr?
-      # If we redirect to the welcome page, the browser will handle
-      # the 302 by itself and the client code will end up rendering
-      # the "welcome" page in some content area where it doesn't make
-      # sense. Instead, we send 401 ("authenticate and try again" or
-      # "display error", depending on how smart the client side is).
-      @errors = ['You are not logged in.']
-      render_error status: 401
-    else
-      redirect_to welcome_users_path(return_to: request.fullpath)
-    end
-  end
-
-  def ensure_current_user_is_admin
-    if not current_user
-      @errors = ['Not logged in']
-      render_error status: 401
-    elsif not current_user.is_admin
-      @errors = ['Permission denied']
-      render_error status: 403
-    end
-  end
-
-  helper_method :unsigned_user_agreements
-  def unsigned_user_agreements
-    @signed_ua_uuids ||= UserAgreement.signatures.map &:head_uuid
-    @unsigned_user_agreements ||= UserAgreement.all.map do |ua|
-      if not @signed_ua_uuids.index ua.uuid
-        Collection.find(ua.uuid)
-      end
-    end.compact
-  end
-
-  def check_user_agreements
-    if current_user && !current_user.is_active
-      if not current_user.is_invited
-        return redirect_to inactive_users_path(return_to: request.fullpath)
-      end
-      if unsigned_user_agreements.empty?
-        # No agreements to sign. Perhaps we just need to ask?
-        current_user.activate
-        if !current_user.is_active
-          logger.warn "#{current_user.uuid.inspect}: " +
-            "No user agreements to sign, but activate failed!"
-        end
-      end
-      if !current_user.is_active
-        redirect_to user_agreements_path(return_to: request.fullpath)
-      end
-    end
-    true
-  end
-
-  def check_user_profile
-    return true if !current_user
-    if request.method.downcase != 'get' || params[:partial] ||
-       params[:tab_pane] || params[:action_method] ||
-       params[:action] == 'setup_popup'
-      return true
-    end
-
-    if missing_required_profile?
-      redirect_to profile_user_path(current_user.uuid, return_to: request.fullpath)
-    end
-    true
-  end
-
-  helper_method :missing_required_profile?
-  def missing_required_profile?
-    missing_required = false
-
-    profile_config = Rails.configuration.Workbench.UserProfileFormFields
-    if current_user && !profile_config.empty?
-      current_user_profile = current_user.prefs[:profile]
-      profile_config.each do |k, entry|
-        if entry[:Required]
-          if !current_user_profile ||
-             !current_user_profile[k] ||
-             current_user_profile[k].empty?
-            missing_required = true
-            break
-          end
-        end
-      end
-    end
-
-    missing_required
-  end
-
-  def select_theme
-    return Rails.configuration.Workbench.Theme
-  end
-
-  @@notification_tests = []
-
-  @@notification_tests.push lambda { |controller, current_user|
-    return nil if Rails.configuration.Services.WebShell.ExternalURL != URI("")
-    AuthorizedKey.limit(1).with_count('none').where(authorized_user_uuid: current_user.uuid).each do
-      return nil
-    end
-    return lambda { |view|
-      view.render partial: 'notifications/ssh_key_notification'
-    }
-  }
-
-  @@notification_tests.push lambda { |controller, current_user|
-    Collection.limit(1).with_count('none').where(created_by: current_user.uuid).each do
-      return nil
-    end
-    return lambda { |view|
-      view.render partial: 'notifications/collections_notification'
-    }
-  }
-
-  @@notification_tests.push lambda { |controller, current_user|
-    if PipelineInstance.api_exists?(:index)
-      PipelineInstance.limit(1).with_count('none').where(created_by: current_user.uuid).each do
-        return nil
-      end
-    else
-      return nil
-    end
-    return lambda { |view|
-      view.render partial: 'notifications/pipelines_notification'
-    }
-  }
-
-  helper_method :user_notifications
-  def user_notifications
-    @errors = nil if !defined?(@errors)
-    return [] if @errors or not current_user.andand.is_active or not Rails.configuration.Workbench.ShowUserNotifications
-    @notifications ||= @@notification_tests.map do |t|
-      t.call(self, current_user)
-    end.compact
-  end
-
-  helper_method :all_projects
-  def all_projects
-    @all_projects ||= Group.
-      filter([['group_class','IN',['project','filter']]]).order('name')
-  end
-
-  helper_method :my_projects
-  def my_projects
-    return @my_projects if @my_projects
-    @my_projects = []
-    root_of = {}
-    all_projects.each do |g|
-      root_of[g.uuid] = g.owner_uuid
-      @my_projects << g
-    end
-    done = false
-    while not done
-      done = true
-      root_of = root_of.each_with_object({}) do |(child, parent), h|
-        if root_of[parent]
-          h[child] = root_of[parent]
-          done = false
-        else
-          h[child] = parent
-        end
-      end
-    end
-    @my_projects = @my_projects.select do |g|
-      root_of[g.uuid] == current_user.uuid
-    end
-  end
-
-  helper_method :projects_shared_with_me
-  def projects_shared_with_me
-    my_project_uuids = my_projects.collect &:uuid
-    all_projects.reject { |x| x.uuid.in? my_project_uuids }
-  end
-
-  helper_method :recent_jobs_and_pipelines
-  def recent_jobs_and_pipelines
-    (Job.limit(10) |
-     PipelineInstance.limit(10).with_count("none")).
-      sort_by do |x|
-      (x.finished_at || x.started_at rescue nil) || x.modified_at || x.created_at
-    end.reverse
-  end
-
-  helper_method :running_pipelines
-  def running_pipelines
-    pi = PipelineInstance.order(["started_at asc", "created_at asc"]).with_count("none").filter([["state", "in", ["RunningOnServer", "RunningOnClient"]]])
-    jobs = {}
-    pi.each do |pl|
-      pl.components.each do |k,v|
-        if v.is_a? Hash and v[:job]
-          jobs[v[:job][:uuid]] = {}
-        end
-      end
-    end
-
-    if jobs.keys.any?
-      Job.filter([["uuid", "in", jobs.keys]]).with_count("none").each do |j|
-        jobs[j[:uuid]] = j
-      end
-
-      pi.each do |pl|
-        pl.components.each do |k,v|
-          if v.is_a? Hash and v[:job]
-            v[:job] = jobs[v[:job][:uuid]]
-          end
-        end
-      end
-    end
-
-    pi
-  end
-
-  helper_method :recent_processes
-  def recent_processes lim
-    lim = 12 if lim.nil?
-
-    procs = {}
-    if PipelineInstance.api_exists?(:index)
-      cols = %w(uuid owner_uuid created_at modified_at pipeline_template_uuid name state started_at finished_at)
-      pipelines = PipelineInstance.select(cols).limit(lim).order(["created_at desc"]).with_count("none")
-      pipelines.results.each { |pi| procs[pi] = pi.created_at }
-    end
-
-    crs = ContainerRequest.limit(lim).with_count("none").order(["created_at desc"]).filter([["requesting_container_uuid", "=", nil]]).select(
-      ["uuid", "name", "container_uuid", "output_uuid", "state", "created_at", "modified_at"])
-    crs.results.each { |c| procs[c] = c.created_at }
-
-    Hash[procs.sort_by {|key, value| value}].keys.reverse.first(lim)
-  end
-
-  helper_method :recent_collections
-  def recent_collections lim
-    c = Collection.limit(lim).with_count("none").order(["modified_at desc"]).results
-    own = {}
-    Group.filter([["uuid", "in", c.map(&:owner_uuid)]]).with_count("none").each do |g|
-      own[g[:uuid]] = g
-    end
-    {collections: c, owners: own}
-  end
-
-  helper_method :my_starred_projects
-  def my_starred_projects user, group_class
-    return if defined?(@starred_projects) && @starred_projects
-    links = Link.filter([['owner_uuid', 'in', ["#{Rails.configuration.ClusterID}-j7d0g-publicfavorites", user.uuid]],
-                         ['link_class', '=', 'star'],
-                         ['head_uuid', 'is_a', 'arvados#group']]).with_count("none").select(%w(head_uuid))
-    uuids = links.collect { |x| x.head_uuid }
-    if group_class == ""
-      starred_projects = Group.filter([['uuid', 'in', uuids]]).order('name').with_count("none")
-    else
-      starred_projects = Group.filter([['uuid', 'in', uuids],['group_class', '=', group_class]]).order('name').with_count("none")
-    end
-    @starred_projects = starred_projects.results
-  end
-
-  # If there are more than 200 projects that are readable by the user,
-  # build the tree using only the top 200+ projects owned by the user,
-  # from the top three levels.
-  # That is: get toplevel projects under home, get subprojects of
-  # these projects, and so on until we hit the limit.
-  def my_wanted_projects(user, page_size=100)
-    return @my_wanted_projects if defined?(@my_wanted_projects) && @my_wanted_projects
-
-    from_top = []
-    uuids = [user.uuid]
-    depth = 0
-    @too_many_projects = false
-    @reached_level_limit = false
-    while from_top.size <= page_size*2
-      current_level = Group.filter([['group_class','IN',['project','filter']],
-                                    ['owner_uuid', 'in', uuids]])
-                      .order('name').limit(page_size*2)
-      break if current_level.results.size == 0
-      @too_many_projects = true if current_level.items_available > current_level.results.size
-      from_top.concat current_level.results
-      uuids = current_level.results.collect(&:uuid)
-      depth += 1
-      if depth >= 3
-        @reached_level_limit = true
-        break
-      end
-    end
-    @my_wanted_projects = from_top
-  end
-
-  helper_method :my_wanted_projects_tree
-  def my_wanted_projects_tree(user, page_size=100)
-    build_my_wanted_projects_tree(user, page_size)
-    [@my_wanted_projects_tree, @too_many_projects, @reached_level_limit]
-  end
-
-  def build_my_wanted_projects_tree(user, page_size=100)
-    return @my_wanted_projects_tree if defined?(@my_wanted_projects_tree) && @my_wanted_projects_tree
-
-    parent_of = {user.uuid => 'me'}
-    my_wanted_projects(user, page_size).each do |ob|
-      parent_of[ob.uuid] = ob.owner_uuid
-    end
-    children_of = {false => [], 'me' => [user]}
-    my_wanted_projects(user, page_size).each do |ob|
-      if ob.owner_uuid != user.uuid and
-          not parent_of.has_key? ob.owner_uuid
-        parent_of[ob.uuid] = false
-      end
-      children_of[parent_of[ob.uuid]] ||= []
-      children_of[parent_of[ob.uuid]] << ob
-    end
-    buildtree = lambda do |chldrn_of, root_uuid=false|
-      tree = {}
-      chldrn_of[root_uuid].andand.each do |ob|
-        tree[ob] = buildtree.call(chldrn_of, ob.uuid)
-      end
-      tree
-    end
-    sorted_paths = lambda do |tree, depth=0|
-      paths = []
-      tree.keys.sort_by { |ob|
-        ob.is_a?(String) ? ob : ob.friendly_link_name
-      }.each do |ob|
-        paths << {object: ob, depth: depth}
-        paths += sorted_paths.call tree[ob], depth+1
-      end
-      paths
-    end
-    @my_wanted_projects_tree =
-      sorted_paths.call buildtree.call(children_of, 'me')
-  end
-
-  helper_method :get_object
-  def get_object uuid
-    if @get_object.nil? and @objects
-      @get_object = @objects.each_with_object({}) do |object, h|
-        h[object.uuid] = object
-      end
-    end
-    @get_object ||= {}
-    @get_object[uuid]
-  end
-
-  helper_method :project_breadcrumbs
-  def project_breadcrumbs
-    crumbs = []
-    current = @name_link || @object
-    while current
-      # Halt if a group ownership loop is detected. API should refuse
-      # to produce this state, but it could still arise from a race
-      # condition when group ownership changes between our find()
-      # queries.
-      break if crumbs.collect(&:uuid).include? current.uuid
-
-      if current.is_a?(Group) and current.group_class == 'project'
-        crumbs.prepend current
-      end
-      if current.is_a? Link
-        current = Group.find?(current.tail_uuid)
-      else
-        current = Group.find?(current.owner_uuid)
-      end
-    end
-    crumbs
-  end
-
-  helper_method :current_project_uuid
-  def current_project_uuid
-    if @object.is_a? Group and @object.group_class == 'project'
-      @object.uuid
-    elsif @name_link.andand.tail_uuid
-      @name_link.tail_uuid
-    elsif @object and resource_class_for_uuid(@object.owner_uuid) == Group
-      @object.owner_uuid
-    else
-      nil
-    end
-  end
-
-  # helper method to get links for given object or uuid
-  helper_method :links_for_object
-  def links_for_object object_or_uuid
-    raise ArgumentError, 'No input argument' unless object_or_uuid
-    preload_links_for_objects([object_or_uuid])
-    uuid = object_or_uuid.is_a?(String) ? object_or_uuid : object_or_uuid.uuid
-    @all_links_for[uuid] ||= []
-  end
-
-  # helper method to preload links for given objects and uuids
-  helper_method :preload_links_for_objects
-  def preload_links_for_objects objects_and_uuids
-    @all_links_for ||= {}
-
-    raise ArgumentError, 'Argument is not an array' unless objects_and_uuids.is_a? Array
-    return @all_links_for if objects_and_uuids.empty?
-
-    uuids = objects_and_uuids.collect { |x| x.is_a?(String) ? x : x.uuid }
-
-    # if already preloaded for all of these uuids, return
-    if not uuids.select { |x| @all_links_for[x].nil? }.any?
-      return @all_links_for
-    end
-
-    uuids.each do |x|
-      @all_links_for[x] = []
-    end
-
-    # TODO: make sure we get every page of results from API server
-    Link.filter([['head_uuid', 'in', uuids]]).with_count("none").each do |link|
-      @all_links_for[link.head_uuid] << link
-    end
-    @all_links_for
-  end
-
-  # helper method to get a certain number of objects of a specific type
-  # this can be used to replace any uses of: "dataclass.limit(n)"
-  helper_method :get_n_objects_of_class
-  def get_n_objects_of_class dataclass, size
-    @objects_map_for ||= {}
-
-    raise ArgumentError, 'Argument is not a data class' unless dataclass.is_a? Class and dataclass < ArvadosBase
-    raise ArgumentError, 'Argument is not a valid limit size' unless (size && size>0)
-
-    # if the objects_map_for has a value for this dataclass, and the
-    # size used to retrieve those objects is equal, return it
-    size_key = "#{dataclass.name}_size"
-    if @objects_map_for[dataclass.name] && @objects_map_for[size_key] &&
-        (@objects_map_for[size_key] == size)
-      return @objects_map_for[dataclass.name]
-    end
-
-    @objects_map_for[size_key] = size
-    @objects_map_for[dataclass.name] = dataclass.limit(size)
-  end
-
-  # helper method to get collections for the given uuid
-  helper_method :collections_for_object
-  def collections_for_object uuid
-    raise ArgumentError, 'No input argument' unless uuid
-    preload_collections_for_objects([uuid])
-    @all_collections_for[uuid] ||= []
-  end
-
-  # helper method to preload collections for the given uuids
-  helper_method :preload_collections_for_objects
-  def preload_collections_for_objects uuids
-    @all_collections_for ||= {}
-
-    raise ArgumentError, 'Argument is not an array' unless uuids.is_a? Array
-    return @all_collections_for if uuids.empty?
-
-    # if already preloaded for all of these uuids, return
-    if not uuids.select { |x| @all_collections_for[x].nil? }.any?
-      return @all_collections_for
-    end
-
-    uuids.each do |x|
-      @all_collections_for[x] = []
-    end
-
-    # TODO: make sure we get every page of results from API server
-    Collection.where(uuid: uuids).with_count("none").each do |collection|
-      @all_collections_for[collection.uuid] << collection
-    end
-    @all_collections_for
-  end
-
-  # helper method to get log collections for the given log
-  helper_method :log_collections_for_object
-  def log_collections_for_object log
-    raise ArgumentError, 'No input argument' unless log
-
-    preload_log_collections_for_objects([log])
-
-    uuid = log
-    fixup = /([a-f0-9]{32}\+\d+)(\+?.*)/.match(log)
-    if fixup && fixup.size>1
-      uuid = fixup[1]
-    end
-
-    @all_log_collections_for[uuid] ||= []
-  end
-
-  # helper method to preload collections for the given uuids
-  helper_method :preload_log_collections_for_objects
-  def preload_log_collections_for_objects logs
-    @all_log_collections_for ||= {}
-
-    raise ArgumentError, 'Argument is not an array' unless logs.is_a? Array
-    return @all_log_collections_for if logs.empty?
-
-    uuids = []
-    logs.each do |log|
-      fixup = /([a-f0-9]{32}\+\d+)(\+?.*)/.match(log)
-      if fixup && fixup.size>1
-        uuids << fixup[1]
-      else
-        uuids << log
-      end
-    end
-
-    # if already preloaded for all of these uuids, return
-    if not uuids.select { |x| @all_log_collections_for[x].nil? }.any?
-      return @all_log_collections_for
-    end
-
-    uuids.each do |x|
-      @all_log_collections_for[x] = []
-    end
-
-    # TODO: make sure we get every page of results from API server
-    Collection.where(uuid: uuids).with_count("none").each do |collection|
-      @all_log_collections_for[collection.uuid] << collection
-    end
-    @all_log_collections_for
-  end
-
-  # Helper method to get one collection for the given portable_data_hash
-  # This is used to determine if a pdh is readable by the current_user
-  helper_method :collection_for_pdh
-  def collection_for_pdh pdh
-    raise ArgumentError, 'No input argument' unless pdh
-    preload_for_pdhs([pdh])
-    @all_pdhs_for[pdh] ||= []
-  end
-
-  # Helper method to preload one collection each for the given pdhs
-  # This is used to determine if a pdh is readable by the current_user
-  helper_method :preload_for_pdhs
-  def preload_for_pdhs pdhs
-    @all_pdhs_for ||= {}
-
-    raise ArgumentError, 'Argument is not an array' unless pdhs.is_a? Array
-    return @all_pdhs_for if pdhs.empty?
-
-    # if already preloaded for all of these pdhs, return
-    if not pdhs.select { |x| @all_pdhs_for[x].nil? }.any?
-      return @all_pdhs_for
-    end
-
-    pdhs.each do |x|
-      @all_pdhs_for[x] = []
-    end
-
-    Collection.select(%w(portable_data_hash)).where(portable_data_hash: pdhs).distinct().with_count("none").each do |collection|
-      @all_pdhs_for[collection.portable_data_hash] << collection
-    end
-    @all_pdhs_for
-  end
-
-  # helper method to get object of a given dataclass and uuid
-  helper_method :object_for_dataclass
-  def object_for_dataclass dataclass, uuid, by_attr=nil
-    raise ArgumentError, 'No input argument dataclass' unless (dataclass && uuid)
-    preload_objects_for_dataclass(dataclass, [uuid], by_attr)
-    @objects_for[uuid]
-  end
-
-  # helper method to preload objects for given dataclass and uuids
-  helper_method :preload_objects_for_dataclass
-  def preload_objects_for_dataclass dataclass, uuids, by_attr=nil, select_fields=nil
-    @objects_for ||= {}
-
-    raise ArgumentError, 'Argument is not a data class' unless dataclass.is_a? Class
-    raise ArgumentError, 'Argument is not an array' unless uuids.is_a? Array
-
-    return @objects_for if uuids.empty?
-
-    # if already preloaded for all of these uuids, return
-    if not uuids.select { |x| !@objects_for.include?(x) }.any?
-      return @objects_for
-    end
-
-    # preset all uuids to nil
-    uuids.each do |x|
-      @objects_for[x] = nil
-    end
-    if by_attr and ![:uuid, :name].include?(by_attr)
-      raise ArgumentError, "Preloading only using lookups by uuid or name are supported: #{by_attr}"
-    elsif by_attr and by_attr == :name
-      dataclass.where(name: uuids).each do |obj|
-        @objects_for[obj.name] = obj
-      end
-    else
-      key_prefix = "request_#{Thread.current.object_id}_#{dataclass.to_s}_"
-      dataclass.where(uuid: uuids).select(select_fields).each do |obj|
-        @objects_for[obj.uuid] = obj
-        if dataclass == Collection
-          # The collecions#index defaults to "all attributes except manifest_text"
-          # Hence, this object is not suitable for preloading the find() cache.
-        else
-          Rails.cache.write(key_prefix + obj.uuid, obj.as_json)
-        end
-      end
-    end
-    @objects_for
-  end
-
-  # helper method to load objects that are already preloaded
-  helper_method :load_preloaded_objects
-  def load_preloaded_objects objs
-    @objects_for ||= {}
-    objs.each do |obj|
-      @objects_for[obj.uuid] = obj
-    end
-  end
-
-  # helper method to get the names of collection files selected
-  helper_method :selected_collection_files
-  def selected_collection_files params
-    link_uuids, coll_ids = params["selection"].partition do |sel_s|
-      ArvadosBase::resource_class_for_uuid(sel_s) == Link
-    end
-
-    unless link_uuids.empty?
-      Link.select([:head_uuid]).where(uuid: link_uuids).with_count("none").each do |link|
-        if ArvadosBase::resource_class_for_uuid(link.head_uuid) == Collection
-          coll_ids << link.head_uuid
-        end
-      end
-    end
-
-    uuids = []
-    pdhs = []
-    source_paths = Hash.new { |hash, key| hash[key] = [] }
-    coll_ids.each do |coll_id|
-      if m = CollectionsHelper.match(coll_id)
-        key = m[1] + m[2]
-        pdhs << key
-        source_paths[key] << m[4]
-      elsif m = CollectionsHelper.match_uuid_with_optional_filepath(coll_id)
-        key = m[1]
-        uuids << key
-        source_paths[key] << m[4]
-      end
-    end
-
-    unless pdhs.empty?
-      Collection.where(portable_data_hash: pdhs.uniq).with_count("none").
-          select([:uuid, :portable_data_hash]).each do |coll|
-        unless source_paths[coll.portable_data_hash].empty?
-          uuids << coll.uuid
-          source_paths[coll.uuid] = source_paths.delete(coll.portable_data_hash)
-        end
-      end
-    end
-
-    [uuids, source_paths]
-  end
-
-  def wiselinks_layout
-    'body'
-  end
-end
diff --git a/apps/workbench/app/controllers/authorized_keys_controller.rb b/apps/workbench/app/controllers/authorized_keys_controller.rb
deleted file mode 100644 (file)
index ac47ce7..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class AuthorizedKeysController < ApplicationController
-  def index_pane_list
-    %w(Recent Help)
-  end
-
-  def new
-    super
-    @object.authorized_user_uuid = current_user.uuid if current_user
-    @object.key_type = 'SSH'
-  end
-
-  def create
-    defaults = { authorized_user_uuid: current_user.uuid, key_type: 'SSH' }
-    @object = AuthorizedKey.new defaults.merge(params[:authorized_key] || {})
-    super
-  end
-end
diff --git a/apps/workbench/app/controllers/collections_controller.rb b/apps/workbench/app/controllers/collections_controller.rb
deleted file mode 100644 (file)
index 9073d06..0000000
+++ /dev/null
@@ -1,394 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require "arvados/keep"
-require "arvados/collection"
-require "uri"
-
-class CollectionsController < ApplicationController
-  include ActionController::Live
-
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    'show' == ctrl.action_name
-  }
-  skip_around_action(:require_thread_api_token,
-                     only: [:show_file, :show_file_links])
-  skip_before_action(:find_object_by_uuid,
-                     only: [:provenance, :show_file, :show_file_links])
-  # We depend on show_file to display the user agreement:
-  skip_before_action :check_user_agreements, only: :show_file
-  skip_before_action :check_user_profile, only: :show_file
-
-  RELATION_LIMIT = 5
-
-  def show_pane_list
-    panes = %w(Files Upload Tags Provenance_graph Used_by Advanced)
-    panes = panes - %w(Upload) unless (@object.editable? rescue false)
-    panes
-  end
-
-  def set_persistent
-    case params[:value]
-    when 'persistent', 'cache'
-      persist_links = Link.filter([['owner_uuid', '=', current_user.uuid],
-                                   ['link_class', '=', 'resources'],
-                                   ['name', '=', 'wants'],
-                                   ['tail_uuid', '=', current_user.uuid],
-                                   ['head_uuid', '=', @object.uuid]]).with_count("none")
-      logger.debug persist_links.inspect
-    else
-      return unprocessable "Invalid value #{value.inspect}"
-    end
-    if params[:value] == 'persistent'
-      if not persist_links.any?
-        Link.create(link_class: 'resources',
-                    name: 'wants',
-                    tail_uuid: current_user.uuid,
-                    head_uuid: @object.uuid)
-      end
-    else
-      persist_links.each do |link|
-        link.destroy || raise
-      end
-    end
-
-    respond_to do |f|
-      f.json { render json: @object }
-    end
-  end
-
-  def index
-    # API server index doesn't return manifest_text by default, but our
-    # callers want it unless otherwise specified.
-    @select ||= Collection.columns.map(&:name)
-    base_search = Collection.select(@select)
-    if params[:search].andand.length.andand > 0
-      tags = Link.where(any: ['contains', params[:search]]).with_count("none")
-      @objects = (base_search.where(uuid: tags.collect(&:head_uuid)) |
-                      base_search.where(any: ['contains', params[:search]])).
-        uniq { |c| c.uuid }
-    else
-      if params[:limit]
-        limit = params[:limit].to_i
-      else
-        limit = 100
-      end
-
-      if params[:offset]
-        offset = params[:offset].to_i
-      else
-        offset = 0
-      end
-
-      @objects = base_search.limit(limit).offset(offset)
-    end
-    @links = Link.where(head_uuid: @objects.collect(&:uuid)).with_count("none")
-    @collection_info = {}
-    @objects.each do |c|
-      @collection_info[c.uuid] = {
-        tag_links: [],
-        wanted: false,
-        wanted_by_me: false,
-        provenance: [],
-        links: []
-      }
-    end
-    @links.each do |link|
-      @collection_info[link.head_uuid] ||= {}
-      info = @collection_info[link.head_uuid]
-      case link.link_class
-      when 'tag'
-        info[:tag_links] << link
-      when 'resources'
-        info[:wanted] = true
-        info[:wanted_by_me] ||= link.tail_uuid == current_user.uuid
-      when 'provenance'
-        info[:provenance] << link.name
-      end
-      info[:links] << link
-    end
-    @request_url = request.url
-
-    render_index
-  end
-
-  def show_file_links
-    return show_file
-  end
-
-  def show_file
-    # The order of searched tokens is important: because the anonymous user
-    # token is passed along with every API request, we have to check it first.
-    # Otherwise, it's impossible to know whether any other request succeeded
-    # because of the reader token.
-    coll = nil
-    tokens = [(if !Rails.configuration.Users.AnonymousUserToken.empty? then
-                Rails.configuration.Users.AnonymousUserToken else nil end),
-              params[:reader_token],
-              Thread.current[:arvados_api_token]].compact
-    usable_token = find_usable_token(tokens) do
-      coll = Collection.find(params[:uuid])
-    end
-    if usable_token.nil?
-      # Response already rendered.
-      return
-    end
-
-    opts = {}
-    if usable_token == params[:reader_token]
-      opts[:path_token] = usable_token
-    elsif usable_token == Rails.configuration.Users.AnonymousUserToken
-      # Don't pass a token at all
-    else
-      # We pass the current user's real token only if it's necessary
-      # to read the collection.
-      opts[:query_token] = usable_token
-    end
-    opts[:disposition] = params[:disposition] if params[:disposition]
-    return redirect_to keep_web_url(params[:uuid], params[:file], opts)
-  end
-
-  def sharing_scopes
-    ["GET /arvados/v1/collections/#{@object.uuid}", "GET /arvados/v1/collections/#{@object.uuid}/", "GET /arvados/v1/keep_services/accessible"]
-  end
-
-  def search_scopes
-    begin
-      ApiClientAuthorization.filter([['scopes', '=', sharing_scopes]]).results
-    rescue ArvadosApiClient::AccessForbiddenException
-      nil
-    end
-  end
-
-  def find_object_by_uuid
-    if not Keep::Locator.parse params[:id]
-      super
-    end
-  end
-
-  def show
-    return super if !@object
-
-    @logs = []
-
-    if params["tab_pane"] == "Provenance_graph"
-      @prov_svg = ProvenanceHelper::create_provenance_graph(@object.provenance, "provenance_svg",
-                                                            {:request => request,
-                                                             :direction => "RL",
-                                                             :combine_jobs => :script_only}) rescue nil
-    end
-
-    if current_user
-      if Keep::Locator.parse params["uuid"]
-        @same_pdh = Collection.filter([["portable_data_hash", "=", @object.portable_data_hash]]).limit(20)
-        if @same_pdh.results.size == 1
-          redirect_to collection_path(@same_pdh[0]["uuid"])
-          return
-        end
-        owners = @same_pdh.map(&:owner_uuid).to_a.uniq
-        preload_objects_for_dataclass Group, owners
-        preload_objects_for_dataclass User, owners
-        uuids = @same_pdh.map(&:uuid).to_a.uniq
-        preload_links_for_objects uuids
-        render 'hash_matches'
-        return
-      else
-        if Job.api_exists?(:index)
-          jobs_with = lambda do |conds|
-            Job.limit(RELATION_LIMIT).with_count("none").where(conds)
-              .results.sort_by { |j| j.finished_at || j.created_at }
-          end
-          @output_of = jobs_with.call(output: @object.portable_data_hash)
-          @log_of = jobs_with.call(log: @object.portable_data_hash)
-        end
-
-        @project_links = Link.limit(RELATION_LIMIT).with_count("none").order("modified_at DESC")
-          .where(head_uuid: @object.uuid, link_class: 'name').results
-        project_hash = Group.where(uuid: @project_links.map(&:tail_uuid)).with_count("none").to_hash
-        @projects = project_hash.values
-
-        @permissions = Link.limit(RELATION_LIMIT).with_count("none").order("modified_at DESC")
-          .where(head_uuid: @object.uuid, link_class: 'permission',
-                 name: 'can_read').results
-        @search_sharing = search_scopes
-
-        if params["tab_pane"] == "Used_by"
-          @used_by_svg = ProvenanceHelper::create_provenance_graph(@object.used_by, "used_by_svg",
-                                                                   {:request => request,
-                                                                    :direction => "LR",
-                                                                    :combine_jobs => :script_only,
-                                                                    :pdata_only => true}) rescue nil
-        end
-      end
-    end
-    super
-  end
-
-  def sharing_popup
-    @search_sharing = search_scopes
-    render("sharing_popup.js", content_type: "text/javascript")
-  end
-
-  helper_method :download_link
-
-  def download_link
-    token = @search_sharing.first.api_token
-    keep_web_url(@object.uuid, nil, {path_token: token})
-  end
-
-  def share
-    ApiClientAuthorization.create(scopes: sharing_scopes)
-    sharing_popup
-  end
-
-  def unshare
-    search_scopes.each do |s|
-      s.destroy
-    end
-    sharing_popup
-  end
-
-  def remove_selected_files
-    uuids, source_paths = selected_collection_files params
-
-    arv_coll = Arv::Collection.new(@object.manifest_text)
-    source_paths[uuids[0]].each do |p|
-      arv_coll.rm "."+p
-    end
-
-    if @object.update_attributes manifest_text: arv_coll.manifest_text
-      show
-    else
-      self.render_error status: 422
-    end
-  end
-
-  def update
-    updated_attr = params[:collection].to_unsafe_hash.each.select {|a| a[0].andand.start_with? 'rename-file-path:'}
-
-    if updated_attr.size > 0
-      # Is it file rename?
-      file_path = updated_attr[0][0].split('rename-file-path:')[-1]
-
-      new_file_path = updated_attr[0][1]
-      if new_file_path.start_with?('./')
-        # looks good
-      elsif new_file_path.start_with?('/')
-        new_file_path = '.' + new_file_path
-      else
-        new_file_path = './' + new_file_path
-      end
-
-      arv_coll = Arv::Collection.new(@object.manifest_text)
-
-      if arv_coll.exist?(new_file_path)
-        @errors = 'Duplicate file path. Please use a different name.'
-        self.render_error status: 422
-      else
-        arv_coll.rename "./"+file_path, new_file_path
-
-        if @object.update_attributes manifest_text: arv_coll.manifest_text
-          show
-        else
-          self.render_error status: 422
-        end
-      end
-    else
-      # Not a file rename; use default
-      super
-    end
-  end
-
-  protected
-
-  def find_usable_token(token_list)
-    # Iterate over every given token to make it the current token and
-    # yield the given block.
-    # If the block succeeds, return the token it used.
-    # Otherwise, render an error response based on the most specific
-    # error we encounter, and return nil.
-    most_specific_error = [401]
-    token_list.each do |api_token|
-      begin
-        # We can't load the corresponding user, because the token may not
-        # be scoped for that.
-        using_specific_api_token(api_token, load_user: false) do
-          yield
-          return api_token
-        end
-      rescue ArvadosApiClient::ApiError => error
-        if error.api_status >= most_specific_error.first
-          most_specific_error = [error.api_status, error]
-        end
-      end
-    end
-    case most_specific_error.shift
-    when 401, 403
-      redirect_to_login
-    when 404
-      render_not_found(*most_specific_error)
-    end
-    return nil
-  end
-
-  def keep_web_url(uuid_or_pdh, file, opts)
-    munged_id = uuid_or_pdh.sub('+', '-')
-
-    tmpl = Rails.configuration.Services.WebDAV.ExternalURL.to_s
-
-    if Rails.configuration.Services.WebDAVDownload.ExternalURL != URI("") and
-        (tmpl.empty? or opts[:disposition] == 'attachment')
-      # Prefer the attachment-only-host when we want an attachment
-      # (and when there is no preview link configured)
-      tmpl = Rails.configuration.Services.WebDAVDownload.ExternalURL.to_s
-    elsif not Rails.configuration.Collections.TrustAllContent
-      check_uri = URI.parse(tmpl.sub("*", munged_id))
-      if opts[:query_token] and
-        (check_uri.host.nil? or (
-          not check_uri.host.start_with?(munged_id + "--") and
-          not check_uri.host.start_with?(munged_id + ".")))
-        # We're about to pass a token in the query string, but
-        # keep-web can't accept that safely at a single-origin URL
-        # template (unless it's -attachment-only-host).
-        tmpl = Rails.configuration.Services.WebDAVDownload.ExternalURL.to_s
-        if tmpl.empty?
-          raise ArgumentError, "Download precluded by site configuration"
-        end
-        logger.warn("Using download link, even though inline content " \
-                    "was requested: #{check_uri.to_s}")
-      end
-    end
-
-    if tmpl == Rails.configuration.Services.WebDAVDownload.ExternalURL.to_s
-      # This takes us to keep-web's -attachment-only-host so there is
-      # no need to add ?disposition=attachment.
-      opts.delete :disposition
-    end
-
-    uri = URI.parse(tmpl.sub("*", munged_id))
-    if tmpl.index("*").nil?
-      uri.path = "/c=#{munged_id}"
-    end
-    uri.path += '/' unless uri.path.end_with? '/'
-    if opts[:path_token]
-      uri.path += 't=' + opts[:path_token] + '/'
-    end
-    uri.path += '_/'
-    uri.path += URI.escape(file) if file
-
-    query = Hash[URI.decode_www_form(uri.query || '')]
-    { query_token: 'api_token',
-      disposition: 'disposition' }.each do |opt, param|
-      if opts.include? opt
-        query[param] = opts[opt]
-      end
-    end
-    unless query.empty?
-      uri.query = URI.encode_www_form(query)
-    end
-
-    uri.to_s
-  end
-end
diff --git a/apps/workbench/app/controllers/container_requests_controller.rb b/apps/workbench/app/controllers/container_requests_controller.rb
deleted file mode 100644 (file)
index be463b0..0000000
+++ /dev/null
@@ -1,219 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ContainerRequestsController < ApplicationController
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    'show' == ctrl.action_name
-  }
-
-  def generate_provenance(cr)
-    return if params['tab_pane'] != "Provenance"
-
-    nodes = {}
-    child_crs = []
-    col_uuids = []
-    col_pdhs = []
-    col_uuids << cr[:output_uuid] if cr[:output_uuid]
-    col_pdhs += ProvenanceHelper::cr_input_pdhs(cr)
-
-    # Search for child CRs
-    if cr[:container_uuid]
-      child_crs = ContainerRequest.where(requesting_container_uuid: cr[:container_uuid]).with_count("none")
-
-      child_crs.each do |child|
-        nodes[child[:uuid]] = child
-        col_uuids << child[:output_uuid] if child[:output_uuid]
-        col_pdhs += ProvenanceHelper::cr_input_pdhs(child)
-      end
-    end
-
-    if nodes.length == 0
-      nodes[cr[:uuid]] = cr
-    end
-
-    pdh_to_col = {} # Indexed by PDH
-    output_pdhs = []
-
-    # Batch requests to get all related collections
-    # First fetch output collections by UUID.
-    Collection.filter([['uuid', 'in', col_uuids.uniq]]).with_count("none").each do |c|
-      output_pdhs << c[:portable_data_hash]
-      pdh_to_col[c[:portable_data_hash]] = c
-      nodes[c[:uuid]] = c
-    end
-    # Next, get input collections by PDH.
-    Collection.filter(
-      [['portable_data_hash', 'in', col_pdhs - output_pdhs]]).with_count("none").each do |c|
-      nodes[c[:portable_data_hash]] = c
-    end
-
-    @svg = ProvenanceHelper::create_provenance_graph(
-      nodes, "provenance_svg",
-      {
-        :request => request,
-        :pdh_to_uuid => pdh_to_col,
-      }
-    )
-  end
-
-  def show_pane_list
-    panes = %w(Status Log Provenance Advanced)
-    if @object.andand.state == 'Uncommitted'
-      panes = %w(Inputs) + panes - %w(Log Provenance)
-    end
-    panes
-  end
-
-  def show
-    generate_provenance(@object)
-    super
-  end
-
-  def cancel
-    if @object.container_uuid
-      c = Container.select(['state']).where(uuid: @object.container_uuid).with_count("none").first
-      if c && c.state != 'Running'
-        # If the container hasn't started yet, setting priority=0
-        # leaves our request in "Committed" state and doesn't cancel
-        # the container (even if no other requests are giving it
-        # priority). To avoid showing this container request as "on
-        # hold" after hitting the Cancel button, set state=Final too.
-        @object.state = 'Final'
-      end
-    end
-    @object.update_attributes! priority: 0
-    if params[:return_to]
-      redirect_to params[:return_to]
-    else
-      redirect_to @object
-    end
-  end
-
-  def update
-    @updates ||= params[@object.class.to_s.underscore.singularize.to_sym]
-    input_obj = @updates[:mounts].andand[:"/var/lib/cwl/cwl.input.json"].andand[:content]
-    if input_obj
-      workflow = @object.mounts[:"/var/lib/cwl/workflow.json"][:content]
-      get_cwl_inputs(workflow).each do |input_schema|
-        if not input_obj.include? cwl_shortname(input_schema[:id])
-          next
-        end
-        required, primary_type, param_id = cwl_input_info(input_schema)
-        if input_obj[param_id] == ""
-          input_obj[param_id] = nil
-        elsif primary_type == "boolean"
-          input_obj[param_id] = input_obj[param_id] == "true"
-        elsif ["int", "long"].include? primary_type
-          input_obj[param_id] = input_obj[param_id].to_i
-        elsif ["float", "double"].include? primary_type
-          input_obj[param_id] = input_obj[param_id].to_f
-        elsif ["File", "Directory"].include? primary_type
-          re = CollectionsHelper.match_uuid_with_optional_filepath(input_obj[param_id])
-          if re
-            c = Collection.find(re[1])
-            input_obj[param_id] = {"class" => primary_type,
-                                   "location" => "keep:#{c.portable_data_hash}#{re[4]}",
-                                   "http://arvados.org/cwl#collectionUUID" => re[1]}
-          end
-        end
-      end
-    end
-    params[:merge] = true
-
-    if !@updates[:reuse_steps].nil?
-      if @updates[:reuse_steps] == "false"
-        @updates[:reuse_steps] = false
-      end
-      @updates[:command] ||= @object.command
-      @updates[:command] -= ["--disable-reuse", "--enable-reuse"]
-      if @updates[:reuse_steps]
-        @updates[:command].insert(1, "--enable-reuse")
-      else
-        @updates[:command].insert(1, "--disable-reuse")
-      end
-      @updates.delete(:reuse_steps)
-    end
-
-    begin
-      super
-    rescue => e
-      flash[:error] = e.to_s
-      show
-    end
-  end
-
-  def copy
-    src = @object
-
-    @object = ContainerRequest.new
-
-    # set owner_uuid to that of source, provided it is a project and writable by current user
-    if params[:work_unit].andand[:owner_uuid]
-      @object.owner_uuid = src.owner_uuid = params[:work_unit][:owner_uuid]
-    else
-      current_project = Group.find(src.owner_uuid) rescue nil
-      if (current_project && current_project.writable_by.andand.include?(current_user.uuid))
-        @object.owner_uuid = src.owner_uuid
-      end
-    end
-
-    command = src.command
-    if command[0] == 'arvados-cwl-runner'
-      command.each_with_index do |arg, i|
-        if arg.start_with? "--project-uuid="
-          command[i] = "--project-uuid=#{@object.owner_uuid}"
-        end
-      end
-      command -= ["--disable-reuse", "--enable-reuse"]
-      command.insert(1, '--enable-reuse')
-    end
-
-    if params[:use_existing] == "false"
-      params[:use_existing] = false
-    elsif params[:use_existing] == "true"
-      params[:use_existing] = true
-    end
-
-    if params[:use_existing] || params[:use_existing].nil?
-      # If nil, reuse workflow steps but not the workflow runner.
-      @object.use_existing = !!params[:use_existing]
-
-      # Pass the correct argument to arvados-cwl-runner command.
-      if command[0] == 'arvados-cwl-runner'
-        command -= ["--disable-reuse", "--enable-reuse"]
-        command.insert(1, '--enable-reuse')
-      end
-    else
-      @object.use_existing = false
-      # Pass the correct argument to arvados-cwl-runner command.
-      if command[0] == 'arvados-cwl-runner'
-        command -= ["--disable-reuse", "--enable-reuse"]
-        command.insert(1, '--disable-reuse')
-      end
-    end
-
-    @object.command = command
-    @object.container_image = src.container_image
-    @object.cwd = src.cwd
-    @object.description = src.description
-    @object.environment = src.environment
-    @object.mounts = src.mounts
-    @object.name = src.name
-    @object.output_path = src.output_path
-    @object.priority = 1
-    @object.properties[:template_uuid] = src.properties[:template_uuid]
-    @object.runtime_constraints = src.runtime_constraints
-    @object.scheduling_parameters = src.scheduling_parameters
-    @object.state = 'Uncommitted'
-
-    super
-  end
-
-  def index
-    @limit = 20
-    super
-  end
-
-end
diff --git a/apps/workbench/app/controllers/containers_controller.rb b/apps/workbench/app/controllers/containers_controller.rb
deleted file mode 100644 (file)
index 4b56067..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ContainersController < ApplicationController
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    'show' == ctrl.action_name
-  }
-
-  def show_pane_list
-    %w(Status Log Advanced)
-  end
-end
diff --git a/apps/workbench/app/controllers/groups_controller.rb b/apps/workbench/app/controllers/groups_controller.rb
deleted file mode 100644 (file)
index 6abd2ff..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class GroupsController < ApplicationController
-  def index
-    @groups = Group.filter [['group_class', '!=', 'project'], ['group_class', '!=', 'filter']]
-    @group_uuids = @groups.collect &:uuid
-    @links_from = Link.where(link_class: 'permission', tail_uuid: @group_uuids).with_count("none")
-    @links_to = Link.where(link_class: 'permission', head_uuid: @group_uuids).with_count("none")
-    render_index
-  end
-
-  def show
-    if @object.group_class == 'project' or @object.group_class == 'filter'
-      redirect_to(project_path(@object))
-    else
-      super
-    end
-  end
-end
diff --git a/apps/workbench/app/controllers/humans_controller.rb b/apps/workbench/app/controllers/humans_controller.rb
deleted file mode 100644 (file)
index dd08b30..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class HumansController < ApplicationController
-end
diff --git a/apps/workbench/app/controllers/job_tasks_controller.rb b/apps/workbench/app/controllers/job_tasks_controller.rb
deleted file mode 100644 (file)
index 67b31ad..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class JobTasksController < ApplicationController
-end
diff --git a/apps/workbench/app/controllers/jobs_controller.rb b/apps/workbench/app/controllers/jobs_controller.rb
deleted file mode 100644 (file)
index e5c71cb..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class JobsController < ApplicationController
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    'show' == ctrl.action_name
-  }
-
-  def generate_provenance(jobs)
-    return if params['tab_pane'] != "Provenance"
-
-    nodes = {}
-    collections = []
-    hashes = []
-    jobs.each do |j|
-      nodes[j[:uuid]] = j
-      hashes << j[:output]
-      ProvenanceHelper::find_collections(j[:script_parameters]) do |hash, uuid|
-        collections << uuid if uuid
-        hashes << hash if hash
-      end
-      nodes[j[:script_version]] = {:uuid => j[:script_version]}
-    end
-
-    Collection.where(uuid: collections).with_count("none").each do |c|
-      nodes[c[:portable_data_hash]] = c
-    end
-
-    Collection.where(portable_data_hash: hashes).with_count("none").each do |c|
-      nodes[c[:portable_data_hash]] = c
-    end
-
-    @svg = ProvenanceHelper::create_provenance_graph nodes, "provenance_svg", {
-      :request => request,
-      :all_script_parameters => true,
-      :script_version_nodes => true}
-  end
-
-  def index
-    @svg = ""
-    if params[:uuid]
-      @objects = Job.where(uuid: params[:uuid])
-      generate_provenance(@objects)
-      render_index
-    else
-      @limit = 20
-      super
-    end
-  end
-
-  def cancel
-    @object.cancel
-    if params[:return_to]
-      redirect_to params[:return_to]
-    else
-      redirect_to @object
-    end
-  end
-
-  def show
-    generate_provenance([@object])
-    super
-  end
-
-  def logs
-    @logs = @object.
-      stderr_log_query(Rails.configuration.Workbench.RunningJobLogRecordsToFetch).
-      map { |e| e.serializable_hash.merge({ 'prepend' => true }) }
-    respond_to do |format|
-      format.json { render json: @logs }
-    end
-  end
-
-  def index_pane_list
-    if params[:uuid]
-      %w(Recent Provenance)
-    else
-      %w(Recent)
-    end
-  end
-
-  def show_pane_list
-    %w(Status Log Details Provenance Advanced)
-  end
-end
diff --git a/apps/workbench/app/controllers/keep_disks_controller.rb b/apps/workbench/app/controllers/keep_disks_controller.rb
deleted file mode 100644 (file)
index c95ebdc..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class KeepDisksController < ApplicationController
-  def create
-    defaults = { is_readable: true, is_writable: true }
-    @object = KeepDisk.new defaults.merge(params[:keep_disk] || {})
-    super
-  end
-
-  def index
-    # Retrieve cache age histogram info from logs.
-
-    # In the logs we expect to find it in an ordered list with entries
-    # of the form (mtime, disk proportion free).
-
-    # An entry of the form (1388747781, 0.52) means that if we deleted
-    # the oldest non-presisted blocks until we had 52% of the disk
-    # free, then all blocks with an mtime greater than 1388747781
-    # would be preserved.
-
-    # The chart we want to produce, will tell us how much of the disk
-    # will be free if we use a cache age of x days. Therefore we will
-    # produce output specifying the age, cache and persisted. age is
-    # specified in milliseconds. cache is the size of the cache if we
-    # delete all blocks older than age. persistent is the size of the
-    # persisted blocks. It is constant regardless of age, but it lets
-    # us show a stacked graph.
-
-    # Finally each entry in cache_age_histogram is a dictionary,
-    # because that's what our charting package wats.
-
-    @cache_age_histogram = []
-    @histogram_pretty_date = nil
-    histogram_log = Log.
-      filter([[:event_type, '=', 'block-age-free-space-histogram']]).
-      order(:created_at => :desc).
-      with_count('none').
-      limit(1)
-    histogram_log.each do |log_entry|
-      # We expect this block to only execute at most once since we
-      # specified limit(1)
-      @cache_age_histogram = log_entry['properties'][:histogram]
-      # Javascript wants dates in milliseconds.
-      histogram_date_ms = log_entry['event_at'].to_i * 1000
-      @histogram_pretty_date = log_entry['event_at'].strftime('%b %-d, %Y')
-
-      total_free_cache = @cache_age_histogram[-1][1]
-      persisted_storage = 1 - total_free_cache
-      @cache_age_histogram.map! { |x| {:age => histogram_date_ms - x[0]*1000,
-          :cache => total_free_cache - x[1],
-          :persisted => persisted_storage} }
-    end
-
-    # Do the regular control work needed.
-    super
-  end
-end
diff --git a/apps/workbench/app/controllers/keep_services_controller.rb b/apps/workbench/app/controllers/keep_services_controller.rb
deleted file mode 100644 (file)
index 361d400..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class KeepServicesController < ApplicationController
-end
diff --git a/apps/workbench/app/controllers/links_controller.rb b/apps/workbench/app/controllers/links_controller.rb
deleted file mode 100644 (file)
index b79fad4..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class LinksController < ApplicationController
-  def show
-    if @object.link_class == 'name' and
-        Collection == ArvadosBase::resource_class_for_uuid(@object.head_uuid)
-      return redirect_to collection_path(@object.uuid)
-    end
-    super
-  end
-end
diff --git a/apps/workbench/app/controllers/logs_controller.rb b/apps/workbench/app/controllers/logs_controller.rb
deleted file mode 100644 (file)
index 7e41328..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class LogsController < ApplicationController
-  before_action :ensure_current_user_is_admin
-end
diff --git a/apps/workbench/app/controllers/management_controller.rb b/apps/workbench/app/controllers/management_controller.rb
deleted file mode 100644 (file)
index 4c8b52f..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'app_version'
-
-class ManagementController < ApplicationController
-  skip_around_action :thread_clear
-  skip_around_action :set_thread_api_token
-  skip_around_action :require_thread_api_token
-  skip_before_action :ensure_arvados_api_exists
-  skip_before_action :accept_uuid_as_id_param
-  skip_before_action :check_user_agreements
-  skip_before_action :check_user_profile
-  skip_before_action :load_filters_and_paging_params
-  skip_before_action :find_object_by_uuid
-
-  before_action :check_auth_header
-
-  def check_auth_header
-    mgmt_token = Rails.configuration.ManagementToken
-    auth_header = request.headers['Authorization']
-
-    if mgmt_token.empty?
-      render :json => {:errors => "disabled"}, :status => 404
-    elsif !auth_header
-      render :json => {:errors => "authorization required"}, :status => 401
-    elsif auth_header != 'Bearer '+mgmt_token
-      render :json => {:errors => "authorization error"}, :status => 403
-    end
-  end
-
-  def metrics
-    render content_type: 'text/plain', plain: <<~EOF
-# HELP arvados_config_load_timestamp_seconds Time when config file was loaded.
-# TYPE arvados_config_load_timestamp_seconds gauge
-arvados_config_load_timestamp_seconds{sha256="#{Rails.configuration.SourceSHA256}"} #{Rails.configuration.LoadTimestamp.to_f}
-# HELP arvados_config_source_timestamp_seconds Timestamp of config file when it was loaded.
-# TYPE arvados_config_source_timestamp_seconds gauge
-arvados_config_source_timestamp_seconds{sha256="#{Rails.configuration.SourceSHA256}"} #{Rails.configuration.SourceTimestamp.to_f}
-# HELP arvados_version_running Indicated version is running.
-# TYPE arvados_version_running gauge
-arvados_version_running{version="#{AppVersion.package_version}"} 1
-EOF
-  end
-
-  def health
-    resp = {"health" => "OK"}
-    render json: resp
-  end
-end
diff --git a/apps/workbench/app/controllers/nodes_controller.rb b/apps/workbench/app/controllers/nodes_controller.rb
deleted file mode 100644 (file)
index 72bde69..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class NodesController < ApplicationController
-end
diff --git a/apps/workbench/app/controllers/pipeline_instances_controller.rb b/apps/workbench/app/controllers/pipeline_instances_controller.rb
deleted file mode 100644 (file)
index 81417ff..0000000
+++ /dev/null
@@ -1,373 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class PipelineInstancesController < ApplicationController
-  skip_before_action :find_object_by_uuid, only: :compare
-  before_action :find_objects_by_uuid, only: :compare
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    'show' == ctrl.action_name
-  }
-
-  include PipelineInstancesHelper
-  include PipelineComponentsHelper
-
-  def copy
-    template = PipelineTemplate.find?(@object.pipeline_template_uuid)
-
-    source = @object
-    @object = PipelineInstance.new
-    @object.pipeline_template_uuid = source.pipeline_template_uuid
-
-    if params['components'] == 'use_latest' and template
-      @object.components = template.components.deep_dup
-      @object.components.each do |cname, component|
-        # Go through the script parameters of each component
-        # that are marked as user input and copy them over.
-        # Skip any components that are not present in the
-        # source instance (there's nothing to copy)
-        if source.components.include? cname
-          component[:script_parameters].each do |pname, val|
-            if val.is_a? Hash and val[:dataclass]
-              # this is user-inputtable, so check the value from the source pipeline
-              srcvalue = source.components[cname][:script_parameters][pname]
-              if not srcvalue.nil?
-                component[:script_parameters][pname] = srcvalue
-              end
-            end
-          end
-        end
-      end
-    else
-      @object.components = source.components.deep_dup
-    end
-
-    if params['script'] == 'use_same'
-      # Go through each component and copy the script_version from each job.
-      @object.components.each do |cname, component|
-        if source.components.include? cname and source.components[cname][:job]
-          component[:script_version] = source.components[cname][:job][:script_version]
-        end
-      end
-    end
-
-    @object.components.each do |cname, component|
-      component.delete :job
-    end
-    @object.state = 'New'
-
-    # set owner_uuid to that of source, provided it is a project and writable by current user
-    current_project = Group.find(source.owner_uuid) rescue nil
-    if (current_project && current_project.writable_by.andand.include?(current_user.uuid))
-      @object.owner_uuid = source.owner_uuid
-    end
-
-    super
-  end
-
-  def update
-    @updates ||= params.to_unsafe_hash[@object.class.to_s.underscore.singularize.to_sym]
-    if (components = @updates[:components])
-      components.each do |cname, component|
-        if component[:script_parameters]
-          component[:script_parameters].each do |param, value_info|
-            if value_info.is_a? Hash
-              value_info_partitioned = value_info[:value].partition('/') if value_info[:value].andand.class.eql?(String)
-              value_info_value = value_info_partitioned ? value_info_partitioned[0] : value_info[:value]
-              value_info_class = resource_class_for_uuid value_info_value
-              if value_info_class == Link
-                # Use the link target, not the link itself, as script
-                # parameter; but keep the link info around as well.
-                link = Link.find value_info[:value]
-                value_info[:value] = link.head_uuid
-                value_info[:link_uuid] = link.uuid
-                value_info[:link_name] = link.name
-              else
-                # Delete stale link_uuid and link_name data.
-                value_info[:link_uuid] = nil
-                value_info[:link_name] = nil
-              end
-              if value_info_class == Collection
-                # to ensure reproducibility, the script_parameter for a
-                # collection should be the portable_data_hash
-                # keep the collection name and uuid for human-readability
-                obj = Collection.find value_info_value
-                if value_info_partitioned
-                  value_info[:value] = obj.portable_data_hash + value_info_partitioned[1] + value_info_partitioned[2]
-                  value_info[:selection_name] = obj.name ? obj.name + value_info_partitioned[1] + value_info_partitioned[2] : obj.name
-                else
-                  value_info[:value] = obj.portable_data_hash
-                  value_info[:selection_name] = obj.name
-                end
-                value_info[:selection_uuid] = obj.uuid
-              end
-            end
-          end
-        end
-      end
-    end
-    super
-  end
-
-  def graph(pipelines)
-    return nil, nil if params['tab_pane'] != "Graph"
-
-    provenance = {}
-    pips = {}
-    n = 1
-
-    # When comparing more than one pipeline, "pips" stores bit fields that
-    # indicates which objects are part of which pipelines.
-
-    pipelines.each do |p|
-      collections = []
-      hashes = []
-      jobs = []
-
-      p[:components].each do |k, v|
-        provenance["component_#{p[:uuid]}_#{k}"] = v
-
-        collections << v[:output_uuid] if v[:output_uuid]
-        jobs << v[:job][:uuid] if v[:job]
-      end
-
-      jobs = jobs.compact.uniq
-      if jobs.any?
-        Job.where(uuid: jobs).with_count("none").each do |j|
-          job_uuid = j.uuid
-
-          provenance[job_uuid] = j
-          pips[job_uuid] = 0 unless pips[job_uuid] != nil
-          pips[job_uuid] |= n
-
-          hashes << j[:output] if j[:output]
-          ProvenanceHelper::find_collections(j) do |hash, uuid|
-            collections << uuid if uuid
-            hashes << hash if hash
-          end
-
-          if j[:script_version]
-            script_uuid = j[:script_version]
-            provenance[script_uuid] = {:uuid => script_uuid}
-            pips[script_uuid] = 0 unless pips[script_uuid] != nil
-            pips[script_uuid] |= n
-          end
-        end
-      end
-
-      hashes = hashes.compact.uniq
-      if hashes.any?
-        Collection.where(portable_data_hash: hashes).with_count("none").each do |c|
-          hash_uuid = c.portable_data_hash
-          provenance[hash_uuid] = c
-          pips[hash_uuid] = 0 unless pips[hash_uuid] != nil
-          pips[hash_uuid] |= n
-        end
-      end
-
-      collections = collections.compact.uniq
-      if collections.any?
-        Collection.where(uuid: collections).with_count("none").each do |c|
-          collection_uuid = c.uuid
-          provenance[collection_uuid] = c
-          pips[collection_uuid] = 0 unless pips[collection_uuid] != nil
-          pips[collection_uuid] |= n
-        end
-      end
-
-      n = n << 1
-    end
-
-    return provenance, pips
-  end
-
-  def show
-    # the #show action can also be called by #compare, which does its own work to set up @pipelines
-    unless defined? @pipelines
-      @pipelines = [@object]
-    end
-
-    provenance, pips = graph(@pipelines)
-    if provenance
-      @prov_svg = ProvenanceHelper::create_provenance_graph provenance, "provenance_svg", {
-        :request => request,
-        :all_script_parameters => true,
-        :combine_jobs => :script_and_version,
-        :pips => pips,
-        :only_components => true,
-        :no_docker => true,
-        :no_log => true}
-    end
-
-    super
-  end
-
-  def compare
-    @breadcrumb_page_name = 'compare'
-
-    @rows = []          # each is {name: S, components: [...]}
-
-    if params['tab_pane'] == "Compare" or params['tab_pane'].nil?
-      # Build a table: x=pipeline y=component
-      @objects.each_with_index do |pi, pi_index|
-        pipeline_jobs(pi).each do |component|
-          # Find a cell with the same name as this component but no
-          # entry for this pipeline
-          target_row = nil
-          @rows.each_with_index do |row, row_index|
-            if row[:name] == component[:name] and !row[:components][pi_index]
-              target_row = row
-            end
-          end
-          if !target_row
-            target_row = {name: component[:name], components: []}
-            @rows << target_row
-          end
-          target_row[:components][pi_index] = component
-        end
-      end
-
-      @rows.each do |row|
-        # Build a "normal" pseudo-component for this row by picking the
-        # most common value for each attribute. If all values are
-        # equally common, there is no "normal".
-        normal = {}              # attr => most common value
-        highscore = {}           # attr => how common "normal" is
-        score = {}               # attr => { value => how common }
-        row[:components].each do |pj|
-          next if pj.nil?
-          pj.each do |k,v|
-            vstr = for_comparison v
-            score[k] ||= {}
-            score[k][vstr] = (score[k][vstr] || 0) + 1
-            highscore[k] ||= 0
-            if score[k][vstr] == highscore[k]
-              # tie for first place = no "normal"
-              normal.delete k
-            elsif score[k][vstr] == highscore[k] + 1
-              # more pipelines have v than anything else
-              highscore[k] = score[k][vstr]
-              normal[k] = vstr
-            end
-          end
-        end
-
-        # Add a hash in component[:is_normal]: { attr => is_the_value_normal? }
-        row[:components].each do |pj|
-          next if pj.nil?
-          pj[:is_normal] = {}
-          pj.each do |k,v|
-            pj[:is_normal][k] = (normal.has_key?(k) && normal[k] == for_comparison(v))
-          end
-        end
-      end
-    end
-
-    if params['tab_pane'] == "Graph"
-      @pipelines = @objects
-    end
-
-    @object = @objects.first
-
-    show
-  end
-
-  def show_pane_list
-    panes = %w(Components Log Graph Advanced)
-    if @object and @object.state.in? ['New', 'Ready']
-      panes = %w(Inputs) + panes - %w(Log)
-    end
-    if not @object.components.values.any? { |x| x[:job] rescue false }
-      panes -= ['Graph']
-    end
-    panes
-  end
-
-  def compare_pane_list
-    %w(Compare Graph)
-  end
-
-  helper_method :unreadable_inputs_present?
-  def unreadable_inputs_present?
-    unless @unreadable_inputs_present.nil?
-      return @unreadable_inputs_present
-    end
-
-    input_uuids = []
-    input_pdhs = []
-    @object.components.each do |k, component|
-      next if !component
-      component[:script_parameters].andand.each do |p, tv|
-        if (tv.is_a? Hash) and ((tv[:dataclass] == "Collection") || (tv[:dataclass] == "File"))
-          if tv[:value]
-            value = tv[:value]
-          elsif tv[:default]
-            value = tv[:default]
-          else
-            value = ''
-          end
-          if value.present?
-            split = value.split '/'
-            if CollectionsHelper.match(split[0])
-              input_pdhs << split[0]
-            else
-              input_uuids << split[0]
-            end
-          end
-        end
-      end
-    end
-
-    input_pdhs = input_pdhs.uniq
-    input_uuids = input_uuids.uniq
-
-    preload_collections_for_objects input_uuids if input_uuids.any?
-    preload_for_pdhs input_pdhs if input_pdhs.any?
-
-    @unreadable_inputs_present = false
-    input_uuids.each do |uuid|
-      if !collections_for_object(uuid).any?
-        @unreadable_inputs_present = true
-        break
-      end
-    end
-    if !@unreadable_inputs_present
-      input_pdhs.each do |pdh|
-        if !collection_for_pdh(pdh).any?
-          @unreadable_inputs_present = true
-          break
-        end
-      end
-    end
-
-    @unreadable_inputs_present
-  end
-
-  def cancel
-    @object.cancel
-    if params[:return_to]
-      redirect_to params[:return_to]
-    else
-      redirect_to @object
-    end
-  end
-
-  protected
-  def for_comparison v
-    if v.is_a? Hash or v.is_a? Array
-      v.to_json
-    else
-      v.to_s
-    end
-  end
-
-  def load_filters_and_paging_params
-    params[:limit] = 20
-    super
-  end
-
-  def find_objects_by_uuid
-    @objects = model_class.where(uuid: params[:uuids])
-  end
-end
diff --git a/apps/workbench/app/controllers/pipeline_templates_controller.rb b/apps/workbench/app/controllers/pipeline_templates_controller.rb
deleted file mode 100644 (file)
index aa444c1..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class PipelineTemplatesController < ApplicationController
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    'show' == ctrl.action_name
-  }
-
-  include PipelineComponentsHelper
-
-  def show
-    @objects = PipelineInstance.where(pipeline_template_uuid: @object.uuid)
-    super
-  end
-
-  def show_pane_list
-    %w(Components Pipelines Advanced)
-  end
-end
diff --git a/apps/workbench/app/controllers/projects_controller.rb b/apps/workbench/app/controllers/projects_controller.rb
deleted file mode 100644 (file)
index e448e1b..0000000
+++ /dev/null
@@ -1,323 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ProjectsController < ApplicationController
-  before_action :set_share_links, if: -> { defined? @object and @object}
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    %w(show tab_counts public).include? ctrl.action_name
-  }
-
-  def model_class
-    Group
-  end
-
-  def find_object_by_uuid
-    if (current_user and params[:uuid] == current_user.uuid) or
-       (resource_class_for_uuid(params[:uuid]) == User)
-      if params[:uuid] != current_user.uuid
-        @object = User.find(params[:uuid])
-      else
-        @object = current_user.dup
-        @object.uuid = current_user.uuid
-      end
-
-      class << @object
-        def name
-          if current_user.uuid == self.uuid
-            'Home'
-          else
-            "Home for #{self.email}"
-          end
-        end
-        def description
-          ''
-        end
-        def attribute_editable? attr, *args
-          case attr
-          when 'description', 'name'
-            false
-          else
-            super
-          end
-        end
-      end
-    else
-      super
-    end
-  end
-
-  def index_pane_list
-    %w(Projects)
-  end
-
-  # Returning an array of hashes instead of an array of strings will allow
-  # us to tell the interface to get counts for each pane (using :filters).
-  # It also seems to me that something like these could be used to configure the contents of the panes.
-  def show_pane_list
-    pane_list = []
-
-    procs = ["arvados#containerRequest"]
-    procs_pane_name = 'Processes'
-    if PipelineInstance.api_exists?(:index)
-      procs << "arvados#pipelineInstance"
-      procs_pane_name = 'Pipelines_and_processes'
-    end
-
-    workflows = ["arvados#workflow"]
-    workflows_pane_name = 'Workflows'
-    if PipelineTemplate.api_exists?(:index)
-      workflows << "arvados#pipelineTemplate"
-      workflows_pane_name = 'Pipeline_templates'
-    end
-
-    if @object.uuid != current_user.andand.uuid
-      pane_list << 'Description'
-    end
-    pane_list <<
-      {
-        :name => 'Data_collections',
-        :filters => [%w(uuid is_a arvados#collection)]
-      }
-    pane_list <<
-      {
-        :name => procs_pane_name,
-        :filters => [%w(uuid is_a) + [procs]]
-      }
-    pane_list <<
-      {
-        :name => workflows_pane_name,
-        :filters => [%w(uuid is_a) + [workflows]]
-      }
-    pane_list <<
-      {
-        :name => 'Subprojects',
-        :filters => [%w(uuid is_a arvados#group)]
-      }
-    pane_list <<
-      {
-        :name => 'Other_objects',
-        :filters => [%w(uuid is_a) + [%w(arvados#human arvados#specimen arvados#trait)]]
-      } if current_user
-    pane_list << { :name => 'Sharing',
-                   :count => @share_links.count } if @user_is_manager
-    pane_list << { :name => 'Advanced' }
-  end
-
-  # Called via AJAX and returns Javascript that populates tab counts into tab titles.
-  # References #show_pane_list action which should return an array of hashes each with :name
-  # and then optionally a :filters to run or a straight up :count
-  #
-  # This action could easily be moved to the ApplicationController to genericize the tab_counts behaviour,
-  # but one or more new routes would have to be created, the js.erb would also have to be moved
-  def tab_counts
-    @tab_counts = {}
-    show_pane_list.each do |pane|
-      if pane.is_a?(Hash)
-        if pane[:count]
-          @tab_counts[pane[:name]] = pane[:count]
-        elsif pane[:filters]
-          @tab_counts[pane[:name]] = @object.contents(filters: pane[:filters]).items_available
-        end
-      end
-    end
-  end
-
-  def remove_item
-    params[:item_uuids] = [params[:item_uuid]]
-    remove_items
-    render template: 'projects/remove_items'
-  end
-
-  def remove_items
-    @removed_uuids = []
-    params[:item_uuids].collect { |uuid| ArvadosBase.find uuid }.each do |item|
-      if item.class == Collection or item.class == Group or item.class == Workflow or item.class == ContainerRequest
-        # Use delete API on collections and projects/groups
-        item.destroy
-        @removed_uuids << item.uuid
-      elsif item.owner_uuid == @object.uuid
-        # Object is owned by this project. Remove it from the project by
-        # changing owner to the current user.
-        begin
-          item.update_attributes owner_uuid: current_user.uuid
-          @removed_uuids << item.uuid
-        rescue ArvadosApiClient::ApiErrorResponseException => e
-          if e.message.include? '_owner_uuid_'
-            rename_to = item.name + ' removed from ' +
-                        (@object.name ? @object.name : @object.uuid) +
-                        ' at ' + Time.now.to_s
-            updates = {}
-            updates[:name] = rename_to
-            updates[:owner_uuid] = current_user.uuid
-            item.update_attributes updates
-            @removed_uuids << item.uuid
-          else
-            raise
-          end
-        end
-      end
-    end
-  end
-
-  def destroy
-    while (objects = Link.filter([['owner_uuid','=',@object.uuid],
-                                  ['tail_uuid','=',@object.uuid]]).with_count("none")).any?
-      objects.each do |object|
-        object.destroy
-      end
-    end
-    while (objects = @object.contents).any?
-      objects.each do |object|
-        object.update_attributes! owner_uuid: current_user.uuid
-      end
-    end
-    if ArvadosBase::resource_class_for_uuid(@object.owner_uuid) == Group
-      params[:return_to] ||= group_path(@object.owner_uuid)
-    else
-      params[:return_to] ||= projects_path
-    end
-    super
-  end
-
-  def find_objects_for_index
-    # We can use the all_projects helper, but we have to dup the
-    # result -- otherwise, when we apply our per-request filters and
-    # limits, they will infect the @all_projects cache too (see
-    # #6640).
-    @objects = all_projects.dup
-    super
-  end
-
-  def load_contents_objects kinds=[]
-    kind_filters = @filters.select do |attr,op,val|
-      op == 'is_a' and val.is_a? Array and val.count > 1
-    end
-    if /^created_at\b/ =~ @order[0] and kind_filters.count == 1
-      # If filtering on multiple types and sorting by date: Get the
-      # first page of each type, sort the entire set, truncate to one
-      # page, and use the last item on this page as a filter for
-      # retrieving the next page. Ideally the API would do this for
-      # us, but it doesn't (yet).
-
-      # To avoid losing items that have the same created_at as the
-      # last item on this page, we retrieve an overlapping page with a
-      # "created_at <= last_created_at" filter, then remove duplicates
-      # with a "uuid not in [...]" filter (see below).
-      nextpage_operator = /\bdesc$/i =~ @order[0] ? '<=' : '>='
-
-      @objects = []
-      @name_link_for = {}
-      kind_filters.each do |attr,op,val|
-        (val.is_a?(Array) ? val : [val]).each do |type|
-          klass = type.split('#')[-1]
-          klass[0] = klass[0].capitalize
-          next if(!Object.const_get(klass).api_exists?(:index))
-
-          filters = @filters - kind_filters + [['uuid', 'is_a', type]]
-          if type == 'arvados#containerRequest'
-            filters = filters + [['container_requests.requesting_container_uuid', '=', nil]]
-          end
-          objects = @object.contents(order: @order,
-                                     limit: @limit,
-                                     filters: filters,
-                                    )
-          objects.each do |object|
-            @name_link_for[object.andand.uuid] = objects.links_for(object, 'name').first
-          end
-          @objects += objects
-        end
-      end
-      @objects = @objects.to_a.sort_by(&:created_at)
-      @objects.reverse! if nextpage_operator == '<='
-      @objects = @objects[0..@limit-1]
-
-      if @objects.any?
-        @next_page_filters = next_page_filters(nextpage_operator)
-        @next_page_href = url_for(partial: :contents_rows,
-                                  limit: @limit,
-                                  filters: @next_page_filters.to_json)
-      else
-        @next_page_href = nil
-      end
-    else
-      @objects = @object.contents(order: @order,
-                                  limit: @limit,
-                                  filters: @filters,
-                                  offset: @offset)
-      @next_page_href = next_page_href(partial: :contents_rows,
-                                       filters: @filters.to_json,
-                                       order: @order.to_json)
-    end
-
-    preload_links_for_objects(@objects.to_a)
-  end
-
-  def show
-    if !@object
-      return render_not_found("object not found")
-    end
-
-    if params[:partial]
-      load_contents_objects
-      respond_to do |f|
-        f.json {
-          render json: {
-            content: render_to_string(partial: 'show_contents_rows.html',
-                                      formats: [:html]),
-            next_page_href: @next_page_href
-          }
-        }
-      end
-    else
-      @objects = []
-      super
-    end
-  end
-
-  def create
-    @new_resource_attrs = (params['project'] || {}).merge(group_class: 'project')
-    @new_resource_attrs[:name] ||= 'New project'
-    super
-  end
-
-  def update
-    @updates = params['project']
-    super
-  end
-
-  helper_method :get_objects_and_names
-  def get_objects_and_names(objects=nil)
-    objects = @objects if objects.nil?
-    objects_and_names = []
-    objects.each do |object|
-      if objects.respond_to? :links_for and
-          !(name_links = objects.links_for(object, 'name')).empty?
-        name_links.each do |name_link|
-          objects_and_names << [object, name_link]
-        end
-      elsif @name_link_for.andand[object.uuid]
-        objects_and_names << [object, @name_link_for[object.uuid]]
-      elsif object.respond_to? :name
-        objects_and_names << [object, object]
-      else
-        objects_and_names << [object,
-                               Link.new(owner_uuid: @object.uuid,
-                                        tail_uuid: @object.uuid,
-                                        head_uuid: object.uuid,
-                                        link_class: "name",
-                                        name: "")]
-
-      end
-    end
-    objects_and_names
-  end
-
-  def public  # Yes 'public' is the name of the action for public projects
-    return render_not_found if Rails.configuration.Users.AnonymousUserToken.empty? or not Rails.configuration.Workbench.EnablePublicProjectsPage
-    @objects = using_specific_api_token Rails.configuration.Users.AnonymousUserToken do
-      Group.where(group_class: 'project').order("modified_at DESC")
-    end
-  end
-end
diff --git a/apps/workbench/app/controllers/repositories_controller.rb b/apps/workbench/app/controllers/repositories_controller.rb
deleted file mode 100644 (file)
index 953ee1d..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class RepositoriesController < ApplicationController
-  before_action :set_share_links, if: -> { defined? @object }
-
-  def index_pane_list
-    %w(repositories help)
-  end
-
-  def show_pane_list
-    if @user_is_manager
-      panes = super | %w(Sharing)
-      panes.insert(panes.length-1, panes.delete_at(panes.index('Advanced'))) if panes.index('Advanced')
-      panes
-    else
-      panes = super
-    end
-    panes.delete('Attributes') if !current_user.is_admin
-    panes
-  end
-
-  def show_tree
-    @commit = params[:commit]
-    @path = params[:path] || ''
-    @subtree = @object.ls_subtree @commit, @path.chomp('/')
-  end
-
-  def show_blob
-    @commit = params[:commit]
-    @path = params[:path]
-    @blobdata = @object.cat_file @commit, @path
-  end
-
-  def show_commit
-    @commit = params[:commit]
-  end
-
-  def all_repos
-    limit = params[:limit].andand.to_i || 100
-    offset = params[:offset].andand.to_i || 0
-    @filters = params[:filters] || []
-
-    if @filters.any?
-      owner_filter = @filters.select do |attr, op, val|
-        (attr == 'owner_uuid')
-      end
-    end
-
-    if !owner_filter.andand.any?
-      filters = @filters + [["owner_uuid", "=", current_user.uuid]]
-      my_repos = Repository.all.order("name ASC").limit(limit).with_count("none").offset(offset).filter(filters).results
-    else      # done fetching all owned repositories
-      my_repos = []
-    end
-
-    if !owner_filter.andand.any?  # if this is next page request, the first page was still fetching "own" repos
-      @filters = @filters.reject do |attr, op, val|
-        (attr == 'owner_uuid') or
-        (attr == 'name') or
-        (attr == 'uuid')
-      end
-    end
-
-    filters = @filters + [["owner_uuid", "!=", current_user.uuid]]
-    other_repos = Repository.all.order("name ASC").limit(limit).with_count("none").offset(offset).filter(filters).results
-
-    @objects = (my_repos + other_repos).first(limit)
-  end
-
-  def find_objects_for_index
-    return if !params[:partial]
-
-    all_repos
-
-    if @objects.any?
-      @next_page_filters = next_page_filters('>=')
-      @next_page_href = url_for(partial: :repositories_rows,
-                                filters: @next_page_filters.to_json)
-    else
-      @next_page_href = nil
-    end
-  end
-
-  def next_page_href with_params={}
-    @next_page_href
-  end
-
-  def next_page_filters nextpage_operator
-    next_page_filters = @filters.reject do |attr, op, val|
-      (attr == 'owner_uuid') or
-      (attr == 'name' and op == nextpage_operator) or
-      (attr == 'uuid' and op == 'not in')
-    end
-
-    if @objects.any?
-      last_obj = @objects.last
-      next_page_filters += [['name', nextpage_operator, last_obj.name]]
-      next_page_filters += [['uuid', 'not in', [last_obj.uuid]]]
-      # if not-owned, it means we are done with owned repos and fetching other repos
-      next_page_filters += [['owner_uuid', '!=', last_obj.uuid]] if last_obj.owner_uuid != current_user.uuid
-    end
-
-    next_page_filters
-  end
-end
diff --git a/apps/workbench/app/controllers/search_controller.rb b/apps/workbench/app/controllers/search_controller.rb
deleted file mode 100644 (file)
index 80f3ff1..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class SearchController < ApplicationController
-  skip_before_action :ensure_arvados_api_exists
-
-  def find_objects_for_index
-    search_what = Group
-    if params[:project_uuid]
-      # Special case for "search all things in project":
-      @filters = @filters.select do |attr, operator, operand|
-        not (attr == 'owner_uuid' and operator == '=')
-      end
-      # Special case for project_uuid is a user uuid:
-      if ArvadosBase::resource_class_for_uuid(params[:project_uuid]) == User
-        search_what = User.find params[:project_uuid]
-      else
-        search_what = Group.find params[:project_uuid]
-      end
-    end
-    @objects = search_what.contents(limit: @limit,
-                                    offset: @offset,
-                                    recursive: true,
-                                    count: 'none',
-                                    last_object_class: params["last_object_class"],
-                                    filters: @filters)
-    super
-  end
-
-  def next_page_href with_params={}
-    super with_params.merge(last_object_class: @objects.last.class.to_s,
-                            project_uuid: params[:project_uuid],
-                            recursive: true,
-                            count: 'none',
-                            filters: @filters.to_json)
-  end
-end
diff --git a/apps/workbench/app/controllers/sessions_controller.rb b/apps/workbench/app/controllers/sessions_controller.rb
deleted file mode 100644 (file)
index 6557fc0..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class SessionsController < ApplicationController
-  skip_around_action :require_thread_api_token, :only => [:destroy, :logged_out]
-  skip_around_action :set_thread_api_token, :only => [:destroy, :logged_out]
-  skip_before_action :find_object_by_uuid
-  skip_before_action :find_objects_for_index, raise: false
-  skip_before_action :ensure_arvados_api_exists
-
-  def destroy
-    token = session[:arvados_api_token]
-    session.clear
-    redirect_to arvados_api_client.arvados_logout_url(return_to: root_url, api_token: token)
-  end
-
-  def logged_out
-    redirect_to root_url if session[:arvados_api_token]
-    render_index
-  end
-
-  def index
-  end
-end
diff --git a/apps/workbench/app/controllers/specimens_controller.rb b/apps/workbench/app/controllers/specimens_controller.rb
deleted file mode 100644 (file)
index 76a1271..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class SpecimensController < ApplicationController
-end
diff --git a/apps/workbench/app/controllers/status_controller.rb b/apps/workbench/app/controllers/status_controller.rb
deleted file mode 100644 (file)
index 0e45daa..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require "app_version"
-
-class StatusController < ApplicationController
-  skip_around_action :require_thread_api_token
-  skip_before_action :find_object_by_uuid
-  def status
-    # Allow non-credentialed cross-origin requests
-    headers['Access-Control-Allow-Origin'] = '*'
-    resp = {
-      apiBaseURL: arvados_api_client.arvados_v1_base.sub(%r{/arvados/v\d+.*}, '/'),
-      version: AppVersion.hash,
-    }
-    render json: resp
-  end
-end
diff --git a/apps/workbench/app/controllers/tests_controller.rb b/apps/workbench/app/controllers/tests_controller.rb
deleted file mode 100644 (file)
index 73c1f4f..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class TestsController < ApplicationController
-  skip_before_action :find_object_by_uuid
-  def mithril
-  end
-end
diff --git a/apps/workbench/app/controllers/traits_controller.rb b/apps/workbench/app/controllers/traits_controller.rb
deleted file mode 100644 (file)
index 81bded4..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class TraitsController < ApplicationController
-end
diff --git a/apps/workbench/app/controllers/trash_items_controller.rb b/apps/workbench/app/controllers/trash_items_controller.rb
deleted file mode 100644 (file)
index d8f7ae6..0000000
+++ /dev/null
@@ -1,147 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class TrashItemsController < ApplicationController
-  def model_class
-    Collection
-  end
-
-  def index_pane_list
-    %w(Trashed_collections Trashed_projects)
-  end
-
-  def find_objects_for_index
-    # If it's not the index rows partial display, just return
-    # The /index request will again be invoked to display the
-    # partial at which time, we will be using the objects found.
-    return if !params[:partial]
-
-    trashed_items
-
-    if @objects.any?
-      @objects = @objects.sort_by { |obj| obj.modified_at }.reverse
-      @next_page_filters = next_page_filters('<=')
-      @next_page_href = url_for(partial: params[:partial],
-                                filters: @next_page_filters.to_json)
-    else
-      @next_page_href = nil
-    end
-  end
-
-  def next_page_href with_params={}
-    @next_page_href
-  end
-
-  def next_page_filters nextpage_operator
-    next_page_filters = @filters.reject do |attr, op, val|
-      (attr == 'modified_at' and op == nextpage_operator) or
-      (attr == 'uuid' and op == 'not in')
-    end
-
-    if @objects.any?
-      last_trash_at = @objects.last.modified_at
-
-      last_uuids = []
-      @objects.each do |obj|
-        last_uuids << obj.uuid if obj.trash_at.eql?(last_trash_at)
-      end
-
-      next_page_filters += [['modified_at', nextpage_operator, last_trash_at]]
-      next_page_filters += [['uuid', 'not in', last_uuids]]
-    end
-
-    next_page_filters
-  end
-
-  def trashed_items
-    if params[:partial] == "trashed_collection_rows"
-      query_on = Collection
-    elsif params[:partial] == "trashed_project_rows"
-      query_on = Group
-    end
-
-    last_mod_at = nil
-    last_uuids = []
-
-    # API server index doesn't return manifest_text by default, but our
-    # callers want it unless otherwise specified.
-    #@select ||= query_on.columns.map(&:name) - %w(id updated_at)
-    limit = if params[:limit] then params[:limit].to_i else 100 end
-    offset = if params[:offset] then params[:offset].to_i else 0 end
-
-    @objects = []
-    while !@objects.any?
-      base_search = query_on
-
-      if !last_mod_at.nil?
-        base_search = base_search.filter([["modified_at", "<=", last_mod_at], ["uuid", "not in", last_uuids]])
-      end
-
-      base_search = base_search.include_trash(true).limit(limit).with_count("none").offset(offset)
-
-      if params[:filters].andand.length.andand > 0
-        tags = Link.filter(params[:filters]).with_count("none")
-        tagged = []
-        if tags.results.length > 0
-          tagged = query_on.include_trash(true).where(uuid: tags.collect(&:head_uuid))
-        end
-        @objects = (tagged | base_search.filter(params[:filters])).uniq(&:uuid)
-      else
-        @objects = base_search.where(is_trashed: true)
-      end
-
-      if @objects.any?
-        owner_uuids = @objects.collect(&:owner_uuid).uniq
-        @owners = {}
-        @not_trashed = {}
-        [Group, User].each do |owner_class|
-          owner_class.filter([["uuid", "in", owner_uuids]]).with_count("none")
-            .include_trash(true).fetch_multiple_pages(false)
-            .each do |owner|
-            @owners[owner.uuid] = owner
-          end
-        end
-        Group.filter([["uuid", "in", owner_uuids]]).with_count("none").select([:uuid]).each do |grp|
-          @not_trashed[grp.uuid] = true
-        end
-      else
-        return
-      end
-
-      last_mod_at = @objects.last.modified_at
-      last_uuids = []
-      @objects.each do |obj|
-        last_uuids << obj.uuid if obj.modified_at.eql?(last_mod_at)
-      end
-
-      @objects = @objects.select {|item| item.is_trashed || @not_trashed[item.owner_uuid].nil? }
-    end
-  end
-
-  def untrash_items
-    @untrashed_uuids = []
-
-    updates = {trash_at: nil}
-
-    if params[:selection].is_a? Array
-      klass = resource_class_for_uuid(params[:selection][0])
-    else
-      klass = resource_class_for_uuid(params[:selection])
-    end
-
-    first = nil
-    klass.include_trash(1).where(uuid: params[:selection]).each do |c|
-      first = c
-      c.untrash
-      @untrashed_uuids << c.uuid
-    end
-
-    respond_to do |format|
-      format.js
-      format.html do
-        redirect_to first
-      end
-    end
-  end
-end
diff --git a/apps/workbench/app/controllers/user_agreements_controller.rb b/apps/workbench/app/controllers/user_agreements_controller.rb
deleted file mode 100644 (file)
index 5e530a6..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class UserAgreementsController < ApplicationController
-  skip_before_action :check_user_agreements
-  skip_before_action :find_object_by_uuid
-  skip_before_action :check_user_profile
-
-  def index
-    if unsigned_user_agreements.empty?
-      if params[:return_to]
-        redirect_to(params[:return_to])
-      else
-        redirect_back(fallback_location: root_path)
-      end
-    end
-  end
-
-  def model_class
-    Collection
-  end
-
-  def sign
-    params[:checked].each do |checked|
-      if (r = CollectionsHelper.match_uuid_with_optional_filepath(checked))
-        UserAgreement.sign uuid: r[1]
-      end
-    end
-    current_user.activate
-    if params[:return_to]
-      redirect_to(params[:return_to])
-    else
-      redirect_back(fallback_location: root_path)
-    end
-  end
-end
diff --git a/apps/workbench/app/controllers/users_controller.rb b/apps/workbench/app/controllers/users_controller.rb
deleted file mode 100644 (file)
index 21ea7a8..0000000
+++ /dev/null
@@ -1,389 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class UsersController < ApplicationController
-  skip_around_action :require_thread_api_token, only: :welcome
-  skip_before_action :check_user_agreements, only: [:welcome, :inactive, :link_account, :merge]
-  skip_before_action :check_user_profile, only: [:welcome, :inactive, :profile, :link_account, :merge]
-  skip_before_action :find_object_by_uuid, only: [:welcome, :activity, :storage]
-  before_action :ensure_current_user_is_admin, only: [:sudo, :unsetup, :setup]
-
-  def show
-    if params[:uuid] == current_user.uuid
-      respond_to do |f|
-        f.html do
-          if request.url.include?("/users/#{current_user.uuid}")
-            super
-          else
-            redirect_to(params[:return_to] || project_path(params[:uuid]))
-          end
-        end
-      end
-    else
-      super
-    end
-  end
-
-  def welcome
-    if current_user
-      redirect_to (params[:return_to] || '/')
-    end
-  end
-
-  def inactive
-    if current_user.andand.is_invited
-      redirect_to (params[:return_to] || '/')
-    end
-  end
-
-  def profile
-    params[:offer_return_to] ||= params[:return_to]
-
-    # In a federation situation, when you get a user record using
-    # "current user of token" it can fetch a stale user record from
-    # the local cluster. So even if profile settings were just written
-    # to the user record on the login cluster (because the user just
-    # filled out the profile), those profile settings may not appear
-    # in the "current user" response because it is returning a cached
-    # record from the local cluster.
-    #
-    # In this case, explicitly fetching user record forces it to get a
-    # fresh record from the login cluster.
-    Thread.current[:user] = User.find(current_user.uuid)
-  end
-
-  def activity
-    @breadcrumb_page_name = nil
-    @users = User.limit(params[:limit]).with_count("none")
-    @user_activity = {}
-    @activity = {
-      logins: {},
-      jobs: {},
-      pipeline_instances: {}
-    }
-    @total_activity = {}
-    @spans = [['This week', Time.now.beginning_of_week, Time.now],
-              ['Last week',
-               Time.now.beginning_of_week.advance(weeks:-1),
-               Time.now.beginning_of_week],
-              ['This month', Time.now.beginning_of_month, Time.now],
-              ['Last month',
-               1.month.ago.beginning_of_month,
-               Time.now.beginning_of_month]]
-    @spans.each do |span, threshold_start, threshold_end|
-      @activity[:logins][span] = Log.select(%w(uuid modified_by_user_uuid)).
-        filter([[:event_type, '=', 'login'],
-                [:object_kind, '=', 'arvados#user'],
-                [:created_at, '>=', threshold_start],
-                [:created_at, '<', threshold_end]]).with_count("none")
-      @activity[:jobs][span] = Job.select(%w(uuid modified_by_user_uuid)).
-        filter([[:created_at, '>=', threshold_start],
-                [:created_at, '<', threshold_end]]).with_count("none")
-      @activity[:pipeline_instances][span] = PipelineInstance.select(%w(uuid modified_by_user_uuid)).
-        filter([[:created_at, '>=', threshold_start],
-                [:created_at, '<', threshold_end]]).with_count("none")
-      @activity.each do |type, act|
-        records = act[span]
-        @users.each do |u|
-          @user_activity[u.uuid] ||= {}
-          @user_activity[u.uuid][span + ' ' + type.to_s] ||= 0
-        end
-        records.each do |record|
-          @user_activity[record.modified_by_user_uuid] ||= {}
-          @user_activity[record.modified_by_user_uuid][span + ' ' + type.to_s] ||= 0
-          @user_activity[record.modified_by_user_uuid][span + ' ' + type.to_s] += 1
-          @total_activity[span + ' ' + type.to_s] ||= 0
-          @total_activity[span + ' ' + type.to_s] += 1
-        end
-      end
-    end
-    @users = @users.sort_by do |a|
-      [-@user_activity[a.uuid].values.inject(:+), a.full_name]
-    end
-    # Prepend a "Total" pseudo-user to the sorted list
-    @user_activity[nil] = @total_activity
-    @users = [OpenStruct.new(uuid: nil)] + @users
-  end
-
-  def storage
-    @breadcrumb_page_name = nil
-    @users = User.limit(params[:limit]).with_count("none")
-    @user_storage = {}
-    total_storage = {}
-    @log_date = {}
-    @users.each do |u|
-      @user_storage[u.uuid] ||= {}
-      storage_log = Log.
-        filter([[:object_uuid, '=', u.uuid],
-                [:event_type, '=', 'user-storage-report']]).
-        order(:created_at => :desc).
-        with_count('none').
-        limit(1)
-      storage_log.each do |log_entry|
-        # We expect this block to only execute once since we specified limit(1)
-        @user_storage[u.uuid] = log_entry['properties']
-        @log_date[u.uuid] = log_entry['event_at']
-      end
-      total_storage.merge!(@user_storage[u.uuid]) { |k,v1,v2| v1 + v2 }
-    end
-    @users = @users.sort_by { |u|
-      [-@user_storage[u.uuid].values.push(0).inject(:+), u.full_name]}
-    # Prepend a "Total" pseudo-user to the sorted list
-    @users = [OpenStruct.new(uuid: nil)] + @users
-    @user_storage[nil] = total_storage
-  end
-
-  def show_pane_list
-    if current_user.andand.is_admin
-      %w(Admin) | super
-    else
-      super
-    end
-  end
-
-  def index_pane_list
-    if current_user.andand.is_admin
-      super | %w(Activity)
-    else
-      super
-    end
-  end
-
-  def sudo
-    resp = arvados_api_client.api(ApiClientAuthorization, '', {
-                                    api_client_authorization: {
-                                      owner_uuid: @object.uuid
-                                    }
-                                  })
-    redirect_to root_url(api_token: "v2/#{resp[:uuid]}/#{resp[:api_token]}")
-  end
-
-  def home
-    @my_ssh_keys = AuthorizedKey.where(authorized_user_uuid: current_user.uuid)
-    @my_tag_links = {}
-
-    @my_jobs = Job.
-      limit(10).
-      order('created_at desc').
-      with_count('none').
-      where(created_by: current_user.uuid)
-
-    @my_collections = Collection.
-      limit(10).
-      order('created_at desc').
-      with_count('none').
-      where(created_by: current_user.uuid)
-    collection_uuids = @my_collections.collect &:uuid
-
-    @persist_state = {}
-    collection_uuids.each do |uuid|
-      @persist_state[uuid] = 'cache'
-    end
-
-    Link.filter([['head_uuid', 'in', collection_uuids],
-                             ['link_class', 'in', ['tag', 'resources']]]).with_count("none")
-      each do |link|
-      case link.link_class
-      when 'tag'
-        (@my_tag_links[link.head_uuid] ||= []) << link
-      when 'resources'
-        if link.name == 'wants'
-          @persist_state[link.head_uuid] = 'persistent'
-        end
-      end
-    end
-
-    @my_pipelines = PipelineInstance.
-      limit(10).
-      order('created_at desc').
-      with_count('none').
-      where(created_by: current_user.uuid)
-
-    respond_to do |f|
-      f.js { render template: 'users/home.js' }
-      f.html { render template: 'users/home' }
-    end
-  end
-
-  def unsetup
-    if current_user.andand.is_admin
-      @object.unsetup
-    end
-    show
-  end
-
-  def setup
-    respond_to do |format|
-      if current_user.andand.is_admin
-        setup_params = {}
-        setup_params[:send_notification_email] = "#{Rails.configuration.Mail.SendUserSetupNotificationEmail}"
-        if params['user_uuid'] && params['user_uuid'].size>0
-          setup_params[:uuid] = params['user_uuid']
-        end
-        if params['email'] && params['email'].size>0
-          user = {email: params['email']}
-          setup_params[:user] = user
-        end
-        if params['openid_prefix'] && params['openid_prefix'].size>0
-          setup_params[:openid_prefix] = params['openid_prefix']
-        end
-        if params['vm_uuid'] && params['vm_uuid'].size>0
-          setup_params[:vm_uuid] = params['vm_uuid']
-        end
-
-        setup_resp = User.setup setup_params
-        if setup_resp
-          vm_link = nil
-          setup_resp[:items].each do |item|
-            if item[:head_kind] == "arvados#virtualMachine"
-              vm_link = item
-              break
-            end
-          end
-          if params[:groups]
-            new_groups = params[:groups].split(',').map(&:strip).select{|i| !i.empty?}
-            if vm_link and new_groups != vm_link[:properties][:groups]
-              vm_login_link = Link.where(uuid: vm_link[:uuid])
-              if vm_login_link.items_available > 0
-                link = vm_login_link.results.first
-                props = link.properties
-                props[:groups] = new_groups
-                link.save!
-              end
-            end
-          end
-
-          format.js
-        else
-          self.render_error status: 422
-        end
-      else
-        self.render_error status: 422
-      end
-    end
-  end
-
-  def setup_popup
-    @vms = VirtualMachine.all.results
-
-    @current_selections = find_current_links @object
-
-    respond_to do |format|
-      format.html
-      format.js
-    end
-  end
-
-  def virtual_machines
-    @my_vm_logins = {}
-    Link.where(tail_uuid: @object.uuid,
-               link_class: 'permission',
-               name: 'can_login').with_count("none").
-          each do |perm_link|
-            if perm_link.properties.andand[:username]
-              @my_vm_logins[perm_link.head_uuid] ||= []
-              @my_vm_logins[perm_link.head_uuid] << perm_link.properties[:username]
-            end
-          end
-    @my_virtual_machines = VirtualMachine.where(uuid: @my_vm_logins.keys).with_count("none")
-  end
-
-  def ssh_keys
-    @my_ssh_keys = AuthorizedKey.where(key_type: 'SSH', owner_uuid: @object.uuid)
-  end
-
-  def add_ssh_key_popup
-    respond_to do |format|
-      format.html
-      format.js
-    end
-  end
-
-  def add_ssh_key
-    respond_to do |format|
-      key_params = {'key_type' => 'SSH'}
-      key_params['authorized_user_uuid'] = current_user.uuid
-
-      if params['name'] && params['name'].size>0
-        key_params['name'] = params['name'].strip
-      end
-      if params['public_key'] && params['public_key'].size>0
-        key_params['public_key'] = params['public_key'].strip
-      end
-
-      if !key_params['name'] && params['public_key'].andand.size>0
-        split_key = key_params['public_key'].split
-        key_params['name'] = split_key[-1] if (split_key.size == 3)
-      end
-
-      new_key = AuthorizedKey.create! key_params
-      if new_key
-        format.js
-      else
-        self.render_error status: 422
-      end
-    end
-  end
-
-  def request_shell_access
-    logger.warn "request_access: #{params.inspect}"
-    params['request_url'] = request.url
-    RequestShellAccessReporter.send_request(current_user, params).deliver
-  end
-
-  def merge
-    User.merge params[:new_user_token], params[:direction]
-    redirect_to "/"
-  end
-
-  protected
-
-  def find_current_links user
-    current_selections = {}
-
-    if !user
-      return current_selections
-    end
-
-    # oid login perm
-    oid_login_perms = Link.where(tail_uuid: user.email,
-                                   head_kind: 'arvados#user',
-                                   link_class: 'permission',
-                                   name: 'can_login').with_count("none")
-
-    if oid_login_perms.any?
-      prefix_properties = oid_login_perms.first.properties
-      current_selections[:identity_url_prefix] = prefix_properties[:identity_url_prefix]
-    end
-
-    # repo perm
-    repo_perms = Link.where(tail_uuid: user.uuid,
-                            head_kind: 'arvados#repository',
-                            link_class: 'permission',
-                            name: 'can_write').with_count("none")
-    if repo_perms.any?
-      repo_uuid = repo_perms.first.head_uuid
-      repos = Repository.where(head_uuid: repo_uuid).with_count("none")
-      if repos.any?
-        repo_name = repos.first.name
-        current_selections[:repo_name] = repo_name
-      end
-    end
-
-    # vm login perm
-    vm_login_perms = Link.where(tail_uuid: user.uuid,
-                              head_kind: 'arvados#virtualMachine',
-                              link_class: 'permission',
-                              name: 'can_login').with_count("none")
-    if vm_login_perms.any?
-      vm_perm = vm_login_perms.first
-      vm_uuid = vm_perm.head_uuid
-      current_selections[:vm_uuid] = vm_uuid
-      current_selections[:groups] = vm_perm.properties[:groups].andand.join(', ')
-    end
-
-    return current_selections
-  end
-
-end
diff --git a/apps/workbench/app/controllers/virtual_machines_controller.rb b/apps/workbench/app/controllers/virtual_machines_controller.rb
deleted file mode 100644 (file)
index c743773..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class VirtualMachinesController < ApplicationController
-  def index
-    @objects ||= model_class.all
-    @vm_logins = {}
-    if @objects.andand.first
-      Link.where(tail_uuid: current_user.uuid,
-                 head_uuid: @objects.collect(&:uuid),
-                 link_class: 'permission',
-                 name: 'can_login').with_count("none").
-        each do |perm_link|
-        if perm_link.properties.andand[:username]
-          @vm_logins[perm_link.head_uuid] ||= []
-          @vm_logins[perm_link.head_uuid] << perm_link.properties[:username]
-        end
-      end
-      @objects.each do |vm|
-        vm.current_user_logins = @vm_logins[vm.uuid].andand.compact || []
-      end
-    end
-    super
-  end
-
-  def webshell
-    return render_not_found if Rails.configuration.Services.WebShell.ExternalURL == URI("")
-    webshell_url = URI(Rails.configuration.Services.WebShell.ExternalURL)
-    if webshell_url.host.index("*") != nil
-      webshell_url.host = webshell_url.host.sub("*", @object.hostname)
-    else
-      webshell_url.path = "/#{@object.hostname}"
-    end
-    @webshell_url = webshell_url.to_s
-    render layout: false
-  end
-
-end
diff --git a/apps/workbench/app/controllers/websocket_controller.rb b/apps/workbench/app/controllers/websocket_controller.rb
deleted file mode 100644 (file)
index 35993dc..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class WebsocketController < ApplicationController
-  skip_before_action :find_objects_for_index, raise: false
-
-  def index
-  end
-
-  def model_class
-    "Websocket"
-  end
-end
diff --git a/apps/workbench/app/controllers/work_unit_templates_controller.rb b/apps/workbench/app/controllers/work_unit_templates_controller.rb
deleted file mode 100644 (file)
index 0376590..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class WorkUnitTemplatesController < ApplicationController
-  def find_objects_for_index
-    return if !params[:partial]
-
-    @limit = 40
-    @filters = @filters || []
-
-    # get next page of pipeline_templates
-    if PipelineTemplate.api_exists?(:index)
-      filters = @filters + [["uuid", "is_a", ["arvados#pipelineTemplate"]]]
-      pipelines = PipelineTemplate.limit(@limit).with_count("none").order(["created_at desc"]).filter(filters)
-    end
-
-    # get next page of workflows
-    filters = @filters + [["uuid", "is_a", ["arvados#workflow"]]]
-    workflows = Workflow.limit(@limit).order(["created_at desc"]).with_count("none").filter(filters)
-
-    @objects = (pipelines.to_a + workflows.to_a).sort_by(&:created_at).reverse.first(@limit)
-
-    if @objects.any?
-      @next_page_filters = next_page_filters('<=')
-      @next_page_href = url_for(partial: :choose_rows,
-                                filters: @next_page_filters.to_json)
-    else
-      @next_page_href = nil
-    end
-  end
-
-  def next_page_href with_params={}
-    @next_page_href
-  end
-end
diff --git a/apps/workbench/app/controllers/work_units_controller.rb b/apps/workbench/app/controllers/work_units_controller.rb
deleted file mode 100644 (file)
index 86e3cdd..0000000
+++ /dev/null
@@ -1,224 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class WorkUnitsController < ApplicationController
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    'show_child_component' == ctrl.action_name
-  }
-
-  def find_objects_for_index
-    # If it's not the index rows partial display, just return
-    # The /index request will again be invoked to display the
-    # partial at which time, we will be using the objects found.
-    return if !params[:partial]
-
-    @limit = 20
-    @filters = @filters || []
-
-    pipelines = []
-    jobs = []
-
-    # get next page of pipeline_instances
-    if PipelineInstance.api_exists?(:index)
-      filters = @filters + [["uuid", "is_a", ["arvados#pipelineInstance"]]]
-      pipelines = PipelineInstance.limit(@limit).order(["created_at desc"]).filter(filters).with_count("none")
-    end
-
-    if params[:show_children]
-      # get next page of jobs
-      if Job.api_exists?(:index)
-        filters = @filters + [["uuid", "is_a", ["arvados#job"]]]
-        jobs = Job.limit(@limit).order(["created_at desc"]).filter(filters).with_count("none")
-      end
-    end
-
-    # get next page of container_requests
-    filters = @filters + [["uuid", "is_a", ["arvados#containerRequest"]]]
-    if !params[:show_children]
-     filters << ["requesting_container_uuid", "=", nil]
-    end
-    crs = ContainerRequest.limit(@limit).order(["created_at desc"]).filter(filters).with_count("none")
-    @objects = (jobs.to_a + pipelines.to_a + crs.to_a).sort_by(&:created_at).reverse.first(@limit)
-
-    if @objects.any?
-      @next_page_filters = next_page_filters('<=')
-      @next_page_href = url_for(partial: :all_processes_rows,
-                                filters: @next_page_filters.to_json,
-                                show_children: params[:show_children])
-      preload_links_for_objects(@objects.to_a)
-    else
-      @next_page_href = nil
-    end
-  end
-
-  def next_page_href with_params={}
-    @next_page_href
-  end
-
-  def create
-    template_uuid = params['work_unit']['template_uuid']
-
-    attrs = {}
-    rc = resource_class_for_uuid(template_uuid)
-    if rc == PipelineTemplate
-      model_class = PipelineInstance
-      attrs['pipeline_template_uuid'] = template_uuid
-    elsif rc == Workflow
-      # workflow json
-      workflow = Workflow.find? template_uuid
-      if workflow.definition
-        begin
-          wf_json = ActiveSupport::HashWithIndifferentAccess.new YAML::load(workflow.definition)
-        rescue => e
-          logger.error "Error converting definition yaml to json: #{e.message}"
-          raise ArgumentError, "Error converting definition yaml to json: #{e.message}"
-        end
-      end
-
-      model_class = ContainerRequest
-
-      attrs['name'] = "#{workflow['name']} container" if workflow['name'].present?
-      attrs['properties'] = {'template_uuid' => template_uuid}
-      attrs['priority'] = 1
-      attrs['state'] = "Uncommitted"
-      attrs['use_existing'] = false
-
-      # required
-      attrs['container_image'] = "arvados/jobs"
-      attrs['cwd'] = "/var/spool/cwl"
-      attrs['output_path'] = "/var/spool/cwl"
-
-      # runtime constriants
-      runtime_constraints = {
-        "vcpus" => 1,
-        "ram" => 1024 * 1024 * 1024,
-        "API" => true
-      }
-
-      keep_cache = 256
-      input_defaults = {}
-      if wf_json
-        main = get_cwl_main(wf_json)
-        main[:inputs].each do |input|
-          if input[:default]
-            input_defaults[cwl_shortname(input[:id])] = input[:default]
-          end
-        end
-        if main[:hints]
-          main[:hints].each do |hint|
-            if hint[:class] == "http://arvados.org/cwl#WorkflowRunnerResources"
-              if hint[:coresMin]
-                runtime_constraints["vcpus"] = hint[:coresMin]
-              end
-              if hint[:ramMin]
-                runtime_constraints["ram"] = hint[:ramMin] * 1024 * 1024
-              end
-              if hint[:keep_cache]
-                keep_cache = hint[:keep_cache]
-              end
-              if hint[:acrContainerImage]
-                attrs['container_image'] = hint[:acrContainerImage]
-              end
-            end
-          end
-        end
-      end
-
-      attrs['command'] = ["arvados-cwl-runner",
-                          "--enable-reuse",
-                          "--local",
-                          "--api=containers",
-                          "--project-uuid=#{params['work_unit']['owner_uuid']}",
-                          "--collection-cache-size=#{keep_cache}",
-                          "/var/lib/cwl/workflow.json#main",
-                          "/var/lib/cwl/cwl.input.json"]
-
-      # mounts
-      mounts = {
-        "/var/lib/cwl/cwl.input.json" => {
-          "kind" => "json",
-          "content" => input_defaults
-        },
-        "stdout" => {
-          "kind" => "file",
-          "path" => "/var/spool/cwl/cwl.output.json"
-        },
-        "/var/spool/cwl" => {
-          "kind" => "collection",
-          "writable" => true
-        }
-      }
-      if wf_json
-        mounts["/var/lib/cwl/workflow.json"] = {
-          "kind" => "json",
-          "content" => wf_json
-        }
-      end
-      attrs['mounts'] = mounts
-
-      attrs['runtime_constraints'] = runtime_constraints
-    else
-      raise ArgumentError, "Unsupported template uuid: #{template_uuid}"
-    end
-
-    attrs['owner_uuid'] = params['work_unit']['owner_uuid']
-    @object ||= model_class.new attrs
-
-    if @object.save
-      redirect_to @object
-    else
-      render_error status: 422
-    end
-  end
-
-  def find_object_by_uuid
-    if params['object_type']
-      @object = params['object_type'].constantize.find(params['uuid'])
-    else
-      super
-    end
-  end
-
-  def show_child_component
-    data = JSON.load(params[:action_data])
-
-    current_obj = {}
-    current_obj_uuid = data['current_obj_uuid']
-    current_obj_name = data['current_obj_name']
-    current_obj_type = data['current_obj_type']
-    current_obj_parent = data['current_obj_parent']
-    if current_obj_uuid
-      resource_class = resource_class_for_uuid current_obj_uuid
-      obj = object_for_dataclass(resource_class, current_obj_uuid)
-      current_obj = obj if obj
-    end
-
-    if current_obj.is_a?(Hash) and !current_obj.any?
-      if current_obj_parent
-        resource_class = resource_class_for_uuid current_obj_parent
-        parent = object_for_dataclass(resource_class, current_obj_parent)
-        parent_wu = parent.work_unit
-        children = parent_wu.children
-        if current_obj_uuid
-          wu = children.select {|c| c.uuid == current_obj_uuid}.first
-        else current_obj_name
-          wu = children.select {|c| c.label.to_s == current_obj_name}.first
-        end
-      end
-    else
-      if current_obj_type == JobWorkUnit.to_s
-        wu = JobWorkUnit.new(current_obj, current_obj_name, current_obj_parent)
-      elsif current_obj_type == PipelineInstanceWorkUnit.to_s
-        wu = PipelineInstanceWorkUnit.new(current_obj, current_obj_name, current_obj_parent)
-      elsif current_obj_type == ContainerWorkUnit.to_s
-        wu = ContainerWorkUnit.new(current_obj, current_obj_name, current_obj_parent)
-      end
-    end
-
-    respond_to do |f|
-      f.html { render(partial: "show_component", locals: {wu: wu}) }
-    end
-  end
-end
diff --git a/apps/workbench/app/controllers/workflows_controller.rb b/apps/workbench/app/controllers/workflows_controller.rb
deleted file mode 100644 (file)
index 4d78ca7..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class WorkflowsController < ApplicationController
-  skip_around_action :require_thread_api_token, if: proc { |ctrl|
-    !Rails.configuration.Users.AnonymousUserToken.empty? and
-    'show' == ctrl.action_name
-  }
-
-  def show_pane_list
-    %w(Definition Advanced)
-  end
-end
diff --git a/apps/workbench/app/helpers/application_helper.rb b/apps/workbench/app/helpers/application_helper.rb
deleted file mode 100644 (file)
index 697c469..0000000
+++ /dev/null
@@ -1,701 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module ApplicationHelper
-  def current_user
-    controller.current_user
-  end
-
-  def self.match_uuid(uuid)
-    /^([0-9a-z]{5})-([0-9a-z]{5})-([0-9a-z]{15})$/.match(uuid.to_s)
-  end
-
-  def current_api_host
-    if Rails.configuration.Services.Controller.ExternalURL.port == 443
-      "#{Rails.configuration.Services.Controller.ExternalURL.hostname}"
-    else
-      "#{Rails.configuration.Services.Controller.ExternalURL.hostname}:#{Rails.configuration.Services.Controller.ExternalURL.port}"
-    end
-  end
-
-  def current_uuid_prefix
-    Rails.configuration.ClusterID
-  end
-
-  def render_markup(markup)
-    allowed_tags = Rails::Html::Sanitizer.white_list_sanitizer.allowed_tags + %w(table tbody th tr td col colgroup caption thead tfoot)
-    sanitize(raw(RedCloth.new(markup.to_s).to_html(:refs_arvados, :textile)), tags: allowed_tags) if markup
-  end
-
-  def human_readable_bytes_html(n)
-    return h(n) unless n.is_a? Integer
-    return "0 bytes" if (n == 0)
-
-    orders = {
-      1 => "bytes",
-      1024 => "KiB",
-      (1024*1024) => "MiB",
-      (1024*1024*1024) => "GiB",
-      (1024*1024*1024*1024) => "TiB"
-    }
-
-    orders.each do |k, v|
-      sig = (n.to_f/k)
-      if sig >=1 and sig < 1024
-        if v == 'bytes'
-          return "%i #{v}" % sig
-        else
-          return "%0.1f #{v}" % sig
-        end
-      end
-    end
-
-    return h(n)
-  end
-
-  def resource_class_for_uuid(attrvalue, opts={})
-    ArvadosBase::resource_class_for_uuid(attrvalue, opts)
-  end
-
-  # When using {remote:true}, or using {method:...} to use an HTTP
-  # method other than GET, move the target URI from href to
-  # data-remote-href. Otherwise, browsers offer features like "open in
-  # new window" and "copy link address" which bypass Rails' click
-  # handler and therefore end up at incorrect/nonexistent routes (by
-  # ignoring data-method) and expect to receive pages rather than
-  # javascript responses.
-  #
-  # See assets/javascripts/link_to_remote.js for supporting code.
-  def link_to *args, &block
-    if (args.last and args.last.is_a? Hash and
-        (args.last[:remote] or
-         (args.last[:method] and
-          args.last[:method].to_s.upcase != 'GET')))
-      if Rails.env.test?
-        # Capybara/phantomjs can't click_link without an href, even if
-        # the click handler means it never gets used.
-        raw super.gsub(' href="', ' href="#" data-remote-href="')
-      else
-        # Regular browsers work as desired: users can click A elements
-        # without hrefs, and click handlers fire; but there's no "copy
-        # link address" option in the right-click menu.
-        raw super.gsub(' href="', ' data-remote-href="')
-      end
-    else
-      super
-    end
-  end
-
-  ##
-  # Returns HTML that links to the Arvados object specified in +attrvalue+
-  # Provides various output control and styling options.
-  #
-  # +attrvalue+ an Arvados model object or uuid
-  #
-  # +opts+ a set of flags to control output:
-  #
-  # [:link_text] the link text to use (may include HTML), overrides everything else
-  #
-  # [:friendly_name] whether to use the "friendly" name in the link text (by
-  # calling #friendly_link_name on the object), otherwise use the uuid
-  #
-  # [:with_class_name] prefix the link text with the class name of the model
-  #
-  # [:no_tags] disable tags in the link text (default is to show tags).
-  # Currently tags are only shown for Collections.
-  #
-  # [:thumbnail] if the object is a collection, show an image thumbnail if the
-  # collection consists of a single image file.
-  #
-  # [:no_link] don't create a link, just return the link text
-  #
-  # +style_opts+ additional HTML properties for the anchor tag, passed to link_to
-  #
-  def link_to_if_arvados_object(attrvalue, opts={}, style_opts={})
-    if (resource_class = resource_class_for_uuid(attrvalue, opts))
-      if attrvalue.is_a? ArvadosBase
-        object = attrvalue
-        link_uuid = attrvalue.uuid
-      else
-        object = nil
-        link_uuid = attrvalue
-      end
-      link_name = opts[:link_text]
-      tags = ""
-      if !link_name
-        link_name = object.andand.default_name || resource_class.default_name
-
-        if opts[:friendly_name]
-          if attrvalue.respond_to? :friendly_link_name
-            link_name = attrvalue.friendly_link_name opts[:lookup]
-          else
-            begin
-              if resource_class.name == 'Collection'
-                if CollectionsHelper.match(link_uuid)
-                  link_name = collection_for_pdh(link_uuid).andand.first.andand.portable_data_hash
-                else
-                  link_name = collections_for_object(link_uuid).andand.first.andand.friendly_link_name
-                end
-              else
-                link_name = object_for_dataclass(resource_class, link_uuid).andand.friendly_link_name
-              end
-            rescue ArvadosApiClient::NotFoundException
-              # If that lookup failed, the link will too. So don't make one.
-              return attrvalue
-            end
-          end
-        end
-        if link_name.nil? or link_name.empty?
-          link_name = attrvalue
-        end
-        if opts[:with_class_name]
-          link_name = "#{resource_class.to_s}: #{link_name}"
-        end
-        if !opts[:no_tags] and resource_class == Collection
-          links_for_object(link_uuid).each do |tag|
-            if tag.link_class.in? ["tag", "identifier"]
-              tags += ' <span class="label label-info">'
-              tags += link_to tag.name, controller: "links", filters: [["link_class", "=", "tag"], ["name", "=", tag.name]].to_json
-              tags += '</span>'
-            end
-          end
-        end
-        if opts[:thumbnail] and resource_class == Collection
-          # add an image thumbnail if the collection consists of a single image file.
-          collections_for_object(link_uuid).each do |c|
-            if c.files.length == 1 and CollectionsHelper::is_image c.files.first[1]
-              link_name += " "
-              link_name += image_tag "#{url_for c}/#{CollectionsHelper::file_path c.files.first}", style: "height: 4em; width: auto"
-            end
-          end
-        end
-      end
-      style_opts[:class] = (style_opts[:class] || '') + ' nowrap'
-      if opts[:no_link] or (resource_class == User && !current_user)
-        raw(link_name)
-      else
-        controller_class = resource_class.to_s.tableize
-        if controller_class.eql?('groups') and (object.andand.group_class.eql?('project') or object.andand.group_class.eql?('filter'))
-          controller_class = 'projects'
-        end
-        (link_to raw(link_name), { controller: controller_class, action: 'show', id: ((opts[:name_link].andand.uuid) || link_uuid) }, style_opts) + raw(tags)
-      end
-    else
-      # just return attrvalue if it is not recognizable as an Arvados object or uuid.
-      if attrvalue.nil? or (attrvalue.is_a? String and attrvalue.empty?)
-        "(none)"
-      else
-        attrvalue
-      end
-    end
-  end
-
-  def link_to_arvados_object_if_readable(attrvalue, link_text_if_not_readable, opts={})
-    resource_class = resource_class_for_uuid(attrvalue.split('/')[0]) if attrvalue.is_a?(String)
-    if !resource_class
-      return link_to_if_arvados_object attrvalue, opts
-    end
-
-    readable = object_readable attrvalue, resource_class
-    if readable
-      link_to_if_arvados_object attrvalue, opts
-    elsif opts[:required] and current_user # no need to show this for anonymous user
-      raw('<div><input type="text" style="border:none;width:100%;background:#ffdddd" disabled=true class="required unreadable-input" value="') + link_text_if_not_readable + raw('" ></input></div>')
-    else
-      link_text_if_not_readable
-    end
-  end
-
-  # This method takes advantage of preloaded collections and objects.
-  # Hence you can improve performance by first preloading objects
-  # related to the page context before using this method.
-  def object_readable attrvalue, resource_class=nil
-    # if it is a collection filename, check readable for the locator
-    attrvalue = attrvalue.split('/')[0] if attrvalue
-
-    resource_class = resource_class_for_uuid(attrvalue) if resource_class.nil?
-    return if resource_class.nil?
-
-    return_value = nil
-    if resource_class.to_s == 'Collection'
-      if CollectionsHelper.match(attrvalue)
-        found = collection_for_pdh(attrvalue)
-        return_value = found.first if found.any?
-      else
-        found = collections_for_object(attrvalue)
-        return_value = found.first if found.any?
-      end
-    else
-      return_value = object_for_dataclass(resource_class, attrvalue)
-    end
-    return_value
-  end
-
-  # Render an editable attribute with the attrvalue of the attr.
-  # The htmloptions are added to the editable element's list of attributes.
-  # The nonhtml_options are only used to customize the display of the element.
-  def render_editable_attribute(object, attr, attrvalue=nil, htmloptions={}, nonhtml_options={})
-    attrvalue = object.send(attr) if attrvalue.nil?
-    if not object.attribute_editable?(attr)
-      if attrvalue && attrvalue.length > 0
-        return render_attribute_as_textile( object, attr, attrvalue, false )
-      else
-        return (attr == 'name' and object.andand.default_name) ||
-                '(none)'
-      end
-    end
-
-    input_type = 'text'
-    opt_selection = nil
-    attrtype = object.class.attribute_info[attr.to_sym].andand[:type]
-    if attrtype == 'text' or attr == 'description'
-      input_type = 'textarea'
-    elsif attrtype == 'datetime'
-      input_type = 'date'
-    elsif attrtype == 'boolean'
-      input_type = 'select'
-      opt_selection = ([{value: "true", text: "true"}, {value: "false", text: "false"}]).to_json
-    else
-      input_type = 'text'
-    end
-
-    attrvalue = attrvalue.to_json if attrvalue.is_a? Hash or attrvalue.is_a? Array
-    rendervalue = render_attribute_as_textile( object, attr, attrvalue, false )
-
-    ajax_options = {
-      "data-pk" => {
-        id: object.uuid,
-        key: object.class.to_s.underscore
-      }
-    }
-    if object.uuid
-      ajax_options['data-url'] = url_for(action: "update", id: object.uuid, controller: object.class.to_s.pluralize.underscore)
-    else
-      ajax_options['data-url'] = url_for(action: "create", controller: object.class.to_s.pluralize.underscore)
-      ajax_options['data-pk'][:defaults] = object.attributes
-    end
-    ajax_options['data-pk'] = ajax_options['data-pk'].to_json
-    @unique_id ||= (Time.now.to_f*1000000).to_i
-    span_id = object.uuid.to_s + '-' + attr.to_s + '-' + (@unique_id += 1).to_s
-
-    span_tag = content_tag 'span', rendervalue, {
-      "data-emptytext" => '(none)',
-      "data-placement" => "bottom",
-      "data-type" => input_type,
-      "data-source" => opt_selection,
-      "data-title" => "Edit #{attr.to_s.gsub '_', ' '}",
-      "data-name" => htmloptions['selection_name'] || attr,
-      "data-object-uuid" => object.uuid,
-      "data-toggle" => "manual",
-      "data-value" => htmloptions['data-value'] || attrvalue,
-      "id" => span_id,
-      :class => "editable #{is_textile?( object, attr ) ? 'editable-textile' : ''}"
-    }.merge(htmloptions).merge(ajax_options)
-
-    edit_tiptitle = 'edit'
-    edit_tiptitle = 'Warning: do not use hyphens in the repository name as they will be stripped' if (object.class.to_s == 'Repository' and attr == 'name')
-
-    edit_button = raw('<a href="#" class="btn btn-xs btn-' + (nonhtml_options[:btnclass] || 'default') + ' btn-nodecorate" data-toggle="x-editable tooltip" data-toggle-selector="#' + span_id + '" data-placement="top" title="' + (nonhtml_options[:tiptitle] || edit_tiptitle) + '"><i class="fa fa-fw fa-pencil"></i>' + (nonhtml_options[:btntext] || '') + '</a>')
-
-    if nonhtml_options[:btnplacement] == :left
-      edit_button + ' ' + span_tag
-    elsif nonhtml_options[:btnplacement] == :top
-      edit_button + raw('<br/>') + span_tag
-    else
-      span_tag + ' ' + edit_button
-    end
-  end
-
-  def render_pipeline_component_attribute(object, attr, subattr, value_info, htmloptions={})
-    datatype = nil
-    required = true
-    attrvalue = value_info
-
-    if value_info.is_a? Hash
-      if value_info[:output_of]
-        return raw("<span class='label label-default'>#{value_info[:output_of]}</span>")
-      end
-      if value_info[:dataclass]
-        dataclass = value_info[:dataclass]
-      end
-      if value_info[:optional] != nil
-        required = (value_info[:optional] != "true")
-      end
-      if value_info[:required] != nil
-        required = value_info[:required]
-      end
-
-      # Pick a suitable attrvalue to show as the current value (i.e.,
-      # the one that would be used if we ran the pipeline right now).
-      if value_info[:value]
-        attrvalue = value_info[:value]
-      elsif value_info[:default]
-        attrvalue = value_info[:default]
-      else
-        attrvalue = ''
-      end
-      preconfigured_search_str = value_info[:search_for]
-    end
-
-    if not object.andand.attribute_editable?(attr)
-      return link_to_arvados_object_if_readable(attrvalue, attrvalue, {friendly_name: true, required: required})
-    end
-
-    if dataclass
-      begin
-        dataclass = dataclass.constantize
-      rescue NameError
-      end
-    else
-      dataclass = ArvadosBase.resource_class_for_uuid(attrvalue)
-    end
-
-    id = "#{object.uuid}-#{subattr.join('-')}"
-    dn = "[#{attr}]"
-    subattr.each do |a|
-      dn += "[#{a}]"
-    end
-    if value_info.is_a? Hash
-      dn += '[value]'
-    end
-
-    if (dataclass == Collection) or (dataclass == File)
-      selection_param = object.class.to_s.underscore + dn
-      display_value = attrvalue
-      if value_info.is_a?(Hash)
-        if (link = Link.find? value_info[:link_uuid])
-          display_value = link.name
-        elsif value_info[:link_name]
-          display_value = value_info[:link_name]
-        elsif (sn = value_info[:selection_name]) && sn != ""
-          display_value = sn
-        end
-      end
-      if (attr == :components) and (subattr.size > 2)
-        chooser_title = "Choose a #{dataclass == Collection ? 'dataset' : 'file'} for #{object.component_input_title(subattr[0], subattr[2])}:"
-      else
-        chooser_title = "Choose a #{dataclass == Collection ? 'dataset' : 'file'}:"
-      end
-      modal_path = choose_collections_path \
-      ({ title: chooser_title,
-         filters: [['owner_uuid', '=', object.owner_uuid]].to_json,
-         action_name: 'OK',
-         action_href: pipeline_instance_path(id: object.uuid),
-         action_method: 'patch',
-         preconfigured_search_str: (preconfigured_search_str || ""),
-         action_data: {
-           merge: true,
-           use_preview_selection: dataclass == File ? true : nil,
-           selection_param: selection_param,
-           success: 'page-refresh'
-         }.to_json,
-        })
-
-      return content_tag('div', :class => 'input-group') do
-        html = text_field_tag(dn, display_value,
-                              :class =>
-                              "form-control #{'required' if required} #{'unreadable-input' if attrvalue.present? and !object_readable(attrvalue, Collection)}")
-        html + content_tag('span', :class => 'input-group-btn') do
-          link_to('Choose',
-                  modal_path,
-                  { :class => "btn btn-primary",
-                    :remote => true,
-                    :method => 'get',
-                  })
-        end
-      end
-    end
-
-    if attrvalue.is_a? String
-      datatype = 'text'
-    elsif attrvalue.is_a?(Array) or dataclass.andand.is_a?(Class)
-      # TODO: find a way to edit with x-editable
-      return attrvalue
-    end
-
-    # When datatype is a String or Fixnum, link_to the attrvalue
-    lt = link_to attrvalue, '#', {
-      "data-emptytext" => "none",
-      "data-placement" => "bottom",
-      "data-type" => datatype,
-      "data-url" => url_for(action: "update", id: object.uuid, controller: object.class.to_s.pluralize.underscore, merge: true),
-      "data-title" => "Set value for #{subattr[-1].to_s}",
-      "data-name" => dn,
-      "data-pk" => "{id: \"#{object.uuid}\", key: \"#{object.class.to_s.underscore}\"}",
-      "data-value" => attrvalue,
-      # "clear" button interferes with form-control's up/down arrows
-      "data-clear" => false,
-      :class => "editable #{'required' if required} form-control",
-      :id => id
-    }.merge(htmloptions)
-
-    lt
-  end
-
-  def get_cwl_main(workflow)
-    if workflow[:"$graph"].nil?
-      return workflow
-    else
-      workflow[:"$graph"].each do |tool|
-        if tool[:id] == "#main"
-          return tool
-        end
-      end
-    end
-  end
-
-  def get_cwl_inputs(workflow)
-    get_cwl_main(workflow)[:inputs]
-  end
-
-
-  def cwl_shortname(id)
-    if id[0] == "#"
-      id = id[1..-1]
-    end
-    return id.split("/")[-1]
-  end
-
-  def cwl_input_info(input_schema)
-    required = !(input_schema[:type].include? "null")
-    if input_schema[:type].is_a? Array
-      primary_type = input_schema[:type].select { |n| n != "null" }[0]
-    elsif input_schema[:type].is_a? String
-      primary_type = input_schema[:type]
-    elsif input_schema[:type].is_a? Hash
-      primary_type = input_schema[:type]
-    end
-    param_id = cwl_shortname(input_schema[:id])
-    return required, primary_type, param_id
-  end
-
-  def cwl_input_value(object, input_schema, set_attr_path)
-    dn = ""
-    attrvalue = object
-    set_attr_path.each do |a|
-      dn += "[#{a}]"
-      attrvalue = attrvalue[a.to_sym]
-    end
-    return dn, attrvalue
-  end
-
-  def cwl_inputs_required(object, inputs_schema, set_attr_path)
-    r = 0
-    inputs_schema.each do |input|
-      required, _, param_id = cwl_input_info(input)
-      _, attrvalue = cwl_input_value(object, input, set_attr_path + [param_id])
-      r += 1 if required and attrvalue.nil?
-    end
-    r
-  end
-
-  def render_cwl_input(object, input_schema, set_attr_path, htmloptions={})
-    required, primary_type, param_id = cwl_input_info(input_schema)
-
-    dn, attrvalue = cwl_input_value(object, input_schema, set_attr_path + [param_id])
-    attrvalue = if attrvalue.nil? then "" else attrvalue end
-
-    id = "#{object.uuid}-#{param_id}"
-
-    opt_empty_selection = if required then [] else [{value: "", text: ""}] end
-
-    if ["Directory", "File"].include? primary_type
-      chooser_title = "Choose a #{primary_type == 'Directory' ? 'dataset' : 'file'}:"
-      selection_param = object.class.to_s.underscore + dn
-      if attrvalue.is_a? Hash
-        display_value = attrvalue[:"http://arvados.org/cwl#collectionUUID"] || attrvalue[:"arv:collection"] || attrvalue[:location]
-        re = CollectionsHelper.match_uuid_with_optional_filepath(display_value)
-        locationre = CollectionsHelper.match(attrvalue[:location][5..-1])
-        if re
-          if locationre and locationre[4]
-            display_value = "#{Collection.find(re[1]).name} / #{locationre[4][1..-1]}"
-          else
-            display_value = Collection.find(re[1]).name
-          end
-        end
-      end
-      modal_path = choose_collections_path \
-      ({ title: chooser_title,
-         filters: [['owner_uuid', '=', object.owner_uuid]].to_json,
-         action_name: 'OK',
-         action_href: container_request_path(id: object.uuid),
-         action_method: 'patch',
-         preconfigured_search_str: "",
-         action_data: {
-           merge: true,
-           use_preview_selection: primary_type == 'File' ? true : nil,
-           selection_param: selection_param,
-           success: 'page-refresh'
-         }.to_json,
-        })
-
-      return content_tag('div', :class => 'input-group') do
-        html = text_field_tag(dn, display_value,
-                              :class =>
-                              "form-control #{'required' if required}")
-        html + content_tag('span', :class => 'input-group-btn') do
-          link_to('Choose',
-                  modal_path,
-                  { :class => "btn btn-primary",
-                    :remote => true,
-                    :method => 'get',
-                  })
-        end
-      end
-    elsif "boolean" == primary_type
-      return link_to attrvalue.to_s, '#', {
-                     "data-emptytext" => "none",
-                     "data-placement" => "bottom",
-                     "data-type" => "select",
-                     "data-source" => (opt_empty_selection + [{value: "true", text: "true"}, {value: "false", text: "false"}]).to_json,
-                     "data-url" => url_for(action: "update", id: object.uuid, controller: object.class.to_s.pluralize.underscore, merge: true),
-                     "data-title" => "Set value for #{cwl_shortname(input_schema[:id])}",
-                     "data-name" => dn,
-                     "data-pk" => "{id: \"#{object.uuid}\", key: \"#{object.class.to_s.underscore}\"}",
-                     "data-value" => attrvalue.to_s,
-                     # "clear" button interferes with form-control's up/down arrows
-                     "data-clear" => false,
-                     :class => "editable #{'required' if required} form-control",
-                     :id => id
-                   }.merge(htmloptions)
-    elsif primary_type.is_a? Hash and primary_type[:type] == "enum"
-      return link_to attrvalue, '#', {
-                     "data-emptytext" => "none",
-                     "data-placement" => "bottom",
-                     "data-type" => "select",
-                     "data-source" => (opt_empty_selection + primary_type[:symbols].map {|i| {:value => cwl_shortname(i), :text => cwl_shortname(i)} }).to_json,
-                     "data-url" => url_for(action: "update", id: object.uuid, controller: object.class.to_s.pluralize.underscore, merge: true),
-                     "data-title" => "Set value for #{cwl_shortname(input_schema[:id])}",
-                     "data-name" => dn,
-                     "data-pk" => "{id: \"#{object.uuid}\", key: \"#{object.class.to_s.underscore}\"}",
-                     "data-value" => attrvalue,
-                     # "clear" button interferes with form-control's up/down arrows
-                     "data-clear" => false,
-                     :class => "editable #{'required' if required} form-control",
-                     :id => id
-                   }.merge(htmloptions)
-    elsif primary_type.is_a? String
-      if ["int", "long"].include? primary_type
-        datatype = "number"
-      else
-        datatype = "text"
-      end
-
-      return link_to attrvalue, '#', {
-                     "data-emptytext" => "none",
-                     "data-placement" => "bottom",
-                     "data-type" => datatype,
-                     "data-url" => url_for(action: "update", id: object.uuid, controller: object.class.to_s.pluralize.underscore, merge: true),
-                     "data-title" => "Set value for #{cwl_shortname(input_schema[:id])}",
-                     "data-name" => dn,
-                     "data-pk" => "{id: \"#{object.uuid}\", key: \"#{object.class.to_s.underscore}\"}",
-                     "data-value" => attrvalue,
-                     # "clear" button interferes with form-control's up/down arrows
-                     "data-clear" => false,
-                     :class => "editable #{'required' if required} form-control",
-                     :id => id
-                     }.merge(htmloptions)
-    else
-      return "Unable to render editing control for parameter type #{primary_type}"
-    end
-  end
-
-  def render_arvados_object_list_start(list, button_text, button_href,
-                                       params={}, *rest, &block)
-    show_max = params.delete(:show_max) || 3
-    params[:class] ||= 'btn btn-xs btn-default'
-    list[0...show_max].each { |item| yield item }
-    unless list[show_max].nil?
-      link_to(h(button_text) +
-              raw(' &nbsp; <i class="fa fa-fw fa-arrow-circle-right"></i>'),
-              button_href, params, *rest)
-    end
-  end
-
-  def render_controller_partial partial, opts
-    cname = opts.delete :controller_name
-    begin
-      render opts.merge(partial: "#{cname}/#{partial}")
-    rescue ActionView::MissingTemplate
-      render opts.merge(partial: "application/#{partial}")
-    end
-  end
-
-  RESOURCE_CLASS_ICONS = {
-    "Collection" => "fa-archive",
-    "ContainerRequest" => "fa-gears",
-    "Group" => "fa-users",
-    "Human" => "fa-male",  # FIXME: Use a more inclusive icon.
-    "Job" => "fa-gears",
-    "KeepDisk" => "fa-hdd-o",
-    "KeepService" => "fa-exchange",
-    "Link" => "fa-arrows-h",
-    "Node" => "fa-cloud",
-    "PipelineInstance" => "fa-gears",
-    "PipelineTemplate" => "fa-gears",
-    "Repository" => "fa-code-fork",
-    "Specimen" => "fa-flask",
-    "Trait" => "fa-clipboard",
-    "User" => "fa-user",
-    "VirtualMachine" => "fa-terminal",
-    "Workflow" => "fa-gears",
-  }
-  DEFAULT_ICON_CLASS = "fa-cube"
-
-  def fa_icon_class_for_class(resource_class, default=DEFAULT_ICON_CLASS)
-    RESOURCE_CLASS_ICONS.fetch(resource_class.to_s, default)
-  end
-
-  def fa_icon_class_for_uuid(uuid, default=DEFAULT_ICON_CLASS)
-    fa_icon_class_for_class(resource_class_for_uuid(uuid), default)
-  end
-
-  def fa_icon_class_for_object(object, default=DEFAULT_ICON_CLASS)
-    case class_name = object.class.to_s
-    when "Group"
-      object.group_class ? 'fa-folder' : 'fa-users'
-    else
-      RESOURCE_CLASS_ICONS.fetch(class_name, default)
-    end
-  end
-
-  def chooser_preview_url_for object, use_preview_selection=false
-    case object.class.to_s
-    when 'Collection'
-      polymorphic_path(object, tab_pane: 'chooser_preview', use_preview_selection: use_preview_selection)
-    else
-      nil
-    end
-  end
-
-  def render_attribute_as_textile( object, attr, attrvalue, truncate )
-    if attrvalue && (is_textile? object, attr)
-      markup = render_markup attrvalue
-      markup = markup[0,markup.index('</p>')+4] if (truncate && markup.index('</p>'))
-      return markup
-    else
-      return attrvalue
-    end
-  end
-
-  def render_localized_date(date, opts="")
-    raw("<span class='utc-date' data-utc-date='#{date}' data-utc-date-opts='noseconds'>#{date}</span>")
-  end
-
-  def render_time duration, use_words, round_to_min=true
-    render_runtime duration, use_words, round_to_min
-  end
-
-  # Keep locators are expected to be of the form \"...<pdh/file_path>\" or \"...<uuid/file_path>\"
-  JSON_KEEP_LOCATOR_REGEXP = /([0-9a-f]{32}\+\d+[^'"]*|[a-z0-9]{5}-4zz18-[a-z0-9]{15}[^'"]*)(?=['"]|\z|$)/
-  def keep_locator_in_json str
-    # Return a list of all matches
-    str.scan(JSON_KEEP_LOCATOR_REGEXP).flatten
-  end
-
-private
-  def is_textile?( object, attr )
-    object.textile_attributes.andand.include?(attr)
-  end
-end
diff --git a/apps/workbench/app/helpers/arvados_api_client_helper.rb b/apps/workbench/app/helpers/arvados_api_client_helper.rb
deleted file mode 100644 (file)
index 929b649..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module ArvadosApiClientHelper
-  def arvados_api_client
-    ArvadosApiClient.new_or_current
-  end
-end
-
-# For the benefit of themes that still expect $arvados_api_client to work:
-class ArvadosClientProxyHack
-  def method_missing *args
-    ArvadosApiClient.new_or_current.send(*args)
-  end
-end
-$arvados_api_client = ArvadosClientProxyHack.new
diff --git a/apps/workbench/app/helpers/collections_helper.rb b/apps/workbench/app/helpers/collections_helper.rb
deleted file mode 100644 (file)
index 0c89ca8..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module CollectionsHelper
-  def d3ify_links(links)
-    links.collect do |x|
-      {source: x.tail_uuid, target: x.head_uuid, type: x.name}
-    end
-  end
-
-  ##
-  # Regex match for collection portable data hash, returns a regex match object with the
-  # hash in group 1, (optional) size in group 2, (optional) subsequent uuid
-  # fields in group 3, and (optional) file path within the collection as group
-  # 4
-  # returns nil for no match.
-  #
-  # +pdh+ the portable data hash string to match
-  #
-  def self.match(pdh)
-    /^([a-f0-9]{32})(\+\d+)(\+[^+]+)*?(\/.*)?$/.match(pdh.to_s)
-  end
-
-  ##
-  # Regex match for collection UUIDs, returns a regex match object with the
-  # uuid in group 1, empty groups 2 and 3 (for consistency with the match
-  # method above), and (optional) file path within the collection as group
-  # 4.
-  # returns nil for no match.
-  #
-  def self.match_uuid_with_optional_filepath(uuid_with_optional_file)
-    /^([0-9a-z]{5}-4zz18-[0-9a-z]{15})()()(\/.*)?$/.match(uuid_with_optional_file.to_s)
-  end
-
-  ##
-  # Regex match for common image file extensions, returns a regex match object
-  # with the matched extension in group 1; or nil for no match.
-  #
-  # +file+ the file string to match
-  #
-  def self.is_image file
-    /\.(jpg|jpeg|gif|png|svg)$/i.match(file)
-  end
-
-  ##
-  # Generates a relative file path than can be appended to the URL of a
-  # collection to get a file download link without adding a spurious ./ at the
-  # beginning for files in the default stream.
-  #
-  # +file+ an entry in the Collection.files list in the form [stream, name, size]
-  #
-  def self.file_path file
-    f0 = file[0]
-    f0 = '' if f0 == '.'
-    f0 = f0[2..-1] if f0[0..1] == './'
-    f0 += '/' if not f0.empty?
-    "#{f0}#{file[1]}"
-  end
-
-  ##
-  # Check if collection preview is allowed for the given filename with extension
-  #
-  def preview_allowed_for file_name
-    file_type = MIME::Types.type_for(file_name).first
-    if file_type.nil?
-      if file_name.downcase.end_with?('.cwl') # unknown mime type, but we support preview
-        true
-      else
-        false
-      end
-    elsif (file_type.raw_media_type == "text") || (file_type.raw_media_type == "image")
-      true
-    elsif (file_type.raw_media_type == "application") &&
-          Rails.configuration.Workbench.ApplicationMimetypesWithViewIcon[file_type.sub_type]
-      true
-    else
-      false
-    end
-  end
-end
diff --git a/apps/workbench/app/helpers/pipeline_components_helper.rb b/apps/workbench/app/helpers/pipeline_components_helper.rb
deleted file mode 100644 (file)
index 702772c..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module PipelineComponentsHelper
-  def render_pipeline_components(template_suffix, fallback=nil, locals={})
-    begin
-      render(partial: "pipeline_instances/show_components_#{template_suffix}",
-             locals: locals)
-    rescue => e
-      logger.error "#{e.inspect}"
-      logger.error "#{e.backtrace.join("\n\t")}"
-      case fallback
-      when :json
-        render(partial: "pipeline_instances/show_components_json",
-               locals: {error_name: e.inspect, backtrace: e.backtrace.join("\n\t")})
-      end
-    end
-  end
-end
diff --git a/apps/workbench/app/helpers/pipeline_instances_helper.rb b/apps/workbench/app/helpers/pipeline_instances_helper.rb
deleted file mode 100644 (file)
index 8e89331..0000000
+++ /dev/null
@@ -1,319 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module PipelineInstancesHelper
-
-  def pipeline_jobs object=nil
-    object ||= @object
-    if object.components[:steps].is_a? Array
-      pipeline_jobs_oldschool object
-    elsif object.components.is_a? Hash
-      pipeline_jobs_newschool object
-    end
-  end
-
-  def render_pipeline_jobs
-    pipeline_jobs.collect do |pj|
-      render_pipeline_job pj
-    end
-  end
-
-  def render_pipeline_job pj
-    pj[:progress_bar] = render partial: 'job_progress', locals: {:j => pj[:job]}
-    pj[:output_link] = link_to_if_arvados_object pj[:output]
-    pj[:job_link] = link_to_if_arvados_object pj[:job][:uuid] if pj[:job]
-    pj
-  end
-
-  # Merge (started_at, finished_at) time range into the list of time ranges in
-  # timestamps (timestamps must be sorted and non-overlapping).
-  # return the updated timestamps list.
-  def merge_range timestamps, started_at, finished_at
-    # in the comments below, 'i' is the entry in the timestamps array and 'j'
-    # is the started_at, finished_at range which is passed in.
-    timestamps.each_index do |i|
-      if started_at
-        if started_at >= timestamps[i][0] and finished_at <= timestamps[i][1]
-          # 'j' started and ended during 'i'
-          return timestamps
-        end
-
-        if started_at < timestamps[i][0] and finished_at >= timestamps[i][0] and finished_at <= timestamps[i][1]
-          # 'j' started before 'i' and finished during 'i'
-          # re-merge range between when 'j' started and 'i' finished
-          finished_at = timestamps[i][1]
-          timestamps.delete_at i
-          return merge_range timestamps, started_at, finished_at
-        end
-
-        if started_at >= timestamps[i][0] and started_at <= timestamps[i][1]
-          # 'j' started during 'i' and finished sometime after
-          # move end time of 'i' back
-          # re-merge range between when 'i' started and 'j' finished
-          started_at = timestamps[i][0]
-          timestamps.delete_at i
-          return merge_range timestamps, started_at, finished_at
-        end
-
-        if finished_at < timestamps[i][0]
-          # 'j' finished before 'i' started, so insert before 'i'
-          timestamps.insert i, [started_at, finished_at]
-          return timestamps
-        end
-      end
-    end
-
-    timestamps << [started_at, finished_at]
-  end
-
-  # Accept a list of objects with [:started_at] and [:finished_at] keys and
-  # merge overlapping ranges to compute the time spent running after periods of
-  # overlapping execution are factored out.
-  def determine_wallclock_runtime jobs
-    timestamps = []
-    jobs.each do |j|
-      started_at = (j.started_at if j.respond_to?(:started_at)) || (j[:started_at] if j.is_a?(Hash))
-      finished_at = (j.finished_at if j.respond_to?(:finished_at)) || (j[:finished_at] if j.is_a?(Hash)) || Time.now
-      if started_at
-        timestamps = merge_range timestamps, started_at, finished_at
-      end
-    end
-    timestamps.map { |t| t[1] - t[0] }.reduce(:+) || 0
-  end
-
-  protected
-
-  def pipeline_jobs_newschool object
-    ret = []
-    i = -1
-
-    jobuuids = object.components.values.map { |c|
-      c[:job][:uuid] if c.is_a?(Hash) and c[:job].is_a?(Hash)
-    }.compact
-    job = {}
-    Job.where(uuid: jobuuids).with_count("none").each do |j|
-      job[j[:uuid]] = j
-    end
-
-    object.components.each do |cname, c|
-      i += 1
-      pj = {index: i, name: cname}
-      if not c.is_a?(Hash)
-        ret << pj
-        next
-      end
-      if c[:job] and c[:job][:uuid] and job[c[:job][:uuid]]
-        pj[:job] = job[c[:job][:uuid]]
-      elsif c[:job].is_a?(Hash)
-        pj[:job] = c[:job]
-        if pj[:job][:started_at].is_a? String
-          pj[:job][:started_at] = Time.parse(pj[:job][:started_at])
-        end
-        if pj[:job][:finished_at].is_a? String
-          pj[:job][:finished_at] = Time.parse(pj[:job][:finished_at])
-        end
-        # If necessary, figure out the state based on the other fields.
-        pj[:job][:state] ||= if pj[:job][:cancelled_at]
-                               "Cancelled"
-                             elsif pj[:job][:success] == false
-                               "Failed"
-                             elsif pj[:job][:success] == true
-                               "Complete"
-                             elsif pj[:job][:running] == true
-                               "Running"
-                             else
-                               "Queued"
-                             end
-      else
-        pj[:job] = {}
-      end
-      pj[:percent_done] = 0
-      pj[:percent_running] = 0
-      if pj[:job][:success]
-        if pj[:job][:output]
-          pj[:progress] = 1.0
-          pj[:percent_done] = 100
-        else
-          pj[:progress] = 0.0
-        end
-      else
-        if pj[:job][:tasks_summary]
-          begin
-            ts = pj[:job][:tasks_summary]
-            denom = ts[:done].to_f + ts[:running].to_f + ts[:todo].to_f
-            pj[:progress] = (ts[:done].to_f + ts[:running].to_f/2) / denom
-            pj[:percent_done] = 100.0 * ts[:done].to_f / denom
-            pj[:percent_running] = 100.0 * ts[:running].to_f / denom
-            pj[:progress_detail] = "#{ts[:done]} done #{ts[:running]} run #{ts[:todo]} todo"
-          rescue
-            pj[:progress] = 0.5
-            pj[:percent_done] = 0.0
-            pj[:percent_running] = 100.0
-          end
-        else
-          pj[:progress] = 0.0
-        end
-      end
-
-      case pj[:job][:state]
-        when 'Complete'
-        pj[:result] = 'complete'
-        pj[:labeltype] = 'success'
-        pj[:complete] = true
-        pj[:progress] = 1.0
-      when 'Failed'
-        pj[:result] = 'failed'
-        pj[:labeltype] = 'danger'
-        pj[:failed] = true
-      when 'Cancelled'
-        pj[:result] = 'cancelled'
-        pj[:labeltype] = 'danger'
-        pj[:failed] = true
-      when 'Running'
-        pj[:result] = 'running'
-        pj[:labeltype] = 'primary'
-      when 'Queued'
-        pj[:result] = 'queued'
-        pj[:labeltype] = 'default'
-      else
-        pj[:result] = 'none'
-        pj[:labeltype] = 'default'
-      end
-
-      pj[:job_id] = pj[:job][:uuid]
-      pj[:script] = pj[:job][:script] || c[:script]
-      pj[:repository] = pj[:job][:script] || c[:repository]
-      pj[:script_parameters] = pj[:job][:script_parameters] || c[:script_parameters]
-      pj[:script_version] = pj[:job][:script_version] || c[:script_version]
-      pj[:nondeterministic] = pj[:job][:nondeterministic] || c[:nondeterministic]
-      pj[:output] = pj[:job][:output]
-      pj[:output_uuid] = c[:output_uuid]
-      pj[:finished_at] = pj[:job][:finished_at]
-      ret << pj
-    end
-    ret
-  end
-
-  def pipeline_jobs_oldschool object
-    ret = []
-    object.components[:steps].each_with_index do |step, i|
-      pj = {index: i, name: step[:name]}
-      if step[:complete] and step[:complete] != 0
-        if step[:output_data_locator]
-          pj[:progress] = 1.0
-        else
-          pj[:progress] = 0.0
-        end
-      else
-        if step[:progress] and
-            (re = step[:progress].match(/^(\d+)\+(\d+)\/(\d+)$/))
-          pj[:progress] = (((re[1].to_f + re[2].to_f/2) / re[3].to_f) rescue 0.5)
-        else
-          pj[:progress] = 0.0
-        end
-        if step[:failed]
-          pj[:result] = 'failed'
-          pj[:failed] = true
-        end
-      end
-      if step[:warehousejob]
-        if step[:complete]
-          pj[:result] = 'complete'
-          pj[:complete] = true
-          pj[:progress] = 1.0
-        elsif step[:warehousejob][:finishtime]
-          pj[:result] = 'failed'
-          pj[:failed] = true
-        elsif step[:warehousejob][:starttime]
-          pj[:result] = 'running'
-        else
-          pj[:result] = 'queued'
-        end
-      end
-      pj[:progress_detail] = (step[:progress] rescue nil)
-      pj[:job_id] = (step[:warehousejob][:id] rescue nil)
-      pj[:job_link] = pj[:job_id]
-      pj[:script] = step[:function]
-      pj[:script_version] = (step[:warehousejob][:revision] rescue nil)
-      pj[:output] = step[:output_data_locator]
-      pj[:finished_at] = (Time.parse(step[:warehousejob][:finishtime]) rescue nil)
-      ret << pj
-    end
-    ret
-  end
-
-  MINUTE = 60
-  HOUR = 60 * MINUTE
-  DAY = 24 * HOUR
-
-  def render_runtime duration, use_words, round_to_min=true
-    days = 0
-    hours = 0
-    minutes = 0
-    seconds = 0
-
-    if duration >= DAY
-      days = (duration / DAY).floor
-      duration -= days * DAY
-    end
-
-    if duration >= HOUR
-      hours = (duration / HOUR).floor
-      duration -= hours * HOUR
-    end
-
-    if duration >= MINUTE
-      minutes = (duration / MINUTE).floor
-      duration -= minutes * MINUTE
-    end
-
-    seconds = duration.floor
-
-    if round_to_min and seconds >= 30
-      minutes += 1
-    end
-
-    if use_words
-      s = []
-      if days > 0 then
-        s << "#{days} day#{'s' if days != 1}"
-      end
-      if hours > 0 then
-        s << "#{hours} hour#{'s' if hours != 1}"
-      end
-      if minutes > 0 then
-        s << "#{minutes} minute#{'s' if minutes != 1}"
-      end
-      if not round_to_min or s.size == 0
-        s << "#{seconds} second#{'s' if seconds != 1}"
-      end
-      s = s * " "
-    else
-      s = ""
-      if days > 0
-        s += "#{days}<span class='time-label-divider'>d</span>"
-      end
-
-      if (hours > 0)
-        s += "#{hours}<span class='time-label-divider'>h</span>"
-      end
-
-      s += "#{minutes}<span class='time-label-divider'>m</span>"
-
-      if not round_to_min or (days == 0 and hours == 0 and minutes == 0)
-        s += "#{seconds}<span class='time-label-divider'>s</span>"
-      end
-    end
-
-    raw(s)
-  end
-
-  def render_unreadable_inputs_present
-    if current_user and controller.class.name.eql?('PipelineInstancesController') and unreadable_inputs_present?
-      raw('<div class="alert alert-danger unreadable-inputs-present">' +
-            '<p>One or more inputs provided are not readable by you. ' +
-              'Please correct these before you can run the pipeline.</p></div>')
-    end
-  end
-end
diff --git a/apps/workbench/app/helpers/provenance_helper.rb b/apps/workbench/app/helpers/provenance_helper.rb
deleted file mode 100644 (file)
index cef5cc7..0000000
+++ /dev/null
@@ -1,437 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module ProvenanceHelper
-
-  class GenerateGraph
-    def initialize(pdata, opts)
-      @pdata = pdata
-      @opts = opts
-      @visited = {}
-      @jobs = {}
-      @node_extra = {}
-    end
-
-    def self.collection_uuid(uuid)
-      Keep::Locator.parse(uuid).andand.strip_hints.andand.to_s
-    end
-
-    def url_for u
-      p = { :host => @opts[:request].host,
-        :port => @opts[:request].port,
-        :protocol => @opts[:request].protocol }
-      p.merge! u
-      Rails.application.routes.url_helpers.url_for (p)
-    end
-
-    def determine_fillcolor(n)
-      fillcolor = %w(666666 669966 666699 666666 996666)[n || 0] || '666666'
-      "style=\"filled\",color=\"#ffffff\",fillcolor=\"##{fillcolor}\",fontcolor=\"#ffffff\""
-    end
-
-    def describe_node(uuid, describe_opts={})
-      bgcolor = determine_fillcolor (describe_opts[:pip] || @opts[:pips].andand[uuid])
-
-      rsc = ArvadosBase::resource_class_for_uuid uuid
-
-      if GenerateGraph::collection_uuid(uuid) || rsc == Collection
-        if Collection.is_empty_blob_locator? uuid.to_s
-          # special case
-          return "\"#{uuid}\" [label=\"(empty collection)\"];\n"
-        end
-
-        if describe_opts[:col_uuid]
-          href = url_for ({:controller => Collection.to_s.tableize,
-                           :action => :show,
-                           :id => describe_opts[:col_uuid].to_s })
-        else
-          href = url_for ({:controller => Collection.to_s.tableize,
-                           :action => :show,
-                           :id => uuid.to_s })
-        end
-
-        return "\"#{uuid}\" [label=\"#{encode_quotes(describe_opts[:label] || (@pdata[uuid] and @pdata[uuid][:name]) || uuid)}\",shape=box,href=\"#{href}\",#{bgcolor}];\n"
-      else
-        href = ""
-        if describe_opts[:href]
-          href = ",href=\"#{url_for ({:controller => describe_opts[:href][:controller],
-                            :action => :show,
-                            :id => describe_opts[:href][:id] })}\""
-        end
-        return "\"#{uuid}\" [label=\"#{encode_quotes(describe_opts[:label] || uuid)}\",#{bgcolor},shape=#{describe_opts[:shape] || 'box'}#{href}];\n"
-      end
-    end
-
-    def job_uuid(job)
-      d = Digest::MD5.hexdigest(job[:script_parameters].to_json)
-      if @opts[:combine_jobs] == :script_only
-        uuid = "#{job[:script]}_#{d}"
-      elsif @opts[:combine_jobs] == :script_and_version
-        uuid = "#{job[:script]}_#{job[:script_version]}_#{d}"
-      else
-        uuid = "#{job[:uuid]}"
-      end
-
-      @jobs[uuid] = [] unless @jobs[uuid]
-      @jobs[uuid] << job unless @jobs[uuid].include? job
-
-      uuid
-    end
-
-    def edge(tail, head, extra)
-      if @opts[:direction] == :bottom_up
-        gr = "\"#{encode_quotes head}\" -> \"#{encode_quotes tail}\""
-      else
-        gr = "\"#{encode_quotes tail}\" -> \"#{encode_quotes head}\""
-      end
-
-      if extra.length > 0
-        gr += " ["
-        extra.each do |k, v|
-          gr += "#{k}=\"#{encode_quotes v}\","
-        end
-        gr += "]"
-      end
-      gr += ";\n"
-      gr
-    end
-
-    def script_param_edges(uuid, sp)
-      gr = ""
-
-      sp.each do |k, v|
-        if @opts[:all_script_parameters]
-          if v.is_a? Array or v.is_a? Hash
-            encv = JSON.pretty_generate(v).gsub("\n", "\\l") + "\\l"
-          else
-            encv = v.to_json
-          end
-          gr += "\"#{encode_quotes encv}\" [shape=box];\n"
-          gr += edge(encv, uuid, {:label => k})
-        end
-      end
-      gr
-    end
-
-    def job_edges job, edge_opts={}
-      uuid = job_uuid(job)
-      gr = ""
-
-      ProvenanceHelper::find_collections job[:script_parameters] do |collection_hash, collection_uuid, key|
-        if collection_uuid
-          gr += describe_node(collection_uuid)
-          gr += edge(collection_uuid, uuid, {:label => key})
-        else
-          gr += describe_node(collection_hash)
-          gr += edge(collection_hash, uuid, {:label => key})
-        end
-      end
-
-      if job[:docker_image_locator] and !@opts[:no_docker]
-        gr += describe_node(job[:docker_image_locator], {label: (job[:runtime_constraints].andand[:docker_image] || job[:docker_image_locator])})
-        gr += edge(job[:docker_image_locator], uuid, {label: "docker_image"})
-      end
-
-      if @opts[:script_version_nodes]
-        gr += describe_node(job[:script_version], {:label => "git:#{job[:script_version]}"})
-        gr += edge(job[:script_version], uuid, {:label => "script_version"})
-      end
-
-      if job[:output] and !edge_opts[:no_output]
-        gr += describe_node(job[:output])
-        gr += edge(uuid, job[:output], {label: "output" })
-      end
-
-      if job[:log] and !edge_opts[:no_log]
-        gr += describe_node(job[:log])
-        gr += edge(uuid, job[:log], {label: "log"})
-      end
-
-      gr
-    end
-
-    def cr_edges cont, edge_opts={}
-      uuid = cont[:uuid]
-      gr = ""
-
-      gr += describe_node(cont[:uuid], {href: {controller: 'container_requests',
-                                             id: cont[:uuid]},
-                                        shape: 'oval',
-                                        label: cont[:name]})
-
-      ProvenanceHelper::find_collections cont[:mounts] do |collection_hash, collection_uuid, key|
-        if @opts[:pdh_to_uuid] and @opts[:pdh_to_uuid][collection_hash]
-          collection_uuid = @opts[:pdh_to_uuid][collection_hash].uuid
-          collection_hash = nil
-        end
-        if collection_uuid and @pdata[collection_uuid]
-          gr += describe_node(collection_uuid)
-          gr += edge(collection_uuid, uuid, {:label => key})
-        elsif collection_hash and @pdata[collection_hash]
-          gr += describe_node(collection_hash)
-          gr += edge(collection_hash, uuid, {:label => key})
-        end
-      end
-
-      if cont[:container_image] and !@opts[:no_docker] and @pdata[cont[:container_image]]
-        gr += describe_node(cont[:container_image], {label: cont[:container_image]})
-        gr += edge(cont[:container_image], uuid, {label: "docker_image"})
-      end
-
-      if cont[:output_uuid] and !edge_opts[:no_output] and @pdata[cont[:output_uuid]]
-        gr += describe_node(cont[:output_uuid])
-        gr += edge(uuid, cont[:output_uuid], {label: "output" })
-      end
-
-      if cont[:log_uuid] and !edge_opts[:no_log] and @pdata[cont[:log_uuid]]
-        gr += describe_node(cont[:log_uuid])
-        gr += edge(uuid, cont[:log_uuid], {label: "log"})
-      end
-
-      gr
-    end
-
-    def container_edges cont, edge_opts={}
-      uuid = cont[:uuid]
-      gr = ""
-
-      gr += describe_node(cont[:uuid], {href: {controller: 'containers',
-                                             id: cont[:uuid]},
-                                      shape: 'oval'})
-
-      ProvenanceHelper::find_collections cont[:mounts] do |collection_hash, collection_uuid, key|
-        if collection_uuid and @pdata[collection_uuid]
-          gr += describe_node(collection_uuid)
-          gr += edge(collection_uuid, uuid, {:label => key})
-        elsif collection_hash and @pdata[collection_hash]
-          gr += describe_node(collection_hash)
-          gr += edge(collection_hash, uuid, {:label => key})
-        end
-      end
-
-      if cont[:container_image] and !@opts[:no_docker] and @pdata[cont[:container_image]]
-        gr += describe_node(cont[:container_image], {label: cont[:container_image]})
-        gr += edge(cont[:container_image], uuid, {label: "docker_image"})
-      end
-
-      if cont[:output] and !edge_opts[:no_output] and @pdata[cont[:output]]
-        gr += describe_node(cont[:output])
-        gr += edge(uuid, cont[:output], {label: "output" })
-      end
-
-      if cont[:log] and !edge_opts[:no_log] and @pdata[cont[:log]]
-        gr += describe_node(cont[:log])
-        gr += edge(uuid, cont[:log], {label: "log"})
-      end
-
-      gr
-    end
-
-    def generate_provenance_edges(uuid)
-      gr = ""
-      m = GenerateGraph::collection_uuid(uuid)
-      uuid = m if m
-
-      if uuid.nil? or uuid.empty? or @visited[uuid]
-        return ""
-      end
-
-      if @pdata[uuid].nil?
-        return ""
-      else
-        @visited[uuid] = true
-      end
-
-      if uuid.start_with? "component_"
-        # Pipeline component inputs
-        job = @pdata[@pdata[uuid][:job].andand[:uuid]]
-
-        if job
-          gr += describe_node(job_uuid(job), {label: uuid[38..-1], pip: @opts[:pips].andand[job[:uuid]], shape: "oval",
-                                href: {controller: 'jobs', id: job[:uuid]}})
-          gr += job_edges job, {no_output: true, no_log: true}
-        end
-
-        # Pipeline component output
-        outuuid = @pdata[uuid][:output_uuid]
-        if outuuid
-          outcollection = @pdata[outuuid]
-          if outcollection
-            gr += edge(job_uuid(job), outcollection[:portable_data_hash], {label: "output"})
-            gr += describe_node(outcollection[:portable_data_hash], {label: outcollection[:name]})
-          end
-        elsif job and job[:output]
-          gr += describe_node(job[:output])
-          gr += edge(job_uuid(job), job[:output], {label: "output" })
-        end
-      else
-        rsc = ArvadosBase::resource_class_for_uuid uuid
-
-        if rsc == Job
-          job = @pdata[uuid]
-          gr += job_edges job if job
-        elsif rsc == ContainerRequest
-          cr = @pdata[uuid]
-          gr += cr_edges cr if cr
-        elsif rsc == Container
-          cr = @pdata[uuid]
-          gr += container_edges cr if cr
-        end
-      end
-
-      @pdata.each do |k, link|
-        if link[:head_uuid] == uuid.to_s and link[:link_class] == "provenance"
-          href = url_for ({:controller => Link.to_s.tableize,
-                            :action => :show,
-                            :id => link[:uuid] })
-
-          gr += describe_node(link[:tail_uuid])
-          gr += edge(link[:head_uuid], link[:tail_uuid], {:label => link[:name], :href => href})
-          gr += generate_provenance_edges(link[:tail_uuid])
-        end
-      end
-
-      gr
-    end
-
-    def describe_jobs
-      gr = ""
-      @jobs.each do |k, v|
-        href = url_for ({:controller => Job.to_s.tableize,
-                          :action => :index })
-
-        gr += "\"#{k}\" [href=\"#{href}?"
-
-        n = 0
-        v.each do |u|
-          gr += ";" unless gr.end_with? "?"
-          gr += "uuid%5b%5d=#{u[:uuid]}"
-          n |= @opts[:pips][u[:uuid]] if @opts[:pips] and @opts[:pips][u[:uuid]]
-        end
-
-        gr += "\",label=\""
-
-        label = "#{v[0][:script]}"
-
-        if label == "run-command" and v[0][:script_parameters][:command].is_a? Array
-          label = v[0][:script_parameters][:command].join(' ')
-        end
-
-        if not @opts[:combine_jobs]
-          label += "\\n#{v[0][:finished_at]}"
-        end
-
-        gr += encode_quotes label
-
-        gr += "\",#{determine_fillcolor n}];\n"
-      end
-      gr
-    end
-
-    def encode_quotes value
-      value.to_s.gsub("\"", "\\\"").gsub("\n", "\\n")
-    end
-  end
-
-  def self.create_provenance_graph(pdata, svgId, opts={})
-    if pdata.is_a? Array or pdata.is_a? ArvadosResourceList
-      p2 = {}
-      pdata.each do |k|
-        p2[k[:uuid]] = k if k[:uuid]
-      end
-      pdata = p2
-    end
-
-    unless pdata.is_a? Hash
-      raise "create_provenance_graph accepts Array or Hash for pdata only, pdata is #{pdata.class}"
-    end
-
-    gr = """strict digraph {
-node [fontsize=10,fontname=\"Helvetica,Arial,sans-serif\"];
-edge [fontsize=10,fontname=\"Helvetica,Arial,sans-serif\"];
-"""
-    if ["LR", "RL"].include? opts[:direction]
-      gr += "rankdir=#{opts[:direction]};"
-    end
-
-    begin
-      pdata = pdata.stringify_keys
-
-      g = GenerateGraph.new(pdata, opts)
-
-      pdata.each do |k, v|
-        if !opts[:only_components] or k.start_with? "component_"
-          gr += g.generate_provenance_edges(k)
-        else
-          #gr += describe_node(k)
-        end
-      end
-
-      if !opts[:only_components]
-        gr += g.describe_jobs
-      end
-
-    rescue => e
-      Rails.logger.warn "#{e.inspect}"
-      Rails.logger.warn "#{e.backtrace.join("\n\t")}"
-      raise
-    end
-
-    gr += "}"
-    svg = ""
-
-    require 'open3'
-
-    Open3.popen2("dot", "-Tsvg") do |stdin, stdout, wait_thr|
-      stdin.print(gr)
-      stdin.close
-      svg = stdout.read()
-      wait_thr.value
-      stdout.close()
-    end
-
-    svg = svg.sub(/<\?xml.*?\?>/m, "")
-    svg = svg.sub(/<!DOCTYPE.*?>/m, "")
-    svg = svg.sub(/<svg /, "<svg id=\"#{svgId}\" ")
-  end
-
-  # yields hash, uuid
-  # Position indicates whether it is a content hash or arvados uuid.
-  # One will hold a value, the other will always be nil.
-  def self.find_collections(sp, key=nil, &b)
-    case sp
-    when ArvadosBase
-      sp.class.columns.each do |c|
-        find_collections(sp[c.name.to_sym], nil, &b)
-      end
-    when Hash
-      sp.each do |k, v|
-        find_collections(v, key || k, &b)
-      end
-    when Array
-      sp.each do |v|
-        find_collections(v, key, &b)
-      end
-    when String
-      if m = /[a-f0-9]{32}\+\d+/.match(sp)
-        yield m[0], nil, key
-      elsif m = /[0-9a-z]{5}-4zz18-[0-9a-z]{15}/.match(sp)
-        yield nil, m[0], key
-      end
-    end
-  end
-
-  def self.cr_input_pdhs cr
-    pdhs = []
-    input_obj = cr[:mounts].andand[:"/var/lib/cwl/cwl.input.json"].andand[:content] || cr[:mounts]
-    if input_obj
-      find_collections input_obj do |col_hash, col_uuid, key|
-        if col_hash
-          pdhs << col_hash
-        end
-      end
-    end
-    pdhs
-  end
-end
diff --git a/apps/workbench/app/helpers/version_helper.rb b/apps/workbench/app/helpers/version_helper.rb
deleted file mode 100644 (file)
index d110712..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module VersionHelper
-  # Get the source_version given in the API server's discovery
-  # document.
-  def api_source_version
-    arvados_api_client.discovery[:source_version]
-  end
-
-  # Get the packageVersion given in the API server's discovery
-  # document.
-  def api_package_version
-    arvados_api_client.discovery[:packageVersion]
-  end
-
-  # URL for browsing source code for the given version.
-  def version_link_target version
-    "https://dev.arvados.org/projects/arvados/repository/changes?rev=#{version.sub(/-.*/, "")}"
-  end
-end
diff --git a/apps/workbench/app/mailers/issue_reporter.rb b/apps/workbench/app/mailers/issue_reporter.rb
deleted file mode 100644 (file)
index 8066b0b..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class IssueReporter < ActionMailer::Base
-  default from: Rails.configuration.Mail.IssueReporterEmailFrom
-  default to: Rails.configuration.Mail.IssueReporterEmailTo
-
-  def send_report(user, params)
-    @user = user
-    @params = params
-    subject = 'Issue reported'
-    subject += " by #{@user.email}" if @user
-    mail(subject: subject)
-  end
-end
diff --git a/apps/workbench/app/mailers/request_shell_access_reporter.rb b/apps/workbench/app/mailers/request_shell_access_reporter.rb
deleted file mode 100644 (file)
index 32de8d7..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class RequestShellAccessReporter < ActionMailer::Base
-  default from: Rails.configuration.Mail.EmailFrom
-  default to: Rails.configuration.Mail.SupportEmailAddress
-
-  def send_request(user, params)
-    @user = user
-    @params = params
-    subject = "Shell account request from #{user.full_name} (#{user.email}, #{user.uuid})"
-    mail(subject: subject)
-  end
-end
diff --git a/apps/workbench/app/models/.gitkeep b/apps/workbench/app/models/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/app/models/api_client_authorization.rb b/apps/workbench/app/models/api_client_authorization.rb
deleted file mode 100644 (file)
index b78cb28..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ApiClientAuthorization < ArvadosBase
-  def editable_attributes
-    %w(expires_at default_owner_uuid)
-  end
-  def self.creatable?
-    false
-  end
-end
diff --git a/apps/workbench/app/models/arvados_api_client.rb b/apps/workbench/app/models/arvados_api_client.rb
deleted file mode 100644 (file)
index 2abcf49..0000000
+++ /dev/null
@@ -1,305 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'httpclient'
-require 'thread'
-
-class ArvadosApiClient
-  class ApiError < StandardError
-    attr_reader :api_response, :api_response_s, :api_status, :request_url
-
-    def initialize(request_url, errmsg)
-      @request_url = request_url
-      @api_response ||= {}
-      errors = @api_response[:errors]
-      if not errors.is_a?(Array)
-        @api_response[:errors] = [errors || errmsg]
-      end
-      super(errmsg)
-    end
-  end
-
-  class NoApiResponseException < ApiError
-    def initialize(request_url, exception)
-      @api_response_s = exception.to_s
-      super(request_url,
-            "#{exception.class.to_s} error connecting to API server")
-    end
-  end
-
-  class InvalidApiResponseException < ApiError
-    def initialize(request_url, api_response)
-      @api_status = api_response.status_code
-      @api_response_s = api_response.content
-      super(request_url, "Unparseable response from API server")
-    end
-  end
-
-  class ApiErrorResponseException < ApiError
-    def initialize(request_url, api_response)
-      @api_status = api_response.status_code
-      @api_response_s = api_response.content
-      @api_response = Oj.strict_load(@api_response_s, :symbol_keys => true)
-      errors = @api_response[:errors]
-      if errors.respond_to?(:join)
-        errors = errors.join("\n\n")
-      else
-        errors = errors.to_s
-      end
-      super(request_url, "#{errors} [API: #{@api_status}]")
-    end
-  end
-
-  class AccessForbiddenException < ApiErrorResponseException; end
-  class NotFoundException < ApiErrorResponseException; end
-  class NotLoggedInException < ApiErrorResponseException; end
-
-  ERROR_CODE_CLASSES = {
-    401 => NotLoggedInException,
-    403 => AccessForbiddenException,
-    404 => NotFoundException,
-  }
-
-  @@profiling_enabled = Rails.configuration.Workbench.ProfilingEnabled
-  @@discovery = nil
-
-  # An API client object suitable for handling API requests on behalf
-  # of the current thread.
-  def self.new_or_current
-    # If this thread doesn't have an API client yet, *or* this model
-    # has been reloaded since the existing client was created, create
-    # a new client. Otherwise, keep using the latest client created in
-    # the current thread.
-    unless Thread.current[:arvados_api_client].andand.class == self
-      Thread.current[:arvados_api_client] = new
-    end
-    Thread.current[:arvados_api_client]
-  end
-
-  def initialize *args
-    @api_client = nil
-    @client_mtx = Mutex.new
-  end
-
-  def api(resources_kind, action, data=nil, tokens={}, include_anon_token=true)
-
-    profile_checkpoint
-
-    if not @api_client
-      @client_mtx.synchronize do
-        @api_client = HTTPClient.new
-        @api_client.ssl_config.timeout = Rails.configuration.Workbench.APIClientConnectTimeout
-        @api_client.connect_timeout = Rails.configuration.Workbench.APIClientConnectTimeout
-        @api_client.receive_timeout = Rails.configuration.Workbench.APIClientReceiveTimeout
-        if Rails.configuration.TLS.Insecure
-          @api_client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE
-        else
-          # Use system CA certificates
-          ["/etc/ssl/certs/ca-certificates.crt",
-           "/etc/pki/tls/certs/ca-bundle.crt"]
-            .select { |ca_path| File.readable?(ca_path) }
-            .each { |ca_path| @api_client.ssl_config.add_trust_ca(ca_path) }
-        end
-        if Rails.configuration.Workbench.APIResponseCompression
-          @api_client.transparent_gzip_decompression = true
-        end
-      end
-    end
-
-    resources_kind = class_kind(resources_kind).pluralize if resources_kind.is_a? Class
-    url = "#{self.arvados_v1_base}/#{resources_kind}#{action}"
-
-    # Clean up /arvados/v1/../../discovery/v1 to /discovery/v1
-    url.sub! '/arvados/v1/../../', '/'
-
-    anon_tokens = [Rails.configuration.Users.AnonymousUserToken].select { |x| !x.empty? && include_anon_token }
-
-    query = {
-      'reader_tokens' => ((tokens[:reader_tokens] ||
-                           Thread.current[:reader_tokens] ||
-                           []) +
-                          anon_tokens).to_json,
-    }
-    if !data.nil?
-      data.each do |k,v|
-        if v.is_a? String or v.nil?
-          query[k] = v
-        elsif v == true
-          query[k] = 1
-        elsif v == false
-          query[k] = 0
-        else
-          query[k] = Oj.dump(v, mode: :compat)
-        end
-      end
-    else
-      query["_method"] = "GET"
-    end
-
-    if @@profiling_enabled
-      query["_profile"] = "true"
-    end
-
-    headers = {
-      "Accept" => "application/json",
-      "Authorization" => "OAuth2 " +
-                         (tokens[:arvados_api_token] ||
-                          Thread.current[:arvados_api_token] ||
-                          ''),
-      "X-Request-Id" => Thread.current[:request_id] || '',
-    }
-
-    profile_checkpoint { "Prepare request #{query["_method"] or "POST"} #{url} #{query[:uuid]} #{query.inspect[0,256]}" }
-    msg = @client_mtx.synchronize do
-      begin
-        @api_client.post(url, query, headers)
-      rescue => exception
-        raise NoApiResponseException.new(url, exception)
-      end
-    end
-    profile_checkpoint 'API transaction'
-    if @@profiling_enabled
-      if msg.headers['X-Runtime']
-        Rails.logger.info "API server: #{msg.headers['X-Runtime']} runtime reported"
-      end
-      Rails.logger.info "Content-Encoding #{msg.headers['Content-Encoding'].inspect}, Content-Length #{msg.headers['Content-Length'].inspect}, actual content size #{msg.content.size}"
-    end
-
-    begin
-      resp = Oj.strict_load(msg.content, :symbol_keys => true)
-    rescue Oj::ParseError
-      resp = nil
-    end
-
-    if not resp.is_a? Hash
-      raise InvalidApiResponseException.new(url, msg)
-    elsif msg.status_code != 200
-      error_class = ERROR_CODE_CLASSES.fetch(msg.status_code,
-                                             ApiErrorResponseException)
-      raise error_class.new(url, msg)
-    end
-
-    if resp[:_profile]
-      Rails.logger.info "API client: " \
-      "#{resp.delete(:_profile)[:request_time]} request_time"
-    end
-    profile_checkpoint 'Parse response'
-    resp
-  end
-
-  def self.patch_paging_vars(ary, items_available, offset, limit, links=nil)
-    if items_available
-      (class << ary; self; end).class_eval { attr_accessor :items_available }
-      ary.items_available = items_available
-    end
-    if offset
-      (class << ary; self; end).class_eval { attr_accessor :offset }
-      ary.offset = offset
-    end
-    if limit
-      (class << ary; self; end).class_eval { attr_accessor :limit }
-      ary.limit = limit
-    end
-    if links
-      (class << ary; self; end).class_eval { attr_accessor :links }
-      ary.links = links
-    end
-    ary
-  end
-
-  def unpack_api_response(j, kind=nil)
-    if j.is_a? Hash and j[:items].is_a? Array and j[:kind].match(/(_list|List)$/)
-      ary = j[:items].collect { |x| unpack_api_response x, x[:kind] }
-      links = ArvadosResourceList.new Link
-      links.results = (j[:links] || []).collect do |x|
-        unpack_api_response x, x[:kind]
-      end
-      self.class.patch_paging_vars(ary, j[:items_available], j[:offset], j[:limit], links)
-    elsif j.is_a? Hash and (kind || j[:kind])
-      oclass = self.kind_class(kind || j[:kind])
-      if oclass
-        j.keys.each do |k|
-          childkind = j["#{k.to_s}_kind".to_sym]
-          if childkind
-            j[k] = self.unpack_api_response(j[k], childkind)
-          end
-        end
-        oclass.new.private_reload(j)
-      else
-        j
-      end
-    else
-      j
-    end
-  end
-
-  def arvados_login_url(params={})
-    if Rails.configuration.testing_override_login_url
-      uri = URI(Rails.configuration.testing_override_login_url)
-      uri.path = "/login"
-      uri.query = URI.encode_www_form(params)
-      return uri.to_s
-    end
-
-    case
-    when Rails.configuration.Login.PAM.Enable,
-         Rails.configuration.Login.LDAP.Enable,
-         Rails.configuration.Login.Test.Enable
-
-      uri = URI.parse(Rails.configuration.Services.Workbench1.ExternalURL.to_s)
-      uri.path = "/users/welcome"
-      uri.query = URI.encode_www_form(params)
-    else
-      uri = URI.parse(Rails.configuration.Services.Controller.ExternalURL.to_s)
-      uri.path = "/login"
-      uri.query = URI.encode_www_form(params)
-    end
-    uri.to_s
-  end
-
-  def arvados_logout_url(params={})
-    uri = URI.parse(Rails.configuration.Services.Controller.ExternalURL.to_s)
-    if Rails.configuration.testing_override_login_url
-      uri = URI(Rails.configuration.testing_override_login_url)
-    end
-    uri.path = "/logout"
-    uri.query = URI.encode_www_form(params)
-    uri.to_s
-  end
-
-  def arvados_v1_base
-    # workaround Ruby 2.3 bug, can't duplicate URI objects
-    # https://github.com/httprb/http/issues/388
-    u = URI.parse(Rails.configuration.Services.Controller.ExternalURL.to_s)
-    u.path = "/arvados/v1"
-    u.to_s
-  end
-
-  def discovery
-    @@discovery ||= api '../../discovery/v1/apis/arvados/v1/rest', ''
-  end
-
-  def kind_class(kind)
-    kind.match(/^arvados\#(.+?)(_list|List)?$/)[1].pluralize.classify.constantize rescue nil
-  end
-
-  def class_kind(resource_class)
-    resource_class.to_s.underscore
-  end
-
-  def self.class_kind(resource_class)
-    resource_class.to_s.underscore
-  end
-
-  protected
-  def profile_checkpoint label=nil
-    return if !@@profiling_enabled
-    label = yield if block_given?
-    t = Time.now
-    if label and @profile_t0
-      Rails.logger.info "API client: #{t - @profile_t0} #{label}"
-    end
-    @profile_t0 = t
-  end
-end
diff --git a/apps/workbench/app/models/arvados_base.rb b/apps/workbench/app/models/arvados_base.rb
deleted file mode 100644 (file)
index c5e1a4e..0000000
+++ /dev/null
@@ -1,623 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ArvadosBase
-  include ActiveModel::Validations
-  include ActiveModel::Conversion
-  include ActiveModel::Serialization
-  include ActiveModel::Dirty
-  include ActiveModel::AttributeAssignment
-  extend ActiveModel::Naming
-
-  Column = Struct.new("Column", :name)
-
-  attr_accessor :attribute_sortkey
-  attr_accessor :create_params
-
-  class Error < StandardError; end
-
-  module Type
-    class Hash < ActiveModel::Type::Value
-      def type
-        :hash
-      end
-
-      def default_value
-        {}
-      end
-
-      private
-      def cast_value(value)
-        (value.class == String) ? ::JSON.parse(value) : value
-      end
-    end
-
-    class Array < ActiveModel::Type::Value
-      def type
-        :array
-      end
-
-      def default_value
-        []
-      end
-
-      private
-      def cast_value(value)
-        (value.class == String) ? ::JSON.parse(value) : value
-      end
-    end
-  end
-
-  def self.arvados_api_client
-    ArvadosApiClient.new_or_current
-  end
-
-  def arvados_api_client
-    ArvadosApiClient.new_or_current
-  end
-
-  def self.uuid_infix_object_kind
-    @@uuid_infix_object_kind ||=
-      begin
-        infix_kind = {}
-        arvados_api_client.discovery[:schemas].each do |name, schema|
-          if schema[:uuidPrefix]
-            infix_kind[schema[:uuidPrefix]] =
-              'arvados#' + name.to_s.camelcase(:lower)
-          end
-        end
-
-        # Recognize obsolete types.
-        infix_kind.
-          merge('mxsvm' => 'arvados#pipelineTemplate', # Pipeline
-                'uo14g' => 'arvados#pipelineInstance', # PipelineInvocation
-                'ldvyl' => 'arvados#group') # Project
-      end
-  end
-
-  def initialize raw_params={}, create_params={}
-    self.class.permit_attribute_params(raw_params)
-    @create_params = create_params
-    @attribute_sortkey ||= {
-      'id' => nil,
-      'name' => '000',
-      'owner_uuid' => '002',
-      'event_type' => '100',
-      'link_class' => '100',
-      'group_class' => '100',
-      'tail_uuid' => '101',
-      'head_uuid' => '102',
-      'object_uuid' => '102',
-      'summary' => '104',
-      'description' => '104',
-      'properties' => '150',
-      'info' => '150',
-      'created_at' => '200',
-      'modified_at' => '201',
-      'modified_by_user_uuid' => '202',
-      'modified_by_client_uuid' => '203',
-      'uuid' => '999',
-    }
-    @loaded_attributes = {}
-    attributes = self.class.columns.map { |c| [c.name.to_sym, nil] }.to_h.merge(raw_params)
-    attributes.symbolize_keys.each do |name, value|
-      send("#{name}=", value)
-    end
-  end
-
-  # The ActiveModel::Dirty API was changed on Rails 5.2
-  # See: https://github.com/rails/rails/commit/c3675f50d2e59b7fc173d7b332860c4b1a24a726#diff-aaddd42c7feb0834b1b5c66af69814d3
-  def mutations_from_database
-    @mutations_from_database ||= ActiveModel::NullMutationTracker.instance
-  end
-
-  def self.columns
-    @discovered_columns = [] if !defined?(@discovered_columns)
-    return @discovered_columns if @discovered_columns.andand.any?
-    @attribute_info ||= {}
-    schema = arvados_api_client.discovery[:schemas][self.to_s.to_sym]
-    return @discovered_columns if schema.nil?
-    schema[:properties].each do |k, coldef|
-      case k
-      when :etag, :kind
-        attr_reader k
-      else
-        if coldef[:type] == coldef[:type].downcase
-          # boolean, integer, etc.
-          @discovered_columns << column(k, coldef[:type])
-        else
-          # Hash, Array
-          @discovered_columns << column(k, coldef[:type], coldef[:type].constantize.new)
-        end
-        attr_reader k
-        @attribute_info[k] = coldef
-      end
-    end
-    @discovered_columns
-  end
-
-  def new_record?
-    # dup method doesn't reset the uuid attr
-    @uuid.nil? || @new_record || false
-  end
-
-  def initialize_dup(other)
-    super
-    @new_record = true
-    @created_at = nil
-  end
-
-  def self.column(name, sql_type = nil, default = nil, null = true)
-    caster = case sql_type
-              when 'integer'
-                ActiveModel::Type::Integer
-              when 'string', 'text'
-                ActiveModel::Type::String
-              when 'float'
-                ActiveModel::Type::Float
-              when 'datetime'
-                ActiveModel::Type::DateTime
-              when 'boolean'
-                ActiveModel::Type::Boolean
-              when 'Hash'
-                ArvadosBase::Type::Hash
-              when 'Array'
-                ArvadosBase::Type::Array
-              when 'jsonb'
-                ArvadosBase::Type::Hash
-              else
-                raise ArvadosBase::Error.new("Type unknown: #{sql_type}")
-            end
-    define_method "#{name}=" do |val|
-      val = default if val.nil?
-      casted_value = caster.new.cast(val)
-      attribute_will_change!(name) if send(name) != casted_value
-      set_attribute_after_cast(name, casted_value)
-    end
-    Column.new(name.to_s)
-  end
-
-  def set_attribute_after_cast(name, casted_value)
-    instance_variable_set("@#{name}", casted_value)
-  end
-
-  def [](attr_name)
-    begin
-      send(attr_name)
-    rescue
-      Rails.logger.debug "BUG: access non-loaded attribute #{attr_name}"
-      nil
-    end
-  end
-
-  def []=(attr_name, attr_val)
-    send("#{attr_name}=", attr_val)
-  end
-
-  def self.attribute_info
-    self.columns
-    @attribute_info
-  end
-
-  def self.find(uuid, opts={})
-    if uuid.class != String or uuid.length < 27 then
-      raise 'argument to find() must be a uuid string. Acceptable formats: warehouse locator or string with format xxxxx-xxxxx-xxxxxxxxxxxxxxx'
-    end
-
-    if self == ArvadosBase
-      # Determine type from uuid and defer to the appropriate subclass.
-      return resource_class_for_uuid(uuid).find(uuid, opts)
-    end
-
-    # Only do one lookup on the API side per {class, uuid, workbench
-    # request} unless {cache: false} is given via opts.
-    cache_key = "request_#{Thread.current.object_id}_#{self.to_s}_#{uuid}"
-    if opts[:cache] == false
-      Rails.cache.write cache_key, arvados_api_client.api(self, '/' + uuid)
-    end
-    hash = Rails.cache.fetch cache_key do
-      arvados_api_client.api(self, '/' + uuid)
-    end
-    new.private_reload(hash)
-  end
-
-  def self.find?(*args)
-    find(*args) rescue nil
-  end
-
-  def self.order(*args)
-    ArvadosResourceList.new(self).order(*args)
-  end
-
-  def self.filter(*args)
-    ArvadosResourceList.new(self).filter(*args)
-  end
-
-  def self.where(*args)
-    ArvadosResourceList.new(self).where(*args)
-  end
-
-  def self.limit(*args)
-    ArvadosResourceList.new(self).limit(*args)
-  end
-
-  def self.select(*args)
-    ArvadosResourceList.new(self).select(*args)
-  end
-
-  def self.with_count(*args)
-    ArvadosResourceList.new(self).with_count(*args)
-  end
-
-  def self.distinct(*args)
-    ArvadosResourceList.new(self).distinct(*args)
-  end
-
-  def self.include_trash(*args)
-    ArvadosResourceList.new(self).include_trash(*args)
-  end
-
-  def self.recursive(*args)
-    ArvadosResourceList.new(self).recursive(*args)
-  end
-
-  def self.eager(*args)
-    ArvadosResourceList.new(self).eager(*args)
-  end
-
-  def self.all
-    ArvadosResourceList.new(self)
-  end
-
-  def self.permit_attribute_params raw_params
-    # strong_parameters does not provide security in Workbench: anyone
-    # who can get this far can just as well do a call directly to our
-    # database (Arvados) with the same credentials we use.
-    #
-    # The following permit! is necessary even with
-    # "ActionController::Parameters.permit_all_parameters = true",
-    # because permit_all does not permit nested attributes.
-    if !raw_params.is_a? ActionController::Parameters
-      raw_params = ActionController::Parameters.new(raw_params)
-    end
-    raw_params.permit!
-  end
-
-  def self.create raw_params={}, create_params={}
-    x = new(permit_attribute_params(raw_params), create_params)
-    x.save
-    x
-  end
-
-  def self.create! raw_params={}, create_params={}
-    x = new(permit_attribute_params(raw_params), create_params)
-    x.save!
-    x
-  end
-
-  def self.table_name
-    self.name.underscore.pluralize.downcase
-  end
-
-  def update_attributes raw_params={}
-    assign_attributes(self.class.permit_attribute_params(raw_params))
-    save
-  end
-
-  def update_attributes! raw_params={}
-    assign_attributes(self.class.permit_attribute_params(raw_params))
-    save!
-  end
-
-  def save
-    obdata = {}
-    self.class.columns.each do |col|
-      # Non-nil serialized values must be sent because we can't tell
-      # whether they've changed. Other than that, any given attribute
-      # is either unchanged (in which case there's no need to send its
-      # old value in the update/create command) or has been added to
-      # #changed by ActiveRecord's #attr= method.
-      if changed.include? col.name or
-          ([Hash, Array].include?(attributes[col.name].class) and
-           @loaded_attributes[col.name])
-        obdata[col.name.to_sym] = self.send col.name
-      end
-    end
-    obdata.delete :id
-    postdata = { self.class.to_s.underscore => obdata }
-    if etag
-      postdata['_method'] = 'PUT'
-      obdata.delete :uuid
-      resp = arvados_api_client.api(self.class, '/' + uuid, postdata)
-    else
-      if @create_params
-        @create_params = @create_params.to_unsafe_hash if @create_params.is_a? ActionController::Parameters
-        postdata.merge!(@create_params)
-      end
-      resp = arvados_api_client.api(self.class, '', postdata)
-    end
-    return false if !resp[:etag] || !resp[:uuid]
-
-    # set read-only non-database attributes
-    @etag = resp[:etag]
-    @kind = resp[:kind]
-
-    # attributes can be modified during "save" -- we should update our copies
-    resp.keys.each do |attr|
-      if self.respond_to? "#{attr}=".to_sym
-        self.send(attr.to_s + '=', resp[attr.to_sym])
-      end
-    end
-
-    changes_applied
-    @new_record = false
-
-    self
-  end
-
-  def save!
-    self.save or raise Exception.new("Save failed")
-  end
-
-  def persisted?
-    (!new_record? && !destroyed?) ? true : false
-  end
-
-  def destroyed?
-    !(new_record? || etag || uuid)
-  end
-
-  def destroy
-    if etag || uuid
-      postdata = { '_method' => 'DELETE' }
-      resp = arvados_api_client.api(self.class, '/' + uuid, postdata)
-      resp[:etag] && resp[:uuid] && resp
-    else
-      true
-    end
-  end
-
-  def links(*args)
-    o = {}
-    o.merge!(args.pop) if args[-1].is_a? Hash
-    o[:link_class] ||= args.shift
-    o[:name] ||= args.shift
-    o[:tail_uuid] = self.uuid
-    if all_links
-      return all_links.select do |m|
-        ok = true
-        o.each do |k,v|
-          if !v.nil?
-            test_v = m.send(k)
-            if (v.respond_to?(:uuid) ? v.uuid : v.to_s) != (test_v.respond_to?(:uuid) ? test_v.uuid : test_v.to_s)
-              ok = false
-            end
-          end
-        end
-        ok
-      end
-    end
-    @links = arvados_api_client.api Link, '', { _method: 'GET', where: o, eager: true }
-    @links = arvados_api_client.unpack_api_response(@links)
-  end
-
-  def all_links
-    return @all_links if @all_links
-    res = arvados_api_client.api Link, '', {
-      _method: 'GET',
-      where: {
-        tail_kind: self.kind,
-        tail_uuid: self.uuid
-      },
-      eager: true
-    }
-    @all_links = arvados_api_client.unpack_api_response(res)
-  end
-
-  def reload
-    private_reload(self.uuid)
-  end
-
-  def private_reload(uuid_or_hash)
-    raise "No such object" if !uuid_or_hash
-    if uuid_or_hash.is_a? Hash
-      hash = uuid_or_hash
-    else
-      hash = arvados_api_client.api(self.class, '/' + uuid_or_hash)
-    end
-    hash.each do |k,v|
-      @loaded_attributes[k.to_s] = true
-      if self.respond_to?(k.to_s + '=')
-        self.send(k.to_s + '=', v)
-      else
-        # When ArvadosApiClient#schema starts telling us what to expect
-        # in API responses (not just the server side database
-        # columns), this sort of awfulness can be avoided:
-        self.instance_variable_set('@' + k.to_s, v)
-        if !self.respond_to? k
-          singleton = class << self; self end
-          singleton.send :define_method, k, lambda { instance_variable_get('@' + k.to_s) }
-        end
-      end
-    end
-    @all_links = nil
-    changes_applied
-    @new_record = false
-    self
-  end
-
-  def to_param
-    uuid
-  end
-
-  def initialize_copy orig
-    super
-    forget_uuid!
-  end
-
-  def attributes
-    kv = self.class.columns.collect {|c| c.name}.map {|key| [key, send(key)]}
-    kv.to_h
-  end
-
-  def attributes_for_display
-    self.attributes.reject { |k,v|
-      attribute_sortkey.has_key?(k) and !attribute_sortkey[k]
-    }.sort_by { |k,v|
-      attribute_sortkey[k] or k
-    }
-  end
-
-  def class_for_display
-    self.class.to_s.underscore.humanize
-  end
-
-  def self.class_for_display
-    self.to_s.underscore.humanize
-  end
-
-  # Array of strings that are names of attributes that should be rendered as textile.
-  def textile_attributes
-    []
-  end
-
-  def self.creatable?
-    current_user.andand.is_active && api_exists?(:create)
-  end
-
-  def self.goes_in_projects?
-    false
-  end
-
-  # can this class of object be copied into a project?
-  # override to false on indivudal model classes for which this should not be true
-  def self.copies_to_projects?
-    self.goes_in_projects?
-  end
-
-  def editable?
-    (current_user and current_user.is_active and
-     (current_user.is_admin or
-      current_user.uuid == self.owner_uuid or
-      new_record? or
-      (respond_to?(:writable_by) ?
-       writable_by.include?(current_user.uuid) :
-       (ArvadosBase.find(owner_uuid).writable_by.include? current_user.uuid rescue false)))) or false
-  end
-
-  def deletable?
-    editable?
-  end
-
-  def self.api_exists?(method)
-    arvados_api_client.discovery[:resources][self.to_s.underscore.pluralize.to_sym].andand[:methods].andand[method]
-  end
-
-  # Array of strings that are the names of attributes that can be edited
-  # with X-Editable.
-  def editable_attributes
-    self.class.columns.map(&:name) -
-      %w(created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at)
-  end
-
-  def attribute_editable?(attr, ever=nil)
-    if not editable_attributes.include?(attr.to_s)
-      false
-    elsif not (current_user.andand.is_active)
-      false
-    elsif attr == 'uuid'
-      current_user.is_admin
-    elsif ever
-      true
-    else
-      editable?
-    end
-  end
-
-  def self.resource_class_for_uuid(uuid, opts={})
-    if uuid.is_a? ArvadosBase
-      return uuid.class
-    end
-    unless uuid.is_a? String
-      return nil
-    end
-    if opts[:class].is_a? Class
-      return opts[:class]
-    end
-    if uuid.match(/^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/)
-      return Collection
-    end
-    resource_class = nil
-    uuid.match(/^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/) do |re|
-      resource_class ||= arvados_api_client.
-        kind_class(self.uuid_infix_object_kind[re[1]])
-    end
-    if opts[:referring_object] and
-        opts[:referring_attr] and
-        opts[:referring_attr].match(/_uuid$/)
-      resource_class ||= arvados_api_client.
-        kind_class(opts[:referring_object].
-                   attributes[opts[:referring_attr].
-                              sub(/_uuid$/, '_kind')])
-    end
-    resource_class
-  end
-
-  def resource_param_name
-    self.class.to_s.underscore
-  end
-
-  def friendly_link_name lookup=nil
-    (name if self.respond_to? :name) || default_name
-  end
-
-  def content_summary
-    self.class_for_display
-  end
-
-  def selection_label
-    friendly_link_name
-  end
-
-  def self.default_name
-    self.to_s.underscore.humanize
-  end
-
-  def controller
-    (self.class.to_s.pluralize + 'Controller').constantize
-  end
-
-  def controller_name
-    self.class.to_s.tableize
-  end
-
-  # Placeholder for name when name is missing or empty
-  def default_name
-    if self.respond_to? :name
-      "New #{class_for_display.downcase}"
-    else
-      uuid
-    end
-  end
-
-  def owner
-    ArvadosBase.find(owner_uuid) rescue nil
-  end
-
-  protected
-
-  def forget_uuid!
-    self.uuid = nil
-    @etag = nil
-    self
-  end
-
-  def self.current_user
-    Thread.current[:user] ||= User.current if Thread.current[:arvados_api_token]
-    Thread.current[:user]
-  end
-  def current_user
-    self.class.current_user
-  end
-end
diff --git a/apps/workbench/app/models/arvados_resource_list.rb b/apps/workbench/app/models/arvados_resource_list.rb
deleted file mode 100644 (file)
index 75a9429..0000000
+++ /dev/null
@@ -1,267 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ArvadosResourceList
-  include ArvadosApiClientHelper
-  include Enumerable
-
-  attr_reader :resource_class
-
-  def initialize resource_class=nil
-    @resource_class = resource_class
-    @fetch_multiple_pages = true
-    @arvados_api_token = Thread.current[:arvados_api_token]
-    @reader_tokens = Thread.current[:reader_tokens]
-    @results = nil
-    @count = nil
-    @offset = 0
-    @cond = nil
-    @eager = nil
-    @select = nil
-    @orderby_spec = nil
-    @filters = nil
-    @distinct = nil
-    @include_trash = nil
-    @limit = nil
-  end
-
-  def eager(bool=true)
-    @eager = bool
-    self
-  end
-
-  def distinct(bool=true)
-    @distinct = bool
-    self
-  end
-
-  def include_trash(option=nil)
-    @include_trash = option
-    self
-  end
-
-  def recursive(option=nil)
-    @recursive = option
-    self
-  end
-
-  def limit(max_results)
-    if not max_results.nil? and not max_results.is_a? Integer
-      raise ArgumentError("argument to limit() must be an Integer or nil")
-    end
-    @limit = max_results
-    self
-  end
-
-  def offset(skip)
-    @offset = skip
-    self
-  end
-
-  def order(orderby_spec)
-    @orderby_spec = orderby_spec
-    self
-  end
-
-  def select(columns=nil)
-    # If no column arguments were given, invoke Enumerable#select.
-    if columns.nil?
-      super()
-    else
-      @select ||= []
-      @select += columns
-      self
-    end
-  end
-
-  def filter _filters
-    @filters ||= []
-    @filters += _filters
-    self
-  end
-
-  def where(cond)
-    @cond = cond.dup
-    @cond.keys.each do |uuid_key|
-      if @cond[uuid_key] and (@cond[uuid_key].is_a? Array or
-                             @cond[uuid_key].is_a? ArvadosBase)
-        # Coerce cond[uuid_key] to an array of uuid strings.  This
-        # allows caller the convenience of passing an array of real
-        # objects and uuids in cond[uuid_key].
-        if !@cond[uuid_key].is_a? Array
-          @cond[uuid_key] = [@cond[uuid_key]]
-        end
-        @cond[uuid_key] = @cond[uuid_key].collect do |item|
-          if item.is_a? ArvadosBase
-            item.uuid
-          else
-            item
-          end
-        end
-      end
-    end
-    @cond.keys.select { |x| x.match(/_kind$/) }.each do |kind_key|
-      if @cond[kind_key].is_a? Class
-        @cond = @cond.merge({ kind_key => 'arvados#' + arvados_api_client.class_kind(@cond[kind_key]) })
-      end
-    end
-    self
-  end
-
-  # with_count sets the 'count' parameter to 'exact' or 'none' -- see
-  # https://doc.arvados.org/api/methods.html#index
-  def with_count(count_param='exact')
-    @count = count_param
-    self
-  end
-
-  def fetch_multiple_pages(f)
-    @fetch_multiple_pages = f
-    self
-  end
-
-  def results
-    if !@results
-      @results = []
-      self.each_page do |r|
-        @results.concat r
-      end
-    end
-    @results
-  end
-
-  def results=(r)
-    @results = r
-    @items_available = r.items_available if r.respond_to? :items_available
-    @result_limit = r.limit if r.respond_to? :limit
-    @result_offset = r.offset if r.respond_to? :offset
-    @results
-  end
-
-  def to_ary
-    results
-  end
-
-  def each(&block)
-    if not @results.nil?
-      @results.each(&block)
-    else
-      results = []
-      self.each_page do |items|
-        items.each do |i|
-          results << i
-          block.call i
-        end
-      end
-      # Cache results only if all were retrieved (block didn't raise
-      # an exception).
-      @results = results
-    end
-    self
-  end
-
-  def first
-    results.first
-  end
-
-  def last
-    results.last
-  end
-
-  def [](*x)
-    results.send('[]', *x)
-  end
-
-  def |(x)
-    if x.is_a? Hash
-      self.to_hash | x
-    else
-      results | x.to_ary
-    end
-  end
-
-  def to_hash
-    Hash[self.collect { |x| [x.uuid, x] }]
-  end
-
-  def empty?
-    self.first.nil?
-  end
-
-  def items_available
-    results
-    @items_available
-  end
-
-  def result_limit
-    results
-    @result_limit
-  end
-
-  def result_offset
-    results
-    @result_offset
-  end
-
-  # Obsolete method retained during api transition.
-  def links_for item_or_uuid, link_class=false
-    []
-  end
-
-  protected
-
-  def each_page
-    api_params = {
-      _method: 'GET'
-    }
-    api_params[:count] = @count if @count
-    api_params[:where] = @cond if @cond
-    api_params[:eager] = '1' if @eager
-    api_params[:select] = @select if @select
-    api_params[:order] = @orderby_spec if @orderby_spec
-    api_params[:filters] = @filters if @filters
-    api_params[:distinct] = @distinct if @distinct
-    api_params[:include_trash] = @include_trash if @include_trash
-    api_params[:cluster_id] = Rails.configuration.ClusterID
-    if @fetch_multiple_pages
-      # Default limit to (effectively) api server's MAX_LIMIT
-      api_params[:limit] = 2**(0.size*8 - 1) - 1
-    end
-
-    item_count = 0
-    offset = @offset || 0
-    @result_limit = nil
-    @result_offset = nil
-
-    begin
-      api_params[:offset] = offset
-      api_params[:limit] = (@limit - item_count) if @limit
-
-      res = arvados_api_client.api(@resource_class, '', api_params,
-                                   arvados_api_token: @arvados_api_token,
-                                   reader_tokens: @reader_tokens)
-      items = arvados_api_client.unpack_api_response res
-
-      @items_available = items.items_available if items.respond_to?(:items_available)
-      @result_limit = items.limit if (@fetch_multiple_pages == false) and items.respond_to?(:limit)
-      @result_offset = items.offset if (@fetch_multiple_pages == false) and items.respond_to?(:offset)
-
-      break if items.nil? or not items.any?
-
-      item_count += items.size
-      if items.respond_to?(:offset)
-        offset = items.offset + items.size
-      else
-        offset = item_count
-      end
-
-      yield items
-
-      break if @limit and item_count >= @limit
-      break if items.respond_to? :items_available and offset >= items.items_available
-    end while @fetch_multiple_pages
-    self
-  end
-
-end
diff --git a/apps/workbench/app/models/authorized_key.rb b/apps/workbench/app/models/authorized_key.rb
deleted file mode 100644 (file)
index 9809eef..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class AuthorizedKey < ArvadosBase
-  def attribute_editable?(attr, ever=nil)
-    if (attr.to_s == 'authorized_user_uuid') and (not ever)
-      current_user.andand.is_admin
-    else
-      super
-    end
-  end
-
-  def self.creatable?
-    false
-  end
-end
diff --git a/apps/workbench/app/models/collection.rb b/apps/workbench/app/models/collection.rb
deleted file mode 100644 (file)
index ead2c95..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require "arvados/keep"
-
-class Collection < ArvadosBase
-  MD5_EMPTY = 'd41d8cd98f00b204e9800998ecf8427e'
-
-  def default_name
-    if Collection.is_empty_blob_locator? self.uuid
-      "Empty Collection"
-    else
-      super
-    end
-  end
-
-  # Return true if the given string is the locator of a zero-length blob
-  def self.is_empty_blob_locator? locator
-    !!locator.to_s.match("^#{MD5_EMPTY}(\\+.*)?\$")
-  end
-
-  def self.goes_in_projects?
-    true
-  end
-
-  def manifest
-    if @manifest.nil? or manifest_text_changed?
-      @manifest = Keep::Manifest.new(manifest_text || "")
-    end
-    @manifest
-  end
-
-  def files
-    # This method provides backwards compatibility for code that relied on
-    # the old files field in API results.  New code should use manifest
-    # methods directly.
-    manifest.files
-  end
-
-  def content_summary
-    if total_bytes > 0
-      ApplicationController.helpers.human_readable_bytes_html(total_bytes) + " " + super
-    else
-      super + " modified at " + modified_at.to_s
-    end
-  end
-
-  def total_bytes
-    manifest.files.inject(0) { |sum, filespec| sum + filespec.last }
-  end
-
-  def files_tree
-    tree = manifest.files.group_by do |file_spec|
-      File.split(file_spec.first)
-    end
-    return [] if tree.empty?
-    # Fill in entries for empty directories.
-    tree.keys.map { |basedir, _| File.split(basedir) }.each do |splitdir|
-      until tree.include?(splitdir)
-        tree[splitdir] = []
-        splitdir = File.split(splitdir.first)
-      end
-    end
-    dir_to_tree = lambda do |dirname|
-      # First list subdirectories, with their files inside.
-      subnodes = tree.keys.select { |bd, td| (bd == dirname) and (td != '.') }
-        .sort.flat_map do |parts|
-        [parts + [nil]] + dir_to_tree.call(File.join(parts))
-      end
-      # Then extend that list with files in this directory, except the empty dir placeholders (0:0:. files).
-      subnodes + tree[File.split(dirname)].reject { |_, basename, size| (basename == '.') and (size == 0) }
-    end
-    dir_to_tree.call('.')
-  end
-
-  def editable_attributes
-    %w(name description manifest_text filename)
-  end
-
-  def provenance
-    arvados_api_client.api "collections/#{self.uuid}/", "provenance"
-  end
-
-  def used_by
-    arvados_api_client.api "collections/#{self.uuid}/", "used_by"
-  end
-
-  def friendly_link_name lookup=nil
-    name || portable_data_hash
-  end
-
-  def textile_attributes
-    [ 'description' ]
-  end
-
-  def untrash
-    arvados_api_client.api(self.class, "/#{self.uuid}/untrash", {"ensure_unique_name" => true})
-  end
-end
diff --git a/apps/workbench/app/models/container.rb b/apps/workbench/app/models/container.rb
deleted file mode 100644 (file)
index 8de28ae..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Container < ArvadosBase
-  def self.creatable?
-    false
-  end
-
-  def work_unit(label=nil, child_objects=nil)
-    ContainerWorkUnit.new(self, label, self.uuid, child_objects=child_objects)
-  end
-end
diff --git a/apps/workbench/app/models/container_request.rb b/apps/workbench/app/models/container_request.rb
deleted file mode 100644 (file)
index be97a6c..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ContainerRequest < ArvadosBase
-  def self.creatable?
-    false
-  end
-
-  def textile_attributes
-    [ 'description' ]
-  end
-
-  def self.goes_in_projects?
-    true
-  end
-
-  def self.copies_to_projects?
-    false
-  end
-
-  def work_unit(label=nil, child_objects=nil)
-    ContainerWorkUnit.new(self, label, self.uuid, child_objects=child_objects)
-  end
-
-  def editable_attributes
-    super + ["reuse_steps"]
-  end
-
-  def reuse_steps
-    command.each do |arg|
-      if arg == "--enable-reuse"
-        return true
-      end
-    end
-    false
-  end
-
-  def self.attribute_info
-    self.columns
-    @attribute_info[:reuse_steps] = {:type => "boolean"}
-    @attribute_info
-  end
-
-end
diff --git a/apps/workbench/app/models/container_work_unit.rb b/apps/workbench/app/models/container_work_unit.rb
deleted file mode 100644 (file)
index 292bc36..0000000
+++ /dev/null
@@ -1,236 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ContainerWorkUnit < ProxyWorkUnit
-  attr_accessor :container
-  attr_accessor :child_proxies
-
-  def initialize proxied, label, parent, child_objects=nil
-    super proxied, label, parent
-    if @proxied.is_a?(ContainerRequest)
-      container_uuid = get(:container_uuid)
-      if container_uuid
-        @container = Container.find(container_uuid)
-      end
-    end
-    @container = nil if !defined?(@container)
-    @child_proxies = child_objects
-  end
-
-  def children
-    return @my_children if @my_children
-
-    items = []
-    container_uuid = if @proxied.is_a?(Container) then uuid else get(:container_uuid) end
-    if container_uuid
-      cols = ContainerRequest.columns.map(&:name) - %w(id updated_at mounts secret_mounts runtime_token)
-      my_children = @child_proxies || ContainerRequest.select(cols).where(requesting_container_uuid: container_uuid).with_count("none").results if !my_children
-      my_child_containers = my_children.map(&:container_uuid).compact.uniq
-      grandchildren = {}
-      my_child_containers.each { |c| grandchildren[c] = []} if my_child_containers.any?
-      reqs = ContainerRequest.select(cols).where(requesting_container_uuid: my_child_containers).order(["requesting_container_uuid", "uuid"]).with_count("none").results if my_child_containers.any?
-      reqs.each {|cr| grandchildren[cr.requesting_container_uuid] << cr} if reqs
-
-      my_children.each do |cr|
-        items << cr.work_unit(cr.name || 'this container', child_objects=grandchildren[cr.container_uuid])
-      end
-    end
-
-    @child_proxies = nil #no need of this any longer
-    @my_children = items
-  end
-
-  def title
-    "container"
-  end
-
-  def uri
-    uuid = get(:uuid)
-
-    return nil unless uuid
-
-    if @proxied.class.respond_to? :table_name
-      "/#{@proxied.class.table_name}/#{uuid}"
-    else
-      resource_class = ArvadosBase.resource_class_for_uuid(uuid)
-      "#{resource_class.table_name}/#{uuid}" if resource_class
-    end
-  end
-
-  def can_cancel?
-    @proxied.is_a?(ContainerRequest) &&
-      @proxied.state == "Committed" &&
-      (@proxied.priority > 0 || get(:state, @container) != 'Running') &&
-      @proxied.editable?
-  end
-
-  def container_uuid
-    get(:container_uuid)
-  end
-
-  def requesting_container_uuid
-    get(:requesting_container_uuid)
-  end
-
-  def priority
-    @proxied.priority
-  end
-
-  # For the following properties, use value from the @container if exists
-  # This applies to a ContainerRequest with container_uuid
-
-  def started_at
-    t = get_combined(:started_at)
-    t = Time.parse(t) if (t.is_a? String)
-    t
-  end
-
-  def modified_at
-    t = get_combined(:modified_at)
-    t = Time.parse(t) if (t.is_a? String)
-    t
-  end
-
-  def finished_at
-    t = get_combined(:finished_at)
-    t = Time.parse(t) if (t.is_a? String)
-    t
-  end
-
-  def state_label
-    if get(:state) == 'Final' && get(:state, @container) != 'Complete'
-      # Request was finalized before its container started (or the
-      # container was cancelled)
-      return 'Cancelled'
-    end
-    state = get(:state, @container) || get(:state, @proxied)
-    case state
-    when 'Locked', 'Queued'
-      if priority == 0
-        'On hold'
-      else
-        'Queued'
-      end
-    when 'Complete'
-      if exit_code == 0
-        state
-      else
-        'Failed'
-      end
-    when 'Running'
-      if runtime_status[:error]
-        'Failing'
-      elsif runtime_status[:warning]
-        'Warning'
-      else
-        state
-      end
-    else
-      # Cancelled, or Uncommitted (no container assigned)
-      state
-    end
-  end
-
-  def runtime_status
-    return get(:runtime_status, @container) || get(:runtime_status, @proxied)
-  end
-
-  def state_bootstrap_class
-    case state_label
-    when 'Failing'
-      'danger'
-    when 'Warning'
-      'warning'
-    else
-      super
-    end
-  end
-
-  def exit_code
-    get_combined(:exit_code)
-  end
-
-  def docker_image
-    get_combined(:container_image)
-  end
-
-  def runtime_constraints
-    get_combined(:runtime_constraints)
-  end
-
-  def log_collection
-    if @proxied.is_a?(ContainerRequest)
-      get(:log_uuid)
-    else
-      get(:log)
-    end
-  end
-
-  def outputs
-    items = []
-    if @proxied.is_a?(ContainerRequest)
-      out = get(:output_uuid)
-    else
-      out = get(:output)
-    end
-    items << out if out
-    items
-  end
-
-  def command
-    get_combined(:command)
-  end
-
-  def cwd
-    get_combined(:cwd)
-  end
-
-  def environment
-    env = get_combined(:environment)
-    env = nil if env.andand.empty?
-    env
-  end
-
-  def mounts
-    mnt = get_combined(:mounts)
-    mnt = nil if mnt.andand.empty?
-    mnt
-  end
-
-  def output_path
-    get_combined(:output_path)
-  end
-
-  def log_object_uuids
-    [get(:uuid, @container), get(:uuid, @proxied)].compact
-  end
-
-  def render_log
-    collection = Collection.find(log_collection) rescue nil
-    if collection
-      return {log: collection, partial: 'collections/show_files', locals: {object: collection, no_checkboxes: true}}
-    end
-  end
-
-  def template_uuid
-    properties = get(:properties)
-    if properties
-      properties[:template_uuid]
-    end
-  end
-
-  # End combined properties
-
-  protected
-  def get_combined key
-    from_container = get(key, @container)
-    from_proxied = get(key, @proxied)
-
-    if from_container.is_a? Hash or from_container.is_a? Array
-      if from_container.any? then from_container else from_proxied end
-    else
-      from_container || from_proxied
-    end
-  end
-end
diff --git a/apps/workbench/app/models/group.rb b/apps/workbench/app/models/group.rb
deleted file mode 100644 (file)
index ea3da2d..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Group < ArvadosBase
-  def self.goes_in_projects?
-    true
-  end
-
-  def self.copies_to_projects?
-    false
-  end
-
-  def self.contents params={}
-    res = arvados_api_client.api self, "/contents", {
-      _method: 'GET'
-    }.merge(params)
-    ret = ArvadosResourceList.new
-    ret.results = arvados_api_client.unpack_api_response(res)
-    ret
-  end
-
-  def editable?
-    if group_class == 'filter'
-      return false
-    end
-    super
-  end
-
-  def contents params={}
-    res = arvados_api_client.api self.class, "/#{self.uuid}/contents", {
-      _method: 'GET'
-    }.merge(params)
-    ret = ArvadosResourceList.new
-    ret.results = arvados_api_client.unpack_api_response(res)
-    ret
-  end
-
-  def class_for_display
-    (group_class == 'project' or group_class == 'filter') ? 'Project' : super
-  end
-
-  def textile_attributes
-    [ 'description' ]
-  end
-
-  def self.creatable?
-    false
-  end
-
-  def untrash
-    arvados_api_client.api(self.class, "/#{self.uuid}/untrash", {"ensure_unique_name" => true})
-  end
-end
diff --git a/apps/workbench/app/models/human.rb b/apps/workbench/app/models/human.rb
deleted file mode 100644 (file)
index c1acef5..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Human < ArvadosBase
-  def self.goes_in_projects?
-    true
-  end
-end
diff --git a/apps/workbench/app/models/job.rb b/apps/workbench/app/models/job.rb
deleted file mode 100644 (file)
index 7c55d9e..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Job < ArvadosBase
-  def self.goes_in_projects?
-    true
-  end
-
-  def content_summary
-    "#{script} job"
-  end
-
-  def editable_attributes
-    %w(description)
-  end
-
-  def default_name
-    if script
-      x = "\"#{script}\" job"
-    else
-      x = super
-    end
-    if finished_at
-      x += " finished #{finished_at.strftime('%b %-d')}"
-    elsif started_at
-      x += " started #{started_at.strftime('%b %-d')}"
-    elsif created_at
-      x += " submitted #{created_at.strftime('%b %-d')}"
-    end
-  end
-
-  def cancel
-    arvados_api_client.api "jobs/#{self.uuid}/", "cancel", {"cascade" => true}
-  end
-
-  def self.queue_size
-    arvados_api_client.api("jobs/", "queue_size", {"_method"=> "GET"})[:queue_size] rescue 0
-  end
-
-  def self.queue
-    arvados_api_client.unpack_api_response arvados_api_client.api("jobs/", "queue", {"_method"=> "GET"})
-  end
-
-  def textile_attributes
-    [ 'description' ]
-  end
-
-  def stderr_log_query(limit=nil)
-    query = Log.where(object_uuid: self.uuid).order("created_at DESC").with_count('none')
-    query = query.limit(limit) if limit
-    query
-  end
-
-  def stderr_log_lines(limit=2000)
-    stderr_log_query(limit).results.reverse.
-      flat_map { |log| log.properties[:text].split("\n") rescue [] }
-  end
-
-  def work_unit(label=nil)
-    JobWorkUnit.new(self, label, self.uuid)
-  end
-end
diff --git a/apps/workbench/app/models/job_task.rb b/apps/workbench/app/models/job_task.rb
deleted file mode 100644 (file)
index b10a2b0..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class JobTask < ArvadosBase
-  def work_unit(label=nil)
-    JobTaskWorkUnit.new(self, label, self.uuid)
-  end
-end
diff --git a/apps/workbench/app/models/job_task_work_unit.rb b/apps/workbench/app/models/job_task_work_unit.rb
deleted file mode 100644 (file)
index f5cd526..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class JobTaskWorkUnit < ProxyWorkUnit
-  def title
-    "job task"
-  end
-end
diff --git a/apps/workbench/app/models/job_work_unit.rb b/apps/workbench/app/models/job_work_unit.rb
deleted file mode 100644 (file)
index 83825a5..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class JobWorkUnit < ProxyWorkUnit
-  def children
-    return @my_children if @my_children
-
-    # Jobs components
-    items = []
-    components = get(:components)
-    uuids = components.andand.collect {|_, v| v}
-    return items if (!uuids or uuids.empty?)
-
-    rcs = {}
-    uuids.each do |u|
-      r = ArvadosBase::resource_class_for_uuid(u)
-      rcs[r] = [] unless rcs[r]
-      rcs[r] << u
-    end
-    rcs.each do |rc, ids|
-      rc.where(uuid: ids).each do |obj|
-        items << obj.work_unit(components.key(obj.uuid))
-      end
-    end
-
-    @my_children = items
-  end
-
-  def child_summary
-    if children.any?
-      super
-    else
-      get(:tasks_summary)
-    end
-  end
-
-  def parameters
-    get(:script_parameters)
-  end
-
-  def repository
-    get(:repository)
-  end
-
-  def script
-    get(:script)
-  end
-
-  def script_version
-    get(:script_version)
-  end
-
-  def supplied_script_version
-    get(:supplied_script_version)
-  end
-
-  def docker_image
-    get(:docker_image_locator)
-  end
-
-  def nondeterministic
-    get(:nondeterministic)
-  end
-
-  def runtime_constraints
-    get(:runtime_constraints)
-  end
-
-  def priority
-    get(:priority)
-  end
-
-  def log_collection
-    get(:log)
-  end
-
-  def outputs
-    items = []
-    items << get(:output) if get(:output)
-    items
-  end
-
-  def can_cancel?
-    state_label.in? ["Queued", "Running"]
-  end
-
-  def confirm_cancellation
-    "All unfinished child jobs and pipelines will also be canceled, even if they are being used in another job or pipeline. Are you sure you want to cancel this job?"
-  end
-
-  def uri
-    uuid = get(:uuid)
-    "/jobs/#{uuid}"
-  end
-
-  def title
-    "job"
-  end
-end
diff --git a/apps/workbench/app/models/keep_disk.rb b/apps/workbench/app/models/keep_disk.rb
deleted file mode 100644 (file)
index f4fea2c..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class KeepDisk < ArvadosBase
-  def self.creatable?
-    false
-  end
-end
diff --git a/apps/workbench/app/models/keep_service.rb b/apps/workbench/app/models/keep_service.rb
deleted file mode 100644 (file)
index 2fea18a..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class KeepService < ArvadosBase
-  def self.creatable?
-    false
-  end
-end
diff --git a/apps/workbench/app/models/link.rb b/apps/workbench/app/models/link.rb
deleted file mode 100644 (file)
index 920b4bd..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Link < ArvadosBase
-  attr_accessor :head
-  attr_accessor :tail
-  def self.by_tail(t, opts={})
-    where(opts.merge :tail_uuid => t.uuid)
-  end
-
-  def default_name
-    self.class.resource_class_for_uuid(head_uuid).default_name rescue super
-  end
-
-  def self.permissions_for(thing)
-    if thing.respond_to? :uuid
-      uuid = thing.uuid
-    else
-      uuid = thing
-    end
-    result = arvados_api_client.api("permissions", "/#{uuid}")
-    arvados_api_client.unpack_api_response(result)
-  end
-
-  def self.creatable?
-    false
-  end
-end
diff --git a/apps/workbench/app/models/log.rb b/apps/workbench/app/models/log.rb
deleted file mode 100644 (file)
index 6bbefa1..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Log < ArvadosBase
-  attr_accessor :object
-  def self.creatable?
-    # Technically yes, but not worth offering: it will be empty, and
-    # you won't be able to edit it.
-    false
-  end
-end
diff --git a/apps/workbench/app/models/node.rb b/apps/workbench/app/models/node.rb
deleted file mode 100644 (file)
index 785cc4f..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Node < ArvadosBase
-  def self.creatable?
-    false
-  end
-  def friendly_link_name lookup=nil
-    (hostname && !hostname.empty?) ? hostname : uuid
-  end
-end
diff --git a/apps/workbench/app/models/pipeline_instance.rb b/apps/workbench/app/models/pipeline_instance.rb
deleted file mode 100644 (file)
index d481f41..0000000
+++ /dev/null
@@ -1,153 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require "arvados/keep"
-
-class PipelineInstance < ArvadosBase
-  attr_accessor :pipeline_template
-
-  def self.goes_in_projects?
-    true
-  end
-
-  def friendly_link_name lookup=nil
-    pipeline_name = self.name
-    if pipeline_name.nil? or pipeline_name.empty?
-      template = if lookup and lookup[self.pipeline_template_uuid]
-                   lookup[self.pipeline_template_uuid]
-                 else
-                   PipelineTemplate.find?(self.pipeline_template_uuid) if self.pipeline_template_uuid
-                 end
-      if template
-        template.name
-      else
-        self.uuid
-      end
-    else
-      pipeline_name
-    end
-  end
-
-  def content_summary
-    begin
-      PipelineTemplate.find(pipeline_template_uuid).name
-    rescue
-      super
-    end
-  end
-
-  def update_job_parameters(new_params)
-    self.components[:steps].each_with_index do |step, i|
-      step[:params].each do |param|
-        if new_params.has_key?(new_param_name = "#{i}/#{param[:name]}") or
-            new_params.has_key?(new_param_name = "#{step[:name]}/#{param[:name]}") or
-            new_params.has_key?(new_param_name = param[:name])
-          param_type = :value
-          %w(hash data_locator).collect(&:to_sym).each do |ptype|
-            param_type = ptype if param.has_key? ptype
-          end
-          param[param_type] = new_params[new_param_name]
-        end
-      end
-    end
-  end
-
-  def editable_attributes
-    %w(name description components)
-  end
-
-  def attribute_editable?(name, ever=nil)
-    if name.to_s == "components"
-      (ever or %w(New Ready).include?(state)) and super
-    else
-      super
-    end
-  end
-
-  def attributes_for_display
-    super.reject { |k,v| k == 'components' }
-  end
-
-  def self.creatable?
-    false
-  end
-
-  def component_input_title(component_name, input_name)
-    component = components[component_name]
-    return nil if component.nil?
-    param_info = component[:script_parameters].andand[input_name.to_sym]
-    if param_info.is_a?(Hash) and param_info[:title]
-      param_info[:title]
-    else
-      "\"#{input_name.to_s}\" parameter for #{component[:script]} script in #{component_name} component"
-    end
-  end
-
-  def textile_attributes
-    [ 'description' ]
-  end
-
-  def job_uuids
-    components_map { |cspec| cspec[:job][:uuid] rescue nil }
-  end
-
-  def job_log_ids
-    components_map { |cspec| cspec[:job][:log] rescue nil }
-  end
-
-  def job_ids
-    components_map { |cspec| cspec[:job][:uuid] rescue nil }
-  end
-
-  def stderr_log_object_uuids
-    result = job_uuids.values.compact
-    result << uuid
-  end
-
-  def stderr_log_query(limit=nil)
-    query = Log.
-            with_count('none').
-            where(event_type: "stderr",
-                  object_uuid: stderr_log_object_uuids).
-            order("created_at DESC")
-    unless limit.nil?
-      query = query.limit(limit)
-    end
-    query
-  end
-
-  def stderr_log_lines(limit=2000)
-    stderr_log_query(limit).results.reverse.
-      flat_map { |log| log.properties[:text].split("\n") rescue [] }
-  end
-
-  def has_readable_logs?
-    log_pdhs, log_uuids = job_log_ids.values.compact.partition do |loc_s|
-      Keep::Locator.parse(loc_s)
-    end
-    if log_pdhs.any? and
-        Collection.where(portable_data_hash: log_pdhs).limit(1).with_count("none").results.any?
-      true
-    elsif log_uuids.any? and
-        Collection.where(uuid: log_uuids).limit(1).with_count("none").results.any?
-      true
-    else
-      stderr_log_query(1).results.any?
-    end
-  end
-
-  def work_unit(label=nil)
-    PipelineInstanceWorkUnit.new(self, label || self.name, self.uuid)
-  end
-
-  def cancel
-    arvados_api_client.api "pipeline_instances/#{self.uuid}/", "cancel", {"cascade" => true}
-  end
-
-  private
-
-  def components_map
-    Hash[components.map { |cname, cspec| [cname, yield(cspec)] }]
-  end
-end
diff --git a/apps/workbench/app/models/pipeline_instance_work_unit.rb b/apps/workbench/app/models/pipeline_instance_work_unit.rb
deleted file mode 100644 (file)
index 1d75f58..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class PipelineInstanceWorkUnit < ProxyWorkUnit
-  def children
-    return @my_children if @my_children
-
-    items = []
-
-    jobs = {}
-    results = Job.where(uuid: @proxied.job_ids.values).with_count("none").results
-    results.each do |j|
-      jobs[j.uuid] = j
-    end
-
-    components = get(:components)
-    components.each do |name, c|
-      if c.is_a?(Hash)
-        job = c[:job]
-        if job
-          if job[:uuid] and jobs[job[:uuid]]
-            items << jobs[job[:uuid]].work_unit(name)
-          else
-            items << JobWorkUnit.new(job, name, uuid)
-          end
-        else
-          items << JobWorkUnit.new(c, name, uuid)
-        end
-      else
-        @unreadable_children = true
-        break
-      end
-    end
-
-    @my_children = items
-  end
-
-  def outputs
-    items = []
-    components = get(:components)
-    components.each do |name, c|
-      if c.is_a?(Hash)
-        items << c[:output_uuid] if c[:output_uuid]
-      end
-    end
-    items
-  end
-
-  def uri
-    uuid = get(:uuid)
-    "/pipeline_instances/#{uuid}"
-  end
-
-  def title
-    "pipeline"
-  end
-
-  def template_uuid
-    get(:pipeline_template_uuid)
-  end
-
-  def state_label
-    if get(:state) != "Failed"
-      return super
-    end
-    if get(:components_summary).andand[:failed].andand > 0
-      return super
-    end
-    # Show "Cancelled" instead of "Failed" if there are no failed
-    # components. #12840
-    get(:components).each do |_, c|
-      jstate = c[:job][:state] rescue nil
-      if jstate == "Failed"
-        return "Failed"
-      end
-    end
-    "Cancelled"
-  end
-end
diff --git a/apps/workbench/app/models/pipeline_template.rb b/apps/workbench/app/models/pipeline_template.rb
deleted file mode 100644 (file)
index bce0f08..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class PipelineTemplate < ArvadosBase
-  def self.goes_in_projects?
-    true
-  end
-
-  def self.creatable?
-    false
-  end
-
-  def textile_attributes
-    [ 'description' ]
-  end
-end
diff --git a/apps/workbench/app/models/proxy_work_unit.rb b/apps/workbench/app/models/proxy_work_unit.rb
deleted file mode 100644 (file)
index adf0bd7..0000000
+++ /dev/null
@@ -1,339 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ProxyWorkUnit < WorkUnit
-  require 'time'
-
-  attr_accessor :lbl
-  attr_accessor :proxied
-  attr_accessor :my_children
-  attr_accessor :unreadable_children
-
-  def initialize proxied, label, parent
-    @lbl = label
-    @proxied = proxied
-    @parent = parent
-  end
-
-  def label
-    @lbl
-  end
-
-  def uuid
-    get(:uuid)
-  end
-
-  def parent
-    @parent
-  end
-
-  def modified_by_user_uuid
-    get(:modified_by_user_uuid)
-  end
-
-  def owner_uuid
-    get(:owner_uuid)
-  end
-
-  def created_at
-    t = get(:created_at)
-    t = Time.parse(t) if (t.is_a? String)
-    t
-  end
-
-  def started_at
-    t = get(:started_at)
-    t = Time.parse(t) if (t.is_a? String)
-    t
-  end
-
-  def modified_at
-    t = get(:modified_at)
-    t = Time.parse(t) if (t.is_a? String)
-    t
-  end
-
-  def finished_at
-    t = get(:finished_at)
-    t = Time.parse(t) if (t.is_a? String)
-    t
-  end
-
-  def state_label
-    state = get(:state)
-    if ["Running", "RunningOnServer", "RunningOnClient"].include? state
-      "Running"
-    elsif state == 'New'
-      "Not started"
-    else
-      state
-    end
-  end
-
-  def state_bootstrap_class
-    state = state_label
-    case state
-    when 'Complete'
-      'success'
-    when 'Failed', 'Cancelled'
-      'danger'
-    when 'Running', 'RunningOnServer', 'RunningOnClient'
-      'info'
-    else
-      'default'
-    end
-  end
-
-  def success?
-    state = state_label
-    if state == 'Complete'
-      true
-    elsif state == 'Failed' or state == 'Cancelled'
-      false
-    else
-      nil
-    end
-  end
-
-  def child_summary
-    done = 0
-    failed = 0
-    todo = 0
-    running = 0
-    children.each do |c|
-      case c.state_label
-      when 'Complete'
-        done = done+1
-      when 'Failed', 'Cancelled'
-        failed = failed+1
-      when 'Running'
-        running = running+1
-      else
-        todo = todo+1
-      end
-    end
-
-    summary = {}
-    summary[:done] = done
-    summary[:failed] = failed
-    summary[:todo] = todo
-    summary[:running] = running
-    summary
-  end
-
-  def child_summary_str
-    summary = child_summary
-    summary_txt = ''
-
-    if state_label == 'Running'
-      done = summary[:done] || 0
-      running = summary[:running] || 0
-      failed = summary[:failed] || 0
-      todo = summary[:todo] || 0
-      total = done + running + failed + todo
-
-      if total > 0
-        summary_txt += "#{summary[:done]} #{'child'.pluralize(summary[:done])} done,"
-        summary_txt += "#{summary[:failed]} failed,"
-        summary_txt += "#{summary[:running]} running,"
-        summary_txt += "#{summary[:todo]} pending"
-      end
-    end
-    summary_txt
-  end
-
-  def progress
-    state = state_label
-    if state == 'Complete'
-      return 1.0
-    elsif state == 'Failed' or state == 'Cancelled'
-      return 0.0
-    end
-
-    summary = child_summary
-    return 0.0 if summary.nil?
-
-    done = summary[:done] || 0
-    running = summary[:running] || 0
-    failed = summary[:failed] || 0
-    todo = summary[:todo] || 0
-    total = done + running + failed + todo
-    if total > 0
-      (done+failed).to_f / total
-    else
-      0.0
-    end
-  end
-
-  def children
-    []
-  end
-
-  def outputs
-    []
-  end
-
-  def title
-    "process"
-  end
-
-  def has_unreadable_children
-    @unreadable_children
-  end
-
-  def walltime
-    if state_label != "Queued"
-      if started_at
-        ((if finished_at then finished_at else Time.now() end) - started_at)
-      end
-    end
-  end
-
-  def cputime
-    if children.any?
-      children.map { |c|
-        c.cputime
-      }.reduce(:+) || 0
-    else
-      if started_at
-        (runtime_constraints.andand[:min_nodes] || 1).to_i * ((finished_at || Time.now()) - started_at)
-      else
-        0
-      end
-    end
-  end
-
-  def queuedtime
-    if state_label == "Queued"
-      Time.now - Time.parse(created_at.to_s)
-    end
-  end
-
-  def is_running?
-    state_label == 'Running'
-  end
-
-  def is_paused?
-    state_label == 'Paused'
-  end
-
-  def is_finished?
-    state_label.in? ["Complete", "Failed", "Cancelled"]
-  end
-
-  def is_failed?
-    state_label == 'Failed'
-  end
-
-  def runtime_contributors
-    contributors = []
-    if children.any?
-      children.each{|c| contributors << c.runtime_contributors}
-    else
-      contributors << self
-    end
-    contributors.flatten
-  end
-
-  def runningtime
-    ApplicationController.helpers.determine_wallclock_runtime runtime_contributors
-  end
-
-  def show_runtime
-    walltime = 0
-    running_time = runningtime
-    if started_at
-      walltime = if finished_at then (finished_at - started_at) else (Time.now - started_at) end
-    end
-    resp = '<p>'
-
-    if started_at
-      resp << "This #{title} started at "
-      resp << ApplicationController.helpers.render_localized_date(started_at)
-      resp << ". It "
-      if state_label == 'Complete'
-        resp << "completed in "
-      elsif state_label == 'Failed'
-        resp << "failed after "
-      elsif state_label == 'Cancelled'
-        resp << "was cancelled after "
-      else
-        resp << "has been active for "
-      end
-
-      resp << ApplicationController.helpers.render_time(walltime, false)
-
-      if finished_at
-        resp << " at "
-        resp << ApplicationController.helpers.render_localized_date(finished_at)
-      end
-      resp << "."
-    else
-      if state_label
-        resp << "This #{title} is "
-        resp << if state_label == 'Running' then 'active' else state_label.downcase end
-        resp << "."
-      end
-    end
-
-    if is_failed?
-      if runtime_status.andand[:error]
-        resp << " Check the error information below."
-      else
-        resp << " Check the Log tab for more detail about why it failed."
-      end
-    end
-    resp << "</p>"
-
-    resp << "<p>"
-    if state_label
-      resp << "It has runtime of "
-
-      cpu_time = cputime
-
-      resp << ApplicationController.helpers.render_time(running_time, false)
-      if (walltime - running_time) > 0
-        resp << "("
-        resp << ApplicationController.helpers.render_time(walltime - running_time, false)
-        resp << "queued)"
-      end
-      if cpu_time == 0
-        resp << "."
-      else
-        resp << " and used "
-        resp << ApplicationController.helpers.render_time(cpu_time, false)
-        resp << " of node allocation time ("
-        resp << (cpu_time/running_time).round(1).to_s
-        resp << "&Cross; scaling)."
-      end
-    end
-    resp << "</p>"
-
-    resp
-  end
-
-  def log_object_uuids
-    [uuid]
-  end
-
-  def live_log_lines(limit)
-    Log.where(object_uuid: log_object_uuids).
-      order("created_at DESC").
-      limit(limit).
-      with_count('none').
-      select { |log| log.properties[:text].is_a? String }.
-      reverse.
-      flat_map { |log| log.properties[:text].split("\n") }
-  end
-
-  protected
-
-  def get key, obj=@proxied
-    if obj.respond_to? key
-      obj.send(key)
-    elsif obj.is_a?(Hash)
-      obj[key] || obj[key.to_s]
-    end
-  end
-end
diff --git a/apps/workbench/app/models/repository.rb b/apps/workbench/app/models/repository.rb
deleted file mode 100644 (file)
index fd30be9..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Repository < ArvadosBase
-  def self.creatable?
-    false
-  end
-  def attributes_for_display
-    super.reject { |x| x[0] == 'fetch_url' }
-  end
-  def editable_attributes
-    if current_user.is_admin
-      super
-    else
-      []
-    end
-  end
-
-  def show commit_sha1
-    refresh
-    run_git 'show', commit_sha1
-  end
-
-  def cat_file commit_sha1, path
-    refresh
-    run_git 'cat-file', 'blob', commit_sha1 + ':' + path
-  end
-
-  def ls_tree_lr commit_sha1
-    refresh
-    run_git 'ls-tree', '-l', '-r', commit_sha1
-  end
-
-  # subtree returns a list of files under the given path at the
-  # specified commit. Results are returned as an array of file nodes,
-  # where each file node is an array [file mode, blob sha1, file size
-  # in bytes, path relative to the given directory]. If the path is
-  # not found, [] is returned.
-  def ls_subtree commit, path
-    path = path.chomp '/'
-    subtree = []
-    ls_tree_lr(commit).each_line do |line|
-      mode, type, sha1, size, filepath = line.split
-      next if type != 'blob'
-      if filepath[0,path.length] == path and
-          (path == '' or filepath[path.length] == '/')
-        subtree << [mode.to_i(8), sha1, size.to_i,
-                    filepath[path.length,filepath.length]]
-      end
-    end
-    subtree
-  end
-
-  # http_fetch_url returns the first http:// or https:// url (if any)
-  # in the api response's clone_urls attribute.
-  def http_fetch_url
-    clone_urls.andand.select { |u| /^http/ =~ u }.first
-  end
-
-  protected
-
-  # refresh fetches the latest repository content into the local
-  # cache. It is a no-op if it has already been run on this object:
-  # this (pretty much) avoids doing more than one remote git operation
-  # per Workbench request.
-  def refresh
-    run_git 'fetch', http_fetch_url, '+*:*' unless @fresh
-    @fresh = true
-  end
-
-  # run_git sets up the ARVADOS_API_TOKEN environment variable,
-  # creates a local git directory for this repository if necessary,
-  # executes "git --git-dir localgitdir {args to run_git}", and
-  # returns the output. It raises GitCommandError if git exits
-  # non-zero.
-  def run_git *gitcmd
-    if not @workdir
-      workdir = File.expand_path uuid+'.git', Rails.configuration.Workbench.RepositoryCache
-      if not File.exists? workdir
-        FileUtils.mkdir_p Rails.configuration.Workbench.RepositoryCache
-        [['git', 'init', '--bare', workdir],
-        ].each do |cmd|
-          system(*cmd, in: "/dev/null")
-          raise GitCommandError.new($?.to_s) unless $?.exitstatus == 0
-        end
-      end
-      @workdir = workdir
-    end
-    [['git', '--git-dir', @workdir, 'config', '--local',
-      "credential.#{http_fetch_url}.username", 'none'],
-     ['git', '--git-dir', @workdir, 'config', '--local',
-      "credential.#{http_fetch_url}.helper",
-      '!cred(){ cat >/dev/null; if [ "$1" = get ]; then echo password=$ARVADOS_API_TOKEN; fi; };cred'],
-     ['git', '--git-dir', @workdir, 'config', '--local',
-           'http.sslVerify',
-           Rails.configuration.TLS.Insecure ? 'false' : 'true'],
-     ].each do |cmd|
-      system(*cmd, in: "/dev/null")
-      raise GitCommandError.new($?.to_s) unless $?.exitstatus == 0
-    end
-    env = {}.
-      merge(ENV).
-      merge('ARVADOS_API_TOKEN' => Thread.current[:arvados_api_token],
-            'GIT_TERMINAL_PROMPT' => '0')
-    cmd = ['git', '--git-dir', @workdir] + gitcmd
-    io = IO.popen(env, cmd, err: [:child, :out], in: "/dev/null")
-    output = io.read
-    io.close
-    # "If [io] is opened by IO.popen, close sets $?." --ruby 2.2.1 docs
-    unless $?.exitstatus == 0
-      raise GitCommandError.new("`git #{gitcmd.join ' '}` #{$?}: #{output}")
-    end
-    output
-  end
-
-  class GitCommandError < StandardError
-  end
-end
diff --git a/apps/workbench/app/models/specimen.rb b/apps/workbench/app/models/specimen.rb
deleted file mode 100644 (file)
index 4418f7c..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Specimen < ArvadosBase
-  def self.goes_in_projects?
-    true
-  end
-end
diff --git a/apps/workbench/app/models/trait.rb b/apps/workbench/app/models/trait.rb
deleted file mode 100644 (file)
index 421a107..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Trait < ArvadosBase
-  def self.goes_in_projects?
-    true
-  end
-end
diff --git a/apps/workbench/app/models/user.rb b/apps/workbench/app/models/user.rb
deleted file mode 100644 (file)
index c4b273c..0000000
+++ /dev/null
@@ -1,115 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class User < ArvadosBase
-  def initialize(*args)
-    super(*args)
-    @attribute_sortkey['first_name'] = '050'
-    @attribute_sortkey['last_name'] = '051'
-  end
-
-  def self.current
-    res = arvados_api_client.api self, '/current', nil, {}, false
-    arvados_api_client.unpack_api_response(res)
-  end
-
-  def self.merge new_user_token, direction
-    # Merge user accounts.
-    #
-    # If the direction is "in", the current user is merged into the
-    # user represented by new_user_token
-    #
-    # If the direction is "out", the user represented by new_user_token
-    # is merged into the current user.
-
-    if direction == "in"
-      user_a = new_user_token
-      user_b = Thread.current[:arvados_api_token]
-      new_group_name = "Migrated from #{Thread.current[:user].email} (#{Thread.current[:user].uuid})"
-    elsif direction == "out"
-      user_a = Thread.current[:arvados_api_token]
-      user_b = new_user_token
-      res = arvados_api_client.api self, '/current', nil, {:arvados_api_token => user_b}, false
-      user_b_info = arvados_api_client.unpack_api_response(res)
-      new_group_name = "Migrated from #{user_b_info.email} (#{user_b_info.uuid})"
-    else
-      raise "Invalid merge direction, expected 'in' or 'out'"
-    end
-
-    # Create a project owned by user_a to accept everything owned by user_b
-    res = arvados_api_client.api Group, nil, {:group => {
-                                                :name => new_group_name,
-                                                :group_class => "project"},
-                                              :ensure_unique_name => true},
-                                 {:arvados_api_token => user_a}, false
-    target = arvados_api_client.unpack_api_response(res)
-
-    # The merge API merges the "current" user (user_b) into the user
-    # represented by "new_user_token" (user_a).
-    # After merging, the user_b redirects to user_a.
-    res = arvados_api_client.api self, '/merge', {:new_user_token => user_a,
-                                                  :new_owner_uuid => target[:uuid],
-                                                  :redirect_to_new_user => true},
-                                 {:arvados_api_token => user_b}, false
-    arvados_api_client.unpack_api_response(res)
-  end
-
-  def self.system
-    @@arvados_system_user ||= begin
-                                res = arvados_api_client.api self, '/system'
-                                arvados_api_client.unpack_api_response(res)
-                              end
-  end
-
-  def full_name
-    (self.first_name || "") + " " + (self.last_name || "")
-  end
-
-  def activate
-    self.private_reload(arvados_api_client.api(self.class,
-                                               "/#{self.uuid}/activate",
-                                               {}))
-  end
-
-  def contents params={}
-    Group.contents params.merge(uuid: self.uuid)
-  end
-
-  def attributes_for_display
-    super.reject { |k,v| %w(owner_uuid default_owner_uuid identity_url prefs).index k }
-  end
-
-  def attribute_editable?(attr, ever=nil)
-    (ever or not (self.uuid.andand.match(/000000000000000$/) and
-                  self.is_admin)) and super
-  end
-
-  def friendly_link_name lookup=nil
-    [self.first_name, self.last_name].compact.join ' '
-  end
-
-  def unsetup
-    self.private_reload(arvados_api_client.api(self.class,
-                                               "/#{self.uuid}/unsetup",
-                                               {}))
-  end
-
-  def self.setup params
-    arvados_api_client.api(self, "/setup", params)
-  end
-
-  def update_profile params
-    self.private_reload(arvados_api_client.api(self.class,
-                                               "/#{self.uuid}/profile",
-                                               params))
-  end
-
-  def deletable?
-    false
-  end
-
-  def self.creatable?
-    current_user.andand.is_admin
-  end
-end
diff --git a/apps/workbench/app/models/user_agreement.rb b/apps/workbench/app/models/user_agreement.rb
deleted file mode 100644 (file)
index fbba426..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class UserAgreement < ArvadosBase
-  def self.signatures
-    res = arvados_api_client.api self, '/signatures'
-    arvados_api_client.unpack_api_response(res)
-  end
-  def self.sign(params)
-    res = arvados_api_client.api self, '/sign', params
-    arvados_api_client.unpack_api_response(res)
-  end
-end
diff --git a/apps/workbench/app/models/virtual_machine.rb b/apps/workbench/app/models/virtual_machine.rb
deleted file mode 100644 (file)
index a81d76f..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class VirtualMachine < ArvadosBase
-  attr_accessor :current_user_logins
-
-  def self.creatable?
-    false
-  end
-
-  def attributes_for_display
-    super.append ['current_user_logins', @current_user_logins]
-  end
-
-  def editable_attributes
-    super - %w(current_user_logins)
-  end
-
-  def self.attribute_info
-    merger = ->(k,a,b) { a.merge(b, &merger) }
-    merger [nil,
-            {current_user_logins: {column_heading: "logins", type: 'array'}},
-            super]
-  end
-
-  def friendly_link_name lookup=nil
-    (hostname && !hostname.empty?) ? hostname : uuid
-  end
-end
diff --git a/apps/workbench/app/models/work_unit.rb b/apps/workbench/app/models/work_unit.rb
deleted file mode 100644 (file)
index 493dd2f..0000000
+++ /dev/null
@@ -1,218 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class WorkUnit
-  # This is an abstract class that documents the WorkUnit interface
-
-  def label
-    # returns the label that was assigned when creating the work unit
-  end
-
-  def uuid
-    # returns the arvados UUID of the underlying object
-  end
-
-  def parent
-    # returns the parent uuid of this work unit
-  end
-
-  def children
-    # returns an array of child work units
-  end
-
-  def modified_by_user_uuid
-    # returns uuid of the user who modified this work unit most recently
-  end
-
-  def owner_uuid
-    # returns uuid of the owner of this work unit
-  end
-
-  def created_at
-    # returns created_at timestamp
-  end
-
-  def modified_at
-    # returns modified_at timestamp
-  end
-
-  def started_at
-    # returns started_at timestamp for this work unit
-  end
-
-  def finished_at
-    # returns finished_at timestamp
-  end
-
-  def state_label
-    # returns a string representing state of the work unit
-  end
-
-  def exit_code
-    # returns the work unit's execution exit code
-  end
-
-  def state_bootstrap_class
-    # returns a class like "danger", "success", or "warning" that a view can use directly to make a display class
-  end
-
-  def success?
-    # returns true if the work unit finished successfully,
-    # false if it has a permanent failure,
-    # and nil if the final state is not determined.
-  end
-
-  def progress
-    # returns a number between 0 and 1
-  end
-
-  def log_collection
-    # returns uuid or pdh with saved log data, if any
-  end
-
-  def parameters
-    # returns work unit parameters, if any
-  end
-
-  def script
-    # returns script for this work unit, if any
-  end
-
-  def repository
-    # returns this work unit's script repository, if any
-  end
-
-  def script_version
-    # returns this work unit's script_version, if any
-  end
-
-  def supplied_script_version
-    # returns this work unit's supplied_script_version, if any
-  end
-
-  def docker_image
-    # returns this work unit's docker_image, if any
-  end
-
-  def runtime_constraints
-    # returns this work unit's runtime_constraints, if any
-  end
-
-  def priority
-    # returns this work unit's priority, if any
-  end
-
-  def nondeterministic
-    # returns if this is nondeterministic
-  end
-
-  def outputs
-    # returns array containing uuid or pdh of output data
-  end
-
-  def child_summary
-    # summary status of any children of this work unit
-  end
-
-  def child_summary_str
-    # textual representation of child summary
-  end
-
-  def can_cancel?
-    # returns true if this work unit can be canceled
-  end
-
-  def confirm_cancellation
-    # returns true if this work unit wants to use a confirmation for cancellation
-  end
-
-  def uri
-    # returns the uri for this work unit
-  end
-
-  def title
-    # title for the work unit
-  end
-
-  def has_unreadable_children
-    # accept it if you can't understand your own children
-  end
-
-  # view helper methods
-  def walltime
-    # return walltime for a running or completed work unit
-  end
-
-  def cputime
-    # return cputime for a running or completed work unit
-  end
-
-  def queuedtime
-    # return queued time if the work unit is queued
-  end
-
-  def is_running?
-    # is the work unit in running state?
-  end
-
-  def is_paused?
-    # is the work unit in paused state?
-  end
-
-  def is_finished?
-    # is the work unit in finished state?
-  end
-
-  def is_failed?
-    # is this work unit in failed state?
-  end
-
-  def command
-    # command to execute
-  end
-
-  def cwd
-    # initial workind directory
-  end
-
-  def environment
-    # environment variables
-  end
-
-  def mounts
-    # mounts
-  end
-
-  def output_path
-    # path to a directory or file to save output
-  end
-
-  def container_uuid
-    # container_uuid of a container_request
-  end
-
-  def requesting_container_uuid
-    # requesting_container_uuid of a container_request
-  end
-
-  def log_object_uuids
-    # object uuids for live log
-  end
-
-  def live_log_lines(limit)
-    # fetch log entries from logs table for @proxied
-  end
-
-  def render_log
-    # return partial and locals to be rendered
-  end
-
-  def template_uuid
-    # return the uuid of this work unit's template, if one exists
-  end
-
-  def runtime_status
-    # Returns this work unit's runtime_status, if any
-  end
-end
diff --git a/apps/workbench/app/models/workflow.rb b/apps/workbench/app/models/workflow.rb
deleted file mode 100644 (file)
index 31d433e..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class Workflow < ArvadosBase
-  def self.goes_in_projects?
-    true
-  end
-
-  def self.creatable?
-    false
-  end
-
-  def textile_attributes
-    [ 'description' ]
-  end
-end
diff --git a/apps/workbench/app/views/api_client_authorizations/_show_help.html.erb b/apps/workbench/app/views/api_client_authorizations/_show_help.html.erb
deleted file mode 100644 (file)
index 18907ed..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<pre>
-### Pasting the following lines at a shell prompt will allow Arvados SDKs
-### to authenticate to your account, <%= current_user.email %>
-
-read ARVADOS_API_TOKEN &lt;&lt;EOF
-<%= Thread.current[:arvados_api_token] %>
-EOF
-export ARVADOS_API_TOKEN ARVADOS_API_HOST=<%= current_api_host %>
-<% if Rails.configuration.TLS.Insecure %>
-export ARVADOS_API_HOST_INSECURE=true
-<% else %>
-unset ARVADOS_API_HOST_INSECURE
-<% end %>
-</pre>
diff --git a/apps/workbench/app/views/application/404.html.erb b/apps/workbench/app/views/application/404.html.erb
deleted file mode 100644 (file)
index 61cbd67..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-   if (controller.andand.action_name == 'show') and params[:uuid]
-     check_trash = controller.model_class.include_trash(true).where(uuid: params[:uuid])
-     class_name = controller.model_class.to_s.underscore
-     class_name_h = class_name.humanize(capitalize: false)
-     req_item = safe_join([class_name_h, " with UUID ",
-                             raw("<code>"), params[:uuid], raw("</code>")], "")
-     req_item_plain_text = safe_join([class_name_h, " with UUID ", params[:uuid]])
-   else
-     req_item = "page you requested"
-     req_item_plain_text = "page you requested"
-   end
-%>
-
-  <% untrash_object = nil %>
-
-  <% if check_trash.andand.any? %>
-    <% object = check_trash.first %>
-    <% if object.respond_to?(:is_trashed) && object.is_trashed %>
-      <% untrash_object = object %>
-    <% else %>
-      <% owner = object %>
-      <% while true %>
-        <% owner = Group.where(uuid: owner.owner_uuid).include_trash(true).first %>
-        <% if owner.nil? %>
-          <% break %>
-        <% end %>
-        <% if owner.is_trashed %>
-          <% untrash_object = owner %>
-          <% break %>
-        <% end %>
-      <% end %>
-    <% end %>
-  <% end %>
-
-  <% if !untrash_object.nil? %>
-    <h2>Trashed</h2>
-
-      <% untrash_name = if !untrash_object.name.blank? then
-                 "'#{untrash_object.name}'"
-                 else
-                 untrash_object.uuid
-               end %>
-
-    <p>The <%= req_item %> is
-      <% if untrash_object == object %>
-        in the trash.
-      <% else %>
-        owned by trashed project <%= untrash_name %> (<code><%= untrash_object.uuid %></code>).
-      <% end %>
-    </p>
-
-    <p>
-      It will be permanently deleted at <%= render_localized_date(untrash_object.delete_at) %>.
-    </p>
-
-  <p>
-    <% if untrash_object != object %>
-      You must untrash the owner project to access this <%= class_name_h %>.
-    <% end %>
-      <% if untrash_object.is_trashed and untrash_object.editable? %>
-        <% msg = "Untrash '#{untrash_name}'?" %>
-        <%= link_to({action: 'untrash_items', selection: [untrash_object.uuid], controller: :trash_items}, remote: true, method: :post,
-        title: "Untrash", style: 'cursor: pointer;') do %>
-
-        <% end %>
-
-        <%= form_tag url_for({action: 'untrash_items', controller: :trash_items}), {method: :post} %>
-        <%= hidden_field_tag :selection, [untrash_object.uuid] %>
-        <button type="submit">Click here to untrash <%= untrash_name %> <i class="fa fa-fw fa-recycle"></i></button>
-      <% end %>
-    </p>
-
-  <% else %>
-
-<h2>Not Found</h2>
-
-<p>The <%= req_item %> was not found.</p>
-
-<% if !current_user %>
-
-  <p>
-    <%= link_to(arvados_api_client.arvados_login_url(return_to: strip_token_from_path(request.url)),
-                {class: "btn btn-primary report-issue-modal-window"}) do %>
-      <i class="fa fa-fw fa-sign-in"></i> Log in
-    <% end %>
-    to view private data.
-  </p>
-
-<% elsif class_name %>
-
-  <p>
-    Perhaps you'd like to <%= link_to("browse all
-    #{class_name_h.pluralize}", action: :index, controller:
-    class_name.tableize) %>?
-  </p>
-
-<% end %>
-
-<% end %>
-
-<% error_message = "The #{req_item_plain_text} was not found." %>
-<%= render :partial => "report_error", :locals => {error_message: error_message, error_type: '404'} %>
diff --git a/apps/workbench/app/views/application/404.json.erb b/apps/workbench/app/views/application/404.json.erb
deleted file mode 100644 (file)
index a697490..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-{"errors":<%= raw @errors.to_json %>}
\ No newline at end of file
diff --git a/apps/workbench/app/views/application/_arvados_attr_value.html.erb b/apps/workbench/app/views/application/_arvados_attr_value.html.erb
deleted file mode 100644 (file)
index 98732dc..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if attrvalue.is_a? Array and attrvalue.collect(&:class).uniq.compact == [String] %>
-  <% attrvalue.each do |message| %>
-    <%= message %><br />
-  <% end %>
-<% else %>
-      <% if attr and obj.attribute_editable?(attr) and (!defined?(editable) || editable) %>
-        <% if resource_class_for_uuid(attrvalue, {referring_object: obj, referring_attr: attr}) %>
-          <%= link_to_if_arvados_object attrvalue, {referring_attr: attr, referring_object: obj, with_class_name: true, friendly_name: true} %>
-          <br>
-        <% end %>
-        <%= render_editable_attribute obj, attr %>
-      <% elsif attr == 'uuid' %>
-        <%= link_to_if_arvados_object attrvalue, {referring_attr: attr, referring_object: obj, with_class_name: false, friendly_name: false} %>
-      <% else %>
-        <%= link_to_if_arvados_object attrvalue, {referring_attr: attr, referring_object: obj, with_class_name: true, friendly_name: true, thumbnail: true} %>
-      <% end %>
-      <!--
-      <% if resource_class_for_uuid(attrvalue, {referring_object: obj, referring_attr: attr}) %>
-        <%= link_to_if_arvados_object(attrvalue, { referring_object: obj, link_text: raw('<span class="glyphicon glyphicon-hand-right"></span>'), referring_attr: attr })  %>
-      <% end %>
-      -->
-<% end %>
diff --git a/apps/workbench/app/views/application/_arvados_object.html.erb b/apps/workbench/app/views/application/_arvados_object.html.erb
deleted file mode 100644 (file)
index 6d59e0e..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :arvados_object_table do %>
-
-<% end %>
-
-<% if content_for? :page_content %>
-<%= yield :page_content %>
-<% else %>
-<%= yield :arvados_object_table %>
-<% end %>
-
-<div>
-  <ul class="nav nav-tabs">
-    <% if content_for? :page_content %>
-    <li><a href="#arvados-object-table" data-toggle="tab">Table</a></li>
-    <% end %>
-    <li class="active"><a href="#arvados-object-json" data-toggle="tab">API response JSON</a></li>
-    <% if @object.andand.uuid %>
-    <li><a href="#arvados-object-curl" data-toggle="tab">curl update example</a></li>
-    <li><a href="#arvados-object-arv" data-toggle="tab">&ldquo;arv&rdquo; CLI examples</a></li>
-    <li><a href="#arvados-object-python" data-toggle="tab">Python example</a></li>
-    <% end %>
-  </ul>
-
-  <div class="tab-content">
-    <% if content_for? :page_content %>
-    <div id="arvados-object-table" class="tab-pane fade">
-      <%= yield :arvados_object_table %>
-    </div>
-    <% end %>
-    <div id="arvados-object-json" class="tab-pane fade in active">
-
-    </div>
-
-
-  </div>
-</div>
diff --git a/apps/workbench/app/views/application/_arvados_object_attr.html.erb b/apps/workbench/app/views/application/_arvados_object_attr.html.erb
deleted file mode 100644 (file)
index 9b9c39f..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% object ||= @object %>
-<% if attrvalue.is_a? Hash then attrvalue.each do |infokey, infocontent| %>
-<tr class="info">
-  <td><%= attr %>[<%= infokey %>]</td>
-  <td>
-    <%= render partial: 'application/arvados_attr_value', locals: { obj: object, attr: nil, attrvalue: infocontent } %>
-  </td>
-</tr>
-<% end %>
-<% elsif attrvalue.is_a? String or attrvalue.respond_to? :to_s %>
-<tr class="<%= 'info' if %w(uuid owner_uuid created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at).include?(attr.to_s) %>">
-  <td><%= attr %></td>
-  <td>
-    <%= render partial: 'application/arvados_attr_value', locals: { obj: object, attr: attr, attrvalue: attrvalue } %>
-  </td>
-</tr>
-<% end %>
diff --git a/apps/workbench/app/views/application/_breadcrumb_page_name.html.erb b/apps/workbench/app/views/application/_breadcrumb_page_name.html.erb
deleted file mode 100644 (file)
index 0ff635b..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-
diff --git a/apps/workbench/app/views/application/_breadcrumbs.html.erb b/apps/workbench/app/views/application/_breadcrumbs.html.erb
deleted file mode 100644 (file)
index c3c2e07..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-      <nav class="navbar navbar-default breadcrumbs" role="navigation">
-        <ul class="nav navbar-nav navbar-left">
-          <li>
-            <a href="/">
-              <i class="fa fa-lg fa-fw fa-dashboard"></i>
-              Dashboard
-            </a>
-          </li>
-          <li class="dropdown">
-            <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="projects-menu">
-              Projects
-              <span class="caret"></span>
-            </a>
-            <ul class="dropdown-menu" style="min-width: 20em" role="menu">
-              <li role="menuitem">
-                  <%= link_to(
-                        url_for(
-                          action: 'choose',
-                          controller: 'search',
-                          filters: [['uuid', 'is_a', 'arvados#group']].to_json,
-                          title: 'Search',
-                          action_name: 'Show',
-                          action_href: url_for(controller: :actions, action: :show),
-                          action_method: 'get',
-                          action_data: {selection_param: 'uuid', success: 'redirect-to-created-object'}.to_json),
-                        { remote: true, method: 'get', title: "Search" }) do %>
-                    <i class="glyphicon fa-fw glyphicon-search"></i> Search all projects ...
-                  <% end %>
-               </li>
-              <% if !Rails.configuration.Users.AnonymousUserToken.empty? and Rails.configuration.Workbench.EnablePublicProjectsPage %>
-                <li role="menuitem"><a href="/projects/public" role="menuitem"><i class="fa fa-fw fa-list"></i> Browse public projects </a>
-                </li>
-              <% end %>
-              <li role="menuitem">
-                <%= link_to projects_path(options: {ensure_unique_name: true}), role: 'menu-item', method: :post do %>
-                  <i class="fa fa-fw fa-plus"></i> Add a new project
-                <% end %>
-              </li>
-              <li role="presentation" class="divider"></li>
-              <%= render partial: "projects_tree_menu", locals: {
-                  :project_link_to => Proc.new do |pnode, &block|
-                    link_to(project_path(pnode[:object].uuid),
-                      data: { 'object-uuid' => pnode[:object].uuid,
-                              'name' => 'name' },
-                      &block)
-                  end,
-              } %>
-            </ul>
-          </li>
-          <% if (defined?(@name_link) && @name_link) or (defined?(@object) && @object) %>
-            <li class="nav-separator">
-              <i class="fa fa-lg fa-angle-double-right"></i>
-            </li>
-            <li>
-              <%= link_to project_path(current_user.uuid) do %>
-                Home
-              <% end %>
-            </li>
-            <% project_breadcrumbs.each do |p| %>
-              <li class="nav-separator">
-                <i class="fa fa-lg fa-angle-double-right"></i>
-              </li>
-              <li>
-                <%= link_to(p.name, project_path(p.uuid), data: {object_uuid: p.uuid, name: 'name'}) %>
-              </li>
-            <% end %>
-          <% end %>
-        </ul>
-        <ul class="nav navbar-nav navbar-right">
-          <li>
-            <a href="/trash">
-              <%= image_tag("trash-icon.png", size: "20x20" ) %> Trash
-            </a>
-          </li>
-        </ul>
-      </nav>
diff --git a/apps/workbench/app/views/application/_browser_unsupported.html b/apps/workbench/app/views/application/_browser_unsupported.html
deleted file mode 100644 (file)
index 5424aba..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<!-- Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 -->
-
-<!-- googleoff: all -->
-<style type="text/css">
-  #browser-unsupported .alert {
-    margin-left: -100px;
-    margin-right: -100px;
-    padding-left: 120px;
-    padding-right: 120px;
-  }
-</style>
-<div id="browser-unsupported" class="hidden">
-  <div class="alert alert-danger">
-    <p>
-      <b>Hey!</b> Your web browser is missing some of the features we
-      rely on.  Usually this means you are running an old version.
-      Updating your system, or switching to a current version
-      of <a class="alert-link"
-      href="//google.com/search?q=download+Mozilla+Firefox">Firefox</a>
-      or <a class="alert-link"
-      href="//google.com/search?q=download+Google+Chrome">Chrome</a>,
-      should fix this.
-    </p>
-  </div>
-</div>
-<!-- googleon: all -->
diff --git a/apps/workbench/app/views/application/_choose.html.erb b/apps/workbench/app/views/application/_choose.html.erb
deleted file mode 100644 (file)
index e3e2708..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="modal arv-choose modal-with-loading-spinner">
-  <div class="modal-dialog" style="width:80%">
-    <div class="modal-content">
-      <div class="modal-header">
-        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <h4 class="modal-title"><%= params[:title] || "Choose #{@objects.resource_class.andand.class_for_display}" %></h4>
-      </div>
-
-      <div class="modal-body">
-        <% if params[:message].present? %>
-          <p> <%= params[:message] %> </p>
-        <% end %>
-
-        <% project_filters, chooser_filters = (params[:filters] || []).partition do |attr, op, val|
-             attr == "owner_uuid" and op == "="
-           end %>
-        <div class="input-group">
-          <% if params[:by_project].to_s != "false" %>
-            <% if project_filters.empty?
-                 selected_project_name = 'All projects'
-               else
-                 val = project_filters.last.last
-                 if val == current_user.uuid
-                   selected_project_name = "Home"
-                 else
-                   selected_project_name = Group.find(val).name rescue val
-                 end
-               end
-               %>
-            <div class="input-group-btn" data-filterable-target=".modal.arv-choose .selectable-container">
-              <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
-                <%= selected_project_name %> <span class="caret"></span>
-              </button>
-              <ul class="dropdown-menu" role="menu">
-                <li>
-                  <%= link_to '#', class: 'chooser-show-project' do %>
-                    All projects
-                  <% end %>
-                </li>
-                <li class="divider" />
-                <%= render partial: "projects_tree_menu", locals: {
-                      :project_link_to => Proc.new do |pnode, &block|
-                        link_to "#", {
-                          class: "chooser-show-project",
-                          data: {'project_uuid' => pnode[:object].uuid},
-                        }, &block
-                      end,
-                      :top_button => nil
-                    } %>
-              </ul>
-            </div>
-          <% end %>
-          <input type="text" value="<%=params[:preconfigured_search_str] || ''%>" class="form-control filterable-control focus-on-display" placeholder="Search" data-filterable-target=".modal.arv-choose .selectable-container"/>
-        </div>
-        <div style="height: 1em" />
-
-        <% preview_pane = (params[:preview_pane].to_s != "false") %>
-        <div class="row" style="height: 20em">
-          <div class="<%= 'col-sm-6' if preview_pane %> col-xs-12 arv-filterable-list selectable-container <%= 'multiple' if multiple %>"
-               style="height: 100%; overflow-y: scroll"
-               data-infinite-scroller="#choose-scroll"
-               id="choose-scroll"
-               data-infinite-content-params-from-chooser="<%= {filters: chooser_filters}.to_json %>"
-               <% if project_filters.any? %>
-                 data-infinite-content-params-from-project-dropdown="<%= {filters: project_filters, project_uuid: project_filters.last.last}.to_json %>"
-               <% end %>
-               <%
-                  action_data = JSON.parse params['action_data'] if params['action_data']
-                  use_preview_sel = action_data ? action_data['use_preview_selection'] : false
-                %>
-               data-infinite-content-href="<%= url_for partial: true,
-                                                       use_preview_selection: use_preview_sel %>">
-          </div>
-          <% if preview_pane %>
-            <div class="col-sm-6 col-xs-12 modal-dialog-preview-pane" style="height: 100%; overflow-y: scroll">
-            </div>
-          <% end %>
-        </div>
-
-        <div class="modal-footer">
-          <button class="btn btn-default" data-dismiss="modal" aria-hidden="true">Cancel</button>
-          <button class="btn btn-primary" aria-hidden="true" data-enable-if-selection disabled><%= raw(params[:action_name]) || 'Select' %></button>
-          <div class="modal-error hide" style="text-align: left; margin-top: 1em;">
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/application/_choose.js.erb b/apps/workbench/app/views/application/_choose.js.erb
deleted file mode 100644 (file)
index 9638028..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-=begin
-
-Parameters received from the caller/requestor of the modal are
-attached to the action button (.btn-primary) as follows:
-
-action_class -- string -- added as a pseudoclass to the action button.
-
-action_href -- string -- will be available at $(btn).attr('data-action-href')
-
-action_data -- json-encoded object -- will be at $(btn).data('action-data')
-
-action_data_form_params -- array -- for each X in this array, the
-value of params[X] during this "show chooser" request will be in
-$(btn).data('action-data-from-params')[X].
-
-=end
-%>
-
-$('body > .modal-container').html("<%= escape_javascript(render partial: 'choose.html', locals: {multiple: multiple}) %>");
-$('body > .modal-container .modal').modal('show');
-$('body > .modal-container .modal .modal-footer .btn-primary').
-    addClass('<%= j params[:action_class] %>').
-    attr('data-action-href', '<%= j params[:action_href] %>').
-    attr('data-method', '<%= j params[:action_method] %>').
-    data('action-data', <%= raw params[:action_data] %>).
-    data('action-data-from-params', <%= raw params.select { |k,v| k.in?(params[:action_data_from_params] || []) }.to_json %>);
diff --git a/apps/workbench/app/views/application/_choose_rows.html.erb b/apps/workbench/app/views/application/_choose_rows.html.erb
deleted file mode 100644 (file)
index 371398d..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @objects.each do |object| %>
-  <div class="row filterable selectable" data-object-uuid="<%= object.uuid %>" data-preview-href="<%= url_for object %>?tab_pane=chooser_preview">
-    <div class="col-sm-12" style="overflow-x:hidden">
-      <i class="fa fa-fw fa-gear"></i>
-      <%= object.name %>
-    </div>
-  </div>
-<% end %>
diff --git a/apps/workbench/app/views/application/_content.html.erb b/apps/workbench/app/views/application/_content.html.erb
deleted file mode 100644 (file)
index c4656e6..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :tab_panes do %>
-
-  <% comparable = controller.respond_to? :compare %>
-
-  <ul class="nav nav-tabs" data-tab-counts-url="<%= url_for(action: :tab_counts) rescue '' %>">
-    <% pane_list.each_with_index do |pane, i| %>
-      <% pane_name = (pane.is_a?(Hash) ? pane[:name] : pane) %>
-
-      <% data_toggle = "tab" %>
-      <% tab_tooltip = "" %>
-      <% link_disabled = "" %>
-
-      <% if (pane_name == "Log") and !(ArvadosBase.find(@object.owner_uuid).writable_by.include?(current_user.andand.uuid) rescue nil)
-          if controller.model_class.to_s == 'Job'
-            if @object.log and !@object.log.empty?
-              logCollection = Collection.find? @object.log
-              if !logCollection
-                data_toggle = "disabled"
-                tab_tooltip = "Log data is not available"
-                link_disabled = "disabled"
-              end
-            end
-          elsif (controller.model_class.to_s == 'PipelineInstance' and
-                 !@object.has_readable_logs?)
-            data_toggle = "disabled"
-            tab_tooltip = "Log data is not available"
-            link_disabled = "disabled"
-          end
-        end
-      %>
-
-      <li class="<%= 'active' if i==0 %> <%= link_disabled %> tab-pane-<%=pane_name%>" data-toggle="tooltip" data-placement="top" title="<%=tab_tooltip%>">
-        <a href="#<%= pane_name %>"
-           id="<%= pane_name %>-tab"
-           data-toggle="<%= data_toggle %>"
-           data-tab-history=true
-           data-tab-history-update-url=true
-           >
-          <%= pane_name.gsub('_', ' ') %> <span id="<%= pane_name %>-count"></span>
-        </a>
-      </li>
-    <% end %>
-  </ul>
-
-  <div class="tab-content">
-    <% pane_list.each_with_index do |pane, i| %>
-      <% pane_name = (pane.is_a?(Hash) ? pane[:name] : pane) %>
-      <div id="<%= pane_name %>"
-           class="tab-pane fade <%= 'in active pane-loaded' if i==0 %> arv-log-event-listener arv-refresh-on-log-event arv-log-event-subscribe-to-pipeline-job-uuids"
-           <% if controller.action_name == "index" %>
-             data-object-kind="arvados#<%= ArvadosApiClient.class_kind controller.model_class %>"
-           <% else %>
-             data-object-uuid="<%= @object.uuid %>"
-           <% end %>
-           data-pane-content-url="<%= url_for(params.permit!.merge(tab_pane: pane_name)) %>"
-           style="margin-top:0.5em;"
-           >
-        <div class="pane-content">
-          <% if i == 0 %>
-            <%= render_pane pane_name, to_string: true %>
-          <% else %>
-            <div class="spinner spinner-32px spinner-h-center"></div>
-          <% end %>
-        </div>
-      </div>
-    <% end %>
-  </div>
-
-<% end %>
diff --git a/apps/workbench/app/views/application/_content_layout.html.erb b/apps/workbench/app/views/application/_content_layout.html.erb
deleted file mode 100644 (file)
index 4aff081..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="clearfix">
-  <%= content_for :content_top %>
-  <div class="pull-right">
-    <%= content_for :tab_line_buttons %>
-  </div>
-</div>
-
-<%= content_for :tab_panes %>
-
-<%= render :partial => 'loading_modal' %>
diff --git a/apps/workbench/app/views/application/_create_new_object_button.html.erb b/apps/workbench/app/views/application/_create_new_object_button.html.erb
deleted file mode 100644 (file)
index 19377ae..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div style="display:inline-block">
-  <%= button_to({action: 'create'}, {class: 'btn btn-sm btn-primary'}) do %>
-    <i class="fa fa-fw fa-plus"></i>
-    Add a new
-    <%= controller.controller_name.singularize.humanize.downcase %>
-  <% end %>
-</div>
diff --git a/apps/workbench/app/views/application/_delete_object_button.html.erb b/apps/workbench/app/views/application/_delete_object_button.html.erb
deleted file mode 100644 (file)
index 4db3aea..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if object.deletable? %>
-  <%= link_to({controller: object.class.table_name, action: 'destroy', id: object.uuid}, method: :delete, remote: true, data: {confirm: "Really delete #{object.class_for_display.downcase} '#{object.friendly_link_name}'?"}) do %>
-    <i class="glyphicon glyphicon-trash"></i>
-  <% end %>
-<% end %>
diff --git a/apps/workbench/app/views/application/_extra_tab_line_buttons.html.erb b/apps/workbench/app/views/application/_extra_tab_line_buttons.html.erb
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/app/views/application/_index.html.erb b/apps/workbench/app/views/application/_index.html.erb
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/app/views/application/_job_progress.html.erb b/apps/workbench/app/views/application/_job_progress.html.erb
deleted file mode 100644 (file)
index 9f5ce55..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if (j.andand[:state] == "Running" or defined? scaleby) and (not defined? show_progress_bar or show_progress_bar) %>
-  <%
-    failed = j[:tasks_summary][:failed] || 0 rescue 0
-    done = j[:tasks_summary][:done] || 0 rescue 0
-    running = j[:tasks_summary][:running] || 0 rescue 0
-    todo = j[:tasks_summary][:todo] || 0 rescue 0
-
-    if done + running + failed + todo == 0
-      # No tasks were ever created for this job;
-      # render an empty progress bar.
-      done_percent = 0
-    else
-      percent_total_tasks = 100.0 / (done + running + failed + todo)
-      if defined? scaleby
-        percent_total_tasks *= scaleby
-      end
-      done_percent = (done+failed) * percent_total_tasks
-    end
-    %>
-
-  <% if not defined? scaleby %>
-    <div class="progress" style="margin-bottom: 0px">
-  <% end %>
-
-  <span class="progress-bar <%= if failed == 0 then 'progress-bar-success' else 'progress-bar-warning' end %>" style="width: <%= done_percent %>%;">
-  </span>
-
-  <% if not defined? scaleby %>
-  </div>
-  <% end %>
-
-<% else %>
-
-<% to_label = {
-     "Cancelled" => "danger",
-     "Complete" => "success",
-     "Running" => "info",
-     "Failed" => "danger",
-     "Queued" => "default",
-     nil => "default"
-   } %>
-
-  <span class="label label-<%= to_label[j.andand[:state]] %>">
-    <%= if defined? title
-          title
-        else
-          if j.andand[:state] then j[:state].downcase else "Not ready" end
-        end
-        %></span>
-
-<% end %>
diff --git a/apps/workbench/app/views/application/_loading.html.erb b/apps/workbench/app/views/application/_loading.html.erb
deleted file mode 100644 (file)
index 6936efd..0000000
+++ /dev/null
@@ -1,194 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="socket">
-  <div class="gel center-gel">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c1 r1">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c2 r1">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c3 r1">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c4 r1">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c5 r1">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c6 r1">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  
-  <div class="gel c7 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  
-  <div class="gel c8 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c9 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c10 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c11 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c12 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c13 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c14 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c15 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c16 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c17 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c18 r2">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c19 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c20 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c21 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c22 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c23 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c24 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c25 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c26 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c28 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c29 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c30 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c31 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c32 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c33 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c34 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c35 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c36 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  <div class="gel c37 r3">
-    <div class="hex-brick h1"></div>
-    <div class="hex-brick h2"></div>
-    <div class="hex-brick h3"></div>
-  </div>
-  
-</div>
diff --git a/apps/workbench/app/views/application/_loading_modal.html.erb b/apps/workbench/app/views/application/_loading_modal.html.erb
deleted file mode 100644 (file)
index 7d88d14..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div id="loading-modal" class="modal fade">
-  <div class="modal-dialog">
-       <div class="modal-content">
-         <div class="modal-header">
-           <h3>Refreshing...</h3>
-         </div>
-         <div class="modal-body">
-           <p>Content may have changed.</p>
-         </div>
-       </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/application/_name_and_description.html.erb b/apps/workbench/app/views/application/_name_and_description.html.erb
deleted file mode 100644 (file)
index 8d6f10b..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'object_name' %>
-<%= render partial: 'object_description' %>
diff --git a/apps/workbench/app/views/application/_object_description.html.erb b/apps/workbench/app/views/application/_object_description.html.erb
deleted file mode 100644 (file)
index 1dbc11d..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @object.respond_to? :description %>
-  <div class="arv-description-as-subtitle">
-    <%= render_editable_attribute @object, 'description', nil, { 'data-emptytext' => "(No description provided)", 'data-toggle' => 'manual' } %>
-  </div>
-<% end %>
diff --git a/apps/workbench/app/views/application/_object_name.html.erb b/apps/workbench/app/views/application/_object_name.html.erb
deleted file mode 100644 (file)
index 2bb456c..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @object.respond_to? :name %>
-  <h2>
-    <%= render_editable_attribute @object, 'name', nil, { 'data-emptytext' => "New #{controller.model_class.to_s.underscore.gsub("_"," ")}" } %>
-  </h2>
-<% end %>
diff --git a/apps/workbench/app/views/application/_paging.html.erb b/apps/workbench/app/views/application/_paging.html.erb
deleted file mode 100644 (file)
index abd6ecb..0000000
+++ /dev/null
@@ -1,132 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :css do %>
-.index-paging {
-text-align: center;
-padding-left: 1em;
-padding-right: 1em;
-background-color: whitesmoke;
-}
-.paging-number {
-display: inline-block;
-min-width: 1.2em;
-}
-<% end %>
-
-<% results.fetch_multiple_pages(false) %>
-
-<% if results.respond_to? :result_offset and
-       results.respond_to? :result_limit and
-       results.respond_to? :items_available and
-       results.result_offset != nil and
-       results.result_limit != nil and
-       results.items_available != nil
-%>
-<div class="index-paging">
-  Displaying <%= results.result_offset+1 %> &ndash;
-  <%= if results.result_offset + results.result_limit > results.items_available
-        results.items_available
-      else
-        results.result_offset + results.result_limit
-      end %>
- out of <%= results.items_available %>
-</div>
-
-<% if not (results.result_offset == 0 and results.items_available <= results.result_limit) %>
-
-<div class="index-paging">
-
-<% if results.result_offset > 0 %>
-  <% if results.result_offset > results.result_limit %>
-    <% prev_offset = results.result_offset - results.result_limit %>
-  <% else %>
-    <% prev_offset = 0 %>
-  <% end %>
-<% else %>
-  <% prev_offset = nil %>
-<% end %>
-
-<% this_offset = results.result_offset %>
-
-<% if (results.result_offset + results.result_limit) < results.items_available %>
-  <% next_offset = results.result_offset + results.result_limit %>
-<% else %>
-  <% next_offset = nil %>
-<% end %>
-
-<span class="pull-left">
-<% if results.result_offset > 0 %>
-  <%= link_to raw("<span class='glyphicon glyphicon-fast-backward'></span>"), {:id => object, :offset => 0, :limit => results.result_limit}  %>
-<% else %>
-  <span class='glyphicon glyphicon-fast-backward text-muted'></span>
-<% end %>
-
-<% if prev_offset %>
-  <%= link_to raw("<span class='glyphicon glyphicon-step-backward'></span>"), {:id => object, :offset => prev_offset, :limit => results.result_limit}  %>
-<% else %>
-<span class='glyphicon glyphicon-step-backward text-muted'></span>
-<% end %>
-</span>
-
-<% first = this_offset - (10 * results.result_limit) %>
-<% last = this_offset + (11 * results.result_limit) %>
-
-<% lastpage_offset = (results.items_available / results.result_limit) * results.result_limit %>
-
-<% if last > results.items_available %>
-  <% first -= (last - lastpage_offset) %>
-  <% last -= (last - results.items_available) %>
-<% end %>
-
-<% if first < 0 %>
-  <% d = -first %>
-  <% first += d %>
-  <% last += d %>
-<% end %>
-
-<% last = results.items_available if last > results.items_available %>
-
-<% i = first %>
-<% n = first / results.result_limit %>
-
-<% if first > 0 %>
-&hellip;
-<% end %>
-
-<% while i < last %>
-<% if i != this_offset %>
-  <%= link_to "#{n+1}", {:id => @object, :offset => i, :limit => results.result_limit}, class: 'paging-number' %>
-<% else %>
-  <span class="paging-number" style="font-weight: bold;"><%= n+1 %></span>
-<% end %>
-<% i += results.result_limit %>
-<% n += 1 %>
-<% end %>
-
-<% if last < results.items_available %>
-&hellip;
-<% end %>
-
-<span class="pull-right">
-<% if next_offset %>
-  <%= link_to raw("<span class='glyphicon glyphicon-step-forward'></span>"), {:id => @object, :offset => next_offset, :limit => results.result_limit}  %>
-<% else %>
-<span class='glyphicon glyphicon-forward text-muted'></span>
-<% end %>
-
-<% if (results.items_available - results.result_offset) >= results.result_limit %>
-  <%= link_to raw("<span class='glyphicon glyphicon-fast-forward'></span>"), {:id => @object, :offset => results.items_available - (results.items_available % results.result_limit),
-        :limit => results.result_limit}  %>
-<% else %>
-  <span class='glyphicon glyphicon-fast-forward text-muted'></span>
-<% end %>
-
-</span>
-
-</div>
-
-<% end %>
-
-<% end %>
diff --git a/apps/workbench/app/views/application/_pipeline_progress.html.erb b/apps/workbench/app/views/application/_pipeline_progress.html.erb
deleted file mode 100644 (file)
index 7ea2e68..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% component_frac = 1.0 / p.components.length %>
-<div class="progress">
-  <% p.components.each do |k,c| %>
-    <% if c.is_a?(Hash) and c[:job] %>
-      <%= render partial: "job_progress", locals: {:j => c[:job], :scaleby => component_frac } %>
-    <% end %>
-  <% end %>
-</div>
diff --git a/apps/workbench/app/views/application/_pipeline_status_label.html.erb b/apps/workbench/app/views/application/_pipeline_status_label.html.erb
deleted file mode 100644 (file)
index c057751..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if p.state == 'Complete' %>
-  <span class="label label-success">complete</span>
-<% elsif p.state == 'Failed' %>
-  <span class="label label-danger">failed</span>
-<% elsif p.state == 'RunningOnServer' || p.state == 'RunningOnClient' %>
-  <span class="label label-info">running</span>
-<% elsif p.state == 'Paused'  %>
-  <span class="label label-default">paused</span>
-<% else %>
-  <% if not p.components.values.any? { |c| c[:job] rescue false } %>
-    <span class="label label-default">not started</span>
-  <% else %>
-    <span class="label label-default">not running</span>
-  <% end %>
-<% end %>
diff --git a/apps/workbench/app/views/application/_projects_tree_menu.html.erb b/apps/workbench/app/views/application/_projects_tree_menu.html.erb
deleted file mode 100644 (file)
index 805d527..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% starred_projects = my_starred_projects current_user, '' %>
-<% if starred_projects.andand.any? %>
-  <li role="presentation" class="dropdown-header">
-    My favorite projects
-  </li>
-  <li>
-    <%= project_link_to.call({object: current_user, depth: 0}) do %>
-      <span style="padding-left: 0">Home</span>
-    <% end %>
-  </li>
-  <% (starred_projects).each do |pnode| %>
-    <li>
-      <%= project_link_to.call({object: pnode, depth: 0}) do%>
-        <span style="padding-left: 0em"></span><%= pnode[:name] %>
-      <% end %>
-    </li>
-  <% end %>
-  <li role="presentation" class="divider"></li>
-<% end %>
-
-<li role="presentation" class="dropdown-header">
-  My projects
-</li>
-<li>
-  <%= project_link_to.call({object: current_user, depth: 0}) do %>
-    <span style="padding-left: 0">Home</span>
-  <% end %>
-</li>
-<% my_tree = my_wanted_projects_tree current_user %>
-<% my_tree[0].each do |pnode| %>
-  <% next if pnode[:object].class != Group %>
-  <li>
-    <%= project_link_to.call pnode do %>
-      <span style="padding-left: <%= pnode[:depth] %>em"></span><%= pnode[:object].name %>
-    <% end %>
-  </li>
-<% end %>
-<% if my_tree[1] or my_tree[0].size > 200 %>
-<li role="presentation" class="dropdown-header">
-  Some projects have been omitted.
-</li>
-<% elsif my_tree[2] %>
-<li role="presentation" class="dropdown-header">
-  Showing top three levels of your projects.
-</li>
-<% end %>
diff --git a/apps/workbench/app/views/application/_report_error.html.erb b/apps/workbench/app/views/application/_report_error.html.erb
deleted file mode 100644 (file)
index 6027208..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-   popup_params = {
-     popup_type: 'report',
-     current_location: request.url,
-     current_path: request.fullpath,
-     action_method: 'post',
-   }
-   if error_type == "api"
-     popup_params.merge!(
-       api_error_request_url: api_error.andand.request_url || "",
-       api_error_response: api_error.andand.api_response || "",
-     )
-   else
-     popup_params.merge!(error_message: error_message)
-   end
-%>
-
-<p>
-<%= link_to(report_issue_popup_path(popup_params),
-            {class: 'btn btn-primary report-issue-modal-window', :remote => true, return_to: request.url}) do %>
-  <i class="fa fa-fw fa-support"></i> Report problem
-<% end %>
-
-or
-
-<%= mail_to(Rails.configuration.Mail.SupportEmailAddress, "email us",
-            subject: "Workbench problem report",
-            body: "Problem while viewing page #{request.url}") %>
-
-if you suspect this is a bug.
-</p>
diff --git a/apps/workbench/app/views/application/_report_issue_popup.html.erb b/apps/workbench/app/views/application/_report_issue_popup.html.erb
deleted file mode 100644 (file)
index 3dc3326..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-  generated_at = arvados_api_client.discovery[:generatedAt]
-  arvados_base = Rails.configuration.Services.Controller.ExternalURL.to_s + "/arvados/v1"
-  support_email = Rails.configuration.Mail.SupportEmailAddress
-
-  additional_info = {}
-  additional_info['Current location'] = params[:current_location]
-  additional_info['User UUID'] = current_user.uuid if current_user
-
-  additional_info_str = additional_info.map {|k,v| "#{k}=#{v}"}.join("\n")
-
-  additional_info['api_source_version'] = api_source_version
-  additional_info['api_package_version'] = api_package_version
-  additional_info['generated_at'] = generated_at
-  additional_info['workbench_version'] = AppVersion.hash
-  additional_info['workbench_package_version'] = AppVersion.package_version
-  additional_info['arvados_base'] = arvados_base
-  additional_info['support_email'] = support_email
-  additional_info['error_message'] = params[:error_message] if params[:error_message]
-  additional_info['api_error_request_url'] = params[:api_error_request_url] if params[:api_error_request_url]
-  additional_info['api_error_response'] = params[:api_error_response] if params[:api_error_response]
-%>
-
-<div class="modal">
- <div class="modal-dialog modal-with-loading-spinner">
-  <div class="modal-content">
-
-    <%= form_tag report_issue_path, {name: 'report-issue-form', method: 'post',
-        class: 'form-horizontal'} do %>
-
-      <%
-        title = 'Version / debugging info'
-        title = 'Report a problem' if params[:popup_type] == 'report'
-      %>
-
-      <div class="modal-header">
-        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <div>
-          <div class="col-sm-8"> <h4 class="modal-title"><%=title%></h4> </div>
-          <div class="spinner spinner-32px spinner-h-center col-sm-1" hidden="true"></div>
-        </div>
-        <br/>
-      </div>
-
-      <div class="modal-body" style="height: 25em; overflow-y: scroll">
-        <div class="form-group">
-          <label for="support_email" class="col-sm-4 control-label"> Support email </label>
-          <div class="col-sm-8">
-            <p class="form-control-static" name="support_version"><a href="mailto:<%=support_email%>?subject=Workbench problem report&amp;body=Problem while viewing page <%=params[:current_location]%>"><%=support_email%></a></p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="current_page" class="col-sm-4 control-label"> Current page </label>
-          <div class="col-sm-8">
-            <p class="form-control-static text-overflow-ellipsis" name="current_page"><%=params[:current_path]%></a></p>
-          </div>
-        </div>
-
-        <% if params[:popup_type] == 'report' %>
-          <div class="form-group">
-            <label for="report_text_label" class="col-sm-4 control-label"> Describe the problem </label>
-            <div class="col-sm-8">
-              <textarea class="form-control" rows="4" id="report_issue_text" name="report_issue_text" type="text" placeholder="Describe the problem"/>
-            </div>
-            <input type="hidden" name="report_additional_info" value="<%=additional_info.to_json%>">
-          </div>
-        <% end %>
-
-        <div class="form-group">
-          <label for="wb_version" class="col-sm-4 control-label"> Workbench version </label>
-          <div class="col-sm-8">
-            <p class="form-control-static" name="wb_version">
-              <%= AppVersion.package_version %> (<%= link_to AppVersion.hash, version_link_target(AppVersion.hash) %>)
-            </p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="server_version" class="col-sm-4 control-label"> API version </label>
-          <div class="col-sm-8">
-            <p class="form-control-static" name="server_version">
-              <%= api_package_version %> (<%= link_to api_source_version, version_link_target(api_source_version) %>)
-            </p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="generated_at" class="col-sm-4 control-label"> API startup time </label>
-          <div class="col-sm-8">
-            <p class="form-control-static" name="generated_at"><%=generated_at%></p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="arvados_base" class="col-sm-4 control-label"> API address </label>
-          <div class="col-sm-8">
-            <p class="form-control-static" name="arvados_base"><%=arvados_base%></p>
-          </div>
-        </div>
-
-        <% if current_user %>
-          <div class="form-group">
-            <label for="user_uuid" class="col-sm-4 control-label"> User UUID </label>
-            <div class="col-sm-8">
-              <p class="form-control-static" name="user_uuid"><%=current_user.uuid%></p>
-            </div>
-          </div>
-        <% end %>
-
-        <% if params[:error_message] %>
-          <div class="form-group">
-            <label for="error_message" class="col-sm-4 control-label"> Error message </label>
-            <div class="col-sm-8">
-              <p class="form-control-static text-overflow-ellipsis" name="error_message"><%=params[:error_message]%></p>
-            </div>
-          </div>
-        <% end %>
-
-        <% if params[:api_error_request_url] %>
-          <div class="form-group">
-            <label for="api_error_url" class="col-sm-4 control-label"> API error request URL </label>
-            <div class="col-sm-8">
-              <p class="form-control-static text-overflow-ellipsis" name="api_error_url"><%=params[:api_error_request_url]%></p>
-            </div>
-          </div>
-        <% end %>
-
-        <% if params[:api_error_response] %>
-          <div class="form-group">
-            <label for="api_error_response" class="col-sm-4 control-label"> API error response </label>
-            <div class="col-sm-8">
-              <p class="form-control-static text-overflow-ellipsis" name="api_error_response"><%=params[:api_error_response]%></p>
-            </div>
-          </div>
-        <% end %>
-      </div>
-
-      <div class="modal-footer">
-        <% if params[:popup_type] == 'report' %>
-          <button class="btn btn-default report-issue-cancel" id="report-issue-cancel" data-dismiss="modal" aria-hidden="true">Cancel</button>
-          <button type="submit" id="report-issue-submit" class="btn btn-primary report-issue-submit" autofocus>Send problem report</button>
-        <% else %>
-          <button class="btn btn-default" data-dismiss="modal" aria-hidden="true">Close</button>
-        <% end %>
-      </div>
-    <% end #form %>
-  </div>
- </div>
-</div>
diff --git a/apps/workbench/app/views/application/_selection_checkbox.html.erb b/apps/workbench/app/views/application/_selection_checkbox.html.erb
deleted file mode 100644 (file)
index af65a6d..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%if object and object.uuid and (object.class.goes_in_projects? or (object.is_a?(Link) and ArvadosBase::resource_class_for_uuid(object.head_uuid).to_s == 'Collection')) %>
-  <% fn = if defined? friendly_name and not friendly_name.nil?
-            friendly_name
-          else
-            link_to_if_arvados_object object, {no_link: true}
-          end
-     %>
-  <% # This 'fn' string may contain embedded HTML which is already marked html_safe.
-     # Since we are putting it into a tag attribute, we need to copy into an
-     # unsafe string so that rails will escape it for us.
-     fn = String.new fn %>
-<%= check_box_tag 'uuids[]', object.uuid, false, {
-      :class => 'persistent-selection',
-      :id => object.uuid,
-      :friendly_type => object.class.name,
-      :friendly_name => fn,
-      :href => "#{url_for controller: object.class.name.tableize, action: 'show', id: object.uuid }",
-      :title => "Click to add this item to your selection list"
-} %>
-<% end %>
diff --git a/apps/workbench/app/views/application/_show_advanced.html.erb b/apps/workbench/app/views/application/_show_advanced.html.erb
deleted file mode 100644 (file)
index d9423c5..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="panel-group" id="arv-adv-accordion">
-  <% ['API response',
-      'Metadata',
-      'Python example',
-      'CLI example',
-      'curl example'].each do |section| %>
-    <% section_id = section.gsub(" ","_").downcase %>
-    <div class="panel panel-default">
-      <div class="panel-heading">
-        <h4 class="panel-title">
-          <a data-toggle="collapse" data-parent="#arv-adv-accordion" href="#advanced_<%=section_id%>">
-            <%= section %>
-          </a>
-        </h4>
-      </div>
-      <div id="advanced_<%=section_id%>" class="panel-collapse collapse <%#= 'in' if section == 'API response'%>">
-        <div class="panel-body">
-          <%= render partial: "show_advanced_#{section_id}", locals: {object: @object} %>
-        </div>
-      </div>
-    </div>
-  <% end %>
-</div>
diff --git a/apps/workbench/app/views/application/_show_advanced_api_response.html.erb b/apps/workbench/app/views/application/_show_advanced_api_response.html.erb
deleted file mode 100644 (file)
index f856f91..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<pre>
-<%= JSON.pretty_generate(object.attributes.reject { |k,v| k == 'id' }) rescue nil %>
-</pre>
diff --git a/apps/workbench/app/views/application/_show_advanced_cli_example.html.erb b/apps/workbench/app/views/application/_show_advanced_cli_example.html.erb
deleted file mode 100644 (file)
index 102cf4a..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-An example arv command to get a <%= object.class.to_s.underscore %> using its uuid:
-<pre>
-arv <%= object.class.to_s.underscore %> get \
- --uuid <%= object.uuid %>
-</pre>
-
-An example arv command to update the "<%= object.attributes.keys[-3] %>" attribute for the current <%= object.class.to_s.underscore %>:
-<pre>
-arv <%= object.class.to_s.underscore %> update \
- --uuid <%= object.uuid %> \
- --<%= object.class.to_s.underscore.gsub '_', '-' %> '<%= JSON.generate({object.attributes.keys[-3] => object.attributes.values[-3]}).gsub("'","'\''") %>'
-</pre>
diff --git a/apps/workbench/app/views/application/_show_advanced_curl_example.html.erb b/apps/workbench/app/views/application/_show_advanced_curl_example.html.erb
deleted file mode 100644 (file)
index c517de3..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-An example curl command to update the "<%= object.attributes.keys[-3] %>" attribute for the current <%= object.class.to_s.underscore %>:
-<pre>
-curl -X PUT \
- -H "Authorization: OAuth2 $ARVADOS_API_TOKEN" \
- --data-urlencode <%= object.class.to_s.underscore %>@/dev/stdin \
- https://$ARVADOS_API_HOST/arvados/v1/<%= object.class.to_s.pluralize.underscore %>/<%= object.uuid %> \
- &lt;&lt;EOF
-<%= JSON.pretty_generate({object.attributes.keys[-3] => object.attributes.values[-3]}) %>
-EOF
-</pre>
diff --git a/apps/workbench/app/views/application/_show_advanced_metadata.html.erb b/apps/workbench/app/views/application/_show_advanced_metadata.html.erb
deleted file mode 100644 (file)
index 062dba9..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% outgoing = Link.where(tail_uuid: @object.uuid) %>
-<% incoming = Link.where(head_uuid: @object.uuid) %>
-
-<%
-  preload_uuids = []
-  preload_head_uuids = []
-  outgoing.results.each do |link|
-    preload_uuids << link.uuid
-    preload_uuids << link.head_uuid
-    preload_head_uuids << link.head_uuid
-  end
-  preload_collections_for_objects preload_uuids
-  preload_links_for_objects preload_head_uuids
-%>
-
-<% if (outgoing | incoming).any? %>
-<table class="table topalign">
-  <colgroup>
-    <col width="20%" />
-    <col width="10%" />
-    <col width="10%" />
-    <col width="20%" />
-    <col width="20%" />
-    <col width="20%" />
-  </colgroup>
-  <thead>
-    <tr>
-      <th></th>
-      <th>link_class</th>
-      <th>name</th>
-      <th>tail</th>
-      <th>head</th>
-      <th>properties</th>
-    </tr>
-  </thead>
-  <tbody>
-    <% (outgoing | incoming).each do |link| %>
-      <tr>
-        <td>
-          <%= render partial: 'show_object_button', locals: { object: link, size: 'xs' } %>
-          <span class="arvados-uuid"><%= link.uuid %></span>
-        </td>
-        <td><%= link.link_class %></td>
-        <td><%= link.name %></td>
-        <td><%= link.tail_uuid == object.uuid ? 'this' : (render partial: 'application/arvados_attr_value', locals: { obj: link, attr: "tail_uuid", attrvalue: link.tail_uuid, editable: false }) %></td>
-        <td><%= link.head_uuid == object.uuid ? 'this' : (render partial: 'application/arvados_attr_value', locals: { obj: link, attr: "head_uuid", attrvalue: link.head_uuid, editable: false }) %></td>
-        <td><%= render partial: 'application/arvados_attr_value', locals: { obj: link, attr: "properties", attrvalue: link.properties, editable: false } %></td>
-      </tr>
-    <% end %>
-  </tbody>
-</table>
-<% else %>
-<span class="deemphasize">
-  (No metadata links found)
-</span>
-<% end %>
diff --git a/apps/workbench/app/views/application/_show_advanced_python_example.html.erb b/apps/workbench/app/views/application/_show_advanced_python_example.html.erb
deleted file mode 100644 (file)
index 4ae3945..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-An example python command to get a <%= object.class.to_s.underscore %> using its uuid:
-<pre>
-import arvados
-
-x = arvados.api().<%= object.class.to_s.pluralize.underscore %>().get(uuid='<%= object.uuid %>').execute()
-</pre>
diff --git a/apps/workbench/app/views/application/_show_api.html.erb b/apps/workbench/app/views/application/_show_api.html.erb
deleted file mode 100644 (file)
index 72cc363..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @object.andand.uuid %>
-
-<div class="panel panel-default">
-  <div class="panel-heading">curl</div>
-  <div class="panel-body">
-  <pre>
-curl -X PUT \
- -H "Authorization: OAuth2 $ARVADOS_API_TOKEN" \
- --data-urlencode <%= @object.class.to_s.underscore %>@/dev/stdin \
- https://$ARVADOS_API_HOST/arvados/v1/<%= @object.class.to_s.pluralize.underscore %>/<%= @object.uuid %> \
- &lt;&lt;EOF
-<%= JSON.pretty_generate({@object.attributes.keys[-3] => @object.attributes.values[-3]}) %>
-EOF
-  </pre>
-  </div>
-</div>
-
-<div class="panel panel-default">
-  <div class="panel-heading"><b>arv</b> command line tool</div>
-  <div class="panel-body">
-  <pre>
-arv <%= @object.class.to_s.underscore %> get \
- --uuid <%= @object.uuid %>
-
-arv <%= @object.class.to_s.underscore %> update \
- --uuid <%= @object.uuid %> \
- --<%= @object.class.to_s.underscore.gsub '_', '-' %> '<%= JSON.generate({@object.attributes.keys[-3] => @object.attributes.values[-3]}).gsub("'","'\''") %>'
-      </pre>
-  </div>
-</div>
-
-<div class="panel panel-default">
-  <div class="panel-heading"><b>Python</b> SDK</div>
-  <div class="panel-body">
-    <pre>
-import arvados
-
-x = arvados.api().<%= @object.class.to_s.pluralize.underscore %>().get(uuid='<%= @object.uuid %>').execute()
-      </pre>
-<% end %>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/application/_show_attributes.html.erb b/apps/workbench/app/views/application/_show_attributes.html.erb
deleted file mode 100644 (file)
index c48428e..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= form_for @object do |f| %>
-<table class="table topalign">
-  <thead>
-  </thead>
-  <tbody>
-    <% @object.attributes_for_display.each do |attr, attrvalue| %>
-    <%= render partial: 'application/arvados_object_attr', locals: { attr: attr, attrvalue: attrvalue } %>
-    <% end %>
-  </tbody>
-</table>
-
-<% end %>
-
diff --git a/apps/workbench/app/views/application/_show_autoselect_text.html.erb b/apps/workbench/app/views/application/_show_autoselect_text.html.erb
deleted file mode 100644 (file)
index a007a55..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%# Render local variable `text` so the entire text is automatically
-    selected when clicked or focused. %>
-<input class="select-on-focus <%= tagclass %>" type="text" readonly
-       size="<%= text.size %>" value="<%= text %>">
diff --git a/apps/workbench/app/views/application/_show_home_button.html.erb b/apps/workbench/app/views/application/_show_home_button.html.erb
deleted file mode 100644 (file)
index 0f87fc8..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if (current_user.is_admin and controller.model_class == User) %>
-  <%= link_to 'Home', "/projects/#{object.uuid}" %>
-<% end %>
diff --git a/apps/workbench/app/views/application/_show_object_button.html.erb b/apps/workbench/app/views/application/_show_object_button.html.erb
deleted file mode 100644 (file)
index 3acfdaa..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% htmloptions = {class: ''}.merge(htmloptions || {})
-   htmloptions[:class] += " btn-#{size}" rescue nil
-   link_text = 'Show' unless defined?(link_text) and link_text
- %>
-<%= link_to_if_arvados_object object, {
-      link_text: raw('<i class="fa fa-fw ' + fa_icon_class_for_object(object) + '"></i> ' + link_text),
-      name_link: (defined?(name_link) && name_link && name_link.uuid) ? name_link : nil
-    }, {
-      data: {
-        toggle: 'tooltip',
-        placement: 'top'
-      },
-      title: 'show ' + object.class_for_display.downcase,
-      class: 'btn btn-default ' + htmloptions[:class],
-    } %>
diff --git a/apps/workbench/app/views/application/_show_object_description_cell.html.erb b/apps/workbench/app/views/application/_show_object_description_cell.html.erb
deleted file mode 100644 (file)
index e681cc2..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= object.content_summary %>
-
diff --git a/apps/workbench/app/views/application/_show_recent.html.erb b/apps/workbench/app/views/application/_show_recent.html.erb
deleted file mode 100644 (file)
index 537cce7..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if objects.empty? %>
-<br/>
-<p style="text-align: center">
-  No <%= controller.controller_name.humanize.downcase %> to display.
-</p>
-
-<% else %>
-
-<% attr_blacklist = ' created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at owner_uuid group_class properties' %>
-
-<%= render partial: "paging", locals: {results: objects, object: @object} %>
-
-<%= form_tag do |f| %>
-
-<table class="table table-condensed arv-index">
-  <thead>
-    <tr>
-      <% if objects.first and objects.first.class.goes_in_projects? %>
-        <th></th>
-      <% end %>
-      <th></th>
-      <% objects.first.attributes_for_display.each do |attr, attrvalue| %>
-      <% next if attr_blacklist.index(" "+attr) %>
-      <th class="arv-attr-<%= attr %>">
-        <%= controller.model_class.attribute_info[attr.to_sym].andand[:column_heading] or attr.sub /_uuid/, '' %>
-      </th>
-      <% end %>
-      <th>
-        <!-- a column for user's home -->
-      </th>
-      <th>
-        <!-- a column for delete buttons -->
-      </th>
-    </tr>
-  </thead>
-
-  <tbody>
-    <% objects.each do |object| %>
-    <tr data-object-uuid="<%= object.uuid %>">
-      <% if objects.first.class.goes_in_projects? %>
-        <td>
-          <%= render :partial => "selection_checkbox", :locals => {:object => object} %>
-        </td>
-      <% end %>
-      <td>
-        <%= render :partial => "show_object_button", :locals => {object: object, size: 'xs'} %>
-      </td>
-
-      <% object.attributes_for_display.each do |attr, attrvalue| %>
-      <% next if attr_blacklist.index(" "+attr) %>
-      <td class="arv-object-<%= object.class.to_s %> arv-attr-<%= attr %>">
-        <% if attr == 'uuid' %>
-          <span class="arvados-uuid"><%= attrvalue %></span>
-        <% else %>
-          <%= link_to_if_arvados_object attrvalue, {referring_attr: attr, referring_object: object, with_class_name: true, friendly_name: true} %>
-        <% end %>
-      </td>
-      <% end %>
-      <td>
-        <%= render partial: 'show_home_button', locals: {object:object} %>
-      </td>
-      <td>
-        <%= render partial: 'delete_object_button', locals: {object:object} %>
-      </td>
-    </tr>
-    <% end %>
-  </tbody>
-
-  <tfoot>
-  </tfoot>
-</table>
-
-<% end %>
-
-<%= render partial: "paging", locals: {results: objects, object: @object} %>
-
-<% end %>
diff --git a/apps/workbench/app/views/application/_show_sharing.html.erb b/apps/workbench/app/views/application/_show_sharing.html.erb
deleted file mode 100644 (file)
index 75773ab..0000000
+++ /dev/null
@@ -1,138 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-   uuid_map = {}
-   if @share_links
-     [User, Group].each do |type|
-       type
-         .filter([['uuid','in',@share_links.collect(&:tail_uuid)]])
-         .with_count("none")
-         .fetch_multiple_pages(false)
-         .each do |o|
-         uuid_map[o.uuid] = o
-       end
-     end
-   end
-   perm_name_desc_map = {}
-   perm_desc_name_map = {}
-   perms_json = []
-   ['Read', 'Write', 'Manage'].each do |link_desc|
-     link_name = "can_#{link_desc.downcase}"
-     perm_name_desc_map[link_name] = link_desc
-     perm_desc_name_map[link_desc] = link_name
-     perms_json << {value: link_name, text: link_desc}
-   end
-   perms_json = perms_json.to_json
-   choose_filters = {
-     "groups" => [["group_class", "=", "role"]],
-   }
-   if Rails.configuration.Users.AnonymousUserToken.empty?
-     # It would be ideal to filter out the anonymous group by UUID,
-     # but that's not readily doable.  Workbench can't generate the
-     # UUID for a != filter, because it can't introspect the API
-     # server's UUID prefix.  And we can't say "uuid not like
-     # %-anonymouspublic", because the API server doesn't support a
-     # "not like" filter.
-     choose_filters["groups"] << ["name", "!=", "Anonymous users"]
-   end
-   choose_filters.default = []
-   owner_icon = fa_icon_class_for_uuid(@object.owner_uuid)
-   if owner_icon == "fa-users"
-     owner_icon = "fa-folder"
-     owner_type = "parent project"
-   else
-     owner_type = "owning user"
-   end
-
-   sharing_path = url_for(:controller => params['controller'], :action => 'share_with')
-%>
-
-<div class="pull-right">
-  <% ["users", "groups"].each do |share_class| %>
-
-  <%= link_to(send("choose_#{share_class}_path",
-      title: "Share with #{share_class}",
-      message: "Only #{share_class} you are allowed to access are shown. Please contact your administrator if you need to be added to a specific group.",
-      by_project: false,
-      preview_pane: false,
-      multiple: true,
-      filters: choose_filters[share_class].to_json,
-      action_method: 'post',
-      action_href: sharing_path,
-      action_name: 'Add',
-      action_data: {selection_param: 'uuids[]', success: 'tab-refresh'}.to_json),
-      class: "btn btn-primary btn-sm", remote: true) do %>
-  <i class="fa fa-fw fa-plus"></i> Share with <%= share_class %>&hellip;
-  <% end %>
-
-  <% end %>
-</div>
-
-<p>Permissions for this <%=@object.class_for_display.downcase%> are inherited from the <%= owner_type %>
-  <i class="fa fa-fw <%= owner_icon %>"></i>
-  <%= link_to_if_arvados_object @object.owner_uuid, friendly_name: true %>.
-</p>
-
-<% if @object.is_a? Repository %>
-<p>
-  Please note that changes to git repository sharing may take up to two minutes to take effect.
-</p>
-<% end %>
-
-<table id="object_sharing" class="topalign table" style="clear: both; margin-top: 1em;">
-  <tr>
-    <th>User/Group Name</th>
-    <th>Email Address</th>
-    <th colspan="2"><%=@object.class_for_display%> Access</th>
-  </tr>
-
-  <% @share_links.andand.each do |link|
-       shared_with = uuid_map[link.tail_uuid]
-       if shared_with.nil?
-         link_name = link.tail_uuid
-       elsif shared_with.respond_to?(:full_name)
-         link_name = shared_with.full_name
-       else
-         link_name = shared_with.name
-       end
-       if shared_with && shared_with.respond_to?(:email)
-         email = shared_with.email
-       end
-  %>
-  <tr data-object-uuid="<%= link.uuid %>">
-    <td>
-      <i class="fa fa-fw <%= fa_icon_class_for_uuid(link.tail_uuid) %>"></i>
-      <%= link_to_if_arvados_object(link.tail_uuid, link_text: link_name) %>
-    </td>
-    <td>
-      <%= email %>
-    </td>
-    <td><%= link_to perm_name_desc_map[link.name], '#', {
-      "data-emptytext" => "Read",
-      "data-placement" => "bottom",
-      "data-type" => "select",
-      "data-url" => url_for(action: "update", id: link.uuid, controller: "links", merge: true),
-      "data-title" => "Set #{link_name}'s access level",
-      "data-name" => "[name]",
-      "data-pk" => {id: link.tail_uuid, key: "link"}.to_json,
-      "data-value" => link.name,
-      "data-clear" => false,
-      "data-source" => perms_json,
-      "data-tpl" => "<select id=\"share_change_level\"></select>",
-      "class" => "editable form-control",
-      } %>
-    </td>
-    <td>
-      <%= link_to(
-          {action: 'destroy', id: link.uuid, controller: "links"},
-          {title: 'Revoke', class: 'btn btn-default btn-nodecorate', method: :delete,
-           data: {confirm: "Revoke #{link_name}'s access to this #{@object.class_for_display.downcase}?",
-                  remote: true}}) do %>
-      <i class="fa fa-fw fa-trash-o"></i>
-      <% end %>
-    </td>
-  </tr>
-  <% end %>
-</table>
diff --git a/apps/workbench/app/views/application/_show_star.html.erb b/apps/workbench/app/views/application/_show_star.html.erb
deleted file mode 100644 (file)
index 6256eae..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if current_user and is_starred %>
-  <%= link_to(star_path(status: 'delete', id:@object.uuid, action_method: 'get'), style: "color:#D00", class: "btn btn-xs star-unstar", title: "Remove from list of favorites", remote: true) do  %>
-            <i class="fa fa-lg fa-star"></i>
-          <% end %>
-<% else %>
-  <%= link_to(star_path(status: 'create', id:@object.uuid, action_method: 'get'), class: "btn btn-xs star-unstar", title: "Add to list of favorites", remote: true) do %>
-            <i class="fa fa-lg fa-star-o"></i>
-          <% end %>
-<% end %>
diff --git a/apps/workbench/app/views/application/_show_text_with_locators.html.erb b/apps/workbench/app/views/application/_show_text_with_locators.html.erb
deleted file mode 100644 (file)
index b34b4ca..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%# The locators in the given text are expected to be of the form JSON_KEEP_LOCATOR_REGEXP %>
-
-<% data_height = data_height || 100 %>
-  <div style="max-height:<%=data_height%>px; overflow:auto;">
-    <% text_data.each_line do |line| %>
-      <% matches = keep_locator_in_json line %>
-
-      <% if matches.nil? or matches.empty? %>
-        <span style="white-space: pre-wrap; margin: none;"><%= line %></span>
-      <% else
-        subs = []
-        matches.uniq.each do |loc|
-          pdh, filename = loc.split('/', 2)
-
-          if object_readable(pdh)
-            # Add PDH link
-            replacement = link_to_arvados_object_if_readable(pdh, pdh, friendly_name: true)
-            if filename
-              link_params = {controller: 'collections', action: 'show_file', uuid: pdh, file: filename}
-              if preview_allowed_for(filename)
-                params = {disposition: 'inline'}
-              else
-                params = {disposition: 'attachment'}
-              end
-              file_link = link_to(raw("/"+filename), link_params.merge(params))
-              # Add file link
-              replacement << file_link
-            end
-            # Add link(s) substitution
-            subs << [loc, replacement]
-          end
-        end
-        # Replace all readable locators with links
-        subs.each do |loc, link|
-          line.gsub!(loc, link)
-        end %>
-        <span style="white-space: pre-wrap; margin: none;"><%= raw line %></span>
-      <% end %>
-    <% end %>
-  </div>
diff --git a/apps/workbench/app/views/application/_svg_div.html.erb b/apps/workbench/app/views/application/_svg_div.html.erb
deleted file mode 100644 (file)
index 8a417d9..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= content_for :css do %>
-/* Need separate style for each instance of svg div because javascript will manipulate the properties. */
-#<%= divId %> {
- padding-left: 3px;
- overflow: auto;
- border: solid;
- border-width: 1px;
- border-color: gray;
- position: absolute;
- left: 25px;
- right: 25px;
-}
-path:hover {
-stroke-width: 5;
-}
-path {
-stroke-linecap: round;
-}
-<% end %>
-
-<%= content_for :js do %>
-    $(window).on('load', function() {
-      $(window).on('load resize scroll', function () { graph_zoom("<%= divId %>","<%=svgId %>", 1) } );
-    });
-<% end %>
-
-<div id="_<%= divId %>_container">
-  <div style="text-align: right">
-    <a style="cursor: pointer"><span class="glyphicon glyphicon-zoom-out" onclick="graph_zoom('<%= divId %>', '<%= svgId %>', .9)"></span></a>
-    <a style="cursor: pointer"><span class="glyphicon glyphicon-zoom-in" onclick="graph_zoom('<%= divId %>', '<%= svgId %>', 1./.9)"></span></a>
-  </div>
-
-  <div id="<%= divId %>" class="smart-scroll">
-    <span id="_<%= divId %>_center" style="padding-left: 0px"></span>
-    <%= raw(svg) %>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/application/_tab_line_buttons.html.erb b/apps/workbench/app/views/application/_tab_line_buttons.html.erb
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/app/views/application/_title_and_buttons.html.erb b/apps/workbench/app/views/application/_title_and_buttons.html.erb
deleted file mode 100644 (file)
index 647243a..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% object_class = @object.class_for_display.downcase %>
-<% content_for :page_title do %>
-  <%= (@object.respond_to?(:properties) and !@object.properties.nil? ? @object.properties[:page_title] : nil) ||
-      @name_link.andand.name ||
-      @object.friendly_link_name %>
-<% end %>
-
-<% content_for :content_top do %>
-  <% if !['Group','User', 'Collection'].include? @object.class.to_s # projects and collections handle it themselves %>
-    <%= render partial: 'name_and_description' %>
-  <% end %>
-<% end %>
-
-<% if @object.class.goes_in_projects? && @object.uuid != current_user.andand.uuid # Not the "Home" project %>
-  <% content_for :tab_line_buttons do %>
-    <% if current_user.andand.is_active %>
-      <%= render partial: 'extra_tab_line_buttons' %>
-    <% end %>
-    <% if current_user.andand.is_active && @object.class.copies_to_projects? %>
-      <%= link_to(
-          choose_projects_path(
-           title: "Copy this #{object_class} to:",
-           action_name: 'Copy',
-           action_href: actions_path,
-           action_method: 'post',
-           action_data: {
-             copy_selections_into_project: true,
-             selection: @name_link.andand.uuid || @object.uuid,
-             selection_param: 'uuid',
-             success: 'redirect-to-created-object'
-           }.to_json),
-          { class: "btn btn-sm btn-primary", remote: true, method: 'get',
-            title: "Make a copy of this #{object_class}" }) do %>
-        <i class="fa fa-fw fa-copy"></i> Copy to project...
-      <% end %>
-    <% end %>
-    <% if (ArvadosBase.find(@object.owner_uuid).writable_by.include?(current_user.andand.uuid) rescue nil) %>
-      <%= link_to(
-          choose_projects_path(
-           title: "Move this #{object_class} to:",
-           action_name: 'Move',
-           action_href: actions_path,
-           action_method: 'post',
-           action_data: {
-             move_selections_into_project: true,
-             selection: @name_link.andand.uuid || @object.uuid,
-             selection_param: 'uuid',
-             success: 'redirect-to-created-object'
-           }.to_json),
-          { class: "btn btn-sm btn-primary force-cache-reload", remote: true, method: 'get',
-            title: "Move this #{object_class} to a different project"}) do %>
-        <i class="fa fa-fw fa-truck"></i> Move <%=object_class%>...
-      <% end %>
-    <% end %>
-  <% end %>
-<% end %>
-
-<% unless flash["error"].blank? %>
-<div class="flash-message alert alert-danger" role="alert">
-  <p class="contain-align-left"><%= flash["error"] %></p>
-</div>
-<% flash.delete("error") %>
-<% end %>
-
-<% unless flash.empty? %>
-<div class="flash-message alert alert-warning">
-  <% flash.each do |_, msg| %>
-  <p class="contain-align-left"><%= msg %></p>
-  <% end %>
-</div>
-<% end %>
diff --git a/apps/workbench/app/views/application/api_error.html.erb b/apps/workbench/app/views/application/api_error.html.erb
deleted file mode 100644 (file)
index 8f3c69b..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<h2>Oh... fiddlesticks.</h2>
-
-<p>An error occurred when Workbench sent a request to the Arvados API server.  Try reloading this page.  If the problem is temporary, your request might go through next time.
-
-<% if not api_error %>
-</p>
-<% else %>
-If that doesn't work, the information below can help system administrators track down the problem.
-</p>
-
-<dl>
-  <dt>API request URL</dt>
-  <dd><code><%= api_error.request_url %></code></dd>
-
-  <% if api_error.api_response.empty? %>
-  <dt>Invalid API response</dt>
-  <dd><%= api_error.api_response_s %></dd>
-  <% else %>
-  <dt>API response</dt>
-  <dd><pre><%= Oj.dump(api_error.api_response, indent: 2) %></pre></dd>
-  <% end %>
-</dl>
-<% end %>
-
-<%= render :partial => "report_error", :locals => {api_error: api_error, error_type: 'api'} %>
diff --git a/apps/workbench/app/views/application/api_error.json.erb b/apps/workbench/app/views/application/api_error.json.erb
deleted file mode 100644 (file)
index a697490..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-{"errors":<%= raw @errors.to_json %>}
\ No newline at end of file
diff --git a/apps/workbench/app/views/application/destroy.js.erb b/apps/workbench/app/views/application/destroy.js.erb
deleted file mode 100644 (file)
index 397acdb..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-$(document).trigger('count-change');
-$('[data-object-uuid=<%= @object.uuid %>]').hide('slow', function() {
-    $(this).remove();
-});
diff --git a/apps/workbench/app/views/application/error.html.erb b/apps/workbench/app/views/application/error.html.erb
deleted file mode 100644 (file)
index e0f579e..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<h2>Oh... fiddlesticks.</h2>
-
-<p>Sorry, I had some trouble handling your request.</p>
-
-<ul>
-<% if @errors.is_a? Array then @errors.each do |error| %>
-<li><%= error %></li>
-<% end end %>
-</ul>
diff --git a/apps/workbench/app/views/application/error.json.erb b/apps/workbench/app/views/application/error.json.erb
deleted file mode 100644 (file)
index a697490..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-{"errors":<%= raw @errors.to_json %>}
\ No newline at end of file
diff --git a/apps/workbench/app/views/application/error.text.erb b/apps/workbench/app/views/application/error.text.erb
deleted file mode 100644 (file)
index 1035182..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-Oh... fiddlesticks.
-
-Sorry, I had some trouble handling your request.
-
-<% if @errors.is_a? Array then @errors.each do |error| %>
-<%= error %>
-<% end end %>
diff --git a/apps/workbench/app/views/application/index.html.erb b/apps/workbench/app/views/application/index.html.erb
deleted file mode 100644 (file)
index 7db8559..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :page_title do %>
-<%= controller.controller_name.humanize.capitalize %>
-<% end %>
-
-<% content_for :tab_line_buttons do %>
-
-  <% if controller.model_class.creatable? %>
-    <%= render partial: 'create_new_object_button' %>
-  <% end %>
-
-<% end %>
-
-<%= render partial: 'content', layout: 'content_layout', locals: {pane_list: controller.index_pane_list }%>
diff --git a/apps/workbench/app/views/application/report_issue_popup.js.erb b/apps/workbench/app/views/application/report_issue_popup.js.erb
deleted file mode 100644 (file)
index bd11f9e..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-$("#report-issue-modal-window").html("<%= escape_javascript(render partial: 'report_issue_popup') %>");
-$("#report-issue-modal-window .modal").modal('show');
-
-// Disable the submit button on modal loading
-$submit = $('#report-issue-submit');
-$submit.prop('disabled', true);
-
-// capture events to enable submit button when applicable
-$('#report_issue_text').bind('input propertychange', function() {
-  var problem_desc = document.forms["report-issue-form"]["report_issue_text"].value;
-  $submit.prop('disabled', (problem_desc === null) || (problem_desc === ""));
-});
diff --git a/apps/workbench/app/views/application/show.html.erb b/apps/workbench/app/views/application/show.html.erb
deleted file mode 100644 (file)
index 15b2f12..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'title_and_buttons' %>
-<%= render partial: 'content', layout: 'content_layout', locals: {pane_list: controller.show_pane_list }%>
diff --git a/apps/workbench/app/views/application/star.js.erb b/apps/workbench/app/views/application/star.js.erb
deleted file mode 100644 (file)
index cbb9834..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-$(".star-unstar").html("<%= escape_javascript(render partial: 'show_star') %>");
-$(".breadcrumbs").html("<%= escape_javascript(render partial: 'breadcrumbs') %>");
diff --git a/apps/workbench/app/views/authorized_keys/create.js.erb b/apps/workbench/app/views/authorized_keys/create.js.erb
deleted file mode 100644 (file)
index 4c682c8..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-;
diff --git a/apps/workbench/app/views/authorized_keys/edit.html.erb b/apps/workbench/app/views/authorized_keys/edit.html.erb
deleted file mode 100644 (file)
index 9b5bd11..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'form' %>
diff --git a/apps/workbench/app/views/collections/_choose.js.erb b/apps/workbench/app/views/collections/_choose.js.erb
deleted file mode 120000 (symlink)
index 8420a7f..0000000
+++ /dev/null
@@ -1 +0,0 @@
-../application/_choose.js.erb
\ No newline at end of file
diff --git a/apps/workbench/app/views/collections/_choose_rows.html.erb b/apps/workbench/app/views/collections/_choose_rows.html.erb
deleted file mode 100644 (file)
index 50f7ffe..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @objects.each do |object| %>
-    <div class="row filterable selectable <%= 'use-preview-selection' if params['use_preview_selection']%>" data-object-uuid="<%= object.uuid %>"
-         data-preview-href="<%= chooser_preview_url_for object, params['use_preview_selection'] %>"
-         style="margin-left: 1em; border-bottom-style: solid; border-bottom-width: 1px; border-bottom-color: #DDDDDD">
-      <i class="fa fa-fw fa-archive"></i>
-      <% if object.respond_to? :name %>
-        <% if not (object.name.nil? or object.name.empty?) %>
-          <%= object.name %>
-        <% elsif object.is_a? Collection and object.files.length > 0 %>
-          <%= object.files[0][1] %>
-          <%= "+ #{object.files.length-1} more" if object.files.length > 1 %>
-        <% else %>
-          <%= object.uuid %>
-        <% end %>
-      <% else %>
-        <%= object.uuid %>
-      <% end %>
-      <% links_for_object(object).each do |tag| %>
-        <% if tag.link_class == 'tag' %>
-          <span class="label label-info"><%= tag.name %></span>
-        <% end %>
-      <% end %>
-    </div>
-<% end %>
diff --git a/apps/workbench/app/views/collections/_create_new_object_button.html.erb b/apps/workbench/app/views/collections/_create_new_object_button.html.erb
deleted file mode 100644 (file)
index 2e1ca47..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%# "Create a new collection" would work, but the search filter on collections#index breaks the tab_line_buttons layout. %>
diff --git a/apps/workbench/app/views/collections/_extra_tab_line_buttons.html.erb b/apps/workbench/app/views/collections/_extra_tab_line_buttons.html.erb
deleted file mode 100644 (file)
index 5664cb2..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @object.editable? %>
-  <i class="fa fa-fw fa-lock lock-collection-btn btn btn-primary" title="Unlock collection to edit files"></i>
-<% end %>
diff --git a/apps/workbench/app/views/collections/_index_tbody.html.erb b/apps/workbench/app/views/collections/_index_tbody.html.erb
deleted file mode 100644 (file)
index 845c92e..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @objects.each do |c| %>
-
-<tr class="collection" data-object-uuid="<%= c.uuid %>">
-  <td>
-    <%=
-       friendly_name = c.friendly_link_name
-       @collection_info[c.uuid][:tag_links].each do |tag_link|
-         friendly_name += raw(" <span class='label label-info'>#{tag_link.name}</span>")
-       end
-       render partial: "selection_checkbox", locals: {
-         object: c,
-         friendly_name: friendly_name
-       }
-    %>
-
-    <%= render :partial => "show_object_button", :locals => {object: c, size: 'xs'} %>
-  </td>
-  <td>
-    <%= c.uuid %>
-  </td>
-  <td>
-    <% i = 0 %>
-    <% while i < 3 and i < c.files.length %>
-      <% file = c.files[i] %>
-      <% file_path = "#{file[0]}/#{file[1]}" %>
-      <%= link_to file[1], {controller: 'collections', action: 'show_file', uuid: c.uuid, file: file_path, size: file[2], disposition: 'inline'}, {title: 'View in browser'} %><br />
-      <% i += 1 %>
-    <% end %>
-    <% if i < c.files.length %>
-      &vellip;
-    <% end %>
-  </td>
-  <td>
-    <%= c.created_at.to_s if c.created_at %>
-  </td>
-  <td class="add-tag-button">
-    <a class="btn btn-xs btn-info add-tag-button pull-right" data-remote-href="<%= url_for(controller: 'links', action: 'create') %>" data-remote-method="post"><i class="glyphicon glyphicon-plus"></i>&nbsp;Add</a>
-    <span class="removable-tag-container">
-    <% if @collection_info[c.uuid] %>
-      <% @collection_info[c.uuid][:tag_links].each do |tag_link| %>
-        <span class="label label-info removable-tag" data-tag-link-uuid="<%= tag_link.uuid %>"><%= tag_link.name %>
-          <% if tag_link.owner_uuid == current_user.andand.uuid %>
-          &nbsp;<a title="Delete tag"><i class="glyphicon glyphicon-trash"></i></a>
-          <% end %>
-        </span>&nbsp;
-      <% end %>
-    <% end %>
-    </span>
-  </td>
-</tr>
-
-<% end %>
diff --git a/apps/workbench/app/views/collections/_sharing_button.html.erb b/apps/workbench/app/views/collections/_sharing_button.html.erb
deleted file mode 100644 (file)
index 3d8ea3f..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% button_attrs = {
-     class: 'btn btn-xs btn-info',
-     remote: true,
-     method: :post,
-   } %>
-<% if @search_sharing.nil? %>
-  <p>Your API token is not authorized to manage collection sharing links.</p>
-<% elsif @search_sharing.empty? %>
-  <%= button_to("Create sharing link", {action: "share"}, button_attrs) %>
-<% else %>
-  <div>
-    <% button_attrs[:class] += " pull-right" %>
-    <%= button_to("Unshare", {action: "unshare"}, button_attrs) %>
-    Shared at:
-    <div class="smaller-text" style="clear: both; word-break: break-all"><%= link_to download_link, download_link %></div>
-  </div>
-<% end %>
diff --git a/apps/workbench/app/views/collections/_show_chooser_preview.html.erb b/apps/workbench/app/views/collections/_show_chooser_preview.html.erb
deleted file mode 100644 (file)
index 77dacc4..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: "show_source_summary" %>
-<%= render partial: "show_files", locals: {no_checkboxes: true, use_preview_selection: params['use_preview_selection']} %>
diff --git a/apps/workbench/app/views/collections/_show_files.html.erb b/apps/workbench/app/views/collections/_show_files.html.erb
deleted file mode 100644 (file)
index 96ddf95..0000000
+++ /dev/null
@@ -1,146 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-  preview_selectable_container = ''
-  preview_selectable = ''
-  padding_left = '1em'
-  if !params['use_preview_selection'].nil? and params['use_preview_selection'] == 'true'
-    preview_selectable_container = 'preview-selectable-container selectable-container'
-    preview_selectable = 'preview-selectable selectable'
-    padding_left = '0em'
-  end
-%>
-
-<% object = @object unless object %>
-
-<div class="selection-action-container" style="padding-left: <%=padding_left%>">
-  <% if Collection.creatable? and (!defined? no_checkboxes or !no_checkboxes) %>
-    <div class="row">
-      <div class="pull-left">
-        <div class="btn-group btn-group-sm">
-          <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">Selection... <span class="caret"></span></button>
-          <ul class="dropdown-menu" role="menu">
-            <li><%= link_to "Create new collection with selected files", '#',
-                    method: :post,
-                    'data-href' => combine_selected_path(
-                      action_data: {current_project_uuid: object.owner_uuid}.to_json
-                    ),
-                    'data-selection-param-name' => 'selection[]',
-                    'data-selection-action' => 'combine-collections',
-                    'data-toggle' => 'dropdown'
-              %></li>
-            <% if object.editable? %>
-            <li><%= link_to "Remove selected files", '#',
-                    method: :post,
-                    'data-href' => url_for(controller: 'collections', action: :remove_selected_files),
-                    'data-selection-param-name' => 'selection[]',
-                    'data-selection-action' => 'remove-selected-files',
-                    'data-toggle' => 'dropdown',
-                    'class' => 'btn-remove-selected-files'
-              %></li>
-            <% end %>
-          </ul>
-        </div>
-        <div class="btn-group btn-group-sm">
-          <button id="select-all" type="button" class="btn btn-default" onClick="select_all_items()">Select all</button>
-          <button id="unselect-all" type="button" class="btn btn-default" onClick="unselect_all_items()">Unselect all</button>
-        </div>
-      </div>
-      <div class="pull-right">
-        <input class="form-control filterable-control" data-filterable-target="ul#collection_files" id="file_regex" name="file_regex" placeholder="filename regex" type="text"/>
-      </div>
-    </div>
-    <p/>
-  <% end %>
-
-  <% file_tree = object.andand.files_tree %>
-  <% if file_tree.nil? or file_tree.empty? %>
-    <p>This collection is empty.</p>
-  <% else %>
-    <ul id="collection_files" class="collection_files arv-selectable-items <%=preview_selectable_container%>">
-    <% dirstack = [file_tree.first.first] %>
-    <% file_tree.take(10000).each_with_index do |(dirname, filename, size), index| %>
-      <% file_path = CollectionsHelper::file_path([dirname, filename]) %>
-      <% while dirstack.any? and (dirstack.last != dirname) %>
-        <% dirstack.pop %></ul></li>
-      <% end %>
-      <li>
-      <% if size.nil?  # This is a subdirectory. %>
-        <% dirstack.push(File.join(dirname, filename)) %>
-        <div class="collection_files_row">
-         <div class="collection_files_name"><i class="fa fa-fw fa-folder-open"></i> <%= filename %></div>
-        </div>
-        <ul class="collection_files">
-      <% else %>
-        <% link_params = {controller: 'collections', action: 'show_file',
-                          uuid: object.portable_data_hash, file: file_path, size: size} %>
-         <div class="collection_files_row filterable <%=preview_selectable%>" href="<%=object.uuid%>/<%=file_path%>">
-          <div class="collection_files_buttons pull-right">
-            <%= raw(human_readable_bytes_html(size)) %>
-            <%= link_to(raw('<i class="fa fa-search"></i>'),
-                        link_params.merge(disposition: 'inline'),
-                        {title: "View #{file_path}", class: "btn btn-info btn-sm", disabled: !preview_allowed_for(file_path)}) %>
-            <%= link_to(raw('<i class="fa fa-download"></i>'),
-                        link_params.merge(disposition: 'attachment'),
-                        {title: "Download #{file_path}", class: "btn btn-info btn-sm"}) %>
-          </div>
-
-          <div class="collection_files_name">
-            <% if (!defined? no_checkboxes or !no_checkboxes) and current_user %>
-            <%= check_box_tag 'uuids[]', "#{object.uuid}/#{file_path}", false, {
-                  :class => "persistent-selection",
-                  :friendly_type => "File",
-                  :friendly_name => "#{object.uuid}/#{file_path}",
-                  :href => url_for(controller: 'collections', action: 'show_file',
-                                   uuid: object.portable_data_hash, file: file_path),
-                  :title => "Include #{file_path} in your selections",
-                  :id => "#{object.uuid}_file_#{index}",
-                } %>
-            <span>&nbsp;</span>
-            <% end %>
-
-            <% if object.editable? %>
-                <span class="btn-collection-remove-file-span">
-                <%= link_to({controller: 'collections', action: 'remove_selected_files', id: object.uuid, selection: [object.portable_data_hash+'/'+file_path]}, method: :post, remote: true, data: {confirm: "Remove #{file_path}?", toggle: 'tooltip', placement: 'top'}, class: 'btn btn-sm btn-default btn-nodecorate btn-collection-file-control', title: 'Remove this file') do %>
-                  <i class="fa fa-fw fa-trash-o"></i>
-                <% end %>
-                </span>
-            <% end %>
-        <% if CollectionsHelper::is_image(filename) %>
-            <i class="fa fa-fw fa-bar-chart-o"></i>
-              <% if object.editable? %>
-                <span class="btn-collection-rename-file-span">
-                <%= render_editable_attribute object, 'filename', filename, {'data-value' => file_path, 'data-toggle' => 'manual', 'selection_name' => 'rename-file-path:'+file_path}, {tiptitle: 'Edit name or directory or both for this file', btnclass: 'collection-file-control'} %>
-                </span>
-              <% else %>
-                <%= filename %>
-              <% end %>
-            </div>
-          <div class="collection_files_inline">
-            <%= link_to(image_tag("#{url_for object}/#{file_path}"),
-                        link_params.merge(disposition: 'inline'),
-                        {title: file_path}) %>
-          </div>
-         </div>
-        <% else %>
-              <% if object.editable? %>
-                <i class="fa fa-fw fa-file"></i><span class="btn-collection-rename-file-span"><%= render_editable_attribute object, 'filename', filename, {'data-value' => file_path, 'data-toggle' => 'manual', 'selection_name' => 'rename-file-path:'+file_path}, {tiptitle: 'Edit name or directory or both for this file', btnclass: 'collection-file-control'}  %>
-                </span>
-              <% else %>
-                <i class="fa fa-fw fa-file" href="<%=object.uuid%>/<%=file_path%>" ></i> <%= filename %>
-              <% end %>
-            </div>
-         </div>
-        <% end %>
-        </li>
-      <% end  # if file or directory %>
-    <% end  # file_tree.each %>
-    <%= raw(dirstack.map { |_| "</ul>" }.join("</li>")) %>
-  <% end  # if file_tree %>
-</div>
-
-<% content_for :footer_html do %>
-<div id="collection-sharing-modal-window" class="modal fade" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true"></div>
-<% end %>
diff --git a/apps/workbench/app/views/collections/_show_provenance_graph.html.erb b/apps/workbench/app/views/collections/_show_provenance_graph.html.erb
deleted file mode 100644 (file)
index 84ee5bd..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'application/svg_div', locals: {
-    divId: "provenance_graph_div", 
-    svgId: "provenance_svg", 
-    svg: @prov_svg } %>
diff --git a/apps/workbench/app/views/collections/_show_recent.html.erb b/apps/workbench/app/views/collections/_show_recent.html.erb
deleted file mode 100644 (file)
index 037c0bf..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="selection-action-container" style="padding-left: 1em">
-  <div class="row">
-    <div class="pull-left">
-      <div class="btn-group btn-group-sm">
-        <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">Selection... <span class="caret"></span></button>
-        <ul class="dropdown-menu" role="menu">
-          <li><%= link_to "Create new collection with selected collections", '#',
-                  method: :post,
-                  'data-href' => combine_selected_path,
-                  'data-selection-param-name' => 'selection[]',
-                  'data-selection-action' => 'combine-collections',
-                  'data-toggle' => 'dropdown'
-            %></li>
-        </ul>
-      </div>
-    </div>
-  </div>
-  <p/>
-
-<%= render partial: "paging", locals: {results: @objects, object: @object} %>
-
-<div style="padding-right: 1em">
-
-<%= form_tag do |f| %>
-
-<table id="collections-index" class="topalign table table-condensed table-fixedlayout"> <!-- table-fixed-header-row -->
-  <colgroup>
-    <col width="10%" />
-    <col width="10%" />
-    <col width="40%" />
-    <col width="10%" />
-    <col width="30%" />
-  </colgroup>
-  <thead>
-    <tr class="contain-align-left">
-      <th></th>
-      <th>uuid</th>
-      <th>contents</th>
-      <th>created at</th>
-      <th>tags</th>
-    </tr>
-  </thead>
-  <tbody>
-    <%= render partial: 'index_tbody' %>
-  </tbody>
-</table>
-
-<% end %>
-
-</div>
-
-<%= render partial: "paging", locals: {results: @objects, object: @object} %>
-
-<% content_for :footer_js do %>
-$(document).on('click', 'form[data-remote] input[type=submit]', function() {
-  $('table#collections-index tbody').fadeTo(200, 0.3);
-  return true;
-});
-<% end %>
-
-</div>
diff --git a/apps/workbench/app/views/collections/_show_source_summary.html.erb b/apps/workbench/app/views/collections/_show_source_summary.html.erb
deleted file mode 100644 (file)
index 398742e..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<p><i>Content size:</i><br />
-  <%= pluralize(@object.manifest.files_count, "file") %> totalling
-  <%= raw(human_readable_bytes_html(@object.manifest.files_size)) %></p>
-
-<% if not (@output_of.andand.any? or @log_of.andand.any?) %>
-  <p><i>No source information available.</i></p>
-<% end %>
-
-<% if @output_of.andand.any? %>
-  <% pipelines = PipelineInstance.limit(5).filter([["components", "like", "%#{@object.uuid}%"]]) %>
-  <%
-    message = "This collection was the output of the following:"
-    if pipelines.items_available > pipelines.results.size
-      message += ' (' + (pipelines.items_available - pipelines.results.size).to_s + ' more results are not shown)'
-    end
-  %>
-  <p><i><%= message %></i><br />
-    <% pipelines.each do |pipeline| %>
-      <% pipeline.components.each do |cname, c| %>
-        <% if c[:output_uuid] == @object.uuid %>
-          <b><%= cname %></b> component of <b><%= link_to_if_arvados_object(pipeline, friendly_name: true) %></b>
-          <% if c.andand[:job].andand[:finished_at] %>
-            finished at <%= render_localized_date(c[:job][:finished_at]) %>
-          <% end %>
-          <br>
-        <% end %>
-      <% end %>
-    <% end %>
-  </p>
-<% end %>
-
-<% if @log_of.andand.any? %>
-  <p><i>This collection contains log messages from:</i><br />
-    <%= render_arvados_object_list_start(@log_of, 'Show all jobs',
-                                         jobs_path(filters: [['log', '=', @object.portable_data_hash]].to_json)) do |job| %>
-      <%= link_to_if_arvados_object(job, friendly_name: true) %><br />
-    <% end %>
-  </p>
-<% end %>
diff --git a/apps/workbench/app/views/collections/_show_tags.html.erb b/apps/workbench/app/views/collections/_show_tags.html.erb
deleted file mode 100644 (file)
index 3e0460a..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-  <div class="arv-log-refresh-control"
-    data-load-throttle="86486400000" <%# 1001 nights (in milliseconds) %>
-    ></div>
-
-  <div class="collection-tags-container" style="padding-left:2em;padding-right:2em;">
-    <div data-mount-mithril="TagEditorApp" data-target-controller="<%= controller_name %>" data-target-uuid="<%= @object.uuid %>" data-target-editable="<%= @object.editable? %>"></div>
-  </div>
\ No newline at end of file
diff --git a/apps/workbench/app/views/collections/_show_upload.html.erb b/apps/workbench/app/views/collections/_show_upload.html.erb
deleted file mode 100644 (file)
index 5805fec..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="arv-log-refresh-control"
-     data-load-throttle="86486400000" <%# 1001 nights (in milliseconds) %>
-     ></div>
-<div ng-cloak ng-controller="UploadToCollection" arv-uuid="<%= @object.uuid %>">
-  <div class="panel panel-primary">
-    <div class="panel-body">
-      <div class="row">
-        <div class="col-sm-4">
-          <input type="file" multiple id="file_selector" ng-model="incoming" onchange="angular.element(this).scope().addFilesToQueue(this.files); $(this).val('');">
-          <div class="btn-group btn-group-sm" role="group" style="margin-top: 1.5em">
-            <button type="button" class="btn btn-default" ng-click="stop()" ng-disabled="uploader.state !== 'Running'"><i class="fa fa-fw fa-pause"></i> Pause</button>
-            <button type="button" class="btn btn-primary" ng-click="go()" ng-disabled="uploader.state === 'Running' || countInStates(['Paused', 'Queued']) === 0"><i class="fa fa-fw fa-play"></i> Start</button>
-          </div>
-        </div>
-        <div class="col-sm-8">
-          <div ng-show="uploader.state === 'Running'"
-               class="alert alert-info"
-               ><i class="fa fa-gear"></i>
-            Upload in progress.
-            <span ng-show="countInStates(['Done']) > 0">
-              {{countInStates(['Done'])}} file{{countInStates(['Done'])>1?'s':''}} finished.
-            </span>
-          </div>
-          <div ng-show="uploader.state === 'Idle' && uploader.stateReason"
-               class="alert alert-success"
-               ><i class="fa fa-fw fa-flag-checkered"></i> &nbsp; {{uploader.stateReason}}
-          </div>
-          <div ng-show="uploader.state === 'Failed'"
-               class="alert alert-danger"
-               ><i class="fa fa-fw fa-warning"></i> &nbsp; {{uploader.stateReason}}
-          </div>
-          <div ng-show="uploader.state === 'Stopped'"
-               class="alert alert-info"
-               ><i class="fa fa-fw fa-info"></i> &nbsp; Paused. Click the Start button to resume uploading.
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-  <div ng-repeat="upload in uploadQueue" class="row" ng-class="{lighten: upload.state==='Done'}">
-    <div class="col-sm-1">
-      <button class="btn btn-xs btn-default"
-              ng-show="upload.state!=='Done'"
-              ng-click="removeFileFromQueue($index)"
-              title="cancel"><i class="fa fa-fw fa-times"></i></button>
-      <span class="label label-success label-info"
-            ng-show="upload.state==='Done'">finished</span>
-    </div>
-    <div class="col-sm-4 nowrap" style="overflow-x:hidden;text-overflow:ellipsis">
-      <span title="{{upload.file.name}}">
-        {{upload.file.name}}
-      </span>
-    </div>
-    <div class="col-sm-1" style="text-align: right">
-      {{upload.file.size/1024 | number:0}}&nbsp;KiB
-    </div>
-    <div class="col-sm-2">
-      <div class="progress">
-        <span class="progress-bar" style="width: {{upload.progress}}%"></span>
-      </div>
-    </div>
-    <div class="col-sm-4" ng-class="{lighten: upload.state !== 'Uploading'}">
-      {{upload.statistics}}
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/collections/_show_used_by.html.erb b/apps/workbench/app/views/collections/_show_used_by.html.erb
deleted file mode 100644 (file)
index a7ec57d..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'application/svg_div', locals: {
-    divId: "used_by_graph", 
-    svgId: "used_by_svg", 
-    svg: @used_by_svg } %>
-
diff --git a/apps/workbench/app/views/collections/graph.html.erb b/apps/workbench/app/views/collections/graph.html.erb
deleted file mode 100644 (file)
index 9d8e540..0000000
+++ /dev/null
@@ -1,195 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%#= render :partial => 'nav' %>
-<table class="table table-bordered">
-  <tbody>
-    <tr>
-      <td class="d3">
-      </td>
-    </tr>
-  </tbody>
-</table>
-
-<% content_for :head do %>
-<%= javascript_include_tag '/d3.v3.min.js' %>
-
-    <style type="text/css">
-
-path.link {
-  fill: none;
-  stroke: #666;
-  stroke-width: 1.5px;
-}
-
-path.link.derived_from {
-  stroke: green;
-  stroke-dasharray: 0,4 1;
-}
-
-path.link.can_write {
-  stroke: green;
-}
-
-path.link.member_of {
-  stroke: blue;
-  stroke-dasharray: 0,4 1;
-}
-
-path.link.created {
-  stroke: red;
-}
-
-circle.node {
-  fill: #ccc;
-  stroke: #333;
-  stroke-width: 1.5px;
-}
-
-edgetext {
-  font: 12px sans-serif;
-  pointer-events: none;
-    text-align: center;
-}
-
-text {
-  font: 12px sans-serif;
-  pointer-events: none;
-}
-
-text.shadow {
-  stroke: #fff;
-  stroke-width: 3px;
-  stroke-opacity: .8;
-}
-
-    </style>
-<% end %>
-
-<% content_for :js do %>
-
-jQuery(function($){
-
-    var links = <%= raw d3ify_links(@links).to_json %>;
-
-    var nodes = {};
-
-    // Compute the distinct nodes from the links.
-    links.forEach(function(link) {
-       link.source = nodes[link.source] || (nodes[link.source] = {name: link.source});
-       link.target = nodes[link.target] || (nodes[link.target] = {name: link.target});
-    });
-
-    var fill_for = {'ldvyl': 'green',
-                   'j58dm': 'red',
-                   '4zz18': 'blue'};
-    jQuery.each(nodes, function(i, node) {
-       var m = node.name.match(/-([a-z0-9]{5})-/)
-       if (m)
-           node.fill = fill_for[m[1]] || '#ccc';
-       else if (node.name.match(/^[0-9a-f]{32}/))
-           node.fill = fill_for['4zz18'];
-       else
-           node.fill = '#ccc';
-    });
-
-    var w = 960,
-    h = 600;
-
-    var force = d3.layout.force()
-       .nodes(d3.values(nodes))
-       .links(links)
-       .size([w, h])
-       .linkDistance(150)
-       .charge(-300)
-       .on("tick", tick)
-       .start();
-
-    var svg = d3.select("td.d3").append("svg:svg")
-       .attr("width", w)
-       .attr("height", h);
-
-    // Per-type markers, as they don't inherit styles.
-    svg.append("svg:defs").selectAll("marker")
-       .data(["member_of", "owner", "derived_from"])
-       .enter().append("svg:marker")
-       .attr("id", String)
-       .attr("viewBox", "0 -5 10 10")
-       .attr("refX", 15)
-       .attr("refY", -1.5)
-       .attr("markerWidth", 6)
-       .attr("markerHeight", 6)
-       .attr("orient", "auto")
-       .append("svg:path")
-       .attr("d", "M0,-5L10,0L0,5");
-
-    var path = svg.append("svg:g").selectAll("path")
-       .data(force.links())
-       .enter().append("svg:path")
-       .attr("class", function(d) { return "link " + d.type; })
-       .attr("marker-end", function(d) { return "url(#" + d.type + ")"; });
-
-    var circle = svg.append("svg:g").selectAll("circle")
-       .data(force.nodes())
-       .enter().append("svg:circle")
-       .attr("r", 6)
-       .style("fill", function(d) { return d.fill; })
-       .call(force.drag);
-
-    var text = svg.append("svg:g").selectAll("g")
-       .data(force.nodes())
-       .enter().append("svg:g");
-
-    // A copy of the text with a thick white stroke for legibility.
-    text.append("svg:text")
-       .attr("x", 8)
-       .attr("y", ".31em")
-       .attr("class", "shadow")
-       .text(function(d) { return d.name.replace(/^([0-9a-z]{5}-){2}/,''); });
-
-    text.append("svg:text")
-       .attr("x", 8)
-       .attr("y", ".31em")
-       .text(function(d) { return d.name.replace(/^([0-9a-z]{5}-){2}/,''); });
-
-    var edgetext = svg.append("svg:g").selectAll("g")
-       .data(force.links())
-       .enter().append("svg:g");
-
-    edgetext
-       .append("svg:text")
-       .attr("x","-5em")
-       .attr("y","-0.2em")
-       .text(function(d) { return d.type; });
-
-    // Use elliptical arc path segments to doubly-encode directionality.
-    function tick() {
-       path.attr("d", function(d) {
-           var dx = d.target.x - d.source.x,
-            dy = d.target.y - d.source.y,
-            // dr = Math.sqrt(dx * dx + dy * dy);
-            dr = 0;
-           return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
-       });
-
-       circle.attr("transform", function(d) {
-           return "translate(" + d.x + "," + d.y + ")";
-       });
-
-       text.attr("transform", function(d) {
-           return "translate(" + d.x + "," + d.y + ")";
-       });
-
-       edgetext.attr("transform", function(d) {
-           return "translate(" +
-               (d.source.x + d.target.x)/2 + "," +
-               (d.source.y + d.target.y)/2 +
-               ")rotate(" +
-               (Math.atan2(d.target.y - d.source.y, d.target.x - d.source.x) * 180 / Math.PI) +
-               ")";
-       });
-    }
-
-})(jQuery);
-<% end %>
diff --git a/apps/workbench/app/views/collections/hash_matches.html.erb b/apps/workbench/app/views/collections/hash_matches.html.erb
deleted file mode 100644 (file)
index ba2a443..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-  message = "The following collections have this content:"
-  if @same_pdh.items_available > @same_pdh.results.size
-    message += ' (' + (@same_pdh.items_available - @same_pdh.results.size).to_s + ' more results are not shown)'
-  end
-%>
-<div class="row">
-  <div class="col-md-10 col-md-offset-1">
-    <div class="panel panel-info">
-      <div class="panel-heading">
-        <h3 class="panel-title"><%= params["uuid"] %></h3>
-      </div>
-      <div class="panel-body">
-        <p><i><%= message %></i></p>
-        <% @same_pdh.sort { |a,b| b.created_at <=> a.created_at }.each do |c| %>
-          <div class="row">
-            <div class="col-md-8">
-              <% owner = object_for_dataclass(Group, c.owner_uuid) || object_for_dataclass(User, c.owner_uuid) %>
-              <%= link_to_if_arvados_object owner, {:friendly_name => true} %> / <%= link_to_if_arvados_object c, {:friendly_name => true} %><br>
-            </div>
-            <div class="col-md-4">
-              <%= render_localized_date c.created_at %>
-            </div>
-          </div>
-        <% end %>
-      </div>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/collections/index.html.erb b/apps/workbench/app/views/collections/index.html.erb
deleted file mode 100644 (file)
index e1285e8..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :tab_line_buttons do %>
- <%= form_tag collections_path, method: 'get', remote: true, class: 'form-search' do %>
- <div class="input-group">
-   <%= text_field_tag :search, params[:search], class: 'form-control', placeholder: 'Search collections' %>
-   <span class="input-group-btn">
-     <%= button_tag(class: 'btn btn-info') do %>
-     <span class="glyphicon glyphicon-search"></span>
-     <% end %>
-   </span>
- </div>
- <% end %>
-<% end %>
-
-<%= render file: 'application/index.html.erb', locals: local_assigns %>
diff --git a/apps/workbench/app/views/collections/index.js.erb b/apps/workbench/app/views/collections/index.js.erb
deleted file mode 100644 (file)
index 3e91c01..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-if(history.replaceState)
-    history.replaceState(null, null, "<%= escape_javascript(@request_url) %>");
-$('table#collections-index tbody').html("<%= escape_javascript(render partial: 'index_tbody') %>");
-$('table#collections-index tbody').fadeTo(200, 1.0);
diff --git a/apps/workbench/app/views/collections/sharing_popup.js.erb b/apps/workbench/app/views/collections/sharing_popup.js.erb
deleted file mode 100644 (file)
index 2975d51..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-$("#sharing-button").html("<%= escape_javascript(render partial: 'sharing_button') %>");
diff --git a/apps/workbench/app/views/collections/show.html.erb b/apps/workbench/app/views/collections/show.html.erb
deleted file mode 100644 (file)
index 8a9200a..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="row row-fill-height">
-  <div class="col-md-7">
-    <div class="panel panel-info">
-      <div class="panel-heading">
-        <h3 class="panel-title">
-          <%= if @object.respond_to? :name
-                render_editable_attribute @object, :name
-              elsif @name_link
-                @name_link.name
-              else
-                @object.uuid
-              end %>
-        </h3>
-      </div>
-      <div class="panel-body">
-        <div class="arv-description-as-subtitle">
-          <%= render_editable_attribute @object, 'description', nil, { 'data-emptytext' => "(No description provided)", 'data-toggle' => 'manual' } %>
-        </div>
-        <img src="/favicon.ico" class="pull-right" alt="" style="opacity: 0.3"/>
-        <p><i>Collection UUID:</i><br />
-          <%= render partial: "show_autoselect_text", locals: {text: @object.uuid, tagclass: "arvados-uuid"} %>
-        </p>
-        <p><i>Content address:</i><br />
-          <%= render partial: "show_autoselect_text", locals: {text: @object.portable_data_hash, tagclass: "arvados-uuid"} %>
-        </p>
-        <%= render partial: "show_source_summary" %>
-      </div>
-    </div>
-  </div>
-  <% if current_user %>
-  <div class="col-md-5">
-    <div class="panel panel-default">
-      <div class="panel-heading">
-        <h3 class="panel-title">
-          Sharing and permissions
-        </h3>
-      </div>
-      <div class="panel-body">
-        <% if !Rails.configuration.Workbench.DisableSharingURLsUI %>
-        <div id="sharing-button">
-          <%= render partial: 'sharing_button' %>
-        </div>
-        <% end %>
-
-        <div style="height:0.5em;"></div>
-        <% if @projects.andand.any? %>
-          <p>Included in projects:<br />
-          <%= render_arvados_object_list_start(@projects, 'Show all projects',
-                links_path(filters: [['head_uuid', '=', @object.uuid],
-                                     ['link_class', '=', 'name']].to_json)) do |project| %>
-            <%= link_to_if_arvados_object(project, friendly_name: true) %><br />
-          <% end %>
-          </p>
-        <% end %>
-        <% if @permissions.andand.any? %>
-          <p>Readable by:<br />
-          <%= render_arvados_object_list_start(@permissions, 'Show all permissions',
-                links_path(filters: [['head_uuid', '=', @object.uuid],
-                                    ['link_class', '=', 'permission']].to_json)) do |link| %>
-          <%= link_to_if_arvados_object(link.tail_uuid, friendly_name: true) %><br />
-          <% end %>
-          </p>
-        <% end %>
-
-      </div>
-    </div>
-  </div>
-  <% else %>
-  <div class="col-md-5">
-    <div class="panel panel-default">
-      <div class="panel-heading">
-        <h3 class="panel-title">
-          Welcome to Arvados
-        </h3>
-      </div>
-      <div class="panel-body">
-        You are accessing public data.
-      </div>
-    </div>
-  </div>
-  <% end %>
-</div>
-
-<%= render file: 'application/show.html.erb', locals: local_assigns %>
diff --git a/apps/workbench/app/views/collections/show_file_links.html.erb b/apps/workbench/app/views/collections/show_file_links.html.erb
deleted file mode 100644 (file)
index d7483a6..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<!DOCTYPE html>
-<html lang="en">
-<% coll_name = "Collection #{@object.uuid}" %>
-<% link_opts = {controller: 'collections', action: 'show_file',
-                uuid: @object.uuid, reader_token: params[:reader_token]} %>
-<head>
-  <meta charset="utf-8">
-  <title>
-    <%= coll_name %> / <%= Rails.configuration.Workbench.SiteName %>
-  </title>
-  <meta name="description" content="">
-  <meta name="author" content="">
-  <meta name="robots" content="NOINDEX">
-  <style type="text/css">
-body {
-  margin: 1.5em;
-}
-pre {
-  background-color: #D9EDF7;
-  border-radius: .25em;
-  padding: .75em;
-  overflow: auto;
-}
-.footer {
-  font-size: 82%;
-}
-.footer h2 {
-  font-size: 1.2em;
-}
-  </style>
-</head>
-<body>
-
-<h1><%= coll_name %></h1>
-
-<p>This collection of data files is being shared with you through
-Arvados.  You can download individual files listed below.  To download
-the entire collection with wget, try:</p>
-
-<pre>$ wget --mirror --no-parent --no-host --cut-dirs=3 <%=
-         url_for(link_opts.merge(action: 'show_file_links', only_path: false,
-                                 trailing_slash: true))
-       %></pre>
-
-<h2>File Listing</h2>
-
-<% file_tree = @object.andand.files_tree %>
-<% if file_tree.andand.any? %>
-  <ul id="collection_files" class="collection_files">
-  <% dirstack = [file_tree.first.first] %>
-  <% file_tree.take(10000).each_with_index do |(dirname, filename, size), index| %>
-    <% file_path = CollectionsHelper::file_path([dirname, filename]) %>
-    <% while dirstack.any? and (dirstack.last != dirname) %>
-      <% dirstack.pop %></ul></li>
-    <% end %>
-    <li>
-    <% if size.nil?  # This is a subdirectory. %>
-      <% dirstack.push(File.join(dirname, filename)) %>
-      <%= filename %>
-      <ul class="collection_files">
-    <% else %>
-      <%= link_to(filename,
-                  link_opts.merge(file: file_path),
-                  {title: "Download #{file_path}"}) %>
-      </li>
-    <% end %>
-  <% end %>
-  <%= raw(dirstack.map { |_| "</ul>" }.join("</li>")) %>
-<% else %>
-  <p>No files in this collection.</p>
-<% end %>
-
-<div class="footer">
-<h2>About Arvados</h2>
-
-<p>Arvados is a free and open source software bioinformatics platform.
-To learn more, visit arvados.org.
-Arvados is not responsible for the files listed on this page.</p>
-</div>
-
-</body>
-</html>
diff --git a/apps/workbench/app/views/container_requests/_extra_tab_line_buttons.html.erb b/apps/workbench/app/views/container_requests/_extra_tab_line_buttons.html.erb
deleted file mode 100644 (file)
index 7a9d68d..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @object.state == 'Final' %>
-<script type="application/javascript">
-  function reset_form_cr_reuse() {
-    $('#use_existing').removeAttr('checked');
-  }
-</script>
-
-    <%= link_to(choose_projects_path(id: "run-workflow-button",
-                                     title: 'Choose project',
-                                     editable: true,
-                                     action_name: 'Choose',
-                                     action_href: copy_container_request_path,
-                                     action_method: 'post',
-                                     action_data: {'selection_param' => 'work_unit[owner_uuid]',
-                                                   'work_unit[template_uuid]' => @object.uuid,
-                                                   'success' => 'redirect-to-created-object'
-                                                  }.to_json),
-          { class: "btn btn-primary btn-sm", title: "Run #{@object.name}", remote: true }
-          ) do %>
-      <i class="fa fa-fw fa-play"></i> Re-run...
-    <% end %>
-
-<% end %>
diff --git a/apps/workbench/app/views/container_requests/_name_and_description.html.erb b/apps/workbench/app/views/container_requests/_name_and_description.html.erb
deleted file mode 100644 (file)
index 085ba83..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-  wu = @object.work_unit
-  template_uuid = wu.template_uuid
-  template = Workflow.find?(template_uuid) if template_uuid
-  div_class = "col-sm-12"
-  div_class = "col-sm-6" if template
-%>
-
-<div class="<%=div_class%>">
-  <%= render partial: 'object_name' %>
-  <%= render partial: 'object_description' %>
-</div>
-
-<% if template %>
-  <div class="alert alert-info <%=div_class%>">
-     This container request was created from the workflow <%= link_to_if_arvados_object template, friendly_name: true %><br />
-     <% if template.modified_at && (template.modified_at > @object.created_at) %>
-        Note: This workflow has been modified since this container request was created.
-     <% end %>
-  </div>
-<% end %>
diff --git a/apps/workbench/app/views/container_requests/_show_inputs.html.erb b/apps/workbench/app/views/container_requests/_show_inputs.html.erb
deleted file mode 100644 (file)
index 07bf7c4..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-n_inputs = if @object.mounts[:"/var/lib/cwl/workflow.json"] && @object.mounts[:"/var/lib/cwl/cwl.input.json"]
-             cwl_inputs_required(@object, get_cwl_inputs(@object.mounts[:"/var/lib/cwl/workflow.json"][:content]), [:mounts, :"/var/lib/cwl/cwl.input.json", :content])
-           else
-             0
-           end
-%>
-
-<% content_for :pi_input_form do %>
-<form role="form" style="width:60%">
-  <div class="form-group">
-    <% workflow = @object.mounts[:"/var/lib/cwl/workflow.json"].andand[:content] %>
-    <% if workflow %>
-      <% inputs = get_cwl_inputs(workflow) %>
-      <% inputs.each do |input| %>
-        <div class="form-control-static">
-          <label for="#input-<%= cwl_shortname(input[:id]) %>">
-            <%= input[:label] || cwl_shortname(input[:id]) %>
-          </label>
-          <%= render_cwl_input @object, input, [:mounts, :"/var/lib/cwl/cwl.input.json", :content] %>
-          <p class="help-block">
-            <%= input[:doc] %>
-          </p>
-        </div>
-      <% end %>
-    <% end %>
-  </div>
-</form>
-<% end %>
-
-<p style="margin-bottom: 2em"><b style="margin-right: 3em">Reuse past workflow steps if available?</b>  <%= render_editable_attribute(@object, :reuse_steps) %></p>
-
-<% if n_inputs == 0 %>
-  <p><i>This workflow does not need any further inputs specified.  Click the "Run" button at the bottom of the page to start the workflow.</i></p>
-<% else %>
-  <p><i>Provide <%= n_inputs > 1 ? 'values' : 'a value' %> for the following <%= n_inputs > 1 ? 'parameters' : 'parameter' %>, then click the "Run" button to start the workflow.</i></p>
-<% end %>
-
-<% if @object.editable? %>
-  <%= content_for :pi_input_form %>
-  <%= link_to(url_for('container_request[state]' => 'Committed'),
-        class: 'btn btn-primary run-pipeline-button',
-        method: :patch
-        ) do %>
-    Run <i class="fa fa-fw fa-play"></i>
-  <% end %>
-<% end %>
-
-<%= render_unreadable_inputs_present %>
diff --git a/apps/workbench/app/views/container_requests/_show_log.html.erb b/apps/workbench/app/views/container_requests/_show_log.html.erb
deleted file mode 100644 (file)
index ec529aa..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render(partial: 'work_units/show_log', locals: {obj: @object, name: @object[:name] || 'this container'}) %>
diff --git a/apps/workbench/app/views/container_requests/_show_object_description_cell.html.erb b/apps/workbench/app/views/container_requests/_show_object_description_cell.html.erb
deleted file mode 100644 (file)
index 2df207a..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="nowrap">
-  <%= object.content_summary %><br />
-  <%= render partial: 'container_requests/state_label', locals: {object: object} %>
-</div>
diff --git a/apps/workbench/app/views/container_requests/_show_provenance.html.erb b/apps/workbench/app/views/container_requests/_show_provenance.html.erb
deleted file mode 100644 (file)
index d9c1273..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'application/svg_div', locals: {
-      divId: "provenance_graph",
-      svgId: "provenance_svg",
-      svg: @svg } %>
diff --git a/apps/workbench/app/views/container_requests/_show_recent.html.erb b/apps/workbench/app/views/container_requests/_show_recent.html.erb
deleted file mode 100644 (file)
index 6cdd8a4..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= form_tag({}, {id: "containerRequests"}) do |f| %>
-
-<table class="table table-condensed table-fixedlayout arv-recent-container-requests">
-  <colgroup>
-    <col width="10%" />
-    <col width="20%" />
-    <col width="20%" />
-    <col width="15%" />
-    <col width="15%" />
-    <col width="15%" />
-    <col width="5%" />
-  </colgroup>
-  <thead>
-    <tr class="contain-align-left">
-      <th>
-        Status
-      </th><th>
-        Name
-      </th><th>
-        Description
-      </th><th>
-        Workflow
-      </th><th>
-        Owner
-      </th><th>
-        Created at
-      </th><th>
-      </th>
-    </tr>
-  </thead>
-
-  <tbody data-infinite-scroller="#recent-container-requests" id="recent-container-requests"
-         data-infinite-content-href="<%= url_for partial: :recent_rows %>" >
-  </tbody>
-</table>
-
-<% end %>
diff --git a/apps/workbench/app/views/container_requests/_show_recent_rows.html.erb b/apps/workbench/app/views/container_requests/_show_recent_rows.html.erb
deleted file mode 100644 (file)
index 0212162..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-  containers = @objects.map(&:container_uuid).compact.uniq
-  preload_objects_for_dataclass(Container, containers) if containers.any?
-
-  workflows = @objects.collect {|o| o.properties[:template_uuid]}.compact.uniq
-  preload_objects_for_dataclass(Workflow, workflows) if workflows.any?
-
-  owner_uuids = @objects.map(&:owner_uuid).compact.uniq
-  preload_objects_for_dataclass(User, owner_uuids) if owner_uuids.any?
-  preload_objects_for_dataclass(Group, owner_uuids) if owner_uuids.any?
-
-  objs = containers + workflows + owner_uuids
-  preload_links_for_objects objs if objs.any?
-%>
-
-<% @objects.sort_by { |obj| obj.created_at }.reverse.each do |obj| %>
-  <% wu = obj.work_unit obj.name %>
-
-  <tr data-object-uuid="<%= wu.uuid %>" class="cr-<%= wu.uuid %>">
-    <td>
-      <span class="label label-<%= wu.state_bootstrap_class %>"><%= wu.state_label %></span>
-    </td><td>
-      <%= link_to_if_arvados_object obj, friendly_name: true, link_text: if obj.name && !obj.name.empty? then obj.name else obj.uuid end %>
-    </td><td>
-      <%= obj.description || '' %>
-    </td><td>
-      <%= link_to_if_arvados_object wu.template_uuid, friendly_name: true %>
-    </td><td>
-      <%= link_to_if_arvados_object wu.owner_uuid, friendly_name: true %>
-    </td><td>
-      <%= wu.created_at.to_s %>
-    </td><td>
-      <%= render partial: 'delete_object_button', locals: {object:obj} %>
-    </td>
-  </tr>
-<% end %>
diff --git a/apps/workbench/app/views/container_requests/_show_status.html.erb b/apps/workbench/app/views/container_requests/_show_status.html.erb
deleted file mode 100644 (file)
index 49dfdcd..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render(partial: 'work_units/show_status', locals: {current_obj: @object, name: @object[:name] || 'this container'}) %>
diff --git a/apps/workbench/app/views/container_requests/_state_label.html.erb b/apps/workbench/app/views/container_requests/_state_label.html.erb
deleted file mode 100644 (file)
index 1ddd2b2..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% wu = object.work_unit object.name %>
-<span class="label label-<%=wu.state_bootstrap_class%>">
-  <%=wu.state_label%>
-</span>
diff --git a/apps/workbench/app/views/container_requests/index.html.erb b/apps/workbench/app/views/container_requests/index.html.erb
deleted file mode 100644 (file)
index d4c64f5..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :tab_line_buttons do %>
-  <div class="input-group">
-    <input type="text" class="form-control filterable-control recent-container-requests-filterable-control"
-           placeholder="Search container requests"
-           data-filterable-target="#recent-container-requests"
-           value="<%= params[:search] %>"
-           />
-  </div>
-<% end %>
-
-<%= render file: 'application/index.html.erb', locals: local_assigns %>
diff --git a/apps/workbench/app/views/containers/_show_log.html.erb b/apps/workbench/app/views/containers/_show_log.html.erb
deleted file mode 100644 (file)
index ec529aa..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render(partial: 'work_units/show_log', locals: {obj: @object, name: @object[:name] || 'this container'}) %>
diff --git a/apps/workbench/app/views/containers/_show_status.html.erb b/apps/workbench/app/views/containers/_show_status.html.erb
deleted file mode 100644 (file)
index 52d2e87..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render(partial: 'work_units/show_status', locals: {current_obj: @object, name: @object[:name] || 'this container'}) %>
-
-<div class="panel panel-default">
-  <div class="panel-heading">
-    <span class="panel-title">Container requests</span>
-  </div>
-  <div class="panel-body">
-    <% crs = ContainerRequest.order("created_at desc").filter([["container_uuid", "=", @object.uuid]]) %>
-    <% crs.each do |cr| %>
-      <div>
-        <%= link_to_if_arvados_object cr, friendly_name: true %>
-        created at
-        <%= render_localized_date(cr.created_at) %>.
-      </div>
-    <% end %>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/getting_started/_getting_started_popup.html.erb b/apps/workbench/app/views/getting_started/_getting_started_popup.html.erb
deleted file mode 100644 (file)
index 99880f2..0000000
+++ /dev/null
@@ -1,183 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<style>
-div.figure {
-}
-.style_image1 {
-  border: 10px solid #ddd;
-  display: block;
-  margin-left: auto;
-  margin-right: auto;
-}
-.style_image2 {
-  border: 10px solid #ddd;
-  display: block;
-  margin-left: 1em;
-}
-div.figure p {
-  text-align: center;
-  font-style: italic;
-  text-indent: 0;
-  border-top:-0.3em;
-}
-</style>
-
-<div id="getting-started-modal-window" class="modal">
-  <div class="modal-dialog modal-with-loading-spinner" style="width: 50em">
-    <div class="modal-content">
-      <div class="modal-header" style="text-align: center">
-        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">x</button>
-        <div>
-          <div class="col-sm-8"><h4 class="modal-title" style="text-align: right">Getting Started with Arvados</h4></div>  <%#Todo: center instead of right%>
-          <div class="spinner spinner-32px spinner-h-center col-sm-1" hidden="true"></div>
-        </div>
-        <br/>
-      </div>
-
-      <%#First Page%>
-      <div class="modal-body" style="height: 40em; overflow-y: scroll">
-        <div style="margin-top: -0.5em; margin-left: 0.5em;">
-          <p><div style="font-size: 150%;">Welcome!</div></p>
-          <p>
-            What you're looking at right now is <b>Workbench</b>, the graphical interface to the Arvados system.
-          </p><p>
-            <div class="figure">
-              <p> <%= image_tag "pipeline-running.gif", :class => "style_image1" %></p> <%#Todo: shorter gif%>
-              <p>Running the Pathomap pipeline in Arvados.</p>
-            </div>
-          </p><p>
-            Click the <span class="btn btn-sm btn-primary">Next &gt;</span> button below for a speed tour of Arvados.
-          </p><p style="margin-top:2em;">
-            <em><strong>Note:</strong> You can always come back to this Getting Started guide by clicking the <span class="fa fa-lg fa-question-circle"></span> in the upper-right corner.</em>
-          </p>
-        </div>
-      </div>
-
-      <%#Page Two%>
-      <div class="modal-body" style="height: 40em; overflow-y: scroll">
-        <div style="margin-top: -0.5em; margin-left: 0.5em;">
-          <p><div style="font-size: 150%;">Take It for a Spin</div></p>
-          <p>
-            Run your first pipeline in 3 quick steps:
-          </p>
-          <div style="display: block; margin: 0em 2em; padding-top: 1em; padding-bottom: 1em; border: thin dashed silver;">
-            <p style="margin-left: 1em;">
-              <em>First, <a href="/users/welcome">log-in or register</a> with any Google account if you haven't already.</em>
-            </p><p>
-              <ol><li> Go to the <span class="btn btn-sm btn-default"><i class="fa fa-lg fa-fw fa-dashboard"></i> Dashboard</span> &gt; <span class="btn btn-sm btn-primary"><i class="fa fa-fw fa-gear"></i> Run a pipeline...</span>
-                  <p style="margin-top:1em;">
-                    <%= image_tag "mouse-move.gif", :class => "style_image2" %>
-                  </p>
-                </li>
-                <li> <span class="btn btn-sm btn-default"><i class="fa fa-fw fa-gear"></i>Mason Lab -- Ancestry Mapper (public)</span> &gt; <span class="btn btn-sm btn-primary">Next: choose inputs <i class="fa fa-fw fa-arrow-circle-right"></i></span></li><br>
-                <li> <span class="btn btn-sm btn-primary">Run <i class="fa fa-fw fa-play"></i></span></li>
-              </ol>
-          </p></div>
-          <p style="margin-top:1em;">
-            <i class="fa fa-flag fa-flip-horizontal" style="color: green"></i> <i class="fa fa-child"></i>
-            <strong>Voila!</strong> <i class="fa fa-child"></i> <i class="fa fa-flag" style="color: green"></i>
-            Your pipeline is now spooling up and getting ready to run!
-          </p><p>
-            Go ahead, try it for yourself right now. <span class="glyphicon glyphicon-thumbs-up"></span>
-          </p><p>
-            Or click <span class="btn btn-sm btn-primary">Next &gt;</span> below to keep reading!
-          </p>
-        </div>
-      </div>
-
-      <%#Page Three%>
-      <div class="modal-body" style="height: 40em; overflow-y: scroll">
-        <div style="margin-top: -0.5em; margin-left: 0.5em;">
-          <p><div style="font-size: 150%;">Three Useful Terms</div></p>
-          <ol>
-            <li>
-              <strong>Pipeline</strong> — A re-usable series of analysis steps.
-              <ul>
-                <li>
-                  Also known as a “workflow” in other systems
-                </li><li>
-                  A list of well-documented public pipelines can be found in the upper right corner by clicking the <span class="fa fa-lg fa-question-circle"></span> &gt; <a href="<%= Rails.configuration.Workbench.ArvadosPublicDataDocURL %>">Public Pipelines and Datasets</a>
-                </li><li>
-                  Pro-tip: A Pipeline contains Jobs which contain Tasks
-                </li><li>
-                  Pipelines can only be shared within a project
-                </li>
-              </ul>
-            </li>
-
-            <li>
-              <strong>Collection </strong>— Like a folder, but better.
-              <ul>
-                <li>
-                  Upload data right in your browser
-                </li><li>
-                  Better than a folder?
-                  <ul><li>
-                      Collections contain the content-address of the data instead of the data itself
-                    </li><li>
-                      Sets of data can be flexibly defined and re-defined without duplicating data
-                    </li>
-                </ul></li><li>
-                  Collections can be shared using the "Sharing and Permissions"  &gt; "Share" button
-                </li>
-              </ul>
-            </li>
-
-            <li>
-              <strong>Projects </strong>— Contain pipelines templates, pipeline instances (individual runs of a pipeline), and collections.
-              <ul><li>
-                  The most useful one is your default "Home" project, under Projects &gt; Home
-                </li><li>
-                  Projects can be shared using the "sharing" tab
-                </li>
-              </ul>
-            </li>
-          </ol>
-
-        </div>
-      </div>
-
-      <%#Page Four%>
-      <div class="modal-body" style="height: 40em; overflow-y: scroll">
-        <div style="margin-top: -0.5em; margin-left: 0.5em;">
-          <p><div style="font-size: 150%;">Six Reasons Arvados is Awesome</div></p>
-          <p>
-            This guide, and in fact all of Workbench, is just a teaser for the full power of Arvados:
-          </p>
-          <ol>
-            <li>
-              <strong>Reproducible analyses</strong>: Enough said.
-            </li><li>
-              <strong>Data provenance</strong>: Every file in Arvados can tell you where it came from.
-            </li><li>
-              <strong>Serious scaling</strong>: Need 500 GB of space? 200 compute hours? Arvados scales and parallelizes your work for you intelligently.
-            </li><li>
-              <strong>Share pipelines or data</strong>: Easily publish your work to the world, just like <a href="http://www.pathomap.org/2015/04/08/run-the-pathomap-human-ancestry-pipeline-on-arvados/">the Pathomap team did</a>.
-            </li><li>
-              <strong>Use existing pipelines</strong>: Use best-practices pipelines on your own data with the click of a button.
-            </li><li>
-              <strong>Open source</strong>: Arvados is completely open source. Check out our <a href="http://dev.arvados.org">developer site</a>.
-            </li>
-          </ol>
-          <p style="margin-top: 1em;">
-            Want to use the command-line, or hungry to learn more? Check out the User Guide at <a href="http://doc.arvados.org/">doc.arvados.org</a>.
-          </p><p>
-            Questions still? Head over to <a href="http://doc.arvados.org/">doc.arvados.org</a> to find mailing-list and contact info for the Arvados community.
-          </p><p>
-            That's all, folks! Click the "x" up top to leave this guide.
-          </p>
-        </div>
-      </div>
-
-      <div class="modal-footer">
-        <div style="text-align:center">
-          <button class="btn btn-default pager-prev"><i class="fa fa-fw fa-chevron-left"></i><span style="font-weight: bold;"> Prev</span></button>
-          <button class="btn btn-default pager-next"><span style="font-weight: bold;">Next </span><i class="fa fa-fw fa-chevron-right"></i></button>
-          <div class="pager-count pull-right"><span style="margin:5px"></span></div>
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/groups/_choose_rows.html.erb b/apps/workbench/app/views/groups/_choose_rows.html.erb
deleted file mode 100644 (file)
index 9286752..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% icon_class = fa_icon_class_for_class(Group) %>
-<% @objects.each do |object| %>
-  <div class="row filterable selectable" data-object-uuid="<%= object.uuid %>">
-    <div class="col-sm-12" style="overflow-x:hidden">
-      <i class="fa fa-fw <%= icon_class %>"></i>
-      <%= object.name %>
-    </div>
-  </div>
-<% end %>
diff --git a/apps/workbench/app/views/groups/_show_recent.html.erb b/apps/workbench/app/views/groups/_show_recent.html.erb
deleted file mode 100644 (file)
index 3acbfef..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: "paging", locals: {results: @groups, object: @object} %>
-
-<table class="table table-hover">
-  <thead>
-    <tr class="contain-align-left">
-      <th>
-       Group
-      </th><th>
-       Owner
-      </th><th>
-       Incoming permissions
-      </th><th>
-       Outgoing permissions
-      </th><th>
-       <!-- column for delete buttons -->
-      </th>
-    </tr>
-  </thead>
-  <tbody>
-
-    <% @groups.sort_by { |g| g[:created_at] }.reverse.each do |g| %>
-
-    <tr>
-      <td>
-        <%= link_to_if_arvados_object g, friendly_name: true %>
-      </td><td>
-        <%= link_to_if_arvados_object g.owner_uuid, friendly_name: true %>
-      </td><td>
-        <%= @links_to.select { |x| x.head_uuid == g.uuid }.collect(&:tail_uuid).uniq.count %>
-      </td><td>
-        <%= @links_from.select { |x| x.tail_uuid == g.uuid }.collect(&:head_uuid).uniq.count %>
-      </td><td>
-        <%= render partial: 'delete_object_button', locals: {object:g} %>
-      </td>
-    </tr>
-
-    <% end %>
-
-  </tbody>
-</table>
-
-<%= render partial: "paging", locals: {results: @groups, object: @object} %>
diff --git a/apps/workbench/app/views/issue_reporter/send_report.text.erb b/apps/workbench/app/views/issue_reporter/send_report.text.erb
deleted file mode 100644 (file)
index a6108dc..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @user %>
-Issue reported by user <%=@user.email%>
-<% else %>
-Issue reported
-<% end %>
-
-Details of the report:
-<% if @params['report_additional_info'] %>
-<%  map_to_s = JSON.parse(@params['report_additional_info']).map {|k,v| "#{k}=#{v}"}.join("\n") %>
-<%= map_to_s %>
-<% end %>
-Report text=<%=@params['report_issue_text'] %>
diff --git a/apps/workbench/app/views/jobs/_create_new_object_button.html.erb b/apps/workbench/app/views/jobs/_create_new_object_button.html.erb
deleted file mode 100644 (file)
index 33c21e2..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%# There is no UI for context-free "create a new job" %>
diff --git a/apps/workbench/app/views/jobs/_rerun_job_with_options_popup.html.erb b/apps/workbench/app/views/jobs/_rerun_job_with_options_popup.html.erb
deleted file mode 100644 (file)
index ba68106..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @job = @object %>
-<div id="jobRerunModal" class="modal" role="dialog" aria-labelledby="jobRerunTitle" aria-hidden="true">
-  <div class="modal-dialog">
-    <div class="modal-content">
-      <%= form_for(@job, method: :post, url: {controller: 'jobs', action: 'create'}) do |f| %>
-        <% [:script, :repository, :supplied_script_version, :nondeterministic].each do |field_sym| %>
-          <%= f.hidden_field(field_sym) %>
-        <% end %>
-        <% [:script_parameters, :runtime_constraints].each do |field_sym| %>
-          <%= f.hidden_field(field_sym, value: @job.send(field_sym).to_json) %>
-        <% end %>
-        <div class="modal-header">
-          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-          <div id="jobRerunTitle">
-            <div class="col-sm-6"> <h4 class="modal-title">Re-run job</h4> </div>
-          </div>
-          <br/>
-        </div>
-
-        <div class="modal-body">
-          <p>
-            If this job is part of a pipeline, that pipeline would not
-            know about the new job you are running.  If you want to
-            update your pipeline results, please re-run the pipeline
-            instead.
-          </p>
-          <p>
-            The inputs and parameters will be the same as the current
-            job.  Thus, the new job will not reflect any changes made
-            to the pipeline that initiated this job.
-          </p>
-          <div style="padding-left: 1em">
-            <% if (@job.supplied_script_version.blank? or
-                   (@job.supplied_script_version == @job.script_version)) %>
-              <%= f.hidden_field(:script_version) %>
-            <% else %>
-              <%= f.radio_button("script_version", @job.script_version) %>
-              <%= f.label(:script_version, "Use same script version as this run", value: @job.script_version) %>
-              <p style="padding-left: 1em"> Use the same script version as the current job.</p>
-
-              <%= f.radio_button(:script_version, @job.supplied_script_version) %>
-              <%= f.label(:script_version, "Use latest script version", value: @job.supplied_script_version) %>
-              <p style="padding-left: 1em"> Use the current commit indicated by '<%= @job.supplied_script_version %>' in the '<%= @job.repository %>' repository.</p>
-            <% end %>
-          </div>
-        </div>
-
-        <div class="modal-footer">
-          <button class="btn btn-default" data-dismiss="modal" aria-hidden="true">Cancel</button>
-          <%= f.submit(value: "Run now", class: "btn btn-primary") %>
-        </div>
-      <% end %>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/jobs/_show_details.html.erb b/apps/workbench/app/views/jobs/_show_details.html.erb
deleted file mode 100644 (file)
index e27cbd2..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'application/show_attributes' %>
diff --git a/apps/workbench/app/views/jobs/_show_job_buttons.html.erb b/apps/workbench/app/views/jobs/_show_job_buttons.html.erb
deleted file mode 100644 (file)
index 7938a65..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @object.state != "Running" and Job.creatable? %>
-  <button type="button" class="btn btn-sm btn-primary" data-toggle="modal" data-target="#jobRerunModal">
-    <i class="fa fa-fw fa-gear"></i> Re-run job...
-  </button>
-<% end %>
diff --git a/apps/workbench/app/views/jobs/_show_log.html.erb b/apps/workbench/app/views/jobs/_show_log.html.erb
deleted file mode 100644 (file)
index 10b7fa1..0000000
+++ /dev/null
@@ -1,286 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if !@object.log %>
-
-<div id="log_graph_div"
-     class="arv-log-event-listener"
-     style="display:none"
-     data-object-uuid="<%= @object.uuid %>"></div>
-
-<pre id="event_log_div"
-     class="arv-log-event-listener arv-log-event-handler-append-logs arv-job-log-window"
-     data-object-uuid="<%= @object.uuid %>"
-  ><%= @object.stderr_log_lines(Rails.configuration.Workbench.RunningJobLogRecordsToFetch).join("\n") %>
-</pre>
-
-<%# Applying a long throttle suppresses the auto-refresh of this
-    partial that would normally be triggered by arv-log-event. %>
-<div class="arv-log-refresh-control"
-     data-load-throttle="86486400000" <%# 1001 nights %>
-     ></div>
-
-<% else %>
-
-<script>
-(function() {
-var pagesize = 1000;
-var logViewer = new List('log-viewer', {
-  valueNames: [ 'id', 'timestamp', 'taskid', 'message', 'type'],
-  page: pagesize
-});
-
-logViewer.page_offset = 0;
-logViewer.on("updated", function() { updatePaging(".log-viewer-paging", logViewer, pagesize) } );
-$(".log-viewer-page-up").on("click", function() { prevPage(logViewer, pagesize, ".log-viewer-paging"); return false; });
-$(".log-viewer-page-down").on("click", function() { nextPage(logViewer, pagesize, ".log-viewer-paging"); return false; });
-
-var taskState = newTaskState();
-
-var makeFilter = function() {
-  var pass = [];
-  $(".toggle-filter, .radio-filter").each(function(i, e) {
-    if (e.checked) {
-      pass.push(e.id.substr(5));
-    }
-  });
-
-  return (function(item) {
-    var v = false;
-    if (item.values().taskid !== "") {
-      for (a in pass) {
-        if (pass[a] == "all-tasks") { v = true; }
-        else if (pass[a] == "successful-tasks" && taskState[item.values().taskid].outcome == "success") { v = true; }
-        else if (pass[a] == "failed-tasks" && taskState[item.values().taskid].outcome == "failure") { v = true; }
-      }
-    } else {
-      v = true;
-    }
-    for (a in pass) {
-      if (pass[a] == item.values().type) { return v; }
-    }
-    return false;
-  });
-}
-
-<% if @object.log and !@object.log.empty? %>
-  <% logcollection = Collection.find @object.log %>
-  <% if logcollection %>
-    var log_size = <%= logcollection.files[0][2] %>
-    var log_maxbytes = <%= Rails.configuration.Workbench.LogViewerMaxBytes %>;
-    var logcollection_url = '<%=j url_for logcollection %>/<%=j logcollection.files[0][1] %>';
-    $("#log-viewer-download-url").attr('href', logcollection_url);
-    $("#log-viewer-download-pane").show();
-    var headers = {};
-    if (log_size > log_maxbytes) {
-      headers['Range'] = 'bytes=0-' + (log_maxbytes - 1);
-    }
-    var ajax_opts = { dataType: 'text', headers: headers };
-    load_log();
-
-    function load_log() {
-        $.ajax(logcollection_url, ajax_opts).done(done).fail(fail);
-    }
-    function done(data, status, jqxhr) {
-        if (jqxhr.getResponseHeader('Content-Type').indexOf('application/json') === 0) {
-            // The browser won't allow a redirect-with-cookie response
-            // because keep-web isn't same-origin with us. Instead, we
-            // assure keep-web it's OK to respond with the content
-            // immediately by setting the token in the request body
-            // instead and adding disposition=attachment.
-            logcollection_url = JSON.parse(data).href;
-            var queryAt = logcollection_url.indexOf('?api_token=');
-            if (queryAt >= 0) {
-                ajax_opts.method = 'POST';
-                ajax_opts.data = {
-                    api_token: logcollection_url.slice(queryAt+11),
-                    disposition: 'attachment',
-                };
-                logcollection_url = logcollection_url.slice(0, queryAt);
-            }
-            return load_log();
-        }
-        logViewer.filter();
-        addToLogViewer(logViewer, data.split("\n"), taskState);
-        logViewer.filter(makeFilter());
-        content_range_hdr = jqxhr.getResponseHeader('Content-Range');
-        var v = content_range_hdr && content_range_hdr.match(/bytes \d+-(\d+)\/(.+)/);
-        short_log = v && (v[2] == '*' || parseInt(v[1]) + 1 < v[2]);
-        if (jqxhr.status == 206 && short_log) {
-            $("#log-viewer-overview").html(
-                '<p>Showing only ' + data.length + ' bytes of this log.' +
-                    ' Timing information is unavailable since' +
-                    ' the full log was not retrieved.</p>'
-            );
-        } else {
-            generateJobOverview("#log-viewer-overview", logViewer, taskState);
-        }
-        $("#log-viewer .spinner").detach();
-    }
-    function fail(jqxhr, status, error) {
-        // TODO: tell the user about the error
-        console.log('load_log failed: status='+status+' error='+error);
-        $("#log-viewer .spinner").detach();
-    }
-  <% end %>
-<% else %>
-  <%# Live log loading not implemented yet. %>
-<% end %>
-
-$(".toggle-filter, .radio-filter").on("change", function() {
-  logViewer.filter(makeFilter());
-});
-
-$("#filter-all").on("click", function() {
-  $(".toggle-filter").each(function(i, f) { f.checked = true; });
-  logViewer.filter(makeFilter());
-});
-
-$("#filter-none").on("click", function() {
-  $(".toggle-filter").each(function(i, f) { f.checked = false; console.log(f); });
-  logViewer.filter(makeFilter());
-});
-
-$("#sort-by-time").on("change", function() {
-  logViewer.sort("id", {sortFunction: sortById});
-});
-
-$("#sort-by-task").on("change", function() {
-  logViewer.sort("taskid", {sortFunction: sortByTask});
-});
-
-$("#sort-by-node").on("change", function() {
-  logViewer.sort("node", {sortFunction: sortByNode});
-});
-
-$("#set-show-failed-only").on("click", function() {
-  $("#sort-by-task").prop("checked", true);
-  $("#show-failed-tasks").prop("checked", true);
-  $("#show-crunch").prop("checked", false);
-  $("#show-task-dispatch").prop("checked", true);
-  $("#show-script-print").prop("checked", true);
-  $("#show-crunchstat").prop("checked", false);
-  logViewer.filter(makeFilter());
-  logViewer.sort("taskid", {sortFunction: sortByTask});
-});
-
-})();
-
-</script>
-
-<div id="log-viewer">
-
-  <h3>Summary</h3>
-  <p id="log-viewer-overview">
-    <% if !logcollection %>
-      The collection containing the job log was not found.
-    <% end %>
-  </p>
-
-  <p id="log-viewer-download-pane" style="display:none">
-    <a id="log-viewer-download-url" href="">Download the full log</a>
-  </p>
-
-  <div class="h3">Log
-
-    <span class="pull-right">
-      <% if @object.andand.tasks_summary.andand[:failed] and @object.tasks_summary[:failed] > 0 %>
-        <button id="set-show-failed-only" class="btn btn-danger">
-          Show failed task diagnostics only
-        </button>
-      <% end %>
-
-      <button id="filter-all" class="btn">
-        Select all
-      </button>
-      <button id="filter-none" class="btn">
-        Select none
-      </button>
-    </span>
-  </div>
-
-  <input class="search pull-right" style="margin-top: 1em" placeholder="Search" />
-
-  <div>
-    <div class="radio-inline log-viewer-button" style="margin-left: 10px">
-      <label><input id="sort-by-time" type="radio" name="sort-radio" checked> Sort by time</label>
-    </div>
-    <div class="radio-inline log-viewer-button">
-      <label><input id="sort-by-node" type="radio" name="sort-radio" > Sort by node</label>
-    </div>
-
-    <div class="radio-inline log-viewer-button">
-      <label><input id="sort-by-task" type="radio" name="sort-radio" > Sort by task</label>
-    </div>
-  </div>
-
-  <div>
-    <div class="radio-inline log-viewer-button" style="margin-left: 10px">
-      <label><input id="show-all-tasks" type="radio" name="show-tasks-group" checked="true" class="radio-filter"> Show all tasks</label>
-    </div>
-    <div class="radio-inline log-viewer-button">
-      <label><input id="show-successful-tasks" type="radio" name="show-tasks-group" class="radio-filter"> Only successful tasks</label>
-    </div>
-    <div class="radio-inline log-viewer-button">
-      <label><input id="show-failed-tasks" type="radio" name="show-tasks-group" class="radio-filter"> Only failed tasks</label>
-    </div>
-  </div>
-
-  <div>
-    <div class="checkbox-inline log-viewer-button" style="margin-left: 10px">
-      <label><input id="show-crunch" type="checkbox" checked="true" class="toggle-filter"> Show crunch diagnostics</label>
-    </div>
-    <div class="checkbox-inline log-viewer-button">
-      <label><input id="show-task-dispatch" type="checkbox" checked="true" class="toggle-filter"> Show task dispatch</label>
-    </div>
-    <div class="checkbox-inline log-viewer-button">
-      <label><input id="show-task-print" type="checkbox" checked="true" class="toggle-filter"> Show task diagnostics</label>
-    </div>
-    <div class="checkbox-inline log-viewer-button">
-      <label><input id="show-crunchstat" type="checkbox" checked="true" class="toggle-filter"> Show compute usage</label>
-    </div>
-
-  </div>
-
-  <div class="smart-scroll" data-smart-scroll-padding-bottom="50" style="margin-bottom: 0px">
-    <table class="log-viewer-table">
-      <thead>
-        <tr>
-          <th class="id" data-sort="id"></th>
-          <th class="timestamp" data-sort="timestamp">Timestamp</th>
-          <th class="node"  data-sort="node">Node</th>
-          <th class="slot"  data-sort="slot">Slot</th>
-          <th class="type" data-sort="type">Log type</th>
-          <th class="taskid"  data-sort="taskid">Task</th>
-          <th class="message" data-sort="message">Message</th>
-        </tr>
-      </thead>
-      <tbody class="list">
-        <tr>
-          <td class="id"></td>
-          <td class="timestamp"></td>
-          <td class="node"></td>
-          <td class="slot"></td>
-          <td class="type"></td>
-          <td class="taskid"></td>
-          <td class="message"></td>
-        </tr>
-      </tbody>
-    </table>
-
-    <% if @object.log and logcollection %>
-      <div class="spinner spinner-32px"></div>
-    <% end %>
-
-  </div>
-
-  <div class="log-viewer-paging-div" style="margin-bottom: -15px">
-    <a href="#" class="log-viewer-page-up"><span class='glyphicon glyphicon-arrow-up'></span></a>
-    <span class="log-viewer-paging"></span>
-    <a href="#" class="log-viewer-page-down"><span class='glyphicon glyphicon-arrow-down'></span></a>
-  </div>
-
-</div>
-
-<% end %>
diff --git a/apps/workbench/app/views/jobs/_show_object_description_cell.html.erb b/apps/workbench/app/views/jobs/_show_object_description_cell.html.erb
deleted file mode 100644 (file)
index cd58fc6..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="nowrap">
-  <div class="row">
-    <div class="col-sm-2 inline-progress-container">
-      <%= render partial: 'job_progress', locals: {j: object} %>
-    </div>
-    <div class="col-sm-10">
-      <%= object.script %>
-      <span class="deemphasize">
-        job
-        using <%= object.script_version %> commit
-        from <%= object.repository %> repository
-      </span>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/jobs/_show_provenance.html.erb b/apps/workbench/app/views/jobs/_show_provenance.html.erb
deleted file mode 100644 (file)
index fd6fba5..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'application/svg_div', locals: {
-      divId: "provenance_graph", 
-      svgId: "provenance_svg", 
-      svg: @svg } %>
diff --git a/apps/workbench/app/views/jobs/_show_recent.html.erb b/apps/workbench/app/views/jobs/_show_recent.html.erb
deleted file mode 100644 (file)
index 1dd0c82..0000000
+++ /dev/null
@@ -1,124 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :css do %>
-  table.topalign>tbody>tr>td {
-  vertical-align: top;
-  }
-  table.topalign>thead>tr>td {
-  vertical-align: bottom;
-  }
-<% end %>
-
-<%= render partial: "paging", locals: {results: objects, object: @object} %>
-
-<table class="topalign table">
-  <thead>
-    <tr class="contain-align-left">
-      <th>
-      </th><th>
-       status
-      </th><th>
-       uuid
-      </th><th>
-       script
-      </th><th>
-       version
-      </th><th>
-       output
-      </th>
-    </tr>
-  </thead>
-  <tbody>
-
-    <% @objects.sort_by { |j| j[:created_at] }.reverse.each do |j| %>
-
-    <tr class="cell-noborder">
-      <td>
-        <i class="icon-plus-sign expand-collapse-row" data-id="<%= j.uuid %>" style="cursor: pointer"></i>
-      </td>
-      <td>
-        <div class="inline-progress-container">
-          <%= render partial: 'job_progress', locals: {:j => j} %>
-        </div>
-      </td>
-      <td>
-        <%= link_to_if_arvados_object j %>
-      </td>
-      <td>
-        <%= j.script %>
-      </td>
-      <td>
-        <%= j.script_version.andand[0..8] %>
-      </td>
-      <td>
-        <%= link_to_if_arvados_object j.output %>
-      </td>
-    </tr>
-    <tr class="cell-noborder" id="<%= j.uuid %>" style="display:none">
-      <td colspan="7"><table class="table table-justforlayout"><tr>
-      <td style="border-left: 1px solid black">
-        <table class="table table-condensed">
-          <tr>
-            <td>
-              queued
-            </td>
-            <td>
-             &#x2709;&nbsp;<span title="<%= j.created_at %>"><%= raw distance_of_time_in_words(Time.now, j.created_at).sub('about ','~').sub(' ','&nbsp;') + '&nbsp;ago' if j.created_at %></span>
-            </td>
-            <td>
-             <%= raw('for&nbsp;' + distance_of_time_in_words(j.started_at, j.created_at).sub('about ','~').sub(' ','&nbsp;')) if j.created_at and j.started_at %>
-            </td>
-          </tr>
-          <% if j.started_at.is_a? Time %>
-          <tr>
-            <td>
-              started
-            </td>
-            <td>
-             &#x2708;&nbsp;<span title="<%= j.created_at %>"><%= raw distance_of_time_in_words(j.started_at, Time.now).sub('about ','~').sub(' ','&nbsp;') + '&nbsp;ago' if j.started_at %></span>
-            </td>
-            <td>
-              <% if j.finished_at.is_a? Time %>
-             <%= raw('ran&nbsp;' + distance_of_time_in_words(j.finished_at, j.started_at).sub('about ','~').sub(' ','&nbsp;')) %>
-              <% elsif j.state == "Running" %>
-              <span class="badge badge-success" title="tasks finished">&#x2714;&nbsp;<%= j.tasks_summary[:done] %></span>
-              <span class="badge badge-info" title="tasks running">&#x2708;&nbsp;<%= j.tasks_summary[:running] %></span>
-              <span class="badge" title="tasks todo">&#x2709;&nbsp;<%= j.tasks_summary[:todo] %></span>
-              <% if j.tasks_summary[:failed] %>
-              <span class="badge badge-warning" title="task failures">&#x2716;&nbsp;<%= j.tasks_summary[:failed] %></span>
-              <% end %>
-              <% end %>
-            </td>
-          </tr>
-          <% end %>
-        </table>
-      </td><td>
-        <table class="table table-condensed">
-          <tr><td colspan="2">
-              <%= j.script %> <%= j.script_version %>
-          </td></tr>
-          <% j.script_parameters.sort.each do |k,v| %>
-          <tr>
-            <td><%= k %></td><td><%= link_to_if_arvados_object v %></td>
-          </tr>
-          <% end %>
-          <tr>
-            <td>output</td><td><%= link_to_if_arvados_object j.output %></td>
-          </tr>
-        </table>
-      </td><td>
-        <table class="table table-condensed">
-        <% j.runtime_constraints.sort.each do |k,v| %>
-        <tr><td><%= v %></td><td><%= k %></td></tr>
-        <% end %>
-        </table>
-      </td>
-      </tr></table></td>
-    </tr>
-
-    <% end %>
-
-  </tbody>
-</table>
diff --git a/apps/workbench/app/views/jobs/_show_status.html.erb b/apps/workbench/app/views/jobs/_show_status.html.erb
deleted file mode 100644 (file)
index ced5b1e..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render(partial: 'work_units/show_status', locals: {current_obj: @object, name: @object[:name] || 'this job'}) %>
-
-<div class="panel panel-default">
-  <div class="panel-heading">
-    <span class="panel-title">Used in pipelines</span>
-  </div>
-  <div class="panel-body used-in-pipelines">
-    <% pi = PipelineInstance.order("created_at desc").filter([["components", "like", "%#{@object.uuid}%"]]) %>
-
-    <% pi.each do |pipeline| %>
-      <% pipeline.components.each do |k, v| %>
-        <% if v[:job] and v[:job][:uuid] == @object.uuid %>
-          <div>
-            <b><%= k %></b>
-            component of
-            <%= link_to_if_arvados_object pipeline, friendly_name: true %>
-            created at
-            <%= render_localized_date(pipeline.created_at) %>.
-          </div>
-        <% end %>
-      <% end %>
-    <% end %>
-  </div>
-
-  <div class="panel-heading">
-    <span class="panel-title">Used in jobs</span>
-  </div>
-
-  <% jobs = Job.order("created_at desc").filter([["components", "like", "%#{@object.uuid}%"]]).limit(10) %>
-  <%
-     too_many_message = ""
-     if jobs.items_available > jobs.results.size
-       too_many_message = (jobs.items_available - jobs.results.size).to_s + ' more jobs are not listed.'
-     end
-  %>
-  <div class="panel-body used-in-jobs">
-    <% if too_many_message != "" %>
-      <p><i><%= too_many_message %></i></p>
-    <% end %>
-    <% jobs.each do |j| %>
-      <% j.components.each do |k, v| %>
-        <% if v == @object.uuid %>
-          <div>
-            <b><%= k %></b>
-            component of
-            <%= link_to_if_arvados_object j, friendly_name: true %>
-            created at
-            <%= render_localized_date(j.created_at) %>.
-          </div>
-        <% end %>
-      <% end %>
-    <% end %>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/jobs/show.html.erb b/apps/workbench/app/views/jobs/show.html.erb
deleted file mode 100644 (file)
index 4ac7601..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :tab_line_buttons do %>
-  <div class="pane-loaded arv-log-event-listener arv-refresh-on-state-change"
-       data-pane-content-url="<%= url_for(params.permit!.merge(tab_pane: "job_buttons")) %>"
-       data-object-uuid="<%= @object.uuid %>"
-       style="display: inline">
-  <%= render partial: 'show_job_buttons', locals: {object: @object}%>
-  </div>
-<% end %>
-
-<%= render partial: 'title_and_buttons' %>
-<%= render partial: 'content', layout: 'content_layout', locals: {pane_list: controller.show_pane_list }%>
-<%= render partial: 'rerun_job_with_options_popup' %>
diff --git a/apps/workbench/app/views/keep_disks/_content_layout.html.erb b/apps/workbench/app/views/keep_disks/_content_layout.html.erb
deleted file mode 100644 (file)
index 06822e5..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% unless @histogram_pretty_date.nil? %>
-  <% content_for :tab_panes do %>
-  <script type="text/javascript">
-    $(document).ready(function(){
-      $.renderHistogram(<%= raw @cache_age_histogram.to_json %>);
-    });
-  </script>
-  <div class='graph'>
-    <h3>Cache Age vs. Disk Utilization</h3>
-    <h4>circa <%= @histogram_pretty_date %></h4>
-    <div id='cache-age-vs-disk-histogram'>
-    </div>
-  </div>
-  <% end %>
-<% end %>
-<%= content_for :content_top %>
-<div class="pull-right">
-  <%= content_for :tab_line_buttons %>
-</div>
-<%= content_for :tab_panes %>
diff --git a/apps/workbench/app/views/layouts/application.html.erb b/apps/workbench/app/views/layouts/application.html.erb
deleted file mode 100644 (file)
index 93ce592..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<!DOCTYPE html>
-<html lang="en" ng-app="Workbench">
-<head>
-  <meta charset="utf-8">
-  <title>
-    <% if content_for? :page_title %>
-    <%= yield :page_title %> / <%= Rails.configuration.Workbench.SiteName %>
-    <% else %>
-    <%= Rails.configuration.Workbench.SiteName %>
-    <% end %>
-  </title>
-  <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  <link rel="icon" href="/favicon.ico" type="image/x-icon">
-  <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
-  <meta name="description" content="">
-  <meta name="author" content="">
-  <% if current_user %>
-    <% content_for :js do %>
-      window.defaultSession = <%=raw({baseURL: Rails.configuration.Services.Controller.ExternalURL.to_s.sub(/\/*$/,'/'), token: Thread.current[:arvados_api_token], user: current_user}.to_json)%>
-    <% end %>
-  <% end %>
-  <% if current_user and $arvados_api_client.discovery[:websocketUrl] %>
-  <meta name="arv-websocket-url" content="<%=$arvados_api_client.discovery[:websocketUrl]%>?api_token=<%=Thread.current[:arvados_api_token]%>">
-  <% end %>
-  <meta name="robots" content="NOINDEX, NOFOLLOW">
-
-  <%# Feature #5645: Add open graph meta tags to generate this page's
-      social graph that search engines can use. http://ogp.me/ %>
-  <meta property="og:type" content="article" />
-  <meta property="og:url" content="<%= request.url %>" />
-  <meta property="og:site_name" content="<%= Rails.configuration.Workbench.SiteName %>" />
-  <% if defined?(@object) && @object %>
-    <% if @object.respond_to?(:name) and @object.name.present? %>
-      <meta property="og:title" content="<%= @object.name%>" />
-    <% end %>
-    <% if (@object.respond_to?(:description) rescue nil) and @object.description.present? %>
-      <meta property="og:description" content="<%= @object.description%>" />
-    <% end %>
-  <% end %>
-  <%# Done adding open graph meta tags %>
-
-  <%= stylesheet_link_tag    "application", :media => "all" %>
-  <%= javascript_include_tag "application" %>
-  <%= csrf_meta_tags %>
-  <%= yield :head %>
-  <%= javascript_tag do %>
-    angular.module('Arvados').value('arvadosApiToken', '<%=Thread.current[:arvados_api_token]%>');
-    angular.module('Arvados').value('arvadosDiscoveryUri', '<%= Rails.configuration.Services.Controller.ExternalURL.to_s.sub(/\/*$/,'/') + 'discovery/v1/apis/arvados/v1/rest' %>');
-  <%= yield :js %>
-  <% end %>
-  <style>
-    <%= yield :css %>
-    body {
-    min-height: 100%;
-    height: 100%;
-    }
-
-    @media (max-width: 979px) { body { padding-top: 0; } }
-
-    @media (max-width: 767px) {
-      .breadcrumbs {
-        padding-top: 0;
-      }
-    }
-  </style>
-  <link href="//netdna.bootstrapcdn.com/font-awesome/4.1.0/css/font-awesome.css" rel="stylesheet">
-  <%= piwik_tracking_tag if (PiwikAnalytics.configuration.url != 'localhost' rescue false) %>
-</head>
-<body>
-<%= render template: 'layouts/body' %>
-<%= javascript_tag do %>
-<%= yield :footer_js %>
-<% end %>
-</body>
-</html>
diff --git a/apps/workbench/app/views/layouts/body.html.erb b/apps/workbench/app/views/layouts/body.html.erb
deleted file mode 100644 (file)
index ed19d51..0000000
+++ /dev/null
@@ -1,273 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-  <div id="wrapper" class="container-fluid">
-    <nav class="navbar navbar-default navbar-fixed-top" role="navigation">
-      <div class="navbar-header">
-        <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
-          <span class="sr-only">Toggle navigation</span>
-          <span class="icon-bar"></span>
-          <span class="icon-bar"></span>
-          <span class="icon-bar"></span>
-        </button>
-        <% site_name = Rails.configuration.Workbench.SiteName.downcase rescue Rails.application.class.parent_name %>
-        <% if current_user %>
-          <a class="navbar-brand" href="/" data-push=true><%= site_name %></a>
-        <% else %>
-          <span class="navbar-brand"><%= site_name %></span>
-        <% end %>
-      </div>
-
-      <div class="collapse navbar-collapse">
-        <ul class="nav navbar-nav navbar-right">
-
-          <li>
-            <a><i class="rotating loading glyphicon glyphicon-refresh"></i></a>
-          </li>
-
-          <% if current_user %>
-            <% if current_user.is_active %>
-              <% if !Rails.configuration.Workbench.MultiSiteSearch.empty? %>
-                <li>
-                  <form class="navbar-form">
-                    <%=
-                       target = Rails.configuration.Workbench.MultiSiteSearch
-                       if target == "true"
-                         target = {controller: 'search', action: 'index'}
-                       end
-                       link_to("Multi-site search", target, {class: 'btn btn-default'}) %>
-                  </form>
-                </li>
-              <% end %>
-              <li>
-                <form class="navbar-form" role="search"
-                           data-search-modal=
-                           "<%= url_for(
-                            action: 'choose',
-                            controller: 'search',
-                            title: 'Search',
-                            action_name: 'Show',
-                            action_href: url_for(controller: :actions, action: :show),
-                            action_method: 'get',
-                            action_data: {selection_param: 'uuid', success: 'redirect-to-created-object'}.to_json)
-                           %>">
-                  <div class="input-group" style="width: 220px">
-                    <input type="text" class="form-control" placeholder="search this site">
-                    <a class="input-group-addon"><span class="glyphicon glyphicon-search"></span></a>
-                  </div>
-                </form>
-              </li>
-            <% end %>
-
-            <li class="dropdown notification-menu">
-              <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="notifications-menu">
-                <span class="badge badge-alert notification-count"><%= user_notifications.length if user_notifications.any? %></span>
-                <span class="fa fa-lg fa-user"></span>
-                <span class="caret"></span>
-              </a>
-              <ul class="dropdown-menu" role="menu">
-                <li role="presentation" class="dropdown-header">
-                  <%= current_user.email %>
-                </li>
-                <% if current_user.is_active %>
-                <li role="menuitem"><a href="/projects/<%=current_user.uuid%>" role="menuitem"><i class="fa fa-lg fa-home fa-fw"></i> Home project </a></li>
-                  <% if Rails.configuration.Services.Composer.ExternalURL != URI("") %>
-                    <li role="menuitem">
-                     <form action="<%= Rails.configuration.Services.Composer.ExternalURL.to_s %>" method="GET">
-                       <input type="hidden" name="api_token" value="<%= Thread.current[:arvados_api_token] %>" />
-                       <button role="menuitem" type="submit">
-                         <i class="fa fa-lg fa-share-alt fa-fw"></i> Workflow Composer
-                       </button>
-                     </form>
-                    </li>
-                  <% end %>
-                <% if Rails.configuration.Services.Workbench2.ExternalURL != URI("") %>
-                <li role="menuitem">
-                  <%
-                    wb2_url = Rails.configuration.Services.Workbench2.ExternalURL.to_s
-                    wb2_url += '/' if wb2_url[-1] != '/'
-                    wb2_url += 'token'
-                  %>
-                  <form action="<%= wb2_url %>" method="GET">
-                    <input type="hidden" name="api_token" value="<%= Thread.current[:arvados_api_token] %>">
-                    <button role="menuitem" type="submit">
-                      <i class="fa fa-lg fa-share-square fa-fw"></i> Go to Workbench 2
-                    </button>
-                  </form>
-                </li>
-                <% end %>
-                <li role="menuitem">
-                  <%= link_to virtual_machines_user_path(current_user), role: 'menu-item' do %>
-                    <i class="fa fa-lg fa-terminal fa-fw"></i> Virtual machines
-                  <% end %>
-                </li>
-                <% if Rails.configuration.Workbench.Repositories %>
-                <li role="menuitem"><a href="/repositories" role="menuitem"><i class="fa fa-lg fa-code-fork fa-fw"></i> Repositories </a></li>
-                <% end -%>
-                <li role="menuitem"><a href="/current_token" role="menuitem"><i class="fa fa-lg fa-ticket fa-fw"></i> Current token</a></li>
-                <li role="menuitem">
-                  <%= link_to ssh_keys_user_path(current_user), role: 'menu-item' do %>
-                    <i class="fa fa-lg fa-key fa-fw"></i> SSH keys
-                  <% end %>
-</li>
-                <li role="menuitem"><a href="/users/link_account" role="menuitem"><i class="fa fa-lg fa-link fa-fw"></i> Link account </a></li>
-                <% if !Rails.configuration.Workbench.UserProfileFormFields.empty? %>
-                  <li role="menuitem"><a href="/users/<%=current_user.uuid%>/profile" role="menuitem"><i class="fa fa-lg fa-user fa-fw"></i> Manage profile</a></li>
-                <% end %>
-                <% end %>
-                <li role="presentation" class="divider"></li>
-                <li role="menuitem"><a href="<%= logout_path %>" role="menuitem"><i class="fa fa-lg fa-sign-out fa-fw"></i> Log out</a></li>
-                <% if user_notifications.any? %>
-                  <li role="presentation" class="divider"></li>
-                  <% user_notifications.each_with_index do |n, i| %>
-                    <% if i > 0 %><li class="divider"></li><% end %>
-                    <li class="notification"><%= n.call(self) %></li>
-                  <% end %>
-                <% end %>
-              </ul>
-            </li>
-
-            <% if current_user.is_admin %>
-              <li class="dropdown">
-                <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="system-menu">
-                  <span class="fa fa-lg fa-gear"></span>
-                  <span class="caret"></span>
-                </a>
-                <ul class="dropdown-menu" role="menu">
-                  <li role="presentation" class="dropdown-header">
-                    Admin Settings
-                  </li>
-                  <% if Rails.configuration.Workbench.Repositories %>
-                  <li role="menuitem"><a href="/repositories">
-                      <i class="fa fa-lg fa-code-fork fa-fw"></i> Repositories
-                  </a></li>
-                  <% end -%>
-                  <li role="menuitem"><a href="/virtual_machines">
-                      <i class="fa fa-lg fa-terminal fa-fw"></i> Virtual machines
-                  </a></li>
-                  <li role="menuitem"><a href="/authorized_keys">
-                      <i class="fa fa-lg fa-key fa-fw"></i> SSH keys
-                  </a></li>
-                  <li role="menuitem"><a href="/api_client_authorizations">
-                      <i class="fa fa-lg fa-ticket fa-fw"></i> API tokens
-                  </a></li>
-                  <li role="menuitem"><a href="/links">
-                      <i class="fa fa-lg fa-arrows-h fa-fw"></i> Links
-                  </a></li>
-                  <li role="menuitem"><a href="/users">
-                      <i class="fa fa-lg fa-user fa-fw"></i> Users
-                  </a></li>
-                  <li role="menuitem"><a href="/groups">
-                      <i class="fa fa-lg fa-users fa-fw"></i> Groups
-                  </a></li>
-                  <li role="menuitem"><a href="/keep_services">
-                      <i class="fa fa-lg fa-exchange fa-fw"></i> Keep services
-                  </a></li>
-                </ul>
-              </li>
-            <% end %>
-          <% else %>
-            <% if !Rails.configuration.Users.AnonymousUserToken.empty? and Rails.configuration.Workbench.EnablePublicProjectsPage %>
-              <li><%= link_to 'Browse public projects', "/projects/public" %></li>
-            <% end %>
-            <li class="dropdown hover-dropdown login-menu">
-              <a href="<%= arvados_api_client.arvados_login_url(return_to: request.url) %>">Log in</a>
-              <ul class="dropdown-menu">
-                <li>
-                  <a href="<%= arvados_api_client.arvados_login_url(return_to: request.url) %>">
-                    <span class="fa fa-lg fa-sign-in"></span>
-                    <p style="margin-left: 1.6em; margin-top: -1.35em; margin-bottom: 0em; margin-right: 0.5em;">Log in or register with<br/>any Google account</p>
-                  </a>
-                </li>
-              </ul>
-            </li>
-          <% end %>
-
-          <li class="dropdown help-menu">
-            <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="arv-help">
-              <span class="fa fa-lg fa-question-circle"></span>
-              <span class="caret"></span>
-            </a>
-            <ul class="dropdown-menu">
-              <li role="presentation" class="dropdown-header">
-                Help
-              </li>
-              <% if Rails.configuration.Workbench.EnableGettingStartedPopup %>
-                <li>
-                <%= link_to raw('<i class="fa fa-fw fa-info"></i> Getting Started ...'), "#",
-                     {'data-toggle' => "modal", 'data-target' => '#getting-started-modal-window'}  %>
-                </li>
-              <% end %>
-              <% if !Rails.configuration.Workbench.ArvadosPublicDataDocURL.empty? %>
-                <li><%= link_to raw('<i class="fa fa-book fa-fw"></i> Public Pipelines and Data sets'), "#{Rails.configuration.Workbench.ArvadosPublicDataDocURL}", target: "_blank" %></li>
-              <% end %>
-              <li><%= link_to raw('<i class="fa fa-book fa-fw"></i> Tutorials and User guide'), "#{Rails.configuration.Workbench.ArvadosDocsite}/user", target: "_blank" %></li>
-              <li><%= link_to raw('<i class="fa fa-book fa-fw"></i> API Reference'), "#{Rails.configuration.Workbench.ArvadosDocsite}/api", target: "_blank" %></li>
-              <li><%= link_to raw('<i class="fa fa-book fa-fw"></i> SDK Reference'), "#{Rails.configuration.Workbench.ArvadosDocsite}/sdk", target: "_blank" %></li>
-              <li role="presentation" class="divider"></li>
-              <li> <%= link_to report_issue_popup_path(popup_type: 'version', current_location: request.url, current_path: request.fullpath, action_method: 'post'),
-                      {class: 'report-issue-modal-window', remote: true, return_to: request.url} do %>
-                       <i class="fa fa-fw fa-support"></i> Show version / debugging info ...
-                      <% end %>
-              </li>
-              <li> <%= link_to report_issue_popup_path(popup_type: 'report', current_location: request.url, current_path: request.fullpath, action_method: 'post'),
-                      {class: 'report-issue-modal-window', remote: true, return_to: request.url} do %>
-                       <i class="fa fa-fw fa-support"></i> Report a problem ...
-                      <% end %>
-              </li>
-            </ul>
-          </li>
-        </ul>
-      </div><!-- /.navbar-collapse -->
-    </nav>
-
-    <% if current_user.andand.is_active %>
-      <%= render partial: 'breadcrumbs' %>
-    <% elsif !current_user %>   <%# anonymous %>
-      <% if (@name_link or @object) and (project_breadcrumbs.any?) %>
-        <nav class="navbar navbar-default breadcrumbs" role="navigation">
-          <ul class="nav navbar-nav navbar-left">
-            <li>
-              <a href="/projects/public">Public Projects</a>
-            </li>
-            <% project_breadcrumbs.each do |p| %>
-              <li class="nav-separator">
-                <i class="fa fa-lg fa-angle-double-right"></i>
-              </li>
-              <li>
-                <%= link_to(p.name, project_path(p.uuid), data: {object_uuid: p.uuid, name: 'name'}) %>
-              </li>
-            <% end %>
-          </ul>
-        </nav>
-      <% end %>
-    <% end %>
-
-    <%= render partial: 'browser_unsupported' %><%# requires JS support below %>
-    <%= render partial: 'getting_started/getting_started_popup' %>
-
-    <div id="page-wrapper">
-      <%= yield %>
-    </div>
-  </div>
-
-  <%= yield :footer_html %>
-
-<div class="modal-container"></div>
-<div id="report-issue-modal-window"></div>
-<script src="/browser_unsupported.js"></script>
-
-<%  if Rails.configuration.Workbench.EnableGettingStartedPopup and current_user and !current_user.prefs[:getting_started_shown] and
-       !request.url.include?("/profile") and
-       !request.url.include?("/user_agreements") and
-       !request.url.include?("/inactive")%>
-  <script>
-    $("#getting-started-modal-window").modal('show');
-  </script>
-  <%
-    prefs = current_user.prefs
-    prefs[:getting_started_shown] = Time.now
-    current_user.update_attributes prefs: prefs.to_json
-  %>
-<% end %>
diff --git a/apps/workbench/app/views/links/_breadcrumb_page_name.html.erb b/apps/workbench/app/views/links/_breadcrumb_page_name.html.erb
deleted file mode 100644 (file)
index 4043908..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @object %>
-(<%= @object.link_class %>)
-<%= @object.name %>:
-<%= @object.tail_kind.andand.sub 'arvados#', '' %>
-&rarr;
-<%= @object.head_kind.andand.sub 'arvados#', '' %>
-<% end %>
-
diff --git a/apps/workbench/app/views/notifications/_collections_notification.html.erb b/apps/workbench/app/views/notifications/_collections_notification.html.erb
deleted file mode 100644 (file)
index 7769046..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-  <%= image_tag "dax.png", class: "dax" %>
-  <p>
-    Hi, I noticed you haven't uploaded a new collection yet.
-    <%= link_to "Click here to learn how to upload data to Arvados Keep.",
-       "#{Rails.configuration.Workbench.ArvadosDocsite}/user/tutorials/tutorial-keep.html",
-       style: "font-weight: bold", target: "_blank" %>
-  </p>
diff --git a/apps/workbench/app/views/notifications/_jobs_notification.html.erb b/apps/workbench/app/views/notifications/_jobs_notification.html.erb
deleted file mode 100644 (file)
index d793ea0..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-  <p><%= image_tag "dax.png", class: "dax" %>
-    Hi, I noticed you haven't run a job yet.
-    <%= link_to "Click here to learn how to run an Arvados Crunch job.",
-       "#{Rails.configuration.Workbench.ArvadosDocsite}/user/tutorials/tutorial-job1.html",
-       style: "font-weight: bold",
-       target: "_blank" %>
-  </p>
diff --git a/apps/workbench/app/views/notifications/_pipelines_notification.html.erb b/apps/workbench/app/views/notifications/_pipelines_notification.html.erb
deleted file mode 100644 (file)
index b275ed8..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-  <p><%= image_tag "dax.png", class: "dax" %>
-    Hi, I noticed you haven't run a pipeline yet.
-    <%= link_to "Click here to learn how to run an Arvados Crunch pipeline.",
-       "#{Rails.configuration.Workbench.ArvadosDocsite}/user/tutorials/tutorial-pipeline-workbench.html",
-       style: "font-weight: bold",
-       target: "_blank" %>
-  </p>
diff --git a/apps/workbench/app/views/notifications/_ssh_key_notification.html.erb b/apps/workbench/app/views/notifications/_ssh_key_notification.html.erb
deleted file mode 100644 (file)
index a17a451..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-   <%= image_tag "dax.png", class: "dax" %>
-    <div>
-      Hi, I noticed that you have not yet set up an SSH public key for use with Arvados.
-      <%= link_to ssh_keys_user_path(current_user) do %>
-        <b>Click here to set up an SSH public key for use with Arvados.</b>
-      <%end%>
-    </div>
diff --git a/apps/workbench/app/views/pipeline_instances/_component_labels.html.erb b/apps/workbench/app/views/pipeline_instances/_component_labels.html.erb
deleted file mode 100644 (file)
index 73154b4..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% pipeline_jobs(object).each do |pj| %>
-  <span class="label label-<%= pj[:labeltype] %>">
-    <%= pj[:name] %>
-  </span>&nbsp;
-<% end %>
diff --git a/apps/workbench/app/views/pipeline_instances/_running_component.html.erb b/apps/workbench/app/views/pipeline_instances/_running_component.html.erb
deleted file mode 100644 (file)
index 6e8785a..0000000
+++ /dev/null
@@ -1,204 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% current_job = pj[:job] if pj[:job] != {} and pj[:job][:uuid] %>
-<div class="panel panel-default">
-  <div class="panel-heading">
-    <div class="container-fluid">
-      <div class="row-fluid">
-        <%# column offset 0 %>
-        <div class="col-md-2" style="word-break:break-all;">
-          <h4 class="panel-title">
-            <a data-toggle="collapse" href="#collapse<%= i %>">
-              <%= pj[:name] %> <span class="caret"></span>
-            </a>
-          </h4>
-        </div>
-
-        <%# column offset 2 %>
-        <div class="col-md-2 pipeline-instance-spacing">
-          <%= pj[:progress_bar] %>
-        </div>
-
-        <%# column offset 4 %>
-        <% if not current_job %>
-          <div class="col-md-8"></div>
-        <% else %>
-          <div class="col-md-1">
-            <% if (pipeline_display rescue nil) %>
-              <% if current_job[:state].in? ["Complete", "Failed", "Cancelled"] %>
-                <% if current_job[:log] %>
-                  <% logCollection = Collection.find? current_job[:log] %>
-                  <% if logCollection %>
-                    <%= link_to "Log", job_path(current_job[:uuid], anchor: "Log") %>
-                  <% else %>
-                    Log unavailable
-                  <% end %>
-                <% end %>
-              <% elsif current_job[:state] == "Running" %>
-                <% job = Job.find? current_job[:uuid] %>
-                <% if job %>
-                  <%= link_to "Log", job_path(current_job[:uuid], anchor: "Log") %>
-                <% else %>
-                  Log unavailable
-                <% end %>
-              <% end %>
-            <% end %>
-          </div>
-
-          <%# column offset 5 %>
-          <% if current_job[:state] != "Queued" %>
-          <div class="col-md-3">
-            <% if current_job[:started_at] %>
-              <% walltime = ((if current_job[:finished_at] then current_job[:finished_at] else Time.now() end) - current_job[:started_at]) %>
-              <% cputime = (current_job[:runtime_constraints].andand[:min_nodes] || 1).to_i *
-                           ((current_job[:finished_at] || Time.now()) - current_job[:started_at]) %>
-              <%= render_runtime(walltime, false) %>
-              <% if cputime > 0 %> / <%= render_runtime(cputime, false) %> (<%= (cputime/walltime).round(1) %>&Cross;)<% end %>
-            <% end %>
-          </div>
-          <% end %>
-
-          <% if current_job[:state] == "Queued" %>
-            <%# column offset 5 %>
-            <div class="col-md-6">
-              <% queuetime = Time.now - Time.parse(current_job[:created_at].to_s) %>
-              Queued for <%= render_runtime(queuetime, false) %>.
-            </div>
-          <% elsif current_job[:state] == "Running" %>
-            <%# column offset 8 %>
-            <div class="col-md-3">
-              <span class="task-summary-status">
-                <%= current_job[:tasks_summary][:done] %>&nbsp;<%= "task".pluralize(current_job[:tasks_summary][:done]) %> done,
-                <%= current_job[:tasks_summary][:failed] %>&nbsp;failed,
-                <%= current_job[:tasks_summary][:running] %>&nbsp;running,
-                <%= current_job[:tasks_summary][:todo] %>&nbsp;pending
-              </span>
-            </div>
-          <% elsif current_job[:state].in? ["Complete", "Failed", "Cancelled"] %>
-            <%# column offset 8 %>
-            <div class="col-md-4 text-overflow-ellipsis">
-              <% if pj[:output_uuid] %>
-                <%= link_to_arvados_object_if_readable(pj[:output_uuid], "#{pj[:output_uuid]} (Unavailable)", friendly_name: true) %>
-              <% elsif current_job[:output] %>
-                <%= link_to_arvados_object_if_readable(current_job[:output], "#{current_job[:output]} (Unavailable)", link_text: "Output of #{pj[:name]}") %>
-              <% else %>
-                No output.
-              <% end %>
-            </div>
-          <% end %>
-
-          <% if current_job[:state].in? ["Queued", "Running"] and @object.editable? %>
-            <%# column offset 11 %>
-            <div class="col-md-1 pipeline-instance-spacing">
-              <%= form_tag "/jobs/#{current_job[:uuid]}/cancel", remote: true, style: "display:inline; padding-left: 1em" do |f| %>
-                <%= hidden_field_tag :return_to, url_for(@object) %>
-                <%= button_tag "Cancel", {class: 'btn btn-xs btn-danger', id: "cancel-job-button"} %>
-              <% end %>
-            </div>
-          <% end %>
-        <% end %>
-      </div>
-    </div>
-  </div>
-
-  <div id="collapse<%= i %>" class="panel-collapse collapse <%= if expanded then 'in' end %>">
-    <div class="panel-body">
-      <div class="container">
-        <% current_component = (if current_job then current_job else pj end) %>
-        <div class="row">
-          <div class="col-md-6">
-            <table>
-              <% # link to repo tree/file only if the repo is readable
-                 # and the commit is a sha1...
-                 repo =
-                 (/^[0-9a-f]{40}$/ =~ current_component[:script_version] and
-                 Repository.where(name: current_component[:repository]).first)
-
-                 # ...and the api server provides an http:// or https:// url
-                 repo = nil unless repo.andand.http_fetch_url
-                 %>
-              <% [:script, :repository, :script_version, :supplied_script_version, :nondeterministic].each do |k| %>
-                <tr>
-                  <td style="padding-right: 1em">
-                    <%= k.to_s %>:
-                  </td>
-                  <td>
-                    <% if current_component[k].nil? %>
-                      (none)
-                    <% elsif repo and k == :repository %>
-                      <%= link_to current_component[k], show_repository_tree_path(id: repo.uuid, commit: current_component[:script_version], path: '/') %>
-                    <% elsif repo and k == :script %>
-                      <%= link_to current_component[k], show_repository_blob_path(id: repo.uuid, commit: current_component[:script_version], path: 'crunch_scripts/'+current_component[:script]) %>
-                    <% elsif repo and k == :script_version %>
-                      <%= link_to current_component[k], show_repository_commit_path(id: repo.uuid, commit: current_component[:script_version]) %>
-                    <% else %>
-                      <%= current_component[k] %>
-                    <% end %>
-                  </td>
-                </tr>
-              <% end %>
-              <% if current_component[:runtime_constraints].andand[:docker_image] and current_component[:docker_image_locator] %>
-                <tr>
-                  <td style="padding-right: 1em">
-                    docker_image:
-                  </td>
-                  <td>
-                    <%= current_component[:runtime_constraints][:docker_image] %>
-                  </td>
-                </tr>
-                <tr>
-                  <td style="padding-right: 1em">
-                    docker_image_locator:
-                  </td>
-                  <td>
-                    <%= link_to_arvados_object_if_readable(current_component[:docker_image_locator],
-                      current_component[:docker_image_locator], friendly_name: true) %>
-                  </td>
-                </tr>
-              <% else %>
-                <tr>
-                  <td style="padding-right: 1em">
-                    docker_image:
-                  </td>
-                  <td>
-                    Not run in Docker
-                  </td>
-                </tr>
-              <% end %>
-            </table>
-          </div>
-          <div class="col-md-5">
-            <table>
-              <% [:uuid, :modified_by_user_uuid, :priority, :created_at, :started_at, :finished_at].each do |k| %>
-                <tr>
-                  <td style="padding-right: 1em">
-                    <%= k.to_s %>:
-                  </td>
-                  <td>
-                    <% if k == :uuid %>
-                      <%= link_to_arvados_object_if_readable(current_component[k], current_component[k], link_text: current_component[k]) %>
-                    <% elsif k.to_s.end_with? 'uuid' %>
-                      <%= link_to_arvados_object_if_readable(current_component[k], current_component[k], friendly_name: true) %>
-                    <% elsif k.to_s.end_with? '_at' %>
-                      <%= render_localized_date(current_component[k]) %>
-                    <% else %>
-                      <%= current_component[k] %>
-                    <% end %>
-                  </td>
-                </tr>
-              <% end %>
-            </table>
-          </div>
-        </div>
-        <div class="row">
-          <div class="col-md-12">
-            <p>script_parameters:</p>
-            <pre><%= JSON.pretty_generate(current_component[:script_parameters]) rescue nil %></pre>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_compare.html.erb b/apps/workbench/app/views/pipeline_instances/_show_compare.html.erb
deleted file mode 100644 (file)
index e730257..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% pi_span = [(10.0/[@objects.count,1].max).floor,1].max %>
-
-<div class="headrow pipeline-compare-headrow">
-  <div class="row">
-  <div class="col-sm-2">
-    <%# label %>
-  </div>
-  <% @objects.each do |object| %>
-  <div class="col-sm-<%= pi_span %>" style="overflow-x: hidden; text-overflow: ellipsis;">
-    <%= render :partial => "show_object_button", :locals => {object: object, size: 'sm' } %>
-    <%= object.name || "unnamed #{object.class_for_display.downcase}" %>
-    <br />
-    <span class="deemphasize">Template:</span> <%= link_to_if_arvados_object object.pipeline_template_uuid, friendly_name: true %>
-  </div>
-  <% end %>
-  </div>
-</div>
-
-<% @rows.each do |row| %>
-<div class="row pipeline-compare-row">
-  <div class="col-sm-2">
-    <%= row[:name] %>
-  </div>
-  <% @objects.each_with_index do |_, x| %>
-    <div class="col-sm-<%= pi_span %>">
-      <div class="row">
-        <div class="col-sm-12">
-
-        <% if row[:components][x] %>
-          <% pj = render_pipeline_job row[:components][x] %>
-
-          <%= link_to_if_arvados_object pj[:job_id], {friendly_name: true, with_class_name: true}, {class: 'deemphasize'} %>
-          <br />
-
-          <% %w(script script_version script_parameters output).each do |key| %>
-              <% unless key=='output' and pj[:result] != 'complete' %>
-              <% val = pj[key.to_sym] || pj[:job].andand[key.to_sym] %>
-              <% link_name = case
-                 when !val
-                   val = ''
-                 when key == 'script_version' && val.match(/^[0-9a-f]{7,}$/)
-                   val = val[0..7] # TODO: leave val alone, make link_to handle git commits
-                 when key == 'output'
-                   val.sub! /\+K.*$/, ''
-                   val[0..12]
-                 when key == 'script_parameters'
-                   val = val.keys.sort.join(', ')
-                 end
-                 %>
-              <span class="deemphasize"><%= key %>:</span>&nbsp;<span class="<%= 'notnormal' if !pj[:is_normal][key.to_sym] %>"><%= link_to_if_arvados_object val, {friendly_name: true, link_text: link_name} %></span>
-              <% end %>
-            <br />
-          <% end %>
-          <% else %>
-          None
-        <% end %>
-        </div>
-      </div>
-    </div>
-  <% end %>
-</div>
-<div class="row" style="padding: .5em">
-</div>
-<% end %>
-
-
diff --git a/apps/workbench/app/views/pipeline_instances/_show_components.html.erb b/apps/workbench/app/views/pipeline_instances/_show_components.html.erb
deleted file mode 100644 (file)
index 3fca07a..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if !@object.state.in? ['New', 'Ready'] %>
-
-  <%
-     job_uuids = @object.components.map { |k,j| j.is_a? Hash and j[:job].andand[:uuid] }.compact
-     throttle = 86486400000 # 1001 nights
-     %>
-  <div class="arv-log-refresh-control"
-       data-load-throttle="<%= throttle %>"
-       data-object-uuids="<%= @object.uuid %> <%= job_uuids.join(' ') %>"
-       ></div>
-
-  <%= render partial: 'work_units/show_component', locals: {wu: @object.work_unit(@object.name)} %>
-
-<% else %>
-  <%# state is either New or Ready %>
-  <%= render_unreadable_inputs_present %>
-
-  <p><i>Here are all of the pipeline's components (jobs that will need to run in order to complete the pipeline). If you know what you're doing (or you're experimenting) you can modify these parameters before starting the pipeline. Usually, you only need to edit the settings presented on the "Inputs" tab above.</i></p>
-
-  <%= render_pipeline_components("editable", :json, editable: true) %>
-<% end %>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_components_editable.html.erb b/apps/workbench/app/views/pipeline_instances/_show_components_editable.html.erb
deleted file mode 100644 (file)
index 5311925..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<table class="table pipeline-components-table" style="margin-top: -.1em">
-  <colgroup>
-    <col style="width: 20%" />
-    <col style="width: 20%" />
-    <col style="width: 20%" />
-    <col style="width: 40%" />
-  </colgroup>
-
-  <thead>
-    <tr>
-      <th>
-        component
-      </th><th>
-        script
-      </th><th>
-        parameter
-      </th><th>
-        value
-      </th>
-    </tr>
-  </thead>
-  <tbody>
-    <% @object.components.each do |k, component| %>
-      <% next if !component %>
-      <tr>
-        <td><%= k %></td>
-
-        <td><%= component[:script] %></td>
-
-        <td>script version</td>
-
-        <td>
-          <%= render_pipeline_component_attribute (editable && @object), :components, [k, :script_version], component[:script_version] %>
-        </td>
-      </tr>
-
-      <% component[:script_parameters].andand.each do |p, tv| %>
-        <tr>
-          <td style="border-top: none"></td>
-          <td style="border-top: none"></td>
-
-          <td class="property-edit-row"><%= p %></td>
-          <td class="property-edit-row"><%= render_pipeline_component_attribute (editable && @object), :components, [k, :script_parameters, p.to_sym], tv %></td>
-        </tr>
-      <% end %>
-    <% end %>
-  </tbody>
-</table>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_components_json.html.erb b/apps/workbench/app/views/pipeline_instances/_show_components_json.html.erb
deleted file mode 100644 (file)
index 4fdc8fb..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<p>The components of this pipeline are in a format that Workbench does not recognize.</p>
-
-<p>Error encountered: <b><%= error_name %></b></p>
-
-    <div id="components-accordion" class="panel panel-default">
-      <div class="panel-heading">
-        <h4 class="panel-title">
-          <a data-toggle="collapse" data-parent="#components-accordion" href="#components-json">
-            Show components JSON
-          </a>
-        </h4>
-      </div>
-      <div id="components-json" class="panel-collapse collapse">
-        <div class="panel-body">
-          <pre><%= Oj.dump(@object.components, indent: 2) %></pre>
-        </div>
-      </div>
-      <% if backtrace %>
-      <div class="panel-heading">
-        <h4 class="panel-title">
-          <a data-toggle="collapse" data-parent="#components-accordion" href="#components-backtrace">
-            Show backtrace
-          </a>
-        </h4>
-      </div>
-      <div id="components-backtrace" class="panel-collapse collapse">
-        <div class="panel-body">
-          <pre><%= backtrace %></pre>
-        </div>
-      </div>
-      <% end %>
-    </div>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_components_running.html.erb b/apps/workbench/app/views/pipeline_instances/_show_components_running.html.erb
deleted file mode 100644 (file)
index 60d4c2a..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%# Summary %>
-
-<div class="pull-right" style="padding-left: 1em">
-  Current state: <span class="badge badge-info" data-pipeline-state="<%= @object.state %>">
-    <% if @object.state == "RunningOnServer" %>
-      Active
-    <% else %>
-      <%= @object.state %>
-    <% end %>
-  </span>&nbsp;
-</div>
-
-<% pipeline_jobs = render_pipeline_jobs %>
-<% job_uuids = pipeline_jobs.map { |j| j[:job].andand[:uuid] }.compact %>
-
-<% if @object.state == 'Paused' %>
-  <p>
-    This pipeline is paused.  Jobs that are
-    already running will continue to run, but no new jobs will be submitted.
-  </p>
-<% end %>
-
-<% runningtime = determine_wallclock_runtime(pipeline_jobs.map {|j| j[:job]}.compact) %>
-
-<p>
-  <% if @object.started_at %>
-    This pipeline started at <%= render_localized_date(@object.started_at) %>.
-    It
-    <% if @object.state == 'Complete' %>
-      completed in
-    <% elsif @object.state == 'Failed' %>
-      failed after
-    <% elsif @object.state == 'Cancelled' %>
-      was cancelled after
-    <% else %>
-      has been active for
-    <% end %>
-
-    <% walltime = if @object.finished_at then
-                    @object.finished_at - @object.started_at
-                  else
-                    Time.now - @object.started_at
-                  end %>
-
-    <%= if walltime > runningtime
-          render_runtime(walltime, false)
-        else
-          render_runtime(runningtime, false)
-        end %><% if @object.finished_at %> at <%= render_localized_date(@object.finished_at) %><% end %>.
-    <% else %>
-      This pipeline is <%= if @object.state.start_with? 'Running' then 'active' else @object.state.downcase end %>.
-        <% walltime = 0%>
-    <% end %>
-
-  <% if @object.state == 'Failed' %>
-    Check the Log tab for more detail about why this pipeline failed.
-  <% end %>
-</p>
-
-<p>
-    This pipeline
-    <% if @object.state.start_with? 'Running' %>
-      has run
-    <% else %>
-      ran
-    <% end %>
-    for
-    <%
-        cputime = pipeline_jobs.map { |j|
-        if j[:job][:started_at]
-          (j[:job][:runtime_constraints].andand[:min_nodes] || 1).to_i * ((j[:job][:finished_at] || Time.now()) - j[:job][:started_at])
-        else
-          0
-        end
-       }.reduce(:+) || 0 %>
-    <%= render_runtime(runningtime, false) %><% if (walltime - runningtime) > 0 %>
-      (<%= render_runtime(walltime - runningtime, false) %> queued)<% end %><% if cputime == 0 %>.<% else %>
-      and used
-    <%= render_runtime(cputime, false) %>
-    of node allocation time (<%= (cputime/runningtime).round(1) %>&Cross; scaling).
-    <% end %>
-</p>
-
-<%# Components %>
-
-<%
-  job_uuids = pipeline_jobs.collect {|j| j[:job][:uuid]}.compact
-  if job_uuids.any?
-    resource_class = resource_class_for_uuid(job_uuids.first, friendly_name: true)
-    preload_objects_for_dataclass resource_class, job_uuids
-  end
-
-  job_collections = pipeline_jobs.collect {|j| j[:job][:output]}.compact
-  job_collections.concat pipeline_jobs.collect {|j| j[:job][:docker_image_locator]}.uniq.compact
-  job_collections_pdhs = job_collections.select {|x| !(m = CollectionsHelper.match(x)).nil?}.uniq.compact
-  job_collections_uuids = job_collections - job_collections_pdhs
-  preload_collections_for_objects job_collections_uuids if job_collections_uuids.any?
-  preload_for_pdhs job_collections_pdhs if job_collections_pdhs.any?
-%>
-
-<% pipeline_jobs.each_with_index do |pj, i| %>
-  <%= render partial: 'running_component', locals: {pj: pj, i: i, expanded: false, pipeline_display: true} %>
-<% end %>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_graph.html.erb b/apps/workbench/app/views/pipeline_instances/_show_graph.html.erb
deleted file mode 100644 (file)
index 1536591..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @pipelines.count > 1 %>
-  <div style="text-align: center; padding-top: 0.5em">
-    <span class="pipeline_color_legend" style="background: #aaffaa"><%= link_to_if_arvados_object @pipelines[0], friendly_name: true %></span>
-    <span class="pipeline_color_legend" style="background: #aaaaff"><%= link_to_if_arvados_object @pipelines[1], friendly_name: true %></span>
-    <% if @pipelines.count > 2 %>
-    <span class="pipeline_color_legend" style="background: #ffaaaa"><%= link_to_if_arvados_object @pipelines[2], friendly_name: true %></span>
-    <% end %>
-    <span class="pipeline_color_legend" style="background: #aaaaaa">Common to <%= @pipelines.count > 2 ? 'multiple' : 'both' %> pipelines</span>
-  </div>
-<% end %>
-
-<%= render partial: 'application/svg_div', locals: {
-      divId: "provenance_graph", 
-      svgId: "provenance_svg", 
-      svg: @prov_svg } %>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_inputs.html.erb b/apps/workbench/app/views/pipeline_instances/_show_inputs.html.erb
deleted file mode 100644 (file)
index 60d4445..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% n_inputs = 0 %>
-
-<% content_for :pi_input_form do %>
-<form role="form" style="width:60%">
-  <div class="form-group">
-    <% @object.components.each do |cname, component| %>
-      <% next if !component %>
-      <% component[:script_parameters].andand.each do |pname, pvalue_spec| %>
-        <% if pvalue_spec.is_a? Hash %>
-          <% if pvalue_spec[:description] or
-                pvalue_spec[:required] or pvalue_spec[:optional] == false %>
-            <% n_inputs += 1 %>
-            <label for="<% "#{cname}-#{pname}" %>">
-              <%= @object.component_input_title(cname, pname) %>
-            </label>
-            <div>
-              <p class="form-control-static">
-                <%= render_pipeline_component_attribute @object, :components, [cname, :script_parameters, pname.to_sym], pvalue_spec %>
-              </p>
-            </div>
-            <p class="help-block">
-              <%= pvalue_spec[:description] %>
-            </p>
-          <% end %>
-        <% end %>
-      <% end %>
-    <% end %>
-  </div>
-</form>
-<% end %>
-
-<% if n_inputs == 0 %>
-  <p>This pipeline does not need any further inputs specified. You can start it by clicking the "Run" button whenever you're ready. (It's not too late to change existing settings, though.)</p>
-<% else %>
-  <%= render_unreadable_inputs_present %>
-
-  <p><i>Provide <%= n_inputs > 1 ? 'values' : 'a value' %> for the following <%= n_inputs > 1 ? 'parameters' : 'parameter' %>, then click the "Run" button to start the pipeline.</i></p>
-  <% if @object.editable? %>
-    <%= content_for :pi_input_form %>
-      <%= link_to(url_for('pipeline_instance[state]' => 'RunningOnServer'),
-          class: 'btn btn-primary run-pipeline-button',
-          method: :patch
-          ) do %>
-        Run <i class="fa fa-fw fa-play"></i>
-    <% end %>
-  <% end %>
-
-<% end %>
-
-<div style="margin-top: 1em;">
-  <p>Click the "Components" tab above to see a full list of pipeline settings.</p>
-</div>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_log.html.erb b/apps/workbench/app/views/pipeline_instances/_show_log.html.erb
deleted file mode 100644 (file)
index 24937ba..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% log_ids = @object.job_log_ids
-   job_ids = @object.job_ids
-   still_logging, done_logging = log_ids.keys.partition { |k| log_ids[k].nil? }
-%>
-
-<% unless done_logging.empty? %>
-  <table class="topalign table table-condensed table-fixedlayout">
-    <colgroup>
-      <col width="40%" />
-      <col width="60%" />
-    </colgroup>
-    <thead>
-      <tr>
-        <th>finished component</th>
-        <th>job log</th>
-      </tr>
-    </thead>
-    <tbody>
-      <% done_logging.each do |cname| %>
-      <tr>
-        <td><%= cname %></td>
-        <td><%= link_to("Log for #{cname}",
-                job_path(job_ids[cname], anchor: "Log"))
-                %></td>
-      </tr>
-      <% end %>
-    </tbody>
-  </table>
-<% end %>
-
-<% unless still_logging.empty? %>
-  <h4>Logs in progress</h4>
-
-  <pre id="event_log_div"
-       class="arv-log-event-listener arv-log-event-handler-append-logs arv-log-event-subscribe-to-pipeline-job-uuids arv-job-log-window"
-       data-object-uuids="<%= @object.stderr_log_object_uuids.join(' ') %>"
-       ><%= @object.stderr_log_lines.join("\n") %></pre>
-
-  <%# Applying a long throttle suppresses the auto-refresh of this
-      partial that would normally be triggered by arv-log-event. %>
-  <div class="arv-log-refresh-control"
-       data-load-throttle="86486400000" <%# 1001 nights %>
-       ></div>
-<% end %>
-
diff --git a/apps/workbench/app/views/pipeline_instances/_show_object_description_cell.html.erb b/apps/workbench/app/views/pipeline_instances/_show_object_description_cell.html.erb
deleted file mode 100644 (file)
index 60ed93b..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="nowrap">
-  <%= object.content_summary %><br />
-  <%= render partial: 'pipeline_instances/component_labels', locals: {object: object} %>
-</div>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_recent.html.erb b/apps/workbench/app/views/pipeline_instances/_show_recent.html.erb
deleted file mode 100644 (file)
index 3aac930..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= form_tag({}, {id: "comparedInstances"}) do |f| %>
-
-<table class="table table-condensed table-fixedlayout arv-recent-pipeline-instances">
-  <colgroup>
-    <col width="5%" />
-    <col width="15%" />
-    <col width="25%" />
-    <col width="20%" />
-    <col width="15%" />
-    <col width="15%" />
-    <col width="5%" />
-  </colgroup>
-  <thead>
-    <tr class="contain-align-left">
-      <th>
-      </th><th>
-       Status
-      </th><th>
-       Instance
-      </th><th>
-       Template
-      </th><th>
-       Owner
-      </th><th>
-       Created at
-      </th><th>
-      </th>
-    </tr>
-  </thead>
-
-  <tbody data-infinite-scroller="#recent-pipeline-instances" id="recent-pipeline-instances"
-         data-infinite-content-href="<%= url_for partial: :recent_rows %>" >
-  </tbody>
-
-</table>
-
-<% end %>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_recent_rows.html.erb b/apps/workbench/app/views/pipeline_instances/_show_recent_rows.html.erb
deleted file mode 100644 (file)
index bcf6b28..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @objects.sort_by { |ob| ob.created_at }.reverse.each do |ob| %>
-    <tr data-object-uuid="<%= ob.uuid %>" data-kind="<%= ob.kind %>" >
-      <td>
-        <%= check_box_tag 'uuids[]', ob.uuid, false, :class => 'persistent-selection' %>
-      </td><td>
-        <%= render partial: 'pipeline_status_label', locals: {:p => ob} %>
-      </td><td colspan="1">
-        <%= link_to_if_arvados_object ob, friendly_name: true %>
-      </td><td>
-        <%= link_to_if_arvados_object ob.pipeline_template_uuid, friendly_name: true %>
-      </td><td>
-        <%= link_to_if_arvados_object ob.owner_uuid, friendly_name: true %>
-      </td><td>
-        <%= ob.created_at.to_s %>
-      </td><td>
-        <%= render partial: 'delete_object_button', locals: {object:ob} %>
-      </td>
-    </tr>
-    <tr data-object-uuid="<%= ob.uuid %>">
-      <td style="border-top: 0;" colspan="2">
-      </td>
-      <td style="border-top: 0; opacity: 0.5;" colspan="6">
-        <% ob.components.each do |cname, c| %>
-          <% if c.is_a?(Hash) and c[:job] %>
-            <%= render partial: "job_progress", locals: {:j => c[:job], :title => cname.to_s, :show_progress_bar => false } %>
-          <% else %>
-            <span class="label label-default"><%= cname.to_s %></span>
-          <% end %>
-        <% end %>
-      </td>
-    </tr>
-<% end %>
diff --git a/apps/workbench/app/views/pipeline_instances/_show_tab_buttons.html.erb b/apps/workbench/app/views/pipeline_instances/_show_tab_buttons.html.erb
deleted file mode 100644 (file)
index ae9e3c7..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if current_user.andand.is_active %>
-  <% if @object.state.in? ['Complete', 'Failed', 'Cancelled', 'Paused'] %>
-
-  <%= link_to(copy_pipeline_instance_path('id' => @object.uuid, 'script' => "use_latest", "components" => "use_latest", "pipeline_instance[state]" => "RunningOnServer"),
-      class: 'btn btn-primary',
-      title: 'Re-run with latest options',
-      #data: {toggle: :tooltip, placement: :top}, title: 'Re-run',
-      method: :post,
-      ) do %>
-    <i class="fa fa-fw fa-play"></i> Re-run with latest
-  <% end %>
-
-  <%= link_to raw('<i class="fa fa-fw fa-cogs"></i> Re-run options...'),
-      "#",
-      {class: 'btn btn-primary', 'data-toggle' =>  "modal",
-        'data-target' => '#clone-and-edit-modal-window',
-        title: 'Re-run with options'}  %>
-  <% end %>
-
-  <% if @object.state.in? ['New', 'Ready'] %>
-    <%= link_to(url_for('pipeline_instance[state]' => 'RunningOnServer'),
-        class: 'btn btn-primary run-pipeline-button',
-        title: 'Run this pipeline',
-        method: :patch
-        ) do %>
-      <i class="fa fa-fw fa-play"></i> Run
-    <% end %>
-  <% else %>
-    <% if @object.state.in? ['RunningOnClient', 'RunningOnServer'] %>
-      <%= link_to(cancel_pipeline_instance_path,
-          class: 'btn btn-primary run-pipeline-button',
-          title: 'Pause this pipeline',
-          data: {confirm: 'All unfinished child jobs will be canceled, even if they are being used in another job or pipeline. Are you sure you want to pause this pipeline?'},
-          method: :post
-          ) do %>
-        <i class="fa fa-fw fa-pause"></i> Pause
-      <% end %>
-    <% elsif @object.state == 'Paused' %>
-      <%= link_to(url_for('pipeline_instance[state]' => 'RunningOnServer'),
-          class: 'btn btn-primary run-pipeline-button',
-          title: 'Resume this pipeline',
-          method: :patch
-          ) do %>
-        <i class="fa fa-fw fa-play"></i> Resume
-      <% end %>
-    <% end %>
-  <% end %>
-<% end %>
diff --git a/apps/workbench/app/views/pipeline_instances/compare.html.erb b/apps/workbench/app/views/pipeline_instances/compare.html.erb
deleted file mode 100644 (file)
index 960d81d..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if (o = Group.find?(@objects.first.owner_uuid)) %>
-  <% content_for :breadcrumbs do %>
-    <li class="nav-separator"><span class="glyphicon glyphicon-arrow-right"></span></li>
-    <li>
-      <%= link_to(o.name, project_path(o.uuid)) %>
-    </li>
-    <li class="nav-separator">
-      <span class="glyphicon glyphicon-arrow-right"></span>
-    </li>
-    <li>
-      <%= link_to '#' do %>compare pipelines<% end %>
-    </li>
-  <% end %>
-<% end %>
-<%= render partial: 'content', layout: 'content_layout', locals: {pane_list: controller.compare_pane_list }  %>
diff --git a/apps/workbench/app/views/pipeline_instances/index.html.erb b/apps/workbench/app/views/pipeline_instances/index.html.erb
deleted file mode 100644 (file)
index 250d51a..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :tab_line_buttons do %>
-  <div class="input-group">
-    <input type="text" class="form-control filterable-control recent-pipeline-instances-filterable-control"
-           placeholder="Search pipeline instances"
-           data-filterable-target="#recent-pipeline-instances"
-           <%# Just for the double-load test in FilterableInfiniteScrollTest: %>
-           value="<%= params[:search] %>"
-           />
-  </div>
-
-  <%= form_tag({action: 'compare', controller: params[:controller], method: 'get'}, {method: 'get', id: 'compare', class: 'pull-right small-form-margin'}) do |f| %>
-    <%= submit_tag 'Compare 2 or 3 selected', {class: 'btn btn-primary', disabled: true} %>
-  <% end rescue nil %>
-
-<% end %>
-
-<%= render file: 'application/index.html.erb', locals: local_assigns %>
diff --git a/apps/workbench/app/views/pipeline_instances/show.html.erb b/apps/workbench/app/views/pipeline_instances/show.html.erb
deleted file mode 100644 (file)
index e573bf5..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% template = PipelineTemplate.find?(@object.pipeline_template_uuid) %>
-<%= content_for :content_top do %>
-  <div class="row">
-    <div class="col-sm-6">
-      <%= render partial: 'name_and_description' %>
-    </div>
-    <% if template %>
-      <div class="alert alert-info col-sm-6">
-        This pipeline was created from the template <%= link_to_if_arvados_object template, friendly_name: true %><br />
-        <% if template.modified_at && (template.modified_at > @object.created_at) %>
-        Note: This template has been modified since this instance was created.
-        <% end %>
-      </div>
-    <% end %>
-  </div>
-<% end %>
-
-<% content_for :tab_line_buttons do %>
-
-  <div id="pipeline-instance-tab-buttons"
-       class="pane-loaded arv-log-event-listener arv-refresh-on-state-change"
-       data-pane-content-url="<%= url_for(params.permit!.merge(tab_pane: "tab_buttons")) %>"
-       data-object-uuid="<%= @object.uuid %>"
-       >
-    <%= render partial: 'show_tab_buttons', locals: {object: @object}%>
-  </div>
-
-<% end %>
-
-<%= render partial: 'content', layout: 'content_layout', locals: {pane_list: controller.show_pane_list }%>
-
-<div id="clone-and-edit-modal-window" class="modal fade" role="dialog"
-     aria-labelledby="myModalLabel" aria-hidden="true">
-  <div class="modal-dialog">
-    <div class="modal-content">
-
-    <%= form_tag copy_pipeline_instance_path do |f| %>
-
-      <div class="modal-header">
-        <button type="button" class="close" onClick="reset_form()" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <div>
-          <div class="col-sm-6"> <h4 class="modal-title">Re-run pipeline</h4> </div>
-        </div>
-        <br/>
-      </div>
-
-      <div class="modal-body">
-              <%= radio_button_tag(:script, "use_latest", true) %>
-              <%= label_tag(:script_use_latest, "Use latest script versions") %>
-              <br>
-              <%= radio_button_tag(:script, "use_same") %>
-              <%= label_tag(:script_use_same, "Use same script versions as this run") %>
-              <br>
-              <% if template %>
-              <br>
-              <%= radio_button_tag(:components, "use_latest", true) %>
-              <%= label_tag(:components_use_latest, "Update components against template") %>
-              <br>
-              <%= radio_button_tag(:components, "use_same") %>
-              <%= label_tag(:components_use_same, "Use same components as this run") %>
-              <% end %>
-      </div>
-
-      <div class="modal-footer">
-        <button class="btn btn-default" onClick="reset_form()" data-dismiss="modal" aria-hidden="true">Cancel</button>
-        <button type="submit" class="btn btn-primary" name="pipeline_instance[state]" value="RunningOnServer">Run now</button>
-        <button type="submit" class="btn btn-primary" name="pipeline_instance[state]" value="New">Copy and edit inputs</button>
-      </div>
-
-    </div>
-    <% end %>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/pipeline_instances/show.js.erb b/apps/workbench/app/views/pipeline_instances/show.js.erb
deleted file mode 100644 (file)
index 28a1fdb..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% self.formats = [:html] %>
-var new_content = "<%= escape_javascript(render template: 'pipeline_instances/show') %>";
-var selected_tab_hrefs = [];
-if ($('div#page-wrapper').html() != new_content) {
-    $('.nav-tabs li.active a').each(function() {
-        selected_tab_hrefs.push($(this).attr('href'));
-    });
-
-    $('div#page-wrapper').html(new_content);
-
-    // Show the same tabs that were active before we rewrote page-wrapper
-    $.each(selected_tab_hrefs, function(i, href) {
-        $('.nav-tabs li a[href="' + href + '"]').tab('show');
-    });
-}
diff --git a/apps/workbench/app/views/pipeline_templates/_choose.js.erb b/apps/workbench/app/views/pipeline_templates/_choose.js.erb
deleted file mode 120000 (symlink)
index 8420a7f..0000000
+++ /dev/null
@@ -1 +0,0 @@
-../application/_choose.js.erb
\ No newline at end of file
diff --git a/apps/workbench/app/views/pipeline_templates/_choose_rows.html.erb b/apps/workbench/app/views/pipeline_templates/_choose_rows.html.erb
deleted file mode 100644 (file)
index 371398d..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @objects.each do |object| %>
-  <div class="row filterable selectable" data-object-uuid="<%= object.uuid %>" data-preview-href="<%= url_for object %>?tab_pane=chooser_preview">
-    <div class="col-sm-12" style="overflow-x:hidden">
-      <i class="fa fa-fw fa-gear"></i>
-      <%= object.name %>
-    </div>
-  </div>
-<% end %>
diff --git a/apps/workbench/app/views/pipeline_templates/_show_attributes.html.erb b/apps/workbench/app/views/pipeline_templates/_show_attributes.html.erb
deleted file mode 100644 (file)
index 1b3557b..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= content_for :content_top do %>
-  <h2>Template '<%= @object.name %>'</h2>
-<% end %>
-
-<table class="table topalign">
-  <thead>
-  </thead>
-  <tbody>
-    <% @object.attributes_for_display.each do |attr, attrvalue| %>
-      <% if attr != 'components' %>
-        <%= render partial: 'application/arvados_object_attr', locals: { attr: attr, attrvalue: attrvalue } %>
-      <% end %>
-    <% end %>
-  </tbody>
-</table>
diff --git a/apps/workbench/app/views/pipeline_templates/_show_chooser_preview.html.erb b/apps/workbench/app/views/pipeline_templates/_show_chooser_preview.html.erb
deleted file mode 100644 (file)
index 614ec33..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="col-sm-11 col-sm-push-1 arv-description-in-table">
-  <%= @object.description %>
-</div>
-<%= render partial: 'show_components' %>
diff --git a/apps/workbench/app/views/pipeline_templates/_show_components.html.erb b/apps/workbench/app/views/pipeline_templates/_show_components.html.erb
deleted file mode 100644 (file)
index fd4a0ed..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render_pipeline_components("editable", :json, editable: false) %>
diff --git a/apps/workbench/app/views/pipeline_templates/_show_pipelines.html.erb b/apps/workbench/app/views/pipeline_templates/_show_pipelines.html.erb
deleted file mode 100644 (file)
index 3df0296..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-
-  <%= render partial: 'pipeline_instances/show_recent' %>
diff --git a/apps/workbench/app/views/pipeline_templates/_show_recent.html.erb b/apps/workbench/app/views/pipeline_templates/_show_recent.html.erb
deleted file mode 100644 (file)
index c708c1f..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: "paging", locals: {results: @objects, object: @object} %>
-
-<table class="table table-condensed arv-index">
-  <colgroup>
-    <col width="8%" />
-    <col width="10%" />
-    <col width="22%" />
-    <col width="45%" />
-    <col width="15%" />
-  </colgroup>
-  <thead>
-    <tr class="contain-align-left">
-      <th>
-      </th><th>
-      </th><th>
-        name
-      </th><th>
-        description/components
-      </th><th>
-        owner
-      </th>
-    </tr>
-  </thead>
-  <tbody>
-
-    <% @objects.sort_by { |ob| ob[:created_at] }.reverse.each do |ob| %>
-
-    <tr>
-      <td>
-        <%= button_to(choose_projects_path(id: "run-pipeline-button",
-                                     title: 'Choose project',
-                                     editable: true,
-                                     action_name: 'Choose',
-                                     action_href: pipeline_instances_path,
-                                     action_method: 'post',
-                                     action_data: {selection_param: 'pipeline_instance[owner_uuid]',
-                                                   'pipeline_instance[pipeline_template_uuid]' => ob.uuid,
-                                                   'pipeline_instance[description]' => "Created at #{Time.now.localtime}" + (ob.name.andand.size.andand>0 ? " using the pipeline template *#{ob.name}*" : ""),
-                                                   'success' => 'redirect-to-created-object'
-                                                  }.to_json),
-                { class: "btn btn-default btn-xs", title: "Run #{ob.name}", remote: true, method: :get }
-            ) do %>
-               <i class="fa fa-fw fa-play"></i> Run
-              <% end %>
-      </td>
-      <td>
-        <%= render :partial => "show_object_button", :locals => {object: ob, size: 'xs'} %>
-      </td><td>
-        <%= render_editable_attribute ob, 'name' %>
-      </td><td>
-        <% if ob.respond_to?(:description) and ob.description %>
-          <%= render_attribute_as_textile(ob, "description", ob.description, false) %>
-          <br />
-        <% end %>
-        <% ob.components.collect { |k,v| k.to_s }.each do |k| %>
-          <span class="label label-default"><%= k %></span>
-        <% end %>
-      </td><td>
-        <%= link_to_if_arvados_object ob.owner_uuid, friendly_name: true %>
-      </td>
-    </tr>
-
-    <% end %>
-
-  </tbody>
-</table>
-
-<%= render partial: "paging", locals: {results: @objects, object: @object} %>
diff --git a/apps/workbench/app/views/pipeline_templates/show.html.erb b/apps/workbench/app/views/pipeline_templates/show.html.erb
deleted file mode 100644 (file)
index 7f07d27..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @object.editable? %>
-  <% content_for :tab_line_buttons do %>
-    <%= link_to(choose_projects_path(
-        id: "run-pipeline-button",
-        title: 'Choose project',
-        editable: true,
-        action_name: 'Choose',
-        action_href: pipeline_instances_path,
-        action_method: 'post',
-        action_data: {
-          'selection_param' => 'pipeline_instance[owner_uuid]',
-          'pipeline_instance[pipeline_template_uuid]' => @object.uuid,
-          'pipeline_instance[description]' => "Created at #{Time.now.localtime}" + (@object.name.andand.size.andand>0 ? " using the pipeline template *#{@object.name}*" : ""),
-          'success' => 'redirect-to-created-object',
-        }.to_json), {
-          class: "btn btn-primary btn-sm",
-          remote: true,
-          title: 'Run this pipeline'
-        }) do %>
-      <i class="fa fa-gear"></i> Run this pipeline
-    <% end %>
-  <% end %>
-<% end %>
-
-<%= render file: 'application/show.html.erb', locals: local_assigns %>
diff --git a/apps/workbench/app/views/projects/_choose.html.erb b/apps/workbench/app/views/projects/_choose.html.erb
deleted file mode 100644 (file)
index 633a9ba..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="modal modal-with-loading-spinner">
-  <div class="modal-dialog">
-    <div class="modal-content">
-
-      <div class="modal-header">
-        <button type="button" class="close" onClick="reset_form()" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <div>
-          <div class="col-sm-6"> <h4 class="modal-title"><%= params[:title] || 'Choose project' %></h4> </div>
-          <div class="spinner spinner-32px spinner-h-center col-sm-1" hidden="true"></div>
-        </div>
-        <br/>
-      </div>
-
-      <div class="modal-body">
-        <div class="selectable-container" style="height: 15em; overflow-y: scroll">
-          <% starred_projects = my_starred_projects current_user, 'project' %>
-          <% if starred_projects.andand.any? %>
-            <% writable_projects = starred_projects.select(&:editable?) %>
-            <% writable_projects.each do |projectnode| %>
-              <% row_name = projectnode.friendly_link_name || 'New project' %>
-              <div class="selectable project row"
-                   style="padding-left: 1em; margin-right: 0px"
-                   data-object-uuid="<%= projectnode.uuid %>">
-                <i class="fa fa-fw fa-folder-o"></i> <%= row_name %> <i class="fa fa-fw fa-star"></i>
-              </div>
-            <% end %>
-          <% end %>
-
-          <% my_projects = my_wanted_projects_tree(current_user) %>
-          <% my_projects[0].each do |projectnode| %>
-            <% if projectnode[:object].uuid == current_user.uuid
-                 row_name = "Home"
-               else
-                 row_name = projectnode[:object].friendly_link_name || 'New project'
-               end %>
-            <div class="selectable project row"
-                 style="padding-left: <%= 1 + projectnode[:depth] %>em; margin-right: 0px"
-                 data-object-uuid="<%= projectnode[:object].uuid %>">
-              <i class="fa fa-fw fa-folder-o"></i> <%= row_name %>
-            </div>
-          <% end %>
-        </div>
-
-        <% if my_projects[1] or my_projects[2] or my_projects[0].size > 200 %>
-          <div>Some of your projects are omitted. Add projects of interest to favorites.</div>
-        <% end %>
-      </div>
-
-      <div class="modal-footer">
-        <button class="btn btn-default" data-dismiss="modal" aria-hidden="true">Cancel</button>
-        <button class="btn btn-primary" aria-hidden="true" data-enable-if-selection disabled><%= params[:action_name] || 'Select' %></button>
-        <div class="modal-error hide" style="text-align: left; margin-top: 1em;">
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/projects/_choose.js.erb b/apps/workbench/app/views/projects/_choose.js.erb
deleted file mode 120000 (symlink)
index 8420a7f..0000000
+++ /dev/null
@@ -1 +0,0 @@
-../application/_choose.js.erb
\ No newline at end of file
diff --git a/apps/workbench/app/views/projects/_compute_node_status.html.erb b/apps/workbench/app/views/projects/_compute_node_status.html.erb
deleted file mode 100644 (file)
index 3de2ab6..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<h4>Node status</h4>
-<div class="compute-summary-nodelist">
-    <% nodes.each do |n| %>
-        <div class="compute-summary">
-          <a data-toggle="collapse" href="#detail_<%= n.hostname %>" class="compute-summary-head label label-<%= if n.crunch_worker_state == 'busy' then 'primary' else 'default' end %>">
-            <%= n.hostname %>
-          </a>
-          <div id="detail_<%= n.hostname %>" class="collapse compute-detail">
-            state: <%= n.crunch_worker_state %><br>
-            <% [:total_cpu_cores, :total_ram_mb, :total_scratch_mb].each do |i| %>
-              <%= i.to_s.gsub '_', ' ' %>: <%= n.properties[i] %><br>
-            <% end %>
-          </div>
-        </div>
-    <% end %>
-</div>
diff --git a/apps/workbench/app/views/projects/_compute_node_summary.html.erb b/apps/workbench/app/views/projects/_compute_node_summary.html.erb
deleted file mode 100644 (file)
index 474fc7b..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="compute-summary-numbers">
-    <table>
-      <colgroup>
-        <col width="50%">
-        <col width="50%">
-      </colgroup>
-      <tr>
-        <td><%= nodes.select {|n| n.crunch_worker_state == "busy" }.size %></td>
-        <td><%= nodes.select {|n| n.crunch_worker_state == "idle" }.size %></td>
-      </tr>
-      <tr>
-        <th scope="col">Busy nodes</th>
-        <th scope="col">Idle nodes</th>
-      </tr>
-    </table>
-</div>
diff --git a/apps/workbench/app/views/projects/_container_summary.html.erb b/apps/workbench/app/views/projects/_container_summary.html.erb
deleted file mode 100644 (file)
index c40ee37..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="compute-summary-numbers">
-  <table>
-      <colgroup>
-        <col width="50%">
-        <col width="50%">
-      </colgroup>
-      <tr>
-        <th scope="col">Pending containers</th>
-        <th scope="col">Running containers</th>
-      </tr>
-      <tr>
-       <% pending_containers = Container.order("created_at asc").filter([["state", "in", ["Queued", "Locked"]], ["priority", ">", 0]]).limit(1) %>
-       <% running_containers = Container.order("started_at asc").where(state: "Running").limit(1) %>
-        <td><%= pending_containers.items_available %></td>
-        <td><%= running_containers.items_available %></td>
-      </tr>
-      <tr>
-        <th scope="col">Oldest pending</th>
-        <th scope="col">Longest running</th>
-      </tr>
-      <tr>
-        <td><% if pending_containers.first then %>
-           <%= link_to_if_arvados_object pending_containers.first, link_text: render_runtime(Time.now - pending_containers.first.created_at, false, false) %>
-         <% else %>
-           -
-         <% end %>
-       </td>
-
-        <td><% if running_containers.first then %>
-           <%= link_to_if_arvados_object running_containers.first, link_text: render_runtime(Time.now - running_containers.first.created_at, false, false) %>
-         <% else %>
-           -
-         <% end %>
-       </td>
-      </tr>
-    </table>
-
-</div>
diff --git a/apps/workbench/app/views/projects/_index_jobs_and_pipelines.html.erb b/apps/workbench/app/views/projects/_index_jobs_and_pipelines.html.erb
deleted file mode 100644 (file)
index d0f36b1..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div>
-  <% any = false %>
-  <% recent_jobs_and_pipelines[0..9].each do |object| %>
-    <% any = true %>
-    <div class="row" style="height: 4.5em">
-      <div class="col-sm-4">
-        <%= render :partial => "show_object_button", :locals => {object: object, size: 'xs'} %>
-        <% if object.respond_to?(:name) %>
-          <%= render_editable_attribute object, 'name', nil, {}, {tiptitle: 'rename'} %>
-        <% else %>
-          <%= object.class_for_display %> <%= object.uuid %>
-        <% end %>
-      </div>
-      <div class="col-sm-8 arv-description-in-table">
-        <%= render_controller_partial(
-            'show_object_description_cell.html',
-            controller_name: object.controller_name,
-            locals: {object: object})
-            %>
-      </div>
-    </div>
-  <% end %>
-  <% if not any %>
-    <span class="deemphasize">No jobs or pipelines to display.</span>
-  <% end %>
-</div>
diff --git a/apps/workbench/app/views/projects/_index_projects.html.erb b/apps/workbench/app/views/projects/_index_projects.html.erb
deleted file mode 100644 (file)
index e726a46..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="container-fluid arv-project-list">
-  <% tree.each do |projectnode| %>
-    <% rowtype = projectnode[:object].class %>
-    <% next if rowtype != Group and !show_root_node %>
-    <div class="<%= 'project' if rowtype.in?([Group,User]) %> row">
-      <div class="col-md-4" style="padding-left: <%= projectnode[:depth] - (show_root_node ? 0 : 1) %>em;">
-        <% if show_root_node and rowtype == String %>
-          <i class="fa fa-fw fa-share-alt"></i>
-          <%= projectnode[:object] %>
-        <% elsif show_root_node and rowtype == User %>
-          <% if projectnode[:object].uuid == current_user.andand.uuid %>
-            <i class="fa fa-fw fa-folder-o"></i>
-            <%= link_to project_path(id: projectnode[:object].uuid) do %>
-              Home
-            <% end %>
-          <% else %>
-            <i class="fa fa-fw fa-folder-o"></i>
-            <%= projectnode[:object].friendly_link_name %>
-          <% end %>
-        <% elsif rowtype == Group %>
-          <i class="fa fa-fw fa-folder-o"></i>
-          <%= link_to projectnode[:object] do %>
-            <%= projectnode[:object].friendly_link_name %>
-          <% end %>
-        <% end %>
-      </div>
-      <% if projectnode[:object].respond_to?(:description) and not projectnode[:object].description.blank? %>
-        <div class="col-md-8 small"><%= render_attribute_as_textile(projectnode[:object], "description", projectnode[:object].description, true) %></div>
-      <% end %>
-    </div>
-  <% end %>
-</div>
diff --git a/apps/workbench/app/views/projects/_show_contents_rows.html.erb b/apps/workbench/app/views/projects/_show_contents_rows.html.erb
deleted file mode 100644 (file)
index d440c46..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% get_objects_and_names.each do |object, name_link| %>
-  <% name_object = (object.respond_to?(:name) || !name_link) ? object : name_link %>
-  <tr class="filterable"
-      data-object-uuid="<%= name_object.uuid %>"
-      data-kind="<%= object.kind %>"
-      data-object-created-at="<%= object.created_at %>"
-      >
-    <td>
-      <div style="width:1em; display:inline-block;">
-        <%= render partial: 'selection_checkbox', locals: {object: object, friendly_name: ((name_object.name rescue '') || '')} %>
-      </div>
-    </td>
-
-    <td>
-      <% if @object.editable? %>
-        <%= link_to({action: 'remove_item', id: @object.uuid, item_uuid: ((name_link && name_link.uuid) || object.uuid)}, method: :delete, remote: true, data: {confirm: "Remove #{object.class_for_display.downcase} #{name_object.name rescue object.uuid} from this project?", toggle: 'tooltip', placement: 'top'}, class: 'btn btn-sm btn-default btn-nodecorate', title: 'remove') do %>
-          <i class="fa fa-fw fa-trash-o"></i>
-        <% end %>
-      <% else %>
-        <i class="fa fa-fw"></i><%# placeholder %>
-      <% end %>
-    </td>
-
-    <td>
-      <%= render :partial => "show_object_button", :locals => {object: object, size: 'sm', name_link: name_link} %>
-    </td>
-
-    <td>
-      <% if object.respond_to?(:name) %>
-        <%= render_editable_attribute (name_link || object), 'name', nil, {}, {tiptitle: 'rename'} %>
-      <% end %>
-    </td>
-
-    <td class="arv-description-in-table">
-      <%= render_controller_partial(
-          'show_object_description_cell.html',
-          controller_name: object.controller_name,
-          locals: {object: object})
-          %>
-    </td>
-  </tr>
-<% end %>
diff --git a/apps/workbench/app/views/projects/_show_dashboard.html.erb b/apps/workbench/app/views/projects/_show_dashboard.html.erb
deleted file mode 100644 (file)
index 61ceaf9..0000000
+++ /dev/null
@@ -1,229 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-  recent_procs = recent_processes(12)
-
-  # preload container_uuids of any container requests
-  recent_crs = recent_procs.map {|p| p if p.is_a?(ContainerRequest)}.compact.uniq
-  recent_cr_containers = recent_crs.map {|cr| cr.container_uuid}.compact.uniq
-  if recent_cr_containers.andand.any?
-    preload_objects_for_dataclass(Container, recent_cr_containers, nil,
-                               ["uuid", "started_at", "finished_at", "state", "runtime_status", "created_at", "modified_at", "exit_code"])
-  end
-
-  wus = {}
-  outputs = []
-  recent_procs.each do |p|
-    wu = p.work_unit
-
-    wus[p] = wu
-    outputs << wu.outputs
-  end
-  outputs = outputs.flatten.uniq
-
-  collection_pdhs = outputs.select {|x| !(m = CollectionsHelper.match(x)).nil?}.uniq.compact
-  collection_uuids = outputs - collection_pdhs
-
-  if Rails.configuration.Workbench.ShowRecentCollectionsOnDashboard
-    recent_cs = recent_collections(8)
-    collection_uuids = collection_uuids + recent_cs[:collections].collect {|c| c.uuid}
-    collection_uuids.flatten.uniq
-  end
-
-  preload_collections_for_objects collection_uuids if collection_uuids.any?
-  preload_for_pdhs collection_pdhs if collection_pdhs.any?
-  preload_links_for_objects(collection_pdhs + collection_uuids)
-%>
-
-<%
-  recent_procs_panel_width = 6
-  if !PipelineInstance.api_exists?(:create)
-    recent_procs_title = 'Recent processes'
-    run_proc_title = 'Choose a workflow to run:'
-    show_node_status = false
-    # Recent processes panel should take the entire width when is the only one
-    # being rendered.
-    if !Rails.configuration.Workbench.ShowRecentCollectionsOnDashboard
-      recent_procs_panel_width = 12
-    end
-  else
-    recent_procs_title = 'Recent pipelines and processes'
-    run_proc_title = 'Choose a pipeline or workflow to run:'
-    show_node_status = true
-  end
-%>
-
-  <div class="row">
-    <div class="col-md-<%= recent_procs_panel_width %>">
-      <div class="panel panel-default" style="min-height: 10.5em">
-        <div class="panel-heading">
-          <span class="panel-title"><%=recent_procs_title%></span>
-          <% if current_user.andand.is_active %>
-            <span class="pull-right recent-processes-actions">
-              <span>
-                <%= link_to(
-                choose_work_unit_templates_path(
-                  title: run_proc_title,
-                  action_name: 'Next: choose inputs <i class="fa fa-fw fa-arrow-circle-right"></i>',
-                  action_href: work_units_path,
-                  action_method: 'post',
-                  action_data: {'selection_param' => 'work_unit[template_uuid]', 'work_unit[owner_uuid]' => current_user.uuid, 'success' => 'redirect-to-created-object'}.to_json),
-                { class: "btn btn-primary btn-xs", remote: true }) do %>
-                  <i class="fa fa-fw fa-gear"></i> Run a process...
-                <% end %>
-              </span>
-              <span>
-                  <%= link_to all_processes_path, class: 'btn btn-default btn-xs' do %>
-                    All processes <i class="fa fa-fw fa-arrow-circle-right"></i>
-                  <% end %>
-              </span>
-            </span>
-          <% end %>
-        </div>
-
-        <div class="panel-body recent-processes">
-          <% if recent_procs.empty? %>
-            No recent pipelines or processes.
-          <% else %>
-          <% wus.each do |p, wu| %>
-            <%
-            # Set up tooltip containing useful runtime information
-            runtime_status_tooltip = nil
-            if wu.runtime_status
-              if wu.runtime_status[:error]
-                runtime_status_tooltip = "Error: #{wu.runtime_status[:error]}"
-              elsif wu.runtime_status[:warning]
-                runtime_status_tooltip = "Warning: #{wu.runtime_status[:warning]}"
-              end
-            end
-            %>
-            <% if wu.is_finished? %>
-            <div class="dashboard-panel-info-row row-<%=wu.uuid%>" title="<%=sanitize(runtime_status_tooltip)%>">
-              <div class="row">
-                <div class="col-md-6 text-overflow-ellipsis">
-                  <%= link_to_if_arvados_object p, {friendly_name: true} %>
-                </div>
-                <div class="col-md-2">
-                  <span class="label label-<%=wu.state_bootstrap_class%>"><%=wu.state_label%></span>
-                </div>
-                <div class="col-md-4">
-                  <%= render_localized_date(wu.finished_at || wu.modified_at, "noseconds") %>
-                </div>
-              </div>
-              <div class="row">
-                <div class="col-md-12">
-                  <% if wu.started_at and wu.finished_at %>
-                    <% wu_time = wu.finished_at - wu.started_at %>
-                    Active for <%= render_runtime(wu_time, false) %>
-                  <% end %>
-
-                  <%= render partial: 'work_units/show_output', locals: {wu: wu, align: 'pull-right', include_icon: true} %>
-                </div>
-              </div>
-
-            </div>
-            <% else %>
-            <div class="dashboard-panel-info-row row-<%=wu.uuid%>" title="<%=sanitize(runtime_status_tooltip)%>">
-              <div class="row">
-                <div class="col-md-6 text-overflow-ellipsis">
-                  <%= link_to_if_arvados_object p, {friendly_name: true} %>
-                </div>
-                <div class="col-md-2">
-                  <span class="label label-<%=wu.state_bootstrap_class%>"><%=wu.state_label%></span>
-                </div>
-              </div>
-
-              <div class="clearfix">
-                <% if wu.started_at %>
-                  Started at <%= render_localized_date(wu.started_at, "noseconds") %>
-                  Active for <%= render_runtime(Time.now - wu.started_at, false) %>.
-                <% else %>
-                  Created at <%= render_localized_date(wu.created_at, "noseconds") %>.
-                  <% if wu.state_label == 'Queued' %>
-                    Queued for <%= render_runtime(Time.now - wu.created_at, false) %>.
-                  <% end %>
-                <% end %>
-              </div>
-            </div>
-            <% end %>
-          <% end %>
-          <% end %>
-        </div>
-      </div>
-    </div>
-
-    <div class="col-md-6">
-      <% if show_node_status %>
-      <% nodes = Node.filter([["last_ping_at", ">", Time.now - 3600]]).results %>
-      <div class="panel panel-default" style="min-height: 10.5em">
-        <div class="panel-heading"><span class="panel-title">Compute node status</span>
-          <span class="pull-right compute-node-actions">
-            <% if current_user.andand.is_admin %>
-              <span>
-                <%= link_to nodes_path, class: 'btn btn-default btn-xs' do %>
-                  All nodes <i class="fa fa-fw fa-arrow-circle-right"></i>
-                <% end %>
-              </span>
-            <% end %>
-          </span>
-        </div>
-        <div class="panel-body compute-node-summary-pane">
-          <div>
-            <%= render partial: 'compute_node_summary', locals: {nodes: nodes} %>
-            <% active_nodes = [] %>
-            <% nodes.sort_by { |n| n.hostname || "" }.each do |n| %>
-              <% if n.crunch_worker_state.in? ["busy", "idle"] %>
-                <% active_nodes << n %>
-              <% end %>
-            <% end %>
-            <% if active_nodes.any? %>
-              <div style="text-align: center">
-                <a data-toggle="collapse" href="#compute_node_status">Details <span class="caret"></span></a>
-              </div>
-            <% end %>
-          </div>
-          <div id="compute_node_status" class="collapse">
-            <%= render partial: 'compute_node_status', locals: {nodes: active_nodes} %>
-          </div>
-        </div>
-      </div>
-      <% end %>
-       <% if Container.api_exists?(:index) %>
-      <div class="panel panel-default" style="min-height: 10.5em">
-        <div class="panel-heading"><span class="panel-title">Container status</span></div>
-        <div class="panel-body containers-summary-pane">
-          <div>
-            <%= render partial: 'container_summary' %>
-         </div>
-       </div>
-      </div>
-      <% end %>
-      <% if Rails.configuration.Workbench.ShowRecentCollectionsOnDashboard %>
-      <div class="panel panel-default">
-        <div class="panel-heading"><span class="panel-title">Recent collections</span>
-          <span class="pull-right">
-            <%= link_to collections_path, class: 'btn btn-default btn-xs' do %>
-              All collections <i class="fa fa-fw fa-arrow-circle-right"></i>
-            <% end %>
-          </span>
-        </div>
-        <div class="panel-body">
-          <% recent_cs[:collections].each do |p| %>
-            <div class="dashboard-panel-info-row">
-              <div>
-                <% if recent_cs[:owners][p[:owner_uuid]].is_a?(Group) %>
-                <i class="fa fa-fw fa-folder-o"></i><%= link_to_if_arvados_object recent_cs[:owners][p[:owner_uuid]], friendly_name: true %>/
-                <% end %>
-                <span class="pull-right"><%= render_localized_date(p[:modified_at], "noseconds") %></span>
-              </div>
-              <div class="text-overflow-ellipsis" style="margin-left: 1em; width: 100%"><%= link_to_if_arvados_object p, {friendly_name: true, no_tags: true} %>
-              </div>
-            </div>
-          <% end %>
-        </div>
-      </div>
-      <% end %>
-    </div>
-  </div>
diff --git a/apps/workbench/app/views/projects/_show_data_collections.html.erb b/apps/workbench/app/views/projects/_show_data_collections.html.erb
deleted file mode 100644 (file)
index 3a390ff..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render_pane 'tab_contents', to_string: true, locals: {
-    filters: [['uuid', 'is_a', "arvados#collection"]],
-    sortable_columns: { 'name' => 'collections.name', 'description' => 'collections.description' }
-    }.merge(local_assigns) %>
diff --git a/apps/workbench/app/views/projects/_show_description.html.erb b/apps/workbench/app/views/projects/_show_description.html.erb
deleted file mode 100644 (file)
index 40780f7..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if @object.respond_to? :description %>
-  <div class="arv-description-as-subtitle">
-    <%= render_editable_attribute @object, 'description', nil, { 'data-emptytext' => "(No description provided)", 'data-toggle' => 'manual', 'data-mode' => 'inline', 'data-rows' => 10 }, { btntext: 'Edit', btnclass: 'primary', btnplacement: :top } %>
-  </div>
-<% end %>
diff --git a/apps/workbench/app/views/projects/_show_featured.html.erb b/apps/workbench/app/views/projects/_show_featured.html.erb
deleted file mode 100644 (file)
index 5a788fd..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="row">
-  <% @objects[0..3].each do |object| %>
-  <div class="card arvados-object">
-    <div class="card-top blue">
-      <a href="#">
-        <img src="/favicon.ico" alt=""/>
-      </a>
-    </div>
-    <div class="card-info">
-      <span class="title"><%= @objects.name_for(object) || object.class_for_display %></span>
-      <div class="desc"><%= object.respond_to?(:description) ? object.description : object.uuid %></div>
-    </div>
-    <div class="card-bottom">
-      <%= render :partial => "show_object_button", :locals => {object: object, htmloptions: {class: 'btn-default btn-block'}} %>
-    </div>
-  </div>
-  <% end %>
-</div>
diff --git a/apps/workbench/app/views/projects/_show_other_objects.html.erb b/apps/workbench/app/views/projects/_show_other_objects.html.erb
deleted file mode 100644 (file)
index f75cf98..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render_pane 'tab_contents', to_string: true, locals: {
-    filters: [['uuid', 'is_a', ["arvados#human", "arvados#specimen", "arvados#trait"]]],
-       sortable_columns: { 'name' => 'humans.uuid, specimens.uuid, traits.name' }
-    }.merge(local_assigns) %>
diff --git a/apps/workbench/app/views/projects/_show_pipeline_templates.html.erb b/apps/workbench/app/views/projects/_show_pipeline_templates.html.erb
deleted file mode 100644 (file)
index 40ba6bd..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render_pane 'tab_contents', to_string: true, locals: {
-    limit: 50,
-    filters: [['uuid', 'is_a', ["arvados#pipelineTemplate", "arvados#workflow"]]],
-       sortable_columns: { 'name' => 'pipeline_templates.name, workflows.name', 'description' => 'pipeline_templates.description, workflows.description' }
-    }.merge(local_assigns) %>
diff --git a/apps/workbench/app/views/projects/_show_pipelines_and_processes.html.erb b/apps/workbench/app/views/projects/_show_pipelines_and_processes.html.erb
deleted file mode 100644 (file)
index 1facf53..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render_pane 'tab_contents', to_string: true, locals: {
-      limit: 50,
-      filters: [['uuid', 'is_a', ["arvados#containerRequest", "arvados#pipelineInstance"]]],
-      sortable_columns: { 'name' => 'container_requests.name, pipeline_instances.name', 'description' => 'container_requests.description, pipeline_instances.description' }
-    }.merge(local_assigns) %>
diff --git a/apps/workbench/app/views/projects/_show_processes.html.erb b/apps/workbench/app/views/projects/_show_processes.html.erb
deleted file mode 100644 (file)
index eb9c87f..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render_pane 'tab_contents', to_string: true, locals: {
-      limit: 50,
-      filters: [['uuid', 'is_a', ["arvados#containerRequest"]]],
-      sortable_columns: { 'name' => 'container_requests.name', 'description' => 'container_requests.description' }
-    }.merge(local_assigns) %>
diff --git a/apps/workbench/app/views/projects/_show_subprojects.html.erb b/apps/workbench/app/views/projects/_show_subprojects.html.erb
deleted file mode 100644 (file)
index 652366a..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render_pane 'tab_contents', to_string: true, locals: {
-    filters: [['uuid', 'is_a', ["arvados#group"]]],
-       sortable_columns: { 'name' => 'groups.name', 'description' => 'groups.description' }
-    }.merge(local_assigns) %>
diff --git a/apps/workbench/app/views/projects/_show_tab_contents.html.erb b/apps/workbench/app/views/projects/_show_tab_contents.html.erb
deleted file mode 100644 (file)
index 2e5c8a3..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% sortable_columns = {} if local_assigns[:sortable_columns].nil? %>
-<div class="selection-action-container">
-  <div class="row">
-    <div class="col-sm-5">
-      <div class="btn-group btn-group-sm">
-        <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">Selection <span class="caret"></span></button>
-        <ul class="dropdown-menu" role="menu">
-          <% if Collection.creatable? %>
-            <li><%= link_to "Create new collection with selected collections", '#',
-                    'data-href' => combine_selected_path(
-                      action_data: {current_project_uuid: @object.uuid}.to_json
-                    ),
-                    'id' => 'combine_selections_button',
-                    method: :post,
-                    'data-selection-param-name' => 'selection[]',
-                    'data-selection-action' => 'combine-project-contents',
-                    'data-toggle' => 'dropdown'
-              %></li>
-          <% end %>
-          <li><%= link_to "Compare selected", '#',
-                  'data-href' => compare_pipeline_instances_path,
-                  'data-selection-param-name' => 'uuids[]',
-                  'data-selection-action' => 'compare',
-                  'data-toggle' => 'dropdown'
-            %></li>
-          <% if Collection.creatable? %>
-            <li><%= link_to "Copy selected...", '#',
-                    'data-href' => choose_projects_path(
-                      title: 'Copy selected items to...',
-                      editable: true,
-                      action_name: 'Copy',
-                      action_href: actions_path,
-                      action_method: 'post',
-                      action_data_from_params: ['selection'],
-                      action_data: {
-                        copy_selections_into_project: true,
-                        selection_param: 'uuid',
-                        success: 'page-refresh'}.to_json),
-                    'data-remote' => true,
-                    'data-selection-param-name' => 'selection[]',
-                    'data-selection-action' => 'copy',
-                    'data-toggle' => 'dropdown'
-              %></li>
-          <% end %>
-          <% if @object.editable? %>
-            <li><%= link_to "Move selected...", '#',
-                    'data-href' => choose_projects_path(
-                      title: 'Move selected items to...',
-                      editable: true,
-                      action_name: 'Move',
-                      action_href: actions_path,
-                      action_method: 'post',
-                      action_data_from_params: ['selection'],
-                      action_data: {
-                        move_selections_into_project: true,
-                        selection_param: 'uuid',
-                        success: 'page-refresh'}.to_json),
-                    'data-remote' => true,
-                    'data-selection-param-name' => 'selection[]',
-                    'data-selection-action' => 'move',
-                    'data-toggle' => 'dropdown'
-              %></li>
-            <li><%= link_to "Remove selected", '#',
-                    method: :delete,
-                    'data-href' => url_for(action: :remove_items),
-                    'data-selection-param-name' => 'item_uuids[]',
-                    'data-selection-action' => 'remove',
-                    'data-remote' => true,
-                    'data-toggle' => 'dropdown'
-              %></li>
-          <% end %>
-        </ul>
-      </div>
-      <div class="btn-group btn-group-sm">
-        <button id="select-all" type="button" class="btn btn-default" onClick="select_all_items()">Select all</button>
-        <button id="unselect-all" type="button" class="btn btn-default" onClick="unselect_all_items()">Unselect all</button>
-      </div>
-    </div>
-    <div class="col-sm-4 pull-right">
-      <input type="text" class="form-control filterable-control" placeholder="Search project contents" data-filterable-target="table.arv-index.arv-project-<%= tab_pane %> tbody"/>
-    </div>
-  </div>
-
-  <table class="table table-condensed arv-index arv-selectable-items arv-project-<%= tab_pane %>">
-    <colgroup>
-      <col width="0*" style="max-width: fit-content;" />
-      <col width="0*" style="max-width: fit-content;" />
-      <col width="0*" style="max-width: fit-content;" />
-      <col width="60%" style="width: 60%;" />
-      <col width="40%" style="width: 40%;" />
-    </colgroup>
-    <tbody data-infinite-scroller="#<%= tab_pane %>-scroll" data-infinite-content-href="<%= url_for partial: :contents_rows %>" data-infinite-content-params-projecttab="<%= local_assigns.select{|k| [:order, :limit, :filters].include? k }.to_json %>" data-infinite-content-params-attr="projecttab">
-    </tbody>
-    <thead>
-      <tr>
-        <th></th>
-        <th></th>
-        <th></th>
-        <% sort_order = sortable_columns['name'].gsub(/\s/,'') if sortable_columns['name'] %>
-        <th <% if !sort_order.nil? %>
-              data-sort-order='<%= sort_order %>'
-            <% end %> >
-          name
-        </th>
-        <% sort_order = sortable_columns['description'].gsub(/\s/,'') if sortable_columns['description'] %>
-        <th <% if !sort_order.nil? %>
-              data-sort-order='<%= sort_order %>'
-            <% end %> >
-          description
-        </th>
-      </tr>
-    </thead>
-  </table>
-</div>
diff --git a/apps/workbench/app/views/projects/_show_workflows.html.erb b/apps/workbench/app/views/projects/_show_workflows.html.erb
deleted file mode 100644 (file)
index 6399a44..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render_pane 'tab_contents', to_string: true, locals: {
-    limit: 50,
-    filters: [['uuid', 'is_a', ["arvados#workflow"]]],
-       sortable_columns: { 'name' => 'workflows.name', 'description' => 'workflows.description' }
-    }.merge(local_assigns) %>
diff --git a/apps/workbench/app/views/projects/index.html.erb b/apps/workbench/app/views/projects/index.html.erb
deleted file mode 100644 (file)
index 14da3e4..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="pane-loaded arv-log-event-listener arv-refresh-on-log-event"
-     data-pane-content-url="<%= root_url tab_pane: "dashboard" %>"
-     data-object-uuid="all"
-     data-load-throttle="15000"
-     >
-  <%= render partial: 'show_dashboard' %>
-</div>
diff --git a/apps/workbench/app/views/projects/public.html.erb b/apps/workbench/app/views/projects/public.html.erb
deleted file mode 100644 (file)
index 9827d54..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<table class="table">
-  <colgroup>
-    <col width="25%" />
-    <col width="75%" />
-  </colgroup>
-  <thead>
-    <tr class="contain-align-left">
-      <th>
-        Name
-      </th>
-      <th>
-        Description
-      </th>
-    </tr>
-  </thead>
-
-  <tbody>
-  <% @objects.each do |p| %>
-    <tr>
-      <td>
-        <%= link_to_if_arvados_object p, {friendly_name: true} %>
-      </td>
-      <td>
-        <%= render_attribute_as_textile(p, "description", p.description, true) %>
-      </td>
-    </tr>
-  <% end %>
-  </tbody>
-</table>
diff --git a/apps/workbench/app/views/projects/remove_items.js.erb b/apps/workbench/app/views/projects/remove_items.js.erb
deleted file mode 100644 (file)
index 1ae95cb..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-$(document).trigger('count-change');
-<% @removed_uuids.each do |uuid| %>
-       $('[data-object-uuid=<%= uuid %>]').hide('slow', function() {
-           $(this).remove();
-       });
-<% end %>
diff --git a/apps/workbench/app/views/projects/show.html.erb b/apps/workbench/app/views/projects/show.html.erb
deleted file mode 100644 (file)
index 60f2d23..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :content_top do %>
-  <h2>
-    <% if @object.uuid == current_user.andand.uuid %>
-      Home
-    <% else %>
-      <%= render partial: "show_star" %>
-      <%= render_editable_attribute @object, 'name', nil, { 'data-emptytext' => "New project" } %>
-    <% end %>
-  </h2>
-  <% if @object.class == Group and @object.group_class == 'filter' %>
-    This is a filter group.
-  <% end %>
-<% end %>
-
-<%
-  if !PipelineInstance.api_exists?(:index)
-    run_proc_title = 'Choose a workflow to run:'
-    run_proc_hover = 'Run a workflow in this project'
-  else
-    run_proc_title = 'Choose a pipeline or workflow to run:'
-    run_proc_hover = 'Run a pipeline or workflow in this project'
-  end
-%>
-
-<% content_for :tab_line_buttons do %>
-  <% if @object.editable? %>
-    <div class="btn-group btn-group-sm">
-      <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown"><i class="fa fa-fw fa-plus"></i> Add data <span class="caret"></span></button>
-      <ul class="dropdown-menu pull-right" role="menu">
-        <li>
-          <%= link_to(
-                choose_collections_path(
-                  title: 'Choose a collection to copy into this project:',
-                  multiple: true,
-                  action_name: 'Copy',
-                  action_href: actions_path(id: @object.uuid),
-                  action_method: 'post',
-                  action_data: {selection_param: 'selection[]', copy_selections_into_project: @object.uuid, success: 'page-refresh'}.to_json),
-                { remote: true, data: {'event-after-select' => 'page-refresh', 'toggle' => 'dropdown'} }) do %>
-            <i class="fa fa-fw fa-clipboard"></i> Copy data from another project
-          <% end %>
-        </li>
-        <li>
-          <%= link_to(collections_path(options: {ensure_unique_name: true}, collection: {manifest_text: "", name: "New collection", owner_uuid: @object.uuid}, redirect_to_anchor: 'Upload'), {
-              method: 'post',
-              data: {toggle: 'dropdown'}}) do %>
-            <i class="fa fa-fw fa-upload"></i> Upload files from my computer
-          <% end %>
-        </li>
-      </ul>
-    </div>
-    <%= link_to(
-          choose_work_unit_templates_path(
-            title: run_proc_title,
-            action_name: 'Next: choose inputs <i class="fa fa-fw fa-arrow-circle-right"></i>',
-            action_href: work_units_path,
-            action_method: 'post',
-            action_data: {'selection_param' => 'work_unit[template_uuid]', 'work_unit[owner_uuid]' => @object.uuid, 'success' => 'redirect-to-created-object'}.to_json),
-          { class: "btn btn-primary btn-sm", remote: true, title: run_proc_hover }) do %>
-      <i class="fa fa-fw fa-gear"></i> Run a process...
-    <% end %>
-    <%= link_to projects_path({'project[owner_uuid]' => @object.uuid, 'options' => {'ensure_unique_name' => true}}), method: :post, title: "Add a subproject to this project", class: 'btn btn-sm btn-primary' do %>
-      <i class="fa fa-fw fa-plus"></i>
-      Add a subproject
-    <% end %>
-  <% end %>
-<% end %>
-
-<%= render file: 'application/show.html.erb', locals: local_assigns %>
diff --git a/apps/workbench/app/views/projects/tab_counts.js.erb b/apps/workbench/app/views/projects/tab_counts.js.erb
deleted file mode 100644 (file)
index 8757a82..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @tab_counts.each do |pane_name, tab_count| %>
-  $('span#<%= pane_name %>-count').html('(<%= tab_count %>)');
-<% end %>
\ No newline at end of file
diff --git a/apps/workbench/app/views/repositories/_add_repository_modal.html.erb b/apps/workbench/app/views/repositories/_add_repository_modal.html.erb
deleted file mode 100644 (file)
index 8fe151b..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-   if current_user.uuid.ends_with?("-000000000000000")
-     repo_prefix = ""
-   else
-     repo_prefix = current_user.username + "/"
-   end
--%>
-<div class="modal" id="add-repository-modal" tabindex="-1" role="dialog" aria-labelledby="add-repository-label" aria-hidden="true">
-  <div class="modal-dialog">
-    <div class="modal-content">
-      <form id="add-repository-form">
-        <input type="hidden" id="add_repo_owner_uuid" name="add_repo_owner_uuid" value="<%= current_user.uuid %>">
-        <input type="hidden" id="add_repo_prefix" name="add_repo_prefix" value="<%= repo_prefix %>">
-        <div class="modal-header">
-          <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
-          <h4 class="modal-title" id="add-repository-label">Add new repository</h4>
-        </div>
-        <div class="modal-body form-horizontal">
-          <div class="form-group">
-            <label for="add_repo_basename" class="col-sm-2 control-label">Name</label>
-            <div class="col-sm-10">
-              <div class="input-group arvados-uuid">
-                <% unless repo_prefix.empty? %>
-                  <span class="input-group-addon"><%= repo_prefix %></span>
-                <% end %>
-                <input type="text" class="form-control" id="add_repo_basename" name="add_repo_basename">
-                <span class="input-group-addon">.git</span>
-              </div>
-            </div>
-          </div>
-          <p class="alert alert-info">It may take a minute or two before you can clone your new repository.</p>
-          <p id="add-repository-error" class="alert alert-danger"></p>
-        </div>
-        <div class="modal-footer">
-          <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
-          <input type="submit" class="btn btn-primary" id="add-repository-submit" name="submit" value="Create">
-        </div>
-      </form>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/repositories/_repository_breadcrumbs.html.erb b/apps/workbench/app/views/repositories/_repository_breadcrumbs.html.erb
deleted file mode 100644 (file)
index 6d0f990..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="pull-right">
-  <span class="deemphasize">Browsing <%= @object.name %> repository at commit</span>
-  <%= link_to(@commit, show_repository_commit_path(id: @object.uuid, commit: @commit), title: 'show commit message') %>
-</div>
-<p>
-  <%= link_to(@object.name, show_repository_tree_path(id: @object.uuid, commit: @commit, path: ''), title: 'show root directory of source tree') %>
-  <% parents = ''
-     (@path || '').split('/').each do |pathpart|
-     parents = parents + pathpart + '/'
-     %>
-    / <%= link_to pathpart, show_repository_tree_path(id: @object.uuid, commit: @commit, path: parents) %>
-  <% end %>
-</p>
diff --git a/apps/workbench/app/views/repositories/_show_help.html.erb b/apps/workbench/app/views/repositories/_show_help.html.erb
deleted file mode 100644 (file)
index 4a7b454..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-    filters = @filters + [["owner_uuid", "=", current_user.uuid]]
-    example = Repository.all.order("name ASC").filter(filters).limit(1).results.first
-    example = Repository.all.order("name ASC").limit(1).results.first if !example
-%>
-
-<% if example %>
-
-<p>
-Sample git quick start:
-</p>
-
-<pre>
-git clone <%= example.push_url %> <%= example.name unless example.push_url.match(/:(\S+)\.git$/).andand[1] == example.name %>
-cd <%= example.name %>
-# edit files
-git add the/files/you/changed
-git commit
-git push
-</pre>
-
-<% end %>
-
-<p>
-  See also:
-  <%= link_to raw('Arvados Docs &rarr; User Guide &rarr; SSH access'),
-  "#{Rails.configuration.Workbench.ArvadosDocsite}/user/getting_started/ssh-access-unix.html",
-      target: "_blank"%> and
-  <%= link_to raw('Arvados Docs &rarr; User Guide &rarr; Writing a Crunch
-  Script'),
-  "#{Rails.configuration.Workbench.ArvadosDocsite}/user/tutorials/tutorial-firstscript.html",
-  target: "_blank"%>.
-</p>
diff --git a/apps/workbench/app/views/repositories/_show_repositories.html.erb b/apps/workbench/app/views/repositories/_show_repositories.html.erb
deleted file mode 100644 (file)
index 871ba1d..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: "add_repository_modal" %>
-
-<div class="container" style="width: 100%">
-  <div class="row">
-    <div class="col-md-pull-9 pull-left">
-      <p>
-        When you are using an Arvados virtual machine, you should clone the https:// URLs. This will authenticate automatically using your API token.
-      </p>
-      <p>
-        In order to clone git repositories using SSH, <%= link_to ssh_keys_user_path(current_user) do%> add an SSH key to your account<%end%> and clone the git@ URLs.
-      </p>
-    </div>
-    <div class="col-md-pull-3 pull-right">
-      <%= link_to raw('<i class="fa fa-plus"></i> Add new repository'), "#",
-                      {class: 'btn btn-xs btn-primary', 'data-toggle' => "modal",
-                       'data-target' => '#add-repository-modal'}  %>
-    </div>
-  </div>
-
-  <div>
-    <table class="table table-condensed table-fixedlayout repositories-table">
-      <colgroup>
-        <col style="width: 10%" />
-        <col style="width: 30%" />
-        <col style="width: 55%" />
-        <col style="width: 5%" />
-      </colgroup>
-      <thead>
-        <tr>
-          <th></th>
-          <th> Name </th>
-          <th> URL </th>
-          <th></th>
-        </tr>
-      </thead>
-
-      <tbody data-infinite-scroller="#repositories-rows" id="repositories-rows"
-        data-infinite-content-href="<%= url_for partial: :repositories_rows %>" >
-      </tbody>
-    </table>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/repositories/_show_repositories_rows.html.erb b/apps/workbench/app/views/repositories/_show_repositories_rows.html.erb
deleted file mode 100644 (file)
index fe88608..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @objects.each do |repo| %>
-  <tr data-object-uuid="<%= repo.uuid %>">
-    <td>
-      <%= render :partial => "show_object_button", :locals => {object: repo, size: 'xs' } %>
-    </td>
-    <td style="word-break:break-all;">
-      <%= repo[:name] %>
-    </td>
-    <td style="word-break:break-all;">
-      <code><%= repo.http_fetch_url %></code><br/>
-      <code><%= repo.editable? ? repo.push_url : repo.fetch_url %></code>
-    </td>
-    <td>
-      <% if repo.editable? %>
-        <%= render partial: 'delete_object_button', locals: {object: repo} %>
-      <% end %>
-    </td>
-  </tr>
-<% end %>
diff --git a/apps/workbench/app/views/repositories/show_blob.html.erb b/apps/workbench/app/views/repositories/show_blob.html.erb
deleted file mode 100644 (file)
index 729c9c6..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'repository_breadcrumbs' %>
-
-<% if not @blobdata.valid_encoding? %>
-  <div class="alert alert-warning">
-    <p>
-      This file has an invalid text encoding, so it can't be shown
-      here.  (This probably just means it's a binary file, not a text
-      file.)
-    </p>
-  </div>
-<% else %>
-  <pre><%= @blobdata %></pre>
-<% end %>
diff --git a/apps/workbench/app/views/repositories/show_commit.html.erb b/apps/workbench/app/views/repositories/show_commit.html.erb
deleted file mode 100644 (file)
index 55e8952..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'repository_breadcrumbs' %>
-
-<pre><%= @object.show @commit %></pre>
diff --git a/apps/workbench/app/views/repositories/show_tree.html.erb b/apps/workbench/app/views/repositories/show_tree.html.erb
deleted file mode 100644 (file)
index 3545131..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'repository_breadcrumbs' %>
-
-<table class="table table-condensed table-hover">
-  <thead>
-    <tr>
-      <th>File</th>
-      <th class="data-size">Size</th>
-    </tr>
-  </thead>
-  <tbody>
-    <% @subtree.each do |mode, sha1, size, subpath| %>
-      <tr>
-        <td>
-          <span style="opacity: 0.6">
-            <% pathparts = subpath.sub(/^\//, '').split('/')
-               basename = pathparts.pop
-               parents = @path
-               pathparts.each do |pathpart| %>
-              <% parents = parents + '/' + pathpart %>
-              <%= link_to pathpart, url_for(path: parents) %>
-              /
-            <% end %>
-          </span>
-          <%= link_to basename, url_for(action: :show_blob, path: parents + '/' + basename) %>
-        </td>
-        <td class="data-size">
-          <%= human_readable_bytes_html(size) %>
-        </td>
-      </tr>
-    <% end %>
-    <% if @subtree.empty? %>
-      <tr>
-        <td>
-          No files found.
-        </td>
-      </tr>
-    <% end %>
-  </tbody>
-  <tfoot></tfoot>
-</table>
diff --git a/apps/workbench/app/views/request_shell_access_reporter/send_request.text.erb b/apps/workbench/app/views/request_shell_access_reporter/send_request.text.erb
deleted file mode 100644 (file)
index ab87517..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-Shell account request from <%=@user.full_name%> (<%=@user.email%>, <%=@user.uuid%>)
-
-Details of the request:
-Full name: <%=@user.full_name%>
-Email address: <%=@user.email%>
-User's UUID: <%=@user.uuid%>
-User setup URL: <%= link_to('setup user', @params['request_url'].gsub('/request_shell_access', '#Admin')) %>
diff --git a/apps/workbench/app/views/search/_choose_rows.html.erb b/apps/workbench/app/views/search/_choose_rows.html.erb
deleted file mode 100644 (file)
index 04de426..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-<% current_class = params[:last_object_class] %>
-<% @objects.each do |object| %>
-  <% icon_class = fa_icon_class_for_class(object.class) %>
-  <% if object.class.to_s != current_class %>
-    <% current_class = object.class.to_s %>
-    <div class="row class-separator" data-section-heading="true" data-section-name="<%= object.class.to_s %>">
-      <div class="col-sm-12">
-        <%= object.class_for_display.pluralize.downcase %>
-      </div>
-    </div>
-  <% end %>
-  <div class="row filterable selectable" data-section-name="<%= object.class.to_s %>" data-object-uuid="<%= object.uuid %>" data-preview-href="<%= chooser_preview_url_for object %>">
-    <div class="col-sm-12" style="overflow-x:hidden; white-space: nowrap">
-      <i class="fa fa-fw <%= icon_class %>"></i>
-      <% if (name_link = @objects.links_for(object, 'name').first) %>
-        <%= name_link.name %>
-        <span style="display:none"><%= object.uuid %></span>
-      <% elsif object.respond_to?(:name) and object.name and object.name.length > 0 %>
-        <%= object.name %>
-        <span style="display:none"><%= object.uuid %></span>
-      <% else %>
-        <span class="arvados-uuid"><%= object.uuid %></span>
-      <% end %>
-    </div>
-  </div>
-<% end %>
diff --git a/apps/workbench/app/views/search/index.html b/apps/workbench/app/views/search/index.html
deleted file mode 100644 (file)
index 6bcad0b..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<!-- Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 -->
-
-<div data-mount-mithril="Search"></div>
diff --git a/apps/workbench/app/views/sessions/index.html b/apps/workbench/app/views/sessions/index.html
deleted file mode 100644 (file)
index bf23028..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<!-- Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 -->
-
-<div data-mount-mithril="SessionsTable"></div>
diff --git a/apps/workbench/app/views/sessions/logged_out.html.erb b/apps/workbench/app/views/sessions/logged_out.html.erb
deleted file mode 100644 (file)
index c3bd449..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<p>You have logged out.</p>
diff --git a/apps/workbench/app/views/tests/mithril.html b/apps/workbench/app/views/tests/mithril.html
deleted file mode 100644 (file)
index fac2d88..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<!-- Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 -->
-
-<div data-mount-mithril="TestComponent"></div>
diff --git a/apps/workbench/app/views/trash_items/_create_new_object_button.html.erb b/apps/workbench/app/views/trash_items/_create_new_object_button.html.erb
deleted file mode 100644 (file)
index 2d34e36..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%# There is no such thing %>
diff --git a/apps/workbench/app/views/trash_items/_show_trash_rows.html.erb b/apps/workbench/app/views/trash_items/_show_trash_rows.html.erb
deleted file mode 100644 (file)
index dd451b6..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @objects.each do |obj| %>
-  <tr data-object-uuid="<%= obj.uuid %>" data-kind="<%= obj.kind %>" >
-    <td>
-      <% if obj.editable? and obj.is_trashed %>
-        <%= check_box_tag 'uuids[]', obj.uuid, false, :class => 'persistent-selection', style: 'cursor: pointer;' %>
-      <% end %>
-    </td>
-    <td>
-      <%= if !obj.name.blank? then obj.name else obj.uuid end %>
-    </td>
-    <% if obj.is_trashed %>
-      <td>
-        <%= link_to_if_arvados_object @owners[obj.owner_uuid], friendly_name: true %>
-      </td>
-
-      <td>
-        <% if obj.trash_at %>
-          <%= render_localized_date(obj.trash_at)  %>
-        <% end %>
-        <br />
-        <% if obj.delete_at %>
-          <%= render_localized_date(obj.delete_at) %>
-        <% end %>
-      </td>
-    <% else %>
-      <td colspan="2" class="trash-project-msg">
-        <%= link_to_if_arvados_object @owners[obj.owner_uuid], friendly_name: true %>
-        <br>
-        This item is contained within a trashed project.
-      </td>
-    <% end %>
-    <td>
-      <%= obj.uuid %>
-      <% if defined? obj.portable_data_hash %>
-        <br /><%= obj.portable_data_hash %>
-      <% end %>
-    </td>
-    <td>
-      <%= render partial: 'untrash_item', locals: {object:obj} %>
-    </td>
-  </tr>
-
-<% end %>
diff --git a/apps/workbench/app/views/trash_items/_show_trashed_collection_rows.html.erb b/apps/workbench/app/views/trash_items/_show_trashed_collection_rows.html.erb
deleted file mode 120000 (symlink)
index 6841b57..0000000
+++ /dev/null
@@ -1 +0,0 @@
-_show_trash_rows.html.erb
\ No newline at end of file
diff --git a/apps/workbench/app/views/trash_items/_show_trashed_collections.html.erb b/apps/workbench/app/views/trash_items/_show_trashed_collections.html.erb
deleted file mode 100644 (file)
index 4c5fd3f..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="container selection-action-container" style="width: 100%">
-  <div class="col-md-2 pull-left">
-    <div class="btn-group btn-group-sm">
-      <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">Selection... <span class="caret"></span></button>
-      <ul class="dropdown-menu" role="menu">
-        <li><%= link_to "Un-trash selected items", '#',
-                method: :post,
-                remote: true,
-                'id' => 'untrash_selected_items',
-                'data-href' => untrash_items_trash_items_path,
-                'data-selection-param-name' => 'selection[]',
-                'data-selection-action' => 'untrash-selected-items',
-                'data-toggle' => 'dropdown'
-          %></li>
-      </ul>
-    </div>
-  </div>
-  <div class="col-md-4 pull-right">
-    <input type="text" class="form-control filterable-control recent-trash-items"
-           placeholder="Search trash"
-           data-filterable-target="#recent-collection-trash-items"
-           value="<%= params[:search] %>" />
-  </div>
-
-  <p>
-    <b>Note:</b> Collections which are located within a trashed project are only shown when searching the trash.
-  </p>
-
-  <div>
-    <table id="trash-index" class="topalign table table-condensed table-fixedlayout">
-      <colgroup>
-        <col width="5%" />
-        <col width="16%" />
-        <col width="25%" />
-        <col width="20%" />
-        <col width="29%" />
-        <col width="5%" />
-      </colgroup>
-
-      <thead>
-        <tr class="contain-align-left">
-          <th></th>
-          <th>Name</th>
-          <th>Parent project</th>
-          <th>Date&nbsp;trashed&nbsp;/<br />to&nbsp;be&nbsp;deleted</th>
-          <th>UUID&nbsp;/<br />Content&nbsp;address&nbsp;(PDH)</th>
-          <th></th>
-        </tr>
-      </thead>
-
-      <tbody data-infinite-scroller="#recent-collection-trash-items" id="recent-collection-trash-items"
-        data-infinite-content-href="<%= url_for partial: :trashed_collection_rows %>" >
-      </tbody>
-    </table>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/trash_items/_show_trashed_project_rows.html.erb b/apps/workbench/app/views/trash_items/_show_trashed_project_rows.html.erb
deleted file mode 120000 (symlink)
index 6841b57..0000000
+++ /dev/null
@@ -1 +0,0 @@
-_show_trash_rows.html.erb
\ No newline at end of file
diff --git a/apps/workbench/app/views/trash_items/_show_trashed_projects.html.erb b/apps/workbench/app/views/trash_items/_show_trashed_projects.html.erb
deleted file mode 100644 (file)
index 6f1e062..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="container selection-action-container" style="width: 100%">
-  <div class="col-md-2 pull-left">
-    <div class="btn-group btn-group-sm">
-      <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">Selection... <span class="caret"></span></button>
-      <ul class="dropdown-menu" role="menu">
-        <li><%= link_to "Un-trash selected items", '#',
-                method: :post,
-                remote: true,
-                'id' => 'untrash_selected_items',
-                'data-href' => untrash_items_trash_items_path,
-                'data-selection-param-name' => 'selection[]',
-                'data-selection-action' => 'untrash-selected-items',
-                'data-toggle' => 'dropdown'
-          %></li>
-      </ul>
-    </div>
-  </div>
-  <div class="col-md-4 pull-right">
-    <input type="text" class="form-control filterable-control recent-trash-items"
-           placeholder="Search trash"
-           data-filterable-target="#recent-project-trash-items"
-           value="<%= params[:search] %>" />
-  </div>
-
-  <p>
-    <b>Note:</b> Projects which are a subproject of a trashed project are only shown when searching the trash.
-  </p>
-
-  <div>
-    <table id="trash-index" class="topalign table table-condensed table-fixedlayout">
-      <colgroup>
-        <col width="5%" />
-        <col width="16%" />
-        <col width="25%" />
-        <col width="20%" />
-        <col width="29%" />
-        <col width="5%" />
-      </colgroup>
-
-      <thead>
-        <tr class="contain-align-left">
-          <th></th>
-          <th>Name</th>
-          <th>Parent project</th>
-          <th>Date&nbsp;trashed&nbsp;/<br />to&nbsp;be&nbsp;deleted</th>
-          <th>UUID</th>
-          <th></th>
-        </tr>
-      </thead>
-
-      <tbody data-infinite-scroller="#recent-project-trash-items" id="recent-project-trash-items"
-        data-infinite-content-href="<%= url_for partial: :trashed_project_rows %>" >
-      </tbody>
-    </table>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/trash_items/_untrash_item.html.erb b/apps/workbench/app/views/trash_items/_untrash_item.html.erb
deleted file mode 100644 (file)
index 50780d9..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if object.editable? %>
-    <%= link_to(url_for(object), {title: "Untrash", style: 'cursor: pointer;'}) do %>
-      <i class="fa fa-fw fa-recycle"></i>
-    <% end %>
-<% end %>
diff --git a/apps/workbench/app/views/trash_items/index.html.erb b/apps/workbench/app/views/trash_items/index.html.erb
deleted file mode 100644 (file)
index 1a55d5b..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render file: 'application/index.html.erb', locals: local_assigns %>
diff --git a/apps/workbench/app/views/trash_items/untrash_items.js.erb b/apps/workbench/app/views/trash_items/untrash_items.js.erb
deleted file mode 100644 (file)
index de773f4..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @untrashed_uuids.each do |uuid| %>
-       $('[data-object-uuid=<%= uuid %>]').hide('slow', function() {
-           $(this).remove();
-       });
-<% end %>
diff --git a/apps/workbench/app/views/user_agreements/index.html.erb b/apps/workbench/app/views/user_agreements/index.html.erb
deleted file mode 100644 (file)
index d52ad64..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :breadcrumbs do raw '<!-- -->' end %>
-
-<% n_files = unsigned_user_agreements.collect(&:files).flatten(1).count %>
-<% content_for :page_title do %>
-<% if n_files == 1 %>
-<%= unsigned_user_agreements.first.files.first[1].sub(/\.[a-z]{3,4}$/,'') %>
-<% else %>
-User agreements
-<% end %>
-<% end %>
-
-<%= form_for(unsigned_user_agreements.first, {url: {action: 'sign', controller: 'user_agreements'}, method: :post}) do |f| %>
-<%= hidden_field_tag :return_to, request.url %>
-<div id="open_user_agreement">
-  <div class="alert alert-info">
-    <strong>Please check <%= n_files > 1 ? 'each' : 'the' %> box below</strong> to indicate that you have read and accepted the user agreement<%= 's' if n_files > 1 %>.
-  </div>
-  <% if n_files == 1 and (Rails.configuration.Workbench.ShowUserAgreementInline rescue false) %>
-  <% ua = unsigned_user_agreements.first; file = ua.files.first %>
-  <object data="<%= url_for(controller: 'collections', action: 'show_file', uuid: ua.uuid, file: "#{file[0]}/#{file[1]}") %>" type="<%= Rack::Mime::MIME_TYPES[file[1].match(/\.\w+$/)[0]] rescue '' %>" width="100%" height="400px">
-  </object>
-  <% end %>
-  <div>
-    <% unsigned_user_agreements.each do |ua| %>
-    <% ua.files.each do |file| %>
-    <div class="checkbox">
-      <%= f.label 'checked[]' do %>
-      <%= check_box_tag 'checked[]', "#{ua.uuid}/#{file[0]}/#{file[1]}", false %>
-      Accept <%= file[1].sub(/\.[a-z]{3,4}$/,'') %>
-      <%= link_to 'View agreement', {controller: 'collections', action: 'show_file', uuid: ua.uuid, file: "#{file[0]}/#{file[1]}"}, {target: '_blank', class: 'btn btn-xs btn-info'} %>
-      <% end %>
-    </div>
-    <% end %>
-    <% end %>
-  </div>
-  <div style="height: 1em"></div>
-  <div>
-    <%= f.submit 'Continue', {class: 'btn btn-primary'} %>
-  </div>
-</div>
-<% end %>
diff --git a/apps/workbench/app/views/users/_add_group_modal.html.erb b/apps/workbench/app/views/users/_add_group_modal.html.erb
deleted file mode 100644 (file)
index f2ae645..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="modal" id="add-group-modal" tabindex="-1" role="dialog" aria-labelledby="add-group-label" aria-hidden="true">
-  <div class="modal-dialog">
-    <div class="modal-content">
-      <form id="add-group-form">
-        <div class="modal-header">
-          <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
-          <h4 class="modal-title" id="add-group-label">Add new group</h4>
-        </div>
-        <div class="modal-body form-horizontal">
-          <div class="form-group">
-            <label for="group_name_input" class="col-sm-1 control-label">Name</label>
-            <div class="col-sm-9">
-              <div class="input-group-name">
-                <input type="text" class="form-control" id="group_name_input" name="group_name_input" placeholder="Enter group name"/>
-              </div>
-            </div>
-          </div>
-          <p id="add-group-error" class="alert alert-danger"></p>
-        </div>
-        <div class="modal-footer">
-          <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
-          <input type="submit" class="btn btn-primary" id="add-group-submit" name="submit" value="Create">
-        </div>
-      </form>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/users/_add_ssh_key_popup.html.erb b/apps/workbench/app/views/users/_add_ssh_key_popup.html.erb
deleted file mode 100644 (file)
index 1d0814c..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="modal-dialog modal-with-loading-spinner">
-  <div class="modal-content">
-
-    <%= form_tag add_ssh_key_path, {method: 'get', id: 'add_new_key_form', name: 'add_new_key_form', class: 'form-search, new_authorized_key', remote: true} do %>
-
-      <div class="modal-header">
-        <button type="button" class="close" onClick="reset_form()" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <div>
-          <div class="col-sm-6"> <h4 class="modal-title">Add SSH Key</h4> </div>
-          <div class="spinner spinner-32px spinner-h-center col-sm-1" hidden="true"></div>
-        </div>
-        <br/>
-      </div>
-
-      <div class="modal-body">
-        <div> <%= link_to "Click here to learn about SSH keys in Arvados.",
-                  "#{Rails.configuration.Workbench.ArvadosDocsite}/user/getting_started/ssh-access-unix.html",
-                  style: "font-weight: bold",
-                  target: "_blank" %>
-        </div>
-        <div class="form-group">
-          <label for="public_key">Public Key</label>
-          <textarea class="form-control" id="public_key" rows="4" name="public_key" type="text"/>
-        </div>
-        <div class="form-group">
-          <label for="name">Name</label>
-          <input class="form-control" id="name" maxlength="250" name="name" type="text"/>
-        </div>
-      </div>
-
-      <div class="modal-footer">
-        <button type="button" class="btn btn-default" onClick="reset_form()" data-dismiss="modal" aria-hidden="true">Cancel</button>
-        <button type="submit" class="btn btn-primary" autofocus>Submit</button>
-      </div>
-
-    <% end #form %>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/users/_choose_rows.html.erb b/apps/workbench/app/views/users/_choose_rows.html.erb
deleted file mode 100644 (file)
index 862efad..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% icon_class = fa_icon_class_for_class(User) %>
-<% @objects.each do |object| %>
-  <div class="row filterable selectable" data-object-uuid="<%= object.uuid %>">
-    <div class="col-sm-12" style="overflow-x:hidden">
-      <i class="fa fa-fw <%= icon_class %>"></i>
-      <%= object.full_name %> &lt;<%= object.email %>&gt;
-    </div>
-  </div>
-<% end %>
diff --git a/apps/workbench/app/views/users/_create_new_object_button.html.erb b/apps/workbench/app/views/users/_create_new_object_button.html.erb
deleted file mode 100644 (file)
index fc959e3..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= link_to setup_user_popup_path,
-  {class: 'btn btn-sm btn-primary', :remote => true, 'data-toggle' =>  "modal",
-    'data-target' => '#user-setup-modal-window', return_to: request.url} do %>
-  <i class="fa fa-fw fa-plus"></i> Add a new user
-<% end %>
-<div id="user-setup-modal-window" class="modal fade" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true"></div>
diff --git a/apps/workbench/app/views/users/_current_token.html.erb b/apps/workbench/app/views/users/_current_token.html.erb
deleted file mode 100644 (file)
index 543d4eb..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="panel panel-default">
-  <div class="panel-heading">
-    <h4 class="panel-title">
-      <a data-parent="#arv-adv-accordion" href="/current_token">
-        Current Token
-      </a>
-    </h4>
-  </div>
-
-<div id="#manage_current_token" class="panel-body">
-<p>The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions. For more information see <%= link_to raw('Getting an API token'), "#{Rails.configuration.Workbench.ArvadosDocsite}/user/reference/api-tokens.html", target: "_blank"%>.</p>
-<p>Paste the following lines at a shell prompt to set up the necessary environment for Arvados SDKs to authenticate to your <b><%= current_user.username %></b> account.</p>
-<%
-  wb2_url = nil
-  if Rails.configuration.Services.Workbench2.ExternalURL != URI("")
-    wb2_url = Rails.configuration.Services.Workbench2.ExternalURL.to_s
-    wb2_url += '/' if wb2_url[-1] != '/'
-    wb2_url += "token?api_token=" + Thread.current[:arvados_api_token]
-  end
-%>
-<p><b>IMPORTANT:</b> This token will expire when logged out. If you need a token for a long running process, it is recommended to <% if wb2_url %><a href="<%= wb2_url %>">get a token from Workbench2's Get API token dialog</a>. <% else %> create a new token using the CLI tools.<% end %></p>
-
-<pre>
-HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
-export ARVADOS_API_TOKEN=<%= Thread.current[:arvados_api_token] %>
-export ARVADOS_API_HOST=<%= current_api_host %>
-<% if Rails.configuration.TLS.Insecure %>
-export ARVADOS_API_HOST_INSECURE=true
-<% else %>
-unset ARVADOS_API_HOST_INSECURE
-<% end %>
-</pre>
-<p>Arvados<%= link_to virtual_machines_user_path(current_user) do%> virtual machines<%end%> do this for you automatically. This setup is needed only when you use the API remotely (e.g., from your own workstation).</p>
-</div>
-</div>
diff --git a/apps/workbench/app/views/users/_home.html.erb b/apps/workbench/app/views/users/_home.html.erb
deleted file mode 100644 (file)
index 96ba627..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :breadcrumbs do raw '<!-- -->' end %>
-<% content_for :css do %>
-      .dash-list {
-        padding: 9px 0;
-      }
-      .dash-list>ul>li>a>span {
-      min-width: 1.5em;
-      margin-left: auto;
-      margin-right: auto;
-      }
-      .centerme {
-      margin-left: auto;
-      margin-right: auto;
-      text-align: center;
-      }
-      .bigfatnumber {
-      font-size: 4em;
-      font-weight: bold;
-      }
-      .dax {
-      max-width: 10%;
-      margin-right: 1em;
-      float: left
-      }
-      .daxalert {
-      overflow: hidden;
-      }
-<% end %>
-
-<div id="home-tables">
-
-    <%= render :partial => 'tables' %>
-
-</div>
diff --git a/apps/workbench/app/views/users/_setup_popup.html.erb b/apps/workbench/app/views/users/_setup_popup.html.erb
deleted file mode 100644 (file)
index 4c3a95e..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="modal-dialog modal-with-loading-spinner">
-  <div class="modal-content">
-
-    <%= form_tag setup_user_path, {id: 'setup_form', name: 'setup_form', method: 'get',
-        class: 'form-search', remote: true} do %>
-
-    <div class="modal-header">
-      <button type="button" class="close" onClick="reset_form()" data-dismiss="modal" aria-hidden="true">&times;</button>
-      <div>
-        <div class="col-sm-6"> <h4 class="modal-title">Setup Account</h4> </div>
-        <div class="spinner spinner-32px spinner-h-center col-sm-1" hidden="true"></div>
-      </div>
-      <br/>
-    </div>
-
-    <div class="modal-body">
-      <% if @object%>
-        <% uuid = @object.uuid %>
-        <% email = @object.email %>
-      <% end %>
-      <% disable_email = uuid != nil %>
-      <% identity_url_prefix = @current_selections[:identity_url_prefix] %>
-      <% disable_url_prefix = identity_url_prefix != nil %>
-      <% selected_vm = @current_selections[:vm_uuid] %>
-      <% groups = @current_selections[:groups] %>
-
-      <input id="user_uuid" maxlength="250" name="user_uuid" type="hidden" value="<%=uuid%>">
-      <div class="form-group">
-        <label for="email">Email</label>
-        <% if disable_email %>
-        <input class="form-control" id="email" maxlength="250" name="email" type="text" value="<%=email%>" disabled>
-        <% else %>
-        <input class="form-control" id="email" maxlength="250" name="email" type="text">
-        <% end %>
-      </div>
-      <div class="form-group">
-        <label for="openid_prefix">Identity URL Prefix</label>
-        <% if disable_url_prefix %>
-        <input class="form-control" id="openid_prefix" maxlength="250" name="openid_prefix" type="text"
-               value="<%=identity_url_prefix%>" disabled=true>
-        <% else %>
-        <input class="form-control" id="openid_prefix" maxlength="250" name="openid_prefix" type="text"
-               value="<%= Rails.configuration.Workbench.DefaultOpenIdPrefix %>">
-        <% end %>
-      </div>
-      <div class="form-group">
-        <label for="vm_uuid">Virtual Machine (optional)</label>
-        <select class="form-control" name="vm_uuid">
-          <option value="" <%= 'selected' unless selected_vm %>>
-            Choose One:
-          </option>
-          <% @vms.each do |vm| %>
-            <option value="<%=vm.uuid%>"
-              <%= 'selected' if selected_vm == vm.uuid %>>
-              <%= vm.hostname %>
-            </option>
-          <% end %>
-        </select>
-      </div>
-      <div class="groups-group">
-        <label for="groups">Groups for virtual machine (comma separated list) (optional)</label>
-        <input class="form-control" id="groups" maxlength="250" name="groups" type="text" value="<%=groups%>">
-      </div>
-    </div>
-
-    <div class="modal-footer">
-      <button class="btn btn-default" onClick="reset_form()" data-dismiss="modal" aria-hidden="true">Cancel</button>
-      <button type="submit" id="register" class="btn btn-primary" autofocus>Submit</button>
-    </div>
-
-    <% end #form %>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/users/_show_activity.html.erb b/apps/workbench/app/views/users/_show_activity.html.erb
deleted file mode 100644 (file)
index b1fba61..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<p>
-  As an admin user, you can <%= link_to "view recent user activity", activity_users_url %>.
-</p>
-
diff --git a/apps/workbench/app/views/users/_show_admin.html.erb b/apps/workbench/app/views/users/_show_admin.html.erb
deleted file mode 100644 (file)
index b151cef..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="row">
-  <div class="col-md-6">
-
-    <p>
-      This page enables you to <a href="https://doc.arvados.org/main/admin/user-management.html">manage users</a>.
-    </p>
-
-    <p>
-      This button sets up a user.  After setup, they will be able use
-      Arvados.  This dialog box also allows you to optionally set up a
-      shell account for this user.  The login name is automatically
-      generated from the user's e-mail address.
-    </p>
-
-    <%= link_to "Setup account #{'for ' if @object.full_name.present?} #{@object.full_name}", setup_popup_user_url(id: @object.uuid),  {class: 'btn btn-primary', :remote => true, 'data-toggle' =>  "modal", 'data-target' => '#user-setup-modal-window'}  %>
-
-    <p style="margin-top: 3em">
-      As an admin, you can deactivate and reset this user. This will
-      remove all repository/VM permissions for the user. If you
-      "setup" the user again, the user will have to sign the user
-      agreement again.  You may also want to <a href="https://doc.arvados.org/main/admin/reassign-ownership.html">reassign data ownership</a>.
-    </p>
-
-    <%= button_to "Deactivate #{@object.full_name}", unsetup_user_url(id: @object.uuid), class: 'btn btn-primary', data: {confirm: "Are you sure you want to deactivate #{@object.full_name}?"} %>
-
-    <p style="margin-top: 3em">
-      As an admin, you can log in as this user. When you&rsquo;ve
-      finished, you will need to log out and log in again with your
-      own account.
-    </p>
-
-    <%= button_to "Log in as #{@object.full_name}", sudo_user_url(id: @object.uuid), class: 'btn btn-primary' %>
-  </div>
-  <div class="col-md-6">
-    <div class="panel panel-default">
-      <div class="panel-heading">
-        Group memberships
-
-        <div class="pull-right">
-          <%= link_to raw('<i class="fa fa-plus"></i> Add new group'), "#",
-                       {class: 'btn btn-xs btn-primary', 'data-toggle' => "modal",
-                        'data-target' => '#add-group-modal'}  %>
-        </div>
-      </div>
-      <div class="panel-body">
-        <div class="alert alert-info">
-          <b>Tip:</b> in most cases, you want <i>both permissions at once</i> for a given group.
-          <br/>
-          The user&rarr;group permission is can_manage.
-          <br/>
-          The group&rarr;user permission is can_read.
-        </div>
-        <form>
-          <% permitted_group_perms = {}
-             Link.filter([
-             ['tail_uuid', '=', @object.uuid],
-             ['head_uuid', 'is_a', 'arvados#group'],
-             ['link_class', '=', 'permission'],
-             ]).each do |perm|
-               permitted_group_perms[perm.head_uuid] = perm.uuid
-             end %>
-          <% member_group_perms = {}
-             Link.permissions_for(@object).each do |perm|
-               member_group_perms[perm.tail_uuid] = perm.uuid
-             end %>
-          <% Group.order(['name']).where(group_class: 'role').each do |group| %>
-            <div>
-              <label class="checkbox-inline" data-toggle-permission="true" data-permission-tail="<%= @object.uuid %>" data-permission-name="can_manage">
-                <%= check_box_tag(
-                    'group_uuids[]',
-                    group.uuid,
-                    permitted_group_perms[group.uuid],
-                    disabled: (group.owner_uuid == @object.uuid),
-                    data: {
-                      permission_head: group.uuid,
-                      permission_uuid: permitted_group_perms[group.uuid] || 'x'}) %>
-                <small>user&rarr;group</small>
-              </label>
-              <label class="checkbox-inline" data-toggle-permission="true" data-permission-head="<%= @object.uuid %>" data-permission-name="can_read">
-                <%= check_box_tag(
-                    'group_uuids[]',
-                    group.uuid,
-                    member_group_perms[group.uuid],
-                    disabled: (group.owner_uuid == @object.uuid),
-                    data: {
-                      permission_tail: group.uuid,
-                      permission_uuid: member_group_perms[group.uuid] || 'x'}) %>
-                <small>group&rarr;user</small>
-              </label>
-              <label class="checkbox-inline">
-                <%= group.name || '(unnamed)' %> <span class="deemphasize">(owned by <%= User.find?(group.owner_uuid).andand.full_name %>)</span>
-              </label>
-            </div>
-          <% end.empty? and begin %>
-            <div>
-              (No groups defined.)
-            </div>
-          <% end %>
-        </form>
-      </div>
-      <div class="panel-footer">
-        These groups (roles) can also be managed from the command line. For example:
-        <ul>
-          <li><code>arv group create \<br/>--group '{"group_class":"role","name":"New group"}'</code></li>
-          <li><code>arv group list \<br/>--filters '[["group_class","=","role"]]' \<br/>--select '["uuid","name"]'</code></li>
-          <li><code>arv edit <i>uuid</i></code></li>
-        </ul>
-      </div>
-    </div>
-  </div>
-</div>
-
-<div id="user-setup-modal-window" class="modal fade" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true"></div>
-<%= render partial: "add_group_modal" %>
diff --git a/apps/workbench/app/views/users/_ssh_keys.html.erb b/apps/workbench/app/views/users/_ssh_keys.html.erb
deleted file mode 100644 (file)
index fa26bc4..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="panel panel-default">
-  <div class="panel-heading">
-    <div class="pull-right">
-      <%= link_to raw('<i class="fa fa-plus"></i>' " Add new SSH key"), add_ssh_key_popup_url,
-                   {class: 'btn btn-xs btn-primary', :remote => true, 'data-toggle' =>  "modal",
-                    'data-target' => '#add-ssh-key-modal-window'}  %>
-    </div>
-    <h4 class="panel-title">
-      <%= link_to ssh_keys_user_path(current_user) do %>
-        SSH Keys
-      <%end%>
-    </h4>
-  </div>
-
-<div id="manage_ssh_keys" class="panel-body">
-  <% if !@my_ssh_keys.any? %>
-     <p> You have not yet set up an SSH public key for use with Arvados. <%= link_to "Learn more.",
-                  "#{Rails.configuration.Workbench.ArvadosDocsite}/user/getting_started/ssh-access-unix.html",
-                  style: "font-weight: bold",
-                  target: "_blank" %>
-     </p>
-     <p> When you have an SSH key you would like to use, add it using the <b>Add</b> button. </p>
-  <% else %>
-    <table class="table manage-ssh-keys-table">
-      <colgroup>
-        <col style="width: 35%" />
-        <col style="width: 55%" />
-        <col style="width: 10%" />
-      </colgroup>
-      <thead>
-        <tr>
-          <th> Name </th>
-          <th> Key Fingerprint </th>
-          <th> </th>
-        </tr>
-      </thead>
-      <tbody>
-        <% @my_ssh_keys.andand.each do |key| %>
-          <tr style="word-break:break-all;">
-            <td>
-              <%= key[:name] %>
-            </td>
-            <td style="word-break:break-all;">
-              <% if key[:public_key] && key[:public_key].size > 0 %>
-                <div>
-                  <span title="<%=key[:public_key]%>"> <%=
-                    begin
-                      SSHKey.fingerprint key[:public_key]
-                    rescue
-                      "INVALID KEY: " + key[:public_key]
-                    end
-                   %> </span>
-                </div>
-              <% else %>
-                  <%= key[:public_key] %>
-              <% end %>
-            </td>
-            <td>
-              <%= link_to(authorized_key_path(id: key[:uuid]), method: :delete, class: 'btn btn-sm', data: {confirm: "Really delete key?"}) do %>
-                  <i class="fa fa-fw fa-trash-o"></i>
-              <% end %>
-            </td>
-          </tr>
-        <% end %>
-      </tbody>
-    </table>
-  <% end %>
-</div>
-</div>
diff --git a/apps/workbench/app/views/users/_tables.html.erb b/apps/workbench/app/views/users/_tables.html.erb
deleted file mode 100644 (file)
index 6e3d9e3..0000000
+++ /dev/null
@@ -1,270 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if current_user.andand.is_active %>
-  <div>
-    <strong>Recent jobs</strong>
-    <%= link_to '(refresh)', {format: :js}, {class: 'refresh', remote: true} %>
-    <%= link_to raw("Show all jobs &rarr;"), jobs_path, class: 'pull-right' %>
-    <% if not current_user.andand.is_active or @my_jobs.empty? %>
-      <p>(None)</p>
-    <% else %>
-      <table class="table table-bordered table-condensed table-fixedlayout">
-        <colgroup>
-          <col width="20%" />
-          <col width="20%" />
-          <col width="20%" />
-          <col width="13%" />
-          <col width="13%" />
-          <col width="20%" />
-        </colgroup>
-
-        <tr>
-          <th>Script</th>
-          <th>Output</th>
-          <th>Log</th>
-          <th>Created at</th>
-          <th>Status</th>
-        </tr>
-
-        <%# Preload collections, logs, and pipeline instance objects %>
-        <%
-          collection_uuids = []
-          log_uuids = []
-          @my_jobs[0..6].each do |j|
-            collection_uuids << j.output
-            log_uuids << j.log
-          end
-
-          @my_collections[0..6].each do |c|
-            collection_uuids << c.uuid
-          end
-
-          preload_collections_for_objects collection_uuids
-          preload_log_collections_for_objects log_uuids
-
-          pi_uuids = []
-          @my_pipelines[0..6].each do |p|
-            pi_uuids << p.uuid
-          end
-          resource_class = resource_class_for_uuid(pi_uuids.first, friendly_name: true)
-          preload_objects_for_dataclass resource_class, pi_uuids
-        %>
-
-        <% @my_jobs[0..6].each do |j| %>
-          <tr data-object-uuid="<%= j.uuid %>">
-            <td>
-              <small>
-                <%= link_to((j.script.andand[0..31] || j.uuid), job_path(j.uuid)) %>
-              </small>
-            </td>
-
-            <td>
-              <small>
-                <% if j.state == "Complete" and j.output %>
-                  <a href="<%= collection_path(j.output) %>">
-                    <% collections = collections_for_object(j.output) %>
-                      <% if collections && !collections.empty? %>
-                      <% c = collections.first %>
-                      <% c.files.each do |file| %>
-                        <%= file[0] == '.' ? file[1] : "#{file[0]}/#{file[1]}" %>
-                      <% end %>
-                     <% end %>
-                  </a>
-              <% end %>
-            </small>
-          </td>
-
-<td>
-  <small>
-    <% if j.log %>
-      <% log_collections = log_collections_for_object(j.log) %>
-      <% if log_collections && !log_collections.empty? %>
-        <% c = log_collections.first %>
-        <% c.files.each do |file| %>
-          <a href="<%= collection_path(j.log) %>/<%= file[1] %>?disposition=inline&size=<%= file[2] %>">Log</a>
-        <% end %>
-      <% end %>
-    <% elsif j.respond_to? :log_buffer and j.log_buffer.is_a? String %>
-      <% buf = j.log_buffer.strip.split("\n").last %>
-      <span title="<%= buf %>"><%= buf %></span>
-    <% end %>
-  </small>
-</td>
-
-<td>
-  <small>
-    <%= j.created_at.to_s if j.created_at %>
-  </small>
-</td>
-
-<td>
-  <div class="inline-progress-container">
-  <%= render partial: 'job_progress', locals: {:j => j} %>
-  </div>
-</td>
-
-</tr>
-<% end %>
-</table>
-<% end %>
-</div>
-
-<div>
-  <strong>Recent pipeline instances</strong>
-  <%= link_to '(refresh)', {format: :js}, {class: 'refresh', remote: true} %>
-  <%= link_to raw("Show all pipeline instances &rarr;"), pipeline_instances_path, class: 'pull-right' %>
-  <% if not current_user.andand.is_active or @my_pipelines.empty? %>
-    <p>(None)</p>
-  <% else %>
-    <table class="table table-bordered table-condensed table-fixedlayout">
-      <colgroup>
-        <col width="30%" />
-        <col width="30%" />
-        <col width="13%" />
-        <col width="13%" />
-        <col width="20%" />
-      </colgroup>
-
-      <tr>
-        <th>Instance</th>
-        <th>Template</th>
-        <th>Created at</th>
-        <th>Status</th>
-        <th>Progress</th>
-      </tr>
-
-      <% @my_pipelines[0..6].each do |p| %>
-        <tr data-object-uuid="<%= p.uuid %>">
-          <td>
-            <small>
-              <%= link_to_if_arvados_object p.uuid, friendly_name: true %>
-            </small>
-          </td>
-
-          <td>
-            <small>
-              <%= link_to_if_arvados_object p.pipeline_template_uuid, friendly_name: true %>
-            </small>
-          </td>
-
-          <td>
-            <small>
-              <%= (p.created_at.to_s) if p.created_at %>
-            </small>
-          </td>
-
-          <td>
-            <%= render partial: 'pipeline_status_label', locals: {:p => p} %>
-          </td>
-
-          <td>
-            <div class="inline-progress-container">
-              <%= render partial: 'pipeline_progress', locals: {:p => p} %>
-            </div>
-          </td>
-        </tr>
-      <% end %>
-    </table>
-  <% end %>
-</div>
-
-<div>
-  <strong>Recent collections</strong>
-  <%= link_to '(refresh)', {format: :js}, {class: 'refresh', remote: true} %>
-  <%= link_to raw("Show all collections &rarr;"), collections_path, class: 'pull-right' %>
-  <div class="pull-right" style="padding-right: 1em; width: 30%;">
-    <%= form_tag collections_path,
-          method: 'get',
-          class: 'form-search small-form-margin' do %>
-    <div class="input-group input-group-sm">
-      <%= text_field_tag :search, params[:search], class: 'form-control', placeholder: 'Search' %>
-      <span class="input-group-btn">
-        <%= button_tag(class: 'btn btn-info') do %>
-        <span class="glyphicon glyphicon-search"></span>
-        <% end %>
-      </span>
-    </div>
-    <% end %>
-  </div>
-  <% if not current_user.andand.is_active or @my_collections.empty? %>
-    <p>(None)</p>
-  <% else %>
-    <table class="table table-bordered table-condensed table-fixedlayout">
-      <colgroup>
-        <col width="46%" />
-        <col width="32%" />
-        <col width="10%" />
-        <col width="12%" />
-      </colgroup>
-
-      <tr>
-        <th>Contents</th>
-        <th>Tags</th>
-        <th>Age</th>
-        <th>Storage</th>
-      </tr>
-
-      <% @my_collections[0..6].each do |c| %>
-        <tr data-object-uuid="<%= c.uuid %>">
-          <td>
-            <small>
-              <a href="<%= collection_path(c.uuid) %>">
-                <% c.files.each do |file| %>
-                  <%= file[0] == '.' ? file[1] : "#{file[0]}/#{file[1]}" %>
-                <% end %>
-              </a>
-            </small>
-          </td>
-          <td>
-            <% if @my_tag_links[c.uuid] %>
-            <small>
-              <%= @my_tag_links[c.uuid].collect(&:name).join(", ") %>
-            </small>
-            <% end %>
-          </td>
-          <td>
-            <small>
-              <%= c.created_at.to_s if c.created_at %>
-            </small>
-          </td>
-          <td>
-            <%= render partial: 'collections/toggle_persist', locals: { uuid: c.uuid, current_state: @persist_state[c.uuid] } %>
-          </td>
-        </tr>
-      <% end %>
-    </table>
-  <% end %>
-</div>
-
-<% else %>
-
-  <div class="row-fluid">
-    <div class="col-sm-4">
-      <%= image_tag "dax.png", style: "max-width:100%" %>
-    </div>
-    <div class="col-sm-8">
-      <h2>Welcome to Arvados, <%= current_user.first_name %>!</h2>
-      <div class="well">
-        <p>
-          Your account must be activated by an Arvados administrator.  If this
-          is your first time accessing Arvados and would like to request
-          access, or you believe you are seeing the page in error, please
-          <%= link_to "contact us", Rails.configuration.Workbench.ActivationContactLink %>.
-          You should receive an email at the address you used to log in when
-          your account is activated.  In the mean time, you can
-          <%= link_to "learn more about Arvados", "https://arvados.org/" %>,
-          and <%= link_to "read the Arvados user guide", "http://doc.arvados.org/user" %>.
-        </p>
-        <p style="padding-bottom: 1em">
-          <%= link_to raw('Contact us &#x2709;'),
-              Rails.configuration.Workbench.ActivationContactLink, class: "pull-right btn btn-primary" %></p>
-      </div>
-    </div>
-  </div>
-<% end %>
-
-<% content_for :js do %>
-setInterval(function(){$('a.refresh:eq(0)').click()}, 60000);
-<% end %>
diff --git a/apps/workbench/app/views/users/_virtual_machines.html.erb b/apps/workbench/app/views/users/_virtual_machines.html.erb
deleted file mode 100644 (file)
index 57b4d6a..0000000
+++ /dev/null
@@ -1,111 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="panel panel-default">
-  <div class="panel-heading">
-    <h4 class="panel-title">
-      <%= link_to virtual_machines_user_path(current_user) do %>
-        Virtual Machines
-      <% end %>
-
-    </h4>
-  </div>
-
-<div id="manage_virtual_machines" class="panel-body">
-  <p>
-    For more information see <%= link_to raw('Arvados Docs &rarr; User Guide &rarr; VM access'),
-  "#{Rails.configuration.Workbench.ArvadosDocsite}/user/getting_started/vm-login-with-webshell.html",
-  target: "_blank"%>.
-  </p>
-
-  <% if !@my_virtual_machines.any? %>
-    <div id="no_shell_access" class="no_shell_access">
-      <div class="alert alert-warning clearfix">
-        <p>
-          You do not have access to any virtual machines.  Some
-          Arvados features require using the command line.  You may
-          request access to a hosted virtual machine with the command
-          line shell.
-        </p>
-        <div class="pull-right">
-          <%= link_to({
-              action: 'request_shell_access',
-              controller: 'users',
-              id: current_user.uuid
-              },
-              method: :post,
-              remote: true,
-              class: 'btn btn-xs btn-primary',
-              data: {
-              disable_with: "Sending request...",
-              on_error_hide: '.no_shell_access .alert-success',
-              on_error_show: '.no_shell_access .alert-danger',
-              on_error_write: '.no_shell_access .alert-danger .error-text',
-              on_success_hide: '.no_shell_access .alert-danger',
-              }) do %>
-            Send request for shell access
-          <% end %>
-        </div>
-      </div>
-      <div class="alert alert-success" style="display:none">
-        <p class="contain-align-left"><%# (see javascripts/request_shell_access.js) %></p>
-      </div>
-      <div class="alert alert-danger" style="display:none">
-        <p class="contain-align-left">Sorry, something went wrong. Please try again. (<span class="error-text"></span>)</p>
-      </div>
-    </div>
-  <% else %>
-    <script> localStorage.removeItem('request_shell_access'); </script>
-    <table class="table virtual-machines-table">
-      <colgroup>
-        <col style="width: 25%" />
-        <col style="width: 25%" />
-        <col style="width: 50%" />
-      </colgroup>
-      <thead>
-        <tr>
-          <th> Host name </th>
-          <th> Login name </th>
-          <th> Command line </th>
-          <% if Rails.configuration.Services.WebShell.ExternalURL != URI("") %>
-            <th> Web shell</th>
-          <% end %>
-        </tr>
-      </thead>
-      <tbody>
-        <% @my_virtual_machines.andand.each do |vm| %>
-          <tr>
-            <td style="word-break:break-all;">
-              <%= vm[:hostname] %>
-            </td>
-            <td style="word-break:break-all;">
-              <%= @my_vm_logins[vm[:uuid]].andand.compact.andand.join(", ") %>
-            </td>
-            <td style="word-break:break-all;">
-              <% if @my_vm_logins[vm[:uuid]] %>
-                <% @my_vm_logins[vm[:uuid]].each do |login| %>
-                  <code>ssh&nbsp;<%= login %>@<%= vm[:hostname] %><%=Rails.configuration.Workbench.SSHHelpHostSuffix%></code>
-                <% end %>
-              <% end %>
-            </td>
-            <% if Rails.configuration.Services.WebShell.ExternalURL != URI("") %>
-              <td>
-                <% @my_vm_logins[vm[:uuid]].andand.each do |login| %>
-                  <%= link_to webshell_virtual_machine_path(vm, login: login), title: "Open a terminal session in your browser", class: 'btn btn-xs btn-default', target: "_blank" do %>
-                    Log in as <%= login %><br />
-                  <% end %>
-                <% end %>
-              </td>
-            <% end %>
-          </tr>
-        <% end %>
-      </tbody>
-    </table>
-  <% end %>
-</div>
-</div>
-
-<p>In order to access virtual machines using SSH, <%= link_to ssh_keys_user_path(current_user) do%>add an SSH key to your account<%end%>.</p>
-
-<%= raw(Rails.configuration.Workbench.SSHHelpPageHTML) %>
diff --git a/apps/workbench/app/views/users/activity.html.erb b/apps/workbench/app/views/users/activity.html.erb
deleted file mode 100644 (file)
index 64be1ea..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :css do %>
-table#users-activity-table th {
-    overflow-x: hidden;
-}
-table#users-activity-table .cell-for-span-This-month,
-table#users-activity-table .cell-for-span-Last-month {
-    background: #eee;
-}
-<% end %>
-<table class="table table-condensed arv-index" id="users-activity-table">
-  <colgroup>
-    <col width="28%" />
-  </colgroup>
-  <% @spans.each do |_| %>
-  <colgroup>
-    <% 3.times do %>
-    <col width="<%= (72 / @spans.count / 3).floor %>%" />
-    <% end %>
-  </colgroup>
-  <% end %>
-
-  <tr>
-    <th scope="col" rowspan="2">User</th>
-    <% @spans.each do |span, start_at, end_at| %>
-    <th scope="col" colspan="3" class="cell-for-span-<%= span.gsub ' ','-' %>">
-      <%= span %>
-      <br />
-      <%= start_at.strftime('%b %-d') %>
-      -
-      <%= (end_at-1.second).strftime('%b %-d') %>
-    </th>
-    <% end %>
-  </tr>
-  <tr>
-    <% @spans.each do |span, _| %>
-    <th scope="col" class="cell-for-span-<%= span.gsub ' ','-' %>">Logins</th>
-    <th scope="col" class="cell-for-span-<%= span.gsub ' ','-' %>">Jobs</th>
-    <th scope="col" class="cell-for-span-<%= span.gsub ' ','-' %>">Pipelines</th>
-    <% end %>
-  </tr>
-
-  <% @users.each do |user| %>
-  <tr>
-    <td>
-      <small>
-        <% if user.uuid %>
-        <%= link_to_if_arvados_object user, friendly_name: true %>
-        <% else %>
-        <b>Total</b>
-        <% end %>
-      </small>
-    </td>
-
-    <% @spans.each do |span, _| %>
-    <% ['logins', 'jobs', 'pipeline_instances'].each do |type| %>
-    <td class="cell-for-span-<%= span.gsub ' ','-' %>">
-      <small>
-        <%= @user_activity[user.uuid][span + " " + type].to_s %>
-      </small>
-    </td>
-    <% end %>
-    <% end %>
-  </tr>
-  <% end %>
-</table>
-
-<% content_for :footer_js do %>
-$('#users-activity-table td small').each(function(){
-  if ($(this).html().trim() == '0')
-    $(this).css('opacity', '0.3');
-});
-<% end %>
diff --git a/apps/workbench/app/views/users/add_ssh_key.js.erb b/apps/workbench/app/views/users/add_ssh_key.js.erb
deleted file mode 100644 (file)
index 42a6252..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-$("#add-ssh-key-modal-window").modal("hide");
-document.location.reload();
diff --git a/apps/workbench/app/views/users/add_ssh_key_popup.js.erb b/apps/workbench/app/views/users/add_ssh_key_popup.js.erb
deleted file mode 100644 (file)
index eba8960..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-$("#add-ssh-key-modal-window").html("<%= escape_javascript(render partial: 'add_ssh_key_popup') %>");
-
-// reset form input fields, for the next time around
-function reset_form() {
-  $('#name').val("");
-  $('#public_key').val("");
-  $('select').val('')
-}
diff --git a/apps/workbench/app/views/users/current_token.html.erb b/apps/workbench/app/views/users/current_token.html.erb
deleted file mode 100644 (file)
index 7ee81e3..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render :partial => 'current_token' %>
diff --git a/apps/workbench/app/views/users/home.html.erb b/apps/workbench/app/views/users/home.html.erb
deleted file mode 100644 (file)
index 8d212cd..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render :partial => 'home' %>
diff --git a/apps/workbench/app/views/users/home.js.erb b/apps/workbench/app/views/users/home.js.erb
deleted file mode 100644 (file)
index aedf947..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-var new_content = "<%= escape_javascript(render partial: 'tables') %>";
-if ($('div#home-tables').html() != new_content)
-   $('div#home-tables').html(new_content);
diff --git a/apps/workbench/app/views/users/inactive.html.erb b/apps/workbench/app/views/users/inactive.html.erb
deleted file mode 100644 (file)
index f3cb3cf..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :breadcrumbs do raw '<!-- -->' end %>
-
-<div class="row">
-  <div class="col-sm-8 col-sm-push-4" style="margin-top: 1em">
-    <div class="well clearfix">
-      <%= image_tag "dax.png", style: "width: 147px; height: 197px; max-width: 25%; margin-right: 2em", class: 'pull-left' %>
-
-      <h3>Hi! You're logged in, but...</h3>
-
-      <p>
-
-        Your account is inactive.
-
-      </p><p>
-
-        An administrator must activate your account before you can get
-        any further.
-
-      </p><p>
-
-        <%= link_to 'Retry', (params[:return_to] || '/'), class: 'btn btn-primary' %>
-
-      </p>
-
-      <p>
-       Already have an account with a different login?  <a href="/users/link_account">Link this login to your existing account.</a>
-      </p>
-
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/users/link_account.html.erb b/apps/workbench/app/views/users/link_account.html.erb
deleted file mode 100644 (file)
index e45073e..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= javascript_tag do %>
-  function update_visibility() {
-    if (sessionStorage.getItem('link_account_api_token') &&
-      sessionStorage.getItem('link_account_uuid') != '<%= Thread.current[:user].uuid %>')
-    {
-      $("#ready-to-link").css({"display": "inherit"});
-      $("#need-login").css({"display": "none"});
-
-      <% if params[:direction] == "in" %>
-      var user_a = "<b>"+sessionStorage.getItem('link_account_email')+"</b> ("+sessionStorage.getItem('link_account_username')+", "+sessionStorage.getItem('link_account_uuid')+")";
-      var user_b = "<b><%= Thread.current[:user].email %></b> (<%= Thread.current[:user].username%>, <%= Thread.current[:user].uuid%>)";
-      var user_a_is_active = (sessionStorage.getItem('link_account_is_active') == "true");
-      var user_a_is_admin = (sessionStorage.getItem('link_account_is_admin') == "true");
-      var user_b_is_admin = <%=if Thread.current[:user].is_admin then "true" else "false" end %>;
-      <% else %>
-      var user_a = "<b><%= Thread.current[:user].email %></b> (<%= Thread.current[:user].username%>, <%= Thread.current[:user].uuid%>)";
-      var user_b = "<b>"+sessionStorage.getItem('link_account_email')+"</b> ("+sessionStorage.getItem('link_account_username')+", "+sessionStorage.getItem('link_account_uuid')+")";
-      var user_a_is_active = <%= Thread.current[:user].is_active %>;
-      var user_a_is_admin = <%=if Thread.current[:user].is_admin then "true" else "false" end %>;
-      var user_b_is_admin = (sessionStorage.getItem('link_account_is_admin') == "true");
-      <% end %>
-
-      $("#new-user-token-input").val(sessionStorage.getItem('link_account_api_token'));
-
-      if (!user_a_is_active) {
-        $("#will-link-to").html("<p>Cannot link "+user_b+" to inactive account "+user_a+".</p>");
-        $("#link-account-submit").prop("disabled", true);
-      } else if (user_b_is_admin && !user_a_is_admin) {
-        $("#will-link-to").html("<p>Cannot link admin account "+user_b+" to non-admin account "+user_a+".</p>");
-        $("#link-account-submit").prop("disabled", true);
-      } else {
-        $("#will-link-to").html("<p>Clicking 'Link accounts' will link "+user_b+" created on <%=Thread.current[:user].created_at%> to "+
-          user_a+" created at <b>"+sessionStorage.getItem('link_account_created_at')+"</b>.</p>"+
-          "<p>After linking, logging in as "+user_b+" will log you into the same account as "+user_a+
-          ".</p>  <p>Any objects owned by "+user_b+" will be transferred to "+user_a+".</p>");
-      }
-    } else {
-      $("#ready-to-link").css({"display": "none"});
-      $("#need-login").css({"display": "inherit"});
-    }
-
-    sessionStorage.removeItem('link_account_api_token');
-    sessionStorage.removeItem('link_account_uuid');
-    sessionStorage.removeItem('link_account_email');
-    sessionStorage.removeItem('link_account_username');
-    sessionStorage.removeItem('link_account_created_at');
-    sessionStorage.removeItem('link_account_is_active');
-    sessionStorage.removeItem('link_account_is_admin');
-  };
-
-  $(window).on("load", function() {
-    update_visibility();
-  });
-
-  function do_login(dir) {
-    sessionStorage.setItem('link_account_api_token', '<%= Thread.current[:arvados_api_token] %>');
-    sessionStorage.setItem('link_account_email', '<%= Thread.current[:user].email %>');
-    sessionStorage.setItem('link_account_username', '<%= Thread.current[:user].username %>');
-    sessionStorage.setItem('link_account_uuid', '<%= Thread.current[:user].uuid %>');
-    sessionStorage.setItem('link_account_created_at', '<%= Thread.current[:user].created_at %>');
-    sessionStorage.setItem('link_account_is_active', <%= if Thread.current[:user].is_active then "true" else "false" end %>);
-    sessionStorage.setItem('link_account_is_admin', <%= if Thread.current[:user].is_admin then "true" else "false" end %>);
-    window.location.replace('<%=arvados_api_client.arvados_logout_url(return_to: arvados_api_client.arvados_login_url(return_to: "#{strip_token_from_path(request.url)}?direction="))%>'+dir);
-  }
-
-  $(document).on("click", "#link-account-in", function(e) { do_login("in"); });
-  $(document).on("click", "#link-account-out", function(e) { do_login("out"); });
-
-  $(document).on("click", "#cancel-link-accounts", function() {
-    window.location.replace('/users/link_account?api_token='+$("#new-user-token-input").val());
-  });
-<% end %>
-
-<% if Rails.configuration.Login.LoginCluster.empty? %>
-
-<div id="need-login" style="display: none">
-
-  <p>You are currently logged in as <b><%= Thread.current[:user].email %></b> (<%= Thread.current[:user].username%>, <%= Thread.current[:user].uuid %>) created at <b><%= Thread.current[:user].created_at%></b></p>
-
-<p>You can link Arvados accounts.  After linking, either login will take you to the same account.</p>
-
-  <p>
-    <% if Thread.current[:user].is_active %>
-  <button class="btn btn-primary" id="link-account-in" style="margin-right: 1em">
-    <i class="fa fa-fw fa-sign-in"></i> Add another login to this account
-  </button>
-  <% end %>
-  <button class="btn btn-primary" id="link-account-out" style="margin-right: 1em">
-    <i class="fa fa-fw fa-sign-in"></i> Use this login to access another account
-  </button>
-
-  </p>
-</div>
-
-<div id="ready-to-link" style="display: none">
-
-  <div id="will-link-to"></div>
-
-  <%= button_tag "Cancel", class: "btn btn-cancel pull-left", id: "cancel-link-accounts", style: "margin-right: 1em" %>
-
-  <%= form_tag do |f| %>
-    <input type="hidden" id="new-user-token-input" name="new_user_token" value="" />
-    <input type="hidden" id="new-user-token-input" name="direction" value="<%=params[:direction]%>" />
-    <%= button_tag class: "btn btn-primary", id: "link-account-submit" do %>
-      <i class="fa fa-fw fa-link"></i> Link accounts
-    <% end %>
-  <% end %>
-
-</div>
-
-<% else %>
-<div>
-Self-serve account linking is not supported on this cluster. Please contact your Arvados administrator.
-</div>
-<% end %>
diff --git a/apps/workbench/app/views/users/profile.html.erb b/apps/workbench/app/views/users/profile.html.erb
deleted file mode 100644 (file)
index caa22bd..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-    profile_config = []
-    Rails.configuration.Workbench.UserProfileFormFields.each do |k, v|
-      r = v.dup
-      r["Key"] = k
-      profile_config << r
-    end
-    profile_config.sort_by! { |v| v["Position"] }
-
-    current_user_profile = current_user.prefs[:profile]
-    show_save_button = false
-
-    profile_message = Rails.configuration.Workbench.UserProfileFormMessage
-%>
-
-<div>
-    <div class="panel panel-default">
-        <div class="panel-heading">
-          <h4 class="panel-title">
-            Profile
-          </h4>
-        </div>
-        <div class="panel-body">
-          <% if !missing_required_profile? && params[:offer_return_to] %>
-            <div class="alert alert-success">
-              <% if current_user.prefs[:getting_started_shown] %>
-                <p>Thank you for filling in your profile. <%= link_to 'Back to work!', params[:offer_return_to], class: 'btn btn-sm btn-primary' %></p>
-              <% else %>
-                <p>Thank you for filling in your profile. <%= link_to 'Get started', params[:offer_return_to], class: 'btn btn-sm btn-primary' %></p>
-              <% end %>
-            </div>
-          <% else %>
-            <div class="alert alert-info">
-              <p><%=raw(profile_message)%></p>
-            </div>
-          <% end %>
-
-            <%= form_for current_user, html: {id: 'save_profile_form', name: 'save_profile_form', class: 'form-horizontal'} do %>
-              <%= hidden_field_tag :offer_return_to, params[:offer_return_to] %>
-              <%= hidden_field_tag :return_to, profile_user_path(current_user.uuid, offer_return_to: params[:offer_return_to]) %>
-              <div class="form-group">
-                  <label for="email" class="col-sm-3 control-label"> E-mail </label>
-                  <div class="col-sm-8">
-                    <p class="form-control-static" id="email" name="email"><%=current_user.email%></p>
-                  </div>
-              </div>
-              <div class="form-group">
-                  <label for="first_name" class="col-sm-3 control-label"> First Name </label>
-                  <div class="col-sm-8">
-                    <p class="form-control-static" id="first_name" name="first_name"><%=current_user.first_name%></p>
-                  </div>
-              </div>
-              <div class="form-group">
-                  <label for="last_name" class="col-sm-3 control-label"> Last Name </label>
-                  <div class="col-sm-8">
-                    <p class="form-control-static" id="last_name" name="last_name"><%=current_user.last_name%></p>
-                  </div>
-              </div>
-              <div class="form-group">
-                  <label for="identity_url" class="col-sm-3 control-label"> Identity URL </label>
-                  <div class="col-sm-8">
-                    <p class="form-control-static" id="identity_url" name="identity_url"><%=current_user.andand.identity_url%></p>
-                  </div>
-              </div>
-
-              <% profile_config.kind_of?(Array) && profile_config.andand.each do |entry| %>
-                <% if entry[:Key] %>
-                  <%
-                      show_save_button = true
-                      label = entry[:Required] ? '* ' : ''
-                      label += entry[:FormFieldTitle]
-                      value = current_user_profile[entry[:Key].to_sym] if current_user_profile
-                  %>
-                  <div class="form-group">
-                    <label for="<%=entry[:Key]%>"
-                           class="col-sm-3 control-label"
-                           style=<%="color:red" if entry[:Required]&&(!value||value.empty?)%>> <%=label%>
-                    </label>
-                    <% if entry[:Type] == 'select' %>
-                      <div class="col-sm-8">
-                        <select class="form-control" name="user[prefs][profile][<%=entry[:Key]%>]">
-                          <% entry[:Options].each do |option, _| %>
-                           <% option = option.to_s %>
-                            <option value="<%=option%>" <%='selected' if option==value%>><%=option%></option>
-                          <% end %>
-                        </select>
-                      </div>
-                    <% else %>
-                      <div class="col-sm-8">
-                        <input type="text" class="form-control" name="user[prefs][profile][<%=entry[:Key]%>]" placeholder="<%=entry[:FormFieldDescription]%>" value="<%=value%>" ></input>
-                      </div>
-                    <% end %>
-                  </div>
-                <% end %>
-              <% end %>
-
-              <%# If the user has other prefs, we need to preserve them %>
-              <% current_user.prefs.each do |key, value| %>
-                <% if key != :profile %>
-                  <input type="hidden" name="user[prefs][<%=key%>]" value="<%=value.to_json%>">
-                <% end %>
-              <% end %>
-
-              <% if show_save_button %>
-                <div class="form-group">
-                  <div class="col-sm-offset-3 col-sm-8">
-                    <button type="submit" class="btn btn-primary">Save profile</button>
-                  </div>
-                </div>
-              <% end %>
-            <% end %>
-        </div>
-    </div>
-</div>
diff --git a/apps/workbench/app/views/users/request_shell_access.js b/apps/workbench/app/views/users/request_shell_access.js
deleted file mode 100644 (file)
index 9a20ace..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-var timestamp = new Date();
-localStorage.setItem("request_shell_access",
-                     "A request for shell access was sent on " +
-                     timestamp.toLocaleDateString() +
-                     " at " +
-                     timestamp.toLocaleTimeString());
-// The storage event gets triggered automatically in _other_ windows
-// when we hit localStorage, but we also need to fire it manually in
-// _this_ window.
-$(document).trigger('storage');
diff --git a/apps/workbench/app/views/users/setup.js.erb b/apps/workbench/app/views/users/setup.js.erb
deleted file mode 100644 (file)
index 6032dfd..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-$("#user-setup-modal-window").modal("hide");
-document.location.reload();
diff --git a/apps/workbench/app/views/users/setup_popup.js.erb b/apps/workbench/app/views/users/setup_popup.js.erb
deleted file mode 100644 (file)
index 0a98719..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-$("#user-setup-modal-window").html("<%= escape_javascript(render partial: 'setup_popup') %>");
-
-// disable the submit button on load
-var $input = $('input:text'),
-$register = $('#register');
-
-var email_disabled = document.forms["setup_form"]["email"].disabled;
-var email_value = document.forms["setup_form"]["email"].value;
-var prefix_value = document.forms["setup_form"]["openid_prefix"].value;
-if ((email_disabled == false) && (email_value == null || email_value == "" ||
-        prefix_value == null || prefix_value == "")) {
-  $register.prop('disabled', true);
-}
-
-// capture events to enable submit button when applicable
-$input.on('keyup paste mouseleave', function() {
-  var trigger = false;
-
-  var email_disabled = document.forms["setup_form"]["email"].disabled;
-  var email_value = document.forms["setup_form"]["email"].value;
-  var prefix_value = document.forms["setup_form"]["openid_prefix"].value;
-
-  var emailRegExp = /^([\w-\.]+@([\w-]+\.)+[\w-]{2,4})?$/;
-  var validEmail = false;
-
-  if (emailRegExp.test(email_value )) {
-    validEmail = true;
-  }
-
-  if ((email_disabled == false) && (!validEmail || email_value == null ||
-            email_value == "" || prefix_value == null || prefix_value == "")){
-    trigger = true;
-  }
-
-  $register.prop('disabled', trigger);
-});
-
-// reset form input fields, for the next time around
-function reset_form() {
-  $('#email').val("");
-  $('#openid_prefix').val("");
-  $('#repo_name').val("");
-  $('select').val('')
-}
diff --git a/apps/workbench/app/views/users/ssh_keys.html.erb b/apps/workbench/app/views/users/ssh_keys.html.erb
deleted file mode 100644 (file)
index d4a1ba4..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render :partial => 'ssh_keys' %>
-<div id="add-ssh-key-modal-window" class="modal fade" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true"></div>
diff --git a/apps/workbench/app/views/users/storage.html.erb b/apps/workbench/app/views/users/storage.html.erb
deleted file mode 100644 (file)
index 2a5265c..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :css do %>
-table#users-storage-table th {
-    overflow-x: hidden;
-    text-align: center;
-}
-table#users-storage-table .byte-value {
-    text-align: right;
-}
-<% end %>
-<table class="table table-condensed arv-index" id="users-storage-table">
-  <colgroup>
-    <col />
-  </colgroup>
-
-  <tr>
-    <th scope="col" rowspan="2">User</th>
-    <th scope="col" colspan="2">
-      Collections Read Size
-    </th>
-    <th scope="col" colspan="2">
-      Collections Persisted Storage
-    </th>
-    <th scope="col" rowspan="2">Measured At</th>
-  </tr>
-  <tr>
-    <% 2.times do %>
-    <th scope="col" class="byte-value">
-      Total (unweighted)
-    </th>
-    <th scope="col" class="byte-value">
-      Shared (weighted)
-    </th>
-    <% end %>
-  </tr>
-
-  <% @users.each do |user| %>
-  <tr>
-    <td>
-      <% if user.uuid %>
-      <small>
-        <%= link_to_if_arvados_object user, friendly_name: true %>
-      </small>
-      <% else %>
-      <b>Total</b>
-      <% end %>
-    </td>
-    <% [:read_collections_total_bytes, :read_collections_weighted_bytes, :persisted_collections_total_bytes, :persisted_collections_weighted_bytes].each do |key| %>
-    <td class="byte-value">
-      <%= human_readable_bytes_html(@user_storage[user.uuid].fetch(key,0).floor) %>
-    </td>
-    <% end %>
-    <% if @log_date.key?(user.uuid) %>
-    <td class="date" title="<%= @log_date[user.uuid] %>">
-      <%= @log_date[user.uuid].strftime('%F') %>
-    </td>
-    <% end %>
-  </tr>
-  <% end %>
-</table>
-
-<% content_for :footer_js do %>
-$('#users-storage-table td small').each(function(){
-  if ($(this).html().trim() == '0')
-    $(this).css('opacity', '0.3');
-});
-<% end %>
diff --git a/apps/workbench/app/views/users/virtual_machines.html.erb b/apps/workbench/app/views/users/virtual_machines.html.erb
deleted file mode 100644 (file)
index 3133f1b..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render :partial => 'virtual_machines' %>
diff --git a/apps/workbench/app/views/users/welcome.html.erb b/apps/workbench/app/views/users/welcome.html.erb
deleted file mode 100644 (file)
index 69009a0..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :breadcrumbs do raw '<!-- -->' end %>
-
-<%= javascript_tag do %>
-      function controller_password_authenticate(event) {
-        event.preventDefault()
-        document.getElementById('login-authenticate-error').innerHTML = '';
-        fetch('<%= "#{Rails.configuration.Services.Controller.ExternalURL}" %>arvados/v1/users/authenticate', {
-          method: 'POST',
-
-          headers: {'Content-Type': 'application/json'},
-          body: JSON.stringify({
-            username: document.getElementById('login-username').value,
-            password: document.getElementById('login-password').value,
-          }),
-        }).then(function(resp) {
-          if (!resp.ok) {
-            resp.json().then(function(respj) {
-              document.getElementById('login-authenticate-error').innerHTML = "<p>"+respj.errors[0]+"</p>";
-            });
-            return;
-           }
-
-           var redir = document.getElementById('login-return-to').value
-           if (redir.indexOf('?') > 0) {
-             redir += '&'
-           } else {
-             redir += '?'
-           }
-           resp.json().then(function(respj) {
-             document.location = redir + "api_token=v2/" + respj.uuid + "/" + respj.api_token;
-           });
-         });
-      }
-      function clear_authenticate_error() {
-        document.getElementById('login-authenticate-error').innerHTML = "";
-      }
-<% end %>
-
-<div class="row">
-  <div class="col-sm-8 col-sm-push-4" style="margin-top: 1em">
-    <div class="well clearfix">
-
-      <%= raw(Rails.configuration.Workbench.WelcomePageHTML) %>
-
-      <% case %>
-      <% when Rails.configuration.Login.PAM.Enable,
-              Rails.configuration.Login.LDAP.Enable,
-              Rails.configuration.Login.Test.Enable %>
-        <form id="login-form-tag" onsubmit="controller_password_authenticate(event)">
-          <p>username <input type="text" class="form-control" name="login-username"
-                            value="" id="login-username" style="width: 50%"
-                            oninput="clear_authenticate_error()"></input></p>
-          <p>password <input type="password" class="form-control" name="login-password" value=""
-                            id="login-password" style="width: 50%"
-                            oninput="clear_authenticate_error()"></input></p>
-        <input type="hidden" name="return_to" value="<%= params[:return_to] || "#{Rails.configuration.Services.Workbench1.ExternalURL}" %>" id="login-return-to">
-        <span style="color: red"><p id="login-authenticate-error"></p></span>
-        <button type="submit" class="btn btn-primary">Log in</button>
-        </form>
-      <% else %>
-        <div class="pull-right">
-          <%= link_to arvados_api_client.arvados_login_url(return_to: request.url), class: "btn btn-primary" do %>
-          Log in to <%= Rails.configuration.Workbench.SiteName %>
-          <i class="fa fa-fw fa-arrow-circle-right"></i>
-          <% end %>
-        </div>
-      <% end %>
-
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/virtual_machines/_show_help.html.erb b/apps/workbench/app/views/virtual_machines/_show_help.html.erb
deleted file mode 100644 (file)
index daf7ba5..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= raw(Rails.configuration.Workbench.SSHHelpPageHTML) %>
diff --git a/apps/workbench/app/views/virtual_machines/webshell.html.erb b/apps/workbench/app/views/virtual_machines/webshell.html.erb
deleted file mode 100644 (file)
index d4f2cd0..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <title><%= @object.hostname %> / <%= Rails.configuration.Workbench.SiteName %></title>
-    <link rel="stylesheet" href="<%= asset_path 'webshell/styles.css' %>" type="text/css">
-    <style type="text/css">
-      body {
-        margin: 0px;
-      }
-    </style>
-    <script type="text/javascript"><!--
-      (function() {
-        // We would like to hide overflowing lines as this can lead to
-        // visually jarring results if the browser substitutes oversized
-        // Unicode characters from different fonts. Unfortunately, a bug
-        // in Firefox prevents it from allowing multi-line text
-        // selections whenever we change the "overflow" style. So, only
-        // do so for non-Netscape browsers.
-        if (typeof navigator.appName == 'undefined' ||
-            navigator.appName != 'Netscape') {
-          document.write('<style type="text/css">' +
-                         '#vt100 #console div, #vt100 #alt_console div {' +
-                         '  overflow: hidden;' +
-                         '}' +
-                         '</style>');
-        }
-      })();
-
-      function login(username, token) {
-        var sh = new ShellInABox("<%= j @webshell_url %>");
-
-        var findText = function(txt) {
-          var a = document.querySelectorAll("span.ansi0");
-          for (var i = 0; i < a.length; i++) {
-            if (a[i].textContent.indexOf(txt) > -1) {
-              return true;
-            }
-          }
-          return false;
-        }
-
-        var trySendToken = function() {
-          // change this text when PAM is reconfigured to present a
-          // password prompt that we can wait for.
-          if (findText("assword:")) {
-             sh.keysPressed("<%= j Thread.current[:arvados_api_token] %>\n");
-             sh.vt100('(sent authentication token)\n');
-          } else {
-            setTimeout(trySendToken, 200);
-          }
-        };
-
-        var trySendLogin = function() {
-          if (findText("login:")) {
-            sh.keysPressed("<%= j params[:login] %>\n");
-            // Make this wait shorter when PAM is reconfigured to
-            // present a password prompt that we can wait for.
-            setTimeout(trySendToken, 200);
-          } else {
-            setTimeout(trySendLogin, 200);
-          }
-        };
-
-        trySendLogin();
-      }
-    // -->
-</script>
-    <script type="text/javascript" src="<%= asset_path 'webshell/shell_in_a_box.js' %>"></script>
-  </head>
-  <!-- Load ShellInABox from a timer as Konqueror sometimes fails to
-       correctly deal with the enclosing frameset (if any), if we do not
-       do this
-   -->
-<body onload="setTimeout(login, 1000)"
-    scroll="no"><noscript>JavaScript must be enabled for ShellInABox</noscript>
-</body>
-</html>
diff --git a/apps/workbench/app/views/websocket/index.html.erb b/apps/workbench/app/views/websocket/index.html.erb
deleted file mode 100644 (file)
index 6274fb0..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% content_for :page_title do %>
-  Event bus debugging page
-<% end %>
-<h1>Event bus debugging page</h1>
-
-<form>
-<textarea style="width:100%; height: 10em" id="websocket-message-content"></textarea>
-<button type="button" id="send-to-websocket">Send</button>
-</form>
-
-<br>
-
-<p id="PutStuffHere"></p>
-
-<script>
-$(function() {
-putStuffThere = function (content) {
-  $("#PutStuffHere").append(content + "<br>");
-};
-
-var dispatcher = new WebSocket('<%= arvados_api_client.discovery[:websocketUrl] %>?api_token=<%= Thread.current[:arvados_api_token] %>');
-dispatcher.onmessage = function(event) {
-  //putStuffThere(JSON.parse(event.data));
-  putStuffThere(event.data);
-};
-
-sendStuff = function () {
-  dispatcher.send($("#websocket-message-content").val());
-};
-
-$("#send-to-websocket").click(sendStuff);
-});
-
-</script>
diff --git a/apps/workbench/app/views/work_units/_component_detail.html.erb b/apps/workbench/app/views/work_units/_component_detail.html.erb
deleted file mode 100644 (file)
index e48a91e..0000000
+++ /dev/null
@@ -1,220 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-  collections = [current_obj.outputs, current_obj.docker_image].flatten.compact.uniq
-  collections_pdhs = collections.select {|x| !CollectionsHelper.match(x).nil?}.uniq.compact
-  collections_uuids = collections - collections_pdhs
-  preload_collections_for_objects collections_uuids if collections_uuids.any?
-  preload_links_for_objects collections_uuids if collections_uuids.any?
-
-  preload_objects_for_dataclass(Repository, [current_obj.repository], :name) if current_obj.repository
-
-  # if container_X, preload mounted collections
-  if @object.is_a? Container or @object.is_a? ContainerRequest
-    # get any collections in mounts
-    mounts = current_obj.send(:mounts) if current_obj.respond_to?(:mounts)
-    input_obj = mounts.andand[:"/var/lib/cwl/cwl.input.json"].andand[:content]
-    if input_obj
-      input_obj.to_s.scan(/([0-9a-f]{32}\+\d+)/).each {|cs| collections_pdhs += cs}
-    end
-
-    command = current_obj.send(:command) if current_obj.respond_to?(:command)
-    if command
-      command.to_s.scan(/([0-9a-f]{32}\+\d+)/).each {|cs| collections_pdhs += cs}
-    end
-  end
-
-  collections_pdhs.compact.uniq
-  preload_for_pdhs collections_pdhs if collections_pdhs.any?
-  preload_links_for_objects collections_pdhs if collections_pdhs.any?
-%>
-
-      <div class="container">
-        <div class="row">
-          <div class="col-md-6" style="overflow-x: auto">
-            <% if current_obj.uuid.nil? %>
-              No <%= current_obj.title %> has been submitted yet.
-            <% else %>
-            <table class="table table-condensed">
-              <% keys = [:uuid, :modified_by_user_uuid, :created_at, :started_at, :finished_at, :container_uuid] %>
-              <% keys << :log_collection if @object.uuid != current_obj.uuid %>
-              <% keys << :outputs %>
-              <% keys.each do |k| %>
-                <%
-                  val = current_obj.send(k) if current_obj.respond_to?(k)
-                  if k == :outputs
-                    has_val = val.andand.any?
-                  elsif k == :log_collection and current_obj.state_label == "Running"
-                    has_val = true
-                  else
-                    has_val = val
-                  end
-                %>
-                <% if has_val %>
-                <tr>
-                  <td style="padding-right: 1em">
-                    <%= k.to_s %>:
-                  </td>
-                  <td>
-                    <% if k == :uuid %>
-                      <%= link_to_arvados_object_if_readable(val, val, link_text: val) %>
-                    <% elsif k.to_s.end_with? 'uuid' %>
-                      <%= link_to_arvados_object_if_readable(val, val, friendly_name: true) %>
-                    <% elsif k.to_s.end_with? '_at' %>
-                      <%= render_localized_date(val) %>
-                    <% elsif k == :outputs and val.any? %>
-                      <% if val.size == 1 %>
-                        <%= link_to_arvados_object_if_readable(val[0], "#{val[0]} (Unavailable)", friendly_name: true) %>
-                      <% else %>
-                        <%= render partial: 'work_units/show_outputs', locals: {id: current_obj.uuid, outputs: val, align:""} %>
-                      <% end %>
-                    <% elsif k == :log_collection %>
-                      <%= render partial: 'work_units/show_log_link', locals: {wu: current_obj} %>
-                    <% else %>
-                      <%= val %>
-                    <% end %>
-                  </td>
-                </tr>
-                <% end %>
-              <% end %>
-            </table>
-            <% end %>
-          </div>
-          <div class="col-md-6">
-            <table class="table table-condensed">
-              <% # link to repo tree/file only if the repo is readable and the commit is a sha1
-                 repo = (/^[0-9a-f]{40}$/ =~ current_obj.script_version and
-                         current_obj.repository and
-                         object_for_dataclass(Repository, current_obj.repository, :name))
-                 repo = nil unless repo.andand.http_fetch_url
-                 %>
-              <% [:script, :repository, :script_version, :supplied_script_version, :nondeterministic,
-                  :priority, :runtime_constraints, :requesting_container_uuid].each do |k| %>
-                <% val = current_obj.send(k) if current_obj.respond_to?(k) %>
-                <% if val %>
-                <tr valign="top">
-                  <td style="padding-right: 1em">
-                    <%= k.to_s %>:
-                  </td>
-                  <td>
-                    <% if repo and k == :repository %>
-                      <%= link_to val, show_repository_tree_path(id: repo.uuid, commit: current_obj.script_version, path: '/') %>
-                    <% elsif repo and k == :script %>
-                      <%= link_to val, show_repository_blob_path(id: repo.uuid, commit: current_obj.script_version, path: 'crunch_scripts/'+current_obj.script) %>
-                    <% elsif repo and k == :script_version %>
-                      <%= link_to val, show_repository_commit_path(id: repo.uuid, commit: current_obj.script_version) %>
-                    <% elsif k == :runtime_constraints and val.any? %>
-                      <%= render partial: 'work_units/show_table_data', locals: {id: current_obj.uuid, name: k, data_map: val} %>
-                    <% elsif k.to_s.end_with? 'uuid' %>
-                      <%= link_to_arvados_object_if_readable(val, val, friendly_name: true) %>
-                    <% else %>
-                      <%= val %>
-                    <% end %>
-                  </td>
-                </tr>
-                <% end %>
-              <% end %>
-
-              <%
-                mounts = current_obj.send(:mounts) if current_obj.respond_to?(:mounts)
-                mount_wf = mounts.andand[:"/var/lib/cwl/workflow.json"]
-                mount_wf = mount_wf[5..-1] if mount_wf.andand.is_a?(String) and mount_wf.start_with?('keep:')
-                mount_wf_cls = resource_class_for_uuid(mount_wf) if mount_wf
-              %>
-              <tr>
-                <% if mount_wf_cls == Collection %>
-                  <td style="padding-right: 1em">
-                    workflow.json:
-                  </td>
-                  <td>
-                    <%= link_to_if_arvados_object mount_wf, friendly_name: true %>
-                  </td>
-                <% end %>
-              </tr>
-
-              <% if current_obj.runtime_constraints.andand[:docker_image] and current_obj.docker_image %>
-                <tr>
-                  <td style="padding-right: 1em">
-                    docker_image:
-                  </td>
-                  <td>
-                    <%= current_obj.runtime_constraints[:docker_image] %>
-                  </td>
-                </tr>
-                <tr>
-                  <td style="padding-right: 1em">
-                    docker_image_locator:
-                  </td>
-                  <td>
-                    <%= link_to_arvados_object_if_readable(current_obj.docker_image,
-                      current_obj.docker_image, friendly_name: true) %>
-                  </td>
-                </tr>
-              <% elsif current_obj.docker_image %>
-                <tr>
-                  <td style="padding-right: 1em">
-                    docker_image_locator:
-                  </td>
-                  <td>
-                    <%= link_to_arvados_object_if_readable(current_obj.docker_image,
-                      current_obj.docker_image, friendly_name: true) %>
-                  </td>
-                </tr>
-              <% end %>
-            </table>
-          </div>
-
-          <div class="col-md-12">
-            <table class="table table-condensed" style="table-layout:fixed;">
-              <col width="15%" />
-              <col width="85%" />
-              <% [:command].each do |k| %>
-                <% val = current_obj.send(k) if current_obj.respond_to?(k) %>
-                <% if val %>
-                <tr>
-                  <td valign="top">
-                    <%= k.to_s %>:
-                  </td>
-                  <td style="word-wrap: break-all;">
-                    <% if k == :command %>
-                        <% val = JSON.pretty_generate(val) %>
-                        <%= render partial: 'show_text_with_locators', locals: {data_height: 200, text_data: val} %>
-                    <% else %>
-                      <%= val %>
-                    <% end %>
-                  </td>
-                </tr>
-                <% end %>
-              <% end %>
-
-              <%
-                mounts = current_obj.send(:mounts) if current_obj.respond_to?(:mounts)
-                input_obj = mounts.andand[:"/var/lib/cwl/cwl.input.json"].andand[:content]
-                mnt_inputs = JSON.pretty_generate(input_obj) if input_obj
-              %>
-              <% if mnt_inputs %>
-                <tr>
-                  <td valign="top">
-                    cwl.input.json:
-                  </td>
-                  <td style="word-wrap: break-all;">
-                    <%= render partial: 'show_text_with_locators', locals: {data_height: 400, text_data: mnt_inputs} %>
-                  </td>
-                </tr>
-              <% end %>
-            </table>
-          </div>
-
-        </div>
-
-        <% if current_obj.parameters and !current_obj.parameters.empty? %>
-        <div class="row">
-          <div class="col-md-12">
-            <p>script_parameters:</p>
-            <pre><%= JSON.pretty_generate(current_obj.parameters) rescue nil %></pre>
-          </div>
-        </div>
-        <% end %>
-      </div>
diff --git a/apps/workbench/app/views/work_units/_progress.html.erb b/apps/workbench/app/views/work_units/_progress.html.erb
deleted file mode 100644 (file)
index bfc5100..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if wu.is_running? %>
-  <% if @object.andand.uuid == wu.uuid and wu.progress == 0.0 %>
-    <span class="label label-<%= wu.state_bootstrap_class %>"> Active </span>
-  <% else%>
-    <div class="progress" style="margin-bottom: 0px">
-      <span class="progress-bar progress-bar-<%= wu.state_bootstrap_class %>" style="width: <%= wu.progress*100 %>%;">
-      </span>
-    </div>
-  <% end %>
-<% else %>
-  <span class="label label-<%= wu.state_bootstrap_class %>"><%= wu.state_label %></span>
-<% end %>
diff --git a/apps/workbench/app/views/work_units/_show_all_processes.html.erb b/apps/workbench/app/views/work_units/_show_all_processes.html.erb
deleted file mode 100644 (file)
index 0d6d831..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="pull-right">
-  <div class="form-group">
-    <input type="text" class="form-control filterable-control recent-all-processes-filterable-control"
-           placeholder="Search all processes"
-           data-filterable-target="#all-processes-scroll"
-           value="<%= params[:search] %>" size="40" />
-  </div>
-  <div class="checkbox">
-    <label>
-      <input id="IncludeChildProcs" type="checkbox" class="filterable-control"
-            data-on-value="{&quot;show_children&quot;:true}"
-            data-off-value="{}"
-            data-filterable-target="#all-processes-scroll" />
-      Show child processes
-    </label>
-  </div>
-</div>
-
-<div>
-  <div>
-    <div>
-      <table class="table table-condensed table-fixedlayout arv-recent-all-processes">
-        <colgroup>
-          <col width="25%" />
-          <col width="10%" />
-          <col width="20%" />
-          <col width="20%" />
-          <col width="20%" />
-          <col width="5%" />
-        </colgroup>
-
-        <thead>
-          <tr class="contain-align-left">
-            <th>
-              Process
-            </th>
-            <th>
-              Status
-            </th>
-            <th>
-              Owner
-            </th>
-            <th>
-              Created at
-            </th>
-            <th>
-              Output
-            </th>
-            <th>
-            </th>
-          </tr>
-        </thead>
-
-        <tbody data-infinite-scroller="#all-processes-scroll" id="all-processes-scroll"
-               data-infinite-content-params-from-exclude-child-procs="{}"
-               data-infinite-content-href="<%= url_for partial: :all_processes_rows %>" >
-        </tbody>
-      </table>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/work_units/_show_all_processes_rows.html.erb b/apps/workbench/app/views/work_units/_show_all_processes_rows.html.erb
deleted file mode 100644 (file)
index b0afb33..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% @objects.each do |obj| %>
-  <% wu = obj.work_unit %>
-  <tr data-object-uuid="<%= wu.uuid %>" >
-    <td>
-      <%= link_to_if_arvados_object obj, friendly_name: true %>
-    </td>
-    <td>
-      <span class="label label-<%= wu.state_bootstrap_class %>"><%= wu.state_label %></span>
-    </td>
-    <td>
-      <%= link_to_if_arvados_object wu.owner_uuid, friendly_name: true %>
-    </td>
-    <td>
-      <%= render_localized_date(wu.created_at) %>
-    </td>
-    <td>
-      <%= render partial: 'work_units/show_output', locals: {wu: wu, align: ''} %>
-    </td>
-    <td>
-      <%= render partial: 'delete_object_button', locals: {object:obj} %>
-    </td>
-  </tr>
-<% end %>
diff --git a/apps/workbench/app/views/work_units/_show_child.html.erb b/apps/workbench/app/views/work_units/_show_child.html.erb
deleted file mode 100644 (file)
index 53f3e43..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="panel panel-default">
-  <div class="panel-heading">
-      <div class="row">
-        <div class="col-md-3" style="word-break:break-all;">
-          <h4 class="panel-title">
-            <a class="component-detail-panel" data-toggle="collapse" href="#collapse<%= i %>">
-              <%= current_obj.label %> <span class="caret" href="#collapse<%= i %>"></span>
-            </a>
-          </h4>
-        </div>
-
-        <div class="col-md-2 pipeline-instance-spacing">
-          <%= render partial: 'work_units/progress', locals: {wu: current_obj} %>
-        </div>
-
-        <% if not current_obj %>
-          <div class="col-md-7"></div>
-        <% else %>
-          <% walltime = current_obj.walltime %>
-          <% cputime = current_obj.cputime %>
-          <% runningtime = current_obj.runningtime %>
-          <div class="col-md-3">
-          <% if walltime and cputime %>
-            <%= render_runtime([walltime, runningtime].max, false) %>
-            <% if cputime > 0 %> / <%= render_runtime(cputime, false) %> (<%= (cputime/runningtime).round(1) %>&Cross;)<% end %>
-          <% end %>
-          </div>
-
-          <% queuetime = current_obj.queuedtime %>
-          <% if queuetime %>
-            <div class="col-md-3">
-              Queued for <%= render_runtime(queuetime, false) %>.
-            </div>
-          <% elsif current_obj.is_running? %>
-            <div class="col-md-3">
-              <span class="task-summary-status">
-                <%= current_obj.child_summary_str %>
-              </span>
-            </div>
-          <% end %>
-
-          <div class="col-md-1 pipeline-instance-spacing">
-          <% if current_obj.can_cancel? and @object.editable? %>
-              <%= form_tag "#{current_obj.uri}/cancel", remote: true, style: "display:inline; padding-left: 1em" do |f| %>
-                <%= hidden_field_tag :return_to, url_for(@object) %>
-                <%= button_tag "Cancel", {class: 'btn btn-xs btn-warning', id: "cancel-child-button"} %>
-              <% end %>
-          <% end %>
-          </div>
-        <% end %>
-      </div>
-  </div>
-
-  <% content_url = url_for(controller: :work_units, action: :show_child_component, id: @object.uuid, object_type: @object.class.to_s) %>
-  <div id="collapse<%=i%>" class="work-unit-component-detail panel-collapse collapse <%= if expanded then 'in' end %>" content-url="<%=content_url%>" action-data="<%={current_obj_type: current_obj.class.to_s, current_obj_uuid: current_obj.uuid, current_obj_name: current_obj.label, current_obj_parent: current_obj.parent}.to_json%>">
-    <div class="panel-body work-unit-component-detail-body">
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/work_units/_show_component.html.erb b/apps/workbench/app/views/work_units/_show_component.html.erb
deleted file mode 100644 (file)
index 4cce090..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%# Work unit status %>
-
-<div class="row">
-  <div class="col-md-4">
-    <% if wu.is_paused? %>
-      <p>
-        This <%= wu.title %> is paused. Children that were running
-        were cancelled and no new processes will be submitted.
-      </p>
-    <% end %>
-
-    <%= raw(wu.show_runtime) %>
-  </div>
-  <%# Need additional handling for main object display  %>
-  <% if @object.uuid == wu.uuid %>
-    <div class="col-md-3">
-      <% if wu.is_running? and wu.child_summary_str %>
-        <%= wu.child_summary_str %>
-      <% end %>
-    </div>
-    <div class="col-md-3">
-      <%= render partial: 'work_units/progress', locals: {wu: wu} %>
-    </div>
-    <div class="col-md-2">
-      <% if wu.can_cancel? and @object.editable? %>
-        <% confirm = if wu.confirm_cancellation then {confirm: wu.confirm_cancellation} else {} end %>
-        <%= form_tag "#{wu.uri}/cancel", remote: true, style: "display:inline; padding-left: 1em" do |f| %>
-          <%= hidden_field_tag :return_to, url_for(@object) %>
-          <%= button_tag "Cancel", {class: 'btn btn-xs btn-warning', id: "cancel-obj-button", data: confirm} %>
-        <% end %>
-      <% end %>
-    </div>
-  <% end %>
-</div>
-
-<%# Display runtime error information %>
-<% if wu.runtime_status.andand[:error] %>
-<div class="container">
-  <div class="col-md-12">
-    <div class="panel panel-danger">
-      <div class="panel-heading">
-        <h4 class="panel-title">
-          <a class="component-detail-panel" data-toggle="collapse" href="#errorDetail">
-            <span class="caret"></span> Error: <%= h(wu.runtime_status[:error]) %>
-          </a>
-        </h4>
-      </div>
-      <div id="errorDetail" class="panel-body panel-collapse collapse">
-        <% if wu.runtime_status[:errorDetail] %>
-          <pre><%= h(wu.runtime_status[:errorDetail]) %></pre>
-        <% else %>
-          No detailed information available.
-        <% end %>
-      </div>
-    </div>
-  </div>
-</div>
-<% end %>
-
-<%# Display runtime warning message %>
-<% if wu.runtime_status.andand[:warning] %>
-<div class="container">
-  <div class="col-md-12">
-    <div class="panel panel-warning">
-      <div class="panel-heading">
-        <h4 class="panel-title">
-          <a class="component-detail-panel" data-toggle="collapse" href="#warningDetail">
-            <span class="caret"></span> Warning: <%= h(wu.runtime_status[:warning]) %>
-          </a>
-        </h4>
-      </div>
-      <div id="warningDetail" class="panel-body panel-collapse collapse">
-        <% if wu.runtime_status[:warningDetail] %>
-          <pre><%= h(wu.runtime_status[:warningDetail]) %></pre>
-        <% else %>
-          No detailed information available.
-        <% end %>
-      </div>
-    </div>
-  </div>
-</div>
-<% end %>
-
-<p>
-  <%= render(partial: 'work_units/component_detail', locals: {current_obj: wu}) %>
-</p>
-
-<%# Work unit children %>
-<% if wu.has_unreadable_children %>
-  <%= render(partial: "pipeline_instances/show_components_json",
-             locals: {error_name: "Unreadable components", backtrace: nil, wu: wu}) %>
-<% else %>
-  <% wu.children.each do |c| %>
-    <%= render(partial: 'work_units/show_child', locals: {current_obj: c, i: (c.uuid || rand(2**128).to_s(36)), expanded: false}) %>
-  <% end %>
-<% end %>
diff --git a/apps/workbench/app/views/work_units/_show_log.html.erb b/apps/workbench/app/views/work_units/_show_log.html.erb
deleted file mode 100644 (file)
index d2c5657..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% wu = obj.work_unit(name) %>
-
-<% render_log = wu.render_log %>
-<% if render_log %>
-  <div>
-    <% log_url = url_for render_log[:log] %>
-    <p> <a href="<%= log_url %>">Download the log</a> </p>
-    <%= render(partial: render_log[:partial], locals: render_log[:locals]) %>
-  </div>
-<% end %>
-
-<% live_log_lines = wu.live_log_lines(Rails.configuration.Workbench.RunningJobLogRecordsToFetch).join("\n") %>
-<% if !render_log or (live_log_lines.size > 0) %>
-<%# Still running, or recently finished and logs are still available from logs table %>
-<%# Show recent logs in terminal window %>
-<h4>Recent logs</h4>
-<pre id="event_log_div"
-     class="arv-log-event-listener arv-log-event-handler-append-logs arv-job-log-window"
-     data-object-uuids="<%= wu.log_object_uuids.join(' ') %>"
-  ><%= live_log_lines %>
-</pre>
-
-<%# Applying a long throttle suppresses the auto-refresh of this
-    partial that would normally be triggered by arv-log-event. %>
-<div class="arv-log-refresh-control"
-     data-load-throttle="86486400000" <%# 1001 nights %>>
-</div>
-<% end %>
diff --git a/apps/workbench/app/views/work_units/_show_log_link.html.erb b/apps/workbench/app/views/work_units/_show_log_link.html.erb
deleted file mode 100644 (file)
index a563a13..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if wu.state_label.in? ["Complete", "Failed", "Cancelled"] %>
-  <% lc = wu.log_collection %>
-  <% if lc and object_readable(lc, Collection) and object_readable(wu.uuid) %>
-    <%= link_to("Log", "#{wu.uri}#Log") %>
-  <% else %>
-    Log unavailable
-  <% end %>
-<% elsif wu.state_label == "Running" %>
-  <% if object_readable(wu.uuid) %>
-    <%= link_to("Log", "#{wu.uri}#Log") %>
-  <% else %>
-    Log unavailable
-  <% end %>
-<% end %>
diff --git a/apps/workbench/app/views/work_units/_show_output.html.erb b/apps/workbench/app/views/work_units/_show_output.html.erb
deleted file mode 100644 (file)
index 9c76b4f..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<span class="<%=align%> text-overflow-ellipsis" style="max-width: 100%">
-  <% outputs = wu.outputs %>
-  <% if outputs.size == 0 %>
-    No output
-  <% elsif outputs.size == 1 %>
-    <% if defined?(include_icon) && include_icon %>
-      <i class="fa fa-fw fa-archive"></i>
-    <% end %>
-    <%= link_to_if_arvados_object outputs[0], friendly_name: true %>
-  <% else %>
-    <%= render partial: 'work_units/show_outputs', locals: {id: wu.uuid, outputs: outputs, align:align} %>
-  <% end %>
-</span>
diff --git a/apps/workbench/app/views/work_units/_show_outputs.html.erb b/apps/workbench/app/views/work_units/_show_outputs.html.erb
deleted file mode 100644 (file)
index 11286ad..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<span class="<%=align%>"><a href="#<%= id %>-outputs" data-toggle="collapse">Outputs <span class="caret"></span></a></span>
-<div class="row collapse" id="<%= id %>-outputs" >
-  <div class="col-md-12">
-    <div class="pull-right" style="max-width: 100%">
-      <% outputs.each do |out| %>
-        <div class="text-overflow-ellipsis">
-          <i class="fa fa-fw fa-archive"></i> <%= link_to_if_arvados_object out, friendly_name: true %>
-        </div>
-      <% end %>
-    </div>
-  </div>
-</div>
diff --git a/apps/workbench/app/views/work_units/_show_status.html.erb b/apps/workbench/app/views/work_units/_show_status.html.erb
deleted file mode 100644 (file)
index 0039485..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-    container_uuid = if @object.is_a?(Container) then @object.uuid elsif @object.is_a?(ContainerRequest) then @object.container_uuid end
-    if container_uuid
-      cols = ContainerRequest.columns.map(&:name) - %w(id updated_at mounts runtime_token)
-      reqs = ContainerRequest.select(cols).where(requesting_container_uuid: container_uuid).results
-      load_preloaded_objects(reqs)
-
-      child_cs = reqs.map(&:requesting_container_uuid).uniq
-      child_cs += reqs.map(&:container_uuid).uniq
-      preload_objects_for_dataclass(Container, child_cs)
-
-      wu = current_obj.work_unit(name, child_objects=reqs)
-    else
-      wu = current_obj.work_unit(name)
-    end
-%>
-
-<div class="arv-log-refresh-control"
-     data-load-throttle="86486400000" <%# 1001 nights %>
-     ></div>
-<%=
-   render(partial: 'work_units/show_component', locals: {wu: wu})
-%>
diff --git a/apps/workbench/app/views/work_units/_show_table_data.html.erb b/apps/workbench/app/views/work_units/_show_table_data.html.erb
deleted file mode 100644 (file)
index 57b4f99..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="data-table <%=name%>-table" id="<%=name%>-table" style="max-height: 150px; overflow-y: auto;">
-  <table>
-    <% data_map.each do |k, v|%>
-      <tr>
-        <td>
-          <%= k.to_s %>
-        </td>
-        <td style="padding-left: 1em; padding-right: 1em">
-          <%= v %>
-        </td>
-      </tr>
-    <% end %>
-  </table>
-</div>
diff --git a/apps/workbench/app/views/work_units/index.html.erb b/apps/workbench/app/views/work_units/index.html.erb
deleted file mode 100644 (file)
index ae59817..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: 'work_units/show_all_processes' %>
diff --git a/apps/workbench/app/views/workflows/_show_chooser_preview.html.erb b/apps/workbench/app/views/workflows/_show_chooser_preview.html.erb
deleted file mode 100644 (file)
index 3ca68a5..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<div class="col-sm-11 col-sm-push-1 arv-description-in-table">
-  <%= (@object.description if @object.description.present?) || 'No description' %>
-</div>
diff --git a/apps/workbench/app/views/workflows/_show_definition.html.erb b/apps/workbench/app/views/workflows/_show_definition.html.erb
deleted file mode 100644 (file)
index f0e01a1..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%
-  wf_def = ActiveSupport::HashWithIndifferentAccess.new YAML::load(@object.definition) if @object.definition
-  wf_def = wf_def[:"$graph"].andand[0] || wf_def if wf_def
-
-  items = {}
-  baseCommand = wf_def.andand["baseCommand"]
-  items['baseCommand'] = baseCommand if baseCommand
-
-  args = wf_def.andand["arguments"]
-  items['arguments'] = args if args
-
-  hints = wf_def.andand["hints"]
-  items['hints'] = hints if hints
-
-  inputs = wf_def.andand["inputs"]
-  items['inputs'] = inputs if inputs
-
-  outputs = wf_def.andand["outputs"]
-  items['outputs'] = outputs if outputs
-
-  # preload the collections
-  collections_pdhs = []
-  items.each do |k, v|
-    v.to_s.scan(/([0-9a-f]{32}\+\d+)/).each {|l| collections_pdhs += l}
-  end
-  collections_pdhs.compact.uniq
-  preload_for_pdhs collections_pdhs if collections_pdhs.any?
-  preload_links_for_objects collections_pdhs if collections_pdhs.any?
-%>
-
-  <div class="col-md-12">
-    <table class="table table-condensed" style="table-layout:fixed;">
-      <col width="15%" />
-      <col width="85%" />
-
-      <% items.each do |k, v| %>
-          <tr>
-            <td valign="top">
-              <%= k %>:
-            </td>
-            <td>
-              <% val = JSON.pretty_generate(v) %>
-              <%= render partial: 'show_text_with_locators', locals: {data_height: 300, text_data: val} %>
-            </td>
-          </tr>
-      <% end %>
-    </table>
-  </div>
diff --git a/apps/workbench/app/views/workflows/_show_recent.html.erb b/apps/workbench/app/views/workflows/_show_recent.html.erb
deleted file mode 100644 (file)
index 4acb1e4..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<%= render partial: "paging", locals: {results: @objects, object: @object} %>
-
-<table class="table table-condensed arv-index">
-  <colgroup>
-    <col width="10%" />
-    <col width="10%" />
-    <col width="25%" />
-    <col width="40%" />
-    <col width="15%" />
-  </colgroup>
-
-  <thead>
-    <tr class="contain-align-left">
-      <th></th>
-      <th></th>
-      <th> name </th>
-      <th> description </th>
-      <th> owner </th>
-    </tr>
-  </thead>
-
-  <tbody>
-    <% @objects.sort_by { |ob| ob[:created_at] }.reverse.each do |ob| %>
-      <tr>
-        <td>
-          <%= button_to(choose_projects_path(id: "run-workflow-button",
-                                             title: 'Choose project',
-                                             editable: true,
-                                             action_name: 'Choose',
-                                             action_href: work_units_path,
-                                             action_method: 'post',
-                                             action_data: {'selection_param' => 'work_unit[owner_uuid]',
-                                                           'work_unit[template_uuid]' => ob.uuid,
-                                                           'success' => 'redirect-to-created-object'
-                                                          }.to_json),
-                  { class: "btn btn-default btn-xs", title: "Run #{ob.name}", remote: true, method: :get }
-              ) do %>
-                 <i class="fa fa-fw fa-play"></i> Run
-          <% end %>
-        </td>
-
-        <td>
-          <%= render :partial => "show_object_button", :locals => {object: ob, size: 'xs'} %>
-        </td>
-
-        <td>
-          <%= render_editable_attribute ob, 'name' %>
-        </td>
-
-        <td>
-          <% if ob.description %>
-            <%= render_attribute_as_textile(ob, "description", ob.description, false) %>
-            <br />
-          <% end %>
-        </td>
-
-        <td>
-          <%= link_to_if_arvados_object ob.owner_uuid, friendly_name: true %>
-        </td>
-      </tr>
-    <% end %>
-  </tbody>
-</table>
-
-<%= render partial: "paging", locals: {results: @objects, object: @object} %>
diff --git a/apps/workbench/app/views/workflows/show.html.erb b/apps/workbench/app/views/workflows/show.html.erb
deleted file mode 100644 (file)
index ccb83de..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<%# Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 %>
-
-<% if current_user.andand.is_active %>
-  <% content_for :tab_line_buttons do %>
-    <%= link_to(choose_projects_path(id: "run-workflow-button",
-                                     title: 'Choose project',
-                                     editable: true,
-                                     action_name: 'Choose',
-                                     action_href: work_units_path,
-                                     action_method: 'post',
-                                     action_data: {'selection_param' => 'work_unit[owner_uuid]',
-                                                   'work_unit[template_uuid]' => @object.uuid,
-                                                   'success' => 'redirect-to-created-object'
-                                                  }.to_json),
-          { class: "btn btn-primary btn-sm", title: "Run #{@object.name}", remote: true }
-        ) do %>
-      <i class="fa fa-fw fa-gear"></i> Run this workflow
-    <% end %>
-  <% end %>
-<% end %>
-
-<%= render file: 'application/show.html.erb', locals: local_assigns %>
diff --git a/apps/workbench/bin/bundle b/apps/workbench/bin/bundle
deleted file mode 100755 (executable)
index cb10307..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env ruby
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
-load Gem.bin_path('bundler', 'bundle')
diff --git a/apps/workbench/bin/rails b/apps/workbench/bin/rails
deleted file mode 100755 (executable)
index 4ab9539..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env ruby
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-APP_PATH = File.expand_path('../config/application', __dir__)
-require_relative '../config/boot'
-require 'rails/commands'
diff --git a/apps/workbench/bin/rake b/apps/workbench/bin/rake
deleted file mode 100755 (executable)
index c69c1c4..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env ruby
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require_relative '../config/boot'
-require 'rake'
-Rake.application.run
diff --git a/apps/workbench/bin/setup b/apps/workbench/bin/setup
deleted file mode 100755 (executable)
index 7aed0fb..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/usr/bin/env ruby
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'fileutils'
-include FileUtils
-
-# path to your application root.
-APP_ROOT = File.expand_path('..', __dir__)
-
-def system!(*args)
-  system(*args) || abort("\n== Command #{args} failed ==")
-end
-
-chdir APP_ROOT do
-  # This script is a starting point to setup your application.
-  # Add necessary setup steps to this file.
-
-  puts '== Installing dependencies =='
-  system! 'gem install bundler --conservative'
-  system('bundle check') || system!('bundle install')
-
-  # Install JavaScript dependencies if using Yarn
-  # system('bin/yarn')
-
-  # puts "\n== Copying sample files =="
-  # unless File.exist?('config/database.yml')
-  #   cp 'config/database.yml.sample', 'config/database.yml'
-  # end
-
-  puts "\n== Preparing database =="
-  system! 'bin/rails db:setup'
-
-  puts "\n== Removing old logs and tempfiles =="
-  system! 'bin/rails log:clear tmp:clear'
-
-  puts "\n== Restarting application server =="
-  system! 'bin/rails restart'
-end
diff --git a/apps/workbench/bin/update b/apps/workbench/bin/update
deleted file mode 100755 (executable)
index 46aa76c..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/usr/bin/env ruby
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'pathname'
-require 'fileutils'
-include FileUtils
-
-# path to your application root.
-APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
-
-def system!(*args)
-  system(*args) || abort("\n== Command #{args} failed ==")
-end
-
-chdir APP_ROOT do
-  # This script is a way to update your development environment automatically.
-  # Add necessary update steps to this file.
-
-  puts '== Installing dependencies =='
-  system! 'gem install bundler --conservative'
-  system('bundle check') || system!('bundle install')
-
-  # Install JavaScript dependencies if using Yarn
-  # system('bin/yarn')
-
-  puts "\n== Updating database =="
-  system! 'bin/rails db:migrate'
-
-  puts "\n== Removing old logs and tempfiles =="
-  system! 'bin/rails log:clear tmp:clear'
-
-  puts "\n== Restarting application server =="
-  system! 'bin/rails restart'
-end
diff --git a/apps/workbench/config.ru b/apps/workbench/config.ru
deleted file mode 100644 (file)
index 7ee9ab6..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# This file is used by Rack-based servers to start the application.
-
-require ::File.expand_path('../config/environment',  __FILE__)
-run ArvadosWorkbench::Application
diff --git a/apps/workbench/config/application.default.yml b/apps/workbench/config/application.default.yml
deleted file mode 100644 (file)
index 255ad44..0000000
+++ /dev/null
@@ -1,120 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Do not use this file for site configuration. Create application.yml
-# instead (see application.yml.example).
-
-# Below is a sample setting for diagnostics testing.
-# Configure workbench URL as "arvados_workbench_url"
-# Configure test user tokens as "user_tokens".
-#   At this time the tests need an "active" user token.
-# Also, configure the pipelines to be executed as "pipelines_to_test".
-# For each of the pipelines identified by the name of your choice
-#     ("pipeline_1" and "pipeline_2" in this sample), provide the following:
-#   template_uuid: is the uuid of the template to be executed
-#   input_paths: an array of inputs for the pipeline. Use either a collection's "uuid"
-#     or a file's "uuid/file_name" path in this array. If the pipeline does not require
-#     any inputs, this can be omitted.
-#   max_wait_seconds: max time in seconds to wait for the pipeline run to complete.
-#     Default value of 30 seconds is used when this value is not provided.
-diagnostics:
-  arvados_workbench_url: https://localhost:3031
-  user_tokens:
-    active: eu33jurqntstmwo05h1jr3eblmi961e802703y6657s8zb14r
-  pipelines_to_test:
-    pipeline_1:
-      template_uuid: zzzzz-p5p6p-rxj8d71854j9idn
-      input_paths: [zzzzz-4zz18-nz98douzhaa3jh2]
-      max_wait_seconds: 10
-    pipeline_2:
-      template_uuid: zzzzz-p5p6p-1xbobfobk94ppbv
-      input_paths: [zzzzz-4zz18-nz98douzhaa3jh2, zzzzz-4zz18-gpw9o5wpcti3nib]
-  container_requests_to_test:
-    container_request_1:
-      workflow_uuid: zzzzz-7fd4e-60e96shgwspt4mw
-      input_paths: []
-      max_wait_seconds: 10
-
-# Below is a sample setting for performance testing.
-# Configure workbench URL as "arvados_workbench_url"
-# Configure test user token as "user_token".
-performance:
-  arvados_workbench_url: https://localhost:3031
-  user_token: eu33jurqntstmwo05h1jr3eblmi961e802703y6657s8zb14r
-
-development:
-  cache_classes: false
-  eager_load: true
-  consider_all_requests_local: true
-  action_controller.perform_caching: false
-  action_mailer.raise_delivery_errors: false
-  active_support.deprecation: :log
-  action_dispatch.best_standards_support: :builtin
-  assets.debug: true
-  profiling_enabled: true
-
-production:
-  force_ssl: true
-  cache_classes: true
-  eager_load: true
-  consider_all_requests_local: false
-  action_controller.perform_caching: true
-  assets.compile: false
-  assets.digest: true
-  i18n.fallbacks: true
-  active_support.deprecation: :notify
-  profiling_enabled: false
-  log_level: info
-
-test:
-  cache_classes: true
-  eager_load: false
-  consider_all_requests_local: true
-  action_controller.perform_caching: false
-  action_dispatch.show_exceptions: false
-  action_controller.allow_forgery_protection: false
-  action_mailer.delivery_method: :test
-  active_support.deprecation: :stderr
-  profiling_enabled: true
-  secret_key_base: <%= rand(2**256).to_s(36) %>
-  site_name: Workbench:test
-
-  # Enable user profile with one required field
-  user_profile_form_fields:
-    - key: organization
-      type: text
-      form_field_title: Institution
-      form_field_description: Your organization
-      required: true
-    - key: role
-      type: select
-      form_field_title: Your role
-      form_field_description: Choose the category that best describes your role in your organization.
-      options:
-        - Bio-informatician
-        - Computational biologist
-        - Biologist or geneticist
-        - Software developer
-        - IT
-        - Other
-
-  repository_cache: <%= File.expand_path 'tmp/git', Rails.root %>
-
-common:
-  assets.js_compressor: false
-  assets.css_compressor: false
-
-  # Override the automatic version string. With the default value of
-  # false, the version string is read from git-commit.version in
-  # Rails.root (included in vendor packages) or determined by invoking
-  # "git log".
-  source_version: false
-
-  # Override the automatic package string. With the default value of
-  # false, the package string is read from package-build.version in
-  # Rails.root (included in vendor packages).
-  package_version: false
-
-  # only used by tests
-  testing_override_login_url: false
diff --git a/apps/workbench/config/application.rb b/apps/workbench/config/application.rb
deleted file mode 100644 (file)
index 2880af2..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require_relative 'boot'
-
-require "rails"
-# Pick only the frameworks we need:
-require "active_model/railtie"
-require "active_job/railtie"
-require "active_record/railtie"
-# Skip ActiveStorage (new in Rails 5.1)
-# require "active_storage/engine"
-require "action_controller/railtie"
-require "action_mailer/railtie"
-require "action_view/railtie"
-# Skip ActionCable (new in Rails 5.0) as it adds '/cable' routes that we're not using
-# require "action_cable/engine"
-require "sprockets/railtie"
-require "rails/test_unit/railtie"
-
-Bundler.require(:default, Rails.env)
-
-if ENV["ARVADOS_RAILS_LOG_TO_STDOUT"]
-  Rails.logger = ActiveSupport::TaggedLogging.new(Logger.new(STDOUT))
-end
-
-module ArvadosWorkbench
-  class Application < Rails::Application
-    # The following is to avoid SafeYAML's warning message
-    SafeYAML::OPTIONS[:default_mode] = :safe
-
-    require_relative "arvados_config.rb"
-
-    # Initialize configuration defaults for originally generated Rails version.
-    config.load_defaults 5.1
-
-    # Settings in config/environments/* take precedence over those specified here.
-    # Application configuration should go into files in config/initializers
-    # -- all .rb files in that directory are automatically loaded.
-
-    # Custom directories with classes and modules you want to be autoloadable.
-    # Autoload paths shouldn't be used anymore since Rails 5.0
-    # See #15258 and https://github.com/rails/rails/issues/13142#issuecomment-74586224
-    # config.autoload_paths += %W(#{config.root}/extras)
-
-    # Only load the plugins named here, in the order given (default is alphabetical).
-    # :all can be used as a placeholder for all plugins not explicitly named.
-    # config.plugins = [ :exception_notification, :ssl_requirement, :all ]
-
-    # Activate observers that should always be running.
-    # config.active_record.observers = :cacher, :garbage_collector, :forum_observer
-
-    # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
-    # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
-    # config.time_zone = 'Central Time (US & Canada)'
-
-    # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
-    # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
-    # config.i18n.default_locale = :de
-
-    # Configure the default encoding used in templates for Ruby 1.9.
-    config.encoding = "utf-8"
-
-    # Configure sensitive parameters which will be filtered from the log file.
-    config.filter_parameters += [:password]
-
-    # Enable escaping HTML in JSON.
-    config.active_support.escape_html_entities_in_json = true
-
-    # Use SQL instead of Active Record's schema dumper when creating the database.
-    # This is necessary if your schema can't be completely dumped by the schema dumper,
-    # like if you have constraints or database-specific column types
-    # config.active_record.schema_format = :sql
-
-    # Enable the asset pipeline
-    config.assets.enabled = true
-
-    # Version of your assets, change this if you want to expire all your assets
-    config.assets.version = '1.0'
-
-    # npm-rails loads top-level modules like window.Mithril, but we
-    # also pull in some code from node_modules in application.js, like
-    # mithril/stream/stream.
-    config.assets.paths << Rails.root.join('node_modules')
-  end
-end
diff --git a/apps/workbench/config/application.yml.example b/apps/workbench/config/application.yml.example
deleted file mode 100644 (file)
index 85df228..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Copy this file to application.yml and edit to suit.
-#
-# Consult application.default.yml for the full list of configuration
-# settings.
-#
-# The order of precedence is:
-# 1. config/environments/{RAILS_ENV}.rb (deprecated)
-# 2. Section in application.yml corresponding to RAILS_ENV (e.g., development)
-# 3. Section in application.yml called "common"
-# 4. Section in application.default.yml corresponding to RAILS_ENV
-# 5. Section in application.default.yml called "common"
-
-development:
-  # At minimum, you need a nice long randomly generated secret_token here.
-  secret_token: ~
-
-  # You probably also want to point to your API server.
-  arvados_login_base: https://arvados.local:3030/login
-  arvados_v1_base: https://arvados.local:3030/arvados/v1
-  arvados_insecure_https: true
-
-  # You need to configure at least one of these:
-  keep_web_url: false
-  keep_web_download_url: false
-
-production:
-  # At minimum, you need a nice long randomly generated secret_token here.
-  secret_token: ~
-
-  # You probably also want to point to your API server.
-  arvados_login_base: https://arvados.local:3030/login
-  arvados_v1_base: https://arvados.local:3030/arvados/v1
-  arvados_insecure_https: false
-
-  # You need to configure at least one of these:
-  keep_web_url: false
-  keep_web_download_url: false
diff --git a/apps/workbench/config/arvados_config.rb b/apps/workbench/config/arvados_config.rb
deleted file mode 100644 (file)
index 86b4a47..0000000
+++ /dev/null
@@ -1,212 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-#
-# Load Arvados configuration from /etc/arvados/config.yml, using defaults
-# from config.default.yml
-#
-# Existing application.yml is migrated into the new config structure.
-# Keys in the legacy application.yml take precedence.
-#
-# Use "bundle exec config:dump" to get the complete active configuration
-#
-# Use "bundle exec config:migrate" to migrate application.yml to
-# config.yml.  After adding the output of config:migrate to
-# /etc/arvados/config.yml, you will be able to delete application.yml.
-
-require 'config_loader'
-require 'config_validators'
-require 'open3'
-
-# Load the defaults, used by config:migrate and fallback loading
-# legacy application.yml
-load_time = Time.now.utc
-defaultYAML, stderr, status = Open3.capture3("arvados-server", "config-dump", "-config=-", "-skip-legacy", stdin_data: "Clusters: {xxxxx: {}}")
-if !status.success?
-  puts stderr
-  raise "error loading config: #{status}"
-end
-confs = YAML.load(defaultYAML, deserialize_symbols: false)
-clusterID, clusterConfig = confs["Clusters"].first
-$arvados_config_defaults = clusterConfig
-$arvados_config_defaults["ClusterID"] = clusterID
-$arvados_config_defaults["SourceTimestamp"] = Time.rfc3339(confs["SourceTimestamp"])
-$arvados_config_defaults["SourceSHA256"] = confs["SourceSHA256"]
-
-if ENV["ARVADOS_CONFIG"] == "none"
-  # Don't load config. This magic value is set by packaging scripts so
-  # they can run "rake assets:precompile" without a real config.
-  $arvados_config_global = $arvados_config_defaults.deep_dup
-else
-  # Load the global config file
-  Open3.popen2("arvados-server", "config-dump", "-skip-legacy") do |stdin, stdout, status_thread|
-    confs = YAML.load(stdout, deserialize_symbols: false)
-    if confs && !confs.empty?
-      # config-dump merges defaults with user configuration, so every
-      # key should be set.
-      clusterID, clusterConfig = confs["Clusters"].first
-      $arvados_config_global = clusterConfig
-      $arvados_config_global["ClusterID"] = clusterID
-      $arvados_config_global["SourceTimestamp"] = Time.rfc3339(confs["SourceTimestamp"])
-      $arvados_config_global["SourceSHA256"] = confs["SourceSHA256"]
-    else
-      # config-dump failed, assume we will be loading from legacy
-      # application.yml, initialize with defaults.
-      $arvados_config_global = $arvados_config_defaults.deep_dup
-    end
-  end
-end
-
-# Now make a copy
-$arvados_config = $arvados_config_global.deep_dup
-$arvados_config["LoadTimestamp"] = load_time
-
-# Declare all our configuration items.
-arvcfg = ConfigLoader.new
-
-arvcfg.declare_config "ManagementToken", String, :ManagementToken
-arvcfg.declare_config "TLS.Insecure", Boolean, :arvados_insecure_https
-arvcfg.declare_config "Collections.TrustAllContent", Boolean, :trust_all_content
-
-arvcfg.declare_config "Services.Controller.ExternalURL", URI, :arvados_v1_base, ->(cfg, k, v) {
-  u = URI(v)
-  u.path = ""
-  ConfigLoader.set_cfg cfg, "Services.Controller.ExternalURL", u
-}
-
-arvcfg.declare_config "Services.WebShell.ExternalURL", URI, :shell_in_a_box_url, ->(cfg, k, v) {
-  v ||= ""
-  u = URI(v.sub("%{hostname}", "*"))
-  u.path = ""
-  ConfigLoader.set_cfg cfg, "Services.WebShell.ExternalURL", u
-}
-
-arvcfg.declare_config "Services.WebDAV.ExternalURL", URI, :keep_web_url, ->(cfg, k, v) {
-  v ||= ""
-  u = URI(v.sub("%{uuid_or_pdh}", "*"))
-  u.path = ""
-  ConfigLoader.set_cfg cfg, "Services.WebDAV.ExternalURL", u
-}
-
-arvcfg.declare_config "Services.WebDAVDownload.ExternalURL", URI, :keep_web_download_url, ->(cfg, k, v) {
-  v ||= ""
-  u = URI(v.sub("%{uuid_or_pdh}", "*"))
-  u.path = ""
-  ConfigLoader.set_cfg cfg, "Services.WebDAVDownload.ExternalURL", u
-}
-
-arvcfg.declare_config "Services.Composer.ExternalURL", URI, :composer_url
-arvcfg.declare_config "Services.Workbench2.ExternalURL", URI, :workbench2_url
-
-arvcfg.declare_config "Users.AnonymousUserToken", String, :anonymous_user_token
-
-arvcfg.declare_config "Workbench.SecretKeyBase", String, :secret_key_base
-
-arvcfg.declare_config "Workbench.ApplicationMimetypesWithViewIcon", Hash, :application_mimetypes_with_view_icon, ->(cfg, k, v) {
-  mimetypes = {}
-  v.each do |m|
-    mimetypes[m] = {}
-  end
-  ConfigLoader.set_cfg cfg, "Workbench.ApplicationMimetypesWithViewIcon", mimetypes
-}
-
-arvcfg.declare_config "Workbench.RunningJobLogRecordsToFetch", Integer, :running_job_log_records_to_fetch
-arvcfg.declare_config "Workbench.LogViewerMaxBytes", Integer, :log_viewer_max_bytes
-arvcfg.declare_config "Workbench.ProfilingEnabled", Boolean, :profiling_enabled
-arvcfg.declare_config "Workbench.APIResponseCompression", Boolean, :api_response_compression
-arvcfg.declare_config "Workbench.UserProfileFormFields", Hash, :user_profile_form_fields, ->(cfg, k, v) {
-  if !v
-    v = []
-  end
-  entries = {}
-  v.each_with_index do |s,i|
-    entries[s["key"]] = {
-      "Type" => s["type"],
-      "FormFieldTitle" => s["form_field_title"],
-      "FormFieldDescription" => s["form_field_description"],
-      "Required" => s["required"],
-      "Position": i
-    }
-    if s["options"]
-      entries[s["key"]]["Options"] = {}
-      s["options"].each do |o|
-        entries[s["key"]]["Options"][o] = {}
-      end
-    end
-  end
-  ConfigLoader.set_cfg cfg, "Workbench.UserProfileFormFields", entries
-}
-arvcfg.declare_config "Workbench.UserProfileFormMessage", String, :user_profile_form_message
-arvcfg.declare_config "Workbench.Theme", String, :arvados_theme
-arvcfg.declare_config "Workbench.ShowUserNotifications", Boolean, :show_user_notifications
-arvcfg.declare_config "Workbench.ShowUserAgreementInline", Boolean, :show_user_agreement_inline
-arvcfg.declare_config "Workbench.RepositoryCache", String, :repository_cache
-arvcfg.declare_config "Workbench.Repositories", Boolean, :repositories
-arvcfg.declare_config "Workbench.APIClientConnectTimeout", ActiveSupport::Duration, :api_client_connect_timeout
-arvcfg.declare_config "Workbench.APIClientReceiveTimeout", ActiveSupport::Duration, :api_client_receive_timeout
-arvcfg.declare_config "Workbench.APIResponseCompression", Boolean, :api_response_compression
-arvcfg.declare_config "Workbench.SiteName", String, :site_name
-arvcfg.declare_config "Workbench.MultiSiteSearch", String, :multi_site_search, ->(cfg, k, v) {
-  if !v
-    v = ""
-  end
-  ConfigLoader.set_cfg cfg, "Workbench.MultiSiteSearch", v.to_s
-}
-arvcfg.declare_config "Workbench.EnablePublicProjectsPage", Boolean, :enable_public_projects_page
-arvcfg.declare_config "Workbench.EnableGettingStartedPopup", Boolean, :enable_getting_started_popup
-arvcfg.declare_config "Workbench.ArvadosPublicDataDocURL", String, :arvados_public_data_doc_url
-arvcfg.declare_config "Workbench.ArvadosDocsite", String, :arvados_docsite
-arvcfg.declare_config "Workbench.ShowRecentCollectionsOnDashboard", Boolean, :show_recent_collections_on_dashboard
-arvcfg.declare_config "Workbench.ActivationContactLink", String, :activation_contact_link
-arvcfg.declare_config "Workbench.DefaultOpenIdPrefix", String, :default_openid_prefix
-
-arvcfg.declare_config "Mail.SendUserSetupNotificationEmail", Boolean, :send_user_setup_notification_email
-arvcfg.declare_config "Mail.IssueReporterEmailFrom", String, :issue_reporter_email_from
-arvcfg.declare_config "Mail.IssueReporterEmailTo", String, :issue_reporter_email_to
-arvcfg.declare_config "Mail.SupportEmailAddress", String, :support_email_address
-arvcfg.declare_config "Mail.EmailFrom", String, :email_from
-
-application_config = {}
-%w(application.default application).each do |cfgfile|
-  path = "#{::Rails.root.to_s}/config/#{cfgfile}.yml"
-  confs = ConfigLoader.load(path, erb: true)
-  # Ignore empty YAML file:
-  next if confs == false
-  application_config.deep_merge!(confs['common'] || {})
-  application_config.deep_merge!(confs[::Rails.env.to_s] || {})
-end
-
-$remaining_config = arvcfg.migrate_config(application_config, $arvados_config)
-
-# Checks for wrongly typed configuration items, coerces properties
-# into correct types (such as Duration), and optionally raise error
-# for essential configuration that can't be empty.
-arvcfg.coercion_and_check $arvados_config_defaults, check_nonempty: false
-arvcfg.coercion_and_check $arvados_config_global, check_nonempty: false
-arvcfg.coercion_and_check $arvados_config, check_nonempty: true
-
-# * $arvados_config_defaults is the defaults
-# * $arvados_config_global is $arvados_config_defaults merged with the contents of /etc/arvados/config.yml
-# These are used by the rake config: tasks
-#
-# * $arvados_config is $arvados_config_global merged with the migrated contents of application.yml
-# This is what actually gets copied into the Rails configuration object.
-
-ArvadosWorkbench::Application.configure do
-  # Copy into the Rails config object.  This also turns Hash into
-  # OrderedOptions so that application code can use
-  # Rails.configuration.API.Blah instead of
-  # Rails.configuration.API["Blah"]
-  ConfigLoader.copy_into_config $arvados_config, config
-  ConfigLoader.copy_into_config $remaining_config, config
-  secrets.secret_key_base = $arvados_config["Workbench"]["SecretKeyBase"]
-  if ENV["ARVADOS_CONFIG"] != "none"
-    ConfigValidators.validate_wb2_url_config()
-    ConfigValidators.validate_download_config()
-  end
-  if Rails.configuration.Users.AnonymousUserToken and
-     !Rails.configuration.Users.AnonymousUserToken.starts_with?("v2/")
-    Rails.configuration.Users.AnonymousUserToken = "v2/#{clusterID}-gj3su-anonymouspublic/#{Rails.configuration.Users.AnonymousUserToken}"
-  end
-end
diff --git a/apps/workbench/config/boot.rb b/apps/workbench/config/boot.rb
deleted file mode 100644 (file)
index 8153266..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'rubygems'
-
-# Set up gems listed in the Gemfile.
-ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
-
-require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE'])
-
-# Use ARVADOS_API_TOKEN environment variable (if set) in console
-require 'rails'
-module ArvadosApiClientConsoleMode
-  class Railtie < Rails::Railtie
-    console do
-      Thread.current[:arvados_api_token] ||= ENV['ARVADOS_API_TOKEN']
-    end
-  end
-end
diff --git a/apps/workbench/config/cable.yml b/apps/workbench/config/cable.yml
deleted file mode 100644 (file)
index c906069..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-development:
-  adapter: async
-
-test:
-  adapter: async
-
-production:
-  adapter: redis
-  url: redis://localhost:6379/1
diff --git a/apps/workbench/config/database.yml b/apps/workbench/config/database.yml
deleted file mode 100644 (file)
index 5908b03..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Note: The database configuration is not actually used.
-development:
-  adapter: nulldb
-test:
-  adapter: nulldb
-production:
-  adapter: nulldb
-diagnostics:
-  adapter: nulldb
-performance:
-  adapter: nulldb
diff --git a/apps/workbench/config/environment.rb b/apps/workbench/config/environment.rb
deleted file mode 100644 (file)
index cd70694..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Load the rails application
-require_relative 'application'
-
-# Initialize the rails application
-Rails.application.initialize!
diff --git a/apps/workbench/config/environments/development.rb.example b/apps/workbench/config/environments/development.rb.example
deleted file mode 100644 (file)
index d0b7efa..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-ArvadosWorkbench::Application.configure do
-  # Settings specified here will take precedence over those in config/application.rb
-
-  # In the development environment your application's code is reloaded on
-  # every request. This slows down response time but is perfect for development
-  # since you don't have to restart the web server when you make code changes.
-  config.cache_classes = false
-
-  # Show full error reports and disable caching
-  config.consider_all_requests_local       = true
-  config.action_controller.perform_caching = false
-
-  # Don't care if the mailer can't send
-  config.action_mailer.raise_delivery_errors = false
-
-  # Print deprecation notices to the Rails logger
-  config.active_support.deprecation = :log
-
-  # Only use best-standards-support built into browsers
-  config.action_dispatch.best_standards_support = :builtin
-
-  # Do not compress assets
-  config.assets.js_compressor = false
-
-  # Expands the lines which load the assets
-  config.assets.debug = true
-
-end
diff --git a/apps/workbench/config/environments/production.rb.example b/apps/workbench/config/environments/production.rb.example
deleted file mode 100644 (file)
index ea2cf34..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-ArvadosWorkbench::Application.configure do
-  # Settings specified here will take precedence over those in config/application.rb
-
-  # Code is not reloaded between requests
-  config.cache_classes = true
-
-  # Full error reports are disabled and caching is turned on
-  config.consider_all_requests_local       = false
-  config.action_controller.perform_caching = true
-
-  # Disable Rails's static asset server (Apache or nginx will already do this)
-  config.public_file_server.enabled = false
-
-  # Compress JavaScripts and CSS
-  config.assets.js_compressor = :uglifier
-
-  # Don't fallback to assets pipeline if a precompiled asset is missed
-  config.assets.compile = false
-
-  # Generate digests for assets URLs
-  config.assets.digest = true
-
-  # Defaults to nil and saved in location specified by config.assets.prefix
-  # config.assets.manifest = YOUR_PATH
-
-  # Specifies the header that your server uses for sending files
-  # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache
-  # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx
-
-  # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
-  # config.force_ssl = true
-
-  # See everything in the log (default is :info)
-  # config.log_level = :debug
-
-  # Prepend all log lines with the following tags
-  # config.log_tags = [ :subdomain, :uuid ]
-
-  # Use a different logger for distributed setups
-  # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
-
-  # Use a different cache store in production
-  # config.cache_store = :mem_cache_store
-
-  # Enable serving of images, stylesheets, and JavaScripts from an asset server
-  # config.action_controller.asset_host = "http://assets.example.com"
-
-  # Precompile additional assets (application.js, application.css, and all non-JS/CSS are already added)
-  # config.assets.precompile += %w( search.js )
-
-  # Disable delivery errors, bad email addresses will be ignored
-  # config.action_mailer.raise_delivery_errors = false
-
-  # Enable threaded mode
-  # config.threadsafe!
-
-  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
-  # the I18n.default_locale when a translation can not be found)
-  config.i18n.fallbacks = true
-
-  # Send deprecation notices to registered listeners
-  config.active_support.deprecation = :notify
-
-  # Log timing data for API transactions
-  config.profiling_enabled = false
-
-end
diff --git a/apps/workbench/config/environments/test.rb b/apps/workbench/config/environments/test.rb
deleted file mode 120000 (symlink)
index f1e9dbf..0000000
+++ /dev/null
@@ -1 +0,0 @@
-test.rb.example
\ No newline at end of file
diff --git a/apps/workbench/config/environments/test.rb.example b/apps/workbench/config/environments/test.rb.example
deleted file mode 100644 (file)
index 373618c..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-ArvadosWorkbench::Application.configure do
-  # Settings specified here will take precedence over those in config/application.rb
-
-  # The test environment is used exclusively to run your application's
-  # test suite. You never need to work with it otherwise. Remember that
-  # your test database is "scratch space" for the test suite and is wiped
-  # and recreated between test runs. Don't rely on the data there!
-  config.cache_classes = true
-
-  # Configure static asset server for tests with Cache-Control for performance
-  config.public_file_server.enabled = true
-  config.public_file_server.headers = { 'Cache-Control' => 'public, max-age=3600' }
-
-  # Show full error reports and disable caching
-  config.consider_all_requests_local       = true
-  config.action_controller.perform_caching = false
-
-  # Raise exceptions instead of rendering exception templates
-  config.action_dispatch.show_exceptions = false
-
-  # Disable request forgery protection in test environment
-  config.action_controller.allow_forgery_protection    = false
-
-  # Tell Action Mailer not to deliver emails to the real world.
-  # The :test delivery method accumulates sent emails in the
-  # ActionMailer::Base.deliveries array.
-  config.action_mailer.delivery_method = :test
-
-  # Print deprecation notices to the stderr
-  config.active_support.deprecation = :stderr
-
-  # Log timing data for API transactions
-  config.profiling_enabled = false
-
-  # Can be :random or :sorted. Rails 5 will use :random by default
-  config.active_support.test_order = :sorted
-
-end
diff --git a/apps/workbench/config/initializers/actionview_xss_fix.rb b/apps/workbench/config/initializers/actionview_xss_fix.rb
deleted file mode 100644 (file)
index 3f5e239..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# This is related to:
-# * https://github.com/advisories/GHSA-65cv-r6x7-79hv
-# * https://nvd.nist.gov/vuln/detail/CVE-2020-5267
-#
-# Until we upgrade to rails 5.2, this monkeypatch should be enough
-ActionView::Helpers::JavaScriptHelper::JS_ESCAPE_MAP.merge!(
-  {
-    "`" => "\\`",
-    "$" => "\\$"
-  }
-)
-
-module ActionView::Helpers::JavaScriptHelper
-  alias :old_ej :escape_javascript
-  alias :old_j :j
-
-  def escape_javascript(javascript)
-    javascript = javascript.to_s
-    if javascript.empty?
-      result = ""
-    else
-      result = javascript.gsub(/(\\|<\/|\r\n|\342\200\250|\342\200\251|[\n\r"']|[`]|[$])/u, JS_ESCAPE_MAP)
-    end
-    javascript.html_safe? ? result.html_safe : result
-  end
-
-  alias :j :escape_javascript
-end
\ No newline at end of file
diff --git a/apps/workbench/config/initializers/application_controller_renderer.rb b/apps/workbench/config/initializers/application_controller_renderer.rb
deleted file mode 100644 (file)
index 525d6ad..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# ActiveSupport::Reloader.to_prepare do
-#   ApplicationController.renderer.defaults.merge!(
-#     http_host: 'example.org',
-#     https: false
-#   )
-# end
diff --git a/apps/workbench/config/initializers/assets.rb b/apps/workbench/config/initializers/assets.rb
deleted file mode 100644 (file)
index 2cb9ae9..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# Version of your assets, change this if you want to expire all your assets.
-Rails.application.config.assets.version = '1.0'
-
-# Add additional assets to the asset load path
-# Rails.application.config.assets.paths << Emoji.images_path
-
-# Precompile additional assets.
-# application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
-Rails.application.config.assets.precompile += %w( webshell/styles.css webshell/shell_in_a_box.js )
diff --git a/apps/workbench/config/initializers/backtrace_silencers.rb b/apps/workbench/config/initializers/backtrace_silencers.rb
deleted file mode 100644 (file)
index b9c6bce..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
-# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
-
-# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
-# Rails.backtrace_cleaner.remove_silencers!
diff --git a/apps/workbench/config/initializers/content_security_policy.rb b/apps/workbench/config/initializers/content_security_policy.rb
deleted file mode 100644 (file)
index 853ecde..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# Define an application-wide content security policy
-# For further information see the following documentation
-# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
-
-# Rails.application.config.content_security_policy do |policy|
-#   policy.default_src :self, :https
-#   policy.font_src    :self, :https, :data
-#   policy.img_src     :self, :https, :data
-#   policy.object_src  :none
-#   policy.script_src  :self, :https
-#   policy.style_src   :self, :https
-
-#   # Specify URI for violation reports
-#   # policy.report_uri "/csp-violation-report-endpoint"
-# end
-
-# If you are using UJS then enable automatic nonce generation
-# Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
-
-# Report CSP violations to a specified URI
-# For further information see the following documentation:
-# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
-# Rails.application.config.content_security_policy_report_only = true
diff --git a/apps/workbench/config/initializers/cookies_serializer.rb b/apps/workbench/config/initializers/cookies_serializer.rb
deleted file mode 100644 (file)
index 5409f55..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# Specify a serializer for the signed and encrypted cookie jars.
-# Valid options are :json, :marshal, and :hybrid.
-Rails.application.config.action_dispatch.cookies_serializer = :marshal
diff --git a/apps/workbench/config/initializers/filter_parameter_logging.rb b/apps/workbench/config/initializers/filter_parameter_logging.rb
deleted file mode 100644 (file)
index f26d0ad..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# Configure sensitive parameters which will be filtered from the log file.
-Rails.application.config.filter_parameters += [:password]
diff --git a/apps/workbench/config/initializers/inflections.rb b/apps/workbench/config/initializers/inflections.rb
deleted file mode 100644 (file)
index 55399f0..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# Add new inflection rules using the following format
-# (all these examples are active by default):
-# ActiveSupport::Inflector.inflections do |inflect|
-#   inflect.plural /^(ox)$/i, '\1en'
-#   inflect.singular /^(ox)en/i, '\1'
-#   inflect.irregular 'person', 'people'
-#   inflect.uncountable %w( fish sheep )
-# end
-#
-# These inflection rules are supported but not enabled by default:
-# ActiveSupport::Inflector.inflections do |inflect|
-#   inflect.acronym 'RESTful'
-# end
-
-ActiveSupport::Inflector.inflections do |inflect|
-  inflect.plural(/^([Ss]pecimen)$/i, '\1s')
-  inflect.singular(/^([Ss]pecimen)s?/i, '\1')
-  inflect.plural(/^([Hh]uman)$/i, '\1s')
-  inflect.singular(/^([Hh]uman)s?/i, '\1')
-end
diff --git a/apps/workbench/config/initializers/lograge.rb b/apps/workbench/config/initializers/lograge.rb
deleted file mode 100644 (file)
index 795be7b..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-ArvadosWorkbench::Application.configure do
-  config.lograge.enabled = true
-  config.lograge.formatter = Lograge::Formatters::Logstash.new
-  config.lograge.custom_options = lambda do |event|
-    payload = {
-      ClusterID: Rails.configuration.ClusterID,
-      request_id: event.payload[:request_id],
-    }
-    # Also log params (minus the pseudo-params added by Rails). But if
-    # params is huge, don't log the whole thing, just hope we get the
-    # most useful bits in truncate(json(params)).
-    exceptions = %w(controller action format id)
-    params = event.payload[:params].except(*exceptions)
-    params_s = Oj.dump(params)
-    if params_s.length > 1000
-      payload[:params_truncated] = params_s[0..1000] + "[...]"
-    else
-      payload[:params] = params
-    end
-    payload
-  end
-end
diff --git a/apps/workbench/config/initializers/mime_types.rb b/apps/workbench/config/initializers/mime_types.rb
deleted file mode 100644 (file)
index 69781a1..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# Add new mime types for use in respond_to blocks:
-# Mime::Type.register "text/richtext", :rtf
-# Mime::Type.register_alias "text/html", :iphone
-
-# add new mime types to MIME from mime_types gem
-
-require 'mime/types'
-include MIME
-[
-  %w(fasta fa fas fsa seq),
-  %w(go),
-  %w(r),
-  %w(sam),
-  %w(python py),
-].each do |suffixes|
-  if (MIME::Types.type_for(suffixes[0]).first.nil?)
-    MIME::Types.add(MIME::Type.new(["application/#{suffixes[0]}", suffixes]))
-  end
-end
diff --git a/apps/workbench/config/initializers/new_framework_defaults.rb b/apps/workbench/config/initializers/new_framework_defaults.rb
deleted file mode 100644 (file)
index 2e2f0b1..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-#
-# This file contains migration options to ease your Rails 5.0 upgrade.
-#
-# Once upgraded flip defaults one by one to migrate to the new default.
-#
-# Read the Guide for Upgrading Ruby on Rails for more info on each option.
-
-Rails.application.config.action_controller.raise_on_unfiltered_parameters = true
-
-# Enable per-form CSRF tokens. Previous versions had false.
-Rails.application.config.action_controller.per_form_csrf_tokens = false
-
-# Enable origin-checking CSRF mitigation. Previous versions had false.
-Rails.application.config.action_controller.forgery_protection_origin_check = false
-
-# Make Ruby 2.4 preserve the timezone of the receiver when calling `to_time`.
-# Previous versions had false.
-ActiveSupport.to_time_preserves_timezone = false
-
-# Require `belongs_to` associations by default. Previous versions had false.
-Rails.application.config.active_record.belongs_to_required_by_default = false
diff --git a/apps/workbench/config/initializers/new_framework_defaults_5_1.rb b/apps/workbench/config/initializers/new_framework_defaults_5_1.rb
deleted file mode 100644 (file)
index 804ee6f..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-#
-# This file contains migration options to ease your Rails 5.1 upgrade.
-#
-# Once upgraded flip defaults one by one to migrate to the new default.
-#
-# Read the Guide for Upgrading Ruby on Rails for more info on each option.
-
-# Make `form_with` generate non-remote forms.
-Rails.application.config.action_view.form_with_generates_remote_forms = false
-
-# Unknown asset fallback will return the path passed in when the given
-# asset is not present in the asset pipeline.
-# Rails.application.config.assets.unknown_asset_fallback = false
diff --git a/apps/workbench/config/initializers/new_framework_defaults_5_2.rb b/apps/workbench/config/initializers/new_framework_defaults_5_2.rb
deleted file mode 100644 (file)
index 93a8d52..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-#
-# This file contains migration options to ease your Rails 5.2 upgrade.
-#
-# Once upgraded flip defaults one by one to migrate to the new default.
-#
-# Read the Guide for Upgrading Ruby on Rails for more info on each option.
-
-# Make Active Record use stable #cache_key alongside new #cache_version method.
-# This is needed for recyclable cache keys.
-# Rails.application.config.active_record.cache_versioning = true
-
-# Use AES-256-GCM authenticated encryption for encrypted cookies.
-# Also, embed cookie expiry in signed or encrypted cookies for increased security.
-#
-# This option is not backwards compatible with earlier Rails versions.
-# It's best enabled when your entire app is migrated and stable on 5.2.
-#
-# Existing cookies will be converted on read then written with the new scheme.
-# Rails.application.config.action_dispatch.use_authenticated_cookie_encryption = true
-
-# Use AES-256-GCM authenticated encryption as default cipher for encrypting messages
-# instead of AES-256-CBC, when use_authenticated_message_encryption is set to true.
-# Rails.application.config.active_support.use_authenticated_message_encryption = true
-
-# Add default protection from forgery to ActionController::Base instead of in
-# ApplicationController.
-# Rails.application.config.action_controller.default_protect_from_forgery = true
-
-# Store boolean values are in sqlite3 databases as 1 and 0 instead of 't' and
-# 'f' after migrating old data.
-# Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true
-
-# Use SHA-1 instead of MD5 to generate non-sensitive digests, such as the ETag header.
-# Rails.application.config.active_support.use_sha1_digests = true
-
-# Make `form_with` generate id attributes for any generated HTML tags.
-# Rails.application.config.action_view.form_with_generates_ids = true
diff --git a/apps/workbench/config/initializers/rack_mini_profile.rb b/apps/workbench/config/initializers/rack_mini_profile.rb
deleted file mode 100644 (file)
index 5fedf3f..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-if not Rails.env.production? and ENV['ENABLE_PROFILING']
-  require 'rack-mini-profiler'
-  require 'flamegraph'
-  Rack::MiniProfilerRails.initialize! Rails.application
-end
diff --git a/apps/workbench/config/initializers/redcloth.rb b/apps/workbench/config/initializers/redcloth.rb
deleted file mode 100644 (file)
index e0d6ac4..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module RedClothArvadosLinkExtension
-
-  class RedClothViewBase < ActionView::Base
-    include ApplicationHelper
-    include ActionView::Helpers::UrlHelper
-    include Rails.application.routes.url_helpers
-
-    def helper_link_to_if_arvados_object(link, opts)
-      link_to_if_arvados_object(link, opts)
-    end
-  end
-
-  def refs_arvados(text)
-    text.gsub!(/"(?!\s)([^"]*\S)":(\S+)/) do
-      text, link = $~[1..2]
-      arvados_link = RedClothViewBase.new.helper_link_to_if_arvados_object(link, { :link_text => text })
-      # if it's not an arvados_link the helper will return the link unprocessed and so we will reconstruct the textile link string so it can be processed normally
-      (arvados_link == link) ? "\"#{text}\":#{link}" : arvados_link
-    end
-  end
-end
-
-RedCloth.send(:include, RedClothArvadosLinkExtension)
diff --git a/apps/workbench/config/initializers/reload_config.rb b/apps/workbench/config/initializers/reload_config.rb
deleted file mode 100644 (file)
index ec6db0c..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Please don't edit this version. Instead, edit
-# services/api/config/initializers/reload_config.rb and update this
-# copy. Or find a more reasonable way to share the code.
-
-def start_reload_thread
-  Thread.new do
-    lockfile = Rails.root.join('tmp', 'reload_config.lock')
-    File.open(lockfile, File::WRONLY|File::CREAT, 0600) do |f|
-      # Note we don't use LOCK_NB here. If we did, each time passenger
-      # kills the lock-holder process, we would be left with nobody
-      # checking for updates until passenger starts a new worker,
-      # which could be a long time.
-      Rails.logger.debug("reload_config: waiting for lock on #{lockfile}")
-      f.flock(File::LOCK_EX)
-
-      t_lastload = Rails.configuration.SourceTimestamp
-      hash_lastload = Rails.configuration.SourceSHA256
-      conffile = ENV['ARVADOS_CONFIG'] || "/etc/arvados/config.yml"
-      Rails.logger.info("reload_config: polling for updated mtime on #{conffile} with threshold #{t_lastload}")
-      while true
-        sleep 1
-        t = File.mtime(conffile)
-        # If the file is newer than 5s, re-read it even if the
-        # timestamp matches the previously loaded file. This enables
-        # us to detect changes even if the filesystem's timestamp
-        # precision cannot represent multiple updates per second.
-        if t.to_f != t_lastload.to_f || Time.now.to_f - t.to_f < 5
-          Open3.popen2("arvados-server", "config-dump", "-skip-legacy") do |stdin, stdout, status_thread|
-            confs = YAML.load(stdout, deserialize_symbols: false)
-            hash = confs["SourceSHA256"]
-          rescue => e
-            Rails.logger.info("reload_config: config file updated but could not be loaded: #{e}")
-            t_lastload = t
-            next
-          end
-          if hash == hash_lastload
-            # If we reloaded a new or updated file, but the content is
-            # identical, keep polling instead of restarting.
-            t_lastload = t
-            next
-          end
-
-          restartfile = Rails.root.join('tmp', 'restart.txt')
-          touchtime = Time.now
-          Rails.logger.info("reload_config: mtime on #{conffile} changed to #{t}, touching #{restartfile} to #{touchtime}")
-          begin
-            File.utime(touchtime, touchtime, restartfile)
-          rescue
-            # remove + re-create works even if the existing file is
-            # owned by root, provided the tempdir is writable.
-            File.unlink(restartfile) rescue nil
-            File.open(restartfile, 'w') {}
-          end
-          # Even if passenger doesn't notice that we hit restart.txt
-          # and kill our process, there's no point waiting around to
-          # hit it again.
-          break
-        end
-      end
-    end
-  end
-end
-
-if !File.owned?(Rails.root.join('tmp'))
-  Rails.logger.debug("reload_config: not owner of #{Rails.root}/tmp, skipping")
-elsif ENV["ARVADOS_CONFIG"] == "none"
-  Rails.logger.debug("reload_config: no config in use, skipping")
-elsif defined?(PhusionPassenger)
-  PhusionPassenger.on_event(:starting_worker_process) do |forked|
-    start_reload_thread
-  end
-else
-  start_reload_thread
-end
diff --git a/apps/workbench/config/initializers/secret_token.rb.example b/apps/workbench/config/initializers/secret_token.rb.example
deleted file mode 100644 (file)
index fa6e816..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# Your secret key for verifying the integrity of signed cookies.
-# If you change this key, all old signed cookies will become invalid!
-# Make sure the secret is at least 30 characters and all random,
-# no regular words or you'll be exposed to dictionary attacks.
-ArvadosWorkbench::Application.config.secret_token ||= rand(2**256).to_s(36)
diff --git a/apps/workbench/config/initializers/session_store.rb b/apps/workbench/config/initializers/session_store.rb
deleted file mode 100644 (file)
index 7a2f297..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-Rails.application.config.session_store :cookie_store, key: '_arvados_workbench_session'
-
-# Use the database for sessions instead of the cookie-based default,
-# which shouldn't be used to store highly confidential information
-# (create the session table with "rails generate session_migration")
-# ArvadosWorkbench::Application.config.session_store :active_record_store
diff --git a/apps/workbench/config/initializers/time_format.rb b/apps/workbench/config/initializers/time_format.rb
deleted file mode 100644 (file)
index b0cc6c9..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class ActiveSupport::TimeWithZone
-  def as_json *args
-    strftime "%Y-%m-%dT%H:%M:%S.%NZ"
-  end
-end
diff --git a/apps/workbench/config/initializers/validate_wb2_url_config.rb b/apps/workbench/config/initializers/validate_wb2_url_config.rb
deleted file mode 100644 (file)
index 0a8f07c..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'config_validators'
-
-include ConfigValidators
-
-ConfigValidators::validate_wb2_url_config()
\ No newline at end of file
diff --git a/apps/workbench/config/initializers/wrap_parameters.rb b/apps/workbench/config/initializers/wrap_parameters.rb
deleted file mode 100644 (file)
index 6fb9786..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-#
-# This file contains settings for ActionController::ParamsWrapper which
-# is enabled by default.
-
-# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
-ActiveSupport.on_load(:action_controller) do
-  wrap_parameters format: [:json]
-end
-
-# Disable root element in JSON by default.
-ActiveSupport.on_load(:active_record) do
-  self.include_root_in_json = false
-end
diff --git a/apps/workbench/config/locales/en.bootstrap.yml b/apps/workbench/config/locales/en.bootstrap.yml
deleted file mode 100644 (file)
index 664de2b..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-# Sample localization file for English. Add more files in this directory for other locales.
-# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
-
-en:
-  helpers:
-    actions: "Actions"
-    links:
-      back: "Back"
-      cancel: "Cancel"
-      confirm: "Are you sure?"
-      destroy: "Delete"
-      new: "New"
-      edit: "Edit"
-    titles:
-      edit: "Edit"
-      save: "Save"
-      new: "New"
-      delete: "Delete"
diff --git a/apps/workbench/config/locales/en.yml b/apps/workbench/config/locales/en.yml
deleted file mode 100644 (file)
index e6a62cb..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Sample localization file for English. Add more files in this directory for other locales.
-# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
-
-en:
-  hello: "Hello world"
diff --git a/apps/workbench/config/piwik.yml.example b/apps/workbench/config/piwik.yml.example
deleted file mode 100644 (file)
index 52a1ffb..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Configuration:
-# 
-# disabled
-#   false if tracking tag should be shown
-# use_async
-#   Set to true if you want to use asynchronous tracking
-# url
-#   The url of your piwik instance (e.g. localhost/piwik/
-# id_site
-#   The id of your website inside Piwik
-#
-production:
-  piwik:
-    id_site: 1
-    url: localhost
-    use_async: false
-    disabled: false
-
-development:
-  piwik:
-    id_site: 1
-    url: localhost
-    disabled: true
-    use_async: false
-    hostname: localhost
-
-test:
-  piwik:
-    id_site: 1
-    url: localhost
-    disabled: true
-    use_async: false
-    hostname: localhost
diff --git a/apps/workbench/config/puma.rb b/apps/workbench/config/puma.rb
deleted file mode 100644 (file)
index e087396..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Puma can serve each request in a thread from an internal thread pool.
-# The `threads` method setting takes two numbers a minimum and maximum.
-# Any libraries that use thread pools should be configured to match
-# the maximum value specified for Puma. Default is set to 5 threads for minimum
-# and maximum, this matches the default thread size of Active Record.
-#
-threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i
-threads threads_count, threads_count
-
-# Specifies the `port` that Puma will listen on to receive requests, default is 3000.
-#
-port        ENV.fetch("PORT") { 3000 }
-
-# Specifies the `environment` that Puma will run in.
-#
-environment ENV.fetch("RAILS_ENV") { "development" }
-
-# Specifies the number of `workers` to boot in clustered mode.
-# Workers are forked webserver processes. If using threads and workers together
-# the concurrency of the application would be max `threads` * `workers`.
-# Workers do not work on JRuby or Windows (both of which do not support
-# processes).
-#
-# workers ENV.fetch("WEB_CONCURRENCY") { 2 }
-
-# Use the `preload_app!` method when specifying a `workers` number.
-# This directive tells Puma to first boot the application and load code
-# before forking the application. This takes advantage of Copy On Write
-# process behavior so workers use less memory. If you use this option
-# you need to make sure to reconnect any threads in the `on_worker_boot`
-# block.
-#
-# preload_app!
-
-# The code in the `on_worker_boot` will be called if you are using
-# clustered mode by specifying a number of `workers`. After each worker
-# process is booted this block will be run, if you are using `preload_app!`
-# option you will want to use this block to reconnect to any threads
-# or connections that may have been created at application boot, Ruby
-# cannot share connections between processes.
-#
-# on_worker_boot do
-#   ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
-# end
-
-# Allow puma to be restarted by `rails restart` command.
-plugin :tmp_restart
diff --git a/apps/workbench/config/routes.rb b/apps/workbench/config/routes.rb
deleted file mode 100644 (file)
index 0bf8dff..0000000
+++ /dev/null
@@ -1,143 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-Rails.application.routes.draw do
-  themes_for_rails
-
-  resources :keep_disks
-  resources :keep_services
-  resources :user_agreements do
-    post 'sign', on: :collection
-    get 'signatures', on: :collection
-  end
-  get '/user_agreements/signatures' => 'user_agreements#signatures'
-  get "users/setup_popup" => 'users#setup_popup', :as => :setup_user_popup
-  get "users/setup" => 'users#setup', :as => :setup_user
-  get "report_issue_popup" => 'actions#report_issue_popup', :as => :report_issue_popup
-  post "report_issue" => 'actions#report_issue', :as => :report_issue
-  get "star" => 'actions#star', :as => :star
-  get "all_processes" => 'work_units#index', :as => :all_processes
-  get "choose_work_unit_templates" => 'work_unit_templates#choose', :as => :choose_work_unit_templates
-  resources :work_units do
-    post 'show_child_component', :on => :member
-  end
-  resources :nodes
-  resources :humans
-  resources :traits
-  resources :api_client_authorizations
-  resources :virtual_machines
-  resources :containers
-  resources :container_requests do
-    post 'cancel', :on => :member
-    post 'copy', on: :member
-  end
-  get '/virtual_machines/:id/webshell/:login' => 'virtual_machines#webshell', :as => :webshell_virtual_machine
-  resources :authorized_keys
-  resources :job_tasks
-  resources :jobs do
-    post 'cancel', :on => :member
-    get 'logs', :on => :member
-  end
-  resources :repositories do
-    post 'share_with', on: :member
-  end
-  # {format: false} prevents rails from treating "foo.png" as foo?format=png
-  get '/repositories/:id/tree/:commit' => 'repositories#show_tree'
-  get '/repositories/:id/tree/:commit/*path' => 'repositories#show_tree', as: :show_repository_tree, format: false
-  get '/repositories/:id/blob/:commit/*path' => 'repositories#show_blob', as: :show_repository_blob, format: false
-  get '/repositories/:id/commit/:commit' => 'repositories#show_commit', as: :show_repository_commit
-  resources :sessions
-  match '/logout' => 'sessions#destroy', via: [:get, :post]
-  get '/logged_out' => 'sessions#logged_out'
-  resources :users do
-    get 'choose', :on => :collection
-    get 'home', :on => :member
-    get 'welcome', :on => :collection
-    get 'inactive', :on => :collection
-    get 'activity', :on => :collection
-    get 'storage', :on => :collection
-    post 'sudo', :on => :member
-    post 'unsetup', :on => :member
-    get 'setup_popup', :on => :member
-    get 'profile', :on => :member
-    post 'request_shell_access', :on => :member
-    get 'virtual_machines', :on => :member
-    get 'repositories', :on => :member
-    get 'ssh_keys', :on => :member
-    get 'link_account', :on => :collection
-    post 'link_account', :on => :collection, :action => :merge
-  end
-  get '/current_token' => 'users#current_token'
-  get "/add_ssh_key_popup" => 'users#add_ssh_key_popup', :as => :add_ssh_key_popup
-  get "/add_ssh_key" => 'users#add_ssh_key', :as => :add_ssh_key
-  resources :logs
-  resources :factory_jobs
-  resources :uploaded_datasets
-  resources :groups do
-    get 'choose', on: :collection
-  end
-  resources :specimens
-  resources :pipeline_templates do
-    get 'choose', on: :collection
-  end
-  resources :pipeline_instances do
-    post 'cancel', :on => :member
-    get 'compare', on: :collection
-    post 'copy', on: :member
-  end
-  resources :links
-  get '/collections/graph' => 'collections#graph'
-  resources :collections do
-    post 'set_persistent', on: :member
-    get 'sharing_popup', :on => :member
-    post 'share', :on => :member
-    post 'unshare', :on => :member
-    get 'choose', on: :collection
-    post 'remove_selected_files', on: :member
-    get 'tags', on: :member
-    post 'save_tags', on: :member
-    get 'multisite', on: :collection, to: redirect('/search')
-  end
-  get('/collections/download/:uuid/:reader_token/*file' => 'collections#show_file',
-      format: false)
-  get '/collections/download/:uuid/:reader_token' => 'collections#show_file_links'
-  get '/collections/:uuid/*file' => 'collections#show_file', :format => false
-  resources :projects do
-    match 'remove/:item_uuid', on: :member, via: :delete, action: :remove_item
-    match 'remove_items', on: :member, via: :delete, action: :remove_items
-    get 'choose', on: :collection
-    post 'share_with', on: :member
-    get 'tab_counts', on: :member
-    get 'public', on: :collection
-  end
-
-  resources :search do
-    get 'choose', :on => :collection
-  end
-
-  resources :workflows
-
-  get "trash" => 'trash_items#index', :as => :trash
-  resources :trash_items do
-    post 'untrash_items', on: :collection
-  end
-
-  post 'actions' => 'actions#post'
-  get 'actions' => 'actions#show'
-  get 'websockets' => 'websocket#index'
-  post "combine_selected" => 'actions#combine_selected_files_into_collection'
-
-  root :to => 'projects#index'
-
-  match '/_health/:check', to: 'management#health', via: [:get]
-  match '/metrics', to: 'management#metrics', via: [:get]
-
-  get '/tests/mithril', to: 'tests#mithril'
-
-  get '/status', to: 'status#status'
-
-  # Send unroutable requests to an arbitrary controller
-  # (ends up at ApplicationController#render_not_found)
-  match '*a', to: 'links#render_not_found', via: [:get, :post]
-end
diff --git a/apps/workbench/config/secrets.yml b/apps/workbench/config/secrets.yml
deleted file mode 100644 (file)
index 5739908..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-
-# Your secret key is used for verifying the integrity of signed cookies.
-# If you change this key, all old signed cookies will become invalid!
-
-# Make sure the secret is at least 30 characters and all random,
-# no regular words or you'll be exposed to dictionary attacks.
-# You can use `rails secret` to generate a secure secret key.
-
-# NOTE that these get overriden by Arvados' own configuration system.
-
-# development:
-#   secret_key_base: <%= rand(1<<255).to_s(36) %>
-
-# test:
-#   secret_key_base: <%= rand(1<<255).to_s(36) %>
-
-# In case this doesn't get overriden for some reason, assign a random key
-# to gracefully degrade by rejecting cookies instead of by opening a
-# vulnerability.
-production:
-  secret_key_base: <%= rand(1<<255).to_s(36) %>
diff --git a/apps/workbench/config/spring.rb b/apps/workbench/config/spring.rb
deleted file mode 100644 (file)
index 101e684..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-%w(
-  .ruby-version
-  .rbenv-vars
-  tmp/restart.txt
-  tmp/caching-dev.txt
-).each { |path| Spring.watch(path) }
diff --git a/apps/workbench/db/schema.rb b/apps/workbench/db/schema.rb
deleted file mode 100644 (file)
index 3412ad8..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# encoding: UTF-8
-# This file is auto-generated from the current state of the database. Instead
-# of editing this file, please use the migrations feature of Active Record to
-# incrementally modify your database, and then regenerate this schema definition.
-#
-# Note that this schema.rb definition is the authoritative source for your
-# database schema. If you need to create the application database on another
-# system, you should be using db:schema:load, not running all the migrations
-# from scratch. The latter is a flawed and unsustainable approach (the more migrations
-# you'll amass, the slower it'll run and the greater likelihood for issues).
-#
-# It's strongly recommended to check this file into your version control system.
-
-ActiveRecord::Schema.define(:version => 0) do
-
-end
diff --git a/apps/workbench/db/seeds.rb b/apps/workbench/db/seeds.rb
deleted file mode 100644 (file)
index d1ae89d..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# This file should contain all the record creation needed to seed the database with its default values.
-# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup).
-#
-# Examples:
-#
-#   cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }])
-#   Mayor.create(name: 'Emanuel', city: cities.first)
diff --git a/apps/workbench/fpm-info.sh b/apps/workbench/fpm-info.sh
deleted file mode 100644 (file)
index a09638a..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-case "$TARGET" in
-    centos*)
-        fpm_depends+=(git bison make automake gcc gcc-c++ graphviz shared-mime-info)
-        ;;
-    ubuntu1804)
-        fpm_depends+=(git g++ bison zlib1g-dev make graphviz shared-mime-info)
-        fpm_conflicts+=(ruby-bundler)
-        ;;
-    debian* | ubuntu*)
-        fpm_depends+=(git g++ bison zlib1g-dev make graphviz shared-mime-info)
-        ;;
-esac
diff --git a/apps/workbench/lib/app_version.rb b/apps/workbench/lib/app_version.rb
deleted file mode 100644 (file)
index 9db76e2..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# If you change this file, you'll probably also want to make the same
-# changes in services/api/lib/app_version.rb.
-
-class AppVersion
-  def self.git(*args, &block)
-    IO.popen(["git", "--git-dir", ".git"] + args, "r",
-             chdir: Rails.root.join('../..'),
-             err: "/dev/null",
-             &block)
-  end
-
-  def self.forget
-    @hash = nil
-    @package_version = nil
-  end
-
-  # Return abbrev commit hash for current code version: "abc1234", or
-  # "abc1234-modified" if there are uncommitted changes. If present,
-  # return contents of {root}/git-commit.version instead.
-  def self.hash
-    if (cached = Rails.configuration.source_version || @hash)
-      return cached
-    end
-
-    # Read the version from our package's git-commit.version file, if available.
-    begin
-      @hash = IO.read(Rails.root.join("git-commit.version")).strip
-    rescue Errno::ENOENT
-    end
-
-    if @hash.nil? or @hash.empty?
-      begin
-        local_modified = false
-        git("status", "--porcelain") do |git_pipe|
-          git_pipe.each_line do |_|
-            STDERR.puts _
-            local_modified = true
-            # Continue reading the pipe so git doesn't get SIGPIPE.
-          end
-        end
-        if $?.success?
-          git("log", "-n1", "--format=%H") do |git_pipe|
-            git_pipe.each_line do |line|
-              @hash = line.chomp[0...8] + (local_modified ? '-modified' : '')
-            end
-          end
-        end
-      rescue SystemCallError
-      end
-    end
-
-    @hash || "unknown"
-  end
-
-  def self.package_version
-    if (cached = Rails.configuration.package_version || @package_version)
-      return cached
-    end
-
-    begin
-      @package_version = IO.read(Rails.root.join("package-build.version")).strip
-    rescue Errno::ENOENT
-      @package_version = "unknown"
-    end
-
-    @package_version
-  end
-end
diff --git a/apps/workbench/lib/assets/.gitkeep b/apps/workbench/lib/assets/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/lib/assets/javascripts/webshell/shell_in_a_box.js b/apps/workbench/lib/assets/javascripts/webshell/shell_in_a_box.js
deleted file mode 100644 (file)
index 1002f7a..0000000
+++ /dev/null
@@ -1,4837 +0,0 @@
-// Copyright (C) 2008-2010 Markus Gutschke <markus@shellinabox.com> All rights reserved.
-//
-// SPDX-License-Identifier: GPL-2.0
-
-// This file contains code from shell_in_a_box.js and vt100.js
-
-
-// ShellInABox.js -- Use XMLHttpRequest to provide an AJAX terminal emulator.
-// Copyright (C) 2008-2010 Markus Gutschke <markus@shellinabox.com>
-//
-// This program is free software; you can redistribute it and/or modify
-// it under the terms of the GNU General Public License version 2 as
-// published by the Free Software Foundation.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License along
-// with this program; if not, write to the Free Software Foundation, Inc.,
-// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-//
-// In addition to these license terms, the author grants the following
-// additional rights:
-//
-// If you modify this program, or any covered work, by linking or
-// combining it with the OpenSSL project's OpenSSL library (or a
-// modified version of that library), containing parts covered by the
-// terms of the OpenSSL or SSLeay licenses, the author
-// grants you additional permission to convey the resulting work.
-// Corresponding Source for a non-source form of such a combination
-// shall include the source code for the parts of OpenSSL used as well
-// as that of the covered work.
-//
-// You may at your option choose to remove this additional permission from
-// the work, or from any part of it.
-//
-// It is possible to build this program in a way that it loads OpenSSL
-// libraries at run-time. If doing so, the following notices are required
-// by the OpenSSL and SSLeay licenses:
-//
-// This product includes software developed by the OpenSSL Project
-// for use in the OpenSSL Toolkit. (http://www.openssl.org/)
-//
-// This product includes cryptographic software written by Eric Young
-// (eay@cryptsoft.com)
-//
-//
-// The most up-to-date version of this program is always available from
-// http://shellinabox.com
-//
-//
-// Notes:
-//
-// The author believes that for the purposes of this license, you meet the
-// requirements for publishing the source code, if your web server publishes
-// the source in unmodified form (i.e. with licensing information, comments,
-// formatting, and identifier names intact). If there are technical reasons
-// that require you to make changes to the source code when serving the
-// JavaScript (e.g to remove pre-processor directives from the source), these
-// changes should be done in a reversible fashion.
-//
-// The author does not consider websites that reference this script in
-// unmodified form, and web servers that serve this script in unmodified form
-// to be derived works. As such, they are believed to be outside of the
-// scope of this license and not subject to the rights or restrictions of the
-// GNU General Public License.
-//
-// If in doubt, consult a legal professional familiar with the laws that
-// apply in your country.
-
-// #define XHR_UNITIALIZED 0
-// #define XHR_OPEN        1
-// #define XHR_SENT        2
-// #define XHR_RECEIVING   3
-// #define XHR_LOADED      4
-
-// IE does not define XMLHttpRequest by default, so we provide a suitable
-// wrapper.
-if (typeof XMLHttpRequest == 'undefined') {
-  XMLHttpRequest = function() {
-    try { return new ActiveXObject('Msxml2.XMLHTTP.6.0');} catch (e) { }
-    try { return new ActiveXObject('Msxml2.XMLHTTP.3.0');} catch (e) { }
-    try { return new ActiveXObject('Msxml2.XMLHTTP');    } catch (e) { }
-    try { return new ActiveXObject('Microsoft.XMLHTTP'); } catch (e) { }
-    throw new Error('');
-  };
-}
-
-function extend(subClass, baseClass) {
-  function inheritance() { }
-  inheritance.prototype          = baseClass.prototype;
-  subClass.prototype             = new inheritance();
-  subClass.prototype.constructor = subClass;
-  subClass.prototype.superClass  = baseClass.prototype;
-};
-
-function ShellInABox(url, container) {
-  if (url == undefined) {
-    this.rooturl    = document.location.href;
-    this.url        = document.location.href.replace(/[?#].*/, '');
-  } else {
-    this.rooturl    = url;
-    this.url        = url;
-  }
-  if (document.location.hash != '') {
-    var hash        = decodeURIComponent(document.location.hash).
-                      replace(/^#/, '');
-    this.nextUrl    = hash.replace(/,.*/, '');
-    this.session    = hash.replace(/[^,]*,/, '');
-  } else {
-    this.nextUrl    = this.url;
-    this.session    = null;
-  }
-  this.pendingKeys  = '';
-  this.keysInFlight = false;
-  this.connected    = false;
-  this.superClass.constructor.call(this, container);
-
-  // We have to initiate the first XMLHttpRequest from a timer. Otherwise,
-  // Chrome never realizes that the page has loaded.
-  setTimeout(function(shellInABox) {
-               return function() {
-                 shellInABox.sendRequest();
-               };
-             }(this), 1);
-};
-extend(ShellInABox, VT100);
-
-ShellInABox.prototype.sessionClosed = function() {
-  try {
-    this.connected    = false;
-    if (this.session) {
-      this.session    = undefined;
-      if (this.cursorX > 0) {
-        this.vt100('\r\n');
-      }
-      this.vt100('Session closed.');
-    }
-    // Revealing the "reconnect" button is commented out until we hook
-    // up the username+token auto-login mechanism to the new session:
-    //this.showReconnect(true);
-  } catch (e) {
-  }
-};
-
-ShellInABox.prototype.reconnect = function() {
-  this.showReconnect(false);
-  if (!this.session) {
-    if (document.location.hash != '') {
-      // A shellinaboxd daemon launched from a CGI only allows a single
-      // session. In order to reconnect, we must reload the frame definition
-      // and obtain a new port number. As this is a different origin, we
-      // need to get enclosing page to help us.
-      parent.location        = this.nextUrl;
-    } else {
-      if (this.url != this.nextUrl) {
-        document.location.replace(this.nextUrl);
-      } else {
-        this.pendingKeys     = '';
-        this.keysInFlight    = false;
-        this.reset(true);
-        this.sendRequest();
-      }
-    }
-  }
-  return false;
-};
-
-ShellInABox.prototype.sendRequest = function(request) {
-  if (request == undefined) {
-    request                  = new XMLHttpRequest();
-  }
-  request.open('POST', this.url + '?', true);
-  request.setRequestHeader('Cache-Control', 'no-cache');
-  request.setRequestHeader('Content-Type',
-                           'application/x-www-form-urlencoded; charset=utf-8');
-  var content                = 'width=' + this.terminalWidth +
-                               '&height=' + this.terminalHeight +
-                               (this.session ? '&session=' +
-                                encodeURIComponent(this.session) : '&rooturl='+
-                                encodeURIComponent(this.rooturl));
-
-  request.onreadystatechange = function(shellInABox) {
-    return function() {
-             try {
-               return shellInABox.onReadyStateChange(request);
-             } catch (e) {
-               shellInABox.sessionClosed();
-             }
-           }
-    }(this);
-  ShellInABox.lastRequestSent = Date.now();
-  request.send(content);
-};
-
-ShellInABox.prototype.onReadyStateChange = function(request) {
-  if (request.readyState == 4 /* XHR_LOADED */) {
-    if (request.status == 200) {
-      this.connected = true;
-      var response   = eval('(' + request.responseText + ')');
-      if (response.data) {
-        this.vt100(response.data);
-      }
-
-      if (!response.session ||
-          this.session && this.session != response.session) {
-        this.sessionClosed();
-      } else {
-        this.session = response.session;
-        this.sendRequest(request);
-      }
-    } else if (request.status == 0) {
-        if (ShellInABox.lastRequestSent + 2000 < Date.now()) {
-            // Timeout, try again
-            this.sendRequest(request);
-        } else {
-            this.vt100('\r\n\r\nRequest failed.');
-            this.sessionClosed();
-        }
-    } else {
-      this.sessionClosed();
-    }
-  }
-};
-
-ShellInABox.prototype.sendKeys = function(keys) {
-  if (!this.connected) {
-    return;
-  }
-  if (this.keysInFlight || this.session == undefined) {
-    this.pendingKeys          += keys;
-  } else {
-    this.keysInFlight          = true;
-    keys                       = this.pendingKeys + keys;
-    this.pendingKeys           = '';
-    var request                = new XMLHttpRequest();
-    request.open('POST', this.url + '?', true);
-    request.setRequestHeader('Cache-Control', 'no-cache');
-    request.setRequestHeader('Content-Type',
-                           'application/x-www-form-urlencoded; charset=utf-8');
-    var content                = 'width=' + this.terminalWidth +
-                                 '&height=' + this.terminalHeight +
-                                 '&session=' +encodeURIComponent(this.session)+
-                                 '&keys=' + encodeURIComponent(keys);
-    request.onreadystatechange = function(shellInABox) {
-      return function() {
-               try {
-                 return shellInABox.keyPressReadyStateChange(request);
-               } catch (e) {
-               }
-             }
-      }(this);
-    request.send(content);
-  }
-};
-
-ShellInABox.prototype.keyPressReadyStateChange = function(request) {
-  if (request.readyState == 4 /* XHR_LOADED */) {
-    this.keysInFlight = false;
-    if (this.pendingKeys) {
-      this.sendKeys('');
-    }
-  }
-};
-
-ShellInABox.prototype.keysPressed = function(ch) {
-  var hex = '0123456789ABCDEF';
-  var s   = '';
-  for (var i = 0; i < ch.length; i++) {
-    var c = ch.charCodeAt(i);
-    if (c < 128) {
-      s += hex.charAt(c >> 4) + hex.charAt(c & 0xF);
-    } else if (c < 0x800) {
-      s += hex.charAt(0xC +  (c >> 10)       ) +
-           hex.charAt(       (c >>  6) & 0xF ) +
-           hex.charAt(0x8 + ((c >>  4) & 0x3)) +
-           hex.charAt(        c        & 0xF );
-    } else if (c < 0x10000) {
-      s += 'E'                                 +
-           hex.charAt(       (c >> 12)       ) +
-           hex.charAt(0x8 + ((c >> 10) & 0x3)) +
-           hex.charAt(       (c >>  6) & 0xF ) +
-           hex.charAt(0x8 + ((c >>  4) & 0x3)) +
-           hex.charAt(        c        & 0xF );
-    } else if (c < 0x110000) {
-      s += 'F'                                 +
-           hex.charAt(       (c >> 18)       ) +
-           hex.charAt(0x8 + ((c >> 16) & 0x3)) +
-           hex.charAt(       (c >> 12) & 0xF ) +
-           hex.charAt(0x8 + ((c >> 10) & 0x3)) +
-           hex.charAt(       (c >>  6) & 0xF ) +
-           hex.charAt(0x8 + ((c >>  4) & 0x3)) +
-           hex.charAt(        c        & 0xF );
-    }
-  }
-  this.sendKeys(s);
-};
-
-ShellInABox.prototype.resized = function(w, h) {
-  // Do not send a resize request until we are fully initialized.
-  if (this.session) {
-    // sendKeys() always transmits the current terminal size. So, flush all
-    // pending keys.
-    this.sendKeys('');
-  }
-};
-
-ShellInABox.prototype.toggleSSL = function() {
-  if (document.location.hash != '') {
-    if (this.nextUrl.match(/\?plain$/)) {
-      this.nextUrl    = this.nextUrl.replace(/\?plain$/, '');
-    } else {
-      this.nextUrl    = this.nextUrl.replace(/[?#].*/, '') + '?plain';
-    }
-    if (!this.session) {
-      parent.location = this.nextUrl;
-    }
-  } else {
-    this.nextUrl      = this.nextUrl.match(/^https:/)
-           ? this.nextUrl.replace(/^https:/, 'http:').replace(/\/*$/, '/plain')
-           : this.nextUrl.replace(/^http/, 'https').replace(/\/*plain$/, '');
-  }
-  if (this.nextUrl.match(/^[:]*:\/\/[^/]*$/)) {
-    this.nextUrl     += '/';
-  }
-  if (this.session && this.nextUrl != this.url) {
-    alert('This change will take effect the next time you login.');
-  }
-};
-
-ShellInABox.prototype.extendContextMenu = function(entries, actions) {
-  // Modify the entries and actions in place, adding any locally defined
-  // menu entries.
-  var oldActions            = [ ];
-  for (var i = 0; i < actions.length; i++) {
-    oldActions[i]           = actions[i];
-  }
-  for (var node = entries.firstChild, i = 0, j = 0; node;
-       node = node.nextSibling) {
-    if (node.tagName == 'LI') {
-      actions[i++]          = oldActions[j++];
-      if (node.id == "endconfig") {
-        node.id             = '';
-        if (typeof serverSupportsSSL != 'undefined' && serverSupportsSSL &&
-            !(typeof disableSSLMenu != 'undefined' && disableSSLMenu)) {
-          // If the server supports both SSL and plain text connections,
-          // provide a menu entry to switch between the two.
-          var newNode       = document.createElement('li');
-          var isSecure;
-          if (document.location.hash != '') {
-            isSecure        = !this.nextUrl.match(/\?plain$/);
-          } else {
-            isSecure        =  this.nextUrl.match(/^https:/);
-          }
-          newNode.innerHTML = (isSecure ? '&#10004; ' : '') + 'Secure';
-          if (node.nextSibling) {
-            entries.insertBefore(newNode, node.nextSibling);
-          } else {
-            entries.appendChild(newNode);
-          }
-          actions[i++]      = this.toggleSSL;
-          node              = newNode;
-        }
-        node.id             = 'endconfig';
-      }
-    }
-  }
-
-};
-
-ShellInABox.prototype.about = function() {
-  alert("Shell In A Box version " + "2.10 (revision 239)" +
-        "\nCopyright 2008-2010 by Markus Gutschke\n" +
-        "For more information check http://shellinabox.com" +
-        (typeof serverSupportsSSL != 'undefined' && serverSupportsSSL ?
-         "\n\n" +
-         "This product includes software developed by the OpenSSL Project\n" +
-         "for use in the OpenSSL Toolkit. (http://www.openssl.org/)\n" +
-         "\n" +
-         "This product includes cryptographic software written by " +
-         "Eric Young\n(eay@cryptsoft.com)" :
-         ""));
-};
-
-
-// VT100.js -- JavaScript based terminal emulator
-// Copyright (C) 2008-2010 Markus Gutschke <markus@shellinabox.com>
-//
-// This program is free software; you can redistribute it and/or modify
-// it under the terms of the GNU General Public License version 2 as
-// published by the Free Software Foundation.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License along
-// with this program; if not, write to the Free Software Foundation, Inc.,
-// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-//
-// In addition to these license terms, the author grants the following
-// additional rights:
-//
-// If you modify this program, or any covered work, by linking or
-// combining it with the OpenSSL project's OpenSSL library (or a
-// modified version of that library), containing parts covered by the
-// terms of the OpenSSL or SSLeay licenses, the author
-// grants you additional permission to convey the resulting work.
-// Corresponding Source for a non-source form of such a combination
-// shall include the source code for the parts of OpenSSL used as well
-// as that of the covered work.
-//
-// You may at your option choose to remove this additional permission from
-// the work, or from any part of it.
-//
-// It is possible to build this program in a way that it loads OpenSSL
-// libraries at run-time. If doing so, the following notices are required
-// by the OpenSSL and SSLeay licenses:
-//
-// This product includes software developed by the OpenSSL Project
-// for use in the OpenSSL Toolkit. (http://www.openssl.org/)
-//
-// This product includes cryptographic software written by Eric Young
-// (eay@cryptsoft.com)
-//
-//
-// The most up-to-date version of this program is always available from
-// http://shellinabox.com
-//
-//
-// Notes:
-//
-// The author believes that for the purposes of this license, you meet the
-// requirements for publishing the source code, if your web server publishes
-// the source in unmodified form (i.e. with licensing information, comments,
-// formatting, and identifier names intact). If there are technical reasons
-// that require you to make changes to the source code when serving the
-// JavaScript (e.g to remove pre-processor directives from the source), these
-// changes should be done in a reversible fashion.
-//
-// The author does not consider websites that reference this script in
-// unmodified form, and web servers that serve this script in unmodified form
-// to be derived works. As such, they are believed to be outside of the
-// scope of this license and not subject to the rights or restrictions of the
-// GNU General Public License.
-//
-// If in doubt, consult a legal professional familiar with the laws that
-// apply in your country.
-
-// #define ESnormal        0
-// #define ESesc           1
-// #define ESsquare        2
-// #define ESgetpars       3
-// #define ESgotpars       4
-// #define ESdeviceattr    5
-// #define ESfunckey       6
-// #define EShash          7
-// #define ESsetG0         8
-// #define ESsetG1         9
-// #define ESsetG2        10
-// #define ESsetG3        11
-// #define ESbang         12
-// #define ESpercent      13
-// #define ESignore       14
-// #define ESnonstd       15
-// #define ESpalette      16
-// #define EStitle        17
-// #define ESss2          18
-// #define ESss3          19
-
-// #define ATTR_DEFAULT   0x00F0
-// #define ATTR_REVERSE   0x0100
-// #define ATTR_UNDERLINE 0x0200
-// #define ATTR_DIM       0x0400
-// #define ATTR_BRIGHT    0x0800
-// #define ATTR_BLINK     0x1000
-
-// #define MOUSE_DOWN     0
-// #define MOUSE_UP       1
-// #define MOUSE_CLICK    2
-
-function VT100(container) {
-  if (typeof linkifyURLs == 'undefined' || linkifyURLs <= 0) {
-    this.urlRE            = null;
-  } else {
-    this.urlRE            = new RegExp(
-    // Known URL protocol are "http", "https", and "ftp".
-    '(?:http|https|ftp)://' +
-
-    // Optionally allow username and passwords.
-    '(?:[^:@/ \u00A0]*(?::[^@/ \u00A0]*)?@)?' +
-
-    // Hostname.
-    '(?:[1-9][0-9]{0,2}(?:[.][1-9][0-9]{0,2}){3}|' +
-    '[0-9a-fA-F]{0,4}(?::{1,2}[0-9a-fA-F]{1,4})+|' +
-    '(?!-)[^[!"#$%&\'()*+,/:;<=>?@\\^_`{|}~\u0000- \u007F-\u00A0]+)' +
-
-    // Port
-    '(?::[1-9][0-9]*)?' +
-
-    // Path.
-    '(?:/(?:(?![/ \u00A0]|[,.)}"\u0027!]+[ \u00A0]|[,.)}"\u0027!]+$).)*)*|' +
-
-    (linkifyURLs <= 1 ? '' :
-    // Also support URLs without a protocol (assume "http").
-    // Optional username and password.
-    '(?:[^:@/ \u00A0]*(?::[^@/ \u00A0]*)?@)?' +
-
-    // Hostnames must end with a well-known top-level domain or must be
-    // numeric.
-    '(?:[1-9][0-9]{0,2}(?:[.][1-9][0-9]{0,2}){3}|' +
-    'localhost|' +
-    '(?:(?!-)' +
-        '[^.[!"#$%&\'()*+,/:;<=>?@\\^_`{|}~\u0000- \u007F-\u00A0]+[.]){2,}' +
-    '(?:(?:com|net|org|edu|gov|aero|asia|biz|cat|coop|info|int|jobs|mil|mobi|'+
-    'museum|name|pro|tel|travel|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|' +
-    'au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|' +
-    'ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|' +
-    'dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|' +
-    'gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|' +
-    'ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|' +
-    'lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|' +
-    'mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|' +
-    'pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|' +
-    'sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|' +
-    'tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|' +
-    'yu|za|zm|zw|arpa)(?![a-zA-Z0-9])|[Xx][Nn]--[-a-zA-Z0-9]+))' +
-
-    // Port
-    '(?::[1-9][0-9]{0,4})?' +
-
-    // Path.
-    '(?:/(?:(?![/ \u00A0]|[,.)}"\u0027!]+[ \u00A0]|[,.)}"\u0027!]+$).)*)*|') +
-
-    // In addition, support e-mail address. Optionally, recognize "mailto:"
-    '(?:mailto:)' + (linkifyURLs <= 1 ? '' : '?') +
-
-    // Username:
-    '[-_.+a-zA-Z0-9]+@' +
-
-    // Hostname.
-    '(?!-)[-a-zA-Z0-9]+(?:[.](?!-)[-a-zA-Z0-9]+)?[.]' +
-    '(?:(?:com|net|org|edu|gov|aero|asia|biz|cat|coop|info|int|jobs|mil|mobi|'+
-    'museum|name|pro|tel|travel|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|' +
-    'au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|' +
-    'ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|' +
-    'dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|' +
-    'gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|' +
-    'ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|' +
-    'lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|' +
-    'mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|' +
-    'pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|' +
-    'sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|' +
-    'tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|' +
-    'yu|za|zm|zw|arpa)(?![a-zA-Z0-9])|[Xx][Nn]--[-a-zA-Z0-9]+)' +
-
-    // Optional arguments
-    '(?:[?](?:(?![ \u00A0]|[,.)}"\u0027!]+[ \u00A0]|[,.)}"\u0027!]+$).)*)?');
-  }
-  this.getUserSettings();
-  this.initializeElements(container);
-  this.maxScrollbackLines = 500;
-  this.npar               = 0;
-  this.par                = [ ];
-  this.isQuestionMark     = false;
-  this.savedX             = [ ];
-  this.savedY             = [ ];
-  this.savedAttr          = [ ];
-  this.savedUseGMap       = 0;
-  this.savedGMap          = [ this.Latin1Map, this.VT100GraphicsMap,
-                              this.CodePage437Map, this.DirectToFontMap ];
-  this.savedValid         = [ ];
-  this.respondString      = '';
-  this.titleString        = '';
-  this.internalClipboard  = undefined;
-  this.reset(true);
-}
-
-VT100.prototype.reset = function(clearHistory) {
-  this.isEsc                                         = 0 /* ESnormal */;
-  this.needWrap                                      = false;
-  this.autoWrapMode                                  = true;
-  this.dispCtrl                                      = false;
-  this.toggleMeta                                    = false;
-  this.insertMode                                    = false;
-  this.applKeyMode                                   = false;
-  this.cursorKeyMode                                 = false;
-  this.crLfMode                                      = false;
-  this.offsetMode                                    = false;
-  this.mouseReporting                                = false;
-  this.printing                                      = false;
-  if (typeof this.printWin != 'undefined' &&
-      this.printWin && !this.printWin.closed) {
-    this.printWin.close();
-  }
-  this.printWin                                      = null;
-  this.utfEnabled                                    = this.utfPreferred;
-  this.utfCount                                      = 0;
-  this.utfChar                                       = 0;
-  this.color                                         = 'ansi0 bgAnsi15';
-  this.style                                         = '';
-  this.attr                                          = 0x00F0 /* ATTR_DEFAULT */;
-  this.useGMap                                       = 0;
-  this.GMap                                          = [ this.Latin1Map,
-                                                         this.VT100GraphicsMap,
-                                                         this.CodePage437Map,
-                                                         this.DirectToFontMap];
-  this.translate                                     = this.GMap[this.useGMap];
-  this.top                                           = 0;
-  this.bottom                                        = this.terminalHeight;
-  this.lastCharacter                                 = ' ';
-  this.userTabStop                                   = [ ];
-
-  if (clearHistory) {
-    for (var i = 0; i < 2; i++) {
-      while (this.console[i].firstChild) {
-        this.console[i].removeChild(this.console[i].firstChild);
-      }
-    }
-  }
-
-  this.enableAlternateScreen(false);
-
-  var wasCompressed                                  = false;
-  var transform                                      = this.getTransformName();
-  if (transform) {
-    for (var i = 0; i < 2; ++i) {
-      wasCompressed                  |= this.console[i].style[transform] != '';
-      this.console[i].style[transform]               = '';
-    }
-    this.cursor.style[transform]                     = '';
-    this.space.style[transform]                      = '';
-    if (transform == 'filter') {
-      this.console[this.currentScreen].style.width   = '';
-    }
-  }
-  this.scale                                         = 1.0;
-  if (wasCompressed) {
-    this.resizer();
-  }
-
-  this.gotoXY(0, 0);
-  this.showCursor();
-  this.isInverted                                    = false;
-  this.refreshInvertedState();
-  this.clearRegion(0, 0, this.terminalWidth, this.terminalHeight,
-                   this.color, this.style);
-};
-
-VT100.prototype.addListener = function(elem, event, listener) {
-  try {
-    if (elem.addEventListener) {
-      elem.addEventListener(event, listener, false);
-    } else {
-      elem.attachEvent('on' + event, listener);
-    }
-  } catch (e) {
-  }
-};
-
-VT100.prototype.getUserSettings = function() {
-  // Compute hash signature to identify the entries in the userCSS menu.
-  // If the menu is unchanged from last time, default values can be
-  // looked up in a cookie associated with this page.
-  this.signature            = 3;
-  this.utfPreferred         = true;
-  this.visualBell           = typeof suppressAllAudio != 'undefined' &&
-                              suppressAllAudio;
-  this.autoprint            = true;
-  this.softKeyboard         = false;
-  this.blinkingCursor       = true;
-  if (this.visualBell) {
-    this.signature          = Math.floor(16807*this.signature + 1) %
-                                         ((1 << 31) - 1);
-  }
-  if (typeof userCSSList != 'undefined') {
-    for (var i = 0; i < userCSSList.length; ++i) {
-      var label             = userCSSList[i][0];
-      for (var j = 0; j < label.length; ++j) {
-        this.signature      = Math.floor(16807*this.signature+
-                                         label.charCodeAt(j)) %
-                                         ((1 << 31) - 1);
-      }
-      if (userCSSList[i][1]) {
-        this.signature      = Math.floor(16807*this.signature + 1) %
-                                         ((1 << 31) - 1);
-      }
-    }
-  }
-
-  var key                   = 'shellInABox=' + this.signature + ':';
-  var settings              = document.cookie.indexOf(key);
-  if (settings >= 0) {
-    settings                = document.cookie.substr(settings + key.length).
-                                                   replace(/([0-1]*).*/, "$1");
-    if (settings.length == 5 + (typeof userCSSList == 'undefined' ?
-                                0 : userCSSList.length)) {
-      this.utfPreferred     = settings.charAt(0) != '0';
-      this.visualBell       = settings.charAt(1) != '0';
-      this.autoprint        = settings.charAt(2) != '0';
-      this.softKeyboard     = settings.charAt(3) != '0';
-      this.blinkingCursor   = settings.charAt(4) != '0';
-      if (typeof userCSSList != 'undefined') {
-        for (var i = 0; i < userCSSList.length; ++i) {
-          userCSSList[i][2] = settings.charAt(i + 5) != '0';
-        }
-      }
-    }
-  }
-  this.utfEnabled           = this.utfPreferred;
-};
-
-VT100.prototype.storeUserSettings = function() {
-  var settings  = 'shellInABox=' + this.signature + ':' +
-                  (this.utfEnabled     ? '1' : '0') +
-                  (this.visualBell     ? '1' : '0') +
-                  (this.autoprint      ? '1' : '0') +
-                  (this.softKeyboard   ? '1' : '0') +
-                  (this.blinkingCursor ? '1' : '0');
-  if (typeof userCSSList != 'undefined') {
-    for (var i = 0; i < userCSSList.length; ++i) {
-      settings += userCSSList[i][2] ? '1' : '0';
-    }
-  }
-  var d         = new Date();
-  d.setDate(d.getDate() + 3653);
-  document.cookie = settings + ';expires=' + d.toGMTString();
-};
-
-VT100.prototype.initializeUserCSSStyles = function() {
-  this.usercssActions                    = [];
-  if (typeof userCSSList != 'undefined') {
-    var menu                             = '';
-    var group                            = '';
-    var wasSingleSel                     = 1;
-    var beginOfGroup                     = 0;
-    for (var i = 0; i <= userCSSList.length; ++i) {
-      if (i < userCSSList.length) {
-        var label                        = userCSSList[i][0];
-        var newGroup                     = userCSSList[i][1];
-        var enabled                      = userCSSList[i][2];
-
-        // Add user style sheet to document
-        var style                        = document.createElement('link');
-        var id                           = document.createAttribute('id');
-        id.nodeValue                     = 'usercss-' + i;
-        style.setAttributeNode(id);
-        var rel                          = document.createAttribute('rel');
-        rel.nodeValue                    = 'stylesheet';
-        style.setAttributeNode(rel);
-        var href                         = document.createAttribute('href');
-        href.nodeValue                   = 'usercss-' + i + '.css';
-        style.setAttributeNode(href);
-        var type                         = document.createAttribute('type');
-        type.nodeValue                   = 'text/css';
-        style.setAttributeNode(type);
-        document.getElementsByTagName('head')[0].appendChild(style);
-        style.disabled                   = !enabled;
-      }
-
-      // Add entry to menu
-      if (newGroup || i == userCSSList.length) {
-        if (beginOfGroup != 0 && (i - beginOfGroup > 1 || !wasSingleSel)) {
-          // The last group had multiple entries that are mutually exclusive;
-          // or the previous to last group did. In either case, we need to
-          // append a "<hr />" before we can add the last group to the menu.
-          menu                          += '<hr />';
-        }
-        wasSingleSel                     = i - beginOfGroup < 1;
-        menu                            += group;
-        group                            = '';
-
-        for (var j = beginOfGroup; j < i; ++j) {
-          this.usercssActions[this.usercssActions.length] =
-            function(vt100, current, begin, count) {
-
-              // Deselect all other entries in the group, then either select
-              // (for multiple entries in group) or toggle (for on/off entry)
-              // the current entry.
-              return function() {
-                var entry                = vt100.getChildById(vt100.menu,
-                                                              'beginusercss');
-                var i                    = -1;
-                var j                    = -1;
-                for (var c = count; c > 0; ++j) {
-                  if (entry.tagName == 'LI') {
-                    if (++i >= begin) {
-                      --c;
-                      var label          = vt100.usercss.childNodes[j];
-
-                      // Restore label to just the text content
-                      if (typeof label.textContent == 'undefined') {
-                        var s            = label.innerText;
-                        label.innerHTML  = '';
-                        label.appendChild(document.createTextNode(s));
-                      } else {
-                        label.textContent= label.textContent;
-                      }
-
-                      // User style sheets are numbered sequentially
-                      var sheet          = document.getElementById(
-                                                               'usercss-' + i);
-                      if (i == current) {
-                        if (count == 1) {
-                          sheet.disabled = !sheet.disabled;
-                        } else {
-                          sheet.disabled = false;
-                        }
-                        if (!sheet.disabled) {
-                          label.innerHTML= '<img src="/webshell/enabled.gif" />' +
-                                           label.innerHTML;
-                        }
-                      } else {
-                        sheet.disabled   = true;
-                      }
-                      userCSSList[i][2]  = !sheet.disabled;
-                    }
-                  }
-                  entry                  = entry.nextSibling;
-                }
-
-                // If the font size changed, adjust cursor and line dimensions
-                this.cursor.style.cssText= '';
-                this.cursorWidth         = this.cursor.clientWidth;
-                this.cursorHeight        = this.lineheight.clientHeight;
-                for (i = 0; i < this.console.length; ++i) {
-                  for (var line = this.console[i].firstChild; line;
-                       line = line.nextSibling) {
-                    line.style.height    = this.cursorHeight + 'px';
-                  }
-                }
-                vt100.resizer();
-              };
-            }(this, j, beginOfGroup, i - beginOfGroup);
-        }
-
-        if (i == userCSSList.length) {
-          break;
-        }
-
-        beginOfGroup                     = i;
-      }
-      // Collect all entries in a group, before attaching them to the menu.
-      // This is necessary as we don't know whether this is a group of
-      // mutually exclusive options (which should be separated by "<hr />" on
-      // both ends), or whether this is a on/off toggle, which can be grouped
-      // together with other on/off options.
-      group                             +=
-        '<li>' + (enabled ? '<img src="/webshell/enabled.gif" />' : '') +
-                 label +
-        '</li>';
-    }
-    this.usercss.innerHTML               = menu;
-  }
-};
-
-VT100.prototype.resetLastSelectedKey = function(e) {
-  var key                          = this.lastSelectedKey;
-  if (!key) {
-    return false;
-  }
-
-  var position                     = this.mousePosition(e);
-
-  // We don't get all the necessary events to reliably reselect a key
-  // if we moved away from it and then back onto it. We approximate the
-  // behavior by remembering the key until either we release the mouse
-  // button (we might never get this event if the mouse has since left
-  // the window), or until we move away too far.
-  var box                          = this.keyboard.firstChild;
-  if (position[0] <  box.offsetLeft + key.offsetWidth ||
-      position[1] <  box.offsetTop + key.offsetHeight ||
-      position[0] >= box.offsetLeft + box.offsetWidth - key.offsetWidth ||
-      position[1] >= box.offsetTop + box.offsetHeight - key.offsetHeight ||
-      position[0] <  box.offsetLeft + key.offsetLeft - key.offsetWidth ||
-      position[1] <  box.offsetTop + key.offsetTop - key.offsetHeight ||
-      position[0] >= box.offsetLeft + key.offsetLeft + 2*key.offsetWidth ||
-      position[1] >= box.offsetTop + key.offsetTop + 2*key.offsetHeight) {
-    if (this.lastSelectedKey.className) log.console('reset: deselecting');
-    this.lastSelectedKey.className = '';
-    this.lastSelectedKey           = undefined;
-  }
-  return false;
-};
-
-VT100.prototype.showShiftState = function(state) {
-  var style              = document.getElementById('shift_state');
-  if (state) {
-    this.setTextContentRaw(style,
-                           '#vt100 #keyboard .shifted {' +
-                             'display: inline }' +
-                           '#vt100 #keyboard .unshifted {' +
-                             'display: none }');
-  } else {
-    this.setTextContentRaw(style, '');
-  }
-  var elems              = this.keyboard.getElementsByTagName('I');
-  for (var i = 0; i < elems.length; ++i) {
-    if (elems[i].id == '16') {
-      elems[i].className = state ? 'selected' : '';
-    }
-  }
-};
-
-VT100.prototype.showCtrlState = function(state) {
-  var ctrl         = this.getChildById(this.keyboard, '17' /* Ctrl */);
-  if (ctrl) {
-    ctrl.className = state ? 'selected' : '';
-  }
-};
-
-VT100.prototype.showAltState = function(state) {
-  var alt         = this.getChildById(this.keyboard, '18' /* Alt */);
-  if (alt) {
-    alt.className = state ? 'selected' : '';
-  }
-};
-
-VT100.prototype.clickedKeyboard = function(e, elem, ch, key, shift, ctrl, alt){
-  var fake      = [ ];
-  fake.charCode = ch;
-  fake.keyCode  = key;
-  fake.ctrlKey  = ctrl;
-  fake.shiftKey = shift;
-  fake.altKey   = alt;
-  fake.metaKey  = alt;
-  return this.handleKey(fake);
-};
-
-VT100.prototype.addKeyBinding = function(elem, ch, key, CH, KEY) {
-  if (elem == undefined) {
-    return;
-  }
-  if (ch == '\u00A0') {
-    // &nbsp; should be treated as a regular space character.
-    ch                                  = ' ';
-  }
-  if (ch != undefined && CH == undefined) {
-    // For letter keys, we automatically compute the uppercase character code
-    // from the lowercase one.
-    CH                                  = ch.toUpperCase();
-  }
-  if (KEY == undefined && key != undefined) {
-    // Most keys have identically key codes for both lowercase and uppercase
-    // keypresses. Normally, only function keys would have distinct key codes,
-    // whereas regular keys have character codes.
-    KEY                                 = key;
-  } else if (KEY == undefined && CH != undefined) {
-    // For regular keys, copy the character code to the key code.
-    KEY                                 = CH.charCodeAt(0);
-  }
-  if (key == undefined && ch != undefined) {
-    // For regular keys, copy the character code to the key code.
-    key                                 = ch.charCodeAt(0);
-  }
-  // Convert characters to numeric character codes. If the character code
-  // is undefined (i.e. this is a function key), set it to zero.
-  ch                                    = ch ? ch.charCodeAt(0) : 0;
-  CH                                    = CH ? CH.charCodeAt(0) : 0;
-
-  // Mouse down events high light the key. We also set lastSelectedKey. This
-  // is needed to that mouseout/mouseover can keep track of the key that
-  // is currently being clicked.
-  this.addListener(elem, 'mousedown',
-    function(vt100, elem, key) { return function(e) {
-      if ((e.which || e.button) == 1) {
-        if (vt100.lastSelectedKey) {
-          vt100.lastSelectedKey.className= '';
-        }
-        // Highlight the key while the mouse button is held down.
-        if (key == 16 /* Shift */) {
-          if (!elem.className != vt100.isShift) {
-            vt100.showShiftState(!vt100.isShift);
-          }
-        } else if (key == 17 /* Ctrl */) {
-          if (!elem.className != vt100.isCtrl) {
-            vt100.showCtrlState(!vt100.isCtrl);
-          }
-        } else if (key == 18 /* Alt */) {
-          if (!elem.className != vt100.isAlt) {
-            vt100.showAltState(!vt100.isAlt);
-          }
-        } else {
-          elem.className                  = 'selected';
-        }
-        vt100.lastSelectedKey             = elem;
-      }
-      return false; }; }(this, elem, key));
-  var clicked                           =
-    // Modifier keys update the state of the keyboard, but do not generate
-    // any key clicks that get forwarded to the application.
-    key >= 16 /* Shift */ && key <= 18 /* Alt */ ?
-    function(vt100, elem) { return function(e) {
-      if (elem == vt100.lastSelectedKey) {
-        if (key == 16 /* Shift */) {
-          // The user clicked the Shift key
-          vt100.isShift                 = !vt100.isShift;
-          vt100.showShiftState(vt100.isShift);
-        } else if (key == 17 /* Ctrl */) {
-          vt100.isCtrl                  = !vt100.isCtrl;
-          vt100.showCtrlState(vt100.isCtrl);
-        } else if (key == 18 /* Alt */) {
-          vt100.isAlt                   = !vt100.isAlt;
-          vt100.showAltState(vt100.isAlt);
-        }
-        vt100.lastSelectedKey           = undefined;
-      }
-      if (vt100.lastSelectedKey) {
-        vt100.lastSelectedKey.className = '';
-        vt100.lastSelectedKey           = undefined;
-      }
-      return false; }; }(this, elem) :
-    // Regular keys generate key clicks, when the mouse button is released or
-    // when a mouse click event is received.
-    function(vt100, elem, ch, key, CH, KEY) { return function(e) {
-      if (vt100.lastSelectedKey) {
-        if (elem == vt100.lastSelectedKey) {
-          // The user clicked a key.
-          if (vt100.isShift) {
-            vt100.clickedKeyboard(e, elem, CH, KEY,
-                                  true, vt100.isCtrl, vt100.isAlt);
-          } else {
-            vt100.clickedKeyboard(e, elem, ch, key,
-                                  false, vt100.isCtrl, vt100.isAlt);
-          }
-          vt100.isShift                 = false;
-          vt100.showShiftState(false);
-          vt100.isCtrl                  = false;
-          vt100.showCtrlState(false);
-          vt100.isAlt                   = false;
-          vt100.showAltState(false);
-        }
-        vt100.lastSelectedKey.className = '';
-        vt100.lastSelectedKey           = undefined;
-      }
-      elem.className                    = '';
-      return false; }; }(this, elem, ch, key, CH, KEY);
-  this.addListener(elem, 'mouseup', clicked);
-  this.addListener(elem, 'click', clicked);
-
-  // When moving the mouse away from a key, check if any keys need to be
-  // deselected.
-  this.addListener(elem, 'mouseout',
-    function(vt100, elem, key) { return function(e) {
-      if (key == 16 /* Shift */) {
-        if (!elem.className == vt100.isShift) {
-          vt100.showShiftState(vt100.isShift);
-        }
-      } else if (key == 17 /* Ctrl */) {
-        if (!elem.className == vt100.isCtrl) {
-          vt100.showCtrlState(vt100.isCtrl);
-        }
-      } else if (key == 18 /* Alt */) {
-        if (!elem.className == vt100.isAlt) {
-          vt100.showAltState(vt100.isAlt);
-        }
-      } else if (elem.className) {
-        elem.className                  = '';
-        vt100.lastSelectedKey           = elem;
-      } else if (vt100.lastSelectedKey) {
-        vt100.resetLastSelectedKey(e);
-      }
-      return false; }; }(this, elem, key));
-
-  // When moving the mouse over a key, select it if the user is still holding
-  // the mouse button down (i.e. elem == lastSelectedKey)
-  this.addListener(elem, 'mouseover',
-    function(vt100, elem, key) { return function(e) {
-      if (elem == vt100.lastSelectedKey) {
-        if (key == 16 /* Shift */) {
-          if (!elem.className != vt100.isShift) {
-            vt100.showShiftState(!vt100.isShift);
-          }
-        } else if (key == 17 /* Ctrl */) {
-          if (!elem.className != vt100.isCtrl) {
-            vt100.showCtrlState(!vt100.isCtrl);
-          }
-        } else if (key == 18 /* Alt */) {
-          if (!elem.className != vt100.isAlt) {
-            vt100.showAltState(!vt100.isAlt);
-          }
-        } else if (!elem.className) {
-          elem.className                = 'selected';
-        }
-      } else {
-        vt100.resetLastSelectedKey(e);
-      }
-      return false; }; }(this, elem, key));
-};
-
-VT100.prototype.initializeKeyBindings = function(elem) {
-  if (elem) {
-    if (elem.nodeName == "I" || elem.nodeName == "B") {
-      if (elem.id) {
-        // Function keys. The Javascript keycode is part of the "id"
-        var i     = parseInt(elem.id);
-        if (i) {
-          // If the id does not parse as a number, it is not a keycode.
-          this.addKeyBinding(elem, undefined, i);
-        }
-      } else {
-        var child = elem.firstChild;
-        if (child) {
-          if (child.nodeName == "#text") {
-            // If the key only has a text node as a child, then it is a letter.
-            // Automatically compute the lower and upper case version of the
-            // key.
-            var text = this.getTextContent(child) ||
-                       this.getTextContent(elem);
-            this.addKeyBinding(elem, text.toLowerCase());
-          } else if (child.nextSibling) {
-            // If the key has two children, they are the lower and upper case
-            // character code, respectively.
-            this.addKeyBinding(elem, this.getTextContent(child), undefined,
-                               this.getTextContent(child.nextSibling));
-          }
-        }
-      }
-    }
-  }
-  // Recursively parse all other child nodes.
-  for (elem = elem.firstChild; elem; elem = elem.nextSibling) {
-    this.initializeKeyBindings(elem);
-  }
-};
-
-VT100.prototype.initializeKeyboardButton = function() {
-  // Configure mouse event handlers for button that displays/hides keyboard
-  this.addListener(this.keyboardImage, 'click',
-    function(vt100) { return function(e) {
-      if (vt100.keyboard.style.display != '') {
-        if (vt100.reconnectBtn.style.visibility != '') {
-          vt100.initializeKeyboard();
-          vt100.showSoftKeyboard();
-        }
-      } else {
-        vt100.hideSoftKeyboard();
-        vt100.input.focus();
-      }
-      return false; }; }(this));
-
-  // Enable button that displays keyboard
-  if (this.softKeyboard) {
-    this.keyboardImage.style.visibility = 'visible';
-  }
-};
-
-VT100.prototype.initializeKeyboard = function() {
-  // Only need to initialize the keyboard the very first time. When doing so,
-  // copy the keyboard layout from the iframe.
-  if (this.keyboard.firstChild) {
-    return;
-  }
-  this.keyboard.innerHTML               =
-                                    this.layout.contentDocument.body.innerHTML;
-  var box                               = this.keyboard.firstChild;
-  this.hideSoftKeyboard();
-
-  // Configure mouse event handlers for on-screen keyboard
-  this.addListener(this.keyboard, 'click',
-    function(vt100) { return function(e) {
-      vt100.hideSoftKeyboard();
-      vt100.input.focus();
-      return false; }; }(this));
-  this.addListener(this.keyboard, 'selectstart', this.cancelEvent);
-  this.addListener(box, 'click', this.cancelEvent);
-  this.addListener(box, 'mouseup',
-    function(vt100) { return function(e) {
-      if (vt100.lastSelectedKey) {
-        vt100.lastSelectedKey.className = '';
-        vt100.lastSelectedKey           = undefined;
-      }
-      return false; }; }(this));
-  this.addListener(box, 'mouseout',
-    function(vt100) { return function(e) {
-      return vt100.resetLastSelectedKey(e); }; }(this));
-  this.addListener(box, 'mouseover',
-    function(vt100) { return function(e) {
-      return vt100.resetLastSelectedKey(e); }; }(this));
-
-  // Configure SHIFT key behavior
-  var style                             = document.createElement('style');
-  var id                                = document.createAttribute('id');
-  id.nodeValue                          = 'shift_state';
-  style.setAttributeNode(id);
-  var type                              = document.createAttribute('type');
-  type.nodeValue                        = 'text/css';
-  style.setAttributeNode(type);
-  document.getElementsByTagName('head')[0].appendChild(style);
-
-  // Set up key bindings
-  this.initializeKeyBindings(box);
-};
-
-VT100.prototype.initializeElements = function(container) {
-  // If the necessary objects have not already been defined in the HTML
-  // page, create them now.
-  if (container) {
-    this.container             = container;
-  } else if (!(this.container  = document.getElementById('vt100'))) {
-    this.container             = document.createElement('div');
-    this.container.id          = 'vt100';
-    document.body.appendChild(this.container);
-  }
-
-  if (!this.getChildById(this.container, 'reconnect')   ||
-      !this.getChildById(this.container, 'menu')        ||
-      !this.getChildById(this.container, 'keyboard')    ||
-      !this.getChildById(this.container, 'kbd_button')  ||
-      !this.getChildById(this.container, 'kbd_img')     ||
-      !this.getChildById(this.container, 'layout')      ||
-      !this.getChildById(this.container, 'scrollable')  ||
-      !this.getChildById(this.container, 'console')     ||
-      !this.getChildById(this.container, 'alt_console') ||
-      !this.getChildById(this.container, 'ieprobe')     ||
-      !this.getChildById(this.container, 'padding')     ||
-      !this.getChildById(this.container, 'cursor')      ||
-      !this.getChildById(this.container, 'lineheight')  ||
-      !this.getChildById(this.container, 'usercss')     ||
-      !this.getChildById(this.container, 'space')       ||
-      !this.getChildById(this.container, 'input')       ||
-      !this.getChildById(this.container, 'cliphelper')) {
-    // Only enable the "embed" object, if we have a suitable plugin. Otherwise,
-    // we might get a pointless warning that a suitable plugin is not yet
-    // installed. If in doubt, we'd rather just stay silent.
-    var embed                  = '';
-    try {
-      if (typeof navigator.mimeTypes["audio/x-wav"].enabledPlugin.name !=
-          'undefined') {
-        embed                  = typeof suppressAllAudio != 'undefined' &&
-                                 suppressAllAudio ? "" :
-        '<embed classid="clsid:02BF25D5-8C17-4B23-BC80-D3488ABDDC6B" ' +
-                       'id="beep_embed" ' +
-                       'src="beep.wav" ' +
-                       'autostart="false" ' +
-                       'volume="100" ' +
-                       'enablejavascript="true" ' +
-                       'type="audio/x-wav" ' +
-                       'height="16" ' +
-                       'width="200" ' +
-                       'style="position:absolute;left:-1000px;top:-1000px" />';
-      }
-    } catch (e) {
-    }
-
-    this.container.innerHTML   =
-                       '<div id="reconnect" style="visibility: hidden">' +
-                         '<input type="button" value="Connect" ' +
-                                'onsubmit="return false" />' +
-                       '</div>' +
-                       '<div id="cursize" style="visibility: hidden">' +
-                       '</div>' +
-                       '<div id="menu"></div>' +
-                       '<div id="keyboard" unselectable="on">' +
-                       '</div>' +
-                       '<div id="scrollable">' +
-                         '<table id="kbd_button">' +
-                           '<tr><td width="100%">&nbsp;</td>' +
-                           '<td><img id="kbd_img" src="/webshell/keyboard.png" /></td>' +
-                           '<td>&nbsp;&nbsp;&nbsp;&nbsp;</td></tr>' +
-                         '</table>' +
-                         '<pre id="lineheight">&nbsp;</pre>' +
-                         '<pre id="console">' +
-                           '<pre></pre>' +
-                           '<div id="ieprobe"><span>&nbsp;</span></div>' +
-                         '</pre>' +
-                         '<pre id="alt_console" style="display: none"></pre>' +
-                         '<div id="padding"></div>' +
-                         '<pre id="cursor">&nbsp;</pre>' +
-                       '</div>' +
-                       '<div class="hidden">' +
-                         '<div id="usercss"></div>' +
-                         '<pre><div><span id="space"></span></div></pre>' +
-                         '<input type="textfield" id="input" autocorrect="off" autocapitalize="off" />' +
-                         '<input type="textfield" id="cliphelper" />' +
-                         (typeof suppressAllAudio != 'undefined' &&
-                          suppressAllAudio ? "" :
-                         embed + '<bgsound id="beep_bgsound" loop=1 />') +
-                          '<iframe id="layout" src="/webshell/keyboard.html" />' +
-                        '</div>';
-  }
-
-  // Find the object used for playing the "beep" sound, if any.
-  if (typeof suppressAllAudio != 'undefined' && suppressAllAudio) {
-    this.beeper                = undefined;
-  } else {
-    this.beeper                = this.getChildById(this.container,
-                                                   'beep_embed');
-    if (!this.beeper || !this.beeper.Play) {
-      this.beeper              = this.getChildById(this.container,
-                                                   'beep_bgsound');
-      if (!this.beeper || typeof this.beeper.src == 'undefined') {
-        this.beeper            = undefined;
-      }
-    }
-  }
-
-  // Initialize the variables for finding the text console and the
-  // cursor.
-  this.reconnectBtn            = this.getChildById(this.container,'reconnect');
-  this.curSizeBox              = this.getChildById(this.container, 'cursize');
-  this.menu                    = this.getChildById(this.container, 'menu');
-  this.keyboard                = this.getChildById(this.container, 'keyboard');
-  this.keyboardImage           = this.getChildById(this.container, 'kbd_img');
-  this.layout                  = this.getChildById(this.container, 'layout');
-  this.scrollable              = this.getChildById(this.container,
-                                                                 'scrollable');
-  this.lineheight              = this.getChildById(this.container,
-                                                                 'lineheight');
-  this.console                 =
-                          [ this.getChildById(this.container, 'console'),
-                            this.getChildById(this.container, 'alt_console') ];
-  var ieProbe                  = this.getChildById(this.container, 'ieprobe');
-  this.padding                 = this.getChildById(this.container, 'padding');
-  this.cursor                  = this.getChildById(this.container, 'cursor');
-  this.usercss                 = this.getChildById(this.container, 'usercss');
-  this.space                   = this.getChildById(this.container, 'space');
-  this.input                   = this.getChildById(this.container, 'input');
-  this.cliphelper              = this.getChildById(this.container,
-                                                                 'cliphelper');
-
-  // Add any user selectable style sheets to the menu
-  this.initializeUserCSSStyles();
-
-  // Remember the dimensions of a standard character glyph. We would
-  // expect that we could just check cursor.clientWidth/Height at any time,
-  // but it turns out that browsers sometimes invalidate these values
-  // (e.g. while displaying a print preview screen).
-  this.cursorWidth             = this.cursor.clientWidth;
-  this.cursorHeight            = this.lineheight.clientHeight;
-
-  // IE has a slightly different boxing model, that we need to compensate for
-  this.isIE                    = ieProbe.offsetTop > 1;
-  ieProbe                      = undefined;
-  this.console.innerHTML       = '';
-
-  // Determine if the terminal window is positioned at the beginning of the
-  // page, or if it is embedded somewhere else in the page. For full-screen
-  // terminals, automatically resize whenever the browser window changes.
-  var marginTop                = parseInt(this.getCurrentComputedStyle(
-                                          document.body, 'marginTop'));
-  var marginLeft               = parseInt(this.getCurrentComputedStyle(
-                                          document.body, 'marginLeft'));
-  var marginRight              = parseInt(this.getCurrentComputedStyle(
-                                          document.body, 'marginRight'));
-  var x                        = this.container.offsetLeft;
-  var y                        = this.container.offsetTop;
-  for (var parent = this.container; parent = parent.offsetParent; ) {
-    x                         += parent.offsetLeft;
-    y                         += parent.offsetTop;
-  }
-  this.isEmbedded              = marginTop != y ||
-                                 marginLeft != x ||
-                                 (window.innerWidth ||
-                                  document.documentElement.clientWidth ||
-                                  document.body.clientWidth) -
-                                 marginRight != x + this.container.offsetWidth;
-  if (!this.isEmbedded) {
-    // Some browsers generate resize events when the terminal is first
-    // shown. Disable showing the size indicator until a little bit after
-    // the terminal has been rendered the first time.
-    this.indicateSize          = false;
-    setTimeout(function(vt100) {
-      return function() {
-        vt100.indicateSize     = true;
-      };
-    }(this), 100);
-    this.addListener(window, 'resize',
-                     function(vt100) {
-                       return function() {
-                         vt100.hideContextMenu();
-                         vt100.resizer();
-                         vt100.showCurrentSize();
-                        }
-                      }(this));
-
-    // Hide extra scrollbars attached to window
-    document.body.style.margin = '0px';
-    try { document.body.style.overflow ='hidden'; } catch (e) { }
-    try { document.body.oncontextmenu = function() {return false;};} catch(e){}
-  }
-
-  // Set up onscreen soft keyboard
-  this.initializeKeyboardButton();
-
-  // Hide context menu
-  this.hideContextMenu();
-
-  // Add listener to reconnect button
-  this.addListener(this.reconnectBtn.firstChild, 'click',
-                   function(vt100) {
-                     return function() {
-                       var rc = vt100.reconnect();
-                       vt100.input.focus();
-                       return rc;
-                     }
-                   }(this));
-
-  // Add input listeners
-  this.addListener(this.input, 'blur',
-                   function(vt100) {
-                     return function() { vt100.blurCursor(); } }(this));
-  this.addListener(this.input, 'focus',
-                   function(vt100) {
-                     return function() { vt100.focusCursor(); } }(this));
-  this.addListener(this.input, 'keydown',
-                   function(vt100) {
-                     return function(e) {
-                       if (!e) e = window.event;
-                       return vt100.keyDown(e); } }(this));
-  this.addListener(this.input, 'keypress',
-                   function(vt100) {
-                     return function(e) {
-                       if (!e) e = window.event;
-                       return vt100.keyPressed(e); } }(this));
-  this.addListener(this.input, 'keyup',
-                   function(vt100) {
-                     return function(e) {
-                       if (!e) e = window.event;
-                       return vt100.keyUp(e); } }(this));
-
-  // Attach listeners that move the focus to the <input> field. This way we
-  // can make sure that we can receive keyboard input.
-  var mouseEvent               = function(vt100, type) {
-    return function(e) {
-      if (!e) e = window.event;
-      return vt100.mouseEvent(e, type);
-    };
-  };
-  this.addListener(this.scrollable,'mousedown',mouseEvent(this, 0 /* MOUSE_DOWN */));
-  this.addListener(this.scrollable,'mouseup',  mouseEvent(this, 1 /* MOUSE_UP */));
-  this.addListener(this.scrollable,'click',    mouseEvent(this, 2 /* MOUSE_CLICK */));
-
-  // Check that browser supports drag and drop
-  if ('draggable' in document.createElement('span')) {
-      var dropEvent            = function (vt100) {
-          return function(e) {
-              if (!e) e = window.event;
-              if (e.preventDefault) e.preventDefault();
-              vt100.keysPressed(e.dataTransfer.getData('Text'));
-              return false;
-          };
-      };
-      // Tell the browser that we *can* drop on this target
-      this.addListener(this.scrollable, 'dragover', cancel);
-      this.addListener(this.scrollable, 'dragenter', cancel);
-
-      // Add a listener for the drop event
-      this.addListener(this.scrollable, 'drop', dropEvent(this));
-  }
-
-  // Initialize the blank terminal window.
-  this.currentScreen           = 0;
-  this.cursorX                 = 0;
-  this.cursorY                 = 0;
-  this.numScrollbackLines      = 0;
-  this.top                     = 0;
-  this.bottom                  = 0x7FFFFFFF;
-  this.scale                   = 1.0;
-  this.resizer();
-  this.focusCursor();
-  this.input.focus();
-};
-
-function cancel(event) {
-  if (event.preventDefault) {
-    event.preventDefault();
-  }
-  return false;
-}
-
-VT100.prototype.getChildById = function(parent, id) {
-  var nodeList = parent.all || parent.getElementsByTagName('*');
-  if (typeof nodeList.namedItem == 'undefined') {
-    for (var i = 0; i < nodeList.length; i++) {
-      if (nodeList[i].id == id) {
-        return nodeList[i];
-      }
-    }
-    return null;
-  } else {
-    var elem = (parent.all || parent.getElementsByTagName('*')).namedItem(id);
-    return elem ? elem[0] || elem : null;
-  }
-};
-
-VT100.prototype.getCurrentComputedStyle = function(elem, style) {
-  if (typeof elem.currentStyle != 'undefined') {
-    return elem.currentStyle[style];
-  } else {
-    return document.defaultView.getComputedStyle(elem, null)[style];
-  }
-};
-
-VT100.prototype.reconnect = function() {
-  return false;
-};
-
-VT100.prototype.showReconnect = function(state) {
-  if (state) {
-    this.hideSoftKeyboard();
-    this.reconnectBtn.style.visibility = '';
-  } else {
-    this.reconnectBtn.style.visibility = 'hidden';
-  }
-};
-
-VT100.prototype.repairElements = function(console) {
-  for (var line = console.firstChild; line; line = line.nextSibling) {
-    if (!line.clientHeight) {
-      var newLine = document.createElement(line.tagName);
-      newLine.style.cssText       = line.style.cssText;
-      newLine.className           = line.className;
-      if (line.tagName == 'DIV') {
-        for (var span = line.firstChild; span; span = span.nextSibling) {
-          var newSpan             = document.createElement(span.tagName);
-          newSpan.style.cssText   = span.style.cssText;
-          newSpan.className       = span.className;
-          this.setTextContent(newSpan, this.getTextContent(span));
-          newLine.appendChild(newSpan);
-        }
-      } else {
-        this.setTextContent(newLine, this.getTextContent(line));
-      }
-      line.parentNode.replaceChild(newLine, line);
-      line                        = newLine;
-    }
-  }
-};
-
-VT100.prototype.resized = function(w, h) {
-};
-
-VT100.prototype.resizer = function() {
-  // Hide onscreen soft keyboard
-  this.hideSoftKeyboard();
-
-  // The cursor can get corrupted if the print-preview is displayed in Firefox.
-  // Recreating it, will repair it.
-  var newCursor                = document.createElement('pre');
-  this.setTextContent(newCursor, ' ');
-  newCursor.id                 = 'cursor';
-  newCursor.style.cssText      = this.cursor.style.cssText;
-  this.cursor.parentNode.insertBefore(newCursor, this.cursor);
-  if (!newCursor.clientHeight) {
-    // Things are broken right now. This is probably because we are
-    // displaying the print-preview. Just don't change any of our settings
-    // until the print dialog is closed again.
-    newCursor.parentNode.removeChild(newCursor);
-    return;
-  } else {
-    // Swap the old broken cursor for the newly created one.
-    this.cursor.parentNode.removeChild(this.cursor);
-    this.cursor                = newCursor;
-  }
-
-  // Really horrible things happen if the contents of the terminal changes
-  // while the print-preview is showing. We get HTML elements that show up
-  // in the DOM, but that do not take up any space. Find these elements and
-  // try to fix them.
-  this.repairElements(this.console[0]);
-  this.repairElements(this.console[1]);
-
-  // Lock the cursor size to the size of a normal character. This helps with
-  // characters that are taller/shorter than normal. Unfortunately, we will
-  // still get confused if somebody enters a character that is wider/narrower
-  // than normal. This can happen if the browser tries to substitute a
-  // characters from a different font.
-  this.cursor.style.width      = this.cursorWidth  + 'px';
-  this.cursor.style.height     = this.cursorHeight + 'px';
-
-  // Adjust height for one pixel padding of the #vt100 element.
-  // The latter is necessary to properly display the inactive cursor.
-  var console                  = this.console[this.currentScreen];
-  var height                   = (this.isEmbedded ? this.container.clientHeight
-                                  : (window.innerHeight ||
-                                     document.documentElement.clientHeight ||
-                                     document.body.clientHeight))-1;
-  var partial                  = height % this.cursorHeight;
-  this.scrollable.style.height = (height > 0 ? height : 0) + 'px';
-  this.padding.style.height    = (partial > 0 ? partial : 0) + 'px';
-  var oldTerminalHeight        = this.terminalHeight;
-  this.updateWidth();
-  this.updateHeight();
-
-  // Clip the cursor to the visible screen.
-  var cx                       = this.cursorX;
-  var cy                       = this.cursorY + this.numScrollbackLines;
-
-  // The alternate screen never keeps a scroll back buffer.
-  this.updateNumScrollbackLines();
-  while (this.currentScreen && this.numScrollbackLines > 0) {
-    console.removeChild(console.firstChild);
-    this.numScrollbackLines--;
-  }
-  cy                          -= this.numScrollbackLines;
-  if (cx < 0) {
-    cx                         = 0;
-  } else if (cx > this.terminalWidth) {
-    cx                         = this.terminalWidth - 1;
-    if (cx < 0) {
-      cx                       = 0;
-    }
-  }
-  if (cy < 0) {
-    cy                         = 0;
-  } else if (cy > this.terminalHeight) {
-    cy                         = this.terminalHeight - 1;
-    if (cy < 0) {
-      cy                       = 0;
-    }
-  }
-
-  // Clip the scroll region to the visible screen.
-  if (this.bottom > this.terminalHeight ||
-      this.bottom == oldTerminalHeight) {
-    this.bottom                = this.terminalHeight;
-  }
-  if (this.top >= this.bottom) {
-    this.top                   = this.bottom-1;
-    if (this.top < 0) {
-      this.top                 = 0;
-    }
-  }
-
-  // Truncate lines, if necessary. Explicitly reposition cursor (this is
-  // particularly important after changing the screen number), and reset
-  // the scroll region to the default.
-  this.truncateLines(this.terminalWidth);
-  this.putString(cx, cy, '', undefined);
-  this.scrollable.scrollTop    = this.numScrollbackLines *
-                                 this.cursorHeight + 1;
-
-  // Update classNames for lines in the scrollback buffer
-  var line                     = console.firstChild;
-  for (var i = 0; i < this.numScrollbackLines; i++) {
-    line.className             = 'scrollback';
-    line                       = line.nextSibling;
-  }
-  while (line) {
-    line.className             = '';
-    line                       = line.nextSibling;
-  }
-
-  // Reposition the reconnect button
-  this.reconnectBtn.style.left = (this.terminalWidth*this.cursorWidth/
-                                  this.scale -
-                                  this.reconnectBtn.clientWidth)/2 + 'px';
-  this.reconnectBtn.style.top  = (this.terminalHeight*this.cursorHeight-
-                                  this.reconnectBtn.clientHeight)/2 + 'px';
-
-  // Send notification that the window size has been changed
-  this.resized(this.terminalWidth, this.terminalHeight);
-};
-
-VT100.prototype.showCurrentSize = function() {
-  if (!this.indicateSize) {
-    return;
-  }
-  this.curSizeBox.innerHTML             = '' + this.terminalWidth + 'x' +
-                                               this.terminalHeight;
-  this.curSizeBox.style.left            =
-                                      (this.terminalWidth*this.cursorWidth/
-                                       this.scale -
-                                       this.curSizeBox.clientWidth)/2 + 'px';
-  this.curSizeBox.style.top             =
-                                      (this.terminalHeight*this.cursorHeight -
-                                       this.curSizeBox.clientHeight)/2 + 'px';
-  this.curSizeBox.style.visibility      = '';
-  if (this.curSizeTimeout) {
-    clearTimeout(this.curSizeTimeout);
-  }
-
-  // Only show the terminal size for a short amount of time after resizing.
-  // Then hide this information, again. Some browsers generate resize events
-  // throughout the entire resize operation. This is nice, and we will show
-  // the terminal size while the user is dragging the window borders.
-  // Other browsers only generate a single event when the user releases the
-  // mouse. In those cases, we can only show the terminal size once at the
-  // end of the resize operation.
-  this.curSizeTimeout                   = setTimeout(function(vt100) {
-    return function() {
-      vt100.curSizeTimeout              = null;
-      vt100.curSizeBox.style.visibility = 'hidden';
-    };
-  }(this), 1000);
-};
-
-VT100.prototype.selection = function() {
-  try {
-    return '' + (window.getSelection && window.getSelection() ||
-                 document.selection && document.selection.type == 'Text' &&
-                 document.selection.createRange().text || '');
-  } catch (e) {
-  }
-  return '';
-};
-
-VT100.prototype.cancelEvent = function(event) {
-  try {
-    // For non-IE browsers
-    event.stopPropagation();
-    event.preventDefault();
-  } catch (e) {
-  }
-  try {
-    // For IE
-    event.cancelBubble = true;
-    event.returnValue  = false;
-    event.button       = 0;
-    event.keyCode      = 0;
-  } catch (e) {
-  }
-  return false;
-};
-
-VT100.prototype.mousePosition = function(event) {
-  var offsetX      = this.container.offsetLeft;
-  var offsetY      = this.container.offsetTop;
-  for (var e = this.container; e = e.offsetParent; ) {
-    offsetX       += e.offsetLeft;
-    offsetY       += e.offsetTop;
-  }
-  return [ event.clientX - offsetX,
-           event.clientY - offsetY ];
-};
-
-VT100.prototype.mouseEvent = function(event, type) {
-  // If any text is currently selected, do not move the focus as that would
-  // invalidate the selection.
-  var selection    = this.selection();
-  if ((type == 1 /* MOUSE_UP */ || type == 2 /* MOUSE_CLICK */) && !selection.length) {
-    this.input.focus();
-  }
-
-  // Compute mouse position in characters.
-  var position     = this.mousePosition(event);
-  var x            = Math.floor(position[0] / this.cursorWidth);
-  var y            = Math.floor((position[1] + this.scrollable.scrollTop) /
-                                this.cursorHeight) - this.numScrollbackLines;
-  var inside       = true;
-  if (x >= this.terminalWidth) {
-    x              = this.terminalWidth - 1;
-    inside         = false;
-  }
-  if (x < 0) {
-    x              = 0;
-    inside         = false;
-  }
-  if (y >= this.terminalHeight) {
-    y              = this.terminalHeight - 1;
-    inside         = false;
-  }
-  if (y < 0) {
-    y              = 0;
-    inside         = false;
-  }
-
-  // Compute button number and modifier keys.
-  var button       = type != 0 /* MOUSE_DOWN */ ? 3 :
-                     typeof event.pageX != 'undefined' ? event.button :
-                     [ undefined, 0, 2, 0, 1, 0, 1, 0  ][event.button];
-  if (button != undefined) {
-    if (event.shiftKey) {
-      button      |= 0x04;
-    }
-    if (event.altKey || event.metaKey) {
-      button      |= 0x08;
-    }
-    if (event.ctrlKey) {
-      button      |= 0x10;
-    }
-  }
-
-  // Report mouse events if they happen inside of the current screen and
-  // with the SHIFT key unpressed. Both of these restrictions do not apply
-  // for button releases, as we always want to report those.
-  if (this.mouseReporting && !selection.length &&
-      (type != 0 /* MOUSE_DOWN */ || !event.shiftKey)) {
-    if (inside || type != 0 /* MOUSE_DOWN */) {
-      if (button != undefined) {
-        var report = '\u001B[M' + String.fromCharCode(button + 32) +
-                                  String.fromCharCode(x      + 33) +
-                                  String.fromCharCode(y      + 33);
-        if (type != 2 /* MOUSE_CLICK */) {
-          this.keysPressed(report);
-        }
-
-        // If we reported the event, stop propagating it (not sure, if this
-        // actually works on most browsers; blocking the global "oncontextmenu"
-        // even is still necessary).
-        return this.cancelEvent(event);
-      }
-    }
-  }
-
-  // Bring up context menu.
-  if (button == 2 && !event.shiftKey) {
-    if (type == 0 /* MOUSE_DOWN */) {
-      this.showContextMenu(position[0], position[1]);
-    }
-    return this.cancelEvent(event);
-  }
-
-  if (this.mouseReporting) {
-    try {
-      event.shiftKey         = false;
-    } catch (e) {
-    }
-  }
-
-  return true;
-};
-
-VT100.prototype.replaceChar = function(s, ch, repl) {
-  for (var i = -1;;) {
-    i = s.indexOf(ch, i + 1);
-    if (i < 0) {
-      break;
-    }
-    s = s.substr(0, i) + repl + s.substr(i + 1);
-  }
-  return s;
-};
-
-VT100.prototype.htmlEscape = function(s) {
-  return this.replaceChar(this.replaceChar(this.replaceChar(this.replaceChar(
-                s, '&', '&amp;'), '<', '&lt;'), '"', '&quot;'), ' ', '\u00A0');
-};
-
-VT100.prototype.getTextContent = function(elem) {
-  return elem.textContent ||
-         (typeof elem.textContent == 'undefined' ? elem.innerText : '');
-};
-
-VT100.prototype.setTextContentRaw = function(elem, s) {
-  // Updating the content of an element is an expensive operation. It actually
-  // pays off to first check whether the element is still unchanged.
-  if (typeof elem.textContent == 'undefined') {
-    if (elem.innerText != s) {
-      try {
-        elem.innerText = s;
-      } catch (e) {
-        // Very old versions of IE do not allow setting innerText. Instead,
-        // remove all children, by setting innerHTML and then set the text
-        // using DOM methods.
-        elem.innerHTML = '';
-        elem.appendChild(document.createTextNode(
-                                          this.replaceChar(s, ' ', '\u00A0')));
-      }
-    }
-  } else {
-    if (elem.textContent != s) {
-      elem.textContent = s;
-    }
-  }
-};
-
-VT100.prototype.setTextContent = function(elem, s) {
-  // Check if we find any URLs in the text. If so, automatically convert them
-  // to links.
-  if (this.urlRE && this.urlRE.test(s)) {
-    var inner          = '';
-    for (;;) {
-      var consumed = 0;
-      if (RegExp.leftContext != null) {
-        inner         += this.htmlEscape(RegExp.leftContext);
-        consumed      += RegExp.leftContext.length;
-      }
-      var url          = this.htmlEscape(RegExp.lastMatch);
-      var fullUrl      = url;
-
-      // If no protocol was specified, try to guess a reasonable one.
-      if (url.indexOf('http://') < 0 && url.indexOf('https://') < 0 &&
-          url.indexOf('ftp://')  < 0 && url.indexOf('mailto:')  < 0) {
-        var slash      = url.indexOf('/');
-        var at         = url.indexOf('@');
-        var question   = url.indexOf('?');
-        if (at > 0 &&
-            (at < question || question < 0) &&
-            (slash < 0 || (question > 0 && slash > question))) {
-          fullUrl      = 'mailto:' + url;
-        } else {
-          fullUrl      = (url.indexOf('ftp.') == 0 ? 'ftp://' : 'http://') +
-                          url;
-        }
-      }
-
-      inner           += '<a target="vt100Link" href="' + fullUrl +
-                         '">' + url + '</a>';
-      consumed        += RegExp.lastMatch.length;
-      s                = s.substr(consumed);
-      if (!this.urlRE.test(s)) {
-        if (RegExp.rightContext != null) {
-          inner       += this.htmlEscape(RegExp.rightContext);
-        }
-        break;
-      }
-    }
-    elem.innerHTML     = inner;
-    return;
-  }
-
-  this.setTextContentRaw(elem, s);
-};
-
-VT100.prototype.insertBlankLine = function(y, color, style) {
-  // Insert a blank line a position y. This method ignores the scrollback
-  // buffer. The caller has to add the length of the scrollback buffer to
-  // the position, if necessary.
-  // If the position is larger than the number of current lines, this
-  // method just adds a new line right after the last existing one. It does
-  // not add any missing lines in between. It is the caller's responsibility
-  // to do so.
-  if (!color) {
-    color                = 'ansi0 bgAnsi15';
-  }
-  if (!style) {
-    style                = '';
-  }
-  var line;
-  if (color != 'ansi0 bgAnsi15' && !style) {
-    line                 = document.createElement('pre');
-    this.setTextContent(line, '\n');
-  } else {
-    line                 = document.createElement('div');
-    var span             = document.createElement('span');
-    span.style.cssText   = style;
-    span.className       = color;
-    this.setTextContent(span, this.spaces(this.terminalWidth));
-    line.appendChild(span);
-  }
-  line.style.height      = this.cursorHeight + 'px';
-  var console            = this.console[this.currentScreen];
-  if (console.childNodes.length > y) {
-    console.insertBefore(line, console.childNodes[y]);
-  } else {
-    console.appendChild(line);
-  }
-};
-
-VT100.prototype.updateWidth = function() {
-  this.terminalWidth = Math.floor(this.console[this.currentScreen].offsetWidth/
-                                  this.cursorWidth*this.scale);
-  return this.terminalWidth;
-};
-
-VT100.prototype.updateHeight = function() {
-  // We want to be able to display either a terminal window that fills the
-  // entire browser window, or a terminal window that is contained in a
-  // <div> which is embededded somewhere in the web page.
-  if (this.isEmbedded) {
-    // Embedded terminal. Use size of the containing <div> (id="vt100").
-    this.terminalHeight = Math.floor((this.container.clientHeight-1) /
-                                     this.cursorHeight);
-  } else {
-    // Use the full browser window.
-    this.terminalHeight = Math.floor(((window.innerHeight ||
-                                       document.documentElement.clientHeight ||
-                                       document.body.clientHeight)-1)/
-                                     this.cursorHeight);
-  }
-  return this.terminalHeight;
-};
-
-VT100.prototype.updateNumScrollbackLines = function() {
-  var scrollback          = Math.floor(
-                                this.console[this.currentScreen].offsetHeight /
-                                this.cursorHeight) -
-                            this.terminalHeight;
-  this.numScrollbackLines = scrollback < 0 ? 0 : scrollback;
-  return this.numScrollbackLines;
-};
-
-VT100.prototype.truncateLines = function(width) {
-  if (width < 0) {
-    width             = 0;
-  }
-  for (var line = this.console[this.currentScreen].firstChild; line;
-       line = line.nextSibling) {
-    if (line.tagName == 'DIV') {
-      var x           = 0;
-
-      // Traverse current line and truncate it once we saw "width" characters
-      for (var span = line.firstChild; span;
-           span = span.nextSibling) {
-        var s         = this.getTextContent(span);
-        var l         = s.length;
-        if (x + l > width) {
-          this.setTextContent(span, s.substr(0, width - x));
-          while (span.nextSibling) {
-            line.removeChild(line.lastChild);
-          }
-          break;
-        }
-        x            += l;
-      }
-      // Prune white space from the end of the current line
-      var span       = line.lastChild;
-      while (span &&
-             span.className == 'ansi0 bgAnsi15' &&
-             !span.style.cssText.length) {
-        // Scan backwards looking for first non-space character
-        var s         = this.getTextContent(span);
-        for (var i = s.length; i--; ) {
-          if (s.charAt(i) != ' ' && s.charAt(i) != '\u00A0') {
-            if (i+1 != s.length) {
-              this.setTextContent(s.substr(0, i+1));
-            }
-            span      = null;
-            break;
-          }
-        }
-        if (span) {
-          var sibling = span;
-          span        = span.previousSibling;
-          if (span) {
-            // Remove blank <span>'s from end of line
-            line.removeChild(sibling);
-          } else {
-            // Remove entire line (i.e. <div>), if empty
-            var blank = document.createElement('pre');
-            blank.style.height = this.cursorHeight + 'px';
-            this.setTextContent(blank, '\n');
-            line.parentNode.replaceChild(blank, line);
-          }
-        }
-      }
-    }
-  }
-};
-
-VT100.prototype.putString = function(x, y, text, color, style) {
-  if (!color) {
-    color                           = 'ansi0 bgAnsi15';
-  }
-  if (!style) {
-    style                           = '';
-  }
-  var yIdx                          = y + this.numScrollbackLines;
-  var line;
-  var sibling;
-  var s;
-  var span;
-  var xPos                          = 0;
-  var console                       = this.console[this.currentScreen];
-  if (!text.length && (yIdx >= console.childNodes.length ||
-                       console.childNodes[yIdx].tagName != 'DIV')) {
-    // Positioning cursor to a blank location
-    span                            = null;
-  } else {
-    // Create missing blank lines at end of page
-    while (console.childNodes.length <= yIdx) {
-      // In order to simplify lookups, we want to make sure that each line
-      // is represented by exactly one element (and possibly a whole bunch of
-      // children).
-      // For non-blank lines, we can create a <div> containing one or more
-      // <span>s. For blank lines, this fails as browsers tend to optimize them
-      // away. But fortunately, a <pre> tag containing a newline character
-      // appears to work for all browsers (a &nbsp; would also work, but then
-      // copying from the browser window would insert superfluous spaces into
-      // the clipboard).
-      this.insertBlankLine(yIdx);
-    }
-    line                            = console.childNodes[yIdx];
-
-    // If necessary, promote blank '\n' line to a <div> tag
-    if (line.tagName != 'DIV') {
-      var div                       = document.createElement('div');
-      div.style.height              = this.cursorHeight + 'px';
-      div.innerHTML                 = '<span></span>';
-      console.replaceChild(div, line);
-      line                          = div;
-    }
-
-    // Scan through list of <span>'s until we find the one where our text
-    // starts
-    span                            = line.firstChild;
-    var len;
-    while (span.nextSibling && xPos < x) {
-      len                           = this.getTextContent(span).length;
-      if (xPos + len > x) {
-        break;
-      }
-      xPos                         += len;
-      span                          = span.nextSibling;
-    }
-
-    if (text.length) {
-      // If current <span> is not long enough, pad with spaces or add new
-      // span
-      s                             = this.getTextContent(span);
-      var oldColor                  = span.className;
-      var oldStyle                  = span.style.cssText;
-      if (xPos + s.length < x) {
-        if (oldColor != 'ansi0 bgAnsi15' || oldStyle != '') {
-          span                      = document.createElement('span');
-          line.appendChild(span);
-          span.className            = 'ansi0 bgAnsi15';
-          span.style.cssText        = '';
-          oldColor                  = 'ansi0 bgAnsi15';
-          oldStyle                  = '';
-          xPos                     += s.length;
-          s                         = '';
-        }
-        do {
-          s                        += ' ';
-        } while (xPos + s.length < x);
-      }
-
-      // If styles do not match, create a new <span>
-      var del                       = text.length - s.length + x - xPos;
-      if (oldColor != color ||
-          (oldStyle != style && (oldStyle || style))) {
-        if (xPos == x) {
-          // Replacing text at beginning of existing <span>
-          if (text.length >= s.length) {
-            // New text is equal or longer than existing text
-            s                       = text;
-          } else {
-            // Insert new <span> before the current one, then remove leading
-            // part of existing <span>, adjust style of new <span>, and finally
-            // set its contents
-            sibling                 = document.createElement('span');
-            line.insertBefore(sibling, span);
-            this.setTextContent(span, s.substr(text.length));
-            span                    = sibling;
-            s                       = text;
-          }
-        } else {
-          // Replacing text some way into the existing <span>
-          var remainder             = s.substr(x + text.length - xPos);
-          this.setTextContent(span, s.substr(0, x - xPos));
-          xPos                      = x;
-          sibling                   = document.createElement('span');
-          if (span.nextSibling) {
-            line.insertBefore(sibling, span.nextSibling);
-            span                    = sibling;
-            if (remainder.length) {
-              sibling               = document.createElement('span');
-              sibling.className     = oldColor;
-              sibling.style.cssText = oldStyle;
-              this.setTextContent(sibling, remainder);
-              line.insertBefore(sibling, span.nextSibling);
-            }
-          } else {
-            line.appendChild(sibling);
-            span                    = sibling;
-            if (remainder.length) {
-              sibling               = document.createElement('span');
-              sibling.className     = oldColor;
-              sibling.style.cssText = oldStyle;
-              this.setTextContent(sibling, remainder);
-              line.appendChild(sibling);
-            }
-          }
-          s                         = text;
-        }
-        span.className              = color;
-        span.style.cssText          = style;
-      } else {
-        // Overwrite (partial) <span> with new text
-        s                           = s.substr(0, x - xPos) +
-          text +
-          s.substr(x + text.length - xPos);
-      }
-      this.setTextContent(span, s);
-
-
-      // Delete all subsequent <span>'s that have just been overwritten
-      sibling                       = span.nextSibling;
-      while (del > 0 && sibling) {
-        s                           = this.getTextContent(sibling);
-        len                         = s.length;
-        if (len <= del) {
-          line.removeChild(sibling);
-          del                      -= len;
-          sibling                   = span.nextSibling;
-        } else {
-          this.setTextContent(sibling, s.substr(del));
-          break;
-        }
-      }
-
-      // Merge <span> with next sibling, if styles are identical
-      if (sibling && span.className == sibling.className &&
-          span.style.cssText == sibling.style.cssText) {
-        this.setTextContent(span,
-                            this.getTextContent(span) +
-                            this.getTextContent(sibling));
-        line.removeChild(sibling);
-      }
-    }
-  }
-
-  // Position cursor
-  this.cursorX                      = x + text.length;
-  if (this.cursorX >= this.terminalWidth) {
-    this.cursorX                    = this.terminalWidth - 1;
-    if (this.cursorX < 0) {
-      this.cursorX                  = 0;
-    }
-  }
-  var pixelX                        = -1;
-  var pixelY                        = -1;
-  if (!this.cursor.style.visibility) {
-    var idx                         = this.cursorX - xPos;
-    if (span) {
-      // If we are in a non-empty line, take the cursor Y position from the
-      // other elements in this line. If dealing with broken, non-proportional
-      // fonts, this is likely to yield better results.
-      pixelY                        = span.offsetTop +
-                                      span.offsetParent.offsetTop;
-      s                             = this.getTextContent(span);
-      var nxtIdx                    = idx - s.length;
-      if (nxtIdx < 0) {
-        this.setTextContent(this.cursor, s.charAt(idx));
-        pixelX                      = span.offsetLeft +
-                                      idx*span.offsetWidth / s.length;
-      } else {
-        if (nxtIdx == 0) {
-          pixelX                    = span.offsetLeft + span.offsetWidth;
-        }
-        if (span.nextSibling) {
-          s                         = this.getTextContent(span.nextSibling);
-          this.setTextContent(this.cursor, s.charAt(nxtIdx));
-          if (pixelX < 0) {
-            pixelX                  = span.nextSibling.offsetLeft +
-                                      nxtIdx*span.offsetWidth / s.length;
-          }
-        } else {
-          this.setTextContent(this.cursor, ' ');
-        }
-      }
-    } else {
-      this.setTextContent(this.cursor, ' ');
-    }
-  }
-  if (pixelX >= 0) {
-    this.cursor.style.left          = (pixelX + (this.isIE ? 1 : 0))/
-                                      this.scale + 'px';
-  } else {
-    this.setTextContent(this.space, this.spaces(this.cursorX));
-    this.cursor.style.left          = (this.space.offsetWidth +
-                                       console.offsetLeft)/this.scale + 'px';
-  }
-  this.cursorY                      = yIdx - this.numScrollbackLines;
-  if (pixelY >= 0) {
-    this.cursor.style.top           = pixelY + 'px';
-  } else {
-    this.cursor.style.top           = yIdx*this.cursorHeight +
-                                      console.offsetTop + 'px';
-  }
-
-  if (text.length) {
-    // Merge <span> with previous sibling, if styles are identical
-    if ((sibling = span.previousSibling) &&
-        span.className == sibling.className &&
-        span.style.cssText == sibling.style.cssText) {
-      this.setTextContent(span,
-                          this.getTextContent(sibling) +
-                          this.getTextContent(span));
-      line.removeChild(sibling);
-    }
-
-    // Prune white space from the end of the current line
-    span                            = line.lastChild;
-    while (span &&
-           span.className == 'ansi0 bgAnsi15' &&
-           !span.style.cssText.length) {
-      // Scan backwards looking for first non-space character
-      s                             = this.getTextContent(span);
-      for (var i = s.length; i--; ) {
-        if (s.charAt(i) != ' ' && s.charAt(i) != '\u00A0') {
-          if (i+1 != s.length) {
-            this.setTextContent(s.substr(0, i+1));
-          }
-          span                      = null;
-          break;
-        }
-      }
-      if (span) {
-        sibling                     = span;
-        span                        = span.previousSibling;
-        if (span) {
-          // Remove blank <span>'s from end of line
-          line.removeChild(sibling);
-        } else {
-          // Remove entire line (i.e. <div>), if empty
-          var blank                 = document.createElement('pre');
-          blank.style.height        = this.cursorHeight + 'px';
-          this.setTextContent(blank, '\n');
-          line.parentNode.replaceChild(blank, line);
-        }
-      }
-    }
-  }
-};
-
-VT100.prototype.gotoXY = function(x, y) {
-  if (x >= this.terminalWidth) {
-    x           = this.terminalWidth - 1;
-  }
-  if (x < 0) {
-    x           = 0;
-  }
-  var minY, maxY;
-  if (this.offsetMode) {
-    minY        = this.top;
-    maxY        = this.bottom;
-  } else {
-    minY        = 0;
-    maxY        = this.terminalHeight;
-  }
-  if (y >= maxY) {
-    y           = maxY - 1;
-  }
-  if (y < minY) {
-    y           = minY;
-  }
-  this.putString(x, y, '', undefined);
-  this.needWrap = false;
-};
-
-VT100.prototype.gotoXaY = function(x, y) {
-  this.gotoXY(x, this.offsetMode ? (this.top + y) : y);
-};
-
-VT100.prototype.refreshInvertedState = function() {
-  if (this.isInverted) {
-    this.scrollable.className += ' inverted';
-  } else {
-    this.scrollable.className = this.scrollable.className.
-                                                     replace(/ *inverted/, '');
-  }
-};
-
-VT100.prototype.enableAlternateScreen = function(state) {
-  // Don't do anything, if we are already on the desired screen
-  if ((state ? 1 : 0) == this.currentScreen) {
-    // Calling the resizer is not actually necessary. But it is a good way
-    // of resetting state that might have gotten corrupted.
-    this.resizer();
-    return;
-  }
-
-  // We save the full state of the normal screen, when we switch away from it.
-  // But for the alternate screen, no saving is necessary. We always reset
-  // it when we switch to it.
-  if (state) {
-    this.saveCursor();
-  }
-
-  // Display new screen, and initialize state (the resizer does that for us).
-  this.currentScreen                                 = state ? 1 : 0;
-  this.console[1-this.currentScreen].style.display   = 'none';
-  this.console[this.currentScreen].style.display     = '';
-
-  // Select appropriate character pitch.
-  var transform                                      = this.getTransformName();
-  if (transform) {
-    if (state) {
-      // Upon enabling the alternate screen, we switch to 80 column mode. But
-      // upon returning to the regular screen, we restore the mode that was
-      // in effect previously.
-      this.console[1].style[transform]               = '';
-    }
-    var style                                        =
-                             this.console[this.currentScreen].style[transform];
-    this.cursor.style[transform]                     = style;
-    this.space.style[transform]                      = style;
-    this.scale                                       = style == '' ? 1.0:1.65;
-    if (transform == 'filter') {
-       this.console[this.currentScreen].style.width  = style == '' ? '165%':'';
-    }
-  }
-  this.resizer();
-
-  // If we switched to the alternate screen, reset it completely. Otherwise,
-  // restore the saved state.
-  if (state) {
-    this.gotoXY(0, 0);
-    this.clearRegion(0, 0, this.terminalWidth, this.terminalHeight);
-  } else {
-    this.restoreCursor();
-  }
-};
-
-VT100.prototype.hideCursor = function() {
-  var hidden = this.cursor.style.visibility == 'hidden';
-  if (!hidden) {
-    this.cursor.style.visibility = 'hidden';
-    return true;
-  }
-  return false;
-};
-
-VT100.prototype.showCursor = function(x, y) {
-  if (this.cursor.style.visibility) {
-    this.cursor.style.visibility = '';
-    this.putString(x == undefined ? this.cursorX : x,
-                   y == undefined ? this.cursorY : y,
-                   '', undefined);
-    return true;
-  }
-  return false;
-};
-
-VT100.prototype.scrollBack = function() {
-  var i                     = this.scrollable.scrollTop -
-                              this.scrollable.clientHeight;
-  this.scrollable.scrollTop = i < 0 ? 0 : i;
-};
-
-VT100.prototype.scrollFore = function() {
-  var i                     = this.scrollable.scrollTop +
-                              this.scrollable.clientHeight;
-  this.scrollable.scrollTop = i > this.numScrollbackLines *
-                                  this.cursorHeight + 1
-                              ? this.numScrollbackLines *
-                                this.cursorHeight + 1
-                              : i;
-};
-
-VT100.prototype.spaces = function(i) {
-  var s = '';
-  while (i-- > 0) {
-    s += ' ';
-  }
-  return s;
-};
-
-VT100.prototype.clearRegion = function(x, y, w, h, color, style) {
-  w         += x;
-  if (x < 0) {
-    x        = 0;
-  }
-  if (w > this.terminalWidth) {
-    w        = this.terminalWidth;
-  }
-  if ((w    -= x) <= 0) {
-    return;
-  }
-  h         += y;
-  if (y < 0) {
-    y        = 0;
-  }
-  if (h > this.terminalHeight) {
-    h        = this.terminalHeight;
-  }
-  if ((h    -= y) <= 0) {
-    return;
-  }
-
-  // Special case the situation where we clear the entire screen, and we do
-  // not have a scrollback buffer. In that case, we should just remove all
-  // child nodes.
-  if (!this.numScrollbackLines &&
-      w == this.terminalWidth && h == this.terminalHeight &&
-      (color == undefined || color == 'ansi0 bgAnsi15') && !style) {
-    var console = this.console[this.currentScreen];
-    while (console.lastChild) {
-      console.removeChild(console.lastChild);
-    }
-    this.putString(this.cursorX, this.cursorY, '', undefined);
-  } else {
-    var hidden = this.hideCursor();
-    var cx     = this.cursorX;
-    var cy     = this.cursorY;
-    var s      = this.spaces(w);
-    for (var i = y+h; i-- > y; ) {
-      this.putString(x, i, s, color, style);
-    }
-    hidden ? this.showCursor(cx, cy) : this.putString(cx, cy, '', undefined);
-  }
-};
-
-VT100.prototype.copyLineSegment = function(dX, dY, sX, sY, w) {
-  var text                            = [ ];
-  var className                       = [ ];
-  var style                           = [ ];
-  var console                         = this.console[this.currentScreen];
-  if (sY >= console.childNodes.length) {
-    text[0]                           = this.spaces(w);
-    className[0]                      = undefined;
-    style[0]                          = undefined;
-  } else {
-    var line = console.childNodes[sY];
-    if (line.tagName != 'DIV' || !line.childNodes.length) {
-      text[0]                         = this.spaces(w);
-      className[0]                    = undefined;
-      style[0]                        = undefined;
-    } else {
-      var x                           = 0;
-      for (var span = line.firstChild; span && w > 0; span = span.nextSibling){
-        var s                         = this.getTextContent(span);
-        var len                       = s.length;
-        if (x + len > sX) {
-          var o                       = sX > x ? sX - x : 0;
-          text[text.length]           = s.substr(o, w);
-          className[className.length] = span.className;
-          style[style.length]         = span.style.cssText;
-          w                          -= len - o;
-        }
-        x                            += len;
-      }
-      if (w > 0) {
-        text[text.length]             = this.spaces(w);
-        className[className.length]   = undefined;
-        style[style.length]           = undefined;
-      }
-    }
-  }
-  var hidden                          = this.hideCursor();
-  var cx                              = this.cursorX;
-  var cy                              = this.cursorY;
-  for (var i = 0; i < text.length; i++) {
-    var color;
-    if (className[i]) {
-      color                           = className[i];
-    } else {
-      color                           = 'ansi0 bgAnsi15';
-    }
-    this.putString(dX, dY - this.numScrollbackLines, text[i], color, style[i]);
-    dX                               += text[i].length;
-  }
-  hidden ? this.showCursor(cx, cy) : this.putString(cx, cy, '', undefined);
-};
-
-VT100.prototype.scrollRegion = function(x, y, w, h, incX, incY,
-                                        color, style) {
-  var left             = incX < 0 ? -incX : 0;
-  var right            = incX > 0 ?  incX : 0;
-  var up               = incY < 0 ? -incY : 0;
-  var down             = incY > 0 ?  incY : 0;
-
-  // Clip region against terminal size
-  var dontScroll       = null;
-  w                   += x;
-  if (x < left) {
-    x                  = left;
-  }
-  if (w > this.terminalWidth - right) {
-    w                  = this.terminalWidth - right;
-  }
-  if ((w              -= x) <= 0) {
-    dontScroll         = 1;
-  }
-  h                   += y;
-  if (y < up) {
-    y                  = up;
-  }
-  if (h > this.terminalHeight - down) {
-    h                  = this.terminalHeight - down;
-  }
-  if ((h              -= y) < 0) {
-    dontScroll         = 1;
-  }
-  if (!dontScroll) {
-    if (style && style.indexOf('underline')) {
-      // Different terminal emulators disagree on the attributes that
-      // are used for scrolling. The consensus seems to be, never to
-      // fill with underlined spaces. N.B. this is different from the
-      // cases when the user blanks a region. User-initiated blanking
-      // always fills with all of the current attributes.
-      style            = style.replace(/text-decoration:underline;/, '');
-    }
-
-    // Compute current scroll position
-    var scrollPos      = this.numScrollbackLines -
-                      (this.scrollable.scrollTop-1) / this.cursorHeight;
-
-    // Determine original cursor position. Hide cursor temporarily to avoid
-    // visual artifacts.
-    var hidden         = this.hideCursor();
-    var cx             = this.cursorX;
-    var cy             = this.cursorY;
-    var console        = this.console[this.currentScreen];
-
-    if (!incX && !x && w == this.terminalWidth) {
-      // Scrolling entire lines
-      if (incY < 0) {
-        // Scrolling up
-        if (!this.currentScreen && y == -incY &&
-            h == this.terminalHeight + incY) {
-          // Scrolling up with adding to the scrollback buffer. This is only
-          // possible if there are at least as many lines in the console,
-          // as the terminal is high
-          while (console.childNodes.length < this.terminalHeight) {
-            this.insertBlankLine(this.terminalHeight);
-          }
-
-          // Add new lines at bottom in order to force scrolling
-          for (var i = 0; i < y; i++) {
-            this.insertBlankLine(console.childNodes.length, color, style);
-          }
-
-          // Adjust the number of lines in the scrollback buffer by
-          // removing excess entries.
-          this.updateNumScrollbackLines();
-          while (this.numScrollbackLines >
-                 (this.currentScreen ? 0 : this.maxScrollbackLines)) {
-            console.removeChild(console.firstChild);
-            this.numScrollbackLines--;
-          }
-
-          // Mark lines in the scrollback buffer, so that they do not get
-          // printed.
-          for (var i = this.numScrollbackLines, j = -incY;
-               i-- > 0 && j-- > 0; ) {
-            console.childNodes[i].className = 'scrollback';
-          }
-        } else {
-          // Scrolling up without adding to the scrollback buffer.
-          for (var i = -incY;
-               i-- > 0 &&
-               console.childNodes.length >
-               this.numScrollbackLines + y + incY; ) {
-            console.removeChild(console.childNodes[
-                                          this.numScrollbackLines + y + incY]);
-          }
-
-          // If we used to have a scrollback buffer, then we must make sure
-          // that we add back blank lines at the bottom of the terminal.
-          // Similarly, if we are scrolling in the middle of the screen,
-          // we must add blank lines to ensure that the bottom of the screen
-          // does not move up.
-          if (this.numScrollbackLines > 0 ||
-              console.childNodes.length > this.numScrollbackLines+y+h+incY) {
-            for (var i = -incY; i-- > 0; ) {
-              this.insertBlankLine(this.numScrollbackLines + y + h + incY,
-                                   color, style);
-            }
-          }
-        }
-      } else {
-        // Scrolling down
-        for (var i = incY;
-             i-- > 0 &&
-             console.childNodes.length > this.numScrollbackLines + y + h; ) {
-          console.removeChild(console.childNodes[this.numScrollbackLines+y+h]);
-        }
-        for (var i = incY; i--; ) {
-          this.insertBlankLine(this.numScrollbackLines + y, color, style);
-        }
-      }
-    } else {
-      // Scrolling partial lines
-      if (incY <= 0) {
-        // Scrolling up or horizontally within a line
-        for (var i = y + this.numScrollbackLines;
-             i < y + this.numScrollbackLines + h;
-             i++) {
-          this.copyLineSegment(x + incX, i + incY, x, i, w);
-        }
-      } else {
-        // Scrolling down
-        for (var i = y + this.numScrollbackLines + h;
-             i-- > y + this.numScrollbackLines; ) {
-          this.copyLineSegment(x + incX, i + incY, x, i, w);
-        }
-      }
-
-      // Clear blank regions
-      if (incX > 0) {
-        this.clearRegion(x, y, incX, h, color, style);
-      } else if (incX < 0) {
-        this.clearRegion(x + w + incX, y, -incX, h, color, style);
-      }
-      if (incY > 0) {
-        this.clearRegion(x, y, w, incY, color, style);
-      } else if (incY < 0) {
-        this.clearRegion(x, y + h + incY, w, -incY, color, style);
-      }
-    }
-
-    // Reset scroll position
-    this.scrollable.scrollTop = (this.numScrollbackLines-scrollPos) *
-                                this.cursorHeight + 1;
-
-    // Move cursor back to its original position
-    hidden ? this.showCursor(cx, cy) : this.putString(cx, cy, '', undefined);
-  }
-};
-
-VT100.prototype.copy = function(selection) {
-  if (selection == undefined) {
-    selection                = this.selection();
-  }
-  this.internalClipboard     = undefined;
-  if (selection.length) {
-    try {
-      // IE
-      this.cliphelper.value  = selection;
-      this.cliphelper.select();
-      this.cliphelper.createTextRange().execCommand('copy');
-    } catch (e) {
-      this.internalClipboard = selection;
-    }
-    this.cliphelper.value    = '';
-  }
-};
-
-VT100.prototype.copyLast = function() {
-  // Opening the context menu can remove the selection. We try to prevent this
-  // from happening, but that is not possible for all browsers. So, instead,
-  // we compute the selection before showing the menu.
-  this.copy(this.lastSelection);
-};
-
-VT100.prototype.pasteFnc = function() {
-  var clipboard     = undefined;
-  if (this.internalClipboard != undefined) {
-    clipboard       = this.internalClipboard;
-  } else {
-    try {
-      this.cliphelper.value = '';
-      this.cliphelper.createTextRange().execCommand('paste');
-      clipboard     = this.cliphelper.value;
-    } catch (e) {
-    }
-  }
-  this.cliphelper.value = '';
-  if (clipboard && this.menu.style.visibility == 'hidden') {
-    return function() {
-      this.keysPressed('' + clipboard);
-    };
-  } else {
-    return undefined;
-  }
-};
-
-VT100.prototype.pasteBrowserFnc = function() {
-  var clipboard     = prompt("Paste into this box:","");
-  if (clipboard != undefined) {
-     return this.keysPressed('' + clipboard);
-  }
-};
-
-VT100.prototype.toggleUTF = function() {
-  this.utfEnabled   = !this.utfEnabled;
-
-  // We always persist the last value that the user selected. Not necessarily
-  // the last value that a random program requested.
-  this.utfPreferred = this.utfEnabled;
-};
-
-VT100.prototype.toggleBell = function() {
-  this.visualBell = !this.visualBell;
-};
-
-VT100.prototype.toggleSoftKeyboard = function() {
-  this.softKeyboard = !this.softKeyboard;
-  this.keyboardImage.style.visibility = this.softKeyboard ? 'visible' : '';
-};
-
-VT100.prototype.deselectKeys = function(elem) {
-  if (elem && elem.className == 'selected') {
-    elem.className = '';
-  }
-  for (elem = elem.firstChild; elem; elem = elem.nextSibling) {
-    this.deselectKeys(elem);
-  }
-};
-
-VT100.prototype.showSoftKeyboard = function() {
-  // Make sure no key is currently selected
-  this.lastSelectedKey           = undefined;
-  this.deselectKeys(this.keyboard);
-  this.isShift                   = false;
-  this.showShiftState(false);
-  this.isCtrl                    = false;
-  this.showCtrlState(false);
-  this.isAlt                     = false;
-  this.showAltState(false);
-
-  this.keyboard.style.left       = '0px';
-  this.keyboard.style.top        = '0px';
-  this.keyboard.style.width      = this.container.offsetWidth  + 'px';
-  this.keyboard.style.height     = this.container.offsetHeight + 'px';
-  this.keyboard.style.visibility = 'hidden';
-  this.keyboard.style.display    = '';
-
-  var kbd                        = this.keyboard.firstChild;
-  var scale                      = 1.0;
-  var transform                  = this.getTransformName();
-  if (transform) {
-    kbd.style[transform]         = '';
-    if (kbd.offsetWidth > 0.9 * this.container.offsetWidth) {
-      scale                      = (kbd.offsetWidth/
-                                    this.container.offsetWidth)/0.9;
-    }
-    if (kbd.offsetHeight > 0.9 * this.container.offsetHeight) {
-      scale                      = Math.max((kbd.offsetHeight/
-                                             this.container.offsetHeight)/0.9);
-    }
-    var style                    = this.getTransformStyle(transform,
-                                              scale > 1.0 ? scale : undefined);
-    kbd.style[transform]         = style;
-  }
-  if (transform == 'filter') {
-    scale                        = 1.0;
-  }
-  kbd.style.left                 = ((this.container.offsetWidth -
-                                     kbd.offsetWidth/scale)/2) + 'px';
-  kbd.style.top                  = ((this.container.offsetHeight -
-                                     kbd.offsetHeight/scale)/2) + 'px';
-
-  this.keyboard.style.visibility = 'visible';
-};
-
-VT100.prototype.hideSoftKeyboard = function() {
-  this.keyboard.style.display    = 'none';
-};
-
-VT100.prototype.toggleCursorBlinking = function() {
-  this.blinkingCursor = !this.blinkingCursor;
-};
-
-VT100.prototype.about = function() {
-  alert("VT100 Terminal Emulator " + "2.10 (revision 239)" +
-        "\nCopyright 2008-2010 by Markus Gutschke\n" +
-        "For more information check http://shellinabox.com");
-};
-
-VT100.prototype.hideContextMenu = function() {
-  this.menu.style.visibility = 'hidden';
-  this.menu.style.top        = '-100px';
-  this.menu.style.left       = '-100px';
-  this.menu.style.width      = '0px';
-  this.menu.style.height     = '0px';
-};
-
-VT100.prototype.extendContextMenu = function(entries, actions) {
-};
-
-VT100.prototype.showContextMenu = function(x, y) {
-  this.menu.innerHTML         =
-    '<table class="popup" ' +
-           'cellpadding="0" cellspacing="0">' +
-      '<tr><td>' +
-        '<ul id="menuentries">' +
-          '<li id="beginclipboard">Copy</li>' +
-          '<li id="endclipboard">Paste</li>' +
-          '<li id="browserclipboard">Paste from browser</li>' +
-          '<hr />' +
-          '<li id="reset">Reset</li>' +
-          '<hr />' +
-          '<li id="beginconfig">' +
-             (this.utfEnabled ? '<img src="/webshell/enabled.gif" />' : '') +
-             'Unicode</li>' +
-          '<li>' +
-             (this.visualBell ? '<img src="/webshell/enabled.gif" />' : '') +
-             'Visual Bell</li>'+
-          '<li>' +
-             (this.softKeyboard ? '<img src="/webshell/enabled.gif" />' : '') +
-             'Onscreen Keyboard</li>' +
-          '<li id="endconfig">' +
-             (this.blinkingCursor ? '<img src="/webshell/enabled.gif" />' : '') +
-             'Blinking Cursor</li>'+
-          (this.usercss.firstChild ?
-           '<hr id="beginusercss" />' +
-           this.usercss.innerHTML +
-           '<hr id="endusercss" />' :
-           '<hr />') +
-          '<li id="about">About...</li>' +
-        '</ul>' +
-      '</td></tr>' +
-    '</table>';
-
-  var popup                   = this.menu.firstChild;
-  var menuentries             = this.getChildById(popup, 'menuentries');
-
-  // Determine menu entries that should be disabled
-  this.lastSelection          = this.selection();
-  if (!this.lastSelection.length) {
-    menuentries.firstChild.className
-                              = 'disabled';
-  }
-  var p                       = this.pasteFnc();
-  if (!p) {
-    menuentries.childNodes[1].className
-                              = 'disabled';
-  }
-
-  // Actions for default items
-  var actions                 = [ this.copyLast, p, this.pasteBrowserFnc, this.reset,
-                                  this.toggleUTF, this.toggleBell,
-                                  this.toggleSoftKeyboard,
-                                  this.toggleCursorBlinking ];
-
-  // Actions for user CSS styles (if any)
-  for (var i = 0; i < this.usercssActions.length; ++i) {
-    actions[actions.length]   = this.usercssActions[i];
-  }
-  actions[actions.length]     = this.about;
-
-  // Allow subclasses to dynamically add entries to the context menu
-  this.extendContextMenu(menuentries, actions);
-
-  // Hook up event listeners
-  for (var node = menuentries.firstChild, i = 0; node;
-       node = node.nextSibling) {
-    if (node.tagName == 'LI') {
-      if (node.className != 'disabled') {
-        this.addListener(node, 'mouseover',
-                         function(vt100, node) {
-                           return function() {
-                             node.className = 'hover';
-                           }
-                         }(this, node));
-        this.addListener(node, 'mouseout',
-                         function(vt100, node) {
-                           return function() {
-                             node.className = '';
-                           }
-                         }(this, node));
-        this.addListener(node, 'mousedown',
-                         function(vt100, action) {
-                           return function(event) {
-                             vt100.hideContextMenu();
-                             action.call(vt100);
-                             vt100.storeUserSettings();
-                             return vt100.cancelEvent(event || window.event);
-                           }
-                         }(this, actions[i]));
-        this.addListener(node, 'mouseup',
-                         function(vt100) {
-                           return function(event) {
-                             return vt100.cancelEvent(event || window.event);
-                           }
-                         }(this));
-        this.addListener(node, 'mouseclick',
-                         function(vt100) {
-                           return function(event) {
-                             return vt100.cancelEvent(event || window.event);
-                           }
-                         }());
-      }
-      i++;
-    }
-  }
-
-  // Position menu next to the mouse pointer
-  this.menu.style.left        = '0px';
-  this.menu.style.top         = '0px';
-  this.menu.style.width       =  this.container.offsetWidth  + 'px';
-  this.menu.style.height      =  this.container.offsetHeight + 'px';
-  popup.style.left            = '0px';
-  popup.style.top             = '0px';
-
-  var margin                  = 2;
-  if (x + popup.clientWidth >= this.container.offsetWidth - margin) {
-    x              = this.container.offsetWidth-popup.clientWidth - margin - 1;
-  }
-  if (x < margin) {
-    x                         = margin;
-  }
-  if (y + popup.clientHeight >= this.container.offsetHeight - margin) {
-    y            = this.container.offsetHeight-popup.clientHeight - margin - 1;
-  }
-  if (y < margin) {
-    y                         = margin;
-  }
-  popup.style.left            = x + 'px';
-  popup.style.top             = y + 'px';
-
-  // Block all other interactions with the terminal emulator
-  this.addListener(this.menu, 'click', function(vt100) {
-                                         return function() {
-                                           vt100.hideContextMenu();
-                                         }
-                                       }(this));
-
-  // Show the menu
-  this.menu.style.visibility  = '';
-};
-
-VT100.prototype.keysPressed = function(ch) {
-  for (var i = 0; i < ch.length; i++) {
-    var c = ch.charCodeAt(i);
-    this.vt100(c >= 7 && c <= 15 ||
-               c == 24 || c == 26 || c == 27 || c >= 32
-               ? String.fromCharCode(c) : '<' + c + '>');
-  }
-};
-
-VT100.prototype.applyModifiers = function(ch, event) {
-  if (ch) {
-    if (event.ctrlKey) {
-      if (ch >= 32 && ch <= 127) {
-        // For historic reasons, some control characters are treated specially
-        switch (ch) {
-        case /* 3 */ 51: ch  =  27; break;
-        case /* 4 */ 52: ch  =  28; break;
-        case /* 5 */ 53: ch  =  29; break;
-        case /* 6 */ 54: ch  =  30; break;
-        case /* 7 */ 55: ch  =  31; break;
-        case /* 8 */ 56: ch  = 127; break;
-        case /* ? */ 63: ch  = 127; break;
-        default:         ch &=  31; break;
-        }
-      }
-    }
-    return String.fromCharCode(ch);
-  } else {
-    return undefined;
-  }
-};
-
-VT100.prototype.handleKey = function(event) {
-  // this.vt100('H: c=' + event.charCode + ', k=' + event.keyCode +
-  //            (event.shiftKey || event.ctrlKey || event.altKey ||
-  //             event.metaKey ? ', ' +
-  //             (event.shiftKey ? 'S' : '') + (event.ctrlKey ? 'C' : '') +
-  //             (event.altKey ? 'A' : '') + (event.metaKey ? 'M' : '') : '') +
-  //            '\r\n');
-  var ch, key;
-  if (typeof event.charCode != 'undefined') {
-    // non-IE keypress events have a translated charCode value. Also, our
-    // fake events generated when receiving keydown events include this data
-    // on all browsers.
-    ch                                = event.charCode;
-    key                               = event.keyCode;
-  } else {
-    // When sending a keypress event, IE includes the translated character
-    // code in the keyCode field.
-    ch                                = event.keyCode;
-    key                               = undefined;
-  }
-
-  // Apply modifier keys (ctrl and shift)
-  if (ch) {
-    key                               = undefined;
-  }
-  ch                                  = this.applyModifiers(ch, event);
-
-  // By this point, "ch" is either defined and contains the character code, or
-  // it is undefined and "key" defines the code of a function key
-  if (ch != undefined) {
-    this.scrollable.scrollTop         = this.numScrollbackLines *
-                                        this.cursorHeight + 1;
-  } else {
-    if ((event.altKey || event.metaKey) && !event.shiftKey && !event.ctrlKey) {
-      // Many programs have difficulties dealing with parametrized escape
-      // sequences for function keys. Thus, if ALT is the only modifier
-      // key, return Emacs-style keycodes for commonly used keys.
-      switch (key) {
-      case  33: /* Page Up      */ ch = '\u001B<';                      break;
-      case  34: /* Page Down    */ ch = '\u001B>';                      break;
-      case  37: /* Left         */ ch = '\u001Bb';                      break;
-      case  38: /* Up           */ ch = '\u001Bp';                      break;
-      case  39: /* Right        */ ch = '\u001Bf';                      break;
-      case  40: /* Down         */ ch = '\u001Bn';                      break;
-      case  46: /* Delete       */ ch = '\u001Bd';                      break;
-      default:                                                          break;
-      }
-    } else if (event.shiftKey && !event.ctrlKey &&
-               !event.altKey && !event.metaKey) {
-      switch (key) {
-      case  33: /* Page Up      */ this.scrollBack();                   return;
-      case  34: /* Page Down    */ this.scrollFore();                   return;
-      default:                                                          break;
-      }
-    }
-    if (ch == undefined) {
-      switch (key) {
-      case   8: /* Backspace    */ ch = '\u007f';                       break;
-      case   9: /* Tab          */ ch = '\u0009';                       break;
-      case  10: /* Return       */ ch = '\u000A';                       break;
-      case  13: /* Enter        */ ch = this.crLfMode ?
-                                        '\r\n' : '\r';                  break;
-      case  16: /* Shift        */                                      return;
-      case  17: /* Ctrl         */                                      return;
-      case  18: /* Alt          */                                      return;
-      case  19: /* Break        */                                      return;
-      case  20: /* Caps Lock    */                                      return;
-      case  27: /* Escape       */ ch = '\u001B';                       break;
-      case  33: /* Page Up      */ ch = '\u001B[5~';                    break;
-      case  34: /* Page Down    */ ch = '\u001B[6~';                    break;
-      case  35: /* End          */ ch = '\u001BOF';                     break;
-      case  36: /* Home         */ ch = '\u001BOH';                     break;
-      case  37: /* Left         */ ch = this.cursorKeyMode ?
-                             '\u001BOD' : '\u001B[D';                   break;
-      case  38: /* Up           */ ch = this.cursorKeyMode ?
-                             '\u001BOA' : '\u001B[A';                   break;
-      case  39: /* Right        */ ch = this.cursorKeyMode ?
-                             '\u001BOC' : '\u001B[C';                   break;
-      case  40: /* Down         */ ch = this.cursorKeyMode ?
-                             '\u001BOB' : '\u001B[B';                   break;
-      case  45: /* Insert       */ ch = '\u001B[2~';                    break;
-      case  46: /* Delete       */ ch = '\u001B[3~';                    break;
-      case  91: /* Left Window  */                                      return;
-      case  92: /* Right Window */                                      return;
-      case  93: /* Select       */                                      return;
-      case  96: /* 0            */ ch = this.applyModifiers(48, event); break;
-      case  97: /* 1            */ ch = this.applyModifiers(49, event); break;
-      case  98: /* 2            */ ch = this.applyModifiers(50, event); break;
-      case  99: /* 3            */ ch = this.applyModifiers(51, event); break;
-      case 100: /* 4            */ ch = this.applyModifiers(52, event); break;
-      case 101: /* 5            */ ch = this.applyModifiers(53, event); break;
-      case 102: /* 6            */ ch = this.applyModifiers(54, event); break;
-      case 103: /* 7            */ ch = this.applyModifiers(55, event); break;
-      case 104: /* 8            */ ch = this.applyModifiers(56, event); break;
-      case 105: /* 9            */ ch = this.applyModifiers(58, event); break;
-      case 106: /* *            */ ch = this.applyModifiers(42, event); break;
-      case 107: /* +            */ ch = this.applyModifiers(43, event); break;
-      case 109: /* -            */ ch = this.applyModifiers(45, event); break;
-      case 110: /* .            */ ch = this.applyModifiers(46, event); break;
-      case 111: /* /            */ ch = this.applyModifiers(47, event); break;
-      case 112: /* F1           */ ch = '\u001BOP';                     break;
-      case 113: /* F2           */ ch = '\u001BOQ';                     break;
-      case 114: /* F3           */ ch = '\u001BOR';                     break;
-      case 115: /* F4           */ ch = '\u001BOS';                     break;
-      case 116: /* F5           */ ch = '\u001B[15~';                   break;
-      case 117: /* F6           */ ch = '\u001B[17~';                   break;
-      case 118: /* F7           */ ch = '\u001B[18~';                   break;
-      case 119: /* F8           */ ch = '\u001B[19~';                   break;
-      case 120: /* F9           */ ch = '\u001B[20~';                   break;
-      case 121: /* F10          */ ch = '\u001B[21~';                   break;
-      case 122: /* F11          */ ch = '\u001B[23~';                   break;
-      case 123: /* F12          */ ch = '\u001B[24~';                   break;
-      case 144: /* Num Lock     */                                      return;
-      case 145: /* Scroll Lock  */                                      return;
-      case 186: /* ;            */ ch = this.applyModifiers(59, event); break;
-      case 187: /* =            */ ch = this.applyModifiers(61, event); break;
-      case 188: /* ,            */ ch = this.applyModifiers(44, event); break;
-      case 189: /* -            */ ch = this.applyModifiers(45, event); break;
-      case 173: /* -            */ ch = this.applyModifiers(45, event); break; // FF15 Patch
-      case 190: /* .            */ ch = this.applyModifiers(46, event); break;
-      case 191: /* /            */ ch = this.applyModifiers(47, event); break;
-      // Conflicts with dead key " on Swiss keyboards
-      //case 192: /* `            */ ch = this.applyModifiers(96, event); break;
-      // Conflicts with dead key " on Swiss keyboards
-      //case 219: /* [            */ ch = this.applyModifiers(91, event); break;
-      case 220: /* \            */ ch = this.applyModifiers(92, event); break;
-      // Conflicts with dead key ^ and ` on Swiss keaboards
-      //                         ^ and " on French keyboards
-      //case 221: /* ]            */ ch = this.applyModifiers(93, event); break;
-      case 222: /* '            */ ch = this.applyModifiers(39, event); break;
-      default:                                                          return;
-      }
-      this.scrollable.scrollTop       = this.numScrollbackLines *
-                                        this.cursorHeight + 1;
-    }
-  }
-
-  // "ch" now contains the sequence of keycodes to send. But we might still
-  // have to apply the effects of modifier keys.
-  if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
-    var start, digit, part1, part2;
-    if ((start = ch.substr(0, 2)) == '\u001B[') {
-      for (part1 = start;
-           part1.length < ch.length &&
-             (digit = ch.charCodeAt(part1.length)) >= 48 && digit <= 57; ) {
-        part1                         = ch.substr(0, part1.length + 1);
-      }
-      part2                           = ch.substr(part1.length);
-      if (part1.length > 2) {
-        part1                        += ';';
-      }
-    } else if (start == '\u001BO') {
-      part1                           = start;
-      part2                           = ch.substr(2);
-    }
-    if (part1 != undefined) {
-      ch                              = part1                                 +
-                                       ((event.shiftKey             ? 1 : 0)  +
-                                        (event.altKey|event.metaKey ? 2 : 0)  +
-                                        (event.ctrlKey              ? 4 : 0)) +
-                                        part2;
-    } else if (ch.length == 1 && (event.altKey || event.metaKey)) {
-      ch                              = '\u001B' + ch;
-    }
-  }
-
-  if (this.menu.style.visibility == 'hidden') {
-    // this.vt100('R: c=');
-    // for (var i = 0; i < ch.length; i++)
-    //   this.vt100((i != 0 ? ', ' : '') + ch.charCodeAt(i));
-    // this.vt100('\r\n');
-    this.keysPressed(ch);
-  }
-};
-
-VT100.prototype.inspect = function(o, d) {
-  if (d == undefined) {
-    d       = 0;
-  }
-  var rc    = '';
-  if (typeof o == 'object' && ++d < 2) {
-    rc      = '[\r\n';
-    for (i in o) {
-      rc   += this.spaces(d * 2) + i + ' -> ';
-      try {
-        rc += this.inspect(o[i], d);
-      } catch (e) {
-        rc += '?' + '?' + '?\r\n';
-      }
-    }
-    rc     += ']\r\n';
-  } else {
-    rc     += ('' + o).replace(/\n/g, ' ').replace(/ +/g,' ') + '\r\n';
-  }
-  return rc;
-};
-
-VT100.prototype.checkComposedKeys = function(event) {
-  // Composed keys (at least on Linux) do not generate normal events.
-  // Instead, they get entered into the text field. We normally catch
-  // this on the next keyup event.
-  var s              = this.input.value;
-  if (s.length) {
-    this.input.value = '';
-    if (this.menu.style.visibility == 'hidden') {
-      this.keysPressed(s);
-    }
-  }
-};
-
-VT100.prototype.fixEvent = function(event) {
-  // Some browsers report AltGR as a combination of ALT and CTRL. As AltGr
-  // is used as a second-level selector, clear the modifier bits before
-  // handling the event.
-  if (event.ctrlKey && event.altKey) {
-    var fake                = [ ];
-    fake.charCode           = event.charCode;
-    fake.keyCode            = event.keyCode;
-    fake.ctrlKey            = false;
-    fake.shiftKey           = event.shiftKey;
-    fake.altKey             = false;
-    fake.metaKey            = event.metaKey;
-    return fake;
-  }
-
-  // Some browsers fail to translate keys, if both shift and alt/meta is
-  // pressed at the same time. We try to translate those cases, but that
-  // only works for US keyboard layouts.
-  if (event.shiftKey) {
-    var u                   = undefined;
-    var s                   = undefined;
-    switch (this.lastNormalKeyDownEvent.keyCode) {
-    case  39: /* ' -> " */ u = 39; s =  34; break;
-    case  44: /* , -> < */ u = 44; s =  60; break;
-    case  45: /* - -> _ */ u = 45; s =  95; break;
-    case  46: /* . -> > */ u = 46; s =  62; break;
-    case  47: /* / -> ? */ u = 47; s =  63; break;
-
-    case  48: /* 0 -> ) */ u = 48; s =  41; break;
-    case  49: /* 1 -> ! */ u = 49; s =  33; break;
-    case  50: /* 2 -> @ */ u = 50; s =  64; break;
-    case  51: /* 3 -> # */ u = 51; s =  35; break;
-    case  52: /* 4 -> $ */ u = 52; s =  36; break;
-    case  53: /* 5 -> % */ u = 53; s =  37; break;
-    case  54: /* 6 -> ^ */ u = 54; s =  94; break;
-    case  55: /* 7 -> & */ u = 55; s =  38; break;
-    case  56: /* 8 -> * */ u = 56; s =  42; break;
-    case  57: /* 9 -> ( */ u = 57; s =  40; break;
-
-    case  59: /* ; -> : */ u = 59; s =  58; break;
-    case  61: /* = -> + */ u = 61; s =  43; break;
-    case  91: /* [ -> { */ u = 91; s = 123; break;
-    case  92: /* \ -> | */ u = 92; s = 124; break;
-    case  93: /* ] -> } */ u = 93; s = 125; break;
-    case  96: /* ` -> ~ */ u = 96; s = 126; break;
-
-    case 109: /* - -> _ */ u = 45; s =  95; break;
-    case 111: /* / -> ? */ u = 47; s =  63; break;
-
-    case 186: /* ; -> : */ u = 59; s =  58; break;
-    case 187: /* = -> + */ u = 61; s =  43; break;
-    case 188: /* , -> < */ u = 44; s =  60; break;
-    case 189: /* - -> _ */ u = 45; s =  95; break;
-    case 173: /* - -> _ */ u = 45; s =  95; break; // FF15 Patch
-    case 190: /* . -> > */ u = 46; s =  62; break;
-    case 191: /* / -> ? */ u = 47; s =  63; break;
-    case 192: /* ` -> ~ */ u = 96; s = 126; break;
-    case 219: /* [ -> { */ u = 91; s = 123; break;
-    case 220: /* \ -> | */ u = 92; s = 124; break;
-    case 221: /* ] -> } */ u = 93; s = 125; break;
-    case 222: /* ' -> " */ u = 39; s =  34; break;
-    default:                                break;
-    }
-    if (s && (event.charCode == u || event.charCode == 0)) {
-      var fake              = [ ];
-      fake.charCode         = s;
-      fake.keyCode          = event.keyCode;
-      fake.ctrlKey          = event.ctrlKey;
-      fake.shiftKey         = event.shiftKey;
-      fake.altKey           = event.altKey;
-      fake.metaKey          = event.metaKey;
-      return fake;
-    }
-  }
-  return event;
-};
-
-VT100.prototype.keyDown = function(event) {
-  // this.vt100('D: c=' + event.charCode + ', k=' + event.keyCode +
-  //            (event.shiftKey || event.ctrlKey || event.altKey ||
-  //             event.metaKey ? ', ' +
-  //             (event.shiftKey ? 'S' : '') + (event.ctrlKey ? 'C' : '') +
-  //             (event.altKey ? 'A' : '') + (event.metaKey ? 'M' : '') : '') +
-  //            '\r\n');
-  this.checkComposedKeys(event);
-  this.lastKeyPressedEvent      = undefined;
-  this.lastKeyDownEvent         = undefined;
-  this.lastNormalKeyDownEvent   = event;
-
-  // Swiss keyboard conflicts:
-  // [ 59
-  // ] 192
-  // ' 219 (dead key)
-  // { 220
-  // ~ 221 (dead key)
-  // } 223
-  // French keyoard conflicts:
-  // ~ 50 (dead key)
-  // } 107
-  var asciiKey                  =
-    event.keyCode ==  32                         ||
-    event.keyCode >=  48 && event.keyCode <=  57 ||
-    event.keyCode >=  65 && event.keyCode <=  90;
-  var alphNumKey                =
-    asciiKey                                     ||
-    event.keyCode ==  59 ||
-    event.keyCode >=  96 && event.keyCode <= 105 ||
-    event.keyCode == 107 ||
-    event.keyCode == 192 ||
-    event.keyCode >= 219 && event.keyCode <= 221 ||
-    event.keyCode == 223 ||
-    event.keyCode == 226;
-  var normalKey                 =
-    alphNumKey                                   ||
-    event.keyCode ==  61 ||
-    event.keyCode == 106 ||
-    event.keyCode >= 109 && event.keyCode <= 111 ||
-    event.keyCode >= 186 && event.keyCode <= 191 ||
-    event.keyCode == 222 ||
-    event.keyCode == 252;
-  try {
-    if (navigator.appName == 'Konqueror') {
-      normalKey                |= event.keyCode < 128;
-    }
-  } catch (e) {
-  }
-
-  // We normally prefer to look at keypress events, as they perform the
-  // translation from keyCode to charCode. This is important, as the
-  // translation is locale-dependent.
-  // But for some keys, we must intercept them during the keydown event,
-  // as they would otherwise get interpreted by the browser.
-  // Even, when doing all of this, there are some keys that we can never
-  // intercept. This applies to some of the menu navigation keys in IE.
-  // In fact, we see them, but we cannot stop IE from seeing them, too.
-  if ((event.charCode || event.keyCode) &&
-      ((alphNumKey && (event.ctrlKey || event.altKey || event.metaKey) &&
-        !event.shiftKey &&
-        // Some browsers signal AltGR as both CTRL and ALT. Do not try to
-        // interpret this sequence ourselves, as some keyboard layouts use
-        // it for second-level layouts.
-        !(event.ctrlKey && event.altKey)) ||
-       this.catchModifiersEarly && normalKey && !alphNumKey &&
-       (event.ctrlKey || event.altKey || event.metaKey) ||
-       !normalKey)) {
-    this.lastKeyDownEvent       = event;
-    var fake                    = [ ];
-    fake.ctrlKey                = event.ctrlKey;
-    fake.shiftKey               = event.shiftKey;
-    fake.altKey                 = event.altKey;
-    fake.metaKey                = event.metaKey;
-    if (asciiKey) {
-      fake.charCode             = event.keyCode;
-      fake.keyCode              = 0;
-    } else {
-      fake.charCode             = 0;
-      fake.keyCode              = event.keyCode;
-      if (!alphNumKey && event.shiftKey) {
-        fake                    = this.fixEvent(fake);
-      }
-    }
-
-    this.handleKey(fake);
-    this.lastNormalKeyDownEvent = undefined;
-
-    try {
-      // For non-IE browsers
-      event.stopPropagation();
-      event.preventDefault();
-    } catch (e) {
-    }
-    try {
-      // For IE
-      event.cancelBubble = true;
-      event.returnValue  = false;
-      event.keyCode      = 0;
-    } catch (e) {
-    }
-
-    return false;
-  }
-  return true;
-};
-
-VT100.prototype.keyPressed = function(event) {
-  // this.vt100('P: c=' + event.charCode + ', k=' + event.keyCode +
-  //            (event.shiftKey || event.ctrlKey || event.altKey ||
-  //             event.metaKey ? ', ' +
-  //             (event.shiftKey ? 'S' : '') + (event.ctrlKey ? 'C' : '') +
-  //             (event.altKey ? 'A' : '') + (event.metaKey ? 'M' : '') : '') +
-  //            '\r\n');
-  if (this.lastKeyDownEvent) {
-    // If we already processed the key on keydown, do not process it
-    // again here. Ideally, the browser should not even have generated a
-    // keypress event in this case. But that does not appear to always work.
-    this.lastKeyDownEvent     = undefined;
-  } else {
-    this.handleKey(event.altKey || event.metaKey
-                   ? this.fixEvent(event) : event);
-  }
-
-  try {
-    // For non-IE browsers
-    event.preventDefault();
-  } catch (e) {
-  }
-
-  try {
-    // For IE
-    event.cancelBubble = true;
-    event.returnValue  = false;
-    event.keyCode      = 0;
-  } catch (e) {
-  }
-
-  this.lastNormalKeyDownEvent = undefined;
-  this.lastKeyPressedEvent    = event;
-  return false;
-};
-
-VT100.prototype.keyUp = function(event) {
-  // this.vt100('U: c=' + event.charCode + ', k=' + event.keyCode +
-  //            (event.shiftKey || event.ctrlKey || event.altKey ||
-  //             event.metaKey ? ', ' +
-  //             (event.shiftKey ? 'S' : '') + (event.ctrlKey ? 'C' : '') +
-  //             (event.altKey ? 'A' : '') + (event.metaKey ? 'M' : '') : '') +
-  //            '\r\n');
-  if (this.lastKeyPressedEvent) {
-    // The compose key on Linux occasionally confuses the browser and keeps
-    // inserting bogus characters into the input field, even if just a regular
-    // key has been pressed. Detect this case and drop the bogus characters.
-    (event.target ||
-     event.srcElement).value      = '';
-  } else {
-    // This is usually were we notice that a key has been composed and
-    // thus failed to generate normal events.
-    this.checkComposedKeys(event);
-
-    // Some browsers don't report keypress events if ctrl or alt is pressed
-    // for non-alphanumerical keys. Patch things up for now, but in the
-    // future we will catch these keys earlier (in the keydown handler).
-    if (this.lastNormalKeyDownEvent) {
-      // this.vt100('ENABLING EARLY CATCHING OF MODIFIER KEYS\r\n');
-      this.catchModifiersEarly    = true;
-      var asciiKey                =
-        event.keyCode ==  32                         ||
-        // Conflicts with dead key ~ (code 50) on French keyboards
-        //event.keyCode >=  48 && event.keyCode <=  57 ||
-        event.keyCode >=  48 && event.keyCode <=  49 ||
-        event.keyCode >=  51 && event.keyCode <=  57 ||
-        event.keyCode >=  65 && event.keyCode <=  90;
-      var alphNumKey              =
-        asciiKey                                     ||
-        event.keyCode ==  50                         ||
-        event.keyCode >=  96 && event.keyCode <= 105;
-      var normalKey               =
-        alphNumKey                                   ||
-        event.keyCode ==  59 || event.keyCode ==  61 ||
-        event.keyCode == 106 || event.keyCode == 107 ||
-        event.keyCode >= 109 && event.keyCode <= 111 ||
-        event.keyCode >= 186 && event.keyCode <= 192 ||
-        event.keyCode >= 219 && event.keyCode <= 223 ||
-        event.keyCode == 252;
-      var fake                    = [ ];
-      fake.ctrlKey                = event.ctrlKey;
-      fake.shiftKey               = event.shiftKey;
-      fake.altKey                 = event.altKey;
-      fake.metaKey                = event.metaKey;
-      if (asciiKey) {
-        fake.charCode             = event.keyCode;
-        fake.keyCode              = 0;
-      } else {
-        fake.charCode             = 0;
-        fake.keyCode              = event.keyCode;
-        if (!alphNumKey && (event.ctrlKey || event.altKey || event.metaKey)) {
-          fake                    = this.fixEvent(fake);
-        }
-      }
-      this.lastNormalKeyDownEvent = undefined;
-      this.handleKey(fake);
-    }
-  }
-
-  try {
-    // For IE
-    event.cancelBubble            = true;
-    event.returnValue             = false;
-    event.keyCode                 = 0;
-  } catch (e) {
-  }
-
-  this.lastKeyDownEvent           = undefined;
-  this.lastKeyPressedEvent        = undefined;
-  return false;
-};
-
-VT100.prototype.animateCursor = function(inactive) {
-  if (!this.cursorInterval) {
-    this.cursorInterval       = setInterval(
-      function(vt100) {
-        return function() {
-          vt100.animateCursor();
-
-          // Use this opportunity to check whether the user entered a composed
-          // key, or whether somebody pasted text into the textfield.
-          vt100.checkComposedKeys();
-        }
-      }(this), 500);
-  }
-  if (inactive != undefined || this.cursor.className != 'inactive') {
-    if (inactive) {
-      this.cursor.className   = 'inactive';
-    } else {
-      if (this.blinkingCursor) {
-        this.cursor.className = this.cursor.className == 'bright'
-                                ? 'dim' : 'bright';
-      } else {
-        this.cursor.className = 'bright';
-      }
-    }
-  }
-};
-
-VT100.prototype.blurCursor = function() {
-  this.animateCursor(true);
-};
-
-VT100.prototype.focusCursor = function() {
-  this.animateCursor(false);
-};
-
-VT100.prototype.flashScreen = function() {
-  this.isInverted       = !this.isInverted;
-  this.refreshInvertedState();
-  this.isInverted       = !this.isInverted;
-  setTimeout(function(vt100) {
-               return function() {
-                 vt100.refreshInvertedState();
-               };
-             }(this), 100);
-};
-
-VT100.prototype.beep = function() {
-  if (this.visualBell) {
-    this.flashScreen();
-  } else {
-    try {
-      this.beeper.Play();
-    } catch (e) {
-      try {
-        this.beeper.src = 'beep.wav';
-      } catch (e) {
-      }
-    }
-  }
-};
-
-VT100.prototype.bs = function() {
-  if (this.cursorX > 0) {
-    this.gotoXY(this.cursorX - 1, this.cursorY);
-    this.needWrap = false;
-  }
-};
-
-VT100.prototype.ht = function(count) {
-  if (count == undefined) {
-    count        = 1;
-  }
-  var cx         = this.cursorX;
-  while (count-- > 0) {
-    while (cx++ < this.terminalWidth) {
-      var tabState = this.userTabStop[cx];
-      if (tabState == false) {
-        // Explicitly cleared tab stop
-        continue;
-      } else if (tabState) {
-        // Explicitly set tab stop
-        break;
-      } else {
-        // Default tab stop at each eighth column
-        if (cx % 8 == 0) {
-          break;
-        }
-      }
-    }
-  }
-  if (cx > this.terminalWidth - 1) {
-    cx           = this.terminalWidth - 1;
-  }
-  if (cx != this.cursorX) {
-    this.gotoXY(cx, this.cursorY);
-  }
-};
-
-VT100.prototype.rt = function(count) {
-  if (count == undefined) {
-    count          = 1 ;
-  }
-  var cx           = this.cursorX;
-  while (count-- > 0) {
-    while (cx-- > 0) {
-      var tabState = this.userTabStop[cx];
-      if (tabState == false) {
-        // Explicitly cleared tab stop
-        continue;
-      } else if (tabState) {
-        // Explicitly set tab stop
-        break;
-      } else {
-        // Default tab stop at each eighth column
-        if (cx % 8 == 0) {
-          break;
-        }
-      }
-    }
-  }
-  if (cx < 0) {
-    cx             = 0;
-  }
-  if (cx != this.cursorX) {
-    this.gotoXY(cx, this.cursorY);
-  }
-};
-
-VT100.prototype.cr = function() {
-  this.gotoXY(0, this.cursorY);
-  this.needWrap = false;
-};
-
-VT100.prototype.lf = function(count) {
-  if (count == undefined) {
-    count    = 1;
-  } else {
-    if (count > this.terminalHeight) {
-      count  = this.terminalHeight;
-    }
-    if (count < 1) {
-      count  = 1;
-    }
-  }
-  while (count-- > 0) {
-    if (this.cursorY == this.bottom - 1) {
-      this.scrollRegion(0, this.top + 1,
-                        this.terminalWidth, this.bottom - this.top - 1,
-                        0, -1, this.color, this.style);
-      offset = undefined;
-    } else if (this.cursorY < this.terminalHeight - 1) {
-      this.gotoXY(this.cursorX, this.cursorY + 1);
-    }
-  }
-};
-
-VT100.prototype.ri = function(count) {
-  if (count == undefined) {
-    count   = 1;
-  } else {
-    if (count > this.terminalHeight) {
-      count = this.terminalHeight;
-    }
-    if (count < 1) {
-      count = 1;
-    }
-  }
-  while (count-- > 0) {
-    if (this.cursorY == this.top) {
-      this.scrollRegion(0, this.top,
-                        this.terminalWidth, this.bottom - this.top - 1,
-                        0, 1, this.color, this.style);
-    } else if (this.cursorY > 0) {
-      this.gotoXY(this.cursorX, this.cursorY - 1);
-    }
-  }
-  this.needWrap = false;
-};
-
-VT100.prototype.respondID = function() {
-  this.respondString += '\u001B[?6c';
-};
-
-VT100.prototype.respondSecondaryDA = function() {
-  this.respondString += '\u001B[>0;0;0c';
-};
-
-
-VT100.prototype.updateStyle = function() {
-  this.style   = '';
-  if (this.attr & 0x0200 /* ATTR_UNDERLINE */) {
-    this.style = 'text-decoration: underline;';
-  }
-  var bg       = (this.attr >> 4) & 0xF;
-  var fg       =  this.attr       & 0xF;
-  if (this.attr & 0x0100 /* ATTR_REVERSE */) {
-    var tmp    = bg;
-    bg         = fg;
-    fg         = tmp;
-  }
-  if ((this.attr & (0x0100 /* ATTR_REVERSE */ | 0x0400 /* ATTR_DIM */)) == 0x0400 /* ATTR_DIM */) {
-    fg         = 8; // Dark grey
-  } else if (this.attr & 0x0800 /* ATTR_BRIGHT */) {
-    fg        |= 8;
-    this.style = 'font-weight: bold;';
-  }
-  if (this.attr & 0x1000 /* ATTR_BLINK */) {
-    this.style = 'text-decoration: blink;';
-  }
-  this.color   = 'ansi' + fg + ' bgAnsi' + bg;
-};
-
-VT100.prototype.setAttrColors = function(attr) {
-  if (attr != this.attr) {
-    this.attr = attr;
-    this.updateStyle();
-  }
-};
-
-VT100.prototype.saveCursor = function() {
-  this.savedX[this.currentScreen]     = this.cursorX;
-  this.savedY[this.currentScreen]     = this.cursorY;
-  this.savedAttr[this.currentScreen]  = this.attr;
-  this.savedUseGMap                   = this.useGMap;
-  for (var i = 0; i < 4; i++) {
-    this.savedGMap[i]                 = this.GMap[i];
-  }
-  this.savedValid[this.currentScreen] = true;
-};
-
-VT100.prototype.restoreCursor = function() {
-  if (!this.savedValid[this.currentScreen]) {
-    return;
-  }
-  this.attr      = this.savedAttr[this.currentScreen];
-  this.updateStyle();
-  this.useGMap   = this.savedUseGMap;
-  for (var i = 0; i < 4; i++) {
-    this.GMap[i] = this.savedGMap[i];
-  }
-  this.translate = this.GMap[this.useGMap];
-  this.needWrap  = false;
-  this.gotoXY(this.savedX[this.currentScreen],
-              this.savedY[this.currentScreen]);
-};
-
-VT100.prototype.getTransformName = function() {
-  var styles = [ 'transform', 'WebkitTransform', 'MozTransform', 'filter' ];
-  for (var i = 0; i < styles.length; ++i) {
-    if (typeof this.console[0].style[styles[i]] != 'undefined') {
-      return styles[i];
-    }
-  }
-  return undefined;
-};
-
-VT100.prototype.getTransformStyle = function(transform, scale) {
-  return scale && scale != 1.0
-    ? transform == 'filter'
-      ? 'progid:DXImageTransform.Microsoft.Matrix(' +
-                                 'M11=' + (1.0/scale) + ',M12=0,M21=0,M22=1,' +
-                                 "sizingMethod='auto expand')"
-      : 'translateX(-50%) ' +
-        'scaleX(' + (1.0/scale) + ') ' +
-        'translateX(50%)'
-    : '';
-};
-
-VT100.prototype.set80_132Mode = function(state) {
-  var transform                  = this.getTransformName();
-  if (transform) {
-    if ((this.console[this.currentScreen].style[transform] != '') == state) {
-      return;
-    }
-    var style                    = state ?
-                                   this.getTransformStyle(transform, 1.65):'';
-    this.console[this.currentScreen].style[transform] = style;
-    this.cursor.style[transform] = style;
-    this.space.style[transform]  = style;
-    this.scale                   = state ? 1.65 : 1.0;
-    if (transform == 'filter') {
-      this.console[this.currentScreen].style.width = state ? '165%' : '';
-    }
-    this.resizer();
-  }
-};
-
-VT100.prototype.setMode = function(state) {
-  for (var i = 0; i <= this.npar; i++) {
-    if (this.isQuestionMark) {
-      switch (this.par[i]) {
-      case  1: this.cursorKeyMode      = state;                      break;
-      case  3: this.set80_132Mode(state);                            break;
-      case  5: this.isInverted = state; this.refreshInvertedState(); break;
-      case  6: this.offsetMode         = state;                      break;
-      case  7: this.autoWrapMode       = state;                      break;
-      case 1000:
-      case  9: this.mouseReporting     = state;                      break;
-      case 25: this.cursorNeedsShowing = state;
-               if (state) { this.showCursor(); }
-               else       { this.hideCursor(); }                     break;
-      case 1047:
-      case 1049:
-      case 47: this.enableAlternateScreen(state);                    break;
-      default:                                                       break;
-      }
-    } else {
-      switch (this.par[i]) {
-      case  3: this.dispCtrl           = state;                      break;
-      case  4: this.insertMode         = state;                      break;
-      case  20:this.crLfMode           = state;                      break;
-      default:                                                       break;
-      }
-    }
-  }
-};
-
-VT100.prototype.statusReport = function() {
-  // Ready and operational.
-  this.respondString += '\u001B[0n';
-};
-
-VT100.prototype.cursorReport = function() {
-  this.respondString += '\u001B[' +
-                        (this.cursorY + (this.offsetMode ? this.top + 1 : 1)) +
-                        ';' +
-                        (this.cursorX + 1) +
-                        'R';
-};
-
-VT100.prototype.setCursorAttr = function(setAttr, xorAttr) {
-  // Changing of cursor color is not implemented.
-};
-
-VT100.prototype.openPrinterWindow = function() {
-  var rc            = true;
-  try {
-    if (!this.printWin || this.printWin.closed) {
-      this.printWin = window.open('', 'print-output',
-        'width=800,height=600,directories=no,location=no,menubar=yes,' +
-        'status=no,toolbar=no,titlebar=yes,scrollbars=yes,resizable=yes');
-      this.printWin.document.body.innerHTML =
-        '<link rel="stylesheet" href="' +
-          document.location.protocol + '//' + document.location.host +
-          document.location.pathname.replace(/[^/]*$/, '') +
-          'print-styles.css" type="text/css">\n' +
-        '<div id="options"><input id="autoprint" type="checkbox"' +
-          (this.autoprint ? ' checked' : '') + '>' +
-          'Automatically, print page(s) when job is ready' +
-        '</input></div>\n' +
-        '<div id="spacer"><input type="checkbox">&nbsp;</input></div>' +
-        '<pre id="print"></pre>\n';
-      var autoprint = this.printWin.document.getElementById('autoprint');
-      this.addListener(autoprint, 'click',
-                       (function(vt100, autoprint) {
-                         return function() {
-                           vt100.autoprint = autoprint.checked;
-                           vt100.storeUserSettings();
-                           return false;
-                         };
-                       })(this, autoprint));
-      this.printWin.document.title = 'ShellInABox Printer Output';
-    }
-  } catch (e) {
-    // Maybe, a popup blocker prevented us from working. Better catch the
-    // exception, so that we won't break the entire terminal session. The
-    // user probably needs to disable the blocker first before retrying the
-    // operation.
-    rc              = false;
-  }
-  rc               &= this.printWin && !this.printWin.closed &&
-                      (this.printWin.innerWidth ||
-                       this.printWin.document.documentElement.clientWidth ||
-                       this.printWin.document.body.clientWidth) > 1;
-
-  if (!rc && this.printing == 100) {
-    // Different popup blockers work differently. We try to detect a couple
-    // of common methods. And then we retry again a brief amount later, as
-    // false positives are otherwise possible. If we are sure that there is
-    // a popup blocker in effect, we alert the user to it. This is helpful
-    // as some popup blockers have minimal or no UI, and the user might not
-    // notice that they are missing the popup. In any case, we only show at
-    // most one message per print job.
-    this.printing   = true;
-    setTimeout((function(win) {
-                  return function() {
-                    if (!win || win.closed ||
-                        (win.innerWidth ||
-                         win.document.documentElement.clientWidth ||
-                         win.document.body.clientWidth) <= 1) {
-                      alert('Attempted to print, but a popup blocker ' +
-                            'prevented the printer window from opening');
-                    }
-                  };
-                })(this.printWin), 2000);
-  }
-  return rc;
-};
-
-VT100.prototype.sendToPrinter = function(s) {
-  this.openPrinterWindow();
-  try {
-    var doc   = this.printWin.document;
-    var print = doc.getElementById('print');
-    if (print.lastChild && print.lastChild.nodeName == '#text') {
-      print.lastChild.textContent += this.replaceChar(s, ' ', '\u00A0');
-    } else {
-      print.appendChild(doc.createTextNode(this.replaceChar(s, ' ','\u00A0')));
-    }
-  } catch (e) {
-    // There probably was a more aggressive popup blocker that prevented us
-    // from accessing the printer windows.
-  }
-};
-
-VT100.prototype.sendControlToPrinter = function(ch) {
-  // We get called whenever doControl() is active. But for the printer, we
-  // only implement a basic line printer that doesn't understand most of
-  // the escape sequences of the VT100 terminal. In fact, the only escape
-  // sequence that we really need to recognize is '^[[5i' for turning the
-  // printer off.
-  try {
-    switch (ch) {
-    case  9:
-      // HT
-      this.openPrinterWindow();
-      var doc                 = this.printWin.document;
-      var print               = doc.getElementById('print');
-      var chars               = print.lastChild &&
-                                print.lastChild.nodeName == '#text' ?
-                                print.lastChild.textContent.length : 0;
-      this.sendToPrinter(this.spaces(8 - (chars % 8)));
-      break;
-    case 10:
-      // CR
-      break;
-    case 12:
-      // FF
-      this.openPrinterWindow();
-      var pageBreak           = this.printWin.document.createElement('div');
-      pageBreak.className     = 'pagebreak';
-      pageBreak.innerHTML     = '<hr />';
-      this.printWin.document.getElementById('print').appendChild(pageBreak);
-      break;
-    case 13:
-      // LF
-      this.openPrinterWindow();
-      var lineBreak           = this.printWin.document.createElement('br');
-      this.printWin.document.getElementById('print').appendChild(lineBreak);
-      break;
-    case 27:
-      // ESC
-      this.isEsc              = 1 /* ESesc */;
-      break;
-    default:
-      switch (this.isEsc) {
-      case 1 /* ESesc */:
-        this.isEsc            = 0 /* ESnormal */;
-        switch (ch) {
-        case 0x5B /*[*/:
-          this.isEsc          = 2 /* ESsquare */;
-          break;
-        default:
-          break;
-        }
-        break;
-      case 2 /* ESsquare */:
-        this.npar             = 0;
-        this.par              = [ 0, 0, 0, 0, 0, 0, 0, 0,
-                                  0, 0, 0, 0, 0, 0, 0, 0 ];
-        this.isEsc            = 3 /* ESgetpars */;
-        this.isQuestionMark   = ch == 0x3F /*?*/;
-        if (this.isQuestionMark) {
-          break;
-        }
-        // Fall through
-      case 3 /* ESgetpars */:
-        if (ch == 0x3B /*;*/) {
-          this.npar++;
-          break;
-        } else if (ch >= 0x30 /*0*/ && ch <= 0x39 /*9*/) {
-          var par             = this.par[this.npar];
-          if (par == undefined) {
-            par               = 0;
-          }
-          this.par[this.npar] = 10*par + (ch & 0xF);
-          break;
-        } else {
-          this.isEsc          = 4 /* ESgotpars */;
-        }
-        // Fall through
-      case 4 /* ESgotpars */:
-        this.isEsc            = 0 /* ESnormal */;
-        if (this.isQuestionMark) {
-          break;
-        }
-        switch (ch) {
-        case 0x69 /*i*/:
-          this.csii(this.par[0]);
-          break;
-        default:
-          break;
-        }
-        break;
-      default:
-        this.isEsc            = 0 /* ESnormal */;
-        break;
-      }
-      break;
-    }
-  } catch (e) {
-    // There probably was a more aggressive popup blocker that prevented us
-    // from accessing the printer windows.
-  }
-};
-
-VT100.prototype.csiAt = function(number) {
-  // Insert spaces
-  if (number == 0) {
-    number      = 1;
-  }
-  if (number > this.terminalWidth - this.cursorX) {
-    number      = this.terminalWidth - this.cursorX;
-  }
-  this.scrollRegion(this.cursorX, this.cursorY,
-                    this.terminalWidth - this.cursorX - number, 1,
-                    number, 0, this.color, this.style);
-  this.needWrap = false;
-};
-
-VT100.prototype.csii = function(number) {
-  // Printer control
-  switch (number) {
-  case 0: // Print Screen
-    window.print();
-    break;
-  case 4: // Stop printing
-    try {
-      if (this.printing && this.printWin && !this.printWin.closed) {
-        var print = this.printWin.document.getElementById('print');
-        while (print.lastChild &&
-               print.lastChild.tagName == 'DIV' &&
-               print.lastChild.className == 'pagebreak') {
-          // Remove trailing blank pages
-          print.removeChild(print.lastChild);
-        }
-        if (this.autoprint) {
-          this.printWin.print();
-        }
-      }
-    } catch (e) {
-    }
-    this.printing = false;
-    break;
-  case 5: // Start printing
-    if (!this.printing && this.printWin && !this.printWin.closed) {
-      this.printWin.document.getElementById('print').innerHTML = '';
-    }
-    this.printing = 100;
-    break;
-  default:
-    break;
-  }
-};
-
-VT100.prototype.csiJ = function(number) {
-  switch (number) {
-  case 0: // Erase from cursor to end of display
-    this.clearRegion(this.cursorX, this.cursorY,
-                     this.terminalWidth - this.cursorX, 1,
-                     this.color, this.style);
-    if (this.cursorY < this.terminalHeight-2) {
-      this.clearRegion(0, this.cursorY+1,
-                       this.terminalWidth, this.terminalHeight-this.cursorY-1,
-                       this.color, this.style);
-    }
-    break;
-  case 1: // Erase from start to cursor
-    if (this.cursorY > 0) {
-      this.clearRegion(0, 0,
-                       this.terminalWidth, this.cursorY,
-                       this.color, this.style);
-    }
-    this.clearRegion(0, this.cursorY, this.cursorX + 1, 1,
-                     this.color, this.style);
-    break;
-  case 2: // Erase whole display
-    this.clearRegion(0, 0, this.terminalWidth, this.terminalHeight,
-                     this.color, this.style);
-    break;
-  default:
-    return;
-  }
-  needWrap = false;
-};
-
-VT100.prototype.csiK = function(number) {
-  switch (number) {
-  case 0: // Erase from cursor to end of line
-    this.clearRegion(this.cursorX, this.cursorY,
-                     this.terminalWidth - this.cursorX, 1,
-                     this.color, this.style);
-    break;
-  case 1: // Erase from start of line to cursor
-    this.clearRegion(0, this.cursorY, this.cursorX + 1, 1,
-                     this.color, this.style);
-    break;
-  case 2: // Erase whole line
-    this.clearRegion(0, this.cursorY, this.terminalWidth, 1,
-                     this.color, this.style);
-    break;
-  default:
-    return;
-  }
-  needWrap = false;
-};
-
-VT100.prototype.csiL = function(number) {
-  // Open line by inserting blank line(s)
-  if (this.cursorY >= this.bottom) {
-    return;
-  }
-  if (number == 0) {
-    number = 1;
-  }
-  if (number > this.bottom - this.cursorY) {
-    number = this.bottom - this.cursorY;
-  }
-  this.scrollRegion(0, this.cursorY,
-                    this.terminalWidth, this.bottom - this.cursorY - number,
-                    0, number, this.color, this.style);
-  needWrap = false;
-};
-
-VT100.prototype.csiM = function(number) {
-  // Delete line(s), scrolling up the bottom of the screen.
-  if (this.cursorY >= this.bottom) {
-    return;
-  }
-  if (number == 0) {
-    number = 1;
-  }
-  if (number > this.bottom - this.cursorY) {
-    number = bottom - cursorY;
-  }
-  this.scrollRegion(0, this.cursorY + number,
-                    this.terminalWidth, this.bottom - this.cursorY - number,
-                    0, -number, this.color, this.style);
-  needWrap = false;
-};
-
-VT100.prototype.csim = function() {
-  for (var i = 0; i <= this.npar; i++) {
-    switch (this.par[i]) {
-    case 0:  this.attr  = 0x00F0 /* ATTR_DEFAULT */;                                break;
-    case 1:  this.attr  = (this.attr & ~0x0400 /* ATTR_DIM */)|0x0800 /* ATTR_BRIGHT */;         break;
-    case 2:  this.attr  = (this.attr & ~0x0800 /* ATTR_BRIGHT */)|0x0400 /* ATTR_DIM */;         break;
-    case 4:  this.attr |= 0x0200 /* ATTR_UNDERLINE */;                              break;
-    case 5:  this.attr |= 0x1000 /* ATTR_BLINK */;                                  break;
-    case 7:  this.attr |= 0x0100 /* ATTR_REVERSE */;                                break;
-    case 10:
-      this.translate    = this.GMap[this.useGMap];
-      this.dispCtrl     = false;
-      this.toggleMeta   = false;
-      break;
-    case 11:
-      this.translate    = this.CodePage437Map;
-      this.dispCtrl     = true;
-      this.toggleMeta   = false;
-      break;
-    case 12:
-      this.translate    = this.CodePage437Map;
-      this.dispCtrl     = true;
-      this.toggleMeta   = true;
-      break;
-    case 21:
-    case 22: this.attr &= ~(0x0800 /* ATTR_BRIGHT */|0x0400 /* ATTR_DIM */);                     break;
-    case 24: this.attr &= ~ 0x0200 /* ATTR_UNDERLINE */;                            break;
-    case 25: this.attr &= ~ 0x1000 /* ATTR_BLINK */;                                break;
-    case 27: this.attr &= ~ 0x0100 /* ATTR_REVERSE */;                              break;
-    case 38: this.attr  = (this.attr & ~(0x0400 /* ATTR_DIM */|0x0800 /* ATTR_BRIGHT */|0x0F))|
-                          0x0200 /* ATTR_UNDERLINE */;                              break;
-    case 39: this.attr &= ~(0x0400 /* ATTR_DIM */|0x0800 /* ATTR_BRIGHT */|0x0200 /* ATTR_UNDERLINE */|0x0F); break;
-    case 49: this.attr |= 0xF0;                                        break;
-    default:
-      if (this.par[i] >= 30 && this.par[i] <= 37) {
-          var fg        = this.par[i] - 30;
-          this.attr     = (this.attr & ~0x0F) | fg;
-      } else if (this.par[i] >= 40 && this.par[i] <= 47) {
-          var bg        = this.par[i] - 40;
-          this.attr     = (this.attr & ~0xF0) | (bg << 4);
-      }
-      break;
-    }
-  }
-  this.updateStyle();
-};
-
-VT100.prototype.csiP = function(number) {
-  // Delete character(s) following cursor
-  if (number == 0) {
-    number = 1;
-  }
-  if (number > this.terminalWidth - this.cursorX) {
-    number = this.terminalWidth - this.cursorX;
-  }
-  this.scrollRegion(this.cursorX + number, this.cursorY,
-                    this.terminalWidth - this.cursorX - number, 1,
-                    -number, 0, this.color, this.style);
-  needWrap = false;
-};
-
-VT100.prototype.csiX = function(number) {
-  // Clear characters following cursor
-  if (number == 0) {
-    number++;
-  }
-  if (number > this.terminalWidth - this.cursorX) {
-    number = this.terminalWidth - this.cursorX;
-  }
-  this.clearRegion(this.cursorX, this.cursorY, number, 1,
-                   this.color, this.style);
-  needWrap = false;
-};
-
-VT100.prototype.settermCommand = function() {
-  // Setterm commands are not implemented
-};
-
-VT100.prototype.doControl = function(ch) {
-  if (this.printing) {
-    this.sendControlToPrinter(ch);
-    return '';
-  }
-  var lineBuf                = '';
-  switch (ch) {
-  case 0x00: /* ignored */                                              break;
-  case 0x08: this.bs();                                                 break;
-  case 0x09: this.ht();                                                 break;
-  case 0x0A:
-  case 0x0B:
-  case 0x0C:
-  case 0x84: this.lf(); if (!this.crLfMode)                             break;
-  case 0x0D: this.cr();                                                 break;
-  case 0x85: this.cr(); this.lf();                                      break;
-  case 0x0E: this.useGMap     = 1;
-             this.translate   = this.GMap[1];
-             this.dispCtrl    = true;                                   break;
-  case 0x0F: this.useGMap     = 0;
-             this.translate   = this.GMap[0];
-             this.dispCtrl    = false;                                  break;
-  case 0x18:
-  case 0x1A: this.isEsc       = 0 /* ESnormal */;                               break;
-  case 0x1B: this.isEsc       = 1 /* ESesc */;                                  break;
-  case 0x7F: /* ignored */                                              break;
-  case 0x88: this.userTabStop[this.cursorX] = true;                     break;
-  case 0x8D: this.ri();                                                 break;
-  case 0x8E: this.isEsc       = 18 /* ESss2 */;                                  break;
-  case 0x8F: this.isEsc       = 19 /* ESss3 */;                                  break;
-  case 0x9A: this.respondID();                                          break;
-  case 0x9B: this.isEsc       = 2 /* ESsquare */;                               break;
-  case 0x07: if (this.isEsc != 17 /* EStitle */) {
-               this.beep();                                             break;
-             }
-             /* fall thru */
-  default:   switch (this.isEsc) {
-    case 1 /* ESesc */:
-      this.isEsc              = 0 /* ESnormal */;
-      switch (ch) {
-/*%*/ case 0x25: this.isEsc   = 13 /* ESpercent */;                              break;
-/*(*/ case 0x28: this.isEsc   = 8 /* ESsetG0 */;                                break;
-/*-*/ case 0x2D:
-/*)*/ case 0x29: this.isEsc   = 9 /* ESsetG1 */;                                break;
-/*.*/ case 0x2E:
-/***/ case 0x2A: this.isEsc   = 10 /* ESsetG2 */;                                break;
-/*/*/ case 0x2F:
-/*+*/ case 0x2B: this.isEsc   = 11 /* ESsetG3 */;                                break;
-/*#*/ case 0x23: this.isEsc   = 7 /* EShash */;                                 break;
-/*7*/ case 0x37: this.saveCursor();                                     break;
-/*8*/ case 0x38: this.restoreCursor();                                  break;
-/*>*/ case 0x3E: this.applKeyMode = false;                              break;
-/*=*/ case 0x3D: this.applKeyMode = true;                               break;
-/*D*/ case 0x44: this.lf();                                             break;
-/*E*/ case 0x45: this.cr(); this.lf();                                  break;
-/*M*/ case 0x4D: this.ri();                                             break;
-/*N*/ case 0x4E: this.isEsc   = 18 /* ESss2 */;                                  break;
-/*O*/ case 0x4F: this.isEsc   = 19 /* ESss3 */;                                  break;
-/*H*/ case 0x48: this.userTabStop[this.cursorX] = true;                 break;
-/*Z*/ case 0x5A: this.respondID();                                      break;
-/*[*/ case 0x5B: this.isEsc   = 2 /* ESsquare */;                               break;
-/*]*/ case 0x5D: this.isEsc   = 15 /* ESnonstd */;                               break;
-/*c*/ case 0x63: this.reset();                                          break;
-/*g*/ case 0x67: this.flashScreen();                                    break;
-      default:                                                          break;
-      }
-      break;
-    case 15 /* ESnonstd */:
-      switch (ch) {
-/*0*/ case 0x30:
-/*1*/ case 0x31:
-/*2*/ case 0x32: this.isEsc   = 17 /* EStitle */; this.titleString = '';         break;
-/*P*/ case 0x50: this.npar    = 0; this.par = [ 0, 0, 0, 0, 0, 0, 0 ];
-                 this.isEsc   = 16 /* ESpalette */;                              break;
-/*R*/ case 0x52: // Palette support is not implemented
-                 this.isEsc   = 0 /* ESnormal */;                               break;
-      default:   this.isEsc   = 0 /* ESnormal */;                               break;
-      }
-      break;
-    case 16 /* ESpalette */:
-      if ((ch >= 0x30 /*0*/ && ch <= 0x39 /*9*/) ||
-          (ch >= 0x41 /*A*/ && ch <= 0x46 /*F*/) ||
-          (ch >= 0x61 /*a*/ && ch <= 0x66 /*f*/)) {
-        this.par[this.npar++] = ch > 0x39  /*9*/ ? (ch & 0xDF) - 55
-                                                : (ch & 0xF);
-        if (this.npar == 7) {
-          // Palette support is not implemented
-          this.isEsc          = 0 /* ESnormal */;
-        }
-      } else {
-        this.isEsc            = 0 /* ESnormal */;
-      }
-      break;
-    case 2 /* ESsquare */:
-      this.npar               = 0;
-      this.par                = [ 0, 0, 0, 0, 0, 0, 0, 0,
-                                  0, 0, 0, 0, 0, 0, 0, 0 ];
-      this.isEsc              = 3 /* ESgetpars */;
-/*[*/ if (ch == 0x5B) { // Function key
-        this.isEsc            = 6 /* ESfunckey */;
-        break;
-      } else {
-/*?*/   this.isQuestionMark   = ch == 0x3F;
-        if (this.isQuestionMark) {
-          break;
-        }
-      }
-      // Fall through
-    case 5 /* ESdeviceattr */:
-    case 3 /* ESgetpars */:
-/*;*/ if (ch == 0x3B) {
-        this.npar++;
-        break;
-      } else if (ch >= 0x30 /*0*/ && ch <= 0x39 /*9*/) {
-        var par               = this.par[this.npar];
-        if (par == undefined) {
-          par                 = 0;
-        }
-        this.par[this.npar]   = 10*par + (ch & 0xF);
-        break;
-      } else if (this.isEsc == 5 /* ESdeviceattr */) {
-        switch (ch) {
-/*c*/   case 0x63: if (this.par[0] == 0) this.respondSecondaryDA();     break;
-/*m*/   case 0x6D: /* (re)set key modifier resource values */           break;
-/*n*/   case 0x6E: /* disable key modifier resource values */           break;
-/*p*/   case 0x70: /* set pointer mode resource value */                break;
-        default:                                                        break;
-        }
-        this.isEsc            = 0 /* ESnormal */;
-        break;
-      } else {
-        this.isEsc            = 4 /* ESgotpars */;
-      }
-      // Fall through
-    case 4 /* ESgotpars */:
-      this.isEsc              = 0 /* ESnormal */;
-      if (this.isQuestionMark) {
-        switch (ch) {
-/*h*/   case 0x68: this.setMode(true);                                  break;
-/*l*/   case 0x6C: this.setMode(false);                                 break;
-/*c*/   case 0x63: this.setCursorAttr(this.par[2], this.par[1]);        break;
-        default:                                                        break;
-        }
-        this.isQuestionMark   = false;
-        break;
-      }
-      switch (ch) {
-/*!*/ case 0x21: this.isEsc   = 12 /* ESbang */;                                 break;
-/*>*/ case 0x3E: if (!this.npar) this.isEsc  = 5 /* ESdeviceattr */;            break;
-/*G*/ case 0x47:
-/*`*/ case 0x60: this.gotoXY(this.par[0] - 1, this.cursorY);            break;
-/*A*/ case 0x41: this.gotoXY(this.cursorX,
-                             this.cursorY - (this.par[0] ? this.par[0] : 1));
-                                                                        break;
-/*B*/ case 0x42:
-/*e*/ case 0x65: this.gotoXY(this.cursorX,
-                             this.cursorY + (this.par[0] ? this.par[0] : 1));
-                                                                        break;
-/*C*/ case 0x43:
-/*a*/ case 0x61: this.gotoXY(this.cursorX + (this.par[0] ? this.par[0] : 1),
-                             this.cursorY);                             break;
-/*D*/ case 0x44: this.gotoXY(this.cursorX - (this.par[0] ? this.par[0] : 1),
-                             this.cursorY);                             break;
-/*E*/ case 0x45: this.gotoXY(0, this.cursorY + (this.par[0] ? this.par[0] :1));
-                                                                        break;
-/*F*/ case 0x46: this.gotoXY(0, this.cursorY - (this.par[0] ? this.par[0] :1));
-                                                                        break;
-/*d*/ case 0x64: this.gotoXaY(this.cursorX, this.par[0] - 1);           break;
-/*H*/ case 0x48:
-/*f*/ case 0x66: this.gotoXaY(this.par[1] - 1, this.par[0] - 1);        break;
-/*I*/ case 0x49: this.ht(this.par[0] ? this.par[0] : 1);                break;
-/*@*/ case 0x40: this.csiAt(this.par[0]);                               break;
-/*i*/ case 0x69: this.csii(this.par[0]);                                break;
-/*J*/ case 0x4A: this.csiJ(this.par[0]);                                break;
-/*K*/ case 0x4B: this.csiK(this.par[0]);                                break;
-/*L*/ case 0x4C: this.csiL(this.par[0]);                                break;
-/*M*/ case 0x4D: this.csiM(this.par[0]);                                break;
-/*m*/ case 0x6D: this.csim();                                           break;
-/*P*/ case 0x50: this.csiP(this.par[0]);                                break;
-/*X*/ case 0x58: this.csiX(this.par[0]);                                break;
-/*S*/ case 0x53: this.lf(this.par[0] ? this.par[0] : 1);                break;
-/*T*/ case 0x54: this.ri(this.par[0] ? this.par[0] : 1);                break;
-/*c*/ case 0x63: if (!this.par[0]) this.respondID();                    break;
-/*g*/ case 0x67: if (this.par[0] == 0) {
-                   this.userTabStop[this.cursorX] = false;
-                 } else if (this.par[0] == 2 || this.par[0] == 3) {
-                   this.userTabStop               = [ ];
-                   for (var i = 0; i < this.terminalWidth; i++) {
-                     this.userTabStop[i]          = false;
-                   }
-                 }
-                 break;
-/*h*/ case 0x68: this.setMode(true);                                    break;
-/*l*/ case 0x6C: this.setMode(false);                                   break;
-/*n*/ case 0x6E: switch (this.par[0]) {
-                 case 5: this.statusReport();                           break;
-                 case 6: this.cursorReport();                           break;
-                 default:                                               break;
-                 }
-                 break;
-/*q*/ case 0x71: // LED control not implemented
-                                                                        break;
-/*r*/ case 0x72: var t        = this.par[0] ? this.par[0] : 1;
-                 var b        = this.par[1] ? this.par[1]
-                                            : this.terminalHeight;
-                 if (t < b && b <= this.terminalHeight) {
-                   this.top   = t - 1;
-                   this.bottom= b;
-                   this.gotoXaY(0, 0);
-                 }
-                 break;
-/*b*/ case 0x62: var c        = this.par[0] ? this.par[0] : 1;
-                 if (c > this.terminalWidth * this.terminalHeight) {
-                   c          = this.terminalWidth * this.terminalHeight;
-                 }
-                 while (c-- > 0) {
-                   lineBuf   += this.lastCharacter;
-                 }
-                 break;
-/*s*/ case 0x73: this.saveCursor();                                     break;
-/*u*/ case 0x75: this.restoreCursor();                                  break;
-/*Z*/ case 0x5A: this.rt(this.par[0] ? this.par[0] : 1);                break;
-/*]*/ case 0x5D: this.settermCommand();                                 break;
-      default:                                                          break;
-      }
-      break;
-    case 12 /* ESbang */:
-      if (ch == 'p') {
-        this.reset();
-      }
-      this.isEsc              = 0 /* ESnormal */;
-      break;
-    case 13 /* ESpercent */:
-      this.isEsc              = 0 /* ESnormal */;
-      switch (ch) {
-/*@*/ case 0x40: this.utfEnabled = false;                               break;
-/*G*/ case 0x47:
-/*8*/ case 0x38: this.utfEnabled = true;                                break;
-      default:                                                          break;
-      }
-      break;
-    case 6 /* ESfunckey */:
-      this.isEsc              = 0 /* ESnormal */;                               break;
-    case 7 /* EShash */:
-      this.isEsc              = 0 /* ESnormal */;
-/*8*/ if (ch == 0x38) {
-        // Screen alignment test not implemented
-      }
-      break;
-    case 8 /* ESsetG0 */:
-    case 9 /* ESsetG1 */:
-    case 10 /* ESsetG2 */:
-    case 11 /* ESsetG3 */:
-      var g                   = this.isEsc - 8 /* ESsetG0 */;
-      this.isEsc              = 0 /* ESnormal */;
-      switch (ch) {
-/*0*/ case 0x30: this.GMap[g] = this.VT100GraphicsMap;                  break;
-/*A*/ case 0x42:
-/*B*/ case 0x42: this.GMap[g] = this.Latin1Map;                         break;
-/*U*/ case 0x55: this.GMap[g] = this.CodePage437Map;                    break;
-/*K*/ case 0x4B: this.GMap[g] = this.DirectToFontMap;                   break;
-      default:                                                          break;
-      }
-      if (this.useGMap == g) {
-        this.translate        = this.GMap[g];
-      }
-      break;
-    case 17 /* EStitle */:
-      if (ch == 0x07) {
-        if (this.titleString && this.titleString.charAt(0) == ';') {
-          this.titleString    = this.titleString.substr(1);
-          if (this.titleString != '') {
-            this.titleString += ' - ';
-          }
-          this.titleString += 'Shell In A Box'
-        }
-        try {
-          window.document.title = this.titleString;
-        } catch (e) {
-        }
-        this.isEsc            = 0 /* ESnormal */;
-      } else {
-        this.titleString     += String.fromCharCode(ch);
-      }
-      break;
-    case 18 /* ESss2 */:
-    case 19 /* ESss3 */:
-      if (ch < 256) {
-          ch                  = this.GMap[this.isEsc - 18 /* ESss2 */ + 2]
-                                         [this.toggleMeta ? (ch | 0x80) : ch];
-        if ((ch & 0xFF00) == 0xF000) {
-          ch                  = ch & 0xFF;
-        } else if (ch == 0xFEFF || (ch >= 0x200A && ch <= 0x200F)) {
-          this.isEsc         = 0 /* ESnormal */;                                break;
-        }
-      }
-      this.lastCharacter      = String.fromCharCode(ch);
-      lineBuf                += this.lastCharacter;
-      this.isEsc              = 0 /* ESnormal */;                               break;
-    default:
-      this.isEsc              = 0 /* ESnormal */;                               break;
-    }
-    break;
-  }
-  return lineBuf;
-};
-
-VT100.prototype.renderString = function(s, showCursor) {
-  if (this.printing) {
-    this.sendToPrinter(s);
-    if (showCursor) {
-      this.showCursor();
-    }
-    return;
-  }
-
-  // We try to minimize the number of DOM operations by coalescing individual
-  // characters into strings. This is a significant performance improvement.
-  var incX = s.length;
-  if (incX > this.terminalWidth - this.cursorX) {
-    incX   = this.terminalWidth - this.cursorX;
-    if (incX <= 0) {
-      return;
-    }
-    s      = s.substr(0, incX - 1) + s.charAt(s.length - 1);
-  }
-  if (showCursor) {
-    // Minimize the number of calls to putString(), by avoiding a direct
-    // call to this.showCursor()
-    this.cursor.style.visibility = '';
-  }
-  this.putString(this.cursorX, this.cursorY, s, this.color, this.style);
-};
-
-VT100.prototype.vt100 = function(s) {
-  this.cursorNeedsShowing = this.hideCursor();
-  this.respondString      = '';
-  var lineBuf             = '';
-  for (var i = 0; i < s.length; i++) {
-    var ch = s.charCodeAt(i);
-    if (this.utfEnabled) {
-      // Decode UTF8 encoded character
-      if (ch > 0x7F) {
-        if (this.utfCount > 0 && (ch & 0xC0) == 0x80) {
-          this.utfChar    = (this.utfChar << 6) | (ch & 0x3F);
-          if (--this.utfCount <= 0) {
-            if (this.utfChar > 0xFFFF || this.utfChar < 0) {
-              ch = 0xFFFD;
-            } else {
-              ch          = this.utfChar;
-            }
-          } else {
-            continue;
-          }
-        } else {
-          if ((ch & 0xE0) == 0xC0) {
-            this.utfCount = 1;
-            this.utfChar  = ch & 0x1F;
-          } else if ((ch & 0xF0) == 0xE0) {
-            this.utfCount = 2;
-            this.utfChar  = ch & 0x0F;
-          } else if ((ch & 0xF8) == 0xF0) {
-            this.utfCount = 3;
-            this.utfChar  = ch & 0x07;
-          } else if ((ch & 0xFC) == 0xF8) {
-            this.utfCount = 4;
-            this.utfChar  = ch & 0x03;
-          } else if ((ch & 0xFE) == 0xFC) {
-            this.utfCount = 5;
-            this.utfChar  = ch & 0x01;
-          } else {
-            this.utfCount = 0;
-          }
-          continue;
-        }
-      } else {
-        this.utfCount     = 0;
-      }
-    }
-    var isNormalCharacter =
-      (ch >= 32 && ch <= 127 || ch >= 160 ||
-       this.utfEnabled && ch >= 128 ||
-       !(this.dispCtrl ? this.ctrlAlways : this.ctrlAction)[ch & 0x1F]) &&
-      (ch != 0x7F || this.dispCtrl);
-
-    if (isNormalCharacter && this.isEsc == 0 /* ESnormal */) {
-      if (ch < 256) {
-        ch                = this.translate[this.toggleMeta ? (ch | 0x80) : ch];
-      }
-      if ((ch & 0xFF00) == 0xF000) {
-        ch                = ch & 0xFF;
-      } else if (ch == 0xFEFF || (ch >= 0x200A && ch <= 0x200F)) {
-        continue;
-      }
-      if (!this.printing) {
-        if (this.needWrap || this.insertMode) {
-          if (lineBuf) {
-            this.renderString(lineBuf);
-            lineBuf       = '';
-          }
-        }
-        if (this.needWrap) {
-          this.cr(); this.lf();
-        }
-        if (this.insertMode) {
-          this.scrollRegion(this.cursorX, this.cursorY,
-                            this.terminalWidth - this.cursorX - 1, 1,
-                            1, 0, this.color, this.style);
-        }
-      }
-      this.lastCharacter  = String.fromCharCode(ch);
-      lineBuf            += this.lastCharacter;
-      if (!this.printing &&
-          this.cursorX + lineBuf.length >= this.terminalWidth) {
-        this.needWrap     = this.autoWrapMode;
-      }
-    } else {
-      if (lineBuf) {
-        this.renderString(lineBuf);
-        lineBuf           = '';
-      }
-      var expand          = this.doControl(ch);
-      if (expand.length) {
-        var r             = this.respondString;
-        this.respondString= r + this.vt100(expand);
-      }
-    }
-  }
-  if (lineBuf) {
-    this.renderString(lineBuf, this.cursorNeedsShowing);
-  } else if (this.cursorNeedsShowing) {
-    this.showCursor();
-  }
-  return this.respondString;
-};
-
-VT100.prototype.Latin1Map = [
-0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
-0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
-0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
-0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
-0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
-0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
-0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
-0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
-0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
-0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
-0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
-0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
-0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
-0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
-0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
-0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F,
-0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
-0x0088, 0x0089, 0x008A, 0x008B, 0x008C, 0x008D, 0x008E, 0x008F,
-0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
-0x0098, 0x0099, 0x009A, 0x009B, 0x009C, 0x009D, 0x009E, 0x009F,
-0x00A0, 0x00A1, 0x00A2, 0x00A3, 0x00A4, 0x00A5, 0x00A6, 0x00A7,
-0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF,
-0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5, 0x00B6, 0x00B7,
-0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF,
-0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
-0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF,
-0x00D0, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7,
-0x00D8, 0x00D9, 0x00DA, 0x00DB, 0x00DC, 0x00DD, 0x00DE, 0x00DF,
-0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7,
-0x00E8, 0x00E9, 0x00EA, 0x00EB, 0x00EC, 0x00ED, 0x00EE, 0x00EF,
-0x00F0, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7,
-0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x00FD, 0x00FE, 0x00FF
-];
-
-VT100.prototype.VT100GraphicsMap = [
-0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
-0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
-0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
-0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
-0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
-0x0028, 0x0029, 0x002A, 0x2192, 0x2190, 0x2191, 0x2193, 0x002F,
-0x2588, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
-0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
-0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
-0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
-0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
-0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x00A0,
-0x25C6, 0x2592, 0x2409, 0x240C, 0x240D, 0x240A, 0x00B0, 0x00B1,
-0x2591, 0x240B, 0x2518, 0x2510, 0x250C, 0x2514, 0x253C, 0xF800,
-0xF801, 0x2500, 0xF803, 0xF804, 0x251C, 0x2524, 0x2534, 0x252C,
-0x2502, 0x2264, 0x2265, 0x03C0, 0x2260, 0x00A3, 0x00B7, 0x007F,
-0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
-0x0088, 0x0089, 0x008A, 0x008B, 0x008C, 0x008D, 0x008E, 0x008F,
-0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
-0x0098, 0x0099, 0x009A, 0x009B, 0x009C, 0x009D, 0x009E, 0x009F,
-0x00A0, 0x00A1, 0x00A2, 0x00A3, 0x00A4, 0x00A5, 0x00A6, 0x00A7,
-0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF,
-0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5, 0x00B6, 0x00B7,
-0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF,
-0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
-0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF,
-0x00D0, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7,
-0x00D8, 0x00D9, 0x00DA, 0x00DB, 0x00DC, 0x00DD, 0x00DE, 0x00DF,
-0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7,
-0x00E8, 0x00E9, 0x00EA, 0x00EB, 0x00EC, 0x00ED, 0x00EE, 0x00EF,
-0x00F0, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7,
-0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x00FD, 0x00FE, 0x00FF
-];
-
-VT100.prototype.CodePage437Map = [
-0x0000, 0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
-0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C,
-0x25B6, 0x25C0, 0x2195, 0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8,
-0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
-0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
-0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
-0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
-0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
-0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
-0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
-0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
-0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
-0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
-0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
-0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
-0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x2302,
-0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7,
-0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5,
-0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9,
-0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192,
-0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA,
-0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB,
-0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
-0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510,
-0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
-0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567,
-0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B,
-0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580,
-0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4,
-0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229,
-0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248,
-0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0
-];
-
-VT100.prototype.DirectToFontMap = [
-0xF000, 0xF001, 0xF002, 0xF003, 0xF004, 0xF005, 0xF006, 0xF007,
-0xF008, 0xF009, 0xF00A, 0xF00B, 0xF00C, 0xF00D, 0xF00E, 0xF00F,
-0xF010, 0xF011, 0xF012, 0xF013, 0xF014, 0xF015, 0xF016, 0xF017,
-0xF018, 0xF019, 0xF01A, 0xF01B, 0xF01C, 0xF01D, 0xF01E, 0xF01F,
-0xF020, 0xF021, 0xF022, 0xF023, 0xF024, 0xF025, 0xF026, 0xF027,
-0xF028, 0xF029, 0xF02A, 0xF02B, 0xF02C, 0xF02D, 0xF02E, 0xF02F,
-0xF030, 0xF031, 0xF032, 0xF033, 0xF034, 0xF035, 0xF036, 0xF037,
-0xF038, 0xF039, 0xF03A, 0xF03B, 0xF03C, 0xF03D, 0xF03E, 0xF03F,
-0xF040, 0xF041, 0xF042, 0xF043, 0xF044, 0xF045, 0xF046, 0xF047,
-0xF048, 0xF049, 0xF04A, 0xF04B, 0xF04C, 0xF04D, 0xF04E, 0xF04F,
-0xF050, 0xF051, 0xF052, 0xF053, 0xF054, 0xF055, 0xF056, 0xF057,
-0xF058, 0xF059, 0xF05A, 0xF05B, 0xF05C, 0xF05D, 0xF05E, 0xF05F,
-0xF060, 0xF061, 0xF062, 0xF063, 0xF064, 0xF065, 0xF066, 0xF067,
-0xF068, 0xF069, 0xF06A, 0xF06B, 0xF06C, 0xF06D, 0xF06E, 0xF06F,
-0xF070, 0xF071, 0xF072, 0xF073, 0xF074, 0xF075, 0xF076, 0xF077,
-0xF078, 0xF079, 0xF07A, 0xF07B, 0xF07C, 0xF07D, 0xF07E, 0xF07F,
-0xF080, 0xF081, 0xF082, 0xF083, 0xF084, 0xF085, 0xF086, 0xF087,
-0xF088, 0xF089, 0xF08A, 0xF08B, 0xF08C, 0xF08D, 0xF08E, 0xF08F,
-0xF090, 0xF091, 0xF092, 0xF093, 0xF094, 0xF095, 0xF096, 0xF097,
-0xF098, 0xF099, 0xF09A, 0xF09B, 0xF09C, 0xF09D, 0xF09E, 0xF09F,
-0xF0A0, 0xF0A1, 0xF0A2, 0xF0A3, 0xF0A4, 0xF0A5, 0xF0A6, 0xF0A7,
-0xF0A8, 0xF0A9, 0xF0AA, 0xF0AB, 0xF0AC, 0xF0AD, 0xF0AE, 0xF0AF,
-0xF0B0, 0xF0B1, 0xF0B2, 0xF0B3, 0xF0B4, 0xF0B5, 0xF0B6, 0xF0B7,
-0xF0B8, 0xF0B9, 0xF0BA, 0xF0BB, 0xF0BC, 0xF0BD, 0xF0BE, 0xF0BF,
-0xF0C0, 0xF0C1, 0xF0C2, 0xF0C3, 0xF0C4, 0xF0C5, 0xF0C6, 0xF0C7,
-0xF0C8, 0xF0C9, 0xF0CA, 0xF0CB, 0xF0CC, 0xF0CD, 0xF0CE, 0xF0CF,
-0xF0D0, 0xF0D1, 0xF0D2, 0xF0D3, 0xF0D4, 0xF0D5, 0xF0D6, 0xF0D7,
-0xF0D8, 0xF0D9, 0xF0DA, 0xF0DB, 0xF0DC, 0xF0DD, 0xF0DE, 0xF0DF,
-0xF0E0, 0xF0E1, 0xF0E2, 0xF0E3, 0xF0E4, 0xF0E5, 0xF0E6, 0xF0E7,
-0xF0E8, 0xF0E9, 0xF0EA, 0xF0EB, 0xF0EC, 0xF0ED, 0xF0EE, 0xF0EF,
-0xF0F0, 0xF0F1, 0xF0F2, 0xF0F3, 0xF0F4, 0xF0F5, 0xF0F6, 0xF0F7,
-0xF0F8, 0xF0F9, 0xF0FA, 0xF0FB, 0xF0FC, 0xF0FD, 0xF0FE, 0xF0FF
-];
-
-VT100.prototype.ctrlAction = [
-  true,  false, false, false, false, false, false, true,
-  true,  true,  true,  true,  true,  true,  true,  true,
-  false, false, false, false, false, false, false, false,
-  true,  false, true,  true,  false, false, false, false
-];
-
-VT100.prototype.ctrlAlways = [
-  true,  false, false, false, false, false, false, false,
-  true,  false, true,  false, true,  true,  true,  true,
-  false, false, false, false, false, false, false, false,
-  false, false, false, true,  false, false, false, false
-];
diff --git a/apps/workbench/lib/config_loader.rb b/apps/workbench/lib/config_loader.rb
deleted file mode 100644 (file)
index 730e468..0000000
+++ /dev/null
@@ -1,243 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module Psych
-  module Visitors
-    class YAMLTree < Psych::Visitors::Visitor
-      def visit_ActiveSupport_Duration o
-        seconds = o.to_i
-        outstr = ""
-        if seconds / 3600 > 0
-          outstr += "#{seconds / 3600}h"
-          seconds = seconds % 3600
-        end
-        if seconds / 60 > 0
-          outstr += "#{seconds / 60}m"
-          seconds = seconds % 60
-        end
-        if seconds > 0
-          outstr += "#{seconds}s"
-        end
-        if outstr == ""
-          outstr = "0s"
-        end
-        @emitter.scalar outstr, nil, nil, true, false, Nodes::Scalar::ANY
-      end
-
-      def visit_URI_Generic o
-        @emitter.scalar o.to_s, nil, nil, true, false, Nodes::Scalar::ANY
-      end
-
-      def visit_URI_HTTP o
-        @emitter.scalar o.to_s, nil, nil, true, false, Nodes::Scalar::ANY
-      end
-
-      def visit_Pathname o
-        @emitter.scalar o.to_s, nil, nil, true, false, Nodes::Scalar::ANY
-      end
-    end
-  end
-end
-
-
-module Boolean; end
-class TrueClass; include Boolean; end
-class FalseClass; include Boolean; end
-
-class NonemptyString < String
-end
-
-class ConfigLoader
-  def initialize
-    @config_migrate_map = {}
-    @config_types = {}
-  end
-
-  def declare_config(assign_to, configtype, migrate_from=nil, migrate_fn=nil)
-    if migrate_from
-      @config_migrate_map[migrate_from] = migrate_fn || ->(cfg, k, v) {
-        ConfigLoader.set_cfg cfg, assign_to, v
-      }
-    end
-    @config_types[assign_to] = configtype
-  end
-
-
-  def migrate_config from_config, to_config
-    remainders = {}
-    from_config.each do |k, v|
-      if @config_migrate_map[k.to_sym]
-        begin
-          @config_migrate_map[k.to_sym].call to_config, k, v
-        rescue => e
-          raise "Error migrating '#{k}: #{v}' got error #{e}"
-        end
-      else
-        remainders[k] = v
-      end
-    end
-    remainders
-  end
-
-  def coercion_and_check check_cfg, check_nonempty: true
-    @config_types.each do |cfgkey, cfgtype|
-      begin
-        cfg = check_cfg
-        k = cfgkey
-        ks = k.split '.'
-        k = ks.pop
-        ks.each do |kk|
-          cfg = cfg[kk]
-          if cfg.nil?
-            break
-          end
-        end
-
-        if cfg.nil?
-          raise "missing #{cfgkey}"
-        end
-
-        if cfgtype == String and !cfg[k]
-          cfg[k] = ""
-        end
-
-        if cfgtype == String and cfg[k].is_a? Symbol
-          cfg[k] = cfg[k].to_s
-        end
-
-        if cfgtype == Pathname and cfg[k].is_a? String
-
-          if cfg[k] == ""
-            cfg[k] = Pathname.new("")
-          else
-            cfg[k] = Pathname.new(cfg[k])
-            if !cfg[k].exist?
-              raise "#{cfgkey} path #{cfg[k]} does not exist"
-            end
-          end
-        end
-
-        if cfgtype == NonemptyString
-          if (!cfg[k] || cfg[k] == "") && check_nonempty
-            raise "#{cfgkey} cannot be empty"
-          end
-          if cfg[k].is_a? String
-            next
-          end
-        end
-
-        if cfgtype == ActiveSupport::Duration
-          if cfg[k].is_a? Integer
-            cfg[k] = cfg[k].seconds
-          elsif cfg[k].is_a? String
-            cfg[k] = ConfigLoader.parse_duration(cfg[k], cfgkey: cfgkey)
-          end
-        end
-
-        if cfgtype == URI
-          if cfg[k]
-            cfg[k] = URI(cfg[k])
-          else
-            cfg[k] = URI("")
-          end
-        end
-
-        if cfgtype == Integer && cfg[k].is_a?(String)
-          v = cfg[k].sub(/B\s*$/, '')
-          if mt = /(-?\d*\.?\d+)\s*([KMGTPE]i?)$/.match(v)
-            if mt[1].index('.')
-              v = mt[1].to_f
-            else
-              v = mt[1].to_i
-            end
-            cfg[k] = v * {
-              'K' => 1000,
-              'Ki' => 1 << 10,
-              'M' => 1000000,
-              'Mi' => 1 << 20,
-             "G" =>  1000000000,
-             "Gi" => 1 << 30,
-             "T" =>  1000000000000,
-             "Ti" => 1 << 40,
-             "P" =>  1000000000000000,
-             "Pi" => 1 << 50,
-             "E" =>  1000000000000000000,
-             "Ei" => 1 << 60,
-            }[mt[2]]
-          end
-        end
-
-      rescue => e
-        raise "#{cfgkey} expected #{cfgtype} but '#{cfg[k]}' got error #{e}"
-      end
-
-      if !cfg[k].is_a? cfgtype
-        raise "#{cfgkey} expected #{cfgtype} but was #{cfg[k].class}"
-      end
-    end
-  end
-
-  def self.set_cfg cfg, k, v
-    # "foo.bar = baz" --> { cfg["foo"]["bar"] = baz }
-    ks = k.split '.'
-    k = ks.pop
-    ks.each do |kk|
-      cfg = cfg[kk]
-      if cfg.nil?
-        break
-      end
-    end
-    if !cfg.nil?
-      cfg[k] = v
-    end
-  end
-
-  def self.parse_duration durstr, cfgkey:
-    duration_re = /-?(\d+(\.\d+)?)(s|m|h)/
-    dursec = 0
-    while durstr != ""
-      mt = duration_re.match durstr
-      if !mt
-        raise "#{cfgkey} not a valid duration: '#{durstr}', accepted suffixes are s, m, h"
-      end
-      multiplier = {s: 1, m: 60, h: 3600}
-      dursec += (Float(mt[1]) * multiplier[mt[3].to_sym])
-      durstr = durstr[mt[0].length..-1]
-    end
-    return dursec.seconds
-  end
-
-  def self.copy_into_config src, dst
-    src.each do |k, v|
-      dst.send "#{k}=", self.to_OrderedOptions(v)
-    end
-  end
-
-  def self.to_OrderedOptions confs
-    if confs.is_a? Hash
-      opts = ActiveSupport::OrderedOptions.new
-      confs.each do |k,v|
-        opts[k] = self.to_OrderedOptions(v)
-      end
-      opts
-    elsif confs.is_a? Array
-      confs.map { |v| self.to_OrderedOptions v }
-    else
-      confs
-    end
-  end
-
-  def self.load path, erb: false
-    if File.exist? path
-      yaml = IO.read path
-      if erb
-        yaml = ERB.new(yaml).result(binding)
-      end
-      YAML.load(yaml, deserialize_symbols: false)
-    else
-      {}
-    end
-  end
-
-end
diff --git a/apps/workbench/lib/config_validators.rb b/apps/workbench/lib/config_validators.rb
deleted file mode 100644 (file)
index 804e3e3..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'uri'
-
-module ConfigValidators
-  def self.validate_wb2_url_config
-    if Rails.configuration.Services.Workbench2.ExternalURL != URI("")
-      if !Rails.configuration.Services.Workbench2.ExternalURL.is_a?(URI::HTTP)
-        raise "workbench2_url config is not an HTTP URL: #{Rails.configuration.Services.Workbench2.ExternalURL}"
-      elsif /.*[\/]{2,}$/.match(Rails.configuration.Services.Workbench2.ExternalURL.to_s)
-        raise "workbench2_url config shouldn't have multiple trailing slashes: #{Rails.configuration.Services.Workbench2.ExternalURL}"
-      else
-        return true
-      end
-    end
-    return false
-  end
-
-  def self.validate_download_config
-    if Rails.configuration.Services.WebDAV.ExternalURL == URI("") and Rails.configuration.Services.WebDAVDownload.ExternalURL == URI("")
-      raise "Keep-web service must be configured in Services.WebDAV and/or Services.WebDAVDownload"
-    end
-  end
-end
diff --git a/apps/workbench/lib/tasks/.gitkeep b/apps/workbench/lib/tasks/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/lib/tasks/config.rake b/apps/workbench/lib/tasks/config.rake
deleted file mode 100644 (file)
index 6067208..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-def diff_hash base, final
-  diffed = {}
-  base.each do |k,v|
-    bk = base[k]
-    fk = final[k]
-    if bk.is_a? Hash
-      d = diff_hash bk, fk
-      if d.length > 0
-        diffed[k] = d
-      end
-    else
-      if bk.to_yaml != fk.to_yaml
-        diffed[k] = fk
-      end
-    end
-  end
-  diffed
-end
-
-namespace :config do
-  desc 'Print items that differ between legacy application.yml and system config.yml'
-  task diff: :environment do
-    diffed = diff_hash $arvados_config_global, $arvados_config
-    cfg = { "Clusters" => {}}
-    cfg["Clusters"][$arvados_config["ClusterID"]] = diffed.select {|k,v| k != "ClusterID"}
-    if cfg["Clusters"][$arvados_config["ClusterID"]].empty?
-      puts "No migrations required for /etc/arvados/config.yml"
-    else
-      puts cfg.to_yaml
-    end
-  end
-
-  desc 'Print config.yml after merging with legacy application.yml'
-  task migrate: :environment do
-    diffed = diff_hash $arvados_config_defaults, $arvados_config
-    cfg = { "Clusters" => {}}
-    cfg["Clusters"][$arvados_config["ClusterID"]] = diffed.select {|k,v| k != "ClusterID"}
-    puts cfg.to_yaml
-  end
-
-  desc 'Print configuration as accessed through Rails.configuration'
-  task dump: :environment do
-    combined = $arvados_config.deep_dup
-    combined.update $remaining_config
-    puts combined.to_yaml
-  end
-
-  desc 'Legacy config check task -- it is a noop now'
-  task check: :environment do
-    # This exists so that build/rails-package-scripts/postinst.sh doesn't fail.
-  end
-end
diff --git a/apps/workbench/log/.gitkeep b/apps/workbench/log/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/npm_packages b/apps/workbench/npm_packages
deleted file mode 100644 (file)
index 05802b4..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Run "rake npm:install"
-
-# Browserify is required.
-npm 'browserify', require: false
-npm 'jquery'
-npm 'awesomplete'
-npm 'jssha', '2.4.2'
-
-npm 'mithril', '1.1.7'
-npm 'es6-object-assign'
diff --git a/apps/workbench/public/404.html b/apps/workbench/public/404.html
deleted file mode 100644 (file)
index 4454c39..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<!-- Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 -->
-
-<!DOCTYPE html>
-<html lang="en">
-<head>
-  <title>The page you were looking for doesn't exist (404)</title>
-  <style type="text/css">
-    body { background-color: #fff; color: #666; text-align: center; font-family: arial, sans-serif; }
-    div.dialog {
-      width: 25em;
-      padding: 0 4em;
-      margin: 4em auto 0 auto;
-      border: 1px solid #ccc;
-      border-right-color: #999;
-      border-bottom-color: #999;
-    }
-    h1 { font-size: 100%; color: #f00; line-height: 1.5em; }
-  </style>
-</head>
-
-<body>
-  <!-- This file lives in public/404.html -->
-  <div class="dialog">
-    <h1>The page you were looking for doesn't exist.</h1>
-    <p>You may have mistyped the address or the page may have moved.</p>
-  </div>
-</body>
-</html>
diff --git a/apps/workbench/public/422.html b/apps/workbench/public/422.html
deleted file mode 100644 (file)
index a9fa93a..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<!-- Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 -->
-
-<!DOCTYPE html>
-<html lang="en">
-<head>
-  <title>The change you wanted was rejected (422)</title>
-  <style type="text/css">
-    body { background-color: #fff; color: #666; text-align: center; font-family: arial, sans-serif; }
-    div.dialog {
-      width: 25em;
-      padding: 0 4em;
-      margin: 4em auto 0 auto;
-      border: 1px solid #ccc;
-      border-right-color: #999;
-      border-bottom-color: #999;
-    }
-    h1 { font-size: 100%; color: #f00; line-height: 1.5em; }
-  </style>
-</head>
-
-<body>
-  <!-- This file lives in public/422.html -->
-  <div class="dialog">
-    <h1>The change you wanted was rejected.</h1>
-    <p>Maybe you tried to change something you didn't have access to.</p>
-  </div>
-</body>
-</html>
diff --git a/apps/workbench/public/500.html b/apps/workbench/public/500.html
deleted file mode 100644 (file)
index 3c545fa..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<!-- Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 -->
-
-<!DOCTYPE html>
-<html lang="en">
-<head>
-  <title>We're sorry, but something went wrong (500)</title>
-  <style type="text/css">
-    body { background-color: #fff; color: #666; text-align: center; font-family: arial, sans-serif; }
-    div.dialog {
-      width: 25em;
-      padding: 0 4em;
-      margin: 4em auto 0 auto;
-      border: 1px solid #ccc;
-      border-right-color: #999;
-      border-bottom-color: #999;
-    }
-    h1 { font-size: 100%; color: #f00; line-height: 1.5em; }
-  </style>
-</head>
-
-<body>
-  <!-- This file lives in public/500.html -->
-  <div class="dialog">
-    <h1>We're sorry, but something went wrong.</h1>
-  </div>
-</body>
-</html>
diff --git a/apps/workbench/public/browser_unsupported.js b/apps/workbench/public/browser_unsupported.js
deleted file mode 100644 (file)
index a972b7f..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-(function() {
-    var ok = false;
-    try {
-        if (window.Blob &&
-            window.File &&
-            window.FileReader &&
-            window.localStorage &&
-            window.WebSocket) {
-            ok = true;
-        }
-    } catch(err) {}
-    if (!ok) {
-        document.getElementById('browser-unsupported').className='';
-    }
-})();
diff --git a/apps/workbench/public/d3.v3.min.js b/apps/workbench/public/d3.v3.min.js
deleted file mode 100644 (file)
index cba27c9..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-(function(){function t(t){return t.target}function n(t){return t.source}function e(t,n){try{for(var e in n)Object.defineProperty(t.prototype,e,{value:n[e],enumerable:!1})}catch(r){t.prototype=n}}function r(t){for(var n=-1,e=t.length,r=[];e>++n;)r.push(t[n]);return r}function i(t){return Array.prototype.slice.call(t)}function u(){}function a(t){return t}function o(){return!0}function c(t){return"function"==typeof t?t:function(){return t}}function l(t,n,e){return function(){var r=e.apply(n,arguments);return arguments.length?t:r}}function s(t){return null!=t&&!isNaN(t)}function f(t){return t.length}function h(t){return t.trim().replace(/\s+/g," ")}function d(t){for(var n=1;t*n%1;)n*=10;return n}function g(t){return 1===t.length?function(n,e){t(null==n?e:null)}:t}function p(t){return t.responseText}function m(t){return JSON.parse(t.responseText)}function v(t){var n=document.createRange();return n.selectNode(document.body),n.createContextualFragment(t.responseText)}function y(t){return t.responseXML}function M(){}function b(t){function n(){for(var n,r=e,i=-1,u=r.length;u>++i;)(n=r[i].on)&&n.apply(this,arguments);return t}var e=[],r=new u;return n.on=function(n,i){var u,a=r.get(n);return 2>arguments.length?a&&a.on:(a&&(a.on=null,e=e.slice(0,u=e.indexOf(a)).concat(e.slice(u+1)),r.remove(n)),i&&e.push(r.set(n,{on:i})),t)},n}function x(t,n){return n-(t?1+Math.floor(Math.log(t+Math.pow(10,1+Math.floor(Math.log(t)/Math.LN10)-n))/Math.LN10):1)}function _(t){return t+""}function w(t,n){var e=Math.pow(10,3*Math.abs(8-n));return{scale:n>8?function(t){return t/e}:function(t){return t*e},symbol:t}}function S(t){return function(n){return 0>=n?0:n>=1?1:t(n)}}function k(t){return function(n){return 1-t(1-n)}}function E(t){return function(n){return.5*(.5>n?t(2*n):2-t(2-2*n))}}function A(t){return t*t}function N(t){return t*t*t}function T(t){if(0>=t)return 0;if(t>=1)return 1;var n=t*t,e=n*t;return 4*(.5>t?e:3*(t-n)+e-.75)}function q(t){return function(n){return Math.pow(n,t)}}function C(t){return 1-Math.cos(t*Ru/2)}function z(t){return Math.pow(2,10*(t-1))}function D(t){return 1-Math.sqrt(1-t*t)}function L(t,n){var e;return 2>arguments.length&&(n=.45),arguments.length?e=n/(2*Ru)*Math.asin(1/t):(t=1,e=n/4),function(r){return 1+t*Math.pow(2,10*-r)*Math.sin(2*(r-e)*Ru/n)}}function F(t){return t||(t=1.70158),function(n){return n*n*((t+1)*n-t)}}function H(t){return 1/2.75>t?7.5625*t*t:2/2.75>t?7.5625*(t-=1.5/2.75)*t+.75:2.5/2.75>t?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375}function R(){d3.event.stopPropagation(),d3.event.preventDefault()}function P(){for(var t,n=d3.event;t=n.sourceEvent;)n=t;return n}function j(t){for(var n=new M,e=0,r=arguments.length;r>++e;)n[arguments[e]]=b(n);return n.of=function(e,r){return function(i){try{var u=i.sourceEvent=d3.event;i.target=t,d3.event=i,n[i.type].apply(e,r)}finally{d3.event=u}}},n}function O(t){var n=[t.a,t.b],e=[t.c,t.d],r=U(n),i=Y(n,e),u=U(I(e,n,-i))||0;n[0]*e[1]<e[0]*n[1]&&(n[0]*=-1,n[1]*=-1,r*=-1,i*=-1),this.rotate=(r?Math.atan2(n[1],n[0]):Math.atan2(-e[0],e[1]))*Ou,this.translate=[t.e,t.f],this.scale=[r,u],this.skew=u?Math.atan2(i,u)*Ou:0}function Y(t,n){return t[0]*n[0]+t[1]*n[1]}function U(t){var n=Math.sqrt(Y(t,t));return n&&(t[0]/=n,t[1]/=n),n}function I(t,n,e){return t[0]+=e*n[0],t[1]+=e*n[1],t}function V(t){return"transform"==t?d3.interpolateTransform:d3.interpolate}function X(t,n){return n=n-(t=+t)?1/(n-t):0,function(e){return(e-t)*n}}function Z(t,n){return n=n-(t=+t)?1/(n-t):0,function(e){return Math.max(0,Math.min(1,(e-t)*n))}}function B(){}function $(t,n,e){return new J(t,n,e)}function J(t,n,e){this.r=t,this.g=n,this.b=e}function G(t){return 16>t?"0"+Math.max(0,t).toString(16):Math.min(255,t).toString(16)}function K(t,n,e){var r,i,u,a=0,o=0,c=0;if(r=/([a-z]+)\((.*)\)/i.exec(t))switch(i=r[2].split(","),r[1]){case"hsl":return e(parseFloat(i[0]),parseFloat(i[1])/100,parseFloat(i[2])/100);case"rgb":return n(nn(i[0]),nn(i[1]),nn(i[2]))}return(u=aa.get(t))?n(u.r,u.g,u.b):(null!=t&&"#"===t.charAt(0)&&(4===t.length?(a=t.charAt(1),a+=a,o=t.charAt(2),o+=o,c=t.charAt(3),c+=c):7===t.length&&(a=t.substring(1,3),o=t.substring(3,5),c=t.substring(5,7)),a=parseInt(a,16),o=parseInt(o,16),c=parseInt(c,16)),n(a,o,c))}function W(t,n,e){var r,i,u=Math.min(t/=255,n/=255,e/=255),a=Math.max(t,n,e),o=a-u,c=(a+u)/2;return o?(i=.5>c?o/(a+u):o/(2-a-u),r=t==a?(n-e)/o+(e>n?6:0):n==a?(e-t)/o+2:(t-n)/o+4,r*=60):i=r=0,en(r,i,c)}function Q(t,n,e){t=tn(t),n=tn(n),e=tn(e);var r=gn((.4124564*t+.3575761*n+.1804375*e)/sa),i=gn((.2126729*t+.7151522*n+.072175*e)/fa),u=gn((.0193339*t+.119192*n+.9503041*e)/ha);return ln(116*i-16,500*(r-i),200*(i-u))}function tn(t){return.04045>=(t/=255)?t/12.92:Math.pow((t+.055)/1.055,2.4)}function nn(t){var n=parseFloat(t);return"%"===t.charAt(t.length-1)?Math.round(2.55*n):n}function en(t,n,e){return new rn(t,n,e)}function rn(t,n,e){this.h=t,this.s=n,this.l=e}function un(t,n,e){function r(t){return t>360?t-=360:0>t&&(t+=360),60>t?u+(a-u)*t/60:180>t?a:240>t?u+(a-u)*(240-t)/60:u}function i(t){return Math.round(255*r(t))}var u,a;return t%=360,0>t&&(t+=360),n=0>n?0:n>1?1:n,e=0>e?0:e>1?1:e,a=.5>=e?e*(1+n):e+n-e*n,u=2*e-a,$(i(t+120),i(t),i(t-120))}function an(t,n,e){return new on(t,n,e)}function on(t,n,e){this.h=t,this.c=n,this.l=e}function cn(t,n,e){return ln(e,Math.cos(t*=ju)*n,Math.sin(t)*n)}function ln(t,n,e){return new sn(t,n,e)}function sn(t,n,e){this.l=t,this.a=n,this.b=e}function fn(t,n,e){var r=(t+16)/116,i=r+n/500,u=r-e/200;return i=dn(i)*sa,r=dn(r)*fa,u=dn(u)*ha,$(pn(3.2404542*i-1.5371385*r-.4985314*u),pn(-.969266*i+1.8760108*r+.041556*u),pn(.0556434*i-.2040259*r+1.0572252*u))}function hn(t,n,e){return an(180*(Math.atan2(e,n)/Ru),Math.sqrt(n*n+e*e),t)}function dn(t){return t>.206893034?t*t*t:(t-4/29)/7.787037}function gn(t){return t>.008856?Math.pow(t,1/3):7.787037*t+4/29}function pn(t){return Math.round(255*(.00304>=t?12.92*t:1.055*Math.pow(t,1/2.4)-.055))}function mn(t){return Iu(t,Ma),t}function vn(t){return function(){return ga(t,this)}}function yn(t){return function(){return pa(t,this)}}function Mn(t,n){function e(){this.removeAttribute(t)}function r(){this.removeAttributeNS(t.space,t.local)}function i(){this.setAttribute(t,n)}function u(){this.setAttributeNS(t.space,t.local,n)}function a(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}function o(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}return t=d3.ns.qualify(t),null==n?t.local?r:e:"function"==typeof n?t.local?o:a:t.local?u:i}function bn(t){return RegExp("(?:^|\\s+)"+d3.requote(t)+"(?:\\s+|$)","g")}function xn(t,n){function e(){for(var e=-1;i>++e;)t[e](this,n)}function r(){for(var e=-1,r=n.apply(this,arguments);i>++e;)t[e](this,r)}t=t.trim().split(/\s+/).map(_n);var i=t.length;return"function"==typeof n?r:e}function _n(t){var n=bn(t);return function(e,r){if(i=e.classList)return r?i.add(t):i.remove(t);var i=e.className,u=null!=i.baseVal,a=u?i.baseVal:i;r?(n.lastIndex=0,n.test(a)||(a=h(a+" "+t),u?i.baseVal=a:e.className=a)):a&&(a=h(a.replace(n," ")),u?i.baseVal=a:e.className=a)}}function wn(t,n,e){function r(){this.style.removeProperty(t)}function i(){this.style.setProperty(t,n,e)}function u(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}return null==n?r:"function"==typeof n?u:i}function Sn(t,n){function e(){delete this[t]}function r(){this[t]=n}function i(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}return null==n?e:"function"==typeof n?i:r}function kn(t){return{__data__:t}}function En(t){return function(){return ya(this,t)}}function An(t){return arguments.length||(t=d3.ascending),function(n,e){return t(n&&n.__data__,e&&e.__data__)}}function Nn(t,n,e){function r(){var n=this[u];n&&(this.removeEventListener(t,n,n.$),delete this[u])}function i(){function i(t){var e=d3.event;d3.event=t,o[0]=a.__data__;try{n.apply(a,o)}finally{d3.event=e}}var a=this,o=Yu(arguments);r.call(this),this.addEventListener(t,this[u]=i,i.$=e),i._=n}var u="__on"+t,a=t.indexOf(".");return a>0&&(t=t.substring(0,a)),n?i:r}function Tn(t,n){for(var e=0,r=t.length;r>e;e++)for(var i,u=t[e],a=0,o=u.length;o>a;a++)(i=u[a])&&n(i,a,e);return t}function qn(t){return Iu(t,xa),t}function Cn(t,n){return Iu(t,wa),t.id=n,t}function zn(t,n,e,r){var i=t.__transition__||(t.__transition__={active:0,count:0}),a=i[e];if(!a){var o=r.time;return a=i[e]={tween:new u,event:d3.dispatch("start","end"),time:o,ease:r.ease,delay:r.delay,duration:r.duration},++i.count,d3.timer(function(r){function u(r){return i.active>e?l():(i.active=e,h.start.call(t,s,n),a.tween.forEach(function(e,r){(r=r.call(t,s,n))&&p.push(r)}),c(r)||d3.timer(c,0,o),1)}function c(r){if(i.active!==e)return l();for(var u=(r-d)/g,a=f(u),o=p.length;o>0;)p[--o].call(t,a);return u>=1?(l(),h.end.call(t,s,n),1):void 0}function l(){return--i.count?delete i[e]:delete t.__transition__,1}var s=t.__data__,f=a.ease,h=a.event,d=a.delay,g=a.duration,p=[];return r>=d?u(r):d3.timer(u,d,o),1},0,o),a}}function Dn(t){return null==t&&(t=""),function(){this.textContent=t}}function Ln(t,n,e,r){var i=t.id;return Tn(t,"function"==typeof e?function(t,u,a){t.__transition__[i].tween.set(n,r(e.call(t,t.__data__,u,a)))}:(e=r(e),function(t){t.__transition__[i].tween.set(n,e)}))}function Fn(){for(var t,n=Date.now(),e=qa;e;)t=n-e.then,t>=e.delay&&(e.flush=e.callback(t)),e=e.next;var r=Hn()-n;r>24?(isFinite(r)&&(clearTimeout(Aa),Aa=setTimeout(Fn,r)),Ea=0):(Ea=1,Ca(Fn))}function Hn(){for(var t=null,n=qa,e=1/0;n;)n.flush?(delete Ta[n.callback.id],n=t?t.next=n.next:qa=n.next):(e=Math.min(e,n.then+n.delay),n=(t=n).next);return e}function Rn(t,n){var e=t.ownerSVGElement||t;if(e.createSVGPoint){var r=e.createSVGPoint();if(0>za&&(window.scrollX||window.scrollY)){e=d3.select(document.body).append("svg").style("position","absolute").style("top",0).style("left",0);var i=e[0][0].getScreenCTM();za=!(i.f||i.e),e.remove()}return za?(r.x=n.pageX,r.y=n.pageY):(r.x=n.clientX,r.y=n.clientY),r=r.matrixTransform(t.getScreenCTM().inverse()),[r.x,r.y]}var u=t.getBoundingClientRect();return[n.clientX-u.left-t.clientLeft,n.clientY-u.top-t.clientTop]}function Pn(){}function jn(t){var n=t[0],e=t[t.length-1];return e>n?[n,e]:[e,n]}function On(t){return t.rangeExtent?t.rangeExtent():jn(t.range())}function Yn(t,n){var e,r=0,i=t.length-1,u=t[r],a=t[i];return u>a&&(e=r,r=i,i=e,e=u,u=a,a=e),(n=n(a-u))&&(t[r]=n.floor(u),t[i]=n.ceil(a)),t}function Un(){return Math}function In(t,n,e,r){function i(){var i=Math.min(t.length,n.length)>2?Gn:Jn,c=r?Z:X;return a=i(t,n,c,e),o=i(n,t,c,d3.interpolate),u}function u(t){return a(t)}var a,o;return u.invert=function(t){return o(t)},u.domain=function(n){return arguments.length?(t=n.map(Number),i()):t},u.range=function(t){return arguments.length?(n=t,i()):n},u.rangeRound=function(t){return u.range(t).interpolate(d3.interpolateRound)},u.clamp=function(t){return arguments.length?(r=t,i()):r},u.interpolate=function(t){return arguments.length?(e=t,i()):e},u.ticks=function(n){return Bn(t,n)},u.tickFormat=function(n){return $n(t,n)},u.nice=function(){return Yn(t,Xn),i()},u.copy=function(){return In(t,n,e,r)},i()}function Vn(t,n){return d3.rebind(t,n,"range","rangeRound","interpolate","clamp")}function Xn(t){return t=Math.pow(10,Math.round(Math.log(t)/Math.LN10)-1),t&&{floor:function(n){return Math.floor(n/t)*t},ceil:function(n){return Math.ceil(n/t)*t}}}function Zn(t,n){var e=jn(t),r=e[1]-e[0],i=Math.pow(10,Math.floor(Math.log(r/n)/Math.LN10)),u=n/r*i;return.15>=u?i*=10:.35>=u?i*=5:.75>=u&&(i*=2),e[0]=Math.ceil(e[0]/i)*i,e[1]=Math.floor(e[1]/i)*i+.5*i,e[2]=i,e}function Bn(t,n){return d3.range.apply(d3,Zn(t,n))}function $n(t,n){return d3.format(",."+Math.max(0,-Math.floor(Math.log(Zn(t,n)[2])/Math.LN10+.01))+"f")}function Jn(t,n,e,r){var i=e(t[0],t[1]),u=r(n[0],n[1]);return function(t){return u(i(t))}}function Gn(t,n,e,r){var i=[],u=[],a=0,o=Math.min(t.length,n.length)-1;for(t[o]<t[0]&&(t=t.slice().reverse(),n=n.slice().reverse());o>=++a;)i.push(e(t[a-1],t[a])),u.push(r(n[a-1],n[a]));return function(n){var e=d3.bisect(t,n,1,o)-1;return u[e](i[e](n))}}function Kn(t,n){function e(e){return t(n(e))}var r=n.pow;return e.invert=function(n){return r(t.invert(n))},e.domain=function(i){return arguments.length?(n=0>i[0]?Qn:Wn,r=n.pow,t.domain(i.map(n)),e):t.domain().map(r)},e.nice=function(){return t.domain(Yn(t.domain(),Un)),e},e.ticks=function(){var e=jn(t.domain()),i=[];if(e.every(isFinite)){var u=Math.floor(e[0]),a=Math.ceil(e[1]),o=r(e[0]),c=r(e[1]);if(n===Qn)for(i.push(r(u));a>u++;)for(var l=9;l>0;l--)i.push(r(u)*l);else{for(;a>u;u++)for(var l=1;10>l;l++)i.push(r(u)*l);i.push(r(u))}for(u=0;o>i[u];u++);for(a=i.length;i[a-1]>c;a--);i=i.slice(u,a)}return i},e.tickFormat=function(t,i){if(2>arguments.length&&(i=Da),!arguments.length)return i;var u,a=Math.max(.1,t/e.ticks().length),o=n===Qn?(u=-1e-12,Math.floor):(u=1e-12,Math.ceil);return function(t){return a>=t/r(o(n(t)+u))?i(t):""}},e.copy=function(){return Kn(t.copy(),n)},Vn(e,t)}function Wn(t){return Math.log(0>t?0:t)/Math.LN10}function Qn(t){return-Math.log(t>0?0:-t)/Math.LN10}function te(t,n){function e(n){return t(r(n))}var r=ne(n),i=ne(1/n);return e.invert=function(n){return i(t.invert(n))},e.domain=function(n){return arguments.length?(t.domain(n.map(r)),e):t.domain().map(i)},e.ticks=function(t){return Bn(e.domain(),t)},e.tickFormat=function(t){return $n(e.domain(),t)},e.nice=function(){return e.domain(Yn(e.domain(),Xn))},e.exponent=function(t){if(!arguments.length)return n;var u=e.domain();return r=ne(n=t),i=ne(1/n),e.domain(u)},e.copy=function(){return te(t.copy(),n)},Vn(e,t)}function ne(t){return function(n){return 0>n?-Math.pow(-n,t):Math.pow(n,t)}}function ee(t,n){function e(n){return a[((i.get(n)||i.set(n,t.push(n)))-1)%a.length]}function r(n,e){return d3.range(t.length).map(function(t){return n+e*t})}var i,a,o;return e.domain=function(r){if(!arguments.length)return t;t=[],i=new u;for(var a,o=-1,c=r.length;c>++o;)i.has(a=r[o])||i.set(a,t.push(a));return e[n.t].apply(e,n.a)},e.range=function(t){return arguments.length?(a=t,o=0,n={t:"range",a:arguments},e):a},e.rangePoints=function(i,u){2>arguments.length&&(u=0);var c=i[0],l=i[1],s=(l-c)/(Math.max(1,t.length-1)+u);return a=r(2>t.length?(c+l)/2:c+s*u/2,s),o=0,n={t:"rangePoints",a:arguments},e},e.rangeBands=function(i,u,c){2>arguments.length&&(u=0),3>arguments.length&&(c=u);var l=i[1]<i[0],s=i[l-0],f=i[1-l],h=(f-s)/(t.length-u+2*c);return a=r(s+h*c,h),l&&a.reverse(),o=h*(1-u),n={t:"rangeBands",a:arguments},e},e.rangeRoundBands=function(i,u,c){2>arguments.length&&(u=0),3>arguments.length&&(c=u);var l=i[1]<i[0],s=i[l-0],f=i[1-l],h=Math.floor((f-s)/(t.length-u+2*c)),d=f-s-(t.length-u)*h;return a=r(s+Math.round(d/2),h),l&&a.reverse(),o=Math.round(h*(1-u)),n={t:"rangeRoundBands",a:arguments},e},e.rangeBand=function(){return o},e.rangeExtent=function(){return jn(n.a[0])},e.copy=function(){return ee(t,n)},e.domain(t)}function re(t,n){function e(){var e=0,u=n.length;for(i=[];u>++e;)i[e-1]=d3.quantile(t,e/u);return r}function r(t){return isNaN(t=+t)?0/0:n[d3.bisect(i,t)]}var i;return r.domain=function(n){return arguments.length?(t=n.filter(function(t){return!isNaN(t)}).sort(d3.ascending),e()):t},r.range=function(t){return arguments.length?(n=t,e()):n},r.quantiles=function(){return i},r.copy=function(){return re(t,n)},e()}function ie(t,n,e){function r(n){return e[Math.max(0,Math.min(a,Math.floor(u*(n-t))))]}function i(){return u=e.length/(n-t),a=e.length-1,r}var u,a;return r.domain=function(e){return arguments.length?(t=+e[0],n=+e[e.length-1],i()):[t,n]},r.range=function(t){return arguments.length?(e=t,i()):e},r.copy=function(){return ie(t,n,e)},i()}function ue(t,n){function e(e){return n[d3.bisect(t,e)]}return e.domain=function(n){return arguments.length?(t=n,e):t},e.range=function(t){return arguments.length?(n=t,e):n},e.copy=function(){return ue(t,n)},e}function ae(t){function n(t){return+t}return n.invert=n,n.domain=n.range=function(e){return arguments.length?(t=e.map(n),n):t},n.ticks=function(n){return Bn(t,n)},n.tickFormat=function(n){return $n(t,n)},n.copy=function(){return ae(t)},n}function oe(t){return t.innerRadius}function ce(t){return t.outerRadius}function le(t){return t.startAngle}function se(t){return t.endAngle}function fe(t){function n(n){function a(){s.push("M",u(t(f),l))}for(var o,s=[],f=[],h=-1,d=n.length,g=c(e),p=c(r);d>++h;)i.call(this,o=n[h],h)?f.push([+g.call(this,o,h),+p.call(this,o,h)]):f.length&&(a(),f=[]);return f.length&&a(),s.length?s.join(""):null}var e=he,r=de,i=o,u=ge,a=u.key,l=.7;return n.x=function(t){return arguments.length?(e=t,n):e},n.y=function(t){return arguments.length?(r=t,n):r},n.defined=function(t){return arguments.length?(i=t,n):i},n.interpolate=function(t){return arguments.length?(a="function"==typeof t?u=t:(u=Oa.get(t)||ge).key,n):a},n.tension=function(t){return arguments.length?(l=t,n):l},n}function he(t){return t[0]}function de(t){return t[1]}function ge(t){return t.join("L")}function pe(t){return ge(t)+"Z"}function me(t){for(var n=0,e=t.length,r=t[0],i=[r[0],",",r[1]];e>++n;)i.push("V",(r=t[n])[1],"H",r[0]);return i.join("")}function ve(t){for(var n=0,e=t.length,r=t[0],i=[r[0],",",r[1]];e>++n;)i.push("H",(r=t[n])[0],"V",r[1]);return i.join("")}function ye(t,n){return 4>t.length?ge(t):t[1]+xe(t.slice(1,t.length-1),_e(t,n))}function Me(t,n){return 3>t.length?ge(t):t[0]+xe((t.push(t[0]),t),_e([t[t.length-2]].concat(t,[t[1]]),n))}function be(t,n){return 3>t.length?ge(t):t[0]+xe(t,_e(t,n))}function xe(t,n){if(1>n.length||t.length!=n.length&&t.length!=n.length+2)return ge(t);var e=t.length!=n.length,r="",i=t[0],u=t[1],a=n[0],o=a,c=1;if(e&&(r+="Q"+(u[0]-2*a[0]/3)+","+(u[1]-2*a[1]/3)+","+u[0]+","+u[1],i=t[1],c=2),n.length>1){o=n[1],u=t[c],c++,r+="C"+(i[0]+a[0])+","+(i[1]+a[1])+","+(u[0]-o[0])+","+(u[1]-o[1])+","+u[0]+","+u[1];for(var l=2;n.length>l;l++,c++)u=t[c],o=n[l],r+="S"+(u[0]-o[0])+","+(u[1]-o[1])+","+u[0]+","+u[1]}if(e){var s=t[c];r+="Q"+(u[0]+2*o[0]/3)+","+(u[1]+2*o[1]/3)+","+s[0]+","+s[1]}return r}function _e(t,n){for(var e,r=[],i=(1-n)/2,u=t[0],a=t[1],o=1,c=t.length;c>++o;)e=u,u=a,a=t[o],r.push([i*(a[0]-e[0]),i*(a[1]-e[1])]);return r}function we(t){if(3>t.length)return ge(t);var n=1,e=t.length,r=t[0],i=r[0],u=r[1],a=[i,i,i,(r=t[1])[0]],o=[u,u,u,r[1]],c=[i,",",u];for(Ne(c,a,o);e>++n;)r=t[n],a.shift(),a.push(r[0]),o.shift(),o.push(r[1]),Ne(c,a,o);for(n=-1;2>++n;)a.shift(),a.push(r[0]),o.shift(),o.push(r[1]),Ne(c,a,o);return c.join("")}function Se(t){if(4>t.length)return ge(t);for(var n,e=[],r=-1,i=t.length,u=[0],a=[0];3>++r;)n=t[r],u.push(n[0]),a.push(n[1]);for(e.push(Ae(Ia,u)+","+Ae(Ia,a)),--r;i>++r;)n=t[r],u.shift(),u.push(n[0]),a.shift(),a.push(n[1]),Ne(e,u,a);return e.join("")}function ke(t){for(var n,e,r=-1,i=t.length,u=i+4,a=[],o=[];4>++r;)e=t[r%i],a.push(e[0]),o.push(e[1]);for(n=[Ae(Ia,a),",",Ae(Ia,o)],--r;u>++r;)e=t[r%i],a.shift(),a.push(e[0]),o.shift(),o.push(e[1]),Ne(n,a,o);return n.join("")}function Ee(t,n){var e=t.length-1;if(e)for(var r,i,u=t[0][0],a=t[0][1],o=t[e][0]-u,c=t[e][1]-a,l=-1;e>=++l;)r=t[l],i=l/e,r[0]=n*r[0]+(1-n)*(u+i*o),r[1]=n*r[1]+(1-n)*(a+i*c);return we(t)}function Ae(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]+t[3]*n[3]}function Ne(t,n,e){t.push("C",Ae(Ya,n),",",Ae(Ya,e),",",Ae(Ua,n),",",Ae(Ua,e),",",Ae(Ia,n),",",Ae(Ia,e))}function Te(t,n){return(n[1]-t[1])/(n[0]-t[0])}function qe(t){for(var n=0,e=t.length-1,r=[],i=t[0],u=t[1],a=r[0]=Te(i,u);e>++n;)r[n]=(a+(a=Te(i=u,u=t[n+1])))/2;return r[n]=a,r}function Ce(t){for(var n,e,r,i,u=[],a=qe(t),o=-1,c=t.length-1;c>++o;)n=Te(t[o],t[o+1]),1e-6>Math.abs(n)?a[o]=a[o+1]=0:(e=a[o]/n,r=a[o+1]/n,i=e*e+r*r,i>9&&(i=3*n/Math.sqrt(i),a[o]=i*e,a[o+1]=i*r));for(o=-1;c>=++o;)i=(t[Math.min(c,o+1)][0]-t[Math.max(0,o-1)][0])/(6*(1+a[o]*a[o])),u.push([i||0,a[o]*i||0]);return u}function ze(t){return 3>t.length?ge(t):t[0]+xe(t,Ce(t))}function De(t){for(var n,e,r,i=-1,u=t.length;u>++i;)n=t[i],e=n[0],r=n[1]+Pa,n[0]=e*Math.cos(r),n[1]=e*Math.sin(r);return t}function Le(t){function n(n){function o(){m.push("M",l(t(y),d),h,f(t(v.reverse()),d),"Z")}for(var s,g,p,m=[],v=[],y=[],M=-1,b=n.length,x=c(e),_=c(i),w=e===r?function(){return g}:c(r),S=i===u?function(){return p}:c(u);b>++M;)a.call(this,s=n[M],M)?(v.push([g=+x.call(this,s,M),p=+_.call(this,s,M)]),y.push([+w.call(this,s,M),+S.call(this,s,M)])):v.length&&(o(),v=[],y=[]);return v.length&&o(),m.length?m.join(""):null}var e=he,r=he,i=0,u=de,a=o,l=ge,s=l.key,f=l,h="L",d=.7;return n.x=function(t){return arguments.length?(e=r=t,n):r},n.x0=function(t){return arguments.length?(e=t,n):e},n.x1=function(t){return arguments.length?(r=t,n):r},n.y=function(t){return arguments.length?(i=u=t,n):u},n.y0=function(t){return arguments.length?(i=t,n):i},n.y1=function(t){return arguments.length?(u=t,n):u},n.defined=function(t){return arguments.length?(a=t,n):a},n.interpolate=function(t){return arguments.length?(s="function"==typeof t?l=t:(l=Oa.get(t)||ge).key,f=l.reverse||l,h=l.closed?"M":"L",n):s},n.tension=function(t){return arguments.length?(d=t,n):d},n}function Fe(t){return t.radius}function He(t){return[t.x,t.y]}function Re(t){return function(){var n=t.apply(this,arguments),e=n[0],r=n[1]+Pa;return[e*Math.cos(r),e*Math.sin(r)]}}function Pe(){return 64}function je(){return"circle"}function Oe(t){var n=Math.sqrt(t/Ru);return"M0,"+n+"A"+n+","+n+" 0 1,1 0,"+-n+"A"+n+","+n+" 0 1,1 0,"+n+"Z"}function Ye(t,n){t.attr("transform",function(t){return"translate("+n(t)+",0)"})}function Ue(t,n){t.attr("transform",function(t){return"translate(0,"+n(t)+")"})}function Ie(t,n,e){if(r=[],e&&n.length>1){for(var r,i,u,a=jn(t.domain()),o=-1,c=n.length,l=(n[1]-n[0])/++e;c>++o;)for(i=e;--i>0;)(u=+n[o]-i*l)>=a[0]&&r.push(u);for(--o,i=0;e>++i&&(u=+n[o]+i*l)<a[1];)r.push(u)}return r}function Ve(){Ja||(Ja=d3.select("body").append("div").style("visibility","hidden").style("top",0).style("height",0).style("width",0).style("overflow-y","scroll").append("div").style("height","2000px").node().parentNode);var t,n=d3.event;try{Ja.scrollTop=1e3,Ja.dispatchEvent(n),t=1e3-Ja.scrollTop}catch(e){t=n.wheelDelta||5*-n.detail}return t}function Xe(t){for(var n=t.source,e=t.target,r=Be(n,e),i=[n];n!==r;)n=n.parent,i.push(n);for(var u=i.length;e!==r;)i.splice(u,0,e),e=e.parent;return i}function Ze(t){for(var n=[],e=t.parent;null!=e;)n.push(t),t=e,e=e.parent;return n.push(t),n}function Be(t,n){if(t===n)return t;for(var e=Ze(t),r=Ze(n),i=e.pop(),u=r.pop(),a=null;i===u;)a=i,i=e.pop(),u=r.pop();return a}function $e(t){t.fixed|=2}function Je(t){t.fixed&=1}function Ge(t){t.fixed|=4,t.px=t.x,t.py=t.y}function Ke(t){t.fixed&=3}function We(t,n,e){var r=0,i=0;if(t.charge=0,!t.leaf)for(var u,a=t.nodes,o=a.length,c=-1;o>++c;)u=a[c],null!=u&&(We(u,n,e),t.charge+=u.charge,r+=u.charge*u.cx,i+=u.charge*u.cy);if(t.point){t.leaf||(t.point.x+=Math.random()-.5,t.point.y+=Math.random()-.5);var l=n*e[t.point.index];t.charge+=t.pointCharge=l,r+=l*t.point.x,i+=l*t.point.y}t.cx=r/t.charge,t.cy=i/t.charge}function Qe(){return 20}function tr(){return 1}function nr(t){return t.x}function er(t){return t.y}function rr(t,n,e){t.y0=n,t.y=e}function ir(t){return d3.range(t.length)}function ur(t){for(var n=-1,e=t[0].length,r=[];e>++n;)r[n]=0;return r}function ar(t){for(var n,e=1,r=0,i=t[0][1],u=t.length;u>e;++e)(n=t[e][1])>i&&(r=e,i=n);return r}function or(t){return t.reduce(cr,0)}function cr(t,n){return t+n[1]}function lr(t,n){return sr(t,Math.ceil(Math.log(n.length)/Math.LN2+1))}function sr(t,n){for(var e=-1,r=+t[0],i=(t[1]-r)/n,u=[];n>=++e;)u[e]=i*e+r;return u}function fr(t){return[d3.min(t),d3.max(t)]}function hr(t,n){return d3.rebind(t,n,"sort","children","value"),t.nodes=t,t.links=mr,t}function dr(t){return t.children}function gr(t){return t.value}function pr(t,n){return n.value-t.value}function mr(t){return d3.merge(t.map(function(t){return(t.children||[]).map(function(n){return{source:t,target:n}})}))}function vr(t,n){return t.value-n.value}function yr(t,n){var e=t._pack_next;t._pack_next=n,n._pack_prev=t,n._pack_next=e,e._pack_prev=n}function Mr(t,n){t._pack_next=n,n._pack_prev=t}function br(t,n){var e=n.x-t.x,r=n.y-t.y,i=t.r+n.r;return i*i-e*e-r*r>.001}function xr(t){function n(t){s=Math.min(t.x-t.r,s),f=Math.max(t.x+t.r,f),h=Math.min(t.y-t.r,h),d=Math.max(t.y+t.r,d)}if((e=t.children)&&(l=e.length)){var e,r,i,u,a,o,c,l,s=1/0,f=-1/0,h=1/0,d=-1/0;if(e.forEach(_r),r=e[0],r.x=-r.r,r.y=0,n(r),l>1&&(i=e[1],i.x=i.r,i.y=0,n(i),l>2))for(u=e[2],kr(r,i,u),n(u),yr(r,u),r._pack_prev=u,yr(u,i),i=r._pack_next,a=3;l>a;a++){kr(r,i,u=e[a]);var g=0,p=1,m=1;for(o=i._pack_next;o!==i;o=o._pack_next,p++)if(br(o,u)){g=1;break}if(1==g)for(c=r._pack_prev;c!==o._pack_prev&&!br(c,u);c=c._pack_prev,m++);g?(m>p||p==m&&i.r<r.r?Mr(r,i=o):Mr(r=c,i),a--):(yr(r,u),i=u,n(u))}var v=(s+f)/2,y=(h+d)/2,M=0;for(a=0;l>a;a++)u=e[a],u.x-=v,u.y-=y,M=Math.max(M,u.r+Math.sqrt(u.x*u.x+u.y*u.y));t.r=M,e.forEach(wr)}}function _r(t){t._pack_next=t._pack_prev=t}function wr(t){delete t._pack_next,delete t._pack_prev}function Sr(t,n,e,r){var i=t.children;if(t.x=n+=r*t.x,t.y=e+=r*t.y,t.r*=r,i)for(var u=-1,a=i.length;a>++u;)Sr(i[u],n,e,r)}function kr(t,n,e){var r=t.r+e.r,i=n.x-t.x,u=n.y-t.y;if(r&&(i||u)){var a=n.r+e.r,o=i*i+u*u;a*=a,r*=r;var c=.5+(r-a)/(2*o),l=Math.sqrt(Math.max(0,2*a*(r+o)-(r-=o)*r-a*a))/(2*o);e.x=t.x+c*i+l*u,e.y=t.y+c*u-l*i}else e.x=t.x+r,e.y=t.y}function Er(t){return 1+d3.max(t,function(t){return t.y})}function Ar(t){return t.reduce(function(t,n){return t+n.x},0)/t.length}function Nr(t){var n=t.children;return n&&n.length?Nr(n[0]):t}function Tr(t){var n,e=t.children;return e&&(n=e.length)?Tr(e[n-1]):t}function qr(t,n){return t.parent==n.parent?1:2}function Cr(t){var n=t.children;return n&&n.length?n[0]:t._tree.thread}function zr(t){var n,e=t.children;return e&&(n=e.length)?e[n-1]:t._tree.thread}function Dr(t,n){var e=t.children;if(e&&(i=e.length))for(var r,i,u=-1;i>++u;)n(r=Dr(e[u],n),t)>0&&(t=r);return t}function Lr(t,n){return t.x-n.x}function Fr(t,n){return n.x-t.x}function Hr(t,n){return t.depth-n.depth}function Rr(t,n){function e(t,r){var i=t.children;if(i&&(a=i.length))for(var u,a,o=null,c=-1;a>++c;)u=i[c],e(u,o),o=u;n(t,r)}e(t,null)}function Pr(t){for(var n,e=0,r=0,i=t.children,u=i.length;--u>=0;)n=i[u]._tree,n.prelim+=e,n.mod+=e,e+=n.shift+(r+=n.change)}function jr(t,n,e){t=t._tree,n=n._tree;var r=e/(n.number-t.number);t.change+=r,n.change-=r,n.shift+=e,n.prelim+=e,n.mod+=e}function Or(t,n,e){return t._tree.ancestor.parent==n.parent?t._tree.ancestor:e}function Yr(t){return{x:t.x,y:t.y,dx:t.dx,dy:t.dy}}function Ur(t,n){var e=t.x+n[3],r=t.y+n[0],i=t.dx-n[1]-n[3],u=t.dy-n[0]-n[2];return 0>i&&(e+=i/2,i=0),0>u&&(r+=u/2,u=0),{x:e,y:r,dx:i,dy:u}}function Ir(t,n){function e(t,e){return d3.xhr(t,n,e).response(r)}function r(t){return e.parse(t.responseText)}function i(n){return n.map(u).join(t)}function u(t){return a.test(t)?'"'+t.replace(/\"/g,'""')+'"':t}var a=RegExp('["'+t+"\n]"),o=t.charCodeAt(0);return e.parse=function(t){var n;return e.parseRows(t,function(t){return n?n(t):(n=Function("d","return {"+t.map(function(t,n){return JSON.stringify(t)+": d["+n+"]"}).join(",")+"}"),void 0)})},e.parseRows=function(t,n){function e(){if(s>=l)return a;if(i)return i=!1,u;var n=s;if(34===t.charCodeAt(n)){for(var e=n;l>e++;)if(34===t.charCodeAt(e)){if(34!==t.charCodeAt(e+1))break;++e}s=e+2;var r=t.charCodeAt(e+1);return 13===r?(i=!0,10===t.charCodeAt(e+2)&&++s):10===r&&(i=!0),t.substring(n+1,e).replace(/""/g,'"')}for(;l>s;){var r=t.charCodeAt(s++),c=1;if(10===r)i=!0;else if(13===r)i=!0,10===t.charCodeAt(s)&&(++s,++c);else if(r!==o)continue;return t.substring(n,s-c)}return t.substring(n)}for(var r,i,u={},a={},c=[],l=t.length,s=0,f=0;(r=e())!==a;){for(var h=[];r!==u&&r!==a;)h.push(r),r=e();(!n||(h=n(h,f++)))&&c.push(h)}return c},e.format=function(t){return t.map(i).join("\n")},e}function Vr(t,n){no.hasOwnProperty(t.type)&&no[t.type](t,n)}function Xr(t,n,e){var r,i=-1,u=t.length-e;for(n.lineStart();u>++i;)r=t[i],n.point(r[0],r[1]);n.lineEnd()}function Zr(t,n){var e=-1,r=t.length;for(n.polygonStart();r>++e;)Xr(t[e],n,1);n.polygonEnd()}function Br(t){return[Math.atan2(t[1],t[0]),Math.asin(Math.max(-1,Math.min(1,t[2])))]}function $r(t,n){return Pu>Math.abs(t[0]-n[0])&&Pu>Math.abs(t[1]-n[1])}function Jr(t){var n=t[0],e=t[1],r=Math.cos(e);return[r*Math.cos(n),r*Math.sin(n),Math.sin(e)]}function Gr(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function Kr(t,n){return[t[1]*n[2]-t[2]*n[1],t[2]*n[0]-t[0]*n[2],t[0]*n[1]-t[1]*n[0]]}function Wr(t,n){t[0]+=n[0],t[1]+=n[1],t[2]+=n[2]}function Qr(t,n){return[t[0]*n,t[1]*n,t[2]*n]}function ti(t){var n=Math.sqrt(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=n,t[1]/=n,t[2]/=n}function ni(t){function n(n){function r(e,r){e=t(e,r),n.point(e[0],e[1])}function u(){s=0/0,p.point=a,n.lineStart()}function a(r,u){var a=Jr([r,u]),o=t(r,u);e(s,f,l,h,d,g,s=o[0],f=o[1],l=r,h=a[0],d=a[1],g=a[2],i,n),n.point(s,f)}function o(){p.point=r,n.lineEnd()}function c(){var t,r,c,m,v,y,M;u(),p.point=function(n,e){a(t=n,r=e),c=s,m=f,v=h,y=d,M=g,p.point=a},p.lineEnd=function(){e(s,f,l,h,d,g,c,m,t,v,y,M,i,n),p.lineEnd=o,o()}}var l,s,f,h,d,g,p={point:r,lineStart:u,lineEnd:o,polygonStart:function(){n.polygonStart(),p.lineStart=c},polygonEnd:function(){n.polygonEnd(),p.lineStart=u}};return p}function e(n,i,u,a,o,c,l,s,f,h,d,g,p,m){var v=l-n,y=s-i,M=v*v+y*y;if(M>4*r&&p--){var b=a+h,x=o+d,_=c+g,w=Math.sqrt(b*b+x*x+_*_),S=Math.asin(_/=w),k=Pu>Math.abs(Math.abs(_)-1)?(u+f)/2:Math.atan2(x,b),E=t(k,S),A=E[0],N=E[1],T=A-n,q=N-i,C=y*T-v*q;(C*C/M>r||Math.abs((v*T+y*q)/M-.5)>.3)&&(e(n,i,u,a,o,c,A,N,k,b/=w,x/=w,_,p,m),m.point(A,N),e(A,N,k,b,x,_,l,s,f,h,d,g,p,m))}}var r=.5,i=16;return n.precision=function(t){return arguments.length?(i=(r=t*t)>0&&16,n):Math.sqrt(r)},n}function ei(t,n){function e(t,n){var e=Math.sqrt(u-2*i*Math.sin(n))/i;return[e*Math.sin(t*=i),a-e*Math.cos(t)]}var r=Math.sin(t),i=(r+Math.sin(n))/2,u=1+r*(2*i-r),a=Math.sqrt(u)/i;return e.invert=function(t,n){var e=a-n;return[Math.atan2(t,e)/i,Math.asin((u-(t*t+e*e)*i*i)/(2*i))]},e}function ri(t){function n(t,n){r>t&&(r=t),t>u&&(u=t),i>n&&(i=n),n>a&&(a=n)}function e(){o.point=o.lineEnd=Pn}var r,i,u,a,o={point:n,lineStart:Pn,lineEnd:Pn,polygonStart:function(){o.lineEnd=e},polygonEnd:function(){o.point=n}};return function(n){return a=u=-(r=i=1/0),d3.geo.stream(n,t(o)),[[r,i],[u,a]]}}function ii(t,n){if(!io){++uo,t*=ju;var e=Math.cos(n*=ju);ao+=(e*Math.cos(t)-ao)/uo,oo+=(e*Math.sin(t)-oo)/uo,co+=(Math.sin(n)-co)/uo}}function ui(){var t,n;io=1,ai(),io=2;var e=lo.point;lo.point=function(r,i){e(t=r,n=i)},lo.lineEnd=function(){lo.point(t,n),oi(),lo.lineEnd=oi}}function ai(){function t(t,i){t*=ju;var u=Math.cos(i*=ju),a=u*Math.cos(t),o=u*Math.sin(t),c=Math.sin(i),l=Math.atan2(Math.sqrt((l=e*c-r*o)*l+(l=r*a-n*c)*l+(l=n*o-e*a)*l),n*a+e*o+r*c);uo+=l,ao+=l*(n+(n=a)),oo+=l*(e+(e=o)),co+=l*(r+(r=c))}var n,e,r;io>1||(1>io&&(io=1,uo=ao=oo=co=0),lo.point=function(i,u){i*=ju;var a=Math.cos(u*=ju);n=a*Math.cos(i),e=a*Math.sin(i),r=Math.sin(u),lo.point=t})}function oi(){lo.point=ii}function ci(t,n){var e=Math.cos(t),r=Math.sin(t);return function(i,u,a,o){null!=i?(i=li(e,i),u=li(e,u),(a>0?u>i:i>u)&&(i+=2*a*Ru)):(i=t+2*a*Ru,u=t);for(var c,l=a*n,s=i;a>0?s>u:u>s;s-=l)o.point((c=Br([e,-r*Math.cos(s),-r*Math.sin(s)]))[0],c[1])}}function li(t,n){var e=Jr(n);e[0]-=t,ti(e);var r=Math.acos(Math.max(-1,Math.min(1,-e[1])));return((0>-e[2]?-r:r)+2*Math.PI-Pu)%(2*Math.PI)}function si(t,n,e){return function(r){function i(n,e){t(n,e)&&r.point(n,e)}function u(t,n){m.point(t,n)}function a(){v.point=u,m.lineStart()}function o(){v.point=i,m.lineEnd()}function c(t,n){M.point(t,n),p.push([t,n])}function l(){M.lineStart(),p=[]}function s(){c(p[0][0],p[0][1]),M.lineEnd();var t,n=M.clean(),e=y.buffer(),i=e.length;if(!i)return g=!0,d+=mi(p,-1),p=null,void 0;if(p=null,1&n){t=e[0],h+=mi(t,1);var u,i=t.length-1,a=-1;for(r.lineStart();i>++a;)r.point((u=t[a])[0],u[1]);return r.lineEnd(),void 0}i>1&&2&n&&e.push(e.pop().concat(e.shift())),f.push(e.filter(gi))}var f,h,d,g,p,m=n(r),v={point:i,lineStart:a,lineEnd:o,polygonStart:function(){v.point=c,v.lineStart=l,v.lineEnd=s,g=!1,d=h=0,f=[],r.polygonStart()
-},polygonEnd:function(){v.point=i,v.lineStart=a,v.lineEnd=o,f=d3.merge(f),f.length?fi(f,e,r):(-Pu>h||g&&-Pu>d)&&(r.lineStart(),e(null,null,1,r),r.lineEnd()),r.polygonEnd(),f=null},sphere:function(){r.polygonStart(),r.lineStart(),e(null,null,1,r),r.lineEnd(),r.polygonEnd()}},y=pi(),M=n(y);return v}}function fi(t,n,e){var r=[],i=[];if(t.forEach(function(t){var n=t.length;if(!(1>=n)){var e=t[0],u=t[n-1],a={point:e,points:t,other:null,visited:!1,entry:!0,subject:!0},o={point:e,points:[e],other:a,visited:!1,entry:!1,subject:!1};a.other=o,r.push(a),i.push(o),a={point:u,points:[u],other:null,visited:!1,entry:!1,subject:!0},o={point:u,points:[u],other:a,visited:!1,entry:!0,subject:!1},a.other=o,r.push(a),i.push(o)}}),i.sort(di),hi(r),hi(i),r.length)for(var u,a,o,c=r[0];;){for(u=c;u.visited;)if((u=u.next)===c)return;a=u.points,e.lineStart();do{if(u.visited=u.other.visited=!0,u.entry){if(u.subject)for(var l=0;a.length>l;l++)e.point((o=a[l])[0],o[1]);else n(u.point,u.next.point,1,e);u=u.next}else{if(u.subject){a=u.prev.points;for(var l=a.length;--l>=0;)e.point((o=a[l])[0],o[1])}else n(u.point,u.prev.point,-1,e);u=u.prev}u=u.other,a=u.points}while(!u.visited);e.lineEnd()}}function hi(t){if(n=t.length){for(var n,e,r=0,i=t[0];n>++r;)i.next=e=t[r],e.prev=i,i=e;i.next=e=t[0],e.prev=i}}function di(t,n){return(0>(t=t.point)[0]?t[1]-Ru/2-Pu:Ru/2-t[1])-(0>(n=n.point)[0]?n[1]-Ru/2-Pu:Ru/2-n[1])}function gi(t){return t.length>1}function pi(){var t,n=[];return{lineStart:function(){n.push(t=[])},point:function(n,e){t.push([n,e])},lineEnd:Pn,buffer:function(){var e=n;return n=[],t=null,e}}}function mi(t,n){if(!(e=t.length))return 0;for(var e,r,i,u=0,a=0,o=t[0],c=o[0],l=o[1],s=Math.cos(l),f=Math.atan2(n*Math.sin(c)*s,Math.sin(l)),h=1-n*Math.cos(c)*s,d=f;e>++u;)o=t[u],s=Math.cos(l=o[1]),r=Math.atan2(n*Math.sin(c=o[0])*s,Math.sin(l)),i=1-n*Math.cos(c)*s,Pu>Math.abs(h-2)&&Pu>Math.abs(i-2)||(Pu>Math.abs(i)||Pu>Math.abs(h)||(Pu>Math.abs(Math.abs(r-f)-Ru)?i+h>2&&(a+=4*(r-f)):a+=Pu>Math.abs(h-2)?4*(r-d):((3*Ru+r-f)%(2*Ru)-Ru)*(h+i)),d=f,f=r,h=i);return a}function vi(t){var n,e=0/0,r=0/0,i=0/0;return{lineStart:function(){t.lineStart(),n=1},point:function(u,a){var o=u>0?Ru:-Ru,c=Math.abs(u-e);Pu>Math.abs(c-Ru)?(t.point(e,r=(r+a)/2>0?Ru/2:-Ru/2),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(o,r),t.point(u,r),n=0):i!==o&&c>=Ru&&(Pu>Math.abs(e-i)&&(e-=i*Pu),Pu>Math.abs(u-o)&&(u-=o*Pu),r=yi(e,r,u,a),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(o,r),n=0),t.point(e=u,r=a),i=o},lineEnd:function(){t.lineEnd(),e=r=0/0},clean:function(){return 2-n}}}function yi(t,n,e,r){var i,u,a=Math.sin(t-e);return Math.abs(a)>Pu?Math.atan((Math.sin(n)*(u=Math.cos(r))*Math.sin(e)-Math.sin(r)*(i=Math.cos(n))*Math.sin(t))/(i*u*a)):(n+r)/2}function Mi(t,n,e,r){var i;if(null==t)i=e*Ru/2,r.point(-Ru,i),r.point(0,i),r.point(Ru,i),r.point(Ru,0),r.point(Ru,-i),r.point(0,-i),r.point(-Ru,-i),r.point(-Ru,0),r.point(-Ru,i);else if(Math.abs(t[0]-n[0])>Pu){var u=(t[0]<n[0]?1:-1)*Ru;i=e*u/2,r.point(-u,i),r.point(0,i),r.point(u,i)}else r.point(n[0],n[1])}function bi(t){function n(t,n){return Math.cos(t)*Math.cos(n)>u}function e(t){var e,i,u,a;return{lineStart:function(){u=i=!1,a=1},point:function(o,c){var l,s=[o,c],f=n(o,c);!e&&(u=i=f)&&t.lineStart(),f!==i&&(l=r(e,s),($r(e,l)||$r(s,l))&&(s[0]+=Pu,s[1]+=Pu,f=n(s[0],s[1]))),f!==i&&(a=0,(i=f)?(t.lineStart(),l=r(s,e),t.point(l[0],l[1])):(l=r(e,s),t.point(l[0],l[1]),t.lineEnd()),e=l),!f||e&&$r(e,s)||t.point(s[0],s[1]),e=s},lineEnd:function(){i&&t.lineEnd(),e=null},clean:function(){return a|(u&&i)<<1}}}function r(t,n){var e=Jr(t,0),r=Jr(n,0),i=[1,0,0],a=Kr(e,r),o=Gr(a,a),c=a[0],l=o-c*c;if(!l)return t;var s=u*o/l,f=-u*c/l,h=Kr(i,a),d=Qr(i,s),g=Qr(a,f);Wr(d,g);var p=h,m=Gr(d,p),v=Gr(p,p),y=Math.sqrt(m*m-v*(Gr(d,d)-1)),M=Qr(p,(-m-y)/v);return Wr(M,d),Br(M)}var i=t*ju,u=Math.cos(i),a=ci(i,6*ju);return si(n,e,a)}function xi(t,n){function e(e,r){return e=t(e,r),n(e[0],e[1])}return t.invert&&n.invert&&(e.invert=function(e,r){return e=n.invert(e,r),e&&t.invert(e[0],e[1])}),e}function _i(t,n){return[t,n]}function wi(t,n,e){var r=d3.range(t,n-Pu,e).concat(n);return function(t){return r.map(function(n){return[t,n]})}}function Si(t,n,e){var r=d3.range(t,n-Pu,e).concat(n);return function(t){return r.map(function(n){return[n,t]})}}function ki(t,n,e,r){function i(t){var n=Math.sin(t*=d)*g,e=Math.sin(d-t)*g,r=e*l+n*f,i=e*s+n*h,u=e*a+n*c;return[Math.atan2(i,r)/ju,Math.atan2(u,Math.sqrt(r*r+i*i))/ju]}var u=Math.cos(n),a=Math.sin(n),o=Math.cos(r),c=Math.sin(r),l=u*Math.cos(t),s=u*Math.sin(t),f=o*Math.cos(e),h=o*Math.sin(e),d=Math.acos(Math.max(-1,Math.min(1,a*c+u*o*Math.cos(e-t)))),g=1/Math.sin(d);return i.distance=d,i}function Ei(t,n){return[t/(2*Ru),Math.max(-.5,Math.min(.5,Math.log(Math.tan(Ru/4+n/2))/(2*Ru)))]}function Ai(t){return"m0,"+t+"a"+t+","+t+" 0 1,1 0,"+-2*t+"a"+t+","+t+" 0 1,1 0,"+2*t+"z"}function Ni(t){var n=ni(function(n,e){return t([n*Ou,e*Ou])});return function(t){return t=n(t),{point:function(n,e){t.point(n*ju,e*ju)},sphere:function(){t.sphere()},lineStart:function(){t.lineStart()},lineEnd:function(){t.lineEnd()},polygonStart:function(){t.polygonStart()},polygonEnd:function(){t.polygonEnd()}}}}function Ti(){function t(t,n){a.push("M",t,",",n,u)}function n(t,n){a.push("M",t,",",n),o.point=e}function e(t,n){a.push("L",t,",",n)}function r(){o.point=t}function i(){a.push("Z")}var u=Ai(4.5),a=[],o={point:t,lineStart:function(){o.point=n},lineEnd:r,polygonStart:function(){o.lineEnd=i},polygonEnd:function(){o.lineEnd=r,o.point=t},pointRadius:function(t){return u=Ai(t),o},result:function(){if(a.length){var t=a.join("");return a=[],t}}};return o}function qi(t){function n(n,e){t.moveTo(n,e),t.arc(n,e,a,0,2*Ru)}function e(n,e){t.moveTo(n,e),o.point=r}function r(n,e){t.lineTo(n,e)}function i(){o.point=n}function u(){t.closePath()}var a=4.5,o={point:n,lineStart:function(){o.point=e},lineEnd:i,polygonStart:function(){o.lineEnd=u},polygonEnd:function(){o.lineEnd=i,o.point=n},pointRadius:function(t){return a=t,o},result:Pn};return o}function Ci(){function t(t,n){po+=i*t-r*n,r=t,i=n}var n,e,r,i;mo.point=function(u,a){mo.point=t,n=r=u,e=i=a},mo.lineEnd=function(){t(n,e)}}function zi(t,n){io||(ao+=t,oo+=n,++co)}function Di(){function t(t,r){var i=t-n,u=r-e,a=Math.sqrt(i*i+u*u);ao+=a*(n+t)/2,oo+=a*(e+r)/2,co+=a,n=t,e=r}var n,e;if(1!==io){if(!(1>io))return;io=1,ao=oo=co=0}vo.point=function(r,i){vo.point=t,n=r,e=i}}function Li(){vo.point=zi}function Fi(){function t(t,n){var e=i*t-r*n;ao+=e*(r+t),oo+=e*(i+n),co+=3*e,r=t,i=n}var n,e,r,i;2>io&&(io=2,ao=oo=co=0),vo.point=function(u,a){vo.point=t,n=r=u,e=i=a},vo.lineEnd=function(){t(n,e)}}function Hi(){function t(t,n){if(t*=ju,n*=ju,!(Pu>Math.abs(Math.abs(u)-Ru/2)&&Pu>Math.abs(Math.abs(n)-Ru/2))){var e=Math.cos(n),c=Math.sin(n);if(Pu>Math.abs(u-Ru/2))Mo+=2*(t-r);else{var l=t-i,s=Math.cos(l),f=Math.atan2(Math.sqrt((f=e*Math.sin(l))*f+(f=a*c-o*e*s)*f),o*c+a*e*s),h=(f+Ru+u+n)/4;Mo+=(0>l&&l>-Ru||l>Ru?-4:4)*Math.atan(Math.sqrt(Math.abs(Math.tan(h)*Math.tan(h-f/2)*Math.tan(h-Ru/4-u/2)*Math.tan(h-Ru/4-n/2))))}r=i,i=t,u=n,a=e,o=c}}var n,e,r,i,u,a,o;bo.point=function(c,l){bo.point=t,r=i=(n=c)*ju,u=(e=l)*ju,a=Math.cos(u),o=Math.sin(u)},bo.lineEnd=function(){t(n,e)}}function Ri(t){return Pi(function(){return t})()}function Pi(t){function n(t){return t=a(t[0]*ju,t[1]*ju),[t[0]*s+o,c-t[1]*s]}function e(t){return t=a.invert((t[0]-o)/s,(c-t[1])/s),t&&[t[0]*Ou,t[1]*Ou]}function r(){a=xi(u=Oi(p,m,v),i);var t=i(d,g);return o=f-t[0]*s,c=h+t[1]*s,n}var i,u,a,o,c,l=ni(function(t,n){return t=i(t,n),[t[0]*s+o,c-t[1]*s]}),s=150,f=480,h=250,d=0,g=0,p=0,m=0,v=0,y=so,M=null;return n.stream=function(t){return ji(u,y(l(t)))},n.clipAngle=function(t){return arguments.length?(y=null==t?(M=t,so):bi(M=+t),n):M},n.scale=function(t){return arguments.length?(s=+t,r()):s},n.translate=function(t){return arguments.length?(f=+t[0],h=+t[1],r()):[f,h]},n.center=function(t){return arguments.length?(d=t[0]%360*ju,g=t[1]%360*ju,r()):[d*Ou,g*Ou]},n.rotate=function(t){return arguments.length?(p=t[0]%360*ju,m=t[1]%360*ju,v=t.length>2?t[2]%360*ju:0,r()):[p*Ou,m*Ou,v*Ou]},d3.rebind(n,l,"precision"),function(){return i=t.apply(this,arguments),n.invert=i.invert&&e,r()}}function ji(t,n){return{point:function(e,r){r=t(e*ju,r*ju),e=r[0],n.point(e>Ru?e-2*Ru:-Ru>e?e+2*Ru:e,r[1])},sphere:function(){n.sphere()},lineStart:function(){n.lineStart()},lineEnd:function(){n.lineEnd()},polygonStart:function(){n.polygonStart()},polygonEnd:function(){n.polygonEnd()}}}function Oi(t,n,e){return t?n||e?xi(Ui(t),Ii(n,e)):Ui(t):n||e?Ii(n,e):_i}function Yi(t){return function(n,e){return n+=t,[n>Ru?n-2*Ru:-Ru>n?n+2*Ru:n,e]}}function Ui(t){var n=Yi(t);return n.invert=Yi(-t),n}function Ii(t,n){function e(t,n){var e=Math.cos(n),o=Math.cos(t)*e,c=Math.sin(t)*e,l=Math.sin(n),s=l*r+o*i;return[Math.atan2(c*u-s*a,o*r-l*i),Math.asin(Math.max(-1,Math.min(1,s*u+c*a)))]}var r=Math.cos(t),i=Math.sin(t),u=Math.cos(n),a=Math.sin(n);return e.invert=function(t,n){var e=Math.cos(n),o=Math.cos(t)*e,c=Math.sin(t)*e,l=Math.sin(n),s=l*u-c*a;return[Math.atan2(c*u+l*a,o*r+s*i),Math.asin(Math.max(-1,Math.min(1,s*r-o*i)))]},e}function Vi(t,n){function e(n,e){var r=Math.cos(n),i=Math.cos(e),u=t(r*i);return[u*i*Math.sin(n),u*Math.sin(e)]}return e.invert=function(t,e){var r=Math.sqrt(t*t+e*e),i=n(r),u=Math.sin(i),a=Math.cos(i);return[Math.atan2(t*u,r*a),Math.asin(r&&e*u/r)]},e}function Xi(t,n,e,r){var i,u,a,o,c,l,s;return i=r[t],u=i[0],a=i[1],i=r[n],o=i[0],c=i[1],i=r[e],l=i[0],s=i[1],(s-a)*(o-u)-(c-a)*(l-u)>0}function Zi(t,n,e){return(e[0]-n[0])*(t[1]-n[1])<(e[1]-n[1])*(t[0]-n[0])}function Bi(t,n,e,r){var i=t[0],u=e[0],a=n[0]-i,o=r[0]-u,c=t[1],l=e[1],s=n[1]-c,f=r[1]-l,h=(o*(c-l)-f*(i-u))/(f*a-o*s);return[i+h*a,c+h*s]}function $i(t,n){var e={list:t.map(function(t,n){return{index:n,x:t[0],y:t[1]}}).sort(function(t,n){return t.y<n.y?-1:t.y>n.y?1:t.x<n.x?-1:t.x>n.x?1:0}),bottomSite:null},r={list:[],leftEnd:null,rightEnd:null,init:function(){r.leftEnd=r.createHalfEdge(null,"l"),r.rightEnd=r.createHalfEdge(null,"l"),r.leftEnd.r=r.rightEnd,r.rightEnd.l=r.leftEnd,r.list.unshift(r.leftEnd,r.rightEnd)},createHalfEdge:function(t,n){return{edge:t,side:n,vertex:null,l:null,r:null}},insert:function(t,n){n.l=t,n.r=t.r,t.r.l=n,t.r=n},leftBound:function(t){var n=r.leftEnd;do n=n.r;while(n!=r.rightEnd&&i.rightOf(n,t));return n=n.l},del:function(t){t.l.r=t.r,t.r.l=t.l,t.edge=null},right:function(t){return t.r},left:function(t){return t.l},leftRegion:function(t){return null==t.edge?e.bottomSite:t.edge.region[t.side]},rightRegion:function(t){return null==t.edge?e.bottomSite:t.edge.region[_o[t.side]]}},i={bisect:function(t,n){var e={region:{l:t,r:n},ep:{l:null,r:null}},r=n.x-t.x,i=n.y-t.y,u=r>0?r:-r,a=i>0?i:-i;return e.c=t.x*r+t.y*i+.5*(r*r+i*i),u>a?(e.a=1,e.b=i/r,e.c/=r):(e.b=1,e.a=r/i,e.c/=i),e},intersect:function(t,n){var e=t.edge,r=n.edge;if(!e||!r||e.region.r==r.region.r)return null;var i=e.a*r.b-e.b*r.a;if(1e-10>Math.abs(i))return null;var u,a,o=(e.c*r.b-r.c*e.b)/i,c=(r.c*e.a-e.c*r.a)/i,l=e.region.r,s=r.region.r;l.y<s.y||l.y==s.y&&l.x<s.x?(u=t,a=e):(u=n,a=r);var f=o>=a.region.r.x;return f&&"l"===u.side||!f&&"r"===u.side?null:{x:o,y:c}},rightOf:function(t,n){var e=t.edge,r=e.region.r,i=n.x>r.x;if(i&&"l"===t.side)return 1;if(!i&&"r"===t.side)return 0;if(1===e.a){var u=n.y-r.y,a=n.x-r.x,o=0,c=0;if(!i&&0>e.b||i&&e.b>=0?c=o=u>=e.b*a:(c=n.x+n.y*e.b>e.c,0>e.b&&(c=!c),c||(o=1)),!o){var l=r.x-e.region.l.x;c=e.b*(a*a-u*u)<l*u*(1+2*a/l+e.b*e.b),0>e.b&&(c=!c)}}else{var s=e.c-e.a*n.x,f=n.y-s,h=n.x-r.x,d=s-r.y;c=f*f>h*h+d*d}return"l"===t.side?c:!c},endPoint:function(t,e,r){t.ep[e]=r,t.ep[_o[e]]&&n(t)},distance:function(t,n){var e=t.x-n.x,r=t.y-n.y;return Math.sqrt(e*e+r*r)}},u={list:[],insert:function(t,n,e){t.vertex=n,t.ystar=n.y+e;for(var r=0,i=u.list,a=i.length;a>r;r++){var o=i[r];if(!(t.ystar>o.ystar||t.ystar==o.ystar&&n.x>o.vertex.x))break}i.splice(r,0,t)},del:function(t){for(var n=0,e=u.list,r=e.length;r>n&&e[n]!=t;++n);e.splice(n,1)},empty:function(){return 0===u.list.length},nextEvent:function(t){for(var n=0,e=u.list,r=e.length;r>n;++n)if(e[n]==t)return e[n+1];return null},min:function(){var t=u.list[0];return{x:t.vertex.x,y:t.ystar}},extractMin:function(){return u.list.shift()}};r.init(),e.bottomSite=e.list.shift();for(var a,o,c,l,s,f,h,d,g,p,m,v,y,M=e.list.shift();;)if(u.empty()||(a=u.min()),M&&(u.empty()||M.y<a.y||M.y==a.y&&M.x<a.x))o=r.leftBound(M),c=r.right(o),h=r.rightRegion(o),v=i.bisect(h,M),f=r.createHalfEdge(v,"l"),r.insert(o,f),p=i.intersect(o,f),p&&(u.del(o),u.insert(o,p,i.distance(p,M))),o=f,f=r.createHalfEdge(v,"r"),r.insert(o,f),p=i.intersect(f,c),p&&u.insert(f,p,i.distance(p,M)),M=e.list.shift();else{if(u.empty())break;o=u.extractMin(),l=r.left(o),c=r.right(o),s=r.right(c),h=r.leftRegion(o),d=r.rightRegion(c),m=o.vertex,i.endPoint(o.edge,o.side,m),i.endPoint(c.edge,c.side,m),r.del(o),u.del(c),r.del(c),y="l",h.y>d.y&&(g=h,h=d,d=g,y="r"),v=i.bisect(h,d),f=r.createHalfEdge(v,y),r.insert(l,f),i.endPoint(v,_o[y],m),p=i.intersect(l,f),p&&(u.del(l),u.insert(l,p,i.distance(p,h))),p=i.intersect(f,s),p&&u.insert(f,p,i.distance(p,h))}for(o=r.right(r.leftEnd);o!=r.rightEnd;o=r.right(o))n(o.edge)}function Ji(){return{leaf:!0,nodes:[],point:null}}function Gi(t,n,e,r,i,u){if(!t(n,e,r,i,u)){var a=.5*(e+i),o=.5*(r+u),c=n.nodes;c[0]&&Gi(t,c[0],e,r,a,o),c[1]&&Gi(t,c[1],a,r,i,o),c[2]&&Gi(t,c[2],e,o,a,u),c[3]&&Gi(t,c[3],a,o,i,u)}}function Ki(){this._=new Date(arguments.length>1?Date.UTC.apply(this,arguments):arguments[0])}function Wi(t,n,e,r){for(var i,u,a=0,o=n.length,c=e.length;o>a;){if(r>=c)return-1;if(i=n.charCodeAt(a++),37===i){if(u=Yo[n.charAt(a++)],!u||0>(r=u(t,e,r)))return-1}else if(i!=e.charCodeAt(r++))return-1}return r}function Qi(t){return RegExp("^(?:"+t.map(d3.requote).join("|")+")","i")}function tu(t){for(var n=new u,e=-1,r=t.length;r>++e;)n.set(t[e].toLowerCase(),e);return n}function nu(t,n,e){t+="";var r=t.length;return e>r?Array(e-r+1).join(n)+t:t}function eu(t,n,e){Lo.lastIndex=0;var r=Lo.exec(n.substring(e));return r?e+=r[0].length:-1}function ru(t,n,e){Do.lastIndex=0;var r=Do.exec(n.substring(e));return r?e+=r[0].length:-1}function iu(t,n,e){Ro.lastIndex=0;var r=Ro.exec(n.substring(e));return r?(t.m=Po.get(r[0].toLowerCase()),e+=r[0].length):-1}function uu(t,n,e){Fo.lastIndex=0;var r=Fo.exec(n.substring(e));return r?(t.m=Ho.get(r[0].toLowerCase()),e+=r[0].length):-1}function au(t,n,e){return Wi(t,""+Oo.c,n,e)}function ou(t,n,e){return Wi(t,""+Oo.x,n,e)}function cu(t,n,e){return Wi(t,""+Oo.X,n,e)}function lu(t,n,e){Uo.lastIndex=0;var r=Uo.exec(n.substring(e,e+4));return r?(t.y=+r[0],e+=r[0].length):-1}function su(t,n,e){Uo.lastIndex=0;var r=Uo.exec(n.substring(e,e+2));return r?(t.y=fu(+r[0]),e+=r[0].length):-1}function fu(t){return t+(t>68?1900:2e3)}function hu(t,n,e){Uo.lastIndex=0;var r=Uo.exec(n.substring(e,e+2));return r?(t.m=r[0]-1,e+=r[0].length):-1}function du(t,n,e){Uo.lastIndex=0;var r=Uo.exec(n.substring(e,e+2));return r?(t.d=+r[0],e+=r[0].length):-1}function gu(t,n,e){Uo.lastIndex=0;var r=Uo.exec(n.substring(e,e+2));return r?(t.H=+r[0],e+=r[0].length):-1}function pu(t,n,e){Uo.lastIndex=0;var r=Uo.exec(n.substring(e,e+2));return r?(t.M=+r[0],e+=r[0].length):-1}function mu(t,n,e){Uo.lastIndex=0;var r=Uo.exec(n.substring(e,e+2));return r?(t.S=+r[0],e+=r[0].length):-1}function vu(t,n,e){Uo.lastIndex=0;var r=Uo.exec(n.substring(e,e+3));return r?(t.L=+r[0],e+=r[0].length):-1}function yu(t,n,e){var r=Io.get(n.substring(e,e+=2).toLowerCase());return null==r?-1:(t.p=r,e)}function Mu(t){var n=t.getTimezoneOffset(),e=n>0?"-":"+",r=~~(Math.abs(n)/60),i=Math.abs(n)%60;return e+nu(r,"0",2)+nu(i,"0",2)}function bu(t){return t.toISOString()}function xu(t,n,e){function r(n){var e=t(n),r=u(e,1);return r-n>n-e?e:r}function i(e){return n(e=t(new wo(e-1)),1),e}function u(t,e){return n(t=new wo(+t),e),t}function a(t,r,u){var a=i(t),o=[];if(u>1)for(;r>a;)e(a)%u||o.push(new Date(+a)),n(a,1);else for(;r>a;)o.push(new Date(+a)),n(a,1);return o}function o(t,n,e){try{wo=Ki;var r=new Ki;return r._=t,a(r,n,e)}finally{wo=Date}}t.floor=t,t.round=r,t.ceil=i,t.offset=u,t.range=a;var c=t.utc=_u(t);return c.floor=c,c.round=_u(r),c.ceil=_u(i),c.offset=_u(u),c.range=o,t}function _u(t){return function(n,e){try{wo=Ki;var r=new Ki;return r._=n,t(r,e)._}finally{wo=Date}}}function wu(t,n,e){function r(n){return t(n)}return r.invert=function(n){return ku(t.invert(n))},r.domain=function(n){return arguments.length?(t.domain(n),r):t.domain().map(ku)},r.nice=function(t){return r.domain(Yn(r.domain(),function(){return t}))},r.ticks=function(e,i){var u=Su(r.domain());if("function"!=typeof e){var a=u[1]-u[0],o=a/e,c=d3.bisect(Xo,o);if(c==Xo.length)return n.year(u,e);if(!c)return t.ticks(e).map(ku);Math.log(o/Xo[c-1])<Math.log(Xo[c]/o)&&--c,e=n[c],i=e[1],e=e[0].range}return e(u[0],new Date(+u[1]+1),i)},r.tickFormat=function(){return e},r.copy=function(){return wu(t.copy(),n,e)},d3.rebind(r,t,"range","rangeRound","interpolate","clamp")}function Su(t){var n=t[0],e=t[t.length-1];return e>n?[n,e]:[e,n]}function ku(t){return new Date(t)}function Eu(t){return function(n){for(var e=t.length-1,r=t[e];!r[1](n);)r=t[--e];return r[0](n)}}function Au(t){var n=new Date(t,0,1);return n.setFullYear(t),n}function Nu(t){var n=t.getFullYear(),e=Au(n),r=Au(n+1);return n+(t-e)/(r-e)}function Tu(t){var n=new Date(Date.UTC(t,0,1));return n.setUTCFullYear(t),n}function qu(t){var n=t.getUTCFullYear(),e=Tu(n),r=Tu(n+1);return n+(t-e)/(r-e)}var Cu=".",zu=",",Du=[3,3];Date.now||(Date.now=function(){return+new Date});try{document.createElement("div").style.setProperty("opacity",0,"")}catch(Lu){var Fu=CSSStyleDeclaration.prototype,Hu=Fu.setProperty;Fu.setProperty=function(t,n,e){Hu.call(this,t,n+"",e)}}d3={version:"3.0.4"};var Ru=Math.PI,Pu=1e-6,ju=Ru/180,Ou=180/Ru,Yu=i;try{Yu(document.documentElement.childNodes)[0].nodeType}catch(Uu){Yu=r}var Iu=[].__proto__?function(t,n){t.__proto__=n}:function(t,n){for(var e in n)t[e]=n[e]};d3.map=function(t){var n=new u;for(var e in t)n.set(e,t[e]);return n},e(u,{has:function(t){return Vu+t in this},get:function(t){return this[Vu+t]},set:function(t,n){return this[Vu+t]=n},remove:function(t){return t=Vu+t,t in this&&delete this[t]},keys:function(){var t=[];return this.forEach(function(n){t.push(n)}),t},values:function(){var t=[];return this.forEach(function(n,e){t.push(e)}),t},entries:function(){var t=[];return this.forEach(function(n,e){t.push({key:n,value:e})}),t},forEach:function(t){for(var n in this)n.charCodeAt(0)===Xu&&t.call(this,n.substring(1),this[n])}});var Vu="\0",Xu=Vu.charCodeAt(0);d3.functor=c,d3.rebind=function(t,n){for(var e,r=1,i=arguments.length;i>++r;)t[e=arguments[r]]=l(t,n,n[e]);return t},d3.ascending=function(t,n){return n>t?-1:t>n?1:t>=n?0:0/0},d3.descending=function(t,n){return t>n?-1:n>t?1:n>=t?0:0/0},d3.mean=function(t,n){var e,r=t.length,i=0,u=-1,a=0;if(1===arguments.length)for(;r>++u;)s(e=t[u])&&(i+=(e-i)/++a);else for(;r>++u;)s(e=n.call(t,t[u],u))&&(i+=(e-i)/++a);return a?i:void 0},d3.median=function(t,n){return arguments.length>1&&(t=t.map(n)),t=t.filter(s),t.length?d3.quantile(t.sort(d3.ascending),.5):void 0},d3.min=function(t,n){var e,r,i=-1,u=t.length;if(1===arguments.length){for(;u>++i&&(null==(e=t[i])||e!=e);)e=void 0;for(;u>++i;)null!=(r=t[i])&&e>r&&(e=r)}else{for(;u>++i&&(null==(e=n.call(t,t[i],i))||e!=e);)e=void 0;for(;u>++i;)null!=(r=n.call(t,t[i],i))&&e>r&&(e=r)}return e},d3.max=function(t,n){var e,r,i=-1,u=t.length;if(1===arguments.length){for(;u>++i&&(null==(e=t[i])||e!=e);)e=void 0;for(;u>++i;)null!=(r=t[i])&&r>e&&(e=r)}else{for(;u>++i&&(null==(e=n.call(t,t[i],i))||e!=e);)e=void 0;for(;u>++i;)null!=(r=n.call(t,t[i],i))&&r>e&&(e=r)}return e},d3.extent=function(t,n){var e,r,i,u=-1,a=t.length;if(1===arguments.length){for(;a>++u&&(null==(e=i=t[u])||e!=e);)e=i=void 0;for(;a>++u;)null!=(r=t[u])&&(e>r&&(e=r),r>i&&(i=r))}else{for(;a>++u&&(null==(e=i=n.call(t,t[u],u))||e!=e);)e=void 0;for(;a>++u;)null!=(r=n.call(t,t[u],u))&&(e>r&&(e=r),r>i&&(i=r))}return[e,i]},d3.random={normal:function(t,n){var e=arguments.length;return 2>e&&(n=1),1>e&&(t=0),function(){var e,r,i;do e=2*Math.random()-1,r=2*Math.random()-1,i=e*e+r*r;while(!i||i>1);return t+n*e*Math.sqrt(-2*Math.log(i)/i)}},logNormal:function(t,n){var e=arguments.length;2>e&&(n=1),1>e&&(t=0);var r=d3.random.normal();return function(){return Math.exp(t+n*r())}},irwinHall:function(t){return function(){for(var n=0,e=0;t>e;e++)n+=Math.random();return n/t}}},d3.sum=function(t,n){var e,r=0,i=t.length,u=-1;if(1===arguments.length)for(;i>++u;)isNaN(e=+t[u])||(r+=e);else for(;i>++u;)isNaN(e=+n.call(t,t[u],u))||(r+=e);return r},d3.quantile=function(t,n){var e=(t.length-1)*n+1,r=Math.floor(e),i=+t[r-1],u=e-r;return u?i+u*(t[r]-i):i},d3.shuffle=function(t){for(var n,e,r=t.length;r;)e=0|Math.random()*r--,n=t[r],t[r]=t[e],t[e]=n;return t},d3.transpose=function(t){return d3.zip.apply(d3,t)},d3.zip=function(){if(!(r=arguments.length))return[];for(var t=-1,n=d3.min(arguments,f),e=Array(n);n>++t;)for(var r,i=-1,u=e[t]=Array(r);r>++i;)u[i]=arguments[i][t];return e},d3.bisector=function(t){return{left:function(n,e,r,i){for(3>arguments.length&&(r=0),4>arguments.length&&(i=n.length);i>r;){var u=r+i>>>1;e>t.call(n,n[u],u)?r=u+1:i=u}return r},right:function(n,e,r,i){for(3>arguments.length&&(r=0),4>arguments.length&&(i=n.length);i>r;){var u=r+i>>>1;t.call(n,n[u],u)>e?i=u:r=u+1}return r}}};var Zu=d3.bisector(function(t){return t});d3.bisectLeft=Zu.left,d3.bisect=d3.bisectRight=Zu.right,d3.nest=function(){function t(n,o){if(o>=a.length)return r?r.call(i,n):e?n.sort(e):n;for(var c,l,s,f=-1,h=n.length,d=a[o++],g=new u,p={};h>++f;)(s=g.get(c=d(l=n[f])))?s.push(l):g.set(c,[l]);return g.forEach(function(n,e){p[n]=t(e,o)}),p}function n(t,e){if(e>=a.length)return t;var r,i=[],u=o[e++];for(r in t)i.push({key:r,values:n(t[r],e)});return u&&i.sort(function(t,n){return u(t.key,n.key)}),i}var e,r,i={},a=[],o=[];return i.map=function(n){return t(n,0)},i.entries=function(e){return n(t(e,0),0)},i.key=function(t){return a.push(t),i},i.sortKeys=function(t){return o[a.length-1]=t,i},i.sortValues=function(t){return e=t,i},i.rollup=function(t){return r=t,i},i},d3.keys=function(t){var n=[];for(var e in t)n.push(e);return n},d3.values=function(t){var n=[];for(var e in t)n.push(t[e]);return n},d3.entries=function(t){var n=[];for(var e in t)n.push({key:e,value:t[e]});return n},d3.permute=function(t,n){for(var e=[],r=-1,i=n.length;i>++r;)e[r]=t[n[r]];return e},d3.merge=function(t){return Array.prototype.concat.apply([],t)},d3.range=function(t,n,e){if(3>arguments.length&&(e=1,2>arguments.length&&(n=t,t=0)),1/0===(n-t)/e)throw Error("infinite range");var r,i=[],u=d(Math.abs(e)),a=-1;if(t*=u,n*=u,e*=u,0>e)for(;(r=t+e*++a)>n;)i.push(r/u);else for(;n>(r=t+e*++a);)i.push(r/u);return i},d3.requote=function(t){return t.replace(Bu,"\\$&")};var Bu=/[\\\^\$\*\+\?\|\[\]\(\)\.\{\}]/g;d3.round=function(t,n){return n?Math.round(t*(n=Math.pow(10,n)))/n:Math.round(t)},d3.xhr=function(t,n,e){function r(){var t=l.status;!t&&l.responseText||t>=200&&300>t||304===t?u.load.call(i,c.call(i,l)):u.error.call(i,l)}var i={},u=d3.dispatch("progress","load","error"),o={},c=a,l=new(window.XDomainRequest&&/^(http(s)?:)?\/\//.test(t)?XDomainRequest:XMLHttpRequest);return"onload"in l?l.onload=l.onerror=r:l.onreadystatechange=function(){l.readyState>3&&r()},l.onprogress=function(t){var n=d3.event;d3.event=t;try{u.progress.call(i,l)}finally{d3.event=n}},i.header=function(t,n){return t=(t+"").toLowerCase(),2>arguments.length?o[t]:(null==n?delete o[t]:o[t]=n+"",i)},i.mimeType=function(t){return arguments.length?(n=null==t?null:t+"",i):n},i.response=function(t){return c=t,i},["get","post"].forEach(function(t){i[t]=function(){return i.send.apply(i,[t].concat(Yu(arguments)))}}),i.send=function(e,r,u){if(2===arguments.length&&"function"==typeof r&&(u=r,r=null),l.open(e,t,!0),null==n||"accept"in o||(o.accept=n+",*/*"),l.setRequestHeader)for(var a in o)l.setRequestHeader(a,o[a]);return null!=n&&l.overrideMimeType&&l.overrideMimeType(n),null!=u&&i.on("error",u).on("load",function(t){u(null,t)}),l.send(null==r?null:r),i},i.abort=function(){return l.abort(),i},d3.rebind(i,u,"on"),2===arguments.length&&"function"==typeof n&&(e=n,n=null),null==e?i:i.get(g(e))},d3.text=function(){return d3.xhr.apply(d3,arguments).response(p)},d3.json=function(t,n){return d3.xhr(t,"application/json",n).response(m)},d3.html=function(t,n){return d3.xhr(t,"text/html",n).response(v)},d3.xml=function(){return d3.xhr.apply(d3,arguments).response(y)};var $u={svg:"http://www.w3.org/2000/svg",xhtml:"http://www.w3.org/1999/xhtml",xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"};d3.ns={prefix:$u,qualify:function(t){var n=t.indexOf(":"),e=t;return n>=0&&(e=t.substring(0,n),t=t.substring(n+1)),$u.hasOwnProperty(e)?{space:$u[e],local:t}:t}},d3.dispatch=function(){for(var t=new M,n=-1,e=arguments.length;e>++n;)t[arguments[n]]=b(t);return t},M.prototype.on=function(t,n){var e=t.indexOf("."),r="";return e>0&&(r=t.substring(e+1),t=t.substring(0,e)),2>arguments.length?this[t].on(r):this[t].on(r,n)},d3.format=function(t){var n=Ju.exec(t),e=n[1]||" ",r=n[2]||">",i=n[3]||"",u=n[4]||"",a=n[5],o=+n[6],c=n[7],l=n[8],s=n[9],f=1,h="",d=!1;switch(l&&(l=+l.substring(1)),(a||"0"===e&&"="===r)&&(a=e="0",r="=",c&&(o-=Math.floor((o-1)/4))),s){case"n":c=!0,s="g";break;case"%":f=100,h="%",s="f";break;case"p":f=100,h="%",s="r";break;case"b":case"o":case"x":case"X":u&&(u="0"+s.toLowerCase());case"c":case"d":d=!0,l=0;break;case"s":f=-1,s="r"}"#"===u&&(u=""),"r"!=s||l||(s="g"),s=Gu.get(s)||_;var g=a&&c;return function(t){if(d&&t%1)return"";var n=0>t||0===t&&0>1/t?(t=-t,"-"):i;if(0>f){var p=d3.formatPrefix(t,l);t=p.scale(t),h=p.symbol}else t*=f;t=s(t,l),!a&&c&&(t=Ku(t));var m=u.length+t.length+(g?0:n.length),v=o>m?Array(m=o-m+1).join(e):"";return g&&(t=Ku(v+t)),Cu&&t.replace(".",Cu),n+=u,("<"===r?n+t+v:">"===r?v+n+t:"^"===r?v.substring(0,m>>=1)+n+t+v.substring(m):n+(g?t:v+t))+h}};var Ju=/(?:([^{])?([<>=^]))?([+\- ])?(#)?(0)?([0-9]+)?(,)?(\.[0-9]+)?([a-zA-Z%])?/,Gu=d3.map({b:function(t){return t.toString(2)},c:function(t){return String.fromCharCode(t)},o:function(t){return t.toString(8)},x:function(t){return t.toString(16)},X:function(t){return t.toString(16).toUpperCase()},g:function(t,n){return t.toPrecision(n)},e:function(t,n){return t.toExponential(n)},f:function(t,n){return t.toFixed(n)},r:function(t,n){return d3.round(t,n=x(t,n)).toFixed(Math.max(0,Math.min(20,n)))}}),Ku=a;if(Du){var Wu=Du.length;Ku=function(t){for(var n=t.lastIndexOf("."),e=n>=0?"."+t.substring(n+1):(n=t.length,""),r=[],i=0,u=Du[0];n>0&&u>0;)r.push(t.substring(n-=u,n+u)),u=Du[i=(i+1)%Wu];return r.reverse().join(zu||"")+e}}var Qu=["y","z","a","f","p","n","μ","m","","k","M","G","T","P","E","Z","Y"].map(w);d3.formatPrefix=function(t,n){var e=0;return t&&(0>t&&(t*=-1),n&&(t=d3.round(t,x(t,n))),e=1+Math.floor(1e-12+Math.log(t)/Math.LN10),e=Math.max(-24,Math.min(24,3*Math.floor((0>=e?e+1:e-1)/3)))),Qu[8+e/3]};var ta=function(){return a},na=d3.map({linear:ta,poly:q,quad:function(){return A},cubic:function(){return N},sin:function(){return C},exp:function(){return z},circle:function(){return D},elastic:L,back:F,bounce:function(){return H}}),ea=d3.map({"in":a,out:k,"in-out":E,"out-in":function(t){return E(k(t))}});d3.ease=function(t){var n=t.indexOf("-"),e=n>=0?t.substring(0,n):t,r=n>=0?t.substring(n+1):"in";return e=na.get(e)||ta,r=ea.get(r)||a,S(r(e.apply(null,Array.prototype.slice.call(arguments,1))))},d3.event=null,d3.transform=function(t){var n=document.createElementNS(d3.ns.prefix.svg,"g");return(d3.transform=function(t){n.setAttribute("transform",t);var e=n.transform.baseVal.consolidate();return new O(e?e.matrix:ra)})(t)},O.prototype.toString=function(){return"translate("+this.translate+")rotate("+this.rotate+")skewX("+this.skew+")scale("+this.scale+")"};var ra={a:1,b:0,c:0,d:1,e:0,f:0};d3.interpolate=function(t,n){for(var e,r=d3.interpolators.length;--r>=0&&!(e=d3.interpolators[r](t,n)););return e},d3.interpolateNumber=function(t,n){return n-=t,function(e){return t+n*e}},d3.interpolateRound=function(t,n){return n-=t,function(e){return Math.round(t+n*e)}},d3.interpolateString=function(t,n){var e,r,i,u,a,o=0,c=0,l=[],s=[];for(ia.lastIndex=0,r=0;e=ia.exec(n);++r)e.index&&l.push(n.substring(o,c=e.index)),s.push({i:l.length,x:e[0]}),l.push(null),o=ia.lastIndex;for(n.length>o&&l.push(n.substring(o)),r=0,u=s.length;(e=ia.exec(t))&&u>r;++r)if(a=s[r],a.x==e[0]){if(a.i)if(null==l[a.i+1])for(l[a.i-1]+=a.x,l.splice(a.i,1),i=r+1;u>i;++i)s[i].i--;else for(l[a.i-1]+=a.x+l[a.i+1],l.splice(a.i,2),i=r+1;u>i;++i)s[i].i-=2;else if(null==l[a.i+1])l[a.i]=a.x;else for(l[a.i]=a.x+l[a.i+1],l.splice(a.i+1,1),i=r+1;u>i;++i)s[i].i--;s.splice(r,1),u--,r--}else a.x=d3.interpolateNumber(parseFloat(e[0]),parseFloat(a.x));for(;u>r;)a=s.pop(),null==l[a.i+1]?l[a.i]=a.x:(l[a.i]=a.x+l[a.i+1],l.splice(a.i+1,1)),u--;return 1===l.length?null==l[0]?s[0].x:function(){return n}:function(t){for(r=0;u>r;++r)l[(a=s[r]).i]=a.x(t);return l.join("")}},d3.interpolateTransform=function(t,n){var e,r=[],i=[],u=d3.transform(t),a=d3.transform(n),o=u.translate,c=a.translate,l=u.rotate,s=a.rotate,f=u.skew,h=a.skew,d=u.scale,g=a.scale;return o[0]!=c[0]||o[1]!=c[1]?(r.push("translate(",null,",",null,")"),i.push({i:1,x:d3.interpolateNumber(o[0],c[0])},{i:3,x:d3.interpolateNumber(o[1],c[1])})):c[0]||c[1]?r.push("translate("+c+")"):r.push(""),l!=s?(l-s>180?s+=360:s-l>180&&(l+=360),i.push({i:r.push(r.pop()+"rotate(",null,")")-2,x:d3.interpolateNumber(l,s)})):s&&r.push(r.pop()+"rotate("+s+")"),f!=h?i.push({i:r.push(r.pop()+"skewX(",null,")")-2,x:d3.interpolateNumber(f,h)}):h&&r.push(r.pop()+"skewX("+h+")"),d[0]!=g[0]||d[1]!=g[1]?(e=r.push(r.pop()+"scale(",null,",",null,")"),i.push({i:e-4,x:d3.interpolateNumber(d[0],g[0])},{i:e-2,x:d3.interpolateNumber(d[1],g[1])})):(1!=g[0]||1!=g[1])&&r.push(r.pop()+"scale("+g+")"),e=i.length,function(t){for(var n,u=-1;e>++u;)r[(n=i[u]).i]=n.x(t);return r.join("")}},d3.interpolateRgb=function(t,n){t=d3.rgb(t),n=d3.rgb(n);var e=t.r,r=t.g,i=t.b,u=n.r-e,a=n.g-r,o=n.b-i;return function(t){return"#"+G(Math.round(e+u*t))+G(Math.round(r+a*t))+G(Math.round(i+o*t))}},d3.interpolateHsl=function(t,n){t=d3.hsl(t),n=d3.hsl(n);var e=t.h,r=t.s,i=t.l,u=n.h-e,a=n.s-r,o=n.l-i;return u>180?u-=360:-180>u&&(u+=360),function(t){return un(e+u*t,r+a*t,i+o*t)+""}},d3.interpolateLab=function(t,n){t=d3.lab(t),n=d3.lab(n);var e=t.l,r=t.a,i=t.b,u=n.l-e,a=n.a-r,o=n.b-i;return function(t){return fn(e+u*t,r+a*t,i+o*t)+""}},d3.interpolateHcl=function(t,n){t=d3.hcl(t),n=d3.hcl(n);var e=t.h,r=t.c,i=t.l,u=n.h-e,a=n.c-r,o=n.l-i;return u>180?u-=360:-180>u&&(u+=360),function(t){return cn(e+u*t,r+a*t,i+o*t)+""}},d3.interpolateArray=function(t,n){var e,r=[],i=[],u=t.length,a=n.length,o=Math.min(t.length,n.length);for(e=0;o>e;++e)r.push(d3.interpolate(t[e],n[e]));for(;u>e;++e)i[e]=t[e];for(;a>e;++e)i[e]=n[e];return function(t){for(e=0;o>e;++e)i[e]=r[e](t);return i}},d3.interpolateObject=function(t,n){var e,r={},i={};for(e in t)e in n?r[e]=V(e)(t[e],n[e]):i[e]=t[e];for(e in n)e in t||(i[e]=n[e]);return function(t){for(e in r)i[e]=r[e](t);return i}};var ia=/[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g;d3.interpolators=[d3.interpolateObject,function(t,n){return n instanceof Array&&d3.interpolateArray(t,n)},function(t,n){return("string"==typeof t||"string"==typeof n)&&d3.interpolateString(t+"",n+"")},function(t,n){return("string"==typeof n?aa.has(n)||/^(#|rgb\(|hsl\()/.test(n):n instanceof B)&&d3.interpolateRgb(t,n)},function(t,n){return!isNaN(t=+t)&&!isNaN(n=+n)&&d3.interpolateNumber(t,n)}],B.prototype.toString=function(){return this.rgb()+""},d3.rgb=function(t,n,e){return 1===arguments.length?t instanceof J?$(t.r,t.g,t.b):K(""+t,$,un):$(~~t,~~n,~~e)};var ua=J.prototype=new B;ua.brighter=function(t){t=Math.pow(.7,arguments.length?t:1);var n=this.r,e=this.g,r=this.b,i=30;return n||e||r?(n&&i>n&&(n=i),e&&i>e&&(e=i),r&&i>r&&(r=i),$(Math.min(255,Math.floor(n/t)),Math.min(255,Math.floor(e/t)),Math.min(255,Math.floor(r/t)))):$(i,i,i)},ua.darker=function(t){return t=Math.pow(.7,arguments.length?t:1),$(Math.floor(t*this.r),Math.floor(t*this.g),Math.floor(t*this.b))
-},ua.hsl=function(){return W(this.r,this.g,this.b)},ua.toString=function(){return"#"+G(this.r)+G(this.g)+G(this.b)};var aa=d3.map({aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"});aa.forEach(function(t,n){aa.set(t,K(n,$,un))}),d3.hsl=function(t,n,e){return 1===arguments.length?t instanceof rn?en(t.h,t.s,t.l):K(""+t,W,en):en(+t,+n,+e)};var oa=rn.prototype=new B;oa.brighter=function(t){return t=Math.pow(.7,arguments.length?t:1),en(this.h,this.s,this.l/t)},oa.darker=function(t){return t=Math.pow(.7,arguments.length?t:1),en(this.h,this.s,t*this.l)},oa.rgb=function(){return un(this.h,this.s,this.l)},d3.hcl=function(t,n,e){return 1===arguments.length?t instanceof on?an(t.h,t.c,t.l):t instanceof sn?hn(t.l,t.a,t.b):hn((t=Q((t=d3.rgb(t)).r,t.g,t.b)).l,t.a,t.b):an(+t,+n,+e)};var ca=on.prototype=new B;ca.brighter=function(t){return an(this.h,this.c,Math.min(100,this.l+la*(arguments.length?t:1)))},ca.darker=function(t){return an(this.h,this.c,Math.max(0,this.l-la*(arguments.length?t:1)))},ca.rgb=function(){return cn(this.h,this.c,this.l).rgb()},d3.lab=function(t,n,e){return 1===arguments.length?t instanceof sn?ln(t.l,t.a,t.b):t instanceof on?cn(t.l,t.c,t.h):Q((t=d3.rgb(t)).r,t.g,t.b):ln(+t,+n,+e)};var la=18,sa=.95047,fa=1,ha=1.08883,da=sn.prototype=new B;da.brighter=function(t){return ln(Math.min(100,this.l+la*(arguments.length?t:1)),this.a,this.b)},da.darker=function(t){return ln(Math.max(0,this.l-la*(arguments.length?t:1)),this.a,this.b)},da.rgb=function(){return fn(this.l,this.a,this.b)};var ga=function(t,n){return n.querySelector(t)},pa=function(t,n){return n.querySelectorAll(t)},ma=document.documentElement,va=ma.matchesSelector||ma.webkitMatchesSelector||ma.mozMatchesSelector||ma.msMatchesSelector||ma.oMatchesSelector,ya=function(t,n){return va.call(t,n)};"function"==typeof Sizzle&&(ga=function(t,n){return Sizzle(t,n)[0]||null},pa=function(t,n){return Sizzle.uniqueSort(Sizzle(t,n))},ya=Sizzle.matchesSelector);var Ma=[];d3.selection=function(){return ba},d3.selection.prototype=Ma,Ma.select=function(t){var n,e,r,i,u=[];"function"!=typeof t&&(t=vn(t));for(var a=-1,o=this.length;o>++a;){u.push(n=[]),n.parentNode=(r=this[a]).parentNode;for(var c=-1,l=r.length;l>++c;)(i=r[c])?(n.push(e=t.call(i,i.__data__,c)),e&&"__data__"in i&&(e.__data__=i.__data__)):n.push(null)}return mn(u)},Ma.selectAll=function(t){var n,e,r=[];"function"!=typeof t&&(t=yn(t));for(var i=-1,u=this.length;u>++i;)for(var a=this[i],o=-1,c=a.length;c>++o;)(e=a[o])&&(r.push(n=Yu(t.call(e,e.__data__,o))),n.parentNode=e);return mn(r)},Ma.attr=function(t,n){if(2>arguments.length){if("string"==typeof t){var e=this.node();return t=d3.ns.qualify(t),t.local?e.getAttributeNS(t.space,t.local):e.getAttribute(t)}for(n in t)this.each(Mn(n,t[n]));return this}return this.each(Mn(t,n))},Ma.classed=function(t,n){if(2>arguments.length){if("string"==typeof t){var e=this.node(),r=(t=t.trim().split(/^|\s+/g)).length,i=-1;if(n=e.classList){for(;r>++i;)if(!n.contains(t[i]))return!1}else for(n=e.className,null!=n.baseVal&&(n=n.baseVal);r>++i;)if(!bn(t[i]).test(n))return!1;return!0}for(n in t)this.each(xn(n,t[n]));return this}return this.each(xn(t,n))},Ma.style=function(t,n,e){var r=arguments.length;if(3>r){if("string"!=typeof t){2>r&&(n="");for(e in t)this.each(wn(e,t[e],n));return this}if(2>r)return getComputedStyle(this.node(),null).getPropertyValue(t);e=""}return this.each(wn(t,n,e))},Ma.property=function(t,n){if(2>arguments.length){if("string"==typeof t)return this.node()[t];for(n in t)this.each(Sn(n,t[n]));return this}return this.each(Sn(t,n))},Ma.text=function(t){return arguments.length?this.each("function"==typeof t?function(){var n=t.apply(this,arguments);this.textContent=null==n?"":n}:null==t?function(){this.textContent=""}:function(){this.textContent=t}):this.node().textContent},Ma.html=function(t){return arguments.length?this.each("function"==typeof t?function(){var n=t.apply(this,arguments);this.innerHTML=null==n?"":n}:null==t?function(){this.innerHTML=""}:function(){this.innerHTML=t}):this.node().innerHTML},Ma.append=function(t){function n(){return this.appendChild(document.createElementNS(this.namespaceURI,t))}function e(){return this.appendChild(document.createElementNS(t.space,t.local))}return t=d3.ns.qualify(t),this.select(t.local?e:n)},Ma.insert=function(t,n){function e(){return this.insertBefore(document.createElementNS(this.namespaceURI,t),ga(n,this))}function r(){return this.insertBefore(document.createElementNS(t.space,t.local),ga(n,this))}return t=d3.ns.qualify(t),this.select(t.local?r:e)},Ma.remove=function(){return this.each(function(){var t=this.parentNode;t&&t.removeChild(this)})},Ma.data=function(t,n){function e(t,e){var r,i,a,o=t.length,f=e.length,h=Math.min(o,f),d=Array(f),g=Array(f),p=Array(o);if(n){var m,v=new u,y=new u,M=[];for(r=-1;o>++r;)m=n.call(i=t[r],i.__data__,r),v.has(m)?p[r]=i:v.set(m,i),M.push(m);for(r=-1;f>++r;)m=n.call(e,a=e[r],r),(i=v.get(m))?(d[r]=i,i.__data__=a):y.has(m)||(g[r]=kn(a)),y.set(m,a),v.remove(m);for(r=-1;o>++r;)v.has(M[r])&&(p[r]=t[r])}else{for(r=-1;h>++r;)i=t[r],a=e[r],i?(i.__data__=a,d[r]=i):g[r]=kn(a);for(;f>r;++r)g[r]=kn(e[r]);for(;o>r;++r)p[r]=t[r]}g.update=d,g.parentNode=d.parentNode=p.parentNode=t.parentNode,c.push(g),l.push(d),s.push(p)}var r,i,a=-1,o=this.length;if(!arguments.length){for(t=Array(o=(r=this[0]).length);o>++a;)(i=r[a])&&(t[a]=i.__data__);return t}var c=qn([]),l=mn([]),s=mn([]);if("function"==typeof t)for(;o>++a;)e(r=this[a],t.call(r,r.parentNode.__data__,a));else for(;o>++a;)e(r=this[a],t);return l.enter=function(){return c},l.exit=function(){return s},l},Ma.datum=function(t){return arguments.length?this.property("__data__",t):this.property("__data__")},Ma.filter=function(t){var n,e,r,i=[];"function"!=typeof t&&(t=En(t));for(var u=0,a=this.length;a>u;u++){i.push(n=[]),n.parentNode=(e=this[u]).parentNode;for(var o=0,c=e.length;c>o;o++)(r=e[o])&&t.call(r,r.__data__,o)&&n.push(r)}return mn(i)},Ma.order=function(){for(var t=-1,n=this.length;n>++t;)for(var e,r=this[t],i=r.length-1,u=r[i];--i>=0;)(e=r[i])&&(u&&u!==e.nextSibling&&u.parentNode.insertBefore(e,u),u=e);return this},Ma.sort=function(t){t=An.apply(this,arguments);for(var n=-1,e=this.length;e>++n;)this[n].sort(t);return this.order()},Ma.on=function(t,n,e){var r=arguments.length;if(3>r){if("string"!=typeof t){2>r&&(n=!1);for(e in t)this.each(Nn(e,t[e],n));return this}if(2>r)return(r=this.node()["__on"+t])&&r._;e=!1}return this.each(Nn(t,n,e))},Ma.each=function(t){return Tn(this,function(n,e,r){t.call(n,n.__data__,e,r)})},Ma.call=function(t){var n=Yu(arguments);return t.apply(n[0]=this,n),this},Ma.empty=function(){return!this.node()},Ma.node=function(){for(var t=0,n=this.length;n>t;t++)for(var e=this[t],r=0,i=e.length;i>r;r++){var u=e[r];if(u)return u}return null},Ma.transition=function(){var t,n,e=_a||++Sa,r=[],i=Object.create(ka);i.time=Date.now();for(var u=-1,a=this.length;a>++u;){r.push(t=[]);for(var o=this[u],c=-1,l=o.length;l>++c;)(n=o[c])&&zn(n,c,e,i),t.push(n)}return Cn(r,e)};var ba=mn([[document]]);ba[0].parentNode=ma,d3.select=function(t){return"string"==typeof t?ba.select(t):mn([[t]])},d3.selectAll=function(t){return"string"==typeof t?ba.selectAll(t):mn([Yu(t)])};var xa=[];d3.selection.enter=qn,d3.selection.enter.prototype=xa,xa.append=Ma.append,xa.insert=Ma.insert,xa.empty=Ma.empty,xa.node=Ma.node,xa.select=function(t){for(var n,e,r,i,u,a=[],o=-1,c=this.length;c>++o;){r=(i=this[o]).update,a.push(n=[]),n.parentNode=i.parentNode;for(var l=-1,s=i.length;s>++l;)(u=i[l])?(n.push(r[l]=e=t.call(i.parentNode,u.__data__,l)),e.__data__=u.__data__):n.push(null)}return mn(a)};var _a,wa=[],Sa=0,ka={ease:T,delay:0,duration:250};wa.call=Ma.call,wa.empty=Ma.empty,wa.node=Ma.node,d3.transition=function(t){return arguments.length?_a?t.transition():t:ba.transition()},d3.transition.prototype=wa,wa.select=function(t){var n,e,r,i=this.id,u=[];"function"!=typeof t&&(t=vn(t));for(var a=-1,o=this.length;o>++a;){u.push(n=[]);for(var c=this[a],l=-1,s=c.length;s>++l;)(r=c[l])&&(e=t.call(r,r.__data__,l))?("__data__"in r&&(e.__data__=r.__data__),zn(e,l,i,r.__transition__[i]),n.push(e)):n.push(null)}return Cn(u,i)},wa.selectAll=function(t){var n,e,r,i,u,a=this.id,o=[];"function"!=typeof t&&(t=yn(t));for(var c=-1,l=this.length;l>++c;)for(var s=this[c],f=-1,h=s.length;h>++f;)if(r=s[f]){u=r.__transition__[a],e=t.call(r,r.__data__,f),o.push(n=[]);for(var d=-1,g=e.length;g>++d;)zn(i=e[d],d,a,u),n.push(i)}return Cn(o,a)},wa.filter=function(t){var n,e,r,i=[];"function"!=typeof t&&(t=En(t));for(var u=0,a=this.length;a>u;u++){i.push(n=[]);for(var e=this[u],o=0,c=e.length;c>o;o++)(r=e[o])&&t.call(r,r.__data__,o)&&n.push(r)}return Cn(i,this.id,this.time).ease(this.ease())},wa.attr=function(t,n){function e(){this.removeAttribute(u)}function r(){this.removeAttributeNS(u.space,u.local)}if(2>arguments.length){for(n in t)this.attr(n,t[n]);return this}var i=V(t),u=d3.ns.qualify(t);return Ln(this,"attr."+t,n,function(t){function n(){var n,e=this.getAttribute(u);return e!==t&&(n=i(e,t),function(t){this.setAttribute(u,n(t))})}function a(){var n,e=this.getAttributeNS(u.space,u.local);return e!==t&&(n=i(e,t),function(t){this.setAttributeNS(u.space,u.local,n(t))})}return null==t?u.local?r:e:(t+="",u.local?a:n)})},wa.attrTween=function(t,n){function e(t,e){var r=n.call(this,t,e,this.getAttribute(i));return r&&function(t){this.setAttribute(i,r(t))}}function r(t,e){var r=n.call(this,t,e,this.getAttributeNS(i.space,i.local));return r&&function(t){this.setAttributeNS(i.space,i.local,r(t))}}var i=d3.ns.qualify(t);return this.tween("attr."+t,i.local?r:e)},wa.style=function(t,n,e){function r(){this.style.removeProperty(t)}var i=arguments.length;if(3>i){if("string"!=typeof t){2>i&&(n="");for(e in t)this.style(e,t[e],n);return this}e=""}var u=V(t);return Ln(this,"style."+t,n,function(n){function i(){var r,i=getComputedStyle(this,null).getPropertyValue(t);return i!==n&&(r=u(i,n),function(n){this.style.setProperty(t,r(n),e)})}return null==n?r:(n+="",i)})},wa.styleTween=function(t,n,e){return 3>arguments.length&&(e=""),this.tween("style."+t,function(r,i){var u=n.call(this,r,i,getComputedStyle(this,null).getPropertyValue(t));return u&&function(n){this.style.setProperty(t,u(n),e)}})},wa.text=function(t){return Ln(this,"text",t,Dn)},wa.remove=function(){return this.each("end.transition",function(){var t;!this.__transition__&&(t=this.parentNode)&&t.removeChild(this)})},wa.ease=function(t){var n=this.id;return 1>arguments.length?this.node().__transition__[n].ease:("function"!=typeof t&&(t=d3.ease.apply(d3,arguments)),Tn(this,function(e){e.__transition__[n].ease=t}))},wa.delay=function(t){var n=this.id;return Tn(this,"function"==typeof t?function(e,r,i){e.__transition__[n].delay=0|t.call(e,e.__data__,r,i)}:(t|=0,function(e){e.__transition__[n].delay=t}))},wa.duration=function(t){var n=this.id;return Tn(this,"function"==typeof t?function(e,r,i){e.__transition__[n].duration=Math.max(1,0|t.call(e,e.__data__,r,i))}:(t=Math.max(1,0|t),function(e){e.__transition__[n].duration=t}))},wa.each=function(t,n){var e=this.id;if(2>arguments.length){var r=ka,i=_a;_a=e,Tn(this,function(n,r,i){ka=n.__transition__[e],t.call(n,n.__data__,r,i)}),ka=r,_a=i}else Tn(this,function(r){r.__transition__[e].event.on(t,n)});return this},wa.transition=function(){for(var t,n,e,r,i=this.id,u=++Sa,a=[],o=0,c=this.length;c>o;o++){a.push(t=[]);for(var n=this[o],l=0,s=n.length;s>l;l++)(e=n[l])&&(r=Object.create(e.__transition__[i]),r.delay+=r.duration,zn(e,l,u,r)),t.push(e)}return Cn(a,u)},wa.tween=function(t,n){var e=this.id;return 2>arguments.length?this.node().__transition__[e].tween.get(t):Tn(this,null==n?function(n){n.__transition__[e].tween.remove(t)}:function(r){r.__transition__[e].tween.set(t,n)})};var Ea,Aa,Na=0,Ta={},qa=null;d3.timer=function(t,n,e){if(3>arguments.length){if(2>arguments.length)n=0;else if(!isFinite(n))return;e=Date.now()}var r=Ta[t.id];r&&r.callback===t?(r.then=e,r.delay=n):Ta[t.id=++Na]=qa={callback:t,then:e,delay:n,next:qa},Ea||(Aa=clearTimeout(Aa),Ea=1,Ca(Fn))},d3.timer.flush=function(){for(var t,n=Date.now(),e=qa;e;)t=n-e.then,e.delay||(e.flush=e.callback(t)),e=e.next;Hn()};var Ca=window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){setTimeout(t,17)};d3.mouse=function(t){return Rn(t,P())};var za=/WebKit/.test(navigator.userAgent)?-1:0;d3.touches=function(t,n){return 2>arguments.length&&(n=P().touches),n?Yu(n).map(function(n){var e=Rn(t,n);return e.identifier=n.identifier,e}):[]},d3.scale={},d3.scale.linear=function(){return In([0,1],[0,1],d3.interpolate,!1)},d3.scale.log=function(){return Kn(d3.scale.linear(),Wn)};var Da=d3.format(".0e");Wn.pow=function(t){return Math.pow(10,t)},Qn.pow=function(t){return-Math.pow(10,-t)},d3.scale.pow=function(){return te(d3.scale.linear(),1)},d3.scale.sqrt=function(){return d3.scale.pow().exponent(.5)},d3.scale.ordinal=function(){return ee([],{t:"range",a:[[]]})},d3.scale.category10=function(){return d3.scale.ordinal().range(La)},d3.scale.category20=function(){return d3.scale.ordinal().range(Fa)},d3.scale.category20b=function(){return d3.scale.ordinal().range(Ha)},d3.scale.category20c=function(){return d3.scale.ordinal().range(Ra)};var La=["#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"],Fa=["#1f77b4","#aec7e8","#ff7f0e","#ffbb78","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5","#8c564b","#c49c94","#e377c2","#f7b6d2","#7f7f7f","#c7c7c7","#bcbd22","#dbdb8d","#17becf","#9edae5"],Ha=["#393b79","#5254a3","#6b6ecf","#9c9ede","#637939","#8ca252","#b5cf6b","#cedb9c","#8c6d31","#bd9e39","#e7ba52","#e7cb94","#843c39","#ad494a","#d6616b","#e7969c","#7b4173","#a55194","#ce6dbd","#de9ed6"],Ra=["#3182bd","#6baed6","#9ecae1","#c6dbef","#e6550d","#fd8d3c","#fdae6b","#fdd0a2","#31a354","#74c476","#a1d99b","#c7e9c0","#756bb1","#9e9ac8","#bcbddc","#dadaeb","#636363","#969696","#bdbdbd","#d9d9d9"];d3.scale.quantile=function(){return re([],[])},d3.scale.quantize=function(){return ie(0,1,[0,1])},d3.scale.threshold=function(){return ue([.5],[0,1])},d3.scale.identity=function(){return ae([0,1])},d3.svg={},d3.svg.arc=function(){function t(){var t=n.apply(this,arguments),u=e.apply(this,arguments),a=r.apply(this,arguments)+Pa,o=i.apply(this,arguments)+Pa,c=(a>o&&(c=a,a=o,o=c),o-a),l=Ru>c?"0":"1",s=Math.cos(a),f=Math.sin(a),h=Math.cos(o),d=Math.sin(o);return c>=ja?t?"M0,"+u+"A"+u+","+u+" 0 1,1 0,"+-u+"A"+u+","+u+" 0 1,1 0,"+u+"M0,"+t+"A"+t+","+t+" 0 1,0 0,"+-t+"A"+t+","+t+" 0 1,0 0,"+t+"Z":"M0,"+u+"A"+u+","+u+" 0 1,1 0,"+-u+"A"+u+","+u+" 0 1,1 0,"+u+"Z":t?"M"+u*s+","+u*f+"A"+u+","+u+" 0 "+l+",1 "+u*h+","+u*d+"L"+t*h+","+t*d+"A"+t+","+t+" 0 "+l+",0 "+t*s+","+t*f+"Z":"M"+u*s+","+u*f+"A"+u+","+u+" 0 "+l+",1 "+u*h+","+u*d+"L0,0"+"Z"}var n=oe,e=ce,r=le,i=se;return t.innerRadius=function(e){return arguments.length?(n=c(e),t):n},t.outerRadius=function(n){return arguments.length?(e=c(n),t):e},t.startAngle=function(n){return arguments.length?(r=c(n),t):r},t.endAngle=function(n){return arguments.length?(i=c(n),t):i},t.centroid=function(){var t=(n.apply(this,arguments)+e.apply(this,arguments))/2,u=(r.apply(this,arguments)+i.apply(this,arguments))/2+Pa;return[Math.cos(u)*t,Math.sin(u)*t]},t};var Pa=-Ru/2,ja=2*Ru-1e-6;d3.svg.line=function(){return fe(a)};var Oa=d3.map({linear:ge,"linear-closed":pe,"step-before":me,"step-after":ve,basis:we,"basis-open":Se,"basis-closed":ke,bundle:Ee,cardinal:be,"cardinal-open":ye,"cardinal-closed":Me,monotone:ze});Oa.forEach(function(t,n){n.key=t,n.closed=/-closed$/.test(t)});var Ya=[0,2/3,1/3,0],Ua=[0,1/3,2/3,0],Ia=[0,1/6,2/3,1/6];d3.svg.line.radial=function(){var t=fe(De);return t.radius=t.x,delete t.x,t.angle=t.y,delete t.y,t},me.reverse=ve,ve.reverse=me,d3.svg.area=function(){return Le(a)},d3.svg.area.radial=function(){var t=Le(De);return t.radius=t.x,delete t.x,t.innerRadius=t.x0,delete t.x0,t.outerRadius=t.x1,delete t.x1,t.angle=t.y,delete t.y,t.startAngle=t.y0,delete t.y0,t.endAngle=t.y1,delete t.y1,t},d3.svg.chord=function(){function e(t,n){var e=r(this,o,t,n),c=r(this,l,t,n);return"M"+e.p0+u(e.r,e.p1,e.a1-e.a0)+(i(e,c)?a(e.r,e.p1,e.r,e.p0):a(e.r,e.p1,c.r,c.p0)+u(c.r,c.p1,c.a1-c.a0)+a(c.r,c.p1,e.r,e.p0))+"Z"}function r(t,n,e,r){var i=n.call(t,e,r),u=s.call(t,i,r),a=f.call(t,i,r)+Pa,o=h.call(t,i,r)+Pa;return{r:u,a0:a,a1:o,p0:[u*Math.cos(a),u*Math.sin(a)],p1:[u*Math.cos(o),u*Math.sin(o)]}}function i(t,n){return t.a0==n.a0&&t.a1==n.a1}function u(t,n,e){return"A"+t+","+t+" 0 "+ +(e>Ru)+",1 "+n}function a(t,n,e,r){return"Q 0,0 "+r}var o=n,l=t,s=Fe,f=le,h=se;return e.radius=function(t){return arguments.length?(s=c(t),e):s},e.source=function(t){return arguments.length?(o=c(t),e):o},e.target=function(t){return arguments.length?(l=c(t),e):l},e.startAngle=function(t){return arguments.length?(f=c(t),e):f},e.endAngle=function(t){return arguments.length?(h=c(t),e):h},e},d3.svg.diagonal=function(){function e(t,n){var e=r.call(this,t,n),a=i.call(this,t,n),o=(e.y+a.y)/2,c=[e,{x:e.x,y:o},{x:a.x,y:o},a];return c=c.map(u),"M"+c[0]+"C"+c[1]+" "+c[2]+" "+c[3]}var r=n,i=t,u=He;return e.source=function(t){return arguments.length?(r=c(t),e):r},e.target=function(t){return arguments.length?(i=c(t),e):i},e.projection=function(t){return arguments.length?(u=t,e):u},e},d3.svg.diagonal.radial=function(){var t=d3.svg.diagonal(),n=He,e=t.projection;return t.projection=function(t){return arguments.length?e(Re(n=t)):n},t},d3.svg.symbol=function(){function t(t,r){return(Va.get(n.call(this,t,r))||Oe)(e.call(this,t,r))}var n=je,e=Pe;return t.type=function(e){return arguments.length?(n=c(e),t):n},t.size=function(n){return arguments.length?(e=c(n),t):e},t};var Va=d3.map({circle:Oe,cross:function(t){var n=Math.sqrt(t/5)/2;return"M"+-3*n+","+-n+"H"+-n+"V"+-3*n+"H"+n+"V"+-n+"H"+3*n+"V"+n+"H"+n+"V"+3*n+"H"+-n+"V"+n+"H"+-3*n+"Z"},diamond:function(t){var n=Math.sqrt(t/(2*Za)),e=n*Za;return"M0,"+-n+"L"+e+",0"+" 0,"+n+" "+-e+",0"+"Z"},square:function(t){var n=Math.sqrt(t)/2;return"M"+-n+","+-n+"L"+n+","+-n+" "+n+","+n+" "+-n+","+n+"Z"},"triangle-down":function(t){var n=Math.sqrt(t/Xa),e=n*Xa/2;return"M0,"+e+"L"+n+","+-e+" "+-n+","+-e+"Z"},"triangle-up":function(t){var n=Math.sqrt(t/Xa),e=n*Xa/2;return"M0,"+-e+"L"+n+","+e+" "+-n+","+e+"Z"}});d3.svg.symbolTypes=Va.keys();var Xa=Math.sqrt(3),Za=Math.tan(30*ju);d3.svg.axis=function(){function t(t){t.each(function(){var t,f=d3.select(this),h=null==l?e.ticks?e.ticks.apply(e,c):e.domain():l,d=null==n?e.tickFormat?e.tickFormat.apply(e,c):String:n,g=Ie(e,h,s),p=f.selectAll(".minor").data(g,String),m=p.enter().insert("line","g").attr("class","tick minor").style("opacity",1e-6),v=d3.transition(p.exit()).style("opacity",1e-6).remove(),y=d3.transition(p).style("opacity",1),M=f.selectAll("g").data(h,String),b=M.enter().insert("g","path").style("opacity",1e-6),x=d3.transition(M.exit()).style("opacity",1e-6).remove(),_=d3.transition(M).style("opacity",1),w=On(e),S=f.selectAll(".domain").data([0]),k=d3.transition(S),E=e.copy(),A=this.__chart__||E;this.__chart__=E,S.enter().append("path").attr("class","domain"),b.append("line").attr("class","tick"),b.append("text");var N=b.select("line"),T=_.select("line"),q=M.select("text").text(d),C=b.select("text"),z=_.select("text");switch(r){case"bottom":t=Ye,m.attr("y2",u),y.attr("x2",0).attr("y2",u),N.attr("y2",i),C.attr("y",Math.max(i,0)+o),T.attr("x2",0).attr("y2",i),z.attr("x",0).attr("y",Math.max(i,0)+o),q.attr("dy",".71em").style("text-anchor","middle"),k.attr("d","M"+w[0]+","+a+"V0H"+w[1]+"V"+a);break;case"top":t=Ye,m.attr("y2",-u),y.attr("x2",0).attr("y2",-u),N.attr("y2",-i),C.attr("y",-(Math.max(i,0)+o)),T.attr("x2",0).attr("y2",-i),z.attr("x",0).attr("y",-(Math.max(i,0)+o)),q.attr("dy","0em").style("text-anchor","middle"),k.attr("d","M"+w[0]+","+-a+"V0H"+w[1]+"V"+-a);break;case"left":t=Ue,m.attr("x2",-u),y.attr("x2",-u).attr("y2",0),N.attr("x2",-i),C.attr("x",-(Math.max(i,0)+o)),T.attr("x2",-i).attr("y2",0),z.attr("x",-(Math.max(i,0)+o)).attr("y",0),q.attr("dy",".32em").style("text-anchor","end"),k.attr("d","M"+-a+","+w[0]+"H0V"+w[1]+"H"+-a);break;case"right":t=Ue,m.attr("x2",u),y.attr("x2",u).attr("y2",0),N.attr("x2",i),C.attr("x",Math.max(i,0)+o),T.attr("x2",i).attr("y2",0),z.attr("x",Math.max(i,0)+o).attr("y",0),q.attr("dy",".32em").style("text-anchor","start"),k.attr("d","M"+a+","+w[0]+"H0V"+w[1]+"H"+a)}if(e.ticks)b.call(t,A),_.call(t,E),x.call(t,E),m.call(t,A),y.call(t,E),v.call(t,E);else{var D=E.rangeBand()/2,L=function(t){return E(t)+D};b.call(t,L),_.call(t,L)}})}var n,e=d3.scale.linear(),r="bottom",i=6,u=6,a=6,o=3,c=[10],l=null,s=0;return t.scale=function(n){return arguments.length?(e=n,t):e},t.orient=function(n){return arguments.length?(r=n,t):r},t.ticks=function(){return arguments.length?(c=arguments,t):c},t.tickValues=function(n){return arguments.length?(l=n,t):l},t.tickFormat=function(e){return arguments.length?(n=e,t):n},t.tickSize=function(n,e){if(!arguments.length)return i;var r=arguments.length-1;return i=+n,u=r>1?+e:i,a=r>0?+arguments[r]:i,t},t.tickPadding=function(n){return arguments.length?(o=+n,t):o},t.tickSubdivide=function(n){return arguments.length?(s=+n,t):s},t},d3.svg.brush=function(){function t(u){u.each(function(){var u,a=d3.select(this),s=a.selectAll(".background").data([0]),f=a.selectAll(".extent").data([0]),h=a.selectAll(".resize").data(l,String);a.style("pointer-events","all").on("mousedown.brush",i).on("touchstart.brush",i),s.enter().append("rect").attr("class","background").style("visibility","hidden").style("cursor","crosshair"),f.enter().append("rect").attr("class","extent").style("cursor","move"),h.enter().append("g").attr("class",function(t){return"resize "+t}).style("cursor",function(t){return Ba[t]}).append("rect").attr("x",function(t){return/[ew]$/.test(t)?-3:null}).attr("y",function(t){return/^[ns]/.test(t)?-3:null}).attr("width",6).attr("height",6).style("visibility","hidden"),h.style("display",t.empty()?"none":null),h.exit().remove(),o&&(u=On(o),s.attr("x",u[0]).attr("width",u[1]-u[0]),e(a)),c&&(u=On(c),s.attr("y",u[0]).attr("height",u[1]-u[0]),r(a)),n(a)})}function n(t){t.selectAll(".resize").attr("transform",function(t){return"translate("+s[+/e$/.test(t)][0]+","+s[+/^s/.test(t)][1]+")"})}function e(t){t.select(".extent").attr("x",s[0][0]),t.selectAll(".extent,.n>rect,.s>rect").attr("width",s[1][0]-s[0][0])}function r(t){t.select(".extent").attr("y",s[0][1]),t.selectAll(".extent,.e>rect,.w>rect").attr("height",s[1][1]-s[0][1])}function i(){function i(){var t=d3.event.changedTouches;return t?d3.touches(v,t)[0]:d3.mouse(v)}function l(){32==d3.event.keyCode&&(S||(p=null,k[0]-=s[1][0],k[1]-=s[1][1],S=2),R())}function f(){32==d3.event.keyCode&&2==S&&(k[0]+=s[1][0],k[1]+=s[1][1],S=0,R())}function h(){var t=i(),u=!1;m&&(t[0]+=m[0],t[1]+=m[1]),S||(d3.event.altKey?(p||(p=[(s[0][0]+s[1][0])/2,(s[0][1]+s[1][1])/2]),k[0]=s[+(t[0]<p[0])][0],k[1]=s[+(t[1]<p[1])][1]):p=null),_&&d(t,o,0)&&(e(b),u=!0),w&&d(t,c,1)&&(r(b),u=!0),u&&(n(b),M({type:"brush",mode:S?"move":"resize"}))}function d(t,n,e){var r,i,a=On(n),o=a[0],c=a[1],l=k[e],f=s[1][e]-s[0][e];return S&&(o-=l,c-=f+l),r=Math.max(o,Math.min(c,t[e])),S?i=(r+=l)+f:(p&&(l=Math.max(o,Math.min(c,2*p[e]-r))),r>l?(i=r,r=l):i=l),s[0][e]!==r||s[1][e]!==i?(u=null,s[0][e]=r,s[1][e]=i,!0):void 0}function g(){h(),b.style("pointer-events","all").selectAll(".resize").style("display",t.empty()?"none":null),d3.select("body").style("cursor",null),E.on("mousemove.brush",null).on("mouseup.brush",null).on("touchmove.brush",null).on("touchend.brush",null).on("keydown.brush",null).on("keyup.brush",null),M({type:"brushend"}),R()}var p,m,v=this,y=d3.select(d3.event.target),M=a.of(v,arguments),b=d3.select(v),x=y.datum(),_=!/^(n|s)$/.test(x)&&o,w=!/^(e|w)$/.test(x)&&c,S=y.classed("extent"),k=i(),E=d3.select(window).on("mousemove.brush",h).on("mouseup.brush",g).on("touchmove.brush",h).on("touchend.brush",g).on("keydown.brush",l).on("keyup.brush",f);if(S)k[0]=s[0][0]-k[0],k[1]=s[0][1]-k[1];else if(x){var A=+/w$/.test(x),N=+/^n/.test(x);m=[s[1-A][0]-k[0],s[1-N][1]-k[1]],k[0]=s[A][0],k[1]=s[N][1]}else d3.event.altKey&&(p=k.slice());b.style("pointer-events","none").selectAll(".resize").style("display",null),d3.select("body").style("cursor",y.style("cursor")),M({type:"brushstart"}),h(),R()}var u,a=j(t,"brushstart","brush","brushend"),o=null,c=null,l=$a[0],s=[[0,0],[0,0]];return t.x=function(n){return arguments.length?(o=n,l=$a[!o<<1|!c],t):o},t.y=function(n){return arguments.length?(c=n,l=$a[!o<<1|!c],t):c},t.extent=function(n){var e,r,i,a,l;return arguments.length?(u=[[0,0],[0,0]],o&&(e=n[0],r=n[1],c&&(e=e[0],r=r[0]),u[0][0]=e,u[1][0]=r,o.invert&&(e=o(e),r=o(r)),e>r&&(l=e,e=r,r=l),s[0][0]=0|e,s[1][0]=0|r),c&&(i=n[0],a=n[1],o&&(i=i[1],a=a[1]),u[0][1]=i,u[1][1]=a,c.invert&&(i=c(i),a=c(a)),i>a&&(l=i,i=a,a=l),s[0][1]=0|i,s[1][1]=0|a),t):(n=u||s,o&&(e=n[0][0],r=n[1][0],u||(e=s[0][0],r=s[1][0],o.invert&&(e=o.invert(e),r=o.invert(r)),e>r&&(l=e,e=r,r=l))),c&&(i=n[0][1],a=n[1][1],u||(i=s[0][1],a=s[1][1],c.invert&&(i=c.invert(i),a=c.invert(a)),i>a&&(l=i,i=a,a=l))),o&&c?[[e,i],[r,a]]:o?[e,r]:c&&[i,a])},t.clear=function(){return u=null,s[0][0]=s[0][1]=s[1][0]=s[1][1]=0,t},t.empty=function(){return o&&s[0][0]===s[1][0]||c&&s[0][1]===s[1][1]},d3.rebind(t,a,"on")};var Ba={n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"},$a=[["n","e","s","w","nw","ne","se","sw"],["e","w"],["n","s"],[]];d3.behavior={},d3.behavior.drag=function(){function t(){this.on("mousedown.drag",n).on("touchstart.drag",n)}function n(){function t(){var t=o.parentNode;return null!=s?d3.touches(t).filter(function(t){return t.identifier===s})[0]:d3.mouse(t)}function n(){if(!o.parentNode)return i();var n=t(),e=n[0]-f[0],r=n[1]-f[1];h|=e|r,f=n,R(),c({type:"drag",x:n[0]+a[0],y:n[1]+a[1],dx:e,dy:r})}function i(){c({type:"dragend"}),h&&(R(),d3.event.target===l&&d.on("click.drag",u,!0)),d.on(null!=s?"touchmove.drag-"+s:"mousemove.drag",null).on(null!=s?"touchend.drag-"+s:"mouseup.drag",null)}function u(){R(),d.on("click.drag",null)}var a,o=this,c=e.of(o,arguments),l=d3.event.target,s=d3.event.touches?d3.event.changedTouches[0].identifier:null,f=t(),h=0,d=d3.select(window).on(null!=s?"touchmove.drag-"+s:"mousemove.drag",n).on(null!=s?"touchend.drag-"+s:"mouseup.drag",i,!0);r?(a=r.apply(o,arguments),a=[a.x-f[0],a.y-f[1]]):a=[0,0],null==s&&R(),c({type:"dragstart"})}var e=j(t,"drag","dragstart","dragend"),r=null;return t.origin=function(n){return arguments.length?(r=n,t):r},d3.rebind(t,e,"on")},d3.behavior.zoom=function(){function t(){this.on("mousedown.zoom",o).on("mousewheel.zoom",c).on("mousemove.zoom",l).on("DOMMouseScroll.zoom",c).on("dblclick.zoom",s).on("touchstart.zoom",f).on("touchmove.zoom",h).on("touchend.zoom",f)}function n(t){return[(t[0]-b[0])/x,(t[1]-b[1])/x]}function e(t){return[t[0]*x+b[0],t[1]*x+b[1]]}function r(t){x=Math.max(_[0],Math.min(_[1],t))}function i(t,n){n=e(n),b[0]+=t[0]-n[0],b[1]+=t[1]-n[1]}function u(){m&&m.domain(p.range().map(function(t){return(t-b[0])/x}).map(p.invert)),y&&y.domain(v.range().map(function(t){return(t-b[1])/x}).map(v.invert))}function a(t){u(),d3.event.preventDefault(),t({type:"zoom",scale:x,translate:b})}function o(){function t(){l=1,i(d3.mouse(u),f),a(o)}function e(){l&&R(),s.on("mousemove.zoom",null).on("mouseup.zoom",null),l&&d3.event.target===c&&s.on("click.zoom",r,!0)}function r(){R(),s.on("click.zoom",null)}var u=this,o=w.of(u,arguments),c=d3.event.target,l=0,s=d3.select(window).on("mousemove.zoom",t).on("mouseup.zoom",e),f=n(d3.mouse(u));window.focus(),R()}function c(){d||(d=n(d3.mouse(this))),r(Math.pow(2,.002*Ve())*x),i(d3.mouse(this),d),a(w.of(this,arguments))}function l(){d=null}function s(){var t=d3.mouse(this),e=n(t),u=Math.log(x)/Math.LN2;r(Math.pow(2,d3.event.shiftKey?Math.ceil(u)-1:Math.floor(u)+1)),i(t,e),a(w.of(this,arguments))}function f(){var t=d3.touches(this),e=Date.now();if(g=x,d={},t.forEach(function(t){d[t.identifier]=n(t)}),R(),1===t.length){if(500>e-M){var u=t[0],o=n(t[0]);r(2*x),i(u,o),a(w.of(this,arguments))}M=e}}function h(){var t=d3.touches(this),n=t[0],e=d[n.identifier];if(u=t[1]){var u,o=d[u.identifier];n=[(n[0]+u[0])/2,(n[1]+u[1])/2],e=[(e[0]+o[0])/2,(e[1]+o[1])/2],r(d3.event.scale*g)}i(n,e),M=null,a(w.of(this,arguments))}var d,g,p,m,v,y,M,b=[0,0],x=1,_=Ga,w=j(t,"zoom");return t.translate=function(n){return arguments.length?(b=n.map(Number),u(),t):b},t.scale=function(n){return arguments.length?(x=+n,u(),t):x},t.scaleExtent=function(n){return arguments.length?(_=null==n?Ga:n.map(Number),t):_},t.x=function(n){return arguments.length?(m=n,p=n.copy(),b=[0,0],x=1,t):m},t.y=function(n){return arguments.length?(y=n,v=n.copy(),b=[0,0],x=1,t):y},d3.rebind(t,w,"on")};var Ja,Ga=[0,1/0];d3.layout={},d3.layout.bundle=function(){return function(t){for(var n=[],e=-1,r=t.length;r>++e;)n.push(Xe(t[e]));return n}},d3.layout.chord=function(){function t(){var t,l,f,h,d,g={},p=[],m=d3.range(u),v=[];for(e=[],r=[],t=0,h=-1;u>++h;){for(l=0,d=-1;u>++d;)l+=i[h][d];p.push(l),v.push(d3.range(u)),t+=l}for(a&&m.sort(function(t,n){return a(p[t],p[n])}),o&&v.forEach(function(t,n){t.sort(function(t,e){return o(i[n][t],i[n][e])
-})}),t=(2*Ru-s*u)/t,l=0,h=-1;u>++h;){for(f=l,d=-1;u>++d;){var y=m[h],M=v[y][d],b=i[y][M],x=l,_=l+=b*t;g[y+"-"+M]={index:y,subindex:M,startAngle:x,endAngle:_,value:b}}r[y]={index:y,startAngle:f,endAngle:l,value:(l-f)/t},l+=s}for(h=-1;u>++h;)for(d=h-1;u>++d;){var w=g[h+"-"+d],S=g[d+"-"+h];(w.value||S.value)&&e.push(w.value<S.value?{source:S,target:w}:{source:w,target:S})}c&&n()}function n(){e.sort(function(t,n){return c((t.source.value+t.target.value)/2,(n.source.value+n.target.value)/2)})}var e,r,i,u,a,o,c,l={},s=0;return l.matrix=function(t){return arguments.length?(u=(i=t)&&i.length,e=r=null,l):i},l.padding=function(t){return arguments.length?(s=t,e=r=null,l):s},l.sortGroups=function(t){return arguments.length?(a=t,e=r=null,l):a},l.sortSubgroups=function(t){return arguments.length?(o=t,e=null,l):o},l.sortChords=function(t){return arguments.length?(c=t,e&&n(),l):c},l.chords=function(){return e||t(),e},l.groups=function(){return r||t(),r},l},d3.layout.force=function(){function t(t){return function(n,e,r,i){if(n.point!==t){var u=n.cx-t.x,a=n.cy-t.y,o=1/Math.sqrt(u*u+a*a);if(v>(i-e)*o){var c=n.charge*o*o;return t.px-=u*c,t.py-=a*c,!0}if(n.point&&isFinite(o)){var c=n.pointCharge*o*o;t.px-=u*c,t.py-=a*c}}return!n.charge}}function n(t){t.px=d3.event.x,t.py=d3.event.y,l.resume()}var e,r,i,u,o,l={},s=d3.dispatch("start","tick","end"),f=[1,1],h=.9,d=Qe,g=tr,p=-30,m=.1,v=.8,y=[],M=[];return l.tick=function(){if(.005>(r*=.99))return s.end({type:"end",alpha:r=0}),!0;var n,e,a,c,l,d,g,v,b,x=y.length,_=M.length;for(e=0;_>e;++e)a=M[e],c=a.source,l=a.target,v=l.x-c.x,b=l.y-c.y,(d=v*v+b*b)&&(d=r*u[e]*((d=Math.sqrt(d))-i[e])/d,v*=d,b*=d,l.x-=v*(g=c.weight/(l.weight+c.weight)),l.y-=b*g,c.x+=v*(g=1-g),c.y+=b*g);if((g=r*m)&&(v=f[0]/2,b=f[1]/2,e=-1,g))for(;x>++e;)a=y[e],a.x+=(v-a.x)*g,a.y+=(b-a.y)*g;if(p)for(We(n=d3.geom.quadtree(y),r,o),e=-1;x>++e;)(a=y[e]).fixed||n.visit(t(a));for(e=-1;x>++e;)a=y[e],a.fixed?(a.x=a.px,a.y=a.py):(a.x-=(a.px-(a.px=a.x))*h,a.y-=(a.py-(a.py=a.y))*h);s.tick({type:"tick",alpha:r})},l.nodes=function(t){return arguments.length?(y=t,l):y},l.links=function(t){return arguments.length?(M=t,l):M},l.size=function(t){return arguments.length?(f=t,l):f},l.linkDistance=function(t){return arguments.length?(d=c(t),l):d},l.distance=l.linkDistance,l.linkStrength=function(t){return arguments.length?(g=c(t),l):g},l.friction=function(t){return arguments.length?(h=t,l):h},l.charge=function(t){return arguments.length?(p="function"==typeof t?t:+t,l):p},l.gravity=function(t){return arguments.length?(m=t,l):m},l.theta=function(t){return arguments.length?(v=t,l):v},l.alpha=function(t){return arguments.length?(r?r=t>0?t:0:t>0&&(s.start({type:"start",alpha:r=t}),d3.timer(l.tick)),l):r},l.start=function(){function t(t,r){for(var i,u=n(e),a=-1,o=u.length;o>++a;)if(!isNaN(i=u[a][t]))return i;return Math.random()*r}function n(){if(!a){for(a=[],r=0;s>r;++r)a[r]=[];for(r=0;h>r;++r){var t=M[r];a[t.source.index].push(t.target),a[t.target.index].push(t.source)}}return a[e]}var e,r,a,c,s=y.length,h=M.length,m=f[0],v=f[1];for(e=0;s>e;++e)(c=y[e]).index=e,c.weight=0;for(i=[],u=[],e=0;h>e;++e)c=M[e],"number"==typeof c.source&&(c.source=y[c.source]),"number"==typeof c.target&&(c.target=y[c.target]),i[e]=d.call(this,c,e),u[e]=g.call(this,c,e),++c.source.weight,++c.target.weight;for(e=0;s>e;++e)c=y[e],isNaN(c.x)&&(c.x=t("x",m)),isNaN(c.y)&&(c.y=t("y",v)),isNaN(c.px)&&(c.px=c.x),isNaN(c.py)&&(c.py=c.y);if(o=[],"function"==typeof p)for(e=0;s>e;++e)o[e]=+p.call(this,y[e],e);else for(e=0;s>e;++e)o[e]=p;return l.resume()},l.resume=function(){return l.alpha(.1)},l.stop=function(){return l.alpha(0)},l.drag=function(){e||(e=d3.behavior.drag().origin(a).on("dragstart",$e).on("drag",n).on("dragend",Je)),this.on("mouseover.force",Ge).on("mouseout.force",Ke).call(e)},d3.rebind(l,s,"on")},d3.layout.partition=function(){function t(n,e,r,i){var u=n.children;if(n.x=e,n.y=n.depth*i,n.dx=r,n.dy=i,u&&(a=u.length)){var a,o,c,l=-1;for(r=n.value?r/n.value:0;a>++l;)t(o=u[l],e,c=o.value*r,i),e+=c}}function n(t){var e=t.children,r=0;if(e&&(i=e.length))for(var i,u=-1;i>++u;)r=Math.max(r,n(e[u]));return 1+r}function e(e,u){var a=r.call(this,e,u);return t(a[0],0,i[0],i[1]/n(a[0])),a}var r=d3.layout.hierarchy(),i=[1,1];return e.size=function(t){return arguments.length?(i=t,e):i},hr(e,r)},d3.layout.pie=function(){function t(u){var a=u.map(function(e,r){return+n.call(t,e,r)}),o=+("function"==typeof r?r.apply(this,arguments):r),c=(("function"==typeof i?i.apply(this,arguments):i)-r)/d3.sum(a),l=d3.range(u.length);null!=e&&l.sort(e===Ka?function(t,n){return a[n]-a[t]}:function(t,n){return e(u[t],u[n])});var s=[];return l.forEach(function(t){var n;s[t]={data:u[t],value:n=a[t],startAngle:o,endAngle:o+=n*c}}),s}var n=Number,e=Ka,r=0,i=2*Ru;return t.value=function(e){return arguments.length?(n=e,t):n},t.sort=function(n){return arguments.length?(e=n,t):e},t.startAngle=function(n){return arguments.length?(r=n,t):r},t.endAngle=function(n){return arguments.length?(i=n,t):i},t};var Ka={};d3.layout.stack=function(){function t(a,c){var l=a.map(function(e,r){return n.call(t,e,r)}),s=l.map(function(n){return n.map(function(n,e){return[u.call(t,n,e),o.call(t,n,e)]})}),f=e.call(t,s,c);l=d3.permute(l,f),s=d3.permute(s,f);var h,d,g,p=r.call(t,s,c),m=l.length,v=l[0].length;for(d=0;v>d;++d)for(i.call(t,l[0][d],g=p[d],s[0][d][1]),h=1;m>h;++h)i.call(t,l[h][d],g+=s[h-1][d][1],s[h][d][1]);return a}var n=a,e=ir,r=ur,i=rr,u=nr,o=er;return t.values=function(e){return arguments.length?(n=e,t):n},t.order=function(n){return arguments.length?(e="function"==typeof n?n:Wa.get(n)||ir,t):e},t.offset=function(n){return arguments.length?(r="function"==typeof n?n:Qa.get(n)||ur,t):r},t.x=function(n){return arguments.length?(u=n,t):u},t.y=function(n){return arguments.length?(o=n,t):o},t.out=function(n){return arguments.length?(i=n,t):i},t};var Wa=d3.map({"inside-out":function(t){var n,e,r=t.length,i=t.map(ar),u=t.map(or),a=d3.range(r).sort(function(t,n){return i[t]-i[n]}),o=0,c=0,l=[],s=[];for(n=0;r>n;++n)e=a[n],c>o?(o+=u[e],l.push(e)):(c+=u[e],s.push(e));return s.reverse().concat(l)},reverse:function(t){return d3.range(t.length).reverse()},"default":ir}),Qa=d3.map({silhouette:function(t){var n,e,r,i=t.length,u=t[0].length,a=[],o=0,c=[];for(e=0;u>e;++e){for(n=0,r=0;i>n;n++)r+=t[n][e][1];r>o&&(o=r),a.push(r)}for(e=0;u>e;++e)c[e]=(o-a[e])/2;return c},wiggle:function(t){var n,e,r,i,u,a,o,c,l,s=t.length,f=t[0],h=f.length,d=[];for(d[0]=c=l=0,e=1;h>e;++e){for(n=0,i=0;s>n;++n)i+=t[n][e][1];for(n=0,u=0,o=f[e][0]-f[e-1][0];s>n;++n){for(r=0,a=(t[n][e][1]-t[n][e-1][1])/(2*o);n>r;++r)a+=(t[r][e][1]-t[r][e-1][1])/o;u+=a*t[n][e][1]}d[e]=c-=i?u/i*o:0,l>c&&(l=c)}for(e=0;h>e;++e)d[e]-=l;return d},expand:function(t){var n,e,r,i=t.length,u=t[0].length,a=1/i,o=[];for(e=0;u>e;++e){for(n=0,r=0;i>n;n++)r+=t[n][e][1];if(r)for(n=0;i>n;n++)t[n][e][1]/=r;else for(n=0;i>n;n++)t[n][e][1]=a}for(e=0;u>e;++e)o[e]=0;return o},zero:ur});d3.layout.histogram=function(){function t(t,u){for(var a,o,c=[],l=t.map(e,this),s=r.call(this,l,u),f=i.call(this,s,l,u),u=-1,h=l.length,d=f.length-1,g=n?1:1/h;d>++u;)a=c[u]=[],a.dx=f[u+1]-(a.x=f[u]),a.y=0;if(d>0)for(u=-1;h>++u;)o=l[u],o>=s[0]&&s[1]>=o&&(a=c[d3.bisect(f,o,1,d)-1],a.y+=g,a.push(t[u]));return c}var n=!0,e=Number,r=fr,i=lr;return t.value=function(n){return arguments.length?(e=n,t):e},t.range=function(n){return arguments.length?(r=c(n),t):r},t.bins=function(n){return arguments.length?(i="number"==typeof n?function(t){return sr(t,n)}:c(n),t):i},t.frequency=function(e){return arguments.length?(n=!!e,t):n},t},d3.layout.hierarchy=function(){function t(n,a,o){var c=i.call(e,n,a);if(n.depth=a,o.push(n),c&&(l=c.length)){for(var l,s,f=-1,h=n.children=[],d=0,g=a+1;l>++f;)s=t(c[f],g,o),s.parent=n,h.push(s),d+=s.value;r&&h.sort(r),u&&(n.value=d)}else u&&(n.value=+u.call(e,n,a)||0);return n}function n(t,r){var i=t.children,a=0;if(i&&(o=i.length))for(var o,c=-1,l=r+1;o>++c;)a+=n(i[c],l);else u&&(a=+u.call(e,t,r)||0);return u&&(t.value=a),a}function e(n){var e=[];return t(n,0,e),e}var r=pr,i=dr,u=gr;return e.sort=function(t){return arguments.length?(r=t,e):r},e.children=function(t){return arguments.length?(i=t,e):i},e.value=function(t){return arguments.length?(u=t,e):u},e.revalue=function(t){return n(t,0),t},e},d3.layout.pack=function(){function t(t,i){var u=n.call(this,t,i),a=u[0];a.x=0,a.y=0,Rr(a,function(t){t.r=Math.sqrt(t.value)}),Rr(a,xr);var o=r[0],c=r[1],l=Math.max(2*a.r/o,2*a.r/c);if(e>0){var s=e*l/2;Rr(a,function(t){t.r+=s}),Rr(a,xr),Rr(a,function(t){t.r-=s}),l=Math.max(2*a.r/o,2*a.r/c)}return Sr(a,o/2,c/2,1/l),u}var n=d3.layout.hierarchy().sort(vr),e=0,r=[1,1];return t.size=function(n){return arguments.length?(r=n,t):r},t.padding=function(n){return arguments.length?(e=+n,t):e},hr(t,n)},d3.layout.cluster=function(){function t(t,i){var u,a=n.call(this,t,i),o=a[0],c=0;Rr(o,function(t){var n=t.children;n&&n.length?(t.x=Ar(n),t.y=Er(n)):(t.x=u?c+=e(t,u):0,t.y=0,u=t)});var l=Nr(o),s=Tr(o),f=l.x-e(l,s)/2,h=s.x+e(s,l)/2;return Rr(o,function(t){t.x=(t.x-f)/(h-f)*r[0],t.y=(1-(o.y?t.y/o.y:1))*r[1]}),a}var n=d3.layout.hierarchy().sort(null).value(null),e=qr,r=[1,1];return t.separation=function(n){return arguments.length?(e=n,t):e},t.size=function(n){return arguments.length?(r=n,t):r},hr(t,n)},d3.layout.tree=function(){function t(t,i){function u(t,n){var r=t.children,i=t._tree;if(r&&(a=r.length)){for(var a,c,l,s=r[0],f=s,h=-1;a>++h;)l=r[h],u(l,c),f=o(l,c,f),c=l;Pr(t);var d=.5*(s._tree.prelim+l._tree.prelim);n?(i.prelim=n._tree.prelim+e(t,n),i.mod=i.prelim-d):i.prelim=d}else n&&(i.prelim=n._tree.prelim+e(t,n))}function a(t,n){t.x=t._tree.prelim+n;var e=t.children;if(e&&(r=e.length)){var r,i=-1;for(n+=t._tree.mod;r>++i;)a(e[i],n)}}function o(t,n,r){if(n){for(var i,u=t,a=t,o=n,c=t.parent.children[0],l=u._tree.mod,s=a._tree.mod,f=o._tree.mod,h=c._tree.mod;o=zr(o),u=Cr(u),o&&u;)c=Cr(c),a=zr(a),a._tree.ancestor=t,i=o._tree.prelim+f-u._tree.prelim-l+e(o,u),i>0&&(jr(Or(o,t,r),t,i),l+=i,s+=i),f+=o._tree.mod,l+=u._tree.mod,h+=c._tree.mod,s+=a._tree.mod;o&&!zr(a)&&(a._tree.thread=o,a._tree.mod+=f-s),u&&!Cr(c)&&(c._tree.thread=u,c._tree.mod+=l-h,r=t)}return r}var c=n.call(this,t,i),l=c[0];Rr(l,function(t,n){t._tree={ancestor:t,prelim:0,mod:0,change:0,shift:0,number:n?n._tree.number+1:0}}),u(l),a(l,-l._tree.prelim);var s=Dr(l,Fr),f=Dr(l,Lr),h=Dr(l,Hr),d=s.x-e(s,f)/2,g=f.x+e(f,s)/2,p=h.depth||1;return Rr(l,function(t){t.x=(t.x-d)/(g-d)*r[0],t.y=t.depth/p*r[1],delete t._tree}),c}var n=d3.layout.hierarchy().sort(null).value(null),e=qr,r=[1,1];return t.separation=function(n){return arguments.length?(e=n,t):e},t.size=function(n){return arguments.length?(r=n,t):r},hr(t,n)},d3.layout.treemap=function(){function t(t,n){for(var e,r,i=-1,u=t.length;u>++i;)r=(e=t[i]).value*(0>n?0:n),e.area=isNaN(r)||0>=r?0:r}function n(e){var u=e.children;if(u&&u.length){var a,o,c,l=f(e),s=[],h=u.slice(),g=1/0,p="slice"===d?l.dx:"dice"===d?l.dy:"slice-dice"===d?1&e.depth?l.dy:l.dx:Math.min(l.dx,l.dy);for(t(h,l.dx*l.dy/e.value),s.area=0;(c=h.length)>0;)s.push(a=h[c-1]),s.area+=a.area,"squarify"!==d||g>=(o=r(s,p))?(h.pop(),g=o):(s.area-=s.pop().area,i(s,p,l,!1),p=Math.min(l.dx,l.dy),s.length=s.area=0,g=1/0);s.length&&(i(s,p,l,!0),s.length=s.area=0),u.forEach(n)}}function e(n){var r=n.children;if(r&&r.length){var u,a=f(n),o=r.slice(),c=[];for(t(o,a.dx*a.dy/n.value),c.area=0;u=o.pop();)c.push(u),c.area+=u.area,null!=u.z&&(i(c,u.z?a.dx:a.dy,a,!o.length),c.length=c.area=0);r.forEach(e)}}function r(t,n){for(var e,r=t.area,i=0,u=1/0,a=-1,o=t.length;o>++a;)(e=t[a].area)&&(u>e&&(u=e),e>i&&(i=e));return r*=r,n*=n,r?Math.max(n*i*g/r,r/(n*u*g)):1/0}function i(t,n,e,r){var i,u=-1,a=t.length,o=e.x,l=e.y,s=n?c(t.area/n):0;if(n==e.dx){for((r||s>e.dy)&&(s=e.dy);a>++u;)i=t[u],i.x=o,i.y=l,i.dy=s,o+=i.dx=Math.min(e.x+e.dx-o,s?c(i.area/s):0);i.z=!0,i.dx+=e.x+e.dx-o,e.y+=s,e.dy-=s}else{for((r||s>e.dx)&&(s=e.dx);a>++u;)i=t[u],i.x=o,i.y=l,i.dx=s,l+=i.dy=Math.min(e.y+e.dy-l,s?c(i.area/s):0);i.z=!1,i.dy+=e.y+e.dy-l,e.x+=s,e.dx-=s}}function u(r){var i=a||o(r),u=i[0];return u.x=0,u.y=0,u.dx=l[0],u.dy=l[1],a&&o.revalue(u),t([u],u.dx*u.dy/u.value),(a?e:n)(u),h&&(a=i),i}var a,o=d3.layout.hierarchy(),c=Math.round,l=[1,1],s=null,f=Yr,h=!1,d="squarify",g=.5*(1+Math.sqrt(5));return u.size=function(t){return arguments.length?(l=t,u):l},u.padding=function(t){function n(n){var e=t.call(u,n,n.depth);return null==e?Yr(n):Ur(n,"number"==typeof e?[e,e,e,e]:e)}function e(n){return Ur(n,t)}if(!arguments.length)return s;var r;return f=null==(s=t)?Yr:"function"==(r=typeof t)?n:"number"===r?(t=[t,t,t,t],e):e,u},u.round=function(t){return arguments.length?(c=t?Math.round:Number,u):c!=Number},u.sticky=function(t){return arguments.length?(h=t,a=null,u):h},u.ratio=function(t){return arguments.length?(g=t,u):g},u.mode=function(t){return arguments.length?(d=t+"",u):d},hr(u,o)},d3.csv=Ir(",","text/csv"),d3.tsv=Ir("        ","text/tab-separated-values"),d3.geo={},d3.geo.stream=function(t,n){to.hasOwnProperty(t.type)?to[t.type](t,n):Vr(t,n)};var to={Feature:function(t,n){Vr(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;i>++r;)Vr(e[r].geometry,n)}},no={Sphere:function(t,n){n.sphere()},Point:function(t,n){var e=t.coordinates;n.point(e[0],e[1])},MultiPoint:function(t,n){for(var e,r=t.coordinates,i=-1,u=r.length;u>++i;)e=r[i],n.point(e[0],e[1])},LineString:function(t,n){Xr(t.coordinates,n,0)},MultiLineString:function(t,n){for(var e=t.coordinates,r=-1,i=e.length;i>++r;)Xr(e[r],n,0)},Polygon:function(t,n){Zr(t.coordinates,n)},MultiPolygon:function(t,n){for(var e=t.coordinates,r=-1,i=e.length;i>++r;)Zr(e[r],n)},GeometryCollection:function(t,n){for(var e=t.geometries,r=-1,i=e.length;i>++r;)Vr(e[r],n)}};d3.geo.albersUsa=function(){function t(t){return n(t)(t)}function n(t){var n=t[0],a=t[1];return a>50?r:-140>n?i:21>a?u:e}var e=d3.geo.albers(),r=d3.geo.albers().rotate([160,0]).center([0,60]).parallels([55,65]),i=d3.geo.albers().rotate([160,0]).center([0,20]).parallels([8,18]),u=d3.geo.albers().rotate([60,0]).center([0,10]).parallels([8,18]);return t.scale=function(n){return arguments.length?(e.scale(n),r.scale(.6*n),i.scale(n),u.scale(1.5*n),t.translate(e.translate())):e.scale()},t.translate=function(n){if(!arguments.length)return e.translate();var a=e.scale(),o=n[0],c=n[1];return e.translate(n),r.translate([o-.4*a,c+.17*a]),i.translate([o-.19*a,c+.2*a]),u.translate([o+.58*a,c+.43*a]),t},t.scale(e.scale())},(d3.geo.albers=function(){var t=29.5*ju,n=45.5*ju,e=Pi(ei),r=e(t,n);return r.parallels=function(r){return arguments.length?e(t=r[0]*ju,n=r[1]*ju):[t*Ou,n*Ou]},r.rotate([98,0]).center([0,38]).scale(1e3)}).raw=ei;var eo=Vi(function(t){return Math.sqrt(2/(1+t))},function(t){return 2*Math.asin(t/2)});(d3.geo.azimuthalEqualArea=function(){return Ri(eo)}).raw=eo;var ro=Vi(function(t){var n=Math.acos(t);return n&&n/Math.sin(n)},a);(d3.geo.azimuthalEquidistant=function(){return Ri(ro)}).raw=ro,d3.geo.bounds=ri(a),d3.geo.centroid=function(t){io=uo=ao=oo=co=0,d3.geo.stream(t,lo);var n;return uo&&Math.abs(n=Math.sqrt(ao*ao+oo*oo+co*co))>Pu?[Math.atan2(oo,ao)*Ou,Math.asin(Math.max(-1,Math.min(1,co/n)))*Ou]:void 0};var io,uo,ao,oo,co,lo={sphere:function(){2>io&&(io=2,uo=ao=oo=co=0)},point:ii,lineStart:ai,lineEnd:oi,polygonStart:function(){2>io&&(io=2,uo=ao=oo=co=0),lo.lineStart=ui},polygonEnd:function(){lo.lineStart=ai}};d3.geo.circle=function(){function t(){var t="function"==typeof r?r.apply(this,arguments):r,n=Oi(-t[0]*ju,-t[1]*ju,0).invert,i=[];return e(null,null,1,{point:function(t,e){i.push(t=n(t,e)),t[0]*=Ou,t[1]*=Ou}}),{type:"Polygon",coordinates:[i]}}var n,e,r=[0,0],i=6;return t.origin=function(n){return arguments.length?(r=n,t):r},t.angle=function(r){return arguments.length?(e=ci((n=+r)*ju,i*ju),t):n},t.precision=function(r){return arguments.length?(e=ci(n*ju,(i=+r)*ju),t):i},t.angle(90)};var so=si(o,vi,Mi);(d3.geo.equirectangular=function(){return Ri(_i).scale(250/Ru)}).raw=_i.invert=_i;var fo=Vi(function(t){return 1/t},Math.atan);(d3.geo.gnomonic=function(){return Ri(fo)}).raw=fo,d3.geo.graticule=function(){function t(){return{type:"MultiLineString",coordinates:n()}}function n(){return d3.range(Math.ceil(r/c)*c,e,c).map(a).concat(d3.range(Math.ceil(u/l)*l,i,l).map(o))}var e,r,i,u,a,o,c=22.5,l=c,s=2.5;return t.lines=function(){return n().map(function(t){return{type:"LineString",coordinates:t}})},t.outline=function(){return{type:"Polygon",coordinates:[a(r).concat(o(i).slice(1),a(e).reverse().slice(1),o(u).reverse().slice(1))]}},t.extent=function(n){return arguments.length?(r=+n[0][0],e=+n[1][0],u=+n[0][1],i=+n[1][1],r>e&&(n=r,r=e,e=n),u>i&&(n=u,u=i,i=n),t.precision(s)):[[r,u],[e,i]]},t.step=function(n){return arguments.length?(c=+n[0],l=+n[1],t):[c,l]},t.precision=function(n){return arguments.length?(s=+n,a=wi(u,i,s),o=Si(r,e,s),t):s},t.extent([[-180+Pu,-90+Pu],[180-Pu,90-Pu]])},d3.geo.interpolate=function(t,n){return ki(t[0]*ju,t[1]*ju,n[0]*ju,n[1]*ju)},d3.geo.greatArc=function(){function e(){for(var t=r||a.apply(this,arguments),n=i||o.apply(this,arguments),e=u||d3.geo.interpolate(t,n),l=0,s=c/e.distance,f=[t];1>(l+=s);)f.push(e(l));return f.push(n),{type:"LineString",coordinates:f}}var r,i,u,a=n,o=t,c=6*ju;return e.distance=function(){return(u||d3.geo.interpolate(r||a.apply(this,arguments),i||o.apply(this,arguments))).distance},e.source=function(t){return arguments.length?(a=t,r="function"==typeof t?null:t,u=r&&i?d3.geo.interpolate(r,i):null,e):a},e.target=function(t){return arguments.length?(o=t,i="function"==typeof t?null:t,u=r&&i?d3.geo.interpolate(r,i):null,e):o},e.precision=function(t){return arguments.length?(c=t*ju,e):c/ju},e},Ei.invert=function(t,n){return[2*Ru*t,2*Math.atan(Math.exp(2*Ru*n))-Ru/2]},(d3.geo.mercator=function(){return Ri(Ei).scale(500)}).raw=Ei;var ho=Vi(function(){return 1},Math.asin);(d3.geo.orthographic=function(){return Ri(ho)}).raw=ho,d3.geo.path=function(){function t(t){return t&&d3.geo.stream(t,r(i.pointRadius("function"==typeof u?+u.apply(this,arguments):u))),i.result()}var n,e,r,i,u=4.5;return t.area=function(t){return go=0,d3.geo.stream(t,r(mo)),go},t.centroid=function(t){return io=ao=oo=co=0,d3.geo.stream(t,r(vo)),co?[ao/co,oo/co]:void 0},t.bounds=function(t){return ri(r)(t)},t.projection=function(e){return arguments.length?(r=(n=e)?e.stream||Ni(e):a,t):n},t.context=function(n){return arguments.length?(i=null==(e=n)?new Ti:new qi(n),t):e},t.pointRadius=function(n){return arguments.length?(u="function"==typeof n?n:+n,t):u},t.projection(d3.geo.albersUsa()).context(null)};var go,po,mo={point:Pn,lineStart:Pn,lineEnd:Pn,polygonStart:function(){po=0,mo.lineStart=Ci},polygonEnd:function(){mo.lineStart=mo.lineEnd=mo.point=Pn,go+=Math.abs(po/2)}},vo={point:zi,lineStart:Di,lineEnd:Li,polygonStart:function(){vo.lineStart=Fi},polygonEnd:function(){vo.point=zi,vo.lineStart=Di,vo.lineEnd=Li}};d3.geo.area=function(t){return yo=0,d3.geo.stream(t,bo),yo};var yo,Mo,bo={sphere:function(){yo+=4*Ru},point:Pn,lineStart:Pn,lineEnd:Pn,polygonStart:function(){Mo=0,bo.lineStart=Hi},polygonEnd:function(){yo+=0>Mo?4*Ru+Mo:Mo,bo.lineStart=bo.lineEnd=bo.point=Pn}};d3.geo.projection=Ri,d3.geo.projectionMutator=Pi;var xo=Vi(function(t){return 1/(1+t)},function(t){return 2*Math.atan(t)});(d3.geo.stereographic=function(){return Ri(xo)}).raw=xo,d3.geom={},d3.geom.hull=function(t){if(3>t.length)return[];var n,e,r,i,u,a,o,c,l,s,f=t.length,h=f-1,d=[],g=[],p=0;for(n=1;f>n;++n)t[n][1]<t[p][1]?p=n:t[n][1]==t[p][1]&&(p=t[n][0]<t[p][0]?n:p);for(n=0;f>n;++n)n!==p&&(i=t[n][1]-t[p][1],r=t[n][0]-t[p][0],d.push({angle:Math.atan2(i,r),index:n}));for(d.sort(function(t,n){return t.angle-n.angle}),l=d[0].angle,c=d[0].index,o=0,n=1;h>n;++n)e=d[n].index,l==d[n].angle?(r=t[c][0]-t[p][0],i=t[c][1]-t[p][1],u=t[e][0]-t[p][0],a=t[e][1]-t[p][1],r*r+i*i>=u*u+a*a?d[n].index=-1:(d[o].index=-1,l=d[n].angle,o=n,c=e)):(l=d[n].angle,o=n,c=e);for(g.push(p),n=0,e=0;2>n;++e)-1!==d[e].index&&(g.push(d[e].index),n++);for(s=g.length;h>e;++e)if(-1!==d[e].index){for(;!Xi(g[s-2],g[s-1],d[e].index,t);)--s;g[s++]=d[e].index}var m=[];for(n=0;s>n;++n)m.push(t[g[n]]);return m},d3.geom.polygon=function(t){return t.area=function(){for(var n=0,e=t.length,r=t[e-1][1]*t[0][0]-t[e-1][0]*t[0][1];e>++n;)r+=t[n-1][1]*t[n][0]-t[n-1][0]*t[n][1];return.5*r},t.centroid=function(n){var e,r,i=-1,u=t.length,a=0,o=0,c=t[u-1];for(arguments.length||(n=-1/(6*t.area()));u>++i;)e=c,c=t[i],r=e[0]*c[1]-c[0]*e[1],a+=(e[0]+c[0])*r,o+=(e[1]+c[1])*r;return[a*n,o*n]},t.clip=function(n){for(var e,r,i,u,a,o,c=-1,l=t.length,s=t[l-1];l>++c;){for(e=n.slice(),n.length=0,u=t[c],a=e[(i=e.length)-1],r=-1;i>++r;)o=e[r],Zi(o,s,u)?(Zi(a,s,u)||n.push(Bi(a,o,s,u)),n.push(o)):Zi(a,s,u)&&n.push(Bi(a,o,s,u)),a=o;s=u}return n},t},d3.geom.voronoi=function(t){var n=t.map(function(){return[]}),e=1e6;return $i(t,function(t){var r,i,u,a,o,c;1===t.a&&t.b>=0?(r=t.ep.r,i=t.ep.l):(r=t.ep.l,i=t.ep.r),1===t.a?(o=r?r.y:-e,u=t.c-t.b*o,c=i?i.y:e,a=t.c-t.b*c):(u=r?r.x:-e,o=t.c-t.a*u,a=i?i.x:e,c=t.c-t.a*a);var l=[u,o],s=[a,c];n[t.region.l.index].push(l,s),n[t.region.r.index].push(l,s)}),n=n.map(function(n,e){var r=t[e][0],i=t[e][1],u=n.map(function(t){return Math.atan2(t[0]-r,t[1]-i)});return d3.range(n.length).sort(function(t,n){return u[t]-u[n]}).filter(function(t,n,e){return!n||u[t]-u[e[n-1]]>Pu}).map(function(t){return n[t]})}),n.forEach(function(n,r){var i=n.length;if(!i)return n.push([-e,-e],[-e,e],[e,e],[e,-e]);if(!(i>2)){var u=t[r],a=n[0],o=n[1],c=u[0],l=u[1],s=a[0],f=a[1],h=o[0],d=o[1],g=Math.abs(h-s),p=d-f;if(Pu>Math.abs(p)){var m=f>l?-e:e;n.push([-e,m],[e,m])}else if(Pu>g){var v=s>c?-e:e;n.push([v,-e],[v,e])}else{var m=(s-c)*(d-f)>(h-s)*(f-l)?e:-e,y=Math.abs(p)-g;Pu>Math.abs(y)?n.push([0>p?m:-m,m]):(y>0&&(m*=-1),n.push([-e,m],[e,m]))}}}),n};var _o={l:"r",r:"l"};d3.geom.delaunay=function(t){var n=t.map(function(){return[]}),e=[];return $i(t,function(e){n[e.region.l.index].push(t[e.region.r.index])}),n.forEach(function(n,r){var i=t[r],u=i[0],a=i[1];n.forEach(function(t){t.angle=Math.atan2(t[0]-u,t[1]-a)}),n.sort(function(t,n){return t.angle-n.angle});for(var o=0,c=n.length-1;c>o;o++)e.push([i,n[o],n[o+1]])}),e},d3.geom.quadtree=function(t,n,e,r,i){function u(t,n,e,r,i,u){if(!isNaN(n.x)&&!isNaN(n.y))if(t.leaf){var o=t.point;o?.01>Math.abs(o.x-n.x)+Math.abs(o.y-n.y)?a(t,n,e,r,i,u):(t.point=null,a(t,o,e,r,i,u),a(t,n,e,r,i,u)):t.point=n}else a(t,n,e,r,i,u)}function a(t,n,e,r,i,a){var o=.5*(e+i),c=.5*(r+a),l=n.x>=o,s=n.y>=c,f=(s<<1)+l;t.leaf=!1,t=t.nodes[f]||(t.nodes[f]=Ji()),l?e=o:i=o,s?r=c:a=c,u(t,n,e,r,i,a)}var o,c=-1,l=t.length;if(5>arguments.length)if(3===arguments.length)i=e,r=n,e=n=0;else for(n=e=1/0,r=i=-1/0;l>++c;)o=t[c],n>o.x&&(n=o.x),e>o.y&&(e=o.y),o.x>r&&(r=o.x),o.y>i&&(i=o.y);var s=r-n,f=i-e;s>f?i=e+s:r=n+f;var h=Ji();return h.add=function(t){u(h,t,n,e,r,i)},h.visit=function(t){Gi(t,h,n,e,r,i)},t.forEach(h.add),h},d3.time={};var wo=Date,So=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];Ki.prototype={getDate:function(){return this._.getUTCDate()},getDay:function(){return this._.getUTCDay()},getFullYear:function(){return this._.getUTCFullYear()},getHours:function(){return this._.getUTCHours()},getMilliseconds:function(){return this._.getUTCMilliseconds()},getMinutes:function(){return this._.getUTCMinutes()},getMonth:function(){return this._.getUTCMonth()},getSeconds:function(){return this._.getUTCSeconds()},getTime:function(){return this._.getTime()},getTimezoneOffset:function(){return 0},valueOf:function(){return this._.valueOf()},setDate:function(){ko.setUTCDate.apply(this._,arguments)},setDay:function(){ko.setUTCDay.apply(this._,arguments)},setFullYear:function(){ko.setUTCFullYear.apply(this._,arguments)},setHours:function(){ko.setUTCHours.apply(this._,arguments)},setMilliseconds:function(){ko.setUTCMilliseconds.apply(this._,arguments)},setMinutes:function(){ko.setUTCMinutes.apply(this._,arguments)},setMonth:function(){ko.setUTCMonth.apply(this._,arguments)},setSeconds:function(){ko.setUTCSeconds.apply(this._,arguments)},setTime:function(){ko.setTime.apply(this._,arguments)}};var ko=Date.prototype,Eo="%a %b %e %X %Y",Ao="%m/%d/%Y",No="%H:%M:%S",To=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],qo=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],Co=["January","February","March","April","May","June","July","August","September","October","November","December"],zo=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];d3.time.format=function(t){function n(n){for(var r,i,u,a=[],o=-1,c=0;e>++o;)37===t.charCodeAt(o)&&(a.push(t.substring(c,o)),null!=(i=jo[r=t.charAt(++o)])&&(r=t.charAt(++o)),(u=Oo[r])&&(r=u(n,null==i?"e"===r?" ":"0":i)),a.push(r),c=o+1);return a.push(t.substring(c,o)),a.join("")}var e=t.length;return n.parse=function(n){var e={y:1900,m:0,d:1,H:0,M:0,S:0,L:0},r=Wi(e,t,n,0);if(r!=n.length)return null;"p"in e&&(e.H=e.H%12+12*e.p);var i=new wo;return i.setFullYear(e.y,e.m,e.d),i.setHours(e.H,e.M,e.S,e.L),i},n.toString=function(){return t},n};var Do=Qi(To),Lo=Qi(qo),Fo=Qi(Co),Ho=tu(Co),Ro=Qi(zo),Po=tu(zo),jo={"-":"",_:" ",0:"0"},Oo={a:function(t){return qo[t.getDay()]},A:function(t){return To[t.getDay()]},b:function(t){return zo[t.getMonth()]},B:function(t){return Co[t.getMonth()]},c:d3.time.format(Eo),d:function(t,n){return nu(t.getDate(),n,2)},e:function(t,n){return nu(t.getDate(),n,2)},H:function(t,n){return nu(t.getHours(),n,2)},I:function(t,n){return nu(t.getHours()%12||12,n,2)},j:function(t,n){return nu(1+d3.time.dayOfYear(t),n,3)},L:function(t,n){return nu(t.getMilliseconds(),n,3)},m:function(t,n){return nu(t.getMonth()+1,n,2)},M:function(t,n){return nu(t.getMinutes(),n,2)},p:function(t){return t.getHours()>=12?"PM":"AM"},S:function(t,n){return nu(t.getSeconds(),n,2)},U:function(t,n){return nu(d3.time.sundayOfYear(t),n,2)},w:function(t){return t.getDay()},W:function(t,n){return nu(d3.time.mondayOfYear(t),n,2)},x:d3.time.format(Ao),X:d3.time.format(No),y:function(t,n){return nu(t.getFullYear()%100,n,2)},Y:function(t,n){return nu(t.getFullYear()%1e4,n,4)},Z:Mu,"%":function(){return"%"}},Yo={a:eu,A:ru,b:iu,B:uu,c:au,d:du,e:du,H:gu,I:gu,L:vu,m:hu,M:pu,p:yu,S:mu,x:ou,X:cu,y:su,Y:lu},Uo=/^\s*\d+/,Io=d3.map({am:0,pm:1});d3.time.format.utc=function(t){function n(t){try{wo=Ki;var n=new wo;return n._=t,e(n)}finally{wo=Date}}var e=d3.time.format(t);return n.parse=function(t){try{wo=Ki;var n=e.parse(t);return n&&n._}finally{wo=Date}},n.toString=e.toString,n};var Vo=d3.time.format.utc("%Y-%m-%dT%H:%M:%S.%LZ");d3.time.format.iso=Date.prototype.toISOString?bu:Vo,bu.parse=function(t){var n=new Date(t);return isNaN(n)?null:n},bu.toString=Vo.toString,d3.time.second=xu(function(t){return new wo(1e3*Math.floor(t/1e3))},function(t,n){t.setTime(t.getTime()+1e3*Math.floor(n))},function(t){return t.getSeconds()}),d3.time.seconds=d3.time.second.range,d3.time.seconds.utc=d3.time.second.utc.range,d3.time.minute=xu(function(t){return new wo(6e4*Math.floor(t/6e4))},function(t,n){t.setTime(t.getTime()+6e4*Math.floor(n))},function(t){return t.getMinutes()}),d3.time.minutes=d3.time.minute.range,d3.time.minutes.utc=d3.time.minute.utc.range,d3.time.hour=xu(function(t){var n=t.getTimezoneOffset()/60;return new wo(36e5*(Math.floor(t/36e5-n)+n))},function(t,n){t.setTime(t.getTime()+36e5*Math.floor(n))},function(t){return t.getHours()}),d3.time.hours=d3.time.hour.range,d3.time.hours.utc=d3.time.hour.utc.range,d3.time.day=xu(function(t){var n=new wo(1970,0);return n.setFullYear(t.getFullYear(),t.getMonth(),t.getDate()),n},function(t,n){t.setDate(t.getDate()+n)},function(t){return t.getDate()-1}),d3.time.days=d3.time.day.range,d3.time.days.utc=d3.time.day.utc.range,d3.time.dayOfYear=function(t){var n=d3.time.year(t);return Math.floor((t-n-6e4*(t.getTimezoneOffset()-n.getTimezoneOffset()))/864e5)},So.forEach(function(t,n){t=t.toLowerCase(),n=7-n;var e=d3.time[t]=xu(function(t){return(t=d3.time.day(t)).setDate(t.getDate()-(t.getDay()+n)%7),t},function(t,n){t.setDate(t.getDate()+7*Math.floor(n))},function(t){var e=d3.time.year(t).getDay();return Math.floor((d3.time.dayOfYear(t)+(e+n)%7)/7)-(e!==n)});d3.time[t+"s"]=e.range,d3.time[t+"s"].utc=e.utc.range,d3.time[t+"OfYear"]=function(t){var e=d3.time.year(t).getDay();return Math.floor((d3.time.dayOfYear(t)+(e+n)%7)/7)}}),d3.time.week=d3.time.sunday,d3.time.weeks=d3.time.sunday.range,d3.time.weeks.utc=d3.time.sunday.utc.range,d3.time.weekOfYear=d3.time.sundayOfYear,d3.time.month=xu(function(t){return t=d3.time.day(t),t.setDate(1),t},function(t,n){t.setMonth(t.getMonth()+n)},function(t){return t.getMonth()}),d3.time.months=d3.time.month.range,d3.time.months.utc=d3.time.month.utc.range,d3.time.year=xu(function(t){return t=d3.time.day(t),t.setMonth(0,1),t},function(t,n){t.setFullYear(t.getFullYear()+n)},function(t){return t.getFullYear()}),d3.time.years=d3.time.year.range,d3.time.years.utc=d3.time.year.utc.range;var Xo=[1e3,5e3,15e3,3e4,6e4,3e5,9e5,18e5,36e5,108e5,216e5,432e5,864e5,1728e5,6048e5,2592e6,7776e6,31536e6],Zo=[[d3.time.second,1],[d3.time.second,5],[d3.time.second,15],[d3.time.second,30],[d3.time.minute,1],[d3.time.minute,5],[d3.time.minute,15],[d3.time.minute,30],[d3.time.hour,1],[d3.time.hour,3],[d3.time.hour,6],[d3.time.hour,12],[d3.time.day,1],[d3.time.day,2],[d3.time.week,1],[d3.time.month,1],[d3.time.month,3],[d3.time.year,1]],Bo=[[d3.time.format("%Y"),o],[d3.time.format("%B"),function(t){return t.getMonth()}],[d3.time.format("%b %d"),function(t){return 1!=t.getDate()}],[d3.time.format("%a %d"),function(t){return t.getDay()&&1!=t.getDate()}],[d3.time.format("%I %p"),function(t){return t.getHours()}],[d3.time.format("%I:%M"),function(t){return t.getMinutes()}],[d3.time.format(":%S"),function(t){return t.getSeconds()}],[d3.time.format(".%L"),function(t){return t.getMilliseconds()}]],$o=d3.scale.linear(),Jo=Eu(Bo);Zo.year=function(t,n){return $o.domain(t.map(Nu)).ticks(n).map(Au)},d3.time.scale=function(){return wu(d3.scale.linear(),Zo,Jo)};var Go=Zo.map(function(t){return[t[0].utc,t[1]]}),Ko=[[d3.time.format.utc("%Y"),o],[d3.time.format.utc("%B"),function(t){return t.getUTCMonth()}],[d3.time.format.utc("%b %d"),function(t){return 1!=t.getUTCDate()}],[d3.time.format.utc("%a %d"),function(t){return t.getUTCDay()&&1!=t.getUTCDate()}],[d3.time.format.utc("%I %p"),function(t){return t.getUTCHours()}],[d3.time.format.utc("%I:%M"),function(t){return t.getUTCMinutes()}],[d3.time.format.utc(":%S"),function(t){return t.getUTCSeconds()}],[d3.time.format.utc(".%L"),function(t){return t.getUTCMilliseconds()}]],Wo=Eu(Ko);Go.year=function(t,n){return $o.domain(t.map(qu)).ticks(n).map(Tu)},d3.time.scale.utc=function(){return wu(d3.scale.linear(),Go,Wo)}})();
\ No newline at end of file
diff --git a/apps/workbench/public/graph-example.html b/apps/workbench/public/graph-example.html
deleted file mode 100644 (file)
index ba6d8d1..0000000
+++ /dev/null
@@ -1,185 +0,0 @@
-<!-- Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: AGPL-3.0 -->
-
-<!DOCTYPE html>
-<!-- from http://bl.ocks.org/1153292 -->
-<html lang="en">
-  <head>
-    <meta http-equiv="Content-type" content="text/html; charset=utf-8">
-    <title>Object graph example</title>
-    <script src="d3.v3.min.js"></script>
-    <style type="text/css">
-
-path.link {
-  fill: none;
-  stroke: #666;
-  stroke-width: 1.5px;
-}
-
-marker#can_read {
-  fill: green;
-}
-
-path.link.can_read {
-  stroke: green;
-  stroke-dasharray: 0,4 1;
-}
-
-path.link.can_write {
-  stroke: green;
-}
-
-path.link.member_of {
-  stroke: blue;
-  stroke-dasharray: 0,4 1;
-}
-
-path.link.created {
-  stroke: red;
-}
-
-circle {
-  fill: #ccc;
-  stroke: #333;
-  stroke-width: 1.5px;
-}
-
-edgetext {
-  font: 12px sans-serif;
-  pointer-events: none;
-    text-align: center;
-}
-
-text {
-  font: 12px sans-serif;
-  pointer-events: none;
-}
-
-text.shadow {
-  stroke: #fff;
-  stroke-width: 3px;
-  stroke-opacity: .8;
-}
-
-    </style>
-  </head>
-  <body>
-    <script type="text/javascript">
-
-var links = [
-  {source: "user: customer", target: "project: customer_project", type: "can_read"},
-  {source: "user: import robot", target: "project: customer_project", type: "can_read"},
-  {source: "user: pipeline robot", target: "project: customer_project", type: "can_read"},
-  {source: "user: uploader", target: "collection: w3anr2hk2wgfpuo", type: "created"},
-  {source: "user: uploader", target: "project: customer_project", type: "created"},
-  {source: "collection: w3anr2hk2wgfpuo", target: "project: customer_project", type: "member_of"}
-];
-
-var nodes = {};
-
-// Compute the distinct nodes from the links.
-links.forEach(function(link) {
-  link.source = nodes[link.source] || (nodes[link.source] = {name: link.source});
-  link.target = nodes[link.target] || (nodes[link.target] = {name: link.target});
-});
-
-var w = 960,
-    h = 500;
-
-var force = d3.layout.force()
-    .nodes(d3.values(nodes))
-    .links(links)
-    .size([w, h])
-    .linkDistance(250)
-    .charge(-300)
-    .on("tick", tick)
-    .start();
-
-var svg = d3.select("body").append("svg:svg")
-    .attr("width", w)
-    .attr("height", h);
-
-// Per-type markers, as they don't inherit styles.
-svg.append("svg:defs").selectAll("marker")
-    .data(["created", "member_of", "can_read", "can_write"])
-  .enter().append("svg:marker")
-    .attr("id", String)
-    .attr("viewBox", "0 -5 10 10")
-    .attr("refX", 15)
-    .attr("refY", -1.5)
-    .attr("markerWidth", 6)
-    .attr("markerHeight", 6)
-    .attr("orient", "auto")
-  .append("svg:path")
-    .attr("d", "M0,-5L10,0L0,5");
-
-var path = svg.append("svg:g").selectAll("path")
-    .data(force.links())
-  .enter().append("svg:path")
-    .attr("class", function(d) { return "link " + d.type; })
-    .attr("marker-end", function(d) { return "url(#" + d.type + ")"; });
-
-var circle = svg.append("svg:g").selectAll("circle")
-    .data(force.nodes())
-  .enter().append("svg:circle")
-    .attr("r", 6)
-    .call(force.drag);
-
-var text = svg.append("svg:g").selectAll("g")
-    .data(force.nodes())
-  .enter().append("svg:g");
-
-// A copy of the text with a thick white stroke for legibility.
-text.append("svg:text")
-    .attr("x", 8)
-    .attr("y", ".31em")
-    .attr("class", "shadow")
-    .text(function(d) { return d.name; });
-
-text.append("svg:text")
-    .attr("x", 8)
-    .attr("y", ".31em")
-    .text(function(d) { return d.name; });
-
-var edgetext = svg.append("svg:g").selectAll("g")
-    .data(force.links())
-    .enter().append("svg:g");
-
-edgetext
-    .append("svg:text")
-    .attr("x",0)
-    .attr("y","-0.2em")
-    .text(function(d) { return d.type; });
-
-// Use elliptical arc path segments to doubly-encode directionality.
-function tick() {
-  path.attr("d", function(d) {
-    var dx = d.target.x - d.source.x,
-        dy = d.target.y - d.source.y,
-        // dr = Math.sqrt(dx * dx + dy * dy);
-        dr = 0;
-    return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
-  });
-
-  circle.attr("transform", function(d) {
-    return "translate(" + d.x + "," + d.y + ")";
-  });
-
-  text.attr("transform", function(d) {
-    return "translate(" + d.x + "," + d.y + ")";
-  });
-
-  edgetext.attr("transform", function(d) {
-      return "translate(" +
-         (d.source.x + d.target.x)/2 + "," +
-         (d.source.y + d.target.y)/2 +
-         ")rotate(" +
-         (Math.atan2(d.target.y - d.source.y, d.target.x - d.source.x) * 180 / Math.PI) +
-         ")";
-  });
-}
-
-    </script>
-  </body>
-</html>
diff --git a/apps/workbench/public/robots.txt b/apps/workbench/public/robots.txt
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/public/vocabulary-example.json b/apps/workbench/public/vocabulary-example.json
deleted file mode 100644 (file)
index b227dc2..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-{
-    "strict": false,
-    "tags": {
-        "fruit": {
-            "values": ["pineapple", "tomato", "orange", "banana", "advocado", "lemon", "apple", "peach", "strawberry"],
-            "strict": true
-        },
-        "animal": {
-            "values": ["human", "dog", "elephant", "eagle"],
-            "strict": false
-        },
-        "color": {
-            "values": ["yellow", "red", "magenta", "green"],
-            "strict": false
-        },
-        "text": {},
-        "category": {
-            "values": ["experimental", "development", "production"]
-        },
-        "comments": {},
-        "importance": {
-            "values": ["critical", "important", "low priority"]
-        },
-        "size": {
-            "values": ["x-small", "small", "medium", "large", "x-large"]
-        },
-        "country": {
-            "values": ["Afghanistan","Åland Islands","Albania","Algeria","American Samoa","AndorrA","Angola","Anguilla","Antarctica","Antigua and Barbuda","Argentina","Armenia","Aruba","Australia","Austria","Azerbaijan","Bahamas","Bahrain","Bangladesh","Barbados","Belarus","Belgium","Belize","Benin","Bermuda","Bhutan","Bolivia","Bosnia and Herzegovina","Botswana","Bouvet Island","Brazil","British Indian Ocean Territory","Brunei Darussalam","Bulgaria","Burkina Faso","Burundi","Cambodia","Cameroon","Canada","Cape Verde","Cayman Islands","Central African Republic","Chad","Chile","China","Christmas Island","Cocos (Keeling) Islands","Colombia","Comoros","Congo","Congo, The Democratic Republic of the","Cook Islands","Costa Rica","Cote D'Ivoire","Croatia","Cuba","Cyprus","Czech Republic","Denmark","Djibouti","Dominica","Dominican Republic","Ecuador","Egypt","El Salvador","Equatorial Guinea","Eritrea","Estonia","Ethiopia","Falkland Islands (Malvinas)","Faroe Islands","Fiji","Finland","France","French Guiana","French Polynesia","French Southern Territories","Gabon","Gambia","Georgia","Germany","Ghana","Gibraltar","Greece","Greenland","Grenada","Guadeloupe","Guam","Guatemala","Guernsey","Guinea","Guinea-Bissau","Guyana","Haiti","Heard Island and Mcdonald Islands","Holy See (Vatican City State)","Honduras","Hong Kong","Hungary","Iceland","India","Indonesia","Iran, Islamic Republic Of","Iraq","Ireland","Isle of Man","Israel","Italy","Jamaica","Japan","Jersey","Jordan","Kazakhstan","Kenya","Kiribati","Korea, Democratic People'S Republic of","Korea, Republic of","Kuwait","Kyrgyzstan","Lao People'S Democratic Republic","Latvia","Lebanon","Lesotho","Liberia","Libyan Arab Jamahiriya","Liechtenstein","Lithuania","Luxembourg","Macao","Macedonia, The Former Yugoslav Republic of","Madagascar","Malawi","Malaysia","Maldives","Mali","Malta","Marshall Islands","Martinique","Mauritania","Mauritius","Mayotte","Mexico","Micronesia, Federated States of","Moldova, Republic of","Monaco","Mongolia","Montserrat","Morocco","Mozambique","Myanmar","Namibia","Nauru","Nepal","Netherlands","Netherlands Antilles","New Caledonia","New Zealand","Nicaragua","Niger","Nigeria","Niue","Norfolk Island","Northern Mariana Islands","Norway","Oman","Pakistan","Palau","Palestinian Territory, Occupied","Panama","Papua New Guinea","Paraguay","Peru","Philippines","Pitcairn","Poland","Portugal","Puerto Rico","Qatar","Reunion","Romania","Russian Federation","RWANDA","Saint Helena","Saint Kitts and Nevis","Saint Lucia","Saint Pierre and Miquelon","Saint Vincent and the Grenadines","Samoa","San Marino","Sao Tome and Principe","Saudi Arabia","Senegal","Serbia and Montenegro","Seychelles","Sierra Leone","Singapore","Slovakia","Slovenia","Solomon Islands","Somalia","South Africa","South Georgia and the South Sandwich Islands","Spain","Sri Lanka","Sudan","Suriname","Svalbard and Jan Mayen","Swaziland","Sweden","Switzerland","Syrian Arab Republic","Taiwan, Province of China","Tajikistan","Tanzania, United Republic of","Thailand","Timor-Leste","Togo","Tokelau","Tonga","Trinidad and Tobago","Tunisia","Turkey","Turkmenistan","Turks and Caicos Islands","Tuvalu","Uganda","Ukraine","United Arab Emirates","United Kingdom","United States","United States Minor Outlying Islands","Uruguay","Uzbekistan","Vanuatu","Venezuela","Viet Nam","Virgin Islands, British","Virgin Islands, U.S.","Wallis and Futuna","Western Sahara","Yemen","Zambia","Zimbabwe"],
-            "strict": true
-        }
-    }
-}
\ No newline at end of file
diff --git a/apps/workbench/public/webshell/keyboard.html b/apps/workbench/public/webshell/keyboard.html
deleted file mode 100644 (file)
index 271c3f7..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xml:lang="en" lang="en">
-<head>
-  <title>webshell keyboard</title>
-</head>
-<body><pre class="box"><div
-  ><i id="27">Esc</i><i id="112">F1</i><i id="113">F2</i><i id="114">F3</i
-  ><i id="115">F4</i><i id="116">F5</i><i id="117">F6</i><i id="118">F7</i
-  ><i id="119">F8</i><i id="120">F9</i><i id="121">F10</i><i id="122">F11</i
-  ><i id="123">F12</i><br
-  /><b><span class="unshifted">`</span><span class="shifted">~</span></b
-    ><b><span class="unshifted">1</span><span class="shifted">!</span></b
-    ><b><span class="unshifted">2</span><span class="shifted">@</span></b
-    ><b><span class="unshifted">3</span><span class="shifted">#</span></b
-    ><b><span class="unshifted">4</span><span class="shifted">&#36;</span></b
-    ><b><span class="unshifted">5</span><span class="shifted">&#37;</span></b
-    ><b><span class="unshifted">6</span><span class="shifted">^</span></b
-    ><b><span class="unshifted">7</span><span class="shifted">&amp;</span></b
-    ><b><span class="unshifted">8</span><span class="shifted">*</span></b
-    ><b><span class="unshifted">9</span><span class="shifted">(</span></b
-    ><b><span class="unshifted">0</span><span class="shifted">)</span></b
-    ><b><span class="unshifted">-</span><span class="shifted">_</span></b
-    ><b><span class="unshifted">=</span><span class="shifted">+</span></b
-    ><i id="8">&nbsp;&larr;&nbsp;</i
-    ><br
-  /><i id="9">Tab</i
-    ><b>Q</b><b>W</b><b>E</b><b>R</b><b>T</b><b>Y</b><b>U</b><b>I</b><b>O</b
-    ><b>P</b
-    ><b><span class="unshifted">[</span><span class="shifted">{</span></b
-    ><b><span class="unshifted">]</span><span class="shifted">}</span></b
-    ><b><span class="unshifted">&#92;</span><span class="shifted">|</span></b
-    ><br
-  /><u>Tab&nbsp;&nbsp;</u
-    ><b>A</b><b>S</b><b>D</b><b>F</b><b>G</b><b>H</b><b>J</b><b>K</b><b>L</b
-    ><b><span class="unshifted">;</span><span class="shifted">:</span></b
-    ><b><span class="unshifted">&#39;</span><span class="shifted">"</span></b
-    ><i id="13">Enter</i
-    ><br
-  /><u>&nbsp;&nbsp;</u
-    ><i id="16">Shift</i
-    ><b>Z</b><b>X</b><b>C</b><b>V</b><b>B</b><b>N</b><b>M</b
-    ><b><span class="unshifted">,</span><span class="shifted">&lt;</span></b
-    ><b><span class="unshifted">.</span><span class="shifted">&gt;</span></b
-    ><b><span class="unshifted">/</span><span class="shifted">?</span></b
-    ><i id="16">Shift</i
-    ><br
-  /><u>XXX</u
-    ><i id="17">Ctrl</i
-    ><i id="18">Alt</i
-    ><i style="width: 25ex">&nbsp</i
-  ></div
-  >&nbsp;&nbsp;&nbsp;<div
-    ><i id="45">Ins</i><i id="46">Del</i><i id="36">Home</i><i id="35">End</i
-    ><br
-    /><u>&nbsp;</u><br
-    /><u>&nbsp;</u><br
-    /><u>Ins</u><s>&nbsp;</s><b id="38">&uarr;</b><s>&nbsp;</s><u>&nbsp;</u
-      ><b id="33">&uArr;</b><br
-    /><u>Ins</u><b id="37">&larr;</b><b id="40">&darr;</b
-      ><b id="39">&rarr;</b><u>&nbsp;</u><b id="34">&dArr;</b
-  ></div
-></pre></body></html>
diff --git a/apps/workbench/script/rails b/apps/workbench/script/rails
deleted file mode 100755 (executable)
index f8da2cf..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/usr/bin/env ruby
-# This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application.
-
-APP_PATH = File.expand_path('../../config/application',  __FILE__)
-require File.expand_path('../../config/boot',  __FILE__)
-require 'rails/commands'
diff --git a/apps/workbench/test/controllers/actions_controller_test.rb b/apps/workbench/test/controllers/actions_controller_test.rb
deleted file mode 100644 (file)
index fbbffe8..0000000
+++ /dev/null
@@ -1,206 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ActionsControllerTest < ActionController::TestCase
-
-  test "send report" do
-    post :report_issue, params: {format: 'js'}, session: session_for(:admin)
-    assert_response :success
-
-    found_email = false
-    ActionMailer::Base.deliveries.andand.each do |email|
-      if email.subject.include? "Issue reported by admin"
-        found_email = true
-        break
-      end
-    end
-    assert_equal true, found_email, 'Expected email after issue reported'
-  end
-
-  test "combine files into new collection" do
-    post(:combine_selected_files_into_collection, params: {
-           selection: ['zzzzz-4zz18-znfnqtbbv4spc3w/foo',
-                       'zzzzz-4zz18-ehbhgtheo8909or/bar',
-                       'zzzzz-4zz18-y9vne9npefyxh8g/baz',
-                       '7a6ef4c162a5c6413070a8bd0bffc818+150'],
-           format: "json"},
-         session: session_for(:active))
-
-    assert_response 302   # collection created and redirected to new collection page
-
-    assert_includes(response.headers['Location'], '/collections/')
-    new_collection_uuid = response.headers['Location'].split('/')[-1]
-
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: new_collection_uuid).first
-    manifest_text = collection['manifest_text']
-    assert_includes(manifest_text, "foo")
-    assert_includes(manifest_text, "bar")
-    assert_includes(manifest_text, "baz")
-    assert_includes(manifest_text, "0:0:file1 0:0:file2 0:0:file3")
-    assert_includes(manifest_text, "dir1/subdir")
-    assert_includes(manifest_text, "dir2")
-  end
-
-  test "combine files  with repeated names into new collection" do
-    post(:combine_selected_files_into_collection, params: {
-           selection: ['zzzzz-4zz18-znfnqtbbv4spc3w/foo',
-                       'zzzzz-4zz18-00000nonamecoll/foo',
-                       'zzzzz-4zz18-abcd6fx123409f7/foo',
-                       'zzzzz-4zz18-ehbhgtheo8909or/bar',
-                       'zzzzz-4zz18-y9vne9npefyxh8g/baz',
-                       '7a6ef4c162a5c6413070a8bd0bffc818+150'],
-           format: "json"},
-         session: session_for(:active))
-
-    assert_response 302   # collection created and redirected to new collection page
-
-    assert_includes(response.headers['Location'], '/collections/')
-    new_collection_uuid = response.headers['Location'].split('/')[-1]
-
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: new_collection_uuid).first
-    manifest_text = collection['manifest_text']
-    assert_includes(manifest_text, "foo(1)")
-    assert_includes(manifest_text, "foo(2)")
-    assert_includes(manifest_text, "bar")
-    assert_includes(manifest_text, "baz")
-    assert_includes(manifest_text, "0:0:file1 0:0:file2 0:0:file3")
-    assert_includes(manifest_text, "dir1/subdir")
-    assert_includes(manifest_text, "dir2")
-  end
-
-  test "combine collections with repeated filenames in almost similar directories and expect files with proper suffixes" do
-    post(:combine_selected_files_into_collection, params: {
-           selection: ['zzzzz-4zz18-duplicatenames1',
-                       'zzzzz-4zz18-duplicatenames2',
-                       'zzzzz-4zz18-znfnqtbbv4spc3w/foo',
-                       'zzzzz-4zz18-00000nonamecoll/foo',],
-           format: "json"},
-         session: session_for(:active))
-
-    assert_response 302   # collection created and redirected to new collection page
-
-    assert response.headers['Location'].include? '/collections/'
-    new_collection_uuid = response.headers['Location'].split('/')[-1]
-
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: new_collection_uuid).first
-    manifest_text = collection['manifest_text']
-
-    assert_includes(manifest_text, 'foo')
-    assert_includes(manifest_text, 'foo(1)')
-
-    streams = manifest_text.split "\n"
-    streams.each do |stream|
-      if stream.start_with? './dir1'
-        # dir1 stream
-        assert_includes(stream, ':alice(1)')
-        assert_includes(stream, ':alice.txt')
-        assert_includes(stream, ':alice(1).txt')
-        assert_includes(stream, ':bob.txt')
-        assert_includes(stream, ':carol.txt')
-      elsif stream.start_with? './dir2'
-        # dir2 stream
-        assert_includes(stream, ':alice.txt')
-        assert_includes(stream, ':alice(1).txt')
-      elsif stream.start_with? '. '
-        # . stream
-        assert_includes(stream, ':foo')
-        assert_includes(stream, ':foo(1)')
-      end
-    end
-  end
-
-  test "combine collections with same filename in two different streams and expect no suffixes for filenames" do
-    post(:combine_selected_files_into_collection, params: {
-           selection: ['zzzzz-4zz18-znfnqtbbv4spc3w',
-                       'zzzzz-4zz18-foonbarfilesdir'],
-           format: "json"},
-         session: session_for(:active))
-
-    assert_response 302   # collection created and redirected to new collection page
-
-    assert_includes(response.headers['Location'], '/collections/')
-    new_collection_uuid = response.headers['Location'].split('/')[-1]
-
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: new_collection_uuid).first
-    manifest_text = collection['manifest_text']
-
-    streams = manifest_text.split "\n"
-    assert_equal 2, streams.length
-    streams.each do |stream|
-      if stream.start_with? './dir1'
-        assert_includes(stream, 'foo')
-      elsif stream.start_with? '. '
-        assert_includes(stream, 'foo')
-      end
-    end
-    refute_includes(manifest_text, 'foo(1)')
-  end
-
-  test "combine foo files from two different collection streams and expect proper filename suffixes" do
-    post(:combine_selected_files_into_collection, params: {
-           selection: ['zzzzz-4zz18-znfnqtbbv4spc3w/foo',
-                       'zzzzz-4zz18-foonbarfilesdir/dir1/foo'],
-           format: "json"},
-         session: session_for(:active))
-
-    assert_response 302   # collection created and redirected to new collection page
-
-    assert_includes(response.headers['Location'], '/collections/')
-    new_collection_uuid = response.headers['Location'].split('/')[-1]
-
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: new_collection_uuid).first
-    manifest_text = collection['manifest_text']
-
-    streams = manifest_text.split "\n"
-    assert_equal 1, streams.length, "Incorrect number of streams in #{manifest_text}"
-    assert_includes(manifest_text, 'foo')
-    assert_includes(manifest_text, 'foo(1)')
-  end
-
-  [
-    ['collections', 'user_agreement_in_anonymously_accessible_project'],
-    ['groups', 'anonymously_accessible_project'],
-    ['jobs', 'running_job_in_publicly_accessible_project'],
-    ['pipeline_instances', 'pipeline_in_publicly_accessible_project'],
-    ['pipeline_templates', 'pipeline_template_in_publicly_accessible_project'],
-  ].each do |dm, fixture|
-    test "access show method for public #{dm} and expect to see page" do
-      Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-      get(:show, params: {uuid: api_fixture(dm)[fixture]['uuid']})
-      assert_response :redirect
-      if dm == 'groups'
-        assert_includes @response.redirect_url, "projects/#{fixture['uuid']}"
-      else
-        assert_includes @response.redirect_url, "#{dm}/#{fixture['uuid']}"
-      end
-    end
-  end
-
-  [
-    ['collections', 'foo_collection_in_aproject', 404],
-    ['groups', 'subproject_in_asubproject_with_same_name_as_one_in_active_user_home', 404],
-    ['jobs', 'job_with_latest_version', 404],
-    ['pipeline_instances', 'pipeline_owned_by_active_in_home', 404],
-    ['pipeline_templates', 'template_in_asubproject_with_same_name_as_one_in_active_user_home', 404],
-    ['traits', 'owned_by_aproject_with_no_name', :redirect],
-  ].each do |dm, fixture, expected|
-    test "access show method for non-public #{dm} and expect #{expected}" do
-      Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-      get(:show, params: {uuid: api_fixture(dm)[fixture]['uuid']})
-      assert_response expected
-      if expected == 404
-        assert_includes @response.inspect, 'Log in'
-      else
-        assert_match /\/users\/welcome/, @response.redirect_url
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/controllers/api_client_authorizations_controller_test.rb b/apps/workbench/test/controllers/api_client_authorizations_controller_test.rb
deleted file mode 100644 (file)
index a2a5eb6..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ApiClientAuthorizationsControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/application_controller_test.rb b/apps/workbench/test/controllers/application_controller_test.rb
deleted file mode 100644 (file)
index 72c3e0a..0000000
+++ /dev/null
@@ -1,517 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ApplicationControllerTest < ActionController::TestCase
-  # These tests don't do state-changing API calls. Save some time by
-  # skipping the database reset.
-  reset_api_fixtures :after_each_test, false
-  reset_api_fixtures :after_suite, true
-
-  setup do
-    @user_dataclass = ArvadosBase.resource_class_for_uuid(api_fixture('users')['active']['uuid'])
-  end
-
-  test "links for object" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    link_head_uuid = api_fixture('links')['foo_file_readable_by_active']['head_uuid']
-
-    links = ac.send :links_for_object, link_head_uuid
-
-    assert links, 'Expected links'
-    assert links.is_a?(Array), 'Expected an array'
-    assert links.size > 0, 'Expected at least one link'
-    assert links[0][:uuid], 'Expected uuid for the head_link'
-  end
-
-  test "preload links for objects and uuids" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    link1_head_uuid = api_fixture('links')['foo_file_readable_by_active']['head_uuid']
-    link2_uuid = api_fixture('links')['bar_file_readable_by_active']['uuid']
-    link3_head_uuid = api_fixture('links')['bar_file_readable_by_active']['head_uuid']
-
-    link2_object = User.find(api_fixture('users')['active']['uuid'])
-    link2_object_uuid = link2_object['uuid']
-
-    uuids = [link1_head_uuid, link2_object, link3_head_uuid]
-    links = ac.send :preload_links_for_objects, uuids
-
-    assert links, 'Expected links'
-    assert links.is_a?(Hash), 'Expected a hash'
-    assert links.size == 3, 'Expected two objects in the preloaded links hash'
-    assert links[link1_head_uuid], 'Expected links for the passed in link head_uuid'
-    assert links[link2_object_uuid], 'Expected links for the passed in object uuid'
-    assert links[link3_head_uuid], 'Expected links for the passed in link head_uuid'
-
-    # invoke again for this same input. this time, the preloaded data will be returned
-    links = ac.send :preload_links_for_objects, uuids
-    assert links, 'Expected links'
-    assert links.is_a?(Hash), 'Expected a hash'
-    assert links.size == 3, 'Expected two objects in the preloaded links hash'
-    assert links[link1_head_uuid], 'Expected links for the passed in link head_uuid'
-  end
-
-  [ [:preload_links_for_objects, [] ],
-    [:preload_collections_for_objects, [] ],
-    [:preload_log_collections_for_objects, [] ],
-    [:preload_objects_for_dataclass, [] ],
-    [:preload_for_pdhs, [] ],
-  ].each do |input|
-    test "preload data for empty array input #{input}" do
-      use_token :active
-
-      ac = ApplicationController.new
-
-      if input[0] == :preload_objects_for_dataclass
-        objects = ac.send input[0], @user_dataclass, input[1]
-      else
-        objects = ac.send input[0], input[1]
-      end
-
-      assert objects, 'Expected objects'
-      assert objects.is_a?(Hash), 'Expected a hash'
-      assert objects.size == 0, 'Expected no objects in the preloaded hash'
-    end
-  end
-
-  [ [:preload_links_for_objects, 'input not an array'],
-    [:preload_links_for_objects, nil],
-    [:links_for_object, nil],
-    [:preload_collections_for_objects, 'input not an array'],
-    [:preload_collections_for_objects, nil],
-    [:collections_for_object, nil],
-    [:preload_log_collections_for_objects, 'input not an array'],
-    [:preload_log_collections_for_objects, nil],
-    [:log_collections_for_object, nil],
-    [:preload_objects_for_dataclass, 'input not an array'],
-    [:preload_objects_for_dataclass, nil],
-    [:object_for_dataclass, 'some_dataclass', nil],
-    [:object_for_dataclass, nil, 'some_uuid'],
-    [:preload_for_pdhs, 'input not an array'],
-    [:preload_for_pdhs, nil],
-  ].each do |input|
-    test "preload data for wrong type input #{input}" do
-      use_token :active
-
-      ac = ApplicationController.new
-
-      if input[0] == :object_for_dataclass
-        assert_raise ArgumentError do
-          ac.send input[0], input[1], input[2]
-        end
-      else
-        assert_raise ArgumentError do
-          ac.send input[0], input[1]
-        end
-      end
-    end
-  end
-
-  [ [:links_for_object, 'no-such-uuid' ],
-    [:collections_for_object, 'no-such-uuid' ],
-    [:log_collections_for_object, 'no-such-uuid' ],
-    [:object_for_dataclass, 'no-such-uuid' ],
-    [:collection_for_pdh, 'no-such-pdh' ],
-  ].each do |input|
-    test "get data for no such uuid #{input}" do
-      use_token :active
-
-      ac = ApplicationController.new
-
-      if input[0] == :object_for_dataclass
-        object = ac.send input[0], @user_dataclass, input[1]
-        assert_not object, 'Expected no object'
-      else
-        objects = ac.send input[0], input[1]
-        assert objects, 'Expected objects'
-        assert objects.is_a?(Array), 'Expected a array'
-        assert_empty objects
-      end
-    end
-  end
-
-  test "get 10 objects of data class user" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    objects = ac.send :get_n_objects_of_class, @user_dataclass, 10
-
-    assert objects, 'Expected objects'
-    assert objects.is_a?(ArvadosResourceList), 'Expected an ArvadosResourceList'
-
-    first_object = objects.first
-    assert first_object, 'Expected at least one object'
-    assert_equal 'User', first_object.class.name, 'Expected user object'
-
-    # invoke it again. this time, the preloaded info will be returned
-    objects = ac.send :get_n_objects_of_class, @user_dataclass, 10
-    assert objects, 'Expected objects'
-    assert_equal 'User', objects.first.class.name, 'Expected user object'
-  end
-
-  [ ['User', 10],
-    [nil, 10],
-    [@user_dataclass, 0],
-    [@user_dataclass, -1],
-    [@user_dataclass, nil] ].each do |input|
-    test "get_n_objects for incorrect input #{input}" do
-      use_token :active
-
-      ac = ApplicationController.new
-
-      assert_raise ArgumentError do
-        ac.send :get_n_objects_of_class, input[0], input[1]
-      end
-    end
-  end
-
-  test "collections for object" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    uuid = api_fixture('collections')['foo_file']['uuid']
-
-    collections = ac.send :collections_for_object, uuid
-
-    assert collections, 'Expected collections'
-    assert collections.is_a?(Array), 'Expected an array'
-    assert collections.size == 1, 'Expected one collection object'
-    assert_equal collections[0][:uuid], uuid, 'Expected uuid not found in collections'
-  end
-
-  test "preload collections for given uuids" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    uuid1 = api_fixture('collections')['foo_file']['uuid']
-    uuid2 = api_fixture('collections')['bar_file']['uuid']
-
-    uuids = [uuid1, uuid2]
-    collections = ac.send :preload_collections_for_objects, uuids
-
-    assert collections, 'Expected collection'
-    assert collections.is_a?(Hash), 'Expected a hash'
-    assert collections.size == 2, 'Expected two objects in the preloaded collection hash'
-    assert collections[uuid1], 'Expected collections for the passed in uuid'
-    assert_equal collections[uuid1].size, 1, 'Expected one collection for the passed in uuid'
-    assert collections[uuid2], 'Expected collections for the passed in uuid'
-    assert_equal collections[uuid2].size, 1, 'Expected one collection for the passed in uuid'
-
-    # invoke again for this same input. this time, the preloaded data will be returned
-    collections = ac.send :preload_collections_for_objects, uuids
-    assert collections, 'Expected collection'
-    assert collections.is_a?(Hash), 'Expected a hash'
-    assert collections.size == 2, 'Expected two objects in the preloaded collection hash'
-    assert collections[uuid1], 'Expected collections for the passed in uuid'
-  end
-
-  test "log collections for object" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    uuid = api_fixture('logs')['system_adds_foo_file']['object_uuid']
-
-    collections = ac.send :log_collections_for_object, uuid
-
-    assert collections, 'Expected collections'
-    assert collections.is_a?(Array), 'Expected an array'
-    assert collections.size == 1, 'Expected one collection object'
-    assert_equal collections[0][:uuid], uuid, 'Expected uuid not found in collections'
-  end
-
-  test "preload log collections for given uuids" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    uuid1 = api_fixture('logs')['system_adds_foo_file']['object_uuid']
-    uuid2 = api_fixture('collections')['bar_file']['uuid']
-
-    uuids = [uuid1, uuid2]
-    collections = ac.send :preload_log_collections_for_objects, uuids
-
-    assert collections, 'Expected collection'
-    assert collections.is_a?(Hash), 'Expected a hash'
-    assert collections.size == 2, 'Expected two objects in the preloaded collection hash'
-    assert collections[uuid1], 'Expected collections for the passed in uuid'
-    assert_equal collections[uuid1].size, 1, 'Expected one collection for the passed in uuid'
-    assert collections[uuid2], 'Expected collections for the passed in uuid'
-    assert_equal collections[uuid2].size, 1, 'Expected one collection for the passed in uuid'
-
-    # invoke again for this same input. this time, the preloaded data will be returned
-    collections = ac.send :preload_log_collections_for_objects, uuids
-    assert collections, 'Expected collection'
-    assert collections.is_a?(Hash), 'Expected a hash'
-    assert collections.size == 2, 'Expected two objects in the preloaded collection hash'
-    assert collections[uuid1], 'Expected collections for the passed in uuid'
-  end
-
-  test "object for dataclass" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    dataclass = ArvadosBase.resource_class_for_uuid(api_fixture('jobs')['running']['uuid'])
-    uuid = api_fixture('jobs')['running']['uuid']
-
-    obj = ac.send :object_for_dataclass, dataclass, uuid
-
-    assert obj, 'Expected object'
-    assert 'Job', obj.class
-    assert_equal uuid, obj['uuid'], 'Expected uuid not found'
-    assert_equal api_fixture('jobs')['running']['script_version'], obj['script_version'],
-      'Expected script_version not found'
-  end
-
-  test "preload objects for dataclass" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    dataclass = ArvadosBase.resource_class_for_uuid(api_fixture('jobs')['running']['uuid'])
-
-    uuid1 = api_fixture('jobs')['running']['uuid']
-    uuid2 = api_fixture('jobs')['running_cancelled']['uuid']
-
-    uuids = [uuid1, uuid2]
-    users = ac.send :preload_objects_for_dataclass, dataclass, uuids
-
-    assert users, 'Expected objects'
-    assert users.is_a?(Hash), 'Expected a hash'
-
-    assert users.size == 2, 'Expected two objects in the preloaded hash'
-    assert users[uuid1], 'Expected user object for the passed in uuid'
-    assert users[uuid2], 'Expected user object for the passed in uuid'
-
-    # invoke again for this same input. this time, the preloaded data will be returned
-    users = ac.send :preload_objects_for_dataclass, dataclass, uuids
-    assert users, 'Expected objects'
-    assert users.is_a?(Hash), 'Expected a hash'
-    assert users.size == 2, 'Expected two objects in the preloaded hash'
-
-    # invoke again for this with one more uuid
-    uuids << api_fixture('jobs')['foobar']['uuid']
-    users = ac.send :preload_objects_for_dataclass, dataclass, uuids
-    assert users, 'Expected objects'
-    assert users.is_a?(Hash), 'Expected a hash'
-    assert users.size == 3, 'Expected two objects in the preloaded hash'
-  end
-
-  test "preload one collection each for given portable_data_hash list" do
-    use_token :active
-
-    ac = ApplicationController.new
-
-    pdh1 = api_fixture('collections')['foo_file']['portable_data_hash']
-    pdh2 = api_fixture('collections')['bar_file']['portable_data_hash']
-
-    pdhs = [pdh1, pdh2]
-    collections = ac.send :preload_for_pdhs, pdhs
-
-    assert collections, 'Expected collections map'
-    assert collections.is_a?(Hash), 'Expected a hash'
-    # Each pdh has more than one collection; however, we should get only one for each
-    assert collections.size == 2, 'Expected two objects in the preloaded collection hash'
-    assert collections[pdh1], 'Expected collections for the passed in pdh #{pdh1}'
-    assert_equal collections[pdh1].size, 1, "Expected one collection for the passed in pdh #{pdh1}"
-    assert collections[pdh2], 'Expected collections for the passed in pdh #{pdh2}'
-    assert_equal collections[pdh2].size, 1, "Expected one collection for the passed in pdh #{pdh2}"
-  end
-
-  test "requesting a nonexistent object returns 404" do
-    # We're really testing ApplicationController's find_object_by_uuid.
-    # It's easiest to do that by instantiating a concrete controller.
-    @controller = NodesController.new
-    get(:show, params: {id: "zzzzz-zzzzz-zzzzzzzzzzzzzzz"}, session: session_for(:admin))
-    assert_response 404
-  end
-
-  test "requesting to the API server includes X-Request-Id header" do
-    got_header = nil
-    stub_api_calls
-    stub_api_client.stubs(:post).with do |url, query, header={}|
-      got_header = header
-      true
-    end.returns fake_api_response('{}', 200, {})
-
-    Rails.configuration.Users.AnonymousUserToken =
-      api_fixture("api_client_authorizations", "anonymous", "api_token")
-    @controller = ProjectsController.new
-    test_uuid = "zzzzz-j7d0g-zzzzzzzzzzzzzzz"
-    get(:show, params: {id: test_uuid})
-
-    assert_not_nil got_header
-    assert_includes got_header, 'X-Request-Id'
-    assert_match /^req-[0-9a-zA-Z]{20}$/, got_header["X-Request-Id"]
-  end
-
-  test "current request_id is nil after a request" do
-    @controller = NodesController.new
-    get(:index, params: {}, session: session_for(:active))
-    assert_nil Thread.current[:request_id]
-  end
-
-  test "X-Request-Id header" do
-    @controller = NodesController.new
-    get(:index, params: {}, session: session_for(:active))
-    assert_match /^req-[0-9a-zA-Z]{20}$/, response.headers['X-Request-Id']
-  end
-
-  [".navbar .login-menu a",
-   ".navbar .login-menu .dropdown-menu a"
-  ].each do |css_selector|
-    test "login link at #{css_selector.inspect} includes return_to param" do
-      # Without an anonymous token, we're immediately redirected to login.
-      Rails.configuration.Users.AnonymousUserToken =
-        api_fixture("api_client_authorizations", "anonymous", "api_token")
-      @controller = ProjectsController.new
-      test_uuid = "zzzzz-j7d0g-zzzzzzzzzzzzzzz"
-      get(:show, params: {id: test_uuid})
-      login_link = css_select(css_selector).first
-      assert_not_nil(login_link, "failed to select login link")
-      login_href = URI.unescape(login_link.attributes["href"].value)
-      # The parameter needs to include the full URL to work.
-      assert_includes(login_href, "://")
-      assert_match(/[\?&]return_to=[^&]*\/projects\/#{test_uuid}(&|$)/,
-                   login_href)
-    end
-  end
-
-  test "Workbench returns 4xx when API server is unreachable" do
-    # We're really testing ApplicationController's render_exception.
-    # Our primary concern is that it doesn't raise an error and
-    # return 500.
-    orig_api_server = Rails.configuration.Services.Controller.ExternalURL
-    begin
-      # The URL should look valid in all respects, and avoid talking over a
-      # network.  100::/64 is the IPv6 discard prefix, so it's perfect.
-      Rails.configuration.Services.Controller.ExternalURL = "https://[100::f]:1/"
-      @controller = NodesController.new
-      get(:index, params: {}, session: session_for(:active))
-      assert_includes(405..422, @response.code.to_i,
-                      "bad response code when API server is unreachable")
-    ensure
-      Rails.configuration.Services.Controller.ExternalURL = orig_api_server
-    end
-  end
-
-  [
-    [CollectionsController.new, api_fixture('collections')['user_agreement_in_anonymously_accessible_project']],
-    [CollectionsController.new, api_fixture('collections')['user_agreement_in_anonymously_accessible_project'], false],
-    [JobsController.new, api_fixture('jobs')['running_job_in_publicly_accessible_project']],
-    [JobsController.new, api_fixture('jobs')['running_job_in_publicly_accessible_project'], false],
-    [PipelineInstancesController.new, api_fixture('pipeline_instances')['pipeline_in_publicly_accessible_project']],
-    [PipelineInstancesController.new, api_fixture('pipeline_instances')['pipeline_in_publicly_accessible_project'], false],
-    [PipelineTemplatesController.new, api_fixture('pipeline_templates')['pipeline_template_in_publicly_accessible_project']],
-    [PipelineTemplatesController.new, api_fixture('pipeline_templates')['pipeline_template_in_publicly_accessible_project'], false],
-    [ProjectsController.new, api_fixture('groups')['anonymously_accessible_project']],
-    [ProjectsController.new, api_fixture('groups')['anonymously_accessible_project'], false],
-  ].each do |controller, fixture, anon_config=true|
-    test "#{controller} show method with anonymous config #{anon_config ? '' : 'not '}enabled" do
-      if anon_config
-        Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-      else
-        Rails.configuration.Users.AnonymousUserToken = ""
-      end
-
-      @controller = controller
-
-      get(:show, params: {id: fixture['uuid']})
-
-      if anon_config
-        assert_response 200
-        if controller.class == JobsController
-          assert_includes @response.inspect, fixture['script']
-        else
-          assert_includes @response.inspect, fixture['name']
-        end
-      else
-        assert_response :redirect
-        assert_match /\/users\/welcome/, @response.redirect_url
-      end
-    end
-  end
-
-  [
-    true,
-    false,
-  ].each do |config|
-    test "invoke show with include_accept_encoding_header config #{config}" do
-      Rails.configuration.APIResponseCompression = config
-
-      @controller = CollectionsController.new
-      get(:show, params: {id: api_fixture('collections')['foo_file']['uuid']}, session: session_for(:admin))
-
-      assert_equal([['.', 'foo', 3]], assigns(:object).files)
-    end
-  end
-
-  test 'Edit name and verify that a duplicate is not created' do
-    @controller = ProjectsController.new
-    project = api_fixture("groups")["aproject"]
-    post :update, params: {
-      id: project["uuid"],
-      project: {
-        name: 'test name'
-      },
-      format: :json
-    }, session: session_for(:active)
-    assert_includes @response.body, 'test name'
-    updated = assigns(:object)
-    assert_equal updated.uuid, project["uuid"]
-    assert_equal 'test name', updated.name
-  end
-
-  [
-    [VirtualMachinesController.new, 'hostname', false],
-    [UsersController.new, 'first_name', true],
-  ].each do |controller, expect_str, expect_home_link|
-    test "access #{controller.controller_name} index as admin and verify Home link is#{' not' if !expect_home_link} shown" do
-      @controller = controller
-
-      get :index, params: {}, session: session_for(:admin)
-
-      assert_response 200
-      assert_includes @response.body, expect_str
-
-      home_link = "/projects/#{api_fixture('users')['active']['uuid']}"
-
-      if expect_home_link
-        refute_empty css_select("[href=\"/projects/#{api_fixture('users')['active']['uuid']}\"]")
-      else
-        assert_empty css_select("[href=\"/projects/#{api_fixture('users')['active']['uuid']}\"]")
-      end
-    end
-  end
-
-  [
-    [VirtualMachinesController.new, 'hostname', true],
-    [UsersController.new, 'first_name', false],
-  ].each do |controller, expect_str, expect_delete_link|
-    test "access #{controller.controller_name} index as admin and verify Delete option is#{' not' if !expect_delete_link} shown" do
-      @controller = controller
-
-      get :index, params: {}, session: session_for(:admin)
-
-      assert_response 200
-      assert_includes @response.body, expect_str
-      if expect_delete_link
-        refute_empty css_select('[data-method=delete]')
-      else
-        assert_empty css_select('[data-method=delete]')
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/controllers/authorized_keys_controller_test.rb b/apps/workbench/test/controllers/authorized_keys_controller_test.rb
deleted file mode 100644 (file)
index fd55bc3..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class AuthorizedKeysControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/collections_controller_test.rb b/apps/workbench/test/controllers/collections_controller_test.rb
deleted file mode 100644 (file)
index a95b649..0000000
+++ /dev/null
@@ -1,745 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class CollectionsControllerTest < ActionController::TestCase
-  # These tests don't do state-changing API calls. Save some time by
-  # skipping the database reset.
-  reset_api_fixtures :after_each_test, false
-  reset_api_fixtures :after_suite, true
-
-  include PipelineInstancesHelper
-
-  NONEXISTENT_COLLECTION = "ffffffffffffffffffffffffffffffff+0"
-
-  def config_anonymous enable
-    Rails.configuration.Users.AnonymousUserToken =
-      if enable
-        api_token('anonymous')
-      else
-        ""
-      end
-  end
-
-  def collection_params(collection_name, file_name=nil)
-    uuid = api_fixture('collections')[collection_name.to_s]['uuid']
-    params = {uuid: uuid, id: uuid}
-    params[:file] = file_name if file_name
-    params
-  end
-
-  def assert_hash_includes(actual_hash, expected_hash, msg=nil)
-    expected_hash.each do |key, value|
-      if value.nil?
-        assert_nil(actual_hash[key], msg)
-      else
-        assert_equal(value, actual_hash[key], msg)
-      end
-    end
-  end
-
-  def assert_no_session
-    assert_hash_includes(session, {arvados_api_token: nil},
-                         "session includes unexpected API token")
-  end
-
-  def assert_session_for_auth(client_auth)
-    api_token =
-      self.api_token(client_auth.to_s)
-    assert_hash_includes(session, {arvados_api_token: api_token},
-                         "session token does not belong to #{client_auth}")
-  end
-
-  def show_collection(params, session={}, response=:success)
-    params = collection_params(params) if not params.is_a? Hash
-    session = session_for(session) if not session.is_a? Hash
-    get(:show, params: params, session: session)
-    assert_response response
-  end
-
-  test "viewing a collection" do
-    show_collection(:foo_file, :active)
-    assert_equal([['.', 'foo', 3]], assigns(:object).files)
-  end
-
-  test "viewing a collection with spaces in filename" do
-    show_collection(:w_a_z_file, :active)
-    assert_equal([['.', 'w a z', 5]], assigns(:object).files)
-  end
-
-  test "download a file with spaces in filename" do
-    setup_for_keep_web
-    collection = api_fixture('collections')['w_a_z_file']
-    get :show_file, params: {
-      uuid: collection['uuid'],
-      file: 'w a z'
-    }, session: session_for(:active)
-    assert_response :redirect
-    assert_match /w%20a%20z/, response.redirect_url
-  end
-
-  test "viewing a collection fetches related projects" do
-    show_collection({id: api_fixture('collections')["foo_file"]['portable_data_hash']}, :active)
-    assert_includes(assigns(:same_pdh).map(&:owner_uuid),
-                    api_fixture('groups')['aproject']['uuid'],
-                    "controller did not find linked project")
-  end
-
-  test "viewing a collection fetches related permissions" do
-    show_collection(:bar_file, :active)
-    assert_includes(assigns(:permissions).map(&:uuid),
-                    api_fixture('links')['bar_file_readable_by_active']['uuid'],
-                    "controller did not find permission link")
-  end
-
-  test "viewing a collection fetches jobs that output it" do
-    show_collection(:bar_file, :active)
-    assert_includes(assigns(:output_of).map(&:uuid),
-                    api_fixture('jobs')['foobar']['uuid'],
-                    "controller did not find output job")
-  end
-
-  test "viewing a collection fetches jobs that logged it" do
-    show_collection(:baz_file, :active)
-    assert_includes(assigns(:log_of).map(&:uuid),
-                    api_fixture('jobs')['foobar']['uuid'],
-                    "controller did not find logger job")
-  end
-
-  test "sharing auths available to admin" do
-    show_collection("collection_owned_by_active", "admin_trustedclient")
-    assert_not_nil assigns(:search_sharing)
-  end
-
-  test "sharing auths available to owner" do
-    show_collection("collection_owned_by_active", "active_trustedclient")
-    assert_not_nil assigns(:search_sharing)
-  end
-
-  test "sharing auths available to reader" do
-    show_collection("foo_collection_in_aproject",
-                    "project_viewer_trustedclient")
-    assert_not_nil assigns(:search_sharing)
-  end
-
-  test "viewing collection files with a reader token" do
-    params = collection_params(:foo_file)
-    params[:reader_token] = api_token("active_all_collections")
-    get(:show_file_links, params: params)
-    assert_response :redirect
-    assert_no_session
-  end
-
-  test "fetching collection file with reader token" do
-    setup_for_keep_web
-    params = collection_params(:foo_file, "foo")
-    params[:reader_token] = api_token("active_all_collections")
-    get(:show_file, params: params)
-    assert_response :redirect
-    assert_match /foo/, response.redirect_url
-    assert_no_session
-  end
-
-  test "reader token Collection links end with trailing slash" do
-    # Testing the fix for #2937.
-    session = session_for(:active_trustedclient)
-    post(:share, params: collection_params(:foo_file), session: session)
-    assert(@controller.download_link.ends_with? '/',
-           "Collection share link does not end with slash for wget")
-  end
-
-  test "getting a file from Keep" do
-    setup_for_keep_web
-    params = collection_params(:foo_file, 'foo')
-    sess = session_for(:active)
-    get(:show_file, params: params, session: sess)
-    assert_response :redirect
-    assert_match /foo/, response.redirect_url
-  end
-
-  test 'anonymous download' do
-    setup_for_keep_web
-    config_anonymous true
-    get :show_file, params: {
-      uuid: api_fixture('collections')['user_agreement_in_anonymously_accessible_project']['uuid'],
-      file: 'GNU_General_Public_License,_version_3.pdf',
-    }
-    assert_response :redirect
-    assert_match /GNU_General_Public_License/, response.redirect_url
-  end
-
-  test "can't get a file from Keep without permission" do
-    params = collection_params(:foo_file, 'foo')
-    sess = session_for(:spectator)
-    get(:show_file, params: params, session: sess)
-    assert_response 404
-  end
-
-  test "getting a file from Keep with a good reader token" do
-    setup_for_keep_web
-    params = collection_params(:foo_file, 'foo')
-    read_token = api_token('active')
-    params[:reader_token] = read_token
-    get(:show_file, params: params)
-    assert_response :redirect
-    assert_match /foo/, response.redirect_url
-    assert_not_equal(read_token, session[:arvados_api_token],
-                     "using a reader token set the session's API token")
-  end
-
-  [false, true].each do |anon|
-    test "download a file using a reader token with insufficient scope, anon #{anon}" do
-      config_anonymous anon
-      params = collection_params(:foo_file, 'foo')
-      params[:reader_token] =
-        api_token('active_noscope')
-      get(:show_file, params: params)
-      if anon
-        # Some files can be shown without a valid token, but not this one.
-        assert_response 404
-      else
-        # No files will ever be shown without a valid token. You
-        # should log in and try again.
-        assert_response :redirect
-      end
-    end
-  end
-
-  test "can get a file with an unpermissioned auth but in-scope reader token" do
-    setup_for_keep_web
-    params = collection_params(:foo_file, 'foo')
-    sess = session_for(:expired)
-    read_token = api_token('active')
-    params[:reader_token] = read_token
-    get(:show_file, params: params, session: sess)
-    assert_response :redirect
-    assert_not_equal(read_token, session[:arvados_api_token],
-                     "using a reader token set the session's API token")
-  end
-
-  test "inactive user can retrieve user agreement" do
-    setup_for_keep_web
-    ua_collection = api_fixture('collections')['user_agreement']
-    # Here we don't test whether the agreement can be retrieved from
-    # Keep. We only test that show_file decides to send file content.
-    get :show_file, params: {
-      uuid: ua_collection['uuid'],
-      file: ua_collection['manifest_text'].match(/ \d+:\d+:(\S+)/)[1]
-    }, session: session_for(:inactive)
-    assert_nil(assigns(:unsigned_user_agreements),
-               "Did not skip check_user_agreements filter " +
-               "when showing the user agreement.")
-    assert_response :redirect
-  end
-
-  test "requesting nonexistent Collection returns 404" do
-    show_collection({uuid: NONEXISTENT_COLLECTION, id: NONEXISTENT_COLLECTION},
-                    :active, 404)
-  end
-
-  test "show file in a subdirectory of a collection" do
-    setup_for_keep_web
-    params = collection_params(:collection_with_files_in_subdir, 'subdir2/subdir3/subdir4/file1_in_subdir4.txt')
-    get(:show_file, params: params, session: session_for(:user1_with_load))
-    assert_response :redirect
-    assert_match /subdir2\/subdir3\/subdir4\/file1_in_subdir4\.txt/, response.redirect_url
-  end
-
-  test 'provenance graph' do
-    use_token 'admin'
-
-    obj = find_fixture Collection, "graph_test_collection3"
-
-    provenance = obj.provenance.stringify_keys
-
-    [obj[:portable_data_hash]].each do |k|
-      assert_not_nil provenance[k], "Expected key #{k} in provenance set"
-    end
-
-    prov_svg = ProvenanceHelper::create_provenance_graph(provenance, "provenance_svg",
-                                                         {:request => RequestDuck,
-                                                           :direction => :bottom_up,
-                                                           :combine_jobs => :script_only})
-
-    stage1 = find_fixture Job, "graph_stage1"
-    stage3 = find_fixture Job, "graph_stage3"
-    previous_job_run = find_fixture Job, "previous_job_run"
-
-    obj_id = obj.portable_data_hash.gsub('+', '\\\+')
-    stage1_out = stage1.output.gsub('+', '\\\+')
-    stage1_id = "#{stage1.script}_#{Digest::MD5.hexdigest(stage1[:script_parameters].to_json)}"
-    stage3_id = "#{stage3.script}_#{Digest::MD5.hexdigest(stage3[:script_parameters].to_json)}"
-
-    assert /#{obj_id}&#45;&gt;#{stage3_id}/.match(prov_svg)
-
-    assert /#{stage3_id}&#45;&gt;#{stage1_out}/.match(prov_svg)
-
-    assert /#{stage1_out}&#45;&gt;#{stage1_id}/.match(prov_svg)
-
-  end
-
-  test 'used_by graph' do
-    use_token 'admin'
-    obj = find_fixture Collection, "graph_test_collection1"
-
-    used_by = obj.used_by.stringify_keys
-
-    used_by_svg = ProvenanceHelper::create_provenance_graph(used_by, "used_by_svg",
-                                                            {:request => RequestDuck,
-                                                              :direction => :top_down,
-                                                              :combine_jobs => :script_only,
-                                                              :pdata_only => true})
-
-    stage2 = find_fixture Job, "graph_stage2"
-    stage3 = find_fixture Job, "graph_stage3"
-
-    stage2_id = "#{stage2.script}_#{Digest::MD5.hexdigest(stage2[:script_parameters].to_json)}"
-    stage3_id = "#{stage3.script}_#{Digest::MD5.hexdigest(stage3[:script_parameters].to_json)}"
-
-    obj_id = obj.portable_data_hash.gsub('+', '\\\+')
-    stage3_out = stage3.output.gsub('+', '\\\+')
-
-    assert /#{obj_id}&#45;&gt;#{stage2_id}/.match(used_by_svg)
-
-    assert /#{obj_id}&#45;&gt;#{stage3_id}/.match(used_by_svg)
-
-    assert /#{stage3_id}&#45;&gt;#{stage3_out}/.match(used_by_svg)
-
-    assert /#{stage3_id}&#45;&gt;#{stage3_out}/.match(used_by_svg)
-
-  end
-
-  test "view collection with empty properties" do
-    fixture_name = :collection_with_empty_properties
-    show_collection(fixture_name, :active)
-    assert_equal(api_fixture('collections')[fixture_name.to_s]['name'], assigns(:object).name)
-    assert_not_nil(assigns(:object).properties)
-    assert_empty(assigns(:object).properties)
-  end
-
-  test "view collection with one property" do
-    fixture_name = :collection_with_one_property
-    show_collection(fixture_name, :active)
-    fixture = api_fixture('collections')[fixture_name.to_s]
-    assert_equal(fixture['name'], assigns(:object).name)
-    assert_equal(fixture['properties'].values[0], assigns(:object).properties.values[0])
-  end
-
-  test "create collection with properties" do
-    post :create, params: {
-      collection: {
-        name: 'collection created with properties',
-        manifest_text: '',
-        properties: {
-          property_1: 'value_1'
-        },
-      },
-      format: :json
-    }, session: session_for(:active)
-    assert_response :success
-    assert_not_nil assigns(:object).uuid
-    assert_equal 'collection created with properties', assigns(:object).name
-    assert_equal 'value_1', assigns(:object).properties[:property_1]
-  end
-
-  test "update description and check manifest_text is not lost" do
-    collection = api_fixture("collections")["multilevel_collection_1"]
-    post :update, params: {
-      id: collection["uuid"],
-      collection: {
-        description: 'test description update'
-      },
-      format: :json
-    }, session: session_for(:active)
-    assert_response :success
-    assert_not_nil assigns(:object)
-    # Ensure the Workbench response still has the original manifest_text
-    assert_equal 'test description update', assigns(:object).description
-    assert_equal true, strip_signatures_and_compare(collection['manifest_text'], assigns(:object).manifest_text)
-    # Ensure the API server still has the original manifest_text after
-    # we called arvados.v1.collections.update
-    use_token :active do
-      assert_equal true, strip_signatures_and_compare(Collection.find(collection['uuid']).manifest_text,
-                                                      collection['manifest_text'])
-    end
-  end
-
-  # Since we got the initial collection from fixture, there are no signatures in manifest_text.
-  # However, after update or find, the collection retrieved will have singed manifest_text.
-  # Hence, let's compare each line after excluding signatures.
-  def strip_signatures_and_compare m1, m2
-    m1_lines = m1.split "\n"
-    m2_lines = m2.split "\n"
-
-    return false if m1_lines.size != m2_lines.size
-
-    m1_lines.each_with_index do |line, i|
-      m1_words = []
-      line.split.each do |word|
-        m1_words << word.split('+A')[0]
-      end
-      m2_words = []
-      m2_lines[i].split.each do |word|
-        m2_words << word.split('+A')[0]
-      end
-      return false if !m1_words.join(' ').eql?(m2_words.join(' '))
-    end
-
-    return true
-  end
-
-  test "view collection and verify none of the file types listed are disabled" do
-    show_collection(:collection_with_several_supported_file_types, :active)
-
-    files = assigns(:object).files
-    assert_equal true, files.length>0, "Expected one or more files in collection"
-
-    disabled = css_select('[disabled="disabled"]').collect do |el|
-      el
-    end
-    assert_equal 0, disabled.length, "Expected no disabled files in collection viewables list"
-  end
-
-  test "view collection and verify file types listed are all disabled" do
-    show_collection(:collection_with_several_unsupported_file_types, :active)
-
-    files = assigns(:object).files.collect do |_, file, _|
-      file
-    end
-    assert_equal true, files.length>0, "Expected one or more files in collection"
-
-    disabled = css_select('[disabled="disabled"]').collect do |el|
-      el.attributes['title'].value.split[-1]
-    end
-
-    assert_equal files.sort, disabled.sort, "Expected to see all collection files in disabled list of files"
-  end
-
-  test "anonymous user accesses collection in shared project" do
-    config_anonymous true
-    collection = api_fixture('collections')['public_text_file']
-    get(:show, params: {id: collection['uuid']})
-
-    response_object = assigns(:object)
-    assert_equal collection['name'], response_object['name']
-    assert_equal collection['uuid'], response_object['uuid']
-    assert_includes @response.body, 'Hello world'
-    assert_includes @response.body, 'Content address'
-    refute_nil css_select('[href="#Advanced"]')
-  end
-
-  test "can view empty collection" do
-    get :show, params: {id: 'd41d8cd98f00b204e9800998ecf8427e+0'}, session: session_for(:active)
-    assert_includes @response.body, 'The following collections have this content'
-  end
-
-  test "collection portable data hash redirect" do
-    di = api_fixture('collections')['docker_image']
-    get :show, params: {id: di['portable_data_hash']}, session: session_for(:active)
-    assert_match /\/collections\/#{di['uuid']}/, @response.redirect_url
-  end
-
-  test "collection portable data hash with multiple matches" do
-    pdh = api_fixture('collections')['foo_file']['portable_data_hash']
-    get :show, params: {id: pdh}, session: session_for(:admin)
-    matches = api_fixture('collections').select {|k,v| v["portable_data_hash"] == pdh}
-    assert matches.size > 1
-
-    matches.each do |k,v|
-      assert_match /href="\/collections\/#{v['uuid']}">.*#{v['name']}<\/a>/, @response.body
-    end
-
-    assert_includes @response.body, 'The following collections have this content:'
-    assert_not_includes @response.body, 'more results are not shown'
-    assert_not_includes @response.body, 'Activity'
-    assert_not_includes @response.body, 'Sharing and permissions'
-  end
-
-  test "collection page renders name" do
-    collection = api_fixture('collections')['foo_file']
-    get :show, params: {id: collection['uuid']}, session: session_for(:active)
-    assert_includes @response.body, collection['name']
-    assert_match /not authorized to manage collection sharing links/, @response.body
-  end
-
-  test "No Upload tab on non-writable collection" do
-    get :show,
-        params: {id: api_fixture('collections')['user_agreement']['uuid']},
-        session: session_for(:active)
-    assert_not_includes @response.body, '<a href="#Upload"'
-  end
-
-  def setup_for_keep_web cfg='https://*.example', dl_cfg=""
-    Rails.configuration.Services.WebDAV.ExternalURL = URI(cfg)
-    Rails.configuration.Services.WebDAVDownload.ExternalURL = URI(dl_cfg)
-  end
-
-  %w(uuid portable_data_hash).each do |id_type|
-    test "Redirect to keep_web_url via #{id_type}" do
-      setup_for_keep_web
-      tok = api_token('active')
-      id = api_fixture('collections')['w_a_z_file'][id_type]
-      get :show_file,
-          params: {uuid: id, file: "w a z"},
-          session: session_for(:active)
-      assert_response :redirect
-      assert_equal "https://#{id.sub '+', '-'}.example/_/w%20a%20z?api_token=#{URI.escape tok, '/'}", @response.redirect_url
-    end
-
-    test "Redirect to keep_web_url via #{id_type} with reader token" do
-      setup_for_keep_web
-      tok = api_token('active')
-      id = api_fixture('collections')['w_a_z_file'][id_type]
-      get :show_file,
-          params: {uuid: id, file: "w a z", reader_token: tok},
-          session: session_for(:expired)
-      assert_response :redirect
-      assert_equal "https://#{id.sub '+', '-'}.example/t=#{URI.escape tok}/_/w%20a%20z", @response.redirect_url
-    end
-
-    test "Redirect to keep_web_url via #{id_type} with no token" do
-      setup_for_keep_web
-      config_anonymous true
-      id = api_fixture('collections')['public_text_file'][id_type]
-      get :show_file, params: {uuid: id, file: "Hello World.txt"}
-      assert_response :redirect
-      assert_equal "https://#{id.sub '+', '-'}.example/_/Hello%20World.txt", @response.redirect_url
-    end
-
-    test "Redirect to keep_web_url via #{id_type} with disposition param" do
-      setup_for_keep_web
-      config_anonymous true
-      id = api_fixture('collections')['public_text_file'][id_type]
-      get :show_file, params: {
-        uuid: id,
-        file: "Hello World.txt",
-        disposition: 'attachment',
-      }
-      assert_response :redirect
-      assert_equal "https://#{id.sub '+', '-'}.example/_/Hello%20World.txt?disposition=attachment", @response.redirect_url
-    end
-
-    test "Redirect to keep_web_download_url via #{id_type}" do
-      setup_for_keep_web('https://collections.example',
-                         'https://download.example')
-      tok = api_token('active')
-      id = api_fixture('collections')['w_a_z_file'][id_type]
-      get :show_file, params: {uuid: id, file: "w a z"}, session: session_for(:active)
-      assert_response :redirect
-      assert_equal "https://download.example/c=#{id.sub '+', '-'}/_/w%20a%20z?api_token=#{URI.escape tok, '/'}", @response.redirect_url
-    end
-
-    test "Redirect to keep_web_url via #{id_type} when trust_all_content enabled" do
-      Rails.configuration.Collections.TrustAllContent = true
-      setup_for_keep_web('https://collections.example',
-                         'https://download.example')
-      tok = api_token('active')
-      id = api_fixture('collections')['w_a_z_file'][id_type]
-      get :show_file, params: {uuid: id, file: "w a z"}, session: session_for(:active)
-      assert_response :redirect
-      assert_equal "https://collections.example/c=#{id.sub '+', '-'}/_/w%20a%20z?api_token=#{URI.escape tok, '/'}", @response.redirect_url
-    end
-  end
-
-  [false, true].each do |anon|
-    test "No redirect to keep_web_url if collection not found, anon #{anon}" do
-      setup_for_keep_web
-      config_anonymous anon
-      id = api_fixture('collections')['w_a_z_file']['uuid']
-      get :show_file, params: {uuid: id, file: "w a z"}, session: session_for(:spectator)
-      assert_response 404
-    end
-
-    test "Redirect download to keep_web_download_url, anon #{anon}" do
-      config_anonymous anon
-      setup_for_keep_web('https://collections.example/',
-                         'https://download.example/')
-      tok = api_token('active')
-      id = api_fixture('collections')['public_text_file']['uuid']
-      get :show_file, params: {
-        uuid: id,
-        file: 'Hello world.txt',
-        disposition: 'attachment',
-      }, session: session_for(:active)
-      assert_response :redirect
-      expect_url = "https://download.example/c=#{id.sub '+', '-'}/_/Hello%20world.txt"
-      if not anon
-        expect_url += "?api_token=#{URI.escape tok, '/'}"
-      end
-      assert_equal expect_url, @response.redirect_url
-    end
-  end
-
-  test "Error if file is impossible to retrieve from keep_web_url" do
-    # Cannot pass a session token using a single-origin keep-web URL,
-    # cannot read this collection without a session token.
-    setup_for_keep_web 'https://collections.example/', ""
-    id = api_fixture('collections')['w_a_z_file']['uuid']
-    get :show_file, params: {uuid: id, file: "w a z"}, session: session_for(:active)
-    assert_response 422
-  end
-
-  [false, true].each do |trust_all_content|
-    test "Redirect preview to keep_web_download_url when preview is disabled and trust_all_content is #{trust_all_content}" do
-      Rails.configuration.Collections.TrustAllContent = trust_all_content
-      setup_for_keep_web "", 'https://download.example/'
-      tok = api_token('active')
-      id = api_fixture('collections')['w_a_z_file']['uuid']
-      get :show_file, params: {uuid: id, file: "w a z"}, session: session_for(:active)
-      assert_response :redirect
-      assert_equal "https://download.example/c=#{id.sub '+', '-'}/_/w%20a%20z?api_token=#{URI.escape tok, '/'}", @response.redirect_url
-    end
-  end
-
-  test "remove selected files from collection" do
-    use_token :active
-
-    # create a new collection to test; using existing collections will cause other tests to fail,
-    # and resetting fixtures after each test makes it take almost 4 times to run this test file.
-    manifest_text = ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:file1 0:0:file2\n./dir1 d41d8cd98f00b204e9800998ecf8427e+0 0:0:file1 0:0:file2\n"
-
-    collection = Collection.create(manifest_text: manifest_text)
-    assert_includes(collection['manifest_text'], "0:0:file1")
-
-    # now remove all files named 'file1' from the collection
-    post :remove_selected_files, params: {
-      id: collection['uuid'],
-      selection: ["#{collection['uuid']}/file1",
-                  "#{collection['uuid']}/dir1/file1"],
-      format: :json
-    }, session: session_for(:active)
-    assert_response :success
-
-    use_token :active
-    # verify no 'file1' in the updated collection
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: collection['uuid']).first
-    assert_not_includes(collection['manifest_text'], "0:0:file1")
-    assert_includes(collection['manifest_text'], "0:0:file2") # but other files still exist
-  end
-
-  test "remove all files from a subdir of a collection" do
-    use_token :active
-
-    # create a new collection to test
-    manifest_text = ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:file1 0:0:file2\n./dir1 d41d8cd98f00b204e9800998ecf8427e+0 0:0:file1 0:0:file2\n"
-
-    collection = Collection.create(manifest_text: manifest_text)
-    assert_includes(collection['manifest_text'], "0:0:file1")
-
-    # now remove all files from "dir1" subdir of the collection
-    post :remove_selected_files, params: {
-      id: collection['uuid'],
-      selection: ["#{collection['uuid']}/dir1/file1",
-                  "#{collection['uuid']}/dir1/file2"],
-      format: :json
-    }, session: session_for(:active)
-    assert_response :success
-
-    # verify that "./dir1" no longer exists in this collection's manifest text
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: collection['uuid']).first
-    assert_match /. d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:file1 0:0:file2\n$/, collection['manifest_text']
-    assert_not_includes(collection['manifest_text'], 'dir1')
-  end
-
-  test "rename file in a collection" do
-    use_token :active
-
-    # create a new collection to test
-    manifest_text = ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:file1 0:0:file2\n./dir1 d41d8cd98f00b204e9800998ecf8427e+0 0:0:dir1file1 0:0:dir1file2 0:0:dir1imagefile.png\n"
-
-    collection = Collection.create(manifest_text: manifest_text)
-    assert_includes(collection['manifest_text'], "0:0:file1")
-
-    # rename 'file1' as 'file1renamed' and verify
-    post :update, params: {
-      id: collection['uuid'],
-      collection: {
-        'rename-file-path:file1' => 'file1renamed'
-      },
-      format: :json
-    }, session: session_for(:active)
-    assert_response :success
-
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: collection['uuid']).first
-    assert_match /. d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:file1renamed 0:0:file2\n.\/dir1 d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:dir1file1 0:0:dir1file2 0:0:dir1imagefile.png\n$/, collection['manifest_text']
-
-    # now rename 'file2' such that it is moved into 'dir1'
-    @test_counter = 0
-    post :update, params: {
-      id: collection['uuid'],
-      collection: {
-        'rename-file-path:file2' => 'dir1/file2'
-      },
-      format: :json
-    }, session: session_for(:active)
-    assert_response :success
-
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: collection['uuid']).first
-    assert_match /. d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:file1renamed\n.\/dir1 d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:dir1file1 0:0:dir1file2 0:0:dir1imagefile.png 0:0:file2\n$/, collection['manifest_text']
-
-    # now rename 'dir1/dir1file1' such that it is moved into a new subdir
-    @test_counter = 0
-    post :update, params: {
-      id: collection['uuid'],
-      collection: {
-        'rename-file-path:dir1/dir1file1' => 'dir2/dir3/dir1file1moved'
-      },
-      format: :json
-    }, session: session_for(:active)
-    assert_response :success
-
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: collection['uuid']).first
-    assert_match /. d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:file1renamed\n.\/dir1 d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:dir1file2 0:0:dir1imagefile.png 0:0:file2\n.\/dir2\/dir3 d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:dir1file1moved\n$/, collection['manifest_text']
-
-    # now rename the image file 'dir1/dir1imagefile.png'
-    @test_counter = 0
-    post :update, params: {
-      id: collection['uuid'],
-      collection: {
-        'rename-file-path:dir1/dir1imagefile.png' => 'dir1/dir1imagefilerenamed.png'
-      },
-      format: :json
-    }, session: session_for(:active)
-    assert_response :success
-
-    use_token :active
-    collection = Collection.select([:uuid, :manifest_text]).where(uuid: collection['uuid']).first
-    assert_match /. d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:file1renamed\n.\/dir1 d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:dir1file2 0:0:dir1imagefilerenamed.png 0:0:file2\n.\/dir2\/dir3 d41d8cd98f00b204e9800998ecf8427e\+0\+A(.*) 0:0:dir1file1moved\n$/, collection['manifest_text']
-  end
-
-  test "renaming file with a duplicate name in same stream not allowed" do
-    use_token :active
-
-    # rename 'file2' as 'file1' and expect error
-    post :update, params: {
-      id: 'zzzzz-4zz18-pyw8yp9g3pr7irn',
-      collection: {
-        'rename-file-path:file2' => 'file1'
-      },
-      format: :json
-    }, session: session_for(:active)
-    assert_response 422
-    assert_includes json_response['errors'], 'Duplicate file path'
-  end
-
-  test "renaming file with a duplicate name as another stream not allowed" do
-    use_token :active
-
-    # rename 'file1' as 'dir1/file1' and expect error
-    post :update, params: {
-      id: 'zzzzz-4zz18-pyw8yp9g3pr7irn',
-      collection: {
-        'rename-file-path:file1' => 'dir1/file1'
-      },
-      format: :json
-    }, session: session_for(:active)
-    assert_response 422
-    assert_includes json_response['errors'], 'Duplicate file path'
-  end
-end
diff --git a/apps/workbench/test/controllers/container_requests_controller_test.rb b/apps/workbench/test/controllers/container_requests_controller_test.rb
deleted file mode 100644 (file)
index c8709df..0000000
+++ /dev/null
@@ -1,144 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ContainerRequestsControllerTest < ActionController::TestCase
-  test "visit completed container request log tab" do
-    use_token 'active'
-
-    cr = api_fixture('container_requests')['completed']
-    container_uuid = cr['container_uuid']
-    container = Container.find(container_uuid)
-
-    get :show, params: {id: cr['uuid'], tab_pane: 'Log'}, session: session_for(:active)
-    assert_response :success
-
-    assert_select "a", {:href=>"/collections/#{container['log']}", :text=>"Download the log"}
-    assert_select "a", {:href=>"#{container['log']}/baz"}
-    assert_not_includes @response.body, '<pre id="event_log_div"'
-  end
-
-  test "visit running container request log tab" do
-    use_token 'active'
-
-    cr = api_fixture('container_requests')['running']
-    container_uuid = cr['container_uuid']
-    container = Container.find(container_uuid)
-
-    get :show, params: {id: cr['uuid'], tab_pane: 'Log'}, session: session_for(:active)
-    assert_response :success
-
-    assert_includes @response.body, '<pre id="event_log_div"'
-    assert_select 'Download the log', false
-  end
-
-  test "completed container request offers re-run option" do
-    use_token 'active'
-
-    uuid = api_fixture('container_requests')['completed']['uuid']
-
-    get :show, params: {id: uuid}, session: session_for(:active)
-    assert_response :success
-
-    assert_includes @response.body, "action_href=%2Fcontainer_requests%2F#{uuid}%2Fcopy"
-  end
-
-  test "cancel request for queued container" do
-    cr_fixture = api_fixture('container_requests')['queued']
-    post :cancel, params: {id: cr_fixture['uuid']}, session: session_for(:active)
-    assert_response 302
-
-    use_token 'active'
-    cr = ContainerRequest.find(cr_fixture['uuid'])
-    assert_equal 'Final', cr.state
-    assert_equal 0, cr.priority
-    c = Container.find(cr_fixture['container_uuid'])
-    assert_equal 'Queued', c.state
-    assert_equal 0, c.priority
-  end
-
-  [
-    ['completed',       false, false],
-    ['completed',        true, false],
-    ['completed',         nil, false],
-    ['completed-older', false, true],
-    ['completed-older',  true, true],
-    ['completed-older',   nil, true],
-  ].each do |cr_fixture, reuse_enabled, uses_acr|
-    test "container request #{uses_acr ? '' : 'not'} using arvados-cwl-runner copy #{reuse_enabled.nil? ? 'nil' : (reuse_enabled ? 'with' : 'without')} reuse enabled" do
-      completed_cr = api_fixture('container_requests')[cr_fixture]
-      # Set up post request params
-      copy_params = {id: completed_cr['uuid']}
-      if !reuse_enabled.nil?
-        copy_params.merge!({use_existing: reuse_enabled})
-      end
-      post(:copy, params: copy_params, session: session_for(:active))
-      assert_response 302
-      copied_cr = assigns(:object)
-      assert_not_nil copied_cr
-      assert_equal 'Uncommitted', copied_cr[:state]
-      assert_equal "Copy of #{completed_cr['name']}", copied_cr['name']
-      assert_equal completed_cr['runtime_constraints']['ram'], copied_cr['runtime_constraints'][:ram]
-      if reuse_enabled
-        assert copied_cr[:use_existing]
-      else
-        refute copied_cr[:use_existing]
-      end
-      # If the CR's command is arvados-cwl-runner, the appropriate flag should
-      # be passed to it
-      if uses_acr
-        assert_equal copied_cr['command'][0], 'arvados-cwl-runner'
-        if reuse_enabled.nil? || reuse_enabled
-          assert_includes copied_cr['command'], '--enable-reuse'
-          assert_not_includes copied_cr['command'], '--disable-reuse'
-        else
-          assert_includes copied_cr['command'], '--disable-reuse'
-          assert_not_includes copied_cr['command'], '--enable-reuse'
-        end
-      else
-        # If no arvados-cwl-runner is being used, the command should be left alone
-        assert_equal completed_cr['command'], copied_cr['command']
-      end
-    end
-  end
-
-  [
-    ['completed', true],
-    ['running', true],
-    ['queued', true],
-    ['uncommitted', false],
-  ].each do |cr_fixture, should_show|
-    test "provenance tab should #{should_show ? '' : 'not'} be shown on #{cr_fixture} container requests" do
-      cr = api_fixture('container_requests')[cr_fixture]
-      assert_not_nil cr
-      get(:show,
-          params: {id: cr['uuid']},
-          session: session_for(:active))
-      assert_response :success
-      if should_show
-        assert_includes @response.body, "href=\"#Provenance\""
-      else
-        assert_not_includes @response.body, "href=\"#Provenance\""
-      end
-    end
-  end
-
-  test "container request display" do
-    use_token 'active'
-
-    cr = api_fixture('container_requests')['completed_with_input_mounts']
-
-    get :show, params: {id: cr['uuid']}, session: session_for(:active)
-    assert_response :success
-
-    assert_match /hello/, @response.body
-    assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/baz\?" # locator on command
-    assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/foobar\?" # locator on command
-    assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/foo" # mount input1
-    assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/bar" # mount input2
-    assert_includes @response.body, "href=\"#Log\""
-    assert_includes @response.body, "href=\"#Provenance\""
-  end
-end
diff --git a/apps/workbench/test/controllers/containers_controller_test.rb b/apps/workbench/test/controllers/containers_controller_test.rb
deleted file mode 100644 (file)
index ff7584e..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ContainersControllerTest < ActionController::TestCase
-  test "visit container log" do
-    use_token 'active'
-
-    container = api_fixture('containers')['completed']
-
-    get :show,
-        params: {id: container['uuid'], tab_pane: 'Log'},
-        session: session_for(:active)
-    assert_response :success
-
-    assert_select "a", {:href=>"/collections/#{container['log']}", :text=>"Download the log"}
-    assert_select "a", {:href=>"#{container['log']}/baz"}
-  end
-end
diff --git a/apps/workbench/test/controllers/disabled_api_test.rb b/apps/workbench/test/controllers/disabled_api_test.rb
deleted file mode 100644 (file)
index 9144564..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-require 'helpers/share_object_helper'
-
-class DisabledApiTest < ActionController::TestCase
-  reset_api_fixtures :after_each_test, false
-  reset_api_fixtures :after_suite, false
-
-  test "dashboard recent processes when pipeline_instance index API is disabled" do
-    @controller = ProjectsController.new
-
-    dd = ArvadosApiClient.new_or_current.discovery.deep_dup
-    dd[:resources][:pipeline_instances][:methods].delete(:index)
-    ArvadosApiClient.any_instance.stubs(:discovery).returns(dd)
-
-    get :index, params: {}, session: session_for(:active)
-    assert_includes @response.body, "zzzzz-xvhdp-cr4runningcntnr" # expect crs
-    assert_not_includes @response.body, "zzzzz-d1hrv-"   # expect no pipelines
-    assert_includes @response.body, "Run a process"
-  end
-
-  test "dashboard compute node status not shown when pipeline_instance index API is disabled" do
-    @controller = ProjectsController.new
-
-    dd = ArvadosApiClient.new_or_current.discovery.deep_dup
-    dd[:resources][:pipeline_instances][:methods].delete(:index)
-    ArvadosApiClient.any_instance.stubs(:discovery).returns(dd)
-
-    get :index, params: {}, session: session_for(:active)
-    assert_not_includes @response.body, "compute-node-summary-pane"
-  end
-
-  [
-    [:jobs, JobsController.new],
-    [:job_tasks, JobTasksController.new],
-    [:pipeline_instances, PipelineInstancesController.new],
-    [:pipeline_templates, PipelineTemplatesController.new],
-  ].each do |ctrl_name, ctrl|
-    test "#{ctrl_name} index page when API is disabled" do
-      @controller = ctrl
-
-      dd = ArvadosApiClient.new_or_current.discovery.deep_dup
-      dd[:resources][ctrl_name][:methods].delete(:index)
-      ArvadosApiClient.any_instance.stubs(:discovery).returns(dd)
-
-      get :index, params: {}, session: session_for(:active)
-      assert_response 404
-    end
-  end
-
-  [
-    :admin,
-    :active,
-    nil,
-  ].each do |user|
-    test "project tabs as user #{user} when pipeline related index APIs are disabled" do
-      @controller = ProjectsController.new
-
-      Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-
-      dd = ArvadosApiClient.new_or_current.discovery.deep_dup
-      dd[:resources][:pipeline_templates][:methods].delete(:index)
-      ArvadosApiClient.any_instance.stubs(:discovery).returns(dd)
-
-      proj_uuid = api_fixture('groups')['anonymously_accessible_project']['uuid']
-
-      if user
-        get(:show, params: {id: proj_uuid}, session: session_for(user))
-      else
-        get(:show, params: {id: proj_uuid})
-      end
-
-      resp = @response.body
-      assert_includes resp, "href=\"#Data_collections\""
-      assert_includes resp, "href=\"#Pipelines_and_processes\""
-      assert_includes resp, "href=\"#Workflows\""
-      assert_not_includes resp, "href=\"#Pipeline_templates\""
-      assert_includes @response.body, "Run a process" if user == :admin
-    end
-  end
-end
diff --git a/apps/workbench/test/controllers/groups_controller_test.rb b/apps/workbench/test/controllers/groups_controller_test.rb
deleted file mode 100644 (file)
index 83f0c9d..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ProjectsControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/humans_controller_test.rb b/apps/workbench/test/controllers/humans_controller_test.rb
deleted file mode 100644 (file)
index 08553c4..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class HumansControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/job_tasks_controller_test.rb b/apps/workbench/test/controllers/job_tasks_controller_test.rb
deleted file mode 100644 (file)
index faccfdb..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class JobTasksControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/jobs_controller_test.rb b/apps/workbench/test/controllers/jobs_controller_test.rb
deleted file mode 100644 (file)
index 29a8efc..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class JobsControllerTest < ActionController::TestCase
-  test "visit jobs index page" do
-    get :index, params: {}, session: session_for(:active)
-    assert_response :success
-  end
-
-  test "job page lists pipelines and jobs in which it is used" do
-    get(:show,
-        params: {id: api_fixture('jobs')['completed_job_in_publicly_accessible_project']['uuid']},
-        session: session_for(:active))
-    assert_response :success
-
-    assert_select "div.used-in-pipelines" do
-      assert_select "a[href=\"/pipeline_instances/zzzzz-d1hrv-n68vc490mloy4fi\"]"
-    end
-
-    assert_select "div.used-in-jobs" do
-      assert_select "a[href=\"/jobs/zzzzz-8i9sb-with2components\"]"
-    end
-  end
-end
diff --git a/apps/workbench/test/controllers/keep_disks_controller_test.rb b/apps/workbench/test/controllers/keep_disks_controller_test.rb
deleted file mode 100644 (file)
index b421dd7..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class KeepDisksControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/links_controller_test.rb b/apps/workbench/test/controllers/links_controller_test.rb
deleted file mode 100644 (file)
index 7ff5457..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class MetadataControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/logs_controller_test.rb b/apps/workbench/test/controllers/logs_controller_test.rb
deleted file mode 100644 (file)
index 4699c0d..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class LogsControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/management_controller_test.rb b/apps/workbench/test/controllers/management_controller_test.rb
deleted file mode 100644 (file)
index 80dc944..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ManagementControllerTest < ActionController::TestCase
-  reset_api_fixtures :after_each_test, false
-  reset_api_fixtures :after_suite, false
-
-  [
-    [false, nil, 404, 'disabled'],
-    [true, nil, 401, 'authorization required'],
-    [true, 'badformatwithnoBearer', 403, 'authorization error'],
-    [true, 'Bearer wrongtoken', 403, 'authorization error'],
-    [true, 'Bearer configuredmanagementtoken', 200, '{"health":"OK"}'],
-  ].each do |enabled, header, error_code, error_msg|
-    test "health check ping when #{if enabled then 'enabled' else 'disabled' end} with header '#{header}'" do
-      if enabled
-        Rails.configuration.ManagementToken = 'configuredmanagementtoken'
-      else
-        Rails.configuration.ManagementToken = ""
-      end
-
-      @request.headers['Authorization'] = header
-      get(:health, params: {check: 'ping'})
-      assert_response error_code
-
-      resp = JSON.parse(@response.body)
-      if error_code == 200
-        assert_equal(JSON.load('{"health":"OK"}'), resp)
-      else
-        assert_equal(resp['errors'], error_msg)
-      end
-    end
-  end
-
-  test "metrics" do
-    mtime = File.mtime(ENV["ARVADOS_CONFIG"])
-    hash = Digest::SHA256.hexdigest(File.read(ENV["ARVADOS_CONFIG"]))
-    Rails.configuration.ManagementToken = "configuredmanagementtoken"
-    @request.headers['Authorization'] = "Bearer configuredmanagementtoken"
-    get :metrics
-    assert_response :success
-    assert_equal 'text/plain', @response.content_type
-
-    assert_match /\narvados_config_source_timestamp_seconds{sha256="#{hash}"} #{Regexp.escape mtime.utc.to_f.to_s}\n/, @response.body
-
-    # Expect mtime < loadtime < now
-    m = @response.body.match(/\narvados_config_load_timestamp_seconds{sha256="#{hash}"} (.*?)\n/)
-    assert_operator m[1].to_f, :>, mtime.utc.to_f
-    assert_operator m[1].to_f, :<, Time.now.utc.to_f
-
-    assert_match /\narvados_version_running{version="#{Regexp.escape AppVersion.package_version}"} 1\n/, @response.body
-  end
-
-  test "metrics disabled" do
-    Rails.configuration.ManagementToken = ""
-    @request.headers['Authorization'] = "Bearer configuredmanagementtoken"
-    get :metrics
-    assert_response 404
-  end
-
-  test "metrics bad token" do
-    Rails.configuration.ManagementToken = "configuredmanagementtoken"
-    @request.headers['Authorization'] = "Bearer asdf"
-    get :metrics
-    assert_response 403
-  end
-
-  test "metrics unauthorized" do
-    Rails.configuration.ManagementToken = "configuredmanagementtoken"
-    get :metrics
-    assert_response 401
-  end
-end
diff --git a/apps/workbench/test/controllers/nodes_controller_test.rb b/apps/workbench/test/controllers/nodes_controller_test.rb
deleted file mode 100644 (file)
index c7e4867..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class NodesControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/pipeline_instances_controller_test.rb b/apps/workbench/test/controllers/pipeline_instances_controller_test.rb
deleted file mode 100644 (file)
index 4067834..0000000
+++ /dev/null
@@ -1,233 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class PipelineInstancesControllerTest < ActionController::TestCase
-  include PipelineInstancesHelper
-
-  def create_instance_long_enough_to(instance_attrs={})
-    # create 'two_part' pipeline with the given instance attributes
-    pt_fixture = api_fixture('pipeline_templates')['two_part']
-    post :create, params: {
-      pipeline_instance: instance_attrs.merge({
-        pipeline_template_uuid: pt_fixture['uuid']
-      }),
-      format: :json
-    }, session: session_for(:active)
-    assert_response :success
-    pi_uuid = assigns(:object).uuid
-    assert_not_nil assigns(:object)
-
-    # yield
-    yield pi_uuid, pt_fixture
-
-    # delete the pipeline instance
-    use_token :active
-    PipelineInstance.where(uuid: pi_uuid).first.destroy
-  end
-
-  test "can render pipeline instance with tagged collections" do
-    # Make sure to pass in a tagged collection to test that part of the rendering behavior.
-    get(:show,
-        params: {id: api_fixture("pipeline_instances")["pipeline_with_tagged_collection_input"]["uuid"]},
-        session: session_for(:active))
-    assert_response :success
-  end
-
-  test "component rendering copes with unexpected components format" do
-    get(:show,
-        params: {id: api_fixture("pipeline_instances")["components_is_jobspec"]["uuid"]},
-        session: session_for(:active))
-    assert_response :success
-  end
-
-  test "dates in JSON components are parsed" do
-    get(:show,
-        params: {id: api_fixture('pipeline_instances')['has_component_with_completed_jobs']['uuid']},
-        session: session_for(:active))
-    assert_response :success
-    assert_not_nil assigns(:object)
-    assert_not_nil assigns(:object).components[:foo][:job]
-    start_at = assigns(:object).components[:foo][:job][:started_at]
-    start_at = Time.parse(start_at) if (start_at.andand.class == String)
-    assert start_at.is_a? Time
-    finished_at = assigns(:object).components[:foo][:job][:started_at]
-    finished_at = Time.parse(finished_at) if (finished_at.andand.class == String)
-    assert finished_at.is_a? Time
-  end
-
-  # The next two tests ensure that a pipeline instance can be copied
-  # when the template has components that do not exist in the
-  # instance (ticket #4000).
-
-  test "generate graph" do
-
-    use_token 'admin'
-
-    pipeline_for_graph = {
-      state: 'Complete',
-      uuid: 'zzzzz-d1hrv-9fm8l10i9z2kqc9',
-      components: {
-        stage1: {
-          repository: 'foo',
-          script: 'hash',
-          script_version: 'master',
-          job: {uuid: 'zzzzz-8i9sb-graphstage10000'},
-          output_uuid: 'zzzzz-4zz18-bv31uwvy3neko22'
-        },
-        stage2: {
-          repository: 'foo',
-          script: 'hash2',
-          script_version: 'master',
-          script_parameters: {
-            input: 'fa7aeb5140e2848d39b416daeef4ffc5+45'
-          },
-          job: {uuid: 'zzzzz-8i9sb-graphstage20000'},
-          output_uuid: 'zzzzz-4zz18-uukreo9rbgwsujx'
-        }
-      }
-    }
-
-    @controller.params['tab_pane'] = "Graph"
-    provenance, pips = @controller.graph([pipeline_for_graph])
-
-    graph_test_collection1 = find_fixture Collection, "graph_test_collection1"
-    stage1 = find_fixture Job, "graph_stage1"
-    stage2 = find_fixture Job, "graph_stage2"
-
-    ['component_zzzzz-d1hrv-9fm8l10i9z2kqc9_stage1',
-     'component_zzzzz-d1hrv-9fm8l10i9z2kqc9_stage2',
-     stage1.uuid,
-     stage2.uuid,
-     stage1.output,
-     stage2.output,
-     pipeline_for_graph[:components][:stage1][:output_uuid],
-     pipeline_for_graph[:components][:stage2][:output_uuid]
-    ].each do |k|
-
-      assert_not_nil provenance[k], "Expected key #{k} in provenance set"
-      assert_equal 1, pips[k], "Expected key #{k} in pips set" if !k.start_with? "component_"
-    end
-
-    prov_svg = ProvenanceHelper::create_provenance_graph provenance, "provenance_svg", {
-        :request => RequestDuck,
-        :all_script_parameters => true,
-        :combine_jobs => :script_and_version,
-        :pips => pips,
-        :only_components => true }
-
-    stage1_id = "#{stage1[:script]}_#{stage1[:script_version]}_#{Digest::MD5.hexdigest(stage1[:script_parameters].to_json)}"
-    stage2_id = "#{stage2[:script]}_#{stage2[:script_version]}_#{Digest::MD5.hexdigest(stage2[:script_parameters].to_json)}"
-
-    stage1_out = stage1[:output].gsub('+','\\\+')
-
-    assert_match /#{stage1_id}&#45;&gt;#{stage1_out}/, prov_svg
-
-    assert_match /#{stage1_out}&#45;&gt;#{stage2_id}/, prov_svg
-
-  end
-
-  test "generate graph compare" do
-
-    use_token 'admin'
-
-    pipeline_for_graph1 = {
-      state: 'Complete',
-      uuid: 'zzzzz-d1hrv-9fm8l10i9z2kqc9',
-      components: {
-        stage1: {
-          repository: 'foo',
-          script: 'hash',
-          script_version: 'master',
-          job: {uuid: 'zzzzz-8i9sb-graphstage10000'},
-          output_uuid: 'zzzzz-4zz18-bv31uwvy3neko22'
-        },
-        stage2: {
-          repository: 'foo',
-          script: 'hash2',
-          script_version: 'master',
-          script_parameters: {
-            input: 'fa7aeb5140e2848d39b416daeef4ffc5+45'
-          },
-          job: {uuid: 'zzzzz-8i9sb-graphstage20000'},
-          output_uuid: 'zzzzz-4zz18-uukreo9rbgwsujx'
-        }
-      }
-    }
-
-    pipeline_for_graph2 = {
-      state: 'Complete',
-      uuid: 'zzzzz-d1hrv-9fm8l10i9z2kqc0',
-      components: {
-        stage1: {
-          repository: 'foo',
-          script: 'hash',
-          script_version: 'master',
-          job: {uuid: 'zzzzz-8i9sb-graphstage10000'},
-          output_uuid: 'zzzzz-4zz18-bv31uwvy3neko22'
-        },
-        stage2: {
-          repository: 'foo',
-          script: 'hash2',
-          script_version: 'master',
-          script_parameters: {
-          },
-          job: {uuid: 'zzzzz-8i9sb-graphstage30000'},
-          output_uuid: 'zzzzz-4zz18-uukreo9rbgwsujj'
-        }
-      }
-    }
-
-    @controller.params['tab_pane'] = "Graph"
-    provenance, pips = @controller.graph([pipeline_for_graph1, pipeline_for_graph2])
-
-    collection1 = find_fixture Collection, "graph_test_collection1"
-
-    stage1 = find_fixture Job, "graph_stage1"
-    stage2 = find_fixture Job, "graph_stage2"
-    stage3 = find_fixture Job, "graph_stage3"
-
-    [['component_zzzzz-d1hrv-9fm8l10i9z2kqc9_stage1', nil],
-     ['component_zzzzz-d1hrv-9fm8l10i9z2kqc9_stage2', nil],
-     ['component_zzzzz-d1hrv-9fm8l10i9z2kqc0_stage1', nil],
-     ['component_zzzzz-d1hrv-9fm8l10i9z2kqc0_stage2', nil],
-     [stage1.uuid, 3],
-     [stage2.uuid, 1],
-     [stage3.uuid, 2],
-     [stage1.output, 3],
-     [stage2.output, 1],
-     [stage3.output, 2],
-     [pipeline_for_graph1[:components][:stage1][:output_uuid], 3],
-     [pipeline_for_graph1[:components][:stage2][:output_uuid], 1],
-     [pipeline_for_graph2[:components][:stage2][:output_uuid], 2]
-    ].each do |k|
-      assert_not_nil provenance[k[0]], "Expected key #{k[0]} in provenance set"
-      assert_equal k[1], pips[k[0]], "Expected key #{k} in pips" if !k[0].start_with? "component_"
-    end
-
-    prov_svg = ProvenanceHelper::create_provenance_graph provenance, "provenance_svg", {
-        :request => RequestDuck,
-        :all_script_parameters => true,
-        :combine_jobs => :script_and_version,
-        :pips => pips,
-        :only_components => true }
-
-    collection1_id = collection1.portable_data_hash.gsub('+','\\\+')
-
-    stage2_id = "#{stage2[:script]}_#{stage2[:script_version]}_#{Digest::MD5.hexdigest(stage2[:script_parameters].to_json)}"
-    stage3_id = "#{stage3[:script]}_#{stage3[:script_version]}_#{Digest::MD5.hexdigest(stage3[:script_parameters].to_json)}"
-
-    stage2_out = stage2[:output].gsub('+','\\\+')
-    stage3_out = stage3[:output].gsub('+','\\\+')
-
-    assert_match /#{collection1_id}&#45;&gt;#{stage2_id}/, prov_svg
-    assert_match /#{collection1_id}&#45;&gt;#{stage3_id}/, prov_svg
-
-    assert_match /#{stage2_id}&#45;&gt;#{stage2_out}/, prov_svg
-    assert_match /#{stage3_id}&#45;&gt;#{stage3_out}/, prov_svg
-
-  end
-
-end
diff --git a/apps/workbench/test/controllers/pipeline_templates_controller_test.rb b/apps/workbench/test/controllers/pipeline_templates_controller_test.rb
deleted file mode 100644 (file)
index 4752f32..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class PipelineTemplatesControllerTest < ActionController::TestCase
-  test "component rendering copes with unexpeceted components format" do
-    get(:show,
-        params: {id: api_fixture("pipeline_templates")["components_is_jobspec"]["uuid"]},
-        session: session_for(:active))
-    assert_response :success
-  end
-end
diff --git a/apps/workbench/test/controllers/projects_controller_test.rb b/apps/workbench/test/controllers/projects_controller_test.rb
deleted file mode 100644 (file)
index 2d379f8..0000000
+++ /dev/null
@@ -1,566 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-require 'helpers/share_object_helper'
-
-class ProjectsControllerTest < ActionController::TestCase
-  include ShareObjectHelper
-
-  test "invited user is asked to sign user agreements on front page" do
-    get :index, params: {}, session: session_for(:inactive)
-    assert_response :redirect
-    assert_match(/^#{Regexp.escape(user_agreements_url)}\b/,
-                 @response.redirect_url,
-                 "Inactive user was not redirected to user_agreements page")
-  end
-
-  test "uninvited user is asked to wait for activation" do
-    get :index, params: {}, session: session_for(:inactive_uninvited)
-    assert_response :redirect
-    assert_match(/^#{Regexp.escape(inactive_users_url)}\b/,
-                 @response.redirect_url,
-                 "Uninvited user was not redirected to inactive user page")
-  end
-
-  [[:active, true],
-   [:project_viewer, false]].each do |which_user, should_show|
-    test "create subproject button #{'not ' unless should_show} shown to #{which_user}" do
-      readonly_project_uuid = api_fixture('groups')['aproject']['uuid']
-      get :show, params: {
-        id: readonly_project_uuid
-      }, session: session_for(which_user)
-      buttons = css_select('[data-method=post]').select do |el|
-        el.attributes['data-remote-href'].value.match /project.*owner_uuid.*#{readonly_project_uuid}/
-      end
-      if should_show
-        assert_not_empty(buttons, "did not offer to create a subproject")
-      else
-        assert_empty(buttons.collect(&:to_s),
-                     "offered to create a subproject in a non-writable project")
-      end
-    end
-  end
-
-  test "sharing a project with a user and group" do
-    uuid_list = [api_fixture("groups")["future_project_viewing_group"]["uuid"],
-                 api_fixture("users")["future_project_user"]["uuid"]]
-    post(:share_with, params: {
-           id: api_fixture("groups")["asubproject"]["uuid"],
-           uuids: uuid_list,
-           format: "json"},
-         session: session_for(:active))
-    assert_response :success
-    assert_equal(uuid_list, json_response["success"])
-  end
-
-  test "user with project read permission can't add permissions" do
-    share_uuid = api_fixture("users")["spectator"]["uuid"]
-    post(:share_with, params: {
-           id: api_fixture("groups")["aproject"]["uuid"],
-           uuids: [share_uuid],
-           format: "json"},
-         session: session_for(:project_viewer))
-    assert_response 422
-    assert(json_response["errors"].andand.
-             any? { |msg| msg.start_with?("#{share_uuid}: ") },
-           "JSON response missing properly formatted sharing error")
-  end
-
-  test "admin can_manage aproject" do
-    assert user_can_manage(:admin, api_fixture("groups")["aproject"])
-  end
-
-  test "owner can_manage aproject" do
-    assert user_can_manage(:active, api_fixture("groups")["aproject"])
-  end
-
-  test "owner can_manage asubproject" do
-    assert user_can_manage(:active, api_fixture("groups")["asubproject"])
-  end
-
-  test "viewer can't manage aproject" do
-    refute user_can_manage(:project_viewer, api_fixture("groups")["aproject"])
-  end
-
-  test "viewer can't manage asubproject" do
-    refute user_can_manage(:project_viewer, api_fixture("groups")["asubproject"])
-  end
-
-  test "subproject_admin can_manage asubproject" do
-    assert user_can_manage(:subproject_admin, api_fixture("groups")["asubproject"])
-  end
-
-  test "detect ownership loop in project breadcrumbs" do
-    # This test has an arbitrary time limit -- otherwise we'd just sit
-    # here forever instead of reporting that the loop was not
-    # detected. The test passes quickly, but fails slowly.
-    Timeout::timeout 10 do
-      get(:show,
-          params: { id: api_fixture("groups")["project_owns_itself"]["uuid"] },
-          session: session_for(:admin))
-    end
-    assert_response :success
-  end
-
-  test "project admin can remove collections from the project" do
-    # Deleting an object that supports 'trash_at' should make it
-    # completely inaccessible to API queries, not simply moved out of
-    # the project.
-    coll_key = "collection_to_remove_from_subproject"
-    coll_uuid = api_fixture("collections")[coll_key]["uuid"]
-    delete(:remove_item,
-           params: { id: api_fixture("groups")["asubproject"]["uuid"],
-             item_uuid: coll_uuid,
-             format: "js" },
-           session: session_for(:subproject_admin))
-    assert_response :success
-    assert_match(/\b#{coll_uuid}\b/, @response.body,
-                 "removed object not named in response")
-
-    use_token :subproject_admin
-    assert_raise ArvadosApiClient::NotFoundException do
-      Collection.find(coll_uuid, cache: false)
-    end
-  end
-
-  test "project admin can remove items from project other than collections" do
-    # An object which does not have an trash_at field (e.g. Specimen)
-    # should be implicitly moved to the user's Home project when removed.
-    specimen_uuid = api_fixture('specimens', 'in_asubproject')['uuid']
-    delete(:remove_item,
-           params: { id: api_fixture('groups', 'asubproject')['uuid'],
-             item_uuid: specimen_uuid,
-             format: 'js' },
-           session: session_for(:subproject_admin))
-    assert_response :success
-    assert_match(/\b#{specimen_uuid}\b/, @response.body,
-                 "removed object not named in response")
-
-    use_token :subproject_admin
-    new_specimen = Specimen.find(specimen_uuid)
-    assert_equal api_fixture('users', 'subproject_admin')['uuid'], new_specimen.owner_uuid
-  end
-
-  test 'projects#show tab infinite scroll partial obeys limit' do
-    get_contents_rows(limit: 1, filters: [['uuid','is_a',['arvados#job']]])
-    assert_response :success
-    assert_equal(1, json_response['content'].scan('<tr').count,
-                 "Did not get exactly one row")
-  end
-
-  ['', ' asc', ' desc'].each do |direction|
-    test "projects#show tab partial orders correctly by created_at#{direction}" do
-      _test_tab_content_order direction
-    end
-  end
-
-  def _test_tab_content_order direction
-    get_contents_rows(limit: 100,
-                      order: "created_at#{direction}",
-                      filters: [['uuid','is_a',['arvados#job',
-                                                'arvados#pipelineInstance']]])
-    assert_response :success
-    not_grouped_by_kind = nil
-    last_timestamp = nil
-    last_kind = nil
-    found_kind = {}
-    json_response['content'].scan /<tr[^>]+>/ do |tr_tag|
-      found_timestamps = 0
-      tr_tag.scan(/\ data-object-created-at=\"(.*?)\"/).each do |t,|
-        if last_timestamp
-          correct_operator = / desc$/ =~ direction ? :>= : :<=
-          assert_operator(last_timestamp, correct_operator, t,
-                          "Rows are not sorted by created_at#{direction}")
-        end
-        last_timestamp = t
-        found_timestamps += 1
-      end
-      assert_equal(1, found_timestamps,
-                   "Content row did not have exactly one timestamp")
-
-      # Confirm that the test for timestamp ordering couldn't have
-      # passed merely because the test fixtures have convenient
-      # timestamps (e.g., there is only one pipeline and one job in
-      # the project being tested, or there are no pipelines at all in
-      # the project being tested):
-      tr_tag.scan /\ data-kind=\"(.*?)\"/ do |kind|
-        if last_kind and last_kind != kind and found_kind[kind]
-          # We saw this kind before, then a different kind, then
-          # this kind again. That means objects are not grouped by
-          # kind.
-          not_grouped_by_kind = true
-        end
-        found_kind[kind] ||= 0
-        found_kind[kind] += 1
-        last_kind = kind
-      end
-    end
-    assert_equal(true, not_grouped_by_kind,
-                 "Could not confirm that results are not grouped by kind")
-  end
-
-  def get_contents_rows params
-    params = {
-      id: api_fixture('users')['active']['uuid'],
-      partial: :contents_rows,
-      format: :json,
-    }.merge(params)
-    encoded_params = Hash[params.map { |k,v|
-                            [k, (v.is_a?(Array) || v.is_a?(Hash)) ? v.to_json : v]
-                          }]
-    get :show, params: encoded_params, session: session_for(:active)
-  end
-
-  test "visit non-public project as anonymous when anonymous browsing is enabled and expect page not found" do
-    Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-    get(:show, params: {id: api_fixture('groups')['aproject']['uuid']})
-    assert_response 404
-    assert_match(/log ?in/i, @response.body)
-  end
-
-  test "visit home page as anonymous when anonymous browsing is enabled and expect login" do
-    Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-    get(:index)
-    assert_response :redirect
-    assert_match /\/users\/welcome/, @response.redirect_url
-  end
-
-  [
-    nil,
-    :active,
-  ].each do |user|
-    test "visit public projects page when anon config is enabled, as user #{user}, and expect page" do
-      Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-
-      if user
-        get :public, params: {}, session: session_for(user)
-      else
-        get :public
-      end
-
-      assert_response :success
-      assert_not_nil assigns(:objects)
-      project_names = assigns(:objects).collect(&:name)
-      assert_includes project_names, 'Unrestricted public data'
-      assert_not_includes project_names, 'A Project'
-      refute_empty css_select('[href="/projects/public"]')
-    end
-  end
-
-  test "visit public projects page when anon config is not enabled as active user and expect 404" do
-    Rails.configuration.Users.AnonymousUserToken = ""
-    Rails.configuration.Workbench.EnablePublicProjectsPage = false
-    get :public, params: {}, session: session_for(:active)
-    assert_response 404
-  end
-
-  test "visit public projects page when anon config is enabled but public projects page is disabled as active user and expect 404" do
-    Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-    Rails.configuration.Workbench.EnablePublicProjectsPage = false
-    get :public, params: {}, session: session_for(:active)
-    assert_response 404
-  end
-
-  test "visit public projects page when anon config is not enabled as anonymous and expect login page" do
-    Rails.configuration.Users.AnonymousUserToken = ""
-    Rails.configuration.Workbench.EnablePublicProjectsPage = false
-    get :public
-    assert_response :redirect
-    assert_match /\/users\/welcome/, @response.redirect_url
-    assert_empty css_select('[href="/projects/public"]')
-  end
-
-  test "visit public projects page when anon config is enabled and public projects page is disabled and expect login page" do
-    Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-    Rails.configuration.Workbench.EnablePublicProjectsPage = false
-    get :index
-    assert_response :redirect
-    assert_match /\/users\/welcome/, @response.redirect_url
-    assert_empty css_select('[href="/projects/public"]')
-  end
-
-  test "visit public projects page when anon config is not enabled and public projects page is enabled and expect login page" do
-    Rails.configuration.Workbench.EnablePublicProjectsPage = true
-    get :index
-    assert_response :redirect
-    assert_match /\/users\/welcome/, @response.redirect_url
-    assert_empty css_select('[href="/projects/public"]')
-  end
-
-  test "find a project and edit its description" do
-    project = api_fixture('groups')['aproject']
-    use_token :active
-    found = Group.find(project['uuid'])
-    found.description = 'test description update'
-    found.save!
-    get(:show, params: {id: project['uuid']}, session: session_for(:active))
-    assert_includes @response.body, 'test description update'
-  end
-
-  test "find a project and edit description to textile description" do
-    project = api_fixture('groups')['aproject']
-    use_token :active
-    found = Group.find(project['uuid'])
-    found.description = '*test bold description for textile formatting*'
-    found.save!
-    get(:show, params: {id: project['uuid']}, session: session_for(:active))
-    assert_includes @response.body, '<strong>test bold description for textile formatting</strong>'
-  end
-
-  test "find a project and edit description to html description" do
-    project = api_fixture('groups')['aproject']
-    use_token :active
-    found = Group.find(project['uuid'])
-    found.description = '<b>Textile</b> description with link to home page <a href="/">take me home</a>.'
-    found.save!
-    get(:show, params: {id: project['uuid']}, session: session_for(:active))
-    assert_includes @response.body, '<b>Textile</b> description with link to home page <a href="/">take me home</a>.'
-  end
-
-  test "find a project and edit description to unsafe html description" do
-    project = api_fixture('groups')['aproject']
-    use_token :active
-    found = Group.find(project['uuid'])
-    found.description = 'Textile description with unsafe script tag <script language="javascript">alert("Hello there")</script>.'
-    found.save!
-    get(:show, params: {id: project['uuid']}, session: session_for(:active))
-    assert_includes @response.body, 'Textile description with unsafe script tag alert("Hello there").'
-  end
-
-  # Tests #14519
-  test "textile table on description renders as table html markup" do
-    use_token :active
-    project = api_fixture('groups')['aproject']
-    textile_table = <<EOT
-table(table table-striped table-condensed).
-|_. First Header |_. Second Header |
-|Content Cell |Content Cell |
-|Content Cell |Content Cell |
-EOT
-    found = Group.find(project['uuid'])
-    found.description = textile_table
-    found.save!
-    get(:show, params: {id: project['uuid']}, session: session_for(:active))
-    assert_includes @response.body, '<th>First Header'
-    assert_includes @response.body, '<td>Content Cell'
-  end
-
-  test "find a project and edit description to textile description with link to object" do
-    project = api_fixture('groups')['aproject']
-    use_token :active
-    found = Group.find(project['uuid'])
-
-    # uses 'Link to object' as a hyperlink for the object
-    found.description = '"Link to object":' + api_fixture('groups')['asubproject']['uuid']
-    found.save!
-    get(:show, params: {id: project['uuid']}, session: session_for(:active))
-
-    # check that input was converted to textile, not staying as inputted
-    refute_includes  @response.body,'"Link to object"'
-    refute_empty css_select('[href="/groups/zzzzz-j7d0g-axqo7eu9pwvna1x"]')
-  end
-
-  test "project viewer can't see project sharing tab" do
-    project = api_fixture('groups')['aproject']
-    get(:show, params: {id: project['uuid']}, session: session_for(:project_viewer))
-    refute_includes @response.body, '<div id="Sharing"'
-    assert_includes @response.body, '<div id="Data_collections"'
-  end
-
-  [
-    'admin',
-    'active',
-  ].each do |username|
-    test "#{username} can see project sharing tab" do
-     project = api_fixture('groups')['aproject']
-     get(:show, params: {id: project['uuid']}, session: session_for(username))
-     assert_includes @response.body, '<div id="Sharing"'
-     assert_includes @response.body, '<div id="Data_collections"'
-    end
-  end
-
-  [
-    ['admin',true],
-    ['active',true],
-    ['project_viewer',false],
-  ].each do |user, can_move|
-    test "#{user} can move subproject from project #{can_move}" do
-      get(:show, params: {id: api_fixture('groups')['aproject']['uuid']}, session: session_for(user))
-      if can_move
-        assert_includes @response.body, 'Move project...'
-      else
-        refute_includes @response.body, 'Move project...'
-      end
-    end
-  end
-
-  [:admin, :active].each do |user|
-    test "in dashboard other index page links as #{user}" do
-      get :index, params: {}, session: session_for(user)
-
-      [["processes", "/all_processes"],
-       ["collections", "/collections"],
-      ].each do |target, path|
-        assert_includes @response.body, "href=\"#{path}\""
-        assert_includes @response.body, "All #{target}"
-      end
-    end
-  end
-
-  test "dashboard should show the correct status for processes" do
-    get :index, params: {}, session: session_for(:active)
-    assert_select 'div.panel-body.recent-processes' do
-      [
-        {
-          fixture: 'container_requests',
-          state: 'completed',
-          selectors: [['div.progress', false],
-                      ['span.label.label-success', true, 'Complete']]
-        },
-        {
-          fixture: 'container_requests',
-          state: 'uncommitted',
-          selectors: [['div.progress', false],
-                      ['span.label.label-default', true, 'Uncommitted']]
-        },
-        {
-          fixture: 'container_requests',
-          state: 'queued',
-          selectors: [['div.progress', false],
-                      ['span.label.label-default', true, 'Queued']]
-        },
-        {
-          fixture: 'container_requests',
-          state: 'running',
-          selectors: [['.label-info', true, 'Running']]
-        },
-        {
-          fixture: 'pipeline_instances',
-          state: 'new_pipeline',
-          selectors: [['div.progress', false],
-                      ['span.label.label-default', true, 'Not started']]
-        },
-        {
-          fixture: 'pipeline_instances',
-          state: 'pipeline_in_running_state',
-          selectors: [['.label-info', true, 'Running']]
-        },
-      ].each do |c|
-        uuid = api_fixture(c[:fixture])[c[:state]]['uuid']
-        assert_select "div.dashboard-panel-info-row.row-#{uuid}" do
-          if c.include? :selectors
-            c[:selectors].each do |selector, should_show, label|
-              assert_select selector, should_show, "UUID #{uuid} should #{should_show ? '' : 'not'} show '#{selector}'"
-              if should_show and not label.nil?
-                assert_select selector, label, "UUID #{uuid} state label should show #{label}"
-              end
-            end
-          end
-        end
-      end
-    end
-  end
-
-  test "visit a public project and verify the public projects page link exists" do
-    Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-    uuid = api_fixture('groups')['anonymously_accessible_project']['uuid']
-    get :show, params: {id: uuid}
-    project = assigns(:object)
-    assert_equal uuid, project['uuid']
-    refute_empty css_select("[href=\"/projects/#{project['uuid']}\"]")
-    assert_includes @response.body, "<a href=\"/projects/public\">Public Projects</a>"
-  end
-
-  test 'all_projects unaffected by params after use by ProjectsController (#6640)' do
-    @controller = ProjectsController.new
-    project_uuid = api_fixture('groups')['aproject']['uuid']
-    get :index, params: {
-      filters: [['uuid', '<', project_uuid]].to_json,
-      limit: 0,
-      offset: 1000,
-    }, session: session_for(:active)
-    assert_select "#projects-menu + ul li.divider ~ li a[href=\"/projects/#{project_uuid}\"]"
-  end
-
-  [
-    ["active", 5, ["aproject", "asubproject"], "anonymously_accessible_project"],
-    ["user1_with_load", 2, ["project_with_10_collections"], "project_with_2_pipelines_and_60_crs"],
-    ["admin", 5, ["anonymously_accessible_project", "subproject_in_anonymous_accessible_project"], "aproject"],
-  ].each do |user, page_size, tree_segment, unexpected|
-    # Note: this test is sensitive to database collation. It passes
-    # with en_US.UTF-8.
-    test "build my projects tree for #{user} user and verify #{unexpected} is omitted" do
-      use_token user
-
-      tree, _, _ = @controller.send(:my_wanted_projects_tree,
-                                    User.current,
-                                    page_size)
-
-      tree_segment_at_depth_1 = api_fixture('groups')[tree_segment[0]]
-      tree_segment_at_depth_2 = api_fixture('groups')[tree_segment[1]] if tree_segment[1]
-
-      node_depth = {}
-      tree.each do |x|
-        node_depth[x[:object]['uuid']] = x[:depth]
-      end
-
-      assert_equal(1, node_depth[tree_segment_at_depth_1['uuid']])
-      assert_equal(2, node_depth[tree_segment_at_depth_2['uuid']]) if tree_segment[1]
-
-      unexpected_project = api_fixture('groups')[unexpected]
-      assert_nil(node_depth[unexpected_project['uuid']], node_depth.inspect)
-    end
-  end
-
-  [
-    ["active", 1],
-    ["project_viewer", 1],
-    ["admin", 0],
-  ].each do |user, size|
-    test "starred projects for #{user}" do
-      use_token user
-      ctrl = ProjectsController.new
-      current_user = User.find(api_fixture('users')[user]['uuid'])
-      my_starred_project = ctrl.send :my_starred_projects, current_user, ''
-      assert_equal(size, my_starred_project.andand.size)
-
-      ctrl2 = ProjectsController.new
-      current_user = User.find(api_fixture('users')[user]['uuid'])
-      my_starred_project = ctrl2.send :my_starred_projects, current_user, ''
-      assert_equal(size, my_starred_project.andand.size)
-    end
-  end
-
-  test "unshare project and verify that it is no longer included in shared user's starred projects" do
-    # remove sharing link
-    use_token :system_user
-    Link.find(api_fixture('links')['share_starred_project_with_project_viewer']['uuid']).destroy
-
-    # verify that project is no longer included in starred projects
-    use_token :project_viewer
-    current_user = User.find(api_fixture('users')['project_viewer']['uuid'])
-    ctrl = ProjectsController.new
-    my_starred_project = ctrl.send :my_starred_projects, current_user, ''
-    assert_equal(0, my_starred_project.andand.size)
-
-    # share it again
-    @controller = LinksController.new
-    post :create, params: {
-      link: {
-        link_class: 'permission',
-        name: 'can_read',
-        head_uuid: api_fixture('groups')['starred_and_shared_active_user_project']['uuid'],
-        tail_uuid: api_fixture('users')['project_viewer']['uuid'],
-      },
-      format: :json
-    }, session: session_for(:system_user)
-
-    # verify that the project is again included in starred projects
-    use_token :project_viewer
-    ctrl = ProjectsController.new
-    my_starred_project = ctrl.send :my_starred_projects, current_user, ''
-    assert_equal(1, my_starred_project.andand.size)
-  end
-end
diff --git a/apps/workbench/test/controllers/repositories_controller_test.rb b/apps/workbench/test/controllers/repositories_controller_test.rb
deleted file mode 100644 (file)
index a5d7209..0000000
+++ /dev/null
@@ -1,144 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-require 'helpers/repository_stub_helper'
-require 'helpers/share_object_helper'
-
-class RepositoriesControllerTest < ActionController::TestCase
-  include RepositoryStubHelper
-  include ShareObjectHelper
-
-  [
-    :active, #owner
-    :admin,
-  ].each do |user|
-    test "#{user} shares repository with a user and group" do
-      uuid_list = [api_fixture("groups")["future_project_viewing_group"]["uuid"],
-                   api_fixture("users")["future_project_user"]["uuid"]]
-      post(:share_with, params: {
-             id: api_fixture("repositories")["foo"]["uuid"],
-             uuids: uuid_list,
-             format: "json"},
-           session: session_for(user))
-      assert_response :success
-      assert_equal(uuid_list, json_response["success"])
-    end
-  end
-
-  test "user with repository read permission cannot add permissions" do
-    share_uuid = api_fixture("users")["project_viewer"]["uuid"]
-    post(:share_with, params: {
-           id: api_fixture("repositories")["arvados"]["uuid"],
-           uuids: [share_uuid],
-           format: "json"},
-         session: session_for(:spectator))
-    assert_response 422
-    assert(json_response["errors"].andand.
-             any? { |msg| msg.start_with?("#{share_uuid}: ") },
-           "JSON response missing properly formatted sharing error")
-  end
-
-  test "admin can_manage repository" do
-    assert user_can_manage(:admin, api_fixture("repositories")["foo"])
-  end
-
-  test "owner can_manage repository" do
-    assert user_can_manage(:active, api_fixture("repositories")["foo"])
-  end
-
-  test "viewer cannot manage repository" do
-    refute user_can_manage(:spectator, api_fixture("repositories")["arvados"])
-  end
-
-  [
-    [:active, ['#Sharing', '#Advanced']],
-    [:admin,  ['#Attributes', '#Sharing', '#Advanced']],
-  ].each do |user, expected_panes|
-    test "#{user} sees panes #{expected_panes}" do
-      get :show, params: {
-        id: api_fixture('repositories')['foo']['uuid']
-      }, session: session_for(user)
-      assert_response :success
-
-      panes = css_select('[data-toggle=tab]').each do |pane|
-        pane_name = pane.attributes['href'].value
-        assert_includes expected_panes, pane_name
-      end
-    end
-  end
-
-  ### Browse repository content
-
-  [:active, :spectator].each do |user|
-    test "show tree to #{user}" do
-      reset_api_fixtures_after_test false
-      sha1, _, _ = stub_repo_content
-      get :show_tree, params: {
-        id: api_fixture('repositories')['foo']['uuid'],
-        commit: sha1,
-      }, session: session_for(user)
-      assert_response :success
-      assert_select 'tr td a', 'COPYING'
-      assert_select 'tr td', '625 bytes'
-      assert_select 'tr td a', 'apps'
-      assert_select 'tr td a', 'workbench'
-      assert_select 'tr td a', 'Gemfile'
-      assert_select 'tr td', '33.7 KiB'
-    end
-
-    test "show commit to #{user}" do
-      reset_api_fixtures_after_test false
-      sha1, commit, _ = stub_repo_content
-      get :show_commit, params: {
-        id: api_fixture('repositories')['foo']['uuid'],
-        commit: sha1,
-      }, session: session_for(user)
-      assert_response :success
-      assert_select 'pre', commit
-    end
-
-    test "show blob to #{user}" do
-      reset_api_fixtures_after_test false
-      sha1, _, filedata = stub_repo_content filename: 'COPYING'
-      get :show_blob, params: {
-        id: api_fixture('repositories')['foo']['uuid'],
-        commit: sha1,
-        path: 'COPYING',
-      }, session: session_for(user)
-      assert_response :success
-      assert_select 'pre', filedata
-    end
-  end
-
-  ['', '/'].each do |path|
-    test "show tree with path '#{path}'" do
-      reset_api_fixtures_after_test false
-      sha1, _, _ = stub_repo_content filename: 'COPYING'
-      get :show_tree, params: {
-        id: api_fixture('repositories')['foo']['uuid'],
-        commit: sha1,
-        path: path,
-      }, session: session_for(:active)
-      assert_response :success
-      assert_select 'tr td', 'COPYING'
-    end
-  end
-
-  test "get repositories lists linked as well as owned repositories" do
-    params = {
-      partial: :repositories_rows,
-      format: :json,
-    }
-    get :index, params: params, session: session_for(:active)
-    assert_response :success
-    repos = assigns(:objects)
-    assert repos
-    assert_not_empty repos, "my_repositories should not be empty"
-    repo_uuids = repos.map(&:uuid)
-    assert_includes repo_uuids, api_fixture('repositories')['repository2']['uuid']  # owned by active
-    assert_includes repo_uuids, api_fixture('repositories')['repository4']['uuid']  # shared with active
-    assert_includes repo_uuids, api_fixture('repositories')['arvados']['uuid']      # shared with all_users
-  end
-end
diff --git a/apps/workbench/test/controllers/search_controller_test.rb b/apps/workbench/test/controllers/search_controller_test.rb
deleted file mode 100644 (file)
index e620fbd..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class SearchControllerTest < ActionController::TestCase
-  # These tests don't do state-changing API calls. Save some time by
-  # skipping the database reset.
-  reset_api_fixtures :after_each_test, false
-  reset_api_fixtures :after_suite, true
-
-  include Rails.application.routes.url_helpers
-
-  test 'Get search dialog' do
-    get :choose, params: {
-      format: :js,
-      title: 'Search',
-      action_name: 'Show',
-      action_href: url_for(host: 'localhost', controller: :actions, action: :show),
-      action_data: {}.to_json,
-    }, session: session_for(:active), xhr: true
-    assert_response :success
-  end
-
-  test 'Get search results for all projects' do
-    get :choose, params: {
-      format: :json,
-      partial: true,
-    }, session: session_for(:active), xhr: true
-    assert_response :success
-    assert_not_empty(json_response['content'],
-                     'search results for all projects should not be empty')
-  end
-
-  test 'Get search results for empty project' do
-    get :choose, params: {
-      format: :json,
-      partial: true,
-      project_uuid: api_fixture('groups')['empty_project']['uuid'],
-    }, session: session_for(:active), xhr: true
-    assert_response :success
-    assert_empty(json_response['content'],
-                 'search results for empty project should be empty')
-  end
-
-  test 'search results for aproject and verify recursive contents' do
-    get :choose, params: {
-      format: :json,
-      partial: true,
-      project_uuid: api_fixture('groups')['aproject']['uuid'],
-    }, session: session_for(:active), xhr: true
-    assert_response :success
-    assert_not_empty(json_response['content'],
-                 'search results for aproject should not be empty')
-    items = []
-    json_response['content'].scan /<div[^>]+>/ do |div_tag|
-      div_tag.scan(/\ data-object-uuid=\"(.*?)\"/).each do |uuid,|
-        items << uuid
-      end
-    end
-
-    assert_includes(items, api_fixture('collections')['collection_to_move_around_in_aproject']['uuid'])
-    assert_includes(items, api_fixture('groups')['asubproject']['uuid'])
-    assert_includes(items, api_fixture('collections')['baz_collection_name_in_asubproject']['uuid'])
-    assert_includes(items,
-      api_fixture('groups')['subproject_in_asubproject_with_same_name_as_one_in_active_user_home']['uuid'])
-  end
-end
diff --git a/apps/workbench/test/controllers/sessions_controller_test.rb b/apps/workbench/test/controllers/sessions_controller_test.rb
deleted file mode 100644 (file)
index bd22cf5..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class SessionsControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/specimens_controller_test.rb b/apps/workbench/test/controllers/specimens_controller_test.rb
deleted file mode 100644 (file)
index 596d078..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class SpecimensControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/traits_controller_test.rb b/apps/workbench/test/controllers/traits_controller_test.rb
deleted file mode 100644 (file)
index 6c33c2f..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class TraitsControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/trash_items_controller_test.rb b/apps/workbench/test/controllers/trash_items_controller_test.rb
deleted file mode 100644 (file)
index c4090f0..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class TrashItemsControllerTest < ActionController::TestCase
-  test "untrash collection with same name as another collection" do
-    collection = api_fixture('collections')['trashed_collection_to_test_name_conflict_on_untrash']
-    items = [collection['uuid']]
-    post :untrash_items, params: {
-      selection: items,
-      format: :js
-    }, session: session_for(:active)
-
-    assert_response :success
-  end
-end
diff --git a/apps/workbench/test/controllers/user_agreements_controller_test.rb b/apps/workbench/test/controllers/user_agreements_controller_test.rb
deleted file mode 100644 (file)
index 4c6e41d..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class UserAgreementsControllerTest < ActionController::TestCase
-  test 'User agreements page shows form if some user agreements are not signed' do
-    get :index, params: {}, session: session_for(:inactive)
-    assert_response 200
-  end
-
-  test 'User agreements page redirects if all user agreements signed' do
-    get :index, params: {return_to: root_path}, session: session_for(:active)
-    assert_response :redirect
-    assert_equal(root_url,
-                 @response.redirect_url,
-                 "Active user was not redirected to :return_to param")
-  end
-end
diff --git a/apps/workbench/test/controllers/users_controller_test.rb b/apps/workbench/test/controllers/users_controller_test.rb
deleted file mode 100644 (file)
index f1fc588..0000000
+++ /dev/null
@@ -1,113 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class UsersControllerTest < ActionController::TestCase
-
-  test "valid token works in controller test" do
-    get :index, params: {}, session: session_for(:active)
-    assert_response :success
-  end
-
-  test "ignore previously valid token (for deleted user), don't crash" do
-    get :activity, params: {}, session: session_for(:valid_token_deleted_user)
-    assert_response :redirect
-    assert_match /^#{Rails.configuration.Services.Workbench1.ExternalURL}users\/welcome/, @response.redirect_url
-    assert_nil assigns(:my_jobs)
-    assert_nil assigns(:my_ssh_keys)
-  end
-
-  test "expired token redirects to api server login" do
-    assert Rails.configuration.Login.Test.Enable
-    get :show, params: {
-      id: api_fixture('users')['active']['uuid']
-    }, session: session_for(:expired_trustedclient)
-    assert_response :redirect
-    assert_match /^#{Rails.configuration.Services.Workbench1.ExternalURL}users\/welcome/, @response.redirect_url
-    assert_nil assigns(:my_jobs)
-    assert_nil assigns(:my_ssh_keys)
-  end
-
-  test "show welcome page if no token provided" do
-    get :index, params: {}
-    assert_response :redirect
-    assert_match /\/users\/welcome/, @response.redirect_url
-  end
-
-  test "'log in as user' feature uses a v2 token" do
-    post :sudo, params: {
-      id: api_fixture('users')['active']['uuid']
-    }, session: session_for('admin_trustedclient')
-    assert_response :redirect
-    assert_match /api_token=v2%2F/, @response.redirect_url
-  end
-
-  test "request shell access" do
-    user = api_fixture('users')['spectator']
-
-    ActionMailer::Base.deliveries = []
-
-    post :request_shell_access, params: {
-      id: user['uuid'],
-      format: 'js'
-    }, session: session_for(:spectator)
-    assert_response :success
-
-    full_name = "#{user['first_name']} #{user['last_name']}"
-    expected = "Shell account request from #{full_name} (#{user['email']}, #{user['uuid']})"
-    found_email = 0
-    ActionMailer::Base.deliveries.each do |email|
-      if email.subject.include?(expected)
-        found_email += 1
-        break
-      end
-    end
-    assert_equal 1, found_email, "Expected 1 email after requesting shell access"
-  end
-
-  [
-    'admin',
-    'active',
-  ].each do |username|
-    test "access users page as #{username} and verify show button is available" do
-      admin_user = api_fixture('users','admin')
-      active_user = api_fixture('users','active')
-      get :index, params: {}, session: session_for(username)
-      if username == 'admin'
-        assert_match /<a href="\/projects\/#{admin_user['uuid']}">Home<\/a>/, @response.body
-        assert_match /<a href="\/projects\/#{active_user['uuid']}">Home<\/a>/, @response.body
-        assert_match /href="\/users\/#{admin_user['uuid']}"><i class="fa fa-fw fa-user"><\/i> Show<\/a/, @response.body
-        assert_match /href="\/users\/#{active_user['uuid']}"><i class="fa fa-fw fa-user"><\/i> Show<\/a/, @response.body
-        assert_includes @response.body, admin_user['email']
-        assert_includes @response.body, active_user['email']
-      else
-        refute_match  /Home<\/a>/, @response.body
-        refute_match /href="\/users\/#{admin_user['uuid']}"><i class="fa fa-fw fa-user"><\/i> Show<\/a/, @response.body
-        assert_match /href="\/users\/#{active_user['uuid']}"><i class="fa fa-fw fa-user"><\/i> Show<\/a/, @response.body
-        assert_includes @response.body, active_user['email']
-      end
-    end
-  end
-
-  [
-    'admin',
-    'active',
-  ].each do |username|
-    test "access settings drop down menu as #{username}" do
-      admin_user = api_fixture('users','admin')
-      active_user = api_fixture('users','active')
-      get :show, params: {
-        id: api_fixture('users')[username]['uuid']
-      }, session: session_for(username)
-      if username == 'admin'
-        assert_includes @response.body, admin_user['email']
-        refute_empty css_select('[id="system-menu"]')
-      else
-        assert_includes @response.body, active_user['email']
-        assert_empty css_select('[id="system-menu"]')
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/controllers/virtual_machines_controller_test.rb b/apps/workbench/test/controllers/virtual_machines_controller_test.rb
deleted file mode 100644 (file)
index 0f781b9..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class VirtualMachinesControllerTest < ActionController::TestCase
-end
diff --git a/apps/workbench/test/controllers/work_units_controller_test.rb b/apps/workbench/test/controllers/work_units_controller_test.rb
deleted file mode 100644 (file)
index 0191c7f..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class WorkUnitsControllerTest < ActionController::TestCase
-  # These tests don't do state-changing API calls.
-  # Save some time by skipping the database reset.
-  reset_api_fixtures :after_each_test, false
-  reset_api_fixtures :after_suite, true
-
-  [
-    ['foo', 10, 25,
-      ['/pipeline_instances/zzzzz-d1hrv-1xfj6xkicf2muk2',
-       '/pipeline_instances/zzzzz-d1hrv-1yfj61234abcdk4',
-       '/jobs/zzzzz-8i9sb-grx15v5mjnsyxk7'],
-      ['/pipeline_instances/zzzzz-d1hrv-1yfj61234abcdk3',
-       '/jobs/zzzzz-8i9sb-n7omg50bvt0m1nf',
-       '/container_requests/zzzzz-xvhdp-cr4completedcr2']],
-    ['pipeline_with_tagged_collection_input', 1, 1,
-      ['/pipeline_instances/zzzzz-d1hrv-1yfj61234abcdk3'],
-      ['/pipeline_instances/zzzzz-d1hrv-1yfj61234abcdk4',
-       '/jobs/zzzzz-8i9sb-pshmckwoma9plh7',
-       '/jobs/zzzzz-8i9sb-n7omg50bvt0m1nf',
-       '/container_requests/zzzzz-xvhdp-cr4completedcr2']],
-    ['no_such_match', 0, 0,
-      [],
-      ['/pipeline_instances/zzzzz-d1hrv-1yfj61234abcdk4',
-       '/jobs/zzzzz-8i9sb-pshmckwoma9plh7',
-       '/jobs/zzzzz-8i9sb-n7omg50bvt0m1nf',
-       '/container_requests/zzzzz-xvhdp-cr4completedcr2']],
-  ].each do |search_filter, expected_min, expected_max, expected, not_expected|
-    test "all_processes page for search filter '#{search_filter}'" do
-      work_units_index(filters: [['any','ilike', "%#{search_filter}%"]], show_children: true)
-      assert_response :success
-
-      # Verify that expected number of processes are found
-      found_count = json_response['content'].scan('<tr').count
-      if expected_min == expected_max
-        assert_equal(true, found_count == expected_min,
-          "Not found expected number of items. Expected #{expected_min} and found #{found_count}")
-      else
-        assert_equal(true, found_count>=expected_min,
-          "Found too few items. Expected at least #{expected_min} and found #{found_count}")
-        assert_equal(true, found_count<=expected_max,
-          "Found too many items. Expected at most #{expected_max} and found #{found_count}")
-      end
-
-      # verify that all expected uuid links are found
-      expected.each do |link|
-        assert_match /href="#{link}"/, json_response['content']
-      end
-
-      # verify that none of the not_expected uuid links are found
-      not_expected.each do |link|
-        assert_no_match /href="#{link}"/, json_response['content']
-      end
-    end
-  end
-
-  def work_units_index params
-    params = {
-      partial: :all_processes_rows,
-      format: :json,
-    }.merge(params)
-    encoded_params = Hash[params.map { |k,v|
-                            [k, (v.is_a?(Array) || v.is_a?(Hash)) ? v.to_json : v]
-                          }]
-    get :index, params: encoded_params, session: session_for(:active)
-  end
-end
diff --git a/apps/workbench/test/controllers/workflows_controller_test.rb b/apps/workbench/test/controllers/workflows_controller_test.rb
deleted file mode 100644 (file)
index 0877e59..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class WorkflowsControllerTest < ActionController::TestCase
-  test "index" do
-    get :index, params: {}, session: session_for(:active)
-    assert_response :success
-    assert_includes @response.body, 'Valid workflow with no definition yaml'
-  end
-
-  test "show" do
-    use_token 'active'
-
-    wf = api_fixture('workflows')['workflow_with_input_specifications']
-
-    get :show, params: {id: wf['uuid']}, session: session_for(:active)
-    assert_response :success
-
-    assert_includes @response.body, "a short label for this parameter (optional)"
-    assert_includes @response.body, "href=\"#Advanced\""
-  end
-end
diff --git a/apps/workbench/test/diagnostics/container_request_test.rb b/apps/workbench/test/diagnostics/container_request_test.rb
deleted file mode 100644 (file)
index 47e7f78..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'diagnostics_test_helper'
-
-# This test assumes that the configured workflow_uuid corresponds to a cwl workflow.
-# Ex: configure a workflow using the steps below and use the resulting workflow uuid:
-#   > cd arvados/doc/user/cwl/bwa-mem
-#   > arvados-cwl-runner --create-workflow bwa-mem.cwl bwa-mem-input.yml
-
-class ContainerRequestTest < DiagnosticsTest
-  crs_to_test = Rails.configuration.container_requests_to_test.andand.keys
-
-  setup do
-    need_selenium 'to make websockets work'
-  end
-
-  crs_to_test.andand.each do |cr_to_test|
-    test "run container_request: #{cr_to_test}" do
-      cr_config = Rails.configuration.container_requests_to_test[cr_to_test]
-
-      visit_page_with_token 'active'
-
-      find('.btn', text: 'Run a process').click
-
-      within('.modal-dialog') do
-        page.find_field('Search').set cr_config['workflow_uuid']
-        wait_for_ajax
-        find('.selectable', text: 'bwa-mem.cwl').click
-        find('.btn', text: 'Next: choose inputs').click
-      end
-
-      page.assert_selector('a.disabled,button.disabled', text: 'Run') if cr_config['input_paths'].any?
-
-      # Choose input for the workflow
-      cr_config['input_paths'].each do |look_for|
-        select_input look_for
-      end
-      wait_for_ajax
-
-      # All needed input are already filled in. Run this workflow now
-      page.assert_no_selector('a.disabled,button.disabled', text: 'Run')
-      find('a,button', text: 'Run').click
-
-      # container_request is running. Run button is no longer available.
-      page.assert_no_selector('a', text: 'Run')
-
-      # Wait for container_request run to complete
-      wait_until_page_has 'completed', cr_config['max_wait_seconds']
-    end
-  end
-end
diff --git a/apps/workbench/test/diagnostics/pipeline_test.rb b/apps/workbench/test/diagnostics/pipeline_test.rb
deleted file mode 100644 (file)
index d90d0cb..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'diagnostics_test_helper'
-
-class PipelineTest < DiagnosticsTest
-  pipelines_to_test = Rails.configuration.pipelines_to_test.andand.keys
-
-  setup do
-    need_selenium 'to make websockets work'
-  end
-
-  pipelines_to_test.andand.each do |pipeline_to_test|
-    test "run pipeline: #{pipeline_to_test}" do
-      visit_page_with_token 'active'
-      pipeline_config = Rails.configuration.pipelines_to_test[pipeline_to_test]
-
-      # Search for tutorial template
-      find '.navbar-fixed-top'
-      within('.navbar-fixed-top') do
-        page.find_field('search this site').set pipeline_config['template_uuid']
-        page.find('.glyphicon-search').click
-      end
-
-      # Run the pipeline
-      assert_triggers_dom_event 'shown.bs.modal' do
-        find('a,button', text: 'Run').click
-      end
-
-      # Choose project
-      within('.modal-dialog') do
-        find('.selectable', text: 'Home').click
-        find('button', text: 'Choose').click
-      end
-
-      page.assert_selector('a.disabled,button.disabled', text: 'Run') if pipeline_config['input_paths'].any?
-
-      # Choose input for the pipeline
-      pipeline_config['input_paths'].each do |look_for|
-        select_input look_for
-      end
-      wait_for_ajax
-
-      # All needed input are filled in. Run this pipeline now
-      find('a,button', text: 'Components').click
-      find('a,button', text: 'Run').click
-
-      # Pipeline is running. We have a "Pause" button instead now.
-      page.assert_selector 'a,button', text: 'Pause'
-
-      # Wait for pipeline run to complete
-      wait_until_page_has 'completed', pipeline_config['max_wait_seconds']
-    end
-  end
-end
diff --git a/apps/workbench/test/diagnostics_test_helper.rb b/apps/workbench/test/diagnostics_test_helper.rb
deleted file mode 100644 (file)
index d53753d..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-require 'yaml'
-
-# Diagnostics tests are executed when "RAILS_ENV=diagnostics" is used.
-# When "RAILS_ENV=test" is used, tests in the "diagnostics" directory
-# will not be executed.
-
-# Command to run diagnostics tests:
-#   RAILS_ENV=diagnostics bundle exec rake TEST=test/diagnostics/**/*.rb
-
-class DiagnosticsTest < ActionDispatch::IntegrationTest
-
-  # Prepends workbench URL to the path provided and visits that page
-  # Expects path parameters such as "/collections/<uuid>"
-  def visit_page_with_token token_name, path='/'
-    workbench_url = Rails.configuration.arvados_workbench_url
-    if workbench_url.end_with? '/'
-      workbench_url = workbench_url[0, workbench_url.size-1]
-    end
-    tokens = Rails.configuration.user_tokens
-    visit page_with_token(tokens[token_name], (workbench_url + path))
-  end
-
-  def select_input look_for
-    inputs_needed = page.all('.btn', text: 'Choose')
-    return if (!inputs_needed || !inputs_needed.any?)
-
-    look_for_uuid = nil
-    look_for_file = nil
-    if look_for.andand.index('/').andand.>0
-      partitions = look_for.partition('/')
-      look_for_uuid = partitions[0]
-      look_for_file = partitions[2]
-    else
-      look_for_uuid = look_for
-      look_for_file = nil
-    end
-
-    assert_triggers_dom_event 'shown.bs.modal' do
-      inputs_needed[0].click
-    end
-
-    within('.modal-dialog') do
-      if look_for_uuid
-        fill_in('Search', with: look_for_uuid, exact: true)
-        wait_for_ajax
-      end
-
-      page.all('.selectable').first.click
-      wait_for_ajax
-      # ajax reload is wiping out input selection after search results; so, select again.
-      page.all('.selectable').first.click
-      wait_for_ajax
-
-      if look_for_file
-        wait_for_ajax
-        within('.collection_files_name', text: look_for_file) do
-          find('.fa-file').click
-        end
-      end
-
-      find('button', text: 'OK').click
-      wait_for_ajax
-    end
-  end
-
-  # Looks for the text_to_look_for for up to the max_time provided
-  def wait_until_page_has text_to_look_for, max_time=30
-    max_time = 30 if (!max_time || (max_time.to_s != max_time.to_i.to_s))
-    text_found = false
-    Timeout.timeout(max_time) do
-      until text_found do
-        visit_page_with_token 'active', current_path
-        text_found = has_text?(text_to_look_for)
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/fixtures/.gitkeep b/apps/workbench/test/fixtures/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/test/functional/.gitkeep b/apps/workbench/test/functional/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/test/helpers/collections_helper_test.rb b/apps/workbench/test/helpers/collections_helper_test.rb
deleted file mode 100644 (file)
index e02b2ab..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class CollectionsHelperTest < ActionView::TestCase
-  reset_api_fixtures :after_each_test, false
-
-  [
-    ["filename.csv", true],
-    ["filename.fa", true],
-    ["filename.fasta", true],
-    ["filename.seq", true],   # another fasta extension
-    ["filename.go", true],
-    ["filename.htm", true],
-    ["filename.html", true],
-    ["filename.json", true],
-    ["filename.md", true],
-    ["filename.pdf", true],
-    ["filename.py", true],
-    ["filename.R", true],
-    ["filename.sam", true],
-    ["filename.sh", true],
-    ["filename.txt", true],
-    ["filename.tiff", true],
-    ["filename.tsv", true],
-    ["filename.vcf", true],
-    ["filename.xml", true],
-    ["filename.xsl", true],
-    ["filename.yml", true],
-    ["filename.yaml", true],
-    ["filename.bed", true],
-    ["filename.cwl", true],
-
-    ["filename.bam", false],
-    ["filename.tar", false],
-    ["filename", false],
-  ].each do |file_name, preview_allowed|
-    test "verify '#{file_name}' is allowed for preview #{preview_allowed}" do
-      assert_equal preview_allowed, preview_allowed_for(file_name)
-    end
-  end
-end
diff --git a/apps/workbench/test/helpers/download_helper.rb b/apps/workbench/test/helpers/download_helper.rb
deleted file mode 100644 (file)
index c8b5712..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module DownloadHelper
-  module_function
-
-  def path
-    Rails.root.join 'tmp', 'downloads'
-  end
-
-  def clear
-    if File.exist? path
-      FileUtils.rm_r path
-    end
-    begin
-      Dir.mkdir path
-    rescue Errno::EEXIST
-    end
-  end
-
-  def done
-    Dir[path.join '*'].reject do |f|
-      /\.part$/ =~ f
-    end
-  end
-end
diff --git a/apps/workbench/test/helpers/fake_websocket_helper.rb b/apps/workbench/test/helpers/fake_websocket_helper.rb
deleted file mode 100644 (file)
index a62775c..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module FakeWebsocketHelper
-  def use_fake_websocket_driver
-    Capybara.current_driver = :poltergeist_with_fake_websocket
-  end
-
-  def fake_websocket_event(logdata)
-    stamp = Time.now.utc.in_time_zone.as_json
-    defaults = {
-      owner_uuid: api_fixture('users')['system_user']['uuid'],
-      event_at: stamp,
-      created_at: stamp,
-      updated_at: stamp,
-    }
-    event = {data: Oj.dump(defaults.merge(logdata), mode: :compat)}
-    script = '$(window).data("arv-websocket").onmessage('+Oj.dump(event, mode: :compat)+');'
-    page.evaluate_script(script)
-  end
-end
diff --git a/apps/workbench/test/helpers/manifest_examples.rb b/apps/workbench/test/helpers/manifest_examples.rb
deleted file mode 120000 (symlink)
index cb908ef..0000000
+++ /dev/null
@@ -1 +0,0 @@
-../../../../services/api/test/helpers/manifest_examples.rb
\ No newline at end of file
diff --git a/apps/workbench/test/helpers/pipeline_instances_helper_test.rb b/apps/workbench/test/helpers/pipeline_instances_helper_test.rb
deleted file mode 100644 (file)
index 413df55..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class PipelineInstancesHelperTest < ActionView::TestCase
-  test "one" do
-    r = [{started_at: 1, finished_at: 3}]
-    assert_equal 2, determine_wallclock_runtime(r)
-
-    r = [{started_at: 1, finished_at: 5}]
-    assert_equal 4, determine_wallclock_runtime(r)
-
-    r = [{started_at: 1, finished_at: 2}, {started_at: 3, finished_at: 5}]
-    assert_equal 3, determine_wallclock_runtime(r)
-
-    r = [{started_at: 3, finished_at: 5}, {started_at: 1, finished_at: 2}]
-    assert_equal 3, determine_wallclock_runtime(r)
-
-    r = [{started_at: 3, finished_at: 5}, {started_at: 1, finished_at: 2},
-         {started_at: 2, finished_at: 4}]
-    assert_equal 4, determine_wallclock_runtime(r)
-
-    r = [{started_at: 1, finished_at: 5}, {started_at: 2, finished_at: 3}]
-    assert_equal 4, determine_wallclock_runtime(r)
-
-    r = [{started_at: 3, finished_at: 5}, {started_at: 1, finished_at: 4}]
-    assert_equal 4, determine_wallclock_runtime(r)
-
-    r = [{started_at: 1, finished_at: 4}, {started_at: 3, finished_at: 5}]
-    assert_equal 4, determine_wallclock_runtime(r)
-
-    r = [{started_at: 1, finished_at: 4}, {started_at: 3, finished_at: 5},
-         {started_at: 5, finished_at: 8}]
-    assert_equal 7, determine_wallclock_runtime(r)
-
-    r = [{started_at: 1, finished_at: 4}, {started_at: 3, finished_at: 5},
-         {started_at: 6, finished_at: 8}]
-    assert_equal 6, determine_wallclock_runtime(r)
-  end
-end
diff --git a/apps/workbench/test/helpers/repository_stub_helper.rb b/apps/workbench/test/helpers/repository_stub_helper.rb
deleted file mode 100644 (file)
index a8e3653..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module RepositoryStubHelper
-  # Supply some fake git content.
-  def stub_repo_content opts={}
-    fakesha1 = opts[:sha1] || 'abcdefabcdefabcdefabcdefabcdefabcdefabcd'
-    fakefilename = opts[:filename] || 'COPYING'
-    fakefilesrc = File.expand_path('../../../../../'+fakefilename, __FILE__)
-    fakefile = File.read fakefilesrc
-    fakecommit = <<-EOS
-      commit abcdefabcdefabcdefabcdefabcdefabcdefabcd
-      Author: Fake R <fake@example.com>
-      Date:   Wed Apr 1 11:59:59 2015 -0400
-
-          It's a fake commit.
-
-    EOS
-    Repository.any_instance.stubs(:ls_tree_lr).with(fakesha1).returns <<-EOS
-      100644 blob eec475862e6ec2a87554e0fca90697e87f441bf5     226    .gitignore
-      100644 blob acbd7523ed49f01217874965aa3180cccec89d61     625    COPYING
-      100644 blob d645695673349e3947e8e5ae42332d0ac3164cd7   11358    LICENSE-2.0.txt
-      100644 blob c7a36c355b4a2b94dfab45c9748330022a788c91     622    README
-      100644 blob dba13ed2ddf783ee8118c6a581dbf75305f816a3   34520    agpl-3.0.txt
-      100644 blob 9bef02bbfda670595750fd99a4461005ce5b8f12     695    apps/workbench/.gitignore
-      100644 blob b51f674d90f68bfb50d9304068f915e42b04aea4    2249    apps/workbench/Gemfile
-      100644 blob b51f674d90f68bfb50d9304068f915e42b04aea4    2249    apps/workbench/Gemfile
-    EOS
-    Repository.any_instance.
-      stubs(:cat_file).with(fakesha1, fakefilename).returns fakefile
-    Repository.any_instance.
-      stubs(:show).with(fakesha1).returns fakecommit
-    return fakesha1, fakecommit, fakefile
-  end
-end
diff --git a/apps/workbench/test/helpers/search_helper_test.rb b/apps/workbench/test/helpers/search_helper_test.rb
deleted file mode 100644 (file)
index acf7390..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class SearchHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/helpers/share_object_helper.rb b/apps/workbench/test/helpers/share_object_helper.rb
deleted file mode 100644 (file)
index e31f196..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-module ShareObjectHelper
-  def show_object_using(auth_key, type, key, expect)
-    obj_uuid = api_fixture(type)[key]['uuid']
-    visit(page_with_token(auth_key, "/#{type}/#{obj_uuid}"))
-    assert(page.has_text?(expect), "expected string not found: #{expect}")
-  end
-
-  def share_rows
-    find('#object_sharing').all('tr')
-  end
-
-  def add_share_and_check(share_type, name, obj=nil)
-    assert(page.has_no_text?(name), "project is already shared with #{name}")
-    start_share_count = share_rows.size
-    click_on("Share with #{share_type}")
-    within(".modal-container") do
-      # Order is important here: we should find something that appears in the
-      # modal before we make any assertions about what's not in the modal.
-      # Otherwise, the not-included assertions might falsely pass because
-      # the modal hasn't loaded yet.
-      find(".selectable", text: name).click
-      assert_text "Only #{share_type} you are allowed to access are shown"
-      assert(has_no_selector?(".modal-dialog-preview-pane"),
-             "preview pane available in sharing dialog")
-      if share_type == 'users' and obj and obj['email']
-        assert(page.has_text?(obj['email']), "Did not find user's email")
-      end
-      assert_raises(Capybara::ElementNotFound,
-                    "Projects pulldown available from sharing dialog") do
-        click_on "All projects"
-      end
-      click_on "Add"
-    end
-    # Admin case takes many times longer than normal user, but not sure why
-    using_wait_time(30) do
-      assert(page.has_link?(name),
-             "new share #{name} was not added to sharing table")
-      assert_equal(start_share_count + 1, share_rows.size,
-                   "new share did not add row to sharing table")
-    end
-  end
-
-  def modify_share_and_check(name)
-    start_rows = share_rows
-    # We assume rows have already been rendered and can be checked quickly
-    link_row = start_rows.select { |row| row.has_text?(name, wait:(0.1) ) }
-    assert_equal(1, link_row.size, "row with new permission not found")
-    within(link_row.first) do
-      click_on("Read")
-      select("Write", from: "share_change_level")
-      click_on("editable-submit")
-      assert(has_link?("Write"),
-             "failed to change access level on new share")
-      click_on "Revoke"
-      if Capybara.current_driver == :selenium
-        page.driver.browser.switch_to.alert.accept
-      else
-        # poltergeist returns true for confirm(), so we don't need to accept.
-      end
-    end
-    # Ensure revoked permission disappears from page.
-    using_wait_time(Capybara.default_max_wait_time * 3) do
-      assert_no_text name
-      assert_equal(start_rows.size - 1, share_rows.size,
-                   "revoking share did not remove row from sharing table")
-    end
-  end
-
-  def user_can_manage(user_sym, fixture)
-    get(:show, params: {id: fixture["uuid"]}, session: session_for(user_sym))
-    is_manager = assigns(:user_is_manager)
-    assert_not_nil(is_manager, "user_is_manager flag not set")
-    if not is_manager
-      assert_empty(assigns(:share_links),
-                   "non-manager has share links set")
-    end
-    is_manager
-  end
-
-end
diff --git a/apps/workbench/test/helpers/time_block.rb b/apps/workbench/test/helpers/time_block.rb
deleted file mode 120000 (symlink)
index afb43e7..0000000
+++ /dev/null
@@ -1 +0,0 @@
-../../../../services/api/test/helpers/time_block.rb
\ No newline at end of file
diff --git a/apps/workbench/test/integration/.gitkeep b/apps/workbench/test/integration/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/test/integration/ajax_errors_test.rb b/apps/workbench/test/integration/ajax_errors_test.rb
deleted file mode 100644 (file)
index b3b1f1f..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class AjaxErrorsTest < ActionDispatch::IntegrationTest
-  setup do
-    # Regrettably...
-    need_selenium 'to assert_text in iframe'
-  end
-
-  test 'load pane with deleted session' do
-    skip 'unreliable test'
-    # Simulate loading a page in browser-tab A, hitting "Log out" in
-    # browser-tab B, then returning to browser-tab A and choosing a
-    # different tab. (Automatic tab refreshes will behave similarly.)
-    visit page_with_token('active', '/projects/' + api_fixture('groups')['aproject']['uuid'])
-    ActionDispatch::Request::Session.any_instance.stubs(:[]).returns(nil)
-    click_link "Subprojects"
-    wait_for_ajax
-    assert_no_double_layout
-    assert_selector 'a,button', text: 'Reload tab'
-    assert_selector '.pane-error-display'
-    page.driver.browser.switch_to.frame 0
-    assert_text 'You are not logged in.'
-  end
-
-  test 'load pane with expired token' do
-    skip 'unreliable test'
-    # Similar to 'deleted session'. Here, the session cookie is still
-    # alive, but it contains a token which has expired. This uses a
-    # different code path because Workbench cannot detect that
-    # anything is amiss until it actually uses the token in an API
-    # request.
-    visit page_with_token('active', '/projects/' + api_fixture('groups')['aproject']['uuid'])
-    use_token :active_trustedclient do
-      # Go behind Workbench's back to expire the "active" token.
-      token = api_fixture('api_client_authorizations')['active']['api_token']
-      auth = ApiClientAuthorization.find(token)
-      auth.update_attributes(expires_at: '1999-12-31T23:59:59Z')
-    end
-    click_link "Subprojects"
-    wait_for_ajax
-    assert_no_double_layout
-    assert_selector 'a,button', text: 'Reload tab'
-    assert_selector '.pane-error-display'
-    page.driver.browser.switch_to.frame 0
-    assert_text 'You are not logged in.'
-  end
-
-  protected
-
-  def assert_no_double_layout
-    # Check we're not rendering a full page layout within a tab
-    # pane. Bootstrap responsive layouts require exactly one
-    # div.container-fluid. Checking "body body" would be more generic,
-    # but doesn't work when the browser/driver automatically collapses
-    # syntatically invalid tags.
-    assert_no_selector '.container-fluid .container-fluid'
-  end
-end
diff --git a/apps/workbench/test/integration/anonymous_access_test.rb b/apps/workbench/test/integration/anonymous_access_test.rb
deleted file mode 100644 (file)
index e47f1ae..0000000
+++ /dev/null
@@ -1,338 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class AnonymousAccessTest < ActionDispatch::IntegrationTest
-  # These tests don't do state-changing API calls. Save some time by
-  # skipping the database reset.
-  reset_api_fixtures :after_each_test, false
-  reset_api_fixtures :after_suite, true
-
-  setup do
-    need_javascript
-    Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-  end
-
-  PUBLIC_PROJECT = "/projects/#{api_fixture('groups')['anonymously_accessible_project']['uuid']}"
-
-  def verify_site_navigation_anonymous_enabled user, is_active
-    if user
-      if user['is_active']
-        assert_text 'Unrestricted public data'
-        assert_selector 'a', text: 'Projects'
-        page.find("#projects-menu").click
-        within('.dropdown-menu') do
-          assert_selector 'a', text: 'Search all projects'
-          assert_selector "a[href=\"/projects/public\"]", text: 'Browse public projects'
-          assert_selector 'a', text: 'Add a new project'
-          assert_selector 'li[class="dropdown-header"]', text: 'My projects'
-        end
-      else
-        assert_text 'indicate that you have read and accepted the user agreement'
-      end
-      within('.navbar-fixed-top') do
-        assert_selector 'a', text: Rails.configuration.Workbench.SiteName.downcase
-        assert(page.has_link?("notifications-menu"), 'no user menu')
-        page.find("#notifications-menu").click
-        within('.dropdown-menu') do
-          assert_selector 'a', text: 'Log out'
-        end
-      end
-    else  # anonymous
-      assert_text 'Unrestricted public data'
-      within('.navbar-fixed-top') do
-        assert_text Rails.configuration.Workbench.SiteName.downcase
-        assert_no_selector 'a', text: Rails.configuration.Workbench.SiteName.downcase
-        assert_selector 'a', text: 'Log in'
-        assert_selector 'a', text: 'Browse public projects'
-      end
-    end
-  end
-
-  [
-    [nil, nil, false, false],
-    ['inactive', api_fixture('users')['inactive'], false, false],
-    ['active', api_fixture('users')['active'], true, true],
-  ].each do |token, user, is_active|
-    test "visit public project as user #{token.inspect} when anonymous browsing is enabled" do
-      if !token
-        visit PUBLIC_PROJECT
-      else
-        visit page_with_token(token, PUBLIC_PROJECT)
-      end
-
-      verify_site_navigation_anonymous_enabled user, is_active
-    end
-  end
-
-  test "selection actions when anonymous user accesses shared project" do
-    visit PUBLIC_PROJECT
-
-    assert_selector 'a', text: 'Description'
-    assert_selector 'a', text: 'Data collections'
-    assert_selector 'a', text: 'Pipelines and processes'
-    assert_selector 'a', text: 'Pipeline templates'
-    assert_selector 'a', text: 'Subprojects'
-    assert_selector 'a', text: 'Advanced'
-    assert_no_selector 'a', text: 'Other objects'
-    assert_no_selector 'button', text: 'Add data'
-
-    click_link 'Data collections'
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li', text: 'Compare selected'
-      assert_no_selector 'li', text: 'Create new collection with selected collections'
-      assert_no_selector 'li', text: 'Copy selected'
-      assert_no_selector 'li', text: 'Move selected'
-      assert_no_selector 'li', text: 'Remove selected'
-    end
-  end
-
-  test "anonymous user accesses data collections tab in shared project" do
-    visit PUBLIC_PROJECT
-    click_link 'Data collections'
-    collection = api_fixture('collections')['user_agreement_in_anonymously_accessible_project']
-    assert_text 'GNU General Public License'
-
-    assert_selector 'a', text: 'Data collections'
-
-    # click on show collection
-    within "tr[data-object-uuid=\"#{collection['uuid']}\"]" do
-      click_link 'Show'
-    end
-
-    # in collection page
-    assert_no_selector 'input', text: 'Create sharing link'
-    assert_no_text 'Sharing and permissions'
-    assert_no_selector 'a', text: 'Upload'
-    assert_no_selector 'button', 'Selection'
-
-    within '#collection_files tr,li', text: 'GNU_General_Public_License,_version_3.pdf' do
-      assert page.has_no_selector?('[value*="GNU_General_Public_License"]')
-      find 'a[title~=View]'
-      find 'a[title~=Download]'
-    end
-  end
-
-  test 'view file' do
-    need_selenium "phantomjs does not follow redirects reliably, maybe https://github.com/ariya/phantomjs/issues/10389"
-    magic = rand(2**512).to_s 36
-    owner = api_fixture('groups')['anonymously_accessible_project']['uuid']
-    col = upload_data_and_get_collection(magic, 'admin', "Hello\\040world.txt", owner)
-    visit '/collections/' + col.uuid
-    find('tr,li', text: 'Hello world.txt').
-      find('a[title~=View]').click
-    assert_text magic
-  end
-
-  [
-    'running anonymously accessible cr',
-    'pipelineInstance'
-  ].each do |proc|
-    test "anonymous user accesses pipelines and processes tab in shared project and clicks on '#{proc}'" do
-      visit PUBLIC_PROJECT
-      click_link 'Data collections'
-      assert_text 'GNU General Public License'
-
-      click_link 'Pipelines and processes'
-      assert_text 'Pipeline in publicly accessible project'
-
-      if proc.include? 'pipeline'
-        verify_pipeline_instance_row
-      else
-        verify_container_request_row proc
-      end
-    end
-  end
-
-  def verify_container_request_row look_for
-    within first('tr', text: look_for) do
-      click_link 'Show'
-    end
-    assert_text 'Public Projects Unrestricted public data'
-    assert_text 'command'
-
-    assert_text 'zzzzz-tpzed-xurymjxw79nv3jz' # modified by user
-    assert_no_selector 'a', text: 'zzzzz-tpzed-xurymjxw79nv3jz'
-    assert_no_selector 'button', text: 'Cancel'
-  end
-
-  def verify_pipeline_instance_row
-    within first('tr[data-kind="arvados#pipelineInstance"]') do
-      assert_text 'Pipeline in publicly accessible project'
-      click_link 'Show'
-    end
-
-    # in pipeline instance page
-    assert_text 'Public Projects Unrestricted public data'
-    assert_text 'This pipeline is complete'
-    assert_no_selector 'a', text: 'Re-run with latest'
-    assert_no_selector 'a', text: 'Re-run options'
-  end
-
-  [
-    'pipelineTemplate',
-    'workflow'
-  ].each do |type|
-    test "anonymous user accesses pipeline templates tab in shared project and click on #{type}" do
-      visit PUBLIC_PROJECT
-      click_link 'Data collections'
-      assert_text 'GNU General Public License'
-
-      assert_selector 'a', text: 'Pipeline templates'
-
-      click_link 'Pipeline templates'
-      assert_text 'Pipeline template in publicly accessible project'
-      assert_text 'Workflow with input specifications'
-
-      if type == 'pipelineTemplate'
-        within first('tr[data-kind="arvados#pipelineTemplate"]') do
-          click_link 'Show'
-        end
-
-        # in template page
-        assert_text 'Public Projects Unrestricted public data'
-        assert_text 'script version'
-        assert_no_selector 'a', text: 'Run this pipeline'
-      else
-        within 'tr[data-kind="arvados#workflow"]', text: "Workflow with default input specifications" do
-          click_link 'Show'
-        end
-
-        # in workflow page
-        assert_text 'Public Projects Unrestricted public data'
-        assert_text 'this workflow has inputs specified'
-      end
-    end
-  end
-
-  test "anonymous user accesses subprojects tab in shared project" do
-    visit PUBLIC_PROJECT + '#Subprojects'
-
-    assert_text 'Subproject in anonymous accessible project'
-
-    within first('tr[data-kind="arvados#group"]') do
-      click_link 'Show'
-    end
-
-    # in subproject
-    assert_text 'Description for subproject in anonymous accessible project'
-  end
-
-  [
-    ['pipeline_in_publicly_accessible_project', true],
-    ['pipeline_in_publicly_accessible_project_but_other_objects_elsewhere', false],
-    ['pipeline_in_publicly_accessible_project_but_other_objects_elsewhere', false, 'spectator'],
-    ['pipeline_in_publicly_accessible_project_but_other_objects_elsewhere', true, 'admin'],
-
-    ['completed_job_in_publicly_accessible_project', true],
-    ['running_job_in_publicly_accessible_project', true],
-    ['job_in_publicly_accessible_project_but_other_objects_elsewhere', false],
-  ].each do |fixture, objects_readable, user=nil|
-    test "access #{fixture} in public project with objects readable=#{objects_readable} with user #{user}" do
-      pipeline_page = true if fixture.include?('pipeline')
-
-      if pipeline_page
-        object = api_fixture('pipeline_instances')[fixture]
-        page_link = "/pipeline_instances/#{object['uuid']}"
-        expect_log_text = "Log for foo"
-      else      # job
-        object = api_fixture('jobs')[fixture]
-        page_link = "/jobs/#{object['uuid']}"
-        expect_log_text = "stderr crunchstat"
-      end
-
-      if user
-        visit page_with_token user, page_link
-      else
-        visit page_link
-      end
-
-      # click job link, if in pipeline page
-      click_link 'foo' if pipeline_page
-
-      if objects_readable
-        assert_selector 'a[href="#Log"]', text: 'Log'
-        assert_no_selector 'a[data-toggle="disabled"]', text: 'Log'
-        assert_no_text 'zzzzz-4zz18-bv31uwvy3neko21 (Unavailable)'
-        if pipeline_page
-          assert_text 'This pipeline was created from'
-          job_id = object['components']['foo']['job']['uuid']
-          assert_selector 'a', text: job_id
-          assert_selector "a[href=\"/jobs/#{job_id}#Log\"]", text: 'Log'
-
-          # We'd like to test the Log tab on job pages too, but we can't right
-          # now because Poltergeist 1.x doesn't support JavaScript's
-          # Function.prototype.bind, which is used by job_log_graph.js.
-          find(:xpath, "//a[@href='#Log']").click
-          assert_text expect_log_text
-        end
-      else
-        assert_selector 'a[data-toggle="disabled"]', text: 'Log'
-        assert_text 'zzzzz-4zz18-bv31uwvy3neko21 (Unavailable)'
-        assert_text object['job']
-        if pipeline_page
-          assert_no_text 'This pipeline was created from'  # template is not readable
-          assert_no_selector 'a', text: object['components']['foo']['job']['uuid']
-          assert_text 'Log unavailable'
-        end
-        find(:xpath, "//a[@href='#Log']").click
-        assert_text 'zzzzz-4zz18-bv31uwvy3neko21 (Unavailable)'
-        assert_no_text expect_log_text
-      end
-    end
-  end
-
-  [
-    ['new_pipeline_in_publicly_accessible_project', true],
-    ['new_pipeline_in_publicly_accessible_project', true, 'spectator'],
-    ['new_pipeline_in_publicly_accessible_project_but_other_objects_elsewhere', false],
-    ['new_pipeline_in_publicly_accessible_project_but_other_objects_elsewhere', false, 'spectator'],
-    ['new_pipeline_in_publicly_accessible_project_but_other_objects_elsewhere', true, 'admin'],
-    ['new_pipeline_in_publicly_accessible_project_with_dataclass_file_and_other_objects_elsewhere', false],
-    ['new_pipeline_in_publicly_accessible_project_with_dataclass_file_and_other_objects_elsewhere', false, 'spectator'],
-    ['new_pipeline_in_publicly_accessible_project_with_dataclass_file_and_other_objects_elsewhere', true, 'admin'],
-  ].each do |fixture, objects_readable, user=nil|
-    test "access #{fixture} in public project with objects readable=#{objects_readable} with user #{user}" do
-      object = api_fixture('pipeline_instances')[fixture]
-      page = "/pipeline_instances/#{object['uuid']}"
-      if user
-        visit page_with_token user, page
-      else
-        visit page
-      end
-
-      # click Components tab
-      click_link 'Components'
-
-      if objects_readable
-        assert_text 'This pipeline was created from'
-        if user == 'admin'
-          assert_text 'input'
-          assert_selector 'a', text: 'Choose'
-          assert_selector 'a', text: 'Run'
-          assert_no_selector 'a.disabled', text: 'Run'
-        else
-          assert_selector 'a', text: object['components']['foo']['script_parameters']['input']['value']
-          user ? (assert_selector 'a', text: 'Run') : (assert_no_selector 'a', text: 'Run')
-        end
-      else
-        assert_no_text 'This pipeline was created from'  # template is not readable
-        input = object['components']['foo']['script_parameters']['input']['value']
-        assert_no_selector 'a', text: input
-        if user
-          input = input.gsub('/', '\\/')
-          assert_text "One or more inputs provided are not readable"
-          assert_selector "input[type=text][value=#{input}]"
-          assert_selector 'a.disabled', text: 'Run'
-        else
-          assert_no_text "One or more inputs provided are not readable"
-          assert_text input
-          assert_no_selector 'a', text: 'Run'
-        end
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/integration/application_layout_test.rb b/apps/workbench/test/integration/application_layout_test.rb
deleted file mode 100644 (file)
index 35a1415..0000000
+++ /dev/null
@@ -1,310 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-require 'config_validators'
-
-class ApplicationLayoutTest < ActionDispatch::IntegrationTest
-  # These tests don't do state-changing API calls. Save some time by
-  # skipping the database reset.
-  reset_api_fixtures :after_each_test, false
-  reset_api_fixtures :after_suite, true
-
-  setup do
-    need_javascript
-  end
-
-  def verify_homepage user, invited, has_profile
-    profile_config = Rails.configuration.Workbench.UserProfileFormFields
-
-    if !user
-      assert page.has_text?('Please log in'), 'Not found text - Please log in'
-      assert page.has_text?('If you have never used Arvados Workbench before'), 'Not found text - If you have never'
-      assert page.has_no_text?('My projects'), 'Found text - My projects'
-      assert page.has_link?("Log in"), 'Not found text - Log in'
-    elsif user['is_active']
-      if profile_config && !has_profile
-        assert page.has_text?('Save profile'), 'No text - Save profile'
-      else
-        assert page.has_link?("Projects"), 'Not found link - Projects'
-        page.find("#projects-menu").click
-        assert_selector 'a', text: 'Search all projects'
-        assert_no_selector 'a', text: 'Browse public projects'
-        assert_selector 'a', text: 'Add a new project'
-        assert_selector 'li[class="dropdown-header"]', text: 'My projects'
-      end
-    elsif invited
-      assert page.has_text?('Please check the box below to indicate that you have read and accepted the user agreement'), 'Not found text - Please check the box below . . .'
-    else
-      assert page.has_text?('Your account is inactive'), 'Not found text - Your account is inactive'
-    end
-
-    within('.navbar-fixed-top') do
-      if !user
-        assert_text Rails.configuration.Workbench.SiteName.downcase
-        assert_no_selector 'a', text: Rails.configuration.Workbench.SiteName.downcase
-        assert page.has_link?('Log in'), 'Not found link - Log in'
-      else
-        # my account menu
-        assert_selector 'a', text: Rails.configuration.Workbench.SiteName.downcase
-        assert(page.has_link?("notifications-menu"), 'no user menu')
-        page.find("#notifications-menu").click
-        within('.dropdown-menu') do
-          if user['is_active']
-            assert page.has_no_link?('Not active'), 'Found link - Not active'
-            assert page.has_no_link?('Sign agreements'), 'Found link - Sign agreements'
-
-            assert_selector "a[href=\"/projects/#{user['uuid']}\"]", text: 'Home project'
-            assert_selector "a[href=\"/users/#{user['uuid']}/virtual_machines\"]", text: 'Virtual machines'
-            assert_selector "a[href=\"/repositories\"]", text: 'Repositories'
-            assert_selector "a[href=\"/current_token\"]", text: 'Current token'
-            assert_selector "a[href=\"/users/#{user['uuid']}/ssh_keys\"]", text: 'SSH keys'
-
-            if profile_config
-              assert_selector "a[href=\"/users/#{user['uuid']}/profile\"]", text: 'Manage profile'
-            else
-              assert_no_selector "a[href=\"/users/#{user['uuid']}/profile\"]", text: 'Manage profile'
-            end
-          else
-            assert_no_selector 'a', text: 'Home project'
-            assert page.has_no_link?('Virtual machines'), 'Found link - Virtual machines'
-            assert page.has_no_link?('Repositories'), 'Found link - Repositories'
-            assert page.has_no_link?('Current token'), 'Found link - Current token'
-            assert page.has_no_link?('SSH keys'), 'Found link - SSH keys'
-            assert page.has_no_link?('Manage profile'), 'Found link - Manage profile'
-          end
-          assert page.has_link?('Log out'), 'No link - Log out'
-        end
-      end
-    end
-  end
-
-  # test the help menu
-  def check_help_menu
-    within('.navbar-fixed-top') do
-      page.find("#arv-help").click
-      within('.dropdown-menu') do
-        assert_no_selector 'a', text:'Getting Started ...'
-        assert_selector 'a', text:'Public Pipelines and Data sets'
-        assert page.has_link?('Tutorials and User guide'), 'No link - Tutorials and User guide'
-        assert page.has_link?('API Reference'), 'No link - API Reference'
-        assert page.has_link?('SDK Reference'), 'No link - SDK Reference'
-        assert page.has_link?('Show version / debugging info ...'), 'No link - Show version / debugging info'
-        assert page.has_link?('Report a problem ...'), 'No link - Report a problem'
-        # Version info and Report a problem are tested in "report_issue_test.rb"
-      end
-    end
-  end
-
-  def verify_system_menu user
-    if user && user['is_admin']
-      assert page.has_link?('system-menu'), 'No link - system menu'
-      within('.navbar-fixed-top') do
-        page.find("#system-menu").click
-        within('.dropdown-menu') do
-          assert page.has_text?('Groups'), 'No text - Groups'
-          assert page.has_link?('Repositories'), 'No link - Repositories'
-          assert page.has_link?('Virtual machines'), 'No link - Virtual machines'
-          assert page.has_link?('SSH keys'), 'No link - SSH keys'
-          assert page.has_link?('API tokens'), 'No link - API tokens'
-          find('a', text: 'Users').click
-        end
-      end
-      assert page.has_text? 'Add a new user'
-    else
-      assert page.has_no_link?('system-menu'), 'Found link - system menu'
-    end
-  end
-
-  [
-    [nil, nil, false, false],
-    ['inactive', api_fixture('users')['inactive'], true, false],
-    ['inactive_uninvited', api_fixture('users')['inactive_uninvited'], false, false],
-    ['active', api_fixture('users')['active'], true, true],
-    ['admin', api_fixture('users')['admin'], true, true],
-    ['active_no_prefs', api_fixture('users')['active_no_prefs'], true, false],
-    ['active_no_prefs_profile_no_getting_started_shown',
-        api_fixture('users')['active_no_prefs_profile_no_getting_started_shown'], true, false],
-  ].each do |token, user, invited, has_profile|
-
-    test "visit home page for user #{token}" do
-      Rails.configuration.Users.AnonymousUserToken = ""
-      if !token
-        visit ('/')
-      else
-        visit page_with_token(token)
-      end
-
-      check_help_menu
-      verify_homepage user, invited, has_profile
-      verify_system_menu user
-    end
-  end
-
-  [
-    ["", false],
-    ['http://wb2.example.org//', false],
-    ['ftp://wb2.example.org', false],
-    ['wb2.example.org', false],
-    ['http://wb2.example.org', true],
-    ['https://wb2.example.org', true],
-    ['http://wb2.example.org/', true],
-    ['https://wb2.example.org/', true],
-  ].each do |wb2_url_config, wb2_menu_appear|
-    test "workbench2_url=#{wb2_url_config} should#{wb2_menu_appear ? '' : ' not'} show WB2 menu" do
-      Rails.configuration.Services.Workbench2.ExternalURL = URI(wb2_url_config)
-      if !wb2_menu_appear and !wb2_url_config.empty?
-        assert_raises RuntimeError do
-          ConfigValidators.validate_wb2_url_config()
-        end
-        Rails.configuration.Services.Workbench2.ExternalURL = URI("")
-      end
-
-      visit page_with_token('active')
-      within('.navbar-fixed-top') do
-        page.find("#notifications-menu").click
-        within('.dropdown-menu') do
-          assert_equal wb2_menu_appear, page.has_text?('Go to Workbench 2')
-        end
-      end
-    end
-  end
-
-  [
-    ['active', true],
-    ['active_with_prefs_profile_no_getting_started_shown', false],
-  ].each do |token, getting_started_shown|
-    test "getting started help menu item #{getting_started_shown}" do
-      Rails.configuration.Workbench.EnableGettingStartedPopup = true
-
-      visit page_with_token(token)
-
-      if getting_started_shown
-        within '.navbar-fixed-top' do
-          find('.help-menu > a').click
-          find('.help-menu .dropdown-menu a', text: 'Getting Started ...').click
-        end
-      end
-
-      within '.modal-content' do
-        assert_text 'Getting Started'
-        assert_selector 'button:not([disabled])', text: 'Next'
-        assert_no_selector 'button:not([disabled])', text: 'Prev'
-
-        # Use Next button to enable Prev button
-        click_button 'Next'
-        assert_selector 'button:not([disabled])', text: 'Prev'  # Prev button is now enabled
-        click_button 'Prev'
-        assert_no_selector 'button:not([disabled])', text: 'Prev'  # Prev button is again disabled
-
-        # Click Next until last page is reached and verify that it is disabled
-        (0..20).each do |i|   # currently we only have 4 pages, and don't expect to have more than 20 in future
-          click_button 'Next'
-          begin
-            find('button:not([disabled])', text: 'Next')
-          rescue => e
-            break
-          end
-        end
-        assert_no_selector 'button:not([disabled])', text: 'Next'  # Next button is disabled
-        assert_selector 'button:not([disabled])', text: 'Prev'     # Prev button is enabled
-        click_button 'Prev'
-        assert_selector 'button:not([disabled])', text: 'Next'     # Next button is now enabled
-
-        first('button', text: 'x').click
-      end
-      assert_text 'Recent processes' # seeing dashboard now
-    end
-  end
-
-  test "test arvados_public_data_doc_url config unset" do
-    Rails.configuration.Workbench.ArvadosPublicDataDocURL = ""
-
-    visit page_with_token('active')
-    within '.navbar-fixed-top' do
-      find('.help-menu > a').click
-
-      assert_no_selector 'a', text:'Public Pipelines and Data sets'
-      assert_no_selector 'a', text:'Getting Started ...'
-
-      assert page.has_link?('Tutorials and User guide'), 'No link - Tutorials and User guide'
-      assert page.has_link?('API Reference'), 'No link - API Reference'
-      assert page.has_link?('SDK Reference'), 'No link - SDK Reference'
-      assert page.has_link?('Show version / debugging info ...'), 'No link - Show version / debugging info'
-      assert page.has_link?('Report a problem ...'), 'No link - Report a problem'
-    end
-  end
-
-  test "no SSH public key notification when shell_in_a_box_url is configured" do
-    Rails.configuration.Services.WebShell.ExternalURL = URI('http://example.com')
-    Rails.configuration.Users.AnonymousUserToken = ""
-    visit page_with_token('job_reader')
-    click_link 'notifications-menu'
-    assert_no_selector 'a', text:'Click here to set up an SSH public key for use with Arvados.'
-    assert_selector 'a', text:'Click here to learn how to run an Arvados Crunch pipeline'
-  end
-
-   [
-    ['Repositories', nil, 'active/crunchdispatchtest'],
-    ['Virtual machines', nil, 'testvm.shell'],
-    ['SSH keys', nil, 'public_key'],
-    ['Links', nil, 'link_class'],
-    ['Groups', nil, 'All users'],
-    ['Keep services', nil, 'service_ssl_flag'],
-  ].each do |page_name, add_button_text, look_for|
-    test "test system menu #{page_name} link" do
-      visit page_with_token('admin')
-      within('.navbar-fixed-top') do
-        page.find("#system-menu").click
-        within('.dropdown-menu') do
-          assert_selector 'a', text: page_name
-          find('a', text: page_name).click
-        end
-      end
-
-      # click the add button if it exists
-      if add_button_text
-        assert_selector 'button', text: "Add a new #{add_button_text}"
-        find('button', text: "Add a new #{add_button_text}").click
-      else
-        assert_no_selector 'button', text:"Add a new"
-      end
-
-      # look for unique property in the current page
-      assert_text look_for
-    end
-  end
-
-  [
-    ['active', false],
-    ['admin', true],
-  ].each do |token, is_admin|
-    test "visit dashboard as #{token}" do
-      visit page_with_token(token)
-
-      assert_text 'Recent processes' # seeing dashboard now
-      within('.recent-processes-actions') do
-        assert page.has_link?('Run a process')
-        assert page.has_link?('All processes')
-      end
-
-      within('.recent-processes') do
-
-        within('.row-zzzzz-xvhdp-cr4runningcntnr') do
-          assert_text 'running'
-        end
-
-        assert_text 'zzzzz-d1hrv-twodonepipeline'
-        within('.row-zzzzz-d1hrv-twodonepipeline')do
-          assert_text 'No output'
-        end
-
-        assert_text 'completed container request'
-        within('.row-zzzzz-xvhdp-cr4completedctr')do
-          assert page.has_link? 'foo_file'
-        end
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/integration/browser_unsupported_test.rb b/apps/workbench/test/integration/browser_unsupported_test.rb
deleted file mode 100644 (file)
index 2933a04..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class BrowserUnsupported < ActionDispatch::IntegrationTest
-  WARNING_FRAGMENT = 'Your web browser is missing some of the features'
-
-  test 'warning if no File API' do
-    Capybara.current_driver = :poltergeist_without_file_api
-    visit '/'
-    assert_text :visible, WARNING_FRAGMENT
-  end
-
-  test 'no warning if File API' do
-    need_javascript
-    visit '/'
-    assert_no_text :visible, WARNING_FRAGMENT
-  end
-end
diff --git a/apps/workbench/test/integration/collection_upload_test.rb b/apps/workbench/test/integration/collection_upload_test.rb
deleted file mode 100644 (file)
index 608cd52..0000000
+++ /dev/null
@@ -1,149 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class CollectionUploadTest < ActionDispatch::IntegrationTest
-  setup do
-    testfiles.each do |filename, content|
-      open(testfile_path(filename), 'w') do |io|
-        io.write content
-      end
-    end
-    # Database reset doesn't restore KeepServices; we have to
-    # save/restore manually.
-    use_token :admin do
-      @keep_services = KeepService.all.to_a
-    end
-  end
-
-  teardown do
-    use_token :admin do
-      @keep_services.each do |ks|
-        KeepService.find(ks.uuid).update_attributes(ks.attributes)
-      end
-    end
-    testfiles.each do |filename, _|
-      File.unlink(testfile_path filename)
-    end
-  end
-
-  test "Create new collection using upload button" do
-    need_javascript
-    visit page_with_token 'active', aproject_path
-    find('.btn', text: 'Add data').click
-    click_link 'Upload files from my computer'
-    # Should be looking at a new empty collection.
-    assert_text 'New collection'
-    assert_text ' 0 files'
-    assert_text ' 0 bytes'
-    # The "Upload" tab should be active and loaded.
-    assert_selector 'div#Upload.active div.panel'
-  end
-
-  test "Upload two empty files with the same name" do
-    need_selenium "to make file uploads work"
-    visit page_with_token 'active', sandbox_path
-
-    unlock_collection
-
-    find('.nav-tabs a', text: 'Upload').click
-    attach_file 'file_selector', testfile_path('empty.txt')
-    assert_selector 'div', text: 'empty.txt'
-    attach_file 'file_selector', testfile_path('empty.txt')
-    assert_selector 'div.row div span[title]', text: 'empty.txt', count: 2
-    click_button 'Start'
-    assert_text :visible, 'Done!'
-    visit sandbox_path+'.json'
-    assert_match /_text":"\. d41d8\S+ 0:0:empty.txt\\n\. d41d8\S+ 0:0:empty\\\\040\(1\).txt\\n"/, body
-  end
-
-  test "Upload non-empty files" do
-    need_selenium "to make file uploads work"
-    visit page_with_token 'active', sandbox_path
-
-    unlock_collection
-
-    find('.nav-tabs a', text: 'Upload').click
-    attach_file 'file_selector', testfile_path('a')
-    attach_file 'file_selector', testfile_path('foo.txt')
-    assert_selector 'button:not([disabled])', text: 'Start'
-    click_button 'Start'
-    assert_text :visible, 'Done!'
-    visit sandbox_path+'.json'
-    assert_match /_text":"\. 0cc1\S+ 0:1:a\\n\. acbd\S+ 0:3:foo.txt\\n"/, body
-  end
-
-  test "Report mixed-content error" do
-    skip 'Test suite does not use TLS'
-    need_selenium "to make file uploads work"
-    use_token :admin do
-      KeepService.where(service_type: 'proxy').first.
-        update_attributes(service_ssl_flag: false)
-    end
-    visit page_with_token 'active', sandbox_path
-    find('.nav-tabs a', text: 'Upload').click
-    attach_file 'file_selector', testfile_path('foo.txt')
-    assert_selector 'button:not([disabled])', text: 'Start'
-    click_button 'Start'
-    using_wait_time 5 do
-      assert_text :visible, 'server setup problem'
-      assert_text :visible, 'cannot be used from origin'
-    end
-  end
-
-  test "Report network error" do
-    need_selenium "to make file uploads work"
-    use_token :admin do
-      # Even if port 0 is a thing, surely nx.example.net won't
-      # respond
-      KeepService.where(service_type: 'proxy').first.
-        update_attributes(service_host: 'nx.example.net',
-                          service_port: 0)
-    end
-    visit page_with_token 'active', sandbox_path
-
-    unlock_collection
-
-    find('.nav-tabs a', text: 'Upload').click
-    attach_file 'file_selector', testfile_path('foo.txt')
-    assert_selector 'button:not([disabled])', text: 'Start'
-    click_button 'Start'
-    using_wait_time 5 do
-      assert_text :visible, 'network error'
-    end
-  end
-
-  protected
-
-  def aproject_path
-    '/projects/' + api_fixture('groups')['aproject']['uuid']
-  end
-
-  def sandbox_uuid
-    api_fixture('collections')['upload_sandbox']['uuid']
-  end
-
-  def sandbox_path
-    '/collections/' + sandbox_uuid
-  end
-
-  def testfiles
-    {
-      'empty.txt' => '',
-      'a' => 'a',
-      'foo.txt' => 'foo'
-    }
-  end
-
-  def testfile_path filename
-    # Must be an absolute path. https://github.com/jnicklas/capybara/issues/621
-    File.join Dir.getwd, 'tmp', filename
-  end
-
-  def unlock_collection
-    first('.lock-collection-btn').click
-    accept_alert
-  end
-end
diff --git a/apps/workbench/test/integration/collections_test.rb b/apps/workbench/test/integration/collections_test.rb
deleted file mode 100644 (file)
index 4d6489d..0000000
+++ /dev/null
@@ -1,435 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-require_relative 'integration_test_utils'
-
-class CollectionsTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  test "Can copy a collection to a project" do
-    collection_uuid = api_fixture('collections')['foo_file']['uuid']
-    collection_name = api_fixture('collections')['foo_file']['name']
-    project_uuid = api_fixture('groups')['aproject']['uuid']
-    project_name = api_fixture('groups')['aproject']['name']
-    visit page_with_token('active', "/collections/#{collection_uuid}")
-    click_link 'Copy to project...'
-    find('.selectable', text: project_name).click
-    find('.modal-footer a,button', text: 'Copy').click
-    # Should navigate to the Data collections tab of the project after copying
-    assert_text project_name
-    assert_text "Copy of #{collection_name}"
-  end
-
-  def check_sharing(want_state, link_regexp)
-    # We specifically want to click buttons.  See #4291.
-    if want_state == :off
-      click_button "Unshare"
-      text_assertion = :assert_no_text
-      link_assertion = :assert_empty
-    else
-      click_button "Create sharing link"
-      text_assertion = :assert_text
-      link_assertion = :refute_empty
-    end
-    using_wait_time(Capybara.default_max_wait_time * 3) do
-      send(text_assertion, "Shared at:")
-    end
-    send(link_assertion, all("a").select { |a| a[:href] =~ link_regexp })
-  end
-
-  test "Hides sharing link button when configured to do so" do
-    Rails.configuration.Workbench.DisableSharingURLsUI = true
-    coll_uuid = api_fixture("collections", "collection_owned_by_active", "uuid")
-    visit page_with_token("active_trustedclient", "/collections/#{coll_uuid}")
-    assert_no_selector 'div#sharing-button'
-  end
-
-  test "creating and uncreating a sharing link" do
-    coll_uuid = api_fixture("collections", "collection_owned_by_active", "uuid")
-    download_link_re =
-      Regexp.new(Regexp.escape("/c=#{coll_uuid}/"))
-    visit page_with_token("active_trustedclient", "/collections/#{coll_uuid}")
-    assert_selector 'div#sharing-button'
-    within "#sharing-button" do
-      check_sharing(:on, download_link_re)
-      check_sharing(:off, download_link_re)
-    end
-  end
-
-  test "can download an entire collection with a reader token" do
-    need_selenium "phantomjs does not follow redirects reliably, maybe https://github.com/ariya/phantomjs/issues/10389"
-
-    token = api_token('active')
-    data = "foo\nfile\n"
-    datablock = `echo -n #{data.shellescape} | ARVADOS_API_TOKEN=#{token.shellescape} arv-put --no-progress --raw -`.strip
-    assert $?.success?, $?
-
-    col = nil
-    use_token 'active' do
-      mtxt = ". #{datablock} 0:#{data.length}:foo\n"
-      col = Collection.create(manifest_text: mtxt)
-    end
-
-    uuid = col.uuid
-    token = api_fixture('api_client_authorizations')['active_all_collections']['api_token']
-    url_head = "/collections/download/#{uuid}/#{token}/"
-    visit url_head
-    assert_text "You can download individual files listed below"
-    # It seems that Capybara can't inspect tags outside the body, so this is
-    # a very blunt approach.
-    assert_no_match(/<\s*meta[^>]+\bnofollow\b/i, page.html,
-                    "wget prohibited from recursing the collection page")
-    # Look at all the links that wget would recurse through using our
-    # recommended options, and check that it's exactly the file list.
-    hrefs = []
-    page.html.scan(/href="(.*?)"/) { |m| hrefs << m[0] }
-    assert_equal(['./foo'], hrefs, "download page did provide strictly file links")
-    click_link "foo"
-    assert_text "foo\nfile\n"
-  end
-
-  test "combine selected collections into new collection" do
-    foo_collection = api_fixture('collections')['foo_file']
-    bar_collection = api_fixture('collections')['bar_file']
-
-    visit page_with_token('active', "/collections")
-
-    assert(page.has_text?(foo_collection['uuid']), "Collection page did not include foo file")
-    assert(page.has_text?(bar_collection['uuid']), "Collection page did not include bar file")
-
-    within "tr[data-object-uuid=\"#{foo_collection['uuid']}\"]" do
-      find('input[type=checkbox]').click
-    end
-
-    within "tr[data-object-uuid=\"#{bar_collection['uuid']}\"]" do
-      find('input[type=checkbox]').click
-    end
-
-    click_button 'Selection...'
-    within('.selection-action-container') do
-      click_link 'Create new collection with selected collections'
-    end
-
-    # now in the newly created collection page
-    assert(page.has_text?('Copy to project'), "Copy to project text not found in new collection page")
-    assert(page.has_no_text?(foo_collection['name']), "Collection page did not include foo file")
-    assert(page.has_text?('foo'), "Collection page did not include foo file")
-    assert(page.has_no_text?(bar_collection['name']), "Collection page did not include foo file")
-    assert(page.has_text?('bar'), "Collection page did not include bar file")
-    assert(page.has_text?('Created new collection in your Home project'),
-                          'Not found flash message that new collection is created in Home project')
-  end
-
-  [
-    ['active', 'foo_file', false],
-    ['active', 'foo_collection_in_aproject', true],
-    ['project_viewer', 'foo_file', false],
-    ['project_viewer', 'foo_collection_in_aproject', false], #aproject not writable
-  ].each do |user, collection, expect_collection_in_aproject|
-    test "combine selected collection files into new collection #{user} #{collection} #{expect_collection_in_aproject}" do
-      my_collection = api_fixture('collections')[collection]
-
-      visit page_with_token(user, "/collections")
-
-      # choose file from foo collection
-      within('tr', text: my_collection['uuid']) do
-        click_link 'Show'
-      end
-
-      # now in collection page
-      find('input[type=checkbox]').click
-
-      click_button 'Selection...'
-      within('.selection-action-container') do
-        click_link 'Create new collection with selected files'
-      end
-
-      # now in the newly created collection page
-      assert(page.has_text?('Copy to project'), "Copy to project text not found in new collection page")
-      assert(page.has_no_text?(my_collection['name']), "Collection page did not include foo file")
-      assert(page.has_text?('foo'), "Collection page did not include foo file")
-      if expect_collection_in_aproject
-        aproject = api_fixture('groups')['aproject']
-        assert page.has_text?("Created new collection in the project #{aproject['name']}"),
-                              'Not found flash message that new collection is created in aproject'
-      else
-        assert page.has_text?("Created new collection in your Home project"),
-                              'Not found flash message that new collection is created in Home project'
-      end
-    end
-  end
-
-  test "combine selected collection files from collection subdirectory" do
-    visit page_with_token('user1_with_load', "/collections/zzzzz-4zz18-filesinsubdir00")
-
-    # now in collection page
-    input_files = page.all('input[type=checkbox]')
-    (0..input_files.count-1).each do |i|
-      input_files[i].click
-    end
-
-    click_button 'Selection...'
-    within('.selection-action-container') do
-      click_link 'Create new collection with selected files'
-    end
-
-    # now in the newly created collection page
-    assert(page.has_text?('file_in_subdir1'), 'file not found - file_in_subdir1')
-    assert(page.has_text?('file1_in_subdir3.txt'), 'file not found - file1_in_subdir3.txt')
-    assert(page.has_text?('file2_in_subdir3.txt'), 'file not found - file2_in_subdir3.txt')
-    assert(page.has_text?('file1_in_subdir4.txt'), 'file not found - file1_in_subdir4.txt')
-    assert(page.has_text?('file2_in_subdir4.txt'), 'file not found - file1_in_subdir4.txt')
-  end
-
-  test "Collection portable data hash with multiple matches with more than one page of results" do
-    pdh = api_fixture('collections')['baz_file']['portable_data_hash']
-    visit page_with_token('admin', "/collections/#{pdh}")
-
-    assert_selector 'a', text: 'Collection_1'
-
-    assert_text 'The following collections have this content:'
-    assert_text 'more results are not shown'
-    assert_no_text 'Activity'
-    assert_no_text 'Sharing and permissions'
-  end
-
-  test "Filtering collection files by regexp" do
-    col = api_fixture('collections', 'multilevel_collection_1')
-    visit page_with_token('active', "/collections/#{col['uuid']}")
-
-    # Filter file list to some but not all files in the collection
-    page.find_field('file_regex').set('file[12]')
-    assert page.has_text?("file1")
-    assert page.has_text?("file2")
-    assert page.has_no_text?("file3")
-
-    # Filter file list with a regex matching all files
-    page.find_field('file_regex').set('.*')
-    assert page.has_text?("file1")
-    assert page.has_text?("file2")
-    assert page.has_text?("file3")
-
-    # Filter file list to a regex matching no files
-    page.find_field('file_regex').set('file9')
-    assert page.has_no_text?("file1")
-    assert page.has_no_text?("file2")
-    assert page.has_no_text?("file3")
-    # make sure that we actually are looking at the collections
-    # page and not e.g. a fiddlesticks
-    assert page.has_text?("multilevel_collection_1")
-    assert page.has_text?(col["name"] || col["uuid"])
-
-    # Set filename filter to a syntactically invalid regex
-    # Page loads, but stops filtering after the last valid regex parse
-    page.find_field('file_regex').set('file[2')
-    assert page.has_text?("multilevel_collection_1")
-    assert page.has_text?(col["name"] || col["uuid"])
-    assert page.has_text?("file1")
-    assert page.has_text?("file2")
-    assert page.has_text?("file3")
-
-    # Test the "Select all" button
-
-    # Note: calling .set('') on a Selenium element is not sufficient
-    # to reset the field for this test, as it does not send any key
-    # events to the browser. To clear the field, we must instead send
-    # a backspace character.
-    # See https://selenium.googlecode.com/svn/trunk/docs/api/rb/Selenium/WebDriver/Element.html#clear-instance_method
-    page.find_field('file_regex').set("\b") # backspace
-    find('button#select-all').click
-    assert_checkboxes_state('input[type=checkbox]', true, '"select all" should check all checkboxes')
-
-    # Test the "Unselect all" button
-    page.find_field('file_regex').set("\b") # backspace
-    find('button#unselect-all').click
-    assert_checkboxes_state('input[type=checkbox]', false, '"unselect all" should clear all checkboxes')
-
-    # Filter files, then "select all", then unfilter
-    page.find_field('file_regex').set("\b") # backspace
-    find('button#unselect-all').click
-    page.find_field('file_regex').set('file[12]')
-    find('button#select-all').click
-    page.find_field('file_regex').set("\b") # backspace
-
-    # all "file1" and "file2" checkboxes must be selected
-    # all "file3" checkboxes must be clear
-    assert_checkboxes_state('[value*="file1"]', true, 'checkboxes for file1 should be selected after filtering')
-    assert_checkboxes_state('[value*="file2"]', true, 'checkboxes for file2 should be selected after filtering')
-    assert_checkboxes_state('[value*="file3"]', false, 'checkboxes for file3 should be clear after filtering')
-
-    # Select all files, then filter, then "unselect all", then unfilter
-    page.find_field('file_regex').set("\b") # backspace
-    find('button#select-all').click
-    page.find_field('file_regex').set('file[12]')
-    find('button#unselect-all').click
-    page.find_field('file_regex').set("\b") # backspace
-
-    # all "file1" and "file2" checkboxes must be clear
-    # all "file3" checkboxes must be selected
-    assert_checkboxes_state('[value*="file1"]', false, 'checkboxes for file1 should be clear after filtering')
-    assert_checkboxes_state('[value*="file2"]', false, 'checkboxes for file2 should be clear after filtering')
-    assert_checkboxes_state('[value*="file3"]', true, 'checkboxes for file3 should be selected after filtering')
-  end
-
-  test "Creating collection from list of filtered files" do
-    col = api_fixture('collections', 'collection_with_files_in_subdir')
-    visit page_with_token('user1_with_load', "/collections/#{col['uuid']}")
-    assert page.has_text?('file_in_subdir1'), 'expected file_in_subdir1 not found'
-    assert page.has_text?('file1_in_subdir3'), 'expected file1_in_subdir3 not found'
-    assert page.has_text?('file2_in_subdir3'), 'expected file2_in_subdir3 not found'
-    assert page.has_text?('file1_in_subdir4'), 'expected file1_in_subdir4 not found'
-    assert page.has_text?('file2_in_subdir4'), 'expected file2_in_subdir4 not found'
-
-    # Select all files but then filter them to files in subdir1, subdir2 or subdir3
-    find('button#select-all').click
-    page.find_field('file_regex').set('_in_subdir[123]')
-    assert page.has_text?('file_in_subdir1'), 'expected file_in_subdir1 not in filtered files'
-    assert page.has_text?('file1_in_subdir3'), 'expected file1_in_subdir3 not in filtered files'
-    assert page.has_text?('file2_in_subdir3'), 'expected file2_in_subdir3 not in filtered files'
-    assert page.has_no_text?('file1_in_subdir4'), 'file1_in_subdir4 found in filtered files'
-    assert page.has_no_text?('file2_in_subdir4'), 'file2_in_subdir4 found in filtered files'
-
-    # Create a new collection
-    click_button 'Selection...'
-    within('.selection-action-container') do
-      click_link 'Create new collection with selected files'
-    end
-
-    # now in the newly created collection page
-    # must have files in subdir1 and subdir3 but not subdir4
-    assert page.has_text?('file_in_subdir1'), 'file_in_subdir1 missing from new collection'
-    assert page.has_text?('file1_in_subdir3'), 'file1_in_subdir3 missing from new collection'
-    assert page.has_text?('file2_in_subdir3'), 'file2_in_subdir3 missing from new collection'
-    assert page.has_no_text?('file1_in_subdir4'), 'file1_in_subdir4 found in new collection'
-    assert page.has_no_text?('file2_in_subdir4'), 'file2_in_subdir4 found in new collection'
-
-    # Make sure we're not still on the old collection page.
-    refute_match(%r{/collections/#{col['uuid']}}, page.current_url)
-  end
-
-  test "remove a file from collection using checkbox and dropdown option" do
-    need_selenium 'to confirm unlock'
-
-    visit page_with_token('active', '/collections/zzzzz-4zz18-a21ux3541sxa8sf')
-    assert(page.has_text?('file1'), 'file not found - file1')
-
-    unlock_collection
-
-    # remove first file
-    input_files = page.all('input[type=checkbox]')
-    input_files[0].click
-
-    click_button 'Selection...'
-    within('.selection-action-container') do
-      click_link 'Remove selected files'
-    end
-
-    assert(page.has_no_text?('file1'), 'file found - file')
-    assert(page.has_text?('file2'), 'file not found - file2')
-  end
-
-  test "remove a file in collection using trash icon" do
-    need_selenium 'to confirm unlock'
-
-    visit page_with_token('active', '/collections/zzzzz-4zz18-a21ux3541sxa8sf')
-    assert(page.has_text?('file1'), 'file not found - file1')
-
-    unlock_collection
-
-    first('.fa-trash-o').click
-    accept_alert
-
-    assert(page.has_no_text?('file1'), 'file found - file')
-    assert(page.has_text?('file2'), 'file not found - file2')
-  end
-
-  test "rename a file in collection" do
-    need_selenium 'to confirm unlock'
-
-    visit page_with_token('active', '/collections/zzzzz-4zz18-a21ux3541sxa8sf')
-
-    unlock_collection
-
-    within('.collection_files') do
-      first('.fa-pencil').click
-      find('.editable-input input').set('file1renamed')
-      find('.editable-submit').click
-    end
-
-    assert(page.has_text?('file1renamed'), 'file not found - file1renamed')
-  end
-
-  test "remove/rename file options not presented if user cannot update a collection" do
-    # visit a publicly accessible collection as 'spectator'
-    visit page_with_token('spectator', '/collections/zzzzz-4zz18-uukreo9rbgwsujr')
-
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li', text: 'Create new collection with selected files'
-      assert_no_selector 'li', text: 'Remove selected files'
-    end
-
-    within('.collection_files') do
-      assert(page.has_text?('GNU_General_Public_License'), 'file not found - GNU_General_Public_License')
-      assert_nil first('.fa-pencil')
-      assert_nil first('.fa-trash-o')
-    end
-  end
-
-  test "unlock collection to modify files" do
-    need_selenium 'to confirm remove'
-
-    collection = api_fixture('collections')['collection_owned_by_active']
-
-    # On load, collection is locked, and upload tab, rename and remove options are disabled
-    visit page_with_token('active', "/collections/#{collection['uuid']}")
-
-    assert_selector 'a[data-toggle="disabled"]', text: 'Upload'
-
-    within('.collection_files') do
-      file_ctrls = page.all('.btn-collection-file-control')
-      assert_equal 2, file_ctrls.size
-      assert_equal true, file_ctrls[0]['class'].include?('disabled')
-      assert_equal true, file_ctrls[1]['class'].include?('disabled')
-      find('input[type=checkbox]').click
-    end
-
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Remove selected files'
-      assert_selector 'li', text: 'Create new collection with selected files'
-    end
-
-    unlock_collection
-
-    assert_no_selector 'a[data-toggle="disabled"]', text: 'Upload'
-    assert_selector 'a', text: 'Upload'
-
-    within('.collection_files') do
-      file_ctrls = page.all('.btn-collection-file-control')
-      assert_equal 2, file_ctrls.size
-      assert_equal false, file_ctrls[0]['class'].include?('disabled')
-      assert_equal false, file_ctrls[1]['class'].include?('disabled')
-
-      # previous checkbox selection won't result in firing a new event;
-      # undo and redo checkbox to fire the selection event again
-      find('input[type=checkbox]').click
-      find('input[type=checkbox]').click
-    end
-
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_no_selector 'li.disabled', text: 'Remove selected files'
-      assert_selector 'li', text: 'Remove selected files'
-    end
-  end
-
-  def unlock_collection
-    first('.lock-collection-btn').click
-    accept_alert
-  end
-end
diff --git a/apps/workbench/test/integration/container_requests_test.rb b/apps/workbench/test/integration/container_requests_test.rb
deleted file mode 100644 (file)
index 151654b..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class ContainerRequestsTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  [
-    ['ex_string', 'abc'],
-    ['ex_string_opt', 'abc'],
-    ['ex_int', 12],
-    ['ex_int_opt', 12],
-    ['ex_long', 12],
-    ['ex_double', '12.34', 12.34],
-    ['ex_float', '12.34', 12.34],
-  ].each do |input_id, input_value, expected_value|
-    test "set input #{input_id} with #{input_value}" do
-      request_uuid = api_fixture("container_requests", "uncommitted", "uuid")
-      visit page_with_token("active", "/container_requests/#{request_uuid}")
-      selector = ".editable[data-name='[mounts][/var/lib/cwl/cwl.input.json][content][#{input_id}]']"
-      find(selector).click
-      find(".editable-input input").set(input_value)
-      find("#editable-submit").click
-      assert_no_selector(".editable-popup")
-      assert_selector(selector, text: expected_value || input_value)
-    end
-  end
-
-  test "select value for boolean input" do
-    request_uuid = api_fixture("container_requests", "uncommitted", "uuid")
-    visit page_with_token("active", "/container_requests/#{request_uuid}")
-    selector = ".editable[data-name='[mounts][/var/lib/cwl/cwl.input.json][content][ex_boolean]']"
-    find(selector).click
-    within(".editable-input") do
-      select "true"
-    end
-    find("#editable-submit").click
-    assert_no_selector(".editable-popup")
-    assert_selector(selector, text: "true")
-  end
-
-  test "select value for enum typed input" do
-    request_uuid = api_fixture("container_requests", "uncommitted", "uuid")
-    visit page_with_token("active", "/container_requests/#{request_uuid}")
-    selector = ".editable[data-name='[mounts][/var/lib/cwl/cwl.input.json][content][ex_enum]']"
-    find(selector).click
-    within(".editable-input") do
-      select "b"    # second value
-    end
-    find("#editable-submit").click
-    assert_no_selector(".editable-popup")
-    assert_selector(selector, text: "b")
-  end
-
-  [
-    ['directory_type'],
-    ['file_type'],
-  ].each do |type|
-    test "select value for #{type} input" do
-      request_uuid = api_fixture("container_requests", "uncommitted-with-directory-input", "uuid")
-      visit page_with_token("active", "/container_requests/#{request_uuid}")
-      assert_text 'Provide a value for the following parameter'
-      click_link 'Choose'
-      within('.modal-dialog') do
-        wait_for_ajax
-        collection = api_fixture('collections', 'collection_with_one_property', 'uuid')
-        find("div[data-object-uuid=#{collection}]").click
-        if type == 'ex_file'
-          wait_for_ajax
-          find('.preview-selectable', text: 'bar').click
-        end
-        find('button', text: 'OK').click
-      end
-      page.assert_no_selector 'a.disabled,button.disabled', text: 'Run'
-      assert_text 'This workflow does not need any further inputs'
-      click_link "Run"
-      wait_for_ajax
-      assert_text 'This container is queued'
-    end
-  end
-
-  test "Run button enabled once all required inputs are provided" do
-    request_uuid = api_fixture("container_requests", "uncommitted-with-required-and-optional-inputs", "uuid")
-    visit page_with_token("active", "/container_requests/#{request_uuid}")
-    assert_text 'Provide a value for the following parameter'
-
-    page.assert_selector 'a.disabled,button.disabled', text: 'Run'
-
-    selector = ".editable[data-name='[mounts][/var/lib/cwl/cwl.input.json][content][int_required]']"
-    find(selector).click
-    find(".editable-input input").set(2016)
-    find("#editable-submit").click
-
-    page.assert_no_selector 'a.disabled,button.disabled', text: 'Run'
-    click_link "Run"
-    wait_for_ajax
-    assert_text 'This container is queued'
-  end
-
-  test "Run button enabled when workflow is empty and no inputs are needed" do
-    visit page_with_token("active")
-
-    find('.btn', text: 'Run a process').click
-    within('.modal-dialog') do
-      find('.selectable', text: 'Valid workflow with no definition yaml').click
-      find('.btn', text: 'Next: choose inputs').click
-    end
-
-    assert_text 'This workflow does not need any further inputs'
-    page.assert_selector 'a', text: 'Run'
-  end
-
-  test "Provenance graph shown on committed container requests" do
-    cr = api_fixture('container_requests', 'completed')
-    visit page_with_token("active", "/container_requests/#{cr['uuid']}")
-    assert page.has_text? 'Provenance'
-    click_link 'Provenance'
-    wait_for_ajax
-    # Check for provenance graph existance
-    page.assert_selector '#provenance_svg'
-    page.assert_selector 'ellipse+text', text: cr['name'], visible: false
-    page.assert_selector 'g.node>title', text: cr['uuid'], visible: false
-  end
-
-  test "index page" do
-    visit page_with_token("active", "/container_requests")
-
-    within(".arv-recent-container-requests") do
-      page.execute_script "window.scrollBy(0,999000)"
-      wait_for_ajax
-    end
-
-    running_owner_active = api_fixture("container_requests", "requester_for_running")
-    anon_accessible_cr = api_fixture("container_requests", "running_anonymous_accessible")
-
-    # both of these CRs should be accessible to the user
-    assert_selector "a[href=\"/container_requests/#{running_owner_active['uuid']}\"]", text: running_owner_active[:name]
-    assert_selector "a[href=\"/container_requests/#{anon_accessible_cr['uuid']}\"]", text: anon_accessible_cr[:name]
-
-    # user can delete the "running" container_request
-    within(".cr-#{running_owner_active['uuid']}") do
-      assert_not_nil first('.glyphicon-trash')
-    end
-
-    # user can not delete the anonymously accessible container_request
-    within(".cr-#{anon_accessible_cr['uuid']}") do
-      assert_nil first('.glyphicon-trash')
-    end
-
-    # verify the search box in the page
-    find('.recent-container-requests-filterable-control').set("anonymous")
-    sleep 0.350 # Wait for 250ms debounce timer (see filterable.js)
-    wait_for_ajax
-    assert_no_selector "a[href=\"/container_requests/#{running_owner_active['uuid']}\"]", text: running_owner_active[:name]
-    assert_selector "a[href=\"/container_requests/#{anon_accessible_cr['uuid']}\"]", text: anon_accessible_cr[:name]
-  end
-end
diff --git a/apps/workbench/test/integration/download_test.rb b/apps/workbench/test/integration/download_test.rb
deleted file mode 100644 (file)
index 6ae9f29..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-require 'helpers/download_helper'
-
-class DownloadTest < ActionDispatch::IntegrationTest
-  @@wrote_test_data = false
-
-  setup do
-    # Make sure Capybara can download files.
-    need_selenium 'for downloading', :selenium_with_download
-    DownloadHelper.clear
-
-    # Keep data isn't populated by fixtures, so we have to write any
-    # data we expect to read.
-    if !@@wrote_test_data
-      ['foo', 'w a z', "Hello world\n"].each do |data|
-        md5 = `echo -n #{data.shellescape} | arv-put --no-progress --raw -`
-        assert_match /^#{Digest::MD5.hexdigest(data)}/, md5
-        assert $?.success?, $?
-      end
-      @@wrote_test_data = true
-    end
-  end
-
-  ['uuid', 'portable_data_hash'].each do |id_type|
-    test "preview from keep-web by #{id_type} using a reader token" do
-      uuid_or_pdh = api_fixture('collections')['foo_file'][id_type]
-      token = api_fixture('api_client_authorizations')['active_all_collections']['api_token']
-      visit "/collections/download/#{uuid_or_pdh}/#{token}/"
-      within 'ul' do
-        click_link 'foo'
-      end
-      assert_no_selector 'a'
-      assert_text 'foo'
-    end
-
-    test "preview anonymous content from keep-web by #{id_type}" do
-      Rails.configuration.Users.AnonymousUserToken =
-        api_fixture('api_client_authorizations')['anonymous']['api_token']
-      uuid_or_pdh =
-        api_fixture('collections')['public_text_file'][id_type]
-      visit "/collections/#{uuid_or_pdh}"
-      within "#collection_files" do
-        find('[title~=View]').click
-      end
-      assert_no_selector 'a'
-      assert_text 'Hello world'
-    end
-
-    test "download anonymous content from keep-web by #{id_type}" do
-      Rails.configuration.Users.AnonymousUserToken =
-        api_fixture('api_client_authorizations')['anonymous']['api_token']
-      uuid_or_pdh =
-        api_fixture('collections')['public_text_file'][id_type]
-      visit "/collections/#{uuid_or_pdh}"
-      within "#collection_files" do
-        find('[title~=Download]').click
-      end
-      wait_for_download 'Hello world.txt', "Hello world\n"
-    end
-  end
-
-  test "download from keep-web using a session token" do
-    uuid = api_fixture('collections')['w_a_z_file']['uuid']
-    token = api_fixture('api_client_authorizations')['active']['api_token']
-    visit page_with_token('active', "/collections/#{uuid}")
-    within "#collection_files" do
-      find('[title~=Download]').click
-    end
-    wait_for_download 'w a z', 'w a z', timeout: 20
-  end
-
-  def wait_for_download filename, expect_data, timeout: 3
-    data = nil
-    tries = 0
-    while tries < timeout*10 && data != expect_data
-      sleep 0.1
-      tries += 1
-      data = File.read(DownloadHelper.path.join filename) rescue nil
-    end
-    assert_equal expect_data, data
-  end
-
-  # TODO(TC): test "view pages hosted by keep-web, using session
-  # token". We might persuade selenium to send
-  # "collection-uuid.dl.example" requests to localhost by configuring
-  # our test nginx server to work as its forward proxy. Until then,
-  # we're relying on the "Redirect to keep_web_url via #{id_type}"
-  # test in CollectionsControllerTest (and keep-web's tests).
-end
diff --git a/apps/workbench/test/integration/errors_test.rb b/apps/workbench/test/integration/errors_test.rb
deleted file mode 100644 (file)
index 86d5902..0000000
+++ /dev/null
@@ -1,128 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class ErrorsTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  BAD_UUID = "ffffffffffffffffffffffffffffffff+0"
-
-  test "error page renders user navigation" do
-    visit(page_with_token("active", "/collections/#{BAD_UUID}"))
-    assert(page.has_link?("notifications-menu"),
-           "User information missing from error page")
-    assert(page.has_no_text?(/log ?in/i),
-           "Logged in user prompted to log in on error page")
-  end
-
-  test "no user navigation with expired token" do
-    visit(page_with_token("expired", "/collections/#{BAD_UUID}"))
-    assert(page.has_no_link?("notifications-menu"),
-           "Page visited with expired token included user information")
-    assert(page.has_selector?("a", text: /log ?in/i),
-           "Login prompt missing on expired token error page")
-  end
-
-  test "error page renders without login" do
-    visit "/collections/download/#{BAD_UUID}/#{@@API_AUTHS['active']['api_token']}"
-    assert(page.has_no_text?(/\b500\b/),
-           "Error page without login returned 500")
-  end
-
-  test "'object not found' page includes search link" do
-    visit(page_with_token("active", "/collections/#{BAD_UUID}"))
-    assert(all("a").any? { |a| a[:href] =~ %r{/collections/?(\?|$)} },
-           "no search link found on 404 page")
-  end
-
-  def now_timestamp
-    Time.now.utc.to_i
-  end
-
-  def page_has_error_token?(start_stamp)
-    matching_stamps = (start_stamp .. now_timestamp).to_a.join("|")
-    # Check the page HTML because we really don't care how it's presented.
-    # I think it would even be reasonable to put it in a comment.
-    page.html =~ /\b(#{matching_stamps})\+[0-9A-Fa-f]{8}\b/
-  end
-
-  test "showing a bad UUID returns 404" do
-    visit(page_with_token("active", "/pipeline_templates/zzz"))
-    assert(page.has_no_text?(/fiddlesticks/i),
-           "trying to show a bad UUID rendered a fiddlesticks page, not 404")
-  end
-
-  test "404 page includes information about missing object" do
-    visit(page_with_token("active", "/groups/zazazaz"))
-    assert(page.has_text?(/group with UUID zazazaz/i),
-           "name of searched group missing from 404 page")
-  end
-
-  test "unrouted 404 page works" do
-    visit(page_with_token("active", "/__asdf/ghjk/zxcv"))
-    assert(page.has_text?(/not found/i),
-           "unrouted page missing 404 text")
-    assert(page.has_no_text?(/fiddlesticks/i),
-           "unrouted request returned a generic error page, not 404")
-  end
-
-  test "API error page has Report problem button" do
-    # point to a bad api server url to generate fiddlesticks error
-    original_arvados_v1_base = Rails.configuration.Services.Controller.ExternalURL
-    Rails.configuration.Services.Controller.ExternalURL = URI("https://[::1]:1/")
-
-    visit page_with_token("active")
-
-    assert_text 'fiddlesticks'
-
-    # reset api server base config to let the popup rendering to work
-    Rails.configuration.Services.Controller.ExternalURL = original_arvados_v1_base
-
-    click_link 'Report problem'
-
-    within '.modal-content' do
-      assert_text 'Report a problem'
-      assert_no_text 'Version / debugging info'
-      assert_text 'Describe the problem'
-      assert_text 'Send problem report'
-      # "Send" button should be disabled until text is entered
-      assert_no_selector 'a,button:not([disabled])', text: 'Send problem report'
-      assert_selector 'a,button', text: 'Cancel'
-
-      report = mock
-      report.expects(:deliver).returns true
-      IssueReporter.expects(:send_report).returns report
-
-      # enter a report text and click on report
-      find_field('report_issue_text').set 'my test report text'
-      click_button 'Send problem report'
-
-      # ajax success updated button texts and added footer message
-      assert_no_selector 'a,button', text: 'Send problem report'
-      assert_no_selector 'a,button', text: 'Cancel'
-      assert_text 'Report sent'
-      assert_text 'Thanks for reporting this issue'
-      click_button 'Close'
-    end
-
-    # out of the popup now and should be back in the error page
-    assert_text 'fiddlesticks'
-  end
-
-  test "showing a trashed collection UUID gives untrash button" do
-    visit(page_with_token("active", "/collections/zzzzz-4zz18-trashedproj2col"))
-    assert(page.has_text?(/You must untrash the owner project to access this/i),
-           "missing untrash instructions")
-  end
-
-  test "showing a trashed container request gives untrash button" do
-    visit(page_with_token("active", "/container_requests/zzzzz-xvhdp-cr5trashedcontr"))
-    assert(page.has_text?(/You must untrash the owner project to access this/i),
-           "missing untrash instructions")
-  end
-
-end
diff --git a/apps/workbench/test/integration/filterable_infinite_scroll_test.rb b/apps/workbench/test/integration/filterable_infinite_scroll_test.rb
deleted file mode 100644 (file)
index ed23d30..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class FilterableInfiniteScrollTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  # Chrome remembers what you had in the text field when you hit
-  # "back". Here, we simulate the same effect by sending an otherwise
-  # unused ?search=foo param to pre-populate the search field.
-  test 'no double-load if text input has a value at page load time' do
-    visit page_with_token('admin', '/pipeline_instances')
-    assert_text 'pipeline_with_job'
-    visit page_with_token('admin', '/pipeline_instances?search=pipeline_with_tagged')
-    # Horrible hack to ensure the search results can't load correctly
-    # on the second attempt.
-    assert_selector '#recent-pipeline-instances'
-    assert page.evaluate_script('$("#recent-pipeline-instances[data-infinite-content-href0]").attr("data-infinite-content-href0","/give-me-an-error").length == 1')
-    # Wait for the first page of results to appear.
-    assert_text 'pipeline_with_tagged_collection_input'
-    # Make sure the results are filtered.
-    assert_no_text 'pipeline_with_job'
-    # Make sure pipeline_with_job didn't disappear merely because
-    # the results were replaced with an error message.
-    assert_text 'pipeline_with_tagged_collection_input'
-  end
-end
diff --git a/apps/workbench/test/integration/integration_test_utils.rb b/apps/workbench/test/integration/integration_test_utils.rb
deleted file mode 100644 (file)
index 336843c..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# This file is used to define methods reusable by two or more integration tests
-#
-
-# check_checkboxes_state asserts that the page holds at least one
-# checkbox matching 'selector', and that all matching checkboxes
-# are in state 'checkbox_status' (i.e. checked if true, unchecked otherwise)
-def assert_checkboxes_state(selector, checkbox_status, msg=nil)
-  assert page.has_selector?(selector)
-  page.all(selector).each do |checkbox|
-    assert(checkbox.checked? == checkbox_status, msg)
-  end
-end
diff --git a/apps/workbench/test/integration/jobs_test.rb b/apps/workbench/test/integration/jobs_test.rb
deleted file mode 100644 (file)
index 7b510f2..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'fileutils'
-require 'tmpdir'
-
-require 'integration_helper'
-
-class JobsTest < ActionDispatch::IntegrationTest
-  setup do
-      need_javascript
-  end
-
-  def fakepipe_with_log_data
-    content =
-      "2014-01-01_12:00:01 zzzzz-8i9sb-0vsrcqi7whchuil 0  log message 1\n" +
-      "2014-01-01_12:00:02 zzzzz-8i9sb-0vsrcqi7whchuil 0  log message 2\n" +
-      "2014-01-01_12:00:03 zzzzz-8i9sb-0vsrcqi7whchuil 0  log message 3\n"
-    StringIO.new content, 'r'
-  end
-
-  [
-    ['active', true],
-    ['job_reader2', false],
-  ].each do |user, readable|
-    test "view job with components as #{user} user" do
-      Rails.configuration.Users.AnonymousUserToken = ""
-      job = api_fixture('jobs')['running_job_with_components']
-      component1 = api_fixture('jobs')['completed_job_in_publicly_accessible_project']
-      component2 = api_fixture('pipeline_instances')['running_pipeline_with_complete_job']
-      component2_child1 = api_fixture('jobs')['previous_job_run']
-      component2_child2 = api_fixture('jobs')['running']
-
-      visit page_with_token(user, "/jobs/#{job['uuid']}")
-      assert page.has_text? job['script_version']
-      assert page.has_no_text? 'script_parameters'
-
-      # The job_reader2 is allowed to read job, component2, and component2_child1,
-      # and component2_child2 only as a component of the pipeline component2
-      if readable
-        assert page.has_link? 'component1'
-        assert page.has_link? 'component2'
-      else
-        assert page.has_no_link? 'component1'
-        assert page.has_link? 'component2'
-      end
-
-      if readable
-        click_link('component1')
-        within('.panel-collapse') do
-          assert(has_text? component1['uuid'])
-          assert(has_text? component1['script_version'])
-          assert(has_text? 'script_parameters')
-        end
-        click_link('component1')
-      end
-
-      click_link('component2')
-      within('.panel-collapse') do
-        assert(has_text? component2['uuid'])
-        assert(has_text? component2['script_version'])
-        assert(has_no_text? 'script_parameters')
-        assert(has_link? 'previous')
-        assert(has_link? 'running')
-
-        click_link('previous')
-        within('.panel-collapse') do
-          assert(has_text? component2_child1['uuid'])
-          assert(has_text? component2_child1['script_version'])
-        end
-        click_link('previous')
-
-        click_link('running')
-        within('.panel-collapse') do
-          assert(has_text? component2_child2['uuid'])
-          if readable
-            assert(has_text? component2_child2['script_version'])
-          else
-            assert(has_no_text? component2_child2['script_version'])
-          end
-        end
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/integration/link_account_test.rb b/apps/workbench/test/integration/link_account_test.rb
deleted file mode 100644 (file)
index 53c7ec8..0000000
+++ /dev/null
@@ -1,170 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-require 'webrick'
-
-class LinkAccountTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  teardown do
-    Rails.configuration.testing_override_login_url = false
-  end
-
-  def start_sso_stub token
-    port = available_port('sso_stub')
-
-    s = WEBrick::HTTPServer.new(
-      :Port => port,
-      :BindAddress => 'localhost',
-      :Logger => WEBrick::Log.new('/dev/null', WEBrick::BasicLog::DEBUG),
-      :AccessLog => [nil,nil]
-    )
-
-    s.mount_proc("/login"){|req, res|
-      res.set_redirect(WEBrick::HTTPStatus::TemporaryRedirect, req.query["return_to"] + "&api_token=#{token}")
-      s.shutdown
-    }
-
-    s.mount_proc("/logout"){|req, res|
-      res.set_redirect(WEBrick::HTTPStatus::TemporaryRedirect, req.query["return_to"])
-    }
-
-    Thread.new do
-      s.start
-    end
-
-    "http://localhost:#{port}/"
-  end
-
-  test "Add another login to this account" do
-    visit page_with_token('active_trustedclient')
-    Rails.configuration.testing_override_login_url = start_sso_stub(api_fixture('api_client_authorizations')['project_viewer_trustedclient']['api_token'])
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-
-    find("a", text: "Link account").click
-    find("button", text: "Add another login to this account").click
-
-    find("#notifications-menu").click
-    assert_text "project-viewer@arvados.local"
-
-    find("button", text: "Link accounts").click
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-  end
-
-  test "Use this login to access another account" do
-    visit page_with_token('project_viewer_trustedclient')
-    Rails.configuration.testing_override_login_url = start_sso_stub(api_fixture('api_client_authorizations')['active_trustedclient']['api_token'])
-
-    find("#notifications-menu").click
-    assert_text "project-viewer@arvados.local"
-
-    find("a", text: "Link account").click
-    find("button", text: "Use this login to access another account").click
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-
-    find("button", text: "Link accounts").click
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-  end
-
-  test "Link login of inactive user to this account" do
-    visit page_with_token('active_trustedclient')
-    Rails.configuration.testing_override_login_url = start_sso_stub(api_fixture('api_client_authorizations')['inactive_uninvited_trustedclient']['api_token'])
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-
-    find("a", text: "Link account").click
-    find("button", text: "Add another login to this account").click
-
-    find("#notifications-menu").click
-    assert_text "inactive-uninvited-user@arvados.local"
-
-    find("button", text: "Link accounts").click
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-  end
-
-  test "Cannot link to inactive user" do
-    visit page_with_token('active_trustedclient')
-    Rails.configuration.testing_override_login_url = start_sso_stub(api_fixture('api_client_authorizations')['inactive_uninvited_trustedclient']['api_token'])
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-
-    find("a", text: "Link account").click
-    find("button", text: "Use this login to access another account").click
-
-    find("#notifications-menu").click
-    assert_text "inactive-uninvited-user@arvados.local"
-
-    assert_text "Cannot link active-user@arvados.local"
-
-    assert find("#link-account-submit")['disabled']
-
-    find("button", text: "Cancel").click
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-  end
-
-  test "Inactive user can link to active account" do
-    visit page_with_token('inactive_uninvited_trustedclient')
-    Rails.configuration.testing_override_login_url = start_sso_stub(api_fixture('api_client_authorizations')['active_trustedclient']['api_token'])
-
-    find("#notifications-menu").click
-    assert_text "inactive-uninvited-user@arvados.local"
-
-    assert_text "Already have an account with a different login?"
-
-    find("a", text: "Link this login to your existing account").click
-
-    assert_no_text "Add another login to this account"
-
-    find("button", text: "Use this login to access another account").click
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-
-    find("button", text: "Link accounts").click
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-  end
-
-  test "Admin cannot link to non-admin" do
-    visit page_with_token('admin_trustedclient')
-    Rails.configuration.testing_override_login_url = start_sso_stub(api_fixture('api_client_authorizations')['active_trustedclient']['api_token'])
-
-    find("#notifications-menu").click
-    assert_text "admin@arvados.local"
-
-    find("a", text: "Link account").click
-    find("button", text: "Use this login to access another account").click
-
-    find("#notifications-menu").click
-    assert_text "active-user@arvados.local"
-
-    assert_text "Cannot link admin account admin@arvados.local"
-
-    assert find("#link-account-submit")['disabled']
-
-    find("button", text: "Cancel").click
-
-    find("#notifications-menu").click
-    assert_text "admin@arvados.local"
-  end
-
-end
diff --git a/apps/workbench/test/integration/logins_test.rb b/apps/workbench/test/integration/logins_test.rb
deleted file mode 100644 (file)
index 3c6ab7c..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class LoginsTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  test "login with api_token works after redirect" do
-    visit page_with_token('active_trustedclient')
-    assert page.has_text?('Recent processes'), "Missing 'Recent processes' from page"
-    assert_no_match(/\bapi_token=/, current_path)
-  end
-
-  test "trying to use expired token redirects to login page" do
-    visit page_with_token('expired_trustedclient')
-    buttons = all("button.btn", text: /Log in/)
-    assert_equal(1, buttons.size, "Failed to find one login button")
-  end
-end
diff --git a/apps/workbench/test/integration/pipeline_instances_test.rb b/apps/workbench/test/integration/pipeline_instances_test.rb
deleted file mode 100644 (file)
index 732e360..0000000
+++ /dev/null
@@ -1,196 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class PipelineInstancesTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  def parse_browser_timestamp t
-    # Timestamps are displayed in the browser's time zone (which can
-    # differ from ours) and they come from toLocaleTimeString (which
-    # means they don't necessarily tell us which time zone they're
-    # using). In order to make sense of them, we need to ask the
-    # browser to parse them and generate a timestamp that can be
-    # parsed reliably.
-    #
-    # Note: Even with all this help, phantomjs seem to behave badly
-    # when parsing timestamps on the other side of a DST transition.
-    # See skipped tests below.
-
-    # In some locales (e.g., en_CA.UTF-8) Firefox can't parse what its
-    # own toLocaleString() puts out.
-    t.sub!(/(\d\d\d\d)-(\d\d)-(\d\d)/, '\2/\3/\1')
-
-    if /(\d+:\d+ [AP]M) (\d+\/\d+\/\d+)/ =~ t
-      # Currently dates.js renders timestamps as
-      # '{t.toLocaleTimeString()} {t.toLocaleDateString()}' which even
-      # en_US browsers can't make sense of. First we need to flip it
-      # around so it looks like what toLocaleString() would have made.
-      t = $~[2] + ', ' + $~[1]
-    end
-
-    utc = page.evaluate_script("new Date('#{t}').toUTCString()")
-    DateTime.parse(utc).to_time
-  end
-
-  test 'view pipeline with job and see graph' do
-    visit page_with_token('active_trustedclient', '/pipeline_instances')
-    assert page.has_text? 'pipeline_with_job'
-
-    find('a', text: 'pipeline_with_job').click
-
-    # since the pipeline component has a job, expect to see the graph
-    assert page.has_text? 'Graph'
-    click_link 'Graph'
-    page.assert_selector "#provenance_graph"
-  end
-
-  test "JSON popup available for strange components" do
-    uuid = api_fixture("pipeline_instances")["components_is_jobspec"]["uuid"]
-    visit page_with_token("active", "/pipeline_instances/#{uuid}")
-    click_on "Components"
-    assert(page.has_no_text?("script_parameters"),
-           "components JSON visible without popup")
-    click_on "Show components JSON"
-    assert(page.has_text?("script_parameters"),
-           "components JSON not found")
-  end
-
-  def create_pipeline_from(template_name, project_name="Home")
-    # Visit the named pipeline template and create a pipeline instance from it.
-    # The instance will be created under the named project.
-    template_uuid = api_fixture("pipeline_templates", template_name, "uuid")
-    visit page_with_token("active", "/pipeline_templates/#{template_uuid}")
-    click_on "Run this pipeline"
-    within(".modal-dialog") do # FIXME: source of 3 test errors
-      # Set project for the new pipeline instance
-      find(".selectable", text: project_name).click
-      click_on "Choose"
-    end
-    assert(has_text?("This pipeline was created from the template"),
-           "did not land on pipeline instance page")
-  end
-
-  [
-    ['user1_with_load', 'zzzzz-d1hrv-10pipelines0001', 0], # run time 0 minutes
-    ['user1_with_load', 'zzzzz-d1hrv-10pipelines0010', 17*60*60 + 51*60], # run time 17 hours and 51 minutes
-    ['active', 'zzzzz-d1hrv-runningpipeline', nil], # state = running
-  ].each do |user, uuid, run_time|
-    test "pipeline start and finish time display for #{uuid}" do
-      need_selenium 'to parse timestamps correctly across DST boundaries'
-      visit page_with_token(user, "/pipeline_instances/#{uuid}")
-
-      regexp = "This pipeline started at (.+?)\\. "
-      if run_time
-        regexp += "It failed after (.+?) at (.+?)\\. Check the Log"
-      else
-        regexp += "It has been active for \\d"
-      end
-      assert_match /#{regexp}/, page.text
-
-      return if !run_time
-
-      # match again to capture (.*)
-      _, started, duration, finished = *(/#{regexp}/.match(page.text))
-      assert_equal(
-        run_time,
-        parse_browser_timestamp(finished) - parse_browser_timestamp(started),
-        "expected: #{run_time}, got: started #{started}, finished #{finished}, duration #{duration}")
-    end
-  end
-
-  [
-    ['fuse', nil, 2, 20],                           # has 2 as of 11-07-2014
-    ['user1_with_load', '000025pipelines', 25, 25], # owned_by the project zzzzz-j7d0g-000025pipelines, two pages
-    ['admin', 'pipeline_20', 1, 1],
-    ['active', 'no such match', 0, 0],
-  ].each do |user, search_filter, expected_min, expected_max|
-    test "scroll pipeline instances page for #{user} with search filter #{search_filter}
-          and expect #{expected_min} <= found_items <= #{expected_max}" do
-      visit page_with_token(user, "/pipeline_instances")
-
-      if search_filter
-        find('.recent-pipeline-instances-filterable-control').set(search_filter)
-        # Wait for 250ms debounce timer (see filterable.js)
-        sleep 0.350
-        wait_for_ajax
-      end
-
-      page_scrolls = expected_max/20 + 2    # scroll num_pages+2 times to test scrolling is disabled when it should be
-      within('.arv-recent-pipeline-instances') do
-        (0..page_scrolls).each do |i|
-          page.driver.scroll_to 0, 999000
-          begin
-            wait_for_ajax
-          rescue
-          end
-        end
-      end
-
-      # Verify that expected number of pipeline instances are found
-      found_items = page.all('tr[data-kind="arvados#pipelineInstance"]')
-      found_count = found_items.count
-      if expected_min == expected_max
-        assert_equal(true, found_count == expected_min,
-          "Not found expected number of items. Expected #{expected_min} and found #{found_count}")
-        assert page.has_no_text? 'request failed'
-      else
-        assert_equal(true, found_count>=expected_min,
-          "Found too few items. Expected at least #{expected_min} and found #{found_count}")
-        assert_equal(true, found_count<=expected_max,
-          "Found too many items. Expected at most #{expected_max} and found #{found_count}")
-      end
-    end
-  end
-
-  test 'render job run time when job record is inaccessible' do
-    pi = api_fixture('pipeline_instances', 'has_component_with_completed_jobs')
-    visit page_with_token 'active', '/pipeline_instances/' + pi['uuid']
-    assert_text 'Queued for '
-  end
-
-  test "job logs linked for running pipeline" do
-    pi = api_fixture("pipeline_instances", "running_pipeline_with_complete_job")
-    visit(page_with_token("active", "/pipeline_instances/#{pi['uuid']}"))
-    find(:xpath, "//a[@href='#Log']").click
-    within "#Log" do
-      assert_text "Log for previous"
-      log_link = find("a", text: "Log for previous")
-      assert_includes(log_link[:href],
-                      "/jobs/#{pi["components"]["previous"]["job"]["uuid"]}#Log")
-      assert_selector "#event_log_div"
-    end
-  end
-
-  test "job logs linked for complete pipeline" do
-    pi = api_fixture("pipeline_instances", "complete_pipeline_with_two_jobs")
-    visit(page_with_token("active", "/pipeline_instances/#{pi['uuid']}"))
-    find(:xpath, "//a[@href='#Log']").click
-    within "#Log" do
-      assert_text "Log for previous"
-      pi["components"].each do |cname, cspec|
-        log_link = find("a", text: "Log for #{cname}")
-        assert_includes(log_link[:href], "/jobs/#{cspec["job"]["uuid"]}#Log")
-      end
-      assert_no_selector "#event_log_div"
-    end
-  end
-
-  test "job logs linked for failed pipeline" do
-    pi = api_fixture("pipeline_instances", "failed_pipeline_with_two_jobs")
-    visit(page_with_token("active", "/pipeline_instances/#{pi['uuid']}"))
-    find(:xpath, "//a[@href='#Log']").click
-    within "#Log" do
-      assert_text "Log for previous"
-      pi["components"].each do |cname, cspec|
-        log_link = find("a", text: "Log for #{cname}")
-        assert_includes(log_link[:href], "/jobs/#{cspec["job"]["uuid"]}#Log")
-      end
-      assert_no_selector "#event_log_div"
-    end
-  end
-end
diff --git a/apps/workbench/test/integration/pipeline_templates_test.rb b/apps/workbench/test/integration/pipeline_templates_test.rb
deleted file mode 100644 (file)
index 1fc4427..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class PipelineTemplatesTest < ActionDispatch::IntegrationTest
-  test "JSON popup available for strange components" do
-    need_javascript
-    uuid = api_fixture("pipeline_templates")["components_is_jobspec"]["uuid"]
-    visit page_with_token("active", "/pipeline_templates/#{uuid}")
-    click_on "Components"
-    assert(page.has_no_text?("script_parameters"),
-           "components JSON visible without popup")
-    click_on "Show components JSON"
-    assert(page.has_text?("script_parameters"),
-           "components JSON not found")
-  end
-
-end
diff --git a/apps/workbench/test/integration/projects_test.rb b/apps/workbench/test/integration/projects_test.rb
deleted file mode 100644 (file)
index 7a51030..0000000
+++ /dev/null
@@ -1,758 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-require 'helpers/share_object_helper'
-require_relative 'integration_test_utils'
-
-class ProjectsTest < ActionDispatch::IntegrationTest
-  include ShareObjectHelper
-
-  setup do
-    need_javascript
-  end
-
-  test 'Check collection count for A Project in the tab pane titles' do
-    project_uuid = api_fixture('groups')['aproject']['uuid']
-    visit page_with_token 'active', '/projects/' + project_uuid
-    click_link 'Data collections'
-    wait_for_ajax
-    collection_count = page.all("[data-pk*='collection']").count
-    assert_selector '#Data_collections-tab span', text: "(#{collection_count})"
-  end
-
-  test 'Find a project and edit its description' do
-    visit page_with_token 'active', '/'
-    find("#projects-menu").click
-    find(".dropdown-menu a", text: "A Project").click
-    within('.container-fluid', text: api_fixture('groups')['aproject']['name']) do
-      find('span', text: api_fixture('groups')['aproject']['name']).click
-      within('.arv-description-as-subtitle') do
-        find('.fa-pencil').click
-        find('.editable-input textarea').set('I just edited this.')
-        find('.editable-submit').click
-      end
-      wait_for_ajax
-    end
-    visit current_path
-    assert(find?('.container-fluid', text: 'I just edited this.'),
-           "Description update did not survive page refresh")
-  end
-
-  test 'Create a project and move it into a different project' do
-    visit page_with_token 'active', '/projects'
-    find("#projects-menu").click
-    within('.dropdown-menu') do
-      first('li', text: 'Home').click
-    end
-    wait_for_ajax
-    find('.btn', text: "Add a subproject").click
-
-    within('h2') do
-      find('.fa-pencil').click
-      find('.editable-input input').set('Project 1234')
-      find('.glyphicon-ok').click
-    end
-    wait_for_ajax
-
-    visit '/projects'
-    find("#projects-menu").click
-    within('.dropdown-menu') do
-      first('li', text: 'Home').click
-    end
-    wait_for_ajax
-    find('.btn', text: "Add a subproject").click
-    within('h2') do
-      find('.fa-pencil').click
-      find('.editable-input input').set('Project 5678')
-      find('.glyphicon-ok').click
-    end
-    wait_for_ajax
-
-    click_link 'Move project...'
-    find('.selectable', text: 'Project 1234').click
-    find('.modal-footer a,button', text: 'Move').click
-    wait_for_ajax
-
-    # Wait for the page to refresh and show the new parent in Sharing panel
-    click_link 'Sharing'
-    assert(page.has_link?("Project 1234"),
-           "Project 5678 should now be inside project 1234")
-  end
-
-  def open_groups_sharing(project_name="aproject", token_name="active")
-    project = api_fixture("groups", project_name)
-    visit(page_with_token(token_name, "/projects/#{project['uuid']}"))
-    click_on "Sharing"
-    click_on "Share with groups"
-  end
-
-  def group_name(group_key)
-    api_fixture("groups", group_key, "name")
-  end
-
-  test "projects not publicly sharable when anonymous browsing disabled" do
-    Rails.configuration.Users.AnonymousUserToken = ""
-    open_groups_sharing
-    # Check for a group we do expect first, to make sure the modal's loaded.
-    assert_selector(".modal-container .selectable",
-                    text: group_name("all_users"))
-    assert_no_selector(".modal-container .selectable",
-                       text: group_name("anonymous_group"))
-  end
-
-  test "projects publicly sharable when anonymous browsing enabled" do
-    Rails.configuration.Users.AnonymousUserToken = "testonlytoken"
-    open_groups_sharing
-    assert_selector(".modal-container .selectable",
-                    text: group_name("anonymous_group"))
-  end
-
-  test "project owner can manage sharing for another user" do
-    add_user = api_fixture('users')['future_project_user']
-    new_name = ["first_name", "last_name"].map { |k| add_user[k] }.join(" ")
-
-    show_object_using('active', 'groups', 'aproject', 'A Project')
-    click_on "Sharing"
-    add_share_and_check("users", new_name, add_user)
-    modify_share_and_check(new_name)
-  end
-
-  test "project owner can manage sharing for another group" do
-    new_name = api_fixture('groups')['future_project_viewing_group']['name']
-
-    show_object_using('active', 'groups', 'aproject', 'A Project')
-    click_on "Sharing"
-    add_share_and_check("groups", new_name)
-    modify_share_and_check(new_name)
-  end
-
-  test "'share with group' listing does not offer projects" do
-    show_object_using('active', 'groups', 'aproject', 'A Project')
-    click_on "Sharing"
-    click_on "Share with groups"
-    good_uuid = api_fixture("groups")["future_project_viewing_group"]["uuid"]
-    assert(page.has_selector?(".selectable[data-object-uuid=\"#{good_uuid}\"]"),
-           "'share with groups' listing missing owned user group")
-    bad_uuid = api_fixture("groups")["asubproject"]["uuid"]
-    assert(page.has_no_selector?(".selectable[data-object-uuid=\"#{bad_uuid}\"]"),
-           "'share with groups' listing includes project")
-  end
-
-  [
-    ['Move',api_fixture('collections')['collection_to_move_around_in_aproject'],
-      api_fixture('groups')['aproject'],api_fixture('groups')['asubproject']],
-    ['Remove',api_fixture('collections')['collection_to_move_around_in_aproject'],
-      api_fixture('groups')['aproject']],
-    ['Copy',api_fixture('collections')['collection_to_move_around_in_aproject'],
-      api_fixture('groups')['aproject'],api_fixture('groups')['asubproject']],
-    ['Remove',api_fixture('collections')['collection_in_aproject_with_same_name_as_in_home_project'],
-      api_fixture('groups')['aproject'],nil,true],
-  ].each do |action, my_collection, src, dest=nil, expect_name_change=nil|
-    test "selection #{action} -> #{expect_name_change.inspect} for project" do
-      perform_selection_action src, dest, my_collection, action
-
-      case action
-      when 'Copy'
-        assert page.has_text?(my_collection['name']), 'Collection not found in src project after copy'
-        visit page_with_token 'active', '/'
-        find("#projects-menu").click
-        find(".dropdown-menu a", text: dest['name']).click
-        click_link 'Data collections'
-        assert page.has_text?(my_collection['name']), 'Collection not found in dest project after copy'
-
-      when 'Move'
-        assert page.has_no_text?(my_collection['name']), 'Collection still found in src project after move'
-        visit page_with_token 'active', '/'
-        find("#projects-menu").click
-        find(".dropdown-menu a", text: dest['name']).click
-        click_link 'Data collections'
-        assert page.has_text?(my_collection['name']), 'Collection not found in dest project after move'
-
-      when 'Remove'
-        assert page.has_no_text?(my_collection['name']), 'Collection still found in src project after remove'
-      end
-    end
-  end
-
-  def perform_selection_action src, dest, item, action
-    visit page_with_token 'active', '/'
-    find("#projects-menu").click
-    find(".dropdown-menu a", text: src['name']).click
-    click_link 'Data collections'
-    assert page.has_text?(item['name']), 'Collection not found in src project'
-
-    within('tr', text: item['name']) do
-      find('input[type=checkbox]').click
-    end
-
-    click_button 'Selection'
-
-    within('.selection-action-container') do
-      assert page.has_text?("Compare selected"), "Compare selected link text not found"
-      assert page.has_link?("Copy selected"), "Copy selected link not found"
-      assert page.has_link?("Move selected"), "Move selected link not found"
-      assert page.has_link?("Remove selected"), "Remove selected link not found"
-
-      click_link "#{action} selected"
-    end
-
-    # select the destination project if a Copy or Move action is being performed
-    if action == 'Copy' || action == 'Move'
-      within(".modal-container") do
-        find('.selectable', text: dest['name']).click
-        find('.modal-footer a,button', text: action).click
-        wait_for_ajax
-      end
-    end
-  end
-
-  # Test copy action state. It should not be available when a subproject is selected.
-  test "copy action is disabled when a subproject is selected" do
-    my_project = api_fixture('groups')['aproject']
-    my_collection = api_fixture('collections')['collection_to_move_around_in_aproject']
-    my_subproject = api_fixture('groups')['asubproject']
-
-    # verify that selection options are disabled on the project until an item is selected
-    visit page_with_token 'active', '/'
-    find("#projects-menu").click
-    find(".dropdown-menu a", text: my_project['name']).click
-
-    click_link 'Data collections'
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li.disabled', text: 'Compare selected'
-      assert_selector 'li.disabled', text: 'Copy selected'
-      assert_selector 'li.disabled', text: 'Move selected'
-      assert_selector 'li.disabled', text: 'Remove selected'
-    end
-
-    # select collection and verify links are enabled
-    visit page_with_token 'active', '/'
-    find("#projects-menu").click
-    find(".dropdown-menu a", text: my_project['name']).click
-    click_link 'Data collections'
-    assert page.has_text?(my_collection['name']), 'Collection not found in project'
-
-    within('tr', text: my_collection['name']) do
-      find('input[type=checkbox]').click
-    end
-
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_no_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li', text: 'Create new collection with selected collections'
-      assert_selector 'li.disabled', text: 'Compare selected'
-      assert_no_selector 'li.disabled', text: 'Copy selected'
-      assert_selector 'li', text: 'Copy selected'
-      assert_no_selector 'li.disabled', text: 'Move selected'
-      assert_selector 'li', text: 'Move selected'
-      assert_no_selector 'li.disabled', text: 'Remove selected'
-      assert_selector 'li', text: 'Remove selected'
-    end
-
-    # select subproject and verify that copy action is disabled
-    visit page_with_token 'active', '/'
-    find("#projects-menu").click
-    find(".dropdown-menu a", text: my_project['name']).click
-
-    click_link 'Subprojects'
-    assert page.has_text?(my_subproject['name']), 'Subproject not found in project'
-
-    within('tr', text: my_subproject['name']) do
-      find('input[type=checkbox]').click
-    end
-
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li.disabled', text: 'Compare selected'
-      assert_selector 'li.disabled', text: 'Copy selected'
-      assert_no_selector 'li.disabled', text: 'Move selected'
-      assert_selector 'li', text: 'Move selected'
-      assert_no_selector 'li.disabled', text: 'Remove selected'
-      assert_selector 'li', text: 'Remove selected'
-    end
-
-    # select subproject and a collection and verify that copy action is still disabled
-    visit page_with_token 'active', '/'
-    find("#projects-menu").click
-    find(".dropdown-menu a", text: my_project['name']).click
-
-    click_link 'Subprojects'
-    assert page.has_text?(my_subproject['name']), 'Subproject not found in project'
-
-    within('tr', text: my_subproject['name']) do
-      find('input[type=checkbox]').click
-    end
-
-    click_link 'Data collections'
-    assert page.has_text?(my_collection['name']), 'Collection not found in project'
-
-    within('tr', text: my_collection['name']) do
-      find('input[type=checkbox]').click
-    end
-
-    click_link 'Subprojects'
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li.disabled', text: 'Compare selected'
-      assert_selector 'li.disabled', text: 'Copy selected'
-      assert_no_selector 'li.disabled', text: 'Move selected'
-      assert_selector 'li', text: 'Move selected'
-      assert_no_selector 'li.disabled', text: 'Remove selected'
-      assert_selector 'li', text: 'Remove selected'
-    end
-  end
-
-  # When project tabs are switched, only options applicable to the current tab's selections are enabled.
-  test "verify selection options when tabs are switched" do
-    my_project = api_fixture('groups')['aproject']
-    my_collection = api_fixture('collections')['collection_to_move_around_in_aproject']
-    my_subproject = api_fixture('groups')['asubproject']
-
-    # select subproject and a collection and verify that copy action is still disabled
-    visit page_with_token 'active', '/'
-    find("#projects-menu").click
-    find(".dropdown-menu a", text: my_project['name']).click
-
-    # Select a sub-project
-    click_link 'Subprojects'
-    assert page.has_text?(my_subproject['name']), 'Subproject not found in project'
-
-    within('tr', text: my_subproject['name']) do
-      find('input[type=checkbox]').click
-    end
-
-    # Select a collection
-    click_link 'Data collections'
-    assert page.has_text?(my_collection['name']), 'Collection not found in project'
-
-    within('tr', text: my_collection['name']) do
-      find('input[type=checkbox]').click
-    end
-
-    # Go back to Subprojects tab
-    click_link 'Subprojects'
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li.disabled', text: 'Compare selected'
-      assert_selector 'li.disabled', text: 'Copy selected'
-      assert_no_selector 'li.disabled', text: 'Move selected'
-      assert_selector 'li', text: 'Move selected'
-      assert_no_selector 'li.disabled', text: 'Remove selected'
-      assert_selector 'li', text: 'Remove selected'
-    end
-
-    # Close the dropdown by clicking outside it.
-    find('.dropdown-toggle', text: 'Selection').find(:xpath, '..').click
-
-    # Go back to Data collections tab
-    find('.nav-tabs a', text: 'Data collections').click
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_no_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li', text: 'Create new collection with selected collections'
-      assert_selector 'li.disabled', text: 'Compare selected'
-      assert_no_selector 'li.disabled', text: 'Copy selected'
-      assert_selector 'li', text: 'Copy selected'
-      assert_no_selector 'li.disabled', text: 'Move selected'
-      assert_selector 'li', text: 'Move selected'
-      assert_no_selector 'li.disabled', text: 'Remove selected'
-      assert_selector 'li', text: 'Remove selected'
-    end
-  end
-
-  # "Move selected" and "Remove selected" options should not be
-  # available when current user cannot write to the project
-  test "move selected and remove selected actions not available when current user cannot write to project" do
-    my_project = api_fixture('groups')['anonymously_accessible_project']
-    visit page_with_token 'active', "/projects/#{my_project['uuid']}"
-
-    click_link 'Data collections'
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li', text: 'Create new collection with selected collections'
-      assert_selector 'li', text: 'Compare selected'
-      assert_selector 'li', text: 'Copy selected'
-      assert_no_selector 'li', text: 'Move selected'
-      assert_no_selector 'li', text: 'Remove selected'
-    end
-  end
-
-  [
-    ['active', true],
-    ['project_viewer', false],
-  ].each do |user, expect_collection_in_aproject|
-    test "combine selected collections into new collection #{user} #{expect_collection_in_aproject}" do
-      my_project = api_fixture('groups')['aproject']
-      my_collection = api_fixture('collections')['collection_to_move_around_in_aproject']
-
-      visit page_with_token user, "/projects/#{my_project['uuid']}"
-      click_link 'Data collections'
-      assert page.has_text?(my_collection['name']), 'Collection not found in project'
-
-      within('tr', text: my_collection['name']) do
-        find('input[type=checkbox]').click
-      end
-
-      click_button 'Selection'
-      within('.selection-action-container') do
-        click_link 'Create new collection with selected collections'
-      end
-
-      # now in the new collection page
-      if expect_collection_in_aproject
-        assert page.has_text?("Created new collection in the project #{my_project['name']}"),
-                              'Not found flash message that new collection is created in aproject'
-      else
-        assert page.has_text?("Created new collection in your Home project"),
-                              'Not found flash message that new collection is created in Home project'
-      end
-    end
-  end
-
-  def scroll_setup(project_name,
-                   total_nbr_items,
-                   item_list_parameter,
-                   sorted = false,
-                   sort_parameters = nil)
-    project_uuid = api_fixture('groups')[project_name]['uuid']
-    visit page_with_token 'user1_with_load', '/projects/' + project_uuid
-
-    assert(page.has_text?("#{item_list_parameter.humanize} (#{total_nbr_items})"), "Number of #{item_list_parameter.humanize} did not match the input amount")
-
-    click_link item_list_parameter.humanize
-    wait_for_ajax
-
-    if sorted
-      find("th[data-sort-order='#{sort_parameters.gsub(/\s/,'')}']").click
-      wait_for_ajax
-    end
-  end
-
-  def scroll_items_check(nbr_items,
-                         fixture_prefix,
-                         item_list_parameter,
-                         item_selector,
-                         sorted = false)
-    items = []
-    for i in 1..nbr_items
-      items << "#{fixture_prefix}#{i}"
-    end
-
-    verify_items = items.dup
-    unexpected_items = []
-    item_count = 0
-    within(".arv-project-#{item_list_parameter}") do
-      page.execute_script "window.scrollBy(0,999000)"
-      begin
-        wait_for_ajax
-      rescue
-      end
-
-      # Visit all rows. If not all expected items are found, retry
-      found_items = page.all(item_selector)
-      item_count = found_items.count
-
-      previous = nil
-      (0..item_count-1).each do |i|
-        # Found row text using the fixture string e.g. "Show Collection_#{n} "
-        item_name = found_items[i].text.split[1]
-        if !items.include? item_name
-          unexpected_items << item_name
-        else
-          verify_items.delete item_name
-        end
-        if sorted
-          # check sort order
-          assert_operator( previous.downcase, :<=, item_name.downcase) if previous
-          previous = item_name
-        end
-      end
-
-      assert_equal true, unexpected_items.empty?, "Found unexpected #{item_list_parameter.humanize} #{unexpected_items.inspect}"
-      assert_equal nbr_items, item_count, "Found different number of #{item_list_parameter.humanize}"
-      assert_equal true, verify_items.empty?, "Did not find all the #{item_list_parameter.humanize}"
-    end
-  end
-
-  [
-    ['project_with_10_collections', 10],
-    ['project_with_201_collections', 201], # two pages of data
-  ].each do |project_name, nbr_items|
-    test "scroll collections tab for #{project_name} with #{nbr_items} objects" do
-      item_list_parameter = "Data_collections"
-      scroll_setup project_name,
-                   nbr_items,
-                   item_list_parameter
-      scroll_items_check nbr_items,
-                         "Collection_",
-                         item_list_parameter,
-                         'tr[data-kind="arvados#collection"]'
-    end
-  end
-
-  [
-    ['project_with_10_collections', 10],
-    ['project_with_201_collections', 201], # two pages of data
-  ].each do |project_name, nbr_items|
-    test "scroll collections tab for #{project_name} with #{nbr_items} objects with ascending sort (case insensitive)" do
-      item_list_parameter = "Data_collections"
-      scroll_setup project_name,
-                   nbr_items,
-                   item_list_parameter,
-                   true,
-                   "collections.name"
-      scroll_items_check nbr_items,
-                         "Collection_",
-                         item_list_parameter,
-                         'tr[data-kind="arvados#collection"]',
-                         true
-    end
-  end
-
-  [
-    ['project_with_10_pipelines', 10, 0],
-    ['project_with_2_pipelines_and_60_crs', 2, 60],
-    ['project_with_25_pipelines', 25, 0],
-  ].each do |project_name, num_pipelines, num_crs|
-    test "scroll pipeline instances tab for #{project_name} with #{num_pipelines} pipelines and #{num_crs} container requests" do
-      item_list_parameter = "Pipelines_and_processes"
-      scroll_setup project_name,
-                   num_pipelines + num_crs,
-                   item_list_parameter
-      # check the general scrolling and the pipelines
-      scroll_items_check num_pipelines,
-                         "pipeline_",
-                         item_list_parameter,
-                         'tr[data-kind="arvados#pipelineInstance"]'
-      # Check container request count separately
-      crs_found = page.all('tr[data-kind="arvados#containerRequest"]')
-      found_cr_count = crs_found.count
-      assert_equal num_crs, found_cr_count, 'Did not find expected number of container requests'
-    end
-  end
-
-  test "error while loading tab" do
-    original_arvados_v1_base = Rails.configuration.Services.Controller.ExternalURL
-
-    visit page_with_token 'active', '/projects/' + api_fixture('groups')['aproject']['uuid']
-
-    # Point to a bad api server url to generate error
-    Rails.configuration.Services.Controller.ExternalURL = "https://[::1]:1/"
-    click_link 'Other objects'
-    within '#Other_objects' do
-      # Error
-      assert_selector('a', text: 'Reload tab')
-
-      # Now point back to the orig api server and reload tab
-      Rails.configuration.Services.Controller.ExternalURL = original_arvados_v1_base
-      click_link 'Reload tab'
-      assert_no_selector('a', text: 'Reload tab')
-      assert_selector('button', text: 'Selection')
-      within '.selection-action-container' do
-        assert_selector 'tr[data-kind="arvados#trait"]'
-      end
-    end
-  end
-
-  test "add new project using projects dropdown" do
-    visit page_with_token 'active', '/'
-
-    # Add a new project
-    find("#projects-menu").click
-    click_link 'Add a new project'
-    assert_text 'New project'
-    assert_text 'No description provided'
-  end
-
-  test "first tab loads data when visiting other tab directly" do
-    # As of 2014-12-19, the first tab of project#show uses infinite scrolling.
-    # Make sure that it loads data even if we visit another tab directly.
-    need_selenium 'to land on specified tab using {url}#Advanced'
-    user = api_fixture("users", "active")
-    visit(page_with_token("active_trustedclient",
-                          "/projects/#{user['uuid']}#Advanced"))
-    assert_text("API response")
-    find("#page-wrapper .nav-tabs :first-child a").click
-    assert_text("Collection modified at")
-  end
-
-  # "Select all" and "Unselect all" options
-  test "select all and unselect all actions" do
-    need_selenium 'to check and uncheck checkboxes'
-
-    visit page_with_token 'active', '/projects/' + api_fixture('groups')['aproject']['uuid']
-
-    # Go to "Data collections" tab and click on "Select all"
-    click_link 'Data collections'
-    wait_for_ajax
-
-    # Initially, all selection options for this tab should be disabled
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li.disabled', text: 'Copy selected'
-    end
-
-    # Select all
-    click_button 'Select all'
-
-    assert_checkboxes_state('input[type=checkbox]', true, '"select all" should check all checkboxes')
-
-    # Now the selection options should be enabled
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li', text: 'Create new collection with selected collections'
-      assert_no_selector 'li.disabled', text: 'Copy selected'
-      assert_selector 'li', text: 'Create new collection with selected collections'
-      assert_no_selector 'li.disabled', text: 'Copy selected'
-    end
-
-    # Go to Pipelines and processes tab and assert none selected
-    click_link 'Pipelines and processes'
-    wait_for_ajax
-
-    # Since this is the first visit to this tab, all selection options should be disabled
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li.disabled', text: 'Copy selected'
-    end
-
-    assert_checkboxes_state('input[type=checkbox]', false, '"select all" should check all checkboxes')
-
-    # Select all
-    click_button 'Select all'
-    assert_checkboxes_state('input[type=checkbox]', true, '"select all" should check all checkboxes')
-
-    # Applicable selection options should be enabled
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li', text: 'Copy selected'
-      assert_no_selector 'li.disabled', text: 'Copy selected'
-    end
-
-    # Unselect all
-    click_button 'Unselect all'
-    assert_checkboxes_state('input[type=checkbox]', false, '"select all" should check all checkboxes')
-
-    # All selection options should be disabled again
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Create new collection with selected collections'
-      assert_selector 'li.disabled', text: 'Copy selected'
-    end
-
-    # Go back to Data collections tab and verify all are still selected
-    click_link 'Data collections'
-    wait_for_ajax
-
-    # Selection options should be enabled based on the fact that all collections are still selected in this tab
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li', text: 'Create new collection with selected collections'
-      assert_no_selector 'li.disabled', text: 'Copy selected'
-      assert_selector 'li', text: 'Create new collection with selected collections'
-      assert_no_selector 'li.disabled', text: 'Copy selected'
-    end
-
-    assert_checkboxes_state('input[type=checkbox]', true, '"select all" should check all checkboxes')
-
-    # Unselect all
-    find('button#unselect-all').click
-    assert_checkboxes_state('input[type=checkbox]', false, '"unselect all" should clear all checkboxes')
-
-    # Now all selection options should be disabled because none of the collections are checked
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Copy selected'
-      assert_selector 'li.disabled', text: 'Copy selected'
-    end
-
-    # Verify checking just one checkbox still works as expected
-    within('tr', text: api_fixture('collections')['collection_to_move_around_in_aproject']['name']) do
-      find('input[type=checkbox]').click
-    end
-
-    click_button 'Selection'
-    within('.selection-action-container') do
-      assert_selector 'li', text: 'Create new collection with selected collections'
-      assert_no_selector 'li.disabled', text: 'Copy selected'
-      assert_selector 'li', text: 'Create new collection with selected collections'
-      assert_no_selector 'li.disabled', text: 'Copy selected'
-    end
-  end
-
-  test "test search all projects menu item in projects menu" do
-     need_selenium
-     visit page_with_token('active')
-     find('#projects-menu').click
-     within('.dropdown-menu') do
-       assert_selector 'a', text: 'Search all projects'
-       find('a', text: 'Search all projects').click
-     end
-     within('.modal-content') do
-        assert page.has_text?('All projects'), 'No text - All projects'
-        assert page.has_text?('Search'), 'No text - Search'
-        assert page.has_text?('Cancel'), 'No text - Cancel'
-        fill_in "Search", with: 'Unrestricted public data'
-        wait_for_ajax
-        assert_selector 'div', text: 'Unrestricted public data'
-        find(:xpath, '//*[@id="choose-scroll"]/div[2]/div').click
-        click_button 'Show'
-     end
-     assert page.has_text?('Unrestricted public data'), 'No text - Unrestricted public data'
-     assert page.has_text?('An anonymously accessible project'), 'No text - An anonymously accessible project'
-  end
-
-  test "test star and unstar project" do
-    visit page_with_token 'active', "/projects/#{api_fixture('groups')['anonymously_accessible_project']['uuid']}"
-
-    # add to favorites
-    find('.fa-star-o').click
-    wait_for_ajax
-
-    find("#projects-menu").click
-    within('.dropdown-menu') do
-      assert_selector 'li', text: 'Unrestricted public data'
-    end
-
-    # remove from favotires
-    find('.fa-star').click
-    wait_for_ajax
-
-    find("#projects-menu").click
-    within('.dropdown-menu') do
-      assert_no_selector 'li', text: 'Unrestricted public data'
-    end
-  end
-
-  [
-    ['Workflow with input specifications', 'this workflow has inputs specified', 'Provide a value for the following'],
-  ].each do |template_name, preview_txt, process_txt|
-    test "run a process using template #{template_name} in a project" do
-      project = api_fixture('groups')['aproject']
-      visit page_with_token 'active', '/projects/' + project['uuid']
-
-      find('.btn', text: 'Run a process').click
-
-      # in the chooser, verify preview and click Next button
-      within('.modal-dialog') do
-        find('.selectable', text: template_name).click
-        assert_text preview_txt
-        find('.btn', text: 'Next: choose inputs').click
-      end
-
-      # in the process page now
-      assert_text process_txt
-      assert_text project['name']
-    end
-  end
-end
diff --git a/apps/workbench/test/integration/report_issue_test.rb b/apps/workbench/test/integration/report_issue_test.rb
deleted file mode 100644 (file)
index 98ce8aa..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class ReportIssueTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-    @user_profile_form_fields = Rails.configuration.Workbench.UserProfileFormFields
-  end
-
-  teardown do
-    Rails.configuration.Workbench.UserProfileFormFields = @user_profile_form_fields
-  end
-
-  # test version info and report issue from help menu
-  def check_version_info_and_report_issue_from_help_menu
-    within '.navbar-fixed-top' do
-      find('.help-menu > a').click
-      within '.help-menu .dropdown-menu' do
-        assert page.has_link?('Tutorials and User guide'), 'No link - Tutorials and User guide'
-        assert page.has_link?('API Reference'), 'No link - API Reference'
-        assert page.has_link?('SDK Reference'), 'No link - SDK Reference'
-        assert page.has_link?('Show version / debugging info ...'), 'No link - Show version / debugging info'
-        assert page.has_link?('Report a problem ...'), 'No link - Report a problem'
-
-        # check show version info link
-        click_link 'Show version / debugging info ...'
-      end
-    end
-
-    within '.modal-content' do
-      assert page.has_text?('Version / debugging info'), 'No text - Version / debugging info'
-      assert page.has_no_text?('Report a problem'), 'Found text - Report a problem'
-      assert page.has_no_text?('Describe the problem?'), 'Found text - Describe the problem'
-      assert page.has_button?('Close'), 'No button - Close'
-      assert page.has_no_button?('Send problem report'), 'Found button - Send problem report'
-      history_links = all('a').select do |a|
-        a[:href] =~ %r!^https://dev.arvados.org/projects/arvados/repository/changes\?rev=[0-9a-f]+$!
-      end
-      assert_operator(2, :<=, history_links.count,
-                      "Should have found two links to revision history " +
-                      "in #{history_links.inspect}")
-      click_button 'Close'
-    end
-
-    # check report issue link
-    within '.navbar-fixed-top' do
-      find('.help-menu > a').click
-      find('.help-menu .dropdown-menu a', text: 'Report a problem ...').click
-    end
-
-    within '.modal-content' do
-      assert page.has_text?('Report a problem'), 'No text - Report a problem'
-      assert page.has_no_text?('Version / debugging info'), 'Found text - Version / debugging info'
-      assert page.has_text?('Describe the problem'), 'No text - Describe the problem'
-      assert page.has_no_button?('Close'), 'Found button - Close'
-      assert page.has_text?('Send problem report'), 'Send problem report button text is not found'
-      assert page.has_no_button?('Send problem report'), 'Send problem report button is not disabled before entering problem description'
-      assert page.has_button?('Cancel'), 'No button - Cancel'
-
-      # enter a report text and click on report
-      page.find_field('report_issue_text').set 'my test report text'
-      assert page.has_button?('Send problem report'), 'Send problem report button not enabled after entering text'
-
-      report = mock
-      report.expects(:deliver).returns true
-      IssueReporter.expects(:send_report).returns report
-
-      click_button 'Send problem report'
-
-      # ajax success updated button texts and added footer message
-      assert page.has_no_text?('Send problem report'), 'Found button - Send problem report'
-      assert page.has_no_button?('Cancel'), 'Found button - Cancel'
-      assert page.has_text?('Report sent'), 'No text - Report sent'
-      assert page.has_button?('Close'), 'No text - Close'
-      assert page.has_text?('Thanks for reporting this issue'), 'No text - Thanks for reporting this issue'
-
-      click_button 'Close'
-    end
-  end
-
-  [
-    [nil, nil],
-    ['inactive', api_fixture('users')['inactive']],
-    ['inactive_uninvited', api_fixture('users')['inactive_uninvited']],
-    ['active', api_fixture('users')['active']],
-    ['admin', api_fixture('users')['admin']],
-    ['active_no_prefs', api_fixture('users')['active_no_prefs']],
-    ['active_no_prefs_profile_no_getting_started_shown',
-        api_fixture('users')['active_no_prefs_profile_no_getting_started_shown']],
-  ].each do |token, user|
-
-    test "check version info and report issue for user #{token}" do
-      if !token
-        visit ('/')
-      else
-        visit page_with_token(token)
-      end
-
-      check_version_info_and_report_issue_from_help_menu
-    end
-
-  end
-
-end
diff --git a/apps/workbench/test/integration/repositories_browse_test.rb b/apps/workbench/test/integration/repositories_browse_test.rb
deleted file mode 100644 (file)
index 4795486..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-require 'helpers/repository_stub_helper'
-require 'helpers/share_object_helper'
-
-class RepositoriesTest < ActionDispatch::IntegrationTest
-  include RepositoryStubHelper
-  include ShareObjectHelper
-
-  reset_api_fixtures :after_each_test, false
-
-  setup do
-    need_javascript
-  end
-
-  test "browse using arv-git-http" do
-    repo = api_fixture('repositories')['foo']
-    commit_sha1 = '1de84a854e2b440dc53bf42f8548afa4c17da332'
-    visit page_with_token('active', "/repositories/#{repo['uuid']}/commit/#{commit_sha1}")
-    assert_text "Date:   Tue Mar 18 15:55:28 2014 -0400"
-    visit page_with_token('active', "/repositories/#{repo['uuid']}/tree/#{commit_sha1}")
-    assert_selector "tbody td a", "foo"
-    assert_text "12 bytes"
-  end
-end
diff --git a/apps/workbench/test/integration/repositories_test.rb b/apps/workbench/test/integration/repositories_test.rb
deleted file mode 100644 (file)
index a7b0baa..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-require 'helpers/share_object_helper'
-
-class RepositoriesTest < ActionDispatch::IntegrationTest
-  include ShareObjectHelper
-
-  setup do
-    need_javascript
-  end
-
-  [
-    'active', #owner
-    'admin'
-  ].each do |user|
-    test "#{user} can manage sharing for another user" do
-      add_user = api_fixture('users')['future_project_user']
-      new_name = ["first_name", "last_name"].map { |k| add_user[k] }.join(" ")
-      show_object_using(user, 'repositories', 'foo',
-                        api_fixture('repositories')['foo']['name'])
-      click_on "Sharing"
-      add_share_and_check("users", new_name, add_user)
-      modify_share_and_check(new_name)
-    end
-  end
-
-  [
-    'active', #owner
-    'admin'
-  ].each do |user|
-    test "#{user} can manage sharing for another group" do
-      new_name = api_fixture('groups')['future_project_viewing_group']['name']
-      show_object_using(user, 'repositories', 'foo',
-                        api_fixture('repositories')['foo']['name'])
-      click_on "Sharing"
-      add_share_and_check("groups", new_name)
-      modify_share_and_check(new_name)
-    end
-  end
-
-  test "spectator does not see repository sharing tab" do
-    show_object_using('spectator', 'repositories', 'arvados',
-                      api_fixture('repositories')['arvados']['name'])
-    assert(page.has_no_link?("Sharing"),
-           "read-only repository user sees sharing tab")
-  end
-end
diff --git a/apps/workbench/test/integration/search_box_test.rb b/apps/workbench/test/integration/search_box_test.rb
deleted file mode 100644 (file)
index 1eed158..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class SearchBoxTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  # test the search box
-  def verify_search_box user
-    if user && user['is_active']
-      aproject_uuid = api_fixture('groups')['aproject']['uuid']
-      # let's search for aproject by uuid
-      within('.navbar-fixed-top') do
-        page.has_field?('search this site')
-        page.find_field('search this site').set aproject_uuid
-        page.find('.glyphicon-search').click
-      end
-
-      # we should now be in aproject as a result of search
-      assert_selector 'a', text:'Data collections'
-      click_link 'Data collections'
-      assert_selector "#Data_collections[data-object-uuid='#{aproject_uuid}']", "Expected to be in user page after search click"
-
-      # let's search again for an invalid uuid
-      within('.navbar-fixed-top') do
-        search_for = String.new user['uuid']
-        search_for[0]='1'
-        page.find_field('search this site').set search_for
-        page.find('.glyphicon-search').click
-      end
-
-      # we should see 'not found' error page
-      assert page.has_text?('Not Found'), 'No text - Not Found'
-      assert page.has_link?('Report problem'), 'No text - Report problem'
-      click_link 'Report problem'
-      within '.modal-content' do
-        assert page.has_text?('Report a problem'), 'No text - Report a problem'
-        assert page.has_no_text?('Version / debugging info'), 'No text - Version / debugging info'
-        assert page.has_text?('Describe the problem'), 'No text - Describe the problem'
-        assert page.has_text?('Send problem report'), 'Send problem report button text is not found'
-        assert page.has_no_button?('Send problem report'), 'Send problem report button is not disabled before entering problem description'
-        assert page.has_button?('Cancel'), 'No button - Cancel'
-
-        # enter a report text and click on report
-        page.find_field('report_issue_text').set 'my test report text'
-        assert page.has_button?('Send problem report'), 'Send problem report button not enabled after entering text'
-        click_button 'Send problem report'
-
-        # ajax success updated button texts and added footer message
-        assert page.has_no_text?('Send problem report'), 'Found button - Send problem report'
-        assert page.has_no_button?('Cancel'), 'Found button - Cancel'
-        assert page.has_text?('Report sent'), 'No text - Report sent'
-        assert page.has_button?('Close'), 'No text - Close'
-        assert page.has_text?('Thanks for reporting this issue'), 'No text - Thanks for reporting this issue'
-
-        click_button 'Close'
-      end
-
-      # let's search for the anonymously accessible project
-      publicly_accessible_project = api_fixture('groups')['anonymously_accessible_project']
-
-      within('.navbar-fixed-top') do
-        # search again for the anonymously accessible project
-        page.find_field('search this site').set publicly_accessible_project['name'][0,10]
-        page.find('.glyphicon-search').click
-      end
-
-      within '.modal-content' do
-        assert page.has_text?('All projects'), 'No text - All projects'
-        assert page.has_text?('Search'), 'No text - Search'
-        assert page.has_text?('Cancel'), 'No text - Cancel'
-        assert_selector('div', text: publicly_accessible_project['name'])
-        find(:xpath, '//div[./span[contains(.,publicly_accessible_project["uuid"])]]').click
-
-        click_button 'Show'
-      end
-
-      # seeing "Unrestricted public data" now
-      assert page.has_text?(publicly_accessible_project['name']), 'No text - publicly accessible project name'
-      assert page.has_text?(publicly_accessible_project['description']), 'No text - publicly accessible project description'
-    else
-      within('.navbar-fixed-top') do
-        page.has_no_field?('search this site')
-      end
-    end
-  end
-
-  [
-    [nil, nil],
-    ['inactive', api_fixture('users')['inactive']],
-    ['inactive_uninvited', api_fixture('users')['inactive_uninvited']],
-    ['active', api_fixture('users')['active']],
-    ['admin', api_fixture('users')['admin']],
-  ].each do |token, user|
-
-    test "test search box for user #{token}" do
-      visit page_with_token(token)
-
-      verify_search_box user
-    end
-
-  end
-
-end
diff --git a/apps/workbench/test/integration/smoke_test.rb b/apps/workbench/test/integration/smoke_test.rb
deleted file mode 100644 (file)
index 18973db..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-require 'uri'
-
-class SmokeTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  def assert_visit_success(allowed=[200])
-    assert_includes(allowed, status_code,
-                    "#{current_url} returned #{status_code}, not one of " +
-                    allowed.inspect)
-  end
-
-  def all_links_in(find_spec, text_regexp=//)
-    all(find_spec + ' a').collect { |tag|
-      if tag[:href].nil? or tag[:href].empty? or (tag.text !~ text_regexp)
-        nil
-      elsif tag[:'data-remote']
-        # these don't necessarily work with format=html
-        nil
-      else
-        url = URI(tag[:href])
-        url.host.nil? ? url.path : nil
-      end
-    }.compact
-  end
-
-  test "all first-level links succeed" do
-    visit page_with_token('active_trustedclient', '/')
-    assert_visit_success
-    click_link 'notifications-menu'
-    urls = [all_links_in('nav'),
-            all_links_in('.navbar', /^Manage /)].flatten
-    seen_urls = ['/']
-    while not (url = urls.shift).nil?
-      next if seen_urls.include? url
-      visit url
-      seen_urls << url
-      assert_visit_success
-      # Uncommenting the line below lets you crawl the entire site for a
-      # more thorough test.
-      # urls += all_links_in('body')
-    end
-  end
-
-  test "mithril test page" do
-    visit page_with_token('active_trustedclient', '/tests/mithril')
-    assert_visit_success
-    assert_selector 'p', text: 'mithril is working'
-  end
-end
diff --git a/apps/workbench/test/integration/trash_test.rb b/apps/workbench/test/integration/trash_test.rb
deleted file mode 100644 (file)
index 22732a3..0000000
+++ /dev/null
@@ -1,169 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class TrashTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  test "trash page" do
-    deleted = api_fixture('collections')['deleted_on_next_sweep']
-    expired1 = api_fixture('collections')['unique_expired_collection']
-    expired2 = api_fixture('collections')['unique_expired_collection2']
-
-    # visit trash page
-    visit page_with_token('active', "/trash")
-
-    assert_text deleted['name']
-    assert_text deleted['uuid']
-    assert_text deleted['portable_data_hash']
-    assert_text expired1['name']
-    assert_no_text expired2['name']   # not readable by this user
-    assert_no_text 'foo_file'         # not trash
-
-    # Un-trash one item using selection dropdown
-    within('tr', text: deleted['name']) do
-      first('input').click
-    end
-
-    click_button 'Selection...'
-    within('.selection-action-container') do
-      click_link 'Un-trash selected items'
-    end
-
-    wait_for_ajax
-
-    assert_text expired1['name']      # this should still be there
-    assert_no_text deleted['name']    # this should no longer be here
-
-    # Un-trash another item using the recycle button
-    within('tr', text: expired1['name']) do
-      first('.fa-recycle').click
-    end
-
-    wait_for_ajax
-
-    assert_text "The collection with UUID #{expired1['uuid']} is in the trash"
-
-    click_on "Click here to untrash '#{expired1['name']}'"
-
-    # verify that the two un-trashed items are now shown in /collections page
-    visit page_with_token('active', "/collections")
-    assert_text deleted['uuid']
-    assert_text expired1['uuid']
-    assert_no_text expired2['uuid']
-  end
-
-  ["button","selection"].each do |method|
-    test "trashed projects using #{method}" do
-      deleted = api_fixture('groups')['trashed_project']
-      aproject = api_fixture('groups')['aproject']
-
-      # verify that the un-trashed item are missing in /groups page
-      visit page_with_token('active', "/projects/zzzzz-tpzed-xurymjxw79nv3jz")
-      click_on "Subprojects"
-      assert_no_text deleted['name']
-
-      # visit trash page
-      visit page_with_token('active', "/trash")
-      click_on "Trashed projects"
-
-      assert_text deleted['name']
-      assert_text deleted['uuid']
-      assert_no_text aproject['name']
-      assert_no_text aproject['uuid']
-
-      # Un-trash item
-      if method == "button"
-        within('tr', text: deleted['uuid']) do
-          first('.fa-recycle').click
-        end
-        assert_text "The group with UUID #{deleted['uuid']} is in the trash"
-        click_on "Click here to untrash '#{deleted['name']}'"
-      else
-        within('tr', text: deleted['uuid']) do
-          first('input').click
-        end
-        click_button 'Selection...'
-        within('.selection-action-container') do
-          click_link 'Un-trash selected items'
-        end
-        wait_for_ajax
-        assert_no_text deleted['uuid']
-      end
-
-      # check that the un-trashed item are now shown on parent project page
-      visit page_with_token('active', "/projects/zzzzz-tpzed-xurymjxw79nv3jz")
-      click_on "Subprojects"
-      assert_text deleted['name']
-      assert_text aproject['name']
-
-      # Trash another item
-      if method == "button"
-        within('tr', text: aproject['name']) do
-          first('.fa-trash-o').click
-        end
-      else
-        within('tr', text: aproject['name']) do
-          first('input').click
-        end
-        click_button 'Selection'
-        within('.selection-action-container') do
-          click_link 'Remove selected'
-        end
-      end
-
-      wait_for_ajax
-      assert_no_text aproject['name']
-      visit current_path
-      assert_no_text aproject['name']
-
-      # visit trash page
-      visit page_with_token('active', "/trash")
-      click_on "Trashed projects"
-
-      assert_text aproject['name']
-      assert_text aproject['uuid']
-    end
-  end
-
-  test "trash page with search" do
-    deleted = api_fixture('collections')['deleted_on_next_sweep']
-    expired = api_fixture('collections')['unique_expired_collection']
-
-    visit page_with_token('active', "/trash")
-
-    assert_text deleted['name']
-    assert_text deleted['uuid']
-    assert_text deleted['portable_data_hash']
-    assert_text expired['name']
-
-    page.find_field('Search trash').set 'expired'
-
-    assert_no_text deleted['name']
-    assert_text expired['name']
-
-    page.find_field('Search trash').set deleted['portable_data_hash'][0..9]
-
-    assert_no_text expired['name']
-    assert_text deleted['name']
-    assert_text deleted['uuid']
-    assert_text deleted['portable_data_hash']
-
-    click_button 'Selection...'
-    within('.selection-action-container') do
-      assert_selector 'li.disabled', text: 'Un-trash selected items'
-    end
-
-    first('input').click
-
-    click_button 'Selection...'
-    within('.selection-action-container') do
-      assert_selector 'li', text: 'Un-trash selected items'
-      assert_selector 'li.disabled', text: 'Un-trash selected items'
-    end
-  end
-end
diff --git a/apps/workbench/test/integration/user_agreements_test.rb b/apps/workbench/test/integration/user_agreements_test.rb
deleted file mode 100644 (file)
index 666e47f..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class UserAgreementsTest < ActionDispatch::IntegrationTest
-
-  setup do
-    need_javascript
-  end
-
-  def continuebutton_selector
-    'input[type=submit][disabled][value=Continue]'
-  end
-
-  test "cannot click continue without ticking checkbox" do
-    visit page_with_token('inactive')
-    assert_selector continuebutton_selector
-  end
-
-  test "continue button is enabled after ticking checkbox" do
-    visit page_with_token('inactive')
-    assert_selector continuebutton_selector
-    find('input[type=checkbox]').click
-    assert_no_selector continuebutton_selector
-    assert_nil(find_button('Continue')[:disabled],
-               'Continue button did not become enabled')
-  end
-
-end
diff --git a/apps/workbench/test/integration/user_profile_test.rb b/apps/workbench/test/integration/user_profile_test.rb
deleted file mode 100644 (file)
index 30d4943..0000000
+++ /dev/null
@@ -1,162 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class UserProfileTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-    @user_profile_form_fields = Rails.configuration.Workbench.UserProfileFormFields
-  end
-
-  teardown do
-    Rails.configuration.Workbench.UserProfileFormFields = @user_profile_form_fields
-  end
-
-  def verify_homepage_with_profile user, invited, has_profile
-    profile_config = Rails.configuration.Workbench.UserProfileFormFields
-
-    if !user
-      assert_text('Please log in')
-    elsif user['is_active']
-      if !profile_config.empty? && !has_profile
-        assert_text('Save profile')
-        add_profile user
-      else
-        assert_text('Recent processes')
-        assert_no_text('Save profile')
-      end
-    elsif invited
-      assert_text('Please check the box below to indicate that you have read and accepted the user agreement')
-      assert_no_text('Save profile')
-    else
-      assert_text('Your account is inactive')
-      assert_no_text('Save profile')
-    end
-
-    # If the user has not already seen getting_started modal, it will be shown on first visit.
-    if user and user['is_active'] and !user['prefs']['getting_started_shown']
-      within '.modal-content' do
-        assert_text 'Getting Started'
-        assert_selector 'button', text: 'Next'
-        assert_selector 'button', text: 'Prev'
-        first('button', text: 'x').click
-      end
-    end
-
-    within('.navbar-fixed-top') do
-      if !user
-        assert page.has_link?('Log in'), 'Not found link - Log in'
-      else
-        # my account menu
-        assert_selector("#notifications-menu")
-        page.find("#notifications-menu").click
-        within('.dropdown-menu') do
-          if user['is_active']
-            assert_no_selector('a', text: 'Not active')
-            assert_no_selector('a', text: 'Sign agreements')
-
-            assert_selector('a', text: 'Virtual machines')
-            assert_selector('a', text: 'Repositories')
-            assert_selector('a', text: 'Current token')
-            assert_selector('a', text: 'SSH keys')
-
-            if !profile_config.empty?
-              assert_selector('a', text: 'Manage profile')
-            else
-              assert_no_selector('a', text: 'Manage profile')
-            end
-          end
-          assert_selector('a', text: 'Log out')
-        end
-      end
-    end
-  end
-
-  # Check manage profile page and add missing profile to the user
-  def add_profile user
-    assert_no_text('My projects')
-    assert_no_text('Projects shared with me')
-
-    assert_text('Profile')
-    assert_text('First Name')
-    assert_text('Last Name')
-    assert_text('Identity URL')
-    assert_text('E-mail')
-    assert_text(user['email'])
-
-    # Using the default profile which has message and one required field
-
-    # Save profile without filling in the required field. Expect to be back in this profile page again
-    click_button "Save profile"
-    assert_text('Profile')
-    assert_text('First Name')
-    assert_text('Last Name')
-    assert_text('Save profile')
-
-    # This time fill in required field and then save. Expect to go to requested page after that.
-    profile_message = Rails.configuration.Workbench.UserProfileFormMessage
-    required_field_title = ''
-    required_field_key = ''
-    profile_config = Rails.configuration.Workbench.UserProfileFormFields
-    profile_config.each do |k, entry|
-      if entry['Required']
-        required_field_key = k.to_s
-        required_field_title = entry['FormFieldTitle']
-        break
-      end
-    end
-
-    assert page.has_text? profile_message.gsub(/<.*?>/,'')
-    assert_text(required_field_title)
-
-    page.find_field('user[prefs][profile]['+required_field_key+']').set 'value to fill required field'
-
-    click_button "Save profile"
-    # profile saved and in profile page now with success
-    assert_text('Thank you for filling in your profile')
-    assert_selector('input' +
-                    '[name="user[prefs][profile]['+required_field_key+']"]' +
-                    '[value="value to fill required field"]')
-    if user['prefs']['getting_started_shown']
-      click_link 'Back to work!'
-    else
-      click_link 'Get started'
-    end
-
-    # profile saved and in home page now
-    assert_text('Recent processes')
-  end
-
-  [
-    [nil, false, false],
-    ['inactive', true, false],
-    ['inactive_uninvited', false, false],
-    ['active', true, true],
-    ['admin', true, true],
-    ['active_no_prefs', true, false],
-    ['active_no_prefs_profile_no_getting_started_shown', true, false],
-    ['active_no_prefs_profile_with_getting_started_shown', true, false],
-  ].each do |token, invited, has_profile|
-    [true, false].each do |profile_required|
-      test "visit #{token} home page when profile is #{'not ' if !profile_required}configured" do
-        if !profile_required
-          Rails.configuration.Workbench.UserProfileFormFields = []
-        else
-          # Our test config enabled profile by default. So, no need to update config
-        end
-        Rails.configuration.Workbench.EnableGettingStartedPopup = true
-
-        if !token
-          visit ('/')
-        else
-          visit page_with_token(token)
-        end
-
-        user = token && api_fixture('users')[token]
-        verify_homepage_with_profile user, invited, has_profile
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/integration/user_settings_menu_test.rb b/apps/workbench/test/integration/user_settings_menu_test.rb
deleted file mode 100644 (file)
index 99076bb..0000000
+++ /dev/null
@@ -1,236 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class UserSettingsMenuTest < ActionDispatch::IntegrationTest
-  setup do
-    need_javascript
-  end
-
-  # test user settings menu
-  def verify_user_settings_menu user
-    if user['is_active']
-      within('.navbar-fixed-top') do
-        page.find("#notifications-menu").click
-        within('.dropdown-menu') do
-          assert_selector 'a', text: 'Virtual machines'
-          assert_selector 'a', text: 'Repositories'
-          assert_selector 'a', text: 'Current token'
-          assert_selector 'a', text: 'SSH keys'
-          find('a', text: 'SSH keys').click
-        end
-      end
-
-      # now in SSH Keys page
-      assert page.has_text?('Add new SSH key'), 'No text - Add SSH key'
-      add_and_verify_ssh_key
-    else  # inactive user
-      within('.navbar-fixed-top') do
-        page.find("#notifications-menu").click
-        within('.dropdown-menu') do
-          assert page.has_no_link?('Manage profile'), 'Found link - Manage profile'
-        end
-      end
-    end
-  end
-
-  def add_and_verify_ssh_key
-      click_link 'Add new SSH key'
-
-      within '.modal-content' do
-        assert page.has_text?('Public Key'), 'No text - Public Key'
-        assert page.has_button?('Cancel'), 'No button - Cancel'
-        assert page.has_button?('Submit'), 'No button - Submit'
-
-        page.find_field('public_key').set 'first test with an incorrect ssh key value'
-        click_button 'Submit'
-        assert_text 'Public key does not appear to be a valid ssh-rsa or dsa public key'
-
-        public_key_str = api_fixture('authorized_keys')['active']['public_key']
-        page.find_field('public_key').set public_key_str
-        page.find_field('name').set 'added_in_test'
-        click_button 'Submit'
-        assert_text 'Public key already exists in the database, use a different key.'
-
-        new_key = SSHKey.generate
-        page.find_field('public_key').set new_key.ssh_public_key
-        page.find_field('name').set 'added_in_test'
-        click_button 'Submit'
-      end
-
-      # key must be added. look for it in the refreshed page
-      assert_text 'added_in_test'
-  end
-
-  [
-    ['inactive', api_fixture('users')['inactive']],
-    ['inactive_uninvited', api_fixture('users')['inactive_uninvited']],
-    ['active', api_fixture('users')['active']],
-    ['admin', api_fixture('users')['admin']],
-  ].each do |token, user|
-    test "test user settings menu for user #{token}" do
-      visit page_with_token(token)
-      verify_user_settings_menu user
-    end
-  end
-
-  test "pipeline notification shown even though public pipelines exist" do
-    skip "created_by doesn't work that way"
-    Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-    visit page_with_token 'job_reader'
-    click_link 'notifications-menu'
-    assert_selector 'a', text: 'Click here to learn how to run an Arvados Crunch pipeline'
-  end
-
-  [
-    ['job_reader', :ssh, :pipeline],
-    ['active'],
-  ].each do |user, *expect|
-    test "user settings menu for #{user} with notifications #{expect.inspect}" do
-      Rails.configuration.Users.AnonymousUserToken = ""
-      visit page_with_token(user)
-      click_link 'notifications-menu'
-      if expect.include? :ssh
-        assert_selector('a', text: 'Click here to set up an SSH public key for use with Arvados')
-        click_link('Click here to set up an SSH public key for use with Arvados')
-        assert_selector('a', text: 'Add new SSH key')
-
-        add_and_verify_ssh_key
-
-        # No more SSH notification
-        click_link 'notifications-menu'
-        assert_no_selector('a', text: 'Click here to set up an SSH public key for use with Arvados')
-      else
-        assert_no_selector('a', text: 'Click here to set up an SSH public key for use with Arvados')
-        assert_no_selector('a', text: 'Click here to learn how to run an Arvados Crunch pipeline')
-      end
-
-      if expect.include? :pipeline
-        assert_selector('a', text: 'Click here to learn how to run an Arvados Crunch pipeline')
-      end
-    end
-  end
-
-  test "verify repositories for active user" do
-    visit page_with_token('active',"/repositories")
-
-    repos = [[api_fixture('repositories')['foo'], true],
-             [api_fixture('repositories')['repository3'], false],
-             [api_fixture('repositories')['repository4'], false],
-             [api_fixture('repositories')['arvados'], false]]
-
-    repos.each do |(repo, owned)|
-      within('tr', text: repo['name']+'.git') do
-        assert_text repo['name']
-        assert_selector 'a', text:'Show'
-        if owned
-          assert_not_nil first('.glyphicon-trash')
-        else
-          assert_nil first('.glyphicon-trash')
-        end
-      end
-    end
-  end
-
-  test "request shell access" do
-    ActionMailer::Base.deliveries = []
-    visit page_with_token('spectator', "/users/#{api_fixture('users')['spectator']['uuid']}/virtual_machines")
-    assert_text 'You do not have access to any virtual machines'
-    click_link 'Send request for shell access'
-
-    # Button text changes to "sending...", then back to normal. In the
-    # test suite we can't depend on confirming the "sending..." state
-    # before it goes back to normal, though.
-    ## assert_selector 'a', text: 'Sending request...'
-    assert_selector 'a', text: 'Send request for shell access'
-    assert_text 'A request for shell access was sent'
-
-    # verify that the email was sent
-    user = api_fixture('users')['spectator']
-    full_name = "#{user['first_name']} #{user['last_name']}"
-    expected = "Shell account request from #{full_name} (#{user['email']}, #{user['uuid']})"
-    found_email = 0
-    ActionMailer::Base.deliveries.each do |email|
-      if email.subject.include?(expected)
-        found_email += 1
-      end
-    end
-    assert_equal 1, found_email, "Expected email after requesting shell access"
-
-    # Revisit the page and verify the request sent message along with
-    # the request button.
-    within('.navbar-fixed-top') do
-      page.find("#notifications-menu").click
-      within('.dropdown-menu') do
-        find('a', text: 'Virtual machines').click
-      end
-    end
-    assert_text 'You do not have access to any virtual machines.'
-    assert_text 'A request for shell access was sent on '
-    assert_selector 'a', text: 'Send request for shell access'
-  end
-
-  test "create new repository" do
-    visit page_with_token("active_trustedclient")
-    within('.navbar-fixed-top') do
-      page.find("#notifications-menu").click
-      within('.dropdown-menu') do
-        assert_selector 'a', text: 'Repositories'
-        find('a', text: 'Repositories').click
-      end
-    end
-    click_on "Add new repository"
-    within ".modal-dialog" do
-      fill_in "Name", with: "workbenchtest"
-      click_on "Create"
-    end
-    assert_text ":active/workbenchtest.git"
-    assert_match /git@git.*:active\/workbenchtest.git/, page.text
-    assert_match /#{Rails.configuration.Services.GitHTTP.ExternalURL.to_s}active\/workbenchtest.git/, page.text
-  end
-
-  [
-    ['virtual_machines', nil, 'Host name', 'testvm2.shell'],
-    ['/repositories', 'Add new repository', 'It may take a minute or two before you can clone your new repository.', 'active/foo'],
-    ['/current_token', nil, 'HISTIGNORE=$HISTIGNORE', 'ARVADOS_API_TOKEN=3kg6k6lzmp9kj5'],
-    ['ssh_keys', 'Add new SSH key', 'Click here to learn about SSH keys in Arvados.', 'active'],
-  ].each do |page_name, button_name, look_for, content|
-    test "test user settings menu for page #{page_name}" do
-      if page_name == '/current_token' || page_name == '/repositories'
-        visit page_with_token('active', page_name)
-      else
-        visit page_with_token('active', "/users/#{api_fixture('users')['active']['uuid']}/#{page_name}")
-      end
-
-      assert page.has_text? content
-      if button_name
-        assert_selector 'a', text: button_name
-        find('a', text: button_name).click
-      end
-
-      assert page.has_text? look_for
-    end
-  end
-
-  [
-    ['virtual_machines', 'You do not have access to any virtual machines.'],
-    ['/repositories', api_fixture('repositories')['arvados']['name']],
-    ['/current_token', 'HISTIGNORE=$HISTIGNORE'],
-    ['ssh_keys', 'You have not yet set up an SSH public key for use with Arvados.'],
-  ].each do |page_name, look_for|
-    test "test user settings menu for page #{page_name} when page is empty" do
-      if page_name == '/current_token' || page_name == '/repositories'
-        visit page_with_token('user1_with_load', page_name)
-      else
-        visit page_with_token('admin', "/users/#{api_fixture('users')['user1_with_load']['uuid']}/#{page_name}")
-      end
-
-      assert page.has_text? look_for
-      if page_name == '/repositories'
-        assert_equal 1, page.all('a[data-original-title="show repository"]').count
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/integration/users_test.rb b/apps/workbench/test/integration/users_test.rb
deleted file mode 100644 (file)
index 57be9d3..0000000
+++ /dev/null
@@ -1,236 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class UsersTest < ActionDispatch::IntegrationTest
-
-  test "login as active user but not admin" do
-    need_javascript
-    visit page_with_token('active_trustedclient')
-
-    assert page.has_no_link? 'Users' 'Found Users link for non-admin user'
-  end
-
-  test "login as admin user and verify active user data" do
-    need_javascript
-    visit page_with_token('admin_trustedclient')
-
-    # go to Users list page
-    find('#system-menu').click
-    click_link 'Users'
-
-    # check active user attributes in the list page
-    page.within(:xpath, '//tr[@data-object-uuid="zzzzz-tpzed-xurymjxw79nv3jz"]') do
-      assert (text.include? 'true false'), 'Expected is_active'
-    end
-
-    find('tr', text: 'zzzzz-tpzed-xurymjxw79nv3jz').
-      find('a', text: 'Show').
-      click
-    assert page.has_text? 'Attributes'
-    assert page.has_text? 'Advanced'
-    assert page.has_text? 'Admin'
-
-    # go to the Attributes tab
-    click_link 'Attributes'
-    assert page.has_text? 'modified_by_user_uuid'
-    page.within(:xpath, '//span[@data-name="is_active"]') do
-      assert_equal "true", text, "Expected user's is_active to be true"
-    end
-    page.within(:xpath, '//span[@data-name="is_admin"]') do
-      assert_equal "false", text, "Expected user's is_admin to be false"
-    end
-
-  end
-
-  test "create a new user" do
-    need_javascript
-
-    visit page_with_token('admin_trustedclient')
-
-    find('#system-menu').click
-    click_link 'Users'
-
-    assert page.has_text? 'zzzzz-tpzed-d9tiejq69daie8f'
-
-    click_link 'Add a new user'
-
-    within '.modal-content' do
-      find 'label', text: 'Virtual Machine'
-      fill_in "email", :with => "foo@example.com"
-      click_button "Submit"
-      wait_for_ajax
-    end
-
-    visit '/users'
-
-    # verify that the new user showed up in the users page and find
-    # the new user's UUID
-    new_user_uuid =
-      find('tr[data-object-uuid]', text: 'foo@example.com')['data-object-uuid']
-    assert new_user_uuid, "Expected new user uuid not found"
-
-    # go to the new user's page
-    find('tr', text: new_user_uuid).
-      find('a', text: 'Show').
-      click
-
-    click_link 'Attributes'
-
-    assert page.has_text? 'modified_by_user_uuid'
-    page.within(:xpath, '//span[@data-name="is_active"]') do
-      assert_equal "false", text, "Expected new user's is_active to be false"
-    end
-
-    click_link 'Advanced'
-    click_link 'Metadata'
-    assert page.has_text? 'can_read' # make sure page is rendered / ready
-    assert page.has_no_text? 'VirtualMachine:'
-  end
-
-  test "setup the active user" do
-    need_javascript
-    visit page_with_token('admin_trustedclient')
-
-    find('#system-menu').click
-    click_link 'Users'
-
-    # click on active user
-    find('tr', text: 'zzzzz-tpzed-xurymjxw79nv3jz').
-      find('a', text: 'Show').
-      click
-    user_url = page.current_url
-
-    # Setup user
-    click_link 'Admin'
-    assert page.has_text? 'This button sets up a user'
-
-    click_link 'Setup account for Active User'
-
-    within '.modal-content' do
-      find 'label', text: 'Virtual Machine'
-      click_button "Submit"
-    end
-
-    visit user_url
-    click_link 'Attributes'
-    assert page.has_text? 'modified_by_client_uuid'
-
-    click_link 'Advanced'
-    click_link 'Metadata'
-    vm_links = all("a", text: "VirtualMachine:")
-    assert_equal(1, vm_links.size)
-    assert_equal("VirtualMachine: testvm2.shell", vm_links.first.text)
-
-    # Click on Setup button again and this time also choose a VM
-    click_link 'Admin'
-    click_link 'Setup account for Active User'
-
-    within '.modal-content' do
-      select("testvm.shell", :from => 'vm_uuid')
-      fill_in "groups", :with => "test group one, test-group-two"
-      click_button "Submit"
-    end
-
-    visit user_url
-    click_link 'Attributes'
-    find '#Attributes', text: 'modified_by_client_uuid'
-
-    click_link 'Advanced'
-    click_link 'Metadata'
-    assert page.has_text? 'VirtualMachine: testvm.shell'
-    assert page.has_text? '["test group one", "test-group-two"]'
-    vm_links = all("a", text: "VirtualMachine:")
-    assert_equal(2, vm_links.size)
-  end
-
-  test "unsetup active user" do
-    need_javascript
-
-    visit page_with_token('admin_trustedclient')
-
-    find('#system-menu').click
-    click_link 'Users'
-
-    # click on active user
-    find('tr', text: 'zzzzz-tpzed-xurymjxw79nv3jz').
-      find('a', text: 'Show').
-      click
-    user_url = page.current_url
-
-    # Verify that is_active is set
-    click_link 'Attributes'
-    assert page.has_text? 'modified_by_user_uuid'
-    page.within(:xpath, '//span[@data-name="is_active"]') do
-      assert_equal "true", text, "Expected user's is_active to be true"
-    end
-
-    # go to Admin tab
-    click_link 'Admin'
-    assert page.has_text? 'As an admin, you can deactivate and reset this user'
-
-    # unsetup user and verify all the above links are deleted
-    click_link 'Admin'
-    click_button 'Deactivate Active User'
-
-    if Capybara.current_driver == :selenium
-      sleep(0.1)
-      page.driver.browser.switch_to.alert.accept
-    else
-      # poltergeist returns true for confirm(), so we don't need to accept.
-    end
-
-    click_link 'Attributes'
-
-    # Should now be back in the Attributes tab for the user
-    assert page.has_text? 'modified_by_user_uuid'
-    page.within(:xpath, '//span[@data-name="is_active"]') do
-      assert_equal "false", text, "Expected user's is_active to be false after unsetup"
-    end
-
-    click_link 'Advanced'
-    click_link 'Metadata'
-    assert page.has_no_text? 'VirtualMachine: testvm.shell'
-
-    # setup user again and verify links present
-    click_link 'Admin'
-    click_link 'Setup account for Active User'
-
-    within '.modal-content' do
-      select("testvm.shell", :from => 'vm_uuid')
-      click_button "Submit"
-    end
-
-    visit user_url
-    click_link 'Attributes'
-    assert page.has_text? 'modified_by_client_uuid'
-
-    click_link 'Advanced'
-    click_link 'Metadata'
-    assert page.has_text? 'VirtualMachine: testvm.shell'
-  end
-
-  test "test add group button" do
-    need_javascript
-
-    user_url = "/users/#{api_fixture('users')['active']['uuid']}"
-    visit page_with_token('admin_trustedclient', user_url)
-
-    # Setup user
-    click_link 'Admin'
-    assert page.has_text? 'This button sets up a user'
-
-    click_link 'Add new group'
-
-    within '.modal-content' do
-      fill_in "group_name_input", :with => "test-group-added-in-modal"
-      click_button "Create"
-    end
-    wait_for_ajax
-
-    # Back in the user "Admin" tab
-    assert page.has_text? 'test-group-added-in-modal'
-  end
-end
diff --git a/apps/workbench/test/integration/virtual_machines_test.rb b/apps/workbench/test/integration/virtual_machines_test.rb
deleted file mode 100644 (file)
index a13abf9..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class VirtualMachinesTest < ActionDispatch::IntegrationTest
-end
diff --git a/apps/workbench/test/integration/websockets_test.rb b/apps/workbench/test/integration/websockets_test.rb
deleted file mode 100644 (file)
index 8349417..0000000
+++ /dev/null
@@ -1,199 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-class WebsocketTest < ActionDispatch::IntegrationTest
-  setup do
-    need_selenium "to make websockets work"
-    @dispatch_client = ArvadosApiClient.new
-  end
-
-  def dispatch_log(body)
-    use_token :dispatch1 do
-      @dispatch_client.api('logs', '', log: body)
-    end
-  end
-
-  test "test page" do
-    visit(page_with_token("active", "/websockets"))
-    fill_in("websocket-message-content", :with => "Stuff")
-    click_button("Send")
-    assert_text '"status":400'
-  end
-
-  [
-   ['pipeline_instances', 'pipeline_in_running_state', api_fixture('jobs')['running']],
-   ['jobs', 'running'],
-   ['containers', 'running'],
-   ['container_requests', 'running', api_fixture('containers')['running']],
-  ].each do |controller, view_fixture_name, log_target_fixture|
-    view_fixture = api_fixture(controller)[view_fixture_name]
-    log_target_fixture ||= view_fixture
-
-    test "test live logging and scrolling for #{controller}" do
-
-      visit(page_with_token("active", "/#{controller}/#{view_fixture['uuid']}\#Log"))
-      assert_no_text '123 hello'
-
-      text = ""
-      (1..1000).each do |i|
-        text << "#{i} hello\n"
-      end
-
-      dispatch_log(owner_uuid: log_target_fixture['owner_uuid'],
-                   object_uuid: log_target_fixture['uuid'],
-                   event_type: "stderr",
-                   properties: {"text" => text})
-      assert_text '1000 hello'
-
-      # First test that when we're already at the bottom of the page, it scrolls down
-      # when a new line is added.
-      old_top = page.evaluate_script("$('#event_log_div').scrollTop()")
-
-      dispatch_log(owner_uuid: log_target_fixture['owner_uuid'],
-                   object_uuid: log_target_fixture['uuid'],
-                   event_type: "dispatch",
-                   properties: {"text" => "1001 hello\n"})
-      assert_text '1001 hello'
-
-      # Check that new value of scrollTop is greater than the old one
-      new_top = page.evaluate_script("$('#event_log_div').scrollTop()")
-      assert_operator new_top, :>, old_top
-
-      # Now scroll to 30 pixels from the top
-      page.execute_script "$('#event_log_div').scrollTop(30)"
-      assert_equal 30, page.evaluate_script("$('#event_log_div').scrollTop()")
-
-      dispatch_log(owner_uuid: log_target_fixture['owner_uuid'],
-                   object_uuid: log_target_fixture['uuid'],
-                   event_type: "stdout",
-                   properties: {"text" => "1002 hello\n"})
-      assert_text '1002 hello'
-
-      # Check that we haven't changed scroll position
-      assert_equal 30, page.evaluate_script("$('#event_log_div').scrollTop()")
-    end
-  end
-
-  test 'job graph appears when first data point is already in logs table' do
-    job_graph_first_datapoint_test
-  end
-
-  test 'job graph appears when first data point arrives by websocket' do
-    use_token :admin do
-      Log.find(api_fixture('logs')['crunchstat_for_running_job']['uuid']).destroy
-    end
-    job_graph_first_datapoint_test expect_existing_datapoints: false
-  end
-
-  def job_graph_first_datapoint_test expect_existing_datapoints: true
-    uuid = api_fixture('jobs')['running']['uuid']
-
-    visit page_with_token "active", "/jobs/#{uuid}"
-    click_link "Log"
-
-    assert_selector '#event_log_div', visible: true
-
-    if expect_existing_datapoints
-      assert_selector '#log_graph_div', visible: true
-      # Magic numbers 12.99 etc come from the job log fixture:
-      assert_last_datapoint 'T1-cpu', (((12.99+0.99)/10.0002)/8)
-    else
-      # Until graphable data arrives, we should see the text log but not the graph.
-      assert_no_selector '#log_graph_div', visible: true
-    end
-
-    text = "2014-11-07_23:33:51 #{uuid} 31708 1 stderr crunchstat: cpu 1970.8200 user 60.2700 sys 8 cpus -- interval 10.0002 seconds 35.3900 user 0.8600 sys"
-
-    assert_triggers_dom_event 'arv-log-event' do
-      dispatch_log(owner_uuid: api_fixture('jobs')['running']['owner_uuid'],
-                   object_uuid: uuid,
-                   event_type: "stderr",
-                   properties: {"text" => text})
-    end
-
-    # Graph should have appeared (even if it hadn't above). It's
-    # important not to wait like matchers usually do: we are
-    # confirming the graph is visible _immediately_ after the first
-    # data point arrives.
-    using_wait_time 0 do
-      assert_selector '#log_graph_div', visible: true
-    end
-    assert_last_datapoint 'T1-cpu', (((35.39+0.86)/10.0002)/8)
-  end
-
-  test "live log charting from replayed log" do
-    uuid = api_fixture("jobs")['running']['uuid']
-
-    visit page_with_token "active", "/jobs/#{uuid}"
-    click_link "Log"
-
-    assert_triggers_dom_event 'arv-log-event' do
-      ApiServerForTests.new.run_rake_task("replay_job_log", "test/job_logs/crunchstatshort.log,1.0,#{uuid}")
-    end
-
-    assert_last_datapoint 'T1-cpu', (((35.39+0.86)/10.0002)/8)
-  end
-
-  def assert_last_datapoint series, value
-    datum = page.evaluate_script("jobGraphData[jobGraphData.length-1]['#{series}']")
-    assert_in_epsilon value, datum.to_f
-  end
-
-  test "test running job with just a few previous log records" do
-    job = api_fixture("jobs")['running']
-
-    # Create just one old log record
-    dispatch_log(owner_uuid: job['owner_uuid'],
-                 object_uuid: job['uuid'],
-                 event_type: "stderr",
-                 properties: {"text" => "Historic log message"})
-
-    visit page_with_token("active", "/jobs/#{job['uuid']}\#Log")
-
-    # Expect "all" historic log records because we have less than
-    # default Rails.configuration.Workbench.RunningJobLogRecordsToFetch
-    assert_text 'Historic log message'
-
-    # Create new log record and expect it to show up in log tab
-    dispatch_log(owner_uuid: job['owner_uuid'],
-                 object_uuid: job['uuid'],
-                 event_type: "stderr",
-                 properties: {"text" => "Log message after subscription"})
-    assert_text 'Log message after subscription'
-  end
-
-  test "test running job with too many previous log records" do
-    max = 5
-    Rails.configuration.Workbench.RunningJobLogRecordsToFetch = max
-    job = api_fixture("jobs")['running']
-
-    # Create max+1 log records
-    (0..max).each do |count|
-      dispatch_log(owner_uuid: job['owner_uuid'],
-                   object_uuid: job['uuid'],
-                   event_type: "stderr",
-                   properties: {"text" => "Old log message #{count}"})
-    end
-
-    visit page_with_token("active", "/jobs/#{job['uuid']}\#Log")
-
-    # Expect all but the first historic log records,
-    # because that was one too many than fetch count.
-    (1..max).each do |count|
-      assert_text "Old log message #{count}"
-    end
-    assert_no_text 'Old log message 0'
-
-    # Create one more log record after subscription
-    dispatch_log(owner_uuid: job['owner_uuid'],
-                 object_uuid: job['uuid'],
-                 event_type: "stderr",
-                 properties: {"text" => "Life goes on!"})
-
-    # Expect it to show up in log tab
-    assert_text 'Life goes on!'
-  end
-end
diff --git a/apps/workbench/test/integration/work_units_test.rb b/apps/workbench/test/integration/work_units_test.rb
deleted file mode 100644 (file)
index 36b2946..0000000
+++ /dev/null
@@ -1,307 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'helpers/fake_websocket_helper'
-require 'integration_helper'
-
-class WorkUnitsTest < ActionDispatch::IntegrationTest
-  include FakeWebsocketHelper
-
-  setup do
-    need_javascript
-  end
-
-  [[true, 25, 100,
-    ['/pipeline_instances/zzzzz-d1hrv-1yfj61234abcdk3',
-     '/pipeline_instances/zzzzz-d1hrv-1yfj61234abcdk4',
-     '/jobs/zzzzz-8i9sb-grx15v5mjnsyxk7',
-     '/jobs/zzzzz-8i9sb-n7omg50bvt0m1nf',
-     '/container_requests/zzzzz-xvhdp-cr4completedcr2',
-     '/container_requests/zzzzz-xvhdp-cr4requestercn2'],
-    ['/pipeline_instances/zzzzz-d1hrv-scarxiyajtshq3l',
-     '/container_requests/zzzzz-xvhdp-oneof60crs00001']],
-   [false, 25, 100,
-    ['/pipeline_instances/zzzzz-d1hrv-1yfj61234abcdk3',
-     '/pipeline_instances/zzzzz-d1hrv-1yfj61234abcdk4',
-     '/container_requests/zzzzz-xvhdp-cr4completedcr2'],
-    ['/pipeline_instances/zzzzz-d1hrv-scarxiyajtshq3l',
-     '/container_requests/zzzzz-xvhdp-oneof60crs00001',
-     '/jobs/zzzzz-8i9sb-grx15v5mjnsyxk7',
-     '/jobs/zzzzz-8i9sb-n7omg50bvt0m1nf',
-     '/container_requests/zzzzz-xvhdp-cr4requestercn2'
-    ]]
-  ].each do |show_children, expected_min, expected_max, expected, not_expected|
-    test "scroll all_processes page with show_children=#{show_children}" do
-      visit page_with_token('active', "/all_processes")
-
-      if show_children
-        find('#IncludeChildProcs').click
-        wait_for_ajax
-      end
-
-      page_scrolls = expected_max/20 + 2
-      within('.arv-recent-all-processes') do
-        (0..page_scrolls).each do |i|
-          page.driver.scroll_to 0, 999000
-          begin
-            wait_for_ajax
-          rescue
-          end
-        end
-      end
-
-      # Verify that expected number of processes are found
-      found_items = page.all('tr[data-object-uuid]')
-      found_count = found_items.count
-      if expected_min == expected_max
-        assert_equal(true, found_count == expected_min,
-                     "Not found expected number of items. Expected #{expected_min} and found #{found_count}")
-        assert page.has_no_text? 'request failed'
-      else
-        assert_equal(true, found_count>=expected_min,
-                     "Found too few items. Expected at least #{expected_min} and found #{found_count}")
-        assert_equal(true, found_count<=expected_max,
-                     "Found too many items. Expected at most #{expected_max} and found #{found_count}")
-      end
-
-      # verify that all expected uuid links are found
-      expected.each do |link|
-        assert_selector "a[href=\"#{link}\"]"
-      end
-
-      # verify that none of the not_expected uuid links are found
-      not_expected.each do |link|
-        assert_no_selector "a[href=\"#{link}\"]"
-      end
-    end
-  end
-
-  [
-    ['containers', 'running', false],
-    ['container_requests', 'running', true],
-  ].each do |type, fixture, cancelable, confirm_cancellation|
-    test "cancel button for #{type}/#{fixture}" do
-      if cancelable
-        need_selenium 'to cancel'
-      end
-
-      obj = api_fixture(type)[fixture]
-      visit page_with_token "active", "/#{type}/#{obj['uuid']}"
-
-      assert_text 'created_at'
-      if cancelable
-        assert_text 'priority: 501' if type.include?('container')
-        if type.include?('pipeline')
-          assert_selector 'a', text: 'Pause'
-          first('a,link', text: 'Pause').click
-        else
-          assert_selector 'button', text: 'Cancel'
-          first('a,button', text: 'Cancel').click
-        end
-        if confirm_cancellation
-          alert = page.driver.browser.switch_to.alert
-          alert.accept
-        end
-        wait_for_ajax
-      end
-
-      if type.include?('pipeline')
-        assert_selector 'a', text: 'Resume'
-        assert_no_selector 'a', text: 'Pause'
-      elsif type.include?('job')
-        assert_text 'Cancelled'
-        assert_text 'Paused'  # this job has a pipeline child which was also cancelled
-        assert_no_selector 'button', text: 'Cancel'
-      elsif cancelable
-        assert_text 'priority: 0'
-      end
-    end
-  end
-
-  [
-    ['container_requests', 'running'],
-    ['container_requests', 'completed'],
-  ].each do |type, fixture|
-    test "edit description for #{type}/#{fixture}" do
-      obj = api_fixture(type)[fixture]
-      visit page_with_token "active", "/#{type}/#{obj['uuid']}"
-
-      within('.arv-description-as-subtitle') do
-        find('.fa-pencil').click
-        find('.editable-input textarea').set('*Textile description for object*')
-        find('.editable-submit').click
-      end
-      wait_for_ajax
-
-      # verify description
-      assert page.has_no_text? '*Textile description for object*'
-      assert page.has_text? 'Textile description for object'
-    end
-  end
-
-  [
-    ['Workflow with default input specifications', 'this workflow has inputs specified', 'Provide a value for the following'],
-  ].each do |template_name, preview_txt, process_txt|
-    test "run a process using template #{template_name} from dashboard" do
-      visit page_with_token('admin')
-      assert_text 'Recent processes' # seeing dashboard now
-
-      within('.recent-processes-actions') do
-        assert page.has_link?('All processes')
-        find('a', text: 'Run a process').click
-      end
-
-      # in the chooser, verify preview and click Next button
-      within('.modal-dialog') do
-        find('.selectable', text: template_name).click
-        assert_text preview_txt
-        find('.btn', text: 'Next: choose inputs').click
-      end
-
-      # in the process page now
-      assert_text process_txt
-      assert_selector 'a', text: template_name
-
-      assert_equal "true", find('span[data-name="reuse_steps"]').text
-
-      assert_equal "Set value for ex_string_def", find('div.form-group > div.form-control-static > a', text: "hello-testing-123")[:"data-title"]
-
-      page.assert_selector 'a.disabled,button.disabled', text: 'Run'
-    end
-  end
-
-  test 'display container state changes in Container Request live log' do
-    use_fake_websocket_driver
-    c = api_fixture('containers')['queued']
-    cr = api_fixture('container_requests')['queued']
-    visit page_with_token('active', '/container_requests/'+cr['uuid'])
-    click_link('Log')
-
-    # The attrs of the "terminal window" text div in the log tab
-    # indicates which objects' events are worth displaying. Events
-    # that arrive too early (before that div exists) are not
-    # shown. For the user's sake, these early logs should also be
-    # retrieved and shown one way or another -- but in this particular
-    # test, we are only interested in logs that arrive by
-    # websocket. Therefore, to avoid races, we wait for the log tab to
-    # display before sending any events.
-    assert_text 'Recent logs'
-
-    [[{
-        event_type: 'dispatch',
-        properties: {
-          text: "dispatch logged a fake message\n",
-        },
-      }, "dispatch logged"],
-     [{
-        event_type: 'update',
-        properties: {
-          old_attributes: {state: 'Locked'},
-          new_attributes: {state: 'Queued'},
-        },
-      }, "Container #{c['uuid']} was returned to the queue"],
-     [{
-        event_type: 'update',
-        properties: {
-          old_attributes: {state: 'Queued'},
-          new_attributes: {state: 'Locked'},
-        },
-      }, "Container #{c['uuid']} was taken from the queue by a dispatch process"],
-     [{
-        event_type: 'crunch-run',
-        properties: {
-          text: "according to fake crunch-run,\nsome setup stuff happened on the compute node\n",
-        },
-      }, "setup stuff happened"],
-     [{
-        event_type: 'update',
-        properties: {
-          old_attributes: {state: 'Locked'},
-          new_attributes: {state: 'Running'},
-        },
-      }, "Container #{c['uuid']} started"],
-     [{
-        event_type: 'update',
-        properties: {
-          old_attributes: {state: 'Running'},
-          new_attributes: {state: 'Complete', exit_code: 1},
-        },
-      }, "Container #{c['uuid']} finished"],
-     # It's unrealistic for state to change again once it's Complete,
-     # but the logging code doesn't care, so we do it to keep the test
-     # simple.
-     [{
-        event_type: 'update',
-        properties: {
-          old_attributes: {state: 'Running'},
-          new_attributes: {state: 'Cancelled'},
-        },
-      }, "Container #{c['uuid']} was cancelled"],
-    ].each do |send_event, expect_log_text|
-      assert_no_text(expect_log_text)
-      fake_websocket_event(send_event.merge(object_uuid: c['uuid']))
-      assert_text(expect_log_text)
-    end
-  end
-
-  test 'Run from workflows index page' do
-    visit page_with_token('active', '/workflows')
-
-    wf_count = page.all('a[data-original-title="show workflow"]').count
-    assert_equal true, wf_count>0
-
-    # Run one of the workflows
-    wf_name = 'Workflow with input specifications'
-    within('tr', text: wf_name) do
-      find('a,button', text: 'Run').click
-    end
-
-    # Choose project for the container_request being created
-    within('.modal-dialog') do
-      find('.selectable', text: 'A Project').click
-      find('button', text: 'Choose').click
-    end
-
-    # In newly created container_request page now
-    assert_text 'A Project' # CR created in "A Project"
-    assert_text "This container request was created from the workflow #{wf_name}"
-    assert_match /Provide a value for .* then click the \"Run\" button to start the workflow/, page.text
-  end
-
-  test 'Run workflow from show page' do
-    visit page_with_token('active', '/workflows/zzzzz-7fd4e-validwithinputs')
-
-    find('a,button', text: 'Run this workflow').click
-
-    # Choose project for the container_request being created
-    within('.modal-dialog') do
-      find('.selectable', text: 'A Project').click
-      find('button', text: 'Choose').click
-    end
-
-    # In newly created container_request page now
-    assert_text 'A Project' # CR created in "A Project"
-    assert_text "This container request was created from the workflow"
-    assert_match /Provide a value for .* then click the \"Run\" button to start the workflow/, page.text
-  end
-
-  test "create workflow with WorkflowRunnerResources" do
-    visit page_with_token('active', '/workflows/zzzzz-7fd4e-validwithinput3')
-
-    find('a,button', text: 'Run this workflow').click
-
-    # Choose project for the container_request being created
-    within('.modal-dialog') do
-      find('.selectable', text: 'A Project').click
-      find('button', text: 'Choose').click
-    end
-    click_link 'Advanced'
-    click_link("API response")
-    assert_text('"container_image": "arvados/jobs:2.0.4"')
-    assert_text('"vcpus": 2')
-    assert_text('"ram": 1293942784')
-    assert_text('"--collection-cache-size=678"')
-
-  end
-end
diff --git a/apps/workbench/test/integration_helper.rb b/apps/workbench/test/integration_helper.rb
deleted file mode 100644 (file)
index 7209f2b..0000000
+++ /dev/null
@@ -1,261 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-require 'capybara/rails'
-require 'capybara/poltergeist'
-require 'uri'
-require 'yaml'
-
-def available_port for_what
-  begin
-    Addrinfo.tcp("0.0.0.0", 0).listen do |srv|
-      port = srv.connect_address.ip_port
-      # Selenium needs an additional locking port, check if it's available
-      # and retry if necessary.
-      if for_what == 'selenium'
-        locking_port = port - 1
-        Addrinfo.tcp("0.0.0.0", locking_port).listen.close
-      end
-      STDERR.puts "Using port #{port} for #{for_what}"
-      return port
-    end
-  rescue Errno::EADDRINUSE, Errno::EACCES
-    retry
-  end
-end
-
-def selenium_opts
-  {
-    port: available_port('selenium'),
-    desired_capabilities: Selenium::WebDriver::Remote::Capabilities.firefox(
-      acceptInsecureCerts: true,
-    ),
-  }
-end
-
-def poltergeist_opts
-  {
-    phantomjs_options: ['--ignore-ssl-errors=true'],
-    port: available_port('poltergeist'),
-    window_size: [1200, 800],
-  }
-end
-
-Capybara.register_driver :poltergeist do |app|
-  Capybara::Poltergeist::Driver.new app, poltergeist_opts
-end
-
-Capybara.register_driver :poltergeist_debug do |app|
-  Capybara::Poltergeist::Driver.new app, poltergeist_opts.merge(inspector: true)
-end
-
-Capybara.register_driver :poltergeist_with_fake_websocket do |app|
-  js = File.expand_path '../support/fake_websocket.js', __FILE__
-  Capybara::Poltergeist::Driver.new app, poltergeist_opts.merge(extensions: [js])
-end
-
-Capybara.register_driver :poltergeist_without_file_api do |app|
-  js = File.expand_path '../support/remove_file_api.js', __FILE__
-  Capybara::Poltergeist::Driver.new app, poltergeist_opts.merge(extensions: [js])
-end
-
-Capybara.register_driver :selenium do |app|
-  Capybara::Selenium::Driver.new app, selenium_opts
-end
-
-Capybara.register_driver :selenium_with_download do |app|
-  profile = Selenium::WebDriver::Firefox::Profile.new
-  profile['browser.download.dir'] = DownloadHelper.path.to_s
-  profile['browser.download.downloadDir'] = DownloadHelper.path.to_s
-  profile['browser.download.defaultFolder'] = DownloadHelper.path.to_s
-  profile['browser.download.folderList'] = 2 # "save to user-defined location"
-  profile['browser.download.manager.showWhenStarting'] = false
-  profile['browser.helperApps.alwaysAsk.force'] = false
-  profile['browser.helperApps.neverAsk.saveToDisk'] = 'text/plain,application/octet-stream'
-  Capybara::Selenium::Driver.new app, selenium_opts.merge(profile: profile)
-end
-
-module WaitForAjax
-  # FIXME: Huge side effect here
-  # The following line changes the global default Capybara wait time, affecting
-  # every test which follows this one. This should be removed and the failing tests
-  # should have their individual wait times increased, if appropriate, using
-  # the using_wait_time(N) construct to temporarily change the wait time.
-  # Note: the below is especially bad because there are places that increase wait
-  # times using a multiplier e.g. using_wait_time(3 * Capybara.default_max_wait_time)
-  Capybara.default_max_wait_time = 10
-  def wait_for_ajax
-    timeout = 10
-    count = 0
-    while page.evaluate_script("jQuery.active").to_i > 0
-      count += 1
-      raise "AJAX request took more than #{timeout} seconds" if count > timeout * 10
-      sleep(0.1)
-    end
-  end
-
-end
-
-module AssertDomEvent
-  # Yield the supplied block, then wait for an event to arrive at a
-  # DOM element.
-  def assert_triggers_dom_event events, target='body'
-    magic = 'received-dom-event-' + rand(2**30).to_s(36)
-    page.execute_script <<eos
-      $('#{target}').one('#{events}', function() {
-        $('body').addClass('#{magic}');
-      });
-eos
-    yield
-    assert_selector "body.#{magic}"
-    page.execute_script "$('body').removeClass('#{magic}');";
-  end
-end
-
-module HeadlessHelper
-  class HeadlessSingleton
-    @display = ENV['ARVADOS_TEST_HEADLESS_DISPLAY'] || rand(400)+100
-    STDERR.puts "Using display :#{@display} for headless tests"
-    def self.get
-      @headless ||= Headless.new reuse: false, display: @display
-    end
-  end
-
-  Capybara.default_driver = :rack_test
-
-  def self.included base
-    base.class_eval do
-      setup do
-        Capybara.use_default_driver
-        @headless = false
-      end
-
-      teardown do
-        if @headless
-          @headless.stop
-          @headless = false
-        end
-      end
-    end
-  end
-
-  def need_selenium reason=nil, driver=:selenium
-    Capybara.current_driver = driver
-    unless ENV['ARVADOS_TEST_HEADFUL'] or @headless
-      @headless = HeadlessSingleton.get
-      @headless.start
-    end
-  end
-
-  def need_javascript reason=nil
-    unless Capybara.current_driver == :selenium
-      Capybara.current_driver = :poltergeist
-    end
-  end
-end
-
-class ActionDispatch::IntegrationTest
-  # Make the Capybara DSL available in all integration tests
-  include Capybara::DSL
-  include ApiFixtureLoader
-  include WaitForAjax
-  include AssertDomEvent
-  include HeadlessHelper
-
-  @@API_AUTHS = self.api_fixture('api_client_authorizations')
-
-  def page_with_token(token, path='/')
-    # Generate a page path with an embedded API token.
-    # Typical usage: visit page_with_token('token_name', page)
-    # The token can be specified by the name of an api_client_authorizations
-    # fixture, or passed as a raw string.
-    api_token = ((@@API_AUTHS.include? token) ?
-                 @@API_AUTHS[token]['api_token'] : token)
-    path_parts = path.partition("#")
-    sep = (path_parts.first.include? '?') ? '&' : '?'
-    q_string = URI.encode_www_form('api_token' => api_token)
-    path_parts.insert(1, "#{sep}#{q_string}")
-    path_parts.join("")
-  end
-
-  # Find a page element, but return false instead of raising an
-  # exception if not found. Use this with assertions to explain that
-  # the error signifies a failed test rather than an unexpected error
-  # during a testing procedure.
-  def find?(*args)
-    begin
-      find(*args)
-    rescue Capybara::ElementNotFound
-      false
-    end
-  end
-
-  @@screenshot_count = 1
-  def screenshot
-    image_file = "./tmp/workbench-fail-#{@@screenshot_count}.png"
-    begin
-      page.save_screenshot image_file
-    rescue Capybara::NotSupportedByDriverError
-      # C'est la vie.
-    else
-      puts "Saved #{image_file}"
-      @@screenshot_count += 1
-    end
-  end
-
-  teardown do
-    if !passed? && !skipped?
-      screenshot
-    end
-    if Capybara.current_driver == :selenium
-      # Clearing localStorage crashes on a page where JS isn't
-      # executed. We also need to make sure we're clearing
-      # localStorage for the test server's origin, even if we finished
-      # the test on a different origin.
-      host = Capybara.current_session.server.host
-      port = Capybara.current_session.server.port
-      base = "http://#{host}:#{port}"
-      if page.evaluate_script("window.document.contentType") != "text/html" ||
-         !page.evaluate_script("window.location.toString()").start_with?(base)
-        visit "#{base}/404"
-      end
-      page.execute_script("window.localStorage.clear()")
-    else
-      page.driver.restart if defined?(page.driver.restart)
-    end
-    Capybara.reset_sessions!
-  end
-
-  def accept_alert
-    if Capybara.current_driver == :selenium
-      (0..9).each do
-        begin
-          page.driver.browser.switch_to.alert.accept
-          break
-        rescue Selenium::WebDriver::Error::NoSuchAlertError
-         sleep 0.1
-        end
-      end
-    else
-      # poltergeist returns true for confirm, so no need to accept
-    end
-  end
-end
-
-def upload_data_and_get_collection(data, user, filename, owner_uuid=nil)
-  token = api_token(user)
-  datablock = `echo -n #{data.shellescape} | ARVADOS_API_TOKEN=#{token.shellescape} arv-put --no-progress --raw -`.strip
-  assert $?.success?, $?
-  col = nil
-  use_token user do
-    mtxt = ". #{datablock} 0:#{data.length}:#{filename}\n"
-    if owner_uuid
-      col = Collection.create(manifest_text: mtxt, owner_uuid: owner_uuid)
-    else
-      col = Collection.create(manifest_text: mtxt)
-    end
-  end
-  return col
-end
diff --git a/apps/workbench/test/integration_performance/collection_unit_test.rb b/apps/workbench/test/integration_performance/collection_unit_test.rb
deleted file mode 100644 (file)
index 3feef94..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-require 'helpers/manifest_examples'
-require 'helpers/time_block'
-
-class Blob
-end
-
-class BigCollectionTest < ActiveSupport::TestCase
-  include ManifestExamples
-
-  setup do
-    Blob.stubs(:sign_locator).returns 'd41d8cd98f00b204e9800998ecf8427e+0'
-  end
-
-  teardown do
-    Thread.current[:arvados_api_client] = nil
-  end
-
-  # You can try with compress=false here too, but at last check it
-  # didn't make a significant difference.
-  [true].each do |compress|
-    test "crud cycle for collection with big manifest (compress=#{compress})" do
-      Rails.configuration.Workbench.APIResponseCompression = compress
-      Thread.current[:arvados_api_client] = nil
-      crudtest
-    end
-  end
-
-  def crudtest
-    use_token :active
-    bigmanifest = time_block 'build example' do
-      make_manifest(streams: 100,
-                    files_per_stream: 100,
-                    blocks_per_file: 20,
-                    bytes_per_block: 0)
-    end
-    c = time_block "new (manifest size = #{bigmanifest.length>>20}MiB)" do
-      Collection.new manifest_text: bigmanifest
-    end
-    time_block 'create' do
-      c.save!
-    end
-    time_block 'read' do
-      Collection.find c.uuid
-    end
-    time_block 'read(cached)' do
-      Collection.find c.uuid
-    end
-    time_block 'list' do
-      list = Collection.select(['uuid', 'manifest_text']).filter [['uuid','=',c.uuid]]
-      assert_equal 1, list.count
-      assert_equal c.uuid, list.first.uuid
-      assert_not_nil list.first.manifest_text
-    end
-    time_block 'update(name-only)' do
-      manifest_text_length = c.manifest_text.length
-      c.update_attributes name: 'renamed during test case'
-      assert_equal c.manifest_text.length, manifest_text_length
-    end
-    time_block 'update' do
-      c.manifest_text += ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:empty.txt\n"
-      c.save!
-    end
-    time_block 'delete' do
-      c.destroy
-    end
-    time_block 'read(404)' do
-      assert_empty Collection.filter([['uuid','=',c.uuid]])
-    end
-  end
-end
diff --git a/apps/workbench/test/integration_performance/collections_controller_test.rb b/apps/workbench/test/integration_performance/collections_controller_test.rb
deleted file mode 100644 (file)
index 17dd9b6..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-require 'helpers/manifest_examples'
-require 'helpers/time_block'
-
-class Blob
-end
-
-class BigCollectionsControllerTest < ActionController::TestCase
-  include ManifestExamples
-
-  setup do
-    Blob.stubs(:sign_locator).returns 'd41d8cd98f00b204e9800998ecf8427e+0'
-  end
-
-  test "combine two big and two small collections" do
-    @controller = ActionsController.new
-    bigmanifest1 = time_block 'build example' do
-      make_manifest(streams: 100,
-                    files_per_stream: 100,
-                    blocks_per_file: 20,
-                    bytes_per_block: 0)
-    end
-    bigmanifest2 = bigmanifest1.gsub '.txt', '.txt2'
-    smallmanifest1 = ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:small1.txt\n"
-    smallmanifest2 = ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:small2.txt\n"
-    totalsize = bigmanifest1.length + bigmanifest2.length +
-      smallmanifest1.length + smallmanifest2.length
-    parts = time_block "create (total #{totalsize>>20}MiB)" do
-      use_token :active do
-        {
-          big1: Collection.create(manifest_text: bigmanifest1),
-          big2: Collection.create(manifest_text: bigmanifest2),
-          small1: Collection.create(manifest_text: smallmanifest1),
-          small2: Collection.create(manifest_text: smallmanifest2),
-        }
-      end
-    end
-    time_block 'combine' do
-      post :combine_selected_files_into_collection, {
-        selection: [parts[:big1].uuid,
-                    parts[:big2].uuid,
-                    parts[:small1].uuid + '/small1.txt',
-                    parts[:small2].uuid + '/small2.txt',
-                   ],
-        format: :html
-      }, session_for(:active)
-    end
-    assert_response :redirect
-  end
-
-  [:json, :html].each do |format|
-    test "show collection with big manifest (#{format})" do
-      bigmanifest = time_block 'build example' do
-        make_manifest(streams: 100,
-                      files_per_stream: 100,
-                      blocks_per_file: 20,
-                      bytes_per_block: 0)
-      end
-      @controller = CollectionsController.new
-      c = time_block "create (manifest size #{bigmanifest.length>>20}MiB)" do
-        use_token :active do
-          Collection.create(manifest_text: bigmanifest)
-        end
-      end
-      time_block 'show' do
-        get :show, {id: c.uuid, format: format}, session_for(:active)
-      end
-      assert_response :success
-    end
-  end
-end
diff --git a/apps/workbench/test/integration_performance/collections_perf_test.rb b/apps/workbench/test/integration_performance/collections_perf_test.rb
deleted file mode 100644 (file)
index c6dc3be..0000000
+++ /dev/null
@@ -1,120 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-# The tests in the "integration_performance" dir are not included in regular
-#   build pipeline since it is not one of the "standard" test directories.
-#
-# To run tests in this directory use the following command:
-# ./run-tests.sh WORKSPACE=~/arvados --only apps/workbench apps/workbench_test="TEST=test/integration_performance/*.rb"
-#
-
-class CollectionsPerfTest < ActionDispatch::IntegrationTest
-  setup do
-    Capybara.current_driver = :rack_test
-  end
-
-  def create_large_collection size, file_name_prefix
-    manifest_text = ". d41d8cd98f00b204e9800998ecf8427e+0"
-
-    i = 0
-    until manifest_text.length > size do
-      manifest_text << " 0:0:#{file_name_prefix}#{i.to_s}"
-      i += 1
-    end
-    manifest_text << "\n"
-
-    Rails.logger.info "Creating collection at #{Time.now.to_f}"
-    collection = Collection.create! ({manifest_text: manifest_text})
-    Rails.logger.info "Done creating collection at #{Time.now.to_f}"
-
-    collection
-  end
-
-  [
-    1000000,
-    10000000,
-    20000000,
-  ].each do |size|
-    test "Create and show large collection with manifest text of #{size}" do
-      use_token :active
-      new_collection = create_large_collection size, 'collection_file_name_with_prefix_'
-
-      Rails.logger.info "Visiting collection at #{Time.now.to_f}"
-      visit page_with_token('active', "/collections/#{new_collection.uuid}")
-      Rails.logger.info "Done visiting collection at #{Time.now.to_f}"
-
-      assert_selector "input[value=\"#{new_collection.uuid}\"]"
-      assert(page.has_link?('collection_file_name_with_prefix_0'), "Collection page did not include file link")
-    end
-  end
-
-  # This does not work with larger sizes because of need_javascript.
-  # Just use one test with 100,000 for now.
-  [
-    100000,
-  ].each do |size|
-    test "Create, show, and update description for large collection with manifest text of #{size}" do
-      need_javascript
-
-      use_token :active
-      new_collection = create_large_collection size, 'collection_file_name_with_prefix_'
-
-      Rails.logger.info "Visiting collection at #{Time.now.to_f}"
-      visit page_with_token('active', "/collections/#{new_collection.uuid}")
-      Rails.logger.info "Done visiting collection at #{Time.now.to_f}"
-
-      assert_selector "input[value=\"#{new_collection.uuid}\"]"
-      assert(page.has_link?('collection_file_name_with_prefix_0'), "Collection page did not include file link")
-
-      # edit description
-      Rails.logger.info "Editing description at #{Time.now.to_f}"
-      within('.arv-description-as-subtitle') do
-        find('.fa-pencil').click
-        find('.editable-input textarea').set('description for this large collection')
-        find('.editable-submit').click
-      end
-      Rails.logger.info "Done editing description at #{Time.now.to_f}"
-
-      assert_text 'description for this large collection'
-    end
-  end
-
-  [
-    [1000000, 10000],
-    [10000000, 10000],
-    [20000000, 10000],
-  ].each do |size1, size2|
-    test "Create one large collection of #{size1} and one small collection of #{size2} and combine them" do
-      use_token :active
-      first_collection = create_large_collection size1, 'collection_file_name_with_prefix_1_'
-      second_collection = create_large_collection size2, 'collection_file_name_with_prefix_2_'
-
-      Rails.logger.info "Visiting collections page at #{Time.now.to_f}"
-      visit page_with_token('active', "/collections")
-      Rails.logger.info "Done visiting collections page at at #{Time.now.to_f}"
-
-      assert_text first_collection.uuid
-      assert_text second_collection.uuid
-
-      within('tr', text: first_collection['uuid']) do
-        find('input[type=checkbox]').click
-      end
-
-      within('tr', text: second_collection['uuid']) do
-        find('input[type=checkbox]').click
-      end
-
-      Rails.logger.info "Clicking on combine collections option at #{Time.now.to_f}"
-      click_button 'Selection...'
-      within('.selection-action-container') do
-        click_link 'Create new collection with selected collections'
-      end
-      Rails.logger.info "Done combining collections at #{Time.now.to_f}"
-
-      assert(page.has_link?('collection_file_name_with_prefix_1_0'), "Collection page did not include file link")
-    end
-  end
-end
diff --git a/apps/workbench/test/mailers/.gitkeep b/apps/workbench/test/mailers/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/test/models/.gitkeep b/apps/workbench/test/models/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/test/performance/browsing_test.rb b/apps/workbench/test/performance/browsing_test.rb
deleted file mode 100644 (file)
index 71e4c5c..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# http://guides.rubyonrails.org/v3.2.13/performance_testing.html
-
-require 'test_helper'
-require 'rails/performance_test_help'
-require 'performance_test_helper'
-require 'selenium-webdriver'
-require 'headless'
-
-class BrowsingTest < WorkbenchPerformanceTest
-  self.profile_options = { :runs => 5,
-                           :metrics => [:wall_time],
-                           :output => 'tmp/performance',
-                           :formats => [:flat] }
-
-  setup do
-    need_javascript
-  end
-
-  test "home page" do
-    visit_page_with_token
-    assert_text 'Dashboard'
-    assert_selector 'a', text: 'Run a process'
-  end
-
-  test "search for hash" do
-    visit_page_with_token
-    assert_text 'Dashboard'
-
-    assert_selector '.navbar-fixed-top'
-    assert_triggers_dom_event 'shown.bs.modal' do
-      within '.navbar-fixed-top' do
-        find_field('search this site').set 'hash'
-        find('.glyphicon-search').click
-      end
-    end
-
-    sleep(50)
-
-    # In the search dialog now. Expect at least one item in the result display.
-    within '.modal-content' do
-      assert_text 'All projects'
-      assert_text 'Search'
-      assert_selector '.selectable[data-object-uuid]'
-      click_button 'Cancel'
-    end
-  end
-end
diff --git a/apps/workbench/test/performance_test_helper.rb b/apps/workbench/test/performance_test_helper.rb
deleted file mode 100644 (file)
index c3b50c2..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'integration_helper'
-
-# Performance test can run in two different ways:
-#
-# 1. Similar to other integration tests using the command:
-#     RAILS_ENV=test bundle exec rake test:benchmark
-#
-# 2. Against a configured workbench url using "RAILS_ENV=performance".
-#     RAILS_ENV=performance bundle exec rake test:benchmark
-
-class WorkbenchPerformanceTest < ActionDispatch::PerformanceTest
-
-  # When running in "RAILS_ENV=performance" mode, uses performance
-  # config params.  In this mode, prepends workbench URL to the given
-  # path provided, and visits that page using the configured
-  # "user_token".
-  def visit_page_with_token path='/'
-    if Rails.env == 'performance'
-      token = Rails.configuration.user_token
-      workbench_url = Rails.configuration.arvados_workbench_url
-      if workbench_url.end_with? '/'
-        workbench_url = workbench_url[0, workbench_url.size-1]
-      end
-    else
-      token = 'active'
-      workbench_url = ''
-    end
-
-    visit page_with_token(token, (workbench_url + path))
-  end
-
-end
diff --git a/apps/workbench/test/support/fake_websocket.js b/apps/workbench/test/support/fake_websocket.js
deleted file mode 100644 (file)
index 7f04a40..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-sockets = [];
-window.WebSocket = function(url) {
-    sockets.push(this);
-    window.setTimeout(function() {
-        sockets.map(function(s) {
-            s.onopen();
-        });
-        sockets.splice(0);
-    }, 1);
-}
-
-window.WebSocket.prototype.send = function(msg) {
-    // Uncomment for debugging:
-    // console.log("fake WebSocket: send: "+msg);
-}
diff --git a/apps/workbench/test/support/remove_file_api.js b/apps/workbench/test/support/remove_file_api.js
deleted file mode 100644 (file)
index 77dd643..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-window.FileReader = null;
diff --git a/apps/workbench/test/test_helper.rb b/apps/workbench/test/test_helper.rb
deleted file mode 100644 (file)
index 2e8ead9..0000000
+++ /dev/null
@@ -1,377 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-ENV["RAILS_ENV"] = "test" if (ENV["RAILS_ENV"] != "diagnostics" and ENV["RAILS_ENV"] != "performance")
-
-unless ENV["NO_COVERAGE_TEST"]
-  begin
-    require 'simplecov'
-    require 'simplecov-rcov'
-    class SimpleCov::Formatter::MergedFormatter
-      def format(result)
-        SimpleCov::Formatter::HTMLFormatter.new.format(result)
-        SimpleCov::Formatter::RcovFormatter.new.format(result)
-      end
-    end
-    SimpleCov.formatter = SimpleCov::Formatter::MergedFormatter
-    SimpleCov.start do
-      add_filter '/test/'
-      add_filter 'initializers/secret_token'
-    end
-  rescue Exception => e
-    $stderr.puts "SimpleCov unavailable (#{e}). Proceeding without."
-  end
-end
-
-require File.expand_path('../../config/environment', __FILE__)
-require 'rails/test_help'
-require 'mocha/minitest'
-
-class ActiveSupport::TestCase
-  # Setup all fixtures in test/fixtures/*.(yml|csv) for all tests in
-  # alphabetical order.
-  #
-  # Note: You'll currently still have to declare fixtures explicitly
-  # in integration tests -- they do not yet inherit this setting
-  fixtures :all
-  def use_token(token_name)
-    user_was = Thread.current[:user]
-    token_was = Thread.current[:arvados_api_token]
-    auth = api_fixture('api_client_authorizations')[token_name.to_s]
-    Thread.current[:arvados_api_token] = "v2/#{auth['uuid']}/#{auth['api_token']}"
-    if block_given?
-      begin
-        yield
-      ensure
-        Thread.current[:user] = user_was
-        Thread.current[:arvados_api_token] = token_was
-      end
-    end
-  end
-
-  teardown do
-    Thread.current[:arvados_api_token] = nil
-    Thread.current[:user] = nil
-    Thread.current[:reader_tokens] = nil
-    # Diagnostics suite doesn't run a server, so there's no cache to clear.
-    Rails.cache.clear unless (Rails.env == "diagnostics")
-    # Restore configuration settings changed during tests
-    self.class.reset_application_config
-  end
-
-  def self.reset_application_config
-    # Restore configuration settings changed during tests
-    ConfigLoader.copy_into_config $arvados_config, Rails.configuration
-    ConfigLoader.copy_into_config $remaining_config, Rails.configuration
-    Rails.configuration.Services.Controller.ExternalURL = URI("https://#{ENV['ARVADOS_API_HOST']}")
-    Rails.configuration.TLS.Insecure = true
-  end
-end
-
-module ApiFixtureLoader
-  def self.included(base)
-    base.extend(ClassMethods)
-  end
-
-  module ClassMethods
-    @@api_fixtures = {}
-    def api_fixture(name, *keys)
-      # Returns the data structure from the named API server test fixture.
-      @@api_fixtures[name] ||= \
-      begin
-        path = File.join(ApiServerForTests::ARV_API_SERVER_DIR,
-                         'test', 'fixtures', "#{name}.yml")
-        file = IO.read(path)
-        trim_index = file.index('# Test Helper trims the rest of the file')
-        file = file[0, trim_index] if trim_index
-        YAML.load(file).each do |name, ob|
-          ob.reject! { |k, v| k.start_with?('secret_') }
-        end
-      end
-      keys.inject(@@api_fixtures[name]) { |hash, key| hash[key] }.deep_dup
-    end
-  end
-
-  def api_fixture(name, *keys)
-    self.class.api_fixture(name, *keys)
-  end
-
-  def api_token(name)
-    auth = api_fixture('api_client_authorizations')[name]
-    "v2/#{auth['uuid']}/#{auth['api_token']}"
-  end
-
-  def find_fixture(object_class, name)
-    object_class.find(api_fixture(object_class.to_s.pluralize.underscore,
-                                  name, "uuid"))
-  end
-end
-
-module ApiMockHelpers
-  def fake_api_response body, status_code, headers
-    resp = mock
-    resp.responds_like_instance_of HTTP::Message
-    resp.stubs(:headers).returns headers
-    resp.stubs(:content).returns body
-    resp.stubs(:status_code).returns status_code
-    resp
-  end
-
-  def stub_api_calls_with_body body, status_code=200, headers={}
-    stub_api_calls
-    resp = fake_api_response body, status_code, headers
-    stub_api_client.stubs(:post).returns resp
-  end
-
-  def stub_api_calls
-    @stubbed_client = ArvadosApiClient.new
-    @stubbed_client.instance_eval do
-      @api_client = HTTPClient.new
-    end
-    ArvadosApiClient.stubs(:new_or_current).returns(@stubbed_client)
-  end
-
-  def stub_api_calls_with_invalid_json
-    stub_api_calls_with_body ']"omg,bogus"['
-  end
-
-  # Return the HTTPClient mock used by the ArvadosApiClient mock. You
-  # must have called stub_api_calls first.
-  def stub_api_client
-    @stubbed_client.instance_eval do
-      @api_client
-    end
-  end
-end
-
-class ActiveSupport::TestCase
-  include ApiMockHelpers
-end
-
-class ActiveSupport::TestCase
-  include ApiFixtureLoader
-  def session_for api_client_auth_name
-    auth = api_fixture('api_client_authorizations')[api_client_auth_name.to_s]
-    {
-      arvados_api_token: "v2/#{auth['uuid']}/#{auth['api_token']}"
-    }
-  end
-  def json_response
-    Oj.safe_load(@response.body)
-  end
-end
-
-class ApiServerForTests
-  PYTHON_TESTS_DIR = File.expand_path('../../../../sdk/python/tests', __FILE__)
-  ARV_API_SERVER_DIR = File.expand_path('../../../../services/api', __FILE__)
-  SERVER_PID_PATH = File.expand_path('tmp/pids/test-server.pid', ARV_API_SERVER_DIR)
-  WEBSOCKET_PID_PATH = File.expand_path('tmp/pids/test-server.pid', ARV_API_SERVER_DIR)
-  @main_process_pid = $$
-  @@server_is_running = false
-
-  def check_output *args
-    output = nil
-    Bundler.with_clean_env do
-      output = IO.popen *args do |io|
-        io.read
-      end
-      if not $?.success?
-        raise RuntimeError, "Command failed (#{$?}): #{args.inspect}"
-      end
-    end
-    output
-  end
-
-  def run_test_server
-    Dir.chdir PYTHON_TESTS_DIR do
-      check_output %w(python ./run_test_server.py start_keep)
-    end
-  end
-
-  def stop_test_server
-    Dir.chdir PYTHON_TESTS_DIR do
-      check_output %w(python ./run_test_server.py stop_keep)
-    end
-    @@server_is_running = false
-  end
-
-  def run args=[]
-    return if @@server_is_running
-
-    # Stop server left over from interrupted previous run
-    stop_test_server
-
-    ::MiniTest.after_run do
-      stop_test_server
-    end
-
-    run_test_server
-    ActiveSupport::TestCase.reset_application_config
-
-    @@server_is_running = true
-  end
-
-  def run_rake_task task_name, arg_string
-    Dir.chdir ARV_API_SERVER_DIR do
-      check_output ['bundle', 'exec', 'rake', "#{task_name}[#{arg_string}]"]
-    end
-  end
-end
-
-class ActionController::TestCase
-  setup do
-    @test_counter = 0
-  end
-
-  def check_counter action
-    @test_counter += 1
-    if @test_counter == 2
-      assert_equal 1, 2, "Multiple actions in controller test"
-    end
-  end
-
-  [:get, :post, :put, :patch, :delete].each do |method|
-    define_method method do |action, *args|
-      check_counter action
-      super action, *args
-    end
-  end
-end
-
-# Test classes can call reset_api_fixtures(when_to_reset,flag) to
-# override the default. Example:
-#
-# class MySuite < ActionDispatch::IntegrationTest
-#   reset_api_fixtures :after_each_test, false
-#   reset_api_fixtures :after_suite, true
-#   ...
-# end
-#
-# The default behavior is reset_api_fixtures(:after_each_test,true).
-#
-class ActiveSupport::TestCase
-
-  def self.inherited subclass
-    subclass.class_eval do
-      class << self
-        attr_accessor :want_reset_api_fixtures
-      end
-      @want_reset_api_fixtures = {
-        after_each_test: true,
-        after_suite: false,
-        before_suite: false,
-      }
-    end
-    super
-  end
-  # Existing subclasses of ActiveSupport::TestCase (ones that already
-  # existed before we set up the self.inherited hook above) will not
-  # get their own instance variable. They're not real test cases
-  # anyway, so we give them a "don't reset anywhere" stub.
-  def self.want_reset_api_fixtures
-    {}
-  end
-
-  def self.reset_api_fixtures where, t=true
-    if not want_reset_api_fixtures.has_key? where
-      raise ArgumentError, "There is no #{where.inspect} hook"
-    end
-    self.want_reset_api_fixtures[where] = t
-  end
-
-  def self.run *args
-    reset_api_fixtures_now if want_reset_api_fixtures[:before_suite]
-    result = super
-    reset_api_fixtures_now if want_reset_api_fixtures[:after_suite]
-    result
-  end
-
-  def after_teardown
-    if self.class.want_reset_api_fixtures[:after_each_test] and
-        (!defined?(@want_reset_api_fixtures) or @want_reset_api_fixtures != false)
-      self.class.reset_api_fixtures_now
-    end
-    super
-  end
-
-  def reset_api_fixtures_after_test t=true
-    @want_reset_api_fixtures = t
-  end
-
-  protected
-  def self.reset_api_fixtures_now
-    # Never try to reset fixtures when we're just using test
-    # infrastructure to run performance/diagnostics suites.
-    return unless Rails.env == 'test'
-
-    auth = api_fixture('api_client_authorizations')['admin_trustedclient']
-    Thread.current[:arvados_api_token] = "v2/#{auth['uuid']}/#{auth['api_token']}"
-    ArvadosApiClient.new.api(nil, '../../database/reset', {})
-    Thread.current[:arvados_api_token] = nil
-  end
-end
-
-# If it quacks like a duck, it must be a HTTP request object.
-class RequestDuck
-  def self.host
-    "localhost"
-  end
-
-  def self.port
-    8080
-  end
-
-  def self.protocol
-    "http"
-  end
-end
-
-# Example:
-#
-# apps/workbench$ RAILS_ENV=test bundle exec irb -Ilib:test
-# > load 'test/test_helper.rb'
-# > singletest 'integration/collection_upload_test.rb', 'Upload two empty files'
-#
-def singletest test_class_file, test_name
-  load File.join('test', test_class_file)
-  Minitest.run ['-v', '-n', "test_#{test_name.gsub ' ', '_'}"]
-  Object.send(:remove_const,
-              test_class_file.gsub(/.*\/|\.rb$/, '').camelize.to_sym)
-  ::Minitest::Runnable.runnables.reject! { true }
-end
-
-if ENV["RAILS_ENV"].eql? 'test'
-  ApiServerForTests.new.run
-  ApiServerForTests.new.run ["--websockets"]
-end
-
-# Reset fixtures now (i.e., before any tests run).
-ActiveSupport::TestCase.reset_api_fixtures_now
-
-module Minitest
-  class Test
-    def capture_exceptions *args
-      begin
-        n = 0
-        begin
-          yield
-        rescue *PASSTHROUGH_EXCEPTIONS
-          raise
-        rescue Exception => e
-          n += 1
-          raise if n > 2 || e.is_a?(Skip)
-          STDERR.puts "Test failed, retrying (##{n})"
-          ActiveSupport::TestCase.reset_api_fixtures_now
-          retry
-        end
-      rescue *PASSTHROUGH_EXCEPTIONS
-        raise
-      rescue Assertion => e
-        self.failures << e
-      rescue Exception => e
-        self.failures << UnexpectedError.new(e)
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/unit/.gitkeep b/apps/workbench/test/unit/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/test/unit/arvados_api_client_test.rb b/apps/workbench/test/unit/arvados_api_client_test.rb
deleted file mode 100644 (file)
index 6d071de..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ArvadosApiClientTest < ActiveSupport::TestCase
-  # We use a mock instead of making real API calls, so there's no need to reset.
-  reset_api_fixtures :after_each_test, false
-
-  test 'successful stubbed api request' do
-    stub_api_calls_with_body '{"foo":"bar","baz":0}'
-    use_token :active
-    resp = ArvadosApiClient.new_or_current.api Link, ''
-    assert_equal Hash, resp.class
-    assert_equal 'bar', resp[:foo]
-    assert_equal 0, resp[:baz]
-  end
-
-  test 'exception if server returns non-JSON' do
-    stub_api_calls_with_invalid_json
-    assert_raises ArvadosApiClient::InvalidApiResponseException do
-      use_token :active
-      resp = ArvadosApiClient.new_or_current.api Link, ''
-    end
-  end
-end
diff --git a/apps/workbench/test/unit/arvados_base_test.rb b/apps/workbench/test/unit/arvados_base_test.rb
deleted file mode 100644 (file)
index d0942dc..0000000
+++ /dev/null
@@ -1,91 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ArvadosBaseTest < ActiveSupport::TestCase
-  test '#save does not send unchanged string attributes' do
-    use_token :active do
-      fixture = api_fixture("collections")["foo_collection_in_aproject"]
-      c = Collection.find(fixture['uuid'])
-
-      new_name = 'name changed during test'
-
-      got_query = nil
-      stub_api_calls
-      stub_api_client.expects(:post).with do |url, query, opts={}|
-        got_query = query
-        true
-      end.returns fake_api_response('{}', 200, {})
-      c.name = new_name
-      c.save
-
-      updates = JSON.parse got_query['collection']
-      assert_equal updates['name'], new_name
-      refute_includes updates, 'description'
-      refute_includes updates, 'manifest_text'
-    end
-  end
-
-  test '#save does not send unchanged attributes missing because of select' do
-    use_token :active do
-      fixture = api_fixture("collections")["foo_collection_in_aproject"]
-      c = Collection.
-        filter([['uuid','=',fixture['uuid']]]).
-        select(['uuid']).
-        first
-      if 'MissingAttribute check is re-enabled' == true
-        assert_raises ActiveModel::MissingAttributeError do
-          c.properties
-        end
-      else
-        assert_equal({}, c.properties)
-      end
-
-      got_query = nil
-      stub_api_calls
-      stub_api_client.expects(:post).with do |url, query, opts={}|
-        got_query = query
-        true
-      end.returns fake_api_response('{}', 200, {})
-      c.name = 'foo'
-      c.save
-
-      updates = JSON.parse got_query['collection']
-      assert_includes updates, 'name'
-      refute_includes updates, 'description'
-      refute_includes updates, 'properties'
-    end
-  end
-
-  [false,
-   {},
-   {'foo' => 'bar'},
-  ].each do |init_props|
-    test "#save sends serialized attributes if changed from #{init_props}" do
-      use_token :active do
-        fixture = api_fixture("collections")["foo_collection_in_aproject"]
-        c = Collection.find(fixture['uuid'])
-
-        if init_props
-          c.properties = init_props if init_props
-          c.save!
-        end
-
-        got_query = nil
-        stub_api_calls
-        stub_api_client.expects(:post).with do |url, query, opts={}|
-          got_query = query
-          true
-        end.returns fake_api_response('{"etag":"fake","uuid":"fake"}', 200, {})
-
-        c.properties['baz'] = 'qux'
-        c.save!
-
-        updates = JSON.parse got_query['collection']
-        assert_includes updates, 'properties'
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/unit/arvados_resource_list_test.rb b/apps/workbench/test/unit/arvados_resource_list_test.rb
deleted file mode 100644 (file)
index 270b962..0000000
+++ /dev/null
@@ -1,122 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ResourceListTest < ActiveSupport::TestCase
-
-  reset_api_fixtures :after_each_test, false
-
-  test 'links_for on a resource list that does not return links' do
-    use_token :active
-    results = Specimen.all
-    assert_equal [], results.links_for(api_fixture('users')['active']['uuid'])
-  end
-
-  test 'get all items by default' do
-    use_token :admin
-    a = 0
-    Collection.where(owner_uuid: 'zzzzz-j7d0g-0201collections').each do
-      a += 1
-    end
-    assert_equal 201, a
-  end
-
-  test 'prefetch all items' do
-    use_token :admin
-    a = 0
-    Collection.where(owner_uuid: 'zzzzz-j7d0g-0201collections').each do
-      a += 1
-    end
-    assert_equal 201, a
-  end
-
-  test 'get limited items' do
-    use_token :admin
-    a = 0
-    Collection.where(owner_uuid: 'zzzzz-j7d0g-0201collections').limit(51).each do
-      a += 1
-    end
-    assert_equal 51, a
-  end
-
-  test 'get limited items, limit % page_size != 0' do
-    skip "Requires server MAX_LIMIT < 200 which is not currently the default"
-
-    use_token :admin
-    max_page_size = Collection.
-      where(owner_uuid: 'zzzzz-j7d0g-0201collections').
-      limit(1000000000).
-      fetch_multiple_pages(false).
-      count
-    # Conditions necessary for this test to be valid:
-    assert_operator 200, :>, max_page_size
-    assert_operator 1, :<, max_page_size
-    # Verify that the server really sends max_page_size when asked for max_page_size+1
-    assert_equal max_page_size, Collection.
-      where(owner_uuid: 'zzzzz-j7d0g-0201collections').
-      limit(max_page_size+1).
-      fetch_multiple_pages(false).
-      results.
-      count
-    # Now that we know the max_page_size+1 is in the middle of page 2,
-    # make sure #each returns page 1 and only the requested part of
-    # page 2.
-    a = 0
-    saw_uuid = {}
-    Collection.where(owner_uuid: 'zzzzz-j7d0g-0201collections').limit(max_page_size+1).each do |item|
-      a += 1
-      saw_uuid[item.uuid] = true
-    end
-    assert_equal max_page_size+1, a
-    # Ensure no overlap between pages
-    assert_equal max_page_size+1, saw_uuid.size
-  end
-
-  test 'get single page of items' do
-    use_token :admin
-    a = 0
-    c = Collection.where(owner_uuid: 'zzzzz-j7d0g-0201collections').fetch_multiple_pages(false)
-    c.each do
-      a += 1
-    end
-
-    assert_operator a, :<, 201
-    assert_equal c.result_limit, a
-  end
-
-  test 'get empty set' do
-    use_token :admin
-    c = Collection.
-      where(owner_uuid: 'doesn-texis-tdoesntexistdoe').
-      fetch_multiple_pages(false)
-    # Important: check c.result_offset before calling c.results here.
-    assert_equal 0, c.result_offset
-    assert_equal 0, c.items_available
-    assert_empty c.results
-  end
-
-  test 'count=none' do
-    use_token :active
-    c = Collection.with_count('none')
-    assert_nil c.items_available
-    refute_empty c.results
-  end
-
-  test 'cache results across each(&block) calls' do
-    use_token :admin
-    c = Collection.where(owner_uuid: 'zzzzz-j7d0g-0201collections').with_count('none')
-    c.each do |x|
-      x.description = 'foo'
-    end
-    found = 0
-    c.each do |x|
-      found += 1
-      # We should get the same objects we modified in the loop above
-      # -- not new objects built from another set of API responses.
-      assert_equal 'foo', x.description
-    end
-    assert_equal 201, found
-  end
-end
diff --git a/apps/workbench/test/unit/collection_test.rb b/apps/workbench/test/unit/collection_test.rb
deleted file mode 100644 (file)
index 870f8ba..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class CollectionTest < ActiveSupport::TestCase
-  test 'recognize empty blob locator' do
-    ['d41d8cd98f00b204e9800998ecf8427e+0',
-     'd41d8cd98f00b204e9800998ecf8427e',
-     'd41d8cd98f00b204e9800998ecf8427e+0+Xyzzy'].each do |x|
-      assert_equal true, Collection.is_empty_blob_locator?(x)
-    end
-    ['d41d8cd98f00b204e9800998ecf8427e0',
-     'acbd18db4cc2f85cedef654fccc4a4d8+3',
-     'acbd18db4cc2f85cedef654fccc4a4d8+0'].each do |x|
-      assert_equal false, Collection.is_empty_blob_locator?(x)
-    end
-  end
-
-  def get_files_tree(coll_name)
-    use_token :admin
-    Collection.find(api_fixture('collections')[coll_name]['uuid']).files_tree
-  end
-
-  test "easy files_tree" do
-    files_in = lambda do |dirname|
-      (1..3).map { |n| [dirname, "file#{n}", 0] }
-    end
-    assert_equal([['.', 'dir1', nil], ['./dir1', 'subdir', nil]] +
-                 files_in['./dir1/subdir'] + files_in['./dir1'] +
-                 [['.', 'dir2', nil]] + files_in['./dir2'] + files_in['.'],
-                 get_files_tree('multilevel_collection_1'),
-                 "Collection file tree was malformed")
-  end
-
-  test "files_tree with files deep in subdirectories" do
-    # This test makes sure files_tree generates synthetic directory entries.
-    # The manifest doesn't list directories with no files.
-    assert_equal([['.', 'dir1', nil], ['./dir1', 'sub1', nil],
-                  ['./dir1/sub1', 'a', 0], ['./dir1/sub1', 'b', 0],
-                  ['.', 'dir2', nil], ['./dir2', 'sub2', nil],
-                  ['./dir2/sub2', 'c', 0], ['./dir2/sub2', 'd', 0]],
-                 get_files_tree('multilevel_collection_2'),
-                 "Collection file tree was malformed")
-  end
-
-  test "portable_data_hash never editable" do
-    refute(Collection.new.attribute_editable?("portable_data_hash", :ever))
-  end
-
-  test "admin can edit name" do
-    use_token :admin
-    assert(find_fixture(Collection, "foo_file").attribute_editable?("name"),
-           "admin not allowed to edit collection name")
-  end
-
-  test "project owner can edit name" do
-    use_token :active
-    assert(find_fixture(Collection, "foo_collection_in_aproject")
-             .attribute_editable?("name"),
-           "project owner not allowed to edit collection name")
-  end
-
-  test "project admin can edit name" do
-    use_token :subproject_admin
-    assert(find_fixture(Collection, "baz_file_in_asubproject")
-             .attribute_editable?("name"),
-           "project admin not allowed to edit collection name")
-  end
-
-  test "project viewer cannot edit name" do
-    use_token :project_viewer
-    refute(find_fixture(Collection, "foo_collection_in_aproject")
-             .attribute_editable?("name"),
-           "project viewer allowed to edit collection name")
-  end
-end
diff --git a/apps/workbench/test/unit/disabled_api_test.rb b/apps/workbench/test/unit/disabled_api_test.rb
deleted file mode 100644 (file)
index 54e7c08..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class DisabledApiTest < ActiveSupport::TestCase
-  test 'Job.creatable? is false' do
-    use_token(:active) do
-      refute(Job.creatable?)
-    end
-  end
-end
diff --git a/apps/workbench/test/unit/group_test.rb b/apps/workbench/test/unit/group_test.rb
deleted file mode 100644 (file)
index 7040f97..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class GroupTest < ActiveSupport::TestCase
-  test "get contents with names" do
-    use_token :active
-    oi = Group.
-      find(api_fixture('groups')['asubproject']['uuid']).
-      contents()
-    assert_operator(0, :<, oi.count,
-                    "Expected to find some items belonging to :active user")
-    assert_operator(0, :<, oi.items_available,
-                    "Expected contents response to have items_available > 0")
-    oi_uuids = oi.collect { |i| i['uuid'] }
-
-    expect_uuid = api_fixture('specimens')['in_asubproject']['uuid']
-    assert_includes(oi_uuids, expect_uuid,
-                    "Expected '#{expect_uuid}' in asubproject's contents")
-  end
-
-  test "can select specific group columns" do
-    use_token :admin
-    Group.select(["uuid", "name"]).limit(5).each do |user|
-      assert_not_nil user.uuid
-      assert_not_nil user.name
-      assert_nil user.owner_uuid
-    end
-  end
-
-  test "project editable by its admin" do
-    use_token :subproject_admin
-    project = Group.find(api_fixture("groups")["asubproject"]["uuid"])
-    assert(project.editable?, "project not editable by admin")
-  end
-
-  test "project not editable by reader" do
-    use_token :project_viewer
-    project = Group.find(api_fixture("groups")["aproject"]["uuid"])
-    refute(project.editable?, "project editable by reader")
-  end
-end
diff --git a/apps/workbench/test/unit/helpers/api_client_authorizations_helper_test.rb b/apps/workbench/test/unit/helpers/api_client_authorizations_helper_test.rb
deleted file mode 100644 (file)
index 01ed430..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ApiClientAuthorizationsHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/authorized_keys_helper_test.rb b/apps/workbench/test/unit/helpers/authorized_keys_helper_test.rb
deleted file mode 100644 (file)
index 010a0fe..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class AuthorizedKeysHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/collections_helper_test.rb b/apps/workbench/test/unit/helpers/collections_helper_test.rb
deleted file mode 100644 (file)
index 15e2a94..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class CollectionsHelperTest < ActionView::TestCase
-  test "file_path generates short names" do
-    assert_equal('foo', CollectionsHelper.file_path(['.', 'foo', 0]),
-                 "wrong result for filename in collection root")
-    assert_equal('foo/bar', CollectionsHelper.file_path(['foo', 'bar', 0]),
-                 "wrong result for filename in directory without leading .")
-    assert_equal('foo/bar', CollectionsHelper.file_path(['./foo', 'bar', 0]),
-                 "wrong result for filename in directory with leading .")
-  end
-end
diff --git a/apps/workbench/test/unit/helpers/groups_helper_test.rb b/apps/workbench/test/unit/helpers/groups_helper_test.rb
deleted file mode 100644 (file)
index 1bde02e..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ProjectsHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/humans_helper_test.rb b/apps/workbench/test/unit/helpers/humans_helper_test.rb
deleted file mode 100644 (file)
index 22f9e81..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class HumansHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/javascript_helper_test.rb b/apps/workbench/test/unit/helpers/javascript_helper_test.rb
deleted file mode 100644 (file)
index 9d5a553..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-# Tests XSS vulnerability monkeypatch
-# See: https://github.com/advisories/GHSA-65cv-r6x7-79hv
-class JavascriptHelperTest < ActionView::TestCase
-  def test_escape_backtick
-    assert_equal "\\`", escape_javascript("`")
-  end
-
-  def test_escape_dollar_sign
-    assert_equal "\\$", escape_javascript("$")
-  end
-end
diff --git a/apps/workbench/test/unit/helpers/job_tasks_helper_test.rb b/apps/workbench/test/unit/helpers/job_tasks_helper_test.rb
deleted file mode 100644 (file)
index af0302c..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class JobTasksHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/jobs_helper_test.rb b/apps/workbench/test/unit/helpers/jobs_helper_test.rb
deleted file mode 100644 (file)
index 9d64b7d..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class JobsHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/keep_disks_helper_test.rb b/apps/workbench/test/unit/helpers/keep_disks_helper_test.rb
deleted file mode 100644 (file)
index 9dcc619..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class KeepDisksHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/links_helper_test.rb b/apps/workbench/test/unit/helpers/links_helper_test.rb
deleted file mode 100644 (file)
index 2d84ea6..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class MetadataHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/logs_helper_test.rb b/apps/workbench/test/unit/helpers/logs_helper_test.rb
deleted file mode 100644 (file)
index 616f6e6..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class LogsHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/nodes_helper_test.rb b/apps/workbench/test/unit/helpers/nodes_helper_test.rb
deleted file mode 100644 (file)
index 8a92eb9..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class NodesHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/pipeline_instances_helper_test.rb b/apps/workbench/test/unit/helpers/pipeline_instances_helper_test.rb
deleted file mode 100644 (file)
index 9d3b5c4..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class PipelineInstancesHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/pipeline_templates_helper_test.rb b/apps/workbench/test/unit/helpers/pipeline_templates_helper_test.rb
deleted file mode 100644 (file)
index 3d3406d..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class PipelineTemplatesHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/projects_helper_test.rb b/apps/workbench/test/unit/helpers/projects_helper_test.rb
deleted file mode 100644 (file)
index 1bde02e..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class ProjectsHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/repositories_helper_test.rb b/apps/workbench/test/unit/helpers/repositories_helper_test.rb
deleted file mode 100644 (file)
index 33cb590..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class RepositoriesHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/sessions_helper_test.rb b/apps/workbench/test/unit/helpers/sessions_helper_test.rb
deleted file mode 100644 (file)
index 98467f9..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class SessionsHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/specimens_helper_test.rb b/apps/workbench/test/unit/helpers/specimens_helper_test.rb
deleted file mode 100644 (file)
index 3709198..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class SpecimensHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/traits_helper_test.rb b/apps/workbench/test/unit/helpers/traits_helper_test.rb
deleted file mode 100644 (file)
index 03b6a97..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class TraitsHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/user_agreements_helper_test.rb b/apps/workbench/test/unit/helpers/user_agreements_helper_test.rb
deleted file mode 100644 (file)
index 3e9a6b9..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class UserAgreementsHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/users_helper_test.rb b/apps/workbench/test/unit/helpers/users_helper_test.rb
deleted file mode 100644 (file)
index 808736d..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class UsersHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/helpers/virtual_machines_helper_test.rb b/apps/workbench/test/unit/helpers/virtual_machines_helper_test.rb
deleted file mode 100644 (file)
index 99fc258..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class VirtualMachinesHelperTest < ActionView::TestCase
-end
diff --git a/apps/workbench/test/unit/job_test.rb b/apps/workbench/test/unit/job_test.rb
deleted file mode 100644 (file)
index 85d2ef3..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class JobTest < ActiveSupport::TestCase
-  test "admin can edit description" do
-    use_token :admin
-    assert(find_fixture(Job, "job_in_subproject")
-             .attribute_editable?("description"),
-           "admin not allowed to edit job description")
-  end
-
-  test "project owner can edit description" do
-    use_token :active
-    assert(find_fixture(Job, "job_in_subproject")
-             .attribute_editable?("description"),
-           "project owner not allowed to edit job description")
-  end
-
-  test "project admin can edit description" do
-    use_token :subproject_admin
-    assert(find_fixture(Job, "job_in_subproject")
-             .attribute_editable?("description"),
-           "project admin not allowed to edit job description")
-  end
-
-  test "project viewer cannot edit description" do
-    use_token :project_viewer
-    refute(find_fixture(Job, "job_in_subproject")
-             .attribute_editable?("description"),
-           "project viewer allowed to edit job description")
-  end
-end
diff --git a/apps/workbench/test/unit/link_test.rb b/apps/workbench/test/unit/link_test.rb
deleted file mode 100644 (file)
index 9fbf98d..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class LinkTest < ActiveSupport::TestCase
-
-  reset_api_fixtures :after_each_test, false
-
-  def uuid_for(fixture_name, object_name)
-    api_fixture(fixture_name)[object_name]["uuid"]
-  end
-
-  test "active user can get permissions for owned project object" do
-    use_token :active
-    project = Group.find(uuid_for("groups", "aproject"))
-    refute_empty(Link.permissions_for(project),
-                 "no permissions found for managed project")
-  end
-
-  test "active user can get permissions for owned project by UUID" do
-    use_token :active
-    refute_empty(Link.permissions_for(uuid_for("groups", "aproject")),
-                 "no permissions found for managed project")
-  end
-
-  test "admin can get permissions for project object" do
-    use_token :admin
-    project = Group.find(uuid_for("groups", "aproject"))
-    refute_empty(Link.permissions_for(project),
-                 "no permissions found for managed project")
-  end
-
-  test "admin can get permissions for project by UUID" do
-    use_token :admin
-    refute_empty(Link.permissions_for(uuid_for("groups", "aproject")),
-                 "no permissions found for managed project")
-  end
-
-  test "project viewer can't get permissions for readable project object" do
-    use_token :project_viewer
-    project = Group.find(uuid_for("groups", "aproject"))
-    assert_raises(ArvadosApiClient::AccessForbiddenException) do
-      Link.permissions_for(project)
-    end
-  end
-
-  test "project viewer can't get permissions for readable project by UUID" do
-    use_token :project_viewer
-    assert_raises(ArvadosApiClient::AccessForbiddenException) do
-      Link.permissions_for(uuid_for("groups", "aproject"))
-    end
-  end
-end
diff --git a/apps/workbench/test/unit/pipeline_instance_test.rb b/apps/workbench/test/unit/pipeline_instance_test.rb
deleted file mode 100644 (file)
index 3ff3fcf..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class PipelineInstanceTest < ActiveSupport::TestCase
-
-  reset_api_fixtures :after_each_test, false
-
-  def find_pi_with(token_name, pi_name)
-    use_token token_name
-    find_fixture(PipelineInstance, pi_name)
-  end
-
-  def attribute_editable_for?(token_name, pi_name, attr_name, ever=nil)
-    find_pi_with(token_name, pi_name).attribute_editable?(attr_name, ever)
-  end
-
-  test "admin can edit name" do
-    assert(attribute_editable_for?(:admin, "new_pipeline_in_subproject",
-                                   "name"),
-           "admin not allowed to edit pipeline instance name")
-  end
-
-  test "project owner can edit name" do
-    assert(attribute_editable_for?(:active, "new_pipeline_in_subproject",
-                                   "name"),
-           "project owner not allowed to edit pipeline instance name")
-  end
-
-  test "project admin can edit name" do
-    assert(attribute_editable_for?(:subproject_admin,
-                                   "new_pipeline_in_subproject", "name"),
-           "project admin not allowed to edit pipeline instance name")
-  end
-
-  test "project viewer cannot edit name" do
-    refute(attribute_editable_for?(:project_viewer,
-                                   "new_pipeline_in_subproject", "name"),
-           "project viewer allowed to edit pipeline instance name")
-  end
-
-  test "name editable on completed pipeline" do
-    assert(attribute_editable_for?(:active, "has_component_with_completed_jobs",
-                                   "name"),
-           "name not editable on complete pipeline")
-  end
-
-  test "components editable on new pipeline" do
-    assert(attribute_editable_for?(:active, "new_pipeline", "components"),
-           "components not editable on new pipeline")
-  end
-
-  test "components not editable on completed pipeline" do
-    refute(attribute_editable_for?(:active, "has_component_with_completed_jobs",
-                                   "components"),
-           "components not editable on new pipeline")
-  end
-
-  test "job_logs for partially complete pipeline" do
-    log_uuid = api_fixture("collections", "real_log_collection", "uuid")
-    pi = find_pi_with(:active, "running_pipeline_with_complete_job")
-    assert_equal({previous: log_uuid, running: nil}, pi.job_log_ids)
-  end
-
-  test "job_logs for complete pipeline" do
-    log_uuid = api_fixture("collections", "real_log_collection", "uuid")
-    pi = find_pi_with(:active, "complete_pipeline_with_two_jobs")
-    assert_equal({ancient: log_uuid, previous: log_uuid}, pi.job_log_ids)
-  end
-
-  test "job_logs for malformed pipeline" do
-    pi = find_pi_with(:active, "components_is_jobspec")
-    assert_empty(pi.job_log_ids.select { |_, log| not log.nil? })
-  end
-
-  def check_stderr_logs(token_name, pi_name, log_name)
-    pi = find_pi_with(token_name, pi_name)
-    actual_logs = pi.stderr_log_lines
-    expected_text = api_fixture("logs", log_name, "properties", "text")
-    expected_text.each_line do |log_line|
-      assert_includes(actual_logs, log_line.chomp)
-    end
-  end
-
-  test "stderr_logs for running pipeline" do
-    check_stderr_logs(:active,
-                      "pipeline_in_publicly_accessible_project",
-                      "log_line_for_pipeline_in_publicly_accessible_project")
-  end
-
-  test "stderr_logs for job in complete pipeline" do
-    check_stderr_logs(:active,
-                      "failed_pipeline_with_two_jobs",
-                      "crunchstat_for_previous_job")
-  end
-
-  test "has_readable_logs? for unrun pipeline" do
-    pi = find_pi_with(:active, "new_pipeline")
-    refute(pi.has_readable_logs?)
-  end
-
-  test "has_readable_logs? for running pipeline" do
-    pi = find_pi_with(:active, "running_pipeline_with_complete_job")
-    assert(pi.has_readable_logs?)
-  end
-
-  test "has_readable_logs? for complete pipeline" do
-    pi = find_pi_with(:active, "pipeline_in_publicly_accessible_project_but_other_objects_elsewhere")
-    assert(pi.has_readable_logs?)
-  end
-
-  test "has_readable_logs? for complete pipeline when jobs unreadable" do
-    pi = find_pi_with(:anonymous, "pipeline_in_publicly_accessible_project_but_other_objects_elsewhere")
-    refute(pi.has_readable_logs?)
-  end
-end
diff --git a/apps/workbench/test/unit/repository_test.rb b/apps/workbench/test/unit/repository_test.rb
deleted file mode 100644 (file)
index d62d02b..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class RepositoryTest < ActiveSupport::TestCase
-  [
-    ['admin', true],
-    ['active', false],
-  ].each do |user, can_edit|
-    test "#{user} can edit attributes #{can_edit}" do
-      use_token user
-      attrs = Repository.new.editable_attributes
-      if can_edit
-        refute_empty attrs
-      else
-        assert_empty attrs
-      end
-    end
-  end
-end
diff --git a/apps/workbench/test/unit/user_test.rb b/apps/workbench/test/unit/user_test.rb
deleted file mode 100644 (file)
index a73e506..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class UserTest < ActiveSupport::TestCase
-  test "can select specific user columns" do
-    use_token :admin
-    User.select(["uuid", "is_active"]).limit(5).each do |user|
-      assert_not_nil user.uuid
-      assert_not_nil user.is_active
-      assert_nil user.first_name
-    end
-  end
-
-  test "User.current doesn't return anonymous user when using invalid token" do
-    # Set up anonymous user token
-    Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-    # First, try with a valid user
-    use_token :active
-    u = User.current
-    assert(find_fixture(User, "active").uuid == u.uuid)
-    # Next, simulate an invalid token
-    Thread.current[:arvados_api_token] = 'thistokenwontwork'
-    assert_raises(ArvadosApiClient::NotLoggedInException) do
-      User.current
-    end
-  end
-end
diff --git a/apps/workbench/test/unit/work_unit_test.rb b/apps/workbench/test/unit/work_unit_test.rb
deleted file mode 100644 (file)
index 4e5ad39..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class WorkUnitTest < ActiveSupport::TestCase
-
-  reset_api_fixtures :after_each_test, false
-
-  setup do
-    Rails.configuration.Users.AnonymousUserToken = api_fixture('api_client_authorizations')['anonymous']['api_token']
-  end
-
-  [
-    [Job, 'running_job_with_components', "jwu", 2, "Running", nil, 0.5],
-    [PipelineInstance, 'pipeline_in_running_state', nil, 1, "Running", nil, 0.0],
-    [PipelineInstance, 'has_component_with_completed_jobs', nil, 3, "Complete", true, 1.0],
-    [PipelineInstance, 'pipeline_with_tagged_collection_input', "pwu", 1, "Ready", nil, 0.0],
-    [PipelineInstance, 'failed_pipeline_with_two_jobs', nil, 2, "Cancelled", false, 0.0],
-    [Container, 'requester', 'cwu', 1, "Complete", true, 1.0],
-    [ContainerRequest, 'cr_for_requester', 'cwu', 1, "Complete", true, 1.0],
-    [ContainerRequest, 'queued', 'cwu', 0, "Queued", nil, 0.0],   # priority 1
-    [ContainerRequest, 'canceled_with_queued_container', 'cwu', 0, "Cancelled", false, 0.0],
-    [ContainerRequest, 'canceled_with_locked_container', 'cwu', 0, "Cancelled", false, 0.0],
-    [ContainerRequest, 'canceled_with_running_container', 'cwu', 1, "Running", nil, 0.0],
-  ].each do |type, fixture, label, num_children, state, success, progress|
-    test "children of #{fixture}" do
-      use_token 'active'
-      obj = find_fixture(type, fixture)
-      wu = obj.work_unit(label)
-
-      if label != nil
-        assert_equal(label, wu.label)
-      elsif obj.name.nil?
-        assert_nil(wu.label)
-      else
-        assert_equal(obj.name, wu.label)
-      end
-      assert_equal(obj['uuid'], wu.uuid)
-      assert_equal(state, wu.state_label)
-      if success.nil?
-        assert_nil(wu.success?)
-      else
-        assert_equal(success, wu.success?)
-      end
-      assert_equal(progress, wu.progress)
-
-      assert_equal(num_children, wu.children.size)
-      wu.children.each do |child|
-        assert_equal(true, child.respond_to?(:script))
-      end
-    end
-  end
-
-  [
-    ['cr_for_failed', 'Failed', 33],
-    ['completed', 'Complete', 0],
-  ].each do |cr_fixture, state, exit_code|
-    test "Completed ContainerRequest state = #{state} with exit_code = #{exit_code}" do
-      use_token 'active'
-      obj = find_fixture(ContainerRequest, cr_fixture)
-      wu = obj.work_unit
-      assert_equal state, wu.state_label
-      assert_equal exit_code, wu.exit_code
-    end
-  end
-
-  [
-    [Job, 'running_job_with_components', 1, 1, nil, true],
-    [Job, 'queued', nil, 0, 1, false],
-    [PipelineInstance, 'pipeline_in_running_state', 1, 1, nil, false],
-    [PipelineInstance, 'has_component_with_completed_jobs', 60, 60, nil, true],
-  ].each do |type, fixture, walltime, cputime, queuedtime, cputime_more_than_walltime|
-    test "times for #{fixture}" do
-      use_token 'active'
-      obj = find_fixture(type, fixture)
-      wu = obj.work_unit
-
-      if walltime
-        assert_equal true, (wu.walltime >= walltime)
-      else
-        if walltime.nil?
-          assert_nil wu.walltime
-        else
-          assert_equal walltime, wu.walltime
-        end
-      end
-
-      if cputime
-        assert_equal true, (wu.cputime >= cputime)
-      else
-        assert_equal cputime, wu.cputime
-      end
-
-      if queuedtime
-        assert_equal true, (wu.queuedtime >= queuedtime)
-      elsif queuedtime.nil?
-        assert_nil wu.queuedtime
-      else
-        assert_equal queuedtime, wu.queuedtime
-      end
-
-      assert_equal cputime_more_than_walltime, (wu.cputime > wu.walltime) if wu.cputime and wu.walltime
-    end
-  end
-
-  test 'can_cancel?' do
-    use_token 'active' do
-      assert find_fixture(Job, 'running').work_unit.can_cancel?
-      refute find_fixture(Container, 'running').work_unit.can_cancel?
-      assert find_fixture(ContainerRequest, 'running').work_unit.can_cancel?
-    end
-    use_token 'spectator' do
-      refute find_fixture(ContainerRequest, 'running_anonymous_accessible').work_unit.can_cancel?
-    end
-    use_token 'admin' do
-      assert find_fixture(ContainerRequest, 'running_anonymous_accessible').work_unit.can_cancel?
-    end
-  end
-end
diff --git a/apps/workbench/vendor/assets/javascripts/.gitkeep b/apps/workbench/vendor/assets/javascripts/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/vendor/assets/stylesheets/.gitkeep b/apps/workbench/vendor/assets/stylesheets/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/apps/workbench/vendor/plugins/.gitkeep b/apps/workbench/vendor/plugins/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
index 54d5ea404cdef205d3cd08ffabcdd6d78d775eb7..e6d14cf66404009c8a6ec6f44606fb6e40f5d5d2 100644 (file)
@@ -1,4 +1,44 @@
-Scripts in this directory:
+Prerequisites
+=============
+
+In order to build packages, you will need:
+
+* Docker installed
+* permission to run Docker commands
+* the `WORKSPACE` environment variable set to the absolute path of an
+  Arvados Git work tree
+
+Quickstart
+==========
+
+Build and test all the packages for a distribution on your architecture by
+running:
+
+    ./run-build-test-packages-one-target.sh --target DISTRO
+
+This will build package build and test Docker images for the named target
+distribution, build all packages in a build container, then test all
+packages in a test container.
+
+Limit the build to a single architecture by adding the `--arch ARCH`
+option. Supported architectures are amd64 and arm64. Note cross-compilation
+from amd64 to arm64 is currently only supported on Debian 11+.
+
+Limit the build to a single package by adding the `--only-build
+PACKAGE_NAME` option. This is helpful when a build is mostly in good shape
+and you're tracking down last bugs in one or two packages.
+
+Get more verbose output by adding the `--debug` option.
+
+By default the script avoids rebuilding or retesting packages that it
+detects have already been done in past runs. You can force the script to
+rebuild or retest package(s) with the `--force-build` and `--force-test`
+options, respectively.
+
+Run the script with `--help` for more information about other options.
+
+Scripts in this directory
+=========================
 
 run-tests.sh                             Run unit and integration test suite.
 
@@ -30,3 +70,29 @@ build-dev-docker-jobs-image.sh           Build developer arvados/jobs Docker ima
 run-library.sh                           A library of functions shared by the
                                          various scripts in this
                                          directory.
+
+Adding a new target
+===================
+
+In order to build packages on a new distribution, you MUST:
+
+* Add a rule for `TARGET/generated` to `package-build-dockerfiles/Makefile`.
+* Add the new `TARGET/generated` rule to the `all` target in
+  `package-build-dockerfiles/Makefile`.
+* Write `package-build-dockerfiles/TARGET/Dockerfile`.
+* Add a rule for `TARGET/generated` to `package-test-dockerfiles/Makefile`.
+* Add the new `TARGET/generated` rule to the `all` target in
+  `package-test-dockerfiles/Makefile`.
+* Write `package-test-dockerfiles/TARGET/Dockerfile`.
+* Create `package-testing/test-packages-TARGET.sh`, ideally by making it a
+  symlink to `FORMAT-common-test-packages.sh`.
+* Update the package download code near the bottom of `test_package_presence`
+  in `run-library.sh` so it can download packages for the new distribution.
+
+Of course, any part of our package build or test infrastructure may need to
+be updated to accommodate the process for new distributions. If you're
+having trouble building lots of packages, consider grepping these build
+scripts for the identifier of the closest working target, and see if you may
+need to add branches or similar hooks for your target. If you're having
+trouble building specific packages, consider doing the same for those
+packages' `fpm-info.sh` files.
index bf1ab3418937a0d2a9bbe16937f9aae257543e60..583b7a54f7e015d4d071c0b6c1042703c95327b6 100755 (executable)
@@ -17,7 +17,6 @@ WORKSPACE=path         Path to the Arvados source tree to build packages from
 CWLTOOL=path           (optional) Path to cwltool git repository.
 SALAD=path             (optional) Path to schema_salad git repository.
 CWL_UTILS=path         (optional) Path to cwl-utils git repository.
-PYCMD=pythonexec       (optional) Specify the python3 executable to use in the docker image. Defaults to "python3".
 
 EOF
 
@@ -28,72 +27,26 @@ if [[ -z "$WORKSPACE" ]] ; then
     echo "Using WORKSPACE $WORKSPACE"
 fi
 
-if [[ -z "$ARVADOS_API_HOST" || -z "$ARVADOS_API_TOKEN" ]] ; then
-    echo "$helpmessage"
-    echo
-    echo "Must set ARVADOS_API_HOST and ARVADOS_API_TOKEN"
-    exit 1
-fi
-
-cd "$WORKSPACE"
-
-py=python3
-pipcmd=pip
-if [[ -n "$PYCMD" ]] ; then
-    py="$PYCMD"
-fi
-if [[ $py = python3 ]] ; then
-    pipcmd=pip3
-fi
+context_dir="$(mktemp --directory --tmpdir dev-jobs.XXXXXXXX)"
+trap 'rm -rf "$context_dir"' EXIT INT TERM QUIT
 
-(cd sdk/python && python3 setup.py sdist)
-sdk=$(cd sdk/python/dist && ls -t arvados-python-client-*.tar.gz | head -n1)
-
-(cd sdk/cwl && python3 setup.py sdist)
-runner=$(cd sdk/cwl/dist && ls -t arvados-cwl-runner-*.tar.gz | head -n1)
-
-rm -rf sdk/cwl/salad_dist
-mkdir -p sdk/cwl/salad_dist
-if [[ -n "$SALAD" ]] ; then
-    (cd "$SALAD" && python3 setup.py sdist)
-    salad=$(cd "$SALAD/dist" && ls -t schema-salad-*.tar.gz | head -n1)
-    cp "$SALAD/dist/$salad" $WORKSPACE/sdk/cwl/salad_dist
-fi
-
-rm -rf sdk/cwl/cwltool_dist
-mkdir -p sdk/cwl/cwltool_dist
-if [[ -n "$CWLTOOL" ]] ; then
-    (cd "$CWLTOOL" && python3 setup.py sdist)
-    cwltool=$(cd "$CWLTOOL/dist" && ls -t cwltool-*.tar.gz | head -n1)
-    cp "$CWLTOOL/dist/$cwltool" $WORKSPACE/sdk/cwl/cwltool_dist
-fi
-
-rm -rf sdk/cwl/cwlutils_dist
-mkdir -p sdk/cwl/cwlutils_dist
-if [[ -n "$CWL_UTILS" ]] ; then
-    (cd "$CWL_UTILS" && python3 setup.py sdist)
-    cwlutils=$(cd "$CWL_UTILS/dist" && ls -t cwl-utils-*.tar.gz | head -n1)
-    cp "$CWL_UTILS/dist/$cwlutils" $WORKSPACE/sdk/cwl/cwlutils_dist
-fi
+for src_dir in "$WORKSPACE/sdk/python" "${CWLTOOL:-}" "${CWL_UTILS:-}" "${SALAD:-}" "$WORKSPACE/tools/crunchstat-summary" "$WORKSPACE/sdk/cwl"; do
+    if [[ -z "$src_dir" ]]; then
+        continue
+    fi
+    env -C "$src_dir" python3 setup.py sdist --dist-dir="$context_dir"
+done
 
+cd "$WORKSPACE"
 . build/run-library.sh
-
 # This defines python_sdk_version and cwl_runner_version with python-style
 # package suffixes (.dev/rc)
 calculate_python_sdk_cwl_package_versions
 
 set -x
 docker build --no-cache \
-       --build-arg sdk=$sdk \
-       --build-arg runner=$runner \
-       --build-arg salad=$salad \
-       --build-arg cwltool=$cwltool \
-       --build-arg pythoncmd=$py \
-       --build-arg pipcmd=$pipcmd \
-       --build-arg cwlutils=$cwlutils \
        -f "$WORKSPACE/sdk/dev-jobs.dockerfile" \
        -t arvados/jobs:$cwl_runner_version \
-       "$WORKSPACE/sdk"
+       "$context_dir"
 
-echo arv-keepdocker arvados/jobs $cwl_runner_version
 arv-keepdocker arvados/jobs $cwl_runner_version
index e4579cbb3f14dcd85d5b6c1749b87c5914bcb22e..390b5dd828f456fa1e8aa0867968814295f888fd 100755 (executable)
@@ -33,7 +33,7 @@ if [[ "$WORKSPACE" == "" ]]; then
 fi
 
 
-debug_echo "package_go_binary $SRC_PATH"
+debug_echo "get-package-version.sh $TYPE_LANG $SRC_PATH"
 
 if [[ "$TYPE_LANG" == "go" ]]; then
   calculate_go_package_version go_package_version $SRC_PATH
@@ -44,12 +44,6 @@ elif [[ "$TYPE_LANG" == "python3" ]]; then
 
   rm -rf dist/*
 
-  # Get the latest setuptools
-  if ! pip3 install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U 'setuptools<45'; then
-    echo "Error, unable to upgrade setuptools with"
-    echo "  pip3 install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U 'setuptools<45'"
-    exit 1
-  fi
   # filter a useless warning (when building the cwltest package) from the stderr output
   if ! python3 setup.py $DASHQ_UNLESS_DEBUG sdist 2> >(grep -v 'warning: no previously-included files matching' |grep -v 'for version number calculation'); then
     echo "Error, unable to run python3 setup.py sdist for $SRC_PATH"
index b8f6315e79410f471230f88336cb297e98206c95..be27fffab75037f4095bd5b17464b603095871db 100644 (file)
@@ -3,35 +3,39 @@
 # SPDX-License-Identifier: AGPL-3.0
 
 SHELL := '/bin/bash'
-all: centos7/generated debian10/generated debian11/generated ubuntu1804/generated ubuntu2004/generated
-
-centos7/generated: common-generated-all
-       test -d centos7/generated || mkdir centos7/generated
-       cp -f -rlt centos7/generated common-generated/*
-
-debian10/generated: common-generated-all
-       test -d debian10/generated || mkdir debian10/generated
-       cp -f -rlt debian10/generated common-generated/*
 
+all: debian11/generated
 debian11/generated: common-generated-all
        test -d debian11/generated || mkdir debian11/generated
        cp -f -rlt debian11/generated common-generated/*
 
-ubuntu1804/generated: common-generated-all
-       test -d ubuntu1804/generated || mkdir ubuntu1804/generated
-       cp -f -rlt ubuntu1804/generated common-generated/*
+all: debian12/generated
+debian12/generated: common-generated-all
+       test -d debian12/generated || mkdir debian12/generated
+       cp -f -rlt debian12/generated common-generated/*
 
+all: rocky8/generated
+rocky8/generated: common-generated-all
+       test -d rocky8/generated || mkdir rocky8/generated
+       cp -f -rlt rocky8/generated common-generated/*
+
+all: ubuntu2004/generated
 ubuntu2004/generated: common-generated-all
        test -d ubuntu2004/generated || mkdir ubuntu2004/generated
        cp -f -rlt ubuntu2004/generated common-generated/*
 
+all: ubuntu2204/generated
+ubuntu2204/generated: common-generated-all
+       test -d ubuntu2204/generated || mkdir ubuntu2204/generated
+       cp -f -rlt ubuntu2204/generated common-generated/*
+
 GOTARBALL_=DOES_NOT_EXIST
 NODETARBALL_=DOES_NOT_EXIST
 GOVERSION=$(shell grep 'const goversion =' ../../lib/install/deps.go |awk -F'"' '{print $$2}')
 GOTARBALL_x86_64=go$(GOVERSION).linux-amd64.tar.gz
-NODETARBALL_x86_64=node-v10.23.1-linux-x64.tar.xz
+NODETARBALL_x86_64=node-v12.22.12-linux-x64.tar.xz
 GOTARBALL_aarch64=go$(GOVERSION).linux-arm64.tar.gz
-NODETARBALL_aarch64=node-v10.23.1-linux-arm64.tar.xz
+NODETARBALL_aarch64=node-v12.22.12-linux-arm64.tar.xz
 
 # Get the bash variable $HOSTTYPE (this requires the SHELL line above)
 HOSTTYPE=$(shell echo $${HOSTTYPE})
@@ -48,7 +52,7 @@ common-generated/$(GOTARBALL): common-generated
        wget -cqO common-generated/$(GOTARBALL) https://dl.google.com/go/$(GOTARBALL)
 
 common-generated/$(NODETARBALL): common-generated
-       wget -cqO common-generated/$(NODETARBALL) https://nodejs.org/dist/v10.23.1/$(NODETARBALL)
+       wget -cqO common-generated/$(NODETARBALL) https://nodejs.org/dist/v12.22.12/$(NODETARBALL)
 
 common-generated/$(RVMKEY1): common-generated
        wget -cqO common-generated/$(RVMKEY1) https://rvm.io/mpapis.asc
diff --git a/build/package-build-dockerfiles/centos7/Dockerfile b/build/package-build-dockerfiles/centos7/Dockerfile
deleted file mode 100644 (file)
index f0ae5df..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-ARG HOSTTYPE
-ARG BRANCH
-ARG GOVERSION
-
-FROM centos:7 as build_x86_64
-# Install go
-ONBUILD ARG GOVERSION
-ONBUILD ADD generated/go${GOVERSION}.linux-amd64.tar.gz /usr/local/
-ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
-# Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-x64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-x64/bin/* /usr/local/bin/
-
-FROM centos:7 as build_aarch64
-# Install go
-ONBUILD ARG GOVERSION
-ONBUILD ADD generated/go${GOVERSION}.linux-arm64.tar.gz /usr/local/
-ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
-# Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-arm64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-arm64/bin/* /usr/local/bin/
-
-FROM build_${HOSTTYPE}
-
-MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
-
-ENV DEBIAN_FRONTEND noninteractive
-
-SHELL ["/bin/bash", "-c"]
-# Install dependencies.
-RUN yum -q -y install make automake gcc gcc-c++ libyaml-devel patch readline-devel zlib-devel libffi-devel openssl-devel bzip2 libtool bison sqlite-devel rpm-build git libattr-devel nss-devel libcurl-devel which tar unzip scl-utils centos-release-scl postgresql-devel fuse-devel xz-libs git wget pam-devel
-
-# Install RVM
-ADD generated/mpapis.asc /tmp/
-ADD generated/pkuczynski.asc /tmp/
-RUN gpg --import --no-tty /tmp/mpapis.asc && \
-    gpg --import --no-tty /tmp/pkuczynski.asc && \
-    curl -L https://get.rvm.io | bash -s stable && \
-    /usr/local/rvm/bin/rvm install 2.7 -j $(grep -c processor /proc/cpuinfo) && \
-    /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
-    echo "gem: --no-document" >> ~/.gemrc && \
-    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19 && \
-    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.10.2
-
-# Install Bash 4.4.12 // see https://dev.arvados.org/issues/15612
-RUN cd /usr/local/src \
-&& wget http://ftp.gnu.org/gnu/bash/bash-4.4.12.tar.gz \
-&& wget http://ftp.gnu.org/gnu/bash/bash-4.4.12.tar.gz.sig \
-&& tar xzf bash-4.4.12.tar.gz \
-&& cd bash-4.4.12 \
-&& ./configure --prefix=/usr/local/$( basename $( pwd ) ) \
-&& make \
-&& make install \
-&& ln -sf /usr/local/src/bash-4.4.12/bash /bin/bash
-
-# Need to "touch" RPM database to workaround bug in interaction between
-# overlayfs and yum (https://bugzilla.redhat.com/show_bug.cgi?id=1213602)
-RUN touch /var/lib/rpm/* && yum -q -y install python3 python3-pip python3-devel
-
-# Install virtualenv
-RUN /usr/bin/pip3 install 'virtualenv<20'
-
-RUN /usr/local/rvm/bin/rvm-exec default bundle config --global jobs $(let a=$(grep -c processor /proc/cpuinfo )-1; echo $a)
-# Cf. https://build.betterup.com/one-weird-trick-that-will-speed-up-your-bundle-install/
-ENV MAKE "make --jobs $(grep -c processor /proc/cpuinfo)"
-
-# Preseed the go module cache and the ruby gems, using the currently checked
-# out branch of the source tree. This avoids potential compatibility issues
-# between the version of Ruby and certain gems.
-RUN git clone --depth 1 git://git.arvados.org/arvados.git /tmp/arvados && \
-    cd /tmp/arvados && \
-    if [[ -n "${BRANCH}" ]]; then git checkout ${BRANCH}; fi && \
-    cd /tmp/arvados/services/api && \
-    /usr/local/rvm/bin/rvm-exec default bundle install && \
-    cd /tmp/arvados/apps/workbench && \
-    /usr/local/rvm/bin/rvm-exec default bundle install && \
-    cd /tmp/arvados && \
-    go mod download
-
-# The version of setuptools that comes with CentOS is way too old
-RUN pip3 install 'setuptools<45'
-
-ENV WORKSPACE /arvados
-CMD ["/usr/local/rvm/bin/rvm-exec", "default", "bash", "/jenkins/run-build-packages.sh", "--target", "centos7"]
diff --git a/build/package-build-dockerfiles/debian10/Dockerfile b/build/package-build-dockerfiles/debian10/Dockerfile
deleted file mode 100644 (file)
index bc4a8be..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-ARG HOSTTYPE
-ARG BRANCH
-ARG GOVERSION
-
-## dont use debian:10 here since the word 'buster' is used for rvm precompiled binaries
-FROM debian:buster as build_x86_64
-# Install go
-ONBUILD ARG GOVERSION
-ONBUILD ADD generated/go${GOVERSION}.linux-amd64.tar.gz /usr/local/
-ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
-# Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-x64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-x64/bin/* /usr/local/bin/
-# No cross compilation support for debian10 because of https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=983477
-
-FROM debian:buster as build_aarch64
-# Install go
-ONBUILD ARG GOVERSION
-ONBUILD ADD generated/go${GOVERSION}.linux-arm64.tar.gz /usr/local/
-ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
-# Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-arm64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-arm64/bin/* /usr/local/bin/
-
-FROM build_${HOSTTYPE}
-
-MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
-
-ENV DEBIAN_FRONTEND noninteractive
-
-SHELL ["/bin/bash", "-c"]
-# Install dependencies.
-RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python3 python3-setuptools python3-pip libcurl4-gnutls-dev curl git procps libattr1-dev libfuse-dev libgnutls28-dev libpq-dev unzip python3-venv python3-dev libpam-dev equivs
-
-# Install virtualenv
-RUN /usr/bin/pip3 install 'virtualenv<20'
-
-# Install RVM
-ADD generated/mpapis.asc /tmp/
-ADD generated/pkuczynski.asc /tmp/
-RUN gpg --import --no-tty /tmp/mpapis.asc && \
-    gpg --import --no-tty /tmp/pkuczynski.asc && \
-    curl -L https://get.rvm.io | bash -s stable && \
-    /usr/local/rvm/bin/rvm install 2.7 -j $(grep -c processor /proc/cpuinfo) && \
-    /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
-    echo "gem: --no-document" >> ~/.gemrc && \
-    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19 && \
-    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.10.2
-
-RUN /usr/local/rvm/bin/rvm-exec default bundle config --global jobs $(let a=$(grep -c processor /proc/cpuinfo )-1; echo $a)
-# Cf. https://build.betterup.com/one-weird-trick-that-will-speed-up-your-bundle-install/
-ENV MAKE "make --jobs $(grep -c processor /proc/cpuinfo)"
-
-# Preseed the go module cache and the ruby gems, using the currently checked
-# out branch of the source tree. This avoids potential compatibility issues
-# between the version of Ruby and certain gems.
-RUN git clone --depth 1 git://git.arvados.org/arvados.git /tmp/arvados && \
-    cd /tmp/arvados && \
-    if [[ -n "${BRANCH}" ]]; then git checkout ${BRANCH}; fi && \
-    cd /tmp/arvados/services/api && \
-    /usr/local/rvm/bin/rvm-exec default bundle install && \
-    cd /tmp/arvados/apps/workbench && \
-    /usr/local/rvm/bin/rvm-exec default bundle install && \
-    cd /tmp/arvados && \
-    go mod download
-
-ENV WORKSPACE /arvados
-CMD ["/usr/local/rvm/bin/rvm-exec", "default", "bash", "/jenkins/run-build-packages.sh", "--target", "debian10"]
index ddbe1114f38cd03e2c2c2d5d5ef77973808c022a..5ca7e1f2434ad4db50e6aa5b587aea5a93de1b15 100644 (file)
@@ -9,12 +9,15 @@ ARG GOVERSION
 ## dont use debian:11 here since the word 'bullseye' is used for rvm precompiled binaries
 FROM debian:bullseye as build_x86_64
 # Install go
+ONBUILD ARG BRANCH
 ONBUILD ARG GOVERSION
 ONBUILD ADD generated/go${GOVERSION}.linux-amd64.tar.gz /usr/local/
 ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 # Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-x64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-x64/bin/* /usr/local/bin/
+ONBUILD ADD generated/node-v12.22.12-linux-x64.tar.xz /usr/local/
+ONBUILD RUN ln -s /usr/local/node-v12.22.12-linux-x64/bin/* /usr/local/bin/
+ONBUILD RUN npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-x64/bin/* /usr/local/bin/
 # On x86, we want some cross-compilation support for arm64
 # Add gcc-aarch64-linux-gnu to compile go binaries for arm64
 ONBUILD RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y gcc-aarch64-linux-gnu
@@ -24,12 +27,15 @@ ONBUILD RUN /usr/bin/apt-get update && /usr/bin/apt-get install -o APT::Immediat
 
 FROM debian:bullseye as build_aarch64
 # Install go
+ONBUILD ARG BRANCH
 ONBUILD ARG GOVERSION
 ONBUILD ADD generated/go${GOVERSION}.linux-arm64.tar.gz /usr/local/
 ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 # Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-arm64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-arm64/bin/* /usr/local/bin/
+ONBUILD ADD generated/node-v12.22.12-linux-arm64.tar.xz /usr/local/
+ONBUILD RUN ln -s /usr/local/node-v12.22.12-linux-arm64/bin/* /usr/local/bin/
+ONBUILD RUN npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-arm64/bin/* /usr/local/bin/
 
 FROM build_${HOSTTYPE}
 RUN echo HOSTTYPE ${HOSTTYPE}
@@ -40,14 +46,12 @@ ENV DEBIAN_FRONTEND noninteractive
 
 SHELL ["/bin/bash", "-c"]
 # Install dependencies.
-RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python3 python3-setuptools python3-pip libcurl4-gnutls-dev curl git procps libattr1-dev libfuse-dev libgnutls28-dev libpq-dev unzip python3-venv python3-dev libpam-dev equivs
+RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python3 libcurl4-gnutls-dev curl git procps libattr1-dev libfuse-dev libgnutls28-dev libpq-dev unzip python3-venv python3-dev libpam-dev equivs
 
-# Install virtualenv
-RUN /usr/bin/pip3 install 'virtualenv<20'
-
-# Install RVM
 ADD generated/mpapis.asc /tmp/
 ADD generated/pkuczynski.asc /tmp/
+# fpm depends on dotenv, but version 3.0 of that gem dropped support for
+# Ruby 2.7, so we need to specifically install an older version.
 RUN gpg --import --no-tty /tmp/mpapis.asc && \
     gpg --import --no-tty /tmp/pkuczynski.asc && \
     curl -L https://get.rvm.io | bash -s stable && \
@@ -55,7 +59,8 @@ RUN gpg --import --no-tty /tmp/mpapis.asc && \
     /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
     echo "gem: --no-document" >> ~/.gemrc && \
     /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19 && \
-    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.10.2
+    /usr/local/rvm/bin/rvm-exec default gem install dotenv --version '~> 2.8' && \
+    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.15.1
 
 RUN /usr/local/rvm/bin/rvm-exec default bundle config --global jobs $(let a=$(grep -c processor /proc/cpuinfo )-1; echo $a)
 # Cf. https://build.betterup.com/one-weird-trick-that-will-speed-up-your-bundle-install/
@@ -64,13 +69,11 @@ ENV MAKE "make --jobs $(grep -c processor /proc/cpuinfo)"
 # Preseed the go module cache and the ruby gems, using the currently checked
 # out branch of the source tree. This avoids potential compatibility issues
 # between the version of Ruby and certain gems.
-RUN git clone --depth 1 git://git.arvados.org/arvados.git /tmp/arvados && \
+RUN git clone git://git.arvados.org/arvados.git /tmp/arvados && \
     cd /tmp/arvados && \
     if [[ -n "${BRANCH}" ]]; then git checkout ${BRANCH}; fi && \
     cd /tmp/arvados/services/api && \
     /usr/local/rvm/bin/rvm-exec default bundle install && \
-    cd /tmp/arvados/apps/workbench && \
-    /usr/local/rvm/bin/rvm-exec default bundle install && \
     cd /tmp/arvados && \
     go mod download
 
diff --git a/build/package-build-dockerfiles/debian12/Dockerfile b/build/package-build-dockerfiles/debian12/Dockerfile
new file mode 100644 (file)
index 0000000..fa1d095
--- /dev/null
@@ -0,0 +1,77 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+ARG HOSTTYPE
+ARG BRANCH
+ARG GOVERSION
+
+## dont use debian:12 here since the word 'bookworm' is used for rvm precompiled binaries
+FROM debian:bookworm as build_x86_64
+ONBUILD ARG BRANCH
+# Install go
+ONBUILD ARG GOVERSION
+ONBUILD ADD generated/go${GOVERSION}.linux-amd64.tar.gz /usr/local/
+ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
+# Install nodejs and npm
+ONBUILD ADD generated/node-v12.22.12-linux-x64.tar.xz /usr/local/
+ONBUILD RUN env -C /usr/local/node-v12.22.12-linux-x64/bin PATH="$PATH:." ./npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-x64/bin/* /usr/local/bin/
+# On x86, we want some cross-compilation support for arm64
+# Add gcc-aarch64-linux-gnu to compile go binaries for arm64
+ONBUILD RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y gcc-aarch64-linux-gnu
+# We also need libpam compiled for arm64
+ONBUILD RUN /usr/bin/dpkg --add-architecture arm64
+ONBUILD RUN /usr/bin/apt-get update && /usr/bin/apt-get install -o APT::Immediate-Configure=0 -q -y libpam0g-dev:arm64 libfuse-dev:arm64
+
+FROM debian:bookworm as build_aarch64
+ONBUILD ARG BRANCH
+# Install go
+ONBUILD ARG GOVERSION
+ONBUILD ADD generated/go${GOVERSION}.linux-arm64.tar.gz /usr/local/
+ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
+# Install nodejs and npm
+ONBUILD ADD generated/node-v12.22.12-linux-arm64.tar.xz /usr/local/
+ONBUILD RUN env -C /usr/local/node-v12.22.12-linux-arm64/bin PATH="$PATH:." ./npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-arm64/bin/* /usr/local/bin/
+
+FROM build_${HOSTTYPE}
+RUN echo HOSTTYPE ${HOSTTYPE}
+
+MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
+
+ENV DEBIAN_FRONTEND noninteractive
+
+SHELL ["/bin/bash", "-c"]
+# Install dependencies.
+RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python3 libcurl4-gnutls-dev curl git procps libattr1-dev libfuse-dev libgnutls28-dev libpq-dev unzip python3-venv python3-dev libpam-dev equivs
+
+# Install RVM
+ADD generated/mpapis.asc /tmp/
+ADD generated/pkuczynski.asc /tmp/
+RUN gpg --import --no-tty /tmp/mpapis.asc && \
+    gpg --import --no-tty /tmp/pkuczynski.asc && \
+    curl -L https://get.rvm.io | bash -s stable && \
+    /usr/local/rvm/bin/rvm install 3.2.2 -j $(grep -c processor /proc/cpuinfo) --disable-binary && \
+    /usr/local/rvm/bin/rvm alias create default ruby-3.2.2 && \
+    echo "gem: --no-document" >> ~/.gemrc && \
+    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19 && \
+    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.15.1
+
+RUN /usr/local/rvm/bin/rvm-exec default bundle config --global jobs $(let a=$(grep -c processor /proc/cpuinfo )-1; echo $a)
+# Cf. https://build.betterup.com/one-weird-trick-that-will-speed-up-your-bundle-install/
+ENV MAKE "make --jobs 8"
+
+# Preseed the go module cache and the ruby gems, using the currently checked
+# out branch of the source tree. This avoids potential compatibility issues
+# between the version of Ruby and certain gems.
+RUN git clone git://git.arvados.org/arvados.git /tmp/arvados && \
+    cd /tmp/arvados && \
+    if [[ -n "${BRANCH}" ]]; then git checkout ${BRANCH}; fi && \
+    cd /tmp/arvados/services/api && \
+    /usr/local/rvm/bin/rvm-exec default bundle install && \
+    cd /tmp/arvados && \
+    go mod download
+
+ENV WORKSPACE /arvados
+CMD ["/usr/local/rvm/bin/rvm-exec", "default", "bash", "/jenkins/run-build-packages.sh", "--target", "debian12"]
diff --git a/build/package-build-dockerfiles/rocky8/Dockerfile b/build/package-build-dockerfiles/rocky8/Dockerfile
new file mode 100644 (file)
index 0000000..a1038a9
--- /dev/null
@@ -0,0 +1,101 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+ARG HOSTTYPE
+ARG BRANCH
+ARG GOVERSION
+
+FROM rockylinux:8.8-minimal as build_x86_64
+ONBUILD ARG BRANCH
+# Install go
+ONBUILD ARG GOVERSION
+ONBUILD ADD generated/go${GOVERSION}.linux-amd64.tar.gz /usr/local/
+ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
+# Install nodejs and npm
+ONBUILD ADD generated/node-v12.22.12-linux-x64.tar.xz /usr/local/
+ONBUILD RUN ln -s /usr/local/node-v12.22.12-linux-x64/bin/* /usr/local/bin/
+ONBUILD RUN npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-x64/bin/* /usr/local/bin/
+
+FROM rockylinux:8.8-minimal as build_aarch64
+ONBUILD ARG BRANCH
+# Install go
+ONBUILD ARG GOVERSION
+ONBUILD ADD generated/go${GOVERSION}.linux-arm64.tar.gz /usr/local/
+ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
+# Install nodejs and npm
+ONBUILD ADD generated/node-v12.22.12-linux-arm64.tar.xz /usr/local/
+ONBUILD RUN ln -s /usr/local/node-v12.22.12-linux-arm64/bin/* /usr/local/bin/
+ONBUILD RUN npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-arm64/bin/* /usr/local/bin/
+
+FROM build_${HOSTTYPE}
+
+MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
+
+# Install dependencies.
+RUN microdnf --assumeyes --enablerepo=devel install \
+    automake \
+    bison \
+    bzip2 \
+    fuse-devel \
+    gcc \
+    gcc-c++ \
+    git \
+    libattr-devel \
+    libcurl-devel \
+    libffi-devel \
+    libtool \
+    libyaml-devel \
+    make \
+    nss-devel \
+    openssl-devel \
+    pam-devel \
+    patch \
+    postgresql-devel \
+    procps-ng \
+    python39 \
+    python39-devel \
+    readline-devel \
+    rpm-build \
+    ruby \
+    sqlite-devel \
+    tar \
+    unzip \
+    wget \
+    which \
+    xz-libs \
+    zlib-devel
+
+ADD generated/mpapis.asc /tmp/
+ADD generated/pkuczynski.asc /tmp/
+# fpm depends on dotenv, but version 3.0 of that gem dropped support for
+# Ruby 2.7, so we need to specifically install an older version.
+RUN gpg --import --no-tty /tmp/mpapis.asc && \
+    gpg --import --no-tty /tmp/pkuczynski.asc && \
+    curl -L https://get.rvm.io | bash -s stable && \
+    /usr/local/rvm/bin/rvm install --disable-binary 2.7 -j $(grep -c processor /proc/cpuinfo) && \
+    /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
+    echo "gem: --no-document" >> ~/.gemrc && \
+    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19 && \
+    /usr/local/rvm/bin/rvm-exec default gem install dotenv --version '~> 2.8' && \
+    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.15.1
+
+RUN /usr/local/rvm/bin/rvm-exec default bundle config --global jobs $(let a=$(grep -c processor /proc/cpuinfo )-1; echo $a)
+# Cf. https://build.betterup.com/one-weird-trick-that-will-speed-up-your-bundle-install/
+ENV MAKE "make --jobs $(grep -c processor /proc/cpuinfo)"
+
+# Preseed the go module cache and the ruby gems, using the currently checked
+# out branch of the source tree. This avoids potential compatibility issues
+# between the version of Ruby and certain gems.
+RUN git clone git://git.arvados.org/arvados.git /tmp/arvados && \
+    cd /tmp/arvados && \
+    if [[ -n "${BRANCH}" ]]; then git checkout ${BRANCH}; fi && \
+    cd /tmp/arvados/services/api && \
+    /usr/local/rvm/bin/rvm-exec default bundle install && \
+    cd /tmp/arvados && \
+    go mod download
+
+ENV WORKSPACE /arvados
+CMD ["/usr/local/rvm/bin/rvm-exec", "default", "bash", "/jenkins/run-build-packages.sh", "--target", "rocky8"]
diff --git a/build/package-build-dockerfiles/ubuntu1804/Dockerfile b/build/package-build-dockerfiles/ubuntu1804/Dockerfile
deleted file mode 100644 (file)
index 80a98aa..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-ARG HOSTTYPE
-ARG BRANCH
-ARG GOVERSION
-
-FROM ubuntu:bionic as build_x86_64
-# Install go
-ONBUILD ARG GOVERSION
-ONBUILD ADD generated/go${GOVERSION}.linux-amd64.tar.gz /usr/local/
-ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
-# Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-x64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-x64/bin/* /usr/local/bin/
-# No cross compilation support for ubuntu1804 because of https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=983477
-
-FROM ubuntu:bionic as build_aarch64
-# Install go
-ONBUILD ARG GOVERSION
-ONBUILD ADD generated/go${GOVERSION}.linux-arm64.tar.gz /usr/local/
-ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
-# Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-arm64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-arm64/bin/* /usr/local/bin/
-
-FROM build_${HOSTTYPE}
-
-MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
-
-ENV DEBIAN_FRONTEND noninteractive
-
-SHELL ["/bin/bash", "-c"]
-# Install dependencies.
-RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python3.8 python3-pip libcurl4-gnutls-dev libgnutls28-dev curl git libattr1-dev libfuse-dev libpq-dev unzip tzdata python3.8-venv python3.8-dev libpam-dev equivs
-
-# Install virtualenv
-RUN /usr/bin/pip3 install 'virtualenv<20'
-
-# Install RVM
-ADD generated/mpapis.asc /tmp/
-ADD generated/pkuczynski.asc /tmp/
-RUN gpg --import --no-tty /tmp/mpapis.asc && \
-    gpg --import --no-tty /tmp/pkuczynski.asc && \
-    curl -L https://get.rvm.io | bash -s stable && \
-    /usr/local/rvm/bin/rvm install 2.7 -j $(grep -c processor /proc/cpuinfo) && \
-    /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
-    echo "gem: --no-document" >> ~/.gemrc && \
-    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19 && \
-    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.10.2
-
-RUN /usr/local/rvm/bin/rvm-exec default bundle config --global jobs $(let a=$(grep -c processor /proc/cpuinfo )-1; echo $a)
-# Cf. https://build.betterup.com/one-weird-trick-that-will-speed-up-your-bundle-install/
-ENV MAKE "make --jobs $(grep -c processor /proc/cpuinfo)"
-
-# Preseed the go module cache and the ruby gems, using the currently checked
-# out branch of the source tree. This avoids potential compatibility issues
-# between the version of Ruby and certain gems.
-RUN git clone --depth 1 git://git.arvados.org/arvados.git /tmp/arvados && \
-    cd /tmp/arvados && \
-    if [[ -n "${BRANCH}" ]]; then git checkout ${BRANCH}; fi && \
-    cd /tmp/arvados/services/api && \
-    /usr/local/rvm/bin/rvm-exec default bundle install && \
-    cd /tmp/arvados/apps/workbench && \
-    /usr/local/rvm/bin/rvm-exec default bundle install && \
-    cd /tmp/arvados && \
-    go mod download
-
-ENV WORKSPACE /arvados
-CMD ["/usr/local/rvm/bin/rvm-exec", "default", "bash", "/jenkins/run-build-packages.sh", "--target", "ubuntu1804"]
index da45077a428c9dd3d7ac0b231676fd187a85333e..576b6021c0595a0dbd9a72ed147f6dc289e5810a 100644 (file)
@@ -7,13 +7,16 @@ ARG BRANCH
 ARG GOVERSION
 
 FROM ubuntu:focal as build_x86_64
+ONBUILD ARG BRANCH
 # Install go
 ONBUILD ARG GOVERSION
 ONBUILD ADD generated/go${GOVERSION}.linux-amd64.tar.gz /usr/local/
 ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 # Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-x64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-x64/bin/* /usr/local/bin/
+ONBUILD ADD generated/node-v12.22.12-linux-x64.tar.xz /usr/local/
+ONBUILD RUN ln -s /usr/local/node-v12.22.12-linux-x64/bin/* /usr/local/bin/
+ONBUILD RUN npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-x64/bin/* /usr/local/bin/
 # On x86, we want some cross-compilation support for arm64
 # Add gcc-aarch64-linux-gnu to compile go binaries for arm64
 ONBUILD RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y gcc-aarch64-linux-gnu
@@ -28,13 +31,16 @@ ONBUILD RUN /usr/bin/apt-get update && /usr/bin/apt-get install -o APT::Immediat
 # ubuntu2204 will have the fix introduced in debian11.
 
 FROM ubuntu:focal as build_aarch64
+ONBUILD ARG BRANCH
 # Install go
 ONBUILD ARG GOVERSION
 ONBUILD ADD generated/go${GOVERSION}.linux-arm64.tar.gz /usr/local/
 ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 # Install nodejs and npm
-ONBUILD ADD generated/node-v10.23.1-linux-arm64.tar.xz /usr/local/
-ONBUILD RUN ln -s /usr/local/node-v10.23.1-linux-arm64/bin/* /usr/local/bin/
+ONBUILD ADD generated/node-v12.22.12-linux-arm64.tar.xz /usr/local/
+ONBUILD RUN ln -s /usr/local/node-v12.22.12-linux-arm64/bin/* /usr/local/bin/
+ONBUILD RUN npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-arm64/bin/* /usr/local/bin/
 
 FROM build_${HOSTTYPE}
 
@@ -44,14 +50,12 @@ ENV DEBIAN_FRONTEND noninteractive
 
 SHELL ["/bin/bash", "-c"]
 # Install dependencies.
-RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python3 python3-pip libcurl4-gnutls-dev libgnutls28-dev curl git libattr1-dev libfuse-dev libpq-dev unzip tzdata python3-venv python3-dev libpam-dev shared-mime-info equivs
+RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python3 libcurl4-gnutls-dev libgnutls28-dev curl git libattr1-dev libfuse-dev libpq-dev unzip tzdata python3-venv python3-dev libpam-dev shared-mime-info equivs
 
-# Install virtualenv
-RUN /usr/bin/pip3 install 'virtualenv<20'
-
-# Install RVM
 ADD generated/mpapis.asc /tmp/
 ADD generated/pkuczynski.asc /tmp/
+# fpm depends on dotenv, but version 3.0 of that gem dropped support for
+# Ruby 2.7, so we need to specifically install an older version.
 RUN gpg --import --no-tty /tmp/mpapis.asc && \
     gpg --import --no-tty /tmp/pkuczynski.asc && \
     curl -L https://get.rvm.io | bash -s stable && \
@@ -59,7 +63,8 @@ RUN gpg --import --no-tty /tmp/mpapis.asc && \
     /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
     echo "gem: --no-document" >> ~/.gemrc && \
     /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19 && \
-    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.10.2
+    /usr/local/rvm/bin/rvm-exec default gem install dotenv --version '~> 2.8' && \
+    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.15.1
 
 RUN /usr/local/rvm/bin/rvm-exec default bundle config --global jobs $(let a=$(grep -c processor /proc/cpuinfo )-1; echo $a)
 # Cf. https://build.betterup.com/one-weird-trick-that-will-speed-up-your-bundle-install/
@@ -68,16 +73,13 @@ ENV MAKE "make --jobs $(grep -c processor /proc/cpuinfo)"
 # Preseed the go module cache and the ruby gems, using the currently checked
 # out branch of the source tree. This avoids potential compatibility issues
 # between the version of Ruby and certain gems.
-RUN git clone --depth 1 git://git.arvados.org/arvados.git /tmp/arvados && \
+RUN git clone git://git.arvados.org/arvados.git /tmp/arvados && \
     cd /tmp/arvados && \
     if [[ -n "${BRANCH}" ]]; then git checkout ${BRANCH}; fi && \
     cd /tmp/arvados/services/api && \
     /usr/local/rvm/bin/rvm-exec default bundle install && \
-    cd /tmp/arvados/apps/workbench && \
-    /usr/local/rvm/bin/rvm-exec default bundle install && \
     cd /tmp/arvados && \
     go mod download
 
-
 ENV WORKSPACE /arvados
 CMD ["/usr/local/rvm/bin/rvm-exec", "default", "bash", "/jenkins/run-build-packages.sh", "--target", "ubuntu2004"]
diff --git a/build/package-build-dockerfiles/ubuntu2204/Dockerfile b/build/package-build-dockerfiles/ubuntu2204/Dockerfile
new file mode 100644 (file)
index 0000000..79664fe
--- /dev/null
@@ -0,0 +1,78 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+ARG HOSTTYPE
+ARG BRANCH
+ARG GOVERSION
+
+FROM ubuntu:jammy as build_x86_64
+ONBUILD ARG BRANCH
+# Install go
+ONBUILD ARG GOVERSION
+ONBUILD ADD generated/go${GOVERSION}.linux-amd64.tar.gz /usr/local/
+ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
+# Install nodejs and npm
+ONBUILD ADD generated/node-v12.22.12-linux-x64.tar.xz /usr/local/
+ONBUILD RUN env -C /usr/local/node-v12.22.12-linux-x64/bin PATH="$PATH:." ./npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-x64/bin/* /usr/local/bin/
+# On x86, we want some cross-compilation support for arm64
+# Add gcc-aarch64-linux-gnu to compile go binaries for arm64
+ONBUILD RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y gcc-aarch64-linux-gnu
+# We also need libpam compiled for arm64, and that requires some sources.list mangling
+ONBUILD RUN /bin/sed -i 's/deb http/deb [ arch=amd64 ] http/' /etc/apt/sources.list
+ONBUILD ADD ports.list /etc/apt/sources.list.d/
+ONBUILD RUN /usr/bin/dpkg --add-architecture arm64
+ONBUILD RUN /usr/bin/apt-get update && /usr/bin/apt-get install -o APT::Immediate-Configure=0 -q -y libpam0g-dev:arm64 libfuse-dev:arm64
+
+FROM ubuntu:jammy as build_aarch64
+ONBUILD ARG BRANCH
+# Install go
+ONBUILD ARG GOVERSION
+ONBUILD ADD generated/go${GOVERSION}.linux-arm64.tar.gz /usr/local/
+ONBUILD RUN ln -s /usr/local/go/bin/go /usr/local/bin/
+# Install nodejs and npm
+ONBUILD ADD generated/node-v12.22.12-linux-arm64.tar.xz /usr/local/
+ONBUILD RUN env -C /usr/local/node-v12.22.12-linux-arm64/bin PATH="$PATH:." ./npm install -g yarn
+ONBUILD RUN ln -sf /usr/local/node-v12.22.12-linux-arm64/bin/* /usr/local/bin/
+
+FROM build_${HOSTTYPE}
+
+LABEL org.opencontainers.image.authors="Arvados Package Maintainers <packaging@arvados.org>"
+
+ENV DEBIAN_FRONTEND noninteractive
+
+SHELL ["/bin/bash", "-c"]
+# Install dependencies.
+RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python3 libcurl4-gnutls-dev libgnutls28-dev curl git libattr1-dev libfuse-dev libpq-dev unzip tzdata python3-venv python3-dev libpam-dev shared-mime-info equivs
+
+# Install RVM
+ADD generated/mpapis.asc /tmp/
+ADD generated/pkuczynski.asc /tmp/
+RUN gpg --import --no-tty /tmp/mpapis.asc && \
+    gpg --import --no-tty /tmp/pkuczynski.asc && \
+    curl -L https://get.rvm.io | bash -s stable && \
+    /usr/local/rvm/bin/rvm install 3.2.2 -j $(grep -c processor /proc/cpuinfo) && \
+    /usr/local/rvm/bin/rvm alias create default ruby-3.2.2 && \
+    echo "gem: --no-document" >> ~/.gemrc && \
+    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19 && \
+    /usr/local/rvm/bin/rvm-exec default gem install fpm --version 1.15.1
+
+RUN /usr/local/rvm/bin/rvm-exec default bundle config --global jobs $(let a=$(grep -c processor /proc/cpuinfo )-1; echo $a)
+# Cf. https://build.betterup.com/one-weird-trick-that-will-speed-up-your-bundle-install/
+ENV MAKE "make --jobs 8"
+
+# Preseed the go module cache and the ruby gems, using the currently checked
+# out branch of the source tree. This avoids potential compatibility issues
+# between the version of Ruby and certain gems.
+RUN git clone git://git.arvados.org/arvados.git /tmp/arvados && \
+    cd /tmp/arvados && \
+    if [[ -n "${BRANCH}" ]]; then git checkout ${BRANCH}; fi && \
+    cd /tmp/arvados/services/api && \
+    /usr/local/rvm/bin/rvm-exec default bundle install && \
+    cd /tmp/arvados && \
+    go mod download
+
+
+ENV WORKSPACE /arvados
+CMD ["/usr/local/rvm/bin/rvm-exec", "default", "bash", "/jenkins/run-build-packages.sh", "--target", "ubuntu2204"]
diff --git a/build/package-build-dockerfiles/ubuntu2204/ports.list b/build/package-build-dockerfiles/ubuntu2204/ports.list
new file mode 100644 (file)
index 0000000..a32f44e
--- /dev/null
@@ -0,0 +1,8 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+deb [arch=arm64,armhf,ppc64el,s390x] http://ports.ubuntu.com/ubuntu-ports/ jammy main restricted universe multiverse
+deb [arch=arm64,armhf,ppc64el,s390x] http://ports.ubuntu.com/ubuntu-ports/ jammy-updates main restricted universe multiverse
+deb [arch=arm64,armhf,ppc64el,s390x] http://ports.ubuntu.com/ubuntu-ports/ jammy-backports main restricted universe multiverse
+deb [arch=arm64,armhf,ppc64el,s390x] http://ports.ubuntu.com/ubuntu-ports/ jammy-security main restricted universe multiverse
index 849decb9a5016bb2676ea4a9e0e92ba639edd6d9..02e2846a2a66dc8c0627b229297e70b02089decc 100644 (file)
@@ -2,28 +2,31 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-all: centos7/generated debian10/generated debian11/generated ubuntu1804/generated ubuntu2004/generated
-
-centos7/generated: common-generated-all
-       test -d centos7/generated || mkdir centos7/generated
-       cp -f -rlt centos7/generated common-generated/*
-
-debian10/generated: common-generated-all
-       test -d debian10/generated || mkdir debian10/generated
-       cp -f -rlt debian10/generated common-generated/*
-
+all: debian11/generated
 debian11/generated: common-generated-all
        test -d debian11/generated || mkdir debian11/generated
        cp -f -rlt debian11/generated common-generated/*
 
-ubuntu1804/generated: common-generated-all
-       test -d ubuntu1804/generated || mkdir ubuntu1804/generated
-       cp -f -rlt ubuntu1804/generated common-generated/*
+all: debian12/generated
+debian12/generated: common-generated-all
+       test -d debian12/generated || mkdir debian12/generated
+       cp -f -rlt debian12/generated common-generated/*
 
+all: rocky8/generated
+rocky8/generated: common-generated-all
+       test -d rocky8/generated || mkdir rocky8/generated
+       cp -f -rlt rocky8/generated common-generated/*
+
+all: ubuntu2004/generated
 ubuntu2004/generated: common-generated-all
        test -d ubuntu2004/generated || mkdir ubuntu2004/generated
        cp -f -rlt ubuntu2004/generated common-generated/*
 
+all: ubuntu2204/generated
+ubuntu2204/generated: common-generated-all
+       test -d ubuntu2204/generated || mkdir ubuntu2204/generated
+       cp -f -rlt ubuntu2204/generated common-generated/*
+
 RVMKEY1=mpapis.asc
 RVMKEY2=pkuczynski.asc
 
diff --git a/build/package-test-dockerfiles/centos7/Dockerfile b/build/package-test-dockerfiles/centos7/Dockerfile
deleted file mode 100644 (file)
index 1010ef8..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-FROM centos:7
-MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
-
-# Install dependencies.
-RUN yum -q -y install scl-utils centos-release-scl which tar wget
-
-# Install RVM
-ADD generated/mpapis.asc /tmp/
-ADD generated/pkuczynski.asc /tmp/
-RUN touch /var/lib/rpm/* && \
-    gpg --import --no-tty /tmp/mpapis.asc && \
-    gpg --import --no-tty /tmp/pkuczynski.asc && \
-    curl -L https://get.rvm.io | bash -s stable && \
-    /usr/local/rvm/bin/rvm install 2.7 -j $(grep -c processor /proc/cpuinfo) && \
-    /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
-    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.9
-
-# Install Bash 4.4.12  // see https://dev.arvados.org/issues/15612
-RUN cd /usr/local/src \
-&& wget http://ftp.gnu.org/gnu/bash/bash-4.4.12.tar.gz \
-&& wget http://ftp.gnu.org/gnu/bash/bash-4.4.12.tar.gz.sig \
-&& tar xzf bash-4.4.12.tar.gz \
-&& cd bash-4.4.12 \
-&& ./configure --prefix=/usr/local/$( basename $( pwd ) ) \
-&& make \
-&& make install \
-&& ln -sf /usr/local/src/bash-4.4.12/bash /bin/bash
-
-# Add epel, we need it for the python-pam dependency
-RUN wget http://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
-RUN rpm -ivh epel-release-latest-7.noarch.rpm
-
-COPY localrepo.repo /etc/yum.repos.d/localrepo.repo
diff --git a/build/package-test-dockerfiles/centos7/localrepo.repo b/build/package-test-dockerfiles/centos7/localrepo.repo
deleted file mode 100644 (file)
index ebb8765..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-[localrepo]
-name=Arvados Test
-baseurl=file:///arvados/packages/centos7
-gpgcheck=0
-enabled=1
diff --git a/build/package-test-dockerfiles/debian10/Dockerfile b/build/package-test-dockerfiles/debian10/Dockerfile
deleted file mode 100644 (file)
index e4b7993..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-FROM debian:buster
-MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
-
-ENV DEBIAN_FRONTEND noninteractive
-
-# Install dependencies
-RUN apt-get update && \
-    apt-get -y install --no-install-recommends curl ca-certificates gpg procps gpg-agent
-
-# Install RVM
-ADD generated/mpapis.asc /tmp/
-ADD generated/pkuczynski.asc /tmp/
-RUN gpg --import --no-tty /tmp/mpapis.asc && \
-    gpg --import --no-tty /tmp/pkuczynski.asc && \
-    curl -L https://get.rvm.io | bash -s stable && \
-    /usr/local/rvm/bin/rvm install 2.7 -j $(grep -c processor /proc/cpuinfo) && \
-    /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
-    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19
-
-# udev daemon can't start in a container, so don't try.
-RUN mkdir -p /etc/udev/disabled
-
-RUN echo "deb file:///arvados/packages/debian10/ /" >>/etc/apt/sources.list
diff --git a/build/package-test-dockerfiles/debian12/Dockerfile b/build/package-test-dockerfiles/debian12/Dockerfile
new file mode 100644 (file)
index 0000000..4cdc41d
--- /dev/null
@@ -0,0 +1,28 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+FROM debian:bookworm
+MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
+
+ENV DEBIAN_FRONTEND noninteractive
+
+# Install dependencies
+RUN apt-get update && \
+    apt-get -y install --no-install-recommends curl ca-certificates gpg procps gpg-agent
+
+# Install RVM
+ADD generated/mpapis.asc /tmp/
+ADD generated/pkuczynski.asc /tmp/
+RUN gpg --import --no-tty /tmp/mpapis.asc && \
+    gpg --import --no-tty /tmp/pkuczynski.asc && \
+    curl -L https://get.rvm.io | bash -s stable && \
+    /usr/local/rvm/bin/rvm install 3.2.2 -j $(grep -c processor /proc/cpuinfo) --disable-binary && \
+    /usr/local/rvm/bin/rvm alias create default ruby-3.2.2 && \
+    echo "gem: --no-document" >> /etc/gemrc && \
+    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19
+
+# udev daemon can't start in a container, so don't try.
+RUN mkdir -p /etc/udev/disabled
+
+RUN echo "deb file:///arvados/packages/debian12/ /" >>/etc/apt/sources.list
diff --git a/build/package-test-dockerfiles/rocky8/Dockerfile b/build/package-test-dockerfiles/rocky8/Dockerfile
new file mode 100644 (file)
index 0000000..809f362
--- /dev/null
@@ -0,0 +1,48 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+FROM rockylinux:8.6-minimal
+MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
+
+# Install dependencies.
+RUN microdnf --assumeyes --enablerepo=devel install \
+    autoconf \
+    automake \
+    bison \
+    bzip2 \
+    cpio \
+    diffutils \
+    findutils \
+    gcc-c++ \
+    glibc-devel \
+    glibc-headers \
+    gzip \
+    libffi-devel \
+    libtool \
+    make \
+    openssl-devel \
+    patch \
+    procps-ng \
+    python3 \
+    readline-devel \
+    ruby \
+    shadow-utils \
+    sqlite-devel \
+    tar \
+    wget \
+    which \
+    zlib-devel
+
+# Install RVM
+ADD generated/mpapis.asc /tmp/
+ADD generated/pkuczynski.asc /tmp/
+RUN touch /var/lib/rpm/* && \
+    gpg --import --no-tty /tmp/mpapis.asc && \
+    gpg --import --no-tty /tmp/pkuczynski.asc && \
+    curl -L https://get.rvm.io | bash -s stable && \
+    /usr/local/rvm/bin/rvm install --disable-binary 2.7 -j $(grep -c processor /proc/cpuinfo) && \
+    /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
+    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19
+
+COPY localrepo.repo /etc/yum.repos.d/localrepo.repo
diff --git a/build/package-test-dockerfiles/rocky8/localrepo.repo b/build/package-test-dockerfiles/rocky8/localrepo.repo
new file mode 100644 (file)
index 0000000..a4f6ab3
--- /dev/null
@@ -0,0 +1,5 @@
+[localrepo]
+name=Arvados Test
+baseurl=file:///arvados/packages/rocky8
+gpgcheck=0
+enabled=1
diff --git a/build/package-test-dockerfiles/ubuntu1804/Dockerfile b/build/package-test-dockerfiles/ubuntu1804/Dockerfile
deleted file mode 100644 (file)
index 64894d7..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-FROM ubuntu:bionic
-MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
-
-ENV DEBIAN_FRONTEND noninteractive
-
-# Install dependencies
-RUN apt-get update && \
-    apt-get -y install --no-install-recommends curl ca-certificates gnupg2
-
-# Install RVM
-ADD generated/mpapis.asc /tmp/
-ADD generated/pkuczynski.asc /tmp/
-RUN gpg --import --no-tty /tmp/mpapis.asc && \
-    gpg --import --no-tty /tmp/pkuczynski.asc && \
-    curl -L https://get.rvm.io | bash -s stable && \
-    /usr/local/rvm/bin/rvm install 2.7 -j $(grep -c processor /proc/cpuinfo) && \
-    /usr/local/rvm/bin/rvm alias create default ruby-2.7 && \
-    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19
-
-# udev daemon can't start in a container, so don't try.
-RUN mkdir -p /etc/udev/disabled
-
-RUN echo "deb [trusted=yes] file:///arvados/packages/ubuntu1804/ /" >>/etc/apt/sources.list
-
-# Add preferences file for the Arvados packages. This pins Arvados
-# packages at priority 501, so that older python dependency versions
-# are preferred in those cases where we need them
-ADD etc-apt-preferences.d-arvados /etc/apt/preferences.d/arvados
diff --git a/build/package-test-dockerfiles/ubuntu1804/etc-apt-preferences.d-arvados b/build/package-test-dockerfiles/ubuntu1804/etc-apt-preferences.d-arvados
deleted file mode 100644 (file)
index 9e24695..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-Package: *
-Pin: release o=Arvados
-Pin-Priority: 501
diff --git a/build/package-test-dockerfiles/ubuntu2204/Dockerfile b/build/package-test-dockerfiles/ubuntu2204/Dockerfile
new file mode 100644 (file)
index 0000000..4926a65
--- /dev/null
@@ -0,0 +1,27 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+FROM ubuntu:jammy
+LABEL org.opencontainers.image.authors="Arvados Package Maintainers <packaging@arvados.org>"
+
+ENV DEBIAN_FRONTEND noninteractive
+
+# Install dependencies
+RUN apt-get update && \
+    apt-get -y install --no-install-recommends curl ca-certificates gnupg2
+
+# Install RVM
+ADD generated/mpapis.asc /tmp/
+ADD generated/pkuczynski.asc /tmp/
+RUN gpg --import --no-tty /tmp/mpapis.asc && \
+    gpg --import --no-tty /tmp/pkuczynski.asc && \
+    curl -L https://get.rvm.io | bash -s stable && \
+    /usr/local/rvm/bin/rvm install 3.2.2 -j $(grep -c processor /proc/cpuinfo) && \
+    /usr/local/rvm/bin/rvm alias create default ruby-3.2.2 && \
+    /usr/local/rvm/bin/rvm-exec default gem install bundler --version 2.2.19
+
+# udev daemon can't start in a container, so don't try.
+RUN mkdir -p /etc/udev/disabled
+
+RUN echo "deb [trusted=yes] file:///arvados/packages/ubuntu2204/ /" >>/etc/apt/sources.list
index e04556bb6b50b2312129726cbddcd354b4fb7685..ee855d8012d6adccfb0670492f73152b5c78e5be 100755 (executable)
@@ -12,30 +12,6 @@ else
     PACKAGE_NAME=$1; shift
 fi
 
-if [ "$PACKAGE_NAME" = "arvados-workbench" ]; then
-  mkdir -p /etc/arvados
-  cat <<'EOF' >/etc/arvados/config.yml
----
-Clusters:
-  xxxxx:
-    Services:
-      Workbench1:
-        ExternalURL: "https://workbench.xxxxx.example.com"
-      WebDAV:
-        ExternalURL: https://*.collections.xxxxx.example.com/
-      WebDAVDownload:
-        ExternalURL: https://download.xxxxx.example.com
-    ManagementToken: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
-    SystemRootToken: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
-    Collections:
-      BlobSigningKey: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
-    Workbench:
-      SecretKeyBase: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
-    Users:
-      AutoAdminFirstUser: true
-EOF
-fi
-
 cd "/var/www/${PACKAGE_NAME%-server}/current"
 
 case "$TARGET" in
@@ -43,9 +19,9 @@ case "$TARGET" in
         apt-get install -y nginx
         dpkg-reconfigure "$PACKAGE_NAME"
         ;;
-    centos*)
-        yum install --assumeyes httpd
-        yum reinstall --assumeyes "$PACKAGE_NAME"
+    rocky*)
+        microdnf --assumeyes install httpd
+        microdnf --assumeyes reinstall "$PACKAGE_NAME"
         ;;
     *)
         echo -e "$0: Unknown target '$TARGET'.\n" >&2
index 32fb2009e15fa063de0b85622b5b98b0ef74cd1a..32788175d2dd6c3af2f53075dc697cd67f498425 100755 (executable)
@@ -47,13 +47,21 @@ fi
 dpkg-deb -x $debpkg .
 
 if [[ "$DEBUG" != "0" ]]; then
-  while read so && [ -n "$so" ]; do
-      echo
-      echo "== Packages dependencies for $so =="
-      ldd "$so" | awk '($3 ~ /^\//){print $3}' | sort -u | xargs dpkg -S | cut -d: -f1 | sort -u
-  done <<EOF
-$(find -name '*.so')
-EOF
+  find -type f -name '*.so' | while read so; do
+      printf "\n== Package dependencies for %s ==\n" "$so"
+      # dpkg is not fully aware of merged-/usr systems: ldd may list a library
+      # under /lib where dpkg thinks it's under /usr/lib, or vice versa.
+      # awk constructs globs that we pass to `dpkg --search` to be flexible
+      # about which version we find. This could potentially return multiple
+      # results, but doing better probably requires restructuring this whole
+      # code to find and report the best match across multiple dpkg queries.
+      ldd "$so" \
+          | awk 'BEGIN { ORS="\0" } ($3 ~ /^\//) {print "*" $3}' \
+          | sort --unique --zero-terminated \
+          | xargs -0 --no-run-if-empty dpkg --search \
+          | cut -d: -f1 \
+          | sort --unique
+  done
 fi
 
 exec /jenkins/package-testing/common-test-packages.sh "$1"
index 12450dd4f954acf65a58fe637880697be5918861..b6d7fec46876cd027ef1d34926d6563dd364cefc 100755 (executable)
@@ -14,40 +14,26 @@ if [[ "$DEBUG" != "0" ]]; then
   STDERR_IF_DEBUG=/dev/stderr
 fi
 
-target=$(basename "$0" | grep -Eo '\bcentos[[:digit:]]+\b')
+target="$(basename "$0" .sh)"
+target="${target##*-}"
 
-yum -q clean all
+microdnf --assumeyes clean all
 touch /var/lib/rpm/*
 
 export ARV_PACKAGES_DIR="/arvados/packages/$target"
 
 rpm -qa | sort > "$ARV_PACKAGES_DIR/$1.before"
-
-yum install --assumeyes -e 0 $1
-
+microdnf --assumeyes install "$1"
 rpm -qa | sort > "$ARV_PACKAGES_DIR/$1.after"
-
 diff "$ARV_PACKAGES_DIR/$1".{before,after} >"$ARV_PACKAGES_DIR/$1.diff" || true
 
-# Enable any Software Collections that the package depended on.
-if [[ -d /opt/rh ]]; then
-    # We have to stage the list to a file, because `ls | while read` would
-    # make a subshell, causing the `source` lines to have no effect.
-    scl_list=$(mktemp)
-    ls /opt/rh >"$scl_list"
-
-    # SCL scripts aren't designed to run with -eu.
-    set +eu
-    while read scl; do
-        source scl_source enable "$scl"
-    done <"$scl_list"
-    set -eu
-    rm "$scl_list"
-fi
-
 mkdir -p /tmp/opts
 cd /tmp/opts
 
+# Install other packages alongside to test for build id conflicts.
+# This line can be removed after we have test-provision-rocky8, #21426.
+microdnf --assumeyes install arvados-client arvados-server python3-arvados-python-client
+
 rpm2cpio $(ls -t "$ARV_PACKAGES_DIR/$1"-*.rpm | head -n1) | cpio -idm 2>/dev/null
 
 if [[ "$DEBUG" != "0" ]]; then
diff --git a/build/package-testing/test-package-arvados-client.sh b/build/package-testing/test-package-arvados-client.sh
new file mode 100755 (executable)
index 0000000..333038d
--- /dev/null
@@ -0,0 +1,8 @@
+#!/bin/sh
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+set -e
+
+arvados-client -version >/dev/null
index 1e294fe0a8be0e4b67511e7f4648116f822f5562..71668d099c44c9161ea7d6cb0158d28081457c0d 100755 (executable)
@@ -7,9 +7,7 @@ set -e
 
 arv-put --version >/dev/null
 
-PYTHON=`ls /usr/share/python3*/dist/python3-arvados-python-client/bin/python3 |head -n1`
-
-$PYTHON << EOF
+/usr/lib/python3-arvados-python-client/bin/python <<EOF
 import arvados
 print("Successfully imported arvados")
 EOF
diff --git a/build/package-testing/test-packages-debian12.sh b/build/package-testing/test-packages-debian12.sh
new file mode 120000 (symlink)
index 0000000..54ce94c
--- /dev/null
@@ -0,0 +1 @@
+deb-common-test-packages.sh
\ No newline at end of file
diff --git a/build/package-testing/test-packages-rocky8.sh b/build/package-testing/test-packages-rocky8.sh
new file mode 120000 (symlink)
index 0000000..64ef604
--- /dev/null
@@ -0,0 +1 @@
+rpm-common-test-packages.sh
\ No newline at end of file
diff --git a/build/package-testing/test-packages-ubuntu2204.sh b/build/package-testing/test-packages-ubuntu2204.sh
new file mode 120000 (symlink)
index 0000000..54ce94c
--- /dev/null
@@ -0,0 +1 @@
+deb-common-test-packages.sh
\ No newline at end of file
diff --git a/build/pypkg_info.py b/build/pypkg_info.py
new file mode 100644 (file)
index 0000000..45f8d16
--- /dev/null
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+"""pypkg_info.py - Introspect installed Python packages
+
+This tool can read metadata about any Python package installed in the current
+environment and report it out in various formats. We use this mainly to pass
+information through when building distribution packages.
+"""
+
+import argparse
+import enum
+import importlib.metadata
+import os
+import sys
+
+from pathlib import PurePath
+
+class RawFormat:
+    def format_metadata(self, key, value):
+        return value
+
+    def format_path(self, path):
+        return str(path)
+
+
+class FPMFormat(RawFormat):
+    PYTHON_METADATA_MAP = {
+        'summary': 'description',
+    }
+
+    def format_metadata(self, key, value):
+        key = key.lower()
+        key = self.PYTHON_METADATA_MAP.get(key, key)
+        return f'--{key}={value}'
+
+
+class Formats(enum.Enum):
+    RAW = RawFormat
+    FPM = FPMFormat
+
+    @classmethod
+    def from_arg(cls, arg):
+        try:
+            return cls[arg.upper()]
+        except KeyError:
+            raise ValueError(f"unknown format {arg!r}") from None
+
+
+def report_binfiles(args):
+    bin_names = [
+        PurePath('bin', path.name)
+        for pkg_name in args.package_names
+        for path in importlib.metadata.distribution(pkg_name).files
+        if path.parts[-3:-1] == ('..', 'bin')
+    ]
+    fmt = args.format.value().format_path
+    return (fmt(path) for path in bin_names)
+
+def report_metadata(args):
+    dist = importlib.metadata.distribution(args.package_name)
+    fmt = args.format.value().format_metadata
+    for key in args.metadata_key:
+        yield fmt(key, dist.metadata.get(key, ''))
+
+def unescape_str(arg):
+    arg = arg.replace('\'', '\\\'')
+    return eval(f"'''{arg}'''", {})
+
+def parse_arguments(arglist=None):
+    parser = argparse.ArgumentParser()
+    parser.set_defaults(action=None)
+    format_names = ', '.join(fmt.name.lower() for fmt in Formats)
+    parser.add_argument(
+        '--format', '-f',
+        choices=list(Formats),
+        default=Formats.RAW,
+        type=Formats.from_arg,
+        help=f"Output format. Choices are: {format_names}",
+    )
+    parser.add_argument(
+        '--delimiter', '-d',
+        default='\n',
+        type=unescape_str,
+        help="Line ending. Python backslash escapes are supported. Default newline.",
+    )
+    subparsers = parser.add_subparsers()
+
+    binfiles = subparsers.add_parser('binfiles')
+    binfiles.set_defaults(action=report_binfiles)
+    binfiles.add_argument(
+        'package_names',
+        nargs=argparse.ONE_OR_MORE,
+    )
+
+    metadata = subparsers.add_parser('metadata')
+    metadata.set_defaults(action=report_metadata)
+    metadata.add_argument(
+        'package_name',
+    )
+    metadata.add_argument(
+        'metadata_key',
+        nargs=argparse.ONE_OR_MORE,
+    )
+
+    args = parser.parse_args()
+    if args.action is None:
+        parser.error("subcommand is required")
+    return args
+
+def main(arglist=None):
+    args = parse_arguments(arglist)
+    try:
+        for line in args.action(args):
+            print(line, end=args.delimiter)
+    except importlib.metadata.PackageNotFoundError as error:
+        print(f"error: package not found: {error.args[0]}", file=sys.stderr)
+        return os.EX_NOTFOUND
+    else:
+        return os.EX_OK
+
+if __name__ == '__main__':
+    exit(main())
index a513c3ad09f885dc18c7187822418d77e535e201..a0e356ce327cba01b876b1330c7430462c317542 100644 (file)
@@ -10,7 +10,7 @@ INSTALL_PATH=/var/www/arvados-api
 CONFIG_PATH=/etc/arvados/api
 DOC_URL="http://doc.arvados.org/install/install-api-server.html#configure"
 
-RAILSPKG_DATABASE_LOAD_TASK=db:structure:load
+RAILSPKG_DATABASE_LOAD_TASK=db:schema:load
 setup_extra_conffiles() {
   # Rails 5.2 does not tolerate dangling symlinks in the initializers directory, and this one
   # can still be there, left over from a previous version of the API server package.
diff --git a/build/rails-package-scripts/arvados-workbench.sh b/build/rails-package-scripts/arvados-workbench.sh
deleted file mode 100644 (file)
index 878c137..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/sh
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# This file declares variables common to all scripts for one Rails package.
-
-PACKAGE_NAME=arvados-workbench
-INSTALL_PATH=/var/www/arvados-workbench
-CONFIG_PATH=/etc/arvados/workbench
-DOC_URL="http://doc.arvados.org/install/install-workbench-app.html#configure"
index f6ae48c0fc4e9373be9d3756698016f28f64493d..e317f85aaff27ac246885c76263ed5365d75cbc2 100644 (file)
@@ -218,8 +218,11 @@ configure_version() {
   # Make sure postgres doesn't try to use a pager.
   export PAGER=
   case "$RAILSPKG_DATABASE_LOAD_TASK" in
-      db:schema:load) chown "$WWW_OWNER:" $RELEASE_PATH/db/schema.rb ;;
-      db:structure:load) chown "$WWW_OWNER:" $RELEASE_PATH/db/structure.sql ;;
+      # db:structure:load was deprecated in Rails 6.1 and shouldn't be used.
+      db:schema:load | db:structure:load)
+          chown "$WWW_OWNER:" $RELEASE_PATH/db/schema.rb || true
+          chown "$WWW_OWNER:" $RELEASE_PATH/db/structure.sql || true
+          ;;
   esac
   chmod 644 $SHARED_PATH/log/*
   chmod -R 2775 $RELEASE_PATH/tmp || true
index 00ef2de417d77a230d5abad0975c4376317b5872..d7ee41743f99836b52fde679f2a0f818663515db 100755 (executable)
@@ -90,25 +90,28 @@ docker_push () {
 
     if [[ ! -z "$tags" ]]
     then
-        for tag in $( echo $tags|tr "," " " )
+        for tag in $(echo $tags|tr "," " " )
         do
              $DOCKER tag $1:$GITHEAD $1:$tag
         done
     fi
 
-    # Sometimes docker push fails; retry it a few times if necessary.
-    for i in `seq 1 5`; do
-        $DOCKER push $*
-        ECODE=$?
-        if [[ "$ECODE" == "0" ]]; then
-            break
-        fi
+    for tag in $(echo $tags|tr "," " " )
+    do
+       # Sometimes docker push fails; retry it a few times if necessary.
+       for i in `seq 1 5`; do
+             $DOCKER push $1:$tag
+             ECODE=$?
+             if [[ "$ECODE" == "0" ]]; then
+                break
+             fi
+       done
+
+       if [[ "$ECODE" != "0" ]]; then
+            title "!!!!!! docker push $1:$tag failed !!!!!!"
+            EXITCODE=$(($EXITCODE + $ECODE))
+       fi
     done
-
-    if [[ "$ECODE" != "0" ]]; then
-        title "!!!!!! docker push $* failed !!!!!!"
-        EXITCODE=$(($EXITCODE + $ECODE))
-    fi
 }
 
 timer_reset() {
index 905af1cbc62a76cc314ccba3dcdd9fa1145d2586..37fe7052413c95b118f97b7a390f5bbfb14dee0f 100755 (executable)
@@ -7,10 +7,10 @@ read -rd "\000" helpmessage <<EOF
 $(basename $0): Orchestrate run-build-packages.sh for one target
 
 Syntax:
-        WORKSPACE=/path/to/arvados $(basename $0) [options]
+        WORKSPACE=/path/to/arvados $(basename $0) --target <target> [options]
 
 --target <target>
-    Distribution to build packages for (default: debian10)
+    Distribution to build packages for
 --command
     Build command to execute (default: use built-in Docker image command)
 --test-packages
@@ -32,6 +32,8 @@ Syntax:
     Version to build (default:
     \$ARVADOS_BUILDING_VERSION-\$ARVADOS_BUILDING_ITERATION or
     0.1.timestamp.commithash)
+--skip-docker-build
+    Don't try to build Docker images
 
 WORKSPACE=path         Path to the Arvados source tree to build packages from
 
@@ -56,16 +58,16 @@ if ! [[ -d "$WORKSPACE" ]]; then
 fi
 
 PARSEDOPTS=$(getopt --name "$0" --longoptions \
-    help,debug,test-packages,target:,command:,only-test:,force-test,only-build:,force-build,arch:,build-version: \
+    help,debug,test-packages,target:,command:,only-test:,force-test,only-build:,force-build,arch:,build-version:,skip-docker-build \
     -- "" "$@")
 if [ $? -ne 0 ]; then
     exit 1
 fi
 
-TARGET=debian10
 FORCE_BUILD=0
 COMMAND=
 DEBUG=
+TARGET=
 
 eval set -- "$PARSEDOPTS"
 while [ $# -gt 0 ]; do
@@ -121,6 +123,9 @@ while [ $# -gt 0 ]; do
             fi
             shift
             ;;
+        --skip-docker-build)
+            SKIP_DOCKER_BUILD=1
+           ;;
         --)
             if [ $# -gt 1 ]; then
                 echo >&2 "$0: unrecognized argument '$2'. Try: $0 --help"
@@ -132,23 +137,36 @@ while [ $# -gt 0 ]; do
 done
 
 set -e
+orig_umask="$(umask)"
+
+if [[ -z "$TARGET" ]]; then
+    echo "FATAL: --target must be specified" >&2
+    exit 2
+elif [[ ! -d "$WORKSPACE/build/package-build-dockerfiles/$TARGET" ]]; then
+    echo "FATAL: unknown build target '$TARGET'" >&2
+    exit 2
+fi
 
 if [[ -n "$ARVADOS_BUILDING_VERSION" ]]; then
     echo "build version='$ARVADOS_BUILDING_VERSION', package iteration='$ARVADOS_BUILDING_ITERATION'"
 fi
 
 if [[ -n "$test_packages" ]]; then
+  # Packages are built world-readable, so package indexes should be too,
+  # especially because since 2022 apt uses an unprivileged user `_apt` to
+  # retrieve everything.  Ensure it has permissions to read the packages
+  # when mounted as a volume inside the Docker container.
+  chmod a+rx "$WORKSPACE" "$WORKSPACE/packages" "$WORKSPACE/packages/$TARGET"
+  umask 022
   if [[ -n "$(find $WORKSPACE/packages/$TARGET -name '*.rpm')" ]] ; then
-    set +e
-    /usr/bin/which createrepo >/dev/null
-    if [[ "$?" != "0" ]]; then
+    CREATEREPO="$(command -v createrepo createrepo_c | tail -n1)"
+    if [[ -z "$CREATEREPO" ]]; then
       echo >&2
-      echo >&2 "Error: please install createrepo. E.g. sudo apt-get install createrepo"
+      echo >&2 "Error: please install createrepo. E.g. sudo apt install createrepo-c"
       echo >&2
       exit 1
     fi
-    set -e
-    createrepo $WORKSPACE/packages/$TARGET
+    "$CREATEREPO" $WORKSPACE/packages/$TARGET
   fi
 
   if [[ -n "$(find $WORKSPACE/packages/$TARGET -name '*.deb')" ]] ; then
@@ -176,6 +194,7 @@ if [[ -n "$test_packages" ]]; then
 
   COMMAND="/jenkins/package-testing/test-packages-$TARGET.sh"
   IMAGE="arvados/package-test:$TARGET"
+  umask "$orig_umask"
 else
   IMAGE="arvados/build:$TARGET"
   if [[ "$COMMAND" != "" ]]; then
@@ -185,23 +204,25 @@ fi
 
 JENKINS_DIR=$(dirname "$(readlink -e "$0")")
 
-if [[ -n "$test_packages" ]]; then
-    pushd "$JENKINS_DIR/package-test-dockerfiles"
-    make "$TARGET/generated"
-else
-    pushd "$JENKINS_DIR/package-build-dockerfiles"
-    make "$TARGET/generated"
-fi
+if [[ "$SKIP_DOCKER_BUILD" != 1 ]] ; then
+    if [[ -n "$test_packages" ]]; then
+       pushd "$JENKINS_DIR/package-test-dockerfiles"
+       make "$TARGET/generated"
+    else
+       pushd "$JENKINS_DIR/package-build-dockerfiles"
+       make "$TARGET/generated"
+    fi
 
-GOVERSION=$(grep 'const goversion =' $WORKSPACE/lib/install/deps.go |awk -F'"' '{print $2}')
+    GOVERSION=$(grep 'const goversion =' $WORKSPACE/lib/install/deps.go |awk -F'"' '{print $2}')
 
-echo $TARGET
-cd $TARGET
-time docker build --tag "$IMAGE" \
-  --build-arg HOSTTYPE=$HOSTTYPE \
-  --build-arg BRANCH=$(git rev-parse --abbrev-ref HEAD) \
-  --build-arg GOVERSION=$GOVERSION --no-cache .
-popd
+    echo $TARGET
+    cd $TARGET
+    time docker build --tag "$IMAGE" \
+        --build-arg HOSTTYPE=$HOSTTYPE \
+        --build-arg BRANCH=$(git rev-parse HEAD) \
+        --build-arg GOVERSION=$GOVERSION --no-cache .
+    popd
+fi
 
 if test -z "$packages" ; then
     packages="arvados-api-server
@@ -216,45 +237,38 @@ if test -z "$packages" ; then
         arvados-src
         arvados-sync-groups
         arvados-sync-users
-        arvados-workbench
         arvados-workbench2
         arvados-ws
         crunch-dispatch-local
         crunch-dispatch-slurm
         crunch-run
-        crunchstat
-        keepproxy
-        keepstore
         keep-balance
         keep-block-check
-        keep-rsync
         keep-exercise
         keep-rsync
-        keep-block-check
         keep-web
+        keepproxy
+        keepstore
         libpam-arvados-go
-        python3-cwltest
+        python3-arvados-cwl-runner
         python3-arvados-fuse
         python3-arvados-python-client
-        python3-arvados-cwl-runner
+        python3-arvados-user-activity
         python3-crunchstat-summary
-        python3-arvados-user-activity"
+        python3-cwltest"
 fi
 
 FINAL_EXITCODE=0
 
 package_fails=""
 
-mkdir -p "$WORKSPACE/apps/workbench/vendor/cache-$TARGET"
 mkdir -p "$WORKSPACE/services/api/vendor/cache-$TARGET"
 
 docker_volume_args=(
     -v "$JENKINS_DIR:/jenkins"
     -v "$WORKSPACE:/arvados"
     -v /arvados/services/api/vendor/bundle
-    -v /arvados/apps/workbench/vendor/bundle
     -v "$WORKSPACE/services/api/vendor/cache-$TARGET:/arvados/services/api/vendor/cache"
-    -v "$WORKSPACE/apps/workbench/vendor/cache-$TARGET:/arvados/apps/workbench/vendor/cache"
 )
 
 if [[ -n "$test_packages" ]]; then
index aded25b592a3a941e794d0223cd0f0c8ea5f412a..ada3bf8b6c00e3c5be06e0d8466d8eec082b8380 100755 (executable)
@@ -9,16 +9,16 @@ read -rd "\000" helpmessage <<EOF
 $(basename "$0"): Build Arvados packages
 
 Syntax:
-        WORKSPACE=/path/to/arvados $(basename "$0") [options]
+        WORKSPACE=/path/to/arvados $(basename "$0") --target <target> [options]
 
 Options:
 
 --build-bundle-packages  (default: false)
-    Build api server and workbench packages with vendor/bundle included
+    Build api server package with vendor/bundle included
 --debug
     Output debug information (default: false)
 --target <target>
-    Distribution to build packages for (default: debian10)
+    Distribution to build packages for
 --only-build <package>
     Build only a specific package (or ONLY_BUILD from environment)
 --arch <arch>
@@ -47,8 +47,8 @@ VENDOR="The Arvados Project"
 DEBUG=${ARVADOS_DEBUG:-0}
 FORCE_BUILD=${FORCE_BUILD:-0}
 EXITCODE=0
-TARGET=debian10
 COMMAND=
+TARGET=
 
 PARSEDOPTS=$(getopt --name "$0" --longoptions \
     help,build-bundle-packages,debug,target:,only-build:,arch:,force-build \
@@ -93,6 +93,14 @@ while [ $# -gt 0 ]; do
     shift
 done
 
+if [[ -z "$TARGET" ]]; then
+    echo "FATAL: --target must be specified" >&2
+    exit 2
+elif [[ ! -d "$WORKSPACE/build/package-build-dockerfiles/$TARGET" ]]; then
+    echo "FATAL: unknown build target '$TARGET'" >&2
+    exit 2
+fi
+
 if [[ "$COMMAND" != "" ]]; then
   COMMAND="/usr/local/rvm/bin/rvm-exec default bash /jenkins/$COMMAND --target $TARGET"
 fi
@@ -106,47 +114,42 @@ if [[ "$DEBUG" != 0 ]]; then
     DASHQ_UNLESS_DEBUG=
 fi
 
-declare -a PYTHON3_BACKPORTS
-
-PYTHON3_EXECUTABLE=python3
-PYTHON3_VERSION=$($PYTHON3_EXECUTABLE -c 'import sys; print("{v.major}.{v.minor}".format(v=sys.version_info))')
-
-## These defaults are suitable for any Debian-based distribution.
-# You can customize them as needed in distro sections below.
-PYTHON3_PACKAGE=python$PYTHON3_VERSION
+# The next section defines a bunch of constants used to build distro packages
+# for our Python tools. Because those packages include C extensions, they need
+# to depend on and refer to a specific minor version of Python 3. The logic
+# below should Just Work for most cases, but you can override variables for a
+# specific distro if you need to to do something weird.
+# * PYTHON3_VERSION: The major+minor version of Python we build against
+#   (e.g., "3.11")
+# * PYTHON3_EXECUTABLE: The command to run that version of Python,
+#   either a full path or something in $PATH (e.g., "python3.11")
+# * PYTHON3_PACKAGE: The name of the distro package that provides
+#   $PYTHON3_EXECUTABLE. Our Python packages will all depend on this.
+# * PYTHON3_PKG_PREFIX: The prefix used in the names of all of our Python
+#   packages. This should match distro convention.
 PYTHON3_PKG_PREFIX=python3
-PYTHON3_PREFIX=/usr
-PYTHON3_INSTALL_LIB=lib/python$PYTHON3_VERSION/dist-packages
-## End Debian Python defaults.
-
 case "$TARGET" in
-    debian*)
-        FORMAT=deb
-        ;;
-    ubuntu1804)
-        FORMAT=deb
-        PYTHON3_EXECUTABLE=python3.8
-        PYTHON3_VERSION=$($PYTHON3_EXECUTABLE -c 'import sys; print("{v.major}.{v.minor}".format(v=sys.version_info))')
-        PYTHON3_PACKAGE=python$PYTHON3_VERSION
-        PYTHON3_INSTALL_LIB=lib/python$PYTHON3_VERSION/dist-packages
+    centos*|rocky*)
+        FORMAT=rpm
         ;;
-    ubuntu*)
+    debian*|ubuntu*)
         FORMAT=deb
         ;;
-    centos*)
-        FORMAT=rpm
-        PYTHON3_PACKAGE=$(rpm -qf "$(which python"$PYTHON3_VERSION")" --queryformat '%{NAME}\n')
-        PYTHON3_PKG_PREFIX=$PYTHON3_PACKAGE
-        PYTHON3_PREFIX=/usr
-        PYTHON3_INSTALL_LIB=lib/python$PYTHON3_VERSION/site-packages
-        export PYCURL_SSL_LIBRARY=nss
-        ;;
     *)
         echo -e "$0: Unknown target '$TARGET'.\n" >&2
         exit 1
         ;;
 esac
-
+: "${PYTHON3_VERSION:=$("${PYTHON3_EXECUTABLE:-python3}" -c 'import sys; print("{v.major}.{v.minor}".format(v=sys.version_info))')}"
+: "${PYTHON3_EXECUTABLE:=python$PYTHON3_VERSION}"
+case "$FORMAT" in
+    deb)
+        : "${PYTHON3_PACKAGE:=python$PYTHON3_VERSION}"
+        ;;
+    rpm)
+        : "${PYTHON3_PACKAGE:=$(rpm -qf "$(command -v "$PYTHON3_EXECUTABLE")" --queryformat '%{NAME}\n')}"
+        ;;
+esac
 
 if [[ -z "$WORKSPACE" ]]; then
   echo >&2 "$helpmessage"
@@ -247,8 +250,6 @@ package_go_binary cmd/arvados-server crunch-dispatch-slurm "$FORMAT" "$ARCH" \
     "Dispatch Crunch containers to a SLURM cluster"
 package_go_binary cmd/arvados-server crunch-run "$FORMAT" "$ARCH" \
     "Supervise a single Crunch container"
-package_go_binary services/crunchstat crunchstat "$FORMAT" "$ARCH" \
-    "Gather cpu/memory/network statistics of running Crunch jobs"
 package_go_binary cmd/arvados-server arvados-health "$FORMAT" "$ARCH" \
     "Check health of all Arvados cluster services"
 package_go_binary cmd/arvados-server keep-balance "$FORMAT" "$ARCH" \
@@ -277,42 +278,25 @@ package_go_so lib/pam pam_arvados.so libpam-arvados-go "$FORMAT" "$ARCH" \
 # Python packages
 debug_echo -e "\nPython packages\n"
 
-# The Python SDK - Python3 package
+# Before a Python package can be built, its dependencies must already be built.
+# This list is ordered accordingly.
+setup_build_virtualenv
+fpm_build_virtualenv cwltest "==2.3.20230108193615" "$FORMAT" "$ARCH"
 fpm_build_virtualenv "arvados-python-client" "sdk/python" "$FORMAT" "$ARCH"
-
-# Arvados cwl runner - Python3 package
-fpm_build_virtualenv "arvados-cwl-runner" "sdk/cwl" "$FORMAT" "$ARCH"
-
-# The FUSE driver - Python3 package
-fpm_build_virtualenv "arvados-fuse" "services/fuse" "$FORMAT" "$ARCH"
-
-# The Arvados crunchstat-summary tool
 fpm_build_virtualenv "crunchstat-summary" "tools/crunchstat-summary" "$FORMAT" "$ARCH"
-
-# The Docker image cleaner
+fpm_build_virtualenv "arvados-cwl-runner" "sdk/cwl" "$FORMAT" "$ARCH"
 fpm_build_virtualenv "arvados-docker-cleaner" "services/dockercleaner" "$FORMAT" "$ARCH"
-
-# The Arvados user activity tool
+fpm_build_virtualenv "arvados-fuse" "services/fuse" "$FORMAT" "$ARCH"
 fpm_build_virtualenv "arvados-user-activity" "tools/user-activity" "$FORMAT" "$ARCH"
 
-# The python->python3 metapackages
-build_metapackage "arvados-fuse" "services/fuse"
-build_metapackage "arvados-python-client" "services/fuse"
-build_metapackage "arvados-cwl-runner" "sdk/cwl"
-build_metapackage "crunchstat-summary" "tools/crunchstat-summary"
-build_metapackage "arvados-docker-cleaner" "services/dockercleaner"
-build_metapackage "arvados-user-activity" "tools/user-activity"
-
-# The cwltest package, which lives out of tree
-handle_cwltest "$FORMAT" "$ARCH"
+# Workbench2
+package_workbench2
 
 # Rails packages
 debug_echo -e "\nRails packages\n"
 
 # The rails api server package
 handle_api_server "$ARCH"
-# The rails workbench package
-handle_workbench "$ARCH"
 
 # clean up temporary GOPATH
 rm -rf "$GOPATH"
index aa4acb6a2bf7817f933f9bdb85b74789c91b154b..d1217162e6109ad74bddb27ba8ea847092d074cb 100755 (executable)
@@ -7,10 +7,10 @@ read -rd "\000" helpmessage <<EOF
 $(basename $0): Build, test and (optionally) upload packages for one target
 
 Syntax:
-        WORKSPACE=/path/to/arvados $(basename $0) [options]
+        WORKSPACE=/path/to/arvados $(basename $0) --target <target> [options]
 
 --target <target>
-    Distribution to build packages for (default: debian10)
+    Distribution to build packages for
 --only-build <package>
     Build only a specific package (or ONLY_BUILD from environment)
 --arch <arch>
@@ -31,6 +31,8 @@ Syntax:
     Version to build (default:
     \$ARVADOS_BUILDING_VERSION-\$ARVADOS_BUILDING_ITERATION or
     0.1.timestamp.commithash)
+--skip-docker-build
+    Don't try to build Docker images
 
 WORKSPACE=path         Path to the Arvados source tree to build packages from
 
@@ -53,16 +55,16 @@ if ! [[ -d "$WORKSPACE" ]]; then
 fi
 
 PARSEDOPTS=$(getopt --name "$0" --longoptions \
-    help,debug,upload,rc,target:,force-test,only-build:,force-build,arch:,build-version: \
+    help,debug,upload,rc,target:,force-test,only-build:,force-build,arch:,build-version:,skip-docker-build \
     -- "" "$@")
 if [ $? -ne 0 ]; then
     exit 1
 fi
 
-TARGET=debian10
 UPLOAD=0
 RC=0
 DEBUG=
+TARGET=
 
 declare -a build_args=()
 
@@ -102,6 +104,9 @@ while [ $# -gt 0 ]; do
             build_args+=("$1" "$2")
             shift
             ;;
+        --skip-docker-build)
+            SKIP_DOCKER_BUILD=1
+           ;;
         --)
             if [ $# -gt 1 ]; then
                 echo >&2 "$0: unrecognized argument '$2'. Try: $0 --help"
@@ -112,6 +117,14 @@ while [ $# -gt 0 ]; do
     shift
 done
 
+if [[ -z "$TARGET" ]]; then
+    echo "FATAL: --target must be specified" >&2
+    exit 2
+elif [[ ! -d "$WORKSPACE/build/package-build-dockerfiles/$TARGET" ]]; then
+    echo "FATAL: unknown build target '$TARGET'" >&2
+    exit 2
+fi
+
 build_args+=(--target "$TARGET")
 
 if [[ -n "$ONLY_BUILD" ]]; then
@@ -126,6 +139,10 @@ if [[ -n "$FORCE_TEST" ]]; then
   build_args+=(--force-test)
 fi
 
+if [[ "$SKIP_DOCKER_BUILD" = 1 ]]; then
+  build_args+=(--skip-docker-build)
+fi
+
 if [[ -n "$ARCH" ]]; then
   build_args+=(--arch "$ARCH")
 fi
index c2466faac0f38f66ade7a3e2d3c509a1e9acbbe8..03d99b13274d233e8d3548a8631ded2566c3be6b 100755 (executable)
@@ -115,6 +115,25 @@ handle_ruby_gem() {
     fi
 }
 
+# Usage: package_workbench2
+package_workbench2() {
+    local pkgname=arvados-workbench2
+    local src=services/workbench2
+    local dst=/var/www/arvados-workbench2/workbench2
+    local description="Arvados Workbench 2"
+    cd "$WORKSPACE/$src"
+    local version="$(version_from_git)"
+    rm -rf ./build
+    NODE_ENV=production yarn install
+    VERSION="$version" BUILD_NUMBER="$(default_iteration "$pkgname" "$version" yarn)" GIT_COMMIT="$(git rev-parse HEAD | head -c9)" yarn build
+    cd "$WORKSPACE/packages/$TARGET"
+    fpm_build "${WORKSPACE}/$src" "${WORKSPACE}/$src/build/=$dst" "$pkgname" dir "$version" \
+              --license="GNU Affero General Public License, version 3.0" \
+              --description="${description}" \
+              --config-files="/etc/arvados/$pkgname/workbench2.example.json" \
+              "$WORKSPACE/services/workbench2/etc/arvados/workbench2/workbench2.example.json=/etc/arvados/$pkgname/workbench2.example.json"
+}
+
 calculate_go_package_version() {
   # $__returnvar has the nameref attribute set, which means it is a reference
   # to another variable that is passed in as the first argument to this function.
@@ -159,12 +178,8 @@ package_go_binary() {
   local license_file="${1:-agpl-3.0.txt}"; shift
 
   if [[ -n "$ONLY_BUILD" ]] && [[ "$prog" != "$ONLY_BUILD" ]]; then
-    # arvados-workbench depends on arvados-server at build time, so even when
-    # only arvados-workbench is being built, we need to build arvados-server too
-    if [[ "$prog" != "arvados-server" ]] || [[ "$ONLY_BUILD" != "arvados-workbench" ]]; then
       debug_echo -e "Skipping build of $prog package."
       return 0
-    fi
   fi
 
   native_arch=$(get_native_arch)
@@ -174,25 +189,23 @@ package_go_binary() {
     return 1
   fi
 
-  cross_compilation=1
-  if [[ "$TARGET" == "centos7" ]]; then
-    if [[ "$native_arch" == "amd64" ]] && [[ -n "$target_arch" ]] && [[ "$native_arch" != "$target_arch" ]]; then
-      echo "Error: no cross compilation support for Go on $native_arch for $TARGET, can not build $prog for $target_arch"
-      return 1
-    fi
-    cross_compilation=0
-  fi
-
-  if [[ "$package_format" == "deb" ]] &&
-     [[ "$TARGET" == "debian10" ]] || [[ "$TARGET" == "ubuntu1804" ]] || [[ "$TARGET" == "ubuntu2004" ]]; then
-    # Due to bug https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=983477 the libfuse-dev package for arm64 does
-    # not install properly side by side with the amd64 version before Debian 11.
-    if [[ "$native_arch" == "amd64" ]] && [[ -n "$target_arch" ]] && [[ "$native_arch" != "$target_arch" ]]; then
-      echo "Error: no cross compilation support for Go on $native_arch for $TARGET, can not build $prog for $target_arch"
-      return 1
-    fi
-    cross_compilation=0
-  fi
+  case "$package_format-$TARGET" in
+    # Ubuntu 20.04 does not support cross compilation because the
+    # libfuse package does not support multiarch. See
+    # <https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=983477>.
+    # Red Hat-based distributions do not support native cross compilation at
+    # all (they use a qemu-based solution we haven't implemented yet).
+    deb-ubuntu2004|rpm-*)
+      cross_compilation=0
+      if [[ "$native_arch" == "amd64" ]] && [[ -n "$target_arch" ]] && [[ "$native_arch" != "$target_arch" ]]; then
+        echo "Error: no cross compilation support for Go on $native_arch for $TARGET, can not build $prog for $target_arch"
+        return 1
+      fi
+      ;;
+    *)
+      cross_compilation=1
+      ;;
+  esac
 
   if [[ -n "$target_arch" ]]; then
     archs=($target_arch)
@@ -249,6 +262,13 @@ package_go_binary_worker() {
       binpath="$GOPATH/bin/linux_${target_arch}/${basename}"
     fi
 
+    case "$package_format" in
+        # As of April 2024 we package identical Go binaries under different
+        # packages and names. This upsets the build id database, so don't
+        # register ourselves there.
+        rpm) switches+=(--rpm-rpmbuild-define="_build_id_links none") ;;
+    esac
+
     systemd_unit="$WORKSPACE/${src_path}/${prog}.service"
     if [[ -e "${systemd_unit}" ]]; then
         switches+=(
@@ -332,7 +352,7 @@ rails_package_version() {
         return
     fi
     local version="$(version_from_git)"
-    if [ $pkgname = "arvados-api-server" -o $pkgname = "arvados-workbench" ] ; then
+    if [ $pkgname = "arvados-api-server" ] ; then
         calculate_go_package_version version cmd/arvados-server "$srcdir"
     fi
     echo $version
@@ -412,11 +432,7 @@ test_package_presence() {
     local iteration="$1"; shift
     local arch="$1"; shift
     if [[ -n "$ONLY_BUILD" ]] && [[ "$pkgname" != "$ONLY_BUILD" ]] ; then
-      # arvados-workbench depends on arvados-server at build time, so even when
-      # only arvados-workbench is being built, we need to build arvados-server too
-      if [[ "$pkgname" != "arvados-server" ]] || [[ "$ONLY_BUILD" != "arvados-workbench" ]]; then
         return 1
-      fi
     fi
 
     local full_pkgname
@@ -431,10 +447,10 @@ test_package_presence() {
       echo "Package $full_pkgname build forced with --force-build, building"
     elif [[ "$FORMAT" == "deb" ]]; then
       declare -A dd
-      dd[debian10]=buster
       dd[debian11]=bullseye
-      dd[ubuntu1804]=bionic
+      dd[debian12]=bookworm
       dd[ubuntu2004]=focal
+      dd[ubuntu2204]=jammy
       D=${dd[$TARGET]}
       if [ ${pkgname:0:3} = "lib" ]; then
         repo_subdir=${pkgname:0:4}
@@ -456,15 +472,20 @@ test_package_presence() {
         return 0
       fi
     else
-      centos_repo="http://rpm.arvados.org/CentOS/7/dev/x86_64/"
-
-      repo_pkg_list=$(curl -s -o - ${centos_repo})
-      echo ${repo_pkg_list} |grep -q ${full_pkgname}
-      if [ $? -eq 0 ]; then
+      local rpm_root
+      case "$TARGET" in
+        rocky8) rpm_root="CentOS/8/dev" ;;
+        *)
+          echo "FIXME: Don't know RPM URL path for $TARGET, building"
+          return 0
+          ;;
+      esac
+      local rpm_url="http://rpm.arvados.org/$rpm_root/$arch/$full_pkgname"
+
+      if curl -fs -o "$WORKSPACE/packages/$TARGET/$full_pkgname" "$rpm_url"; then
         echo "Package $full_pkgname exists upstream, not rebuilding, downloading instead!"
-        curl -s -o "$WORKSPACE/packages/$TARGET/${full_pkgname}" ${centos_repo}${full_pkgname}
         return 1
-      elif test -f "$WORKSPACE/packages/$TARGET/processed/${full_pkgname}" ; then
+      elif [[ -f "$WORKSPACE/packages/$TARGET/processed/$full_pkgname" ]]; then
         echo "Package $full_pkgname exists, not rebuilding!"
         return 1
       else
@@ -512,13 +533,10 @@ handle_rails_package() {
     fi
     # For some reason fpm excludes need to not start with /.
     local exclude_root="${railsdir#/}"
-    local -a exclude_list=(tmp log coverage Capfile\* \
-                           config/deploy\* config/application.yml)
-    # for arvados-workbench, we need to have the (dummy) config/database.yml in the package
-    if  [[ "$pkgname" != "arvados-workbench" ]]; then
-      exclude_list+=('config/database.yml')
-    fi
-    for exclude in ${exclude_list[@]}; do
+    for exclude in tmp log coverage Capfile\* \
+                       config/deploy\* \
+                       config/application.yml \
+                       config/database.yml; do
         switches+=(-x "$exclude_root/$exclude")
     done
     fpm_build "${srcdir}" "${pos_args[@]}" "${switches[@]}" \
@@ -555,112 +573,6 @@ handle_api_server () {
   fi
 }
 
-# Usage: handle_workbench [amd64|arm64]
-handle_workbench () {
-  local target_arch="${1:-amd64}"; shift
-  if [[ -n "$ONLY_BUILD" ]] && [[ "$ONLY_BUILD" != "arvados-workbench" ]] ; then
-    debug_echo -e "Skipping build of arvados-workbench package."
-    return 0
-  fi
-
-  native_arch=$(get_native_arch)
-  if [[ "$target_arch" != "$native_arch" ]]; then
-    echo "Error: no cross compilation support for Rails yet, can not build arvados-workbench for $native_arch"
-    echo
-    exit 1
-  fi
-
-  if [[ "$native_arch" != "amd64" ]]; then
-    echo "Error: building the arvados-workbench package is not yet supported on this architecture ($native_arch)."
-    echo
-    exit 1
-  fi
-
-  # Build the workbench server package
-  test_rails_package_presence arvados-workbench "$WORKSPACE/apps/workbench"
-  if [[ "$?" == "0" ]] ; then
-    calculate_go_package_version arvados_server_version cmd/arvados-server
-    arvados_server_iteration=$(default_iteration "arvados-server" "$arvados_server_version" "go")
-
-    (
-        set -e
-
-        # The workbench package has a build-time dependency on the arvados-server
-        # package for config manipulation, so install it first.
-        cd $WORKSPACE/cmd/arvados-server
-        get_complete_package_name arvados_server_pkgname arvados-server ${arvados_server_version} go
-
-        arvados_server_pkg_path="$WORKSPACE/packages/$TARGET/${arvados_server_pkgname}"
-        if [[ ! -e ${arvados_server_pkg_path} ]]; then
-          arvados_server_pkg_path="$WORKSPACE/packages/$TARGET/processed/${arvados_server_pkgname}"
-        fi
-        if [[ "$FORMAT" == "deb" ]]; then
-          dpkg -i ${arvados_server_pkg_path}
-        else
-          rpm -i ${arvados_server_pkg_path}
-        fi
-
-        cd "$WORKSPACE/apps/workbench"
-
-        # We need to bundle to be ready even when we build a package without vendor directory
-        # because asset compilation requires it.
-        bundle config set --local system 'true' >"$STDOUT_IF_DEBUG"
-        bundle install >"$STDOUT_IF_DEBUG"
-
-        # clear the tmp directory; the asset generation step will recreate tmp/cache/assets,
-        # and we want that in the package, so it's easier to not exclude the tmp directory
-        # from the package - empty it instead.
-        rm -rf tmp
-        mkdir tmp
-
-        # Set up an appropriate config.yml
-        arvados-server config-dump -config <(cat /etc/arvados/config.yml 2>/dev/null || echo  "Clusters: {zzzzz: {}}") > /tmp/x
-        mkdir -p /etc/arvados/
-        mv /tmp/x /etc/arvados/config.yml
-        perl -p -i -e 'BEGIN{undef $/;} s/WebDAV(.*?):\n( *)ExternalURL: ""/WebDAV$1:\n$2ExternalURL: "example.com"/g' /etc/arvados/config.yml
-
-        ARVADOS_CONFIG=none RAILS_ENV=production RAILS_GROUPS=assets bin/rake npm:install >"$STDOUT_IF_DEBUG"
-        ARVADOS_CONFIG=none RAILS_ENV=production RAILS_GROUPS=assets bin/rake assets:precompile >"$STDOUT_IF_DEBUG"
-
-        # Remove generated configuration files so they don't go in the package.
-        rm -rf /etc/arvados/
-    )
-
-    if [[ "$?" != "0" ]]; then
-      echo "ERROR: Asset precompilation failed"
-      EXITCODE=1
-    else
-      handle_rails_package arvados-workbench "$WORKSPACE/apps/workbench" \
-          "$WORKSPACE/agpl-3.0.txt" --url="https://arvados.org" \
-          --description="Arvados Workbench - Arvados is a free and open source platform for big data science." \
-          --license="GNU Affero General Public License, version 3.0" --depends "arvados-server = ${arvados_server_version}-${arvados_server_iteration}"
-    fi
-  fi
-}
-
-# Usage: handle_cwltest [deb|rpm] [amd64|arm64]
-handle_cwltest () {
-  local package_format="$1"; shift
-  local target_arch="${1:-amd64}"; shift
-
-  if [[ -n "$ONLY_BUILD" ]] && [[ "$ONLY_BUILD" != "python3-cwltest" ]] ; then
-    debug_echo -e "Skipping build of cwltest package."
-    return 0
-  fi
-  cd "$WORKSPACE"
-  if [[ -e "$WORKSPACE/cwltest" ]]; then
-    rm -rf "$WORKSPACE/cwltest"
-  fi
-  git clone https://github.com/common-workflow-language/cwltest.git
-  # signal to our build script that we want a cwltest executable installed in /usr/bin/
-  mkdir cwltest/bin && touch cwltest/bin/cwltest
-  fpm_build_virtualenv "cwltest" "cwltest" "$package_format" "$target_arch"
-  # The python->python3 metapackage
-  build_metapackage "cwltest" "cwltest"
-  cd "$WORKSPACE"
-  rm -rf "$WORKSPACE/cwltest"
-}
-
 # Usage: handle_arvados_src
 handle_arvados_src () {
   if [[ -n "$ONLY_BUILD" ]] && [[ "$ONLY_BUILD" != "arvados-src" ]] ; then
@@ -696,6 +608,13 @@ handle_arvados_src () {
   )
 }
 
+setup_build_virtualenv() {
+    PYTHON_BUILDROOT="$(mktemp --directory --tmpdir pybuild.XXXXXXXX)"
+    "$PYTHON3_EXECUTABLE" -m venv "$PYTHON_BUILDROOT/venv"
+    "$PYTHON_BUILDROOT/venv/bin/pip" install --upgrade build piprepo setuptools wheel
+    mkdir "$PYTHON_BUILDROOT/wheelhouse"
+}
+
 # Build python packages with a virtualenv built-in
 # Usage: fpm_build_virtualenv arvados-python-client sdk/python [deb|rpm] [amd64|arm64]
 fpm_build_virtualenv () {
@@ -705,27 +624,6 @@ fpm_build_virtualenv () {
   local target_arch="${1:-amd64}"; shift
 
   native_arch=$(get_native_arch)
-
-  if [[ "$pkg" != "arvados-docker-cleaner" ]]; then
-    PYTHON_PKG=$PYTHON3_PKG_PREFIX-$pkg
-  else
-    # Exception to our package naming convention
-    PYTHON_PKG=$pkg
-  fi
-
-  if [[ -n "$ONLY_BUILD" ]] && [[ "$PYTHON_PKG" != "$ONLY_BUILD" ]]; then
-    # arvados-python-client sdist should always be built if we are building a
-    # python package.
-    if [[ "$ONLY_BUILD" != "python3-arvados-cwl-runner" ]] &&
-       [[ "$ONLY_BUILD" != "python3-arvados-fuse" ]] &&
-       [[ "$ONLY_BUILD" != "python3-crunchstat-summary" ]] &&
-       [[ "$ONLY_BUILD" != "arvados-docker-cleaner" ]] &&
-       [[ "$ONLY_BUILD" != "python3-arvados-user-activity" ]]; then
-      debug_echo -e "Skipping build of $pkg package."
-      return 0
-    fi
-  fi
-
   if [[ -n "$target_arch" ]] && [[ "$native_arch" == "$target_arch" ]]; then
       fpm_build_virtualenv_worker "$pkg" "$pkg_dir" "$package_format" "$native_arch" "$target_arch"
   elif [[ -z "$target_arch" ]]; then
@@ -758,10 +656,7 @@ fpm_build_virtualenv_worker () {
     ARVADOS_BUILDING_ITERATION=1
   fi
 
-  local python=$PYTHON3_EXECUTABLE
-  pip=pip3
   PACKAGE_PREFIX=$PYTHON3_PKG_PREFIX
-
   if [[ "$PKG" != "arvados-docker-cleaner" ]]; then
     PYTHON_PKG=$PACKAGE_PREFIX-$PKG
   else
@@ -769,133 +664,106 @@ fpm_build_virtualenv_worker () {
     PYTHON_PKG=$PKG
   fi
 
-  cd $WORKSPACE/$PKG_DIR
-
-  rm -rf dist/*
-
-  # Get the latest setuptools
-  if ! $pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U 'setuptools<45'; then
-    echo "Error, unable to upgrade setuptools with"
-    echo "  $pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U 'setuptools<45'"
-    exit 1
+  # We must always add a wheel to our repository, even if we're not building
+  # this distro package, because it might be a dependency for a later
+  # package we do build.
+  if [[ "$PKG_DIR" =~ ^.=[0-9]+\. ]]; then
+      # Not source to build, but a version to download.
+      # The rest of the function expects a filesystem path, so set one afterwards.
+      "$PYTHON_BUILDROOT/venv/bin/pip" download --dest="$PYTHON_BUILDROOT/wheelhouse" "$PKG$PKG_DIR" \
+          && PKG_DIR="$PYTHON_BUILDROOT/nonexistent"
+  else
+      # Make PKG_DIR absolute.
+      PKG_DIR="$(env -C "$WORKSPACE" readlink -e "$PKG_DIR")"
+      if [[ -e "$PKG_DIR/pyproject.toml" ]]; then
+          "$PYTHON_BUILDROOT/venv/bin/python" -m build --outdir="$PYTHON_BUILDROOT/wheelhouse" "$PKG_DIR"
+      else
+          env -C "$PKG_DIR" "$PYTHON_BUILDROOT/venv/bin/python" setup.py bdist_wheel --dist-dir="$PYTHON_BUILDROOT/wheelhouse"
+      fi
   fi
-  # filter a useless warning (when building the cwltest package) from the stderr output
-  if ! $python setup.py $DASHQ_UNLESS_DEBUG sdist 2> >(grep -v 'warning: no previously-included files matching'); then
-    echo "Error, unable to run $python setup.py sdist for $PKG"
+  if [[ $? -ne 0 ]]; then
+    printf "Error, unable to download/build wheel for %s @ %s" "$PKG" "$PKG_DIR"
+    exit 1
+  elif ! "$PYTHON_BUILDROOT/venv/bin/piprepo" build "$PYTHON_BUILDROOT/wheelhouse"; then
+    printf "Error, unable to update local wheel repository"
     exit 1
-  fi
-
-  PACKAGE_PATH=`(cd dist; ls *tar.gz)`
-
-  if [[ "arvados-python-client" == "$PKG" ]]; then
-    PYSDK_PATH=`pwd`/dist/
   fi
 
   if [[ -n "$ONLY_BUILD" ]] && [[ "$PYTHON_PKG" != "$ONLY_BUILD" ]] && [[ "$PKG" != "$ONLY_BUILD" ]]; then
     return 0
   fi
 
-  # Determine the package version from the generated sdist archive
-  if [[ -n "$ARVADOS_BUILDING_VERSION" ]] ; then
-      UNFILTERED_PYTHON_VERSION=$ARVADOS_BUILDING_VERSION
-      PYTHON_VERSION=$(echo -n $ARVADOS_BUILDING_VERSION | sed s/~dev/.dev/g | sed s/~rc/rc/g)
-  else
-      PYTHON_VERSION=$(awk '($1 == "Version:"){print $2}' *.egg-info/PKG-INFO)
-      UNFILTERED_PYTHON_VERSION=$(echo -n $PYTHON_VERSION | sed s/\.dev/~dev/g |sed 's/\([0-9]\)rc/\1~rc/g')
-  fi
-
-  # See if we actually need to build this package; does it exist already?
-  # We can't do this earlier than here, because we need PYTHON_VERSION...
-  # This isn't so bad; the sdist call above is pretty quick compared to
-  # the invocation of virtualenv and fpm, below.
-  if ! test_package_presence "$PYTHON_PKG" "$UNFILTERED_PYTHON_VERSION" "$python" "$ARVADOS_BUILDING_ITERATION" "$target_arch"; then
-    return 0
-  fi
-
-  echo "Building $package_format ($target_arch) package for $PKG from $PKG_DIR"
-
-  # Package the sdist in a virtualenv
+  local venv_dir="$PYTHON_BUILDROOT/$PYTHON_PKG"
   echo "Creating virtualenv..."
-
-  cd dist
-
-  rm -rf build
-  rm -f $PYTHON_PKG*deb
-  echo "virtualenv version: `virtualenv --version`"
-  virtualenv_command="virtualenv --python `which $python` $DASHQ_UNLESS_DEBUG build/usr/share/$python/dist/$PYTHON_PKG"
-
-  if ! $virtualenv_command; then
-    echo "Error, unable to run"
-    echo "  $virtualenv_command"
+  if ! "$PYTHON3_EXECUTABLE" -m venv "$venv_dir"; then
+    printf "Error, unable to run\n  %s -m venv %s\n" "$PYTHON3_EXECUTABLE" "$venv_dir"
     exit 1
-  fi
-
-  if ! build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U pip; then
-    echo "Error, unable to upgrade pip with"
-    echo "  build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U pip"
+  # We must have the dependency resolver introduced in late 2020 for the rest
+  # of our install process to work.
+  # <https://blog.python.org/2020/11/pip-20-3-release-new-resolver.html>
+  elif ! "$venv_dir/bin/pip" install "pip>=20.3"; then
+    printf "Error, unable to run\n  %s/bin/pip install 'pip>=20.3'\n" "$venv_dir"
     exit 1
   fi
-  echo "pip version:        `build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip --version`"
 
-  if ! build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U 'setuptools<45'; then
-    echo "Error, unable to upgrade setuptools with"
-    echo "  build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U 'setuptools<45'"
+  local pip_wheel="$(ls --sort=time --reverse "$PYTHON_BUILDROOT/wheelhouse/$(echo "$PKG" | sed s/-/_/g)-"*.whl | tail -n1)"
+  if [[ -z "$pip_wheel" ]]; then
+    printf "Error, unable to find built wheel for $PKG"
     exit 1
-  fi
-  echo "setuptools version: `build/usr/share/$python/dist/$PYTHON_PKG/bin/$python -c 'import setuptools; print(setuptools.__version__)'`"
-
-  if ! build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U wheel; then
-    echo "Error, unable to upgrade wheel with"
-    echo "  build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U wheel"
+  elif ! "$venv_dir/bin/pip" install $DASHQ_UNLESS_DEBUG $CACHE_FLAG --extra-index-url="file://$PYTHON_BUILDROOT/wheelhouse/simple" "$pip_wheel"; then
+    printf "Error, unable to run
+  %s/bin/pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG --extra-index-url=file://%s %s
+" "$venv_dir" "$PYTHON_BUILDROOT/wheelhouse/simple" "$pip_wheel"
     exit 1
   fi
-  echo "wheel version:      `build/usr/share/$python/dist/$PYTHON_PKG/bin/wheel version`"
 
-  if [[ "$TARGET" != "centos7" ]] || [[ "$PYTHON_PKG" != "python-arvados-fuse" ]]; then
-    build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -f $PYSDK_PATH $PACKAGE_PATH
-  else
-    # centos7 needs these special tweaks to install python-arvados-fuse
-    build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG docutils
-    PYCURL_SSL_LIBRARY=nss build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -f $PYSDK_PATH $PACKAGE_PATH
-  fi
+  # Determine the package version from the wheel
+  PYTHON_VERSION="$("$venv_dir/bin/python" "$WORKSPACE/build/pypkg_info.py" metadata "$PKG" Version)"
+  UNFILTERED_PYTHON_VERSION="$(echo "$PYTHON_VERSION" | sed 's/\.dev/~dev/; s/\([0-9]\)rc/\1~rc/')"
 
-  if [[ "$?" != "0" ]]; then
-    echo "Error, unable to run"
-    echo "  build/usr/share/$python/dist/$PYTHON_PKG/bin/$pip install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -f $PYSDK_PATH $PACKAGE_PATH"
-    exit 1
+  # See if we actually need to build this package; does it exist already?
+  # We can't do this earlier than here, because we need PYTHON_VERSION.
+  if ! test_package_presence "$PYTHON_PKG" "$UNFILTERED_PYTHON_VERSION" python3 "$ARVADOS_BUILDING_ITERATION" "$target_arch"; then
+    return 0
   fi
-
-  cd build/usr/share/$python/dist/$PYTHON_PKG/
+  echo "Building $package_format ($target_arch) package for $PKG from $PKG_DIR"
 
   # Replace the shebang lines in all python scripts, and handle the activate
   # scripts too. This is a functional replacement of the 237 line
   # virtualenv_tools.py script that doesn't work in python3 without serious
   # patching, minus the parts we don't need (modifying pyc files, etc).
-  for binfile in `ls bin/`; do
-    if ! file --mime bin/$binfile |grep -q binary; then
-      # Not a binary file
-      if [[ "$binfile" =~ ^activate(.csh|.fish|)$ ]]; then
-        # these 'activate' scripts need special treatment
-        sed -i "s/VIRTUAL_ENV=\".*\"/VIRTUAL_ENV=\"\/usr\/share\/$python\/dist\/$PYTHON_PKG\"/" bin/$binfile
-        sed -i "s/VIRTUAL_ENV \".*\"/VIRTUAL_ENV \"\/usr\/share\/$python\/dist\/$PYTHON_PKG\"/" bin/$binfile
-      else
-        if grep -q -E '^#!.*/bin/python\d?' bin/$binfile; then
-          # Replace shebang line
-          sed -i "1 s/^.*$/#!\/usr\/share\/$python\/dist\/$PYTHON_PKG\/bin\/python/" bin/$binfile
-        fi
-      fi
+  local sys_venv_dir="/usr/lib/$PYTHON_PKG"
+  local sys_venv_py="$sys_venv_dir/bin/python$PYTHON3_VERSION"
+  find "$venv_dir/bin" -type f | while read binfile; do
+    if file --mime "$binfile" | grep -q binary; then
+      :  # Nothing to do for binary files
+    elif [[ "$binfile" =~ /activate(.csh|.fish|)$ ]]; then
+      sed -ri "s@VIRTUAL_ENV(=| )\".*\"@VIRTUAL_ENV\\1\"$sys_venv_dir\"@" "$binfile"
+    else
+      # Replace shebang line
+      sed -ri "1 s@^#\![^[:space:]]+/bin/python[0-9.]*@#\!$sys_venv_py@" "$binfile"
     fi
   done
 
-  cd - >$STDOUT_IF_DEBUG
-
-  find build -iname '*.pyc' -exec rm {} \;
-  find build -iname '*.pyo' -exec rm {} \;
-
-  # Finally, generate the package
-  echo "Creating package..."
-
-  declare -a COMMAND_ARR=("fpm" "-s" "dir" "-t" "$package_format")
+  # Using `env -C` sets the directory where the package is built.
+  # Using `fpm --chdir` sets the root directory for source arguments.
+  declare -a COMMAND_ARR=(
+      env -C "$PYTHON_BUILDROOT" fpm
+      --chdir="$venv_dir"
+      --name="$PYTHON_PKG"
+      --version="$UNFILTERED_PYTHON_VERSION"
+      --input-type=dir
+      --output-type="$package_format"
+      --depends="$PYTHON3_PACKAGE"
+      --iteration="$ARVADOS_BUILDING_ITERATION"
+      --replaces="python-$PKG"
+      --url="https://arvados.org"
+  )
+  # Append fpm flags corresponding to Python package metadata.
+  readarray -d "" -O "${#COMMAND_ARR[@]}" -t COMMAND_ARR < \
+            <("$venv_dir/bin/python3" "$WORKSPACE/build/pypkg_info.py" \
+                                      --delimiter=\\0 --format=fpm \
+                                      metadata "$PKG" License Summary)
 
   if [[ -n "$target_arch" ]] && [[ "$target_arch" != "amd64" ]]; then
     COMMAND_ARR+=("-a$target_arch")
@@ -909,40 +777,31 @@ fpm_build_virtualenv_worker () {
     COMMAND_ARR+=('--vendor' "$VENDOR")
   fi
 
-  COMMAND_ARR+=('--url' 'https://arvados.org')
-
-  # Get description
-  DESCRIPTION=`grep '\sdescription' $WORKSPACE/$PKG_DIR/setup.py|cut -f2 -d=|sed -e "s/[',\\"]//g"`
-  COMMAND_ARR+=('--description' "$DESCRIPTION")
-
-  # Get license string
-  LICENSE_STRING=`grep license $WORKSPACE/$PKG_DIR/setup.py|cut -f2 -d=|sed -e "s/[',\\"]//g"`
-  COMMAND_ARR+=('--license' "$LICENSE_STRING")
-
-  if [[ "$package_format" == "rpm" ]]; then
-    # Make sure to conflict with the old rh-python36 packages we used to publish
-    COMMAND_ARR+=('--conflicts' "rh-python36-python-$PKG")
-  fi
-
   if [[ "$DEBUG" != "0" ]]; then
     COMMAND_ARR+=('--verbose' '--log' 'info')
   fi
 
-  COMMAND_ARR+=('-v' $(echo -n "$PYTHON_VERSION" | sed s/.dev/~dev/g | sed s/rc/~rc/g))
-  COMMAND_ARR+=('--iteration' "$ARVADOS_BUILDING_ITERATION")
-  COMMAND_ARR+=('-n' "$PYTHON_PKG")
-  COMMAND_ARR+=('-C' "build")
-
-  systemd_unit="$WORKSPACE/$PKG_DIR/$PKG.service"
+  systemd_unit="$PKG_DIR/$PKG.service"
   if [[ -e "${systemd_unit}" ]]; then
     COMMAND_ARR+=('--after-install' "${WORKSPACE}/build/go-python-package-scripts/postinst")
     COMMAND_ARR+=('--before-remove' "${WORKSPACE}/build/go-python-package-scripts/prerm")
   fi
 
-  COMMAND_ARR+=('--depends' "$PYTHON3_PACKAGE")
-
-  # avoid warning
-  COMMAND_ARR+=('--deb-no-default-config-files')
+  case "$package_format" in
+      deb)
+          COMMAND_ARR+=(
+              # Avoid warning
+              --deb-no-default-config-files
+          ) ;;
+      rpm)
+          COMMAND_ARR+=(
+              # Conflict with older packages we used to publish
+              --conflicts "rh-python36-python-$PKG"
+              # Do not generate /usr/lib/.build-id links on RH8+
+              # (otherwise our packages conflict with platform-python)
+              --rpm-rpmbuild-define "_build_id_links none"
+          ) ;;
+  esac
 
   # Append --depends X and other arguments specified by fpm-info.sh in
   # the package source dir. These are added last so they can override
@@ -950,7 +809,7 @@ fpm_build_virtualenv_worker () {
   declare -a fpm_args=()
   declare -a fpm_depends=()
 
-  fpminfo="$WORKSPACE/$PKG_DIR/fpm-info.sh"
+  fpminfo="$PKG_DIR/fpm-info.sh"
   if [[ -e "$fpminfo" ]]; then
     echo "Loading fpm overrides from $fpminfo"
     if ! source "$fpminfo"; then
@@ -963,36 +822,24 @@ fpm_build_virtualenv_worker () {
     COMMAND_ARR+=('--depends' "$i")
   done
 
-  for i in "${fpm_depends[@]}"; do
-    COMMAND_ARR+=('--replaces' "python-$PKG")
-  done
-
   # make sure the systemd service file ends up in the right place
   # used by arvados-docker-cleaner
   if [[ -e "${systemd_unit}" ]]; then
-    COMMAND_ARR+=("usr/share/$python/dist/$PKG/share/doc/$PKG/$PKG.service=/lib/systemd/system/$PKG.service")
+    COMMAND_ARR+=("share/doc/$PKG/$PKG.service=/lib/systemd/system/$PKG.service")
   fi
 
   COMMAND_ARR+=("${fpm_args[@]}")
 
-  # Make sure to install all our package binaries in /usr/bin.
-  # We have to walk $WORKSPACE/$PKG_DIR/bin rather than
-  # $WORKSPACE/build/usr/share/$python/dist/$PYTHON_PKG/bin/ to get the list
-  # because the latter also includes all the python binaries for the virtualenv.
-  # We have to take the copies of our binaries from the latter directory, though,
-  # because those are the ones we rewrote the shebang line of, above.
-  if [[ -e "$WORKSPACE/$PKG_DIR/bin" ]]; then
-    for binary in `ls $WORKSPACE/$PKG_DIR/bin`; do
-      COMMAND_ARR+=("usr/share/$python/dist/$PYTHON_PKG/bin/$binary=/usr/bin/")
-    done
-  fi
+  while read -d "" binpath; do
+      COMMAND_ARR+=("$binpath=/usr/$binpath")
+  done < <("$venv_dir/bin/python3" "$WORKSPACE/build/pypkg_info.py" --delimiter=\\0 binfiles "$PKG")
 
   # the python3-arvados-cwl-runner package comes with cwltool, expose that version
-  if [[ -e "$WORKSPACE/$PKG_DIR/dist/build/usr/share/$python/dist/$PYTHON_PKG/bin/cwltool" ]]; then
-    COMMAND_ARR+=("usr/share/$python/dist/$PYTHON_PKG/bin/cwltool=/usr/bin/")
+  if [[ "$PKG" == arvados-cwl-runner ]]; then
+    COMMAND_ARR+=("bin/cwltool=/usr/bin/cwltool")
   fi
 
-  COMMAND_ARR+=(".")
+  COMMAND_ARR+=(".=$sys_venv_dir")
 
   debug_echo -e "\n${COMMAND_ARR[@]}\n"
 
@@ -1005,144 +852,14 @@ fpm_build_virtualenv_worker () {
     echo
     echo -e "\n${COMMAND_ARR[@]}\n"
   else
-    echo `ls *$package_format`
-    mv $WORKSPACE/$PKG_DIR/dist/*$package_format $WORKSPACE/packages/$TARGET/
+    ls "$PYTHON_BUILDROOT"/*."$package_format"
+    mv "$PYTHON_BUILDROOT"/*."$package_format" "$WORKSPACE/packages/$TARGET/"
   fi
   echo
 }
 
-# build_metapackage builds meta packages that help with the python to python 3 package migration
-build_metapackage() {
-  # base package name (e.g. arvados-python-client)
-  BASE_NAME=$1
-  shift
-  PKG_DIR=$1
-  shift
-
-  if [[ -n "$ONLY_BUILD" ]] && [[ "python-$BASE_NAME" != "$ONLY_BUILD" ]]; then
-    return 0
-  fi
-
-  if [[ "$ARVADOS_BUILDING_ITERATION" == "" ]]; then
-    ARVADOS_BUILDING_ITERATION=1
-  fi
-
-  if [[ -z "$ARVADOS_BUILDING_VERSION" ]]; then
-    cd $WORKSPACE/$PKG_DIR
-    pwd
-    rm -rf dist/*
-
-    # Get the latest setuptools
-    if ! pip3 install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U 'setuptools<45'; then
-      echo "Error, unable to upgrade setuptools with XY"
-      echo "  pip3 install $DASHQ_UNLESS_DEBUG $CACHE_FLAG -U 'setuptools<45'"
-      exit 1
-    fi
-    # filter a useless warning (when building the cwltest package) from the stderr output
-    if ! python3 setup.py $DASHQ_UNLESS_DEBUG sdist 2> >(grep -v 'warning: no previously-included files matching'); then
-      echo "Error, unable to run python3 setup.py sdist for $PKG"
-      exit 1
-    fi
-
-    PYTHON_VERSION=$(awk '($1 == "Version:"){print $2}' *.egg-info/PKG-INFO)
-    UNFILTERED_PYTHON_VERSION=$(echo -n $PYTHON_VERSION | sed s/\.dev/~dev/g |sed 's/\([0-9]\)rc/\1~rc/g')
-
-  else
-    UNFILTERED_PYTHON_VERSION=$ARVADOS_BUILDING_VERSION
-    PYTHON_VERSION=$(echo -n $ARVADOS_BUILDING_VERSION | sed s/~dev/.dev/g | sed s/~rc/rc/g)
-  fi
-
-  cd - >$STDOUT_IF_DEBUG
-  if [[ -d "$BASE_NAME" ]]; then
-    rm -rf $BASE_NAME
-  fi
-  mkdir $BASE_NAME
-  cd $BASE_NAME
-
-  if [[ "$FORMAT" == "deb" ]]; then
-    cat >ns-control <<EOF
-Section: misc
-Priority: optional
-Standards-Version: 3.9.2
-
-Package: python-${BASE_NAME}
-Version: ${PYTHON_VERSION}-${ARVADOS_BUILDING_ITERATION}
-Maintainer: Arvados Package Maintainers <packaging@arvados.org>
-Depends: python3-${BASE_NAME}
-Description: metapackage to ease the upgrade to the Pyhon 3 version of ${BASE_NAME}
- This package is a metapackage that will automatically install the new version of
- ${BASE_NAME} which is Python 3 based and has a different name.
-EOF
-
-    /usr/bin/equivs-build ns-control
-    if [[ $? -ne 0 ]]; then
-      echo "Error running 'equivs-build ns-control', is the 'equivs' package installed?"
-      return 1
-    fi
-  elif [[ "$FORMAT" == "rpm" ]]; then
-    cat >meta.spec <<EOF
-Summary: metapackage to ease the upgrade to the Python 3 version of ${BASE_NAME}
-Name: python-${BASE_NAME}
-Version: ${PYTHON_VERSION}
-Release: ${ARVADOS_BUILDING_ITERATION}
-License: distributable
-
-Requires: python3-${BASE_NAME}
-
-%description
-This package is a metapackage that will automatically install the new version of
-python-${BASE_NAME} which is Python 3 based and has a different name.
-
-%prep
-
-%build
-
-%clean
-
-%install
-
-%post
-
-%files
-
-
-%changelog
-* Mon Apr 12 2021 Arvados Package Maintainers <packaging@arvados.org>
-- initial release
-EOF
-
-    /usr/bin/rpmbuild -ba meta.spec
-    if [[ $? -ne 0 ]]; then
-      echo "Error running 'rpmbuild -ba meta.spec', is the 'rpm-build' package installed?"
-      return 1
-    else
-      mv /root/rpmbuild/RPMS/x86_64/python-${BASE_NAME}*.${FORMAT} .
-      if [[ $? -ne 0 ]]; then
-        echo "Error finding rpm file output of 'rpmbuild -ba meta.spec'"
-        return 1
-      fi
-    fi
-  else
-    echo "Unknown format"
-    return 1
-  fi
-
-  if [[ $EXITCODE -ne 0 ]]; then
-    return 1
-  else
-    echo `ls *$FORMAT`
-    mv *$FORMAT $WORKSPACE/packages/$TARGET/
-  fi
-
-  # clean up
-  cd - >$STDOUT_IF_DEBUG
-  if [[ -d "$BASE_NAME" ]]; then
-    rm -rf $BASE_NAME
-  fi
-}
-
 # Build packages for everything
-fpm_build () {
+fpm_build() {
   # Source dir where fpm-info.sh (if any) will be found.
   SRC_DIR=$1
   shift
@@ -1162,11 +879,7 @@ fpm_build () {
   shift
 
   if [[ -n "$ONLY_BUILD" ]] && [[ "$PACKAGE_NAME" != "$ONLY_BUILD" ]] && [[ "$PACKAGE" != "$ONLY_BUILD" ]] ; then
-    # arvados-workbench depends on arvados-server at build time, so even when
-    # only arvados-workbench is being built, we need to build arvados-server too
-    if [[ "$PACKAGE_NAME" != "arvados-server" ]] || [[ "$ONLY_BUILD" != "arvados-workbench" ]]; then
       return 0
-    fi
   fi
 
   local default_iteration_value="$(default_iteration "$PACKAGE" "$VERSION" "$PACKAGE_TYPE")"
@@ -1266,6 +979,8 @@ fpm_build () {
 
   FPM_RESULTS=$("${COMMAND_ARR[@]}")
   FPM_EXIT_CODE=$?
+  echo "fpm: exit code $FPM_EXIT_CODE" >>$STDOUT_IF_DEBUG
+  echo "$FPM_RESULTS" >>$STDOUT_IF_DEBUG
 
   fpm_verify $FPM_EXIT_CODE $FPM_RESULTS
 
@@ -1282,7 +997,7 @@ fpm_verify () {
   FPM_RESULTS=$@
 
   FPM_PACKAGE_NAME=''
-  if [[ $FPM_RESULTS =~ ([A-Za-z0-9_\.-]*\.)(deb|rpm) ]]; then
+  if [[ $FPM_RESULTS =~ ([A-Za-z0-9_\.~-]*\.)(deb|rpm) ]]; then
     FPM_PACKAGE_NAME=${BASH_REMATCH[1]}${BASH_REMATCH[2]}
   fi
 
index a5c7277580496cd1fe4748aed040ce2261bd3e95..b8d2081e6e65a14c84ec2a7a3917eefe7ca855ac 100755 (executable)
@@ -38,8 +38,6 @@ services/api_test="TEST=test/functional/arvados/v1/collections_controller_test.r
                Restrict apiserver tests to the given file
 sdk/python_test="--test-suite tests.test_keep_locator"
                Restrict Python SDK tests to the given class
-apps/workbench_test="TEST=test/integration/pipeline_instances_test.rb"
-               Restrict Workbench tests to the given file
 services/githttpd_test="-check.vv"
                Show all log messages, even when tests pass (also works
                with services/keepstore_test etc.)
@@ -62,12 +60,6 @@ https://dev.arvados.org/projects/arvados/wiki/Running_tests
 
 Available tests:
 
-apps/workbench (*)
-apps/workbench_units (*)
-apps/workbench_functionals (*)
-apps/workbench_integration (*)
-apps/workbench_benchmark
-apps/workbench_profile
 cmd/arvados-client
 cmd/arvados-package
 cmd/arvados-server
@@ -94,7 +86,6 @@ lib/pam
 lib/service
 services/api
 services/githttpd
-services/crunchstat
 services/dockercleaner
 services/fuse
 services/fuse:py3
@@ -106,10 +97,13 @@ services/keep-balance
 services/login-sync
 services/crunch-dispatch-local
 services/crunch-dispatch-slurm
+services/workbench2_units
+services/workbench2_integration
 services/ws
 sdk/cli
 sdk/python
 sdk/python:py3
+sdk/ruby-google-api-client
 sdk/ruby
 sdk/go/arvados
 sdk/go/arvadosclient
@@ -133,9 +127,6 @@ tools/keep-exercise
 tools/keep-rsync
 tools/keep-block-check
 
-(*) apps/workbench is shorthand for apps/workbench_units +
-    apps/workbench_functionals + apps/workbench_integration
-
 EOF
 
 # First make sure to remove any ARVADOS_ variables from the calling
@@ -182,10 +173,6 @@ fatal() {
 
 exit_cleanly() {
     trap - INT
-    if which create-plot-data-from-log.sh >/dev/null; then
-        create-plot-data-from-log.sh $BUILD_NUMBER "$WORKSPACE/apps/workbench/log/test.log" "$WORKSPACE/apps/workbench/log/"
-    fi
-    rotate_logfile "$WORKSPACE/apps/workbench/log/" "test.log"
     stop_services
     rotate_logfile "$WORKSPACE/services/api/log/" "test.log"
     report_outcomes
@@ -221,7 +208,8 @@ sanity_checks() {
     find /usr/include -path '*gnutls/gnutls.h' | egrep --max-count=1 . \
         || fatal "No gnutls/gnutls.h. Try: apt-get install libgnutls28-dev"
     echo -n 'virtualenv: '
-    python3 -m venv -h | egrep --max-count=1 . \
+    python3 -m venv --help | grep -q '^usage: venv ' \
+        && echo "venv module found" \
         || fatal "No virtualenv. Try: apt-get install python3-venv"
     echo -n 'Python3 pyconfig.h: '
     find /usr/include -path '*/python3*/pyconfig.h' | egrep --max-count=1 . \
@@ -236,7 +224,7 @@ sanity_checks() {
         || fatal "No gitolite. Try: apt-get install gitolite3"
     echo -n 'npm: '
     npm --version \
-        || fatal "No npm. Try: wget -O- https://nodejs.org/dist/v10.23.1/node-v10.23.1-linux-x64.tar.xz | sudo tar -C /usr/local -xJf - && sudo ln -s ../node-v10.23.1-linux-x64/bin/{node,npm} /usr/local/bin/"
+        || fatal "No npm. Try: wget -O- https://nodejs.org/dist/v12.22.12/node-v12.22.12-linux-x64.tar.xz | sudo tar -C /usr/local -xJf - && sudo ln -s ../node-v12.22.12-linux-x64/bin/{node,npm} /usr/local/bin/"
     echo -n 'cadaver: '
     cadaver --version | grep -w cadaver \
           || fatal "No cadaver. Try: apt-get install cadaver"
@@ -254,14 +242,10 @@ sanity_checks() {
         || fatal "No libpam pam_appl.h. Try: apt-get install libpam0g-dev"
     echo -n 'postgresql: '
     psql --version || fatal "No postgresql. Try: apt-get install postgresql postgresql-client-common"
-    echo -n 'phantomjs: '
-    phantomjs --version || fatal "No phantomjs. Try: apt-get install phantomjs"
     echo -n 'xvfb: '
     which Xvfb || fatal "No xvfb. Try: apt-get install xvfb"
     echo -n 'graphviz: '
     dot -V || fatal "No graphviz. Try: apt-get install graphviz"
-    echo -n 'geckodriver: '
-    geckodriver --version | grep ^geckodriver || echo "No geckodriver. Try: arvados-server install"
     echo -n 'singularity: '
     singularity --version || fatal "No singularity. Try: arvados-server install"
     echo -n 'docker client: '
@@ -287,7 +271,7 @@ sanity_checks() {
 }
 
 rotate_logfile() {
-  # i.e.  rotate_logfile "$WORKSPACE/apps/workbench/log/" "test.log"
+  # i.e.  rotate_logfile "$WORKSPACE/services/api/log/" "test.log"
   # $BUILD_NUMBER is set by Jenkins if this script is being called as part of a Jenkins run
   if [[ -f "$1/$2" ]]; then
     THEDATE=`date +%Y%m%d%H%M%S`
@@ -300,7 +284,6 @@ declare -a failures
 declare -A skip
 declare -A only
 declare -A testargs
-skip[apps/workbench_profile]=1
 
 while [[ -n "$1" ]]
 do
@@ -590,7 +573,7 @@ setup_virtualenv() {
     elif [[ -n "$short" ]]; then
         return
     fi
-    "$venvdest/bin/pip3" install --no-cache-dir 'setuptools>=18.5' 'pip>=7'
+    "$venvdest/bin/pip3" install --no-cache-dir 'setuptools>=68' 'pip>=20'
 }
 
 initialize() {
@@ -653,22 +636,16 @@ install_env() {
     setup_virtualenv "$VENV3DIR"
     . "$VENV3DIR/bin/activate"
 
-    # Needed for run_test_server.py which is used by certain (non-Python) tests.
-    # pdoc3 needed to generate the Python SDK documentation.
-    (
-        set -e
-        "${VENV3DIR}/bin/pip3" install wheel
-        "${VENV3DIR}/bin/pip3" install PyYAML
-        "${VENV3DIR}/bin/pip3" install httplib2
-        "${VENV3DIR}/bin/pip3" install future
-        "${VENV3DIR}/bin/pip3" install google-api-python-client
-        "${VENV3DIR}/bin/pip3" install ciso8601
-        "${VENV3DIR}/bin/pip3" install pycurl
-        "${VENV3DIR}/bin/pip3" install ws4py
-        "${VENV3DIR}/bin/pip3" install pdoc3
-        cd "$WORKSPACE/sdk/python"
-        python3 setup.py install
-    ) || fatal "installing PyYAML and sdk/python failed"
+    # wheel modernizes the venv (as of early 2024) and makes it more closely
+    # match our package build environment.
+    # PyYAML is a test requirement used by run_test_server.py and needed for
+    # other, non-Python tests.
+    # pdoc is needed to build PySDK documentation.
+    # We run `setup.py build` first to generate _version.py.
+    pip install PyYAML pdoc wheel \
+        && env -C "$WORKSPACE/sdk/python" python3 setup.py build \
+        && pip install "$WORKSPACE/sdk/python" \
+        || fatal "installing Python SDK and related dependencies failed"
 }
 
 retry() {
@@ -696,8 +673,8 @@ retry() {
 
 do_test() {
     case "${1}" in
-        apps/workbench_units | apps/workbench_functionals | apps/workbench_integration)
-            suite=apps/workbench
+        services/workbench2_units | services/workbench2_integration)
+            suite=services/workbench2
             ;;
         *)
             suite="${1}"
@@ -713,7 +690,22 @@ do_test() {
             stop_services
             check_arvados_config "$1"
             ;;
-        gofmt | doc | lib/cli | lib/cloud/azure | lib/cloud/ec2 | lib/cloud/cloudtest | lib/cmd | lib/dispatchcloud/sshexecutor | lib/dispatchcloud/worker)
+        gofmt \
+            | arvados_version.py \
+            | cmd/arvados-package \
+            | doc \
+            | lib/boot \
+            | lib/cli \
+            | lib/cloud/azure \
+            | lib/cloud/cloudtest \
+            | lib/cloud/ec2 \
+            | lib/cmd \
+            | lib/dispatchcloud/sshexecutor \
+            | lib/dispatchcloud/worker \
+            | lib/install \
+            | services/workbench2_integration \
+            | services/workbench2_units \
+            )
             check_arvados_config "$1"
             # don't care whether services are running
             ;;
@@ -909,6 +901,10 @@ install_sdk/ruby() {
     install_gem arvados sdk/ruby
 }
 
+install_sdk/ruby-google-api-client() {
+    install_gem arvados-google-api-client sdk/ruby-google-api-client
+}
+
 install_sdk/R() {
   if [[ "$NEED_SDK_R" = true ]]; then
     cd "$WORKSPACE/sdk/R" \
@@ -921,6 +917,7 @@ install_sdk/cli() {
 }
 
 install_services/login-sync() {
+    install_gem arvados-google-api-client sdk/ruby-google-api-client
     install_gem arvados sdk/ruby
     install_gem arvados-login-sync services/login-sync
 }
@@ -977,38 +974,56 @@ install_services/api() {
 
 declare -a pythonstuff
 pythonstuff=(
+    # The ordering of sdk/python, tools/crunchstat-summary, and
+    # sdk/cwl here is significant. See
+    # https://dev.arvados.org/issues/19744#note-26
     sdk/python:py3
+    tools/crunchstat-summary:py3
     sdk/cwl:py3
     services/dockercleaner:py3
     services/fuse:py3
-    tools/crunchstat-summary:py3
 )
 
 declare -a gostuff
 gostuff=($(cd "$WORKSPACE" && git ls-files | grep '\.go$' | sed -e 's/\/[^\/]*$//' | sort -u))
 
-install_apps/workbench() {
-    cd "$WORKSPACE/apps/workbench" \
-        && mkdir -p tmp/cache \
-        && RAILS_ENV=test bundle_install_trylocal \
-        && RAILS_ENV=test RAILS_GROUPS=assets "$bundle" exec rake npm:install
+install_services/workbench2() {
+    cd "$WORKSPACE/services/workbench2" \
+        && make yarn-install ARVADOS_DIRECTORY="${WORKSPACE}"
 }
 
 test_doc() {
-    (
-        set -e
-        cd "$WORKSPACE/doc"
-        ARVADOS_API_HOST=pirca.arvadosapi.com
-        # Make sure python-epydoc is installed or the next line won't
-        # do much good!
-        PYTHONPATH=$WORKSPACE/sdk/python/ "$bundle" exec rake linkchecker baseurl=file://$WORKSPACE/doc/.site/ arvados_workbench_host=https://workbench.$ARVADOS_API_HOST arvados_api_host=$ARVADOS_API_HOST
-    )
+    local arvados_api_host=pirca.arvadosapi.com && \
+        env -C "$WORKSPACE/doc" \
+        "$bundle" exec rake linkchecker \
+        arvados_api_host="$arvados_api_host" \
+        arvados_workbench_host="https://workbench.$arvados_api_host" \
+        baseurl="file://$WORKSPACE/doc/.site/" \
+        ${testargs[doc]}
 }
 
 test_gofmt() {
     cd "$WORKSPACE" || return 1
     dirs=$(ls -d */ | egrep -v 'vendor|tmp')
     [[ -z "$(gofmt -e -d $dirs | tee -a /dev/stderr)" ]]
+    go vet -composites=false ./...
+}
+
+test_arvados_version.py() {
+    local orig_fn=""
+    local fail_count=0
+    while read -d "" fn; do
+        if [[ -z "$orig_fn" ]]; then
+            orig_fn="$fn"
+        elif ! cmp "$orig_fn" "$fn"; then
+            fail_count=$(( $fail_count + 1 ))
+            printf "FAIL: %s and %s are not identical\n" "$orig_fn" "$fn"
+        fi
+    done < <(git -C "$WORKSPACE" ls-files -z | grep -z '/arvados_version\.py$')
+    case "$orig_fn" in
+        "") return 66 ;;  # EX_NOINPUT
+        *) return "$fail_count" ;;
+    esac
 }
 
 test_services/api() {
@@ -1022,6 +1037,11 @@ test_sdk/ruby() {
         && "$bundle" exec rake test TESTOPTS=-v ${testargs[sdk/ruby]}
 }
 
+test_sdk/ruby-google-api-client() {
+    echo "*** note \`test sdk/ruby-google-api-client\` does not actually run any tests, see https://dev.arvados.org/issues/20993 ***"
+    true
+}
+
 test_sdk/R() {
   if [[ "$NEED_SDK_R" = true ]]; then
     cd "$WORKSPACE/sdk/R" \
@@ -1044,44 +1064,26 @@ test_services/login-sync() {
         && "$bundle" exec rake test TESTOPTS=-v ${testargs[services/login-sync]}
 }
 
-test_apps/workbench_units() {
-    local TASK="test:units"
-    cd "$WORKSPACE/apps/workbench" \
-        && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} TESTOPTS=\'-v -d\' ${testargs[apps/workbench]} ${testargs[apps/workbench_units]}
-}
-
-test_apps/workbench_functionals() {
-    local TASK="test:functionals"
-    cd "$WORKSPACE/apps/workbench" \
-        && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} TESTOPTS=\'-v -d\' ${testargs[apps/workbench]} ${testargs[apps/workbench_functionals]}
+test_services/workbench2_units() {
+    cd "$WORKSPACE/services/workbench2" && make unit-tests ARVADOS_DIRECTORY="${WORKSPACE}" WORKSPACE="$(pwd)" ${testargs[services/workbench2]}
 }
 
-test_apps/workbench_integration() {
-    local TASK="test:integration"
-    cd "$WORKSPACE/apps/workbench" \
-        && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} TESTOPTS=\'-v -d\' ${testargs[apps/workbench]} ${testargs[apps/workbench_integration]}
-}
-
-test_apps/workbench_benchmark() {
-    local TASK="test:benchmark"
-    cd "$WORKSPACE/apps/workbench" \
-        && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} ${testargs[apps/workbench_benchmark]}
-}
-
-test_apps/workbench_profile() {
-    local TASK="test:profile"
-    cd "$WORKSPACE/apps/workbench" \
-        && eval env RAILS_ENV=test ${short:+RAILS_TEST_SHORT=1} "$bundle" exec rake ${TASK} ${testargs[apps/workbench_profile]}
+test_services/workbench2_integration() {
+    cd "$WORKSPACE/services/workbench2" && make integration-tests ARVADOS_DIRECTORY="${WORKSPACE}" WORKSPACE="$(pwd)" ${testargs[services/workbench2]}
 }
 
 install_deps() {
     # Install parts needed by test suites
     do_install env
     do_install cmd/arvados-server go
-    do_install sdk/cli
-    do_install sdk/python pip "${VENV3DIR}/bin/"
+    do_install tools/crunchstat-summary pip "${VENV3DIR}/bin/"
+    do_install sdk/ruby-google-api-client
     do_install sdk/ruby
+    do_install sdk/cli
     do_install services/api
+    # lib/controller integration tests depend on arv-mount to run
+    # containers.
+    do_install services/fuse pip "${VENV3DIR}/bin/"
     do_install services/keepproxy go
     do_install services/keep-web go
 }
@@ -1089,6 +1091,7 @@ install_deps() {
 install_all() {
     do_install env
     do_install doc
+    do_install sdk/ruby-google-api-client
     do_install sdk/ruby
     do_install sdk/R
     do_install sdk/cli
@@ -1105,7 +1108,7 @@ install_all() {
         do_install "$g" go
     done
     do_install services/api
-    do_install apps/workbench
+    do_install services/workbench2
 }
 
 test_all() {
@@ -1120,7 +1123,9 @@ test_all() {
     fi
 
     do_test gofmt
+    do_test arvados_version.py
     do_test doc
+    do_test sdk/ruby-google-api-client
     do_test sdk/ruby
     do_test sdk/R
     do_test sdk/cli
@@ -1138,11 +1143,8 @@ test_all() {
     do
         do_test "$g" go
     done
-    do_test apps/workbench_units
-    do_test apps/workbench_functionals
-    do_test apps/workbench_integration
-    do_test apps/workbench_benchmark
-    do_test apps/workbench_profile
+    do_test services/workbench2_units
+    do_test services/workbench2_integration
 }
 
 test_go() {
@@ -1183,11 +1185,6 @@ done
 testfuncargs["sdk/cli"]="sdk/cli"
 testfuncargs["sdk/R"]="sdk/R"
 testfuncargs["sdk/java-v2"]="sdk/java-v2"
-testfuncargs["apps/workbench_units"]="apps/workbench_units"
-testfuncargs["apps/workbench_functionals"]="apps/workbench_functionals"
-testfuncargs["apps/workbench_integration"]="apps/workbench_integration"
-testfuncargs["apps/workbench_benchmark"]="apps/workbench_benchmark"
-testfuncargs["apps/workbench_profile"]="apps/workbench_profile"
 
 if [[ -z ${interactive} ]]; then
     install_all
index e42b8753934b07581aa69b52950a8cdad5bc521d..50b1e300e79d65d17a5ca2ccc8d07adcfd32543d 100755 (executable)
@@ -8,44 +8,36 @@ commit="$1"
 versionglob="[0-9].[0-9]*.[0-9]*"
 devsuffix="~dev"
 
-# automatically assign version
+# automatically assign *development* version
 #
 # handles the following cases:
 #
-# 1. commit is directly tagged.  print that.
-#
-# 2. commit is on main or a development branch, the nearest tag is older
+# *  commit is on main or a development branch, the nearest tag is older
 #    than commit where this branch joins main.
 #    -> take greatest version tag in repo X.Y.Z and assign X.(Y+1).0
 #
-# 3. commit is on a release branch, the nearest tag is newer
+#  commit is on a release branch, the nearest tag is newer
 #    than the commit where this branch joins main.
 #    -> take nearest tag X.Y.Z and assign X.Y.(Z+1)
 
-tagged=$(git tag --points-at "$commit")
-
-if [[ -n "$tagged" ]] ; then
-    echo $tagged
-else
-    # 1. get the nearest tag with 'git describe'
-    # 2. get the merge base between this commit and main
-    # 3. if the tag is an ancestor of the merge base,
-    #    (tag is older than merge base) increment minor version
-    #    else, tag is newer than merge base, so increment point version
+# 1. get the nearest tag with 'git describe'
+# 2. get the merge base between this commit and main
+# 3. if the tag is an ancestor of the merge base,
+#    (tag is older than merge base) increment minor version
+#    else, tag is newer than merge base, so increment point version
 
-    nearest_tag=$(git describe --tags --abbrev=0 --match "$versionglob" "$commit")
-    merge_base=$(git merge-base origin/main "$commit")
+nearest_tag=$(git describe --tags --abbrev=0 --match "$versionglob" "$commit")
+merge_base=$(git merge-base origin/main "$commit")
 
-    if git merge-base --is-ancestor "$nearest_tag" "$merge_base" ; then
-        # x.(y+1).0~devTIMESTAMP, where x.y.z is the newest version that does not contain $commit
-       # grep reads the list of tags (-f) that contain $commit and filters them out (-v)
-       # this prevents a newer tag from retroactively changing the versions of everything before it
-        v=$(git tag | grep -vFf <(git tag --contains "$commit") | sort -Vr | head -n1 | perl -pe 's/(\d+)\.(\d+)\.\d+.*/"$1.".($2+1).".0"/e')
-    else
-        # x.y.(z+1)~devTIMESTAMP, where x.y.z is the latest released ancestor of $commit
-        v=$(echo $nearest_tag | perl -pe 's/(\d+)$/$1+1/e')
-    fi
-    isodate=$(TZ=UTC git log -n1 --format=%cd --date=iso "$commit")
-    ts=$(TZ=UTC date --date="$isodate" "+%Y%m%d%H%M%S")
-    echo "${v}${devsuffix}${ts}"
+if git merge-base --is-ancestor "$nearest_tag" "$merge_base" ; then
+    # x.(y+1).0~devTIMESTAMP, where x.y.z is the newest version that does not contain $commit
+    # grep reads the list of tags (-f) that contain $commit and filters them out (-v)
+    # this prevents a newer tag from retroactively changing the versions of everything before it
+    v=$(git tag | grep -vFf <(git tag --contains "$merge_base") | sort -Vr | head -n1 | perl -pe 's/(\d+)\.(\d+)\.\d+.*/"$1.".($2+1).".0"/e')
+else
+    # x.y.(z+1)~devTIMESTAMP, where x.y.z is the latest released ancestor of $commit
+    v=$(echo $nearest_tag | perl -pe 's/(\d+)$/$1+1/e')
 fi
+isodate=$(TZ=UTC git log -n1 --format=%cd --date=iso "$commit")
+ts=$(TZ=UTC date --date="$isodate" "+%Y%m%d%H%M%S")
+echo "${v}${devsuffix}${ts}"
index c10783c978be6c48e6f7aea0c9ad1a959303b656..19d13437c898baad0fb2ceaea363c56ef7318d44 100644 (file)
@@ -55,12 +55,13 @@ var (
                "virtual_machine":          cli.APICall,
                "workflow":                 cli.APICall,
 
-               "mount":                mount.Command,
-               "deduplication-report": deduplicationreport.Command,
-               "costanalyzer":         costanalyzer.Command,
-               "shell":                shellCommand{},
                "connect-ssh":          connectSSHCommand{},
+               "costanalyzer":         costanalyzer.Command,
+               "deduplication-report": deduplicationreport.Command,
                "diagnostics":          diagnostics.Command{},
+               "logs":                 logsCommand{},
+               "mount":                mount.Command,
+               "shell":                shellCommand{},
                "sudo":                 sudoCommand{},
        })
 )
index cbbc7b1f9505cf58515bfdade093298cb8a182da..911375c655e0d295957903793a9b98f4943d4d8c 100644 (file)
@@ -9,6 +9,7 @@ import (
        "io/ioutil"
        "testing"
 
+       "git.arvados.org/arvados.git/lib/cmd"
        check "gopkg.in/check.v1"
 )
 
@@ -23,12 +24,12 @@ type ClientSuite struct{}
 
 func (s *ClientSuite) TestBadCommand(c *check.C) {
        exited := handler.RunCommand("arvados-client", []string{"no such command"}, bytes.NewReader(nil), ioutil.Discard, ioutil.Discard)
-       c.Check(exited, check.Equals, 2)
+       c.Check(exited, check.Equals, cmd.EXIT_INVALIDARGUMENT)
 }
 
 func (s *ClientSuite) TestBadSubcommandArgs(c *check.C) {
        exited := handler.RunCommand("arvados-client", []string{"get"}, bytes.NewReader(nil), ioutil.Discard, ioutil.Discard)
-       c.Check(exited, check.Equals, 2)
+       c.Check(exited, check.Equals, cmd.EXIT_INVALIDARGUMENT)
 }
 
 func (s *ClientSuite) TestVersion(c *check.C) {
index 55f8c33bc70c77d31f13f16bb924ee4c2a6a1613..2baa8012eae6dcb49c9ac9f4bd06e29f46a948b1 100644 (file)
@@ -7,21 +7,300 @@ package main
 import (
        "bytes"
        "context"
+       "crypto/tls"
        "flag"
        "fmt"
        "io"
+       "net/http"
        "net/url"
        "os"
        "os/exec"
        "path/filepath"
+       "sort"
        "strings"
        "syscall"
+       "time"
 
        "git.arvados.org/arvados.git/lib/cmd"
        "git.arvados.org/arvados.git/lib/controller/rpc"
        "git.arvados.org/arvados.git/sdk/go/arvados"
 )
 
+// logsCommand displays logs from a running container.
+type logsCommand struct {
+       ac *arvados.Client
+}
+
+func (lc logsCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+       f := flag.NewFlagSet(prog, flag.ContinueOnError)
+       follow := f.Bool("f", false, "follow: poll for new data until the container finishes")
+       pollInterval := f.Duration("poll", time.Second*2, "minimum duration to wait before polling for new data")
+       if ok, code := cmd.ParseFlags(f, prog, args, "container-request-uuid", stderr); !ok {
+               return code
+       } else if f.NArg() < 1 {
+               fmt.Fprintf(stderr, "missing required argument: container-request-uuid (try -help)\n")
+               return 2
+       } else if f.NArg() > 1 {
+               fmt.Fprintf(stderr, "encountered extra arguments after container-request-uuid (try -help)\n")
+               return 2
+       }
+       target := f.Args()[0]
+
+       lc.ac = arvados.NewClientFromEnv()
+       lc.ac.Client = &http.Client{}
+       if lc.ac.Insecure {
+               lc.ac.Client.Transport = &http.Transport{
+                       TLSClientConfig: &tls.Config{
+                               InsecureSkipVerify: true}}
+       }
+
+       err := lc.tail(target, stdout, stderr, *follow, *pollInterval)
+       if err != nil {
+               fmt.Fprintln(stderr, err)
+               return 1
+       }
+       return 0
+}
+
+func (lc *logsCommand) tail(crUUID string, stdout, stderr io.Writer, follow bool, pollInterval time.Duration) error {
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+
+       rpcconn, err := rpcFromEnv()
+       if err != nil {
+               return err
+       }
+       err = lc.checkAPISupport(ctx, crUUID)
+       if err != nil {
+               return err
+       }
+
+       var (
+               // files to display
+               watching = []string{"crunch-run.txt", "stderr.txt"}
+               // fnm => file offset of next byte to display
+               mark = map[string]int64{}
+               // fnm => current size of file reported by api
+               size = map[string]int64{}
+               // has anything worked? (if so, retry after errors)
+               anySuccess = false
+               // container UUID whose logs we are displaying
+               displayingUUID = ""
+               // container UUID we last showed in a "connected,
+               // polling" message
+               reportedUUID = ""
+       )
+
+       cr, err := rpcconn.ContainerRequestGet(ctx, arvados.GetOptions{UUID: crUUID, Select: []string{"uuid", "container_uuid", "state"}})
+       if err != nil {
+               return fmt.Errorf("error retrieving %s: %w", crUUID, err)
+       }
+       displayingUUID = cr.ContainerUUID
+poll:
+       for delay := pollInterval; ; time.Sleep(delay) {
+               if cr.ContainerUUID == "" {
+                       return fmt.Errorf("%s has no assigned container (state is %s)", crUUID, cr.State)
+               }
+               if delay < pollInterval {
+                       delay = pollInterval
+               }
+               // When .../container_requests/{uuid}/log_events is
+               // implemented, we'll wait here for the next
+               // server-sent event to tell us some updated file
+               // sizes. For now, we poll.
+               for _, fnm := range watching {
+                       currentsize, _, err := lc.copyRange(ctx, cr.UUID, displayingUUID, fnm, "-0", nil)
+                       if err != nil {
+                               if !anySuccess {
+                                       return err
+                               }
+                               fmt.Fprintln(stderr, err)
+                               delay = pollInterval
+                               continue poll
+                       }
+                       if reportedUUID != displayingUUID {
+                               fmt.Fprintln(stderr, "connected, polling for log data from container", displayingUUID)
+                               reportedUUID = displayingUUID
+                       }
+                       size[fnm] = currentsize
+                       if oldsize, seen := mark[fnm]; !seen && currentsize > 10000 {
+                               mark[fnm] = currentsize - 10000
+                       } else if !seen {
+                               mark[fnm] = 0
+                       } else if currentsize < oldsize {
+                               // Log collection must have been
+                               // emptied and reset.
+                               fmt.Fprintln(stderr, "--- log restarted ---")
+                               mark = map[string]int64{}
+                               delay = pollInterval
+                               continue poll
+                       }
+               }
+               newData := map[string]*bytes.Buffer{}
+               for _, fnm := range watching {
+                       if size[fnm] > mark[fnm] {
+                               newData[fnm] = &bytes.Buffer{}
+                               _, n, err := lc.copyRange(ctx, cr.UUID, displayingUUID, fnm, fmt.Sprintf("%d-", mark[fnm]), newData[fnm])
+                               if err != nil {
+                                       fmt.Fprintln(stderr, err)
+                               }
+                               if n > 0 {
+                                       mark[fnm] += n
+                                       anySuccess = true
+                               }
+                       }
+               }
+               checkState := lc.display(stdout, stderr, watching, newData)
+               if displayingUUID != cr.ContainerUUID {
+                       // A different container had already been
+                       // assigned when we started fetching the
+                       // latest batch of logs. We can now safely
+                       // start displaying logs from the new
+                       // container, without missing any of the
+                       // previous container's logs.
+                       displayingUUID = cr.ContainerUUID
+                       delay = 0
+                       continue
+               } else if cr.State == arvados.ContainerRequestStateFinal || !follow {
+                       break
+               } else if len(newData) > 0 {
+                       delay = pollInterval
+               } else {
+                       delay = delay * 2
+                       if delay > pollInterval*5 {
+                               delay = pollInterval * 5
+                       }
+                       checkState = true
+               }
+               if checkState {
+                       cr, err = rpcconn.ContainerRequestGet(ctx, arvados.GetOptions{UUID: crUUID, Select: []string{"uuid", "container_uuid", "state"}})
+                       if err != nil {
+                               if !anySuccess {
+                                       return fmt.Errorf("error retrieving %s: %w", crUUID, err)
+                               }
+                               fmt.Fprintln(stderr, err)
+                               delay = pollInterval
+                               continue
+                       }
+               }
+       }
+       return nil
+}
+
+func (lc *logsCommand) srcURL(crUUID, cUUID, fnm string) string {
+       u := url.URL{
+               Scheme: "https",
+               Host:   lc.ac.APIHost,
+               Path:   "/arvados/v1/container_requests/" + crUUID + "/log/" + cUUID + "/" + fnm,
+       }
+       return u.String()
+}
+
+// Check whether the API is new enough to support the
+// .../container_requests/{uuid}/log/ endpoint.
+//
+// Older versions return 200 for an OPTIONS request at the .../log/
+// API endpoint, but the response header does not have a "Dav" header.
+//
+// Note an error response with no "Dav" header is not taken to
+// indicate lack of API support. It may come from a new server that
+// has a configuration or networking problem.
+func (lc *logsCommand) checkAPISupport(ctx context.Context, crUUID string) error {
+       ctx, cancel := context.WithDeadline(ctx, time.Now().Add(20*time.Second))
+       defer cancel()
+       req, err := http.NewRequestWithContext(ctx, "OPTIONS", strings.TrimSuffix(lc.srcURL(crUUID, "", ""), "/"), nil)
+       if err != nil {
+               return err
+       }
+       req.Header.Set("Authorization", "Bearer "+lc.ac.AuthToken)
+       resp, err := lc.ac.Client.Do(req)
+       if err != nil {
+               return err
+       }
+       defer resp.Body.Close()
+       if resp.StatusCode == http.StatusOK && resp.Header.Get("Dav") == "" {
+               return fmt.Errorf("server does not support container logs API (OPTIONS request returned HTTP %s, Dav: %q)", resp.Status, resp.Header.Get("Dav"))
+       }
+       return nil
+}
+
+// Retrieve specified byte range (e.g., "12-34", "1234-") from given
+// fnm and write to out.
+//
+// If range is empty ("-0"), out can be nil.
+//
+// Return values are current file size, bytes copied, error.
+//
+// If the file does not exist, return values are 0, 0, nil.
+func (lc *logsCommand) copyRange(ctx context.Context, crUUID, cUUID, fnm, byterange string, out io.Writer) (int64, int64, error) {
+       ctx, cancel := context.WithDeadline(ctx, time.Now().Add(20*time.Second))
+       defer cancel()
+       req, err := http.NewRequestWithContext(ctx, http.MethodGet, lc.srcURL(crUUID, cUUID, fnm), nil)
+       if err != nil {
+               return 0, 0, err
+       }
+       req.Header.Set("Range", "bytes="+byterange)
+       req.Header.Set("Authorization", "Bearer "+lc.ac.AuthToken)
+       resp, err := lc.ac.Client.Do(req)
+       if err != nil {
+               return 0, 0, err
+       }
+       defer resp.Body.Close()
+       if resp.StatusCode == http.StatusNotFound {
+               return 0, 0, nil
+       }
+       if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
+               body, _ := io.ReadAll(io.LimitReader(resp.Body, 10000))
+               return 0, 0, fmt.Errorf("error getting %s: HTTP %s -- %s", fnm, resp.Status, bytes.TrimSuffix(body, []byte{'\n'}))
+       }
+       var rstart, rend, rsize int64
+       _, err = fmt.Sscanf(resp.Header.Get("Content-Range"), "bytes %d-%d/%d", &rstart, &rend, &rsize)
+       if err != nil {
+               return 0, 0, fmt.Errorf("error parsing Content-Range header %q: %s", resp.Header.Get("Content-Range"), err)
+       }
+       if out == nil {
+               return rsize, 0, nil
+       }
+       n, err := io.Copy(out, resp.Body)
+       return rsize, n, err
+}
+
+// display some log data, formatted as desired (prefixing each line
+// with a tag indicating which file it came from, etc.).
+//
+// Return value is true if the log data contained a hint that it's a
+// good time to check whether the container is finished so we can
+// exit.
+func (lc *logsCommand) display(out, stderr io.Writer, watching []string, received map[string]*bytes.Buffer) bool {
+       checkState := false
+       var sorted []string
+       for _, fnm := range watching {
+               buf := received[fnm]
+               if buf == nil || buf.Len() == 0 {
+                       continue
+               }
+               for _, line := range bytes.Split(bytes.TrimSuffix(buf.Bytes(), []byte{'\n'}), []byte{'\n'}) {
+                       sorted = append(sorted, fmt.Sprintf("%-14s %s\n", fnm, line))
+                       if fnm == "crunch-run.txt" {
+                               checkState = checkState ||
+                                       bytes.HasSuffix(line, []byte("Complete")) ||
+                                       bytes.HasSuffix(line, []byte("Cancelled")) ||
+                                       bytes.HasSuffix(line, []byte("Queued"))
+                       }
+               }
+       }
+       sort.Slice(sorted, func(i, j int) bool {
+               return sorted[i][15:] < sorted[j][15:]
+       })
+       for _, s := range sorted {
+               _, err := fmt.Fprint(out, s)
+               if err != nil {
+                       fmt.Fprintln(stderr, err)
+               }
+       }
+       return checkState
+}
+
 // shellCommand connects the terminal to an interactive shell on a
 // running container.
 type shellCommand struct{}
@@ -125,41 +404,18 @@ Options:
                loginUsername = targetUUID[:i]
                targetUUID = targetUUID[i+1:]
        }
-       if os.Getenv("ARVADOS_API_HOST") == "" || os.Getenv("ARVADOS_API_TOKEN") == "" {
-               fmt.Fprintln(stderr, "fatal: ARVADOS_API_HOST and ARVADOS_API_TOKEN environment variables are not set")
+       rpcconn, err := rpcFromEnv()
+       if err != nil {
+               fmt.Fprintln(stderr, err)
                return 1
        }
-       insecure := os.Getenv("ARVADOS_API_HOST_INSECURE")
-       rpcconn := rpc.NewConn("",
-               &url.URL{
-                       Scheme: "https",
-                       Host:   os.Getenv("ARVADOS_API_HOST"),
-               },
-               insecure == "1" || insecure == "yes" || insecure == "true",
-               func(context.Context) ([]string, error) {
-                       return []string{os.Getenv("ARVADOS_API_TOKEN")}, nil
-               })
-       if strings.Contains(targetUUID, "-xvhdp-") {
-               crs, err := rpcconn.ContainerRequestList(context.TODO(), arvados.ListOptions{Limit: -1, Filters: []arvados.Filter{{"uuid", "=", targetUUID}}})
-               if err != nil {
-                       fmt.Fprintln(stderr, err)
-                       return 1
-               }
-               if len(crs.Items) < 1 {
-                       fmt.Fprintf(stderr, "container request %q not found\n", targetUUID)
-                       return 1
-               }
-               cr := crs.Items[0]
-               if cr.ContainerUUID == "" {
-                       fmt.Fprintf(stderr, "no container assigned, container request state is %s\n", strings.ToLower(string(cr.State)))
-                       return 1
-               }
-               targetUUID = cr.ContainerUUID
-               fmt.Fprintln(stderr, "connecting to container", targetUUID)
-       } else if !strings.Contains(targetUUID, "-dz642-") {
-               fmt.Fprintf(stderr, "target UUID is not a container or container request UUID: %s\n", targetUUID)
+       targetUUID, err = resolveToContainerUUID(rpcconn, targetUUID)
+       if err != nil {
+               fmt.Fprintln(stderr, err)
                return 1
        }
+       fmt.Fprintln(stderr, "connecting to container", targetUUID)
+
        ctx, cancel := context.WithCancel(context.Background())
        defer cancel()
        sshconn, err := rpcconn.ContainerSSH(ctx, arvados.ContainerSSHOptions{
@@ -199,3 +455,41 @@ Options:
 func shellescape(s string) string {
        return "'" + strings.Replace(s, "'", "'\\''", -1) + "'"
 }
+
+func rpcFromEnv() (*rpc.Conn, error) {
+       ac := arvados.NewClientFromEnv()
+       if ac.APIHost == "" || ac.AuthToken == "" {
+               return nil, fmt.Errorf("fatal: ARVADOS_API_HOST and ARVADOS_API_TOKEN environment variables are not set, and ~/.config/arvados/settings.conf is not readable")
+       }
+       return rpc.NewConn("",
+               &url.URL{
+                       Scheme: "https",
+                       Host:   ac.APIHost,
+               },
+               ac.Insecure,
+               func(context.Context) ([]string, error) {
+                       return []string{ac.AuthToken}, nil
+               }), nil
+}
+
+func resolveToContainerUUID(rpcconn *rpc.Conn, targetUUID string) (string, error) {
+       switch {
+       case strings.Contains(targetUUID, "-dz642-") && len(targetUUID) == 27:
+               return targetUUID, nil
+       case strings.Contains(targetUUID, "-xvhdp-") && len(targetUUID) == 27:
+               crs, err := rpcconn.ContainerRequestList(context.TODO(), arvados.ListOptions{Limit: -1, Filters: []arvados.Filter{{"uuid", "=", targetUUID}}})
+               if err != nil {
+                       return "", err
+               }
+               if len(crs.Items) < 1 {
+                       return "", fmt.Errorf("container request %q not found", targetUUID)
+               }
+               cr := crs.Items[0]
+               if cr.ContainerUUID == "" {
+                       return "", fmt.Errorf("no container assigned, container request state is %s", strings.ToLower(string(cr.State)))
+               }
+               return cr.ContainerUUID, nil
+       default:
+               return "", fmt.Errorf("target UUID is not a container or container request UUID: %s", targetUUID)
+       }
+}
index 743b91d69bde058e78183faf50a0965b56d4f7b6..016b793f3f5987ab7912927399909ad9eda1ad50 100644 (file)
@@ -10,6 +10,7 @@ import (
        "crypto/hmac"
        "crypto/sha256"
        "fmt"
+       "io"
        "io/ioutil"
        "net"
        "net/http"
@@ -24,34 +25,48 @@ import (
        "git.arvados.org/arvados.git/lib/controller/rpc"
        "git.arvados.org/arvados.git/lib/crunchrun"
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "git.arvados.org/arvados.git/sdk/go/keepclient"
        check "gopkg.in/check.v1"
 )
 
-func (s *ClientSuite) TestShellGatewayNotAvailable(c *check.C) {
-       var stdout, stderr bytes.Buffer
-       cmd := exec.Command("go", "run", ".", "shell", arvadostest.QueuedContainerUUID, "-o", "controlpath=none", "echo", "ok")
-       cmd.Env = append(cmd.Env, os.Environ()...)
-       cmd.Env = append(cmd.Env, "ARVADOS_API_TOKEN="+arvadostest.ActiveTokenV2)
-       cmd.Stdout = &stdout
-       cmd.Stderr = &stderr
-       c.Check(cmd.Run(), check.NotNil)
-       c.Log(stderr.String())
-       c.Check(stderr.String(), check.Matches, `(?ms).*container is not running yet \(state is "Queued"\).*`)
+var _ = check.Suite(&shellSuite{})
+
+type shellSuite struct {
+       gobindir    string
+       homedir     string
+       runningUUID string
 }
 
-func (s *ClientSuite) TestShellGateway(c *check.C) {
-       defer func() {
-               c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
-       }()
-       uuid := arvadostest.QueuedContainerUUID
+func (s *shellSuite) SetUpSuite(c *check.C) {
+       tmpdir := c.MkDir()
+       s.gobindir = tmpdir + "/bin"
+       c.Check(os.Mkdir(s.gobindir, 0777), check.IsNil)
+       s.homedir = tmpdir + "/home"
+       c.Check(os.Mkdir(s.homedir, 0777), check.IsNil)
+
+       // We explicitly build a client binary in our tempdir here,
+       // instead of using "go run .", because (a) we're going to
+       // invoke the same binary several times, and (b) we're going
+       // to change $HOME to a temp dir in some of the tests, which
+       // would force "go run ." to recompile the world instead of
+       // using the cached object files in the real $HOME.
+       c.Logf("building arvados-client binary in %s", s.gobindir)
+       cmd := exec.Command("go", "install", ".")
+       cmd.Env = append(os.Environ(), "GOBIN="+s.gobindir)
+       cmd.Stdout = os.Stdout
+       cmd.Stderr = os.Stderr
+       c.Assert(cmd.Run(), check.IsNil)
+
+       s.runningUUID = arvadostest.RunningContainerUUID
        h := hmac.New(sha256.New, []byte(arvadostest.SystemRootToken))
-       fmt.Fprint(h, uuid)
+       fmt.Fprint(h, s.runningUUID)
        authSecret := fmt.Sprintf("%x", h.Sum(nil))
        gw := crunchrun.Gateway{
-               ContainerUUID: uuid,
+               ContainerUUID: s.runningUUID,
                Address:       "0.0.0.0:0",
                AuthSecret:    authSecret,
                Log:           ctxlog.TestLogger(c),
@@ -72,32 +87,78 @@ func (s *ClientSuite) TestShellGateway(c *check.C) {
                func(context.Context) ([]string, error) {
                        return []string{arvadostest.SystemRootToken}, nil
                })
-       _, err = rpcconn.ContainerUpdate(context.TODO(), arvados.UpdateOptions{UUID: uuid, Attrs: map[string]interface{}{
-               "state": arvados.ContainerStateLocked,
-       }})
-       c.Assert(err, check.IsNil)
-       _, err = rpcconn.ContainerUpdate(context.TODO(), arvados.UpdateOptions{UUID: uuid, Attrs: map[string]interface{}{
-               "state":           arvados.ContainerStateRunning,
+       _, err = rpcconn.ContainerUpdate(context.TODO(), arvados.UpdateOptions{UUID: s.runningUUID, Attrs: map[string]interface{}{
                "gateway_address": gw.Address,
        }})
        c.Assert(err, check.IsNil)
+}
 
+func (s *shellSuite) TearDownSuite(c *check.C) {
+       c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
+}
+
+func (s *shellSuite) TestShellGatewayNotAvailable(c *check.C) {
        var stdout, stderr bytes.Buffer
-       cmd := exec.Command("go", "run", ".", "shell", uuid, "-o", "controlpath=none", "-o", "userknownhostsfile="+c.MkDir()+"/known_hosts", "echo", "ok")
+       cmd := exec.Command(s.gobindir+"/arvados-client", "shell", arvadostest.QueuedContainerUUID, "-o", "controlpath=none", "echo", "ok")
        cmd.Env = append(cmd.Env, os.Environ()...)
        cmd.Env = append(cmd.Env, "ARVADOS_API_TOKEN="+arvadostest.ActiveTokenV2)
        cmd.Stdout = &stdout
        cmd.Stderr = &stderr
+       c.Check(cmd.Run(), check.NotNil)
+       c.Log(stderr.String())
+       c.Check(stderr.String(), check.Matches, `(?ms).*container is not running yet \(state is "Queued"\).*`)
+}
+
+func (s *shellSuite) TestShellGatewayUsingEnvVars(c *check.C) {
+       s.testShellGateway(c, false)
+}
+func (s *shellSuite) TestShellGatewayUsingSettingsConf(c *check.C) {
+       s.testShellGateway(c, true)
+}
+func (s *shellSuite) testShellGateway(c *check.C, useSettingsConf bool) {
+       var stdout, stderr bytes.Buffer
+       cmd := exec.Command(
+               s.gobindir+"/arvados-client", "shell", s.runningUUID,
+               "-o", "controlpath=none",
+               "-o", "userknownhostsfile="+s.homedir+"/known_hosts",
+               "echo", "ok")
+       if useSettingsConf {
+               settings := "ARVADOS_API_HOST=" + os.Getenv("ARVADOS_API_HOST") + "\nARVADOS_API_TOKEN=" + arvadostest.ActiveTokenV2 + "\nARVADOS_API_HOST_INSECURE=true\n"
+               err := os.MkdirAll(s.homedir+"/.config/arvados", 0777)
+               c.Assert(err, check.IsNil)
+               err = os.WriteFile(s.homedir+"/.config/arvados/settings.conf", []byte(settings), 0777)
+               c.Assert(err, check.IsNil)
+               for _, kv := range os.Environ() {
+                       if !strings.HasPrefix(kv, "ARVADOS_") && !strings.HasPrefix(kv, "HOME=") {
+                               cmd.Env = append(cmd.Env, kv)
+                       }
+               }
+               cmd.Env = append(cmd.Env, "HOME="+s.homedir)
+       } else {
+               err := os.Remove(s.homedir + "/.config/arvados/settings.conf")
+               if !os.IsNotExist(err) {
+                       c.Assert(err, check.IsNil)
+               }
+               cmd.Env = append(cmd.Env, os.Environ()...)
+               cmd.Env = append(cmd.Env, "ARVADOS_API_TOKEN="+arvadostest.ActiveTokenV2)
+       }
+       cmd.Stdout = &stdout
+       cmd.Stderr = &stderr
        stdin, err := cmd.StdinPipe()
        c.Assert(err, check.IsNil)
        go fmt.Fprintln(stdin, "data appears on stdin, but stdin does not close; cmd should exit anyway, not hang")
-       time.AfterFunc(5*time.Second, func() {
+       timeout := time.AfterFunc(5*time.Second, func() {
                c.Errorf("timed out -- remote end is probably hung waiting for us to close stdin")
                stdin.Close()
        })
+       c.Logf("cmd.Args: %s", cmd.Args)
        c.Check(cmd.Run(), check.IsNil)
+       timeout.Stop()
        c.Check(stdout.String(), check.Equals, "ok\n")
+}
 
+func (s *shellSuite) TestShellGatewayPortForwarding(c *check.C) {
+       c.Log("setting up an http server")
        // Set up an http server, and try using "arvados-client shell"
        // to forward traffic to it.
        httpTarget := &httpserver.Server{}
@@ -109,7 +170,7 @@ func (s *ClientSuite) TestShellGateway(c *check.C) {
                        w.WriteHeader(http.StatusNotFound)
                }
        })
-       err = httpTarget.Start()
+       err := httpTarget.Start()
        c.Assert(err, check.IsNil)
 
        ln, err := net.Listen("tcp", ":0")
@@ -117,22 +178,22 @@ func (s *ClientSuite) TestShellGateway(c *check.C) {
        _, forwardedPort, _ := net.SplitHostPort(ln.Addr().String())
        ln.Close()
 
-       stdout.Reset()
-       stderr.Reset()
+       c.Log("connecting")
+       var stdout, stderr bytes.Buffer
        ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(10*time.Second))
        defer cancel()
-       cmd = exec.CommandContext(ctx,
-               "go", "run", ".", "shell", uuid,
+       cmd := exec.CommandContext(ctx,
+               s.gobindir+"/arvados-client", "shell", s.runningUUID,
                "-L", forwardedPort+":"+httpTarget.Addr,
                "-o", "controlpath=none",
-               "-o", "userknownhostsfile="+c.MkDir()+"/known_hosts",
+               "-o", "userknownhostsfile="+s.homedir+"/known_hosts",
                "-N",
        )
-       c.Logf("cmd.Args: %s", cmd.Args)
        cmd.Env = append(cmd.Env, os.Environ()...)
        cmd.Env = append(cmd.Env, "ARVADOS_API_TOKEN="+arvadostest.ActiveTokenV2)
        cmd.Stdout = &stdout
        cmd.Stderr = &stderr
+       c.Logf("cmd.Args: %s", cmd.Args)
        cmd.Start()
 
        forwardedURL := fmt.Sprintf("http://localhost:%s/foo", forwardedPort)
@@ -178,3 +239,150 @@ func (s *ClientSuite) TestShellGateway(c *check.C) {
        }
        wg.Wait()
 }
+
+var _ = check.Suite(&logsSuite{})
+
+type logsSuite struct{}
+
+func (s *logsSuite) TestContainerRequestLog(c *check.C) {
+       arvadostest.StartKeep(2, true)
+       ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
+       defer cancel()
+
+       rpcconn := rpc.NewConn("",
+               &url.URL{
+                       Scheme: "https",
+                       Host:   os.Getenv("ARVADOS_API_HOST"),
+               },
+               true,
+               func(context.Context) ([]string, error) {
+                       return []string{arvadostest.SystemRootToken}, nil
+               })
+       imageColl, err := rpcconn.CollectionCreate(ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
+               "manifest_text": ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.tar\n",
+       }})
+       c.Assert(err, check.IsNil)
+       c.Logf("imageColl %+v", imageColl)
+       cr, err := rpcconn.ContainerRequestCreate(ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
+               "state":           "Committed",
+               "command":         []string{"echo", fmt.Sprintf("%d", time.Now().Unix())},
+               "container_image": imageColl.PortableDataHash,
+               "cwd":             "/",
+               "output_path":     "/",
+               "priority":        1,
+               "runtime_constraints": arvados.RuntimeConstraints{
+                       VCPUs: 1,
+                       RAM:   1000000000,
+               },
+               "container_count_max": 1,
+       }})
+       c.Assert(err, check.IsNil)
+       h := hmac.New(sha256.New, []byte(arvadostest.SystemRootToken))
+       fmt.Fprint(h, cr.ContainerUUID)
+       authSecret := fmt.Sprintf("%x", h.Sum(nil))
+
+       coll := arvados.Collection{}
+       client := arvados.NewClientFromEnv()
+       ac, err := arvadosclient.New(client)
+       c.Assert(err, check.IsNil)
+       kc, err := keepclient.MakeKeepClient(ac)
+       c.Assert(err, check.IsNil)
+       cfs, err := coll.FileSystem(client, kc)
+       c.Assert(err, check.IsNil)
+
+       c.Log("running logs command on queued container")
+       var stdout, stderr bytes.Buffer
+       cmd := exec.CommandContext(ctx, "go", "run", ".", "logs", "-f", "-poll=250ms", cr.UUID)
+       cmd.Env = append(cmd.Env, os.Environ()...)
+       cmd.Env = append(cmd.Env, "ARVADOS_API_TOKEN="+arvadostest.SystemRootToken)
+       cmd.Stdout = io.MultiWriter(&stdout, os.Stderr)
+       cmd.Stderr = io.MultiWriter(&stderr, os.Stderr)
+       err = cmd.Start()
+       c.Assert(err, check.Equals, nil)
+
+       c.Log("changing container state to Locked")
+       _, err = rpcconn.ContainerUpdate(ctx, arvados.UpdateOptions{UUID: cr.ContainerUUID, Attrs: map[string]interface{}{
+               "state": arvados.ContainerStateLocked,
+       }})
+       c.Assert(err, check.IsNil)
+       c.Log("starting gateway")
+       gw := crunchrun.Gateway{
+               ContainerUUID: cr.ContainerUUID,
+               Address:       "0.0.0.0:0",
+               AuthSecret:    authSecret,
+               Log:           ctxlog.TestLogger(c),
+               Target:        crunchrun.GatewayTargetStub{},
+               LogCollection: cfs,
+       }
+       err = gw.Start()
+       c.Assert(err, check.IsNil)
+       c.Log("updating container gateway address")
+       _, err = rpcconn.ContainerUpdate(ctx, arvados.UpdateOptions{UUID: cr.ContainerUUID, Attrs: map[string]interface{}{
+               "gateway_address": gw.Address,
+               "state":           arvados.ContainerStateRunning,
+       }})
+       c.Assert(err, check.IsNil)
+
+       const rfc3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
+       fCrunchrun, err := cfs.OpenFile("crunch-run.txt", os.O_CREATE|os.O_WRONLY, 0777)
+       c.Assert(err, check.IsNil)
+       _, err = fmt.Fprintf(fCrunchrun, "%s line 1 of crunch-run.txt\n", time.Now().UTC().Format(rfc3339NanoFixed))
+       c.Assert(err, check.IsNil)
+       fStderr, err := cfs.OpenFile("stderr.txt", os.O_CREATE|os.O_WRONLY, 0777)
+       c.Assert(err, check.IsNil)
+       _, err = fmt.Fprintf(fStderr, "%s line 1 of stderr\n", time.Now().UTC().Format(rfc3339NanoFixed))
+       c.Assert(err, check.IsNil)
+
+       {
+               // Without "-f", just show the existing logs and
+               // exit. Timeout needs to be long enough for "go run".
+               ctxNoFollow, cancel := context.WithDeadline(ctx, time.Now().Add(time.Second*5))
+               defer cancel()
+               cmdNoFollow := exec.CommandContext(ctxNoFollow, "go", "run", ".", "logs", "-poll=250ms", cr.UUID)
+               buf, err := cmdNoFollow.CombinedOutput()
+               c.Check(err, check.IsNil)
+               c.Check(string(buf), check.Matches, `(?ms).*line 1 of stderr\n`)
+       }
+
+       time.Sleep(time.Second * 2)
+       _, err = fmt.Fprintf(fCrunchrun, "%s line 2 of crunch-run.txt", time.Now().UTC().Format(rfc3339NanoFixed))
+       c.Assert(err, check.IsNil)
+       _, err = fmt.Fprintf(fStderr, "%s --end--", time.Now().UTC().Format(rfc3339NanoFixed))
+       c.Assert(err, check.IsNil)
+
+       for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline) && !strings.Contains(stdout.String(), "--end--"); time.Sleep(time.Second / 10) {
+       }
+       c.Check(stdout.String(), check.Matches, `(?ms).*stderr\.txt +20\S+Z --end--\n.*`)
+
+       mtxt, err := cfs.MarshalManifest(".")
+       c.Assert(err, check.IsNil)
+       savedLog, err := rpcconn.CollectionCreate(ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
+               "manifest_text": mtxt,
+       }})
+       c.Assert(err, check.IsNil)
+       _, err = rpcconn.ContainerUpdate(ctx, arvados.UpdateOptions{UUID: cr.ContainerUUID, Attrs: map[string]interface{}{
+               "state":     arvados.ContainerStateComplete,
+               "log":       savedLog.PortableDataHash,
+               "output":    "d41d8cd98f00b204e9800998ecf8427e+0",
+               "exit_code": 0,
+       }})
+       c.Assert(err, check.IsNil)
+
+       err = cmd.Wait()
+       c.Check(err, check.IsNil)
+       // Ensure controller doesn't cheat by fetching data from the
+       // gateway after the container is complete.
+       gw.LogCollection = nil
+
+       c.Logf("re-running logs command on completed container")
+       {
+               ctx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Second*5))
+               defer cancel()
+               cmd := exec.CommandContext(ctx, "go", "run", ".", "logs", "-f", cr.UUID)
+               cmd.Env = append(cmd.Env, os.Environ()...)
+               cmd.Env = append(cmd.Env, "ARVADOS_API_TOKEN="+arvadostest.SystemRootToken)
+               buf, err := cmd.CombinedOutput()
+               c.Check(err, check.Equals, nil)
+               c.Check(string(buf), check.Matches, `(?ms).*--end--\n`)
+       }
+}
diff --git a/cmd/arvados-client/fpm-info.sh b/cmd/arvados-client/fpm-info.sh
new file mode 100644 (file)
index 0000000..c717632
--- /dev/null
@@ -0,0 +1,11 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+fpm_depends+=(fuse)
+
+case "$TARGET" in
+    centos*|rocky*)
+        fpm_depends+=(fuse-libs)
+        ;;
+esac
index db3d63f277da4f972d6733f0b87c351cc9ad45c6..c16d8a1c95605400a9b26d262f4780c1c5b4ef4d 100644 (file)
@@ -71,7 +71,7 @@ type opts struct {
 func parseFlags(prog string, args []string, stderr io.Writer) (_ opts, ok bool, exitCode int) {
        opts := opts{
                SourceDir:  ".",
-               TargetOS:   "debian:10",
+               TargetOS:   "debian:11",
                Maintainer: "Arvados Package Maintainers <packaging@arvados.org>",
                Vendor:     "The Arvados Project",
        }
index 420cbb035a7e7177f84ef7a9ca07117d70e37e5f..a66db787a765241c551e85a63d9a8e187ad9a4c8 100644 (file)
@@ -7,8 +7,6 @@ Description=Arvados controller
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -19,9 +17,7 @@ ExecStart=/usr/bin/arvados-controller
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index 8d57e8a1612ca49ecc9d98b44a716eb1485e2640..09b0ba94a91eb4eb30665cfa46d5d4ab4e88a074 100644 (file)
@@ -7,8 +7,6 @@ Description=arvados-dispatch-cloud
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -19,9 +17,7 @@ ExecStart=/usr/bin/arvados-dispatch-cloud
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index 65d8786670af43a4e1a8ce610fdd3babf5af1270..a683e856885a5d1696507a1fe87b410374701027 100644 (file)
@@ -7,8 +7,6 @@ Description=arvados-dispatch-lsf
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -19,9 +17,7 @@ ExecStart=/usr/bin/arvados-dispatch-lsf
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index b45587ffc06bd683467b8a5f3de086a08e5598b9..517a75c03d027d66268773fbcf1e7c711c8dfd26 100644 (file)
@@ -7,8 +7,6 @@ Description=Arvados git server
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -19,9 +17,7 @@ ExecStart=/usr/bin/arvados-git-httpd
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index cf246b0ee2a13a0fbd830a47314e1203067af822..899bfac219c879fec682cdcb71b0725b10445f05 100644 (file)
@@ -7,8 +7,6 @@ Description=Arvados healthcheck server
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -19,9 +17,7 @@ ExecStart=/usr/bin/arvados-health
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index f73db5d08032369c619e42429ddf7a68550b8551..fc6eb4978a156a28fec5df3ce4a362a151a70152 100644 (file)
@@ -7,8 +7,6 @@ Description=Arvados websocket server
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -18,9 +16,7 @@ ExecStart=/usr/bin/arvados-ws
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index 438ca206daa06dbb9f5211fce8bf57ed788cf6a0..c02b8fb57cf90cc318604d0d24063751c7da90d1 100644 (file)
@@ -21,6 +21,7 @@ import (
        "git.arvados.org/arvados.git/lib/config"
        "git.arvados.org/arvados.git/lib/controller"
        "git.arvados.org/arvados.git/lib/crunchrun"
+       "git.arvados.org/arvados.git/lib/crunchstat"
        "git.arvados.org/arvados.git/lib/dispatchcloud"
        "git.arvados.org/arvados.git/lib/install"
        "git.arvados.org/arvados.git/lib/lsf"
@@ -52,6 +53,7 @@ var (
                "config-dump":        config.DumpCommand,
                "controller":         controller.Command,
                "crunch-run":         crunchrun.Command,
+               "crunchstat":         crunchstat.Command,
                "dispatch-cloud":     dispatchcloud.Command,
                "dispatch-lsf":       lsf.DispatchCommand,
                "dispatch-slurm":     dispatchslurm.Command,
index 51b4e58c35b77ce1f391be6cea43f46d4961cd07..83933c17604376a45589c76edcbcbea930735515 100644 (file)
@@ -7,8 +7,6 @@ Description=Arvados Crunch Dispatcher for SLURM
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -19,9 +17,7 @@ ExecStart=/usr/bin/crunch-dispatch-slurm
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index 1c5808288b38a4e0a1b5b4706cfa446f6a224841..1d759d623786cbfb89a55351bcc32fbeb67568d3 100644 (file)
@@ -7,22 +7,18 @@ Description=Arvados Keep Balance
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
 Type=notify
 EnvironmentFile=-/etc/arvados/environment
-ExecStart=/usr/bin/keep-balance -commit-pulls -commit-trash
+ExecStart=/usr/bin/keep-balance
 # Set a reasonable default for the open file limit
 LimitNOFILE=65536
 Restart=always
 RestartSec=10s
 Nice=19
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index c0e193d6d812d1c367160aa5b25bd29dced9185e..d94124c6de69c4c520037144e1c63f45a53bf9d5 100644 (file)
@@ -7,8 +7,6 @@ Description=Arvados Keep WebDAV and S3 gateway
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -19,9 +17,7 @@ ExecStart=/usr/bin/keep-web
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index 7d4d0926775286411b1176047913c452431f1cf6..c4083f23c95d2ef22150ef7f2c80a6d6b72b0a42 100644 (file)
@@ -7,8 +7,6 @@ Description=Arvados Keep Proxy
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -19,9 +17,7 @@ ExecStart=/usr/bin/keepproxy
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index bcfde3a7881f0c9d7a3217236d773e7845b2458c..aa5e013dee5dd81e30a7bb39d29e440032c41437 100644 (file)
@@ -7,8 +7,6 @@ Description=Arvados Keep Storage Daemon
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -23,9 +21,7 @@ ExecStart=/usr/bin/keepstore
 LimitNOFILE=65536
 Restart=always
 RestartSec=1
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+RestartPreventExitStatus=2
 
 [Install]
 WantedBy=multi-user.target
index 5fcdbb64432fb2fad49e2ed29dd3366e8a3d15d1..c2ef90d87807c3f711d86d6f263f45dfa8f4ac02 100644 (file)
@@ -1,20 +1,29 @@
 GEM
   remote: https://rubygems.org/
   specs:
-    RedCloth (4.3.2)
+    RedCloth (4.3.3)
     coderay (1.1.3)
-    colorize (0.8.1)
+    colorize (1.1.0)
     commonjs (0.2.7)
-    kramdown (1.17.0)
+    kramdown (2.4.0)
+      rexml
+    kramdown-parser-gfm (1.1.0)
+      kramdown (~> 2.0)
+    kramdown-syntax-coderay (1.0.1)
+      coderay (~> 1.1)
+      kramdown (~> 2.0)
     less (2.6.0)
       commonjs (~> 0.2.7)
-    liquid (4.0.3)
+    liquid (4.0.4)
     makerakeworkwell (1.0.4)
       rake (>= 0.9.2, < 15)
-    rake (13.0.1)
-    zenweb (3.10.4)
+    rake (13.1.0)
+    rexml (3.2.6)
+    zenweb (3.11.0)
       coderay (~> 1.0)
-      kramdown (~> 1.4)
+      kramdown (~> 2.0)
+      kramdown-parser-gfm (~> 1.0)
+      kramdown-syntax-coderay (~> 1.0)
       less (~> 2.0)
       makerakeworkwell (~> 1.0)
       rake (>= 0.9, < 15)
@@ -29,4 +38,4 @@ DEPENDENCIES
   zenweb
 
 BUNDLED WITH
-   2.1.4
+   2.2.19
index 85757980a7a40dd9b80d15c05e108209f0432ccb..84275955454c2f3f40ca7d3827539d98d84dbe69 100644 (file)
@@ -12,17 +12,35 @@ Additional information is available on the "'Documentation' page on the Arvados
 
 h2. Install dependencies
 
+To build the core Arvados documentation:
+
 <pre>
 arvados/doc$ sudo apt-get install build-essential libcurl4-openssl-dev libgnutls28-dev libssl-dev
 arvados/doc$ bundle install
 </pre>
 
-To generate the Python SDK documentation, these additional dependencies are needed:
+SDK reference documentation has additional, optional build requirements.
+
+h3. Java SDK documentation
+
+<pre>
+$ sudo apt install gradle
+</pre>
+
+h3. Python SDK documentation
+
+<pre>
+arvados/doc$ sudo apt install python3-venv
+arvados/doc$ python3 -m venv .venv
+arvados/doc$ .venv/bin/pip install pdoc setuptools
+</pre>
+
+Then you must activate the virtualenv (e.g., run @. .venv/bin/activate@) before you run the @bundle exec rake@ commands below.
+
+h3. R SDK documentation
 
 <pre>
-arvados/doc$ sudo apt-get install python3-pip
-arvados/doc$ pip3 install arvados-python-client
-arvados/doc$ pip3 install pdoc3
+$ sudo apt install r-cran-devtools r-cran-roxygen2 r-cran-knitr r-cran-markdown r-cran-xml
 </pre>
 
 h2. Generate HTML pages
@@ -37,6 +55,18 @@ Alternately, to make the documentation browsable on the local filesystem:
 arvados/doc$ bundle exec rake generate baseurl=$PWD/.site
 </pre>
 
+h3. Selecting SDK documentation to build
+
+By default, the build process will try to detect what SDK documentation it can build, build all that, and skip the rest. You can specify exactly what you want to build using the @sdks@ environment variable. This is a list of comma- or space-separated SDKs you wanted to build documentation for. Valid values are @java@, @python@, @r@, @all@, or @none@. @all@ is a shortcut for listing all the valid SDKs. @none@ means do not build documentation for any SDK. For example, to build documentation for the Java and Python SDKs, but skip R:
+
+<pre>
+arvados/doc$ bundle exec rake generate baseurl=$PWD/.site sdks=java,python
+</pre>
+
+Specifying @sdks@ skips the build detection logic. If the Rakefile cannot build the requested SDK documentation, the build will fail.
+
+For backwards compatibility, if you do not specify @sdks@, but the @NO_SDK@ environment variable is set, or the @no-sdk@ file exists, the build will run as if you set @sdks=none@.
+
 h2. Run linkchecker
 
 If you have "Linkchecker":http://wummel.github.io/linkchecker/ installed on
@@ -69,12 +99,6 @@ You can set @baseurl@ (the URL prefix for all internal links), @arvados_cluster_
 arvados/doc$ bundle exec rake generate baseurl=/doc arvados_api_host=xyzzy.arvadosapi.com
 </pre>
 
-Make the docs appear at {workbench_host}/doc by creating a symbolic link in Workbench's @public@ directory, pointing to the generated HTML tree.
-
-<pre>
-arvados/doc$ ln -sn ../../../doc/.site ../apps/workbench/public/doc
-</pre>
-
 h2. Delete generated files
 
 <pre>
index 1390aa0e9c0ca7dd9f3bf14772c037686db8ff3f..f2932284d9715d8fb95471b20e7ca4c240bf5458 100644 (file)
 #
 # and then visit http://localhost:8000 in a browser.
 
+require "uri"
+
 require "rubygems"
 require "colorize"
 
+def can_run?(*command, **options)
+  options = {
+    :in => :close,
+    :out => [File::NULL, "w"],
+  }.merge(options)
+  system(*command, **options)
+end
+
+class JavaSDK
+  def self.build_path
+    "sdk/java-v2"
+  end
+
+  def self.can_build?
+    can_run?("gradle", "--version")
+  end
+end
+
+class PythonSDK
+  def self.build_path
+    "sdk/python/arvados"
+  end
+
+  def self.can_build?
+    can_run?("./pysdk_pdoc.py", "--version")
+  end
+end
+
+class RSDK
+  def self.build_path
+    "sdk/R"
+  end
+
+  def self.can_build?
+    can_run?("R", "--version")
+  end
+end
+
+$build_sdks = begin
+  no_sdk_env = ENV.fetch("NO_SDK", "")
+  sdks_env = ENV.fetch("sdks", "")
+  all_sdks = Hash[[JavaSDK, PythonSDK, RSDK].map { |c| [c.name, c] }]
+
+  if no_sdk_env != "" and sdks_env != ""
+    fail "both NO_SDK and sdks defined in environment"
+  elsif sdks_env != ""
+    # Nothing to do
+  elsif no_sdk_env != "" or File.exist?("no-sdk")
+    sdks_env = "none"
+  end
+
+  if sdks_env == ""
+    all_sdks.each_pair.filter_map do |name, sdk|
+      if sdk.can_build?
+        sdk
+      else
+        puts "Warning: cannot build #{name.gsub(/SDK$/, ' SDK')} documentation, skipping".colorize(:light_red)
+      end
+    end
+  else
+    wanted_sdks = []
+    sdks_env.split(/\s*[,\s]\s*/).each do |key|
+      key = "#{key.capitalize}SDK"
+      if key == "AllSDK"
+        wanted_sdks = all_sdks.values
+      elsif key == "NoneSDK"
+        wanted_sdks.clear
+      elsif sdk = all_sdks[key]
+        wanted_sdks << sdk
+      else
+        fail "cannot build documentation for unknown #{key}"
+      end
+    end
+    wanted_sdks
+  end
+end
+
 module Zenweb
   class Site
     @binary_files = %w[png jpg gif eot svg ttf woff2? ico pdf m4a t?gz xlsx]
   end
 end
 
-task :generate => [ :realclean, 'sdk/python/arvados/index.html', 'sdk/R/arvados/index.html', 'sdk/java-v2/javadoc/index.html' ] do
+task :generate => [ :realclean, 'sdk/python/arvados.html', 'sdk/R/arvados/index.html', 'sdk/java-v2/javadoc/index.html' ] do
   vars = ['baseurl', 'arvados_cluster_uuid', 'arvados_api_host', 'arvados_workbench_host']
   if ! ENV.key?('baseurl') || ENV['baseurl'] == ""
     if !ENV.key?('WORKSPACE') || ENV['WORKSPACE'] == ""
@@ -50,43 +129,34 @@ file ["install/new_cluster_checklist_Azure.xlsx", "install/new_cluster_checklist
   cp(t, t)
 end
 
-file "sdk/python/arvados/index.html" do |t|
-  if ENV['NO_SDK'] || File.exists?("no-sdk")
-    next
-  end
-  `which pdoc`
-  if $? == 0
-    STDERR.puts `pdoc --html -o sdk/python ../sdk/python/arvados/ 2>&1`
-    raise if $? != 0
-  else
-    puts "Warning: pdoc3 not found, Python documentation will not be generated".colorize(:light_red)
-  end
+file "sdk/python/arvados.html" do |t|
+  next unless $build_sdks.include?(PythonSDK)
+  raise unless system("python3", "setup.py", "build",
+                      chdir: "../sdk/python", out: :err)
+  raise unless system("python3", "pysdk_pdoc.py",
+                      out: :err)
 end
 
 file "sdk/R/arvados/index.html" do |t|
-  if ENV['NO_SDK'] || File.exists?("no-sdk")
-    next
-  end
-  `which R`
-  if $? == 0
-    tgt = Dir.pwd
-    Dir.mkdir("sdk/R")
-    Dir.mkdir("sdk/R/arvados")
-    puts("tgt", tgt)
-    cp('css/R.css', 'sdk/R/arvados')
-    docfiles = []
-    Dir.chdir("../sdk/R/") do
-      Dir.entries("man").each do |rd|
-        if rd[-3..-1] == ".Rd"
-          htmlfile = "#{rd[0..-4]}.html"
-          `R CMD Rdconv -t html man/#{rd} > #{tgt}/sdk/R/arvados/#{htmlfile}`
-          docfiles << htmlfile
-        end
+  next unless $build_sdks.include?(RSDK)
+  tgt = Dir.pwd
+  Dir.mkdir("sdk/R")
+  Dir.mkdir("sdk/R/arvados")
+  puts("tgt", tgt)
+  cp('css/R.css', 'sdk/R/arvados')
+  docfiles = []
+  Dir.chdir("../sdk/R/") do
+    Dir.entries("man").each do |rd|
+      if rd[-3..-1] == ".Rd"
+        htmlfile = "#{rd[0..-4]}.html"
+        `R CMD Rdconv -t html man/#{rd} > #{tgt}/sdk/R/arvados/#{htmlfile}`
+        docfiles << htmlfile
       end
     end
-    raise if $? != 0
+  end
+  raise if $? != 0
 
-    File.open("../sdk/R/README.md", "r") do |rd|
+  File.open("../sdk/R/README.md", "r") do |rd|
     File.open("sdk/R/index.html.md", "w") do |fn|
       fn.write(<<-EOF
 ---
@@ -99,11 +169,11 @@ title: "R SDK Overview"
 #{rd.read}
 EOF
               )
-      end
     end
+  end
 
-    File.open("sdk/R/arvados/index.html.textile.liquid", "w") do |fn|
-      fn.write(<<-EOF
+  File.open("sdk/R/arvados/index.html.textile.liquid", "w") do |fn|
+    fn.write(<<-EOF
 ---
 layout: default
 navsection: sdk
@@ -117,53 +187,46 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
 EOF
-              )
-
-      docfiles.sort.each do |d|
-        fn.write("* \"#{d[0..-6]}\":#{d}\n")
-      end
-
+            )
+    docfiles.sort.each do |d|
+      fn.write("* \"#{d[0..-6]}\":#{d}\n")
     end
-  else
-    puts "Warning: R not found, R documentation will not be generated".colorize(:light_red)
   end
 end
 
 file "sdk/java-v2/javadoc/index.html" do |t|
-  if ENV['NO_SDK'] || File.exists?("no-sdk")
-    next
-  end
-  `which java`
-  if $? == 0
-    `which gradle`
-    if $? != 0
-      puts "Warning: gradle not found, java sdk documentation will not be generated".colorize(:light_red)
-    else
-      tgt = Dir.pwd
-      docfiles = []
-      Dir.chdir("../sdk/java-v2") do
-        STDERR.puts `gradle javadoc 2>&1`
-        raise if $? != 0
-        puts `sed -i "s/@import.*dejavu.css.*//g" build/docs/javadoc/stylesheet.css`
-        raise if $? != 0
-      end
-      cp_r("../sdk/java-v2/build/docs/javadoc", "sdk/java-v2")
-      raise if $? != 0
-    end
-  else
-    puts "Warning: java not found, java sdk documentation will not be generated".colorize(:light_red)
+  next unless $build_sdks.include?(JavaSDK)
+  tgt = Dir.pwd
+  docfiles = []
+  Dir.chdir("../sdk/java-v2") do
+    STDERR.puts `gradle javadoc 2>&1`
+    raise if $? != 0
+    puts `sed -i "s/@import.*dejavu.css.*//g" build/docs/javadoc/stylesheet.css`
+    raise if $? != 0
   end
+  cp_r("../sdk/java-v2/build/docs/javadoc", "sdk/java-v2")
+  raise if $? != 0
 end
 
 task :linkchecker => [ :generate ] do
-  Dir.chdir(".site") do
-    `which linkchecker`
-    if $? == 0
-      # we need --check-extern to check relative links, weird but true
-      system "linkchecker index.html --check-extern --ignore-url='!file://'" or exit $?.exitstatus
-    else
-      puts "Warning: linkchecker not found, skipping run".colorize(:light_red)
-    end
+  # we need --check-extern to check relative links, weird but true
+  opts = [
+    "--check-extern",
+    "--ignore-url=!^file://",
+  ]
+  ([JavaSDK, PythonSDK, RSDK] - $build_sdks).map(&:build_path).each do |sdk_path|
+    sdk_url = URI.join(ENV["baseurl"], sdk_path)
+    url_re = Regexp.escape(sdk_url.to_s)
+    opts << "--ignore-url=^#{url_re}[./]"
+  end
+  result = system(
+    "linkchecker", *opts, "index.html",
+    chdir: ".site",
+  )
+  if result.nil?
+    fail "could not run linkchecker command (is it installed?)"
+  elsif !result
+    fail "linkchecker exited #{$?.exitstatus}"
   end
 end
 
@@ -200,6 +263,8 @@ end
 
 task :clean do
   rm_rf "sdk/python/arvados"
+  rm_f "sdk/python/arvados.html"
+  rm_f "sdk/python/index.html"
   rm_rf "sdk/R"
   rm_rf "sdk/java-v2/javadoc"
 end
@@ -210,5 +275,5 @@ load "zenweb-liquid.rb"
 load "zenweb-fix-body.rb"
 
 task :extra_wirings do
-  $website.pages["sdk/python/python.html.textile.liquid"].depends_on("sdk/python/arvados/index.html")
+  $website.pages["sdk/python/python.html.textile.liquid"].depends_on("sdk/python/arvados.html")
 end
index 4f86253018b42865233ede6375f78dc04c379567..053922a24a4890ad4c26f21dd0f34036aaf5aa36 100644 (file)
@@ -4,18 +4,20 @@
 
 # baseurl is the location of the generated site from the browser's
 # perspective (e.g., http://doc.arvados.org or
-# file:///tmp/arvados/doc/.site). To make docs show up inside
-# workbench, use /doc here and add a symlink at
-# apps/workbench/public/doc pointing to ../../../doc/.site
-# You can also set these on the command line:
-# $ rake generate baseurl=/example arvados_api_host=example.com
+# file:///tmp/arvados/doc/.site).  You can also set these on the
+# command line: $ rake generate baseurl=/example
+# arvados_api_host=example.com
 
 baseurl:
 current_version:
 all_versions:
+latest_version:
 arvados_api_host: localhost
 arvados_cluster_uuid: local
 arvados_workbench_host: http://localhost
+google_analytics: "G-EFLSBXJ5SQ"
+matomo_analytics_url: "https://piwik.arvados.org"
+matomo_analytics_siteid: "3"
 
 exclude: ["Rakefile", "tmp", "vendor"]
 
@@ -25,47 +27,45 @@ navbar:
       - user/index.html.textile.liquid
       - user/getting_started/community.html.textile.liquid
     - Walkthough:
-      - user/tutorials/wgs-tutorial.html.textile.liquid
-    - Using Workbench:
       - user/getting_started/workbench.html.textile.liquid
-      - user/tutorials/tutorial-workflow-workbench.html.textile.liquid
-      - user/topics/workbench-migration.html.textile.liquid
+      - user/tutorials/wgs-tutorial.html.textile.liquid
     - Working at the Command Line:
       - user/getting_started/setup-cli.html.textile.liquid
       - user/reference/api-tokens.html.textile.liquid
       - user/getting_started/check-environment.html.textile.liquid
-    - Access an Arvados virtual machine:
-      - user/getting_started/vm-login-with-webshell.html.textile.liquid
-      - user/getting_started/ssh-access-unix.html.textile.liquid
-      - user/getting_started/ssh-access-windows.html.textile.liquid
     - Working with data sets:
+      - user/tutorials/tutorial-projects.html.textile.liquid
       - user/tutorials/tutorial-keep.html.textile.liquid
       - user/tutorials/tutorial-keep-get.html.textile.liquid
+      - user/tutorials/tutorial-keep-collection-lifecycle.html.textile.liquid
+      - user/topics/arv-copy.html.textile.liquid
       - user/tutorials/tutorial-keep-mount-gnu-linux.html.textile.liquid
       - user/tutorials/tutorial-keep-mount-os-x.html.textile.liquid
       - user/tutorials/tutorial-keep-mount-windows.html.textile.liquid
-      - user/tutorials/tutorial-keep-collection-lifecycle.html.textile.liquid
-      - user/topics/arv-copy.html.textile.liquid
       - user/topics/collection-versioning.html.textile.liquid
       - user/topics/storage-classes.html.textile.liquid
     - Data Analysis with Workflows:
-      - user/cwl/arvados-vscode-training.html.md.liquid
-      - user/cwl/rnaseq-cwl-training.html.textile.liquid
+      - user/tutorials/tutorial-workflow-workbench.html.textile.liquid
       - user/cwl/cwl-runner.html.textile.liquid
       - user/cwl/cwl-run-options.html.textile.liquid
-      - user/tutorials/writing-cwl-workflow.html.textile.liquid
+      - user/cwl/crunchstat-summary.html.textile.liquid
+      - user/debugging/container-shell-access.html.textile.liquid
+      - user/cwl/costanalyzer.html.textile.liquid
+      - user/cwl/federated-workflows.html.textile.liquid
+    - Common Workflow Language:
+      - user/cwl/rnaseq-cwl-training.html.textile.liquid
+      - user/cwl/arvados-vscode-training.html.md.liquid
       - user/topics/arv-docker.html.textile.liquid
       - user/cwl/cwl-style.html.textile.liquid
+      - user/tutorials/writing-cwl-workflow.html.textile.liquid
       - user/cwl/cwl-extensions.html.textile.liquid
-      - user/cwl/federated-workflows.html.textile.liquid
       - user/cwl/cwl-versions.html.textile.liquid
-      - user/cwl/crunchstat-summary.html.textile.liquid
-      - user/cwl/costanalyzer.html.textile.liquid
-      - user/debugging/container-shell-access.html.textile.liquid
-    - Working with git repositories:
-      - user/tutorials/add-new-repository.html.textile.liquid
-      - user/tutorials/git-arvados-guide.html.textile.liquid
+    - Access an Arvados virtual machine:
+      - user/getting_started/vm-login-with-webshell.html.textile.liquid
+      - user/getting_started/ssh-access-unix.html.textile.liquid
+      - user/getting_started/ssh-access-windows.html.textile.liquid
     - Reference:
+      - user/topics/workbench-migration.html.textile.liquid
       - user/topics/link-accounts.html.textile.liquid
       - user/reference/cookbook.html.textile.liquid
     - Arvados License:
@@ -81,28 +81,29 @@ navbar:
       - sdk/python/api-client.html.textile.liquid
       - sdk/python/cookbook.html.textile.liquid
       - sdk/python/python.html.textile.liquid
-      - sdk/python/arvados-fuse.html.textile.liquid
       - sdk/python/arvados-cwl-runner.html.textile.liquid
       - sdk/python/events.html.textile.liquid
-    - CLI:
+    - Command line tools (CLI SDK):
       - sdk/cli/install.html.textile.liquid
       - sdk/cli/index.html.textile.liquid
       - sdk/cli/reference.html.textile.liquid
       - sdk/cli/subcommands.html.textile.liquid
-      - sdk/cli/project-management.html.textile.liquid
+    - FUSE Driver:
+      - sdk/fuse/install.html.textile.liquid
+      - sdk/fuse/options.html.textile.liquid
     - Go:
       - sdk/go/index.html.textile.liquid
       - sdk/go/example.html.textile.liquid
+    - Java:
+      - sdk/java-v2/index.html.textile.liquid
+      - sdk/java-v2/example.html.textile.liquid
+      - sdk/java-v2/javadoc.html.textile.liquid
     - R:
       - sdk/R/index.html.md
       - sdk/R/arvados/index.html.textile.liquid
     - Ruby:
       - sdk/ruby/index.html.textile.liquid
       - sdk/ruby/example.html.textile.liquid
-    - Java v2:
-      - sdk/java-v2/index.html.textile.liquid
-      - sdk/java-v2/example.html.textile.liquid
-      - sdk/java-v2/javadoc.html.textile.liquid
   api:
     - Concepts:
       - api/index.html.textile.liquid
@@ -111,19 +112,14 @@ navbar:
       - api/methods.html.textile.liquid
       - api/resources.html.textile.liquid
     - Permission and authentication:
+      - api/methods/users.html.textile.liquid
+      - api/methods/groups.html.textile.liquid
       - api/methods/api_client_authorizations.html.textile.liquid
-      - api/methods/api_clients.html.textile.liquid
+      - api/methods/links.html.textile.liquid
       - api/methods/authorized_keys.html.textile.liquid
-      - api/methods/groups.html.textile.liquid
-      - api/methods/users.html.textile.liquid
+      - api/methods/api_clients.html.textile.liquid
       - api/methods/user_agreements.html.textile.liquid
-    - System resources:
-      - api/methods/keep_services.html.textile.liquid
-      - api/methods/links.html.textile.liquid
-      - api/methods/logs.html.textile.liquid
-      - api/methods/nodes.html.textile.liquid
       - api/methods/virtual_machines.html.textile.liquid
-      - api/methods/keep_disks.html.textile.liquid
     - Data management:
       - api/keep-webdav.html.textile.liquid
       - api/keep-s3.html.textile.liquid
@@ -131,12 +127,12 @@ navbar:
       - api/projects.html.textile.liquid
       - api/properties.html.textile.liquid
       - api/methods/collections.html.textile.liquid
-      - api/methods/repositories.html.textile.liquid
+      - api/methods/logs.html.textile.liquid
+      - api/methods/keep_services.html.textile.liquid
     - Container engine:
       - api/methods/container_requests.html.textile.liquid
       - api/methods/containers.html.textile.liquid
       - api/methods/workflows.html.textile.liquid
-    - Management (admin/system):
       - api/dispatch.html.textile.liquid
     - Jobs engine (legacy):
       - api/crunch-scripts.html.textile.liquid
@@ -144,7 +140,10 @@ navbar:
       - api/methods/job_tasks.html.textile.liquid
       - api/methods/pipeline_instances.html.textile.liquid
       - api/methods/pipeline_templates.html.textile.liquid
-    - Metadata for bioinformatics (deprecated):
+      - api/methods/nodes.html.textile.liquid
+      - api/methods/repositories.html.textile.liquid
+      - api/methods/keep_disks.html.textile.liquid
+    - Metadata for bioinformatics (legacy):
       - api/methods/humans.html.textile.liquid
       - api/methods/specimens.html.textile.liquid
       - api/methods/traits.html.textile.liquid
@@ -184,6 +183,7 @@ navbar:
       - admin/logging.html.textile.liquid
       - admin/metrics.html.textile.liquid
       - admin/health-checks.html.textile.liquid
+      - admin/inspect.html.textile.liquid
       - admin/diagnostics.html.textile.liquid
       - admin/management-token.html.textile.liquid
       - admin/user-activity.html.textile.liquid
@@ -198,6 +198,7 @@ navbar:
       - admin/storage-classes.html.textile.liquid
       - admin/keep-recovering-data.html.textile.liquid
       - admin/keep-measuring-deduplication.html.textile.liquid
+      - admin/keep-faster-gc-s3.html.textile.liquid
     - Cloud:
       - admin/spot-instances.html.textile.liquid
       - admin/cloudtest.html.textile.liquid
@@ -240,7 +241,6 @@ navbar:
     - User interface:
       - install/setup-login.html.textile.liquid
       - install/install-ws.html.textile.liquid
-      - install/install-workbench-app.html.textile.liquid
       - install/install-workbench2-app.html.textile.liquid
       - install/workbench.html.textile.liquid
 #      - install/install-composer.html.textile.liquid
index f6f42d25502f259c1e4c2a0ebc3c630c095a9e4e..1c62dbb239f5b0ed81f52e622f1d33d8b8f2ea00 100644 (file)
@@ -20,6 +20,7 @@ table(table table-bordered table-condensed).
 h3. CUDA GPU support
 
 table(table table-bordered table-condensed).
+|_. Key|_. Type|_. Description|_. Notes|
 |device_count|int|Number of GPUs to request.|Count greater than 0 enables CUDA GPU support.|
 |driver_version|string|Minimum CUDA driver version, in "X.Y" format.|Required when device_count > 0|
 |hardware_capability|string|Minimum CUDA hardware capability, in "X.Y" format.|Required when device_count > 0|
index 724ed1416ee8f81d62d3c03a8bc908f7f3fc4b3b..461debd4928a949a8cc014a8e9f7fa297a738f4b 100644 (file)
@@ -9,7 +9,7 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 This is a package-based installation method, however the installation script is currently distributed in source form via @git@. We recommend checking out the git tree on your local workstation, not directly on the target(s) where you want to install and run Arvados.
 
 <notextile>
-<pre><code>git clone https://github.com/arvados/arvados.git
+<pre><code class="userinput">git clone https://github.com/arvados/arvados.git
 cd arvados
 git checkout {{ branchname }}
 cd tools/salt-install
@@ -31,7 +31,7 @@ h3. Using Terraform (AWS specific)
 If you are going to use Terraform to set up the infrastructure on AWS, you first need to install the "Terraform CLI":https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli and the "AWS CLI":https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html tool.  Then you can initialize the installer.
 
 <notextile>
-<pre><code>CLUSTER=xarv1
+<pre><code class="userinput">CLUSTER=xarv1
 ./installer.sh initialize ~/setup-arvados-${CLUSTER} {{local_params_src}} {{config_examples_src}} {{terraform_src}}
 cd ~/setup-arvados-${CLUSTER}
 </code></pre>
@@ -40,7 +40,7 @@ cd ~/setup-arvados-${CLUSTER}
 h3. Without Terraform
 
 <notextile>
-<pre><code>CLUSTER=xarv1
+<pre><code class="userinput">CLUSTER=xarv1
 ./installer.sh initialize ~/setup-arvados-${CLUSTER} {{local_params_src}} {{config_examples_src}}
 cd ~/setup-arvados-${CLUSTER}
 </code></pre>
diff --git a/doc/_includes/_google_analytics.liquid b/doc/_includes/_google_analytics.liquid
new file mode 100644 (file)
index 0000000..05de01a
--- /dev/null
@@ -0,0 +1,21 @@
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+<script>
+  window['ga-disable-{{ site.google_analytics }}'] =
+    window.doNotTrack === '1' ||
+    navigator.doNotTrack === '1' ||
+    navigator.doNotTrack === 'yes' ||
+    navigator.msDoNotTrack === '1';
+  window.dataLayer = window.dataLayer || [];
+  function gtag() {
+    window.dataLayer.push(arguments);
+  }
+  gtag('js', new Date());
+
+  gtag('config', '{{ site.google_analytics }}');
+</script>
+<script defer src="https://www.googletagmanager.com/gtag/js?id={{ site.google_analytics }}"></script>
\ No newline at end of file
diff --git a/doc/_includes/_hpc_max_gateway_tunnels.liquid b/doc/_includes/_hpc_max_gateway_tunnels.liquid
new file mode 100644 (file)
index 0000000..ba8769c
--- /dev/null
@@ -0,0 +1,18 @@
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+h3(#MaxGatewayTunnels). API.MaxGatewayTunnels
+
+Each Arvados container that runs on your HPC cluster will bring up a long-lived connection to the Arvados controller and keep it open for the entire duration of the container. This connection is used to access real-time container logs from Workbench, and to enable the "container shell":{{site.baseurl}}/install/container-shell-access.html feature.
+
+Set the @MaxGatewayTunnels@ config entry high enough to accommodate the maximum number of containers you expect to run concurrently on your HPC cluster, plus incoming container shell sessions.
+
+<notextile>
+<pre>    API:
+      MaxGatewayTunnels: 2000</pre>
+</notextile>
+
+Also, configure Nginx (and any other HTTP proxies or load balancers running between the HPC and Arvados controller) to allow the expected number of connections, i.e., @MaxConcurrentRequests + MaxQueuedRequests + MaxGatewayTunnels@.
index 279356a3459e0befc4ee3d352b19847c46aee5e7..a886a62dbdd4141ffeaa9a2ba0c58b6177b6175e 100644 (file)
@@ -48,7 +48,7 @@ h4. Debian/Ubuntu
 </code></pre>
 </notextile>
 
-h4. CentOS
+h4. Alma/CentOS/Red Hat/Rocky
 
 <notextile>
 <pre><code>cp {{ca_cert_name}} /etc/pki/ca-trust/source/anchors/
index a6f2515abbd52225e659967ef1c9a589cee1c5af..9c3e54c7c3c73ab6e2a20508749b4aea95e5d7ea 100644 (file)
@@ -15,7 +15,7 @@ If you are using a distribution in the compute nodes that ships with cgroups v2
 
 After making changes, reboot the system to make these changes effective.
 
-h3. Red Hat and CentOS
+h3. Alma/CentOS/Red Hat/Rocky
 
 <notextile>
 <pre><code>~$ <span class="userinput">sudo grubby --update-kernel=ALL --args='cgroup_enable=memory swapaccount=1 systemd.unified_cgroup_hierarchy=0'</span>
index bfac32d8343b768c17c260431005f73a3cb5bd7e..595b0a8b71667d17987d2aa5fa7b1ece6332a5b4 100644 (file)
@@ -1,4 +1,8 @@
 {% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+
 packages_to_install should be a list
 fallback on arvados_component if not defined
 {% endcomment %}
@@ -9,10 +13,10 @@ fallback on arvados_component if not defined
 
 h2(#install-packages). Install {{packages_to_install | join: " and " }}
 
-h3. Red Hat and Centos
+h3. Alma/CentOS/Red Hat/Rocky
 
 <notextile>
-<pre><code># <span class="userinput">yum install {{packages_to_install | join: " "}}</span>
+<pre><code># <span class="userinput">dnf install {{packages_to_install | join: " "}}</span>
 </code></pre>
 </notextile>
 
index 549e1446348047d7499ad61b815fad7009bae5af..5d5bc9e9d7c3b30ac51fb0b8d434d08a519bef66 100644 (file)
@@ -4,25 +4,27 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-Ruby 2.6 or newer is required.
+Ruby 2.7 or newer is required.
 
 * "Option 1: Install from packages":#packages
 * "Option 2: Install with RVM":#rvm
-* "Option 3: Install from source":#fromsource
 
 h2(#packages). Option 1: Install from packages
 
-{% include 'notebox_begin' %}
-Future versions of Arvados may require a newer version of Ruby than is packaged with your OS.  Using OS packages simplifies initial install, but may complicate upgrades that rely on a newer Ruby.  If this is a concern, we recommend using "RVM":#rvm.
-{% include 'notebox_end' %}
+h3. Alma/CentOS/Red Hat/Rocky
+
+Version 7 of these distributions does not provide a new enough Ruby version.  Use "RVM":#rvm to install Ruby 2.7 or newer.
 
-h3. Centos 7
+Version 8 of these distributions provides Ruby 2.7. You can install it by running:
 
-The Ruby version shipped with Centos 7 is too old.  Use "RVM":#rvm to install a newer version of Ruby (we recommend installing version 2.7 or newer).
+<notextile>
+<pre><code># <span class="userinput">dnf module enable ruby:2.7</span>
+# <span class="userinput">dnf install --enablerepo=devel ruby ruby-devel</span></code></pre>
+</notextile>
 
 h3. Debian and Ubuntu
 
-Debian 10 (buster) and Ubuntu 18.04 (bionic) ship with Ruby 2.5, which is too old for Arvados. Use "RVM":#rvm to install a newer version of Ruby (we recommend installing version 2.7 or newer).
+Debian 10 (buster) and Ubuntu 18.04 (bionic) ship with Ruby 2.5, which is too old for Arvados. Use "RVM":#rvm to install Ruby 2.7 or newer.
 
 Debian 11 (bullseye) and Ubuntu 20.04 (focal) and later ship with Ruby 2.7 or newer, which is sufficient for Arvados.
 
@@ -32,28 +34,42 @@ Debian 11 (bullseye) and Ubuntu 20.04 (focal) and later ship with Ruby 2.7 or ne
 
 h2(#rvm). Option 2: Install with RVM
 
+{% include 'notebox_begin_warning' %}
+We do not recommend using RVM unless the Ruby version provided by your OS distribution is older than 2.7.
+{% include 'notebox_end' %}
+
 h3. Install gpg and curl
 
-h4. Centos 7
+h4. CentOS/Red Hat 7
+
+<pre>
+yum install gpg curl which findutils procps
+</pre>
+
+{% comment %}
+To build ruby 3.2.2 on CentOS 7, add: "yum --enablerepo=powertools install libyaml-devel"
+{% endcomment %}
+
+h4. Alma/CentOS/Red Hat/Rocky 8+
 
 <pre>
-yum install gpg curl which
+dnf install gpg curl which findutils procps
 </pre>
 
 h4. Debian and Ubuntu
 
 <pre>
-apt-get --no-install-recommends install gpg curl
+apt-get --no-install-recommends install gpg curl ca-certificates dirmngr procps
 </pre>
 
 h3. Install RVM, Ruby and Bundler
 
 <notextile>
-<pre><code><span class="userinput">gpg --keyserver pgp.mit.edu --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
-\curl -sSL https://get.rvm.io | bash -s stable --ruby=2.7
+<pre><code><span class="userinput">gpg --keyserver pgp.mit.edu --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
+\curl -sSL https://get.rvm.io | bash -s stable --ruby=2.7.7
 </span></code></pre></notextile>
 
-This command installs the latest Ruby 2.7.x release, as well as the @gem@ and @bundle@ commands.
+This command installs the Ruby 2.7.7 release, as well as the @gem@ and @bundle@ commands.
 
 To use Ruby installed from RVM, load it in an open shell like this:
 
@@ -66,41 +82,3 @@ Alternately you can use @rvm-exec@ (the first parameter is the ruby version to u
 <notextile>
 <pre><code><span class="userinput">rvm-exec default ruby -v
 </span></code></pre></notextile>
-
-h2(#fromsource). Option 3: Install from source
-
-Install prerequisites for Debian 10, Ubuntu 18.04 and Ubuntu 20.04:
-
-<notextile>
-<pre><code><span class="userinput">sudo apt-get install \
-    bison build-essential gettext libcurl4 \
-    libcurl4-openssl-dev libpcre3-dev libreadline-dev \
-    libssl-dev libxslt1.1 zlib1g-dev
-</span></code></pre></notextile>
-
-Install prerequisites for CentOS 7:
-
-<notextile>
-<pre><code><span class="userinput">sudo yum install \
-    libyaml-devel glibc-headers autoconf gcc-c++ glibc-devel \
-    patch readline-devel zlib-devel libffi-devel openssl-devel \
-    make automake libtool bison sqlite-devel tar
-</span></code></pre></notextile>
-
-Build and install Ruby:
-
-<notextile>
-<pre><code><span class="userinput">mkdir -p ~/src
-cd ~/src
-curl -f https://cache.ruby-lang.org/pub/ruby/2.7/ruby-2.7.5.tar.gz | tar xz
-cd ruby-2.7.5
-./configure --disable-install-rdoc
-make
-sudo make install
-
-# Make sure the post install script can find the gem and ruby executables
-sudo ln -s /usr/local/bin/gem /usr/bin/gem
-sudo ln -s /usr/local/bin/ruby /usr/bin/ruby
-# Install bundler
-sudo -i gem install bundler</span>
-</code></pre></notextile>
diff --git a/doc/_includes/_matomo_analytics.liquid b/doc/_includes/_matomo_analytics.liquid
new file mode 100644 (file)
index 0000000..5b65cf5
--- /dev/null
@@ -0,0 +1,16 @@
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+<!-- Matomo analytics (used to be called "Piwik") -->
+<script type="text/javascript">
+  var _paq = _paq || [];
+  _paq.push(["trackPageView"]);
+  _paq.push(["enableLinkTracking"]);
+  _paq.push(["setTrackerUrl", "{{ site.matomo_analytics_url }}/piwik.php"]);
+  _paq.push(["setSiteId", "{{ site.matomo_analytics_siteid }}"]);
+</script>
+<script defer src="{{ site.matomo_analytics_url }}/piwik.js"></script>
+<!-- End Matomo code -->
index 7063eb28fbf04924bd0f3b989757129ecd4ac298..2d8bbfc806a28509e38e8310a4587916a2c3c7da 100644 (file)
@@ -14,20 +14,23 @@ xarv1.example.com
 *.collections.xarv1.example.com
 </pre>
 
-(Replacing xarv1 with your own ${CLUSTER}.${DOMAIN})
+(Replacing @xarv1.example.com@ with your own @${DOMAIN}@)
 
 Copy your certificates to the directory specified with the variable @CUSTOM_CERTS_DIR@ in the remote directory where you copied the @provision.sh@ script. The provision script will find the certificates there.
 
 The script expects cert/key files with these basenames (matching the role except for <i>keepweb</i>, which is split in both <i>download / collections</i>):
 
+# @balancer@         -- Optional on multi-node installations
+# @collections@      -- Part of keepweb, must be a wildcard for @*.collections.${DOMAIN}@
 # @controller@
-# @websocket@        -- note: corresponds to default domain @ws.${CLUSTER}.${DOMAIN}@
-# @keepproxy@        -- note: corresponds to default domain @keep.${CLUSTER}.${DOMAIN}@
 # @download@         -- Part of keepweb
-# @collections@      -- Part of keepweb, must be a wildcard for @*.collections.${CLUSTER}.${DOMAIN}@
+# @grafana@          -- Service available by default on multi-node installations
+# @keepproxy@        -- Corresponds to default domain @keep.${DOMAIN}@
+# @prometheus@       -- Service available by default on multi-node installations
+# @webshell@
+# @websocket@        -- Corresponds to default domain @ws.${DOMAIN}@
 # @workbench@
 # @workbench2@
-# @webshell@
 
 For example, for the @keepproxy@ service the script will expect to find this certificate:
 
@@ -42,7 +45,7 @@ Make sure that all the FQDNs that you will use for the public-facing application
 Note: because the installer currently looks for a different certificate file for each service, if you use a single certificate, we recommend creating a symlink for each certificate and key file to the primary certificate and key, e.g.
 
 <notextile>
-<pre><code>ln -s xarv1.crt ${CUSTOM_CERTS_DIR}/controller.crt
+<pre><code class="userinput">ln -s xarv1.crt ${CUSTOM_CERTS_DIR}/controller.crt
 ln -s xarv1.key ${CUSTOM_CERTS_DIR}/controller.key
 ln -s xarv1.crt ${CUSTOM_CERTS_DIR}/keepproxy.crt
 ln -s xarv1.key ${CUSTOM_CERTS_DIR}/keepproxy.key
index de0da6a7673e81612456822cc74ec724536b0934..f2c4fe55c144309ab89122683c8eea97a1d30fce 100644 (file)
@@ -9,19 +9,20 @@ You may now proceed to "adding your key to the Arvados Workbench.":#workbench
 
 h1(#workbench). Adding your key to Arvados Workbench
 
-h3. From the Workbench dashboard
+In the Workbench top navigation menu, click on the dropdown menu icon <i class="fa fa-lg fa-user"></i> to access the Account Management menu. Then, click on the menu item *Ssh keys* to go to the *SSH keys* page. Click on the <span class="btn btn-primary">+ ADD NEW SSH KEY</span> button in the upper-right on that page. You will see a popup as shown in this screenshot:
 
-In the Workbench top navigation menu, click on the dropdown menu icon <span class="fa fa-lg fa-user"></span> <span class="caret"></span> to access the user settings menu and click on the menu item *SSH keys* to go to the *SSH keys* page. Click on the <span class="btn btn-primary">*+* Add new SSH key</span> button in this page. This will open a popup as shown in this screenshot:
+!{width: 100%;}{{ site.baseurl }}/images/ssh-adding-public-key.png!
 
-!{{ site.baseurl }}/images/ssh-adding-public-key.png!
-Paste your public key into the text area labeled *Public Key*, and click on the <span class="btn btn-primary">Submit</span> button. You are now ready to "log into an Arvados VM":#login.
+Paste your _public_ key into the text area labeled *Public Key*, and click on the <span class="btn btn-sm btn-primary">ADD NEW SSH KEY</span> button in lower-right. You are now ready to "log into an Arvados VM":#login.
 
 h1(#login). Using SSH to log into an Arvados VM
 
-To see a list of virtual machines that you have access to, click on the dropdown menu icon <span class="fa fa-lg fa-user"></span> <span class="caret"></span> in the upper right corner of the top navigation menu to access the user settings menu, then click on the menu item *Virtual machines* to go to the Virtual machines page.
+To see a list of virtual machines that you have access to, click on the dropdown menu icon <i class="fa fa-lg fa-user"></i> in the upper right corner of the top navigation menu to access the Account Management menu. Then, click on the menu item *Virtual Machines*.
 
-This page lists the virtual machines you can access. The *Host name* column lists the name of each available VM.  The *Login name* column lists your login name on that VM.  The *Command line* column provides a sample @ssh@ command line.
+You will then see a page that lists the virtual machines you can access. The *Host name* column lists the name of each available VM.  The *Login name* column lists your login name on that VM.  The *Command line* column provides a sample @ssh@ command.
+
+!{width: 100%;}{{ site.baseurl }}/images/vm-access-with-webshell.png!
 
 At the bottom of the page there may be additional instructions for connecting your specific Arvados instance.  If so, follow your site-specific instructions.  If there are no site-specific instructions, you can probably connect directly with @ssh@.
 
-The following are generic instructions.  In the examples the login will be *_you_* and the hostname will be *_shell.ClusterID.example.com_* and .  Replace these with your login name and hostname as appropriate.
+The following are generic instructions.  In these examples, the login name will be *_you_* and the host domain will be *_ClusterID.example.com_*.  Replace these with your login name and hostname as appropriate.
index b4d6eff6164e29a9c043f57ae7efce3fb4f29360..19513bd16a0e229e61787464778d80454e754044 100644 (file)
@@ -38,3 +38,36 @@ To supply your own certificates, change the configuration like this:
 {% include 'multi_host_install_custom_certificates' %}
 
 All certificate files will be used by nginx. You may need to include intermediate certificates in your certificate files. See "the nginx documentation":http://nginx.org/en/docs/http/configuring_https_servers.html#chains for more details.
+
+h4(#secure-tls-keys). Securing your TLS certificate keys (AWS specific) (optional)
+
+When using @SSL_MODE=bring-your-own@, you can keep your TLS certificate keys encrypted on the server nodes. This reduces the risk of certificate leaks from node disk volumes snapshots or backups.
+
+This feature is currently implemented in AWS by providing the certificate keys’ password via Amazon’s "Secrets Manager":https://aws.amazon.com/es/secrets-manager/ service, and installing appropriate services on the nodes that provide this password to nginx via a file that only lives in system RAM.
+
+If you use the installer's Terraform code, the secret and related permission cloud resources are created automatically, and you can customize the secret's name by editing @terraform/services/terraform.tfvars@ and setting its suffix in @ssl_password_secret_name_suffix@.
+
+In @local.params@ you need to set @SSL_KEY_ENCRYPTED@ to @yes@ and change the default values for @SSL_KEY_AWS_SECRET_NAME@ and @SSL_KEY_AWS_REGION@ if necessary.
+
+Then, if your certificate key file is not yet encrypted, you can generated an encrypted version of it by running the @openssl@ command as follows:
+
+<notextile>
+<pre><code>openssl rsa -aes256 -in your.key -out your.encrypted.key
+</code></pre>
+</notextile>
+(this will ask you to type the encryption password)
+
+This encrypted key file will be the one needed to be copied to the @${CUSTOM_CERTS_DIR}@ directory, instead of the plain key file.
+
+In order to allow the appropriate nodes decrypt the key file, you should set the password on Amazon Secrets Manager. There're a couple way this can be done:
+
+# Through AWS web interface may be the easiest, just make sure to set it as "plain text" instead of JSON.
+# By using the AWS CLI tools, for example:
+<notextile>
+<pre><code>aws secretsmanager put-secret-value --secret-id pkey-pwd --secret-string "p455w0rd" --region us-east-1
+</code></pre>
+</notextile>Where @pkey-pwd@ should match with what's set in @SSL_KEY_AWS_SECRET_NAME@ and @us-east-1@ with what's set in @SSL_KEY_AWS_REGION@.
+
+Take into account that the AWS secret should be set before running @installer.sh deploy@ to avoid any failures when trying to start the @nginx@ servers.
+
+If you ever need to change the encryption password on a running cluster, you should first change the secret's value on AWS, and only then copy the newly encrypted key file to @${CUSTOM_CERTS_DIR}@ and re-run the deploy command.
\ No newline at end of file
index 08a20750c7214c119a4c1b3b6648ace5b6fa4d88..a6829093551d9886cd9ed1da59ff6b13c0c0268d 100644 (file)
@@ -6,7 +6,11 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 
 table(table table-bordered table-condensed).
 |_. *Supported Linux Distributions*|
+|AlmaLinux 8|
+|CentOS 8|
 |CentOS 7|
+|Red Hat Enterprise Linux 8|
+|Rocky Linux 8|
 |Debian 11 ("bullseye")|
 |Debian 10 ("buster")|
 |Ubuntu 20.04 ("focal")|
diff --git a/doc/_includes/_terraform_datastorage_tfvars.liquid b/doc/_includes/_terraform_datastorage_tfvars.liquid
new file mode 120000 (symlink)
index 0000000..bcb8f5a
--- /dev/null
@@ -0,0 +1 @@
+../../tools/salt-install/terraform/aws/data-storage/terraform.tfvars
\ No newline at end of file
diff --git a/doc/_includes/_terraform_services_tfvars.liquid b/doc/_includes/_terraform_services_tfvars.liquid
new file mode 120000 (symlink)
index 0000000..ff53a85
--- /dev/null
@@ -0,0 +1 @@
+../../tools/salt-install/terraform/aws/services/terraform.tfvars
\ No newline at end of file
diff --git a/doc/_includes/_terraform_vpc_tfvars.liquid b/doc/_includes/_terraform_vpc_tfvars.liquid
new file mode 120000 (symlink)
index 0000000..96d67c3
--- /dev/null
@@ -0,0 +1 @@
+../../tools/salt-install/terraform/aws/vpc/terraform.tfvars
\ No newline at end of file
index d4d05078f6ce86f8ac564b11049ce282539b16f2..3499b5930770aa8cf2a8b754a215055d6be9ba59 100644 (file)
@@ -5,5 +5,5 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
 {% include 'notebox_begin' %}
-This tutorial assumes that you have access to the "Arvados command line tools":{{ site.baseurl }}/user/getting_started/setup-cli.html and have set the "API token":{{site.baseurl}}/user/reference/api-tokens.html and confirmed a "working environment.":{{site.baseurl}}/user/getting_started/check-environment.html .
+This tutorial assumes that you have access to "Arvados command line tools":{{ site.baseurl }}/user/getting_started/setup-cli.html, configured your "API token":{{site.baseurl}}/user/reference/api-tokens.html, and confirmed a "working environment":{{site.baseurl}}/user/getting_started/check-environment.html.
 {% include 'notebox_end' %}
index f07f33054493306690343ff6a3417a4f749bbbcb..2144695d1a2b004a8b7735a07b6cfa5efa030d8f 100644 (file)
@@ -10,8 +10,11 @@ SPDX-License-Identifier: CC-BY-SA-3.0
     <meta charset="utf-8">
     <title>{% unless page.title == "Arvados | Documentation" %} Arvados {% if page.navmenu %}| {{ page.navmenu }} {% endif %} | {% endunless %}{{ page.title }}</title>
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <meta name="description" content="">
-    <meta name="author" content="">
+    <meta name="description" content="Arvados documentation site">
+    <meta name="author" content="Arvados authors">
+    {% if site.current_version != site.latest_version %}
+    <meta name="robots" content="noindex">
+    {% endif %}
     <link rel="icon" href="{{ site.baseurl }}/images/favicon.ico" type="image/x-icon">
     <link rel="shortcut icon" href="{{ site.baseurl }}/images/favicon.ico" type="image/x-icon">
     <link href="{{ site.baseurl }}/css/bootstrap.css" rel="stylesheet">
@@ -27,15 +30,9 @@ SPDX-License-Identifier: CC-BY-SA-3.0
     <script src="{{ site.baseurl }}/js/bootstrap.min.js"></script>
     <script src="https://hypothes.is/embed.js" async></script>
 
-    <!-- Global site tag (gtag.js) - Google Analytics -->
-    <script async src="https://www.googletagmanager.com/gtag/js?id=G-EFLSBXJ5SQ"></script>
-    <script>
-      window.dataLayer = window.dataLayer || [];
-      function gtag(){dataLayer.push(arguments);}
-      gtag('js', new Date());
+    {% include 'matomo_analytics' %}
+    {% include 'google_analytics' %}
 
-      gtag('config', 'G-EFLSBXJ5SQ');
-    </script>
   </head>
   <body class="nopad">
     {% include 'navbar_top' %}
index 500e0d8c8c13aea9a73a9c2a00e9a7e87b381002..3cf6e79722a4ae03b9f55b4b6fd8fd891fc34c03 100644 (file)
@@ -36,9 +36,8 @@ table(table table-bordered table-condensed).
 |keepproxy      |yes                    |yes|no ^2^|InternalURLs only used by reverse proxy (e.g. Nginx)|
 |keepstore      |no                     |yes|yes   |All clients connect to InternalURLs|
 |keep-balance   |no                     |yes|no ^3^|InternalURLs only used to expose Prometheus metrics|
-|keep-web       |yes                    |yes|no ^2^|InternalURLs only used by reverse proxy (e.g. Nginx)|
+|keep-web       |yes                    |yes|yes ^5^|InternalURLs used by reverse proxy and container log API|
 |websocket      |yes                    |yes|no ^2^|InternalURLs only used by reverse proxy (e.g. Nginx)|
-|workbench1     |yes                    |no|no     ||
 |workbench2     |yes                    |no|no     ||
 </div>
 
@@ -46,6 +45,7 @@ table(table table-bordered table-condensed).
 ^2^ If the reverse proxy (e.g. Nginx) does not run on the same host as the Arvados service it fronts, the @InternalURLs@ will need to be reachable from the host that runs the reverse proxy.
 ^3^ If the Prometheus metrics are not collected from the same machine that runs the service, the @InternalURLs@ will need to be reachable from the host that collects the metrics.
 ^4^ If dispatching containers to HPC (Slurm/LSF) and there are multiple @Controller@ services, they must be able to connect to one another using their InternalURLs, otherwise the "tunnel connections":{{site.baseurl}}/architecture/hpc.html enabling "container shell access":{{site.baseurl}}/install/container-shell-access.html will not work.
+^5^ All URLs in @Services.WebDAV.InternalURLs@ must be reachable by all Controller services. Alternatively, each entry in @Services.Controller.InternalURLs@ must have a corresponding entry in @Services.WebDAV.InternalURLs@ with the same hostname.
 
 When @InternalURLs@ do not need to be reachable from other nodes, it is most secure to use loopback addresses as @InternalURLs@, e.g. @http://127.0.0.1:9005@.
 
@@ -148,38 +148,6 @@ server {
 
 If a client connects to the @Keepproxy@ service, it will talk to Nginx which will reverse proxy the traffic to the @Keepproxy@ service.
 
-h3. Workbench
-
-Consider this section for the @Workbench@ service:
-
-{% codeblock as yaml %}
-  Workbench1:
-    ExternalURL: "https://workbench.ClusterID.example.com"
-{% endcodeblock %}
-
-The @ExternalURL@ advertised is @https://workbench.ClusterID.example.com@. There is no value for @InternalURLs@ because Workbench1 is a Rails application served by Passenger. The only client connecting to the Passenger process is the reverse proxy (e.g. Nginx), and the listening host/post is configured in its configuration:
-
-<notextile><pre><code>
-server {
-  listen       443 ssl;
-  server_name  workbench.ClusterID.example.com;
-
-  ssl_certificate     /YOUR/PATH/TO/cert.pem;
-  ssl_certificate_key /YOUR/PATH/TO/cert.key;
-
-  root /var/www/arvados-workbench/current/public;
-  index  index.html;
-
-  passenger_enabled on;
-  # If you're using RVM, uncomment the line below.
-  #passenger_ruby /usr/local/rvm/wrappers/default/ruby;
-
-  # `client_max_body_size` should match the corresponding setting in
-  # the API.MaxRequestSize and Controller's server's Nginx configuration.
-  client_max_body_size 128m;
-}
-</code></pre></notextile>
-
 h3. API server
 
 Consider this section for the @RailsAPI@ service:
index ec6a9bf9d5511340ba58a9c4f9db8cbc045fad0d..f78533fddd2d90f9e79f684fbf2bc4ee4f8ce814 100644 (file)
@@ -45,6 +45,22 @@ The diagnostics output indicates whether its client connection is categorized by
 ERROR     60: checking internal/external client detection (11 ms): expecting internal=true external=false, but found internal=false external=true
 </pre></notextile>
 
+h2(#container-options). Container-running options
+
+By default, the @diagnostics@ command builds a custom Docker image containing a copy of its own binary, and uses that image to run diagnostic checks from inside an Arvados container. This can help detect problems like lack of network connectivity between containers and Arvados cluster services.
+
+The default approach works well if the client host (i.e., the host where you invoke @arvados-client diagnostics@) meets certain conditions:
+* Docker is installed and working (so the diagnostics command can run @docker build@ and @docker save@).
+* Its hardware and kernel are similar to the cluster's compute instances (so the @arvados-client@ binary and the custom-built Docker image are compatible with the compute instances).
+* Network bandwidth supports uploading the Docker image (about 100 megabytes) in less than a minute.
+
+The following options provide flexibility in case the default approach is not suitable.
+* @-priority=0@ skips the container-running part of the diagnostics suite.
+* @-docker-image="hello-world"@ uses a tiny "hello world" image that is already embedded in the @arvados-client@ binary. This works even if the client host does not have any docker tools installed, and it minimizes the data transferred during the diagnostics suite. It provides less test coverage than the default option, but it will at least check that it is possible to run a container on the cluster.
+* @-docker-image=X@ (where @X@ is a Docker image name or a portable data hash) uses a Docker image that has already been uploaded to your Arvados cluster using @arv keep docker@. In this case the diagnostics tool will run a container with the command @echo {timestamp}@.
+* @-docker-image-from=NAME@ builds a custom Docker image on the fly as described above, but using the specified image as a base instead of the default @debian:slim-stable@ image. Note that the build recipe runs commands like @apt-get install [...] libfuse2 ca-certificates@ so only Debian-based base images are supported. For more flexibility, use one of the above @-docker-image=...@ options.
+* @-timeout=2m@ extends the time limit for each HTTP request made by the diagnostics suite, including the process of uploading a custom-built Docker image, to 2 minutes (the default HTTP request timeout is 10 seconds, and the default upload time limit is either the HTTP timeout or 1 minute, whichever is longer).
+
 h2. Example output
 
 <notextile><pre>
@@ -75,7 +91,6 @@ INFO     123: downloading from webdav (https://download.zzzzz.arvadosapi.com/c=d
 INFO     124: downloading from webdav (https://a15a27cbc1c7d2d4a0d9e02529aaec7e-128.collections.zzzzz.arvadosapi.com/sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412.tar)
 INFO     125: downloading from webdav (https://download.zzzzz.arvadosapi.com/c=zzzzz-4zz18-twitqma8mbvwydy/_/sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412.tar)
 INFO     130: getting list of virtual machines
-INFO     140: getting workbench1 webshell page
 INFO     150: connecting to webshell service
 INFO     160: running a container
 INFO      ... container request submitted, waiting up to 10m for container to run
diff --git a/doc/admin/inspect.html.textile.liquid b/doc/admin/inspect.html.textile.liquid
new file mode 100644 (file)
index 0000000..fff94cb
--- /dev/null
@@ -0,0 +1,68 @@
+---
+layout: default
+navsection: admin
+title: Inspecting active requests
+...
+
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+Most Arvados services publish a snapshot of HTTP requests currently being serviced at @/_inspect/requests@. This can be useful for troubleshooting slow requests and understanding high server load conditions.
+
+To access snapshots, services must be configured with a "management token":management-token.html. When accessing this endpoint, prefix the management token with @"Bearer "@ and supply it in the @Authorization@ request header.
+
+In an interactive setting, use the @jq@ tool to format the JSON response.
+
+<notextile><pre><code>curl -sfH "Authorization: Bearer <span class="userinput">your_management_token_goes_here</span>" "https://<span class="userinput">0.0.0.0:25107</span>/_inspect/requests" | jq .
+</code></pre></notextile>
+
+table(table table-bordered table-condensed table-hover){width:40em}.
+|_. Component|_. Provides @/_inspect/requests@ endpoint|
+|arvados-api-server||
+|arvados-controller|✓|
+|arvados-dispatch-cloud|✓|
+|arvados-dispatch-lsf|✓|
+|arvados-git-httpd||
+|arvados-ws|✓|
+|composer||
+|keepproxy|✓|
+|keepstore|✓|
+|keep-balance|✓|
+|keep-web|✓|
+|workbench2||
+
+h2. Report fields
+
+Most fields are self explanatory.
+
+The @Host@ field reports the virtual host specified in the incoming HTTP request.
+
+The @RemoteAddr@ field reports the source of the incoming TCP connection, which is typically a local address associated with the Nginx proxy service.
+
+The @Elapsed@ field reports the number of seconds since the incoming HTTP request headers were received.
+
+h2. Example response
+
+<pre>
+[
+  {
+    "RequestID": "req-1vzzj6nwrki0rd2hj08a",
+    "Method": "GET",
+    "Host": "tordo.arvadosapi.com",
+    "URL": "/arvados/v1/groups?order=name+asc&filters=[[%22owner_uuid%22,%22%3D%22,%22zzzzz-tpzed-aaaaaaaaaaaaaaa%22],[%22group_class%22,%22in%22,[%22project%22,%22filter%22]]]",
+    "RemoteAddr": "127.0.0.1:55822",
+    "Elapsed": 0.006363228
+  },
+  {
+    "RequestID": "req-1wrof2b2wlj5s1rao4u3",
+    "Method": "GET",
+    "Host": "tordo.arvadosapi.com",
+    "URL": "/arvados/v1/users/current",
+    "RemoteAddr": "127.0.0.1:55814",
+    "Elapsed": 0.04796585
+  }
+]
+</pre>
index 2785930de82fc30eb7f07ea359e10f0822181631..4d18307cf75a65cd9c78af775c7cd43cbf2b1d92 100644 (file)
@@ -30,14 +30,12 @@ The @Collections.BalancePeriod@ value in @/etc/arvados/config.yml@ determines th
 
 Keep-balance can also be run with the @-once@ flag to do a single scan/balance operation and then exit. The exit code will be zero if the operation was successful.
 
-h3. Committing
-
-Keep-balance computes and reports changes but does not implement them by sending pull and trash lists to the Keep services unless the @-commit-pull@ and @-commit-trash@ flags are used.
-
 h3. Additional configuration
 
 For configuring resource usage tuning and lost block reporting, please see the @Collections.BlobMissingReport@, @Collections.BalanceCollectionBatch@, @Collections.BalanceCollectionBuffers@ option in the "default config.yml file":{{site.baseurl}}/admin/config.html.
 
+The @Collections.BalancePullLimit@ and @Collections.BalanceTrashLimit@ configuration entries determine the maximum number of pull and trash operations keep-balance will attempt to apply on each keepstore server. If both values are zero, keep-balance will operate in "dry run" mode, where all changes are computed but none are committed.
+
 h3. Limitations
 
 Keep-balance does not attempt to discover whether committed pull and trash requests ever get carried out -- only that they are accepted by the Keep services. If some services are full, new copies of under-replicated blocks might never get made, only repeatedly requested.
diff --git a/doc/admin/keep-faster-gc-s3.html.textile.liquid b/doc/admin/keep-faster-gc-s3.html.textile.liquid
new file mode 100644 (file)
index 0000000..4569dcb
--- /dev/null
@@ -0,0 +1,41 @@
+---
+layout: default
+navsection: admin
+title: "Faster garbage collection in S3"
+...
+
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+When there is a large number of unneeded blocks stored in an S3 bucket, particularly when using @PrefixLength: 0@, the speed of garbage collection can be severely limited by AWS API rate limits and Arvados's multi-step trash/delete process.
+
+The multi-step trash/delete process can be short-circuited by setting @BlobTrashLifetime@ to zero and enabling @UnsafeDelete@ on S3-backed volumes. However, on an actively used cluster such a configuration *can result in data loss* in the rare case where a given block is trashed and then rewritten soon afterward, and S3 processes the write and delete requests in the opposite order.
+
+The following steps can be used to temporarily disable writes on an S3 bucket to enable faster garbage collection without data loss or service interruption. Note that garbage collection on other S3 volumes will be temporarily disabled during this procedure.
+# Create a new S3 bucket and configure it as an additional volume (this step may be skipped if the configuration already has enough writable volumes that clients will still be able to write blocks while the target volume is read-only). We recommend using @PrefixLength: 3@ for the new volume because this results in a much higher rate limit for I/O and garbage collection operations compared to the default @PrefixLength: 0@. If the target volume configuration specifies @StorageClasses@, use the same values for the new volume.
+# Shut down the @keep-balance@ service.
+# Update your configuration as follows: <notextile><pre>
+  Collections:
+    BlobTrashLifetime: 0
+    BalancePullLimit: 0
+  [...]
+  Volumes:
+    <span class="userinput">target-volume-uuid</span>:
+      ReadOnly: true
+      AllowTrashWhenReadOnly: true
+      DriverParameters:
+        UnsafeDelete: true
+</pre></notextile> Note that @BlobTrashLifetime: 0@ instructs keepstore to delete unneeded blocks outright (bypassing the recoverable trash phase); however, in this mode it will normally not trash any blocks at all on an S3 volume due to the safety issue mentioned above, unless the volume is configured with @UnsafeDelete: true@.
+# Restart all @keepstore@ services with the updated configuration.
+# Start the @keep-balance@ service.
+# Objects will be deleted immediately instead of being first copied to trash on the S3 volume, which should significantly speed up cleanup of trashed objects. Monitor progress by watching @keep-balance@ logs and metrics. When garbage collection is complete, keep-balance logs will show an empty changeset: <notextile><pre><code>zzzzz-bi6l4-0123456789abcdef (keep0.zzzzz.arvadosapi.com:25107, disk): ChangeSet{Pulls:0, Trashes:0}</code></pre></notextile>
+# Remove the @UnsafeDelete@ configuration entry on the target volume.
+# Remove the @BlobTrashLifetime@ configuration entry (or restore it to its previous value).
+# If the target volume has @PrefixLength: 0@ and the new volume has @PrefixLength: 3@, skip the next two steps: new data will be stored on the new volume, some existing data will be moved automatically to other volumes, and some will be left on the target volume as long as it's needed.
+# If you want to resume writing new data to the target volume, revert to @ReadOnly: false@ and @AllowTrashWhenReadOnly: false@ on the target volume.
+# If you want to stop writing new data to the newly created volume, set @ReadOnly: true@ and @AllowTrashWhenReadOnly: true@ on the new volume.
+# Remove the @BalancePullLimit@ configuration entry (or restore its previous value), and restart @keep-balance@.
+# Restart all @keepstore@ services with the updated configuration.
index ef794054a7aafbb1b9c925b6e49126416a5cf5d4..2f3aa20fd811de5662288e2724b24ab64a220028 100644 (file)
@@ -14,10 +14,14 @@ This page aims to provide insight about managing the ever growing API Server's l
 
 h3. Logs table purpose & behavior
 
-This database table currently serves three purposes:
-* It's an audit log, permitting admins and users to look up the time and details of past changes to Arvados objects via @arvados.v1.logs.*@ endpoints.
-* It's a mechanism for passing cache-invalidation events, used by websocket servers, the Python SDK "events" library, and @arvados-cwl-runner@ to detect when an object has changed.
-* It's a staging area for stdout/stderr text coming from users' containers, permitting users to see what their containers are doing while they are still running (i.e., before those text files are written to Keep).
+This database table is accessed via "the @logs@ endpoint.":../api/methods/logs.html
+
+This table currently serves several purposes:
+
+* Audit logging, permitting admins and users to look up the time and details of past changes to Arvados objects.
+* Logging other system events, specifically "file uploads and downloads from keep-web.":restricting-upload-download.html#audit_logs
+* The source for cache-invalidation events, published through websockets to Workbench to refresh the view.  It can also be monitored by the Python SDK "events module.":../sdk/python/events.html
+* Prior to Arvados 2.7, it was used a staging area for stdout/stderr text coming from users' containers, permitting users to see what their containers are doing while they are still running (i.e., before those text files are written to Keep).  Starting with Arvados 2.7, this is superseded by a more efficient mechanism, so these logs are disabled by default.  See "2.7.0 upgrade notes":upgrading.html#v2_7_0 for details.
 
 As a result, this table grows indefinitely, even on sites where policy does not require an audit log; making backups, migrations, and upgrades unnecessarily slow and painful.
 
index b140bcc1badda0c2996725bf62a026345c0646c6..ed9fbbd7ae33f292697143af09363feb18cb5cc5 100644 (file)
@@ -31,16 +31,15 @@ When configuring Prometheus, use a @bearer_token@ or @bearer_token_file@ option
 
 table(table table-bordered table-condensed table-hover).
 |_. Component|_. Metrics endpoint|
-|arvados-api-server||
+|arvados-api-server||
 |arvados-controller|✓|
 |arvados-dispatch-cloud|✓|
 |arvados-dispatch-lsf|✓|
 |arvados-git-httpd||
 |arvados-ws|✓|
 |composer||
-|keepproxy||
+|keepproxy||
 |keepstore|✓|
 |keep-balance|✓|
 |keep-web|✓|
-|workbench1||
 |workbench2||
index 44a0467cf472f66afc4e0bf759be89894606bf94..add99bbadb20635571e835c968cacf47ed3f5d40 100644 (file)
@@ -18,7 +18,7 @@ There are two services involved in accessing data from outside the cluster.
 
 h2. Keepproxy Permissions
 
-Permitting @keeproxy@ makes it possible to use @arv-put@ and @arv-get@, and upload from Workbench 1.  It works in terms of individual 64 MiB keep blocks.  It prints a log line each time a user uploads or downloads an individual block. Those logs are usually stored by @journald@ or @syslog@.
+Permitting @keepproxy@ makes it possible to use @arv-put@ and @arv-get@.  It works in terms of individual 64 MiB keep blocks.  It prints a log line each time a user uploads or downloads an individual block. Those logs are usually stored by @journald@ or @syslog@.
 
 The default policy allows anyone to upload or download.
 
@@ -35,7 +35,7 @@ The default policy allows anyone to upload or download.
 
 h2. WebDAV and S3 API Permissions
 
-Permitting @WebDAV@ makes it possible to use WebDAV, S3 API, download from Workbench 1, and upload/download with Workbench 2.  It works in terms of individual files.  It prints a log each time a user uploads or downloads a file.  When @WebDAVLogEvents@ (default true) is enabled, it also adds an entry into the API server @logs@ table.
+Permitting @WebDAV@ makes it possible to use WebDAV, S3 API, and upload/download with Workbench 2.  It works in terms of individual files.  It prints a log each time a user uploads or downloads a file.  When @WebDAVLogEvents@ (default true) is enabled, it also adds an entry into the API server @logs@ table.
 
 When a user attempts to upload or download from a service without permission, they will receive a @403 Forbidden@ response.  This only applies to file content.
 
@@ -148,7 +148,7 @@ This policy is suitable for an installation where data is being shared with a gr
 </pre>
 
 
-h2. Accessing the audit log
+h2(#audit_log). Accessing the audit log
 
 When @WebDAVLogEvents@ is enabled, uploads and downloads of files are logged in the Arvados audit log. These events are included in the "User Activity Report":user-activity.html. The audit log can also be accessed via the API, SDKs or command line. For example, to show the 100 most recent file downloads:
 
@@ -158,12 +158,12 @@ arv log list --filters '[["event_type","=","file_download"]]' -o 'created_at des
 
 For uploads, use the @file_upload@ event type.
 
-Note that this only covers upload and download activity via WebDAV, S3, Workbench 1 (download only) and Workbench 2.
+Note that this only covers upload and download activity via WebDAV, S3, and Workbench 2.
 
-File upload in Workbench 1 and the @arv-get@ and @arv-put@ tools use @Keepproxy@, which does not log activity to the audit log because it operates at the block level, not the file level. @Keepproxy@ records the uuid of the user that owns the token used in the request in its system logs. Those logs are usually stored by @journald@ or @syslog@. A typical log line for such a block download looks like this:
+The @arv-get@ and @arv-put@ tools upload via @Keepproxy@, which does not log activity to the audit log because it operates at the block level, not the file level. @Keepproxy@ records the uuid of the user that owns the token used in the request in its system logs. Those logs are usually stored by @journald@ or @syslog@. A typical log line for such a block download looks like this:
 
 <pre>
-Jul 20 15:03:38 workbench.xxxx1.arvadosapi.com keepproxy[63828]: {"level":"info","locator":"abcdefghijklmnopqrstuvwxyz012345+53251584","msg":"Block download","time":"2021-07-20T15:03:38.458792300Z","user_full_name":"Albert User","user_uuid":"ce8i5-tpzed-abcdefghijklmno"}
+Jul 20 15:03:38 keep.xxxx1.arvadosapi.com keepproxy[63828]: {"level":"info","locator":"abcdefghijklmnopqrstuvwxyz012345+53251584","msg":"Block download","time":"2021-07-20T15:03:38.458792300Z","user_full_name":"Albert User","user_uuid":"ce8i5-tpzed-abcdefghijklmno"}
 </pre>
 
-It is possible to do a reverse lookup from the locator to find all matching collections: the @manifest_text@ field of a collection lists all the block locators that are part of the collection. The @manifest_text@ field also provides the relevant filename in the collection. Because this lookup is rather involved and there is no automated tool to do it, we recommend disabling @KeepproxyPermission/User/Download@ and @KeepproxyPermission/User/Upload@ for sites where the audit log is important and @arv-get@ and @arv-put@ are not essential.
+It is possible to do a reverse lookup from the locator to find all matching collections: the @manifest_text@ field of a collection lists all the block locators that are part of the collection. The @manifest_text@ field also provides the relevant filename in the collection. Because this lookup is rather involved and there is no automated tool to do it, we recommend disabling @KeepproxyPermission.User.Download@ and @KeepproxyPermission.User.Upload@ for sites where the audit log is important and @arv-get@ and @arv-put@ are not essential.
index 18578a78d683cb02d58c836b03b36362fbabc4bf..415f635dcd159ec6c98a749f98e494993b9c4742 100644 (file)
@@ -18,9 +18,9 @@ Another example is situations where admin access is required but there is risk o
 
 h2. Defining scopes
 
-A "scope" consists of a HTTP method and API path.  A token can have multiple scopes.  Token scopes act as a whitelist, and the API server checks the HTTP method and the API path of every request against the scopes of the request token.  Scopes are also described on the "API Authorization":{{site.baseurl}}/api/tokens.html#scopes page of the "API documentation":{{site.baseurl}}/api .
+A "scope" consists of a HTTP method and API path.  A token can have multiple scopes.  Token scopes act as a whitelist, and the API server checks the HTTP method and the API path of every request against the scopes of the request token.  Scopes are also described on the "API Authorization":{{site.baseurl}}/api/tokens.html#scopes page of the "API documentation":{{site.baseurl}}/api/index.html.
 
-These examples use @/arvados/v1/collections@, but can be applied to any endpoint.  Consult the "API documentation":{{site.baseurl}}/api to determine the endpoints for specific methods.
+These examples use @/arvados/v1/collections@, but can be applied to any endpoint.  Consult the "API documentation":{{site.baseurl}}/api/index.html to determine the endpoints for specific methods.
 
 The scope @["GET", "/arvados/v1/collections"]@ will allow only GET or HEAD requests for the list of collections.  Any other HTTP method or path (including requests for a specific collection record, eg a request with path @/arvados/v1/collections/zzzzz-4zz18-0123456789abcde@) will return a permission error.
 
@@ -36,18 +36,20 @@ Object update calls use the @PATCH@ method.  A scope of @["PATCH", "/arvados/v1/
 
 Similarly, you can use a scope of @["PATCH", "/arvados/v1/collections/zzzzz-4zz18-0123456789abcde"]@ to restrict updates to a single collection.
 
+There is one special exception to the scope rules: a valid token is always allowed to issue a request to "@GET /arvados/v1/api_client_authorizations/current@":{{ site.baseurl }}/api/methods/api_client_authorizations.html#current regardless of its scopes. This allows clients to reliably determine whether a request failed because a token is invalid, or because the token is not permitted to perform a particular request. The API server itself needs to be able to do this to validate tokens issued by other clusters in a federation.
+
 h2. Creating a scoped token
 
 A scoped token can be created at the command line:
 
-<pre>
-$ arv api_client_authorization create --api-client-authorization '{"scopes": [["GET", "/arvados/v1/collections"], ["GET", "/arvados/v1/collections/"]]}'
+<notextile>
+<pre><code>$ <span class="userinput">arv api_client_authorization create --api-client-authorization '{"scopes": [["GET", "/arvados/v1/collections"], ["GET", "/arvados/v1/collections/"]]}'</span>
 {
- "href":"/api_client_authorizations/x1u39-gj3su-bizbsw0mx5pju3w",
+ "href":"/api_client_authorizations/zzzzz-gj3su-bizbsw0mx5pju3w",
  "kind":"arvados#apiClientAuthorization",
  "etag":"9yk144t0v6cvyp0342exoh2vq",
- "uuid":"x1u39-gj3su-bizbsw0mx5pju3w",
- "owner_uuid":"x1u39-tpzed-fr97h9t4m5jffxs",
+ "uuid":"zzzzz-gj3su-bizbsw0mx5pju3w",
+ "owner_uuid":"zzzzz-tpzed-fr97h9t4m5jffxs",
  "created_at":"2020-03-12T20:36:12.517375422Z",
  "modified_by_client_uuid":null,
  "modified_by_user_uuid":null,
@@ -71,6 +73,7 @@ $ arv api_client_authorization create --api-client-authorization '{"scopes": [["
   ]
  ]
 }
-</pre>
+</code></pre>
+</notextile>
 
 The response will include @api_token@ field which is the newly issued secret token.  It can be passed directly to the API server that issued it, or can be used to construct a @v2@ token.  A @v2@ format token is required if the token will be used to access other clusters in an Arvados federation.  An Arvados @v2@ format token consists of three fields separate by slashes: the prefix @v2@, followed by the token uuid, followed by the token secret.  For example: @v2/x1u39-gj3su-bizbsw0mx5pju3w/5a74htnoqwkhtfo2upekpfbsg04hv7cy5v4nowf7dtpxer086m@.
index 4b45142a75df9792bef45e5d5de8e71fa19a7b0b..64a113b6f8aba840814119be4486de02870984dc 100644 (file)
@@ -28,13 +28,170 @@ TODO: extract this information based on git commit messages and generate changel
 <div class="releasenotes">
 </notextile>
 
-h2(#main). development main (as of 2023-03-06)
+h2(#main). development main
+
+"previous: Upgrading to 2.7.1":#v2_7_1
+
+h3. Virtual environments inside distribution Python packages have moved
+
+The distribution packages that we publish for Python packages include an entire virtualenv with all required libraries. In Arvados 3.0 these virtualenvs have moved from @/usr/share/python3/dist/PACKAGE_NAME@ to @/usr/lib/PACKAGE_NAME@ to prevent conflicts with distribution packages and better conform to filesystem standards.
+
+If you only run the executables installed by these packages, you don't need to change anything. Those are still installed under @/usr/bin@ and will use the new location when you upgrade. If you have written your own scripts or tools that rely on these virtualenvs, you may need to update those with the new location. For example, if you have a shell script that activates the virtualenv by running:
+
+<pre><code class="shell">source /usr/share/python3/dist/python3-arvados-python-client/bin/activate</code></pre>
+
+You must update it to:
+
+<notextile>
+<pre><code class="shell">source <span class="userinput">/usr/lib/python3-arvados-python-client</span>/bin/activate</code></pre>
+</notextile>
+
+If you have a Python script with this shebang line:
+
+<pre><code class="shell">#!/usr/share/python3/dist/python3-arvados-python-client/bin/python</code></pre>
+
+You must update it to:
+
+<notextile>
+<pre><code class="shell">#!<span class="userinput">/usr/lib/python3-arvados-python-client</span>/bin/python</code></pre>
+</notextile>
+
+h3. WebDAV service uses @/var/cache@ for file content
+
+@keep-web@ now stores copies of recently accessed data blocks in @/var/cache/arvados/keep@ instead of in memory. That directory will be created automatically. The default cache size is 10% of the filesystem size. Use the new @Collections.WebDAVCache.DiskCacheSize@ config to specify a different percentage or an absolute size.
+
+If the previously supported @MaxBlockEntries@ config is present, remove it to avoid warning messages at startup.
+
+h3. Check MaxGatewayTunnels config
+
+If you use the LSF or Slurm dispatcher, ensure the new @API.MaxGatewayTunnels@ config entry is high enough to support the size of your cluster. See "LSF docs":{{site.baseurl}}/install/crunch2-lsf/install-dispatch.html#MaxGatewayTunnels or "Slurm docs":{{site.baseurl}}/install/crunch2-slurm/install-dispatch.html#MaxGatewayTunnels for details.
+
+h2(#2_7_1). v2.7.1 (2023-12-12)
+
+"previous: Upgrading to 2.7.0":#v2_7_0
+
+h3. Remove Workbench1 packages after upgrading the salt installer
+
+If you installed a previous version of Arvados with the Salt installer, and you upgrade your installer to upgrade the cluster, you should uninstall the @arvados-workbench@ package from the workbench instance afterwards.
+
+h3. Remove Workbench1 packages and configuration
+
+The Workbench1 application has been removed from the Arvados distribution. We recommend the following follow-up steps.
+* Remove the Workbench1 package from any service node where it is installed (e.g., @apt remove arvados-workbench@).
+* In your Nginx configuration, add your Workbench1 URL host (from @Services.Workbench1.ExternalURL@) to the @server_name@ directive in the Workbench2 section. For example: <notextile><pre>server {
+  listen 443 ssl;
+  server_name workbench.ClusterID.example.com workbench2.ClusterID.example.com;
+  ...
+}</pre></notextile>
+* In your Nginx configuration, remove the @upstream@ and @server@ sections for Workbench1.
+* Remove the @Services.Workbench1.InternalURLs@ section of your configuration file. (Do not remove @ExternalURL@.)
+* Run @arvados-server config-check@ to identify any Workbench1-specific entries in your configuration file, and remove them.
+
+h3. Check implications of Containers.MaximumPriceFactor 1.5
+
+When scheduling a container, Arvados now considers using instance types other than the lowest-cost type consistent with the container's resource constraints. If a larger instance is already running and idle, or the cloud provider reports that the optimal instance type is not currently available, Arvados will select a larger instance type, provided the cost does not exceed 1.5x the optimal instance type cost.
+
+This will typically reduce overall latency for containers and reduce instance booting/shutdown overhead, but may increase costs depending on workload and instance availability. To avoid this behavior, configure @Containers.MaximumPriceFactor: 1.0@.
+
+h3. Synchronize keepstore and keep-balance upgrades
+
+The internal communication between keepstore and keep-balance about read-only volumes has changed. After keep-balance is upgraded, old versions of keepstore will be treated as read-only. We recommend upgrading and restarting all keepstore services first, then upgrading and restarting keep-balance.
+
+h3. Separate configs for MaxConcurrentRequests and MaxConcurrentRailsRequests
+
+The default configuration value @API.MaxConcurrentRequests@ (the number of concurrent requests that will be processed by a single instance of an arvados service process) is raised from 8 to 64.
+
+A new configuration key @API.MaxConcurrentRailsRequests@ (default 8) limits the number of concurrent requests processed by a RailsAPI service process.
+
+h2(#v2_7_0). v2.7.0 (2023-09-21)
+
+"previous: Upgrading to 2.6.3":#v2_6_3
+
+h3. New system for live container logs
+
+Starting with Arvados 2.7, a new system for fetching live container logs is in place.  This system features significantly reduced database load compared to previous releases.  When Workbench or another application needs to access the logs of a process (running or completed), they should use the "log endpoint of container_requests":{{ site.baseurl }}/api/methods/container_requests.html which forwards requests to the running container.  This supersedes the previous system where compute processes would send all of their logs to the database, which produced significant load.
+
+The legacy logging system is now disabled by default for all installations with the setting @Containers.Logging.LimitLogBytesForJob: 0@.  If you have an existing Arvados installation where you have customized this value and do not need the legacy container logging system, we recommend removing @LimitLogBytesForJob@ from your configuration.
+
+If you need to re-enable the legacy logging system, set @Containers.Logging.LimitLogBytesForJob@ to a positive value (the previous default was @Containers.Logging.LimitLogBytesForJob: 67108864@).
+
+h3. Workbench 1 deprecated
+
+The original Arvados Workbench application (referred to as "Workbench 1") is deprecated and will be removed in a future major version of Arvados.  Users are advised to migrate to "Workbench 2".  Starting with this release, new installations of Arvados will only set up Workbench 2 and no longer include Workbench 1 by default.
+
+It is also important to note that Workbench 1 only supports the legacy logging system, which is now disabled by default.  If you need to re-enable the legacy logging system, see above.
+
+h3. Multi-node installer's domain name configuration changes
+
+The @domain_name@ variable at @terraform/vpc/terraform.tfvars@ and @DOMAIN@ variable at @local.params@ changed their meaning. In previous versions they were used in combination with @cluster_name@ and @CLUSTER@ to build the cluster's domain name (e.g.: @cluster_name@.@domain_name@). To allow the use of any arbitrary cluster domain, now we don't enforce using the cluster prefix as part of the domain, so @domain_name@ and @DOMAIN@ need to hold the entire domain for the given cluster.
+For example, if @cluster_name@ is set to @"xarv1"@ and @domain_name@ was previously set to @"example.com"@, it should now be set to @"xarv1.example.com"@ to keep using the same cluster domain.
+
+h3. Crunchstat log format change
+
+The reported number of CPUs available in a container is now formatted in @crunchstat.txt@ log files and @crunchstat-summary@ text reports as a floating-point number rather than an integer (@2.00 cpus@ rather than @2 cpus@). Programs that parse these files may need to be updated accordingly.
+
+h3. arvados-login-sync configuration changes, including ignored groups
+
+In the @Users@ section of your cluster configuration, there are now several options to control what system resources are or are not managed by @arvados-login-sync@. These options all have names that begin with @Sync@.
+
+The defaults for all of these options match the previous behavior of @arvados-login-sync@ _except_ for @SyncIgnoredGroups@. This list names groups that @arvados-login-sync@ will never modify by adding or removing members. As a security precaution, the default list names security-sensitive system groups on Debian- and Red Hat-based distributions. If you are using Arvados to manage system group membership on shell nodes, especially @sudo@ or @wheel@, you may want to provide your own list. Set @SyncIgnoredGroups: []@ to restore the original behavior of ignoring no groups.
+
+h3. API clients can always retrieve their current token, regardless of scopes
+
+We have introduced a small exception to the previous behavior of "Arvados API token scopes":{{ site.baseurl }}/admin/scoped-tokens.html in this release. A valid token is now always allowed to issue a request to "@GET /arvados/v1/api_client_authorizations/current@":{{ site.baseurl }}/api/methods/api_client_authorizations.html#current regardless of its scopes. This allows clients to reliably determine whether a request failed because a token is invalid, or because the token is not permitted to perform a particular request. The API server itself needs to be able to do this to validate tokens issued by other clusters in a federation.
+
+h3. Deprecated/legacy APIs slated for removal
+
+The legacy APIs "humans":../api/methods/humans.html, "specimens":../api/methods/specimens.html, "traits":../api/methods/traits.html, "jobs":../api/methods/jobs.html, "job_tasks":../api/methods/job_tasks.html, "pipeline_instances":../api/methods/pipeline_instances.html, "pipeline_templates":../api/methods/pipeline_templates.html, "nodes":../api/methods/nodes.html, "repositories":../api/methods/repositories.html, and "keep_disks":../api/methods/keep_disks.html are deprecated and will be removed in a future major version of Arvados.
+
+In addition, the @default_owner_uuid@, @api_client_id@, and @user_id@ fields of "api_client_authorizations":../api/methods/api_client_authorizations.html are deprecated and will be removed from @api_client_authorization@ responses in a future major version of Arvados.  This should not affect clients as  @default_owner_uuid@ was never implemented, and @api_client_id@ and @user_id@ returned internal ids that were not meaningful or usable with any other API call.
+
+h3. UseAWSS3v2Driver option removed
+
+The old "v1" S3 driver for keepstore has been removed. The new "v2" implementation, which has been the default since Arvados 2.5.0, is always used. The @Volumes.*.DriverParameters.UseAWSS3v2Driver@ configuration key is no longer recognized. If your config file uses it, remove it to avoid warning messages at startup.
+
+h2(#v2_6_3). v2.6.3 (2023-06-06)
+
+h3. Python SDK automatically retries failed requests much more
+
+The Python SDK has always provided functionality to retry API requests that fail due to temporary problems like network failures, by passing @num_retries=N@ to a request's @execute()@ method. In this release, API client constructor functions like @arvados.api@ also accept a @num_retries@ argument. This value is stored on the client object and used as a floor for all API requests made with this client. This allows developers to set their preferred retry strategy once, without having to pass it to each @execute()@ call.
+
+The default value for @num_retries@ in API constructor functions is 10. This means that an API request that repeatedly encounters temporary problems may spend up to about 35 minutes retrying in the worst case. We believe this is an appropriate default for most users, where eventual success is a much greater concern than responsiveness. If you have client applications where this is undesirable, update them to pass a lower @num_retries@ value to the constructor function. You can even pass @num_retries=0@ to have the API client act as it did before, like this:
+
+{% codeblock as python %}
+import arvados
+arv_client = arvados.api('v1', num_retries=0, ...)
+{% endcodeblock %}
+
+The first time the Python SDK fetches an Arvados API discovery document, it will ensure that @googleapiclient.http@ logs are handled so you have a way to know about early problems that are being retried. If you prefer to handle these logs your own way, just ensure that the @googleapiclient.http@ logger (or a parent logger) has a handler installed before you call any Arvados API client constructor.
+
+h2(#v2_6_2). v2.6.2 (2023-05-22)
+
+"previous: Upgrading to 2.6.1":#v2_6_1
+
+This version introduces a new API feature which is used by Workbench 2 to improve page loading performance.  To avoid any errors using the new Workbench with an old API server, be sure to upgrade the API server before upgrading Workbench 2.
+
+h2(#v2_6_1). v2.6.1 (2023-04-17)
+
+"previous: Upgrading to 2.6.0":#v2_6_0
+
+h3. Performance improvement for permission row de-duplication migration
+
+The migration which de-duplicates permission links has been optimized.  We recommend upgrading from 2.5.0 directly to 2.6.1 in order to avoid the slow permission de-deplication migration in 2.6.0.
+
+You should still plan for the arvados-api-server package upgrade to take longer than usual due to the database schema update changing the integer id column in each table from 32-bit to 64-bit.
+
+h2(#v2_6_0). v2.6.0 (2023-04-06)
 
 "previous: Upgrading to 2.5.0":#v2_5_0
 
+h3. WebDAV InternalURLs must be reachable from controller nodes
+
+Ensure your internal keep-web service addresses are listed in the @Services.WebDAV.InternalURLs@ section of your configuration file, and reachable from controller processes, as noted on the "updated install page":{{site.baseurl}}/admin/config-urls.html.
+
 h3. Slow migration on upgrade
 
-Important!  This upgrade includes a database schema update changing the integer id column in each table from 32-bit to 64-bit.  Because it touches every row in the table, on moderate to large sized installations this may be very slow (on the order of hours). Plan for the arvados-api-server package upgrade to take longer than usual.
+Important!  This upgrade includes a database schema update changing the integer id column in each table from 32-bit to 64-bit.  Because it touches every row in the table, on moderate to large sized installations *this may be very slow* (on the order of hours). Plan for the arvados-api-server package upgrade to take longer than usual.
 
 h3. Default request concurrency, new limit on log requests
 
index 949ce6a5527a6a763aace11943bf19fb61f6b631..c2d4743ddfdf5b58372ac9b31dfff9452eb2db26 100644 (file)
@@ -40,7 +40,7 @@ h3. Deactivate user
 
 When deactivating a user, you may also want to "reassign ownership of their data":{{site.baseurl}}/admin/reassign-ownership.html .
 
-h3. Directly activate user
+h3(#activate-user). Directly activate user
 
 <notextile>
 <pre><code>$ <span class="userinput">arv user update --uuid "zzzzz-tpzed-3kz0nwtjehhl0u4" --user '{"is_active":true}'</span>
index 296660d01bda247653b68958a0b9f67f15aa5d24..7d30ee88d1e70cbca7eb046e967e337abd154ac0 100644 (file)
@@ -10,13 +10,28 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
+# "Authentication":#authentication
+## "Federated Authentication":#federated_auth
+# "User activation":#user_activation
+# "User agreements and self-activation":#user_agreements
+# "User profile":#user_profile
+# "User visibility":#user_visibility
+# "Pre-setup user by email address":#pre-activated
+# "Pre-activate federated user":#pre-activated-fed
+# "Auto-setup federated users from trusted clusters":#auto_setup_federated
+# "Activation flows":#activation_flows
+## "Private instance":#activation_flow_private
+## "Federated instance":#federated
+## "Open instance":#activation_flow_open
+# "Service Accounts":#service_accounts
+
 {% comment %}
 TODO: Link to relevant workbench documentation when it gets written
 {% endcomment %}
 
 This page describes how user accounts are created, set up and activated.
 
-h2. Authentication
+h2(#authentication). Authentication
 
 "Browser login and management of API tokens is described here.":{{site.baseurl}}/api/tokens.html
 
@@ -30,11 +45,11 @@ If no user account is found, a new user account is created with the information
 
 If a user account has been "linked":{{site.baseurl}}/user/topics/link-accounts.html or "migrated":merge-remote-account.html the API server may follow internal redirects (@redirect_to_user_uuid@) to select the linked or migrated user account.
 
-h3. Federated Authentication
+h3(#federated_auth). Federated Authentication
 
 A federated user follows a slightly different flow.  The client presents a token issued by the remote cluster.  The local API server contacts the remote cluster to verify the user's identity.  This results in a user object (representing the remote user) being created on the local cluster.  If the user cannot be verified, the token will be rejected.  If the user is inactive on the remote cluster, a user record will be created, but it will also be inactive.
 
-h2. User activation
+h2(#user_activation). User activation
 
 This section describes the different user account states.
 
@@ -94,13 +109,13 @@ The @user_agreements/sign@ endpoint creates a Link object:
 
 The @user_agreements/signatures@ endpoint returns the list of Link objects that represent signatures by the current user (created by @sign@).
 
-h2. User profile
+h2(#user_profile). User profile
 
 The fields making up the user profile are described in @Workbench.UserProfileFormFields@ .  See "Configuration reference":config.html .
 
 The user profile is checked by workbench after checking if user agreements need to be signed.  The values entered are stored in the @properties@ field on the user object.  Unlike user agreements, the requirement to fill out the user profile is not enforced by the API server.
 
-h2. User visibility
+h2(#user_visibility). User visibility
 
 Initially, a user is not part of any groups and will not be able to interact with other users on the system.  The admin should determine who the user is permited to interact with and use Workbench or the "command line":group-management.html#add to create and add the user to the appropriate group(s).
 
@@ -118,7 +133,7 @@ $ arv user setup --uuid clsr1-tpzed-1234567890abcdf
 
 2. When the user logs in the first time, the email address will be recognized and the user will be associated with the existing user object.
 
-h2. Pre-activate federated user
+h2(#pre-activated-fed). Pre-activate federated user
 
 1. As admin, create a user object with the @uuid@ of the federated user (this is the user's uuid on their home cluster, called @clsr2@ in this example):
 
@@ -128,13 +143,13 @@ $ arv user create --user '{"uuid": "clsr2-tpzed-1234567890abcdf", "email": "foo@
 
 2. When the user logs in, they will be associated with the existing user object.
 
-h2. Auto-setup federated users from trusted clusters
+h2(#auto_setup_federated). Auto-setup federated users from trusted clusters
 
 By setting @ActivateUsers: true@ for each federated cluster in @RemoteClusters@, a federated user from one of the listed clusters will be automatically set up and activated on this cluster.  See configuration example in "Federated instance":#federated .
 
-h2. Activation flows
+h2(#activation_flows). Activation flows
 
-h3. Private instance
+h3(#activation_flow_private). Private instance
 
 Policy: users must be manually set up by the admin.
 
@@ -171,7 +186,7 @@ RemoteClusters:
 # Because 'clsr2' has @ActivateUsers@ the user is set up and activated.
 # User can immediately start using Workbench.
 
-h3. Open instance
+h3(#activation_flow_open). Open instance
 
 Policy: anybody who shows up and signs the agreements is activated.
 
@@ -187,3 +202,11 @@ Users:
 # Workbench presents user with list of user agreements, user reads and clicks "sign" for each one.
 # Workbench tries to activate user.
 # User is activated.
+
+h2(#service_accounts). Service Accounts
+
+For automation purposes, you can create service accounts that aren't tied to an external authorization system. These kind of accounts don't really differ much from standard user accounts, they just cannot be accessed through a normal login mechanism.
+
+As an admin, you can create accounts like described in the "user pre-setup section above":#pre-activated and then "activate them by updating its @is_active@ field":{{site.baseurl}}/admin/user-management-cli.html#activate-user.
+
+Once a service account is created you can "use an admin account to set up a token":{{site.baseurl}}/admin/user-management-cli.html#create-token for it, so that the required automations can authenticate. Note that these tokens support having a limited lifetime by using the @expires_at@ field and also "limited scope":{{site.baseurl}}/admin/scoped-tokens.html, if required by your security policies. You can read more about them at "the API reference page":{{site.baseurl}}/api/methods/api_client_authorizations.html.
\ No newline at end of file
index 3d6ccbdd5b8ac5d25411e60a5e0b6a1591bc53d7..a0d244d9bcb2cbfb7d144c904992555fc8516321 100644 (file)
@@ -11,7 +11,9 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-p=. *Legacy.  The job APIs are read-only and disabled by default in new installations.  Use "container requests":methods/container_requests.html .*
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and slated to be removed entirely in a future major release of Arvados.  It is replaced by "container requests.":methods/container_requests.html
+{% include 'notebox_end' %}
 
 h2. Crunch scripts
 
index b06136db9a8a219b8a93c27c0decbca19324b60e..cfe57640c4785cca1c5ca4df4faadbd8cea996b1 100644 (file)
@@ -32,6 +32,7 @@ Return a list of containers that are either ready to dispatch, or being started/
 Each entry in the returned list of @items@ includes:
 * an @instance_type@ entry with the name and attributes of the instance type that will be used to schedule the container (chosen from the @InstanceTypes@ section of your cluster config file); and
 * a @container@ entry with selected attributes of the container itself, including @uuid@, @priority@, @runtime_constraints@, and @state@. Other fields of the container records are not loaded by the dispatcher, and will have empty/zero values here (e.g., @{...,"created_at":"0001-01-01T00:00:00Z","command":[],...}@).
+* a @scheduling_status@ field with a brief explanation of the container's status in the dispatch queue, or an empty string if scheduling is not applicable, e.g., the container has already started running.
 
 Example response:
 
@@ -56,12 +57,31 @@ Example response:
         "AddedScratch": 0,
         "Price": 0.146,
         "Preemptible": false
-      }
+      },
+      "scheduling_status": "waiting for new instance to be ready"
     },
     ...
   ]
 }</pre></notextile>
 
+h3. Get specified container
+
+@GET /arvados/v1/dispatch/container?container_uuid={uuid}@
+
+Return the same information as "list containers" above, but for a single specified container.
+
+Example response:
+
+<notextile><pre>{
+  "container": {
+    ...
+  },
+  "instance_type": {
+    ...
+  },
+  "scheduling_status": "waiting for new instance to be ready"
+}</pre></notextile>
+
 h3. Terminate a container
 
 @POST /arvados/v1/dispatch/containers/kill?container_uuid={uuid}&reason={string}@
index 3d69d02ea92379fcb11c17d78cae442112e8df84..44ca265cd8d55444653a598070573544e7a0aed7 100644 (file)
@@ -18,12 +18,8 @@ The API server publishes a machine-readable description of its endpoints and som
 
 h2. Exported configuration
 
-The Controller exposes a subset of the cluster's configuration and makes it available to clients in JSON format. This public config includes valuable information like several service's URLs, timeout settings, etc. and it is available at @/arvados/v1/config@, for example @https://{{ site.arvados_api_host }}/arvados/v1/config@. The new Workbench is one example of a client using this information, as it's a client-side application and doesn't have access to the cluster's config file.
+The Controller exposes a subset of the cluster's configuration and makes it available to clients in JSON format. This public config includes valuable information like several service's URLs, timeout settings, etc. and it is available at @/arvados/v1/config@, for example @https://{{ site.arvados_api_host }}/arvados/v1/config@. Workbench is one example of a client using this information, as it's a client-side application and doesn't have access to the cluster's config file.
 
 h2. Exported vocabulary definition
 
-When configured, the Controller also exports the "metadata vocabulary definition":{{site.baseurl}}/admin/metadata-vocabulary.html in JSON format. This functionality is useful for clients like Workbench2 and the Python SDK to provide "identifier to human-readable labels" translations facilities for reading and writing objects on the system. This is available at @/arvados/v1/vocabulary@, for example @https://{{ site.arvados_api_host }}/arvados/v1/vocabulary@.
-
-h2. Workbench examples
-
-Many Arvados Workbench pages, under the *Advanced* tab, provide examples of API and SDK use for accessing the current resource .
+When configured, the Controller also exports the "metadata vocabulary definition":{{site.baseurl}}/admin/metadata-vocabulary.html in JSON format. This functionality is useful for clients like Workbench and the Python SDK to translate between identifiers and human-readable labels when reading and writing objects on the system. This is available at @/arvados/v1/vocabulary@, for example @https://{{ site.arvados_api_host }}/arvados/v1/vocabulary@.
index f068a49c2c032e92803135637ba0446579655993..e95d523b9dec8e4ac4f88a743c968e7250260a4c 100644 (file)
@@ -35,6 +35,12 @@ The @users@ folder will return a listing of the users for whom the client has pe
 
 In addition to the @/by_id/@ path prefix, the collection or project can be specified using a path prefix of @/c=<uuid or pdh>/@ or (if the cluster is properly configured) as a virtual host.  This is described on "Keep-web URLs":keep-web-urls.html
 
+It is possible for a project or a "filter group":methods/groups.html#filter to appear as its own descendant in the @by_id@ and @users@ tree (a filter group may match itself, its own ancestor, another filter group that matches its ancestor, etc). When this happens, the descendant appears as an empty read-only directory. For example, if filter group @f@ matches its own parent @p@:
+* @/users/example/p/f@ will show the filter group's contents (matched projects and collections).
+* @/users/example/p/f/p@ will appear as an empty directory.
+* @/by_id/uuid_of_f/p@ will show the parent project's contents, including @f@.
+* @/by_id/uuid_of_f/p/f@ will appear as an empty directory.
+
 h3(#auth). Authentication mechanisms
 
 A token can be provided in an Authorization header as a @Bearer@ token:
index 7f05142dbf2e4dc4a17dd2b52a6236ed963d4832..7b28533e30b3c3263721c2e9daa40d1bb00e945e 100644 (file)
@@ -110,7 +110,7 @@ table(table table-bordered table-condensed).
 @["storage_classes_desired","=","[\"default\"]"]@|
 |@<@, @<=@, @>=@, @>@|string, number, or timestamp|Ordering comparison|@["script_version",">","123"]@|
 |@like@, @ilike@|string|SQL pattern match.  Single character match is @_@ and wildcard is @%@. The @ilike@ operator is case-insensitive|@["script_version","like","d00220fb%"]@|
-|@in@, @not in@|array of strings|Set membership|@["script_version","in",["main","d00220fb38d4b85ca8fc28a8151702a2b9d1dec5"]]@|
+|@in@, @not in@|array of strings or integers|Set membership|@["script_version","in",["main","d00220fb38d4b85ca8fc28a8151702a2b9d1dec5"]]@|
 |@is_a@|string|Arvados object type|@["head_uuid","is_a","arvados#collection"]@|
 |@exists@|string|Presence of subproperty|@["properties","exists","my_subproperty"]@|
 |@contains@|string, array of strings|Presence of one or more keys or array elements|@["storage_classes_desired", "contains", ["foo", "bar"]]@ (matches both @["foo", "bar"]@ and @["foo", "bar", "baz"]@)
index bcf77564c5e793f12646fca760cf22dd9c575640..5bfeca8bc66ea50960793a09792931959eec63c7 100644 (file)
@@ -27,8 +27,6 @@ table(table table-bordered table-condensed).
 |_. Attribute|_. Type|_. Description|_. Example|
 |uuid|string|An identifier used to refer to the token without exposing the actual token.||
 |api_token|string|The actual token string that is expected in the Authorization header.||
-|api_client_id|integer|-||
-|user_id|integer|-||
 |created_by_ip_address|string|-||
 |last_used_by_ip_address|string|The network address of the most recent client using this token.||
 |last_used_at|datetime|Timestamp of the most recent request using this token.||
@@ -65,6 +63,15 @@ table(table table-bordered table-condensed).
 |api_client_id|integer||query||
 |scopes|array||query||
 
+h3(#current). current
+
+Return the full record associated with the provided API token. This endpoint is often used to check the validity of a given token.
+
+Arguments:
+
+table(table table-bordered table-condensed).
+|_. Argument |_. Type |_. Description |_. Location |_. Example |
+
 h3. delete
 
 Delete an existing ApiClientAuthorization.
index 5871337b0ae8b14760d9d32292a27f1eed29ecc8..29d28d42a221cd95e6436a1e2c61a6eb173cb2aa 100644 (file)
@@ -19,7 +19,7 @@ Example UUID: @zzzzz-4zz18-0123456789abcde@
 
 h2. Resource
 
-Collections describe sets of files in terms of data blocks stored in Keep.  See "Keep - Content-Addressable Storage":{{site.baseurl}}/architecture/storage.html for details.
+Collections describe sets of files in terms of data blocks stored in Keep.  See "Keep - Content-Addressable Storage":{{site.baseurl}}/architecture/storage.html and "using collection versioning":../../user/topics/collection-versioning.html for details.
 
 Each collection has, in addition to the "Common resource fields":{{site.baseurl}}/api/resources.html:
 
index fad051f4bf2bc2126e4f83105272eb7347ac14b8..1c269fb3e613cf0c8d03c2ac99fbc25f20a9b7e7 100644 (file)
@@ -54,7 +54,8 @@ table(table table-bordered table-condensed).
 |priority|integer|Range 0-1000.  Indicate scheduling order preference.|Clients are expected to submit container requests with zero priority in order to preview the container that will be used to satisfy it. Priority can be null if and only if state!="Committed".  See "below for more details":#priority .|
 |expires_at|datetime|After this time, priority is considered to be zero.|Not yet implemented.|
 |use_existing|boolean|If possible, use an existing (non-failed) container to satisfy the request instead of creating a new one.|Default is true|
-|log_uuid|string|Log collection containing log messages provided by the scheduler and crunch processes.|Null if the container has not yet started running.|
+|log_uuid|string|Log collection containing log messages provided by the scheduler and crunch processes.|Null if the container has not yet started running.
+To retrieve logs in real time while the container is running, use the log API (see below).|
 |output_uuid|string|Output collection created when the container finished successfully.|Null if the container has failed or not yet completed.|
 |filters|string|Additional constraints for satisfying the container_request, given in the same form as the filters parameter accepted by the container_requests.list API.|
 |runtime_token|string|A v2 token to be passed into the container itself, used to access Keep-backed mounts, etc.  |Not returned in API responses.  Reset to null when state is "Complete" or "Cancelled".|
@@ -222,3 +223,51 @@ table(table table-bordered table-condensed).
 Setting the priority of a committed container_request to 0 may cancel a running container assigned for it.
 See "Canceling a container request":{{site.baseurl}}/api/methods/container_requests.html#cancel_container for further details.
 {% include 'notebox_end' %}
+
+h3(#container_status). container_status
+
+Get container status.
+
+table(table table-bordered table-condensed).
+|_. Argument |_. Type |_. Description |_. Location |
+{background:#ccffcc}.|uuid|string|The UUID of the container request in question.|path|
+
+Example request: @GET /arvados/v1/container_requests/zzzzz-xvdhp-0123456789abcde/container_status@
+
+Response attributes:
+
+table(table table-bordered table-condensed).
+|_. Attribute|_. Type|_. Description|_. Examples|
+|uuid|string|The UUID of the container assigned to this request.||
+|state|string|The state of the container assigned to this request (see "container resource attributes":containers.html).||
+|scheduling_status|string|A brief explanation of the container's status in the dispatch queue, or an empty string if scheduling is not applicable, e.g., the container is running or finished.|@waiting for cloud resources: queue position 3@
+@creating new instance@
+@preparing runtime environment@|
+
+h3(#log). log
+
+Get container log data using WebDAV methods.
+
+This API retrieves data from the container request's log collection. It can be used at any time in the container request lifecycle.
+* Before a container has been assigned (the request is @Uncommitted@) it returns an empty directory.
+* While the container is @Queued@ or @Locked@, it returns an empty directory.
+* While the container is @Running@, @.../log/{container_uuid}/@ returns real-time logging data.
+* While the container is @Complete@ or @Cancelled@, @.../log/{container_uuid}/@ returns the final log collection.
+
+If a request results in multiple containers being run (see @container_count_max@ above), the logs from prior attempts remain available at @.../log/{old_container_uuid}/@.
+
+Currently, this API has a limitation that a directory listing at the top level @/arvados/v1/container_requests/{uuid}/log/@ does not reveal the per-container subdirectories. Instead, clients should look up the container request record and use the @container_uuid@ attribute to request files and directory listings under the per-container directory, as in the examples below.
+
+This API supports the @Range@ request header, so it can be used to poll for and retrieve logs incrementally while the container is running.
+
+Arguments:
+
+table(table table-bordered table-condensed).
+|_. Argument |_. Type |_. Description |_. Location |_. Example |
+{background:#ccffcc}.|method|string|Read-only WebDAV method|HTTP method|@GET@, @OPTIONS@, @PROPFIND@|
+{background:#ccffcc}.|uuid|string|The UUID of the container request.|path|zzzzz-xvdhp-0123456789abcde|
+{background:#ccffcc}.|path|string|Path to a file in the log collection.|path|@/zzzzz-dz642-0123456789abcde/stderr.txt@|
+
+Examples:
+* @GET /arvados/v1/container_requests/zzzzz-xvdhp-0123456789abcde/log/zzzzz-dz642-0123456789abcde/stderr.txt@
+* @PROPFIND /arvados/v1/container_requests/zzzzz-xvdhp-0123456789abcde/log/zzzzz-dz642-0123456789abcde/@
index 1d6c415a9edb4633fa2a7d3bce24cf0f418446b7..1d2fed768cdf78d158abe346866bf926bdb762c3 100644 (file)
@@ -105,10 +105,12 @@ Required arguments are displayed in %{background:#ccffcc}green%.
 
 Supports federated @get@ and @list@.
 
-h2(#create). create
+h3(#create). create
 
 Create a new Container.
 
+This API requires admin privileges. In normal operation, it should not be used at all.
+
 Arguments:
 
 table(table table-bordered table-condensed).
index af14c56f40e26a2783343851955441c78a8ae24e..05d3fb1c7b9c4f83da5d5b4af0221be197375c1b 100644 (file)
@@ -46,7 +46,7 @@ The @frozen_by_uuid@ attribute can be cleared by an admin user. It can also be c
 
 The optional @API.FreezeProjectRequiresDescription@ and @API.FreezeProjectRequiresProperties@ configuration settings can be used to prevent users from freezing projects that have empty @description@ and/or specified @properties@ entries.
 
-h3. Filter groups
+h3(#filter). Filter groups
 
 @filter@ groups are virtual groups; they can not own other objects. Filter groups have a special @properties@ field named @filters@, which must be an array of filter conditions. See "list method filters":{{site.baseurl}}/api/methods.html#filters for details on the syntax of valid filters, but keep in mind that the attributes must include the object type (@collections@, @container_requests@, @groups@, @workflows@), separated with a dot from the field to be filtered on.
 
@@ -101,13 +101,13 @@ Required arguments are displayed in %{background:#ccffcc}green%.
 
 h3(#contents). contents
 
-Retrieve a list of items owned by the group.  Use "recursive" to list objects within subprojects as well.
+Retrieve a list of items owned by the group or user.  Use "recursive" to list objects within subprojects as well.
 
 Arguments:
 
 table(table table-bordered table-condensed).
 |_. Argument |_. Type |_. Description |_. Location |_. Example |
-{background:#ccffcc}.|uuid|string|The UUID of the group in question.|path||
+{background:#ccffcc}.|uuid|string|The UUID of the group or user to enumerate. If this is a user UUID, this method returns the contents of that user's home project.|path||
 |limit|integer (default 100)|Maximum number of items to return.|query||
 |order|array|Attributes to use as sort keys to determine the order resources are returned, each optionally followed by @asc@ or @desc@ to indicate ascending or descending order. Sort within a resource type by prefixing the attribute with the resource name and a period.|query|@["collections.modified_at desc"]@|
 |filters|array|Conditions for filtering items.|query|@[["uuid", "is_a", "arvados#job"]]@|
@@ -116,6 +116,7 @@ table(table table-bordered table-condensed).
 |include|string|If provided with the value "owner_uuid", this will return owner objects in the "included" field of the response.|query||
 |include_trash|boolean (default false)|Include trashed objects.|query|@true@|
 |include_old_versions|boolean (default false)|Include past versions of the collections being listed.|query|@true@|
+|select|array|Attributes of each object to return in the response. Specify an unqualified name like @uuid@ to select that attribute on all object types, or a qualified name like @collections.name@ to select that attribute on objects of the specified type. By default, all available attributes are returned, except on collections, where @manifest_text@ is not returned and cannot be selected due to an implementation limitation. This limitation may be removed in the future.|query|@["uuid", "collections.name"]@|
 
 Notes:
 
index e08e941cf66f68a21c0d6b765f09ddbec6faab11..1c338217eb8b0673a021457532038bf7c9ecbc34 100644 (file)
@@ -11,7 +11,9 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-p=. *Deprecated, likely to be removed in a future version.  The recommended way to store metadata is "collection properties":collections.html*
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and is slated to be removed entirely in a future major release of Arvados.  The recommended way to store metadata is with "'properties' field on collections and projects.":../properties.html
+{% include 'notebox_end' %}
 
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/humans@
 
index 69c3f07e3006a29ae3f64f7d6540aaf95343561d..880fe56219f461140b8011dc96880b807dbf2c20 100644 (file)
@@ -11,7 +11,9 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-p=. *Legacy.  This endpoint is read-only and disabled by default in new installations.*
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and slated to be removed entirely in a future major release of Arvados.  It is replaced by "container requests.":container_requests.html
+{% include 'notebox_end' %}
 
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/job_tasks@
 
index aa7a58898a58dcb998f0de202db907a97843e5bf..75d7368c8e7ac86bfa69f2e5147096920a74d013 100644 (file)
@@ -11,7 +11,9 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-p=. *Legacy.  This endpoint is read-only and disabled by default in new installations.*
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and slated to be removed entirely in a future major release of Arvados.  It is replaced by "container requests.":container_requests.html
+{% include 'notebox_end' %}
 
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/jobs@
 
index 7624b6699f9874559edbe77e31f03cc8b67d313f..9a82a3e7ce7f7b4f67777986f63ce1bd9a10139d 100644 (file)
@@ -2,7 +2,7 @@
 layout: default
 navsection: api
 navmenu: API Methods
-title: "keep_disks (deprecated)"
+title: "keep_disks"
 
 ...
 {% comment %}
@@ -11,6 +11,10 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and slated to be removed entirely in a future major release of Arvados.  It is replaced by "keep services.":keep_services.html
+{% include 'notebox_end' %}
+
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/keep_disks@
 
 Object type: @penuu@
index 7ddc62519c1922ad48a254827078f7b0651065ea..b29527ceeb5908286858ab9dff976bab36f8d730 100644 (file)
@@ -11,6 +11,10 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and slated to be removed entirely in a future major release of Arvados.  It is replaced by "cloud dispatcher API.":../dispatch.html
+{% include 'notebox_end' %}
+
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/nodes@
 
 Object type: @7ekkf@
index 55baee9b5ab248d7f270eb3ff10d908270a48131..e19dfba02a6ea2b14e398e0bd2629fa9c6d63d8f 100644 (file)
@@ -11,7 +11,9 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-p=. *Legacy.  This endpoint is read-only and disabled by default in new installations.*
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and slated to be removed entirely in a future major release of Arvados.  It is replaced by "container requests.":container_requests.html
+{% include 'notebox_end' %}
 
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/pipeline_instances@
 
index 141072c51c451770830a9d22bd0fdd4185a826d9..ddbe8ad389793b7ca69652b53ceee12b7e2274b5 100644 (file)
@@ -11,7 +11,9 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-p=. *Legacy.  This endpoint is read-only and disabled by default in new installations.*
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and slated to be removed entirely in a future major release of Arvados.  It is replaced by "registered workflows.":workflows.html
+{% include 'notebox_end' %}
 
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/pipeline_templates@
 
index 7a47da6a3b27824247de24b5876c11cecb066b5c..b2b2cab7d568e200021f6d1acba234f198f6cc69 100644 (file)
@@ -11,6 +11,10 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and slated to be removed entirely in a future major release of Arvados.  It is replaced by "collection versioning.":collections.html
+{% include 'notebox_end' %}
+
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/repositories@
 
 Object type: @s0uqq@
index be3712a2064cdd174150769078ccf5eaf5c5d8a6..3820eeb242b9125e3b3a9320af03e4043227edf2 100644 (file)
@@ -10,7 +10,9 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-p=. *Deprecated, likely to be removed in a future version.  The recommended way to store metadata is "collection properties":collections.html*
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and is slated to be removed entirely in a future major release of Arvados.  The recommended way to store metadata is with "'properties' field on collections and projects.":../properties.html
+{% include 'notebox_end' %}
 
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/specimens@
 
index e48804702eac9fd330d2431cf8e189a01188357d..4e356b95234e1d1bd8b21481c4d8d2d97825ecc3 100644 (file)
@@ -11,7 +11,9 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-p=. *Deprecated, likely to be removed in a future version.  The recommended way to store metadata is "collection properties":collections.html*
+{% include 'notebox_begin_warning' %}
+This is a legacy API.  This endpoint is deprecated, disabled by default in new installations, and is slated to be removed entirely in a future major release of Arvados.  The recommended way to store metadata is with "'properties' field on collections and projects.":../properties.html
+{% include 'notebox_end' %}
 
 API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/traits@
 
index 780f7a1ff24ad95fc66217f0347cc126587e353f..175c59b8c4012f680558f9eeacabbd81e9c5ac17 100644 (file)
@@ -22,14 +22,15 @@ table(table table-bordered table-condensed).
 {% comment %}
 The arv:git* container properties, and the associated Git commands, primarily come from arvados_cwl.executor.ArvCwlExecutor.get_git_info.
 {% endcomment -%}
-|arv:gitBranch|container|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the name of the branch checked out (the output of @git rev-parse --abbrev-ref HEAD@)|
-|arv:gitCommitter|container|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the name and email address of the committer of the most recent commit (the output of @git log --format='%cn <%ce>' -n1 HEAD@)|
-|arv:gitCommit|container|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the full checksum of the most recent commit (the output of @git log --format='%H' -n1 HEAD@)|
-|arv:gitDate|container|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the commit date of the most recent commit in RFC 2822 format (the output of @git log --format='%cD' -n1 HEAD@)|
-|arv:gitDescribe|container|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the name of the most recent tag that is reachable from the most recent commit (the output of @git describe --always --tags@)|
-|arv:gitOrigin|container|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the URL of the remote named @origin@, if set (the output of @git remote get-url origin@)|
-|arv:gitPath|container|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the absolute path of the checkout on the filesystem|
-|arv:gitStatus|container|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with a machine-readable summary of files modified in the checkout since the most recent commit (the output of @git status --untracked-files=no --porcelain@)|
+|arv:gitBranch|container request, collection of type=workflow|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the name of the branch checked out (the output of @git rev-parse --abbrev-ref HEAD@)|
+|arv:gitCommitter|container request, collection of type=workflow|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the name and email address of the committer of the most recent commit (the output of @git log --format='%cn <%ce>' -n1 HEAD@)|
+|arv:gitCommit|container request, collection of type=workflow|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the full checksum of the most recent commit (the output of @git log --format='%H' -n1 HEAD@)|
+|arv:gitDate|container request, collection of type=workflow|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the commit date of the most recent commit in RFC 2822 format (the output of @git log --format='%cD' -n1 HEAD@)|
+|arv:gitDescribe|container request, collection of type=workflow|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the name of the most recent tag that is reachable from the most recent commit (the output of @git describe --always --tags@)|
+|arv:gitOrigin|container request, collection of type=workflow|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the URL of the remote named @origin@, if set (the output of @git remote get-url origin@)|
+|arv:gitPath|container request, collection of type=workflow|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with the absolute path of the checkout on the filesystem|
+|arv:gitStatus|container request, collection of type=workflow|string|When @arvados-cwl-runner@ is run from a Git checkout, this property is set with a machine-readable summary of files modified in the checkout since the most recent commit (the output of @git status --untracked-files=no --porcelain@)|
+|arv:workflowMain|collection of type=workflow|string|Set on a collection containing a workflow created by @arvados-cwl-runner --create-workflow@, this is a relative reference inside the collection to the entry point of the workflow.|
 
 The following system properties predate the @arv:@ key prefix, but are still reserved and can always be set.
 
@@ -56,6 +57,7 @@ table(table table-bordered table-condensed).
 |log|The collection contains log files from a container run.|
 |output|The collection contains the output of a top-level container run (this is a container request where @requesting_container_uuid@  is null).|
 |intermediate|The collection contains the output of a child container run (this is a container request where @requesting_container_uuid@ is non-empty).|
+|workflow|A collection created by @arvados-cwl-runner --create-workflow@ containing a workflow definition.|
 
 h2. Controlling user-supplied properties
 
index 0935f9ba1d2a3bf7eb5c5bb7db4eb20b528ac3ed..99c5f58a218490a8895488dc8052f55b97bbb93f 100644 (file)
@@ -73,6 +73,8 @@ Each entry in scopes consists of a @request_method@ and @request_path@.  The @re
 
 As a special case, a scope of @["all"]@ allows all resources.  This is the default if no scope is given.
 
+A valid token is always allowed to issue a request to "@GET /arvados/v1/api_client_authorizations/current@":{{ site.baseurl }}/api/methods/api_client_authorizations.html#current regardless of its scopes.
+
 Using scopes is also described on the "Securing API access with scoped tokens":{{site.baseurl}}/admin/scoped-tokens.html page of the admin documentation.
 
 h3. Scope examples
@@ -80,7 +82,7 @@ h3. Scope examples
 A scope of @GET /arvados/v1/collections@ permits listing collections.
 
 * Requests with different methods, such as creating a new collection using @POST /arvados/v1/collections@, will be rejected.
-* Requests to access other resources, such as @GET /arvados/v1/groups@, will be rejected.
+* Requests to access other resources, such as @GET /arvados/v1/groups@, will be rejected (except "@GET /arvados/v1/api_client_authorizations/current@":{{ site.baseurl }}/api/methods/api_client_authorizations.html#current, which is always allowed).
 * Be aware that requests for specific records, such as @GET /arvados/v1/collections/962eh-4zz18-xi32mpz2621o8km@ will also be rejected.  This is because the scope @GET /arvados/v1/collections@ does not end in @/@
 
 A scope of @GET /arvados/v1/collections/@ (with @/@ suffix) will permit access to individual collections.
index 09d593db2b71874d82b667425acf81df9719c74d..f5405c16e17cb84a7f082c2daa563aede25f5569 100644 (file)
@@ -14,7 +14,7 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 
 h3. Services
 
-Located in @arvados/services@ except for Workbench which is located in @arvados/apps/workbench@.
+Located in @arvados/services@.
 
 table(table table-bordered table-condensed).
 |_. Component|_. Description|
index 8ebc6f73df6af90866f1d4f31f20968f18830a4b..b4c3778e046afef48e996d13379bd2ca7c44b1ea 100644 (file)
@@ -32,4 +32,4 @@ Arvados @Singularity@ support is a work in progress. These are the current limit
 * Even when using the Singularity runtime, users' container images are expected to be saved in Docker format. Specifying a @.sif@ file as an image when submitting a container request is not yet supported.
 * Arvados' Singularity implementation does not yet limit the amount of memory available in a container. Each container will have access to all memory on the host where it runs, unless memory use is restricted by Slurm/LSF.
 * The Docker ENTRYPOINT instruction is ignored.
-* Arvados is tested with Singularity version 3.9.9. Other versions may not work.
+* Arvados is tested with Singularity version 3.10.4. Other versions may not work.
diff --git a/doc/gen_api_method_docs.py b/doc/gen_api_method_docs.py
deleted file mode 100755 (executable)
index 9a29d46..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: CC-BY-SA-3.0
-
-# gen_api_method_docs.py
-#
-# Generate docs for Arvados methods.
-#
-# This script will retrieve the discovery document at
-# https://localhost:9900/discovery/v1/apis/arvados/v1/rest
-# and will generate Textile documentation files in the current
-# directory.
-
-import argparse
-import pprint
-import re
-import requests
-import os
-import sys #debugging
-
-p = argparse.ArgumentParser(description='Generate Arvados API method documentation.')
-
-p.add_argument('--host',
-               type=str,
-               default='localhost',
-               help="The hostname or IP address of the API server")
-
-p.add_argument('--port',
-               type=int,
-               default=9900,
-               help="The port of the API server")
-
-p.add_argument('--output-dir',
-               type=str,
-               default='.',
-               help="Directory in which to write output files.")
-
-args = p.parse_args()
-
-api_url = 'https://{host}:{port}/discovery/v1/apis/arvados/v1/rest'.format(**vars(args))
-
-r = requests.get(api_url, verify=False)
-if r.status_code != 200:
-    raise Exception('Bad status code %d: %s' % (r.status_code, r.text))
-
-if 'application/json' not in r.headers.get('content-type', ''):
-    raise Exception('Unexpected content type: %s: %s' %
-                    (r.headers.get('content-type', ''), r.text))
-
-api = r.json()
-
-resource_num = 0
-for resource in sorted(api[u'resources']):
-    resource_num = resource_num + 1
-    out_fname = os.path.join(args.output_dir, resource + '.textile')
-    if os.path.exists(out_fname):
-        backup_name = out_fname + '.old'
-        try:
-            os.rename(out_fname, backup_name)
-        except OSError as e:
-            print "WARNING: could not back up {0} as {1}: {2}".format(
-                out_fname, backup_name, e)
-    outf = open(out_fname, 'w')
-    outf.write(
-"""---
-navsection: api
-navmenu: API Methods
-title: "{resource}"
-navorder: {resource_num}
----
-
-h1. {resource}
-
-Required arguments are displayed in %{{background:#ccffcc}}green%.
-
-""".format(resource_num=resource_num, resource=resource))
-
-    methods = api['resources'][resource]['methods']
-    for method in sorted(methods.keys()):
-        methodinfo = methods[method]
-        outf.write(
-"""
-h2. {method}
-
-{description}
-
-Arguments:
-
-table(table table-bordered table-condensed).
-|_. Argument |_. Type |_. Description |_. Location |_. Example |
-""".format(
-    method=method, description=methodinfo['description']))
-
-        required = []
-        notrequired = []
-        for param, paraminfo in methodinfo['parameters'].iteritems():
-            paraminfo.setdefault(u'description', '')
-            paraminfo.setdefault(u'location', '')
-            limit = ''
-            if paraminfo.get('minimum', '') or paraminfo.get('maximum', ''):
-                limit = "range {0}-{1}".format(
-                    paraminfo.get('minimum', ''),
-                    paraminfo.get('maximum', 'unlimited'))
-            if paraminfo.get('default', ''):
-                if limit:
-                    limit = limit + '; '
-                limit = limit + 'default %d' % paraminfo['default']
-            if limit:
-                paraminfo['type'] = '{0} ({1})'.format(
-                    paraminfo['type'], limit)
-
-            row = "|{param}|{type}|{description}|{location}||\n".format(
-                param=param, **paraminfo)
-            if paraminfo.get('required', False):
-                required.append(row)
-            else:
-                notrequired.append(row)
-
-        for row in sorted(required):
-            outf.write("{background:#ccffcc}." + row)
-        for row in sorted(notrequired):
-            outf.write(row)
-
-        # pprint.pprint(methodinfo)
-
-    outf.close()
-    print "wrote ", out_fname
-
-
diff --git a/doc/gen_api_schema_docs.py b/doc/gen_api_schema_docs.py
deleted file mode 100755 (executable)
index 3c3ab2e..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: CC-BY-SA-3.0
-
-# gen_api_schema_docs.py
-#
-# Generate Textile documentation pages for Arvados schema resources.
-
-import requests
-import re
-import os
-
-r = requests.get('https://localhost:9900/arvados/v1/schema',
-                 verify=False)
-if r.status_code != 200:
-    raise Exception('Bad status code %d: %s' % (r.status_code, r.text))
-
-if 'application/json' not in r.headers.get('content-type', ''):
-    raise Exception('Unexpected content type: %s: %s' %
-                    (r.headers.get('content-type', ''), r.text))
-
-schema = r.json()
-navorder = 0
-for resource in sorted(schema.keys()):
-    navorder = navorder + 1
-    properties = schema[resource]
-    res_api_endpoint = re.sub(r'([a-z])([A-Z])', r'\1_\2', resource).lower()
-    outfile = "{}.textile".format(resource)
-    if os.path.exists(outfile):
-        outfile = "{}_new.textile".format(resource)
-    print outfile, "..."
-    with open(outfile, "w") as f:
-        f.write("""---
-layout: default
-navsection: api
-navmenu: Schema
-title: {resource}
----
-
-h1. {resource}
-
-A **{resource}** represents...
-
-h2. Methods
-
-        See "REST methods for working with Arvados resources":{{{{site.baseurl}}}}/api/methods.html
-
-API endpoint base: @https://{{{{ site.arvados_api_host }}}}/arvados/v1/{res_api_endpoint}@
-
-h2. Creation
-
-h3. Prerequisites
-
-Prerequisites for creating a {resource}.
-
-h3. Side effects
-
-Side effects of creating a {resource}.
-
-h2. Resources
-
-Each {resource} has, in addition to the usual "attributes of Arvados resources":resources.html:
-
-table(table table-bordered table-condensed).
-|_. Attribute|_. Type|_. Description|_. Example|
-""".format(
-    resource=resource,
-    navorder=navorder,
-    res_api_endpoint=res_api_endpoint))
-
-        for prop in properties:
-            if prop not in ['id', 'uuid', 'href', 'kind', 'etag', 'self_link',
-                            'owner_uuid', 'created_at',
-                            'modified_by_client_uuid',
-                            'modified_by_user_uuid',
-                            'modified_at']:
-                f.write('|{name}|{type}|||\n'.format(**prop))
-
diff --git a/doc/images/add-new-collection-wb2.png b/doc/images/add-new-collection-wb2.png
new file mode 100644 (file)
index 0000000..39195d3
Binary files /dev/null and b/doc/images/add-new-collection-wb2.png differ
index 61938447f609b4cb7dbc5b745cdeb108fe479f40..d62a9869a2f9233dec8ac34dc678ae20e90c42dc 100644 (file)
Binary files a/doc/images/add-new-repository.png and b/doc/images/add-new-repository.png differ
diff --git a/doc/images/files-uploaded.png b/doc/images/files-uploaded.png
deleted file mode 100644 (file)
index ccd8e16..0000000
Binary files a/doc/images/files-uploaded.png and /dev/null differ
diff --git a/doc/images/new-collection-modal-wb2.png b/doc/images/new-collection-modal-wb2.png
new file mode 100644 (file)
index 0000000..464bbcb
Binary files /dev/null and b/doc/images/new-collection-modal-wb2.png differ
diff --git a/doc/images/newly-created-collection-empty-wb2.png b/doc/images/newly-created-collection-empty-wb2.png
new file mode 100644 (file)
index 0000000..41e1635
Binary files /dev/null and b/doc/images/newly-created-collection-empty-wb2.png differ
index 3e12860fb03d9722f46d81f1865f45bee1e5849d..c8f00f487bec64e6ef408caa9e83b99dff39e410 100644 (file)
Binary files a/doc/images/repositories-panel.png and b/doc/images/repositories-panel.png differ
diff --git a/doc/images/shared-collection.png b/doc/images/shared-collection.png
deleted file mode 100644 (file)
index 446bab5..0000000
Binary files a/doc/images/shared-collection.png and /dev/null differ
diff --git a/doc/images/sharing-collection-url.png b/doc/images/sharing-collection-url.png
new file mode 100644 (file)
index 0000000..aba75dc
Binary files /dev/null and b/doc/images/sharing-collection-url.png differ
index 8aea827473e2a6620b4b98d75ab56b70e5d73451..ab6101657edf65937926092e14cc08ab2d526f76 100644 (file)
Binary files a/doc/images/ssh-adding-public-key.png and b/doc/images/ssh-adding-public-key.png differ
diff --git a/doc/images/switch-to-wb1.png b/doc/images/switch-to-wb1.png
deleted file mode 100644 (file)
index 3787e31..0000000
Binary files a/doc/images/switch-to-wb1.png and /dev/null differ
diff --git a/doc/images/switch-to-wb2.png b/doc/images/switch-to-wb2.png
deleted file mode 100644 (file)
index 177090b..0000000
Binary files a/doc/images/switch-to-wb2.png and /dev/null differ
diff --git a/doc/images/trash-button-topnav.png b/doc/images/trash-button-topnav.png
deleted file mode 100644 (file)
index d266437..0000000
Binary files a/doc/images/trash-button-topnav.png and /dev/null differ
diff --git a/doc/images/trash-buttons.png b/doc/images/trash-buttons.png
new file mode 100644 (file)
index 0000000..43f33a2
Binary files /dev/null and b/doc/images/trash-buttons.png differ
diff --git a/doc/images/upload-data-progress-wb2.png b/doc/images/upload-data-progress-wb2.png
new file mode 100644 (file)
index 0000000..31aa001
Binary files /dev/null and b/doc/images/upload-data-progress-wb2.png differ
diff --git a/doc/images/upload-data-prompt-with-files-wb2.png b/doc/images/upload-data-prompt-with-files-wb2.png
new file mode 100644 (file)
index 0000000..a25ffd1
Binary files /dev/null and b/doc/images/upload-data-prompt-with-files-wb2.png differ
diff --git a/doc/images/upload-tab-in-new-collection.png b/doc/images/upload-tab-in-new-collection.png
deleted file mode 100644 (file)
index f027c79..0000000
Binary files a/doc/images/upload-tab-in-new-collection.png and /dev/null differ
diff --git a/doc/images/upload-using-workbench.png b/doc/images/upload-using-workbench.png
deleted file mode 100644 (file)
index 3d67577..0000000
Binary files a/doc/images/upload-using-workbench.png and /dev/null differ
index b980fdc274fa0fefc387ebe5941b93d5290b6a11..91954543e24f163f8b119e142d08fb30ae07e650 100644 (file)
Binary files a/doc/images/vm-access-with-webshell.png and b/doc/images/vm-access-with-webshell.png differ
index 854f4412014cd771f46003b5c9c0ed4c115782a4..2d3af539d45eb2a21aad5a489e3008ae720fa2dd 100644 (file)
Binary files a/doc/images/wgs-tutorial/image1.png and b/doc/images/wgs-tutorial/image1.png differ
index ad805298d7b0d8b059ba37b716e67073f004da48..3f628b672de4b00fb94545e072137723122e40e1 100644 (file)
Binary files a/doc/images/wgs-tutorial/image4.png and b/doc/images/wgs-tutorial/image4.png differ
index 8ee9048ee9d4156d19224f82a1c5a8f6e8ef6817..d513ee502837873415dcd6a0d9e2508ed876ae09 100644 (file)
Binary files a/doc/images/wgs-tutorial/image5.png and b/doc/images/wgs-tutorial/image5.png differ
index 41dc28dedc694cb5ec5f0baaf488420607589302..17f66cecaad98955ac6652226596d092fcf60fc6 100644 (file)
Binary files a/doc/images/wgs-tutorial/image6.png and b/doc/images/wgs-tutorial/image6.png differ
diff --git a/doc/images/wgs-tutorial/image7.png b/doc/images/wgs-tutorial/image7.png
new file mode 100644 (file)
index 0000000..39633db
Binary files /dev/null and b/doc/images/wgs-tutorial/image7.png differ
diff --git a/doc/images/wgs-tutorial/image8.png b/doc/images/wgs-tutorial/image8.png
new file mode 100644 (file)
index 0000000..9eb4f54
Binary files /dev/null and b/doc/images/wgs-tutorial/image8.png differ
diff --git a/doc/images/workbench-dashboard.png b/doc/images/workbench-dashboard.png
deleted file mode 100644 (file)
index 3cdf1e4..0000000
Binary files a/doc/images/workbench-dashboard.png and /dev/null differ
diff --git a/doc/images/workbench-first-page.png b/doc/images/workbench-first-page.png
new file mode 100644 (file)
index 0000000..531c86f
Binary files /dev/null and b/doc/images/workbench-first-page.png differ
diff --git a/doc/images/workbench-move-selected.png b/doc/images/workbench-move-selected.png
deleted file mode 100644 (file)
index bba1a1c..0000000
Binary files a/doc/images/workbench-move-selected.png and /dev/null differ
diff --git a/doc/images/workbench-move-wb2.png b/doc/images/workbench-move-wb2.png
new file mode 100644 (file)
index 0000000..59c1b9e
Binary files /dev/null and b/doc/images/workbench-move-wb2.png differ
index b4e0c1a312fd1b1feb3d0bf27fa9a05b0bc416dc..31ad994f0b7a7f9d674a8679fab8c75f1b446603 100644 (file)
@@ -70,9 +70,6 @@ h2(#example). Configuration example
           # might be needed for other S3-compatible services.
           V2Signature: false
 
-          # Use the AWS S3 v2 Go driver instead of the goamz driver.
-          UseAWSS3v2Driver: false
-
           # By default keepstore stores data using the MD5 checksum
           # (32 hexadecimal characters) as the object name, e.g.,
           # "0123456abc...". Setting PrefixLength to 3 changes this
@@ -121,12 +118,6 @@ h2(#example). Configuration example
         StorageClasses: null
 </code></pre></notextile>
 
-Two S3 drivers are available. Historically, Arvados has used the @goamz@ driver to talk to S3-compatible services. More recently, support for the @aws-sdk-go-v2@ driver was added. This driver can be activated by setting the @UseAWSS3v2Driver@ flag to @true@.
-
-The @aws-sdk-go-v2@ does not support the old S3 v2 signing algorithm. This will not affect interacting with AWS S3, but it might be an issue when Keep is backed by a very old version of a third party S3-compatible service.
-
-The @aws-sdk-go-v2@ driver can improve read performance by 50-100% over the @goamz@ driver, but it has not had as much production use. See the "wiki":https://dev.arvados.org/projects/arvados/wiki/Keep_real_world_performance_numbers for details.
-
 h2(#IAM). IAM Policy
 
 On Amazon, VMs which will access the S3 bucket (these include keepstore and compute nodes) will need an IAM policy with "permission that can read, write, list and delete objects in the bucket":https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create.html .  Here is an example policy:
index fb69a0df3c19d9459b15a8424c68740085dea4e0..c20e4855ad79242f51eaeb2c3fe5afc1f01389a0 100644 (file)
@@ -195,7 +195,7 @@ The @VPC@ and @Subnet@ should be configured for where you want the compute image
 
 h3(#aws-ebs-autoscaler). Autoscaling compute node scratch space
 
-Arvados supports "AWS EBS autoscaler":https://github.com/awslabs/amazon-ebs-autoscale .  This feature automatically expands the scratch space on the compute node on demand by 200 GB at a time, up to 5 TB.
+Arvados supports "AWS EBS autoscaler":https://github.com/awslabs/amazon-ebs-autoscale.  This feature automatically expands the scratch space on the compute node on demand by 200 GB at a time, up to 5 TB.
 
 If you want to add the daemon in your images, add the @--aws-ebs-autoscale@ flag to the "the build script":#building.
 
@@ -228,7 +228,7 @@ The AWS EBS autoscaler daemon will be installed with this configuration:
 
 Changing the ebs-autoscale configuration is left as an exercise for the reader.
 
-This feature also requires a few Arvados configuration changes, described in "EBS-Autoscale configuration"#aws-ebs-autoscaler .
+This feature also requires a few Arvados configuration changes, described in "EBS Autoscale configuration":install-dispatch-cloud.html#aws-ebs-autoscaler.
 
 h2(#azure). Build an Azure image
 
index 198925c7bda33a77352304ee124ab9875ff0f182..579ec6e1b30b644255e5ab667d44e3f1602c67d2 100644 (file)
@@ -93,7 +93,7 @@ To specify instance types with NVIDIA GPUs, "the compute image must be built wit
 </code></pre>
 </notextile>
 
-The @DriverVersion@ is the version of the CUDA toolkit installed in your compute image (in X.Y format, do not include the patchlevel).  The @HardwareCapability@ is the CUDA compute capability of the GPUs available for this instance type.  The @DeviceCount@ is the number of GPU cores available for this instance type.
+The @DriverVersion@ is the version of the CUDA toolkit installed in your compute image (in X.Y format, do not include the patchlevel).  The @HardwareCapability@ is the "CUDA compute capability of the GPUs available for this instance type":https://developer.nvidia.com/cuda-gpus.  The @DeviceCount@ is the number of GPU cores available for this instance type.
 
 h3(#aws-ebs-autoscaler). EBS Autoscale configuration
 
index d4328d89a3f55b98d909108329bc9f0782ec7718..6aeb11040cd1d387966263dc66cd4662e6e689a8 100644 (file)
@@ -40,6 +40,8 @@ Add a DispatchLSF entry to the Services section, using the hostname where @arvad
 
 Review the following configuration parameters and adjust as needed.
 
+{% include 'hpc_max_gateway_tunnels' %}
+
 h3(#BsubSudoUser). Containers.LSF.BsubSudoUser
 
 arvados-dispatch-lsf uses @sudo@ to execute @bsub@, for example @sudo -E -u crunch bsub [...]@. This means the @crunch@ account must exist on the hosts where LSF jobs run ("execution hosts"), as well as on the host where you are installing the Arvados LSF dispatcher (the "submission host"). To use a user account other than @crunch@, configure @BsubSudoUser@:
@@ -73,6 +75,7 @@ Template variables starting with % will be substituted as follows:
 %M memory in MB
 %T tmp in MB
 %G number of GPU devices (@runtime_constraints.cuda.device_count@)
+%W maximum job run time in minutes, suitable for use with @-W@ or @-We@ flags (see MaxRunTimeOverhead MaxRunTimeDefault below)
 
 Use %% to express a literal %. The %%J in the default will be changed to %J, which is interpreted by @bsub@ itself.
 
@@ -81,7 +84,7 @@ For example:
 <notextile>
 <pre>    Containers:
       LSF:
-        <code class="userinput">BsubArgumentsList: <b>["-o", "/tmp/crunch-run.%%J.out", "-e", "/tmp/crunch-run.%%J.err", "-J", "%U", "-n", "%C", "-D", "%MMB", "-R", "rusage[mem=%MMB:tmp=%TMB] span[hosts=1]", "-R", "select[mem>=%MMB]", "-R", "select[tmp>=%TMB]", "-R", "select[ncpus>=%C]"]</b></code>
+        <code class="userinput">BsubArgumentsList: <b>["-o", "/tmp/crunch-run.%%J.out", "-e", "/tmp/crunch-run.%%J.err", "-J", "%U", "-n", "%C", "-D", "%MMB", "-R", "rusage[mem=%MMB:tmp=%TMB] span[hosts=1]", "-R", "select[mem>=%MMB]", "-R", "select[tmp>=%TMB]", "-R", "select[ncpus>=%C]", "-We", "%W"]</b></code>
 </pre>
 </notextile>
 
@@ -98,6 +101,14 @@ If the container requests access to GPUs (@runtime_constraints.cuda.device_count
 </pre>
 </notextile>
 
+h3(#MaxRunTimeOverhead). Containers.LSF.MaxRunTimeOverhead
+
+Extra time to add to each container's @scheduling_parameters.max_run_time@ value when substituting for @%W@ in @BsubArgumentsList@, to account for time spent setting up the container image, copying output files, etc.
+
+h3(#MaxRunTimeDefault). Containers.LSF.MaxRunTimeDefault
+
+Default @max_run_time@ value to use for containers that do not specify one in @scheduling_parameters.max_run_time@. If this is zero, and @BsubArgumentsList@ contains @"-W", "%W"@ or @"-We", "%W"@, those arguments will be dropped when submitting containers that do not specify @scheduling_parameters.max_run_time@.
+
 h3(#PollInterval). Containers.PollInterval
 
 arvados-dispatch-lsf polls the API server periodically for new containers to run.  The @PollInterval@ option controls how often this poll happens.  Set this to a string of numbers suffixed with one of the time units @s@, @m@, or @h@.  For example:
index 9b664ec9efb96a3208dfcdcdf3a42090e76b2ecc..16af80d127706b23c08f0c222474d312dd8f7a76 100644 (file)
@@ -41,6 +41,8 @@ Add a DispatchSLURM entry to the Services section, using the hostname where @cru
 
 The following configuration parameters are optional.
 
+{% include 'hpc_max_gateway_tunnels' %}
+
 h3(#PollPeriod). Containers.PollInterval
 
 crunch-dispatch-slurm polls the API server periodically for new containers to run.  The @PollInterval@ option controls how often this poll happens.  Set this to a string of numbers suffixed with one of the time units @ns@, @us@, @ms@, @s@, @m@, or @h@.  For example:
@@ -125,7 +127,9 @@ If your Slurm cluster uses the @task/cgroup@ TaskPlugin, you can configure Crunc
 </pre>
 </notextile>
 
-The choice of subsystem ("memory" in this example) must correspond to one of the resource types enabled in Slurm's @cgroup.conf@. Limits for other resource types will also be respected.  The specified subsystem is singled out only to let Crunch determine the name of the cgroup provided by Slurm.  When doing this, you should also set "ReserveExtraRAM":#ReserveExtraRAM .
+When using cgroups v1, the choice of subsystem ("memory" in this example) must correspond to one of the resource types enabled in Slurm's @cgroup.conf@.  The specified subsystem is singled out only to let Crunch determine the name of the cgroup provided by Slurm.  Limits for other resource types will also be respected.
+
+When doing this, you should also set "ReserveExtraRAM":#ReserveExtraRAM .
 
 {% include 'notebox_begin' %}
 
index 2afdf8a919150e62b3464e58b8637e6045bf4398..9f1e6a899032151a5f047b4b03f34c7a3214564e 100644 (file)
@@ -36,11 +36,13 @@ This page describes how to configure a compute node so that it can be used to ru
 
 h2(#singularity). Set up Singularity
 
-Follow the "Singularity installation instructions":https://sylabs.io/guides/3.9/user-guide/quick_start.html. Make sure @singularity@ and @mksquashfs@ are working:
+Follow the "Singularity installation instructions":https://sylabs.io/guides/latest/user-guide/quick_start.html. Note that while the latest stable version is normally expected to be compatible, Arvados is currently tested with singularity 3.10.4.
+
+Make sure @singularity@ and @mksquashfs@ are working:
 
 <notextile>
 <pre><code>$ <span class="userinput">singularity version</span>
-3.9.9
+singularity-ce version 3.10.4-dirty
 $ <span class="userinput">mksquashfs -version</span>
 mksquashfs version 4.4 (2019/08/29)
 [...]
index b7589032561cb03046bd597bd973506a665278d9..476c89005fed8607f57984f61b4781b12ecd9040 100644 (file)
@@ -34,10 +34,10 @@ Git services must be installed on the same host as the Arvados Rails API server.
 
 h2(#dependencies). Install dependencies
 
-h3. Centos 7
+h3. Alma/CentOS/Red Hat/Rocky
 
 <notextile>
-<pre><code># <span class="userinput">yum install git perl-Data-Dumper openssh-server</span>
+<pre><code># <span class="userinput">dnf install git perl-Data-Dumper openssh-server</span>
 </code></pre>
 </notextile>
 
@@ -246,10 +246,10 @@ h2(#install-packages). Install the arvados-git-httpd package
 
 The arvados-git-httpd package provides HTTP access, using Arvados authentication tokens instead of passwords. It must be installed on the system where your git repositories are stored.
 
-h3. Centos 7
+h3. Alma/CentOS/Red Hat/Rocky
 
 <notextile>
-<pre><code># <span class="userinput">yum install arvados-git-httpd</span>
+<pre><code># <span class="userinput">dnf install arvados-git-httpd</span>
 </code></pre>
 </notextile>
 
index bb4ae7b3d8ef1b9b5f810d2cfecfe8901f7a2be0..05d27b7cb45ff121c43df6fec7544bdf0590c5cc 100644 (file)
@@ -24,7 +24,7 @@ Keep-balance can be installed anywhere with network access to Keep services, arv
 
 {% include 'notebox_begin' %}
 
-If you are installing keep-balance on an existing system with valuable data, you can run keep-balance in "dry run" mode first and review its logs as a precaution. To do this, edit your keep-balance startup script to use the flags @-commit-pulls=false -commit-trash=false -commit-confirmed-fields=false@.
+If you are installing keep-balance on an existing system with valuable data, you can run keep-balance in "dry run" mode first and review its logs as a precaution. To do this, set the @Collections.BalancePullLimit@ and @Collections.BalanceTrashLimit@ configuration entries to zero.
 
 {% include 'notebox_end' %}
 
index b3c63861299c4ab986bc8692755ce6a79202ec8e..0b051e715d48a47694331088e65790fa7fdb5527 100644 (file)
@@ -163,6 +163,15 @@ Normally, Keep-web accepts requests for multiple collections using the same host
 In such cases -- for example, a site which is not reachable from the internet, where some data is world-readable from Arvados's perspective but is intended to be available only to users within the local network -- the downstream proxy should configured to return 401 for all paths beginning with "/c="
 {% include 'notebox_end' %}
 
+h3. Configure filesystem cache size
+
+Keep-web stores copies of recently accessed data blocks in @/var/cache/arvados/keep@. The cache size defaults to 10% of the size of the filesystem where that directory is located (typically @/var@) and can be customized with the @DiskCacheSize@ config entry.
+
+<notextile>
+<pre><code>  Collections:
+    WebDAVCache:
+      DiskCacheSize: 20 GiB</code></pre></notextile>
+
 {% assign arvados_component = 'keep-web' %}
 
 {% include 'install_packages' %}
index 999883b65861980aadf930294277c4b26ebc0faa..20021bd42e539985054f134f6f24f8513069b935 100644 (file)
@@ -21,7 +21,7 @@ h2(#introduction). Introduction
 
 The Keepproxy server is a gateway into your Keep storage. Unlike the Keepstore servers, which are only accessible on the local LAN, Keepproxy is suitable for clients located elsewhere on the internet. Specifically, in contrast to Keepstore:
 * A client writing through Keepproxy sends a single copy of a data block, and Keepproxy distributes copies to the appropriate Keepstore servers.
-* A client can write through Keepproxy without precomputing content hashes. Notably, the browser-based upload feature in Workbench requires Keepproxy.
+* A client can write through Keepproxy without precomputing content hashes.
 * Keepproxy checks API token validity before processing requests. (Clients that can connect directly to Keepstore can use it as scratch space even without a valid API token.)
 
 By convention, we use the following hostname for the Keepproxy server:
index 67b9f0de334758addbbb63837da58ea7248eb409..8819b0210f94609fc883eb2c55be05ce5a98c575 100644 (file)
@@ -43,7 +43,7 @@ table(table table-bordered table-condensed).
 |"Keep-web":install-keep-web.html |Gateway service providing read/write HTTP and WebDAV support on top of Keep.|Required to access files from Workbench.|
 |"Keep-balance":install-keep-balance.html |Storage cluster maintenance daemon responsible for moving blocks to their optimal server location, adjusting block replication levels, and trashing unreferenced blocks.|Required to free deleted data from underlying storage, and to ensure proper replication and block distribution (including support for storage classes).|
 |\3=. *User interface*|
-|"Workbench":install-workbench-app.html, "Workbench2":install-workbench2-app.html |Primary graphical user interface for working with file collections and running containers.|Optional.  Depends on API server, keep-web, websockets server.|
+|"Workbench2":install-workbench2-app.html |Primary graphical user interface for working with file collections and running containers.|Optional.  Depends on API server, keep-web, websockets server.|
 |\3=. *Additional services*|
 |"Websockets server":install-ws.html |Event distribution server.|Required to view streaming container logs in Workbench.|
 |"Shell server":install-shell-server.html |Grant Arvados users access to Unix shell accounts on dedicated shell nodes.|Optional.|
index 5bb7e422da0097b8326558656328e73e7028b2a6..56ad95635c9947e06a2ff31615386181899e54bc 100644 (file)
@@ -13,6 +13,7 @@ Arvados requires at least version *9.4* of PostgreSQL. We recommend using versio
 
 * "AWS":#aws
 * "CentOS 7":#centos7
+* "Alma/CentOS/Red Hat/Rocky 8":#rh8
 * "Debian or Ubuntu":#debian
 
 h3(#aws). AWS
@@ -35,6 +36,23 @@ h3(#centos7). CentOS 7
 # Configure the database to launch at boot and start now
   <notextile><pre># <span class="userinput">systemctl enable --now rh-postgresql12-postgresql</span></pre></notextile>
 
+h3(#rh8). Alma/CentOS/Red Hat/Rocky 8
+
+{% comment %}
+The default version on RH8 is PostgreSQL 10. You can install up to PostgreSQL 13.
+{% endcomment %}
+
+# Install PostgreSQL
+  <notextile><pre># <span class="userinput">dnf install postgresql-server postgresql-contrib</span></pre></notextile>
+# Initialize the database
+  <notextile><pre># <span class="userinput">postgresql-setup initdb</span></pre></notextile>
+# Configure the database to accept password connections from localhost
+  <notextile><pre><code># <span class="userinput">sed -ri -e 's/^(host +all +all +(127\.0\.0\.1\/32|::1\/128) +)ident$/\1md5/' /var/lib/pgsql/data/pg_hba.conf</span></code></pre></notextile>
+# Configure the database to accept password connections from the local network (replace @10.9.8.0/24@ with your private network mask)
+  <notextile><pre><code># <span class="userinput">echo 'host all all 10.9.8.0/24 md5' | tee -a /var/lib/pgsql/data/pg_hba.conf</span></code></pre></notextile>
+# Configure the database to launch at boot and start now
+  <notextile><pre># <span class="userinput">systemctl enable --now postgresql</span></pre></notextile>
+
 h3(#debian). Debian or Ubuntu
 
 Debian 10 (Buster) and Ubuntu 16.04 (Xenial) and later versions include a sufficiently recent version of Postgres.
index 57b79d2042311805b1b7ea909e17d2e6e7e8fcc4..f864f37563ba42b83cb8e7c54fbfcae2c425b3e6 100644 (file)
@@ -35,7 +35,7 @@ h2(#dependencies). Install Dependencies and SDKs
 
 # "Install Ruby and Bundler":ruby.html
 # "Install the Python SDK":{{site.baseurl}}/sdk/python/sdk-python.html
-# "Install the FUSE driver":{{site.baseurl}}/sdk/python/arvados-fuse.html
+# "Install the FUSE driver":{{site.baseurl}}/sdk/fuse/install.html
 # "Install the CLI":{{site.baseurl}}/sdk/cli/install.html
 # "Install the R SDK":{{site.baseurl}}/sdk/R/index.html (optional)
 # "Install Docker":install-docker.html (optional)
index 95254abdea3d311e36c685ec88e47c683ade5e4d..12b413d5d34862778ab63b3142c0c1b6c94fbd7d 100644 (file)
@@ -26,7 +26,7 @@ Arvados supports @webshell@, which allows ssh access to shell nodes via the brow
 
 h2(#prerequisites). Prerequisites
 
-# "Install workbench":{{site.baseurl}}/install/install-workbench-app.html
+# "Install Workbench 2":{{site.baseurl}}/install/install-workbench2-app.html
 # "Set up a shell node":{{site.baseurl}}/install/install-shell-server.html
 
 h2(#configure). Update config.yml
@@ -105,7 +105,7 @@ For additional shell nodes with @shell-in-a-box@, add @location@ and @upstream@
 
 h2(#config-shellinabox). Configure shellinabox
 
-h3. Red Hat and Centos
+h3. Alma/CentOS/Red Hat/Rocky
 
 Edit @/etc/sysconfig/shellinaboxd@:
 
diff --git a/doc/install/install-workbench-app.html.textile.liquid b/doc/install/install-workbench-app.html.textile.liquid
deleted file mode 100644 (file)
index 7ee8db9..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
----
-layout: default
-navsection: installguide
-title: Install Workbench
-...
-{% comment %}
-Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: CC-BY-SA-3.0
-{% endcomment %}
-
-# "Install dependencies":#dependencies
-# "Update config.yml":#update-config
-# "Update Nginx configuration":#update-nginx
-# "Trusted client flag":#trusted_client
-# "Install arvados-workbench":#install-packages
-# "Restart the API server and controller":#restart-api
-# "Confirm working installation":#confirm-working
-
-h2(#dependencies). Install dependencies
-
-# "Install Ruby and Bundler":ruby.html
-# "Install nginx":nginx.html
-# "Install Phusion Passenger":https://www.phusionpassenger.com/library/walkthroughs/deploy/ruby/ownserver/nginx/oss/install_passenger_main.html
-
-h2(#configure). Update config.yml
-
-Edit @config.yml@ to set the keys below.  The full set of configuration options are in the "Workbench section of config.yml":{{site.baseurl}}/admin/config.html
-
-<notextile>
-<pre><code>    Services:
-      Workbench1:
-        ExternalURL: <span class="userinput">"https://workbench.ClusterID.example.com"</span>
-    Workbench:
-      SecretKeyBase: <span class="userinput">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</span>
-    Users:
-      AutoAdminFirstUser: true
-</code></pre>
-</notextile>
-
-This application needs a secret token. Generate a new secret:
-
-<notextile>
-<pre><code>~$ <span class="userinput">ruby -e 'puts rand(2**400).to_s(36)'</span>
-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
-</code></pre>
-</notextile>
-
-Then put that value in the @Workbench.SecretKeyBase@ field.
-
-You probably want to enable @Users.AutoAdminFirstUser@ .  The first user to log in when no other admin user exists will automatically be made an admin.
-
-h2(#update-nginx). Update nginx configuration
-
-Use a text editor to create a new file @/etc/nginx/conf.d/arvados-workbench.conf@ with the following configuration.  Options that need attention are marked in <span class="userinput">red</span>.
-
-<notextile>
-<pre><code>server {
-    listen       80;
-    server_name  workbench.<span class="userinput">ClusterID.example.com</span>;
-    return 301   https://workbench.<span class="userinput">ClusterID.example.com</span>$request_uri;
-}
-
-server {
-  listen       443 ssl;
-  server_name  workbench.<span class="userinput">ClusterID.example.com</span>;
-
-  ssl_certificate     <span class="userinput">/YOUR/PATH/TO/cert.pem</span>;
-  ssl_certificate_key <span class="userinput">/YOUR/PATH/TO/cert.key</span>;
-
-  root /var/www/arvados-workbench/current/public;
-  index  index.html;
-
-  passenger_enabled on;
-  # If you're using RVM, uncomment the line below.
-  #passenger_ruby /usr/local/rvm/wrappers/default/ruby;
-
-  # `client_max_body_size` should match the corresponding setting in
-  # the API.MaxRequestSize and Controller's server's Nginx configuration.
-  client_max_body_size 128m;
-}
-</code></pre>
-</notextile>
-
-h2(#trusted_client). Trusted client flag
-
-In the <strong>API server</strong> project root, start the Rails console.  {% include 'install_rails_command' %}
-
-Create an ApiClient record for your Workbench installation with the @is_trusted@ flag set.
-
-<notextile><pre><code>irb(main):001:0&gt; <span class="userinput">include CurrentApiClient</span>
-=&gt; true
-irb(main):002:0&gt; <span class="userinput">act_as_system_user do ApiClient.create!(url_prefix: "https://workbench.ClusterID.example.com/", is_trusted: true) end</span>
-=&gt; #&lt;ApiClient id: 2, uuid: "...", owner_uuid: "...", modified_by_client_uuid: nil, modified_by_user_uuid: "...", modified_at: "2019-12-16 14:19:10", name: nil, url_prefix: "https://workbench.ClusterID.example.com/", created_at: "2019-12-16 14:19:10", updated_at: "2019-12-16 14:19:10", is_trusted: true&gt;
-</code></pre>
-</notextile>
-
-{% assign arvados_component = 'arvados-workbench' %}
-
-{% include 'install_packages' %}
-
-{% include 'restart_api' %}
-
-h2(#confirm-working). Confirm working installation
-
-Visit @https://workbench.ClusterID.example.com@ in a browser.  You should be able to log in using the login method you configured in the previous step.  If @Users.AutoAdminFirstUser@ is true, you will be an admin user.
index 63159611828268dc05b4fac99d4d243881571552..bbcbd7ef1d79377d9022e2f4f8b6d19b5c7356f3 100644 (file)
@@ -99,7 +99,7 @@ At the console, enter the following commands to locate the ApiClient record for
 =&gt; ["https://workbench.example.com/", Sat, 19 Apr 2014 03:35:12 UTC +00:00]
 irb(main):002:0&gt; <span class="userinput">include CurrentApiClient</span>
 =&gt; true
-irb(main):003:0&gt; <span class="userinput">act_as_system_user do wb.update_attributes!(is_trusted: true) end</span>
+irb(main):003:0&gt; <span class="userinput">act_as_system_user do wb.update!(is_trusted: true) end</span>
 =&gt; true
 </code></pre>
 </notextile>
index d86f3858b1396a005231f111cf4da412bd3b7399..7d97c3e38351cc506d7b447099ba1053c9dba8ce 100644 (file)
@@ -9,13 +9,19 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-h3. Centos 7
+h3. CentOS 7
 
 <notextile>
 <pre><code># <span class="userinput">yum install epel-release</span></code>
 <code># <span class="userinput">yum install nginx</span></code></pre>
 </notextile>
 
+h3. Alma/CentOS/Red Hat/Rocky 8
+
+<notextile>
+<pre><code># <span class="userinput">dnf install nginx</span></code></pre>
+</notextile>
+
 h3. Debian and Ubuntu
 
 <notextile>
index 49e3937006d055643df7ed596e033553cb092708..f867381cff81d7fcceb310bc7ea368bda5a408ed 100644 (file)
@@ -11,12 +11,24 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 
 On any host where you install Arvados software, you'll need to add the Arvados package repository.  They're available for several popular distributions.
 
-* "Centos 7":#centos7
+* "AlmaLinux, CentOS, RHEL, and Rocky Linux":#redhat
 * "Debian and Ubuntu":#debian
 
-h3(#centos7). CentOS
+<notextile>
+<a id="centos7" style="display: none;"></a>
+</notextile>
+
+h3(#redhat). AlmaLinux, CentOS, RHEL, and Rocky Linux
+
+Packages are available for the following Red Hat-based distributions:
+
+* AlmaLinux 8
+* CentOS 7
+* CentOS 8
+* RHEL 8
+* Rocky Linux 8
 
-Packages are available for CentOS 7. To install them with yum, save this configuration block in @/etc/yum.repos.d/arvados.repo@:
+To install them with dnf or yum, save this configuration block in @/etc/yum.repos.d/arvados.repo@:
 
 <notextile>
 <pre><code>[arvados]
index ae76c5b58deab73fbf30099e9958824dadcb55ef..a3cdd03300c0f9722611492175c216880301a888 100644 (file)
@@ -16,11 +16,12 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 # "Set up your infrastructure":#setup-infra
 ## "Create AWS infrastructure with Terraform":#terraform
 ## "Create required infrastructure manually":#inframanual
-# "Edit local.params":#localparams
+# "Edit local.params* files":#localparams
 # "Configure Keep storage":#keep
 # "Choose the SSL configuration":#certificates
 ## "Using a Let's Encrypt certificates":#lets-encrypt
 ## "Bring your own certificates":#bring-your-own
+### "Securing your TLS certificate keys":#secure-tls-keys
 # "Create a compute image":#create_a_compute_image
 # "Begin installation":#installation
 # "Further customization of the installation":#further_customization
@@ -29,6 +30,8 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 ## "Iterating on config changes":#iterating
 ## "Common problems and solutions":#common-problems
 # "Initial user and login":#initial_user
+# "Monitoring and Metrics":#monitoring
+# "Load balancing controllers":#load_balancing
 # "After the installation":#post_install
 
 h2(#introduction). Introduction
@@ -43,7 +46,7 @@ Choose a 5-character cluster identifier that will represent the cluster.  Here a
 
 Determine the base domain for the cluster.  This will be referred to as @${DOMAIN}@.
 
-For example, if CLUSTER is @xarv1@ and DOMAIN is @example.com@, then @controller.${CLUSTER}.${DOMAIN}@ means @controller.xarv1.example.com@.
+For example, if DOMAIN is @xarv1.example.com@, then @controller.${DOMAIN}@ means @controller.xarv1.example.com@.
 
 h3(#DNS). DNS hostnames for each service
 
@@ -51,17 +54,19 @@ You will need a DNS entry for each service.  When using the "Terraform script":#
 
 In the default configuration these are:
 
-# @controller.${CLUSTER}.${DOMAIN}@
-# @ws.${CLUSTER}.${DOMAIN}@
-# @keep0.${CLUSTER}.${DOMAIN}@
-# @keep1.${CLUSTER}.${DOMAIN}@
-# @keep.${CLUSTER}.${DOMAIN}@
-# @download.${CLUSTER}.${DOMAIN}@
-# @*.collections.${CLUSTER}.${DOMAIN}@  -- important note, this must be a wildcard DNS, resolving to the @keepweb@ service
-# @workbench.${CLUSTER}.${DOMAIN}@
-# @workbench2.${CLUSTER}.${DOMAIN}@
-# @webshell.${CLUSTER}.${DOMAIN}@
-# @shell.${CLUSTER}.${DOMAIN}@
+# @controller.${DOMAIN}@
+# @ws.${DOMAIN}@
+# @keep0.${DOMAIN}@
+# @keep1.${DOMAIN}@
+# @keep.${DOMAIN}@
+# @download.${DOMAIN}@
+# @*.collections.${DOMAIN}@  -- important note, this must be a wildcard DNS, resolving to the @keepweb@ service
+# @workbench.${DOMAIN}@
+# @workbench2.${DOMAIN}@
+# @webshell.${DOMAIN}@
+# @shell.${DOMAIN}@
+# @prometheus.${DOMAIN}@
+# @grafana.${DOMAIN}@
 
 For more information, see "DNS entries and TLS certificates":install-manual-prerequisites.html#dnstls.
 
@@ -95,21 +100,30 @@ The Terraform state files (that keep crucial infrastructure information from the
 
 h4. Terraform code configuration
 
-Each section described above contain a @terraform.tfvars@ file with some configuration values that you should set before applying each configuration. You should set the cluster prefix and domain name in @vpc/terraform.tfvars@:
+Each section described above contain a @terraform.tfvars@ file with some configuration values that you should set before applying each configuration. You should at least set the AWS region, cluster prefix and domain name in @terraform/vpc/terraform.tfvars@:
 
-<pre><code>region_name = "us-east-1"
-# cluster_name = "xarv1"
-# domain_name = "example.com"</code></pre>
+<pre><code>{% include 'terraform_vpc_tfvars' %}</code></pre>
+
+If you don't set the main configuration variables at @vpc/terraform.tfvars@ file, you will be asked to re-enter these parameters every time you run Terraform.
+
+The @data-storage/terraform.tfvars@ and @services/terraform.tfvars@ let you configure additional details, including the SSH public key for deployment, instance & volume sizes, etc. All these configurations are provided with sensible defaults:
+
+<pre><code>{% include 'terraform_datastorage_tfvars' %}</code></pre>
+
+<pre><code>{% include 'terraform_services_tfvars' %}</code></pre>
+
+h4. Set credentials
 
-If you don't set the variables @vpc/terraform.tfvars@ file, you will be asked to re-enter these parameters every time you run Terraform.
+You will need an AWS access key and secret key to create the infrastructure.
 
-The @data-storage/terraform.tfvars@ and @services/terraform.tfvars@ let you configure the location of your ssh public key (default @~/.ssh/id_rsa.pub@) and the instance type to use (default @m5a.large@).
+<pre><code class="userinput">export AWS_ACCESS_KEY_ID="anaccesskey"
+export AWS_SECRET_ACCESS_KEY="asecretkey"</code></pre>
 
 h4. Create the infrastructure
 
 Build the infrastructure by running @./installer.sh terraform@.  The last stage will output the information needed to set up the cluster's domain and continue with the installer. for example:
 
-<pre><code>$ ./installer.sh terraform
+<pre><code class="userinput">./installer.sh terraform
 ...
 Apply complete! Resources: 16 added, 0 changed, 0 destroyed.
 
@@ -117,10 +131,11 @@ Outputs:
 
 arvados_sg_id = "sg-02f999a99973999d7"
 arvados_subnet_id = "subnet-01234567abc"
+cluster_int_cidr = "10.1.0.0/16"
 cluster_name = "xarv1"
 compute_subnet_id = "subnet-abcdef12345"
 deploy_user = "admin"
-domain_name = "example.com"
+domain_name = "xarv1.example.com"
 letsencrypt_iam_access_key_id = "AKAA43MAAAWAKAADAASD"
 private_ip = {
   "controller" = "10.1.1.1"
@@ -145,7 +160,7 @@ route53_dns_ns = tolist([
   "ns-437.awsdns-54.com",
   "ns-809.awsdns-37.net",
 ])
-vpc_cidr = "10.1.0.0/16"
+ssl_password_secret_name = "xarv1-arvados-ssl-privkey-password"
 vpc_id = "vpc-0999994998399923a"
 letsencrypt_iam_secret_access_key = "XXXXXSECRETACCESSKEYXXXX"
 </code></pre>
@@ -157,7 +172,7 @@ Once Terraform has completed, the infrastructure for your Arvados cluster is up
 
 The domain names for your cluster (e.g.: controller.xarv1.example.com) are managed via "Route 53":https://aws.amazon.com/route53/ and the TLS certificates will be issued using "Let's Encrypt":https://letsencrypt.org/ .
 
-You need to configure the parent domain to delegate to the newly created zone.  In other words, you need to configure @${DOMAIN}@ (e.g. "example.com") to delegate the subdomain @${CLUSTER}.${DOMAIN}@ (e.g. "xarv1.example.com") to the nameservers for the Arvados hostname records created by Terraform.  You do this by creating a @NS@ record on the parent domain that refers to the name servers listed in the Terraform output parameter @route53_dns_ns@.
+You need to configure the parent domain to delegate to the newly created zone.  For example, you need to configure "example.com" to delegate the subdomain "xarv1.example.com" to the nameservers for the Arvados hostname records created by Terraform.  You do this by creating a @NS@ record on the parent domain that refers to the name servers listed in the Terraform output parameter @route53_dns_ns@.
 
 If your parent domain is also controlled by Route 53, the process will be like this:
 
@@ -175,11 +190,11 @@ h4. Other important output parameters
 
 The certificates will be requested from Let's Encrypt when you run the installer.
 
-* @vpc_cidr@ will be used to set @CLUSTER_INT_CIDR@
+* @cluster_int_cidr@ will be used to set @CLUSTER_INT_CIDR@
 
-* You'll also need @compute_subnet_id@ and @arvados_sg_id@ to set @DriverParameters.SubnetID@ and @DriverParameters.SecurityGroupIDs@ in @local_config_dir/pillars/arvados.sls@ and when you "create a compute image":#create_a_compute_image.
+* You'll also need @compute_subnet_id@ and @arvados_sg_id@ to set @COMPUTE_SUBNET@ and @COMPUTE_SG@ in @local.params@ and when you "create a compute image":#create_a_compute_image.
 
-You can now proceed to "edit local.params":#localparams.
+You can now proceed to "edit local.params* files":#localparams.
 
 h3(#inframanual). Create required infrastructure manually
 
@@ -214,21 +229,20 @@ The installer will set up the Arvados services on your machines.  Here is the de
 # API node
 ## postgresql server
 ## arvados api server
-## arvados controller  (recommendend hostname @controller.${CLUSTER}.${DOMAIN}@)
-## arvados websocket   (recommendend hostname @ws.${CLUSTER}.${DOMAIN}@)
+## arvados controller  (recommendend hostname @controller.${DOMAIN}@)
+# KEEPSTORE nodes (at least 1 if using S3 as a Keep backend, else 2)
+## arvados keepstore   (recommendend hostnames @keep0.${DOMAIN}@ and @keep1.${DOMAIN}@)
+# WORKBENCH node
+## arvados legacy workbench URLs   (recommendend hostname @workbench.${DOMAIN}@)
+## arvados workbench2              (recommendend hostname @workbench2.${DOMAIN}@)
+## arvados webshell                (recommendend hostname @webshell.${DOMAIN}@)
+## arvados websocket               (recommendend hostname @ws.${DOMAIN}@)
 ## arvados cloud dispatcher
 ## arvados keepbalance
-# KEEPSTORE nodes (at least 2)
-## arvados keepstore   (recommendend hostnames @keep0.${CLUSTER}.${DOMAIN}@ and @keep1.${CLUSTER}.${DOMAIN}@)
-# KEEPPROXY node
-## arvados keepproxy   (recommendend hostname @keep.${CLUSTER}.${DOMAIN}@)
-## arvados keepweb     (recommendend hostname @download.${CLUSTER}.${DOMAIN}@ and @*.collections.${CLUSTER}.${DOMAIN}@)
-# WORKBENCH node
-## arvados workbench   (recommendend hostname @workbench.${CLUSTER}.${DOMAIN}@)
-## arvados workbench2  (recommendend hostname @workbench2.${CLUSTER}.${DOMAIN}@)
-## arvados webshell    (recommendend hostname @webshell.${CLUSTER}.${DOMAIN}@)
+## arvados keepproxy   (recommendend hostname @keep.${DOMAIN}@)
+## arvados keepweb     (recommendend hostname @download.${DOMAIN}@ and @*.collections.${DOMAIN}@)
 # SHELL node  (optional)
-## arvados shell       (recommended hostname @shell.${CLUSTER}.${DOMAIN}@)
+## arvados shell       (recommended hostname @shell.${DOMAIN}@)
 
 When using the database installed by Arvados (and not an "external database":#ext-database), the database is stored under @/var/lib/postgresql@.  Arvados logs are also kept in @/var/log@ and @/var/www/arvados-api/shared/log@.  Accordingly, you should ensure that the disk partition containing @/var@ has adequate storage for your planned usage.  We suggest starting with 50GiB of free space on the database host.
 
@@ -246,24 +260,30 @@ This usually means adding the account to the @sudo@ group and having a rule like
 
 If your infrastructure differs from the setup proposed above (ie, different hostnames), you can still use the installer, but "additional customization may be necessary":#further_customization .
 
-h2(#localparams). Edit @local.params@
+h2(#localparams). Edit @local.params*@ files
+
+The cluster configuration parameters are included in two files: @local.params@ and @local.params.secrets@. These files can be found wherever you choose to initialize the installation files (e.g., @~/setup-arvados-xarv1@ in these examples).
 
-This can be found wherever you choose to initialize the install files (@~/setup-arvados-xarv1@ in these examples).
+The @local.params.secrets@ file is intended to store security-sensitive data such as passwords, private keys, tokens, etc. Depending on the security requirements of the cluster deployment, you may wish to store this file in a secrets store like AWS Secrets Manager or Jenkins credentials.
 
-# Set @CLUSTER@ to the 5-character cluster identifier (e.g "xarv1")
-# Set @DOMAIN@ to the base DNS domain of the environment, e.g. "example.com"
+h3. Parameters from @local.params@:
+
+# Set @CLUSTER@ to the 5-character cluster identifier. (e.g. "xarv1")
+# Set @DOMAIN@ to the base DNS domain of the environment. (e.g. "xarv1.example.com")
 # Set the @*_INT_IP@ variables with the internal (private) IP addresses of each host. Since services share hosts, some hosts are the same.  See "note about /etc/hosts":#etchosts
-# Edit @CLUSTER_INT_CIDR@, this should be the CIDR of the private network that Arvados is running on, e.g. the VPC.
-CIDR stands for "Classless Inter-Domain Routing" and describes which portion of the IP address that refers to the network.  For example 192.168.3.0/24 means that the first 24 bits are the network (192.168.3) and the last 8 bits are a specific host on that network.
+# Edit @CLUSTER_INT_CIDR@, this should be the CIDR of the private network that Arvados is running on, e.g. the VPC.  If you used terraform, this is emitted as @cluster_int_cidr@.
+_CIDR stands for "Classless Inter-Domain Routing" and describes which portion of the IP address that refers to the network.  For example 192.168.3.0/24 means that the first 24 bits are the network (192.168.3) and the last 8 bits are a specific host on that network._
 _AWS Specific: Go to the AWS console and into the VPC service, there is a column in this table view of the VPCs that gives the CIDR for the VPC (IPv4 CIDR)._
 # Set @INITIAL_USER_EMAIL@ to your email address, as you will be the first admin user of the system.
+
+h3. Parameters from @local.params.secrets@:
+
 # Set each @KEY@ / @TOKEN@ / @PASSWORD@ to a random string.  You can use @installer.sh generate-tokens@
-<pre><code>$ ./installer.sh generate-tokens
+<pre><code class="userinput">./installer.sh generate-tokens
 BLOB_SIGNING_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
 MANAGEMENT_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
 SYSTEM_ROOT_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
 ANONYMOUS_USER_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
-WORKBENCH_SECRET_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
 DATABASE_PASSWORD=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
 </code></pre>
 # Set @DATABASE_PASSWORD@ to a random string (unless you "already have a database":#ext-database then you should set it to that database's password)
@@ -271,6 +291,13 @@ DATABASE_PASSWORD=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    For example, if the password is @Lq&MZ<V']d?j@
    With backslash quoting the special characters it should appear like this in local.params:
 <pre><code>DATABASE_PASSWORD="Lq\&MZ\<V\'\]d\?j"</code></pre>
+# Set @DISPATCHER_SSH_PRIVKEY@ to a SSH private key that @arvados-dispatch-cloud@ will use to connect to the compute nodes:
+<pre><code>DISPATCHER_SSH_PRIVKEY="-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
+...
+s4VY40kNxs6MsAAAAPbHVjYXNAaW5zdGFsbGVyAQIDBA==
+-----END OPENSSH PRIVATE KEY-----"
+</code></pre>You can create one by following the steps described on the "building a compute node documentation":{{site.baseurl}}/install/crunch2-cloud/install-compute-node.html#sshkeypair page.
 
 h3(#etchosts). Note on @/etc/hosts@
 
@@ -286,16 +313,14 @@ The @multi_host/aws@ template uses S3 for storage.  Arvados also supports "files
 
 h3. Object storage in S3 (AWS Specific)
 
-Open @local_config_dir/pillars/arvados.sls@ and edit as follows:
-
-# In the @arvados.cluster.Volumes.DriverParameters@ section, set @Region@ to the appropriate AWS region (e.g. 'us-east-1')
+If you "followed the recommendend naming scheme":#keep-bucket for both the bucket and role (or used the provided Terraform script), you're done.
 
-If "followed the recommendend naming scheme":#keep-bucket for both the bucket and role (or used the provided Terraform script), you're done.
+If you did not follow the recommendend naming scheme for either the bucket or role, you'll need to update these parameters in @local.params@:
 
-If you did not follow the recommendend naming scheme for either the bucket or role, you'll need to update these parameters as well:
+# Set @KEEP_AWS_S3_BUCKET@ to the value of "keepstore bucket you created earlier":#keep-bucket
+# Set @KEEP_AWS_IAM_ROLE@ to "keepstore role you created earlier":#keep-bucket
 
-# Set @Bucket@ to the value of "keepstore bucket you created earlier":#keep-bucket
-# Set @IAMRole@ to "keepstore role you created earlier":#keep-bucket
+You can also configure a specific AWS Region for the S3 bucket by setting @KEEP_AWS_REGION@.
 
 {% include 'ssl_config_multi' %}
 
@@ -311,15 +336,17 @@ Arvados requires a database that is compatible with PostgreSQL 9.5 or later.  Fo
 
 # In @local.params@, remove 'database' from the list of roles assigned to the controller node:
 <pre><code>NODES=(
-  [controller.${CLUSTER}.${DOMAIN}]=api,controller,websocket,dispatcher,keepbalance
+  [controller.${DOMAIN}]=controller,websocket,dispatcher,keepbalance
   ...
 )
 </code></pre>
-# In @local.params@, set @DATABASE_INT_IP@ to the database endpoint (can be a hostname, does not have to be an IP address).
-<pre><code>DATABASE_INT_IP=...
+# In @local.params@, set @DATABASE_INT_IP@ to empty string and @DATABASE_EXTERNAL_SERVICE_HOST_OR_IP@ to the database endpoint (can be a hostname, does not have to be an IP address).
+<pre><code>DATABASE_INT_IP=""
+...
+DATABASE_EXTERNAL_SERVICE_HOST_OR_IP="arvados.xxxxxxx.eu-east-1.rds.amazonaws.com"
 </code></pre>
-# In @local.params@, set @DATABASE_PASSWORD@ to the correct value.  "See the previous section describing correct quoting":#localparams
-# In @local_config_dir/pillars/arvados.sls@ you may need to adjust the database name and user.  This can be found in the section @arvados.cluster.database@.
+# In @local.params.secrets@, set @DATABASE_PASSWORD@ to the correct value.  "See the previous section describing correct quoting":#localparams
+# In @local.params@ you may need to adjust the database name and user.
 
 h2(#further_customization). Further customization of the installation (optional)
 
@@ -343,16 +370,14 @@ Follow "the instructions to build a cloud compute node image":{{site.baseurl}}/i
 
 h3. Configure the compute image
 
-Once the image has been created, open @local_config_dir/pillars/arvados.sls@ and edit as follows (AWS specific settings described here, other cloud providers will have similar settings in their respective configuration section):
+Once the image has been created, open @local.params@ and edit as follows (AWS specific settings described here, you will need to make custom changes for other cloud providers):
 
-# In the @arvados.cluster.Containers.CloudVMs@ section:
-## Set @ImageID@ to the AMI produced by Packer
-## Set @DriverParameters.Region@ to the appropriate AWS region
-## Set @DriverParameters.AdminUsername@ to the admin user account on the image
-## Set the @DriverParameters.SecurityGroupIDs@ list to the VPC security group which you set up to allow SSH connections to these nodes
-## Set @DriverParameters.SubnetID@ to the value of SubnetId of your VPC
-# Update @arvados.cluster.Containers.DispatchPrivateKey@ and paste the contents of the @~/.ssh/id_dispatcher@ file you generated in an earlier step.
-# Update @arvados.cluster.InstanceTypes@ as necessary.  The example instance types are for AWS, other cloud providers will of course have different instance types with different names and specifications.
+# Set @COMPUTE_AMI@ to the AMI produced by Packer
+# Set @COMPUTE_AWS_REGION@ to the appropriate AWS region
+# Set @COMPUTE_USER@ to the admin user account on the image
+# Set the @COMPUTE_SG@ list to the VPC security group which you set up to allow SSH connections to these nodes
+# Set @COMPUTE_SUBNET@ to the value of SubnetId of your VPC
+# Update @arvados.cluster.InstanceTypes@ in @local_config_dir/pillars/arvados.sls@ as necessary.  The example instance types are for AWS, other cloud providers will of course have different instance types with different names and specifications.
 (AWS specific) If m5/c5 node types are not available, replace them with m4/c4. You'll need to double check the values for Price and IncludedScratch/AddedScratch for each type that is changed.
 
 h2(#installation). Begin installation
@@ -361,9 +386,7 @@ At this point, you are ready to run the installer script in deploy mode that wil
 
 Run this in the @~/arvados-setup-xarv1@ directory:
 
-<pre>
-./installer.sh deploy
-</pre>
+<pre><code class="userinput">./installer.sh deploy</code></pre>
 
 This will install and configure Arvados on all the nodes.  It will take a while and produce a lot of logging.  If it runs into an error, it will stop.
 
@@ -377,9 +400,7 @@ If you are running the diagnostics from one of the Arvados machines inside the p
 
 You are an "external client" if you running the diagnostics from your workstation outside of the private network.
 
-<pre>
-./installer.sh diagnostics (-internal-client|-external-client)
-</pre>
+<pre><code class="userinput">./installer.sh diagnostics (-internal-client|-external-client)</code></pre>
 
 h3(#debugging). Debugging issues
 
@@ -387,13 +408,7 @@ The installer records log files for each deployment.
 
 Most service logs go to @/var/log/syslog@.
 
-The logs for Rails API server and for Workbench can be found in
-
-@/var/www/arvados-api/current/log/production.log@
-and
-@/var/www/arvados-workbench/current/log/production.log@
-
-on the appropriate instances.
+The logs for Rails API server can be found in @/var/www/arvados-api/current/log/production.log@ on the appropriate instance(s).
 
 Workbench 2 is a client-side Javascript application.  If you are having trouble loading Workbench 2, check the browser's developer console (this can be found in "Tools &rarr; Developer Tools").
 
@@ -403,9 +418,7 @@ You can iterate on the config and maintain the cluster by making changes to @loc
 
 If you are debugging a configuration issue on a specific node, you can speed up the cycle a bit by deploying just one node:
 
-<pre>
-./installer.sh deploy keep0.xarv1.example.com@
-</pre>
+<pre><code class="userinput">./installer.sh deploy keep0.xarv1.example.com</code></pre>
 
 However, once you have a final configuration, you should run a full deploy to ensure that the configuration has been synchronized on all the nodes.
 
@@ -426,7 +439,7 @@ If this happens, you need to
 1. correct the database information
 2. run @./installer.sh deploy xarv1.example.com@ to update the configuration on the API/controller node
 3. Log in to the API/controller server node, then run this command to re-run the post-install script, which will set up the database:
-<pre>dpkg-reconfigure arvados-api-server</pre>
+<pre><code class="userinput">dpkg-reconfigure arvados-api-server</code></pre>
 4. Re-run @./installer.sh deploy@ again to synchronize everything, and so that the install steps that need to contact the API server are run successfully.
 
 h4. Missing ENA support (AWS Specific)
@@ -437,12 +450,73 @@ h2(#initial_user). Initial user and login
 
 At this point you should be able to log into the Arvados cluster. The initial URL will be
 
-https://workbench.@${CLUSTER}.${DOMAIN}@
+@https://workbench.${DOMAIN}@
 
-If you did *not* "configure a different authentication provider":#authentication you will be using the "Test" provider, and the provision script creates an initial user for testing purposes. This user is configured as administrator of the newly created cluster.  It uses the values of @INITIAL_USER@ and @INITIAL_USER_PASSWORD@ the @local.params@ file.
+If you did *not* "configure a different authentication provider":#authentication you will be using the "Test" provider, and the provision script creates an initial user for testing purposes. This user is configured as administrator of the newly created cluster.  It uses the values of @INITIAL_USER@ and @INITIAL_USER_PASSWORD@ from the @local.params*@ file.
 
 If you *did* configure a different authentication provider, the first user to log in will automatically be given Arvados admin privileges.
 
+h2(#monitoring). Monitoring and Metrics
+
+You can monitor the health and performance of the system using the admin dashboard:
+
+@https://grafana.${DOMAIN}@
+
+To log in, use username "admin" and @${INITIAL_USER_PASSWORD}@ from @local.params.secrets@.
+
+Once logged in, you will want to add the dashboards to the front page.
+
+# On the left icon bar, click on "Browse"
+# You should see a folder called "Arvados Cluster", click to open it
+## If you don't see anything, make sure the check box next to "Starred" is not selected
+# You should see three dashboards "Arvados cluster overview", "Node exporter" and "Postgres exporter"
+# Visit each dashboard, at the top of the page click on the star next to the title to "Mark as favorite"
+# They should now be linked on the front page.
+
+h2(#load_balancing). Load balancing controllers (optional)
+
+In order to handle high loads and perform rolling upgrades, the controller service can be scaled to a number of hosts and the installer make this implementation a fairly simple task.
+
+First, you should take care of the infrastructure deployment: if you use our Terraform code, you will need to set up the @terraform.tfvars@ in @terraform/vpc/@ so that in addition to the node named @controller@ (the load-balancer), a number of @controllerN@ nodes (backends) are defined as needed, and added to the @internal_service_hosts@ list.
+
+We suggest that the backend nodes just hold the controller service and nothing else, so they can be easily created or destroyed as needed without other service disruption.
+
+The following is an example @terraform/vpc/terraform.tfvars@ file that describes a cluster with a load-balancer, 2 backend nodes, a separate database node, a shell node, a keepstore node and a workbench node that will also hold other miscelaneous services:
+
+<pre><code>region_name = "us-east-1"
+cluster_name = "xarv1"
+domain_name = "xarv1.example.com"
+# Include controller nodes in this list so instances are assigned to the
+# private subnet. Only the balancer node should be connecting to them.
+internal_service_hosts = [ "keep0", "shell", "database", "controller1", "controller2" ]
+
+# Assign private IPs for the controller nodes. These will be used to create
+# internal DNS resolutions that will get used by the balancer and database nodes.
+private_ip = {
+  controller = "10.1.1.11"
+  workbench = "10.1.1.15"
+  database = "10.1.2.12"
+  controller1 = "10.1.2.21"
+  controller2 = "10.1.2.22"
+  shell = "10.1.2.17"
+  keep0 = "10.1.2.13"
+}</code></pre>
+
+Once the infrastructure is deployed, you'll then need to define which node will be using the @balancer@ role and which will be the @controller@ nodes in @local.params@, as it's being shown in this partial example:
+
+<pre><code>NODES=(
+  [controller.${DOMAIN}]=balancer
+  [controller1.${DOMAIN}]=controller
+  [controller2.${DOMAIN}]=controller
+  [database.${DOMAIN}]=database
+  ...
+)
+</code></pre>
+
+Note that we also set the @database@ role to its own node instead of just leaving it in a shared controller node.
+
+Each time you run @installer.sh deploy@, the system will automatically do rolling upgrades. This means it will make changes to one controller node at a time, after removing it from the balancer so that there's no downtime.
+
 h2(#post_install). After the installation
 
 As part of the operation of @installer.sh@, it automatically creates a @git@ repository with your configuration templates.  You should retain this repository but *be aware that it contains sensitive information* (passwords and tokens used by the Arvados services as well as cloud credentials if you used Terraform to create the infrastructure).
index 28a03a9c553ae37ab51df01c2a7e575b344b41f7..92c1aa2645b6493bc14e83bd1503d0531e050682 100644 (file)
@@ -12,7 +12,7 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 # "Limitations of the single host install":#limitations
 # "Prerequisites and planning":#prerequisites
 # "Download the installer":#download
-# "Edit local.params":#localparams
+# "Edit local.params* files":#localparams
 # "Choose the SSL configuration":#certificates
 ## "Using a self-signed certificate":#self-signed
 ## "Using a Let's Encrypt certificate":#lets-encrypt
@@ -48,7 +48,7 @@ Determine if you will use a single hostname, or multiple hostnames.
 
 If you are using multiple hostnames, determine the base domain for the cluster.  This will be referred to as @${DOMAIN}@.
 
-For example, if CLUSTER is @xarv1@ and DOMAIN is @example.com@, then @controller.${CLUSTER}.${DOMAIN}@" means @controller.xarv1.example.com@.
+For example, if CLUSTER is @xarv1@ and DOMAIN is @example.com@, then @controller.${CLUSTER}.${DOMAIN}@ means @controller.xarv1.example.com@.
 
 h3. Machine specification
 
@@ -80,6 +80,8 @@ In the default configuration these are:
 # @workbench2.${CLUSTER}.${DOMAIN}@
 # @webshell.${CLUSTER}.${DOMAIN}@
 # @shell.${CLUSTER}.${DOMAIN}@
+# @prometheus.${CLUSTER}.${DOMAIN}@
+# @grafana.${CLUSTER}.${DOMAIN}@
 
 This is described in more detail in "DNS entries and TLS certificates":install-manual-prerequisites.html#dnstls.
 
@@ -102,15 +104,22 @@ h2(#download). Download the installer
 
 If you are using multiple hostname configuration, substitute 'multiple_hostnames' where it says 'single_hostname' in the command above.
 
-h2(#localparams). Edit @local.params@
+h2(#localparams). Edit @local.params*@ files
 
-This can be found wherever you choose to initialize the install files (@~/setup-arvados-xarv1@ in these examples).
+The cluster configuration parameters are included in two files: @local.params@ and @local.params.secrets@. These files can be found wherever you choose to initialize the installation files (e.g., @~/setup-arvados-xarv1@ in these examples).
+
+The @local.params.secrets@ file is intended to store security-sensitive data such as passwords, private keys, tokens, etc. Depending on the security requirements of the cluster deployment, you may wish to store this file in a secrets store like AWS Secrets Manager or Jenkins credentials.
+
+h3. Parameters from @local.params@:
 
 # Set @CLUSTER@ to the 5-character cluster identifier (e.g "xarv1")
 # Set @DOMAIN@ to the base DNS domain of the environment, e.g. "example.com"
 # Single hostname only: set @IP_INT@ to the host's IP address.
 # Single hostname only: set @HOSTNAME_EXT@ to the hostname that users will use to connect.
 # Set @INITIAL_USER_EMAIL@ to your email address, as you will be the first admin user of the system.
+
+h3. Parameters from @local.params.secrets@:
+
 # Set each @KEY@ / @TOKEN@ to a random string
        Here's an easy way to create five random tokens:
 <pre><code>for i in 1 2 3 4 5; do
@@ -122,7 +131,7 @@ done
    For example, if the password is @Lq&MZ<V']d?j@
    With backslash quoting the special characters it should appear like this in local.params:
 <pre><code>DATABASE_PASSWORD="Lq\&MZ\<V\'\]d\?j"</code></pre>
-
+# Set @DISPATCHER_SSH_PRIVKEY@ to @"no"@, as it isn't needed.
 {% include 'ssl_config_single' %}
 
 h2(#authentication). Configure your authentication provider (optional, recommended)
@@ -181,13 +190,7 @@ The installer records log files for each deployment.
 
 Most service logs go to @/var/log/syslog@.
 
-The logs for Rails API server and for Workbench can be found in
-
-@/var/www/arvados-api/current/log/production.log@
-and
-@/var/www/arvados-workbench/current/log/production.log@
-
-on the appropriate instances.
+The logs for Rails API server can be found in @/var/www/arvados-api/current/log/production.log@ on the appropriate instance.
 
 Workbench 2 is a client-side Javascript application.  If you are having trouble loading Workbench 2, check the browser's developer console (this can be found in "Tools &rarr; Developer Tools").
 
@@ -229,6 +232,24 @@ If you did *not* "configure a different authentication provider":#authentication
 
 If you *did* configure a different authentication provider, the first user to log in will automatically be given Arvados admin privileges.
 
+h2(#monitoring). Monitoring and Metrics
+
+You can monitor the health and performance of the system using the admin dashboard.
+
+For the multi-hostname install, it will be:
+
+https://grafana.@${CLUSTER}.${DOMAIN}@
+
+To log in, use username "admin" and @${INITIAL_USER_PASSWORD}@ from @local.conf@.
+
+Once logged in, you will want to add the dashboards to the front page.
+
+# On the left icon bar, click on "Browse"
+# If the check box next to "Starred" is selected, click on it to de-select it
+# You should see a folder with "Arvados cluster overview", "Node exporter" and "Postgres exporter"
+# You can visit each dashboard and click on the star next to the title to "Mark as favorite"
+# They should now be linked on the front page.
+
 h2(#post_install). After the installation
 
 As part of the operation of @installer.sh@, it automatically creates a @git@ repository with your configuration templates.  You should retain this repository but be aware that it contains sensitive information (passwords and tokens used by the Arvados services).
index 19a2cd5100550e297a62eb57a6ceeb64c16ef0ca..0e400759c0c6c4ecd340d66ce23cf19d5366d596 100644 (file)
@@ -84,7 +84,7 @@ To access your Arvados instance using command line clients (such as arv-get and
 </code></pre>
 </notextile>
 
-* On CentOS:
+* On Alma/CentOS/Red Hat/Rocky:
 
 <notextile>
 <pre><code>cp arvados-root-cert.pem /etc/pki/ca-trust/source/anchors/
index 21b986fb89746600ad2647e4246881edb19c0b48..a9991f642e6b048d52cf03c4dc6c04ea239b6c0f 100644 (file)
@@ -45,7 +45,11 @@ Use the <a href="https://console.developers.google.com" target="_blank">Google D
 
 h2(#oidc). OpenID Connect
 
-With this configuration, users will sign in with a third-party OpenID Connect provider. The provider will supply appropriate values for the issuer URL, client ID, and client secret config entries.
+With this configuration, users will sign in with a third-party OpenID Connect provider such as GitHub, Auth0, Okta, or PingFederate.
+
+Similar to the Google login section above, you will need to register your Arvados cluster with the provider as an application (relying party). When asked for a redirect URL or callback URL, use @https://ClusterID.example.com/login@ (the external URL of your controller service, plus @/login@).
+
+The provider will supply an issuer URL, client ID, and client secret. Add these to your Arvados configuration.
 
 {% codeblock as yaml %}
     Login:
@@ -56,6 +60,24 @@ With this configuration, users will sign in with a third-party OpenID Connect pr
         ClientSecret: "zzzzzzzzzzzzzzzzzzzzzzzz"
 {% endcodeblock %}
 
+h3. Accepting OpenID bearer tokens as Arvados API tokens
+
+Arvados can also be configured to accept provider-issued access tokens as Arvados API tokens by setting @Login.OpenIDConnect.AcceptAccessToken@ to @true@. This can be useful for integrating third party applications.
+
+{% codeblock as yaml %}
+    Login:
+      OpenIDConnect:
+        AcceptAccessToken: true
+        AcceptAccessTokenScope: "arvados"
+{% endcodeblock %}
+
+# If the provider-issued tokens are JWTs, and @Login.OpenIDConnect.AcceptAccessTokenScope@ is not empty, Arvados will check that the token contains the configured scope, and reject tokens that do not have the configured scope.  This can be used to control which users or applications are permitted to access your Arvados instance.
+# Tokens are validated by presenting them to the UserInfo endpoint advertised by the OIDC provider.
+# Once validated, a token is cached and accepted without re-checking for up to 10 minutes.
+# A token that fails validation is cached and will not be re-checked for up to 5 minutes.
+# Network errors and HTTP 5xx responses from the provider's UserInfo endpoint are not cached.
+# The OIDC token cache size is currently limited to 1000 tokens, if the number of distinct tokens used in a 5 minute period is greater than this, tokens may be checked more frequently.
+
 Check the OpenIDConnect section in the "default config file":{{site.baseurl}}/admin/config.html for more details and configuration options.
 
 h2(#ldap). LDAP
index 9e08b56e6f9827de49a61f1955bdecf82f9df7a5..b3e5d6975c2642fd93b4a80769095f14e5d6b8d6 100644 (file)
@@ -68,6 +68,12 @@ The banner appears when a user loads workbench and have not yet viewed the curre
 
 The banner text (HTML formatted) is loaded from the file @banner.html@ in the collection provided in @BannerUUID@.
 
+The following HTML tags are allowed in banner.html: a, b, blockquote, br, code, del, dd, dl, dt, em, h1-h6, hr, i, img, kbd, li, ol, p, pre, s, del, section, span, strong, sub, sup, and ul. 
+
+The following attributes are allowed: src, width, height, href, alt, title, and style. 
+
+All styling must be made in-line with the style attribute. Disallowed tags and attributes will not render.
+
 h3. Tooltips
 
 You can provide a custom tooltip overlay to provide site-specific guidance for using workbench.  Users can opt-out by selecting *Disable Tooltips* from the *Notifications* menu.
diff --git a/doc/pysdk_pdoc.py b/doc/pysdk_pdoc.py
new file mode 100755 (executable)
index 0000000..b246a83
--- /dev/null
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+"""pysdk_pdoc.py - Run pdoc with extra rendering options
+
+This script is a wrapper around the standard `pdoc` tool that enables the
+`admonitions` and `smarty-pants` extras for nicer rendering. It checks that
+the version of `markdown2` included with `pdoc` supports those extras.
+
+If run without arguments, it uses arguments to build the Arvados Python SDK
+documentation.
+"""
+
+import collections
+import functools
+import os
+import sys
+
+try:
+    import pdoc.__main__
+    import pdoc.markdown2
+    import pdoc.render_helpers
+except ImportError as err:
+    if __name__ == '__main__':
+        _imp_err = err
+    else:
+        raise
+else:
+    _imp_err = None
+
+DEFAULT_ARGLIST = [
+    '--output-directory=sdk/python',
+    '../sdk/python/build/lib/arvados/',
+]
+MD_EXTENSIONS = {
+    'admonitions': None,
+    'smarty-pants': None,
+}
+
+def main(arglist=None):
+    if _imp_err is not None:
+        print("error: failed to import pdoc:", _imp_err, file=sys.stderr)
+        return os.EX_SOFTWARE
+    # Ensure markdown2 is new enough to support our desired extras.
+    elif pdoc.markdown2.__version_info__ < (2, 4, 3):
+        print("error: need markdown2>=2.4.3 to render admonitions", file=sys.stderr)
+        return os.EX_SOFTWARE
+
+    # Configure pdoc to use extras we want.
+    pdoc.render_helpers.markdown_extensions = collections.ChainMap(
+        pdoc.render_helpers.markdown_extensions,
+        MD_EXTENSIONS,
+    )
+    pdoc.__main__.cli(arglist)
+    return os.EX_OK
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv[1:] or DEFAULT_ARGLIST))
index 511a41e0b82043dd1197e4f0fc9aa902e35f4a71..ea10c830bc44006363d6270e91cb9bf951b40c38 100644 (file)
@@ -1,7 +1,7 @@
 ---
 layout: default
 navsection: sdk
-navmenu: CLI
+navmenu: Command line tools (CLI SDK)
 title: "Overview"
 
 ...
index 9657d236addf3c2dd89d154ac9dd28b801cfd064..e0d50b874b9251ea3074e31a3f91538bb282f4ab 100644 (file)
@@ -1,7 +1,7 @@
 ---
 layout: default
 navsection: sdk
-navmenu: CLI
+navmenu: Command line tools (CLI SDK)
 title: "Installation"
 ...
 {% comment %}
index 735ba5ca8719af5b39fb876bfde9e4b1a45f9ecb..307fecd9a045e6708902b4ac67fecadebd20f3bb 100644 (file)
@@ -1,7 +1,7 @@
 ---
 layout: default
 navsection: sdk
-navmenu: CLI
+navmenu: Command line tools (CLI SDK)
 title: "arv reference"
 ...
 {% comment %}
index 5dda77ab5ee65cdf3700be3404f53455c0c25f28..dadb1d56c728404a9a8134478a60961d2966696b 100644 (file)
@@ -1,7 +1,7 @@
 ---
 layout: default
 navsection: sdk
-navmenu: CLI
+navmenu: Command line tools (CLI SDK)
 title: "arv subcommands"
 
 ...
diff --git a/doc/sdk/fuse/install.html.textile.liquid b/doc/sdk/fuse/install.html.textile.liquid
new file mode 100644 (file)
index 0000000..52ffb2b
--- /dev/null
@@ -0,0 +1,42 @@
+---
+layout: default
+navsection: sdk
+navmenu: FUSE Driver
+title: Installing the FUSE Driver
+...
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+The Arvados FUSE driver is a Python utility that allows you to browse Arvados projects and collections in a filesystem, so you can access that data using existing Unix tools.
+
+h2. Installation
+
+If you are logged in to a managed Arvados VM, the @arv-mount@ utility should already be installed.
+
+To use the FUSE driver elsewhere, you can install from a distribution package or pip.
+
+h2. Option 1: Install from distribution packages
+
+First, "add the appropriate package repository for your distribution":{{ site.baseurl }}/install/packages.html.
+
+{% assign arvados_component = 'python3-arvados-fuse' %}
+
+{% include 'install_packages' %}
+
+h2. Option 2: Install with pip
+
+Run @pip install arvados_fuse@ in an appropriate installation environment, such as a virtualenv.
+
+Note: The FUSE driver depends on the @libcurl@ and @libfuse@ C libraries.  To install the module you may need to install development headers from your distribution.  On Debian-based distributions you can install them by running:
+
+<notextile>
+<pre><code># <span class="userinput">apt install build-essential python3-dev libcurl4-openssl-dev libfuse-dev libssl-dev</span>
+</code></pre>
+</notextile>
+
+h2. Usage
+
+For an introduction of how to mount and navigate data, refer to the "Access Keep as a GNU/Linux filesystem":{{site.baseurl}}/user/tutorials/tutorial-keep-mount-gnu-linux.html tutorial.
diff --git a/doc/sdk/fuse/options.html.textile.liquid b/doc/sdk/fuse/options.html.textile.liquid
new file mode 100644 (file)
index 0000000..1ebfa24
--- /dev/null
@@ -0,0 +1,193 @@
+---
+layout: default
+navsection: sdk
+navmenu: FUSE Driver
+title: arv-mount options
+...
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+This page documents all available @arv-mount@ options with some usage examples.
+
+# "Mount contents":#contents
+# "Mount custom layout and filtering":#layout
+## "@--filters@ usage and limitations":#filters
+# "Mount access and permissions":#access
+# "Mount lifecycle management":#lifecycle
+# "Mount logging and statistics":#logging
+# "Mount local cache setup":#cache
+# "Mount interactions with Arvados and Linux":#plumbing
+# "Examples":#examples
+## "Using @--exec@":#exec
+## "Running arv-mount as a systemd service":#systemd
+
+h2(#contents). Mount contents
+
+table(table table-bordered table-condensed).
+|_. Option(s)|_. Description|
+|@--all@|Mount a subdirectory for each mode: @home@, @shared@, @by_id@, and @by_tag@ (default if no @--mount-*@ options are given)|
+|@--custom@|Mount a subdirectory for each mode specified by a @--mount-*@ option (default if any @--mount-*@ options are given; see "Mount custom layout and filtering":#layout section)|
+|@--collection UUID_OR_PDH@|Mount the specified collection|
+|@--home@|Mount your home project|
+|@--project UUID@|Mount the specified project|
+|@--shared@|Mount a subdirectory for each project shared with you|
+|@--by-id@|Mount a magic directory where collections and projects are accessible through subdirectories named after their UUID or portable data hash|
+|@--by-pdh@|Mount a magic directory where collections are accessible through subdirectories named after their portable data hash|
+|@--by-tag@|Mount a subdirectory for each tag attached to a collection or project|
+
+h2(#layout). Mount custom layout and filtering
+
+table(table table-bordered table-condensed).
+|_. Option(s)|_. Description|
+|@--filters FILTERS@|Filters to apply to all project, shared, and tag directory contents. Pass filters as either a JSON string or a path to a JSON file. The JSON object should be a list of filters in "Arvados API list filter syntax":{{ site.baseurl }}/api/methods.html#filters. See the "example filters":#filters.|
+|@--mount-home PATH@|Make your home project available under the mount at @PATH@|
+|@--mount-shared PATH@|Make projects shared with you available under the mount at @PATH@|
+|@--mount-tmp PATH@|Make a new temporary writable collection available under the mount at @PATH@. This collection is deleted when the mount is unmounted.|
+|@--mount-by-id PATH@|Make a magic directory available under the mount at @PATH@ where collections and projects are accessible through subdirectories named after their UUID or portable data hash|
+|@--mount-by-pdh PATH@|Make a magic directory available under the mount at @PATH@ where collections are accessible through subdirectories named after portable data hash|
+|@--mount-by-tag PATH@|Make a subdirectory for each tag attached to a collection or project available under the mount at @PATH@|
+
+h3(#filters). @--filters@ usage and limitations
+
+Your argument to @--filters@ should be a JSON list of filters in "Arvados API list filter syntax":{{ site.baseurl }}/api/methods.html#filters. If your filter checks any field besides @uuid@, you should prefix it with the @<resource type>.@ Taken together, here's an example that mounts your home directory excluding filter groups, workflow intermediate output collections, and workflow log collections:
+
+<notextile>
+<pre><code>$ arv-mount --home <span class="userinput">--filters '[["groups.group_class", "!=", "filter"], ["collections.properties.type", "not in", ["intermediate", "log"]]]'</span> ...
+</code></pre>
+</notextile>
+
+Because filters can be awkward to write on the command line, you can also write them in a file, and pass that file path to the @--filters@ option. This example does the same filtering:
+
+<notextile>
+<pre><code>$ <span class="userinput">cat &gt;~/arv-mount-filters.json &lt;&lt;EOF
+[
+  [
+    "groups.group_class",
+    "!=",
+    "filter"
+  ],
+  [
+    "collections.properties.type",
+    "not in",
+    [
+      "intermediate",
+      "log"
+    ]
+  ]
+]
+EOF</span>
+$ arv-mount --home <span class="userinput">--filters ~/arv-mount-filters.json</span> ...
+</code></pre>
+</notextile>
+
+The current implementation of @--filters@ has a few limitations. These may be lifted in a future release:
+
+* You can always access any project or collection by UUID or portable data hash under a magic directory. If you access a project this way, your filters _will_ apply to the project contents.
+* Tag directory listings are generated by querying tags alone. Only filters that apply to @links@ will affect these listings.
+
+h2(#access). Mount access and permissions
+
+table(table table-bordered table-condensed).
+|_. Option(s)|_. Description|
+|@--allow-other@|Let other users on this system read mounted data (default false)|
+|@--read-only@|Mounted data cannot be modified from the mount (default)|
+|@--read-write@|Mounted data can be modified from the mount|
+
+h2(#lifecycle). Mount lifecycle management
+
+table(table table-bordered table-condensed).
+|_. Option(s)|_. Description|
+|@--exec ...@|Mount data, run the specified command, then unmount and exit. @--exec@ reads all remaining options as the command to run, so it must be the last option you specify. Either end your command arguments (and other options) with a @--@ argument, or specify @--exec@ after your mount point.|
+|@--foreground@|Run mount process in the foreground instead of daemonizing (default false)|
+|@--subtype SUBTYPE@|Set mounted filesystem type to @fuse.SUBTYPE@ (default is just @fuse@)|
+|@--replace@|If a FUSE mount is already mounted at the given directory, unmount it before mounting the requested data. If @--subtype@ is specified, unmount only if the mount has that subtype. WARNING: This command can affect any kind of FUSE mount, not just arv-mount.|
+|@--unmount@|If a FUSE mount is already mounted at the given directory, unmount it and exit. If @--subtype@ is specified, unmount only if the mount has that subtype. WARNING: This command can affect any kind of FUSE mount, not just arv-mount.|
+|@--unmount-all@|Unmount all FUSE mounts at or below the given directory, then exit. If @--subtype@ is specified, unmount only if the mount has that subtype. WARNING: This command can affect any kind of FUSE mount, not just arv-mount.|
+|@--unmount-timeout SECONDS@|The number of seconds to wait for a clean unmount after an @--exec@ command has exited (default 2.0). After this time, the mount will be forcefully unmounted.|
+
+h2(#logging). Mount logging and statistics
+
+table(table table-bordered table-condensed).
+|_. Option(s)|_. Description|
+|@--crunchstat-interval SECONDS@|Write stats to stderr every N seconds (default disabled)|
+|@--debug@|Log debug information|
+|@--logfile LOGFILE@|Write debug logs and errors to the specified file (default stderr)|
+
+h2(#cache). Mount local cache setup
+
+table(table table-bordered table-condensed).
+|_. Option(s)|_. Description|
+|@--disk-cache@|Cache data on the local filesystem (default)|
+|@--ram-cache@|Cache data in memory|
+|@--disk-cache-dir DIRECTORY@|Filesystem cache location (default @~/.cache/arvados/keep@)|
+|@--directory-cache BYTES@|Size of directory data cache in bytes (default 128 MiB)|
+|@--file-cache BYTES@|Size of file data cache in bytes (default 8 GiB for filesystem cache, 256 MiB for memory cache)|
+
+h2(#plumbing). Mount interactions with Arvados and Linux
+
+table(table table-bordered table-condensed).
+|_. Option(s)|_. Description|
+|@--disable-event-listening@|Don't subscribe to events on the API server to update mount contents|
+|@--encoding ENCODING@|Filesystem character encoding (default 'utf-8'; specify a name from the "Python codec registry":https://docs.python.org/3/library/codecs.html#standard-encodings)|
+|@--retries RETRIES@|Maximum number of times to retry server requests that encounter temporary failures (e.g., server down). Default 10.|
+|@--storage-classes CLASSES@|Comma-separated list of storage classes to request for new collections|
+
+h2(#examples). Examples
+
+h3(#exec). Using @--exec@
+
+There are a couple of details that are important to understand when you use @--exec@:
+
+* @--exec@ reads all remaining options as the command to run, so it must be the last option you specify. Either end your command arguments (and other options) with a @--@ argument, or specify @--exec@ after your mount point.
+* The command you specify runs from the same directory that you started @arv-mount@ from. To access data inside the mount, you will generally need to pass the path to the mount as an argument.
+
+For example, this generates a recursive listing of all the projects and collections under your home project:
+
+<notextile>
+<pre><code>$ <span class="userinput">arv-mount --home --exec find -type d ArvadosHome -- ArvadosHome</span>
+</code></pre>
+</notextile>
+
+The first @ArvadosHome@ is a path argument to @find@. The second is the mount point argument to @arv-mount@.
+
+h3(#systemd). Running arv-mount as a systemd service
+
+If you want to run @arv-mount@ as a long-running service, it's easy to write a systemd service definition for it. We do not publish one because the entire definition tends to be site-specific, but you can start from this template. You must change the @ExecStart@ path. Comments detail other changes you might want to make.
+
+<notextile>
+<pre><code>[Unit]
+Description=Arvados FUSE mount
+Documentation={{ site.baseurl }}/sdk/fuse/options.html
+
+[Service]
+Type=simple
+CacheDirectory=arvados/keep
+CacheDirectoryMode=0700
+
+# This unit makes the mount available as `Arvados` under the runtime directory root.
+# If this is a system service installed under /etc/systemd/system,
+# the mount will be at /run/Arvados.
+# If this is a user service installed under ~/.config/systemd/user,
+# the mount will be at $XDG_RUNTIME_DIR/Arvados.
+# If you want to mount at another location on the filesystem, remove RuntimeDirectory
+# and replace both instances of %t/Arvados with your desired path.
+RuntimeDirectory=Arvados
+# The arv-mount path must be the absolute path where you installed the command.
+# If you installed from a distribution package, make this /usr/bin/arv-mount.
+# If you installed from pip, replace ... with the path to your virtualenv.
+# You can add options to select what gets mounted, access permissions,
+# cache size, log level, etc.
+ExecStart=<span class="userinput">...</span>/bin/arv-mount --foreground --disk-cache-dir %C/arvados/keep %t/Arvados
+ExecStop=/usr/bin/fusermount -u %t/Arvados
+
+# This unit assumes the running user has a ~/.config/arvados/settings.conf
+# with ARVADOS_API_HOST and ARVADOS_API_TOKEN defined.
+# If not, you can write those in a separate file
+# and set its path as EnvironmentFile.
+# Make sure that file is owned and only readable by the running user (mode 0600).
+#EnvironmentFile=...
+</code></pre>
+</notextile>
index b733d03bfc37d5152afdb3a3d515a9e66e4e4d23..9abfa9789f381c395fe4bf1760b38ef643a96ebb 100644 (file)
@@ -9,13 +9,18 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-This section documents language bindings for the "Arvados API":{{site.baseurl}}/api/index.html and Keep that are available for various programming languages.  Not all features are available in every SDK.  The most complete SDK is the Python SDK.  Note that this section only gives a high level overview of each SDK.  Consult the "Arvados API":{{site.baseurl}}/api/index.html section for detailed documentation about Arvados API calls available on each resource.
+This section documents client tools and language bindings for the "Arvados API":{{site.baseurl}}/api/index.html and Keep that are available for various programming languages. The most mature, popular packages are:
+
+* "Python SDK":{{site.baseurl}}/sdk/python/sdk-python.html (also includes essential command line tools such as @arv-put@ and @arv-get@)
+* "Command line SDK":{{site.baseurl}}/sdk/cli/install.html (includes the @arv@ tool)
+
+Many Arvados Workbench pages provide examples of using the Python SDK and command line tools to access a given resource. Open "API details" from the action menu and open the tab with the example you're interested in.
+
+We provide API bindings for several other languages, but these SDKs may be missing some features or documentation:
 
-* "Python SDK":{{site.baseurl}}/sdk/python/sdk-python.html (also includes essential command line tools such as "arv-put" and "arv-get")
-* "Command line SDK":{{site.baseurl}}/sdk/cli/install.html ("arv")
 * "Go SDK":{{site.baseurl}}/sdk/go/index.html
+* "Java SDK":{{site.baseurl}}/sdk/java-v2/index.html
 * "R SDK":{{site.baseurl}}/sdk/R/index.html
 * "Ruby SDK":{{site.baseurl}}/sdk/ruby/index.html
-* "Java SDK v2":{{site.baseurl}}/sdk/java-v2/index.html
 
-Many Arvados Workbench pages, under the *Advanced* tab, provide examples of API and SDK use for accessing the current resource .
+Consult the "Arvados API":{{site.baseurl}}/api/index.html section for detailed documentation about Arvados API calls available on each resource.
index 8d2fc2f4af086db6072c282ee59fc027fb11e9b3..a0841ec432faf5cb206513d444f4dc29ba877521 100644 (file)
@@ -1,7 +1,7 @@
 ---
 layout: default
 navsection: sdk
-navmenu: Java SDK v2
+navmenu: Java
 title: Examples
 ...
 {% comment %}
index ad9f0e1a9d1a7e3679f8409a478f47e67faa5d0e..aca9c4807856b3ae0e7b0d5f4ecaa062bd3521f9 100644 (file)
@@ -1,7 +1,7 @@
 ---
 layout: default
 navsection: sdk
-navmenu: Java SDK v2
+navmenu: Java
 title: "Installation"
 ...
 {% comment %}
index 872150f62518956d15584c7a89c57a0d4350f90b..686cd2440f039e212d2c7937f048b656d05a1c0b 100644 (file)
@@ -1,7 +1,7 @@
 ---
 layout: default
 navsection: sdk
-navmenu: Java v2
+navmenu: Java
 title: "Javadoc Reference"
 
 no_nav_left: true
index 020c0fc62cdafc384fd085b2f069424072cb1fc9..dabd2d37f8c8d6ef6bbcc8c747ff3c04c1f0b722 100644 (file)
@@ -46,7 +46,7 @@ The API client has a method that corresponds to each "type of resource supported
 
 Each resource object has a method that corresponds to each API method supported by that resource type. You call these methods with the keyword arguments and values documented in the API reference. They return an API request object.
 
-Each API request object has an @execute()@ method. You may pass a @num_retries@ integer argument to retry the operation that many times, with exponential back-off, in case of temporary errors like network problems. If it ultimately succeeds, it returns the kind of object documented in the API reference for that method. Usually that's a dictionary with details about the object you requested. If there's a problem, it raises an exception.
+Each API request object has an @execute()@ method. If it succeeds, it returns the kind of object documented in the API reference for that method. Usually that's a dictionary with details about the object you requested. If there's a problem, it raises an exception.
 
 Putting it all together, basic API requests usually look like:
 
@@ -54,10 +54,19 @@ Putting it all together, basic API requests usually look like:
 arv_object = arv_client.resource_type().api_method(
     argument=...,
     other_argument=...,
-).execute(num_retries=3)
+).execute()
 {% endcodeblock %}
 
-The following sections detail how to call "common resource methods in the API":{{site.baseurl}}/api/methods.html with more concrete examples. Additional methods may be available on specific resource types.
+Later sections detail how to call "common resource methods in the API":{{site.baseurl}}/api/methods.html with more concrete examples. Additional methods may be available on specific resource types.
+
+h3. Retrying failed requests
+
+If you execute an API request and it fails because of a temporary error like a network problem, the SDK waits with randomized exponential back-off, then retries the request. You can specify the maximum number of retries by passing a @num_retries@ integer to either @arvados.api@ or the @execute()@ method; the SDK will use whichever number is greater. The default number of retries is 10, which means that an API request could take up to about 35 minutes if the temporary problem persists that long. To disable automatic retries, just pass @num_retries=0@ to @arvados.api@:
+
+{% codeblock as python %}
+import arvados
+arv_client = arvados.api('v1', num_retries=0, ...)
+{% endcodeblock %}
 
 h2. get method
 
index 1cfbd6054566f5d8ab394902d46ca1852aa2e16b..237721ac12fce95babec56a634e7b25f80aa8b43 100644 (file)
@@ -10,17 +10,17 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-The Arvados FUSE driver is a Python utility that allows you to see the Keep service as a normal filesystem, so that data can be accessed using standard tools. This driver requires the Python SDK installed in order to access Arvados services.
+The Arvados CWL Runner is a Python tool that allows you to register and submit workflows to Arvados. You can oversee a running workflow on your local system, or let that run inside an Arvados container. This tool requires the Python SDK installed in order to access Arvados services.
 
 h2. Installation
 
-If you are logged in to a managed Arvados VM, the @arv-mount@ utility should already be installed.
+If you are logged in to a managed Arvados VM, the @arvados-cwl-runner@ utility should already be installed.
 
-To use the FUSE driver elsewhere, you can install from a distribution package, or PyPI.
+To use the CWL Runner elsewhere, you can install it from a distribution package or PyPI.
 
 h2. Option 1: Install from distribution packages
 
-First, "add the appropriate package repository for your distribution":{{ site.baseurl }}/install/packages.html
+First, "add the appropriate package repository for your distribution":{{ site.baseurl }}/install/packages.html.
 
 {% assign arvados_component = 'python3-arvados-cwl-runner' %}
 
@@ -32,13 +32,14 @@ Run @pip install arvados-cwl-runner@ in an appropriate installation environment,
 
 Note:
 
-The SDK uses @pycurl@ which depends on the @libcurl@ C library.  To build the module you may have to first install additional packages.  On Debian 10 this is:
+The CWL Runner uses @pycurl@ which depends on the @libcurl@ C library.  To build the module you may have to first install additional packages.  On Debian-based distributions you can install them by running:
 
-<pre>
-$ apt-get install git build-essential python3-dev libcurl4-openssl-dev libssl1.0-dev python3-llfuse
-</pre>
+<notextile>
+<pre><code># <span class="userinput">apt install git build-essential python3-dev libcurl4-openssl-dev libssl-dev</span>
+</code></pre>
+</notextile>
 
-h3. Check Docker access
+h2. Check Docker access
 
 In order to pull and upload Docker images, @arvados-cwl-runner@ requires access to Docker.  You do not need Docker if the Docker images you intend to use are already available in Arvados.
 
@@ -66,6 +67,6 @@ Server:
 
 If this returns an error, contact the sysadmin of your cluster for assistance.
 
-h3. Usage
+h2. Usage
 
-Please refer to the "Accessing Keep from GNU/Linux":{{site.baseurl}}/user/tutorials/tutorial-keep-mount-gnu-linux.html tutorial for more information.
+Please refer to the "Starting a Workflow at the Command Line":{{site.baseurl}}/user/cwl/cwl-runner.html tutorial for more information.
diff --git a/doc/sdk/python/arvados-fuse.html.textile.liquid b/doc/sdk/python/arvados-fuse.html.textile.liquid
deleted file mode 100644 (file)
index 04dca2c..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
----
-layout: default
-navsection: sdk
-navmenu: Python
-title: Arvados FUSE driver
-...
-{% comment %}
-Copyright (C) The Arvados Authors. All rights reserved.
-
-SPDX-License-Identifier: CC-BY-SA-3.0
-{% endcomment %}
-
-The Arvados FUSE driver is a Python utility that allows you to see the Keep service as a normal filesystem, so that data can be accessed using standard tools. This driver requires the Python SDK installed in order to access Arvados services.
-
-h2. Installation
-
-If you are logged in to a managed Arvados VM, the @arv-mount@ utility should already be installed.
-
-To use the FUSE driver elsewhere, you can install from a distribution package, or PyPI.
-
-h2. Option 1: Install from distribution packages
-
-First, "add the appropriate package repository for your distribution":{{ site.baseurl }}/install/packages.html
-
-{% assign arvados_component = 'python-arvados-fuse' %}
-
-{% include 'install_packages' %}
-
-h2. Option 2: Install with pip
-
-Run @pip install arvados_fuse@ in an appropriate installation environment, such as a virtualenv.
-
-Note:
-
-The SDK uses @pycurl@ which depends on the @libcurl@ C library.  To build the module you may have to first install additional packages.  On Debian 10 this is:
-
-<pre>
-$ apt-get install git build-essential python3-dev libcurl4-openssl-dev libssl-dev python3-llfuse
-</pre>
-
-h3. Usage
-
-Please refer to the "Accessing Keep from GNU/Linux":{{site.baseurl}}/user/tutorials/tutorial-keep-mount-gnu-linux.html tutorial for more information.
index f2d087625e662347d44f2b458159c97a926dfe2b..d7d34fc0b0aa91221b26f38282dd85642559f73e 100644 (file)
@@ -471,17 +471,16 @@ import collections
 import pathlib
 root_collection = arvados.collection.Collection(...)
 # Start work from the base stream.
-stream_queue = collections.deque(['.'])
+stream_queue = collections.deque([pathlib.PurePosixPath('.')])
 while stream_queue:
-    stream_name = stream_queue.popleft()
-    collection = root_collection.find(stream_name)
+    stream_path = stream_queue.popleft()
+    collection = root_collection.find(str(stream_path))
     for item_name in collection:
         try:
             my_file = collection.open(item_name)
         except IsADirectoryError:
             # item_name refers to a stream. Queue it to walk later.
-            stream_path = pathlib.Path(stream_name, item_name)
-            stream_queue.append(stream_path.as_posix())
+            stream_queue.append(stream_path / item_name)
             continue
         with my_file:
             ...  # Work with my_file as desired
@@ -499,7 +498,7 @@ dst_collection.copy(
     # The path of the source file or directory to copy
     'ExamplePath',
     # The path where the source file or directory will be copied.
-    # Pass the empty string like this to copy it to the same path.
+    # Pass an empty string like this to copy it to the same path.
     '',
     # The collection where the source file or directory comes from.
     # If not specified, the default is the current collection (so you'll
index 8ba2dc73e36e7acbae3804353da7699c460dfd2c..9fa364c5ed4f754e8757b8c19bf1f916eeea516e 100644 (file)
@@ -12,4 +12,4 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-notextile. <iframe src="arvados/" style="width:100%; height:100%; border:none" />
+notextile. <iframe src="arvados.html" style="width:100%; height:100%; border:none" />
index 46393069220f6fe3be82954bdba9ad9f147c70d9..4a6ba029feeca0bcdb53628a2caaecfc4bfafc5c 100644 (file)
@@ -19,7 +19,7 @@ If you are logged in to an Arvados VM, the Python SDK should be installed.
 To use the Python SDK elsewhere, you can install it "from an Arvados distribution package":#package-install or "from PyPI using pip":#pip-install.
 
 {% include 'notebox_begin_warning' %}
-As of Arvados 2.2, the Python SDK requires Python 3.6+.  The last version to support Python 2.7 is Arvados 2.0.4.
+As of Arvados 3.0, the Python SDK requires Python 3.8+.
 {% include 'notebox_end' %}
 
 h2(#package-install). Install from a distribution package
@@ -32,14 +32,10 @@ First, configure the "Arvados package repositories":../../install/packages.html
 
 {% include 'install_packages' %}
 
-{% include 'notebox_begin_warning' %}
-If you are on Ubuntu 18.04, please note that the Arvados packages that use Python depend on the python-3.8 package. This means they are installed under @/usr/share/python3.8@, not @/usr/share/python3@. You will need to update the commands below accordingly.
-{% include 'notebox_end' %}
-
 The package includes a virtualenv, which means the correct Python environment needs to be loaded before the Arvados SDK can be imported. You can test the installation by doing that, then creating a client object. Ensure your "@ARVADOS_API_HOST@ and @ARVADOS_API_TOKEN@ credentials are set up correctly":{{site.baseurl}}/user/reference/api-tokens.html. Then you should be able to run the following without any errors:
 
 <notextile>
-<pre>~$ <code class="userinput">source /usr/share/python3/dist/python3-arvados-python-client/bin/activate</code>
+<pre>~$ <code class="userinput">source /usr/lib/python3-arvados-python-client/bin/activate</code>
 (python-arvados-python-client) ~$ <code class="userinput">python</code>
 Python 3.7.3 (default, Jul 25 2020, 13:03:44)
 [GCC 8.3.0] on linux
@@ -53,7 +49,7 @@ Type "help", "copyright", "credits" or "license" for more information.
 Alternatively, you can run the Python executable inside the @virtualenv@ directly:
 
 <notextile>
-<pre>~$ <code class="userinput">/usr/share/python3/dist/python3-arvados-python-client/bin/python</code>
+<pre>~$ <code class="userinput">/usr/lib/python3-arvados-python-client/bin/python</code>
 Python 3.7.3 (default, Jul 25 2020, 13:03:44)
 [GCC 8.3.0] on linux
 Type "help", "copyright", "credits" or "license" for more information.
@@ -69,11 +65,12 @@ h2(#pip-install). Install from PyPI with pip
 
 This installation method is recommended to use the SDK in your own Python programs. If installed into a @virtualenv@, it can coexist with the system-wide installation method from a distribution package.
 
-The SDK uses @pycurl@ which depends on the @libcurl@ C library.  To build the module you may have to first install additional packages.  On Debian 10 you can do this by running:
+Note the Python SDK uses @pycurl@ which depends on the @libcurl@ C library.  To build the module you may have to first install additional packages.  On Debian-based distributions you can install them by running:
 
-<pre>
-$ apt-get install git build-essential python3-dev libcurl4-openssl-dev libssl-dev
-</pre>
+<notextile>
+<pre><code># <span class="userinput">apt install git build-essential python3-dev libcurl4-openssl-dev libssl-dev</span>
+</code></pre>
+</notextile>
 
 Run @python3 -m pip install arvados-python-client@ in an appropriate installation environment, such as a @virtualenv@.
 
index c435916e34bb3f1014f7a542a9a9196cdeb43817..1d20c85f549752dab59e470f94ca7a49b2637319 100644 (file)
@@ -9,6 +9,8 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
+{% include 'tutorial_expectations' %}
+
 {% include 'notebox_begin' %}
 
 This is only applicable when Arvados runs in a cloud environment and @arvados-dispatch-cloud@ is used to dispatch @crunch@ jobs. The per node-hour price for each defined InstanceType most be supplied in "config.yml":{{site.baseurl}}/admin/config.html.
index adeb9e7fea8bb8fbcba4b7e71226f2ccbb5bf7ea..a28acd56ec5621a4631cfcbdeb11fb580958d01a 100644 (file)
@@ -9,7 +9,11 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-The @crunchstat-summary@ tool can be used to analyze workflow and container performance. It can be installed from packages (@apt install python3-crunchstat-summary@ or @yum install rh-python36-python-crunchstat-summary@). @crunchstat-summary@ analyzes the crunchstat lines from the logs of a container or workflow and generates a report in text or html format.
+{% include 'tutorial_expectations' %}
+
+*Note:* Starting from Arvados 2.7.2, these reports are generated automatically by @arvados-cwl-runner@ and can be found as @usage_report.html@ in a container request's log collection.
+
+The @crunchstat-summary@ tool can be used to analyze workflow and container performance. It can be installed from packages (@apt install python3-crunchstat-summary@ or @yum install rh-python36-python-crunchstat-summary@), or in a Python virtualenv (@pip install crunchstat_summary@). @crunchstat-summary@ analyzes the crunchstat lines from the logs of a container or workflow and generates a report in text or html format.
 
 h2(#syntax). Syntax
 
@@ -46,105 +50,110 @@ optional arguments:
 </code></pre>
 </notextile>
 
+When @crunchstat-summary@ is given a container or container request uuid for a toplevel workflow runner container, it will generate a report for the whole workflow. If the workflow is big, it can take a long time to generate the report.
+
 h2(#examples). Examples
 
 @crunchstat-summary@ prints to stdout. The html report, in particular, should be redirected to a file and then loaded in a browser.
 
-An example text report for a single workflow step:
+The html report can be generated as follows:
 
 <notextile>
-<pre><code>~$ <span class="userinput">crunchstat-summary --container-request pirca-xvhdp-rs0ef250emtmbj8 --format text</span>
-category  metric  task_max  task_max_rate job_total
-blkio:0:0 read  63067755822 53687091.20 63067755822
-blkio:0:0 write 64484253320 16376234.80 64484253320
-cpu cpus  16  - -
-cpu sys 2147.29 0.60  2147.29
-cpu user  549046.22 15.99 549046.22
-cpu user+sys  551193.51 16.00 551193.51
-fuseop:create count 1 0.10  1
-fuseop:create time  0.01  0.00  0.01
-fuseop:destroy  count 0 0 0
-fuseop:destroy  time  0 0 0.00
-fuseop:flush  count 12  0.70  12
-fuseop:flush  time  0.00  0.00  0.00
-fuseop:forget count 0 0 0
-fuseop:forget time  0 0 0.00
-fuseop:getattr  count 40  2.70  40
-fuseop:getattr  time  0.00  0.00  0.00
-fuseop:lookup count 36  2.90  36
-fuseop:lookup time  0.67  0.07  0.67
-fuseop:mkdir  count 0 0 0
-fuseop:mkdir  time  0 0 0.00
-fuseop:on_event count 0 0 0
-fuseop:on_event time  0 0 0.00
-fuseop:open count 9 0.30  9
-fuseop:open time  0.00  0.00  0.00
-fuseop:opendir  count 0 0 0
-fuseop:opendir  time  0 0 0.00
-fuseop:read count 481185  409.60  481185
-fuseop:read time  370.11  2.14  370.11
-fuseop:readdir  count 0 0 0
-fuseop:readdir  time  0 0 0.00
-fuseop:release  count 7 0.30  7
-fuseop:release  time  0.00  0.00  0.00
-fuseop:rename count 0 0 0
-fuseop:rename time  0 0 0.00
-fuseop:rmdir  count 0 0 0
-fuseop:rmdir  time  0 0 0.00
-fuseop:setattr  count 0 0 0
-fuseop:setattr  time  0 0 0.00
-fuseop:statfs count 0 0 0
-fuseop:statfs time  0 0 0.00
-fuseop:unlink count 0 0 0
-fuseop:unlink time  0 0 0.00
-fuseop:write  count 5414406 1123.00 5414406
-fuseop:write  time  475.04  0.11  475.04
-fuseops read  481185  409.60  481185
-fuseops write 5414406 1123.00 5414406
-keepcache hit 961402  819.20  961402
-keepcache miss  946 0.90  946
-keepcalls get 962348  820.00  962348
-keepcalls put 961 0.30  961
-mem cache 22748987392 - -
-mem pgmajfault  0 - 0
-mem rss 27185491968 - -
-net:docker0 rx  0 - 0
-net:docker0 tx  0 - 0
-net:docker0 tx+rx 0 - 0
-net:ens5  rx  1100398604  - 1100398604
-net:ens5  tx  1445464 - 1445464
-net:ens5  tx+rx 1101844068  - 1101844068
-net:keep0 rx  63086467386 53687091.20 63086467386
-net:keep0 tx  64482237590 20131128.60 64482237590
-net:keep0 tx+rx 127568704976  53687091.20 127568704976
-statfs  available 398721179648  - 398721179648
-statfs  total 400289181696  - 400289181696
-statfs  used  1568198656  0 1568002048
-time  elapsed 34820 - 34820
-# Number of tasks: 1
-# Max CPU time spent by a single task: 551193.51s
-# Max CPU usage in a single interval: 1599.52%
-# Overall CPU usage: 1582.98%
-# Max memory used by a single task: 27.19GB
-# Max network traffic in a single task: 127.57GB
-# Max network speed in a single interval: 53.69MB/s
-# Keep cache miss rate 0.10%
-# Keep cache utilization 99.97%
-# Temp disk utilization 0.39%
-#!! bwamem-samtools-view max RSS was 25927 MiB -- try reducing runtime_constraints to "ram":27541477785
-#!! bwamem-samtools-view max temp disk utilization was 0% of 381746 MiB -- consider reducing "tmpdirMin" and/or "outdirMin"
+<pre><code>~$ <span class="userinput">crunchstat-summary --container-request pirca-xvhdp-rs0ef250emtmbj8 --format html > report.html</span>
 </code></pre>
 </notextile>
 
-When @crunchstat-summary@ is given a container or container request uuid for a toplevel workflow runner container, it will generate a report for the whole workflow. If the workflow is big, it can take a long time to generate the report.
+When loaded in a browser:
 
-The equivalent html report can be generated as follows:
+!(full-width)images/crunchstat-summary-html.png!
+
+<br>
+
+Using @--format text@ will print detailed usage and summary:
 
 <notextile>
-<pre><code>~$ <span class="userinput">crunchstat-summary --container-request pirca-xvhdp-rs0ef250emtmbj8 --format html > report.html</span>
+<pre><code>~$ <span class="userinput">crunchstat-summary --container-request pirca-xvhdp-rs0ef250emtmbj8 --format text</span>
+category       metric  task_max        task_max_rate   job_total
+blkio:0:0      read    63067755822     53687091.20     63067755822
+blkio:0:0      write   64484253320     16376234.80     64484253320
+cpu    cpus    16      -       -
+cpu    sys     2147.29 0.60    2147.29
+cpu    user    549046.22       15.99   549046.22
+cpu    user+sys        551193.51       16.00   551193.51
+fuseop:create  count   1       0.10    1
+fuseop:create  time    0.01    0.00    0.01
+fuseop:destroy count   0       0       0
+fuseop:destroy time    0       0       0.00
+fuseop:flush   count   12      0.70    12
+fuseop:flush   time    0.00    0.00    0.00
+fuseop:forget  count   0       0       0
+fuseop:forget  time    0       0       0.00
+fuseop:getattr count   40      2.70    40
+fuseop:getattr time    0.00    0.00    0.00
+fuseop:lookup  count   36      2.90    36
+fuseop:lookup  time    0.67    0.07    0.67
+fuseop:mkdir   count   0       0       0
+fuseop:mkdir   time    0       0       0.00
+fuseop:on_event        count   0       0       0
+fuseop:on_event        time    0       0       0.00
+fuseop:open    count   9       0.30    9
+fuseop:open    time    0.00    0.00    0.00
+fuseop:opendir count   0       0       0
+fuseop:opendir time    0       0       0.00
+fuseop:read    count   481185  409.60  481185
+fuseop:read    time    370.11  2.14    370.11
+fuseop:readdir count   0       0       0
+fuseop:readdir time    0       0       0.00
+fuseop:release count   7       0.30    7
+fuseop:release time    0.00    0.00    0.00
+fuseop:rename  count   0       0       0
+fuseop:rename  time    0       0       0.00
+fuseop:rmdir   count   0       0       0
+fuseop:rmdir   time    0       0       0.00
+fuseop:setattr count   0       0       0
+fuseop:setattr time    0       0       0.00
+fuseop:statfs  count   0       0       0
+fuseop:statfs  time    0       0       0.00
+fuseop:unlink  count   0       0       0
+fuseop:unlink  time    0       0       0.00
+fuseop:write   count   5414406 1123.00 5414406
+fuseop:write   time    475.04  0.11    475.04
+fuseops        read    481185  409.60  481185
+fuseops        write   5414406 1123.00 5414406
+keepcache      hit     961402  819.20  961402
+keepcache      miss    946     0.90    946
+keepcalls      get     962348  820.00  962348
+keepcalls      put     961     0.30    961
+mem    cache   22748987392     -       -
+mem    pgmajfault      0       -       0
+mem    rss     27185491968     -       -
+net:docker0    rx      0       -       0
+net:docker0    tx      0       -       0
+net:docker0    tx+rx   0       -       0
+net:ens5       rx      1100398604      -       1100398604
+net:ens5       tx      1445464 -       1445464
+net:ens5       tx+rx   1101844068      -       1101844068
+net:keep0      rx      63086467386     53687091.20     63086467386
+net:keep0      tx      64482237590     20131128.60     64482237590
+net:keep0      tx+rx   127568704976    53687091.20     127568704976
+statfs available       398721179648    -       398721179648
+statfs total   400289181696    -       400289181696
+statfs used    1568198656      0       1568002048
+time   elapsed 34820   -       34820
+# Elapsed time: 9h 40m 20s
+# Assigned instance type: m5.4xlarge
+# Instance hourly price: $0.768
+# Max CPU usage in a single interval: 1599.52%
+# Overall CPU usage: 1582.98%
+# Requested CPU cores: 16
+# Instance VCPUs: 16
+# Max memory used: 25926.11MB
+# Requested RAM: 50000.00MB
+# Maximum RAM request for this instance type: 61736.70MB
+# Max network traffic: 127.57GB
+# Max network speed in a single interval: 53.69MB/s
+# Keep cache miss rate: 0.10%
+# Keep cache utilization: 99.97%
+# Temp disk utilization: 0.39%
 </code></pre>
 </notextile>
-
-When loaded in a browser:
-
-!(full-width)images/crunchstat-summary-html.png!
index e05072ddf6843a9a01599d4854317a49da62b4a1..3c8366721d86da6aafed9071729ad5f9d720034a 100644 (file)
@@ -73,7 +73,7 @@ hints:
     usePreemptible: true
 
   arv:OutOfMemoryRetry:
-    memoryRetryMultipler: 2
+    memoryRetryMultiplier: 2
     memoryErrorRegex: "custom memory error"
 {% endcodeblock %}
 
@@ -195,7 +195,7 @@ table(table table-bordered table-condensed).
 
 h2(#OutOfMemoryRetry). arv:OutOfMemoryRetry
 
-Specify that when a workflow step appears to have failed because it did not request enough RAM, it should be re-submitted with more RAM.  Out of memory conditions are detected either by the container being unexpectedly killed (exit code 137) or by matching a pattern in the container's output (see @memoryErrorRegex@).  Retrying will increase the base RAM request by the value of @memoryRetryMultipler@.  For example, if the original RAM request was 10 GiB and the multiplier is 1.5, then it will re-submit with 15 GiB.
+Specify that when a workflow step appears to have failed because it did not request enough RAM, it should be re-submitted with more RAM.  Out of memory conditions are detected either by the container being unexpectedly killed (exit code 137) or by matching a pattern in the container's output (see @memoryErrorRegex@).  Retrying will increase the base RAM request by the value of @memoryRetryMultiplier@.  For example, if the original RAM request was 10 GiB and the multiplier is 1.5, then it will re-submit with 15 GiB.
 
 Containers are only re-submitted once.  If it fails a second time after increasing RAM, then the worklow step will still fail.
 
@@ -203,7 +203,7 @@ Also note that expressions that use @$(runtime.ram)@ (such as dynamic command li
 
 table(table table-bordered table-condensed).
 |_. Field |_. Type |_. Description |
-|memoryRetryMultipler|float|Required, the retry will multiply the base memory request by this factor to get the retry memory request.|
+|memoryRetryMultiplier|float|Optional, default value is 2.  The retry will multiply the base memory request by this factor to get the retry memory request.|
 |memoryErrorRegex|string|Optional, a custom regex that, if found in the stdout, stderr or crunch-run logging of a program, will trigger a retry with greater RAM.  If not provided, the default pattern matches "out of memory" (with or without spaces), "memory error" (with or without spaces), "bad_alloc" and "container using over 90% of memory".|
 
 h2. arv:dockerCollectionPDH
index 703ec89139baf45afcafc8cf84c93b304ecee2b1..27db90fbd359aed61c5bbb323950777c1aeed6cd 100644 (file)
@@ -74,7 +74,8 @@ table(table table-bordered table-condensed).
 |==--skip-schemas==|      Skip loading of schemas|
 |==--trash-intermediate==|Immediately trash intermediate outputs on workflow success.|
 |==--no-trash-intermediate==|Do not trash intermediate outputs (default).|
-
+|==--enable-usage-report==|Create usage_report.html with a summary of each step's resource usage.|
+|==--disable-usage-report==|Disable usage report.|
 
 h3(#names). Specify workflow and output names
 
index d3aed6ce58bbec10974f39d9d6aa1060e5a3339d..dcb2c850d14fb88407b69140b5156cb69265ef86 100644 (file)
@@ -131,7 +131,7 @@ See "arvados-cwl-runner options":{{site.baseurl}}/user/cwl/cwl-run-options.html
 
 h2(#registering). Registering a workflow to use in Workbench
 
-Use @--create-workflow@ to register a CWL workflow with Arvados.  Use @--project-uuid@ to upload the workflow to a specific project, along with its dependencies.  You can share the workflow with other Arvados users by sharing that project.  You can run the workflow by clicking the <span class="btn btn-sm btn-primary"><i class="fa fa-fw fa-gear"></i> Run a process...</span> button on the Workbench Dashboard, and on the command line by UUID.
+Use @--create-workflow@ to register a CWL workflow with Arvados.  Use @--project-uuid@ to upload the workflow to a specific project, along with its dependencies.  You can share the workflow with other Arvados users by sharing that project.  You can run the workflow by clicking the <span class="btn btn-sm btn-primary">+ NEW</span> → <i class="fa fa-fw fa-gear"></i> *Run a process* menu items on the Workbench, and on the command line by UUID.
 
 <notextile>
 <pre><code>~/arvados/doc/user/cwl/bwa-mem$ <span class="userinput">arvados-cwl-runner --project-uuid zzzzz-j7d0g-p32bi47ogkjke11 --create-workflow bwa-mem.cwl</span>
index 488541b3a745cd2f9458605e68b323775725d1fc..3832734e708de4b2424673279c8d5695a1901755 100644 (file)
Binary files a/doc/user/cwl/images/crunchstat-summary-html.png and b/doc/user/cwl/images/crunchstat-summary-html.png differ
index 91347e66f2ffb9a4f8f86dba73f98c33128905ac..9c249800492109cdab9fba962547fd6171d1d917 100644 (file)
@@ -9,6 +9,8 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
+{% include 'tutorial_expectations' %}
+
 {% include 'notebox_begin' %}
 
 To use this feature, your Arvados installation must be configured to allow container shell access. See "the install guide":{{site.baseurl}}/install/container-shell-access.html for more information.
index 46ea770eff7c27ebee5bffd802bcd1f950f214fe..18f675d04e17c9b69973b9fa6d832b3352892320 100644 (file)
@@ -11,10 +11,35 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 
 Many operations in Arvados can be performed using either the web Workbench or through command line tools.  Some operations can only be done using the command line.
 
-To use the command line tools, you can either log into an Arvados-managed VM instance where those tools are pre-installed, or install the Arvados tools on your own system.
+To use the command line tools, you can either log into an Arvados virtual machine where those tools are pre-installed, or install the Arvados tools on your own system.
 
-To log into an Arvados-managed VM, see instructions for "Webshell":{{site.baseurl}}/user/getting_started/vm-login-with-webshell.html or "Unix":{{site.baseurl}}/user/getting_started/ssh-access-unix.html or "Windows":{{site.baseurl}}/user/getting_started/ssh-access-windows.html .
+h2. Option 1: Using an Arvados virtual machine
 
-To install the Arvados tools on your own system, you should install the "Command line SDK":{{site.baseurl}}/sdk/cli/install.html (requires Ruby) and "Python SDK":{{site.baseurl}}/sdk/python/sdk-python.html (requires Python).  You may also want to install "arvados-cwl-runner":{{site.baseurl}}/sdk/python/arvados-cwl-runner.html to submit workflows and "arvados-fuse":{{site.baseurl}}/sdk/python/arvados-fuse.html to mount keep as a filesystem.
+This is the command line interface we recommend for most day-to-day work, because the tools are all preinstalled and preconfigured for you. You can log in to any virtual machine where you have permission by using:
 
-Once you are logged in or have command line tools installed, see "getting an API token":{{site.baseurl}}/user/reference/api-tokens.html and "check your environment":{{site.baseurl}}/user/getting_started/check-environment.html .
+* "the Webshell client":{{site.baseurl}}/user/getting_started/vm-login-with-webshell.html accessible through Arvados Workbench
+* "Unix SSH clients":{{site.baseurl}}/user/getting_started/ssh-access-unix.html
+* "Windows SSH clients":{{site.baseurl}}/user/getting_started/ssh-access-windows.html
+
+h2. Option 2: Installing Arvados tools on your own system
+
+This option gives you more flexibility in your work, but takes more time to set up.
+
+h3. Configure Arvados package repositories for your system
+
+Doing this isn't strictly required for most tools, but will streamline the installation process. Follow the "Arvados package repository instructions":{{site.baseurl}}/install/packages.html.
+
+h3. Install individual tool packages
+
+Here are the client packages you can install on your system. You can skip any you don't want or need except for the Python SDK (most other tools require it).
+
+* "Python SDK":{{site.baseurl}}/sdk/python/sdk-python.html: This provides an Arvados API client in Python, as well as low-level command line tools.
+* "Command-line SDK":{{site.baseurl}}/sdk/cli/install.html: This provides the high-level @arv@ command and user interface to the Arvados API.
+* "FUSE Driver":{{site.baseurl}}/sdk/fuse/install.html: This provides the @arv-mount@ command and FUSE driver that lets you access Keep using standard Linux filesystem tools.
+* "CWL Runner":{{site.baseurl}}/sdk/python/arvados-cwl-runner.html: This provides the @arvados-cwl-runner@ command to register and run workflows in Crunch.
+* "crunchstat-summary":{{site.baseurl}}/user/cwl/crunchstat-summary.html: This tool provides performance reports for Crunch containers.
+* "arvados-client":{{site.baseurl}}/user/debugging/container-shell-access.html: This tool provides subcommands for inspecting Crunch containers, both interactively while they're running and after they've finished.
+
+h2. After Installation: Check your environment
+
+Once you are logged in or have command line tools installed, move on to "getting an API token":{{site.baseurl}}/user/reference/api-tokens.html and "checking your environment":{{site.baseurl}}/user/getting_started/check-environment.html.
index 80cb3913145c959d9faff32b20a856d2edab95f1..b131b5b36d6b8fb0302f7d663bf2c77e70d68df2 100644 (file)
@@ -37,9 +37,9 @@ Enter same passphrase again:
 * @-t@ specifies the key type (must be "rsa")
 * @-C@ specifies a comment (to remember which account the key is associated with)
 
-We strongly recommend that you protect your key with a passphrase.  This means that when the key is used, you will be required to enter the passphrase.  However, unlike logging into remote system using a password, the passphrase is never sent over the network, it is only used to decrypt your private key.
+We strongly recommend that you protect your key with a passphrase.  This means that when the key is used, you will be required to enter the passphrase.  However, unlike logging into remote system using a password, the passphrase is never sent over the network; it is only used to decrypt your private key locally.
 
-Display the contents of @~/.ssh/id_rsa.pub@ (this is your public key) using @cat@ and then copy it onto the clipboard:
+Display the contents of @~/.ssh/id_rsa.pub@ (this is your public key) using @cat@, and then copy it onto the clipboard. The content of the public key may look similar to the following example:
 
 <notextile>
 <pre><code>$ <span class="userinput">cat ~/.ssh/id_rsa.pub</span>
@@ -47,6 +47,8 @@ ssh-rsa AAAAB3NzaC1ycEDoNotUseExampleKeyDoNotUseExampleKeyDoNotUseExampleKeyDoNo
 </code></pre>
 </notextile>
 
+* The above is a specimen that cannot be used as a valid public key.
+
 Now you can set up @ssh-agent@ (next) or proceed with "adding your key to the Arvados Workbench.":#workbench
 
 h3. Set up ssh-agent (optional)
@@ -55,13 +57,13 @@ If you find you are entering your passphrase frequently, you can use @ssh-agent@
 
 notextile. <pre><code>$ <span class="userinput">ssh-add -l</span></code></pre>
 
-If you get the error "Could not open a connection to your authentication agent" you will need to run @ssh-agent@ with the following command:
+If you get the error "_Could not open a connection to your authentication agent_", you will need to run @ssh-agent@ with the following command:
 
-notextile. <pre><code>$ <span class="userinput">eval $(ssh-agent -s)</span></code></pre>
+notextile. <pre><code>$ <span class="userinput">eval "$(ssh-agent -s)"</span></code></pre>
 
-@ssh-agent -s@ prints out values for environment variables SSH_AUTH_SOCK and SSH_AGENT_PID and then runs in the background.  Using "eval" on the output as shown here causes those variables to be set in the current shell environment so that subsequent calls to SSH can discover how to access the agent process.
+@ssh-agent -s@ runs an agent process in the background to hold your SSH credentials, and it prints out the values of environment variables @SSH_AUTH_SOCK@ and @SSH_AGENT_PID@.  By applying the shell builtin @eval@ to this output, as we show here using the shell command-substitution syntax, we set those variables in the current shell environment. In this way, subsequent invocations of @ssh@ in this shell session will be able to access the agent process for the credentials without asking you each time.
 
-After running @ssh-agent@, or if @ssh-add -l@ prints "The agent has no identities", add your key using the following command.  The passphrase to decrypt the key is the same used to protect the key when it was created with @ssh-keygen@:
+After running @ssh-agent@, or if @ssh-add -l@ prints "_The agent has no identities_", add your private key to the SSH agent using the following command.  The passphrase to decrypt the key is the same one used to protect the key when it was created with @ssh-keygen@:
 
 <notextile>
 <pre><code>$ <span class="userinput">ssh-add</span>
@@ -70,7 +72,7 @@ Identity added: /home/example/.ssh/id_rsa (/home/example/.ssh/id_rsa)
 </code></pre>
 </notextile>
 
-When everything is set up, @ssh-add -l@ should yield output that looks something like this:
+When everything is set up, @ssh-add -l@ should yield output that looks like this:
 
 <notextile>
 <pre><code>$ <span class="userinput">ssh-add -l</span>
@@ -82,29 +84,29 @@ When everything is set up, @ssh-add -l@ should yield output that looks something
 
 h3. Connecting directly
 
-If the VM is available on the public Internet (or you are on the same private network as the VM) you can connect directly with @ssh@.  You can probably copy-and-paste the text from *Command line* column directly into a terminal.
+If the VM is available on the public Internet (or you are on the same private network as the VM), you can connect directly with @ssh@.  You can copy-and-paste the text from the *Command line* column (see the screenshot above) directly into a shell session.
 
-Use the following example command to connect as _you_ to the _shell.ClusterID.example.com_ VM instance.  Replace *<code>you@shell.ClusterID.example.com</code>* at the end of the following command with your *login* and *hostname* from Workbench.
+Use the following example command to connect, as the user "_you_" to the VM instance at the hostname "_shell.ClusterID.example.com_".  Replace *<code>you@shell.ClusterID.example.com</code>* at the end of the following command with your actual *login* and *hostname* from Workbench.
 
 notextile. <pre><code>$ <span class="userinput">ssh <b>you@shell.ClusterID.example.com</b></span></code></pre>
 
 h3. Connecting through switchyard
 
-Some Arvados installations use "switchyard" to isolate shell VMs from the public Internet.
+Some Arvados installations use "switchyard" to isolate shell VMs from the public Internet.  In such cases, you cannot log in directly to virtual machines over the public Internet.  Instead, you log into a "switchyard" server and then tell the switchyard which virtual machine you want to connect to.
 
 Use the following example command to connect to the _shell_ VM instance as _you_.  Replace *<code>you@shell</code>* at the end of the following command with your *login* and *hostname* from Workbench:
 
 notextile. <pre><code>$ <span class="userinput">ssh -o "ProxyCommand ssh -p2222 turnout@switchyard.ClusterID.example.com -x -a <b>shell</b>" -x <b>you@shell</b></span></code></pre>
 
-This command does several things at once. You usually cannot log in directly to virtual machines over the public Internet.  Instead, you log into a "switchyard" server and then tell the switchyard which virtual machine you want to connect to.
+This command does several things at once.
 
 * @-o "ProxyCommand ..."@ configures SSH to run the specified command to create a proxy and route your connection through it.
 * @-p2222@ specifies that the switchyard is running on non-standard port 2222.
 * <code>turnout@switchyard.{{ site.arvados_api_host }}</code> specifies the user (@turnout@) and hostname (@switchyard.{{ site.arvados_api_host }}@) of the switchyard server that will proxy our connection to the VM.
 * @-x@ tells SSH not to forward your X session to the switchyard.
 * @-a@ tells SSH not to forward your ssh-agent credentials to the switchyard.
-* *@shell@* is the name of the VM that we want to connect to.  This is sent to the switchyard server as if it were an SSH command, and the switchyard server connects to the VM on our behalf.
-* After the ProxyCommand section, we repeat @-x@ to disable X session forwarding to the virtual machine.
+* *@shell@* is the host name of the VM that we want to connect to.  In summary, the string inside the quotation marks is sent to the switchyard server, as if it were an SSH command, and the switchyard server connects to the VM on our behalf.
+* After the @ProxyCommand@ section, we repeat @-x@ to disable X session forwarding to the virtual machine.
 * Finally, *<code>you@shell</code>* specifies your login name and repeats the hostname of the VM.  The username can be found in the *logins* column in the VMs Workbench page, discussed in the previous section.
 
 You should now be able to log into the Arvados VM and "check your environment.":check-environment.html
index 0aeabab11bea1db943c031c9409d1ab6b693b50f..33168dda3c3a1d9e66018b7528ce3d5625e99bf9 100644 (file)
@@ -19,10 +19,10 @@ Webshell gives you access to an arvados virtual machine from your browser with n
 Some Arvados clusters may not have webshell set up.  If you do not see a "Log in" button or "web shell" column, you will have to follow the "Unix":ssh-access-unix.html or "Windows":ssh-access-windows.html @ssh@ instructions.
 {% include 'notebox_end' %}
 
-In the Arvados Workbench, click on the dropdown menu icon <span class="fa fa-lg fa-user"></span> <span class="caret"></span> in the upper right corner of the top navigation menu to access the user settings menu, and click on the menu item *Virtual machines* to see the list of virtual machines you can access.  If you do not have access to any virtual machines,  please click on <span class="btn btn-sm btn-primary">Send request for shell access</span> (if present) or contact your system administrator.  For the Arvados Playground, this is "info@curii.com":mailto:info@curii.com .
+In the Arvados Workbench, click on the dropdown menu icon <i class="fa fa-lg fa-user"></i> in the upper right corner of the top navigation menu to access the _Account Management_ menu, and click on the menu item *Virtual machines* to see the list of virtual machines you can access.  If you do not have access to any virtual machines,  please click on <span class="btn btn-sm btn-primary">SEND REQUEST FOR SHELL ACCESS</span> (if present) or contact your system administrator.  For the Arvados Playground, this is "info@curii.com":mailto:info@curii.com .
 
-Each row in the Virtual Machines panel lists the hostname of the VM, along with a <code>Log in as *you*</code> button under the column "Web shell". Clicking on this button will open up a webshell terminal for you in a new browser tab and log you in.
+Each row in the Virtual Machines panel lists the hostname of the VM, along with a <span class="btn btn-sm btn-default" style="background-color: #e0e0e0">Log in as [your name]</span> button under the column "*Web shell*". Clicking on this button will open up a webshell terminal for you in a new browser tab and log you in.
 
-!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/vm-access-with-webshell.png!
+!{width: 100%;}{{ site.baseurl }}/images/vm-access-with-webshell.png!
 
 You are now ready to work in your Arvados VM.
index 7091e31eae78fb02dc6b357a1befdf6568d469dc..d96280d30ad77b4bcbe1864c790c53bfe9dbfcba 100644 (file)
@@ -10,7 +10,7 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
 {% include 'notebox_begin' %}
-This guide covers the classic Arvados Workbench web application, sometimes referred to as "Workbench 1".  There is also a new Workbench web application under development called "Workbench 2".  This guide will be updated to cover "Workbench 2" in the future.  See "Workbench 2 migration":{{site.baseurl}}/user/topics/workbench-migration.html for more information.
+This guide covers modern Arvados Workbench web application, which may be referred to as "Workbench 2" to distinguish it from the previous Arvados Workbench web application ("Workbench 1").  Documentation for the classic Workbench can be found in "older versions of the user guide":https://doc.arvados.org/v2.6/user/getting_started/workbench.html .  See also "Workbench 2 migration":{{site.baseurl}}/user/topics/workbench-migration.html for more information.
 {% include 'notebox_end' %}
 
 You can access the Arvados Workbench used in this guide using this link:
@@ -27,6 +27,6 @@ h2. Logging in
 
 You will be asked to log in.  Arvados uses only your name and email address for identification, and will never access any personal information.  If you are accessing Arvados for the first time, the Workbench may indicate your account status is *New / inactive*.  If this is the case, contact the administrator of the Arvados instance to request activation of your account.
 
-Once your account is active, logging in to the Workbench will present you with the Dashboard. This gives a summary of your projects and recent activity in the Arvados instance.  You are now ready to "upload data":{{ site.baseurl }}/user/tutorials/tutorial-keep.html or "run your first workflow.":{{ site.baseurl }}/user/tutorials/tutorial-workflow-workbench.html
+Once your account is active, logging in to the Workbench will present you with an overview of your Home Projects.  You are now ready to "upload data":{{ site.baseurl }}/user/tutorials/tutorial-keep.html or "run your first workflow.":{{ site.baseurl }}/user/tutorials/tutorial-workflow-workbench.html
 
-!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/workbench-dashboard.png!
+!{width: 100%;}{{ site.baseurl }}/images/workbench-first-page.png!
index 6afc20bf4fd9071b7fa67cf9849960ea997bcb53..4c35530e607d1194ce3827400390c826693407bf 100644 (file)
@@ -15,11 +15,11 @@ Access the Arvados Workbench using this link: "{{site.arvados_workbench_host}}/"
 
 Open a shell on the system where you want to use the Arvados client. This may be your local workstation, or an Arvados virtual machine accessed with "Webshell":{{site.baseurl}}/user/getting_started/vm-login-with-webshell.html or SSH (instructions for "Unix":{{site.baseurl}}/user/getting_started/ssh-access-unix.html#login or "Windows":{{site.baseurl}}/user/getting_started/ssh-access-windows.html#login).
 
-In the Arvados Workbench, click on the dropdown menu icon <span class="fa fa-lg fa-user"></span> <span class="caret"></span> in the upper right corner of the top navigation menu to access the user settings menu, and click on the menu item *Current token*, which lists your current token and instructions to set up your environment.
+In the Arvados Workbench, click on the dropdown menu icon <span class="fa fa-lg fa-user"></span> in the upper right corner of the top navigation menu to access the _Account Management_ menu. Then, in the pop-up menu, click on the menu item *Get API token*. This will open a dialog box that lists your current token and the instructions for setting up your environment.
 
 h2. Setting environment variables
 
-The *Current token* page, accessed using the dropdown menu icon <span class="fa fa-lg fa-user"></span> <span class="caret"></span> in the upper right corner of the top navigation menu, includes a command you may copy and paste directly into the shell.  It will look something as the following.
+In the dialog box opened after clicking on the *Get API token* menu item, there is a sequence of commands you may copy and paste directly into the shell.  It will look something as the following.
 
 bc. HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
 export ARVADOS_API_TOKEN=2jv9346o396exampledonotuseexampledonotuseexes7j1ld
@@ -38,9 +38,12 @@ $ <span class="userinput">echo "ARVADOS_API_TOKEN=$ARVADOS_API_TOKEN" >> ~/.conf
 </code></pre>
 </notextile>
 
+* The output-redirection operator @>@ in the first command will cause the target file @~/.config/arvados/settings.conf@ to be created anew, wiping out the content of any existing file at that path.
+* The @>>@ operator in the second command appends to the target file.
+
 h2. .bashrc
 
-Alternately, you may add the declarations of @ARVADOS_API_HOST@ and @ARVADOS_API_TOKEN@ to the @~/.bashrc@ file on the system on which you intend to use the Arvados client.  If you have already put the variables into the environment following the instructions above, you can use these commands to append the environment variables to your @~/.bashrc@:
+Alternately, you may add the definitions of @ARVADOS_API_HOST@ and @ARVADOS_API_TOKEN@ to the @~/.bashrc@ file on the system where you intend to use the Arvados client.  If you have already put the variables into the environment following the instructions above, you can use the commands below to append to your @~/.bashrc@, which tells Bash to export them as environment variables in newly-started interactive shell sessions:
 
 <notextile>
 <pre><code>$ <span class="userinput">echo "export ARVADOS_API_HOST=$ARVADOS_API_HOST" >> ~/.bashrc</span>
index 15c9623224dd9440703b883c8d97dff2b97fab0c..a05620d62d7502d54a17d8e03df094d05810c64f 100644 (file)
@@ -15,7 +15,7 @@ This tutorial describes how to copy Arvados objects from one cluster to another
 
 h2. arv-copy
 
-@arv-copy@ allows users to copy collections, workflow definitions and projects from one cluster to another.
+@arv-copy@ allows users to copy collections, workflow definitions and projects from one cluster to another.  You can also use @arv-copy@ to import resources from HTTP URLs into Keep.
 
 For projects, @arv-copy@ will copy all the collections workflow definitions owned by the project, and recursively copy subprojects.
 
@@ -71,10 +71,14 @@ Additionally, if you need to specify the storage classes where to save the copie
 
 h3. How to copy a workflow
 
+Copying workflows requires @arvados-cwl-runner@ to be available in your @$PATH@.
+
 We will use the uuid @jutro-7fd4e-mkmmq53m1ze6apx@ as an example workflow.
 
+Arv-copy will infer the source cluster is @jutro@ from the object uuid, and destination cluster is @pirca@ from @--project-uuid@.
+
 <notextile>
-<pre><code>~$ <span class="userinput">arv-copy --src jutro --dst pirca --project-uuid pirca-j7d0g-ecak8knpefz8ere jutro-7fd4e-mkmmq53m1ze6apx</span>
+<pre><code>~$ <span class="userinput">arv-copy --project-uuid pirca-j7d0g-ecak8knpefz8ere jutro-7fd4e-mkmmq53m1ze6apx</span>
 ae480c5099b81e17267b7445e35b4bc7+180: 23M / 23M 100.0%
 2463fa9efeb75e099685528b3b9071e0+438: 156M / 156M 100.0%
 jutro-4zz18-vvvqlops0a0kpdl: 94M / 94M 100.0%
@@ -91,8 +95,10 @@ h3. How to copy a project
 
 We will use the uuid @jutro-j7d0g-xj19djofle3aryq@ as an example project.
 
+Arv-copy will infer the source cluster is @jutro@ from the source project uuid, and destination cluster is @pirca@ from @--project-uuid@.
+
 <notextile>
-<pre><code>~$ <span class="userinput">peteramstutz@shell:~$ arv-copy --project-uuid pirca-j7d0g-lr8sq3tx3ovn68k jutro-j7d0g-xj19djofle3aryq
+<pre><code>~$ <span class="userinput">arv-copy --project-uuid pirca-j7d0g-lr8sq3tx3ovn68k jutro-j7d0g-xj19djofle3aryq</span>
 2021-09-08 21:29:32 arvados.arv-copy[6377] INFO:
 2021-09-08 21:29:32 arvados.arv-copy[6377] INFO: Success: created copy with uuid pirca-j7d0g-ig9gvu5piznducp
 </code></pre>
@@ -101,3 +107,23 @@ We will use the uuid @jutro-j7d0g-xj19djofle3aryq@ as an example project.
 The name and description of the original project will be used for the destination copy.  If a project already exists with the same name, collections and workflow definitions will be copied into the project with the same name.
 
 If you would like to copy the project but not its subproject, you can use the @--no-recursive@ flag.
+
+h3. Importing HTTP resources to Keep
+
+You can also use @arv-copy@ to copy the contents of a HTTP URL into Keep.  When you do this, Arvados keeps track of the original URL the resource came from.  This allows you to refer to the resource by its original URL in Workflow inputs, but actually read from the local copy in Keep.
+
+<notextile>
+<pre><code>~$ <span class="userinput">arv-copy --project-uuid tordo-j7d0g-lr8sq3tx3ovn68k https://example.com/index.html</span>
+tordo-4zz18-dhpb6y9km2byb94
+2023-10-06 10:15:36 arvados.arv-copy[374147] INFO: Success: created copy with uuid tordo-4zz18-dhpb6y9km2byb94
+</code></pre>
+</notextile>
+
+In addition, when importing from HTTP URLs, you may provide a different cluster than the destination in @--src@. This tells @arv-copy@ to search the other cluster for a collection associated with that URL, and if found, copy the collection from that cluster instead of downloading from the original URL.
+
+The following @arv-copy@ command line options affect the behavior of HTTP import.
+
+table(table table-bordered table-condensed).
+|_. Option |_. Description |
+|==--varying-url-params== VARYING_URL_PARAMS|A comma separated list of URL query parameters that should be ignored when storing HTTP URLs in Keep.|
+|==--prefer-cached-downloads==|If a HTTP URL is found in Keep, skip upstream URL freshness check (will not notice if the upstream has changed, but also not error if upstream is unavailable).|
index 9a36435eac0e45e6a45868d4337da3af34f1a970..7ca04ffefa22d7f8af0f3219f3bab576fb740bd3 100644 (file)
@@ -9,17 +9,11 @@ Copyright (C) The Arvados Authors. All rights reserved.
 SPDX-License-Identifier: CC-BY-SA-3.0
 {% endcomment %}
 
-Arvados is in the process of migrating from the classic web application, referred to as "Workbench 1", to a completely new web application, referred to as "Workbench 2".
+Beginning in version 2.7, Arvados now defaults to a new web application, referred to as "Workbench 2".  This is a major step in the migration from the classic web application, referred to as "Workbench 1".  Workbench 1 should be considered deprecated and suppport for the Workbench 1 application will be dropped in a future Arvados release.
 
 !{width: 90%}{{ site.baseurl }}/images/wb2-example.png!
 
-Workbench 2 is the new Workbench web application that will, over time, replace Workbench 1. Workbench 2 is being built based on user feedback, and it is approaching feature parity with Workbench 1.  Workbench 2 has a modern look and feel and offers several advanced features and performance enhancements.  Arvados clusters typically have both Workbench applications installed and have a dropdown menu option in the user menu to switch between versions.
-
-!{{ site.baseurl }}/images/switch-to-wb2.png!
-
-Workbench 2 is stable and recommended for general use, but still lacks some features available in the classic Workbench 1 application.  When necessary, you can easily switch back:
-
-!{{ site.baseurl }}/images/switch-to-wb1.png!
+Workbench 2 is the new Workbench web application that replaces Workbench 1. Workbench 2 is being built based on user feedback, and has feature parity with Workbench 1.  Workbench 2 has a modern look and feel and offers many advanced features and performance enhancements over the previous Workbench application.
 
 Some major improvements of Workbench 2 include:
 
index e28b9612386d13aa49ff61a0ab8e8aca83dcf6a9..6046e7d14bd690f984631fbe6cf1920b6ef83b2c 100644 (file)
@@ -24,19 +24,19 @@ Before you start using Git and arvados repositories, you should do some basic co
 
 h2. Add "tutorial" repository
 
-On the Arvados Workbench, click on the dropdown menu icon <span class="fa fa-lg fa-user"></span> <span class="caret"></span> in the upper right corner of the top navigation menu to access the user settings menu, and click on the menu item *Repositories*.
+On the Arvados Workbench, click on the dropdown menu icon <i class="fa fa-lg fa-user"></i> (Account Management) in the upper right corner of the top navigation menu to access the user settings menu, and click on the menu item *Repositories*.
 
-In the *Repositories* page, you will see the *Add new repository* button.
+In the *Repositories* page, you will see the <span class="btn btn-sm btn-primary">+ NEW REPOSITORY</span> button.
 
-!{display: block;margin-left: 25px;margin-right: auto;}{{ site.baseurl }}/images/repositories-panel.png!
+!{width: 100%;}{{ site.baseurl }}/images/repositories-panel.png!
 
-Click the *Add new Repository* button to open the popup to add a new arvados repository. You will see a text box where you can enter the name of the repository. Enter *tutorial* in this text box and click on *Create*.
+Click the <span class="btn btn-sm btn-primary">+ NEW REPOSITORY</span> button to open the popup to add a new Arvados repository. You will see a text box where you can enter the name of the repository. Enter *tutorial* in this text box and click on *Create*.
 
 {% include 'notebox_begin' %}
 The name you enter here must begin with a letter and can only contain alphanumeric characters.
 {% include 'notebox_end' %}
 
-!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/add-new-repository.png!
+!{width: 100%;}{{ site.baseurl }}/images/add-new-repository.png!
 
 This will create a new repository with the name @$USER/tutorial@. It can be accessed using the URL <notextile><code>https://git.{{ site.arvados_api_host }}/$USER/tutorial.git</code></notextile> or <notextile><code>git@git.{{ site.arvados_api_host }}:$USER/tutorial.git</code></notextile>
 
index a552e4ee000abff673010fdf1c92e0d00fb6099d..a4ac2a5795c5072c5f06de7da0efaf6f96a6cbd4 100644 (file)
@@ -24,7 +24,7 @@ Before you start using Git, you should do some basic configuration (you only nee
 ~$ <span class="userinput">git config --global user.email $USER@example.com</span></code></pre>
 </notextile>
 
-On the Arvados Workbench, click on the dropdown menu icon <span class="fa fa-lg fa-user"></span> <span class="caret"></span> in the upper right corner of the top navigation menu to access the user settings menu, and click on the menu item *Repositories*. In the *Repositories* page, you should see the @$USER/tutorial@ repository listed in the *name* column.  Next to *name* is the column *URL*. Copy the *URL* value associated with your repository.  This should look like <notextile><code>https://git.{{ site.arvados_api_host }}/$USER/tutorial.git</code></notextile>. Alternatively, you can use <notextile><code>git@git.{{ site.arvados_api_host }}:$USER/tutorial.git</code></notextile>
+On the Arvados Workbench, click on the dropdown menu icon <i class="fa fa-lg fa-user"></i> in the upper right corner of the top navigation menu to access the Account Management menu, and click on the menu item *Repositories*. In the *Repositories* page, you should see the @$USER/tutorial@ repository listed in the *name* column.  Next to *name* is the column *URL*. Copy the *URL* value associated with your repository.  This should look like <notextile><code>https://git.{{ site.arvados_api_host }}/$USER/tutorial.git</code></notextile>. Alternatively, you can use <notextile><code>git@git.{{ site.arvados_api_host }}:$USER/tutorial.git</code></notextile>
 
 Next, on the Arvados virtual machine, clone your Git repository:
 
index 9ddec04f5e7459194f7758e70d14c7d4a751d864..234458c82e5c4cb9aac41da863f0cdbb6115bef1 100644 (file)
@@ -22,9 +22,9 @@ h2(#delete-collection). Trashing (deleting) collections
 
 A collection can be trashed using workbench or the arv command line tool.
 
-h3. Trashing a collection using workbench
+h3. Trashing a collection using Workbench
 
-To trash a collection using workbench, go to the Data collections tab in the project, and use the <i class="fa fa-fw fa-trash-o"></i> trash icon for this collection row.
+To trash a collection using Workbench, open the ︙ action menu for the collection, and select *Move to trash*. You can do this from the collection page directly, or from the project listing that contains the collection.
 
 h3. Trashing a collection using arv command line tool
 
@@ -36,11 +36,11 @@ h2(#trash-recovery). Recovering trashed collections
 
 A collection can be untrashed / recovered using workbench or the arv command line tool.
 
-h3. Untrashing a collection using workbench
+h3. Untrashing a collection using Workbench
 
-To untrash a collection using workbench, go to trash page on workbench by clicking on the "Trash" icon in the top navigation in workbench and use the recycle icon or selection dropdown option.
+To untrash a collection using Workbench, open the *Trash* page from the left navigation menu. For each collection in this listing, you can press the *Restore* button on the far right to untrash it. You can also open a collection to review its contents. From that collection page, you can open the ︙ action menu and select *Restore* to untrash the collection.
 
-!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/trash-button-topnav.png!
+!{width: 80%}{{ site.baseurl }}/images/trash-buttons.png!
 
 h3. Untrashing a collection using arv command line tool
 
index 05924f8475874a878c1d314e6454f52d76251158..5fa31970c4f03de6caad3de923104ea66aeff0c2 100644 (file)
@@ -12,36 +12,34 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 Arvados Data collections can be downloaded using either the arv commands or using Workbench.
 
 # "*Download using Workbench*":#download-using-workbench
-# "*Sharing collections*":#download-shared-collection
+# "*Creating a special download URL for a collection*":#download-shared-collection
 # "*Download using command line tools*":#download-using-arv
 
 h2(#download-using-workbench). Download using Workbench
 
 You can also download Arvados data collections using the Workbench.
 
-Visit the Workbench *Dashboard*. Click on *Projects*<span class="caret"></span> dropdown menu in the top navigation menu, select your *Home* project. You will see the *Data collections* tab, which lists the collections in this project.
+When you visit a project in Workbench (for instance, the <i class="fa fa-fw fa-folder"></i> *Home Projects* or any projects under it), the collections will show up on the project details page, with "_Data collection_" in the *Type* column.
 
-You can access the contents of a collection by clicking on the *<i class="fa fa-fw fa-archive"></i> Show* button next to the collection. This will take you to the collection's page. Using this page you can see the collection's contents, and download individual files.
+Clicking on a collection will bring you to its details page. There, the lower panel acts like a file manager where you can navigate to or search for files, select them for actions, and download them.
 
-You can now download the collection files by clicking on the <span class="btn btn-sm btn-info"><i class="fa fa-download"></i></span> button(s).
+To download a file, simply click on the file, or bring up the context menu using right-click or the triple-dot button on its row, and then select the menu item *Download*.
 
-h2(#download-shared-collection). Sharing collections
+h2(#download-shared-collection). Creating a special download URL for a collection
 
-h3. Sharing with other Arvados users
+To share a collection with users that do not have an account on your Arvados cluster, locate the collection and then go to the *Sharing settings* dialog box as described above. There, select the *SHARING URLS* tab.
 
-Collections can be shared with other users on the Arvados cluster by sharing the parent project.  Navigate to the parent project using the "breadcrumbs" bar, then click on the *Sharing* tab.  From the sharing tab, you can choose which users or groups to share with, and their level of access.
+You can then generate a new sharing URL using the <span class="btn btn-sm btn-primary">CREATE SHARING URL</span> button, with the option to set an expiration time for the URL. You can then copy the URL to the clipboard for sharing with others. To revoke (that is, delete) a sharing URL, click on the cross icon beside it.
 
-h3. Creating a special download URL
+<figure>!{width: 80%}{{ site.baseurl }}/images/sharing-collection-url.png!<figcaption>_The_ *SHARING URLS* _tab in the_ *Sharing settings* _dialog box, showing the created URL with an expiration time_</figcaption></figure>
 
-To share a collection with users that do not have an account on your Arvados cluster, visit the collection page using Workbench as described in the above section. Once on this page, click on the <span class="btn btn-sm btn-primary" >Create sharing link</span> button.
+Any user with the sharing URL can download this collection by simply accessing this URL using browser. It will present a downloadable version of the collection as shown below.
 
-This will create a sharing link for the collection as shown below. You can copy the sharing link in this page and share it with other users.
-
-!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/shared-collection.png!
+!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/download-shared-collection.png!
 
-A user with this url can download this collection by simply accessing this url using browser. It will present a downloadable version of the collection as shown below.
+When a collection is being shared by URL, in the *WITH USERS/GROUS* tab of *Sharing settings*, the following message will appear if *General access* is Private: _Although there aren't specific permissions set, this is publicly accessible via Sharing URL(s)._
 
-!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/download-shared-collection.png!
+* *Note:* Sharing by URL is specific to collections. Projects or individual files cannot be shared in this way.
 
 h2(#download-using-arv). Download using command line tools
 
index 21efc475c54b4b5baa5c1023b029aa82708181ef..1832a1530e391e79920b8b2aa1c4d5b663ee2509 100644 (file)
@@ -12,40 +12,40 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 Arvados Data collections can be uploaded using either Workbench or the @arv-put@ command line tool.
 
 # "*Upload using Workbench*":#upload-using-workbench
-# "*Creating projects*":#creating-projects
 # "*Upload using command line tool*":#upload-using-command
 
 h2(#upload-using-workbench). Upload using Workbench
 
-To upload using Workbench, visit the Workbench *Dashboard*. Click on *Projects*<span class="caret"></span> dropdown menu in the top navigation menu and select your *Home* project or any other project of your choosing.  You will see the *Data collections* tab for this project, which lists the collections in this project.
+To upload using Workbench, first identify the project to upload the files into. This is done by browsing your projects in the navigation menu on the left, or to search for the project using the search field on the top.
 
-To upload files into a new collection, click on *Add data*<span class="caret"></span> dropdown menu and select *Upload files from my computer*.
+Having navigated to the project, click on the <span class="btn btn-sm btn-primary">+ NEW</span> button in the top-left corner. In the pop-up menu, select the item *New collection*.
 
-!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/upload-using-workbench.png!
+<figure> !{width: 80%;}{{ site.baseurl }}/images/add-new-collection-wb2.png! <figcaption> _Creating a new collection in the project "WGS Processing Tutorial"_ </figcaption></figure>
 
-<br/>This will create a new empty collection in your chosen project and will take you to the *Upload* tab for that collection.
+In the dialog box that follows, you will be prompted to create a new collection in your chosen project. Here, the *Collection Name* field is required. After entering the name for this new collection (and optionally other fields), you have the choice to create it with new file updates -- by drag-and-drop into the *Files* area or with the traditional file-upload dialog opened by your browser.
 
-!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/upload-tab-in-new-collection.png!
+<figure>!{width: 100%;}{{ site.baseurl }}/images/new-collection-modal-wb2.png!<figcaption>_Providing the new collection with a name (required). Optionally, you can upload files in this step._</figcaption></figure>
 
-Click on the *Browse...* button and select the files you would like to upload. Selected files will be added to a list of files to be uploaded. After you are done selecting files to upload, click on the *<i class="fa fa-fw fa-play"></i> Start* button to start upload. This will start uploading files to Arvados and Workbench will show you the progress bar. When upload is completed, you will see an indication to that effect.
+You can then click on the <span class="btn btn-sm btn-primary">CREATE A COLLECTION</span> button and proceed to the newly-created collection's page. If you don't upload any data when creating the collection, the new collection will be empty, and you can upload files into it later.
 
-!{display: block;margin-left: 25px;margin-right: auto;border:1px solid lightgray;}{{ site.baseurl }}/images/files-uploaded.png!
+<figure>!{width: 100%;}{{ site.baseurl }}/images/newly-created-collection-empty-wb2.png!<figcaption>_The newly-created collection without any files yet._</figcaption></figure>
 
-*Note:* If you leave the collection page during the upload, the upload process will be aborted and you will need to upload the files again.
+In the <span class="btn btn-sm btn-primary">FILES</span> panel, there is a button labeled <span class="btn btn-sm btn-primary">UPLOAD DATA</span>. Click on it, and you will be prompted to upload files by drag-and-drop or the file-selection dialog opened by your browser.
 
-*Note:* You can also use the Upload tab to add additional files to an existing collection.
+The files you choose to upload will then be displayed, and you can review them before clicking on the <span class="btn btn-sm btn-primary">UPLOAD DATA</span> button to initiate the actual file transfer.
 
-notextile. <div class="spaced-out">
+<figure>!{width: 100%;}{{ site.baseurl }}/images/upload-data-prompt-with-files-wb2.png!<figcaption>_Selecting the files to upload_</figcaption></figure>
 
-h2(#creating-projects). Creating projects
+Once the file upload completes, you will be notified by a message, and the files will appear under the <span class="btn btn-sm btn-primary">FILES</span> panel shortly.
 
-Files are organized into Collections, and Collections are organized by Projects.
+<figure>!{width: 100%;}{{ site.baseurl }}/images/upload-data-progress-wb2.png!<figcaption>_Upload status being displayed, with the files to appear shortly_</figcaption></figure>
 
-Click on *Projects*<span class="caret"></span> <span class="rarr">&rarr;</span> <i class="fa fa-fw fa-plus"></i>*Add a new project* to add a top level project.
+*Note:* If you leave the collection page during the upload, the upload process will be aborted and you will need to upload the files again.
 
-To create a subproject, navigate to the parent project, and click on <i class="fa fa-fw fa-plus"></i>*Add a subproject*.
+*Note:* You can also use the <span class="btn btn-sm btn-primary">UPLOAD DATA</span> button to add additional files to an existing collection.
+
+notextile. <div class="spaced-out">
 
-See "Sharing collections":tutorial-keep-get.html#download-shared-collection for information about sharing projects and collections with other users.
 
 h2(#upload-using-command). Upload using command line tool
 
@@ -85,12 +85,12 @@ In both examples, the @arv-put@ command created a collection. The first collecti
 
 h3. Locate your collection in Workbench
 
-Visit the Workbench *Dashboard*.  Click on *Projects*<span class="caret"></span> dropdown menu in the top navigation menu, select your *Home* project.  Your newly uploaded collection should appear near the top of the *Data collections* tab.  The collection name printed by @arv-put@ will appear under the *name* column.
+Visit the Workbench and go to your <i class="fa fa-fw fa-folder"></i> *Home Projects*.  Your newly uploaded collection should appear in the main panel.  The collection name printed by @arv-put@ will appear under the *Name* column, and its *Type* will be "_Data collection_".
 
-To move the collection to a different project, check the box at the left of the collection row.  Pull down the *Selection...*<span class="caret"></span> menu near the top of the page tab, and select *Move selected...* button. This will open a dialog box where you can select a destination project for the collection.  Click a project, then finally the <span class="btn btn-sm btn-primary">Move</span> button.
+Click on the collection's name will lead you to its Workbench page, where you can see the collection's contents and download individual files.
 
-!{display: block;margin-left: 25px;margin-right: auto;}{{ site.baseurl }}/images/workbench-move-selected.png!
+To move the collection to a different project, locate the collection and right-click on it. This will bring up a context menu with *Move to*. Click on this item, and you will see a dialog box where you can select the target project to move this collection to, by search or navigation. This context menu is also available from the triple-dot button in the project/collection listing or the collection details page.
 
-Click on the *<i class="fa fa-fw fa-archive"></i> Show* button next to the collection's listing on a project page to go to the Workbench page for your collection.  On this page, you can see the collection's contents, download individual files, and set sharing options.
+<figure>!{width: 80%;}{{ site.baseurl }}/images/workbench-move-wb2.png!<figcaption> _Context menu with the_ *Move to* _item_ </figcaption></figure>
 
 notextile. </div>
diff --git a/doc/user/tutorials/tutorial-projects.html.textile.liquid b/doc/user/tutorials/tutorial-projects.html.textile.liquid
new file mode 100644 (file)
index 0000000..b4dc9ed
--- /dev/null
@@ -0,0 +1,41 @@
+---
+layout: default
+navsection: userguide
+title: "Organizating data"
+...
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+h2. Projects and Collections
+
+In Arvados, files are organized into "collections", and collections are organized by "project".
+
+Only collections can contain files.  A collection is a distinct database record identified by a universal unique id (UUID).  Arvados maintains a history of changes to the collection.  Every collection version has an immutable identifier called a "portable data hash" which is computed from the file content of the collection.  This can be used to refer to the immutable file content independently of the collection UUID.  If two collections have the same portable data hash, they have the same file content.
+
+Projects contain collections, workflows and workflow runs, and other projects (subprojects).  Both collections and projects can have user-provided metadata.
+
+Projects are the main unit of organization and sharing.  See "Sharing collections":#sharing-projects for information about sharing projects and collections with other users.
+
+h2(#creating-projects). Creating a project
+
+When you have navigated to any existing project, clicking on <span class="btn btn-sm btn-primary">+ NEW</span> <span class="rarr">&rarr;</span> <i class="fa fa-fw fa-folder"></i> *New project* will prompt you to create a new subproject under the current project.
+
+If you're at the top-level <i class="fa fa-fw fa-folder"></i> *Home Projects*, a new top-level project will be created.
+
+Alternatively, you can right-click on the link to an existing project to bring up a context menu, and select *New project*.
+
+h2(#sharing-projects). Sharing projects
+
+Projects can be shared with other users on the Arvados cluster.  First, locate the collection or project using any available means (for instance, by manually navigating in the Workbench, or using the Search bar). Then right-click on its link in a listing, or click on the triple-dot button in the details page. You will find the menu item *Share*, which opens the dialog box *Sharing settings*.
+
+To share with other Arvados users, select the *WITH USERS/GROUPS* tab in the *Sharing settings* dialog box. Under *Add people and groups*, in the input field you can search for the user or group names. Select one you will be sharing with, choose the *Authorization* level (Read/Write/Manage) in the drop-down menu, and click on the plus sign (+) on the right. This can be repeated for other users or groups, each with their own *Authorization* level. The selected ones will appear under *People with access*. You can revisit the *Sharing settings* dialog box to modify the users or their access levels at a later time.
+
+The *General access* drop-down menu controls the default sharing setting, with the following choices:
+
+* *Private*: This is the initial state when no users or groups have been selected for sharing. At any time, by setting *General access* to private, the current sharing setting will be cleared, and any users or groups formerly with access will lose that access.
+* *Public*: This means the list of *People with access* will include _Anonymous users_, even if they are not users of the current cluster. You can further set their access level in the *Authorization* level.
+* *All users*: This means sharing with other users who are logged in on the current cluster.
+* *Shared*: When you choose to share with specific people or groups, *General access* will be set to *Shared*. From this state, you can further specify the default sharing settings for *Public* and *All users*.
index 8a082257231196293c06bf2c6e3b5c879df6e3c7..3259f0d24dec319d669f8d720d5b7eae79d8b98d 100644 (file)
@@ -21,18 +21,19 @@ h3. Steps
 
 notextile. <div class="spaced-out">
 
-# Start from the *Workbench Dashboard*.  You can access the Dashboard by clicking on *<i class="fa fa-lg fa-fw fa-dashboard"></i> Dashboard* in the upper left corner of any Workbench page.
-# Click on the <span class="btn btn-sm btn-primary"><i class="fa fa-fw fa-gear"></i> Run a process...</span> button.  This will open a dialog box titled *Choose a pipeline or workflow to run*.
-# In the search box, type in *bwa-mem.cwl*.
-# Select *<i class="fa fa-fw fa-gear"></i> bwa-mem.cwl* and click the <span class="btn btn-sm btn-primary" >Next: choose inputs <i class="fa fa-fw fa-arrow-circle-right"></i></span> button.  This will create a new process in your *Home* project and will open it. You can now supply the inputs for the process. Please note that all required inputs are populated with default values and you can change them if you prefer.
-# For example, let's see how to set read pair *read_p1* and *read_p2* for this workflow. Click the <span class="btn btn-sm btn-primary">Choose</span> button beneath the *read_p1* header.  This will open a dialog box titled *Choose a file*.
-# In the file dialog, click on *Home <span class="caret"></span>* menu and then select *All Projects*.
-# Enter *HWI-ST1027* into the search box.  You will see one or more collections. Click on *<i class="fa fa-fw fa-archive"></i>  HWI-ST1027_129_D0THKACXX for CWL tutorial*
-# The right hand panel will list two files.  Click on the first one ending in "_1" and click the <span class="btn btn-sm btn-primary" >OK</span> button.
-# Repeat the steps 5-8 to set the *read_p2* except selecting the second file ending in "_2"
-# Scroll to the bottom of the "Inputs" panel and click on the <span class="btn btn-sm btn-primary" >Run <i class="fa fa-fw fa-play"></i></span> button.  The page updates to show you that the process has been submitted to run on the Arvados cluster.
-# Once the process starts running, you can track the progress by watching log messages from the component(s).  This page refreshes automatically.  You will see a <span class="label label-success">complete</span> label when the process completes successfully.
-# Click on the *Output* link to see the results of the process.  This will load a new page listing the output files from this process.  You'll see the output SAM file from the alignment tool under the *Files* tab.
-# Click on the <span class="btn btn-sm btn-info"><i class="fa fa-download"></i></span> download button to the right of the SAM file to download your results.
+# Click on the <span class="btn btn-sm btn-primary">+ NEW</span> button in the top-left.
+# In the pop-up menu, select *<i class="fa fa-fw fa-gear"></i> Run a workflow*.  This will open the _Run Process_ panel in the Workbench.
+# In the search field under *Choose a workflow*, type in _bwa-mem.cwl_.
+# Select *bwa-mem.cwl* in the search results, and click the <span class="btn btn-sm btn-primary" >NEXT</span> button.  This will create a new process in one of your Home Projects and will open it. To specify the project for the workflow run, click on the input line below "*Project where the workflow will run*", and in the pop-up dialog box, choose a project under your Home Projects.
+# You can now supply the inputs for the process. Please note that all required inputs are populated with default values and you can change them if you prefer.
+# For example, let's see how to set read pair *read_p1* and *read_p2* for this workflow. Click on the input line under the *read_p1* header.  This will open a dialog box titled *Choose a file*.
+# Enter the search terms _user guide resources_ into the *Search for a Project* field on the left.  You will see one or more collections in the search results appearing below and, among them, the one with the exact title *<i class="fa fa-fw fa-folder"></i> User guide resources*. Your goal is to locate the file _HWI-ST1027_129_D0THKACXX.1_1.fastq_.
+# You may either locate the file manually, by clicking on the triangles ▶ to the left of each item to expand them (projects and the collections under it) until you find the file, or by filtering the search results using the *Filter Collections list in Projects* field, for example, with a term like "_HWI-ST1027_".
+# Either way, you will find the file <i class="fa fa-fw fa-file"></i> *HWI-ST1027_129_D0THKACXX.1_1.fastq* in the search results. Click on it, and then the <span class="btn btn-sm btn-primary">OK</span> button in the bottom-right.
+# Repeat the steps 7--9 to set the value for *read_p2*, except selecting the file ending in "_2"
+# Scroll to the bottom of the "Inputs" panel and click on the <span class="btn btn-sm btn-primary" >RUN WORKFLOW</span> button.  The page updates to show you that the process has been queued to run on the Arvados cluster.
+# Once the process starts running, you can track the progress by watching the log messages from the component(s) (scroll down to the *Logs* panel).  This page refreshes automatically, and you can also click on the <span class="btn btn-sm btn-primary">REFRESH</span> button on the top of the page. You will see a <span class="label label-success">Completed</span> label when the process completes successfully.
+# The output of the workflow can be found by following the link "Output from bwa-mem.cwl" under the heading *Output collection* in the main or <span class="btn btn-sm btn-primary">DETAILS</span> panel, or in the <span class="btn btn-sm btn-primary">OUTPUTS</span> panel further down. Click on the *Output from bwa-mem.cwl* link to see the detailed results from the workflow run.  This will lead you to a page that lists the metadata of the outputs, and you'll see the output SAM file there, in the <span class="btn btn-sm btn-primary">FILES</span> panel.
+# To download your results, simply click on the SAM file name.
 
 notextile. </div>
index 81ad97ed8337cab48af4b30cac4c1528fcf26430..b64dc828bd2fc53e5ff1ed125df588ee9be454ad 100644 (file)
@@ -58,21 +58,24 @@ _Ways to Learn More About CWL_
 
 h2. 3. Setting Up to Run the WGS Processing Workflow
 
-Let’s get a little familiar with the Arvados Workbench while also setting up to run the WGS processing tutorial workflow.  Logging into the workbench will present you with the Dashboard. This gives a summary of your projects and recent activity in your Arvados instance, i.e. the Arvados Playground.  The Dashboard will only give you information about projects and activities that you have permissions to view and/or access.  Other users' private or restricted projects and activities will not be visible by design.
+Let’s get a little familiar with the Arvados Workbench while also setting up to run the WGS processing tutorial workflow.  Logging into the workbench will present you with the front page. This gives a summary of your projects in your Arvados instance (i.e. the Arvados Playground) as well as a left hand side navigation bar, top search bar, and help, profile settings, and notifications on the top right.  The front page will only give you information about projects and activities that you have permissions to view and/or access.  Other users' private or restricted projects and activities will not be visible by design.
 
 h3. 3a. Setting up a New Project
 
 Projects in Arvados help you organize and track your work - and can contain data, workflow code, details about workflow runs, and results.  Let’s begin by setting up a new project for the work you will be doing in this walkthrough.
 
-To create a new project, go to the Projects dropdown menu and select “Add a New Project”.
+To create a new project, go to the Projects dropdown menu and select the "+NEW" button, then select “New project”.
 
 <figure> !{width: 100%}{{ site.baseurl }}/images/wgs-tutorial/image4.png!
-<figcaption> _*Figure 3*:  Adding a new project using Arvados Workbench._ </figcaption> </figure>
+<figcaption> _*Figure 3*:  Adding a new project using Arvados Workbench, select the "+NEW" button in the upper left-hand corner and click "New project"._ </figcaption> </figure>
 
-Let’s name your project “WGS Processing Tutorial”. You can also add a description of your project using the  *Edit* button. The universally unique identifier (UUID) of the project can be found in the URL.
+Let’s name your project “WGS Processing Tutorial”. You can also add a description of your project by typing in the **Description - optional** field. The universally unique identifier (UUID) of the project can be found in the URL, or by clicking the info button on the upper right-hand corner.
 
 <figure> !{width: 100%}{{ site.baseurl }}/images/wgs-tutorial/image6.png!
-<figcaption> _*Figure 4*:  Renaming new project using Arvados Workbench.   The UUID of the project can be found in the URL and is highlighted in yellow in this image for emphasis._ </figcaption> </figure>
+<figcaption> _*Figure 4*:  Renaming new project using Arvados Workbench, enter the name in the "Project Name" box._ </figcaption> </figure>
+
+<figure> !{width: 100%}{{ site.baseurl }}/images/wgs-tutorial/image7.png!
+<figcaption> _*Figure 5*: The UUID of the project can be found by selecting the "i" in the upper right-hand corner, under "UUID" and copied using the copy to clipboard option, highlighted in yellow in this image for emphasis._ </figcaption> </figure>
 
 If you choose to use another name for your project, just keep in mind when the project name is referenced in the walkthrough later on.
 
@@ -80,18 +83,18 @@ h3. 3b. Working with Collections
 
 Collections in Arvados help organize and manage your data. You can upload your existing data into a collection or reuse data from one or more existing collections. Collections allow us to reorganize our files without duplicating or physically moving the data, making them very efficient to use even when working with terabytes of data.   Each collection has a universally unique identifier (collection UUID).  This is a constant for this collection, even if we add or remove files -- or rename the collection.  You use this if we want to to identify the most recent version of our collection to use in our workflows.
 
-Arvados uses a content-addressable filesystem (i.e. Keep) where the addresses of files are derived from their contents.  A major benefit of this is that Arvados can then verify that when a dataset is retrieved it is the dataset you requested  and can track the exact datasets that were used for each of our previous calculations.  This is what allows you to be certain that we are always working with the data that you think you are using.  You use the content address of a collection when you want to guarantee that you use the same version as input to your workflow.
+Arvados uses a content-addressable filesystem (i.e. Keep) where the addresses of files are derived from their contents.  A major benefit of this is that Arvados can then verify that when a dataset is retrieved it is the dataset you requested  and can track the exact datasets that were used for each of our previous calculations.  This is what allows you to be certain that we are always working with the data that you think you are using.  You use the portable data hash of a collection when you want to guarantee that you use the same version as input to your workflow.
 
 <figure> !{width: 100%}{{ site.baseurl }}/images/wgs-tutorial/image1.png!
-<figcaption> _*Figure 5*:  A collection in Arvados as viewed via the Arvados Workbench. On the upper left you will find a panel that contains: the name of the collection (editable), a description of the collection (editable),  the collection UUID and the content address and content size._ </figcaption> </figure>
+<figcaption> _*Figure 6*:  A collection in Arvados as viewed via the Arvados Workbench. You will find a panel that contains: the name of the collection (this is editable, if you hit the three dots in the upper right-hand corner and click "Edit collection"), a description of the collection (also editable through the same way), the collection UUID, the portable data hash, content size, and some other information like version number._ </figcaption> </figure>
 
 Let’s start working with collections by copying the existing collection that stores the FASTQ data being processed into our new “WGS Processing Tutorial” project.
 
-First, you must find the collection you are interested in copying over to your project.  There are several ways to search for a collection: by collection name, by UUID or by content address.  In this case, let’s search for our collection by name.
+First, you must find the collection you are interested in copying over to your project.  There are several ways to search for a collection: by collection name, by UUID or by portable data hash.  In this case, let’s search for our collection by name.
 
-In this case it is called “PGP UK FASTQs” and by searching for it in the “search this site” box.  It will come up and you can navigate to it.  You would do similarly if you would want to search by UUID or content address.
+In this case it is called “PGP UK FASTQs (ten genomes)” and by searching for it in the “Search” box.  It will come up and you can navigate to it.  You would do similarly if you would want to search by UUID or portable data hash.
 
-Now that you have found the collection of FASTQs you want to copy to your project, you can simply use the <span class="btn btn-sm btn-primary" >Copy to project...</span> button and select your new project to copy the collection there.  You can rename your collection whatever you wish, or use the default name on copy and add whatever description you would like.
+Now that you have found the collection of FASTQs you want to copy to your project, you can simply click the three dots in the right corner and click "Make a copy" and select your new project to copy the collection there.  You can rename your collection whatever you wish, or use the default name on copy and add whatever description you would like.
 
 
 
@@ -105,17 +108,18 @@ In this section, we will be discussing three ways to run the tutorial workflow u
 
 h3. 4a. Interactively Running a Workflow Using Workbench
 
-Workflows can be registered in Arvados. Registration allows you to share a workflow with other Arvados users, and let’s them run the workflow by clicking the  <span class="btn btn-sm btn-primary" >Run a process…</span> button on the Workbench Dashboard and on the command line by specifying the workflow UUID.  Default values can be specified for workflow inputs.
+Workflows can be registered in Arvados. Registration allows you to share a workflow with other Arvados users, and let’s them run the workflow by clicking the  "+NEW" button and selecting "Run a workflow" on the Workbench Dashboard or on the command line by specifying the workflow UUID.  Default values can be specified for workflow inputs.
 
 We have already previously registered the WGS workflow and set default input values for this set of the walkthrough.
 
 Let’s find the registered WGS Processing Workflow and run it interactively in our newly created project.
 
-# To find the registered workflow, you can search for it in the search box located in the top right corner of the Arvados Workbench by looking for the name  “WGS Processing Workflow”.
-# Once you have found the registered workflow, you can run it your project by using the  <span class="btn btn-sm btn-primary" >Run this workflow..</span> button and selecting your project ("WGS Processing Tutorial") that you set up in Section 3a.
-# Default inputs to the registered workflow will be automatically filled in.  These inputs will still work.  You can verify this by checking the addresses of the collections you copied over to your New Project.
-# The input *Directory of paired FASTQ files* will need to be set.  Click on <span class="btn btn-sm btn-primary" >Choose</span> button, select "PGP UK FASTQs" in the *Choose a dataset* dialog and then click <span class="btn btn-sm btn-primary" >OK</span>.
-# Now, you can submit your workflow by scrolling to the bottom of the page and hitting the <span class="btn btn-sm btn-primary" >Run</span> button.
+# To find the registered workflow, in the left-hand navigation bar, select "Public Favorites". That listing will include the "WGS Processing Workflow" project. Open that project, and it will include the workflow "WGS processing workflow scattered over samples". Open that workflow.
+# Once you have found the registered workflow, you can run it your project by using the "Run Workflow" button and selecting your project ("WGS Processing Tutorial") that you set up in Section 3a, under *Project where the workflow will run*.
+<figure> !{width: 100%}{{ site.baseurl }}/images/wgs-tutorial/image8.png!
+<figcaption> _*Figure 7*: This is the page that pops up when you hit "Run Workflow", the input that needs selected is highlighted in yellow._ </figcaption> </figure>
+# Default inputs to the registered workflow will be automatically filled in.  These inputs will still work.  You can verify this by checking the addresses of the collections you copied over to your new project.
+# Now, you can submit your workflow by selecting the "Run Workflow" button.
 
 Congratulations! You have now submitted your workflow to run. You can move to Section 5 to learn how to check the state of your submitted workflow and Section 6 to learn how to examine the results of and logs from your workflow.
 
@@ -171,7 +175,7 @@ The tutorial directories are as follows:
 
 Before we run the WGS processing workflow, we want to adjust the inputs to match those in your new project.  The workflow that we want to submit is described by the file @/cwl/@ and the inputs are given by the file @/yml/@.  Note: while all the cwl files are needed to describe the full workflow only the single yml with the workflow inputs is needed to run the workflow. The additional yml files (in the helper folder) are provided for testing purposes or if one might want to test or run an underlying subworkflow or cwl for a command line tool by itself.
 
-Several of the inputs in the yml file point to original content addresses of collections that you make copies of in our New Project.  These still work because even though we made copies of the collections into our new project we haven’t changed the underlying contents. However, by changing this file is in general how you would alter the inputs in the accompanying yml file for a given workflow.
+Several of the inputs in the yml file point to original portable data hashes of collections that you make copies of in our New Project.  These still work because even though we made copies of the collections into our new project we haven’t changed the underlying contents. However, by changing this file is in general how you would alter the inputs in the accompanying yml file for a given workflow.
 
 The command to submit to the Arvados Playground Cluster is @arvados-cwl-runner@.
 To submit the WGS processing workflow , you need to run the following command replacing YOUR_PROJECT_UUID with the UUID of the new project you created for this tutorial.
@@ -192,23 +196,20 @@ Now, you are ready to check the state of your submitted workflow.
 
 h2. 5.  Checking the State Of a Submitted Workflow
 
-Once you have submitted your workflow, you can examine its state interactively using the Arvados Workbench.  If you aren’t already viewing your workflow process on the workbench, there several ways to get to your submitted workflow.  Here are two of the simplest ways:
-
-* Via the Dashboard: It should be listed at the top of the list of “Recent Processes”. Just click on the name of your submitted workflow and it will take you to the submitted workflow information.
-* Via Your Project:  You will want to go back to your new project, using the Projects pulldown menu or searching for the project name.  Note: You can mark a Project as a favorite (if/when you have multiple Projects) to make it easier to find on the pulldown menu using the star next to the project name on the project page.
+Once you have submitted your workflow, you can examine its state interactively using the Arvados Workbench.  If you aren’t already viewing your workflow process on the workbench, you can navigate there via your project. You will want to go back to your new project, using the projects pulldown menu (the list of projects on the left) or searching for the project name.  Note: You can mark a project as a favorite (if/when you have multiple projects) to make it easier to find on the pulldown menu by right-clicking on the project name on the project pulldown menu and selecting "Add to favorites".
 
-The process you will be looking for will be titled “WGS processing workflow scattered over samples”(if you submitted via the command line) or NAME OF REGISTERED WORKFLOW container (if you submitted via the Registered Workflow).
+The process you will be looking for will be titled “WGS processing workflow scattered over samples” (if you submitted via the command line/Workbench).
 
 Once you have found your workflow, you can clearly see the state of the overall workflow and underlying steps below by their label.
 
 Common states you will see are as follows:
 
-* <span class="label label-default">Queued</span>  -  Workflow or step is waiting to run
-* <span class="label label-info">Running</span> or <span class="label label-info">Active</span> - Workflow is currently running
-* <span class="label label-success">Complete</span> - Workflow or step has successfully completed
-* <span class="label label-warning">Failing</span> - Workflow is running but has steps that have failed
-* <span class="label label-danger">Failed</span> - Workflow or step did not complete successfully
-* <span class="label label-danger">Cancelled</span>  - Workflow or step was either manually cancelled or was canceled by Arvados due to a system error
+* "Queued"  -  Workflow or step is waiting to run
+* "Running" or "Active"- Workflow is currently running
+* "Complete" - Workflow or step has successfully completed
+* "Failing"- Workflow is running but has steps that have failed
+* "Failed"- Workflow or step did not complete successfully
+* "Cancelled"  - Workflow or step was either manually cancelled or was canceled by Arvados due to a system error
 
 Since Arvados Crunch reuses steps and workflows if possible, this workflow should run relatively quickly since this workflow has been run before and you have access to those previously run steps.  You may notice an initial period where the top level job shows the option of canceling while the other steps are filled in with already finished steps.
 
@@ -217,13 +218,13 @@ h2. 6.  Examining a Finished Workflow
 Once your workflow has finished, you can see how long it took the workflow to run, see scaling information, and examine the logs and outputs.  Outputs will be only available for steps that have been successfully completed.   Outputs will be saved for every step in the workflow and be saved for the workflow itself.  Outputs are saved in collections.  You can access each collection by clicking on the link corresponding to the output.
 
 <figure> !{width: 100%}{{ site.baseurl }}/images/wgs-tutorial/image5.png!
-<figcaption> _*Figure 6*:  A completed workflow process in Arvados as viewed via the Arvados Workbench. You can click on the outputs link (highlighted in yellow) to view the outputs. Outputs of a workflow are stored in a collection._ </figcaption> </figure>
+<figcaption> _*Figure 8*:  A completed workflow process in Arvados as viewed via the Arvados Workbench. You can click on the outputs link (highlighted in yellow) to view the outputs. Outputs of a workflow are stored in a collection._ </figcaption> </figure>
 
-If we click on the outputs of the workflow, we will see the output collection.
+If we click on the outputs of the workflow, we will see the output collection. It contains the GVCF, tabix index file, and HTML ClinVar report for each analyzed sample (e.g., set of FASTQs). You can open a report in the browser by selecting it from the listing. You can also download a file to your local machine by right-clicking a file and selecting "Download" from the context menu, or from the action menu available from the far right of each listing.
 
-Contained in this collection, is the GVCF, tabix index file, and html ClinVar report for each analyzed sample (e.g. set of FASTQs).   By clicking on the download button to the right of the file, you can download it to your local machine.  You can also use the command line to download single files or whole collections to your machine. You can examine the outputs of a step similarly by using the arrow to expand the panel to see more details.
+Logs for the main process can be found back on the workflow process page. Selecting the "LOGS" button at the top navigates down to the logs. You can view the logs directly through that panel, or in the upper right-hand corner select the button with hover-over text "Go to Log collection".
 
-Logs for the main process can be found in the Log tab.  There several logs available, so here is a basic summary of what some of the more commonly used logs contain.  Let's first define a few terms that will help us understand what the logs are tracking.
+There are several logs available, so here is a basic summary of what some of the more commonly used logs contain.  Let's first define a few terms that will help us understand what the logs are tracking.
 
 As you may recall, Arvados Crunch manages the running of workflows. A _container request_ is an order sent to Arvados Crunch to perform some computational work. Crunch fulfils a request by either choosing a worker node to execute a container, or finding an identical/equivalent container that has already run. You can use _container request_ or _container_ to distinguish between a work order that is submitted to be run and a work order that is actually running or has been run. So our container request in this case is just the submitted workflow we sent to the Arvados cluster.
 
@@ -233,10 +234,11 @@ A _node_ is a compute resource where Arvardos can schedule work.  In our case si
 ** Captures everything written to standard error by the programs run by the executing container
 * @node-info.txt@ and @node.json@
 ** Contains information about the nodes that executed this container. For the Arvados Playground, this gives information about the virtual machine instance that ran the container.
-node.json gives a high level overview about the instance such as name, price, and RAM while node-info.txt gives more detailed information about the virtual machine (e.g. cpu of each processor)
+node.json gives a high level overview about the instance such as name, price, and RAM while node-info.txt gives more detailed information about the virtual machine (e.g., CPU of each processor)
 * @crunch-run.txt@ and @crunchstat.txt@
 ** @crunch-run.txt@ has info about how the container's execution environment was set up (e.g., time spent loading the docker image) and timing/results of copying output data to Keep (if applicable)
 ** @crunchstat.txt@ has info about resource consumption (RAM, cpu, disk, network) by the container while it was running.
+* @usage_report.html@ can be viewed directly in the browser by clicking on it.  It provides a summary and chart of the resource consumption derived from the raw data in @crunchstat.txt@.  (Available starting with @arvados-cwl-runner@ 2.7.2).
 * @container.json@
 ** Describes the container (unit of work to be done), contains CWL code, runtime constraints (RAM, vcpus) amongst other details
 * @arv-mount.txt@
@@ -268,9 +270,9 @@ Let’s take a peek at a few of these logs to get you more familiar with them.
 
 You can see the output of all the work that arvados-cwl-runner does by managing the execution of the CWL workflow and all the underlying steps and subworkflows.
 
-Now, let’s explore the logs for a step in the workflow.   Remember that those logs can be found by expanding the steps and clicking on the link to the log collection.   Let’s look at the log for the step that does the alignment.  That step is named bwamem-samtools-view.  We can see there are 10 of them because we are aligning 10 genomes.  Let’s look at *bwamem-samtools-view2.*
+Now, let’s explore the logs for a subprocess in the workflow. Start by navigating back to the workflow process page. The logs can be found by selecting the appropriate subprocess under the "Subprocesses" tab, and getting the logs in the way as mentioned above.  Let’s look at the log for the subprocess that does the alignment.  That subprocess is named bwamem-samtools-view.  We can see there are 10 of them because we are aligning 10 genomes.  Let’s look at *bwamem-samtools-view_2.*
 
-We click the arrow to open up the step, and then can click on the log collection to access the logs.  You may notice there are two sets of seemingly identical logs.  One listed under a directory named for a container and one up in the main directory.  This is done in case your step had to be automatically re-run due to any issues and gives the logs of each re-run. The logs in the main directory are the logs for the successful run. In most cases this does not happen, you will just see one directory and one those logs will match the logs in the main directory.  Let’s open the logs labeled node-info.txt and stderr.txt.
+We click on the subprocess to open it and then can go down to the "Logs" section to access the logs.  You may notice there are two sets of seemingly identical logs.  One listed under a directory named for a container and one up in the main directory.  This is done in case your subprocess had to be automatically re-run due to any issues and gives the logs of each re-run. The logs in the main directory are the logs for the successful run. In most cases this does not happen, you will just see one directory and one those logs will match the logs in the main directory.  Let’s open the logs labeled node-info.txt and stderr.txt.
 
 @node-info.txt@ gives us information about detailed information about the virtual machine this step was run on.  The tail end of the log should look like the following:
 
@@ -346,6 +348,8 @@ The tail end of our log should be similar to the following:
 
 This is the command we ran to invoke bwa-mem, and the scaling information for running bwa-mem multi-threaded across 16 cores (15.4x).
 
+You can also view outputs for the subprocess just like you do for the main workflow process. Back on the subprocess page for *bwamem-samtools-view_2*, the Outputs pane shows the output files of this specific subprocess. In this case, it is a single BAM file. This way, if your workflow succeeds but produces a surprising result, you can download and review the intermediate outputs to investigate further.
+
 We hope that now that you have a bit more familiarity with the logs you can continue to use them to debug and optimize your own workflows as you move forward with using Arvados if your own work in the future.
 
 h2. 7.  Conclusion
index 1b75e13420bce8bf77b3d4942705ce726e5a8e6e..05d8547c52a5126f36ecc79ae85e3a9f4658ac44 100644 (file)
@@ -3,31 +3,16 @@
 # SPDX-License-Identifier: Apache-2.0
 
 # Based on Debian
-FROM debian:buster-slim
+FROM debian:bullseye-slim
 MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
 
-ENV DEBIAN_FRONTEND noninteractive
-
-RUN apt-get update -q
-RUN apt-get install -yq --no-install-recommends gnupg
-
 ARG repo_version
-RUN echo repo_version $repo_version
-ADD apt.arvados.org-$repo_version.list /etc/apt/sources.list.d/
-
-ADD 1078ECD7.key /tmp/
-RUN cat /tmp/1078ECD7.key | apt-key add -
-
-ARG python_sdk_version
 ARG cwl_runner_version
-RUN echo cwl_runner_version $cwl_runner_version python_sdk_version $python_sdk_version
 
+ADD apt.arvados.org-$repo_version.list /etc/apt/sources.list.d/
+ADD 1078ECD7.key /etc/apt/trusted.gpg.d/arvados.asc
 RUN apt-get update -q
-RUN apt-get install -yq --no-install-recommends python3-arvados-cwl-runner=$cwl_runner_version
-
-# use the Python executable from the python-arvados-cwl-runner package
-RUN PYTHON=`ls /usr/share/python3*/dist/python3-arvados-cwl-runner/bin/python|head -n1` && rm -f /usr/bin/python && ln -s $PYTHON /usr/bin/python
-RUN PYTHON3=`ls /usr/share/python3*/dist/python3-arvados-cwl-runner/bin/python3|head -n1` && rm -f /usr/bin/python3 && ln -s $PYTHON3 /usr/bin/python3
+RUN DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends python3-arvados-cwl-runner=$cwl_runner_version
 
 # Install dependencies and set up system.
 RUN /usr/sbin/adduser --disabled-password \
@@ -35,3 +20,4 @@ RUN /usr/sbin/adduser --disabled-password \
     /usr/bin/install --directory --owner=crunch --group=crunch --mode=0700 /keep /tmp/crunch-src /tmp/crunch-job
 
 USER crunch
+ENV PATH=/usr/lib/python3-arvados-cwl-runner/bin:/usr/local/bin:/usr/bin:/bin
index 210f5d55119da35ff6e2060fa6dfddeb8099a54d..155244ba9f581b3ee62d5cf32f8609097ec1acf8 100644 (file)
@@ -1,2 +1,2 @@
 # apt.arvados.org
-deb http://apt.arvados.org/buster buster-dev main
+deb http://apt.arvados.org/bullseye bullseye-dev main
index 153e7298057eff34ccd2e4e8f39e2bcda978adf2..5a4b8c91c8f2e03a1fa3da1d3b1223eeb1175e4a 100644 (file)
@@ -1,2 +1,2 @@
 # apt.arvados.org
-deb http://apt.arvados.org/buster buster main
+deb http://apt.arvados.org/bullseye bullseye main
index d5f458168585ada0d74aa36afa79d5a2bdbf9f31..302862ca643724d76199db63d9448526ab183d53 100644 (file)
@@ -1,2 +1,2 @@
 # apt.arvados.org
-deb http://apt.arvados.org/buster buster-testing main
+deb http://apt.arvados.org/bullseye bullseye-testing main
diff --git a/go.mod b/go.mod
index 58b64b7b8ae1520dce1c1eb80655beeaab9c4fc4..aef54ac1c1da9ffe4b93c500144bc5a987b7e0fc 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -14,15 +14,16 @@ require (
        github.com/bradleypeabody/godap v0.0.0-20170216002349-c249933bc092
        github.com/coreos/go-oidc/v3 v3.5.0
        github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e
-       github.com/creack/pty v1.1.7
-       github.com/docker/docker v17.12.0-ce-rc1.0.20210128214336-420b1d36250f+incompatible
+       github.com/creack/pty v1.1.18
+       github.com/docker/docker v24.0.9+incompatible
        github.com/dustin/go-humanize v1.0.0
        github.com/fsnotify/fsnotify v1.4.9
        github.com/ghodss/yaml v1.0.0
        github.com/go-ldap/ldap v3.0.3+incompatible
        github.com/gogo/protobuf v1.3.2
        github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
-       github.com/gorilla/mux v1.7.2
+       github.com/gorilla/mux v1.8.0
+       github.com/hashicorp/go-retryablehttp v0.7.2
        github.com/hashicorp/golang-lru v0.5.1
        github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87
        github.com/imdario/mergo v0.3.12
@@ -36,11 +37,11 @@ require (
        github.com/prometheus/client_model v0.3.0
        github.com/prometheus/common v0.39.0
        github.com/sirupsen/logrus v1.8.1
-       golang.org/x/crypto v0.5.0
-       golang.org/x/net v0.5.0
-       golang.org/x/oauth2 v0.4.0
-       golang.org/x/sys v0.5.0
-       google.golang.org/api v0.30.0
+       golang.org/x/crypto v0.22.0
+       golang.org/x/net v0.21.0
+       golang.org/x/oauth2 v0.11.0
+       golang.org/x/sys v0.19.0
+       google.golang.org/api v0.126.0
        gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
        gopkg.in/square/go-jose.v2 v2.5.1
        gopkg.in/src-d/go-billy.v4 v4.0.1
@@ -49,7 +50,9 @@ require (
 )
 
 require (
-       cloud.google.com/go v0.65.0 // indirect
+       cloud.google.com/go/compute v1.23.0 // indirect
+       cloud.google.com/go/compute/metadata v0.2.3 // indirect
+       github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
        github.com/Azure/go-autorest v14.2.0+incompatible // indirect
        github.com/Azure/go-autorest/autorest/adal v0.9.17 // indirect
        github.com/Azure/go-autorest/autorest/azure/cli v0.4.4 // indirect
@@ -57,24 +60,29 @@ require (
        github.com/Azure/go-autorest/autorest/validation v0.3.0 // indirect
        github.com/Azure/go-autorest/logger v0.2.1 // indirect
        github.com/Azure/go-autorest/tracing v0.6.0 // indirect
-       github.com/Microsoft/go-winio v0.4.17 // indirect
+       github.com/Microsoft/go-winio v0.5.2 // indirect
        github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 // indirect
        github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 // indirect
        github.com/beorn7/perks v1.0.1 // indirect
+       github.com/bgentry/speakeasy v0.1.0 // indirect
        github.com/cespare/xxhash/v2 v2.2.0 // indirect
-       github.com/containerd/containerd v1.5.10 // indirect
        github.com/davecgh/go-spew v1.1.1 // indirect
        github.com/dimchansky/utfbom v1.1.1 // indirect
-       github.com/docker/distribution v2.7.1+incompatible // indirect
+       github.com/dnaeon/go-vcr v1.2.0 // indirect
+       github.com/docker/distribution v2.8.2+incompatible // indirect
        github.com/docker/go-connections v0.3.0 // indirect
        github.com/docker/go-units v0.4.0 // indirect
        github.com/gliderlabs/ssh v0.2.2 // indirect
        github.com/go-asn1-ber/asn1-ber v1.4.1 // indirect
-       github.com/go-jose/go-jose/v3 v3.0.0 // indirect
+       github.com/go-jose/go-jose/v3 v3.0.3 // indirect
        github.com/golang-jwt/jwt/v4 v4.1.0 // indirect
-       github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
-       github.com/golang/protobuf v1.5.2 // indirect
-       github.com/googleapis/gax-go/v2 v2.0.5 // indirect
+       github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+       github.com/golang/protobuf v1.5.3 // indirect
+       github.com/google/s2a-go v0.1.4 // indirect
+       github.com/google/uuid v1.3.1 // indirect
+       github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
+       github.com/googleapis/gax-go/v2 v2.11.0 // indirect
+       github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
        github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
        github.com/jmespath/go-jmespath v0.4.0 // indirect
        github.com/kevinburke/ssh_config v0.0.0-20171013211458-802051befeb5 // indirect
@@ -82,6 +90,7 @@ require (
        github.com/kr/text v0.1.0 // indirect
        github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
        github.com/mitchellh/go-homedir v1.1.0 // indirect
+       github.com/moby/term v0.5.0 // indirect
        github.com/morikuni/aec v1.0.0 // indirect
        github.com/opencontainers/go-digest v1.0.0 // indirect
        github.com/opencontainers/image-spec v1.0.2 // indirect
@@ -89,22 +98,24 @@ require (
        github.com/pkg/errors v0.9.1 // indirect
        github.com/prometheus/procfs v0.9.0 // indirect
        github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
-       github.com/satori/go.uuid v1.2.1-0.20180103174451-36e9d2ebbde5 // indirect
+       github.com/satori/go.uuid v1.2.1-0.20180404165556-75cca531ea76 // indirect
        github.com/sergi/go-diff v1.0.0 // indirect
        github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63 // indirect
        github.com/src-d/gcfg v1.3.0 // indirect
        github.com/xanzy/ssh-agent v0.1.0 // indirect
-       go.opencensus.io v0.22.4 // indirect
-       golang.org/x/text v0.6.0 // indirect
-       golang.org/x/tools v0.1.12 // indirect
+       go.opencensus.io v0.24.0 // indirect
+       golang.org/x/text v0.14.0 // indirect
+       golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
+       golang.org/x/tools v0.6.0 // indirect
        google.golang.org/appengine v1.6.7 // indirect
-       google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a // indirect
-       google.golang.org/grpc v1.33.2 // indirect
-       google.golang.org/protobuf v1.28.1 // indirect
+       google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect
+       google.golang.org/grpc v1.59.0 // indirect
+       google.golang.org/protobuf v1.33.0 // indirect
        gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
        gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 // indirect
        gopkg.in/warnings.v0 v0.1.2 // indirect
        gopkg.in/yaml.v2 v2.4.0 // indirect
+       gotest.tools/v3 v3.0.3 // indirect
 )
 
 replace github.com/AdRoll/goamz => github.com/arvados/goamz v0.0.0-20190905141525-1bba09f407ef
diff --git a/go.sum b/go.sum
index 43ed89c47f4cf27297113a0a296568e2e7cfe073..c5f4d837d395a84617150b5ac87306491ab41fe1 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -1,52 +1,19 @@
-bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
-cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
-cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
-cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
-cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
-cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
-cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
-cloud.google.com/go v0.65.0 h1:Dg9iHVQfrhq82rUNu9ZxUDrJLaxFUe/HlCVaLyRruq8=
-cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
-cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
-cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
-cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
+cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
 cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
-cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
-cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
-cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
-cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
-cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
+cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
 github.com/Azure/azure-sdk-for-go v45.1.0+incompatible h1:kxtaPD8n2z5Za+9e3sKsYG2IX6PG2R6VXtgS7gAbh3A=
 github.com/Azure/azure-sdk-for-go v45.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
-github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
-github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
 github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
 github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
-github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
 github.com/Azure/go-autorest/autorest v0.11.19/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA=
 github.com/Azure/go-autorest/autorest v0.11.22 h1:bXiQwDjrRmBQOE67bwlvUKAC1EU1yZTPQ38c+bstZws=
 github.com/Azure/go-autorest/autorest v0.11.22/go.mod h1:BAWYUWGPEtKPzjVkp0Q6an0MJcJDsoh5Z1BFAEFs4Xs=
-github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
 github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
 github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
 github.com/Azure/go-autorest/autorest/adal v0.9.14/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
@@ -59,333 +26,114 @@ github.com/Azure/go-autorest/autorest/azure/cli v0.4.4 h1:iuooz5cZL6VRcO7DVSFYxR
 github.com/Azure/go-autorest/autorest/azure/cli v0.4.4/go.mod h1:yAQ2b6eP/CmLPnmLvxtT1ALIY3OR1oFcCqVBi8vHiTc=
 github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
 github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
-github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
 github.com/Azure/go-autorest/autorest/mocks v0.4.1 h1:K0laFcLE6VLTOwNgSxaGbUcLPuGXlNkbVvq4cW4nIHk=
 github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
 github.com/Azure/go-autorest/autorest/to v0.4.0 h1:oXVqrxakqqV1UZdSazDOPOLvOIz+XA683u8EctwboHk=
 github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE=
 github.com/Azure/go-autorest/autorest/validation v0.3.0 h1:3I9AAI63HfcLtphd9g39ruUwRI+Ca+z/f36KHPFRUss=
 github.com/Azure/go-autorest/autorest/validation v0.3.0/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E=
-github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
 github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg=
 github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
 github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo=
 github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
-github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
-github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
-github.com/Microsoft/go-winio v0.4.16-0.20201130162521-d1ffc52c7331/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
-github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
-github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
-github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
-github.com/Microsoft/go-winio v0.4.17 h1:iT12IBVClFevaf8PuVyi3UmZOVh4OqnaLxDTW2O6j3w=
-github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
-github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
-github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
-github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
-github.com/Microsoft/hcsshim v0.8.9/go.mod h1:5692vkUqntj1idxauYlpoINNKeqCiG6Sg38RRsjT5y8=
-github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2ow3VK6a9Lg=
-github.com/Microsoft/hcsshim v0.8.15/go.mod h1:x38A4YbHbdxJtc0sF6oIz+RG0npwSCAvn69iY6URG00=
-github.com/Microsoft/hcsshim v0.8.16/go.mod h1:o5/SZqmR7x9JNKsW3pu+nqHm0MF8vbA+VxGOoXdC600=
-github.com/Microsoft/hcsshim v0.8.23/go.mod h1:4zegtUJth7lAvFyc6cH2gGQ5B3OFQim01nnU2M8jKDg=
-github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU=
-github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY=
-github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
-github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
-github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
+github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs=
 github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/arvados/cgofuse v1.2.0-arvados1 h1:4Q4vRJ4hbTCcI4gGEaa6hqwj3rqlUuzeFQkfoEA2HqE=
 github.com/arvados/cgofuse v1.2.0-arvados1/go.mod h1:79WFV98hrkRHK9XPhh2IGGOwpFSjocsWubgxAs2KhRc=
 github.com/arvados/goamz v0.0.0-20190905141525-1bba09f407ef h1:cl7DIRbiAYNqaVxg3CZY8qfZoBOKrj06H/x9SPGaxas=
 github.com/arvados/goamz v0.0.0-20190905141525-1bba09f407ef/go.mod h1:rCtgyMmBGEbjTm37fCuBYbNL0IhztiALzo3OB9HyiOM=
 github.com/arvados/yaml v0.0.0-20210427145106-92a1cab0904b h1:hK0t0aJTTXI64lpXln2A1SripqOym+GVNTnwsLes39Y=
 github.com/arvados/yaml v0.0.0-20210427145106-92a1cab0904b/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
-github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
 github.com/aws/aws-sdk-go v1.17.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.44.174 h1:9lR4a6MKQW/t6YCG0ZKAt1GAkjdEPP8sWch/pfcuR0c=
 github.com/aws/aws-sdk-go v1.44.174/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
 github.com/aws/aws-sdk-go-v2 v0.23.0 h1:+E1q1LLSfHSDn/DzOtdJOX+pLZE2HiNV2yO5AjZINwM=
 github.com/aws/aws-sdk-go-v2 v0.23.0/go.mod h1:2LhT7UgHOXK3UXONKI5OMgIyoQL6zTAw/jwIeX6yqzw=
-github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
 github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
-github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
-github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
-github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
-github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
 github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
 github.com/bradleypeabody/godap v0.0.0-20170216002349-c249933bc092 h1:0Di2onNnlN5PAyWPbqlPyN45eOQ+QW/J9eqLynt4IV4=
 github.com/bradleypeabody/godap v0.0.0-20170216002349-c249933bc092/go.mod h1:8IzBjZCRSnsvM6MJMG8HNNtnzMl48H22rbJL2kRUJ0Y=
-github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
-github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
-github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
-github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=
-github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
-github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw=
-github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg=
-github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc=
-github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs=
-github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
-github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
-github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE=
-github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU=
-github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU=
-github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU=
-github.com/containerd/btrfs v0.0.0-20201111183144-404b9149801e/go.mod h1:jg2QkJcsabfHugurUvvPhS3E08Oxiuh5W/g1ybB4e0E=
-github.com/containerd/btrfs v0.0.0-20210316141732-918d888fb676/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss=
-github.com/containerd/btrfs v1.0.0/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss=
-github.com/containerd/cgroups v0.0.0-20190717030353-c4b9ac5c7601/go.mod h1:X9rLEHIqSf/wfK8NsPqxJmeZgW4pcfzdXITDrUSJ6uI=
-github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko=
-github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59/go.mod h1:pA0z1pT8KYB3TCXK/ocprsh7MAkoW8bZVzPdih9snmM=
-github.com/containerd/cgroups v0.0.0-20200710171044-318312a37340/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo=
-github.com/containerd/cgroups v0.0.0-20200824123100-0b889c03f102/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo=
-github.com/containerd/cgroups v0.0.0-20210114181951-8a68de567b68/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE=
-github.com/containerd/cgroups v1.0.1/go.mod h1:0SJrPIenamHDcZhEcJMNBB85rHcUsw4f25ZfBiPYRkU=
-github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
-github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
-github.com/containerd/console v0.0.0-20191206165004-02ecf6a7291e/go.mod h1:8Pf4gM6VEbTNRIT26AyyU7hxdQU3MvAvxVI0sc00XBE=
-github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
-github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
-github.com/containerd/containerd v1.2.10/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.3.1-0.20191213020239-082f7e3aed57/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.4.0-beta.2.0.20200729163537-40b22ef07410/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.4.9/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.5.0-beta.1/go.mod h1:5HfvG1V2FsKesEGQ17k5/T7V960Tmcumvqn8Mc+pCYQ=
-github.com/containerd/containerd v1.5.0-beta.3/go.mod h1:/wr9AVtEM7x9c+n0+stptlo/uBBoBORwEx6ardVcmKU=
-github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09ZvgqEq8EfBp/m3lcVZIvPHhI=
-github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s=
-github.com/containerd/containerd v1.5.10 h1:3cQ2uRVCkJVcx5VombsE7105Gl9Wrl7ORAO3+4+ogf4=
-github.com/containerd/containerd v1.5.10/go.mod h1:fvQqCfadDGga5HZyn3j4+dx56qj2I9YwBrlSdalvJYQ=
-github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
-github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
-github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
-github.com/containerd/continuity v0.0.0-20200710164510-efbc4488d8fe/go.mod h1:cECdGN1O8G9bgKTlLhuPJimka6Xb/Gg7vYzCTNVxhvo=
-github.com/containerd/continuity v0.0.0-20201208142359-180525291bb7/go.mod h1:kR3BEg7bDFaEddKm54WSmrol1fKWDU1nKYkgrcgZT7Y=
-github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e/go.mod h1:EXlVlkqNba9rJe3j7w3Xa924itAMLgZH4UD/Q4PExuQ=
-github.com/containerd/continuity v0.1.0/go.mod h1:ICJu0PwR54nI0yPEnJ6jcS+J7CZAUXrLh8lPo2knzsM=
-github.com/containerd/fifo v0.0.0-20180307165137-3d5202aec260/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
-github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
-github.com/containerd/fifo v0.0.0-20200410184934-f15a3290365b/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0=
-github.com/containerd/fifo v0.0.0-20201026212402-0724c46b320c/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0=
-github.com/containerd/fifo v0.0.0-20210316144830-115abcc95a1d/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4=
-github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4=
-github.com/containerd/go-cni v1.0.1/go.mod h1:+vUpYxKvAF72G9i1WoDOiPGRtQpqsNW/ZHtSlv++smU=
-github.com/containerd/go-cni v1.0.2/go.mod h1:nrNABBHzu0ZwCug9Ije8hL2xBCYh/pjfMb1aZGrrohk=
-github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0=
-github.com/containerd/go-runc v0.0.0-20190911050354-e029b79d8cda/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0=
-github.com/containerd/go-runc v0.0.0-20200220073739-7016d3ce2328/go.mod h1:PpyHrqVs8FTi9vpyHwPwiNEGaACDxT/N/pLcvMSRA9g=
-github.com/containerd/go-runc v0.0.0-20201020171139-16b287bc67d0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok=
-github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok=
-github.com/containerd/imgcrypt v1.0.1/go.mod h1:mdd8cEPW7TPgNG4FpuP3sGBiQ7Yi/zak9TYCG3juvb0=
-github.com/containerd/imgcrypt v1.0.4-0.20210301171431-0ae5c75f59ba/go.mod h1:6TNsg0ctmizkrOgXRNQjAPFWpMYRWuiB6dSF4Pfa5SA=
-github.com/containerd/imgcrypt v1.1.1-0.20210312161619-7ed62a527887/go.mod h1:5AZJNI6sLHJljKuI9IHnw1pWqo/F0nGDOuR9zgTs7ow=
-github.com/containerd/imgcrypt v1.1.1/go.mod h1:xpLnwiQmEUJPvQoAapeb2SNCxz7Xr6PJrXQb0Dpc4ms=
-github.com/containerd/nri v0.0.0-20201007170849-eb1350a75164/go.mod h1:+2wGSDGFYfE5+So4M5syatU0N0f0LbWpuqyMi4/BE8c=
-github.com/containerd/nri v0.0.0-20210316161719-dbaa18c31c14/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY=
-github.com/containerd/nri v0.1.0/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY=
-github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
-github.com/containerd/ttrpc v0.0.0-20190828172938-92c8520ef9f8/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
-github.com/containerd/ttrpc v0.0.0-20191028202541-4f1b8fe65a5c/go.mod h1:LPm1u0xBw8r8NOKoOdNMeVHSawSsltak+Ihv+etqsE8=
-github.com/containerd/ttrpc v1.0.1/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y=
-github.com/containerd/ttrpc v1.0.2/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y=
-github.com/containerd/ttrpc v1.1.0/go.mod h1:XX4ZTnoOId4HklF4edwc4DcqskFZuvXB1Evzy5KFQpQ=
-github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc=
-github.com/containerd/typeurl v0.0.0-20190911142611-5eb25027c9fd/go.mod h1:GeKYzf2pQcqv7tJ0AoCuuhtnqhva5LNU3U+OyKxxJpk=
-github.com/containerd/typeurl v1.0.1/go.mod h1:TB1hUtrpaiO88KEK56ijojHS1+NeF0izUACaJW2mdXg=
-github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s=
-github.com/containerd/zfs v0.0.0-20200918131355-0a33824f23a2/go.mod h1:8IgZOBdv8fAgXddBT4dBXJPtxyRsejFIpXoklgxgEjw=
-github.com/containerd/zfs v0.0.0-20210301145711-11e8f1707f62/go.mod h1:A9zfAbMlQwE+/is6hi0Xw8ktpL+6glmqZYtevJgaB8Y=
-github.com/containerd/zfs v0.0.0-20210315114300-dde8f0fda960/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY=
-github.com/containerd/zfs v0.0.0-20210324211415-d5c4544f0433/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY=
-github.com/containerd/zfs v1.0.0/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY=
-github.com/containernetworking/cni v0.7.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY=
-github.com/containernetworking/cni v0.8.0/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY=
-github.com/containernetworking/cni v0.8.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY=
-github.com/containernetworking/plugins v0.8.6/go.mod h1:qnw5mN19D8fIwkqW7oHHYDHVlzhJpcY6TQxn/fUyDDM=
-github.com/containernetworking/plugins v0.9.1/go.mod h1:xP/idU2ldlzN6m4p5LmGiwRDjeJr6FLK6vuiUwoH7P8=
-github.com/containers/ocicrypt v1.0.1/go.mod h1:MeJDzk1RJHv89LjsH0Sp5KTY3ZYkjXO/C+bKAeWFIrc=
-github.com/containers/ocicrypt v1.1.0/go.mod h1:b8AOe0YR67uU8OqfVNcznfFpAzu3rdgUV4GP9qXPfu4=
-github.com/containers/ocicrypt v1.1.1/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B0oB3dj3jFxY=
-github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
-github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
-github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
-github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
+github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/coreos/go-oidc/v3 v3.5.0 h1:VxKtbccHZxs8juq7RdJntSqtXFtde9YpNpGn0yqgEHw=
 github.com/coreos/go-oidc/v3 v3.5.0/go.mod h1:ecXRtV4romGPeO6ieExAsUK9cb/3fp9hXNz1tlv8PIM=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
-github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
-github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/creack/pty v1.1.7 h1:6pwm8kMQKCmgUg0ZHTm5+/YvRK0s3THD/28+T6/kk4A=
-github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
-github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
-github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ=
-github.com/d2g/dhcp4client v1.0.0/go.mod h1:j0hNfjhrt2SxUOw55nL0ATM/z4Yt3t2Kd1mW34z5W5s=
-github.com/d2g/dhcp4server v0.0.0-20181031114812-7d4a0a7f59a5/go.mod h1:Eo87+Kg/IX2hfWJfwxMzLyuSZyxSoAug2nGa1G2QAi8=
-github.com/d2g/hardwareaddr v0.0.0-20190221164911-e7d9fbe030e4/go.mod h1:bMl4RjIciD2oAxI7DmWRx6gbeqrkoLqv3MV0vzNad+I=
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0=
-github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
 github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
 github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
-github.com/dnaeon/go-vcr v1.0.1 h1:r8L/HqC0Hje5AXMu1ooW8oyQyOFv4GxqpL0nRP7SLLY=
-github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
-github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
-github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
-github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v17.12.0-ce-rc1.0.20210128214336-420b1d36250f+incompatible h1:nhVo1udYfMj0Jsw0lnqrTjjf33aLpdgW9Wve9fHVzhQ=
-github.com/docker/docker v17.12.0-ce-rc1.0.20210128214336-420b1d36250f+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
+github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
+github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
+github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
+github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
+github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/go-connections v0.3.0 h1:3lOnM9cSzgGwx8VfK/NGOW5fLQ0GjIlCkaktF+n1M6o=
 github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
-github.com/docker/go-events v0.0.0-20170721190031-9461782956ad/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
-github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
-github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=
-github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
 github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
 github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
-github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
-github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
-github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
-github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
-github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
-github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
-github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
-github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
 github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
 github.com/go-asn1-ber/asn1-ber v1.4.1 h1:qP/QDxOtmMoJVgXHCXNzDpA0+wkgYB2x5QoLMVOciyw=
 github.com/go-asn1-ber/asn1-ber v1.4.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
-github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
-github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
 github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
+github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
 github.com/go-ldap/ldap v3.0.3+incompatible h1:HTeSZO8hWMS1Rgb2Ziku6b8a7qRIZZMHjsvuZyatzwk=
 github.com/go-ldap/ldap v3.0.3+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
-github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
-github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
-github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
-github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
-github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
-github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
-github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
-github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
-github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
-github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
-github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gogo/googleapis v1.2.0/go.mod h1:Njal3psf3qN6dwBtQfUmBZh2ybovJ0tlu3o/AC7HYjU=
-github.com/gogo/googleapis v1.4.0/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
-github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
-github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
-github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
 github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang-jwt/jwt/v4 v4.1.0 h1:XUgk2Ex5veyVFVeLm0xhusUTQybEbexJXrvPNOKkSY0=
 github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
-github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
 github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -396,83 +144,49 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc=
+github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
-github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
-github.com/gorilla/mux v1.7.2 h1:zoNxOV7WjqXptQOVngLmcSQgXmgk4NMz1HibBchjl/I=
-github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
-github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
-github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
-github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
-github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
+github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
+github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
+github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4=
+github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
+github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
+github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0=
+github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
 github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 h1:xixZ2bWeofWV68J+x6AzmKuVM/JWCQwkWm6GW/MUR6I=
 github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
-github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
-github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
-github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
 github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff h1:6NvhExg4omUC9NfA+l4Oq3ibNNeJUdiAF3iBVB0PlDk=
 github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff/go.mod h1:ddfPX8Z28YMjiqoaJhNBzWHapTHXejnB5cDCUWDwriw=
-github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
-github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
@@ -482,618 +196,232 @@ github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
 github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
 github.com/johannesboyne/gofakes3 v0.0.0-20200716060623-6b2b4cb092cc h1:JJPhSHowepOF2+ElJVyb9jgt5ZyBkPMkPuhS0uODSFs=
 github.com/johannesboyne/gofakes3 v0.0.0-20200716060623-6b2b4cb092cc/go.mod h1:fNiSoOiEI5KlkWXn26OwKnNe58ilTIkpBlgOrt7Olu8=
-github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
-github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
 github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
 github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
 github.com/kevinburke/ssh_config v0.0.0-20171013211458-802051befeb5 h1:xXn0nBttYwok7DhU4RxqaADEpQn7fEMt5kKc3yoj/n0=
 github.com/kevinburke/ssh_config v0.0.0-20171013211458-802051befeb5/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
-github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
-github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
-github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
 github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
 github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
 github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
 github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
-github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
-github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
-github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
-github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
-github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
-github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ=
-github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
-github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
 github.com/msteinert/pam v0.0.0-20190215180659-f29b9f28d6f9 h1:ZivaaKmjs9q90zi6I4gTLW6tbVGtlBjellr3hMYaly0=
 github.com/msteinert/pam v0.0.0-20190215180659-f29b9f28d6f9/go.mod h1:np1wUFZ6tyoke22qDJZY40URn9Ae51gX7ljIWXN5TJs=
-github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
-github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM=
-github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
-github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
-github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
-github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
-github.com/onsi/gomega v0.0.0-20151007035656-2152b45fa28a/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
-github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
-github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
-github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc=
-github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
-github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
-github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
-github.com/opencontainers/go-digest v1.0.0-rc1.0.20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
-github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
-github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
 github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
-github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
-github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
-github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
-github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
-github.com/opencontainers/runc v1.0.0-rc93/go.mod h1:3NOsor4w32B2tC0Zbl8Knk4Wg84SM2ImC1fxBuqJ/H0=
-github.com/opencontainers/runc v1.0.2/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0=
-github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.2-0.20190207185410-29686dbc5559/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.3-0.20200929063507-e6143ca7d51d/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs=
-github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqiriPsEqVhc+svHE=
-github.com/opencontainers/selinux v1.8.0/go.mod h1:RScLhm78qiWa2gbVCcGkC7tCGdgk3ogry1nUQF8Evvo=
-github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8=
 github.com/pelletier/go-buffruneio v0.2.0 h1:U4t4R6YkofJ5xHm3dJzuRpPZ0mr5MMCoAWooScCR7aA=
 github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
-github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
-github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
-github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
-github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
-github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
 github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
 github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
-github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
 github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
-github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
-github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
-github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
-github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
 github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8uhsI=
 github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y=
-github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.0-20190522114515-bc1a522cf7b1/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
-github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
-github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
-github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
 github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
 github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
-github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
-github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
 github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
-github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
-github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
-github.com/satori/go.uuid v1.2.1-0.20180103174451-36e9d2ebbde5 h1:Jw7W4WMfQDxsXvfeFSaS2cHlY7bAF4MGrgnbd0+Uo78=
-github.com/satori/go.uuid v1.2.1-0.20180103174451-36e9d2ebbde5/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
-github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
+github.com/satori/go.uuid v1.2.1-0.20180404165556-75cca531ea76 h1:ofyVTM1w4iyKwaQIlRR6Ip06mXXx5Cnz7a4mTGYq1hE=
+github.com/satori/go.uuid v1.2.1-0.20180404165556-75cca531ea76/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63 h1:J6qvD6rbmOil46orKqJaRPG+zTpoGlBTUdyv8ki63L0=
 github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63/go.mod h1:n+VKSARF5y/tS9XFSP7vWDfS+GUC5vs/YT7M5XDTUEM=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
-github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
-github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
 github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
-github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
-github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
-github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
-github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
 github.com/src-d/gcfg v1.3.0 h1:2BEDr8r0I0b8h/fOqwtxCEiq2HJu8n2JGZJQFGXWLjg=
 github.com/src-d/gcfg v1.3.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI=
-github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8=
-github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
-github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
-github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
-github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
-github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
-github.com/tchap/go-patricia v2.2.6+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
-github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
-github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
-github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
-github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
-github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
-github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI=
-github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
-github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
-github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
-github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/xanzy/ssh-agent v0.1.0 h1:lOhdXLxtmYjaHc76ZtNmJWPg948y/RnT+3N3cvKWFzY=
 github.com/xanzy/ssh-agent v0.1.0/go.mod h1:0NyE30eGUDliuLEHJgYte/zncp2zdTStcOnWhgSqHD8=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
-github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
-github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
-github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
-go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
-go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3CSBatqGNg7GRmsnfLWtoW60w4eDYfh7vHDg=
-go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
-go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.4 h1:LYy1Hy3MJdrCdMwwzxA/dRok4ejH+RwNGbuoD9fCjto=
-go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
-go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
-golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
+go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
+go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
-golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
+golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
+golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
-golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
-golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190310074541-c10a0554eabf/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
 golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
-golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
-golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
-golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M=
-golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec=
+golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU=
+golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190522044717-8097e1b27ff5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190812073006-9eafafc0a87e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200120151820-655fe14d7479/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200922070232-aee5d888a860/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201117170446-d9b008d0a637/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
+golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
-golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg=
-golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
-golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s=
 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190308174544-00c44ba9c14f/go.mod h1:25r3+/G6/xytQM8iWZKq3Hn0kr0rgFKPUNVEL/dr3z4=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
-golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
-google.golang.org/api v0.30.0 h1:yfrXXP61wVuLb0vBcG6qaOoIoqYEzOQS8jum51jkv2w=
-google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/api v0.126.0 h1:q4GJq+cAdMAC7XP7njvQ4tvohGLiSlytuL4BQxbIZ+o=
+google.golang.org/api v0.126.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
 google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190522204451-c2c4e71fbf69/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200117163144-32f20d992d24/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
-google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
+google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
-google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a h1:pOwg4OoaRYScjmR4LlLgdtnyoHYTSAVhhqe5uPdpII8=
-google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
+google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY=
+google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
-google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.2 h1:EQyQC3sa8M+p6Ulc8yy9SWSS2GVwyRc83gAbG8lrl4o=
+google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
+google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
+google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -1102,34 +430,19 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
 google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
-google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM=
 gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
-gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
-gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
-gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
-gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
-gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w=
 gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/src-d/go-billy.v4 v4.0.1 h1:iMxwQPj2cuKRyaIZ985zxClkcdTtT5VpXYf4PTJc0Ek=
@@ -1138,55 +451,14 @@ gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOA
 gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g=
 gopkg.in/src-d/go-git.v4 v4.0.0 h1:9ZRNKHuhaTaJRGcGaH6Qg7uUORO2X0MNB5WL/CDdqto=
 gopkg.in/src-d/go-git.v4 v4.0.0/go.mod h1:CzbUWqMn4pvmvndg3gnh5iZFmSsbhyhUWdI0IQ60AQo=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
-gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
-gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
 gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-k8s.io/api v0.20.1/go.mod h1:KqwcCVogGxQY3nBlRpwt+wpAMF/KjaCc7RpywacvqUo=
-k8s.io/api v0.20.4/go.mod h1:++lNL1AJMkDymriNniQsWRkMDzRaX2Y/POTUi8yvqYQ=
-k8s.io/api v0.20.6/go.mod h1:X9e8Qag6JV/bL5G6bU8sdVRltWKmdHsFUGS3eVndqE8=
-k8s.io/apimachinery v0.20.1/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
-k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
-k8s.io/apimachinery v0.20.6/go.mod h1:ejZXtW1Ra6V1O5H8xPBGz+T3+4gfkTCeExAHKU57MAc=
-k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU=
-k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
-k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q=
-k8s.io/client-go v0.20.1/go.mod h1:/zcHdt1TeWSd5HoUe6elJmHSQ6uLLgp4bIJHVEuy+/Y=
-k8s.io/client-go v0.20.4/go.mod h1:LiMv25ND1gLUdBeYxBIwKpkSC5IsozMMmOOeSJboP+k=
-k8s.io/client-go v0.20.6/go.mod h1:nNQMnOvEUEsOzRRFIIkdmYOjAZrC8bgq0ExboWSU1I0=
-k8s.io/component-base v0.20.1/go.mod h1:guxkoJnNoh8LNrbtiQOlyp2Y2XFCZQmrcg2n/DeYNLk=
-k8s.io/component-base v0.20.4/go.mod h1:t4p9EdiagbVCJKrQ1RsA5/V4rFQNDfRlevJajlGwgjI=
-k8s.io/component-base v0.20.6/go.mod h1:6f1MPBAeI+mvuts3sIdtpjljHWBQ2cIy38oBIWMYnrM=
-k8s.io/cri-api v0.17.3/go.mod h1:X1sbHmuXhwaHs9xxYffLqJogVsnI+f6cPRcgPel7ywM=
-k8s.io/cri-api v0.20.1/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI=
-k8s.io/cri-api v0.20.4/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI=
-k8s.io/cri-api v0.20.6/go.mod h1:ew44AjNXwyn1s0U4xCKGodU7J1HzBeZ1MpGrpa5r8Yc=
-k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
-k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE=
-k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
-k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM=
-k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk=
-k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/getopt v0.0.0-20170811000552-20be20937449 h1:UukjJOsjQH0DIuyyrcod6CXHS6cdaMMuJmrt+SN1j4A=
 rsc.io/getopt v0.0.0-20170811000552-20be20937449/go.mod h1:dhCdeqAxkyt5u3/sKRkUXuHaMXUu1Pt13GTQAM2xnig=
-rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
-rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
-sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.14/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
-sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.15/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
-sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
-sigs.k8s.io/structured-merge-diff/v4 v4.0.3/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
-sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
-sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
index 4b7284556eef20c17594ec5115238d47f308455b..3d653e97af9cc4744ec2aca3113e5d58b15d66e7 100644 (file)
@@ -67,10 +67,9 @@ func (bcmd bootCommand) run(ctx context.Context, prog string, args []string, std
        flags.StringVar(&super.ConfigPath, "config", "/etc/arvados/config.yml", "arvados config file `path`")
        flags.StringVar(&super.SourcePath, "source", ".", "arvados source tree `directory`")
        flags.StringVar(&super.ClusterType, "type", "production", "cluster `type`: development, test, or production")
-       flags.StringVar(&super.ListenHost, "listen-host", "localhost", "host name or interface address for internal services whose InternalURLs are not configured")
+       flags.StringVar(&super.ListenHost, "listen-host", "127.0.0.1", "host name or interface address for internal services whose InternalURLs are not configured")
        flags.StringVar(&super.ControllerAddr, "controller-address", ":0", "desired controller address, `host:port` or `:port`")
-       flags.StringVar(&super.Workbench2Source, "workbench2-source", "../arvados-workbench2", "path to arvados-workbench2 source tree")
-       flags.BoolVar(&super.NoWorkbench1, "no-workbench1", false, "do not run workbench1")
+       flags.BoolVar(&super.NoWorkbench1, "no-workbench1", true, "do not run workbench1")
        flags.BoolVar(&super.NoWorkbench2, "no-workbench2", false, "do not run workbench2")
        flags.BoolVar(&super.OwnTemporaryDatabase, "own-temporary-database", false, "bring up a postgres server and create a temporary database")
        timeout := flags.Duration("timeout", 0, "maximum time to wait for cluster to be ready")
index 77036e934017efd2f9cfd44dba23b36f8dcbc522..6a5514ada0cbc8b91ca0988d8084664df595c9b5 100644 (file)
@@ -45,7 +45,7 @@ func (super *Supervisor) ClientsWithToken(clusterID, token string) (context.Cont
 // communicating with the cluster on behalf of the 'example' user.
 func (super *Supervisor) UserClients(clusterID string, rootctx context.Context, c *check.C, conn *rpc.Conn, authEmail string, activate bool) (context.Context, *arvados.Client, *keepclient.KeepClient, arvados.User) {
        login, err := conn.UserSessionCreate(rootctx, rpc.UserSessionCreateOptions{
-               ReturnTo: ",https://example.com",
+               ReturnTo: ",https://controller.api.client.invalid",
                AuthInfo: rpc.UserSessionAuthInfo{
                        Email:     authEmail,
                        FirstName: "Example",
index 16e150172de2fd372958d476ee27a10a67622129..3464e52b9aa8fa7b8b337f6e7c588243cf4d3ee0 100644 (file)
@@ -106,6 +106,9 @@ func migrationList(dir string, log logrus.FieldLogger) (map[string]bool, error)
                        return nil
                }
                fnm := d.Name()
+               if strings.HasSuffix(fnm, "~") {
+                       return nil
+               }
                if !strings.HasSuffix(fnm, ".rb") {
                        log.Warnf("unexpected file in db/migrate dir: %s", fnm)
                        return nil
index 0f0600f181d7e4371687f921f0f2ca318db80546..ac269b933abd226551441e977e4ce0f3daea896a 100644 (file)
@@ -61,8 +61,7 @@ type Supervisor struct {
        // explicitly configured in config file. If blank, use a
        // random port on ListenHost.
        ControllerAddr string
-       // Path to arvados-workbench2 source tree checkout.
-       Workbench2Source     string
+
        NoWorkbench1         bool
        NoWorkbench2         bool
        OwnTemporaryDatabase bool
@@ -112,7 +111,7 @@ func (super *Supervisor) Start(ctx context.Context) {
        super.ctx, super.cancel = context.WithCancel(ctx)
        super.done = make(chan struct{})
 
-       sigch := make(chan os.Signal)
+       sigch := make(chan os.Signal, 1)
        signal.Notify(sigch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
        go func() {
                defer signal.Stop(sigch)
@@ -205,15 +204,24 @@ func (super *Supervisor) Wait() error {
 func (super *Supervisor) startFederation(cfg *arvados.Config) {
        super.children = map[string]*Supervisor{}
        for id, cc := range cfg.Clusters {
-               super2 := *super
                yaml, err := json.Marshal(arvados.Config{Clusters: map[string]arvados.Cluster{id: cc}})
                if err != nil {
                        panic(fmt.Sprintf("json.Marshal partial config: %s", err))
                }
-               super2.ConfigYAML = string(yaml)
-               super2.ConfigPath = "-"
-               super2.children = nil
-
+               super2 := &Supervisor{
+                       ConfigPath:           "-",
+                       ConfigYAML:           string(yaml),
+                       SourcePath:           super.SourcePath,
+                       SourceVersion:        super.SourceVersion,
+                       ClusterType:          super.ClusterType,
+                       ListenHost:           super.ListenHost,
+                       ControllerAddr:       super.ControllerAddr,
+                       NoWorkbench1:         super.NoWorkbench1,
+                       NoWorkbench2:         super.NoWorkbench2,
+                       OwnTemporaryDatabase: super.OwnTemporaryDatabase,
+                       Stdin:                super.Stdin,
+                       Stderr:               super.Stderr,
+               }
                if super2.ClusterType == "test" {
                        super2.Stderr = &service.LogPrefixer{
                                Writer: super.Stderr,
@@ -221,7 +229,7 @@ func (super *Supervisor) startFederation(cfg *arvados.Config) {
                        }
                }
                super2.Start(super.ctx)
-               super.children[id] = &super2
+               super.children[id] = super2
        }
 }
 
@@ -325,13 +333,13 @@ func (super *Supervisor) runCluster() error {
        } else if super.SourceVersion == "" {
                // Find current source tree version.
                var buf bytes.Buffer
-               err = super.RunProgram(super.ctx, ".", runOptions{output: &buf}, "git", "diff", "--shortstat")
+               err = super.RunProgram(super.ctx, super.SourcePath, runOptions{output: &buf}, "git", "diff", "--shortstat")
                if err != nil {
                        return err
                }
                dirty := buf.Len() > 0
                buf.Reset()
-               err = super.RunProgram(super.ctx, ".", runOptions{output: &buf}, "git", "log", "-n1", "--format=%H")
+               err = super.RunProgram(super.ctx, super.SourcePath, runOptions{output: &buf}, "git", "log", "-n1", "--format=%H")
                if err != nil {
                        return err
                }
@@ -372,10 +380,7 @@ func (super *Supervisor) runCluster() error {
                }},
        }
        if !super.NoWorkbench1 {
-               tasks = append(tasks,
-                       installPassenger{src: "apps/workbench", varlibdir: "workbench1", depends: []supervisedTask{railsDatabase{}}}, // dependency ensures workbench doesn't delay api install/startup
-                       runPassenger{src: "apps/workbench", varlibdir: "workbench1", svc: super.cluster.Services.Workbench1, depends: []supervisedTask{installPassenger{src: "apps/workbench", varlibdir: "workbench1"}}},
-               )
+               return errors.New("workbench1 is no longer supported")
        }
        if !super.NoWorkbench2 {
                tasks = append(tasks,
@@ -849,7 +854,7 @@ func (super *Supervisor) autofillConfig() error {
                if super.NoWorkbench1 && svc == &super.cluster.Services.Workbench1 ||
                        super.NoWorkbench2 && svc == &super.cluster.Services.Workbench2 ||
                        !super.cluster.Containers.CloudVMs.Enable && svc == &super.cluster.Services.DispatchCloud {
-                       // When workbench1 is disabled, it gets an
+                       // When Workbench is disabled, it gets an
                        // ExternalURL (so we have a valid listening
                        // port to write in our Nginx config) but no
                        // InternalURLs (so health checker doesn't
index 5a319ebfe4bb45e0ea902bb62b5182044c442207..8c8c607f4592d4cdc0784c55a832e227ad689923 100644 (file)
@@ -37,25 +37,31 @@ func (runner runWorkbench2) Run(ctx context.Context, fail func(error), super *Su
                        err = super.RunProgram(ctx, "/var/lib/arvados/workbench2", runOptions{
                                user: "www-data",
                        }, "arvados-server", "workbench2", super.cluster.Services.Controller.ExternalURL.Host, net.JoinHostPort(host, port), ".")
-               } else if super.Workbench2Source == "" {
-                       super.logger.Info("skipping Workbench2: Workbench2Source==\"\" and not in production mode")
-                       return
                } else {
-                       stdinr, stdinw := io.Pipe()
-                       defer stdinw.Close()
-                       go func() {
-                               <-ctx.Done()
-                               stdinw.Close()
-                       }()
-                       if err = os.Mkdir(super.Workbench2Source+"/public/_health", 0777); err != nil && !errors.Is(err, fs.ErrExist) {
+                       // super.SourcePath might be readonly, so for
+                       // dev/test mode we make a copy in a writable
+                       // dir.
+                       livedir := super.wwwtempdir + "/workbench2"
+                       if err := super.RunProgram(ctx, super.SourcePath+"/services/workbench2", runOptions{}, "rsync", "-a", "--delete-after", super.SourcePath+"/services/workbench2/", livedir); err != nil {
+                               fail(err)
+                               return
+                       }
+                       if err = os.Mkdir(livedir+"/public/_health", 0777); err != nil && !errors.Is(err, fs.ErrExist) {
                                fail(err)
                                return
                        }
-                       if err = ioutil.WriteFile(super.Workbench2Source+"/public/_health/ping", []byte(`{"health":"OK"}`), 0666); err != nil {
+                       if err = ioutil.WriteFile(livedir+"/public/_health/ping", []byte(`{"health":"OK"}`), 0666); err != nil {
                                fail(err)
                                return
                        }
-                       err = super.RunProgram(ctx, super.Workbench2Source, runOptions{
+
+                       stdinr, stdinw := io.Pipe()
+                       defer stdinw.Close()
+                       go func() {
+                               <-ctx.Done()
+                               stdinw.Close()
+                       }()
+                       err = super.RunProgram(ctx, livedir, runOptions{
                                env: []string{
                                        "CI=true",
                                        "HTTPS=false",
index 9625214e22ebd1c805fe0ae21b04c47e2304aece..352e7b9af61ea2a51a12656ae26141335b1e64c2 100644 (file)
@@ -30,12 +30,12 @@ func (getCmd) RunCommand(prog string, args []string, stdin io.Reader, stdout, st
        flags.SetOutput(stderr)
        err = flags.Parse(args)
        if err != nil {
-               return 2
+               return cmd.EXIT_INVALIDARGUMENT
        }
        if len(flags.Args()) != 1 {
                fmt.Fprintf(stderr, "usage of %s:\n", prog)
                flags.PrintDefaults()
-               return 2
+               return cmd.EXIT_INVALIDARGUMENT
        }
        if opts.Short {
                opts.Format = "uuid"
index 7b170958b6ee69921f9d6186cfeaa79f091eedfb..71f2a23dc963baa747f173d446135175754ffeec 100644 (file)
@@ -28,6 +28,7 @@ import (
        "github.com/Azure/go-autorest/autorest/azure/auth"
        "github.com/Azure/go-autorest/autorest/to"
        "github.com/jmcvetta/randutil"
+       "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
        "golang.org/x/crypto/ssh"
 )
@@ -238,7 +239,7 @@ type azureInstanceSet struct {
        logger             logrus.FieldLogger
 }
 
-func newAzureInstanceSet(config json.RawMessage, dispatcherID cloud.InstanceSetID, _ cloud.SharedResourceTags, logger logrus.FieldLogger) (prv cloud.InstanceSet, err error) {
+func newAzureInstanceSet(config json.RawMessage, dispatcherID cloud.InstanceSetID, _ cloud.SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (prv cloud.InstanceSet, err error) {
        azcfg := azureInstanceSetConfig{}
        err = json.Unmarshal(config, &azcfg)
        if err != nil {
@@ -514,20 +515,23 @@ func (az *azureInstanceSet) Create(
                                AdminUsername: to.StringPtr(az.azconfig.AdminUsername),
                                LinuxConfiguration: &compute.LinuxConfiguration{
                                        DisablePasswordAuthentication: to.BoolPtr(true),
-                                       SSH: &compute.SSHConfiguration{
-                                               PublicKeys: &[]compute.SSHPublicKey{
-                                                       {
-                                                               Path:    to.StringPtr("/home/" + az.azconfig.AdminUsername + "/.ssh/authorized_keys"),
-                                                               KeyData: to.StringPtr(string(ssh.MarshalAuthorizedKey(publicKey))),
-                                                       },
-                                               },
-                                       },
                                },
                                CustomData: &customData,
                        },
                },
        }
 
+       if publicKey != nil {
+               vmParameters.VirtualMachineProperties.OsProfile.LinuxConfiguration.SSH = &compute.SSHConfiguration{
+                       PublicKeys: &[]compute.SSHPublicKey{
+                               {
+                                       Path:    to.StringPtr("/home/" + az.azconfig.AdminUsername + "/.ssh/authorized_keys"),
+                                       KeyData: to.StringPtr(string(ssh.MarshalAuthorizedKey(publicKey))),
+                               },
+                       },
+               }
+       }
+
        if instanceType.Preemptible {
                // Setting maxPrice to -1 is the equivalent of paying spot price, up to the
                // normal price. This means the node will not be pre-empted for price
index b6aa9a16b6187b4fd5486037076885fe4eb69d19..de8d655b197a685a6b9a6124a63652fa1bb6a527 100644 (file)
@@ -69,14 +69,17 @@ var _ = check.Suite(&AzureInstanceSetSuite{})
 
 const testNamePrefix = "compute-test123-"
 
-type VirtualMachinesClientStub struct{}
+type VirtualMachinesClientStub struct {
+       vmParameters compute.VirtualMachine
+}
 
-func (*VirtualMachinesClientStub) createOrUpdate(ctx context.Context,
+func (stub *VirtualMachinesClientStub) createOrUpdate(ctx context.Context,
        resourceGroupName string,
        VMName string,
        parameters compute.VirtualMachine) (result compute.VirtualMachine, err error) {
        parameters.ID = &VMName
        parameters.Name = &VMName
+       stub.vmParameters = parameters
        return parameters, nil
 }
 
@@ -124,7 +127,7 @@ type testConfig struct {
 
 var live = flag.String("live-azure-cfg", "", "Test with real azure API, provide config file")
 
-func GetInstanceSet() (cloud.InstanceSet, cloud.ImageID, arvados.Cluster, error) {
+func GetInstanceSet() (*azureInstanceSet, cloud.ImageID, arvados.Cluster, error) {
        cluster := arvados.Cluster{
                InstanceTypes: arvados.InstanceTypeMap(map[string]arvados.InstanceType{
                        "tiny": {
@@ -153,8 +156,8 @@ func GetInstanceSet() (cloud.InstanceSet, cloud.ImageID, arvados.Cluster, error)
                        return nil, cloud.ImageID(""), cluster, err
                }
 
-               ap, err := newAzureInstanceSet(exampleCfg.DriverParameters, "test123", nil, logrus.StandardLogger())
-               return ap, cloud.ImageID(exampleCfg.ImageIDForTestSuite), cluster, err
+               ap, err := newAzureInstanceSet(exampleCfg.DriverParameters, "test123", nil, logrus.StandardLogger(), nil)
+               return ap.(*azureInstanceSet), cloud.ImageID(exampleCfg.ImageIDForTestSuite), cluster, err
        }
        ap := azureInstanceSet{
                azconfig: azureInstanceSetConfig{
@@ -193,18 +196,25 @@ func (*AzureInstanceSetSuite) TestCreate(c *check.C) {
        tags := inst.Tags()
        c.Check(tags["TestTagName"], check.Equals, "test tag value")
        c.Logf("inst.String()=%v Address()=%v Tags()=%v", inst.String(), inst.Address(), tags)
+       if *live == "" {
+               c.Check(ap.vmClient.(*VirtualMachinesClientStub).vmParameters.VirtualMachineProperties.OsProfile.LinuxConfiguration.SSH, check.NotNil)
+       }
 
        instPreemptable, err := ap.Create(cluster.InstanceTypes["tinyp"],
                img, map[string]string{
                        "TestTagName": "test tag value",
-               }, "umask 0600; echo -n test-file-data >/var/run/test-file", pk)
+               }, "umask 0600; echo -n test-file-data >/var/run/test-file", nil)
 
        c.Assert(err, check.IsNil)
 
        tags = instPreemptable.Tags()
        c.Check(tags["TestTagName"], check.Equals, "test tag value")
        c.Logf("instPreemptable.String()=%v Address()=%v Tags()=%v", instPreemptable.String(), instPreemptable.Address(), tags)
-
+       if *live == "" {
+               // Should not have set SSH option, because publickey
+               // arg was nil
+               c.Check(ap.vmClient.(*VirtualMachinesClientStub).vmParameters.VirtualMachineProperties.OsProfile.LinuxConfiguration.SSH, check.IsNil)
+       }
 }
 
 func (*AzureInstanceSetSuite) TestListInstances(c *check.C) {
@@ -229,7 +239,7 @@ func (*AzureInstanceSetSuite) TestManageNics(c *check.C) {
                c.Fatal("Error making provider", err)
        }
 
-       ap.(*azureInstanceSet).manageNics()
+       ap.manageNics()
        ap.Stop()
 }
 
@@ -239,7 +249,7 @@ func (*AzureInstanceSetSuite) TestManageBlobs(c *check.C) {
                c.Fatal("Error making provider", err)
        }
 
-       ap.(*azureInstanceSet).manageBlobs()
+       ap.manageBlobs()
        ap.Stop()
 }
 
@@ -263,7 +273,7 @@ func (*AzureInstanceSetSuite) TestDeleteFake(c *check.C) {
                c.Fatal("Error making provider", err)
        }
 
-       _, err = ap.(*azureInstanceSet).netClient.delete(context.Background(), "fakefakefake", "fakefakefake")
+       _, err = ap.netClient.delete(context.Background(), "fakefakefake", "fakefakefake")
 
        de, ok := err.(autorest.DetailedError)
        if ok {
index 0ec79e1175dcda50d98609c55d4ff15d7e976d11..2dc13e5a51a2f3c423db922a837186400e3d812d 100644 (file)
@@ -18,7 +18,6 @@ import (
        "git.arvados.org/arvados.git/lib/dispatchcloud"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
-       "golang.org/x/crypto/ssh"
 )
 
 var Command command
@@ -65,9 +64,9 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
        if err != nil {
                return 1
        }
-       key, err := ssh.ParsePrivateKey([]byte(cluster.Containers.DispatchPrivateKey))
+       key, err := config.LoadSSHKey(cluster.Containers.DispatchPrivateKey)
        if err != nil {
-               err = fmt.Errorf("error parsing configured Containers.DispatchPrivateKey: %s", err)
+               err = fmt.Errorf("error loading Containers.DispatchPrivateKey: %s", err)
                return 1
        }
        driver, ok := dispatchcloud.Drivers[cluster.Containers.CloudVMs.Driver]
@@ -86,22 +85,24 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
        tagKeyPrefix := cluster.Containers.CloudVMs.TagKeyPrefix
        tags[tagKeyPrefix+"CloudTestPID"] = fmt.Sprintf("%d", os.Getpid())
        if !(&tester{
-               Logger:           logger,
-               Tags:             tags,
-               TagKeyPrefix:     tagKeyPrefix,
-               SetID:            cloud.InstanceSetID(*instanceSetID),
-               DestroyExisting:  *destroyExisting,
-               ProbeInterval:    cluster.Containers.CloudVMs.ProbeInterval.Duration(),
-               SyncInterval:     cluster.Containers.CloudVMs.SyncInterval.Duration(),
-               TimeoutBooting:   cluster.Containers.CloudVMs.TimeoutBooting.Duration(),
-               Driver:           driver,
-               DriverParameters: cluster.Containers.CloudVMs.DriverParameters,
-               ImageID:          cloud.ImageID(*imageID),
-               InstanceType:     it,
-               SSHKey:           key,
-               SSHPort:          cluster.Containers.CloudVMs.SSHPort,
-               BootProbeCommand: cluster.Containers.CloudVMs.BootProbeCommand,
-               ShellCommand:     *shellCommand,
+               Logger:              logger,
+               Tags:                tags,
+               TagKeyPrefix:        tagKeyPrefix,
+               SetID:               cloud.InstanceSetID(*instanceSetID),
+               DestroyExisting:     *destroyExisting,
+               ProbeInterval:       cluster.Containers.CloudVMs.ProbeInterval.Duration(),
+               SyncInterval:        cluster.Containers.CloudVMs.SyncInterval.Duration(),
+               TimeoutBooting:      cluster.Containers.CloudVMs.TimeoutBooting.Duration(),
+               Driver:              driver,
+               DriverParameters:    cluster.Containers.CloudVMs.DriverParameters,
+               ImageID:             cloud.ImageID(*imageID),
+               InstanceType:        it,
+               SSHKey:              key,
+               SSHPort:             cluster.Containers.CloudVMs.SSHPort,
+               DeployPublicKey:     cluster.Containers.CloudVMs.DeployPublicKey,
+               BootProbeCommand:    cluster.Containers.CloudVMs.BootProbeCommand,
+               InstanceInitCommand: cloud.InitCommand(cluster.Containers.CloudVMs.InstanceInitCommand),
+               ShellCommand:        *shellCommand,
                PauseBeforeDestroy: func() {
                        if *pauseBeforeDestroy {
                                logger.Info("waiting for operator to press Enter")
index 9fd7c9e74941f8e12c47ae6bab60f5ea764fa422..a335278ed6b15a91794bc8927697dec3a6aef1ec 100644 (file)
@@ -27,23 +27,25 @@ var (
 // configuration. Run() should be called only once, after assigning
 // suitable values to public fields.
 type tester struct {
-       Logger             logrus.FieldLogger
-       Tags               cloud.SharedResourceTags
-       TagKeyPrefix       string
-       SetID              cloud.InstanceSetID
-       DestroyExisting    bool
-       ProbeInterval      time.Duration
-       SyncInterval       time.Duration
-       TimeoutBooting     time.Duration
-       Driver             cloud.Driver
-       DriverParameters   json.RawMessage
-       InstanceType       arvados.InstanceType
-       ImageID            cloud.ImageID
-       SSHKey             ssh.Signer
-       SSHPort            string
-       BootProbeCommand   string
-       ShellCommand       string
-       PauseBeforeDestroy func()
+       Logger              logrus.FieldLogger
+       Tags                cloud.SharedResourceTags
+       TagKeyPrefix        string
+       SetID               cloud.InstanceSetID
+       DestroyExisting     bool
+       ProbeInterval       time.Duration
+       SyncInterval        time.Duration
+       TimeoutBooting      time.Duration
+       Driver              cloud.Driver
+       DriverParameters    json.RawMessage
+       InstanceType        arvados.InstanceType
+       ImageID             cloud.ImageID
+       SSHKey              ssh.Signer
+       SSHPort             string
+       DeployPublicKey     bool
+       BootProbeCommand    string
+       InstanceInitCommand cloud.InitCommand
+       ShellCommand        string
+       PauseBeforeDestroy  func()
 
        is              cloud.InstanceSet
        testInstance    *worker.TagVerifier
@@ -54,16 +56,60 @@ type tester struct {
        failed bool
 }
 
+// Run the test suite once for each applicable permutation of
+// DriverParameters.  Return true if everything worked.
+//
+// Currently this means run once for each configured SubnetID.
+func (t *tester) Run() bool {
+       var dp map[string]interface{}
+       if len(t.DriverParameters) > 0 {
+               err := json.Unmarshal(t.DriverParameters, &dp)
+               if err != nil {
+                       t.Logger.WithError(err).Error("error decoding configured CloudVMs.DriverParameters")
+                       return false
+               }
+       }
+       subnets, ok := dp["SubnetID"].([]interface{})
+       if !ok || len(subnets) <= 1 {
+               // Easy, only one SubnetID to test.
+               return t.runWithDriverParameters(t.DriverParameters)
+       }
+
+       deferredError := false
+       for i, subnet := range subnets {
+               subnet, ok := subnet.(string)
+               if !ok {
+                       t.Logger.Errorf("CloudVMs.DriverParameters.SubnetID[%d] is invalid -- must be a string", i)
+                       deferredError = true
+                       continue
+               }
+               dp["SubnetID"] = subnet
+               t.Logger.Infof("running tests using SubnetID[%d] %q", i, subnet)
+               dpjson, err := json.Marshal(dp)
+               if err != nil {
+                       t.Logger.WithError(err).Error("error encoding driver parameters")
+                       deferredError = true
+                       continue
+               }
+               ok = t.runWithDriverParameters(dpjson)
+               if !ok {
+                       t.Logger.Infof("failed tests using SubnetID[%d] %q", i, subnet)
+                       deferredError = true
+               }
+       }
+       return !deferredError
+}
+
 // Run the test suite as specified, clean up as needed, and return
 // true (everything is OK) or false (something went wrong).
-func (t *tester) Run() bool {
+func (t *tester) runWithDriverParameters(driverParameters json.RawMessage) bool {
        // This flag gets set when we encounter a non-fatal error, so
        // we can continue doing more tests but remember to return
        // false (failure) at the end.
        deferredError := false
 
        var err error
-       t.is, err = t.Driver.InstanceSet(t.DriverParameters, t.SetID, t.Tags, t.Logger)
+       t.is, err = t.Driver.InstanceSet(driverParameters, t.SetID, t.Tags, t.Logger, nil)
        if err != nil {
                t.Logger.WithError(err).Info("error initializing driver")
                return false
@@ -127,7 +173,12 @@ func (t *tester) Run() bool {
        defer t.destroyTestInstance()
 
        bootDeadline := time.Now().Add(t.TimeoutBooting)
-       initCommand := worker.TagVerifier{Instance: nil, Secret: t.secret, ReportVerified: nil}.InitCommand()
+       initCommand := worker.TagVerifier{Instance: nil, Secret: t.secret, ReportVerified: nil}.InitCommand() + "\n" + t.InstanceInitCommand
+
+       installPublicKey := t.SSHKey.PublicKey()
+       if !t.DeployPublicKey {
+               installPublicKey = nil
+       }
 
        t.Logger.WithFields(logrus.Fields{
                "InstanceType":         t.InstanceType.Name,
@@ -135,9 +186,10 @@ func (t *tester) Run() bool {
                "ImageID":              t.ImageID,
                "Tags":                 tags,
                "InitCommand":          initCommand,
+               "DeployPublicKey":      installPublicKey != nil,
        }).Info("creating instance")
        t0 := time.Now()
-       inst, err := t.is.Create(t.InstanceType, t.ImageID, tags, initCommand, t.SSHKey.PublicKey())
+       inst, err := t.is.Create(t.InstanceType, t.ImageID, tags, initCommand, installPublicKey)
        lgrC := t.Logger.WithField("Duration", time.Since(t0))
        if err != nil {
                // Create() might have failed due to a bug or network
index a74f12561003a6f8763311be4170c3e38e12d8ad..6251f18df0fc485f876e710731875a9e9061e14d 100644 (file)
@@ -13,7 +13,9 @@ import (
        "encoding/json"
        "fmt"
        "math/big"
+       "regexp"
        "strconv"
+       "strings"
        "sync"
        "sync/atomic"
        "time"
@@ -28,6 +30,7 @@ import (
        "github.com/aws/aws-sdk-go/aws/request"
        "github.com/aws/aws-sdk-go/aws/session"
        "github.com/aws/aws-sdk-go/service/ec2"
+       "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
        "golang.org/x/crypto/ssh"
 )
@@ -45,7 +48,7 @@ type ec2InstanceSetConfig struct {
        SecretAccessKey         string
        Region                  string
        SecurityGroupIDs        arvados.StringSet
-       SubnetID                string
+       SubnetID                sliceOrSingleString
        AdminUsername           string
        EBSVolumeType           string
        EBSPrice                float64
@@ -53,6 +56,39 @@ type ec2InstanceSetConfig struct {
        SpotPriceUpdateInterval arvados.Duration
 }
 
+type sliceOrSingleString []string
+
+// UnmarshalJSON unmarshals an array of strings, and also accepts ""
+// as [], and "foo" as ["foo"].
+func (ss *sliceOrSingleString) UnmarshalJSON(data []byte) error {
+       if len(data) == 0 {
+               *ss = nil
+       } else if data[0] == '[' {
+               var slice []string
+               err := json.Unmarshal(data, &slice)
+               if err != nil {
+                       return err
+               }
+               if len(slice) == 0 {
+                       *ss = nil
+               } else {
+                       *ss = slice
+               }
+       } else {
+               var str string
+               err := json.Unmarshal(data, &str)
+               if err != nil {
+                       return err
+               }
+               if str == "" {
+                       *ss = nil
+               } else {
+                       *ss = []string{str}
+               }
+       }
+       return nil
+}
+
 type ec2Interface interface {
        DescribeKeyPairs(input *ec2.DescribeKeyPairsInput) (*ec2.DescribeKeyPairsOutput, error)
        ImportKeyPair(input *ec2.ImportKeyPairInput) (*ec2.ImportKeyPairOutput, error)
@@ -66,6 +102,7 @@ type ec2Interface interface {
 
 type ec2InstanceSet struct {
        ec2config              ec2InstanceSetConfig
+       currentSubnetIDIndex   int32
        instanceSetID          cloud.InstanceSetID
        logger                 logrus.FieldLogger
        client                 ec2Interface
@@ -77,9 +114,12 @@ type ec2InstanceSet struct {
        prices        map[priceKey][]cloud.InstancePrice
        pricesLock    sync.Mutex
        pricesUpdated map[priceKey]time.Time
+
+       mInstances      *prometheus.GaugeVec
+       mInstanceStarts *prometheus.CounterVec
 }
 
-func newEC2InstanceSet(config json.RawMessage, instanceSetID cloud.InstanceSetID, _ cloud.SharedResourceTags, logger logrus.FieldLogger) (prv cloud.InstanceSet, err error) {
+func newEC2InstanceSet(config json.RawMessage, instanceSetID cloud.InstanceSetID, _ cloud.SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (prv cloud.InstanceSet, err error) {
        instanceSet := &ec2InstanceSet{
                instanceSetID: instanceSetID,
                logger:        logger,
@@ -106,6 +146,36 @@ func newEC2InstanceSet(config json.RawMessage, instanceSetID cloud.InstanceSetID
        if instanceSet.ec2config.EBSVolumeType == "" {
                instanceSet.ec2config.EBSVolumeType = "gp2"
        }
+
+       // Set up metrics
+       instanceSet.mInstances = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+               Namespace: "arvados",
+               Subsystem: "dispatchcloud",
+               Name:      "ec2_instances",
+               Help:      "Number of instances running",
+       }, []string{"subnet_id"})
+       instanceSet.mInstanceStarts = prometheus.NewCounterVec(prometheus.CounterOpts{
+               Namespace: "arvados",
+               Subsystem: "dispatchcloud",
+               Name:      "ec2_instance_starts_total",
+               Help:      "Number of attempts to start a new instance",
+       }, []string{"subnet_id", "success"})
+       // Initialize all of the series we'll be reporting.  Otherwise
+       // the {subnet=A, success=0} series doesn't appear in metrics
+       // at all until there's a failure in subnet A.
+       for _, subnet := range instanceSet.ec2config.SubnetID {
+               instanceSet.mInstanceStarts.WithLabelValues(subnet, "0").Add(0)
+               instanceSet.mInstanceStarts.WithLabelValues(subnet, "1").Add(0)
+       }
+       if len(instanceSet.ec2config.SubnetID) == 0 {
+               instanceSet.mInstanceStarts.WithLabelValues("", "0").Add(0)
+               instanceSet.mInstanceStarts.WithLabelValues("", "1").Add(0)
+       }
+       if reg != nil {
+               reg.MustRegister(instanceSet.mInstances)
+               reg.MustRegister(instanceSet.mInstanceStarts)
+       }
+
        return instanceSet, nil
 }
 
@@ -149,40 +219,6 @@ func (instanceSet *ec2InstanceSet) Create(
        initCommand cloud.InitCommand,
        publicKey ssh.PublicKey) (cloud.Instance, error) {
 
-       md5keyFingerprint, sha1keyFingerprint, err := awsKeyFingerprint(publicKey)
-       if err != nil {
-               return nil, fmt.Errorf("Could not make key fingerprint: %v", err)
-       }
-       instanceSet.keysMtx.Lock()
-       var keyname string
-       var ok bool
-       if keyname, ok = instanceSet.keys[md5keyFingerprint]; !ok {
-               keyout, err := instanceSet.client.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
-                       Filters: []*ec2.Filter{{
-                               Name:   aws.String("fingerprint"),
-                               Values: []*string{&md5keyFingerprint, &sha1keyFingerprint},
-                       }},
-               })
-               if err != nil {
-                       return nil, fmt.Errorf("Could not search for keypair: %v", err)
-               }
-
-               if len(keyout.KeyPairs) > 0 {
-                       keyname = *(keyout.KeyPairs[0].KeyName)
-               } else {
-                       keyname = "arvados-dispatch-keypair-" + md5keyFingerprint
-                       _, err := instanceSet.client.ImportKeyPair(&ec2.ImportKeyPairInput{
-                               KeyName:           &keyname,
-                               PublicKeyMaterial: ssh.MarshalAuthorizedKey(publicKey),
-                       })
-                       if err != nil {
-                               return nil, fmt.Errorf("Could not import keypair: %v", err)
-                       }
-               }
-               instanceSet.keys[md5keyFingerprint] = keyname
-       }
-       instanceSet.keysMtx.Unlock()
-
        ec2tags := []*ec2.Tag{}
        for k, v := range newTags {
                ec2tags = append(ec2tags, &ec2.Tag{
@@ -201,7 +237,6 @@ func (instanceSet *ec2InstanceSet) Create(
                InstanceType: &instanceType.ProviderType,
                MaxCount:     aws.Int64(1),
                MinCount:     aws.Int64(1),
-               KeyName:      &keyname,
 
                NetworkInterfaces: []*ec2.InstanceNetworkInterfaceSpecification{
                        {
@@ -209,7 +244,6 @@ func (instanceSet *ec2InstanceSet) Create(
                                DeleteOnTermination:      aws.Bool(true),
                                DeviceIndex:              aws.Int64(0),
                                Groups:                   aws.StringSlice(groups),
-                               SubnetId:                 &instanceSet.ec2config.SubnetID,
                        }},
                DisableApiTermination:             aws.Bool(false),
                InstanceInitiatedShutdownBehavior: aws.String("terminate"),
@@ -218,9 +252,23 @@ func (instanceSet *ec2InstanceSet) Create(
                                ResourceType: aws.String("instance"),
                                Tags:         ec2tags,
                        }},
+               MetadataOptions: &ec2.InstanceMetadataOptionsRequest{
+                       // Require IMDSv2, as described at
+                       // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html
+                       HttpEndpoint: aws.String(ec2.InstanceMetadataEndpointStateEnabled),
+                       HttpTokens:   aws.String(ec2.HttpTokensStateRequired),
+               },
                UserData: aws.String(base64.StdEncoding.EncodeToString([]byte("#!/bin/sh\n" + initCommand + "\n"))),
        }
 
+       if publicKey != nil {
+               keyname, err := instanceSet.getKeyName(publicKey)
+               if err != nil {
+                       return nil, err
+               }
+               rii.KeyName = &keyname
+       }
+
        if instanceType.AddedScratch > 0 {
                rii.BlockDeviceMappings = []*ec2.BlockDeviceMapping{{
                        DeviceName: aws.String("/dev/xvdt"),
@@ -246,10 +294,48 @@ func (instanceSet *ec2InstanceSet) Create(
                }
        }
 
-       rsv, err := instanceSet.client.RunInstances(&rii)
-       err = wrapError(err, &instanceSet.throttleDelayCreate)
-       if err != nil {
-               return nil, err
+       var rsv *ec2.Reservation
+       var errToReturn error
+       subnets := instanceSet.ec2config.SubnetID
+       currentSubnetIDIndex := int(atomic.LoadInt32(&instanceSet.currentSubnetIDIndex))
+       for tryOffset := 0; ; tryOffset++ {
+               tryIndex := 0
+               trySubnet := ""
+               if len(subnets) > 0 {
+                       tryIndex = (currentSubnetIDIndex + tryOffset) % len(subnets)
+                       trySubnet = subnets[tryIndex]
+                       rii.NetworkInterfaces[0].SubnetId = aws.String(trySubnet)
+               }
+               var err error
+               rsv, err = instanceSet.client.RunInstances(&rii)
+               instanceSet.mInstanceStarts.WithLabelValues(trySubnet, boolLabelValue[err == nil]).Add(1)
+               if !isErrorCapacity(errToReturn) || isErrorCapacity(err) {
+                       // We want to return the last capacity error,
+                       // if any; otherwise the last non-capacity
+                       // error.
+                       errToReturn = err
+               }
+               if isErrorSubnetSpecific(err) &&
+                       tryOffset < len(subnets)-1 {
+                       instanceSet.logger.WithError(err).WithField("SubnetID", subnets[tryIndex]).
+                               Warn("RunInstances failed, trying next subnet")
+                       continue
+               }
+               // Succeeded, or exhausted all subnets, or got a
+               // non-subnet-related error.
+               //
+               // We intentionally update currentSubnetIDIndex even
+               // in the non-retryable-failure case here to avoid a
+               // situation where successive calls to Create() keep
+               // returning errors for the same subnet (perhaps
+               // "subnet full") and never reveal the errors for the
+               // other configured subnets (perhaps "subnet ID
+               // invalid").
+               atomic.StoreInt32(&instanceSet.currentSubnetIDIndex, int32(tryIndex))
+               break
+       }
+       if rsv == nil || len(rsv.Instances) == 0 {
+               return nil, wrapError(errToReturn, &instanceSet.throttleDelayCreate)
        }
        return &ec2Instance{
                provider: instanceSet,
@@ -257,6 +343,40 @@ func (instanceSet *ec2InstanceSet) Create(
        }, nil
 }
 
+func (instanceSet *ec2InstanceSet) getKeyName(publicKey ssh.PublicKey) (string, error) {
+       instanceSet.keysMtx.Lock()
+       defer instanceSet.keysMtx.Unlock()
+       md5keyFingerprint, sha1keyFingerprint, err := awsKeyFingerprint(publicKey)
+       if err != nil {
+               return "", fmt.Errorf("Could not make key fingerprint: %v", err)
+       }
+       if keyname, ok := instanceSet.keys[md5keyFingerprint]; ok {
+               return keyname, nil
+       }
+       keyout, err := instanceSet.client.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
+               Filters: []*ec2.Filter{{
+                       Name:   aws.String("fingerprint"),
+                       Values: []*string{&md5keyFingerprint, &sha1keyFingerprint},
+               }},
+       })
+       if err != nil {
+               return "", fmt.Errorf("Could not search for keypair: %v", err)
+       }
+       if len(keyout.KeyPairs) > 0 {
+               return *(keyout.KeyPairs[0].KeyName), nil
+       }
+       keyname := "arvados-dispatch-keypair-" + md5keyFingerprint
+       _, err = instanceSet.client.ImportKeyPair(&ec2.ImportKeyPairInput{
+               KeyName:           &keyname,
+               PublicKeyMaterial: ssh.MarshalAuthorizedKey(publicKey),
+       })
+       if err != nil {
+               return "", fmt.Errorf("Could not import keypair: %v", err)
+       }
+       instanceSet.keys[md5keyFingerprint] = keyname
+       return keyname, nil
+}
+
 func (instanceSet *ec2InstanceSet) Instances(tags cloud.InstanceTags) (instances []cloud.Instance, err error) {
        var filters []*ec2.Filter
        for k, v := range tags {
@@ -311,6 +431,24 @@ func (instanceSet *ec2InstanceSet) Instances(tags cloud.InstanceTags) (instances
                }
                instanceSet.updateSpotPrices(instances)
        }
+
+       // Count instances in each subnet, and report in metrics.
+       subnetInstances := map[string]int{"": 0}
+       for _, subnet := range instanceSet.ec2config.SubnetID {
+               subnetInstances[subnet] = 0
+       }
+       for _, inst := range instances {
+               subnet := inst.(*ec2Instance).instance.SubnetId
+               if subnet != nil {
+                       subnetInstances[*subnet]++
+               } else {
+                       subnetInstances[""]++
+               }
+       }
+       for subnet, count := range subnetInstances {
+               instanceSet.mInstances.WithLabelValues(subnet).Set(float64(count))
+       }
+
        return instances, err
 }
 
@@ -540,25 +678,77 @@ func (err rateLimitError) EarliestRetry() time.Time {
        return err.earliestRetry
 }
 
-var isCodeCapacity = map[string]bool{
+type capacityError struct {
+       error
+       isInstanceTypeSpecific bool
+}
+
+func (er *capacityError) IsCapacityError() bool {
+       return true
+}
+
+func (er *capacityError) IsInstanceTypeSpecific() bool {
+       return er.isInstanceTypeSpecific
+}
+
+var isCodeQuota = map[string]bool{
+       "InstanceLimitExceeded":             true,
+       "InsufficientAddressCapacity":       true,
        "InsufficientFreeAddressesInSubnet": true,
-       "InsufficientInstanceCapacity":      true,
        "InsufficientVolumeCapacity":        true,
        "MaxSpotInstanceCountExceeded":      true,
        "VcpuLimitExceeded":                 true,
 }
 
-// isErrorCapacity returns whether the error is to be throttled based on its code.
+// isErrorQuota returns whether the error indicates we have reached
+// some usage quota/limit -- i.e., immediately retrying with an equal
+// or larger instance type will probably not work.
+//
 // Returns false if error is nil.
-func isErrorCapacity(err error) bool {
+func isErrorQuota(err error) bool {
        if aerr, ok := err.(awserr.Error); ok && aerr != nil {
-               if _, ok := isCodeCapacity[aerr.Code()]; ok {
+               if _, ok := isCodeQuota[aerr.Code()]; ok {
                        return true
                }
        }
        return false
 }
 
+var reSubnetSpecificInvalidParameterMessage = regexp.MustCompile(`(?ms).*( subnet |sufficient free [Ii]pv[46] addresses).*`)
+
+// isErrorSubnetSpecific returns true if the problem encountered by
+// RunInstances might be avoided by trying a different subnet.
+func isErrorSubnetSpecific(err error) bool {
+       aerr, ok := err.(awserr.Error)
+       if !ok {
+               return false
+       }
+       code := aerr.Code()
+       return strings.Contains(code, "Subnet") ||
+               code == "InsufficientInstanceCapacity" ||
+               code == "InsufficientVolumeCapacity" ||
+               code == "Unsupported" ||
+               // See TestIsErrorSubnetSpecific for examples of why
+               // we look for substrings in code/message instead of
+               // only using specific codes here.
+               (strings.Contains(code, "InvalidParameter") &&
+                       reSubnetSpecificInvalidParameterMessage.MatchString(aerr.Message()))
+}
+
+// isErrorCapacity returns true if the error indicates lack of
+// capacity (either temporary or permanent) to run a specific instance
+// type -- i.e., retrying with a different instance type might
+// succeed.
+func isErrorCapacity(err error) bool {
+       aerr, ok := err.(awserr.Error)
+       if !ok {
+               return false
+       }
+       code := aerr.Code()
+       return code == "InsufficientInstanceCapacity" ||
+               (code == "Unsupported" && strings.Contains(aerr.Message(), "requested instance type"))
+}
+
 type ec2QuotaError struct {
        error
 }
@@ -580,8 +770,10 @@ func wrapError(err error, throttleValue *atomic.Value) error {
                }
                throttleValue.Store(d)
                return rateLimitError{error: err, earliestRetry: time.Now().Add(d)}
-       } else if isErrorCapacity(err) {
+       } else if isErrorQuota(err) {
                return &ec2QuotaError{err}
+       } else if isErrorCapacity(err) {
+               return &capacityError{err, true}
        } else if err != nil {
                throttleValue.Store(time.Duration(0))
                return err
@@ -589,3 +781,5 @@ func wrapError(err error, throttleValue *atomic.Value) error {
        throttleValue.Store(time.Duration(0))
        return nil
 }
+
+var boolLabelValue = map[bool]string{false: "0", true: "1"}
index 38ada13ed3f5f14fe781e548727745d5fd079c4e..5e6cf2c82b5caee81de1255936ee30a2edeb1024 100644 (file)
@@ -24,7 +24,9 @@ package ec2
 
 import (
        "encoding/json"
+       "errors"
        "flag"
+       "fmt"
        "sync/atomic"
        "testing"
        "time"
@@ -32,10 +34,14 @@ import (
        "git.arvados.org/arvados.git/lib/cloud"
        "git.arvados.org/arvados.git/lib/dispatchcloud/test"
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
        "git.arvados.org/arvados.git/sdk/go/config"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "github.com/aws/aws-sdk-go/aws"
        "github.com/aws/aws-sdk-go/aws/awserr"
        "github.com/aws/aws-sdk-go/service/ec2"
+       "github.com/ghodss/yaml"
+       "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
        check "gopkg.in/check.v1"
 )
@@ -47,6 +53,34 @@ func Test(t *testing.T) {
        check.TestingT(t)
 }
 
+type sliceOrStringSuite struct{}
+
+var _ = check.Suite(&sliceOrStringSuite{})
+
+func (s *sliceOrStringSuite) TestUnmarshal(c *check.C) {
+       var conf ec2InstanceSetConfig
+       for _, trial := range []struct {
+               input  string
+               output sliceOrSingleString
+       }{
+               {``, nil},
+               {`""`, nil},
+               {`[]`, nil},
+               {`"foo"`, sliceOrSingleString{"foo"}},
+               {`["foo"]`, sliceOrSingleString{"foo"}},
+               {`[foo]`, sliceOrSingleString{"foo"}},
+               {`["foo", "bar"]`, sliceOrSingleString{"foo", "bar"}},
+               {`[foo-bar, baz]`, sliceOrSingleString{"foo-bar", "baz"}},
+       } {
+               c.Logf("trial: %+v", trial)
+               err := yaml.Unmarshal([]byte("SubnetID: "+trial.input+"\n"), &conf)
+               if !c.Check(err, check.IsNil) {
+                       continue
+               }
+               c.Check(conf.SubnetID, check.DeepEquals, trial.output)
+       }
+}
+
 type EC2InstanceSetSuite struct{}
 
 var _ = check.Suite(&EC2InstanceSetSuite{})
@@ -57,19 +91,34 @@ type testConfig struct {
 }
 
 type ec2stub struct {
-       c       *check.C
-       reftime time.Time
+       c                     *check.C
+       reftime               time.Time
+       importKeyPairCalls    []*ec2.ImportKeyPairInput
+       describeKeyPairsCalls []*ec2.DescribeKeyPairsInput
+       runInstancesCalls     []*ec2.RunInstancesInput
+       // {subnetID => error}: RunInstances returns error if subnetID
+       // matches.
+       subnetErrorOnRunInstances map[string]error
 }
 
 func (e *ec2stub) ImportKeyPair(input *ec2.ImportKeyPairInput) (*ec2.ImportKeyPairOutput, error) {
+       e.importKeyPairCalls = append(e.importKeyPairCalls, input)
        return nil, nil
 }
 
 func (e *ec2stub) DescribeKeyPairs(input *ec2.DescribeKeyPairsInput) (*ec2.DescribeKeyPairsOutput, error) {
+       e.describeKeyPairsCalls = append(e.describeKeyPairsCalls, input)
        return &ec2.DescribeKeyPairsOutput{}, nil
 }
 
 func (e *ec2stub) RunInstances(input *ec2.RunInstancesInput) (*ec2.Reservation, error) {
+       e.runInstancesCalls = append(e.runInstancesCalls, input)
+       if len(input.NetworkInterfaces) > 0 && input.NetworkInterfaces[0].SubnetId != nil {
+               err := e.subnetErrorOnRunInstances[*input.NetworkInterfaces[0].SubnetId]
+               if err != nil {
+                       return nil, err
+               }
+       }
        return &ec2.Reservation{Instances: []*ec2.Instance{{
                InstanceId:   aws.String("i-123"),
                InstanceType: aws.String("t2.micro"),
@@ -150,7 +199,21 @@ func (e *ec2stub) TerminateInstances(input *ec2.TerminateInstancesInput) (*ec2.T
        return nil, nil
 }
 
-func GetInstanceSet(c *check.C) (*ec2InstanceSet, cloud.ImageID, arvados.Cluster) {
+type ec2stubError struct {
+       code    string
+       message string
+}
+
+func (err *ec2stubError) Code() string    { return err.code }
+func (err *ec2stubError) Message() string { return err.message }
+func (err *ec2stubError) Error() string   { return fmt.Sprintf("%s: %s", err.code, err.message) }
+func (err *ec2stubError) OrigErr() error  { return errors.New("stub OrigErr") }
+
+// Ensure ec2stubError satisfies the aws.Error interface
+var _ = awserr.Error(&ec2stubError{})
+
+func GetInstanceSet(c *check.C, conf string) (*ec2InstanceSet, cloud.ImageID, arvados.Cluster, *prometheus.Registry) {
+       reg := prometheus.NewRegistry()
        cluster := arvados.Cluster{
                InstanceTypes: arvados.InstanceTypeMap(map[string]arvados.InstanceType{
                        "tiny": {
@@ -186,21 +249,19 @@ func GetInstanceSet(c *check.C) (*ec2InstanceSet, cloud.ImageID, arvados.Cluster
                err := config.LoadFile(&exampleCfg, *live)
                c.Assert(err, check.IsNil)
 
-               ap, err := newEC2InstanceSet(exampleCfg.DriverParameters, "test123", nil, logrus.StandardLogger())
+               is, err := newEC2InstanceSet(exampleCfg.DriverParameters, "test123", nil, logrus.StandardLogger(), reg)
                c.Assert(err, check.IsNil)
-               return ap.(*ec2InstanceSet), cloud.ImageID(exampleCfg.ImageIDForTestSuite), cluster
-       }
-       ap := ec2InstanceSet{
-               instanceSetID: "test123",
-               logger:        logrus.StandardLogger(),
-               client:        &ec2stub{c: c, reftime: time.Now().UTC()},
-               keys:          make(map[string]string),
+               return is.(*ec2InstanceSet), cloud.ImageID(exampleCfg.ImageIDForTestSuite), cluster, reg
+       } else {
+               is, err := newEC2InstanceSet(json.RawMessage(conf), "test123", nil, ctxlog.TestLogger(c), reg)
+               c.Assert(err, check.IsNil)
+               is.(*ec2InstanceSet).client = &ec2stub{c: c, reftime: time.Now().UTC()}
+               return is.(*ec2InstanceSet), cloud.ImageID("blob"), cluster, reg
        }
-       return &ap, cloud.ImageID("blob"), cluster
 }
 
 func (*EC2InstanceSetSuite) TestCreate(c *check.C) {
-       ap, img, cluster := GetInstanceSet(c)
+       ap, img, cluster, _ := GetInstanceSet(c, "{}")
        pk, _ := test.LoadTestKey(c, "../../dispatchcloud/test/sshkey_dispatch")
 
        inst, err := ap.Create(cluster.InstanceTypes["tiny"],
@@ -213,16 +274,24 @@ func (*EC2InstanceSetSuite) TestCreate(c *check.C) {
        c.Check(tags["TestTagName"], check.Equals, "test tag value")
        c.Logf("inst.String()=%v Address()=%v Tags()=%v", inst.String(), inst.Address(), tags)
 
+       if *live == "" {
+               c.Check(ap.client.(*ec2stub).describeKeyPairsCalls, check.HasLen, 1)
+               c.Check(ap.client.(*ec2stub).importKeyPairCalls, check.HasLen, 1)
+
+               runcalls := ap.client.(*ec2stub).runInstancesCalls
+               if c.Check(runcalls, check.HasLen, 1) {
+                       c.Check(runcalls[0].MetadataOptions.HttpEndpoint, check.DeepEquals, aws.String("enabled"))
+                       c.Check(runcalls[0].MetadataOptions.HttpTokens, check.DeepEquals, aws.String("required"))
+               }
+       }
 }
 
 func (*EC2InstanceSetSuite) TestCreateWithExtraScratch(c *check.C) {
-       ap, img, cluster := GetInstanceSet(c)
-       pk, _ := test.LoadTestKey(c, "../../dispatchcloud/test/sshkey_dispatch")
-
+       ap, img, cluster, _ := GetInstanceSet(c, "{}")
        inst, err := ap.Create(cluster.InstanceTypes["tiny-with-extra-scratch"],
                img, map[string]string{
                        "TestTagName": "test tag value",
-               }, "umask 0600; echo -n test-file-data >/var/run/test-file", pk)
+               }, "umask 0600; echo -n test-file-data >/var/run/test-file", nil)
 
        c.Assert(err, check.IsNil)
 
@@ -230,10 +299,16 @@ func (*EC2InstanceSetSuite) TestCreateWithExtraScratch(c *check.C) {
        c.Check(tags["TestTagName"], check.Equals, "test tag value")
        c.Logf("inst.String()=%v Address()=%v Tags()=%v", inst.String(), inst.Address(), tags)
 
+       if *live == "" {
+               // Should not have called key pair APIs, because
+               // publickey arg was nil
+               c.Check(ap.client.(*ec2stub).describeKeyPairsCalls, check.HasLen, 0)
+               c.Check(ap.client.(*ec2stub).importKeyPairCalls, check.HasLen, 0)
+       }
 }
 
 func (*EC2InstanceSetSuite) TestCreatePreemptible(c *check.C) {
-       ap, img, cluster := GetInstanceSet(c)
+       ap, img, cluster, _ := GetInstanceSet(c, "{}")
        pk, _ := test.LoadTestKey(c, "../../dispatchcloud/test/sshkey_dispatch")
 
        inst, err := ap.Create(cluster.InstanceTypes["tiny-preemptible"],
@@ -249,8 +324,171 @@ func (*EC2InstanceSetSuite) TestCreatePreemptible(c *check.C) {
 
 }
 
+func (*EC2InstanceSetSuite) TestCreateFailoverSecondSubnet(c *check.C) {
+       if *live != "" {
+               c.Skip("not applicable in live mode")
+               return
+       }
+
+       ap, img, cluster, reg := GetInstanceSet(c, `{"SubnetID":["subnet-full","subnet-good"]}`)
+       ap.client.(*ec2stub).subnetErrorOnRunInstances = map[string]error{
+               "subnet-full": &ec2stubError{
+                       code:    "InsufficientFreeAddressesInSubnet",
+                       message: "subnet is full",
+               },
+       }
+       inst, err := ap.Create(cluster.InstanceTypes["tiny"], img, nil, "", nil)
+       c.Check(err, check.IsNil)
+       c.Check(inst, check.NotNil)
+       c.Check(ap.client.(*ec2stub).runInstancesCalls, check.HasLen, 2)
+       metrics := arvadostest.GatherMetricsAsString(reg)
+       c.Check(metrics, check.Matches, `(?ms).*`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="0"} 1\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="1"} 0\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-good",success="0"} 0\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-good",success="1"} 1\n`+
+               `.*`)
+
+       // Next RunInstances call should try the working subnet first
+       inst, err = ap.Create(cluster.InstanceTypes["tiny"], img, nil, "", nil)
+       c.Check(err, check.IsNil)
+       c.Check(inst, check.NotNil)
+       c.Check(ap.client.(*ec2stub).runInstancesCalls, check.HasLen, 3)
+       metrics = arvadostest.GatherMetricsAsString(reg)
+       c.Check(metrics, check.Matches, `(?ms).*`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="0"} 1\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="1"} 0\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-good",success="0"} 0\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-good",success="1"} 2\n`+
+               `.*`)
+}
+
+func (*EC2InstanceSetSuite) TestIsErrorSubnetSpecific(c *check.C) {
+       c.Check(isErrorSubnetSpecific(nil), check.Equals, false)
+       c.Check(isErrorSubnetSpecific(errors.New("misc error")), check.Equals, false)
+
+       c.Check(isErrorSubnetSpecific(&ec2stubError{
+               code: "InsufficientInstanceCapacity",
+       }), check.Equals, true)
+
+       c.Check(isErrorSubnetSpecific(&ec2stubError{
+               code: "InsufficientVolumeCapacity",
+       }), check.Equals, true)
+
+       c.Check(isErrorSubnetSpecific(&ec2stubError{
+               code:    "InsufficientFreeAddressesInSubnet",
+               message: "Not enough free addresses in subnet subnet-abcdefg\n\tstatus code: 400, request id: abcdef01-2345-6789-abcd-ef0123456789",
+       }), check.Equals, true)
+
+       // #21603: (Sometimes?) EC2 returns code InvalidParameterValue
+       // even though the code "InsufficientFreeAddressesInSubnet"
+       // seems like it must be meant for exactly this error.
+       c.Check(isErrorSubnetSpecific(&ec2stubError{
+               code:    "InvalidParameterValue",
+               message: "Not enough free addresses in subnet subnet-abcdefg\n\tstatus code: 400, request id: abcdef01-2345-6789-abcd-ef0123456789",
+       }), check.Equals, true)
+
+       // Similarly, AWS docs
+       // (https://repost.aws/knowledge-center/vpc-insufficient-ip-errors)
+       // suggest the following code/message combinations also exist.
+       c.Check(isErrorSubnetSpecific(&ec2stubError{
+               code:    "Client.InvalidParameterValue",
+               message: "There aren't sufficient free Ipv4 addresses or prefixes",
+       }), check.Equals, true)
+       c.Check(isErrorSubnetSpecific(&ec2stubError{
+               code:    "InvalidParameterValue",
+               message: "There aren't sufficient free Ipv4 addresses or prefixes",
+       }), check.Equals, true)
+       // Meanwhile, other AWS docs
+       // (https://docs.aws.amazon.com/AWSEC2/latest/APIReference/errors-overview.html)
+       // suggest Client.InvalidParameterValue is not a real code but
+       // ClientInvalidParameterValue is.
+       c.Check(isErrorSubnetSpecific(&ec2stubError{
+               code:    "ClientInvalidParameterValue",
+               message: "There aren't sufficient free Ipv4 addresses or prefixes",
+       }), check.Equals, true)
+
+       c.Check(isErrorSubnetSpecific(&ec2stubError{
+               code:    "InvalidParameterValue",
+               message: "Some other invalid parameter error",
+       }), check.Equals, false)
+}
+
+func (*EC2InstanceSetSuite) TestCreateAllSubnetsFailing(c *check.C) {
+       if *live != "" {
+               c.Skip("not applicable in live mode")
+               return
+       }
+
+       ap, img, cluster, reg := GetInstanceSet(c, `{"SubnetID":["subnet-full","subnet-broken"]}`)
+       ap.client.(*ec2stub).subnetErrorOnRunInstances = map[string]error{
+               "subnet-full": &ec2stubError{
+                       code:    "InsufficientFreeAddressesInSubnet",
+                       message: "subnet is full",
+               },
+               "subnet-broken": &ec2stubError{
+                       code:    "InvalidSubnetId.NotFound",
+                       message: "bogus subnet id",
+               },
+       }
+       _, err := ap.Create(cluster.InstanceTypes["tiny"], img, nil, "", nil)
+       c.Check(err, check.NotNil)
+       c.Check(err, check.ErrorMatches, `.*InvalidSubnetId\.NotFound.*`)
+       c.Check(ap.client.(*ec2stub).runInstancesCalls, check.HasLen, 2)
+       metrics := arvadostest.GatherMetricsAsString(reg)
+       c.Check(metrics, check.Matches, `(?ms).*`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-broken",success="0"} 1\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-broken",success="1"} 0\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="0"} 1\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="1"} 0\n`+
+               `.*`)
+
+       _, err = ap.Create(cluster.InstanceTypes["tiny"], img, nil, "", nil)
+       c.Check(err, check.NotNil)
+       c.Check(err, check.ErrorMatches, `.*InsufficientFreeAddressesInSubnet.*`)
+       c.Check(ap.client.(*ec2stub).runInstancesCalls, check.HasLen, 4)
+       metrics = arvadostest.GatherMetricsAsString(reg)
+       c.Check(metrics, check.Matches, `(?ms).*`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-broken",success="0"} 2\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-broken",success="1"} 0\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="0"} 2\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="1"} 0\n`+
+               `.*`)
+}
+
+func (*EC2InstanceSetSuite) TestCreateOneSubnetFailingCapacity(c *check.C) {
+       if *live != "" {
+               c.Skip("not applicable in live mode")
+               return
+       }
+       ap, img, cluster, reg := GetInstanceSet(c, `{"SubnetID":["subnet-full","subnet-broken"]}`)
+       ap.client.(*ec2stub).subnetErrorOnRunInstances = map[string]error{
+               "subnet-full": &ec2stubError{
+                       code:    "InsufficientFreeAddressesInSubnet",
+                       message: "subnet is full",
+               },
+               "subnet-broken": &ec2stubError{
+                       code:    "InsufficientInstanceCapacity",
+                       message: "insufficient capacity",
+               },
+       }
+       for i := 0; i < 3; i++ {
+               _, err := ap.Create(cluster.InstanceTypes["tiny"], img, nil, "", nil)
+               c.Check(err, check.NotNil)
+               c.Check(err, check.ErrorMatches, `.*InsufficientInstanceCapacity.*`)
+       }
+       c.Check(ap.client.(*ec2stub).runInstancesCalls, check.HasLen, 6)
+       metrics := arvadostest.GatherMetricsAsString(reg)
+       c.Check(metrics, check.Matches, `(?ms).*`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-broken",success="0"} 3\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-broken",success="1"} 0\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="0"} 3\n`+
+               `arvados_dispatchcloud_ec2_instance_starts_total{subnet_id="subnet-full",success="1"} 0\n`+
+               `.*`)
+}
+
 func (*EC2InstanceSetSuite) TestTagInstances(c *check.C) {
-       ap, _, _ := GetInstanceSet(c)
+       ap, _, _, _ := GetInstanceSet(c, "{}")
        l, err := ap.Instances(nil)
        c.Assert(err, check.IsNil)
 
@@ -262,7 +500,7 @@ func (*EC2InstanceSetSuite) TestTagInstances(c *check.C) {
 }
 
 func (*EC2InstanceSetSuite) TestListInstances(c *check.C) {
-       ap, _, _ := GetInstanceSet(c)
+       ap, _, _, reg := GetInstanceSet(c, "{}")
        l, err := ap.Instances(nil)
        c.Assert(err, check.IsNil)
 
@@ -270,10 +508,15 @@ func (*EC2InstanceSetSuite) TestListInstances(c *check.C) {
                tg := i.Tags()
                c.Logf("%v %v %v", i.String(), i.Address(), tg)
        }
+
+       metrics := arvadostest.GatherMetricsAsString(reg)
+       c.Check(metrics, check.Matches, `(?ms).*`+
+               `arvados_dispatchcloud_ec2_instances{subnet_id="[^"]*"} \d+\n`+
+               `.*`)
 }
 
 func (*EC2InstanceSetSuite) TestDestroyInstances(c *check.C) {
-       ap, _, _ := GetInstanceSet(c)
+       ap, _, _, _ := GetInstanceSet(c, "{}")
        l, err := ap.Instances(nil)
        c.Assert(err, check.IsNil)
 
@@ -283,7 +526,7 @@ func (*EC2InstanceSetSuite) TestDestroyInstances(c *check.C) {
 }
 
 func (*EC2InstanceSetSuite) TestInstancePriceHistory(c *check.C) {
-       ap, img, cluster := GetInstanceSet(c)
+       ap, img, cluster, _ := GetInstanceSet(c, "{}")
        pk, _ := test.LoadTestKey(c, "../../dispatchcloud/test/sshkey_dispatch")
        tags := cloud.InstanceTags{"arvados-ec2-driver": "test"}
 
@@ -353,8 +596,23 @@ func (*EC2InstanceSetSuite) TestWrapError(c *check.C) {
        _, ok := wrapped.(cloud.RateLimitError)
        c.Check(ok, check.Equals, true)
 
-       quotaError := awserr.New("InsufficientInstanceCapacity", "", nil)
+       quotaError := awserr.New("InstanceLimitExceeded", "", nil)
        wrapped = wrapError(quotaError, nil)
        _, ok = wrapped.(cloud.QuotaError)
        c.Check(ok, check.Equals, true)
+
+       for _, trial := range []struct {
+               code string
+               msg  string
+       }{
+               {"InsufficientInstanceCapacity", ""},
+               {"Unsupported", "Your requested instance type (t3.micro) is not supported in your requested Availability Zone (us-east-1e). Please retry your request by not specifying an Availability Zone or choosing us-east-1a, us-east-1b, us-east-1c, us-east-1d, us-east-1f."},
+       } {
+               capacityError := awserr.New(trial.code, trial.msg, nil)
+               wrapped = wrapError(capacityError, nil)
+               caperr, ok := wrapped.(cloud.CapacityError)
+               c.Check(ok, check.Equals, true)
+               c.Check(caperr.IsCapacityError(), check.Equals, true)
+               c.Check(caperr.IsInstanceTypeSpecific(), check.Equals, true)
+       }
 }
index 27cf26152c2962fa1b8d9afeba34f4fad57e2922..a2aa9e143296f9251bf629c41863b6f985fac258 100644 (file)
@@ -11,6 +11,7 @@ import (
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
        "golang.org/x/crypto/ssh"
 )
@@ -36,6 +37,20 @@ type QuotaError interface {
        error
 }
 
+// A CapacityError should be returned by an InstanceSet's Create
+// method when the cloud service indicates it has insufficient
+// capacity to create new instances -- i.e., we shouldn't retry right
+// away.
+type CapacityError interface {
+       // If true, wait before trying to create more instances.
+       IsCapacityError() bool
+       // If true, the condition is specific to the requested
+       // instance types.  Wait before trying to create more
+       // instances of that same type.
+       IsInstanceTypeSpecific() bool
+       error
+}
+
 type SharedResourceTags map[string]string
 type InstanceSetID string
 type InstanceTags map[string]string
@@ -191,7 +206,7 @@ type InitCommand string
 //
 //     type exampleDriver struct {}
 //
-//     func (*exampleDriver) InstanceSet(config json.RawMessage, id cloud.InstanceSetID, tags cloud.SharedResourceTags, logger logrus.FieldLogger) (cloud.InstanceSet, error) {
+//     func (*exampleDriver) InstanceSet(config json.RawMessage, id cloud.InstanceSetID, tags cloud.SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (cloud.InstanceSet, error) {
 //             var is exampleInstanceSet
 //             if err := json.Unmarshal(config, &is); err != nil {
 //                     return nil, err
@@ -199,20 +214,18 @@ type InitCommand string
 //             is.ownID = id
 //             return &is, nil
 //     }
-//
-//     var _ = registerCloudDriver("example", &exampleDriver{})
 type Driver interface {
-       InstanceSet(config json.RawMessage, id InstanceSetID, tags SharedResourceTags, logger logrus.FieldLogger) (InstanceSet, error)
+       InstanceSet(config json.RawMessage, id InstanceSetID, tags SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (InstanceSet, error)
 }
 
 // DriverFunc makes a Driver using the provided function as its
 // InstanceSet method. This is similar to http.HandlerFunc.
-func DriverFunc(fn func(config json.RawMessage, id InstanceSetID, tags SharedResourceTags, logger logrus.FieldLogger) (InstanceSet, error)) Driver {
+func DriverFunc(fn func(config json.RawMessage, id InstanceSetID, tags SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (InstanceSet, error)) Driver {
        return driverFunc(fn)
 }
 
-type driverFunc func(config json.RawMessage, id InstanceSetID, tags SharedResourceTags, logger logrus.FieldLogger) (InstanceSet, error)
+type driverFunc func(config json.RawMessage, id InstanceSetID, tags SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (InstanceSet, error)
 
-func (df driverFunc) InstanceSet(config json.RawMessage, id InstanceSetID, tags SharedResourceTags, logger logrus.FieldLogger) (InstanceSet, error) {
-       return df(config, id, tags, logger)
+func (df driverFunc) InstanceSet(config json.RawMessage, id InstanceSetID, tags SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (InstanceSet, error) {
+       return df(config, id, tags, logger, reg)
 }
index 8afaa452570c1b79c90989edd8247408f5eb2b54..41878acd2285f56946dd94b978371bc24f7380a7 100644 (file)
@@ -21,6 +21,7 @@ import (
        "git.arvados.org/arvados.git/lib/cloud"
        "git.arvados.org/arvados.git/lib/dispatchcloud/test"
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
        "golang.org/x/crypto/ssh"
 )
@@ -45,7 +46,7 @@ type instanceSet struct {
        mtx           sync.Mutex
 }
 
-func newInstanceSet(config json.RawMessage, instanceSetID cloud.InstanceSetID, _ cloud.SharedResourceTags, logger logrus.FieldLogger) (cloud.InstanceSet, error) {
+func newInstanceSet(config json.RawMessage, instanceSetID cloud.InstanceSetID, _ cloud.SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (cloud.InstanceSet, error) {
        is := &instanceSet{
                instanceSetID: instanceSetID,
                logger:        logger,
index 5c30f5f0e1b24e9de092316a318eff08b67c2800..0716179cb7c335b600a024f4627a108a7d8eec15 100644 (file)
@@ -29,7 +29,7 @@ var _ = check.Suite(&suite{})
 
 func (*suite) TestCreateListExecDestroy(c *check.C) {
        logger := ctxlog.TestLogger(c)
-       is, err := Driver.InstanceSet(json.RawMessage("{}"), "testInstanceSetID", cloud.SharedResourceTags{"sharedTag": "sharedTagValue"}, logger)
+       is, err := Driver.InstanceSet(json.RawMessage("{}"), "testInstanceSetID", cloud.SharedResourceTags{"sharedTag": "sharedTagValue"}, logger, nil)
        c.Assert(err, check.IsNil)
 
        clientRSAKey, err := rsa.GenerateKey(rand.Reader, 1024)
index a03cb90f68e6cd84ee0e9091971af7370f0ee7db..40e80f5eaab74df0f5370f50c12d1d2868e0e6f9 100644 (file)
@@ -14,12 +14,15 @@ import (
        "path/filepath"
        "regexp"
        "runtime"
+       "runtime/debug"
        "sort"
        "strings"
 
        "github.com/sirupsen/logrus"
 )
 
+const EXIT_INVALIDARGUMENT = 2
+
 type Handler interface {
        RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int
 }
@@ -35,7 +38,13 @@ func (f HandlerFunc) RunCommand(prog string, args []string, stdin io.Reader, std
 // 0.
 var Version versionCommand
 
-var version = "dev"
+var (
+       // These default version/commit strings should be set at build
+       // time: `go install -buildvcs=false -ldflags "-X
+       // git.arvados.org/arvados.git/lib/cmd.version=1.2.3"`
+       version = "dev"
+       commit  = "0000000000000000000000000000000000000000"
+)
 
 type versionCommand struct{}
 
@@ -43,6 +52,17 @@ func (versionCommand) String() string {
        return fmt.Sprintf("%s (%s)", version, runtime.Version())
 }
 
+func (versionCommand) Commit() string {
+       if bi, ok := debug.ReadBuildInfo(); ok {
+               for _, bs := range bi.Settings {
+                       if bs.Key == "vcs.revision" {
+                               return bs.Value
+                       }
+               }
+       }
+       return commit
+}
+
 func (versionCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
        prog = regexp.MustCompile(` -*version$`).ReplaceAllLiteralString(prog, "")
        fmt.Fprintf(stdout, "%s %s (%s)\n", prog, version, runtime.Version())
@@ -55,12 +75,12 @@ func (versionCommand) RunCommand(prog string, args []string, stdin io.Reader, st
 //
 // Example:
 //
-//     os.Exit(Multi(map[string]Handler{
-//             "foobar": HandlerFunc(func(prog string, args []string) int {
-//                     fmt.Println(args[0])
-//                     return 2
-//             }),
-//     })("/usr/bin/multi", []string{"foobar", "baz"}, os.Stdin, os.Stdout, os.Stderr))
+//     os.Exit(Multi(map[string]Handler{
+//             "foobar": HandlerFunc(func(prog string, args []string) int {
+//                     fmt.Println(args[0])
+//                     return 2
+//             }),
+//     })("/usr/bin/multi", []string{"foobar", "baz"}, os.Stdin, os.Stdout, os.Stderr))
 //
 // ...prints "baz" and exits 2.
 type Multi map[string]Handler
@@ -86,13 +106,13 @@ func (m Multi) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
        } else if len(args) < 1 {
                fmt.Fprintf(stderr, "usage: %s command [args]\n", prog)
                m.Usage(stderr)
-               return 2
+               return EXIT_INVALIDARGUMENT
        } else if cmd, ok = m[args[0]]; ok {
                return cmd.RunCommand(prog+" "+args[0], args[1:], stdin, stdout, stderr)
        } else {
                fmt.Fprintf(stderr, "%s: unrecognized command %q\n", prog, args[0])
                m.Usage(stderr)
-               return 2
+               return EXIT_INVALIDARGUMENT
        }
 }
 
index 3e872fcd11986a2ba69786a7da3d3267128f2704..275e063f3118b17290ee64582b9bf976f1555166 100644 (file)
@@ -8,8 +8,13 @@ import (
        "flag"
        "fmt"
        "io"
+       "reflect"
 )
 
+// Hack to enable checking whether a given FlagSet's Usage method is
+// the (private) default one.
+var defaultFlagSet = flag.NewFlagSet("none", flag.ContinueOnError)
+
 // ParseFlags calls f.Parse(args) and prints appropriate error/help
 // messages to stderr.
 //
@@ -30,11 +35,16 @@ func ParseFlags(f FlagSet, prog string, args []string, positional string, stderr
        case nil:
                if f.NArg() > 0 && positional == "" {
                        fmt.Fprintf(stderr, "unrecognized command line arguments: %v (try -help)\n", f.Args())
-                       return false, 2
+                       return false, EXIT_INVALIDARGUMENT
                }
                return true, 0
        case flag.ErrHelp:
-               if f, ok := f.(*flag.FlagSet); ok && f.Usage != nil {
+               // Use our own default usage func, not the one
+               // provided by the flag pkg, if the caller hasn't set
+               // one. (We use reflect to determine whether f.Usage
+               // is the private defaultUsage func that
+               // flag.NewFlagSet uses.)
+               if f, ok := f.(*flag.FlagSet); ok && f.Usage != nil && reflect.ValueOf(f.Usage).String() != reflect.ValueOf(defaultFlagSet.Usage).String() {
                        f.SetOutput(stderr)
                        f.Usage()
                } else {
@@ -45,6 +55,6 @@ func ParseFlags(f FlagSet, prog string, args []string, positional string, stderr
                return false, 0
        default:
                fmt.Fprintf(stderr, "error parsing command line arguments: %s (try -help)\n", err)
-               return false, 2
+               return false, EXIT_INVALIDARGUMENT
        }
 }
index 9503a54d2d7c137ec5c2e805a3aaec9b990014ce..c2854895cadf0b689b545103937d545c570db426 100644 (file)
@@ -33,7 +33,7 @@ func (s *CommandSuite) SetUpSuite(c *check.C) {
 func (s *CommandSuite) TestDump_BadArg(c *check.C) {
        var stderr bytes.Buffer
        code := DumpCommand.RunCommand("arvados config-dump", []string{"-badarg"}, bytes.NewBuffer(nil), bytes.NewBuffer(nil), &stderr)
-       c.Check(code, check.Equals, 2)
+       c.Check(code, check.Equals, cmd.EXIT_INVALIDARGUMENT)
        c.Check(stderr.String(), check.Equals, "error parsing command line arguments: flag provided but not defined: -badarg (try -help)\n")
 }
 
@@ -69,8 +69,6 @@ Clusters:
         Type: select
         Options:
           fuchsia: {}
-    ApplicationMimetypesWithViewIcon:
-      whitespace: {}
 `
        code := CheckCommand.RunCommand("arvados config-check", []string{"-config", "-"}, bytes.NewBufferString(in), &stdout, &stderr)
        c.Check(code, check.Equals, 0)
@@ -124,8 +122,6 @@ Clusters:
         Type: select
         Options:
           fuchsia: {}
-    ApplicationMimetypesWithViewIcon:
-      whitespace: {}
 `
        code := CheckCommand.RunCommand("arvados config-check", []string{"-config", "-"}, bytes.NewBufferString(in), &stdout, &stderr)
        c.Check(code, check.Equals, 1)
index 1919d7b704af0ce7c0d92fe4d0320229f84d0c51..a3ae4fd56bbc179f67fc1f21e6c9cdb2db5c43df 100644 (file)
@@ -223,10 +223,44 @@ Clusters:
       # parameter higher than this value, this value is used instead.
       MaxItemsPerResponse: 1000
 
-      # Maximum number of concurrent requests to accept in a single
-      # service process, or 0 for no limit.
+      # Maximum number of concurrent requests to process concurrently
+      # in a single service process, or 0 for no limit.
+      #
+      # Note this applies to all Arvados services (controller, webdav,
+      # websockets, etc.). Concurrency in the controller service is
+      # also effectively limited by MaxConcurrentRailsRequests (see
+      # below) because most controller requests proxy through to the
+      # RailsAPI service.
+      #
+      # HTTP proxies and load balancers downstream of arvados services
+      # should be configured to allow at least {MaxConcurrentRequest +
+      # MaxQueuedRequests + MaxGatewayTunnels} concurrent requests.
       MaxConcurrentRequests: 64
 
+      # Maximum number of concurrent requests to process concurrently
+      # in a single RailsAPI service process, or 0 for no limit.
+      MaxConcurrentRailsRequests: 8
+
+      # Maximum number of incoming requests to hold in a priority
+      # queue waiting for one of the MaxConcurrentRequests slots to be
+      # free. When the queue is longer than this, respond 503 to the
+      # lowest priority request.
+      #
+      # If MaxQueuedRequests is 0, respond 503 immediately to
+      # additional requests while at the MaxConcurrentRequests limit.
+      MaxQueuedRequests: 128
+
+      # Maximum time a "lock container" request is allowed to wait in
+      # the incoming request queue before returning 503.
+      MaxQueueTimeForLockRequests: 2s
+
+      # Maximum number of active gateway tunnel connections. One slot
+      # is consumed by each "container shell" connection. If using an
+      # HPC dispatcher (LSF or Slurm), one slot is consumed by each
+      # running container.  These do not count toward
+      # MaxConcurrentRequests.
+      MaxGatewayTunnels: 1000
+
       # Fraction of MaxConcurrentRequests that can be "log create"
       # messages at any given time.  This is to prevent logging
       # updates from crowding out more important requests.
@@ -331,34 +365,59 @@ Clusters:
       # false.
       ActivatedUsersAreVisibleToOthers: true
 
-      # The e-mail address of the user you would like to become marked as an admin
-      # user on their first login.
+      # If a user creates an account with this email address, they
+      # will be automatically set to admin.
       AutoAdminUserWithEmail: ""
 
       # If AutoAdminFirstUser is set to true, the first user to log in when no
       # other admin users exist will automatically become an admin user.
       AutoAdminFirstUser: false
 
-      # Email address to notify whenever a user creates a profile for the
-      # first time
+      # Recipient for notification email sent out when a user sets a
+      # profile on their account.
       UserProfileNotificationAddress: ""
+
+      # When sending a NewUser, NewInactiveUser, or UserProfile
+      # notification, this is the 'From' address to use
       AdminNotifierEmailFrom: arvados@example.com
+
+      # Prefix for email subjects for NewUser and NewInactiveUser emails
       EmailSubjectPrefix: "[ARVADOS] "
+
+      # When sending a welcome email to the user, the 'From' address to use
       UserNotifierEmailFrom: arvados@example.com
-      UserNotifierEmailBcc: {}
-      NewUserNotificationRecipients: {}
-      NewInactiveUserNotificationRecipients: {}
+
+      # The welcome email sent to new users will be blind copied to
+      # these addresses.
+      UserNotifierEmailBcc:
+        SAMPLE: {}
+
+      # Recipients for notification email sent out when a user account
+      # is created and already set up to be able to log in
+      NewUserNotificationRecipients:
+        SAMPLE: {}
+
+      # Recipients for notification email sent out when a user account
+      # has been created but the user cannot log in until they are
+      # set up by an admin.
+      NewInactiveUserNotificationRecipients:
+        SAMPLE: {}
 
       # Set AnonymousUserToken to enable anonymous user access. Populate this
       # field with a random string at least 50 characters long.
       AnonymousUserToken: ""
 
-      # If a new user has an alternate email address (local@domain)
-      # with the domain given here, its local part becomes the new
-      # user's default username. Otherwise, the user's primary email
-      # address is used.
+      # The login provider for a user may supply a primary email
+      # address and one or more alternate email addresses.  If a new
+      # user has an alternate email address with the domain given
+      # here, use the username from the alternate email to generate
+      # the user's Arvados username. Otherwise, the username from
+      # user's primary email address is used for the Arvados username.
+      # Currently implemented for OpenID Connect only.
       PreferDomainForUsername: ""
 
+      # Ruby ERB template used for the email sent out to users when
+      # they have been set up.
       UserSetupMailText: |
         <% if not @user.full_name.empty? -%>
         <%= @user.full_name %>,
@@ -399,6 +458,48 @@ Clusters:
       # Use 0 to disable activity logging.
       ActivityLoggingPeriod: 24h
 
+      # The SyncUser* options control what system resources are managed by
+      # arvados-login-sync on shell nodes. They correspond to:
+      # * SyncUserAccounts: The user's Unix account on the shell node
+      # * SyncUserGroups: The group memberships of that account
+      # * SyncUserSSHKeys: Whether to authorize the user's Arvados SSH keys
+      # * SyncUserAPITokens: Whether to set up the user's Arvados API token
+      # All default to true.
+      SyncUserAccounts: true
+      SyncUserGroups: true
+      SyncUserSSHKeys: true
+      SyncUserAPITokens: true
+
+      # If SyncUserGroups=true, then arvados-login-sync will ensure that all
+      # managed accounts are members of the Unix groups listed in
+      # SyncRequiredGroups, in addition to any groups listed in their Arvados
+      # login permission. The default list includes the "fuse" group so
+      # users can use arv-mount. You can require no groups by specifying an
+      # empty list (i.e., `SyncRequiredGroups: []`).
+      SyncRequiredGroups:
+        - fuse
+
+      # SyncIgnoredGroups is a list of group names. arvados-login-sync will
+      # never modify these groups. If user login permissions list any groups
+      # in SyncIgnoredGroups, they will be ignored. If a user's Unix account
+      # belongs to any of these groups, arvados-login-sync will not remove
+      # the account from that group. The default is a set of particularly
+      # security-sensitive groups across Debian- and Red Hat-based
+      # distributions.
+      SyncIgnoredGroups:
+        - adm
+        - disk
+        - kmem
+        - mem
+        - root
+        - shadow
+        - staff
+        - sudo
+        - sys
+        - utempter
+        - utmp
+        - wheel
+
     AuditLogs:
       # Time to keep audit logs, in seconds. (An audit log is a row added
       # to the "logs" table in the PostgreSQL database each time an
@@ -442,6 +543,15 @@ Clusters:
       # params_truncated.
       MaxRequestLogParamsSize: 2000
 
+      # In all services except RailsAPI, periodically check whether
+      # the incoming HTTP request queue is nearly full (see
+      # MaxConcurrentRequests) and, if so, write a snapshot of the
+      # request queue to {service}-requests.json in the specified
+      # directory.
+      #
+      # Leave blank to disable.
+      RequestQueueDumpDirectory: ""
+
     Collections:
 
       # Enable access controls for data stored in Keep. This should
@@ -543,11 +653,12 @@ Clusters:
       BalanceCollectionBatch: 0
 
       # The size of keep-balance's internal queue of
-      # collections. Higher values use more memory and improve throughput
-      # by allowing keep-balance to fetch the next page of collections
-      # while the current page is still being processed. If this is zero
-      # or omitted, pages are processed serially.
-      BalanceCollectionBuffers: 1000
+      # collections. Higher values may improve throughput by allowing
+      # keep-balance to fetch collections from the database while the
+      # current collection are still being processed, at the expense of
+      # using more memory.  If this is zero or omitted, pages are
+      # processed serially.
+      BalanceCollectionBuffers: 4
 
       # Maximum time for a rebalancing run. This ensures keep-balance
       # eventually gives up and retries if, for example, a network
@@ -563,6 +674,15 @@ Clusters:
       # once.
       BalanceUpdateLimit: 100000
 
+      # Maximum number of "pull block from other server" and "trash
+      # block" requests to send to each keepstore server at a
+      # time. Smaller values use less memory in keepstore and
+      # keep-balance. Larger values allow more progress per
+      # keep-balance iteration. A zero value computes all of the
+      # needed changes but does not apply any.
+      BalancePullLimit: 100000
+      BalanceTrashLimit: 100000
+
       # Default lifetime for ephemeral collections: 2 weeks. This must not
       # be less than BlobSigningTTL.
       DefaultTrashLifetime: 336h
@@ -637,16 +757,18 @@ Clusters:
         # Time to cache manifests, permission checks, and sessions.
         TTL: 300s
 
-        # Block cache entries. Each block consumes up to 64 MiB RAM.
-        MaxBlockEntries: 20
+        # Maximum amount of data cached in /var/cache/arvados/keep.
+        # Can be given as a percentage ("10%") or a number of bytes
+        # ("10 GiB")
+        DiskCacheSize: 10%
 
         # Approximate memory limit (in bytes) for session cache.
         #
         # Note this applies to the in-memory representation of
         # projects and collections -- metadata, block locators,
-        # filenames, etc. -- excluding cached file content, which is
-        # limited by MaxBlockEntries.
-        MaxCollectionBytes: 100000000
+        # filenames, etc. -- not the file data itself (see
+        # DiskCacheSize).
+        MaxCollectionBytes: 100 MB
 
         # Persistent sessions.
         MaxSessions: 100
@@ -741,7 +863,7 @@ Clusters:
         # OpenID claim field containing the email verification
         # flag. Normally "email_verified".  To accept every returned
         # email address without checking a "verified" field at all,
-        # use the empty string "".
+        # use an empty string "".
         EmailVerifiedClaim: "email_verified"
 
         # OpenID claim field containing the user's preferred
@@ -909,6 +1031,9 @@ Clusters:
       # probably want to include the other Workbench instances in the
       # federation in this list.
       #
+      # A wildcard like "https://*.example" will match client URLs
+      # like "https://a.example" and "https://a.b.c.example".
+      #
       # Example:
       #
       # TrustedClients:
@@ -1006,7 +1131,7 @@ Clusters:
 
       # Number of times a container can be unlocked before being
       # automatically cancelled.
-      MaxDispatchAttempts: 5
+      MaxDispatchAttempts: 10
 
       # Default value for container_count_max for container requests.  This is the
       # number of times Arvados will create a new container to satisfy a container
@@ -1034,10 +1159,25 @@ Clusters:
       # A price factor of 1.0 is a reasonable starting point.
       PreemptiblePriceFactor: 0
 
+      # When the lowest-priced instance type for a given container is
+      # not available, try other instance types, up to the indicated
+      # maximum price factor.
+      #
+      # For example, with AvailabilityPriceFactor 1.5, if the
+      # lowest-cost instance type A suitable for a given container
+      # costs $2/h, Arvados may run the container on any instance type
+      # B costing $3/h or less when instance type A is not available
+      # or an idle instance of type B is already running.
+      MaximumPriceFactor: 1.5
+
       # PEM encoded SSH key (RSA, DSA, or ECDSA) used by the
       # cloud dispatcher for executing containers on worker VMs.
       # Begins with "-----BEGIN RSA PRIVATE KEY-----\n"
       # and ends with "\n-----END RSA PRIVATE KEY-----\n".
+      #
+      # Use "file:///absolute/path/to/key" to load the key from a
+      # separate file instead of embedding it in the configuration
+      # file.
       DispatchPrivateKey: ""
 
       # Maximum time to wait for workers to come up before abandoning
@@ -1140,9 +1280,14 @@ Clusters:
         # before being silenced until the end of the period.
         LogThrottleLines: 1024
 
-        # Maximum bytes that may be logged by a single job.  Log bytes that are
-        # silenced by throttling are not counted against this total.
-        LimitLogBytesPerJob: 67108864
+        # Maximum bytes that may be logged as legacy log events
+        # (records posted to the "logs" table). Starting with Arvados
+        # 2.7, container live logging has migrated to a new system
+        # (polling the container request live log endpoint) and this
+        # value should be 0.  As of this writing, the container will
+        # still create a single log on the API server, noting for that
+        # log events are throttled.
+        LimitLogBytesPerJob: 0
 
         LogPartialLineThrottlePeriod: 5s
 
@@ -1234,15 +1379,23 @@ Clusters:
         # %M memory in MB
         # %T tmp in MB
         # %G number of GPU devices (runtime_constraints.cuda.device_count)
+        # %W maximum run time in minutes (see MaxRunTimeOverhead and
+        #    MaxRunTimeDefault below)
         #
-        # Use %% to express a literal %. The %%J in the default will be changed
-        # to %J, which is interpreted by bsub itself.
+        # Use %% to express a literal %. For example, the %%J in the
+        # default argument list will be changed to %J, which is
+        # interpreted by bsub itself.
         #
         # Note that the default arguments cause LSF to write two files
         # in /tmp on the compute node each time an Arvados container
         # runs. Ensure you have something in place to delete old files
         # from /tmp, or adjust the "-o" and "-e" arguments accordingly.
-        BsubArgumentsList: ["-o", "/tmp/crunch-run.%%J.out", "-e", "/tmp/crunch-run.%%J.err", "-J", "%U", "-n", "%C", "-D", "%MMB", "-R", "rusage[mem=%MMB:tmp=%TMB] span[hosts=1]", "-R", "select[mem>=%MMB]", "-R", "select[tmp>=%TMB]", "-R", "select[ncpus>=%C]"]
+        #
+        # If ["-We", "%W"] or ["-W", "%W"] appear in this argument
+        # list, and MaxRunTimeDefault is not set (see below), both of
+        # those arguments will be dropped from the argument list when
+        # running a container that has no max_run_time value.
+        BsubArgumentsList: ["-o", "/tmp/crunch-run.%%J.out", "-e", "/tmp/crunch-run.%%J.err", "-J", "%U", "-n", "%C", "-D", "%MMB", "-R", "rusage[mem=%MMB:tmp=%TMB] span[hosts=1]", "-R", "select[mem>=%MMB]", "-R", "select[tmp>=%TMB]", "-R", "select[ncpus>=%C]", "-We", "%W"]
 
         # Arguments that will be appended to the bsub command line
         # when submitting Arvados containers as LSF jobs with
@@ -1257,6 +1410,19 @@ Clusters:
         # Arvados LSF dispatcher runs ("submission host").
         BsubSudoUser: "crunch"
 
+        # When passing the scheduling_constraints.max_run_time value
+        # to LSF via "%W", add this much time to account for
+        # crunch-run startup/shutdown overhead.
+        MaxRunTimeOverhead: 5m
+
+        # If non-zero, MaxRunTimeDefault is used as the default value
+        # for max_run_time for containers that do not specify a time
+        # limit.  MaxRunTimeOverhead will be added to this.
+        #
+        # Example:
+        # MaxRunTimeDefault: 2h
+        MaxRunTimeDefault: 0
+
       JobsAPI:
         # Enable the legacy 'jobs' API (crunch v1).  This value must be a string.
         #
@@ -1339,10 +1505,31 @@ Clusters:
         # down.
         MaxInstances: 64
 
-        # Maximum fraction of CloudVMs.MaxInstances allowed to run
-        # "supervisor" containers at any given time. A supervisor is a
-        # container whose purpose is mainly to submit and manage other
-        # containers, such as arvados-cwl-runner workflow runner.
+        # The minimum number of instances expected to be runnable
+        # without reaching a provider-imposed quota.
+        #
+        # This is used as the initial value for the dispatcher's
+        # dynamic instance limit, which increases (up to MaxInstances)
+        # as containers start up successfully and decreases in
+        # response to high API load and cloud quota errors.
+        #
+        # Setting this to 0 means the dynamic instance limit will
+        # start at MaxInstances.
+        #
+        # Situations where you may want to set this (to a value less
+        # than MaxInstances) would be when there is significant
+        # variability or uncertainty in the actual cloud resources
+        # available.  Upon reaching InitialQuotaEstimate the
+        # dispatcher will switch to a more conservative behavior with
+        # slower instance start to avoid over-shooting cloud resource
+        # limits.
+        InitialQuotaEstimate: 0
+
+        # Maximum fraction of available instance capacity allowed to
+        # run "supervisor" containers at any given time. A supervisor
+        # is a container whose purpose is mainly to submit and manage
+        # other containers, such as arvados-cwl-runner workflow
+        # runner.
         #
         # If there is a hard limit on the amount of concurrent
         # containers that the cluster can run, it is important to
@@ -1350,9 +1537,9 @@ Clusters:
         # containers who just create more work.
         #
         # For example, with the default MaxInstances of 64, it will
-        # schedule at most floor(64*0.30) = 19 concurrent workflows,
-        # ensuring 45 slots are available for work.
-        SupervisorFraction: 0.30
+        # schedule at most floor(64*0.50) = 32 concurrent workflow
+        # runners, ensuring 32 slots are available for work.
+        SupervisorFraction: 0.50
 
         # Interval between cloud provider syncs/updates ("list all
         # instances").
@@ -1384,16 +1571,28 @@ Clusters:
         # https://xxxxx.blob.core.windows.net/system/Microsoft.Compute/Images/images/xxxxx.vhd
         ImageID: ""
 
+        # Shell script to run on new instances using the cloud
+        # provider's UserData (EC2) or CustomData (Azure) feature.
+        #
+        # It is not necessary to include a #!/bin/sh line.
+        InstanceInitCommand: ""
+
         # An executable file (located on the dispatcher host) to be
         # copied to cloud instances at runtime and used as the
         # container runner/supervisor. The default value is the
         # dispatcher program itself.
         #
-        # Use the empty string to disable this step: nothing will be
+        # Use an empty string to disable this step: nothing will be
         # copied, and cloud instances are assumed to have a suitable
         # version of crunch-run installed; see CrunchRunCommand above.
         DeployRunnerBinary: "/proc/self/exe"
 
+        # Install the Dispatcher's SSH public key (derived from
+        # DispatchPrivateKey) when creating new cloud
+        # instances. Change this to false if you are using a different
+        # mechanism to pre-install the public key on new instances.
+        DeployPublicKey: true
+
         # Tags to add on all resources (VMs, NICs, disks) created by
         # the container dispatcher. (Arvados's own tags --
         # InstanceType, IdleBehavior, and InstanceSecret -- will also
@@ -1425,10 +1624,23 @@ Clusters:
           SecretAccessKey: ""
 
           # (ec2) Instance configuration.
+
+          # (ec2) Region, like "us-east-1".
+          Region: ""
+
+          # (ec2) Security group IDs. Omit or use {} to use the
+          # default security group.
           SecurityGroupIDs:
             "SAMPLE": {}
+
+          # (ec2) One or more subnet IDs. Omit or leave empty to let
+          # AWS choose a default subnet from your default VPC. If
+          # multiple subnets are configured here (enclosed in brackets
+          # like [subnet-abc123, subnet-def456]) the cloud dispatcher
+          # will detect subnet-related errors and retry using a
+          # different subnet. Most sites specify one subnet.
           SubnetID: ""
-          Region: ""
+
           EBSVolumeType: gp2
           AdminUsername: debian
           # (ec2) name of the IAMInstanceProfile for instances started by
@@ -1561,6 +1773,11 @@ Clusters:
             ReadOnly: false
           "http://host1.example:25107": {}
         ReadOnly: false
+        # AllowTrashWhenReadOnly enables unused and overreplicated
+        # blocks to be trashed/deleted even when ReadOnly is
+        # true. Normally, this is false and ReadOnly prevents all
+        # trash/delete operations as well as writes.
+        AllowTrashWhenReadOnly: false
         Replication: 1
         StorageClasses:
           # If you have configured storage classes (see StorageClasses
@@ -1584,8 +1801,6 @@ Clusters:
           ReadTimeout: 10m
           RaceWindow: 24h
           PrefixLength: 0
-          # Use aws-s3-go (v2) instead of goamz
-          UseAWSS3v2Driver: true
 
           # For S3 driver, potentially unsafe tuning parameter,
           # intentionally excluded from main documentation.
@@ -1631,8 +1846,18 @@ Clusters:
           Serialize: false
 
     Mail:
-      MailchimpAPIKey: ""
-      MailchimpListID: ""
+      # In order to send mail, Arvados expects a default SMTP server
+      # on localhost:25.  It cannot require authentication on
+      # connections from localhost.  That server should be configured
+      # to relay mail to a "real" SMTP server that is able to send
+      # email on behalf of your domain.
+
+      # See also the "Users" configuration section for additional
+      # email-related options.
+
+      # When a user has been set up (meaning they are able to log in)
+      # they will receive an email using the template specified
+      # earlier in Users.UserSetupMailText
       SendUserSetupNotificationEmail: true
 
       # Bug/issue report notification to and from addresses
@@ -1642,6 +1867,10 @@ Clusters:
 
       # Generic issue email from
       EmailFrom: "arvados@example.com"
+
+      # No longer supported, to be removed.
+      MailchimpAPIKey: ""
+      MailchimpListID: ""
     RemoteClusters:
       "*":
         Host: ""
@@ -1675,18 +1904,12 @@ Clusters:
       ArvadosDocsite: https://doc.arvados.org
       ArvadosPublicDataDocURL: https://playground.arvados.org/projects/public
       ShowUserAgreementInline: false
-      SecretKeyBase: ""
 
       # Set this configuration to true to avoid providing an easy way for users
       # to share data with unauthenticated users; this may be necessary on
       # installations where strict data access controls are needed.
       DisableSharingURLsUI: false
 
-      # Scratch directory used by the remote repository browsing
-      # feature. If it doesn't exist, it (and any missing parents) will be
-      # created using mkdir_p.
-      RepositoryCache: /var/www/arvados-workbench/current/tmp/git
-
       # Below is a sample setting of user_profile_form_fields config parameter.
       # This configuration parameter should be set to either false (to disable) or
       # to a map as shown below.
@@ -1733,71 +1956,7 @@ Clusters:
       # to display on the profile page.
       UserProfileFormMessage: 'Welcome to Arvados. All <span style="color:red">required fields</span> must be completed before you can proceed.'
 
-      # Mimetypes of applications for which the view icon
-      # would be enabled in a collection's show page.
-      # It is sufficient to list only applications here.
-      # No need to list text and image types.
-      ApplicationMimetypesWithViewIcon:
-        cwl: {}
-        fasta: {}
-        go: {}
-        javascript: {}
-        json: {}
-        pdf: {}
-        python: {}
-        x-python: {}
-        r: {}
-        rtf: {}
-        sam: {}
-        x-sh: {}
-        vnd.realvnc.bed: {}
-        xml: {}
-        xsl: {}
-        SAMPLE: {}
-
-      # The maximum number of bytes to load in the log viewer
-      LogViewerMaxBytes: 1M
-
-      # When anonymous_user_token is configured, show public projects page
-      EnablePublicProjectsPage: true
-
-      # By default, disable the "Getting Started" popup which is specific to Arvados playground
-      EnableGettingStartedPopup: false
-
-      # Ask Arvados API server to compress its response payloads.
-      APIResponseCompression: true
-
-      # Timeouts for API requests.
-      APIClientConnectTimeout: 2m
-      APIClientReceiveTimeout: 5m
-
-      # Maximum number of historic log records of a running job to fetch
-      # and display in the Log tab, while subscribing to web sockets.
-      RunningJobLogRecordsToFetch: 2000
-
-      # In systems with many shared projects, loading of dashboard and topnav
-      # can be slow due to collections indexing; use the following parameters
-      # to suppress these properties
-      ShowRecentCollectionsOnDashboard: true
-      ShowUserNotifications: true
-
-      # Enable/disable "multi-site search" in top nav ("true"/"false"), or
-      # a link to the multi-site search page on a "home" Workbench site.
-      #
-      # Example:
-      #   https://workbench.zzzzz.arvadosapi.com/collections/multisite
-      MultiSiteSearch: ""
-
-      # Should workbench allow management of local git repositories? Set to false if
-      # the jobs api is disabled and there are no local git repositories.
-      Repositories: true
-
       SiteName: Arvados Workbench
-      ProfilingEnabled: false
-
-      # This is related to obsolete Google OpenID 1.0 login
-      # but some workbench stuff still expects it to be set.
-      DefaultOpenIdPrefix: "https://www.google.com/accounts/o8/id"
 
       # Workbench2 configs
       FileViewersConfigURL: ""
index d5c09d67061115a44cb5c8b5ef2a195fa7652734..d518b3414ad193795179f7e9776bccc26fedc129 100644 (file)
@@ -495,7 +495,7 @@ func (ldr *Loader) loadOldKeepWebConfig(cfg *arvados.Config) error {
                cluster.Collections.WebDAVCache.TTL = *oc.Cache.TTL
        }
        if oc.Cache.MaxCollectionBytes != nil {
-               cluster.Collections.WebDAVCache.MaxCollectionBytes = *oc.Cache.MaxCollectionBytes
+               cluster.Collections.WebDAVCache.MaxCollectionBytes = arvados.ByteSize(*oc.Cache.MaxCollectionBytes)
        }
        if oc.AnonymousTokens != nil {
                if len(*oc.AnonymousTokens) > 0 {
index f9b1d1661b1f3c16b745c7e7c6e9304f060e2c64..e06a1f231d96887467759087c14f8fb74b4b3e87 100644 (file)
@@ -199,7 +199,7 @@ func (s *LoadSuite) TestLegacyKeepWebConfig(c *check.C) {
        c.Check(cluster.SystemRootToken, check.Equals, "abcdefg")
 
        c.Check(cluster.Collections.WebDAVCache.TTL, check.Equals, arvados.Duration(60*time.Second))
-       c.Check(cluster.Collections.WebDAVCache.MaxCollectionBytes, check.Equals, int64(1234567890))
+       c.Check(cluster.Collections.WebDAVCache.MaxCollectionBytes, check.Equals, arvados.ByteSize(1234567890))
 
        c.Check(cluster.Services.WebDAVDownload.ExternalURL, check.Equals, arvados.URL{Host: "download.example.com", Path: "/"})
        c.Check(cluster.Services.WebDAVDownload.InternalURLs[arvados.URL{Host: ":80"}], check.NotNil)
index 44fd559418ea177f365921c00b89c76a1a4088b8..4b6c142ff2e29f41bcf2b843ac6479b54dd436aa 100644 (file)
@@ -37,8 +37,8 @@ func ExportJSON(w io.Writer, cluster *arvados.Cluster) error {
        return json.NewEncoder(w).Encode(m)
 }
 
-// whitelist classifies configs as safe/unsafe to reveal to
-// unauthenticated clients.
+// whitelist classifies configs as safe/unsafe to reveal through the API
+// endpoint. Note that endpoint does not require authentication.
 //
 // Every config entry must either be listed explicitly here along with
 // all of its parent keys (e.g., "API" + "API.RequestTimeout"), or
@@ -65,13 +65,17 @@ var whitelist = map[string]bool{
        "API.FreezeProjectRequiresDescription":     true,
        "API.FreezeProjectRequiresProperties":      true,
        "API.FreezeProjectRequiresProperties.*":    true,
-       "API.LockBeforeUpdate":                     false,
        "API.KeepServiceRequestTimeout":            false,
-       "API.MaxConcurrentRequests":                false,
+       "API.LockBeforeUpdate":                     false,
        "API.LogCreateRequestFraction":             false,
+       "API.MaxConcurrentRailsRequests":           false,
+       "API.MaxConcurrentRequests":                false,
+       "API.MaxGatewayTunnels":                    false,
        "API.MaxIndexDatabaseRead":                 false,
        "API.MaxItemsPerResponse":                  true,
        "API.MaxKeepBlobBuffers":                   false,
+       "API.MaxQueuedRequests":                    false,
+       "API.MaxQueueTimeForLockRequests":          false,
        "API.MaxRequestAmplification":              false,
        "API.MaxRequestSize":                       true,
        "API.MaxTokenLifetime":                     false,
@@ -90,7 +94,9 @@ var whitelist = map[string]bool{
        "Collections.BalanceCollectionBatch":       false,
        "Collections.BalanceCollectionBuffers":     false,
        "Collections.BalancePeriod":                false,
+       "Collections.BalancePullLimit":             false,
        "Collections.BalanceTimeout":               false,
+       "Collections.BalanceTrashLimit":            false,
        "Collections.BalanceUpdateLimit":           false,
        "Collections.BlobDeleteConcurrency":        false,
        "Collections.BlobMissingReport":            false,
@@ -133,6 +139,7 @@ var whitelist = map[string]bool{
        "Containers.LogReuseDecisions":             false,
        "Containers.LSF":                           false,
        "Containers.MaxDispatchAttempts":           false,
+       "Containers.MaximumPriceFactor":            true,
        "Containers.MaxRetryAttempts":              true,
        "Containers.MinRetryPeriod":                true,
        "Containers.PreemptiblePriceFactor":        false,
@@ -245,6 +252,12 @@ var whitelist = map[string]bool{
        "Users.NewUsersAreActive":                             false,
        "Users.PreferDomainForUsername":                       false,
        "Users.RoleGroupsVisibleToAll":                        false,
+       "Users.SyncIgnoredGroups":                             true,
+       "Users.SyncRequiredGroups":                            true,
+       "Users.SyncUserAccounts":                              true,
+       "Users.SyncUserAPITokens":                             true,
+       "Users.SyncUserGroups":                                true,
+       "Users.SyncUserSSHKeys":                               true,
        "Users.UserNotifierEmailBcc":                          false,
        "Users.UserNotifierEmailFrom":                         false,
        "Users.UserProfileNotificationAddress":                false,
@@ -282,7 +295,6 @@ var whitelist = map[string]bool{
        "Workbench.Repositories":                              false,
        "Workbench.RepositoryCache":                           false,
        "Workbench.RunningJobLogRecordsToFetch":               true,
-       "Workbench.SecretKeyBase":                             false,
        "Workbench.ShowRecentCollectionsOnDashboard":          true,
        "Workbench.ShowUserAgreementInline":                   true,
        "Workbench.ShowUserNotifications":                     true,
index 9269ddf27f59011b5dad2855edde8fb9b676ed41..d504f7796c323f12598a0af4429e289390e22e52 100644 (file)
@@ -26,6 +26,7 @@ import (
        "github.com/imdario/mergo"
        "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
+       "golang.org/x/crypto/ssh"
        "golang.org/x/sys/unix"
 )
 
@@ -690,3 +691,17 @@ func (ldr *Loader) RegisterMetrics(reg *prometheus.Registry) {
        vec.WithLabelValues(hash).Set(float64(ldr.loadTimestamp.UnixNano()) / 1e9)
        reg.MustRegister(vec)
 }
+
+// Load an SSH private key from the given confvalue, which is either
+// the literal key or an absolute path to a file containing the key.
+func LoadSSHKey(confvalue string) (ssh.Signer, error) {
+       if fnm := strings.TrimPrefix(confvalue, "file://"); fnm != confvalue && strings.HasPrefix(fnm, "/") {
+               keydata, err := os.ReadFile(fnm)
+               if err != nil {
+                       return nil, err
+               }
+               return ssh.ParsePrivateKey(keydata)
+       } else {
+               return ssh.ParsePrivateKey([]byte(confvalue))
+       }
+}
index a19400c191df1db7a36e9e1ad8d242a0cbb301cc..75efc6a35aa67f103a33fbdd4eee2751df1da294 100644 (file)
@@ -19,10 +19,10 @@ import (
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "github.com/ghodss/yaml"
        "github.com/prometheus/client_golang/prometheus"
-       "github.com/prometheus/common/expfmt"
        "github.com/sirupsen/logrus"
        "golang.org/x/sys/unix"
        check "gopkg.in/check.v1"
@@ -882,15 +882,10 @@ func (s *LoadSuite) TestSourceTimestamp(c *check.C) {
                c.Check(int(cfg.SourceTimestamp.Sub(trial.expectTime).Seconds()), check.Equals, 0)
                c.Check(int(ldr.loadTimestamp.Sub(time.Now()).Seconds()), check.Equals, 0)
 
-               var buf bytes.Buffer
                reg := prometheus.NewRegistry()
                ldr.RegisterMetrics(reg)
-               enc := expfmt.NewEncoder(&buf, expfmt.FmtText)
-               got, _ := reg.Gather()
-               for _, mf := range got {
-                       enc.Encode(mf)
-               }
-               c.Check(buf.String(), check.Matches, `# HELP .*
+               metrics := arvadostest.GatherMetricsAsString(reg)
+               c.Check(metrics, check.Matches, `# HELP .*
 # TYPE .*
 arvados_config_load_timestamp_seconds{sha256="83aea5d82eb1d53372cd65c936c60acc1c6ef946e61977bbca7cfea709d201a8"} \Q`+fmt.Sprintf("%g", float64(ldr.loadTimestamp.UnixNano())/1e9)+`\E
 # HELP .*
@@ -912,3 +907,10 @@ func (s *LoadSuite) TestGetFilesystemSize(c *check.C) {
        c.Check(err, check.IsNil)
        c.Logf("getFilesystemSize(%q) == %v", path, size)
 }
+
+func (s *LoadSuite) TestLoadSSHKey(c *check.C) {
+       cwd, err := os.Getwd()
+       c.Assert(err, check.IsNil)
+       _, err = LoadSSHKey("file://" + cwd + "/../dispatchcloud/test/sshkey_dispatch")
+       c.Check(err, check.IsNil)
+}
diff --git a/lib/controller/federation/collection_test.go b/lib/controller/federation/collection_test.go
new file mode 100644 (file)
index 0000000..8256819
--- /dev/null
@@ -0,0 +1,106 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package federation
+
+import (
+       "context"
+       "fmt"
+       "net/http"
+
+       "git.arvados.org/arvados.git/lib/ctrlctx"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&collectionSuite{})
+
+type collectionSuite struct {
+       FederationSuite
+}
+
+func (s *collectionSuite) TestMultipleBackendFailureStatus(c *check.C) {
+       nxPDH := "a4f995dd0c08216f37cb1bdec990f0cd+1234"
+       s.cluster.ClusterID = "local"
+       for _, trial := range []struct {
+               label        string
+               token        string
+               localStatus  int
+               remoteStatus map[string]int
+               expectStatus int
+       }{
+               {
+                       "all backends return 404 => 404",
+                       arvadostest.SystemRootToken,
+                       http.StatusNotFound,
+                       map[string]int{
+                               "aaaaa": http.StatusNotFound,
+                               "bbbbb": http.StatusNotFound,
+                       },
+                       http.StatusNotFound,
+               },
+               {
+                       "all backends return 401 => 401 (e.g., bad token)",
+                       arvadostest.SystemRootToken,
+                       http.StatusUnauthorized,
+                       map[string]int{
+                               "aaaaa": http.StatusUnauthorized,
+                               "bbbbb": http.StatusUnauthorized,
+                       },
+                       http.StatusUnauthorized,
+               },
+               {
+                       "local 404, remotes 403 => 422 (mix of non-retryable errors)",
+                       arvadostest.SystemRootToken,
+                       http.StatusNotFound,
+                       map[string]int{
+                               "aaaaa": http.StatusForbidden,
+                               "bbbbb": http.StatusForbidden,
+                       },
+                       http.StatusUnprocessableEntity,
+               },
+               {
+                       "local 404, remotes 401/403/404 => 422 (mix of non-retryable errors)",
+                       arvadostest.SystemRootToken,
+                       http.StatusNotFound,
+                       map[string]int{
+                               "aaaaa": http.StatusUnauthorized,
+                               "bbbbb": http.StatusForbidden,
+                               "ccccc": http.StatusNotFound,
+                       },
+                       http.StatusUnprocessableEntity,
+               },
+               {
+                       "local 404, remotes 401/403/500 => 502 (at least one remote is retryable)",
+                       arvadostest.SystemRootToken,
+                       http.StatusNotFound,
+                       map[string]int{
+                               "aaaaa": http.StatusUnauthorized,
+                               "bbbbb": http.StatusForbidden,
+                               "ccccc": http.StatusInternalServerError,
+                       },
+                       http.StatusBadGateway,
+               },
+       } {
+               c.Logf("trial: %v", trial)
+               s.fed = New(s.ctx, s.cluster, nil, (&ctrlctx.DBConnector{PostgreSQL: s.cluster.PostgreSQL}).GetDB)
+               s.fed.local = &arvadostest.APIStub{Error: httpserver.ErrorWithStatus(fmt.Errorf("stub error %d", trial.localStatus), trial.localStatus)}
+               for id, status := range trial.remoteStatus {
+                       s.addDirectRemote(c, id, &arvadostest.APIStub{Error: httpserver.ErrorWithStatus(fmt.Errorf("stub error %d", status), status)})
+               }
+
+               ctx := context.Background()
+               ctx = ctxlog.Context(ctx, ctxlog.TestLogger(c))
+               if trial.token != "" {
+                       ctx = auth.NewContext(ctx, &auth.Credentials{Tokens: []string{trial.token}})
+               }
+
+               _, err := s.fed.CollectionGet(s.ctx, arvados.GetOptions{UUID: nxPDH})
+               c.Check(err.(httpserver.HTTPStatusError).HTTPStatus(), check.Equals, trial.expectStatus)
+       }
+}
index 3a232d29b89e7dcbafb79c705762da3f9ed11d45..949cc56dd24cc34b71a8f7ef8ea7ac1d15df6e29 100644 (file)
@@ -14,6 +14,7 @@ import (
        "net/url"
        "regexp"
        "strings"
+       "sync"
        "time"
 
        "git.arvados.org/arvados.git/lib/config"
@@ -178,20 +179,29 @@ func (conn *Conn) tryLocalThenRemotes(ctx context.Context, forwardedFor string,
                        errchan <- fn(ctx, remoteID, be)
                }()
        }
-       all404 := true
+       returncode := http.StatusNotFound
        var errs []error
        for i := 0; i < cap(errchan); i++ {
                err := <-errchan
                if err == nil {
                        return nil
                }
-               all404 = all404 && errStatus(err) == http.StatusNotFound
                errs = append(errs, err)
+               if code := errStatus(err); code >= 500 || code == http.StatusTooManyRequests {
+                       // If any of the remotes have a retryable
+                       // error (and none succeed) we'll return 502.
+                       returncode = http.StatusBadGateway
+               } else if code != http.StatusNotFound && returncode != http.StatusBadGateway {
+                       // If some of the remotes have non-retryable
+                       // non-404 errors (and none succeed or have
+                       // retryable errors) we'll return 422.
+                       returncode = http.StatusUnprocessableEntity
+               }
        }
-       if all404 {
+       if returncode == http.StatusNotFound {
                return notFoundError{}
        }
-       return httpErrorf(http.StatusBadGateway, "errors: %v", errs)
+       return httpErrorf(returncode, "errors: %v", errs)
 }
 
 func (conn *Conn) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
@@ -215,7 +225,11 @@ func (conn *Conn) ConfigGet(ctx context.Context) (json.RawMessage, error) {
 }
 
 func (conn *Conn) VocabularyGet(ctx context.Context) (arvados.Vocabulary, error) {
-       return conn.chooseBackend(conn.cluster.ClusterID).VocabularyGet(ctx)
+       return conn.local.VocabularyGet(ctx)
+}
+
+func (conn *Conn) DiscoveryDocument(ctx context.Context) (arvados.DiscoveryDocument, error) {
+       return conn.local.DiscoveryDocument(ctx)
 }
 
 func (conn *Conn) Login(ctx context.Context, options arvados.LoginOptions) (arvados.LoginResponse, error) {
@@ -244,30 +258,71 @@ func (conn *Conn) Login(ctx context.Context, options arvados.LoginOptions) (arva
        return conn.local.Login(ctx, options)
 }
 
+var v2TokenRegexp = regexp.MustCompile(`^v2/[a-z0-9]{5}-gj3su-[a-z0-9]{15}/`)
+
 func (conn *Conn) Logout(ctx context.Context, options arvados.LogoutOptions) (arvados.LogoutResponse, error) {
-       // If the logout request comes with an API token from a known
-       // remote cluster, redirect to that cluster's logout handler
-       // so it has an opportunity to clear sessions, expire tokens,
-       // etc. Otherwise use the local endpoint.
-       reqauth, ok := auth.FromContext(ctx)
-       if !ok || len(reqauth.Tokens) == 0 || len(reqauth.Tokens[0]) < 8 || !strings.HasPrefix(reqauth.Tokens[0], "v2/") {
-               return conn.local.Logout(ctx, options)
-       }
-       id := reqauth.Tokens[0][3:8]
-       if id == conn.cluster.ClusterID {
-               return conn.local.Logout(ctx, options)
-       }
-       remote, ok := conn.remotes[id]
-       if !ok {
-               return conn.local.Logout(ctx, options)
+       // If the token was issued by another cluster, we want to issue a logout
+       // request to the issuing instance to invalidate the token federation-wide.
+       // If this federation has a login cluster, that's always considered the
+       // issuing cluster.
+       // Otherwise, if this is a v2 token, use the UUID to find the issuing
+       // cluster.
+       // Note that remoteBE may still be conn.local even *after* one of these
+       // conditions is true.
+       var remoteBE backend = conn.local
+       if conn.cluster.Login.LoginCluster != "" {
+               remoteBE = conn.chooseBackend(conn.cluster.Login.LoginCluster)
+       } else {
+               reqauth, ok := auth.FromContext(ctx)
+               if ok && len(reqauth.Tokens) > 0 && v2TokenRegexp.MatchString(reqauth.Tokens[0]) {
+                       remoteBE = conn.chooseBackend(reqauth.Tokens[0][3:8])
+               }
        }
-       baseURL := remote.BaseURL()
-       target, err := baseURL.Parse(arvados.EndpointLogout.Path)
-       if err != nil {
-               return arvados.LogoutResponse{}, fmt.Errorf("internal error getting redirect target: %s", err)
+
+       // We always want to invalidate the token locally. Start that process.
+       var localResponse arvados.LogoutResponse
+       var localErr error
+       wg := sync.WaitGroup{}
+       wg.Add(1)
+       go func() {
+               localResponse, localErr = conn.local.Logout(ctx, options)
+               wg.Done()
+       }()
+
+       // If the token was issued by another cluster, log out there too.
+       if remoteBE != conn.local {
+               response, err := remoteBE.Logout(ctx, options)
+               // If the issuing cluster returns a redirect or error, that's more
+               // important to return to the user than anything that happens locally.
+               if response.RedirectLocation != "" || err != nil {
+                       return response, err
+               }
        }
-       target.RawQuery = url.Values{"return_to": {options.ReturnTo}}.Encode()
-       return arvados.LogoutResponse{RedirectLocation: target.String()}, nil
+
+       // Either the local cluster is the issuing cluster, or the issuing cluster's
+       // response was uninteresting.
+       wg.Wait()
+       return localResponse, localErr
+}
+
+func (conn *Conn) AuthorizedKeyCreate(ctx context.Context, options arvados.CreateOptions) (arvados.AuthorizedKey, error) {
+       return conn.chooseBackend(options.ClusterID).AuthorizedKeyCreate(ctx, options)
+}
+
+func (conn *Conn) AuthorizedKeyUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.AuthorizedKey, error) {
+       return conn.chooseBackend(options.UUID).AuthorizedKeyUpdate(ctx, options)
+}
+
+func (conn *Conn) AuthorizedKeyGet(ctx context.Context, options arvados.GetOptions) (arvados.AuthorizedKey, error) {
+       return conn.chooseBackend(options.UUID).AuthorizedKeyGet(ctx, options)
+}
+
+func (conn *Conn) AuthorizedKeyList(ctx context.Context, options arvados.ListOptions) (arvados.AuthorizedKeyList, error) {
+       return conn.generated_AuthorizedKeyList(ctx, options)
+}
+
+func (conn *Conn) AuthorizedKeyDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.AuthorizedKey, error) {
+       return conn.chooseBackend(options.UUID).AuthorizedKeyDelete(ctx, options)
 }
 
 func (conn *Conn) CollectionGet(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
@@ -455,6 +510,14 @@ func (conn *Conn) ContainerRequestDelete(ctx context.Context, options arvados.De
        return conn.chooseBackend(options.UUID).ContainerRequestDelete(ctx, options)
 }
 
+func (conn *Conn) ContainerRequestContainerStatus(ctx context.Context, options arvados.GetOptions) (arvados.ContainerStatus, error) {
+       return conn.chooseBackend(options.UUID).ContainerRequestContainerStatus(ctx, options)
+}
+
+func (conn *Conn) ContainerRequestLog(ctx context.Context, options arvados.ContainerLogOptions) (http.Handler, error) {
+       return conn.chooseBackend(options.UUID).ContainerRequestLog(ctx, options)
+}
+
 func (conn *Conn) GroupCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Group, error) {
        return conn.chooseBackend(options.ClusterID).GroupCreate(ctx, options)
 }
@@ -572,6 +635,7 @@ var userAttrsCachedFromLoginCluster = map[string]bool{
        "first_name":  true,
        "is_active":   true,
        "is_admin":    true,
+       "is_invited":  true,
        "last_name":   true,
        "modified_at": true,
        "prefs":       true,
@@ -581,7 +645,6 @@ var userAttrsCachedFromLoginCluster = map[string]bool{
        "etag":                    false,
        "full_name":               false,
        "identity_url":            false,
-       "is_invited":              false,
        "modified_by_client_uuid": false,
        "modified_by_user_uuid":   false,
        "owner_uuid":              false,
@@ -593,7 +656,8 @@ var userAttrsCachedFromLoginCluster = map[string]bool{
 
 func (conn *Conn) batchUpdateUsers(ctx context.Context,
        options arvados.ListOptions,
-       items []arvados.User) (err error) {
+       items []arvados.User,
+       includeAdminAndInvited bool) (err error) {
 
        id := conn.cluster.Login.LoginCluster
        logger := ctxlog.FromContext(ctx)
@@ -640,6 +704,11 @@ func (conn *Conn) batchUpdateUsers(ctx context.Context,
                                }
                        }
                }
+               if !includeAdminAndInvited {
+                       // make sure we don't send these fields.
+                       delete(updates, "is_admin")
+                       delete(updates, "is_invited")
+               }
                batchOpts.Updates[user.UUID] = updates
        }
        if len(batchOpts.Updates) > 0 {
@@ -652,13 +721,47 @@ func (conn *Conn) batchUpdateUsers(ctx context.Context,
        return nil
 }
 
+func (conn *Conn) includeAdminAndInvitedInBatchUpdate(ctx context.Context, be backend, updateUserUUID string) (bool, error) {
+       // API versions prior to 20231117 would only include the
+       // is_invited and is_admin fields if the current user is an
+       // admin, or is requesting their own user record.  If those
+       // fields aren't actually valid then we don't want to
+       // send them in the batch update.
+       dd, err := be.DiscoveryDocument(ctx)
+       if err != nil {
+               // couldn't get discovery document
+               return false, err
+       }
+       if dd.Revision >= "20231117" {
+               // newer version, fields are valid.
+               return true, nil
+       }
+       selfuser, err := be.UserGetCurrent(ctx, arvados.GetOptions{})
+       if err != nil {
+               // couldn't get our user record
+               return false, err
+       }
+       if selfuser.IsAdmin || selfuser.UUID == updateUserUUID {
+               // we are an admin, or the current user is the same as
+               // the user that we are updating.
+               return true, nil
+       }
+       // Better safe than sorry.
+       return false, nil
+}
+
 func (conn *Conn) UserList(ctx context.Context, options arvados.ListOptions) (arvados.UserList, error) {
        if id := conn.cluster.Login.LoginCluster; id != "" && id != conn.cluster.ClusterID && !options.BypassFederation {
-               resp, err := conn.chooseBackend(id).UserList(ctx, options)
+               be := conn.chooseBackend(id)
+               resp, err := be.UserList(ctx, options)
                if err != nil {
                        return resp, err
                }
-               err = conn.batchUpdateUsers(ctx, options, resp.Items)
+               includeAdminAndInvited, err := conn.includeAdminAndInvitedInBatchUpdate(ctx, be, "")
+               if err != nil {
+                       return arvados.UserList{}, err
+               }
+               err = conn.batchUpdateUsers(ctx, options, resp.Items, includeAdminAndInvited)
                if err != nil {
                        return arvados.UserList{}, err
                }
@@ -675,13 +778,18 @@ func (conn *Conn) UserUpdate(ctx context.Context, options arvados.UpdateOptions)
        if options.BypassFederation {
                return conn.local.UserUpdate(ctx, options)
        }
-       resp, err := conn.chooseBackend(options.UUID).UserUpdate(ctx, options)
+       be := conn.chooseBackend(options.UUID)
+       resp, err := be.UserUpdate(ctx, options)
        if err != nil {
                return resp, err
        }
        if !strings.HasPrefix(options.UUID, conn.cluster.ClusterID) {
+               includeAdminAndInvited, err := conn.includeAdminAndInvitedInBatchUpdate(ctx, be, options.UUID)
+               if err != nil {
+                       return arvados.User{}, err
+               }
                // Copy the updated user record to the local cluster
-               err = conn.batchUpdateUsers(ctx, arvados.ListOptions{}, []arvados.User{resp})
+               err = conn.batchUpdateUsers(ctx, arvados.ListOptions{}, []arvados.User{resp}, includeAdminAndInvited)
                if err != nil {
                        return arvados.User{}, err
                }
@@ -728,7 +836,8 @@ func (conn *Conn) UserUnsetup(ctx context.Context, options arvados.GetOptions) (
 }
 
 func (conn *Conn) UserGet(ctx context.Context, options arvados.GetOptions) (arvados.User, error) {
-       resp, err := conn.chooseBackend(options.UUID).UserGet(ctx, options)
+       be := conn.chooseBackend(options.UUID)
+       resp, err := be.UserGet(ctx, options)
        if err != nil {
                return resp, err
        }
@@ -736,7 +845,11 @@ func (conn *Conn) UserGet(ctx context.Context, options arvados.GetOptions) (arva
                return arvados.User{}, httpErrorf(http.StatusBadGateway, "Had requested %v but response was for %v", options.UUID, resp.UUID)
        }
        if options.UUID[:5] != conn.cluster.ClusterID {
-               err = conn.batchUpdateUsers(ctx, arvados.ListOptions{Select: options.Select}, []arvados.User{resp})
+               includeAdminAndInvited, err := conn.includeAdminAndInvitedInBatchUpdate(ctx, be, options.UUID)
+               if err != nil {
+                       return arvados.User{}, err
+               }
+               err = conn.batchUpdateUsers(ctx, arvados.ListOptions{Select: options.Select}, []arvados.User{resp}, includeAdminAndInvited)
                if err != nil {
                        return arvados.User{}, err
                }
index 86bbf9d9e3fcd991b0020f0a2332a25eb16c9108..2dc2918f79ec8330dcfbe316af1ec728c3e27f07 100644 (file)
@@ -53,7 +53,7 @@ func main() {
                defer out.Close()
                out.Write(regexp.MustCompile(`(?ms)^.*package .*?import.*?\n\)\n`).Find(buf))
                io.WriteString(out, "//\n// -- this file is auto-generated -- do not edit -- edit list.go and run \"go generate\" instead --\n//\n\n")
-               for _, t := range []string{"Container", "ContainerRequest", "Group", "Specimen", "User", "Link", "Log", "APIClientAuthorization"} {
+               for _, t := range []string{"AuthorizedKey", "Container", "ContainerRequest", "Group", "Specimen", "User", "Link", "Log", "APIClientAuthorization"} {
                        _, err := out.Write(bytes.ReplaceAll(orig, []byte("Collection"), []byte(t)))
                        if err != nil {
                                panic(err)
index 637a1ce9194953aeff865a0cd3f86dad13ba1068..8c8666fea122787d91bf71305421d121e00ae7f9 100755 (executable)
@@ -17,6 +17,47 @@ import (
 // -- this file is auto-generated -- do not edit -- edit list.go and run "go generate" instead --
 //
 
+func (conn *Conn) generated_AuthorizedKeyList(ctx context.Context, options arvados.ListOptions) (arvados.AuthorizedKeyList, error) {
+       var mtx sync.Mutex
+       var merged arvados.AuthorizedKeyList
+       var needSort atomic.Value
+       needSort.Store(false)
+       err := conn.splitListRequest(ctx, options, func(ctx context.Context, _ string, backend arvados.API, options arvados.ListOptions) ([]string, error) {
+               options.ForwardedFor = conn.cluster.ClusterID + "-" + options.ForwardedFor
+               cl, err := backend.AuthorizedKeyList(ctx, options)
+               if err != nil {
+                       return nil, err
+               }
+               mtx.Lock()
+               defer mtx.Unlock()
+               if len(merged.Items) == 0 {
+                       merged = cl
+               } else if len(cl.Items) > 0 {
+                       merged.Items = append(merged.Items, cl.Items...)
+                       needSort.Store(true)
+               }
+               uuids := make([]string, 0, len(cl.Items))
+               for _, item := range cl.Items {
+                       uuids = append(uuids, item.UUID)
+               }
+               return uuids, nil
+       })
+       if needSort.Load().(bool) {
+               // Apply the default/implied order, "modified_at desc"
+               sort.Slice(merged.Items, func(i, j int) bool {
+                       mi, mj := merged.Items[i].ModifiedAt, merged.Items[j].ModifiedAt
+                       return mj.Before(mi)
+               })
+       }
+       if merged.Items == nil {
+               // Return empty results as [], not null
+               // (https://github.com/golang/go/issues/27589 might be
+               // a better solution in the future)
+               merged.Items = []arvados.AuthorizedKey{}
+       }
+       return merged, err
+}
+
 func (conn *Conn) generated_ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
        var mtx sync.Mutex
        var merged arvados.ContainerList
index a6743b320b7b7556153d15321fdf3dbf06788769..ab39619c79703ff683a4adc4e16396cde2d3ebc5 100644 (file)
@@ -8,10 +8,8 @@ import (
        "context"
        "net/url"
 
-       "git.arvados.org/arvados.git/lib/ctrlctx"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
-       "git.arvados.org/arvados.git/sdk/go/auth"
        check "gopkg.in/check.v1"
 )
 
@@ -40,40 +38,3 @@ func (s *LoginSuite) TestDeferToLoginCluster(c *check.C) {
                c.Check(remotePresent, check.Equals, remote != "")
        }
 }
-
-func (s *LoginSuite) TestLogout(c *check.C) {
-       otherOrigin := arvados.URL{Scheme: "https", Host: "app.example.com", Path: "/"}
-       otherURL := "https://app.example.com/foo"
-       s.cluster.Services.Workbench1.ExternalURL = arvados.URL{Scheme: "https", Host: "workbench1.example.com"}
-       s.cluster.Services.Workbench2.ExternalURL = arvados.URL{Scheme: "https", Host: "workbench2.example.com"}
-       s.cluster.Login.TrustedClients = map[arvados.URL]struct{}{otherOrigin: {}}
-       s.addHTTPRemote(c, "zhome", &arvadostest.APIStub{})
-       s.cluster.Login.LoginCluster = "zhome"
-       // s.fed is already set by SetUpTest, but we need to
-       // reinitialize with the above config changes.
-       s.fed = New(s.ctx, s.cluster, nil, (&ctrlctx.DBConnector{PostgreSQL: s.cluster.PostgreSQL}).GetDB)
-
-       for _, trial := range []struct {
-               token    string
-               returnTo string
-               target   string
-       }{
-               {token: "", returnTo: "", target: s.cluster.Services.Workbench2.ExternalURL.String()},
-               {token: "", returnTo: otherURL, target: otherURL},
-               {token: "zzzzzzzzzzzzzzzzzzzzz", returnTo: otherURL, target: otherURL},
-               {token: "v2/zzzzz-aaaaa-aaaaaaaaaaaaaaa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", returnTo: otherURL, target: otherURL},
-               {token: "v2/zhome-aaaaa-aaaaaaaaaaaaaaa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", returnTo: otherURL, target: "http://" + s.cluster.RemoteClusters["zhome"].Host + "/logout?" + url.Values{"return_to": {otherURL}}.Encode()},
-       } {
-               c.Logf("trial %#v", trial)
-               ctx := s.ctx
-               if trial.token != "" {
-                       ctx = auth.NewContext(ctx, &auth.Credentials{Tokens: []string{trial.token}})
-               }
-               resp, err := s.fed.Logout(ctx, arvados.LogoutOptions{ReturnTo: trial.returnTo})
-               c.Assert(err, check.IsNil)
-               c.Logf("  RedirectLocation %q", resp.RedirectLocation)
-               target, err := url.Parse(resp.RedirectLocation)
-               c.Check(err, check.IsNil)
-               c.Check(target.String(), check.Equals, trial.target)
-       }
-}
diff --git a/lib/controller/federation/logout_test.go b/lib/controller/federation/logout_test.go
new file mode 100644 (file)
index 0000000..af6f6d9
--- /dev/null
@@ -0,0 +1,246 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package federation
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "net/url"
+
+       "git.arvados.org/arvados.git/lib/ctrlctx"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&LogoutSuite{})
+var emptyURL = &url.URL{}
+
+type LogoutStub struct {
+       arvadostest.APIStub
+       redirectLocation *url.URL
+}
+
+func (as *LogoutStub) CheckCalls(c *check.C, returnURL *url.URL) bool {
+       actual := as.APIStub.Calls(as.APIStub.Logout)
+       allOK := c.Check(actual, check.Not(check.HasLen), 0,
+               check.Commentf("Logout stub never called"))
+       expected := returnURL.String()
+       for _, call := range actual {
+               opts, ok := call.Options.(arvados.LogoutOptions)
+               allOK = c.Check(ok, check.Equals, true,
+                       check.Commentf("call options were not LogoutOptions")) &&
+                       c.Check(opts.ReturnTo, check.Equals, expected) &&
+                       allOK
+       }
+       return allOK
+}
+
+func (as *LogoutStub) Logout(ctx context.Context, options arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+       as.APIStub.Logout(ctx, options)
+       loc := as.redirectLocation.String()
+       if loc == "" {
+               loc = options.ReturnTo
+       }
+       return arvados.LogoutResponse{
+               RedirectLocation: loc,
+       }, as.Error
+}
+
+type LogoutSuite struct {
+       FederationSuite
+}
+
+func (s *LogoutSuite) badReturnURL(path string) *url.URL {
+       return &url.URL{
+               Scheme: "https",
+               Host:   "example.net",
+               Path:   path,
+       }
+}
+
+func (s *LogoutSuite) goodReturnURL(path string) *url.URL {
+       u, _ := url.Parse(s.cluster.Services.Workbench2.ExternalURL.String())
+       u.Path = path
+       return u
+}
+
+func (s *LogoutSuite) setupFederation(loginCluster string) {
+       if loginCluster == "" {
+               s.cluster.Login.Test.Enable = true
+       } else {
+               s.cluster.Login.LoginCluster = loginCluster
+       }
+       dbconn := ctrlctx.DBConnector{PostgreSQL: s.cluster.PostgreSQL}
+       s.fed = New(s.ctx, s.cluster, nil, dbconn.GetDB)
+}
+
+func (s *LogoutSuite) setupStub(c *check.C, id string, stubURL *url.URL, stubErr error) *LogoutStub {
+       loc, err := url.Parse(stubURL.String())
+       c.Check(err, check.IsNil)
+       stub := LogoutStub{redirectLocation: loc}
+       stub.Error = stubErr
+       if id == s.cluster.ClusterID {
+               s.fed.local = &stub
+       } else {
+               s.addDirectRemote(c, id, &stub)
+       }
+       return &stub
+}
+
+func (s *LogoutSuite) v2Token(clusterID string) string {
+       return fmt.Sprintf("v2/%s-gj3su-12345abcde67890/abcdefghijklmnopqrstuvwxy", clusterID)
+}
+
+func (s *LogoutSuite) TestLocalLogoutOK(c *check.C) {
+       s.setupFederation("")
+       resp, err := s.fed.Logout(s.ctx, arvados.LogoutOptions{})
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, s.cluster.Services.Workbench2.ExternalURL.String())
+}
+
+func (s *LogoutSuite) TestLocalLogoutRedirect(c *check.C) {
+       s.setupFederation("")
+       expURL := s.cluster.Services.Workbench1.ExternalURL
+       opts := arvados.LogoutOptions{ReturnTo: expURL.String()}
+       resp, err := s.fed.Logout(s.ctx, opts)
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, expURL.String())
+}
+
+func (s *LogoutSuite) TestLocalLogoutBadRequestError(c *check.C) {
+       s.setupFederation("")
+       returnTo := s.badReturnURL("TestLocalLogoutBadRequestError")
+       opts := arvados.LogoutOptions{ReturnTo: returnTo.String()}
+       _, err := s.fed.Logout(s.ctx, opts)
+       c.Check(err, check.NotNil)
+}
+
+func (s *LogoutSuite) TestRemoteLogoutRedirect(c *check.C) {
+       s.setupFederation("zhome")
+       redirect := url.URL{Scheme: "https", Host: "example.com"}
+       loginStub := s.setupStub(c, "zhome", &redirect, nil)
+       returnTo := s.goodReturnURL("TestRemoteLogoutRedirect")
+       resp, err := s.fed.Logout(s.ctx, arvados.LogoutOptions{ReturnTo: returnTo.String()})
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, redirect.String())
+       loginStub.CheckCalls(c, returnTo)
+}
+
+func (s *LogoutSuite) TestRemoteLogoutError(c *check.C) {
+       s.setupFederation("zhome")
+       expErr := errors.New("TestRemoteLogoutError expErr")
+       loginStub := s.setupStub(c, "zhome", emptyURL, expErr)
+       returnTo := s.goodReturnURL("TestRemoteLogoutError")
+       _, err := s.fed.Logout(s.ctx, arvados.LogoutOptions{ReturnTo: returnTo.String()})
+       c.Check(err, check.Equals, expErr)
+       loginStub.CheckCalls(c, returnTo)
+}
+
+func (s *LogoutSuite) TestRemoteLogoutLocalRedirect(c *check.C) {
+       s.setupFederation("zhome")
+       loginStub := s.setupStub(c, "zhome", emptyURL, nil)
+       redirect := url.URL{Scheme: "https", Host: "example.com"}
+       localStub := s.setupStub(c, "aaaaa", &redirect, nil)
+       resp, err := s.fed.Logout(s.ctx, arvados.LogoutOptions{})
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, redirect.String())
+       // emptyURL to match the empty LogoutOptions
+       loginStub.CheckCalls(c, emptyURL)
+       localStub.CheckCalls(c, emptyURL)
+}
+
+func (s *LogoutSuite) TestRemoteLogoutLocalError(c *check.C) {
+       s.setupFederation("zhome")
+       expErr := errors.New("TestRemoteLogoutLocalError expErr")
+       loginStub := s.setupStub(c, "zhome", emptyURL, nil)
+       localStub := s.setupStub(c, "aaaaa", emptyURL, expErr)
+       _, err := s.fed.Logout(s.ctx, arvados.LogoutOptions{})
+       c.Check(err, check.Equals, expErr)
+       loginStub.CheckCalls(c, emptyURL)
+       localStub.CheckCalls(c, emptyURL)
+}
+
+func (s *LogoutSuite) TestV2TokenRedirect(c *check.C) {
+       s.setupFederation("")
+       redirect := url.URL{Scheme: "https", Host: "example.com"}
+       returnTo := s.goodReturnURL("TestV2TokenRedirect")
+       localErr := errors.New("TestV2TokenRedirect error")
+       tokenStub := s.setupStub(c, "zzzzz", &redirect, nil)
+       s.setupStub(c, "aaaaa", emptyURL, localErr)
+       tokens := []string{s.v2Token("zzzzz")}
+       ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: tokens})
+       resp, err := s.fed.Logout(ctx, arvados.LogoutOptions{ReturnTo: returnTo.String()})
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, redirect.String())
+       tokenStub.CheckCalls(c, returnTo)
+}
+
+func (s *LogoutSuite) TestV2TokenError(c *check.C) {
+       s.setupFederation("")
+       returnTo := s.goodReturnURL("TestV2TokenError")
+       tokenErr := errors.New("TestV2TokenError error")
+       tokenStub := s.setupStub(c, "zzzzz", emptyURL, tokenErr)
+       s.setupStub(c, "aaaaa", emptyURL, nil)
+       tokens := []string{s.v2Token("zzzzz")}
+       ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: tokens})
+       _, err := s.fed.Logout(ctx, arvados.LogoutOptions{ReturnTo: returnTo.String()})
+       c.Check(err, check.Equals, tokenErr)
+       tokenStub.CheckCalls(c, returnTo)
+}
+
+func (s *LogoutSuite) TestV2TokenLocalRedirect(c *check.C) {
+       s.setupFederation("")
+       redirect := url.URL{Scheme: "https", Host: "example.com"}
+       tokenStub := s.setupStub(c, "zzzzz", emptyURL, nil)
+       localStub := s.setupStub(c, "aaaaa", &redirect, nil)
+       tokens := []string{s.v2Token("zzzzz")}
+       ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: tokens})
+       resp, err := s.fed.Logout(ctx, arvados.LogoutOptions{})
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, redirect.String())
+       tokenStub.CheckCalls(c, emptyURL)
+       localStub.CheckCalls(c, emptyURL)
+}
+
+func (s *LogoutSuite) TestV2TokenLocalError(c *check.C) {
+       s.setupFederation("")
+       tokenErr := errors.New("TestV2TokenLocalError error")
+       tokenStub := s.setupStub(c, "zzzzz", emptyURL, nil)
+       localStub := s.setupStub(c, "aaaaa", emptyURL, tokenErr)
+       tokens := []string{s.v2Token("zzzzz")}
+       ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: tokens})
+       _, err := s.fed.Logout(ctx, arvados.LogoutOptions{})
+       c.Check(err, check.Equals, tokenErr)
+       tokenStub.CheckCalls(c, emptyURL)
+       localStub.CheckCalls(c, emptyURL)
+}
+
+func (s *LogoutSuite) TestV2LocalTokenRedirect(c *check.C) {
+       s.setupFederation("")
+       redirect := url.URL{Scheme: "https", Host: "example.com"}
+       returnTo := s.goodReturnURL("TestV2LocalTokenRedirect")
+       localStub := s.setupStub(c, "aaaaa", &redirect, nil)
+       tokens := []string{s.v2Token("aaaaa")}
+       ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: tokens})
+       resp, err := s.fed.Logout(ctx, arvados.LogoutOptions{ReturnTo: returnTo.String()})
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, redirect.String())
+       localStub.CheckCalls(c, returnTo)
+}
+
+func (s *LogoutSuite) TestV2LocalTokenError(c *check.C) {
+       s.setupFederation("")
+       returnTo := s.goodReturnURL("TestV2LocalTokenError")
+       tokenErr := errors.New("TestV2LocalTokenError error")
+       localStub := s.setupStub(c, "aaaaa", emptyURL, tokenErr)
+       tokens := []string{s.v2Token("aaaaa")}
+       ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: tokens})
+       _, err := s.fed.Logout(ctx, arvados.LogoutOptions{ReturnTo: returnTo.String()})
+       c.Check(err, check.Equals, tokenErr)
+       localStub.CheckCalls(c, returnTo)
+}
index 1bd1bd2f18b9315c44428da2623f121e61e17fe8..33bc95d0ea2e6ac331087911cc8b4b9d8948f8a1 100644 (file)
@@ -78,7 +78,7 @@ func (s *UserSuite) TestLoginClusterUserList(c *check.C) {
                                                "identity_url": false,
                                                // virtual attrs
                                                "full_name":  false,
-                                               "is_invited": false,
+                                               "is_invited": true,
                                        }
                                        if opts.Select != nil {
                                                // Only the selected
@@ -146,7 +146,7 @@ func (s *UserSuite) TestLoginClusterUserGet(c *check.C) {
                        "identity_url": false,
                        // virtual attrs
                        "full_name":  false,
-                       "is_invited": false,
+                       "is_invited": true,
                }
                if opts.Select != nil {
                        // Only the selected
index 4fbb3440ed377103da603db406d2c4b29eeacd63..599686e3e6cc64866793114839f4167605328f86 100644 (file)
@@ -707,7 +707,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequestCheckRuntimeToken(c *c
        s.testHandler.Cluster.API.MaxTokenLifetime = arvados.Duration(time.Hour)
 
        resp := s.testRequest(req).Result()
-       c.Check(resp.StatusCode, check.Equals, http.StatusOK)
+       c.Assert(resp.StatusCode, check.Equals, http.StatusOK)
 
        cr := s.getCRfromMockRequest(c)
 
index 4810ec3c257e18d626cbf8a12ff44c475a46ab5d..7c4bb0912fb3feae8871d9a5e2f920bb777738c4 100644 (file)
@@ -6,12 +6,17 @@ package controller
 
 import (
        "context"
+       "encoding/json"
+       "errors"
        "fmt"
+       "io/ioutil"
+       "mime"
        "net/http"
        "net/http/httptest"
        "net/url"
        "strings"
        "sync"
+       "time"
 
        "git.arvados.org/arvados.git/lib/controller/api"
        "git.arvados.org/arvados.git/lib/controller/federation"
@@ -20,6 +25,7 @@ import (
        "git.arvados.org/arvados.git/lib/controller/router"
        "git.arvados.org/arvados.git/lib/ctrlctx"
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "git.arvados.org/arvados.git/sdk/go/health"
        "git.arvados.org/arvados.git/sdk/go/httpserver"
 
@@ -39,6 +45,8 @@ type Handler struct {
        insecureClient *http.Client
        dbConnector    ctrlctx.DBConnector
        limitLogCreate chan struct{}
+
+       cache map[string]*cacheEnt
 }
 
 func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
@@ -132,6 +140,8 @@ func (h *Handler) setup() {
        mux.Handle("/arvados/v1/groups/", rtr)
        mux.Handle("/arvados/v1/links", rtr)
        mux.Handle("/arvados/v1/links/", rtr)
+       mux.Handle("/arvados/v1/authorized_keys", rtr)
+       mux.Handle("/arvados/v1/authorized_keys/", rtr)
        mux.Handle("/login", rtr)
        mux.Handle("/logout", rtr)
        mux.Handle("/arvados/v1/api_client_authorizations", rtr)
@@ -139,6 +149,7 @@ func (h *Handler) setup() {
 
        hs := http.NotFoundHandler()
        hs = prepend(hs, h.proxyRailsAPI)
+       hs = prepend(hs, h.routeContainerEndpoints(rtr))
        hs = prepend(hs, h.limitLogCreateRequests)
        hs = h.setupProxyRemoteCluster(hs)
        hs = prepend(hs, oidcAuthorizer.Middleware)
@@ -162,6 +173,9 @@ func (h *Handler) setup() {
        h.proxy = &proxy{
                Name: "arvados-controller",
        }
+       h.cache = map[string]*cacheEnt{
+               "/discovery/v1/apis/arvados/v1/rest": &cacheEnt{validate: validateDiscoveryDoc},
+       }
 
        go h.trashSweepWorker()
        go h.containerLogSweepWorker()
@@ -191,9 +205,32 @@ func (h *Handler) localClusterRequest(req *http.Request) (*http.Response, error)
        if insecure {
                client = h.insecureClient
        }
+       // Clearing the Host field here causes the Go http client to
+       // use the host part of urlOut as the Host header in the
+       // outgoing request, instead of the Host value from the
+       // original request we received.
+       req.Host = ""
        return h.proxy.Do(req, urlOut, client)
 }
 
+// Route /arvados/v1/containers/{uuid}/log*, .../ssh, and
+// .../gateway_tunnel to rtr, pass everything else to next.
+//
+// (http.ServeMux doesn't let us route these without also routing
+// everything under /containers/, which we don't want yet.)
+func (h *Handler) routeContainerEndpoints(rtr http.Handler) middlewareFunc {
+       return func(w http.ResponseWriter, req *http.Request, next http.Handler) {
+               trim := strings.TrimPrefix(req.URL.Path, "/arvados/v1/containers/")
+               if trim != req.URL.Path && (strings.Index(trim, "/log") == 27 ||
+                       strings.Index(trim, "/ssh") == 27 ||
+                       strings.Index(trim, "/gateway_tunnel") == 27) {
+                       rtr.ServeHTTP(w, req)
+               } else {
+                       next.ServeHTTP(w, req)
+               }
+       }
+}
+
 func (h *Handler) limitLogCreateRequests(w http.ResponseWriter, req *http.Request, next http.Handler) {
        if cap(h.limitLogCreate) > 0 && req.Method == http.MethodPost && strings.HasPrefix(req.URL.Path, "/arvados/v1/logs") {
                select {
@@ -208,7 +245,129 @@ func (h *Handler) limitLogCreateRequests(w http.ResponseWriter, req *http.Reques
        next.ServeHTTP(w, req)
 }
 
+// cacheEnt implements a basic stale-while-revalidate cache, suitable
+// for the Arvados discovery document.
+type cacheEnt struct {
+       validate     func(body []byte) error
+       mtx          sync.Mutex
+       header       http.Header
+       body         []byte
+       expireAfter  time.Time
+       refreshAfter time.Time
+       refreshLock  sync.Mutex
+}
+
+const (
+       cacheTTL    = 5 * time.Minute
+       cacheExpire = 24 * time.Hour
+)
+
+func (ent *cacheEnt) refresh(path string, do func(*http.Request) (*http.Response, error)) (http.Header, []byte, error) {
+       ent.refreshLock.Lock()
+       defer ent.refreshLock.Unlock()
+       if header, body, needRefresh := ent.response(); !needRefresh {
+               // another goroutine refreshed successfully while we
+               // were waiting for refreshLock
+               return header, body, nil
+       } else if body != nil {
+               // Cache is present, but expired. We'll try to refresh
+               // below. Meanwhile, other refresh() calls will queue
+               // up for refreshLock -- and we don't want them to
+               // turn into N upstream requests, even if upstream is
+               // failing.  (If we succeed we'll update the expiry
+               // time again below with the real cacheTTL -- this
+               // just takes care of the error case.)
+               ent.mtx.Lock()
+               ent.refreshAfter = time.Now().Add(time.Second)
+               ent.mtx.Unlock()
+       }
+
+       ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
+       defer cancel()
+       // "http://localhost" is just a placeholder here -- we'll fill
+       // in req.URL.Path below, and then do(), which is
+       // localClusterRequest(), will replace the scheme and host
+       // parts with the real proxy destination.
+       req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil)
+       if err != nil {
+               return nil, nil, err
+       }
+       req.URL.Path = path
+       resp, err := do(req)
+       if err != nil {
+               return nil, nil, err
+       }
+       if resp.StatusCode != http.StatusOK {
+               return nil, nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
+       }
+       body, err := ioutil.ReadAll(resp.Body)
+       if err != nil {
+               return nil, nil, fmt.Errorf("Read error: %w", err)
+       }
+       header := http.Header{}
+       for k, v := range resp.Header {
+               if !dropHeaders[k] && k != "X-Request-Id" {
+                       header[k] = v
+               }
+       }
+       if ent.validate != nil {
+               if err := ent.validate(body); err != nil {
+                       return nil, nil, err
+               }
+       } else if mediatype, _, err := mime.ParseMediaType(header.Get("Content-Type")); err == nil && mediatype == "application/json" {
+               if !json.Valid(body) {
+                       return nil, nil, errors.New("invalid JSON encoding in response")
+               }
+       }
+       ent.mtx.Lock()
+       defer ent.mtx.Unlock()
+       ent.header = header
+       ent.body = body
+       ent.refreshAfter = time.Now().Add(cacheTTL)
+       ent.expireAfter = time.Now().Add(cacheExpire)
+       return ent.header, ent.body, nil
+}
+
+func (ent *cacheEnt) response() (http.Header, []byte, bool) {
+       ent.mtx.Lock()
+       defer ent.mtx.Unlock()
+       if ent.expireAfter.Before(time.Now()) {
+               ent.header, ent.body, ent.refreshAfter = nil, nil, time.Time{}
+       }
+       return ent.header, ent.body, ent.refreshAfter.Before(time.Now())
+}
+
+func (ent *cacheEnt) ServeHTTP(ctx context.Context, w http.ResponseWriter, path string, do func(*http.Request) (*http.Response, error)) {
+       header, body, needRefresh := ent.response()
+       if body == nil {
+               // need to fetch before we can return anything
+               var err error
+               header, body, err = ent.refresh(path, do)
+               if err != nil {
+                       http.Error(w, err.Error(), http.StatusBadGateway)
+                       return
+               }
+       } else if needRefresh {
+               // re-fetch in background
+               go func() {
+                       _, _, err := ent.refresh(path, do)
+                       if err != nil {
+                               ctxlog.FromContext(ctx).WithError(err).WithField("path", path).Warn("error refreshing cache")
+                       }
+               }()
+       }
+       for k, v := range header {
+               w.Header()[k] = v
+       }
+       w.WriteHeader(http.StatusOK)
+       w.Write(body)
+}
+
 func (h *Handler) proxyRailsAPI(w http.ResponseWriter, req *http.Request, next http.Handler) {
+       if ent, ok := h.cache[req.URL.Path]; ok && req.Method == http.MethodGet {
+               ent.ServeHTTP(req.Context(), w, req.URL.Path, h.localClusterRequest)
+               return
+       }
        resp, err := h.localClusterRequest(req)
        n, err := h.proxy.ForwardResponse(w, resp, err)
        if err != nil {
@@ -232,3 +391,15 @@ func findRailsAPI(cluster *arvados.Cluster) (*url.URL, bool, error) {
        }
        return best, cluster.TLS.Insecure, nil
 }
+
+func validateDiscoveryDoc(body []byte) error {
+       var dd arvados.DiscoveryDocument
+       err := json.Unmarshal(body, &dd)
+       if err != nil {
+               return fmt.Errorf("error decoding JSON response: %w", err)
+       }
+       if dd.BasePath == "" {
+               return errors.New("error in discovery document: no value for basePath")
+       }
+       return nil
+}
index 76eab9ca1568c1e1f134489fb70d9e732d5e9bae..eef0443b9a931afa1d5a0160a6cce8e67cc9c419 100644 (file)
@@ -16,6 +16,7 @@ import (
        "net/url"
        "os"
        "strings"
+       "sync"
        "testing"
        "time"
 
@@ -37,11 +38,12 @@ func Test(t *testing.T) {
 var _ = check.Suite(&HandlerSuite{})
 
 type HandlerSuite struct {
-       cluster *arvados.Cluster
-       handler *Handler
-       logbuf  *bytes.Buffer
-       ctx     context.Context
-       cancel  context.CancelFunc
+       cluster  *arvados.Cluster
+       handler  *Handler
+       railsSpy *arvadostest.Proxy
+       logbuf   *bytes.Buffer
+       ctx      context.Context
+       cancel   context.CancelFunc
 }
 
 func (s *HandlerSuite) SetUpTest(c *check.C) {
@@ -55,6 +57,8 @@ func (s *HandlerSuite) SetUpTest(c *check.C) {
        s.cluster.API.RequestTimeout = arvados.Duration(5 * time.Minute)
        s.cluster.TLS.Insecure = true
        arvadostest.SetServiceURL(&s.cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
+       s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
+       arvadostest.SetServiceURL(&s.cluster.Services.RailsAPI, s.railsSpy.URL.String())
        arvadostest.SetServiceURL(&s.cluster.Services.Controller, "http://localhost:/")
        s.handler = newHandler(s.ctx, s.cluster, "", prometheus.NewRegistry()).(*Handler)
 }
@@ -93,6 +97,205 @@ func (s *HandlerSuite) TestConfigExport(c *check.C) {
        }
 }
 
+func (s *HandlerSuite) TestDiscoveryDocCache(c *check.C) {
+       countRailsReqs := func() int {
+               s.railsSpy.Wait()
+               n := 0
+               for _, req := range s.railsSpy.RequestDumps {
+                       if bytes.Contains(req, []byte("/discovery/v1/apis/arvados/v1/rest")) {
+                               n++
+                       }
+               }
+               return n
+       }
+       getDD := func() int {
+               req := httptest.NewRequest(http.MethodGet, "/discovery/v1/apis/arvados/v1/rest", nil)
+               resp := httptest.NewRecorder()
+               s.handler.ServeHTTP(resp, req)
+               if resp.Code == http.StatusOK {
+                       var dd arvados.DiscoveryDocument
+                       err := json.Unmarshal(resp.Body.Bytes(), &dd)
+                       c.Check(err, check.IsNil)
+                       c.Check(dd.Schemas["Collection"].UUIDPrefix, check.Equals, "4zz18")
+               }
+               return resp.Code
+       }
+       getDDConcurrently := func(n int, expectCode int, checkArgs ...interface{}) *sync.WaitGroup {
+               var wg sync.WaitGroup
+               for i := 0; i < n; i++ {
+                       wg.Add(1)
+                       go func() {
+                               defer wg.Done()
+                               c.Check(getDD(), check.Equals, append([]interface{}{expectCode}, checkArgs...)...)
+                       }()
+               }
+               return &wg
+       }
+       clearCache := func() {
+               for _, ent := range s.handler.cache {
+                       ent.refreshLock.Lock()
+                       ent.mtx.Lock()
+                       ent.body, ent.header, ent.refreshAfter = nil, nil, time.Time{}
+                       ent.mtx.Unlock()
+                       ent.refreshLock.Unlock()
+               }
+       }
+       waitPendingUpdates := func() {
+               for _, ent := range s.handler.cache {
+                       ent.refreshLock.Lock()
+                       defer ent.refreshLock.Unlock()
+                       ent.mtx.Lock()
+                       defer ent.mtx.Unlock()
+               }
+       }
+       refreshNow := func() {
+               waitPendingUpdates()
+               for _, ent := range s.handler.cache {
+                       ent.refreshAfter = time.Now()
+               }
+       }
+       expireNow := func() {
+               waitPendingUpdates()
+               for _, ent := range s.handler.cache {
+                       ent.expireAfter = time.Now()
+               }
+       }
+
+       // Easy path: first req fetches, subsequent reqs use cache.
+       c.Check(countRailsReqs(), check.Equals, 0)
+       c.Check(getDD(), check.Equals, http.StatusOK)
+       c.Check(countRailsReqs(), check.Equals, 1)
+       c.Check(getDD(), check.Equals, http.StatusOK)
+       c.Check(countRailsReqs(), check.Equals, 1)
+       c.Check(getDD(), check.Equals, http.StatusOK)
+       c.Check(countRailsReqs(), check.Equals, 1)
+
+       // To guarantee we have concurrent requests, we set up
+       // railsSpy to hold up the Handler's outgoing requests until
+       // we send to (or close) holdReqs.
+       holdReqs := make(chan struct{})
+       s.railsSpy.Director = func(*http.Request) {
+               <-holdReqs
+       }
+
+       // Race at startup: first req fetches, other concurrent reqs
+       // wait for the initial fetch to complete, then all return.
+       clearCache()
+       reqsBefore := countRailsReqs()
+       wg := getDDConcurrently(5, http.StatusOK, check.Commentf("race at startup"))
+       close(holdReqs)
+       wg.Wait()
+       c.Check(countRailsReqs(), check.Equals, reqsBefore+1)
+
+       // Race after expiry: concurrent reqs return the cached data
+       // but initiate a new fetch in the background.
+       refreshNow()
+       holdReqs = make(chan struct{})
+       wg = getDDConcurrently(5, http.StatusOK, check.Commentf("race after expiry"))
+       reqsBefore = countRailsReqs()
+       close(holdReqs)
+       wg.Wait()
+       for deadline := time.Now().Add(time.Second); time.Now().Before(deadline) && countRailsReqs() < reqsBefore+1; {
+               time.Sleep(time.Second / 100)
+       }
+       c.Check(countRailsReqs(), check.Equals, reqsBefore+1)
+
+       // Configure railsSpy to return an error or bad content
+       // depending on flags.
+       var wantError, wantBadContent bool
+       s.railsSpy.Director = func(req *http.Request) {
+               if wantError {
+                       req.Method = "MAKE-COFFEE"
+               } else if wantBadContent {
+                       req.URL.Path = "/_health/ping"
+                       req.Header.Set("Authorization", "Bearer "+arvadostest.ManagementToken)
+               }
+       }
+
+       // Error at startup (empty cache) => caller gets error, and we
+       // make an upstream attempt for each incoming request because
+       // we have nothing better to return
+       clearCache()
+       wantError, wantBadContent = true, false
+       reqsBefore = countRailsReqs()
+       holdReqs = make(chan struct{})
+       wg = getDDConcurrently(5, http.StatusBadGateway, check.Commentf("error at startup"))
+       close(holdReqs)
+       wg.Wait()
+       c.Check(countRailsReqs(), check.Equals, reqsBefore+5)
+
+       // Response status is OK but body is not a discovery document
+       wantError, wantBadContent = false, true
+       reqsBefore = countRailsReqs()
+       c.Check(getDD(), check.Equals, http.StatusBadGateway)
+       c.Check(countRailsReqs(), check.Equals, reqsBefore+1)
+
+       // Error condition clears => caller gets OK, cache is warmed
+       // up
+       wantError, wantBadContent = false, false
+       reqsBefore = countRailsReqs()
+       getDDConcurrently(5, http.StatusOK, check.Commentf("success after errors at startup")).Wait()
+       c.Check(countRailsReqs(), check.Equals, reqsBefore+1)
+
+       // Error with warm cache => caller gets OK (with no attempt to
+       // re-fetch)
+       wantError, wantBadContent = true, false
+       reqsBefore = countRailsReqs()
+       getDDConcurrently(5, http.StatusOK, check.Commentf("error with warm cache")).Wait()
+       c.Check(countRailsReqs(), check.Equals, reqsBefore)
+
+       // Error with stale cache => caller gets OK with stale data
+       // while the re-fetch is attempted in the background
+       refreshNow()
+       wantError, wantBadContent = true, false
+       reqsBefore = countRailsReqs()
+       holdReqs = make(chan struct{})
+       getDDConcurrently(5, http.StatusOK, check.Commentf("error with stale cache")).Wait()
+       close(holdReqs)
+       // Only one attempt to re-fetch (holdReqs ensured the first
+       // update took long enough for the last incoming request to
+       // arrive)
+       c.Check(countRailsReqs(), check.Equals, reqsBefore+1)
+
+       refreshNow()
+       wantError, wantBadContent = false, false
+       reqsBefore = countRailsReqs()
+       holdReqs = make(chan struct{})
+       getDDConcurrently(5, http.StatusOK, check.Commentf("refresh cache after error condition clears")).Wait()
+       close(holdReqs)
+       waitPendingUpdates()
+       c.Check(countRailsReqs(), check.Equals, reqsBefore+1)
+
+       // Make sure expireAfter is getting set
+       waitPendingUpdates()
+       exp := s.handler.cache["/discovery/v1/apis/arvados/v1/rest"].expireAfter.Sub(time.Now())
+       c.Check(exp > cacheTTL, check.Equals, true)
+       c.Check(exp < cacheExpire, check.Equals, true)
+
+       // After the cache *expires* it behaves as if uninitialized:
+       // each incoming request does a new upstream request until one
+       // succeeds.
+       //
+       // First check failure after expiry:
+       expireNow()
+       wantError, wantBadContent = true, false
+       reqsBefore = countRailsReqs()
+       holdReqs = make(chan struct{})
+       wg = getDDConcurrently(5, http.StatusBadGateway, check.Commentf("error after expiry"))
+       close(holdReqs)
+       wg.Wait()
+       c.Check(countRailsReqs(), check.Equals, reqsBefore+5)
+
+       // Success after expiry:
+       wantError, wantBadContent = false, false
+       reqsBefore = countRailsReqs()
+       holdReqs = make(chan struct{})
+       wg = getDDConcurrently(5, http.StatusOK, check.Commentf("success after expiry"))
+       close(holdReqs)
+       wg.Wait()
+       c.Check(countRailsReqs(), check.Equals, reqsBefore+1)
+}
+
 func (s *HandlerSuite) TestVocabularyExport(c *check.C) {
        voc := `{
                "strict_tags": false,
@@ -210,7 +413,7 @@ func (s *HandlerSuite) TestProxyDiscoveryDoc(c *check.C) {
 // etc.
 func (s *HandlerSuite) TestRequestCancel(c *check.C) {
        ctx, cancel := context.WithCancel(context.Background())
-       req := httptest.NewRequest("GET", "/discovery/v1/apis/arvados/v1/rest", nil).WithContext(ctx)
+       req := httptest.NewRequest("GET", "/static/login_failure", nil).WithContext(ctx)
        resp := httptest.NewRecorder()
        cancel()
        s.handler.ServeHTTP(resp, req)
@@ -437,7 +640,7 @@ func (s *HandlerSuite) TestGetObjects(c *check.C) {
        testCases := map[string]map[string]bool{
                "api_clients/" + arvadostest.TrustedWorkbenchAPIClientUUID:     nil,
                "api_client_authorizations/" + auth.UUID:                       {"href": true, "modified_by_client_uuid": true, "modified_by_user_uuid": true},
-               "authorized_keys/" + arvadostest.AdminAuthorizedKeysUUID:       nil,
+               "authorized_keys/" + arvadostest.AdminAuthorizedKeysUUID:       {"href": true},
                "collections/" + arvadostest.CollectionWithUniqueWordsUUID:     {"href": true},
                "containers/" + arvadostest.RunningContainerUUID:               nil,
                "container_requests/" + arvadostest.QueuedContainerRequestUUID: nil,
index e207e669c8ec8ac203b6211ff69a3f33d7e17dfa..45f35a6d2e937b5285fe49308329f9752ed7163b 100644 (file)
@@ -28,6 +28,7 @@ import (
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "git.arvados.org/arvados.git/sdk/go/keepclient"
        check "gopkg.in/check.v1"
 )
 
@@ -167,6 +168,20 @@ func (s *IntegrationSuite) TestDefaultStorageClassesOnCollections(c *check.C) {
        c.Assert(coll.StorageClassesDesired, check.DeepEquals, kc.DefaultStorageClasses)
 }
 
+func (s *IntegrationSuite) createTestCollectionManifest(c *check.C, ac *arvados.Client, kc *keepclient.KeepClient, content string) string {
+       fs, err := (&arvados.Collection{}).FileSystem(ac, kc)
+       c.Assert(err, check.IsNil)
+       f, err := fs.OpenFile("test.txt", os.O_CREATE|os.O_RDWR, 0777)
+       c.Assert(err, check.IsNil)
+       _, err = io.WriteString(f, content)
+       c.Assert(err, check.IsNil)
+       err = f.Close()
+       c.Assert(err, check.IsNil)
+       mtxt, err := fs.MarshalManifest(".")
+       c.Assert(err, check.IsNil)
+       return mtxt
+}
+
 func (s *IntegrationSuite) TestGetCollectionByPDH(c *check.C) {
        conn1 := s.super.Conn("z1111")
        rootctx1, _, _ := s.super.RootClients("z1111")
@@ -175,34 +190,70 @@ func (s *IntegrationSuite) TestGetCollectionByPDH(c *check.C) {
 
        // Create the collection to find its PDH (but don't save it
        // anywhere yet)
-       var coll1 arvados.Collection
-       fs1, err := coll1.FileSystem(ac1, kc1)
-       c.Assert(err, check.IsNil)
-       f, err := fs1.OpenFile("test.txt", os.O_CREATE|os.O_RDWR, 0777)
-       c.Assert(err, check.IsNil)
-       _, err = io.WriteString(f, "IntegrationSuite.TestGetCollectionByPDH")
-       c.Assert(err, check.IsNil)
-       err = f.Close()
-       c.Assert(err, check.IsNil)
-       mtxt, err := fs1.MarshalManifest(".")
-       c.Assert(err, check.IsNil)
+       mtxt := s.createTestCollectionManifest(c, ac1, kc1, c.TestName())
        pdh := arvados.PortableDataHash(mtxt)
 
        // Looking up the PDH before saving returns 404 if cycle
        // detection is working.
-       _, err = conn1.CollectionGet(userctx1, arvados.GetOptions{UUID: pdh})
+       _, err := conn1.CollectionGet(userctx1, arvados.GetOptions{UUID: pdh})
        c.Assert(err, check.ErrorMatches, `.*404 Not Found.*`)
 
        // Save the collection on cluster z1111.
-       coll1, err = conn1.CollectionCreate(userctx1, arvados.CreateOptions{Attrs: map[string]interface{}{
+       _, err = conn1.CollectionCreate(userctx1, arvados.CreateOptions{Attrs: map[string]interface{}{
                "manifest_text": mtxt,
        }})
        c.Assert(err, check.IsNil)
 
        // Retrieve the collection from cluster z3333.
-       coll, err := conn3.CollectionGet(userctx1, arvados.GetOptions{UUID: pdh})
+       coll2, err := conn3.CollectionGet(userctx1, arvados.GetOptions{UUID: pdh})
        c.Check(err, check.IsNil)
-       c.Check(coll.PortableDataHash, check.Equals, pdh)
+       c.Check(coll2.PortableDataHash, check.Equals, pdh)
+}
+
+func (s *IntegrationSuite) TestFederation_Write1Read2(c *check.C) {
+       s.testFederationCollectionAccess(c, "z1111", "z2222")
+}
+
+func (s *IntegrationSuite) TestFederation_Write2Read1(c *check.C) {
+       s.testFederationCollectionAccess(c, "z2222", "z1111")
+}
+
+func (s *IntegrationSuite) TestFederation_Write2Read3(c *check.C) {
+       s.testFederationCollectionAccess(c, "z2222", "z3333")
+}
+
+func (s *IntegrationSuite) testFederationCollectionAccess(c *check.C, writeCluster, readCluster string) {
+       conn1 := s.super.Conn("z1111")
+       rootctx1, _, _ := s.super.RootClients("z1111")
+       _, ac1, _, _ := s.super.UserClients("z1111", rootctx1, c, conn1, s.oidcprovider.AuthEmail, true)
+
+       connW := s.super.Conn(writeCluster)
+       userctxW, acW, kcW := s.super.ClientsWithToken(writeCluster, ac1.AuthToken)
+       kcW.DiskCacheSize = keepclient.DiskCacheDisabled
+       connR := s.super.Conn(readCluster)
+       userctxR, acR, kcR := s.super.ClientsWithToken(readCluster, ac1.AuthToken)
+       kcR.DiskCacheSize = keepclient.DiskCacheDisabled
+
+       filedata := fmt.Sprintf("%s: write to %s, read from %s", c.TestName(), writeCluster, readCluster)
+       mtxt := s.createTestCollectionManifest(c, acW, kcW, filedata)
+       collW, err := connW.CollectionCreate(userctxW, arvados.CreateOptions{Attrs: map[string]interface{}{
+               "manifest_text": mtxt,
+       }})
+       c.Assert(err, check.IsNil)
+
+       collR, err := connR.CollectionGet(userctxR, arvados.GetOptions{UUID: collW.UUID})
+       if !c.Check(err, check.IsNil) {
+               return
+       }
+       fsR, err := collR.FileSystem(acR, kcR)
+       if !c.Check(err, check.IsNil) {
+               return
+       }
+       buf, err := fs.ReadFile(arvados.FS(fsR), "test.txt")
+       if !c.Check(err, check.IsNil) {
+               return
+       }
+       c.Check(string(buf), check.Equals, filedata)
 }
 
 // Tests bug #18004
@@ -501,6 +552,7 @@ func (s *IntegrationSuite) TestCreateContainerRequestWithFedToken(c *check.C) {
        req.Header.Set("Authorization", "OAuth2 "+ac2.AuthToken)
        resp, err = arvados.InsecureHTTPClient.Do(req)
        c.Assert(err, check.IsNil)
+       defer resp.Body.Close()
        err = json.NewDecoder(resp.Body).Decode(&cr)
        c.Check(err, check.IsNil)
        c.Check(cr.UUID, check.Matches, "z2222-.*")
@@ -538,8 +590,10 @@ func (s *IntegrationSuite) TestCreateContainerRequestWithBadToken(c *check.C) {
                c.Assert(err, check.IsNil)
                req.Header.Set("Content-Type", "application/json")
                resp, err := ac1.Do(req)
-               c.Assert(err, check.IsNil)
-               c.Assert(resp.StatusCode, check.Equals, tt.expectedCode)
+               if c.Check(err, check.IsNil) {
+                       c.Assert(resp.StatusCode, check.Equals, tt.expectedCode)
+                       resp.Body.Close()
+               }
        }
 }
 
@@ -607,9 +661,11 @@ func (s *IntegrationSuite) TestRequestIDHeader(c *check.C) {
                        var jresp httpserver.ErrorResponse
                        err := json.NewDecoder(resp.Body).Decode(&jresp)
                        c.Check(err, check.IsNil)
-                       c.Assert(jresp.Errors, check.HasLen, 1)
-                       c.Check(jresp.Errors[0], check.Matches, `.*\(`+respHdr+`\).*`)
+                       if c.Check(jresp.Errors, check.HasLen, 1) {
+                               c.Check(jresp.Errors[0], check.Matches, `.*\(`+respHdr+`\).*`)
+                       }
                }
+               resp.Body.Close()
        }
 }
 
@@ -966,8 +1022,8 @@ func (s *IntegrationSuite) TestSetupUserWithVM(c *check.C) {
                        "hostname": "example",
                },
                })
+       c.Assert(err, check.IsNil)
        c.Check(outVM.UUID[0:5], check.Equals, "z3333")
-       c.Check(err, check.IsNil)
 
        // Make sure z3333 user list is up to date
        _, err = conn3.UserList(rootctx3, arvados.ListOptions{Limit: 1000})
@@ -1226,12 +1282,35 @@ func (s *IntegrationSuite) runContainer(c *check.C, clusterID string, token stri
                return cfs
        }
 
+       checkwebdavlogs := func(cr arvados.ContainerRequest) {
+               req, err := http.NewRequest("OPTIONS", "https://"+ac.APIHost+"/arvados/v1/container_requests/"+cr.UUID+"/log/"+cr.ContainerUUID+"/", nil)
+               c.Assert(err, check.IsNil)
+               req.Header.Set("Origin", "http://example.example")
+               resp, err := ac.Do(req)
+               c.Assert(err, check.IsNil)
+               c.Check(resp.StatusCode, check.Equals, http.StatusOK)
+               // Check for duplicate headers -- must use Header[], not Header.Get()
+               c.Check(resp.Header["Access-Control-Allow-Origin"], check.DeepEquals, []string{"*"})
+       }
+
        var ctr arvados.Container
        var lastState arvados.ContainerState
+       var status, lastStatus arvados.ContainerStatus
+       var allStatus string
+       checkstatus := func() {
+               err := ac.RequestAndDecode(&status, "GET", "/arvados/v1/container_requests/"+cr.UUID+"/container_status", nil, nil)
+               c.Assert(err, check.IsNil)
+               if status != lastStatus {
+                       c.Logf("container status: %s, %s", status.State, status.SchedulingStatus)
+                       allStatus += fmt.Sprintf("%s, %s\n", status.State, status.SchedulingStatus)
+                       lastStatus = status
+               }
+       }
        deadline := time.Now().Add(time.Minute)
-       for cr.State != arvados.ContainerRequestStateFinal {
+       for cr.State != arvados.ContainerRequestStateFinal || (lastStatus.State != arvados.ContainerStateComplete && lastStatus.State != arvados.ContainerStateCancelled) {
                err = ac.RequestAndDecode(&cr, "GET", "/arvados/v1/container_requests/"+cr.UUID, nil, nil)
                c.Assert(err, check.IsNil)
+               checkstatus()
                err = ac.RequestAndDecode(&ctr, "GET", "/arvados/v1/containers/"+cr.ContainerUUID, nil, nil)
                if err != nil {
                        c.Logf("error getting container state: %s", err)
@@ -1241,12 +1320,17 @@ func (s *IntegrationSuite) runContainer(c *check.C, clusterID string, token stri
                } else {
                        if time.Now().After(deadline) {
                                c.Errorf("timed out, container state is %q", cr.State)
-                               showlogs(ctr.Log)
+                               if ctr.Log == "" {
+                                       c.Logf("=== NO LOG COLLECTION saved for container")
+                               } else {
+                                       showlogs(ctr.Log)
+                               }
                                c.FailNow()
                        }
                        time.Sleep(time.Second / 2)
                }
        }
+       checkstatus()
        c.Logf("cr.CumulativeCost == %f", cr.CumulativeCost)
        c.Check(cr.CumulativeCost, check.Not(check.Equals), 0.0)
        if expectExitCode >= 0 {
@@ -1254,7 +1338,15 @@ func (s *IntegrationSuite) runContainer(c *check.C, clusterID string, token stri
                c.Check(ctr.ExitCode, check.Equals, expectExitCode)
                err = ac.RequestAndDecode(&outcoll, "GET", "/arvados/v1/collections/"+cr.OutputUUID, nil, nil)
                c.Assert(err, check.IsNil)
+               c.Check(allStatus, check.Matches, `Queued, waiting for dispatch\n`+
+                       `(Queued, waiting.*\n)*`+
+                       `(Locked, waiting for dispatch\n)?`+
+                       `(Locked, waiting for new instance to be ready\n)?`+
+                       `(Locked, preparing runtime environment\n)?`+
+                       `(Running, \n)?`+
+                       `Complete, \n`)
        }
        logcfs = showlogs(cr.LogUUID)
+       checkwebdavlogs(cr)
        return outcoll, logcfs
 }
diff --git a/lib/controller/localdb/authorized_key.go b/lib/controller/localdb/authorized_key.go
new file mode 100644 (file)
index 0000000..4d858c8
--- /dev/null
@@ -0,0 +1,59 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "net/http"
+       "strings"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "golang.org/x/crypto/ssh"
+)
+
+// AuthorizedKeyCreate checks that the provided public key is valid,
+// then proxies to railsproxy.
+func (conn *Conn) AuthorizedKeyCreate(ctx context.Context, opts arvados.CreateOptions) (arvados.AuthorizedKey, error) {
+       if err := validateKey(opts.Attrs); err != nil {
+               return arvados.AuthorizedKey{}, httpserver.ErrorWithStatus(err, http.StatusBadRequest)
+       }
+       return conn.railsProxy.AuthorizedKeyCreate(ctx, opts)
+}
+
+// AuthorizedKeyUpdate checks that the provided public key is valid,
+// then proxies to railsproxy.
+func (conn *Conn) AuthorizedKeyUpdate(ctx context.Context, opts arvados.UpdateOptions) (arvados.AuthorizedKey, error) {
+       if err := validateKey(opts.Attrs); err != nil {
+               return arvados.AuthorizedKey{}, httpserver.ErrorWithStatus(err, http.StatusBadRequest)
+       }
+       return conn.railsProxy.AuthorizedKeyUpdate(ctx, opts)
+}
+
+func validateKey(attrs map[string]interface{}) error {
+       in, _ := attrs["public_key"].(string)
+       if in == "" {
+               return nil
+       }
+       in = strings.TrimSpace(in)
+       if strings.IndexAny(in, "\r\n") >= 0 {
+               return errors.New("Public key does not appear to be valid: extra data after key")
+       }
+       pubkey, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(in))
+       if err != nil {
+               return fmt.Errorf("Public key does not appear to be valid: %w", err)
+       }
+       if len(rest) > 0 {
+               return errors.New("Public key does not appear to be valid: extra data after key")
+       }
+       if i := strings.Index(in, " "); i < 0 {
+               return errors.New("Public key does not appear to be valid: no leading type field")
+       } else if in[:i] != pubkey.Type() {
+               return fmt.Errorf("Public key does not appear to be valid: leading type field %q does not match actual key type %q", in[:i], pubkey.Type())
+       }
+       return nil
+}
diff --git a/lib/controller/localdb/authorized_key_test.go b/lib/controller/localdb/authorized_key_test.go
new file mode 100644 (file)
index 0000000..44fa3cf
--- /dev/null
@@ -0,0 +1,114 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       _ "embed"
+       "errors"
+       "io/ioutil"
+       "net/http"
+       "os"
+       "strings"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       . "gopkg.in/check.v1"
+)
+
+var _ = Suite(&authorizedKeySuite{})
+
+type authorizedKeySuite struct {
+       localdbSuite
+}
+
+//go:embed testdata/rsa.pub
+var testPubKey string
+
+func (s *authorizedKeySuite) TestAuthorizedKeyCreate(c *C) {
+       ak, err := s.localdb.AuthorizedKeyCreate(s.userctx, arvados.CreateOptions{
+               Attrs: map[string]interface{}{
+                       "name":     "testkey",
+                       "key_type": "SSH",
+               }})
+       c.Assert(err, IsNil)
+       c.Check(ak.KeyType, Equals, "SSH")
+       defer s.localdb.AuthorizedKeyDelete(s.userctx, arvados.DeleteOptions{UUID: ak.UUID})
+       updated, err := s.localdb.AuthorizedKeyUpdate(s.userctx, arvados.UpdateOptions{
+               UUID:  ak.UUID,
+               Attrs: map[string]interface{}{"name": "testkeyrenamed"}})
+       c.Check(err, IsNil)
+       c.Check(updated.UUID, Equals, ak.UUID)
+       c.Check(updated.Name, Equals, "testkeyrenamed")
+       c.Check(updated.ModifiedByUserUUID, Equals, arvadostest.ActiveUserUUID)
+
+       _, err = s.localdb.AuthorizedKeyCreate(s.userctx, arvados.CreateOptions{
+               Attrs: map[string]interface{}{
+                       "name":       "testkey",
+                       "public_key": "ssh-dsa boguskey\n",
+               }})
+       c.Check(err, ErrorMatches, `Public key does not appear to be valid: ssh: no key found`)
+       _, err = s.localdb.AuthorizedKeyUpdate(s.userctx, arvados.UpdateOptions{
+               UUID: ak.UUID,
+               Attrs: map[string]interface{}{
+                       "public_key": strings.Replace(testPubKey, "A", "#", 1),
+               }})
+       c.Check(err, ErrorMatches, `Public key does not appear to be valid: ssh: no key found`)
+       _, err = s.localdb.AuthorizedKeyUpdate(s.userctx, arvados.UpdateOptions{
+               UUID: ak.UUID,
+               Attrs: map[string]interface{}{
+                       "public_key": testPubKey + testPubKey,
+               }})
+       c.Check(err, ErrorMatches, `Public key does not appear to be valid: extra data after key`)
+       _, err = s.localdb.AuthorizedKeyUpdate(s.userctx, arvados.UpdateOptions{
+               UUID: ak.UUID,
+               Attrs: map[string]interface{}{
+                       "public_key": testPubKey + "# extra data\n",
+               }})
+       c.Check(err, ErrorMatches, `Public key does not appear to be valid: extra data after key`)
+       _, err = s.localdb.AuthorizedKeyUpdate(s.userctx, arvados.UpdateOptions{
+               UUID: ak.UUID,
+               Attrs: map[string]interface{}{
+                       "public_key": strings.Replace(testPubKey, "ssh-rsa", "ssh-dsa", 1),
+               }})
+       c.Check(err, ErrorMatches, `Public key does not appear to be valid: leading type field "ssh-dsa" does not match actual key type "ssh-rsa"`)
+       var se httpserver.HTTPStatusError
+       if c.Check(errors.As(err, &se), Equals, true) {
+               c.Check(se.HTTPStatus(), Equals, http.StatusBadRequest)
+       }
+
+       dirents, err := os.ReadDir("./testdata")
+       c.Assert(err, IsNil)
+       c.Assert(dirents, Not(HasLen), 0)
+       for _, dirent := range dirents {
+               if !strings.HasSuffix(dirent.Name(), ".pub") {
+                       continue
+               }
+               pubkeyfile := "./testdata/" + dirent.Name()
+               c.Logf("checking public key from %s", pubkeyfile)
+               pubkey, err := ioutil.ReadFile(pubkeyfile)
+               if !c.Check(err, IsNil) {
+                       continue
+               }
+               updated, err := s.localdb.AuthorizedKeyUpdate(s.userctx, arvados.UpdateOptions{
+                       UUID: ak.UUID,
+                       Attrs: map[string]interface{}{
+                               "public_key": string(pubkey),
+                       }})
+               c.Check(err, IsNil)
+               c.Check(updated.PublicKey, Equals, string(pubkey))
+
+               _, err = s.localdb.AuthorizedKeyUpdate(s.userctx, arvados.UpdateOptions{
+                       UUID: ak.UUID,
+                       Attrs: map[string]interface{}{
+                               "public_key": strings.Replace(string(pubkey), " ", "-bogus ", 1),
+                       }})
+               c.Check(err, ErrorMatches, `.*type field ".*" does not match actual key type ".*"`)
+       }
+
+       deleted, err := s.localdb.AuthorizedKeyDelete(s.userctx, arvados.DeleteOptions{UUID: ak.UUID})
+       c.Check(err, IsNil)
+       c.Check(deleted.UUID, Equals, ak.UUID)
+}
index 02590b0723165985ab582e95d2aa7a342850ba68..7d1a909a6fdc4c7135caf6e57a235f9a32be927e 100644 (file)
@@ -212,7 +212,7 @@ func (s *CollectionSuite) expectFiles(c *check.C, coll arvados.Collection, expec
        c.Assert(err, check.IsNil)
        kc, err := keepclient.MakeKeepClient(ac)
        c.Assert(err, check.IsNil)
-       cfs, err := coll.FileSystem(arvados.NewClientFromEnv(), kc)
+       cfs, err := coll.FileSystem(client, kc)
        c.Assert(err, check.IsNil)
        var found []string
        nonemptydirs := map[string]bool{}
index 81f257181b0de10acea925d1fd04e500c42abc5f..da2e16e7036667fc62d8b5173f08931dce261507 100644 (file)
@@ -30,13 +30,15 @@ func (conn *Conn) ContainerUpdate(ctx context.Context, opts arvados.UpdateOption
        return resp, err
 }
 
+var containerPriorityUpdateInterval = 5 * time.Minute
+
 // runContainerPriorityUpdateThread periodically (and immediately
 // after each container update request) corrects any inconsistent
 // container priorities caused by races.
 func (conn *Conn) runContainerPriorityUpdateThread(ctx context.Context) {
        ctx = ctrlctx.NewWithToken(ctx, conn.cluster, conn.cluster.SystemRootToken)
        log := ctxlog.FromContext(ctx).WithField("worker", "runContainerPriorityUpdateThread")
-       ticker := time.NewTicker(5 * time.Minute)
+       ticker := time.NewTicker(containerPriorityUpdateInterval)
        for ctx.Err() == nil {
                select {
                case <-ticker.C:
@@ -56,6 +58,10 @@ func (conn *Conn) containerPriorityUpdate(ctx context.Context, log logrus.FieldL
        if err != nil {
                return fmt.Errorf("getdb: %w", err)
        }
+       // Stage 1: Fix containers that have priority>0 but should
+       // have priority=0 because there are no active
+       // container_requests (unfinished, priority>0) associated with
+       // them.
        res, err := db.ExecContext(ctx, `
                UPDATE containers
                SET priority=0
@@ -73,6 +79,16 @@ func (conn *Conn) containerPriorityUpdate(ctx context.Context, log logrus.FieldL
        } else if rows > 0 {
                log.Infof("found %d containers with priority>0 and no active requests, updated to priority=0", rows)
        }
+
+       // Stage 2: Fix containers that have priority=0 but should
+       // have priority>0 because there are active container_requests
+       // (priority>0, unfinished, and not children of cancelled
+       // containers).
+       //
+       // Fixing here means calling out to RailsAPI to compute the
+       // correct priority for the contianer and (if needed)
+       // propagate that change to child containers.
+
        // In this loop we look for a single container that needs
        // fixing, call out to Rails to fix it, and repeat until we
        // don't find any more.
@@ -86,14 +102,14 @@ func (conn *Conn) containerPriorityUpdate(ctx context.Context, log logrus.FieldL
                err := db.QueryRowxContext(ctx, `
                        SELECT containers.uuid from containers
                        JOIN container_requests
-                        ON container_requests.container_uuid=containers.uuid
+                        ON container_requests.container_uuid = containers.uuid
                         AND container_requests.state = 'Committed' AND container_requests.priority > 0
                        LEFT JOIN containers parent
                         ON parent.uuid = container_requests.requesting_container_uuid
                        WHERE containers.state IN ('Queued', 'Locked', 'Running')
                         AND containers.priority = 0
-                        AND container_requests.uuid IS NOT NULL
                         AND (parent.uuid IS NULL OR parent.priority > 0)
+                       ORDER BY containers.created_at
                        LIMIT 1`).Scan(&uuid)
                if err == sql.ErrNoRows {
                        break
index 77c5182e9cd924460b81a39770f647baefb3af19..0b6a630faea3707a2fdf59164cf2f96e04750644 100644 (file)
@@ -19,16 +19,21 @@ import (
        "io/ioutil"
        "net"
        "net/http"
+       "net/http/httputil"
        "net/url"
+       "os"
        "strings"
 
        "git.arvados.org/arvados.git/lib/controller/rpc"
        "git.arvados.org/arvados.git/lib/service"
+       "git.arvados.org/arvados.git/lib/webdavfs"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/auth"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "git.arvados.org/arvados.git/sdk/go/httpserver"
+       keepweb "git.arvados.org/arvados.git/services/keep-web"
        "github.com/hashicorp/yamux"
+       "golang.org/x/net/webdav"
 )
 
 var (
@@ -36,6 +41,298 @@ var (
        forceInternalURLForTest *arvados.URL
 )
 
+// ContainerRequestLog returns a WebDAV handler that reads logs from
+// the indicated container request. It works by proxying the incoming
+// HTTP request to
+//
+//   - the container gateway, if there is an associated container that
+//     is running
+//
+//   - a different controller process, if there is a running container
+//     whose gateway is accessible through a tunnel to a different
+//     controller process
+//
+//   - keep-web, if saved logs exist and there is no gateway (or the
+//     associated container is finished)
+//
+//   - an empty-collection stub, if there is no gateway and no saved
+//     log
+//
+// For an incoming request
+//
+//     GET /arvados/v1/container_requests/{cr_uuid}/log/{c_uuid}{/c_log_path}
+//
+// The upstream request may be to {c_uuid}'s container gateway
+//
+//     GET /arvados/v1/container_requests/{cr_uuid}/log/{c_uuid}{/c_log_path}
+//     X-Webdav-Prefix: /arvados/v1/container_requests/{cr_uuid}/log/{c_uuid}
+//     X-Webdav-Source: /log
+//
+// ...or the upstream request may be to keep-web (where {cr_log_uuid}
+// is the container request log collection UUID)
+//
+//     GET /arvados/v1/container_requests/{cr_uuid}/log/{c_uuid}{/c_log_path}
+//     Host: {cr_log_uuid}.internal
+//     X-Webdav-Prefix: /arvados/v1/container_requests/{cr_uuid}/log
+//     X-Arvados-Container-Uuid: {c_uuid}
+//
+// ...or the request may be handled locally using an empty-collection
+// stub.
+func (conn *Conn) ContainerRequestLog(ctx context.Context, opts arvados.ContainerLogOptions) (http.Handler, error) {
+       if opts.Method == "OPTIONS" && opts.Header.Get("Access-Control-Request-Method") != "" {
+               return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+                       if !keepweb.ServeCORSPreflight(w, opts.Header) {
+                               // Inconceivable.  We already checked
+                               // for the only condition where
+                               // ServeCORSPreflight returns false.
+                               httpserver.Error(w, "unhandled CORS preflight request", http.StatusInternalServerError)
+                       }
+               }), nil
+       }
+       cr, err := conn.railsProxy.ContainerRequestGet(ctx, arvados.GetOptions{UUID: opts.UUID, Select: []string{"uuid", "container_uuid", "log_uuid"}})
+       if err != nil {
+               if se := httpserver.HTTPStatusError(nil); errors.As(err, &se) && se.HTTPStatus() == http.StatusUnauthorized {
+                       // Hint to WebDAV client that we accept HTTP basic auth.
+                       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+                               w.Header().Set("Www-Authenticate", "Basic realm=\"collections\"")
+                               w.WriteHeader(http.StatusUnauthorized)
+                       }), nil
+               }
+               return nil, err
+       }
+       ctr, err := conn.railsProxy.ContainerGet(ctx, arvados.GetOptions{UUID: cr.ContainerUUID, Select: []string{"uuid", "state", "gateway_address"}})
+       if err != nil {
+               return nil, err
+       }
+       // .../log/{ctr.UUID} is a directory where the currently
+       // assigned container's log data [will] appear (as opposed to
+       // previous attempts in .../log/{previous_ctr_uuid}). Requests
+       // that are outside that directory, and requests on a
+       // non-running container, are proxied to keep-web instead of
+       // going through the container gateway system.
+       //
+       // Side note: a depth>1 directory tree listing starting at
+       // .../{cr_uuid}/log will only include subdirectories for
+       // finished containers, i.e., will not include a subdirectory
+       // with log data for a current (unfinished) container UUID.
+       // In order to access live logs, a client must look up the
+       // container_uuid field of the container request record, and
+       // explicitly request a path under .../{cr_uuid}/log/{c_uuid}.
+       if ctr.GatewayAddress == "" ||
+               (ctr.State != arvados.ContainerStateLocked && ctr.State != arvados.ContainerStateRunning) ||
+               !(opts.Path == "/"+ctr.UUID || strings.HasPrefix(opts.Path, "/"+ctr.UUID+"/")) {
+               return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+                       conn.serveContainerRequestLogViaKeepWeb(opts, cr, w, r)
+               }), nil
+       }
+       dial, arpc, err := conn.findGateway(ctx, ctr, opts.NoForward)
+       if err != nil {
+               return nil, err
+       }
+       if arpc != nil {
+               opts.NoForward = true
+               return arpc.ContainerRequestLog(ctx, opts)
+       }
+       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               r = r.WithContext(ctx)
+               var proxyReq *http.Request
+               var proxyErr error
+               var expectRespondAuth string
+               proxy := &httputil.ReverseProxy{
+                       // Our custom Transport:
+                       //
+                       // - Uses a custom dialer to connect to the
+                       // gateway (either directly or through a
+                       // tunnel set up though ContainerTunnel)
+                       //
+                       // - Verifies the gateway's TLS certificate
+                       // using X-Arvados-Authorization headers.
+                       //
+                       // This involves modifying the outgoing
+                       // request header in DialTLSContext.
+                       // (ReverseProxy certainly doesn't expect us
+                       // to do this, but it works.)
+                       Transport: &http.Transport{
+                               DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+                                       tlsconn, requestAuth, respondAuth, err := dial()
+                                       if err != nil {
+                                               return nil, err
+                                       }
+                                       proxyReq.Header.Set("X-Arvados-Authorization", requestAuth)
+                                       expectRespondAuth = respondAuth
+                                       return tlsconn, nil
+                               },
+                       },
+                       Director: func(r *http.Request) {
+                               // Scheme/host of incoming r.URL are
+                               // irrelevant now, and may even be
+                               // missing. Host is ignored by our
+                               // DialTLSContext, but we need a
+                               // generic syntactically correct URL
+                               // for net/http to work with.
+                               r.URL.Scheme = "https"
+                               r.URL.Host = "0.0.0.0:0"
+                               r.Header.Set("X-Arvados-Container-Gateway-Uuid", ctr.UUID)
+                               r.Header.Set("X-Webdav-Prefix", "/arvados/v1/container_requests/"+cr.UUID+"/log/"+ctr.UUID)
+                               r.Header.Set("X-Webdav-Source", "/log")
+                               proxyReq = r
+                       },
+                       ModifyResponse: func(resp *http.Response) error {
+                               if resp.Header.Get("X-Arvados-Authorization-Response") != expectRespondAuth {
+                                       // Note this is how we detect
+                                       // an attacker-in-the-middle.
+                                       return httpserver.ErrorWithStatus(errors.New("bad X-Arvados-Authorization-Response header"), http.StatusBadGateway)
+                               }
+                               resp.Header.Del("X-Arvados-Authorization-Response")
+                               preemptivelyDeduplicateHeaders(w.Header(), resp.Header)
+                               return nil
+                       },
+                       ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+                               proxyErr = err
+                       },
+               }
+               proxy.ServeHTTP(w, r)
+               if proxyErr == nil {
+                       // proxy succeeded
+                       return
+               }
+               // If proxying to the container gateway fails, it
+               // might be caused by a race where crunch-run exited
+               // after we decided (above) the log was not final.
+               // In that case we should proxy to keep-web.
+               ctr, err := conn.railsProxy.ContainerGet(ctx, arvados.GetOptions{
+                       UUID:   ctr.UUID,
+                       Select: []string{"uuid", "state", "gateway_address", "log"},
+               })
+               if err != nil {
+                       // Lost access to the container record?
+                       httpserver.Error(w, "error re-fetching container record: "+err.Error(), http.StatusServiceUnavailable)
+               } else if ctr.State == arvados.ContainerStateLocked || ctr.State == arvados.ContainerStateRunning {
+                       // No race, proxyErr was the best we can do
+                       httpserver.Error(w, "proxy error: "+proxyErr.Error(), http.StatusServiceUnavailable)
+               } else {
+                       conn.serveContainerRequestLogViaKeepWeb(opts, cr, w, r)
+               }
+       }), nil
+}
+
+// serveContainerLogViaKeepWeb handles a request for saved container
+// log content by proxying to one of the configured keep-web servers.
+//
+// It tries to choose a keep-web server that is running on this host.
+func (conn *Conn) serveContainerRequestLogViaKeepWeb(opts arvados.ContainerLogOptions, cr arvados.ContainerRequest, w http.ResponseWriter, r *http.Request) {
+       if cr.LogUUID == "" {
+               // Special case: if no log data exists yet, we serve
+               // an empty collection by ourselves instead of
+               // proxying to keep-web.
+               conn.serveEmptyDir("/arvados/v1/container_requests/"+cr.UUID+"/log", w, r)
+               return
+       }
+       myURL, _ := service.URLFromContext(r.Context())
+       u := url.URL(myURL)
+       myHostname := u.Hostname()
+       var webdavBase arvados.URL
+       var ok bool
+       for webdavBase = range conn.cluster.Services.WebDAV.InternalURLs {
+               ok = true
+               u := url.URL(webdavBase)
+               if h := u.Hostname(); h == "127.0.0.1" || h == "0.0.0.0" || h == "::1" || h == myHostname {
+                       // Prefer a keep-web service running on the
+                       // same host as us. (If we don't find one, we
+                       // pick one arbitrarily.)
+                       break
+               }
+       }
+       if !ok {
+               httpserver.Error(w, "no internalURLs configured for WebDAV service", http.StatusInternalServerError)
+               return
+       }
+       proxy := &httputil.ReverseProxy{
+               Director: func(r *http.Request) {
+                       r.URL.Scheme = webdavBase.Scheme
+                       r.URL.Host = webdavBase.Host
+                       // Outgoing Host header specifies the
+                       // collection ID.
+                       r.Host = cr.LogUUID + ".internal"
+                       // We already checked permission on the
+                       // container, so we can use a root token here
+                       // instead of counting on the "access to log
+                       // via container request and container"
+                       // permission check, which can be racy when a
+                       // request gets retried with a new container.
+                       r.Header.Set("Authorization", "Bearer "+conn.cluster.SystemRootToken)
+                       // We can't change r.URL.Path without
+                       // confusing WebDAV (request body and response
+                       // headers refer to the same paths) so we tell
+                       // keep-web to map the log collection onto the
+                       // containers/X/log/ namespace.
+                       r.Header.Set("X-Webdav-Prefix", "/arvados/v1/container_requests/"+cr.UUID+"/log")
+                       if len(opts.Path) >= 28 && opts.Path[6:13] == "-dz642-" {
+                               // "/arvados/v1/container_requests/{crUUID}/log/{cUUID}..."
+                               // proxies to
+                               // "/log for container {cUUID}..."
+                               r.Header.Set("X-Webdav-Prefix", "/arvados/v1/container_requests/"+cr.UUID+"/log/"+opts.Path[1:28])
+                               r.Header.Set("X-Webdav-Source", "/log for container "+opts.Path[1:28]+"/")
+                       }
+               },
+               ModifyResponse: func(resp *http.Response) error {
+                       preemptivelyDeduplicateHeaders(w.Header(), resp.Header)
+                       return nil
+               },
+       }
+       if conn.cluster.TLS.Insecure {
+               proxy.Transport = &http.Transport{
+                       TLSClientConfig: &tls.Config{
+                               InsecureSkipVerify: conn.cluster.TLS.Insecure,
+                       },
+               }
+       }
+       proxy.ServeHTTP(w, r)
+}
+
+// httputil.ReverseProxy uses (http.Header)Add() to copy headers from
+// the upstream Response to the downstream ResponseWriter. If headers
+// have already been set on the downstream ResponseWriter, Add() will
+// result in duplicate headers. For example, if we set CORS headers
+// and then use ReverseProxy with an upstream that also sets CORS
+// headers, our client will receive
+//
+//     Access-Control-Allow-Origin: *
+//     Access-Control-Allow-Origin: *
+//
+// ...which is incorrect.
+//
+// preemptivelyDeduplicateHeaders, when called from a ModifyResponse
+// hook, solves this by removing any conflicting headers from
+// ResponseWriter. This way, when ReverseProxy calls Add(), it will
+// assign the new values without causing duplicates.
+//
+// dst is the downstream ResponseWriter's Header(). src is the
+// upstream resp.Header.
+func preemptivelyDeduplicateHeaders(dst, src http.Header) {
+       for hdr := range src {
+               dst.Del(hdr)
+       }
+}
+
+// serveEmptyDir handles read-only webdav requests as if there was an
+// empty collection rooted at the given path. It's equivalent to
+// proxying to an empty collection in keep-web, but avoids the extra
+// hop.
+func (conn *Conn) serveEmptyDir(path string, w http.ResponseWriter, r *http.Request) {
+       wh := webdav.Handler{
+               Prefix:     path,
+               FileSystem: webdav.NewMemFS(),
+               LockSystem: webdavfs.NoLockSystem,
+               Logger: func(r *http.Request, err error) {
+                       if err != nil && !os.IsNotExist(err) {
+                               ctxlog.FromContext(r.Context()).WithError(err).Info("webdav error on empty collection fs")
+                       }
+               },
+       }
+       wh.ServeHTTP(w, r)
+}
+
 // ContainerSSH returns a connection to the SSH server in the
 // appropriate crunch-run process on the worker node where the
 // specified container is running.
@@ -47,7 +344,7 @@ func (conn *Conn) ContainerSSH(ctx context.Context, opts arvados.ContainerSSHOpt
        if err != nil {
                return sshconn, err
        }
-       ctr, err := conn.railsProxy.ContainerGet(ctx, arvados.GetOptions{UUID: opts.UUID})
+       ctr, err := conn.railsProxy.ContainerGet(ctx, arvados.GetOptions{UUID: opts.UUID, Select: []string{"uuid", "state", "gateway_address", "interactive_session_started"}})
        if err != nil {
                return sshconn, err
        }
@@ -70,138 +367,36 @@ func (conn *Conn) ContainerSSH(ctx context.Context, opts arvados.ContainerSSHOpt
                }
        }
 
-       conn.gwTunnelsLock.Lock()
-       tunnel := conn.gwTunnels[opts.UUID]
-       conn.gwTunnelsLock.Unlock()
-
        if ctr.State == arvados.ContainerStateQueued || ctr.State == arvados.ContainerStateLocked {
                return sshconn, httpserver.ErrorWithStatus(fmt.Errorf("container is not running yet (state is %q)", ctr.State), http.StatusServiceUnavailable)
        } else if ctr.State != arvados.ContainerStateRunning {
                return sshconn, httpserver.ErrorWithStatus(fmt.Errorf("container has ended (state is %q)", ctr.State), http.StatusGone)
        }
 
-       // targetHost is the value we'll use in the Host header in our
-       // "Upgrade: ssh" http request. It's just a placeholder
-       // "localhost", unless we decide to connect directly, in which
-       // case we'll set it to the gateway's external ip:host. (The
-       // gateway doesn't even look at it, but we might as well.)
-       targetHost := "localhost"
-       myURL, _ := service.URLFromContext(ctx)
-
-       var rawconn net.Conn
-       if host, _, splitErr := net.SplitHostPort(ctr.GatewayAddress); splitErr == nil && host != "" && host != "127.0.0.1" {
-               // If crunch-run provided a GatewayAddress like
-               // "ipaddr:port", that means "ipaddr" is one of the
-               // external interfaces where the gateway is
-               // listening. In that case, it's the most
-               // reliable/direct option, so we use it even if a
-               // tunnel might also be available.
-               targetHost = ctr.GatewayAddress
-               rawconn, err = net.Dial("tcp", ctr.GatewayAddress)
-               if err != nil {
-                       return sshconn, httpserver.ErrorWithStatus(err, http.StatusServiceUnavailable)
-               }
-       } else if tunnel != nil && !(forceProxyForTest && !opts.NoForward) {
-               // If we can't connect directly, and the gateway has
-               // established a yamux tunnel with us, connect through
-               // the tunnel.
-               //
-               // ...except: forceProxyForTest means we are emulating
-               // a situation where the gateway has established a
-               // yamux tunnel with controller B, and the
-               // ContainerSSH request arrives at controller A. If
-               // opts.NoForward==false then we are acting as A, so
-               // we pretend not to have a tunnel, and fall through
-               // to the "tunurl" case below. If opts.NoForward==true
-               // then the client is A and we are acting as B, so we
-               // connect to our tunnel.
-               rawconn, err = tunnel.Open()
-               if err != nil {
-                       return sshconn, httpserver.ErrorWithStatus(err, http.StatusServiceUnavailable)
-               }
-       } else if ctr.GatewayAddress == "" {
-               return sshconn, httpserver.ErrorWithStatus(errors.New("container is running but gateway is not available"), http.StatusServiceUnavailable)
-       } else if tunurl := strings.TrimPrefix(ctr.GatewayAddress, "tunnel "); tunurl != ctr.GatewayAddress &&
-               tunurl != "" &&
-               tunurl != myURL.String() &&
-               !opts.NoForward {
-               // If crunch-run provided a GatewayAddress like
-               // "tunnel https://10.0.0.10:1010/", that means the
-               // gateway has established a yamux tunnel with the
-               // controller process at the indicated InternalURL
-               // (which isn't us, otherwise we would have had
-               // "tunnel != nil" above). We need to proxy through to
-               // the other controller process in order to use the
-               // tunnel.
-               for u := range conn.cluster.Services.Controller.InternalURLs {
-                       if u.String() == tunurl {
-                               ctxlog.FromContext(ctx).Debugf("proxying ContainerSSH request to other controller at %s", u)
-                               u := url.URL(u)
-                               arpc := rpc.NewConn(conn.cluster.ClusterID, &u, conn.cluster.TLS.Insecure, rpc.PassthroughTokenProvider)
-                               opts.NoForward = true
-                               return arpc.ContainerSSH(ctx, opts)
-                       }
-               }
-               ctxlog.FromContext(ctx).Warnf("container gateway provided a tunnel endpoint %s that is not one of Services.Controller.InternalURLs", tunurl)
-               return sshconn, httpserver.ErrorWithStatus(errors.New("container gateway is running but tunnel endpoint is invalid"), http.StatusServiceUnavailable)
-       } else {
-               return sshconn, httpserver.ErrorWithStatus(errors.New("container gateway is running but tunnel is down"), http.StatusServiceUnavailable)
+       dial, arpc, err := conn.findGateway(ctx, ctr, opts.NoForward)
+       if err != nil {
+               return sshconn, err
+       }
+       if arpc != nil {
+               opts.NoForward = true
+               return arpc.ContainerSSH(ctx, opts)
        }
 
-       // crunch-run uses a self-signed / unverifiable TLS
-       // certificate, so we use the following scheme to ensure we're
-       // not talking to a MITM.
-       //
-       // 1. Compute ctrKey = HMAC-SHA256(sysRootToken,ctrUUID) --
-       // this will be the same ctrKey that a-d-c supplied to
-       // crunch-run in the GatewayAuthSecret env var.
-       //
-       // 2. Compute requestAuth = HMAC-SHA256(ctrKey,serverCert) and
-       // send it to crunch-run as the X-Arvados-Authorization
-       // header, proving that we know ctrKey. (Note a MITM cannot
-       // replay the proof to a real crunch-run server, because the
-       // real crunch-run server would have a different cert.)
-       //
-       // 3. Compute respondAuth = HMAC-SHA256(ctrKey,requestAuth)
-       // and ensure the server returns it in the
-       // X-Arvados-Authorization-Response header, proving that the
-       // server knows ctrKey.
-       var requestAuth, respondAuth string
-       tlsconn := tls.Client(rawconn, &tls.Config{
-               InsecureSkipVerify: true,
-               VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
-                       if len(rawCerts) == 0 {
-                               return errors.New("no certificate received, cannot compute authorization header")
-                       }
-                       h := hmac.New(sha256.New, []byte(conn.cluster.SystemRootToken))
-                       fmt.Fprint(h, opts.UUID)
-                       authKey := fmt.Sprintf("%x", h.Sum(nil))
-                       h = hmac.New(sha256.New, []byte(authKey))
-                       h.Write(rawCerts[0])
-                       requestAuth = fmt.Sprintf("%x", h.Sum(nil))
-                       h.Reset()
-                       h.Write([]byte(requestAuth))
-                       respondAuth = fmt.Sprintf("%x", h.Sum(nil))
-                       return nil
-               },
-       })
-       err = tlsconn.HandshakeContext(ctx)
+       tlsconn, requestAuth, respondAuth, err := dial()
        if err != nil {
-               return sshconn, httpserver.ErrorWithStatus(fmt.Errorf("TLS handshake failed: %w", err), http.StatusBadGateway)
-       }
-       if respondAuth == "" {
-               tlsconn.Close()
-               return sshconn, httpserver.ErrorWithStatus(errors.New("BUG: no respondAuth"), http.StatusInternalServerError)
+               return sshconn, err
        }
        bufr := bufio.NewReader(tlsconn)
        bufw := bufio.NewWriter(tlsconn)
 
        u := url.URL{
                Scheme: "http",
-               Host:   targetHost,
+               Host:   tlsconn.RemoteAddr().String(),
                Path:   "/ssh",
        }
        postform := url.Values{
+               // uuid is only needed for older crunch-run versions
+               // (current version uses X-Arvados-* header below)
                "uuid":           {opts.UUID},
                "detach_keys":    {opts.DetachKeys},
                "login_username": {opts.LoginUsername},
@@ -211,6 +406,7 @@ func (conn *Conn) ContainerSSH(ctx context.Context, opts arvados.ContainerSSHOpt
        bufw.WriteString("POST " + u.String() + " HTTP/1.1\r\n")
        bufw.WriteString("Host: " + u.Host + "\r\n")
        bufw.WriteString("Upgrade: ssh\r\n")
+       bufw.WriteString("X-Arvados-Container-Gateway-Uuid: " + opts.UUID + "\r\n")
        bufw.WriteString("X-Arvados-Authorization: " + requestAuth + "\r\n")
        bufw.WriteString("Content-Type: application/x-www-form-urlencoded\r\n")
        fmt.Fprintf(bufw, "Content-Length: %d\r\n", len(postdata))
@@ -308,3 +504,137 @@ func (conn *Conn) ContainerGatewayTunnel(ctx context.Context, opts arvados.Conta
        }
        return
 }
+
+type gatewayDialer func() (conn net.Conn, requestAuth, respondAuth string, err error)
+
+// findGateway figures out how to connect to ctr's gateway.
+//
+// If the gateway can be contacted directly or through a tunnel on
+// this instance, the first return value is a non-nil dialer.
+//
+// If the gateway is only accessible through a tunnel through a
+// different controller process, the second return value is a non-nil
+// *rpc.Conn for that controller.
+func (conn *Conn) findGateway(ctx context.Context, ctr arvados.Container, noForward bool) (gatewayDialer, *rpc.Conn, error) {
+       conn.gwTunnelsLock.Lock()
+       tunnel := conn.gwTunnels[ctr.UUID]
+       conn.gwTunnelsLock.Unlock()
+
+       myURL, _ := service.URLFromContext(ctx)
+
+       if host, _, splitErr := net.SplitHostPort(ctr.GatewayAddress); splitErr == nil && host != "" && host != "127.0.0.1" {
+               // If crunch-run provided a GatewayAddress like
+               // "ipaddr:port", that means "ipaddr" is one of the
+               // external interfaces where the gateway is
+               // listening. In that case, it's the most
+               // reliable/direct option, so we use it even if a
+               // tunnel might also be available.
+               return func() (net.Conn, string, string, error) {
+                       rawconn, err := (&net.Dialer{}).DialContext(ctx, "tcp", ctr.GatewayAddress)
+                       if err != nil {
+                               return nil, "", "", httpserver.ErrorWithStatus(err, http.StatusServiceUnavailable)
+                       }
+                       return conn.dialGatewayTLS(ctx, ctr, rawconn)
+               }, nil, nil
+       }
+       if tunnel != nil && !(forceProxyForTest && !noForward) {
+               // If we can't connect directly, and the gateway has
+               // established a yamux tunnel with us, connect through
+               // the tunnel.
+               //
+               // ...except: forceProxyForTest means we are emulating
+               // a situation where the gateway has established a
+               // yamux tunnel with controller B, and the
+               // ContainerSSH request arrives at controller A. If
+               // noForward==false then we are acting as A, so
+               // we pretend not to have a tunnel, and fall through
+               // to the "tunurl" case below. If noForward==true
+               // then the client is A and we are acting as B, so we
+               // connect to our tunnel.
+               return func() (net.Conn, string, string, error) {
+                       rawconn, err := tunnel.Open()
+                       if err != nil {
+                               return nil, "", "", httpserver.ErrorWithStatus(err, http.StatusServiceUnavailable)
+                       }
+                       return conn.dialGatewayTLS(ctx, ctr, rawconn)
+               }, nil, nil
+       }
+       if tunurl := strings.TrimPrefix(ctr.GatewayAddress, "tunnel "); tunurl != ctr.GatewayAddress &&
+               tunurl != "" &&
+               tunurl != myURL.String() &&
+               !noForward {
+               // If crunch-run provided a GatewayAddress like
+               // "tunnel https://10.0.0.10:1010/", that means the
+               // gateway has established a yamux tunnel with the
+               // controller process at the indicated InternalURL
+               // (which isn't us, otherwise we would have had
+               // "tunnel != nil" above). We need to proxy through to
+               // the other controller process in order to use the
+               // tunnel.
+               for u := range conn.cluster.Services.Controller.InternalURLs {
+                       if u.String() == tunurl {
+                               ctxlog.FromContext(ctx).Debugf("connecting to container gateway through other controller at %s", u)
+                               u := url.URL(u)
+                               return nil, rpc.NewConn(conn.cluster.ClusterID, &u, conn.cluster.TLS.Insecure, rpc.PassthroughTokenProvider), nil
+                       }
+               }
+               ctxlog.FromContext(ctx).Warnf("container gateway provided a tunnel endpoint %s that is not one of Services.Controller.InternalURLs", tunurl)
+               return nil, nil, httpserver.ErrorWithStatus(errors.New("container gateway is running but tunnel endpoint is invalid"), http.StatusServiceUnavailable)
+       }
+       if ctr.GatewayAddress == "" {
+               return nil, nil, httpserver.ErrorWithStatus(errors.New("container is running but gateway is not available"), http.StatusServiceUnavailable)
+       } else {
+               return nil, nil, httpserver.ErrorWithStatus(errors.New("container is running but tunnel is down"), http.StatusServiceUnavailable)
+       }
+}
+
+// dialGatewayTLS negotiates a TLS connection to a container gateway
+// over the given raw connection.
+func (conn *Conn) dialGatewayTLS(ctx context.Context, ctr arvados.Container, rawconn net.Conn) (*tls.Conn, string, string, error) {
+       // crunch-run uses a self-signed / unverifiable TLS
+       // certificate, so we use the following scheme to ensure we're
+       // not talking to an attacker-in-the-middle.
+       //
+       // 1. Compute ctrKey = HMAC-SHA256(sysRootToken,ctrUUID) --
+       // this will be the same ctrKey that a-d-c supplied to
+       // crunch-run in the GatewayAuthSecret env var.
+       //
+       // 2. Compute requestAuth = HMAC-SHA256(ctrKey,serverCert) and
+       // send it to crunch-run as the X-Arvados-Authorization
+       // header, proving that we know ctrKey. (Note a MITM cannot
+       // replay the proof to a real crunch-run server, because the
+       // real crunch-run server would have a different cert.)
+       //
+       // 3. Compute respondAuth = HMAC-SHA256(ctrKey,requestAuth)
+       // and ensure the server returns it in the
+       // X-Arvados-Authorization-Response header, proving that the
+       // server knows ctrKey.
+       var requestAuth, respondAuth string
+       tlsconn := tls.Client(rawconn, &tls.Config{
+               InsecureSkipVerify: true,
+               VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
+                       if len(rawCerts) == 0 {
+                               return errors.New("no certificate received, cannot compute authorization header")
+                       }
+                       h := hmac.New(sha256.New, []byte(conn.cluster.SystemRootToken))
+                       fmt.Fprint(h, ctr.UUID)
+                       authKey := fmt.Sprintf("%x", h.Sum(nil))
+                       h = hmac.New(sha256.New, []byte(authKey))
+                       h.Write(rawCerts[0])
+                       requestAuth = fmt.Sprintf("%x", h.Sum(nil))
+                       h.Reset()
+                       h.Write([]byte(requestAuth))
+                       respondAuth = fmt.Sprintf("%x", h.Sum(nil))
+                       return nil
+               },
+       })
+       err := tlsconn.HandshakeContext(ctx)
+       if err != nil {
+               return nil, "", "", httpserver.ErrorWithStatus(fmt.Errorf("TLS handshake failed: %w", err), http.StatusBadGateway)
+       }
+       if respondAuth == "" {
+               tlsconn.Close()
+               return nil, "", "", httpserver.ErrorWithStatus(errors.New("BUG: no respondAuth"), http.StatusInternalServerError)
+       }
+       return tlsconn, requestAuth, respondAuth, nil
+}
index ca5e32d071582d1b2d4ce77b86a06dba4aca6438..0c58a9192c1c38327b14d17aadfee9d0c0e8ccc2 100644 (file)
@@ -5,14 +5,20 @@
 package localdb
 
 import (
+       "bytes"
+       "context"
        "crypto/hmac"
        "crypto/sha256"
        "fmt"
        "io"
        "io/ioutil"
        "net"
+       "net/http"
        "net/http/httptest"
        "net/url"
+       "os"
+       "os/exec"
+       "path/filepath"
        "strings"
        "time"
 
@@ -21,8 +27,12 @@ import (
        "git.arvados.org/arvados.git/lib/crunchrun"
        "git.arvados.org/arvados.git/lib/ctrlctx"
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/auth"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "git.arvados.org/arvados.git/sdk/go/keepclient"
        "golang.org/x/crypto/ssh"
        check "gopkg.in/check.v1"
 )
@@ -31,29 +41,53 @@ var _ = check.Suite(&ContainerGatewaySuite{})
 
 type ContainerGatewaySuite struct {
        localdbSuite
+       reqUUID string
        ctrUUID string
+       srv     *httptest.Server
        gw      *crunchrun.Gateway
 }
 
 func (s *ContainerGatewaySuite) SetUpTest(c *check.C) {
        s.localdbSuite.SetUpTest(c)
 
-       s.ctrUUID = arvadostest.QueuedContainerUUID
+       cr, err := s.localdb.ContainerRequestCreate(s.userctx, arvados.CreateOptions{
+               Attrs: map[string]interface{}{
+                       "command":             []string{"echo", time.Now().Format(time.RFC3339Nano)},
+                       "container_count_max": 1,
+                       "container_image":     "arvados/apitestfixture:latest",
+                       "cwd":                 "/tmp",
+                       "environment":         map[string]string{},
+                       "output_path":         "/out",
+                       "priority":            1,
+                       "state":               arvados.ContainerRequestStateCommitted,
+                       "mounts": map[string]interface{}{
+                               "/out": map[string]interface{}{
+                                       "kind":     "tmp",
+                                       "capacity": 1000000,
+                               },
+                       },
+                       "runtime_constraints": map[string]interface{}{
+                               "vcpus": 1,
+                               "ram":   2,
+                       }}})
+       c.Assert(err, check.IsNil)
+       s.reqUUID = cr.UUID
+       s.ctrUUID = cr.ContainerUUID
 
        h := hmac.New(sha256.New, []byte(s.cluster.SystemRootToken))
        fmt.Fprint(h, s.ctrUUID)
        authKey := fmt.Sprintf("%x", h.Sum(nil))
 
        rtr := router.New(s.localdb, router.Config{})
-       srv := httptest.NewUnstartedServer(rtr)
-       srv.StartTLS()
+       s.srv = httptest.NewUnstartedServer(httpserver.AddRequestIDs(httpserver.LogRequests(rtr)))
+       s.srv.StartTLS()
        // the test setup doesn't use lib/service so
        // service.URLFromContext() returns nothing -- instead, this
        // is how we advertise our internal URL and enable
        // proxy-to-other-controller mode,
-       forceInternalURLForTest = &arvados.URL{Scheme: "https", Host: srv.Listener.Addr().String()}
+       forceInternalURLForTest = &arvados.URL{Scheme: "https", Host: s.srv.Listener.Addr().String()}
        ac := &arvados.Client{
-               APIHost:   srv.Listener.Addr().String(),
+               APIHost:   s.srv.Listener.Addr().String(),
                AuthToken: arvadostest.Dispatch1Token,
                Insecure:  true,
        }
@@ -66,15 +100,14 @@ func (s *ContainerGatewaySuite) SetUpTest(c *check.C) {
                ArvadosClient: ac,
        }
        c.Assert(s.gw.Start(), check.IsNil)
+
        rootctx := ctrlctx.NewWithToken(s.ctx, s.cluster, s.cluster.SystemRootToken)
-       // OK if this line fails (because state is already Running
-       // from a previous test case) as long as the following line
-       // succeeds:
-       s.localdb.ContainerUpdate(rootctx, arvados.UpdateOptions{
+       _, err = s.localdb.ContainerUpdate(rootctx, arvados.UpdateOptions{
                UUID: s.ctrUUID,
                Attrs: map[string]interface{}{
                        "state": arvados.ContainerStateLocked}})
-       _, err := s.localdb.ContainerUpdate(rootctx, arvados.UpdateOptions{
+       c.Assert(err, check.IsNil)
+       _, err = s.localdb.ContainerUpdate(rootctx, arvados.UpdateOptions{
                UUID: s.ctrUUID,
                Attrs: map[string]interface{}{
                        "state":           arvados.ContainerStateRunning,
@@ -87,6 +120,11 @@ func (s *ContainerGatewaySuite) SetUpTest(c *check.C) {
        c.Check(err, check.IsNil)
 }
 
+func (s *ContainerGatewaySuite) TearDownTest(c *check.C) {
+       s.srv.Close()
+       s.localdbSuite.TearDownTest(c)
+}
+
 func (s *ContainerGatewaySuite) TestConfig(c *check.C) {
        for _, trial := range []struct {
                configAdmin bool
@@ -188,6 +226,299 @@ func (s *ContainerGatewaySuite) TestDirectTCP(c *check.C) {
        }
 }
 
+func (s *ContainerGatewaySuite) setupLogCollection(c *check.C) {
+       files := map[string]string{
+               "stderr.txt":   "hello world\n",
+               "a/b/c/d.html": "<html></html>\n",
+       }
+       client := arvados.NewClientFromEnv()
+       ac, err := arvadosclient.New(client)
+       c.Assert(err, check.IsNil)
+       kc, err := keepclient.MakeKeepClient(ac)
+       c.Assert(err, check.IsNil)
+       cfs, err := (&arvados.Collection{}).FileSystem(client, kc)
+       c.Assert(err, check.IsNil)
+       for name, content := range files {
+               for i, ch := range name {
+                       if ch == '/' {
+                               err := cfs.Mkdir("/"+name[:i], 0777)
+                               c.Assert(err, check.IsNil)
+                       }
+               }
+               f, err := cfs.OpenFile("/"+name, os.O_CREATE|os.O_WRONLY, 0777)
+               c.Assert(err, check.IsNil)
+               f.Write([]byte(content))
+               err = f.Close()
+               c.Assert(err, check.IsNil)
+       }
+       cfs.Sync()
+       s.gw.LogCollection = cfs
+}
+
+func (s *ContainerGatewaySuite) saveLogAndCloseGateway(c *check.C) {
+       rootctx := ctrlctx.NewWithToken(s.ctx, s.cluster, s.cluster.SystemRootToken)
+       txt, err := s.gw.LogCollection.MarshalManifest(".")
+       c.Assert(err, check.IsNil)
+       coll, err := s.localdb.CollectionCreate(rootctx, arvados.CreateOptions{
+               Attrs: map[string]interface{}{
+                       "manifest_text": txt,
+               }})
+       c.Assert(err, check.IsNil)
+       _, err = s.localdb.ContainerUpdate(rootctx, arvados.UpdateOptions{
+               UUID: s.ctrUUID,
+               Attrs: map[string]interface{}{
+                       "state":     arvados.ContainerStateComplete,
+                       "exit_code": 0,
+                       "log":       coll.PortableDataHash,
+               }})
+       c.Assert(err, check.IsNil)
+       updatedReq, err := s.localdb.ContainerRequestGet(rootctx, arvados.GetOptions{UUID: s.reqUUID})
+       c.Assert(err, check.IsNil)
+       c.Logf("container request log UUID is %s", updatedReq.LogUUID)
+       crLog, err := s.localdb.CollectionGet(rootctx, arvados.GetOptions{UUID: updatedReq.LogUUID, Select: []string{"manifest_text"}})
+       c.Assert(err, check.IsNil)
+       c.Logf("collection log manifest:\n%s", crLog.ManifestText)
+       // Ensure localdb can't circumvent the keep-web proxy test by
+       // getting content from the container gateway.
+       s.gw.LogCollection = nil
+}
+
+func (s *ContainerGatewaySuite) TestContainerRequestLogViaTunnel(c *check.C) {
+       forceProxyForTest = true
+       defer func() { forceProxyForTest = false }()
+
+       s.gw = s.setupGatewayWithTunnel(c)
+       s.setupLogCollection(c)
+
+       for _, broken := range []bool{false, true} {
+               c.Logf("broken=%v", broken)
+
+               if broken {
+                       delete(s.cluster.Services.Controller.InternalURLs, *forceInternalURLForTest)
+               } else {
+                       s.cluster.Services.Controller.InternalURLs[*forceInternalURLForTest] = arvados.ServiceInstance{}
+                       defer delete(s.cluster.Services.Controller.InternalURLs, *forceInternalURLForTest)
+               }
+
+               r, err := http.NewRequestWithContext(s.userctx, "GET", "https://controller.example/arvados/v1/container_requests/"+s.reqUUID+"/log/"+s.ctrUUID+"/stderr.txt", nil)
+               c.Assert(err, check.IsNil)
+               r.Header.Set("Authorization", "Bearer "+arvadostest.ActiveTokenV2)
+               handler, err := s.localdb.ContainerRequestLog(s.userctx, arvados.ContainerLogOptions{
+                       UUID: s.reqUUID,
+                       WebDAVOptions: arvados.WebDAVOptions{
+                               Method: "GET",
+                               Header: r.Header,
+                               Path:   "/" + s.ctrUUID + "/stderr.txt",
+                       },
+               })
+               if broken {
+                       c.Check(err, check.ErrorMatches, `.*tunnel endpoint is invalid.*`)
+                       continue
+               }
+               c.Check(err, check.IsNil)
+               c.Assert(handler, check.NotNil)
+               rec := httptest.NewRecorder()
+               handler.ServeHTTP(rec, r)
+               resp := rec.Result()
+               c.Check(resp.StatusCode, check.Equals, http.StatusOK)
+               buf, err := ioutil.ReadAll(resp.Body)
+               c.Check(err, check.IsNil)
+               c.Check(string(buf), check.Equals, "hello world\n")
+       }
+}
+
+func (s *ContainerGatewaySuite) TestContainerRequestLogViaGateway(c *check.C) {
+       s.setupLogCollection(c)
+       s.testContainerRequestLog(c)
+}
+
+func (s *ContainerGatewaySuite) TestContainerRequestLogViaKeepWeb(c *check.C) {
+       s.setupLogCollection(c)
+       s.saveLogAndCloseGateway(c)
+       s.testContainerRequestLog(c)
+}
+
+func (s *ContainerGatewaySuite) testContainerRequestLog(c *check.C) {
+       for _, trial := range []struct {
+               method          string
+               path            string
+               header          http.Header
+               unauthenticated bool
+               expectStatus    int
+               expectBodyRe    string
+               expectHeader    http.Header
+       }{
+               {
+                       method:       "GET",
+                       path:         s.ctrUUID + "/stderr.txt",
+                       expectStatus: http.StatusOK,
+                       expectBodyRe: "hello world\n",
+                       expectHeader: http.Header{
+                               "Content-Type": {"text/plain; charset=utf-8"},
+                       },
+               },
+               {
+                       method: "GET",
+                       path:   s.ctrUUID + "/stderr.txt",
+                       header: http.Header{
+                               "Range": {"bytes=-6"},
+                       },
+                       expectStatus: http.StatusPartialContent,
+                       expectBodyRe: "world\n",
+                       expectHeader: http.Header{
+                               "Content-Type":  {"text/plain; charset=utf-8"},
+                               "Content-Range": {"bytes 6-11/12"},
+                       },
+               },
+               {
+                       method:       "OPTIONS",
+                       path:         s.ctrUUID + "/stderr.txt",
+                       expectStatus: http.StatusOK,
+                       expectBodyRe: "",
+                       expectHeader: http.Header{
+                               "Dav":   {"1, 2"},
+                               "Allow": {"OPTIONS, LOCK, GET, HEAD, POST, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND, PUT"},
+                       },
+               },
+               {
+                       method:          "OPTIONS",
+                       path:            s.ctrUUID + "/stderr.txt",
+                       unauthenticated: true,
+                       header: http.Header{
+                               "Access-Control-Request-Method": {"POST"},
+                       },
+                       expectStatus: http.StatusOK,
+                       expectBodyRe: "",
+                       expectHeader: http.Header{
+                               "Access-Control-Allow-Headers": {"Authorization, Content-Type, Range, Depth, Destination, If, Lock-Token, Overwrite, Timeout, Cache-Control"},
+                               "Access-Control-Allow-Methods": {"COPY, DELETE, GET, LOCK, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PROPPATCH, PUT, RMCOL, UNLOCK"},
+                               "Access-Control-Allow-Origin":  {"*"},
+                               "Access-Control-Max-Age":       {"86400"},
+                       },
+               },
+               {
+                       method:       "PROPFIND",
+                       path:         s.ctrUUID + "/",
+                       expectStatus: http.StatusMultiStatus,
+                       expectBodyRe: `.*\Q<D:displayname>stderr.txt</D:displayname>\E.*>\n?`,
+                       expectHeader: http.Header{
+                               "Content-Type": {"text/xml; charset=utf-8"},
+                       },
+               },
+               {
+                       method:       "PROPFIND",
+                       path:         s.ctrUUID,
+                       expectStatus: http.StatusMultiStatus,
+                       expectBodyRe: `.*\Q<D:displayname>stderr.txt</D:displayname>\E.*>\n?`,
+                       expectHeader: http.Header{
+                               "Content-Type": {"text/xml; charset=utf-8"},
+                       },
+               },
+               {
+                       method:       "PROPFIND",
+                       path:         s.ctrUUID + "/a/b/c/",
+                       expectStatus: http.StatusMultiStatus,
+                       expectBodyRe: `.*\Q<D:displayname>d.html</D:displayname>\E.*>\n?`,
+                       expectHeader: http.Header{
+                               "Content-Type": {"text/xml; charset=utf-8"},
+                       },
+               },
+               {
+                       method:       "GET",
+                       path:         s.ctrUUID + "/a/b/c/d.html",
+                       expectStatus: http.StatusOK,
+                       expectBodyRe: "<html></html>\n",
+                       expectHeader: http.Header{
+                               "Content-Type": {"text/html; charset=utf-8"},
+                       },
+               },
+       } {
+               c.Logf("trial %#v", trial)
+               ctx := s.userctx
+               if trial.unauthenticated {
+                       ctx = auth.NewContext(context.Background(), auth.CredentialsFromRequest(&http.Request{URL: &url.URL{}, Header: http.Header{}}))
+               }
+               r, err := http.NewRequestWithContext(ctx, trial.method, "https://controller.example/arvados/v1/container_requests/"+s.reqUUID+"/log/"+trial.path, nil)
+               c.Assert(err, check.IsNil)
+               for k := range trial.header {
+                       r.Header.Set(k, trial.header.Get(k))
+               }
+               handler, err := s.localdb.ContainerRequestLog(ctx, arvados.ContainerLogOptions{
+                       UUID: s.reqUUID,
+                       WebDAVOptions: arvados.WebDAVOptions{
+                               Method: trial.method,
+                               Header: r.Header,
+                               Path:   "/" + trial.path,
+                       },
+               })
+               c.Assert(err, check.IsNil)
+               c.Assert(handler, check.NotNil)
+               rec := httptest.NewRecorder()
+               handler.ServeHTTP(rec, r)
+               resp := rec.Result()
+               c.Check(resp.StatusCode, check.Equals, trial.expectStatus)
+               for k := range trial.expectHeader {
+                       c.Check(resp.Header[k], check.DeepEquals, trial.expectHeader[k])
+               }
+               buf, err := ioutil.ReadAll(resp.Body)
+               c.Check(err, check.IsNil)
+               c.Check(string(buf), check.Matches, trial.expectBodyRe)
+       }
+}
+
+func (s *ContainerGatewaySuite) TestContainerRequestLogViaCadaver(c *check.C) {
+       s.setupLogCollection(c)
+
+       out := s.runCadaver(c, arvadostest.ActiveToken, "/arvados/v1/container_requests/"+s.reqUUID+"/log/"+s.ctrUUID, "ls")
+       c.Check(out, check.Matches, `(?ms).*stderr\.txt\s+12\s.*`)
+       c.Check(out, check.Matches, `(?ms).*a\s+0\s.*`)
+
+       out = s.runCadaver(c, arvadostest.ActiveTokenV2, "/arvados/v1/container_requests/"+s.reqUUID+"/log/"+s.ctrUUID, "get stderr.txt")
+       c.Check(out, check.Matches, `(?ms).*Downloading .* to stderr\.txt: .* succeeded\..*`)
+
+       s.saveLogAndCloseGateway(c)
+
+       out = s.runCadaver(c, arvadostest.ActiveTokenV2, "/arvados/v1/container_requests/"+s.reqUUID+"/log/"+s.ctrUUID, "get stderr.txt")
+       c.Check(out, check.Matches, `(?ms).*Downloading .* to stderr\.txt: .* succeeded\..*`)
+}
+
+func (s *ContainerGatewaySuite) runCadaver(c *check.C, password, path, stdin string) string {
+       // Replace s.srv with an HTTP server, otherwise cadaver will
+       // just fail on TLS cert verification.
+       s.srv.Close()
+       rtr := router.New(s.localdb, router.Config{})
+       s.srv = httptest.NewUnstartedServer(httpserver.AddRequestIDs(httpserver.LogRequests(rtr)))
+       s.srv.Start()
+
+       tempdir, err := ioutil.TempDir("", "localdb-test-")
+       c.Assert(err, check.IsNil)
+       defer os.RemoveAll(tempdir)
+
+       cmd := exec.Command("cadaver", s.srv.URL+path)
+       if password != "" {
+               cmd.Env = append(os.Environ(), "HOME="+tempdir)
+               f, err := os.OpenFile(filepath.Join(tempdir, ".netrc"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
+               c.Assert(err, check.IsNil)
+               _, err = fmt.Fprintf(f, "default login none password %s\n", password)
+               c.Assert(err, check.IsNil)
+               c.Assert(f.Close(), check.IsNil)
+       }
+       cmd.Stdin = bytes.NewBufferString(stdin)
+       cmd.Dir = tempdir
+       stdout, err := cmd.StdoutPipe()
+       c.Assert(err, check.Equals, nil)
+       cmd.Stderr = cmd.Stdout
+       c.Logf("cmd: %v", cmd.Args)
+       go cmd.Start()
+
+       var buf bytes.Buffer
+       _, err = io.Copy(&buf, stdout)
+       c.Check(err, check.Equals, nil)
+       err = cmd.Wait()
+       c.Check(err, check.Equals, nil)
+       return buf.String()
+}
+
 func (s *ContainerGatewaySuite) TestConnect(c *check.C) {
        c.Logf("connecting to %s", s.gw.Address)
        sshconn, err := s.localdb.ContainerSSH(s.userctx, arvados.ContainerSSHOptions{UUID: s.ctrUUID})
@@ -274,7 +605,7 @@ func (s *ContainerGatewaySuite) TestConnectThroughTunnelWithProxyOK(c *check.C)
 func (s *ContainerGatewaySuite) TestConnectThroughTunnelWithProxyError(c *check.C) {
        forceProxyForTest = true
        defer func() { forceProxyForTest = false }()
-       // forceInternalURLForTest shouldn't be used because it isn't
+       // forceInternalURLForTest will not be usable because it isn't
        // listed in s.cluster.Services.Controller.InternalURLs
        s.testConnectThroughTunnel(c, `.*tunnel endpoint is invalid.*`)
 }
@@ -283,7 +614,7 @@ func (s *ContainerGatewaySuite) TestConnectThroughTunnelNoProxyOK(c *check.C) {
        s.testConnectThroughTunnel(c, "")
 }
 
-func (s *ContainerGatewaySuite) testConnectThroughTunnel(c *check.C, expectErrorMatch string) {
+func (s *ContainerGatewaySuite) setupGatewayWithTunnel(c *check.C) *crunchrun.Gateway {
        rootctx := ctrlctx.NewWithToken(s.ctx, s.cluster, s.cluster.SystemRootToken)
        // Until the tunnel starts up, set gateway_address to a value
        // that can't work. We want to ensure the only way we can
@@ -327,7 +658,11 @@ func (s *ContainerGatewaySuite) testConnectThroughTunnel(c *check.C, expectError
                        break
                }
        }
+       return tungw
+}
 
+func (s *ContainerGatewaySuite) testConnectThroughTunnel(c *check.C, expectErrorMatch string) {
+       s.setupGatewayWithTunnel(c)
        c.Log("connecting to gateway through tunnel")
        arpc := rpc.NewConn("", &url.URL{Scheme: "https", Host: s.gw.ArvadosClient.APIHost}, true, rpc.PassthroughTokenProvider)
        sshconn, err := arpc.ContainerSSH(s.userctx, arvados.ContainerSSHOptions{UUID: s.ctrUUID})
index 49e21840ea206f69684738e2f9aec98f0f6c2fd3..0234ee8fa6e618fa9d095c938cb2721ae69bda90 100644 (file)
@@ -6,8 +6,15 @@ package localdb
 
 import (
        "context"
+       "encoding/json"
+       "fmt"
+       "net/http"
+       "net/url"
 
+       "git.arvados.org/arvados.git/lib/dispatchcloud/scheduler"
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
 )
 
 // ContainerRequestCreate defers to railsProxy for everything except
@@ -54,3 +61,87 @@ func (conn *Conn) ContainerRequestDelete(ctx context.Context, opts arvados.Delet
        conn.logActivity(ctx)
        return conn.railsProxy.ContainerRequestDelete(ctx, opts)
 }
+
+func (conn *Conn) ContainerRequestContainerStatus(ctx context.Context, opts arvados.GetOptions) (arvados.ContainerStatus, error) {
+       conn.logActivity(ctx)
+       var ret arvados.ContainerStatus
+       cr, err := conn.railsProxy.ContainerRequestGet(ctx, arvados.GetOptions{UUID: opts.UUID, Select: []string{"uuid", "container_uuid", "log_uuid"}})
+       if err != nil {
+               return ret, err
+       }
+       if cr.ContainerUUID == "" {
+               ret.SchedulingStatus = "no container assigned"
+               return ret, nil
+       }
+       // We use admin credentials to get the container record so we
+       // don't get an error when we're in a race with auto-retry and
+       // the container became user-unreadable since we fetched the
+       // CR above.
+       ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{conn.cluster.SystemRootToken}})
+       ctr, err := conn.railsProxy.ContainerGet(ctxRoot, arvados.GetOptions{UUID: cr.ContainerUUID, Select: []string{"uuid", "state", "priority"}})
+       if err != nil {
+               return ret, err
+       }
+       ret.UUID = ctr.UUID
+       ret.State = ctr.State
+       if ctr.State != arvados.ContainerStateQueued && ctr.State != arvados.ContainerStateLocked {
+               // Scheduling status is not a thing once the container
+               // is in running state.
+               return ret, nil
+       }
+       var lastErr error
+       for dispatchurl := range conn.cluster.Services.DispatchCloud.InternalURLs {
+               baseurl := url.URL(dispatchurl)
+               apiurl, err := baseurl.Parse("/arvados/v1/dispatch/container?container_uuid=" + cr.ContainerUUID)
+               if err != nil {
+                       lastErr = err
+                       continue
+               }
+               req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiurl.String(), nil)
+               if err != nil {
+                       lastErr = err
+                       continue
+               }
+               req.Header.Set("Authorization", "Bearer "+conn.cluster.ManagementToken)
+               resp, err := http.DefaultClient.Do(req)
+               if err != nil {
+                       lastErr = fmt.Errorf("error getting status from dispatcher: %w", err)
+                       continue
+               }
+               if resp.StatusCode == http.StatusNotFound {
+                       continue
+               } else if resp.StatusCode != http.StatusOK {
+                       lastErr = fmt.Errorf("error getting status from dispatcher: %s", resp.Status)
+                       continue
+               }
+               var qent scheduler.QueueEnt
+               err = json.NewDecoder(resp.Body).Decode(&qent)
+               if err != nil {
+                       lastErr = err
+                       continue
+               }
+               ret.State = qent.Container.State // Prefer dispatcher's view of state if not equal to ctr.State
+               ret.SchedulingStatus = qent.SchedulingStatus
+               return ret, nil
+       }
+       if lastErr != nil {
+               // If we got a non-nil error from a dispatchcloud
+               // service, and the container state suggests
+               // dispatchcloud should know about it, then we return
+               // an error so the client knows to retry.
+               return ret, httpserver.ErrorWithStatus(lastErr, http.StatusBadGateway)
+       }
+       // All running dispatchcloud services confirm they don't have
+       // this container (the dispatcher hasn't yet noticed it
+       // appearing in the queue) or there are no dispatchcloud
+       // services configured. Either way, all we can say is that
+       // it's queued.
+       if ctr.State == arvados.ContainerStateQueued && ctr.Priority < 1 {
+               // If it hasn't been picked up by a dispatcher
+               // already, it won't be -- it's just on hold.
+               // Scheduling status does not apply.
+               return ret, nil
+       }
+       ret.SchedulingStatus = "waiting for dispatch"
+       return ret, nil
+}
index 437e30b144cac8f12ca34f3a3e5d310b684d005b..86ae714ba9b2fab38ce8e959f9061defa92232a0 100644 (file)
@@ -10,6 +10,7 @@ import (
        "errors"
        "fmt"
        "math/rand"
+       "strings"
        "sync"
        "time"
 
@@ -47,7 +48,9 @@ func (s *containerSuite) crAttrs(c *C) map[string]interface{} {
 }
 
 func (s *containerSuite) SetUpTest(c *C) {
+       containerPriorityUpdateInterval = 2 * time.Second
        s.localdbSuite.SetUpTest(c)
+       s.starttime = time.Now()
        var err error
        s.topcr, err = s.localdb.ContainerRequestCreate(s.userctx, arvados.CreateOptions{Attrs: s.crAttrs(c)})
        c.Assert(err, IsNil)
@@ -55,7 +58,11 @@ func (s *containerSuite) SetUpTest(c *C) {
        c.Assert(err, IsNil)
        c.Assert(int(s.topc.Priority), Not(Equals), 0)
        c.Logf("topcr %s topc %s", s.topcr.UUID, s.topc.UUID)
-       s.starttime = time.Now()
+}
+
+func (s *containerSuite) TearDownTest(c *C) {
+       containerPriorityUpdateInterval = 5 * time.Minute
+       s.localdbSuite.TearDownTest(c)
 }
 
 func (s *containerSuite) syncUpdatePriority(c *C) {
@@ -94,6 +101,10 @@ func (s *containerSuite) TestUpdatePriorityShouldBeZero(c *C) {
 }
 
 func (s *containerSuite) TestUpdatePriorityMultiLevelWorkflow(c *C) {
+       testCtx, testCancel := context.WithDeadline(s.ctx, time.Now().Add(30*time.Second))
+       defer testCancel()
+       adminCtx := ctrlctx.NewWithToken(testCtx, s.cluster, s.cluster.SystemRootToken)
+
        childCR := func(parent arvados.ContainerRequest, arg string) arvados.ContainerRequest {
                attrs := s.crAttrs(c)
                attrs["command"] = []string{c.TestName(), fmt.Sprintf("%d", s.starttime.UnixMilli()), arg}
@@ -101,6 +112,16 @@ func (s *containerSuite) TestUpdatePriorityMultiLevelWorkflow(c *C) {
                c.Assert(err, IsNil)
                _, err = s.db.Exec("update container_requests set requesting_container_uuid=$1 where uuid=$2", parent.ContainerUUID, cr.UUID)
                c.Assert(err, IsNil)
+               _, err = s.localdb.ContainerUpdate(adminCtx, arvados.UpdateOptions{
+                       UUID:  cr.ContainerUUID,
+                       Attrs: map[string]interface{}{"state": "Locked"},
+               })
+               c.Assert(err, IsNil)
+               _, err = s.localdb.ContainerUpdate(adminCtx, arvados.UpdateOptions{
+                       UUID:  cr.ContainerUUID,
+                       Attrs: map[string]interface{}{"state": "Running"},
+               })
+               c.Assert(err, IsNil)
                return cr
        }
        // Build a tree of container requests and containers (3 levels
@@ -119,38 +140,16 @@ func (s *containerSuite) TestUpdatePriorityMultiLevelWorkflow(c *C) {
                }
        }
 
-       testCtx, testCancel := context.WithDeadline(s.ctx, time.Now().Add(time.Second*20))
-       defer testCancel()
-
        // Set priority=0 on a parent+child, plus 18 other randomly
        // selected containers in the tree
-       adminCtx := ctrlctx.NewWithToken(testCtx, s.cluster, s.cluster.SystemRootToken)
-       needfix := make([]int, 20)
-       running := make(map[int]bool)
-       for n := range needfix {
-               var i int // which container are we going to run & then set priority=0
-               if n < 2 {
-                       // first two are allcrs[1] (which is "i 0")
-                       // and allcrs[2] (which is "i 0 j 0")
-                       i = n + 1
-               } else {
-                       // rest are random
-                       i = rand.Intn(len(allcrs))
-               }
+       //
+       // First entries of needfix are allcrs[1] (which is "i 0") and
+       // allcrs[2] ("i 0 j 0") -- we want to make sure to get at
+       // least one parent/child pair -- and the rest were chosen
+       // randomly.
+       needfix := []int{1, 2, 23, 12, 20, 14, 13, 15, 7, 17, 6, 22, 21, 11, 1, 17, 18}
+       for n, i := range needfix {
                needfix[n] = i
-               if !running[i] {
-                       _, err := s.localdb.ContainerUpdate(adminCtx, arvados.UpdateOptions{
-                               UUID:  allcrs[i].ContainerUUID,
-                               Attrs: map[string]interface{}{"state": "Locked"},
-                       })
-                       c.Assert(err, IsNil)
-                       _, err = s.localdb.ContainerUpdate(adminCtx, arvados.UpdateOptions{
-                               UUID:  allcrs[i].ContainerUUID,
-                               Attrs: map[string]interface{}{"state": "Running"},
-                       })
-                       c.Assert(err, IsNil)
-                       running[i] = true
-               }
                res, err := s.db.Exec("update containers set priority=0 where uuid=$1", allcrs[i].ContainerUUID)
                c.Assert(err, IsNil)
                updated, err := res.RowsAffected()
@@ -195,9 +194,38 @@ func (s *containerSuite) TestUpdatePriorityMultiLevelWorkflow(c *C) {
                c.Assert(err, IsNil)
                c.Check(priority, Not(Equals), 0)
        }
-
        chaosCancel()
 
+       // Flood railsapi with priority updates. This can cause
+       // database deadlock: one call acquires row locks in the order
+       // {i0j0, i0, i0j1}, while another call acquires row locks in
+       // the order {i0j1, i0, i0j0}.
+       deadlockCtx, deadlockCancel := context.WithDeadline(adminCtx, time.Now().Add(30*time.Second))
+       defer deadlockCancel()
+       for _, cr := range allcrs {
+               if strings.Contains(cr.Command[2], " j ") && !strings.Contains(cr.Command[2], " k ") {
+                       cr := cr
+                       wg.Add(1)
+                       go func() {
+                               defer wg.Done()
+                               for _, p := range []int{1, 2, 3, 4} {
+                                       var err error
+                                       for {
+                                               _, err = s.localdb.ContainerRequestUpdate(deadlockCtx, arvados.UpdateOptions{
+                                                       UUID: cr.UUID,
+                                                       Attrs: map[string]interface{}{
+                                                               "priority": p,
+                                                       },
+                                               })
+                                               c.Check(err, IsNil)
+                                               break
+                                       }
+                               }
+                       }()
+               }
+       }
+       wg.Wait()
+
        // Simulate cascading cancellation of the entire tree. For
        // this we need a goroutine to notice and cancel containers
        // with state=Running and priority=0, and cancel them
@@ -209,7 +237,7 @@ func (s *containerSuite) TestUpdatePriorityMultiLevelWorkflow(c *C) {
                defer wg.Done()
                for dispCtx.Err() == nil {
                        needcancel, err := s.localdb.ContainerList(dispCtx, arvados.ListOptions{
-                               Limit:   1,
+                               Limit:   10,
                                Filters: []arvados.Filter{{"state", "=", "Running"}, {"priority", "=", 0}},
                        })
                        if errors.Is(err, context.Canceled) {
@@ -223,8 +251,12 @@ func (s *containerSuite) TestUpdatePriorityMultiLevelWorkflow(c *C) {
                                                "state": "Cancelled",
                                        },
                                })
+                               if errors.Is(err, context.Canceled) {
+                                       break
+                               }
                                c.Assert(err, IsNil)
                        }
+                       time.Sleep(time.Second / 10)
                }
        }()
 
@@ -240,6 +272,16 @@ func (s *containerSuite) TestUpdatePriorityMultiLevelWorkflow(c *C) {
        for {
                time.Sleep(time.Second / 2)
                if testCtx.Err() != nil {
+                       for i, cr := range allcrs {
+                               var ctr arvados.Container
+                               var command string
+                               err = s.db.QueryRowContext(s.ctx, `select cr.priority, cr.state, cr.container_uuid, c.state, c.priority, cr.command
+                                       from container_requests cr
+                                       left join containers c on cr.container_uuid = c.uuid
+                                       where cr.uuid=$1`, cr.UUID).Scan(&cr.Priority, &cr.State, &ctr.UUID, &ctr.State, &ctr.Priority, &command)
+                               c.Check(err, IsNil)
+                               c.Logf("allcrs[%d] cr.pri %d %s c.pri %d %s cr.uuid %s c.uuid %s cmd %s", i, cr.Priority, cr.State, ctr.Priority, ctr.State, cr.UUID, ctr.UUID, command)
+                       }
                        c.Fatal("timed out")
                }
                done := true
@@ -247,7 +289,8 @@ func (s *containerSuite) TestUpdatePriorityMultiLevelWorkflow(c *C) {
                        var priority int
                        var crstate, command, ctrUUID string
                        var parent sql.NullString
-                       err := s.db.QueryRowContext(s.ctx, "select state, priority, command, container_uuid, requesting_container_uuid from container_requests where uuid=$1", cr.UUID).Scan(&crstate, &priority, &command, &ctrUUID, &parent)
+                       err := s.db.QueryRowContext(s.ctx, `select state, priority, container_uuid, requesting_container_uuid, command
+                               from container_requests where uuid=$1`, cr.UUID).Scan(&crstate, &priority, &ctrUUID, &parent, &command)
                        if errors.Is(err, context.Canceled) {
                                break
                        }
index e326ae68d6af76ecff16b04631331bc924d136a7..053031a8cfdec1b8c8aea58d5cfe93f79a838448 100644 (file)
@@ -31,6 +31,10 @@ type localdbSuite struct {
        railsSpy    *arvadostest.Proxy
 }
 
+func (s *localdbSuite) SetUpSuite(c *check.C) {
+       arvadostest.StartKeep(2, true)
+}
+
 func (s *localdbSuite) TearDownSuite(c *check.C) {
        // Undo any changes/additions to the user database so they
        // don't affect subsequent tests.
@@ -40,8 +44,10 @@ func (s *localdbSuite) TearDownSuite(c *check.C) {
 
 func (s *localdbSuite) SetUpTest(c *check.C) {
        *s = localdbSuite{}
+       logger := ctxlog.TestLogger(c)
        s.ctx, s.cancel = context.WithCancel(context.Background())
-       cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+       s.ctx = ctxlog.Context(s.ctx, logger)
+       cfg, err := config.NewLoader(nil, logger).Load()
        c.Assert(err, check.IsNil)
        s.cluster, err = cfg.GetCluster("")
        c.Assert(err, check.IsNil)
index a1ac2c55b02657462ce1c78d860df4a4fdc94186..f9b968a705255408132bb6449885c33356728cd2 100644 (file)
@@ -164,6 +164,8 @@ func (conn *Conn) CreateAPIClientAuthorization(ctx context.Context, rootToken st
        return
 }
 
+var errUserinfoInRedirectTarget = errors.New("redirect target rejected because it contains userinfo")
+
 func validateLoginRedirectTarget(cluster *arvados.Cluster, returnTo string) error {
        u, err := url.Parse(returnTo)
        if err != nil {
@@ -173,16 +175,27 @@ func validateLoginRedirectTarget(cluster *arvados.Cluster, returnTo string) erro
        if err != nil {
                return err
        }
-       if u.Port() == "80" && u.Scheme == "http" {
-               u.Host = u.Hostname()
-       } else if u.Port() == "443" && u.Scheme == "https" {
-               u.Host = u.Hostname()
+       if u.User != nil {
+               return errUserinfoInRedirectTarget
        }
-       if _, ok := cluster.Login.TrustedClients[arvados.URL(*u)]; ok {
-               return nil
+       target := origin(*u)
+       for trusted := range cluster.Login.TrustedClients {
+               trustedOrigin := origin(url.URL(trusted))
+               if trustedOrigin == target {
+                       return nil
+               }
+               // If TrustedClients has https://*.bar.example, we
+               // trust https://foo.bar.example. Note origin() has
+               // already stripped the incoming Path, so we won't
+               // accidentally trust
+               // https://attacker.example/pwn.bar.example here. See
+               // tests.
+               if strings.HasPrefix(trustedOrigin, u.Scheme+"://*.") && strings.HasSuffix(target, trustedOrigin[len(u.Scheme)+4:]) {
+                       return nil
+               }
        }
-       if u.String() == cluster.Services.Workbench1.ExternalURL.String() ||
-               u.String() == cluster.Services.Workbench2.ExternalURL.String() {
+       if target == origin(url.URL(cluster.Services.Workbench1.ExternalURL)) ||
+               target == origin(url.URL(cluster.Services.Workbench2.ExternalURL)) {
                return nil
        }
        if cluster.Login.TrustPrivateNetworks {
@@ -199,3 +212,19 @@ func validateLoginRedirectTarget(cluster *arvados.Cluster, returnTo string) erro
        }
        return fmt.Errorf("requesting site is not listed in TrustedClients config")
 }
+
+// origin returns the canonical origin of a URL, e.g.,
+// origin("https://example:443/foo") returns "https://example/"
+func origin(u url.URL) string {
+       origin := url.URL{
+               Scheme: u.Scheme,
+               Host:   u.Host,
+               Path:   "/",
+       }
+       if origin.Port() == "80" && origin.Scheme == "http" {
+               origin.Host = origin.Hostname()
+       } else if origin.Port() == "443" && origin.Scheme == "https" {
+               origin.Host = origin.Hostname()
+       }
+       return origin.String()
+}
index 6fc6dd9444bf0f44d30f220a9381a5a994684b0f..c539e0e60b124c75c3dd14792b4402e84f6c8756 100755 (executable)
@@ -208,7 +208,7 @@ docker run --detach --rm --name=${ctrlctr} \
        -v "${tmpdir}/arvados-server":/bin/arvados-server:ro \
        -v "${tmpdir}/zzzzz.yml":/etc/arvados/config.yml:ro \
        -v $(realpath "${PWD}/../../.."):/arvados:ro \
-       debian:10 \
+       debian:11 \
        bash -c "${setup_pam_ldap:-true} && arvados-server controller"
 docker logs --follow ${ctrlctr} 2>$debug >$debug &
 ctrlhostports=$(docker port ${ctrlctr} 9999/tcp)
index 65e2e250e54066e9276747fc156988b44247af5b..d91cdddc018f42f02a720d60189fec52a6de385f 100644 (file)
@@ -68,10 +68,11 @@ type oidcLoginController struct {
        // https://people.googleapis.com/)
        peopleAPIBasePath string
 
-       provider   *oidc.Provider        // initialized by setup()
-       oauth2conf *oauth2.Config        // initialized by setup()
-       verifier   *oidc.IDTokenVerifier // initialized by setup()
-       mu         sync.Mutex            // protects setup()
+       provider      *oidc.Provider        // initialized by setup()
+       endSessionURL *url.URL              // initialized by setup()
+       oauth2conf    *oauth2.Config        // initialized by setup()
+       verifier      *oidc.IDTokenVerifier // initialized by setup()
+       mu            sync.Mutex            // protects setup()
 }
 
 // Initialize ctrl.provider and ctrl.oauth2conf.
@@ -101,11 +102,46 @@ func (ctrl *oidcLoginController) setup() error {
                ClientID: ctrl.ClientID,
        })
        ctrl.provider = provider
+       var claims struct {
+               EndSessionEndpoint string `json:"end_session_endpoint"`
+       }
+       err = provider.Claims(&claims)
+       if err != nil {
+               return fmt.Errorf("error parsing OIDC discovery metadata: %v", err)
+       } else if claims.EndSessionEndpoint == "" {
+               ctrl.endSessionURL = nil
+       } else {
+               u, err := url.Parse(claims.EndSessionEndpoint)
+               if err != nil {
+                       return fmt.Errorf("OIDC end_session_endpoint is not a valid URL: %v", err)
+               } else if u.Scheme != "https" {
+                       return fmt.Errorf("OIDC end_session_endpoint MUST use HTTPS but does not: %v", u.String())
+               } else {
+                       ctrl.endSessionURL = u
+               }
+       }
        return nil
 }
 
 func (ctrl *oidcLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
-       return logout(ctx, ctrl.Cluster, opts)
+       err := ctrl.setup()
+       if err != nil {
+               return arvados.LogoutResponse{}, fmt.Errorf("error setting up OpenID Connect provider: %s", err)
+       }
+       resp, err := logout(ctx, ctrl.Cluster, opts)
+       if err != nil {
+               return arvados.LogoutResponse{}, err
+       }
+       creds, credsOK := auth.FromContext(ctx)
+       if ctrl.endSessionURL != nil && credsOK && len(creds.Tokens) > 0 {
+               values := ctrl.endSessionURL.Query()
+               values.Set("client_id", ctrl.ClientID)
+               values.Set("post_logout_redirect_uri", resp.RedirectLocation)
+               u := *ctrl.endSessionURL
+               u.RawQuery = values.Encode()
+               resp.RedirectLocation = u.String()
+       }
+       return resp, err
 }
 
 func (ctrl *oidcLoginController) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
@@ -154,10 +190,39 @@ func (ctrl *oidcLoginController) Login(ctx context.Context, opts arvados.LoginOp
                return loginError(err)
        }
        ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{ctrl.Cluster.SystemRootToken}})
-       return ctrl.Parent.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
-               ReturnTo: state.Remote + "," + state.ReturnTo,
+       resp, err := ctrl.Parent.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+               ReturnTo: state.Remote + ",https://controller.api.client.invalid",
                AuthInfo: *authinfo,
        })
+       if err != nil {
+               return resp, err
+       }
+       // Extract token from rails' UserSessionCreate response, and
+       // attach it to our caller's desired ReturnTo URL.  The Rails
+       // handler explicitly disallows sending the real ReturnTo as a
+       // belt-and-suspenders defence against Rails accidentally
+       // exposing an additional login relay.
+       u, err := url.Parse(resp.RedirectLocation)
+       if err != nil {
+               return resp, err
+       }
+       token := u.Query().Get("api_token")
+       if token == "" {
+               resp.RedirectLocation = state.ReturnTo
+       } else {
+               u, err := url.Parse(state.ReturnTo)
+               if err != nil {
+                       return resp, err
+               }
+               q := u.Query()
+               if q == nil {
+                       q = url.Values{}
+               }
+               q.Set("api_token", token)
+               u.RawQuery = q.Encode()
+               resp.RedirectLocation = u.String()
+       }
+       return resp, nil
 }
 
 func (ctrl *oidcLoginController) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
index cf9cf30eca0086cbc18eed565bd1071a4e7cc1c2..f505f5bc4997682f759176624e851be5a700f8fa 100644 (file)
@@ -15,6 +15,7 @@ import (
        "net/http"
        "net/http/httptest"
        "net/url"
+       "regexp"
        "sort"
        "strings"
        "sync"
@@ -44,7 +45,7 @@ type OIDCLoginSuite struct {
 }
 
 func (s *OIDCLoginSuite) SetUpTest(c *check.C) {
-       s.trustedURL = &arvados.URL{Scheme: "https", Host: "app.example.com", Path: "/"}
+       s.trustedURL = &arvados.URL{Scheme: "https", Host: "app.example.com:443", Path: "/"}
 
        s.fakeProvider = arvadostest.NewOIDCProvider(c)
        s.fakeProvider.AuthEmail = "active-user@arvados.local"
@@ -97,6 +98,75 @@ func (s *OIDCLoginSuite) TestGoogleLogout(c *check.C) {
        c.Check(resp.RedirectLocation, check.Equals, "https://192.168.1.1/bar")
 }
 
+func (s *OIDCLoginSuite) checkRPInitiatedLogout(c *check.C, returnTo string) {
+       if !c.Check(s.fakeProvider.EndSessionEndpoint, check.NotNil,
+               check.Commentf("buggy test: EndSessionEndpoint not configured")) {
+               return
+       }
+       expURL, err := url.Parse(s.fakeProvider.Issuer.URL)
+       if !c.Check(err, check.IsNil, check.Commentf("error parsing expected URL")) {
+               return
+       }
+       expURL.Path = expURL.Path + s.fakeProvider.EndSessionEndpoint.Path
+
+       accessToken := s.fakeProvider.ValidAccessToken()
+       ctx := ctrlctx.NewWithToken(s.ctx, s.cluster, accessToken)
+       resp, err := s.localdb.Logout(ctx, arvados.LogoutOptions{ReturnTo: returnTo})
+       if !c.Check(err, check.IsNil) {
+               return
+       }
+       loc, err := url.Parse(resp.RedirectLocation)
+       if !c.Check(err, check.IsNil, check.Commentf("error parsing response URL")) {
+               return
+       }
+
+       c.Check(loc.Scheme, check.Equals, "https")
+       c.Check(loc.Host, check.Equals, expURL.Host)
+       c.Check(loc.Path, check.Equals, expURL.Path)
+
+       var expReturn string
+       switch returnTo {
+       case "":
+               expReturn = s.cluster.Services.Workbench2.ExternalURL.String()
+       default:
+               expReturn = returnTo
+       }
+       values := loc.Query()
+       c.Check(values.Get("client_id"), check.Equals, s.cluster.Login.Google.ClientID)
+       c.Check(values.Get("post_logout_redirect_uri"), check.Equals, expReturn)
+}
+
+func (s *OIDCLoginSuite) TestRPInitiatedLogoutWithoutReturnTo(c *check.C) {
+       s.fakeProvider.EndSessionEndpoint = &url.URL{Path: "/logout/fromRP"}
+       s.checkRPInitiatedLogout(c, "")
+}
+
+func (s *OIDCLoginSuite) TestRPInitiatedLogoutWithReturnTo(c *check.C) {
+       s.fakeProvider.EndSessionEndpoint = &url.URL{Path: "/rp_logout"}
+       u := arvados.URL{Scheme: "https", Host: "foo.example", Path: "/"}
+       s.cluster.Login.TrustedClients[u] = struct{}{}
+       s.checkRPInitiatedLogout(c, u.String())
+}
+
+func (s *OIDCLoginSuite) TestEndSessionEndpointBadScheme(c *check.C) {
+       // RP-Initiated Logout 1.0 says: "This URL MUST use the https scheme..."
+       u := url.URL{Scheme: "http", Host: "example.com"}
+       s.fakeProvider.EndSessionEndpoint = &u
+       _, err := s.localdb.Logout(s.ctx, arvados.LogoutOptions{})
+       c.Check(err, check.ErrorMatches,
+               `.*\bend_session_endpoint MUST use HTTPS but does not: `+regexp.QuoteMeta(u.String()))
+}
+
+func (s *OIDCLoginSuite) TestNoRPInitiatedLogoutWithoutToken(c *check.C) {
+       endPath := "/TestNoRPInitiatedLogoutWithoutToken"
+       s.fakeProvider.EndSessionEndpoint = &url.URL{Path: endPath}
+       resp, _ := s.localdb.Logout(s.ctx, arvados.LogoutOptions{})
+       u, err := url.Parse(resp.RedirectLocation)
+       c.Check(err, check.IsNil)
+       c.Check(strings.HasSuffix(u.Path, endPath), check.Equals, false,
+               check.Commentf("logout redirected to end_session_endpoint without token"))
+}
+
 func (s *OIDCLoginSuite) TestGoogleLogin_Start_Bogus(c *check.C) {
        resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{})
        c.Check(err, check.IsNil)
diff --git a/lib/controller/localdb/login_test.go b/lib/controller/localdb/login_test.go
new file mode 100644 (file)
index 0000000..5c8e928
--- /dev/null
@@ -0,0 +1,75 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "encoding/json"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&loginSuite{})
+
+type loginSuite struct{}
+
+func (s *loginSuite) TestValidateLoginRedirectTarget(c *check.C) {
+       var cluster arvados.Cluster
+       for _, trial := range []struct {
+               pass    bool
+               wb1     string
+               wb2     string
+               trusted string
+               target  string
+       }{
+               {true, "https://wb1.example/", "https://wb2.example/", "", "https://wb2.example/"},
+               {true, "https://wb1.example:443/", "https://wb2.example:443/", "", "https://wb2.example/"},
+               {true, "https://wb1.example:443/", "https://wb2.example:443/", "", "https://wb2.example"},
+               {true, "https://wb1.example:443", "https://wb2.example:443", "", "https://wb2.example/"},
+               {true, "http://wb1.example:80/", "http://wb2.example:80/", "", "http://wb2.example/"},
+               {false, "https://wb1.example:80/", "https://wb2.example:80/", "", "https://wb2.example/"},
+               {false, "https://wb1.example:1234/", "https://wb2.example:1234/", "", "https://wb2.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "", "https://bad.wb2.example/"},
+               {true, "https://wb1.example/", "https://wb2.example/", "https://good.wb2.example/", "https://good.wb2.example"},
+               {true, "https://wb1.example/", "https://wb2.example/", "https://good.wb2.example:443/", "https://good.wb2.example"},
+               {true, "https://wb1.example/", "https://wb2.example/", "https://good.wb2.example:443", "https://good.wb2.example/"},
+
+               {true, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "https://ok.wildcard.example/"},
+               {true, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "https://ok.ok.wildcard.example/"},
+               {true, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "https://[ok.ok.wildcard.example]:443/"},
+               {true, "https://wb1.example/", "https://wb2.example/", "https://[*.wildcard.example]:443", "https://ok.ok.wildcard.example/"},
+               {true, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example:443", "https://ok.wildcard.example/"},
+               {true, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "https://ok.wildcard.example:443/"},
+               {true, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example:443", "https://ok.wildcard.example:443/"},
+
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "http://wildcard.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "http://.wildcard.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "http://wrongscheme.wildcard.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "http://wrongscheme.wildcard.example:443/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "https://wrongport.wildcard.example:80/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "https://notmatching-wildcard.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "http://notmatching.wildcard.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example:443", "https://attacker.example/ok.wildcard.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "https://attacker.example/ok.wildcard.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "https://attacker.example/?https://ok.wildcard.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*.wildcard.example", "https://attacker.example/#https://ok.wildcard.example/"},
+               {false, "https://wb1.example/", "https://wb2.example/", "https://*-wildcard.example", "https://notsupported-wildcard.example/"},
+       } {
+               c.Logf("trial %+v", trial)
+               // We use json.Unmarshal() to load the test strings
+               // because we're testing behavior when the config file
+               // contains string X.
+               err := json.Unmarshal([]byte(`"`+trial.wb1+`"`), &cluster.Services.Workbench1.ExternalURL)
+               c.Assert(err, check.IsNil)
+               err = json.Unmarshal([]byte(`"`+trial.wb2+`"`), &cluster.Services.Workbench2.ExternalURL)
+               c.Assert(err, check.IsNil)
+               if trial.trusted != "" {
+                       err = json.Unmarshal([]byte(`{"`+trial.trusted+`": {}}`), &cluster.Login.TrustedClients)
+                       c.Assert(err, check.IsNil)
+               }
+               err = validateLoginRedirectTarget(&cluster, trial.target)
+               c.Check(err == nil, check.Equals, trial.pass)
+       }
+}
diff --git a/lib/controller/localdb/testdata/dsa.pub b/lib/controller/localdb/testdata/dsa.pub
new file mode 100644 (file)
index 0000000..8a2743d
--- /dev/null
@@ -0,0 +1 @@
+ssh-dss AAAAB3NzaC1kc3MAAACBAIS5sFWjsFPK5yEa/TjXEEudJrBaFjQ6WvYLiJmh8AmCqWlC83ETv5gEFeIwJo8om8bat4n6l6IKkG4wDo7uxNN0lEWGnOBXatpWOcrJphb0PgYMstZnW7K5GBpTY52TDShx5OS5nvb9iJiQjd1/WQ63knmYoVZH3Ijhv6vDikL3AAAAFQDotNYD4D4IjS8BjJFk8qCGg1FWGQAAAIBlqZ/KwlJpJiekR2Yv+8k456kiFhPUasjeDqx+zGP//+0xNGx2yYzdkPlmvYrdG3YvRjA8KX5C+qJT9CfS1FMcY8/3cXWmDCxi3zKvaXjUcLk1nfVbhsPHdaebpSX3N+C6meehjoQIhYIgZghdPuWOgyGjwIavO9DYMlTGVhHRCgAAAIAjqJonYsmaSd3/0SoD2NGKBvRhngKcaTu63OLIY/V2kdg4Zrph7Ptx//S994rlhugLq68c0wnNoeq4vjVoRY8gDaCy8KXsk9Sq8THbxNseFeqa04txJJXe7g8/6nopfqrhi0NgpIyaNn/0BfqjWOErQuhzxhMqZ5if0aRi1k+g5A== tom@slab
diff --git a/lib/controller/localdb/testdata/ecdsa-sk.pub b/lib/controller/localdb/testdata/ecdsa-sk.pub
new file mode 100644 (file)
index 0000000..9f18e6b
--- /dev/null
@@ -0,0 +1 @@
+sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBFj1zodcmSKWeUgNxzDOv7m9TeLhNRb64wa9oQwQK4tFZzLQRgcsmaVQmMx/ZbY+ThZbHLHSpKRxaByINu99NKUAAAAEc3NoOg== tom@slab
diff --git a/lib/controller/localdb/testdata/ecdsa.pub b/lib/controller/localdb/testdata/ecdsa.pub
new file mode 100644 (file)
index 0000000..b34e821
--- /dev/null
@@ -0,0 +1 @@
+ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDLajzRPnSI3FBChDvvNJyIBPdyA/nC7GWFWwizK93XL8HkQ5+X6D/xaqowq6iIPq/XHSdbZ3ebdb0OH81ovrCQ= tom@slab
diff --git a/lib/controller/localdb/testdata/ed25519-sk.pub b/lib/controller/localdb/testdata/ed25519-sk.pub
new file mode 100644 (file)
index 0000000..0aa08f5
--- /dev/null
@@ -0,0 +1 @@
+sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIJMteBo9BvwQTeiBq4FvS4qJ83YjoCvKrH6EnvrOCILmAAAABHNzaDo= test key
diff --git a/lib/controller/localdb/testdata/ed25519.pub b/lib/controller/localdb/testdata/ed25519.pub
new file mode 100644 (file)
index 0000000..ffcde15
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIElzlGk8QUevhJQ2mhf8p73lUAh044icWqssl3bMoCaT tom@slab
diff --git a/lib/controller/localdb/testdata/generate b/lib/controller/localdb/testdata/generate
new file mode 100755 (executable)
index 0000000..d39d72a
--- /dev/null
@@ -0,0 +1,25 @@
+#!/bin/bash
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+# This script uses ssh-keygen to generate an example public key for
+# each supported type, to be used by test cases. Private keys are
+# discarded. If ${keytype}.pub already exists, it is left alone.
+
+set -e
+
+err=
+keytypes=$(ssh-keygen -_ 2>&1 | grep -- -t | tr -d '[|]' | tr ' ' '\n' | grep -vw t)
+for keytype in ${keytypes[@]}; do
+    if [[ ! -e "./${keytype}.pub" ]]; then
+        if ssh-keygen -t "${keytype}" -f "./${keytype}" -N ""; then
+            # discard private key
+            rm "./${keytype}"
+        else
+            echo >&2 "ssh-keygen -t ${keytype} failed"
+            err=1
+        fi
+    fi
+done
+exit $err
diff --git a/lib/controller/localdb/testdata/rsa.pub b/lib/controller/localdb/testdata/rsa.pub
new file mode 100644 (file)
index 0000000..4b5ab75
--- /dev/null
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCtlBJsNterzUR26k/3tbXi2LViRj0vPyyJ7msqyGtRjJKuMqZkVJz6GN42/+aESeHfJw9FNlwW4oMa3Z4BB5llvZSG8yhY1HXbBlK5sURjSo9tid/U+PlKPGqteiXTguXLj5PAwoAoQ4JnGKR/+YphWxuWy+VR4toLcuKG9pX5d6iwkmWU1/smUnF6+vq38Xrhv94EpeNmyTEPC6OijDdmcas3rwDGW/I2Vij/Bxdj9DY/tHLv9V+yznbV1YB9yxda0YeIGMa2d35dOIxBeWmXzAGczVNQeXE7ooFOH6zCyoJZ4HH/AhAZ9GHyNGsf72CM+WkTBUEYmBmRIDHtMXY32KxyreRWUU1l47md5gefkb4c57OI369AQed154SVQaoiiVqIXinXGGezmfa09nnaSelD54Hky71GC/qqMvzkv7pXkETB37hYC2z2NixXQ6pf21vRHZLAtA8LK9OB5yxdr9b5buMIdTLViKufr3pPk8bcJrlB7tilw5X/PUioWws= tom@slab
index 47b8cb47112ad5990d2f80dd23c72cf98fb85a70..26d1859ec874341af736dc9cd0b9ef3ca4a936cf 100644 (file)
@@ -45,6 +45,11 @@ var dropHeaders = map[string]bool{
 
        // Content-Length depends on encoding.
        "Content-Length": true,
+
+       // Defend against Rails vulnerability CVE-2023-22795 -
+       // we don't use this functionality anyway, so it costs us nothing.
+       // <https://discuss.rubyonrails.org/t/cve-2023-22795-possible-redos-based-dos-vulnerability-in-action-dispatch/82118>
+       "If-None-Match": true,
 }
 
 type ResponseFilter func(*http.Response, error) (*http.Response, error)
index 97efe31726abe69d378a9742ac84042064d627b0..68fffa0681333dc1dfead29eefc45c528424abd4 100644 (file)
@@ -55,12 +55,12 @@ func guessAndParse(k, v string) (interface{}, error) {
        // foo=["bar","baz"]?
 }
 
-// Parse req as an Arvados V1 API request and return the request
-// parameters.
+// Return a map of incoming HTTP request parameters. Also load
+// parameters into opts, unless opts is nil.
 //
 // If the request has a parameter whose name is attrsKey (e.g.,
 // "collection"), it is renamed to "attrs".
-func (rtr *router) loadRequestParams(req *http.Request, attrsKey string) (map[string]interface{}, error) {
+func (rtr *router) loadRequestParams(req *http.Request, attrsKey string, opts interface{}) (map[string]interface{}, error) {
        // Here we call ParseForm and ParseMultipartForm explicitly
        // (even though ParseMultipartForm calls ParseForm if
        // necessary) to ensure we catch errors encountered in
@@ -153,6 +153,24 @@ func (rtr *router) loadRequestParams(req *http.Request, attrsKey string) (map[st
                }
        }
 
+       if opts != nil {
+               // Load all path, query, and form params into opts.
+               err = rtr.transcode(params, opts)
+               if err != nil {
+                       return nil, fmt.Errorf("transcode: %w", err)
+               }
+
+               // Special case: if opts has Method or Header fields, load the
+               // request method/header.
+               err = rtr.transcode(struct {
+                       Method string
+                       Header http.Header
+               }{req.Method, req.Header}, opts)
+               if err != nil {
+                       return nil, fmt.Errorf("transcode: %w", err)
+               }
+       }
+
        return params, nil
 }
 
index 82f1fb8e89df9e8085b0a0e90a8a467365c0caa0..b689eb681f8bf17ce0ff4581028fdc3570730e23 100644 (file)
@@ -13,6 +13,7 @@ import (
        "net/http/httptest"
        "net/url"
 
+       "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
        check "gopkg.in/check.v1"
 )
@@ -147,12 +148,15 @@ func (s *RouterSuite) TestAttrsInBody(c *check.C) {
        } {
                c.Logf("tr: %#v", tr)
                req := tr.Request()
-               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               var opts struct{ Attrs struct{ Foo string } }
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey, &opts)
                c.Logf("params: %#v", params)
                c.Assert(err, check.IsNil)
                c.Check(params, check.NotNil)
-               c.Assert(params["attrs"], check.FitsTypeOf, map[string]interface{}{})
-               c.Check(params["attrs"].(map[string]interface{})["foo"], check.Equals, "bar")
+               c.Check(opts.Attrs.Foo, check.Equals, "bar")
+               if c.Check(params["attrs"], check.FitsTypeOf, map[string]interface{}{}) {
+                       c.Check(params["attrs"].(map[string]interface{})["foo"], check.Equals, "bar")
+               }
        }
 }
 
@@ -169,11 +173,14 @@ func (s *RouterSuite) TestBoolParam(c *check.C) {
                c.Logf("#%d, tr: %#v", i, tr)
                req := tr.Request()
                c.Logf("tr.body: %s", tr.bodyContent())
-               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               var opts struct{ EnsureUniqueName bool }
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey, &opts)
                c.Logf("params: %#v", params)
                c.Assert(err, check.IsNil)
-               c.Check(params, check.NotNil)
-               c.Check(params[testKey], check.Equals, false)
+               c.Check(opts.EnsureUniqueName, check.Equals, false)
+               if c.Check(params, check.NotNil) {
+                       c.Check(params[testKey], check.Equals, false)
+               }
        }
 
        for i, tr := range []testReq{
@@ -185,11 +192,16 @@ func (s *RouterSuite) TestBoolParam(c *check.C) {
                c.Logf("#%d, tr: %#v", i, tr)
                req := tr.Request()
                c.Logf("tr.body: %s", tr.bodyContent())
-               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               var opts struct {
+                       EnsureUniqueName bool `json:"ensure_unique_name"`
+               }
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey, &opts)
                c.Logf("params: %#v", params)
                c.Assert(err, check.IsNil)
-               c.Check(params, check.NotNil)
-               c.Check(params[testKey], check.Equals, true)
+               c.Check(opts.EnsureUniqueName, check.Equals, true)
+               if c.Check(params, check.NotNil) {
+                       c.Check(params[testKey], check.Equals, true)
+               }
        }
 }
 
@@ -204,7 +216,7 @@ func (s *RouterSuite) TestOrderParam(c *check.C) {
        } {
                c.Logf("#%d, tr: %#v", i, tr)
                req := tr.Request()
-               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey, nil)
                c.Assert(err, check.IsNil)
                c.Assert(params, check.NotNil)
                if order, ok := params["order"]; ok && order != nil {
@@ -221,8 +233,10 @@ func (s *RouterSuite) TestOrderParam(c *check.C) {
        } {
                c.Logf("#%d, tr: %#v", i, tr)
                req := tr.Request()
-               params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+               var opts arvados.ListOptions
+               params, err := s.rtr.loadRequestParams(req, tr.attrsKey, &opts)
                c.Assert(err, check.IsNil)
+               c.Check(opts.Order, check.DeepEquals, []string{"foo", "bar desc"})
                if _, ok := params["order"].([]string); ok {
                        c.Check(params["order"], check.DeepEquals, []string{"foo", "bar desc"})
                } else {
index e46ed5f19950feae4ee2e073ac2550ff6b4e7c39..054bcffaf7ecf33b12965bb8e0d0be2d9590e1e0 100644 (file)
@@ -86,6 +86,41 @@ func (rtr *router) addRoutes() {
                                return rtr.backend.Logout(ctx, *opts.(*arvados.LogoutOptions))
                        },
                },
+               {
+                       arvados.EndpointAuthorizedKeyCreate,
+                       func() interface{} { return &arvados.CreateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.AuthorizedKeyCreate(ctx, *opts.(*arvados.CreateOptions))
+                       },
+               },
+               {
+                       arvados.EndpointAuthorizedKeyUpdate,
+                       func() interface{} { return &arvados.UpdateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.AuthorizedKeyUpdate(ctx, *opts.(*arvados.UpdateOptions))
+                       },
+               },
+               {
+                       arvados.EndpointAuthorizedKeyGet,
+                       func() interface{} { return &arvados.GetOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.AuthorizedKeyGet(ctx, *opts.(*arvados.GetOptions))
+                       },
+               },
+               {
+                       arvados.EndpointAuthorizedKeyList,
+                       func() interface{} { return &arvados.ListOptions{Limit: -1} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.AuthorizedKeyList(ctx, *opts.(*arvados.ListOptions))
+                       },
+               },
+               {
+                       arvados.EndpointAuthorizedKeyDelete,
+                       func() interface{} { return &arvados.DeleteOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.AuthorizedKeyDelete(ctx, *opts.(*arvados.DeleteOptions))
+                       },
+               },
                {
                        arvados.EndpointCollectionCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
@@ -191,41 +226,6 @@ func (rtr *router) addRoutes() {
                                return rtr.backend.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
                        },
                },
-               {
-                       arvados.EndpointContainerRequestCreate,
-                       func() interface{} { return &arvados.CreateOptions{} },
-                       func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.backend.ContainerRequestCreate(ctx, *opts.(*arvados.CreateOptions))
-                       },
-               },
-               {
-                       arvados.EndpointContainerRequestUpdate,
-                       func() interface{} { return &arvados.UpdateOptions{} },
-                       func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.backend.ContainerRequestUpdate(ctx, *opts.(*arvados.UpdateOptions))
-                       },
-               },
-               {
-                       arvados.EndpointContainerRequestGet,
-                       func() interface{} { return &arvados.GetOptions{} },
-                       func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.backend.ContainerRequestGet(ctx, *opts.(*arvados.GetOptions))
-                       },
-               },
-               {
-                       arvados.EndpointContainerRequestList,
-                       func() interface{} { return &arvados.ListOptions{Limit: -1} },
-                       func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.backend.ContainerRequestList(ctx, *opts.(*arvados.ListOptions))
-                       },
-               },
-               {
-                       arvados.EndpointContainerRequestDelete,
-                       func() interface{} { return &arvados.DeleteOptions{} },
-                       func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.backend.ContainerRequestDelete(ctx, *opts.(*arvados.DeleteOptions))
-                       },
-               },
                {
                        arvados.EndpointContainerLock,
                        func() interface{} {
@@ -251,6 +251,13 @@ func (rtr *router) addRoutes() {
                                return rtr.backend.ContainerSSH(ctx, *opts.(*arvados.ContainerSSHOptions))
                        },
                },
+               {
+                       arvados.EndpointContainerSSHCompat,
+                       func() interface{} { return &arvados.ContainerSSHOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerSSH(ctx, *opts.(*arvados.ContainerSSHOptions))
+                       },
+               },
                {
                        // arvados-client built before commit
                        // bdc29d3129f6d75aa9ce0a24ffb849a272b06f08
@@ -269,6 +276,62 @@ func (rtr *router) addRoutes() {
                                return rtr.backend.ContainerGatewayTunnel(ctx, *opts.(*arvados.ContainerGatewayTunnelOptions))
                        },
                },
+               {
+                       arvados.EndpointContainerGatewayTunnelCompat,
+                       func() interface{} { return &arvados.ContainerGatewayTunnelOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerGatewayTunnel(ctx, *opts.(*arvados.ContainerGatewayTunnelOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestCreate,
+                       func() interface{} { return &arvados.CreateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestCreate(ctx, *opts.(*arvados.CreateOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestUpdate,
+                       func() interface{} { return &arvados.UpdateOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestUpdate(ctx, *opts.(*arvados.UpdateOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestGet,
+                       func() interface{} { return &arvados.GetOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestGet(ctx, *opts.(*arvados.GetOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestList,
+                       func() interface{} { return &arvados.ListOptions{Limit: -1} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestList(ctx, *opts.(*arvados.ListOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestDelete,
+                       func() interface{} { return &arvados.DeleteOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestDelete(ctx, *opts.(*arvados.DeleteOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestContainerStatus,
+                       func() interface{} { return &arvados.GetOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestContainerStatus(ctx, *opts.(*arvados.GetOptions))
+                       },
+               },
+               {
+                       arvados.EndpointContainerRequestLog,
+                       func() interface{} { return &arvados.ContainerLogOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerRequestLog(ctx, *opts.(*arvados.ContainerLogOptions))
+                       },
+               },
                {
                        arvados.EndpointGroupCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
@@ -585,9 +648,23 @@ func (rtr *router) addRoutes() {
                rtr.addRoute(route.endpoint, route.defaultOpts, exec)
        }
        rtr.mux.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+               if req.Method == "OPTIONS" {
+                       // For non-webdav endpoints, return an empty
+                       // response with the CORS headers we already
+                       // added in ServeHTTP.
+                       w.WriteHeader(http.StatusOK)
+                       return
+               }
                httpserver.Errors(w, []string{"API endpoint not found"}, http.StatusNotFound)
        })
        rtr.mux.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+               if req.Method == "OPTIONS" {
+                       // For non-webdav endpoints, return an empty
+                       // response with the CORS headers we already
+                       // added in ServeHTTP.
+                       w.WriteHeader(http.StatusOK)
+                       return
+               }
                httpserver.Errors(w, []string{"API endpoint not found"}, http.StatusMethodNotAllowed)
        })
 }
@@ -602,9 +679,14 @@ func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() int
        if alt, ok := altMethod[endpoint.Method]; ok {
                methods = append(methods, alt)
        }
+       if strings.HasSuffix(endpoint.Path, ".*}") {
+               // webdav methods
+               methods = append(methods, "OPTIONS", "PROPFIND")
+       }
        rtr.mux.Methods(methods...).Path("/" + endpoint.Path).HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
                logger := ctxlog.FromContext(req.Context())
-               params, err := rtr.loadRequestParams(req, endpoint.AttrsKey)
+               opts := defaultOpts()
+               params, err := rtr.loadRequestParams(req, endpoint.AttrsKey, opts)
                if err != nil {
                        logger.WithFields(logrus.Fields{
                                "req":      req,
@@ -614,13 +696,6 @@ func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() int
                        rtr.sendError(w, err)
                        return
                }
-               opts := defaultOpts()
-               err = rtr.transcode(params, opts)
-               if err != nil {
-                       logger.WithField("params", params).WithError(err).Debugf("error transcoding params to %T", opts)
-                       rtr.sendError(w, err)
-                       return
-               }
                respOpts, err := rtr.responseOptions(opts)
                if err != nil {
                        logger.WithField("opts", opts).WithError(err).Debugf("error getting response options from %T", opts)
@@ -643,11 +718,8 @@ func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() int
                }
                ctx := auth.NewContext(req.Context(), creds)
                ctx = arvados.ContextWithRequestID(ctx, req.Header.Get("X-Request-Id"))
-               logger.WithFields(logrus.Fields{
-                       "apiEndpoint": endpoint,
-                       "apiOptsType": fmt.Sprintf("%T", opts),
-                       "apiOpts":     opts,
-               }).Debug("exec")
+               req = req.WithContext(ctx)
+
                // Extract the token UUIDs (or a placeholder for v1 tokens)
                var tokenUUIDs []string
                for _, t := range creds.Tokens {
@@ -664,7 +736,13 @@ func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() int
                                tokenUUIDs = append(tokenUUIDs, "v1 token ending in "+end)
                        }
                }
-               httpserver.SetResponseLogFields(req.Context(), logrus.Fields{"tokenUUIDs": tokenUUIDs})
+               httpserver.SetResponseLogFields(ctx, logrus.Fields{"tokenUUIDs": tokenUUIDs})
+
+               logger.WithFields(logrus.Fields{
+                       "apiEndpoint": endpoint,
+                       "apiOptsType": fmt.Sprintf("%T", opts),
+                       "apiOpts":     opts,
+               }).Debug("exec")
                resp, err := exec(ctx, opts)
                if err != nil {
                        logger.WithError(err).Debugf("returning error type %T", err)
@@ -680,13 +758,11 @@ func (rtr *router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        case "login", "logout", "auth":
        default:
                w.Header().Set("Access-Control-Allow-Origin", "*")
-               w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, POST, PATCH, DELETE")
-               w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Http-Method-Override")
+               w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS, PROPFIND, PUT, POST, PATCH, DELETE")
+               w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Range, X-Http-Method-Override")
+               w.Header().Set("Access-Control-Expose-Headers", "Content-Range")
                w.Header().Set("Access-Control-Max-Age", "86486400")
        }
-       if r.Method == "OPTIONS" {
-               return
-       }
        if r.Body != nil {
                // Wrap r.Body in a http.MaxBytesReader(), otherwise
                // r.ParseForm() uses a default max request body size
index 11b090a21495edb68312ff9856da9e3504872c81..a8359a440026e5eb617c35c4623b998571a277d5 100644 (file)
@@ -47,14 +47,15 @@ func (s *RouterSuite) SetUpTest(c *check.C) {
 func (s *RouterSuite) TestOptions(c *check.C) {
        token := arvadostest.ActiveToken
        for _, trial := range []struct {
-               comment      string // unparsed -- only used to help match test failures to trials
-               method       string
-               path         string
-               header       http.Header
-               body         string
-               shouldStatus int // zero value means 200
-               shouldCall   string
-               withOptions  interface{}
+               comment         string // unparsed -- only used to help match test failures to trials
+               method          string
+               path            string
+               header          http.Header
+               body            string
+               unauthenticated bool
+               shouldStatus    int // zero value means 200
+               shouldCall      string
+               withOptions     interface{}
        }{
                {
                        method:      "GET",
@@ -174,6 +175,114 @@ func (s *RouterSuite) TestOptions(c *check.C) {
                        path:         "/arvados/v1/collections",
                        shouldStatus: http.StatusMethodNotAllowed,
                },
+               {
+                       comment:    "container log webdav GET root",
+                       method:     "GET",
+                       path:       "/arvados/v1/container_requests/" + arvadostest.CompletedContainerRequestUUID + "/log/" + arvadostest.CompletedContainerUUID + "/",
+                       shouldCall: "ContainerRequestLog",
+                       withOptions: arvados.ContainerLogOptions{
+                               UUID: arvadostest.CompletedContainerRequestUUID,
+                               WebDAVOptions: arvados.WebDAVOptions{
+                                       Method: "GET",
+                                       Header: http.Header{"Authorization": {"Bearer " + arvadostest.ActiveToken}},
+                                       Path:   "/" + arvadostest.CompletedContainerUUID + "/"}},
+               },
+               {
+                       comment:    "container log webdav GET root without trailing slash",
+                       method:     "GET",
+                       path:       "/arvados/v1/container_requests/" + arvadostest.CompletedContainerRequestUUID + "/log/" + arvadostest.CompletedContainerUUID + "",
+                       shouldCall: "ContainerRequestLog",
+                       withOptions: arvados.ContainerLogOptions{
+                               UUID: arvadostest.CompletedContainerRequestUUID,
+                               WebDAVOptions: arvados.WebDAVOptions{
+                                       Method: "GET",
+                                       Header: http.Header{"Authorization": {"Bearer " + arvadostest.ActiveToken}},
+                                       Path:   "/" + arvadostest.CompletedContainerUUID}},
+               },
+               {
+                       comment:    "container log webdav OPTIONS root",
+                       method:     "OPTIONS",
+                       path:       "/arvados/v1/container_requests/" + arvadostest.CompletedContainerRequestUUID + "/log/" + arvadostest.CompletedContainerUUID + "/",
+                       shouldCall: "ContainerRequestLog",
+                       withOptions: arvados.ContainerLogOptions{
+                               UUID: arvadostest.CompletedContainerRequestUUID,
+                               WebDAVOptions: arvados.WebDAVOptions{
+                                       Method: "OPTIONS",
+                                       Header: http.Header{"Authorization": {"Bearer " + arvadostest.ActiveToken}},
+                                       Path:   "/" + arvadostest.CompletedContainerUUID + "/"}},
+               },
+               {
+                       comment:    "container log webdav OPTIONS root without trailing slash",
+                       method:     "OPTIONS",
+                       path:       "/arvados/v1/container_requests/" + arvadostest.CompletedContainerRequestUUID + "/log/" + arvadostest.CompletedContainerUUID,
+                       shouldCall: "ContainerRequestLog",
+                       withOptions: arvados.ContainerLogOptions{
+                               UUID: arvadostest.CompletedContainerRequestUUID,
+                               WebDAVOptions: arvados.WebDAVOptions{
+                                       Method: "OPTIONS",
+                                       Header: http.Header{"Authorization": {"Bearer " + arvadostest.ActiveToken}},
+                                       Path:   "/" + arvadostest.CompletedContainerUUID}},
+               },
+               {
+                       comment:         "container log webdav OPTIONS for CORS",
+                       unauthenticated: true,
+                       method:          "OPTIONS",
+                       path:            "/arvados/v1/container_requests/" + arvadostest.CompletedContainerRequestUUID + "/log/" + arvadostest.CompletedContainerUUID + "/",
+                       header:          http.Header{"Access-Control-Request-Method": {"POST"}},
+                       shouldCall:      "ContainerRequestLog",
+                       withOptions: arvados.ContainerLogOptions{
+                               UUID: arvadostest.CompletedContainerRequestUUID,
+                               WebDAVOptions: arvados.WebDAVOptions{
+                                       Method: "OPTIONS",
+                                       Header: http.Header{
+                                               "Access-Control-Request-Method": {"POST"},
+                                       },
+                                       Path: "/" + arvadostest.CompletedContainerUUID + "/"}},
+               },
+               {
+                       comment:    "container log webdav PROPFIND root",
+                       method:     "PROPFIND",
+                       path:       "/arvados/v1/container_requests/" + arvadostest.CompletedContainerRequestUUID + "/log/" + arvadostest.CompletedContainerUUID + "/",
+                       shouldCall: "ContainerRequestLog",
+                       withOptions: arvados.ContainerLogOptions{
+                               UUID: arvadostest.CompletedContainerRequestUUID,
+                               WebDAVOptions: arvados.WebDAVOptions{
+                                       Method: "PROPFIND",
+                                       Header: http.Header{"Authorization": {"Bearer " + arvadostest.ActiveToken}},
+                                       Path:   "/" + arvadostest.CompletedContainerUUID + "/"}},
+               },
+               {
+                       comment:    "container log webdav PROPFIND root without trailing slash",
+                       method:     "PROPFIND",
+                       path:       "/arvados/v1/container_requests/" + arvadostest.CompletedContainerRequestUUID + "/log/" + arvadostest.CompletedContainerUUID + "",
+                       shouldCall: "ContainerRequestLog",
+                       withOptions: arvados.ContainerLogOptions{
+                               UUID: arvadostest.CompletedContainerRequestUUID,
+                               WebDAVOptions: arvados.WebDAVOptions{
+                                       Method: "PROPFIND",
+                                       Header: http.Header{"Authorization": {"Bearer " + arvadostest.ActiveToken}},
+                                       Path:   "/" + arvadostest.CompletedContainerUUID}},
+               },
+               {
+                       comment:    "container log webdav no_forward=true",
+                       method:     "GET",
+                       path:       "/arvados/v1/container_requests/" + arvadostest.CompletedContainerRequestUUID + "/log/" + arvadostest.CompletedContainerUUID + "/?no_forward=true",
+                       shouldCall: "ContainerRequestLog",
+                       withOptions: arvados.ContainerLogOptions{
+                               UUID:      arvadostest.CompletedContainerRequestUUID,
+                               NoForward: true,
+                               WebDAVOptions: arvados.WebDAVOptions{
+                                       Method: "GET",
+                                       Header: http.Header{"Authorization": {"Bearer " + arvadostest.ActiveToken}},
+                                       Path:   "/" + arvadostest.CompletedContainerUUID + "/"}},
+               },
+               {
+                       comment:      "/logX does not route to ContainerRequestLog",
+                       method:       "GET",
+                       path:         "/arvados/v1/containers/" + arvadostest.CompletedContainerRequestUUID + "/logX",
+                       shouldStatus: http.StatusNotFound,
+                       shouldCall:   "",
+               },
        } {
                // Reset calls captured in previous trial
                s.stub = arvadostest.APIStub{}
@@ -181,7 +290,7 @@ func (s *RouterSuite) TestOptions(c *check.C) {
                c.Logf("trial: %+v", trial)
                comment := check.Commentf("trial comment: %s", trial.comment)
 
-               _, rr, _ := doRequest(c, s.rtr, token, trial.method, trial.path, trial.header, bytes.NewBufferString(trial.body))
+               _, rr := doRequest(c, s.rtr, token, trial.method, trial.path, !trial.unauthenticated, trial.header, bytes.NewBufferString(trial.body), nil)
                if trial.shouldStatus == 0 {
                        c.Check(rr.Code, check.Equals, http.StatusOK, comment)
                } else {
@@ -222,7 +331,8 @@ func (s *RouterIntegrationSuite) TestCollectionResponses(c *check.C) {
        token := arvadostest.ActiveTokenV2
 
        // Check "get collection" response has "kind" key
-       _, rr, jresp := doRequest(c, s.rtr, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"include_trash":true}`))
+       jresp := map[string]interface{}{}
+       _, rr := doRequest(c, s.rtr, token, "GET", `/arvados/v1/collections`, true, nil, bytes.NewBufferString(`{"include_trash":true}`), jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
        c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
@@ -236,7 +346,8 @@ func (s *RouterIntegrationSuite) TestCollectionResponses(c *check.C) {
                `,"select":["name"]`,
                `,"select":["uuid"]`,
        } {
-               _, rr, jresp = doRequest(c, s.rtr, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"where":{"uuid":["`+arvadostest.FooCollection+`"]}`+selectj+`}`))
+               jresp := map[string]interface{}{}
+               _, rr = doRequest(c, s.rtr, token, "GET", `/arvados/v1/collections`, true, nil, bytes.NewBufferString(`{"where":{"uuid":["`+arvadostest.FooCollection+`"]}`+selectj+`}`), jresp)
                c.Check(rr.Code, check.Equals, http.StatusOK)
                c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
                c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
@@ -261,7 +372,8 @@ func (s *RouterIntegrationSuite) TestCollectionResponses(c *check.C) {
        }
 
        // Check "create collection" response has "kind" key
-       _, rr, jresp = doRequest(c, s.rtr, token, "POST", `/arvados/v1/collections`, http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, bytes.NewBufferString(`ensure_unique_name=true`))
+       jresp = map[string]interface{}{}
+       _, rr = doRequest(c, s.rtr, token, "POST", `/arvados/v1/collections`, true, http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, bytes.NewBufferString(`ensure_unique_name=true`), jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["uuid"], check.FitsTypeOf, "")
        c.Check(jresp["kind"], check.Equals, "arvados#collection")
@@ -286,11 +398,11 @@ func (s *RouterIntegrationSuite) TestMaxRequestSize(c *check.C) {
                hdr := http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}
 
                body := bytes.NewBufferString(url.Values{"foo_bar": {okstr}}.Encode())
-               _, rr, _ := doRequest(c, s.rtr, token, "POST", `/arvados/v1/collections`, hdr, body)
+               _, rr := doRequest(c, s.rtr, token, "POST", `/arvados/v1/collections`, true, hdr, body, nil)
                c.Check(rr.Code, check.Equals, http.StatusOK)
 
                body = bytes.NewBufferString(url.Values{"foo_bar": {okstr + okstr}}.Encode())
-               _, rr, _ = doRequest(c, s.rtr, token, "POST", `/arvados/v1/collections`, hdr, body)
+               _, rr = doRequest(c, s.rtr, token, "POST", `/arvados/v1/collections`, true, hdr, body, nil)
                c.Check(rr.Code, check.Equals, http.StatusRequestEntityTooLarge)
        }
 }
@@ -298,20 +410,23 @@ func (s *RouterIntegrationSuite) TestMaxRequestSize(c *check.C) {
 func (s *RouterIntegrationSuite) TestContainerList(c *check.C) {
        token := arvadostest.ActiveTokenV2
 
-       _, rr, jresp := doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?limit=0`, nil, nil)
+       jresp := map[string]interface{}{}
+       _, rr := doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?limit=0`, true, nil, nil, jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
        c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
        c.Check(jresp["items"], check.NotNil)
        c.Check(jresp["items"], check.HasLen, 0)
 
-       _, rr, jresp = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?filters=[["uuid","in",[]]]`, nil, nil)
+       jresp = map[string]interface{}{}
+       _, rr = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?filters=[["uuid","in",[]]]`, true, nil, nil, jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["items_available"], check.Equals, float64(0))
        c.Check(jresp["items"], check.NotNil)
        c.Check(jresp["items"], check.HasLen, 0)
 
-       _, rr, jresp = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?limit=2&select=["uuid","command"]`, nil, nil)
+       jresp = map[string]interface{}{}
+       _, rr = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers?limit=2&select=["uuid","command"]`, true, nil, nil, jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
        c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
@@ -322,7 +437,8 @@ func (s *RouterIntegrationSuite) TestContainerList(c *check.C) {
        c.Check(item0["command"].([]interface{})[0], check.FitsTypeOf, "")
        c.Check(item0["mounts"], check.IsNil)
 
-       _, rr, jresp = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers`, nil, nil)
+       jresp = map[string]interface{}{}
+       _, rr = doRequest(c, s.rtr, token, "GET", `/arvados/v1/containers`, true, nil, nil, jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
        c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
@@ -338,25 +454,33 @@ func (s *RouterIntegrationSuite) TestContainerList(c *check.C) {
 func (s *RouterIntegrationSuite) TestContainerLock(c *check.C) {
        uuid := arvadostest.QueuedContainerUUID
        token := arvadostest.AdminToken
-       _, rr, jresp := doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
+
+       jresp := map[string]interface{}{}
+       _, rr := doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", true, nil, nil, jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["uuid"], check.HasLen, 27)
        c.Check(jresp["state"], check.Equals, "Locked")
-       _, rr, _ = doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
+
+       _, rr = doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", true, nil, nil, nil)
        c.Check(rr.Code, check.Equals, http.StatusUnprocessableEntity)
        c.Check(rr.Body.String(), check.Not(check.Matches), `.*"uuid":.*`)
-       _, rr, jresp = doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
+
+       jresp = map[string]interface{}{}
+       _, rr = doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", true, nil, nil, jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["uuid"], check.HasLen, 27)
        c.Check(jresp["state"], check.Equals, "Queued")
        c.Check(jresp["environment"], check.IsNil)
-       _, rr, jresp = doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
+
+       jresp = map[string]interface{}{}
+       _, rr = doRequest(c, s.rtr, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", true, nil, nil, jresp)
        c.Check(rr.Code, check.Equals, http.StatusUnprocessableEntity)
        c.Check(jresp["uuid"], check.IsNil)
 }
 
 func (s *RouterIntegrationSuite) TestWritableBy(c *check.C) {
-       _, rr, jresp := doRequest(c, s.rtr, arvadostest.ActiveTokenV2, "GET", `/arvados/v1/users/`+arvadostest.ActiveUserUUID, nil, nil)
+       jresp := map[string]interface{}{}
+       _, rr := doRequest(c, s.rtr, arvadostest.ActiveTokenV2, "GET", `/arvados/v1/users/`+arvadostest.ActiveUserUUID, true, nil, nil, jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["writable_by"], check.DeepEquals, []interface{}{"zzzzz-tpzed-000000000000000", "zzzzz-tpzed-xurymjxw79nv3jz", "zzzzz-j7d0g-48foin4vonvc2at"})
 }
@@ -365,7 +489,8 @@ func (s *RouterIntegrationSuite) TestFullTimestampsInResponse(c *check.C) {
        uuid := arvadostest.CollectionReplicationDesired2Confirmed2UUID
        token := arvadostest.ActiveTokenV2
 
-       _, rr, jresp := doRequest(c, s.rtr, token, "GET", `/arvados/v1/collections/`+uuid, nil, nil)
+       jresp := map[string]interface{}{}
+       _, rr := doRequest(c, s.rtr, token, "GET", `/arvados/v1/collections/`+uuid, true, nil, nil, jresp)
        c.Check(rr.Code, check.Equals, http.StatusOK)
        c.Check(jresp["uuid"], check.Equals, uuid)
        expectNS := map[string]int{
@@ -392,14 +517,15 @@ func (s *RouterIntegrationSuite) TestSelectParam(c *check.C) {
        } {
                j, err := json.Marshal(sel)
                c.Assert(err, check.IsNil)
-               _, rr, resp := doRequest(c, s.rtr, token, "GET", "/arvados/v1/containers/"+uuid+"?select="+string(j), nil, nil)
+               jresp := map[string]interface{}{}
+               _, rr := doRequest(c, s.rtr, token, "GET", "/arvados/v1/containers/"+uuid+"?select="+string(j), true, nil, nil, jresp)
                c.Check(rr.Code, check.Equals, http.StatusOK)
 
-               c.Check(resp["kind"], check.Equals, "arvados#container")
-               c.Check(resp["uuid"], check.HasLen, 27)
-               c.Check(resp["command"], check.HasLen, 2)
-               c.Check(resp["mounts"], check.IsNil)
-               _, hasMounts := resp["mounts"]
+               c.Check(jresp["kind"], check.Equals, "arvados#container")
+               c.Check(jresp["uuid"], check.HasLen, 27)
+               c.Check(jresp["command"], check.HasLen, 2)
+               c.Check(jresp["mounts"], check.IsNil)
+               _, hasMounts := jresp["mounts"]
                c.Check(hasMounts, check.Equals, false)
        }
        // POST & PUT
@@ -409,23 +535,23 @@ func (s *RouterIntegrationSuite) TestSelectParam(c *check.C) {
        for _, method := range []string{"PUT", "POST"} {
                desc := "Today is " + time.Now().String()
                reqBody := "{\"description\":\"" + desc + "\"}"
-               var resp map[string]interface{}
+               jresp := map[string]interface{}{}
                var rr *httptest.ResponseRecorder
                if method == "PUT" {
-                       _, rr, resp = doRequest(c, s.rtr, token, method, "/arvados/v1/collections/"+uuid+"?select="+string(j), nil, bytes.NewReader([]byte(reqBody)))
+                       _, rr = doRequest(c, s.rtr, token, method, "/arvados/v1/collections/"+uuid+"?select="+string(j), true, nil, bytes.NewReader([]byte(reqBody)), jresp)
                } else {
-                       _, rr, resp = doRequest(c, s.rtr, token, method, "/arvados/v1/collections?select="+string(j), nil, bytes.NewReader([]byte(reqBody)))
+                       _, rr = doRequest(c, s.rtr, token, method, "/arvados/v1/collections?select="+string(j), true, nil, bytes.NewReader([]byte(reqBody)), jresp)
                }
                c.Check(rr.Code, check.Equals, http.StatusOK)
-               c.Check(resp["kind"], check.Equals, "arvados#collection")
-               c.Check(resp["uuid"], check.HasLen, 27)
-               c.Check(resp["description"], check.Equals, desc)
-               c.Check(resp["manifest_text"], check.IsNil)
+               c.Check(jresp["kind"], check.Equals, "arvados#collection")
+               c.Check(jresp["uuid"], check.HasLen, 27)
+               c.Check(jresp["description"], check.Equals, desc)
+               c.Check(jresp["manifest_text"], check.IsNil)
        }
 }
 
 func (s *RouterIntegrationSuite) TestHEAD(c *check.C) {
-       _, rr, _ := doRequest(c, s.rtr, arvadostest.ActiveTokenV2, "HEAD", "/arvados/v1/containers/"+arvadostest.QueuedContainerUUID, nil, nil)
+       _, rr := doRequest(c, s.rtr, arvadostest.ActiveTokenV2, "HEAD", "/arvados/v1/containers/"+arvadostest.QueuedContainerUUID, true, nil, nil, nil)
        c.Check(rr.Code, check.Equals, http.StatusOK)
 }
 
@@ -497,17 +623,24 @@ func (s *RouterIntegrationSuite) TestCORS(c *check.C) {
        }
 }
 
-func doRequest(c *check.C, rtr http.Handler, token, method, path string, hdrs http.Header, body io.Reader) (*http.Request, *httptest.ResponseRecorder, map[string]interface{}) {
+func doRequest(c *check.C, rtr http.Handler, token, method, path string, auth bool, hdrs http.Header, body io.Reader, jresp map[string]interface{}) (*http.Request, *httptest.ResponseRecorder) {
        req := httptest.NewRequest(method, path, body)
        for k, v := range hdrs {
                req.Header[k] = v
        }
-       req.Header.Set("Authorization", "Bearer "+token)
+       if auth {
+               req.Header.Set("Authorization", "Bearer "+token)
+       }
        rr := httptest.NewRecorder()
        rtr.ServeHTTP(rr, req)
-       c.Logf("response body: %s", rr.Body.String())
-       var jresp map[string]interface{}
-       err := json.Unmarshal(rr.Body.Bytes(), &jresp)
-       c.Check(err, check.IsNil)
-       return req, rr, jresp
+       respbody := rr.Body.String()
+       if len(respbody) > 10000 {
+               respbody = respbody[:10000] + "[...]"
+       }
+       c.Logf("response body: %s", respbody)
+       if jresp != nil {
+               err := json.Unmarshal(rr.Body.Bytes(), &jresp)
+               c.Check(err, check.IsNil)
+       }
+       return req, rr
 }
index d5763d9ef9bb9ad85a6395bb6c51a7651e53df3e..c6be679a256cb2e860d5ce179646e3378219d1c6 100644 (file)
@@ -16,9 +16,11 @@ import (
        "io/ioutil"
        "net"
        "net/http"
+       "net/http/httputil"
        "net/url"
        "strconv"
        "strings"
+       "sync"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
@@ -43,10 +45,13 @@ type Conn struct {
        SendHeader         http.Header
        RedactHostInErrors bool
 
-       clusterID     string
-       httpClient    http.Client
-       baseURL       url.URL
-       tokenProvider TokenProvider
+       clusterID                string
+       httpClient               http.Client
+       baseURL                  url.URL
+       tokenProvider            TokenProvider
+       discoveryDocument        *arvados.DiscoveryDocument
+       discoveryDocumentMtx     sync.Mutex
+       discoveryDocumentExpires time.Time
 }
 
 func NewConn(clusterID string, url *url.URL, insecure bool, tp TokenProvider) *Conn {
@@ -88,6 +93,8 @@ func (conn *Conn) requestAndDecode(ctx context.Context, dst interface{}, ep arva
                Scheme:     conn.baseURL.Scheme,
                APIHost:    conn.baseURL.Host,
                SendHeader: conn.SendHeader,
+               // Disable auto-retry
+               Timeout: 0,
        }
        tokens, err := conn.tokenProvider(ctx)
        if err != nil {
@@ -143,10 +150,13 @@ func (conn *Conn) requestAndDecode(ctx context.Context, dst interface{}, ep arva
        }
 
        if len(tokens) > 1 {
+               if params == nil {
+                       params = make(map[string]interface{})
+               }
                params["reader_tokens"] = tokens[1:]
        }
        path := ep.Path
-       if strings.Contains(ep.Path, "/{uuid}") {
+       if strings.Contains(ep.Path, "/{uuid}") && params != nil {
                uuid, _ := params["uuid"].(string)
                path = strings.Replace(path, "/{uuid}", "/"+uuid, 1)
                delete(params, "uuid")
@@ -186,6 +196,22 @@ func (conn *Conn) VocabularyGet(ctx context.Context) (arvados.Vocabulary, error)
        return resp, err
 }
 
+func (conn *Conn) DiscoveryDocument(ctx context.Context) (arvados.DiscoveryDocument, error) {
+       conn.discoveryDocumentMtx.Lock()
+       defer conn.discoveryDocumentMtx.Unlock()
+       if conn.discoveryDocument != nil && time.Now().Before(conn.discoveryDocumentExpires) {
+               return *conn.discoveryDocument, nil
+       }
+       var dd arvados.DiscoveryDocument
+       err := conn.requestAndDecode(ctx, &dd, arvados.EndpointDiscoveryDocument, nil, nil)
+       if err != nil {
+               return dd, err
+       }
+       conn.discoveryDocument = &dd
+       conn.discoveryDocumentExpires = time.Now().Add(time.Hour)
+       return *conn.discoveryDocument, nil
+}
+
 func (conn *Conn) Login(ctx context.Context, options arvados.LoginOptions) (arvados.LoginResponse, error) {
        ep := arvados.EndpointLogin
        var resp arvados.LoginResponse
@@ -217,6 +243,41 @@ func (conn *Conn) relativeToBaseURL(location string) string {
        return location
 }
 
+func (conn *Conn) AuthorizedKeyCreate(ctx context.Context, options arvados.CreateOptions) (arvados.AuthorizedKey, error) {
+       ep := arvados.EndpointAuthorizedKeyCreate
+       var resp arvados.AuthorizedKey
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) AuthorizedKeyUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.AuthorizedKey, error) {
+       ep := arvados.EndpointAuthorizedKeyUpdate
+       var resp arvados.AuthorizedKey
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) AuthorizedKeyGet(ctx context.Context, options arvados.GetOptions) (arvados.AuthorizedKey, error) {
+       ep := arvados.EndpointAuthorizedKeyGet
+       var resp arvados.AuthorizedKey
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) AuthorizedKeyList(ctx context.Context, options arvados.ListOptions) (arvados.AuthorizedKeyList, error) {
+       ep := arvados.EndpointAuthorizedKeyList
+       var resp arvados.AuthorizedKeyList
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) AuthorizedKeyDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.AuthorizedKey, error) {
+       ep := arvados.EndpointAuthorizedKeyDelete
+       var resp arvados.AuthorizedKey
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
 func (conn *Conn) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
        ep := arvados.EndpointCollectionCreate
        var resp arvados.Collection
@@ -340,7 +401,7 @@ func (conn *Conn) ContainerUnlock(ctx context.Context, options arvados.GetOption
 // a running container. If the returned error is nil, the caller is
 // responsible for closing sshconn.Conn.
 func (conn *Conn) ContainerSSH(ctx context.Context, options arvados.ContainerSSHOptions) (sshconn arvados.ConnectionResponse, err error) {
-       u, err := conn.baseURL.Parse("/" + strings.Replace(arvados.EndpointContainerSSH.Path, "{uuid}", options.UUID, -1))
+       u, err := conn.baseURL.Parse("/" + strings.Replace(arvados.EndpointContainerSSHCompat.Path, "{uuid}", options.UUID, -1))
        if err != nil {
                err = fmt.Errorf("url.Parse: %w", err)
                return
@@ -356,7 +417,7 @@ func (conn *Conn) ContainerSSH(ctx context.Context, options arvados.ContainerSSH
 // the controller. The caller should connect the returned resp.Conn to
 // a client-side yamux session.
 func (conn *Conn) ContainerGatewayTunnel(ctx context.Context, options arvados.ContainerGatewayTunnelOptions) (tunnelconn arvados.ConnectionResponse, err error) {
-       u, err := conn.baseURL.Parse("/" + strings.Replace(arvados.EndpointContainerGatewayTunnel.Path, "{uuid}", options.UUID, -1))
+       u, err := conn.baseURL.Parse("/" + strings.Replace(arvados.EndpointContainerGatewayTunnelCompat.Path, "{uuid}", options.UUID, -1))
        if err != nil {
                err = fmt.Errorf("url.Parse: %w", err)
                return
@@ -421,11 +482,11 @@ func (conn *Conn) socket(ctx context.Context, u *url.URL, upgradeHeader string,
                } else {
                        message = fmt.Sprintf("%q", body)
                }
-               return connresp, fmt.Errorf("server did not provide a tunnel: %s: %s", resp.Status, message)
+               return connresp, httpserver.ErrorWithStatus(fmt.Errorf("server did not provide a tunnel: %s: %s", resp.Status, message), resp.StatusCode)
        }
        if strings.ToLower(resp.Header.Get("Upgrade")) != upgradeHeader ||
                strings.ToLower(resp.Header.Get("Connection")) != "upgrade" {
-               return connresp, fmt.Errorf("bad response from server: Upgrade %q Connection %q", resp.Header.Get("Upgrade"), resp.Header.Get("Connection"))
+               return connresp, httpserver.ErrorWithStatus(fmt.Errorf("bad response from server: Upgrade %q Connection %q", resp.Header.Get("Upgrade"), resp.Header.Get("Connection")), http.StatusBadGateway)
        }
        connresp.Conn = netconn
        connresp.Bufrw = &bufio.ReadWriter{Reader: bufr, Writer: bufw}
@@ -468,6 +529,26 @@ func (conn *Conn) ContainerRequestDelete(ctx context.Context, options arvados.De
        return resp, err
 }
 
+func (conn *Conn) ContainerRequestContainerStatus(ctx context.Context, options arvados.GetOptions) (arvados.ContainerStatus, error) {
+       ep := arvados.EndpointContainerRequestContainerStatus
+       var resp arvados.ContainerStatus
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
+func (conn *Conn) ContainerRequestLog(ctx context.Context, options arvados.ContainerLogOptions) (resp http.Handler, err error) {
+       proxy := &httputil.ReverseProxy{
+               Transport: conn.httpClient.Transport,
+               Director: func(r *http.Request) {
+                       u := conn.baseURL
+                       u.Path = r.URL.Path
+                       u.RawQuery = fmt.Sprintf("no_forward=%v", options.NoForward)
+                       r.URL = &u
+               },
+       }
+       return proxy, nil
+}
+
 func (conn *Conn) GroupCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Group, error) {
        ep := arvados.EndpointGroupCreate
        var resp arvados.Group
index eee8db9ac830588754956afc62806b757d72b89a..0d1200fe12096843c8696a809d7220b4490acff9 100644 (file)
@@ -10,6 +10,7 @@ import (
        "os"
        "testing"
 
+       "git.arvados.org/arvados.git/lib/config"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
@@ -39,13 +40,26 @@ type RPCSuite struct {
 func (s *RPCSuite) SetUpTest(c *check.C) {
        ctx := ctxlog.Context(context.Background(), ctxlog.TestLogger(c))
        s.ctx = context.WithValue(ctx, contextKeyTestTokens, []string{arvadostest.ActiveToken})
-       s.conn = NewConn("zzzzz", &url.URL{Scheme: "https", Host: os.Getenv("ARVADOS_TEST_API_HOST")}, true, func(ctx context.Context) ([]string, error) {
+}
+
+func (s *RPCSuite) setupConn(c *check.C, host string) {
+       s.conn = NewConn("zzzzz", &url.URL{Scheme: "https", Host: host}, true, func(ctx context.Context) ([]string, error) {
                tokens, _ := ctx.Value(contextKeyTestTokens).([]string)
                return tokens, nil
        })
 }
 
-func (s *RPCSuite) TestLogin(c *check.C) {
+func (s *RPCSuite) workbench2URL(c *check.C) string {
+       loader := config.NewLoader(nil, s.log)
+       cfg, err := loader.Load()
+       c.Assert(err, check.IsNil)
+       cluster, err := cfg.GetCluster("")
+       c.Assert(err, check.IsNil)
+       return cluster.Services.Workbench2.ExternalURL.String()
+}
+
+func (s *RPCSuite) TestRailsLogin404(c *check.C) {
+       s.setupConn(c, os.Getenv("ARVADOS_TEST_API_HOST"))
        s.ctx = context.Background()
        opts := arvados.LoginOptions{
                ReturnTo: "https://foo.example.com/bar",
@@ -54,17 +68,30 @@ func (s *RPCSuite) TestLogin(c *check.C) {
        c.Check(err.(*arvados.TransactionError).StatusCode, check.Equals, 404)
 }
 
-func (s *RPCSuite) TestLogout(c *check.C) {
+func (s *RPCSuite) TestRailsLogout404(c *check.C) {
+       s.setupConn(c, os.Getenv("ARVADOS_TEST_API_HOST"))
        s.ctx = context.Background()
        opts := arvados.LogoutOptions{
                ReturnTo: "https://foo.example.com/bar",
        }
+       _, err := s.conn.Logout(s.ctx, opts)
+       c.Check(err.(*arvados.TransactionError).StatusCode, check.Equals, 404)
+}
+
+func (s *RPCSuite) TestControllerLogout(c *check.C) {
+       s.setupConn(c, os.Getenv("ARVADOS_API_HOST"))
+       s.ctx = context.Background()
+       url := s.workbench2URL(c)
+       opts := arvados.LogoutOptions{
+               ReturnTo: url,
+       }
        resp, err := s.conn.Logout(s.ctx, opts)
        c.Check(err, check.IsNil)
-       c.Check(resp.RedirectLocation, check.Equals, opts.ReturnTo)
+       c.Check(resp.RedirectLocation, check.Equals, url)
 }
 
 func (s *RPCSuite) TestCollectionCreate(c *check.C) {
+       s.setupConn(c, os.Getenv("ARVADOS_TEST_API_HOST"))
        coll, err := s.conn.CollectionCreate(s.ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
                "owner_uuid":         arvadostest.ActiveUserUUID,
                "portable_data_hash": "d41d8cd98f00b204e9800998ecf8427e+0",
@@ -74,6 +101,7 @@ func (s *RPCSuite) TestCollectionCreate(c *check.C) {
 }
 
 func (s *RPCSuite) TestSpecimenCRUD(c *check.C) {
+       s.setupConn(c, os.Getenv("ARVADOS_TEST_API_HOST"))
        sp, err := s.conn.SpecimenCreate(s.ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
                "owner_uuid": arvadostest.ActiveUserUUID,
                "properties": map[string]string{"foo": "bar"},
index 48ec93b8768c0a117f0af60d8433bb30d8f4467c..a722e5f1423b715ebc8f65e9cc1afb1cdb847f2e 100644 (file)
@@ -7,13 +7,16 @@ package crunchrun
 import (
        "bytes"
        "fmt"
-       "io/ioutil"
+       "io/fs"
 )
 
 // Return the current process's cgroup for the given subsystem.
-func findCgroup(subsystem string) (string, error) {
+//
+// If the host has cgroups v2 and not v1 (i.e., unified mode), return
+// the current process's cgroup.
+func findCgroup(fsys fs.FS, subsystem string) (string, error) {
        subsys := []byte(subsystem)
-       cgroups, err := ioutil.ReadFile("/proc/self/cgroup")
+       cgroups, err := fs.ReadFile(fsys, "proc/self/cgroup")
        if err != nil {
                return "", err
        }
@@ -22,7 +25,20 @@ func findCgroup(subsystem string) (string, error) {
                if len(toks) < 3 {
                        continue
                }
+               if len(toks[1]) == 0 && string(toks[0]) == "0" {
+                       // cgroups v2: "0::$PATH"
+                       //
+                       // In "hybrid" mode, this entry is last, so we
+                       // use it when the specified subsystem doesn't
+                       // match a cgroups v1 entry.
+                       //
+                       // In "unified" mode, this is the only entry,
+                       // so we use it regardless of which subsystem
+                       // was specified.
+                       return string(toks[2]), nil
+               }
                for _, s := range bytes.Split(toks[1], []byte(",")) {
+                       // cgroups v1: "7:cpu,cpuacct:/user.slice"
                        if bytes.Compare(s, subsys) == 0 {
                                return string(toks[2]), nil
                        }
index eb87456d14b0d1e0e60245460009d75cfe4a01b2..a1acb6fb922910bc7a386c396ff3b4719d0b9eac 100644 (file)
@@ -5,6 +5,11 @@
 package crunchrun
 
 import (
+       "bytes"
+       "os"
+       "os/exec"
+       "strings"
+
        . "gopkg.in/check.v1"
 )
 
@@ -13,11 +18,57 @@ type CgroupSuite struct{}
 var _ = Suite(&CgroupSuite{})
 
 func (s *CgroupSuite) TestFindCgroup(c *C) {
-       for _, s := range []string{"devices", "cpu", "cpuset"} {
-               g, err := findCgroup(s)
-               if c.Check(err, IsNil) {
-                       c.Check(g, Not(Equals), "", Commentf("subsys %q", s))
+       var testfiles []string
+       buf, err := exec.Command("find", "../crunchstat/testdata", "-name", "cgroup", "-type", "f").Output()
+       c.Assert(err, IsNil)
+       for _, testfile := range bytes.Split(buf, []byte{'\n'}) {
+               if len(testfile) > 0 {
+                       testfiles = append(testfiles, string(testfile))
+               }
+       }
+       testfiles = append(testfiles, "/proc/self/cgroup")
+
+       tmpdir := c.MkDir()
+       err = os.MkdirAll(tmpdir+"/proc/self", 0777)
+       c.Assert(err, IsNil)
+       fsys := os.DirFS(tmpdir)
+
+       for _, trial := range []struct {
+               match  string // if non-empty, only check testfiles containing this string
+               subsys string
+               expect string // empty means "any" (we never actually expect empty string)
+       }{
+               {"debian11", "blkio", "/user.slice/user-1000.slice/session-5424.scope"},
+               {"debian12", "cpuacct", "/user.slice/user-1000.slice/session-4.scope"},
+               {"debian12", "bogus-does-not-matter", "/user.slice/user-1000.slice/session-4.scope"},
+               {"ubuntu1804", "blkio", "/user.slice"},
+               {"ubuntu1804", "cpuacct", "/user.slice"},
+               {"", "cpu", ""},
+               {"", "cpuset", ""},
+               {"", "devices", ""},
+               {"", "bogus-does-not-matter", ""},
+       } {
+               for _, testfile := range testfiles {
+                       if !strings.Contains(testfile, trial.match) {
+                               continue
+                       }
+                       c.Logf("trial %+v testfile %s", trial, testfile)
+
+                       // Copy cgroup file into our fake proc/self/ dir
+                       buf, err := os.ReadFile(testfile)
+                       c.Assert(err, IsNil)
+                       err = os.WriteFile(tmpdir+"/proc/self/cgroup", buf, 0777)
+                       c.Assert(err, IsNil)
+
+                       cgroup, err := findCgroup(fsys, trial.subsys)
+                       if !c.Check(err, IsNil) {
+                               continue
+                       }
+                       c.Logf("\tcgroup = %q", cgroup)
+                       c.Check(cgroup, Not(Equals), "")
+                       if trial.expect != "" {
+                               c.Check(cgroup, Equals, trial.expect)
+                       }
                }
-               c.Logf("cgroup(%q) == %q", s, g)
        }
 }
index 3cb93fc746a78d01afd6a40023e00903a5c3a846..5b68e2c50ebaa88168c31039706f6aa5a3b3c38f 100644 (file)
@@ -5,6 +5,7 @@
 package crunchrun
 
 import (
+       "context"
        "crypto/hmac"
        "crypto/rand"
        "crypto/rsa"
@@ -17,12 +18,14 @@ import (
        "net/url"
        "os"
        "os/exec"
+       "strings"
        "sync"
        "syscall"
        "time"
 
        "git.arvados.org/arvados.git/lib/controller/rpc"
        "git.arvados.org/arvados.git/lib/selfsigned"
+       "git.arvados.org/arvados.git/lib/webdavfs"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/auth"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
@@ -31,7 +34,7 @@ import (
        "github.com/google/shlex"
        "github.com/hashicorp/yamux"
        "golang.org/x/crypto/ssh"
-       "golang.org/x/net/context"
+       "golang.org/x/net/webdav"
 )
 
 type GatewayTarget interface {
@@ -78,6 +81,10 @@ type Gateway struct {
        // controller process at the other end of the tunnel.
        UpdateTunnelURL func(url string)
 
+       // Source for serving WebDAV requests with
+       // X-Webdav-Source: /log
+       LogCollection arvados.CollectionFileSystem
+
        sshConfig   ssh.ServerConfig
        requestAuth string
        respondAuth string
@@ -157,7 +164,7 @@ func (gw *Gateway) Start() error {
 
        srv := &httpserver.Server{
                Server: http.Server{
-                       Handler: http.HandlerFunc(gw.handleSSH),
+                       Handler: gw,
                        TLSConfig: &tls.Config{
                                Certificates: []tls.Certificate{cert},
                        },
@@ -213,7 +220,7 @@ func (gw *Gateway) runTunnel(addr string) error {
                AuthSecret: gw.AuthSecret,
        })
        if err != nil {
-               return fmt.Errorf("error creating gateway tunnel: %s", err)
+               return fmt.Errorf("error creating gateway tunnel: %w", err)
        }
        mux, err := yamux.Client(tun.Conn, nil)
        if err != nil {
@@ -260,6 +267,75 @@ func (gw *Gateway) runTunnel(addr string) error {
        }
 }
 
+var webdavMethod = map[string]bool{
+       "GET":      true,
+       "OPTIONS":  true,
+       "PROPFIND": true,
+}
+
+func (gw *Gateway) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+       w.Header().Set("Vary", "X-Arvados-Authorization, X-Arvados-Container-Gateway-Uuid, X-Webdav-Prefix, X-Webdav-Source")
+       reqUUID := req.Header.Get("X-Arvados-Container-Gateway-Uuid")
+       if reqUUID == "" {
+               // older controller versions only send UUID as query param
+               req.ParseForm()
+               reqUUID = req.Form.Get("uuid")
+       }
+       if reqUUID != gw.ContainerUUID {
+               http.Error(w, fmt.Sprintf("misdirected request: meant for %q but received by crunch-run %q", reqUUID, gw.ContainerUUID), http.StatusBadGateway)
+               return
+       }
+       if req.Header.Get("X-Arvados-Authorization") != gw.requestAuth {
+               http.Error(w, "bad X-Arvados-Authorization header", http.StatusUnauthorized)
+               return
+       }
+       w.Header().Set("X-Arvados-Authorization-Response", gw.respondAuth)
+       switch {
+       case req.Method == "POST" && req.Header.Get("Upgrade") == "ssh":
+               gw.handleSSH(w, req)
+       case req.Header.Get("X-Webdav-Source") == "/log":
+               if !webdavMethod[req.Method] {
+                       http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+                       return
+               }
+               gw.handleLogsWebDAV(w, req)
+       default:
+               http.Error(w, "path not found", http.StatusNotFound)
+       }
+}
+
+func (gw *Gateway) handleLogsWebDAV(w http.ResponseWriter, r *http.Request) {
+       prefix := r.Header.Get("X-Webdav-Prefix")
+       if !strings.HasPrefix(r.URL.Path, prefix) {
+               http.Error(w, "X-Webdav-Prefix header is not a prefix of the requested path", http.StatusBadRequest)
+               return
+       }
+       if gw.LogCollection == nil {
+               http.Error(w, "Not found", http.StatusNotFound)
+               return
+       }
+       wh := webdav.Handler{
+               Prefix: prefix,
+               FileSystem: &webdavfs.FS{
+                       FileSystem:    gw.LogCollection,
+                       Prefix:        "",
+                       Writing:       false,
+                       AlwaysReadEOF: r.Method == "PROPFIND",
+               },
+               LockSystem: webdavfs.NoLockSystem,
+               Logger:     gw.webdavLogger,
+       }
+       wh.ServeHTTP(w, r)
+}
+
+func (gw *Gateway) webdavLogger(r *http.Request, err error) {
+       if err != nil && !os.IsNotExist(err) {
+               ctxlog.FromContext(r.Context()).WithError(err).Info("error reported by webdav handler")
+       } else {
+               ctxlog.FromContext(r.Context()).WithError(err).Debug("webdav request log")
+       }
+}
+
 // handleSSH connects to an SSH server that allows the caller to run
 // interactive commands as root (or any other desired user) inside the
 // container. The tunnel itself can only be created by an
@@ -282,22 +358,7 @@ func (gw *Gateway) runTunnel(addr string) error {
 // X-Arvados-Login-Username: argument to "docker exec --user": account
 // used to run command(s) inside the container.
 func (gw *Gateway) handleSSH(w http.ResponseWriter, req *http.Request) {
-       // In future we'll handle browser traffic too, but for now the
-       // only traffic we expect is an SSH tunnel from
-       // (*lib/controller/localdb.Conn)ContainerSSH()
-       if req.Method != "POST" || req.Header.Get("Upgrade") != "ssh" {
-               http.Error(w, "path not found", http.StatusNotFound)
-               return
-       }
        req.ParseForm()
-       if want := req.Form.Get("uuid"); want != gw.ContainerUUID {
-               http.Error(w, fmt.Sprintf("misdirected request: meant for %q but received by crunch-run %q", want, gw.ContainerUUID), http.StatusBadGateway)
-               return
-       }
-       if req.Header.Get("X-Arvados-Authorization") != gw.requestAuth {
-               http.Error(w, "bad X-Arvados-Authorization header", http.StatusUnauthorized)
-               return
-       }
        detachKeys := req.Form.Get("detach_keys")
        username := req.Form.Get("login_username")
        if username == "" {
@@ -316,7 +377,6 @@ func (gw *Gateway) handleSSH(w http.ResponseWriter, req *http.Request) {
        defer netconn.Close()
        w.Header().Set("Connection", "upgrade")
        w.Header().Set("Upgrade", "ssh")
-       w.Header().Set("X-Arvados-Authorization-Response", gw.respondAuth)
        netconn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\n"))
        w.Header().Write(netconn)
        netconn.Write([]byte("\r\n"))
index 72c714dfa4ef47bbe4d60adff4693edd97e7b7cb..a081c5d325dc2c3f8954de873dc26bf332e12066 100644 (file)
@@ -51,7 +51,6 @@ type filetodo struct {
 //     manifest, err := (&copier{...}).Copy()
 type copier struct {
        client        *arvados.Client
-       arvClient     IArvadosClient
        keepClient    IKeepClient
        hostOutputDir string
        ctrOutputDir  string
@@ -109,7 +108,7 @@ func (cp *copier) Copy() (string, error) {
 }
 
 func (cp *copier) copyFile(fs arvados.CollectionFileSystem, f filetodo) (int64, error) {
-       cp.logger.Printf("copying %q (%d bytes)", f.dst, f.size)
+       cp.logger.Printf("copying %q (%d bytes)", strings.TrimLeft(f.dst, "/"), f.size)
        dst, err := fs.OpenFile(f.dst, os.O_CREATE|os.O_WRONLY, 0666)
        if err != nil {
                return 0, err
@@ -162,6 +161,20 @@ func (cp *copier) walkMount(dest, src string, maxSymlinks int, walkMountsBelow b
        // copy, relative to its mount point -- ".", "./foo.txt", ...
        srcRelPath := filepath.Join(".", srcMount.Path, src[len(srcRoot):])
 
+       // outputRelPath is the path relative in the output directory
+       // that corresponds to the path in the output collection where
+       // the file will go, for logging
+       var outputRelPath = ""
+       if strings.HasPrefix(src, cp.ctrOutputDir) {
+               outputRelPath = strings.TrimPrefix(src[len(cp.ctrOutputDir):], "/")
+       }
+       if outputRelPath == "" {
+               // blank means copy a whole directory, so replace it
+               // with a wildcard to make it a little clearer what's
+               // going on since outputRelPath is only used for logging
+               outputRelPath = "*"
+       }
+
        switch {
        case srcMount.ExcludeFromOutput:
        case srcMount.Kind == "tmp":
@@ -170,12 +183,14 @@ func (cp *copier) walkMount(dest, src string, maxSymlinks int, walkMountsBelow b
        case srcMount.Kind != "collection":
                return fmt.Errorf("%q: unsupported mount %q in output (kind is %q)", src, srcRoot, srcMount.Kind)
        case !srcMount.Writable:
+               cp.logger.Printf("copying %q from %v/%v", outputRelPath, srcMount.PortableDataHash, strings.TrimPrefix(srcRelPath, "./"))
                mft, err := cp.getManifest(srcMount.PortableDataHash)
                if err != nil {
                        return err
                }
                cp.manifest += mft.Extract(srcRelPath, dest).Text
        default:
+               cp.logger.Printf("copying %q", outputRelPath)
                hostRoot, err := cp.hostRoot(srcRoot)
                if err != nil {
                        return err
@@ -356,7 +371,7 @@ func (cp *copier) getManifest(pdh string) (*manifest.Manifest, error) {
                return mft, nil
        }
        var coll arvados.Collection
-       err := cp.arvClient.Get("collections", pdh, nil, &coll)
+       err := cp.client.RequestAndDecode(&coll, "GET", "arvados/v1/collections/"+pdh, nil, nil)
        if err != nil {
                return nil, fmt.Errorf("error retrieving collection record for %q: %s", pdh, err)
        }
index 5e92490163f6e34bc935eae42d2002fdea74436f..c8936d1a9f53f31b218441ac24820ed906de2e00 100644 (file)
@@ -12,7 +12,6 @@ import (
        "syscall"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
-       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
        "github.com/sirupsen/logrus"
        check "gopkg.in/check.v1"
@@ -27,12 +26,9 @@ type copierSuite struct {
 
 func (s *copierSuite) SetUpTest(c *check.C) {
        tmpdir := c.MkDir()
-       api, err := arvadosclient.MakeArvadosClient()
-       c.Assert(err, check.IsNil)
        s.log = bytes.Buffer{}
        s.cp = copier{
                client:        arvados.NewClientFromEnv(),
-               arvClient:     api,
                hostOutputDir: tmpdir,
                ctrOutputDir:  "/ctr/outdir",
                mounts: map[string]arvados.Mount{
index 3708be0c2417d5e603a671d1c2058517b4d0a807..556a3bfe133389c80ba17be9536ec13b79969d5f 100644 (file)
@@ -12,6 +12,7 @@ import (
        "flag"
        "fmt"
        "io"
+       "io/fs"
        "io/ioutil"
        "log"
        "net"
@@ -45,6 +46,8 @@ import (
 
 type command struct{}
 
+var arvadosCertPath = "/etc/arvados/ca-certificates.crt"
+
 var Command = command{}
 
 // ConfigData contains environment variables and (when needed) cluster
@@ -75,7 +78,6 @@ type IKeepClient interface {
        ReadAt(locator string, p []byte, off int) (int, error)
        ManifestFileReader(m manifest.Manifest, filename string) (arvados.File, error)
        LocalLocator(locator string) (string, error)
-       ClearBlockCache()
        SetStorageClasses(sc []string)
 }
 
@@ -152,20 +154,12 @@ type ContainerRunner struct {
        hoststatLogger   io.WriteCloser
        hoststatReporter *crunchstat.Reporter
        statInterval     time.Duration
-       cgroupRoot       string
-       // What we expect the container's cgroup parent to be.
-       expectCgroupParent string
        // What we tell docker to use as the container's cgroup
-       // parent. Note: Ideally we would use the same field for both
-       // expectCgroupParent and setCgroupParent, and just make it
-       // default to "docker". However, when using docker < 1.10 with
-       // systemd, specifying a non-empty cgroup parent (even the
-       // default value "docker") hits a docker bug
-       // (https://github.com/docker/docker/issues/17126). Using two
-       // separate fields makes it possible to use the "expect cgroup
-       // parent to be X" feature even on sites where the "specify
-       // cgroup parent" feature breaks.
+       // parent.
        setCgroupParent string
+       // Fake root dir where crunchstat.Reporter should read OS
+       // files, for testing.
+       crunchstatFakeFS fs.FS
 
        cStateLock sync.Mutex
        cCancelled bool // StopContainer() invoked
@@ -501,7 +495,7 @@ func (runner *ContainerRunner) SetupMounts() (map[string]bindmount, error) {
                        }
                }
 
-               if bind == "/etc/arvados/ca-certificates.crt" {
+               if bind == arvadosCertPath {
                        needCertMount = false
                }
 
@@ -638,7 +632,7 @@ func (runner *ContainerRunner) SetupMounts() (map[string]bindmount, error) {
                        if err != nil {
                                return nil, fmt.Errorf("creating temp dir: %v", err)
                        }
-                       err = gitMount(mnt).extractTree(runner.ContainerArvClient, tmpdir, token)
+                       err = gitMount(mnt).extractTree(runner.containerClient, tmpdir, token)
                        if err != nil {
                                return nil, err
                        }
@@ -651,10 +645,19 @@ func (runner *ContainerRunner) SetupMounts() (map[string]bindmount, error) {
        }
 
        if needCertMount && runner.Container.RuntimeConstraints.API {
-               for _, certfile := range arvadosclient.CertFiles {
-                       _, err := os.Stat(certfile)
-                       if err == nil {
-                               bindmounts["/etc/arvados/ca-certificates.crt"] = bindmount{HostPath: certfile, ReadOnly: true}
+               for _, certfile := range []string{
+                       // Populated by caller, or sdk/go/arvados init(), or test suite:
+                       os.Getenv("SSL_CERT_FILE"),
+                       // Copied from Go 1.21 stdlib (src/crypto/x509/root_linux.go):
+                       "/etc/ssl/certs/ca-certificates.crt",                // Debian/Ubuntu/Gentoo etc.
+                       "/etc/pki/tls/certs/ca-bundle.crt",                  // Fedora/RHEL 6
+                       "/etc/ssl/ca-bundle.pem",                            // OpenSUSE
+                       "/etc/pki/tls/cacert.pem",                           // OpenELEC
+                       "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS/RHEL 7
+                       "/etc/ssl/cert.pem",                                 // Alpine Linux
+               } {
+                       if _, err := os.Stat(certfile); err == nil {
+                               bindmounts[arvadosCertPath] = bindmount{HostPath: certfile, ReadOnly: true}
                                break
                        }
                }
@@ -749,8 +752,16 @@ func (runner *ContainerRunner) startHoststat() error {
        }
        runner.hoststatLogger = NewThrottledLogger(w)
        runner.hoststatReporter = &crunchstat.Reporter{
-               Logger:     log.New(runner.hoststatLogger, "", 0),
-               CgroupRoot: runner.cgroupRoot,
+               Logger: log.New(runner.hoststatLogger, "", 0),
+               // Our own cgroup is the "host" cgroup, in the sense
+               // that it accounts for resource usage outside the
+               // container. It doesn't count _all_ resource usage on
+               // the system.
+               //
+               // TODO?: Use the furthest ancestor of our own cgroup
+               // that has stats available. (Currently crunchstat
+               // does not have that capability.)
+               Pid:        os.Getpid,
                PollPeriod: runner.statInterval,
        }
        runner.hoststatReporter.Start()
@@ -765,10 +776,9 @@ func (runner *ContainerRunner) startCrunchstat() error {
        }
        runner.statLogger = NewThrottledLogger(w)
        runner.statReporter = &crunchstat.Reporter{
-               CgroupParent: runner.expectCgroupParent,
-               CgroupRoot:   runner.cgroupRoot,
-               CID:          runner.executor.CgroupID(),
-               Logger:       log.New(runner.statLogger, "", 0),
+               Pid:    runner.executor.Pid,
+               FS:     runner.crunchstatFakeFS,
+               Logger: log.New(runner.statLogger, "", 0),
                MemThresholds: map[string][]crunchstat.Threshold{
                        "rss": crunchstat.NewThresholdsFromPercentages(runner.Container.RuntimeConstraints.RAM, []int64{90, 95, 99}),
                },
@@ -1124,6 +1134,7 @@ func (runner *ContainerRunner) WaitFinish() error {
        }
        runner.CrunchLog.Printf("Container exited with status code %d%s", exitcode, extra)
        err = runner.DispatcherArvClient.Update("containers", runner.Container.UUID, arvadosclient.Dict{
+               "select":    []string{"uuid"},
                "container": arvadosclient.Dict{"exit_code": exitcode},
        }, nil)
        if err != nil {
@@ -1200,7 +1211,10 @@ func (runner *ContainerRunner) updateLogs() {
                }
 
                err = runner.DispatcherArvClient.Update("containers", runner.Container.UUID, arvadosclient.Dict{
-                       "container": arvadosclient.Dict{"log": saved.PortableDataHash},
+                       "select": []string{"uuid"},
+                       "container": arvadosclient.Dict{
+                               "log": saved.PortableDataHash,
+                       },
                }, nil)
                if err != nil {
                        runner.CrunchLog.Printf("error updating container log to %s: %s", saved.PortableDataHash, err)
@@ -1316,6 +1330,7 @@ func (runner *ContainerRunner) checkSpotInterruptionNotices() {
 
 func (runner *ContainerRunner) updateRuntimeStatus(status arvadosclient.Dict) {
        err := runner.DispatcherArvClient.Update("containers", runner.Container.UUID, arvadosclient.Dict{
+               "select": []string{"uuid"},
                "container": arvadosclient.Dict{
                        "runtime_status": status,
                },
@@ -1332,7 +1347,9 @@ func (runner *ContainerRunner) CaptureOutput(bindmounts map[string]bindmount) er
                // Output may have been set directly by the container, so
                // refresh the container record to check.
                err := runner.DispatcherArvClient.Get("containers", runner.Container.UUID,
-                       nil, &runner.Container)
+                       arvadosclient.Dict{
+                               "select": []string{"output"},
+                       }, &runner.Container)
                if err != nil {
                        return err
                }
@@ -1345,7 +1362,6 @@ func (runner *ContainerRunner) CaptureOutput(bindmounts map[string]bindmount) er
 
        txt, err := (&copier{
                client:        runner.containerClient,
-               arvClient:     runner.ContainerArvClient,
                keepClient:    runner.ContainerKeepClient,
                hostOutputDir: runner.HostOutputDir,
                ctrOutputDir:  runner.Container.OutputPath,
@@ -1371,6 +1387,7 @@ func (runner *ContainerRunner) CaptureOutput(bindmounts map[string]bindmount) er
        var resp arvados.Collection
        err = runner.ContainerArvClient.Create("collections", arvadosclient.Dict{
                "ensure_unique_name": true,
+               "select":             []string{"portable_data_hash"},
                "collection": arvadosclient.Dict{
                        "is_trashed":    true,
                        "name":          "output for " + runner.Container.UUID,
@@ -1497,6 +1514,8 @@ func (runner *ContainerRunner) CommitLogs() error {
        return nil
 }
 
+// Create/update the log collection. Return value has UUID and
+// PortableDataHash fields populated, but others may be blank.
 func (runner *ContainerRunner) saveLogCollection(final bool) (response arvados.Collection, err error) {
        runner.logMtx.Lock()
        defer runner.logMtx.Unlock()
@@ -1521,11 +1540,20 @@ func (runner *ContainerRunner) saveLogCollection(final bool) (response arvados.C
        if final {
                updates["is_trashed"] = true
        } else {
-               exp := time.Now().Add(crunchLogUpdatePeriod * 24)
+               // We set trash_at so this collection gets
+               // automatically cleaned up eventually.  It used to be
+               // 12 hours but we had a situation where the API
+               // server was down over a weekend but the containers
+               // kept running such that the log collection got
+               // trashed, so now we make it 2 weeks.  refs #20378
+               exp := time.Now().Add(time.Duration(24*14) * time.Hour)
                updates["trash_at"] = exp
                updates["delete_at"] = exp
        }
-       reqBody := arvadosclient.Dict{"collection": updates}
+       reqBody := arvadosclient.Dict{
+               "select":     []string{"uuid", "portable_data_hash"},
+               "collection": updates,
+       }
        var err2 error
        if runner.logUUID == "" {
                reqBody["ensure_unique_name"] = true
@@ -1560,7 +1588,10 @@ func (runner *ContainerRunner) UpdateContainerRunning(logId string) error {
        return runner.DispatcherArvClient.Update(
                "containers",
                runner.Container.UUID,
-               arvadosclient.Dict{"container": updates},
+               arvadosclient.Dict{
+                       "select":    []string{"uuid"},
+                       "container": updates,
+               },
                nil,
        )
 }
@@ -1598,7 +1629,10 @@ func (runner *ContainerRunner) UpdateContainerFinal() error {
                update["output"] = *runner.OutputPDH
        }
        update["cost"] = runner.calculateCost(time.Now())
-       return runner.DispatcherArvClient.Update("containers", runner.Container.UUID, arvadosclient.Dict{"container": update}, nil)
+       return runner.DispatcherArvClient.Update("containers", runner.Container.UUID, arvadosclient.Dict{
+               "select":    []string{"uuid"},
+               "container": update,
+       }, nil)
 }
 
 // IsCancelled returns the value of Cancelled, with goroutine safety.
@@ -1643,11 +1677,7 @@ func (runner *ContainerRunner) Run() (err error) {
        signal.Notify(sigusr2, syscall.SIGUSR2)
        defer signal.Stop(sigusr2)
        runner.loadPrices()
-       go func() {
-               for range sigusr2 {
-                       runner.loadPrices()
-               }
-       }()
+       go runner.handleSIGUSR2(sigusr2)
 
        runner.finalState = "Queued"
 
@@ -1889,9 +1919,9 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
        log := log.New(stderr, "", 0)
        flags := flag.NewFlagSet(prog, flag.ContinueOnError)
        statInterval := flags.Duration("crunchstat-interval", 10*time.Second, "sampling period for periodic resource usage reporting")
-       cgroupRoot := flags.String("cgroup-root", "/sys/fs/cgroup", "path to sysfs cgroup tree")
-       cgroupParent := flags.String("cgroup-parent", "docker", "name of container's parent cgroup (ignored if -cgroup-parent-subsystem is used)")
-       cgroupParentSubsystem := flags.String("cgroup-parent-subsystem", "", "use current cgroup for given subsystem as parent cgroup for container")
+       flags.String("cgroup-root", "/sys/fs/cgroup", "path to sysfs cgroup tree (obsolete, ignored)")
+       flags.String("cgroup-parent", "docker", "name of container's parent cgroup (obsolete, ignored)")
+       cgroupParentSubsystem := flags.String("cgroup-parent-subsystem", "", "use current cgroup for given `subsystem` as parent cgroup for container (subsystem argument is only relevant for cgroups v1; in cgroups v2 / unified mode, any non-empty value means use current cgroup); if empty, use the docker daemon's default cgroup parent. See https://doc.arvados.org/install/crunch2-slurm/install-dispatch.html#CrunchRunCommand-cgroups")
        caCertsPath := flags.String("ca-certs", "", "Path to TLS root certificates")
        detach := flags.Bool("detach", false, "Detach from parent process and run in the background")
        stdinConfig := flags.Bool("stdin-config", false, "Load config and environment variables from JSON message on stdin")
@@ -1976,7 +2006,7 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
        time.Sleep(*sleep)
 
        if *caCertsPath != "" {
-               arvadosclient.CertFiles = []string{*caCertsPath}
+               os.Setenv("SSL_CERT_FILE", *caCertsPath)
        }
 
        keepstore, err := startLocalKeepstore(conf, io.MultiWriter(&keepstoreLogbuf, stderr))
@@ -1993,14 +2023,15 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
                log.Printf("%s: %v", containerUUID, err)
                return 1
        }
-       api.Retries = 8
+       // arvadosclient now interprets Retries=10 to mean
+       // Timeout=10m, retrying with exponential backoff + jitter.
+       api.Retries = 10
 
        kc, err := keepclient.MakeKeepClient(api)
        if err != nil {
                log.Printf("%s: %v", containerUUID, err)
                return 1
        }
-       kc.BlockCache = &keepclient.BlockCache{MaxBlocks: 2}
        kc.Retries = 4
 
        cr, err := NewContainerRunner(arvados.NewClientFromEnv(), api, kc, containerUUID)
@@ -2081,6 +2112,7 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
                        ContainerUUID: containerUUID,
                        Target:        cr.executor,
                        Log:           cr.CrunchLog,
+                       LogCollection: cr.LogCollection,
                }
                if gwListen == "" {
                        // Direct connection won't work, so we use the
@@ -2091,7 +2123,10 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
                        cr.gateway.UpdateTunnelURL = func(url string) {
                                cr.gateway.Address = "tunnel " + url
                                cr.DispatcherArvClient.Update("containers", containerUUID,
-                                       arvadosclient.Dict{"container": arvadosclient.Dict{"gateway_address": cr.gateway.Address}}, nil)
+                                       arvadosclient.Dict{
+                                               "select":    []string{"uuid"},
+                                               "container": arvadosclient.Dict{"gateway_address": cr.gateway.Address},
+                                       }, nil)
                        }
                }
                err = cr.gateway.Start()
@@ -2109,19 +2144,16 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
 
        cr.parentTemp = parentTemp
        cr.statInterval = *statInterval
-       cr.cgroupRoot = *cgroupRoot
-       cr.expectCgroupParent = *cgroupParent
        cr.enableMemoryLimit = *enableMemoryLimit
        cr.enableNetwork = *enableNetwork
        cr.networkMode = *networkMode
        if *cgroupParentSubsystem != "" {
-               p, err := findCgroup(*cgroupParentSubsystem)
+               p, err := findCgroup(os.DirFS("/"), *cgroupParentSubsystem)
                if err != nil {
                        log.Printf("fatal: cgroup parent subsystem: %s", err)
                        return 1
                }
                cr.setCgroupParent = p
-               cr.expectCgroupParent = p
        }
 
        if conf.EC2SpotCheck {
@@ -2169,7 +2201,9 @@ func hpcConfData(uuid string, configFile string, stderr io.Writer) ConfigData {
                fmt.Fprintf(stderr, "error setting up arvadosclient: %s\n", err)
                return conf
        }
-       arv.Retries = 8
+       // arvadosclient now interprets Retries=10 to mean
+       // Timeout=10m, retrying with exponential backoff + jitter.
+       arv.Retries = 10
        var ctr arvados.Container
        err = arv.Call("GET", "containers", uuid, "", arvadosclient.Dict{"select": []string{"runtime_constraints"}}, &ctr)
        if err != nil {
@@ -2222,9 +2256,14 @@ func startLocalKeepstore(configData ConfigData, logbuf io.Writer) (*exec.Cmd, er
        }
 
        // Rather than have an alternate way to tell keepstore how
-       // many buffers to use when starting it this way, we just
-       // modify the cluster configuration that we feed it on stdin.
-       configData.Cluster.API.MaxKeepBlobBuffers = configData.KeepBuffers
+       // many buffers to use, etc., when starting it this way, we
+       // just modify the cluster configuration that we feed it on
+       // stdin.
+       ccfg := *configData.Cluster
+       ccfg.API.MaxKeepBlobBuffers = configData.KeepBuffers
+       ccfg.Collections.BlobTrash = false
+       ccfg.Collections.BlobTrashConcurrency = 0
+       ccfg.Collections.BlobDeleteConcurrency = 0
 
        localaddr := localKeepstoreAddr()
        ln, err := net.Listen("tcp", net.JoinHostPort(localaddr, "0"))
@@ -2244,7 +2283,7 @@ func startLocalKeepstore(configData ConfigData, logbuf io.Writer) (*exec.Cmd, er
        var confJSON bytes.Buffer
        err = json.NewEncoder(&confJSON).Encode(arvados.Config{
                Clusters: map[string]arvados.Cluster{
-                       configData.Cluster.ClusterID: *configData.Cluster,
+                       ccfg.ClusterID: ccfg,
                },
        })
        if err != nil {
@@ -2453,3 +2492,16 @@ func (cr *ContainerRunner) calculateCost(now time.Time) float64 {
 
        return cost
 }
+
+func (runner *ContainerRunner) handleSIGUSR2(sigchan chan os.Signal) {
+       for range sigchan {
+               runner.loadPrices()
+               update := arvadosclient.Dict{
+                       "select": []string{"uuid"},
+                       "container": arvadosclient.Dict{
+                               "cost": runner.calculateCost(time.Now()),
+                       },
+               }
+               runner.DispatcherArvClient.Update("containers", runner.Container.UUID, update, nil)
+       }
+}
index 701be4517b631178924d2be2fbcdef5fdfd85139..276dd366617e71a17bd7acb907a30941459ce7a5 100644 (file)
@@ -6,6 +6,7 @@ package crunchrun
 
 import (
        "bytes"
+       "context"
        "crypto/md5"
        "encoding/json"
        "errors"
@@ -16,11 +17,13 @@ import (
        "math/rand"
        "net/http"
        "net/http/httptest"
+       "net/http/httputil"
+       "net/url"
        "os"
        "os/exec"
+       "path"
        "regexp"
        "runtime/pprof"
-       "strconv"
        "strings"
        "sync"
        "sync/atomic"
@@ -34,9 +37,10 @@ import (
        "git.arvados.org/arvados.git/sdk/go/arvadosclient"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
        "git.arvados.org/arvados.git/sdk/go/manifest"
-       "golang.org/x/net/context"
 
        . "gopkg.in/check.v1"
+       git_client "gopkg.in/src-d/go-git.v4/plumbing/transport/client"
+       git_http "gopkg.in/src-d/go-git.v4/plumbing/transport/http"
 )
 
 // Gocheck boilerplate
@@ -57,6 +61,20 @@ type TestSuite struct {
        keepmountTmp             []string
        testDispatcherKeepClient KeepTestClient
        testContainerKeepClient  KeepTestClient
+       debian12MemoryCurrent    int64
+       debian12SwapCurrent      int64
+}
+
+func (s *TestSuite) SetUpSuite(c *C) {
+       buf, err := os.ReadFile("../crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.current")
+       c.Assert(err, IsNil)
+       _, err = fmt.Sscanf(string(buf), "%d", &s.debian12MemoryCurrent)
+       c.Assert(err, IsNil)
+
+       buf, err = os.ReadFile("../crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.swap.current")
+       c.Assert(err, IsNil)
+       _, err = fmt.Sscanf(string(buf), "%d", &s.debian12SwapCurrent)
+       c.Assert(err, IsNil)
 }
 
 func (s *TestSuite) SetUpTest(c *C) {
@@ -145,9 +163,9 @@ func (e *stubExecutor) Start() error {
        go func() { e.exit <- e.runFunc() }()
        return e.startErr
 }
-func (e *stubExecutor) CgroupID() string { return "cgroupid" }
-func (e *stubExecutor) Stop() error      { e.stopped = true; go func() { e.exit <- -1 }(); return e.stopErr }
-func (e *stubExecutor) Close()           { e.closed = true }
+func (e *stubExecutor) Pid() int    { return 1115883 } // matches pid in ../crunchstat/testdata/debian12/proc/
+func (e *stubExecutor) Stop() error { e.stopped = true; go func() { e.exit <- -1 }(); return e.stopErr }
+func (e *stubExecutor) Close()      { e.closed = true }
 func (e *stubExecutor) Wait(context.Context) (int, error) {
        return <-e.exit, e.waitErr
 }
@@ -350,9 +368,6 @@ func (client *KeepTestClient) ReadAt(string, []byte, int) (int, error) {
        return 0, errors.New("not implemented")
 }
 
-func (client *KeepTestClient) ClearBlockCache() {
-}
-
 func (client *KeepTestClient) Close() {
        client.Content = nil
 }
@@ -415,6 +430,67 @@ func (client *KeepTestClient) ManifestFileReader(m manifest.Manifest, filename s
        return nil, nil
 }
 
+type apiStubServer struct {
+       server    *httptest.Server
+       proxy     *httputil.ReverseProxy
+       intercept func(http.ResponseWriter, *http.Request) bool
+
+       container arvados.Container
+       logs      map[string]string
+}
+
+func apiStub() (*arvados.Client, *apiStubServer) {
+       client := arvados.NewClientFromEnv()
+       apistub := &apiStubServer{}
+       apistub.server = httptest.NewTLSServer(apistub)
+       apistub.proxy = httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "https", Host: client.APIHost})
+       if client.Insecure {
+               apistub.proxy.Transport = arvados.InsecureHTTPClient.Transport
+       }
+       client.APIHost = apistub.server.Listener.Addr().String()
+       return client, apistub
+}
+
+func (apistub *apiStubServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+       if apistub.intercept != nil && apistub.intercept(w, r) {
+               return
+       }
+       if r.Method == "POST" && r.URL.Path == "/arvados/v1/logs" {
+               var body struct {
+                       Log struct {
+                               EventType  string `json:"event_type"`
+                               Properties struct {
+                                       Text string
+                               }
+                       }
+               }
+               json.NewDecoder(r.Body).Decode(&body)
+               apistub.logs[body.Log.EventType] += body.Log.Properties.Text
+               return
+       }
+       if r.Method == "GET" && r.URL.Path == "/arvados/v1/collections/"+hwPDH {
+               json.NewEncoder(w).Encode(arvados.Collection{ManifestText: hwManifest})
+               return
+       }
+       if r.Method == "GET" && r.URL.Path == "/arvados/v1/collections/"+otherPDH {
+               json.NewEncoder(w).Encode(arvados.Collection{ManifestText: otherManifest})
+               return
+       }
+       if r.Method == "GET" && r.URL.Path == "/arvados/v1/collections/"+normalizedWithSubdirsPDH {
+               json.NewEncoder(w).Encode(arvados.Collection{ManifestText: normalizedManifestWithSubdirs})
+               return
+       }
+       if r.Method == "GET" && r.URL.Path == "/arvados/v1/collections/"+denormalizedWithSubdirsPDH {
+               json.NewEncoder(w).Encode(arvados.Collection{ManifestText: denormalizedManifestWithSubdirs})
+               return
+       }
+       if r.Method == "GET" && r.URL.Path == "/arvados/v1/containers/"+apistub.container.UUID {
+               json.NewEncoder(w).Encode(apistub.container)
+               return
+       }
+       apistub.proxy.ServeHTTP(w, r)
+}
+
 func (s *TestSuite) TestLoadImage(c *C) {
        s.runner.Container.ContainerImage = arvadostest.DockerImage112PDH
        s.runner.Container.Mounts = map[string]arvados.Mount{
@@ -686,8 +762,9 @@ func (s *TestSuite) fullRunHelper(c *C, record string, extraMounts []string, fn
                }
                return d, err
        }
+       client, _ := apiStub()
        s.runner.MkArvClient = func(token string) (IArvadosClient, IKeepClient, *arvados.Client, error) {
-               return &ArvTestClient{secretMounts: secretMounts}, &s.testContainerKeepClient, nil, nil
+               return &ArvTestClient{secretMounts: secretMounts}, &s.testContainerKeepClient, client, nil
        }
 
        if extraMounts != nil && len(extraMounts) > 0 {
@@ -899,6 +976,7 @@ func (s *TestSuite) TestContainerWaitFails(c *C) {
 }
 
 func (s *TestSuite) TestCrunchstat(c *C) {
+       s.runner.crunchstatFakeFS = os.DirFS("../crunchstat/testdata/debian12")
        s.fullRunHelper(c, `{
                "command": ["sleep", "1"],
                "container_image": "`+arvadostest.DockerImage112PDH+`",
@@ -917,18 +995,11 @@ func (s *TestSuite) TestCrunchstat(c *C) {
        c.Check(s.api.CalledWith("container.exit_code", 0), NotNil)
        c.Check(s.api.CalledWith("container.state", "Complete"), NotNil)
 
-       // We didn't actually start a container, so crunchstat didn't
-       // find accounting files and therefore didn't log any stats.
-       // It should have logged a "can't find accounting files"
-       // message after one poll interval, though, so we can confirm
-       // it's alive:
        c.Assert(s.api.Logs["crunchstat"], NotNil)
-       c.Check(s.api.Logs["crunchstat"].String(), Matches, `(?ms).*cgroup stats files have not appeared after 100ms.*`)
+       c.Check(s.api.Logs["crunchstat"].String(), Matches, `(?ms).*mem \d+ swap \d+ pgmajfault \d+ rss.*`)
 
-       // The "files never appeared" log assures us that we called
-       // (*crunchstat.Reporter)Stop(), and that we set it up with
-       // the correct container ID "abcde":
-       c.Check(s.api.Logs["crunchstat"].String(), Matches, `(?ms).*cgroup stats files never appeared for cgroupid\n`)
+       // Check that we called (*crunchstat.Reporter)Stop().
+       c.Check(s.api.Logs["crunch-run"].String(), Matches, `(?ms).*Maximum crunch-run memory rss usage was \d+ bytes\n.*`)
 }
 
 func (s *TestSuite) TestNodeInfoLog(c *C) {
@@ -988,8 +1059,8 @@ func (s *TestSuite) TestLogVersionAndRuntime(c *C) {
        c.Check(s.api.Logs["crunch-run"].String(), Matches, `(?ms).*Using container runtime: stub.*`)
 }
 
-func (s *TestSuite) testLogRSSThresholds(c *C, ram int, expected []int, notExpected int) {
-       s.runner.cgroupRoot = "testdata/fakestat"
+func (s *TestSuite) testLogRSSThresholds(c *C, ram int64, expected []int, notExpected int) {
+       s.runner.crunchstatFakeFS = os.DirFS("../crunchstat/testdata/debian12")
        s.fullRunHelper(c, `{
                "command": ["true"],
                "container_image": "`+arvadostest.DockerImage112PDH+`",
@@ -998,35 +1069,36 @@ func (s *TestSuite) testLogRSSThresholds(c *C, ram int, expected []int, notExpec
                "mounts": {"/tmp": {"kind": "tmp"} },
                "output_path": "/tmp",
                "priority": 1,
-               "runtime_constraints": {"ram": `+strconv.Itoa(ram)+`},
+               "runtime_constraints": {"ram": `+fmt.Sprintf("%d", ram)+`},
                "state": "Locked"
        }`, nil, func() int { return 0 })
+       c.Logf("=== crunchstat logs\n%s\n", s.api.Logs["crunchstat"].String())
        logs := s.api.Logs["crunch-run"].String()
-       pattern := logLineStart + `Container using over %d%% of memory \(rss 734003200/%d bytes\)`
+       pattern := logLineStart + `Container using over %d%% of memory \(rss %d/%d bytes\)`
        var threshold int
        for _, threshold = range expected {
-               c.Check(logs, Matches, fmt.Sprintf(pattern, threshold, ram))
+               c.Check(logs, Matches, fmt.Sprintf(pattern, threshold, s.debian12MemoryCurrent, ram))
        }
        if notExpected > threshold {
-               c.Check(logs, Not(Matches), fmt.Sprintf(pattern, notExpected, ram))
+               c.Check(logs, Not(Matches), fmt.Sprintf(pattern, notExpected, s.debian12MemoryCurrent, ram))
        }
 }
 
 func (s *TestSuite) TestLogNoRSSThresholds(c *C) {
-       s.testLogRSSThresholds(c, 7340032000, []int{}, 90)
+       s.testLogRSSThresholds(c, s.debian12MemoryCurrent*10, []int{}, 90)
 }
 
 func (s *TestSuite) TestLogSomeRSSThresholds(c *C) {
-       onePercentRSS := 7340032
+       onePercentRSS := s.debian12MemoryCurrent / 100
        s.testLogRSSThresholds(c, 102*onePercentRSS, []int{90, 95}, 99)
 }
 
 func (s *TestSuite) TestLogAllRSSThresholds(c *C) {
-       s.testLogRSSThresholds(c, 734003299, []int{90, 95, 99}, 0)
+       s.testLogRSSThresholds(c, s.debian12MemoryCurrent, []int{90, 95, 99}, 0)
 }
 
 func (s *TestSuite) TestLogMaximaAfterRun(c *C) {
-       s.runner.cgroupRoot = "testdata/fakestat"
+       s.runner.crunchstatFakeFS = os.DirFS("../crunchstat/testdata/debian12")
        s.runner.parentTemp = c.MkDir()
        s.fullRunHelper(c, `{
         "command": ["true"],
@@ -1036,16 +1108,15 @@ func (s *TestSuite) TestLogMaximaAfterRun(c *C) {
         "mounts": {"/tmp": {"kind": "tmp"} },
         "output_path": "/tmp",
         "priority": 1,
-        "runtime_constraints": {"ram": 7340032000},
+        "runtime_constraints": {"ram": `+fmt.Sprintf("%d", s.debian12MemoryCurrent*10)+`},
         "state": "Locked"
     }`, nil, func() int { return 0 })
        logs := s.api.Logs["crunch-run"].String()
        for _, expected := range []string{
                `Maximum disk usage was \d+%, \d+/\d+ bytes`,
-               `Maximum container memory cache usage was 73400320 bytes`,
-               `Maximum container memory swap usage was 320 bytes`,
-               `Maximum container memory pgmajfault usage was 20 faults`,
-               `Maximum container memory rss usage was 10%, 734003200/7340032000 bytes`,
+               fmt.Sprintf(`Maximum container memory swap usage was %d bytes`, s.debian12SwapCurrent),
+               `Maximum container memory pgmajfault usage was \d+ faults`,
+               fmt.Sprintf(`Maximum container memory rss usage was 10%%, %d/%d bytes`, s.debian12MemoryCurrent, s.debian12MemoryCurrent*10),
                `Maximum crunch-run memory rss usage was \d+ bytes`,
        } {
                c.Check(logs, Matches, logLineStart+expected)
@@ -1339,11 +1410,11 @@ func (am *ArvMountCmdLine) ArvMountTest(c []string, token string) (*exec.Cmd, er
        return nil, nil
 }
 
-func stubCert(temp string) string {
+func stubCert(c *C, temp string) string {
        path := temp + "/ca-certificates.crt"
-       crt, _ := os.Create(path)
-       crt.Close()
-       arvadosclient.CertFiles = []string{path}
+       err := os.WriteFile(path, []byte{}, 0666)
+       c.Assert(err, IsNil)
+       os.Setenv("SSL_CERT_FILE", path)
        return path
 }
 
@@ -1351,13 +1422,14 @@ func (s *TestSuite) TestSetupMounts(c *C) {
        cr := s.runner
        am := &ArvMountCmdLine{}
        cr.RunArvMount = am.ArvMountTest
+       cr.containerClient, _ = apiStub()
        cr.ContainerArvClient = &ArvTestClient{}
        cr.ContainerKeepClient = &KeepTestClient{}
        cr.Container.OutputStorageClasses = []string{"default"}
 
        realTemp := c.MkDir()
        certTemp := c.MkDir()
-       stubCertPath := stubCert(certTemp)
+       stubCertPath := stubCert(c, certTemp)
        cr.parentTemp = realTemp
 
        i := 0
@@ -1673,7 +1745,7 @@ func (s *TestSuite) TestSetupMounts(c *C) {
        {
                i = 0
                cr.ArvMountPoint = ""
-               (*GitMountSuite)(nil).useTestGitServer(c)
+               git_client.InstallProtocol("https", git_http.NewClient(arvados.InsecureHTTPClient))
                cr.token = arvadostest.ActiveToken
                cr.Container.Mounts = make(map[string]arvados.Mount)
                cr.Container.Mounts = map[string]arvados.Mount{
@@ -2350,6 +2422,80 @@ func (s *TestSuite) TestCalculateCost(c *C) {
        c.Check(logbuf.String(), Not(Matches), `(?ms).*changed to 2\.00 .* changed to 2\.00 .*`)
 }
 
+func (s *TestSuite) TestSIGUSR2CostUpdate(c *C) {
+       pid := os.Getpid()
+       now := time.Now()
+       pricesJSON, err := json.Marshal([]cloud.InstancePrice{
+               {StartTime: now.Add(-4 * time.Hour), Price: 2.4},
+               {StartTime: now.Add(-2 * time.Hour), Price: 2.6},
+       })
+       c.Assert(err, IsNil)
+
+       os.Setenv("InstanceType", `{"Price":2.2}`)
+       defer os.Unsetenv("InstanceType")
+       defer func(s string) { lockdir = s }(lockdir)
+       lockdir = c.MkDir()
+
+       // We can't use s.api.CalledWith because timing differences will yield
+       // different cost values across runs. getCostUpdate iterates over API
+       // calls until it finds one that sets the cost, then writes that value
+       // to the next index of costUpdates.
+       deadline := now.Add(time.Second)
+       costUpdates := make([]float64, 2)
+       costIndex := 0
+       apiIndex := 0
+       getCostUpdate := func() {
+               for ; time.Now().Before(deadline); time.Sleep(time.Second / 10) {
+                       for apiIndex < len(s.api.Content) {
+                               update := s.api.Content[apiIndex]
+                               apiIndex++
+                               var ok bool
+                               var cost float64
+                               if update, ok = update["container"].(arvadosclient.Dict); !ok {
+                                       continue
+                               }
+                               if cost, ok = update["cost"].(float64); !ok {
+                                       continue
+                               }
+                               c.Logf("API call #%d updates cost to %v", apiIndex-1, cost)
+                               costUpdates[costIndex] = cost
+                               costIndex++
+                               return
+                       }
+               }
+       }
+
+       s.fullRunHelper(c, `{
+               "command": ["true"],
+               "container_image": "`+arvadostest.DockerImage112PDH+`",
+               "cwd": ".",
+               "environment": {},
+               "mounts": {"/tmp": {"kind": "tmp"} },
+               "output_path": "/tmp",
+               "priority": 1,
+               "runtime_constraints": {},
+               "state": "Locked",
+               "uuid": "zzzzz-dz642-20230320101530a"
+       }`, nil, func() int {
+               s.runner.costStartTime = now.Add(-3 * time.Hour)
+               err := syscall.Kill(pid, syscall.SIGUSR2)
+               c.Check(err, IsNil, Commentf("error sending first SIGUSR2 to runner"))
+               getCostUpdate()
+
+               err = os.WriteFile(path.Join(lockdir, pricesfile), pricesJSON, 0o700)
+               c.Check(err, IsNil, Commentf("error writing JSON prices file"))
+               err = syscall.Kill(pid, syscall.SIGUSR2)
+               c.Check(err, IsNil, Commentf("error sending second SIGUSR2 to runner"))
+               getCostUpdate()
+
+               return 0
+       })
+       // Comparing with format strings makes it easy to ignore minor variations
+       // in cost across runs while keeping diagnostics pretty.
+       c.Check(fmt.Sprintf("%.3f", costUpdates[0]), Equals, "6.600")
+       c.Check(fmt.Sprintf("%.3f", costUpdates[1]), Equals, "7.600")
+}
+
 type FakeProcess struct {
        cmdLine []string
 }
index bb635265862fff8a4bf36127000297bbad16e051..4f449133f3a18014500c0ebd7d0fd78ae8e3b6af 100644 (file)
@@ -4,6 +4,7 @@
 package crunchrun
 
 import (
+       "context"
        "fmt"
        "io"
        "io/ioutil"
@@ -17,7 +18,6 @@ import (
        dockertypes "github.com/docker/docker/api/types"
        dockercontainer "github.com/docker/docker/api/types/container"
        dockerclient "github.com/docker/docker/client"
-       "golang.org/x/net/context"
 )
 
 // Docker daemon won't let you set a limit less than ~10 MiB
@@ -34,7 +34,7 @@ const DockerAPIVersion = "1.35"
 // Number of consecutive "inspect container" failures before
 // concluding Docker is unresponsive, giving up, and cancelling the
 // container.
-const dockerWatchdogThreshold = 3
+const dockerWatchdogThreshold = 5
 
 type dockerExecutor struct {
        containerUUID    string
@@ -52,7 +52,7 @@ func newDockerExecutor(containerUUID string, logf func(string, ...interface{}),
        // currently the minimum version we want to support.
        client, err := dockerclient.NewClient(dockerclient.DefaultDockerHost, DockerAPIVersion, nil, nil)
        if watchdogInterval < 1 {
-               watchdogInterval = time.Minute
+               watchdogInterval = time.Minute * 2
        }
        return &dockerExecutor{
                containerUUID:    containerUUID,
@@ -187,7 +187,7 @@ func (e *dockerExecutor) config(spec containerSpec) (dockercontainer.Config, doc
 
 func (e *dockerExecutor) Create(spec containerSpec) error {
        cfg, hostCfg := e.config(spec)
-       created, err := e.dockerclient.ContainerCreate(context.TODO(), &cfg, &hostCfg, nil, e.containerUUID)
+       created, err := e.dockerclient.ContainerCreate(context.TODO(), &cfg, &hostCfg, nil, nil, e.containerUUID)
        if err != nil {
                return fmt.Errorf("While creating container: %v", err)
        }
@@ -195,8 +195,15 @@ func (e *dockerExecutor) Create(spec containerSpec) error {
        return e.startIO(spec.Stdin, spec.Stdout, spec.Stderr)
 }
 
-func (e *dockerExecutor) CgroupID() string {
-       return e.containerID
+func (e *dockerExecutor) Pid() int {
+       ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(10*time.Second))
+       defer cancel()
+       ctr, err := e.dockerclient.ContainerInspect(ctx, e.containerID)
+       if err == nil && ctr.State != nil {
+               return ctr.State.Pid
+       } else {
+               return 0
+       }
 }
 
 func (e *dockerExecutor) Start() error {
index 1ed460acd966c3a64d32e11d749a6ac04e8260f1..308b05cdeb33b529d46564658cdb5c6dcfc8b1af 100644 (file)
@@ -4,10 +4,10 @@
 package crunchrun
 
 import (
+       "context"
        "io"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
-       "golang.org/x/net/context"
 )
 
 type bindmount struct {
@@ -51,8 +51,9 @@ type containerExecutor interface {
        // Start the container
        Start() error
 
-       // CID the container will belong to
-       CgroupID() string
+       // Process ID of a process in the container.  Return 0 if
+       // container is finished or no process has started yet.
+       Pid() int
 
        // Stop the container immediately
        Stop() error
index e757f579fe957eff333892c2fc7a12de9fff82e2..3a91c7864113ce5b422c24d0a5a02ed690d51fdc 100644 (file)
@@ -6,6 +6,7 @@ package crunchrun
 
 import (
        "bytes"
+       "context"
        "fmt"
        "io"
        "io/ioutil"
@@ -18,7 +19,6 @@ import (
        "git.arvados.org/arvados.git/lib/diagnostics"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
-       "golang.org/x/net/context"
        . "gopkg.in/check.v1"
 )
 
@@ -134,6 +134,10 @@ func (s *executorSuite) TestExecCleanEnv(c *C) {
                        // singularity also sets this by itself (v3.5.2, but not v3.7.4)
                case "PROMPT_COMMAND", "PS1", "SINGULARITY_BIND", "SINGULARITY_COMMAND", "SINGULARITY_ENVIRONMENT":
                        // singularity also sets these by itself (v3.7.4)
+               case "SINGULARITY_NO_EVAL":
+                       // our singularity driver sets this to control
+                       // singularity behavior, and it gets passed
+                       // through to the container
                default:
                        got[kv[0]] = kv[1]
                }
index 92bb6d11d94a3f0ad49095db5d45692f0dc9f8b7..561ea18de402ef04f04545dd3b2f8cde855eeffc 100644 (file)
@@ -48,25 +48,22 @@ func (gm gitMount) validate() error {
 
 // ExtractTree extracts the specified tree into dir, which is an
 // existing empty local directory.
-func (gm gitMount) extractTree(ac IArvadosClient, dir string, token string) error {
+func (gm gitMount) extractTree(ac *arvados.Client, dir string, token string) error {
        err := gm.validate()
        if err != nil {
                return err
        }
-       baseURL, err := ac.Discovery("gitUrl")
+       dd, err := ac.DiscoveryDocument()
        if err != nil {
-               return fmt.Errorf("discover gitUrl from API: %s", err)
-       } else if _, ok := baseURL.(string); !ok {
-               return fmt.Errorf("discover gitUrl from API: expected string, found %T", baseURL)
+               return fmt.Errorf("error getting discovery document: %w", err)
        }
-
-       u, err := url.Parse(baseURL.(string))
+       u, err := url.Parse(dd.GitURL)
        if err != nil {
-               return fmt.Errorf("parse gitUrl %q: %s", baseURL, err)
+               return fmt.Errorf("parse gitUrl %q: %s", dd.GitURL, err)
        }
        u, err = u.Parse("/" + gm.UUID + ".git")
        if err != nil {
-               return fmt.Errorf("build git url from %q, %q: %s", baseURL, gm.UUID, err)
+               return fmt.Errorf("build git url from %q, %q: %s", dd.GitURL, gm.UUID, err)
        }
        store := memory.NewStorage()
        repo, err := git.Init(store, osfs.New(dir))
index e39beaa943832487f23c4215b697becc6a47dc02..ac98dcc480254ae77a8dedeabe3076675c3c6c98 100644 (file)
@@ -6,14 +6,11 @@ package crunchrun
 
 import (
        "io/ioutil"
-       "net/url"
        "os"
        "path/filepath"
 
-       "git.arvados.org/arvados.git/lib/config"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
-       "git.arvados.org/arvados.git/sdk/go/ctxlog"
        check "gopkg.in/check.v1"
        git_client "gopkg.in/src-d/go-git.v4/plumbing/transport/client"
        git_http "gopkg.in/src-d/go-git.v4/plumbing/transport/http"
@@ -26,11 +23,10 @@ type GitMountSuite struct {
 var _ = check.Suite(&GitMountSuite{})
 
 func (s *GitMountSuite) SetUpTest(c *check.C) {
-       s.useTestGitServer(c)
-
        var err error
        s.tmpdir, err = ioutil.TempDir("", "")
        c.Assert(err, check.IsNil)
+       git_client.InstallProtocol("https", git_http.NewClient(arvados.InsecureHTTPClient))
 }
 
 func (s *GitMountSuite) TearDownTest(c *check.C) {
@@ -39,13 +35,14 @@ func (s *GitMountSuite) TearDownTest(c *check.C) {
 }
 
 // Commit fd3531f is crunch-run-tree-test
-func (s *GitMountSuite) TestextractTree(c *check.C) {
+func (s *GitMountSuite) TestExtractTree(c *check.C) {
        gm := gitMount{
                Path:   "/",
                UUID:   arvadostest.Repository2UUID,
                Commit: "fd3531f42995344f36c30b79f55f27b502f3d344",
        }
-       err := gm.extractTree(&ArvTestClient{}, s.tmpdir, arvadostest.ActiveToken)
+       ac := arvados.NewClientFromEnv()
+       err := gm.extractTree(ac, s.tmpdir, arvadostest.ActiveToken)
        c.Check(err, check.IsNil)
 
        fnm := filepath.Join(s.tmpdir, "dir1/dir2/file with mode 0644")
@@ -85,7 +82,7 @@ func (s *GitMountSuite) TestExtractNonTipCommit(c *check.C) {
                UUID:   arvadostest.Repository2UUID,
                Commit: "5ebfab0522851df01fec11ec55a6d0f4877b542e",
        }
-       err := gm.extractTree(&ArvTestClient{}, s.tmpdir, arvadostest.ActiveToken)
+       err := gm.extractTree(arvados.NewClientFromEnv(), s.tmpdir, arvadostest.ActiveToken)
        c.Check(err, check.IsNil)
 
        fnm := filepath.Join(s.tmpdir, "file only on testbranch")
@@ -100,7 +97,7 @@ func (s *GitMountSuite) TestNonexistentRepository(c *check.C) {
                UUID:   "zzzzz-s0uqq-nonexistentrepo",
                Commit: "5ebfab0522851df01fec11ec55a6d0f4877b542e",
        }
-       err := gm.extractTree(&ArvTestClient{}, s.tmpdir, arvadostest.ActiveToken)
+       err := gm.extractTree(arvados.NewClientFromEnv(), s.tmpdir, arvadostest.ActiveToken)
        c.Check(err, check.NotNil)
        c.Check(err, check.ErrorMatches, ".*repository not found.*")
 
@@ -113,7 +110,7 @@ func (s *GitMountSuite) TestNonexistentCommit(c *check.C) {
                UUID:   arvadostest.Repository2UUID,
                Commit: "bb66b6bb6b6bbb6b6b6b66b6b6b6b6b6b6b6b66b",
        }
-       err := gm.extractTree(&ArvTestClient{}, s.tmpdir, arvadostest.ActiveToken)
+       err := gm.extractTree(arvados.NewClientFromEnv(), s.tmpdir, arvadostest.ActiveToken)
        c.Check(err, check.NotNil)
        c.Check(err, check.ErrorMatches, ".*object not found.*")
 
@@ -127,8 +124,8 @@ func (s *GitMountSuite) TestGitUrlDiscoveryFails(c *check.C) {
                UUID:   arvadostest.Repository2UUID,
                Commit: "5ebfab0522851df01fec11ec55a6d0f4877b542e",
        }
-       err := gm.extractTree(&ArvTestClient{}, s.tmpdir, arvadostest.ActiveToken)
-       c.Check(err, check.ErrorMatches, ".*gitUrl.*")
+       err := gm.extractTree(&arvados.Client{}, s.tmpdir, arvadostest.ActiveToken)
+       c.Check(err, check.ErrorMatches, ".*error getting discovery doc.*")
 }
 
 func (s *GitMountSuite) TestInvalid(c *check.C) {
@@ -186,7 +183,7 @@ func (s *GitMountSuite) TestInvalid(c *check.C) {
                        matcher: ".*writable.*",
                },
        } {
-               err := trial.gm.extractTree(&ArvTestClient{}, s.tmpdir, arvadostest.ActiveToken)
+               err := trial.gm.extractTree(arvados.NewClientFromEnv(), s.tmpdir, arvadostest.ActiveToken)
                c.Check(err, check.NotNil)
                s.checkTmpdirContents(c, []string{})
 
@@ -202,15 +199,3 @@ func (s *GitMountSuite) checkTmpdirContents(c *check.C, expect []string) {
        c.Check(err, check.IsNil)
        c.Check(names, check.DeepEquals, expect)
 }
-
-func (*GitMountSuite) useTestGitServer(c *check.C) {
-       git_client.InstallProtocol("https", git_http.NewClient(arvados.InsecureHTTPClient))
-
-       loader := config.NewLoader(nil, ctxlog.TestLogger(c))
-       cfg, err := loader.Load()
-       c.Assert(err, check.IsNil)
-       cluster, err := cfg.GetCluster("")
-       c.Assert(err, check.IsNil)
-
-       discoveryMap["gitUrl"] = (*url.URL)(&cluster.Services.GitHTTP.ExternalURL).String()
-}
index d569020824c22373d5098e0afd4c14d6156dd773..ef5cc567dbb6118522eea3f33477fd19649bd024 100644 (file)
@@ -20,7 +20,6 @@ import (
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "git.arvados.org/arvados.git/sdk/go/keepclient"
-       "git.arvados.org/arvados.git/services/keepstore"
        . "gopkg.in/check.v1"
 )
 
@@ -195,7 +194,9 @@ func (s *integrationSuite) TestRunTrivialContainerWithLocalKeepstore(c *C) {
                        volume.Replication = 2
                        cluster.Volumes[uuid] = volume
 
-                       var v keepstore.UnixVolume
+                       var v struct {
+                               Root string
+                       }
                        err = json.Unmarshal(volume.DriverParameters, &v)
                        c.Assert(err, IsNil)
                        err = os.Mkdir(v.Root, 0777)
@@ -220,6 +221,8 @@ func (s *integrationSuite) TestRunTrivialContainerWithLocalKeepstore(c *C) {
                if trial.logConfig == "none" {
                        c.Check(logExists, Equals, false)
                } else {
+                       c.Check(log, Matches, `(?ms).*not running trash worker.*`)
+                       c.Check(log, Matches, `(?ms).*not running trash emptier.*`)
                        c.Check(log, trial.matchGetReq, `(?ms).*"reqMethod":"GET".*`)
                        c.Check(log, trial.matchPutReq, `(?ms).*"reqMethod":"PUT".*,"reqPath":"0e3bcff26d51c895a60ea0d4585e134d".*`)
                }
index 76a55c4992bbd933085e83282391b3e3b241fb04..91a1b77cf4fa6ab2d22bf9b476be8a756ea6b0b1 100644 (file)
@@ -175,9 +175,9 @@ func ReadWriteLines(in io.Reader, writer io.Writer, done chan<- bool) {
 }
 
 // NewThrottledLogger creates a new thottled logger that
-// (a) prepends timestamps to each line
-// (b) batches log messages and only calls the underlying Writer
-//  at most once per "crunchLogSecondsBetweenEvents" seconds.
+//   - prepends timestamps to each line, and
+//   - batches log messages and only calls the underlying Writer
+//     at most once per "crunchLogSecondsBetweenEvents" seconds.
 func NewThrottledLogger(writer io.WriteCloser) *ThrottledLogger {
        tl := &ThrottledLogger{}
        tl.flush = make(chan struct{}, 1)
index fdd4f27b7f9af5463517e3658020795c75ceb8d5..42f165fd756b8027e7a9df880ac910db9fa0b2b5 100644 (file)
@@ -191,6 +191,10 @@ func (s *LoggingTestSuite) TestWriteLogsWithRateLimitThrottleBytesPerEvent(c *C)
        s.testWriteLogsWithRateLimit(c, "crunchLimitLogBytesPerJob", 50, 67108864, "Exceeded log limit 50 bytes (crunch_limit_log_bytes_per_job)")
 }
 
+func (s *LoggingTestSuite) TestWriteLogsWithZeroBytesPerJob(c *C) {
+       s.testWriteLogsWithRateLimit(c, "crunchLimitLogBytesPerJob", 0, 67108864, "Exceeded log limit 0 bytes (crunch_limit_log_bytes_per_job)")
+}
+
 func (s *LoggingTestSuite) testWriteLogsWithRateLimit(c *C, throttleParam string, throttleValue int, throttleDefault int, expected string) {
        discoveryMap[throttleParam] = float64(throttleValue)
        defer func() {
index 1da401f859f94b36655f771e5fea5af750b0cbe7..fd26297713c4a77b084d58e72512f6c22b1e670e 100644 (file)
@@ -6,6 +6,7 @@ package crunchrun
 
 import (
        "bytes"
+       "context"
        "errors"
        "fmt"
        "io/ioutil"
@@ -21,7 +22,6 @@ import (
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
-       "golang.org/x/net/context"
 )
 
 type singularityExecutor struct {
@@ -353,8 +353,9 @@ func (e *singularityExecutor) Start() error {
        return nil
 }
 
-func (e *singularityExecutor) CgroupID() string {
-       return ""
+func (e *singularityExecutor) Pid() int {
+       // see https://dev.arvados.org/issues/17244#note-21
+       return 0
 }
 
 func (e *singularityExecutor) Stop() error {
diff --git a/lib/crunchstat/command.go b/lib/crunchstat/command.go
new file mode 100644 (file)
index 0000000..8c79c13
--- /dev/null
@@ -0,0 +1,106 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package crunchstat
+
+import (
+       "flag"
+       "fmt"
+       "io"
+       "log"
+       "os/exec"
+       "syscall"
+       "time"
+
+       "git.arvados.org/arvados.git/lib/cmd"
+)
+
+var Command = command{}
+
+type command struct{}
+
+func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+       flags := flag.NewFlagSet(prog, flag.ExitOnError)
+       poll := flags.Duration("poll", 10*time.Second, "reporting interval")
+       debug := flags.Bool("debug", false, "show additional debug info")
+       dump := flags.String("dump", "", "save snapshot of OS files in given `directory` (for creating test cases)")
+       getVersion := flags.Bool("version", false, "print version information and exit")
+
+       if ok, code := cmd.ParseFlags(flags, prog, args, "program [args ...]", stderr); !ok {
+               return code
+       } else if *getVersion {
+               fmt.Printf("%s %s\n", prog, cmd.Version.String())
+               return 0
+       } else if flags.NArg() == 0 {
+               fmt.Fprintf(stderr, "missing required argument: program (try -help)\n")
+               return 2
+       }
+
+       reporter := &Reporter{
+               Logger:     log.New(stderr, prog+": ", 0),
+               Debug:      *debug,
+               PollPeriod: *poll,
+       }
+       reporter.Logger.Printf("%s %s", prog, cmd.Version.String())
+       reporter.Logger.Printf("running %v", flags.Args())
+       cmd := exec.Command(flags.Arg(0), flags.Args()[1:]...)
+
+       // Child process will use our stdin and stdout pipes (we close
+       // our copies below)
+       cmd.Stdin = stdin
+       cmd.Stdout = stdout
+       // Child process stderr and our stats will both go to stderr
+       cmd.Stderr = stderr
+
+       if err := cmd.Start(); err != nil {
+               reporter.Logger.Printf("error in cmd.Start: %v", err)
+               return 1
+       }
+       reporter.Pid = func() int {
+               return cmd.Process.Pid
+       }
+       reporter.Start()
+       defer reporter.Stop()
+       if stdin, ok := stdin.(io.Closer); ok {
+               stdin.Close()
+       }
+       if stdout, ok := stdout.(io.Closer); ok {
+               stdout.Close()
+       }
+
+       failed := false
+       if *dump != "" {
+               err := reporter.dumpSourceFiles(*dump)
+               if err != nil {
+                       fmt.Fprintf(stderr, "error dumping source files: %s\n", err)
+                       failed = true
+               }
+       }
+
+       err := cmd.Wait()
+
+       if err, ok := err.(*exec.ExitError); ok {
+               // The program has exited with an exit code != 0
+
+               // This works on both Unix and Windows. Although
+               // package syscall is generally platform dependent,
+               // WaitStatus is defined for both Unix and Windows and
+               // in both cases has an ExitStatus() method with the
+               // same signature.
+               if status, ok := err.Sys().(syscall.WaitStatus); ok {
+                       return status.ExitStatus()
+               } else {
+                       reporter.Logger.Printf("ExitError without WaitStatus: %v", err)
+                       return 1
+               }
+       } else if err != nil {
+               reporter.Logger.Printf("error running command: %v", err)
+               return 1
+       }
+
+       if failed {
+               return 1
+       }
+       return 0
+}
index ad1cc7a97a47eba4423bd3be704b0c21807fb62b..bbd0a7fd2f0acae244857bb1eb466cc2fe2f0246 100644 (file)
@@ -12,8 +12,10 @@ import (
        "errors"
        "fmt"
        "io"
+       "io/fs"
        "io/ioutil"
        "os"
+       "path/filepath"
        "regexp"
        "sort"
        "strconv"
@@ -33,26 +35,20 @@ type logPrinter interface {
 // A Reporter gathers statistics for a cgroup and writes them to a
 // log.Logger.
 type Reporter struct {
-       // CID of the container to monitor. If empty, read the CID
-       // from CIDFile (first waiting until a non-empty file appears
-       // at CIDFile). If CIDFile is also empty, report host
-       // statistics.
-       CID string
-
-       // Path to a file we can read CID from.
-       CIDFile string
-
-       // Where cgroup accounting files live on this system, e.g.,
-       // "/sys/fs/cgroup".
-       CgroupRoot string
-
-       // Parent cgroup, e.g., "docker".
-       CgroupParent string
+       // Func that returns the pid of a process inside the desired
+       // cgroup. Reporter will call Pid periodically until it
+       // returns a positive number, then start reporting stats for
+       // the cgroup that process belongs to.
+       //
+       // Pid is used when cgroups v2 is available. For cgroups v1,
+       // see below.
+       Pid func() int
 
        // Interval between samples. Must be positive.
        PollPeriod time.Duration
 
-       // Temporary directory, will be monitored for available, used & total space.
+       // Temporary directory, will be monitored for available, used
+       // & total space.
        TempDir string
 
        // Where to write statistics. Must not be nil.
@@ -66,8 +62,28 @@ type Reporter struct {
        // When the corresponding stat exceeds a threshold, that will be logged.
        MemThresholds map[string][]Threshold
 
+       // Filesystem to read /proc entries and cgroup stats from.
+       // Non-nil for testing, nil for real root filesystem.
+       FS fs.FS
+
+       // Enable debug messages.
+       Debug bool
+
+       // available cgroup hierarchies
+       statFiles struct {
+               cpuMax            string // v2
+               cpusetCpus        string // v1,v2 (via /proc/$PID/cpuset)
+               cpuacctStat       string // v1 (via /proc/$PID/cgroup => cpuacct)
+               cpuStat           string // v2
+               ioServiceBytes    string // v1 (via /proc/$PID/cgroup => blkio)
+               ioStat            string // v2
+               memoryStat        string // v1 and v2 (but v2 is missing some entries)
+               memoryCurrent     string // v2
+               memorySwapCurrent string // v2
+               netDev            string // /proc/$PID/net/dev
+       }
+
        kernelPageSize      int64
-       reportedStatFile    map[string]string
        lastNetSample       map[string]ioSample
        lastDiskIOSample    map[string]ioSample
        lastCPUSample       cpuSample
@@ -76,10 +92,16 @@ type Reporter struct {
        maxDiskSpaceSample  diskSpaceSample
        maxMemSample        map[memoryKey]int64
 
+       // process returned by Pid(), whose cgroup stats we are
+       // reporting
+       pid int
+
+       // individual processes whose memory size we are reporting
        reportPIDs   map[string]int
        reportPIDsMu sync.Mutex
 
        done    chan struct{} // closed when we should stop reporting
+       ready   chan struct{} // have pid and stat files
        flushed chan struct{} // closed when we have made our last report
 }
 
@@ -126,7 +148,11 @@ type memoryKey struct {
 // Callers should not modify public data fields after calling Start.
 func (r *Reporter) Start() {
        r.done = make(chan struct{})
+       r.ready = make(chan struct{})
        r.flushed = make(chan struct{})
+       if r.FS == nil {
+               r.FS = os.DirFS("/")
+       }
        go r.run()
 }
 
@@ -150,6 +176,164 @@ func (r *Reporter) Stop() {
        <-r.flushed
 }
 
+var v1keys = map[string]bool{
+       "blkio":   true,
+       "cpuacct": true,
+       "cpuset":  true,
+       "memory":  true,
+}
+
+// Find cgroup hierarchies in /proc/mounts, e.g.,
+//
+//     {
+//             "blkio": "/sys/fs/cgroup/blkio",
+//             "unified": "/sys/fs/cgroup/unified",
+//     }
+func (r *Reporter) cgroupMounts() map[string]string {
+       procmounts, err := fs.ReadFile(r.FS, "proc/mounts")
+       if err != nil {
+               r.Logger.Printf("error reading /proc/mounts: %s", err)
+               return nil
+       }
+       mounts := map[string]string{}
+       for _, line := range bytes.Split(procmounts, []byte{'\n'}) {
+               fields := bytes.SplitN(line, []byte{' '}, 6)
+               if len(fields) != 6 {
+                       continue
+               }
+               switch string(fields[2]) {
+               case "cgroup2":
+                       // cgroup /sys/fs/cgroup/unified cgroup2 rw,nosuid,nodev,noexec,relatime 0 0
+                       mounts["unified"] = string(fields[1])
+               case "cgroup":
+                       // cgroup /sys/fs/cgroup/blkio cgroup rw,nosuid,nodev,noexec,relatime,blkio 0 0
+                       options := bytes.Split(fields[3], []byte{','})
+                       for _, option := range options {
+                               option := string(option)
+                               if v1keys[option] {
+                                       mounts[option] = string(fields[1])
+                                       break
+                               }
+                       }
+               }
+       }
+       return mounts
+}
+
+// generate map of cgroup controller => path for r.pid.
+//
+// the "unified" controller represents cgroups v2.
+func (r *Reporter) cgroupPaths(mounts map[string]string) map[string]string {
+       if len(mounts) == 0 {
+               return nil
+       }
+       procdir := fmt.Sprintf("proc/%d", r.pid)
+       buf, err := fs.ReadFile(r.FS, procdir+"/cgroup")
+       if err != nil {
+               r.Logger.Printf("error reading cgroup file: %s", err)
+               return nil
+       }
+       paths := map[string]string{}
+       for _, line := range bytes.Split(buf, []byte{'\n'}) {
+               // The entry for cgroup v2 is always in the format
+               // "0::$PATH" --
+               // https://docs.kernel.org/admin-guide/cgroup-v2.html
+               if bytes.HasPrefix(line, []byte("0::/")) && mounts["unified"] != "" {
+                       paths["unified"] = mounts["unified"] + string(line[3:])
+                       continue
+               }
+               // cgroups v1 entries look like
+               // "6:cpu,cpuacct:/user.slice"
+               fields := bytes.SplitN(line, []byte{':'}, 3)
+               if len(fields) != 3 {
+                       continue
+               }
+               for _, key := range bytes.Split(fields[1], []byte{','}) {
+                       key := string(key)
+                       if mounts[key] != "" {
+                               paths[key] = mounts[key] + string(fields[2])
+                       }
+               }
+       }
+       // In unified mode, /proc/$PID/cgroup doesn't have a cpuset
+       // entry, but we still need it -- there's no cpuset.cpus file
+       // in the cgroup2 subtree indicated by the 0::$PATH entry. We
+       // have to get the right path from /proc/$PID/cpuset.
+       if _, found := paths["cpuset"]; !found && mounts["unified"] != "" {
+               buf, _ := fs.ReadFile(r.FS, procdir+"/cpuset")
+               cpusetPath := string(bytes.TrimRight(buf, "\n"))
+               paths["cpuset"] = mounts["unified"] + cpusetPath
+       }
+       return paths
+}
+
+func (r *Reporter) findStatFiles() {
+       mounts := r.cgroupMounts()
+       paths := r.cgroupPaths(mounts)
+       done := map[*string]bool{}
+       for _, try := range []struct {
+               statFile *string
+               pathkey  string
+               file     string
+       }{
+               {&r.statFiles.cpuMax, "unified", "cpu.max"},
+               {&r.statFiles.cpusetCpus, "cpuset", "cpuset.cpus.effective"},
+               {&r.statFiles.cpusetCpus, "cpuset", "cpuset.cpus"},
+               {&r.statFiles.cpuacctStat, "cpuacct", "cpuacct.stat"},
+               {&r.statFiles.cpuStat, "unified", "cpu.stat"},
+               // blkio.throttle.io_service_bytes must precede
+               // blkio.io_service_bytes -- on ubuntu1804, the latter
+               // is present but reports 0
+               {&r.statFiles.ioServiceBytes, "blkio", "blkio.throttle.io_service_bytes"},
+               {&r.statFiles.ioServiceBytes, "blkio", "blkio.io_service_bytes"},
+               {&r.statFiles.ioStat, "unified", "io.stat"},
+               {&r.statFiles.memoryStat, "unified", "memory.stat"},
+               {&r.statFiles.memoryStat, "memory", "memory.stat"},
+               {&r.statFiles.memoryCurrent, "unified", "memory.current"},
+               {&r.statFiles.memorySwapCurrent, "unified", "memory.swap.current"},
+       } {
+               startpath, ok := paths[try.pathkey]
+               if !ok || done[try.statFile] {
+                       continue
+               }
+               // /proc/$PID/cgroup says cgroup path is
+               // /exa/mple/exa/mple, however, sometimes the file we
+               // need is not under that path, it's only available in
+               // a parent cgroup's dir.  So we start at
+               // /sys/fs/cgroup/unified/exa/mple/exa/mple/ and walk
+               // up to /sys/fs/cgroup/unified/ until we find the
+               // desired file.
+               //
+               // This might mean our reported stats include more
+               // cgroups in the cgroup tree, but it's the best we
+               // can do.
+               for path := startpath; path != "" && path != "/" && (path == startpath || strings.HasPrefix(path, mounts[try.pathkey])); path, _ = filepath.Split(strings.TrimRight(path, "/")) {
+                       target := strings.TrimLeft(filepath.Join(path, try.file), "/")
+                       buf, err := fs.ReadFile(r.FS, target)
+                       if err != nil || len(buf) == 0 || bytes.Equal(buf, []byte{'\n'}) {
+                               if r.Debug {
+                                       if os.IsNotExist(err) {
+                                               // don't stutter
+                                               err = os.ErrNotExist
+                                       }
+                                       r.Logger.Printf("skip /%s: %s", target, err)
+                               }
+                               continue
+                       }
+                       *try.statFile = target
+                       done[try.statFile] = true
+                       r.Logger.Printf("notice: reading stats from /%s", target)
+                       break
+               }
+       }
+
+       netdev := fmt.Sprintf("proc/%d/net/dev", r.pid)
+       if buf, err := fs.ReadFile(r.FS, netdev); err == nil && len(buf) > 0 {
+               r.statFiles.netDev = netdev
+               r.Logger.Printf("using /%s", netdev)
+       }
+}
+
 func (r *Reporter) reportMemoryMax(logger logPrinter, source, statName string, value, limit int64) {
        var units string
        switch statName {
@@ -170,7 +354,7 @@ func (r *Reporter) reportMemoryMax(logger logPrinter, source, statName string, v
 
 func (r *Reporter) LogMaxima(logger logPrinter, memLimits map[string]int64) {
        if r.lastCPUSample.hasData {
-               logger.Printf("Total CPU usage was %f user and %f sys on %d CPUs",
+               logger.Printf("Total CPU usage was %f user and %f sys on %.2f CPUs",
                        r.lastCPUSample.user, r.lastCPUSample.sys, r.lastCPUSample.cpus)
        }
        for disk, sample := range r.lastDiskIOSample {
@@ -214,85 +398,6 @@ func (r *Reporter) readAllOrWarn(in io.Reader) ([]byte, error) {
        return content, err
 }
 
-// Open the cgroup stats file in /sys/fs corresponding to the target
-// cgroup, and return an io.ReadCloser. If no stats file is available,
-// return nil.
-//
-// Log the file that was opened, if it isn't the same file opened on
-// the last openStatFile for this stat.
-//
-// Log "not available" if no file is found and either this stat has
-// been available in the past, or verbose==true.
-//
-// TODO: Instead of trying all options, choose a process in the
-// container, and read /proc/PID/cgroup to determine the appropriate
-// cgroup root for the given statgroup. (This will avoid falling back
-// to host-level stats during container setup and teardown.)
-func (r *Reporter) openStatFile(statgroup, stat string, verbose bool) (io.ReadCloser, error) {
-       var paths []string
-       if r.CID != "" {
-               // Collect container's stats
-               paths = []string{
-                       fmt.Sprintf("%s/%s/%s/%s/%s", r.CgroupRoot, statgroup, r.CgroupParent, r.CID, stat),
-                       fmt.Sprintf("%s/%s/%s/%s", r.CgroupRoot, r.CgroupParent, r.CID, stat),
-               }
-       } else {
-               // Collect this host's stats
-               paths = []string{
-                       fmt.Sprintf("%s/%s/%s", r.CgroupRoot, statgroup, stat),
-                       fmt.Sprintf("%s/%s", r.CgroupRoot, stat),
-               }
-       }
-       var path string
-       var file *os.File
-       var err error
-       for _, path = range paths {
-               file, err = os.Open(path)
-               if err == nil {
-                       break
-               } else {
-                       path = ""
-               }
-       }
-       if pathWas := r.reportedStatFile[stat]; pathWas != path {
-               // Log whenever we start using a new/different cgroup
-               // stat file for a given statistic. This typically
-               // happens 1 to 3 times per statistic, depending on
-               // whether we happen to collect stats [a] before any
-               // processes have been created in the container and
-               // [b] after all contained processes have exited.
-               if path == "" && verbose {
-                       r.Logger.Printf("notice: stats not available: stat %s, statgroup %s, cid %s, parent %s, root %s\n", stat, statgroup, r.CID, r.CgroupParent, r.CgroupRoot)
-               } else if pathWas != "" {
-                       r.Logger.Printf("notice: stats moved from %s to %s\n", r.reportedStatFile[stat], path)
-               } else {
-                       r.Logger.Printf("notice: reading stats from %s\n", path)
-               }
-               r.reportedStatFile[stat] = path
-       }
-       return file, err
-}
-
-func (r *Reporter) getContainerNetStats() (io.Reader, error) {
-       procsFile, err := r.openStatFile("cpuacct", "cgroup.procs", true)
-       if err != nil {
-               return nil, err
-       }
-       defer procsFile.Close()
-       reader := bufio.NewScanner(procsFile)
-       for reader.Scan() {
-               taskPid := reader.Text()
-               statsFilename := fmt.Sprintf("/proc/%s/net/dev", taskPid)
-               stats, err := ioutil.ReadFile(statsFilename)
-               if err != nil {
-                       r.Logger.Printf("notice: %v", err)
-                       continue
-               }
-               return strings.NewReader(string(stats)), nil
-       }
-       return nil, errors.New("Could not read stats for any proc in container")
-}
-
 type ioSample struct {
        sampleTime time.Time
        txBytes    int64
@@ -300,33 +405,58 @@ type ioSample struct {
 }
 
 func (r *Reporter) doBlkIOStats() {
-       c, err := r.openStatFile("blkio", "blkio.io_service_bytes", true)
-       if err != nil {
-               return
-       }
-       defer c.Close()
-       b := bufio.NewScanner(c)
        var sampleTime = time.Now()
        newSamples := make(map[string]ioSample)
-       for b.Scan() {
-               var device, op string
-               var val int64
-               if _, err := fmt.Sscanf(string(b.Text()), "%s %s %d", &device, &op, &val); err != nil {
-                       continue
+
+       if r.statFiles.ioStat != "" {
+               statfile, err := fs.ReadFile(r.FS, r.statFiles.ioStat)
+               if err != nil {
+                       return
+               }
+               for _, line := range bytes.Split(statfile, []byte{'\n'}) {
+                       // 254:16 rbytes=72163328 wbytes=117370880 rios=3811 wios=3906 dbytes=0 dios=0
+                       words := bytes.Split(line, []byte{' '})
+                       if len(words) < 2 {
+                               continue
+                       }
+                       thisSample := ioSample{sampleTime, -1, -1}
+                       for _, kv := range words[1:] {
+                               if bytes.HasPrefix(kv, []byte("rbytes=")) {
+                                       fmt.Sscanf(string(kv[7:]), "%d", &thisSample.rxBytes)
+                               } else if bytes.HasPrefix(kv, []byte("wbytes=")) {
+                                       fmt.Sscanf(string(kv[7:]), "%d", &thisSample.txBytes)
+                               }
+                       }
+                       if thisSample.rxBytes >= 0 && thisSample.txBytes >= 0 {
+                               newSamples[string(words[0])] = thisSample
+                       }
                }
-               var thisSample ioSample
-               var ok bool
-               if thisSample, ok = newSamples[device]; !ok {
-                       thisSample = ioSample{sampleTime, -1, -1}
+       } else if r.statFiles.ioServiceBytes != "" {
+               statfile, err := fs.ReadFile(r.FS, r.statFiles.ioServiceBytes)
+               if err != nil {
+                       return
                }
-               switch op {
-               case "Read":
-                       thisSample.rxBytes = val
-               case "Write":
-                       thisSample.txBytes = val
+               for _, line := range bytes.Split(statfile, []byte{'\n'}) {
+                       var device, op string
+                       var val int64
+                       if _, err := fmt.Sscanf(string(line), "%s %s %d", &device, &op, &val); err != nil {
+                               continue
+                       }
+                       var thisSample ioSample
+                       var ok bool
+                       if thisSample, ok = newSamples[device]; !ok {
+                               thisSample = ioSample{sampleTime, -1, -1}
+                       }
+                       switch op {
+                       case "Read":
+                               thisSample.rxBytes = val
+                       case "Write":
+                               thisSample.txBytes = val
+                       }
+                       newSamples[device] = thisSample
                }
-               newSamples[device] = thisSample
        }
+
        for dev, sample := range newSamples {
                if sample.txBytes < 0 || sample.rxBytes < 0 {
                        continue
@@ -349,13 +479,16 @@ type memSample struct {
 }
 
 func (r *Reporter) getMemSample() {
-       c, err := r.openStatFile("memory", "memory.stat", true)
+       thisSample := memSample{time.Now(), make(map[string]int64)}
+
+       // memory.stat contains "pgmajfault" in cgroups v1 and v2. It
+       // also contains "rss", "swap", and "cache" in cgroups v1.
+       c, err := r.FS.Open(r.statFiles.memoryStat)
        if err != nil {
                return
        }
        defer c.Close()
        b := bufio.NewScanner(c)
-       thisSample := memSample{time.Now(), make(map[string]int64)}
        for b.Scan() {
                var stat string
                var val int64
@@ -363,6 +496,33 @@ func (r *Reporter) getMemSample() {
                        continue
                }
                thisSample.memStat[stat] = val
+       }
+
+       // In cgroups v2, we need to read "memory.current" and
+       // "memory.swap.current" as well.
+       for stat, fnm := range map[string]string{
+               // memory.current includes cache. We don't get
+               // separate rss/cache values, so we call
+               // memory usage "rss" for compatibility, and
+               // omit "cache".
+               "rss":  r.statFiles.memoryCurrent,
+               "swap": r.statFiles.memorySwapCurrent,
+       } {
+               if fnm == "" {
+                       continue
+               }
+               buf, err := fs.ReadFile(r.FS, fnm)
+               if err != nil {
+                       continue
+               }
+               var val int64
+               _, err = fmt.Sscanf(string(buf), "%d", &val)
+               if err != nil {
+                       continue
+               }
+               thisSample.memStat[stat] = val
+       }
+       for stat, val := range thisSample.memStat {
                maxKey := memoryKey{statName: stat}
                if val > r.maxMemSample[maxKey] {
                        r.maxMemSample[maxKey] = val
@@ -417,7 +577,7 @@ func (r *Reporter) doProcmemStats() {
                // assign "don't try again" value in case we give up
                // and return without assigning the real value
                r.kernelPageSize = -1
-               buf, err := os.ReadFile("/proc/self/smaps")
+               buf, err := fs.ReadFile(r.FS, "proc/self/smaps")
                if err != nil {
                        r.Logger.Printf("error reading /proc/self/smaps: %s", err)
                        return
@@ -449,7 +609,7 @@ func (r *Reporter) doProcmemStats() {
        procmem := ""
        for _, procname := range procnames {
                pid := r.reportPIDs[procname]
-               buf, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
+               buf, err := fs.ReadFile(r.FS, fmt.Sprintf("proc/%d/stat", pid))
                if err != nil {
                        continue
                }
@@ -485,12 +645,15 @@ func (r *Reporter) doProcmemStats() {
 }
 
 func (r *Reporter) doNetworkStats() {
+       if r.statFiles.netDev == "" {
+               return
+       }
        sampleTime := time.Now()
-       stats, err := r.getContainerNetStats()
+       stats, err := r.FS.Open(r.statFiles.netDev)
        if err != nil {
                return
        }
-
+       defer stats.Close()
        scanner := bufio.NewScanner(stats)
        for scanner.Scan() {
                var ifName string
@@ -572,55 +735,100 @@ type cpuSample struct {
        sampleTime time.Time
        user       float64
        sys        float64
-       cpus       int64
+       cpus       float64
 }
 
-// Return the number of CPUs available in the container. Return 0 if
-// we can't figure out the real number of CPUs.
-func (r *Reporter) getCPUCount() int64 {
-       cpusetFile, err := r.openStatFile("cpuset", "cpuset.cpus", true)
-       if err != nil {
-               return 0
-       }
-       defer cpusetFile.Close()
-       b, err := r.readAllOrWarn(cpusetFile)
-       if err != nil {
-               return 0
+// Return the number of virtual CPUs available in the container. This
+// can be based on a scheduling ratio (which is not necessarily a
+// whole number) or a restricted set of accessible CPUs.
+//
+// Return the number of host processors based on /proc/cpuinfo if
+// cgroupfs doesn't reveal anything.
+//
+// Return 0 if even that doesn't work.
+func (r *Reporter) getCPUCount() float64 {
+       if buf, err := fs.ReadFile(r.FS, r.statFiles.cpuMax); err == nil {
+               // cpu.max looks like "150000 100000" if CPU usage is
+               // restricted to 150% (docker run --cpus=1.5), or "max
+               // 100000\n" if not.
+               var max, period int64
+               if _, err := fmt.Sscanf(string(buf), "%d %d", &max, &period); err == nil {
+                       return float64(max) / float64(period)
+               }
+       }
+       if buf, err := fs.ReadFile(r.FS, r.statFiles.cpusetCpus); err == nil {
+               // cpuset.cpus looks like "0,4-7\n" if only CPUs
+               // 0,4,5,6,7 are available.
+               cpus := 0
+               for _, v := range bytes.Split(buf, []byte{','}) {
+                       var min, max int
+                       n, _ := fmt.Sscanf(string(v), "%d-%d", &min, &max)
+                       if n == 2 {
+                               cpus += (max - min) + 1
+                       } else {
+                               cpus++
+                       }
+               }
+               return float64(cpus)
        }
-       sp := strings.Split(string(b), ",")
-       cpus := int64(0)
-       for _, v := range sp {
-               var min, max int64
-               n, _ := fmt.Sscanf(v, "%d-%d", &min, &max)
-               if n == 2 {
-                       cpus += (max - min) + 1
-               } else {
-                       cpus++
+       if buf, err := fs.ReadFile(r.FS, "proc/cpuinfo"); err == nil {
+               // cpuinfo has a line like "processor\t: 0\n" for each
+               // CPU.
+               cpus := 0
+               for _, line := range bytes.Split(buf, []byte{'\n'}) {
+                       if bytes.HasPrefix(line, []byte("processor\t:")) {
+                               cpus++
+                       }
                }
+               return float64(cpus)
        }
-       return cpus
+       return 0
 }
 
 func (r *Reporter) doCPUStats() {
-       statFile, err := r.openStatFile("cpuacct", "cpuacct.stat", true)
-       if err != nil {
-               return
-       }
-       defer statFile.Close()
-       b, err := r.readAllOrWarn(statFile)
-       if err != nil {
-               return
-       }
+       var nextSample cpuSample
+       if r.statFiles.cpuStat != "" {
+               // v2
+               f, err := r.FS.Open(r.statFiles.cpuStat)
+               if err != nil {
+                       return
+               }
+               defer f.Close()
+               nextSample = cpuSample{
+                       hasData:    true,
+                       sampleTime: time.Now(),
+                       cpus:       r.getCPUCount(),
+               }
+               for {
+                       var stat string
+                       var val int64
+                       n, err := fmt.Fscanf(f, "%s %d\n", &stat, &val)
+                       if err != nil || n != 2 {
+                               break
+                       }
+                       if stat == "user_usec" {
+                               nextSample.user = float64(val) / 1000000
+                       } else if stat == "system_usec" {
+                               nextSample.sys = float64(val) / 1000000
+                       }
+               }
+       } else if r.statFiles.cpuacctStat != "" {
+               // v1
+               b, err := fs.ReadFile(r.FS, r.statFiles.cpuacctStat)
+               if err != nil {
+                       return
+               }
 
-       var userTicks, sysTicks int64
-       fmt.Sscanf(string(b), "user %d\nsystem %d", &userTicks, &sysTicks)
-       userHz := float64(100)
-       nextSample := cpuSample{
-               hasData:    true,
-               sampleTime: time.Now(),
-               user:       float64(userTicks) / userHz,
-               sys:        float64(sysTicks) / userHz,
-               cpus:       r.getCPUCount(),
+               var userTicks, sysTicks int64
+               fmt.Sscanf(string(b), "user %d\nsystem %d", &userTicks, &sysTicks)
+               userHz := float64(100)
+               nextSample = cpuSample{
+                       hasData:    true,
+                       sampleTime: time.Now(),
+                       user:       float64(userTicks) / userHz,
+                       sys:        float64(sysTicks) / userHz,
+                       cpus:       r.getCPUCount(),
+               }
        }
 
        delta := ""
@@ -630,7 +838,7 @@ func (r *Reporter) doCPUStats() {
                        nextSample.user-r.lastCPUSample.user,
                        nextSample.sys-r.lastCPUSample.sys)
        }
-       r.Logger.Printf("cpu %.4f user %.4f sys %d cpus%s\n",
+       r.Logger.Printf("cpu %.4f user %.4f sys %.2f cpus%s\n",
                nextSample.user, nextSample.sys, nextSample.cpus, delta)
        r.lastCPUSample = nextSample
 }
@@ -650,11 +858,12 @@ func (r *Reporter) run() {
        defer close(r.flushed)
 
        r.maxMemSample = make(map[memoryKey]int64)
-       r.reportedStatFile = make(map[string]string)
 
-       if !r.waitForCIDFile() || !r.waitForCgroup() {
+       if !r.waitForPid() {
                return
        }
+       r.findStatFiles()
+       close(r.ready)
 
        r.lastNetSample = make(map[string]ioSample)
        r.lastDiskIOSample = make(map[string]ioSample)
@@ -670,6 +879,10 @@ func (r *Reporter) run() {
        r.getMemSample()
        r.doAllStats()
 
+       if r.PollPeriod < 1 {
+               r.PollPeriod = time.Second * 10
+       }
+
        memTicker := time.NewTicker(time.Second)
        mainTicker := time.NewTicker(r.PollPeriod)
        for {
@@ -684,51 +897,90 @@ func (r *Reporter) run() {
        }
 }
 
-// If CID is empty, wait for it to appear in CIDFile. Return true if
-// we get it before we learn (via r.done) that someone called Stop.
-func (r *Reporter) waitForCIDFile() bool {
-       if r.CID != "" || r.CIDFile == "" {
-               return true
-       }
-
+// Wait for Pid() to return a real pid.  Return true if this succeeds
+// before Stop is called.
+func (r *Reporter) waitForPid() bool {
        ticker := time.NewTicker(100 * time.Millisecond)
        defer ticker.Stop()
+       warningTimer := time.After(r.PollPeriod)
        for {
-               cid, err := ioutil.ReadFile(r.CIDFile)
-               if err == nil && len(cid) > 0 {
-                       r.CID = string(cid)
-                       return true
+               r.pid = r.Pid()
+               if r.pid > 0 {
+                       break
                }
                select {
                case <-ticker.C:
+               case <-warningTimer:
+                       r.Logger.Printf("warning: Pid() did not return a process ID after %v (config error?) -- still waiting...", r.PollPeriod)
                case <-r.done:
-                       r.Logger.Printf("warning: CID never appeared in %+q: %v", r.CIDFile, err)
+                       r.Logger.Printf("warning: Pid() never returned a process ID")
                        return false
                }
        }
+       return true
 }
 
-// Wait for the cgroup stats files to appear in cgroup_root. Return
-// true if they appear before r.done indicates someone called Stop. If
-// they don't appear within one poll interval, log a warning and keep
-// waiting.
-func (r *Reporter) waitForCgroup() bool {
-       ticker := time.NewTicker(100 * time.Millisecond)
-       defer ticker.Stop()
-       warningTimer := time.After(r.PollPeriod)
-       for {
-               c, err := r.openStatFile("cpuacct", "cgroup.procs", false)
-               if err == nil {
-                       c.Close()
-                       return true
+func (r *Reporter) dumpSourceFiles(destdir string) error {
+       select {
+       case <-r.done:
+               return errors.New("reporter was never ready")
+       case <-r.ready:
+       }
+       todo := []string{
+               fmt.Sprintf("proc/%d/cgroup", r.pid),
+               fmt.Sprintf("proc/%d/cpuset", r.pid),
+               "proc/cpuinfo",
+               "proc/mounts",
+               "proc/self/smaps",
+               r.statFiles.cpuMax,
+               r.statFiles.cpusetCpus,
+               r.statFiles.cpuacctStat,
+               r.statFiles.cpuStat,
+               r.statFiles.ioServiceBytes,
+               r.statFiles.ioStat,
+               r.statFiles.memoryStat,
+               r.statFiles.memoryCurrent,
+               r.statFiles.memorySwapCurrent,
+               r.statFiles.netDev,
+       }
+       for _, path := range todo {
+               if path == "" {
+                       continue
                }
-               select {
-               case <-ticker.C:
-               case <-warningTimer:
-                       r.Logger.Printf("warning: cgroup stats files have not appeared after %v (config error?) -- still waiting...", r.PollPeriod)
-               case <-r.done:
-                       r.Logger.Printf("warning: cgroup stats files never appeared for %v", r.CID)
-                       return false
+               err := r.createParentsAndCopyFile(destdir, path)
+               if err != nil {
+                       return err
+               }
+       }
+       r.reportPIDsMu.Lock()
+       r.reportPIDsMu.Unlock()
+       for _, pid := range r.reportPIDs {
+               path := fmt.Sprintf("proc/%d/stat", pid)
+               err := r.createParentsAndCopyFile(destdir, path)
+               if err != nil {
+                       return err
+               }
+       }
+       if proc, err := os.FindProcess(r.pid); err != nil || proc.Signal(syscall.Signal(0)) != nil {
+               return fmt.Errorf("process %d no longer exists, snapshot is probably broken", r.pid)
+       }
+       return nil
+}
+
+func (r *Reporter) createParentsAndCopyFile(destdir, path string) error {
+       buf, err := fs.ReadFile(r.FS, path)
+       if os.IsNotExist(err) {
+               return nil
+       } else if err != nil {
+               return err
+       }
+       if parent, _ := filepath.Split(path); parent != "" {
+               err = os.MkdirAll(destdir+"/"+parent, 0777)
+               if err != nil {
+                       return fmt.Errorf("mkdir %s: %s", destdir+"/"+parent, err)
                }
        }
+       destfile := destdir + "/" + path
+       r.Logger.Printf("copy %s to %s -- size %d", path, destfile, len(buf))
+       return os.WriteFile(destfile, buf, 0777)
 }
index 88de12f076623f9ddde773ee1ae061c2bf041166..415c58a53388fd9fb68e62885a4b78ad7ef09db7 100644 (file)
@@ -6,11 +6,11 @@ package crunchstat
 
 import (
        "bytes"
-       "errors"
        "fmt"
+       "io/fs"
        "os"
-       "path"
        "regexp"
+       "runtime"
        "strconv"
        "testing"
        "time"
@@ -20,118 +20,100 @@ import (
 )
 
 const logMsgPrefix = `(?m)(.*\n)*.* msg="`
-const GiB = int64(1024 * 1024 * 1024)
 
-type fakeStat struct {
-       cgroupRoot string
-       statName   string
-       unit       string
-       value      int64
+func Test(t *testing.T) {
+       TestingT(t)
 }
 
-var fakeRSS = fakeStat{
-       cgroupRoot: "testdata/fakestat",
-       statName:   "mem rss",
-       unit:       "bytes",
-       // Note this is the value of total_rss, not rss, because that's what should
-       // always be reported for thresholds and maxima.
-       value: 750 * 1024 * 1024,
+var _ = Suite(&suite{})
+
+type testdatasource struct {
+       fspath string
+       pid    int
 }
 
-func Test(t *testing.T) {
-       TestingT(t)
+func (s testdatasource) Pid() int {
+       return s.pid
+}
+func (s testdatasource) FS() fs.FS {
+       return os.DirFS(s.fspath)
 }
 
-var _ = Suite(&suite{
-       logger: logrus.New(),
-})
+// To generate a test case for a new OS target, build
+// cmd/arvados-server and run
+//
+//     arvados-server crunchstat -dump ./testdata/example1234 sleep 2
+var testdata = map[string]testdatasource{
+       "debian10":   {fspath: "testdata/debian10", pid: 3288},
+       "debian11":   {fspath: "testdata/debian11", pid: 4153022},
+       "debian12":   {fspath: "testdata/debian12", pid: 1115883},
+       "ubuntu1804": {fspath: "testdata/ubuntu1804", pid: 2523},
+       "ubuntu2004": {fspath: "testdata/ubuntu2004", pid: 1360},
+       "ubuntu2204": {fspath: "testdata/ubuntu2204", pid: 1967},
+}
 
 type suite struct {
-       cgroupRoot string
-       logbuf     bytes.Buffer
-       logger     *logrus.Logger
+       logbuf                bytes.Buffer
+       logger                *logrus.Logger
+       debian12MemoryCurrent int64
 }
 
 func (s *suite) SetUpSuite(c *C) {
+       s.logger = logrus.New()
        s.logger.Out = &s.logbuf
+
+       buf, err := os.ReadFile("testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.current")
+       c.Assert(err, IsNil)
+       _, err = fmt.Sscanf(string(buf), "%d", &s.debian12MemoryCurrent)
+       c.Assert(err, IsNil)
 }
 
 func (s *suite) SetUpTest(c *C) {
-       s.cgroupRoot = ""
        s.logbuf.Reset()
 }
 
-func (s *suite) tempCgroup(c *C, sourceDir string) error {
-       tempDir := c.MkDir()
-       dirents, err := os.ReadDir(sourceDir)
-       if err != nil {
-               return err
+// Report stats for the current (go test) process's cgroup, using the
+// test host's real procfs/sysfs.
+func (s *suite) TestReportCurrent(c *C) {
+       r := Reporter{
+               Pid:        os.Getpid,
+               Logger:     s.logger,
+               PollPeriod: time.Second,
+       }
+       r.Start()
+       defer r.Stop()
+       checkPatterns := []string{
+               `(?ms).*rss.*`,
+               `(?ms).*net:.*`,
+               `(?ms).*blkio:.*`,
+               `(?ms).* [\d.]+ user [\d.]+ sys ` + fmt.Sprintf("%.2f", float64(runtime.NumCPU())) + ` cpus -- .*`,
        }
-       for _, dirent := range dirents {
-               srcData, err := os.ReadFile(path.Join(sourceDir, dirent.Name()))
-               if err != nil {
-                       return err
+       for deadline := time.Now().Add(4 * time.Second); !c.Failed(); time.Sleep(time.Millisecond) {
+               done := true
+               for _, pattern := range checkPatterns {
+                       if m := regexp.MustCompile(pattern).FindSubmatch(s.logbuf.Bytes()); len(m) == 0 {
+                               done = false
+                               if time.Now().After(deadline) {
+                                       c.Errorf("timed out waiting for %s", pattern)
+                               }
+                       }
                }
-               destPath := path.Join(tempDir, dirent.Name())
-               err = os.WriteFile(destPath, srcData, 0o600)
-               if err != nil {
-                       return err
+               if done {
+                       break
                }
        }
-       s.cgroupRoot = tempDir
-       return nil
-}
-
-func (s *suite) addPidToCgroup(pid int) error {
-       if s.cgroupRoot == "" {
-               return errors.New("cgroup has not been set up for this test")
-       }
-       procsPath := path.Join(s.cgroupRoot, "cgroup.procs")
-       procsFile, err := os.OpenFile(procsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
-       if err != nil {
-               return err
-       }
-       pidLine := strconv.Itoa(pid) + "\n"
-       _, err = procsFile.Write([]byte(pidLine))
-       if err != nil {
-               procsFile.Close()
-               return err
-       }
-       return procsFile.Close()
-}
-
-func (s *suite) TestReadAllOrWarnFail(c *C) {
-       rep := Reporter{Logger: s.logger}
-
-       // The special file /proc/self/mem can be opened for
-       // reading, but reading from byte 0 returns an error.
-       f, err := os.Open("/proc/self/mem")
-       c.Assert(err, IsNil)
-       defer f.Close()
-       _, err = rep.readAllOrWarn(f)
-       c.Check(err, NotNil)
-       c.Check(s.logbuf.String(), Matches, ".* msg=\"warning: read /proc/self/mem: .*\n")
-}
-
-func (s *suite) TestReadAllOrWarnSuccess(c *C) {
-       rep := Reporter{Logger: s.logger}
-
-       f, err := os.Open("./crunchstat_test.go")
-       c.Assert(err, IsNil)
-       defer f.Close()
-       data, err := rep.readAllOrWarn(f)
-       c.Check(err, IsNil)
-       c.Check(string(data), Matches, "(?ms).*\npackage crunchstat\n.*")
-       c.Check(s.logbuf.String(), Equals, "")
+       c.Logf("%s", s.logbuf.String())
 }
 
+// Report stats for a the current (go test) process.
 func (s *suite) TestReportPIDs(c *C) {
        r := Reporter{
+               Pid:        func() int { return 1 },
                Logger:     s.logger,
-               CgroupRoot: "/sys/fs/cgroup",
                PollPeriod: time.Second,
        }
        r.Start()
+       defer r.Stop()
        r.ReportPID("init", 1)
        r.ReportPID("test_process", os.Getpid())
        r.ReportPID("nonexistent", 12345) // should be silently ignored/omitted
@@ -154,13 +136,37 @@ func (s *suite) TestReportPIDs(c *C) {
        c.Logf("%s", s.logbuf.String())
 }
 
+func (s *suite) TestAllTestdata(c *C) {
+       for platform, datasource := range testdata {
+               s.logbuf.Reset()
+               c.Logf("=== %s", platform)
+               rep := Reporter{
+                       Pid:             datasource.Pid,
+                       FS:              datasource.FS(),
+                       Logger:          s.logger,
+                       PollPeriod:      time.Second,
+                       ThresholdLogger: s.logger,
+                       Debug:           true,
+               }
+               rep.Start()
+               rep.Stop()
+               logs := s.logbuf.String()
+               c.Logf("%s", logs)
+               c.Check(logs, Matches, `(?ms).* \d\d+ rss\\n.*`)
+               c.Check(logs, Matches, `(?ms).*blkio:\d+:\d+ \d+ write \d+ read\\n.*`)
+               c.Check(logs, Matches, `(?ms).*net:\S+ \d+ tx \d+ rx\\n.*`)
+               c.Check(logs, Matches, `(?ms).* [\d.]+ user [\d.]+ sys [2-9]\d*\.\d\d cpus.*`)
+       }
+}
+
 func (s *suite) testRSSThresholds(c *C, rssPercentages []int64, alertCount int) {
        c.Assert(alertCount <= len(rssPercentages), Equals, true)
        rep := Reporter{
-               CgroupRoot: fakeRSS.cgroupRoot,
-               Logger:     s.logger,
+               Pid:    testdata["debian12"].Pid,
+               FS:     testdata["debian12"].FS(),
+               Logger: s.logger,
                MemThresholds: map[string][]Threshold{
-                       "rss": NewThresholdsFromPercentages(GiB, rssPercentages),
+                       "rss": NewThresholdsFromPercentages(s.debian12MemoryCurrent*3/2, rssPercentages),
                },
                PollPeriod:      time.Second * 10,
                ThresholdLogger: s.logger,
@@ -178,7 +184,7 @@ func (s *suite) testRSSThresholds(c *C, rssPercentages []int64, alertCount int)
                        logCheck = Not(Matches)
                }
                pattern := fmt.Sprintf(`%sContainer using over %d%% of memory \(rss %d/%d bytes\)"`,
-                       logMsgPrefix, expectPercentage, fakeRSS.value, GiB)
+                       logMsgPrefix, expectPercentage, s.debian12MemoryCurrent, s.debian12MemoryCurrent*3/2)
                c.Check(logs, logCheck, pattern)
        }
 }
@@ -200,7 +206,7 @@ func (s *suite) TestMultipleRSSThresholdsNonePassed(c *C) {
 }
 
 func (s *suite) TestMultipleRSSThresholdsSomePassed(c *C) {
-       s.testRSSThresholds(c, []int64{60, 70, 80, 90}, 2)
+       s.testRSSThresholds(c, []int64{45, 60, 75, 90}, 2)
 }
 
 func (s *suite) TestMultipleRSSThresholdsAllPassed(c *C) {
@@ -208,27 +214,25 @@ func (s *suite) TestMultipleRSSThresholdsAllPassed(c *C) {
 }
 
 func (s *suite) TestLogMaxima(c *C) {
-       err := s.tempCgroup(c, fakeRSS.cgroupRoot)
-       c.Assert(err, IsNil)
        rep := Reporter{
-               CgroupRoot: s.cgroupRoot,
+               Pid:        testdata["debian12"].Pid,
+               FS:         testdata["debian12"].FS(),
                Logger:     s.logger,
                PollPeriod: time.Second * 10,
-               TempDir:    s.cgroupRoot,
+               TempDir:    "/",
        }
        rep.Start()
        rep.Stop()
-       rep.LogMaxima(s.logger, map[string]int64{"rss": GiB})
+       rep.LogMaxima(s.logger, map[string]int64{"rss": s.debian12MemoryCurrent * 3 / 2})
        logs := s.logbuf.String()
        c.Logf("%s", logs)
 
        expectRSS := fmt.Sprintf(`Maximum container memory rss usage was %d%%, %d/%d bytes`,
-               100*fakeRSS.value/GiB, fakeRSS.value, GiB)
+               66, s.debian12MemoryCurrent, s.debian12MemoryCurrent*3/2)
        for _, expected := range []string{
                `Maximum disk usage was \d+%, \d+/\d+ bytes`,
-               `Maximum container memory cache usage was 73400320 bytes`,
-               `Maximum container memory swap usage was 320 bytes`,
-               `Maximum container memory pgmajfault usage was 20 faults`,
+               `Maximum container memory swap usage was \d\d+ bytes`,
+               `Maximum container memory pgmajfault usage was \d\d+ faults`,
                expectRSS,
        } {
                pattern := logMsgPrefix + expected + `"`
@@ -237,19 +241,12 @@ func (s *suite) TestLogMaxima(c *C) {
 }
 
 func (s *suite) TestLogProcessMemMax(c *C) {
-       err := s.tempCgroup(c, fakeRSS.cgroupRoot)
-       c.Assert(err, IsNil)
-       pid := os.Getpid()
-       err = s.addPidToCgroup(pid)
-       c.Assert(err, IsNil)
-
        rep := Reporter{
-               CgroupRoot: s.cgroupRoot,
+               Pid:        os.Getpid,
                Logger:     s.logger,
                PollPeriod: time.Second * 10,
-               TempDir:    s.cgroupRoot,
        }
-       rep.ReportPID("test-run", pid)
+       rep.ReportPID("test-run", os.Getpid())
        rep.Start()
        rep.Stop()
        rep.LogProcessMemMax(s.logger)
diff --git a/lib/crunchstat/testdata/debian10/proc/3288/cgroup b/lib/crunchstat/testdata/debian10/proc/3288/cgroup
new file mode 100755 (executable)
index 0000000..b51ec39
--- /dev/null
@@ -0,0 +1 @@
+0::/user.slice/user-1000.slice/session-7.scope
diff --git a/lib/crunchstat/testdata/debian10/proc/3288/cpuset b/lib/crunchstat/testdata/debian10/proc/3288/cpuset
new file mode 100755 (executable)
index 0000000..b498fd4
--- /dev/null
@@ -0,0 +1 @@
+/
diff --git a/lib/crunchstat/testdata/debian10/proc/3288/net/dev b/lib/crunchstat/testdata/debian10/proc/3288/net/dev
new file mode 100755 (executable)
index 0000000..44d19e1
--- /dev/null
@@ -0,0 +1,5 @@
+Inter-|   Receive                                                |  Transmit
+ face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
+  ens5: 168696850   62770    0    0    0     0          0         0  1202238   11890    0    0    0     0       0          0
+    lo:       0       0    0    0    0     0          0         0        0       0    0    0    0     0       0          0
+docker0:       0       0    0    0    0     0          0         0     1080      12    0    0    0     0       0          0
diff --git a/lib/crunchstat/testdata/debian10/proc/cpuinfo b/lib/crunchstat/testdata/debian10/proc/cpuinfo
new file mode 100755 (executable)
index 0000000..b57280f
--- /dev/null
@@ -0,0 +1,54 @@
+processor      : 0
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 85
+model name     : Intel(R) Xeon(R) Platinum 8175M CPU @ 2.50GHz
+stepping       : 4
+microcode      : 0x2007006
+cpu MHz                : 2499.998
+cache size     : 33792 KB
+physical id    : 0
+siblings       : 2
+core id                : 0
+cpu cores      : 1
+apicid         : 0
+initial apicid : 0
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl xtopology nonstop_tsc cpuid tsc_known_freq pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch invpcid_single pti fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid mpx avx512f avx512dq rdseed adx smap clflushopt clwb avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves ida arat pku ospke
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs itlb_multihit mmio_stale_data retbleed gds
+bogomips       : 4999.99
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 46 bits physical, 48 bits virtual
+power management:
+
+processor      : 1
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 85
+model name     : Intel(R) Xeon(R) Platinum 8175M CPU @ 2.50GHz
+stepping       : 4
+microcode      : 0x2007006
+cpu MHz                : 2499.998
+cache size     : 33792 KB
+physical id    : 0
+siblings       : 2
+core id                : 0
+cpu cores      : 1
+apicid         : 1
+initial apicid : 1
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl xtopology nonstop_tsc cpuid tsc_known_freq pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch invpcid_single pti fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid mpx avx512f avx512dq rdseed adx smap clflushopt clwb avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves ida arat pku ospke
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs itlb_multihit mmio_stale_data retbleed gds
+bogomips       : 4999.99
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 46 bits physical, 48 bits virtual
+power management:
+
diff --git a/lib/crunchstat/testdata/debian10/proc/mounts b/lib/crunchstat/testdata/debian10/proc/mounts
new file mode 100755 (executable)
index 0000000..e74553e
--- /dev/null
@@ -0,0 +1,19 @@
+sysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0
+proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
+udev /dev devtmpfs rw,nosuid,relatime,size=992288k,nr_inodes=248072,mode=755 0 0
+devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000 0 0
+tmpfs /run tmpfs rw,nosuid,noexec,relatime,size=200676k,mode=755 0 0
+/dev/nvme0n1p1 / ext4 rw,relatime,discard,errors=remount-ro 0 0
+securityfs /sys/kernel/security securityfs rw,nosuid,nodev,noexec,relatime 0 0
+tmpfs /dev/shm tmpfs rw,nosuid,nodev 0 0
+tmpfs /run/lock tmpfs rw,nosuid,nodev,noexec,relatime,size=5120k 0 0
+cgroup2 /sys/fs/cgroup cgroup2 rw,nosuid,nodev,noexec,relatime,nsdelegate 0 0
+pstore /sys/fs/pstore pstore rw,nosuid,nodev,noexec,relatime 0 0
+bpf /sys/fs/bpf bpf rw,nosuid,nodev,noexec,relatime,mode=700 0 0
+systemd-1 /proc/sys/fs/binfmt_misc autofs rw,relatime,fd=33,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=9700 0 0
+mqueue /dev/mqueue mqueue rw,relatime 0 0
+debugfs /sys/kernel/debug debugfs rw,relatime 0 0
+hugetlbfs /dev/hugepages hugetlbfs rw,relatime,pagesize=2M 0 0
+/dev/nvme0n1p15 /boot/efi vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro 0 0
+tmpfs /run/user/1000 tmpfs rw,nosuid,nodev,relatime,size=200676k,mode=700,uid=1000,gid=1000 0 0
+/dev/mapper/autoscale_vg-autoscale_lv /tmp ext4 rw,relatime 0 0
diff --git a/lib/crunchstat/testdata/debian10/proc/self/smaps b/lib/crunchstat/testdata/debian10/proc/self/smaps
new file mode 100755 (executable)
index 0000000..e4f80e5
--- /dev/null
@@ -0,0 +1,2185 @@
+00400000-00403000 r--p 00000000 103:01 268952                            /home/admin/arvados-server
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:        12 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me dw sd 
+00403000-01779000 r-xp 00003000 103:01 268952                            /home/admin/arvados-server
+Size:              19928 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               12376 kB
+Pss:               12376 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:     12376 kB
+Private_Dirty:         0 kB
+Referenced:        12376 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me dw sd 
+01779000-02f2d000 r--p 01379000 103:01 268952                            /home/admin/arvados-server
+Size:              24272 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               10588 kB
+Pss:               10588 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:      9084 kB
+Private_Dirty:      1504 kB
+Referenced:        10588 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me dw sd 
+02f2e000-02f2f000 r--p 02b2d000 103:01 268952                            /home/admin/arvados-server
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me dw ac sd 
+02f2f000-02fc6000 rw-p 02b2e000 103:01 268952                            /home/admin/arvados-server
+Size:                604 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 456 kB
+Pss:                 456 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       456 kB
+Referenced:          456 kB
+Anonymous:           176 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd wr mr mw me dw ac sd 
+02fc6000-0300d000 rw-p 00000000 00:00 0 
+Size:                284 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  96 kB
+Pss:                  96 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        96 kB
+Referenced:           96 kB
+Anonymous:            96 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+03590000-035b1000 rw-p 00000000 00:00 0                                  [heap]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+c000000000-c000800000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                8192 kB
+Pss:                8192 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      8192 kB
+Referenced:         8192 kB
+Anonymous:          8192 kB
+LazyFree:              0 kB
+AnonHugePages:      8192 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+c000800000-c004000000 ---p 00000000 00:00 0 
+Size:              57344 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f851ffc0000-7f8520000000 rw-p 00000000 00:00 0 
+Size:                256 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 244 kB
+Pss:                 244 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       244 kB
+Referenced:          244 kB
+Anonymous:           244 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f8520000000-7f8520021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me nr sd 
+7f8520021000-7f8524000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me nr sd 
+7f8524000000-7f8524021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me nr sd 
+7f8524021000-7f8528000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me nr sd 
+7f8528000000-7f8528021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me nr sd 
+7f8528021000-7f852c000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me nr sd 
+7f852c000000-7f852c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me nr sd 
+7f852c021000-7f8530000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me nr sd 
+7f8530000000-7f8530021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me nr sd 
+7f8530021000-7f8534000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me nr sd 
+7f853401c000-7f853429c000 rw-p 00000000 00:00 0 
+Size:               2560 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                1028 kB
+Pss:                1028 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      1028 kB
+Referenced:         1028 kB
+Anonymous:          1028 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f853429c000-7f853429d000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f853429d000-7f8534a9d000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f8534a9d000-7f8534a9e000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f8534a9e000-7f85352de000 rw-p 00000000 00:00 0 
+Size:               8448 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 252 kB
+Pss:                 252 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       252 kB
+Referenced:          252 kB
+Anonymous:           252 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f85352de000-7f85352df000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f85352df000-7f8535adf000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f8535adf000-7f8535ae0000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f8535ae0000-7f85362e0000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f85362e0000-7f85362e1000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f85362e1000-7f8538e00000 rw-p 00000000 00:00 0 
+Size:              44156 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                2200 kB
+Pss:                2200 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      2200 kB
+Referenced:         2200 kB
+Anonymous:          2200 kB
+LazyFree:              0 kB
+AnonHugePages:      2048 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f8538e00000-7f8539000000 rw-p 00000000 00:00 0 
+Size:               2048 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd hg 
+7f8539000000-7f853911d000 rw-p 00000000 00:00 0 
+Size:               1140 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f853911d000-7f8549696000 ---p 00000000 00:00 0 
+Size:             267748 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f8549696000-7f8549697000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f8549697000-7f855b546000 ---p 00000000 00:00 0 
+Size:             293564 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f855b546000-7f855b547000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855b547000-7f855d91c000 ---p 00000000 00:00 0 
+Size:              36692 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f855d91c000-7f855d91d000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855d91d000-7f855dd96000 ---p 00000000 00:00 0 
+Size:               4580 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f855dd96000-7f855dd97000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855dd97000-7f855de16000 ---p 00000000 00:00 0 
+Size:                508 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f855de16000-7f855de79000 rw-p 00000000 00:00 0 
+Size:                396 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  56 kB
+Pss:                  56 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        56 kB
+Referenced:           56 kB
+Anonymous:            56 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855de79000-7f855de7b000 r--p 00000000 103:01 2008                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   0 kB
+Shared_Clean:          8 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            8 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855de7b000-7f855de7e000 r-xp 00002000 103:01 2008                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me sd 
+7f855de7e000-7f855de7f000 r--p 00005000 103:01 2008                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855de7f000-7f855de80000 r--p 00005000 103:01 2008                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me ac sd 
+7f855de80000-7f855de81000 rw-p 00006000 103:01 2008                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855de81000-7f855de83000 rw-p 00000000 00:00 0 
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855de83000-7f855de84000 r--p 00000000 103:01 2214                      /usr/lib/x86_64-linux-gnu/libdl-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855de84000-7f855de85000 r-xp 00001000 103:01 2214                      /usr/lib/x86_64-linux-gnu/libdl-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me sd 
+7f855de85000-7f855de86000 r--p 00002000 103:01 2214                      /usr/lib/x86_64-linux-gnu/libdl-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855de86000-7f855de87000 r--p 00002000 103:01 2214                      /usr/lib/x86_64-linux-gnu/libdl-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me ac sd 
+7f855de87000-7f855de88000 rw-p 00003000 103:01 2214                      /usr/lib/x86_64-linux-gnu/libdl-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855de88000-7f855de8b000 r--p 00000000 103:01 324                       /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855de8b000-7f855de92000 r-xp 00003000 103:01 324                       /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 28 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  24 kB
+Pss:                   1 kB
+Shared_Clean:         24 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           24 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me sd 
+7f855de92000-7f855dea6000 r--p 0000a000 103:01 324                       /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 80 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855dea6000-7f855dea7000 ---p 0001e000 103:01 324                       /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f855dea7000-7f855dea8000 r--p 0001e000 103:01 324                       /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me ac sd 
+7f855dea8000-7f855dea9000 rw-p 0001f000 103:01 324                       /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855dea9000-7f855deb3000 rw-p 00000000 00:00 0 
+Size:                 40 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855deb3000-7f855ded5000 r--p 00000000 103:01 2212                      /usr/lib/x86_64-linux-gnu/libc-2.28.so
+Size:                136 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 136 kB
+Pss:                   4 kB
+Shared_Clean:        136 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          136 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855ded5000-7f855e01c000 r-xp 00022000 103:01 2212                      /usr/lib/x86_64-linux-gnu/libc-2.28.so
+Size:               1308 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 612 kB
+Pss:                  20 kB
+Shared_Clean:        612 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          612 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me sd 
+7f855e01c000-7f855e068000 r--p 00169000 103:01 2212                      /usr/lib/x86_64-linux-gnu/libc-2.28.so
+Size:                304 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 128 kB
+Pss:                   4 kB
+Shared_Clean:        128 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          128 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855e068000-7f855e069000 ---p 001b5000 103:01 2212                      /usr/lib/x86_64-linux-gnu/libc-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f855e069000-7f855e06d000 r--p 001b5000 103:01 2212                      /usr/lib/x86_64-linux-gnu/libc-2.28.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me ac sd 
+7f855e06d000-7f855e06f000 rw-p 001b9000 103:01 2212                      /usr/lib/x86_64-linux-gnu/libc-2.28.so
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855e06f000-7f855e073000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        12 kB
+Referenced:           12 kB
+Anonymous:            12 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855e073000-7f855e076000 r--p 00000000 103:01 2514                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855e076000-7f855e07e000 r-xp 00003000 103:01 2514                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                 32 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  32 kB
+Pss:                   2 kB
+Shared_Clean:         32 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           32 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me sd 
+7f855e07e000-7f855e082000 r--p 0000b000 103:01 2514                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855e082000-7f855e083000 r--p 0000e000 103:01 2514                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me ac sd 
+7f855e083000-7f855e084000 rw-p 0000f000 103:01 2514                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855e084000-7f855e08a000 r--p 00000000 103:01 2228                      /usr/lib/x86_64-linux-gnu/libpthread-2.28.so
+Size:                 24 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  24 kB
+Pss:                   1 kB
+Shared_Clean:         24 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           24 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855e08a000-7f855e099000 r-xp 00006000 103:01 2228                      /usr/lib/x86_64-linux-gnu/libpthread-2.28.so
+Size:                 60 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  60 kB
+Pss:                   2 kB
+Shared_Clean:         60 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           60 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me sd 
+7f855e099000-7f855e09f000 r--p 00015000 103:01 2228                      /usr/lib/x86_64-linux-gnu/libpthread-2.28.so
+Size:                 24 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855e09f000-7f855e0a0000 r--p 0001a000 103:01 2228                      /usr/lib/x86_64-linux-gnu/libpthread-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me ac sd 
+7f855e0a0000-7f855e0a1000 rw-p 0001b000 103:01 2228                      /usr/lib/x86_64-linux-gnu/libpthread-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855e0a1000-7f855e0a5000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855e0a5000-7f855e0a9000 r--p 00000000 103:01 2229                      /usr/lib/x86_64-linux-gnu/libresolv-2.28.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                   2 kB
+Shared_Clean:         16 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           16 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855e0a9000-7f855e0b6000 r-xp 00004000 103:01 2229                      /usr/lib/x86_64-linux-gnu/libresolv-2.28.so
+Size:                 52 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  52 kB
+Pss:                   7 kB
+Shared_Clean:         52 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           52 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me sd 
+7f855e0b6000-7f855e0ba000 r--p 00011000 103:01 2229                      /usr/lib/x86_64-linux-gnu/libresolv-2.28.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me sd 
+7f855e0ba000-7f855e0bb000 ---p 00015000 103:01 2229                      /usr/lib/x86_64-linux-gnu/libresolv-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: mr mw me sd 
+7f855e0bb000-7f855e0bc000 r--p 00015000 103:01 2229                      /usr/lib/x86_64-linux-gnu/libresolv-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me ac sd 
+7f855e0bc000-7f855e0bd000 rw-p 00016000 103:01 2229                      /usr/lib/x86_64-linux-gnu/libresolv-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855e0bd000-7f855e0c1000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7f855e0cd000-7f855e0ce000 r--p 00000000 103:01 2204                      /usr/lib/x86_64-linux-gnu/ld-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me dw sd 
+7f855e0ce000-7f855e0ec000 r-xp 00001000 103:01 2204                      /usr/lib/x86_64-linux-gnu/ld-2.28.so
+Size:                120 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 120 kB
+Pss:                   3 kB
+Shared_Clean:        120 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          120 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me dw sd 
+7f855e0ec000-7f855e0f4000 r--p 0001f000 103:01 2204                      /usr/lib/x86_64-linux-gnu/ld-2.28.so
+Size:                 32 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  32 kB
+Pss:                   1 kB
+Shared_Clean:         32 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           32 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me dw sd 
+7f855e0f4000-7f855e0f5000 r--p 00026000 103:01 2204                      /usr/lib/x86_64-linux-gnu/ld-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr mw me dw ac sd 
+7f855e0f5000-7f855e0f6000 rw-p 00027000 103:01 2204                      /usr/lib/x86_64-linux-gnu/ld-2.28.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd wr mr mw me dw ac sd 
+7f855e0f6000-7f855e0f7000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me ac sd 
+7fffd54dc000-7fffd54fd000 rw-p 00000000 00:00 0                          [stack]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+ProtectionKey:         0
+VmFlags: rd wr mr mw me gd ac 
+7fffd556f000-7fffd5572000 r--p 00000000 00:00 0                          [vvar]
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd mr pf io de dd sd 
+7fffd5572000-7fffd5574000 r-xp 00000000 00:00 0                          [vdso]
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+ProtectionKey:         0
+VmFlags: rd ex mr mw me de sd 
diff --git a/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/cpu.max b/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/cpu.max
new file mode 100755 (executable)
index 0000000..1c1d3e7
--- /dev/null
@@ -0,0 +1 @@
+max 100000
diff --git a/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/io.stat b/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/io.stat
new file mode 100755 (executable)
index 0000000..14a8cfc
--- /dev/null
@@ -0,0 +1,3 @@
+259:4 rbytes=12288 wbytes=123613184 rios=3 wios=482 dbytes=0 dios=0
+254:0 rbytes=12288 wbytes=123613184 rios=3 wios=482 dbytes=0 dios=0
+259:0 rbytes=4071424 wbytes=38789120 rios=248 wios=157 dbytes=0 dios=0
diff --git a/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/cpu.stat b/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/cpu.stat
new file mode 100755 (executable)
index 0000000..c719427
--- /dev/null
@@ -0,0 +1,3 @@
+usage_usec 2670017
+user_usec 1381923
+system_usec 1288094
diff --git a/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/memory.current b/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/memory.current
new file mode 100755 (executable)
index 0000000..438275a
--- /dev/null
@@ -0,0 +1 @@
+133386240
diff --git a/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/memory.stat b/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/memory.stat
new file mode 100755 (executable)
index 0000000..533635b
--- /dev/null
@@ -0,0 +1,28 @@
+anon 16777216
+file 109891584
+kernel_stack 98304
+slab 5595136
+sock 0
+shmem 0
+file_mapped 23924736
+file_dirty 7163904
+file_writeback 135168
+inactive_anon 0
+active_anon 16818176
+inactive_file 108355584
+active_file 1560576
+unevictable 0
+slab_reclaimable 4489216
+slab_unreclaimable 1105920
+pgfault 67947
+pgmajfault 0
+pgrefill 0
+pgscan 0
+pgsteal 0
+pgactivate 0
+pgdeactivate 0
+pglazyfree 0
+pglazyfreed 0
+workingset_refault 0
+workingset_activate 0
+workingset_nodereclaim 0
diff --git a/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/memory.swap.current b/lib/crunchstat/testdata/debian10/sys/fs/cgroup/user.slice/user-1000.slice/session-7.scope/memory.swap.current
new file mode 100755 (executable)
index 0000000..573541a
--- /dev/null
@@ -0,0 +1 @@
+0
diff --git a/lib/crunchstat/testdata/debian11/proc/4153022/cgroup b/lib/crunchstat/testdata/debian11/proc/4153022/cgroup
new file mode 100755 (executable)
index 0000000..3db44ec
--- /dev/null
@@ -0,0 +1 @@
+0::/user.slice/user-1000.slice/session-5424.scope
diff --git a/lib/crunchstat/testdata/debian11/proc/4153022/cpuset b/lib/crunchstat/testdata/debian11/proc/4153022/cpuset
new file mode 100755 (executable)
index 0000000..fb6c61a
--- /dev/null
@@ -0,0 +1 @@
+/user.slice
diff --git a/lib/crunchstat/testdata/debian11/proc/4153022/net/dev b/lib/crunchstat/testdata/debian11/proc/4153022/net/dev
new file mode 100755 (executable)
index 0000000..abd7cef
--- /dev/null
@@ -0,0 +1,7 @@
+Inter-|   Receive                                                |  Transmit
+ face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
+    lo: 161155690314 90375905    0    0    0     0          0         0 161155690314 90375905    0    0    0     0       0          0
+  ens3: 163923112 1884265    0    0    0     0          0         0 43218121  239766    0    0    0     0       0          0
+  ens9: 24574250159 83081845    0    0    0     0          0         0 49312502353 91591944    0    0    0     0       0          0
+docker0: 6958795  109630    0    0    0     0          0         0 671569248  187319    0    0    0     0       0          0
+tailscale0: 82192857  118550    0    0    0     0          0         0  6898232  100243    0    0    0     0       0          0
diff --git a/lib/crunchstat/testdata/debian11/proc/cpuinfo b/lib/crunchstat/testdata/debian11/proc/cpuinfo
new file mode 100644 (file)
index 0000000..6df8854
--- /dev/null
@@ -0,0 +1,224 @@
+processor      : 0
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 61
+model name     : Intel Core Processor (Broadwell)
+stepping       : 2
+microcode      : 0x1
+cpu MHz                : 3292.366
+cache size     : 4096 KB
+physical id    : 0
+siblings       : 8
+core id                : 0
+cpu cores      : 8
+apicid         : 0
+initial apicid : 0
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.73
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 40 bits physical, 48 bits virtual
+power management:
+
+processor      : 1
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 61
+model name     : Intel Core Processor (Broadwell)
+stepping       : 2
+microcode      : 0x1
+cpu MHz                : 3292.366
+cache size     : 4096 KB
+physical id    : 0
+siblings       : 8
+core id                : 1
+cpu cores      : 8
+apicid         : 1
+initial apicid : 1
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.73
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 40 bits physical, 48 bits virtual
+power management:
+
+processor      : 2
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 61
+model name     : Intel Core Processor (Broadwell)
+stepping       : 2
+microcode      : 0x1
+cpu MHz                : 3292.366
+cache size     : 4096 KB
+physical id    : 0
+siblings       : 8
+core id                : 2
+cpu cores      : 8
+apicid         : 2
+initial apicid : 2
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.73
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 40 bits physical, 48 bits virtual
+power management:
+
+processor      : 3
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 61
+model name     : Intel Core Processor (Broadwell)
+stepping       : 2
+microcode      : 0x1
+cpu MHz                : 3292.366
+cache size     : 4096 KB
+physical id    : 0
+siblings       : 8
+core id                : 3
+cpu cores      : 8
+apicid         : 3
+initial apicid : 3
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.73
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 40 bits physical, 48 bits virtual
+power management:
+
+processor      : 4
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 61
+model name     : Intel Core Processor (Broadwell)
+stepping       : 2
+microcode      : 0x1
+cpu MHz                : 3292.366
+cache size     : 4096 KB
+physical id    : 0
+siblings       : 8
+core id                : 4
+cpu cores      : 8
+apicid         : 4
+initial apicid : 4
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.73
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 40 bits physical, 48 bits virtual
+power management:
+
+processor      : 5
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 61
+model name     : Intel Core Processor (Broadwell)
+stepping       : 2
+microcode      : 0x1
+cpu MHz                : 3292.366
+cache size     : 4096 KB
+physical id    : 0
+siblings       : 8
+core id                : 5
+cpu cores      : 8
+apicid         : 5
+initial apicid : 5
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.73
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 40 bits physical, 48 bits virtual
+power management:
+
+processor      : 6
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 61
+model name     : Intel Core Processor (Broadwell)
+stepping       : 2
+microcode      : 0x1
+cpu MHz                : 3292.366
+cache size     : 4096 KB
+physical id    : 0
+siblings       : 8
+core id                : 6
+cpu cores      : 8
+apicid         : 6
+initial apicid : 6
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.73
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 40 bits physical, 48 bits virtual
+power management:
+
+processor      : 7
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 61
+model name     : Intel Core Processor (Broadwell)
+stepping       : 2
+microcode      : 0x1
+cpu MHz                : 3292.366
+cache size     : 4096 KB
+physical id    : 0
+siblings       : 8
+core id                : 7
+cpu cores      : 8
+apicid         : 7
+initial apicid : 7
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 13
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.73
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 40 bits physical, 48 bits virtual
+power management:
+
diff --git a/lib/crunchstat/testdata/debian11/proc/mounts b/lib/crunchstat/testdata/debian11/proc/mounts
new file mode 100755 (executable)
index 0000000..715844c
--- /dev/null
@@ -0,0 +1,23 @@
+sysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0
+proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
+udev /dev devtmpfs rw,nosuid,relatime,size=4055540k,nr_inodes=1013885,mode=755 0 0
+devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000 0 0
+tmpfs /run tmpfs rw,nosuid,nodev,noexec,relatime,size=814692k,mode=755 0 0
+/dev/vdb1 / ext4 rw,relatime,errors=remount-ro 0 0
+securityfs /sys/kernel/security securityfs rw,nosuid,nodev,noexec,relatime 0 0
+tmpfs /dev/shm tmpfs rw,nosuid,nodev 0 0
+tmpfs /run/lock tmpfs rw,nosuid,nodev,noexec,relatime,size=5120k 0 0
+cgroup2 /sys/fs/cgroup cgroup2 rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot 0 0
+pstore /sys/fs/pstore pstore rw,nosuid,nodev,noexec,relatime 0 0
+none /sys/fs/bpf bpf rw,nosuid,nodev,noexec,relatime,mode=700 0 0
+systemd-1 /proc/sys/fs/binfmt_misc autofs rw,relatime,fd=30,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=9589 0 0
+hugetlbfs /dev/hugepages hugetlbfs rw,relatime,pagesize=2M 0 0
+mqueue /dev/mqueue mqueue rw,nosuid,nodev,noexec,relatime 0 0
+debugfs /sys/kernel/debug debugfs rw,nosuid,nodev,noexec,relatime 0 0
+tracefs /sys/kernel/tracing tracefs rw,nosuid,nodev,noexec,relatime 0 0
+configfs /sys/kernel/config configfs rw,nosuid,nodev,noexec,relatime 0 0
+fusectl /sys/fs/fuse/connections fusectl rw,nosuid,nodev,noexec,relatime 0 0
+none /tmp tmpfs rw,relatime 0 0
+tmpfs /run/user/1000 tmpfs rw,nosuid,nodev,relatime,size=814688k,nr_inodes=203672,mode=700,uid=1000,gid=1000 0 0
+binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc rw,nosuid,nodev,noexec,relatime 0 0
+arvados-client /home/tom/keep fuse.arvados-client rw,nosuid,nodev,relatime,user_id=1000,group_id=1000 0 0
diff --git a/lib/crunchstat/testdata/debian11/proc/self/smaps b/lib/crunchstat/testdata/debian11/proc/self/smaps
new file mode 100755 (executable)
index 0000000..f82b32b
--- /dev/null
@@ -0,0 +1,2461 @@
+00400000-00403000 r--p 00000000 fe:11 1200832                            /home/tom/.cache/arvados-build/GOPATH/bin/arvados-server
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:        12 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me dw sd 
+00403000-01776000 r-xp 00003000 fe:11 1200832                            /home/tom/.cache/arvados-build/GOPATH/bin/arvados-server
+Size:              19916 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               12492 kB
+Pss:               12492 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:     12492 kB
+Private_Dirty:         0 kB
+Referenced:        12492 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me dw sd 
+01776000-02f28000 r--p 01376000 fe:11 1200832                            /home/tom/.cache/arvados-build/GOPATH/bin/arvados-server
+Size:              24264 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               10984 kB
+Pss:               10984 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:     10984 kB
+Private_Dirty:         0 kB
+Referenced:        10984 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me dw sd 
+02f28000-02f29000 r--p 02b27000 fe:11 1200832                            /home/tom/.cache/arvados-build/GOPATH/bin/arvados-server
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me dw ac sd 
+02f29000-02fc0000 rw-p 02b28000 fe:11 1200832                            /home/tom/.cache/arvados-build/GOPATH/bin/arvados-server
+Size:                604 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 480 kB
+Pss:                 480 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:       304 kB
+Private_Dirty:       176 kB
+Referenced:          480 kB
+Anonymous:           176 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me dw ac sd 
+02fc0000-03007000 rw-p 00000000 00:00 0 
+Size:                284 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 100 kB
+Pss:                 100 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       100 kB
+Referenced:          100 kB
+Anonymous:           100 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+03a45000-03a66000 rw-p 00000000 00:00 0                                  [heap]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+c000000000-c000800000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                5684 kB
+Pss:                5684 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      5684 kB
+Referenced:         5684 kB
+Anonymous:          5684 kB
+LazyFree:              0 kB
+AnonHugePages:      2048 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+c000800000-c004000000 ---p 00000000 00:00 0 
+Size:              57344 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f3efc000000-7f3efc021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f3efc021000-7f3f00000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f3f00000000-7f3f00021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f3f00021000-7f3f04000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f3f077ff000-7f3f07800000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f07800000-7f3f08000000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        12 kB
+Referenced:           12 kB
+Anonymous:            12 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f3f08000000-7f3f08021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f3f08021000-7f3f0c000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f3f0c000000-7f3f0c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f3f0c021000-7f3f10000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f3f10000000-7f3f10021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f3f10021000-7f3f14000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f3f14000000-7f3f14021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f3f14021000-7f3f18000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f3f18000000-7f3f18021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f3f18021000-7f3f1c000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f3f1c000000-7f3f1c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f3f1c021000-7f3f20000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f3f20000000-7f3f20021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f3f20021000-7f3f24000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f3f24361000-7f3f24421000 rw-p 00000000 00:00 0 
+Size:                768 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 276 kB
+Pss:                 276 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       276 kB
+Referenced:          276 kB
+Anonymous:           276 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f24421000-7f3f24422000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f24422000-7f3f24c62000 rw-p 00000000 00:00 0 
+Size:               8448 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f3f24c62000-7f3f24c63000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f24c63000-7f3f25543000 rw-p 00000000 00:00 0 
+Size:               9088 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 720 kB
+Pss:                 720 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       720 kB
+Referenced:          720 kB
+Anonymous:           720 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f3f25543000-7f3f25544000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f25544000-7f3f25ee4000 rw-p 00000000 00:00 0 
+Size:               9856 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 288 kB
+Pss:                 288 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       288 kB
+Referenced:          288 kB
+Anonymous:           288 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f3f25ee4000-7f3f25ee5000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f25ee5000-7f3f266e5000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f3f266e5000-7f3f266e6000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f266e6000-7f3f26f26000 rw-p 00000000 00:00 0 
+Size:               8448 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 264 kB
+Pss:                 264 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       264 kB
+Referenced:          264 kB
+Anonymous:           264 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f3f26f26000-7f3f26f27000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f26f27000-7f3f27727000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f3f27727000-7f3f27728000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f27728000-7f3f27f28000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f3f27f28000-7f3f27f29000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f27f29000-7f3f2aa00000 rw-p 00000000 00:00 0 
+Size:              43868 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 164 kB
+Pss:                 164 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       164 kB
+Referenced:          164 kB
+Anonymous:           164 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f3f2aa00000-7f3f2ac00000 rw-p 00000000 00:00 0 
+Size:               2048 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd hg 
+7f3f2ac00000-7f3f2ad65000 rw-p 00000000 00:00 0 
+Size:               1428 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f2ad65000-7f3f3b2de000 ---p 00000000 00:00 0 
+Size:             267748 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f3f3b2de000-7f3f3b2df000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f3b2df000-7f3f4d18e000 ---p 00000000 00:00 0 
+Size:             293564 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f3f4d18e000-7f3f4d18f000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4d18f000-7f3f4f564000 ---p 00000000 00:00 0 
+Size:              36692 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f3f4f564000-7f3f4f565000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4f565000-7f3f4f9de000 ---p 00000000 00:00 0 
+Size:               4580 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f3f4f9de000-7f3f4f9df000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4f9df000-7f3f4fa5e000 ---p 00000000 00:00 0 
+Size:                508 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f3f4fa5e000-7f3f4fac1000 rw-p 00000000 00:00 0 
+Size:                396 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  72 kB
+Pss:                  72 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        72 kB
+Referenced:           72 kB
+Anonymous:            72 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fac1000-7f3f4fac3000 r--p 00000000 fe:11 131148                     /lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   0 kB
+Shared_Clean:          8 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            8 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fac3000-7f3f4fac6000 r-xp 00002000 fe:11 131148                     /lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f3f4fac6000-7f3f4fac7000 r--p 00005000 fe:11 131148                     /lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fac7000-7f3f4fac8000 r--p 00005000 fe:11 131148                     /lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f3f4fac8000-7f3f4fac9000 rw-p 00006000 fe:11 131148                     /lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fac9000-7f3f4facb000 rw-p 00000000 00:00 0 
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4facb000-7f3f4facc000 r--p 00000000 fe:11 131382                     /lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4facc000-7f3f4face000 r-xp 00001000 fe:11 131382                     /lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   0 kB
+Shared_Clean:          8 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            8 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f3f4face000-7f3f4facf000 r--p 00003000 fe:11 131382                     /lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4facf000-7f3f4fad0000 r--p 00003000 fe:11 131382                     /lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f3f4fad0000-7f3f4fad1000 rw-p 00004000 fe:11 131382                     /lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fad1000-7f3f4fad4000 r--p 00000000 fe:11 131116                     /lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fad4000-7f3f4fadc000 r-xp 00003000 fe:11 131116                     /lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 32 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  32 kB
+Pss:                   1 kB
+Shared_Clean:         32 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           32 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f3f4fadc000-7f3f4faf0000 r--p 0000b000 fe:11 131116                     /lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 80 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4faf0000-7f3f4faf1000 r--p 0001e000 fe:11 131116                     /lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f3f4faf1000-7f3f4faf2000 rw-p 0001f000 fe:11 131116                     /lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4faf2000-7f3f4fb02000 rw-p 00000000 00:00 0 
+Size:                 64 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fb02000-7f3f4fb24000 r--p 00000000 fe:11 131364                     /lib/x86_64-linux-gnu/libc-2.31.so
+Size:                136 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 136 kB
+Pss:                   2 kB
+Shared_Clean:        136 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          136 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fb24000-7f3f4fc7d000 r-xp 00022000 fe:11 131364                     /lib/x86_64-linux-gnu/libc-2.31.so
+Size:               1380 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 688 kB
+Pss:                  13 kB
+Shared_Clean:        688 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          688 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f3f4fc7d000-7f3f4fccc000 r--p 0017b000 fe:11 131364                     /lib/x86_64-linux-gnu/libc-2.31.so
+Size:                316 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 128 kB
+Pss:                   2 kB
+Shared_Clean:        128 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          128 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fccc000-7f3f4fcd0000 r--p 001c9000 fe:11 131364                     /lib/x86_64-linux-gnu/libc-2.31.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f3f4fcd0000-7f3f4fcd2000 rw-p 001cd000 fe:11 131364                     /lib/x86_64-linux-gnu/libc-2.31.so
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fcd2000-7f3f4fcd6000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fcd6000-7f3f4fcd9000 r--p 00000000 fe:11 131147                     /lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fcd9000-7f3f4fce2000 r-xp 00003000 fe:11 131147                     /lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                 36 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  36 kB
+Pss:                   1 kB
+Shared_Clean:         36 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           36 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f3f4fce2000-7f3f4fce6000 r--p 0000c000 fe:11 131147                     /lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fce6000-7f3f4fce7000 r--p 0000f000 fe:11 131147                     /lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f3f4fce7000-7f3f4fce8000 rw-p 00010000 fe:11 131147                     /lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fce8000-7f3f4fcee000 r--p 00000000 fe:11 131619                     /lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                 24 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  24 kB
+Pss:                   0 kB
+Shared_Clean:         24 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           24 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fcee000-7f3f4fcfe000 r-xp 00006000 fe:11 131619                     /lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                 64 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  64 kB
+Pss:                   1 kB
+Shared_Clean:         64 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           64 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f3f4fcfe000-7f3f4fd04000 r--p 00016000 fe:11 131619                     /lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                 24 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fd04000-7f3f4fd05000 r--p 0001b000 fe:11 131619                     /lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f3f4fd05000-7f3f4fd06000 rw-p 0001c000 fe:11 131619                     /lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fd06000-7f3f4fd0a000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fd0a000-7f3f4fd0e000 r--p 00000000 fe:11 133617                     /lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                   1 kB
+Shared_Clean:         16 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           16 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fd0e000-7f3f4fd1c000 r-xp 00004000 fe:11 133617                     /lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                 56 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  56 kB
+Pss:                   4 kB
+Shared_Clean:         56 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           56 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f3f4fd1c000-7f3f4fd20000 r--p 00012000 fe:11 133617                     /lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f3f4fd20000-7f3f4fd21000 r--p 00015000 fe:11 133617                     /lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f3f4fd21000-7f3f4fd22000 rw-p 00016000 fe:11 133617                     /lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fd22000-7f3f4fd26000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f3f4fd34000-7f3f4fd35000 r--p 00000000 fe:11 131157                     /lib/x86_64-linux-gnu/ld-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me dw sd 
+7f3f4fd35000-7f3f4fd55000 r-xp 00001000 fe:11 131157                     /lib/x86_64-linux-gnu/ld-2.31.so
+Size:                128 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 128 kB
+Pss:                   1 kB
+Shared_Clean:        128 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          128 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me dw sd 
+7f3f4fd55000-7f3f4fd5d000 r--p 00021000 fe:11 131157                     /lib/x86_64-linux-gnu/ld-2.31.so
+Size:                 32 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  32 kB
+Pss:                   0 kB
+Shared_Clean:         32 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           32 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me dw sd 
+7f3f4fd5e000-7f3f4fd5f000 r--p 00029000 fe:11 131157                     /lib/x86_64-linux-gnu/ld-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me dw ac sd 
+7f3f4fd5f000-7f3f4fd60000 rw-p 0002a000 fe:11 131157                     /lib/x86_64-linux-gnu/ld-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me dw ac sd 
+7f3f4fd60000-7f3f4fd61000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7fff6be6f000-7fff6be90000 rw-p 00000000 00:00 0                          [stack]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  20 kB
+Pss:                  20 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        20 kB
+Referenced:           20 kB
+Anonymous:            20 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me gd ac 
+7fff6bee1000-7fff6bee5000 r--p 00000000 00:00 0                          [vvar]
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr pf io de dd sd 
+7fff6bee5000-7fff6bee7000 r-xp 00000000 00:00 0                          [vdso]
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me de sd 
diff --git a/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/cpu.max b/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/cpu.max
new file mode 100755 (executable)
index 0000000..1c1d3e7
--- /dev/null
@@ -0,0 +1 @@
+max 100000
diff --git a/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/cpuset.cpus.effective b/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/cpuset.cpus.effective
new file mode 100755 (executable)
index 0000000..74fc2fb
--- /dev/null
@@ -0,0 +1 @@
+0-7
diff --git a/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/io.stat b/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/io.stat
new file mode 100755 (executable)
index 0000000..34cbdb8
--- /dev/null
@@ -0,0 +1,4 @@
+7:1 rbytes=7218176 wbytes=0 rios=240 wios=0 dbytes=0 dios=0
+7:2 rbytes=2115584 wbytes=0 rios=64 wios=0 dbytes=0 dios=0
+7:0 rbytes=218925056 wbytes=0 rios=7382 wios=0 dbytes=0 dios=0
+254:16 rbytes=268548554752 wbytes=121274503168 rios=32054623 wios=8793862 dbytes=0 dios=0
diff --git a/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/cpu.stat b/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/cpu.stat
new file mode 100755 (executable)
index 0000000..ffd3445
--- /dev/null
@@ -0,0 +1,3 @@
+usage_usec 935017572836
+user_usec 441034348821
+system_usec 493983224015
diff --git a/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/memory.current b/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/memory.current
new file mode 100755 (executable)
index 0000000..9e5f0fb
--- /dev/null
@@ -0,0 +1 @@
+3662082048
diff --git a/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/memory.stat b/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/memory.stat
new file mode 100755 (executable)
index 0000000..e72becb
--- /dev/null
@@ -0,0 +1,36 @@
+anon 869666816
+file 2622799872
+kernel_stack 4276224
+percpu 0
+sock 0
+shmem 849936384
+file_mapped 57311232
+file_dirty 270336
+file_writeback 135168
+anon_thp 553648128
+inactive_anon 391749632
+active_anon 1332850688
+inactive_file 243453952
+active_file 1529008128
+unevictable 0
+slab_reclaimable 135355928
+slab_unreclaimable 8377048
+slab 143732976
+workingset_refault_anon 84645
+workingset_refault_file 7429752
+workingset_activate_anon 15444
+workingset_activate_file 4704645
+workingset_restore_anon 1551
+workingset_restore_file 2826087
+workingset_nodereclaim 0
+pgfault 1688981547
+pgmajfault 322476
+pgrefill 24091451
+pgscan 32183888
+pgsteal 18202144
+pgactivate 32572518
+pgdeactivate 13641072
+pglazyfree 1254
+pglazyfreed 0
+thp_fault_alloc 149061
+thp_collapse_alloc 3267
diff --git a/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/memory.swap.current b/lib/crunchstat/testdata/debian11/sys/fs/cgroup/user.slice/user-1000.slice/session-5424.scope/memory.swap.current
new file mode 100755 (executable)
index 0000000..cadc7c5
--- /dev/null
@@ -0,0 +1 @@
+2462470144
diff --git a/lib/crunchstat/testdata/debian12/proc/1115883/cgroup b/lib/crunchstat/testdata/debian12/proc/1115883/cgroup
new file mode 100755 (executable)
index 0000000..af9540a
--- /dev/null
@@ -0,0 +1 @@
+0::/user.slice/user-1000.slice/session-4.scope
diff --git a/lib/crunchstat/testdata/debian12/proc/1115883/cpuset b/lib/crunchstat/testdata/debian12/proc/1115883/cpuset
new file mode 100755 (executable)
index 0000000..fb6c61a
--- /dev/null
@@ -0,0 +1 @@
+/user.slice
diff --git a/lib/crunchstat/testdata/debian12/proc/1115883/net/dev b/lib/crunchstat/testdata/debian12/proc/1115883/net/dev
new file mode 100755 (executable)
index 0000000..6a28430
--- /dev/null
@@ -0,0 +1,4 @@
+Inter-|   Receive                                                |  Transmit
+ face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
+    lo: 44467931   32124    0    0    0     0          0         0 44467931   32124    0    0    0     0       0          0
+enp4s0: 76312173774 219652689    0   33    0     0          0    226563 52498381226 153789479    0    0    0     0       0          0
diff --git a/lib/crunchstat/testdata/debian12/proc/cpuinfo b/lib/crunchstat/testdata/debian12/proc/cpuinfo
new file mode 100644 (file)
index 0000000..0685c5f
--- /dev/null
@@ -0,0 +1,224 @@
+processor      : 0
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3591.771
+cache size     : 6144 KB
+physical id    : 0
+siblings       : 8
+core id                : 0
+cpu cores      : 4
+apicid         : 0
+initial apicid : 0
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest ple
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa itlb_multihit srbds mmio_unknown
+bogomips       : 6584.91
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 1
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3591.750
+cache size     : 6144 KB
+physical id    : 0
+siblings       : 8
+core id                : 0
+cpu cores      : 4
+apicid         : 1
+initial apicid : 1
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest ple
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa itlb_multihit srbds mmio_unknown
+bogomips       : 6584.91
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 2
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3602.533
+cache size     : 6144 KB
+physical id    : 0
+siblings       : 8
+core id                : 1
+cpu cores      : 4
+apicid         : 2
+initial apicid : 2
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest ple
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa itlb_multihit srbds mmio_unknown
+bogomips       : 6584.91
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 3
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3607.600
+cache size     : 6144 KB
+physical id    : 0
+siblings       : 8
+core id                : 1
+cpu cores      : 4
+apicid         : 3
+initial apicid : 3
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest ple
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa itlb_multihit srbds mmio_unknown
+bogomips       : 6584.91
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 4
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3600.169
+cache size     : 6144 KB
+physical id    : 0
+siblings       : 8
+core id                : 2
+cpu cores      : 4
+apicid         : 4
+initial apicid : 4
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest ple
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa itlb_multihit srbds mmio_unknown
+bogomips       : 6584.91
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 5
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3609.318
+cache size     : 6144 KB
+physical id    : 0
+siblings       : 8
+core id                : 2
+cpu cores      : 4
+apicid         : 5
+initial apicid : 5
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest ple
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa itlb_multihit srbds mmio_unknown
+bogomips       : 6584.91
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 6
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3591.905
+cache size     : 6144 KB
+physical id    : 0
+siblings       : 8
+core id                : 3
+cpu cores      : 4
+apicid         : 6
+initial apicid : 6
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest ple
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa itlb_multihit srbds mmio_unknown
+bogomips       : 6584.91
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 7
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3591.804
+cache size     : 6144 KB
+physical id    : 0
+siblings       : 8
+core id                : 3
+cpu cores      : 4
+apicid         : 7
+initial apicid : 7
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest ple
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa itlb_multihit srbds mmio_unknown
+bogomips       : 6584.91
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
diff --git a/lib/crunchstat/testdata/debian12/proc/mounts b/lib/crunchstat/testdata/debian12/proc/mounts
new file mode 100755 (executable)
index 0000000..f8850e2
--- /dev/null
@@ -0,0 +1,32 @@
+sysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0
+proc /proc proc rw,relatime 0 0
+udev /dev devtmpfs rw,nosuid,relatime,size=16346052k,nr_inodes=4086513,mode=755,inode64 0 0
+devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000 0 0
+tmpfs /run tmpfs rw,nosuid,nodev,noexec,relatime,size=3275420k,mode=755,inode64 0 0
+/dev/mapper/slab1-root / ext4 rw,relatime,errors=remount-ro,stripe=8191 0 0
+securityfs /sys/kernel/security securityfs rw,nosuid,nodev,noexec,relatime 0 0
+tmpfs /dev/shm tmpfs rw,nosuid,nodev,inode64 0 0
+tmpfs /run/lock tmpfs rw,nosuid,nodev,noexec,relatime,size=5120k,inode64 0 0
+cgroup2 /sys/fs/cgroup cgroup2 rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot 0 0
+pstore /sys/fs/pstore pstore rw,nosuid,nodev,noexec,relatime 0 0
+bpf /sys/fs/bpf bpf rw,nosuid,nodev,noexec,relatime,mode=700 0 0
+systemd-1 /proc/sys/fs/binfmt_misc autofs rw,relatime,fd=29,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=16801 0 0
+tracefs /sys/kernel/tracing tracefs rw,nosuid,nodev,noexec,relatime 0 0
+mqueue /dev/mqueue mqueue rw,nosuid,nodev,noexec,relatime 0 0
+debugfs /sys/kernel/debug debugfs rw,nosuid,nodev,noexec,relatime 0 0
+hugetlbfs /dev/hugepages hugetlbfs rw,relatime,pagesize=2M 0 0
+fusectl /sys/fs/fuse/connections fusectl rw,nosuid,nodev,noexec,relatime 0 0
+configfs /sys/kernel/config configfs rw,nosuid,nodev,noexec,relatime 0 0
+ramfs /run/credentials/systemd-sysusers.service ramfs ro,nosuid,nodev,noexec,relatime,mode=700 0 0
+ramfs /run/credentials/systemd-sysctl.service ramfs ro,nosuid,nodev,noexec,relatime,mode=700 0 0
+ramfs /run/credentials/systemd-tmpfiles-setup-dev.service ramfs ro,nosuid,nodev,noexec,relatime,mode=700 0 0
+/dev/mapper/slab1-home /home ext4 rw,relatime,errors=remount-ro 0 0
+/dev/md0p1 /boot ext4 rw,relatime,stripe=8191 0 0
+ramfs /run/credentials/systemd-tmpfiles-setup.service ramfs ro,nosuid,nodev,noexec,relatime,mode=700 0 0
+binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc rw,nosuid,nodev,noexec,relatime 0 0
+tmpfs /run/user/1000 tmpfs rw,nosuid,nodev,relatime,size=3275416k,nr_inodes=818854,mode=700,uid=1000,gid=1000,inode64 0 0
+gvfsd-fuse /run/user/1000/gvfs fuse.gvfsd-fuse rw,nosuid,nodev,relatime,user_id=1000,group_id=1000 0 0
+/dev/mapper/sea5a /sea5a ext4 rw,relatime 0 0
+portal /run/user/1000/doc fuse.portal rw,nosuid,nodev,relatime,user_id=1000,group_id=1000 0 0
+curve:/ /tmp/c fuse.sshfs rw,nosuid,nodev,relatime,user_id=1000,group_id=1000 0 0
+tmpfs /run/user/0 tmpfs rw,nosuid,nodev,relatime,size=3275416k,nr_inodes=818854,mode=700,inode64 0 0
diff --git a/lib/crunchstat/testdata/debian12/proc/self/smaps b/lib/crunchstat/testdata/debian12/proc/self/smaps
new file mode 100755 (executable)
index 0000000..6152a72
--- /dev/null
@@ -0,0 +1,2640 @@
+00400000-00403000 r--p 00000000 fd:01 2228820                            /tmp/arvados-server
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:        12 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+00403000-01776000 r-xp 00003000 fd:01 2228820                            /tmp/arvados-server
+Size:              19916 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               12492 kB
+Pss:               12492 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:     12492 kB
+Private_Dirty:         0 kB
+Referenced:        12492 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+01776000-02f28000 r--p 01376000 fd:01 2228820                            /tmp/arvados-server
+Size:              24264 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               10856 kB
+Pss:               10856 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:     10856 kB
+Private_Dirty:         0 kB
+Referenced:        10856 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+02f28000-02f29000 r--p 02b27000 fd:01 2228820                            /tmp/arvados-server
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+02f29000-02fc0000 rw-p 02b28000 fd:01 2228820                            /tmp/arvados-server
+Size:                604 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 480 kB
+Pss:                 480 kB
+Pss_Dirty:           176 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:       304 kB
+Private_Dirty:       176 kB
+Referenced:          480 kB
+Anonymous:           176 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+02fc0000-03007000 rw-p 00000000 00:00 0 
+Size:                284 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 100 kB
+Pss:                 100 kB
+Pss_Dirty:           100 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       100 kB
+Referenced:          100 kB
+Anonymous:           100 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+04b9f000-04bc0000 rw-p 00000000 00:00 0                                  [heap]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+c000000000-c000c00000 rw-p 00000000 00:00 0 
+Size:              12288 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                5596 kB
+Pss:                5596 kB
+Pss_Dirty:          5596 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      5596 kB
+Referenced:         5596 kB
+Anonymous:          5596 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+c000c00000-c004000000 ---p 00000000 00:00 0 
+Size:              53248 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f716c000000-7f716c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f716c021000-7f7170000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f7170000000-7f7170021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7170021000-7f7174000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f7174000000-7f7174021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7174021000-7f7178000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f717a7fd000-7f717a7fe000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f717a7fe000-7f717affe000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f717affe000-7f717afff000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f717afff000-7f717b7ff000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f717b7ff000-7f717b800000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f717b800000-7f717c000000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Pss_Dirty:            12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        12 kB
+Referenced:           12 kB
+Anonymous:            12 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f717c000000-7f717c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f717c021000-7f7180000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f7180000000-7f7180021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7180021000-7f7184000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f7184000000-7f7184021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7184021000-7f7188000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f7188000000-7f7188021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7188021000-7f718c000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f718c000000-7f718c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f718c021000-7f7190000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f7190000000-7f7190021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7190021000-7f7194000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f7194000000-7f7194021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7194021000-7f7198000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f71985d9000-7f7198769000 rw-p 00000000 00:00 0 
+Size:               1600 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 968 kB
+Pss:                 968 kB
+Pss_Dirty:           968 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       968 kB
+Referenced:          968 kB
+Anonymous:           968 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f7198769000-7f719876a000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f719876a000-7f7198faa000 rw-p 00000000 00:00 0 
+Size:               8448 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  24 kB
+Pss:                  24 kB
+Pss_Dirty:            24 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        24 kB
+Referenced:           24 kB
+Anonymous:            24 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f7198faa000-7f7198fab000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f7198fab000-7f71997fb000 rw-p 00000000 00:00 0 
+Size:               8512 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  32 kB
+Pss:                  32 kB
+Pss_Dirty:            32 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        32 kB
+Referenced:           32 kB
+Anonymous:            32 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f71997fb000-7f71997fc000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f71997fc000-7f7199ffc000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f7199ffc000-7f7199ffd000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f7199ffd000-7f719a7fd000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f719a7fd000-7f719a7fe000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f719a7fe000-7f719affe000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f719affe000-7f719afff000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f719afff000-7f719b7ff000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f719b7ff000-7f719b800000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f719b800000-7f719c000000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f719c000000-7f719c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f719c021000-7f71a0000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me nr sd 
+7f71a000b000-7f71a01eb000 rw-p 00000000 00:00 0 
+Size:               1920 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 524 kB
+Pss:                 524 kB
+Pss_Dirty:           524 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       524 kB
+Referenced:          524 kB
+Anonymous:           524 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71a01eb000-7f71a01ec000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f71a01ec000-7f71a2e00000 rw-p 00000000 00:00 0 
+Size:              45136 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 168 kB
+Pss:                 168 kB
+Pss_Dirty:           168 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       168 kB
+Referenced:          168 kB
+Anonymous:           168 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd 
+7f71a2e00000-7f71a3000000 rw-p 00000000 00:00 0 
+Size:               2048 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd hg 
+7f71a3000000-7f71a3018000 rw-p 00000000 00:00 0 
+Size:                 96 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71a3018000-7f71b3591000 ---p 00000000 00:00 0 
+Size:             267748 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f71b3591000-7f71b3592000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71b3592000-7f71c5441000 ---p 00000000 00:00 0 
+Size:             293564 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f71c5441000-7f71c5442000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c5442000-7f71c7817000 ---p 00000000 00:00 0 
+Size:              36692 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f71c7817000-7f71c7818000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7818000-7f71c7c91000 ---p 00000000 00:00 0 
+Size:               4580 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: mr mw me sd 
+7f71c7c91000-7f71c7c92000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7c92000-7f71c7d11000 ---p 00000000 00:00 0 
+Size:                508 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f71c7d11000-7f71c7d74000 rw-p 00000000 00:00 0 
+Size:                396 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  72 kB
+Pss:                  72 kB
+Pss_Dirty:            72 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        72 kB
+Referenced:           72 kB
+Anonymous:            72 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7d74000-7f71c7d76000 r--p 00000000 fd:01 1609774                    /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   2 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          8 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            8 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7d76000-7f71c7d79000 r-xp 00002000 fd:01 1609774                    /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   1 kB
+Pss_Dirty:             0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f71c7d79000-7f71c7d7a000 r--p 00005000 fd:01 1609774                    /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   1 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7d7a000-7f71c7d7b000 r--p 00006000 fd:01 1609774                    /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f71c7d7b000-7f71c7d7c000 rw-p 00007000 fd:01 1609774                    /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7d7c000-7f71c7d7e000 rw-p 00000000 00:00 0 
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7d7e000-7f71c7d81000 r--p 00000000 fd:01 1609746                    /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   3 kB
+Pss_Dirty:             0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7d81000-7f71c7d88000 r-xp 00003000 fd:01 1609746                    /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 28 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  28 kB
+Pss:                   1 kB
+Pss_Dirty:             0 kB
+Shared_Clean:         28 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           28 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f71c7d88000-7f71c7d9d000 r--p 0000a000 fd:01 1609746                    /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 84 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7d9d000-7f71c7d9e000 r--p 0001e000 fd:01 1609746                    /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f71c7d9e000-7f71c7d9f000 rw-p 0001f000 fd:01 1609746                    /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7d9f000-7f71c7daf000 rw-p 00000000 00:00 0 
+Size:                 64 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7daf000-7f71c7dd5000 r--p 00000000 fd:01 1576589                    /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:                152 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 152 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:        152 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          152 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7dd5000-7f71c7f2a000 r-xp 00026000 fd:01 1576589                    /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:               1364 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 940 kB
+Pss:                   4 kB
+Pss_Dirty:             0 kB
+Shared_Clean:        940 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          940 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f71c7f2a000-7f71c7f7d000 r--p 0017b000 fd:01 1576589                    /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:                332 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 128 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:        128 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          128 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7f7d000-7f71c7f81000 r--p 001ce000 fd:01 1576589                    /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Pss_Dirty:            16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f71c7f81000-7f71c7f83000 rw-p 001d2000 fd:01 1576589                    /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7f83000-7f71c7f90000 rw-p 00000000 00:00 0 
+Size:                 52 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  20 kB
+Pss:                  20 kB
+Pss_Dirty:            20 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        20 kB
+Referenced:           20 kB
+Anonymous:            20 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7f90000-7f71c7f93000 r--p 00000000 fd:01 1609792                    /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   3 kB
+Pss_Dirty:             0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7f93000-7f71c7f9c000 r-xp 00003000 fd:01 1609792                    /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                 36 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  36 kB
+Pss:                  11 kB
+Pss_Dirty:             0 kB
+Shared_Clean:         36 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           36 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f71c7f9c000-7f71c7fa0000 r--p 0000c000 fd:01 1609792                    /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7fa0000-7f71c7fa1000 r--p 0000f000 fd:01 1609792                    /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f71c7fa1000-7f71c7fa2000 rw-p 00010000 fd:01 1609792                    /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7fa2000-7f71c7fa3000 r--p 00000000 fd:01 1609844                    /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7fa3000-7f71c7fa4000 r-xp 00001000 fd:01 1609844                    /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f71c7fa4000-7f71c7fa5000 r--p 00002000 fd:01 1609844                    /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7fa5000-7f71c7fa6000 r--p 00002000 fd:01 1609844                    /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f71c7fa6000-7f71c7fa7000 rw-p 00003000 fd:01 1609844                    /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7fa7000-7f71c7faa000 r--p 00000000 fd:01 1609840                    /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7faa000-7f71c7fb2000 r-xp 00003000 fd:01 1609840                    /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                 32 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  28 kB
+Pss:                   1 kB
+Pss_Dirty:             0 kB
+Shared_Clean:         28 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           28 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f71c7fb2000-7f71c7fb4000 r--p 0000b000 fd:01 1609840                    /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7fb4000-7f71c7fb5000 r--p 0000d000 fd:01 1609840                    /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f71c7fb5000-7f71c7fb6000 rw-p 0000e000 fd:01 1609840                    /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Pss_Dirty:             4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7fb6000-7f71c7fb8000 rw-p 00000000 00:00 0 
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7fbe000-7f71c7fd0000 rw-p 00000000 00:00 0 
+Size:                 72 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  20 kB
+Pss:                  20 kB
+Pss_Dirty:            20 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        20 kB
+Referenced:           20 kB
+Anonymous:            20 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f71c7fd0000-7f71c7fd1000 r--p 00000000 fd:01 1586742                    /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c7fd1000-7f71c7ff6000 r-xp 00001000 fd:01 1586742                    /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                148 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 148 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:        148 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          148 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f71c7ff6000-7f71c8000000 r--p 00026000 fd:01 1586742                    /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                 40 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  40 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:         40 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           40 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f71c8000000-7f71c8002000 r--p 00030000 fd:01 1586742                    /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f71c8002000-7f71c8004000 rw-p 00032000 fd:01 1586742                    /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Pss_Dirty:             8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7fff31879000-7fff3189a000 rw-p 00000000 00:00 0                          [stack]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Pss_Dirty:            16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me gd ac 
+7fff3191c000-7fff31920000 r--p 00000000 00:00 0                          [vvar]
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr pf io de dd sd 
+7fff31920000-7fff31922000 r-xp 00000000 00:00 0                          [vdso]
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Pss_Dirty:             0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me de sd 
diff --git a/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/cpuset.cpus.effective b/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/cpuset.cpus.effective
new file mode 100755 (executable)
index 0000000..74fc2fb
--- /dev/null
@@ -0,0 +1 @@
+0-7
diff --git a/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/io.stat b/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/io.stat
new file mode 100755 (executable)
index 0000000..04f9838
--- /dev/null
@@ -0,0 +1,8 @@
+253:2 rbytes=2110803968 wbytes=8333664256 rios=515333 wios=1682507 dbytes=0 dios=0
+8:32 rbytes=50547765248 wbytes=0 rios=12340763 wios=0 dbytes=0 dios=0
+253:16 rbytes=50547765248 wbytes=3666890752 rios=12340763 wios=566510 dbytes=0 dios=0
+253:1 rbytes=9051578368 wbytes=3648737280 rios=879731 wios=167625 dbytes=0 dios=0
+8:16 rbytes=21434400768 wbytes=0 rios=2586700 wios=0 dbytes=0 dios=0
+9:0 rbytes=21434400768 wbytes=0 rios=2586700 wios=1033447 dbytes=0 dios=0
+253:0 rbytes=21433970688 wbytes=107989528576 rios=2586167 wios=5402495 dbytes=0 dios=0
+253:3 rbytes=10271588352 wbytes=181110276096 rios=1191103 wios=15544929 dbytes=0 dios=0
diff --git a/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/cpu.max b/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/cpu.max
new file mode 100755 (executable)
index 0000000..1c1d3e7
--- /dev/null
@@ -0,0 +1 @@
+max 100000
diff --git a/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/cpu.stat b/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/cpu.stat
new file mode 100755 (executable)
index 0000000..ccf3564
--- /dev/null
@@ -0,0 +1,8 @@
+usage_usec 1055978930168
+user_usec 980146248781
+system_usec 75832681387
+nr_periods 0
+nr_throttled 0
+throttled_usec 0
+nr_bursts 0
+burst_usec 0
diff --git a/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.current b/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.current
new file mode 100755 (executable)
index 0000000..90f5f91
--- /dev/null
@@ -0,0 +1 @@
+12591513600
diff --git a/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.stat b/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.stat
new file mode 100755 (executable)
index 0000000..84b90e5
--- /dev/null
@@ -0,0 +1,51 @@
+anon 9158508544
+file 2762801152
+kernel 503017472
+kernel_stack 27049984
+pagetables 149635072
+sec_pagetables 0
+percpu 58040
+sock 217088
+vmalloc 630784
+shmem 2040651776
+zswap 0
+zswapped 0
+file_mapped 445124608
+file_dirty 7008256
+file_writeback 0
+swapcached 170151936
+anon_thp 981467136
+file_thp 0
+shmem_thp 0
+inactive_anon 6160973824
+active_anon 4963110912
+inactive_file 213557248
+active_file 508547072
+unevictable 240934912
+slab_reclaimable 227201576
+slab_unreclaimable 94041680
+slab 321243256
+workingset_refault_anon 496572
+workingset_refault_file 2613659
+workingset_activate_anon 61432
+workingset_activate_file 1430266
+workingset_restore_anon 5935
+workingset_restore_file 922840
+workingset_nodereclaim 0
+pgscan 18707280
+pgsteal 10023314
+pgscan_kswapd 14949081
+pgscan_direct 3758199
+pgsteal_kswapd 8515423
+pgsteal_direct 1507891
+pgfault 5724466729
+pgmajfault 271316
+pgrefill 5283337
+pgactivate 130257374
+pgdeactivate 3808695
+pglazyfree 0
+pglazyfreed 0
+zswpin 0
+zswpout 0
+thp_fault_alloc 102655
+thp_collapse_alloc 5073
diff --git a/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.swap.current b/lib/crunchstat/testdata/debian12/sys/fs/cgroup/user.slice/user-1000.slice/session-4.scope/memory.swap.current
new file mode 100755 (executable)
index 0000000..dd476ba
--- /dev/null
@@ -0,0 +1 @@
+3554775040
diff --git a/lib/crunchstat/testdata/fakestat/cgroup.procs b/lib/crunchstat/testdata/fakestat/cgroup.procs
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/lib/crunchstat/testdata/fakestat/memory.stat b/lib/crunchstat/testdata/fakestat/memory.stat
deleted file mode 100644 (file)
index f124521..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-rss 990
-total_rss 786432000
-pgmajfault 3200
-total_cache 73400320
-total_pgmajfault 20
-total_swap 320
diff --git a/lib/crunchstat/testdata/ubuntu1804/proc/2523/cgroup b/lib/crunchstat/testdata/ubuntu1804/proc/2523/cgroup
new file mode 100755 (executable)
index 0000000..a56b7e2
--- /dev/null
@@ -0,0 +1,13 @@
+12:freezer:/
+11:rdma:/
+10:devices:/user.slice
+9:blkio:/user.slice
+8:net_cls,net_prio:/
+7:cpu,cpuacct:/user.slice
+6:memory:/user.slice
+5:cpuset:/
+4:perf_event:/
+3:pids:/user.slice/user-1000.slice/session-1.scope
+2:hugetlb:/
+1:name=systemd:/user.slice/user-1000.slice/session-1.scope
+0::/user.slice/user-1000.slice/session-1.scope
diff --git a/lib/crunchstat/testdata/ubuntu1804/proc/2523/cpuset b/lib/crunchstat/testdata/ubuntu1804/proc/2523/cpuset
new file mode 100755 (executable)
index 0000000..b498fd4
--- /dev/null
@@ -0,0 +1 @@
+/
diff --git a/lib/crunchstat/testdata/ubuntu1804/proc/2523/net/dev b/lib/crunchstat/testdata/ubuntu1804/proc/2523/net/dev
new file mode 100755 (executable)
index 0000000..d2e7d37
--- /dev/null
@@ -0,0 +1,4 @@
+Inter-|   Receive                                                |  Transmit
+ face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
+    lo:    8492     102    0    0    0     0          0         0     8492     102    0    0    0     0       0          0
+enp1s0: 392046996  307389    0 31358    0     0          0         0  2402023   32125    0    0    0     0       0          0
diff --git a/lib/crunchstat/testdata/ubuntu1804/proc/cpuinfo b/lib/crunchstat/testdata/ubuntu1804/proc/cpuinfo
new file mode 100755 (executable)
index 0000000..8cae829
--- /dev/null
@@ -0,0 +1,54 @@
+processor      : 0
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3292.388
+cache size     : 16384 KB
+physical id    : 0
+siblings       : 1
+core id                : 0
+cpu cores      : 1
+apicid         : 0
+initial apicid : 0
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.77
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 1
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3292.388
+cache size     : 16384 KB
+physical id    : 1
+siblings       : 1
+core id                : 0
+cpu cores      : 1
+apicid         : 1
+initial apicid : 1
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.77
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
diff --git a/lib/crunchstat/testdata/ubuntu1804/proc/mounts b/lib/crunchstat/testdata/ubuntu1804/proc/mounts
new file mode 100755 (executable)
index 0000000..17d7f08
--- /dev/null
@@ -0,0 +1,34 @@
+sysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0
+proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
+udev /dev devtmpfs rw,nosuid,relatime,size=986344k,nr_inodes=246586,mode=755 0 0
+devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000 0 0
+tmpfs /run tmpfs rw,nosuid,noexec,relatime,size=204064k,mode=755 0 0
+/dev/mapper/ubuntu--vg-ubuntu--lv / ext4 rw,relatime,data=ordered 0 0
+securityfs /sys/kernel/security securityfs rw,nosuid,nodev,noexec,relatime 0 0
+tmpfs /dev/shm tmpfs rw,nosuid,nodev 0 0
+tmpfs /run/lock tmpfs rw,nosuid,nodev,noexec,relatime,size=5120k 0 0
+tmpfs /sys/fs/cgroup tmpfs ro,nosuid,nodev,noexec,mode=755 0 0
+cgroup /sys/fs/cgroup/unified cgroup2 rw,nosuid,nodev,noexec,relatime 0 0
+cgroup /sys/fs/cgroup/systemd cgroup rw,nosuid,nodev,noexec,relatime,xattr,name=systemd 0 0
+pstore /sys/fs/pstore pstore rw,nosuid,nodev,noexec,relatime 0 0
+cgroup /sys/fs/cgroup/hugetlb cgroup rw,nosuid,nodev,noexec,relatime,hugetlb 0 0
+cgroup /sys/fs/cgroup/pids cgroup rw,nosuid,nodev,noexec,relatime,pids 0 0
+cgroup /sys/fs/cgroup/perf_event cgroup rw,nosuid,nodev,noexec,relatime,perf_event 0 0
+cgroup /sys/fs/cgroup/cpuset cgroup rw,nosuid,nodev,noexec,relatime,cpuset 0 0
+cgroup /sys/fs/cgroup/memory cgroup rw,nosuid,nodev,noexec,relatime,memory 0 0
+cgroup /sys/fs/cgroup/cpu,cpuacct cgroup rw,nosuid,nodev,noexec,relatime,cpu,cpuacct 0 0
+cgroup /sys/fs/cgroup/net_cls,net_prio cgroup rw,nosuid,nodev,noexec,relatime,net_cls,net_prio 0 0
+cgroup /sys/fs/cgroup/blkio cgroup rw,nosuid,nodev,noexec,relatime,blkio 0 0
+cgroup /sys/fs/cgroup/devices cgroup rw,nosuid,nodev,noexec,relatime,devices 0 0
+cgroup /sys/fs/cgroup/rdma cgroup rw,nosuid,nodev,noexec,relatime,rdma 0 0
+cgroup /sys/fs/cgroup/freezer cgroup rw,nosuid,nodev,noexec,relatime,freezer 0 0
+hugetlbfs /dev/hugepages hugetlbfs rw,relatime,pagesize=2M 0 0
+mqueue /dev/mqueue mqueue rw,relatime 0 0
+systemd-1 /proc/sys/fs/binfmt_misc autofs rw,relatime,fd=38,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=12761 0 0
+debugfs /sys/kernel/debug debugfs rw,relatime 0 0
+configfs /sys/kernel/config configfs rw,relatime 0 0
+fusectl /sys/fs/fuse/connections fusectl rw,relatime 0 0
+binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc rw,relatime 0 0
+/dev/vda2 /boot ext4 rw,relatime,data=ordered 0 0
+lxcfs /var/lib/lxcfs fuse.lxcfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other 0 0
+tmpfs /run/user/1000 tmpfs rw,nosuid,nodev,relatime,size=204060k,mode=700,uid=1000,gid=1000 0 0
diff --git a/lib/crunchstat/testdata/ubuntu1804/proc/self/smaps b/lib/crunchstat/testdata/ubuntu1804/proc/self/smaps
new file mode 100755 (executable)
index 0000000..59f8688
--- /dev/null
@@ -0,0 +1,1848 @@
+00400000-00403000 r--p 00000000 fd:00 135685                             /tmp/arvados-server
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        12 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me dw sd 
+00403000-01776000 r-xp 00003000 fd:00 135685                             /tmp/arvados-server
+Size:              19916 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               12492 kB
+Pss:               12492 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:     12492 kB
+Referenced:        12492 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me dw sd 
+01776000-02f28000 r--p 01376000 fd:00 135685                             /tmp/arvados-server
+Size:              24264 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               11368 kB
+Pss:               11368 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:     11368 kB
+Referenced:        11368 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me dw sd 
+02f28000-02f29000 r--p 02b27000 fd:00 135685                             /tmp/arvados-server
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me dw ac sd 
+02f29000-02fc0000 rw-p 02b28000 fd:00 135685                             /tmp/arvados-server
+Size:                604 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 480 kB
+Pss:                 480 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       480 kB
+Referenced:          480 kB
+Anonymous:           176 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me dw ac sd 
+02fc0000-03007000 rw-p 00000000 00:00 0 
+Size:                284 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 100 kB
+Pss:                 100 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       100 kB
+Referenced:          100 kB
+Anonymous:           100 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+03d38000-03d59000 rw-p 00000000 00:00 0                                  [heap]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+c000000000-c000800000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                5996 kB
+Pss:                5996 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      5996 kB
+Referenced:         5996 kB
+Anonymous:          5996 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+c000800000-c004000000 ---p 00000000 00:00 0 
+Size:              57344 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f7580000000-7f7580021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me nr sd 
+7f7580021000-7f7584000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me nr sd 
+7f7584000000-7f7584021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me nr sd 
+7f7584021000-7f7588000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me nr sd 
+7f7588000000-7f7588021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me nr sd 
+7f7588021000-7f758c000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me nr sd 
+7f758c000000-7f758c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me nr sd 
+7f758c021000-7f7590000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me nr sd 
+7f7590000000-7f7590021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me nr sd 
+7f7590021000-7f7594000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me nr sd 
+7f7597cff000-7f7597d00000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f7597d00000-7f7598500000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f7599ffc000-7f7599ffd000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f7599ffd000-7f759a7fd000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f759a7fd000-7f759a7fe000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f759a7fe000-7f759affe000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f759affe000-7f759afff000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f759afff000-7f759b7ff000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f759b7ff000-7f759b800000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f759b800000-7f759c000000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f759c000000-7f759c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me nr sd 
+7f759c021000-7f75a0000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me nr sd 
+7f75a0260000-7f75a0500000 rw-p 00000000 00:00 0 
+Size:               2688 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                1300 kB
+Pss:                1300 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      1300 kB
+Referenced:         1300 kB
+Anonymous:          1300 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75a0500000-7f75a0501000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75a0501000-7f75a3000000 rw-p 00000000 00:00 0 
+Size:              44028 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 136 kB
+Pss:                 136 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       136 kB
+Referenced:          136 kB
+Anonymous:           136 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75a3000000-7f75a3200000 rw-p 00000000 00:00 0 
+Size:               2048 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd hg 
+7f75a3200000-7f75a331d000 rw-p 00000000 00:00 0 
+Size:               1140 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75a331d000-7f75b3896000 ---p 00000000 00:00 0 
+Size:             267748 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75b3896000-7f75b3897000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75b3897000-7f75c5746000 ---p 00000000 00:00 0 
+Size:             293564 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c5746000-7f75c5747000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c5747000-7f75c7b1c000 ---p 00000000 00:00 0 
+Size:              36692 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c7b1c000-7f75c7b1d000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c7b1d000-7f75c7f16000 ---p 00000000 00:00 0 
+Size:               4068 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c7f16000-7f75c7f1a000 r-xp 00000000 fd:00 132041                     /lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                   1 kB
+Shared_Clean:         16 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           16 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me sd 
+7f75c7f1a000-7f75c8119000 ---p 00004000 fd:00 132041                     /lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:               2044 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c8119000-7f75c811a000 r--p 00003000 fd:00 132041                     /lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me ac sd 
+7f75c811a000-7f75c811b000 rw-p 00004000 fd:00 132041                     /lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c811b000-7f75c811e000 r-xp 00000000 fd:00 136252                     /lib/x86_64-linux-gnu/libdl-2.27.so
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me sd 
+7f75c811e000-7f75c831d000 ---p 00003000 fd:00 136252                     /lib/x86_64-linux-gnu/libdl-2.27.so
+Size:               2044 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c831d000-7f75c831e000 r--p 00002000 fd:00 136252                     /lib/x86_64-linux-gnu/libdl-2.27.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me ac sd 
+7f75c831e000-7f75c831f000 rw-p 00003000 fd:00 136252                     /lib/x86_64-linux-gnu/libdl-2.27.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c831f000-7f75c833c000 r-xp 00000000 fd:00 132036                     /lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                116 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  60 kB
+Pss:                   6 kB
+Shared_Clean:         60 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           60 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me sd 
+7f75c833c000-7f75c853c000 ---p 0001d000 fd:00 132036                     /lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:               2048 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c853c000-7f75c853d000 r--p 0001d000 fd:00 132036                     /lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me ac sd 
+7f75c853d000-7f75c853e000 rw-p 0001e000 fd:00 132036                     /lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c853e000-7f75c8548000 rw-p 00000000 00:00 0 
+Size:                 40 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c8548000-7f75c872f000 r-xp 00000000 fd:00 136249                     /lib/x86_64-linux-gnu/libc-2.27.so
+Size:               1948 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                1040 kB
+Pss:                  41 kB
+Shared_Clean:       1040 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:         1040 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me sd 
+7f75c872f000-7f75c892f000 ---p 001e7000 fd:00 136249                     /lib/x86_64-linux-gnu/libc-2.27.so
+Size:               2048 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c892f000-7f75c8933000 r--p 001e7000 fd:00 136249                     /lib/x86_64-linux-gnu/libc-2.27.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me ac sd 
+7f75c8933000-7f75c8935000 rw-p 001eb000 fd:00 136249                     /lib/x86_64-linux-gnu/libc-2.27.so
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c8935000-7f75c8939000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        12 kB
+Referenced:           12 kB
+Anonymous:            12 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c8939000-7f75c8946000 r-xp 00000000 fd:00 131482                     /lib/x86_64-linux-gnu/libpam.so.0.83.1
+Size:                 52 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  52 kB
+Pss:                   6 kB
+Shared_Clean:         52 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           52 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me sd 
+7f75c8946000-7f75c8b45000 ---p 0000d000 fd:00 131482                     /lib/x86_64-linux-gnu/libpam.so.0.83.1
+Size:               2044 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c8b45000-7f75c8b46000 r--p 0000c000 fd:00 131482                     /lib/x86_64-linux-gnu/libpam.so.0.83.1
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me ac sd 
+7f75c8b46000-7f75c8b47000 rw-p 0000d000 fd:00 131482                     /lib/x86_64-linux-gnu/libpam.so.0.83.1
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c8b47000-7f75c8b61000 r-xp 00000000 fd:00 136264                     /lib/x86_64-linux-gnu/libpthread-2.27.so
+Size:                104 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 100 kB
+Pss:                   4 kB
+Shared_Clean:        100 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          100 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me sd 
+7f75c8b61000-7f75c8d60000 ---p 0001a000 fd:00 136264                     /lib/x86_64-linux-gnu/libpthread-2.27.so
+Size:               2044 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c8d60000-7f75c8d61000 r--p 00019000 fd:00 136264                     /lib/x86_64-linux-gnu/libpthread-2.27.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me ac sd 
+7f75c8d61000-7f75c8d62000 rw-p 0001a000 fd:00 136264                     /lib/x86_64-linux-gnu/libpthread-2.27.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c8d62000-7f75c8d66000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c8d66000-7f75c8d7d000 r-xp 00000000 fd:00 136265                     /lib/x86_64-linux-gnu/libresolv-2.27.so
+Size:                 92 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  60 kB
+Pss:                   8 kB
+Shared_Clean:         60 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           60 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me sd 
+7f75c8d7d000-7f75c8f7c000 ---p 00017000 fd:00 136265                     /lib/x86_64-linux-gnu/libresolv-2.27.so
+Size:               2044 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c8f7c000-7f75c8f7d000 r--p 00016000 fd:00 136265                     /lib/x86_64-linux-gnu/libresolv-2.27.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me ac sd 
+7f75c8f7d000-7f75c8f7e000 rw-p 00017000 fd:00 136265                     /lib/x86_64-linux-gnu/libresolv-2.27.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c8f7e000-7f75c8f80000 rw-p 00000000 00:00 0 
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c8f80000-7f75c8fa9000 r-xp 00000000 fd:00 135731                     /lib/x86_64-linux-gnu/ld-2.27.so
+Size:                164 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 164 kB
+Pss:                   6 kB
+Shared_Clean:        164 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          164 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me dw sd 
+7f75c8fbc000-7f75c903c000 rw-p 00000000 00:00 0 
+Size:                512 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 272 kB
+Pss:                 272 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       272 kB
+Referenced:          272 kB
+Anonymous:           272 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c903c000-7f75c90bc000 ---p 00000000 00:00 0 
+Size:                512 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c90bc000-7f75c90bd000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c90bd000-7f75c913c000 ---p 00000000 00:00 0 
+Size:                508 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: mr mw me sd 
+7f75c913c000-7f75c91a3000 rw-p 00000000 00:00 0 
+Size:                412 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  68 kB
+Pss:                  68 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        68 kB
+Referenced:           68 kB
+Anonymous:            68 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7f75c91a9000-7f75c91aa000 r--p 00029000 fd:00 135731                     /lib/x86_64-linux-gnu/ld-2.27.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr mw me dw ac sd 
+7f75c91aa000-7f75c91ab000 rw-p 0002a000 fd:00 135731                     /lib/x86_64-linux-gnu/ld-2.27.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me dw ac sd 
+7f75c91ab000-7f75c91ac000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me ac sd 
+7ffdc0ff9000-7ffdc101a000 rw-p 00000000 00:00 0                          [stack]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd wr mr mw me gd ac 
+7ffdc1151000-7ffdc1154000 r--p 00000000 00:00 0                          [vvar]
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd mr pf io de dd sd 
+7ffdc1154000-7ffdc1156000 r-xp 00000000 00:00 0                          [vdso]
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex mr mw me de sd 
+ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+VmFlags: rd ex 
diff --git a/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/blkio/user.slice/blkio.throttle.io_service_bytes b/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/blkio/user.slice/blkio.throttle.io_service_bytes
new file mode 100755 (executable)
index 0000000..77ad60d
--- /dev/null
@@ -0,0 +1,11 @@
+252:0 Read 6119424
+252:0 Write 0
+252:0 Sync 6119424
+252:0 Async 0
+252:0 Total 6119424
+253:0 Read 6119424
+253:0 Write 0
+253:0 Sync 6119424
+253:0 Async 0
+253:0 Total 6119424
+Total 12238848
diff --git a/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/cpu,cpuacct/user.slice/cpuacct.stat b/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/cpu,cpuacct/user.slice/cpuacct.stat
new file mode 100755 (executable)
index 0000000..5bb0142
--- /dev/null
@@ -0,0 +1,2 @@
+user 243
+system 255
diff --git a/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/cpuset/cpuset.cpus b/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/cpuset/cpuset.cpus
new file mode 100755 (executable)
index 0000000..8b0fab8
--- /dev/null
@@ -0,0 +1 @@
+0-1
diff --git a/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/memory/user.slice/memory.stat b/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/memory/user.slice/memory.stat
new file mode 100755 (executable)
index 0000000..dc60bbb
--- /dev/null
@@ -0,0 +1,33 @@
+cache 55107584
+rss 14348288
+rss_huge 0
+shmem 0
+mapped_file 25276416
+dirty 45821952
+writeback 0
+pgpgin 61677
+pgpgout 44641
+pgfault 85734
+pgmajfault 66
+inactive_anon 0
+active_anon 14536704
+inactive_file 25812992
+active_file 29433856
+unevictable 0
+hierarchical_memory_limit 9223372036854771712
+total_cache 55107584
+total_rss 14348288
+total_rss_huge 0
+total_shmem 0
+total_mapped_file 25276416
+total_dirty 45821952
+total_writeback 0
+total_pgpgin 61677
+total_pgpgout 44641
+total_pgfault 85734
+total_pgmajfault 66
+total_inactive_anon 0
+total_active_anon 14536704
+total_inactive_file 25812992
+total_active_file 29433856
+total_unevictable 0
diff --git a/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/unified/user.slice/user-1000.slice/session-1.scope/cpu.stat b/lib/crunchstat/testdata/ubuntu1804/sys/fs/cgroup/unified/user.slice/user-1000.slice/session-1.scope/cpu.stat
new file mode 100755 (executable)
index 0000000..6d71376
--- /dev/null
@@ -0,0 +1,3 @@
+usage_usec 4947324
+user_usec 2409841
+system_usec 2537483
diff --git a/lib/crunchstat/testdata/ubuntu2004/proc/1360/cgroup b/lib/crunchstat/testdata/ubuntu2004/proc/1360/cgroup
new file mode 100755 (executable)
index 0000000..a4a34a4
--- /dev/null
@@ -0,0 +1,13 @@
+12:net_cls,net_prio:/
+11:pids:/user.slice/user-1000.slice/session-2.scope
+10:hugetlb:/
+9:cpuset:/
+8:perf_event:/
+7:cpu,cpuacct:/user.slice
+6:devices:/user.slice
+5:rdma:/
+4:blkio:/user.slice
+3:memory:/user.slice/user-1000.slice/session-2.scope
+2:freezer:/
+1:name=systemd:/user.slice/user-1000.slice/session-2.scope
+0::/user.slice/user-1000.slice/session-2.scope
diff --git a/lib/crunchstat/testdata/ubuntu2004/proc/1360/cpuset b/lib/crunchstat/testdata/ubuntu2004/proc/1360/cpuset
new file mode 100755 (executable)
index 0000000..b498fd4
--- /dev/null
@@ -0,0 +1 @@
+/
diff --git a/lib/crunchstat/testdata/ubuntu2004/proc/1360/net/dev b/lib/crunchstat/testdata/ubuntu2004/proc/1360/net/dev
new file mode 100755 (executable)
index 0000000..320a0e8
--- /dev/null
@@ -0,0 +1,4 @@
+Inter-|   Receive                                                |  Transmit
+ face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
+    lo:    7232      92    0    0    0     0          0         0     7232      92    0    0    0     0       0          0
+enp1s0: 48329280   34878    0 1282    0     0          0         0   257876    3434    0    0    0     0       0          0
diff --git a/lib/crunchstat/testdata/ubuntu2004/proc/cpuinfo b/lib/crunchstat/testdata/ubuntu2004/proc/cpuinfo
new file mode 100755 (executable)
index 0000000..f212206
--- /dev/null
@@ -0,0 +1,54 @@
+processor      : 0
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3292.388
+cache size     : 16384 KB
+physical id    : 0
+siblings       : 1
+core id                : 0
+cpu cores      : 1
+apicid         : 0
+initial apicid : 0
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.77
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 1
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3292.388
+cache size     : 16384 KB
+physical id    : 1
+siblings       : 1
+core id                : 0
+cpu cores      : 1
+apicid         : 1
+initial apicid : 1
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.77
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
diff --git a/lib/crunchstat/testdata/ubuntu2004/proc/mounts b/lib/crunchstat/testdata/ubuntu2004/proc/mounts
new file mode 100755 (executable)
index 0000000..6e4a3f2
--- /dev/null
@@ -0,0 +1,42 @@
+sysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0
+proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
+udev /dev devtmpfs rw,nosuid,noexec,relatime,size=1960772k,nr_inodes=490193,mode=755 0 0
+devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000 0 0
+tmpfs /run tmpfs rw,nosuid,nodev,noexec,relatime,size=401380k,mode=755 0 0
+/dev/mapper/ubuntu--vg-ubuntu--lv / ext4 rw,relatime 0 0
+securityfs /sys/kernel/security securityfs rw,nosuid,nodev,noexec,relatime 0 0
+tmpfs /dev/shm tmpfs rw,nosuid,nodev 0 0
+tmpfs /run/lock tmpfs rw,nosuid,nodev,noexec,relatime,size=5120k 0 0
+tmpfs /sys/fs/cgroup tmpfs ro,nosuid,nodev,noexec,mode=755 0 0
+cgroup2 /sys/fs/cgroup/unified cgroup2 rw,nosuid,nodev,noexec,relatime,nsdelegate 0 0
+cgroup /sys/fs/cgroup/systemd cgroup rw,nosuid,nodev,noexec,relatime,xattr,name=systemd 0 0
+pstore /sys/fs/pstore pstore rw,nosuid,nodev,noexec,relatime 0 0
+none /sys/fs/bpf bpf rw,nosuid,nodev,noexec,relatime,mode=700 0 0
+cgroup /sys/fs/cgroup/freezer cgroup rw,nosuid,nodev,noexec,relatime,freezer 0 0
+cgroup /sys/fs/cgroup/memory cgroup rw,nosuid,nodev,noexec,relatime,memory 0 0
+cgroup /sys/fs/cgroup/blkio cgroup rw,nosuid,nodev,noexec,relatime,blkio 0 0
+cgroup /sys/fs/cgroup/rdma cgroup rw,nosuid,nodev,noexec,relatime,rdma 0 0
+cgroup /sys/fs/cgroup/devices cgroup rw,nosuid,nodev,noexec,relatime,devices 0 0
+cgroup /sys/fs/cgroup/cpu,cpuacct cgroup rw,nosuid,nodev,noexec,relatime,cpu,cpuacct 0 0
+cgroup /sys/fs/cgroup/perf_event cgroup rw,nosuid,nodev,noexec,relatime,perf_event 0 0
+cgroup /sys/fs/cgroup/cpuset cgroup rw,nosuid,nodev,noexec,relatime,cpuset 0 0
+cgroup /sys/fs/cgroup/hugetlb cgroup rw,nosuid,nodev,noexec,relatime,hugetlb 0 0
+cgroup /sys/fs/cgroup/pids cgroup rw,nosuid,nodev,noexec,relatime,pids 0 0
+cgroup /sys/fs/cgroup/net_cls,net_prio cgroup rw,nosuid,nodev,noexec,relatime,net_cls,net_prio 0 0
+systemd-1 /proc/sys/fs/binfmt_misc autofs rw,relatime,fd=28,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=16350 0 0
+hugetlbfs /dev/hugepages hugetlbfs rw,relatime,pagesize=2M 0 0
+mqueue /dev/mqueue mqueue rw,nosuid,nodev,noexec,relatime 0 0
+debugfs /sys/kernel/debug debugfs rw,nosuid,nodev,noexec,relatime 0 0
+tracefs /sys/kernel/tracing tracefs rw,nosuid,nodev,noexec,relatime 0 0
+fusectl /sys/fs/fuse/connections fusectl rw,nosuid,nodev,noexec,relatime 0 0
+configfs /sys/kernel/config configfs rw,nosuid,nodev,noexec,relatime 0 0
+binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc rw,nosuid,nodev,noexec,relatime 0 0
+/dev/loop1 /snap/core20/1974 squashfs ro,nodev,relatime 0 0
+/dev/loop0 /snap/lxd/24061 squashfs ro,nodev,relatime 0 0
+/dev/loop3 /snap/core20/1828 squashfs ro,nodev,relatime 0 0
+/dev/loop2 /snap/snapd/19457 squashfs ro,nodev,relatime 0 0
+/dev/loop4 /snap/snapd/18357 squashfs ro,nodev,relatime 0 0
+/dev/vda2 /boot ext4 rw,relatime 0 0
+tmpfs /run/snapd/ns tmpfs rw,nosuid,nodev,noexec,relatime,size=401380k,mode=755 0 0
+nsfs /run/snapd/ns/lxd.mnt nsfs rw 0 0
+tmpfs /run/user/1000 tmpfs rw,nosuid,nodev,relatime,size=401376k,mode=700,uid=1000,gid=1000 0 0
diff --git a/lib/crunchstat/testdata/ubuntu2004/proc/self/smaps b/lib/crunchstat/testdata/ubuntu2004/proc/self/smaps
new file mode 100755 (executable)
index 0000000..3ce8dba
--- /dev/null
@@ -0,0 +1,2231 @@
+00400000-00403000 r--p 00000000 fd:00 11041                              /tmp/arvados-server
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        12 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me dw sd 
+00403000-01776000 r-xp 00003000 fd:00 11041                              /tmp/arvados-server
+Size:              19916 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               12492 kB
+Pss:               12492 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:     12492 kB
+Referenced:        12492 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me dw sd 
+01776000-02f28000 r--p 01376000 fd:00 11041                              /tmp/arvados-server
+Size:              24264 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               10856 kB
+Pss:               10856 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:     10856 kB
+Referenced:        10856 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me dw sd 
+02f28000-02f29000 r--p 02b27000 fd:00 11041                              /tmp/arvados-server
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me dw ac sd 
+02f29000-02fc0000 rw-p 02b28000 fd:00 11041                              /tmp/arvados-server
+Size:                604 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 480 kB
+Pss:                 480 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       480 kB
+Referenced:          480 kB
+Anonymous:           176 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me dw ac sd 
+02fc0000-03007000 rw-p 00000000 00:00 0 
+Size:                284 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 104 kB
+Pss:                 104 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       104 kB
+Referenced:          104 kB
+Anonymous:           104 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+04a85000-04aa6000 rw-p 00000000 00:00 0                                  [heap]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+c000000000-c000800000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                5756 kB
+Pss:                5756 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      5756 kB
+Referenced:         5756 kB
+Anonymous:          5756 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+c000800000-c004000000 ---p 00000000 00:00 0 
+Size:              57344 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8ce8000000-7f8ce8021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me nr sd 
+7f8ce8021000-7f8cec000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me nr sd 
+7f8cf0000000-7f8cf0021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me nr sd 
+7f8cf0021000-7f8cf4000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me nr sd 
+7f8cf4000000-7f8cf4021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me nr sd 
+7f8cf4021000-7f8cf8000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me nr sd 
+7f8cf8000000-7f8cf8021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me nr sd 
+7f8cf8021000-7f8cfc000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me nr sd 
+7f8cfc000000-7f8cfc021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me nr sd 
+7f8cfc021000-7f8d00000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me nr sd 
+7f8d00000000-7f8d00021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me nr sd 
+7f8d00021000-7f8d04000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me nr sd 
+7f8d05302000-7f8d05452000 rw-p 00000000 00:00 0 
+Size:               1344 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                1004 kB
+Pss:                1004 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      1004 kB
+Referenced:         1004 kB
+Anonymous:          1004 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d05452000-7f8d05453000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d05453000-7f8d05c63000 rw-p 00000000 00:00 0 
+Size:               8256 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  20 kB
+Pss:                  20 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        20 kB
+Referenced:           20 kB
+Anonymous:            20 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d05c63000-7f8d05c64000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d05c64000-7f8d06604000 rw-p 00000000 00:00 0 
+Size:               9856 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 304 kB
+Pss:                 304 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       304 kB
+Referenced:          304 kB
+Anonymous:           304 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d06604000-7f8d06605000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d06605000-7f8d06e45000 rw-p 00000000 00:00 0 
+Size:               8448 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 240 kB
+Pss:                 240 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       240 kB
+Referenced:          240 kB
+Anonymous:           240 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d06e45000-7f8d06e46000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d06e46000-7f8d07646000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d07646000-7f8d07647000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d07647000-7f8d07e47000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d07e47000-7f8d07e48000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d07e48000-7f8d0aa00000 rw-p 00000000 00:00 0 
+Size:              44768 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 152 kB
+Pss:                 152 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       152 kB
+Referenced:          152 kB
+Anonymous:           152 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d0aa00000-7f8d0ac00000 rw-p 00000000 00:00 0 
+Size:               2048 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           1
+VmFlags: rd wr mr mw me ac sd hg 
+7f8d0ac00000-7f8d0ac84000 rw-p 00000000 00:00 0 
+Size:                528 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d0ac84000-7f8d1b1fd000 ---p 00000000 00:00 0 
+Size:             267748 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d1b1fd000-7f8d1b1fe000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d1b1fe000-7f8d2d0ad000 ---p 00000000 00:00 0 
+Size:             293564 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d2d0ad000-7f8d2d0ae000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2d0ae000-7f8d2f483000 ---p 00000000 00:00 0 
+Size:              36692 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d2f483000-7f8d2f484000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2f484000-7f8d2f8fd000 ---p 00000000 00:00 0 
+Size:               4580 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d2f8fd000-7f8d2f8fe000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2f8fe000-7f8d2f97d000 ---p 00000000 00:00 0 
+Size:                508 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d2f97d000-7f8d2f9e0000 rw-p 00000000 00:00 0 
+Size:                396 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  56 kB
+Pss:                  56 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        56 kB
+Referenced:           56 kB
+Anonymous:            56 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2f9e0000-7f8d2f9e2000 r--p 00000000 fd:00 12252                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   0 kB
+Shared_Clean:          8 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            8 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2f9e2000-7f8d2f9e5000 r-xp 00002000 fd:00 12252                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me sd 
+7f8d2f9e5000-7f8d2f9e6000 r--p 00005000 fd:00 12252                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2f9e6000-7f8d2f9e7000 r--p 00005000 fd:00 12252                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me ac sd 
+7f8d2f9e7000-7f8d2f9e8000 rw-p 00006000 fd:00 12252                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2f9e8000-7f8d2f9ea000 rw-p 00000000 00:00 0 
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2f9ea000-7f8d2f9eb000 r--p 00000000 fd:00 12268                      /usr/lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2f9eb000-7f8d2f9ed000 r-xp 00001000 fd:00 12268                      /usr/lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   0 kB
+Shared_Clean:          8 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            8 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me sd 
+7f8d2f9ed000-7f8d2f9ee000 r--p 00003000 fd:00 12268                      /usr/lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2f9ee000-7f8d2f9ef000 r--p 00003000 fd:00 12268                      /usr/lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me ac sd 
+7f8d2f9ef000-7f8d2f9f0000 rw-p 00004000 fd:00 12268                      /usr/lib/x86_64-linux-gnu/libdl-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2f9f0000-7f8d2f9f3000 r--p 00000000 fd:00 12234                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2f9f3000-7f8d2f9fb000 r-xp 00003000 fd:00 12234                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 32 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  28 kB
+Pss:                   1 kB
+Shared_Clean:         28 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           28 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me sd 
+7f8d2f9fb000-7f8d2fa0f000 r--p 0000b000 fd:00 12234                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 80 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2fa0f000-7f8d2fa10000 ---p 0001f000 fd:00 12234                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: mr mw me sd 
+7f8d2fa10000-7f8d2fa11000 r--p 0001f000 fd:00 12234                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me ac sd 
+7f8d2fa11000-7f8d2fa12000 rw-p 00020000 fd:00 12234                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2fa12000-7f8d2fa1c000 rw-p 00000000 00:00 0 
+Size:                 40 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2fa1c000-7f8d2fa3e000 r--p 00000000 fd:00 12250                      /usr/lib/x86_64-linux-gnu/libc-2.31.so
+Size:                136 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 136 kB
+Pss:                   4 kB
+Shared_Clean:        136 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          136 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2fa3e000-7f8d2fbb6000 r-xp 00022000 fd:00 12250                      /usr/lib/x86_64-linux-gnu/libc-2.31.so
+Size:               1504 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 768 kB
+Pss:                  28 kB
+Shared_Clean:        768 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          768 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me sd 
+7f8d2fbb6000-7f8d2fc04000 r--p 0019a000 fd:00 12250                      /usr/lib/x86_64-linux-gnu/libc-2.31.so
+Size:                312 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 128 kB
+Pss:                   4 kB
+Shared_Clean:        128 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          128 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2fc04000-7f8d2fc08000 r--p 001e7000 fd:00 12250                      /usr/lib/x86_64-linux-gnu/libc-2.31.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me ac sd 
+7f8d2fc08000-7f8d2fc0a000 rw-p 001eb000 fd:00 12250                      /usr/lib/x86_64-linux-gnu/libc-2.31.so
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2fc0a000-7f8d2fc0e000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        12 kB
+Referenced:           12 kB
+Anonymous:            12 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2fc0e000-7f8d2fc11000 r--p 00000000 fd:00 12406                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2fc11000-7f8d2fc1a000 r-xp 00003000 fd:00 12406                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                 36 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  36 kB
+Pss:                   2 kB
+Shared_Clean:         36 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           36 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me sd 
+7f8d2fc1a000-7f8d2fc1e000 r--p 0000c000 fd:00 12406                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2fc1e000-7f8d2fc1f000 r--p 0000f000 fd:00 12406                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me ac sd 
+7f8d2fc1f000-7f8d2fc20000 rw-p 00010000 fd:00 12406                      /usr/lib/x86_64-linux-gnu/libpam.so.0.84.2
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2fc20000-7f8d2fc26000 r--p 00000000 fd:00 12434                      /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                 24 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  24 kB
+Pss:                   0 kB
+Shared_Clean:         24 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           24 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2fc26000-7f8d2fc37000 r-xp 00006000 fd:00 12434                      /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                 68 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  68 kB
+Pss:                   2 kB
+Shared_Clean:         68 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           68 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me sd 
+7f8d2fc37000-7f8d2fc3d000 r--p 00017000 fd:00 12434                      /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                 24 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2fc3d000-7f8d2fc3e000 r--p 0001c000 fd:00 12434                      /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me ac sd 
+7f8d2fc3e000-7f8d2fc3f000 rw-p 0001d000 fd:00 12434                      /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2fc3f000-7f8d2fc43000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2fc43000-7f8d2fc47000 r--p 00000000 fd:00 12439                      /usr/lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                   1 kB
+Shared_Clean:         16 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           16 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2fc47000-7f8d2fc57000 r-xp 00004000 fd:00 12439                      /usr/lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                 64 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  64 kB
+Pss:                   6 kB
+Shared_Clean:         64 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           64 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me sd 
+7f8d2fc57000-7f8d2fc5b000 r--p 00014000 fd:00 12439                      /usr/lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me sd 
+7f8d2fc5b000-7f8d2fc5c000 r--p 00017000 fd:00 12439                      /usr/lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me ac sd 
+7f8d2fc5c000-7f8d2fc5d000 rw-p 00018000 fd:00 12439                      /usr/lib/x86_64-linux-gnu/libresolv-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2fc5d000-7f8d2fc61000 rw-p 00000000 00:00 0 
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7f8d2fc68000-7f8d2fc69000 r--p 00000000 fd:00 12105                      /usr/lib/x86_64-linux-gnu/ld-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me dw sd 
+7f8d2fc69000-7f8d2fc8c000 r-xp 00001000 fd:00 12105                      /usr/lib/x86_64-linux-gnu/ld-2.31.so
+Size:                140 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 140 kB
+Pss:                   4 kB
+Shared_Clean:        140 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          140 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me dw sd 
+7f8d2fc8c000-7f8d2fc94000 r--p 00024000 fd:00 12105                      /usr/lib/x86_64-linux-gnu/ld-2.31.so
+Size:                 32 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  32 kB
+Pss:                   1 kB
+Shared_Clean:         32 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           32 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me dw sd 
+7f8d2fc95000-7f8d2fc96000 r--p 0002c000 fd:00 12105                      /usr/lib/x86_64-linux-gnu/ld-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr mw me dw ac sd 
+7f8d2fc96000-7f8d2fc97000 rw-p 0002d000 fd:00 12105                      /usr/lib/x86_64-linux-gnu/ld-2.31.so
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me dw ac sd 
+7f8d2fc97000-7f8d2fc98000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me ac sd 
+7ffe58cef000-7ffe58d10000 rw-p 00000000 00:00 0                          [stack]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd wr mr mw me gd ac 
+7ffe58d36000-7ffe58d39000 r--p 00000000 00:00 0                          [vvar]
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd mr pf io de dd sd 
+7ffe58d39000-7ffe58d3a000 r-xp 00000000 00:00 0                          [vdso]
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: rd ex mr mw me de sd 
+ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:        0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:           0
+VmFlags: ex 
diff --git a/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/blkio/user.slice/blkio.throttle.io_service_bytes b/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/blkio/user.slice/blkio.throttle.io_service_bytes
new file mode 100755 (executable)
index 0000000..6aa2cc4
--- /dev/null
@@ -0,0 +1,13 @@
+252:0 Read 2322432
+252:0 Write 0
+252:0 Sync 2322432
+252:0 Async 0
+252:0 Discard 0
+252:0 Total 2322432
+253:0 Read 2322432
+253:0 Write 0
+253:0 Sync 2322432
+253:0 Async 0
+253:0 Discard 0
+253:0 Total 2322432
+Total 4644864
diff --git a/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/cpu,cpuacct/user.slice/cpuacct.stat b/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/cpu,cpuacct/user.slice/cpuacct.stat
new file mode 100755 (executable)
index 0000000..3aac4cc
--- /dev/null
@@ -0,0 +1,2 @@
+user 31
+system 40
diff --git a/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/cpuset/cpuset.cpus b/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/cpuset/cpuset.cpus
new file mode 100755 (executable)
index 0000000..8b0fab8
--- /dev/null
@@ -0,0 +1 @@
+0-1
diff --git a/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/memory/user.slice/user-1000.slice/session-2.scope/memory.stat b/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/memory/user.slice/user-1000.slice/session-2.scope/memory.stat
new file mode 100755 (executable)
index 0000000..d31dffc
--- /dev/null
@@ -0,0 +1,33 @@
+cache 47984640
+rss 12845056
+rss_huge 0
+shmem 0
+mapped_file 24870912
+dirty 45821952
+writeback 0
+pgpgin 25839
+pgpgout 10933
+pgfault 18513
+pgmajfault 0
+inactive_anon 0
+active_anon 12840960
+inactive_file 47579136
+active_file 270336
+unevictable 0
+hierarchical_memory_limit 9223372036854771712
+total_cache 47984640
+total_rss 12845056
+total_rss_huge 0
+total_shmem 0
+total_mapped_file 24870912
+total_dirty 45821952
+total_writeback 0
+total_pgpgin 25839
+total_pgpgout 10933
+total_pgfault 18513
+total_pgmajfault 0
+total_inactive_anon 0
+total_active_anon 12840960
+total_inactive_file 47579136
+total_active_file 270336
+total_unevictable 0
diff --git a/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/unified/user.slice/user-1000.slice/session-2.scope/cpu.stat b/lib/crunchstat/testdata/ubuntu2004/sys/fs/cgroup/unified/user.slice/user-1000.slice/session-2.scope/cpu.stat
new file mode 100755 (executable)
index 0000000..25fa4a7
--- /dev/null
@@ -0,0 +1,3 @@
+usage_usec 843527
+user_usec 355576
+system_usec 487951
diff --git a/lib/crunchstat/testdata/ubuntu2204/proc/1967/cgroup b/lib/crunchstat/testdata/ubuntu2204/proc/1967/cgroup
new file mode 100755 (executable)
index 0000000..24c88e8
--- /dev/null
@@ -0,0 +1 @@
+0::/user.slice/user-1000.slice/session-1.scope
diff --git a/lib/crunchstat/testdata/ubuntu2204/proc/1967/cpuset b/lib/crunchstat/testdata/ubuntu2204/proc/1967/cpuset
new file mode 100755 (executable)
index 0000000..fb6c61a
--- /dev/null
@@ -0,0 +1 @@
+/user.slice
diff --git a/lib/crunchstat/testdata/ubuntu2204/proc/1967/net/dev b/lib/crunchstat/testdata/ubuntu2204/proc/1967/net/dev
new file mode 100755 (executable)
index 0000000..405de33
--- /dev/null
@@ -0,0 +1,4 @@
+Inter-|   Receive                                                |  Transmit
+ face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
+    lo:   10505     124    0    0    0     0          0         0    10505     124    0    0    0     0       0          0
+enp1s0: 227109019  173999    0 30971    0     0          0         0  1938868   25576    0    0    0     0       0          0
diff --git a/lib/crunchstat/testdata/ubuntu2204/proc/cpuinfo b/lib/crunchstat/testdata/ubuntu2204/proc/cpuinfo
new file mode 100755 (executable)
index 0000000..c482b05
--- /dev/null
@@ -0,0 +1,56 @@
+processor      : 0
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3292.388
+cache size     : 16384 KB
+physical id    : 0
+siblings       : 1
+core id                : 0
+cpu cores      : 1
+apicid         : 0
+initial apicid : 0
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.77
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
+processor      : 1
+vendor_id      : GenuineIntel
+cpu family     : 6
+model          : 71
+model name     : Intel(R) Core(TM) i7-5775C CPU @ 3.30GHz
+stepping       : 1
+microcode      : 0x13
+cpu MHz                : 3292.388
+cache size     : 16384 KB
+physical id    : 1
+siblings       : 1
+core id                : 0
+cpu cores      : 1
+apicid         : 1
+initial apicid : 1
+fpu            : yes
+fpu_exception  : yes
+cpuid level    : 20
+wp             : yes
+flags          : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt arat umip arch_capabilities
+vmx flags      : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest shadow_vmcs pml
+bugs           : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa srbds mmio_unknown
+bogomips       : 6584.77
+clflush size   : 64
+cache_alignment        : 64
+address sizes  : 39 bits physical, 48 bits virtual
+power management:
+
diff --git a/lib/crunchstat/testdata/ubuntu2204/proc/mounts b/lib/crunchstat/testdata/ubuntu2204/proc/mounts
new file mode 100755 (executable)
index 0000000..f98f161
--- /dev/null
@@ -0,0 +1,30 @@
+sysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0
+proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
+udev /dev devtmpfs rw,nosuid,relatime,size=1944524k,nr_inodes=486131,mode=755,inode64 0 0
+devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000 0 0
+tmpfs /run tmpfs rw,nosuid,nodev,noexec,relatime,size=400584k,mode=755,inode64 0 0
+/dev/mapper/ubuntu--vg-ubuntu--lv / ext4 rw,relatime 0 0
+securityfs /sys/kernel/security securityfs rw,nosuid,nodev,noexec,relatime 0 0
+tmpfs /dev/shm tmpfs rw,nosuid,nodev,inode64 0 0
+tmpfs /run/lock tmpfs rw,nosuid,nodev,noexec,relatime,size=5120k,inode64 0 0
+cgroup2 /sys/fs/cgroup cgroup2 rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot 0 0
+pstore /sys/fs/pstore pstore rw,nosuid,nodev,noexec,relatime 0 0
+bpf /sys/fs/bpf bpf rw,nosuid,nodev,noexec,relatime,mode=700 0 0
+systemd-1 /proc/sys/fs/binfmt_misc autofs rw,relatime,fd=29,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=17759 0 0
+hugetlbfs /dev/hugepages hugetlbfs rw,relatime,pagesize=2M 0 0
+mqueue /dev/mqueue mqueue rw,nosuid,nodev,noexec,relatime 0 0
+debugfs /sys/kernel/debug debugfs rw,nosuid,nodev,noexec,relatime 0 0
+tracefs /sys/kernel/tracing tracefs rw,nosuid,nodev,noexec,relatime 0 0
+fusectl /sys/fs/fuse/connections fusectl rw,nosuid,nodev,noexec,relatime 0 0
+configfs /sys/kernel/config configfs rw,nosuid,nodev,noexec,relatime 0 0
+none /run/credentials/systemd-sysusers.service ramfs ro,nosuid,nodev,noexec,relatime,mode=700 0 0
+/dev/loop0 /snap/lxd/24322 squashfs ro,nodev,relatime,errors=continue 0 0
+/dev/loop1 /snap/snapd/18357 squashfs ro,nodev,relatime,errors=continue 0 0
+/dev/loop2 /snap/core20/1822 squashfs ro,nodev,relatime,errors=continue 0 0
+/dev/vda2 /boot ext4 rw,relatime 0 0
+binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc rw,nosuid,nodev,noexec,relatime 0 0
+tmpfs /run/snapd/ns tmpfs rw,nosuid,nodev,noexec,relatime,size=400584k,mode=755,inode64 0 0
+nsfs /run/snapd/ns/lxd.mnt nsfs rw 0 0
+tmpfs /run/user/1000 tmpfs rw,nosuid,nodev,relatime,size=400580k,nr_inodes=100145,mode=700,uid=1000,gid=1000,inode64 0 0
+/dev/loop3 /snap/snapd/19457 squashfs ro,nodev,relatime,errors=continue 0 0
+/dev/loop4 /snap/core20/1974 squashfs ro,nodev,relatime,errors=continue 0 0
diff --git a/lib/crunchstat/testdata/ubuntu2204/proc/self/smaps b/lib/crunchstat/testdata/ubuntu2204/proc/self/smaps
new file mode 100755 (executable)
index 0000000..104eef1
--- /dev/null
@@ -0,0 +1,1978 @@
+00400000-00403000 r--p 00000000 fd:00 393261                             /tmp/arvados-server
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                  12 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:        12 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+00403000-01776000 r-xp 00003000 fd:00 393261                             /tmp/arvados-server
+Size:              19916 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               12492 kB
+Pss:               12492 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:     12492 kB
+Private_Dirty:         0 kB
+Referenced:        12492 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+01776000-02f28000 r--p 01376000 fd:00 393261                             /tmp/arvados-server
+Size:              24264 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:               11048 kB
+Pss:               11048 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:     11048 kB
+Private_Dirty:         0 kB
+Referenced:        11048 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+02f28000-02f29000 r--p 02b27000 fd:00 393261                             /tmp/arvados-server
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+02f29000-02fc0000 rw-p 02b28000 fd:00 393261                             /tmp/arvados-server
+Size:                604 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 480 kB
+Pss:                 480 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:       304 kB
+Private_Dirty:       176 kB
+Referenced:          480 kB
+Anonymous:           176 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+02fc0000-03007000 rw-p 00000000 00:00 0 
+Size:                284 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 100 kB
+Pss:                 100 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       100 kB
+Referenced:          100 kB
+Anonymous:           100 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+03f05000-03f26000 rw-p 00000000 00:00 0                                  [heap]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+c000000000-c000800000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                5500 kB
+Pss:                5500 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:      5500 kB
+Referenced:         5500 kB
+Anonymous:          5500 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+c000800000-c004000000 ---p 00000000 00:00 0 
+Size:              57344 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f750c000000-7f750c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f750c021000-7f7510000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me nr sd 
+7f7510000000-7f7510021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7510021000-7f7514000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me nr sd 
+7f7514000000-7f7514021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7514021000-7f7518000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me nr sd 
+7f7518000000-7f7518021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f7518021000-7f751c000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me nr sd 
+7f751c000000-7f751c021000 rw-p 00000000 00:00 0 
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me nr sd 
+7f751c021000-7f7520000000 ---p 00000000 00:00 0 
+Size:              65404 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me nr sd 
+7f7520f2f000-7f752108f000 rw-p 00000000 00:00 0 
+Size:               1408 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 992 kB
+Pss:                 992 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       992 kB
+Referenced:          992 kB
+Anonymous:           992 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f752108f000-7f7521090000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f7521090000-7f7521a30000 rw-p 00000000 00:00 0 
+Size:               9856 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 332 kB
+Pss:                 332 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       332 kB
+Referenced:          332 kB
+Anonymous:           332 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f7521a30000-7f7521a31000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f7521a31000-7f7522271000 rw-p 00000000 00:00 0 
+Size:               8448 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 244 kB
+Pss:                 244 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       244 kB
+Referenced:          244 kB
+Anonymous:           244 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f7522271000-7f7522272000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f7522272000-7f7522a72000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f7522a72000-7f7522a73000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f7522a73000-7f7523273000 rw-p 00000000 00:00 0 
+Size:               8192 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f7523273000-7f7523274000 ---p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f7523274000-7f7525e00000 rw-p 00000000 00:00 0 
+Size:              44592 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 156 kB
+Pss:                 156 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:       156 kB
+Referenced:          156 kB
+Anonymous:           156 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f7525e00000-7f7526000000 rw-p 00000000 00:00 0 
+Size:               2048 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    1
+VmFlags: rd wr mr mw me ac sd hg 
+7f7526000000-7f75260b0000 rw-p 00000000 00:00 0 
+Size:                704 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f75260b0000-7f7536629000 ---p 00000000 00:00 0 
+Size:             267748 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f7536629000-7f753662a000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f753662a000-7f75484d9000 ---p 00000000 00:00 0 
+Size:             293564 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f75484d9000-7f75484da000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f75484da000-7f754a8af000 ---p 00000000 00:00 0 
+Size:              36692 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f754a8af000-7f754a8b0000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754a8b0000-7f754ad29000 ---p 00000000 00:00 0 
+Size:               4580 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f754ad29000-7f754ad2a000 rw-p 00000000 00:00 0 
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754ad2a000-7f754ada9000 ---p 00000000 00:00 0 
+Size:                508 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: mr mw me sd 
+7f754ada9000-7f754ae0c000 rw-p 00000000 00:00 0 
+Size:                396 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  56 kB
+Pss:                  56 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        56 kB
+Referenced:           56 kB
+Anonymous:            56 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754ae0c000-7f754ae0e000 r--p 00000000 fd:00 11091                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   0 kB
+Shared_Clean:          8 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            8 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754ae0e000-7f754ae11000 r-xp 00002000 fd:00 11091                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f754ae11000-7f754ae12000 r--p 00005000 fd:00 11091                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754ae12000-7f754ae13000 r--p 00005000 fd:00 11091                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f754ae13000-7f754ae14000 rw-p 00006000 fd:00 11091                      /usr/lib/x86_64-linux-gnu/libcap-ng.so.0.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754ae14000-7f754ae16000 rw-p 00000000 00:00 0 
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754ae16000-7f754ae19000 r--p 00000000 fd:00 11071                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754ae19000-7f754ae21000 r-xp 00003000 fd:00 11071                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 32 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  32 kB
+Pss:                   2 kB
+Shared_Clean:         32 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           32 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f754ae21000-7f754ae36000 r--p 0000b000 fd:00 11071                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                 84 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754ae36000-7f754ae37000 r--p 0001f000 fd:00 11071                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f754ae37000-7f754ae38000 rw-p 00020000 fd:00 11071                      /usr/lib/x86_64-linux-gnu/libaudit.so.1.0.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754ae38000-7f754ae44000 rw-p 00000000 00:00 0 
+Size:                 48 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754ae44000-7f754ae6c000 r--p 00000000 fd:00 11089                      /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:                160 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 160 kB
+Pss:                   6 kB
+Shared_Clean:        160 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          160 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754ae6c000-7f754b001000 r-xp 00028000 fd:00 11089                      /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:               1620 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 912 kB
+Pss:                  40 kB
+Shared_Clean:        912 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          912 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f754b001000-7f754b059000 r--p 001bd000 fd:00 11089                      /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:                352 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 128 kB
+Pss:                   5 kB
+Shared_Clean:        128 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          128 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754b059000-7f754b05d000 r--p 00214000 fd:00 11089                      /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f754b05d000-7f754b05f000 rw-p 00218000 fd:00 11089                      /usr/lib/x86_64-linux-gnu/libc.so.6
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754b05f000-7f754b06c000 rw-p 00000000 00:00 0 
+Size:                 52 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  20 kB
+Pss:                  20 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        20 kB
+Referenced:           20 kB
+Anonymous:            20 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754b06c000-7f754b06f000 r--p 00000000 fd:00 11245                      /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   0 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754b06f000-7f754b078000 r-xp 00003000 fd:00 11245                      /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                 36 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  36 kB
+Pss:                   3 kB
+Shared_Clean:         36 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           36 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f754b078000-7f754b07c000 r--p 0000c000 fd:00 11245                      /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754b07c000-7f754b07d000 r--p 0000f000 fd:00 11245                      /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f754b07d000-7f754b07e000 rw-p 00010000 fd:00 11245                      /usr/lib/x86_64-linux-gnu/libpam.so.0.85.1
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754b07e000-7f754b07f000 r--p 00000000 fd:00 11272                      /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   2 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754b07f000-7f754b080000 r-xp 00001000 fd:00 11272                      /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   2 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f754b080000-7f754b081000 r--p 00002000 fd:00 11272                      /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754b081000-7f754b082000 r--p 00002000 fd:00 11272                      /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f754b082000-7f754b083000 rw-p 00003000 fd:00 11272                      /usr/lib/x86_64-linux-gnu/libpthread.so.0
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754b083000-7f754b086000 r--p 00000000 fd:00 11276                      /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  12 kB
+Pss:                   3 kB
+Shared_Clean:         12 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           12 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754b086000-7f754b090000 r-xp 00003000 fd:00 11276                      /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                 40 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  36 kB
+Pss:                   9 kB
+Shared_Clean:         36 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           36 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f754b090000-7f754b093000 r--p 0000d000 fd:00 11276                      /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                 12 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754b093000-7f754b094000 r--p 0000f000 fd:00 11276                      /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f754b094000-7f754b095000 rw-p 00010000 fd:00 11276                      /usr/lib/x86_64-linux-gnu/libresolv.so.2
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   4 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         4 kB
+Referenced:            4 kB
+Anonymous:             4 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754b095000-7f754b097000 rw-p 00000000 00:00 0 
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754b09c000-7f754b09e000 rw-p 00000000 00:00 0 
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7f754b09e000-7f754b0a0000 r--p 00000000 fd:00 10938                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   0 kB
+Shared_Clean:          8 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            8 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754b0a0000-7f754b0ca000 r-xp 00002000 fd:00 10938                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                168 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                 168 kB
+Pss:                   6 kB
+Shared_Clean:        168 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:          168 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me sd 
+7f754b0ca000-7f754b0d5000 r--p 0002c000 fd:00 10938                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                 44 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  44 kB
+Pss:                   1 kB
+Shared_Clean:         44 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:           44 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me sd 
+7f754b0d6000-7f754b0d8000 r--p 00037000 fd:00 10938                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr mw me ac sd 
+7f754b0d8000-7f754b0da000 rw-p 00039000 fd:00 10938                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   8 kB
+Pss:                   8 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         8 kB
+Referenced:            8 kB
+Anonymous:             8 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me ac sd 
+7ffed2e14000-7ffed2e35000 rw-p 00000000 00:00 0                          [stack]
+Size:                132 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                  16 kB
+Pss:                  16 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:        16 kB
+Referenced:           16 kB
+Anonymous:            16 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd wr mr mw me gd ac 
+7ffed2fc4000-7ffed2fc8000 r--p 00000000 00:00 0                          [vvar]
+Size:                 16 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd mr pf io de dd sd 
+7ffed2fc8000-7ffed2fca000 r-xp 00000000 00:00 0                          [vdso]
+Size:                  8 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   4 kB
+Pss:                   0 kB
+Shared_Clean:          4 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            4 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: rd ex mr mw me de sd 
+ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]
+Size:                  4 kB
+KernelPageSize:        4 kB
+MMUPageSize:           4 kB
+Rss:                   0 kB
+Pss:                   0 kB
+Shared_Clean:          0 kB
+Shared_Dirty:          0 kB
+Private_Clean:         0 kB
+Private_Dirty:         0 kB
+Referenced:            0 kB
+Anonymous:             0 kB
+LazyFree:              0 kB
+AnonHugePages:         0 kB
+ShmemPmdMapped:        0 kB
+FilePmdMapped:         0 kB
+Shared_Hugetlb:        0 kB
+Private_Hugetlb:       0 kB
+Swap:                  0 kB
+SwapPss:               0 kB
+Locked:                0 kB
+THPeligible:    0
+VmFlags: ex 
diff --git a/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/cpu.max b/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/cpu.max
new file mode 100755 (executable)
index 0000000..1c1d3e7
--- /dev/null
@@ -0,0 +1 @@
+max 100000
diff --git a/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/cpuset.cpus.effective b/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/cpuset.cpus.effective
new file mode 100755 (executable)
index 0000000..8b0fab8
--- /dev/null
@@ -0,0 +1 @@
+0-1
diff --git a/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/io.stat b/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/io.stat
new file mode 100755 (executable)
index 0000000..97b7e1c
--- /dev/null
@@ -0,0 +1,2 @@
+252:0 rbytes=3551232 wbytes=147263488 rios=141 wios=208 dbytes=0 dios=0
+253:0 rbytes=3551232 wbytes=147263488 rios=141 wios=109 dbytes=0 dios=0
diff --git a/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/cpu.stat b/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/cpu.stat
new file mode 100755 (executable)
index 0000000..cf516a6
--- /dev/null
@@ -0,0 +1,3 @@
+usage_usec 1750563
+user_usec 703305
+system_usec 1047257
diff --git a/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/memory.current b/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/memory.current
new file mode 100755 (executable)
index 0000000..b779bcd
--- /dev/null
@@ -0,0 +1 @@
+68902912
diff --git a/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/memory.stat b/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/memory.stat
new file mode 100755 (executable)
index 0000000..fbf50f1
--- /dev/null
@@ -0,0 +1,40 @@
+anon 13606912
+file 52432896
+kernel_stack 180224
+pagetables 438272
+percpu 0
+sock 0
+shmem 4096
+file_mapped 25767936
+file_dirty 86016
+file_writeback 0
+swapcached 0
+anon_thp 0
+file_thp 0
+shmem_thp 0
+inactive_anon 13574144
+active_anon 20480
+inactive_file 26722304
+active_file 25669632
+unevictable 0
+slab_reclaimable 1646344
+slab_unreclaimable 328072
+slab 1974416
+workingset_refault_anon 0
+workingset_refault_file 0
+workingset_activate_anon 0
+workingset_activate_file 0
+workingset_restore_anon 0
+workingset_restore_file 0
+workingset_nodereclaim 0
+pgfault 33355
+pgmajfault 27
+pgrefill 0
+pgscan 0
+pgsteal 0
+pgactivate 6253
+pgdeactivate 0
+pglazyfree 0
+pglazyfreed 0
+thp_fault_alloc 0
+thp_collapse_alloc 0
diff --git a/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/memory.swap.current b/lib/crunchstat/testdata/ubuntu2204/sys/fs/cgroup/user.slice/user-1000.slice/session-1.scope/memory.swap.current
new file mode 100755 (executable)
index 0000000..573541a
--- /dev/null
@@ -0,0 +1 @@
+0
index ed963e1ef75b42439ed1e23fef7d11e9a62a695c..0fd3b3eca2ceee767c0a6550e15dc3b7a8c8dd81 100644 (file)
@@ -8,7 +8,9 @@ import (
        "archive/tar"
        "bytes"
        "context"
+       "crypto/sha256"
        _ "embed"
+       "encoding/json"
        "flag"
        "fmt"
        "io"
@@ -17,6 +19,8 @@ import (
        "net/http"
        "net/url"
        "os"
+       "os/exec"
+       "regexp"
        "strings"
        "time"
 
@@ -33,9 +37,10 @@ type Command struct{}
 func (Command) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
        var diag diagnoser
        f := flag.NewFlagSet(prog, flag.ContinueOnError)
-       f.StringVar(&diag.projectName, "project-name", "scratch area for diagnostics", "name of project to find/create in home project and use for temporary/test objects")
-       f.StringVar(&diag.logLevel, "log-level", "info", "logging level (debug, info, warning, error)")
-       f.StringVar(&diag.dockerImage, "docker-image", "", "image to use when running a test container (default: use embedded hello-world image)")
+       f.StringVar(&diag.projectName, "project-name", "scratch area for diagnostics", "`name` of project to find/create in home project and use for temporary/test objects")
+       f.StringVar(&diag.logLevel, "log-level", "info", "logging `level` (debug, info, warning, error)")
+       f.StringVar(&diag.dockerImage, "docker-image", "", "`image` (tag or portable data hash) to use when running a test container, or \"hello-world\" to use embedded hello-world image (default: build a custom image containing this executable, and run diagnostics inside the container too)")
+       f.StringVar(&diag.dockerImageFrom, "docker-image-from", "debian:stable-slim", "`base` image to use when building a custom image (see https://doc.arvados.org/main/admin/diagnostics.html#container-options)")
        f.BoolVar(&diag.checkInternal, "internal-client", false, "check that this host is considered an \"internal\" client")
        f.BoolVar(&diag.checkExternal, "external-client", false, "check that this host is considered an \"external\" client")
        f.BoolVar(&diag.verbose, "v", false, "verbose: include more information in report")
@@ -44,6 +49,8 @@ func (Command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
        if ok, code := cmd.ParseFlags(f, prog, args, "", stderr); !ok {
                return code
        }
+       diag.stdout = stdout
+       diag.stderr = stderr
        diag.logger = ctxlog.New(stdout, "text", diag.logLevel)
        diag.logger.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true, DisableLevelTruncation: true, PadLevelText: true})
        diag.runtests()
@@ -67,19 +74,20 @@ func (Command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
 var HelloWorldDockerImage []byte
 
 type diagnoser struct {
-       stdout        io.Writer
-       stderr        io.Writer
-       logLevel      string
-       priority      int
-       projectName   string
-       dockerImage   string
-       checkInternal bool
-       checkExternal bool
-       verbose       bool
-       timeout       time.Duration
-       logger        *logrus.Logger
-       errors        []string
-       done          map[int]bool
+       stdout          io.Writer
+       stderr          io.Writer
+       logLevel        string
+       priority        int
+       projectName     string
+       dockerImage     string
+       dockerImageFrom string
+       checkInternal   bool
+       checkExternal   bool
+       verbose         bool
+       timeout         time.Duration
+       logger          *logrus.Logger
+       errors          []string
+       done            map[int]bool
 }
 
 func (diag *diagnoser) debugf(f string, args ...interface{}) {
@@ -131,6 +139,8 @@ func (diag *diagnoser) dotest(id int, title string, fn func() error) {
 
 func (diag *diagnoser) runtests() {
        client := arvados.NewClientFromEnv()
+       // Disable auto-retry, use context instead
+       client.Timeout = 0
 
        if client.APIHost == "" || client.AuthToken == "" {
                diag.errorf("ARVADOS_API_HOST and ARVADOS_API_TOKEN environment variables are not set -- aborting without running any tests")
@@ -442,38 +452,100 @@ func (diag *diagnoser) runtests() {
                }()
        }
 
-       // Read hello-world.tar to find image ID, so we can upload it
-       // as "sha256:{...}.tar"
+       tempdir, err := ioutil.TempDir("", "arvados-diagnostics")
+       if err != nil {
+               diag.errorf("error creating temp dir: %s", err)
+               return
+       }
+       defer os.RemoveAll(tempdir)
+
        var imageSHA2 string
-       {
-               tr := tar.NewReader(bytes.NewReader(HelloWorldDockerImage))
-               for {
-                       hdr, err := tr.Next()
-                       if err == io.EOF {
-                               break
-                       }
+       var dockerImageData []byte
+       if diag.dockerImage != "" || diag.priority < 1 {
+               // We won't be using the self-built docker image, so
+               // don't build it.  But we will write the embedded
+               // "hello-world" image to our test collection to test
+               // upload/download, whether or not we're using it as a
+               // docker image.
+               dockerImageData = HelloWorldDockerImage
+
+               if diag.priority > 0 {
+                       imageSHA2, err = getSHA2FromImageData(dockerImageData)
                        if err != nil {
-                               diag.errorf("internal error/bug: cannot read embedded docker image tar file: %s", err)
+                               diag.errorf("internal error/bug: %s", err)
                                return
                        }
-                       if s := strings.TrimSuffix(hdr.Name, ".json"); len(s) == 64 && s != hdr.Name {
-                               imageSHA2 = s
-                       }
                }
-               if imageSHA2 == "" {
-                       diag.errorf("internal error/bug: cannot find {sha256}.json file in embedded docker image tar file")
+       } else if selfbin, err := os.Readlink("/proc/self/exe"); err != nil {
+               diag.errorf("readlink /proc/self/exe: %s", err)
+               return
+       } else if selfbindata, err := os.ReadFile(selfbin); err != nil {
+               diag.errorf("error reading %s: %s", selfbin, err)
+               return
+       } else {
+               selfbinSha := fmt.Sprintf("%x", sha256.Sum256(selfbindata))
+               tag := "arvados-client-diagnostics:" + selfbinSha[:9]
+               err := os.WriteFile(tempdir+"/arvados-client", selfbindata, 0777)
+               if err != nil {
+                       diag.errorf("error writing %s: %s", tempdir+"/arvados-client", err)
+                       return
+               }
+
+               dockerfile := "FROM " + diag.dockerImageFrom + "\n"
+               dockerfile += "RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends libfuse2 ca-certificates && apt-get clean\n"
+               dockerfile += "COPY /arvados-client /arvados-client\n"
+               cmd := exec.Command("docker", "build", "--tag", tag, "-f", "-", tempdir)
+               cmd.Stdin = strings.NewReader(dockerfile)
+               cmd.Stdout = diag.stderr
+               cmd.Stderr = diag.stderr
+               err = cmd.Run()
+               if err != nil {
+                       diag.errorf("error building docker image: %s", err)
+                       return
+               }
+               checkversion, err := exec.Command("docker", "run", tag, "/arvados-client", "version").CombinedOutput()
+               if err != nil {
+                       diag.errorf("docker image does not seem to work: %s", err)
+                       return
+               }
+               diag.infof("arvados-client version: %s", checkversion)
+
+               buf, err := exec.Command("docker", "inspect", "--format={{.Id}}", tag).Output()
+               if err != nil {
+                       diag.errorf("docker inspect --format={{.Id}} %s: %s", tag, err)
+                       return
+               }
+               imageSHA2 = min64HexDigits.FindString(string(buf))
+               if len(imageSHA2) != 64 {
+                       diag.errorf("docker inspect --format={{.Id}} output %q does not seem to contain sha256 digest", buf)
+                       return
+               }
+
+               buf, err = exec.Command("docker", "save", tag).Output()
+               if err != nil {
+                       diag.errorf("docker save %s: %s", tag, err)
                        return
                }
+               diag.infof("docker image size is %d", len(buf))
+               dockerImageData = buf
        }
+
        tarfilename := "sha256:" + imageSHA2 + ".tar"
 
        diag.dotest(100, "uploading file via webdav", func() error {
-               ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(diag.timeout))
+               timeout := diag.timeout
+               if len(dockerImageData) > 10<<20 && timeout < time.Minute {
+                       // Extend the normal http timeout if we're
+                       // uploading a substantial docker image.
+                       timeout = time.Minute
+               }
+               ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(timeout))
                defer cancel()
                if collection.UUID == "" {
                        return fmt.Errorf("skipping, no test collection")
                }
-               req, err := http.NewRequestWithContext(ctx, "PUT", cluster.Services.WebDAVDownload.ExternalURL.String()+"c="+collection.UUID+"/"+tarfilename, bytes.NewReader(HelloWorldDockerImage))
+               t0 := time.Now()
+               req, err := http.NewRequestWithContext(ctx, "PUT", cluster.Services.WebDAVDownload.ExternalURL.String()+"c="+collection.UUID+"/"+tarfilename, bytes.NewReader(dockerImageData))
                if err != nil {
                        return fmt.Errorf("BUG? http.NewRequest: %s", err)
                }
@@ -486,12 +558,12 @@ func (diag *diagnoser) runtests() {
                if resp.StatusCode != http.StatusCreated {
                        return fmt.Errorf("status %s", resp.Status)
                }
-               diag.debugf("ok, status %s", resp.Status)
+               diag.verbosef("upload ok, status %s, %f MB/s", resp.Status, float64(len(dockerImageData))/time.Since(t0).Seconds()/1000000)
                err = client.RequestAndDecodeContext(ctx, &collection, "GET", "arvados/v1/collections/"+collection.UUID, nil, nil)
                if err != nil {
                        return fmt.Errorf("get updated collection: %s", err)
                }
-               diag.debugf("ok, pdh %s", collection.PortableDataHash)
+               diag.verbosef("upload pdh %s", collection.PortableDataHash)
                return nil
        })
 
@@ -547,7 +619,7 @@ func (diag *diagnoser) runtests() {
                        if resp.StatusCode != trial.status {
                                return fmt.Errorf("unexpected response status: %s", resp.Status)
                        }
-                       if trial.status == http.StatusOK && !bytes.Equal(body, HelloWorldDockerImage) {
+                       if trial.status == http.StatusOK && !bytes.Equal(body, dockerImageData) {
                                excerpt := body
                                if len(excerpt) > 128 {
                                        excerpt = append([]byte(nil), body[:128]...)
@@ -576,35 +648,6 @@ func (diag *diagnoser) runtests() {
                return nil
        })
 
-       diag.dotest(140, "getting workbench1 webshell page", func() error {
-               ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(diag.timeout))
-               defer cancel()
-               if vm.UUID == "" {
-                       diag.warnf("skipping, no vm available")
-                       return nil
-               }
-               webshelltermurl := cluster.Services.Workbench1.ExternalURL.String() + "virtual_machines/" + vm.UUID + "/webshell/testusername"
-               diag.debugf("url %s", webshelltermurl)
-               req, err := http.NewRequestWithContext(ctx, "GET", webshelltermurl, nil)
-               if err != nil {
-                       return err
-               }
-               req.Header.Set("Authorization", "Bearer "+client.AuthToken)
-               resp, err := http.DefaultClient.Do(req)
-               if err != nil {
-                       return err
-               }
-               defer resp.Body.Close()
-               body, err := ioutil.ReadAll(resp.Body)
-               if err != nil {
-                       return fmt.Errorf("reading response: %s", err)
-               }
-               if resp.StatusCode != http.StatusOK {
-                       return fmt.Errorf("unexpected response status: %s %q", resp.Status, body)
-               }
-               return nil
-       })
-
        diag.dotest(150, "connecting to webshell service", func() error {
                ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(diag.timeout))
                defer cancel()
@@ -660,13 +703,26 @@ func (diag *diagnoser) runtests() {
                }
 
                timestamp := time.Now().Format(time.RFC3339)
-               ctrCommand := []string{"echo", timestamp}
-               if diag.dockerImage == "" {
+
+               var ctrCommand []string
+               switch diag.dockerImage {
+               case "":
+                       if collection.UUID == "" {
+                               return fmt.Errorf("skipping, no test collection to use as docker image")
+                       }
+                       diag.dockerImage = collection.PortableDataHash
+                       ctrCommand = []string{"/arvados-client", "diagnostics",
+                               "-priority=0", // don't run a container
+                               "-log-level=" + diag.logLevel,
+                               "-internal-client=true"}
+               case "hello-world":
                        if collection.UUID == "" {
                                return fmt.Errorf("skipping, no test collection to use as docker image")
                        }
                        diag.dockerImage = collection.PortableDataHash
                        ctrCommand = []string{"/hello"}
+               default:
+                       ctrCommand = []string{"echo", timestamp}
                }
 
                var cr arvados.ContainerRequest
@@ -690,15 +746,16 @@ func (diag *diagnoser) runtests() {
                                },
                        },
                        "runtime_constraints": arvados.RuntimeConstraints{
+                               API:          true,
                                VCPUs:        1,
-                               RAM:          1 << 26,
-                               KeepCacheRAM: 1 << 26,
+                               RAM:          128 << 20,
+                               KeepCacheRAM: 64 << 20,
                        },
                }})
                if err != nil {
                        return err
                }
-               diag.verbosef("container request uuid = %s", cr.UUID)
+               diag.infof("container request uuid = %s", cr.UUID)
                diag.verbosef("container uuid = %s", cr.ContainerUUID)
 
                timeout := 10 * time.Minute
@@ -752,3 +809,36 @@ func (diag *diagnoser) runtests() {
                return nil
        })
 }
+
+func getSHA2FromImageData(dockerImageData []byte) (string, error) {
+       tr := tar.NewReader(bytes.NewReader(dockerImageData))
+       for {
+               hdr, err := tr.Next()
+               if err == io.EOF {
+                       return "", fmt.Errorf("cannot find manifest.json in docker image tar file")
+               }
+               if err != nil {
+                       return "", fmt.Errorf("cannot read docker image tar file: %s", err)
+               }
+               if hdr.Name != "manifest.json" {
+                       continue
+               }
+               var manifest []struct {
+                       Config string
+               }
+               err = json.NewDecoder(tr).Decode(&manifest)
+               if err != nil {
+                       return "", fmt.Errorf("cannot read manifest.json from docker image tar file: %s", err)
+               }
+               if len(manifest) == 0 {
+                       return "", fmt.Errorf("manifest.json is empty")
+               }
+               s := min64HexDigits.FindString(manifest[0].Config)
+               if len(s) != 64 {
+                       return "", fmt.Errorf("found manifest.json but .[0].Config %q does not seem to contain sha256 digest", manifest[0].Config)
+               }
+               return s, nil
+       }
+}
+
+var min64HexDigits = regexp.MustCompile(`[0-9a-f]{64,}`)
diff --git a/lib/diagnostics/docker_image_test.go b/lib/diagnostics/docker_image_test.go
new file mode 100644 (file)
index 0000000..ace4a2c
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package diagnostics
+
+import (
+       "testing"
+
+       . "gopkg.in/check.v1"
+)
+
+func Test(t *testing.T) {
+       TestingT(t)
+}
+
+var _ = Suite(&suite{})
+
+type suite struct{}
+
+func (*suite) TestGetSHA2FromImageData(c *C) {
+       imageSHA2, err := getSHA2FromImageData(HelloWorldDockerImage)
+       c.Check(err, IsNil)
+       c.Check(imageSHA2, Matches, `[0-9a-f]{64}`)
+}
index 0254c6526c19103d336ea14fac445877c685376a..81982cdc1a7dad1a52c1b9bf05d34879ef7924ba 100644 (file)
@@ -21,6 +21,10 @@ func newHandler(ctx context.Context, cluster *arvados.Cluster, token string, reg
        if err != nil {
                return service.ErrorHandler(ctx, cluster, fmt.Errorf("error initializing client from cluster config: %s", err))
        }
+       // Disable auto-retry.  We have transient failure recovery at
+       // the application level, so we would rather receive/report
+       // upstream errors right away.
+       ac.Timeout = 0
        d := &dispatcher{
                Cluster:   cluster,
                Context:   ctx,
index 938ef915f251e4d27e1ea4f714b82f10425d4224..8d8b7ff9af09a152ec106d020097c6b820920b62 100644 (file)
@@ -15,7 +15,14 @@ import (
        "github.com/sirupsen/logrus"
 )
 
-type typeChooser func(*arvados.Container) (arvados.InstanceType, error)
+// Stop fetching queued containers after this many of the highest
+// priority non-supervisor containers. Reduces API load when queue is
+// long. This also limits how quickly a large batch of queued
+// containers can be started, which improves reliability under high
+// load at the cost of increased under light load.
+const queuedContainersTarget = 100
+
+type typeChooser func(*arvados.Container) ([]arvados.InstanceType, error)
 
 // An APIClient performs Arvados API requests. It is typically an
 // *arvados.Client.
@@ -27,11 +34,11 @@ type APIClient interface {
 // record and the instance type that should be used to run it.
 type QueueEnt struct {
        // The container to run. Only the UUID, State, Priority,
-       // RuntimeConstraints, Mounts, and ContainerImage fields are
-       // populated.
-       Container    arvados.Container    `json:"container"`
-       InstanceType arvados.InstanceType `json:"instance_type"`
-       FirstSeenAt  time.Time            `json:"first_seen_at"`
+       // RuntimeConstraints, ContainerImage, SchedulingParameters,
+       // and CreatedAt fields are populated.
+       Container     arvados.Container      `json:"container"`
+       InstanceTypes []arvados.InstanceType `json:"instance_types"`
+       FirstSeenAt   time.Time              `json:"first_seen_at"`
 }
 
 // String implements fmt.Stringer by returning the queued container's
@@ -232,13 +239,30 @@ func (cq *Queue) delEnt(uuid string, state arvados.ContainerState) {
 
 // Caller must have lock.
 func (cq *Queue) addEnt(uuid string, ctr arvados.Container) {
-       it, err := cq.chooseType(&ctr)
+       logger := cq.logger.WithField("ContainerUUID", ctr.UUID)
+       // We didn't ask for the Mounts field when polling
+       // controller/RailsAPI, because it can be expensive on the
+       // Rails side, and most of the time we already have it.  But
+       // this is the first time we're seeing this container, so we
+       // need to fetch mounts in order to choose an instance type.
+       err := cq.client.RequestAndDecode(&ctr, "GET", "arvados/v1/containers/"+ctr.UUID, nil, arvados.GetOptions{
+               Select: []string{"mounts"},
+       })
+       if err != nil {
+               logger.WithError(err).Warn("error getting mounts")
+               return
+       }
+       types, err := cq.chooseType(&ctr)
+
+       // Avoid wasting memory on a large Mounts attr (we don't need
+       // it after choosing type).
+       ctr.Mounts = nil
+
        if err != nil && (ctr.State == arvados.ContainerStateQueued || ctr.State == arvados.ContainerStateLocked) {
                // We assume here that any chooseType error is a hard
                // error: it wouldn't help to try again, or to leave
                // it for a different dispatcher process to attempt.
                errorString := err.Error()
-               logger := cq.logger.WithField("ContainerUUID", ctr.UUID)
                logger.WithError(err).Warn("cancel container with no suitable instance type")
                go func() {
                        if ctr.State == arvados.ContainerStateQueued {
@@ -280,13 +304,20 @@ func (cq *Queue) addEnt(uuid string, ctr arvados.Container) {
                }()
                return
        }
+       typeNames := ""
+       for _, it := range types {
+               if typeNames != "" {
+                       typeNames += ", "
+               }
+               typeNames += it.Name
+       }
        cq.logger.WithFields(logrus.Fields{
                "ContainerUUID": ctr.UUID,
                "State":         ctr.State,
                "Priority":      ctr.Priority,
-               "InstanceType":  it.Name,
+               "InstanceTypes": typeNames,
        }).Info("adding container to queue")
-       cq.current[uuid] = QueueEnt{Container: ctr, InstanceType: it, FirstSeenAt: time.Now()}
+       cq.current[uuid] = QueueEnt{Container: ctr, InstanceTypes: types, FirstSeenAt: time.Now()}
 }
 
 // Lock acquires the dispatch lock for the given container.
@@ -384,7 +415,7 @@ func (cq *Queue) poll() (map[string]*arvados.Container, error) {
                        *next[upd.UUID] = upd
                }
        }
-       selectParam := []string{"uuid", "state", "priority", "runtime_constraints", "container_image", "mounts", "scheduling_parameters", "created_at"}
+       selectParam := []string{"uuid", "state", "priority", "runtime_constraints", "container_image", "scheduling_parameters", "created_at"}
        limitParam := 1000
 
        mine, err := cq.fetchAll(arvados.ResourceListParams{
@@ -393,7 +424,7 @@ func (cq *Queue) poll() (map[string]*arvados.Container, error) {
                Limit:   &limitParam,
                Count:   "none",
                Filters: []arvados.Filter{{"locked_by_uuid", "=", auth.UUID}},
-       })
+       }, 0)
        if err != nil {
                return nil, err
        }
@@ -401,16 +432,23 @@ func (cq *Queue) poll() (map[string]*arvados.Container, error) {
 
        avail, err := cq.fetchAll(arvados.ResourceListParams{
                Select:  selectParam,
-               Order:   "uuid",
+               Order:   "priority desc",
                Limit:   &limitParam,
                Count:   "none",
                Filters: []arvados.Filter{{"state", "=", arvados.ContainerStateQueued}, {"priority", ">", "0"}},
-       })
+       }, queuedContainersTarget)
        if err != nil {
                return nil, err
        }
        apply(avail)
 
+       // Check for containers that we already know about but weren't
+       // returned by any of the above queries, and fetch them
+       // explicitly by UUID. If they're in a final state we can drop
+       // them, but otherwise we need to apply updates, e.g.,
+       //
+       // - Queued container priority has been reduced
+       // - Locked container has been requeued with lower priority
        missing := map[string]bool{}
        cq.mtx.Lock()
        for uuid, ent := range cq.current {
@@ -436,7 +474,7 @@ func (cq *Queue) poll() (map[string]*arvados.Container, error) {
                        Order:   "uuid",
                        Count:   "none",
                        Filters: filters,
-               })
+               }, 0)
                if err != nil {
                        return nil, err
                }
@@ -471,10 +509,18 @@ func (cq *Queue) poll() (map[string]*arvados.Container, error) {
        return next, nil
 }
 
-func (cq *Queue) fetchAll(initialParams arvados.ResourceListParams) ([]arvados.Container, error) {
+// Fetch all pages of containers.
+//
+// Except: if maxNonSuper>0, stop fetching more pages after receving
+// that many non-supervisor containers. Along with {Order: "priority
+// desc"}, this enables fetching enough high priority scheduling-ready
+// containers to make progress, without necessarily fetching the
+// entire queue.
+func (cq *Queue) fetchAll(initialParams arvados.ResourceListParams, maxNonSuper int) ([]arvados.Container, error) {
        var results []arvados.Container
        params := initialParams
        params.Offset = 0
+       nonSuper := 0
        for {
                // This list variable must be a new one declared
                // inside the loop: otherwise, items in the API
@@ -490,8 +536,23 @@ func (cq *Queue) fetchAll(initialParams arvados.ResourceListParams) ([]arvados.C
                        break
                }
 
+               // Conserve memory by deleting mounts that aren't
+               // relevant to choosing the instance type.
+               for _, c := range list.Items {
+                       for path, mnt := range c.Mounts {
+                               if mnt.Kind != "tmp" {
+                                       delete(c.Mounts, path)
+                               }
+                       }
+                       if !c.SchedulingParameters.Supervisor {
+                               nonSuper++
+                       }
+               }
+
                results = append(results, list.Items...)
-               if len(params.Order) == 1 && params.Order == "uuid" {
+               if maxNonSuper > 0 && nonSuper >= maxNonSuper {
+                       break
+               } else if params.Order == "uuid" {
                        params.Filters = append(initialParams.Filters, arvados.Filter{"uuid", ">", list.Items[len(list.Items)-1].UUID})
                } else {
                        params.Offset += len(list.Items)
@@ -523,7 +584,7 @@ func (cq *Queue) runMetrics(reg *prometheus.Registry) {
                }
                ents, _ := cq.Entries()
                for _, ent := range ents {
-                       count[entKey{ent.Container.State, ent.InstanceType.Name}]++
+                       count[entKey{ent.Container.State, ent.InstanceTypes[0].Name}]++
                }
                for k, v := range count {
                        mEntries.WithLabelValues(string(k.state), k.inst).Set(float64(v))
index 0075ee324ef8eb1d10a54207af97ddbf6a70b6bf..928c6dd8c87400403d8feaab6459b2ba0fff40f0 100644 (file)
@@ -40,8 +40,9 @@ func (suite *IntegrationSuite) TearDownTest(c *check.C) {
 }
 
 func (suite *IntegrationSuite) TestGetLockUnlockCancel(c *check.C) {
-       typeChooser := func(ctr *arvados.Container) (arvados.InstanceType, error) {
-               return arvados.InstanceType{Name: "testType"}, nil
+       typeChooser := func(ctr *arvados.Container) ([]arvados.InstanceType, error) {
+               c.Check(ctr.Mounts["/tmp"].Capacity, check.Equals, int64(24000000000))
+               return []arvados.InstanceType{{Name: "testType"}}, nil
        }
 
        client := arvados.NewClientFromEnv()
@@ -61,9 +62,12 @@ func (suite *IntegrationSuite) TestGetLockUnlockCancel(c *check.C) {
        var wg sync.WaitGroup
        for uuid, ent := range ents {
                c.Check(ent.Container.UUID, check.Equals, uuid)
-               c.Check(ent.InstanceType.Name, check.Equals, "testType")
+               c.Check(ent.InstanceTypes, check.HasLen, 1)
+               c.Check(ent.InstanceTypes[0].Name, check.Equals, "testType")
                c.Check(ent.Container.State, check.Equals, arvados.ContainerStateQueued)
                c.Check(ent.Container.Priority > 0, check.Equals, true)
+               // Mounts should be deleted to avoid wasting memory
+               c.Check(ent.Container.Mounts, check.IsNil)
 
                ctr, ok := cq.Get(uuid)
                c.Check(ok, check.Equals, true)
@@ -105,7 +109,7 @@ func (suite *IntegrationSuite) TestGetLockUnlockCancel(c *check.C) {
 }
 
 func (suite *IntegrationSuite) TestCancelIfNoInstanceType(c *check.C) {
-       errorTypeChooser := func(ctr *arvados.Container) (arvados.InstanceType, error) {
+       errorTypeChooser := func(ctr *arvados.Container) ([]arvados.InstanceType, error) {
                // Make sure the relevant container fields are
                // actually populated.
                c.Check(ctr.ContainerImage, check.Equals, "test")
@@ -113,7 +117,7 @@ func (suite *IntegrationSuite) TestCancelIfNoInstanceType(c *check.C) {
                c.Check(ctr.RuntimeConstraints.RAM, check.Equals, int64(12000000000))
                c.Check(ctr.Mounts["/tmp"].Capacity, check.Equals, int64(24000000000))
                c.Check(ctr.Mounts["/var/spool/cwl"].Capacity, check.Equals, int64(24000000000))
-               return arvados.InstanceType{}, errors.New("no suitable instance type")
+               return nil, errors.New("no suitable instance type")
        }
 
        client := arvados.NewClientFromEnv()
index 06a558d5fe2b58c7b9acf149faba020fc8f16c4e..04283df48f6faf60bd0968b327a6e953c41d6d18 100644 (file)
@@ -15,6 +15,7 @@ import (
        "time"
 
        "git.arvados.org/arvados.git/lib/cloud"
+       "git.arvados.org/arvados.git/lib/config"
        "git.arvados.org/arvados.git/lib/controller/dblock"
        "git.arvados.org/arvados.git/lib/ctrlctx"
        "git.arvados.org/arvados.git/lib/dispatchcloud/container"
@@ -60,14 +61,22 @@ type dispatcher struct {
        instanceSet cloud.InstanceSet
        pool        pool
        queue       scheduler.ContainerQueue
+       sched       *scheduler.Scheduler
        httpHandler http.Handler
        sshKey      ssh.Signer
 
        setupOnce sync.Once
        stop      chan struct{}
        stopped   chan struct{}
+
+       schedQueueMtx       sync.Mutex
+       schedQueueRefreshed time.Time
+       schedQueue          []scheduler.QueueEnt
+       schedQueueMap       map[string]scheduler.QueueEnt
 }
 
+var schedQueueRefresh = time.Second
+
 // Start starts the dispatcher. Start can be called multiple times
 // with no ill effect.
 func (disp *dispatcher) Start() {
@@ -110,7 +119,7 @@ func (disp *dispatcher) newExecutor(inst cloud.Instance) worker.Executor {
        return exr
 }
 
-func (disp *dispatcher) typeChooser(ctr *arvados.Container) (arvados.InstanceType, error) {
+func (disp *dispatcher) typeChooser(ctr *arvados.Container) ([]arvados.InstanceType, error) {
        return ChooseInstanceType(disp.Cluster, ctr)
 }
 
@@ -137,11 +146,15 @@ func (disp *dispatcher) initialize() {
        disp.stop = make(chan struct{}, 1)
        disp.stopped = make(chan struct{})
 
-       if key, err := ssh.ParsePrivateKey([]byte(disp.Cluster.Containers.DispatchPrivateKey)); err != nil {
+       if key, err := config.LoadSSHKey(disp.Cluster.Containers.DispatchPrivateKey); err != nil {
                disp.logger.Fatalf("error parsing configured Containers.DispatchPrivateKey: %s", err)
        } else {
                disp.sshKey = key
        }
+       installPublicKey := disp.sshKey.PublicKey()
+       if !disp.Cluster.Containers.CloudVMs.DeployPublicKey {
+               installPublicKey = nil
+       }
 
        instanceSet, err := newInstanceSet(disp.Cluster, disp.InstanceSetID, disp.logger, disp.Registry)
        if err != nil {
@@ -149,8 +162,23 @@ func (disp *dispatcher) initialize() {
        }
        dblock.Dispatch.Lock(disp.Context, disp.dbConnector.GetDB)
        disp.instanceSet = instanceSet
-       disp.pool = worker.NewPool(disp.logger, disp.ArvClient, disp.Registry, disp.InstanceSetID, disp.instanceSet, disp.newExecutor, disp.sshKey.PublicKey(), disp.Cluster)
-       disp.queue = container.NewQueue(disp.logger, disp.Registry, disp.typeChooser, disp.ArvClient)
+       disp.pool = worker.NewPool(disp.logger, disp.ArvClient, disp.Registry, disp.InstanceSetID, disp.instanceSet, disp.newExecutor, installPublicKey, disp.Cluster)
+       if disp.queue == nil {
+               disp.queue = container.NewQueue(disp.logger, disp.Registry, disp.typeChooser, disp.ArvClient)
+       }
+
+       staleLockTimeout := time.Duration(disp.Cluster.Containers.StaleLockTimeout)
+       if staleLockTimeout == 0 {
+               staleLockTimeout = defaultStaleLockTimeout
+       }
+       pollInterval := time.Duration(disp.Cluster.Containers.CloudVMs.PollInterval)
+       if pollInterval <= 0 {
+               pollInterval = defaultPollInterval
+       }
+       disp.sched = scheduler.New(disp.Context, disp.ArvClient, disp.queue, disp.pool, disp.Registry, staleLockTimeout, pollInterval,
+               disp.Cluster.Containers.CloudVMs.InitialQuotaEstimate,
+               disp.Cluster.Containers.CloudVMs.MaxInstances,
+               disp.Cluster.Containers.CloudVMs.SupervisorFraction)
 
        if disp.Cluster.ManagementToken == "" {
                disp.httpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -159,6 +187,7 @@ func (disp *dispatcher) initialize() {
        } else {
                mux := httprouter.New()
                mux.HandlerFunc("GET", "/arvados/v1/dispatch/containers", disp.apiContainers)
+               mux.HandlerFunc("GET", "/arvados/v1/dispatch/container", disp.apiContainer)
                mux.HandlerFunc("POST", "/arvados/v1/dispatch/containers/kill", disp.apiContainerKill)
                mux.HandlerFunc("GET", "/arvados/v1/dispatch/instances", disp.apiInstances)
                mux.HandlerFunc("POST", "/arvados/v1/dispatch/instances/hold", disp.apiInstanceHold)
@@ -185,37 +214,53 @@ func (disp *dispatcher) run() {
        defer disp.instanceSet.Stop()
        defer disp.pool.Stop()
 
-       staleLockTimeout := time.Duration(disp.Cluster.Containers.StaleLockTimeout)
-       if staleLockTimeout == 0 {
-               staleLockTimeout = defaultStaleLockTimeout
-       }
-       pollInterval := time.Duration(disp.Cluster.Containers.CloudVMs.PollInterval)
-       if pollInterval <= 0 {
-               pollInterval = defaultPollInterval
-       }
-       maxSupervisors := int(float64(disp.Cluster.Containers.CloudVMs.MaxInstances) * disp.Cluster.Containers.CloudVMs.SupervisorFraction)
-       if maxSupervisors == 0 && disp.Cluster.Containers.CloudVMs.SupervisorFraction > 0 {
-               maxSupervisors = 1
-       }
-       sched := scheduler.New(disp.Context, disp.ArvClient, disp.queue, disp.pool, disp.Registry, staleLockTimeout, pollInterval, maxSupervisors)
-       sched.Start()
-       defer sched.Stop()
+       disp.sched.Start()
+       defer disp.sched.Stop()
 
        <-disp.stop
 }
 
-// Management API: all active and queued containers.
+// Get a snapshot of the scheduler's queue, no older than
+// schedQueueRefresh.
+//
+// First return value is in the sorted order used by the scheduler.
+// Second return value is a map of the same entries, for efficiently
+// looking up a single container.
+func (disp *dispatcher) schedQueueCurrent() ([]scheduler.QueueEnt, map[string]scheduler.QueueEnt) {
+       disp.schedQueueMtx.Lock()
+       defer disp.schedQueueMtx.Unlock()
+       if time.Since(disp.schedQueueRefreshed) > schedQueueRefresh {
+               disp.schedQueue = disp.sched.Queue()
+               disp.schedQueueMap = make(map[string]scheduler.QueueEnt)
+               for _, ent := range disp.schedQueue {
+                       disp.schedQueueMap[ent.Container.UUID] = ent
+               }
+               disp.schedQueueRefreshed = time.Now()
+       }
+       return disp.schedQueue, disp.schedQueueMap
+}
+
+// Management API: scheduling queue entries for all active and queued
+// containers.
 func (disp *dispatcher) apiContainers(w http.ResponseWriter, r *http.Request) {
        var resp struct {
-               Items []container.QueueEnt `json:"items"`
-       }
-       qEntries, _ := disp.queue.Entries()
-       for _, ent := range qEntries {
-               resp.Items = append(resp.Items, ent)
+               Items []scheduler.QueueEnt `json:"items"`
        }
+       resp.Items, _ = disp.schedQueueCurrent()
        json.NewEncoder(w).Encode(resp)
 }
 
+// Management API: scheduling queue entry for a specified container.
+func (disp *dispatcher) apiContainer(w http.ResponseWriter, r *http.Request) {
+       _, sq := disp.schedQueueCurrent()
+       ent, ok := sq[r.FormValue("container_uuid")]
+       if !ok {
+               httpserver.Error(w, "container not found", http.StatusNotFound)
+               return
+       }
+       json.NewEncoder(w).Encode(ent)
+}
+
 // Management API: all active instances (cloud VMs).
 func (disp *dispatcher) apiInstances(w http.ResponseWriter, r *http.Request) {
        var resp struct {
index a9ed95c7c3b5f581c4e3a60776f0d5c207c34db9..d651e73a67c341bbd8df7ac2465bcecc060aa042 100644 (file)
@@ -8,13 +8,16 @@ import (
        "context"
        "crypto/tls"
        "encoding/json"
+       "fmt"
        "io/ioutil"
        "math/rand"
        "net/http"
        "net/http/httptest"
        "net/url"
        "os"
+       "strings"
        "sync"
+       "sync/atomic"
        "time"
 
        "git.arvados.org/arvados.git/lib/config"
@@ -49,8 +52,10 @@ func (s *DispatcherSuite) SetUpTest(c *check.C) {
        s.stubDriver = &test.StubDriver{
                HostKey:                   hostpriv,
                AuthorizedKeys:            []ssh.PublicKey{dispatchpub},
+               ErrorRateCreate:           0.1,
                ErrorRateDestroy:          0.1,
                MinTimeBetweenCreateCalls: time.Millisecond,
+               QuotaMaxInstances:         10,
        }
 
        // We need the postgresql connection info from the integration
@@ -69,6 +74,8 @@ func (s *DispatcherSuite) SetUpTest(c *check.C) {
                        DispatchPrivateKey:     string(dispatchprivraw),
                        StaleLockTimeout:       arvados.Duration(5 * time.Millisecond),
                        RuntimeEngine:          "stub",
+                       MaxDispatchAttempts:    10,
+                       MaximumPriceFactor:     1.5,
                        CloudVMs: arvados.CloudVMsConfig{
                                Driver:               "test",
                                SyncInterval:         arvados.Duration(10 * time.Millisecond),
@@ -77,6 +84,7 @@ func (s *DispatcherSuite) SetUpTest(c *check.C) {
                                TimeoutProbe:         arvados.Duration(15 * time.Millisecond),
                                TimeoutShutdown:      arvados.Duration(5 * time.Millisecond),
                                MaxCloudOpsPerSecond: 500,
+                               InitialQuotaEstimate: 8,
                                PollInterval:         arvados.Duration(5 * time.Millisecond),
                                ProbeInterval:        arvados.Duration(5 * time.Millisecond),
                                MaxProbesPerSecond:   1000,
@@ -101,9 +109,14 @@ func (s *DispatcherSuite) SetUpTest(c *check.C) {
        arvadostest.SetServiceURL(&s.cluster.Services.Controller, "https://"+os.Getenv("ARVADOS_API_HOST")+"/")
 
        arvClient, err := arvados.NewClientFromConfig(s.cluster)
-       c.Check(err, check.IsNil)
+       c.Assert(err, check.IsNil)
+       // Disable auto-retry
+       arvClient.Timeout = 0
 
-       s.error503Server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }))
+       s.error503Server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               c.Logf("503 stub: returning 503")
+               w.WriteHeader(http.StatusServiceUnavailable)
+       }))
        arvClient.Client = &http.Client{
                Transport: &http.Transport{
                        Proxy: s.arvClientProxy(c),
@@ -116,6 +129,10 @@ func (s *DispatcherSuite) SetUpTest(c *check.C) {
                ArvClient: arvClient,
                AuthToken: arvadostest.AdminToken,
                Registry:  prometheus.NewRegistry(),
+               // Providing a stub queue here prevents
+               // disp.initialize() from making a real one that uses
+               // the integration test servers/database.
+               queue: &test.Queue{},
        }
        // Test cases can modify s.cluster before calling
        // initialize(), and then modify private state before calling
@@ -134,6 +151,7 @@ func (s *DispatcherSuite) TearDownTest(c *check.C) {
 func (s *DispatcherSuite) arvClientProxy(c *check.C) func(*http.Request) (*url.URL, error) {
        return func(req *http.Request) (*url.URL, error) {
                if req.URL.Path == "/503" {
+                       c.Logf("arvClientProxy: proxying to 503 stub")
                        return url.Parse(s.error503Server.URL)
                } else {
                        return nil, nil
@@ -147,9 +165,9 @@ func (s *DispatcherSuite) arvClientProxy(c *check.C) func(*http.Request) (*url.U
 // artificial errors in order to exercise a variety of code paths.
 func (s *DispatcherSuite) TestDispatchToStubDriver(c *check.C) {
        Drivers["test"] = s.stubDriver
-       s.disp.setupOnce.Do(s.disp.initialize)
        queue := &test.Queue{
-               ChooseType: func(ctr *arvados.Container) (arvados.InstanceType, error) {
+               MaxDispatchAttempts: 5,
+               ChooseType: func(ctr *arvados.Container) ([]arvados.InstanceType, error) {
                        return ChooseInstanceType(s.cluster, ctr)
                },
                Logger: ctxlog.TestLogger(c),
@@ -166,6 +184,7 @@ func (s *DispatcherSuite) TestDispatchToStubDriver(c *check.C) {
                })
        }
        s.disp.queue = queue
+       s.disp.setupOnce.Do(s.disp.initialize)
 
        var mtx sync.Mutex
        done := make(chan struct{})
@@ -183,6 +202,7 @@ func (s *DispatcherSuite) TestDispatchToStubDriver(c *check.C) {
                delete(waiting, ctr.UUID)
                if len(waiting) == 100 {
                        // trigger scheduler maxConcurrency limit
+                       c.Logf("test: requesting 503 in order to trigger maxConcurrency limit")
                        s.disp.ArvClient.RequestAndDecode(nil, "GET", "503", nil, nil)
                }
                if len(waiting) == 0 {
@@ -193,26 +213,51 @@ func (s *DispatcherSuite) TestDispatchToStubDriver(c *check.C) {
                finishContainer(ctr)
                return int(rand.Uint32() & 0x3)
        }
-       n := 0
+       var type4BrokenUntil time.Time
+       var countCapacityErrors int64
+       vmCount := int32(0)
        s.stubDriver.Queue = queue
-       s.stubDriver.SetupVM = func(stubvm *test.StubVM) {
-               n++
+       s.stubDriver.SetupVM = func(stubvm *test.StubVM) error {
+               if pt := stubvm.Instance().ProviderType(); pt == test.InstanceType(6).ProviderType {
+                       c.Logf("test: returning capacity error for instance type %s", pt)
+                       atomic.AddInt64(&countCapacityErrors, 1)
+                       return test.CapacityError{InstanceTypeSpecific: true}
+               }
+               n := atomic.AddInt32(&vmCount, 1)
+               c.Logf("SetupVM: instance %s n=%d", stubvm.Instance(), n)
                stubvm.Boot = time.Now().Add(time.Duration(rand.Int63n(int64(5 * time.Millisecond))))
                stubvm.CrunchRunDetachDelay = time.Duration(rand.Int63n(int64(10 * time.Millisecond)))
                stubvm.ExecuteContainer = executeContainer
                stubvm.CrashRunningContainer = finishContainer
                stubvm.ExtraCrunchRunArgs = "'--runtime-engine=stub' '--foo' '--extra='\\''args'\\'''"
-               switch n % 7 {
-               case 0:
+               switch {
+               case stubvm.Instance().ProviderType() == test.InstanceType(4).ProviderType &&
+                       (type4BrokenUntil.IsZero() || time.Now().Before(type4BrokenUntil)):
+                       // Initially (at least 2*TimeoutBooting), all
+                       // instances of this type are completely
+                       // broken. This ensures the
+                       // boot_outcomes{outcome="failure"} metric is
+                       // not zero.
+                       stubvm.Broken = time.Now()
+                       if type4BrokenUntil.IsZero() {
+                               type4BrokenUntil = time.Now().Add(2 * s.cluster.Containers.CloudVMs.TimeoutBooting.Duration())
+                       }
+               case n%7 == 0:
+                       // some instances start out OK but then stop
+                       // running any commands
                        stubvm.Broken = time.Now().Add(time.Duration(rand.Int63n(90)) * time.Millisecond)
-               case 1:
+               case n%7 == 1:
+                       // some instances never pass a run-probe
                        stubvm.CrunchRunMissing = true
-               case 2:
+               case n%7 == 2:
+                       // some instances start out OK but then start
+                       // reporting themselves as broken
                        stubvm.ReportBroken = time.Now().Add(time.Duration(rand.Int63n(200)) * time.Millisecond)
                default:
                        stubvm.CrunchRunCrashRate = 0.1
                        stubvm.ArvMountDeadlockRate = 0.1
                }
+               return nil
        }
        s.stubDriver.Bugf = c.Errorf
 
@@ -226,9 +271,9 @@ func (s *DispatcherSuite) TestDispatchToStubDriver(c *check.C) {
                select {
                case <-done:
                        // loop will end because len(waiting)==0
-               case <-time.After(3 * time.Second):
+               case <-time.After(5 * time.Second):
                        if len(waiting) >= waswaiting {
-                               c.Fatalf("timed out; no progress in 3s while waiting for %d containers: %q", len(waiting), waiting)
+                               c.Fatalf("timed out; no progress in s while waiting for %d containers: %q", len(waiting), waiting)
                        }
                }
        }
@@ -248,6 +293,8 @@ func (s *DispatcherSuite) TestDispatchToStubDriver(c *check.C) {
                }
        }
 
+       c.Check(countCapacityErrors, check.Not(check.Equals), int64(0))
+
        req := httptest.NewRequest("GET", "/metrics", nil)
        req.Header.Set("Authorization", "Bearer "+s.cluster.ManagementToken)
        resp := httptest.NewRecorder()
@@ -282,11 +329,10 @@ func (s *DispatcherSuite) TestDispatchToStubDriver(c *check.C) {
        c.Check(resp.Body.String(), check.Matches, `(?ms).*max_concurrent_containers [1-9][0-9e+.]*`)
 }
 
-func (s *DispatcherSuite) TestAPIPermissions(c *check.C) {
+func (s *DispatcherSuite) TestManagementAPI_Permissions(c *check.C) {
        s.cluster.ManagementToken = "abcdefgh"
        Drivers["test"] = s.stubDriver
        s.disp.setupOnce.Do(s.disp.initialize)
-       s.disp.queue = &test.Queue{}
        go s.disp.run()
 
        for _, token := range []string{"abc", ""} {
@@ -304,11 +350,10 @@ func (s *DispatcherSuite) TestAPIPermissions(c *check.C) {
        }
 }
 
-func (s *DispatcherSuite) TestAPIDisabled(c *check.C) {
+func (s *DispatcherSuite) TestManagementAPI_Disabled(c *check.C) {
        s.cluster.ManagementToken = ""
        Drivers["test"] = s.stubDriver
        s.disp.setupOnce.Do(s.disp.initialize)
-       s.disp.queue = &test.Queue{}
        go s.disp.run()
 
        for _, token := range []string{"abc", ""} {
@@ -322,13 +367,121 @@ func (s *DispatcherSuite) TestAPIDisabled(c *check.C) {
        }
 }
 
-func (s *DispatcherSuite) TestInstancesAPI(c *check.C) {
+func (s *DispatcherSuite) TestManagementAPI_Containers(c *check.C) {
+       s.cluster.ManagementToken = "abcdefgh"
+       s.cluster.Containers.CloudVMs.InitialQuotaEstimate = 4
+       Drivers["test"] = s.stubDriver
+       queue := &test.Queue{
+               MaxDispatchAttempts: 5,
+               ChooseType: func(ctr *arvados.Container) ([]arvados.InstanceType, error) {
+                       return ChooseInstanceType(s.cluster, ctr)
+               },
+               Logger: ctxlog.TestLogger(c),
+       }
+       s.stubDriver.Queue = queue
+       s.stubDriver.QuotaMaxInstances = 4
+       s.stubDriver.SetupVM = func(stubvm *test.StubVM) error {
+               if stubvm.Instance().ProviderType() >= test.InstanceType(4).ProviderType {
+                       return test.CapacityError{InstanceTypeSpecific: true}
+               }
+               stubvm.ExecuteContainer = func(ctr arvados.Container) int {
+                       time.Sleep(5 * time.Second)
+                       return 0
+               }
+               return nil
+       }
+       s.disp.queue = queue
+       s.disp.setupOnce.Do(s.disp.initialize)
+
+       go s.disp.run()
+
+       type queueEnt struct {
+               Container        arvados.Container
+               InstanceType     arvados.InstanceType `json:"instance_type"`
+               SchedulingStatus string               `json:"scheduling_status"`
+       }
+       type containersResponse struct {
+               Items []queueEnt
+       }
+       getContainers := func() containersResponse {
+               schedQueueRefresh = time.Millisecond
+               req := httptest.NewRequest("GET", "/arvados/v1/dispatch/containers", nil)
+               req.Header.Set("Authorization", "Bearer abcdefgh")
+               resp := httptest.NewRecorder()
+               s.disp.ServeHTTP(resp, req)
+               var cresp containersResponse
+               c.Check(resp.Code, check.Equals, http.StatusOK)
+               err := json.Unmarshal(resp.Body.Bytes(), &cresp)
+               c.Check(err, check.IsNil)
+               return cresp
+       }
+
+       c.Check(getContainers().Items, check.HasLen, 0)
+
+       for i := 0; i < 20; i++ {
+               queue.Containers = append(queue.Containers, arvados.Container{
+                       UUID:     test.ContainerUUID(i),
+                       State:    arvados.ContainerStateQueued,
+                       Priority: int64(100 - i),
+                       RuntimeConstraints: arvados.RuntimeConstraints{
+                               RAM:   int64(i%3+1) << 30,
+                               VCPUs: i%8 + 1,
+                       },
+               })
+       }
+       queue.Update()
+
+       expect := `
+ 0 zzzzz-dz642-000000000000000 (Running) ""
+ 1 zzzzz-dz642-000000000000001 (Running) ""
+ 2 zzzzz-dz642-000000000000002 (Locked) "waiting for suitable instance type to become available: queue position 1"
+ 3 zzzzz-dz642-000000000000003 (Locked) "waiting for suitable instance type to become available: queue position 2"
+ 4 zzzzz-dz642-000000000000004 (Queued) "waiting while cluster is running at capacity: queue position 3"
+ 5 zzzzz-dz642-000000000000005 (Queued) "waiting while cluster is running at capacity: queue position 4"
+ 6 zzzzz-dz642-000000000000006 (Queued) "waiting while cluster is running at capacity: queue position 5"
+ 7 zzzzz-dz642-000000000000007 (Queued) "waiting while cluster is running at capacity: queue position 6"
+ 8 zzzzz-dz642-000000000000008 (Queued) "waiting while cluster is running at capacity: queue position 7"
+ 9 zzzzz-dz642-000000000000009 (Queued) "waiting while cluster is running at capacity: queue position 8"
+ 10 zzzzz-dz642-000000000000010 (Queued) "waiting while cluster is running at capacity: queue position 9"
+ 11 zzzzz-dz642-000000000000011 (Queued) "waiting while cluster is running at capacity: queue position 10"
+ 12 zzzzz-dz642-000000000000012 (Queued) "waiting while cluster is running at capacity: queue position 11"
+ 13 zzzzz-dz642-000000000000013 (Queued) "waiting while cluster is running at capacity: queue position 12"
+ 14 zzzzz-dz642-000000000000014 (Queued) "waiting while cluster is running at capacity: queue position 13"
+ 15 zzzzz-dz642-000000000000015 (Queued) "waiting while cluster is running at capacity: queue position 14"
+ 16 zzzzz-dz642-000000000000016 (Queued) "waiting while cluster is running at capacity: queue position 15"
+ 17 zzzzz-dz642-000000000000017 (Queued) "waiting while cluster is running at capacity: queue position 16"
+ 18 zzzzz-dz642-000000000000018 (Queued) "waiting while cluster is running at capacity: queue position 17"
+ 19 zzzzz-dz642-000000000000019 (Queued) "waiting while cluster is running at capacity: queue position 18"
+`
+       sequence := make(map[string][]string)
+       var summary string
+       for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
+               cresp := getContainers()
+               summary = "\n"
+               for i, ent := range cresp.Items {
+                       summary += fmt.Sprintf("% 2d %s (%s) %q\n", i, ent.Container.UUID, ent.Container.State, ent.SchedulingStatus)
+                       s := sequence[ent.Container.UUID]
+                       if len(s) == 0 || s[len(s)-1] != ent.SchedulingStatus {
+                               sequence[ent.Container.UUID] = append(s, ent.SchedulingStatus)
+                       }
+               }
+               if summary == expect {
+                       break
+               }
+       }
+       c.Check(summary, check.Equals, expect)
+       for i := 0; i < 5; i++ {
+               c.Logf("sequence for container %d:\n... %s", i, strings.Join(sequence[test.ContainerUUID(i)], "\n... "))
+       }
+}
+
+func (s *DispatcherSuite) TestManagementAPI_Instances(c *check.C) {
        s.cluster.ManagementToken = "abcdefgh"
        s.cluster.Containers.CloudVMs.TimeoutBooting = arvados.Duration(time.Second)
        Drivers["test"] = s.stubDriver
        s.disp.setupOnce.Do(s.disp.initialize)
-       s.disp.queue = &test.Queue{}
        go s.disp.run()
+       defer s.disp.Close()
 
        type instance struct {
                Instance             string
@@ -356,6 +509,7 @@ func (s *DispatcherSuite) TestInstancesAPI(c *check.C) {
        sr := getInstances()
        c.Check(len(sr.Items), check.Equals, 0)
 
+       s.stubDriver.ErrorRateCreate = 0
        ch := s.disp.pool.Subscribe()
        defer s.disp.pool.Unsubscribe(ch)
        ok := s.disp.pool.Create(test.InstanceType(1))
index 93515defb7d8ebb68c1a5800770c203d52aeaa38..44adc23fd37c28b660755ab81426a03fd0716c08 100644 (file)
@@ -33,7 +33,7 @@ func newInstanceSet(cluster *arvados.Cluster, setID cloud.InstanceSetID, logger
                return nil, fmt.Errorf("unsupported cloud driver %q", cluster.Containers.CloudVMs.Driver)
        }
        sharedResourceTags := cloud.SharedResourceTags(cluster.Containers.CloudVMs.ResourceTags)
-       is, err := driver.InstanceSet(cluster.Containers.CloudVMs.DriverParameters, setID, sharedResourceTags, logger)
+       is, err := driver.InstanceSet(cluster.Containers.CloudVMs.DriverParameters, setID, sharedResourceTags, logger, reg)
        is = newInstrumentedInstanceSet(is, reg)
        if maxops := cluster.Containers.CloudVMs.MaxCloudOpsPerSecond; maxops > 0 {
                is = rateLimitedInstanceSet{
index 0b394f4cfe4f76849fc2eb42541ed613e325921f..802bc65c28ca3b96c05ae269b467f7698fac55db 100644 (file)
@@ -6,6 +6,7 @@ package dispatchcloud
 
 import (
        "errors"
+       "math"
        "regexp"
        "sort"
        "strconv"
@@ -99,12 +100,16 @@ func versionLess(vs1 string, vs2 string) (bool, error) {
        return v1 < v2, nil
 }
 
-// ChooseInstanceType returns the cheapest available
-// arvados.InstanceType big enough to run ctr.
-func ChooseInstanceType(cc *arvados.Cluster, ctr *arvados.Container) (best arvados.InstanceType, err error) {
+// ChooseInstanceType returns the arvados.InstanceTypes eligible to
+// run ctr, i.e., those that have enough RAM, VCPUs, etc., and are not
+// too expensive according to cluster configuration.
+//
+// The returned types are sorted with lower prices first.
+//
+// The error is non-nil if and only if the returned slice is empty.
+func ChooseInstanceType(cc *arvados.Cluster, ctr *arvados.Container) ([]arvados.InstanceType, error) {
        if len(cc.InstanceTypes) == 0 {
-               err = ErrInstanceTypesNotConfigured
-               return
+               return nil, ErrInstanceTypesNotConfigured
        }
 
        needScratch := EstimateScratchSpace(ctr)
@@ -121,31 +126,33 @@ func ChooseInstanceType(cc *arvados.Cluster, ctr *arvados.Container) (best arvad
        }
        needRAM = (needRAM * 100) / int64(100-discountConfiguredRAMPercent)
 
-       ok := false
+       maxPriceFactor := math.Max(cc.Containers.MaximumPriceFactor, 1)
+       var types []arvados.InstanceType
+       var maxPrice float64
        for _, it := range cc.InstanceTypes {
                driverInsuff, driverErr := versionLess(it.CUDA.DriverVersion, ctr.RuntimeConstraints.CUDA.DriverVersion)
                capabilityInsuff, capabilityErr := versionLess(it.CUDA.HardwareCapability, ctr.RuntimeConstraints.CUDA.HardwareCapability)
 
                switch {
                // reasons to reject a node
-               case ok && it.Price > best.Price: // already selected a node, and this one is more expensive
+               case maxPrice > 0 && it.Price > maxPrice: // too expensive
                case int64(it.Scratch) < needScratch: // insufficient scratch
                case int64(it.RAM) < needRAM: // insufficient RAM
                case it.VCPUs < needVCPUs: // insufficient VCPUs
                case it.Preemptible != ctr.SchedulingParameters.Preemptible: // wrong preemptable setting
-               case it.Price == best.Price && (it.RAM < best.RAM || it.VCPUs < best.VCPUs): // same price, worse specs
                case it.CUDA.DeviceCount < ctr.RuntimeConstraints.CUDA.DeviceCount: // insufficient CUDA devices
                case ctr.RuntimeConstraints.CUDA.DeviceCount > 0 && (driverInsuff || driverErr != nil): // insufficient driver version
                case ctr.RuntimeConstraints.CUDA.DeviceCount > 0 && (capabilityInsuff || capabilityErr != nil): // insufficient hardware capability
                        // Don't select this node
                default:
                        // Didn't reject the node, so select it
-                       // Lower price || (same price && better specs)
-                       best = it
-                       ok = true
+                       types = append(types, it)
+                       if newmax := it.Price * maxPriceFactor; newmax < maxPrice || maxPrice == 0 {
+                               maxPrice = newmax
+                       }
                }
        }
-       if !ok {
+       if len(types) == 0 {
                availableTypes := make([]arvados.InstanceType, 0, len(cc.InstanceTypes))
                for _, t := range cc.InstanceTypes {
                        availableTypes = append(availableTypes, t)
@@ -153,11 +160,39 @@ func ChooseInstanceType(cc *arvados.Cluster, ctr *arvados.Container) (best arvad
                sort.Slice(availableTypes, func(a, b int) bool {
                        return availableTypes[a].Price < availableTypes[b].Price
                })
-               err = ConstraintsNotSatisfiableError{
+               return nil, ConstraintsNotSatisfiableError{
                        errors.New("constraints not satisfiable by any configured instance type"),
                        availableTypes,
                }
-               return
        }
-       return
+       sort.Slice(types, func(i, j int) bool {
+               if types[i].Price != types[j].Price {
+                       // prefer lower price
+                       return types[i].Price < types[j].Price
+               }
+               if types[i].RAM != types[j].RAM {
+                       // if same price, prefer more RAM
+                       return types[i].RAM > types[j].RAM
+               }
+               if types[i].VCPUs != types[j].VCPUs {
+                       // if same price and RAM, prefer more VCPUs
+                       return types[i].VCPUs > types[j].VCPUs
+               }
+               if types[i].Scratch != types[j].Scratch {
+                       // if same price and RAM and VCPUs, prefer more scratch
+                       return types[i].Scratch > types[j].Scratch
+               }
+               // no preference, just sort the same way each time
+               return types[i].Name < types[j].Name
+       })
+       // Truncate types at maxPrice. We rejected it.Price>maxPrice
+       // in the loop above, but at that point maxPrice wasn't
+       // necessarily the final (lowest) maxPrice.
+       for i, it := range types {
+               if i > 0 && it.Price > maxPrice {
+                       types = types[:i]
+                       break
+               }
+       }
+       return types, nil
 }
index 86bfbec7b629dc731e309740346dec85a24ae2d7..5d2713e982a4a4d397a9888081eb225133617443 100644 (file)
@@ -93,12 +93,51 @@ func (*NodeSizeSuite) TestChoose(c *check.C) {
                                KeepCacheRAM: 123456789,
                        },
                })
-               c.Check(err, check.IsNil)
-               c.Check(best.Name, check.Equals, "best")
-               c.Check(best.RAM >= 1234567890, check.Equals, true)
-               c.Check(best.VCPUs >= 2, check.Equals, true)
-               c.Check(best.Scratch >= 2*GiB, check.Equals, true)
+               c.Assert(err, check.IsNil)
+               c.Assert(best, check.Not(check.HasLen), 0)
+               c.Check(best[0].Name, check.Equals, "best")
+               c.Check(best[0].RAM >= 1234567890, check.Equals, true)
+               c.Check(best[0].VCPUs >= 2, check.Equals, true)
+               c.Check(best[0].Scratch >= 2*GiB, check.Equals, true)
+               for i := range best {
+                       // If multiple instance types are returned
+                       // then they should all have the same price,
+                       // because we didn't set MaximumPriceFactor>1.
+                       c.Check(best[i].Price, check.Equals, best[0].Price)
+               }
+       }
+}
+
+func (*NodeSizeSuite) TestMaximumPriceFactor(c *check.C) {
+       menu := map[string]arvados.InstanceType{
+               "best+7":  {Price: 3.4, RAM: 8000000000, VCPUs: 8, Scratch: 64 * GiB, Name: "best+7"},
+               "best+5":  {Price: 3.0, RAM: 8000000000, VCPUs: 8, Scratch: 16 * GiB, Name: "best+5"},
+               "best+3":  {Price: 2.6, RAM: 4000000000, VCPUs: 8, Scratch: 16 * GiB, Name: "best+3"},
+               "best+2":  {Price: 2.4, RAM: 4000000000, VCPUs: 8, Scratch: 4 * GiB, Name: "best+2"},
+               "best+1":  {Price: 2.2, RAM: 2000000000, VCPUs: 4, Scratch: 4 * GiB, Name: "best+1"},
+               "best":    {Price: 2.0, RAM: 2000000000, VCPUs: 4, Scratch: 2 * GiB, Name: "best"},
+               "small+1": {Price: 1.1, RAM: 1000000000, VCPUs: 2, Scratch: 16 * GiB, Name: "small+1"},
+               "small":   {Price: 1.0, RAM: 2000000000, VCPUs: 2, Scratch: 1 * GiB, Name: "small"},
        }
+       best, err := ChooseInstanceType(&arvados.Cluster{InstanceTypes: menu, Containers: arvados.ContainersConfig{
+               MaximumPriceFactor: 1.5,
+       }}, &arvados.Container{
+               Mounts: map[string]arvados.Mount{
+                       "/tmp": {Kind: "tmp", Capacity: 2 * int64(GiB)},
+               },
+               RuntimeConstraints: arvados.RuntimeConstraints{
+                       VCPUs:        2,
+                       RAM:          987654321,
+                       KeepCacheRAM: 123456789,
+               },
+       })
+       c.Assert(err, check.IsNil)
+       c.Assert(best, check.HasLen, 5)
+       c.Check(best[0].Name, check.Equals, "best") // best price is $2
+       c.Check(best[1].Name, check.Equals, "best+1")
+       c.Check(best[2].Name, check.Equals, "best+2")
+       c.Check(best[3].Name, check.Equals, "best+3")
+       c.Check(best[4].Name, check.Equals, "best+5") // max price is $2 * 1.5 = $3
 }
 
 func (*NodeSizeSuite) TestChooseWithBlobBuffersOverhead(c *check.C) {
@@ -121,7 +160,8 @@ func (*NodeSizeSuite) TestChooseWithBlobBuffersOverhead(c *check.C) {
                },
        })
        c.Check(err, check.IsNil)
-       c.Check(best.Name, check.Equals, "best")
+       c.Assert(best, check.HasLen, 1)
+       c.Check(best[0].Name, check.Equals, "best")
 }
 
 func (*NodeSizeSuite) TestChoosePreemptible(c *check.C) {
@@ -145,11 +185,12 @@ func (*NodeSizeSuite) TestChoosePreemptible(c *check.C) {
                },
        })
        c.Check(err, check.IsNil)
-       c.Check(best.Name, check.Equals, "best")
-       c.Check(best.RAM >= 1234567890, check.Equals, true)
-       c.Check(best.VCPUs >= 2, check.Equals, true)
-       c.Check(best.Scratch >= 2*GiB, check.Equals, true)
-       c.Check(best.Preemptible, check.Equals, true)
+       c.Assert(best, check.HasLen, 1)
+       c.Check(best[0].Name, check.Equals, "best")
+       c.Check(best[0].RAM >= 1234567890, check.Equals, true)
+       c.Check(best[0].VCPUs >= 2, check.Equals, true)
+       c.Check(best[0].Scratch >= 2*GiB, check.Equals, true)
+       c.Check(best[0].Preemptible, check.Equals, true)
 }
 
 func (*NodeSizeSuite) TestScratchForDockerImage(c *check.C) {
@@ -252,9 +293,10 @@ func (*NodeSizeSuite) TestChooseGPU(c *check.C) {
                                CUDA:         tc.CUDA,
                        },
                })
-               if best.Name != "" {
+               if len(best) > 0 {
                        c.Check(err, check.IsNil)
-                       c.Check(best.Name, check.Equals, tc.SelectedInstance)
+                       c.Assert(best, check.HasLen, 1)
+                       c.Check(best[0].Name, check.Equals, tc.SelectedInstance)
                } else {
                        c.Check(err, check.Not(check.IsNil))
                }
index 78f8c804e20087aa2b49ff307e91dfb0681efcb9..6e56bd8c40962e9f9c6c595cf0ef4cc550fa82eb 100644 (file)
@@ -34,6 +34,7 @@ type WorkerPool interface {
        Running() map[string]time.Time
        Unallocated() map[arvados.InstanceType]int
        CountWorkers() map[worker.State]int
+       AtCapacity(arvados.InstanceType) bool
        AtQuota() bool
        Create(arvados.InstanceType) bool
        Shutdown(arvados.InstanceType) bool
index b8158579a3a3a1b30a0eccc80b421f8abe8bdecf..d2709722956cbf22b0b909c8e4fcf508a2ff4c0a 100644 (file)
@@ -5,6 +5,7 @@
 package scheduler
 
 import (
+       "fmt"
        "sort"
        "time"
 
@@ -15,14 +16,69 @@ import (
 
 var quietAfter503 = time.Minute
 
+type QueueEnt struct {
+       container.QueueEnt
+
+       // Human-readable scheduling status as of the last scheduling
+       // iteration.
+       SchedulingStatus string `json:"scheduling_status"`
+}
+
+const (
+       schedStatusPreparingRuntimeEnvironment = "preparing runtime environment"
+       schedStatusPriorityZero                = "not scheduling: priority 0" // ", state X" appended at runtime
+       schedStatusContainerLimitReached       = "not starting: supervisor container limit has been reached"
+       schedStatusWaitingForPreviousAttempt   = "waiting for previous attempt to exit"
+       schedStatusWaitingNewInstance          = "waiting for new instance to be ready"
+       schedStatusWaitingInstanceType         = "waiting for suitable instance type to become available" // ": queue position X" appended at runtime
+       schedStatusWaitingCloudResources       = "waiting for cloud resources"
+       schedStatusWaitingClusterCapacity      = "waiting while cluster is running at capacity" // ": queue position X" appended at runtime
+)
+
+// Queue returns the sorted queue from the last scheduling iteration.
+func (sch *Scheduler) Queue() []QueueEnt {
+       ents, _ := sch.lastQueue.Load().([]QueueEnt)
+       return ents
+}
+
 func (sch *Scheduler) runQueue() {
+       running := sch.pool.Running()
+       unalloc := sch.pool.Unallocated()
+
+       totalInstances := 0
+       for _, n := range sch.pool.CountWorkers() {
+               totalInstances += n
+       }
+
        unsorted, _ := sch.queue.Entries()
-       sorted := make([]container.QueueEnt, 0, len(unsorted))
+       sorted := make([]QueueEnt, 0, len(unsorted))
        for _, ent := range unsorted {
-               sorted = append(sorted, ent)
+               sorted = append(sorted, QueueEnt{QueueEnt: ent})
        }
        sort.Slice(sorted, func(i, j int) bool {
-               if pi, pj := sorted[i].Container.Priority, sorted[j].Container.Priority; pi != pj {
+               _, irunning := running[sorted[i].Container.UUID]
+               _, jrunning := running[sorted[j].Container.UUID]
+               if irunning != jrunning {
+                       // Ensure the "tryrun" loop (see below) sees
+                       // already-scheduled containers first, to
+                       // ensure existing supervisor containers are
+                       // properly counted before we decide whether
+                       // we have room for new ones.
+                       return irunning
+               }
+               ilocked := sorted[i].Container.State == arvados.ContainerStateLocked
+               jlocked := sorted[j].Container.State == arvados.ContainerStateLocked
+               if ilocked != jlocked {
+                       // Give precedence to containers that we have
+                       // already locked, even if higher-priority
+                       // containers have since arrived in the
+                       // queue. This avoids undesirable queue churn
+                       // effects including extra lock/unlock cycles
+                       // and bringing up new instances and quickly
+                       // shutting them down to make room for
+                       // different instance sizes.
+                       return ilocked
+               } else if pi, pj := sorted[i].Container.Priority, sorted[j].Container.Priority; pi != pj {
                        return pi > pj
                } else {
                        // When containers have identical priority,
@@ -34,9 +90,6 @@ func (sch *Scheduler) runQueue() {
                }
        })
 
-       running := sch.pool.Running()
-       unalloc := sch.pool.Unallocated()
-
        if t := sch.client.Last503(); t.After(sch.last503time) {
                // API has sent an HTTP 503 response since last time
                // we checked. Use current #containers - 1 as
@@ -67,8 +120,54 @@ func (sch *Scheduler) runQueue() {
        } else {
                sch.mLast503Time.Set(float64(sch.last503time.Unix()))
        }
+       if sch.maxInstances > 0 && sch.maxConcurrency > sch.maxInstances {
+               sch.maxConcurrency = sch.maxInstances
+       }
+       if sch.instancesWithinQuota > 0 && sch.instancesWithinQuota < totalInstances {
+               // Evidently it is possible to run this many
+               // instances, so raise our estimate.
+               sch.instancesWithinQuota = totalInstances
+       }
+       if sch.pool.AtQuota() {
+               // Consider current workload to be the maximum
+               // allowed, for the sake of reporting metrics and
+               // calculating max supervisors.
+               //
+               // Now that sch.maxConcurrency is set, we will only
+               // raise it past len(running) by 10%.  This helps
+               // avoid running an inappropriate number of
+               // supervisors when we reach the cloud-imposed quota
+               // (which may be based on # CPUs etc) long before the
+               // configured MaxInstances.
+               if sch.maxConcurrency == 0 || sch.maxConcurrency > totalInstances {
+                       if totalInstances == 0 {
+                               sch.maxConcurrency = 1
+                       } else {
+                               sch.maxConcurrency = totalInstances
+                       }
+               }
+               sch.instancesWithinQuota = totalInstances
+       } else if sch.instancesWithinQuota > 0 && sch.maxConcurrency > sch.instancesWithinQuota+1 {
+               // Once we've hit a quota error and started tracking
+               // instancesWithinQuota (i.e., it's not zero), we
+               // avoid exceeding that known-working level by more
+               // than 1.
+               //
+               // If we don't do this, we risk entering a pattern of
+               // repeatedly locking several containers, hitting
+               // quota again, and unlocking them again each time the
+               // driver stops reporting AtQuota, which tends to use
+               // up the max lock/unlock cycles on the next few
+               // containers in the queue, and cause them to fail.
+               sch.maxConcurrency = sch.instancesWithinQuota + 1
+       }
        sch.mMaxContainerConcurrency.Set(float64(sch.maxConcurrency))
 
+       maxSupervisors := int(float64(sch.maxConcurrency) * sch.supervisorFraction)
+       if maxSupervisors < 1 && sch.supervisorFraction > 0 && sch.maxConcurrency > 0 {
+               maxSupervisors = 1
+       }
+
        sch.logger.WithFields(logrus.Fields{
                "Containers":     len(sorted),
                "Processes":      len(running),
@@ -76,7 +175,9 @@ func (sch *Scheduler) runQueue() {
        }).Debug("runQueue")
 
        dontstart := map[arvados.InstanceType]bool{}
-       var overquota []container.QueueEnt // entries that are unmappable because of worker pool quota
+       var atcapacity = map[string]bool{} // ProviderTypes reported as AtCapacity during this runQueue() invocation
+       var overquota []QueueEnt           // entries that are unmappable because of worker pool quota
+       var overmaxsuper []QueueEnt        // unmappable because max supervisors (these are not included in overquota)
        var containerAllocatedWorkerBootingCount int
 
        // trying is #containers running + #containers we're trying to
@@ -84,53 +185,116 @@ func (sch *Scheduler) runQueue() {
        // reaches the dynamic maxConcurrency limit.
        trying := len(running)
 
+       qpos := 0
        supervisors := 0
 
 tryrun:
-       for i, ctr := range sorted {
-               ctr, it := ctr.Container, ctr.InstanceType
+       for i, ent := range sorted {
+               ctr, types := ent.Container, ent.InstanceTypes
                logger := sch.logger.WithFields(logrus.Fields{
                        "ContainerUUID": ctr.UUID,
-                       "InstanceType":  it.Name,
                })
                if ctr.SchedulingParameters.Supervisor {
                        supervisors += 1
-                       if sch.maxSupervisors > 0 && supervisors > sch.maxSupervisors {
-                               continue
+               }
+               if _, running := running[ctr.UUID]; running {
+                       if ctr.State == arvados.ContainerStateQueued || ctr.State == arvados.ContainerStateLocked {
+                               sorted[i].SchedulingStatus = schedStatusPreparingRuntimeEnvironment
                        }
+                       continue
+               }
+               if ctr.Priority < 1 {
+                       sorted[i].SchedulingStatus = schedStatusPriorityZero + ", state " + string(ctr.State)
+                       continue
                }
-               if _, running := running[ctr.UUID]; running || ctr.Priority < 1 {
+               if ctr.SchedulingParameters.Supervisor && maxSupervisors > 0 && supervisors > maxSupervisors {
+                       overmaxsuper = append(overmaxsuper, sorted[i])
+                       sorted[i].SchedulingStatus = schedStatusContainerLimitReached
                        continue
                }
+               // If we have unalloc instances of any of the eligible
+               // instance types, unallocOK is true and unallocType
+               // is the lowest-cost type.
+               var unallocOK bool
+               var unallocType arvados.InstanceType
+               for _, it := range types {
+                       if unalloc[it] > 0 {
+                               unallocOK = true
+                               unallocType = it
+                               break
+                       }
+               }
+               // If the pool is not reporting AtCapacity for any of
+               // the eligible instance types, availableOK is true
+               // and availableType is the lowest-cost type.
+               var availableOK bool
+               var availableType arvados.InstanceType
+               for _, it := range types {
+                       if atcapacity[it.ProviderType] {
+                               continue
+                       } else if sch.pool.AtCapacity(it) {
+                               atcapacity[it.ProviderType] = true
+                               continue
+                       } else {
+                               availableOK = true
+                               availableType = it
+                               break
+                       }
+               }
                switch ctr.State {
                case arvados.ContainerStateQueued:
                        if sch.maxConcurrency > 0 && trying >= sch.maxConcurrency {
                                logger.Tracef("not locking: already at maxConcurrency %d", sch.maxConcurrency)
-                               overquota = sorted[i:]
-                               break tryrun
+                               continue
                        }
                        trying++
-                       if unalloc[it] < 1 && sch.pool.AtQuota() {
-                               logger.Trace("not locking: AtQuota and no unalloc workers")
+                       if !unallocOK && sch.pool.AtQuota() {
+                               logger.Trace("not starting: AtQuota and no unalloc workers")
                                overquota = sorted[i:]
                                break tryrun
                        }
+                       if !unallocOK && !availableOK {
+                               logger.Trace("not locking: AtCapacity and no unalloc workers")
+                               continue
+                       }
                        if sch.pool.KillContainer(ctr.UUID, "about to lock") {
                                logger.Info("not locking: crunch-run process from previous attempt has not exited")
                                continue
                        }
                        go sch.lockContainer(logger, ctr.UUID)
-                       unalloc[it]--
+                       unalloc[unallocType]--
                case arvados.ContainerStateLocked:
                        if sch.maxConcurrency > 0 && trying >= sch.maxConcurrency {
-                               logger.Debugf("not starting: already at maxConcurrency %d", sch.maxConcurrency)
-                               overquota = sorted[i:]
-                               break tryrun
+                               logger.Tracef("not starting: already at maxConcurrency %d", sch.maxConcurrency)
+                               continue
                        }
                        trying++
-                       if unalloc[it] > 0 {
-                               unalloc[it]--
-                       } else if sch.pool.AtQuota() {
+                       if unallocOK {
+                               // We have a suitable instance type,
+                               // so mark it as allocated, and try to
+                               // start the container.
+                               unalloc[unallocType]--
+                               logger = logger.WithField("InstanceType", unallocType.Name)
+                               if dontstart[unallocType] {
+                                       // We already tried & failed to start
+                                       // a higher-priority container on the
+                                       // same instance type. Don't let this
+                                       // one sneak in ahead of it.
+                               } else if sch.pool.KillContainer(ctr.UUID, "about to start") {
+                                       sorted[i].SchedulingStatus = schedStatusWaitingForPreviousAttempt
+                                       logger.Info("not restarting yet: crunch-run process from previous attempt has not exited")
+                               } else if sch.pool.StartContainer(unallocType, ctr) {
+                                       sorted[i].SchedulingStatus = schedStatusPreparingRuntimeEnvironment
+                                       logger.Trace("StartContainer => true")
+                               } else {
+                                       sorted[i].SchedulingStatus = schedStatusWaitingNewInstance
+                                       logger.Trace("StartContainer => false")
+                                       containerAllocatedWorkerBootingCount += 1
+                                       dontstart[unallocType] = true
+                               }
+                               continue
+                       }
+                       if sch.pool.AtQuota() {
                                // Don't let lower-priority containers
                                // starve this one by using keeping
                                // idle workers alive on different
@@ -138,60 +302,104 @@ tryrun:
                                logger.Trace("overquota")
                                overquota = sorted[i:]
                                break tryrun
-                       } else if sch.pool.Create(it) {
-                               // Success. (Note pool.Create works
-                               // asynchronously and does its own
-                               // logging about the eventual outcome,
-                               // so we don't need to.)
-                               logger.Info("creating new instance")
-                       } else {
+                       }
+                       if !availableOK {
+                               // Continue trying lower-priority
+                               // containers in case they can run on
+                               // different instance types that are
+                               // available.
+                               //
+                               // The local "atcapacity" cache helps
+                               // when the pool's flag resets after
+                               // we look at container A but before
+                               // we look at lower-priority container
+                               // B. In that case we want to run
+                               // container A on the next call to
+                               // runQueue(), rather than run
+                               // container B now.
+                               qpos++
+                               sorted[i].SchedulingStatus = schedStatusWaitingInstanceType + fmt.Sprintf(": queue position %d", qpos)
+                               logger.Trace("all eligible types at capacity")
+                               continue
+                       }
+                       logger = logger.WithField("InstanceType", availableType.Name)
+                       if !sch.pool.Create(availableType) {
                                // Failed despite not being at quota,
-                               // e.g., cloud ops throttled.  TODO:
-                               // avoid getting starved here if
-                               // instances of a specific type always
-                               // fail.
+                               // e.g., cloud ops throttled.
                                logger.Trace("pool declined to create new instance")
                                continue
                        }
-
-                       if dontstart[it] {
-                               // We already tried & failed to start
-                               // a higher-priority container on the
-                               // same instance type. Don't let this
-                               // one sneak in ahead of it.
-                       } else if sch.pool.KillContainer(ctr.UUID, "about to start") {
-                               logger.Info("not restarting yet: crunch-run process from previous attempt has not exited")
-                       } else if sch.pool.StartContainer(it, ctr) {
-                               logger.Trace("StartContainer => true")
-                               // Success.
-                       } else {
-                               logger.Trace("StartContainer => false")
-                               containerAllocatedWorkerBootingCount += 1
-                               dontstart[it] = true
-                       }
+                       // Success. (Note pool.Create works
+                       // asynchronously and does its own logging
+                       // about the eventual outcome, so we don't
+                       // need to.)
+                       sorted[i].SchedulingStatus = schedStatusWaitingNewInstance
+                       logger.Info("creating new instance")
+                       // Don't bother trying to start the container
+                       // yet -- obviously the instance will take
+                       // some time to boot and become ready.
+                       containerAllocatedWorkerBootingCount += 1
+                       dontstart[availableType] = true
                }
        }
 
        sch.mContainersAllocatedNotStarted.Set(float64(containerAllocatedWorkerBootingCount))
-       sch.mContainersNotAllocatedOverQuota.Set(float64(len(overquota)))
+       sch.mContainersNotAllocatedOverQuota.Set(float64(len(overquota) + len(overmaxsuper)))
 
-       if len(overquota) > 0 {
+       var qreason string
+       if sch.pool.AtQuota() {
+               qreason = schedStatusWaitingCloudResources
+       } else {
+               qreason = schedStatusWaitingClusterCapacity
+       }
+       for i, ent := range sorted {
+               if ent.SchedulingStatus == "" && (ent.Container.State == arvados.ContainerStateQueued || ent.Container.State == arvados.ContainerStateLocked) {
+                       qpos++
+                       sorted[i].SchedulingStatus = fmt.Sprintf("%s: queue position %d", qreason, qpos)
+               }
+       }
+       sch.lastQueue.Store(sorted)
+
+       if len(overquota)+len(overmaxsuper) > 0 {
                // Unlock any containers that are unmappable while
                // we're at quota (but if they have already been
                // scheduled and they're loading docker images etc.,
                // let them run).
-               for _, ctr := range overquota {
+               var unlock []QueueEnt
+               unlock = append(unlock, overmaxsuper...)
+               if totalInstances > 0 && len(overquota) > 1 {
+                       // We don't unlock the next-in-line container
+                       // when at quota.  This avoids a situation
+                       // where our "at quota" state expires, we lock
+                       // the next container and try to create an
+                       // instance, the cloud provider still returns
+                       // a quota error, we unlock the container, and
+                       // we repeat this until the container reaches
+                       // its limit of lock/unlock cycles.
+                       unlock = append(unlock, overquota[1:]...)
+               } else {
+                       // However, if totalInstances is 0 and we're
+                       // still getting quota errors, then the
+                       // next-in-line container is evidently not
+                       // possible to run, so we should let it
+                       // exhaust its lock/unlock cycles and
+                       // eventually cancel, to avoid starvation.
+                       unlock = append(unlock, overquota...)
+               }
+               for _, ctr := range unlock {
                        ctr := ctr.Container
                        _, toolate := running[ctr.UUID]
                        if ctr.State == arvados.ContainerStateLocked && !toolate {
                                logger := sch.logger.WithField("ContainerUUID", ctr.UUID)
-                               logger.Debug("unlock because pool capacity is used by higher priority containers")
+                               logger.Info("unlock because pool capacity is used by higher priority containers")
                                err := sch.queue.Unlock(ctr.UUID)
                                if err != nil {
                                        logger.WithError(err).Warn("error unlocking")
                                }
                        }
                }
+       }
+       if len(overquota) > 0 {
                // Shut down idle workers that didn't get any
                // containers mapped onto them before we hit quota.
                for it, n := range unalloc {
index 3278c7de69333926dfea8363b3f41626be294a21..e4a05daba535f4a2e50252085f64c447b1476bcf 100644 (file)
@@ -29,19 +29,15 @@ var (
        }()
 )
 
-type stubQuotaError struct {
-       error
-}
-
-func (stubQuotaError) IsQuotaError() bool { return true }
-
 type stubPool struct {
        notify    <-chan struct{}
        unalloc   map[arvados.InstanceType]int // idle+booting+unknown
+       busy      map[arvados.InstanceType]int
        idle      map[arvados.InstanceType]int
        unknown   map[arvados.InstanceType]int
        running   map[string]time.Time
        quota     int
+       capacity  map[string]int
        canCreate int
        creates   []arvados.InstanceType
        starts    []string
@@ -61,6 +57,20 @@ func (p *stubPool) AtQuota() bool {
        }
        return n >= p.quota
 }
+func (p *stubPool) AtCapacity(it arvados.InstanceType) bool {
+       supply, ok := p.capacity[it.ProviderType]
+       if !ok {
+               return false
+       }
+       for _, existing := range []map[arvados.InstanceType]int{p.unalloc, p.busy} {
+               for eit, n := range existing {
+                       if eit.ProviderType == it.ProviderType {
+                               supply -= n
+                       }
+               }
+       }
+       return supply < 1
+}
 func (p *stubPool) Subscribe() <-chan struct{}  { return p.notify }
 func (p *stubPool) Unsubscribe(<-chan struct{}) {}
 func (p *stubPool) Running() map[string]time.Time {
@@ -122,14 +132,15 @@ func (p *stubPool) StartContainer(it arvados.InstanceType, ctr arvados.Container
        if p.idle[it] == 0 {
                return false
        }
+       p.busy[it]++
        p.idle[it]--
        p.unalloc[it]--
        p.running[ctr.UUID] = time.Time{}
        return true
 }
 
-func chooseType(ctr *arvados.Container) (arvados.InstanceType, error) {
-       return test.InstanceType(ctr.RuntimeConstraints.VCPUs), nil
+func chooseType(ctr *arvados.Container) ([]arvados.InstanceType, error) {
+       return []arvados.InstanceType{test.InstanceType(ctr.RuntimeConstraints.VCPUs)}, nil
 }
 
 var _ = check.Suite(&SchedulerSuite{})
@@ -192,10 +203,11 @@ func (*SchedulerSuite) TestUseIdleWorkers(c *check.C) {
                        test.InstanceType(1): 1,
                        test.InstanceType(2): 2,
                },
+               busy:      map[arvados.InstanceType]int{},
                running:   map[string]time.Time{},
                canCreate: 0,
        }
-       New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0).runQueue()
+       New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0).runQueue()
        c.Check(pool.creates, check.DeepEquals, []arvados.InstanceType{test.InstanceType(1), test.InstanceType(1), test.InstanceType(1)})
        c.Check(pool.starts, check.DeepEquals, []string{test.ContainerUUID(4)})
        c.Check(pool.running, check.HasLen, 1)
@@ -242,12 +254,13 @@ func (*SchedulerSuite) TestShutdownAtQuota(c *check.C) {
                        idle: map[arvados.InstanceType]int{
                                test.InstanceType(2): 2,
                        },
+                       busy:      map[arvados.InstanceType]int{},
                        running:   map[string]time.Time{},
                        creates:   []arvados.InstanceType{},
                        starts:    []string{},
                        canCreate: 0,
                }
-               sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0)
+               sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
                sch.sync()
                sch.runQueue()
                sch.sync()
@@ -255,12 +268,12 @@ func (*SchedulerSuite) TestShutdownAtQuota(c *check.C) {
                case 1, 2:
                        // Can't create a type3 node for ctr3, so we
                        // shutdown an unallocated node (type2), and
-                       // unlock both containers.
+                       // unlock the 2nd-in-line container, but not
+                       // the 1st-in-line container.
                        c.Check(pool.starts, check.HasLen, 0)
                        c.Check(pool.shutdowns, check.Equals, 1)
                        c.Check(pool.creates, check.HasLen, 0)
                        c.Check(queue.StateChanges(), check.DeepEquals, []test.QueueStateChange{
-                               {UUID: test.ContainerUUID(3), From: "Locked", To: "Queued"},
                                {UUID: test.ContainerUUID(2), From: "Locked", To: "Queued"},
                        })
                case 3:
@@ -278,6 +291,303 @@ func (*SchedulerSuite) TestShutdownAtQuota(c *check.C) {
        }
 }
 
+// If pool.AtCapacity(it) is true for one instance type, try running a
+// lower-priority container that uses a different node type.  Don't
+// lock/unlock/start any container that requires the affected instance
+// type.
+func (*SchedulerSuite) TestInstanceCapacity(c *check.C) {
+       ctx := ctxlog.Context(context.Background(), ctxlog.TestLogger(c))
+
+       queue := test.Queue{
+               ChooseType: chooseType,
+               Containers: []arvados.Container{
+                       {
+                               UUID:     test.ContainerUUID(1),
+                               Priority: 1,
+                               State:    arvados.ContainerStateLocked,
+                               RuntimeConstraints: arvados.RuntimeConstraints{
+                                       VCPUs: 1,
+                                       RAM:   1 << 30,
+                               },
+                       },
+                       {
+                               UUID:     test.ContainerUUID(2),
+                               Priority: 2,
+                               State:    arvados.ContainerStateQueued,
+                               RuntimeConstraints: arvados.RuntimeConstraints{
+                                       VCPUs: 4,
+                                       RAM:   4 << 30,
+                               },
+                       },
+                       {
+                               UUID:     test.ContainerUUID(3),
+                               Priority: 3,
+                               State:    arvados.ContainerStateLocked,
+                               RuntimeConstraints: arvados.RuntimeConstraints{
+                                       VCPUs: 4,
+                                       RAM:   4 << 30,
+                               },
+                       },
+                       {
+                               UUID:     test.ContainerUUID(4),
+                               Priority: 4,
+                               State:    arvados.ContainerStateLocked,
+                               RuntimeConstraints: arvados.RuntimeConstraints{
+                                       VCPUs: 4,
+                                       RAM:   4 << 30,
+                               },
+                       },
+               },
+       }
+       queue.Update()
+       pool := stubPool{
+               quota:    99,
+               capacity: map[string]int{test.InstanceType(4).ProviderType: 1},
+               unalloc: map[arvados.InstanceType]int{
+                       test.InstanceType(4): 1,
+               },
+               idle: map[arvados.InstanceType]int{
+                       test.InstanceType(4): 1,
+               },
+               busy:      map[arvados.InstanceType]int{},
+               running:   map[string]time.Time{},
+               creates:   []arvados.InstanceType{},
+               starts:    []string{},
+               canCreate: 99,
+       }
+       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
+       sch.sync()
+       sch.runQueue()
+       sch.sync()
+
+       // Start container4, but then pool reports AtCapacity for
+       // type4, so we skip trying to create an instance for
+       // container3, skip locking container2, but do try to create a
+       // type1 instance for container1.
+       c.Check(pool.starts, check.DeepEquals, []string{test.ContainerUUID(4)})
+       c.Check(pool.shutdowns, check.Equals, 0)
+       c.Check(pool.creates, check.DeepEquals, []arvados.InstanceType{test.InstanceType(1)})
+       c.Check(queue.StateChanges(), check.HasLen, 0)
+}
+
+// Don't unlock containers or shutdown unalloc (booting/idle) nodes
+// just because some 503 errors caused us to reduce maxConcurrency
+// below the current load level.
+//
+// We expect to raise maxConcurrency soon when we stop seeing 503s. If
+// that doesn't happen soon, the idle timeout will take care of the
+// excess nodes.
+func (*SchedulerSuite) TestIdleIn503QuietPeriod(c *check.C) {
+       ctx := ctxlog.Context(context.Background(), ctxlog.TestLogger(c))
+       queue := test.Queue{
+               ChooseType: chooseType,
+               Containers: []arvados.Container{
+                       // scheduled on an instance (but not Running yet)
+                       {
+                               UUID:     test.ContainerUUID(1),
+                               Priority: 1000,
+                               State:    arvados.ContainerStateLocked,
+                               RuntimeConstraints: arvados.RuntimeConstraints{
+                                       VCPUs: 2,
+                                       RAM:   2 << 30,
+                               },
+                       },
+                       // not yet scheduled
+                       {
+                               UUID:     test.ContainerUUID(2),
+                               Priority: 1000,
+                               State:    arvados.ContainerStateLocked,
+                               RuntimeConstraints: arvados.RuntimeConstraints{
+                                       VCPUs: 2,
+                                       RAM:   2 << 30,
+                               },
+                       },
+                       // scheduled on an instance (but not Running yet)
+                       {
+                               UUID:     test.ContainerUUID(3),
+                               Priority: 1000,
+                               State:    arvados.ContainerStateLocked,
+                               RuntimeConstraints: arvados.RuntimeConstraints{
+                                       VCPUs: 3,
+                                       RAM:   3 << 30,
+                               },
+                       },
+                       // not yet scheduled
+                       {
+                               UUID:     test.ContainerUUID(4),
+                               Priority: 1000,
+                               State:    arvados.ContainerStateLocked,
+                               RuntimeConstraints: arvados.RuntimeConstraints{
+                                       VCPUs: 3,
+                                       RAM:   3 << 30,
+                               },
+                       },
+                       // not yet locked
+                       {
+                               UUID:     test.ContainerUUID(5),
+                               Priority: 1000,
+                               State:    arvados.ContainerStateQueued,
+                               RuntimeConstraints: arvados.RuntimeConstraints{
+                                       VCPUs: 3,
+                                       RAM:   3 << 30,
+                               },
+                       },
+               },
+       }
+       queue.Update()
+       pool := stubPool{
+               quota: 16,
+               unalloc: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 2,
+                       test.InstanceType(3): 2,
+               },
+               idle: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 1,
+                       test.InstanceType(3): 1,
+               },
+               busy: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 1,
+                       test.InstanceType(3): 1,
+               },
+               running: map[string]time.Time{
+                       test.ContainerUUID(1): {},
+                       test.ContainerUUID(3): {},
+               },
+               creates:   []arvados.InstanceType{},
+               starts:    []string{},
+               canCreate: 0,
+       }
+       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
+       sch.last503time = time.Now()
+       sch.maxConcurrency = 3
+       sch.sync()
+       sch.runQueue()
+       sch.sync()
+
+       c.Check(pool.starts, check.DeepEquals, []string{test.ContainerUUID(2)})
+       c.Check(pool.shutdowns, check.Equals, 0)
+       c.Check(pool.creates, check.HasLen, 0)
+       c.Check(queue.StateChanges(), check.HasLen, 0)
+}
+
+// If we somehow have more supervisor containers in Locked state than
+// we should (e.g., config changed since they started), and some
+// appropriate-sized instances booting up, unlock the excess
+// supervisor containers, but let the instances keep booting.
+func (*SchedulerSuite) TestUnlockExcessSupervisors(c *check.C) {
+       ctx := ctxlog.Context(context.Background(), ctxlog.TestLogger(c))
+       queue := test.Queue{
+               ChooseType: chooseType,
+       }
+       for i := 1; i <= 6; i++ {
+               queue.Containers = append(queue.Containers, arvados.Container{
+                       UUID:     test.ContainerUUID(i),
+                       Priority: int64(1000 - i),
+                       State:    arvados.ContainerStateLocked,
+                       RuntimeConstraints: arvados.RuntimeConstraints{
+                               VCPUs: 2,
+                               RAM:   2 << 30,
+                       },
+                       SchedulingParameters: arvados.SchedulingParameters{
+                               Supervisor: true,
+                       },
+               })
+       }
+       queue.Update()
+       pool := stubPool{
+               quota: 16,
+               unalloc: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 2,
+               },
+               idle: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 1,
+               },
+               busy: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 4,
+               },
+               running: map[string]time.Time{
+                       test.ContainerUUID(1): {},
+                       test.ContainerUUID(2): {},
+                       test.ContainerUUID(3): {},
+                       test.ContainerUUID(4): {},
+               },
+               creates:   []arvados.InstanceType{},
+               starts:    []string{},
+               canCreate: 0,
+       }
+       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 8, 0.5)
+       sch.sync()
+       sch.runQueue()
+       sch.sync()
+
+       c.Check(pool.starts, check.DeepEquals, []string{})
+       c.Check(pool.shutdowns, check.Equals, 0)
+       c.Check(pool.creates, check.HasLen, 0)
+       c.Check(queue.StateChanges(), check.DeepEquals, []test.QueueStateChange{
+               {UUID: test.ContainerUUID(5), From: "Locked", To: "Queued"},
+               {UUID: test.ContainerUUID(6), From: "Locked", To: "Queued"},
+       })
+}
+
+// Assuming we're not at quota, don't try to shutdown idle nodes
+// merely because we have more queued/locked supervisor containers
+// than MaxSupervisors -- it won't help.
+func (*SchedulerSuite) TestExcessSupervisors(c *check.C) {
+       ctx := ctxlog.Context(context.Background(), ctxlog.TestLogger(c))
+       queue := test.Queue{
+               ChooseType: chooseType,
+       }
+       for i := 1; i <= 8; i++ {
+               queue.Containers = append(queue.Containers, arvados.Container{
+                       UUID:     test.ContainerUUID(i),
+                       Priority: int64(1000 + i),
+                       State:    arvados.ContainerStateQueued,
+                       RuntimeConstraints: arvados.RuntimeConstraints{
+                               VCPUs: 2,
+                               RAM:   2 << 30,
+                       },
+                       SchedulingParameters: arvados.SchedulingParameters{
+                               Supervisor: true,
+                       },
+               })
+       }
+       for i := 2; i < 4; i++ {
+               queue.Containers[i].State = arvados.ContainerStateLocked
+       }
+       for i := 4; i < 6; i++ {
+               queue.Containers[i].State = arvados.ContainerStateRunning
+       }
+       queue.Update()
+       pool := stubPool{
+               quota: 16,
+               unalloc: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 2,
+               },
+               idle: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 1,
+               },
+               busy: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 2,
+               },
+               running: map[string]time.Time{
+                       test.ContainerUUID(5): {},
+                       test.ContainerUUID(6): {},
+               },
+               creates:   []arvados.InstanceType{},
+               starts:    []string{},
+               canCreate: 0,
+       }
+       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 8, 0.5)
+       sch.sync()
+       sch.runQueue()
+       sch.sync()
+
+       c.Check(pool.starts, check.HasLen, 2)
+       c.Check(pool.shutdowns, check.Equals, 0)
+       c.Check(pool.creates, check.HasLen, 0)
+       c.Check(queue.StateChanges(), check.HasLen, 0)
+}
+
 // Don't flap lock/unlock when equal-priority containers compete for
 // limited workers.
 //
@@ -313,12 +623,13 @@ func (*SchedulerSuite) TestEqualPriorityContainers(c *check.C) {
                idle: map[arvados.InstanceType]int{
                        test.InstanceType(3): 2,
                },
+               busy:      map[arvados.InstanceType]int{},
                running:   map[string]time.Time{},
                creates:   []arvados.InstanceType{},
                starts:    []string{},
                canCreate: 0,
        }
-       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0)
+       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
        for i := 0; i < 30; i++ {
                sch.runQueue()
                sch.sync()
@@ -351,6 +662,7 @@ func (*SchedulerSuite) TestStartWhileCreating(c *check.C) {
                        test.InstanceType(1): 1,
                        test.InstanceType(2): 1,
                },
+               busy:      map[arvados.InstanceType]int{},
                running:   map[string]time.Time{},
                canCreate: 4,
        }
@@ -420,7 +732,7 @@ func (*SchedulerSuite) TestStartWhileCreating(c *check.C) {
                },
        }
        queue.Update()
-       New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0).runQueue()
+       New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0).runQueue()
        c.Check(pool.creates, check.DeepEquals, []arvados.InstanceType{test.InstanceType(2), test.InstanceType(1)})
        c.Check(pool.starts, check.DeepEquals, []string{uuids[6], uuids[5], uuids[3], uuids[2]})
        running := map[string]bool{}
@@ -444,6 +756,9 @@ func (*SchedulerSuite) TestKillNonexistentContainer(c *check.C) {
                idle: map[arvados.InstanceType]int{
                        test.InstanceType(2): 0,
                },
+               busy: map[arvados.InstanceType]int{
+                       test.InstanceType(2): 1,
+               },
                running: map[string]time.Time{
                        test.ContainerUUID(2): {},
                },
@@ -464,7 +779,7 @@ func (*SchedulerSuite) TestKillNonexistentContainer(c *check.C) {
                },
        }
        queue.Update()
-       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0)
+       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
        c.Check(pool.running, check.HasLen, 1)
        sch.sync()
        for deadline := time.Now().Add(time.Second); len(pool.Running()) > 0 && time.Now().Before(deadline); time.Sleep(time.Millisecond) {
@@ -497,7 +812,7 @@ func (*SchedulerSuite) TestContainersMetrics(c *check.C) {
        pool := stubPool{
                unalloc: map[arvados.InstanceType]int{test.InstanceType(1): 1},
        }
-       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0)
+       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
        sch.runQueue()
        sch.updateMetrics()
 
@@ -509,7 +824,7 @@ func (*SchedulerSuite) TestContainersMetrics(c *check.C) {
        // 'over quota' metric will be 1 because no workers are available and canCreate defaults
        // to zero.
        pool = stubPool{}
-       sch = New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0)
+       sch = New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
        sch.runQueue()
        sch.updateMetrics()
 
@@ -540,9 +855,10 @@ func (*SchedulerSuite) TestContainersMetrics(c *check.C) {
        pool = stubPool{
                idle:    map[arvados.InstanceType]int{test.InstanceType(1): 1},
                unalloc: map[arvados.InstanceType]int{test.InstanceType(1): 1},
+               busy:    map[arvados.InstanceType]int{},
                running: map[string]time.Time{},
        }
-       sch = New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0)
+       sch = New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
        sch.runQueue()
        sch.updateMetrics()
 
@@ -613,10 +929,11 @@ func (*SchedulerSuite) TestSkipSupervisors(c *check.C) {
                        test.InstanceType(1): 4,
                        test.InstanceType(2): 4,
                },
+               busy:      map[arvados.InstanceType]int{},
                running:   map[string]time.Time{},
                canCreate: 0,
        }
-       New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 2).runQueue()
+       New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 10, 0.2).runQueue()
        c.Check(pool.creates, check.DeepEquals, []arvados.InstanceType(nil))
        c.Check(pool.starts, check.DeepEquals, []string{test.ContainerUUID(4), test.ContainerUUID(3), test.ContainerUUID(1)})
 }
index 21510ee091110768c116e2d77f39be18850eefee..bc6574a21a538134c618320f9e97511b84d9b307 100644 (file)
@@ -9,6 +9,7 @@ package scheduler
 import (
        "context"
        "sync"
+       "sync/atomic"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
@@ -46,22 +47,26 @@ type Scheduler struct {
        stop    chan struct{}
        stopped chan struct{}
 
-       last503time    time.Time // last time API responded 503
-       maxConcurrency int       // dynamic container limit (0 = unlimited), see runQueue()
-       maxSupervisors int       // maximum number of "supervisor" containers (these are containers who's main job is to launch other containers, e.g. workflow runners)
+       last503time          time.Time // last time API responded 503
+       maxConcurrency       int       // dynamic container limit (0 = unlimited), see runQueue()
+       supervisorFraction   float64   // maximum fraction of "supervisor" containers (these are containers who's main job is to launch other containers, e.g. workflow runners)
+       maxInstances         int       // maximum number of instances the pool will bring up (0 = unlimited)
+       instancesWithinQuota int       // max concurrency achieved since last quota error (0 = no quota error yet)
 
        mContainersAllocatedNotStarted   prometheus.Gauge
        mContainersNotAllocatedOverQuota prometheus.Gauge
        mLongestWaitTimeSinceQueue       prometheus.Gauge
        mLast503Time                     prometheus.Gauge
        mMaxContainerConcurrency         prometheus.Gauge
+
+       lastQueue atomic.Value // stores a []QueueEnt
 }
 
 // New returns a new unstarted Scheduler.
 //
 // Any given queue and pool should not be used by more than one
 // scheduler at a time.
-func New(ctx context.Context, client *arvados.Client, queue ContainerQueue, pool WorkerPool, reg *prometheus.Registry, staleLockTimeout, queueUpdateInterval time.Duration, maxSupervisors int) *Scheduler {
+func New(ctx context.Context, client *arvados.Client, queue ContainerQueue, pool WorkerPool, reg *prometheus.Registry, staleLockTimeout, queueUpdateInterval time.Duration, minQuota, maxInstances int, supervisorFraction float64) *Scheduler {
        sch := &Scheduler{
                logger:              ctxlog.FromContext(ctx),
                client:              client,
@@ -74,7 +79,13 @@ func New(ctx context.Context, client *arvados.Client, queue ContainerQueue, pool
                stop:                make(chan struct{}),
                stopped:             make(chan struct{}),
                uuidOp:              map[string]string{},
-               maxSupervisors:      maxSupervisors,
+               supervisorFraction:  supervisorFraction,
+               maxInstances:        maxInstances,
+       }
+       if minQuota > 0 {
+               sch.maxConcurrency = minQuota
+       } else {
+               sch.maxConcurrency = maxInstances
        }
        sch.registerMetrics(reg)
        return sch
@@ -119,6 +130,18 @@ func (sch *Scheduler) registerMetrics(reg *prometheus.Registry) {
                Help:      "Dynamically assigned limit on number of containers scheduled concurrency, set after receiving 503 errors from API.",
        })
        reg.MustRegister(sch.mMaxContainerConcurrency)
+       reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
+               Namespace: "arvados",
+               Subsystem: "dispatchcloud",
+               Name:      "at_quota",
+               Help:      "Flag indicating the cloud driver is reporting an at-quota condition.",
+       }, func() float64 {
+               if sch.pool.AtQuota() {
+                       return 1
+               } else {
+                       return 0
+               }
+       }))
 }
 
 func (sch *Scheduler) updateMetrics() {
@@ -172,14 +195,23 @@ func (sch *Scheduler) run() {
        }
 
        // Keep the queue up to date.
-       poll := time.NewTicker(sch.queueUpdateInterval)
-       defer poll.Stop()
        go func() {
-               for range poll.C {
+               for {
+                       starttime := time.Now()
                        err := sch.queue.Update()
                        if err != nil {
                                sch.logger.Errorf("error updating queue: %s", err)
                        }
+                       // If the previous update took a long time,
+                       // that probably means the server is
+                       // overloaded, so wait that long before doing
+                       // another. Otherwise, wait for the configured
+                       // poll interval.
+                       delay := time.Since(starttime)
+                       if delay < sch.queueUpdateInterval {
+                               delay = sch.queueUpdateInterval
+                       }
+                       time.Sleep(delay)
                }
        }()
 
index 788d946484bd9400060421237453ebef9c56a041..846bb4fc9e90fb5e7ba4a1ae0105d62bedf9023a 100644 (file)
@@ -48,7 +48,7 @@ func (*SchedulerSuite) TestForgetIrrelevantContainers(c *check.C) {
        ents, _ := queue.Entries()
        c.Check(ents, check.HasLen, 1)
 
-       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0)
+       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
        sch.sync()
 
        ents, _ = queue.Entries()
@@ -80,7 +80,7 @@ func (*SchedulerSuite) TestCancelOrphanedContainers(c *check.C) {
        ents, _ := queue.Entries()
        c.Check(ents, check.HasLen, 1)
 
-       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0)
+       sch := New(ctx, arvados.NewClientFromEnv(), &queue, &pool, nil, time.Millisecond, time.Millisecond, 0, 0, 0)
 
        // Sync shouldn't cancel the container because it might be
        // running on the VM with state=="unknown".
index c37169921cf594ac035263ad4c53d4c176c13214..3761c699225079704c26889197913ae3cd3e8eca 100644 (file)
@@ -18,6 +18,8 @@ import (
        "golang.org/x/crypto/ssh"
 )
 
+var ErrNoAddress = errors.New("instance has no address")
+
 // New returns a new Executor, using the given target.
 func New(t cloud.ExecutorTarget) *Executor {
        return &Executor{target: t}
@@ -196,7 +198,7 @@ func (exr *Executor) TargetHostPort() (string, string) {
 func (exr *Executor) setupSSHClient() (*ssh.Client, error) {
        addr := net.JoinHostPort(exr.TargetHostPort())
        if addr == ":" {
-               return nil, errors.New("instance has no address")
+               return nil, ErrNoAddress
        }
        var receivedKey ssh.PublicKey
        client, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{
index b4afeafa82dab3e671f48802646df185d8a64590..95b29fa6aceb3420732dc55aef8a213ce8a756f7 100644 (file)
@@ -6,6 +6,7 @@ package sshexecutor
 
 import (
        "bytes"
+       "errors"
        "fmt"
        "io"
        "io/ioutil"
@@ -146,6 +147,7 @@ func (s *ExecutorSuite) TestExecute(c *check.C) {
                exr.SetTargetPort("0")
                _, _, err = exr.Execute(nil, command, nil)
                c.Check(err, check.ErrorMatches, `.*connection refused.*`)
+               c.Check(errors.As(err, new(*net.OpError)), check.Equals, true)
 
                // Use the test server's listening port.
                exr.SetTargetPort(target.Port())
index fcb2cfb33b31627ca85ccadc2c5705c18f1e055e..ea2b98236ffe34d325fc2c69f96c5be3eea2450e 100644 (file)
@@ -22,7 +22,10 @@ type Queue struct {
 
        // ChooseType will be called for each entry in Containers. It
        // must not be nil.
-       ChooseType func(*arvados.Container) (arvados.InstanceType, error)
+       ChooseType func(*arvados.Container) ([]arvados.InstanceType, error)
+
+       // Mimic railsapi implementation of MaxDispatchAttempts config
+       MaxDispatchAttempts int
 
        Logger logrus.FieldLogger
 
@@ -133,7 +136,15 @@ func (q *Queue) changeState(uuid string, from, to arvados.ContainerState) error
        q.entries[uuid] = ent
        for i, ctr := range q.Containers {
                if ctr.UUID == uuid {
-                       q.Containers[i].State = to
+                       if max := q.MaxDispatchAttempts; max > 0 && ctr.LockCount >= max && to == arvados.ContainerStateQueued {
+                               q.Containers[i].State = arvados.ContainerStateCancelled
+                               q.Containers[i].RuntimeStatus = map[string]interface{}{"error": fmt.Sprintf("Failed to start: lock_count == %d", ctr.LockCount)}
+                       } else {
+                               q.Containers[i].State = to
+                               if to == arvados.ContainerStateLocked {
+                                       q.Containers[i].LockCount++
+                               }
+                       }
                        break
                }
        }
@@ -156,11 +167,12 @@ func (q *Queue) Update() error {
                        ent.Container = ctr
                        upd[ctr.UUID] = ent
                } else {
-                       it, _ := q.ChooseType(&ctr)
+                       types, _ := q.ChooseType(&ctr)
+                       ctr.Mounts = nil
                        upd[ctr.UUID] = container.QueueEnt{
-                               Container:    ctr,
-                               InstanceType: it,
-                               FirstSeenAt:  time.Now(),
+                               Container:     ctr,
+                               InstanceTypes: types,
+                               FirstSeenAt:   time.Now(),
                        }
                }
        }
index 01af8e6d547931d43a5e72725159c72a7e6dd3ac..2265be6e1610015358036f515c50acea5bad5c11 100644 (file)
@@ -20,6 +20,7 @@ import (
        "git.arvados.org/arvados.git/lib/cloud"
        "git.arvados.org/arvados.git/lib/crunchrun"
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
        "golang.org/x/crypto/ssh"
 )
@@ -33,7 +34,10 @@ type StubDriver struct {
        // SetupVM, if set, is called upon creation of each new
        // StubVM. This is the caller's opportunity to customize the
        // VM's error rate and other behaviors.
-       SetupVM func(*StubVM)
+       //
+       // If SetupVM returns an error, that error will be returned to
+       // the caller of Create(), and the new VM will be discarded.
+       SetupVM func(*StubVM) error
 
        // Bugf, if set, is called if a bug is detected in the caller
        // or stub. Typically set to (*check.C)Errorf. If unset,
@@ -45,7 +49,8 @@ type StubDriver struct {
        Queue *Queue
 
        // Frequency of artificially introduced errors on calls to
-       // Destroy. 0=always succeed, 1=always fail.
+       // Create and Destroy. 0=always succeed, 1=always fail.
+       ErrorRateCreate  float64
        ErrorRateDestroy float64
 
        // If Create() or Instances() is called too frequently, return
@@ -53,6 +58,8 @@ type StubDriver struct {
        MinTimeBetweenCreateCalls    time.Duration
        MinTimeBetweenInstancesCalls time.Duration
 
+       QuotaMaxInstances int
+
        // If true, Create and Destroy calls block until Release() is
        // called.
        HoldCloudOps bool
@@ -62,7 +69,7 @@ type StubDriver struct {
 }
 
 // InstanceSet returns a new *StubInstanceSet.
-func (sd *StubDriver) InstanceSet(params json.RawMessage, id cloud.InstanceSetID, _ cloud.SharedResourceTags, logger logrus.FieldLogger) (cloud.InstanceSet, error) {
+func (sd *StubDriver) InstanceSet(params json.RawMessage, id cloud.InstanceSetID, _ cloud.SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (cloud.InstanceSet, error) {
        if sd.holdCloudOps == nil {
                sd.holdCloudOps = make(chan bool)
        }
@@ -108,7 +115,7 @@ type StubInstanceSet struct {
        lastInstanceID     int
 }
 
-func (sis *StubInstanceSet) Create(it arvados.InstanceType, image cloud.ImageID, tags cloud.InstanceTags, cmd cloud.InitCommand, authKey ssh.PublicKey) (cloud.Instance, error) {
+func (sis *StubInstanceSet) Create(it arvados.InstanceType, image cloud.ImageID, tags cloud.InstanceTags, initCommand cloud.InitCommand, authKey ssh.PublicKey) (cloud.Instance, error) {
        if sis.driver.HoldCloudOps {
                sis.driver.holdCloudOps <- true
        }
@@ -120,6 +127,12 @@ func (sis *StubInstanceSet) Create(it arvados.InstanceType, image cloud.ImageID,
        if sis.allowCreateCall.After(time.Now()) {
                return nil, RateLimitError{sis.allowCreateCall}
        }
+       if math_rand.Float64() < sis.driver.ErrorRateCreate {
+               return nil, fmt.Errorf("StubInstanceSet: rand < ErrorRateCreate %f", sis.driver.ErrorRateCreate)
+       }
+       if max := sis.driver.QuotaMaxInstances; max > 0 && len(sis.servers) >= max {
+               return nil, QuotaError{fmt.Errorf("StubInstanceSet: reached QuotaMaxInstances %d", max)}
+       }
        sis.allowCreateCall = time.Now().Add(sis.driver.MinTimeBetweenCreateCalls)
        ak := sis.driver.AuthorizedKeys
        if authKey != nil {
@@ -127,11 +140,11 @@ func (sis *StubInstanceSet) Create(it arvados.InstanceType, image cloud.ImageID,
        }
        sis.lastInstanceID++
        svm := &StubVM{
+               InitCommand:  initCommand,
                sis:          sis,
                id:           cloud.InstanceID(fmt.Sprintf("inst%d,%s", sis.lastInstanceID, it.ProviderType)),
                tags:         copyTags(tags),
                providerType: it.ProviderType,
-               initCommand:  cmd,
                running:      map[string]stubProcess{},
                killing:      map[string]bool{},
        }
@@ -142,7 +155,10 @@ func (sis *StubInstanceSet) Create(it arvados.InstanceType, image cloud.ImageID,
                Exec:           svm.Exec,
        }
        if setup := sis.driver.SetupVM; setup != nil {
-               setup(svm)
+               err := setup(svm)
+               if err != nil {
+                       return nil, err
+               }
        }
        sis.servers[svm.id] = svm
        return svm.Instance(), nil
@@ -171,11 +187,26 @@ func (sis *StubInstanceSet) Stop() {
        sis.stopped = true
 }
 
+func (sis *StubInstanceSet) StubVMs() (svms []*StubVM) {
+       sis.mtx.Lock()
+       defer sis.mtx.Unlock()
+       for _, vm := range sis.servers {
+               svms = append(svms, vm)
+       }
+       return
+}
+
 type RateLimitError struct{ Retry time.Time }
 
 func (e RateLimitError) Error() string            { return fmt.Sprintf("rate limited until %s", e.Retry) }
 func (e RateLimitError) EarliestRetry() time.Time { return e.Retry }
 
+type CapacityError struct{ InstanceTypeSpecific bool }
+
+func (e CapacityError) Error() string                { return "insufficient capacity" }
+func (e CapacityError) IsCapacityError() bool        { return true }
+func (e CapacityError) IsInstanceTypeSpecific() bool { return e.InstanceTypeSpecific }
+
 // StubVM is a fake server that runs an SSH service. It represents a
 // VM running in a fake cloud.
 //
@@ -196,16 +227,20 @@ type StubVM struct {
        CrashRunningContainer func(arvados.Container)
        ExtraCrunchRunArgs    string // extra args expected after "crunch-run --detach --stdin-config "
 
+       // Populated by (*StubInstanceSet)Create()
+       InitCommand cloud.InitCommand
+
        sis          *StubInstanceSet
        id           cloud.InstanceID
        tags         cloud.InstanceTags
-       initCommand  cloud.InitCommand
        providerType string
        SSHService   SSHService
        running      map[string]stubProcess
        killing      map[string]bool
        lastPID      int64
        deadlocked   string
+       stubprocs    sync.WaitGroup
+       destroying   bool
        sync.Mutex
 }
 
@@ -234,6 +269,17 @@ func (svm *StubVM) Instance() stubInstance {
 }
 
 func (svm *StubVM) Exec(env map[string]string, command string, stdin io.Reader, stdout, stderr io.Writer) uint32 {
+       // Ensure we don't start any new stubprocs after Destroy()
+       // has started Wait()ing for stubprocs to end.
+       svm.Lock()
+       if svm.destroying {
+               svm.Unlock()
+               return 1
+       }
+       svm.stubprocs.Add(1)
+       defer svm.stubprocs.Done()
+       svm.Unlock()
+
        stdinData, err := ioutil.ReadAll(stdin)
        if err != nil {
                fmt.Fprintf(stderr, "error reading stdin: %s\n", err)
@@ -271,7 +317,15 @@ func (svm *StubVM) Exec(env map[string]string, command string, stdin io.Reader,
                pid := svm.lastPID
                svm.running[uuid] = stubProcess{pid: pid}
                svm.Unlock()
+
                time.Sleep(svm.CrunchRunDetachDelay)
+
+               svm.Lock()
+               defer svm.Unlock()
+               if svm.destroying {
+                       fmt.Fprint(stderr, "crunch-run: killed by system shutdown\n")
+                       return 9
+               }
                fmt.Fprintf(stderr, "starting %s\n", uuid)
                logger := svm.sis.logger.WithFields(logrus.Fields{
                        "Instance":      svm.id,
@@ -279,13 +333,18 @@ func (svm *StubVM) Exec(env map[string]string, command string, stdin io.Reader,
                        "PID":           pid,
                })
                logger.Printf("[test] starting crunch-run stub")
+               svm.stubprocs.Add(1)
                go func() {
+                       defer svm.stubprocs.Done()
                        var ctr arvados.Container
                        var started, completed bool
                        defer func() {
                                logger.Print("[test] exiting crunch-run stub")
                                svm.Lock()
                                defer svm.Unlock()
+                               if svm.destroying {
+                                       return
+                               }
                                if svm.running[uuid].pid != pid {
                                        bugf := svm.sis.driver.Bugf
                                        if bugf == nil {
@@ -325,8 +384,10 @@ func (svm *StubVM) Exec(env map[string]string, command string, stdin io.Reader,
 
                        svm.Lock()
                        killed := svm.killing[uuid]
+                       delete(svm.killing, uuid)
+                       destroying := svm.destroying
                        svm.Unlock()
-                       if killed || wantCrashEarly {
+                       if killed || wantCrashEarly || destroying {
                                return
                        }
 
@@ -418,6 +479,10 @@ func (si stubInstance) Destroy() error {
        if math_rand.Float64() < si.svm.sis.driver.ErrorRateDestroy {
                return errors.New("instance could not be destroyed")
        }
+       si.svm.Lock()
+       si.svm.destroying = true
+       si.svm.Unlock()
+       si.svm.stubprocs.Wait()
        si.svm.SSHService.Close()
        sis.mtx.Lock()
        defer sis.mtx.Unlock()
@@ -474,3 +539,9 @@ func copyTags(src cloud.InstanceTags) cloud.InstanceTags {
 func (si stubInstance) PriceHistory(arvados.InstanceType) []cloud.InstancePrice {
        return nil
 }
+
+type QuotaError struct {
+       error
+}
+
+func (QuotaError) IsQuotaError() bool { return true }
index c270eef4943eb48f44ba264ac2dee472e747e5b5..13c369d0c65113015cb4297375a0d11d815d9ad9 100644 (file)
@@ -82,6 +82,9 @@ const (
        // instances have been shutdown.
        quotaErrorTTL = time.Minute
 
+       // Time after a capacity error to try again
+       capacityErrorTTL = time.Minute
+
        // Time between "X failed because rate limiting" messages
        logRateLimitErrorInterval = time.Second * 10
 )
@@ -106,6 +109,7 @@ func NewPool(logger logrus.FieldLogger, arvClient *arvados.Client, reg *promethe
                newExecutor:                    newExecutor,
                cluster:                        cluster,
                bootProbeCommand:               cluster.Containers.CloudVMs.BootProbeCommand,
+               instanceInitCommand:            cloud.InitCommand(cluster.Containers.CloudVMs.InstanceInitCommand),
                runnerSource:                   cluster.Containers.CloudVMs.DeployRunnerBinary,
                imageID:                        cloud.ImageID(cluster.Containers.CloudVMs.ImageID),
                instanceTypes:                  cluster.InstanceTypes,
@@ -149,6 +153,7 @@ type Pool struct {
        newExecutor                    func(cloud.Instance) Executor
        cluster                        *arvados.Cluster
        bootProbeCommand               string
+       instanceInitCommand            cloud.InitCommand
        runnerSource                   string
        imageID                        cloud.ImageID
        instanceTypes                  map[string]arvados.InstanceType
@@ -171,19 +176,21 @@ type Pool struct {
        runnerArgs                     []string // extra args passed to crunch-run
 
        // private state
-       subscribers  map[<-chan struct{}]chan<- struct{}
-       creating     map[string]createCall // unfinished (cloud.InstanceSet)Create calls (key is instance secret)
-       workers      map[cloud.InstanceID]*worker
-       loaded       bool                 // loaded list of instances from InstanceSet at least once
-       exited       map[string]time.Time // containers whose crunch-run proc has exited, but ForgetContainer has not been called
-       atQuotaUntil time.Time
-       atQuotaErr   cloud.QuotaError
-       stop         chan bool
-       mtx          sync.RWMutex
-       setupOnce    sync.Once
-       runnerData   []byte
-       runnerMD5    [md5.Size]byte
-       runnerCmd    string
+       subscribers                map[<-chan struct{}]chan<- struct{}
+       creating                   map[string]createCall // unfinished (cloud.InstanceSet)Create calls (key is instance secret)
+       workers                    map[cloud.InstanceID]*worker
+       loaded                     bool                 // loaded list of instances from InstanceSet at least once
+       exited                     map[string]time.Time // containers whose crunch-run proc has exited, but ForgetContainer has not been called
+       atQuotaUntilFewerInstances int
+       atQuotaUntil               time.Time
+       atQuotaErr                 cloud.QuotaError
+       atCapacityUntil            map[string]time.Time
+       stop                       chan bool
+       mtx                        sync.RWMutex
+       setupOnce                  sync.Once
+       runnerData                 []byte
+       runnerMD5                  [md5.Size]byte
+       runnerCmd                  string
 
        mContainersRunning        prometheus.Gauge
        mInstances                *prometheus.GaugeVec
@@ -197,6 +204,8 @@ type Pool struct {
        mTimeFromShutdownToGone   prometheus.Summary
        mTimeFromQueueToCrunchRun prometheus.Summary
        mRunProbeDuration         *prometheus.SummaryVec
+       mProbeAgeMax              prometheus.Gauge
+       mProbeAgeMedian           prometheus.Gauge
 }
 
 type createCall struct {
@@ -315,13 +324,11 @@ func (wp *Pool) Create(it arvados.InstanceType) bool {
                // Boot probe is certain to fail.
                return false
        }
-       wp.mtx.Lock()
-       defer wp.mtx.Unlock()
-       if time.Now().Before(wp.atQuotaUntil) ||
-               wp.instanceSet.throttleCreate.Error() != nil ||
-               (wp.maxInstances > 0 && wp.maxInstances <= len(wp.workers)+len(wp.creating)) {
+       if wp.AtCapacity(it) || wp.AtQuota() || wp.instanceSet.throttleCreate.Error() != nil {
                return false
        }
+       wp.mtx.Lock()
+       defer wp.mtx.Unlock()
        // The maxConcurrentInstanceCreateOps knob throttles the number of node create
        // requests in flight. It was added to work around a limitation in Azure's
        // managed disks, which support no more than 20 concurrent node creation
@@ -345,7 +352,7 @@ func (wp *Pool) Create(it arvados.InstanceType) bool {
                        wp.tagKeyPrefix + tagKeyIdleBehavior:   string(IdleBehaviorRun),
                        wp.tagKeyPrefix + tagKeyInstanceSecret: secret,
                }
-               initCmd := TagVerifier{nil, secret, nil}.InitCommand()
+               initCmd := TagVerifier{nil, secret, nil}.InitCommand() + "\n" + wp.instanceInitCommand
                inst, err := wp.instanceSet.Create(it, wp.imageID, tags, initCmd, wp.installPublicKey)
                wp.mtx.Lock()
                defer wp.mtx.Unlock()
@@ -356,8 +363,37 @@ func (wp *Pool) Create(it arvados.InstanceType) bool {
                if err != nil {
                        if err, ok := err.(cloud.QuotaError); ok && err.IsQuotaError() {
                                wp.atQuotaErr = err
-                               wp.atQuotaUntil = time.Now().Add(quotaErrorTTL)
-                               time.AfterFunc(quotaErrorTTL, wp.notify)
+                               n := len(wp.workers) + len(wp.creating) - 1
+                               if n < 1 {
+                                       // Quota error with no
+                                       // instances running --
+                                       // nothing to do but wait
+                                       wp.atQuotaUntilFewerInstances = 0
+                                       wp.atQuotaUntil = time.Now().Add(quotaErrorTTL)
+                                       time.AfterFunc(quotaErrorTTL, wp.notify)
+                                       logger.WithField("atQuotaUntil", wp.atQuotaUntil).Info("quota error with 0 running -- waiting for quotaErrorTTL")
+                               } else if n < wp.atQuotaUntilFewerInstances || wp.atQuotaUntilFewerInstances == 0 {
+                                       // Quota error with N
+                                       // instances running -- report
+                                       // AtQuota until some
+                                       // instances shut down
+                                       wp.atQuotaUntilFewerInstances = n
+                                       wp.atQuotaUntil = time.Time{}
+                                       logger.WithField("atQuotaUntilFewerInstances", n).Info("quota error -- waiting for next instance shutdown")
+                               }
+                       }
+                       if err, ok := err.(cloud.CapacityError); ok && err.IsCapacityError() {
+                               capKey := it.ProviderType
+                               if !err.IsInstanceTypeSpecific() {
+                                       // set capacity flag for all
+                                       // instance types
+                                       capKey = ""
+                               }
+                               if wp.atCapacityUntil == nil {
+                                       wp.atCapacityUntil = map[string]time.Time{}
+                               }
+                               wp.atCapacityUntil[capKey] = time.Now().Add(capacityErrorTTL)
+                               time.AfterFunc(capacityErrorTTL, wp.notify)
                        }
                        logger.WithError(err).Error("create failed")
                        wp.instanceSet.throttleCreate.CheckRateLimitError(err, wp.logger, "create instance", wp.notify)
@@ -371,13 +407,31 @@ func (wp *Pool) Create(it arvados.InstanceType) bool {
        return true
 }
 
+// AtCapacity returns true if Create() is currently expected to fail
+// for the given instance type.
+func (wp *Pool) AtCapacity(it arvados.InstanceType) bool {
+       wp.mtx.Lock()
+       defer wp.mtx.Unlock()
+       if t, ok := wp.atCapacityUntil[it.ProviderType]; ok && time.Now().Before(t) {
+               // at capacity for this instance type
+               return true
+       }
+       if t, ok := wp.atCapacityUntil[""]; ok && time.Now().Before(t) {
+               // at capacity for all instance types
+               return true
+       }
+       return false
+}
+
 // AtQuota returns true if Create is not expected to work at the
 // moment (e.g., cloud provider has reported quota errors, or we are
 // already at our own configured quota).
 func (wp *Pool) AtQuota() bool {
        wp.mtx.Lock()
        defer wp.mtx.Unlock()
-       return time.Now().Before(wp.atQuotaUntil) || (wp.maxInstances > 0 && wp.maxInstances <= len(wp.workers)+len(wp.creating))
+       return wp.atQuotaUntilFewerInstances > 0 ||
+               time.Now().Before(wp.atQuotaUntil) ||
+               (wp.maxInstances > 0 && wp.maxInstances <= len(wp.workers)+len(wp.creating))
 }
 
 // SetIdleBehavior determines how the indicated instance will behave
@@ -626,6 +680,20 @@ func (wp *Pool) registerMetrics(reg *prometheus.Registry) {
                Help:      "Number of containers reported running by cloud VMs.",
        })
        reg.MustRegister(wp.mContainersRunning)
+       wp.mProbeAgeMax = prometheus.NewGauge(prometheus.GaugeOpts{
+               Namespace: "arvados",
+               Subsystem: "dispatchcloud",
+               Name:      "probe_age_seconds_max",
+               Help:      "Maximum number of seconds since an instance's most recent successful probe.",
+       })
+       reg.MustRegister(wp.mProbeAgeMax)
+       wp.mProbeAgeMedian = prometheus.NewGauge(prometheus.GaugeOpts{
+               Namespace: "arvados",
+               Subsystem: "dispatchcloud",
+               Name:      "probe_age_seconds_median",
+               Help:      "Median number of seconds since an instance's most recent successful probe.",
+       })
+       reg.MustRegister(wp.mProbeAgeMedian)
        wp.mInstances = prometheus.NewGaugeVec(prometheus.GaugeOpts{
                Namespace: "arvados",
                Subsystem: "dispatchcloud",
@@ -738,6 +806,8 @@ func (wp *Pool) updateMetrics() {
        cpu := map[string]int64{}
        mem := map[string]int64{}
        var running int64
+       now := time.Now()
+       var probed []time.Time
        for _, wkr := range wp.workers {
                var cat string
                switch {
@@ -757,6 +827,7 @@ func (wp *Pool) updateMetrics() {
                cpu[cat] += int64(wkr.instType.VCPUs)
                mem[cat] += int64(wkr.instType.RAM)
                running += int64(len(wkr.running) + len(wkr.starting))
+               probed = append(probed, wkr.probed)
        }
        for _, cat := range []string{"inuse", "hold", "booting", "unknown", "idle"} {
                wp.mInstancesPrice.WithLabelValues(cat).Set(price[cat])
@@ -773,6 +844,15 @@ func (wp *Pool) updateMetrics() {
                wp.mInstances.WithLabelValues(k.cat, k.instType).Set(float64(v))
        }
        wp.mContainersRunning.Set(float64(running))
+
+       if len(probed) == 0 {
+               wp.mProbeAgeMax.Set(0)
+               wp.mProbeAgeMedian.Set(0)
+       } else {
+               sort.Slice(probed, func(i, j int) bool { return probed[i].Before(probed[j]) })
+               wp.mProbeAgeMax.Set(now.Sub(probed[0]).Seconds())
+               wp.mProbeAgeMedian.Set(now.Sub(probed[len(probed)/2]).Seconds())
+       }
 }
 
 func (wp *Pool) runProbes() {
@@ -878,6 +958,9 @@ func (wp *Pool) Instances() []InstanceView {
 // KillInstance destroys a cloud VM instance. It returns an error if
 // the given instance does not exist.
 func (wp *Pool) KillInstance(id cloud.InstanceID, reason string) error {
+       wp.setupOnce.Do(wp.setup)
+       wp.mtx.Lock()
+       defer wp.mtx.Unlock()
        wkr, ok := wp.workers[id]
        if !ok {
                return errors.New("instance not found")
@@ -999,6 +1082,14 @@ func (wp *Pool) sync(threshold time.Time, instances []cloud.Instance) {
                notify = true
        }
 
+       if wp.atQuotaUntilFewerInstances > len(wp.workers)+len(wp.creating) {
+               // After syncing, there are fewer instances (including
+               // pending creates) than there were last time we saw a
+               // quota error.  This might mean it's now possible to
+               // create new instances.  Reset our "at quota" state.
+               wp.atQuotaUntilFewerInstances = 0
+       }
+
        if !wp.loaded {
                notify = true
                wp.loaded = true
index 7b5634605fee5c20b987c06078eb78b0dc6841b6..8d2ba09ebe849f5962f7fcf1570de27f5fe3012e 100644 (file)
@@ -78,7 +78,7 @@ func (suite *PoolSuite) TestResumeAfterRestart(c *check.C) {
 
        driver := &test.StubDriver{}
        instanceSetID := cloud.InstanceSetID("test-instance-set-id")
-       is, err := driver.InstanceSet(nil, instanceSetID, nil, suite.logger)
+       is, err := driver.InstanceSet(nil, instanceSetID, nil, suite.logger, nil)
        c.Assert(err, check.IsNil)
 
        newExecutor := func(cloud.Instance) Executor {
@@ -157,7 +157,7 @@ func (suite *PoolSuite) TestResumeAfterRestart(c *check.C) {
 
 func (suite *PoolSuite) TestDrain(c *check.C) {
        driver := test.StubDriver{}
-       instanceSet, err := driver.InstanceSet(nil, "test-instance-set-id", nil, suite.logger)
+       instanceSet, err := driver.InstanceSet(nil, "test-instance-set-id", nil, suite.logger, nil)
        c.Assert(err, check.IsNil)
 
        ac := arvados.NewClientFromEnv()
@@ -210,7 +210,7 @@ func (suite *PoolSuite) TestDrain(c *check.C) {
 
 func (suite *PoolSuite) TestNodeCreateThrottle(c *check.C) {
        driver := test.StubDriver{HoldCloudOps: true}
-       instanceSet, err := driver.InstanceSet(nil, "test-instance-set-id", nil, suite.logger)
+       instanceSet, err := driver.InstanceSet(nil, "test-instance-set-id", nil, suite.logger, nil)
        c.Assert(err, check.IsNil)
 
        type1 := test.InstanceType(1)
@@ -250,7 +250,7 @@ func (suite *PoolSuite) TestNodeCreateThrottle(c *check.C) {
 
 func (suite *PoolSuite) TestCreateUnallocShutdown(c *check.C) {
        driver := test.StubDriver{HoldCloudOps: true}
-       instanceSet, err := driver.InstanceSet(nil, "test-instance-set-id", nil, suite.logger)
+       instanceSet, err := driver.InstanceSet(nil, "test-instance-set-id", nil, suite.logger, nil)
        c.Assert(err, check.IsNil)
 
        type1 := arvados.InstanceType{Name: "a1s", ProviderType: "a1.small", VCPUs: 1, RAM: 1 * GiB, Price: .01}
@@ -266,6 +266,7 @@ func (suite *PoolSuite) TestCreateUnallocShutdown(c *check.C) {
                        type2.Name: type2,
                        type3.Name: type3,
                },
+               instanceInitCommand: "echo 'instance init command goes here'",
        }
        notify := pool.Subscribe()
        defer pool.Unsubscribe(notify)
@@ -294,6 +295,9 @@ func (suite *PoolSuite) TestCreateUnallocShutdown(c *check.C) {
                return len(pool.workers) == 4
        })
 
+       vms := instanceSet.(*test.StubInstanceSet).StubVMs()
+       c.Check(string(vms[0].InitCommand), check.Matches, `umask 0177 && echo -n "[0-9a-f]+" >/var/run/arvados-instance-secret\necho 'instance init command goes here'`)
+
        // Place type3 node on admin-hold
        ivs := suite.instancesByType(pool, type3)
        c.Assert(ivs, check.HasLen, 1)
index ac039272cf9c5b5374c6a9dd60462b0b9e2a684c..f22b8922ad42c06f44ff7651494b468911149b37 100644 (file)
@@ -138,7 +138,7 @@ func (rr *remoteRunner) Kill(reason string) {
                termDeadline := time.Now().Add(rr.timeoutTERM)
                t := time.NewTicker(rr.timeoutSignal)
                defer t.Stop()
-               for range t.C {
+               for ; ; <-t.C {
                        switch {
                        case rr.isClosed():
                                return
index b2ed6c2bff5b039435944b851a9fe3646c922001..10a28157e43ee0e496fd82b8705542a58aa0c8ca 100644 (file)
@@ -7,17 +7,21 @@ package worker
 import (
        "bytes"
        "encoding/json"
+       "errors"
        "fmt"
        "io"
+       "net"
        "path/filepath"
        "strings"
        "sync"
        "time"
 
        "git.arvados.org/arvados.git/lib/cloud"
+       "git.arvados.org/arvados.git/lib/dispatchcloud/sshexecutor"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/stats"
        "github.com/sirupsen/logrus"
+       "golang.org/x/crypto/ssh"
 )
 
 const (
@@ -184,6 +188,14 @@ func (wkr *worker) startContainer(ctr arvados.Container) {
                }
                wkr.mtx.Lock()
                defer wkr.mtx.Unlock()
+               if wkr.starting[ctr.UUID] != rr {
+                       // Someone else (e.g., wkr.probeAndUpdate() ->
+                       // wkr.updateRunning() or wkr.Close()) already
+                       // moved our runner from wkr.starting to
+                       // wkr.running or deleted it while we were in
+                       // rr.Start().
+                       return
+               }
                now := time.Now()
                wkr.updated = now
                wkr.busy = now
@@ -236,6 +248,7 @@ func (wkr *worker) probeAndUpdate() {
                ctrUUIDs []string
                ok       bool
                stderr   []byte // from probeBooted
+               errLast  error  // from probeBooted or copyRunnerData
        )
 
        switch initialState {
@@ -252,20 +265,33 @@ func (wkr *worker) probeAndUpdate() {
        logger := wkr.logger.WithField("ProbeStart", probeStart)
 
        if !booted {
-               booted, stderr = wkr.probeBooted()
+               stderr, errLast = wkr.probeBooted()
+               booted = errLast == nil
+               shouldCopy := booted || initialState == StateUnknown
                if !booted {
                        // Pretend this probe succeeded if another
                        // concurrent attempt succeeded.
                        wkr.mtx.Lock()
-                       booted = wkr.state == StateRunning || wkr.state == StateIdle
+                       if wkr.state == StateRunning || wkr.state == StateIdle {
+                               booted = true
+                               shouldCopy = false
+                       }
                        wkr.mtx.Unlock()
                }
+               if shouldCopy {
+                       _, stderrCopy, err := wkr.copyRunnerData()
+                       if err != nil {
+                               booted = false
+                               wkr.logger.WithError(err).WithField("stderr", string(stderrCopy)).Warn("error copying runner binary")
+                               errLast = err
+                       }
+               }
                if booted {
                        logger.Info("instance booted; will try probeRunning")
                }
        }
        reportedBroken := false
-       if booted || wkr.state == StateUnknown {
+       if booted || initialState == StateUnknown {
                ctrUUIDs, reportedBroken, ok = wkr.probeRunning()
        }
        wkr.mtx.Lock()
@@ -290,17 +316,17 @@ func (wkr *worker) probeAndUpdate() {
                dur := probeStart.Sub(wkr.probed)
                if wkr.shutdownIfBroken(dur) {
                        // stderr from failed run-probes will have
-                       // been logged already, but boot-probe
+                       // been logged already, but some boot-probe
                        // failures are normal so they are logged only
-                       // at Debug level. This is our chance to log
-                       // some evidence about why the node never
+                       // at Debug level. This may be our chance to
+                       // log some evidence about why the node never
                        // booted, even in non-debug mode.
                        if !booted {
                                wkr.reportBootOutcome(BootOutcomeFailed)
                                logger.WithFields(logrus.Fields{
                                        "Duration": dur,
                                        "stderr":   string(stderr),
-                               }).Info("boot failed")
+                               }).WithError(errLast).Info("boot failed")
                        }
                }
                return
@@ -451,7 +477,7 @@ func (wkr *worker) probeRunning() (running []string, reportsBroken, ok bool) {
        return
 }
 
-func (wkr *worker) probeBooted() (ok bool, stderr []byte) {
+func (wkr *worker) probeBooted() (stderr []byte, err error) {
        cmd := wkr.wp.bootProbeCommand
        if cmd == "" {
                cmd = "true"
@@ -463,25 +489,41 @@ func (wkr *worker) probeBooted() (ok bool, stderr []byte) {
                "stderr":  string(stderr),
        })
        if err != nil {
-               logger.WithError(err).Debug("boot probe failed")
-               return false, stderr
+               if errors.Is(err, sshexecutor.ErrNoAddress) ||
+                       errors.As(err, new(*net.OpError)) ||
+                       errors.As(err, new(*ssh.ExitError)) {
+                       // These errors are expected while the
+                       // instance is booting, so we only log them at
+                       // debug level.
+                       logger.WithError(err).Debug("boot probe failed")
+               } else {
+                       // Other errors are more likely to indicate a
+                       // configuration problem, and it's more
+                       // sysadmin-friendly to show them right away
+                       // instead of waiting until boot timeout and
+                       // only showing the last error.
+                       //
+                       // Example: "ssh: handshake failed: ssh:
+                       // unable to authenticate, attempted methods
+                       // [none publickey], no supported methods
+                       // remain"
+                       logger.WithError(err).Warn("boot probe failed")
+               }
+               return stderr, err
        }
        logger.Info("boot probe succeeded")
+       return stderr, nil
+}
+
+func (wkr *worker) copyRunnerData() (stdout, stderr []byte, err error) {
        if err = wkr.wp.loadRunnerData(); err != nil {
                wkr.logger.WithError(err).Warn("cannot boot worker: error loading runner binary")
-               return false, stderr
+               return
        } else if len(wkr.wp.runnerData) == 0 {
                // Assume crunch-run is already installed
-       } else if _, stderr2, err := wkr.copyRunnerData(); err != nil {
-               wkr.logger.WithError(err).WithField("stderr", string(stderr2)).Warn("error copying runner binary")
-               return false, stderr2
-       } else {
-               stderr = append(stderr, stderr2...)
+               return
        }
-       return true, stderr
-}
 
-func (wkr *worker) copyRunnerData() (stdout, stderr []byte, err error) {
        hash := fmt.Sprintf("%x", wkr.wp.runnerMD5)
        dstdir, _ := filepath.Split(wkr.wp.runnerCmd)
        logger := wkr.logger.WithFields(logrus.Fields{
@@ -513,9 +555,11 @@ func (wkr *worker) shutdownIfBroken(dur time.Duration) bool {
                // Never shut down.
                return false
        }
-       label, threshold := "", wkr.wp.timeoutProbe
+       prologue, epilogue, threshold := "", "", wkr.wp.timeoutProbe
        if wkr.state == StateUnknown || wkr.state == StateBooting {
-               label, threshold = "new ", wkr.wp.timeoutBooting
+               prologue = "new "
+               epilogue = " -- `arvados-server cloudtest` might help troubleshoot, see https://doc.arvados.org/main/admin/cloudtest.html"
+               threshold = wkr.wp.timeoutBooting
        }
        if dur < threshold {
                return false
@@ -524,7 +568,7 @@ func (wkr *worker) shutdownIfBroken(dur time.Duration) bool {
                "Duration": dur,
                "Since":    wkr.probed,
                "State":    wkr.state,
-       }).Warnf("%sinstance unresponsive, shutting down", label)
+       }).Warnf("%sinstance unresponsive, shutting down%s", prologue, epilogue)
        wkr.shutdown()
        return true
 }
@@ -631,10 +675,12 @@ func (wkr *worker) Close() {
        for uuid, rr := range wkr.running {
                wkr.logger.WithField("ContainerUUID", uuid).Info("crunch-run process abandoned")
                rr.Close()
+               delete(wkr.running, uuid)
        }
        for uuid, rr := range wkr.starting {
                wkr.logger.WithField("ContainerUUID", uuid).Info("crunch-run process abandoned")
                rr.Close()
+               delete(wkr.starting, uuid)
        }
 }
 
index 2ee6b7c3622d66a5b85299826b035fa47ce97d26..5d8c67e9162140a9eeb8f62a897c7c955d900188 100644 (file)
@@ -43,7 +43,7 @@ func (suite *WorkerSuite) TestProbeAndUpdate(c *check.C) {
        probeTimeout := time.Second
 
        ac := arvados.NewClientFromEnv()
-       is, err := (&test.StubDriver{}).InstanceSet(nil, "test-instance-set-id", nil, suite.logger)
+       is, err := (&test.StubDriver{}).InstanceSet(nil, "test-instance-set-id", nil, suite.logger, nil)
        c.Assert(err, check.IsNil)
        inst, err := is.Create(arvados.InstanceType{}, "", nil, "echo InitCommand", nil)
        c.Assert(err, check.IsNil)
@@ -122,6 +122,39 @@ func (suite *WorkerSuite) TestProbeAndUpdate(c *check.C) {
                        expectState:     StateUnknown,
                        expectRunning:   1,
                },
+               {
+                       testCaseComment: "Unknown, boot probe fails, deployRunner succeeds, container is running",
+                       state:           StateUnknown,
+                       respBoot:        respFail,
+                       respRun:         respFail,
+                       respRunDeployed: respContainerRunning,
+                       deployRunner:    []byte("ELF"),
+                       expectStdin:     []byte("ELF"),
+                       expectState:     StateUnknown,
+                       expectRunning:   1,
+               },
+               {
+                       testCaseComment: "Unknown, boot timeout exceeded, boot probe fails but deployRunner succeeds and container is running",
+                       state:           StateUnknown,
+                       age:             bootTimeout * 2,
+                       respBoot:        respFail,
+                       respRun:         respFail,
+                       respRunDeployed: respContainerRunning,
+                       deployRunner:    []byte("ELF"),
+                       expectStdin:     []byte("ELF"),
+                       expectState:     StateUnknown,
+                       expectRunning:   1,
+               },
+               {
+                       testCaseComment: "Unknown, boot timeout exceeded, boot probe fails but deployRunner succeeds and no container is running",
+                       state:           StateUnknown,
+                       age:             bootTimeout * 2,
+                       respBoot:        respFail,
+                       respRun:         respFail,
+                       deployRunner:    []byte("ELF"),
+                       expectStdin:     []byte("ELF"),
+                       expectState:     StateShutdown,
+               },
                {
                        testCaseComment: "Booting, boot probe fails, run probe fails",
                        state:           StateBooting,
index 3b68f31e9fac07208c8b4dcff46ee58f4e99deda..f536001f77ca9abdb3b1883ad8dd9051ab202961 100644 (file)
@@ -7,8 +7,6 @@ Description=Arvados server
 Documentation=https://doc.arvados.org/
 After=network.target
 AssertPathExists=/etc/arvados/config.yml
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -21,8 +19,5 @@ Restart=always
 RestartSec=1
 LimitNOFILE=65536
 
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
-
 [Install]
 WantedBy=multi-user.target
index e0defa888a2cec5c0d2c3d20f320f7ebad0fb3a4..3f0245293e5f1a39475613e2afe7c7f0ca6d980a 100755 (executable)
@@ -1,4 +1,8 @@
 #!/bin/bash
+#
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
 
 set -ex -o pipefail
 
@@ -7,7 +11,7 @@ SRC=$(realpath $(dirname ${BASH_SOURCE[0]})/../..)
 ctrname=arvadostest
 ctrbase=${ctrname}
 if [[ "${1}" != "--update" ]] || ! docker images --format={{.Repository}} | grep -x ${ctrbase}; then
-    ctrbase=debian:10
+    ctrbase=debian:11
 fi
 
 if docker ps -a --format={{.Names}} | grep -x ${ctrname}; then
index 1b4bf7266d29124dd56a92c9d9284828896cd706..9720a30d26f1283e9ddaae0f1fb7f9ff4b1aba7b 100644 (file)
@@ -17,6 +17,7 @@ import (
        "os/exec"
        "os/user"
        "path/filepath"
+       "regexp"
        "runtime"
        "strconv"
        "strings"
@@ -30,28 +31,32 @@ import (
 
 var Command cmd.Handler = &installCommand{}
 
-const goversion = "1.18.8"
+const goversion = "1.20.6"
 
 const (
-       rubyversion             = "2.7.6"
-       bundlerversion          = "2.2.19"
-       singularityversion      = "3.9.9"
-       pjsversion              = "1.9.8"
-       geckoversion            = "0.24.0"
-       gradleversion           = "5.3.1"
-       nodejsversion           = "v12.22.12"
-       devtestDatabasePassword = "insecure_arvados_test"
-       workbench2version       = "e30e54d674c95ee15e296c71e471c1555bdc5a38" // 2.4.3
+       defaultRubyVersion        = "3.2.2"
+       defaultBundlerVersion     = "2.2.19"
+       defaultSingularityVersion = "3.10.4"
+       pjsversion                = "1.9.8"
+       geckoversion              = "0.24.0"
+       gradleversion             = "5.3.1"
+       defaultNodejsVersion      = "14.21.3"
+       devtestDatabasePassword   = "insecure_arvados_test"
 )
 
 //go:embed arvados.service
 var arvadosServiceFile []byte
 
 type installCommand struct {
-       ClusterType    string
-       SourcePath     string
-       PackageVersion string
-       EatMyData      bool
+       ClusterType        string
+       SourcePath         string
+       Commit             string
+       PackageVersion     string
+       RubyVersion        string
+       BundlerVersion     string
+       SingularityVersion string
+       NodejsVersion      string
+       EatMyData          bool
 }
 
 func (inst *installCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
@@ -72,7 +77,12 @@ func (inst *installCommand) RunCommand(prog string, args []string, stdin io.Read
        versionFlag := flags.Bool("version", false, "Write version information to stdout and exit 0")
        flags.StringVar(&inst.ClusterType, "type", "production", "cluster `type`: development, test, production, or package")
        flags.StringVar(&inst.SourcePath, "source", "/arvados", "source tree location (required for -type=package)")
+       flags.StringVar(&inst.Commit, "commit", "", "source commit `hash` to embed (blank means use 'git log' or all-zero placeholder)")
        flags.StringVar(&inst.PackageVersion, "package-version", "0.0.0", "version string to embed in executable files")
+       flags.StringVar(&inst.RubyVersion, "ruby-version", defaultRubyVersion, "Ruby `version` to install (do not override in production mode)")
+       flags.StringVar(&inst.BundlerVersion, "bundler-version", defaultBundlerVersion, "Bundler `version` to install (do not override in production mode)")
+       flags.StringVar(&inst.SingularityVersion, "singularity-version", defaultSingularityVersion, "Singularity `version` to install (do not override in production mode)")
+       flags.StringVar(&inst.NodejsVersion, "nodejs-version", defaultNodejsVersion, "Nodejs `version` to install (not applicable in production mode)")
        flags.BoolVar(&inst.EatMyData, "eatmydata", false, "use eatmydata to speed up install")
 
        if ok, code := cmd.ParseFlags(flags, prog, args, "", stderr); !ok {
@@ -81,6 +91,14 @@ func (inst *installCommand) RunCommand(prog string, args []string, stdin io.Read
                return cmd.Version.RunCommand(prog, args, stdin, stdout, stderr)
        }
 
+       if inst.Commit == "" {
+               if commit, err := exec.Command("env", "-C", inst.SourcePath, "git", "log", "-n1", "--format=%H").CombinedOutput(); err == nil {
+                       inst.Commit = strings.TrimSpace(string(commit))
+               } else {
+                       inst.Commit = "0000000000000000000000000000000000000000"
+               }
+       }
+
        var dev, test, prod, pkg bool
        switch inst.ClusterType {
        case "development":
@@ -101,6 +119,23 @@ func (inst *installCommand) RunCommand(prog string, args []string, stdin io.Read
                return 1
        }
 
+       if ok, _ := regexp.MatchString(`^\d\.\d+\.\d+$`, inst.RubyVersion); !ok {
+               fmt.Fprintf(stderr, "invalid argument %q for -ruby-version\n", inst.RubyVersion)
+               return 2
+       }
+       if ok, _ := regexp.MatchString(`^\d`, inst.BundlerVersion); !ok {
+               fmt.Fprintf(stderr, "invalid argument %q for -bundler-version\n", inst.BundlerVersion)
+               return 2
+       }
+       if ok, _ := regexp.MatchString(`^\d`, inst.SingularityVersion); !ok {
+               fmt.Fprintf(stderr, "invalid argument %q for -singularity-version\n", inst.SingularityVersion)
+               return 2
+       }
+       if ok, _ := regexp.MatchString(`^\d`, inst.NodejsVersion); !ok {
+               fmt.Fprintf(stderr, "invalid argument %q for -nodejs-version\n", inst.NodejsVersion)
+               return 2
+       }
+
        osv, err := identifyOS()
        if err != nil {
                return 1
@@ -155,6 +190,7 @@ func (inst *installCommand) RunCommand(prog string, args []string, stdin io.Read
                        "default-jre-headless",
                        "gettext",
                        "libattr1-dev",
+                       "libffi-dev",
                        "libfuse-dev",
                        "libgbm1", // cypress / workbench2 tests
                        "libgnutls28-dev",
@@ -165,6 +201,7 @@ func (inst *installCommand) RunCommand(prog string, args []string, stdin io.Read
                        "libssl-dev",
                        "libxml2-dev",
                        "libxslt1-dev",
+                       "libyaml-dev",
                        "linkchecker",
                        "lsof",
                        "make",
@@ -193,22 +230,30 @@ func (inst *installCommand) RunCommand(prog string, args []string, stdin io.Read
                if test {
                        if osv.Debian && osv.Major <= 10 {
                                pkgs = append(pkgs, "iceweasel")
+                       } else if osv.Debian && osv.Major >= 11 {
+                               pkgs = append(pkgs, "firefox-esr")
                        } else {
                                pkgs = append(pkgs, "firefox")
                        }
+                       if osv.Debian && osv.Major >= 11 {
+                               // not available in Debian <11
+                               pkgs = append(pkgs, "s3cmd")
+                       }
                }
                if dev || test {
-                       pkgs = append(pkgs, "squashfs-tools") // for singularity
-                       pkgs = append(pkgs, "gnupg")          // for docker install recipe
+                       pkgs = append(pkgs,
+                               "libglib2.0-dev", // singularity (conmon)
+                               "libseccomp-dev", // singularity (seccomp)
+                               "squashfs-tools", // singularity
+                               "gnupg")          // docker install recipe
                }
                switch {
-               case osv.Debian && osv.Major >= 11:
-                       pkgs = append(pkgs, "g++", "libcurl4", "libcurl4-openssl-dev")
-               case osv.Debian && osv.Major >= 10:
+               case osv.Debian && osv.Major >= 10,
+                       osv.Ubuntu && osv.Major >= 22:
                        pkgs = append(pkgs, "g++", "libcurl4", "libcurl4-openssl-dev")
                case osv.Debian || osv.Ubuntu:
                        pkgs = append(pkgs, "g++", "libcurl3", "libcurl3-openssl-dev")
-               case osv.Centos:
+               case osv.RedHat:
                        pkgs = append(pkgs, "gcc", "gcc-c++", "libcurl-devel", "postgresql-devel")
                }
                cmd := exec.CommandContext(ctx, "apt-get")
@@ -227,15 +272,15 @@ func (inst *installCommand) RunCommand(prog string, args []string, stdin io.Read
        }
 
        if dev || test {
-               if havedockerversion, err := exec.Command("docker", "--version").CombinedOutput(); err == nil {
+               if havedockerversion, err2 := exec.Command("docker", "--version").CombinedOutput(); err2 == nil {
                        logger.Printf("%s installed, assuming that version is ok", bytes.TrimSuffix(havedockerversion, []byte("\n")))
                } else if osv.Debian {
                        var codename string
                        switch osv.Major {
-                       case 10:
-                               codename = "buster"
                        case 11:
                                codename = "bullseye"
+                       case 12:
+                               codename = "bookworm"
                        default:
                                err = fmt.Errorf("don't know how to install docker-ce for debian %d", osv.Major)
                                return 1
@@ -243,7 +288,7 @@ func (inst *installCommand) RunCommand(prog string, args []string, stdin io.Read
                        err = inst.runBash(`
 rm -f /usr/share/keyrings/docker-archive-keyring.gpg
 curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
-echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian/ `+codename+` stable' | \
+echo 'deb [arch=`+runtime.GOARCH+` signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian/ `+codename+` stable' | \
     tee /etc/apt/sources.list.d/docker.list
 apt-get update
 DEBIAN_FRONTEND=noninteractive apt-get --yes --no-install-recommends install docker-ce
@@ -255,6 +300,21 @@ DEBIAN_FRONTEND=noninteractive apt-get --yes --no-install-recommends install doc
                        err = fmt.Errorf("don't know how to install docker for osversion %v", osv)
                        return 1
                }
+
+               err = inst.runBash(`
+key=fs.inotify.max_user_watches
+min=524288
+if [[ "$(sysctl --values "${key}")" -lt "${min}" ]]; then
+    sysctl "${key}=${min}"
+    # writing sysctl worked, so we should make it permanent
+    echo "${key}=${min}" | tee -a /etc/sysctl.conf
+    sysctl -p
+fi
+`, stdout, stderr)
+               if err != nil {
+                       err = fmt.Errorf("couldn't set fs.inotify.max_user_watches value. (Is this a docker container? Fix this on the docker host by adding fs.inotify.max_user_watches=524288 to /etc/sysctl.conf and running `sysctl -p`)")
+                       return 1
+               }
        }
 
        os.Mkdir("/var/lib/arvados", 0755)
@@ -273,19 +333,25 @@ DEBIAN_FRONTEND=noninteractive apt-get --yes --no-install-recommends install doc
                        return 1
                }
        }
-       rubymajorversion := rubyversion[:strings.LastIndex(rubyversion, ".")]
-       if haverubyversion, err := exec.Command("/var/lib/arvados/bin/ruby", "-v").CombinedOutput(); err == nil && bytes.HasPrefix(haverubyversion, []byte("ruby "+rubyversion)) {
-               logger.Print("ruby " + rubyversion + " already installed")
+       rubyminorversion := inst.RubyVersion[:strings.LastIndex(inst.RubyVersion, ".")]
+       if haverubyversion, err := exec.Command("/var/lib/arvados/bin/ruby", "-v").CombinedOutput(); err == nil && bytes.HasPrefix(haverubyversion, []byte("ruby "+inst.RubyVersion)) {
+               logger.Print("ruby " + inst.RubyVersion + " already installed")
        } else {
                err = inst.runBash(`
+rubyversion="`+inst.RubyVersion+`"
+rubyminorversion="`+rubyminorversion+`"
 tmp="$(mktemp -d)"
 trap 'rm -r "${tmp}"' ERR EXIT
-wget --progress=dot:giga -O- https://cache.ruby-lang.org/pub/ruby/`+rubymajorversion+`/ruby-`+rubyversion+`.tar.gz | tar -C "${tmp}" -xzf -
-cd "${tmp}/ruby-`+rubyversion+`"
+wget --progress=dot:giga -O- "https://cache.ruby-lang.org/pub/ruby/$rubyminorversion/ruby-$rubyversion.tar.gz" | tar -C "${tmp}" -xzf -
+cd "${tmp}/ruby-$rubyversion"
 ./configure --disable-install-static-library --enable-shared --disable-install-doc --prefix /var/lib/arvados
 make -j8
+rm -f /var/lib/arvados/bin/erb
 make install
-/var/lib/arvados/bin/gem install bundler --no-document
+if [[ "$rubyversion" > "3" ]]; then
+  /var/lib/arvados/bin/gem update --no-document --system 3.4.21
+fi
+/var/lib/arvados/bin/gem install bundler:`+inst.BundlerVersion+` --no-document
 `, stdout, stderr)
                if err != nil {
                        return 1
@@ -299,7 +365,7 @@ make install
                        err = inst.runBash(`
 cd /tmp
 rm -rf /var/lib/arvados/go/
-wget --progress=dot:giga -O- https://storage.googleapis.com/golang/go`+goversion+`.linux-amd64.tar.gz | tar -C /var/lib/arvados -xzf -
+wget --progress=dot:giga -O- https://storage.googleapis.com/golang/go`+goversion+`.linux-`+runtime.GOARCH+`.tar.gz | tar -C /var/lib/arvados -xzf -
 ln -sfv /var/lib/arvados/go/bin/* /usr/local/bin/
 `, stdout, stderr)
                        if err != nil {
@@ -309,32 +375,6 @@ ln -sfv /var/lib/arvados/go/bin/* /usr/local/bin/
        }
 
        if !prod && !pkg {
-               if havepjsversion, err := exec.Command("/usr/local/bin/phantomjs", "--version").CombinedOutput(); err == nil && string(havepjsversion) == "1.9.8\n" {
-                       logger.Print("phantomjs " + pjsversion + " already installed")
-               } else {
-                       err = inst.runBash(`
-PJS=phantomjs-`+pjsversion+`-linux-x86_64
-wget --progress=dot:giga -O- https://cache.arvados.org/$PJS.tar.bz2 | tar -C /var/lib/arvados -xjf -
-ln -sfv /var/lib/arvados/$PJS/bin/phantomjs /usr/local/bin/
-`, stdout, stderr)
-                       if err != nil {
-                               return 1
-                       }
-               }
-
-               if havegeckoversion, err := exec.Command("/usr/local/bin/geckodriver", "--version").CombinedOutput(); err == nil && strings.Contains(string(havegeckoversion), " "+geckoversion+" ") {
-                       logger.Print("geckodriver " + geckoversion + " already installed")
-               } else {
-                       err = inst.runBash(`
-GD=v`+geckoversion+`
-wget --progress=dot:giga -O- https://github.com/mozilla/geckodriver/releases/download/$GD/geckodriver-$GD-linux64.tar.gz | tar -C /var/lib/arvados/bin -xzf - geckodriver
-ln -sfv /var/lib/arvados/bin/geckodriver /usr/local/bin/
-`, stdout, stderr)
-                       if err != nil {
-                               return 1
-                       }
-               }
-
                if havegradleversion, err := exec.Command("/usr/local/bin/gradle", "--version").CombinedOutput(); err == nil && strings.Contains(string(havegradleversion), "Gradle "+gradleversion+"\n") {
                        logger.Print("gradle " + gradleversion + " already installed")
                } else {
@@ -352,15 +392,15 @@ rm ${zip}
                        }
                }
 
-               if havesingularityversion, err := exec.Command("/var/lib/arvados/bin/singularity", "--version").CombinedOutput(); err == nil && strings.Contains(string(havesingularityversion), singularityversion) {
-                       logger.Print("singularity " + singularityversion + " already installed")
+               if havesingularityversion, err := exec.Command("/var/lib/arvados/bin/singularity", "--version").CombinedOutput(); err == nil && strings.Contains(string(havesingularityversion), inst.SingularityVersion) {
+                       logger.Print("singularity " + inst.SingularityVersion + " already installed")
                } else if dev || test {
                        err = inst.runBash(`
-S=`+singularityversion+`
+S=`+inst.SingularityVersion+`
 tmp=/var/lib/arvados/tmp/singularity
 trap "rm -r ${tmp}" ERR EXIT
 cd /var/lib/arvados/tmp
-git clone https://github.com/sylabs/singularity
+git clone --recurse-submodules https://github.com/sylabs/singularity
 cd singularity
 git checkout v${S}
 ./mconfig --prefix=/var/lib/arvados
@@ -501,15 +541,23 @@ setcap "cap_sys_admin+pei cap_sys_chroot+pei" /var/lib/arvados/bin/nsenter
                }
        }
 
+       var njsArch string
+       switch runtime.GOARCH {
+       case "amd64":
+               njsArch = "x64"
+       default:
+               njsArch = runtime.GOARCH
+       }
+
        if !prod {
-               if havenodejsversion, err := exec.Command("/usr/local/bin/node", "--version").CombinedOutput(); err == nil && string(havenodejsversion) == nodejsversion+"\n" {
-                       logger.Print("nodejs " + nodejsversion + " already installed")
+               if havenodejsversion, err := exec.Command("/usr/local/bin/node", "--version").CombinedOutput(); err == nil && string(havenodejsversion) == "v"+inst.NodejsVersion+"\n" {
+                       logger.Print("nodejs " + inst.NodejsVersion + " already installed")
                } else {
                        err = inst.runBash(`
-NJS=`+nodejsversion+`
-rm -rf /var/lib/arvados/node-*-linux-x64
-wget --progress=dot:giga -O- https://nodejs.org/dist/${NJS}/node-${NJS}-linux-x64.tar.xz | sudo tar -C /var/lib/arvados -xJf -
-ln -sfv /var/lib/arvados/node-${NJS}-linux-x64/bin/{node,npm} /usr/local/bin/
+NJS=v`+inst.NodejsVersion+`
+rm -rf /var/lib/arvados/node-*-linux-`+njsArch+`
+wget --progress=dot:giga -O- https://nodejs.org/dist/${NJS}/node-${NJS}-linux-`+njsArch+`.tar.xz | sudo tar -C /var/lib/arvados -xJf -
+ln -sfv /var/lib/arvados/node-${NJS}-linux-`+njsArch+`/bin/{node,npm} /usr/local/bin/
 `, stdout, stderr)
                        if err != nil {
                                return 1
@@ -521,44 +569,12 @@ ln -sfv /var/lib/arvados/node-${NJS}-linux-x64/bin/{node,npm} /usr/local/bin/
                } else {
                        err = inst.runBash(`
 npm install -g yarn
-ln -sfv /var/lib/arvados/node-`+nodejsversion+`-linux-x64/bin/{yarn,yarnpkg} /usr/local/bin/
+ln -sfv /var/lib/arvados/node-v`+inst.NodejsVersion+`-linux-`+njsArch+`/bin/{yarn,yarnpkg} /usr/local/bin/
 `, stdout, stderr)
                        if err != nil {
                                return 1
                        }
                }
-
-               if havewb2version, err := exec.Command("git", "--git-dir=/var/lib/arvados/arvados-workbench2/.git", "log", "-n1", "--format=%H").CombinedOutput(); err == nil && string(havewb2version) == workbench2version+"\n" {
-                       logger.Print("workbench2 repo is already at " + workbench2version)
-               } else {
-                       err = inst.runBash(`
-V=`+workbench2version+`
-cd /var/lib/arvados
-if [[ ! -e arvados-workbench2 ]]; then
-  git clone https://git.arvados.org/arvados-workbench2.git
-  cd arvados-workbench2
-  git checkout $V
-else
-  cd arvados-workbench2
-  if ! git checkout $V; then
-    git fetch
-    git checkout yarn.lock
-    git checkout $V
-  fi
-fi
-rm -rf build
-`, stdout, stderr)
-                       if err != nil {
-                               return 1
-                       }
-               }
-
-               if err = inst.runBash(`
-cd /var/lib/arvados/arvados-workbench2
-yarn install
-`, stdout, stderr); err != nil {
-                       return 1
-               }
        }
 
        if prod || pkg {
@@ -568,7 +584,16 @@ yarn install
                        "cmd/arvados-server",
                } {
                        fmt.Fprintf(stderr, "building %s...\n", srcdir)
-                       cmd := exec.Command("go", "install", "-ldflags", "-X git.arvados.org/arvados.git/lib/cmd.version="+inst.PackageVersion+" -X main.version="+inst.PackageVersion+" -s -w")
+                       // -buildvcs=false here avoids a fatal "error
+                       // obtaining VCS status" when git refuses to
+                       // run (for example) as root in a docker
+                       // container using a non-root-owned git tree
+                       // mounted from the host -- as in
+                       // "arvados-package build".
+                       cmd := exec.Command("go", "install", "-buildvcs=false",
+                               "-ldflags", "-s -w"+
+                                       " -X git.arvados.org/arvados.git/lib/cmd.version="+inst.PackageVersion+
+                                       " -X git.arvados.org/arvados.git/lib/cmd.commit="+inst.Commit)
                        cmd.Env = append(cmd.Env, os.Environ()...)
                        cmd.Env = append(cmd.Env, "GOBIN=/var/lib/arvados/bin")
                        cmd.Dir = filepath.Join(inst.SourcePath, srcdir)
@@ -603,94 +628,103 @@ v=/var/lib/arvados/lib/python
 tmp=/var/lib/arvados/tmp/python
 python3 -m venv "$v"
 . "$v/bin/activate"
-pip3 install --no-cache-dir 'setuptools>=18.5' 'pip>=7'
+pip3 install --no-cache-dir 'setuptools>=68' 'pip>=20'
 export ARVADOS_BUILDING_VERSION="`+inst.PackageVersion+`"
 for src in "`+inst.SourcePath+`/sdk/python" "`+inst.SourcePath+`/services/fuse"; do
   rsync -a --delete-after "$src/" "$tmp/"
-  cd "$tmp"
-  python3 setup.py install
-  cd ..
+  env -C "$tmp" python3 setup.py build
+  pip3 install "$tmp"
   rm -rf "$tmp"
 done
 `, stdout, stderr); err != nil {
                        return 1
                }
 
-               // Install Rails apps to /var/lib/arvados/{railsapi,workbench1}/
-               for dstdir, srcdir := range map[string]string{
-                       "railsapi":   "services/api",
-                       "workbench1": "apps/workbench",
+               // Install RailsAPI to /var/lib/arvados/railsapi/
+               fmt.Fprintln(stderr, "building railsapi...")
+               cmd = exec.Command("rsync",
+                       "-a", "--no-owner", "--no-group", "--delete-after", "--delete-excluded",
+                       "--exclude", "/coverage",
+                       "--exclude", "/log",
+                       "--exclude", "/node_modules",
+                       "--exclude", "/tmp",
+                       "--exclude", "/public/assets",
+                       "--exclude", "/vendor",
+                       "--exclude", "/config/environments",
+                       "./", "/var/lib/arvados/railsapi/")
+               cmd.Dir = filepath.Join(inst.SourcePath, "services", "api")
+               cmd.Stdout = stdout
+               cmd.Stderr = stderr
+               err = cmd.Run()
+               if err != nil {
+                       return 1
+               }
+               for _, cmdline := range [][]string{
+                       {"mkdir", "-p", "log", "public/assets", "tmp", "vendor", ".bundle", "/var/www/.bundle", "/var/www/.gem", "/var/www/.npm", "/var/www/.passenger"},
+                       {"touch", "log/production.log"},
+                       {"chown", "-R", "--from=root", "www-data:www-data", "/var/www/.bundle", "/var/www/.gem", "/var/www/.npm", "/var/www/.passenger", "log", "tmp", "vendor", ".bundle", "Gemfile.lock", "config.ru", "config/environment.rb"},
+                       {"sudo", "-u", "www-data", "/var/lib/arvados/bin/gem", "install", "--user", "--conservative", "--no-document", "bundler:" + inst.BundlerVersion},
+                       {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "config", "set", "--local", "deployment", "true"},
+                       {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "config", "set", "--local", "path", "/var/www/.gem"},
+                       {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "config", "set", "--local", "without", "development test diagnostics performance"},
+                       {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "install", "--jobs", fmt.Sprintf("%d", runtime.NumCPU())},
+
+                       {"chown", "www-data:www-data", ".", "public/assets"},
+                       // {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "config", "set", "--local", "system", "true"},
+                       {"sudo", "-u", "www-data", "ARVADOS_CONFIG=none", "RAILS_GROUPS=assets", "RAILS_ENV=production", "PATH=/var/lib/arvados/bin:" + os.Getenv("PATH"), "/var/lib/arvados/bin/bundle", "exec", "rake", "npm:install"},
+                       {"sudo", "-u", "www-data", "ARVADOS_CONFIG=none", "RAILS_GROUPS=assets", "RAILS_ENV=production", "PATH=/var/lib/arvados/bin:" + os.Getenv("PATH"), "/var/lib/arvados/bin/bundle", "exec", "rake", "assets:precompile"},
+                       {"chown", "root:root", "."},
+                       {"chown", "-R", "root:root", "public/assets", "vendor"},
+
+                       {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "exec", "passenger-config", "build-native-support"},
+                       {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "exec", "passenger-config", "install-standalone-runtime"},
                } {
-                       fmt.Fprintf(stderr, "building %s...\n", srcdir)
-                       cmd := exec.Command("rsync",
-                               "-a", "--no-owner", "--no-group", "--delete-after", "--delete-excluded",
-                               "--exclude", "/coverage",
-                               "--exclude", "/log",
-                               "--exclude", "/node_modules",
-                               "--exclude", "/tmp",
-                               "--exclude", "/public/assets",
-                               "--exclude", "/vendor",
-                               "--exclude", "/config/environments",
-                               "./", "/var/lib/arvados/"+dstdir+"/")
-                       cmd.Dir = filepath.Join(inst.SourcePath, srcdir)
-                       cmd.Stdout = stdout
-                       cmd.Stderr = stderr
-                       err = cmd.Run()
-                       if err != nil {
-                               return 1
-                       }
-                       for _, cmdline := range [][]string{
-                               {"mkdir", "-p", "log", "public/assets", "tmp", "vendor", ".bundle", "/var/www/.bundle", "/var/www/.gem", "/var/www/.npm", "/var/www/.passenger"},
-                               {"touch", "log/production.log"},
-                               {"chown", "-R", "--from=root", "www-data:www-data", "/var/www/.bundle", "/var/www/.gem", "/var/www/.npm", "/var/www/.passenger", "log", "tmp", "vendor", ".bundle", "Gemfile.lock", "config.ru", "config/environment.rb"},
-                               {"sudo", "-u", "www-data", "/var/lib/arvados/bin/gem", "install", "--user", "--conservative", "--no-document", "bundler:" + bundlerversion},
-                               {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "config", "set", "--local", "deployment", "true"},
-                               {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "config", "set", "--local", "path", "/var/www/.gem"},
-                               {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "config", "set", "--local", "without", "development test diagnostics performance"},
-                               {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "install", "--jobs", fmt.Sprintf("%d", runtime.NumCPU())},
-
-                               {"chown", "www-data:www-data", ".", "public/assets"},
-                               // {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "config", "set", "--local", "system", "true"},
-                               {"sudo", "-u", "www-data", "ARVADOS_CONFIG=none", "RAILS_GROUPS=assets", "RAILS_ENV=production", "PATH=/var/lib/arvados/bin:" + os.Getenv("PATH"), "/var/lib/arvados/bin/bundle", "exec", "rake", "npm:install"},
-                               {"sudo", "-u", "www-data", "ARVADOS_CONFIG=none", "RAILS_GROUPS=assets", "RAILS_ENV=production", "PATH=/var/lib/arvados/bin:" + os.Getenv("PATH"), "/var/lib/arvados/bin/bundle", "exec", "rake", "assets:precompile"},
-                               {"chown", "root:root", "."},
-                               {"chown", "-R", "root:root", "public/assets", "vendor"},
-
-                               {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "exec", "passenger-config", "build-native-support"},
-                               {"sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "exec", "passenger-config", "install-standalone-runtime"},
-                       } {
-                               if cmdline[len(cmdline)-2] == "rake" && dstdir != "workbench1" {
-                                       continue
-                               }
-                               cmd = exec.Command(cmdline[0], cmdline[1:]...)
-                               cmd.Dir = "/var/lib/arvados/" + dstdir
-                               cmd.Stdout = stdout
-                               cmd.Stderr = stderr
-                               fmt.Fprintf(stderr, "... %s\n", cmd.Args)
-                               err = cmd.Run()
-                               if err != nil {
-                                       return 1
-                               }
+                       if cmdline[len(cmdline)-2] == "rake" {
+                               continue
                        }
-                       cmd = exec.Command("sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "exec", "passenger-config", "validate-install")
-                       cmd.Dir = "/var/lib/arvados/" + dstdir
+                       cmd = exec.Command(cmdline[0], cmdline[1:]...)
+                       cmd.Dir = "/var/lib/arvados/railsapi"
                        cmd.Stdout = stdout
                        cmd.Stderr = stderr
+                       fmt.Fprintf(stderr, "... %s\n", cmd.Args)
                        err = cmd.Run()
-                       if err != nil && !strings.Contains(err.Error(), "exit status 2") {
-                               // Exit code 2 indicates there were warnings (like
-                               // "other passenger installations have been detected",
-                               // which we can't expect to avoid) but no errors.
-                               // Other non-zero exit codes (1, 9) indicate errors.
+                       if err != nil {
                                return 1
                        }
                }
+               cmd = exec.Command("sudo", "-u", "www-data", "/var/lib/arvados/bin/bundle", "exec", "passenger-config", "validate-install")
+               cmd.Dir = "/var/lib/arvados/railsapi"
+               cmd.Stdout = stdout
+               cmd.Stderr = stderr
+               err = cmd.Run()
+               if err != nil && !strings.Contains(err.Error(), "exit status 2") {
+                       // Exit code 2 indicates there were warnings (like
+                       // "other passenger installations have been detected",
+                       // which we can't expect to avoid) but no errors.
+                       // Other non-zero exit codes (1, 9) indicate errors.
+                       return 1
+               }
 
-               // Install workbench2 app to /var/lib/arvados/workbench2/
+               // Install workbench2 app to
+               // /var/lib/arvados/workbench2/.
+               //
+               // We copy the source tree from the (possibly
+               // readonly) source tree into a temp dir because `yarn
+               // build` writes to {source-tree}/build/. When we
+               // upgrade to react-scripts >= 4.0.2 we may be able to
+               // build from the source dir and write directly to the
+               // final destination (using
+               // YARN_INSTALL_STATE_PATH=/dev/null
+               // BUILD_PATH=/var/lib/arvados/workbench2) instead of
+               // using two rsync steps here.
                if err = inst.runBash(`
-cd /var/lib/arvados/arvados-workbench2
-VERSION="`+inst.PackageVersion+`" BUILD_NUMBER=1 GIT_COMMIT="`+workbench2version[:9]+`" yarn build
-rsync -a --delete-after build/ /var/lib/arvados/workbench2/
+src="`+inst.SourcePath+`/services/workbench2"
+tmp=/var/lib/arvados/tmp/workbench2
+trap "rm -r ${tmp}" ERR EXIT
+dst=/var/lib/arvados/workbench2
+rsync -a --delete-after "$src/" "$tmp/"
+env -C "$tmp" VERSION="`+inst.PackageVersion+`" BUILD_NUMBER=1 GIT_COMMIT="`+inst.Commit[:9]+`" yarn build
+rsync -a --delete-after "$tmp/build/" "$dst/"
 `, stdout, stderr); err != nil {
                        return 1
                }
@@ -769,7 +803,7 @@ rsync -a --delete-after build/ /var/lib/arvados/workbench2/
 type osversion struct {
        Debian bool
        Ubuntu bool
-       Centos bool
+       RedHat bool
        Major  int
 }
 
@@ -807,10 +841,24 @@ func identifyOS() (osversion, error) {
                osv.Ubuntu = true
        case "debian":
                osv.Debian = true
-       case "centos":
-               osv.Centos = true
        default:
-               return osv, fmt.Errorf("unsupported ID in /etc/os-release: %q", kv["ID"])
+               idLikeMatched := false
+               for _, idLike := range strings.Split(kv["ID_LIKE"], " ") {
+                       switch idLike {
+                       case "debian":
+                               osv.Debian = true
+                               idLikeMatched = true
+                       case "rhel":
+                               osv.RedHat = true
+                               idLikeMatched = true
+                       }
+                       if idLikeMatched {
+                               break
+                       }
+               }
+               if !idLikeMatched {
+                       return osv, fmt.Errorf("no supported ID found in /etc/os-release")
+               }
        }
        vstr := kv["VERSION_ID"]
        if i := strings.Index(vstr, "."); i > 0 {
@@ -871,7 +919,7 @@ func prodpkgs(osv osversion) []string {
                return append(pkgs,
                        "mime-support", // keep-web
                )
-       } else if osv.Centos {
+       } else if osv.RedHat {
                return append(pkgs,
                        "fuse-libs", // services/fuse
                        "mailcap",   // keep-web
index 1a69b6e6174e10148d53fb448c27429d47127119..a434c834d180f4d7269c7053dc822b566cd18232 100644 (file)
@@ -20,13 +20,11 @@ var _ = check.Suite(&Suite{})
 
 type Suite struct{}
 
-/*
-       TestExtractGoVersion tests the grep/awk command used in
-       tools/arvbox/bin/arvbox to extract the version of Go to install for
-       bootstrapping `arvados-server`.
-
-       If this test is changed, the arvbox code will also need to be updated.
-*/
+// TestExtractGoVersion tests the grep/awk command used in
+// tools/arvbox/bin/arvbox to extract the version of Go to install for
+// bootstrapping `arvados-server`.
+//
+// If this test is changed, the arvbox code will also need to be updated.
 func (*Suite) TestExtractGoVersion(c *check.C) {
        script := `
   sourcepath="$(realpath ../..)"
index 993e779e5b7944e1da6d68cac34b4d8f00154b33..b9274b425ca6b971653e8e2b817708b6d022c7cc 100644 (file)
@@ -13,19 +13,10 @@ package install
 
 import (
        "os"
-       "testing"
 
        "gopkg.in/check.v1"
 )
 
-func Test(t *testing.T) {
-       check.TestingT(t)
-}
-
-var _ = check.Suite(&Suite{})
-
-type Suite struct{}
-
 func (*Suite) TestInstallDeps(c *check.C) {
        tmp := c.MkDir()
        script := `
@@ -36,13 +27,14 @@ sourcepath="$(realpath ../..)"
 docker run -i --rm --workdir /arvados \
        -v ${tmp}/arvados-server:/arvados-server:ro \
        -v ${sourcepath}:/arvados:ro \
-       -v /arvados/apps/workbench/.bundle \
        -v /arvados/services/api/.bundle \
        -v /arvados/services/api/tmp \
        --env http_proxy \
        --env https_proxy \
-       debian:10 \
-       bash -c "/arvados-server install -type test && /arvados-server boot -type test -config doc/examples/config/zzzzz.yml -own-temporary-database -shutdown -timeout 9m"
+       debian:11 \
+       bash -c "/arvados-server install -type test &&
+           git config --global --add safe.directory /arvados &&
+           /arvados-server boot -type test -config doc/examples/config/zzzzz.yml -own-temporary-database -shutdown -timeout 9m"
 `
-       c.Check(runBash(script, os.Stdout, os.Stderr), check.IsNil)
+       c.Check((&installCommand{}).runBash(script, os.Stdout, os.Stderr), check.IsNil)
 }
index 03d9b7f63b4a031666b7eb5cd37f624122d50b7a..182e1bfeb55657021135fe320d830b99cb8deec0 100644 (file)
@@ -1,17 +1,19 @@
 #!/bin/bash
+#
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
 
 set -e -o pipefail
 
-# Starting with a base debian buster system, like "docker run -it
-# debian:10"...
+# Starting with a base debian bullseye system, like "docker run -it
+# debian:11"...
 
 apt update
 apt upgrade
 apt install --no-install-recommends build-essential ca-certificates git golang
 git clone https://git.arvados.org/arvados.git
-cd arvados
-[[ -e lib/install ]] || git checkout origin/16053-install-deps
-cd cmd/arvados-server
+cd arvados/cmd/arvados-server
 go run ./cmd/arvados-server install -type test
-pg_isready || pg_ctlcluster 11 main start # only needed if there's no init process (as in docker)
+pg_isready || pg_ctlcluster 13 main start # only needed if there's no init process (as in docker)
 build/run-tests.sh
index c362c32b872bec65d7c8f79ad5cb29b8c04f9a8c..d9b74f6a0630600680f8bc3516f97376e72924b7 100644 (file)
@@ -301,8 +301,6 @@ func (initcmd *initCommand) RunCommand(prog string, args []string, stdin io.Read
         DriverParameters:
           Root: /var/lib/arvados/keep
         Replication: 2
-    Workbench:
-      SecretKeyBase: {{printf "%q" ( .RandomHex 50 )}}
     {{if .LoginPAM}}
     Login:
       PAM:
index d1408d23cb1a4e3c2274f40d2f02b66bda29e82d..897e5803f2334d6d7b3a40671c3a1c04e5cdd34d 100644 (file)
@@ -306,6 +306,15 @@ func (disp *dispatcher) bsubArgs(container arvados.Container) ([]string, error)
                container.RuntimeConstraints.KeepCacheRAM+
                int64(disp.Cluster.Containers.ReserveExtraRAM)) / 1048576))
 
+       maxruntime := time.Duration(container.SchedulingParameters.MaxRunTime) * time.Second
+       if maxruntime == 0 {
+               maxruntime = disp.Cluster.Containers.LSF.MaxRunTimeDefault.Duration()
+       }
+       if maxruntime > 0 {
+               maxruntime += disp.Cluster.Containers.LSF.MaxRunTimeOverhead.Duration()
+       }
+       maxrunminutes := int64(math.Ceil(float64(maxruntime.Seconds()) / 60))
+
        repl := map[string]string{
                "%%": "%",
                "%C": fmt.Sprintf("%d", vcpus),
@@ -313,6 +322,7 @@ func (disp *dispatcher) bsubArgs(container arvados.Container) ([]string, error)
                "%T": fmt.Sprintf("%d", tmp),
                "%U": container.UUID,
                "%G": fmt.Sprintf("%d", container.RuntimeConstraints.CUDA.DeviceCount),
+               "%W": fmt.Sprintf("%d", maxrunminutes),
        }
 
        re := regexp.MustCompile(`%.`)
@@ -321,7 +331,16 @@ func (disp *dispatcher) bsubArgs(container arvados.Container) ([]string, error)
        if container.RuntimeConstraints.CUDA.DeviceCount > 0 {
                argumentTemplate = append(argumentTemplate, disp.Cluster.Containers.LSF.BsubCUDAArguments...)
        }
-       for _, a := range argumentTemplate {
+       for idx, a := range argumentTemplate {
+               if idx > 0 && (argumentTemplate[idx-1] == "-W" || argumentTemplate[idx-1] == "-We") && a == "%W" && maxrunminutes == 0 {
+                       // LSF docs don't specify an argument to "-W"
+                       // or "-We" that indicates "unknown", so
+                       // instead we drop the "-W %W" part of the
+                       // command line entirely when max runtime is
+                       // unknown.
+                       args = args[:len(args)-1]
+                       continue
+               }
                args = append(args, re.ReplaceAllStringFunc(a, func(s string) string {
                        subst := repl[s]
                        if len(subst) == 0 {
index cd41071d2cebc8bb62c96764ba767fa7da5e5835..e1e0bcae310ed2a52c9cfa4c8be281ba1ab96716 100644 (file)
@@ -34,6 +34,7 @@ type suite struct {
        crTooBig      arvados.ContainerRequest
        crPending     arvados.ContainerRequest
        crCUDARequest arvados.ContainerRequest
+       crMaxRunTime  arvados.ContainerRequest
 }
 
 func (s *suite) TearDownTest(c *check.C) {
@@ -116,6 +117,25 @@ func (s *suite) SetUpTest(c *check.C) {
        })
        c.Assert(err, check.IsNil)
 
+       err = arvados.NewClientFromEnv().RequestAndDecode(&s.crMaxRunTime, "POST", "arvados/v1/container_requests", nil, map[string]interface{}{
+               "container_request": map[string]interface{}{
+                       "runtime_constraints": arvados.RuntimeConstraints{
+                               RAM:   1000000,
+                               VCPUs: 1,
+                       },
+                       "scheduling_parameters": arvados.SchedulingParameters{
+                               MaxRunTime: 124,
+                       },
+                       "container_image":     arvadostest.DockerImage112PDH,
+                       "command":             []string{"sleep", "123"},
+                       "mounts":              map[string]arvados.Mount{"/mnt/out": {Kind: "tmp", Capacity: 1000}},
+                       "output_path":         "/mnt/out",
+                       "state":               arvados.ContainerRequestStateCommitted,
+                       "priority":            1,
+                       "container_count_max": 1,
+               },
+       })
+       c.Assert(err, check.IsNil)
 }
 
 type lsfstub struct {
@@ -141,12 +161,7 @@ func (stub lsfstub) stubCommand(s *suite, c *check.C) func(prog string, args ...
                }
                switch prog {
                case "bsub":
-                       defaultArgs := s.disp.Cluster.Containers.LSF.BsubArgumentsList
-                       if args[5] == s.crCUDARequest.ContainerUUID {
-                               c.Assert(len(args), check.Equals, len(defaultArgs)+len(s.disp.Cluster.Containers.LSF.BsubCUDAArguments))
-                       } else {
-                               c.Assert(len(args), check.Equals, len(defaultArgs))
-                       }
+                       c.Assert(len(args) > 5, check.Equals, true)
                        // %%J must have been rewritten to %J
                        c.Check(args[1], check.Equals, "/tmp/crunch-run.%J.out")
                        args = args[4:]
@@ -204,6 +219,21 @@ func (stub lsfstub) stubCommand(s *suite, c *check.C) func(prog string, args ...
                                fakejobq[nextjobid] = args[1]
                                nextjobid++
                                mtx.Unlock()
+                       case s.crMaxRunTime.ContainerUUID:
+                               c.Check(args, check.DeepEquals, []string{
+                                       "-J", s.crMaxRunTime.ContainerUUID,
+                                       "-n", "1",
+                                       "-D", "257MB",
+                                       "-R", "rusage[mem=257MB:tmp=2304MB] span[hosts=1]",
+                                       "-R", "select[mem>=257MB]",
+                                       "-R", "select[tmp>=2304MB]",
+                                       "-R", "select[ncpus>=1]",
+                                       "-We", "8", // 124s + 5m overhead + roundup = 8m
+                               })
+                               mtx.Lock()
+                               fakejobq[nextjobid] = args[1]
+                               nextjobid++
+                               mtx.Unlock()
                        default:
                                c.Errorf("unexpected uuid passed to bsub: args %q", args)
                                return exec.Command("false")
index f88d977c4c9bb059e6712fac1c727a71ed22dea7..eab9fd944ce405825c4a3723f5e159f01e151ae5 100644 (file)
@@ -17,8 +17,11 @@ import (
        "git.arvados.org/arvados.git/lib/cmd"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadosclient"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "git.arvados.org/arvados.git/sdk/go/keepclient"
        "github.com/arvados/cgofuse/fuse"
+       "github.com/ghodss/yaml"
+       "github.com/sirupsen/logrus"
 )
 
 var Command = &mountCommand{}
@@ -27,7 +30,7 @@ type mountCommand struct {
        // ready, if non-nil, will be closed when the mount is
        // initialized.  If ready is non-nil, it RunCommand() should
        // not be called more than once, or when ready is already
-       // closed.
+       // closed.  Only intended for testing.
        ready chan struct{}
        // It is safe to call Unmount only after ready has been
        // closed.
@@ -39,19 +42,32 @@ type mountCommand struct {
 // The "-d" fuse option (and perhaps other features) ignores the
 // stderr argument and prints to os.Stderr instead.
 func (c *mountCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
-       logger := log.New(stderr, prog+" ", 0)
+       logger := ctxlog.New(stderr, "text", "info")
+       defer logger.Debug("exiting")
+
        flags := flag.NewFlagSet(prog, flag.ContinueOnError)
        ro := flags.Bool("ro", false, "read-only")
        experimental := flags.Bool("experimental", false, "acknowledge this is an experimental command, and should not be used in production (required)")
-       blockCache := flags.Int("block-cache", 4, "read cache size (number of 64MiB blocks)")
+       cacheSizeStr := flags.String("cache-size", "0", "cache size as percent of home filesystem size (\"5%\") or size (\"10GiB\") or 0 for automatic")
+       logLevel := flags.String("log-level", "info", "logging level (debug, info, ...)")
+       debug := flags.Bool("debug", false, "alias for -log-level=debug")
        pprof := flags.String("pprof", "", "serve Go profile data at `[addr]:port`")
        if ok, code := cmd.ParseFlags(flags, prog, args, "[FUSE mount options]", stderr); !ok {
                return code
        }
        if !*experimental {
-               logger.Printf("error: experimental command %q used without --experimental flag", prog)
+               logger.Errorf("experimental command %q used without --experimental flag", prog)
+               return 2
+       }
+       lvl, err := logrus.ParseLevel(*logLevel)
+       if err != nil {
+               logger.WithError(err).Error("invalid argument for -log-level flag")
                return 2
        }
+       if *debug {
+               lvl = logrus.DebugLevel
+       }
+       logger.SetLevel(lvl)
        if *pprof != "" {
                go func() {
                        log.Println(http.ListenAndServe(*pprof, nil))
@@ -59,26 +75,32 @@ func (c *mountCommand) RunCommand(prog string, args []string, stdin io.Reader, s
        }
 
        client := arvados.NewClientFromEnv()
+       if err := yaml.Unmarshal([]byte(*cacheSizeStr), &client.DiskCacheSize); err != nil {
+               logger.Errorf("error parsing -cache-size argument: %s", err)
+               return 2
+       }
        ac, err := arvadosclient.New(client)
        if err != nil {
-               logger.Print(err)
+               logger.Error(err)
                return 1
        }
        kc, err := keepclient.MakeKeepClient(ac)
        if err != nil {
-               logger.Print(err)
+               logger.Error(err)
                return 1
        }
-       kc.BlockCache = &keepclient.BlockCache{MaxBlocks: *blockCache}
        host := fuse.NewFileSystemHost(&keepFS{
                Client:     client,
                KeepClient: kc,
                ReadOnly:   *ro,
                Uid:        os.Getuid(),
                Gid:        os.Getgid(),
+               Logger:     logger,
                ready:      c.ready,
        })
        c.Unmount = host.Unmount
+
+       logger.WithField("mountargs", flags.Args()).Debug("mounting")
        ok := host.Mount("", flags.Args())
        if !ok {
                return 1
index 3c2e628d0115e361f58150f589060ee14bc57f1b..dece44d25d1c8639bf2bff8a5f3450acc04b1438 100644 (file)
@@ -15,6 +15,7 @@ import (
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/keepclient"
        "github.com/arvados/cgofuse/fuse"
+       "github.com/sirupsen/logrus"
 )
 
 // sharedFile wraps arvados.File with a sync.Mutex, so fuse can safely
@@ -33,6 +34,7 @@ type keepFS struct {
        ReadOnly   bool
        Uid        int
        Gid        int
+       Logger     logrus.FieldLogger
 
        root   arvados.CustomFileSystem
        open   map[uint64]*sharedFile
@@ -79,6 +81,7 @@ func (fs *keepFS) Init() {
 
 func (fs *keepFS) Create(path string, flags int, mode uint32) (errc int, fh uint64) {
        defer fs.debugPanics()
+       fs.debugOp("Create", path)
        if fs.ReadOnly {
                return -fuse.EROFS, invalidFH
        }
@@ -93,6 +96,7 @@ func (fs *keepFS) Create(path string, flags int, mode uint32) (errc int, fh uint
 
 func (fs *keepFS) Open(path string, flags int) (errc int, fh uint64) {
        defer fs.debugPanics()
+       fs.debugOp("Open", path)
        if fs.ReadOnly && flags&(os.O_RDWR|os.O_WRONLY|os.O_CREATE) != 0 {
                return -fuse.EROFS, invalidFH
        }
@@ -110,21 +114,30 @@ func (fs *keepFS) Open(path string, flags int) (errc int, fh uint64) {
 
 func (fs *keepFS) Utimens(path string, tmsp []fuse.Timespec) int {
        defer fs.debugPanics()
+       fs.debugOp("Utimens", path)
        if fs.ReadOnly {
                return -fuse.EROFS
        }
        f, err := fs.root.OpenFile(path, 0, 0)
        if err != nil {
-               return fs.errCode(err)
+               return fs.errCode("Utimens", path, err)
        }
        f.Close()
        return 0
 }
 
-func (fs *keepFS) errCode(err error) int {
+func (fs *keepFS) errCode(op, path string, err error) (errc int) {
        if err == nil {
                return 0
        }
+       defer func() {
+               fs.Logger.WithFields(logrus.Fields{
+                       "op":    op,
+                       "path":  path,
+                       "errno": errc,
+                       "error": err,
+               }).Debug("fuse call returned error")
+       }()
        if errors.Is(err, os.ErrNotExist) {
                return -fuse.ENOENT
        }
@@ -145,12 +158,13 @@ func (fs *keepFS) errCode(err error) int {
 
 func (fs *keepFS) Mkdir(path string, mode uint32) int {
        defer fs.debugPanics()
+       fs.debugOp("Mkdir", path)
        if fs.ReadOnly {
                return -fuse.EROFS
        }
        f, err := fs.root.OpenFile(path, os.O_CREATE|os.O_EXCL, os.FileMode(mode)|os.ModeDir)
        if err != nil {
-               return fs.errCode(err)
+               return fs.errCode("Mkdir", path, err)
        }
        f.Close()
        return 0
@@ -158,11 +172,12 @@ func (fs *keepFS) Mkdir(path string, mode uint32) int {
 
 func (fs *keepFS) Opendir(path string) (errc int, fh uint64) {
        defer fs.debugPanics()
+       fs.debugOp("Opendir", path)
        f, err := fs.root.OpenFile(path, 0, 0)
        if err != nil {
-               return fs.errCode(err), invalidFH
+               return fs.errCode("Opendir", path, err), invalidFH
        } else if fi, err := f.Stat(); err != nil {
-               return fs.errCode(err), invalidFH
+               return fs.errCode("Opendir", path, err), invalidFH
        } else if !fi.IsDir() {
                f.Close()
                return -fuse.ENOTDIR, invalidFH
@@ -172,16 +187,19 @@ func (fs *keepFS) Opendir(path string) (errc int, fh uint64) {
 
 func (fs *keepFS) Releasedir(path string, fh uint64) (errc int) {
        defer fs.debugPanics()
+       fs.debugOp("Releasedir", path)
        return fs.Release(path, fh)
 }
 
 func (fs *keepFS) Rmdir(path string) int {
        defer fs.debugPanics()
-       return fs.errCode(fs.root.Remove(path))
+       fs.debugOp("Rmdir", path)
+       return fs.errCode("Rmdir", path, fs.root.Remove(path))
 }
 
 func (fs *keepFS) Release(path string, fh uint64) (errc int) {
        defer fs.debugPanics()
+       fs.debugOp("Release", path)
        fs.Lock()
        defer fs.Unlock()
        defer delete(fs.open, fh)
@@ -196,22 +214,25 @@ func (fs *keepFS) Release(path string, fh uint64) (errc int) {
 
 func (fs *keepFS) Rename(oldname, newname string) (errc int) {
        defer fs.debugPanics()
+       fs.debugOp("Rename", oldname+" -> "+newname)
        if fs.ReadOnly {
                return -fuse.EROFS
        }
-       return fs.errCode(fs.root.Rename(oldname, newname))
+       return fs.errCode("Rename", oldname+" -> "+newname, fs.root.Rename(oldname, newname))
 }
 
 func (fs *keepFS) Unlink(path string) (errc int) {
        defer fs.debugPanics()
+       fs.debugOp("Unlink", path)
        if fs.ReadOnly {
                return -fuse.EROFS
        }
-       return fs.errCode(fs.root.Remove(path))
+       return fs.errCode("Unlink", path, fs.root.Remove(path))
 }
 
 func (fs *keepFS) Truncate(path string, size int64, fh uint64) (errc int) {
        defer fs.debugPanics()
+       fs.debugOp("Truncate", path)
        if fs.ReadOnly {
                return -fuse.EROFS
        }
@@ -219,20 +240,21 @@ func (fs *keepFS) Truncate(path string, size int64, fh uint64) (errc int) {
        // Sometimes fh is a valid filehandle and we don't need to
        // waste a name lookup.
        if f := fs.lookupFH(fh); f != nil {
-               return fs.errCode(f.Truncate(size))
+               return fs.errCode("Truncate", path, f.Truncate(size))
        }
 
        // Other times, fh is invalid and we need to lookup path.
        f, err := fs.root.OpenFile(path, os.O_RDWR, 0)
        if err != nil {
-               return fs.errCode(err)
+               return fs.errCode("Truncate", path, err)
        }
        defer f.Close()
-       return fs.errCode(f.Truncate(size))
+       return fs.errCode("Truncate", path, f.Truncate(size))
 }
 
 func (fs *keepFS) Getattr(path string, stat *fuse.Stat_t, fh uint64) (errc int) {
        defer fs.debugPanics()
+       fs.debugOp("Getattr", path)
        var fi os.FileInfo
        var err error
        if f := fs.lookupFH(fh); f != nil {
@@ -243,18 +265,20 @@ func (fs *keepFS) Getattr(path string, stat *fuse.Stat_t, fh uint64) (errc int)
                fi, err = fs.root.Stat(path)
        }
        if err != nil {
-               return fs.errCode(err)
+               return fs.errCode("Getattr", path, err)
        }
        fs.fillStat(stat, fi)
        return 0
 }
 
 func (fs *keepFS) Chmod(path string, mode uint32) (errc int) {
+       defer fs.debugPanics()
+       fs.debugOp("Chmod", path)
        if fs.ReadOnly {
                return -fuse.EROFS
        }
        if fi, err := fs.root.Stat(path); err != nil {
-               return fs.errCode(err)
+               return fs.errCode("Chmod", path, err)
        } else if mode & ^uint32(fuse.S_IFREG|fuse.S_IFDIR|0777) != 0 {
                // Refuse to set mode bits other than
                // regfile/dir/perms
@@ -298,6 +322,7 @@ func (fs *keepFS) fillStat(stat *fuse.Stat_t, fi os.FileInfo) {
 
 func (fs *keepFS) Write(path string, buf []byte, ofst int64, fh uint64) (n int) {
        defer fs.debugPanics()
+       fs.debugOp("Write", path)
        if fs.ReadOnly {
                return -fuse.EROFS
        }
@@ -308,18 +333,18 @@ func (fs *keepFS) Write(path string, buf []byte, ofst int64, fh uint64) (n int)
        f.Lock()
        defer f.Unlock()
        if _, err := f.Seek(ofst, io.SeekStart); err != nil {
-               return fs.errCode(err)
+               return fs.errCode("Write", path, err)
        }
        n, err := f.Write(buf)
        if err != nil {
-               log.Printf("error writing %q: %s", path, err)
-               return fs.errCode(err)
+               return fs.errCode("Write", path, err)
        }
        return n
 }
 
 func (fs *keepFS) Read(path string, buf []byte, ofst int64, fh uint64) (n int) {
        defer fs.debugPanics()
+       fs.debugOp("Read", path)
        f := fs.lookupFH(fh)
        if f == nil {
                return -fuse.EBADF
@@ -327,7 +352,7 @@ func (fs *keepFS) Read(path string, buf []byte, ofst int64, fh uint64) (n int) {
        f.Lock()
        defer f.Unlock()
        if _, err := f.Seek(ofst, io.SeekStart); err != nil {
-               return fs.errCode(err)
+               return fs.errCode("Read", path, err)
        }
        n, err := f.Read(buf)
        for err == nil && n < len(buf) {
@@ -341,8 +366,7 @@ func (fs *keepFS) Read(path string, buf []byte, ofst int64, fh uint64) (n int) {
                n += done
        }
        if err != nil && err != io.EOF {
-               log.Printf("error reading %q: %s", path, err)
-               return fs.errCode(err)
+               return fs.errCode("Read", path, err)
        }
        return n
 }
@@ -352,6 +376,7 @@ func (fs *keepFS) Readdir(path string,
        ofst int64,
        fh uint64) (errc int) {
        defer fs.debugPanics()
+       fs.debugOp("Readdir", path)
        f := fs.lookupFH(fh)
        if f == nil {
                return -fuse.EBADF
@@ -361,7 +386,7 @@ func (fs *keepFS) Readdir(path string,
        var stat fuse.Stat_t
        fis, err := f.Readdir(-1)
        if err != nil {
-               return fs.errCode(err)
+               return fs.errCode("Readdir", path, err)
        }
        for _, fi := range fis {
                fs.fillStat(&stat, fi)
@@ -372,14 +397,16 @@ func (fs *keepFS) Readdir(path string,
 
 func (fs *keepFS) Fsync(path string, datasync bool, fh uint64) int {
        defer fs.debugPanics()
+       fs.debugOp("Fsync", path)
        f := fs.lookupFH(fh)
        if f == nil {
                return -fuse.EBADF
        }
-       return fs.errCode(f.Sync())
+       return fs.errCode("Fsync", path, f.Sync())
 }
 
 func (fs *keepFS) Fsyncdir(path string, datasync bool, fh uint64) int {
+       fs.debugOp("Fsyncdir", path)
        return fs.Fsync(path, datasync, fh)
 }
 
@@ -393,3 +420,7 @@ func (fs *keepFS) debugPanics() {
                panic(err)
        }
 }
+
+func (fs *keepFS) debugOp(op, path string) {
+       fs.Logger.WithFields(nil).Tracef("fuse call %s %s", op, path)
+}
index fef2c0f0696ca924227febc8e22f12f66f8c5f1b..442af7a9985550abf4fcf78e32579274ca0ddcc9 100644 (file)
@@ -9,6 +9,7 @@ import (
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadosclient"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "git.arvados.org/arvados.git/sdk/go/keepclient"
        "github.com/arvados/cgofuse/fuse"
        check "gopkg.in/check.v1"
@@ -37,6 +38,7 @@ func (*FSSuite) TestOpendir(c *check.C) {
        var fs fuse.FileSystemInterface = &keepFS{
                Client:     client,
                KeepClient: kc,
+               Logger:     ctxlog.TestLogger(c),
        }
        fs.Init()
        errc, fh := fs.Opendir("/by_id")
index fa16b313beaed8b66b9f1544d148a30c05b78f83..196cb97174952a41fa3daceeb7e5b3665416200a 100644 (file)
@@ -114,7 +114,7 @@ func (s *DockerSuite) runTestClient(c *check.C, args ...string) (stdout, stderr
                "-v", s.tmpdir + "/pam_arvados.so:/usr/lib/pam_arvados.so:ro",
                "-v", s.tmpdir + "/conffile:/usr/share/pam-configs/arvados:ro",
                "-v", s.tmpdir + "/testclient:/testclient:ro",
-               "debian:buster",
+               "debian:bullseye",
                "/testclient"}, args...)...)
        stdout = &bytes.Buffer{}
        stderr = &bytes.Buffer{}
index 43c04a67e2c647cf0ce6942bb6633d309dbea3bc..952fb557c746ce53e6589ff6a6c1aac1bb977bed 100644 (file)
@@ -3,5 +3,8 @@
 # SPDX-License-Identifier: Apache-2.0
 
 fpm_depends+=(ca-certificates)
+case "$TARGET" in
+    rocky*) fpm_depends+=(pam) ;;
+esac
 
 fpm_args+=(--conflicts=libpam-arvados)
index 20441c2a6c4534eb697a85bfc4c369e64ae0aad9..82e95fe0b4c38b8ab0e7cfa49ab6c17da386da00 100644 (file)
@@ -12,10 +12,13 @@ import (
        "io"
        "net"
        "net/http"
+       "net/http/httptest"
        _ "net/http/pprof"
        "net/url"
        "os"
+       "regexp"
        "strings"
+       "time"
 
        "git.arvados.org/arvados.git/lib/cmd"
        "git.arvados.org/arvados.git/lib/config"
@@ -45,6 +48,8 @@ type command struct {
        ctx        context.Context // enables tests to shutdown service; no public API yet
 }
 
+var requestQueueDumpCheckInterval = time.Minute
+
 // Command returns a cmd.Handler that loads site config, calls
 // newHandler with the current cluster and node configs, and brings up
 // an http server with the returned handler.
@@ -150,7 +155,7 @@ func (c *command) RunCommand(prog string, args []string, stdin io.Reader, stdout
                                httpserver.Inspect(reg, cluster.ManagementToken,
                                        httpserver.LogRequests(
                                                interceptHealthReqs(cluster.ManagementToken, handler.CheckHealth,
-                                                       httpserver.NewRequestLimiter(cluster.API.MaxConcurrentRequests, handler, reg)))))))
+                                                       c.requestLimiter(handler, cluster, reg)))))))
        srv := &httpserver.Server{
                Server: http.Server{
                        Handler:     ifCollectionInHost(instrumented, instrumented.ServeAPI(cluster.ManagementToken, instrumented)),
@@ -189,6 +194,7 @@ func (c *command) RunCommand(prog string, args []string, stdin io.Reader, stdout
                <-handler.Done()
                srv.Close()
        }()
+       go c.requestQueueDumpCheck(cluster, prog, reg, &srv.Server, logger)
        err = srv.Wait()
        if err != nil {
                return 1
@@ -196,6 +202,153 @@ func (c *command) RunCommand(prog string, args []string, stdin io.Reader, stdout
        return 0
 }
 
+// If SystemLogs.RequestQueueDumpDirectory is set, monitor the
+// server's incoming HTTP request limiters. When the number of
+// concurrent requests in any queue ("api" or "tunnel") exceeds 90% of
+// its maximum slots, write the /_inspect/requests data to a JSON file
+// in the specified directory.
+func (c *command) requestQueueDumpCheck(cluster *arvados.Cluster, prog string, reg *prometheus.Registry, srv *http.Server, logger logrus.FieldLogger) {
+       outdir := cluster.SystemLogs.RequestQueueDumpDirectory
+       if outdir == "" || cluster.ManagementToken == "" {
+               return
+       }
+       logger = logger.WithField("worker", "RequestQueueDump")
+       outfile := outdir + "/" + prog + "-requests.json"
+       for range time.NewTicker(requestQueueDumpCheckInterval).C {
+               mfs, err := reg.Gather()
+               if err != nil {
+                       logger.WithError(err).Warn("error getting metrics")
+                       continue
+               }
+               cur := map[string]int{} // queue label => current
+               max := map[string]int{} // queue label => max
+               for _, mf := range mfs {
+                       for _, m := range mf.GetMetric() {
+                               for _, ml := range m.GetLabel() {
+                                       if ml.GetName() == "queue" {
+                                               n := int(m.GetGauge().GetValue())
+                                               if name := mf.GetName(); name == "arvados_concurrent_requests" {
+                                                       cur[*ml.Value] = n
+                                               } else if name == "arvados_max_concurrent_requests" {
+                                                       max[*ml.Value] = n
+                                               }
+                                       }
+                               }
+                       }
+               }
+               dump := false
+               for queue, n := range cur {
+                       if n > 0 && max[queue] > 0 && n >= max[queue]*9/10 {
+                               dump = true
+                               break
+                       }
+               }
+               if dump {
+                       req, err := http.NewRequest("GET", "/_inspect/requests", nil)
+                       if err != nil {
+                               logger.WithError(err).Warn("error in http.NewRequest")
+                               continue
+                       }
+                       req.Header.Set("Authorization", "Bearer "+cluster.ManagementToken)
+                       resp := httptest.NewRecorder()
+                       srv.Handler.ServeHTTP(resp, req)
+                       if code := resp.Result().StatusCode; code != http.StatusOK {
+                               logger.WithField("StatusCode", code).Warn("error getting /_inspect/requests")
+                               continue
+                       }
+                       err = os.WriteFile(outfile, resp.Body.Bytes(), 0777)
+                       if err != nil {
+                               logger.WithError(err).Warn("error writing file")
+                               continue
+                       }
+               }
+       }
+}
+
+// Set up a httpserver.RequestLimiter with separate queues/streams for
+// API requests (obeying MaxConcurrentRequests etc) and gateway tunnel
+// requests (obeying MaxGatewayTunnels).
+func (c *command) requestLimiter(handler http.Handler, cluster *arvados.Cluster, reg *prometheus.Registry) http.Handler {
+       maxReqs := cluster.API.MaxConcurrentRequests
+       if maxRails := cluster.API.MaxConcurrentRailsRequests; maxRails > 0 &&
+               (maxRails < maxReqs || maxReqs == 0) &&
+               c.svcName == arvados.ServiceNameController {
+               // Ideally, we would accept up to
+               // MaxConcurrentRequests, and apply the
+               // MaxConcurrentRailsRequests limit only for requests
+               // that require calling upstream to RailsAPI. But for
+               // now we make the simplifying assumption that every
+               // controller request causes an upstream RailsAPI
+               // request.
+               maxReqs = maxRails
+       }
+       rqAPI := &httpserver.RequestQueue{
+               Label:                      "api",
+               MaxConcurrent:              maxReqs,
+               MaxQueue:                   cluster.API.MaxQueuedRequests,
+               MaxQueueTimeForMinPriority: cluster.API.MaxQueueTimeForLockRequests.Duration(),
+       }
+       rqTunnel := &httpserver.RequestQueue{
+               Label:         "tunnel",
+               MaxConcurrent: cluster.API.MaxGatewayTunnels,
+               MaxQueue:      0,
+       }
+       return &httpserver.RequestLimiter{
+               Handler:  handler,
+               Priority: c.requestPriority,
+               Registry: reg,
+               Queue: func(req *http.Request) *httpserver.RequestQueue {
+                       if req.Method == http.MethodPost && reTunnelPath.MatchString(req.URL.Path) {
+                               return rqTunnel
+                       } else {
+                               return rqAPI
+                       }
+               },
+       }
+}
+
+// reTunnelPath matches paths of API endpoints that go in the "tunnel"
+// queue.
+var reTunnelPath = regexp.MustCompile(func() string {
+       rePathVar := regexp.MustCompile(`{.*?}`)
+       out := ""
+       for _, endpoint := range []arvados.APIEndpoint{
+               arvados.EndpointContainerGatewayTunnel,
+               arvados.EndpointContainerGatewayTunnelCompat,
+               arvados.EndpointContainerSSH,
+               arvados.EndpointContainerSSHCompat,
+       } {
+               if out != "" {
+                       out += "|"
+               }
+               out += `\Q/` + rePathVar.ReplaceAllString(endpoint.Path, `\E[^/]*\Q`) + `\E`
+       }
+       return "^(" + out + ")$"
+}())
+
+func (c *command) requestPriority(req *http.Request, queued time.Time) int64 {
+       switch {
+       case req.Method == http.MethodPost && strings.HasPrefix(req.URL.Path, "/arvados/v1/containers/") && strings.HasSuffix(req.URL.Path, "/lock"):
+               // Return 503 immediately instead of queueing. We want
+               // to send feedback to dispatchcloud ASAP to stop
+               // bringing up new containers.
+               return httpserver.MinPriority
+       case req.Method == http.MethodPost && strings.HasPrefix(req.URL.Path, "/arvados/v1/logs"):
+               // "Create log entry" is the most harmless kind of
+               // request to drop. Negative priority is called "low"
+               // in aggregate metrics.
+               return -1
+       case req.Header.Get("Origin") != "":
+               // Handle interactive requests first. Positive
+               // priority is called "high" in aggregate metrics.
+               return 1
+       default:
+               // Zero priority is called "normal" in aggregate
+               // metrics.
+               return 0
+       }
+}
+
 // If an incoming request's target vhost has an embedded collection
 // UUID or PDH, handle it with hTrue, otherwise handle it with
 // hFalse.
index 7db91092745e2e4886f0b1b35a3015da0f0387fc..9ead90019e1302917b7f8d60448eb8b3f27d0bf6 100644 (file)
@@ -9,12 +9,16 @@ import (
        "bytes"
        "context"
        "crypto/tls"
+       "encoding/json"
        "fmt"
        "io/ioutil"
        "net"
        "net/http"
        "net/url"
        "os"
+       "strings"
+       "sync"
+       "sync/atomic"
        "testing"
        "time"
 
@@ -37,15 +41,19 @@ const (
        contextKey key = iota
 )
 
-func (*Suite) TestGetListenAddress(c *check.C) {
+func unusedPort(c *check.C) string {
        // Find an available port on the testing host, so the test
        // cases don't get confused by "already in use" errors.
        listener, err := net.Listen("tcp", ":")
        c.Assert(err, check.IsNil)
-       _, unusedPort, err := net.SplitHostPort(listener.Addr().String())
-       c.Assert(err, check.IsNil)
        listener.Close()
+       _, port, err := net.SplitHostPort(listener.Addr().String())
+       c.Assert(err, check.IsNil)
+       return port
+}
 
+func (*Suite) TestGetListenAddress(c *check.C) {
+       port := unusedPort(c)
        defer os.Unsetenv("ARVADOS_SERVICE_INTERNAL_URL")
        for idx, trial := range []struct {
                // internalURL => listenURL, both with trailing "/"
@@ -58,17 +66,17 @@ func (*Suite) TestGetListenAddress(c *check.C) {
                expectInternal   string
        }{
                {
-                       internalURLs:   map[string]string{"http://localhost:" + unusedPort + "/": ""},
-                       expectListen:   "http://localhost:" + unusedPort + "/",
-                       expectInternal: "http://localhost:" + unusedPort + "/",
+                       internalURLs:   map[string]string{"http://localhost:" + port + "/": ""},
+                       expectListen:   "http://localhost:" + port + "/",
+                       expectInternal: "http://localhost:" + port + "/",
                },
                { // implicit port 80 in InternalURLs
                        internalURLs:     map[string]string{"http://localhost/": ""},
                        expectErrorMatch: `.*:80: bind: permission denied`,
                },
                { // implicit port 443 in InternalURLs
-                       internalURLs:   map[string]string{"https://host.example/": "http://localhost:" + unusedPort + "/"},
-                       expectListen:   "http://localhost:" + unusedPort + "/",
+                       internalURLs:   map[string]string{"https://host.example/": "http://localhost:" + port + "/"},
+                       expectListen:   "http://localhost:" + port + "/",
                        expectInternal: "https://host.example/",
                },
                { // implicit port 443 in ListenURL
@@ -83,16 +91,16 @@ func (*Suite) TestGetListenAddress(c *check.C) {
                {
                        internalURLs: map[string]string{
                                "https://hostname1.example/": "http://localhost:12435/",
-                               "https://hostname2.example/": "http://localhost:" + unusedPort + "/",
+                               "https://hostname2.example/": "http://localhost:" + port + "/",
                        },
                        envVar:         "https://hostname2.example", // note this works despite missing trailing "/"
-                       expectListen:   "http://localhost:" + unusedPort + "/",
+                       expectListen:   "http://localhost:" + port + "/",
                        expectInternal: "https://hostname2.example/",
                },
                { // cannot listen on any of the ListenURLs
                        internalURLs: map[string]string{
-                               "https://hostname1.example/": "http://1.2.3.4:" + unusedPort + "/",
-                               "https://hostname2.example/": "http://1.2.3.4:" + unusedPort + "/",
+                               "https://hostname1.example/": "http://1.2.3.4:" + port + "/",
+                               "https://hostname2.example/": "http://1.2.3.4:" + port + "/",
                        },
                        expectErrorMatch: "configuration does not enable the \"arvados-controller\" service on this host",
                },
@@ -192,7 +200,232 @@ func (*Suite) TestCommand(c *check.C) {
        c.Check(stderr.String(), check.Matches, `(?ms).*"msg":"CheckHealth called".*`)
 }
 
+func (s *Suite) TestTunnelPathRegexp(c *check.C) {
+       c.Check(reTunnelPath.MatchString(`/arvados/v1/connect/zzzzz-dz642-aaaaaaaaaaaaaaa/gateway_tunnel`), check.Equals, true)
+       c.Check(reTunnelPath.MatchString(`/arvados/v1/containers/zzzzz-dz642-aaaaaaaaaaaaaaa/gateway_tunnel`), check.Equals, true)
+       c.Check(reTunnelPath.MatchString(`/arvados/v1/connect/zzzzz-dz642-aaaaaaaaaaaaaaa/ssh`), check.Equals, true)
+       c.Check(reTunnelPath.MatchString(`/arvados/v1/containers/zzzzz-dz642-aaaaaaaaaaaaaaa/ssh`), check.Equals, true)
+       c.Check(reTunnelPath.MatchString(`/blah/arvados/v1/containers/zzzzz-dz642-aaaaaaaaaaaaaaa/ssh`), check.Equals, false)
+       c.Check(reTunnelPath.MatchString(`/arvados/v1/containers/zzzzz-dz642-aaaaaaaaaaaaaaa`), check.Equals, false)
+}
+
+func (s *Suite) TestRequestLimitsAndDumpRequests_Keepweb(c *check.C) {
+       s.testRequestLimitAndDumpRequests(c, arvados.ServiceNameKeepweb, "MaxConcurrentRequests")
+}
+
+func (s *Suite) TestRequestLimitsAndDumpRequests_Controller(c *check.C) {
+       s.testRequestLimitAndDumpRequests(c, arvados.ServiceNameController, "MaxConcurrentRailsRequests")
+}
+
+func (*Suite) testRequestLimitAndDumpRequests(c *check.C, serviceName arvados.ServiceName, maxReqsConfigKey string) {
+       defer func(orig time.Duration) { requestQueueDumpCheckInterval = orig }(requestQueueDumpCheckInterval)
+       requestQueueDumpCheckInterval = time.Second / 10
+
+       port := unusedPort(c)
+       tmpdir := c.MkDir()
+       cf, err := ioutil.TempFile(tmpdir, "cmd_test.")
+       c.Assert(err, check.IsNil)
+       defer os.Remove(cf.Name())
+       defer cf.Close()
+
+       max := 24
+       maxTunnels := 30
+       fmt.Fprintf(cf, `
+Clusters:
+ zzzzz:
+  SystemRootToken: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+  ManagementToken: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
+  API:
+   `+maxReqsConfigKey+`: %d
+   MaxQueuedRequests: 1
+   MaxGatewayTunnels: %d
+  SystemLogs: {RequestQueueDumpDirectory: %q}
+  Services:
+   Controller:
+    ExternalURL: "http://localhost:`+port+`"
+    InternalURLs: {"http://localhost:`+port+`": {}}
+   WebDAV:
+    ExternalURL: "http://localhost:`+port+`"
+    InternalURLs: {"http://localhost:`+port+`": {}}
+`, max, maxTunnels, tmpdir)
+       cf.Close()
+
+       started := make(chan bool, max+1)
+       hold := make(chan bool)
+       handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               if strings.Contains(r.URL.Path, "/ssh") || strings.Contains(r.URL.Path, "/gateway_tunnel") {
+                       <-hold
+               } else {
+                       started <- true
+                       <-hold
+               }
+       })
+       healthCheck := make(chan bool, 1)
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+
+       cmd := Command(serviceName, func(ctx context.Context, _ *arvados.Cluster, token string, reg *prometheus.Registry) Handler {
+               return &testHandler{ctx: ctx, handler: handler, healthCheck: healthCheck}
+       })
+       cmd.(*command).ctx = context.WithValue(ctx, contextKey, "bar")
+
+       exited := make(chan bool)
+       var stdin, stdout, stderr bytes.Buffer
+
+       go func() {
+               cmd.RunCommand(string(serviceName), []string{"-config", cf.Name()}, &stdin, &stdout, &stderr)
+               close(exited)
+       }()
+       select {
+       case <-healthCheck:
+       case <-exited:
+               c.Logf("%s", stderr.String())
+               c.Error("command exited without health check")
+       }
+       client := http.Client{}
+       deadline := time.Now().Add(time.Second * 2)
+       var activeReqs sync.WaitGroup
+
+       // Start some API reqs
+       var apiResp200, apiResp503 int64
+       for i := 0; i < max+1; i++ {
+               activeReqs.Add(1)
+               go func() {
+                       defer activeReqs.Done()
+                       target := "http://localhost:" + port + "/testpath"
+                       resp, err := client.Get(target)
+                       for err != nil && strings.Contains(err.Error(), "dial tcp") && deadline.After(time.Now()) {
+                               time.Sleep(time.Second / 100)
+                               resp, err = client.Get(target)
+                       }
+                       if c.Check(err, check.IsNil) {
+                               if resp.StatusCode == http.StatusOK {
+                                       atomic.AddInt64(&apiResp200, 1)
+                               } else if resp.StatusCode == http.StatusServiceUnavailable {
+                                       atomic.AddInt64(&apiResp503, 1)
+                               }
+                       }
+               }()
+       }
+
+       // Start some gateway tunnel reqs that don't count toward our
+       // API req limit
+       extraTunnelReqs := 20
+       var tunnelResp200, tunnelResp503 int64
+       var paths = []string{
+               "/" + strings.Replace(arvados.EndpointContainerSSH.Path, "{uuid}", "z1234-dz642-abcdeabcdeabcde", -1),
+               "/" + strings.Replace(arvados.EndpointContainerSSHCompat.Path, "{uuid}", "z1234-dz642-abcdeabcdeabcde", -1),
+               "/" + strings.Replace(arvados.EndpointContainerGatewayTunnel.Path, "{uuid}", "z1234-dz642-abcdeabcdeabcde", -1),
+               "/" + strings.Replace(arvados.EndpointContainerGatewayTunnelCompat.Path, "{uuid}", "z1234-dz642-abcdeabcdeabcde", -1),
+       }
+       for i := 0; i < maxTunnels+extraTunnelReqs; i++ {
+               i := i
+               activeReqs.Add(1)
+               go func() {
+                       defer activeReqs.Done()
+                       target := "http://localhost:" + port + paths[i%len(paths)]
+                       resp, err := client.Post(target, "application/octet-stream", nil)
+                       for err != nil && strings.Contains(err.Error(), "dial tcp") && deadline.After(time.Now()) {
+                               time.Sleep(time.Second / 100)
+                               resp, err = client.Post(target, "application/octet-stream", nil)
+                       }
+                       if c.Check(err, check.IsNil) {
+                               if resp.StatusCode == http.StatusOK {
+                                       atomic.AddInt64(&tunnelResp200, 1)
+                               } else if resp.StatusCode == http.StatusServiceUnavailable {
+                                       atomic.AddInt64(&tunnelResp503, 1)
+                               } else {
+                                       c.Errorf("tunnel response code %d", resp.StatusCode)
+                               }
+                       }
+               }()
+       }
+       for i := 0; i < max; i++ {
+               select {
+               case <-started:
+               case <-time.After(time.Second):
+                       c.Logf("%s", stderr.String())
+                       c.Logf("apiResp200 %d", apiResp200)
+                       c.Logf("apiResp503 %d", apiResp503)
+                       c.Logf("tunnelResp200 %d", tunnelResp200)
+                       c.Logf("tunnelResp503 %d", tunnelResp503)
+                       c.Fatal("timed out")
+               }
+       }
+       for delay := time.Second / 100; ; delay = delay * 2 {
+               time.Sleep(delay)
+               j, err := os.ReadFile(tmpdir + "/" + string(serviceName) + "-requests.json")
+               if os.IsNotExist(err) && deadline.After(time.Now()) {
+                       continue
+               }
+               c.Assert(err, check.IsNil)
+               c.Logf("stderr:\n%s", stderr.String())
+               c.Logf("json:\n%s", string(j))
+
+               var loaded []struct{ URL string }
+               err = json.Unmarshal(j, &loaded)
+               c.Check(err, check.IsNil)
+
+               for i := 0; i < len(loaded); i++ {
+                       if strings.Contains(loaded[i].URL, "/ssh") || strings.Contains(loaded[i].URL, "/gateway_tunnel") {
+                               // Filter out a gateway tunnel req
+                               // that doesn't count toward our API
+                               // req limit
+                               if i < len(loaded)-1 {
+                                       copy(loaded[i:], loaded[i+1:])
+                                       i--
+                               }
+                               loaded = loaded[:len(loaded)-1]
+                       }
+               }
+
+               if len(loaded) < max {
+                       // Dumped when #requests was >90% but <100% of
+                       // limit. If we stop now, we won't be able to
+                       // confirm (below) that management endpoints
+                       // are still accessible when normal requests
+                       // are at 100%.
+                       c.Logf("loaded dumped requests, but len %d < max %d -- still waiting", len(loaded), max)
+                       continue
+               }
+               c.Check(loaded, check.HasLen, max+1)
+               c.Check(loaded[0].URL, check.Equals, "/testpath")
+               break
+       }
+
+       for _, path := range []string{"/_inspect/requests", "/metrics"} {
+               req, err := http.NewRequest("GET", "http://localhost:"+port+""+path, nil)
+               c.Assert(err, check.IsNil)
+               req.Header.Set("Authorization", "Bearer bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
+               resp, err := client.Do(req)
+               if !c.Check(err, check.IsNil) {
+                       break
+               }
+               c.Logf("got response for %s", path)
+               c.Check(resp.StatusCode, check.Equals, http.StatusOK)
+               buf, err := ioutil.ReadAll(resp.Body)
+               c.Check(err, check.IsNil)
+               switch path {
+               case "/metrics":
+                       c.Check(string(buf), check.Matches, `(?ms).*arvados_concurrent_requests{queue="api"} `+fmt.Sprintf("%d", max)+`\n.*`)
+                       c.Check(string(buf), check.Matches, `(?ms).*arvados_queued_requests{priority="normal",queue="api"} 1\n.*`)
+               case "/_inspect/requests":
+                       c.Check(string(buf), check.Matches, `(?ms).*"URL":"/testpath".*`)
+               default:
+                       c.Error("oops, testing bug")
+               }
+       }
+       close(hold)
+       activeReqs.Wait()
+       c.Check(int(apiResp200), check.Equals, max+1)
+       c.Check(int(apiResp503), check.Equals, 0)
+       c.Check(int(tunnelResp200), check.Equals, maxTunnels)
+       c.Check(int(tunnelResp503), check.Equals, extraTunnelReqs)
+       cancel()
+}
+
 func (*Suite) TestTLS(c *check.C) {
+       port := unusedPort(c)
        cwd, err := os.Getwd()
        c.Assert(err, check.IsNil)
 
@@ -202,8 +435,8 @@ Clusters:
   SystemRootToken: abcde
   Services:
    Controller:
-    ExternalURL: "https://localhost:12345"
-    InternalURLs: {"https://localhost:12345": {}}
+    ExternalURL: "https://localhost:` + port + `"
+    InternalURLs: {"https://localhost:` + port + `": {}}
   TLS:
    Key: file://` + cwd + `/../../services/api/tmp/self-signed.key
    Certificate: file://` + cwd + `/../../services/api/tmp/self-signed.pem
@@ -228,7 +461,7 @@ Clusters:
                defer close(got)
                client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
                for range time.NewTicker(time.Millisecond).C {
-                       resp, err := client.Get("https://localhost:12345")
+                       resp, err := client.Get("https://localhost:" + port)
                        if err != nil {
                                c.Log(err)
                                continue
diff --git a/lib/webdavfs/fs.go b/lib/webdavfs/fs.go
new file mode 100644 (file)
index 0000000..eaa1a5a
--- /dev/null
@@ -0,0 +1,180 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Package webdavfs adds special behaviors to an arvados.FileSystem so
+// it's suitable to use with a webdav server.
+package webdavfs
+
+import (
+       "context"
+       "crypto/rand"
+       "errors"
+       "fmt"
+       "io"
+       prand "math/rand"
+       "os"
+       "strings"
+       "sync/atomic"
+       "time"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "golang.org/x/net/webdav"
+)
+
+var (
+       lockPrefix     string = uuid()
+       nextLockSuffix int64  = prand.Int63()
+       ErrReadOnly           = errors.New("read-only filesystem")
+)
+
+// FS implements a webdav.FileSystem by wrapping an
+// arvados.CollectionFilesystem.
+type FS struct {
+       FileSystem arvados.FileSystem
+       // Prefix works like fs.Sub: Stat(name) calls
+       // Stat(prefix+name) in the wrapped filesystem.
+       Prefix string
+       // If Writing is false, all write operations return errors.
+       // (Opening a file for writing succeeds -- otherwise webdav
+       // would return 404 -- but writing to it fails.)
+       Writing bool
+       // webdav PROPFIND reads the first few bytes of each file
+       // whose filename extension isn't recognized, which is
+       // prohibitively expensive: we end up fetching multiple 64MiB
+       // blocks. Avoid this by returning EOF on all reads when
+       // handling a PROPFIND.
+       AlwaysReadEOF bool
+}
+
+func (fs *FS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
+       if !fs.Writing {
+               return ErrReadOnly
+       }
+       name = strings.TrimRight(name, "/")
+       return fs.FileSystem.Mkdir(fs.Prefix+name, 0755)
+}
+
+func (fs *FS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (f webdav.File, err error) {
+       writing := flag&(os.O_WRONLY|os.O_RDWR|os.O_TRUNC) != 0
+       f, err = fs.FileSystem.OpenFile(fs.Prefix+name, flag, perm)
+       if !fs.Writing {
+               // webdav module returns 404 on all OpenFile errors,
+               // but returns 405 Method Not Allowed if OpenFile()
+               // succeeds but Write() or Close() fails. We'd rather
+               // have 405. writeFailer ensures Close() fails if the
+               // file is opened for writing *or* Write() is called.
+               var err error
+               if writing {
+                       err = ErrReadOnly
+               }
+               f = writeFailer{File: f, err: err}
+       }
+       if fs.AlwaysReadEOF {
+               f = readEOF{File: f}
+       }
+       return
+}
+
+func (fs *FS) RemoveAll(ctx context.Context, name string) error {
+       return fs.FileSystem.RemoveAll(fs.Prefix + name)
+}
+
+func (fs *FS) Rename(ctx context.Context, oldName, newName string) error {
+       if !fs.Writing {
+               return ErrReadOnly
+       }
+       if strings.HasSuffix(oldName, "/") {
+               // WebDAV "MOVE foo/ bar/" means rename foo to bar.
+               oldName = oldName[:len(oldName)-1]
+               newName = strings.TrimSuffix(newName, "/")
+       }
+       return fs.FileSystem.Rename(fs.Prefix+oldName, fs.Prefix+newName)
+}
+
+func (fs *FS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
+       return fs.FileSystem.Stat(fs.Prefix + name)
+}
+
+type writeFailer struct {
+       webdav.File
+       err error
+}
+
+func (wf writeFailer) Write([]byte) (int, error) {
+       wf.err = ErrReadOnly
+       return 0, wf.err
+}
+
+func (wf writeFailer) Close() error {
+       err := wf.File.Close()
+       if err != nil {
+               wf.err = err
+       }
+       return wf.err
+}
+
+type readEOF struct {
+       webdav.File
+}
+
+func (readEOF) Read(p []byte) (int, error) {
+       return 0, io.EOF
+}
+
+// NoLockSystem implements webdav.LockSystem by returning success for
+// every possible locking operation, even though it has no side
+// effects such as actually locking anything. This works for a
+// read-only webdav filesystem because webdav locks only apply to
+// writes.
+//
+// This is more suitable than webdav.NewMemLS() for two reasons:
+// First, it allows keep-web to use one locker for all collections
+// even though coll1.vhost/foo and coll2.vhost/foo have the same path
+// but represent different resources. Additionally, it returns valid
+// tokens (rfc2518 specifies that tokens are represented as URIs and
+// are unique across all resources for all time), which might improve
+// client compatibility.
+//
+// However, it does also permit impossible operations, like acquiring
+// conflicting locks and releasing non-existent locks.  This might
+// confuse some clients if they try to probe for correctness.
+//
+// Currently this is a moot point: the LOCK and UNLOCK methods are not
+// accepted by keep-web, so it suffices to implement the
+// webdav.LockSystem interface.
+var NoLockSystem = noLockSystem{}
+
+type noLockSystem struct{}
+
+func (noLockSystem) Confirm(time.Time, string, string, ...webdav.Condition) (func(), error) {
+       return noop, nil
+}
+
+func (noLockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
+       return fmt.Sprintf("opaquelocktoken:%s-%x", lockPrefix, atomic.AddInt64(&nextLockSuffix, 1)), nil
+}
+
+func (noLockSystem) Refresh(now time.Time, token string, duration time.Duration) (webdav.LockDetails, error) {
+       return webdav.LockDetails{}, nil
+}
+
+func (noLockSystem) Unlock(now time.Time, token string) error {
+       return nil
+}
+
+func noop() {}
+
+// Return a version 1 variant 4 UUID, meaning all bits are random
+// except the ones indicating the version and variant.
+func uuid() string {
+       var data [16]byte
+       if _, err := rand.Read(data[:]); err != nil {
+               panic(err)
+       }
+       // variant 1: N=10xx
+       data[8] = data[8]&0x3f | 0x80
+       // version 4: M=0100
+       data[6] = data[6]&0x0f | 0x40
+       return fmt.Sprintf("%x-%x-%x-%x-%x", data[0:4], data[4:6], data[6:8], data[8:10], data[10:])
+}
diff --git a/lib/webdavfs/fs_test.go b/lib/webdavfs/fs_test.go
new file mode 100644 (file)
index 0000000..1a6085d
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package webdavfs
+
+import "golang.org/x/net/webdav"
+
+var _ webdav.FileSystem = &FS{}
index 9d685006448884501f2a168715a333734f646f2a..c6c01adebd4f912678c5287fa6c0556f26cb514b 100644 (file)
@@ -1,7 +1,7 @@
 Package: ArvadosR
 Type: Package
 Title: Arvados R SDK
-Version: 2.5.0
+Version: 2.6.0
 Authors@R: c(person("Fuad", "Muhic", role = c("aut", "ctr"), email = "fmuhic@capeannenterprises.com"),
              person("Peter", "Amstutz", role = c("cre"), email = "peter.amstutz@curii.com"),
              person("Piotr", "Nowosielski", role = c("aut"), email = "piotr.nowosielski@contractors.roche.com"),
@@ -11,7 +11,7 @@ URL: http://doc.arvados.org
 License: Apache-2.0
 Encoding: UTF-8
 LazyData: true
-RoxygenNote: 7.1.1
+RoxygenNote: 7.2.3
 Imports:
     R6,
     httr,
index 882e272c36344a1c5aa81ac0dcaa715c2d734a5a..ed65d1fc4cfce0662f2b130ae785f6de91859c26 100644 (file)
@@ -51,7 +51,9 @@ Arvados <- R6::R6Class(
         #' project_exist enables checking if the project with such a UUID exist.
         #' @param uuid The UUID of a project or a file.
         #' @examples
-        #' arv$project_exist(uuid = projectUUID)
+        #' \dontrun{
+        #' arv$project_exist(uuid = "projectUUID")
+        #' }
         project_exist = function(uuid)
         {
             proj <- self$project_list(list(list("uuid", '=', uuid)))
@@ -68,7 +70,9 @@ Arvados <- R6::R6Class(
         #' project_get returns the demanded project.
         #' @param uuid The UUID of the Group in question.
         #' @examples
-        #' project <- arv$project_get(uuid = projectUUID)
+        #' \dontrun{
+        #' project <- arv$project_get(uuid = 'projectUUID')
+        #' }
         project_get = function(uuid)
         {
             self$groups_get(uuid)
@@ -82,8 +86,10 @@ Arvados <- R6::R6Class(
         #' @param properties List of the properties of the project.
         #' @param ensureUniqueName Adjust name to ensure uniqueness instead of returning an error.
         #' @examples
+        #' \dontrun{
         #' Properties <- list() # should contain a list of new properties to be added
         #' new_project <- arv$project_create(name = "project name", description = "project description", owner_uuid = "project UUID", properties = NULL, ensureUniqueName = "false")
+        #' }
         project_create = function(name, description, ownerUUID, properties = NULL, ensureUniqueName = "false")
         {
             group <- list(name = name, description = description, owner_uuid = ownerUUID, properties = properties)
@@ -96,8 +102,10 @@ Arvados <- R6::R6Class(
         #' @param listProperties List of new properties.
         #' @param uuid The UUID of a project or a file.
         #' @examples
+        #' \dontrun{
         #' Properties <- list() # should contain a list of new properties to be added
         #' arv$project_properties_set(Properties, uuid)
+        #' }
         project_properties_set = function(listProperties, uuid)
         {
             group <- c("group_class" = "project", list("properties" = listProperties))
@@ -107,11 +115,13 @@ Arvados <- R6::R6Class(
 
         #' @description
         #' project_properties_append is a method defined in Arvados class that enables appending properties. Allows to add new properties.
-        #' @param listOfNewProperties List of new properties.
+        #' @param properties List of new properties.
         #' @param uuid The UUID of a project or a file.
         #' @examples
+        #' \dontrun{
         #' newProperties <- list() # should contain a list of new properties to be added
         #' arv$project_properties_append(properties = newProperties, uuid)
+        #' }
         project_properties_append = function(properties, uuid)
         {
             proj <- self$project_list(list(list('uuid', '=', uuid)))
@@ -130,7 +140,9 @@ Arvados <- R6::R6Class(
         #' project_properties_get is a method defined in Arvados class that returns properties.
         #' @param uuid The UUID of a project or a file.
         #' @examples
+        #' \dontrun{
         #' arv$project_properties_get(projectUUID)
+        #' }
         project_properties_get = function(uuid)
         {
             proj <- self$project_list(list(list('uuid', '=', uuid)))
@@ -142,8 +154,10 @@ Arvados <- R6::R6Class(
         #' @param oneProp Property to be deleted.
         #' @param uuid The UUID of a project or a file.
         #' @examples
+        #' \dontrun{
         #' Properties <- list() # should contain a list of new properties to be added
         #' arv$project_properties_delete(Properties,  projectUUID)
+        #' }
         project_properties_delete = function(oneProp, uuid)
         {
             proj <- self$project_list(list(list('uuid', '=', uuid))) # find project
@@ -162,8 +176,10 @@ Arvados <- R6::R6Class(
         #' @param ... Feature to be updated (name, description, properties).
         #' @param uuid The UUID of a project in question.
         #' @examples
+        #' \dontrun{
         #' newProperties <- list() # should contain a list of new properties to be added
         #' arv$project_update(name = "new project name", properties = newProperties, uuid = projectUUID)
+        #' }
         project_update = function(..., uuid) {
             vec <- list(...)
             for (i in 1:length(vec))
@@ -190,7 +206,9 @@ Arvados <- R6::R6Class(
         #' @param uuid The UUID of a project in question.
         #' @param recursive Include contents from child groups recursively.
         #' @examples
+        #' \dontrun{
         #' listOfprojects <- arv$project_list(list(list("owner_uuid", "=", projectUUID))) # Sample query which show projects within the project of a given UUID
+        #' }
         project_list = function(filters = NULL, where = NULL,
                                 order = NULL, select = NULL, distinct = NULL,
                                 limit = "100", offset = "0", count = "exact",
@@ -204,6 +222,10 @@ Arvados <- R6::R6Class(
         #' @description
         #' project_delete trashes project of a given uuid. It can be restored from trash or deleted permanently.
         #' @param uuid The UUID of the Group in question.
+        #' @examples
+        #' \dontrun{
+        #' arv$project_delete(uuid = 'projectUUID')
+        #' }
         project_delete = function(uuid)
         {
             self$groups_delete(uuid)
@@ -688,7 +710,10 @@ Arvados <- R6::R6Class(
         #' @description
         #' collections_get is a method defined in Arvados class.
         #' @param uuid The UUID of the Collection in question.
+        #' @examples
+        #' \dontrun{
         #' collection <- arv$collections_get(uuid = collectionUUID)
+        #' }
         collections_get = function(uuid)
         {
             endPoint <- stringr::str_interp("collections/${uuid}")
@@ -718,8 +743,10 @@ Arvados <- R6::R6Class(
         #' @param ensureUniqueName Adjust name to ensure uniqueness instead of returning an error.
         #' @param clusterID Create object on a remote federated cluster instead of the current one.
         #' @examples
+        #' \dontrun{
         #' Properties <- list() # should contain a list of new properties to be added
         #' arv$collections_create(name = "collectionTitle", description = "collectionDescription", ownerUUID = "collectionOwner", properties = Properties)
+        #' }
         collections_create = function(name, description, ownerUUID = NULL, properties = NULL, # name and description are obligatory
                                       ensureUniqueName = "false", clusterID = NULL)
         {
@@ -760,7 +787,9 @@ Arvados <- R6::R6Class(
         #' @param properties New list of properties of the collection.
         #' @param uuid The UUID of the Collection in question.
         #' @examples
+        #' \dontrun{
         #' collection <- arv$collections_update(name = "newCollectionTitle", description = "newCollectionDescription", ownerUUID = "collectionOwner", properties = NULL, uuid = "collectionUUID")
+        #' }
         collections_update = function(name, description, ownerUUID = NULL, properties = NULL, uuid)
         {
             endPoint <- stringr::str_interp("collections/${uuid}")
@@ -790,7 +819,9 @@ Arvados <- R6::R6Class(
         #' collections_delete is a method defined in Arvados class.
         #' @param uuid The UUID of the Collection in question.
         #' @examples
+        #' \dontrun{
         #' arv$collection_delete(collectionUUID)
+        #' }
         collections_delete = function(uuid)
         {
             endPoint <- stringr::str_interp("collections/${uuid}")
@@ -815,7 +846,9 @@ Arvados <- R6::R6Class(
         #' collections_provenance is a method defined in Arvados class, it returns the collection by uuid.
         #' @param uuid The UUID of the Collection in question.
         #' @examples
+        #' \dontrun{
         #' collection <- arv$collections_provenance(collectionUUID)
+        #' }
         collections_provenance = function(uuid)
         {
             endPoint <- stringr::str_interp("collections/${uuid}/provenance")
@@ -863,7 +896,9 @@ Arvados <- R6::R6Class(
         #' collections_trash is a method defined in Arvados class, it moves collection to trash.
         #' @param uuid The UUID of the Collection in question.
         #' @examples
+        #' \dontrun{
         #' arv$collections_trash(collectionUUID)
+        #' }
         collections_trash = function(uuid)
         {
             endPoint <- stringr::str_interp("collections/${uuid}/trash")
@@ -888,7 +923,9 @@ Arvados <- R6::R6Class(
         #' collections_untrash is a method defined in Arvados class, it moves collection from trash to project.
         #' @param uuid The UUID of the Collection in question.
         #' @examples
+        #' \dontrun{
         #' arv$collections_untrash(collectionUUID)
+        #' }
         collections_untrash = function(uuid)
         {
             endPoint <- stringr::str_interp("collections/${uuid}/untrash")
@@ -924,7 +961,9 @@ Arvados <- R6::R6Class(
         #' @param includeTrash Include collections whose is_trashed attribute is true.
         #' @param includeOldVersions Include past collection versions.
         #' @examples
+        #' \dontrun{
         #' collectionList <- arv$collections_list(list(list("name", "=", "Example")))
+        #' }
         collections_list = function(filters = NULL,
                                     where = NULL, order = NULL, select = NULL,
                                     distinct = NULL, limit = "100", offset = "0",
@@ -1826,7 +1865,9 @@ Arvados <- R6::R6Class(
         #' @param uuid The UUID of a project or a file.
         #' @param user The UUID of the person that gets the permission.
         #' @examples
+        #' \dontrun{
         #' arv$project_permission_give(type = "can_read", uuid = objectUUID, user = userUUID)
+        #' }
         project_permission_give = function(type, uuid, user)
         {
             endPoint <- stringr::str_interp("links")
@@ -1860,7 +1901,9 @@ Arvados <- R6::R6Class(
         #' @param uuid The UUID of a project or a file.
         #' @param user The UUID of a person that permissions are taken from.
         #' @examples
+        #' \dontrun{
         #' arv$project_permission_refuse(type = "can_read", uuid = objectUUID, user = userUUID)
+        #' }
         project_permission_refuse = function(type, uuid, user)
         {
             examples <- self$links_list(list(list("head_uuid","=", uuid)))
@@ -1884,7 +1927,9 @@ Arvados <- R6::R6Class(
         #' @param uuid The UUID of a project or a file.
         #' @param user The UUID of the person that the permission is being updated.
         #' @examples
+        #' \dontrun{
         #' arv$project_permission_update(typeOld = "can_read", typeNew = "can_write", uuid = objectUUID, user = userUUID)
+        #' }
         project_permission_update = function(typeOld, typeNew, uuid, user)
         {
             link <- list("name" = typeNew)
@@ -1908,7 +1953,9 @@ Arvados <- R6::R6Class(
         #' @param user The UUID of the person that the permission is being updated.
         #' @param type Possible options are can_read or can_write or can_manage.
         #' @examples
+        #' \dontrun{
         #' arv$project_permission_check(type = "can_read", uuid = objectUUID, user = userUUID)
+        #' }
         project_permission_check = function(uuid, user, type = NULL)
         {
             examples <- self$links_list(list(list("head_uuid","=", uuid)))
index 938d12a7f981ecbc8847ef75e330622e3baa0d90..f585d1f94675092f627e9677a7dc50b85d269a53 100644 (file)
@@ -19,7 +19,9 @@ ArvadosFile <- R6::R6Class(
         #' @param name Name of the new enviroment.
         #' @return A new `ArvadosFile` object.
         #' @examples
+        #' \dontrun{
         #' myFile   <- ArvadosFile$new("myFile")
+        #' }
         initialize = function(name)
         {
             if(name == "")
@@ -31,14 +33,18 @@ ArvadosFile <- R6::R6Class(
         #' @description
         #' Returns name of the file.
         #' @examples
+        #' \dontrun{
         #' arvadosFile$getName()
+        #' }
         getName = function() private$name,
 
         #' @description
         #' Returns collections file content as character vector.
         #' @param fullPath Checking if TRUE.
         #' @examples
+        #' \dontrun{
         #' arvadosFile$getFileListing()
+        #' }
         getFileListing = function(fullpath = TRUE)
         {
             self$getName()
@@ -47,7 +53,9 @@ ArvadosFile <- R6::R6Class(
         #' @description
         #' Returns collections content size in bytes.
         #' @examples
+        #' \dontrun{
         #' arvadosFile$getSizeInBytes()
+        #' }
         getSizeInBytes = function()
         {
             if(is.null(private$collection))
@@ -112,9 +120,11 @@ ArvadosFile <- R6::R6Class(
         #' @param offset Describes the location of a piece of data compared to another location
         #' @param length Length of content
         #' @examples
+        #' \dontrun{
         #' collection <- Collection$new(arv, collectionUUID)
         #' arvadosFile <- collection$get(fileName)
         #' fileContent <- arvadosFile$read("text")
+        #' }
         read = function(contentType = "raw", offset = 0, length = 0)
         {
             if(is.null(private$collection))
@@ -135,9 +145,11 @@ ArvadosFile <- R6::R6Class(
         #' Get connection opened in "read" or "write" mode.
         #' @param rw Type of connection.
         #' @examples
+        #' \dontrun{
         #' collection <- Collection$new(arv, collectionUUID)
         #' arvadosFile <- collection$get(fileName)
         #' arvConnection <- arvadosFile$connection("w")
+        #' }
         connection = function(rw)
         {
             if (rw == "r" || rw == "rb")
@@ -158,10 +170,12 @@ ArvadosFile <- R6::R6Class(
         #' @description
         #' Write connections content to a file or override current content of the file.
         #' @examples
+        #' \dontrun{
         #' collection <- Collection$new(arv, collectionUUID)
         #' arvadosFile <- collection$get(fileName)
         #' myFile$write("This is new file content")
         #' arvadosFile$flush()
+        #' }
         flush = function()
         {
             v <- textConnectionValue(private$buffer)
@@ -174,9 +188,11 @@ ArvadosFile <- R6::R6Class(
         #' @param content File to write.
         #' @param contentType Type of content. Possible is "text", "raw".
         #' @examples
+        #' \dontrun{
         #' collection <- Collection$new(arv, collectionUUID)
         #' arvadosFile <- collection$get(fileName)
         #' myFile$write("This is new file content")
+        #' }
         write = function(content, contentType = "text/html")
         {
             if(is.null(private$collection))
@@ -194,7 +210,9 @@ ArvadosFile <- R6::R6Class(
         #' Moves file to a new location inside collection.
         #' @param destination Path to new folder.
         #' @examples
+        #' \dontrun{
         #' arvadosFile$move(newPath)
+        #' }
         move = function(destination)
         {
             if(is.null(private$collection))
@@ -231,7 +249,9 @@ ArvadosFile <- R6::R6Class(
         #' Copies file to a new location inside collection.
         #' @param destination Path to new folder.
         #' @examples
+        #' \dontrun{
         #' arvadosFile$copy("NewName.format")
+        #' }
         copy = function(destination)
         {
             if(is.null(private$collection))
index d2747bdf1b15d047b5cb5b9430e0f8b61589d4fd..00b068c28a47820f4a7005203921a54390a06d8f 100644 (file)
@@ -17,8 +17,9 @@
 #' \item Piotr Nowosielski}\r
 #'\r
 #' @seealso \itemize{\r
-#' \item \code{\link{https://github.com/arvados/arvados/blob/main/README.md}}\r
-#' \item \code{\link{https://github.com/arvados/arvados/tree/main/sdk/R}}}\r
+#' \item https://arvados.org\r
+#' \item https://doc.arvados.org/sdk/R/index.html\r
+#' \item https://git.arvados.org/arvados.git/tree/HEAD:/sdk/R}\r
 #'\r
 #' @name ArvadosR\r
 NULL\r
index 655bf98b3dbccd417c17e22b8fe8b84ae52b5cb0..9ca74accc56eb27b33c7739980adcaa22ca7da56 100644 (file)
@@ -9,7 +9,7 @@
 #' for exaplme actions like creating, updating, moving or removing are possible.
 #'
 #' @seealso
-#' \code{\link{https://github.com/arvados/arvados/tree/main/sdk/R}}
+#' https://git.arvados.org/arvados.git/tree/HEAD:/sdk/R
 #'
 #' @export
 
@@ -28,7 +28,9 @@ Collection <- R6::R6Class(
         #' @param uuid The UUID Autentic for Collection UUID.
         #' @return A new `Collection` object.
         #' @examples
+        #' \dontrun{
         #' collection <- Collection$new(arv, CollectionUUID)
+        #' }
         initialize = function(api, uuid)
         {
             private$REST <- api$getRESTService()
@@ -89,6 +91,7 @@ Collection <- R6::R6Class(
         #' @param Ncol Used in reading binary file to set numbers of columns in data.frame.
         #' @param Nrow Used in reading binary file to set numbers of rows in data.frame size.
         #' @examples
+        #' \dontrun{
         #' collection <- Collection$new(arv, collectionUUID)
         #' readFile <- collection$readArvFile(arvadosFile, istable = 'yes')                    # table
         #' readFile <- collection$readArvFile(arvadosFile, istable = 'no')                     # text
@@ -96,6 +99,7 @@ Collection <- R6::R6Class(
         #' readFile <- collection$readArvFile(arvadosFile, fileclass = 'fasta')                # fasta
         #' readFile <- collection$readArvFile(arvadosFile, Ncol= 4, Nrow = 32)                 # binary, only numbers
         #' readFile <- collection$readArvFile(arvadosFile, Ncol = 5, Nrow = 150, istable = "factor") # binary with factor or text
+        #' }
         readArvFile = function(file, con, sep = ',', istable = NULL, fileclass = "SeqFastadna", Ncol = NULL, Nrow = NULL, wantedFunction = NULL)
         {
             arvFile <- self$get(file)
@@ -197,6 +201,7 @@ Collection <- R6::R6Class(
         #' @param file File to be saved.
         #' @param istable Used in writing txt file to check if the file is table or not.
         #' @examples
+        #' \dontrun{
         #' collection <- Collection$new(arv, collectionUUID)
         #' writeFile <- collection$writeFile(name = "myoutput.csv", file = file, fileFormat = "csv", istable = NULL, collectionUUID = collectionUUID)             # csv
         #' writeFile <- collection$writeFile(name = "myoutput.tsv", file = file, fileFormat = "tsv", istable = NULL, collectionUUID = collectionUUID)             # tsv
@@ -205,8 +210,9 @@ Collection <- R6::R6Class(
         #' writeFile <- collection$writeFile(name = "myoutputtext.txt", file = file, fileFormat = "txt", istable = "no", collectionUUID = collectionUUID)         # txt text
         #' writeFile <- collection$writeFile(name = "myoutputbinary.dat", file = file, fileFormat = "dat", collectionUUID = collectionUUID)                       # binary
         #' writeFile <- collection$writeFile(name = "myoutputxlsx.xlsx", file = file, fileFormat = "xlsx", collectionUUID = collectionUUID)                       # xlsx
-        writeFile = function(name, file, collectionUUID, fileFormat, istable = NULL, seqName = NULL) {
-
+        #' }
+        writeFile = function(name, file, collectionUUID, fileFormat, istable = NULL, seqName = NULL)
+        {
             # set enviroment
             ARVADOS_API_TOKEN <- Sys.getenv("ARVADOS_API_TOKEN")
             ARVADOS_API_HOST <- Sys.getenv("ARVADOS_API_HOST")
@@ -247,7 +253,9 @@ Collection <- R6::R6Class(
         #' Creates one or more ArvadosFiles and adds them to the collection at specified path.
         #' @param files Content to be created.
         #' @examples
+        #' \dontrun{
         #' collection <- arv$collections_create(name = collectionTitle, description = collectionDescription, owner_uuid = collectionOwner, properties = list("ROX37196928443768648" = "ROX37742976443830153"))
+        #' }
         create = function(files)
         {
             if(is.null(private$tree))
@@ -281,7 +289,9 @@ Collection <- R6::R6Class(
         #' Remove one or more files from the collection.
         #' @param paths Content to be removed.
         #' @examples
+        #' \dontrun{
         #' collection$remove(fileName.format)
+        #' }
         remove = function(paths)
         {
             if(is.null(private$tree))
@@ -320,7 +330,9 @@ Collection <- R6::R6Class(
         #' @param content Content to be moved.
         #' @param destination Path to move content.
         #' @examples
+        #' \dontrun{
         #' collection$move("fileName.format", path)
+        #' }
         move = function(content, destination)
         {
             if(is.null(private$tree))
@@ -341,7 +353,9 @@ Collection <- R6::R6Class(
         #' @param content Content to be moved.
         #' @param destination Path to move content.
         #' @examples
+        #' \dontrun{
         #' copied <- collection$copy("oldName.format", "newName.format")
+        #' }
         copy = function(content, destination)
         {
             if(is.null(private$tree))
@@ -360,7 +374,9 @@ Collection <- R6::R6Class(
         #' @description
         #' Refreshes the environment.
         #' @examples
+        #' \dontrun{
         #' collection$refresh()
+        #' }
         refresh = function()
         {
             if(!is.null(private$tree))
@@ -373,7 +389,9 @@ Collection <- R6::R6Class(
         #' @description
         #' Returns collections file content as character vector.
         #' @examples
+        #' \dontrun{
         #' list <- collection$getFileListing()
+        #' }
         getFileListing = function()
         {
             if(is.null(private$tree))
@@ -387,11 +405,13 @@ Collection <- R6::R6Class(
         #' If relativePath is valid, returns ArvadosFile or Subcollection specified by relativePath, else returns NULL.
         #' @param relativePath Path from content is taken.
         #' @examples
+        #' \dontrun{
         #' arvadosFile <- collection$get(fileName)
+        #' }
         get = function(relativePath)
         {
             if(is.null(private$tree))
-                private$generateCollectionTreeStructure(relativePath)
+                private$generateCollectionTreeStructure()
 
             private$tree$getElement(relativePath)
         },
@@ -434,10 +454,3 @@ print.Collection = function(x, ...)
     cat(paste0("Type: ", "\"", "Arvados Collection", "\""), sep = "\n")
     cat(paste0("uuid: ", "\"", x$uuid,               "\""), sep = "\n")
 }
-
-
-
-
-
-
-
index c86684f8b0a13ab62f53eddc23884a16e2504ffc..fbf58c2f51744173335709d35b517037a92d62ef 100644 (file)
@@ -9,6 +9,10 @@ getAPIDocument <- function(){
     httr::content(serverResponse, as = "parsed", type = "application/json")
 }
 
+#' generateAPI
+#'
+#' Autogenerate classes to interact with Arvados from the Arvados discovery document.
+#'
 #' @export
 generateAPI <- function()
 {
index 939e69b8023b31c8063d4be0253e665ad4071199..fe98e648ca97fbe613e6db98ca6b2c84e03cb089 100644 (file)
@@ -63,41 +63,56 @@ This parameter can be set at any time using `setNumRetries`
 arv$setNumRetries(5)
 ```
 
-### Working with collections
+### Working with Aravdos projects
 
-#### Get a collection:
+##### Create project:
 
 ```r
-collection <- arv$collections_get("uuid")
+newProject <- arv$project_create(name = "project name", description = "project description", owner_uuid = "project UUID", properties = NULL, ensureUniqueName = "false")
 ```
 
-Be aware that the result from `collections_get` is not a Collection class. The object returned from this method lets you access collection fields like “name” and “description”. The Collection class lets you access the files in the collection for reading and writing, and is described in the next section.
+##### Update project:
 
-#### List collections:
+```r
+updatedProject <- arv$project_update(name = "new project name", properties = newProperties, uuid = "projectUUID")
+```
+
+##### Delete a project:
 
 ```r
-# offset of 0 and default limit of 100
-collectionList <- arv$collections_list(list(list("name", "like", "Test%")))
+deletedProject <- arv$project_delete("uuid")
+```
 
-collectionList <- arv$collections_list(list(list("name", "like", "Test%")), limit = 10, offset = 2)
+#### Find a project:
 
-# count of total number of items (may be more than returned due to paging)
-collectionList$items_available
+##### Get a project:
 
-# items which match the filter criteria
-collectionList$items
+```r
+project <- arv$project_get("uuid")
 ```
 
-#### List all collections even if the number of items is greater than maximum API limit:
+##### List projects:
 
 ```r
-collectionList <- listAll(arv$collections_list, list(list("name", "like", "Test%")))
+list subprojects of a project
+projects <- arv$project_list(list(list("owner_uuid", "=", "aaaaa-j7d0g-ccccccccccccccc")))
+
+list projects which have names beginning with Example
+examples <- arv$project_list(list(list("name","like","Example%")))
 ```
 
-#### Delete a collection:
+##### List all projects even if the number of items is greater than maximum API limit:
 
 ```r
-deletedCollection <- arv$collections_delete("uuid")
+projects <- listAll(arv$project_list, list(list("name","like","Example%")))
+```
+
+### Working with collections
+
+#### Create a new collection:
+
+```r
+newCollection <- arv$collections_create(name = "collectionTitle", description = "collectionDescription", ownerUUID = "collectionOwner", properties = Properties)
 ```
 
 #### Update a collection’s metadata:
@@ -106,10 +121,41 @@ deletedCollection <- arv$collections_delete("uuid")
 collection <- arv$collections_update(name = "newCollectionTitle", description = "newCollectionDescription", ownerUUID = "collectionOwner", properties = NULL, uuid =  "collectionUUID")
 ```
 
-#### Create a new collection:
+#### Delete a collection:
 
 ```r
-newCollection <- arv$collections_create(name = "collectionTitle", description = "collectionDescription", ownerUUID = "collectionOwner", properties = Properties)
+deletedCollection <- arv$collections_delete("uuid")
+```
+
+#### Find a collection:
+
+#### Get a collection:
+
+```r
+collection <- arv$collections_get("uuid")
+```
+
+Be aware that the result from `collections_get` is not a Collection class. The object returned from this method lets you access collection fields like “name” and “description”. The Collection class lets you access the files in the collection for reading and writing, and is described in the next section.
+
+#### List collections:
+
+```r
+# offset of 0 and default limit of 100
+collectionList <- arv$collections_list(list(list("name", "like", "Test%")))
+
+collectionList <- arv$collections_list(list(list("name", "like", "Test%")), limit = 10, offset = 2)
+
+# count of total number of items (may be more than returned due to paging)
+collectionList$items_available
+
+# items which match the filter criteria
+collectionList$items
+```
+
+#### List all collections even if the number of items is greater than maximum API limit:
+
+```r
+collectionList <- listAll(arv$collections_list, list(list("name", "like", "Test%")))
 ```
 
 ### Manipulating collection content
@@ -284,47 +330,6 @@ subcollection <- collection$get("location/to/folder")
 subcollection$copy("destination/folder")
 ```
 
-### Working with Aravdos projects
-
-#### Get a project:
-
-```r
-project <- arv$project_get("uuid")
-```
-
-#### List projects:
-
-```r
-list subprojects of a project
-projects <- arv$project_list(list(list("owner_uuid", "=", "aaaaa-j7d0g-ccccccccccccccc")))
-
-list projects which have names beginning with Example
-examples <- arv$project_list(list(list("name","like","Example%")))
-```
-
-#### List all projects even if the number of items is greater than maximum API limit:
-
-```r
-projects <- listAll(arv$project_list, list(list("name","like","Example%")))
-```
-
-##### Delete a project:
-
-```r
-deletedProject <- arv$project_delete("uuid")
-```
-
-##### Update project:
-
-```r
-updatedProject <- arv$project_update(name = "new project name", properties = newProperties, uuid = "projectUUID")
-```
-
-##### Create project:
-
-```r
-newProject <- arv$project_create(name = "project name", description = "project description", owner_uuid = "project UUID", properties = NULL, ensureUniqueName = "false")
-```
 
 ### Help
 
index 6c33f97913f83aaeaebf392fec740ba9f9d0d98a..4e6c5c88f4af70c290b0663ad343c0d75bfd30fd 100644 (file)
@@ -2,7 +2,7 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-options(repos=structure(c(CRAN="http://cran.wustl.edu/")))
+options(repos=structure(c(CRAN="https://cloud.r-project.org/")))
 if (!requireNamespace("devtools")) {
   install.packages("devtools")
 }
@@ -16,10 +16,7 @@ if (!requireNamespace("markdown")) {
   install.packages("markdown")
 }
 if (!requireNamespace("XML")) {
-  # XML 3.99-0.4 depends on R >= 4.0.0, but we run tests on debian
-  # stable (10) with R 3.5.2 so we install an older version from
-  # source.
-  install.packages("https://cran.r-project.org/src/contrib/Archive/XML/XML_3.99-0.3.tar.gz", repos=NULL, type="source")
+  install.packages("XML")
 }
 
 devtools::install_dev_deps()
index d028d0a07856392e0d43bcf8752130f4539490ef..924bfeae9befad890ca358bfacc85a80442e8b69 100644 (file)
@@ -18,260 +18,316 @@ arv <- Arvados$new(authToken = "ARVADOS_API_TOKEN", hostName = "ARVADOS_API_HOST
 ## Method `Arvados$project_exist`\r
 ## ------------------------------------------------\r
 \r
-arv$project_exist(uuid = projectUUID)\r
+\dontrun{\r
+arv$project_exist(uuid = "projectUUID")\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_get`\r
 ## ------------------------------------------------\r
 \r
-project <- arv$project_get(uuid = projectUUID)\r
+\dontrun{\r
+project <- arv$project_get(uuid = 'projectUUID')\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_create`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 Properties <- list() # should contain a list of new properties to be added\r
 new_project <- arv$project_create(name = "project name", description = "project description", owner_uuid = "project UUID", properties = NULL, ensureUniqueName = "false")\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_properties_set`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 Properties <- list() # should contain a list of new properties to be added\r
 arv$project_properties_set(Properties, uuid)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_properties_append`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 newProperties <- list() # should contain a list of new properties to be added\r
 arv$project_properties_append(properties = newProperties, uuid)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_properties_get`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arv$project_properties_get(projectUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_properties_delete`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 Properties <- list() # should contain a list of new properties to be added\r
 arv$project_properties_delete(Properties,  projectUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_update`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 newProperties <- list() # should contain a list of new properties to be added\r
 arv$project_update(name = "new project name", properties = newProperties, uuid = projectUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_list`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 listOfprojects <- arv$project_list(list(list("owner_uuid", "=", projectUUID))) # Sample query which show projects within the project of a given UUID\r
+}\r
+\r
+## ------------------------------------------------\r
+## Method `Arvados$project_delete`\r
+## ------------------------------------------------\r
+\r
+\dontrun{\r
+arv$project_delete(uuid = 'projectUUID')\r
+}\r
+\r
+## ------------------------------------------------\r
+## Method `Arvados$collections_get`\r
+## ------------------------------------------------\r
+\r
+\dontrun{\r
+collection <- arv$collections_get(uuid = collectionUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$collections_create`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 Properties <- list() # should contain a list of new properties to be added\r
 arv$collections_create(name = "collectionTitle", description = "collectionDescription", ownerUUID = "collectionOwner", properties = Properties)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$collections_update`\r
 ## ------------------------------------------------\r
 \r
-collection <- arv$collections_create(name = "newCollectionTitle", description = "newCollectionDescription", ownerUUID = "collectionOwner", properties = NULL)\r
+\dontrun{\r
+collection <- arv$collections_update(name = "newCollectionTitle", description = "newCollectionDescription", ownerUUID = "collectionOwner", properties = NULL, uuid = "collectionUUID")\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$collections_delete`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arv$collection_delete(collectionUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$collections_provenance`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection <- arv$collections_provenance(collectionUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$collections_trash`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arv$collections_trash(collectionUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$collections_untrash`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arv$collections_untrash(collectionUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$collections_list`\r
 ## ------------------------------------------------\r
 \r
-collectionList <- arv$collections.list(list(list("name", "=", "Example")))\r
+\dontrun{\r
+collectionList <- arv$collections_list(list(list("name", "=", "Example")))\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_permission_give`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arv$project_permission_give(type = "can_read", uuid = objectUUID, user = userUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_permission_refuse`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arv$project_permission_refuse(type = "can_read", uuid = objectUUID, user = userUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_permission_update`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arv$project_permission_update(typeOld = "can_read", typeNew = "can_write", uuid = objectUUID, user = userUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Arvados$project_permission_check`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arv$project_permission_check(type = "can_read", uuid = objectUUID, user = userUUID)\r
 }\r
+}\r
 \section{Methods}{\r
 \subsection{Public methods}{\r
 \itemize{\r
-\item \href{#method-new}{\code{Arvados$new()}}\r
-\item \href{#method-project_exist}{\code{Arvados$project_exist()}}\r
-\item \href{#method-project_get}{\code{Arvados$project_get()}}\r
-\item \href{#method-project_create}{\code{Arvados$project_create()}}\r
-\item \href{#method-project_properties_set}{\code{Arvados$project_properties_set()}}\r
-\item \href{#method-project_properties_append}{\code{Arvados$project_properties_append()}}\r
-\item \href{#method-project_properties_get}{\code{Arvados$project_properties_get()}}\r
-\item \href{#method-project_properties_delete}{\code{Arvados$project_properties_delete()}}\r
-\item \href{#method-project_update}{\code{Arvados$project_update()}}\r
-\item \href{#method-project_list}{\code{Arvados$project_list()}}\r
-\item \href{#method-project_delete}{\code{Arvados$project_delete()}}\r
-\item \href{#method-api_clients_get}{\code{Arvados$api_clients_get()}}\r
-\item \href{#method-api_clients_create}{\code{Arvados$api_clients_create()}}\r
-\item \href{#method-api_clients_update}{\code{Arvados$api_clients_update()}}\r
-\item \href{#method-api_clients_delete}{\code{Arvados$api_clients_delete()}}\r
-\item \href{#method-api_clients_list}{\code{Arvados$api_clients_list()}}\r
-\item \href{#method-api_client_authorizations_get}{\code{Arvados$api_client_authorizations_get()}}\r
-\item \href{#method-api_client_authorizations_create}{\code{Arvados$api_client_authorizations_create()}}\r
-\item \href{#method-api_client_authorizations_update}{\code{Arvados$api_client_authorizations_update()}}\r
-\item \href{#method-api_client_authorizations_delete}{\code{Arvados$api_client_authorizations_delete()}}\r
-\item \href{#method-api_client_authorizations_create_system_auth}{\code{Arvados$api_client_authorizations_create_system_auth()}}\r
-\item \href{#method-api_client_authorizations_current}{\code{Arvados$api_client_authorizations_current()}}\r
-\item \href{#method-api_client_authorizations_list}{\code{Arvados$api_client_authorizations_list()}}\r
-\item \href{#method-authorized_keys_get}{\code{Arvados$authorized_keys_get()}}\r
-\item \href{#method-authorized_keys_create}{\code{Arvados$authorized_keys_create()}}\r
-\item \href{#method-authorized_keys_update}{\code{Arvados$authorized_keys_update()}}\r
-\item \href{#method-authorized_keys_delete}{\code{Arvados$authorized_keys_delete()}}\r
-\item \href{#method-authorized_keys_list}{\code{Arvados$authorized_keys_list()}}\r
-\item \href{#method-collections_get}{\code{Arvados$collections_get()}}\r
-\item \href{#method-collections_create}{\code{Arvados$collections_create()}}\r
-\item \href{#method-collections_update}{\code{Arvados$collections_update()}}\r
-\item \href{#method-collections_delete}{\code{Arvados$collections_delete()}}\r
-\item \href{#method-collections_provenance}{\code{Arvados$collections_provenance()}}\r
-\item \href{#method-collections_used_by}{\code{Arvados$collections_used_by()}}\r
-\item \href{#method-collections_trash}{\code{Arvados$collections_trash()}}\r
-\item \href{#method-collections_untrash}{\code{Arvados$collections_untrash()}}\r
-\item \href{#method-collections_list}{\code{Arvados$collections_list()}}\r
-\item \href{#method-containers_get}{\code{Arvados$containers_get()}}\r
-\item \href{#method-containers_create}{\code{Arvados$containers_create()}}\r
-\item \href{#method-containers_update}{\code{Arvados$containers_update()}}\r
-\item \href{#method-containers_delete}{\code{Arvados$containers_delete()}}\r
-\item \href{#method-containers_auth}{\code{Arvados$containers_auth()}}\r
-\item \href{#method-containers_lock}{\code{Arvados$containers_lock()}}\r
-\item \href{#method-containers_unlock}{\code{Arvados$containers_unlock()}}\r
-\item \href{#method-containers_secret_mounts}{\code{Arvados$containers_secret_mounts()}}\r
-\item \href{#method-containers_current}{\code{Arvados$containers_current()}}\r
-\item \href{#method-containers_list}{\code{Arvados$containers_list()}}\r
-\item \href{#method-container_requests_get}{\code{Arvados$container_requests_get()}}\r
-\item \href{#method-container_requests_create}{\code{Arvados$container_requests_create()}}\r
-\item \href{#method-container_requests_update}{\code{Arvados$container_requests_update()}}\r
-\item \href{#method-container_requests_delete}{\code{Arvados$container_requests_delete()}}\r
-\item \href{#method-container_requests_list}{\code{Arvados$container_requests_list()}}\r
-\item \href{#method-groups_get}{\code{Arvados$groups_get()}}\r
-\item \href{#method-groups_create}{\code{Arvados$groups_create()}}\r
-\item \href{#method-groups_update}{\code{Arvados$groups_update()}}\r
-\item \href{#method-groups_delete}{\code{Arvados$groups_delete()}}\r
-\item \href{#method-groups_contents}{\code{Arvados$groups_contents()}}\r
-\item \href{#method-groups_shared}{\code{Arvados$groups_shared()}}\r
-\item \href{#method-groups_trash}{\code{Arvados$groups_trash()}}\r
-\item \href{#method-groups_untrash}{\code{Arvados$groups_untrash()}}\r
-\item \href{#method-groups_list}{\code{Arvados$groups_list()}}\r
-\item \href{#method-keep_services_get}{\code{Arvados$keep_services_get()}}\r
-\item \href{#method-keep_services_create}{\code{Arvados$keep_services_create()}}\r
-\item \href{#method-keep_services_update}{\code{Arvados$keep_services_update()}}\r
-\item \href{#method-keep_services_delete}{\code{Arvados$keep_services_delete()}}\r
-\item \href{#method-keep_services_accessible}{\code{Arvados$keep_services_accessible()}}\r
-\item \href{#method-keep_services_list}{\code{Arvados$keep_services_list()}}\r
-\item \href{#method-project_permission_give}{\code{Arvados$project_permission_give()}}\r
-\item \href{#method-project_permission_refuse}{\code{Arvados$project_permission_refuse()}}\r
-\item \href{#method-project_permission_update}{\code{Arvados$project_permission_update()}}\r
-\item \href{#method-project_permission_check}{\code{Arvados$project_permission_check()}}\r
-\item \href{#method-links_get}{\code{Arvados$links_get()}}\r
-\item \href{#method-links_create}{\code{Arvados$links_create()}}\r
-\item \href{#method-links_update}{\code{Arvados$links_update()}}\r
-\item \href{#method-links_delete}{\code{Arvados$links_delete()}}\r
-\item \href{#method-links_list}{\code{Arvados$links_list()}}\r
-\item \href{#method-links_get_permissions}{\code{Arvados$links_get_permissions()}}\r
-\item \href{#method-logs_get}{\code{Arvados$logs_get()}}\r
-\item \href{#method-logs_create}{\code{Arvados$logs_create()}}\r
-\item \href{#method-logs_update}{\code{Arvados$logs_update()}}\r
-\item \href{#method-logs_delete}{\code{Arvados$logs_delete()}}\r
-\item \href{#method-logs_list}{\code{Arvados$logs_list()}}\r
-\item \href{#method-users_get}{\code{Arvados$users_get()}}\r
-\item \href{#method-users_create}{\code{Arvados$users_create()}}\r
-\item \href{#method-users_update}{\code{Arvados$users_update()}}\r
-\item \href{#method-users_delete}{\code{Arvados$users_delete()}}\r
-\item \href{#method-users_current}{\code{Arvados$users_current()}}\r
-\item \href{#method-users_system}{\code{Arvados$users_system()}}\r
-\item \href{#method-users_activate}{\code{Arvados$users_activate()}}\r
-\item \href{#method-users_setup}{\code{Arvados$users_setup()}}\r
-\item \href{#method-users_unsetup}{\code{Arvados$users_unsetup()}}\r
-\item \href{#method-users_merge}{\code{Arvados$users_merge()}}\r
-\item \href{#method-users_list}{\code{Arvados$users_list()}}\r
-\item \href{#method-repositories_get}{\code{Arvados$repositories_get()}}\r
-\item \href{#method-repositories_create}{\code{Arvados$repositories_create()}}\r
-\item \href{#method-repositories_update}{\code{Arvados$repositories_update()}}\r
-\item \href{#method-repositories_delete}{\code{Arvados$repositories_delete()}}\r
-\item \href{#method-repositories_get_all_permissions}{\code{Arvados$repositories_get_all_permissions()}}\r
-\item \href{#method-repositories_list}{\code{Arvados$repositories_list()}}\r
-\item \href{#method-virtual_machines_get}{\code{Arvados$virtual_machines_get()}}\r
-\item \href{#method-virtual_machines_create}{\code{Arvados$virtual_machines_create()}}\r
-\item \href{#method-virtual_machines_update}{\code{Arvados$virtual_machines_update()}}\r
-\item \href{#method-virtual_machines_delete}{\code{Arvados$virtual_machines_delete()}}\r
-\item \href{#method-virtual_machines_logins}{\code{Arvados$virtual_machines_logins()}}\r
-\item \href{#method-virtual_machines_get_all_logins}{\code{Arvados$virtual_machines_get_all_logins()}}\r
-\item \href{#method-virtual_machines_list}{\code{Arvados$virtual_machines_list()}}\r
-\item \href{#method-workflows_get}{\code{Arvados$workflows_get()}}\r
-\item \href{#method-workflows_create}{\code{Arvados$workflows_create()}}\r
-\item \href{#method-workflows_update}{\code{Arvados$workflows_update()}}\r
-\item \href{#method-workflows_delete}{\code{Arvados$workflows_delete()}}\r
-\item \href{#method-workflows_list}{\code{Arvados$workflows_list()}}\r
-\item \href{#method-user_agreements_get}{\code{Arvados$user_agreements_get()}}\r
-\item \href{#method-user_agreements_create}{\code{Arvados$user_agreements_create()}}\r
-\item \href{#method-user_agreements_update}{\code{Arvados$user_agreements_update()}}\r
-\item \href{#method-user_agreements_delete}{\code{Arvados$user_agreements_delete()}}\r
-\item \href{#method-user_agreements_signatures}{\code{Arvados$user_agreements_signatures()}}\r
-\item \href{#method-user_agreements_sign}{\code{Arvados$user_agreements_sign()}}\r
-\item \href{#method-user_agreements_list}{\code{Arvados$user_agreements_list()}}\r
-\item \href{#method-user_agreements_new}{\code{Arvados$user_agreements_new()}}\r
-\item \href{#method-configs_get}{\code{Arvados$configs_get()}}\r
-\item \href{#method-getHostName}{\code{Arvados$getHostName()}}\r
-\item \href{#method-getToken}{\code{Arvados$getToken()}}\r
-\item \href{#method-setRESTService}{\code{Arvados$setRESTService()}}\r
-\item \href{#method-getRESTService}{\code{Arvados$getRESTService()}}\r
-}\r
-}\r
-\if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-new"></a>}}\r
-\if{latex}{\out{\hypertarget{method-new}{}}}\r
+\item \href{#method-Arvados-new}{\code{Arvados$new()}}\r
+\item \href{#method-Arvados-project_exist}{\code{Arvados$project_exist()}}\r
+\item \href{#method-Arvados-project_get}{\code{Arvados$project_get()}}\r
+\item \href{#method-Arvados-project_create}{\code{Arvados$project_create()}}\r
+\item \href{#method-Arvados-project_properties_set}{\code{Arvados$project_properties_set()}}\r
+\item \href{#method-Arvados-project_properties_append}{\code{Arvados$project_properties_append()}}\r
+\item \href{#method-Arvados-project_properties_get}{\code{Arvados$project_properties_get()}}\r
+\item \href{#method-Arvados-project_properties_delete}{\code{Arvados$project_properties_delete()}}\r
+\item \href{#method-Arvados-project_update}{\code{Arvados$project_update()}}\r
+\item \href{#method-Arvados-project_list}{\code{Arvados$project_list()}}\r
+\item \href{#method-Arvados-project_delete}{\code{Arvados$project_delete()}}\r
+\item \href{#method-Arvados-api_clients_get}{\code{Arvados$api_clients_get()}}\r
+\item \href{#method-Arvados-api_clients_create}{\code{Arvados$api_clients_create()}}\r
+\item \href{#method-Arvados-api_clients_update}{\code{Arvados$api_clients_update()}}\r
+\item \href{#method-Arvados-api_clients_delete}{\code{Arvados$api_clients_delete()}}\r
+\item \href{#method-Arvados-api_clients_list}{\code{Arvados$api_clients_list()}}\r
+\item \href{#method-Arvados-api_client_authorizations_get}{\code{Arvados$api_client_authorizations_get()}}\r
+\item \href{#method-Arvados-api_client_authorizations_create}{\code{Arvados$api_client_authorizations_create()}}\r
+\item \href{#method-Arvados-api_client_authorizations_update}{\code{Arvados$api_client_authorizations_update()}}\r
+\item \href{#method-Arvados-api_client_authorizations_delete}{\code{Arvados$api_client_authorizations_delete()}}\r
+\item \href{#method-Arvados-api_client_authorizations_create_system_auth}{\code{Arvados$api_client_authorizations_create_system_auth()}}\r
+\item \href{#method-Arvados-api_client_authorizations_current}{\code{Arvados$api_client_authorizations_current()}}\r
+\item \href{#method-Arvados-api_client_authorizations_list}{\code{Arvados$api_client_authorizations_list()}}\r
+\item \href{#method-Arvados-authorized_keys_get}{\code{Arvados$authorized_keys_get()}}\r
+\item \href{#method-Arvados-authorized_keys_create}{\code{Arvados$authorized_keys_create()}}\r
+\item \href{#method-Arvados-authorized_keys_update}{\code{Arvados$authorized_keys_update()}}\r
+\item \href{#method-Arvados-authorized_keys_delete}{\code{Arvados$authorized_keys_delete()}}\r
+\item \href{#method-Arvados-authorized_keys_list}{\code{Arvados$authorized_keys_list()}}\r
+\item \href{#method-Arvados-collections_get}{\code{Arvados$collections_get()}}\r
+\item \href{#method-Arvados-collections_create}{\code{Arvados$collections_create()}}\r
+\item \href{#method-Arvados-collections_update}{\code{Arvados$collections_update()}}\r
+\item \href{#method-Arvados-collections_delete}{\code{Arvados$collections_delete()}}\r
+\item \href{#method-Arvados-collections_provenance}{\code{Arvados$collections_provenance()}}\r
+\item \href{#method-Arvados-collections_used_by}{\code{Arvados$collections_used_by()}}\r
+\item \href{#method-Arvados-collections_trash}{\code{Arvados$collections_trash()}}\r
+\item \href{#method-Arvados-collections_untrash}{\code{Arvados$collections_untrash()}}\r
+\item \href{#method-Arvados-collections_list}{\code{Arvados$collections_list()}}\r
+\item \href{#method-Arvados-containers_get}{\code{Arvados$containers_get()}}\r
+\item \href{#method-Arvados-containers_create}{\code{Arvados$containers_create()}}\r
+\item \href{#method-Arvados-containers_update}{\code{Arvados$containers_update()}}\r
+\item \href{#method-Arvados-containers_delete}{\code{Arvados$containers_delete()}}\r
+\item \href{#method-Arvados-containers_auth}{\code{Arvados$containers_auth()}}\r
+\item \href{#method-Arvados-containers_lock}{\code{Arvados$containers_lock()}}\r
+\item \href{#method-Arvados-containers_unlock}{\code{Arvados$containers_unlock()}}\r
+\item \href{#method-Arvados-containers_secret_mounts}{\code{Arvados$containers_secret_mounts()}}\r
+\item \href{#method-Arvados-containers_current}{\code{Arvados$containers_current()}}\r
+\item \href{#method-Arvados-containers_list}{\code{Arvados$containers_list()}}\r
+\item \href{#method-Arvados-container_requests_get}{\code{Arvados$container_requests_get()}}\r
+\item \href{#method-Arvados-container_requests_create}{\code{Arvados$container_requests_create()}}\r
+\item \href{#method-Arvados-container_requests_update}{\code{Arvados$container_requests_update()}}\r
+\item \href{#method-Arvados-container_requests_delete}{\code{Arvados$container_requests_delete()}}\r
+\item \href{#method-Arvados-container_requests_list}{\code{Arvados$container_requests_list()}}\r
+\item \href{#method-Arvados-groups_get}{\code{Arvados$groups_get()}}\r
+\item \href{#method-Arvados-groups_create}{\code{Arvados$groups_create()}}\r
+\item \href{#method-Arvados-groups_update}{\code{Arvados$groups_update()}}\r
+\item \href{#method-Arvados-groups_delete}{\code{Arvados$groups_delete()}}\r
+\item \href{#method-Arvados-groups_contents}{\code{Arvados$groups_contents()}}\r
+\item \href{#method-Arvados-groups_shared}{\code{Arvados$groups_shared()}}\r
+\item \href{#method-Arvados-groups_trash}{\code{Arvados$groups_trash()}}\r
+\item \href{#method-Arvados-groups_untrash}{\code{Arvados$groups_untrash()}}\r
+\item \href{#method-Arvados-groups_list}{\code{Arvados$groups_list()}}\r
+\item \href{#method-Arvados-keep_services_get}{\code{Arvados$keep_services_get()}}\r
+\item \href{#method-Arvados-keep_services_create}{\code{Arvados$keep_services_create()}}\r
+\item \href{#method-Arvados-keep_services_update}{\code{Arvados$keep_services_update()}}\r
+\item \href{#method-Arvados-keep_services_delete}{\code{Arvados$keep_services_delete()}}\r
+\item \href{#method-Arvados-keep_services_accessible}{\code{Arvados$keep_services_accessible()}}\r
+\item \href{#method-Arvados-keep_services_list}{\code{Arvados$keep_services_list()}}\r
+\item \href{#method-Arvados-project_permission_give}{\code{Arvados$project_permission_give()}}\r
+\item \href{#method-Arvados-project_permission_refuse}{\code{Arvados$project_permission_refuse()}}\r
+\item \href{#method-Arvados-project_permission_update}{\code{Arvados$project_permission_update()}}\r
+\item \href{#method-Arvados-project_permission_check}{\code{Arvados$project_permission_check()}}\r
+\item \href{#method-Arvados-links_get}{\code{Arvados$links_get()}}\r
+\item \href{#method-Arvados-links_create}{\code{Arvados$links_create()}}\r
+\item \href{#method-Arvados-links_update}{\code{Arvados$links_update()}}\r
+\item \href{#method-Arvados-links_delete}{\code{Arvados$links_delete()}}\r
+\item \href{#method-Arvados-links_list}{\code{Arvados$links_list()}}\r
+\item \href{#method-Arvados-links_get_permissions}{\code{Arvados$links_get_permissions()}}\r
+\item \href{#method-Arvados-logs_get}{\code{Arvados$logs_get()}}\r
+\item \href{#method-Arvados-logs_create}{\code{Arvados$logs_create()}}\r
+\item \href{#method-Arvados-logs_update}{\code{Arvados$logs_update()}}\r
+\item \href{#method-Arvados-logs_delete}{\code{Arvados$logs_delete()}}\r
+\item \href{#method-Arvados-logs_list}{\code{Arvados$logs_list()}}\r
+\item \href{#method-Arvados-users_get}{\code{Arvados$users_get()}}\r
+\item \href{#method-Arvados-users_create}{\code{Arvados$users_create()}}\r
+\item \href{#method-Arvados-users_update}{\code{Arvados$users_update()}}\r
+\item \href{#method-Arvados-users_delete}{\code{Arvados$users_delete()}}\r
+\item \href{#method-Arvados-users_current}{\code{Arvados$users_current()}}\r
+\item \href{#method-Arvados-users_system}{\code{Arvados$users_system()}}\r
+\item \href{#method-Arvados-users_activate}{\code{Arvados$users_activate()}}\r
+\item \href{#method-Arvados-users_setup}{\code{Arvados$users_setup()}}\r
+\item \href{#method-Arvados-users_unsetup}{\code{Arvados$users_unsetup()}}\r
+\item \href{#method-Arvados-users_merge}{\code{Arvados$users_merge()}}\r
+\item \href{#method-Arvados-users_list}{\code{Arvados$users_list()}}\r
+\item \href{#method-Arvados-repositories_get}{\code{Arvados$repositories_get()}}\r
+\item \href{#method-Arvados-repositories_create}{\code{Arvados$repositories_create()}}\r
+\item \href{#method-Arvados-repositories_update}{\code{Arvados$repositories_update()}}\r
+\item \href{#method-Arvados-repositories_delete}{\code{Arvados$repositories_delete()}}\r
+\item \href{#method-Arvados-repositories_get_all_permissions}{\code{Arvados$repositories_get_all_permissions()}}\r
+\item \href{#method-Arvados-repositories_list}{\code{Arvados$repositories_list()}}\r
+\item \href{#method-Arvados-virtual_machines_get}{\code{Arvados$virtual_machines_get()}}\r
+\item \href{#method-Arvados-virtual_machines_create}{\code{Arvados$virtual_machines_create()}}\r
+\item \href{#method-Arvados-virtual_machines_update}{\code{Arvados$virtual_machines_update()}}\r
+\item \href{#method-Arvados-virtual_machines_delete}{\code{Arvados$virtual_machines_delete()}}\r
+\item \href{#method-Arvados-virtual_machines_logins}{\code{Arvados$virtual_machines_logins()}}\r
+\item \href{#method-Arvados-virtual_machines_get_all_logins}{\code{Arvados$virtual_machines_get_all_logins()}}\r
+\item \href{#method-Arvados-virtual_machines_list}{\code{Arvados$virtual_machines_list()}}\r
+\item \href{#method-Arvados-workflows_get}{\code{Arvados$workflows_get()}}\r
+\item \href{#method-Arvados-workflows_create}{\code{Arvados$workflows_create()}}\r
+\item \href{#method-Arvados-workflows_update}{\code{Arvados$workflows_update()}}\r
+\item \href{#method-Arvados-workflows_delete}{\code{Arvados$workflows_delete()}}\r
+\item \href{#method-Arvados-workflows_list}{\code{Arvados$workflows_list()}}\r
+\item \href{#method-Arvados-user_agreements_get}{\code{Arvados$user_agreements_get()}}\r
+\item \href{#method-Arvados-user_agreements_create}{\code{Arvados$user_agreements_create()}}\r
+\item \href{#method-Arvados-user_agreements_update}{\code{Arvados$user_agreements_update()}}\r
+\item \href{#method-Arvados-user_agreements_delete}{\code{Arvados$user_agreements_delete()}}\r
+\item \href{#method-Arvados-user_agreements_signatures}{\code{Arvados$user_agreements_signatures()}}\r
+\item \href{#method-Arvados-user_agreements_sign}{\code{Arvados$user_agreements_sign()}}\r
+\item \href{#method-Arvados-user_agreements_list}{\code{Arvados$user_agreements_list()}}\r
+\item \href{#method-Arvados-user_agreements_new}{\code{Arvados$user_agreements_new()}}\r
+\item \href{#method-Arvados-configs_get}{\code{Arvados$configs_get()}}\r
+\item \href{#method-Arvados-getHostName}{\code{Arvados$getHostName()}}\r
+\item \href{#method-Arvados-getToken}{\code{Arvados$getToken()}}\r
+\item \href{#method-Arvados-setRESTService}{\code{Arvados$setRESTService()}}\r
+\item \href{#method-Arvados-getRESTService}{\code{Arvados$getRESTService()}}\r
+}\r
+}\r
+\if{html}{\out{<hr>}}\r
+\if{html}{\out{<a id="method-Arvados-new"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-new}{}}}\r
 \subsection{Method \code{new()}}{\r
 Initialize new enviroment.\r
 \subsection{Usage}{\r
@@ -302,8 +358,8 @@ A new `Arvados` object.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_exist"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_exist}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_exist"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_exist}{}}}\r
 \subsection{Method \code{project_exist()}}{\r
 project_exist enables checking if the project with such a UUID exist.\r
 \subsection{Usage}{\r
@@ -319,7 +375,9 @@ project_exist enables checking if the project with such a UUID exist.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arv$project_exist(uuid = projectUUID)\r
+\preformatted{\dontrun{\r
+arv$project_exist(uuid = "projectUUID")\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -327,8 +385,8 @@ project_exist enables checking if the project with such a UUID exist.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_get}{}}}\r
 \subsection{Method \code{project_get()}}{\r
 project_get returns the demanded project.\r
 \subsection{Usage}{\r
@@ -344,7 +402,9 @@ project_get returns the demanded project.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{project <- arv$project_get(uuid = projectUUID)\r
+\preformatted{\dontrun{\r
+project <- arv$project_get(uuid = 'projectUUID')\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -352,8 +412,8 @@ project_get returns the demanded project.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_create}{}}}\r
 \subsection{Method \code{project_create()}}{\r
 project_create creates a new project of a given name and description.\r
 \subsection{Usage}{\r
@@ -383,17 +443,19 @@ project_create creates a new project of a given name and description.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{Properties <- list() # should contain a list of new properties to be added\r
+\preformatted{\dontrun{\r
+Properties <- list() # should contain a list of new properties to be added\r
 new_project <- arv$project_create(name = "project name", description = "project description", owner_uuid = "project UUID", properties = NULL, ensureUniqueName = "false")\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_properties_set"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_properties_set}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_properties_set"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_properties_set}{}}}\r
 \subsection{Method \code{project_properties_set()}}{\r
 project_properties_set is a method defined in Arvados class that enables setting properties. Allows to set or overwrite the properties. In case there are set already it overwrites them.\r
 \subsection{Usage}{\r
@@ -411,17 +473,19 @@ project_properties_set is a method defined in Arvados class that enables setting
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{Properties <- list() # should contain a list of new properties to be added\r
+\preformatted{\dontrun{\r
+Properties <- list() # should contain a list of new properties to be added\r
 arv$project_properties_set(Properties, uuid)\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_properties_append"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_properties_append}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_properties_append"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_properties_append}{}}}\r
 \subsection{Method \code{project_properties_append()}}{\r
 project_properties_append is a method defined in Arvados class that enables appending properties. Allows to add new properties.\r
 \subsection{Usage}{\r
@@ -431,25 +495,27 @@ project_properties_append is a method defined in Arvados class that enables appe
 \subsection{Arguments}{\r
 \if{html}{\out{<div class="arguments">}}\r
 \describe{\r
-\item{\code{uuid}}{The UUID of a project or a file.}\r
+\item{\code{properties}}{List of new properties.}\r
 \r
-\item{\code{listOfNewProperties}}{List of new properties.}\r
+\item{\code{uuid}}{The UUID of a project or a file.}\r
 }\r
 \if{html}{\out{</div>}}\r
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{newProperties <- list() # should contain a list of new properties to be added\r
+\preformatted{\dontrun{\r
+newProperties <- list() # should contain a list of new properties to be added\r
 arv$project_properties_append(properties = newProperties, uuid)\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_properties_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_properties_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_properties_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_properties_get}{}}}\r
 \subsection{Method \code{project_properties_get()}}{\r
 project_properties_get is a method defined in Arvados class that returns properties.\r
 \subsection{Usage}{\r
@@ -465,7 +531,9 @@ project_properties_get is a method defined in Arvados class that returns propert
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arv$project_properties_get(projectUUID)\r
+\preformatted{\dontrun{\r
+arv$project_properties_get(projectUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -473,8 +541,8 @@ project_properties_get is a method defined in Arvados class that returns propert
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_properties_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_properties_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_properties_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_properties_delete}{}}}\r
 \subsection{Method \code{project_properties_delete()}}{\r
 project_properties_delete is a method defined in Arvados class that deletes list of properties.\r
 \subsection{Usage}{\r
@@ -492,17 +560,19 @@ project_properties_delete is a method defined in Arvados class that deletes list
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{Properties <- list() # should contain a list of new properties to be added\r
+\preformatted{\dontrun{\r
+Properties <- list() # should contain a list of new properties to be added\r
 arv$project_properties_delete(Properties,  projectUUID)\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_update}{}}}\r
 \subsection{Method \code{project_update()}}{\r
 project_update enables updating project. New name, description and properties may be given.\r
 \subsection{Usage}{\r
@@ -520,17 +590,19 @@ project_update enables updating project. New name, description and properties ma
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{newProperties <- list() # should contain a list of new properties to be added\r
+\preformatted{\dontrun{\r
+newProperties <- list() # should contain a list of new properties to be added\r
 arv$project_update(name = "new project name", properties = newProperties, uuid = projectUUID)\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_list}{}}}\r
 \subsection{Method \code{project_list()}}{\r
 project_list enables listing project by its name, uuid, properties, permissions.\r
 \subsection{Usage}{\r
@@ -560,7 +632,9 @@ project_list enables listing project by its name, uuid, properties, permissions.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{listOfprojects <- arv$project_list(list(list("owner_uuid", "=", projectUUID))) # Sample query which show projects within the project of a given UUID\r
+\preformatted{\dontrun{\r
+listOfprojects <- arv$project_list(list(list("owner_uuid", "=", projectUUID))) # Sample query which show projects within the project of a given UUID\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -568,8 +642,8 @@ project_list enables listing project by its name, uuid, properties, permissions.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_delete}{}}}\r
 \subsection{Method \code{project_delete()}}{\r
 project_delete trashes project of a given uuid. It can be restored from trash or deleted permanently.\r
 \subsection{Usage}{\r
@@ -583,10 +657,20 @@ project_delete trashes project of a given uuid. It can be restored from trash or
 }\r
 \if{html}{\out{</div>}}\r
 }\r
+\subsection{Examples}{\r
+\if{html}{\out{<div class="r example copy">}}\r
+\preformatted{\dontrun{\r
+arv$project_delete(uuid = 'projectUUID')\r
+}\r
+}\r
+\if{html}{\out{</div>}}\r
+\r
+}\r
+\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_clients_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_clients_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_clients_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_clients_get}{}}}\r
 \subsection{Method \code{api_clients_get()}}{\r
 api_clients_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -602,8 +686,8 @@ api_clients_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_clients_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_clients_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_clients_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_clients_create}{}}}\r
 \subsection{Method \code{api_clients_create()}}{\r
 api_clients_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -627,8 +711,8 @@ api_clients_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_clients_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_clients_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_clients_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_clients_update}{}}}\r
 \subsection{Method \code{api_clients_update()}}{\r
 api_clients_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -646,8 +730,8 @@ api_clients_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_clients_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_clients_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_clients_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_clients_delete}{}}}\r
 \subsection{Method \code{api_clients_delete()}}{\r
 api_clients_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -663,8 +747,8 @@ api_clients_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_clients_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_clients_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_clients_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_clients_list}{}}}\r
 \subsection{Method \code{api_clients_list()}}{\r
 api_clients_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -693,8 +777,8 @@ api_clients_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_client_authorizations_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_client_authorizations_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_client_authorizations_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_client_authorizations_get}{}}}\r
 \subsection{Method \code{api_client_authorizations_get()}}{\r
 api_client_authorizations_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -710,8 +794,8 @@ api_client_authorizations_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_client_authorizations_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_client_authorizations_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_client_authorizations_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_client_authorizations_create}{}}}\r
 \subsection{Method \code{api_client_authorizations_create()}}{\r
 api_client_authorizations_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -735,8 +819,8 @@ api_client_authorizations_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_client_authorizations_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_client_authorizations_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_client_authorizations_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_client_authorizations_update}{}}}\r
 \subsection{Method \code{api_client_authorizations_update()}}{\r
 api_client_authorizations_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -754,8 +838,8 @@ api_client_authorizations_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_client_authorizations_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_client_authorizations_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_client_authorizations_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_client_authorizations_delete}{}}}\r
 \subsection{Method \code{api_client_authorizations_delete()}}{\r
 api_client_authorizations_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -771,8 +855,8 @@ api_client_authorizations_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_client_authorizations_create_system_auth"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_client_authorizations_create_system_auth}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_client_authorizations_create_system_auth"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_client_authorizations_create_system_auth}{}}}\r
 \subsection{Method \code{api_client_authorizations_create_system_auth()}}{\r
 api_client_authorizations_create_system_auth is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -784,8 +868,8 @@ api_client_authorizations_create_system_auth is a method defined in Arvados clas
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_client_authorizations_current"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_client_authorizations_current}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_client_authorizations_current"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_client_authorizations_current}{}}}\r
 \subsection{Method \code{api_client_authorizations_current()}}{\r
 api_client_authorizations_current is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -794,8 +878,8 @@ api_client_authorizations_current is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-api_client_authorizations_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-api_client_authorizations_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-api_client_authorizations_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-api_client_authorizations_list}{}}}\r
 \subsection{Method \code{api_client_authorizations_list()}}{\r
 api_client_authorizations_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -824,8 +908,8 @@ api_client_authorizations_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-authorized_keys_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-authorized_keys_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-authorized_keys_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-authorized_keys_get}{}}}\r
 \subsection{Method \code{authorized_keys_get()}}{\r
 authorized_keys_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -841,8 +925,8 @@ authorized_keys_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-authorized_keys_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-authorized_keys_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-authorized_keys_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-authorized_keys_create}{}}}\r
 \subsection{Method \code{authorized_keys_create()}}{\r
 authorized_keys_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -866,8 +950,8 @@ authorized_keys_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-authorized_keys_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-authorized_keys_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-authorized_keys_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-authorized_keys_update}{}}}\r
 \subsection{Method \code{authorized_keys_update()}}{\r
 authorized_keys_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -885,8 +969,8 @@ authorized_keys_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-authorized_keys_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-authorized_keys_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-authorized_keys_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-authorized_keys_delete}{}}}\r
 \subsection{Method \code{authorized_keys_delete()}}{\r
 authorized_keys_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -902,8 +986,8 @@ authorized_keys_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-authorized_keys_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-authorized_keys_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-authorized_keys_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-authorized_keys_list}{}}}\r
 \subsection{Method \code{authorized_keys_list()}}{\r
 authorized_keys_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -932,8 +1016,8 @@ authorized_keys_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-collections_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-collections_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-collections_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-collections_get}{}}}\r
 \subsection{Method \code{collections_get()}}{\r
 collections_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -943,15 +1027,24 @@ collections_get is a method defined in Arvados class.
 \subsection{Arguments}{\r
 \if{html}{\out{<div class="arguments">}}\r
 \describe{\r
-\item{\code{uuid}}{The UUID of the Collection in question.\r
-collection <- arv$collections_get(uuid = collectionUUID)}\r
+\item{\code{uuid}}{The UUID of the Collection in question.}\r
+}\r
+\if{html}{\out{</div>}}\r
+}\r
+\subsection{Examples}{\r
+\if{html}{\out{<div class="r example copy">}}\r
+\preformatted{\dontrun{\r
+collection <- arv$collections_get(uuid = collectionUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
+\r
 }\r
+\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-collections_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-collections_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-collections_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-collections_create}{}}}\r
 \subsection{Method \code{collections_create()}}{\r
 collections_create is a method defined in Arvados class that enables collections creation.\r
 \subsection{Usage}{\r
@@ -984,17 +1077,19 @@ collections_create is a method defined in Arvados class that enables collections
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{Properties <- list() # should contain a list of new properties to be added\r
+\preformatted{\dontrun{\r
+Properties <- list() # should contain a list of new properties to be added\r
 arv$collections_create(name = "collectionTitle", description = "collectionDescription", ownerUUID = "collectionOwner", properties = Properties)\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-collections_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-collections_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-collections_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-collections_update}{}}}\r
 \subsection{Method \code{collections_update()}}{\r
 collections_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1024,7 +1119,9 @@ collections_update is a method defined in Arvados class.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- arv$collections_create(name = "newCollectionTitle", description = "newCollectionDescription", ownerUUID = "collectionOwner", properties = NULL)\r
+\preformatted{\dontrun{\r
+collection <- arv$collections_update(name = "newCollectionTitle", description = "newCollectionDescription", ownerUUID = "collectionOwner", properties = NULL, uuid = "collectionUUID")\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1032,8 +1129,8 @@ collections_update is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-collections_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-collections_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-collections_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-collections_delete}{}}}\r
 \subsection{Method \code{collections_delete()}}{\r
 collections_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1049,7 +1146,9 @@ collections_delete is a method defined in Arvados class.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arv$collection_delete(collectionUUID)\r
+\preformatted{\dontrun{\r
+arv$collection_delete(collectionUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1057,8 +1156,8 @@ collections_delete is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-collections_provenance"></a>}}\r
-\if{latex}{\out{\hypertarget{method-collections_provenance}{}}}\r
+\if{html}{\out{<a id="method-Arvados-collections_provenance"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-collections_provenance}{}}}\r
 \subsection{Method \code{collections_provenance()}}{\r
 collections_provenance is a method defined in Arvados class, it returns the collection by uuid.\r
 \subsection{Usage}{\r
@@ -1074,7 +1173,9 @@ collections_provenance is a method defined in Arvados class, it returns the coll
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- arv$collections_provenance(collectionUUID)\r
+\preformatted{\dontrun{\r
+collection <- arv$collections_provenance(collectionUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1082,8 +1183,8 @@ collections_provenance is a method defined in Arvados class, it returns the coll
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-collections_used_by"></a>}}\r
-\if{latex}{\out{\hypertarget{method-collections_used_by}{}}}\r
+\if{html}{\out{<a id="method-Arvados-collections_used_by"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-collections_used_by}{}}}\r
 \subsection{Method \code{collections_used_by()}}{\r
 collections_used_by is a method defined in Arvados class, it returns collection by portable_data_hash.\r
 \subsection{Usage}{\r
@@ -1099,8 +1200,8 @@ collections_used_by is a method defined in Arvados class, it returns collection
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-collections_trash"></a>}}\r
-\if{latex}{\out{\hypertarget{method-collections_trash}{}}}\r
+\if{html}{\out{<a id="method-Arvados-collections_trash"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-collections_trash}{}}}\r
 \subsection{Method \code{collections_trash()}}{\r
 collections_trash is a method defined in Arvados class, it moves collection to trash.\r
 \subsection{Usage}{\r
@@ -1116,7 +1217,9 @@ collections_trash is a method defined in Arvados class, it moves collection to t
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arv$collections_trash(collectionUUID)\r
+\preformatted{\dontrun{\r
+arv$collections_trash(collectionUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1124,8 +1227,8 @@ collections_trash is a method defined in Arvados class, it moves collection to t
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-collections_untrash"></a>}}\r
-\if{latex}{\out{\hypertarget{method-collections_untrash}{}}}\r
+\if{html}{\out{<a id="method-Arvados-collections_untrash"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-collections_untrash}{}}}\r
 \subsection{Method \code{collections_untrash()}}{\r
 collections_untrash is a method defined in Arvados class, it moves collection from trash to project.\r
 \subsection{Usage}{\r
@@ -1141,7 +1244,9 @@ collections_untrash is a method defined in Arvados class, it moves collection fr
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arv$collections_untrash(collectionUUID)\r
+\preformatted{\dontrun{\r
+arv$collections_untrash(collectionUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1149,8 +1254,8 @@ collections_untrash is a method defined in Arvados class, it moves collection fr
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-collections_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-collections_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-collections_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-collections_list}{}}}\r
 \subsection{Method \code{collections_list()}}{\r
 collections_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1185,7 +1290,9 @@ collections_list is a method defined in Arvados class.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collectionList <- arv$collections.list(list(list("name", "=", "Example")))\r
+\preformatted{\dontrun{\r
+collectionList <- arv$collections_list(list(list("name", "=", "Example")))\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1193,8 +1300,8 @@ collections_list is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_get}{}}}\r
 \subsection{Method \code{containers_get()}}{\r
 containers_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1210,8 +1317,8 @@ containers_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_create}{}}}\r
 \subsection{Method \code{containers_create()}}{\r
 containers_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1235,8 +1342,8 @@ containers_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_update}{}}}\r
 \subsection{Method \code{containers_update()}}{\r
 containers_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1254,8 +1361,8 @@ containers_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_delete}{}}}\r
 \subsection{Method \code{containers_delete()}}{\r
 containers_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1271,8 +1378,8 @@ containers_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_auth"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_auth}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_auth"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_auth}{}}}\r
 \subsection{Method \code{containers_auth()}}{\r
 containers_auth is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1288,8 +1395,8 @@ containers_auth is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_lock"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_lock}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_lock"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_lock}{}}}\r
 \subsection{Method \code{containers_lock()}}{\r
 containers_lock is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1305,8 +1412,8 @@ containers_lock is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_unlock"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_unlock}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_unlock"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_unlock}{}}}\r
 \subsection{Method \code{containers_unlock()}}{\r
 containers_unlock is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1322,8 +1429,8 @@ containers_unlock is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_secret_mounts"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_secret_mounts}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_secret_mounts"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_secret_mounts}{}}}\r
 \subsection{Method \code{containers_secret_mounts()}}{\r
 containers_secret_mounts is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1339,8 +1446,8 @@ containers_secret_mounts is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_current"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_current}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_current"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_current}{}}}\r
 \subsection{Method \code{containers_current()}}{\r
 containers_current is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1349,8 +1456,8 @@ containers_current is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-containers_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-containers_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-containers_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-containers_list}{}}}\r
 \subsection{Method \code{containers_list()}}{\r
 containers_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1379,8 +1486,8 @@ containers_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-container_requests_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-container_requests_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-container_requests_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-container_requests_get}{}}}\r
 \subsection{Method \code{container_requests_get()}}{\r
 container_requests_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1396,8 +1503,8 @@ container_requests_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-container_requests_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-container_requests_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-container_requests_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-container_requests_create}{}}}\r
 \subsection{Method \code{container_requests_create()}}{\r
 container_requests_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1421,8 +1528,8 @@ container_requests_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-container_requests_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-container_requests_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-container_requests_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-container_requests_update}{}}}\r
 \subsection{Method \code{container_requests_update()}}{\r
 container_requests_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1440,8 +1547,8 @@ container_requests_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-container_requests_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-container_requests_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-container_requests_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-container_requests_delete}{}}}\r
 \subsection{Method \code{container_requests_delete()}}{\r
 container_requests_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1457,8 +1564,8 @@ container_requests_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-container_requests_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-container_requests_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-container_requests_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-container_requests_list}{}}}\r
 \subsection{Method \code{container_requests_list()}}{\r
 container_requests_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1490,8 +1597,8 @@ container_requests_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-groups_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-groups_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-groups_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-groups_get}{}}}\r
 \subsection{Method \code{groups_get()}}{\r
 groups_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1507,8 +1614,8 @@ groups_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-groups_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-groups_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-groups_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-groups_create}{}}}\r
 \subsection{Method \code{groups_create()}}{\r
 groups_create is a method defined in Arvados class that supports project creation.\r
 \subsection{Usage}{\r
@@ -1535,8 +1642,8 @@ groups_create is a method defined in Arvados class that supports project creatio
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-groups_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-groups_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-groups_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-groups_update}{}}}\r
 \subsection{Method \code{groups_update()}}{\r
 groups_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1556,8 +1663,8 @@ groups_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-groups_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-groups_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-groups_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-groups_delete}{}}}\r
 \subsection{Method \code{groups_delete()}}{\r
 groups_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1573,8 +1680,8 @@ groups_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-groups_contents"></a>}}\r
-\if{latex}{\out{\hypertarget{method-groups_contents}{}}}\r
+\if{html}{\out{<a id="method-Arvados-groups_contents"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-groups_contents}{}}}\r
 \subsection{Method \code{groups_contents()}}{\r
 groups_contents is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1612,8 +1719,8 @@ groups_contents is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-groups_shared"></a>}}\r
-\if{latex}{\out{\hypertarget{method-groups_shared}{}}}\r
+\if{html}{\out{<a id="method-Arvados-groups_shared"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-groups_shared}{}}}\r
 \subsection{Method \code{groups_shared()}}{\r
 groups_shared is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1646,8 +1753,8 @@ groups_shared is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-groups_trash"></a>}}\r
-\if{latex}{\out{\hypertarget{method-groups_trash}{}}}\r
+\if{html}{\out{<a id="method-Arvados-groups_trash"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-groups_trash}{}}}\r
 \subsection{Method \code{groups_trash()}}{\r
 groups_trash is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1663,8 +1770,8 @@ groups_trash is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-groups_untrash"></a>}}\r
-\if{latex}{\out{\hypertarget{method-groups_untrash}{}}}\r
+\if{html}{\out{<a id="method-Arvados-groups_untrash"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-groups_untrash}{}}}\r
 \subsection{Method \code{groups_untrash()}}{\r
 groups_untrash is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1680,8 +1787,8 @@ groups_untrash is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-groups_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-groups_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-groups_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-groups_list}{}}}\r
 \subsection{Method \code{groups_list()}}{\r
 groups_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1713,8 +1820,8 @@ groups_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-keep_services_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-keep_services_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-keep_services_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-keep_services_get}{}}}\r
 \subsection{Method \code{keep_services_get()}}{\r
 keep_services_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1730,8 +1837,8 @@ keep_services_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-keep_services_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-keep_services_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-keep_services_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-keep_services_create}{}}}\r
 \subsection{Method \code{keep_services_create()}}{\r
 keep_services_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1755,8 +1862,8 @@ keep_services_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-keep_services_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-keep_services_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-keep_services_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-keep_services_update}{}}}\r
 \subsection{Method \code{keep_services_update()}}{\r
 keep_services_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1774,8 +1881,8 @@ keep_services_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-keep_services_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-keep_services_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-keep_services_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-keep_services_delete}{}}}\r
 \subsection{Method \code{keep_services_delete()}}{\r
 keep_services_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1791,8 +1898,8 @@ keep_services_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-keep_services_accessible"></a>}}\r
-\if{latex}{\out{\hypertarget{method-keep_services_accessible}{}}}\r
+\if{html}{\out{<a id="method-Arvados-keep_services_accessible"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-keep_services_accessible}{}}}\r
 \subsection{Method \code{keep_services_accessible()}}{\r
 keep_services_accessible is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1801,8 +1908,8 @@ keep_services_accessible is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-keep_services_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-keep_services_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-keep_services_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-keep_services_list}{}}}\r
 \subsection{Method \code{keep_services_list()}}{\r
 keep_services_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1831,8 +1938,8 @@ keep_services_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_permission_give"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_permission_give}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_permission_give"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_permission_give}{}}}\r
 \subsection{Method \code{project_permission_give()}}{\r
 project_permission_give is a method defined in Arvados class that enables sharing files with another users.\r
 \subsection{Usage}{\r
@@ -1852,7 +1959,9 @@ project_permission_give is a method defined in Arvados class that enables sharin
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arv$project_permission_give(type = "can_read", uuid = objectUUID, user = userUUID)\r
+\preformatted{\dontrun{\r
+arv$project_permission_give(type = "can_read", uuid = objectUUID, user = userUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1860,8 +1969,8 @@ project_permission_give is a method defined in Arvados class that enables sharin
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_permission_refuse"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_permission_refuse}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_permission_refuse"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_permission_refuse}{}}}\r
 \subsection{Method \code{project_permission_refuse()}}{\r
 project_permission_refuse is a method defined in Arvados class that unables sharing files with another users.\r
 \subsection{Usage}{\r
@@ -1881,7 +1990,9 @@ project_permission_refuse is a method defined in Arvados class that unables shar
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arv$project_permission_refuse(type = "can_read", uuid = objectUUID, user = userUUID)\r
+\preformatted{\dontrun{\r
+arv$project_permission_refuse(type = "can_read", uuid = objectUUID, user = userUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1889,8 +2000,8 @@ project_permission_refuse is a method defined in Arvados class that unables shar
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_permission_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_permission_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_permission_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_permission_update}{}}}\r
 \subsection{Method \code{project_permission_update()}}{\r
 project_permission_update is a method defined in Arvados class that enables updating permissions.\r
 \subsection{Usage}{\r
@@ -1912,7 +2023,9 @@ project_permission_update is a method defined in Arvados class that enables upda
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arv$project_permission_update(typeOld = "can_read", typeNew = "can_write", uuid = objectUUID, user = userUUID)\r
+\preformatted{\dontrun{\r
+arv$project_permission_update(typeOld = "can_read", typeNew = "can_write", uuid = objectUUID, user = userUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1920,8 +2033,8 @@ project_permission_update is a method defined in Arvados class that enables upda
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-project_permission_check"></a>}}\r
-\if{latex}{\out{\hypertarget{method-project_permission_check}{}}}\r
+\if{html}{\out{<a id="method-Arvados-project_permission_check"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-project_permission_check}{}}}\r
 \subsection{Method \code{project_permission_check()}}{\r
 project_permission_check is a method defined in Arvados class that enables checking file permissions.\r
 \subsection{Usage}{\r
@@ -1941,7 +2054,9 @@ project_permission_check is a method defined in Arvados class that enables check
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arv$project_permission_check(type = "can_read", uuid = objectUUID, user = userUUID)\r
+\preformatted{\dontrun{\r
+arv$project_permission_check(type = "can_read", uuid = objectUUID, user = userUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -1949,8 +2064,8 @@ project_permission_check is a method defined in Arvados class that enables check
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-links_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-links_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-links_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-links_get}{}}}\r
 \subsection{Method \code{links_get()}}{\r
 links_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1966,8 +2081,8 @@ links_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-links_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-links_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-links_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-links_create}{}}}\r
 \subsection{Method \code{links_create()}}{\r
 links_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -1987,8 +2102,8 @@ links_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-links_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-links_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-links_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-links_update}{}}}\r
 \subsection{Method \code{links_update()}}{\r
 links_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2006,8 +2121,8 @@ links_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-links_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-links_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-links_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-links_delete}{}}}\r
 \subsection{Method \code{links_delete()}}{\r
 links_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2023,8 +2138,8 @@ links_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-links_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-links_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-links_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-links_list}{}}}\r
 \subsection{Method \code{links_list()}}{\r
 links_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2053,8 +2168,8 @@ links_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-links_get_permissions"></a>}}\r
-\if{latex}{\out{\hypertarget{method-links_get_permissions}{}}}\r
+\if{html}{\out{<a id="method-Arvados-links_get_permissions"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-links_get_permissions}{}}}\r
 \subsection{Method \code{links_get_permissions()}}{\r
 links_get_permissions is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2070,8 +2185,8 @@ links_get_permissions is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-logs_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-logs_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-logs_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-logs_get}{}}}\r
 \subsection{Method \code{logs_get()}}{\r
 logs_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2087,8 +2202,8 @@ logs_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-logs_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-logs_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-logs_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-logs_create}{}}}\r
 \subsection{Method \code{logs_create()}}{\r
 logs_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2108,8 +2223,8 @@ logs_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-logs_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-logs_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-logs_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-logs_update}{}}}\r
 \subsection{Method \code{logs_update()}}{\r
 logs_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2127,8 +2242,8 @@ logs_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-logs_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-logs_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-logs_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-logs_delete}{}}}\r
 \subsection{Method \code{logs_delete()}}{\r
 logs_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2144,8 +2259,8 @@ logs_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-logs_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-logs_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-logs_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-logs_list}{}}}\r
 \subsection{Method \code{logs_list()}}{\r
 logs_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2174,8 +2289,8 @@ logs_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_get}{}}}\r
 \subsection{Method \code{users_get()}}{\r
 users_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2191,8 +2306,8 @@ users_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_create}{}}}\r
 \subsection{Method \code{users_create()}}{\r
 users_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2212,8 +2327,8 @@ users_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_update}{}}}\r
 \subsection{Method \code{users_update()}}{\r
 users_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2231,8 +2346,8 @@ users_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_delete}{}}}\r
 \subsection{Method \code{users_delete()}}{\r
 users_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2248,8 +2363,8 @@ users_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_current"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_current}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_current"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_current}{}}}\r
 \subsection{Method \code{users_current()}}{\r
 users_current is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2258,8 +2373,8 @@ users_current is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_system"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_system}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_system"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_system}{}}}\r
 \subsection{Method \code{users_system()}}{\r
 users_system is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2268,8 +2383,8 @@ users_system is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_activate"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_activate}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_activate"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_activate}{}}}\r
 \subsection{Method \code{users_activate()}}{\r
 users_activate is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2285,8 +2400,8 @@ users_activate is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_setup"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_setup}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_setup"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_setup}{}}}\r
 \subsection{Method \code{users_setup()}}{\r
 users_setup is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2301,8 +2416,8 @@ users_setup is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_unsetup"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_unsetup}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_unsetup"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_unsetup}{}}}\r
 \subsection{Method \code{users_unsetup()}}{\r
 users_unsetup is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2318,8 +2433,8 @@ users_unsetup is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_merge"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_merge}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_merge"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_merge}{}}}\r
 \subsection{Method \code{users_merge()}}{\r
 users_merge is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2334,8 +2449,8 @@ users_merge is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-users_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-users_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-users_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-users_list}{}}}\r
 \subsection{Method \code{users_list()}}{\r
 users_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2364,8 +2479,8 @@ users_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-repositories_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-repositories_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-repositories_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-repositories_get}{}}}\r
 \subsection{Method \code{repositories_get()}}{\r
 repositories_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2381,8 +2496,8 @@ repositories_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-repositories_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-repositories_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-repositories_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-repositories_create}{}}}\r
 \subsection{Method \code{repositories_create()}}{\r
 repositories_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2406,8 +2521,8 @@ repositories_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-repositories_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-repositories_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-repositories_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-repositories_update}{}}}\r
 \subsection{Method \code{repositories_update()}}{\r
 repositories_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2425,8 +2540,8 @@ repositories_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-repositories_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-repositories_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-repositories_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-repositories_delete}{}}}\r
 \subsection{Method \code{repositories_delete()}}{\r
 repositories_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2442,8 +2557,8 @@ repositories_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-repositories_get_all_permissions"></a>}}\r
-\if{latex}{\out{\hypertarget{method-repositories_get_all_permissions}{}}}\r
+\if{html}{\out{<a id="method-Arvados-repositories_get_all_permissions"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-repositories_get_all_permissions}{}}}\r
 \subsection{Method \code{repositories_get_all_permissions()}}{\r
 repositories_get_all_permissions is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2452,8 +2567,8 @@ repositories_get_all_permissions is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-repositories_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-repositories_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-repositories_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-repositories_list}{}}}\r
 \subsection{Method \code{repositories_list()}}{\r
 repositories_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2482,8 +2597,8 @@ repositories_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-virtual_machines_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-virtual_machines_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-virtual_machines_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-virtual_machines_get}{}}}\r
 \subsection{Method \code{virtual_machines_get()}}{\r
 virtual_machines_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2499,8 +2614,8 @@ virtual_machines_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-virtual_machines_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-virtual_machines_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-virtual_machines_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-virtual_machines_create}{}}}\r
 \subsection{Method \code{virtual_machines_create()}}{\r
 virtual_machines_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2524,8 +2639,8 @@ virtual_machines_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-virtual_machines_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-virtual_machines_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-virtual_machines_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-virtual_machines_update}{}}}\r
 \subsection{Method \code{virtual_machines_update()}}{\r
 virtual_machines_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2543,8 +2658,8 @@ virtual_machines_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-virtual_machines_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-virtual_machines_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-virtual_machines_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-virtual_machines_delete}{}}}\r
 \subsection{Method \code{virtual_machines_delete()}}{\r
 virtual_machines_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2560,8 +2675,8 @@ virtual_machines_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-virtual_machines_logins"></a>}}\r
-\if{latex}{\out{\hypertarget{method-virtual_machines_logins}{}}}\r
+\if{html}{\out{<a id="method-Arvados-virtual_machines_logins"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-virtual_machines_logins}{}}}\r
 \subsection{Method \code{virtual_machines_logins()}}{\r
 virtual_machines_logins is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2577,8 +2692,8 @@ virtual_machines_logins is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-virtual_machines_get_all_logins"></a>}}\r
-\if{latex}{\out{\hypertarget{method-virtual_machines_get_all_logins}{}}}\r
+\if{html}{\out{<a id="method-Arvados-virtual_machines_get_all_logins"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-virtual_machines_get_all_logins}{}}}\r
 \subsection{Method \code{virtual_machines_get_all_logins()}}{\r
 virtual_machines_get_all_logins is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2587,8 +2702,8 @@ virtual_machines_get_all_logins is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-virtual_machines_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-virtual_machines_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-virtual_machines_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-virtual_machines_list}{}}}\r
 \subsection{Method \code{virtual_machines_list()}}{\r
 virtual_machines_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2617,8 +2732,8 @@ virtual_machines_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-workflows_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-workflows_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-workflows_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-workflows_get}{}}}\r
 \subsection{Method \code{workflows_get()}}{\r
 workflows_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2634,8 +2749,8 @@ workflows_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-workflows_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-workflows_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-workflows_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-workflows_create}{}}}\r
 \subsection{Method \code{workflows_create()}}{\r
 workflows_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2659,8 +2774,8 @@ workflows_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-workflows_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-workflows_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-workflows_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-workflows_update}{}}}\r
 \subsection{Method \code{workflows_update()}}{\r
 workflows_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2678,8 +2793,8 @@ workflows_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-workflows_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-workflows_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-workflows_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-workflows_delete}{}}}\r
 \subsection{Method \code{workflows_delete()}}{\r
 workflows_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2695,8 +2810,8 @@ workflows_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-workflows_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-workflows_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-workflows_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-workflows_list}{}}}\r
 \subsection{Method \code{workflows_list()}}{\r
 workflows_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2725,8 +2840,8 @@ workflows_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-user_agreements_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-user_agreements_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-user_agreements_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-user_agreements_get}{}}}\r
 \subsection{Method \code{user_agreements_get()}}{\r
 user_agreements_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2742,8 +2857,8 @@ user_agreements_get is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-user_agreements_create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-user_agreements_create}{}}}\r
+\if{html}{\out{<a id="method-Arvados-user_agreements_create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-user_agreements_create}{}}}\r
 \subsection{Method \code{user_agreements_create()}}{\r
 user_agreements_create is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2767,8 +2882,8 @@ user_agreements_create is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-user_agreements_update"></a>}}\r
-\if{latex}{\out{\hypertarget{method-user_agreements_update}{}}}\r
+\if{html}{\out{<a id="method-Arvados-user_agreements_update"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-user_agreements_update}{}}}\r
 \subsection{Method \code{user_agreements_update()}}{\r
 user_agreements_update is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2786,8 +2901,8 @@ user_agreements_update is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-user_agreements_delete"></a>}}\r
-\if{latex}{\out{\hypertarget{method-user_agreements_delete}{}}}\r
+\if{html}{\out{<a id="method-Arvados-user_agreements_delete"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-user_agreements_delete}{}}}\r
 \subsection{Method \code{user_agreements_delete()}}{\r
 user_agreements_delete is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2803,8 +2918,8 @@ user_agreements_delete is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-user_agreements_signatures"></a>}}\r
-\if{latex}{\out{\hypertarget{method-user_agreements_signatures}{}}}\r
+\if{html}{\out{<a id="method-Arvados-user_agreements_signatures"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-user_agreements_signatures}{}}}\r
 \subsection{Method \code{user_agreements_signatures()}}{\r
 user_agreements_signatures is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2813,8 +2928,8 @@ user_agreements_signatures is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-user_agreements_sign"></a>}}\r
-\if{latex}{\out{\hypertarget{method-user_agreements_sign}{}}}\r
+\if{html}{\out{<a id="method-Arvados-user_agreements_sign"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-user_agreements_sign}{}}}\r
 \subsection{Method \code{user_agreements_sign()}}{\r
 user_agreements_sign is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2823,8 +2938,8 @@ user_agreements_sign is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-user_agreements_list"></a>}}\r
-\if{latex}{\out{\hypertarget{method-user_agreements_list}{}}}\r
+\if{html}{\out{<a id="method-Arvados-user_agreements_list"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-user_agreements_list}{}}}\r
 \subsection{Method \code{user_agreements_list()}}{\r
 user_agreements_list is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2853,8 +2968,8 @@ user_agreements_list is a method defined in Arvados class.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-user_agreements_new"></a>}}\r
-\if{latex}{\out{\hypertarget{method-user_agreements_new}{}}}\r
+\if{html}{\out{<a id="method-Arvados-user_agreements_new"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-user_agreements_new}{}}}\r
 \subsection{Method \code{user_agreements_new()}}{\r
 user_agreements_new is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2863,8 +2978,8 @@ user_agreements_new is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-configs_get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-configs_get}{}}}\r
+\if{html}{\out{<a id="method-Arvados-configs_get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-configs_get}{}}}\r
 \subsection{Method \code{configs_get()}}{\r
 configs_get is a method defined in Arvados class.\r
 \subsection{Usage}{\r
@@ -2873,8 +2988,8 @@ configs_get is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getHostName"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getHostName}{}}}\r
+\if{html}{\out{<a id="method-Arvados-getHostName"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-getHostName}{}}}\r
 \subsection{Method \code{getHostName()}}{\r
 \subsection{Usage}{\r
 \if{html}{\out{<div class="r">}}\preformatted{Arvados$getHostName()}\if{html}{\out{</div>}}\r
@@ -2882,8 +2997,8 @@ configs_get is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getToken"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getToken}{}}}\r
+\if{html}{\out{<a id="method-Arvados-getToken"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-getToken}{}}}\r
 \subsection{Method \code{getToken()}}{\r
 \subsection{Usage}{\r
 \if{html}{\out{<div class="r">}}\preformatted{Arvados$getToken()}\if{html}{\out{</div>}}\r
@@ -2891,8 +3006,8 @@ configs_get is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-setRESTService"></a>}}\r
-\if{latex}{\out{\hypertarget{method-setRESTService}{}}}\r
+\if{html}{\out{<a id="method-Arvados-setRESTService"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-setRESTService}{}}}\r
 \subsection{Method \code{setRESTService()}}{\r
 \subsection{Usage}{\r
 \if{html}{\out{<div class="r">}}\preformatted{Arvados$setRESTService(newREST)}\if{html}{\out{</div>}}\r
@@ -2900,8 +3015,8 @@ configs_get is a method defined in Arvados class.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getRESTService"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getRESTService}{}}}\r
+\if{html}{\out{<a id="method-Arvados-getRESTService"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Arvados-getRESTService}{}}}\r
 \subsection{Method \code{getRESTService()}}{\r
 \subsection{Usage}{\r
 \if{html}{\out{<div class="r">}}\preformatted{Arvados$getRESTService()}\if{html}{\out{</div>}}\r
index 8275b7b6003fd1a540f9047f7f483d6f6267528d..81c25af5f14323f880e4f9235dbde984e5d7ac0e 100644 (file)
@@ -12,97 +12,117 @@ ArvadosFile class represents a file inside Arvados collection.
 ## Method `ArvadosFile$new`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 myFile   <- ArvadosFile$new("myFile")\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `ArvadosFile$getName`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arvadosFile$getName()\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `ArvadosFile$getFileListing`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arvadosFile$getFileListing()\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `ArvadosFile$getSizeInBytes`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arvadosFile$getSizeInBytes()\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `ArvadosFile$read`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection <- Collection$new(arv, collectionUUID)\r
 arvadosFile <- collection$get(fileName)\r
 fileContent <- arvadosFile$read("text")\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `ArvadosFile$connection`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection <- Collection$new(arv, collectionUUID)\r
 arvadosFile <- collection$get(fileName)\r
 arvConnection <- arvadosFile$connection("w")\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `ArvadosFile$flush`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection <- Collection$new(arv, collectionUUID)\r
 arvadosFile <- collection$get(fileName)\r
 myFile$write("This is new file content")\r
 arvadosFile$flush()\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `ArvadosFile$write`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection <- Collection$new(arv, collectionUUID)\r
 arvadosFile <- collection$get(fileName)\r
 myFile$write("This is new file content")\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `ArvadosFile$move`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arvadosFile$move(newPath)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `ArvadosFile$copy`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arvadosFile$copy("NewName.format")\r
 }\r
+}\r
 \section{Methods}{\r
 \subsection{Public methods}{\r
 \itemize{\r
-\item \href{#method-new}{\code{ArvadosFile$new()}}\r
-\item \href{#method-getName}{\code{ArvadosFile$getName()}}\r
-\item \href{#method-getFileListing}{\code{ArvadosFile$getFileListing()}}\r
-\item \href{#method-getSizeInBytes}{\code{ArvadosFile$getSizeInBytes()}}\r
-\item \href{#method-get}{\code{ArvadosFile$get()}}\r
-\item \href{#method-getFirst}{\code{ArvadosFile$getFirst()}}\r
-\item \href{#method-getCollection}{\code{ArvadosFile$getCollection()}}\r
-\item \href{#method-setCollection}{\code{ArvadosFile$setCollection()}}\r
-\item \href{#method-getRelativePath}{\code{ArvadosFile$getRelativePath()}}\r
-\item \href{#method-getParent}{\code{ArvadosFile$getParent()}}\r
-\item \href{#method-setParent}{\code{ArvadosFile$setParent()}}\r
-\item \href{#method-read}{\code{ArvadosFile$read()}}\r
-\item \href{#method-connection}{\code{ArvadosFile$connection()}}\r
-\item \href{#method-flush}{\code{ArvadosFile$flush()}}\r
-\item \href{#method-write}{\code{ArvadosFile$write()}}\r
-\item \href{#method-move}{\code{ArvadosFile$move()}}\r
-\item \href{#method-copy}{\code{ArvadosFile$copy()}}\r
-\item \href{#method-duplicate}{\code{ArvadosFile$duplicate()}}\r
+\item \href{#method-ArvadosFile-new}{\code{ArvadosFile$new()}}\r
+\item \href{#method-ArvadosFile-getName}{\code{ArvadosFile$getName()}}\r
+\item \href{#method-ArvadosFile-getFileListing}{\code{ArvadosFile$getFileListing()}}\r
+\item \href{#method-ArvadosFile-getSizeInBytes}{\code{ArvadosFile$getSizeInBytes()}}\r
+\item \href{#method-ArvadosFile-get}{\code{ArvadosFile$get()}}\r
+\item \href{#method-ArvadosFile-getFirst}{\code{ArvadosFile$getFirst()}}\r
+\item \href{#method-ArvadosFile-getCollection}{\code{ArvadosFile$getCollection()}}\r
+\item \href{#method-ArvadosFile-setCollection}{\code{ArvadosFile$setCollection()}}\r
+\item \href{#method-ArvadosFile-getRelativePath}{\code{ArvadosFile$getRelativePath()}}\r
+\item \href{#method-ArvadosFile-getParent}{\code{ArvadosFile$getParent()}}\r
+\item \href{#method-ArvadosFile-setParent}{\code{ArvadosFile$setParent()}}\r
+\item \href{#method-ArvadosFile-read}{\code{ArvadosFile$read()}}\r
+\item \href{#method-ArvadosFile-connection}{\code{ArvadosFile$connection()}}\r
+\item \href{#method-ArvadosFile-flush}{\code{ArvadosFile$flush()}}\r
+\item \href{#method-ArvadosFile-write}{\code{ArvadosFile$write()}}\r
+\item \href{#method-ArvadosFile-move}{\code{ArvadosFile$move()}}\r
+\item \href{#method-ArvadosFile-copy}{\code{ArvadosFile$copy()}}\r
+\item \href{#method-ArvadosFile-duplicate}{\code{ArvadosFile$duplicate()}}\r
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-new"></a>}}\r
-\if{latex}{\out{\hypertarget{method-new}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-new"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-new}{}}}\r
 \subsection{Method \code{new()}}{\r
 Initialize new enviroment.\r
 \subsection{Usage}{\r
@@ -121,7 +141,9 @@ A new `ArvadosFile` object.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{myFile   <- ArvadosFile$new("myFile")\r
+\preformatted{\dontrun{\r
+myFile   <- ArvadosFile$new("myFile")\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -129,8 +151,8 @@ A new `ArvadosFile` object.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getName"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getName}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-getName"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-getName}{}}}\r
 \subsection{Method \code{getName()}}{\r
 Returns name of the file.\r
 \subsection{Usage}{\r
@@ -139,7 +161,9 @@ Returns name of the file.
 \r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arvadosFile$getName()\r
+\preformatted{\dontrun{\r
+arvadosFile$getName()\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -147,8 +171,8 @@ Returns name of the file.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getFileListing"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getFileListing}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-getFileListing"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-getFileListing}{}}}\r
 \subsection{Method \code{getFileListing()}}{\r
 Returns collections file content as character vector.\r
 \subsection{Usage}{\r
@@ -164,7 +188,9 @@ Returns collections file content as character vector.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arvadosFile$getFileListing()\r
+\preformatted{\dontrun{\r
+arvadosFile$getFileListing()\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -172,8 +198,8 @@ Returns collections file content as character vector.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getSizeInBytes"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getSizeInBytes}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-getSizeInBytes"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-getSizeInBytes}{}}}\r
 \subsection{Method \code{getSizeInBytes()}}{\r
 Returns collections content size in bytes.\r
 \subsection{Usage}{\r
@@ -182,7 +208,9 @@ Returns collections content size in bytes.
 \r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arvadosFile$getSizeInBytes()\r
+\preformatted{\dontrun{\r
+arvadosFile$getSizeInBytes()\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -190,8 +218,8 @@ Returns collections content size in bytes.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-get}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-get}{}}}\r
 \subsection{Method \code{get()}}{\r
 \subsection{Usage}{\r
 \if{html}{\out{<div class="r">}}\preformatted{ArvadosFile$get(fileLikeObjectName)}\if{html}{\out{</div>}}\r
@@ -199,8 +227,8 @@ Returns collections content size in bytes.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getFirst"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getFirst}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-getFirst"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-getFirst}{}}}\r
 \subsection{Method \code{getFirst()}}{\r
 \subsection{Usage}{\r
 \if{html}{\out{<div class="r">}}\preformatted{ArvadosFile$getFirst()}\if{html}{\out{</div>}}\r
@@ -208,8 +236,8 @@ Returns collections content size in bytes.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getCollection"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getCollection}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-getCollection"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-getCollection}{}}}\r
 \subsection{Method \code{getCollection()}}{\r
 Returns collection UUID.\r
 \subsection{Usage}{\r
@@ -218,8 +246,8 @@ Returns collection UUID.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-setCollection"></a>}}\r
-\if{latex}{\out{\hypertarget{method-setCollection}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-setCollection"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-setCollection}{}}}\r
 \subsection{Method \code{setCollection()}}{\r
 Sets new collection.\r
 \subsection{Usage}{\r
@@ -228,8 +256,8 @@ Sets new collection.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getRelativePath"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getRelativePath}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-getRelativePath"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-getRelativePath}{}}}\r
 \subsection{Method \code{getRelativePath()}}{\r
 Returns file path relative to the root.\r
 \subsection{Usage}{\r
@@ -238,8 +266,8 @@ Returns file path relative to the root.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getParent"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getParent}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-getParent"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-getParent}{}}}\r
 \subsection{Method \code{getParent()}}{\r
 Returns project UUID.\r
 \subsection{Usage}{\r
@@ -248,8 +276,8 @@ Returns project UUID.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-setParent"></a>}}\r
-\if{latex}{\out{\hypertarget{method-setParent}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-setParent"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-setParent}{}}}\r
 \subsection{Method \code{setParent()}}{\r
 Sets project collection.\r
 \subsection{Usage}{\r
@@ -258,8 +286,8 @@ Sets project collection.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-read"></a>}}\r
-\if{latex}{\out{\hypertarget{method-read}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-read"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-read}{}}}\r
 \subsection{Method \code{read()}}{\r
 Read file content.\r
 \subsection{Usage}{\r
@@ -279,18 +307,20 @@ Read file content.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- Collection$new(arv, collectionUUID)\r
+\preformatted{\dontrun{\r
+collection <- Collection$new(arv, collectionUUID)\r
 arvadosFile <- collection$get(fileName)\r
 fileContent <- arvadosFile$read("text")\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-connection"></a>}}\r
-\if{latex}{\out{\hypertarget{method-connection}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-connection"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-connection}{}}}\r
 \subsection{Method \code{connection()}}{\r
 Get connection opened in "read" or "write" mode.\r
 \subsection{Usage}{\r
@@ -306,18 +336,20 @@ Get connection opened in "read" or "write" mode.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- Collection$new(arv, collectionUUID)\r
+\preformatted{\dontrun{\r
+collection <- Collection$new(arv, collectionUUID)\r
 arvadosFile <- collection$get(fileName)\r
 arvConnection <- arvadosFile$connection("w")\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-flush"></a>}}\r
-\if{latex}{\out{\hypertarget{method-flush}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-flush"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-flush}{}}}\r
 \subsection{Method \code{flush()}}{\r
 Write connections content to a file or override current content of the file.\r
 \subsection{Usage}{\r
@@ -326,19 +358,21 @@ Write connections content to a file or override current content of the file.
 \r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- Collection$new(arv, collectionUUID)\r
+\preformatted{\dontrun{\r
+collection <- Collection$new(arv, collectionUUID)\r
 arvadosFile <- collection$get(fileName)\r
 myFile$write("This is new file content")\r
 arvadosFile$flush()\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-write"></a>}}\r
-\if{latex}{\out{\hypertarget{method-write}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-write"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-write}{}}}\r
 \subsection{Method \code{write()}}{\r
 Write to file or override current content of the file.\r
 \subsection{Usage}{\r
@@ -356,18 +390,20 @@ Write to file or override current content of the file.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- Collection$new(arv, collectionUUID)\r
+\preformatted{\dontrun{\r
+collection <- Collection$new(arv, collectionUUID)\r
 arvadosFile <- collection$get(fileName)\r
 myFile$write("This is new file content")\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-move"></a>}}\r
-\if{latex}{\out{\hypertarget{method-move}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-move"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-move}{}}}\r
 \subsection{Method \code{move()}}{\r
 Moves file to a new location inside collection.\r
 \subsection{Usage}{\r
@@ -383,7 +419,9 @@ Moves file to a new location inside collection.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arvadosFile$move(newPath)\r
+\preformatted{\dontrun{\r
+arvadosFile$move(newPath)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -391,8 +429,8 @@ Moves file to a new location inside collection.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-copy"></a>}}\r
-\if{latex}{\out{\hypertarget{method-copy}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-copy"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-copy}{}}}\r
 \subsection{Method \code{copy()}}{\r
 Copies file to a new location inside collection.\r
 \subsection{Usage}{\r
@@ -408,7 +446,9 @@ Copies file to a new location inside collection.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arvadosFile$copy("NewName.format")\r
+\preformatted{\dontrun{\r
+arvadosFile$copy("NewName.format")\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -416,8 +456,8 @@ Copies file to a new location inside collection.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-duplicate"></a>}}\r
-\if{latex}{\out{\hypertarget{method-duplicate}{}}}\r
+\if{html}{\out{<a id="method-ArvadosFile-duplicate"></a>}}\r
+\if{latex}{\out{\hypertarget{method-ArvadosFile-duplicate}{}}}\r
 \subsection{Method \code{duplicate()}}{\r
 Duplicate file and gives it a new name.\r
 \subsection{Usage}{\r
index 1432491f69d895f494a1c1b29f5b64e81622cfb7..51edb8b138a451bf6ad07b13912ca0fc4b7fb245 100644 (file)
@@ -4,18 +4,20 @@
 \alias{ArvadosR}
 \title{ArvadosR}
 \description{
-
 Arvados is an open source platform for managing, processing, and sharing genomic and other large scientific and biomedical data. With Arvados, bioinformaticians run and scale compute-intensive workflows, developers create biomedical applications, and IT administrators manage large compute and storage resources.
 }
 \seealso{
 \itemize{
-\item \code{\link{https://github.com/arvados/arvados/blob/main/README.md}}
-\item \code{\link{https://github.com/arvados/arvados/tree/main/sdk/R}}}
+\item https://arvados.org
+\item https://doc.arvados.org/sdk/R/index.html
+\item https://git.arvados.org/arvados.git/tree/HEAD:/sdk/R}
 }
 \author{
 \itemize{
 \item Lucas Di Pentima
 \item Ward Vandewege
+\item Fuad Muhic
 \item Peter Amstutz
-\item Fuad Muhic}
+\item Aneta Stanczyk
+\item Piotr Nowosielski}
 }
index bb72cc1b3fe75e192f81c9c091c1a4d6f7a259df..0de9a842e6805656c15a3924b58030f3e2412dcc 100644 (file)
@@ -13,24 +13,29 @@ for exaplme actions like creating, updating, moving or removing are possible.
 ## Method `Collection$new`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection <- Collection$new(arv, CollectionUUID)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Collection$readArvFile`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection <- Collection$new(arv, collectionUUID)\r
 readFile <- collection$readArvFile(arvadosFile, istable = 'yes')                    # table\r
 readFile <- collection$readArvFile(arvadosFile, istable = 'no')                     # text\r
 readFile <- collection$readArvFile(arvadosFile)                                     # xlsx, csv, tsv, rds, rdata\r
-readFile <- collection$readArvFile(arvadosFile, fileclass = 'lala')                 # fasta\r
+readFile <- collection$readArvFile(arvadosFile, fileclass = 'fasta')                # fasta\r
 readFile <- collection$readArvFile(arvadosFile, Ncol= 4, Nrow = 32)                 # binary, only numbers\r
 readFile <- collection$readArvFile(arvadosFile, Ncol = 5, Nrow = 150, istable = "factor") # binary with factor or text\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Collection$writeFile`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection <- Collection$new(arv, collectionUUID)\r
 writeFile <- collection$writeFile(name = "myoutput.csv", file = file, fileFormat = "csv", istable = NULL, collectionUUID = collectionUUID)             # csv\r
 writeFile <- collection$writeFile(name = "myoutput.tsv", file = file, fileFormat = "tsv", istable = NULL, collectionUUID = collectionUUID)             # tsv\r
@@ -39,51 +44,66 @@ writeFile <- collection$writeFile(name = "myoutputtable.txt", file = file, fileF
 writeFile <- collection$writeFile(name = "myoutputtext.txt", file = file, fileFormat = "txt", istable = "no", collectionUUID = collectionUUID)         # txt text\r
 writeFile <- collection$writeFile(name = "myoutputbinary.dat", file = file, fileFormat = "dat", collectionUUID = collectionUUID)                       # binary\r
 writeFile <- collection$writeFile(name = "myoutputxlsx.xlsx", file = file, fileFormat = "xlsx", collectionUUID = collectionUUID)                       # xlsx\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Collection$create`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection <- arv$collections_create(name = collectionTitle, description = collectionDescription, owner_uuid = collectionOwner, properties = list("ROX37196928443768648" = "ROX37742976443830153"))\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Collection$remove`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection$remove(fileName.format)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Collection$move`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection$move("fileName.format", path)\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Collection$copy`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 copied <- collection$copy("oldName.format", "newName.format")\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Collection$refresh`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 collection$refresh()\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Collection$getFileListing`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 list <- collection$getFileListing()\r
+}\r
 \r
 ## ------------------------------------------------\r
 ## Method `Collection$get`\r
 ## ------------------------------------------------\r
 \r
+\dontrun{\r
 arvadosFile <- collection$get(fileName)\r
 }\r
+}\r
 \seealso{\r
-\code{\link{https://github.com/arvados/arvados/tree/main/sdk/R}}\r
+https://git.arvados.org/arvados.git/tree/HEAD:/sdk/R\r
 }\r
 \section{Public fields}{\r
 \if{html}{\out{<div class="r6-fields">}}\r
@@ -95,24 +115,24 @@ arvadosFile <- collection$get(fileName)
 \section{Methods}{\r
 \subsection{Public methods}{\r
 \itemize{\r
-\item \href{#method-new}{\code{Collection$new()}}\r
-\item \href{#method-add}{\code{Collection$add()}}\r
-\item \href{#method-readArvFile}{\code{Collection$readArvFile()}}\r
-\item \href{#method-writeFile}{\code{Collection$writeFile()}}\r
-\item \href{#method-create}{\code{Collection$create()}}\r
-\item \href{#method-remove}{\code{Collection$remove()}}\r
-\item \href{#method-move}{\code{Collection$move()}}\r
-\item \href{#method-copy}{\code{Collection$copy()}}\r
-\item \href{#method-refresh}{\code{Collection$refresh()}}\r
-\item \href{#method-getFileListing}{\code{Collection$getFileListing()}}\r
-\item \href{#method-get}{\code{Collection$get()}}\r
-\item \href{#method-getRESTService}{\code{Collection$getRESTService()}}\r
-\item \href{#method-setRESTService}{\code{Collection$setRESTService()}}\r
+\item \href{#method-Collection-new}{\code{Collection$new()}}\r
+\item \href{#method-Collection-add}{\code{Collection$add()}}\r
+\item \href{#method-Collection-readArvFile}{\code{Collection$readArvFile()}}\r
+\item \href{#method-Collection-writeFile}{\code{Collection$writeFile()}}\r
+\item \href{#method-Collection-create}{\code{Collection$create()}}\r
+\item \href{#method-Collection-remove}{\code{Collection$remove()}}\r
+\item \href{#method-Collection-move}{\code{Collection$move()}}\r
+\item \href{#method-Collection-copy}{\code{Collection$copy()}}\r
+\item \href{#method-Collection-refresh}{\code{Collection$refresh()}}\r
+\item \href{#method-Collection-getFileListing}{\code{Collection$getFileListing()}}\r
+\item \href{#method-Collection-get}{\code{Collection$get()}}\r
+\item \href{#method-Collection-getRESTService}{\code{Collection$getRESTService()}}\r
+\item \href{#method-Collection-setRESTService}{\code{Collection$setRESTService()}}\r
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-new"></a>}}\r
-\if{latex}{\out{\hypertarget{method-new}{}}}\r
+\if{html}{\out{<a id="method-Collection-new"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-new}{}}}\r
 \subsection{Method \code{new()}}{\r
 Initialize new enviroment.\r
 \subsection{Usage}{\r
@@ -133,7 +153,9 @@ A new `Collection` object.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- Collection$new(arv, CollectionUUID)\r
+\preformatted{\dontrun{\r
+collection <- Collection$new(arv, CollectionUUID)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -141,8 +163,8 @@ A new `Collection` object.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-add"></a>}}\r
-\if{latex}{\out{\hypertarget{method-add}{}}}\r
+\if{html}{\out{<a id="method-Collection-add"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-add}{}}}\r
 \subsection{Method \code{add()}}{\r
 Adds ArvadosFile or Subcollection specified by content to the collection. Used only with ArvadosFile or Subcollection.\r
 \subsection{Usage}{\r
@@ -160,8 +182,8 @@ Adds ArvadosFile or Subcollection specified by content to the collection. Used o
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-readArvFile"></a>}}\r
-\if{latex}{\out{\hypertarget{method-readArvFile}{}}}\r
+\if{html}{\out{<a id="method-Collection-readArvFile"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-readArvFile}{}}}\r
 \subsection{Method \code{readArvFile()}}{\r
 Read file content.\r
 \subsection{Usage}{\r
@@ -198,22 +220,24 @@ Read file content.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- Collection$new(arv, collectionUUID)\r
+\preformatted{\dontrun{\r
+collection <- Collection$new(arv, collectionUUID)\r
 readFile <- collection$readArvFile(arvadosFile, istable = 'yes')                    # table\r
 readFile <- collection$readArvFile(arvadosFile, istable = 'no')                     # text\r
 readFile <- collection$readArvFile(arvadosFile)                                     # xlsx, csv, tsv, rds, rdata\r
-readFile <- collection$readArvFile(arvadosFile, fileclass = 'lala')                 # fasta\r
+readFile <- collection$readArvFile(arvadosFile, fileclass = 'fasta')                # fasta\r
 readFile <- collection$readArvFile(arvadosFile, Ncol= 4, Nrow = 32)                 # binary, only numbers\r
 readFile <- collection$readArvFile(arvadosFile, Ncol = 5, Nrow = 150, istable = "factor") # binary with factor or text\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-writeFile"></a>}}\r
-\if{latex}{\out{\hypertarget{method-writeFile}{}}}\r
+\if{html}{\out{<a id="method-Collection-writeFile"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-writeFile}{}}}\r
 \subsection{Method \code{writeFile()}}{\r
 Write file content\r
 \subsection{Usage}{\r
@@ -240,7 +264,8 @@ Write file content
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- Collection$new(arv, collectionUUID)\r
+\preformatted{\dontrun{\r
+collection <- Collection$new(arv, collectionUUID)\r
 writeFile <- collection$writeFile(name = "myoutput.csv", file = file, fileFormat = "csv", istable = NULL, collectionUUID = collectionUUID)             # csv\r
 writeFile <- collection$writeFile(name = "myoutput.tsv", file = file, fileFormat = "tsv", istable = NULL, collectionUUID = collectionUUID)             # tsv\r
 writeFile <- collection$writeFile(name = "myoutput.fasta", file = file, fileFormat = "fasta", istable = NULL, collectionUUID = collectionUUID)         # fasta\r
@@ -249,14 +274,15 @@ writeFile <- collection$writeFile(name = "myoutputtext.txt", file = file, fileFo
 writeFile <- collection$writeFile(name = "myoutputbinary.dat", file = file, fileFormat = "dat", collectionUUID = collectionUUID)                       # binary\r
 writeFile <- collection$writeFile(name = "myoutputxlsx.xlsx", file = file, fileFormat = "xlsx", collectionUUID = collectionUUID)                       # xlsx\r
 }\r
+}\r
 \if{html}{\out{</div>}}\r
 \r
 }\r
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-create"></a>}}\r
-\if{latex}{\out{\hypertarget{method-create}{}}}\r
+\if{html}{\out{<a id="method-Collection-create"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-create}{}}}\r
 \subsection{Method \code{create()}}{\r
 Creates one or more ArvadosFiles and adds them to the collection at specified path.\r
 \subsection{Usage}{\r
@@ -272,7 +298,9 @@ Creates one or more ArvadosFiles and adds them to the collection at specified pa
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection <- arv$collections_create(name = collectionTitle, description = collectionDescription, owner_uuid = collectionOwner, properties = list("ROX37196928443768648" = "ROX37742976443830153"))\r
+\preformatted{\dontrun{\r
+collection <- arv$collections_create(name = collectionTitle, description = collectionDescription, owner_uuid = collectionOwner, properties = list("ROX37196928443768648" = "ROX37742976443830153"))\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -280,8 +308,8 @@ Creates one or more ArvadosFiles and adds them to the collection at specified pa
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-remove"></a>}}\r
-\if{latex}{\out{\hypertarget{method-remove}{}}}\r
+\if{html}{\out{<a id="method-Collection-remove"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-remove}{}}}\r
 \subsection{Method \code{remove()}}{\r
 Remove one or more files from the collection.\r
 \subsection{Usage}{\r
@@ -297,7 +325,9 @@ Remove one or more files from the collection.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection$remove(fileName.format)\r
+\preformatted{\dontrun{\r
+collection$remove(fileName.format)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -305,8 +335,8 @@ Remove one or more files from the collection.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-move"></a>}}\r
-\if{latex}{\out{\hypertarget{method-move}{}}}\r
+\if{html}{\out{<a id="method-Collection-move"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-move}{}}}\r
 \subsection{Method \code{move()}}{\r
 Moves ArvadosFile or Subcollection to another location in the collection.\r
 \subsection{Usage}{\r
@@ -324,7 +354,9 @@ Moves ArvadosFile or Subcollection to another location in the collection.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection$move("fileName.format", path)\r
+\preformatted{\dontrun{\r
+collection$move("fileName.format", path)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -332,8 +364,8 @@ Moves ArvadosFile or Subcollection to another location in the collection.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-copy"></a>}}\r
-\if{latex}{\out{\hypertarget{method-copy}{}}}\r
+\if{html}{\out{<a id="method-Collection-copy"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-copy}{}}}\r
 \subsection{Method \code{copy()}}{\r
 Copies ArvadosFile or Subcollection to another location in the collection.\r
 \subsection{Usage}{\r
@@ -351,7 +383,9 @@ Copies ArvadosFile or Subcollection to another location in the collection.
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{copied <- collection$copy("oldName.format", "newName.format")\r
+\preformatted{\dontrun{\r
+copied <- collection$copy("oldName.format", "newName.format")\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -359,8 +393,8 @@ Copies ArvadosFile or Subcollection to another location in the collection.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-refresh"></a>}}\r
-\if{latex}{\out{\hypertarget{method-refresh}{}}}\r
+\if{html}{\out{<a id="method-Collection-refresh"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-refresh}{}}}\r
 \subsection{Method \code{refresh()}}{\r
 Refreshes the environment.\r
 \subsection{Usage}{\r
@@ -369,7 +403,9 @@ Refreshes the environment.
 \r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{collection$refresh()\r
+\preformatted{\dontrun{\r
+collection$refresh()\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -377,8 +413,8 @@ Refreshes the environment.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getFileListing"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getFileListing}{}}}\r
+\if{html}{\out{<a id="method-Collection-getFileListing"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-getFileListing}{}}}\r
 \subsection{Method \code{getFileListing()}}{\r
 Returns collections file content as character vector.\r
 \subsection{Usage}{\r
@@ -387,7 +423,9 @@ Returns collections file content as character vector.
 \r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{list <- collection$getFileListing()\r
+\preformatted{\dontrun{\r
+list <- collection$getFileListing()\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -395,8 +433,8 @@ Returns collections file content as character vector.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-get}{}}}\r
+\if{html}{\out{<a id="method-Collection-get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-get}{}}}\r
 \subsection{Method \code{get()}}{\r
 If relativePath is valid, returns ArvadosFile or Subcollection specified by relativePath, else returns NULL.\r
 \subsection{Usage}{\r
@@ -412,7 +450,9 @@ If relativePath is valid, returns ArvadosFile or Subcollection specified by rela
 }\r
 \subsection{Examples}{\r
 \if{html}{\out{<div class="r example copy">}}\r
-\preformatted{arvadosFile <- collection$get(fileName)\r
+\preformatted{\dontrun{\r
+arvadosFile <- collection$get(fileName)\r
+}\r
 }\r
 \if{html}{\out{</div>}}\r
 \r
@@ -420,8 +460,8 @@ If relativePath is valid, returns ArvadosFile or Subcollection specified by rela
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getRESTService"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getRESTService}{}}}\r
+\if{html}{\out{<a id="method-Collection-getRESTService"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-getRESTService}{}}}\r
 \subsection{Method \code{getRESTService()}}{\r
 \subsection{Usage}{\r
 \if{html}{\out{<div class="r">}}\preformatted{Collection$getRESTService()}\if{html}{\out{</div>}}\r
@@ -429,8 +469,8 @@ If relativePath is valid, returns ArvadosFile or Subcollection specified by rela
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-setRESTService"></a>}}\r
-\if{latex}{\out{\hypertarget{method-setRESTService}{}}}\r
+\if{html}{\out{<a id="method-Collection-setRESTService"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Collection-setRESTService}{}}}\r
 \subsection{Method \code{setRESTService()}}{\r
 \subsection{Usage}{\r
 \if{html}{\out{<div class="r">}}\preformatted{Collection$setRESTService(newRESTService)}\if{html}{\out{</div>}}\r
index 1c9ec96e119746b059895701f719e1a8836d00b4..9faf0c279e6544e7e73870409d24366dcb7b1e24 100644 (file)
@@ -10,27 +10,27 @@ It is essentially a composite of arvadosFiles and other subcollections.
 \section{Methods}{\r
 \subsection{Public methods}{\r
 \itemize{\r
-\item \href{#method-new}{\code{Subcollection$new()}}\r
-\item \href{#method-getName}{\code{Subcollection$getName()}}\r
-\item \href{#method-getRelativePath}{\code{Subcollection$getRelativePath()}}\r
-\item \href{#method-add}{\code{Subcollection$add()}}\r
-\item \href{#method-remove}{\code{Subcollection$remove()}}\r
-\item \href{#method-getFileListing}{\code{Subcollection$getFileListing()}}\r
-\item \href{#method-getSizeInBytes}{\code{Subcollection$getSizeInBytes()}}\r
-\item \href{#method-move}{\code{Subcollection$move()}}\r
-\item \href{#method-copy}{\code{Subcollection$copy()}}\r
-\item \href{#method-duplicate}{\code{Subcollection$duplicate()}}\r
-\item \href{#method-get}{\code{Subcollection$get()}}\r
-\item \href{#method-getFirst}{\code{Subcollection$getFirst()}}\r
-\item \href{#method-setCollection}{\code{Subcollection$setCollection()}}\r
-\item \href{#method-getCollection}{\code{Subcollection$getCollection()}}\r
-\item \href{#method-getParent}{\code{Subcollection$getParent()}}\r
-\item \href{#method-setParent}{\code{Subcollection$setParent()}}\r
-}\r
-}\r
-\if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-new"></a>}}\r
-\if{latex}{\out{\hypertarget{method-new}{}}}\r
+\item \href{#method-Subcollection-new}{\code{Subcollection$new()}}\r
+\item \href{#method-Subcollection-getName}{\code{Subcollection$getName()}}\r
+\item \href{#method-Subcollection-getRelativePath}{\code{Subcollection$getRelativePath()}}\r
+\item \href{#method-Subcollection-add}{\code{Subcollection$add()}}\r
+\item \href{#method-Subcollection-remove}{\code{Subcollection$remove()}}\r
+\item \href{#method-Subcollection-getFileListing}{\code{Subcollection$getFileListing()}}\r
+\item \href{#method-Subcollection-getSizeInBytes}{\code{Subcollection$getSizeInBytes()}}\r
+\item \href{#method-Subcollection-move}{\code{Subcollection$move()}}\r
+\item \href{#method-Subcollection-copy}{\code{Subcollection$copy()}}\r
+\item \href{#method-Subcollection-duplicate}{\code{Subcollection$duplicate()}}\r
+\item \href{#method-Subcollection-get}{\code{Subcollection$get()}}\r
+\item \href{#method-Subcollection-getFirst}{\code{Subcollection$getFirst()}}\r
+\item \href{#method-Subcollection-setCollection}{\code{Subcollection$setCollection()}}\r
+\item \href{#method-Subcollection-getCollection}{\code{Subcollection$getCollection()}}\r
+\item \href{#method-Subcollection-getParent}{\code{Subcollection$getParent()}}\r
+\item \href{#method-Subcollection-setParent}{\code{Subcollection$setParent()}}\r
+}\r
+}\r
+\if{html}{\out{<hr>}}\r
+\if{html}{\out{<a id="method-Subcollection-new"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-new}{}}}\r
 \subsection{Method \code{new()}}{\r
 Initialize new enviroment.\r
 \subsection{Usage}{\r
@@ -49,8 +49,8 @@ A new `Subcollection` object.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getName"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getName}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-getName"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-getName}{}}}\r
 \subsection{Method \code{getName()}}{\r
 Returns name of the file.\r
 \subsection{Usage}{\r
@@ -59,8 +59,8 @@ Returns name of the file.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getRelativePath"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getRelativePath}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-getRelativePath"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-getRelativePath}{}}}\r
 \subsection{Method \code{getRelativePath()}}{\r
 Returns Subcollection's path relative to the root.\r
 \subsection{Usage}{\r
@@ -69,8 +69,8 @@ Returns Subcollection's path relative to the root.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-add"></a>}}\r
-\if{latex}{\out{\hypertarget{method-add}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-add"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-add}{}}}\r
 \subsection{Method \code{add()}}{\r
 Adds ArvadosFile or Subcollection specified by content to the Subcollection.\r
 \subsection{Usage}{\r
@@ -86,8 +86,8 @@ Adds ArvadosFile or Subcollection specified by content to the Subcollection.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-remove"></a>}}\r
-\if{latex}{\out{\hypertarget{method-remove}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-remove"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-remove}{}}}\r
 \subsection{Method \code{remove()}}{\r
 Removes ArvadosFile or Subcollection specified by name from the Subcollection.\r
 \subsection{Usage}{\r
@@ -103,8 +103,8 @@ Removes ArvadosFile or Subcollection specified by name from the Subcollection.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getFileListing"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getFileListing}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-getFileListing"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-getFileListing}{}}}\r
 \subsection{Method \code{getFileListing()}}{\r
 Returns Subcollections file content as character vector.\r
 \subsection{Usage}{\r
@@ -120,8 +120,8 @@ Returns Subcollections file content as character vector.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getSizeInBytes"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getSizeInBytes}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-getSizeInBytes"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-getSizeInBytes}{}}}\r
 \subsection{Method \code{getSizeInBytes()}}{\r
 Returns subcollections content size in bytes.\r
 \subsection{Usage}{\r
@@ -130,8 +130,8 @@ Returns subcollections content size in bytes.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-move"></a>}}\r
-\if{latex}{\out{\hypertarget{method-move}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-move"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-move}{}}}\r
 \subsection{Method \code{move()}}{\r
 Moves Subcollection to a new location inside collection.\r
 \subsection{Usage}{\r
@@ -147,8 +147,8 @@ Moves Subcollection to a new location inside collection.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-copy"></a>}}\r
-\if{latex}{\out{\hypertarget{method-copy}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-copy"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-copy}{}}}\r
 \subsection{Method \code{copy()}}{\r
 Copies Subcollection to a new location inside collection.\r
 \subsection{Usage}{\r
@@ -164,8 +164,8 @@ Copies Subcollection to a new location inside collection.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-duplicate"></a>}}\r
-\if{latex}{\out{\hypertarget{method-duplicate}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-duplicate"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-duplicate}{}}}\r
 \subsection{Method \code{duplicate()}}{\r
 Duplicate Subcollection and gives it a new name.\r
 \subsection{Usage}{\r
@@ -181,8 +181,8 @@ Duplicate Subcollection and gives it a new name.
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-get"></a>}}\r
-\if{latex}{\out{\hypertarget{method-get}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-get"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-get}{}}}\r
 \subsection{Method \code{get()}}{\r
 If name is valid, returns ArvadosFile or Subcollection specified by relativePath, else returns NULL.\r
 \subsection{Usage}{\r
@@ -198,8 +198,8 @@ If name is valid, returns ArvadosFile or Subcollection specified by relativePath
 }\r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getFirst"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getFirst}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-getFirst"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-getFirst}{}}}\r
 \subsection{Method \code{getFirst()}}{\r
 Returns files in Subcollection.\r
 \subsection{Usage}{\r
@@ -208,8 +208,8 @@ Returns files in Subcollection.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-setCollection"></a>}}\r
-\if{latex}{\out{\hypertarget{method-setCollection}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-setCollection"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-setCollection}{}}}\r
 \subsection{Method \code{setCollection()}}{\r
 Sets Collection by its UUID.\r
 \subsection{Usage}{\r
@@ -218,8 +218,8 @@ Sets Collection by its UUID.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getCollection"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getCollection}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-getCollection"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-getCollection}{}}}\r
 \subsection{Method \code{getCollection()}}{\r
 Returns Collection of Subcollection.\r
 \subsection{Usage}{\r
@@ -228,8 +228,8 @@ Returns Collection of Subcollection.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-getParent"></a>}}\r
-\if{latex}{\out{\hypertarget{method-getParent}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-getParent"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-getParent}{}}}\r
 \subsection{Method \code{getParent()}}{\r
 Returns Collection UUID.\r
 \subsection{Usage}{\r
@@ -238,8 +238,8 @@ Returns Collection UUID.
 \r
 }\r
 \if{html}{\out{<hr>}}\r
-\if{html}{\out{<a id="method-setParent"></a>}}\r
-\if{latex}{\out{\hypertarget{method-setParent}{}}}\r
+\if{html}{\out{<a id="method-Subcollection-setParent"></a>}}\r
+\if{latex}{\out{\hypertarget{method-Subcollection-setParent}{}}}\r
 \subsection{Method \code{setParent()}}{\r
 Sets new Collection.\r
 \subsection{Usage}{\r
index 156dde1080c5040373d55633ff8a689a8867484a..1384c1f8c1aa726920cb19b4ae472df380a46ded 100644 (file)
@@ -2,6 +2,8 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+devtools::check()
+
 results <- devtools::test()
 any_error <- any(as.data.frame(results)$error)
 if (any_error) {
index 095392661afc014ae438b4903e5b99026ee68fa8..255e64d1b4bf88e8542a67cc00bf10b5a5aa498b 100644 (file)
@@ -146,7 +146,11 @@ FakeRESTService <- R6::R6Class(
         getCollectionContent = function(uuid, relativePath = NULL)
         {
             self$getCollectionContentCallCount <- self$getCollectionContentCallCount + 1
-            self$collectionContent
+            if (!is.null(relativePath)) {
+                self$collectionContent[startsWith(self$collectionContent, relativePath)]
+            } else {
+                self$collectionContent
+            }
         },
 
         getResourceSize = function(uuid, relativePathToResource)
index 20a2ecf05b120bb769d7f0b8d01c007099638516..3023a1b23fafcf01c28fda05e775cbe471927e04 100644 (file)
@@ -239,6 +239,12 @@ test_that("get returns arvados file or subcollection from internal tree structur
 
     expect_true(fishIsNotNull)
     expect_that(fish$getName(), equals("fish"))
+
+    ball <- collection$get("ball")
+    ballIsNotNull <- !is.null(ball)
+
+    expect_true(ballIsNotNull)
+    expect_that(ball$getName(), equals("ball"))
 })
 
 test_that(paste("copy copies content to a new location inside file tree",
index 61cf76dbddd91c813a96496c5196f76aa896ab36..f34204e029cb6d2c57568b93b685106413643075 100644 (file)
@@ -6,4 +6,3 @@ source 'https://rubygems.org'
 gemspec
 gem 'minitest', '>= 5.0.0'
 gem 'rake'
-gem 'signet', '<= 0.11'
index 1ff841acdd93e67c080c51a60dfe1ae9ea45055a..67f93c19c318ab276204b0d6d883c47024ad9242 100644 (file)
@@ -38,13 +38,12 @@ Gem::Specification.new do |s|
   s.files       = ["bin/arv", "bin/arv-tag", "LICENSE-2.0.txt"]
   s.executables << "arv"
   s.executables << "arv-tag"
-  s.required_ruby_version = '>= 2.1.0'
-  s.add_runtime_dependency 'arvados', '>= 1.4.1.20190320201707'
-  # Our google-api-client dependency used to be < 0.9, but that could be
-  # satisfied by the buggy 0.9.pre*, cf. https://dev.arvados.org/issues/9213
-  # We need at least version 0.8.7.3, cf. https://dev.arvados.org/issues/15673
-  s.add_runtime_dependency('arvados-google-api-client', '>= 0.8.7.3', '< 0.8.9')
-  s.add_runtime_dependency 'activesupport', '>= 3.2.13', '< 5.3'
+  s.required_ruby_version = '>= 2.7.0'
+  s.add_runtime_dependency 'arvados', '~> 2.8.a'
+  # arvados fork of google-api-client gem with old API and new
+  # compatibility fixes, built from ../ruby-google-api-client/
+  s.add_runtime_dependency('arvados-google-api-client', '>= 0.8.7.5', '< 0.8.9')
+  s.add_runtime_dependency 'activesupport', '>= 3.2.13', '< 8.0'
   s.add_runtime_dependency 'json', '>= 1.7.7', '<3'
   s.add_runtime_dependency 'optimist', '~> 3.0'
   s.add_runtime_dependency 'andand', '~> 1.3', '>= 1.3.3'
index 74ca9312bf54b1e965984f8bb893525ee2147e05..7e13488758b10f5ec9f2ac5a61ec31dfaa1ba4f8 100644 (file)
@@ -10,11 +10,12 @@ from future.utils import viewitems
 from builtins import str
 
 import argparse
+import importlib.metadata
+import importlib.resources
 import logging
 import os
 import sys
 import re
-import pkg_resources  # part of setuptools
 
 from schema_salad.sourceline import SourceLine
 import schema_salad.validate as validate
@@ -28,10 +29,10 @@ from cwltool.utils import adjustFileObjs, adjustDirObjs, get_listing
 
 import arvados
 import arvados.config
+import arvados.logging
 from arvados.keep import KeepClient
 from arvados.errors import ApiError
 import arvados.commands._util as arv_cmd
-from arvados.api import OrderedJsonModel
 
 from .perf import Perf
 from ._version import __version__
@@ -57,18 +58,18 @@ arvados.log_handler.setFormatter(logging.Formatter(
 
 def versionstring():
     """Print version string of key packages for provenance and debugging."""
-
-    arvcwlpkg = pkg_resources.require("arvados-cwl-runner")
-    arvpkg = pkg_resources.require("arvados-python-client")
-    cwlpkg = pkg_resources.require("cwltool")
-
-    return "%s %s, %s %s, %s %s" % (sys.argv[0], arvcwlpkg[0].version,
-                                    "arvados-python-client", arvpkg[0].version,
-                                    "cwltool", cwlpkg[0].version)
-
+    return "{} {}, arvados-python-client {}, cwltool {}".format(
+        sys.argv[0],
+        importlib.metadata.version('arvados-cwl-runner'),
+        importlib.metadata.version('arvados-python-client'),
+        importlib.metadata.version('cwltool'),
+    )
 
 def arg_parser():  # type: () -> argparse.ArgumentParser
-    parser = argparse.ArgumentParser(description='Arvados executor for Common Workflow Language')
+    parser = argparse.ArgumentParser(
+        description='Arvados executor for Common Workflow Language',
+        parents=[arv_cmd.retry_opt],
+    )
 
     parser.add_argument("--basedir",
                         help="Base directory used to resolve relative references in the input, default to directory of input object file or current directory (if inputs piped/provided on command line).")
@@ -120,6 +121,8 @@ def arg_parser():  # type: () -> argparse.ArgumentParser
     exgroup.add_argument("--create-workflow", action="store_true", help="Register an Arvados workflow that can be run from Workbench")
     exgroup.add_argument("--update-workflow", metavar="UUID", help="Update an existing Arvados workflow with the given UUID.")
 
+    exgroup.add_argument("--print-keep-deps", action="store_true", help="To assist copying, print a list of Keep collections that this workflow depends on.")
+
     exgroup = parser.add_mutually_exclusive_group()
     exgroup.add_argument("--wait", action="store_true", help="After submitting workflow runner, wait for completion.",
                         default=True, dest="wait")
@@ -255,6 +258,10 @@ def arg_parser():  # type: () -> argparse.ArgumentParser
                         default=False, dest="trash_intermediate",
                         help="Do not trash intermediate outputs (default).")
 
+    exgroup = parser.add_mutually_exclusive_group()
+    exgroup.add_argument("--enable-usage-report", dest="enable_usage_report", default=None, action="store_true", help="Create usage_report.html with a summary of each step's resource usage.")
+    exgroup.add_argument("--disable-usage-report", dest="enable_usage_report", default=None, action="store_false", help="Disable usage report.")
+
     parser.add_argument("workflow", default=None, help="The workflow to execute")
     parser.add_argument("job_order", nargs=argparse.REMAINDER, help="The input object to the workflow.")
 
@@ -265,10 +272,8 @@ def add_arv_hints():
     cwltool.command_line_tool.ACCEPTLIST_RE = cwltool.command_line_tool.ACCEPTLIST_EN_RELAXED_RE
     supported_versions = ["v1.0", "v1.1", "v1.2"]
     for s in supported_versions:
-        res = pkg_resources.resource_stream(__name__, 'arv-cwl-schema-%s.yml' % s)
-        customschema = res.read().decode('utf-8')
+        customschema = importlib.resources.read_text(__name__, f'arv-cwl-schema-{s}.yml', 'utf-8')
         use_custom_schema(s, "http://arvados.org/cwl", customschema)
-        res.close()
     cwltool.process.supportedProcessRequirements.extend([
         "http://arvados.org/cwl#RunInSingleContainer",
         "http://arvados.org/cwl#OutputDirType",
@@ -321,7 +326,9 @@ def main(args=sys.argv[1:],
             return 1
         arvargs.work_api = want_api
 
-    if (arvargs.create_workflow or arvargs.update_workflow) and not arvargs.job_order:
+    workflow_op = arvargs.create_workflow or arvargs.update_workflow or arvargs.print_keep_deps
+
+    if workflow_op and not arvargs.job_order:
         job_order_object = ({}, "")
 
     add_arv_hints()
@@ -333,8 +340,13 @@ def main(args=sys.argv[1:],
     try:
         if api_client is None:
             api_client = arvados.safeapi.ThreadSafeApiCache(
-                api_params={"model": OrderedJsonModel(), "timeout": arvargs.http_timeout},
-                keep_params={"num_retries": 4},
+                api_params={
+                    'num_retries': arvargs.retries,
+                    'timeout': arvargs.http_timeout,
+                },
+                keep_params={
+                    'num_retries': arvargs.retries,
+                },
                 version='v1',
             )
             keep_client = api_client.keep
@@ -342,8 +354,18 @@ def main(args=sys.argv[1:],
             api_client.users().current().execute()
         if keep_client is None:
             block_cache = arvados.keep.KeepBlockCache(disk_cache=True)
-            keep_client = arvados.keep.KeepClient(api_client=api_client, num_retries=4, block_cache=block_cache)
-        executor = ArvCwlExecutor(api_client, arvargs, keep_client=keep_client, num_retries=4, stdout=stdout)
+            keep_client = arvados.keep.KeepClient(
+                api_client=api_client,
+                block_cache=block_cache,
+                num_retries=arvargs.retries,
+            )
+        executor = ArvCwlExecutor(
+            api_client,
+            arvargs,
+            keep_client=keep_client,
+            num_retries=arvargs.retries,
+            stdout=stdout,
+        )
     except WorkflowException as e:
         logger.error(e, exc_info=(sys.exc_info()[1] if arvargs.debug else False))
         return 1
@@ -353,9 +375,25 @@ def main(args=sys.argv[1:],
 
     # Note that unless in debug mode, some stack traces related to user
     # workflow errors may be suppressed.
+
+    # Set the logging on most modules INFO (instead of default which is WARNING)
+    logger.setLevel(logging.INFO)
+    logging.getLogger('arvados').setLevel(logging.INFO)
+    logging.getLogger('arvados.keep').setLevel(logging.WARNING)
+    # API retries are filtered to the INFO level and can be noisy, but as long as
+    # they succeed we don't need to see warnings about it.
+    googleapiclient_http_logger = logging.getLogger('googleapiclient.http')
+    googleapiclient_http_logger.addFilter(arvados.logging.GoogleHTTPClientFilter())
+    googleapiclient_http_logger.setLevel(logging.WARNING)
+
     if arvargs.debug:
         logger.setLevel(logging.DEBUG)
         logging.getLogger('arvados').setLevel(logging.DEBUG)
+        # In debug mode show logs about retries, but we arn't
+        # debugging the google client so we don't need to see
+        # everything.
+        googleapiclient_http_logger.setLevel(logging.NOTSET)
+        logging.getLogger('googleapiclient').setLevel(logging.INFO)
 
     if arvargs.quiet:
         logger.setLevel(logging.WARN)
@@ -382,9 +420,11 @@ def main(args=sys.argv[1:],
         # unit tests.
         stdout = None
 
+    executor.loadingContext.default_docker_image = arvargs.submit_runner_image or "arvados/jobs:"+__version__
+
     if arvargs.workflow.startswith("arvwf:") or workflow_uuid_pattern.match(arvargs.workflow) or arvargs.workflow.startswith("keep:"):
         executor.loadingContext.do_validate = False
-        if arvargs.submit:
+        if arvargs.submit and not workflow_op:
             executor.fast_submit = True
 
     return cwltool.main.main(args=arvargs,
@@ -397,4 +437,4 @@ def main(args=sys.argv[1:],
                              custom_schema_callback=add_arv_hints,
                              loadingContext=executor.loadingContext,
                              runtimeContext=executor.toplevel_runtimeContext,
-                             input_required=not (arvargs.create_workflow or arvargs.update_workflow))
+                             input_required=not workflow_op)
index 91a05e125439952b8023dd197dd620211052d9eb..aeb41db568722e35ef045a407117f6864f213a95 100644 (file)
@@ -478,8 +478,13 @@ $graph:
         and stderr produced by the tool to determine if a failed job
         should be retried with more RAM.  By default, searches for the
         substrings 'bad_alloc' and 'OutOfMemory'.
-    - name: memoryRetryMultipler
-      type: float
+    - name: memoryRetryMultiplier
+      type: float?
       doc: |
         If the container failed on its first run, re-submit the
         container with the RAM request multiplied by this factor.
+    - name: memoryRetryMultipler
+      type: float?
+      doc: |
+        Deprecated misspelling of "memoryRetryMultiplier".  Kept only
+        for backwards compatability, don't use this.
index 458d5a37a7b0339bfd4bc21dd43fa5ac09d0fe86..0e51d50080ce032897132322635a7d2a0941aaf9 100644 (file)
@@ -421,8 +421,13 @@ $graph:
         and stderr produced by the tool to determine if a failed job
         should be retried with more RAM.  By default, searches for the
         substrings 'bad_alloc' and 'OutOfMemory'.
-    - name: memoryRetryMultipler
-      type: float
+    - name: memoryRetryMultiplier
+      type: float?
       doc: |
         If the container failed on its first run, re-submit the
         container with the RAM request multiplied by this factor.
+    - name: memoryRetryMultipler
+      type: float?
+      doc: |
+        Deprecated misspelling of "memoryRetryMultiplier".  Kept only
+        for backwards compatability, don't use this.
index f4246ed70a5b5f04f240a83b3baf8ec1c67d3827..a753579c9aa7bbd7c945b813e1be9d689350d084 100644 (file)
@@ -424,8 +424,33 @@ $graph:
         and stderr produced by the tool to determine if a failed job
         should be retried with more RAM.  By default, searches for the
         substrings 'bad_alloc' and 'OutOfMemory'.
-    - name: memoryRetryMultipler
-      type: float
+    - name: memoryRetryMultiplier
+      type: float?
       doc: |
         If the container failed on its first run, re-submit the
         container with the RAM request multiplied by this factor.
+    - name: memoryRetryMultipler
+      type: float?
+      doc: |
+        Deprecated misspelling of "memoryRetryMultiplier".  Kept only
+        for backwards compatability, don't use this.
+
+
+- name: SeparateRunner
+  type: record
+  extends: cwl:ProcessRequirement
+  inVocab: false
+  doc: |
+    Indicates that a subworkflow should run in a separate
+    arvados-cwl-runner process.
+  fields:
+    - name: class
+      type: string
+      doc: "Always 'arv:SeparateRunner'"
+      jsonldPredicate:
+        _id: "@type"
+        _type: "@vocab"
+    - name: runnerProcessName
+      type: ['null', string, cwl:Expression]
+      doc: |
+        Custom name to use for the runner process
index be8e557bd8f9f2e0626eedff68a0dfe65fefc35d..c3b914ba996a795623c5c9a1f155a2b11098b4d9 100644 (file)
@@ -27,6 +27,9 @@ from cwltool.job import JobBase
 
 import arvados.collection
 
+import crunchstat_summary.summarizer
+import crunchstat_summary.reader
+
 from .arvdocker import arv_docker_get_image
 from . import done
 from .runner import Runner, arvados_jobs_image, packed_workflow, trim_anonymous_location, remove_redundant_fields, make_builder
@@ -370,8 +373,13 @@ class ArvadosContainer(JobBase):
         ram_multiplier = [1]
 
         oom_retry_req, _ = self.get_requirement("http://arvados.org/cwl#OutOfMemoryRetry")
-        if oom_retry_req and oom_retry_req.get('memoryRetryMultipler'):
-            ram_multiplier.append(oom_retry_req.get('memoryRetryMultipler'))
+        if oom_retry_req:
+            if oom_retry_req.get('memoryRetryMultiplier'):
+                ram_multiplier.append(oom_retry_req.get('memoryRetryMultiplier'))
+            elif oom_retry_req.get('memoryRetryMultipler'):
+                ram_multiplier.append(oom_retry_req.get('memoryRetryMultipler'))
+            else:
+                ram_multiplier.append(2)
 
         if runtimeContext.runnerjob.startswith("arvwf:"):
             wfuuid = runtimeContext.runnerjob[6:runtimeContext.runnerjob.index("#")]
@@ -459,6 +467,7 @@ class ArvadosContainer(JobBase):
     def done(self, record):
         outputs = {}
         retried = False
+        rcode = None
         try:
             container = self.arvrunner.api.containers().get(
                 uuid=record["container_uuid"]
@@ -491,15 +500,18 @@ class ArvadosContainer(JobBase):
             else:
                 processStatus = "permanentFail"
 
-            if processStatus == "permanentFail" and record["log_uuid"]:
-                logc = arvados.collection.CollectionReader(record["log_uuid"],
-                                                           api_client=self.arvrunner.api,
-                                                           keep_client=self.arvrunner.keep_client,
-                                                           num_retries=self.arvrunner.num_retries)
+            logc = None
+            if record["log_uuid"]:
+                logc = arvados.collection.Collection(record["log_uuid"],
+                                                     api_client=self.arvrunner.api,
+                                                     keep_client=self.arvrunner.keep_client,
+                                                     num_retries=self.arvrunner.num_retries)
+
+            if processStatus == "permanentFail" and logc is not None:
                 label = self.arvrunner.label(self)
                 done.logtail(
                     logc, logger.error,
-                    "%s (%s) error log:" % (label, record["uuid"]), maxlen=40)
+                    "%s (%s) error log:" % (label, record["uuid"]), maxlen=40, include_crunchrun=(rcode is None or rcode > 127))
 
             if record["output_uuid"]:
                 if self.arvrunner.trash_intermediate or self.arvrunner.intermediate_output_ttl:
@@ -521,6 +533,28 @@ class ArvadosContainer(JobBase):
                 uuid=self.uuid,
                 body={"container_request": {"properties": properties}}
             ).execute(num_retries=self.arvrunner.num_retries)
+
+            if logc is not None and self.job_runtime.enable_usage_report is not False:
+                try:
+                    summarizer = crunchstat_summary.summarizer.ContainerRequestSummarizer(
+                        record,
+                        collection_object=logc,
+                        label=self.name,
+                        arv=self.arvrunner.api)
+                    summarizer.run()
+                    with logc.open("usage_report.html", "wt") as mr:
+                        mr.write(summarizer.html_report())
+                    logc.save()
+
+                    # Post warnings about nodes that are under-utilized.
+                    for rc in summarizer._recommend_gen(lambda x: x):
+                        self.job_runtime.usage_report_notes.append(rc)
+
+                except Exception as e:
+                    logger.warning("%s unable to generate resource usage report",
+                                 self.arvrunner.label(self),
+                                 exc_info=(e if self.arvrunner.debug else False))
+
         except WorkflowException as e:
             # Only include a stack trace if in debug mode.
             # A stack trace may obfuscate more useful output about the workflow.
@@ -559,13 +593,19 @@ class RunnerContainer(Runner):
                 }
                 self.job_order[param] = {"$include": mnt}
 
+        container_image = arvados_jobs_image(self.arvrunner, self.jobs_image, runtimeContext)
+
+        workflow_runner_req, _ = self.embedded_tool.get_requirement("http://arvados.org/cwl#WorkflowRunnerResources")
+        if workflow_runner_req and workflow_runner_req.get("acrContainerImage"):
+            container_image = workflow_runner_req.get("acrContainerImage")
+
         container_req = {
             "name": self.name,
             "output_path": "/var/spool/cwl",
             "cwd": "/var/spool/cwl",
             "priority": self.priority,
             "state": "Committed",
-            "container_image": arvados_jobs_image(self.arvrunner, self.jobs_image, runtimeContext),
+            "container_image": container_image,
             "mounts": {
                 "/var/lib/cwl/cwl.input.json": {
                     "kind": "json",
@@ -586,7 +626,7 @@ class RunnerContainer(Runner):
                 "ram": 1024*1024 * (math.ceil(self.submit_runner_ram) + math.ceil(self.collection_cache_size)),
                 "API": True
             },
-            "use_existing": False, # Never reuse the runner container - see #15497.
+            "use_existing": self.reuse_runner,
             "properties": {}
         }
 
@@ -610,6 +650,8 @@ class RunnerContainer(Runner):
                 "content": packed
             }
             container_req["properties"]["template_uuid"] = self.embedded_tool.tool["id"][6:33]
+        elif self.embedded_tool.tool.get("id", "").startswith("file:"):
+            raise WorkflowException("Tool id '%s' is a local file but expected keep: or arvwf:" % self.embedded_tool.tool.get("id"))
         else:
             main = self.loadingContext.loader.idx["_:main"]
             if main.get("id") == "_:main":
@@ -690,6 +732,12 @@ class RunnerContainer(Runner):
         if runtimeContext.prefer_cached_downloads:
             command.append("--prefer-cached-downloads")
 
+        if runtimeContext.enable_usage_report is True:
+            command.append("--enable-usage-report")
+
+        if runtimeContext.enable_usage_report is False:
+            command.append("--disable-usage-report")
+
         if self.fast_parser:
             command.append("--fast-parser")
 
@@ -730,14 +778,9 @@ class RunnerContainer(Runner):
 
         logger.info("%s submitted container_request %s", self.arvrunner.label(self), response["uuid"])
 
-        workbench1 = self.arvrunner.api.config()["Services"]["Workbench1"]["ExternalURL"]
         workbench2 = self.arvrunner.api.config()["Services"]["Workbench2"]["ExternalURL"]
-        url = ""
         if workbench2:
             url = "{}processes/{}".format(workbench2, response["uuid"])
-        elif workbench1:
-            url = "{}container_requests/{}".format(workbench1, response["uuid"])
-        if url:
             logger.info("Monitor workflow progress at %s", url)
 
 
index b66e8ad3aac6b73b3bb086a60a1403c8a6cf7a64..86fecc0a1dbb6e1e0687b8f2cf96f8f8ba44f5da 100644 (file)
@@ -10,6 +10,7 @@ from ._version import __version__
 from functools import partial
 from schema_salad.sourceline import SourceLine
 from cwltool.errors import WorkflowException
+from arvados.util import portable_data_hash_pattern
 
 def validate_cluster_target(arvrunner, runtimeContext):
     if (runtimeContext.submit_runner_cluster and
@@ -61,8 +62,12 @@ class ArvadosCommandTool(CommandLineTool):
 
         (docker_req, docker_is_req) = self.get_requirement("DockerRequirement")
         if not docker_req:
-            self.hints.append({"class": "DockerRequirement",
-                               "dockerPull": "arvados/jobs:"+__version__})
+            if portable_data_hash_pattern.match(loadingContext.default_docker_image):
+                self.hints.append({"class": "DockerRequirement",
+                                   "http://arvados.org/cwl#dockerCollectionPDH": loadingContext.default_docker_image})
+            else:
+                self.hints.append({"class": "DockerRequirement",
+                                   "dockerPull": loadingContext.default_docker_image})
 
         self.arvrunner = arvrunner
 
index 895676565d53f6b817ac0ad555330aa4b12781e4..c592b83dc7739b142fb51ffff25a630a5494f5fc 100644 (file)
@@ -29,7 +29,7 @@ from cwltool.load_tool import fetch_document, resolve_and_validate_document
 from cwltool.process import shortname, uniquename
 from cwltool.workflow import Workflow, WorkflowException, WorkflowStep
 from cwltool.utils import adjustFileObjs, adjustDirObjs, visit_class, normalizeFilesDirs
-from cwltool.context import LoadingContext
+from cwltool.context import LoadingContext, getdefault
 
 from schema_salad.ref_resolver import file_uri, uri_file_path
 
@@ -38,9 +38,12 @@ import ruamel.yaml as yaml
 from .runner import (upload_dependencies, packed_workflow, upload_workflow_collection,
                      trim_anonymous_location, remove_redundant_fields, discover_secondary_files,
                      make_builder, arvados_jobs_image, FileUpdates)
+from .arvcontainer import RunnerContainer
 from .pathmapper import ArvPathMapper, trim_listing
 from .arvtool import ArvadosCommandTool, set_cluster_target
 from ._version import __version__
+from .util import common_prefix
+from .arvdocker import arv_docker_get_image
 
 from .perf import Perf
 
@@ -50,6 +53,21 @@ metrics = logging.getLogger('arvados.cwl-runner.metrics')
 max_res_pars = ("coresMin", "coresMax", "ramMin", "ramMax", "tmpdirMin", "tmpdirMax")
 sum_res_pars = ("outdirMin", "outdirMax")
 
+_basetype_re = re.compile(r'''(?:
+Directory
+|File
+|array
+|boolean
+|double
+|enum
+|float
+|int
+|long
+|null
+|record
+|string
+)(?:\[\])?\??''', re.VERBOSE)
+
 def make_wrapper_workflow(arvRunner, main, packed, project_uuid, name, git_info, tool):
     col = arvados.collection.Collection(api_client=arvRunner.api,
                                         keep_client=arvRunner.keep_client)
@@ -130,7 +148,7 @@ def make_wrapper_workflow(arvRunner, main, packed, project_uuid, name, git_info,
 
 
 def rel_ref(s, baseuri, urlexpander, merged_map, jobmapper):
-    if s.startswith("keep:"):
+    if s.startswith("keep:") or s.startswith("arvwf:"):
         return s
 
     uri = urlexpander(s, baseuri)
@@ -160,21 +178,16 @@ def rel_ref(s, baseuri, urlexpander, merged_map, jobmapper):
     return os.path.join(r, p3)
 
 def is_basetype(tp):
-    basetypes = ("null", "boolean", "int", "long", "float", "double", "string", "File", "Directory", "record", "array", "enum")
-    for b in basetypes:
-        if re.match(b+"(\[\])?\??", tp):
-            return True
-    return False
-
+    return _basetype_re.match(tp) is not None
 
-def update_refs(d, baseuri, urlexpander, merged_map, jobmapper, runtimeContext, prefix, replacePrefix):
+def update_refs(api, d, baseuri, urlexpander, merged_map, jobmapper, runtimeContext, prefix, replacePrefix):
     if isinstance(d, MutableSequence):
         for i, s in enumerate(d):
             if prefix and isinstance(s, str):
                 if s.startswith(prefix):
                     d[i] = replacePrefix+s[len(prefix):]
             else:
-                update_refs(s, baseuri, urlexpander, merged_map, jobmapper, runtimeContext, prefix, replacePrefix)
+                update_refs(api, s, baseuri, urlexpander, merged_map, jobmapper, runtimeContext, prefix, replacePrefix)
     elif isinstance(d, MutableMapping):
         for field in ("id", "name"):
             if isinstance(d.get(field), str) and d[field].startswith("_:"):
@@ -187,8 +200,8 @@ def update_refs(d, baseuri, urlexpander, merged_map, jobmapper, runtimeContext,
             baseuri = urlexpander(d["name"], baseuri, scoped_id=True)
 
         if d.get("class") == "DockerRequirement":
-            dockerImageId = d.get("dockerImageId") or d.get("dockerPull")
-            d["http://arvados.org/cwl#dockerCollectionPDH"] = runtimeContext.cached_docker_lookups.get(dockerImageId)
+            d["http://arvados.org/cwl#dockerCollectionPDH"] = arv_docker_get_image(api, d, False,
+                                                                                   runtimeContext)
 
         for field in d:
             if field in ("location", "run", "name") and isinstance(d[field], str):
@@ -211,15 +224,21 @@ def update_refs(d, baseuri, urlexpander, merged_map, jobmapper, runtimeContext,
                     if isinstance(d["inputs"][inp], str) and not is_basetype(d["inputs"][inp]):
                         d["inputs"][inp] = rel_ref(d["inputs"][inp], baseuri, urlexpander, merged_map, jobmapper)
                     if isinstance(d["inputs"][inp], MutableMapping):
-                        update_refs(d["inputs"][inp], baseuri, urlexpander, merged_map, jobmapper, runtimeContext, prefix, replacePrefix)
+                        update_refs(api, d["inputs"][inp], baseuri, urlexpander, merged_map, jobmapper, runtimeContext, prefix, replacePrefix)
                 continue
 
+            if field in ("requirements", "hints") and isinstance(d[field], MutableMapping):
+                dr = d[field].get("DockerRequirement")
+                if dr:
+                    dr["http://arvados.org/cwl#dockerCollectionPDH"] = arv_docker_get_image(api, dr, False,
+                                                                                            runtimeContext)
+
             if field == "$schemas":
                 for n, s in enumerate(d["$schemas"]):
                     d["$schemas"][n] = rel_ref(d["$schemas"][n], baseuri, urlexpander, merged_map, jobmapper)
                 continue
 
-            update_refs(d[field], baseuri, urlexpander, merged_map, jobmapper, runtimeContext, prefix, replacePrefix)
+            update_refs(api, d[field], baseuri, urlexpander, merged_map, jobmapper, runtimeContext, prefix, replacePrefix)
 
 
 def fix_schemadef(req, baseuri, urlexpander, merged_map, jobmapper, pdh):
@@ -235,6 +254,7 @@ def fix_schemadef(req, baseuri, urlexpander, merged_map, jobmapper, pdh):
             merged_map[mm].resolved[r] = rename
     return req
 
+
 def drop_ids(d):
     if isinstance(d, MutableSequence):
         for i, s in enumerate(d):
@@ -280,22 +300,8 @@ def upload_workflow(arvRunner, tool, job_order, project_uuid,
     # Find the longest common prefix among all the file names.  We'll
     # use this to recreate the directory structure in a keep
     # collection with correct relative references.
-    n = 7
-    allmatch = True
-    if firstfile:
-        while allmatch:
-            n += 1
-            for f in all_files:
-                if len(f)-1 < n:
-                    n -= 1
-                    allmatch = False
-                    break
-                if f[n] != firstfile[n]:
-                    allmatch = False
-                    break
-
-        while firstfile[n] != "/":
-            n -= 1
+    prefix = common_prefix(firstfile, all_files) if firstfile else ""
+
 
     col = arvados.collection.Collection(api_client=arvRunner.api)
 
@@ -329,25 +335,25 @@ def upload_workflow(arvRunner, tool, job_order, project_uuid,
 
         # 2. find $import, $include, $schema, run, location
         # 3. update field value
-        update_refs(result, w, tool.doc_loader.expand_url, merged_map, jobmapper, runtimeContext, "", "")
+        update_refs(arvRunner.api, result, w, tool.doc_loader.expand_url, merged_map, jobmapper, runtimeContext, "", "")
 
         # Write the updated file to the collection.
-        with col.open(w[n+1:], "wt") as f:
+        with col.open(w[len(prefix):], "wt") as f:
             if export_as_json:
                 json.dump(result, f, indent=4, separators=(',',': '))
             else:
                 yamlloader.dump(result, stream=f)
 
         # Also store a verbatim copy of the original files
-        with col.open(os.path.join("original", w[n+1:]), "wt") as f:
+        with col.open(os.path.join("original", w[len(prefix):]), "wt") as f:
             f.write(text)
 
 
     # Upload files referenced by $include directives, these are used
     # unchanged and don't need to be updated.
     for w in include_files:
-        with col.open(w[n+1:], "wb") as f1:
-            with col.open(os.path.join("original", w[n+1:]), "wb") as f3:
+        with col.open(w[len(prefix):], "wb") as f1:
+            with col.open(os.path.join("original", w[len(prefix):]), "wb") as f3:
                 with open(uri_file_path(w), "rb") as f2:
                     dat = f2.read(65536)
                     while dat:
@@ -361,7 +367,7 @@ def upload_workflow(arvRunner, tool, job_order, project_uuid,
     if git_info and git_info.get("http://arvados.org/cwl#gitDescribe"):
         toolname = "%s (%s)" % (toolname, git_info.get("http://arvados.org/cwl#gitDescribe"))
 
-    toolfile = tool.tool["id"][n+1:]
+    toolfile = tool.tool["id"][len(prefix):]
 
     properties = {
         "type": "workflow",
@@ -414,9 +420,10 @@ def upload_workflow(arvRunner, tool, job_order, project_uuid,
         wf_runner_resources = {"class": "http://arvados.org/cwl#WorkflowRunnerResources"}
         hints.append(wf_runner_resources)
 
-    wf_runner_resources["acrContainerImage"] = arvados_jobs_image(arvRunner,
-                                                                  submit_runner_image or "arvados/jobs:"+__version__,
-                                                                  runtimeContext)
+    if "acrContainerImage" not in wf_runner_resources:
+        wf_runner_resources["acrContainerImage"] = arvados_jobs_image(arvRunner,
+                                                                      submit_runner_image or "arvados/jobs:"+__version__,
+                                                                      runtimeContext)
 
     if submit_runner_ram:
         wf_runner_resources["ramMin"] = submit_runner_ram
@@ -486,7 +493,7 @@ def upload_workflow(arvRunner, tool, job_order, project_uuid,
         if r["class"] == "SchemaDefRequirement":
             wrapper["requirements"][i] = fix_schemadef(r, main["id"], tool.doc_loader.expand_url, merged_map, jobmapper, col.portable_data_hash())
 
-    update_refs(wrapper, main["id"], tool.doc_loader.expand_url, merged_map, jobmapper, runtimeContext, main["id"]+"#", "#main/")
+    update_refs(arvRunner.api, wrapper, main["id"], tool.doc_loader.expand_url, merged_map, jobmapper, runtimeContext, main["id"]+"#", "#main/")
 
     doc = {"cwlVersion": "v1.2", "$graph": [wrapper]}
 
@@ -596,21 +603,22 @@ class ArvadosWorkflow(Workflow):
         self.dynamic_resource_req = []
         self.static_resource_req = []
         self.wf_reffiles = []
-        self.loadingContext = loadingContext
-        super(ArvadosWorkflow, self).__init__(toolpath_object, loadingContext)
-        self.cluster_target_req, _ = self.get_requirement("http://arvados.org/cwl#ClusterTarget")
+        self.loadingContext = loadingContext.copy()
 
-    def job(self, joborder, output_callback, runtimeContext):
+        self.requirements = copy.deepcopy(getdefault(loadingContext.requirements, []))
+        tool_requirements = toolpath_object.get("requirements", [])
+        self.hints = copy.deepcopy(getdefault(loadingContext.hints, []))
+        tool_hints = toolpath_object.get("hints", [])
 
-        builder = make_builder(joborder, self.hints, self.requirements, runtimeContext, self.metadata)
-        runtimeContext = set_cluster_target(self.tool, self.arvrunner, builder, runtimeContext)
+        workflow_runner_req, _ = self.get_requirement("http://arvados.org/cwl#WorkflowRunnerResources")
+        if workflow_runner_req and workflow_runner_req.get("acrContainerImage"):
+            self.loadingContext.default_docker_image = workflow_runner_req.get("acrContainerImage")
 
-        req, _ = self.get_requirement("http://arvados.org/cwl#RunInSingleContainer")
-        if not req:
-            return super(ArvadosWorkflow, self).job(joborder, output_callback, runtimeContext)
+        super(ArvadosWorkflow, self).__init__(toolpath_object, self.loadingContext)
+        self.cluster_target_req, _ = self.get_requirement("http://arvados.org/cwl#ClusterTarget")
 
-        # RunInSingleContainer is true
 
+    def runInSingleContainer(self, joborder, output_callback, runtimeContext, builder):
         with SourceLine(self.tool, None, WorkflowException, logger.isEnabledFor(logging.DEBUG)):
             if "id" not in self.tool:
                 raise WorkflowException("%s object must have 'id'" % (self.tool["class"]))
@@ -773,6 +781,51 @@ class ArvadosWorkflow(Workflow):
         })
         return ArvadosCommandTool(self.arvrunner, wf_runner, self.loadingContext).job(joborder_resolved, output_callback, runtimeContext)
 
+
+    def separateRunner(self, joborder, output_callback, runtimeContext, req, builder):
+
+        name = runtimeContext.name
+
+        rpn = req.get("runnerProcessName")
+        if rpn:
+            name = builder.do_eval(rpn)
+
+        return RunnerContainer(self.arvrunner,
+                               self,
+                               self.loadingContext,
+                               runtimeContext.enable_reuse,
+                               None,
+                               None,
+                               submit_runner_ram=runtimeContext.submit_runner_ram,
+                               name=name,
+                               on_error=runtimeContext.on_error,
+                               submit_runner_image=runtimeContext.submit_runner_image,
+                               intermediate_output_ttl=runtimeContext.intermediate_output_ttl,
+                               merged_map=None,
+                               priority=runtimeContext.priority,
+                               secret_store=self.arvrunner.secret_store,
+                               collection_cache_size=runtimeContext.collection_cache_size,
+                               collection_cache_is_default=self.arvrunner.should_estimate_cache_size,
+                               git_info=runtimeContext.git_info,
+                               reuse_runner=True).job(joborder, output_callback, runtimeContext)
+
+
+    def job(self, joborder, output_callback, runtimeContext):
+
+        builder = make_builder(joborder, self.hints, self.requirements, runtimeContext, self.metadata)
+        runtimeContext = set_cluster_target(self.tool, self.arvrunner, builder, runtimeContext)
+
+        req, _ = self.get_requirement("http://arvados.org/cwl#RunInSingleContainer")
+        if req:
+            return self.runInSingleContainer(joborder, output_callback, runtimeContext, builder)
+
+        req, _ = self.get_requirement("http://arvados.org/cwl#SeparateRunner")
+        if req:
+            return self.separateRunner(joborder, output_callback, runtimeContext, req, builder)
+
+        return super(ArvadosWorkflow, self).job(joborder, output_callback, runtimeContext)
+
+
     def make_workflow_step(self,
                            toolpath_object,      # type: Dict[Text, Any]
                            pos,                  # type: int
index 125527f783f9faa49d66f5e2c09b46c1373eb353..60ea9bdff50b5c6e46bcfa6381edc662952e15a3 100644 (file)
@@ -7,6 +7,7 @@ from collections import namedtuple
 
 class ArvLoadingContext(LoadingContext):
     def __init__(self, kwargs=None):
+        self.default_docker_image = None
         super(ArvLoadingContext, self).__init__(kwargs)
 
 class ArvRuntimeContext(RuntimeContext):
@@ -43,6 +44,10 @@ class ArvRuntimeContext(RuntimeContext):
         self.varying_url_params = ""
         self.prefer_cached_downloads = False
         self.cached_docker_lookups = {}
+        self.print_keep_deps = False
+        self.git_info = {}
+        self.enable_usage_report = None
+        self.usage_report_notes = []
 
         super(ArvRuntimeContext, self).__init__(kwargs)
 
index e12fe185a039ff509e360b2a6d29d1219ca29afe..5c1241976597286eba0edbda62690a431d349c0f 100644 (file)
@@ -57,43 +57,45 @@ def done_outputs(self, record, tmpdir, outdir, keepdir):
 crunchstat_re = re.compile(r"^\d{4}-\d\d-\d\d_\d\d:\d\d:\d\d [a-z0-9]{5}-8i9sb-[a-z0-9]{15} \d+ \d+ stderr crunchstat:")
 timestamp_re = re.compile(r"^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z) (.*)")
 
-def logtail(logcollection, logfunc, header, maxlen=25):
+def logtail(logcollection, logfunc, header, maxlen=25, include_crunchrun=True):
     if len(logcollection) == 0:
         logfunc("%s\n%s", header, "  ** log is empty **")
         return
 
-    containersapi = ("crunch-run.txt" in logcollection)
     mergelogs = {}
+    logfiles = ["stdout.txt", "stderr.txt"]
 
-    for log in list(logcollection):
-        if not containersapi or log in ("crunch-run.txt", "stdout.txt", "stderr.txt"):
-            logname = log[:-4]
-            logt = deque([], maxlen)
-            mergelogs[logname] = logt
-            with logcollection.open(log, encoding="utf-8") as f:
-                for l in f:
-                    if containersapi:
-                        g = timestamp_re.match(l)
-                        logt.append((g.group(1), g.group(2)))
-                    elif not crunchstat_re.match(l):
-                        logt.append(l)
-
-    if containersapi:
-        keys = list(mergelogs)
-        loglines = []
-        while True:
-            earliest = None
-            for k in keys:
-                if mergelogs[k]:
-                    if earliest is None or mergelogs[k][0][0] < mergelogs[earliest][0][0]:
-                        earliest = k
-            if earliest is None:
-                break
-            ts, msg = mergelogs[earliest].popleft()
-            loglines.append("%s %s %s" % (ts, earliest, msg))
-        loglines = loglines[-maxlen:]
-    else:
-        loglines = mergelogs[list(mergelogs)[0]]
+    if include_crunchrun:
+        logfiles.append("crunch-run.txt")
+
+    for log in logfiles:
+        if log not in logcollection:
+            continue
+        logname = log[:-4]  # trim off the .txt
+        logt = deque([], maxlen)
+        mergelogs[logname] = logt
+        with logcollection.open(log, encoding="utf-8") as f:
+            for l in f:
+                g = timestamp_re.match(l)
+                logt.append((g.group(1), g.group(2)))
+
+    keys = list(mergelogs)
+    loglines = []
+
+    # we assume the log lines are all in order so this this is a
+    # straight linear merge where we look at the next timestamp of
+    # each log and take whichever one is earliest.
+    while True:
+        earliest = None
+        for k in keys:
+            if mergelogs[k]:
+                if earliest is None or mergelogs[k][0][0] < mergelogs[earliest][0][0]:
+                    earliest = k
+        if earliest is None:
+            break
+        ts, msg = mergelogs[earliest].popleft()
+        loglines.append("%s %s %s" % (ts, earliest, msg))
+    loglines = loglines[-maxlen:]
 
     logtxt = "\n  ".join(l.strip() for l in loglines)
-    logfunc("%s\n\n  %s", header, logtxt)
+    logfunc("%s\n\n  %s\n", header, logtxt)
index ef84dd4983c870d2779a3cf993bcaeff8aa13f6f..432b380aabcd90c4c91ff3d7d72a9af29ab52823 100644 (file)
@@ -34,7 +34,7 @@ from arvados.errors import ApiError
 
 import arvados_cwl.util
 from .arvcontainer import RunnerContainer, cleanup_name_for_collection
-from .runner import Runner, upload_docker, upload_job_order, upload_workflow_deps, make_builder, update_from_merged_map
+from .runner import Runner, upload_docker, upload_job_order, upload_workflow_deps, make_builder, update_from_merged_map, print_keep_deps
 from .arvtool import ArvadosCommandTool, validate_cluster_target, ArvadosExpressionTool
 from .arvworkflow import ArvadosWorkflow, upload_workflow, make_workflow_record
 from .fsaccess import CollectionFsAccess, CollectionFetcher, collectionResolver, CollectionCache, pdh_size
@@ -70,7 +70,7 @@ class RuntimeStatusLoggingHandler(logging.Handler):
             kind = 'error'
         elif record.levelno >= logging.WARNING:
             kind = 'warning'
-        if kind == 'warning' and record.name == "salad":
+        if kind == 'warning' and record.name in ("salad", "crunchstat_summary"):
             # Don't send validation warnings to runtime status,
             # they're noisy and unhelpful.
             return
@@ -146,6 +146,7 @@ class ArvCwlExecutor(object):
         self.stdout = stdout
         self.fast_submit = False
         self.git_info = arvargs.git_info
+        self.debug = False
 
         if keep_client is not None:
             self.keep_client = keep_client
@@ -266,7 +267,7 @@ The 'jobs' API is no longer supported.
         activity statuses, for example in the RuntimeStatusLoggingHandler.
         """
 
-        if kind not in ('error', 'warning'):
+        if kind not in ('error', 'warning', 'activity'):
             # Ignore any other status kind
             return
 
@@ -281,7 +282,7 @@ The 'jobs' API is no longer supported.
             runtime_status = current.get('runtime_status', {})
 
             original_updatemessage = updatemessage = runtime_status.get(kind, "")
-            if not updatemessage:
+            if kind == "activity" or not updatemessage:
                 updatemessage = message
 
             # Subsequent messages tacked on in detail
@@ -368,9 +369,11 @@ The 'jobs' API is no longer supported.
                 while keys:
                     page = keys[:pageSize]
                     try:
-                        proc_states = table.list(filters=[["uuid", "in", page]]).execute(num_retries=self.num_retries)
+                        proc_states = table.list(filters=[["uuid", "in", page]], select=["uuid", "container_uuid", "state", "log_uuid",
+                                                                                         "output_uuid", "modified_at", "properties",
+                                                                                         "runtime_constraints"]).execute(num_retries=self.num_retries)
                     except Exception as e:
-                        logger.exception("Error checking states on API server: %s", e)
+                        logger.warning("Temporary error checking states on API server: %s", e)
                         remain_wait = self.poll_interval
                         continue
 
@@ -593,6 +596,8 @@ The 'jobs' API is no longer supported.
     def arv_executor(self, updated_tool, job_order, runtimeContext, logger=None):
         self.debug = runtimeContext.debug
 
+        self.runtime_status_update("activity", "initialization")
+
         git_info = self.get_git_info(updated_tool) if self.git_info else {}
         if git_info:
             logger.info("Git provenance")
@@ -600,6 +605,8 @@ The 'jobs' API is no longer supported.
                 if git_info[g]:
                     logger.info("  %s: %s", g.split("#", 1)[1], git_info[g])
 
+        runtimeContext.git_info = git_info
+
         workbench1 = self.api.config()["Services"]["Workbench1"]["ExternalURL"]
         workbench2 = self.api.config()["Services"]["Workbench2"]["ExternalURL"]
         controller = self.api.config()["Services"]["Controller"]["ExternalURL"]
@@ -646,6 +653,10 @@ The 'jobs' API is no longer supported.
             runtimeContext.copy_deps = True
             runtimeContext.match_local_docker = True
 
+        if runtimeContext.print_keep_deps:
+            runtimeContext.copy_deps = False
+            runtimeContext.match_local_docker = False
+
         if runtimeContext.update_workflow and self.project_uuid is None:
             # If we are updating a workflow, make sure anything that
             # gets uploaded goes into the same parent project, unless
@@ -655,6 +666,8 @@ The 'jobs' API is no longer supported.
 
         self.project_uuid = runtimeContext.project_uuid
 
+        self.runtime_status_update("activity", "data transfer")
+
         # Upload local file references in the job order.
         with Perf(metrics, "upload_job_order"):
             job_order, jobmapper = upload_job_order(self, "%s input" % runtimeContext.name,
@@ -666,12 +679,10 @@ The 'jobs' API is no longer supported.
         # are going to wait for the result, and always_submit_runner
         # is false, then we don't submit a runner process.
 
-        submitting = (runtimeContext.update_workflow or
-                      runtimeContext.create_workflow or
-                      (runtimeContext.submit and not
+        submitting = (runtimeContext.submit and not
                        (updated_tool.tool["class"] == "CommandLineTool" and
                         runtimeContext.wait and
-                        not runtimeContext.always_submit_runner)))
+                        not runtimeContext.always_submit_runner))
 
         loadingContext = self.loadingContext.copy()
         loadingContext.do_validate = False
@@ -697,7 +708,7 @@ The 'jobs' API is no longer supported.
         loadingContext.skip_resolve_all = True
 
         workflow_wrapper = None
-        if submitting and not self.fast_submit:
+        if (submitting and not self.fast_submit) or runtimeContext.update_workflow or runtimeContext.create_workflow or runtimeContext.print_keep_deps:
             # upload workflow and get back the workflow wrapper
 
             workflow_wrapper = upload_workflow(self, tool, job_order,
@@ -720,6 +731,11 @@ The 'jobs' API is no longer supported.
                 self.stdout.write(uuid + "\n")
                 return (None, "success")
 
+            if runtimeContext.print_keep_deps:
+                # Just find and print out all the collection dependencies and exit
+                print_keep_deps(self, runtimeContext, merged_map, tool)
+                return (None, "success")
+
             # Did not register a workflow, we're going to submit
             # it instead.
             loadingContext.loader.idx.clear()
@@ -823,6 +839,8 @@ The 'jobs' API is no longer supported.
         # We either running the workflow directly, or submitting it
         # and will wait for a final result.
 
+        self.runtime_status_update("activity", "workflow execution")
+
         current_container = arvados_cwl.util.get_current_container(self.api, self.num_retries, logger)
         if current_container:
             logger.info("Running inside container %s", current_container.get("uuid"))
@@ -860,7 +878,8 @@ The 'jobs' API is no longer supported.
                     if (self.task_queue.in_flight + len(self.processes)) > 0:
                         self.workflow_eval_lock.wait(3)
                     else:
-                        logger.error("Workflow is deadlocked, no runnable processes and not waiting on any pending processes.")
+                        if self.final_status is None:
+                            logger.error("Workflow is deadlocked, no runnable processes and not waiting on any pending processes.")
                         break
 
                 if self.stop_polling.is_set():
@@ -910,6 +929,11 @@ The 'jobs' API is no longer supported.
         if self.final_output is None:
             raise WorkflowException("Workflow did not return a result.")
 
+        if runtimeContext.usage_report_notes:
+            logger.info("Steps with low resource utilization (possible optimization opportunities):")
+            for x in runtimeContext.usage_report_notes:
+                logger.info("  %s", x)
+
         if runtimeContext.submit and isinstance(tool, Runner):
             logger.info("Final output collection %s", tool.final_output)
             if workbench2 or workbench1:
diff --git a/sdk/cwl/arvados_cwl/http.py b/sdk/cwl/arvados_cwl/http.py
deleted file mode 100644 (file)
index f2415bc..0000000
+++ /dev/null
@@ -1,224 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: Apache-2.0
-
-from __future__ import division
-from future import standard_library
-standard_library.install_aliases()
-
-import requests
-import email.utils
-import time
-import datetime
-import re
-import arvados
-import arvados.collection
-import urllib.parse
-import logging
-import calendar
-import urllib.parse
-
-logger = logging.getLogger('arvados.cwl-runner')
-
-def my_formatdate(dt):
-    return email.utils.formatdate(timeval=calendar.timegm(dt.timetuple()),
-                                  localtime=False, usegmt=True)
-
-def my_parsedate(text):
-    parsed = email.utils.parsedate_tz(text)
-    if parsed:
-        if parsed[9]:
-            # Adjust to UTC
-            return datetime.datetime(*parsed[:6]) + datetime.timedelta(seconds=parsed[9])
-        else:
-            # TZ is zero or missing, assume UTC.
-            return datetime.datetime(*parsed[:6])
-    else:
-        return datetime.datetime(1970, 1, 1)
-
-def fresh_cache(url, properties, now):
-    pr = properties[url]
-    expires = None
-
-    logger.debug("Checking cache freshness for %s using %s", url, pr)
-
-    if "Cache-Control" in pr:
-        if re.match(r"immutable", pr["Cache-Control"]):
-            return True
-
-        g = re.match(r"(s-maxage|max-age)=(\d+)", pr["Cache-Control"])
-        if g:
-            expires = my_parsedate(pr["Date"]) + datetime.timedelta(seconds=int(g.group(2)))
-
-    if expires is None and "Expires" in pr:
-        expires = my_parsedate(pr["Expires"])
-
-    if expires is None:
-        # Use a default cache time of 24 hours if upstream didn't set
-        # any cache headers, to reduce redundant downloads.
-        expires = my_parsedate(pr["Date"]) + datetime.timedelta(hours=24)
-
-    if not expires:
-        return False
-
-    return (now < expires)
-
-def remember_headers(url, properties, headers, now):
-    properties.setdefault(url, {})
-    for h in ("Cache-Control", "ETag", "Expires", "Date", "Content-Length"):
-        if h in headers:
-            properties[url][h] = headers[h]
-    if "Date" not in headers:
-        properties[url]["Date"] = my_formatdate(now)
-
-
-def changed(url, clean_url, properties, now):
-    req = requests.head(url, allow_redirects=True)
-
-    if req.status_code != 200:
-        # Sometimes endpoints are misconfigured and will deny HEAD but
-        # allow GET so instead of failing here, we'll try GET If-None-Match
-        return True
-
-    etag = properties[url].get("ETag")
-
-    if url in properties:
-        del properties[url]
-    remember_headers(clean_url, properties, req.headers, now)
-
-    if "ETag" in req.headers and etag == req.headers["ETag"]:
-        # Didn't change
-        return False
-
-    return True
-
-def etag_quote(etag):
-    # if it already has leading and trailing quotes, do nothing
-    if etag[0] == '"' and etag[-1] == '"':
-        return etag
-    else:
-        # Add quotes.
-        return '"' + etag + '"'
-
-
-def http_to_keep(api, project_uuid, url, utcnow=datetime.datetime.utcnow, varying_url_params="", prefer_cached_downloads=False):
-    varying_params = [s.strip() for s in varying_url_params.split(",")]
-
-    parsed = urllib.parse.urlparse(url)
-    query = [q for q in urllib.parse.parse_qsl(parsed.query)
-             if q[0] not in varying_params]
-
-    clean_url = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params,
-                                         urllib.parse.urlencode(query, safe="/"),  parsed.fragment))
-
-    r1 = api.collections().list(filters=[["properties", "exists", url]]).execute()
-
-    if clean_url == url:
-        items = r1["items"]
-    else:
-        r2 = api.collections().list(filters=[["properties", "exists", clean_url]]).execute()
-        items = r1["items"] + r2["items"]
-
-    now = utcnow()
-
-    etags = {}
-
-    for item in items:
-        properties = item["properties"]
-
-        if clean_url in properties:
-            cache_url = clean_url
-        elif url in properties:
-            cache_url = url
-        else:
-            return False
-
-        if prefer_cached_downloads or fresh_cache(cache_url, properties, now):
-            # HTTP caching rules say we should use the cache
-            cr = arvados.collection.CollectionReader(item["portable_data_hash"], api_client=api)
-            return "keep:%s/%s" % (item["portable_data_hash"], list(cr.keys())[0])
-
-        if not changed(cache_url, clean_url, properties, now):
-            # ETag didn't change, same content, just update headers
-            api.collections().update(uuid=item["uuid"], body={"collection":{"properties": properties}}).execute()
-            cr = arvados.collection.CollectionReader(item["portable_data_hash"], api_client=api)
-            return "keep:%s/%s" % (item["portable_data_hash"], list(cr.keys())[0])
-
-        if "ETag" in properties[cache_url] and len(properties[cache_url]["ETag"]) > 2:
-            etags[properties[cache_url]["ETag"]] = item
-
-    logger.debug("Found ETags %s", etags)
-
-    properties = {}
-    headers = {}
-    if etags:
-        headers['If-None-Match'] = ', '.join([etag_quote(k) for k,v in etags.items()])
-    logger.debug("Sending GET request with headers %s", headers)
-    req = requests.get(url, stream=True, allow_redirects=True, headers=headers)
-
-    if req.status_code not in (200, 304):
-        raise Exception("Failed to download '%s' got status %s " % (url, req.status_code))
-
-    remember_headers(clean_url, properties, req.headers, now)
-
-    if req.status_code == 304 and "ETag" in req.headers and req.headers["ETag"] in etags:
-        item = etags[req.headers["ETag"]]
-        item["properties"].update(properties)
-        api.collections().update(uuid=item["uuid"], body={"collection":{"properties": item["properties"]}}).execute()
-        cr = arvados.collection.CollectionReader(item["portable_data_hash"], api_client=api)
-        return "keep:%s/%s" % (item["portable_data_hash"], list(cr.keys())[0])
-
-    if "Content-Length" in properties[clean_url]:
-        cl = int(properties[clean_url]["Content-Length"])
-        logger.info("Downloading %s (%s bytes)", url, cl)
-    else:
-        cl = None
-        logger.info("Downloading %s (unknown size)", url)
-
-    c = arvados.collection.Collection()
-
-    if req.headers.get("Content-Disposition"):
-        grp = re.search(r'filename=("((\"|[^"])+)"|([^][()<>@,;:\"/?={} ]+))', req.headers["Content-Disposition"])
-        if grp.group(2):
-            name = grp.group(2)
-        else:
-            name = grp.group(4)
-    else:
-        name = parsed.path.split("/")[-1]
-
-    count = 0
-    start = time.time()
-    checkpoint = start
-    with c.open(name, "wb") as f:
-        for chunk in req.iter_content(chunk_size=1024):
-            count += len(chunk)
-            f.write(chunk)
-            loopnow = time.time()
-            if (loopnow - checkpoint) > 20:
-                bps = count / (loopnow - start)
-                if cl is not None:
-                    logger.info("%2.1f%% complete, %3.2f MiB/s, %1.0f seconds left",
-                                ((count * 100) / cl),
-                                (bps // (1024*1024)),
-                                ((cl-count) // bps))
-                else:
-                    logger.info("%d downloaded, %3.2f MiB/s", count, (bps / (1024*1024)))
-                checkpoint = loopnow
-
-    logger.info("Download complete")
-
-    collectionname = "Downloaded from %s" % urllib.parse.quote(clean_url, safe='')
-
-    # max length - space to add a timestamp used by ensure_unique_name
-    max_name_len = 254 - 28
-
-    if len(collectionname) > max_name_len:
-        over = len(collectionname) - max_name_len
-        split = int(max_name_len/2)
-        collectionname = collectionname[0:split] + "…" + collectionname[split+over:]
-
-    c.save_new(name=collectionname, owner_uuid=project_uuid, ensure_unique_name=True)
-
-    api.collections().update(uuid=c.manifest_locator(), body={"collection":{"properties": properties}}).execute()
-
-    return "keep:%s/%s" % (c.portable_data_hash(), name)
index e2e287bf1dbd9cbcfbe63275ae40087393bb1d1f..448facf776823c68f5c706cc0ec1707460222cf7 100644 (file)
@@ -26,7 +26,7 @@ from cwltool.utils import adjustFileObjs, adjustDirObjs
 from cwltool.stdfsaccess import abspath
 from cwltool.workflow import WorkflowException
 
-from .http import http_to_keep
+from arvados.http_to_keep import http_to_keep
 
 logger = logging.getLogger('arvados.cwl-runner')
 
@@ -109,13 +109,14 @@ class ArvPathMapper(PathMapper):
                         # passthrough, we'll download it later.
                         self._pathmap[src] = MapperEnt(src, src, srcobj["class"], True)
                     else:
-                        keepref = http_to_keep(self.arvrunner.api, self.arvrunner.project_uuid, src,
-                                               varying_url_params=self.arvrunner.toplevel_runtimeContext.varying_url_params,
-                                               prefer_cached_downloads=self.arvrunner.toplevel_runtimeContext.prefer_cached_downloads)
+                        results = http_to_keep(self.arvrunner.api, self.arvrunner.project_uuid, src,
+                                                              varying_url_params=self.arvrunner.toplevel_runtimeContext.varying_url_params,
+                                                              prefer_cached_downloads=self.arvrunner.toplevel_runtimeContext.prefer_cached_downloads)
+                        keepref = "keep:%s/%s" % (results[0], results[1])
                         logger.info("%s is %s", src, keepref)
                         self._pathmap[src] = MapperEnt(keepref, keepref, srcobj["class"], True)
                 except Exception as e:
-                    logger.warning(str(e))
+                    logger.warning("Download error: %s", e)
             else:
                 self._pathmap[src] = MapperEnt(src, src, srcobj["class"], True)
 
@@ -147,7 +148,7 @@ class ArvPathMapper(PathMapper):
             for opt in self.optional_deps:
                 if obj["location"] == opt["location"]:
                     return
-            raise SourceLine(obj, "location", WorkflowException).makeError("Don't know what to do with '%s'" % obj["location"])
+            raise SourceLine(obj, "location", WorkflowException).makeError("Can't handle '%s'" % obj["location"])
 
     def needs_new_collection(self, srcobj, prefix=""):
         """Check if files need to be staged into a new collection.
index 54af2be5173eb0b3ea9b4a843d9344f6d129ece0..437aa39eb86dc7453fee161c53d85686fbe00f5c 100644 (file)
@@ -42,10 +42,7 @@ from cwltool.utils import (
     CWLOutputType,
 )
 
-if os.name == "posix" and sys.version_info[0] < 3:
-    import subprocess32 as subprocess
-else:
-    import subprocess
+import subprocess
 
 from schema_salad.sourceline import SourceLine, cmap
 
@@ -828,7 +825,8 @@ class Runner(Process):
                  priority=None, secret_store=None,
                  collection_cache_size=256,
                  collection_cache_is_default=True,
-                 git_info=None):
+                 git_info=None,
+                 reuse_runner=False):
 
         self.loadingContext = loadingContext.copy()
 
@@ -861,6 +859,7 @@ class Runner(Process):
         self.enable_dev = self.loadingContext.enable_dev
         self.git_info = git_info
         self.fast_parser = self.loadingContext.fast_parser
+        self.reuse_runner = reuse_runner
 
         self.submit_runner_cores = 1
         self.submit_runner_ram = 1024  # defaut 1 GiB
@@ -923,7 +922,8 @@ class Runner(Process):
                                                            api_client=self.arvrunner.api,
                                                            keep_client=self.arvrunner.keep_client,
                                                            num_retries=self.arvrunner.num_retries)
-                done.logtail(logc, logger.error, "%s (%s) error log:" % (self.arvrunner.label(self), record["uuid"]), maxlen=40)
+                done.logtail(logc, logger.error, "%s (%s) error log:" % (self.arvrunner.label(self), record["uuid"]), maxlen=40,
+                             include_crunchrun=(record.get("exit_code") is None or record.get("exit_code") > 127))
 
             self.final_output = record["output"]
             outc = arvados.collection.CollectionReader(self.final_output,
@@ -945,3 +945,42 @@ class Runner(Process):
             self.arvrunner.output_callback({}, "permanentFail")
         else:
             self.arvrunner.output_callback(outputs, processStatus)
+
+
+def print_keep_deps_visitor(api, runtimeContext, references, doc_loader, tool):
+    def collect_locators(obj):
+        loc = obj.get("location", "")
+
+        g = arvados.util.keepuri_pattern.match(loc)
+        if g:
+            references.add(g[1])
+
+        if obj.get("class") == "http://arvados.org/cwl#WorkflowRunnerResources" and "acrContainerImage" in obj:
+            references.add(obj["acrContainerImage"])
+
+        if obj.get("class") == "DockerRequirement":
+            references.add(arvados_cwl.arvdocker.arv_docker_get_image(api, obj, False, runtimeContext))
+
+    sc_result = scandeps(tool["id"], tool,
+                         set(),
+                         set(("location", "id")),
+                         None, urljoin=doc_loader.fetcher.urljoin,
+                         nestdirs=False)
+
+    visit_class(sc_result, ("File", "Directory"), collect_locators)
+    visit_class(tool, ("DockerRequirement", "http://arvados.org/cwl#WorkflowRunnerResources"), collect_locators)
+
+
+def print_keep_deps(arvRunner, runtimeContext, merged_map, tool):
+    references = set()
+
+    tool.visit(partial(print_keep_deps_visitor, arvRunner.api, runtimeContext, references, tool.doc_loader))
+
+    for mm in merged_map:
+        for k, v in merged_map[mm].resolved.items():
+            g = arvados.util.keepuri_pattern.match(v)
+            if g:
+                references.add(g[1])
+
+    json.dump(sorted(references), arvRunner.stdout)
+    print(file=arvRunner.stdout)
index a0c34ea52d2c1ca407f0c291f3a0a46e77eca9c6..299f854ec202020b08156c2929bd35bf9a32cfaf 100644 (file)
@@ -34,3 +34,18 @@ def get_current_container(api, num_retries=0, logger=None):
             raise e
 
     return current_container
+
+
+def common_prefix(firstfile, all_files):
+    common_parts = firstfile.split('/')
+    common_parts[-1] = ''
+    for f in all_files:
+        f_parts = f.split('/')
+        for index, (a, b) in enumerate(zip(common_parts, f_parts)):
+            if a != b:
+                common_parts = common_parts[:index + 1]
+                common_parts[-1] = ''
+                break
+        if not any(common_parts):
+            break
+    return '/'.join(common_parts)
index c3936617f09aa46e11a6822aa2cb868608d20c53..794b6afe4261cba9c6bfc4c5dd3fee9d6bb6c19b 100644 (file)
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+#
+# This file runs in one of three modes:
+#
+# 1. If the ARVADOS_BUILDING_VERSION environment variable is set, it writes
+#    _version.py and generates dependencies based on that value.
+# 2. If running from an arvados Git checkout, it writes _version.py
+#    and generates dependencies from Git.
+# 3. Otherwise, we expect this is source previously generated from Git, and
+#    it reads _version.py and generates dependencies from it.
 
-import subprocess
-import time
 import os
 import re
+import runpy
+import subprocess
 import sys
 
-SETUP_DIR = os.path.dirname(os.path.abspath(__file__))
-VERSION_PATHS = {
-        SETUP_DIR,
-        os.path.abspath(os.path.join(SETUP_DIR, "../python")),
-        os.path.abspath(os.path.join(SETUP_DIR, "../../build/version-at-commit.sh"))
-        }
+from pathlib import Path
+
+# These maps explain the relationships between different Python modules in
+# the arvados repository. We use these to help generate setup.py.
+PACKAGE_DEPENDENCY_MAP = {
+    'arvados-cwl-runner': ['arvados-python-client', 'crunchstat_summary'],
+    'arvados-user-activity': ['arvados-python-client'],
+    'arvados_fuse': ['arvados-python-client'],
+    'crunchstat_summary': ['arvados-python-client'],
+}
+PACKAGE_MODULE_MAP = {
+    'arvados-cwl-runner': 'arvados_cwl',
+    'arvados-docker-cleaner': 'arvados_docker',
+    'arvados-python-client': 'arvados',
+    'arvados-user-activity': 'arvados_user_activity',
+    'arvados_fuse': 'arvados_fuse',
+    'crunchstat_summary': 'crunchstat_summary',
+}
+PACKAGE_SRCPATH_MAP = {
+    'arvados-cwl-runner': Path('sdk', 'cwl'),
+    'arvados-docker-cleaner': Path('services', 'dockercleaner'),
+    'arvados-python-client': Path('sdk', 'python'),
+    'arvados-user-activity': Path('tools', 'user-activity'),
+    'arvados_fuse': Path('services', 'fuse'),
+    'crunchstat_summary': Path('tools', 'crunchstat-summary'),
+}
+
+ENV_VERSION = os.environ.get("ARVADOS_BUILDING_VERSION")
+SETUP_DIR = Path(__file__).absolute().parent
+try:
+    REPO_PATH = Path(subprocess.check_output(
+        ['git', '-C', str(SETUP_DIR), 'rev-parse', '--show-toplevel'],
+        stderr=subprocess.DEVNULL,
+        text=True,
+    ).rstrip('\n'))
+except (subprocess.CalledProcessError, OSError):
+    REPO_PATH = None
+else:
+    # Verify this is the arvados monorepo
+    if all((REPO_PATH / path).exists() for path in PACKAGE_SRCPATH_MAP.values()):
+        PACKAGE_NAME, = (
+            pkg_name for pkg_name, path in PACKAGE_SRCPATH_MAP.items()
+            if (REPO_PATH / path) == SETUP_DIR
+        )
+        MODULE_NAME = PACKAGE_MODULE_MAP[PACKAGE_NAME]
+        VERSION_SCRIPT_PATH = Path(REPO_PATH, 'build', 'version-at-commit.sh')
+    else:
+        REPO_PATH = None
+if REPO_PATH is None:
+    (PACKAGE_NAME, MODULE_NAME), = (
+        (pkg_name, mod_name)
+        for pkg_name, mod_name in PACKAGE_MODULE_MAP.items()
+        if (SETUP_DIR / mod_name).is_dir()
+    )
+
+def short_tests_only(arglist=sys.argv):
+    try:
+        arglist.remove('--short-tests-only')
+    except ValueError:
+        return False
+    else:
+        return True
+
+def git_log_output(path, *args):
+    return subprocess.check_output(
+        ['git', '-C', str(REPO_PATH),
+         'log', '--first-parent', '--max-count=1',
+         *args, str(path)],
+        text=True,
+    ).rstrip('\n')
 
 def choose_version_from():
-    ts = {}
-    for path in VERSION_PATHS:
-        ts[subprocess.check_output(
-            ['git', 'log', '--first-parent', '--max-count=1',
-             '--format=format:%ct', path]).strip()] = path
-
-    sorted_ts = sorted(ts.items())
-    getver = sorted_ts[-1][1]
-    print("Using "+getver+" for version number calculation of "+SETUP_DIR, file=sys.stderr)
+    ver_paths = [SETUP_DIR, VERSION_SCRIPT_PATH, *(
+        PACKAGE_SRCPATH_MAP[pkg]
+        for pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ())
+    )]
+    getver = max(ver_paths, key=lambda path: git_log_output(path, '--format=format:%ct'))
+    print(f"Using {getver} for version number calculation of {SETUP_DIR}", file=sys.stderr)
     return getver
 
 def git_version_at_commit():
     curdir = choose_version_from()
-    myhash = subprocess.check_output(['git', 'log', '-n1', '--first-parent',
-                                       '--format=%H', curdir]).strip()
-    myversion = subprocess.check_output([SETUP_DIR+'/../../build/version-at-commit.sh', myhash]).strip().decode()
-    return myversion
+    myhash = git_log_output(curdir, '--format=%H')
+    return subprocess.check_output(
+        [str(VERSION_SCRIPT_PATH), myhash],
+        text=True,
+    ).rstrip('\n')
 
 def save_version(setup_dir, module, v):
-    v = v.replace("~dev", ".dev").replace("~rc", "rc")
-    with open(os.path.join(setup_dir, module, "_version.py"), 'wt') as fp:
-        return fp.write("__version__ = '%s'\n" % v)
+    with Path(setup_dir, module, '_version.py').open('w') as fp:
+        print(f"__version__ = {v!r}", file=fp)
 
 def read_version(setup_dir, module):
-    with open(os.path.join(setup_dir, module, "_version.py"), 'rt') as fp:
-        return re.match("__version__ = '(.*)'$", fp.read()).groups()[0]
-
-def get_version(setup_dir, module):
-    env_version = os.environ.get("ARVADOS_BUILDING_VERSION")
+    file_vars = runpy.run_path(Path(setup_dir, module, '_version.py'))
+    return file_vars['__version__']
 
-    if env_version:
-        save_version(setup_dir, module, env_version)
+def get_version(setup_dir=SETUP_DIR, module=MODULE_NAME):
+    if ENV_VERSION:
+        version = ENV_VERSION
+    elif REPO_PATH is None:
+        return read_version(setup_dir, module)
     else:
-        try:
-            save_version(setup_dir, module, git_version_at_commit())
-        except (subprocess.CalledProcessError, OSError) as err:
-            print("ERROR: {0}".format(err), file=sys.stderr)
-            pass
+        version = git_version_at_commit()
+    version = version.replace("~dev", ".dev").replace("~rc", "rc")
+    save_version(setup_dir, module, version)
+    return version
 
-    return read_version(setup_dir, module)
+def iter_dependencies(version=None):
+    if version is None:
+        version = get_version()
+    # A packaged development release should be installed with other
+    # development packages built from the same source, but those
+    # dependencies may have earlier "dev" versions (read: less recent
+    # Git commit timestamps). This compatible version dependency
+    # expresses that as closely as possible. Allowing versions
+    # compatible with .dev0 allows any development release.
+    # Regular expression borrowed partially from
+    # <https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers-regex>
+    dep_ver, match_count = re.subn(r'\.dev(0|[1-9][0-9]*)$', '.dev0', version, 1)
+    dep_op = '~=' if match_count else '=='
+    for dep_pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ()):
+        yield f'{dep_pkg}{dep_op}{dep_ver}'
 
 # Called from calculate_python_sdk_cwl_package_versions() in run-library.sh
 if __name__ == '__main__':
-    print(get_version(SETUP_DIR, "arvados_cwl"))
+    print(get_version())
index 9af61a3d951c8b00c31de652550267a280def52b..551bd964b1dd152b1c26f073a97c42eaf50d614c 100644 (file)
@@ -9,16 +9,9 @@ import sys
 
 from setuptools import setup, find_packages
 
-SETUP_DIR = os.path.dirname(__file__) or '.'
-README = os.path.join(SETUP_DIR, 'README.rst')
-
 import arvados_version
-version = arvados_version.get_version(SETUP_DIR, "arvados_cwl")
-if os.environ.get('ARVADOS_BUILDING_VERSION', False):
-    pysdk_dep = "=={}".format(version)
-else:
-    # On dev releases, arvados-python-client may have a different timestamp
-    pysdk_dep = "<={}".format(version)
+version = arvados_version.get_version()
+README = os.path.join(arvados_version.SETUP_DIR, 'README.rst')
 
 setup(name='arvados-cwl-runner',
       version=version,
@@ -36,26 +29,25 @@ setup(name='arvados-cwl-runner',
       # file to determine what version of cwltool and schema-salad to
       # build.
       install_requires=[
-          'cwltool==3.1.20230127121939',
-          'schema-salad==8.4.20230127112827',
-          'arvados-python-client{}'.format(pysdk_dep),
+          *arvados_version.iter_dependencies(version),
+          'cwltool==3.1.20230601100705',
+          'schema-salad==8.4.20230601112322',
           'ciso8601 >= 2.0.0',
           'networkx < 2.6',
           'msgpack==1.0.3',
           'importlib-metadata<5',
-          'setuptools>=40.3.0'
+          'setuptools>=40.3.0',
       ],
       data_files=[
           ('share/doc/arvados-cwl-runner', ['LICENSE-2.0.txt', 'README.rst']),
       ],
-      python_requires=">=3.5, <4",
+      python_requires="~=3.8",
       classifiers=[
           'Programming Language :: Python :: 3',
       ],
       test_suite='tests',
       tests_require=[
           'mock>=1.0,<4',
-          'subprocess32>=3.5.1',
       ],
       zip_safe=True,
 )
index 6823a8e2a5158dcb62d4614ff888eca81816a97b..51d64b3f84c8dc4c36e2dd637be9b622a8afaa3c 100755 (executable)
@@ -5,8 +5,10 @@
 
 set -x
 
+cwldir=$(readlink -f $(dirname $0))
+
 if ! which arvbox >/dev/null ; then
-    export PATH=$PATH:$(readlink -f $(dirname $0)/../../tools/arvbox/bin)
+    export PATH=$PATH:$cwldir/../../tools/arvbox/bin
 fi
 
 reset_container=1
@@ -14,9 +16,9 @@ leave_running=0
 config=dev
 devcwl=0
 tag="latest"
-pythoncmd=python3
 suite=conformance
 runapi=containers
+reinstall=0
 
 while test -n "$1" ; do
     arg="$1"
@@ -45,8 +47,12 @@ while test -n "$1" ; do
             devcwl=1
             shift
             ;;
+        --reinstall)
+            reinstall=1
+            shift
+            ;;
         --pythoncmd)
-            pythoncmd=$2
+            echo "warning: --pythoncmd option is no longer supported; ignored" >&2
             shift ; shift
             ;;
         --suite)
@@ -58,7 +64,7 @@ while test -n "$1" ; do
             shift ; shift
             ;;
         -h|--help)
-            echo "$0 [--no-reset-container] [--leave-running] [--config dev|localdemo] [--tag docker_tag] [--build] [--pythoncmd python(2|3)] [--suite (integration|conformance-v1.0|conformance-*)]"
+            echo "$0 [--no-reset-container] [--leave-running] [--config dev|localdemo] [--tag docker_tag] [--build] [--suite (integration|conformance-v1.0|conformance-*)]"
             exit
             ;;
         *)
@@ -87,28 +93,24 @@ arvbox start $config $tag
 # of using the one inside the container, so we can make changes to the
 # integration tests without necessarily having to rebuilding the
 # container image.
-docker cp -L $(readlink -f $(dirname $0)/tests) $ARVBOX_CONTAINER:/usr/src/arvados/sdk/cwl
+docker cp -L $cwldir/tests $ARVBOX_CONTAINER:/usr/src/arvados/sdk/cwl
 
 arvbox pipe <<EOF
 set -eu -o pipefail
 
 . /usr/local/lib/arvbox/common.sh
 
-export PYCMD=$pythoncmd
-
-if test $config = dev ; then
-  cd /usr/src/arvados/sdk/cwl
-  \$PYCMD setup.py sdist
-  pip_install \$(ls -r dist/arvados-cwl-runner-*.tar.gz | head -n1)
+if test $config = dev -o $reinstall = 1; then
+  pip_install_sdist sdk/python sdk/cwl
 fi
 
 set -x
 
-if [ "\$PYCMD" = "python3" ]; then
-    pip3 install cwltest
-else
-    pip install cwltest
-fi
+# 2.3.20230527113600 release of cwltest confirms that files exist on disk, since
+# our files are in Keep, all the tests fail.
+# We should add [optional] Arvados support to cwltest so it can access
+# Keep but for the time being just install the last working version.
+/opt/arvados-py/bin/pip install 'cwltest<2.3.20230527113600'
 
 mkdir -p /tmp/cwltest
 cd /tmp/cwltest
@@ -135,7 +137,7 @@ if [[ "$suite" = "conformance-v1.1" ]] ; then
 fi
 
 if [[ "$suite" = "conformance-v1.2" ]] ; then
-   git checkout 1.2.1_proposed
+   git checkout v1.2.1
 fi
 
 #if [[ "$suite" != "integration" ]] ; then
@@ -170,24 +172,23 @@ cwltest --version
 # Skip test 199 in the v1.1 suite because it has different output
 # depending on whether there is a pty associated with stdout (fixed in
 # the v1.2 suite)
-#
-# Skip test 307 in the v1.2 suite because the test relied on
-# secondary file behavior of cwltool that wasn't actually correct to specification
 
 if [[ "$suite" = "integration" ]] ; then
    cd /usr/src/arvados/sdk/cwl/tests
    exec ./arvados-tests.sh $@
 elif [[ "$suite" = "conformance-v1.2" ]] ; then
-   exec cwltest --tool arvados-cwl-runner --test conformance_tests.yaml -Sdocker_entrypoint,timelimit_invalid_wf -N307 $@ -- \$EXTRA
+   exec cwltest --tool arvados-cwl-runner --test conformance_tests.yaml -Sdocker_entrypoint --badgedir /tmp/badges $@ -- \$EXTRA
 elif [[ "$suite" = "conformance-v1.1" ]] ; then
-   exec cwltest --tool arvados-cwl-runner --test conformance_tests.yaml -Sdocker_entrypoint,timelimit_invalid_wf -N199 $@ -- \$EXTRA
+   exec cwltest --tool arvados-cwl-runner --test conformance_tests.yaml -Sdocker_entrypoint,timelimit_invalid_wf -N199 --badgedir /tmp/badges $@ -- \$EXTRA
 elif [[ "$suite" = "conformance-v1.0" ]] ; then
-   exec cwltest --tool arvados-cwl-runner --test v1.0/conformance_test_v1.0.yaml -Sdocker_entrypoint $@ -- \$EXTRA
+   exec cwltest --tool arvados-cwl-runner --test v1.0/conformance_test_v1.0.yaml -Sdocker_entrypoint --badgedir /tmp/badges $@ -- \$EXTRA
 fi
 EOF
 
 CODE=$?
 
+docker cp -L $ARVBOX_CONTAINER:/tmp/badges $cwldir/badges
+
 if test $leave_running = 0 ; then
     arvbox stop
 fi
index 9d6646e875e9c9dc3717bb6d666dc9c05ddb99bd..0cf43405ec0fd09523110be2cee861b26df28ec8 100755 (executable)
@@ -24,7 +24,7 @@ if ! arv-get 20850f01122e860fb878758ac1320877+71 > /dev/null ; then
 fi
 
 # Use the python executor associated with the installed OS package, if present.
-python=$(((ls /usr/share/python3*/dist/python3-arvados-cwl-runner/bin/python || echo python3) | head -n1) 2>/dev/null)
+python="$(PATH="/usr/lib/python3-arvados-cwl-runner/bin:/opt/arvados-py/bin:$PATH" command -v python3)"
 
 # Test for #18888
 # This is a standalone test because the bug was observed with this
index a93c64a224c1e83b3d126b720fadeba6e8f59039..cb4a151f0eda9792855ffadc5c4956f8afc7b38e 100644 (file)
   tool: oom/19975-oom.cwl
   doc: "Test feature 19975 - retry on exit 137"
 
+- job: oom/fakeoom.yml
+  output: {}
+  tool: oom/19975-oom-mispelled.cwl
+  doc: "Test feature 19975 - retry on exit 137, old misspelled version"
+
 - job: oom/fakeoom2.yml
   output: {}
   tool: oom/19975-oom.cwl
   output: {}
   tool: oom/19975-oom3.cwl
   doc: "Test feature 19975 - retry on custom error"
+
+- job: null
+  output:
+    out: out
+  tool: wf/runseparate-wf.cwl
+  doc: "test arv:SeparateRunner"
diff --git a/sdk/cwl/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-arv-mount.txt b/sdk/cwl/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-arv-mount.txt
new file mode 100644 (file)
index 0000000..e8e79cc
--- /dev/null
@@ -0,0 +1,10 @@
+2018-10-03T18:21:16.944508412Z crunchstat: keepcalls 0 put 0 get -- interval 10.0000 seconds 0 put 0 get
+2018-10-03T18:21:16.944508412Z crunchstat: net:keep0 0 tx 0 rx -- interval 10.0000 seconds 0 tx 0 rx
+2018-10-03T18:21:16.944508412Z crunchstat: keepcache 0 hit 0 miss -- interval 10.0000 seconds 0 hit 0 miss
+2018-10-03T18:21:16.944508412Z crunchstat: fuseops 0 write 0 read -- interval 10.0000 seconds 0 write 0 read
+2018-10-03T18:21:16.944508412Z crunchstat: blkio:0:0 0 write 0 read -- interval 10.0000 seconds 0 write 0 read
+2018-10-03T18:21:26.954764471Z crunchstat: keepcalls 0 put 0 get -- interval 10.0000 seconds 0 put 0 get
+2018-10-03T18:21:26.954764471Z crunchstat: net:keep0 0 tx 0 rx -- interval 10.0000 seconds 0 tx 0 rx
+2018-10-03T18:21:26.954764471Z crunchstat: keepcache 0 hit 0 miss -- interval 10.0000 seconds 0 hit 0 miss
+2018-10-03T18:21:26.954764471Z crunchstat: fuseops 0 write 0 read -- interval 10.0000 seconds 0 write 0 read
+2018-10-03T18:21:26.954764471Z crunchstat: blkio:0:0 0 write 0 read -- interval 10.0000 seconds 0 write 0 read
diff --git a/sdk/cwl/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-crunchstat.txt b/sdk/cwl/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-crunchstat.txt
new file mode 100644 (file)
index 0000000..6580843
--- /dev/null
@@ -0,0 +1,17 @@
+2018-10-03T18:21:07.823780191Z notice: reading stats from /sys/fs/cgroup/cpuacct//slurm_compute0/uid_0/job_6478/step_batch/c1df52c9940aae3f0fd586cacd7c0d7cb81b33aec973a67c9a7519bfe38ea914/cgroup.procs
+2018-10-03T18:21:07.823841282Z notice: monitoring temp dir /tmp/crunch-run.9tee4-dz642-lymtndkpy39eibk.438029160
+2018-10-03T18:21:07.823917514Z notice: reading stats from /sys/fs/cgroup/memory//slurm_compute0/uid_0/job_6478/step_batch/c1df52c9940aae3f0fd586cacd7c0d7cb81b33aec973a67c9a7519bfe38ea914/memory.stat
+2018-10-03T18:21:07.824136521Z mem 0 cache 0 swap 0 pgmajfault 1187840 rss
+2018-10-03T18:21:07.824187182Z notice: reading stats from /sys/fs/cgroup/cpuacct//slurm_compute0/uid_0/job_6478/step_batch/c1df52c9940aae3f0fd586cacd7c0d7cb81b33aec973a67c9a7519bfe38ea914/cpuacct.stat
+2018-10-03T18:21:07.824253726Z notice: reading stats from /sys/fs/cgroup/cpuset//slurm_compute0/uid_0/job_6478/step_batch/c1df52c9940aae3f0fd586cacd7c0d7cb81b33aec973a67c9a7519bfe38ea914/cpuset.cpus
+2018-10-03T18:21:07.824296720Z cpu 0.0000 user 0.0100 sys 20.00 cpus
+2018-10-03T18:21:07.824361476Z notice: reading stats from /sys/fs/cgroup/blkio//slurm_compute0/uid_0/job_6478/step_batch/c1df52c9940aae3f0fd586cacd7c0d7cb81b33aec973a67c9a7519bfe38ea914/blkio.io_service_bytes
+2018-10-03T18:21:07.824551021Z statfs 397741461504 available 4869779456 used 402611240960 total
+2018-10-03T18:21:17.824503045Z mem 172032 cache 0 swap 0 pgmajfault 68247552 rss
+2018-10-03T18:21:17.824702097Z cpu 2.0000 user 0.3800 sys 20.00 cpus -- interval 10.0004 seconds 2.0000 user 0.3700 sys
+2018-10-03T18:21:17.824984621Z net:eth0 51930 tx 844687 rx
+2018-10-03T18:21:17.825021992Z statfs 397740937216 available 4870303744 used 402611240960 total -- interval 10.0005 seconds 524288 used
+2018-10-03T18:21:27.824480114Z mem 172032 cache 0 swap 0 pgmajfault 69525504 rss
+2018-10-03T18:21:27.826909728Z cpu 2.0600 user 0.3900 sys 20.00 cpus -- interval 10.0022 seconds 0.0600 user 0.0100 sys
+2018-10-03T18:21:27.827141860Z net:eth0 55888 tx 859480 rx -- interval 10.0022 seconds 3958 tx 14793 rx
+2018-10-03T18:21:27.827177703Z statfs 397744787456 available 4866453504 used 402611240960 total -- interval 10.0022 seconds -3850240 used
diff --git a/sdk/cwl/tests/oom/19975-oom-mispelled.cwl b/sdk/cwl/tests/oom/19975-oom-mispelled.cwl
new file mode 100644 (file)
index 0000000..bbd26b9
--- /dev/null
@@ -0,0 +1,19 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+cwlVersion: v1.2
+class: CommandLineTool
+$namespaces:
+  arv: "http://arvados.org/cwl#"
+hints:
+  arv:OutOfMemoryRetry:
+    # legacy misspelled name, should behave exactly the same
+    memoryRetryMultipler: 2
+  ResourceRequirement:
+    ramMin: 256
+  arv:APIRequirement: {}
+inputs:
+  fakeoom: File
+outputs: []
+arguments: [python3, $(inputs.fakeoom)]
index ec806487161539522193c04e01eb59f73ab5bfc1..bf3e5cc389172b07bca62658d95943cb55cc755e 100644 (file)
@@ -8,7 +8,7 @@ $namespaces:
   arv: "http://arvados.org/cwl#"
 hints:
   arv:OutOfMemoryRetry:
-    memoryRetryMultipler: 2
+    memoryRetryMultiplier: 2
   ResourceRequirement:
     ramMin: 256
   arv:APIRequirement: {}
index af3271b847cec74c095e764608d1aa3c6a96de07..bbca110b6f59cfc0ecc21f77d5e6b92577a82855 100644 (file)
@@ -8,7 +8,7 @@ $namespaces:
   arv: "http://arvados.org/cwl#"
 hints:
   arv:OutOfMemoryRetry:
-    memoryRetryMultipler: 2
+    memoryRetryMultiplier: 2
     memoryErrorRegex: Whoops
   ResourceRequirement:
     ramMin: 256
index a2f404d7ebe9b24b3d726d6057be14848b088cdd..b95b8eb67bbc4d83b357fcb209bccaf6ebf7dca4 100644 (file)
@@ -23,6 +23,7 @@ import cwltool.load_tool
 from cwltool.update import INTERNAL_VERSION
 from schema_salad.ref_resolver import Loader
 from schema_salad.sourceline import cmap
+import io
 
 from .matcher import JsonDiffMatcher, StripYAMLComments
 from .mock_discovery import get_rootDesc
@@ -85,7 +86,8 @@ class TestContainer(unittest.TestCase):
              "construct_tool_object": runner.arv_make_tool,
              "fetcher_constructor": functools.partial(arvados_cwl.CollectionFetcher, api_client=runner.api, fs_access=fs_access),
              "loader": Loader({}),
-             "metadata": cmap({"cwlVersion": INTERNAL_VERSION, "http://commonwl.org/cwltool#original_cwlVersion": "v1.0"})
+             "metadata": cmap({"cwlVersion": INTERNAL_VERSION, "http://commonwl.org/cwltool#original_cwlVersion": "v1.0"}),
+             "default_docker_image": "arvados/jobs:"+arvados_cwl.__version__
              })
         runtimeContext = arvados_cwl.context.ArvRuntimeContext(
             {"work_api": "containers",
@@ -517,11 +519,47 @@ class TestContainer(unittest.TestCase):
         runner.intermediate_output_ttl = 0
         runner.secret_store = cwltool.secrets.SecretStore()
 
+        runner.api.container_requests().get().execute.return_value = {"container_uuid":"zzzzz-xvhdp-zzzzzzzzzzzzzzz"}
+
         runner.api.containers().get().execute.return_value = {"state":"Complete",
                                                               "output": "abc+123",
                                                               "exit_code": 0}
 
-        col().open.return_value = []
+        # Need to noop-out the close method otherwise it gets
+        # discarded when closed and we can't call getvalue() to check
+        # it.
+        class NoopCloseStringIO(io.StringIO):
+            def close(self):
+                pass
+
+        usage_report = NoopCloseStringIO()
+        def colreader_action(name, mode):
+            nonlocal usage_report
+            if name == "node.json":
+                return io.StringIO("""{
+    "ProviderType": "c5.large",
+    "VCPUs": 2,
+    "RAM": 4294967296,
+    "IncludedScratch": 8000000000000,
+    "AddedScratch": 0,
+    "Price": 0.085,
+    "Preemptible": false,
+    "CUDA": {
+        "DriverVersion": "",
+        "HardwareCapability": "",
+        "DeviceCount": 0
+    }
+}""")
+            if name == 'crunchstat.txt':
+                return open("tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-arv-mount.txt", "rt")
+            if name == 'arv-mount.txt':
+                return open("tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-crunchstat.txt", "rt")
+            if name == 'usage_report.html':
+                return usage_report
+            return None
+
+        col().open.side_effect = colreader_action
+        col().__iter__.return_value = ['node.json', 'crunchstat.txt', 'arv-mount.txt']
 
         loadingContext, runtimeContext = self.helper(runner)
 
@@ -549,12 +587,16 @@ class TestContainer(unittest.TestCase):
             "uuid": "zzzzz-xvhdp-zzzzzzzzzzzzzzz",
             "container_uuid": "zzzzz-8i9sb-zzzzzzzzzzzzzzz",
             "modified_at": "2017-05-26T12:01:22Z",
-            "properties": {}
+            "properties": {},
+            "name": "testjob"
         })
 
         self.assertFalse(api.collections().create.called)
         self.assertFalse(runner.runtime_status_error.called)
 
+        # Assert that something was written to the usage report
+        self.assertTrue(len(usage_report.getvalue()) > 0)
+
         arvjob.collect_outputs.assert_called_with("keep:abc+123", 0)
         arvjob.output_callback.assert_called_with({"out": "stuff"}, "success")
         runner.add_intermediate_output.assert_called_with("zzzzz-4zz18-zzzzzzzzzzzzzz2")
@@ -650,11 +692,14 @@ class TestContainer(unittest.TestCase):
             "properties": {}
         })
 
-        rts_mock.assert_called_with(
-            'error',
-            'arvados.cwl-runner: [container testjob] (zzzzz-xvhdp-zzzzzzzzzzzzzzz) error log:',
-            '  ** log is empty **'
-        )
+        rts_mock.assert_has_calls([
+            mock.call('error',
+                      'arvados.cwl-runner: [container testjob] (zzzzz-xvhdp-zzzzzzzzzzzzzzz) error log:',
+                      '  ** log is empty **'
+                      ),
+            mock.call('warning',
+                      'arvados.cwl-runner: [container testjob] unable to generate resource usage report'
+        )])
         arvjob.output_callback.assert_called_with({"out": "stuff"}, "permanentFail")
 
     # The test passes no builder.resources
@@ -1463,7 +1508,8 @@ class TestWorkflow(unittest.TestCase):
              "make_fs_access": make_fs_access,
              "loader": document_loader,
              "metadata": {"cwlVersion": INTERNAL_VERSION, "http://commonwl.org/cwltool#original_cwlVersion": "v1.0"},
-             "construct_tool_object": runner.arv_make_tool})
+             "construct_tool_object": runner.arv_make_tool,
+             "default_docker_image": "arvados/jobs:"+arvados_cwl.__version__})
         runtimeContext = arvados_cwl.context.ArvRuntimeContext(
             {"work_api": "containers",
              "basedir": "",
diff --git a/sdk/cwl/tests/test_http.py b/sdk/cwl/tests/test_http.py
deleted file mode 100644 (file)
index 5598b1f..0000000
+++ /dev/null
@@ -1,442 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: Apache-2.0
-
-from future import standard_library
-standard_library.install_aliases()
-
-import copy
-import io
-import functools
-import hashlib
-import json
-import logging
-import mock
-import sys
-import unittest
-import datetime
-
-import arvados
-import arvados.collection
-import arvados_cwl
-import arvados_cwl.runner
-import arvados.keep
-
-from .matcher import JsonDiffMatcher, StripYAMLComments
-from .mock_discovery import get_rootDesc
-
-import arvados_cwl.http
-
-import ruamel.yaml as yaml
-
-
-class TestHttpToKeep(unittest.TestCase):
-
-    @mock.patch("requests.get")
-    @mock.patch("arvados.collection.Collection")
-    def test_http_get(self, collectionmock, getmock):
-        api = mock.MagicMock()
-
-        api.collections().list().execute.return_value = {
-            "items": []
-        }
-
-        cm = mock.MagicMock()
-        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
-        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
-        collectionmock.return_value = cm
-
-        req = mock.MagicMock()
-        req.status_code = 200
-        req.headers = {}
-        req.iter_content.return_value = ["abc"]
-        getmock.return_value = req
-
-        utcnow = mock.MagicMock()
-        utcnow.return_value = datetime.datetime(2018, 5, 15)
-
-        r = arvados_cwl.http.http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
-        self.assertEqual(r, "keep:99999999999999999999999999999998+99/file1.txt")
-
-        getmock.assert_called_with("http://example.com/file1.txt", stream=True, allow_redirects=True, headers={})
-
-        cm.open.assert_called_with("file1.txt", "wb")
-        cm.save_new.assert_called_with(name="Downloaded from http%3A%2F%2Fexample.com%2Ffile1.txt",
-                                       owner_uuid=None, ensure_unique_name=True)
-
-        api.collections().update.assert_has_calls([
-            mock.call(uuid=cm.manifest_locator(),
-                      body={"collection":{"properties": {'http://example.com/file1.txt': {'Date': 'Tue, 15 May 2018 00:00:00 GMT'}}}})
-        ])
-
-
-    @mock.patch("requests.get")
-    @mock.patch("arvados.collection.CollectionReader")
-    def test_http_expires(self, collectionmock, getmock):
-        api = mock.MagicMock()
-
-        api.collections().list().execute.return_value = {
-            "items": [{
-                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
-                "portable_data_hash": "99999999999999999999999999999998+99",
-                "properties": {
-                    'http://example.com/file1.txt': {
-                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
-                        'Expires': 'Tue, 17 May 2018 00:00:00 GMT'
-                    }
-                }
-            }]
-        }
-
-        cm = mock.MagicMock()
-        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
-        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
-        cm.keys.return_value = ["file1.txt"]
-        collectionmock.return_value = cm
-
-        req = mock.MagicMock()
-        req.status_code = 200
-        req.headers = {}
-        req.iter_content.return_value = ["abc"]
-        getmock.return_value = req
-
-        utcnow = mock.MagicMock()
-        utcnow.return_value = datetime.datetime(2018, 5, 16)
-
-        r = arvados_cwl.http.http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
-        self.assertEqual(r, "keep:99999999999999999999999999999998+99/file1.txt")
-
-        getmock.assert_not_called()
-
-
-    @mock.patch("requests.get")
-    @mock.patch("arvados.collection.CollectionReader")
-    def test_http_cache_control(self, collectionmock, getmock):
-        api = mock.MagicMock()
-
-        api.collections().list().execute.return_value = {
-            "items": [{
-                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
-                "portable_data_hash": "99999999999999999999999999999998+99",
-                "properties": {
-                    'http://example.com/file1.txt': {
-                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
-                        'Cache-Control': 'max-age=172800'
-                    }
-                }
-            }]
-        }
-
-        cm = mock.MagicMock()
-        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
-        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
-        cm.keys.return_value = ["file1.txt"]
-        collectionmock.return_value = cm
-
-        req = mock.MagicMock()
-        req.status_code = 200
-        req.headers = {}
-        req.iter_content.return_value = ["abc"]
-        getmock.return_value = req
-
-        utcnow = mock.MagicMock()
-        utcnow.return_value = datetime.datetime(2018, 5, 16)
-
-        r = arvados_cwl.http.http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
-        self.assertEqual(r, "keep:99999999999999999999999999999998+99/file1.txt")
-
-        getmock.assert_not_called()
-
-
-    @mock.patch("requests.get")
-    @mock.patch("requests.head")
-    @mock.patch("arvados.collection.Collection")
-    def test_http_expired(self, collectionmock, headmock, getmock):
-        api = mock.MagicMock()
-
-        api.collections().list().execute.return_value = {
-            "items": [{
-                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
-                "portable_data_hash": "99999999999999999999999999999998+99",
-                "properties": {
-                    'http://example.com/file1.txt': {
-                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
-                        'Expires': 'Tue, 16 May 2018 00:00:00 GMT'
-                    }
-                }
-            }]
-        }
-
-        cm = mock.MagicMock()
-        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz4"
-        cm.portable_data_hash.return_value = "99999999999999999999999999999997+99"
-        cm.keys.return_value = ["file1.txt"]
-        collectionmock.return_value = cm
-
-        req = mock.MagicMock()
-        req.status_code = 200
-        req.headers = {'Date': 'Tue, 17 May 2018 00:00:00 GMT'}
-        req.iter_content.return_value = ["def"]
-        getmock.return_value = req
-        headmock.return_value = req
-
-        utcnow = mock.MagicMock()
-        utcnow.return_value = datetime.datetime(2018, 5, 17)
-
-        r = arvados_cwl.http.http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
-        self.assertEqual(r, "keep:99999999999999999999999999999997+99/file1.txt")
-
-        getmock.assert_called_with("http://example.com/file1.txt", stream=True, allow_redirects=True, headers={})
-
-        cm.open.assert_called_with("file1.txt", "wb")
-        cm.save_new.assert_called_with(name="Downloaded from http%3A%2F%2Fexample.com%2Ffile1.txt",
-                                       owner_uuid=None, ensure_unique_name=True)
-
-        api.collections().update.assert_has_calls([
-            mock.call(uuid=cm.manifest_locator(),
-                      body={"collection":{"properties": {'http://example.com/file1.txt': {'Date': 'Tue, 17 May 2018 00:00:00 GMT'}}}})
-        ])
-
-
-    @mock.patch("requests.get")
-    @mock.patch("requests.head")
-    @mock.patch("arvados.collection.CollectionReader")
-    def test_http_etag(self, collectionmock, headmock, getmock):
-        api = mock.MagicMock()
-
-        api.collections().list().execute.return_value = {
-            "items": [{
-                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
-                "portable_data_hash": "99999999999999999999999999999998+99",
-                "properties": {
-                    'http://example.com/file1.txt': {
-                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
-                        'Expires': 'Tue, 16 May 2018 00:00:00 GMT',
-                        'ETag': '"123456"'
-                    }
-                }
-            }]
-        }
-
-        cm = mock.MagicMock()
-        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
-        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
-        cm.keys.return_value = ["file1.txt"]
-        collectionmock.return_value = cm
-
-        req = mock.MagicMock()
-        req.status_code = 200
-        req.headers = {
-            'Date': 'Tue, 17 May 2018 00:00:00 GMT',
-            'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
-            'ETag': '"123456"'
-        }
-        headmock.return_value = req
-
-        utcnow = mock.MagicMock()
-        utcnow.return_value = datetime.datetime(2018, 5, 17)
-
-        r = arvados_cwl.http.http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
-        self.assertEqual(r, "keep:99999999999999999999999999999998+99/file1.txt")
-
-        getmock.assert_not_called()
-        cm.open.assert_not_called()
-
-        api.collections().update.assert_has_calls([
-            mock.call(uuid=cm.manifest_locator(),
-                      body={"collection":{"properties": {'http://example.com/file1.txt': {
-                          'Date': 'Tue, 17 May 2018 00:00:00 GMT',
-                          'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
-                          'ETag': '"123456"'
-                      }}}})
-                      ])
-
-    @mock.patch("requests.get")
-    @mock.patch("arvados.collection.Collection")
-    def test_http_content_disp(self, collectionmock, getmock):
-        api = mock.MagicMock()
-
-        api.collections().list().execute.return_value = {
-            "items": []
-        }
-
-        cm = mock.MagicMock()
-        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
-        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
-        collectionmock.return_value = cm
-
-        req = mock.MagicMock()
-        req.status_code = 200
-        req.headers = {"Content-Disposition": "attachment; filename=file1.txt"}
-        req.iter_content.return_value = ["abc"]
-        getmock.return_value = req
-
-        utcnow = mock.MagicMock()
-        utcnow.return_value = datetime.datetime(2018, 5, 15)
-
-        r = arvados_cwl.http.http_to_keep(api, None, "http://example.com/download?fn=/file1.txt", utcnow=utcnow)
-        self.assertEqual(r, "keep:99999999999999999999999999999998+99/file1.txt")
-
-        getmock.assert_called_with("http://example.com/download?fn=/file1.txt", stream=True, allow_redirects=True, headers={})
-
-        cm.open.assert_called_with("file1.txt", "wb")
-        cm.save_new.assert_called_with(name="Downloaded from http%3A%2F%2Fexample.com%2Fdownload%3Ffn%3D%2Ffile1.txt",
-                                       owner_uuid=None, ensure_unique_name=True)
-
-        api.collections().update.assert_has_calls([
-            mock.call(uuid=cm.manifest_locator(),
-                      body={"collection":{"properties": {"http://example.com/download?fn=/file1.txt": {'Date': 'Tue, 15 May 2018 00:00:00 GMT'}}}})
-        ])
-
-    @mock.patch("requests.get")
-    @mock.patch("requests.head")
-    @mock.patch("arvados.collection.CollectionReader")
-    def test_http_etag_if_none_match(self, collectionmock, headmock, getmock):
-        api = mock.MagicMock()
-
-        api.collections().list().execute.return_value = {
-            "items": [{
-                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
-                "portable_data_hash": "99999999999999999999999999999998+99",
-                "properties": {
-                    'http://example.com/file1.txt': {
-                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
-                        'Expires': 'Tue, 16 May 2018 00:00:00 GMT',
-                        'ETag': '"123456"'
-                    }
-                }
-            }]
-        }
-
-        cm = mock.MagicMock()
-        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
-        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
-        cm.keys.return_value = ["file1.txt"]
-        collectionmock.return_value = cm
-
-        # Head request fails, will try a conditional GET instead
-        req = mock.MagicMock()
-        req.status_code = 403
-        req.headers = {
-        }
-        headmock.return_value = req
-
-        utcnow = mock.MagicMock()
-        utcnow.return_value = datetime.datetime(2018, 5, 17)
-
-        req = mock.MagicMock()
-        req.status_code = 304
-        req.headers = {
-            'Date': 'Tue, 17 May 2018 00:00:00 GMT',
-            'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
-            'ETag': '"123456"'
-        }
-        getmock.return_value = req
-
-        r = arvados_cwl.http.http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
-        self.assertEqual(r, "keep:99999999999999999999999999999998+99/file1.txt")
-
-        getmock.assert_called_with("http://example.com/file1.txt", stream=True, allow_redirects=True, headers={"If-None-Match": '"123456"'})
-        cm.open.assert_not_called()
-
-        api.collections().update.assert_has_calls([
-            mock.call(uuid=cm.manifest_locator(),
-                      body={"collection":{"properties": {'http://example.com/file1.txt': {
-                          'Date': 'Tue, 17 May 2018 00:00:00 GMT',
-                          'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
-                          'ETag': '"123456"'
-                      }}}})
-                      ])
-
-
-    @mock.patch("requests.get")
-    @mock.patch("requests.head")
-    @mock.patch("arvados.collection.CollectionReader")
-    def test_http_prefer_cached_downloads(self, collectionmock, headmock, getmock):
-        api = mock.MagicMock()
-
-        api.collections().list().execute.return_value = {
-            "items": [{
-                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
-                "portable_data_hash": "99999999999999999999999999999998+99",
-                "properties": {
-                    'http://example.com/file1.txt': {
-                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
-                        'Expires': 'Tue, 16 May 2018 00:00:00 GMT',
-                        'ETag': '"123456"'
-                    }
-                }
-            }]
-        }
-
-        cm = mock.MagicMock()
-        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
-        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
-        cm.keys.return_value = ["file1.txt"]
-        collectionmock.return_value = cm
-
-        utcnow = mock.MagicMock()
-        utcnow.return_value = datetime.datetime(2018, 5, 17)
-
-        r = arvados_cwl.http.http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow, prefer_cached_downloads=True)
-        self.assertEqual(r, "keep:99999999999999999999999999999998+99/file1.txt")
-
-        headmock.assert_not_called()
-        getmock.assert_not_called()
-        cm.open.assert_not_called()
-        api.collections().update.assert_not_called()
-
-    @mock.patch("requests.get")
-    @mock.patch("requests.head")
-    @mock.patch("arvados.collection.CollectionReader")
-    def test_http_varying_url_params(self, collectionmock, headmock, getmock):
-        for prurl in ("http://example.com/file1.txt", "http://example.com/file1.txt?KeyId=123&Signature=456&Expires=789"):
-            api = mock.MagicMock()
-
-            api.collections().list().execute.return_value = {
-                "items": [{
-                    "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
-                    "portable_data_hash": "99999999999999999999999999999998+99",
-                    "properties": {
-                        prurl: {
-                            'Date': 'Tue, 15 May 2018 00:00:00 GMT',
-                            'Expires': 'Tue, 16 May 2018 00:00:00 GMT',
-                            'ETag': '"123456"'
-                        }
-                    }
-                }]
-            }
-
-            cm = mock.MagicMock()
-            cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
-            cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
-            cm.keys.return_value = ["file1.txt"]
-            collectionmock.return_value = cm
-
-            req = mock.MagicMock()
-            req.status_code = 200
-            req.headers = {
-                'Date': 'Tue, 17 May 2018 00:00:00 GMT',
-                'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
-                'ETag': '"123456"'
-            }
-            headmock.return_value = req
-
-            utcnow = mock.MagicMock()
-            utcnow.return_value = datetime.datetime(2018, 5, 17)
-
-            r = arvados_cwl.http.http_to_keep(api, None, "http://example.com/file1.txt?KeyId=123&Signature=456&Expires=789",
-                                              utcnow=utcnow, varying_url_params="KeyId,Signature,Expires")
-            self.assertEqual(r, "keep:99999999999999999999999999999998+99/file1.txt")
-
-            getmock.assert_not_called()
-            cm.open.assert_not_called()
-
-            api.collections().update.assert_has_calls([
-                mock.call(uuid=cm.manifest_locator(),
-                          body={"collection":{"properties": {'http://example.com/file1.txt': {
-                              'Date': 'Tue, 17 May 2018 00:00:00 GMT',
-                              'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
-                              'ETag': '"123456"'
-                          }}}})
-                          ])
index d415be8856a48149e73d165c1060fbd92ef2d556..c8bf1279511cd8591104af5b196b4938dd71eb88 100644 (file)
@@ -10,6 +10,7 @@ from future.utils import viewvalues
 
 import copy
 import io
+import itertools
 import functools
 import hashlib
 import json
@@ -1047,43 +1048,37 @@ class TestSubmit(unittest.TestCase):
         api.return_value = mock.MagicMock()
         arvrunner.api = api.return_value
         arvrunner.runtimeContext.match_local_docker = False
-        arvrunner.api.links().list().execute.side_effect = ({"items": [{"created_at": "",
-                                                                        "head_uuid": "zzzzz-4zz18-zzzzzzzzzzzzzzb",
-                                                                        "link_class": "docker_image_repo+tag",
-                                                                        "name": "arvados/jobs:"+arvados_cwl.__version__,
-                                                                        "owner_uuid": "",
-                                                                        "properties": {"image_timestamp": ""}}], "items_available": 1, "offset": 0},
-                                                            {"items": [{"created_at": "",
-                                                                        "head_uuid": "",
-                                                                        "link_class": "docker_image_hash",
-                                                                        "name": "123456",
-                                                                        "owner_uuid": "",
-                                                                        "properties": {"image_timestamp": ""}}], "items_available": 1, "offset": 0},
-                                                            {"items": [{"created_at": "",
-                                                                        "head_uuid": "zzzzz-4zz18-zzzzzzzzzzzzzzb",
-                                                                        "link_class": "docker_image_repo+tag",
-                                                                        "name": "arvados/jobs:"+arvados_cwl.__version__,
-                                                                        "owner_uuid": "",
-                                                                        "properties": {"image_timestamp": ""}}], "items_available": 1, "offset": 0},
-                                                            {"items": [{"created_at": "",
-                                                                        "head_uuid": "",
-                                                                        "link_class": "docker_image_hash",
-                                                                        "name": "123456",
-                                                                        "owner_uuid": "",
-                                                                        "properties": {"image_timestamp": ""}}], "items_available": 1, "offset": 0}
-        )
+        arvrunner.api.links().list().execute.side_effect = itertools.cycle([
+            {"items": [{"created_at": "2023-08-25T12:34:56.123456Z",
+                        "head_uuid": "zzzzz-4zz18-zzzzzzzzzzzzzzb",
+                        "link_class": "docker_image_repo+tag",
+                        "name": "arvados/jobs:"+arvados_cwl.__version__,
+                        "owner_uuid": "",
+                        "uuid": "zzzzz-o0j2j-arvadosjobsrepo",
+                        "properties": {"image_timestamp": ""}}]},
+            {"items": []},
+            {"items": []},
+            {"items": [{"created_at": "2023-08-25T12:34:57.234567Z",
+                        "head_uuid": "",
+                        "link_class": "docker_image_hash",
+                        "name": "123456",
+                        "owner_uuid": "",
+                        "uuid": "zzzzz-o0j2j-arvadosjobshash",
+                        "properties": {"image_timestamp": ""}}]},
+            {"items": []},
+            {"items": []},
+        ])
         find_one_image_hash.return_value = "123456"
 
-        arvrunner.api.collections().list().execute.side_effect = ({"items": [{"uuid": "zzzzz-4zz18-zzzzzzzzzzzzzzb",
-                                                                              "owner_uuid": "",
-                                                                              "manifest_text": "",
-                                                                              "properties": ""
-                                                                              }], "items_available": 1, "offset": 0},
-                                                                  {"items": [{"uuid": "zzzzz-4zz18-zzzzzzzzzzzzzzb",
-                                                                              "owner_uuid": "",
-                                                                              "manifest_text": "",
-                                                                              "properties": ""
-                                                                          }], "items_available": 1, "offset": 0})
+        arvrunner.api.collections().list().execute.side_effect = itertools.cycle([
+            {"items": [{"uuid": "zzzzz-4zz18-zzzzzzzzzzzzzzb",
+                        "owner_uuid": "",
+                        "manifest_text": "",
+                        "created_at": "2023-08-25T12:34:55.012345Z",
+                        "properties": {}}]},
+            {"items": []},
+            {"items": []},
+        ])
         arvrunner.api.collections().create().execute.return_value = {"uuid": ""}
         arvrunner.api.collections().get().execute.return_value = {"uuid": "zzzzz-4zz18-zzzzzzzzzzzzzzb",
                                                                   "portable_data_hash": "9999999999999999999999999999999b+99"}
@@ -1185,7 +1180,7 @@ class TestSubmit(unittest.TestCase):
                                         "out": [
                                             {"id": "#main/step/out"}
                                         ],
-                                        "run": "keep:7628e49da34b93de9f4baf08a6212817+247/secret_wf.cwl"
+                                        "run": "keep:991302581d01db470345a131480e623b+247/secret_wf.cwl"
                                     }
                                 ]
                             }
@@ -1742,3 +1737,55 @@ class TestCreateWorkflow(unittest.TestCase):
         self.assertEqual(stubs.capture_stdout.getvalue(),
                          stubs.expect_workflow_uuid + '\n')
         self.assertEqual(exited, 0)
+
+    @stubs()
+    def test_create_map(self, stubs):
+        # test uploading a document that uses objects instead of arrays
+        # for certain fields like inputs and requirements.
+
+        project_uuid = 'zzzzz-j7d0g-zzzzzzzzzzzzzzz'
+        stubs.api.groups().get().execute.return_value = {"group_class": "project"}
+
+        exited = arvados_cwl.main(
+            ["--create-workflow", "--debug",
+             "--api=containers",
+             "--project-uuid", project_uuid,
+             "--disable-git",
+             "tests/wf/submit_wf_map.cwl", "tests/submit_test_job.json"],
+            stubs.capture_stdout, sys.stderr, api_client=stubs.api)
+
+        stubs.api.pipeline_templates().create.refute_called()
+        stubs.api.container_requests().create.refute_called()
+
+        expect_workflow = StripYAMLComments(
+            open("tests/wf/expect_upload_wrapper_map.cwl").read().rstrip())
+
+        body = {
+            "workflow": {
+                "owner_uuid": project_uuid,
+                "name": "submit_wf_map.cwl",
+                "description": "",
+                "definition": expect_workflow,
+            }
+        }
+        stubs.api.workflows().create.assert_called_with(
+            body=JsonDiffMatcher(body))
+
+        self.assertEqual(stubs.capture_stdout.getvalue(),
+                         stubs.expect_workflow_uuid + '\n')
+        self.assertEqual(exited, 0)
+
+
+class TestPrintKeepDeps(unittest.TestCase):
+    @stubs()
+    def test_print_keep_deps(self, stubs):
+        # test --print-keep-deps which is used by arv-copy
+
+        exited = arvados_cwl.main(
+            ["--print-keep-deps", "--debug",
+             "tests/wf/submit_wf_map.cwl"],
+            stubs.capture_stdout, sys.stderr, api_client=stubs.api)
+
+        self.assertEqual(stubs.capture_stdout.getvalue(),
+                         '["5d373e7629203ce39e7c22af98a0f881+52", "999999999999999999999999999999d4+99"]' + '\n')
+        self.assertEqual(exited, 0)
index 1209b88d8eb6d4a2d70d5632dfbc34e367ccf257..bf3d6fe0ef3de8d46a1f372f39dbe0607fd8aef9 100644 (file)
@@ -11,6 +11,7 @@ import httplib2
 
 from arvados_cwl.util import *
 from arvados.errors import ApiError
+from arvados_cwl.util import common_prefix
 
 class MockDateTime(datetime.datetime):
     @classmethod
@@ -53,4 +54,19 @@ class TestUtil(unittest.TestCase):
         logger = mock.MagicMock()
 
         current_container = get_current_container(api, num_retries=0, logger=logger)
-        self.assertEqual(current_container, None)
\ No newline at end of file
+        self.assertEqual(current_container, None)
+
+    def test_common_prefix(self):
+        self.assertEqual(common_prefix("file:///foo/bar", ["file:///foo/bar/baz"]), "file:///foo/")
+        self.assertEqual(common_prefix("file:///foo", ["file:///foo", "file:///foo/bar", "file:///foo/bar/"]), "file:///")
+        self.assertEqual(common_prefix("file:///foo/", ["file:///foo/", "file:///foo/bar", "file:///foo/bar/"]), "file:///foo/")
+        self.assertEqual(common_prefix("file:///foo/bar", ["file:///foo/bar", "file:///foo/baz", "file:///foo/quux/q2"]), "file:///foo/")
+        self.assertEqual(common_prefix("file:///foo/bar/", ["file:///foo/bar/", "file:///foo/baz", "file:///foo/quux/q2"]), "file:///foo/")
+        self.assertEqual(common_prefix("file:///foo/bar/splat", ["file:///foo/bar/splat", "file:///foo/baz", "file:///foo/quux/q2"]), "file:///foo/")
+        self.assertEqual(common_prefix("file:///foo/bar/splat", ["file:///foo/bar/splat", "file:///nope", "file:///foo/quux/q2"]), "file:///")
+        self.assertEqual(common_prefix("file:///blub/foo", ["file:///blub/foo", "file:///blub/foo/bar", "file:///blub/foo/bar/"]), "file:///blub/")
+
+        # sanity check, the subsequent code strips off the prefix so
+        # just confirm the logic doesn't have a fencepost error
+        prefix = "file:///"
+        self.assertEqual("file:///foo/bar"[len(prefix):], "foo/bar")
diff --git a/sdk/cwl/tests/tool/submit_tool_map.cwl b/sdk/cwl/tests/tool/submit_tool_map.cwl
new file mode 100644 (file)
index 0000000..7a833d4
--- /dev/null
@@ -0,0 +1,24 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# Test case for arvados-cwl-runner
+#
+# Used to test whether scanning a tool file for dependencies (e.g. default
+# value blub.txt) and uploading to Keep works as intended.
+
+class: CommandLineTool
+cwlVersion: v1.0
+requirements:
+  DockerRequirement:
+    dockerPull: debian:buster-slim
+inputs:
+  x:
+    type: File
+    default:
+      class: File
+      location: blub.txt
+    inputBinding:
+      position: 1
+outputs: []
+baseCommand: cat
diff --git a/sdk/cwl/tests/wf/expect_upload_wrapper_map.cwl b/sdk/cwl/tests/wf/expect_upload_wrapper_map.cwl
new file mode 100644 (file)
index 0000000..8f98f47
--- /dev/null
@@ -0,0 +1,88 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+{
+    "$graph": [
+        {
+            "class": "Workflow",
+            "hints": [
+                {
+                    "acrContainerImage": "999999999999999999999999999999d3+99",
+                    "class": "http://arvados.org/cwl#WorkflowRunnerResources"
+                }
+            ],
+            "id": "#main",
+            "inputs": [
+                {
+                    "default": {
+                        "basename": "blorp.txt",
+                        "class": "File",
+                        "location": "keep:169f39d466a5438ac4a90e779bf750c7+53/blorp.txt",
+                        "nameext": ".txt",
+                        "nameroot": "blorp",
+                        "size": 16
+                    },
+                    "id": "#main/x",
+                    "type": "File"
+                },
+                {
+                    "default": {
+                        "basename": "99999999999999999999999999999998+99",
+                        "class": "Directory",
+                        "location": "keep:99999999999999999999999999999998+99"
+                    },
+                    "id": "#main/y",
+                    "type": "Directory"
+                },
+                {
+                    "default": {
+                        "basename": "anonymous",
+                        "class": "Directory",
+                        "listing": [
+                            {
+                                "basename": "renamed.txt",
+                                "class": "File",
+                                "location": "keep:99999999999999999999999999999998+99/file1.txt",
+                                "nameext": ".txt",
+                                "nameroot": "renamed",
+                                "size": 0
+                            }
+                        ]
+                    },
+                    "id": "#main/z",
+                    "type": "Directory"
+                }
+            ],
+            "outputs": [],
+            "requirements": [
+                {
+                    "class": "SubworkflowFeatureRequirement"
+                }
+            ],
+            "steps": [
+                {
+                    "id": "#main/submit_wf_map.cwl",
+                    "in": [
+                        {
+                            "id": "#main/step/x",
+                            "source": "#main/x"
+                        },
+                        {
+                            "id": "#main/step/y",
+                            "source": "#main/y"
+                        },
+                        {
+                            "id": "#main/step/z",
+                            "source": "#main/z"
+                        }
+                    ],
+                    "label": "submit_wf_map.cwl",
+                    "out": [],
+                    "run": "keep:2b94b65162db72023301a582e085646f+290/wf/submit_wf_map.cwl"
+                }
+            ]
+        }
+    ],
+    "cwlVersion": "v1.2"
+}
diff --git a/sdk/cwl/tests/wf/runseparate-wf.cwl b/sdk/cwl/tests/wf/runseparate-wf.cwl
new file mode 100644 (file)
index 0000000..e4ab627
--- /dev/null
@@ -0,0 +1,68 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+class: Workflow
+cwlVersion: v1.0
+$namespaces:
+  arv: "http://arvados.org/cwl#"
+inputs:
+  sleeptime:
+    type: int
+    default: 5
+  fileblub:
+    type: File
+    default:
+      class: File
+      location: keep:d7514270f356df848477718d58308cc4+94/a
+      secondaryFiles:
+        - class: File
+          location: keep:d7514270f356df848477718d58308cc4+94/b
+outputs:
+  out:
+    type: string
+    outputSource: substep/out
+requirements:
+  SubworkflowFeatureRequirement: {}
+  ScatterFeatureRequirement: {}
+  InlineJavascriptRequirement: {}
+  StepInputExpressionRequirement: {}
+steps:
+  substep:
+    in:
+      sleeptime: sleeptime
+      fileblub: fileblub
+    out: [out]
+    hints:
+      - class: arv:SeparateRunner
+        runnerProcessName: $("sleeptime "+inputs.sleeptime)
+      - class: DockerRequirement
+        dockerPull: arvados/jobs:2.2.2
+    run:
+      class: Workflow
+      id: mysub
+      inputs:
+        fileblub: File
+        sleeptime: int
+      outputs:
+        out:
+          type: string
+          outputSource: sleep1/out
+      steps:
+        sleep1:
+          in:
+            fileblub: fileblub
+          out: [out]
+          run:
+            class: CommandLineTool
+            id: subtool
+            inputs:
+              fileblub:
+                type: File
+                inputBinding: {position: 1}
+            outputs:
+              out:
+                type: string
+                outputBinding:
+                  outputEval: 'out'
+            baseCommand: cat
diff --git a/sdk/cwl/tests/wf/submit_wf_map.cwl b/sdk/cwl/tests/wf/submit_wf_map.cwl
new file mode 100644 (file)
index 0000000..e8bb9cf
--- /dev/null
@@ -0,0 +1,25 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# Test case for arvados-cwl-runner
+#
+# Used to test whether scanning a workflow file for dependencies
+# (e.g. submit_tool.cwl) and uploading to Keep works as intended.
+
+class: Workflow
+cwlVersion: v1.2
+inputs:
+  x:
+    type: File
+  y:
+    type: Directory
+  z:
+    type: Directory
+outputs: []
+steps:
+  step1:
+    in:
+      x: x
+    out: []
+    run: ../tool/submit_tool_map.cwl
index 95b039eba9231588b0fce92af7d6b3a369af1f24..f66f670d815d1936d88ee079e842ebadf8094a0f 100644 (file)
@@ -9,47 +9,23 @@
 # version.
 #
 # Use arvados/build/build-dev-docker-jobs-image.sh to build.
-#
-# (This dockerfile file must be located in the arvados/sdk/ directory because
-#  of the docker build root.)
 
-FROM debian:buster-slim
+FROM debian:bullseye-slim
 MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
 
-ENV DEBIAN_FRONTEND noninteractive
-
-ARG pythoncmd=python3
-ARG pipcmd=pip3
-
-RUN apt-get update -q && apt-get install -qy --no-install-recommends \
-    git ${pythoncmd}-pip ${pythoncmd}-virtualenv ${pythoncmd}-dev libcurl4-gnutls-dev \
-    libgnutls28-dev nodejs ${pythoncmd}-pyasn1-modules build-essential ${pythoncmd}-setuptools
-
-ARG sdk
-ARG runner
-ARG salad
-ARG cwlutils
-ARG cwltool
-
-ADD python/dist/$sdk /tmp/
-ADD cwl/salad_dist/$salad /tmp/
-ADD cwl/cwltool_dist/$cwltool /tmp/
-ADD cwl/cwlutils_dist/$cwlutils /tmp/
-ADD cwl/dist/$runner /tmp/
+RUN DEBIAN_FRONTEND=noninteractive apt-get update -q && apt-get install -qy --no-install-recommends \
+    git python3-dev python3-venv libcurl4-gnutls-dev libgnutls28-dev nodejs build-essential
 
-RUN $pipcmd install wheel
-RUN cd /tmp/arvados-python-client-* && $pipcmd install .
-RUN if test -d /tmp/schema-salad-* ; then cd /tmp/schema-salad-* && $pipcmd install . ; fi
-RUN if test -d /tmp/cwl-utils-* ; then cd /tmp/cwl-utils-* && $pipcmd install . ; fi
-RUN if test -d /tmp/cwltool-* ; then cd /tmp/cwltool-* && $pipcmd install . ; fi
-RUN cd /tmp/arvados-cwl-runner-* && $pipcmd install .
+RUN python3 -m venv /opt/arvados-py
+ENV PATH=/opt/arvados-py/bin:/usr/local/bin:/usr/bin:/bin
+RUN python3 -m pip install --no-cache-dir setuptools wheel
 
-# Sometimes Python dependencies install successfully but don't
-# actually work.  So run arvados-cwl-runner here to catch fun
-# dependency errors like pkg_resources.DistributionNotFound.
-RUN arvados-cwl-runner --version
+# The build script sets up our build context with all the Python source
+# packages to install.
+COPY . /usr/local/src/
+# Run a-c-r afterward to check for a successful install.
+RUN python3 -m pip install --no-cache-dir /usr/local/src/* && arvados-cwl-runner --version && crunchstat-summary --version
 
-# Install dependencies and set up system.
 RUN /usr/sbin/adduser --disabled-password \
       --gecos 'Crunch execution user' crunch && \
     /usr/bin/install --directory --owner=crunch --group=crunch --mode=0700 /keep /tmp/crunch-src /tmp/crunch-job
index 1a4d61b42a0cf9f53d21d123076e1b888a9b215f..c3d0ea8aef676b3d3c57ce0bfbbcbe129b7689ac 100644 (file)
@@ -23,81 +23,91 @@ type APIEndpoint struct {
 }
 
 var (
-       EndpointConfigGet                     = APIEndpoint{"GET", "arvados/v1/config", ""}
-       EndpointVocabularyGet                 = APIEndpoint{"GET", "arvados/v1/vocabulary", ""}
-       EndpointLogin                         = APIEndpoint{"GET", "login", ""}
-       EndpointLogout                        = APIEndpoint{"GET", "logout", ""}
-       EndpointCollectionCreate              = APIEndpoint{"POST", "arvados/v1/collections", "collection"}
-       EndpointCollectionUpdate              = APIEndpoint{"PATCH", "arvados/v1/collections/{uuid}", "collection"}
-       EndpointCollectionGet                 = APIEndpoint{"GET", "arvados/v1/collections/{uuid}", ""}
-       EndpointCollectionList                = APIEndpoint{"GET", "arvados/v1/collections", ""}
-       EndpointCollectionProvenance          = APIEndpoint{"GET", "arvados/v1/collections/{uuid}/provenance", ""}
-       EndpointCollectionUsedBy              = APIEndpoint{"GET", "arvados/v1/collections/{uuid}/used_by", ""}
-       EndpointCollectionDelete              = APIEndpoint{"DELETE", "arvados/v1/collections/{uuid}", ""}
-       EndpointCollectionTrash               = APIEndpoint{"POST", "arvados/v1/collections/{uuid}/trash", ""}
-       EndpointCollectionUntrash             = APIEndpoint{"POST", "arvados/v1/collections/{uuid}/untrash", ""}
-       EndpointSpecimenCreate                = APIEndpoint{"POST", "arvados/v1/specimens", "specimen"}
-       EndpointSpecimenUpdate                = APIEndpoint{"PATCH", "arvados/v1/specimens/{uuid}", "specimen"}
-       EndpointSpecimenGet                   = APIEndpoint{"GET", "arvados/v1/specimens/{uuid}", ""}
-       EndpointSpecimenList                  = APIEndpoint{"GET", "arvados/v1/specimens", ""}
-       EndpointSpecimenDelete                = APIEndpoint{"DELETE", "arvados/v1/specimens/{uuid}", ""}
-       EndpointContainerCreate               = APIEndpoint{"POST", "arvados/v1/containers", "container"}
-       EndpointContainerUpdate               = APIEndpoint{"PATCH", "arvados/v1/containers/{uuid}", "container"}
-       EndpointContainerPriorityUpdate       = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/update_priority", "container"}
-       EndpointContainerGet                  = APIEndpoint{"GET", "arvados/v1/containers/{uuid}", ""}
-       EndpointContainerList                 = APIEndpoint{"GET", "arvados/v1/containers", ""}
-       EndpointContainerDelete               = APIEndpoint{"DELETE", "arvados/v1/containers/{uuid}", ""}
-       EndpointContainerLock                 = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/lock", ""}
-       EndpointContainerUnlock               = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/unlock", ""}
-       EndpointContainerSSH                  = APIEndpoint{"POST", "arvados/v1/connect/{uuid}/ssh", ""}            // move to /containers after #17014 fixes routing
-       EndpointContainerGatewayTunnel        = APIEndpoint{"POST", "arvados/v1/connect/{uuid}/gateway_tunnel", ""} // move to /containers after #17014 fixes routing
-       EndpointContainerRequestCreate        = APIEndpoint{"POST", "arvados/v1/container_requests", "container_request"}
-       EndpointContainerRequestUpdate        = APIEndpoint{"PATCH", "arvados/v1/container_requests/{uuid}", "container_request"}
-       EndpointContainerRequestGet           = APIEndpoint{"GET", "arvados/v1/container_requests/{uuid}", ""}
-       EndpointContainerRequestList          = APIEndpoint{"GET", "arvados/v1/container_requests", ""}
-       EndpointContainerRequestDelete        = APIEndpoint{"DELETE", "arvados/v1/container_requests/{uuid}", ""}
-       EndpointGroupCreate                   = APIEndpoint{"POST", "arvados/v1/groups", "group"}
-       EndpointGroupUpdate                   = APIEndpoint{"PATCH", "arvados/v1/groups/{uuid}", "group"}
-       EndpointGroupGet                      = APIEndpoint{"GET", "arvados/v1/groups/{uuid}", ""}
-       EndpointGroupList                     = APIEndpoint{"GET", "arvados/v1/groups", ""}
-       EndpointGroupContents                 = APIEndpoint{"GET", "arvados/v1/groups/contents", ""}
-       EndpointGroupContentsUUIDInPath       = APIEndpoint{"GET", "arvados/v1/groups/{uuid}/contents", ""} // Alternative HTTP route; client-side code should always use EndpointGroupContents instead
-       EndpointGroupShared                   = APIEndpoint{"GET", "arvados/v1/groups/shared", ""}
-       EndpointGroupDelete                   = APIEndpoint{"DELETE", "arvados/v1/groups/{uuid}", ""}
-       EndpointGroupTrash                    = APIEndpoint{"POST", "arvados/v1/groups/{uuid}/trash", ""}
-       EndpointGroupUntrash                  = APIEndpoint{"POST", "arvados/v1/groups/{uuid}/untrash", ""}
-       EndpointLinkCreate                    = APIEndpoint{"POST", "arvados/v1/links", "link"}
-       EndpointLinkUpdate                    = APIEndpoint{"PATCH", "arvados/v1/links/{uuid}", "link"}
-       EndpointLinkGet                       = APIEndpoint{"GET", "arvados/v1/links/{uuid}", ""}
-       EndpointLinkList                      = APIEndpoint{"GET", "arvados/v1/links", ""}
-       EndpointLinkDelete                    = APIEndpoint{"DELETE", "arvados/v1/links/{uuid}", ""}
-       EndpointLogCreate                     = APIEndpoint{"POST", "arvados/v1/logs", "log"}
-       EndpointLogUpdate                     = APIEndpoint{"PATCH", "arvados/v1/logs/{uuid}", "log"}
-       EndpointLogGet                        = APIEndpoint{"GET", "arvados/v1/logs/{uuid}", ""}
-       EndpointLogList                       = APIEndpoint{"GET", "arvados/v1/logs", ""}
-       EndpointLogDelete                     = APIEndpoint{"DELETE", "arvados/v1/logs/{uuid}", ""}
-       EndpointSysTrashSweep                 = APIEndpoint{"POST", "sys/trash_sweep", ""}
-       EndpointUserActivate                  = APIEndpoint{"POST", "arvados/v1/users/{uuid}/activate", ""}
-       EndpointUserCreate                    = APIEndpoint{"POST", "arvados/v1/users", "user"}
-       EndpointUserCurrent                   = APIEndpoint{"GET", "arvados/v1/users/current", ""}
-       EndpointUserDelete                    = APIEndpoint{"DELETE", "arvados/v1/users/{uuid}", ""}
-       EndpointUserGet                       = APIEndpoint{"GET", "arvados/v1/users/{uuid}", ""}
-       EndpointUserGetCurrent                = APIEndpoint{"GET", "arvados/v1/users/current", ""}
-       EndpointUserGetSystem                 = APIEndpoint{"GET", "arvados/v1/users/system", ""}
-       EndpointUserList                      = APIEndpoint{"GET", "arvados/v1/users", ""}
-       EndpointUserMerge                     = APIEndpoint{"POST", "arvados/v1/users/merge", ""}
-       EndpointUserSetup                     = APIEndpoint{"POST", "arvados/v1/users/setup", "user"}
-       EndpointUserSystem                    = APIEndpoint{"GET", "arvados/v1/users/system", ""}
-       EndpointUserUnsetup                   = APIEndpoint{"POST", "arvados/v1/users/{uuid}/unsetup", ""}
-       EndpointUserUpdate                    = APIEndpoint{"PATCH", "arvados/v1/users/{uuid}", "user"}
-       EndpointUserBatchUpdate               = APIEndpoint{"PATCH", "arvados/v1/users/batch_update", ""}
-       EndpointUserAuthenticate              = APIEndpoint{"POST", "arvados/v1/users/authenticate", ""}
-       EndpointAPIClientAuthorizationCurrent = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/current", ""}
-       EndpointAPIClientAuthorizationCreate  = APIEndpoint{"POST", "arvados/v1/api_client_authorizations", "api_client_authorization"}
-       EndpointAPIClientAuthorizationUpdate  = APIEndpoint{"PUT", "arvados/v1/api_client_authorizations/{uuid}", "api_client_authorization"}
-       EndpointAPIClientAuthorizationList    = APIEndpoint{"GET", "arvados/v1/api_client_authorizations", ""}
-       EndpointAPIClientAuthorizationDelete  = APIEndpoint{"DELETE", "arvados/v1/api_client_authorizations/{uuid}", ""}
-       EndpointAPIClientAuthorizationGet     = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/{uuid}", ""}
+       EndpointConfigGet                       = APIEndpoint{"GET", "arvados/v1/config", ""}
+       EndpointVocabularyGet                   = APIEndpoint{"GET", "arvados/v1/vocabulary", ""}
+       EndpointDiscoveryDocument               = APIEndpoint{"GET", "discovery/v1/apis/arvados/v1/rest", ""}
+       EndpointLogin                           = APIEndpoint{"GET", "login", ""}
+       EndpointLogout                          = APIEndpoint{"GET", "logout", ""}
+       EndpointAuthorizedKeyCreate             = APIEndpoint{"POST", "arvados/v1/authorized_keys", "authorized_key"}
+       EndpointAuthorizedKeyUpdate             = APIEndpoint{"PATCH", "arvados/v1/authorized_keys/{uuid}", "authorized_key"}
+       EndpointAuthorizedKeyGet                = APIEndpoint{"GET", "arvados/v1/authorized_keys/{uuid}", ""}
+       EndpointAuthorizedKeyList               = APIEndpoint{"GET", "arvados/v1/authorized_keys", ""}
+       EndpointAuthorizedKeyDelete             = APIEndpoint{"DELETE", "arvados/v1/authorized_keys/{uuid}", ""}
+       EndpointCollectionCreate                = APIEndpoint{"POST", "arvados/v1/collections", "collection"}
+       EndpointCollectionUpdate                = APIEndpoint{"PATCH", "arvados/v1/collections/{uuid}", "collection"}
+       EndpointCollectionGet                   = APIEndpoint{"GET", "arvados/v1/collections/{uuid}", ""}
+       EndpointCollectionList                  = APIEndpoint{"GET", "arvados/v1/collections", ""}
+       EndpointCollectionProvenance            = APIEndpoint{"GET", "arvados/v1/collections/{uuid}/provenance", ""}
+       EndpointCollectionUsedBy                = APIEndpoint{"GET", "arvados/v1/collections/{uuid}/used_by", ""}
+       EndpointCollectionDelete                = APIEndpoint{"DELETE", "arvados/v1/collections/{uuid}", ""}
+       EndpointCollectionTrash                 = APIEndpoint{"POST", "arvados/v1/collections/{uuid}/trash", ""}
+       EndpointCollectionUntrash               = APIEndpoint{"POST", "arvados/v1/collections/{uuid}/untrash", ""}
+       EndpointSpecimenCreate                  = APIEndpoint{"POST", "arvados/v1/specimens", "specimen"}
+       EndpointSpecimenUpdate                  = APIEndpoint{"PATCH", "arvados/v1/specimens/{uuid}", "specimen"}
+       EndpointSpecimenGet                     = APIEndpoint{"GET", "arvados/v1/specimens/{uuid}", ""}
+       EndpointSpecimenList                    = APIEndpoint{"GET", "arvados/v1/specimens", ""}
+       EndpointSpecimenDelete                  = APIEndpoint{"DELETE", "arvados/v1/specimens/{uuid}", ""}
+       EndpointContainerCreate                 = APIEndpoint{"POST", "arvados/v1/containers", "container"}
+       EndpointContainerUpdate                 = APIEndpoint{"PATCH", "arvados/v1/containers/{uuid}", "container"}
+       EndpointContainerPriorityUpdate         = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/update_priority", "container"}
+       EndpointContainerGet                    = APIEndpoint{"GET", "arvados/v1/containers/{uuid}", ""}
+       EndpointContainerList                   = APIEndpoint{"GET", "arvados/v1/containers", ""}
+       EndpointContainerDelete                 = APIEndpoint{"DELETE", "arvados/v1/containers/{uuid}", ""}
+       EndpointContainerLock                   = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/lock", ""}
+       EndpointContainerUnlock                 = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/unlock", ""}
+       EndpointContainerSSH                    = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/ssh", ""}
+       EndpointContainerSSHCompat              = APIEndpoint{"POST", "arvados/v1/connect/{uuid}/ssh", ""} // for compatibility with arvados <2.7
+       EndpointContainerGatewayTunnel          = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/gateway_tunnel", ""}
+       EndpointContainerGatewayTunnelCompat    = APIEndpoint{"POST", "arvados/v1/connect/{uuid}/gateway_tunnel", ""} // for compatibility with arvados <2.7
+       EndpointContainerRequestCreate          = APIEndpoint{"POST", "arvados/v1/container_requests", "container_request"}
+       EndpointContainerRequestUpdate          = APIEndpoint{"PATCH", "arvados/v1/container_requests/{uuid}", "container_request"}
+       EndpointContainerRequestGet             = APIEndpoint{"GET", "arvados/v1/container_requests/{uuid}", ""}
+       EndpointContainerRequestList            = APIEndpoint{"GET", "arvados/v1/container_requests", ""}
+       EndpointContainerRequestDelete          = APIEndpoint{"DELETE", "arvados/v1/container_requests/{uuid}", ""}
+       EndpointContainerRequestContainerStatus = APIEndpoint{"GET", "arvados/v1/container_requests/{uuid}/container_status", ""}
+       EndpointContainerRequestLog             = APIEndpoint{"GET", "arvados/v1/container_requests/{uuid}/log{path:|/.*}", ""}
+       EndpointGroupCreate                     = APIEndpoint{"POST", "arvados/v1/groups", "group"}
+       EndpointGroupUpdate                     = APIEndpoint{"PATCH", "arvados/v1/groups/{uuid}", "group"}
+       EndpointGroupGet                        = APIEndpoint{"GET", "arvados/v1/groups/{uuid}", ""}
+       EndpointGroupList                       = APIEndpoint{"GET", "arvados/v1/groups", ""}
+       EndpointGroupContents                   = APIEndpoint{"GET", "arvados/v1/groups/contents", ""}
+       EndpointGroupContentsUUIDInPath         = APIEndpoint{"GET", "arvados/v1/groups/{uuid}/contents", ""} // Alternative HTTP route; client-side code should always use EndpointGroupContents instead
+       EndpointGroupShared                     = APIEndpoint{"GET", "arvados/v1/groups/shared", ""}
+       EndpointGroupDelete                     = APIEndpoint{"DELETE", "arvados/v1/groups/{uuid}", ""}
+       EndpointGroupTrash                      = APIEndpoint{"POST", "arvados/v1/groups/{uuid}/trash", ""}
+       EndpointGroupUntrash                    = APIEndpoint{"POST", "arvados/v1/groups/{uuid}/untrash", ""}
+       EndpointLinkCreate                      = APIEndpoint{"POST", "arvados/v1/links", "link"}
+       EndpointLinkUpdate                      = APIEndpoint{"PATCH", "arvados/v1/links/{uuid}", "link"}
+       EndpointLinkGet                         = APIEndpoint{"GET", "arvados/v1/links/{uuid}", ""}
+       EndpointLinkList                        = APIEndpoint{"GET", "arvados/v1/links", ""}
+       EndpointLinkDelete                      = APIEndpoint{"DELETE", "arvados/v1/links/{uuid}", ""}
+       EndpointLogCreate                       = APIEndpoint{"POST", "arvados/v1/logs", "log"}
+       EndpointLogUpdate                       = APIEndpoint{"PATCH", "arvados/v1/logs/{uuid}", "log"}
+       EndpointLogGet                          = APIEndpoint{"GET", "arvados/v1/logs/{uuid}", ""}
+       EndpointLogList                         = APIEndpoint{"GET", "arvados/v1/logs", ""}
+       EndpointLogDelete                       = APIEndpoint{"DELETE", "arvados/v1/logs/{uuid}", ""}
+       EndpointSysTrashSweep                   = APIEndpoint{"POST", "sys/trash_sweep", ""}
+       EndpointUserActivate                    = APIEndpoint{"POST", "arvados/v1/users/{uuid}/activate", ""}
+       EndpointUserCreate                      = APIEndpoint{"POST", "arvados/v1/users", "user"}
+       EndpointUserCurrent                     = APIEndpoint{"GET", "arvados/v1/users/current", ""}
+       EndpointUserDelete                      = APIEndpoint{"DELETE", "arvados/v1/users/{uuid}", ""}
+       EndpointUserGet                         = APIEndpoint{"GET", "arvados/v1/users/{uuid}", ""}
+       EndpointUserGetCurrent                  = APIEndpoint{"GET", "arvados/v1/users/current", ""}
+       EndpointUserGetSystem                   = APIEndpoint{"GET", "arvados/v1/users/system", ""}
+       EndpointUserList                        = APIEndpoint{"GET", "arvados/v1/users", ""}
+       EndpointUserMerge                       = APIEndpoint{"POST", "arvados/v1/users/merge", ""}
+       EndpointUserSetup                       = APIEndpoint{"POST", "arvados/v1/users/setup", "user"}
+       EndpointUserSystem                      = APIEndpoint{"GET", "arvados/v1/users/system", ""}
+       EndpointUserUnsetup                     = APIEndpoint{"POST", "arvados/v1/users/{uuid}/unsetup", ""}
+       EndpointUserUpdate                      = APIEndpoint{"PATCH", "arvados/v1/users/{uuid}", "user"}
+       EndpointUserBatchUpdate                 = APIEndpoint{"PATCH", "arvados/v1/users/batch_update", ""}
+       EndpointUserAuthenticate                = APIEndpoint{"POST", "arvados/v1/users/authenticate", ""}
+       EndpointAPIClientAuthorizationCurrent   = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/current", ""}
+       EndpointAPIClientAuthorizationCreate    = APIEndpoint{"POST", "arvados/v1/api_client_authorizations", "api_client_authorization"}
+       EndpointAPIClientAuthorizationUpdate    = APIEndpoint{"PUT", "arvados/v1/api_client_authorizations/{uuid}", "api_client_authorization"}
+       EndpointAPIClientAuthorizationList      = APIEndpoint{"GET", "arvados/v1/api_client_authorizations", ""}
+       EndpointAPIClientAuthorizationDelete    = APIEndpoint{"DELETE", "arvados/v1/api_client_authorizations/{uuid}", ""}
+       EndpointAPIClientAuthorizationGet       = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/{uuid}", ""}
 )
 
 type ContainerSSHOptions struct {
@@ -232,11 +242,17 @@ type LogoutOptions struct {
        ReturnTo string `json:"return_to"` // Redirect to this URL after logging out
 }
 
+type BlockReadOptions struct {
+       Locator      string
+       WriteTo      io.Writer
+       LocalLocator func(string)
+}
+
 type BlockWriteOptions struct {
        Hash           string
        Data           []byte
-       Reader         io.Reader
-       DataSize       int // Must be set if Data is nil.
+       Reader         io.Reader // Must be set if Data is nil.
+       DataSize       int       // Must be set if Data is nil.
        RequestID      string
        StorageClasses []string
        Replicas       int
@@ -244,8 +260,21 @@ type BlockWriteOptions struct {
 }
 
 type BlockWriteResponse struct {
-       Locator  string
-       Replicas int
+       Locator        string
+       Replicas       int
+       StorageClasses map[string]int
+}
+
+type WebDAVOptions struct {
+       Method string
+       Path   string
+       Header http.Header
+}
+
+type ContainerLogOptions struct {
+       UUID      string `json:"uuid"`
+       NoForward bool   `json:"no_forward"`
+       WebDAVOptions
 }
 
 type API interface {
@@ -253,6 +282,11 @@ type API interface {
        VocabularyGet(ctx context.Context) (Vocabulary, error)
        Login(ctx context.Context, options LoginOptions) (LoginResponse, error)
        Logout(ctx context.Context, options LogoutOptions) (LogoutResponse, error)
+       AuthorizedKeyCreate(ctx context.Context, options CreateOptions) (AuthorizedKey, error)
+       AuthorizedKeyUpdate(ctx context.Context, options UpdateOptions) (AuthorizedKey, error)
+       AuthorizedKeyGet(ctx context.Context, options GetOptions) (AuthorizedKey, error)
+       AuthorizedKeyList(ctx context.Context, options ListOptions) (AuthorizedKeyList, error)
+       AuthorizedKeyDelete(ctx context.Context, options DeleteOptions) (AuthorizedKey, error)
        CollectionCreate(ctx context.Context, options CreateOptions) (Collection, error)
        CollectionUpdate(ctx context.Context, options UpdateOptions) (Collection, error)
        CollectionGet(ctx context.Context, options GetOptions) (Collection, error)
@@ -277,6 +311,8 @@ type API interface {
        ContainerRequestGet(ctx context.Context, options GetOptions) (ContainerRequest, error)
        ContainerRequestList(ctx context.Context, options ListOptions) (ContainerRequestList, error)
        ContainerRequestDelete(ctx context.Context, options DeleteOptions) (ContainerRequest, error)
+       ContainerRequestContainerStatus(ctx context.Context, options GetOptions) (ContainerStatus, error)
+       ContainerRequestLog(ctx context.Context, options ContainerLogOptions) (http.Handler, error)
        GroupCreate(ctx context.Context, options CreateOptions) (Group, error)
        GroupUpdate(ctx context.Context, options UpdateOptions) (Group, error)
        GroupGet(ctx context.Context, options GetOptions) (Group, error)
@@ -321,4 +357,5 @@ type API interface {
        APIClientAuthorizationDelete(ctx context.Context, options DeleteOptions) (APIClientAuthorization, error)
        APIClientAuthorizationUpdate(ctx context.Context, options UpdateOptions) (APIClientAuthorization, error)
        APIClientAuthorizationGet(ctx context.Context, options GetOptions) (APIClientAuthorization, error)
+       DiscoveryDocument(ctx context.Context) (DiscoveryDocument, error)
 }
diff --git a/sdk/go/arvados/authorized_key.go b/sdk/go/arvados/authorized_key.go
new file mode 100644 (file)
index 0000000..642fc11
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import "time"
+
+// AuthorizedKey is an arvados#authorizedKey resource.
+type AuthorizedKey struct {
+       UUID                 string    `json:"uuid"`
+       Etag                 string    `json:"etag"`
+       OwnerUUID            string    `json:"owner_uuid"`
+       CreatedAt            time.Time `json:"created_at"`
+       ModifiedAt           time.Time `json:"modified_at"`
+       ModifiedByClientUUID string    `json:"modified_by_client_uuid"`
+       ModifiedByUserUUID   string    `json:"modified_by_user_uuid"`
+       Name                 string    `json:"name"`
+       AuthorizedUserUUID   string    `json:"authorized_user_uuid"`
+       PublicKey            string    `json:"public_key"`
+       KeyType              string    `json:"key_type"`
+       ExpiresAt            time.Time `json:"expires_at"`
+}
+
+// AuthorizedKeyList is an arvados#authorizedKeyList resource.
+type AuthorizedKeyList struct {
+       Items          []AuthorizedKey `json:"items"`
+       ItemsAvailable int             `json:"items_available"`
+       Offset         int             `json:"offset"`
+       Limit          int             `json:"limit"`
+}
index 08cc83e126952e6349bb294c0493899253124107..7cc2c697811a682dd60de00449b3e0b7a7e8b066 100644 (file)
@@ -8,11 +8,16 @@ import (
        "encoding/json"
        "fmt"
        "math"
+       "strconv"
        "strings"
 )
 
 type ByteSize int64
 
+// ByteSizeOrPercent indicates either a number of bytes or a
+// percentage from 1 to 100.
+type ByteSizeOrPercent ByteSize
+
 var prefixValue = map[string]int64{
        "":   1,
        "K":  1000,
@@ -89,3 +94,54 @@ func (n *ByteSize) UnmarshalJSON(data []byte) error {
                return fmt.Errorf("bug: json.Number for %q is not int64 or float64: %s", s, err)
        }
 }
+
+func (n ByteSizeOrPercent) MarshalJSON() ([]byte, error) {
+       if n < 0 && n >= -100 {
+               return []byte(fmt.Sprintf("\"%d%%\"", -n)), nil
+       } else {
+               return json.Marshal(int64(n))
+       }
+}
+
+func (n *ByteSizeOrPercent) UnmarshalJSON(data []byte) error {
+       if len(data) == 0 || data[0] != '"' {
+               return (*ByteSize)(n).UnmarshalJSON(data)
+       }
+       var s string
+       err := json.Unmarshal(data, &s)
+       if err != nil {
+               return err
+       }
+       if s := strings.TrimSpace(s); len(s) > 0 && s[len(s)-1] == '%' {
+               pct, err := strconv.ParseInt(strings.TrimSpace(s[:len(s)-1]), 10, 64)
+               if err != nil {
+                       return err
+               }
+               if pct < 0 || pct > 100 {
+                       return fmt.Errorf("invalid value %q (percentage must be between 0 and 100)", s)
+               }
+               *n = ByteSizeOrPercent(-pct)
+               return nil
+       }
+       return (*ByteSize)(n).UnmarshalJSON(data)
+}
+
+// ByteSize returns the absolute byte size specified by n, or 0 if n
+// specifies a percent.
+func (n ByteSizeOrPercent) ByteSize() ByteSize {
+       if n >= -100 && n < 0 {
+               return 0
+       } else {
+               return ByteSize(n)
+       }
+}
+
+// ByteSize returns the percentage specified by n, or 0 if n specifies
+// an absolute byte size.
+func (n ByteSizeOrPercent) Percent() int64 {
+       if n >= -100 && n < 0 {
+               return int64(-n)
+       } else {
+               return 0
+       }
+}
index 7c4aff207258aab5e4c8e9dfc266089004559831..e5fb10ebdb352137f3abd974b488503565c82671 100644 (file)
@@ -64,7 +64,54 @@ func (s *ByteSizeSuite) TestUnmarshal(c *check.C) {
        } {
                var n ByteSize
                err := yaml.Unmarshal([]byte(testcase+"\n"), &n)
-               c.Logf("%v => error: %v", n, err)
+               c.Logf("%s => error: %v", testcase, err)
+               c.Check(err, check.NotNil)
+       }
+}
+
+func (s *ByteSizeSuite) TestMarshalByteSizeOrPercent(c *check.C) {
+       for _, testcase := range []struct {
+               in  ByteSizeOrPercent
+               out string
+       }{
+               {0, "0"},
+               {-1, "1%"},
+               {-100, "100%"},
+               {8, "8"},
+       } {
+               out, err := yaml.Marshal(&testcase.in)
+               c.Check(err, check.IsNil)
+               c.Check(string(out), check.Equals, testcase.out+"\n")
+       }
+}
+
+func (s *ByteSizeSuite) TestUnmarshalByteSizeOrPercent(c *check.C) {
+       for _, testcase := range []struct {
+               in  string
+               out int64
+       }{
+               {"0", 0},
+               {"100", 100},
+               {"0%", 0},
+               {"1%", -1},
+               {"100%", -100},
+               {"8 GB", 8000000000},
+       } {
+               var n ByteSizeOrPercent
+               err := yaml.Unmarshal([]byte(testcase.in+"\n"), &n)
+               c.Logf("%v => %v: %v", testcase.in, testcase.out, n)
+               c.Check(err, check.IsNil)
+               c.Check(int64(n), check.Equals, testcase.out)
+       }
+       for _, testcase := range []string{
+               "1000%", "101%", "-1%",
+               "%", "-%", "%%", "%1",
+               "400000 EB",
+               "4.11e4 EB",
+       } {
+               var n ByteSizeOrPercent
+               err := yaml.Unmarshal([]byte(testcase+"\n"), &n)
+               c.Logf("%s => error: %v", testcase, err)
                c.Check(err, check.NotNil)
        }
 }
index 05176214ae1eb188fd4c8a0113b76e115f095a99..7bc3d5bc420404559939247b04cb4b7849c620d6 100644 (file)
@@ -16,17 +16,22 @@ import (
        "io/fs"
        "io/ioutil"
        "log"
+       "math"
        "math/big"
+       mathrand "math/rand"
        "net"
        "net/http"
        "net/url"
        "os"
        "regexp"
+       "strconv"
        "strings"
+       "sync"
        "sync/atomic"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "github.com/hashicorp/go-retryablehttp"
 )
 
 // A Client is an HTTP client with an API endpoint and a set of
@@ -65,11 +70,18 @@ type Client struct {
 
        // Timeout for requests. NewClientFromConfig and
        // NewClientFromEnv return a Client with a default 5 minute
-       // timeout.  To disable this timeout and rely on each
-       // http.Request's context deadline instead, set Timeout to
-       // zero.
+       // timeout. Within this time, retryable errors are
+       // automatically retried with exponential backoff.
+       //
+       // To disable automatic retries, set Timeout to zero and use a
+       // context deadline to establish a maximum request time.
        Timeout time.Duration
 
+       // Maximum disk cache size in bytes or percent of total
+       // filesystem size. If zero, use default, currently 10% of
+       // filesystem size.
+       DiskCacheSize ByteSizeOrPercent
+
        dd *DiscoveryDocument
 
        defaultRequestID string
@@ -82,7 +94,10 @@ type Client struct {
        // differs from an outgoing connection limit (a feature
        // provided by http.Transport) when concurrent calls are
        // multiplexed on a single http2 connection.
-       requestLimiter requestLimiter
+       //
+       // getRequestLimiter() should always be used, because this can
+       // be nil.
+       requestLimiter *requestLimiter
 
        last503 atomic.Value
 }
@@ -139,11 +154,13 @@ func NewClientFromConfig(cluster *Cluster) (*Client, error) {
                }
        }
        return &Client{
-               Client:   hc,
-               Scheme:   ctrlURL.Scheme,
-               APIHost:  ctrlURL.Host,
-               Insecure: cluster.TLS.Insecure,
-               Timeout:  5 * time.Minute,
+               Client:         hc,
+               Scheme:         ctrlURL.Scheme,
+               APIHost:        ctrlURL.Host,
+               Insecure:       cluster.TLS.Insecure,
+               Timeout:        5 * time.Minute,
+               DiskCacheSize:  cluster.Collections.WebDAVCache.DiskCacheSize,
+               requestLimiter: &requestLimiter{maxlimit: int64(cluster.API.MaxConcurrentRequests / 4)},
        }, nil
 }
 
@@ -229,9 +246,13 @@ func NewClientFromEnv() *Client {
 
 var reqIDGen = httpserver.IDGenerator{Prefix: "req-"}
 
-// Do adds Authorization and X-Request-Id headers, delays in order to
-// comply with rate-limiting restrictions, and then calls
-// (*http.Client)Do().
+var nopCancelFunc context.CancelFunc = func() {}
+
+var reqErrorRe = regexp.MustCompile(`net/http: invalid header `)
+
+// Do augments (*http.Client)Do(): adds Authorization and X-Request-Id
+// headers, delays in order to comply with rate-limiting restrictions,
+// and retries failed requests when appropriate.
 func (c *Client) Do(req *http.Request) (*http.Response, error) {
        ctx := req.Context()
        if auth, _ := ctx.Value(contextKeyAuthorization{}).(string); auth != "" {
@@ -255,39 +276,101 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
                        req.Header.Set("X-Request-Id", reqid)
                }
        }
-       var cancel context.CancelFunc
+
+       rreq, err := retryablehttp.FromRequest(req)
+       if err != nil {
+               return nil, err
+       }
+
+       cancel := nopCancelFunc
+       var lastResp *http.Response
+       var lastRespBody io.ReadCloser
+       var lastErr error
+       var checkRetryCalled int
+
+       rclient := retryablehttp.NewClient()
+       rclient.HTTPClient = c.httpClient()
+       rclient.Backoff = exponentialBackoff
        if c.Timeout > 0 {
+               rclient.RetryWaitMax = c.Timeout / 10
+               rclient.RetryMax = 32
                ctx, cancel = context.WithDeadline(ctx, time.Now().Add(c.Timeout))
-               req = req.WithContext(ctx)
+               rreq = rreq.WithContext(ctx)
        } else {
-               cancel = context.CancelFunc(func() {})
+               rclient.RetryMax = 0
        }
+       rclient.CheckRetry = func(ctx context.Context, resp *http.Response, respErr error) (bool, error) {
+               checkRetryCalled++
+               if c.getRequestLimiter().Report(resp, respErr) {
+                       c.last503.Store(time.Now())
+               }
+               if c.Timeout == 0 {
+                       return false, nil
+               }
+               // This check can be removed when
+               // https://github.com/hashicorp/go-retryablehttp/pull/210
+               // (or equivalent) is merged and we update go.mod.
+               // Until then, it is needed to pass
+               // TestNonRetryableStdlibError.
+               if respErr != nil && reqErrorRe.MatchString(respErr.Error()) {
+                       return false, nil
+               }
+               retrying, err := retryablehttp.DefaultRetryPolicy(ctx, resp, respErr)
+               if retrying {
+                       lastResp, lastRespBody, lastErr = resp, nil, respErr
+                       if respErr == nil {
+                               // Save the response and body so we
+                               // can return it instead of "deadline
+                               // exceeded". retryablehttp.Client
+                               // will drain and discard resp.body,
+                               // so we need to stash it separately.
+                               buf, err := ioutil.ReadAll(resp.Body)
+                               if err == nil {
+                                       lastRespBody = io.NopCloser(bytes.NewReader(buf))
+                               } else {
+                                       lastResp, lastErr = nil, err
+                               }
+                       }
+               }
+               return retrying, err
+       }
+       rclient.Logger = nil
 
-       c.requestLimiter.Acquire(ctx)
+       limiter := c.getRequestLimiter()
+       limiter.Acquire(ctx)
        if ctx.Err() != nil {
-               c.requestLimiter.Release()
+               limiter.Release()
+               cancel()
                return nil, ctx.Err()
        }
-
-       // Attach Release() to cancel func, see cancelOnClose below.
-       cancelOrig := cancel
-       cancel = func() {
-               c.requestLimiter.Release()
-               cancelOrig()
-       }
-
-       resp, err := c.httpClient().Do(req)
-       if c.requestLimiter.Report(resp, err) {
-               c.last503.Store(time.Now())
+       resp, err := rclient.Do(rreq)
+       if (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) && (lastResp != nil || lastErr != nil) {
+               resp = lastResp
+               err = lastErr
+               if checkRetryCalled > 0 && err != nil {
+                       // Mimic retryablehttp's "giving up after X
+                       // attempts" message, even if we gave up
+                       // because of time rather than maxretries.
+                       err = fmt.Errorf("%s %s giving up after %d attempt(s): %w", req.Method, req.URL.String(), checkRetryCalled, err)
+               }
+               if resp != nil {
+                       resp.Body = lastRespBody
+               }
        }
-       if err == nil {
-               // We need to call cancel() eventually, but we can't
-               // use "defer cancel()" because the context has to
-               // stay alive until the caller has finished reading
-               // the response body.
-               resp.Body = cancelOnClose{ReadCloser: resp.Body, cancel: cancel}
-       } else {
+       if err != nil {
+               limiter.Release()
                cancel()
+               return nil, err
+       }
+       // We need to call cancel() eventually, but we can't use
+       // "defer cancel()" because the context has to stay alive
+       // until the caller has finished reading the response body.
+       resp.Body = cancelOnClose{
+               ReadCloser: resp.Body,
+               cancel: func() {
+                       limiter.Release()
+                       cancel()
+               },
        }
        return resp, err
 }
@@ -299,6 +382,30 @@ func (c *Client) Last503() time.Time {
        return t
 }
 
+// globalRequestLimiter entries (one for each APIHost) don't have a
+// hard limit on outgoing connections, but do add a delay and reduce
+// concurrency after 503 errors.
+var (
+       globalRequestLimiter     = map[string]*requestLimiter{}
+       globalRequestLimiterLock sync.Mutex
+)
+
+// Get this client's requestLimiter, or a global requestLimiter
+// singleton for c's APIHost if this client doesn't have its own.
+func (c *Client) getRequestLimiter() *requestLimiter {
+       if c.requestLimiter != nil {
+               return c.requestLimiter
+       }
+       globalRequestLimiterLock.Lock()
+       defer globalRequestLimiterLock.Unlock()
+       limiter := globalRequestLimiter[c.APIHost]
+       if limiter == nil {
+               limiter = &requestLimiter{}
+               globalRequestLimiter[c.APIHost] = limiter
+       }
+       return limiter
+}
+
 // cancelOnClose calls a provided CancelFunc when its wrapped
 // ReadCloser's Close() method is called.
 type cancelOnClose struct {
@@ -321,6 +428,40 @@ func isRedirectStatus(code int) bool {
        }
 }
 
+const minExponentialBackoffBase = time.Second
+
+// Implements retryablehttp.Backoff using the server-provided
+// Retry-After header if available, otherwise nearly-full jitter
+// exponential backoff (similar to
+// https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/),
+// in all cases respecting the provided min and max.
+func exponentialBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
+       if attemptNum > 0 && min < minExponentialBackoffBase {
+               min = minExponentialBackoffBase
+       }
+       var t time.Duration
+       if resp != nil && (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable) {
+               if s := resp.Header.Get("Retry-After"); s != "" {
+                       if sleep, err := strconv.ParseInt(s, 10, 64); err == nil {
+                               t = time.Second * time.Duration(sleep)
+                       } else if stamp, err := time.Parse(time.RFC1123, s); err == nil {
+                               t = stamp.Sub(time.Now())
+                       }
+               }
+       }
+       if t == 0 {
+               jitter := mathrand.New(mathrand.NewSource(int64(time.Now().Nanosecond()))).Float64()
+               t = min + time.Duration((math.Pow(2, float64(attemptNum))*float64(min)-float64(min))*jitter)
+       }
+       if t < min {
+               return min
+       } else if t > max {
+               return max
+       } else {
+               return t
+       }
+}
+
 // DoAndDecode performs req and unmarshals the response (which must be
 // JSON) into dst. Use this instead of RequestAndDecode if you need
 // more control of the http.Request object.
@@ -453,6 +594,12 @@ func (c *Client) RequestAndDecodeContext(ctx context.Context, dst interface{}, m
        if err != nil {
                return err
        }
+       if dst == nil {
+               if urlValues == nil {
+                       urlValues = url.Values{}
+               }
+               urlValues["select"] = []string{`["uuid"]`}
+       }
        if urlValues == nil {
                // Nothing to send
        } else if body != nil || ((method == "GET" || method == "HEAD") && len(urlValues.Encode()) < 1000) {
@@ -526,7 +673,11 @@ func (c *Client) apiURL(path string) string {
        if scheme == "" {
                scheme = "https"
        }
-       return scheme + "://" + c.APIHost + "/" + path
+       // Double-slash in URLs tend to cause subtle hidden problems
+       // (e.g., they can behave differently when a load balancer is
+       // in the picture). Here we ensure exactly one "/" regardless
+       // of whether the given APIHost or path has a superfluous one.
+       return scheme + "://" + strings.TrimSuffix(c.APIHost, "/") + "/" + strings.TrimPrefix(path, "/")
 }
 
 // DiscoveryDocument is the Arvados server's description of itself.
@@ -537,6 +688,7 @@ type DiscoveryDocument struct {
        GitURL                       string              `json:"gitUrl"`
        Schemas                      map[string]Schema   `json:"schemas"`
        Resources                    map[string]Resource `json:"resources"`
+       Revision                     string              `json:"revision"`
 }
 
 type Resource struct {
index 2363803cab1de157f4074d3a2770f2cc0c9201ca..55e2f998c4a88589efe1d917272e152a29210247 100644 (file)
@@ -6,14 +6,19 @@ package arvados
 
 import (
        "bytes"
+       "context"
        "fmt"
        "io/ioutil"
+       "math"
+       "math/rand"
        "net/http"
+       "net/http/httptest"
        "net/url"
        "os"
        "strings"
        "sync"
        "testing/iotest"
+       "time"
 
        check "gopkg.in/check.v1"
 )
@@ -165,6 +170,44 @@ func (*clientSuite) TestAnythingToValues(c *check.C) {
        }
 }
 
+// select=["uuid"] is added automatically when RequestAndDecode's
+// destination argument is nil.
+func (*clientSuite) TestAutoSelectUUID(c *check.C) {
+       var req *http.Request
+       var err error
+       server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               c.Check(r.ParseForm(), check.IsNil)
+               req = r
+               w.Write([]byte("{}"))
+       }))
+       client := Client{
+               APIHost:   strings.TrimPrefix(server.URL, "https://"),
+               AuthToken: "zzz",
+               Insecure:  true,
+               Timeout:   2 * time.Second,
+       }
+
+       req = nil
+       err = client.RequestAndDecode(nil, http.MethodPost, "test", nil, nil)
+       c.Check(err, check.IsNil)
+       c.Check(req.FormValue("select"), check.Equals, `["uuid"]`)
+
+       req = nil
+       err = client.RequestAndDecode(nil, http.MethodGet, "test", nil, nil)
+       c.Check(err, check.IsNil)
+       c.Check(req.FormValue("select"), check.Equals, `["uuid"]`)
+
+       req = nil
+       err = client.RequestAndDecode(nil, http.MethodGet, "test", nil, map[string]interface{}{"select": []string{"blergh"}})
+       c.Check(err, check.IsNil)
+       c.Check(req.FormValue("select"), check.Equals, `["uuid"]`)
+
+       req = nil
+       err = client.RequestAndDecode(&struct{}{}, http.MethodGet, "test", nil, map[string]interface{}{"select": []string{"blergh"}})
+       c.Check(err, check.IsNil)
+       c.Check(req.FormValue("select"), check.Equals, `["blergh"]`)
+}
+
 func (*clientSuite) TestLoadConfig(c *check.C) {
        oldenv := os.Environ()
        defer func() {
@@ -217,3 +260,198 @@ func (*clientSuite) TestLoadConfig(c *check.C) {
        c.Check(client.APIHost, check.Equals, "[::]:3")
        c.Check(client.Insecure, check.Equals, false)
 }
+
+var _ = check.Suite(&clientRetrySuite{})
+
+type clientRetrySuite struct {
+       server     *httptest.Server
+       client     Client
+       reqs       []*http.Request
+       respStatus chan int
+       respDelay  time.Duration
+
+       origLimiterQuietPeriod time.Duration
+}
+
+func (s *clientRetrySuite) SetUpTest(c *check.C) {
+       // Test server: delay and return errors until a final status
+       // appears on the respStatus channel.
+       s.origLimiterQuietPeriod = requestLimiterQuietPeriod
+       requestLimiterQuietPeriod = time.Second / 100
+       s.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               s.reqs = append(s.reqs, r)
+               delay := s.respDelay
+               if delay == 0 {
+                       delay = time.Duration(rand.Int63n(int64(time.Second / 10)))
+               }
+               timer := time.NewTimer(delay)
+               defer timer.Stop()
+               select {
+               case code, ok := <-s.respStatus:
+                       if !ok {
+                               code = http.StatusOK
+                       }
+                       w.WriteHeader(code)
+                       w.Write([]byte(`{}`))
+               case <-timer.C:
+                       w.WriteHeader(http.StatusServiceUnavailable)
+               }
+       }))
+       s.reqs = nil
+       s.respStatus = make(chan int, 1)
+       s.client = Client{
+               APIHost:   s.server.URL[8:],
+               AuthToken: "zzz",
+               Insecure:  true,
+               Timeout:   2 * time.Second,
+       }
+}
+
+func (s *clientRetrySuite) TearDownTest(c *check.C) {
+       s.server.Close()
+       requestLimiterQuietPeriod = s.origLimiterQuietPeriod
+}
+
+func (s *clientRetrySuite) TestOK(c *check.C) {
+       s.respStatus <- http.StatusOK
+       err := s.client.RequestAndDecode(&struct{}{}, http.MethodGet, "test", nil, nil)
+       c.Check(err, check.IsNil)
+       c.Check(s.reqs, check.HasLen, 1)
+}
+
+func (s *clientRetrySuite) TestNetworkError(c *check.C) {
+       // Close the stub server to produce a "connection refused" error.
+       s.server.Close()
+
+       start := time.Now()
+       timeout := time.Second
+       ctx, cancel := context.WithDeadline(context.Background(), start.Add(timeout))
+       defer cancel()
+       s.client.Timeout = timeout * 2
+       err := s.client.RequestAndDecodeContext(ctx, &struct{}{}, http.MethodGet, "test", nil, nil)
+       c.Check(err, check.ErrorMatches, `.*dial tcp .* connection refused.*`)
+       delta := time.Since(start)
+       c.Check(delta > timeout, check.Equals, true, check.Commentf("time.Since(start) == %v, timeout = %v", delta, timeout))
+}
+
+func (s *clientRetrySuite) TestNonRetryableError(c *check.C) {
+       s.respStatus <- http.StatusBadRequest
+       err := s.client.RequestAndDecode(&struct{}{}, http.MethodGet, "test", nil, nil)
+       c.Check(err, check.ErrorMatches, `.*400 Bad Request.*`)
+       c.Check(s.reqs, check.HasLen, 1)
+}
+
+// as of 0.7.2., retryablehttp does not recognize this as a
+// non-retryable error.
+func (s *clientRetrySuite) TestNonRetryableStdlibError(c *check.C) {
+       s.respStatus <- http.StatusOK
+       req, err := http.NewRequest(http.MethodGet, "https://"+s.client.APIHost+"/test", nil)
+       c.Assert(err, check.IsNil)
+       req.Header.Set("Good-Header", "T\033rrible header value")
+       err = s.client.DoAndDecode(&struct{}{}, req)
+       c.Check(err, check.ErrorMatches, `.*after 1 attempt.*net/http: invalid header .*`)
+       if !c.Check(s.reqs, check.HasLen, 0) {
+               c.Logf("%v", s.reqs[0])
+       }
+}
+
+func (s *clientRetrySuite) TestNonRetryableAfter503s(c *check.C) {
+       time.AfterFunc(time.Second, func() { s.respStatus <- http.StatusNotFound })
+       err := s.client.RequestAndDecode(&struct{}{}, http.MethodGet, "test", nil, nil)
+       c.Check(err, check.ErrorMatches, `.*404 Not Found.*`)
+}
+
+func (s *clientRetrySuite) TestOKAfter503s(c *check.C) {
+       start := time.Now()
+       delay := time.Second
+       time.AfterFunc(delay, func() { s.respStatus <- http.StatusOK })
+       err := s.client.RequestAndDecode(&struct{}{}, http.MethodGet, "test", nil, nil)
+       c.Check(err, check.IsNil)
+       c.Check(len(s.reqs) > 1, check.Equals, true, check.Commentf("len(s.reqs) == %d", len(s.reqs)))
+       c.Check(time.Since(start) > delay, check.Equals, true)
+}
+
+func (s *clientRetrySuite) TestTimeoutAfter503(c *check.C) {
+       s.respStatus <- http.StatusServiceUnavailable
+       s.respDelay = time.Second * 2
+       s.client.Timeout = time.Second / 2
+       err := s.client.RequestAndDecode(&struct{}{}, http.MethodGet, "test", nil, nil)
+       c.Check(err, check.ErrorMatches, `.*503 Service Unavailable.*`)
+       c.Check(s.reqs, check.HasLen, 2)
+}
+
+func (s *clientRetrySuite) Test503Forever(c *check.C) {
+       err := s.client.RequestAndDecode(&struct{}{}, http.MethodGet, "test", nil, nil)
+       c.Check(err, check.ErrorMatches, `.*503 Service Unavailable.*`)
+       c.Check(len(s.reqs) > 1, check.Equals, true, check.Commentf("len(s.reqs) == %d", len(s.reqs)))
+}
+
+func (s *clientRetrySuite) TestContextAlreadyCanceled(c *check.C) {
+       ctx, cancel := context.WithCancel(context.Background())
+       cancel()
+       err := s.client.RequestAndDecodeContext(ctx, &struct{}{}, http.MethodGet, "test", nil, nil)
+       c.Check(err, check.Equals, context.Canceled)
+}
+
+func (s *clientRetrySuite) TestExponentialBackoff(c *check.C) {
+       var min, max time.Duration
+       min, max = time.Second, 64*time.Second
+
+       t := exponentialBackoff(min, max, 0, nil)
+       c.Check(t, check.Equals, min)
+
+       for e := float64(1); e < 5; e += 1 {
+               ok := false
+               for i := 0; i < 20; i++ {
+                       t = exponentialBackoff(min, max, int(e), nil)
+                       // Every returned value must be between min and min(2^e, max)
+                       c.Check(t >= min, check.Equals, true)
+                       c.Check(t <= min*time.Duration(math.Pow(2, e)), check.Equals, true)
+                       c.Check(t <= max, check.Equals, true)
+                       // Check that jitter is actually happening by
+                       // checking that at least one in 20 trials is
+                       // between min*2^(e-.75) and min*2^(e-.25)
+                       jittermin := time.Duration(float64(min) * math.Pow(2, e-0.75))
+                       jittermax := time.Duration(float64(min) * math.Pow(2, e-0.25))
+                       c.Logf("min %v max %v e %v jittermin %v jittermax %v t %v", min, max, e, jittermin, jittermax, t)
+                       if t > jittermin && t < jittermax {
+                               ok = true
+                               break
+                       }
+               }
+               c.Check(ok, check.Equals, true)
+       }
+
+       for i := 0; i < 20; i++ {
+               t := exponentialBackoff(min, max, 100, nil)
+               c.Check(t < max, check.Equals, true)
+       }
+
+       for _, trial := range []struct {
+               retryAfter string
+               expect     time.Duration
+       }{
+               {"1", time.Second * 4},             // minimum enforced
+               {"5", time.Second * 5},             // header used
+               {"55", time.Second * 10},           // maximum enforced
+               {"eleventy-nine", time.Second * 4}, // invalid header, exponential backoff used
+               {time.Now().UTC().Add(time.Second).Format(time.RFC1123), time.Second * 4},  // minimum enforced
+               {time.Now().UTC().Add(time.Minute).Format(time.RFC1123), time.Second * 10}, // maximum enforced
+               {time.Now().UTC().Add(-time.Minute).Format(time.RFC1123), time.Second * 4}, // minimum enforced
+       } {
+               c.Logf("trial %+v", trial)
+               t := exponentialBackoff(time.Second*4, time.Second*10, 0, &http.Response{
+                       StatusCode: http.StatusTooManyRequests,
+                       Header:     http.Header{"Retry-After": {trial.retryAfter}}})
+               c.Check(t, check.Equals, trial.expect)
+       }
+       t = exponentialBackoff(time.Second*4, time.Second*10, 0, &http.Response{
+               StatusCode: http.StatusTooManyRequests,
+       })
+       c.Check(t, check.Equals, time.Second*4)
+
+       t = exponentialBackoff(0, max, 0, nil)
+       c.Check(t, check.Equals, time.Duration(0))
+       t = exponentialBackoff(0, max, 1, nil)
+       c.Check(t, check.Not(check.Equals), time.Duration(0))
+}
index 4466b0a4deffda52a71d9084dc324c1a1df807e6..698ee20d8c6bcc58119a02e0330f19ca0e7a64ee 100644 (file)
@@ -63,8 +63,8 @@ func (sc *Config) GetCluster(clusterID string) (*Cluster, error) {
 
 type WebDAVCacheConfig struct {
        TTL                Duration
-       MaxBlockEntries    int
-       MaxCollectionBytes int64
+       DiskCacheSize      ByteSizeOrPercent
+       MaxCollectionBytes ByteSize
        MaxSessions        int
 }
 
@@ -99,7 +99,11 @@ type Cluster struct {
                DisabledAPIs                     StringSet
                MaxIndexDatabaseRead             int
                MaxItemsPerResponse              int
+               MaxConcurrentRailsRequests       int
                MaxConcurrentRequests            int
+               MaxQueuedRequests                int
+               MaxGatewayTunnels                int
+               MaxQueueTimeForLockRequests      Duration
                LogCreateRequestFraction         float64
                MaxKeepBlobBuffers               int
                MaxRequestAmplification          int
@@ -147,6 +151,8 @@ type Cluster struct {
                BalanceCollectionBuffers int
                BalanceTimeout           Duration
                BalanceUpdateLimit       int
+               BalancePullLimit         int
+               BalanceTrashLimit        int
 
                WebDAVCache WebDAVCacheConfig
 
@@ -221,9 +227,10 @@ type Cluster struct {
                EmailFrom                      string
        }
        SystemLogs struct {
-               LogLevel                string
-               Format                  string
-               MaxRequestLogParamsSize int
+               LogLevel                  string
+               Format                    string
+               MaxRequestLogParamsSize   int
+               RequestQueueDumpDirectory string
        }
        TLS struct {
                Certificate string
@@ -255,35 +262,25 @@ type Cluster struct {
                RoleGroupsVisibleToAll                bool
                CanCreateRoleGroups                   bool
                ActivityLoggingPeriod                 Duration
+               SyncIgnoredGroups                     []string
+               SyncRequiredGroups                    []string
+               SyncUserAccounts                      bool
+               SyncUserAPITokens                     bool
+               SyncUserGroups                        bool
+               SyncUserSSHKeys                       bool
        }
        StorageClasses map[string]StorageClassConfig
        Volumes        map[string]Volume
        Workbench      struct {
-               ActivationContactLink            string
-               APIClientConnectTimeout          Duration
-               APIClientReceiveTimeout          Duration
-               APIResponseCompression           bool
-               ApplicationMimetypesWithViewIcon StringSet
-               ArvadosDocsite                   string
-               ArvadosPublicDataDocURL          string
-               DefaultOpenIdPrefix              string
-               DisableSharingURLsUI             bool
-               EnableGettingStartedPopup        bool
-               EnablePublicProjectsPage         bool
-               FileViewersConfigURL             string
-               LogViewerMaxBytes                ByteSize
-               MultiSiteSearch                  string
-               ProfilingEnabled                 bool
-               Repositories                     bool
-               RepositoryCache                  string
-               RunningJobLogRecordsToFetch      int
-               SecretKeyBase                    string
-               ShowRecentCollectionsOnDashboard bool
-               ShowUserAgreementInline          bool
-               ShowUserNotifications            bool
-               SiteName                         string
-               Theme                            string
-               UserProfileFormFields            map[string]struct {
+               ActivationContactLink   string
+               ArvadosDocsite          string
+               ArvadosPublicDataDocURL string
+               DisableSharingURLsUI    bool
+               FileViewersConfigURL    string
+               ShowUserAgreementInline bool
+               SiteName                string
+               Theme                   string
+               UserProfileFormFields   map[string]struct {
                        Type                 string
                        FormFieldTitle       string
                        FormFieldDescription string
@@ -307,12 +304,13 @@ type StorageClassConfig struct {
 }
 
 type Volume struct {
-       AccessViaHosts   map[URL]VolumeAccess
-       ReadOnly         bool
-       Replication      int
-       StorageClasses   map[string]bool
-       Driver           string
-       DriverParameters json.RawMessage
+       AccessViaHosts         map[URL]VolumeAccess
+       ReadOnly               bool
+       AllowTrashWhenReadOnly bool
+       Replication            int
+       StorageClasses         map[string]bool
+       Driver                 string
+       DriverParameters       json.RawMessage
 }
 
 type S3VolumeDriverParameters struct {
@@ -324,7 +322,6 @@ type S3VolumeDriverParameters struct {
        Bucket             string
        LocationConstraint bool
        V2Signature        bool
-       UseAWSS3v2Driver   bool
        IndexPageSize      int
        ConnectTimeout     Duration
        ReadTimeout        Duration
@@ -507,6 +504,7 @@ type ContainersConfig struct {
        SupportedDockerImageFormats   StringSet
        AlwaysUsePreemptibleInstances bool
        PreemptiblePriceFactor        float64
+       MaximumPriceFactor            float64
        RuntimeEngine                 string
        LocalKeepBlobBuffersPerVCPU   int
        LocalKeepLogsToContainerLog   string
@@ -547,9 +545,11 @@ type ContainersConfig struct {
                }
        }
        LSF struct {
-               BsubSudoUser      string
-               BsubArgumentsList []string
-               BsubCUDAArguments []string
+               BsubSudoUser       string
+               BsubArgumentsList  []string
+               BsubCUDAArguments  []string
+               MaxRunTimeOverhead Duration
+               MaxRunTimeDefault  Duration
        }
 }
 
@@ -557,12 +557,15 @@ type CloudVMsConfig struct {
        Enable bool
 
        BootProbeCommand               string
+       InstanceInitCommand            string
        DeployRunnerBinary             string
+       DeployPublicKey                bool
        ImageID                        string
        MaxCloudOpsPerSecond           int
        MaxProbesPerSecond             int
        MaxConcurrentInstanceCreateOps int
        MaxInstances                   int
+       InitialQuotaEstimate           int
        SupervisorFraction             float64
        PollInterval                   Duration
        ProbeInterval                  Duration
index 7b31726aa06f1d2fcd46eae82f51e6aec97cf290..91c8fbfe2936d972b8c5f196467072a9d7715b84 100644 (file)
@@ -19,6 +19,7 @@ type Container struct {
        Cwd                       string                 `json:"cwd"`
        Environment               map[string]string      `json:"environment"`
        LockedByUUID              string                 `json:"locked_by_uuid"`
+       LockCount                 int                    `json:"lock_count"`
        Mounts                    map[string]Mount       `json:"mounts"`
        Output                    string                 `json:"output"`
        OutputPath                string                 `json:"output_path"`
@@ -159,3 +160,9 @@ const (
        ContainerRequestStateCommitted  = ContainerRequestState("Committed")
        ContainerRequestStateFinal      = ContainerRequestState("Final")
 )
+
+type ContainerStatus struct {
+       UUID             string         `json:"uuid"`
+       State            ContainerState `json:"container_state"`
+       SchedulingStatus string         `json:"scheduling_status"`
+}
index 274d20702287ed464d4ea8f3796e528c3f61b30b..430a0d4c9be4f69e4f86adc3cb93d2549c8fc930 100644 (file)
@@ -13,6 +13,7 @@ import (
        "net/http"
        "os"
        "path"
+       "path/filepath"
        "strings"
        "sync"
        "time"
@@ -387,17 +388,28 @@ func (n *treenode) Size() int64 {
 }
 
 func (n *treenode) FileInfo() os.FileInfo {
-       n.Lock()
-       defer n.Unlock()
-       n.fileinfo.size = int64(len(n.inodes))
-       return n.fileinfo
+       n.RLock()
+       defer n.RUnlock()
+       fi := n.fileinfo
+       fi.size = int64(len(n.inodes))
+       return fi
 }
 
 func (n *treenode) Readdir() (fi []os.FileInfo, err error) {
+       // We need RLock to safely read n.inodes, but we must release
+       // it before calling FileInfo() on the child nodes. Otherwise,
+       // we risk deadlock when filter groups A and B match each
+       // other, concurrent Readdir() calls try to RLock them in
+       // opposite orders, and one cannot be RLocked a second time
+       // because a third caller is waiting for a write lock.
        n.RLock()
-       defer n.RUnlock()
-       fi = make([]os.FileInfo, 0, len(n.inodes))
+       inodes := make([]inode, 0, len(n.inodes))
        for _, inode := range n.inodes {
+               inodes = append(inodes, inode)
+       }
+       n.RUnlock()
+       fi = make([]os.FileInfo, 0, len(inodes))
+       for _, inode := range inodes {
                fi = append(fi, inode.FileInfo())
        }
        return
@@ -468,7 +480,8 @@ func (fs *fileSystem) openFile(name string, flag int, perm os.FileMode) (*fileha
                return nil, ErrSyncNotSupported
        }
        dirname, name := path.Split(name)
-       parent, err := rlookup(fs.root, dirname)
+       ancestors := map[inode]bool{}
+       parent, err := rlookup(fs.root, dirname, ancestors)
        if err != nil {
                return nil, err
        }
@@ -533,6 +546,24 @@ func (fs *fileSystem) openFile(name string, flag int, perm os.FileMode) (*fileha
                        return nil, err
                }
        }
+       // If n and one of its parents/ancestors are [hardlinks to]
+       // the same node (e.g., a filter group that matches itself),
+       // open an "empty directory" node instead, so the inner
+       // hardlink appears empty. This is needed to ensure
+       // Open("a/b/c/x/x").Readdir() appears empty, matching the
+       // behavior of rlookup("a/b/c/x/x/z") => ErrNotExist.
+       if hl, ok := n.(*hardlink); (ok && ancestors[hl.inode]) || ancestors[n] {
+               n = &treenode{
+                       fs:     n.FS(),
+                       parent: parent,
+                       inodes: nil,
+                       fileinfo: fileinfo{
+                               name:    name,
+                               modTime: time.Now(),
+                               mode:    0555 | os.ModeDir,
+                       },
+               }
+       }
        return &filehandle{
                inode:    n,
                append:   flag&os.O_APPEND != 0,
@@ -551,7 +582,7 @@ func (fs *fileSystem) Create(name string) (File, error) {
 
 func (fs *fileSystem) Mkdir(name string, perm os.FileMode) error {
        dirname, name := path.Split(name)
-       n, err := rlookup(fs.root, dirname)
+       n, err := rlookup(fs.root, dirname, nil)
        if err != nil {
                return err
        }
@@ -575,7 +606,7 @@ func (fs *fileSystem) Mkdir(name string, perm os.FileMode) error {
 }
 
 func (fs *fileSystem) Stat(name string) (os.FileInfo, error) {
-       node, err := rlookup(fs.root, name)
+       node, err := rlookup(fs.root, name, nil)
        if err != nil {
                return nil, err
        }
@@ -704,7 +735,7 @@ func (fs *fileSystem) remove(name string, recursive bool) error {
        if name == "" || name == "." || name == ".." {
                return ErrInvalidArgument
        }
-       dir, err := rlookup(fs.root, dirname)
+       dir, err := rlookup(fs.root, dirname, nil)
        if err != nil {
                return err
        }
@@ -741,9 +772,31 @@ func (fs *fileSystem) MemorySize() int64 {
 // rlookup (recursive lookup) returns the inode for the file/directory
 // with the given name (which may contain "/" separators). If no such
 // file/directory exists, the returned node is nil.
-func rlookup(start inode, path string) (node inode, err error) {
+//
+// The visited map should be either nil or empty. If non-nil, all
+// nodes and hardlink targets visited by the given path will be added
+// to it.
+//
+// If a cycle is detected, the second occurrence of the offending node
+// will be replaced by an empty directory. For example, if "x" is a
+// filter group that matches itself, then rlookup("a/b/c/x") will
+// return the filter group, and rlookup("a/b/c/x/x") will return an
+// empty directory.
+func rlookup(start inode, path string, visited map[inode]bool) (node inode, err error) {
+       if visited == nil {
+               visited = map[inode]bool{}
+       }
        node = start
+       // Clean up ./ and ../ and double-slashes, but (unlike
+       // filepath.Clean) retain a trailing slash, because looking up
+       // ".../regularfile/" should fail.
+       trailingSlash := strings.HasSuffix(path, "/")
+       path = filepath.Clean(path)
+       if trailingSlash && path != "/" {
+               path += "/"
+       }
        for _, name := range strings.Split(path, "/") {
+               visited[node] = true
                if node.IsDir() {
                        if name == "." || name == "" {
                                continue
@@ -761,6 +814,24 @@ func rlookup(start inode, path string) (node inode, err error) {
                if node == nil || err != nil {
                        break
                }
+               checknode := node
+               if hardlinked, ok := checknode.(*hardlink); ok {
+                       checknode = hardlinked.inode
+               }
+               if visited[checknode] {
+                       node = &treenode{
+                               fs:     node.FS(),
+                               parent: node.Parent(),
+                               inodes: nil,
+                               fileinfo: fileinfo{
+                                       name:    name,
+                                       modTime: time.Now(),
+                                       mode:    0555 | os.ModeDir,
+                               },
+                       }
+               } else {
+                       visited[checknode] = true
+               }
        }
        if node == nil && err == nil {
                err = os.ErrNotExist
index 84ff69d6bd0ad16b053fd9ab6409fc04ee5c3024..052cc1aa37581aca2351200c5444304ce912571c 100644 (file)
@@ -457,7 +457,7 @@ func (fs *collectionFileSystem) Sync() error {
 }
 
 func (fs *collectionFileSystem) Flush(path string, shortBlocks bool) error {
-       node, err := rlookup(fs.fileSystem.root, path)
+       node, err := rlookup(fs.fileSystem.root, path, nil)
        if err != nil {
                return err
        }
index 2bb09995e16e476b3889abbdc1c61b6fc1abbd15..7f2244931877aff551e5f1c0687d81d4cc8c49ed 100644 (file)
@@ -48,7 +48,19 @@ func (ln *lookupnode) Readdir() ([]os.FileInfo, error) {
                        return nil, err
                }
                for _, child := range all {
-                       _, err = ln.treenode.Child(child.FileInfo().Name(), func(inode) (inode, error) {
+                       var name string
+                       if hl, ok := child.(*hardlink); ok && hl.inode == ln {
+                               // If child is a hardlink to its
+                               // parent, FileInfo()->RLock() will
+                               // deadlock, because we already have
+                               // the write lock. In this situation
+                               // we can safely access the hardlink's
+                               // name directly.
+                               name = hl.name
+                       } else {
+                               name = child.FileInfo().Name()
+                       }
+                       _, err = ln.treenode.Child(name, func(inode) (inode, error) {
                                return child, nil
                        })
                        if err != nil {
index a68e83945e348a0f3e740b7c0a313155917b795a..df1d06e753b35191f344499fe17c9953dc849daa 100644 (file)
@@ -35,10 +35,11 @@ func (fs *customFileSystem) projectsLoadOne(parent inode, uuid, name string) (in
                contents = CollectionList{}
                err = fs.RequestAndDecode(&contents, "GET", "arvados/v1/groups/"+uuid+"/contents", nil, ResourceListParams{
                        Count: "none",
+                       Order: "uuid",
                        Filters: []Filter{
                                {"name", "=", strings.Replace(name, subst, "/", -1)},
                                {"uuid", "is_a", []string{"arvados#collection", "arvados#group"}},
-                               {"groups.group_class", "=", "project"},
+                               {"groups.group_class", "in", []string{"project", "filter"}},
                        },
                        Select: []string{"uuid", "name", "modified_at", "properties"},
                })
@@ -104,7 +105,7 @@ func (fs *customFileSystem) projectsLoadAll(parent inode, uuid string) ([]inode,
                        {"uuid", "is_a", class},
                }
                if class == "arvados#group" {
-                       filters = append(filters, Filter{"group_class", "=", "project"})
+                       filters = append(filters, Filter{"groups.group_class", "in", []string{"project", "filter"}})
                }
 
                params := ResourceListParams{
index d3dac7a14f7424539c29c89811ee900eb47f603d..5c2eb33d121b553de5876b11650117a7968fa296 100644 (file)
@@ -42,61 +42,94 @@ func (sc *spyingClient) RequestAndDecode(dst interface{}, method, path string, b
 func (s *SiteFSSuite) TestFilterGroup(c *check.C) {
        // Make sure that a collection and group that match the filter are present,
        // and that a group that does not match the filter is not present.
-       s.fs.MountProject("fg", fixtureThisFilterGroupUUID)
 
-       _, err := s.fs.OpenFile("/fg/baz_file", 0, 0)
-       c.Assert(err, check.IsNil)
+       checkOpen := func(path string, exists bool) {
+               f, err := s.fs.Open(path)
+               if exists {
+                       if c.Check(err, check.IsNil) {
+                               c.Check(f.Close(), check.IsNil)
+                       }
+               } else {
+                       c.Check(err, check.Equals, os.ErrNotExist)
+               }
+       }
 
-       _, err = s.fs.OpenFile("/fg/A Subproject", 0, 0)
-       c.Assert(err, check.IsNil)
+       checkDirContains := func(parent, child string, exists bool) {
+               f, err := s.fs.Open(parent)
+               if !c.Check(err, check.IsNil) {
+                       return
+               }
+               ents, err := f.Readdir(-1)
+               if !c.Check(err, check.IsNil) {
+                       return
+               }
+               for _, ent := range ents {
+                       if !exists {
+                               c.Check(ent.Name(), check.Not(check.Equals), child)
+                               if child == "" {
+                                       // no children are expected
+                                       c.Errorf("child %q found in parent %q", child, parent)
+                               }
+                       } else if ent.Name() == child {
+                               return
+                       }
+               }
+               if exists {
+                       c.Errorf("child %q not found in parent %q", child, parent)
+               }
+       }
 
-       _, err = s.fs.OpenFile("/fg/A Project", 0, 0)
-       c.Assert(err, check.Not(check.IsNil))
+       checkOpen("/users/active/This filter group/baz_file", true)
+       checkOpen("/users/active/This filter group/A Subproject", true)
+       checkOpen("/users/active/This filter group/A Project", false)
+       s.fs.MountProject("fg", fixtureThisFilterGroupUUID)
+       checkOpen("/fg/baz_file", true)
+       checkOpen("/fg/A Subproject", true)
+       checkOpen("/fg/A Project", false)
+       s.fs.MountProject("home", "")
+       checkOpen("/home/A filter group with an is_a collection filter/baz_file", true)
+       checkOpen("/home/A filter group with an is_a collection filter/baz_file/baz", true)
+       checkOpen("/home/A filter group with an is_a collection filter/A Subproject", false)
+       checkOpen("/home/A filter group with an is_a collection filter/A Project", false)
 
        // An empty filter means everything that is visible should be returned.
+       checkOpen("/users/active/A filter group without filters/baz_file", true)
+       checkOpen("/users/active/A filter group without filters/A Subproject", true)
+       checkOpen("/users/active/A filter group without filters/A Project", true)
        s.fs.MountProject("fg2", fixtureAFilterGroupTwoUUID)
+       checkOpen("/fg2/baz_file", true)
+       checkOpen("/fg2/A Subproject", true)
+       checkOpen("/fg2/A Project", true)
 
-       _, err = s.fs.OpenFile("/fg2/baz_file", 0, 0)
-       c.Assert(err, check.IsNil)
-
-       _, err = s.fs.OpenFile("/fg2/A Subproject", 0, 0)
-       c.Assert(err, check.IsNil)
-
-       _, err = s.fs.OpenFile("/fg2/A Project", 0, 0)
-       c.Assert(err, check.IsNil)
+       // If a filter group matches itself or one of its ancestors,
+       // the matched item appears as an empty directory.
+       checkDirContains("/users/active/A filter group without filters", "A filter group without filters", true)
+       checkOpen("/users/active/A filter group without filters/A filter group without filters", true)
+       checkOpen("/users/active/A filter group without filters/A filter group without filters/baz_file", false)
+       checkDirContains("/users/active/A filter group without filters/A filter group without filters", "", false)
 
        // An 'is_a' 'arvados#collection' filter means only collections should be returned.
+       checkOpen("/users/active/A filter group with an is_a collection filter/baz_file", true)
+       checkOpen("/users/active/A filter group with an is_a collection filter/baz_file/baz", true)
+       checkOpen("/users/active/A filter group with an is_a collection filter/A Subproject", false)
+       checkOpen("/users/active/A filter group with an is_a collection filter/A Project", false)
        s.fs.MountProject("fg3", fixtureAFilterGroupThreeUUID)
-
-       _, err = s.fs.OpenFile("/fg3/baz_file", 0, 0)
-       c.Assert(err, check.IsNil)
-
-       _, err = s.fs.OpenFile("/fg3/A Subproject", 0, 0)
-       c.Assert(err, check.Not(check.IsNil))
+       checkOpen("/fg3/baz_file", true)
+       checkOpen("/fg3/baz_file/baz", true)
+       checkOpen("/fg3/A Subproject", false)
 
        // An 'exists' 'arvados#collection' filter means only collections with certain properties should be returned.
        s.fs.MountProject("fg4", fixtureAFilterGroupFourUUID)
-
-       _, err = s.fs.Stat("/fg4/collection with list property with odd values")
-       c.Assert(err, check.IsNil)
-
-       _, err = s.fs.Stat("/fg4/collection with list property with even values")
-       c.Assert(err, check.IsNil)
+       checkOpen("/fg4/collection with list property with odd values", true)
+       checkOpen("/fg4/collection with list property with even values", true)
+       checkOpen("/fg4/baz_file", false)
 
        // A 'contains' 'arvados#collection' filter means only collections with certain properties should be returned.
        s.fs.MountProject("fg5", fixtureAFilterGroupFiveUUID)
-
-       _, err = s.fs.Stat("/fg5/collection with list property with odd values")
-       c.Assert(err, check.IsNil)
-
-       _, err = s.fs.Stat("/fg5/collection with list property with string value")
-       c.Assert(err, check.IsNil)
-
-       _, err = s.fs.Stat("/fg5/collection with prop2 5")
-       c.Assert(err, check.Not(check.IsNil))
-
-       _, err = s.fs.Stat("/fg5/collection with list property with even values")
-       c.Assert(err, check.Not(check.IsNil))
+       checkOpen("/fg5/collection with list property with odd values", true)
+       checkOpen("/fg5/collection with list property with string value", true)
+       checkOpen("/fg5/collection with prop2 5", false)
+       checkOpen("/fg5/collection with list property with even values", false)
 }
 
 func (s *SiteFSSuite) TestCurrentUserHome(c *check.C) {
index a4a18837e00e7074521ce3e562fb30c21b84c1eb..d4f02416822a8b52494f62672a95bd00c1c04519 100644 (file)
@@ -123,6 +123,10 @@ func (fs *customFileSystem) ForwardSlashNameSubstitution(repl string) {
        fs.forwardSlashNameSubstitution = repl
 }
 
+func (fs *customFileSystem) MemorySize() int64 {
+       return fs.fileSystem.MemorySize() + fs.byIDRoot.MemorySize()
+}
+
 // SiteFileSystem returns a FileSystem that maps collections and other
 // Arvados objects onto a filesystem layout.
 //
@@ -386,3 +390,7 @@ func (hl *hardlink) FileInfo() os.FileInfo {
        }
        return fi
 }
+
+func (hl *hardlink) MemorySize() int64 {
+       return 64 + int64(len(hl.name))
+}
index c7d6b2a4646a33db1f0510b00d59bec812850fe6..2c86536b2f7f19a2308f8218910969b13fd22c31 100644 (file)
@@ -185,6 +185,16 @@ func (s *SiteFSSuite) TestByUUIDAndPDH(c *check.C) {
                names = append(names, fi.Name())
        }
        c.Check(names, check.DeepEquals, []string{"baz"})
+       f, err = s.fs.Open("/by_id/" + fixtureAProjectUUID + "/A Subproject/baz_file/baz")
+       c.Assert(err, check.IsNil)
+       err = f.Close()
+       c.Assert(err, check.IsNil)
+       _, err = s.fs.Open("/by_id/" + fixtureAProjectUUID + "/A Subproject/baz_file/baz/")
+       c.Assert(err, check.Equals, ErrNotADirectory)
+       _, err = s.fs.Open("/by_id/" + fixtureAProjectUUID + "/A Subproject/baz_file/baz/z")
+       c.Assert(err, check.Equals, ErrNotADirectory)
+       _, err = s.fs.Open("/by_id/" + fixtureAProjectUUID + "/A Subproject/baz_file/baz/..")
+       c.Assert(err, check.Equals, ErrNotADirectory)
 
        _, err = s.fs.OpenFile("/by_id/"+fixtureNonexistentCollection, os.O_RDWR|os.O_CREATE, 0755)
        c.Check(err, ErrorIs, ErrInvalidOperation)
diff --git a/sdk/go/arvados/keep_cache.go b/sdk/go/arvados/keep_cache.go
new file mode 100644 (file)
index 0000000..108081d
--- /dev/null
@@ -0,0 +1,744 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "bytes"
+       "context"
+       "crypto/md5"
+       "errors"
+       "fmt"
+       "io"
+       "io/fs"
+       "os"
+       "path/filepath"
+       "sort"
+       "strconv"
+       "strings"
+       "sync"
+       "sync/atomic"
+       "syscall"
+       "time"
+
+       "github.com/sirupsen/logrus"
+       "golang.org/x/sys/unix"
+)
+
+type KeepGateway interface {
+       ReadAt(locator string, dst []byte, offset int) (int, error)
+       BlockRead(ctx context.Context, opts BlockReadOptions) (int, error)
+       BlockWrite(ctx context.Context, opts BlockWriteOptions) (BlockWriteResponse, error)
+       LocalLocator(locator string) (string, error)
+}
+
+// DiskCache wraps KeepGateway, adding a disk-based cache layer.
+//
+// A DiskCache is automatically incorporated into the backend stack of
+// each keepclient.KeepClient. Most programs do not need to use
+// DiskCache directly.
+type DiskCache struct {
+       KeepGateway
+       Dir     string
+       MaxSize ByteSizeOrPercent
+       Logger  logrus.FieldLogger
+
+       *sharedCache
+       setupOnce sync.Once
+}
+
+var (
+       sharedCachesLock sync.Mutex
+       sharedCaches     = map[string]*sharedCache{}
+)
+
+// sharedCache has fields that coordinate the cache usage in a single
+// cache directory; it can be shared by multiple DiskCaches.
+//
+// This serves to share a single pool of held-open filehandles, a
+// single tidying goroutine, etc., even when the program (like
+// keep-web) uses multiple KeepGateway stacks that use different auth
+// tokens, etc.
+type sharedCache struct {
+       dir     string
+       maxSize ByteSizeOrPercent
+
+       tidying        int32 // see tidy()
+       defaultMaxSize int64
+
+       // The "heldopen" fields are used to open cache files for
+       // reading, and leave them open for future/concurrent ReadAt
+       // operations. See quickReadAt.
+       heldopen     map[string]*openFileEnt
+       heldopenMax  int
+       heldopenLock sync.Mutex
+
+       // The "writing" fields allow multiple concurrent/sequential
+       // ReadAt calls to be notified as a single
+       // read-block-from-backend-into-cache goroutine fills the
+       // cache file.
+       writing     map[string]*writeprogress
+       writingCond *sync.Cond
+       writingLock sync.Mutex
+
+       sizeMeasured    int64 // actual size on disk after last tidy(); zero if not measured yet
+       sizeEstimated   int64 // last measured size, plus files we have written since
+       lastFileCount   int64 // number of files on disk at last count
+       writesSinceTidy int64 // number of files written since last tidy()
+}
+
+type writeprogress struct {
+       cond    *sync.Cond     // broadcast whenever size or done changes
+       done    bool           // size and err have their final values
+       size    int            // bytes copied into cache file so far
+       err     error          // error encountered while copying from backend to cache
+       sharedf *os.File       // readable filehandle, usable if done && err==nil
+       readers sync.WaitGroup // goroutines that haven't finished reading from f yet
+}
+
+type openFileEnt struct {
+       sync.RWMutex
+       f   *os.File
+       err error // if err is non-nil, f should not be used.
+}
+
+const (
+       cacheFileSuffix = ".keepcacheblock"
+       tmpFileSuffix   = ".tmp"
+)
+
+func (cache *DiskCache) setup() {
+       sharedCachesLock.Lock()
+       defer sharedCachesLock.Unlock()
+       dir := cache.Dir
+       if sharedCaches[dir] == nil {
+               sharedCaches[dir] = &sharedCache{dir: dir, maxSize: cache.MaxSize}
+       }
+       cache.sharedCache = sharedCaches[dir]
+}
+
+func (cache *DiskCache) cacheFile(locator string) string {
+       hash := locator
+       if i := strings.Index(hash, "+"); i > 0 {
+               hash = hash[:i]
+       }
+       return filepath.Join(cache.dir, hash[:3], hash+cacheFileSuffix)
+}
+
+// Open a cache file, creating the parent dir if necessary.
+func (cache *DiskCache) openFile(name string, flags int) (*os.File, error) {
+       f, err := os.OpenFile(name, flags, 0600)
+       if os.IsNotExist(err) {
+               // Create the parent dir and try again. (We could have
+               // checked/created the parent dir before, but that
+               // would be less efficient in the much more common
+               // situation where it already exists.)
+               parent, _ := filepath.Split(name)
+               os.Mkdir(parent, 0700)
+               f, err = os.OpenFile(name, flags, 0600)
+       }
+       return f, err
+}
+
+// Rename a file, creating the new path's parent dir if necessary.
+func (cache *DiskCache) rename(old, new string) error {
+       if nil == os.Rename(old, new) {
+               return nil
+       }
+       parent, _ := filepath.Split(new)
+       os.Mkdir(parent, 0700)
+       return os.Rename(old, new)
+}
+
+func (cache *DiskCache) debugf(format string, args ...interface{}) {
+       logger := cache.Logger
+       if logger == nil {
+               return
+       }
+       logger.Debugf(format, args...)
+}
+
+// BlockWrite writes through to the wrapped KeepGateway, and (if
+// possible) retains a copy of the written block in the cache.
+func (cache *DiskCache) BlockWrite(ctx context.Context, opts BlockWriteOptions) (BlockWriteResponse, error) {
+       cache.setupOnce.Do(cache.setup)
+       unique := fmt.Sprintf("%x.%p%s", os.Getpid(), &opts, tmpFileSuffix)
+       tmpfilename := filepath.Join(cache.dir, "tmp", unique)
+       tmpfile, err := cache.openFile(tmpfilename, os.O_CREATE|os.O_EXCL|os.O_RDWR)
+       if err != nil {
+               cache.debugf("BlockWrite: open(%s) failed: %s", tmpfilename, err)
+               return cache.KeepGateway.BlockWrite(ctx, opts)
+       }
+
+       ctx, cancel := context.WithCancel(ctx)
+       defer cancel()
+       copyerr := make(chan error, 1)
+
+       // Start a goroutine to copy the caller's source data to
+       // tmpfile, a hash checker, and (via pipe) the wrapped
+       // KeepGateway.
+       pipereader, pipewriter := io.Pipe()
+       defer pipereader.Close()
+       go func() {
+               // Note this is a double-close (which is a no-op) in
+               // the happy path.
+               defer tmpfile.Close()
+               // Note this is a no-op in the happy path (the
+               // uniquely named tmpfilename will have been renamed).
+               defer os.Remove(tmpfilename)
+               defer pipewriter.Close()
+
+               // Copy from opts.Data or opts.Reader, depending on
+               // which was provided.
+               var src io.Reader
+               if opts.Data != nil {
+                       src = bytes.NewReader(opts.Data)
+               } else {
+                       src = opts.Reader
+               }
+
+               hashcheck := md5.New()
+               n, err := io.Copy(io.MultiWriter(tmpfile, pipewriter, hashcheck), src)
+               if err != nil {
+                       copyerr <- err
+                       cancel()
+                       return
+               } else if opts.DataSize > 0 && opts.DataSize != int(n) {
+                       copyerr <- fmt.Errorf("block size %d did not match provided size %d", n, opts.DataSize)
+                       cancel()
+                       return
+               }
+               err = tmpfile.Close()
+               if err != nil {
+                       // Don't rename tmpfile into place, but allow
+                       // the BlockWrite call to succeed if nothing
+                       // else goes wrong.
+                       return
+               }
+               hash := fmt.Sprintf("%x", hashcheck.Sum(nil))
+               if opts.Hash != "" && opts.Hash != hash {
+                       // Even if the wrapped KeepGateway doesn't
+                       // notice a problem, this should count as an
+                       // error.
+                       copyerr <- fmt.Errorf("block hash %s did not match provided hash %s", hash, opts.Hash)
+                       cancel()
+                       return
+               }
+               cachefilename := cache.cacheFile(hash)
+               err = cache.rename(tmpfilename, cachefilename)
+               if err != nil {
+                       cache.debugf("BlockWrite: rename(%s, %s) failed: %s", tmpfilename, cachefilename, err)
+               }
+               atomic.AddInt64(&cache.sizeEstimated, int64(n))
+               cache.gotidy()
+       }()
+
+       // Write through to the wrapped KeepGateway from the pipe,
+       // instead of the original reader.
+       newopts := opts
+       if newopts.DataSize == 0 {
+               newopts.DataSize = len(newopts.Data)
+       }
+       newopts.Reader = pipereader
+       newopts.Data = nil
+
+       resp, err := cache.KeepGateway.BlockWrite(ctx, newopts)
+       if len(copyerr) > 0 {
+               // If the copy-to-pipe goroutine failed, that error
+               // will be more helpful than the resulting "context
+               // canceled" or "read [from pipereader] failed" error
+               // seen by the wrapped KeepGateway.
+               //
+               // If the wrapped KeepGateway encounters an error
+               // before all the data is copied into the pipe, it
+               // stops reading from the pipe, which causes the
+               // io.Copy() in the goroutine to block until our
+               // deferred pipereader.Close() call runs. In that case
+               // len(copyerr)==0 here, so the wrapped KeepGateway
+               // error is the one we return to our caller.
+               err = <-copyerr
+       }
+       return resp, err
+}
+
+type funcwriter func([]byte) (int, error)
+
+func (fw funcwriter) Write(p []byte) (int, error) {
+       return fw(p)
+}
+
+// ReadAt reads the entire block from the wrapped KeepGateway into the
+// cache if needed, and copies the requested portion into the provided
+// slice.
+//
+// ReadAt returns as soon as the requested portion is available in the
+// cache. The remainder of the block may continue to be copied into
+// the cache in the background.
+func (cache *DiskCache) ReadAt(locator string, dst []byte, offset int) (int, error) {
+       cache.setupOnce.Do(cache.setup)
+       cachefilename := cache.cacheFile(locator)
+       if n, err := cache.quickReadAt(cachefilename, dst, offset); err == nil {
+               return n, nil
+       }
+
+       cache.writingLock.Lock()
+       progress := cache.writing[cachefilename]
+       if progress == nil {
+               // Nobody else is fetching from backend, so we'll add
+               // a new entry to cache.writing, fetch in a separate
+               // goroutine.
+               progress = &writeprogress{}
+               progress.cond = sync.NewCond(&sync.Mutex{})
+               if cache.writing == nil {
+                       cache.writing = map[string]*writeprogress{}
+               }
+               cache.writing[cachefilename] = progress
+
+               // Start a goroutine to copy from backend to f. As
+               // data arrives, wake up any waiting loops (see below)
+               // so ReadAt() requests for partial data can return as
+               // soon as the relevant bytes have been copied.
+               go func() {
+                       var size int
+                       var err error
+                       defer func() {
+                               if err == nil && progress.sharedf != nil {
+                                       err = progress.sharedf.Sync()
+                               }
+                               progress.cond.L.Lock()
+                               progress.err = err
+                               progress.done = true
+                               progress.size = size
+                               progress.cond.L.Unlock()
+                               progress.cond.Broadcast()
+                               cache.writingLock.Lock()
+                               delete(cache.writing, cachefilename)
+                               cache.writingLock.Unlock()
+
+                               // Wait for other goroutines to wake
+                               // up, notice we're done, and use our
+                               // sharedf to read their data, before
+                               // we close sharedf.
+                               //
+                               // Nobody can join the WaitGroup after
+                               // the progress entry is deleted from
+                               // cache.writing above. Therefore,
+                               // this Wait ensures nobody else is
+                               // accessing progress, and we don't
+                               // need to lock anything.
+                               progress.readers.Wait()
+                               progress.sharedf.Close()
+                       }()
+                       progress.sharedf, err = cache.openFile(cachefilename, os.O_CREATE|os.O_RDWR)
+                       if err != nil {
+                               err = fmt.Errorf("ReadAt: %w", err)
+                               return
+                       }
+                       err = syscall.Flock(int(progress.sharedf.Fd()), syscall.LOCK_SH)
+                       if err != nil {
+                               err = fmt.Errorf("flock(%s, lock_sh) failed: %w", cachefilename, err)
+                               return
+                       }
+                       size, err = cache.KeepGateway.BlockRead(context.Background(), BlockReadOptions{
+                               Locator: locator,
+                               WriteTo: funcwriter(func(p []byte) (int, error) {
+                                       n, err := progress.sharedf.Write(p)
+                                       if n > 0 {
+                                               progress.cond.L.Lock()
+                                               progress.size += n
+                                               progress.cond.L.Unlock()
+                                               progress.cond.Broadcast()
+                                       }
+                                       return n, err
+                               })})
+                       atomic.AddInt64(&cache.sizeEstimated, int64(size))
+                       cache.gotidy()
+               }()
+       }
+       // We add ourselves to the readers WaitGroup so the
+       // fetch-from-backend goroutine doesn't close the shared
+       // filehandle before we read the data we need from it.
+       progress.readers.Add(1)
+       defer progress.readers.Done()
+       cache.writingLock.Unlock()
+
+       progress.cond.L.Lock()
+       for !progress.done && progress.size < len(dst)+offset {
+               progress.cond.Wait()
+       }
+       sharedf := progress.sharedf
+       err := progress.err
+       progress.cond.L.Unlock()
+
+       if err != nil {
+               // If the copy-from-backend goroutine encountered an
+               // error, we return that error. (Even if we read the
+               // desired number of bytes, the error might be
+               // something like BadChecksum so we should not ignore
+               // it.)
+               return 0, err
+       }
+       if len(dst) == 0 {
+               // It's possible that sharedf==nil here (the writer
+               // goroutine might not have done anything at all yet)
+               // and we don't need it anyway because no bytes are
+               // being read. Reading zero bytes seems pointless, but
+               // if someone does it, we might as well return
+               // suitable values, rather than risk a crash by
+               // calling sharedf.ReadAt() when sharedf is nil.
+               return 0, nil
+       }
+       return sharedf.ReadAt(dst, int64(offset))
+}
+
+var quickReadAtLostRace = errors.New("quickReadAt: lost race")
+
+// Remove the cache entry for the indicated cachefilename if it
+// matches expect (quickReadAt() usage), or if expect is nil (tidy()
+// usage).
+//
+// If expect is non-nil, close expect's filehandle.
+//
+// If expect is nil and a different cache entry is deleted, close its
+// filehandle.
+func (cache *DiskCache) deleteHeldopen(cachefilename string, expect *openFileEnt) {
+       needclose := expect
+
+       cache.heldopenLock.Lock()
+       found := cache.heldopen[cachefilename]
+       if found != nil && (expect == nil || expect == found) {
+               delete(cache.heldopen, cachefilename)
+               needclose = found
+       }
+       cache.heldopenLock.Unlock()
+
+       if needclose != nil {
+               needclose.Lock()
+               defer needclose.Unlock()
+               if needclose.f != nil {
+                       needclose.f.Close()
+                       needclose.f = nil
+               }
+       }
+}
+
+// quickReadAt attempts to use a cached-filehandle approach to read
+// from the indicated file. The expectation is that the caller
+// (ReadAt) will try a more robust approach when this fails, so
+// quickReadAt doesn't try especially hard to ensure success in
+// races. In particular, when there are concurrent calls, and one
+// fails, that can cause others to fail too.
+func (cache *DiskCache) quickReadAt(cachefilename string, dst []byte, offset int) (int, error) {
+       isnew := false
+       cache.heldopenLock.Lock()
+       if cache.heldopenMax == 0 {
+               // Choose a reasonable limit on open cache files based
+               // on RLIMIT_NOFILE. Note Go automatically raises
+               // softlimit to hardlimit, so it's typically 1048576,
+               // not 1024.
+               lim := syscall.Rlimit{}
+               err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim)
+               if err != nil {
+                       cache.heldopenMax = 100
+               } else if lim.Cur > 400000 {
+                       cache.heldopenMax = 10000
+               } else {
+                       cache.heldopenMax = int(lim.Cur / 40)
+               }
+       }
+       heldopen := cache.heldopen[cachefilename]
+       if heldopen == nil {
+               isnew = true
+               heldopen = &openFileEnt{}
+               if cache.heldopen == nil {
+                       cache.heldopen = make(map[string]*openFileEnt, cache.heldopenMax)
+               } else if len(cache.heldopen) > cache.heldopenMax {
+                       // Rather than go to the trouble of tracking
+                       // last access time, just close all files, and
+                       // open again as needed. Even in the worst
+                       // pathological case, this causes one extra
+                       // open+close per read, which is not
+                       // especially bad (see benchmarks).
+                       go func(m map[string]*openFileEnt) {
+                               for _, heldopen := range m {
+                                       heldopen.Lock()
+                                       defer heldopen.Unlock()
+                                       if heldopen.f != nil {
+                                               heldopen.f.Close()
+                                               heldopen.f = nil
+                                       }
+                               }
+                       }(cache.heldopen)
+                       cache.heldopen = nil
+               }
+               cache.heldopen[cachefilename] = heldopen
+               heldopen.Lock()
+       }
+       cache.heldopenLock.Unlock()
+
+       if isnew {
+               // Open and flock the file, save the filehandle (or
+               // error) in heldopen.f, and release the write lock so
+               // other goroutines waiting at heldopen.RLock() below
+               // can use the shared filehandle (or shared error).
+               f, err := os.Open(cachefilename)
+               if err == nil {
+                       err = syscall.Flock(int(f.Fd()), syscall.LOCK_SH)
+                       if err == nil {
+                               heldopen.f = f
+                       } else {
+                               f.Close()
+                       }
+               }
+               if err != nil {
+                       heldopen.err = err
+                       go cache.deleteHeldopen(cachefilename, heldopen)
+               }
+               heldopen.Unlock()
+       }
+       // Acquire read lock to ensure (1) initialization is complete,
+       // if it's done by a different goroutine, and (2) any "delete
+       // old/unused entries" waits for our read to finish before
+       // closing the file.
+       heldopen.RLock()
+       defer heldopen.RUnlock()
+       if heldopen.err != nil {
+               // Other goroutine encountered an error during setup
+               return 0, heldopen.err
+       } else if heldopen.f == nil {
+               // Other goroutine closed the file before we got RLock
+               return 0, quickReadAtLostRace
+       }
+
+       // If another goroutine is currently writing the file, wait
+       // for it to catch up to the end of the range we need.
+       cache.writingLock.Lock()
+       progress := cache.writing[cachefilename]
+       cache.writingLock.Unlock()
+       if progress != nil {
+               progress.cond.L.Lock()
+               for !progress.done && progress.size < len(dst)+offset {
+                       progress.cond.Wait()
+               }
+               progress.cond.L.Unlock()
+               // If size<needed && progress.err!=nil here, we'll end
+               // up reporting a less helpful "EOF reading from cache
+               // file" below, instead of the actual error fetching
+               // from upstream to cache file.  This is OK though,
+               // because our caller (ReadAt) doesn't even report our
+               // error, it just retries.
+       }
+
+       n, err := heldopen.f.ReadAt(dst, int64(offset))
+       if err != nil {
+               // wait for any concurrent users to finish, then
+               // delete this cache entry in case reopening the
+               // backing file helps.
+               go cache.deleteHeldopen(cachefilename, heldopen)
+       }
+       return n, err
+}
+
+// BlockRead reads an entire block using a 128 KiB buffer.
+func (cache *DiskCache) BlockRead(ctx context.Context, opts BlockReadOptions) (int, error) {
+       cache.setupOnce.Do(cache.setup)
+       i := strings.Index(opts.Locator, "+")
+       if i < 0 || i >= len(opts.Locator) {
+               return 0, errors.New("invalid block locator: no size hint")
+       }
+       sizestr := opts.Locator[i+1:]
+       i = strings.Index(sizestr, "+")
+       if i > 0 {
+               sizestr = sizestr[:i]
+       }
+       blocksize, err := strconv.ParseInt(sizestr, 10, 32)
+       if err != nil || blocksize < 0 {
+               return 0, errors.New("invalid block locator: invalid size hint")
+       }
+
+       offset := 0
+       buf := make([]byte, 131072)
+       for offset < int(blocksize) {
+               if ctx.Err() != nil {
+                       return offset, ctx.Err()
+               }
+               if int(blocksize)-offset < len(buf) {
+                       buf = buf[:int(blocksize)-offset]
+               }
+               nr, err := cache.ReadAt(opts.Locator, buf, offset)
+               if nr > 0 {
+                       nw, err := opts.WriteTo.Write(buf[:nr])
+                       if err != nil {
+                               return offset + nw, err
+                       }
+               }
+               offset += nr
+               if err != nil {
+                       return offset, err
+               }
+       }
+       return offset, nil
+}
+
+// Start a tidy() goroutine, unless one is already running / recently
+// finished.
+func (cache *DiskCache) gotidy() {
+       writes := atomic.AddInt64(&cache.writesSinceTidy, 1)
+       // Skip if another tidy goroutine is running in this process.
+       n := atomic.AddInt32(&cache.tidying, 1)
+       if n != 1 {
+               atomic.AddInt32(&cache.tidying, -1)
+               return
+       }
+       // Skip if sizeEstimated is based on an actual measurement and
+       // is below maxSize, and we haven't done very many writes
+       // since last tidy (defined as 1% of number of cache files at
+       // last count).
+       if cache.sizeMeasured > 0 &&
+               atomic.LoadInt64(&cache.sizeEstimated) < atomic.LoadInt64(&cache.defaultMaxSize) &&
+               writes < cache.lastFileCount/100 {
+               atomic.AddInt32(&cache.tidying, -1)
+               return
+       }
+       go func() {
+               cache.tidy()
+               atomic.StoreInt64(&cache.writesSinceTidy, 0)
+               atomic.AddInt32(&cache.tidying, -1)
+       }()
+}
+
+// Delete cache files as needed to control disk usage.
+func (cache *DiskCache) tidy() {
+       maxsize := int64(cache.maxSize.ByteSize())
+       if maxsize < 1 {
+               maxsize = atomic.LoadInt64(&cache.defaultMaxSize)
+               if maxsize == 0 {
+                       // defaultMaxSize not yet computed. Use 10% of
+                       // filesystem capacity (or different
+                       // percentage if indicated by cache.maxSize)
+                       pct := cache.maxSize.Percent()
+                       if pct == 0 {
+                               pct = 10
+                       }
+                       var stat unix.Statfs_t
+                       if nil == unix.Statfs(cache.dir, &stat) {
+                               maxsize = int64(stat.Bavail) * stat.Bsize * pct / 100
+                               atomic.StoreInt64(&cache.defaultMaxSize, maxsize)
+                       } else {
+                               // In this case we will set
+                               // defaultMaxSize below after
+                               // measuring current usage.
+                       }
+               }
+       }
+
+       // Bail if a tidy goroutine is running in a different process.
+       lockfile, err := cache.openFile(filepath.Join(cache.dir, "tmp", "tidy.lock"), os.O_CREATE|os.O_WRONLY)
+       if err != nil {
+               return
+       }
+       defer lockfile.Close()
+       err = syscall.Flock(int(lockfile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
+       if err != nil {
+               return
+       }
+
+       type entT struct {
+               path  string
+               atime time.Time
+               size  int64
+       }
+       var ents []entT
+       var totalsize int64
+       filepath.Walk(cache.dir, func(path string, info fs.FileInfo, err error) error {
+               if err != nil {
+                       cache.debugf("tidy: skipping dir %s: %s", path, err)
+                       return nil
+               }
+               if info.IsDir() {
+                       return nil
+               }
+               if !strings.HasSuffix(path, cacheFileSuffix) && !strings.HasSuffix(path, tmpFileSuffix) {
+                       return nil
+               }
+               var atime time.Time
+               if stat, ok := info.Sys().(*syscall.Stat_t); ok {
+                       // Access time is available (hopefully the
+                       // filesystem is not mounted with noatime)
+                       atime = time.Unix(stat.Atim.Sec, stat.Atim.Nsec)
+               } else {
+                       // If access time isn't available we fall back
+                       // to sorting by modification time.
+                       atime = info.ModTime()
+               }
+               ents = append(ents, entT{path, atime, info.Size()})
+               totalsize += info.Size()
+               return nil
+       })
+       if cache.Logger != nil {
+               cache.Logger.WithFields(logrus.Fields{
+                       "totalsize": totalsize,
+                       "maxsize":   maxsize,
+               }).Debugf("DiskCache: checked current cache usage")
+       }
+
+       // If MaxSize wasn't specified and we failed to come up with a
+       // defaultSize above, use the larger of {current cache size, 1
+       // GiB} as the defaultMaxSize for subsequent tidy()
+       // operations.
+       if maxsize == 0 {
+               if totalsize < 1<<30 {
+                       atomic.StoreInt64(&cache.defaultMaxSize, 1<<30)
+               } else {
+                       atomic.StoreInt64(&cache.defaultMaxSize, totalsize)
+               }
+               cache.debugf("found initial size %d, setting defaultMaxSize %d", totalsize, cache.defaultMaxSize)
+               return
+       }
+
+       // If we're below MaxSize or there's only one block in the
+       // cache, just update the usage estimate and return.
+       //
+       // (We never delete the last block because that would merely
+       // cause the same block to get re-fetched repeatedly from the
+       // backend.)
+       if totalsize <= maxsize || len(ents) == 1 {
+               atomic.StoreInt64(&cache.sizeMeasured, totalsize)
+               atomic.StoreInt64(&cache.sizeEstimated, totalsize)
+               cache.lastFileCount = int64(len(ents))
+               return
+       }
+
+       // Set a new size target of maxsize minus 5%.  This makes some
+       // room for sizeEstimate to grow before it triggers another
+       // tidy. We don't want to walk/sort an entire large cache
+       // directory each time we write a block.
+       target := maxsize - (maxsize / 20)
+
+       // Delete oldest entries until totalsize < target or we're
+       // down to a single cached block.
+       sort.Slice(ents, func(i, j int) bool {
+               return ents[i].atime.Before(ents[j].atime)
+       })
+       deleted := 0
+       for _, ent := range ents {
+               os.Remove(ent.path)
+               go cache.deleteHeldopen(ent.path, nil)
+               deleted++
+               totalsize -= ent.size
+               if totalsize <= target || deleted == len(ents)-1 {
+                       break
+               }
+       }
+
+       if cache.Logger != nil {
+               cache.Logger.WithFields(logrus.Fields{
+                       "deleted":   deleted,
+                       "totalsize": totalsize,
+               }).Debugf("DiskCache: remaining cache usage after deleting")
+       }
+       atomic.StoreInt64(&cache.sizeMeasured, totalsize)
+       atomic.StoreInt64(&cache.sizeEstimated, totalsize)
+       cache.lastFileCount = int64(len(ents) - deleted)
+}
diff --git a/sdk/go/arvados/keep_cache_test.go b/sdk/go/arvados/keep_cache_test.go
new file mode 100644 (file)
index 0000000..776d9bb
--- /dev/null
@@ -0,0 +1,464 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "bytes"
+       "context"
+       "crypto/md5"
+       "errors"
+       "fmt"
+       "io"
+       "math/rand"
+       "os"
+       "path/filepath"
+       "sync"
+       "sync/atomic"
+       "time"
+
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&keepCacheSuite{})
+
+type keepCacheSuite struct {
+}
+
+type keepGatewayBlackHole struct {
+}
+
+func (*keepGatewayBlackHole) ReadAt(locator string, dst []byte, offset int) (int, error) {
+       return 0, errors.New("block not found")
+}
+func (*keepGatewayBlackHole) BlockRead(ctx context.Context, opts BlockReadOptions) (int, error) {
+       return 0, errors.New("block not found")
+}
+func (*keepGatewayBlackHole) LocalLocator(locator string) (string, error) {
+       return locator, nil
+}
+func (*keepGatewayBlackHole) BlockWrite(ctx context.Context, opts BlockWriteOptions) (BlockWriteResponse, error) {
+       h := md5.New()
+       var size int64
+       if opts.Reader == nil {
+               size, _ = io.Copy(h, bytes.NewReader(opts.Data))
+       } else {
+               size, _ = io.Copy(h, opts.Reader)
+       }
+       return BlockWriteResponse{Locator: fmt.Sprintf("%x+%d", h.Sum(nil), size), Replicas: 1}, nil
+}
+
+type keepGatewayMemoryBacked struct {
+       mtx                 sync.RWMutex
+       data                map[string][]byte
+       pauseBlockReadAfter int
+       pauseBlockReadUntil chan error
+}
+
+func (k *keepGatewayMemoryBacked) ReadAt(locator string, dst []byte, offset int) (int, error) {
+       k.mtx.RLock()
+       data := k.data[locator]
+       k.mtx.RUnlock()
+       if data == nil {
+               return 0, errors.New("block not found: " + locator)
+       }
+       var n int
+       if len(data) > offset {
+               n = copy(dst, data[offset:])
+       }
+       if n < len(dst) {
+               return n, io.EOF
+       }
+       return n, nil
+}
+func (k *keepGatewayMemoryBacked) BlockRead(ctx context.Context, opts BlockReadOptions) (int, error) {
+       k.mtx.RLock()
+       data := k.data[opts.Locator]
+       k.mtx.RUnlock()
+       if data == nil {
+               return 0, errors.New("block not found: " + opts.Locator)
+       }
+       if k.pauseBlockReadUntil != nil {
+               src := bytes.NewReader(data)
+               n, err := io.CopyN(opts.WriteTo, src, int64(k.pauseBlockReadAfter))
+               if err != nil {
+                       return int(n), err
+               }
+               <-k.pauseBlockReadUntil
+               n2, err := io.Copy(opts.WriteTo, src)
+               return int(n + n2), err
+       }
+       return opts.WriteTo.Write(data)
+}
+func (k *keepGatewayMemoryBacked) LocalLocator(locator string) (string, error) {
+       return locator, nil
+}
+func (k *keepGatewayMemoryBacked) BlockWrite(ctx context.Context, opts BlockWriteOptions) (BlockWriteResponse, error) {
+       h := md5.New()
+       data := bytes.NewBuffer(nil)
+       if opts.Reader == nil {
+               data.Write(opts.Data)
+               h.Write(data.Bytes())
+       } else {
+               io.Copy(io.MultiWriter(h, data), opts.Reader)
+       }
+       locator := fmt.Sprintf("%x+%d", h.Sum(nil), data.Len())
+       k.mtx.Lock()
+       if k.data == nil {
+               k.data = map[string][]byte{}
+       }
+       k.data[locator] = data.Bytes()
+       k.mtx.Unlock()
+       return BlockWriteResponse{Locator: locator, Replicas: 1}, nil
+}
+
+func (s *keepCacheSuite) TestBlockWrite(c *check.C) {
+       backend := &keepGatewayMemoryBacked{}
+       cache := DiskCache{
+               KeepGateway: backend,
+               MaxSize:     40000000,
+               Dir:         c.MkDir(),
+               Logger:      ctxlog.TestLogger(c),
+       }
+       ctx := context.Background()
+       real, err := cache.BlockWrite(ctx, BlockWriteOptions{
+               Data: make([]byte, 100000),
+       })
+       c.Assert(err, check.IsNil)
+
+       // Write different data but supply the same hash. Should be
+       // rejected (even though our fake backend doesn't notice).
+       _, err = cache.BlockWrite(ctx, BlockWriteOptions{
+               Hash: real.Locator[:32],
+               Data: make([]byte, 10),
+       })
+       c.Check(err, check.ErrorMatches, `block hash .+ did not match provided hash .+`)
+
+       // Ensure the bogus write didn't overwrite (or delete) the
+       // real cached data associated with that hash.
+       delete(backend.data, real.Locator)
+       n, err := cache.ReadAt(real.Locator, make([]byte, 100), 0)
+       c.Check(n, check.Equals, 100)
+       c.Check(err, check.IsNil)
+}
+
+func (s *keepCacheSuite) TestMaxSize(c *check.C) {
+       backend := &keepGatewayMemoryBacked{}
+       cache := DiskCache{
+               KeepGateway: backend,
+               MaxSize:     40000000,
+               Dir:         c.MkDir(),
+               Logger:      ctxlog.TestLogger(c),
+       }
+       ctx := context.Background()
+       resp1, err := cache.BlockWrite(ctx, BlockWriteOptions{
+               Data: make([]byte, 44000000),
+       })
+       c.Check(err, check.IsNil)
+
+       // Wait for tidy to finish, check that it doesn't delete the
+       // only block.
+       time.Sleep(time.Millisecond)
+       for atomic.LoadInt32(&cache.tidying) > 0 {
+               time.Sleep(time.Millisecond)
+       }
+       c.Check(atomic.LoadInt64(&cache.sizeMeasured), check.Equals, int64(44000000))
+
+       resp2, err := cache.BlockWrite(ctx, BlockWriteOptions{
+               Data: make([]byte, 32000000),
+       })
+       c.Check(err, check.IsNil)
+       delete(backend.data, resp1.Locator)
+       delete(backend.data, resp2.Locator)
+
+       // Wait for tidy to finish, check that it deleted the older
+       // block.
+       time.Sleep(time.Millisecond)
+       for atomic.LoadInt32(&cache.tidying) > 0 {
+               time.Sleep(time.Millisecond)
+       }
+       c.Check(atomic.LoadInt64(&cache.sizeMeasured), check.Equals, int64(32000000))
+
+       n, err := cache.ReadAt(resp1.Locator, make([]byte, 2), 0)
+       c.Check(n, check.Equals, 0)
+       c.Check(err, check.ErrorMatches, `block not found: .*\+44000000`)
+
+       n, err = cache.ReadAt(resp2.Locator, make([]byte, 2), 0)
+       c.Check(n > 0, check.Equals, true)
+       c.Check(err, check.IsNil)
+}
+
+func (s *keepCacheSuite) TestConcurrentReadersNoRefresh(c *check.C) {
+       s.testConcurrentReaders(c, true, false)
+}
+func (s *keepCacheSuite) TestConcurrentReadersMangleCache(c *check.C) {
+       s.testConcurrentReaders(c, false, true)
+}
+func (s *keepCacheSuite) testConcurrentReaders(c *check.C, cannotRefresh, mangleCache bool) {
+       blksize := 64000000
+       backend := &keepGatewayMemoryBacked{}
+       cache := DiskCache{
+               KeepGateway: backend,
+               MaxSize:     ByteSizeOrPercent(blksize),
+               Dir:         c.MkDir(),
+               Logger:      ctxlog.TestLogger(c),
+       }
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+
+       resp, err := cache.BlockWrite(ctx, BlockWriteOptions{
+               Data: make([]byte, blksize),
+       })
+       c.Check(err, check.IsNil)
+       if cannotRefresh {
+               // Delete the block from the backing store, to ensure
+               // the cache doesn't rely on re-reading a block that
+               // it has just written.
+               delete(backend.data, resp.Locator)
+       }
+       if mangleCache {
+               // Replace cache files with truncated files (and
+               // delete them outright) while the ReadAt loop is
+               // running, to ensure the cache can re-fetch from the
+               // backend as needed.
+               var nRemove, nTrunc int
+               defer func() {
+                       c.Logf("nRemove %d", nRemove)
+                       c.Logf("nTrunc %d", nTrunc)
+               }()
+               go func() {
+                       // Truncate/delete the cache file at various
+                       // intervals. Readers should re-fetch/recover from
+                       // this.
+                       fnm := cache.cacheFile(resp.Locator)
+                       for ctx.Err() == nil {
+                               trunclen := rand.Int63() % int64(blksize*2)
+                               if trunclen > int64(blksize) {
+                                       err := os.Remove(fnm)
+                                       if err == nil {
+                                               nRemove++
+                                       }
+                               } else if os.WriteFile(fnm+"#", make([]byte, trunclen), 0700) == nil {
+                                       err := os.Rename(fnm+"#", fnm)
+                                       if err == nil {
+                                               nTrunc++
+                                       }
+                               }
+                       }
+               }()
+       }
+
+       failed := false
+       var wg sync.WaitGroup
+       var slots = make(chan bool, 100) // limit concurrency / memory usage
+       for i := 0; i < 20000; i++ {
+               offset := (i * 123456) % blksize
+               slots <- true
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       defer func() { <-slots }()
+                       buf := make([]byte, 654321)
+                       if offset+len(buf) > blksize {
+                               buf = buf[:blksize-offset]
+                       }
+                       n, err := cache.ReadAt(resp.Locator, buf, offset)
+                       if failed {
+                               // don't fill logs with subsequent errors
+                               return
+                       }
+                       if !c.Check(err, check.IsNil, check.Commentf("offset=%d", offset)) {
+                               failed = true
+                       }
+                       c.Assert(n, check.Equals, len(buf))
+               }()
+       }
+       wg.Wait()
+}
+
+func (s *keepCacheSuite) TestStreaming(c *check.C) {
+       blksize := 64000000
+       backend := &keepGatewayMemoryBacked{
+               pauseBlockReadUntil: make(chan error),
+               pauseBlockReadAfter: blksize / 8,
+       }
+       cache := DiskCache{
+               KeepGateway: backend,
+               MaxSize:     ByteSizeOrPercent(blksize),
+               Dir:         c.MkDir(),
+               Logger:      ctxlog.TestLogger(c),
+       }
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+
+       resp, err := cache.BlockWrite(ctx, BlockWriteOptions{
+               Data: make([]byte, blksize),
+       })
+       c.Check(err, check.IsNil)
+       os.RemoveAll(filepath.Join(cache.Dir, resp.Locator[:3]))
+
+       // Start a lot of concurrent requests for various ranges of
+       // the same block. Our backend will return the first 8MB and
+       // then pause. The requests that can be satisfied by the first
+       // 8MB of data should return quickly. The rest should wait,
+       // and return after we release pauseBlockReadUntil.
+       var wgEarly, wgLate sync.WaitGroup
+       var doneEarly, doneLate int32
+       for i := 0; i < 10000; i++ {
+               wgEarly.Add(1)
+               go func() {
+                       offset := int(rand.Int63() % int64(blksize-benchReadSize))
+                       if offset+benchReadSize > backend.pauseBlockReadAfter {
+                               wgLate.Add(1)
+                               defer wgLate.Done()
+                               wgEarly.Done()
+                               defer atomic.AddInt32(&doneLate, 1)
+                       } else {
+                               defer wgEarly.Done()
+                               defer atomic.AddInt32(&doneEarly, 1)
+                       }
+                       buf := make([]byte, benchReadSize)
+                       n, err := cache.ReadAt(resp.Locator, buf, offset)
+                       c.Check(n, check.Equals, len(buf))
+                       c.Check(err, check.IsNil)
+               }()
+       }
+
+       // Ensure all early ranges finish while backend request(s) are
+       // paused.
+       wgEarly.Wait()
+       c.Logf("doneEarly = %d", doneEarly)
+       c.Check(doneLate, check.Equals, int32(0))
+
+       // Unpause backend request(s).
+       close(backend.pauseBlockReadUntil)
+       wgLate.Wait()
+       c.Logf("doneLate = %d", doneLate)
+}
+
+var _ = check.Suite(&keepCacheBenchSuite{})
+
+type keepCacheBenchSuite struct {
+       blksize  int
+       blkcount int
+       backend  *keepGatewayMemoryBacked
+       cache    *DiskCache
+       locators []string
+}
+
+func (s *keepCacheBenchSuite) SetUpTest(c *check.C) {
+       s.blksize = 64000000
+       s.blkcount = 8
+       s.backend = &keepGatewayMemoryBacked{}
+       s.cache = &DiskCache{
+               KeepGateway: s.backend,
+               MaxSize:     ByteSizeOrPercent(s.blksize),
+               Dir:         c.MkDir(),
+               Logger:      ctxlog.TestLogger(c),
+       }
+       s.locators = make([]string, s.blkcount)
+       data := make([]byte, s.blksize)
+       for b := 0; b < s.blkcount; b++ {
+               for i := range data {
+                       data[i] = byte(b)
+               }
+               resp, err := s.cache.BlockWrite(context.Background(), BlockWriteOptions{
+                       Data: data,
+               })
+               c.Assert(err, check.IsNil)
+               s.locators[b] = resp.Locator
+       }
+}
+
+func (s *keepCacheBenchSuite) BenchmarkConcurrentReads(c *check.C) {
+       var wg sync.WaitGroup
+       for i := 0; i < c.N; i++ {
+               i := i
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       buf := make([]byte, benchReadSize)
+                       _, err := s.cache.ReadAt(s.locators[i%s.blkcount], buf, int((int64(i)*1234)%int64(s.blksize-benchReadSize)))
+                       if err != nil {
+                               c.Fail()
+                       }
+               }()
+       }
+       wg.Wait()
+}
+
+func (s *keepCacheBenchSuite) BenchmarkSequentialReads(c *check.C) {
+       buf := make([]byte, benchReadSize)
+       for i := 0; i < c.N; i++ {
+               _, err := s.cache.ReadAt(s.locators[i%s.blkcount], buf, int((int64(i)*1234)%int64(s.blksize-benchReadSize)))
+               if err != nil {
+                       c.Fail()
+               }
+       }
+}
+
+const benchReadSize = 1000
+
+var _ = check.Suite(&fileOpsSuite{})
+
+type fileOpsSuite struct{}
+
+// BenchmarkOpenClose and BenchmarkKeepOpen can be used to measure the
+// potential performance improvement of caching filehandles rather
+// than opening/closing the cache file for each read.
+//
+// Results from a development machine indicate a ~3x throughput
+// improvement: ~636 MB/s when opening/closing the file for each
+// 1000-byte read vs. ~2 GB/s when opening the file once and doing
+// concurrent reads using the same file descriptor.
+func (s *fileOpsSuite) BenchmarkOpenClose(c *check.C) {
+       fnm := c.MkDir() + "/testfile"
+       os.WriteFile(fnm, make([]byte, 64000000), 0700)
+       var wg sync.WaitGroup
+       for i := 0; i < c.N; i++ {
+               i := i
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       f, err := os.OpenFile(fnm, os.O_CREATE|os.O_RDWR, 0700)
+                       if err != nil {
+                               c.Fail()
+                               return
+                       }
+                       _, err = f.ReadAt(make([]byte, benchReadSize), (int64(i)*1000000)%63123123)
+                       if err != nil {
+                               c.Fail()
+                               return
+                       }
+                       f.Close()
+               }()
+       }
+       wg.Wait()
+}
+
+func (s *fileOpsSuite) BenchmarkKeepOpen(c *check.C) {
+       fnm := c.MkDir() + "/testfile"
+       os.WriteFile(fnm, make([]byte, 64000000), 0700)
+       f, err := os.OpenFile(fnm, os.O_CREATE|os.O_RDWR, 0700)
+       if err != nil {
+               c.Fail()
+               return
+       }
+       var wg sync.WaitGroup
+       for i := 0; i < c.N; i++ {
+               i := i
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       _, err = f.ReadAt(make([]byte, benchReadSize), (int64(i)*1000000)%63123123)
+                       if err != nil {
+                               c.Fail()
+                               return
+                       }
+               }()
+       }
+       wg.Wait()
+       f.Close()
+}
index 5b6d71a4fb73953109b884156d39e6143c92e4a4..85750d8cfc97d8530794c6c4dfdbfc78aa258bb4 100644 (file)
@@ -33,7 +33,8 @@ type KeepService struct {
 type KeepMount struct {
        UUID           string          `json:"uuid"`
        DeviceID       string          `json:"device_id"`
-       ReadOnly       bool            `json:"read_only"`
+       AllowWrite     bool            `json:"allow_write"`
+       AllowTrash     bool            `json:"allow_trash"`
        Replication    int             `json:"replication"`
        StorageClasses map[string]bool `json:"storage_classes"`
 }
index f62264c636f96dfa4b55ff0d581fc8cd50b05c09..dc944160ab2dd5d459a31fa2386ebe9a5f6bb2c3 100644 (file)
@@ -13,11 +13,15 @@ import (
        "time"
 )
 
-var requestLimiterQuietPeriod = time.Second
+var (
+       requestLimiterQuietPeriod        = time.Second
+       requestLimiterInitialLimit int64 = 8
+)
 
 type requestLimiter struct {
        current    int64
        limit      int64
+       maxlimit   int64
        lock       sync.Mutex
        cond       *sync.Cond
        quietUntil time.Time
@@ -33,6 +37,7 @@ func (rl *requestLimiter) Acquire(ctx context.Context) {
        if rl.cond == nil {
                // First use of requestLimiter. Initialize.
                rl.cond = sync.NewCond(&rl.lock)
+               rl.limit = requestLimiterInitialLimit
        }
        // Wait out the quiet period(s) immediately following a 503.
        for ctx.Err() == nil {
@@ -137,9 +142,12 @@ func (rl *requestLimiter) Report(resp *http.Response, err error) bool {
                        increase = 1
                }
                rl.limit += increase
-               if max := rl.current * 2; max > rl.limit {
+               if max := rl.current * 2; max < rl.limit {
                        rl.limit = max
                }
+               if rl.maxlimit > 0 && rl.maxlimit < rl.limit {
+                       rl.limit = rl.maxlimit
+               }
                rl.cond.Broadcast()
        }
        return false
index d32ab9699999d97a7dad2c63c4332e937419f739..1e73b1c28f44555da1be00ca926ccc7e9c0f7946 100644 (file)
@@ -18,23 +18,23 @@ var _ = Suite(&limiterSuite{})
 
 type limiterSuite struct{}
 
-func (*limiterSuite) TestUnlimitedBeforeFirstReport(c *C) {
+func (*limiterSuite) TestInitialLimit(c *C) {
        ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
        defer cancel()
        rl := requestLimiter{}
 
        var wg sync.WaitGroup
-       wg.Add(1000)
-       for i := 0; i < 1000; i++ {
+       wg.Add(int(requestLimiterInitialLimit))
+       for i := int64(0); i < requestLimiterInitialLimit; i++ {
                go func() {
                        rl.Acquire(ctx)
                        wg.Done()
                }()
        }
        wg.Wait()
-       c.Check(rl.current, Equals, int64(1000))
-       wg.Add(1000)
-       for i := 0; i < 1000; i++ {
+       c.Check(rl.current, Equals, requestLimiterInitialLimit)
+       wg.Add(int(requestLimiterInitialLimit))
+       for i := int64(0); i < requestLimiterInitialLimit; i++ {
                go func() {
                        rl.Release()
                        wg.Done()
@@ -49,8 +49,8 @@ func (*limiterSuite) TestCancelWhileWaitingForAcquire(c *C) {
        defer cancel()
        rl := requestLimiter{}
 
-       rl.limit = 1
        rl.Acquire(ctx)
+       rl.limit = 1
        ctxShort, cancel := context.WithDeadline(ctx, time.Now().Add(time.Millisecond))
        defer cancel()
        rl.Acquire(ctxShort)
@@ -74,7 +74,7 @@ func (*limiterSuite) TestReducedLimitAndQuietPeriod(c *C) {
                rl.Acquire(ctx)
        }
        rl.Report(&http.Response{StatusCode: http.StatusServiceUnavailable}, nil)
-       c.Check(rl.limit, Equals, int64(3))
+       c.Check(rl.limit, Equals, requestLimiterInitialLimit/2)
        for i := 0; i < 5; i++ {
                rl.Release()
        }
index 06d7987e321299af7577084c043c0e56b5c664da..b5860d059362779d8a5087285b53a5c209297b1b 100644 (file)
@@ -10,7 +10,7 @@ import (
 
 // Log is an arvados#log record
 type Log struct {
-       ID              uint64                 `json:"id"`
+       ID              int64                  `json:"id"`
        UUID            string                 `json:"uuid"`
        OwnerUUID       string                 `json:"owner_uuid"`
        ObjectUUID      string                 `json:"object_uuid"`
diff --git a/sdk/go/arvados/tls_certs.go b/sdk/go/arvados/tls_certs.go
new file mode 100644 (file)
index 0000000..db52781
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import "os"
+
+// Load root CAs from /etc/arvados/ca-certificates.crt if it exists
+// and SSL_CERT_FILE does not already specify a different file.
+func init() {
+       envvar := "SSL_CERT_FILE"
+       certfile := "/etc/arvados/ca-certificates.crt"
+       if os.Getenv(envvar) != "" {
+               // Caller has already specified SSL_CERT_FILE.
+               return
+       }
+       if _, err := os.ReadFile(certfile); err != nil {
+               // Custom cert file is not present/readable.
+               return
+       }
+       os.Setenv(envvar, certfile)
+}
diff --git a/sdk/go/arvados/tls_certs_test.go b/sdk/go/arvados/tls_certs_test.go
new file mode 100644 (file)
index 0000000..7900867
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "os"
+       "os/exec"
+
+       check "gopkg.in/check.v1"
+)
+
+type tlsCertsSuite struct{}
+
+var _ = check.Suite(&tlsCertsSuite{})
+
+func (s *tlsCertsSuite) TestCustomCert(c *check.C) {
+       certfile := "/etc/arvados/ca-certificates.crt"
+       if _, err := os.Stat(certfile); err != nil {
+               c.Skip("custom cert file " + certfile + " does not exist")
+       }
+       out, err := exec.Command("bash", "-c", "SSL_CERT_FILE= go run tls_certs_test_showenv.go").CombinedOutput()
+       c.Logf("%s", out)
+       c.Assert(err, check.IsNil)
+       c.Check(string(out), check.Equals, certfile+"\n")
+
+       out, err = exec.Command("bash", "-c", "SSL_CERT_FILE=/dev/null go run tls_certs_test_showenv.go").CombinedOutput()
+       c.Logf("%s", out)
+       c.Assert(err, check.IsNil)
+       c.Check(string(out), check.Equals, "/dev/null\n")
+}
diff --git a/sdk/go/arvados/tls_certs_test_showenv.go b/sdk/go/arvados/tls_certs_test_showenv.go
new file mode 100644 (file)
index 0000000..f2622cf
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build ignore
+
+// This is a test program invoked by tls_certs_test.go
+
+package main
+
+import (
+       "fmt"
+       "os"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+)
+
+var _ = arvados.Client{}
+
+func main() {
+       fmt.Println(os.Getenv("SSL_CERT_FILE"))
+}
index 13b3a30ac40d8c9f21f5a39434164f34d353d270..d0ebdc1b018b9c4039ed0319b77f671b9e5364fe 100644 (file)
@@ -9,16 +9,12 @@ package arvadosclient
 import (
        "bytes"
        "crypto/tls"
-       "crypto/x509"
        "encoding/json"
        "errors"
        "fmt"
        "io"
-       "io/ioutil"
-       "log"
        "net/http"
        "net/url"
-       "os"
        "strings"
        "sync"
        "time"
@@ -109,6 +105,11 @@ type ArvadosClient struct {
        // available services.
        KeepServiceURIs []string
 
+       // Maximum disk cache size in bytes or percent of total
+       // filesystem size. If zero, use default, currently 10% of
+       // filesystem size.
+       DiskCacheSize arvados.ByteSizeOrPercent
+
        // Discovery document
        DiscoveryDoc Dict
 
@@ -121,40 +122,10 @@ type ArvadosClient struct {
        RequestID string
 }
 
-var CertFiles = []string{
-       "/etc/arvados/ca-certificates.crt",
-       "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Gentoo etc.
-       "/etc/pki/tls/certs/ca-bundle.crt",   // Fedora/RHEL
-}
-
 // MakeTLSConfig sets up TLS configuration for communicating with
 // Arvados and Keep services.
 func MakeTLSConfig(insecure bool) *tls.Config {
-       tlsconfig := tls.Config{InsecureSkipVerify: insecure}
-
-       if !insecure {
-               // Use the first entry in CertFiles that we can read
-               // certificates from. If none of those work out, use
-               // the Go defaults.
-               certs := x509.NewCertPool()
-               for _, file := range CertFiles {
-                       data, err := ioutil.ReadFile(file)
-                       if err != nil {
-                               if !os.IsNotExist(err) {
-                                       log.Printf("proceeding without loading cert file %q: %s", file, err)
-                               }
-                               continue
-                       }
-                       if !certs.AppendCertsFromPEM(data) {
-                               log.Printf("unable to load any certificates from %v", file)
-                               continue
-                       }
-                       tlsconfig.RootCAs = certs
-                       break
-               }
-       }
-
-       return &tlsconfig
+       return &tls.Config{InsecureSkipVerify: insecure}
 }
 
 // New returns an ArvadosClient using the given arvados.Client
@@ -178,6 +149,7 @@ func New(c *arvados.Client) (*ArvadosClient, error) {
                Client:            hc,
                Retries:           2,
                KeepServiceURIs:   c.KeepServiceURIs,
+               DiskCacheSize:     c.DiskCacheSize,
                lastClosedIdlesAt: time.Now(),
        }
 
@@ -231,74 +203,37 @@ func (c *ArvadosClient) CallRaw(method string, resourceType string, uuid string,
                        vals.Set(k, string(m))
                }
        }
-
-       retryable := false
-       switch method {
-       case "GET", "HEAD", "PUT", "OPTIONS", "DELETE":
-               retryable = true
-       }
-
-       // Non-retryable methods such as POST are not safe to retry automatically,
-       // so we minimize such failures by always using a new or recently active socket
-       if !retryable {
-               if time.Since(c.lastClosedIdlesAt) > MaxIdleConnectionDuration {
-                       c.lastClosedIdlesAt = time.Now()
-                       c.Client.Transport.(*http.Transport).CloseIdleConnections()
-               }
-       }
-
-       // Make the request
        var req *http.Request
-       var resp *http.Response
-
-       for attempt := 0; attempt <= c.Retries; attempt++ {
-               if method == "GET" || method == "HEAD" {
-                       u.RawQuery = vals.Encode()
-                       if req, err = http.NewRequest(method, u.String(), nil); err != nil {
-                               return nil, err
-                       }
-               } else {
-                       if req, err = http.NewRequest(method, u.String(), bytes.NewBufferString(vals.Encode())); err != nil {
-                               return nil, err
-                       }
-                       req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
-               }
-
-               // Add api token header
-               req.Header.Add("Authorization", fmt.Sprintf("OAuth2 %s", c.ApiToken))
-               if c.RequestID != "" {
-                       req.Header.Add("X-Request-Id", c.RequestID)
-               }
-
-               resp, err = c.Client.Do(req)
-               if err != nil {
-                       if retryable {
-                               time.Sleep(RetryDelay)
-                               continue
-                       } else {
-                               return nil, err
-                       }
-               }
-
-               if resp.StatusCode == http.StatusOK {
-                       return resp.Body, nil
+       if method == "GET" || method == "HEAD" {
+               u.RawQuery = vals.Encode()
+               if req, err = http.NewRequest(method, u.String(), nil); err != nil {
+                       return nil, err
                }
-
-               defer resp.Body.Close()
-
-               switch resp.StatusCode {
-               case 408, 409, 422, 423, 500, 502, 503, 504:
-                       time.Sleep(RetryDelay)
-                       continue
-               default:
-                       return nil, newAPIServerError(c.ApiServer, resp)
+       } else {
+               if req, err = http.NewRequest(method, u.String(), bytes.NewBufferString(vals.Encode())); err != nil {
+                       return nil, err
                }
+               req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
        }
-
-       if resp != nil {
+       if c.RequestID != "" {
+               req.Header.Add("X-Request-Id", c.RequestID)
+       }
+       client := arvados.Client{
+               Client:    c.Client,
+               APIHost:   c.ApiServer,
+               AuthToken: c.ApiToken,
+               Insecure:  c.ApiInsecure,
+               Timeout:   30 * RetryDelay * time.Duration(c.Retries),
+       }
+       resp, err := client.Do(req)
+       if err != nil {
+               return nil, err
+       }
+       if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
+               defer resp.Body.Close()
                return nil, newAPIServerError(c.ApiServer, resp)
        }
-       return nil, err
+       return resp.Body, nil
 }
 
 func newAPIServerError(ServerAddress string, resp *http.Response) APIServerError {
@@ -332,12 +267,12 @@ func newAPIServerError(ServerAddress string, resp *http.Response) APIServerError
 
 // Call an API endpoint and parse the JSON response into an object.
 //
-//   method - HTTP method: GET, HEAD, PUT, POST, PATCH or DELETE.
-//   resourceType - the type of arvados resource to act on (e.g., "collections", "pipeline_instances").
-//   uuid - the uuid of the specific item to access. May be empty.
-//   action - API method name (e.g., "lock"). This is often empty if implied by method and uuid.
-//   parameters - method parameters.
-//   output - a map or annotated struct which is a legal target for encoding/json/Decoder.
+//     method - HTTP method: GET, HEAD, PUT, POST, PATCH or DELETE.
+//     resourceType - the type of arvados resource to act on (e.g., "collections", "pipeline_instances").
+//     uuid - the uuid of the specific item to access. May be empty.
+//     action - API method name (e.g., "lock"). This is often empty if implied by method and uuid.
+//     parameters - method parameters.
+//     output - a map or annotated struct which is a legal target for encoding/json/Decoder.
 //
 // Returns a non-nil error if an error occurs making the API call, the
 // API responds with a non-successful HTTP status, or an error occurs
index 27e23c1aea4e3d1b4fab84811dc8dddd70b6cde4..b074e21e8124f88e7831fc21b184d070e9d0079a 100644 (file)
@@ -31,7 +31,7 @@ type ServerRequiredSuite struct{}
 
 func (s *ServerRequiredSuite) SetUpSuite(c *C) {
        arvadostest.StartKeep(2, false)
-       RetryDelay = 0
+       RetryDelay = 2 * time.Second
 }
 
 func (s *ServerRequiredSuite) TearDownSuite(c *C) {
@@ -248,7 +248,7 @@ func (s *UnitSuite) TestPDHMatch(c *C) {
 type MockArvadosServerSuite struct{}
 
 func (s *MockArvadosServerSuite) SetUpSuite(c *C) {
-       RetryDelay = 0
+       RetryDelay = 100 * time.Millisecond
 }
 
 func (s *MockArvadosServerSuite) SetUpTest(c *C) {
@@ -279,15 +279,17 @@ type APIStub struct {
 }
 
 func (h *APIStub) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
-       if req.URL.Path == "/redirect-loop" {
-               http.Redirect(resp, req, "/redirect-loop", http.StatusFound)
-               return
-       }
-       if h.respStatus[h.retryAttempts] < 0 {
-               // Fail the client's Do() by starting a redirect loop
-               http.Redirect(resp, req, "/redirect-loop", http.StatusFound)
+       if status := h.respStatus[h.retryAttempts]; status < 0 {
+               // Fail the client's Do() by hanging up without
+               // sending an HTTP response header.
+               conn, _, err := resp.(http.Hijacker).Hijack()
+               if err != nil {
+                       panic(err)
+               }
+               conn.Write([]byte("zzzzzzzzzz"))
+               conn.Close()
        } else {
-               resp.WriteHeader(h.respStatus[h.retryAttempts])
+               resp.WriteHeader(status)
                resp.Write([]byte(h.responseBody[h.retryAttempts]))
        }
        h.retryAttempts++
@@ -302,22 +304,22 @@ func (s *MockArvadosServerSuite) TestWithRetries(c *C) {
                        "create", 0, 200, []int{200, 500}, []string{`{"ok":"ok"}`, ``},
                },
                {
-                       "get", 0, 500, []int{500, 500, 500, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
+                       "get", 0, 423, []int{500, 500, 423, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
                },
                {
-                       "create", 0, 500, []int{500, 500, 500, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
+                       "create", 0, 423, []int{500, 500, 423, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
                },
                {
-                       "update", 0, 500, []int{500, 500, 500, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
+                       "update", 0, 422, []int{500, 500, 422, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
                },
                {
-                       "delete", 0, 500, []int{500, 500, 500, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
+                       "delete", 0, 422, []int{500, 500, 422, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
                },
                {
-                       "get", 0, 502, []int{500, 500, 502, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
+                       "get", 0, 401, []int{500, 502, 401, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
                },
                {
-                       "create", 0, 502, []int{500, 500, 502, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
+                       "create", 0, 422, []int{500, 502, 422, 200}, []string{``, ``, ``, `{"ok":"ok"}`},
                },
                {
                        "get", 0, 200, []int{500, 500, 200}, []string{``, ``, `{"ok":"ok"}`},
@@ -337,6 +339,12 @@ func (s *MockArvadosServerSuite) TestWithRetries(c *C) {
                {
                        "create", 0, 401, []int{401, 200}, []string{``, `{"ok":"ok"}`},
                },
+               {
+                       "create", 0, 403, []int{403, 200}, []string{``, `{"ok":"ok"}`},
+               },
+               {
+                       "create", 0, 422, []int{422, 200}, []string{``, `{"ok":"ok"}`},
+               },
                {
                        "get", 0, 404, []int{404, 200}, []string{``, `{"ok":"ok"}`},
                },
@@ -352,11 +360,13 @@ func (s *MockArvadosServerSuite) TestWithRetries(c *C) {
                {
                        "get", 0, 200, []int{-1, -1, 200}, []string{``, ``, `{"ok":"ok"}`},
                },
-               // "POST" is not safe to retry: fail after one error
+               // "POST" protocol error is safe to retry
                {
-                       "create", 0, -1, []int{-1, 200}, []string{``, `{"ok":"ok"}`},
+                       "create", 0, 200, []int{-1, 200}, []string{``, `{"ok":"ok"}`},
                },
        } {
+               c.Logf("stub: %#v", stub)
+
                api, err := RunFakeArvadosServer(&stub)
                c.Check(err, IsNil)
 
@@ -396,7 +406,9 @@ func (s *MockArvadosServerSuite) TestWithRetries(c *C) {
                default:
                        c.Check(err, NotNil)
                        c.Check(err, ErrorMatches, fmt.Sprintf("arvados API server error: %d.*", stub.expected))
-                       c.Check(err.(APIServerError).HttpStatusCode, Equals, stub.expected)
+                       if c.Check(err, FitsTypeOf, APIServerError{}) {
+                               c.Check(err.(APIServerError).HttpStatusCode, Equals, stub.expected)
+                       }
                }
        }
 }
index 9b51e5ce2e662521d7ad771ae7109d8b5bc66568..e1827b5d1f7995e3c3e01baa52ef016f349dcd95 100644 (file)
@@ -8,12 +8,15 @@ import (
        "context"
        "encoding/json"
        "errors"
+       "io"
+       "net/http"
        "net/url"
        "reflect"
        "runtime"
        "sync"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
 )
 
 var ErrStubUnimplemented = errors.New("stub unimplemented")
@@ -37,6 +40,10 @@ func (as *APIStub) VocabularyGet(ctx context.Context) (arvados.Vocabulary, error
        as.appendCall(ctx, as.VocabularyGet, nil)
        return arvados.Vocabulary{}, as.Error
 }
+func (as *APIStub) DiscoveryDocument(ctx context.Context) (arvados.DiscoveryDocument, error) {
+       as.appendCall(ctx, as.DiscoveryDocument, nil)
+       return arvados.DiscoveryDocument{}, as.Error
+}
 func (as *APIStub) Login(ctx context.Context, options arvados.LoginOptions) (arvados.LoginResponse, error) {
        as.appendCall(ctx, as.Login, options)
        return arvados.LoginResponse{}, as.Error
@@ -45,6 +52,26 @@ func (as *APIStub) Logout(ctx context.Context, options arvados.LogoutOptions) (a
        as.appendCall(ctx, as.Logout, options)
        return arvados.LogoutResponse{}, as.Error
 }
+func (as *APIStub) AuthorizedKeyCreate(ctx context.Context, options arvados.CreateOptions) (arvados.AuthorizedKey, error) {
+       as.appendCall(ctx, as.AuthorizedKeyCreate, options)
+       return arvados.AuthorizedKey{}, as.Error
+}
+func (as *APIStub) AuthorizedKeyUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.AuthorizedKey, error) {
+       as.appendCall(ctx, as.AuthorizedKeyUpdate, options)
+       return arvados.AuthorizedKey{}, as.Error
+}
+func (as *APIStub) AuthorizedKeyGet(ctx context.Context, options arvados.GetOptions) (arvados.AuthorizedKey, error) {
+       as.appendCall(ctx, as.AuthorizedKeyGet, options)
+       return arvados.AuthorizedKey{}, as.Error
+}
+func (as *APIStub) AuthorizedKeyList(ctx context.Context, options arvados.ListOptions) (arvados.AuthorizedKeyList, error) {
+       as.appendCall(ctx, as.AuthorizedKeyList, options)
+       return arvados.AuthorizedKeyList{}, as.Error
+}
+func (as *APIStub) AuthorizedKeyDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.AuthorizedKey, error) {
+       as.appendCall(ctx, as.AuthorizedKeyDelete, options)
+       return arvados.AuthorizedKey{}, as.Error
+}
 func (as *APIStub) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
        as.appendCall(ctx, as.CollectionCreate, options)
        return arvados.Collection{}, as.Error
@@ -141,6 +168,26 @@ func (as *APIStub) ContainerRequestDelete(ctx context.Context, options arvados.D
        as.appendCall(ctx, as.ContainerRequestDelete, options)
        return arvados.ContainerRequest{}, as.Error
 }
+func (as *APIStub) ContainerRequestContainerStatus(ctx context.Context, options arvados.GetOptions) (arvados.ContainerStatus, error) {
+       as.appendCall(ctx, as.ContainerRequestContainerStatus, options)
+       return arvados.ContainerStatus{}, as.Error
+}
+func (as *APIStub) ContainerRequestLog(ctx context.Context, options arvados.ContainerLogOptions) (http.Handler, error) {
+       as.appendCall(ctx, as.ContainerRequestLog, options)
+       // Return a handler that responds with the configured
+       // error/success status.
+       return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+               if as.Error == nil {
+                       w.WriteHeader(http.StatusOK)
+               } else if err := httpserver.HTTPStatusError(nil); errors.As(as.Error, &err) {
+                       w.WriteHeader(err.HTTPStatus())
+                       io.WriteString(w, err.Error())
+               } else {
+                       w.WriteHeader(http.StatusInternalServerError)
+                       io.WriteString(w, err.Error())
+               }
+       }), nil
+}
 func (as *APIStub) GroupCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Group, error) {
        as.appendCall(ctx, as.GroupCreate, options)
        return arvados.Group{}, as.Error
@@ -317,6 +364,26 @@ func (as *APIStub) APIClientAuthorizationGet(ctx context.Context, options arvado
        as.appendCall(ctx, as.APIClientAuthorizationGet, options)
        return arvados.APIClientAuthorization{}, as.Error
 }
+func (as *APIStub) ReadAt(locator string, dst []byte, offset int) (int, error) {
+       as.appendCall(context.TODO(), as.ReadAt, struct {
+               locator string
+               dst     []byte
+               offset  int
+       }{locator, dst, offset})
+       return 0, as.Error
+}
+func (as *APIStub) BlockRead(ctx context.Context, options arvados.BlockReadOptions) (int, error) {
+       as.appendCall(ctx, as.BlockRead, options)
+       return 0, as.Error
+}
+func (as *APIStub) BlockWrite(ctx context.Context, options arvados.BlockWriteOptions) (arvados.BlockWriteResponse, error) {
+       as.appendCall(ctx, as.BlockWrite, options)
+       return arvados.BlockWriteResponse{}, as.Error
+}
+func (as *APIStub) LocalLocator(locator string) (int, error) {
+       as.appendCall(context.TODO(), as.LocalLocator, locator)
+       return 0, as.Error
+}
 
 func (as *APIStub) appendCall(ctx context.Context, method interface{}, options interface{}) {
        as.mtx.Lock()
index ac12f7ae13e93405b37a6814ed4e16bbda2b911e..3b8a618fea099255434033751a156ab62d9a02d8 100644 (file)
@@ -37,8 +37,9 @@ const (
        StorageClassesDesiredArchiveConfirmedDefault = "zzzzz-4zz18-3t236wr12769qqa"
        EmptyCollectionUUID                          = "zzzzz-4zz18-gs9ooj1h9sd5mde"
 
-       AProjectUUID    = "zzzzz-j7d0g-v955i6s2oi1cbso"
-       ASubprojectUUID = "zzzzz-j7d0g-axqo7eu9pwvna1x"
+       AProjectUUID     = "zzzzz-j7d0g-v955i6s2oi1cbso"
+       ASubprojectUUID  = "zzzzz-j7d0g-axqo7eu9pwvna1x"
+       AFilterGroupUUID = "zzzzz-j7d0g-thisfiltergroup"
 
        FooAndBarFilesInDirUUID = "zzzzz-4zz18-foonbarfilesdir"
        FooAndBarFilesInDirPDH  = "870369fc72738603c2fad16664e50e2d+58"
diff --git a/sdk/go/arvadostest/keep_stub.go b/sdk/go/arvadostest/keep_stub.go
new file mode 100644 (file)
index 0000000..ddfa390
--- /dev/null
@@ -0,0 +1,7 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvadostest
+
+type KeepStub struct{}
diff --git a/sdk/go/arvadostest/metrics.go b/sdk/go/arvadostest/metrics.go
new file mode 100644 (file)
index 0000000..5fe1d60
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvadostest
+
+import (
+       "bytes"
+
+       "github.com/prometheus/client_golang/prometheus"
+       "github.com/prometheus/common/expfmt"
+)
+
+func GatherMetricsAsString(reg *prometheus.Registry) string {
+       buf := bytes.NewBuffer(nil)
+       enc := expfmt.NewEncoder(buf, expfmt.FmtText)
+       got, _ := reg.Gather()
+       for _, mf := range got {
+               enc.Encode(mf)
+       }
+       return buf.String()
+}
index 529c1dca12b15a9550dbffcbb9c37e145fa39cb7..31a26671226a0a8ba3452e9e61a41a263bbd3429 100644 (file)
@@ -33,6 +33,10 @@ type OIDCProvider struct {
        AuthGivenName      string
        AuthFamilyName     string
        AccessTokenPayload map[string]interface{}
+       // end_session_endpoint metadata URL.
+       // If nil or empty, not included in discovery.
+       // If relative, built from Issuer.URL.
+       EndSessionEndpoint *url.URL
 
        PeopleAPIResponse map[string]interface{}
 
@@ -71,13 +75,26 @@ func (p *OIDCProvider) serveOIDC(w http.ResponseWriter, req *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        switch req.URL.Path {
        case "/.well-known/openid-configuration":
-               json.NewEncoder(w).Encode(map[string]interface{}{
+               configuration := map[string]interface{}{
                        "issuer":                 p.Issuer.URL,
                        "authorization_endpoint": p.Issuer.URL + "/auth",
                        "token_endpoint":         p.Issuer.URL + "/token",
                        "jwks_uri":               p.Issuer.URL + "/jwks",
                        "userinfo_endpoint":      p.Issuer.URL + "/userinfo",
-               })
+               }
+               if p.EndSessionEndpoint == nil {
+                       // Not included in configuration
+               } else if p.EndSessionEndpoint.Scheme != "" {
+                       configuration["end_session_endpoint"] = p.EndSessionEndpoint.String()
+               } else {
+                       u, err := url.Parse(p.Issuer.URL)
+                       p.c.Check(err, check.IsNil,
+                               check.Commentf("error parsing IssuerURL for EndSessionEndpoint"))
+                       u.Scheme = "https"
+                       u.Path = u.Path + p.EndSessionEndpoint.Path
+                       configuration["end_session_endpoint"] = u.String()
+               }
+               json.NewEncoder(w).Encode(configuration)
        case "/token":
                var clientID, clientSecret string
                auth, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(req.Header.Get("Authorization"), "Basic "))
index 48700d8b186d8fd4d104ed820a6d3060772a86bc..85d433089aa8b7ba746660fafb2aa64457b83067 100644 (file)
@@ -11,6 +11,7 @@ import (
        "net/http/httptest"
        "net/http/httputil"
        "net/url"
+       "sync"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
@@ -26,6 +27,12 @@ type Proxy struct {
 
        // A dump of each request that has been proxied.
        RequestDumps [][]byte
+
+       // If non-nil, func will be called on each incoming request
+       // before proxying it.
+       Director func(*http.Request)
+
+       wg sync.WaitGroup
 }
 
 // NewProxy returns a new Proxy that saves a dump of each reqeust
@@ -62,11 +69,25 @@ func NewProxy(c *check.C, svc arvados.Service) *Proxy {
                Server: srv,
                URL:    u,
        }
+       var mtx sync.Mutex
        rp.Director = func(r *http.Request) {
+               proxy.wg.Add(1)
+               defer proxy.wg.Done()
+               if proxy.Director != nil {
+                       proxy.Director(r)
+               }
                dump, _ := httputil.DumpRequest(r, true)
+               mtx.Lock()
                proxy.RequestDumps = append(proxy.RequestDumps, dump)
+               mtx.Unlock()
                r.URL.Scheme = target.Scheme
                r.URL.Host = target.Host
        }
        return proxy
 }
+
+// Wait waits until all of the proxied requests that have been sent to
+// Director() have also been recorded in RequestDumps.
+func (proxy *Proxy) Wait() {
+       proxy.wg.Wait()
+}
index f1c2e243b53a8f5d7ae604d1b67df55968430fcd..da9b4ea5b8f193fd83072e72ae3ece3cfa6602bc 100644 (file)
@@ -54,13 +54,13 @@ func (a *Credentials) LoadTokensFromHTTPRequest(r *http.Request) {
        // Load plain token from "Authorization: OAuth2 ..." header
        // (typically used by smart API clients)
        if toks := strings.SplitN(r.Header.Get("Authorization"), " ", 2); len(toks) == 2 && (toks[0] == "OAuth2" || toks[0] == "Bearer") {
-               a.Tokens = append(a.Tokens, toks[1])
+               a.Tokens = append(a.Tokens, strings.TrimSpace(toks[1]))
        }
 
        // Load base64-encoded token from "Authorization: Basic ..."
        // header (typically used by git via credential helper)
        if _, password, ok := r.BasicAuth(); ok {
-               a.Tokens = append(a.Tokens, password)
+               a.Tokens = append(a.Tokens, strings.TrimSpace(password))
        }
 
        // Load tokens from query string. It's generally not a good
@@ -76,7 +76,9 @@ func (a *Credentials) LoadTokensFromHTTPRequest(r *http.Request) {
        // find/report decoding errors in a suitable way.
        qvalues, _ := url.ParseQuery(r.URL.RawQuery)
        if val, ok := qvalues["api_token"]; ok {
-               a.Tokens = append(a.Tokens, val...)
+               for _, token := range val {
+                       a.Tokens = append(a.Tokens, strings.TrimSpace(token))
+               }
        }
 
        a.loadTokenFromCookie(r)
@@ -94,7 +96,7 @@ func (a *Credentials) loadTokenFromCookie(r *http.Request) {
        if err != nil {
                return
        }
-       a.Tokens = append(a.Tokens, string(token))
+       a.Tokens = append(a.Tokens, strings.TrimSpace(string(token)))
 }
 
 // LoadTokensFromHTTPRequestBody loads credentials from the request
@@ -111,7 +113,7 @@ func (a *Credentials) LoadTokensFromHTTPRequestBody(r *http.Request) error {
                return err
        }
        if t := r.PostFormValue("api_token"); t != "" {
-               a.Tokens = append(a.Tokens, t)
+               a.Tokens = append(a.Tokens, strings.TrimSpace(t))
        }
        return nil
 }
index 362aeb7f04feacf35dd47da718fc852dae4110f5..85ea8893a50f61bf9531e6e3a5acd01ff09b008a 100644 (file)
@@ -7,6 +7,8 @@ package auth
 import (
        "net/http"
        "net/http/httptest"
+       "net/url"
+       "strings"
        "testing"
 
        check "gopkg.in/check.v1"
@@ -32,9 +34,36 @@ func (s *HandlersSuite) SetUpTest(c *check.C) {
 func (s *HandlersSuite) TestLoadToken(c *check.C) {
        handler := LoadToken(s)
        handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/foo/bar?api_token=xyzzy", nil))
-       c.Assert(s.gotCredentials, check.NotNil)
-       c.Assert(s.gotCredentials.Tokens, check.HasLen, 1)
-       c.Check(s.gotCredentials.Tokens[0], check.Equals, "xyzzy")
+       c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
+}
+
+// Ignore leading and trailing spaces, newlines, etc. in case a user
+// has added them inadvertently during copy/paste.
+func (s *HandlersSuite) TestTrimSpaceInQuery(c *check.C) {
+       handler := LoadToken(s)
+       handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/foo/bar?api_token=%20xyzzy%0a", nil))
+       c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
+}
+func (s *HandlersSuite) TestTrimSpaceInPostForm(c *check.C) {
+       handler := LoadToken(s)
+       req := httptest.NewRequest("POST", "/foo/bar", strings.NewReader(url.Values{"api_token": []string{"\nxyzzy\n"}}.Encode()))
+       req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+       handler.ServeHTTP(httptest.NewRecorder(), req)
+       c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
+}
+func (s *HandlersSuite) TestTrimSpaceInCookie(c *check.C) {
+       handler := LoadToken(s)
+       req := httptest.NewRequest("GET", "/foo/bar", nil)
+       req.AddCookie(&http.Cookie{Name: "arvados_api_token", Value: EncodeTokenCookie([]byte("\vxyzzy\n"))})
+       handler.ServeHTTP(httptest.NewRecorder(), req)
+       c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
+}
+func (s *HandlersSuite) TestTrimSpaceInBasicAuth(c *check.C) {
+       handler := LoadToken(s)
+       req := httptest.NewRequest("GET", "/foo/bar", nil)
+       req.SetBasicAuth("username", "\txyzzy\n")
+       handler.ServeHTTP(httptest.NewRecorder(), req)
+       c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
 }
 
 func (s *HandlersSuite) TestRequireLiteralTokenEmpty(c *check.C) {
@@ -76,4 +105,5 @@ func (s *HandlersSuite) TestRequireLiteralToken(c *check.C) {
 func (s *HandlersSuite) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        s.served++
        s.gotCredentials = CredentialsFromRequest(r)
+       s.gotCredentials.LoadTokensFromHTTPRequestBody(r)
 }
index 75ff85336fada402d3fab4dedece6f634f8aaed9..7a4233d6c6beec1d5ca912bf61a060d3b17756e5 100644 (file)
@@ -10,6 +10,11 @@ import (
        "net/http"
 )
 
+type HTTPStatusError interface {
+       error
+       HTTPStatus() int
+}
+
 func Errorf(status int, tmpl string, args ...interface{}) error {
        return errorWithStatus{fmt.Errorf(tmpl, args...), status}
 }
index 8889453125c4753a62927f830c4e236ecbc272a6..1e3316ed487d17ca2eade2655ad3bfb04c8c6851 100644 (file)
 package httpserver
 
 import (
+       "container/heap"
+       "math"
        "net/http"
-       "sync/atomic"
+       "sync"
+       "time"
 
        "github.com/prometheus/client_golang/prometheus"
+       "github.com/sirupsen/logrus"
 )
 
-// RequestCounter is an http.Handler that tracks the number of
-// requests in progress.
-type RequestCounter interface {
-       http.Handler
+const MinPriority = math.MinInt64
 
-       // Current() returns the number of requests in progress.
-       Current() int
+// Prometheus typically polls every 10 seconds, but it doesn't cost us
+// much to also accommodate higher frequency collection by updating
+// internal stats more frequently. (This limits time resolution only
+// for the metrics that aren't generated on the fly.)
+const metricsUpdateInterval = time.Second
 
-       // Max() returns the maximum number of concurrent requests
-       // that will be accepted.
-       Max() int
+// RequestLimiter wraps http.Handler, limiting the number of
+// concurrent requests being handled by the wrapped Handler. Requests
+// that arrive when the handler is already at the specified
+// concurrency limit are queued and handled in the order indicated by
+// the Priority function.
+//
+// Caller must not modify any RequestLimiter fields after calling its
+// methods.
+type RequestLimiter struct {
+       Handler http.Handler
+
+       // Queue determines which queue a request is assigned to.
+       Queue func(req *http.Request) *RequestQueue
+
+       // Priority determines queue ordering. Requests with higher
+       // priority are handled first. Requests with equal priority
+       // are handled FIFO. If Priority is nil, all requests are
+       // handled FIFO.
+       Priority func(req *http.Request, queued time.Time) int64
+
+       // "concurrent_requests", "max_concurrent_requests",
+       // "queued_requests", and "max_queued_requests" metrics are
+       // registered with Registry, if it is not nil.
+       Registry *prometheus.Registry
+
+       setupOnce     sync.Once
+       mQueueDelay   *prometheus.SummaryVec
+       mQueueTimeout *prometheus.SummaryVec
+       mQueueUsage   *prometheus.GaugeVec
+       mtx           sync.Mutex
+       rqs           map[*RequestQueue]bool // all RequestQueues in use
+}
+
+type RequestQueue struct {
+       // Label for metrics. No two queues should have the same label.
+       Label string
+
+       // Maximum number of requests being handled at once. Beyond
+       // this limit, requests will be queued.
+       MaxConcurrent int
+
+       // Maximum number of requests in the queue. Beyond this limit,
+       // the lowest priority requests will return 503.
+       MaxQueue int
+
+       // Return 503 for any request for which Priority() returns
+       // MinPriority if it spends longer than this in the queue
+       // before starting processing.
+       MaxQueueTimeForMinPriority time.Duration
+
+       queue    queue
+       handling int
 }
 
-type limiterHandler struct {
-       requests chan struct{}
-       handler  http.Handler
-       count    int64 // only used if cap(requests)==0
+type qent struct {
+       rq       *RequestQueue
+       queued   time.Time
+       priority int64
+       heappos  int
+       ready    chan bool // true = handle now; false = return 503 now
 }
 
-// NewRequestLimiter returns a RequestCounter that delegates up to
-// maxRequests at a time to the given handler, and responds 503 to all
-// incoming requests beyond that limit.
-//
-// "concurrent_requests" and "max_concurrent_requests" metrics are
-// registered with the given reg, if reg is not nil.
-func NewRequestLimiter(maxRequests int, handler http.Handler, reg *prometheus.Registry) RequestCounter {
-       h := &limiterHandler{
-               requests: make(chan struct{}, maxRequests),
-               handler:  handler,
+type queue []*qent
+
+func (h queue) Swap(i, j int) {
+       h[i], h[j] = h[j], h[i]
+       h[i].heappos, h[j].heappos = i, j
+}
+
+func (h queue) Less(i, j int) bool {
+       pi, pj := h[i].priority, h[j].priority
+       return pi > pj || (pi == pj && h[i].queued.Before(h[j].queued))
+}
+
+func (h queue) Len() int {
+       return len(h)
+}
+
+func (h *queue) Push(x interface{}) {
+       n := len(*h)
+       ent := x.(*qent)
+       ent.heappos = n
+       *h = append(*h, ent)
+}
+
+func (h *queue) Pop() interface{} {
+       n := len(*h)
+       ent := (*h)[n-1]
+       ent.heappos = -1
+       (*h)[n-1] = nil
+       *h = (*h)[0 : n-1]
+       return ent
+}
+
+func (h *queue) add(ent *qent) {
+       ent.heappos = h.Len()
+       h.Push(ent)
+}
+
+func (h *queue) removeMax() *qent {
+       return heap.Pop(h).(*qent)
+}
+
+func (h *queue) remove(i int) {
+       heap.Remove(h, i)
+}
+
+func (rl *RequestLimiter) setup() {
+       if rl.Registry != nil {
+               mCurrentReqs := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+                       Namespace: "arvados",
+                       Name:      "concurrent_requests",
+                       Help:      "Number of requests in progress",
+               }, []string{"queue"})
+               rl.Registry.MustRegister(mCurrentReqs)
+               mMaxReqs := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+                       Namespace: "arvados",
+                       Name:      "max_concurrent_requests",
+                       Help:      "Maximum number of concurrent requests",
+               }, []string{"queue"})
+               rl.Registry.MustRegister(mMaxReqs)
+               mMaxQueue := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+                       Namespace: "arvados",
+                       Name:      "max_queued_requests",
+                       Help:      "Maximum number of queued requests",
+               }, []string{"queue"})
+               rl.Registry.MustRegister(mMaxQueue)
+               rl.mQueueUsage = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+                       Namespace: "arvados",
+                       Name:      "queued_requests",
+                       Help:      "Number of requests in queue",
+               }, []string{"queue", "priority"})
+               rl.Registry.MustRegister(rl.mQueueUsage)
+               rl.mQueueDelay = prometheus.NewSummaryVec(prometheus.SummaryOpts{
+                       Namespace:  "arvados",
+                       Name:       "queue_delay_seconds",
+                       Help:       "Time spent in the incoming request queue before start of processing",
+                       Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.95: 0.005, 0.99: 0.001},
+               }, []string{"queue", "priority"})
+               rl.Registry.MustRegister(rl.mQueueDelay)
+               rl.mQueueTimeout = prometheus.NewSummaryVec(prometheus.SummaryOpts{
+                       Namespace:  "arvados",
+                       Name:       "queue_timeout_seconds",
+                       Help:       "Time spent in the incoming request queue before client timed out or disconnected",
+                       Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.95: 0.005, 0.99: 0.001},
+               }, []string{"queue", "priority"})
+               rl.Registry.MustRegister(rl.mQueueTimeout)
+               go func() {
+                       for range time.NewTicker(metricsUpdateInterval).C {
+                               rl.mtx.Lock()
+                               for rq := range rl.rqs {
+                                       var low, normal, high int
+                                       for _, ent := range rq.queue {
+                                               switch {
+                                               case ent.priority < 0:
+                                                       low++
+                                               case ent.priority > 0:
+                                                       high++
+                                               default:
+                                                       normal++
+                                               }
+                                       }
+                                       mCurrentReqs.WithLabelValues(rq.Label).Set(float64(rq.handling))
+                                       mMaxReqs.WithLabelValues(rq.Label).Set(float64(rq.MaxConcurrent))
+                                       mMaxQueue.WithLabelValues(rq.Label).Set(float64(rq.MaxQueue))
+                                       rl.mQueueUsage.WithLabelValues(rq.Label, "low").Set(float64(low))
+                                       rl.mQueueUsage.WithLabelValues(rq.Label, "normal").Set(float64(normal))
+                                       rl.mQueueUsage.WithLabelValues(rq.Label, "high").Set(float64(high))
+                               }
+                               rl.mtx.Unlock()
+                       }
+               }()
        }
-       if reg != nil {
-               reg.MustRegister(prometheus.NewGaugeFunc(
-                       prometheus.GaugeOpts{
-                               Namespace: "arvados",
-                               Name:      "concurrent_requests",
-                               Help:      "Number of requests in progress",
-                       },
-                       func() float64 { return float64(h.Current()) },
-               ))
-               reg.MustRegister(prometheus.NewGaugeFunc(
-                       prometheus.GaugeOpts{
-                               Namespace: "arvados",
-                               Name:      "max_concurrent_requests",
-                               Help:      "Maximum number of concurrent requests",
-                       },
-                       func() float64 { return float64(h.Max()) },
-               ))
+}
+
+// caller must have lock
+func (rq *RequestQueue) runqueue() {
+       // Handle entries from the queue as capacity permits
+       for len(rq.queue) > 0 && (rq.MaxConcurrent == 0 || rq.handling < rq.MaxConcurrent) {
+               rq.handling++
+               ent := rq.queue.removeMax()
+               ent.ready <- true
        }
-       return h
 }
 
-func (h *limiterHandler) Current() int {
-       if cap(h.requests) == 0 {
-               return int(atomic.LoadInt64(&h.count))
+// If the queue is too full, fail and remove the lowest-priority
+// entry. Caller must have lock. Queue must not be empty.
+func (rq *RequestQueue) trimqueue() {
+       if len(rq.queue) <= rq.MaxQueue {
+               return
        }
-       return len(h.requests)
+       min := 0
+       for i := range rq.queue {
+               if i == 0 || rq.queue.Less(min, i) {
+                       min = i
+               }
+       }
+       rq.queue[min].ready <- false
+       rq.queue.remove(min)
 }
 
-func (h *limiterHandler) Max() int {
-       return cap(h.requests)
+func (rl *RequestLimiter) enqueue(req *http.Request) *qent {
+       rl.mtx.Lock()
+       defer rl.mtx.Unlock()
+       qtime := time.Now()
+       var priority int64
+       if rl.Priority != nil {
+               priority = rl.Priority(req, qtime)
+       }
+       ent := &qent{
+               rq:       rl.Queue(req),
+               queued:   qtime,
+               priority: priority,
+               ready:    make(chan bool, 1),
+               heappos:  -1,
+       }
+       if rl.rqs == nil {
+               rl.rqs = map[*RequestQueue]bool{}
+       }
+       rl.rqs[ent.rq] = true
+       if ent.rq.MaxConcurrent == 0 || ent.rq.MaxConcurrent > ent.rq.handling {
+               // fast path, skip the queue
+               ent.rq.handling++
+               ent.ready <- true
+               return ent
+       }
+       ent.rq.queue.add(ent)
+       ent.rq.trimqueue()
+       return ent
 }
 
-func (h *limiterHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
-       if cap(h.requests) == 0 {
-               atomic.AddInt64(&h.count, 1)
-               defer atomic.AddInt64(&h.count, -1)
-               h.handler.ServeHTTP(resp, req)
-               return
+func (rl *RequestLimiter) remove(ent *qent) {
+       rl.mtx.Lock()
+       defer rl.mtx.Unlock()
+       if ent.heappos >= 0 {
+               ent.rq.queue.remove(ent.heappos)
+               ent.ready <- false
+       }
+}
+
+func (rl *RequestLimiter) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
+       rl.setupOnce.Do(rl.setup)
+       ent := rl.enqueue(req)
+       SetResponseLogFields(req.Context(), logrus.Fields{"priority": ent.priority, "queue": ent.rq.Label})
+       if ent.priority == MinPriority {
+               // Note that MaxQueueTime==0 does not cancel a req
+               // that skips the queue, because in that case
+               // rl.enqueue() has already fired ready<-true and
+               // rl.remove() is a no-op.
+               go func() {
+                       time.Sleep(ent.rq.MaxQueueTimeForMinPriority)
+                       rl.remove(ent)
+               }()
        }
+       var ok bool
        select {
-       case h.requests <- struct{}{}:
-       default:
-               // reached max requests
+       case <-req.Context().Done():
+               rl.remove(ent)
+               // we still need to wait for ent.ready, because
+               // sometimes runqueue() will have already decided to
+               // send true before our rl.remove() call, and in that
+               // case we'll need to decrement ent.rq.handling below.
+               ok = <-ent.ready
+       case ok = <-ent.ready:
+       }
+
+       // Report time spent in queue in the appropriate bucket:
+       // mQueueDelay if the request actually got processed,
+       // mQueueTimeout if it was abandoned or cancelled before
+       // getting a processing slot.
+       var series *prometheus.SummaryVec
+       if ok {
+               series = rl.mQueueDelay
+       } else {
+               series = rl.mQueueTimeout
+       }
+       if series != nil {
+               var qlabel string
+               switch {
+               case ent.priority < 0:
+                       qlabel = "low"
+               case ent.priority > 0:
+                       qlabel = "high"
+               default:
+                       qlabel = "normal"
+               }
+               series.WithLabelValues(ent.rq.Label, qlabel).Observe(time.Now().Sub(ent.queued).Seconds())
+       }
+
+       if !ok {
                resp.WriteHeader(http.StatusServiceUnavailable)
                return
        }
-       h.handler.ServeHTTP(resp, req)
-       <-h.requests
+       defer func() {
+               rl.mtx.Lock()
+               defer rl.mtx.Unlock()
+               ent.rq.handling--
+               // unblock the next waiting request
+               ent.rq.runqueue()
+       }()
+       rl.Handler.ServeHTTP(resp, req)
 }
index 9258fbfa58f4b5a4867651fc15aba4e9b9616dcf..7366e1426ba5831b1ebdc551cda7c332bdf0446e 100644 (file)
@@ -5,11 +5,14 @@
 package httpserver
 
 import (
+       "fmt"
        "net/http"
        "net/http/httptest"
+       "strconv"
        "sync"
-       "testing"
        "time"
+
+       check "gopkg.in/check.v1"
 )
 
 type testHandler struct {
@@ -29,9 +32,13 @@ func newTestHandler() *testHandler {
        }
 }
 
-func TestRequestLimiter1(t *testing.T) {
+func (s *Suite) TestRequestLimiter1(c *check.C) {
        h := newTestHandler()
-       l := NewRequestLimiter(1, h, nil)
+       rq := &RequestQueue{
+               MaxConcurrent: 1}
+       l := RequestLimiter{
+               Queue:   func(*http.Request) *RequestQueue { return rq },
+               Handler: h}
        var wg sync.WaitGroup
        resps := make([]*httptest.ResponseRecorder, 10)
        for i := 0; i < 10; i++ {
@@ -59,7 +66,7 @@ func TestRequestLimiter1(t *testing.T) {
        select {
        case <-done:
        case <-time.After(10 * time.Second):
-               t.Fatal("test timed out, probably deadlocked")
+               c.Fatal("test timed out, probably deadlocked")
        }
        n200 := 0
        n503 := 0
@@ -70,11 +77,11 @@ func TestRequestLimiter1(t *testing.T) {
                case 503:
                        n503++
                default:
-                       t.Fatalf("Unexpected response code %d", resps[i].Code)
+                       c.Fatalf("Unexpected response code %d", resps[i].Code)
                }
        }
        if n200 != 1 || n503 != 9 {
-               t.Fatalf("Got %d 200 responses, %d 503 responses (expected 1, 9)", n200, n503)
+               c.Fatalf("Got %d 200 responses, %d 503 responses (expected 1, 9)", n200, n503)
        }
        // Now that all 10 are finished, an 11th request should
        // succeed.
@@ -85,13 +92,17 @@ func TestRequestLimiter1(t *testing.T) {
        resp := httptest.NewRecorder()
        l.ServeHTTP(resp, &http.Request{})
        if resp.Code != 200 {
-               t.Errorf("Got status %d on 11th request, want 200", resp.Code)
+               c.Errorf("Got status %d on 11th request, want 200", resp.Code)
        }
 }
 
-func TestRequestLimiter10(t *testing.T) {
+func (*Suite) TestRequestLimiter10(c *check.C) {
        h := newTestHandler()
-       l := NewRequestLimiter(10, h, nil)
+       rq := &RequestQueue{
+               MaxConcurrent: 10}
+       l := RequestLimiter{
+               Queue:   func(*http.Request) *RequestQueue { return rq },
+               Handler: h}
        var wg sync.WaitGroup
        for i := 0; i < 10; i++ {
                wg.Add(1)
@@ -108,3 +119,99 @@ func TestRequestLimiter10(t *testing.T) {
        }
        wg.Wait()
 }
+
+func (*Suite) TestRequestLimiterQueuePriority(c *check.C) {
+       h := newTestHandler()
+       rq := &RequestQueue{
+               MaxConcurrent: 1000,
+               MaxQueue:      200,
+       }
+       rl := RequestLimiter{
+               Handler: h,
+               Queue:   func(*http.Request) *RequestQueue { return rq },
+               Priority: func(r *http.Request, _ time.Time) int64 {
+                       p, _ := strconv.ParseInt(r.Header.Get("Priority"), 10, 64)
+                       return p
+               }}
+
+       c.Logf("starting initial requests")
+       for i := 0; i < rq.MaxConcurrent; i++ {
+               go func() {
+                       rl.ServeHTTP(httptest.NewRecorder(), &http.Request{Header: http.Header{"No-Priority": {"x"}}})
+               }()
+       }
+       c.Logf("waiting for initial requests to consume all MaxConcurrent slots")
+       for i := 0; i < rq.MaxConcurrent; i++ {
+               <-h.inHandler
+       }
+
+       c.Logf("starting %d priority=MinPriority requests (should respond 503 immediately)", rq.MaxQueue)
+       var wgX sync.WaitGroup
+       for i := 0; i < rq.MaxQueue; i++ {
+               wgX.Add(1)
+               go func() {
+                       defer wgX.Done()
+                       resp := httptest.NewRecorder()
+                       rl.ServeHTTP(resp, &http.Request{Header: http.Header{"Priority": {fmt.Sprintf("%d", MinPriority)}}})
+                       c.Check(resp.Code, check.Equals, http.StatusServiceUnavailable)
+               }()
+       }
+       wgX.Wait()
+
+       c.Logf("starting %d priority=MinPriority requests (should respond 503 after 100 ms)", rq.MaxQueue)
+       // Usage docs say the caller isn't allowed to change fields
+       // after first use, but we secretly know it's OK to change
+       // this field on the fly as long as no requests are arriving
+       // concurrently.
+       rq.MaxQueueTimeForMinPriority = time.Millisecond * 100
+       for i := 0; i < rq.MaxQueue; i++ {
+               wgX.Add(1)
+               go func() {
+                       defer wgX.Done()
+                       resp := httptest.NewRecorder()
+                       t0 := time.Now()
+                       rl.ServeHTTP(resp, &http.Request{Header: http.Header{"Priority": {fmt.Sprintf("%d", MinPriority)}}})
+                       c.Check(resp.Code, check.Equals, http.StatusServiceUnavailable)
+                       elapsed := time.Since(t0)
+                       c.Check(elapsed > rq.MaxQueueTimeForMinPriority, check.Equals, true)
+                       c.Check(elapsed < rq.MaxQueueTimeForMinPriority*10, check.Equals, true)
+               }()
+       }
+       wgX.Wait()
+
+       c.Logf("starting %d priority=1 and %d priority=1 requests", rq.MaxQueue, rq.MaxQueue)
+       var wg1, wg2 sync.WaitGroup
+       wg1.Add(rq.MaxQueue)
+       wg2.Add(rq.MaxQueue)
+       for i := 0; i < rq.MaxQueue*2; i++ {
+               i := i
+               go func() {
+                       pri := (i & 1) + 1
+                       resp := httptest.NewRecorder()
+                       rl.ServeHTTP(resp, &http.Request{Header: http.Header{"Priority": {fmt.Sprintf("%d", pri)}}})
+                       if pri == 1 {
+                               c.Check(resp.Code, check.Equals, http.StatusServiceUnavailable)
+                               wg1.Done()
+                       } else {
+                               c.Check(resp.Code, check.Equals, http.StatusOK)
+                               wg2.Done()
+                       }
+               }()
+       }
+
+       c.Logf("waiting for queued priority=1 requests to fail")
+       wg1.Wait()
+
+       c.Logf("allowing initial requests to proceed")
+       for i := 0; i < rq.MaxConcurrent; i++ {
+               h.okToProceed <- struct{}{}
+       }
+
+       c.Logf("allowing queued priority=2 requests to proceed")
+       for i := 0; i < rq.MaxQueue; i++ {
+               <-h.inHandler
+               h.okToProceed <- struct{}{}
+       }
+       c.Logf("waiting for queued priority=2 requests to succeed")
+       wg2.Wait()
+}
diff --git a/sdk/go/keepclient/block_cache.go b/sdk/go/keepclient/block_cache.go
deleted file mode 100644 (file)
index 89eecc6..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-package keepclient
-
-import (
-       "fmt"
-       "io"
-       "sort"
-       "strconv"
-       "strings"
-       "sync"
-       "time"
-)
-
-var DefaultBlockCache = &BlockCache{}
-
-type BlockCache struct {
-       // Maximum number of blocks to keep in the cache. If 0, a
-       // default size (currently 4) is used instead.
-       MaxBlocks int
-
-       cache map[string]*cacheBlock
-       mtx   sync.Mutex
-}
-
-const defaultMaxBlocks = 4
-
-// Sweep deletes the least recently used blocks from the cache until
-// there are no more than MaxBlocks left.
-func (c *BlockCache) Sweep() {
-       max := c.MaxBlocks
-       if max == 0 {
-               max = defaultMaxBlocks
-       }
-       c.mtx.Lock()
-       defer c.mtx.Unlock()
-       if len(c.cache) <= max {
-               return
-       }
-       lru := make([]time.Time, 0, len(c.cache))
-       for _, b := range c.cache {
-               lru = append(lru, b.lastUse)
-       }
-       sort.Sort(sort.Reverse(timeSlice(lru)))
-       threshold := lru[max]
-       for loc, b := range c.cache {
-               if !b.lastUse.After(threshold) {
-                       delete(c.cache, loc)
-               }
-       }
-}
-
-// ReadAt returns data from the cache, first retrieving it from Keep if
-// necessary.
-func (c *BlockCache) ReadAt(kc *KeepClient, locator string, p []byte, off int) (int, error) {
-       buf, err := c.Get(kc, locator)
-       if err != nil {
-               return 0, err
-       }
-       if off > len(buf) {
-               return 0, io.ErrUnexpectedEOF
-       }
-       return copy(p, buf[off:]), nil
-}
-
-// Get returns data from the cache, first retrieving it from Keep if
-// necessary.
-func (c *BlockCache) Get(kc *KeepClient, locator string) ([]byte, error) {
-       cacheKey := locator[:32]
-       bufsize := BLOCKSIZE
-       if parts := strings.SplitN(locator, "+", 3); len(parts) >= 2 {
-               datasize, err := strconv.ParseInt(parts[1], 10, 32)
-               if err == nil && datasize >= 0 {
-                       bufsize = int(datasize)
-               }
-       }
-       c.mtx.Lock()
-       if c.cache == nil {
-               c.cache = make(map[string]*cacheBlock)
-       }
-       b, ok := c.cache[cacheKey]
-       if !ok || b.err != nil {
-               b = &cacheBlock{
-                       fetched: make(chan struct{}),
-                       lastUse: time.Now(),
-               }
-               c.cache[cacheKey] = b
-               go func() {
-                       rdr, size, _, err := kc.Get(locator)
-                       var data []byte
-                       if err == nil {
-                               data = make([]byte, size, bufsize)
-                               _, err = io.ReadFull(rdr, data)
-                               err2 := rdr.Close()
-                               if err == nil && err2 != nil {
-                                       err = fmt.Errorf("close(): %w", err2)
-                               }
-                               if err != nil {
-                                       err = fmt.Errorf("Get %s: %w", locator, err)
-                               }
-                       }
-                       c.mtx.Lock()
-                       b.data, b.err = data, err
-                       c.mtx.Unlock()
-                       close(b.fetched)
-                       go c.Sweep()
-               }()
-       }
-       c.mtx.Unlock()
-
-       // Wait (with mtx unlocked) for the fetch goroutine to finish,
-       // in case it hasn't already.
-       <-b.fetched
-
-       c.mtx.Lock()
-       b.lastUse = time.Now()
-       c.mtx.Unlock()
-       return b.data, b.err
-}
-
-func (c *BlockCache) Clear() {
-       c.mtx.Lock()
-       c.cache = nil
-       c.mtx.Unlock()
-}
-
-type timeSlice []time.Time
-
-func (ts timeSlice) Len() int { return len(ts) }
-
-func (ts timeSlice) Less(i, j int) bool { return ts[i].Before(ts[j]) }
-
-func (ts timeSlice) Swap(i, j int) { ts[i], ts[j] = ts[j], ts[i] }
-
-type cacheBlock struct {
-       data    []byte
-       err     error
-       fetched chan struct{}
-       lastUse time.Time
-}
index 75603f1baa2bcd59f3af7249e7fc540a06d63dae..c1bad8557d70896572c9ef5d24615df5175e6ac6 100644 (file)
@@ -237,7 +237,9 @@ func (s *CollectionReaderUnit) TestCollectionReaderManyBlocks(c *check.C) {
 }
 
 func (s *CollectionReaderUnit) TestCollectionReaderCloseEarly(c *check.C) {
-       s.kc.BlockCache = &BlockCache{}
+       // Disable cache
+       s.kc.gatewayStack = &keepViaHTTP{s.kc}
+
        s.kc.PutB([]byte("foo"))
        s.kc.PutB([]byte("bar"))
        s.kc.PutB([]byte("baz"))
diff --git a/sdk/go/keepclient/gateway_shim.go b/sdk/go/keepclient/gateway_shim.go
new file mode 100644 (file)
index 0000000..2608244
--- /dev/null
@@ -0,0 +1,78 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package keepclient
+
+import (
+       "context"
+       "fmt"
+       "io"
+       "net/http"
+       "strings"
+       "time"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+)
+
+// keepViaHTTP implements arvados.KeepGateway by using a KeepClient to
+// do upstream requests to keepstore and keepproxy.
+//
+// This enables KeepClient to use KeepGateway wrappers (like
+// arvados.DiskCache) to wrap its own HTTP client back-end methods
+// (getOrHead, httpBlockWrite).
+//
+// See (*KeepClient)upstreamGateway() for the relevant glue.
+type keepViaHTTP struct {
+       *KeepClient
+}
+
+func (kvh *keepViaHTTP) ReadAt(locator string, dst []byte, offset int) (int, error) {
+       rdr, _, _, _, err := kvh.getOrHead("GET", locator, nil)
+       if err != nil {
+               return 0, err
+       }
+       defer rdr.Close()
+       _, err = io.CopyN(io.Discard, rdr, int64(offset))
+       if err != nil {
+               return 0, err
+       }
+       n, err := rdr.Read(dst)
+       return int(n), err
+}
+
+func (kvh *keepViaHTTP) BlockRead(ctx context.Context, opts arvados.BlockReadOptions) (int, error) {
+       rdr, _, _, _, err := kvh.getOrHead("GET", opts.Locator, nil)
+       if err != nil {
+               return 0, err
+       }
+       n, err := io.Copy(opts.WriteTo, rdr)
+       errClose := rdr.Close()
+       if err == nil {
+               err = errClose
+       }
+       return int(n), err
+}
+
+func (kvh *keepViaHTTP) BlockWrite(ctx context.Context, req arvados.BlockWriteOptions) (arvados.BlockWriteResponse, error) {
+       return kvh.httpBlockWrite(ctx, req)
+}
+
+func (kvh *keepViaHTTP) LocalLocator(locator string) (string, error) {
+       if !strings.Contains(locator, "+R") {
+               // Either it has +A, or it's unsigned and we assume
+               // it's a local locator on a site with signatures
+               // disabled.
+               return locator, nil
+       }
+       sighdr := fmt.Sprintf("local, time=%s", time.Now().UTC().Format(time.RFC3339))
+       _, _, url, hdr, err := kvh.KeepClient.getOrHead("HEAD", locator, http.Header{"X-Keep-Signature": []string{sighdr}})
+       if err != nil {
+               return "", err
+       }
+       loc := hdr.Get("X-Keep-Locator")
+       if loc == "" {
+               return "", fmt.Errorf("missing X-Keep-Locator header in HEAD response from %s", url)
+       }
+       return loc, nil
+}
index 0966e072eae6d354ad8664d935ce290fb35f7649..f1d5c6ccceda3ab0993871e62529d9376ba963d3 100644 (file)
@@ -47,12 +47,7 @@ func (hcr HashCheckingReader) Read(p []byte) (n int, err error) {
 // BadChecksum if writing is successful but the checksum doesn't
 // match.
 func (hcr HashCheckingReader) WriteTo(dest io.Writer) (written int64, err error) {
-       if writeto, ok := hcr.Reader.(io.WriterTo); ok {
-               written, err = writeto.WriteTo(io.MultiWriter(dest, hcr.Hash))
-       } else {
-               written, err = io.Copy(io.MultiWriter(dest, hcr.Hash), hcr.Reader)
-       }
-
+       written, err = io.Copy(io.MultiWriter(dest, hcr.Hash), hcr.Reader)
        if err != nil {
                return written, err
        }
index 68ac886ddd8ede2809a56a577baedfd67604e7d1..d97a2d1fcd2096a7f44983bbc7349ce11c24d307 100644 (file)
@@ -7,6 +7,7 @@
 package keepclient
 
 import (
+       "bufio"
        "bytes"
        "context"
        "crypto/md5"
@@ -16,6 +17,8 @@ import (
        "io/ioutil"
        "net"
        "net/http"
+       "os"
+       "path/filepath"
        "regexp"
        "strconv"
        "strings"
@@ -40,6 +43,12 @@ var (
        DefaultProxyConnectTimeout      = 30 * time.Second
        DefaultProxyTLSHandshakeTimeout = 10 * time.Second
        DefaultProxyKeepAlive           = 120 * time.Second
+
+       DefaultRetryDelay = 2 * time.Second // see KeepClient.RetryDelay
+       MinimumRetryDelay = time.Millisecond
+
+       rootCacheDir = "/var/cache/arvados/keep"
+       userCacheDir = ".cache/arvados/keep" // relative to HOME
 )
 
 // Error interface with an error and boolean indicating whether the error is temporary
@@ -69,6 +78,8 @@ type ErrNotFound struct {
        multipleResponseError
 }
 
+func (*ErrNotFound) HTTPStatus() int { return http.StatusNotFound }
+
 type InsufficientReplicasError struct{ error }
 
 type OversizeBlockError struct{ error }
@@ -95,20 +106,33 @@ type HTTPClient interface {
        Do(*http.Request) (*http.Response, error)
 }
 
+const DiskCacheDisabled = arvados.ByteSizeOrPercent(1)
+
 // KeepClient holds information about Arvados and Keep servers.
 type KeepClient struct {
-       Arvados               *arvadosclient.ArvadosClient
-       Want_replicas         int
-       localRoots            map[string]string
-       writableLocalRoots    map[string]string
-       gatewayRoots          map[string]string
-       lock                  sync.RWMutex
-       HTTPClient            HTTPClient
-       Retries               int
-       BlockCache            *BlockCache
+       Arvados            *arvadosclient.ArvadosClient
+       Want_replicas      int
+       localRoots         map[string]string
+       writableLocalRoots map[string]string
+       gatewayRoots       map[string]string
+       lock               sync.RWMutex
+       HTTPClient         HTTPClient
+
+       // Number of times to automatically retry a read/write
+       // operation after a transient failure.
+       Retries int
+
+       // Initial maximum delay for automatic retry. If zero,
+       // DefaultRetryDelay is used.  The delay after attempt N
+       // (0-based) will be a random duration between
+       // MinimumRetryDelay and RetryDelay * 2^N, not to exceed a cap
+       // of RetryDelay * 10.
+       RetryDelay time.Duration
+
        RequestID             string
        StorageClasses        []string
-       DefaultStorageClasses []string // Set by cluster's exported config
+       DefaultStorageClasses []string                  // Set by cluster's exported config
+       DiskCacheSize         arvados.ByteSizeOrPercent // See also DiskCacheDisabled
 
        // set to 1 if all writable services are of disk type, otherwise 0
        replicasPerService int
@@ -118,6 +142,30 @@ type KeepClient struct {
 
        // Disable automatic discovery of keep services
        disableDiscovery bool
+
+       gatewayStack arvados.KeepGateway
+}
+
+func (kc *KeepClient) Clone() *KeepClient {
+       kc.lock.Lock()
+       defer kc.lock.Unlock()
+       return &KeepClient{
+               Arvados:               kc.Arvados,
+               Want_replicas:         kc.Want_replicas,
+               localRoots:            kc.localRoots,
+               writableLocalRoots:    kc.writableLocalRoots,
+               gatewayRoots:          kc.gatewayRoots,
+               HTTPClient:            kc.HTTPClient,
+               Retries:               kc.Retries,
+               RetryDelay:            kc.RetryDelay,
+               RequestID:             kc.RequestID,
+               StorageClasses:        kc.StorageClasses,
+               DefaultStorageClasses: kc.DefaultStorageClasses,
+               DiskCacheSize:         kc.DiskCacheSize,
+               replicasPerService:    kc.replicasPerService,
+               foundNonDiskSvc:       kc.foundNonDiskSvc,
+               disableDiscovery:      kc.disableDiscovery,
+       }
 }
 
 func (kc *KeepClient) loadDefaultClasses() error {
@@ -238,6 +286,7 @@ func (kc *KeepClient) getOrHead(method string, locator string, header http.Heade
 
        var errs []string
 
+       delay := delayCalculator{InitialMaxDelay: kc.RetryDelay}
        triesRemaining := 1 + kc.Retries
 
        serversToTry := kc.getSortedRoots(locator)
@@ -317,6 +366,9 @@ func (kc *KeepClient) getOrHead(method string, locator string, header http.Heade
                        return nil, expectLength, url, resp.Header, nil
                }
                serversToTry = retryList
+               if len(serversToTry) > 0 && triesRemaining > 0 {
+                       time.Sleep(delay.Next())
+               }
        }
        DebugPrintf("DEBUG: %s %s failed: %v", method, locator, errs)
 
@@ -332,44 +384,123 @@ func (kc *KeepClient) getOrHead(method string, locator string, header http.Heade
        return nil, 0, "", nil, err
 }
 
+// attempt to create dir/subdir/ and its parents, up to but not
+// including dir itself, using mode 0700.
+func makedirs(dir, subdir string) {
+       for _, part := range strings.Split(subdir, string(os.PathSeparator)) {
+               dir = filepath.Join(dir, part)
+               os.Mkdir(dir, 0700)
+       }
+}
+
+// upstreamGateway creates/returns the KeepGateway stack used to read
+// and write data: a disk-backed cache on top of an http backend.
+func (kc *KeepClient) upstreamGateway() arvados.KeepGateway {
+       kc.lock.Lock()
+       defer kc.lock.Unlock()
+       if kc.gatewayStack != nil {
+               return kc.gatewayStack
+       }
+       var cachedir string
+       if os.Geteuid() == 0 {
+               cachedir = rootCacheDir
+               makedirs("/", cachedir)
+       } else {
+               home := "/" + os.Getenv("HOME")
+               makedirs(home, userCacheDir)
+               cachedir = filepath.Join(home, userCacheDir)
+       }
+       backend := &keepViaHTTP{kc}
+       if kc.DiskCacheSize == DiskCacheDisabled {
+               kc.gatewayStack = backend
+       } else {
+               kc.gatewayStack = &arvados.DiskCache{
+                       Dir:         cachedir,
+                       MaxSize:     kc.DiskCacheSize,
+                       KeepGateway: backend,
+               }
+       }
+       return kc.gatewayStack
+}
+
 // LocalLocator returns a locator equivalent to the one supplied, but
 // with a valid signature from the local cluster. If the given locator
 // already has a local signature, it is returned unchanged.
 func (kc *KeepClient) LocalLocator(locator string) (string, error) {
-       if !strings.Contains(locator, "+R") {
-               // Either it has +A, or it's unsigned and we assume
-               // it's a local locator on a site with signatures
-               // disabled.
-               return locator, nil
-       }
-       sighdr := fmt.Sprintf("local, time=%s", time.Now().UTC().Format(time.RFC3339))
-       _, _, url, hdr, err := kc.getOrHead("HEAD", locator, http.Header{"X-Keep-Signature": []string{sighdr}})
-       if err != nil {
-               return "", err
-       }
-       loc := hdr.Get("X-Keep-Locator")
-       if loc == "" {
-               return "", fmt.Errorf("missing X-Keep-Locator header in HEAD response from %s", url)
-       }
-       return loc, nil
+       return kc.upstreamGateway().LocalLocator(locator)
 }
 
-// Get retrieves a block, given a locator. Returns a reader, the
-// expected data length, the URL the block is being fetched from, and
-// an error.
+// Get retrieves the specified block from the local cache or a backend
+// server. Returns a reader, the expected data length (or -1 if not
+// known), and an error.
+//
+// The third return value (formerly a source URL in previous versions)
+// is an empty string.
 //
 // If the block checksum does not match, the final Read() on the
 // reader returned by this method will return a BadChecksum error
 // instead of EOF.
+//
+// New code should use BlockRead and/or ReadAt instead of Get.
 func (kc *KeepClient) Get(locator string) (io.ReadCloser, int64, string, error) {
-       rdr, size, url, _, err := kc.getOrHead("GET", locator, nil)
-       return rdr, size, url, err
+       loc, err := MakeLocator(locator)
+       if err != nil {
+               return nil, 0, "", err
+       }
+       pr, pw := io.Pipe()
+       go func() {
+               n, err := kc.BlockRead(context.Background(), arvados.BlockReadOptions{
+                       Locator: locator,
+                       WriteTo: pw,
+               })
+               if err != nil {
+                       pw.CloseWithError(err)
+               } else if loc.Size >= 0 && n != loc.Size {
+                       pw.CloseWithError(fmt.Errorf("expected block size %d but read %d bytes", loc.Size, n))
+               } else {
+                       pw.Close()
+               }
+       }()
+       // Wait for the first byte to arrive, so that, if there's an
+       // error before we receive any data, we can return the error
+       // directly, instead of indirectly via a reader that returns
+       // an error.
+       bufr := bufio.NewReader(pr)
+       _, err = bufr.Peek(1)
+       if err != nil && err != io.EOF {
+               pr.CloseWithError(err)
+               return nil, 0, "", err
+       }
+       if err == io.EOF && (loc.Size == 0 || loc.Hash == "d41d8cd98f00b204e9800998ecf8427e") {
+               // In the special case of the zero-length block, EOF
+               // error from Peek() is normal.
+               return pr, 0, "", nil
+       }
+       return struct {
+               io.Reader
+               io.Closer
+       }{
+               Reader: bufr,
+               Closer: pr,
+       }, int64(loc.Size), "", err
+}
+
+// BlockRead retrieves a block from the cache if it's present, otherwise
+// from the network.
+func (kc *KeepClient) BlockRead(ctx context.Context, opts arvados.BlockReadOptions) (int, error) {
+       return kc.upstreamGateway().BlockRead(ctx, opts)
 }
 
 // ReadAt retrieves a portion of block from the cache if it's
 // present, otherwise from the network.
 func (kc *KeepClient) ReadAt(locator string, p []byte, off int) (int, error) {
-       return kc.cache().ReadAt(kc, locator, p, off)
+       return kc.upstreamGateway().ReadAt(locator, p, off)
+}
+
+// BlockWrite writes a full block to upstream servers and saves a copy
+// in the local cache.
+func (kc *KeepClient) BlockWrite(ctx context.Context, req arvados.BlockWriteOptions) (arvados.BlockWriteResponse, error) {
+       return kc.upstreamGateway().BlockWrite(ctx, req)
 }
 
 // Ask verifies that a block with the given hash is available and
@@ -511,17 +642,6 @@ func (kc *KeepClient) getSortedRoots(locator string) []string {
        return found
 }
 
-func (kc *KeepClient) cache() *BlockCache {
-       if kc.BlockCache != nil {
-               return kc.BlockCache
-       }
-       return DefaultBlockCache
-}
-
-func (kc *KeepClient) ClearBlockCache() {
-       kc.cache().Clear()
-}
-
 func (kc *KeepClient) SetStorageClasses(sc []string) {
        // make a copy so the caller can't mess with it.
        kc.StorageClasses = append([]string{}, sc...)
index a6e0a11d510b6f5c79a510a1db22633cc775a134..531db31b25cf6cb3d0e20b938358f155922b7433 100644 (file)
@@ -17,6 +17,7 @@ import (
        "os"
        "strings"
        "sync"
+       "sync/atomic"
        "testing"
        "time"
 
@@ -26,8 +27,8 @@ import (
        . "gopkg.in/check.v1"
 )
 
-// Gocheck boilerplate
 func Test(t *testing.T) {
+       DefaultRetryDelay = 50 * time.Millisecond
        TestingT(t)
 }
 
@@ -39,10 +40,25 @@ var _ = Suite(&StandaloneSuite{})
 type ServerRequiredSuite struct{}
 
 // Standalone tests
-type StandaloneSuite struct{}
+type StandaloneSuite struct {
+       origDefaultRetryDelay time.Duration
+       origMinimumRetryDelay time.Duration
+}
+
+var origHOME = os.Getenv("HOME")
 
 func (s *StandaloneSuite) SetUpTest(c *C) {
        RefreshServiceDiscovery()
+       // Prevent cache state from leaking between test cases
+       os.Setenv("HOME", c.MkDir())
+       s.origDefaultRetryDelay = DefaultRetryDelay
+       s.origMinimumRetryDelay = MinimumRetryDelay
+}
+
+func (s *StandaloneSuite) TearDownTest(c *C) {
+       os.Setenv("HOME", origHOME)
+       DefaultRetryDelay = s.origDefaultRetryDelay
+       MinimumRetryDelay = s.origMinimumRetryDelay
 }
 
 func pythonDir() string {
@@ -56,19 +72,22 @@ func (s *ServerRequiredSuite) SetUpSuite(c *C) {
 
 func (s *ServerRequiredSuite) TearDownSuite(c *C) {
        arvadostest.StopKeep(2)
+       os.Setenv("HOME", origHOME)
 }
 
 func (s *ServerRequiredSuite) SetUpTest(c *C) {
        RefreshServiceDiscovery()
+       // Prevent cache state from leaking between test cases
+       os.Setenv("HOME", c.MkDir())
 }
 
 func (s *ServerRequiredSuite) TestMakeKeepClient(c *C) {
        arv, err := arvadosclient.MakeArvadosClient()
-       c.Assert(err, Equals, nil)
+       c.Assert(err, IsNil)
 
        kc, err := MakeKeepClient(arv)
 
-       c.Assert(err, Equals, nil)
+       c.Assert(err, IsNil)
        c.Check(len(kc.LocalRoots()), Equals, 2)
        for _, root := range kc.LocalRoots() {
                c.Check(root, Matches, "http://localhost:\\d+")
@@ -129,7 +148,7 @@ func (sph *StubPutHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request
                sph.c.Check(req.Header.Get("X-Keep-Storage-Classes"), Equals, sph.expectStorageClass)
        }
        body, err := ioutil.ReadAll(req.Body)
-       sph.c.Check(err, Equals, nil)
+       sph.c.Check(err, IsNil)
        sph.c.Check(body, DeepEquals, []byte(sph.expectBody))
        resp.Header().Set("X-Keep-Replicas-Stored", "1")
        if sph.returnStorageClasses != "" {
@@ -410,17 +429,17 @@ func (fh FailHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
 }
 
 type FailThenSucceedHandler struct {
+       morefails      int // fail 1 + this many times before succeeding
        handled        chan string
-       count          int
+       count          atomic.Int64
        successhandler http.Handler
        reqIDs         []string
 }
 
 func (fh *FailThenSucceedHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
        fh.reqIDs = append(fh.reqIDs, req.Header.Get("X-Request-Id"))
-       if fh.count == 0 {
+       if int(fh.count.Add(1)) <= fh.morefails+1 {
                resp.WriteHeader(500)
-               fh.count++
                fh.handled <- fmt.Sprintf("http://%s", req.Host)
        } else {
                fh.successhandler.ServeHTTP(resp, req)
@@ -549,14 +568,7 @@ func (s *StandaloneSuite) TestPutHR(c *C) {
 
        kc.SetServiceRoots(localRoots, writableLocalRoots, nil)
 
-       reader, writer := io.Pipe()
-
-       go func() {
-               writer.Write([]byte("foo"))
-               writer.Close()
-       }()
-
-       kc.PutHR(hash, reader, 3)
+       kc.PutHR(hash, bytes.NewBuffer([]byte("foo")), 3)
 
        shuff := NewRootSorter(kc.LocalRoots(), hash).GetSortedRoots()
 
@@ -618,7 +630,7 @@ func (s *StandaloneSuite) TestPutWithFail(c *C) {
 
        <-fh.handled
 
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
        c.Check(phash, Equals, "")
        c.Check(replicas, Equals, 2)
 
@@ -697,7 +709,7 @@ func (sgh StubGetHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request)
 }
 
 func (s *StandaloneSuite) TestGet(c *C) {
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        st := StubGetHandler{
                c,
@@ -715,19 +727,18 @@ func (s *StandaloneSuite) TestGet(c *C) {
        arv.ApiToken = "abc123"
        kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
 
-       r, n, url2, err := kc.Get(hash)
-       defer r.Close()
-       c.Check(err, Equals, nil)
+       r, n, _, err := kc.Get(hash)
+       c.Assert(err, IsNil)
        c.Check(n, Equals, int64(3))
-       c.Check(url2, Equals, fmt.Sprintf("%s/%s", ks.url, hash))
 
        content, err2 := ioutil.ReadAll(r)
-       c.Check(err2, Equals, nil)
+       c.Check(err2, IsNil)
        c.Check(content, DeepEquals, []byte("foo"))
+       c.Check(r.Close(), IsNil)
 }
 
 func (s *StandaloneSuite) TestGet404(c *C) {
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        st := Error404Handler{make(chan string, 1)}
 
@@ -740,11 +751,10 @@ func (s *StandaloneSuite) TestGet404(c *C) {
        arv.ApiToken = "abc123"
        kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
 
-       r, n, url2, err := kc.Get(hash)
+       r, n, _, err := kc.Get(hash)
        c.Check(err, Equals, BlockNotFound)
        c.Check(n, Equals, int64(0))
-       c.Check(url2, Equals, "")
-       c.Check(r, Equals, nil)
+       c.Check(r, IsNil)
 }
 
 func (s *StandaloneSuite) TestGetEmptyBlock(c *C) {
@@ -759,18 +769,18 @@ func (s *StandaloneSuite) TestGetEmptyBlock(c *C) {
        arv.ApiToken = "abc123"
        kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
 
-       r, n, url2, err := kc.Get("d41d8cd98f00b204e9800998ecf8427e+0")
+       r, n, _, err := kc.Get("d41d8cd98f00b204e9800998ecf8427e+0")
        c.Check(err, IsNil)
        c.Check(n, Equals, int64(0))
-       c.Check(url2, Equals, "")
        c.Assert(r, NotNil)
        buf, err := ioutil.ReadAll(r)
        c.Check(err, IsNil)
        c.Check(buf, DeepEquals, []byte{})
+       c.Check(r.Close(), IsNil)
 }
 
 func (s *StandaloneSuite) TestGetFail(c *C) {
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        st := FailHandler{make(chan string, 1)}
 
@@ -784,57 +794,84 @@ func (s *StandaloneSuite) TestGetFail(c *C) {
        kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
        kc.Retries = 0
 
-       r, n, url2, err := kc.Get(hash)
+       r, n, _, err := kc.Get(hash)
        errNotFound, _ := err.(*ErrNotFound)
-       c.Check(errNotFound, NotNil)
-       c.Check(strings.Contains(errNotFound.Error(), "HTTP 500"), Equals, true)
-       c.Check(errNotFound.Temporary(), Equals, true)
+       if c.Check(errNotFound, NotNil) {
+               c.Check(strings.Contains(errNotFound.Error(), "HTTP 500"), Equals, true)
+               c.Check(errNotFound.Temporary(), Equals, true)
+       }
        c.Check(n, Equals, int64(0))
-       c.Check(url2, Equals, "")
-       c.Check(r, Equals, nil)
+       c.Check(r, IsNil)
 }
 
 func (s *StandaloneSuite) TestGetFailRetry(c *C) {
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
-
-       st := &FailThenSucceedHandler{
-               handled: make(chan string, 1),
-               successhandler: StubGetHandler{
-                       c,
-                       hash,
-                       "abc123",
-                       http.StatusOK,
-                       []byte("foo")}}
-
-       ks := RunFakeKeepServer(st)
-       defer ks.listener.Close()
-
-       arv, err := arvadosclient.MakeArvadosClient()
-       c.Check(err, IsNil)
-       kc, _ := MakeKeepClient(arv)
-       arv.ApiToken = "abc123"
-       kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
-
-       r, n, url2, err := kc.Get(hash)
-       defer r.Close()
-       c.Check(err, Equals, nil)
-       c.Check(n, Equals, int64(3))
-       c.Check(url2, Equals, fmt.Sprintf("%s/%s", ks.url, hash))
-
-       content, err2 := ioutil.ReadAll(r)
-       c.Check(err2, Equals, nil)
-       c.Check(content, DeepEquals, []byte("foo"))
-
-       c.Logf("%q", st.reqIDs)
-       c.Assert(len(st.reqIDs) > 1, Equals, true)
-       for _, reqid := range st.reqIDs {
-               c.Check(reqid, Not(Equals), "")
-               c.Check(reqid, Equals, st.reqIDs[0])
+       defer func(origDefault, origMinimum time.Duration) {
+               DefaultRetryDelay = origDefault
+               MinimumRetryDelay = origMinimum
+       }(DefaultRetryDelay, MinimumRetryDelay)
+       DefaultRetryDelay = time.Second / 8
+       MinimumRetryDelay = time.Millisecond
+
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
+
+       for _, delay := range []time.Duration{0, time.Nanosecond, time.Second / 8, time.Second / 16} {
+               c.Logf("=== initial delay %v", delay)
+
+               st := &FailThenSucceedHandler{
+                       morefails: 2,
+                       handled:   make(chan string, 4),
+                       successhandler: StubGetHandler{
+                               c,
+                               hash,
+                               "abc123",
+                               http.StatusOK,
+                               []byte("foo")}}
+
+               ks := RunFakeKeepServer(st)
+               defer ks.listener.Close()
+
+               arv, err := arvadosclient.MakeArvadosClient()
+               c.Check(err, IsNil)
+               kc, _ := MakeKeepClient(arv)
+               arv.ApiToken = "abc123"
+               kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
+               kc.Retries = 3
+               kc.RetryDelay = delay
+               kc.DiskCacheSize = DiskCacheDisabled
+
+               t0 := time.Now()
+               r, n, _, err := kc.Get(hash)
+               c.Assert(err, IsNil)
+               c.Check(n, Equals, int64(3))
+               elapsed := time.Since(t0)
+
+               nonsleeptime := time.Second / 10
+               expect := kc.RetryDelay
+               if expect == 0 {
+                       expect = DefaultRetryDelay
+               }
+               min := MinimumRetryDelay * 3
+               max := expect + expect*2 + expect*2*2 + nonsleeptime
+               c.Check(elapsed >= min, Equals, true, Commentf("elapsed %v / expect min %v", elapsed, min))
+               c.Check(elapsed <= max, Equals, true, Commentf("elapsed %v / expect max %v", elapsed, max))
+
+               content, err := ioutil.ReadAll(r)
+               c.Check(err, IsNil)
+               c.Check(content, DeepEquals, []byte("foo"))
+               c.Check(r.Close(), IsNil)
+
+               c.Logf("%q", st.reqIDs)
+               if c.Check(st.reqIDs, Not(HasLen), 0) {
+                       for _, reqid := range st.reqIDs {
+                               c.Check(reqid, Not(Equals), "")
+                               c.Check(reqid, Equals, st.reqIDs[0])
+                       }
+               }
        }
 }
 
 func (s *StandaloneSuite) TestGetNetError(c *C) {
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        arv, err := arvadosclient.MakeArvadosClient()
        c.Check(err, IsNil)
@@ -842,19 +879,19 @@ func (s *StandaloneSuite) TestGetNetError(c *C) {
        arv.ApiToken = "abc123"
        kc.SetServiceRoots(map[string]string{"x": "http://localhost:62222"}, nil, nil)
 
-       r, n, url2, err := kc.Get(hash)
+       r, n, _, err := kc.Get(hash)
        errNotFound, _ := err.(*ErrNotFound)
-       c.Check(errNotFound, NotNil)
-       c.Check(strings.Contains(errNotFound.Error(), "connection refused"), Equals, true)
-       c.Check(errNotFound.Temporary(), Equals, true)
+       if c.Check(errNotFound, NotNil) {
+               c.Check(strings.Contains(errNotFound.Error(), "connection refused"), Equals, true)
+               c.Check(errNotFound.Temporary(), Equals, true)
+       }
        c.Check(n, Equals, int64(0))
-       c.Check(url2, Equals, "")
-       c.Check(r, Equals, nil)
+       c.Check(r, IsNil)
 }
 
 func (s *StandaloneSuite) TestGetWithServiceHint(c *C) {
        uuid := "zzzzz-bi6l4-123451234512345"
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        // This one shouldn't be used:
        ks0 := RunFakeKeepServer(StubGetHandler{
@@ -882,22 +919,21 @@ func (s *StandaloneSuite) TestGetWithServiceHint(c *C) {
                nil,
                map[string]string{uuid: ks.url})
 
-       r, n, uri, err := kc.Get(hash + "+K@" + uuid)
-       defer r.Close()
-       c.Check(err, Equals, nil)
+       r, n, _, err := kc.Get(hash + "+K@" + uuid)
+       c.Assert(err, IsNil)
        c.Check(n, Equals, int64(3))
-       c.Check(uri, Equals, fmt.Sprintf("%s/%s", ks.url, hash+"+K@"+uuid))
 
        content, err := ioutil.ReadAll(r)
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
        c.Check(content, DeepEquals, []byte("foo"))
+       c.Check(r.Close(), IsNil)
 }
 
 // Use a service hint to fetch from a local disk service, overriding
 // rendezvous probe order.
 func (s *StandaloneSuite) TestGetWithLocalServiceHint(c *C) {
        uuid := "zzzzz-bi6l4-zzzzzzzzzzzzzzz"
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        // This one shouldn't be used, although it appears first in
        // rendezvous probe order:
@@ -905,8 +941,8 @@ func (s *StandaloneSuite) TestGetWithLocalServiceHint(c *C) {
                c,
                "error if used",
                "abc123",
-               http.StatusOK,
-               []byte("foo")})
+               http.StatusBadGateway,
+               nil})
        defer ks0.listener.Close()
        // This one should be used:
        ks := RunFakeKeepServer(StubGetHandler{
@@ -935,20 +971,19 @@ func (s *StandaloneSuite) TestGetWithLocalServiceHint(c *C) {
                        uuid:                          ks.url},
        )
 
-       r, n, uri, err := kc.Get(hash + "+K@" + uuid)
-       defer r.Close()
-       c.Check(err, Equals, nil)
+       r, n, _, err := kc.Get(hash + "+K@" + uuid)
+       c.Assert(err, IsNil)
        c.Check(n, Equals, int64(3))
-       c.Check(uri, Equals, fmt.Sprintf("%s/%s", ks.url, hash+"+K@"+uuid))
 
        content, err := ioutil.ReadAll(r)
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
        c.Check(content, DeepEquals, []byte("foo"))
+       c.Check(r.Close(), IsNil)
 }
 
 func (s *StandaloneSuite) TestGetWithServiceHintFailoverToLocals(c *C) {
        uuid := "zzzzz-bi6l4-123451234512345"
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        ksLocal := RunFakeKeepServer(StubGetHandler{
                c,
@@ -974,15 +1009,14 @@ func (s *StandaloneSuite) TestGetWithServiceHintFailoverToLocals(c *C) {
                nil,
                map[string]string{uuid: ksGateway.url})
 
-       r, n, uri, err := kc.Get(hash + "+K@" + uuid)
-       c.Assert(err, Equals, nil)
-       defer r.Close()
+       r, n, _, err := kc.Get(hash + "+K@" + uuid)
+       c.Assert(err, IsNil)
        c.Check(n, Equals, int64(3))
-       c.Check(uri, Equals, fmt.Sprintf("%s/%s", ksLocal.url, hash+"+K@"+uuid))
 
        content, err := ioutil.ReadAll(r)
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
        c.Check(content, DeepEquals, []byte("foo"))
+       c.Check(r.Close(), IsNil)
 }
 
 type BarHandler struct {
@@ -995,8 +1029,8 @@ func (h BarHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
 }
 
 func (s *StandaloneSuite) TestChecksum(c *C) {
-       foohash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
-       barhash := fmt.Sprintf("%x", md5.Sum([]byte("bar")))
+       foohash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
+       barhash := fmt.Sprintf("%x+3", md5.Sum([]byte("bar")))
 
        st := BarHandler{make(chan string, 1)}
 
@@ -1010,25 +1044,36 @@ func (s *StandaloneSuite) TestChecksum(c *C) {
        kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
 
        r, n, _, err := kc.Get(barhash)
-       c.Check(err, IsNil)
-       _, err = ioutil.ReadAll(r)
-       c.Check(n, Equals, int64(3))
-       c.Check(err, Equals, nil)
+       if c.Check(err, IsNil) {
+               _, err = ioutil.ReadAll(r)
+               c.Check(n, Equals, int64(3))
+               c.Check(err, IsNil)
+       }
 
-       <-st.handled
+       select {
+       case <-st.handled:
+       case <-time.After(time.Second):
+               c.Fatal("timed out")
+       }
 
        r, n, _, err = kc.Get(foohash)
-       c.Check(err, IsNil)
-       _, err = ioutil.ReadAll(r)
-       c.Check(n, Equals, int64(3))
+       if err == nil {
+               buf, readerr := ioutil.ReadAll(r)
+               c.Logf("%q", buf)
+               err = readerr
+       }
        c.Check(err, Equals, BadChecksum)
 
-       <-st.handled
+       select {
+       case <-st.handled:
+       case <-time.After(time.Second):
+               c.Fatal("timed out")
+       }
 }
 
 func (s *StandaloneSuite) TestGetWithFailures(c *C) {
        content := []byte("waz")
-       hash := fmt.Sprintf("%x", md5.Sum(content))
+       hash := fmt.Sprintf("%x+3", md5.Sum(content))
 
        fh := Error404Handler{
                make(chan string, 4)}
@@ -1072,16 +1117,20 @@ func (s *StandaloneSuite) TestGetWithFailures(c *C) {
        // an example that passes this Assert.)
        c.Assert(NewRootSorter(localRoots, hash).GetSortedRoots()[0], Not(Equals), ks1[0].url)
 
-       r, n, url2, err := kc.Get(hash)
+       r, n, _, err := kc.Get(hash)
 
-       <-fh.handled
-       c.Check(err, Equals, nil)
+       select {
+       case <-fh.handled:
+       case <-time.After(time.Second):
+               c.Fatal("timed out")
+       }
+       c.Assert(err, IsNil)
        c.Check(n, Equals, int64(3))
-       c.Check(url2, Equals, fmt.Sprintf("%s/%s", ks1[0].url, hash))
 
        readContent, err2 := ioutil.ReadAll(r)
-       c.Check(err2, Equals, nil)
+       c.Check(err2, IsNil)
        c.Check(readContent, DeepEquals, content)
+       c.Check(r.Close(), IsNil)
 }
 
 func (s *ServerRequiredSuite) TestPutGetHead(c *C) {
@@ -1090,9 +1139,9 @@ func (s *ServerRequiredSuite) TestPutGetHead(c *C) {
        arv, err := arvadosclient.MakeArvadosClient()
        c.Check(err, IsNil)
        kc, err := MakeKeepClient(arv)
-       c.Assert(err, Equals, nil)
+       c.Assert(err, IsNil)
 
-       hash := fmt.Sprintf("%x", md5.Sum(content))
+       hash := fmt.Sprintf("%x+%d", md5.Sum(content), len(content))
 
        {
                n, _, err := kc.Ask(hash)
@@ -1101,29 +1150,32 @@ func (s *ServerRequiredSuite) TestPutGetHead(c *C) {
        }
        {
                hash2, replicas, err := kc.PutB(content)
-               c.Check(hash2, Matches, fmt.Sprintf(`%s\+%d\b.*`, hash, len(content)))
+               c.Check(err, IsNil)
+               c.Check(hash2, Matches, `\Q`+hash+`\E\b.*`)
                c.Check(replicas, Equals, 2)
-               c.Check(err, Equals, nil)
        }
        {
-               r, n, url2, err := kc.Get(hash)
-               c.Check(err, Equals, nil)
+               r, n, _, err := kc.Get(hash)
+               c.Check(err, IsNil)
                c.Check(n, Equals, int64(len(content)))
-               c.Check(url2, Matches, fmt.Sprintf("http://localhost:\\d+/%s", hash))
-
-               readContent, err2 := ioutil.ReadAll(r)
-               c.Check(err2, Equals, nil)
-               c.Check(readContent, DeepEquals, content)
+               if c.Check(r, NotNil) {
+                       readContent, err := ioutil.ReadAll(r)
+                       c.Check(err, IsNil)
+                       if c.Check(len(readContent), Equals, len(content)) {
+                               c.Check(readContent, DeepEquals, content)
+                       }
+                       c.Check(r.Close(), IsNil)
+               }
        }
        {
                n, url2, err := kc.Ask(hash)
-               c.Check(err, Equals, nil)
+               c.Check(err, IsNil)
                c.Check(n, Equals, int64(len(content)))
-               c.Check(url2, Matches, fmt.Sprintf("http://localhost:\\d+/%s", hash))
+               c.Check(url2, Matches, "http://localhost:\\d+/\\Q"+hash+"\\E")
        }
        {
                loc, err := kc.LocalLocator(hash)
-               c.Check(err, Equals, nil)
+               c.Check(err, IsNil)
                c.Assert(len(loc) >= 32, Equals, true)
                c.Check(loc[:32], Equals, hash[:32])
        }
@@ -1170,7 +1222,7 @@ func (s *StandaloneSuite) TestPutProxy(c *C) {
        _, replicas, err := kc.PutB([]byte("foo"))
        <-st.handled
 
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
        c.Check(replicas, Equals, 2)
 }
 
@@ -1204,7 +1256,7 @@ func (s *StandaloneSuite) TestPutProxyInsufficientReplicas(c *C) {
 
 func (s *StandaloneSuite) TestMakeLocator(c *C) {
        l, err := MakeLocator("91f372a266fe2bf2823cb8ec7fda31ce+3+Aabcde@12345678")
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
        c.Check(l.Hash, Equals, "91f372a266fe2bf2823cb8ec7fda31ce")
        c.Check(l.Size, Equals, 3)
        c.Check(l.Hints, DeepEquals, []string{"3", "Aabcde@12345678"})
@@ -1212,7 +1264,7 @@ func (s *StandaloneSuite) TestMakeLocator(c *C) {
 
 func (s *StandaloneSuite) TestMakeLocatorNoHints(c *C) {
        l, err := MakeLocator("91f372a266fe2bf2823cb8ec7fda31ce")
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
        c.Check(l.Hash, Equals, "91f372a266fe2bf2823cb8ec7fda31ce")
        c.Check(l.Size, Equals, -1)
        c.Check(l.Hints, DeepEquals, []string{})
@@ -1220,7 +1272,7 @@ func (s *StandaloneSuite) TestMakeLocatorNoHints(c *C) {
 
 func (s *StandaloneSuite) TestMakeLocatorNoSizeHint(c *C) {
        l, err := MakeLocator("91f372a266fe2bf2823cb8ec7fda31ce+Aabcde@12345678")
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
        c.Check(l.Hash, Equals, "91f372a266fe2bf2823cb8ec7fda31ce")
        c.Check(l.Size, Equals, -1)
        c.Check(l.Hints, DeepEquals, []string{"Aabcde@12345678"})
@@ -1229,7 +1281,7 @@ func (s *StandaloneSuite) TestMakeLocatorNoSizeHint(c *C) {
 func (s *StandaloneSuite) TestMakeLocatorPreservesUnrecognizedHints(c *C) {
        str := "91f372a266fe2bf2823cb8ec7fda31ce+3+Unknown+Kzzzzz+Afoobar"
        l, err := MakeLocator(str)
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
        c.Check(l.Hash, Equals, "91f372a266fe2bf2823cb8ec7fda31ce")
        c.Check(l.Size, Equals, 3)
        c.Check(l.Hints, DeepEquals, []string{"3", "Unknown", "Kzzzzz", "Afoobar"})
@@ -1335,14 +1387,14 @@ func (h StubGetIndexHandler) ServeHTTP(resp http.ResponseWriter, req *http.Reque
 }
 
 func (s *StandaloneSuite) TestGetIndexWithNoPrefix(c *C) {
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        st := StubGetIndexHandler{
                c,
                "/index",
                "abc123",
                http.StatusOK,
-               []byte(hash + "+3 1443559274\n\n")}
+               []byte(hash + " 1443559274\n\n")}
 
        ks := RunFakeKeepServer(st)
        defer ks.listener.Close()
@@ -1358,19 +1410,19 @@ func (s *StandaloneSuite) TestGetIndexWithNoPrefix(c *C) {
        c.Check(err, IsNil)
 
        content, err2 := ioutil.ReadAll(r)
-       c.Check(err2, Equals, nil)
+       c.Check(err2, IsNil)
        c.Check(content, DeepEquals, st.body[0:len(st.body)-1])
 }
 
 func (s *StandaloneSuite) TestGetIndexWithPrefix(c *C) {
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        st := StubGetIndexHandler{
                c,
                "/index/" + hash[0:3],
                "abc123",
                http.StatusOK,
-               []byte(hash + "+3 1443559274\n\n")}
+               []byte(hash + " 1443559274\n\n")}
 
        ks := RunFakeKeepServer(st)
        defer ks.listener.Close()
@@ -1382,15 +1434,15 @@ func (s *StandaloneSuite) TestGetIndexWithPrefix(c *C) {
        kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
 
        r, err := kc.GetIndex("x", hash[0:3])
-       c.Assert(err, Equals, nil)
+       c.Assert(err, IsNil)
 
        content, err2 := ioutil.ReadAll(r)
-       c.Check(err2, Equals, nil)
+       c.Check(err2, IsNil)
        c.Check(content, DeepEquals, st.body[0:len(st.body)-1])
 }
 
 func (s *StandaloneSuite) TestGetIndexIncomplete(c *C) {
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        st := StubGetIndexHandler{
                c,
@@ -1413,7 +1465,7 @@ func (s *StandaloneSuite) TestGetIndexIncomplete(c *C) {
 }
 
 func (s *StandaloneSuite) TestGetIndexWithNoSuchServer(c *C) {
-       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       hash := fmt.Sprintf("%x+3", md5.Sum([]byte("foo")))
 
        st := StubGetIndexHandler{
                c,
@@ -1453,55 +1505,78 @@ func (s *StandaloneSuite) TestGetIndexWithNoSuchPrefix(c *C) {
        kc.SetServiceRoots(map[string]string{"x": ks.url}, nil, nil)
 
        r, err := kc.GetIndex("x", "abcd")
-       c.Check(err, Equals, nil)
+       c.Check(err, IsNil)
 
        content, err2 := ioutil.ReadAll(r)
-       c.Check(err2, Equals, nil)
+       c.Check(err2, IsNil)
        c.Check(content, DeepEquals, st.body[0:len(st.body)-1])
 }
 
 func (s *StandaloneSuite) TestPutBRetry(c *C) {
-       st := &FailThenSucceedHandler{
-               handled: make(chan string, 1),
-               successhandler: &StubPutHandler{
-                       c:                    c,
-                       expectPath:           Md5String("foo"),
-                       expectAPIToken:       "abc123",
-                       expectBody:           "foo",
-                       expectStorageClass:   "default",
-                       returnStorageClasses: "",
-                       handled:              make(chan string, 5),
-               },
-       }
+       DefaultRetryDelay = time.Second / 8
+       MinimumRetryDelay = time.Millisecond
+
+       for _, delay := range []time.Duration{0, time.Nanosecond, time.Second / 8, time.Second / 16} {
+               c.Logf("=== initial delay %v", delay)
+
+               st := &FailThenSucceedHandler{
+                       morefails: 5, // handler will fail 6x in total, 3 for each server
+                       handled:   make(chan string, 10),
+                       successhandler: &StubPutHandler{
+                               c:                    c,
+                               expectPath:           Md5String("foo"),
+                               expectAPIToken:       "abc123",
+                               expectBody:           "foo",
+                               expectStorageClass:   "default",
+                               returnStorageClasses: "",
+                               handled:              make(chan string, 5),
+                       },
+               }
 
-       arv, _ := arvadosclient.MakeArvadosClient()
-       kc, _ := MakeKeepClient(arv)
+               arv, _ := arvadosclient.MakeArvadosClient()
+               kc, _ := MakeKeepClient(arv)
+               kc.Retries = 3
+               kc.RetryDelay = delay
+               kc.DiskCacheSize = DiskCacheDisabled
+               kc.Want_replicas = 2
 
-       kc.Want_replicas = 2
-       arv.ApiToken = "abc123"
-       localRoots := make(map[string]string)
-       writableLocalRoots := make(map[string]string)
+               arv.ApiToken = "abc123"
+               localRoots := make(map[string]string)
+               writableLocalRoots := make(map[string]string)
 
-       ks := RunSomeFakeKeepServers(st, 2)
+               ks := RunSomeFakeKeepServers(st, 2)
 
-       for i, k := range ks {
-               localRoots[fmt.Sprintf("zzzzz-bi6l4-fakefakefake%03d", i)] = k.url
-               writableLocalRoots[fmt.Sprintf("zzzzz-bi6l4-fakefakefake%03d", i)] = k.url
-               defer k.listener.Close()
-       }
+               for i, k := range ks {
+                       localRoots[fmt.Sprintf("zzzzz-bi6l4-fakefakefake%03d", i)] = k.url
+                       writableLocalRoots[fmt.Sprintf("zzzzz-bi6l4-fakefakefake%03d", i)] = k.url
+                       defer k.listener.Close()
+               }
 
-       kc.SetServiceRoots(localRoots, writableLocalRoots, nil)
+               kc.SetServiceRoots(localRoots, writableLocalRoots, nil)
 
-       hash, replicas, err := kc.PutB([]byte("foo"))
+               t0 := time.Now()
+               hash, replicas, err := kc.PutB([]byte("foo"))
 
-       c.Check(err, Equals, nil)
-       c.Check(hash, Equals, "")
-       c.Check(replicas, Equals, 2)
+               c.Check(err, IsNil)
+               c.Check(hash, Equals, "")
+               c.Check(replicas, Equals, 2)
+               elapsed := time.Since(t0)
+
+               nonsleeptime := time.Second / 10
+               expect := kc.RetryDelay
+               if expect == 0 {
+                       expect = DefaultRetryDelay
+               }
+               min := MinimumRetryDelay * 3
+               max := expect + expect*2 + expect*2*2
+               max += nonsleeptime
+               checkInterval(c, elapsed, min, max)
+       }
 }
 
 func (s *ServerRequiredSuite) TestMakeKeepClientWithNonDiskTypeService(c *C) {
        arv, err := arvadosclient.MakeArvadosClient()
-       c.Assert(err, Equals, nil)
+       c.Assert(err, IsNil)
 
        // Add an additional "testblobstore" keepservice
        blobKeepService := make(arvadosclient.Dict)
@@ -1511,13 +1586,13 @@ func (s *ServerRequiredSuite) TestMakeKeepClientWithNonDiskTypeService(c *C) {
                        "service_port": "21321",
                        "service_type": "testblobstore"}},
                &blobKeepService)
-       c.Assert(err, Equals, nil)
+       c.Assert(err, IsNil)
        defer func() { arv.Delete("keep_services", blobKeepService["uuid"].(string), nil, nil) }()
        RefreshServiceDiscovery()
 
        // Make a keepclient and ensure that the testblobstore is included
        kc, err := MakeKeepClient(arv)
-       c.Assert(err, Equals, nil)
+       c.Assert(err, IsNil)
 
        // verify kc.LocalRoots
        c.Check(len(kc.LocalRoots()), Equals, 3)
@@ -1544,3 +1619,60 @@ func (s *ServerRequiredSuite) TestMakeKeepClientWithNonDiskTypeService(c *C) {
        c.Assert(kc.foundNonDiskSvc, Equals, true)
        c.Assert(kc.httpClient().(*http.Client).Timeout, Equals, 300*time.Second)
 }
+
+func (s *StandaloneSuite) TestDelayCalculator_Default(c *C) {
+       MinimumRetryDelay = time.Second / 2
+       DefaultRetryDelay = time.Second
+
+       dc := delayCalculator{InitialMaxDelay: 0}
+       checkInterval(c, dc.Next(), time.Second/2, time.Second)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*2)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*4)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*8)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*10)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*10)
+}
+
+func (s *StandaloneSuite) TestDelayCalculator_SetInitial(c *C) {
+       MinimumRetryDelay = time.Second / 2
+       DefaultRetryDelay = time.Second
+
+       dc := delayCalculator{InitialMaxDelay: time.Second * 2}
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*2)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*4)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*8)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*16)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*20)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*20)
+       checkInterval(c, dc.Next(), time.Second/2, time.Second*20)
+}
+
+func (s *StandaloneSuite) TestDelayCalculator_EnsureSomeLongDelays(c *C) {
+       dc := delayCalculator{InitialMaxDelay: time.Second * 5}
+       var d time.Duration
+       n := 4000
+       for i := 0; i < n; i++ {
+               if i < 20 || i%10 == 0 {
+                       c.Logf("i=%d, delay=%v", i, d)
+               }
+               if d = dc.Next(); d > dc.InitialMaxDelay*9 {
+                       return
+               }
+       }
+       c.Errorf("after %d trials, never got a delay more than 90%% of expected max %d; last was %v", n, dc.InitialMaxDelay*10, d)
+}
+
+// If InitialMaxDelay is less than MinimumRetryDelay/10, then delay is
+// always MinimumRetryDelay.
+func (s *StandaloneSuite) TestDelayCalculator_InitialLessThanMinimum(c *C) {
+       MinimumRetryDelay = time.Second / 2
+       dc := delayCalculator{InitialMaxDelay: time.Millisecond}
+       for i := 0; i < 20; i++ {
+               c.Check(dc.Next(), Equals, time.Second/2)
+       }
+}
+
+func checkInterval(c *C, t, min, max time.Duration) {
+       c.Check(t >= min, Equals, true, Commentf("got %v which is below expected min %v", t, min))
+       c.Check(t <= max, Equals, true, Commentf("got %v which is above expected max %v", t, max))
+}
index 8d299815b2dbd1d0bd52d60b9f5936904811a0b9..d3d799dc5dc2c229d8303d215328fea577c61c10 100644 (file)
@@ -13,10 +13,12 @@ import (
        "io"
        "io/ioutil"
        "log"
+       "math/rand"
        "net/http"
        "os"
        "strconv"
        "strings"
+       "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadosclient"
@@ -127,7 +129,7 @@ func (kc *KeepClient) uploadToKeepServer(host string, hash string, classesTodo [
        }
 }
 
-func (kc *KeepClient) BlockWrite(ctx context.Context, req arvados.BlockWriteOptions) (arvados.BlockWriteResponse, error) {
+func (kc *KeepClient) httpBlockWrite(ctx context.Context, req arvados.BlockWriteOptions) (arvados.BlockWriteResponse, error) {
        var resp arvados.BlockWriteResponse
        var getReader func() io.Reader
        if req.Data == nil && req.Reader == nil {
@@ -149,8 +151,12 @@ func (kc *KeepClient) BlockWrite(ctx context.Context, req arvados.BlockWriteOpti
                getReader = func() io.Reader { return bytes.NewReader(req.Data[:req.DataSize]) }
        } else {
                buf := asyncbuf.NewBuffer(make([]byte, 0, req.DataSize))
+               reader := req.Reader
+               if req.Hash != "" {
+                       reader = HashCheckingReader{req.Reader, md5.New(), req.Hash}
+               }
                go func() {
-                       _, err := io.Copy(buf, HashCheckingReader{req.Reader, md5.New(), req.Hash})
+                       _, err := io.Copy(buf, reader)
                        buf.CloseWithError(err)
                }()
                getReader = buf.NewReader
@@ -214,6 +220,7 @@ func (kc *KeepClient) BlockWrite(ctx context.Context, req arvados.BlockWriteOpti
                replicasPerThread = req.Replicas
        }
 
+       delay := delayCalculator{InitialMaxDelay: kc.RetryDelay}
        retriesRemaining := req.Attempts
        var retryServers []string
 
@@ -302,14 +309,17 @@ func (kc *KeepClient) BlockWrite(ctx context.Context, req arvados.BlockWriteOpti
                        }
 
                        if status.statusCode == 0 || status.statusCode == 408 || status.statusCode == 429 ||
-                               (status.statusCode >= 500 && status.statusCode != 503) {
+                               (status.statusCode >= 500 && status.statusCode != http.StatusInsufficientStorage) {
                                // Timeout, too many requests, or other server side failure
-                               // Do not retry when status code is 503, which means the keep server is full
+                               // (do not auto-retry status 507 "full")
                                retryServers = append(retryServers, status.url[0:strings.LastIndex(status.url, "/")])
                        }
                }
 
                sv = retryServers
+               if len(sv) > 0 {
+                       time.Sleep(delay.Next())
+               }
        }
 
        return resp, nil
@@ -341,3 +351,37 @@ func parseStorageClassesConfirmedHeader(hdr string) (map[string]int, error) {
        }
        return classesStored, nil
 }
+
+// delayCalculator calculates a series of delays for implementing
+// exponential backoff with jitter.  The first call to Next() returns
+// a random duration between MinimumRetryDelay and the specified
+// InitialMaxDelay (or DefaultRetryDelay if 0).  The max delay is
+// doubled on each subsequent call to Next(), up to 10x the initial
+// max delay.
+type delayCalculator struct {
+       InitialMaxDelay time.Duration
+       n               int // number of delays returned so far
+       nextmax         time.Duration
+       limit           time.Duration
+}
+
+func (dc *delayCalculator) Next() time.Duration {
+       if dc.nextmax <= MinimumRetryDelay {
+               // initialize
+               if dc.InitialMaxDelay > 0 {
+                       dc.nextmax = dc.InitialMaxDelay
+               } else {
+                       dc.nextmax = DefaultRetryDelay
+               }
+               dc.limit = 10 * dc.nextmax
+       }
+       d := time.Duration(rand.Float64() * float64(dc.nextmax))
+       if d < MinimumRetryDelay {
+               d = MinimumRetryDelay
+       }
+       dc.nextmax *= 2
+       if dc.nextmax > dc.limit {
+               dc.nextmax = dc.limit
+       }
+       return d
+}
index 954fb710c0596a4580f76bf2a78945e39203f2f6..a597003859b7d76c3a13b055fa22edab5f9ba6d6 100644 (file)
@@ -11,12 +11,13 @@ package manifest
 import (
        "errors"
        "fmt"
-       "git.arvados.org/arvados.git/sdk/go/blockdigest"
        "path"
        "regexp"
        "sort"
        "strconv"
        "strings"
+
+       "git.arvados.org/arvados.git/sdk/go/blockdigest"
 )
 
 var ErrInvalidToken = errors.New("Invalid token")
@@ -467,21 +468,21 @@ func (m segmentedManifest) manifestTextForPath(srcpath, relocate string) string
 // If 'srcpath' and 'relocate' are '.' it simply returns an equivalent manifest
 // in normalized form.
 //
-//   Extract(".", ".")  // return entire normalized manfest text
+//     Extract(".", ".")  // return entire normalized manfest text
 //
 // If 'srcpath' points to a single file, it will return manifest text for just that file.
 // The value of "relocate" is can be used to rename the file or set the file stream.
 //
-//   Extract("./foo", ".")          // extract file "foo" and put it in stream "."
-//   Extract("./foo", "./bar")      // extract file "foo", rename it to "bar" in stream "."
-//   Extract("./foo", "./bar/")     // extract file "foo", rename it to "./bar/foo"
-//   Extract("./foo", "./bar/baz")  // extract file "foo", rename it to "./bar/baz")
+//     Extract("./foo", ".")          // extract file "foo" and put it in stream "."
+//     Extract("./foo", "./bar")      // extract file "foo", rename it to "bar" in stream "."
+//     Extract("./foo", "./bar/")     // extract file "foo", rename it to "./bar/foo"
+//     Extract("./foo", "./bar/baz")  // extract file "foo", rename it to "./bar/baz")
 //
 // Otherwise it will return the manifest text for all streams with the prefix in "srcpath" and place
 // them under the path in "relocate".
 //
-//   Extract("./stream", ".")      // extract "./stream" to "." and "./stream/subdir" to "./subdir")
-//   Extract("./stream", "./bar")  // extract "./stream" to "./bar" and "./stream/subdir" to "./bar/subdir")
+//     Extract("./stream", ".")      // extract "./stream" to "." and "./stream/subdir" to "./subdir")
+//     Extract("./stream", "./bar")  // extract "./stream" to "./bar" and "./stream/subdir" to "./bar/subdir")
 func (m Manifest) Extract(srcpath, relocate string) (ret Manifest) {
        segmented, err := m.segment()
        if err != nil {
index ab03d34f19b1e0d1e8714edf3dfca186b2efbc12..4bd59a75d7e0084e4429af13d054bb13d37db67a 100644 (file)
@@ -27,7 +27,7 @@ import java.util.Map;
 
 public abstract class BaseStandardApiClient<T extends Item, L extends ItemList> extends BaseApiClient {
 
-    private static final MediaType JSON = MediaType.parse(com.google.common.net.MediaType.JSON_UTF_8.toString());
+    protected static final MediaType JSON = MediaType.parse(com.google.common.net.MediaType.JSON_UTF_8.toString());
     private final Logger log = org.slf4j.LoggerFactory.getLogger(BaseStandardApiClient.class);
 
     BaseStandardApiClient(ConfigProvider config) {
@@ -107,7 +107,7 @@ public abstract class BaseStandardApiClient<T extends Item, L extends ItemList>
         return MAPPER.readValue(content, cls);
     }
 
-    private <TL> String mapToJson(TL type) {
+    protected  <TL> String mapToJson(TL type) {
         ObjectWriter writer = MAPPER.writer().withDefaultPrettyPrinter();
         try {
             return writer.writeValueAsString(type);
index 141f02deba38e6227e0c6b24ef881fd5cdae422a..581253f53cd2cf1fbb2d6b96aa9f7e616cac1fcb 100644 (file)
@@ -9,12 +9,18 @@ package org.arvados.client.api.client;
 
 import org.arvados.client.api.model.Collection;
 import org.arvados.client.api.model.CollectionList;
+import org.arvados.client.api.model.CollectionReplaceFiles;
 import org.arvados.client.config.ConfigProvider;
 import org.slf4j.Logger;
 
+import okhttp3.HttpUrl;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+
 public class CollectionsApiClient extends BaseStandardApiClient<Collection, CollectionList> {
 
     private static final String RESOURCE = "collections";
+
     private final Logger log = org.slf4j.LoggerFactory.getLogger(CollectionsApiClient.class);
 
     public CollectionsApiClient(ConfigProvider config) {
@@ -28,6 +34,14 @@ public class CollectionsApiClient extends BaseStandardApiClient<Collection, Coll
         return newCollection;
     }
 
+    public Collection update(String collectionUUID, CollectionReplaceFiles replaceFilesRequest) {
+        String json = mapToJson(replaceFilesRequest);
+        RequestBody body = RequestBody.create(JSON, json);
+        HttpUrl url = getUrlBuilder().addPathSegment(collectionUUID).build();
+        Request request = getRequestBuilder().put(body).url(url).build();
+        return callForType(request);
+    }
+
     @Override
     String getResource() {
         return RESOURCE;
index 43fcdba5c69a20a1817aa77400ca6cae95b0513d..d6eb033ff5cd2717369aa5d034f01a140889d72a 100644 (file)
@@ -7,12 +7,9 @@
 
 package org.arvados.client.api.client;
 
-import okhttp3.MediaType;
-import okhttp3.RequestBody;
 import okio.BufferedSink;
 import okio.Okio;
 import okio.Source;
-import org.slf4j.Logger;
 
 import java.io.File;
 
@@ -20,32 +17,20 @@ import java.io.File;
  * Based on:
  * {@link} https://gist.github.com/eduardb/dd2dc530afd37108e1ac
  */
-public class CountingFileRequestBody extends RequestBody {
-
-    private static final int SEGMENT_SIZE = 2048; // okio.Segment.SIZE
-    private static final MediaType CONTENT_BINARY = MediaType.parse(com.google.common.net.MediaType.OCTET_STREAM.toString());
-
-    private final File file;
-    private final ProgressListener listener;
+public class CountingFileRequestBody extends CountingRequestBody<File> {
 
     CountingFileRequestBody(final File file, final ProgressListener listener) {
-        this.file = file;
-        this.listener = listener;
+        super(file, listener);
     }
 
     @Override
     public long contentLength() {
-        return file.length();
-    }
-
-    @Override
-    public MediaType contentType() {
-        return CONTENT_BINARY;
+        return requestBodyData.length();
     }
 
     @Override
     public void writeTo(BufferedSink sink) {
-        try (Source source = Okio.source(file)) {
+        try (Source source = Okio.source(requestBodyData)) {
             long total = 0;
             long read;
 
@@ -61,24 +46,4 @@ public class CountingFileRequestBody extends RequestBody {
             //ignore
         }
     }
-
-    static class TransferData {
-
-        private final Logger log = org.slf4j.LoggerFactory.getLogger(TransferData.class);
-        private int progressValue;
-        private long totalSize;
-
-        TransferData(long totalSize) {
-            this.progressValue = 0;
-            this.totalSize = totalSize;
-        }
-
-        void updateTransferProgress(long transferred) {
-            float progress = (transferred / (float) totalSize) * 100;
-            if (progressValue != (int) progress) {
-                progressValue = (int) progress;
-                log.debug("{} / {} / {}%", transferred, totalSize, progressValue);
-            }
-        }
-    }
 }
\ No newline at end of file
diff --git a/sdk/java-v2/src/main/java/org/arvados/client/api/client/CountingRequestBody.java b/sdk/java-v2/src/main/java/org/arvados/client/api/client/CountingRequestBody.java
new file mode 100644 (file)
index 0000000..397a1e2
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) The Arvados Authors. All rights reserved.
+ *
+ * SPDX-License-Identifier: AGPL-3.0 OR Apache-2.0
+ *
+ */
+
+package org.arvados.client.api.client;
+
+import okhttp3.MediaType;
+import okhttp3.RequestBody;
+import org.slf4j.Logger;
+
+abstract class CountingRequestBody<T> extends RequestBody {
+
+    protected static final int SEGMENT_SIZE = 2048; // okio.Segment.SIZE
+    protected static final MediaType CONTENT_BINARY = MediaType.parse(com.google.common.net.MediaType.OCTET_STREAM.toString());
+
+    protected final ProgressListener listener;
+
+    protected final T requestBodyData;
+
+    CountingRequestBody(T file, final ProgressListener listener) {
+        this.requestBodyData = file;
+        this.listener = listener;
+    }
+
+    @Override
+    public MediaType contentType() {
+        return CONTENT_BINARY;
+    }
+
+    static class TransferData {
+
+        private final Logger log = org.slf4j.LoggerFactory.getLogger(TransferData.class);
+        private int progressValue;
+        private long totalSize;
+
+        TransferData(long totalSize) {
+            this.progressValue = 0;
+            this.totalSize = totalSize;
+        }
+
+        void updateTransferProgress(long transferred) {
+            float progress = (transferred / (float) totalSize) * 100;
+            if (progressValue != (int) progress) {
+                progressValue = (int) progress;
+                log.debug("{} / {} / {}%", transferred, totalSize, progressValue);
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/sdk/java-v2/src/main/java/org/arvados/client/api/client/CountingStreamRequestBody.java b/sdk/java-v2/src/main/java/org/arvados/client/api/client/CountingStreamRequestBody.java
new file mode 100644 (file)
index 0000000..7c39371
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) The Arvados Authors. All rights reserved.
+ *
+ * SPDX-License-Identifier: AGPL-3.0 OR Apache-2.0
+ *
+ */
+
+package org.arvados.client.api.client;
+
+import okio.BufferedSink;
+import okio.Okio;
+import okio.Source;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class CountingStreamRequestBody extends CountingRequestBody<InputStream> {
+
+    CountingStreamRequestBody(final InputStream inputStream, final ProgressListener listener) {
+        super(inputStream, listener);
+    }
+
+    @Override
+    public long contentLength() throws IOException {
+        return requestBodyData.available();
+    }
+
+    @Override
+    public void writeTo(BufferedSink sink) {
+        try (Source source = Okio.source(requestBodyData)) {
+            long total = 0;
+            long read;
+
+            while ((read = source.read(sink.buffer(), SEGMENT_SIZE)) != -1) {
+                total += read;
+                sink.flush();
+                listener.updateProgress(total);
+
+            }
+        } catch (RuntimeException rethrown) {
+            throw rethrown;
+        } catch (Exception ignored) {
+            //ignore
+        }
+    }
+}
\ No newline at end of file
index a9306ca2ecf970591be242164d7135f5458f929a..c1525e07a77003a9c9e8254c970bfae97cb7c63a 100644 (file)
@@ -9,7 +9,7 @@ package org.arvados.client.api.client;
 
 import okhttp3.Request;
 import okhttp3.RequestBody;
-import org.arvados.client.api.client.CountingFileRequestBody.TransferData;
+import org.arvados.client.api.client.CountingRequestBody.TransferData;
 import org.arvados.client.common.Headers;
 import org.arvados.client.config.ConfigProvider;
 import org.slf4j.Logger;
index 05d39e9e6085ac66f6ea0aa1901c3ce2ae096f68..ad37dad2bbda5e88296c52d5905701d4bd34cbff 100644 (file)
@@ -10,9 +10,14 @@ package org.arvados.client.api.client;
 import okhttp3.HttpUrl;
 import okhttp3.Request;
 import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
 import org.arvados.client.config.ConfigProvider;
 
 import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
 
 public class KeepWebApiClient extends BaseApiClient {
 
@@ -29,6 +34,27 @@ public class KeepWebApiClient extends BaseApiClient {
         return newFileCall(request);
     }
 
+    public InputStream get(String collectionUuid, String filePathName, long start, Long end) throws IOException {
+        Request.Builder builder = this.getRequestBuilder();
+        String rangeValue = "bytes=" + start + "-";
+        if (end != null) {
+            rangeValue += end;
+        }
+        builder.addHeader("Range", rangeValue);
+        Request request = builder.url(this.getUrlBuilder(collectionUuid, filePathName).build()).get().build();
+        Response response = client.newCall(request).execute();
+        if (!response.isSuccessful()) {
+            response.close();
+            throw new IOException("Failed to download file: " + response);
+        }
+        ResponseBody body = response.body();
+        if (body == null) {
+            response.close();
+            throw new IOException("Response body is null for request: " + request);
+        }
+        return body.byteStream();
+    }
+
     public String delete(String collectionUuid, String filePathName) {
         Request request = getRequestBuilder()
                 .url(getUrlBuilder(collectionUuid, filePathName).build())
@@ -48,6 +74,16 @@ public class KeepWebApiClient extends BaseApiClient {
         return newCall(request);
     }
 
+    public String upload(String collectionUuid, InputStream inputStream, String fileName, ProgressListener progressListener) {
+        RequestBody requestBody = new CountingStreamRequestBody(inputStream, progressListener);
+
+        Request request = getRequestBuilder()
+                .url(getUrlBuilder(collectionUuid, fileName).build())
+                .put(requestBody)
+                .build();
+        return newCall(request);
+    }
+
     private HttpUrl.Builder getUrlBuilder(String collectionUuid, String filePathName) {
         return new HttpUrl.Builder()
                 .scheme(config.getApiProtocol())
diff --git a/sdk/java-v2/src/main/java/org/arvados/client/api/model/CollectionReplaceFiles.java b/sdk/java-v2/src/main/java/org/arvados/client/api/model/CollectionReplaceFiles.java
new file mode 100644 (file)
index 0000000..2ef19ce
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) The Arvados Authors. All rights reserved.
+ *
+ * SPDX-License-Identifier: AGPL-3.0 OR Apache-2.0
+ *
+ */
+
+package org.arvados.client.api.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class CollectionReplaceFiles {
+
+    @JsonProperty("collection")
+    private CollectionOptions collectionOptions;
+
+    @JsonProperty("replace_files")
+    private Map<String, String> replaceFiles;
+
+    public CollectionReplaceFiles() {
+        this.collectionOptions = new CollectionOptions();
+        this.replaceFiles = new HashMap<>();
+    }
+
+    public void addFileReplacement(String targetPath, String sourcePath) {
+        this.replaceFiles.put(targetPath, sourcePath);
+    }
+
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    @JsonIgnoreProperties(ignoreUnknown = true)
+    public static class CollectionOptions {
+        @JsonProperty("preserve_version")
+        private boolean preserveVersion;
+
+        public CollectionOptions() {
+            this.preserveVersion = true;
+        }
+
+        public boolean isPreserveVersion() {
+            return preserveVersion;
+        }
+
+        public void setPreserveVersion(boolean preserveVersion) {
+            this.preserveVersion = preserveVersion;
+        }
+    }
+
+    public CollectionOptions getCollectionOptions() {
+        return collectionOptions;
+    }
+
+    public void setCollectionOptions(CollectionOptions collectionOptions) {
+        this.collectionOptions = collectionOptions;
+    }
+
+    public Map<String, String> getReplaceFiles() {
+        return replaceFiles;
+    }
+
+    public void setReplaceFiles(Map<String, String> replaceFiles) {
+        this.replaceFiles = replaceFiles;
+    }
+}
\ No newline at end of file
index ca86c585e82e160a84307f61e08dcf4bb7d395b7..9230973698973f60fcec4cb6de685547a6052f59 100644 (file)
@@ -14,7 +14,7 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
 import java.util.List;
 
 @JsonInclude(JsonInclude.Include.NON_NULL)
-@JsonPropertyOrder({ "limit", "offset", "filters", "order", "select", "distinct", "count", "exclude_home_project" })
+@JsonPropertyOrder({ "limit", "offset", "filters", "order", "select", "distinct", "count", "exclude_home_project", "include_old_versions", "include_trash" })
 public class ListArgument extends Argument {
 
     @JsonProperty("limit")
@@ -41,7 +41,17 @@ public class ListArgument extends Argument {
     @JsonProperty("exclude_home_project")
     private Boolean excludeHomeProject;
 
-    ListArgument(Integer limit, Integer offset, List<Filter> filters, List<String> order, List<String> select, Boolean distinct, Count count, Boolean excludeHomeProject) {
+    @JsonProperty("include_old_versions")
+    private Boolean includeOldVersions;
+
+    @JsonProperty("include_trash")
+    private Boolean includeTrash;
+
+    ListArgument(
+            Integer limit, Integer offset, List<Filter> filters, List<String> order, List<String> select,
+            Boolean distinct, Count count, Boolean excludeHomeProject, Boolean includeOldVersions,
+            Boolean includeTrash
+    ) {
         this.limit = limit;
         this.offset = offset;
         this.filters = filters;
@@ -50,6 +60,8 @@ public class ListArgument extends Argument {
         this.distinct = distinct;
         this.count = count;
         this.excludeHomeProject = excludeHomeProject;
+        this.includeOldVersions = includeOldVersions;
+        this.includeTrash = includeTrash;
     }
 
     public static ListArgumentBuilder builder() {
@@ -74,6 +86,8 @@ public class ListArgument extends Argument {
         private Boolean distinct;
         private Count count;
         private Boolean excludeHomeProject;
+        private Boolean includeOldVersions;
+        private Boolean includeTrash;
 
         ListArgumentBuilder() {
         }
@@ -118,8 +132,18 @@ public class ListArgument extends Argument {
             return this;
         }
 
+        public ListArgument.ListArgumentBuilder includeOldVersions(Boolean includeOldVersions) {
+            this.includeOldVersions = includeOldVersions;
+            return this;
+        }
+
+        public ListArgument.ListArgumentBuilder includeTrash(Boolean includeTrash) {
+            this.includeTrash = includeTrash;
+            return this;
+        }
+
         public ListArgument build() {
-            return new ListArgument(limit, offset, filters, order, select, distinct, count, excludeHomeProject);
+            return new ListArgument(limit, offset, filters, order, select, distinct, count, excludeHomeProject, includeOldVersions, includeTrash);
         }
 
         public String toString() {
@@ -127,7 +151,10 @@ public class ListArgument extends Argument {
                     ", offset=" + this.offset + ", filters=" + this.filters +
                     ", order=" + this.order + ", select=" + this.select +
                     ", distinct=" + this.distinct + ", count=" + this.count +
-                    ", excludeHomeProject=" + this.excludeHomeProject + ")";
+                    ", excludeHomeProject=" + this.excludeHomeProject +
+                    ", includeOldVersions=" + this.includeOldVersions +
+                    ", includeTrash=" + this.includeTrash +
+                    ")";
         }
     }
 }
index d592b23ac34e81f13f12f5250736e7de529fd112..e3d706ed0ca88eb0c3b00024c79ee5b682b9b7e9 100644 (file)
@@ -11,6 +11,10 @@ import java.io.File;
 
 public class ExternalConfigProvider implements ConfigProvider {
 
+    private static final int DEFAULT_CONNECTION_TIMEOUT = 60000;
+    private static final int DEFAULT_READ_TIMEOUT = 60000;
+    private static final int DEFAULT_WRITE_TIMEOUT = 60000;
+
     private boolean apiHostInsecure;
     private String keepWebHost;
     private int keepWebPort;
@@ -41,9 +45,9 @@ public class ExternalConfigProvider implements ConfigProvider {
         this.fileSplitDirectory = fileSplitDirectory;
         this.numberOfCopies = numberOfCopies;
         this.numberOfRetries = numberOfRetries;
-       this.connectTimeout = 60000;
-       this.readTimeout = 60000;
-       this.writeTimeout = 60000;
+       this.connectTimeout = DEFAULT_CONNECTION_TIMEOUT;
+       this.readTimeout = DEFAULT_READ_TIMEOUT;
+       this.writeTimeout = DEFAULT_WRITE_TIMEOUT;
     }
 
     ExternalConfigProvider(boolean apiHostInsecure, String keepWebHost, int keepWebPort, String apiHost, int apiPort,
@@ -156,6 +160,9 @@ public class ExternalConfigProvider implements ConfigProvider {
         private File fileSplitDirectory;
         private int numberOfCopies;
         private int numberOfRetries;
+        private int connectTimeout = DEFAULT_CONNECTION_TIMEOUT;
+        private int readTimeout = DEFAULT_READ_TIMEOUT;
+        private int writeTimeout = DEFAULT_WRITE_TIMEOUT;
 
         ExternalConfigProviderBuilder() {
         }
@@ -215,8 +222,23 @@ public class ExternalConfigProvider implements ConfigProvider {
             return this;
         }
 
+        public ExternalConfigProvider.ExternalConfigProviderBuilder connectTimeout(int connectTimeout) {
+            this.connectTimeout = connectTimeout;
+            return this;
+        }
+
+        public ExternalConfigProvider.ExternalConfigProviderBuilder readTimeout(int readTimeout) {
+            this.readTimeout = readTimeout;
+            return this;
+        }
+
+        public ExternalConfigProvider.ExternalConfigProviderBuilder writeTimeout(int writeTimeout) {
+            this.writeTimeout = writeTimeout;
+            return this;
+        }
+
         public ExternalConfigProvider build() {
-            return new ExternalConfigProvider(apiHostInsecure, keepWebHost, keepWebPort, apiHost, apiPort, apiToken, apiProtocol, fileSplitSize, fileSplitDirectory, numberOfCopies, numberOfRetries);
+            return new ExternalConfigProvider(apiHostInsecure, keepWebHost, keepWebPort, apiHost, apiPort, apiToken, apiProtocol, fileSplitSize, fileSplitDirectory, numberOfCopies, numberOfRetries, connectTimeout, readTimeout, writeTimeout);
         }
 
     }
index 571cb2590906f9d041a342dbf26d95724184e3b0..8b65cebc59a0d20d0c9ba1b6add34aaf65e2a584 100644 (file)
@@ -28,6 +28,7 @@ import java.io.File;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
 public class ArvadosFacade {
 
@@ -201,6 +202,21 @@ public class ArvadosFacade {
         return collectionsApiClient.create(collection);
     }
 
+    /**
+     * Uploads multiple files to an existing collection.
+     *
+     * @param collectionUUID UUID of collection to which the files are to be copied
+     * @param files          map of files to be copied to existing collection.
+     *                       The map consists of a pair in the form of a filename and a filename
+     *                       along with the Portable data hash
+     * @return collection object mapped from JSON that is returned from server after successful copied
+     */
+    public Collection updateWithReplaceFiles(String collectionUUID, Map<String, String> files) {
+        CollectionReplaceFiles replaceFilesRequest = new CollectionReplaceFiles();
+        replaceFilesRequest.getReplaceFiles().putAll(files);
+        return collectionsApiClient.update(collectionUUID, replaceFilesRequest);
+    }
+
     /**
      * Returns current user information based on Api Token provided via configuration
      *
index c1e8849e39f625128133bea1d8376e01e005ca54..5bfcabc10984bdb55a20bf130a2be0c88d819254 100644 (file)
@@ -23,6 +23,8 @@ import org.slf4j.Logger;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.RandomAccessFile;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
@@ -70,6 +72,37 @@ public class FileDownloader {
         return downloadedFile;
     }
 
+    public File downloadFileWithResume(String collectionUuid, String fileName, String pathToDownloadFolder, long start, Long end) throws IOException {
+        if (end != null && end < start) {
+            throw new IllegalArgumentException("End index must be greater than or equal to the start index");
+        }
+
+        File destinationFile = new File(pathToDownloadFolder, fileName);
+
+        if (!destinationFile.exists()) {
+            boolean isCreated = destinationFile.createNewFile();
+            if (!isCreated) {
+                throw new IOException("Failed to create new file: " + destinationFile.getAbsolutePath());
+            }
+        }
+
+        try (RandomAccessFile outputFile = new RandomAccessFile(destinationFile, "rw");
+             InputStream inputStream = keepWebApiClient.get(collectionUuid, fileName, start, end)) {
+            outputFile.seek(start);
+
+            long remaining = (end == null) ? Long.MAX_VALUE : end - start + 1;
+            byte[] buffer = new byte[4096];
+            int bytesRead;
+            while ((bytesRead = inputStream.read(buffer)) != -1 && remaining > 0) {
+                int bytesToWrite = (int) Math.min(bytesRead, remaining);
+                outputFile.write(buffer, 0, bytesToWrite);
+                remaining -= bytesToWrite;
+            }
+        }
+
+        return destinationFile;
+    }
+
     public List<File> downloadFilesFromCollectionUsingKeepWeb(String collectionUuid, String pathToDownloadFolder) {
         String collectionTargetDir = setTargetDirectory(collectionUuid, pathToDownloadFolder).getAbsolutePath();
         List<FileToken> fileTokens = listFileInfoFromCollection(collectionUuid);
index 8da3bfbf514b04c6f188bb0f5e1185d42c8002d9..94a79041a0f135bb997ec9b3704d29eb58811bfd 100644 (file)
@@ -7,21 +7,39 @@
 
 package org.arvados.client.api.client;
 
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
 import okhttp3.mockwebserver.RecordedRequest;
 import org.arvados.client.api.model.Collection;
 import org.arvados.client.api.model.CollectionList;
+import org.arvados.client.api.model.CollectionReplaceFiles;
 import org.arvados.client.test.utils.RequestMethod;
 import org.arvados.client.test.utils.ArvadosClientMockedWebServerTest;
+import org.junit.Before;
 import org.junit.Test;
 
 import static org.arvados.client.test.utils.ApiClientTestUtils.*;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertEquals;
 
 public class CollectionsApiClientTest extends ArvadosClientMockedWebServerTest {
 
     private static final String RESOURCE = "collections";
-
-    private CollectionsApiClient client = new CollectionsApiClient(CONFIG);
+    private static final String TEST_COLLECTION_NAME = "Super Collection";
+    private static final String TEST_COLLECTION_UUID = "test-collection-uuid";
+    private ObjectMapper objectMapper;
+    private CollectionsApiClient client;
+
+    @Before
+    public void setUp() {
+        objectMapper = new ObjectMapper();
+        objectMapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
+        client = new CollectionsApiClient(CONFIG);
+    }
 
     @Test
     public void listCollections() throws Exception {
@@ -66,7 +84,7 @@ public class CollectionsApiClientTest extends ArvadosClientMockedWebServerTest {
         // given
         server.enqueue(getResponse("collections-create-simple"));
 
-        String name = "Super Collection";
+        String name = TEST_COLLECTION_NAME;
         
         Collection collection = new Collection();
         collection.setName(name);
@@ -90,7 +108,7 @@ public class CollectionsApiClientTest extends ArvadosClientMockedWebServerTest {
         // given
         server.enqueue(getResponse("collections-create-manifest"));
 
-        String name = "Super Collection";
+        String name = TEST_COLLECTION_NAME;
         String manifestText = ". 7df44272090cee6c0732382bba415ee9+70+Aa5ece4560e3329315165b36c239b8ab79c888f8a@5a1d5708 0:70:README.md\n";
         
         Collection collection = new Collection();
@@ -109,4 +127,45 @@ public class CollectionsApiClientTest extends ArvadosClientMockedWebServerTest {
         assertThat(actual.getPortableDataHash()).isEqualTo("d41d8cd98f00b204e9800998ecf8427e+0");
         assertThat(actual.getManifestText()).isEqualTo(manifestText);
     }
+
+    @Test
+    public void testUpdateWithReplaceFiles() throws IOException, InterruptedException {
+        // given
+        server.enqueue(getResponse("collections-create-manifest"));
+
+        Map<String, String> files = new HashMap<>();
+        files.put("targetPath1", "sourcePath1");
+        files.put("targetPath2", "sourcePath2");
+
+        CollectionReplaceFiles replaceFilesRequest = new CollectionReplaceFiles();
+        replaceFilesRequest.setReplaceFiles(files);
+
+        // when
+        Collection actual = client.update(TEST_COLLECTION_UUID, replaceFilesRequest);
+
+        // then
+        RecordedRequest request = server.takeRequest();
+        assertAuthorizationHeader(request);
+        assertRequestPath(request, "collections/test-collection-uuid");
+        assertRequestMethod(request, RequestMethod.PUT);
+        assertThat(actual.getPortableDataHash()).isEqualTo("d41d8cd98f00b204e9800998ecf8427e+0");
+
+        String actualRequestBody = request.getBody().readUtf8();
+        Map<String, Object> actualRequestMap = objectMapper.readValue(actualRequestBody, Map.class);
+
+        Map<String, Object> expectedRequestMap = new HashMap<>();
+        Map<String, Object> collectionOptionsMap = new HashMap<>();
+        collectionOptionsMap.put("preserve_version", true);
+
+        Map<String, String> replaceFilesMap = new HashMap<>();
+        replaceFilesMap.put("targetPath1", "sourcePath1");
+        replaceFilesMap.put("targetPath2", "sourcePath2");
+
+        expectedRequestMap.put("collection", collectionOptionsMap);
+        expectedRequestMap.put("replace_files", replaceFilesMap);
+
+        String expectedJson = objectMapper.writeValueAsString(expectedRequestMap);
+        String actualJson = objectMapper.writeValueAsString(actualRequestMap);
+        assertEquals(expectedJson, actualJson);
+    }
 }
index 07b7b2533991a1a32e3c0b7a5c6587b9ed07dec2..9b6b4fa17fe094f55935004414bcf9ddbb5d75d7 100644 (file)
@@ -10,15 +10,23 @@ package org.arvados.client.api.client;
 import org.arvados.client.test.utils.ArvadosClientMockedWebServerTest;
 import org.junit.Test;
 
+import java.io.ByteArrayOutputStream;
 import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
 import java.nio.file.Files;
 
+import okhttp3.mockwebserver.MockResponse;
+import okio.Buffer;
+
 import static org.arvados.client.test.utils.ApiClientTestUtils.getResponse;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertNotNull;
 
 public class KeepWebApiClientTest extends ArvadosClientMockedWebServerTest {
 
-    private KeepWebApiClient client = new KeepWebApiClient(CONFIG);
+    private final KeepWebApiClient client = new KeepWebApiClient(CONFIG);
 
     @Test
     public void uploadFile() throws Exception {
@@ -36,4 +44,38 @@ public class KeepWebApiClientTest extends ArvadosClientMockedWebServerTest {
         assertThat(uploadResponse).isEqualTo("Created");
     }
 
+    @Test
+    public void downloadPartialIsPerformedSuccessfully() throws Exception {
+        // given
+        String collectionUuid = "some-collection-uuid";
+        String filePathName = "sample-file-path";
+        long start = 1024;
+        Long end = null;
+
+        byte[] expectedData = "test data".getBytes();
+
+        try (Buffer buffer = new Buffer().write(expectedData)) {
+            server.enqueue(new MockResponse().setBody(buffer));
+
+            // when
+            InputStream inputStream = client.get(collectionUuid, filePathName, start, end);
+            byte[] actualData = inputStreamToByteArray(inputStream);
+
+            // then
+            assertNotNull(actualData);
+            assertArrayEquals(expectedData, actualData);
+        }
+    }
+
+    private byte[] inputStreamToByteArray(InputStream inputStream) throws IOException {
+        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+        int nRead;
+        byte[] data = new byte[1024];
+        while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
+            buffer.write(data, 0, nRead);
+        }
+        buffer.flush();
+        return buffer.toByteArray();
+    }
+
 }
index 07269f7e7d905dbb7283165ffcd330612ac4429c..05ba8d1b09d4269342a18d1a0f5a5376fd1cc057 100644 (file)
@@ -223,6 +223,9 @@ public class ArvadosFacadeIntegrationTest extends ArvadosClientIntegrationTest {
                 .fileSplitDirectory(CONFIG.getFileSplitDirectory())
                 .numberOfCopies(CONFIG.getNumberOfCopies())
                 .numberOfRetries(CONFIG.getNumberOfRetries())
+                .connectTimeout(CONFIG.getConnectTimeout())
+                .readTimeout(CONFIG.getReadTimeout())
+                .writeTimeout(CONFIG.getWriteTimeout())
                 .build();
     }
 
index 0fb1f0206c5afad8aa6717e193568fc25a1453ea..741f80f7c99bee94e996470d62d7d09585076eb7 100644 (file)
@@ -19,7 +19,6 @@ import org.arvados.client.test.utils.FileTestUtils;
 import org.arvados.client.utils.FileMerge;
 import org.apache.commons.io.FileUtils;
 import org.junit.After;
-import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -27,8 +26,11 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
 
+import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -36,6 +38,10 @@ import java.util.UUID;
 
 import static org.arvados.client.test.utils.FileTestUtils.*;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
 import static org.mockito.Mockito.when;
 
 @RunWith(MockitoJUnitRunner.class)
@@ -80,17 +86,17 @@ public class FileDownloaderTest {
         List<File> downloadedFiles = fileDownloader.downloadFilesFromCollection(collectionToDownload.getUuid(), FILE_DOWNLOAD_TEST_DIR);
 
         //then
-        Assert.assertEquals(3, downloadedFiles.size()); // 3 files downloaded
+        assertEquals(3, downloadedFiles.size()); // 3 files downloaded
 
         File collectionDir = new File(FILE_DOWNLOAD_TEST_DIR + Characters.SLASH + collectionToDownload.getUuid());
-        Assert.assertTrue(collectionDir.exists()); // collection directory created
+        assertTrue(collectionDir.exists()); // collection directory created
 
         // 3 files correctly saved
         assertThat(downloadedFiles).allMatch(File::exists);
 
         for(int i = 0; i < downloadedFiles.size(); i ++) {
             File downloaded = new File(collectionDir + Characters.SLASH + files.get(i).getName());
-            Assert.assertArrayEquals(FileUtils.readFileToByteArray(downloaded), FileUtils.readFileToByteArray(files.get(i)));
+            assertArrayEquals(FileUtils.readFileToByteArray(downloaded), FileUtils.readFileToByteArray(files.get(i)));
         }
     }
 
@@ -108,9 +114,32 @@ public class FileDownloaderTest {
         File downloadedFile = fileDownloader.downloadSingleFileUsingKeepWeb(file.getName(), collectionToDownload.getUuid(), FILE_DOWNLOAD_TEST_DIR);
 
         //then
-        Assert.assertTrue(downloadedFile.exists());
-        Assert.assertEquals(file.getName(), downloadedFile.getName());
-        Assert.assertArrayEquals(FileUtils.readFileToByteArray(downloadedFile), FileUtils.readFileToByteArray(file));
+        assertTrue(downloadedFile.exists());
+        assertEquals(file.getName(), downloadedFile.getName());
+        assertArrayEquals(FileUtils.readFileToByteArray(downloadedFile), FileUtils.readFileToByteArray(file));
+    }
+
+    @Test
+    public void testDownloadFileWithResume() throws Exception {
+        //given
+        String collectionUuid = "some-collection-uuid";
+        String expectedDataString = "testData";
+        String fileName = "sample-file-name";
+        long start = 0;
+        Long end = null;
+
+        InputStream inputStream = new ByteArrayInputStream(expectedDataString.getBytes());
+
+        when(keepWebApiClient.get(collectionUuid, fileName, start, end)).thenReturn(inputStream);
+
+        //when
+        File downloadedFile = fileDownloader.downloadFileWithResume(collectionUuid, fileName, FILE_DOWNLOAD_TEST_DIR, start, end);
+
+        //then
+        assertNotNull(downloadedFile);
+        assertTrue(downloadedFile.exists());
+        String actualDataString = Files.readString(downloadedFile.toPath());
+        assertEquals("The content of the file does not match the expected data.", expectedDataString, actualDataString);
     }
 
     @After
index 50a29234beac746d1ad29d9a2436a150d4e82c5e..2dba5819ee70b867077db51a73170ba3f11d2080 100644 (file)
@@ -4,4 +4,6 @@
 
 include LICENSE-2.0.txt
 include README.rst
-include arvados_version.py
\ No newline at end of file
+include arvados-v1-discovery.json
+include arvados_version.py
+include discovery2pydoc.py
index 5e9bf64c4f724a7cd90f4e8f40ff75ea67efd177..e40866c624e116ca63758bcbbf2f140aa80cf16a 100644 (file)
@@ -22,17 +22,29 @@ Installation
 Installing under your user account
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-This method lets you install the package without root access.
-However, other users on the same system won't be able to use it.
+This method lets you install the package without root access.  However,
+other users on the same system will need to reconfigure their shell in order
+to be able to use it. Run the following to install the package in an
+environment at ``~/arvclients``::
 
-1. Run ``pip install --user arvados-python-client``.
+  python3 -m venv ~/arvclients
+  ~/arvclients/bin/pip install arvados-python-client
 
-2. In your shell configuration, make sure you add ``$HOME/.local/bin``
-   to your PATH environment variable.  For example, you could add the
-   command ``PATH=$PATH:$HOME/.local/bin`` to your ``.bashrc`` file.
+Command line tools will be installed under ``~/arvclients/bin``. You can
+test one by running::
 
-3. Reload your shell configuration.  For example, bash users could run
-   ``source ~/.bashrc``.
+  ~/arvclients/bin/arv-get --version
+
+You can run these tools by specifying the full path every time, or you can
+add the directory to your shell's search path by running::
+
+  export PATH="$PATH:$HOME/arvclients/bin"
+
+You can make this search path change permanent by adding this command to
+your shell's configuration, for example ``~/.bashrc`` if you're using bash.
+You can test the change by running::
+
+  arv-get --version
 
 Installing on Debian systems
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/sdk/python/arvados-v1-discovery.json b/sdk/python/arvados-v1-discovery.json
new file mode 100644 (file)
index 0000000..232c88d
--- /dev/null
@@ -0,0 +1,11322 @@
+{
+  "auth": {
+    "oauth2": {
+      "scopes": {
+        "https://api.arvados.org/auth/arvados": {
+          "description": "View and manage objects"
+        },
+        "https://api.arvados.org/auth/arvados.readonly": {
+          "description": "View objects"
+        }
+      }
+    }
+  },
+  "basePath": "/arvados/v1/",
+  "batchPath": "batch",
+  "description": "The API to interact with Arvados.",
+  "discoveryVersion": "v1",
+  "documentationLink": "http://doc.arvados.org/api/index.html",
+  "id": "arvados:v1",
+  "kind": "discovery#restDescription",
+  "name": "arvados",
+  "parameters": {
+    "alt": {
+      "type": "string",
+      "description": "Data format for the response.",
+      "default": "json",
+      "enum": [
+        "json"
+      ],
+      "enumDescriptions": [
+        "Responses with Content-Type of application/json"
+      ],
+      "location": "query"
+    },
+    "fields": {
+      "type": "string",
+      "description": "Selector specifying which fields to include in a partial response.",
+      "location": "query"
+    },
+    "key": {
+      "type": "string",
+      "description": "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.",
+      "location": "query"
+    },
+    "oauth_token": {
+      "type": "string",
+      "description": "OAuth 2.0 token for the current user.",
+      "location": "query"
+    }
+  },
+  "protocol": "rest",
+  "resources": {
+    "api_clients": {
+      "methods": {
+        "get": {
+          "id": "arvados.api_clients.get",
+          "path": "api_clients/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a ApiClient's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the ApiClient in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "ApiClient"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.api_clients.list",
+          "path": "api_clients",
+          "httpMethod": "GET",
+          "description": "List ApiClients.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching ApiClients. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#apiClientList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ApiClientList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.api_clients.create",
+          "path": "api_clients",
+          "httpMethod": "POST",
+          "description": "Create a new ApiClient.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "api_client": {
+                "$ref": "ApiClient"
+              }
+            }
+          },
+          "response": {
+            "$ref": "ApiClient"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.api_clients.update",
+          "path": "api_clients/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing ApiClient.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the ApiClient in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "api_client": {
+                "$ref": "ApiClient"
+              }
+            }
+          },
+          "response": {
+            "$ref": "ApiClient"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.api_clients.delete",
+          "path": "api_clients/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing ApiClient.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the ApiClient in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "ApiClient"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.api_clients.list",
+          "path": "api_clients",
+          "httpMethod": "GET",
+          "description": "List ApiClients.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching ApiClients. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#apiClientList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ApiClientList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.api_clients.show",
+          "path": "api_clients/{uuid}",
+          "httpMethod": "GET",
+          "description": "show api_clients",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ApiClient"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.api_clients.destroy",
+          "path": "api_clients/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy api_clients",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "ApiClient"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "api_client_authorizations": {
+      "methods": {
+        "get": {
+          "id": "arvados.api_client_authorizations.get",
+          "path": "api_client_authorizations/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a ApiClientAuthorization's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the ApiClientAuthorization in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "ApiClientAuthorization"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.api_client_authorizations.list",
+          "path": "api_client_authorizations",
+          "httpMethod": "GET",
+          "description": "List ApiClientAuthorizations.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching ApiClientAuthorizations. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#apiClientAuthorizationList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ApiClientAuthorizationList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.api_client_authorizations.create",
+          "path": "api_client_authorizations",
+          "httpMethod": "POST",
+          "description": "Create a new ApiClientAuthorization.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "api_client_authorization": {
+                "$ref": "ApiClientAuthorization"
+              }
+            }
+          },
+          "response": {
+            "$ref": "ApiClientAuthorization"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.api_client_authorizations.update",
+          "path": "api_client_authorizations/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing ApiClientAuthorization.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the ApiClientAuthorization in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "api_client_authorization": {
+                "$ref": "ApiClientAuthorization"
+              }
+            }
+          },
+          "response": {
+            "$ref": "ApiClientAuthorization"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.api_client_authorizations.delete",
+          "path": "api_client_authorizations/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing ApiClientAuthorization.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the ApiClientAuthorization in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "ApiClientAuthorization"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "create_system_auth": {
+          "id": "arvados.api_client_authorizations.create_system_auth",
+          "path": "api_client_authorizations/create_system_auth",
+          "httpMethod": "POST",
+          "description": "create_system_auth api_client_authorizations",
+          "parameters": {
+            "api_client_id": {
+              "type": "integer",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "scopes": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ApiClientAuthorization"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "current": {
+          "id": "arvados.api_client_authorizations.current",
+          "path": "api_client_authorizations/current",
+          "httpMethod": "GET",
+          "description": "current api_client_authorizations",
+          "parameters": {},
+          "response": {
+            "$ref": "ApiClientAuthorization"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.api_client_authorizations.list",
+          "path": "api_client_authorizations",
+          "httpMethod": "GET",
+          "description": "List ApiClientAuthorizations.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching ApiClientAuthorizations. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#apiClientAuthorizationList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ApiClientAuthorizationList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.api_client_authorizations.show",
+          "path": "api_client_authorizations/{uuid}",
+          "httpMethod": "GET",
+          "description": "show api_client_authorizations",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ApiClientAuthorization"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.api_client_authorizations.destroy",
+          "path": "api_client_authorizations/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy api_client_authorizations",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "ApiClientAuthorization"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "authorized_keys": {
+      "methods": {
+        "get": {
+          "id": "arvados.authorized_keys.get",
+          "path": "authorized_keys/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a AuthorizedKey's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the AuthorizedKey in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "AuthorizedKey"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.authorized_keys.list",
+          "path": "authorized_keys",
+          "httpMethod": "GET",
+          "description": "List AuthorizedKeys.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching AuthorizedKeys. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#authorizedKeyList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "AuthorizedKeyList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.authorized_keys.create",
+          "path": "authorized_keys",
+          "httpMethod": "POST",
+          "description": "Create a new AuthorizedKey.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "authorized_key": {
+                "$ref": "AuthorizedKey"
+              }
+            }
+          },
+          "response": {
+            "$ref": "AuthorizedKey"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.authorized_keys.update",
+          "path": "authorized_keys/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing AuthorizedKey.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the AuthorizedKey in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "authorized_key": {
+                "$ref": "AuthorizedKey"
+              }
+            }
+          },
+          "response": {
+            "$ref": "AuthorizedKey"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.authorized_keys.delete",
+          "path": "authorized_keys/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing AuthorizedKey.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the AuthorizedKey in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "AuthorizedKey"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.authorized_keys.list",
+          "path": "authorized_keys",
+          "httpMethod": "GET",
+          "description": "List AuthorizedKeys.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching AuthorizedKeys. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#authorizedKeyList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "AuthorizedKeyList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.authorized_keys.show",
+          "path": "authorized_keys/{uuid}",
+          "httpMethod": "GET",
+          "description": "show authorized_keys",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "AuthorizedKey"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.authorized_keys.destroy",
+          "path": "authorized_keys/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy authorized_keys",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "AuthorizedKey"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "collections": {
+      "methods": {
+        "get": {
+          "id": "arvados.collections.get",
+          "path": "collections/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Collection's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Collection in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.collections.list",
+          "path": "collections",
+          "httpMethod": "GET",
+          "description": "List Collections.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Collections. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#collectionList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include collections whose is_trashed attribute is true.",
+              "location": "query"
+            },
+            "include_old_versions": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include past collection versions.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "CollectionList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.collections.create",
+          "path": "collections",
+          "httpMethod": "POST",
+          "description": "Create a new Collection.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "replace_files": {
+              "type": "object",
+              "description": "Files and directories to initialize/replace with content from other collections.",
+              "required": false,
+              "location": "query",
+              "properties": {},
+              "additionalProperties": {
+                "type": "string"
+              }
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "collection": {
+                "$ref": "Collection"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.collections.update",
+          "path": "collections/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Collection.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Collection in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "replace_files": {
+              "type": "object",
+              "description": "Files and directories to initialize/replace with content from other collections.",
+              "required": false,
+              "location": "query",
+              "properties": {},
+              "additionalProperties": {
+                "type": "string"
+              }
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "collection": {
+                "$ref": "Collection"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.collections.delete",
+          "path": "collections/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Collection.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Collection in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "provenance": {
+          "id": "arvados.collections.provenance",
+          "path": "collections/{uuid}/provenance",
+          "httpMethod": "GET",
+          "description": "provenance collections",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "used_by": {
+          "id": "arvados.collections.used_by",
+          "path": "collections/{uuid}/used_by",
+          "httpMethod": "GET",
+          "description": "used_by collections",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "trash": {
+          "id": "arvados.collections.trash",
+          "path": "collections/{uuid}/trash",
+          "httpMethod": "POST",
+          "description": "trash collections",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "untrash": {
+          "id": "arvados.collections.untrash",
+          "path": "collections/{uuid}/untrash",
+          "httpMethod": "POST",
+          "description": "untrash collections",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.collections.list",
+          "path": "collections",
+          "httpMethod": "GET",
+          "description": "List Collections.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Collections. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#collectionList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include collections whose is_trashed attribute is true.",
+              "location": "query"
+            },
+            "include_old_versions": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include past collection versions.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "CollectionList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.collections.show",
+          "path": "collections/{uuid}",
+          "httpMethod": "GET",
+          "description": "show collections",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Show collection even if its is_trashed attribute is true.",
+              "location": "query"
+            },
+            "include_old_versions": {
+              "type": "boolean",
+              "required": false,
+              "default": "true",
+              "description": "Include past collection versions.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.collections.destroy",
+          "path": "collections/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy collections",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Collection"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "containers": {
+      "methods": {
+        "get": {
+          "id": "arvados.containers.get",
+          "path": "containers/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Container's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Container in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.containers.list",
+          "path": "containers",
+          "httpMethod": "GET",
+          "description": "List Containers.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Containers. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#containerList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ContainerList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.containers.create",
+          "path": "containers",
+          "httpMethod": "POST",
+          "description": "Create a new Container.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "container": {
+                "$ref": "Container"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.containers.update",
+          "path": "containers/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Container.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Container in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "container": {
+                "$ref": "Container"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.containers.delete",
+          "path": "containers/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Container.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Container in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "auth": {
+          "id": "arvados.containers.auth",
+          "path": "containers/{uuid}/auth",
+          "httpMethod": "GET",
+          "description": "auth containers",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "lock": {
+          "id": "arvados.containers.lock",
+          "path": "containers/{uuid}/lock",
+          "httpMethod": "POST",
+          "description": "lock containers",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "unlock": {
+          "id": "arvados.containers.unlock",
+          "path": "containers/{uuid}/unlock",
+          "httpMethod": "POST",
+          "description": "unlock containers",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update_priority": {
+          "id": "arvados.containers.update_priority",
+          "path": "containers/{uuid}/update_priority",
+          "httpMethod": "POST",
+          "description": "update_priority containers",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "secret_mounts": {
+          "id": "arvados.containers.secret_mounts",
+          "path": "containers/{uuid}/secret_mounts",
+          "httpMethod": "GET",
+          "description": "secret_mounts containers",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "current": {
+          "id": "arvados.containers.current",
+          "path": "containers/current",
+          "httpMethod": "GET",
+          "description": "current containers",
+          "parameters": {},
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.containers.list",
+          "path": "containers",
+          "httpMethod": "GET",
+          "description": "List Containers.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Containers. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#containerList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ContainerList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.containers.show",
+          "path": "containers/{uuid}",
+          "httpMethod": "GET",
+          "description": "show containers",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.containers.destroy",
+          "path": "containers/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy containers",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Container"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "container_requests": {
+      "methods": {
+        "get": {
+          "id": "arvados.container_requests.get",
+          "path": "container_requests/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a ContainerRequest's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the ContainerRequest in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "ContainerRequest"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.container_requests.list",
+          "path": "container_requests",
+          "httpMethod": "GET",
+          "description": "List ContainerRequests.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching ContainerRequests. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#containerRequestList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include container requests whose owner project is trashed.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ContainerRequestList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.container_requests.create",
+          "path": "container_requests",
+          "httpMethod": "POST",
+          "description": "Create a new ContainerRequest.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "container_request": {
+                "$ref": "ContainerRequest"
+              }
+            }
+          },
+          "response": {
+            "$ref": "ContainerRequest"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.container_requests.update",
+          "path": "container_requests/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing ContainerRequest.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the ContainerRequest in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "container_request": {
+                "$ref": "ContainerRequest"
+              }
+            }
+          },
+          "response": {
+            "$ref": "ContainerRequest"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.container_requests.delete",
+          "path": "container_requests/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing ContainerRequest.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the ContainerRequest in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "ContainerRequest"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "container_status": {
+          "id": "arvados.container_requests.container_status",
+          "path": "container_requests/{uuid}/container_status",
+          "httpMethod": "GET",
+          "description": "container_status container_requests",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "required": true,
+              "description": "The UUID of the ContainerRequest in question.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ContainerRequest"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.container_requests.list",
+          "path": "container_requests",
+          "httpMethod": "GET",
+          "description": "List ContainerRequests.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching ContainerRequests. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#containerRequestList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include container requests whose owner project is trashed.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ContainerRequestList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.container_requests.show",
+          "path": "container_requests/{uuid}",
+          "httpMethod": "GET",
+          "description": "show container_requests",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Show container request even if its owner project is trashed.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ContainerRequest"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.container_requests.destroy",
+          "path": "container_requests/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy container_requests",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "ContainerRequest"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "groups": {
+      "methods": {
+        "get": {
+          "id": "arvados.groups.get",
+          "path": "groups/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Group's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Group in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.groups.list",
+          "path": "groups",
+          "httpMethod": "GET",
+          "description": "List Groups.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Groups. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#groupList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include items whose is_trashed attribute is true.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "GroupList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.groups.create",
+          "path": "groups",
+          "httpMethod": "POST",
+          "description": "Create a new Group.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "async": {
+              "required": false,
+              "type": "boolean",
+              "location": "query",
+              "default": "false",
+              "description": "defer permissions update"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "group": {
+                "$ref": "Group"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.groups.update",
+          "path": "groups/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Group.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Group in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "async": {
+              "required": false,
+              "type": "boolean",
+              "location": "query",
+              "default": "false",
+              "description": "defer permissions update"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "group": {
+                "$ref": "Group"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.groups.delete",
+          "path": "groups/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Group.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Group in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "contents": {
+          "id": "arvados.groups.contents",
+          "path": "groups/contents",
+          "httpMethod": "GET",
+          "description": "contents groups",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include items whose is_trashed attribute is true.",
+              "location": "query"
+            },
+            "uuid": {
+              "type": "string",
+              "required": false,
+              "default": "",
+              "description": "",
+              "location": "query"
+            },
+            "recursive": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include contents from child groups recursively.",
+              "location": "query"
+            },
+            "include": {
+              "type": "string",
+              "required": false,
+              "description": "Include objects referred to by listed field in \"included\" (only owner_uuid).",
+              "location": "query"
+            },
+            "include_old_versions": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include past collection versions.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "shared": {
+          "id": "arvados.groups.shared",
+          "path": "groups/shared",
+          "httpMethod": "GET",
+          "description": "shared groups",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include items whose is_trashed attribute is true.",
+              "location": "query"
+            },
+            "include": {
+              "type": "string",
+              "required": false,
+              "description": "",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "trash": {
+          "id": "arvados.groups.trash",
+          "path": "groups/{uuid}/trash",
+          "httpMethod": "POST",
+          "description": "trash groups",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "untrash": {
+          "id": "arvados.groups.untrash",
+          "path": "groups/{uuid}/untrash",
+          "httpMethod": "POST",
+          "description": "untrash groups",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.groups.list",
+          "path": "groups",
+          "httpMethod": "GET",
+          "description": "List Groups.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Groups. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#groupList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Include items whose is_trashed attribute is true.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "GroupList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.groups.show",
+          "path": "groups/{uuid}",
+          "httpMethod": "GET",
+          "description": "show groups",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "include_trash": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "Show group/project even if its is_trashed attribute is true.",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.groups.destroy",
+          "path": "groups/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy groups",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Group"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "humans": {
+      "methods": {
+        "get": {
+          "id": "arvados.humans.get",
+          "path": "humans/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Human's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Human in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Human"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.humans.list",
+          "path": "humans",
+          "httpMethod": "GET",
+          "description": "List Humans.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Humans. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#humanList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "HumanList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.humans.create",
+          "path": "humans",
+          "httpMethod": "POST",
+          "description": "Create a new Human.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "human": {
+                "$ref": "Human"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Human"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.humans.update",
+          "path": "humans/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Human.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Human in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "human": {
+                "$ref": "Human"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Human"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.humans.delete",
+          "path": "humans/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Human.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Human in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Human"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.humans.list",
+          "path": "humans",
+          "httpMethod": "GET",
+          "description": "List Humans.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Humans. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#humanList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "HumanList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.humans.show",
+          "path": "humans/{uuid}",
+          "httpMethod": "GET",
+          "description": "show humans",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Human"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.humans.destroy",
+          "path": "humans/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy humans",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Human"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "jobs": {
+      "methods": {
+        "get": {
+          "id": "arvados.jobs.get",
+          "path": "jobs/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Job's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Job in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.jobs.list",
+          "path": "jobs",
+          "httpMethod": "GET",
+          "description": "List Jobs.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Jobs. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#jobList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "JobList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.jobs.create",
+          "path": "jobs",
+          "httpMethod": "POST",
+          "description": "Create a new Job.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "find_or_create": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "minimum_script_version": {
+              "type": "string",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "exclude_script_versions": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "job": {
+                "$ref": "Job"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.jobs.update",
+          "path": "jobs/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Job.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Job in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "job": {
+                "$ref": "Job"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.jobs.delete",
+          "path": "jobs/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Job.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Job in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "queue": {
+          "id": "arvados.jobs.queue",
+          "path": "jobs/queue",
+          "httpMethod": "GET",
+          "description": "queue jobs",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "queue_size": {
+          "id": "arvados.jobs.queue_size",
+          "path": "jobs/queue_size",
+          "httpMethod": "GET",
+          "description": "queue_size jobs",
+          "parameters": {},
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "cancel": {
+          "id": "arvados.jobs.cancel",
+          "path": "jobs/{uuid}/cancel",
+          "httpMethod": "POST",
+          "description": "cancel jobs",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "lock": {
+          "id": "arvados.jobs.lock",
+          "path": "jobs/{uuid}/lock",
+          "httpMethod": "POST",
+          "description": "lock jobs",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.jobs.list",
+          "path": "jobs",
+          "httpMethod": "GET",
+          "description": "List Jobs.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Jobs. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#jobList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "JobList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.jobs.show",
+          "path": "jobs/{uuid}",
+          "httpMethod": "GET",
+          "description": "show jobs",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.jobs.destroy",
+          "path": "jobs/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy jobs",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Job"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "job_tasks": {
+      "methods": {
+        "get": {
+          "id": "arvados.job_tasks.get",
+          "path": "job_tasks/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a JobTask's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the JobTask in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "JobTask"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.job_tasks.list",
+          "path": "job_tasks",
+          "httpMethod": "GET",
+          "description": "List JobTasks.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching JobTasks. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#jobTaskList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "JobTaskList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.job_tasks.create",
+          "path": "job_tasks",
+          "httpMethod": "POST",
+          "description": "Create a new JobTask.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "job_task": {
+                "$ref": "JobTask"
+              }
+            }
+          },
+          "response": {
+            "$ref": "JobTask"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.job_tasks.update",
+          "path": "job_tasks/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing JobTask.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the JobTask in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "job_task": {
+                "$ref": "JobTask"
+              }
+            }
+          },
+          "response": {
+            "$ref": "JobTask"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.job_tasks.delete",
+          "path": "job_tasks/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing JobTask.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the JobTask in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "JobTask"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.job_tasks.list",
+          "path": "job_tasks",
+          "httpMethod": "GET",
+          "description": "List JobTasks.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching JobTasks. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#jobTaskList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "JobTaskList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.job_tasks.show",
+          "path": "job_tasks/{uuid}",
+          "httpMethod": "GET",
+          "description": "show job_tasks",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "JobTask"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.job_tasks.destroy",
+          "path": "job_tasks/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy job_tasks",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "JobTask"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "keep_disks": {
+      "methods": {
+        "get": {
+          "id": "arvados.keep_disks.get",
+          "path": "keep_disks/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a KeepDisk's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the KeepDisk in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "KeepDisk"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.keep_disks.list",
+          "path": "keep_disks",
+          "httpMethod": "GET",
+          "description": "List KeepDisks.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching KeepDisks. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#keepDiskList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "KeepDiskList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.keep_disks.create",
+          "path": "keep_disks",
+          "httpMethod": "POST",
+          "description": "Create a new KeepDisk.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "keep_disk": {
+                "$ref": "KeepDisk"
+              }
+            }
+          },
+          "response": {
+            "$ref": "KeepDisk"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.keep_disks.update",
+          "path": "keep_disks/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing KeepDisk.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the KeepDisk in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "keep_disk": {
+                "$ref": "KeepDisk"
+              }
+            }
+          },
+          "response": {
+            "$ref": "KeepDisk"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.keep_disks.delete",
+          "path": "keep_disks/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing KeepDisk.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the KeepDisk in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "KeepDisk"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "ping": {
+          "id": "arvados.keep_disks.ping",
+          "path": "keep_disks/ping",
+          "httpMethod": "POST",
+          "description": "ping keep_disks",
+          "parameters": {
+            "uuid": {
+              "required": false,
+              "type": "string",
+              "description": "",
+              "location": "query"
+            },
+            "ping_secret": {
+              "required": true,
+              "type": "string",
+              "description": "",
+              "location": "query"
+            },
+            "node_uuid": {
+              "required": false,
+              "type": "string",
+              "description": "",
+              "location": "query"
+            },
+            "filesystem_uuid": {
+              "required": false,
+              "type": "string",
+              "description": "",
+              "location": "query"
+            },
+            "service_host": {
+              "required": false,
+              "type": "string",
+              "description": "",
+              "location": "query"
+            },
+            "service_port": {
+              "required": true,
+              "type": "string",
+              "description": "",
+              "location": "query"
+            },
+            "service_ssl_flag": {
+              "required": true,
+              "type": "string",
+              "description": "",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "KeepDisk"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.keep_disks.list",
+          "path": "keep_disks",
+          "httpMethod": "GET",
+          "description": "List KeepDisks.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching KeepDisks. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#keepDiskList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "KeepDiskList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.keep_disks.show",
+          "path": "keep_disks/{uuid}",
+          "httpMethod": "GET",
+          "description": "show keep_disks",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "KeepDisk"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.keep_disks.destroy",
+          "path": "keep_disks/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy keep_disks",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "KeepDisk"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "keep_services": {
+      "methods": {
+        "get": {
+          "id": "arvados.keep_services.get",
+          "path": "keep_services/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a KeepService's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the KeepService in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "KeepService"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.keep_services.list",
+          "path": "keep_services",
+          "httpMethod": "GET",
+          "description": "List KeepServices.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching KeepServices. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#keepServiceList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "KeepServiceList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.keep_services.create",
+          "path": "keep_services",
+          "httpMethod": "POST",
+          "description": "Create a new KeepService.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "keep_service": {
+                "$ref": "KeepService"
+              }
+            }
+          },
+          "response": {
+            "$ref": "KeepService"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.keep_services.update",
+          "path": "keep_services/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing KeepService.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the KeepService in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "keep_service": {
+                "$ref": "KeepService"
+              }
+            }
+          },
+          "response": {
+            "$ref": "KeepService"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.keep_services.delete",
+          "path": "keep_services/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing KeepService.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the KeepService in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "KeepService"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "accessible": {
+          "id": "arvados.keep_services.accessible",
+          "path": "keep_services/accessible",
+          "httpMethod": "GET",
+          "description": "accessible keep_services",
+          "parameters": {},
+          "response": {
+            "$ref": "KeepService"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.keep_services.list",
+          "path": "keep_services",
+          "httpMethod": "GET",
+          "description": "List KeepServices.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching KeepServices. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#keepServiceList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "KeepServiceList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.keep_services.show",
+          "path": "keep_services/{uuid}",
+          "httpMethod": "GET",
+          "description": "show keep_services",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "KeepService"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.keep_services.destroy",
+          "path": "keep_services/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy keep_services",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "KeepService"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "links": {
+      "methods": {
+        "get": {
+          "id": "arvados.links.get",
+          "path": "links/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Link's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Link in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Link"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.links.list",
+          "path": "links",
+          "httpMethod": "GET",
+          "description": "List Links.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Links. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#linkList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "LinkList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.links.create",
+          "path": "links",
+          "httpMethod": "POST",
+          "description": "Create a new Link.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "link": {
+                "$ref": "Link"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Link"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.links.update",
+          "path": "links/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Link.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Link in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "link": {
+                "$ref": "Link"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Link"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.links.delete",
+          "path": "links/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Link.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Link in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Link"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.links.list",
+          "path": "links",
+          "httpMethod": "GET",
+          "description": "List Links.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Links. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#linkList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "LinkList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.links.show",
+          "path": "links/{uuid}",
+          "httpMethod": "GET",
+          "description": "show links",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Link"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.links.destroy",
+          "path": "links/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy links",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Link"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "get_permissions": {
+          "id": "arvados.links.get_permissions",
+          "path": "permissions/{uuid}",
+          "httpMethod": "GET",
+          "description": "get_permissions links",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Link"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "logs": {
+      "methods": {
+        "get": {
+          "id": "arvados.logs.get",
+          "path": "logs/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Log's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Log in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Log"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.logs.list",
+          "path": "logs",
+          "httpMethod": "GET",
+          "description": "List Logs.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Logs. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#logList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "LogList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.logs.create",
+          "path": "logs",
+          "httpMethod": "POST",
+          "description": "Create a new Log.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "log": {
+                "$ref": "Log"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Log"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.logs.update",
+          "path": "logs/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Log.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Log in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "log": {
+                "$ref": "Log"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Log"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.logs.delete",
+          "path": "logs/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Log.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Log in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Log"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.logs.list",
+          "path": "logs",
+          "httpMethod": "GET",
+          "description": "List Logs.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Logs. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#logList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "LogList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.logs.show",
+          "path": "logs/{uuid}",
+          "httpMethod": "GET",
+          "description": "show logs",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Log"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.logs.destroy",
+          "path": "logs/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy logs",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Log"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "nodes": {
+      "methods": {
+        "get": {
+          "id": "arvados.nodes.get",
+          "path": "nodes/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Node's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Node in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Node"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.nodes.list",
+          "path": "nodes",
+          "httpMethod": "GET",
+          "description": "List Nodes.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Nodes. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#nodeList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "NodeList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.nodes.create",
+          "path": "nodes",
+          "httpMethod": "POST",
+          "description": "Create a new Node.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "assign_slot": {
+              "required": false,
+              "type": "boolean",
+              "description": "assign slot and hostname",
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "node": {
+                "$ref": "Node"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Node"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.nodes.update",
+          "path": "nodes/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Node.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Node in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "assign_slot": {
+              "required": false,
+              "type": "boolean",
+              "description": "assign slot and hostname",
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "node": {
+                "$ref": "Node"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Node"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.nodes.delete",
+          "path": "nodes/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Node.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Node in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Node"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "ping": {
+          "id": "arvados.nodes.ping",
+          "path": "nodes/{uuid}/ping",
+          "httpMethod": "POST",
+          "description": "ping nodes",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "ping_secret": {
+              "required": true,
+              "type": "string",
+              "description": "",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Node"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.nodes.list",
+          "path": "nodes",
+          "httpMethod": "GET",
+          "description": "List Nodes.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Nodes. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#nodeList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "NodeList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.nodes.show",
+          "path": "nodes/{uuid}",
+          "httpMethod": "GET",
+          "description": "show nodes",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Node"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.nodes.destroy",
+          "path": "nodes/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy nodes",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Node"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "pipeline_instances": {
+      "methods": {
+        "get": {
+          "id": "arvados.pipeline_instances.get",
+          "path": "pipeline_instances/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a PipelineInstance's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the PipelineInstance in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "PipelineInstance"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.pipeline_instances.list",
+          "path": "pipeline_instances",
+          "httpMethod": "GET",
+          "description": "List PipelineInstances.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching PipelineInstances. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#pipelineInstanceList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "PipelineInstanceList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.pipeline_instances.create",
+          "path": "pipeline_instances",
+          "httpMethod": "POST",
+          "description": "Create a new PipelineInstance.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "pipeline_instance": {
+                "$ref": "PipelineInstance"
+              }
+            }
+          },
+          "response": {
+            "$ref": "PipelineInstance"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.pipeline_instances.update",
+          "path": "pipeline_instances/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing PipelineInstance.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the PipelineInstance in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "pipeline_instance": {
+                "$ref": "PipelineInstance"
+              }
+            }
+          },
+          "response": {
+            "$ref": "PipelineInstance"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.pipeline_instances.delete",
+          "path": "pipeline_instances/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing PipelineInstance.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the PipelineInstance in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "PipelineInstance"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "cancel": {
+          "id": "arvados.pipeline_instances.cancel",
+          "path": "pipeline_instances/{uuid}/cancel",
+          "httpMethod": "POST",
+          "description": "cancel pipeline_instances",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "PipelineInstance"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.pipeline_instances.list",
+          "path": "pipeline_instances",
+          "httpMethod": "GET",
+          "description": "List PipelineInstances.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching PipelineInstances. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#pipelineInstanceList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "PipelineInstanceList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.pipeline_instances.show",
+          "path": "pipeline_instances/{uuid}",
+          "httpMethod": "GET",
+          "description": "show pipeline_instances",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "PipelineInstance"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.pipeline_instances.destroy",
+          "path": "pipeline_instances/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy pipeline_instances",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "PipelineInstance"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "pipeline_templates": {
+      "methods": {
+        "get": {
+          "id": "arvados.pipeline_templates.get",
+          "path": "pipeline_templates/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a PipelineTemplate's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the PipelineTemplate in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "PipelineTemplate"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.pipeline_templates.list",
+          "path": "pipeline_templates",
+          "httpMethod": "GET",
+          "description": "List PipelineTemplates.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching PipelineTemplates. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#pipelineTemplateList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "PipelineTemplateList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.pipeline_templates.create",
+          "path": "pipeline_templates",
+          "httpMethod": "POST",
+          "description": "Create a new PipelineTemplate.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "pipeline_template": {
+                "$ref": "PipelineTemplate"
+              }
+            }
+          },
+          "response": {
+            "$ref": "PipelineTemplate"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.pipeline_templates.update",
+          "path": "pipeline_templates/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing PipelineTemplate.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the PipelineTemplate in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "pipeline_template": {
+                "$ref": "PipelineTemplate"
+              }
+            }
+          },
+          "response": {
+            "$ref": "PipelineTemplate"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.pipeline_templates.delete",
+          "path": "pipeline_templates/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing PipelineTemplate.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the PipelineTemplate in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "PipelineTemplate"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.pipeline_templates.list",
+          "path": "pipeline_templates",
+          "httpMethod": "GET",
+          "description": "List PipelineTemplates.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching PipelineTemplates. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#pipelineTemplateList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "PipelineTemplateList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.pipeline_templates.show",
+          "path": "pipeline_templates/{uuid}",
+          "httpMethod": "GET",
+          "description": "show pipeline_templates",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "PipelineTemplate"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.pipeline_templates.destroy",
+          "path": "pipeline_templates/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy pipeline_templates",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "PipelineTemplate"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "repositories": {
+      "methods": {
+        "get": {
+          "id": "arvados.repositories.get",
+          "path": "repositories/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Repository's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Repository in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Repository"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.repositories.list",
+          "path": "repositories",
+          "httpMethod": "GET",
+          "description": "List Repositories.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Repositories. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#repositoryList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "RepositoryList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.repositories.create",
+          "path": "repositories",
+          "httpMethod": "POST",
+          "description": "Create a new Repository.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "repository": {
+                "$ref": "Repository"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Repository"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.repositories.update",
+          "path": "repositories/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Repository.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Repository in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "repository": {
+                "$ref": "Repository"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Repository"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.repositories.delete",
+          "path": "repositories/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Repository.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Repository in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Repository"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "get_all_permissions": {
+          "id": "arvados.repositories.get_all_permissions",
+          "path": "repositories/get_all_permissions",
+          "httpMethod": "GET",
+          "description": "get_all_permissions repositories",
+          "parameters": {},
+          "response": {
+            "$ref": "Repository"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.repositories.list",
+          "path": "repositories",
+          "httpMethod": "GET",
+          "description": "List Repositories.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Repositories. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#repositoryList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "RepositoryList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.repositories.show",
+          "path": "repositories/{uuid}",
+          "httpMethod": "GET",
+          "description": "show repositories",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Repository"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.repositories.destroy",
+          "path": "repositories/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy repositories",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Repository"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "specimens": {
+      "methods": {
+        "get": {
+          "id": "arvados.specimens.get",
+          "path": "specimens/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Specimen's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Specimen in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Specimen"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.specimens.list",
+          "path": "specimens",
+          "httpMethod": "GET",
+          "description": "List Specimens.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Specimens. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#specimenList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "SpecimenList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.specimens.create",
+          "path": "specimens",
+          "httpMethod": "POST",
+          "description": "Create a new Specimen.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "specimen": {
+                "$ref": "Specimen"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Specimen"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.specimens.update",
+          "path": "specimens/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Specimen.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Specimen in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "specimen": {
+                "$ref": "Specimen"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Specimen"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.specimens.delete",
+          "path": "specimens/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Specimen.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Specimen in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Specimen"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.specimens.list",
+          "path": "specimens",
+          "httpMethod": "GET",
+          "description": "List Specimens.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Specimens. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#specimenList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "SpecimenList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.specimens.show",
+          "path": "specimens/{uuid}",
+          "httpMethod": "GET",
+          "description": "show specimens",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Specimen"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.specimens.destroy",
+          "path": "specimens/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy specimens",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Specimen"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "traits": {
+      "methods": {
+        "get": {
+          "id": "arvados.traits.get",
+          "path": "traits/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Trait's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Trait in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Trait"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.traits.list",
+          "path": "traits",
+          "httpMethod": "GET",
+          "description": "List Traits.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Traits. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#traitList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "TraitList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.traits.create",
+          "path": "traits",
+          "httpMethod": "POST",
+          "description": "Create a new Trait.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "trait": {
+                "$ref": "Trait"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Trait"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.traits.update",
+          "path": "traits/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Trait.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Trait in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "trait": {
+                "$ref": "Trait"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Trait"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.traits.delete",
+          "path": "traits/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Trait.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Trait in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Trait"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.traits.list",
+          "path": "traits",
+          "httpMethod": "GET",
+          "description": "List Traits.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Traits. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#traitList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "TraitList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.traits.show",
+          "path": "traits/{uuid}",
+          "httpMethod": "GET",
+          "description": "show traits",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Trait"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.traits.destroy",
+          "path": "traits/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy traits",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Trait"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "users": {
+      "methods": {
+        "get": {
+          "id": "arvados.users.get",
+          "path": "users/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a User's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the User in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.users.list",
+          "path": "users",
+          "httpMethod": "GET",
+          "description": "List Users.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Users. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#userList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "UserList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.users.create",
+          "path": "users",
+          "httpMethod": "POST",
+          "description": "Create a new User.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "user": {
+                "$ref": "User"
+              }
+            }
+          },
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.users.update",
+          "path": "users/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing User.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the User in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "user": {
+                "$ref": "User"
+              }
+            }
+          },
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.users.delete",
+          "path": "users/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing User.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the User in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "current": {
+          "id": "arvados.users.current",
+          "path": "users/current",
+          "httpMethod": "GET",
+          "description": "current users",
+          "parameters": {},
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "system": {
+          "id": "arvados.users.system",
+          "path": "users/system",
+          "httpMethod": "GET",
+          "description": "system users",
+          "parameters": {},
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "activate": {
+          "id": "arvados.users.activate",
+          "path": "users/{uuid}/activate",
+          "httpMethod": "POST",
+          "description": "activate users",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "setup": {
+          "id": "arvados.users.setup",
+          "path": "users/setup",
+          "httpMethod": "POST",
+          "description": "setup users",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "user": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "repo_name": {
+              "type": "string",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "vm_uuid": {
+              "type": "string",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "send_notification_email": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "unsetup": {
+          "id": "arvados.users.unsetup",
+          "path": "users/{uuid}/unsetup",
+          "httpMethod": "POST",
+          "description": "unsetup users",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "merge": {
+          "id": "arvados.users.merge",
+          "path": "users/merge",
+          "httpMethod": "POST",
+          "description": "merge users",
+          "parameters": {
+            "new_owner_uuid": {
+              "type": "string",
+              "required": true,
+              "description": "",
+              "location": "query"
+            },
+            "new_user_token": {
+              "type": "string",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "redirect_to_new_user": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "old_user_uuid": {
+              "type": "string",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "new_user_uuid": {
+              "type": "string",
+              "required": false,
+              "description": "",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.users.list",
+          "path": "users",
+          "httpMethod": "GET",
+          "description": "List Users.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Users. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#userList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "UserList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.users.show",
+          "path": "users/{uuid}",
+          "httpMethod": "GET",
+          "description": "show users",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.users.destroy",
+          "path": "users/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy users",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "User"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "user_agreements": {
+      "methods": {
+        "get": {
+          "id": "arvados.user_agreements.get",
+          "path": "user_agreements/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a UserAgreement's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the UserAgreement in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "UserAgreement"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.user_agreements.list",
+          "path": "user_agreements",
+          "httpMethod": "GET",
+          "description": "List UserAgreements.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching UserAgreements. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#userAgreementList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "UserAgreementList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.user_agreements.create",
+          "path": "user_agreements",
+          "httpMethod": "POST",
+          "description": "Create a new UserAgreement.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "user_agreement": {
+                "$ref": "UserAgreement"
+              }
+            }
+          },
+          "response": {
+            "$ref": "UserAgreement"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.user_agreements.update",
+          "path": "user_agreements/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing UserAgreement.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the UserAgreement in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "user_agreement": {
+                "$ref": "UserAgreement"
+              }
+            }
+          },
+          "response": {
+            "$ref": "UserAgreement"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.user_agreements.delete",
+          "path": "user_agreements/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing UserAgreement.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the UserAgreement in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "UserAgreement"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "signatures": {
+          "id": "arvados.user_agreements.signatures",
+          "path": "user_agreements/signatures",
+          "httpMethod": "GET",
+          "description": "signatures user_agreements",
+          "parameters": {},
+          "response": {
+            "$ref": "UserAgreement"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "sign": {
+          "id": "arvados.user_agreements.sign",
+          "path": "user_agreements/sign",
+          "httpMethod": "POST",
+          "description": "sign user_agreements",
+          "parameters": {},
+          "response": {
+            "$ref": "UserAgreement"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.user_agreements.list",
+          "path": "user_agreements",
+          "httpMethod": "GET",
+          "description": "List UserAgreements.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching UserAgreements. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#userAgreementList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "UserAgreementList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "new": {
+          "id": "arvados.user_agreements.new",
+          "path": "user_agreements/new",
+          "httpMethod": "GET",
+          "description": "new user_agreements",
+          "parameters": {},
+          "response": {
+            "$ref": "UserAgreement"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "show": {
+          "id": "arvados.user_agreements.show",
+          "path": "user_agreements/{uuid}",
+          "httpMethod": "GET",
+          "description": "show user_agreements",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "UserAgreement"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.user_agreements.destroy",
+          "path": "user_agreements/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy user_agreements",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "UserAgreement"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "virtual_machines": {
+      "methods": {
+        "get": {
+          "id": "arvados.virtual_machines.get",
+          "path": "virtual_machines/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a VirtualMachine's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the VirtualMachine in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "VirtualMachine"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.virtual_machines.list",
+          "path": "virtual_machines",
+          "httpMethod": "GET",
+          "description": "List VirtualMachines.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching VirtualMachines. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#virtualMachineList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "VirtualMachineList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.virtual_machines.create",
+          "path": "virtual_machines",
+          "httpMethod": "POST",
+          "description": "Create a new VirtualMachine.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "virtual_machine": {
+                "$ref": "VirtualMachine"
+              }
+            }
+          },
+          "response": {
+            "$ref": "VirtualMachine"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.virtual_machines.update",
+          "path": "virtual_machines/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing VirtualMachine.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the VirtualMachine in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "virtual_machine": {
+                "$ref": "VirtualMachine"
+              }
+            }
+          },
+          "response": {
+            "$ref": "VirtualMachine"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.virtual_machines.delete",
+          "path": "virtual_machines/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing VirtualMachine.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the VirtualMachine in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "VirtualMachine"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "logins": {
+          "id": "arvados.virtual_machines.logins",
+          "path": "virtual_machines/{uuid}/logins",
+          "httpMethod": "GET",
+          "description": "logins virtual_machines",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "VirtualMachine"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "get_all_logins": {
+          "id": "arvados.virtual_machines.get_all_logins",
+          "path": "virtual_machines/get_all_logins",
+          "httpMethod": "GET",
+          "description": "get_all_logins virtual_machines",
+          "parameters": {},
+          "response": {
+            "$ref": "VirtualMachine"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.virtual_machines.list",
+          "path": "virtual_machines",
+          "httpMethod": "GET",
+          "description": "List VirtualMachines.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching VirtualMachines. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#virtualMachineList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "VirtualMachineList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.virtual_machines.show",
+          "path": "virtual_machines/{uuid}",
+          "httpMethod": "GET",
+          "description": "show virtual_machines",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "VirtualMachine"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.virtual_machines.destroy",
+          "path": "virtual_machines/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy virtual_machines",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "VirtualMachine"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "workflows": {
+      "methods": {
+        "get": {
+          "id": "arvados.workflows.get",
+          "path": "workflows/{uuid}",
+          "httpMethod": "GET",
+          "description": "Gets a Workflow's metadata by UUID.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Workflow in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "parameterOrder": [
+            "uuid"
+          ],
+          "response": {
+            "$ref": "Workflow"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "index": {
+          "id": "arvados.workflows.list",
+          "path": "workflows",
+          "httpMethod": "GET",
+          "description": "List Workflows.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Workflows. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#workflowList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "WorkflowList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "create": {
+          "id": "arvados.workflows.create",
+          "path": "workflows",
+          "httpMethod": "POST",
+          "description": "Create a new Workflow.",
+          "parameters": {
+            "select": {
+              "type": "array",
+              "description": "Attributes of the new object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "ensure_unique_name": {
+              "type": "boolean",
+              "description": "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
+              "location": "query",
+              "required": false,
+              "default": "false"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "Create object on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "workflow": {
+                "$ref": "Workflow"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Workflow"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "update": {
+          "id": "arvados.workflows.update",
+          "path": "workflows/{uuid}",
+          "httpMethod": "PUT",
+          "description": "Update attributes of an existing Workflow.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Workflow in question.",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the updated object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "request": {
+            "required": true,
+            "properties": {
+              "workflow": {
+                "$ref": "Workflow"
+              }
+            }
+          },
+          "response": {
+            "$ref": "Workflow"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "delete": {
+          "id": "arvados.workflows.delete",
+          "path": "workflows/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "Delete an existing Workflow.",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "The UUID of the Workflow in question.",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Workflow"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "list": {
+          "id": "arvados.workflows.list",
+          "path": "workflows",
+          "httpMethod": "GET",
+          "description": "List Workflows.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching Workflows. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#workflowList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "offset": {
+              "type": "integer",
+              "required": false,
+              "default": "0",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            },
+            "cluster_id": {
+              "type": "string",
+              "description": "List objects on a remote federated cluster instead of the current one.",
+              "location": "query",
+              "required": false
+            },
+            "bypass_federation": {
+              "type": "boolean",
+              "required": false,
+              "description": "bypass federation behavior, list items from local instance database only",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "WorkflowList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
+        "show": {
+          "id": "arvados.workflows.show",
+          "path": "workflows/{uuid}",
+          "httpMethod": "GET",
+          "description": "show workflows",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of the object to return in the response.",
+              "required": false,
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "Workflow"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        },
+        "destroy": {
+          "id": "arvados.workflows.destroy",
+          "path": "workflows/{uuid}",
+          "httpMethod": "DELETE",
+          "description": "destroy workflows",
+          "parameters": {
+            "uuid": {
+              "type": "string",
+              "description": "",
+              "required": true,
+              "location": "path"
+            }
+          },
+          "response": {
+            "$ref": "Workflow"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados"
+          ]
+        }
+      }
+    },
+    "configs": {
+      "methods": {
+        "get": {
+          "id": "arvados.configs.get",
+          "path": "config",
+          "httpMethod": "GET",
+          "description": "Get public config",
+          "parameters": {},
+          "parameterOrder": [],
+          "response": {},
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        }
+      }
+    },
+    "vocabularies": {
+      "methods": {
+        "get": {
+          "id": "arvados.vocabularies.get",
+          "path": "vocabulary",
+          "httpMethod": "GET",
+          "description": "Get vocabulary definition",
+          "parameters": {},
+          "parameterOrder": [],
+          "response": {},
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        }
+      }
+    },
+    "sys": {
+      "methods": {
+        "get": {
+          "id": "arvados.sys.trash_sweep",
+          "path": "sys/trash_sweep",
+          "httpMethod": "POST",
+          "description": "apply scheduled trash and delete operations",
+          "parameters": {},
+          "parameterOrder": [],
+          "response": {},
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        }
+      }
+    }
+  },
+  "revision": "20231117",
+  "schemas": {
+    "ApiClientList": {
+      "id": "ApiClientList",
+      "description": "ApiClient list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#apiClientList.",
+          "default": "arvados#apiClientList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of ApiClients.",
+          "items": {
+            "$ref": "ApiClient"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of ApiClients."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of ApiClients."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "ApiClient": {
+      "id": "ApiClient",
+      "description": "ApiClient",
+      "type": "object",
+      "uuidPrefix": "ozdt8",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "name": {
+          "type": "string"
+        },
+        "url_prefix": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "is_trusted": {
+          "type": "boolean"
+        }
+      }
+    },
+    "ApiClientAuthorizationList": {
+      "id": "ApiClientAuthorizationList",
+      "description": "ApiClientAuthorization list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#apiClientAuthorizationList.",
+          "default": "arvados#apiClientAuthorizationList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of ApiClientAuthorizations.",
+          "items": {
+            "$ref": "ApiClientAuthorization"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of ApiClientAuthorizations."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of ApiClientAuthorizations."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "ApiClientAuthorization": {
+      "id": "ApiClientAuthorization",
+      "description": "ApiClientAuthorization",
+      "type": "object",
+      "uuidPrefix": "gj3su",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "api_token": {
+          "type": "string"
+        },
+        "api_client_id": {
+          "type": "integer"
+        },
+        "user_id": {
+          "type": "integer"
+        },
+        "created_by_ip_address": {
+          "type": "string"
+        },
+        "last_used_by_ip_address": {
+          "type": "string"
+        },
+        "last_used_at": {
+          "type": "datetime"
+        },
+        "expires_at": {
+          "type": "datetime"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "default_owner_uuid": {
+          "type": "string"
+        },
+        "scopes": {
+          "type": "Array"
+        }
+      }
+    },
+    "AuthorizedKeyList": {
+      "id": "AuthorizedKeyList",
+      "description": "AuthorizedKey list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#authorizedKeyList.",
+          "default": "arvados#authorizedKeyList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of AuthorizedKeys.",
+          "items": {
+            "$ref": "AuthorizedKey"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of AuthorizedKeys."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of AuthorizedKeys."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "AuthorizedKey": {
+      "id": "AuthorizedKey",
+      "description": "AuthorizedKey",
+      "type": "object",
+      "uuidPrefix": "fngyi",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "name": {
+          "type": "string"
+        },
+        "key_type": {
+          "type": "string"
+        },
+        "authorized_user_uuid": {
+          "type": "string"
+        },
+        "public_key": {
+          "type": "text"
+        },
+        "expires_at": {
+          "type": "datetime"
+        },
+        "created_at": {
+          "type": "datetime"
+        }
+      }
+    },
+    "CollectionList": {
+      "id": "CollectionList",
+      "description": "Collection list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#collectionList.",
+          "default": "arvados#collectionList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Collections.",
+          "items": {
+            "$ref": "Collection"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Collections."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Collections."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Collection": {
+      "id": "Collection",
+      "description": "Collection",
+      "type": "object",
+      "uuidPrefix": "4zz18",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "portable_data_hash": {
+          "type": "string"
+        },
+        "replication_desired": {
+          "type": "integer"
+        },
+        "replication_confirmed_at": {
+          "type": "datetime"
+        },
+        "replication_confirmed": {
+          "type": "integer"
+        },
+        "manifest_text": {
+          "type": "text"
+        },
+        "name": {
+          "type": "string"
+        },
+        "description": {
+          "type": "string"
+        },
+        "properties": {
+          "type": "Hash"
+        },
+        "delete_at": {
+          "type": "datetime"
+        },
+        "trash_at": {
+          "type": "datetime"
+        },
+        "is_trashed": {
+          "type": "boolean"
+        },
+        "storage_classes_desired": {
+          "type": "Array"
+        },
+        "storage_classes_confirmed": {
+          "type": "Array"
+        },
+        "storage_classes_confirmed_at": {
+          "type": "datetime"
+        },
+        "current_version_uuid": {
+          "type": "string"
+        },
+        "version": {
+          "type": "integer"
+        },
+        "preserve_version": {
+          "type": "boolean"
+        },
+        "file_count": {
+          "type": "integer"
+        },
+        "file_size_total": {
+          "type": "integer"
+        }
+      }
+    },
+    "ContainerList": {
+      "id": "ContainerList",
+      "description": "Container list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#containerList.",
+          "default": "arvados#containerList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Containers.",
+          "items": {
+            "$ref": "Container"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Containers."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Containers."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Container": {
+      "id": "Container",
+      "description": "Container",
+      "type": "object",
+      "uuidPrefix": "dz642",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "state": {
+          "type": "string"
+        },
+        "started_at": {
+          "type": "datetime"
+        },
+        "finished_at": {
+          "type": "datetime"
+        },
+        "log": {
+          "type": "string"
+        },
+        "environment": {
+          "type": "Hash"
+        },
+        "cwd": {
+          "type": "string"
+        },
+        "command": {
+          "type": "Array"
+        },
+        "output_path": {
+          "type": "string"
+        },
+        "mounts": {
+          "type": "Hash"
+        },
+        "runtime_constraints": {
+          "type": "Hash"
+        },
+        "output": {
+          "type": "string"
+        },
+        "container_image": {
+          "type": "string"
+        },
+        "progress": {
+          "type": "float"
+        },
+        "priority": {
+          "type": "integer"
+        },
+        "exit_code": {
+          "type": "integer"
+        },
+        "auth_uuid": {
+          "type": "string"
+        },
+        "locked_by_uuid": {
+          "type": "string"
+        },
+        "scheduling_parameters": {
+          "type": "Hash"
+        },
+        "runtime_status": {
+          "type": "Hash"
+        },
+        "runtime_user_uuid": {
+          "type": "text"
+        },
+        "runtime_auth_scopes": {
+          "type": "Array"
+        },
+        "lock_count": {
+          "type": "integer"
+        },
+        "gateway_address": {
+          "type": "string"
+        },
+        "interactive_session_started": {
+          "type": "boolean"
+        },
+        "output_storage_classes": {
+          "type": "Array"
+        },
+        "output_properties": {
+          "type": "Hash"
+        },
+        "cost": {
+          "type": "float"
+        },
+        "subrequests_cost": {
+          "type": "float"
+        }
+      }
+    },
+    "ContainerRequestList": {
+      "id": "ContainerRequestList",
+      "description": "ContainerRequest list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#containerRequestList.",
+          "default": "arvados#containerRequestList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of ContainerRequests.",
+          "items": {
+            "$ref": "ContainerRequest"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of ContainerRequests."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of ContainerRequests."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "ContainerRequest": {
+      "id": "ContainerRequest",
+      "description": "ContainerRequest",
+      "type": "object",
+      "uuidPrefix": "xvhdp",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string"
+        },
+        "description": {
+          "type": "text"
+        },
+        "properties": {
+          "type": "Hash"
+        },
+        "state": {
+          "type": "string"
+        },
+        "requesting_container_uuid": {
+          "type": "string"
+        },
+        "container_uuid": {
+          "type": "string"
+        },
+        "container_count_max": {
+          "type": "integer"
+        },
+        "mounts": {
+          "type": "Hash"
+        },
+        "runtime_constraints": {
+          "type": "Hash"
+        },
+        "container_image": {
+          "type": "string"
+        },
+        "environment": {
+          "type": "Hash"
+        },
+        "cwd": {
+          "type": "string"
+        },
+        "command": {
+          "type": "Array"
+        },
+        "output_path": {
+          "type": "string"
+        },
+        "priority": {
+          "type": "integer"
+        },
+        "expires_at": {
+          "type": "datetime"
+        },
+        "filters": {
+          "type": "text"
+        },
+        "container_count": {
+          "type": "integer"
+        },
+        "use_existing": {
+          "type": "boolean"
+        },
+        "scheduling_parameters": {
+          "type": "Hash"
+        },
+        "output_uuid": {
+          "type": "string"
+        },
+        "log_uuid": {
+          "type": "string"
+        },
+        "output_name": {
+          "type": "string"
+        },
+        "output_ttl": {
+          "type": "integer"
+        },
+        "output_storage_classes": {
+          "type": "Array"
+        },
+        "output_properties": {
+          "type": "Hash"
+        },
+        "cumulative_cost": {
+          "type": "float"
+        }
+      }
+    },
+    "GroupList": {
+      "id": "GroupList",
+      "description": "Group list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#groupList.",
+          "default": "arvados#groupList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Groups.",
+          "items": {
+            "$ref": "Group"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Groups."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Groups."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Group": {
+      "id": "Group",
+      "description": "Group",
+      "type": "object",
+      "uuidPrefix": "j7d0g",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "name": {
+          "type": "string"
+        },
+        "description": {
+          "type": "string"
+        },
+        "group_class": {
+          "type": "string"
+        },
+        "trash_at": {
+          "type": "datetime"
+        },
+        "is_trashed": {
+          "type": "boolean"
+        },
+        "delete_at": {
+          "type": "datetime"
+        },
+        "properties": {
+          "type": "Hash"
+        },
+        "frozen_by_uuid": {
+          "type": "string"
+        }
+      }
+    },
+    "HumanList": {
+      "id": "HumanList",
+      "description": "Human list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#humanList.",
+          "default": "arvados#humanList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Humans.",
+          "items": {
+            "$ref": "Human"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Humans."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Humans."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Human": {
+      "id": "Human",
+      "description": "Human",
+      "type": "object",
+      "uuidPrefix": "7a9it",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "properties": {
+          "type": "Hash"
+        },
+        "created_at": {
+          "type": "datetime"
+        }
+      }
+    },
+    "JobList": {
+      "id": "JobList",
+      "description": "Job list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#jobList.",
+          "default": "arvados#jobList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Jobs.",
+          "items": {
+            "$ref": "Job"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Jobs."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Jobs."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Job": {
+      "id": "Job",
+      "description": "Job",
+      "type": "object",
+      "uuidPrefix": "8i9sb",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "submit_id": {
+          "type": "string"
+        },
+        "script": {
+          "type": "string"
+        },
+        "script_version": {
+          "type": "string"
+        },
+        "script_parameters": {
+          "type": "Hash"
+        },
+        "cancelled_by_client_uuid": {
+          "type": "string"
+        },
+        "cancelled_by_user_uuid": {
+          "type": "string"
+        },
+        "cancelled_at": {
+          "type": "datetime"
+        },
+        "started_at": {
+          "type": "datetime"
+        },
+        "finished_at": {
+          "type": "datetime"
+        },
+        "running": {
+          "type": "boolean"
+        },
+        "success": {
+          "type": "boolean"
+        },
+        "output": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "is_locked_by_uuid": {
+          "type": "string"
+        },
+        "log": {
+          "type": "string"
+        },
+        "tasks_summary": {
+          "type": "Hash"
+        },
+        "runtime_constraints": {
+          "type": "Hash"
+        },
+        "nondeterministic": {
+          "type": "boolean"
+        },
+        "repository": {
+          "type": "string"
+        },
+        "supplied_script_version": {
+          "type": "string"
+        },
+        "docker_image_locator": {
+          "type": "string"
+        },
+        "priority": {
+          "type": "integer"
+        },
+        "description": {
+          "type": "string"
+        },
+        "state": {
+          "type": "string"
+        },
+        "arvados_sdk_version": {
+          "type": "string"
+        },
+        "components": {
+          "type": "Hash"
+        }
+      }
+    },
+    "JobTaskList": {
+      "id": "JobTaskList",
+      "description": "JobTask list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#jobTaskList.",
+          "default": "arvados#jobTaskList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of JobTasks.",
+          "items": {
+            "$ref": "JobTask"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of JobTasks."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of JobTasks."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "JobTask": {
+      "id": "JobTask",
+      "description": "JobTask",
+      "type": "object",
+      "uuidPrefix": "ot0gb",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "job_uuid": {
+          "type": "string"
+        },
+        "sequence": {
+          "type": "integer"
+        },
+        "parameters": {
+          "type": "Hash"
+        },
+        "output": {
+          "type": "text"
+        },
+        "progress": {
+          "type": "float"
+        },
+        "success": {
+          "type": "boolean"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "created_by_job_task_uuid": {
+          "type": "string"
+        },
+        "qsequence": {
+          "type": "integer"
+        },
+        "started_at": {
+          "type": "datetime"
+        },
+        "finished_at": {
+          "type": "datetime"
+        }
+      }
+    },
+    "KeepDiskList": {
+      "id": "KeepDiskList",
+      "description": "KeepDisk list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#keepDiskList.",
+          "default": "arvados#keepDiskList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of KeepDisks.",
+          "items": {
+            "$ref": "KeepDisk"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of KeepDisks."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of KeepDisks."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "KeepDisk": {
+      "id": "KeepDisk",
+      "description": "KeepDisk",
+      "type": "object",
+      "uuidPrefix": "penuu",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "node_uuid": {
+          "type": "string"
+        },
+        "filesystem_uuid": {
+          "type": "string"
+        },
+        "bytes_total": {
+          "type": "integer"
+        },
+        "bytes_free": {
+          "type": "integer"
+        },
+        "is_readable": {
+          "type": "boolean"
+        },
+        "is_writable": {
+          "type": "boolean"
+        },
+        "last_read_at": {
+          "type": "datetime"
+        },
+        "last_write_at": {
+          "type": "datetime"
+        },
+        "last_ping_at": {
+          "type": "datetime"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "keep_service_uuid": {
+          "type": "string"
+        }
+      }
+    },
+    "KeepServiceList": {
+      "id": "KeepServiceList",
+      "description": "KeepService list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#keepServiceList.",
+          "default": "arvados#keepServiceList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of KeepServices.",
+          "items": {
+            "$ref": "KeepService"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of KeepServices."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of KeepServices."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "KeepService": {
+      "id": "KeepService",
+      "description": "KeepService",
+      "type": "object",
+      "uuidPrefix": "bi6l4",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "service_host": {
+          "type": "string"
+        },
+        "service_port": {
+          "type": "integer"
+        },
+        "service_ssl_flag": {
+          "type": "boolean"
+        },
+        "service_type": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "read_only": {
+          "type": "boolean"
+        }
+      }
+    },
+    "LinkList": {
+      "id": "LinkList",
+      "description": "Link list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#linkList.",
+          "default": "arvados#linkList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Links.",
+          "items": {
+            "$ref": "Link"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Links."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Links."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Link": {
+      "id": "Link",
+      "description": "Link",
+      "type": "object",
+      "uuidPrefix": "o0j2j",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "tail_uuid": {
+          "type": "string"
+        },
+        "link_class": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string"
+        },
+        "head_uuid": {
+          "type": "string"
+        },
+        "properties": {
+          "type": "Hash"
+        }
+      }
+    },
+    "LogList": {
+      "id": "LogList",
+      "description": "Log list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#logList.",
+          "default": "arvados#logList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Logs.",
+          "items": {
+            "$ref": "Log"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Logs."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Logs."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Log": {
+      "id": "Log",
+      "description": "Log",
+      "type": "object",
+      "uuidPrefix": "57u5n",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "id": {
+          "type": "integer"
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "object_uuid": {
+          "type": "string"
+        },
+        "event_at": {
+          "type": "datetime"
+        },
+        "event_type": {
+          "type": "string"
+        },
+        "summary": {
+          "type": "text"
+        },
+        "properties": {
+          "type": "Hash"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "object_owner_uuid": {
+          "type": "string"
+        }
+      }
+    },
+    "NodeList": {
+      "id": "NodeList",
+      "description": "Node list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#nodeList.",
+          "default": "arvados#nodeList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Nodes.",
+          "items": {
+            "$ref": "Node"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Nodes."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Nodes."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Node": {
+      "id": "Node",
+      "description": "Node",
+      "type": "object",
+      "uuidPrefix": "7ekkf",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "slot_number": {
+          "type": "integer"
+        },
+        "hostname": {
+          "type": "string"
+        },
+        "domain": {
+          "type": "string"
+        },
+        "ip_address": {
+          "type": "string"
+        },
+        "last_ping_at": {
+          "type": "datetime"
+        },
+        "properties": {
+          "type": "Hash"
+        },
+        "job_uuid": {
+          "type": "string"
+        }
+      }
+    },
+    "PipelineInstanceList": {
+      "id": "PipelineInstanceList",
+      "description": "PipelineInstance list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#pipelineInstanceList.",
+          "default": "arvados#pipelineInstanceList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of PipelineInstances.",
+          "items": {
+            "$ref": "PipelineInstance"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of PipelineInstances."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of PipelineInstances."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "PipelineInstance": {
+      "id": "PipelineInstance",
+      "description": "PipelineInstance",
+      "type": "object",
+      "uuidPrefix": "d1hrv",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "pipeline_template_uuid": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string"
+        },
+        "components": {
+          "type": "Hash"
+        },
+        "properties": {
+          "type": "Hash"
+        },
+        "state": {
+          "type": "string"
+        },
+        "components_summary": {
+          "type": "Hash"
+        },
+        "started_at": {
+          "type": "datetime"
+        },
+        "finished_at": {
+          "type": "datetime"
+        },
+        "description": {
+          "type": "string"
+        }
+      }
+    },
+    "PipelineTemplateList": {
+      "id": "PipelineTemplateList",
+      "description": "PipelineTemplate list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#pipelineTemplateList.",
+          "default": "arvados#pipelineTemplateList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of PipelineTemplates.",
+          "items": {
+            "$ref": "PipelineTemplate"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of PipelineTemplates."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of PipelineTemplates."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "PipelineTemplate": {
+      "id": "PipelineTemplate",
+      "description": "PipelineTemplate",
+      "type": "object",
+      "uuidPrefix": "p5p6p",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "name": {
+          "type": "string"
+        },
+        "components": {
+          "type": "Hash"
+        },
+        "description": {
+          "type": "string"
+        }
+      }
+    },
+    "RepositoryList": {
+      "id": "RepositoryList",
+      "description": "Repository list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#repositoryList.",
+          "default": "arvados#repositoryList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Repositories.",
+          "items": {
+            "$ref": "Repository"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Repositories."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Repositories."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Repository": {
+      "id": "Repository",
+      "description": "Repository",
+      "type": "object",
+      "uuidPrefix": "s0uqq",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "name": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        }
+      }
+    },
+    "SpecimenList": {
+      "id": "SpecimenList",
+      "description": "Specimen list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#specimenList.",
+          "default": "arvados#specimenList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Specimens.",
+          "items": {
+            "$ref": "Specimen"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Specimens."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Specimens."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Specimen": {
+      "id": "Specimen",
+      "description": "Specimen",
+      "type": "object",
+      "uuidPrefix": "j58dm",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "material": {
+          "type": "string"
+        },
+        "properties": {
+          "type": "Hash"
+        }
+      }
+    },
+    "TraitList": {
+      "id": "TraitList",
+      "description": "Trait list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#traitList.",
+          "default": "arvados#traitList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Traits.",
+          "items": {
+            "$ref": "Trait"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Traits."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Traits."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Trait": {
+      "id": "Trait",
+      "description": "Trait",
+      "type": "object",
+      "uuidPrefix": "q1cn2",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "name": {
+          "type": "string"
+        },
+        "properties": {
+          "type": "Hash"
+        },
+        "created_at": {
+          "type": "datetime"
+        }
+      }
+    },
+    "UserList": {
+      "id": "UserList",
+      "description": "User list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#userList.",
+          "default": "arvados#userList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Users.",
+          "items": {
+            "$ref": "User"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Users."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Users."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "User": {
+      "id": "User",
+      "description": "User",
+      "type": "object",
+      "uuidPrefix": "tpzed",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "email": {
+          "type": "string"
+        },
+        "first_name": {
+          "type": "string"
+        },
+        "last_name": {
+          "type": "string"
+        },
+        "identity_url": {
+          "type": "string"
+        },
+        "is_admin": {
+          "type": "boolean"
+        },
+        "prefs": {
+          "type": "Hash"
+        },
+        "is_active": {
+          "type": "boolean"
+        },
+        "username": {
+          "type": "string"
+        }
+      }
+    },
+    "UserAgreementList": {
+      "id": "UserAgreementList",
+      "description": "UserAgreement list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#userAgreementList.",
+          "default": "arvados#userAgreementList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of UserAgreements.",
+          "items": {
+            "$ref": "UserAgreement"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of UserAgreements."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of UserAgreements."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "UserAgreement": {
+      "id": "UserAgreement",
+      "description": "UserAgreement",
+      "type": "object",
+      "uuidPrefix": "gv0sa",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "portable_data_hash": {
+          "type": "string"
+        },
+        "replication_desired": {
+          "type": "integer"
+        },
+        "replication_confirmed_at": {
+          "type": "datetime"
+        },
+        "replication_confirmed": {
+          "type": "integer"
+        },
+        "manifest_text": {
+          "type": "text"
+        },
+        "name": {
+          "type": "string"
+        },
+        "description": {
+          "type": "string"
+        },
+        "properties": {
+          "type": "Hash"
+        },
+        "delete_at": {
+          "type": "datetime"
+        },
+        "trash_at": {
+          "type": "datetime"
+        },
+        "is_trashed": {
+          "type": "boolean"
+        },
+        "storage_classes_desired": {
+          "type": "Array"
+        },
+        "storage_classes_confirmed": {
+          "type": "Array"
+        },
+        "storage_classes_confirmed_at": {
+          "type": "datetime"
+        },
+        "current_version_uuid": {
+          "type": "string"
+        },
+        "version": {
+          "type": "integer"
+        },
+        "preserve_version": {
+          "type": "boolean"
+        },
+        "file_count": {
+          "type": "integer"
+        },
+        "file_size_total": {
+          "type": "integer"
+        }
+      }
+    },
+    "VirtualMachineList": {
+      "id": "VirtualMachineList",
+      "description": "VirtualMachine list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#virtualMachineList.",
+          "default": "arvados#virtualMachineList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of VirtualMachines.",
+          "items": {
+            "$ref": "VirtualMachine"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of VirtualMachines."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of VirtualMachines."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "VirtualMachine": {
+      "id": "VirtualMachine",
+      "description": "VirtualMachine",
+      "type": "object",
+      "uuidPrefix": "2x53u",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "hostname": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        }
+      }
+    },
+    "WorkflowList": {
+      "id": "WorkflowList",
+      "description": "Workflow list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#workflowList.",
+          "default": "arvados#workflowList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of Workflows.",
+          "items": {
+            "$ref": "Workflow"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of Workflows."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of Workflows."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "Workflow": {
+      "id": "Workflow",
+      "description": "Workflow",
+      "type": "object",
+      "uuidPrefix": "7fd4e",
+      "properties": {
+        "uuid": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string",
+          "description": "Object version."
+        },
+        "owner_uuid": {
+          "type": "string"
+        },
+        "created_at": {
+          "type": "datetime"
+        },
+        "modified_at": {
+          "type": "datetime"
+        },
+        "modified_by_client_uuid": {
+          "type": "string"
+        },
+        "modified_by_user_uuid": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string"
+        },
+        "description": {
+          "type": "text"
+        },
+        "definition": {
+          "type": "text"
+        }
+      }
+    }
+  },
+  "servicePath": "arvados/v1/",
+  "title": "Arvados API",
+  "version": "v1"
+}
\ No newline at end of file
index c8c70298077092ea8c0b14707e6e6f8563ab2411..83f658201ca3b8c8100c7a0497744af9f4cb3f49 100644 (file)
@@ -1,53 +1,62 @@
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+"""Arvados Python SDK
 
-from __future__ import print_function
-from __future__ import absolute_import
-from future import standard_library
-standard_library.install_aliases()
-from builtins import object
-import bz2
-import fcntl
-import hashlib
-import http.client
-import httplib2
-import json
-import logging
+This module provides the entire Python SDK for Arvados. The most useful modules
+include:
+
+* arvados.api - After you `import arvados`, you can call `arvados.api` as a
+  shortcut to the client constructor function `arvados.api.api`.
+
+* arvados.collection - The `arvados.collection.Collection` class provides a
+  high-level interface to read and write collections. It coordinates sending
+  data to and from Keep, and synchronizing updates with the collection object.
+
+* arvados.util - Utility functions to use mostly in conjunction with the API
+  client object and the results it returns.
+
+Other submodules provide lower-level functionality.
+"""
+
+import logging as stdliblog
 import os
-import pprint
-import re
-import string
 import sys
-import time
 import types
-import zlib
 
-if sys.version_info >= (3, 0):
-    from collections import UserDict
-else:
-    from UserDict import UserDict
+from collections import UserDict
 
-from .api import api, api_from_config, http_cache
+from . import api, errors, util
+from .api import api_from_config, http_cache
 from .collection import CollectionReader, CollectionWriter, ResumableCollectionWriter
 from arvados.keep import *
 from arvados.stream import *
 from .arvfile import StreamFileReader
+from .logging import log_format, log_date_format, log_handler
 from .retry import RetryLoop
-import arvados.errors as errors
-import arvados.util as util
+
+# Previous versions of the PySDK used to say `from .api import api`.  This
+# made it convenient to call the API client constructor, but difficult to
+# access the rest of the `arvados.api` module. The magic below fixes that
+# bug while retaining backwards compatibility: `arvados.api` is now the
+# module and you can import it normally, but we make that module callable so
+# all the existing code that says `arvados.api('v1', ...)` still works.
+class _CallableAPIModule(api.__class__):
+    __call__ = staticmethod(api.api)
+api.__class__ = _CallableAPIModule
+
+# Override logging module pulled in via `from ... import *`
+# so users can `import arvados.logging`.
+logging = sys.modules['arvados.logging']
 
 # Set up Arvados logging based on the user's configuration.
 # All Arvados code should log under the arvados hierarchy.
-log_format = '%(asctime)s %(name)s[%(process)d] %(levelname)s: %(message)s'
-log_date_format = '%Y-%m-%d %H:%M:%S'
-log_handler = logging.StreamHandler()
-log_handler.setFormatter(logging.Formatter(log_format, log_date_format))
-logger = logging.getLogger('arvados')
+logger = stdliblog.getLogger('arvados')
 logger.addHandler(log_handler)
-logger.setLevel(logging.DEBUG if config.get('ARVADOS_DEBUG')
-                else logging.WARNING)
+logger.setLevel(stdliblog.DEBUG if config.get('ARVADOS_DEBUG')
+                else stdliblog.WARNING)
 
+@util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
 def task_set_output(self, s, num_retries=5):
     for tries_left in RetryLoop(num_retries=num_retries, backoff_start=0):
         try:
@@ -65,6 +74,7 @@ def task_set_output(self, s, num_retries=5):
                 raise
 
 _current_task = None
+@util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
 def current_task(num_retries=5):
     global _current_task
     if _current_task:
@@ -85,6 +95,7 @@ def current_task(num_retries=5):
                 raise
 
 _current_job = None
+@util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
 def current_job(num_retries=5):
     global _current_job
     if _current_job:
@@ -103,21 +114,26 @@ def current_job(num_retries=5):
             else:
                 raise
 
+@util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
 def getjobparam(*args):
     return current_job()['script_parameters'].get(*args)
 
+@util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
 def get_job_param_mount(*args):
     return os.path.join(os.environ['TASK_KEEPMOUNT'], current_job()['script_parameters'].get(*args))
 
+@util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
 def get_task_param_mount(*args):
     return os.path.join(os.environ['TASK_KEEPMOUNT'], current_task()['parameters'].get(*args))
 
 class JobTask(object):
+    @util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
     def __init__(self, parameters=dict(), runtime_constraints=dict()):
         print("init jobtask %s %s" % (parameters, runtime_constraints))
 
 class job_setup(object):
     @staticmethod
+    @util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
     def one_task_per_input_file(if_sequence=0, and_end_task=True, input_as_path=False, api_client=None):
         if if_sequence != current_task()['sequence']:
             return
@@ -150,6 +166,7 @@ class job_setup(object):
             exit(0)
 
     @staticmethod
+    @util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
     def one_task_per_input_stream(if_sequence=0, and_end_task=True):
         if if_sequence != current_task()['sequence']:
             return
index 485c757e7fce34dda579185608f39bfe4911bd94..c72b82be1cef2ae9778a5e99217e304f7c59f828 100644 (file)
@@ -8,9 +8,7 @@ from . import config
 import re
 
 def escape(path):
-    path = re.sub('\\\\', lambda m: '\\134', path)
-    path = re.sub('[:\000-\040]', lambda m: "\\%03o" % ord(m.group(0)), path)
-    return path
+    return re.sub(r'[\\:\000-\040]', lambda m: "\\%03o" % ord(m.group(0)), path)
 
 def normalize_stream(stream_name, stream):
     """Take manifest stream and return a list of tokens in normalized format.
diff --git a/sdk/python/arvados/_pycurlhelper.py b/sdk/python/arvados/_pycurlhelper.py
new file mode 100644 (file)
index 0000000..749548a
--- /dev/null
@@ -0,0 +1,86 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import collections
+import socket
+import pycurl
+import math
+
+class PyCurlHelper:
+    # Default Keep server connection timeout:  2 seconds
+    # Default Keep server read timeout:       256 seconds
+    # Default Keep server bandwidth minimum:  32768 bytes per second
+    # Default Keep proxy connection timeout:  20 seconds
+    # Default Keep proxy read timeout:        256 seconds
+    # Default Keep proxy bandwidth minimum:   32768 bytes per second
+    DEFAULT_TIMEOUT = (2, 256, 32768)
+    DEFAULT_PROXY_TIMEOUT = (20, 256, 32768)
+
+    def __init__(self, title_case_headers=False):
+        self._socket = None
+        self.title_case_headers = title_case_headers
+
+    def _socket_open(self, *args, **kwargs):
+        if len(args) + len(kwargs) == 2:
+            return self._socket_open_pycurl_7_21_5(*args, **kwargs)
+        else:
+            return self._socket_open_pycurl_7_19_3(*args, **kwargs)
+
+    def _socket_open_pycurl_7_19_3(self, family, socktype, protocol, address=None):
+        return self._socket_open_pycurl_7_21_5(
+            purpose=None,
+            address=collections.namedtuple(
+                'Address', ['family', 'socktype', 'protocol', 'addr'],
+            )(family, socktype, protocol, address))
+
+    def _socket_open_pycurl_7_21_5(self, purpose, address):
+        """Because pycurl doesn't have CURLOPT_TCP_KEEPALIVE"""
+        s = socket.socket(address.family, address.socktype, address.protocol)
+        s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
+        # Will throw invalid protocol error on mac. This test prevents that.
+        if hasattr(socket, 'TCP_KEEPIDLE'):
+            s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 75)
+        s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 75)
+        self._socket = s
+        return s
+
+    def _setcurltimeouts(self, curl, timeouts, ignore_bandwidth=False):
+        if not timeouts:
+            return
+        elif isinstance(timeouts, tuple):
+            if len(timeouts) == 2:
+                conn_t, xfer_t = timeouts
+                bandwidth_bps = self.DEFAULT_TIMEOUT[2]
+            else:
+                conn_t, xfer_t, bandwidth_bps = timeouts
+        else:
+            conn_t, xfer_t = (timeouts, timeouts)
+            bandwidth_bps = self.DEFAULT_TIMEOUT[2]
+        curl.setopt(pycurl.CONNECTTIMEOUT_MS, int(conn_t*1000))
+        if not ignore_bandwidth:
+            curl.setopt(pycurl.LOW_SPEED_TIME, int(math.ceil(xfer_t)))
+            curl.setopt(pycurl.LOW_SPEED_LIMIT, int(math.ceil(bandwidth_bps)))
+
+    def _headerfunction(self, header_line):
+        if isinstance(header_line, bytes):
+            header_line = header_line.decode('iso-8859-1')
+        if ':' in header_line:
+            name, value = header_line.split(':', 1)
+            if self.title_case_headers:
+                name = name.strip().title()
+            else:
+                name = name.strip().lower()
+            value = value.strip()
+        elif self._headers:
+            name = self._lastheadername
+            value = self._headers[name] + ' ' + header_line.strip()
+        elif header_line.startswith('HTTP/'):
+            name = 'x-status-line'
+            value = header_line
+        else:
+            _logger.error("Unexpected header line: %s", header_line)
+            return
+        self._lastheadername = name
+        self._headers[name] = value
+        # Returning None implies all bytes were written
index 19154f3e8b368f5b0dbeb631e4290ac7f32d101f..8a17e42fcb3af881e517d8d580e3b5bdb4c25e41 100644 (file)
@@ -9,60 +9,74 @@ niceties such as caching, X-Request-Id header for tracking, and more. The main
 client constructors are `api` and `api_from_config`.
 """
 
-from __future__ import absolute_import
-from future import standard_library
-standard_library.install_aliases()
-from builtins import range
 import collections
-import http.client
 import httplib2
 import json
 import logging
 import os
+import pathlib
 import re
 import socket
 import ssl
 import sys
+import threading
 import time
 import types
 
+from typing import (
+    Any,
+    Dict,
+    List,
+    Mapping,
+    Optional,
+)
+
 import apiclient
+import apiclient.http
 from apiclient import discovery as apiclient_discovery
 from apiclient import errors as apiclient_errors
 from . import config
 from . import errors
+from . import retry
 from . import util
 from . import cache
+from .logging import GoogleHTTPClientFilter, log_handler
 
 _logger = logging.getLogger('arvados.api')
+_googleapiclient_log_lock = threading.Lock()
 
 MAX_IDLE_CONNECTION_DURATION = 30
-RETRY_DELAY_INITIAL = 2
-RETRY_DELAY_BACKOFF = 2
-RETRY_COUNT = 2
+"""
+Number of seconds that API client HTTP connections should be allowed to idle
+in keepalive state before they are forced closed. Client code can adjust this
+constant, and it will be used for all Arvados API clients constructed after
+that point.
+"""
+
+# An unused HTTP 5xx status code to request a retry internally.
+# See _intercept_http_request. This should not be user-visible.
+_RETRY_4XX_STATUS = 545
 
 if sys.version_info >= (3,):
     httplib2.SSLHandshakeError = None
 
-class OrderedJsonModel(apiclient.model.JsonModel):
-    """Model class for JSON that preserves the contents' order.
-
-    API clients that care about preserving the order of fields in API
-    server responses can use this model to do so, like this:
-
-        from arvados.api import OrderedJsonModel
-        client = arvados.api('v1', ..., model=OrderedJsonModel())
-    """
-
-    def deserialize(self, content):
-        # This is a very slightly modified version of the parent class'
-        # implementation.  Copyright (c) 2010 Google.
-        content = content.decode('utf-8')
-        body = json.loads(content, object_pairs_hook=collections.OrderedDict)
-        if self._data_wrapper and isinstance(body, dict) and 'data' in body:
-            body = body['data']
-        return body
-
+_orig_retry_request = apiclient.http._retry_request
+def _retry_request(http, num_retries, *args, **kwargs):
+    try:
+        num_retries = max(num_retries, http.num_retries)
+    except AttributeError:
+        # `http` client object does not have a `num_retries` attribute.
+        # It apparently hasn't gone through _patch_http_request, possibly
+        # because this isn't an Arvados API client. Pass through to
+        # avoid interfering with other Google API clients.
+        return _orig_retry_request(http, num_retries, *args, **kwargs)
+    response, body = _orig_retry_request(http, num_retries, *args, **kwargs)
+    # If _intercept_http_request ran out of retries for a 4xx response,
+    # restore the original status code.
+    if response.status == _RETRY_4XX_STATUS:
+        response.status = int(response['status'])
+    return (response, body)
+apiclient.http._retry_request = _retry_request
 
 def _intercept_http_request(self, uri, method="GET", headers={}, **kwargs):
     if not headers.get('X-Request-Id'):
@@ -75,12 +89,7 @@ def _intercept_http_request(self, uri, method="GET", headers={}, **kwargs):
 
         headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
 
-        retryable = method in [
-            'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
-        retry_count = self._retry_count if retryable else 0
-
-        if (not retryable and
-            time.time() - self._last_request_time > self._max_keepalive_idle):
+        if (time.time() - self._last_request_time) > self._max_keepalive_idle:
             # High probability of failure due to connection atrophy. Make
             # sure this request [re]opens a new connection by closing and
             # forgetting all cached connections first.
@@ -88,32 +97,17 @@ def _intercept_http_request(self, uri, method="GET", headers={}, **kwargs):
                 conn.close()
             self.connections.clear()
 
-        delay = self._retry_delay_initial
-        for _ in range(retry_count):
-            self._last_request_time = time.time()
-            try:
-                return self.orig_http_request(uri, method, headers=headers, **kwargs)
-            except http.client.HTTPException:
-                _logger.debug("[%s] Retrying API request in %d s after HTTP error",
-                              headers['X-Request-Id'], delay, exc_info=True)
-            except ssl.SSLCertVerificationError as e:
-                raise ssl.SSLCertVerificationError(e.args[0], "Could not connect to %s\n%s\nPossible causes: remote SSL/TLS certificate expired, or was issued by an untrusted certificate authority." % (uri, e)) from None
-            except socket.error:
-                # This is the one case where httplib2 doesn't close the
-                # underlying connection first.  Close all open
-                # connections, expecting this object only has the one
-                # connection to the API server.  This is safe because
-                # httplib2 reopens connections when needed.
-                _logger.debug("[%s] Retrying API request in %d s after socket error",
-                              headers['X-Request-Id'], delay, exc_info=True)
-                for conn in self.connections.values():
-                    conn.close()
-
-            time.sleep(delay)
-            delay = delay * self._retry_delay_backoff
-
         self._last_request_time = time.time()
-        return self.orig_http_request(uri, method, headers=headers, **kwargs)
+        try:
+            response, body = self.orig_http_request(uri, method, headers=headers, **kwargs)
+        except ssl.SSLCertVerificationError as e:
+            raise ssl.SSLCertVerificationError(e.args[0], "Could not connect to %s\n%s\nPossible causes: remote SSL/TLS certificate expired, or was issued by an untrusted certificate authority." % (uri, e)) from None
+        # googleapiclient only retries 403, 429, and 5xx status codes.
+        # If we got another 4xx status that we want to retry, convert it into
+        # 5xx so googleapiclient handles it the way we want.
+        if response.status in retry._HTTP_CAN_RETRY and response.status < 500:
+            response.status = _RETRY_4XX_STATUS
+        return (response, body)
     except Exception as e:
         # Prepend "[request_id] " to the error message, which we
         # assume is the first string argument passed to the exception
@@ -124,16 +118,14 @@ def _intercept_http_request(self, uri, method="GET", headers={}, **kwargs):
                 raise type(e)(*e.args)
         raise
 
-def _patch_http_request(http, api_token):
+def _patch_http_request(http, api_token, num_retries):
     http.arvados_api_token = api_token
     http.max_request_size = 0
+    http.num_retries = num_retries
     http.orig_http_request = http.request
     http.request = types.MethodType(_intercept_http_request, http)
     http._last_request_time = 0
     http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
-    http._retry_delay_initial = RETRY_DELAY_INITIAL
-    http._retry_delay_backoff = RETRY_DELAY_BACKOFF
-    http._retry_count = RETRY_COUNT
     http._request_id = util.new_request_id
     return http
 
@@ -163,29 +155,43 @@ def _new_http_error(cls, *args, **kwargs):
         errors.ApiError, *args, **kwargs)
 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
 
-def http_cache(data_type):
-    homedir = os.environ.get('HOME')
-    if not homedir or len(homedir) == 0:
+def http_cache(data_type: str) -> cache.SafeHTTPCache:
+    """Set up an HTTP file cache
+
+    This function constructs and returns an `arvados.cache.SafeHTTPCache`
+    backed by the filesystem under `~/.cache/arvados/`, or `None` if the
+    directory cannot be set up. The return value can be passed to
+    `httplib2.Http` as the `cache` argument.
+
+    Arguments:
+
+    * data_type: str --- The name of the subdirectory under `~/.cache/arvados`
+      where data is cached.
+    """
+    try:
+        homedir = pathlib.Path.home()
+    except RuntimeError:
         return None
-    path = homedir + '/.cache/arvados/' + data_type
+    path = pathlib.Path(homedir, '.cache', 'arvados', data_type)
     try:
-        util.mkdir_dash_p(path)
+        path.mkdir(parents=True, exist_ok=True)
     except OSError:
         return None
-    return cache.SafeHTTPCache(path, max_age=60*60*24*2)
+    return cache.SafeHTTPCache(str(path), max_age=60*60*24*2)
 
 def api_client(
-        version,
-        discoveryServiceUrl,
-        token,
+        version: str,
+        discoveryServiceUrl: str,
+        token: str,
         *,
-        cache=True,
-        http=None,
-        insecure=False,
-        request_id=None,
-        timeout=5*60,
-        **kwargs,
-):
+        cache: bool=True,
+        http: Optional[httplib2.Http]=None,
+        insecure: bool=False,
+        num_retries: int=10,
+        request_id: Optional[str]=None,
+        timeout: int=5*60,
+        **kwargs: Any,
+) -> apiclient_discovery.Resource:
     """Build an Arvados API client
 
     This function returns a `googleapiclient.discovery.Resource` object
@@ -195,38 +201,37 @@ def api_client(
 
     Arguments:
 
-    version: str
-    : A string naming the version of the Arvados API to use.
+    * version: str --- A string naming the version of the Arvados API to use.
 
-    discoveryServiceUrl: str
-    : The URL used to discover APIs passed directly to
-      `googleapiclient.discovery.build`.
+    * discoveryServiceUrl: str --- The URL used to discover APIs passed
+      directly to `googleapiclient.discovery.build`.
 
-    token: str
-    : The authentication token to send with each API call.
+    * token: str --- The authentication token to send with each API call.
 
     Keyword-only arguments:
 
-    cache: bool
-    : If true, loads the API discovery document from, or saves it to, a cache
-      on disk (located at `~/.cache/arvados/discovery`).
+    * cache: bool --- If true, loads the API discovery document from, or
+      saves it to, a cache on disk (located at
+      `~/.cache/arvados/discovery`).
+
+    * http: httplib2.Http | None --- The HTTP client object the API client
+      object will use to make requests.  If not provided, this function will
+      build its own to use. Either way, the object will be patched as part
+      of the build process.
 
-    http: httplib2.Http | None
-    : The HTTP client object the API client object will use to make requests.
-      If not provided, this function will build its own to use. Either way, the
-      object will be patched as part of the build process.
+    * insecure: bool --- If true, ignore SSL certificate validation
+      errors. Default `False`.
 
-    insecure: bool
-    : If true, ignore SSL certificate validation errors. Default `False`.
+    * num_retries: int --- The number of times to retry each API request if
+      it encounters a temporary failure. Default 10.
 
-    request_id: str | None
-    : Default `X-Request-Id` header value for outgoing requests that
-      don't already provide one. If `None` or omitted, generate a random
-      ID. When retrying failed requests, the same ID is used on all
-      attempts.
+    * request_id: str | None --- Default `X-Request-Id` header value for
+      outgoing requests that don't already provide one. If `None` or
+      omitted, generate a random ID. When retrying failed requests, the same
+      ID is used on all attempts.
 
-    timeout: int
-    : A timeout value for HTTP requests in seconds. Default 300 (5 minutes).
+    * timeout: int --- A timeout value for HTTP requests in seconds. Default
+      300 (5 minutes).
 
     Additional keyword arguments will be passed directly to
     `googleapiclient.discovery.build`.
@@ -239,15 +244,46 @@ def api_client(
         )
     if http.timeout is None:
         http.timeout = timeout
-    http = _patch_http_request(http, token)
-
-    svc = apiclient_discovery.build(
-        'arvados', version,
-        cache_discovery=False,
-        discoveryServiceUrl=discoveryServiceUrl,
-        http=http,
-        **kwargs,
+    http = _patch_http_request(http, token, num_retries)
+
+    # The first time a client is instantiated, temporarily route
+    # googleapiclient.http retry logs if they're not already. These are
+    # important because temporary problems fetching the discovery document
+    # can cause clients to appear to hang early. This can be removed after
+    # we have a more general story for handling googleapiclient logs (#20521).
+    client_logger = logging.getLogger('googleapiclient.http')
+    # "first time a client is instantiated" = thread that acquires this lock
+    # It is never released.
+    # googleapiclient sets up its own NullHandler so we detect if logging is
+    # configured by looking for a real handler anywhere in the hierarchy.
+    client_logger_unconfigured = _googleapiclient_log_lock.acquire(blocking=False) and all(
+        isinstance(handler, logging.NullHandler)
+        for logger_name in ['', 'googleapiclient', 'googleapiclient.http']
+        for handler in logging.getLogger(logger_name).handlers
     )
+    if client_logger_unconfigured:
+        client_level = client_logger.level
+        client_filter = GoogleHTTPClientFilter()
+        client_logger.addFilter(client_filter)
+        client_logger.addHandler(log_handler)
+        if logging.NOTSET < client_level < client_filter.retry_levelno:
+            client_logger.setLevel(client_level)
+        else:
+            client_logger.setLevel(client_filter.retry_levelno)
+    try:
+        svc = apiclient_discovery.build(
+            'arvados', version,
+            cache_discovery=False,
+            discoveryServiceUrl=discoveryServiceUrl,
+            http=http,
+            num_retries=num_retries,
+            **kwargs,
+        )
+    finally:
+        if client_logger_unconfigured:
+            client_logger.removeHandler(log_handler)
+            client_logger.removeFilter(client_filter)
+            client_logger.setLevel(client_level)
     svc.api_token = token
     svc.insecure = insecure
     svc.request_id = request_id
@@ -260,12 +296,12 @@ def api_client(
     return svc
 
 def normalize_api_kwargs(
-        version=None,
-        discoveryServiceUrl=None,
-        host=None,
-        token=None,
-        **kwargs,
-):
+        version: Optional[str]=None,
+        discoveryServiceUrl: Optional[str]=None,
+        host: Optional[str]=None,
+        token: Optional[str]=None,
+        **kwargs: Any,
+) -> Dict[str, Any]:
     """Validate kwargs from `api` and build kwargs for `api_client`
 
     This method takes high-level keyword arguments passed to the `api`
@@ -275,22 +311,19 @@ def normalize_api_kwargs(
 
     Arguments:
 
-    version: str | None
-    : A string naming the version of the Arvados API to use. If not specified,
-      the code will log a warning and fall back to 'v1'.
+    * version: str | None --- A string naming the version of the Arvados API
+      to use. If not specified, the code will log a warning and fall back to
+      'v1'.
 
-    discoveryServiceUrl: str | None
-    : The URL used to discover APIs passed directly to
-      `googleapiclient.discovery.build`. It is an error to pass both
-      `discoveryServiceUrl` and `host`.
+    * discoveryServiceUrl: str | None --- The URL used to discover APIs
+      passed directly to `googleapiclient.discovery.build`. It is an error
+      to pass both `discoveryServiceUrl` and `host`.
 
-    host: str | None
-    : The hostname and optional port number of the Arvados API server. Used to
-      build `discoveryServiceUrl`. It is an error to pass both
-      `discoveryServiceUrl` and `host`.
+    * host: str | None --- The hostname and optional port number of the
+      Arvados API server. Used to build `discoveryServiceUrl`. It is an
+      error to pass both `discoveryServiceUrl` and `host`.
 
-    token: str
-    : The authentication token to send with each API call.
+    * token: str --- The authentication token to send with each API call.
 
     Additional keyword arguments will be included in the return value.
     """
@@ -321,7 +354,11 @@ def normalize_api_kwargs(
         **kwargs,
     }
 
-def api_kwargs_from_config(version=None, apiconfig=None, **kwargs):
+def api_kwargs_from_config(
+        version: Optional[str]=None,
+        apiconfig: Optional[Mapping[str, str]]=None,
+        **kwargs: Any
+) -> Dict[str, Any]:
     """Build `api_client` keyword arguments from configuration
 
     This function accepts a mapping with Arvados configuration settings like
@@ -331,14 +368,15 @@ def api_kwargs_from_config(version=None, apiconfig=None, **kwargs):
 
     Arguments:
 
-    version: str | None
-    : A string naming the version of the Arvados API to use. If not specified,
-      the code will log a warning and fall back to 'v1'.
+    * version: str | None --- A string naming the version of the Arvados API
+      to use. If not specified, the code will log a warning and fall back to
+      'v1'.
 
-    apiconfig: Mapping[str, str] | None
-    : A mapping with entries for `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and
-      optionally `ARVADOS_API_HOST_INSECURE`. If not provided, calls
-      `arvados.config.settings` to get these parameters from user configuration.
+    * apiconfig: Mapping[str, str] | None --- A mapping with entries for
+      `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally
+      `ARVADOS_API_HOST_INSECURE`. If not provided, calls
+      `arvados.config.settings` to get these parameters from user
+      configuration.
 
     Additional keyword arguments will be included in the return value.
     """
@@ -363,9 +401,18 @@ def api_kwargs_from_config(version=None, apiconfig=None, **kwargs):
         **kwargs,
     )
 
-def api(version=None, cache=True, host=None, token=None, insecure=False,
-        request_id=None, timeout=5*60, *,
-        discoveryServiceUrl=None, **kwargs):
+def api(
+        version: Optional[str]=None,
+        cache: bool=True,
+        host: Optional[str]=None,
+        token: Optional[str]=None,
+        insecure: bool=False,
+        request_id: Optional[str]=None,
+        timeout: int=5*60,
+        *,
+        discoveryServiceUrl: Optional[str]=None,
+        **kwargs: Any,
+) -> 'arvados.safeapi.ThreadSafeApiCache':
     """Dynamically build an Arvados API client
 
     This function provides a high-level "do what I mean" interface to build an
@@ -381,19 +428,18 @@ def api(version=None, cache=True, host=None, token=None, insecure=False,
 
     Arguments:
 
-    version: str | None
-    : A string naming the version of the Arvados API to use. If not specified,
-      the code will log a warning and fall back to 'v1'.
+    * version: str | None --- A string naming the version of the Arvados API
+      to use. If not specified, the code will log a warning and fall back to
+      'v1'.
 
-    host: str | None
-    : The hostname and optional port number of the Arvados API server.
+    * host: str | None --- The hostname and optional port number of the
+      Arvados API server.
 
-    token: str | None
-    : The authentication token to send with each API call.
+    * token: str | None --- The authentication token to send with each API
+      call.
 
-    discoveryServiceUrl: str | None
-    : The URL used to discover APIs passed directly to
-      `googleapiclient.discovery.build`.
+    * discoveryServiceUrl: str | None --- The URL used to discover APIs
+      passed directly to `googleapiclient.discovery.build`.
 
     If `host`, `token`, and `discoveryServiceUrl` are all omitted, `host` and
     `token` will be loaded from the user's configuration. Otherwise, you must
@@ -418,7 +464,11 @@ def api(version=None, cache=True, host=None, token=None, insecure=False,
     from .safeapi import ThreadSafeApiCache
     return ThreadSafeApiCache({}, {}, kwargs, version)
 
-def api_from_config(version=None, apiconfig=None, **kwargs):
+def api_from_config(
+        version: Optional[str]=None,
+        apiconfig: Optional[Mapping[str, str]]=None,
+        **kwargs: Any
+) -> 'arvados.safeapi.ThreadSafeApiCache':
     """Build an Arvados API client from a configuration mapping
 
     This function builds an Arvados API client from a mapping with user
@@ -432,16 +482,63 @@ def api_from_config(version=None, apiconfig=None, **kwargs):
 
     Arguments:
 
-    version: str | None
-    : A string naming the version of the Arvados API to use. If not specified,
-      the code will log a warning and fall back to 'v1'.
+    * version: str | None --- A string naming the version of the Arvados API
+      to use. If not specified, the code will log a warning and fall back to
+      'v1'.
 
-    apiconfig: Mapping[str, str] | None
-    : A mapping with entries for `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and
-      optionally `ARVADOS_API_HOST_INSECURE`. If not provided, calls
-      `arvados.config.settings` to get these parameters from user configuration.
+    * apiconfig: Mapping[str, str] | None --- A mapping with entries for
+      `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally
+      `ARVADOS_API_HOST_INSECURE`. If not provided, calls
+      `arvados.config.settings` to get these parameters from user
+      configuration.
 
     Other arguments are passed directly to `api_client`. See that function's
     docstring for more information about their meaning.
     """
     return api(**api_kwargs_from_config(version, apiconfig, **kwargs))
+
+class OrderedJsonModel(apiclient.model.JsonModel):
+    """Model class for JSON that preserves the contents' order
+
+    .. WARNING:: Deprecated
+       This model is redundant now that Python dictionaries preserve insertion
+       ordering. Code that passes this model to API constructors can remove it.
+
+    In Python versions before 3.6, API clients that cared about preserving the
+    order of fields in API server responses could use this model to do so.
+    Typical usage looked like:
+
+        from arvados.api import OrderedJsonModel
+        client = arvados.api('v1', ..., model=OrderedJsonModel())
+    """
+    @util._deprecated(preferred="the default model and rely on Python's built-in dictionary ordering")
+    def __init__(self, data_wrapper=False):
+        return super().__init__(data_wrapper)
+
+
+RETRY_DELAY_INITIAL = 0
+"""
+.. WARNING:: Deprecated
+   This constant was used by retry code in previous versions of the Arvados SDK.
+   Changing the value has no effect anymore.
+   Prefer passing `num_retries` to an API client constructor instead.
+   Refer to the constructor docstrings for details.
+"""
+
+RETRY_DELAY_BACKOFF = 0
+"""
+.. WARNING:: Deprecated
+   This constant was used by retry code in previous versions of the Arvados SDK.
+   Changing the value has no effect anymore.
+   Prefer passing `num_retries` to an API client constructor instead.
+   Refer to the constructor docstrings for details.
+"""
+
+RETRY_COUNT = 0
+"""
+.. WARNING:: Deprecated
+   This constant was used by retry code in previous versions of the Arvados SDK.
+   Changing the value has no effect anymore.
+   Prefer passing `num_retries` to an API client constructor instead.
+   Refer to the constructor docstrings for details.
+"""
index 2ce0e46b30bd67ad948f832183ab091865c2ea53..e0e972b5c178422f84b1f2a8614adab1f6321bc9 100644 (file)
@@ -100,7 +100,7 @@ class ArvadosFileReaderBase(_FileLikeObjectBase):
             yield data
 
     def decompressed_name(self):
-        return re.sub('\.(bz2|gz)$', '', self.name)
+        return re.sub(r'\.(bz2|gz)$', '', self.name)
 
     @_FileLikeObjectBase._before_close
     def seek(self, pos, whence=os.SEEK_SET):
@@ -479,20 +479,20 @@ class _BlockManager(object):
     """
 
     DEFAULT_PUT_THREADS = 2
-    DEFAULT_GET_THREADS = 2
 
-    def __init__(self, keep, copies=None, put_threads=None, num_retries=None, storage_classes_func=None, get_threads=None):
+    def __init__(self, keep,
+                 copies=None,
+                 put_threads=None,
+                 num_retries=None,
+                 storage_classes_func=None):
         """keep: KeepClient object to use"""
         self._keep = keep
         self._bufferblocks = collections.OrderedDict()
         self._put_queue = None
         self._put_threads = None
-        self._prefetch_queue = None
-        self._prefetch_threads = None
         self.lock = threading.Lock()
-        self.prefetch_enabled = True
+        self.prefetch_lookahead = self._keep.num_prefetch_threads
         self.num_put_threads = put_threads or _BlockManager.DEFAULT_PUT_THREADS
-        self.num_get_threads = get_threads or _BlockManager.DEFAULT_GET_THREADS
         self.copies = copies
         self.storage_classes = storage_classes_func or (lambda: [])
         self._pending_write_size = 0
@@ -586,29 +586,6 @@ class _BlockManager(object):
                     thread.daemon = True
                     thread.start()
 
-    def _block_prefetch_worker(self):
-        """The background downloader thread."""
-        while True:
-            try:
-                b = self._prefetch_queue.get()
-                if b is None:
-                    return
-                self._keep.get(b, prefetch=True)
-            except Exception:
-                _logger.exception("Exception doing block prefetch")
-
-    @synchronized
-    def start_get_threads(self):
-        if self._prefetch_threads is None:
-            self._prefetch_queue = queue.Queue()
-            self._prefetch_threads = []
-            for i in range(0, self.num_get_threads):
-                thread = threading.Thread(target=self._block_prefetch_worker)
-                self._prefetch_threads.append(thread)
-                thread.daemon = True
-                thread.start()
-
-
     @synchronized
     def stop_threads(self):
         """Shut down and wait for background upload and download threads to finish."""
@@ -621,14 +598,6 @@ class _BlockManager(object):
         self._put_threads = None
         self._put_queue = None
 
-        if self._prefetch_threads is not None:
-            for t in self._prefetch_threads:
-                self._prefetch_queue.put(None)
-            for t in self._prefetch_threads:
-                t.join()
-        self._prefetch_threads = None
-        self._prefetch_queue = None
-
     def __enter__(self):
         return self
 
@@ -828,25 +797,20 @@ class _BlockManager(object):
                         owner.flush(sync=True)
                     self.delete_bufferblock(k)
 
+        self.stop_threads()
+
     def block_prefetch(self, locator):
         """Initiate a background download of a block.
-
-        This assumes that the underlying KeepClient implements a block cache,
-        so repeated requests for the same block will not result in repeated
-        downloads (unless the block is evicted from the cache.)  This method
-        does not block.
-
         """
 
-        if not self.prefetch_enabled:
+        if not self.prefetch_lookahead:
             return
 
         with self.lock:
             if locator in self._bufferblocks:
                 return
 
-        self.start_get_threads()
-        self._prefetch_queue.put(locator)
+        self._keep.block_prefetch(locator)
 
 
 class ArvadosFile(object):
@@ -861,7 +825,7 @@ class ArvadosFile(object):
     """
 
     __slots__ = ('parent', 'name', '_writers', '_committed',
-                 '_segments', 'lock', '_current_bblock', 'fuse_entry')
+                 '_segments', 'lock', '_current_bblock', 'fuse_entry', '_read_counter')
 
     def __init__(self, parent, name, stream=[], segments=[]):
         """
@@ -882,6 +846,7 @@ class ArvadosFile(object):
         for s in segments:
             self._add_segment(stream, s.locator, s.range_size)
         self._current_bblock = None
+        self._read_counter = 0
 
     def writable(self):
         return self.parent.writable()
@@ -1096,7 +1061,25 @@ class ArvadosFile(object):
             if size == 0 or offset >= self.size():
                 return b''
             readsegs = locators_and_ranges(self._segments, offset, size)
-            prefetch = locators_and_ranges(self._segments, offset + size, config.KEEP_BLOCK_SIZE * self.parent._my_block_manager().num_get_threads, limit=32)
+
+            prefetch = None
+            prefetch_lookahead = self.parent._my_block_manager().prefetch_lookahead
+            if prefetch_lookahead:
+                # Doing prefetch on every read() call is surprisingly expensive
+                # when we're trying to deliver data at 600+ MiBps and want
+                # the read() fast path to be as lightweight as possible.
+                #
+                # Only prefetching every 128 read operations
+                # dramatically reduces the overhead while still
+                # getting the benefit of prefetching (e.g. when
+                # reading 128 KiB at a time, it checks for prefetch
+                # every 16 MiB).
+                self._read_counter = (self._read_counter+1) % 128
+                if self._read_counter == 1:
+                    prefetch = locators_and_ranges(self._segments,
+                                                   offset + size,
+                                                   config.KEEP_BLOCK_SIZE * prefetch_lookahead,
+                                                   limit=(1+prefetch_lookahead))
 
         locs = set()
         data = []
@@ -1104,17 +1087,21 @@ class ArvadosFile(object):
             block = self.parent._my_block_manager().get_block_contents(lr.locator, num_retries=num_retries, cache_only=(bool(data) and not exact))
             if block:
                 blockview = memoryview(block)
-                data.append(blockview[lr.segment_offset:lr.segment_offset+lr.segment_size].tobytes())
+                data.append(blockview[lr.segment_offset:lr.segment_offset+lr.segment_size])
                 locs.add(lr.locator)
             else:
                 break
 
-        for lr in prefetch:
-            if lr.locator not in locs:
-                self.parent._my_block_manager().block_prefetch(lr.locator)
-                locs.add(lr.locator)
+        if prefetch:
+            for lr in prefetch:
+                if lr.locator not in locs:
+                    self.parent._my_block_manager().block_prefetch(lr.locator)
+                    locs.add(lr.locator)
 
-        return b''.join(data)
+        if len(data) == 1:
+            return data[0]
+        else:
+            return b''.join(data)
 
     @must_be_writable
     @synchronized
index ebca15c54bad35fbb0eeb04583ec05a321b4e8a0..9e6bd06071b5653a560da72a0f666217d7c0102c 100644 (file)
@@ -1,6 +1,16 @@
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+"""Tools to work with Arvados collections
+
+This module provides high-level interfaces to create, read, and update
+Arvados collections. Most users will want to instantiate `Collection`
+objects, and use methods like `Collection.open` and `Collection.mkdirs` to
+read and write data in the collection. Refer to the Arvados Python SDK
+cookbook for [an introduction to using the Collection class][cookbook].
+
+[cookbook]: https://doc.arvados.org/sdk/python/cookbook.html#working-with-collections
+"""
 
 from __future__ import absolute_import
 from future.utils import listitems, listvalues, viewkeys
@@ -35,30 +45,65 @@ import arvados.util
 import arvados.events as events
 from arvados.retry import retry_method
 
-_logger = logging.getLogger('arvados.collection')
-
-
-if sys.version_info >= (3, 0):
-    TextIOWrapper = io.TextIOWrapper
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    IO,
+    Iterator,
+    List,
+    Mapping,
+    Optional,
+    Tuple,
+    Union,
+)
+
+if sys.version_info < (3, 8):
+    from typing_extensions import Literal
 else:
-    class TextIOWrapper(io.TextIOWrapper):
-        """To maintain backward compatibility, cast str to unicode in
-        write('foo').
+    from typing import Literal
 
-        """
-        def write(self, data):
-            if isinstance(data, basestring):
-                data = unicode(data)
-            return super(TextIOWrapper, self).write(data)
+_logger = logging.getLogger('arvados.collection')
 
+ADD = "add"
+"""Argument value for `Collection` methods to represent an added item"""
+DEL = "del"
+"""Argument value for `Collection` methods to represent a removed item"""
+MOD = "mod"
+"""Argument value for `Collection` methods to represent a modified item"""
+TOK = "tok"
+"""Argument value for `Collection` methods to represent an item with token differences"""
+FILE = "file"
+"""`create_type` value for `Collection.find_or_create`"""
+COLLECTION = "collection"
+"""`create_type` value for `Collection.find_or_create`"""
+
+ChangeList = List[Union[
+    Tuple[Literal[ADD, DEL], str, 'Collection'],
+    Tuple[Literal[MOD, TOK], str, 'Collection', 'Collection'],
+]]
+ChangeType = Literal[ADD, DEL, MOD, TOK]
+CollectionItem = Union[ArvadosFile, 'Collection']
+ChangeCallback = Callable[[ChangeType, 'Collection', str, CollectionItem], object]
+CreateType = Literal[COLLECTION, FILE]
+Properties = Dict[str, Any]
+StorageClasses = List[str]
 
 class CollectionBase(object):
-    """Abstract base class for Collection classes."""
+    """Abstract base class for Collection classes
+
+    .. ATTENTION:: Internal
+       This class is meant to be used by other parts of the SDK. User code
+       should instantiate or subclass `Collection` or one of its subclasses
+       directly.
+    """
 
     def __enter__(self):
+        """Enter a context block with this collection instance"""
         return self
 
     def __exit__(self, exc_type, exc_value, traceback):
+        """Exit a context block with this collection instance"""
         pass
 
     def _my_keep(self):
@@ -67,12 +112,13 @@ class CollectionBase(object):
                                            num_retries=self.num_retries)
         return self._keep_client
 
-    def stripped_manifest(self):
-        """Get the manifest with locator hints stripped.
+    def stripped_manifest(self) -> str:
+        """Create a copy of the collection manifest with only size hints
 
-        Return the manifest for the current collection with all
-        non-portable hints (i.e., permission signatures and other
-        hints other than size hints) removed from the locators.
+        This method returns a string with the current collection's manifest
+        text with all non-portable locator hints like permission hints and
+        remote cluster hints removed. The only hints in the returned manifest
+        will be size hints.
         """
         raw = self.manifest_text()
         clean = []
@@ -111,707 +157,379 @@ class _WriterFile(_FileLikeObjectBase):
         self.dest.flush_data()
 
 
-class CollectionWriter(CollectionBase):
-    """Deprecated, use Collection instead."""
+class RichCollectionBase(CollectionBase):
+    """Base class for Collection classes
 
-    def __init__(self, api_client=None, num_retries=0, replication=None):
-        """Instantiate a CollectionWriter.
+    .. ATTENTION:: Internal
+       This class is meant to be used by other parts of the SDK. User code
+       should instantiate or subclass `Collection` or one of its subclasses
+       directly.
+    """
 
-        CollectionWriter lets you build a new Arvados Collection from scratch.
-        Write files to it.  The CollectionWriter will upload data to Keep as
-        appropriate, and provide you with the Collection manifest text when
-        you're finished.
+    def __init__(self, parent=None):
+        self.parent = parent
+        self._committed = False
+        self._has_remote_blocks = False
+        self._callback = None
+        self._items = {}
 
-        Arguments:
-        * api_client: The API client to use to look up Collections.  If not
-          provided, CollectionReader will build one from available Arvados
-          configuration.
-        * num_retries: The default number of times to retry failed
-          service requests.  Default 0.  You may change this value
-          after instantiation, but note those changes may not
-          propagate to related objects like the Keep client.
-        * replication: The number of copies of each block to store.
-          If this argument is None or not supplied, replication is
-          the server-provided default if available, otherwise 2.
-        """
-        self._api_client = api_client
-        self.num_retries = num_retries
-        self.replication = (2 if replication is None else replication)
-        self._keep_client = None
-        self._data_buffer = []
-        self._data_buffer_len = 0
-        self._current_stream_files = []
-        self._current_stream_length = 0
-        self._current_stream_locators = []
-        self._current_stream_name = '.'
-        self._current_file_name = None
-        self._current_file_pos = 0
-        self._finished_streams = []
-        self._close_file = None
-        self._queued_file = None
-        self._queued_dirents = deque()
-        self._queued_trees = deque()
-        self._last_open = None
+    def _my_api(self):
+        raise NotImplementedError()
 
-    def __exit__(self, exc_type, exc_value, traceback):
-        if exc_type is None:
-            self.finish()
+    def _my_keep(self):
+        raise NotImplementedError()
 
-    def do_queued_work(self):
-        # The work queue consists of three pieces:
-        # * _queued_file: The file object we're currently writing to the
-        #   Collection.
-        # * _queued_dirents: Entries under the current directory
-        #   (_queued_trees[0]) that we want to write or recurse through.
-        #   This may contain files from subdirectories if
-        #   max_manifest_depth == 0 for this directory.
-        # * _queued_trees: Directories that should be written as separate
-        #   streams to the Collection.
-        # This function handles the smallest piece of work currently queued
-        # (current file, then current directory, then next directory) until
-        # no work remains.  The _work_THING methods each do a unit of work on
-        # THING.  _queue_THING methods add a THING to the work queue.
-        while True:
-            if self._queued_file:
-                self._work_file()
-            elif self._queued_dirents:
-                self._work_dirents()
-            elif self._queued_trees:
-                self._work_trees()
-            else:
-                break
+    def _my_block_manager(self):
+        raise NotImplementedError()
 
-    def _work_file(self):
-        while True:
-            buf = self._queued_file.read(config.KEEP_BLOCK_SIZE)
-            if not buf:
-                break
-            self.write(buf)
-        self.finish_current_file()
-        if self._close_file:
-            self._queued_file.close()
-        self._close_file = None
-        self._queued_file = None
+    def writable(self) -> bool:
+        """Indicate whether this collection object can be modified
 
-    def _work_dirents(self):
-        path, stream_name, max_manifest_depth = self._queued_trees[0]
-        if stream_name != self.current_stream_name():
-            self.start_new_stream(stream_name)
-        while self._queued_dirents:
-            dirent = self._queued_dirents.popleft()
-            target = os.path.join(path, dirent)
-            if os.path.isdir(target):
-                self._queue_tree(target,
-                                 os.path.join(stream_name, dirent),
-                                 max_manifest_depth - 1)
-            else:
-                self._queue_file(target, dirent)
-                break
-        if not self._queued_dirents:
-            self._queued_trees.popleft()
+        This method returns `False` if this object is a `CollectionReader`,
+        else `True`.
+        """
+        raise NotImplementedError()
 
-    def _work_trees(self):
-        path, stream_name, max_manifest_depth = self._queued_trees[0]
-        d = arvados.util.listdir_recursive(
-            path, max_depth = (None if max_manifest_depth == 0 else 0))
-        if d:
-            self._queue_dirents(stream_name, d)
-        else:
-            self._queued_trees.popleft()
+    def root_collection(self) -> 'Collection':
+        """Get this collection's root collection object
 
-    def _queue_file(self, source, filename=None):
-        assert (self._queued_file is None), "tried to queue more than one file"
-        if not hasattr(source, 'read'):
-            source = open(source, 'rb')
-            self._close_file = True
-        else:
-            self._close_file = False
-        if filename is None:
-            filename = os.path.basename(source.name)
-        self.start_new_file(filename)
-        self._queued_file = source
+        If you open a subcollection with `Collection.find`, calling this method
+        on that subcollection returns the source Collection object.
+        """
+        raise NotImplementedError()
 
-    def _queue_dirents(self, stream_name, dirents):
-        assert (not self._queued_dirents), "tried to queue more than one tree"
-        self._queued_dirents = deque(sorted(dirents))
+    def stream_name(self) -> str:
+        """Get the name of the manifest stream represented by this collection
 
-    def _queue_tree(self, path, stream_name, max_manifest_depth):
-        self._queued_trees.append((path, stream_name, max_manifest_depth))
+        If you open a subcollection with `Collection.find`, calling this method
+        on that subcollection returns the name of the stream you opened.
+        """
+        raise NotImplementedError()
 
-    def write_file(self, source, filename=None):
-        self._queue_file(source, filename)
-        self.do_queued_work()
+    @synchronized
+    def has_remote_blocks(self) -> bool:
+        """Indiciate whether the collection refers to remote data
 
-    def write_directory_tree(self,
-                             path, stream_name='.', max_manifest_depth=-1):
-        self._queue_tree(path, stream_name, max_manifest_depth)
-        self.do_queued_work()
+        Returns `True` if the collection manifest includes any Keep locators
+        with a remote hint (`+R`), else `False`.
+        """
+        if self._has_remote_blocks:
+            return True
+        for item in self:
+            if self[item].has_remote_blocks():
+                return True
+        return False
 
-    def write(self, newdata):
-        if isinstance(newdata, bytes):
-            pass
-        elif isinstance(newdata, str):
-            newdata = newdata.encode()
-        elif hasattr(newdata, '__iter__'):
-            for s in newdata:
-                self.write(s)
-            return
-        self._data_buffer.append(newdata)
-        self._data_buffer_len += len(newdata)
-        self._current_stream_length += len(newdata)
-        while self._data_buffer_len >= config.KEEP_BLOCK_SIZE:
-            self.flush_data()
+    @synchronized
+    def set_has_remote_blocks(self, val: bool) -> None:
+        """Cache whether this collection refers to remote blocks
 
-    def open(self, streampath, filename=None):
-        """open(streampath[, filename]) -> file-like object
+        .. ATTENTION:: Internal
+           This method is only meant to be used by other Collection methods.
 
-        Pass in the path of a file to write to the Collection, either as a
-        single string or as two separate stream name and file name arguments.
-        This method returns a file-like object you can write to add it to the
-        Collection.
+        Set this collection's cached "has remote blocks" flag to the given
+        value.
+        """
+        self._has_remote_blocks = val
+        if self.parent:
+            self.parent.set_has_remote_blocks(val)
 
-        You may only have one file object from the Collection open at a time,
-        so be sure to close the object when you're done.  Using the object in
-        a with statement makes that easy::
+    @must_be_writable
+    @synchronized
+    def find_or_create(
+            self,
+            path: str,
+            create_type: CreateType,
+    ) -> CollectionItem:
+        """Get the item at the given path, creating it if necessary
+
+        If `path` refers to a stream in this collection, returns a
+        corresponding `Subcollection` object. If `path` refers to a file in
+        this collection, returns a corresponding
+        `arvados.arvfile.ArvadosFile` object. If `path` does not exist in
+        this collection, then this method creates a new object and returns
+        it, creating parent streams as needed. The type of object created is
+        determined by the value of `create_type`.
+
+        Arguments:
+
+        * path: str --- The path to find or create within this collection.
 
-          with cwriter.open('./doc/page1.txt') as outfile:
-              outfile.write(page1_data)
-          with cwriter.open('./doc/page2.txt') as outfile:
-              outfile.write(page2_data)
+        * create_type: Literal[COLLECTION, FILE] --- The type of object to
+          create at `path` if one does not exist. Passing `COLLECTION`
+          creates a stream and returns the corresponding
+          `Subcollection`. Passing `FILE` creates a new file and returns the
+          corresponding `arvados.arvfile.ArvadosFile`.
         """
-        if filename is None:
-            streampath, filename = split(streampath)
-        if self._last_open and not self._last_open.closed:
-            raise errors.AssertionError(
-                u"can't open '{}' when '{}' is still open".format(
-                    filename, self._last_open.name))
-        if streampath != self.current_stream_name():
-            self.start_new_stream(streampath)
-        self.set_current_file_name(filename)
-        self._last_open = _WriterFile(self, filename)
-        return self._last_open
+        pathcomponents = path.split("/", 1)
+        if pathcomponents[0]:
+            item = self._items.get(pathcomponents[0])
+            if len(pathcomponents) == 1:
+                if item is None:
+                    # create new file
+                    if create_type == COLLECTION:
+                        item = Subcollection(self, pathcomponents[0])
+                    else:
+                        item = ArvadosFile(self, pathcomponents[0])
+                    self._items[pathcomponents[0]] = item
+                    self.set_committed(False)
+                    self.notify(ADD, self, pathcomponents[0], item)
+                return item
+            else:
+                if item is None:
+                    # create new collection
+                    item = Subcollection(self, pathcomponents[0])
+                    self._items[pathcomponents[0]] = item
+                    self.set_committed(False)
+                    self.notify(ADD, self, pathcomponents[0], item)
+                if isinstance(item, RichCollectionBase):
+                    return item.find_or_create(pathcomponents[1], create_type)
+                else:
+                    raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
+        else:
+            return self
 
-    def flush_data(self):
-        data_buffer = b''.join(self._data_buffer)
-        if data_buffer:
-            self._current_stream_locators.append(
-                self._my_keep().put(
-                    data_buffer[0:config.KEEP_BLOCK_SIZE],
-                    copies=self.replication))
-            self._data_buffer = [data_buffer[config.KEEP_BLOCK_SIZE:]]
-            self._data_buffer_len = len(self._data_buffer[0])
+    @synchronized
+    def find(self, path: str) -> CollectionItem:
+        """Get the item at the given path
 
-    def start_new_file(self, newfilename=None):
-        self.finish_current_file()
-        self.set_current_file_name(newfilename)
+        If `path` refers to a stream in this collection, returns a
+        corresponding `Subcollection` object. If `path` refers to a file in
+        this collection, returns a corresponding
+        `arvados.arvfile.ArvadosFile` object. If `path` does not exist in
+        this collection, then this method raises `NotADirectoryError`.
 
-    def set_current_file_name(self, newfilename):
-        if re.search(r'[\t\n]', newfilename):
-            raise errors.AssertionError(
-                "Manifest filenames cannot contain whitespace: %s" %
-                newfilename)
-        elif re.search(r'\x00', newfilename):
-            raise errors.AssertionError(
-                "Manifest filenames cannot contain NUL characters: %s" %
-                newfilename)
-        self._current_file_name = newfilename
+        Arguments:
 
-    def current_file_name(self):
-        return self._current_file_name
+        * path: str --- The path to find or create within this collection.
+        """
+        if not path:
+            raise errors.ArgumentError("Parameter 'path' is empty.")
 
-    def finish_current_file(self):
-        if self._current_file_name is None:
-            if self._current_file_pos == self._current_stream_length:
-                return
-            raise errors.AssertionError(
-                "Cannot finish an unnamed file " +
-                "(%d bytes at offset %d in '%s' stream)" %
-                (self._current_stream_length - self._current_file_pos,
-                 self._current_file_pos,
-                 self._current_stream_name))
-        self._current_stream_files.append([
-                self._current_file_pos,
-                self._current_stream_length - self._current_file_pos,
-                self._current_file_name])
-        self._current_file_pos = self._current_stream_length
-        self._current_file_name = None
+        pathcomponents = path.split("/", 1)
+        if pathcomponents[0] == '':
+            raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
 
-    def start_new_stream(self, newstreamname='.'):
-        self.finish_current_stream()
-        self.set_current_stream_name(newstreamname)
+        item = self._items.get(pathcomponents[0])
+        if item is None:
+            return None
+        elif len(pathcomponents) == 1:
+            return item
+        else:
+            if isinstance(item, RichCollectionBase):
+                if pathcomponents[1]:
+                    return item.find(pathcomponents[1])
+                else:
+                    return item
+            else:
+                raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
 
-    def set_current_stream_name(self, newstreamname):
-        if re.search(r'[\t\n]', newstreamname):
-            raise errors.AssertionError(
-                "Manifest stream names cannot contain whitespace: '%s'" %
-                (newstreamname))
-        self._current_stream_name = '.' if newstreamname=='' else newstreamname
+    @synchronized
+    def mkdirs(self, path: str) -> 'Subcollection':
+        """Create and return a subcollection at `path`
 
-    def current_stream_name(self):
-        return self._current_stream_name
+        If `path` exists within this collection, raises `FileExistsError`.
+        Otherwise, creates a stream at that path and returns the
+        corresponding `Subcollection`.
+        """
+        if self.find(path) != None:
+            raise IOError(errno.EEXIST, "Directory or file exists", path)
 
-    def finish_current_stream(self):
-        self.finish_current_file()
-        self.flush_data()
-        if not self._current_stream_files:
-            pass
-        elif self._current_stream_name is None:
-            raise errors.AssertionError(
-                "Cannot finish an unnamed stream (%d bytes in %d files)" %
-                (self._current_stream_length, len(self._current_stream_files)))
-        else:
-            if not self._current_stream_locators:
-                self._current_stream_locators.append(config.EMPTY_BLOCK_LOCATOR)
-            self._finished_streams.append([self._current_stream_name,
-                                           self._current_stream_locators,
-                                           self._current_stream_files])
-        self._current_stream_files = []
-        self._current_stream_length = 0
-        self._current_stream_locators = []
-        self._current_stream_name = None
-        self._current_file_pos = 0
-        self._current_file_name = None
+        return self.find_or_create(path, COLLECTION)
 
-    def finish(self):
-        """Store the manifest in Keep and return its locator.
+    def open(
+            self,
+            path: str,
+            mode: str="r",
+            encoding: Optional[str]=None,
+    ) -> IO:
+        """Open a file-like object within the collection
 
-        This is useful for storing manifest fragments (task outputs)
-        temporarily in Keep during a Crunch job.
+        This method returns a file-like object that can read and/or write the
+        file located at `path` within the collection. If you attempt to write
+        a `path` that does not exist, the file is created with `find_or_create`.
+        If the file cannot be opened for any other reason, this method raises
+        `OSError` with an appropriate errno.
 
-        In other cases you should make a collection instead, by
-        sending manifest_text() to the API server's "create
-        collection" endpoint.
-        """
-        return self._my_keep().put(self.manifest_text().encode(),
-                                   copies=self.replication)
+        Arguments:
 
-    def portable_data_hash(self):
-        stripped = self.stripped_manifest().encode()
-        return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped))
+        * path: str --- The path of the file to open within this collection
 
-    def manifest_text(self):
-        self.finish_current_stream()
-        manifest = ''
+        * mode: str --- The mode to open this file. Supports all the same
+          values as `builtins.open`.
 
-        for stream in self._finished_streams:
-            if not re.search(r'^\.(/.*)?$', stream[0]):
-                manifest += './'
-            manifest += stream[0].replace(' ', '\\040')
-            manifest += ' ' + ' '.join(stream[1])
-            manifest += ' ' + ' '.join("%d:%d:%s" % (sfile[0], sfile[1], sfile[2].replace(' ', '\\040')) for sfile in stream[2])
-            manifest += "\n"
+        * encoding: str | None --- The text encoding of the file. Only used
+          when the file is opened in text mode. The default is
+          platform-dependent.
+        """
+        if not re.search(r'^[rwa][bt]?\+?$', mode):
+            raise errors.ArgumentError("Invalid mode {!r}".format(mode))
 
-        return manifest
+        if mode[0] == 'r' and '+' not in mode:
+            fclass = ArvadosFileReader
+            arvfile = self.find(path)
+        elif not self.writable():
+            raise IOError(errno.EROFS, "Collection is read only")
+        else:
+            fclass = ArvadosFileWriter
+            arvfile = self.find_or_create(path, FILE)
 
-    def data_locators(self):
-        ret = []
-        for name, locators, files in self._finished_streams:
-            ret += locators
-        return ret
+        if arvfile is None:
+            raise IOError(errno.ENOENT, "File not found", path)
+        if not isinstance(arvfile, ArvadosFile):
+            raise IOError(errno.EISDIR, "Is a directory", path)
 
-    def save_new(self, name=None):
-        return self._api_client.collections().create(
-            ensure_unique_name=True,
-            body={
-                'name': name,
-                'manifest_text': self.manifest_text(),
-            }).execute(num_retries=self.num_retries)
+        if mode[0] == 'w':
+            arvfile.truncate(0)
 
+        binmode = mode[0] + 'b' + re.sub('[bt]', '', mode[1:])
+        f = fclass(arvfile, mode=binmode, num_retries=self.num_retries)
+        if 'b' not in mode:
+            bufferclass = io.BufferedRandom if f.writable() else io.BufferedReader
+            f = io.TextIOWrapper(bufferclass(WrappableFile(f)), encoding=encoding)
+        return f
 
-class ResumableCollectionWriter(CollectionWriter):
-    """Deprecated, use Collection instead."""
+    def modified(self) -> bool:
+        """Indicate whether this collection has an API server record
 
-    STATE_PROPS = ['_current_stream_files', '_current_stream_length',
-                   '_current_stream_locators', '_current_stream_name',
-                   '_current_file_name', '_current_file_pos', '_close_file',
-                   '_data_buffer', '_dependencies', '_finished_streams',
-                   '_queued_dirents', '_queued_trees']
+        Returns `False` if this collection corresponds to a record loaded from
+        the API server, `True` otherwise.
+        """
+        return not self.committed()
 
-    def __init__(self, api_client=None, **kwargs):
-        self._dependencies = {}
-        super(ResumableCollectionWriter, self).__init__(api_client, **kwargs)
+    @synchronized
+    def committed(self):
+        """Indicate whether this collection has an API server record
 
-    @classmethod
-    def from_state(cls, state, *init_args, **init_kwargs):
-        # Try to build a new writer from scratch with the given state.
-        # If the state is not suitable to resume (because files have changed,
-        # been deleted, aren't predictable, etc.), raise a
-        # StaleWriterStateError.  Otherwise, return the initialized writer.
-        # The caller is responsible for calling writer.do_queued_work()
-        # appropriately after it's returned.
-        writer = cls(*init_args, **init_kwargs)
-        for attr_name in cls.STATE_PROPS:
-            attr_value = state[attr_name]
-            attr_class = getattr(writer, attr_name).__class__
-            # Coerce the value into the same type as the initial value, if
-            # needed.
-            if attr_class not in (type(None), attr_value.__class__):
-                attr_value = attr_class(attr_value)
-            setattr(writer, attr_name, attr_value)
-        # Check dependencies before we try to resume anything.
-        if any(KeepLocator(ls).permission_expired()
-               for ls in writer._current_stream_locators):
-            raise errors.StaleWriterStateError(
-                "locators include expired permission hint")
-        writer.check_dependencies()
-        if state['_current_file'] is not None:
-            path, pos = state['_current_file']
-            try:
-                writer._queued_file = open(path, 'rb')
-                writer._queued_file.seek(pos)
-            except IOError as error:
-                raise errors.StaleWriterStateError(
-                    u"failed to reopen active file {}: {}".format(path, error))
-        return writer
+        Returns `True` if this collection corresponds to a record loaded from
+        the API server, `False` otherwise.
+        """
+        return self._committed
 
-    def check_dependencies(self):
-        for path, orig_stat in listitems(self._dependencies):
-            if not S_ISREG(orig_stat[ST_MODE]):
-                raise errors.StaleWriterStateError(u"{} not file".format(path))
-            try:
-                now_stat = tuple(os.stat(path))
-            except OSError as error:
-                raise errors.StaleWriterStateError(
-                    u"failed to stat {}: {}".format(path, error))
-            if ((not S_ISREG(now_stat[ST_MODE])) or
-                (orig_stat[ST_MTIME] != now_stat[ST_MTIME]) or
-                (orig_stat[ST_SIZE] != now_stat[ST_SIZE])):
-                raise errors.StaleWriterStateError(u"{} changed".format(path))
+    @synchronized
+    def set_committed(self, value: bool=True):
+        """Cache whether this collection has an API server record
 
-    def dump_state(self, copy_func=lambda x: x):
-        state = {attr: copy_func(getattr(self, attr))
-                 for attr in self.STATE_PROPS}
-        if self._queued_file is None:
-            state['_current_file'] = None
-        else:
-            state['_current_file'] = (os.path.realpath(self._queued_file.name),
-                                      self._queued_file.tell())
-        return state
+        .. ATTENTION:: Internal
+           This method is only meant to be used by other Collection methods.
 
-    def _queue_file(self, source, filename=None):
-        try:
-            src_path = os.path.realpath(source)
-        except Exception:
-            raise errors.AssertionError(u"{} not a file path".format(source))
-        try:
-            path_stat = os.stat(src_path)
-        except OSError as stat_error:
-            path_stat = None
-        super(ResumableCollectionWriter, self)._queue_file(source, filename)
-        fd_stat = os.fstat(self._queued_file.fileno())
-        if not S_ISREG(fd_stat.st_mode):
-            # We won't be able to resume from this cache anyway, so don't
-            # worry about further checks.
-            self._dependencies[source] = tuple(fd_stat)
-        elif path_stat is None:
-            raise errors.AssertionError(
-                u"could not stat {}: {}".format(source, stat_error))
-        elif path_stat.st_ino != fd_stat.st_ino:
-            raise errors.AssertionError(
-                u"{} changed between open and stat calls".format(source))
+        Set this collection's cached "committed" flag to the given
+        value and propagates it as needed.
+        """
+        if value == self._committed:
+            return
+        if value:
+            for k,v in listitems(self._items):
+                v.set_committed(True)
+            self._committed = True
         else:
-            self._dependencies[src_path] = tuple(fd_stat)
+            self._committed = False
+            if self.parent is not None:
+                self.parent.set_committed(False)
 
-    def write(self, data):
-        if self._queued_file is None:
-            raise errors.AssertionError(
-                "resumable writer can't accept unsourced data")
-        return super(ResumableCollectionWriter, self).write(data)
+    @synchronized
+    def __iter__(self) -> Iterator[str]:
+        """Iterate names of streams and files in this collection
 
+        This method does not recurse. It only iterates the contents of this
+        collection's corresponding stream.
+        """
+        return iter(viewkeys(self._items))
 
-ADD = "add"
-DEL = "del"
-MOD = "mod"
-TOK = "tok"
-FILE = "file"
-COLLECTION = "collection"
+    @synchronized
+    def __getitem__(self, k: str) -> CollectionItem:
+        """Get a `arvados.arvfile.ArvadosFile` or `Subcollection` in this collection
 
-class RichCollectionBase(CollectionBase):
-    """Base class for Collections and Subcollections.
+        This method does not recurse. If you want to search a path, use
+        `RichCollectionBase.find` instead.
+        """
+        return self._items[k]
 
-    Implements the majority of functionality relating to accessing items in the
-    Collection.
-
-    """
-
-    def __init__(self, parent=None):
-        self.parent = parent
-        self._committed = False
-        self._has_remote_blocks = False
-        self._callback = None
-        self._items = {}
-
-    def _my_api(self):
-        raise NotImplementedError()
-
-    def _my_keep(self):
-        raise NotImplementedError()
-
-    def _my_block_manager(self):
-        raise NotImplementedError()
-
-    def writable(self):
-        raise NotImplementedError()
-
-    def root_collection(self):
-        raise NotImplementedError()
-
-    def notify(self, event, collection, name, item):
-        raise NotImplementedError()
-
-    def stream_name(self):
-        raise NotImplementedError()
+    @synchronized
+    def __contains__(self, k: str) -> bool:
+        """Indicate whether this collection has an item with this name
 
+        This method does not recurse. It you want to check a path, use
+        `RichCollectionBase.exists` instead.
+        """
+        return k in self._items
 
     @synchronized
-    def has_remote_blocks(self):
-        """Recursively check for a +R segment locator signature."""
-
-        if self._has_remote_blocks:
-            return True
-        for item in self:
-            if self[item].has_remote_blocks():
-                return True
-        return False
+    def __len__(self):
+        """Get the number of items directly contained in this collection
 
-    @synchronized
-    def set_has_remote_blocks(self, val):
-        self._has_remote_blocks = val
-        if self.parent:
-            self.parent.set_has_remote_blocks(val)
+        This method does not recurse. It only counts the streams and files
+        in this collection's corresponding stream.
+        """
+        return len(self._items)
 
     @must_be_writable
     @synchronized
-    def find_or_create(self, path, create_type):
-        """Recursively search the specified file path.
-
-        May return either a `Collection` or `ArvadosFile`.  If not found, will
-        create a new item at the specified path based on `create_type`.  Will
-        create intermediate subcollections needed to contain the final item in
-        the path.
-
-        :create_type:
-          One of `arvados.collection.FILE` or
-          `arvados.collection.COLLECTION`.  If the path is not found, and value
-          of create_type is FILE then create and return a new ArvadosFile for
-          the last path component.  If COLLECTION, then create and return a new
-          Collection for the last path component.
+    def __delitem__(self, p: str) -> None:
+        """Delete an item from this collection's stream
 
+        This method does not recurse. If you want to remove an item by a
+        path, use `RichCollectionBase.remove` instead.
         """
-
-        pathcomponents = path.split("/", 1)
-        if pathcomponents[0]:
-            item = self._items.get(pathcomponents[0])
-            if len(pathcomponents) == 1:
-                if item is None:
-                    # create new file
-                    if create_type == COLLECTION:
-                        item = Subcollection(self, pathcomponents[0])
-                    else:
-                        item = ArvadosFile(self, pathcomponents[0])
-                    self._items[pathcomponents[0]] = item
-                    self.set_committed(False)
-                    self.notify(ADD, self, pathcomponents[0], item)
-                return item
-            else:
-                if item is None:
-                    # create new collection
-                    item = Subcollection(self, pathcomponents[0])
-                    self._items[pathcomponents[0]] = item
-                    self.set_committed(False)
-                    self.notify(ADD, self, pathcomponents[0], item)
-                if isinstance(item, RichCollectionBase):
-                    return item.find_or_create(pathcomponents[1], create_type)
-                else:
-                    raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
-        else:
-            return self
+        del self._items[p]
+        self.set_committed(False)
+        self.notify(DEL, self, p, None)
 
     @synchronized
-    def find(self, path):
-        """Recursively search the specified file path.
-
-        May return either a Collection or ArvadosFile. Return None if not
-        found.
-        If path is invalid (ex: starts with '/'), an IOError exception will be
-        raised.
+    def keys(self) -> Iterator[str]:
+        """Iterate names of streams and files in this collection
 
+        This method does not recurse. It only iterates the contents of this
+        collection's corresponding stream.
         """
-        if not path:
-            raise errors.ArgumentError("Parameter 'path' is empty.")
-
-        pathcomponents = path.split("/", 1)
-        if pathcomponents[0] == '':
-            raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
-
-        item = self._items.get(pathcomponents[0])
-        if item is None:
-            return None
-        elif len(pathcomponents) == 1:
-            return item
-        else:
-            if isinstance(item, RichCollectionBase):
-                if pathcomponents[1]:
-                    return item.find(pathcomponents[1])
-                else:
-                    return item
-            else:
-                raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
+        return self._items.keys()
 
     @synchronized
-    def mkdirs(self, path):
-        """Recursive subcollection create.
-
-        Like `os.makedirs()`.  Will create intermediate subcollections needed
-        to contain the leaf subcollection path.
-
-        """
-
-        if self.find(path) != None:
-            raise IOError(errno.EEXIST, "Directory or file exists", path)
-
-        return self.find_or_create(path, COLLECTION)
-
-    def open(self, path, mode="r", encoding=None):
-        """Open a file-like object for access.
-
-        :path:
-          path to a file in the collection
-        :mode:
-          a string consisting of "r", "w", or "a", optionally followed
-          by "b" or "t", optionally followed by "+".
-          :"b":
-            binary mode: write() accepts bytes, read() returns bytes.
-          :"t":
-            text mode (default): write() accepts strings, read() returns strings.
-          :"r":
-            opens for reading
-          :"r+":
-            opens for reading and writing.  Reads/writes share a file pointer.
-          :"w", "w+":
-            truncates to 0 and opens for reading and writing.  Reads/writes share a file pointer.
-          :"a", "a+":
-            opens for reading and writing.  All writes are appended to
-            the end of the file.  Writing does not affect the file pointer for
-            reading.
+    def values(self) -> List[CollectionItem]:
+        """Get a list of objects in this collection's stream
 
+        The return value includes a `Subcollection` for every stream, and an
+        `arvados.arvfile.ArvadosFile` for every file, directly within this
+        collection's stream.  This method does not recurse.
         """
-
-        if not re.search(r'^[rwa][bt]?\+?$', mode):
-            raise errors.ArgumentError("Invalid mode {!r}".format(mode))
-
-        if mode[0] == 'r' and '+' not in mode:
-            fclass = ArvadosFileReader
-            arvfile = self.find(path)
-        elif not self.writable():
-            raise IOError(errno.EROFS, "Collection is read only")
-        else:
-            fclass = ArvadosFileWriter
-            arvfile = self.find_or_create(path, FILE)
-
-        if arvfile is None:
-            raise IOError(errno.ENOENT, "File not found", path)
-        if not isinstance(arvfile, ArvadosFile):
-            raise IOError(errno.EISDIR, "Is a directory", path)
-
-        if mode[0] == 'w':
-            arvfile.truncate(0)
-
-        binmode = mode[0] + 'b' + re.sub('[bt]', '', mode[1:])
-        f = fclass(arvfile, mode=binmode, num_retries=self.num_retries)
-        if 'b' not in mode:
-            bufferclass = io.BufferedRandom if f.writable() else io.BufferedReader
-            f = TextIOWrapper(bufferclass(WrappableFile(f)), encoding=encoding)
-        return f
-
-    def modified(self):
-        """Determine if the collection has been modified since last commited."""
-        return not self.committed()
-
-    @synchronized
-    def committed(self):
-        """Determine if the collection has been committed to the API server."""
-        return self._committed
+        return listvalues(self._items)
 
     @synchronized
-    def set_committed(self, value=True):
-        """Recursively set committed flag.
-
-        If value is True, set committed to be True for this and all children.
+    def items(self) -> List[Tuple[str, CollectionItem]]:
+        """Get a list of `(name, object)` tuples from this collection's stream
 
-        If value is False, set committed to be False for this and all parents.
+        The return value includes a `Subcollection` for every stream, and an
+        `arvados.arvfile.ArvadosFile` for every file, directly within this
+        collection's stream.  This method does not recurse.
         """
-        if value == self._committed:
-            return
-        if value:
-            for k,v in listitems(self._items):
-                v.set_committed(True)
-            self._committed = True
-        else:
-            self._committed = False
-            if self.parent is not None:
-                self.parent.set_committed(False)
+        return listitems(self._items)
 
-    @synchronized
-    def __iter__(self):
-        """Iterate over names of files and collections contained in this collection."""
-        return iter(viewkeys(self._items))
+    def exists(self, path: str) -> bool:
+        """Indicate whether this collection includes an item at `path`
 
-    @synchronized
-    def __getitem__(self, k):
-        """Get a file or collection that is directly contained by this collection.
+        This method returns `True` if `path` refers to a stream or file within
+        this collection, else `False`.
 
-        If you want to search a path, use `find()` instead.
+        Arguments:
 
+        * path: str --- The path to check for existence within this collection
         """
-        return self._items[k]
-
-    @synchronized
-    def __contains__(self, k):
-        """Test if there is a file or collection a directly contained by this collection."""
-        return k in self._items
-
-    @synchronized
-    def __len__(self):
-        """Get the number of items directly contained in this collection."""
-        return len(self._items)
+        return self.find(path) is not None
 
     @must_be_writable
     @synchronized
-    def __delitem__(self, p):
-        """Delete an item by name which is directly contained by this collection."""
-        del self._items[p]
-        self.set_committed(False)
-        self.notify(DEL, self, p, None)
-
-    @synchronized
-    def keys(self):
-        """Get a list of names of files and collections directly contained in this collection."""
-        return self._items.keys()
-
-    @synchronized
-    def values(self):
-        """Get a list of files and collection objects directly contained in this collection."""
-        return listvalues(self._items)
-
-    @synchronized
-    def items(self):
-        """Get a list of (name, object) tuples directly contained in this collection."""
-        return listitems(self._items)
+    def remove(self, path: str, recursive: bool=False) -> None:
+        """Remove the file or stream at `path`
 
-    def exists(self, path):
-        """Test if there is a file or collection at `path`."""
-        return self.find(path) is not None
+        Arguments:
 
-    @must_be_writable
-    @synchronized
-    def remove(self, path, recursive=False):
-        """Remove the file or subcollection (directory) at `path`.
+        * path: str --- The path of the item to remove from the collection
 
-        :recursive:
-          Specify whether to remove non-empty subcollections (True), or raise an error (False).
+        * recursive: bool --- Controls the method's behavior if `path` refers
+          to a nonempty stream. If `False` (the default), this method raises
+          `OSError` with errno `ENOTEMPTY`. If `True`, this method removes all
+          items under the stream.
         """
-
         if not path:
             raise errors.ArgumentError("Parameter 'path' is empty.")
 
@@ -838,26 +556,33 @@ class RichCollectionBase(CollectionBase):
 
     @must_be_writable
     @synchronized
-    def add(self, source_obj, target_name, overwrite=False, reparent=False):
-        """Copy or move a file or subcollection to this collection.
+    def add(
+            self,
+            source_obj: CollectionItem,
+            target_name: str,
+            overwrite: bool=False,
+            reparent: bool=False,
+    ) -> None:
+        """Copy or move a file or subcollection object to this collection
 
-        :source_obj:
-          An ArvadosFile, or Subcollection object
+        Arguments:
 
-        :target_name:
-          Destination item name.  If the target name already exists and is a
-          file, this will raise an error unless you specify `overwrite=True`.
+        * source_obj: arvados.arvfile.ArvadosFile | Subcollection --- The file or subcollection
+          to add to this collection
 
-        :overwrite:
-          Whether to overwrite target file if it already exists.
+        * target_name: str --- The path inside this collection where
+          `source_obj` should be added.
 
-        :reparent:
-          If True, source_obj will be moved from its parent collection to this collection.
-          If False, source_obj will be copied and the parent collection will be
-          unmodified.
+        * overwrite: bool --- Controls the behavior of this method when the
+          collection already contains an object at `target_name`. If `False`
+          (the default), this method will raise `FileExistsError`. If `True`,
+          the object at `target_name` will be replaced with `source_obj`.
 
+        * reparent: bool --- Controls whether this method copies or moves
+          `source_obj`. If `False` (the default), `source_obj` is copied into
+          this collection. If `True`, `source_obj` is moved into this
+          collection.
         """
-
         if target_name in self and not overwrite:
             raise IOError(errno.EEXIST, "File already exists", target_name)
 
@@ -924,92 +649,117 @@ class RichCollectionBase(CollectionBase):
 
     @must_be_writable
     @synchronized
-    def copy(self, source, target_path, source_collection=None, overwrite=False):
-        """Copy a file or subcollection to a new path in this collection.
+    def copy(
+            self,
+            source: Union[str, CollectionItem],
+            target_path: str,
+            source_collection: Optional['RichCollectionBase']=None,
+            overwrite: bool=False,
+    ) -> None:
+        """Copy a file or subcollection object to this collection
+
+        Arguments:
 
-        :source:
-          A string with a path to source file or subcollection, or an actual ArvadosFile or Subcollection object.
+        * source: str | arvados.arvfile.ArvadosFile |
+          arvados.collection.Subcollection --- The file or subcollection to
+          add to this collection. If `source` is a str, the object will be
+          found by looking up this path from `source_collection` (see
+          below).
 
-        :target_path:
-          Destination file or path.  If the target path already exists and is a
-          subcollection, the item will be placed inside the subcollection.  If
-          the target path already exists and is a file, this will raise an error
-          unless you specify `overwrite=True`.
+        * target_path: str --- The path inside this collection where the
+          source object should be added.
 
-        :source_collection:
-          Collection to copy `source_path` from (default `self`)
+        * source_collection: arvados.collection.Collection | None --- The
+          collection to find the source object from when `source` is a
+          path. Defaults to the current collection (`self`).
 
-        :overwrite:
-          Whether to overwrite target file if it already exists.
+        * overwrite: bool --- Controls the behavior of this method when the
+          collection already contains an object at `target_path`. If `False`
+          (the default), this method will raise `FileExistsError`. If `True`,
+          the object at `target_path` will be replaced with `source_obj`.
         """
-
         source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, True)
         target_dir.add(source_obj, target_name, overwrite, False)
 
     @must_be_writable
     @synchronized
-    def rename(self, source, target_path, source_collection=None, overwrite=False):
-        """Move a file or subcollection from `source_collection` to a new path in this collection.
+    def rename(
+            self,
+            source: Union[str, CollectionItem],
+            target_path: str,
+            source_collection: Optional['RichCollectionBase']=None,
+            overwrite: bool=False,
+    ) -> None:
+        """Move a file or subcollection object to this collection
 
-        :source:
-          A string with a path to source file or subcollection.
+        Arguments:
 
-        :target_path:
-          Destination file or path.  If the target path already exists and is a
-          subcollection, the item will be placed inside the subcollection.  If
-          the target path already exists and is a file, this will raise an error
-          unless you specify `overwrite=True`.
+        * source: str | arvados.arvfile.ArvadosFile |
+          arvados.collection.Subcollection --- The file or subcollection to
+          add to this collection. If `source` is a str, the object will be
+          found by looking up this path from `source_collection` (see
+          below).
 
-        :source_collection:
-          Collection to copy `source_path` from (default `self`)
+        * target_path: str --- The path inside this collection where the
+          source object should be added.
 
-        :overwrite:
-          Whether to overwrite target file if it already exists.
-        """
+        * source_collection: arvados.collection.Collection | None --- The
+          collection to find the source object from when `source` is a
+          path. Defaults to the current collection (`self`).
 
+        * overwrite: bool --- Controls the behavior of this method when the
+          collection already contains an object at `target_path`. If `False`
+          (the default), this method will raise `FileExistsError`. If `True`,
+          the object at `target_path` will be replaced with `source_obj`.
+        """
         source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, False)
         if not source_obj.writable():
             raise IOError(errno.EROFS, "Source collection is read only", source)
         target_dir.add(source_obj, target_name, overwrite, True)
 
-    def portable_manifest_text(self, stream_name="."):
-        """Get the manifest text for this collection, sub collections and files.
+    def portable_manifest_text(self, stream_name: str=".") -> str:
+        """Get the portable manifest text for this collection
 
-        This method does not flush outstanding blocks to Keep.  It will return
-        a normalized manifest with access tokens stripped.
+        The portable manifest text is normalized, and does not include access
+        tokens. This method does not flush outstanding blocks to Keep.
 
-        :stream_name:
-          Name to use for this stream (directory)
+        Arguments:
 
+        * stream_name: str --- The name to use for this collection's stream in
+          the generated manifest. Default `'.'`.
         """
         return self._get_manifest_text(stream_name, True, True)
 
     @synchronized
-    def manifest_text(self, stream_name=".", strip=False, normalize=False,
-                      only_committed=False):
-        """Get the manifest text for this collection, sub collections and files.
+    def manifest_text(
+            self,
+            stream_name: str=".",
+            strip: bool=False,
+            normalize: bool=False,
+            only_committed: bool=False,
+    ) -> str:
+        """Get the manifest text for this collection
 
-        This method will flush outstanding blocks to Keep.  By default, it will
-        not normalize an unmodified manifest or strip access tokens.
-
-        :stream_name:
-          Name to use for this stream (directory)
+        Arguments:
 
-        :strip:
-          If True, remove signing tokens from block locators if present.
-          If False (default), block locators are left unchanged.
+        * stream_name: str --- The name to use for this collection's stream in
+          the generated manifest. Default `'.'`.
 
-        :normalize:
-          If True, always export the manifest text in normalized form
-          even if the Collection is not modified.  If False (default) and the collection
-          is not modified, return the original manifest text even if it is not
-          in normalized form.
+        * strip: bool --- Controls whether or not the returned manifest text
+          includes access tokens. If `False` (the default), the manifest text
+          will include access tokens. If `True`, the manifest text will not
+          include access tokens.
 
-        :only_committed:
-          If True, don't commit pending blocks.
+        * normalize: bool --- Controls whether or not the returned manifest
+          text is normalized. Default `False`.
 
+        * only_committed: bool --- Controls whether or not this method uploads
+          pending data to Keep before building and returning the manifest text.
+          If `False` (the default), this method will finish uploading all data
+          to Keep, then return the final manifest. If `True`, this method will
+          build and return a manifest that only refers to the data that has
+          finished uploading at the time this method was called.
         """
-
         if not only_committed:
             self._my_block_manager().commit_all()
         return self._get_manifest_text(stream_name, strip, normalize,
@@ -1088,11 +838,27 @@ class RichCollectionBase(CollectionBase):
         return remote_blocks
 
     @synchronized
-    def diff(self, end_collection, prefix=".", holding_collection=None):
-        """Generate list of add/modify/delete actions.
+    def diff(
+            self,
+            end_collection: 'RichCollectionBase',
+            prefix: str=".",
+            holding_collection: Optional['Collection']=None,
+    ) -> ChangeList:
+        """Build a list of differences between this collection and another
 
-        When given to `apply`, will change `self` to match `end_collection`
+        Arguments:
+
+        * end_collection: arvados.collection.RichCollectionBase --- A
+          collection object with the desired end state. The returned diff
+          list will describe how to go from the current collection object
+          `self` to `end_collection`.
 
+        * prefix: str --- The name to use for this collection's stream in
+          the diff list. Default `'.'`.
+
+        * holding_collection: arvados.collection.Collection | None --- A
+          collection object used to hold objects for the returned diff
+          list. By default, a new empty collection is created.
         """
         changes = []
         if holding_collection is None:
@@ -1114,12 +880,20 @@ class RichCollectionBase(CollectionBase):
 
     @must_be_writable
     @synchronized
-    def apply(self, changes):
-        """Apply changes from `diff`.
+    def apply(self, changes: ChangeList) -> None:
+        """Apply a list of changes from to this collection
+
+        This method takes a list of changes generated by
+        `RichCollectionBase.diff` and applies it to this
+        collection. Afterward, the state of this collection object will
+        match the state of `end_collection` passed to `diff`. If a change
+        conflicts with a local change, it will be saved to an alternate path
+        indicating the conflict.
 
-        If a change conflicts with a local change, it will be saved to an
-        alternate path indicating the conflict.
+        Arguments:
 
+        * changes: arvados.collection.ChangeList --- The list of differences
+          generated by `RichCollectionBase.diff`.
         """
         if changes:
             self.set_committed(False)
@@ -1161,8 +935,8 @@ class RichCollectionBase(CollectionBase):
                 # else, the file is modified or already removed, in either
                 # case we don't want to try to remove it.
 
-    def portable_data_hash(self):
-        """Get the portable data hash for this collection's manifest."""
+    def portable_data_hash(self) -> str:
+        """Get the portable data hash for this collection's manifest"""
         if self._manifest_locator and self.committed():
             # If the collection is already saved on the API server, and it's committed
             # then return API server's PDH response.
@@ -1172,25 +946,64 @@ class RichCollectionBase(CollectionBase):
             return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped))
 
     @synchronized
-    def subscribe(self, callback):
+    def subscribe(self, callback: ChangeCallback) -> None:
+        """Set a notify callback for changes to this collection
+
+        Arguments:
+
+        * callback: arvados.collection.ChangeCallback --- The callable to
+          call each time the collection is changed.
+        """
         if self._callback is None:
             self._callback = callback
         else:
             raise errors.ArgumentError("A callback is already set on this collection.")
 
     @synchronized
-    def unsubscribe(self):
+    def unsubscribe(self) -> None:
+        """Remove any notify callback set for changes to this collection"""
         if self._callback is not None:
             self._callback = None
 
     @synchronized
-    def notify(self, event, collection, name, item):
+    def notify(
+            self,
+            event: ChangeType,
+            collection: 'RichCollectionBase',
+            name: str,
+            item: CollectionItem,
+    ) -> None:
+        """Notify any subscribed callback about a change to this collection
+
+        .. ATTENTION:: Internal
+           This method is only meant to be used by other Collection methods.
+
+        If a callback has been registered with `RichCollectionBase.subscribe`,
+        it will be called with information about a change to this collection.
+        Then this notification will be propagated to this collection's root.
+
+        Arguments:
+
+        * event: Literal[ADD, DEL, MOD, TOK] --- The type of modification to
+          the collection.
+
+        * collection: arvados.collection.RichCollectionBase --- The
+          collection that was modified.
+
+        * name: str --- The name of the file or stream within `collection` that
+          was modified.
+
+        * item: arvados.arvfile.ArvadosFile |
+          arvados.collection.Subcollection --- The new contents at `name`
+          within `collection`.
+        """
         if self._callback:
             self._callback(event, collection, name, item)
         self.root_collection().notify(event, collection, name, item)
 
     @synchronized
-    def __eq__(self, other):
+    def __eq__(self, other: Any) -> bool:
+        """Indicate whether this collection object is equal to another"""
         if other is self:
             return True
         if not isinstance(other, RichCollectionBase):
@@ -1204,102 +1017,97 @@ class RichCollectionBase(CollectionBase):
                 return False
         return True
 
-    def __ne__(self, other):
+    def __ne__(self, other: Any) -> bool:
+        """Indicate whether this collection object is not equal to another"""
         return not self.__eq__(other)
 
     @synchronized
-    def flush(self):
-        """Flush bufferblocks to Keep."""
+    def flush(self) -> None:
+        """Upload any pending data to Keep"""
         for e in listvalues(self):
             e.flush()
 
 
 class Collection(RichCollectionBase):
-    """Represents the root of an Arvados Collection.
-
-    This class is threadsafe.  The root collection object, all subcollections
-    and files are protected by a single lock (i.e. each access locks the entire
-    collection).
-
-    Brief summary of
-    useful methods:
-
-    :To read an existing file:
-      `c.open("myfile", "r")`
-
-    :To write a new file:
-      `c.open("myfile", "w")`
-
-    :To determine if a file exists:
-      `c.find("myfile") is not None`
-
-    :To copy a file:
-      `c.copy("source", "dest")`
+    """Read and manipulate an Arvados collection
 
-    :To delete a file:
-      `c.remove("myfile")`
-
-    :To save to an existing collection record:
-      `c.save()`
-
-    :To save a new collection record:
-    `c.save_new()`
-
-    :To merge remote changes into this object:
-      `c.update()`
-
-    Must be associated with an API server Collection record (during
-    initialization, or using `save_new`) to use `save` or `update`
+    This class provides a high-level interface to create, read, and update
+    Arvados collections and their contents. Refer to the Arvados Python SDK
+    cookbook for [an introduction to using the Collection class][cookbook].
 
+    [cookbook]: https://doc.arvados.org/sdk/python/cookbook.html#working-with-collections
     """
 
-    def __init__(self, manifest_locator_or_text=None,
-                 api_client=None,
-                 keep_client=None,
-                 num_retries=None,
-                 parent=None,
-                 apiconfig=None,
-                 block_manager=None,
-                 replication_desired=None,
-                 storage_classes_desired=None,
-                 put_threads=None,
-                 get_threads=None):
-        """Collection constructor.
-
-        :manifest_locator_or_text:
-          An Arvados collection UUID, portable data hash, raw manifest
-          text, or (if creating an empty collection) None.
-
-        :parent:
-          the parent Collection, may be None.
-
-        :apiconfig:
-          A dict containing keys for ARVADOS_API_HOST and ARVADOS_API_TOKEN.
-          Prefer this over supplying your own api_client and keep_client (except in testing).
-          Will use default config settings if not specified.
-
-        :api_client:
-          The API client object to use for requests.  If not specified, create one using `apiconfig`.
-
-        :keep_client:
-          the Keep client to use for requests.  If not specified, create one using `apiconfig`.
-
-        :num_retries:
-          the number of retries for API and Keep requests.
-
-        :block_manager:
-          the block manager to use.  If not specified, create one.
-
-        :replication_desired:
-          How many copies should Arvados maintain. If None, API server default
-          configuration applies. If not None, this value will also be used
-          for determining the number of block copies being written.
-
-        :storage_classes_desired:
-          A list of storage class names where to upload the data. If None,
-          the keep client is expected to store the data into the cluster's
-          default storage class(es).
+    def __init__(self, manifest_locator_or_text: Optional[str]=None,
+                 api_client: Optional['arvados.api_resources.ArvadosAPIClient']=None,
+                 keep_client: Optional['arvados.keep.KeepClient']=None,
+                 num_retries: int=10,
+                 parent: Optional['Collection']=None,
+                 apiconfig: Optional[Mapping[str, str]]=None,
+                 block_manager: Optional['arvados.arvfile._BlockManager']=None,
+                 replication_desired: Optional[int]=None,
+                 storage_classes_desired: Optional[List[str]]=None,
+                 put_threads: Optional[int]=None):
+        """Initialize a Collection object
 
+        Arguments:
+
+        * manifest_locator_or_text: str | None --- This string can contain a
+          collection manifest text, portable data hash, or UUID. When given a
+          portable data hash or UUID, this instance will load a collection
+          record from the API server. Otherwise, this instance will represent a
+          new collection without an API server record. The default value `None`
+          instantiates a new collection with an empty manifest.
+
+        * api_client: arvados.api_resources.ArvadosAPIClient | None --- The
+          Arvados API client object this instance uses to make requests. If
+          none is given, this instance creates its own client using the
+          settings from `apiconfig` (see below). If your client instantiates
+          many Collection objects, you can help limit memory utilization by
+          calling `arvados.api.api` to construct an
+          `arvados.safeapi.ThreadSafeApiCache`, and use that as the `api_client`
+          for every Collection.
+
+        * keep_client: arvados.keep.KeepClient | None --- The Keep client
+          object this instance uses to make requests. If none is given, this
+          instance creates its own client using its `api_client`.
+
+        * num_retries: int --- The number of times that client requests are
+          retried. Default 10.
+
+        * parent: arvados.collection.Collection | None --- The parent Collection
+          object of this instance, if any. This argument is primarily used by
+          other Collection methods; user client code shouldn't need to use it.
+
+        * apiconfig: Mapping[str, str] | None --- A mapping with entries for
+          `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally
+          `ARVADOS_API_HOST_INSECURE`. When no `api_client` is provided, the
+          Collection object constructs one from these settings. If no
+          mapping is provided, calls `arvados.config.settings` to get these
+          parameters from user configuration.
+
+        * block_manager: arvados.arvfile._BlockManager | None --- The
+          _BlockManager object used by this instance to coordinate reading
+          and writing Keep data blocks. If none is given, this instance
+          constructs its own. This argument is primarily used by other
+          Collection methods; user client code shouldn't need to use it.
+
+        * replication_desired: int | None --- This controls both the value of
+          the `replication_desired` field on API collection records saved by
+          this class, as well as the number of Keep services that the object
+          writes new data blocks to. If none is given, uses the default value
+          configured for the cluster.
+
+        * storage_classes_desired: list[str] | None --- This controls both
+          the value of the `storage_classes_desired` field on API collection
+          records saved by this class, as well as selecting which specific
+          Keep services the object writes new data blocks to. If none is
+          given, defaults to an empty list.
+
+        * put_threads: int | None --- The number of threads to run
+          simultaneously to upload data blocks to Keep. This value is used when
+          building a new `block_manager`. It is unused when a `block_manager`
+          is provided.
         """
 
         if storage_classes_desired and type(storage_classes_desired) is not list:
@@ -1317,14 +1125,13 @@ class Collection(RichCollectionBase):
         self.replication_desired = replication_desired
         self._storage_classes_desired = storage_classes_desired
         self.put_threads = put_threads
-        self.get_threads = get_threads
 
         if apiconfig:
             self._config = apiconfig
         else:
             self._config = config.settings()
 
-        self.num_retries = num_retries if num_retries is not None else 0
+        self.num_retries = num_retries
         self._manifest_locator = None
         self._manifest_text = None
         self._portable_data_hash = None
@@ -1354,19 +1161,33 @@ class Collection(RichCollectionBase):
             except errors.SyntaxError as e:
                 raise errors.ArgumentError("Error processing manifest text: %s", str(e)) from None
 
-    def storage_classes_desired(self):
+    def storage_classes_desired(self) -> List[str]:
+        """Get this collection's `storage_classes_desired` value"""
         return self._storage_classes_desired or []
 
-    def root_collection(self):
+    def root_collection(self) -> 'Collection':
         return self
 
-    def get_properties(self):
+    def get_properties(self) -> Properties:
+        """Get this collection's properties
+
+        This method always returns a dict. If this collection object does not
+        have an associated API record, or that record does not have any
+        properties set, this method returns an empty dict.
+        """
         if self._api_response and self._api_response["properties"]:
             return self._api_response["properties"]
         else:
             return {}
 
-    def get_trash_at(self):
+    def get_trash_at(self) -> Optional[datetime.datetime]:
+        """Get this collection's `trash_at` field
+
+        This method parses the `trash_at` field of the collection's API
+        record and returns a datetime from it. If that field is not set, or
+        this collection object does not have an associated API record,
+        returns None.
+        """
         if self._api_response and self._api_response["trash_at"]:
             try:
                 return ciso8601.parse_datetime(self._api_response["trash_at"])
@@ -1375,21 +1196,57 @@ class Collection(RichCollectionBase):
         else:
             return None
 
-    def stream_name(self):
+    def stream_name(self) -> str:
         return "."
 
-    def writable(self):
+    def writable(self) -> bool:
         return True
 
     @synchronized
-    def known_past_version(self, modified_at_and_portable_data_hash):
+    def known_past_version(
+            self,
+            modified_at_and_portable_data_hash: Tuple[Optional[str], Optional[str]]
+    ) -> bool:
+        """Indicate whether an API record for this collection has been seen before
+
+        As this collection object loads records from the API server, it records
+        their `modified_at` and `portable_data_hash` fields. This method accepts
+        a 2-tuple with values for those fields, and returns `True` if the
+        combination was previously loaded.
+        """
         return modified_at_and_portable_data_hash in self._past_versions
 
     @synchronized
     @retry_method
-    def update(self, other=None, num_retries=None):
-        """Merge the latest collection on the API server with the current collection."""
+    def update(
+            self,
+            other: Optional['Collection']=None,
+            num_retries: Optional[int]=None,
+    ) -> None:
+        """Merge another collection's contents into this one
+
+        This method compares the manifest of this collection instance with
+        another, then updates this instance's manifest with changes from the
+        other, renaming files to flag conflicts where necessary.
+
+        When called without any arguments, this method reloads the collection's
+        API record, and updates this instance with any changes that have
+        appeared server-side. If this instance does not have a corresponding
+        API record, this method raises `arvados.errors.ArgumentError`.
 
+        Arguments:
+
+        * other: arvados.collection.Collection | None --- The collection
+          whose contents should be merged into this instance. When not
+          provided, this method reloads this collection's API record and
+          constructs a Collection object from it.  If this instance does not
+          have a corresponding API record, this method raises
+          `arvados.errors.ArgumentError`.
+
+        * num_retries: int | None --- The number of times to retry reloading
+          the collection's API record from the API server. If not specified,
+          uses the `num_retries` provided when this instance was constructed.
+        """
         if other is None:
             if self._manifest_locator is None:
                 raise errors.ArgumentError("`other` is None but collection does not have a manifest_locator uuid")
@@ -1435,8 +1292,7 @@ class Collection(RichCollectionBase):
                                                 copies=copies,
                                                 put_threads=self.put_threads,
                                                 num_retries=self.num_retries,
-                                                storage_classes_func=self.storage_classes_desired,
-                                                get_threads=self.get_threads,)
+                                                storage_classes_func=self.storage_classes_desired)
         return self._block_manager
 
     def _remember_api_response(self, response):
@@ -1483,35 +1339,68 @@ class Collection(RichCollectionBase):
         return self
 
     def __exit__(self, exc_type, exc_value, traceback):
-        """Support scoped auto-commit in a with: block."""
+        """Exit a context with this collection instance
+
+        If no exception was raised inside the context block, and this
+        collection is writable and has a corresponding API record, that
+        record will be updated to match the state of this instance at the end
+        of the block.
+        """
         if exc_type is None:
             if self.writable() and self._has_collection_uuid():
                 self.save()
         self.stop_threads()
 
-    def stop_threads(self):
+    def stop_threads(self) -> None:
+        """Stop background Keep upload/download threads"""
         if self._block_manager is not None:
             self._block_manager.stop_threads()
 
     @synchronized
-    def manifest_locator(self):
-        """Get the manifest locator, if any.
-
-        The manifest locator will be set when the collection is loaded from an
-        API server record or the portable data hash of a manifest.
-
-        The manifest locator will be None if the collection is newly created or
-        was created directly from manifest text.  The method `save_new()` will
-        assign a manifest locator.
-
+    def manifest_locator(self) -> Optional[str]:
+        """Get this collection's manifest locator, if any
+
+        * If this collection instance is associated with an API record with a
+          UUID, return that.
+        * Otherwise, if this collection instance was loaded from an API record
+          by portable data hash, return that.
+        * Otherwise, return `None`.
         """
         return self._manifest_locator
 
     @synchronized
-    def clone(self, new_parent=None, new_name=None, readonly=False, new_config=None):
-        if new_config is None:
-            new_config = self._config
-        if readonly:
+    def clone(
+            self,
+            new_parent: Optional['Collection']=None,
+            new_name: Optional[str]=None,
+            readonly: bool=False,
+            new_config: Optional[Mapping[str, str]]=None,
+    ) -> 'Collection':
+        """Create a Collection object with the same contents as this instance
+
+        This method creates a new Collection object with contents that match
+        this instance's. The new collection will not be associated with any API
+        record.
+
+        Arguments:
+
+        * new_parent: arvados.collection.Collection | None --- This value is
+          passed to the new Collection's constructor as the `parent`
+          argument.
+
+        * new_name: str | None --- This value is unused.
+
+        * readonly: bool --- If this value is true, this method constructs and
+          returns a `CollectionReader`. Otherwise, it returns a mutable
+          `Collection`. Default `False`.
+
+        * new_config: Mapping[str, str] | None --- This value is passed to the
+          new Collection's constructor as `apiconfig`. If no value is provided,
+          defaults to the configuration passed to this instance's constructor.
+        """
+        if new_config is None:
+            new_config = self._config
+        if readonly:
             newcollection = CollectionReader(parent=new_parent, apiconfig=new_config)
         else:
             newcollection = Collection(parent=new_parent, apiconfig=new_config)
@@ -1520,31 +1409,31 @@ class Collection(RichCollectionBase):
         return newcollection
 
     @synchronized
-    def api_response(self):
-        """Returns information about this Collection fetched from the API server.
-
-        If the Collection exists in Keep but not the API server, currently
-        returns None.  Future versions may provide a synthetic response.
+    def api_response(self) -> Optional[Dict[str, Any]]:
+        """Get this instance's associated API record
 
+        If this Collection instance has an associated API record, return it.
+        Otherwise, return `None`.
         """
         return self._api_response
 
-    def find_or_create(self, path, create_type):
-        """See `RichCollectionBase.find_or_create`"""
+    def find_or_create(
+            self,
+            path: str,
+            create_type: CreateType,
+    ) -> CollectionItem:
         if path == ".":
             return self
         else:
             return super(Collection, self).find_or_create(path[2:] if path.startswith("./") else path, create_type)
 
-    def find(self, path):
-        """See `RichCollectionBase.find`"""
+    def find(self, path: str) -> CollectionItem:
         if path == ".":
             return self
         else:
             return super(Collection, self).find(path[2:] if path.startswith("./") else path)
 
-    def remove(self, path, recursive=False):
-        """See `RichCollectionBase.remove`"""
+    def remove(self, path: str, recursive: bool=False) -> None:
         if path == ".":
             raise errors.ArgumentError("Cannot remove '.'")
         else:
@@ -1553,49 +1442,52 @@ class Collection(RichCollectionBase):
     @must_be_writable
     @synchronized
     @retry_method
-    def save(self,
-             properties=None,
-             storage_classes=None,
-             trash_at=None,
-             merge=True,
-             num_retries=None,
-             preserve_version=False):
-        """Save collection to an existing collection record.
-
-        Commit pending buffer blocks to Keep, merge with remote record (if
-        merge=True, the default), and update the collection record. Returns
-        the current manifest text.
-
-        Will raise AssertionError if not associated with a collection record on
-        the API server.  If you want to save a manifest to Keep only, see
-        `save_new()`.
-
-        :properties:
-          Additional properties of collection. This value will replace any existing
-          properties of collection.
-
-        :storage_classes:
-          Specify desirable storage classes to be used when writing data to Keep.
-
-        :trash_at:
-          A collection is *expiring* when it has a *trash_at* time in the future.
-          An expiring collection can be accessed as normal,
-          but is scheduled to be trashed automatically at the *trash_at* time.
-
-        :merge:
-          Update and merge remote changes before saving.  Otherwise, any
-          remote changes will be ignored and overwritten.
-
-        :num_retries:
-          Retry count on API calls (if None,  use the collection default)
-
-        :preserve_version:
-          If True, indicate that the collection content being saved right now
-          should be preserved in a version snapshot if the collection record is
-          updated in the future. Requires that the API server has
-          Collections.CollectionVersioning enabled, if not, setting this will
-          raise an exception.
+    def save(
+            self,
+            properties: Optional[Properties]=None,
+            storage_classes: Optional[StorageClasses]=None,
+            trash_at: Optional[datetime.datetime]=None,
+            merge: bool=True,
+            num_retries: Optional[int]=None,
+            preserve_version: bool=False,
+    ) -> str:
+        """Save collection to an existing API record
+
+        This method updates the instance's corresponding API record to match
+        the instance's state. If this instance does not have a corresponding API
+        record yet, raises `AssertionError`. (To create a new API record, use
+        `Collection.save_new`.) This method returns the saved collection
+        manifest.
 
+        Arguments:
+
+        * properties: dict[str, Any] | None --- If provided, the API record will
+          be updated with these properties. Note this will completely replace
+          any existing properties.
+
+        * storage_classes: list[str] | None --- If provided, the API record will
+          be updated with this value in the `storage_classes_desired` field.
+          This value will also be saved on the instance and used for any
+          changes that follow.
+
+        * trash_at: datetime.datetime | None --- If provided, the API record
+          will be updated with this value in the `trash_at` field.
+
+        * merge: bool --- If `True` (the default), this method will first
+          reload this collection's API record, and merge any new contents into
+          this instance before saving changes. See `Collection.update` for
+          details.
+
+        * num_retries: int | None --- The number of times to retry reloading
+          the collection's API record from the API server. If not specified,
+          uses the `num_retries` provided when this instance was constructed.
+
+        * preserve_version: bool --- This value will be passed to directly
+          to the underlying API call. If `True`, the Arvados API will
+          preserve the versions of this collection both immediately before
+          and after the update. If `True` when the API server is not
+          configured with collection versioning, this method raises
+          `arvados.errors.ArgumentError`.
         """
         if properties and type(properties) is not dict:
             raise errors.ArgumentError("properties must be dictionary type.")
@@ -1659,60 +1551,66 @@ class Collection(RichCollectionBase):
     @must_be_writable
     @synchronized
     @retry_method
-    def save_new(self, name=None,
-                 create_collection_record=True,
-                 owner_uuid=None,
-                 properties=None,
-                 storage_classes=None,
-                 trash_at=None,
-                 ensure_unique_name=False,
-                 num_retries=None,
-                 preserve_version=False):
-        """Save collection to a new collection record.
-
-        Commit pending buffer blocks to Keep and, when create_collection_record
-        is True (default), create a new collection record.  After creating a
-        new collection record, this Collection object will be associated with
-        the new record used by `save()`. Returns the current manifest text.
-
-        :name:
-          The collection name.
-
-        :create_collection_record:
-           If True, create a collection record on the API server.
-           If False, only commit blocks to Keep and return the manifest text.
-
-        :owner_uuid:
-          the user, or project uuid that will own this collection.
-          If None, defaults to the current user.
-
-        :properties:
-          Additional properties of collection. This value will replace any existing
-          properties of collection.
-
-        :storage_classes:
-          Specify desirable storage classes to be used when writing data to Keep.
-
-        :trash_at:
-          A collection is *expiring* when it has a *trash_at* time in the future.
-          An expiring collection can be accessed as normal,
-          but is scheduled to be trashed automatically at the *trash_at* time.
-
-        :ensure_unique_name:
-          If True, ask the API server to rename the collection
-          if it conflicts with a collection with the same name and owner.  If
-          False, a name conflict will result in an error.
-
-        :num_retries:
-          Retry count on API calls (if None,  use the collection default)
-
-        :preserve_version:
-          If True, indicate that the collection content being saved right now
-          should be preserved in a version snapshot if the collection record is
-          updated in the future. Requires that the API server has
-          Collections.CollectionVersioning enabled, if not, setting this will
-          raise an exception.
+    def save_new(
+            self,
+            name: Optional[str]=None,
+            create_collection_record: bool=True,
+            owner_uuid: Optional[str]=None,
+            properties: Optional[Properties]=None,
+            storage_classes: Optional[StorageClasses]=None,
+            trash_at: Optional[datetime.datetime]=None,
+            ensure_unique_name: bool=False,
+            num_retries: Optional[int]=None,
+            preserve_version: bool=False,
+    ):
+        """Save collection to a new API record
+
+        This method finishes uploading new data blocks and (optionally)
+        creates a new API collection record with the provided data. If a new
+        record is created, this instance becomes associated with that record
+        for future updates like `save()`. This method returns the saved
+        collection manifest.
+
+        Arguments:
+
+        * name: str | None --- The `name` field to use on the new collection
+          record. If not specified, a generic default name is generated.
+
+        * create_collection_record: bool --- If `True` (the default), creates a
+          collection record on the API server. If `False`, the method finishes
+          all data uploads and only returns the resulting collection manifest
+          without sending it to the API server.
+
+        * owner_uuid: str | None --- The `owner_uuid` field to use on the
+          new collection record.
+
+        * properties: dict[str, Any] | None --- The `properties` field to use on
+          the new collection record.
+
+        * storage_classes: list[str] | None --- The
+          `storage_classes_desired` field to use on the new collection record.
+
+        * trash_at: datetime.datetime | None --- The `trash_at` field to use
+          on the new collection record.
 
+        * ensure_unique_name: bool --- This value is passed directly to the
+          Arvados API when creating the collection record. If `True`, the API
+          server may modify the submitted `name` to ensure the collection's
+          `name`+`owner_uuid` combination is unique. If `False` (the default),
+          if a collection already exists with this same `name`+`owner_uuid`
+          combination, creating a collection record will raise a validation
+          error.
+
+        * num_retries: int | None --- The number of times to retry reloading
+          the collection's API record from the API server. If not specified,
+          uses the `num_retries` provided when this instance was constructed.
+
+        * preserve_version: bool --- This value will be passed to directly
+          to the underlying API call. If `True`, the Arvados API will
+          preserve the versions of this collection both immediately before
+          and after the update. If `True` when the API server is not
+          configured with collection versioning, this method raises
+          `arvados.errors.ArgumentError`.
         """
         if properties and type(properties) is not dict:
             raise errors.ArgumentError("properties must be dictionary type.")
@@ -1773,7 +1671,7 @@ class Collection(RichCollectionBase):
     _segment_re = re.compile(r'(\d+):(\d+):(\S+)')
 
     def _unescape_manifest_path(self, path):
-        return re.sub('\\\\([0-3][0-7][0-7])', lambda m: chr(int(m.group(1), 8)), path)
+        return re.sub(r'\\([0-3][0-7][0-7])', lambda m: chr(int(m.group(1), 8)), path)
 
     @synchronized
     def _import_manifest(self, manifest_text):
@@ -1850,17 +1748,24 @@ class Collection(RichCollectionBase):
         self.set_committed(True)
 
     @synchronized
-    def notify(self, event, collection, name, item):
+    def notify(
+            self,
+            event: ChangeType,
+            collection: 'RichCollectionBase',
+            name: str,
+            item: CollectionItem,
+    ) -> None:
         if self._callback:
             self._callback(event, collection, name, item)
 
 
 class Subcollection(RichCollectionBase):
-    """This is a subdirectory within a collection that doesn't have its own API
-    server record.
-
-    Subcollection locking falls under the umbrella lock of its root collection.
+    """Read and manipulate a stream/directory within an Arvados collection
 
+    This class represents a single stream (like a directory) within an Arvados
+    `Collection`. It is returned by `Collection.find` and provides the same API.
+    Operations that work on the API collection record propagate to the parent
+    `Collection` object.
     """
 
     def __init__(self, parent, name):
@@ -1870,10 +1775,10 @@ class Subcollection(RichCollectionBase):
         self.name = name
         self.num_retries = parent.num_retries
 
-    def root_collection(self):
+    def root_collection(self) -> 'Collection':
         return self.parent.root_collection()
 
-    def writable(self):
+    def writable(self) -> bool:
         return self.root_collection().writable()
 
     def _my_api(self):
@@ -1885,11 +1790,15 @@ class Subcollection(RichCollectionBase):
     def _my_block_manager(self):
         return self.root_collection()._my_block_manager()
 
-    def stream_name(self):
+    def stream_name(self) -> str:
         return os.path.join(self.parent.stream_name(), self.name)
 
     @synchronized
-    def clone(self, new_parent, new_name):
+    def clone(
+            self,
+            new_parent: Optional['Collection']=None,
+            new_name: Optional[str]=None,
+    ) -> 'Subcollection':
         c = Subcollection(new_parent, new_name)
         c._clonefrom(self)
         return c
@@ -1916,11 +1825,11 @@ class Subcollection(RichCollectionBase):
 
 
 class CollectionReader(Collection):
-    """A read-only collection object.
-
-    Initialize from a collection UUID or portable data hash, or raw
-    manifest text.  See `Collection` constructor for detailed options.
+    """Read-only `Collection` subclass
 
+    This class will never create or update any API collection records. You can
+    use this class for additional code safety when you only need to read
+    existing collections.
     """
     def __init__(self, manifest_locator_or_text, *args, **kwargs):
         self._in_init = True
@@ -1934,7 +1843,7 @@ class CollectionReader(Collection):
         # all_streams() and all_files()
         self._streams = None
 
-    def writable(self):
+    def writable(self) -> bool:
         return self._in_init
 
     def _populate_streams(orig_func):
@@ -1951,16 +1860,10 @@ class CollectionReader(Collection):
             return orig_func(self, *args, **kwargs)
         return populate_streams_wrapper
 
+    @arvados.util._deprecated('3.0', 'Collection iteration')
     @_populate_streams
     def normalize(self):
-        """Normalize the streams returned by `all_streams`.
-
-        This method is kept for backwards compatability and only affects the
-        behavior of `all_streams()` and `all_files()`
-
-        """
-
-        # Rearrange streams
+        """Normalize the streams returned by `all_streams`"""
         streams = {}
         for s in self.all_streams():
             for f in s.all_files():
@@ -1974,13 +1877,436 @@ class CollectionReader(Collection):
 
         self._streams = [normalize_stream(s, streams[s])
                          for s in sorted(streams)]
+
+    @arvados.util._deprecated('3.0', 'Collection iteration')
     @_populate_streams
     def all_streams(self):
         return [StreamReader(s, self._my_keep(), num_retries=self.num_retries)
                 for s in self._streams]
 
+    @arvados.util._deprecated('3.0', 'Collection iteration')
     @_populate_streams
     def all_files(self):
         for s in self.all_streams():
             for f in s.all_files():
                 yield f
+
+
+class CollectionWriter(CollectionBase):
+    """Create a new collection from scratch
+
+    .. WARNING:: Deprecated
+       This class is deprecated. Prefer `arvados.collection.Collection`
+       instead.
+    """
+
+    @arvados.util._deprecated('3.0', 'arvados.collection.Collection')
+    def __init__(self, api_client=None, num_retries=0, replication=None):
+        """Instantiate a CollectionWriter.
+
+        CollectionWriter lets you build a new Arvados Collection from scratch.
+        Write files to it.  The CollectionWriter will upload data to Keep as
+        appropriate, and provide you with the Collection manifest text when
+        you're finished.
+
+        Arguments:
+        * api_client: The API client to use to look up Collections.  If not
+          provided, CollectionReader will build one from available Arvados
+          configuration.
+        * num_retries: The default number of times to retry failed
+          service requests.  Default 0.  You may change this value
+          after instantiation, but note those changes may not
+          propagate to related objects like the Keep client.
+        * replication: The number of copies of each block to store.
+          If this argument is None or not supplied, replication is
+          the server-provided default if available, otherwise 2.
+        """
+        self._api_client = api_client
+        self.num_retries = num_retries
+        self.replication = (2 if replication is None else replication)
+        self._keep_client = None
+        self._data_buffer = []
+        self._data_buffer_len = 0
+        self._current_stream_files = []
+        self._current_stream_length = 0
+        self._current_stream_locators = []
+        self._current_stream_name = '.'
+        self._current_file_name = None
+        self._current_file_pos = 0
+        self._finished_streams = []
+        self._close_file = None
+        self._queued_file = None
+        self._queued_dirents = deque()
+        self._queued_trees = deque()
+        self._last_open = None
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if exc_type is None:
+            self.finish()
+
+    def do_queued_work(self):
+        # The work queue consists of three pieces:
+        # * _queued_file: The file object we're currently writing to the
+        #   Collection.
+        # * _queued_dirents: Entries under the current directory
+        #   (_queued_trees[0]) that we want to write or recurse through.
+        #   This may contain files from subdirectories if
+        #   max_manifest_depth == 0 for this directory.
+        # * _queued_trees: Directories that should be written as separate
+        #   streams to the Collection.
+        # This function handles the smallest piece of work currently queued
+        # (current file, then current directory, then next directory) until
+        # no work remains.  The _work_THING methods each do a unit of work on
+        # THING.  _queue_THING methods add a THING to the work queue.
+        while True:
+            if self._queued_file:
+                self._work_file()
+            elif self._queued_dirents:
+                self._work_dirents()
+            elif self._queued_trees:
+                self._work_trees()
+            else:
+                break
+
+    def _work_file(self):
+        while True:
+            buf = self._queued_file.read(config.KEEP_BLOCK_SIZE)
+            if not buf:
+                break
+            self.write(buf)
+        self.finish_current_file()
+        if self._close_file:
+            self._queued_file.close()
+        self._close_file = None
+        self._queued_file = None
+
+    def _work_dirents(self):
+        path, stream_name, max_manifest_depth = self._queued_trees[0]
+        if stream_name != self.current_stream_name():
+            self.start_new_stream(stream_name)
+        while self._queued_dirents:
+            dirent = self._queued_dirents.popleft()
+            target = os.path.join(path, dirent)
+            if os.path.isdir(target):
+                self._queue_tree(target,
+                                 os.path.join(stream_name, dirent),
+                                 max_manifest_depth - 1)
+            else:
+                self._queue_file(target, dirent)
+                break
+        if not self._queued_dirents:
+            self._queued_trees.popleft()
+
+    def _work_trees(self):
+        path, stream_name, max_manifest_depth = self._queued_trees[0]
+        d = arvados.util.listdir_recursive(
+            path, max_depth = (None if max_manifest_depth == 0 else 0))
+        if d:
+            self._queue_dirents(stream_name, d)
+        else:
+            self._queued_trees.popleft()
+
+    def _queue_file(self, source, filename=None):
+        assert (self._queued_file is None), "tried to queue more than one file"
+        if not hasattr(source, 'read'):
+            source = open(source, 'rb')
+            self._close_file = True
+        else:
+            self._close_file = False
+        if filename is None:
+            filename = os.path.basename(source.name)
+        self.start_new_file(filename)
+        self._queued_file = source
+
+    def _queue_dirents(self, stream_name, dirents):
+        assert (not self._queued_dirents), "tried to queue more than one tree"
+        self._queued_dirents = deque(sorted(dirents))
+
+    def _queue_tree(self, path, stream_name, max_manifest_depth):
+        self._queued_trees.append((path, stream_name, max_manifest_depth))
+
+    def write_file(self, source, filename=None):
+        self._queue_file(source, filename)
+        self.do_queued_work()
+
+    def write_directory_tree(self,
+                             path, stream_name='.', max_manifest_depth=-1):
+        self._queue_tree(path, stream_name, max_manifest_depth)
+        self.do_queued_work()
+
+    def write(self, newdata):
+        if isinstance(newdata, bytes):
+            pass
+        elif isinstance(newdata, str):
+            newdata = newdata.encode()
+        elif hasattr(newdata, '__iter__'):
+            for s in newdata:
+                self.write(s)
+            return
+        self._data_buffer.append(newdata)
+        self._data_buffer_len += len(newdata)
+        self._current_stream_length += len(newdata)
+        while self._data_buffer_len >= config.KEEP_BLOCK_SIZE:
+            self.flush_data()
+
+    def open(self, streampath, filename=None):
+        """open(streampath[, filename]) -> file-like object
+
+        Pass in the path of a file to write to the Collection, either as a
+        single string or as two separate stream name and file name arguments.
+        This method returns a file-like object you can write to add it to the
+        Collection.
+
+        You may only have one file object from the Collection open at a time,
+        so be sure to close the object when you're done.  Using the object in
+        a with statement makes that easy:
+
+            with cwriter.open('./doc/page1.txt') as outfile:
+                outfile.write(page1_data)
+            with cwriter.open('./doc/page2.txt') as outfile:
+                outfile.write(page2_data)
+        """
+        if filename is None:
+            streampath, filename = split(streampath)
+        if self._last_open and not self._last_open.closed:
+            raise errors.AssertionError(
+                u"can't open '{}' when '{}' is still open".format(
+                    filename, self._last_open.name))
+        if streampath != self.current_stream_name():
+            self.start_new_stream(streampath)
+        self.set_current_file_name(filename)
+        self._last_open = _WriterFile(self, filename)
+        return self._last_open
+
+    def flush_data(self):
+        data_buffer = b''.join(self._data_buffer)
+        if data_buffer:
+            self._current_stream_locators.append(
+                self._my_keep().put(
+                    data_buffer[0:config.KEEP_BLOCK_SIZE],
+                    copies=self.replication))
+            self._data_buffer = [data_buffer[config.KEEP_BLOCK_SIZE:]]
+            self._data_buffer_len = len(self._data_buffer[0])
+
+    def start_new_file(self, newfilename=None):
+        self.finish_current_file()
+        self.set_current_file_name(newfilename)
+
+    def set_current_file_name(self, newfilename):
+        if re.search(r'[\t\n]', newfilename):
+            raise errors.AssertionError(
+                "Manifest filenames cannot contain whitespace: %s" %
+                newfilename)
+        elif re.search(r'\x00', newfilename):
+            raise errors.AssertionError(
+                "Manifest filenames cannot contain NUL characters: %s" %
+                newfilename)
+        self._current_file_name = newfilename
+
+    def current_file_name(self):
+        return self._current_file_name
+
+    def finish_current_file(self):
+        if self._current_file_name is None:
+            if self._current_file_pos == self._current_stream_length:
+                return
+            raise errors.AssertionError(
+                "Cannot finish an unnamed file " +
+                "(%d bytes at offset %d in '%s' stream)" %
+                (self._current_stream_length - self._current_file_pos,
+                 self._current_file_pos,
+                 self._current_stream_name))
+        self._current_stream_files.append([
+                self._current_file_pos,
+                self._current_stream_length - self._current_file_pos,
+                self._current_file_name])
+        self._current_file_pos = self._current_stream_length
+        self._current_file_name = None
+
+    def start_new_stream(self, newstreamname='.'):
+        self.finish_current_stream()
+        self.set_current_stream_name(newstreamname)
+
+    def set_current_stream_name(self, newstreamname):
+        if re.search(r'[\t\n]', newstreamname):
+            raise errors.AssertionError(
+                "Manifest stream names cannot contain whitespace: '%s'" %
+                (newstreamname))
+        self._current_stream_name = '.' if newstreamname=='' else newstreamname
+
+    def current_stream_name(self):
+        return self._current_stream_name
+
+    def finish_current_stream(self):
+        self.finish_current_file()
+        self.flush_data()
+        if not self._current_stream_files:
+            pass
+        elif self._current_stream_name is None:
+            raise errors.AssertionError(
+                "Cannot finish an unnamed stream (%d bytes in %d files)" %
+                (self._current_stream_length, len(self._current_stream_files)))
+        else:
+            if not self._current_stream_locators:
+                self._current_stream_locators.append(config.EMPTY_BLOCK_LOCATOR)
+            self._finished_streams.append([self._current_stream_name,
+                                           self._current_stream_locators,
+                                           self._current_stream_files])
+        self._current_stream_files = []
+        self._current_stream_length = 0
+        self._current_stream_locators = []
+        self._current_stream_name = None
+        self._current_file_pos = 0
+        self._current_file_name = None
+
+    def finish(self):
+        """Store the manifest in Keep and return its locator.
+
+        This is useful for storing manifest fragments (task outputs)
+        temporarily in Keep during a Crunch job.
+
+        In other cases you should make a collection instead, by
+        sending manifest_text() to the API server's "create
+        collection" endpoint.
+        """
+        return self._my_keep().put(self.manifest_text().encode(),
+                                   copies=self.replication)
+
+    def portable_data_hash(self):
+        stripped = self.stripped_manifest().encode()
+        return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped))
+
+    def manifest_text(self):
+        self.finish_current_stream()
+        manifest = ''
+
+        for stream in self._finished_streams:
+            if not re.search(r'^\.(/.*)?$', stream[0]):
+                manifest += './'
+            manifest += stream[0].replace(' ', '\\040')
+            manifest += ' ' + ' '.join(stream[1])
+            manifest += ' ' + ' '.join("%d:%d:%s" % (sfile[0], sfile[1], sfile[2].replace(' ', '\\040')) for sfile in stream[2])
+            manifest += "\n"
+
+        return manifest
+
+    def data_locators(self):
+        ret = []
+        for name, locators, files in self._finished_streams:
+            ret += locators
+        return ret
+
+    def save_new(self, name=None):
+        return self._api_client.collections().create(
+            ensure_unique_name=True,
+            body={
+                'name': name,
+                'manifest_text': self.manifest_text(),
+            }).execute(num_retries=self.num_retries)
+
+
+class ResumableCollectionWriter(CollectionWriter):
+    """CollectionWriter that can serialize internal state to disk
+
+    .. WARNING:: Deprecated
+       This class is deprecated. Prefer `arvados.collection.Collection`
+       instead.
+    """
+
+    STATE_PROPS = ['_current_stream_files', '_current_stream_length',
+                   '_current_stream_locators', '_current_stream_name',
+                   '_current_file_name', '_current_file_pos', '_close_file',
+                   '_data_buffer', '_dependencies', '_finished_streams',
+                   '_queued_dirents', '_queued_trees']
+
+    @arvados.util._deprecated('3.0', 'arvados.collection.Collection')
+    def __init__(self, api_client=None, **kwargs):
+        self._dependencies = {}
+        super(ResumableCollectionWriter, self).__init__(api_client, **kwargs)
+
+    @classmethod
+    def from_state(cls, state, *init_args, **init_kwargs):
+        # Try to build a new writer from scratch with the given state.
+        # If the state is not suitable to resume (because files have changed,
+        # been deleted, aren't predictable, etc.), raise a
+        # StaleWriterStateError.  Otherwise, return the initialized writer.
+        # The caller is responsible for calling writer.do_queued_work()
+        # appropriately after it's returned.
+        writer = cls(*init_args, **init_kwargs)
+        for attr_name in cls.STATE_PROPS:
+            attr_value = state[attr_name]
+            attr_class = getattr(writer, attr_name).__class__
+            # Coerce the value into the same type as the initial value, if
+            # needed.
+            if attr_class not in (type(None), attr_value.__class__):
+                attr_value = attr_class(attr_value)
+            setattr(writer, attr_name, attr_value)
+        # Check dependencies before we try to resume anything.
+        if any(KeepLocator(ls).permission_expired()
+               for ls in writer._current_stream_locators):
+            raise errors.StaleWriterStateError(
+                "locators include expired permission hint")
+        writer.check_dependencies()
+        if state['_current_file'] is not None:
+            path, pos = state['_current_file']
+            try:
+                writer._queued_file = open(path, 'rb')
+                writer._queued_file.seek(pos)
+            except IOError as error:
+                raise errors.StaleWriterStateError(
+                    u"failed to reopen active file {}: {}".format(path, error))
+        return writer
+
+    def check_dependencies(self):
+        for path, orig_stat in listitems(self._dependencies):
+            if not S_ISREG(orig_stat[ST_MODE]):
+                raise errors.StaleWriterStateError(u"{} not file".format(path))
+            try:
+                now_stat = tuple(os.stat(path))
+            except OSError as error:
+                raise errors.StaleWriterStateError(
+                    u"failed to stat {}: {}".format(path, error))
+            if ((not S_ISREG(now_stat[ST_MODE])) or
+                (orig_stat[ST_MTIME] != now_stat[ST_MTIME]) or
+                (orig_stat[ST_SIZE] != now_stat[ST_SIZE])):
+                raise errors.StaleWriterStateError(u"{} changed".format(path))
+
+    def dump_state(self, copy_func=lambda x: x):
+        state = {attr: copy_func(getattr(self, attr))
+                 for attr in self.STATE_PROPS}
+        if self._queued_file is None:
+            state['_current_file'] = None
+        else:
+            state['_current_file'] = (os.path.realpath(self._queued_file.name),
+                                      self._queued_file.tell())
+        return state
+
+    def _queue_file(self, source, filename=None):
+        try:
+            src_path = os.path.realpath(source)
+        except Exception:
+            raise errors.AssertionError(u"{} not a file path".format(source))
+        try:
+            path_stat = os.stat(src_path)
+        except OSError as stat_error:
+            path_stat = None
+        super(ResumableCollectionWriter, self)._queue_file(source, filename)
+        fd_stat = os.fstat(self._queued_file.fileno())
+        if not S_ISREG(fd_stat.st_mode):
+            # We won't be able to resume from this cache anyway, so don't
+            # worry about further checks.
+            self._dependencies[source] = tuple(fd_stat)
+        elif path_stat is None:
+            raise errors.AssertionError(
+                u"could not stat {}: {}".format(source, stat_error))
+        elif path_stat.st_ino != fd_stat.st_ino:
+            raise errors.AssertionError(
+                u"{} changed between open and stat calls".format(source))
+        else:
+            self._dependencies[src_path] = tuple(fd_stat)
+
+    def write(self, data):
+        if self._queued_file is None:
+            raise errors.AssertionError(
+                "resumable writer can't accept unsourced data")
+        return super(ResumableCollectionWriter, self).write(data)
index d10d38eb5bd1d4b08b3f2f2f33b00c234bc6b5eb..6c792b2e0d54d7f1e25ffa9850723b4dc9289cc0 100644 (file)
@@ -4,12 +4,21 @@
 
 import argparse
 import errno
-import os
+import json
 import logging
+import os
+import re
 import signal
-from future.utils import listitems, listvalues
 import sys
 
+FILTER_STR_RE = re.compile(r'''
+^\(
+\ *(\w+)
+\ *(<|<=|=|>=|>)
+\ *(\w+)
+\ *\)$
+''', re.ASCII | re.VERBOSE)
+
 def _pos_int(s):
     num = int(s)
     if num < 0:
@@ -17,9 +26,9 @@ def _pos_int(s):
     return num
 
 retry_opt = argparse.ArgumentParser(add_help=False)
-retry_opt.add_argument('--retries', type=_pos_int, default=3, help="""
+retry_opt.add_argument('--retries', type=_pos_int, default=10, help="""
 Maximum number of times to retry server requests that encounter temporary
-failures (e.g., server down).  Default 3.""")
+failures (e.g., server down).  Default 10.""")
 
 def _ignore_error(error):
     return None
@@ -61,5 +70,89 @@ def install_signal_handlers():
                             for sigcode in CAUGHT_SIGNALS}
 
 def restore_signal_handlers():
-    for sigcode, orig_handler in listitems(orig_signal_handlers):
+    for sigcode, orig_handler in orig_signal_handlers.items():
         signal.signal(sigcode, orig_handler)
+
+def validate_filters(filters):
+    """Validate user-provided filters
+
+    This function validates that a user-defined object represents valid
+    Arvados filters that can be passed to an API client: that it's a list of
+    3-element lists with the field name and operator given as strings. If any
+    of these conditions are not true, it raises a ValueError with details about
+    the problem.
+
+    It returns validated filters. Currently the provided filters are returned
+    unmodified. Future versions of this function may clean up the filters with
+    "obvious" type conversions, so callers SHOULD use the returned value for
+    Arvados API calls.
+    """
+    if not isinstance(filters, list):
+        raise ValueError(f"filters are not a list: {filters!r}")
+    for index, f in enumerate(filters):
+        if isinstance(f, str):
+            match = FILTER_STR_RE.fullmatch(f)
+            if match is None:
+                raise ValueError(f"filter at index {index} has invalid syntax: {f!r}")
+            s, op, o = match.groups()
+            if s[0].isdigit():
+                raise ValueError(f"filter at index {index} has invalid syntax: bad field name {s!r}")
+            if o[0].isdigit():
+                raise ValueError(f"filter at index {index} has invalid syntax: bad field name {o!r}")
+            continue
+        elif not isinstance(f, list):
+            raise ValueError(f"filter at index {index} is not a string or list: {f!r}")
+        try:
+            s, op, o = f
+        except ValueError:
+            raise ValueError(
+                f"filter at index {index} does not have three items (field name, operator, operand): {f!r}",
+            ) from None
+        if not isinstance(s, str):
+            raise ValueError(f"filter at index {index} field name is not a string: {s!r}")
+        if not isinstance(op, str):
+            raise ValueError(f"filter at index {index} operator is not a string: {op!r}")
+    return filters
+
+
+class JSONArgument:
+    """Parse a JSON file from a command line argument string or path
+
+    JSONArgument objects can be called with a string and return an arbitrary
+    object. First it will try to decode the string as JSON. If that fails, it
+    will try to open a file at the path named by the string, and decode it as
+    JSON. If that fails, it raises ValueError with more detail.
+
+    This is designed to be used as an argparse argument type.
+    Typical usage looks like:
+
+        parser = argparse.ArgumentParser()
+        parser.add_argument('--object', type=JSONArgument(), ...)
+
+    You can construct JSONArgument with an optional validation function. If
+    given, it is called with the object decoded from user input, and its
+    return value replaces it. It should raise ValueError if there is a problem
+    with the input. (argparse turns ValueError into a useful error message.)
+
+        filters_type = JSONArgument(validate_filters)
+        parser.add_argument('--filters', type=filters_type, ...)
+    """
+    def __init__(self, validator=None):
+        self.validator = validator
+
+    def __call__(self, value):
+        try:
+            retval = json.loads(value)
+        except json.JSONDecodeError:
+            try:
+                with open(value, 'rb') as json_file:
+                    retval = json.load(json_file)
+            except json.JSONDecodeError as error:
+                raise ValueError(f"error decoding JSON from file {value!r}: {error}") from None
+            except (FileNotFoundError, ValueError):
+                raise ValueError(f"not a valid JSON string or file path: {value!r}") from None
+            except OSError as error:
+                raise ValueError(f"error reading JSON file path {value!r}: {error.strerror}") from None
+        if self.validator is not None:
+            retval = self.validator(retval)
+        return retval
index 7951842acc6741c52b2669400f7082171c68d377..7f5245db863acd0c9c446ce6328b58f237125a3c 100755 (executable)
@@ -30,11 +30,15 @@ import getpass
 import os
 import re
 import shutil
+import subprocess
 import sys
 import logging
 import tempfile
 import urllib.parse
 import io
+import json
+import queue
+import threading
 
 import arvados
 import arvados.config
@@ -42,9 +46,9 @@ import arvados.keep
 import arvados.util
 import arvados.commands._util as arv_cmd
 import arvados.commands.keepdocker
+import arvados.http_to_keep
 import ruamel.yaml as yaml
 
-from arvados.api import OrderedJsonModel
 from arvados._version import __version__
 
 COMMIT_HASH_RE = re.compile(r'^[0-9a-f]{1,40}$')
@@ -105,6 +109,11 @@ def main():
     copy_opts.add_argument(
         '--storage-classes', dest='storage_classes',
         help='Comma separated list of storage classes to be used when saving data to the destinaton Arvados instance.')
+    copy_opts.add_argument("--varying-url-params", type=str, default="",
+                        help="A comma separated list of URL query parameters that should be ignored when storing HTTP URLs in Keep.")
+
+    copy_opts.add_argument("--prefer-cached-downloads", action="store_true", default=False,
+                        help="If a HTTP URL is found in Keep, skip upstream URL freshness check (will not notice if the upstream has changed, but also not error if upstream is unavailable).")
 
     copy_opts.add_argument(
         'object_uuid',
@@ -125,40 +134,51 @@ def main():
     else:
         logger.setLevel(logging.INFO)
 
-    if not args.source_arvados:
+    if not args.source_arvados and arvados.util.uuid_pattern.match(args.object_uuid):
         args.source_arvados = args.object_uuid[:5]
 
     # Create API clients for the source and destination instances
-    src_arv = api_for_instance(args.source_arvados)
-    dst_arv = api_for_instance(args.destination_arvados)
+    src_arv = api_for_instance(args.source_arvados, args.retries)
+    dst_arv = api_for_instance(args.destination_arvados, args.retries)
 
     if not args.project_uuid:
         args.project_uuid = dst_arv.users().current().execute(num_retries=args.retries)["uuid"]
 
     # Identify the kind of object we have been given, and begin copying.
     t = uuid_type(src_arv, args.object_uuid)
-    if t == 'Collection':
-        set_src_owner_uuid(src_arv.collections(), args.object_uuid, args)
-        result = copy_collection(args.object_uuid,
-                                 src_arv, dst_arv,
-                                 args)
-    elif t == 'Workflow':
-        set_src_owner_uuid(src_arv.workflows(), args.object_uuid, args)
-        result = copy_workflow(args.object_uuid, src_arv, dst_arv, args)
-    elif t == 'Group':
-        set_src_owner_uuid(src_arv.groups(), args.object_uuid, args)
-        result = copy_project(args.object_uuid, src_arv, dst_arv, args.project_uuid, args)
-    else:
-        abort("cannot copy object {} of type {}".format(args.object_uuid, t))
+
+    try:
+        if t == 'Collection':
+            set_src_owner_uuid(src_arv.collections(), args.object_uuid, args)
+            result = copy_collection(args.object_uuid,
+                                     src_arv, dst_arv,
+                                     args)
+        elif t == 'Workflow':
+            set_src_owner_uuid(src_arv.workflows(), args.object_uuid, args)
+            result = copy_workflow(args.object_uuid, src_arv, dst_arv, args)
+        elif t == 'Group':
+            set_src_owner_uuid(src_arv.groups(), args.object_uuid, args)
+            result = copy_project(args.object_uuid, src_arv, dst_arv, args.project_uuid, args)
+        elif t == 'httpURL':
+            result = copy_from_http(args.object_uuid, src_arv, dst_arv, args)
+        else:
+            abort("cannot copy object {} of type {}".format(args.object_uuid, t))
+    except Exception as e:
+        logger.error("%s", e, exc_info=args.verbose)
+        exit(1)
 
     # Clean up any outstanding temp git repositories.
     for d in listvalues(local_repo_dir):
         shutil.rmtree(d, ignore_errors=True)
 
+    if not result:
+        exit(1)
+
     # If no exception was thrown and the response does not have an
     # error_token field, presume success
-    if 'error_token' in result or 'uuid' not in result:
-        logger.error("API server returned an error result: {}".format(result))
+    if result is None or 'error_token' in result or 'uuid' not in result:
+        if result:
+            logger.error("API server returned an error result: {}".format(result))
         exit(1)
 
     print(result['uuid'])
@@ -187,10 +207,10 @@ def set_src_owner_uuid(resource, uuid, args):
 #     Otherwise, it is presumed to be the name of a file in
 #     $HOME/.config/arvados/instance_name.conf
 #
-def api_for_instance(instance_name):
+def api_for_instance(instance_name, num_retries):
     if not instance_name:
         # Use environment
-        return arvados.api('v1', model=OrderedJsonModel())
+        return arvados.api('v1')
 
     if '/' in instance_name:
         config_file = instance_name
@@ -214,7 +234,8 @@ def api_for_instance(instance_name):
                              host=cfg['ARVADOS_API_HOST'],
                              token=cfg['ARVADOS_API_TOKEN'],
                              insecure=api_is_insecure,
-                             model=OrderedJsonModel())
+                             num_retries=num_retries,
+                             )
     else:
         abort('need ARVADOS_API_HOST and ARVADOS_API_TOKEN for {}'.format(instance_name))
     return client
@@ -222,8 +243,12 @@ def api_for_instance(instance_name):
 # Check if git is available
 def check_git_availability():
     try:
-        arvados.util.run_command(['git', '--help'])
-    except Exception:
+        subprocess.run(
+            ['git', '--version'],
+            check=True,
+            stdout=subprocess.DEVNULL,
+        )
+    except FileNotFoundError:
         abort('git command is not available. Please ensure git is installed.')
 
 
@@ -302,21 +327,26 @@ def copy_workflow(wf_uuid, src, dst, args):
 
     # copy collections and docker images
     if args.recursive and wf["definition"]:
-        wf_def = yaml.safe_load(wf["definition"])
-        if wf_def is not None:
-            locations = []
-            docker_images = {}
-            graph = wf_def.get('$graph', None)
-            if graph is not None:
-                workflow_collections(graph, locations, docker_images)
-            else:
-                workflow_collections(wf_def, locations, docker_images)
+        env = {"ARVADOS_API_HOST": urllib.parse.urlparse(src._rootDesc["rootUrl"]).netloc,
+               "ARVADOS_API_TOKEN": src.api_token,
+               "PATH": os.environ["PATH"]}
+        try:
+            result = subprocess.run(["arvados-cwl-runner", "--quiet", "--print-keep-deps", "arvwf:"+wf_uuid],
+                                    capture_output=True, env=env)
+        except FileNotFoundError:
+            no_arv_copy = True
+        else:
+            no_arv_copy = result.returncode == 2
+
+        if no_arv_copy:
+            raise Exception('Copying workflows requires arvados-cwl-runner 2.7.1 or later to be installed in PATH.')
+        elif result.returncode != 0:
+            raise Exception('There was an error getting Keep dependencies from workflow using arvados-cwl-runner --print-keep-deps')
 
-            if locations:
-                copy_collections(locations, src, dst, args)
+        locations = json.loads(result.stdout)
 
-            for image in docker_images:
-                copy_docker_image(image, docker_images[image], src, dst, args)
+        if locations:
+            copy_collections(locations, src, dst, args)
 
     # copy the workflow itself
     del wf['uuid']
@@ -560,6 +590,125 @@ def copy_collection(obj_uuid, src, dst, args):
     else:
         progress_writer = None
 
+    # go through the words
+    # put each block loc into 'get' queue
+    # 'get' threads get block and put it into 'put' queue
+    # 'put' threads put block and then update dst_locators
+    #
+    # after going through the whole manifest we go back through it
+    # again and build dst_manifest
+
+    lock = threading.Lock()
+
+    # the get queue should be unbounded because we'll add all the
+    # block hashes we want to get, but these are small
+    get_queue = queue.Queue()
+
+    threadcount = 4
+
+    # the put queue contains full data blocks
+    # and if 'get' is faster than 'put' we could end up consuming
+    # a great deal of RAM if it isn't bounded.
+    put_queue = queue.Queue(threadcount)
+    transfer_error = []
+
+    def get_thread():
+        while True:
+            word = get_queue.get()
+            if word is None:
+                put_queue.put(None)
+                get_queue.task_done()
+                return
+
+            blockhash = arvados.KeepLocator(word).md5sum
+            with lock:
+                if blockhash in dst_locators:
+                    # Already uploaded
+                    get_queue.task_done()
+                    continue
+
+            try:
+                logger.debug("Getting block %s", word)
+                data = src_keep.get(word)
+                put_queue.put((word, data))
+            except e:
+                logger.error("Error getting block %s: %s", word, e)
+                transfer_error.append(e)
+                try:
+                    # Drain the 'get' queue so we end early
+                    while True:
+                        get_queue.get(False)
+                        get_queue.task_done()
+                except queue.Empty:
+                    pass
+            finally:
+                get_queue.task_done()
+
+    def put_thread():
+        nonlocal bytes_written
+        while True:
+            item = put_queue.get()
+            if item is None:
+                put_queue.task_done()
+                return
+
+            word, data = item
+            loc = arvados.KeepLocator(word)
+            blockhash = loc.md5sum
+            with lock:
+                if blockhash in dst_locators:
+                    # Already uploaded
+                    put_queue.task_done()
+                    continue
+
+            try:
+                logger.debug("Putting block %s (%s bytes)", blockhash, loc.size)
+                dst_locator = dst_keep.put(data, classes=(args.storage_classes or []))
+                with lock:
+                    dst_locators[blockhash] = dst_locator
+                    bytes_written += loc.size
+                    if progress_writer:
+                        progress_writer.report(obj_uuid, bytes_written, bytes_expected)
+            except e:
+                logger.error("Error putting block %s (%s bytes): %s", blockhash, loc.size, e)
+                try:
+                    # Drain the 'get' queue so we end early
+                    while True:
+                        get_queue.get(False)
+                        get_queue.task_done()
+                except queue.Empty:
+                    pass
+                transfer_error.append(e)
+            finally:
+                put_queue.task_done()
+
+    for line in manifest.splitlines():
+        words = line.split()
+        for word in words[1:]:
+            try:
+                loc = arvados.KeepLocator(word)
+            except ValueError:
+                # If 'word' can't be parsed as a locator,
+                # presume it's a filename.
+                continue
+
+            get_queue.put(word)
+
+    for i in range(0, threadcount):
+        get_queue.put(None)
+
+    for i in range(0, threadcount):
+        threading.Thread(target=get_thread, daemon=True).start()
+
+    for i in range(0, threadcount):
+        threading.Thread(target=put_thread, daemon=True).start()
+
+    get_queue.join()
+    put_queue.join()
+
+    if len(transfer_error) > 0:
+        return {"error_token": "Failed to transfer blocks"}
+
     for line in manifest.splitlines():
         words = line.split()
         dst_manifest.write(words[0])
@@ -573,16 +722,6 @@ def copy_collection(obj_uuid, src, dst, args):
                 dst_manifest.write(word)
                 continue
             blockhash = loc.md5sum
-            # copy this block if we haven't seen it before
-            # (otherwise, just reuse the existing dst_locator)
-            if blockhash not in dst_locators:
-                logger.debug("Copying block %s (%s bytes)", blockhash, loc.size)
-                if progress_writer:
-                    progress_writer.report(obj_uuid, bytes_written, bytes_expected)
-                data = src_keep.get(word)
-                dst_locator = dst_keep.put(data, classes=(args.storage_classes or []))
-                dst_locators[blockhash] = dst_locator
-                bytes_written += loc.size
             dst_manifest.write(' ')
             dst_manifest.write(dst_locators[blockhash])
         dst_manifest.write("\n")
@@ -610,8 +749,6 @@ def select_git_url(api, repo_name, retries, allow_insecure_http, allow_insecure_
 
     priority = https_url + other_url + http_url
 
-    git_config = []
-    git_url = None
     for url in priority:
         if url.startswith("http"):
             u = urllib.parse.urlsplit(url)
@@ -623,17 +760,22 @@ def select_git_url(api, repo_name, retries, allow_insecure_http, allow_insecure_
 
         try:
             logger.debug("trying %s", url)
-            arvados.util.run_command(["git"] + git_config + ["ls-remote", url],
-                                      env={"HOME": os.environ["HOME"],
-                                           "ARVADOS_API_TOKEN": api.api_token,
-                                           "GIT_ASKPASS": "/bin/false"})
-        except arvados.errors.CommandFailedError:
+            subprocess.run(
+                ['git', *git_config, 'ls-remote', url],
+                check=True,
+                env={
+                    'ARVADOS_API_TOKEN': api.api_token,
+                    'GIT_ASKPASS': '/bin/false',
+                    'HOME': os.environ['HOME'],
+                },
+                stdout=subprocess.DEVNULL,
+            )
+        except subprocess.CalledProcessError:
             pass
         else:
             git_url = url
             break
-
-    if not git_url:
+    else:
         raise Exception('Cannot access git repository, tried {}'
                         .format(priority))
 
@@ -696,20 +838,20 @@ def copy_project(obj_uuid, src, dst, owner_uuid, args):
 
     # Copy collections
     try:
-        copy_collections([col["uuid"] for col in arvados.util.list_all(src.collections().list, filters=[["owner_uuid", "=", obj_uuid]])],
+        copy_collections([col["uuid"] for col in arvados.util.keyset_list_all(src.collections().list, filters=[["owner_uuid", "=", obj_uuid]])],
                          src, dst, args)
     except Exception as e:
         partial_error += "\n" + str(e)
 
     # Copy workflows
-    for w in arvados.util.list_all(src.workflows().list, filters=[["owner_uuid", "=", obj_uuid]]):
+    for w in arvados.util.keyset_list_all(src.workflows().list, filters=[["owner_uuid", "=", obj_uuid]]):
         try:
             copy_workflow(w["uuid"], src, dst, args)
         except Exception as e:
             partial_error += "\n" + "Error while copying %s: %s" % (w["uuid"], e)
 
     if args.recursive:
-        for g in arvados.util.list_all(src.groups().list, filters=[["owner_uuid", "=", obj_uuid]]):
+        for g in arvados.util.keyset_list_all(src.groups().list, filters=[["owner_uuid", "=", obj_uuid]]):
             try:
                 copy_project(g["uuid"], src, dst, project_record["uuid"], args)
             except Exception as e:
@@ -726,9 +868,14 @@ def copy_project(obj_uuid, src, dst, owner_uuid, args):
 #    repository)
 #
 def git_rev_parse(rev, repo):
-    gitout, giterr = arvados.util.run_command(
-        ['git', 'rev-parse', rev], cwd=repo)
-    return gitout.strip()
+    proc = subprocess.run(
+        ['git', 'rev-parse', rev],
+        check=True,
+        cwd=repo,
+        stdout=subprocess.PIPE,
+        text=True,
+    )
+    return proc.stdout.read().strip()
 
 # uuid_type(api, object_uuid)
 #
@@ -743,6 +890,10 @@ def git_rev_parse(rev, repo):
 def uuid_type(api, object_uuid):
     if re.match(arvados.util.keep_locator_pattern, object_uuid):
         return 'Collection'
+
+    if object_uuid.startswith("http:") or object_uuid.startswith("https:"):
+        return 'httpURL'
+
     p = object_uuid.split('-')
     if len(p) == 3:
         type_prefix = p[1]
@@ -752,6 +903,27 @@ def uuid_type(api, object_uuid):
                 return k
     return None
 
+
+def copy_from_http(url, src, dst, args):
+
+    project_uuid = args.project_uuid
+    varying_url_params = args.varying_url_params
+    prefer_cached_downloads = args.prefer_cached_downloads
+
+    cached = arvados.http_to_keep.check_cached_url(src, project_uuid, url, {},
+                                                   varying_url_params=varying_url_params,
+                                                   prefer_cached_downloads=prefer_cached_downloads)
+    if cached[2] is not None:
+        return copy_collection(cached[2], src, dst, args)
+
+    cached = arvados.http_to_keep.http_to_keep(dst, project_uuid, url,
+                                               varying_url_params=varying_url_params,
+                                               prefer_cached_downloads=prefer_cached_downloads)
+
+    if cached is not None:
+        return {"uuid": cached[2]}
+
+
 def abort(msg, code=1):
     logger.info("arv-copy: %s", msg)
     exit(code)
index 5c1bb29e764c549d1612bb13f2890a076866fc74..770e1609db6ec60dc39567678986c53f3a6f5a35 100755 (executable)
@@ -24,6 +24,7 @@ import os
 import hashlib
 import re
 from arvados._version import __version__
+from . import _util as arv_cmd
 
 EMAIL=0
 USERNAME=1
@@ -43,10 +44,10 @@ def connect_clusters(args):
                 host = r[0]
                 token = r[1]
                 print("Contacting %s" % (host))
-                arv = arvados.api(host=host, token=token, cache=False)
+                arv = arvados.api(host=host, token=token, cache=False, num_retries=args.retries)
                 clusters[arv._rootDesc["uuidPrefix"]] = arv
     else:
-        arv = arvados.api(cache=False)
+        arv = arvados.api(cache=False, num_retries=args.retries)
         rh = arv._rootDesc["remoteHosts"]
         tok = arv.api_client_authorizations().current().execute()
         token = "v2/%s/%s" % (tok["uuid"], tok["api_token"])
@@ -96,13 +97,12 @@ def fetch_users(clusters, loginCluster):
     by_email = {}
     by_username = {}
 
-    users = []
-    for c, arv in clusters.items():
-        print("Getting user list from %s" % c)
-        ul = arvados.util.list_all(arv.users().list, bypass_federation=True)
-        for l in ul:
-            if l["uuid"].startswith(c):
-                users.append(l)
+    users = [
+        user
+        for prefix, arv in clusters.items()
+        for user in arvados.util.keyset_list_all(arv.users().list, bypass_federation=True)
+        if user['uuid'].startswith(prefix)
+    ]
 
     # Users list is sorted by email
     # Go through users and collect users with same email
@@ -110,7 +110,7 @@ def fetch_users(clusters, loginCluster):
     # call add_accum_rows() to generate the report rows with
     # the "home cluster" set, and also fill in the by_email table.
 
-    users = sorted(users, key=lambda u: u["email"]+"::"+(u["username"] or "")+"::"+u["uuid"])
+    users.sort(key=lambda u: (u["email"], u["username"] or "", u["uuid"]))
 
     accum = []
     lastemail = None
@@ -326,7 +326,10 @@ def migrate_user(args, migratearv, email, new_user_uuid, old_user_uuid):
 
 
 def main():
-    parser = argparse.ArgumentParser(description='Migrate users to federated identity, see https://doc.arvados.org/admin/merge-remote-account.html')
+    parser = argparse.ArgumentParser(
+        description='Migrate users to federated identity, see https://doc.arvados.org/admin/merge-remote-account.html',
+        parents=[arv_cmd.retry_opt],
+    )
     parser.add_argument(
         '--version', action='version', version="%s %s" % (sys.argv[0], __version__),
         help='Print version and exit.')
index bb421def618cddd36ba7d2241e2b1e81b58581ac..b37a8477acb1606e72516b1401b4a0fc5c718b60 100755 (executable)
@@ -6,6 +6,7 @@
 import argparse
 import hashlib
 import os
+import pathlib
 import re
 import string
 import sys
@@ -155,7 +156,7 @@ def main(arguments=None, stdout=sys.stdout, stderr=sys.stderr):
     request_id = arvados.util.new_request_id()
     logger.info('X-Request-Id: '+request_id)
 
-    api_client = arvados.api('v1', request_id=request_id)
+    api_client = arvados.api('v1', request_id=request_id, num_retries=args.retries)
 
     r = re.search(r'^(.*?)(/.*)?$', args.locator)
     col_loc = r.group(1)
@@ -197,8 +198,7 @@ def main(arguments=None, stdout=sys.stdout, stderr=sys.stderr):
     try:
         reader = arvados.CollectionReader(
             col_loc, api_client=api_client, num_retries=args.retries,
-            keep_client=arvados.keep.KeepClient(block_cache=arvados.keep.KeepBlockCache((args.threads+1)*64 * 1024 * 1024)),
-            get_threads=args.threads)
+            keep_client=arvados.keep.KeepClient(block_cache=arvados.keep.KeepBlockCache((args.threads+1)*64 * 1024 * 1024), num_prefetch_threads=args.threads))
     except Exception as error:
         logger.error("failed to read collection: {}".format(error))
         return 1
@@ -262,7 +262,7 @@ def main(arguments=None, stdout=sys.stdout, stderr=sys.stderr):
                     logger.error('Local file %s already exists.' % (outfilename,))
                     return 1
                 if args.r:
-                    arvados.util.mkdir_dash_p(os.path.dirname(outfilename))
+                    pathlib.Path(outfilename).parent.mkdir(parents=True, exist_ok=True)
                 try:
                     outfile = open(outfilename, 'wb')
                 except Exception as error:
index 2d5c0150c995826f4b4d8193e220edabad45014a..6823ee1beada080526c9a9aa901d752e7b5aefd9 100644 (file)
@@ -2,37 +2,29 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from builtins import next
 import argparse
 import collections
 import datetime
 import errno
+import fcntl
 import json
+import logging
 import os
 import re
+import subprocess
 import sys
 import tarfile
 import tempfile
-import shutil
-import _strptime
-import fcntl
+
+import ciso8601
 from operator import itemgetter
 from stat import *
 
-if os.name == "posix" and sys.version_info[0] < 3:
-    import subprocess32 as subprocess
-else:
-    import subprocess
-
 import arvados
+import arvados.config
 import arvados.util
 import arvados.commands._util as arv_cmd
 import arvados.commands.put as arv_put
-from arvados.collection import CollectionReader
-import ciso8601
-import logging
-import arvados.config
-
 from arvados._version import __version__
 
 logger = logging.getLogger('arvados.keepdocker')
@@ -240,8 +232,9 @@ def docker_link_sort_key(link):
     return (image_timestamp, created_timestamp)
 
 def _get_docker_links(api_client, num_retries, **kwargs):
-    links = arvados.util.list_all(api_client.links().list,
-                                  num_retries, **kwargs)
+    links = list(arvados.util.keyset_list_all(
+        api_client.links().list, num_retries=num_retries, **kwargs,
+    ))
     for link in links:
         link['_sort_key'] = docker_link_sort_key(link)
     links.sort(key=itemgetter('_sort_key'), reverse=True)
@@ -340,10 +333,12 @@ def list_images_in_arv(api_client, num_retries, image_name=None, image_tag=None,
         images.sort(key=itemgetter('_sort_key'), reverse=True)
 
     # Remove any image listings that refer to unknown collections.
-    existing_coll_uuids = {coll['uuid'] for coll in arvados.util.list_all(
-            api_client.collections().list, num_retries,
-            filters=[['uuid', 'in', [im['collection'] for im in images]]]+project_filter,
-            select=['uuid'])}
+    existing_coll_uuids = {coll['uuid'] for coll in arvados.util.keyset_list_all(
+        api_client.collections().list,
+        num_retries=num_retries,
+        filters=[['uuid', 'in', [im['collection'] for im in images]]]+project_filter,
+        select=['uuid'],
+    )}
     return [(image['collection'], image) for image in images
             if image['collection'] in existing_coll_uuids]
 
@@ -356,10 +351,29 @@ def _uuid2pdh(api, uuid):
         select=['portable_data_hash'],
     ).execute()['items'][0]['portable_data_hash']
 
+def load_image_metadata(image_file):
+    """Load an image manifest and config from an archive
+
+    Given an image archive as an open binary file object, this function loads
+    the image manifest and configuration, deserializing each from JSON and
+    returning them in a 2-tuple of dicts.
+    """
+    image_file.seek(0)
+    with tarfile.open(fileobj=image_file) as image_tar:
+        with image_tar.extractfile('manifest.json') as manifest_file:
+            image_manifest_list = json.load(manifest_file)
+        # Because arv-keepdocker only saves one image, there should only be
+        # one manifest.  This extracts that from the list and raises
+        # ValueError if there's not exactly one.
+        image_manifest, = image_manifest_list
+        with image_tar.extractfile(image_manifest['Config']) as config_file:
+            image_config = json.load(config_file)
+    return image_manifest, image_config
+
 def main(arguments=None, stdout=sys.stdout, install_sig_handlers=True, api=None):
     args = arg_parser.parse_args(arguments)
     if api is None:
-        api = arvados.api('v1')
+        api = arvados.api('v1', num_retries=args.retries)
 
     if args.image is None or args.image == 'images':
         fmt = "{:30}  {:10}  {:12}  {:29}  {:20}\n"
@@ -532,21 +546,9 @@ def main(arguments=None, stdout=sys.stdout, install_sig_handlers=True, api=None)
         # Managed properties could be already set
         coll_properties = api.collections().get(uuid=coll_uuid).execute(num_retries=args.retries).get('properties', {})
         coll_properties.update({"docker-image-repo-tag": image_repo_tag})
-
         api.collections().update(uuid=coll_uuid, body={"properties": coll_properties}).execute(num_retries=args.retries)
 
-        # Read the image metadata and make Arvados links from it.
-        image_file.seek(0)
-        image_tar = tarfile.open(fileobj=image_file)
-        image_hash_type, _, raw_image_hash = image_hash.rpartition(':')
-        if image_hash_type:
-            json_filename = raw_image_hash + '.json'
-        else:
-            json_filename = raw_image_hash + '/json'
-        json_file = image_tar.extractfile(image_tar.getmember(json_filename))
-        image_metadata = json.loads(json_file.read().decode('utf-8'))
-        json_file.close()
-        image_tar.close()
+        _, image_metadata = load_image_metadata(image_file)
         link_base = {'head_uuid': coll_uuid, 'properties': {}}
         if 'created' in image_metadata:
             link_base['properties']['image_timestamp'] = image_metadata['created']
index 86e728ed4978cb6dcbc55dc167d074af31000aed..ac038f5040a8081ecd4dabfe38ed36eef289bca0 100644 (file)
@@ -43,7 +43,7 @@ def main(args, stdout, stderr, api_client=None, logger=None):
     args = parse_args(args)
 
     if api_client is None:
-        api_client = arvados.api('v1')
+        api_client = arvados.api('v1', num_retries=args.retries)
 
     if logger is None:
         logger = logging.getLogger('arvados.arv-ls')
index 3ce47b20660bc68c51833b981cdfdda17c6672e8..2fef419ee8e66863a6a7cf92e985bf741f267d56 100644 (file)
@@ -18,6 +18,7 @@ import arvados
 import arvados.commands.keepdocker
 from arvados._version import __version__
 from arvados.collection import CollectionReader
+from .. import util
 
 logger = logging.getLogger('arvados.migrate-docker19')
 logger.setLevel(logging.DEBUG if arvados.config.get('ARVADOS_DEBUG')
@@ -29,6 +30,7 @@ _migration_link_name = 'migrate_1.9_1.10'
 class MigrationFailed(Exception):
     pass
 
+@util._deprecated('3.0')
 def main(arguments=None):
     """Docker image format migration tool for Arvados.
 
index be7cd629c98cfbac0ff36be2ce10c4de2c30cf2e..0e732eafde87223a3b3c3522f4f02e089324d711 100644 (file)
@@ -1136,7 +1136,7 @@ def main(arguments=None, stdout=sys.stdout, stderr=sys.stderr,
     logging.getLogger('arvados').handlers[0].setFormatter(formatter)
 
     if api_client is None:
-        api_client = arvados.api('v1', request_id=request_id)
+        api_client = arvados.api('v1', request_id=request_id, num_retries=args.retries)
 
     if install_sig_handlers:
         arv_cmd.install_signal_handlers()
index 37dab55d60351b69bf97980f1dd9fa1376e4303b..04a90cf20b8b1556819a47166c37f44d66b0dfd6 100644 (file)
@@ -10,12 +10,13 @@ import arvados
 import json
 from arvados.events import subscribe
 from arvados._version import __version__
+from . import _util as arv_cmd
 import signal
 
 def main(arguments=None):
     logger = logging.getLogger('arvados.arv-ws')
 
-    parser = argparse.ArgumentParser()
+    parser = argparse.ArgumentParser(parents=[arv_cmd.retry_opt])
     parser.add_argument('--version', action='version',
                         version="%s %s" % (sys.argv[0], __version__),
                         help='Print version and exit.')
@@ -56,7 +57,7 @@ def main(arguments=None):
             filters = new_filters
             known_component_jobs = pipeline_jobs
 
-    api = arvados.api('v1')
+    api = arvados.api('v1', num_retries=args.retries)
 
     if args.uuid:
         filters += [ ['object_uuid', '=', args.uuid] ]
index e17eb1ff57dd13b29faacb412eb15d06bbb5503d..6f3bd027901181a5e68e79218d383dec4d13c32a 100644 (file)
@@ -38,9 +38,7 @@ def load(config_file):
     cfg = {}
     with open(config_file, "r") as f:
         for config_line in f:
-            if re.match('^\s*$', config_line):
-                continue
-            if re.match('^\s*#', config_line):
+            if re.match(r'^\s*(?:#|$)', config_line):
                 continue
             var, val = config_line.rstrip().split('=', 2)
             cfg[var] = val
index 70b8b440338b319c54abf2b96cc477ed90586549..6dd144c43b4c60226724d7c5348dcfc42e747e1f 100644 (file)
@@ -5,6 +5,7 @@
 from builtins import object
 import json
 import os
+from . import util
 
 class TaskOutputDir(object):
     """Keep-backed directory for staging outputs of Crunch tasks.
@@ -21,6 +22,7 @@ class TaskOutputDir(object):
             f.write('42')
         arvados.current_task().set_output(out.manifest_text())
     """
+    @util._deprecated('3.0', 'arvados-cwl-runner or the containers API')
     def __init__(self):
         self.path = os.environ['TASK_KEEPMOUNT_TMP']
 
index f8fca5780332e41ec1f894759b27df5c0bffd1a1..528a7d28b58146af1a33eac0ada4b746a9eaa12d 100644 (file)
@@ -13,6 +13,7 @@ import time
 import errno
 import logging
 import weakref
+import collections
 
 _logger = logging.getLogger('arvados.keep')
 
@@ -31,6 +32,15 @@ class DiskCacheSlot(object):
 
     def get(self):
         self.ready.wait()
+        # 'content' can None, an empty byte string, or a nonempty mmap
+        # region.  If it is an mmap region, we want to advise the
+        # kernel we're going to use it.  This nudges the kernel to
+        # re-read most or all of the block if necessary (instead of
+        # just a few pages at a time), reducing the number of page
+        # faults and improving performance by 4x compared to not
+        # calling madvise.
+        if self.content:
+            self.content.madvise(mmap.MADV_WILLNEED)
         return self.content
 
     def set(self, value):
@@ -39,18 +49,18 @@ class DiskCacheSlot(object):
             if value is None:
                 self.content = None
                 self.ready.set()
-                return
+                return False
 
             if len(value) == 0:
                 # Can't mmap a 0 length file
                 self.content = b''
                 self.ready.set()
-                return
+                return True
 
             if self.content is not None:
                 # Has been set already
                 self.ready.set()
-                return
+                return False
 
             blockdir = os.path.join(self.cachedir, self.locator[0:3])
             os.makedirs(blockdir, mode=0o700, exist_ok=True)
@@ -73,6 +83,7 @@ class DiskCacheSlot(object):
             self.content = mmap.mmap(self.filehandle.fileno(), 0, access=mmap.ACCESS_READ)
             # only set the event when mmap is successful
             self.ready.set()
+            return True
         finally:
             if tmpfile is not None:
                 # If the tempfile hasn't been renamed on disk yet, try to delete it.
@@ -95,65 +106,61 @@ class DiskCacheSlot(object):
             return len(self.content)
 
     def evict(self):
-        if self.content is not None and len(self.content) > 0:
-            # The mmap region might be in use when we decided to evict
-            # it.  This can happen if the cache is too small.
-            #
-            # If we call close() now, it'll throw an error if
-            # something tries to access it.
-            #
-            # However, we don't need to explicitly call mmap.close()
-            #
-            # I confirmed in mmapmodule.c that that both close
-            # and deallocate do the same thing:
+        if not self.content:
+            return
+
+        # The mmap region might be in use when we decided to evict
+        # it.  This can happen if the cache is too small.
+        #
+        # If we call close() now, it'll throw an error if
+        # something tries to access it.
+        #
+        # However, we don't need to explicitly call mmap.close()
+        #
+        # I confirmed in mmapmodule.c that that both close
+        # and deallocate do the same thing:
+        #
+        # a) close the file descriptor
+        # b) unmap the memory range
+        #
+        # So we can forget it in the cache and delete the file on
+        # disk, and it will tear it down after any other
+        # lingering Python references to the mapped memory are
+        # gone.
+
+        blockdir = os.path.join(self.cachedir, self.locator[0:3])
+        final = os.path.join(blockdir, self.locator) + cacheblock_suffix
+        try:
+            fcntl.flock(self.filehandle, fcntl.LOCK_UN)
+
+            # try to get an exclusive lock, this ensures other
+            # processes are not using the block.  It is
+            # nonblocking and will throw an exception if we
+            # can't get it, which is fine because that means
+            # we just won't try to delete it.
             #
-            # a) close the file descriptor
-            # b) unmap the memory range
+            # I should note here, the file locking is not
+            # strictly necessary, we could just remove it and
+            # the kernel would ensure that the underlying
+            # inode remains available as long as other
+            # processes still have the file open.  However, if
+            # you have multiple processes sharing the cache
+            # and deleting each other's files, you'll end up
+            # with a bunch of ghost files that don't show up
+            # in the file system but are still taking up
+            # space, which isn't particularly user friendly.
+            # The locking strategy ensures that cache blocks
+            # in use remain visible.
             #
-            # So we can forget it in the cache and delete the file on
-            # disk, and it will tear it down after any other
-            # lingering Python references to the mapped memory are
-            # gone.
-
-            blockdir = os.path.join(self.cachedir, self.locator[0:3])
-            final = os.path.join(blockdir, self.locator) + cacheblock_suffix
-            try:
-                fcntl.flock(self.filehandle, fcntl.LOCK_UN)
-
-                # try to get an exclusive lock, this ensures other
-                # processes are not using the block.  It is
-                # nonblocking and will throw an exception if we
-                # can't get it, which is fine because that means
-                # we just won't try to delete it.
-                #
-                # I should note here, the file locking is not
-                # strictly necessary, we could just remove it and
-                # the kernel would ensure that the underlying
-                # inode remains available as long as other
-                # processes still have the file open.  However, if
-                # you have multiple processes sharing the cache
-                # and deleting each other's files, you'll end up
-                # with a bunch of ghost files that don't show up
-                # in the file system but are still taking up
-                # space, which isn't particularly user friendly.
-                # The locking strategy ensures that cache blocks
-                # in use remain visible.
-                #
-                fcntl.flock(self.filehandle, fcntl.LOCK_EX | fcntl.LOCK_NB)
-
-                os.remove(final)
-                return True
-            except OSError:
-                pass
-            finally:
-                self.filehandle = None
-                self.linger = weakref.ref(self.content)
-                self.content = None
-        return False
+            fcntl.flock(self.filehandle, fcntl.LOCK_EX | fcntl.LOCK_NB)
 
-    def gone(self):
-        # Test if an evicted object is lingering
-        return self.content is None and (self.linger is None or self.linger() is None)
+            os.remove(final)
+            return True
+        except OSError:
+            pass
+        finally:
+            self.filehandle = None
+            self.content = None
 
     @staticmethod
     def get_from_disk(locator, cachedir):
@@ -237,13 +244,13 @@ class DiskCacheSlot(object):
 
         # Map in all the files we found, up to maxslots, if we exceed
         # maxslots, start throwing things out.
-        cachelist = []
+        cachelist: collections.OrderedDict = collections.OrderedDict()
         for b in blocks:
             got = DiskCacheSlot.get_from_disk(b[0], cachedir)
             if got is None:
                 continue
             if len(cachelist) < maxslots:
-                cachelist.append(got)
+                cachelist[got.locator] = got
             else:
                 # we found more blocks than maxslots, try to
                 # throw it out of the cache.
index e53e4980a86f01a595649331d020c6b87e823e6a..88a916e659e54643468536a11c94474ccb2ee3d0 100644 (file)
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+"""Follow events on an Arvados cluster
 
-from __future__ import absolute_import
-from future import standard_library
-standard_library.install_aliases()
-from builtins import str
-from builtins import object
-import arvados
-from . import config
-from . import errors
-from .retry import RetryLoop
+This module provides different ways to get notified about events that happen
+on an Arvados cluster. You indicate which events you want updates about, and
+provide a function that is called any time one of those events is received
+from the server.
 
-import logging
+`subscribe` is the main entry point. It helps you construct one of the two
+API-compatible client classes: `EventClient` (which uses WebSockets) or
+`PollClient` (which periodically queries the logs list methods).
+"""
+
+import enum
 import json
-import _thread
-import threading
-import time
+import logging
 import os
 import re
 import ssl
-from ws4py.client.threadedclient import WebSocketClient
+import sys
+import _thread
+import threading
+import time
+
+import websockets.exceptions as ws_exc
+import websockets.sync.client as ws_client
+
+from . import config
+from . import errors
+from . import util
+from .retry import RetryLoop
+from ._version import __version__
+
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    Union,
+)
+
+EventCallback = Callable[[Dict[str, Any]], object]
+"""Type signature for an event handler callback"""
+FilterCondition = List[Union[None, str, 'Filter']]
+"""Type signature for a single filter condition"""
+Filter = List[FilterCondition]
+"""Type signature for an entire filter"""
 
 _logger = logging.getLogger('arvados.events')
 
+class WSMethod(enum.Enum):
+    """Arvados WebSocket methods
 
-class _EventClient(WebSocketClient):
-    def __init__(self, url, filters, on_event, last_log_id, on_closed):
-        ssl_options = {'ca_certs': arvados.util.ca_certs_path()}
-        if config.flag_is_true('ARVADOS_API_HOST_INSECURE'):
-            ssl_options['cert_reqs'] = ssl.CERT_NONE
-        else:
-            ssl_options['cert_reqs'] = ssl.CERT_REQUIRED
+    This enum represents valid values for the `method` field in messages
+    sent to an Arvados WebSocket server.
+    """
+    SUBSCRIBE = 'subscribe'
+    SUB = SUBSCRIBE
+    UNSUBSCRIBE = 'unsubscribe'
+    UNSUB = UNSUBSCRIBE
 
-        # Warning: If the host part of url resolves to both IPv6 and
-        # IPv4 addresses (common with "localhost"), only one of them
-        # will be attempted -- and it might not be the right one. See
-        # ws4py's WebSocketBaseClient.__init__.
-        super(_EventClient, self).__init__(url, ssl_options=ssl_options)
 
-        self.filters = filters
-        self.on_event = on_event
+class EventClient(threading.Thread):
+    """Follow Arvados events via WebSocket
+
+    EventClient follows events on Arvados cluster published by the WebSocket
+    server. Users can select the events they want to follow and run their own
+    callback function on each.
+    """
+    _USER_AGENT = 'Python/{}.{}.{} arvados.events/{}'.format(
+        *sys.version_info[:3],
+        __version__,
+    )
+
+    def __init__(
+            self,
+            url: str,
+            filters: Optional[Filter],
+            on_event_cb: EventCallback,
+            last_log_id: Optional[int]=None,
+            *,
+            insecure: Optional[bool]=None,
+    ) -> None:
+        """Initialize a WebSocket client
+
+        Constructor arguments:
+
+        * url: str --- The `wss` URL for an Arvados WebSocket server.
+
+        * filters: arvados.events.Filter | None --- One event filter to
+          subscribe to after connecting to the WebSocket server. If not
+          specified, the client will subscribe to all events.
+
+        * on_event_cb: arvados.events.EventCallback --- When the client
+          receives an event from the WebSocket server, it calls this
+          function with the event object.
+
+        * last_log_id: int | None --- If specified, this will be used as the
+          value for the `last_log_id` field in subscribe messages sent by
+          the client.
+
+        Constructor keyword arguments:
+
+        * insecure: bool | None --- If `True`, the client will not check the
+          validity of the server's TLS certificate. If not specified, uses
+          the value from the user's `ARVADOS_API_HOST_INSECURE` setting.
+        """
+        self.url = url
+        self.filters = [filters or []]
+        self.on_event_cb = on_event_cb
         self.last_log_id = last_log_id
-        self._closing_lock = threading.RLock()
-        self._closing = False
-        self._closed = threading.Event()
-        self.on_closed = on_closed
+        self.is_closed = threading.Event()
+        self._ssl_ctx = ssl.create_default_context(
+            purpose=ssl.Purpose.SERVER_AUTH,
+            cafile=util.ca_certs_path(),
+        )
+        if insecure is None:
+            insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE')
+        if insecure:
+            self._ssl_ctx.check_hostname = False
+            self._ssl_ctx.verify_mode = ssl.CERT_NONE
+        self._subscribe_lock = threading.Lock()
+        self._connect()
+        super().__init__(daemon=True)
+        self.start()
+
+    def _connect(self) -> None:
+        # There are no locks protecting this method. After the thread starts,
+        # it should only be called from inside.
+        self._client = ws_client.connect(
+            self.url,
+            logger=_logger,
+            ssl_context=self._ssl_ctx,
+            user_agent_header=self._USER_AGENT,
+        )
+        self._client_ok = True
+
+    def _subscribe(self, f: Filter, last_log_id: Optional[int]) -> None:
+        extra = {}
+        if last_log_id is not None:
+            extra['last_log_id'] = last_log_id
+        return self._update_sub(WSMethod.SUBSCRIBE, f, **extra)
 
-    def opened(self):
-        for f in self.filters:
-            self.subscribe(f, self.last_log_id)
+    def _update_sub(self, method: WSMethod, f: Filter, **extra: Any) -> None:
+        msg = json.dumps({
+            'method': method.value,
+            'filters': f,
+            **extra,
+        })
+        self._client.send(msg)
 
-    def closed(self, code, reason=None):
-        self._closed.set()
-        self.on_closed()
+    def close(self, code: int=1000, reason: str='', timeout: float=0) -> None:
+        """Close the WebSocket connection and stop processing events
 
-    def received_message(self, m):
-        with self._closing_lock:
-            if not self._closing:
-                self.on_event(json.loads(str(m)))
+        Arguments:
 
-    def close(self, code=1000, reason='', timeout=0):
-        """Close event client and optionally wait for it to finish.
+        * code: int --- The WebSocket close code sent to the server when
+          disconnecting. Default 1000.
 
-        :timeout: is the number of seconds to wait for ws4py to
-        indicate that the connection has closed.
+        * reason: str --- The WebSocket close reason sent to the server when
+          disconnecting. Default is an empty string.
+
+        * timeout: float --- How long to wait for the WebSocket server to
+          acknowledge the disconnection, in seconds. Default 0, which means
+          no timeout.
         """
-        super(_EventClient, self).close(code, reason)
-        with self._closing_lock:
-            # make sure we don't process any more messages.
-            self._closing = True
-        # wait for ws4py to tell us the connection is closed.
-        self._closed.wait(timeout=timeout)
+        self.is_closed.set()
+        self._client.close_timeout = timeout or None
+        self._client.close(code, reason)
 
-    def subscribe(self, f, last_log_id=None):
-        m = {"method": "subscribe", "filters": f}
-        if last_log_id is not None:
-            m["last_log_id"] = last_log_id
-        self.send(json.dumps(m))
+    def run_forever(self) -> None:
+        """Run the WebSocket client indefinitely
 
-    def unsubscribe(self, f):
-        self.send(json.dumps({"method": "unsubscribe", "filters": f}))
+        This method blocks until the `close` method is called (e.g., from
+        another thread) or the client permanently loses its connection.
+        """
+        # Have to poll here to let KeyboardInterrupt get raised.
+        while not self.is_closed.wait(1):
+            pass
 
+    def subscribe(self, f: Filter, last_log_id: Optional[int]=None) -> None:
+        """Subscribe to another set of events from the server
 
-class EventClient(object):
-    def __init__(self, url, filters, on_event_cb, last_log_id):
-        self.url = url
-        if filters:
-            self.filters = [filters]
-        else:
-            self.filters = [[]]
-        self.on_event_cb = on_event_cb
-        self.last_log_id = last_log_id
-        self.is_closed = threading.Event()
-        self._setup_event_client()
+        Arguments:
 
-    def _setup_event_client(self):
-        self.ec = _EventClient(self.url, self.filters, self.on_event,
-                               self.last_log_id, self.on_closed)
-        self.ec.daemon = True
-        try:
-            self.ec.connect()
-        except Exception:
-            self.ec.close_connection()
-            raise
+        * f: arvados.events.Filter | None --- One filter to subscribe to
+          events for.
 
-    def subscribe(self, f, last_log_id=None):
-        self.filters.append(f)
-        self.ec.subscribe(f, last_log_id)
+        * last_log_id: int | None --- If specified, request events starting
+          from this id. If not specified, the server will only send events
+          that occur after processing the subscription.
+        """
+        with self._subscribe_lock:
+            self._subscribe(f, last_log_id)
+            self.filters.append(f)
 
-    def unsubscribe(self, f):
-        del self.filters[self.filters.index(f)]
-        self.ec.unsubscribe(f)
+    def unsubscribe(self, f: Filter) -> None:
+        """Unsubscribe from an event stream
 
-    def close(self, code=1000, reason='', timeout=0):
-        self.is_closed.set()
-        self.ec.close(code, reason, timeout)
+        Arguments:
+
+        * f: arvados.events.Filter | None --- One event filter to stop
+        receiving events for.
+        """
+        with self._subscribe_lock:
+            try:
+                index = self.filters.index(f)
+            except ValueError:
+                raise ValueError(f"filter not subscribed: {f!r}") from None
+            self._update_sub(WSMethod.UNSUBSCRIBE, f)
+            del self.filters[index]
+
+    def on_closed(self) -> None:
+        """Handle disconnection from the WebSocket server
+
+        This method is called when the client loses its connection from
+        receiving events. This implementation tries to establish a new
+        connection if it was not closed client-side.
+        """
+        if self.is_closed.is_set():
+            return
+        _logger.warning("Unexpected close. Reconnecting.")
+        for _ in RetryLoop(num_retries=25, backoff_start=.1, max_wait=15):
+            try:
+                self._connect()
+            except Exception as e:
+                _logger.warning("Error '%s' during websocket reconnect.", e)
+            else:
+                _logger.warning("Reconnect successful.")
+                break
+        else:
+            _logger.error("EventClient thread could not contact websocket server.")
+            self.is_closed.set()
+            _thread.interrupt_main()
+
+    def on_event(self, m: Dict[str, Any]) -> None:
+        """Handle an event from the WebSocket server
 
-    def on_event(self, m):
-        if m.get('id') != None:
-            self.last_log_id = m.get('id')
+        This method is called whenever the client receives an event from the
+        server. This implementation records the `id` field internally, then
+        calls the callback function provided at initialization time.
+
+        Arguments:
+
+        * m: Dict[str, Any] --- The event object, deserialized from JSON.
+        """
+        try:
+            self.last_log_id = m['id']
+        except KeyError:
+            pass
         try:
             self.on_event_cb(m)
-        except Exception as e:
+        except Exception:
             _logger.exception("Unexpected exception from event callback.")
             _thread.interrupt_main()
 
-    def on_closed(self):
-        if not self.is_closed.is_set():
-            _logger.warning("Unexpected close. Reconnecting.")
-            for tries_left in RetryLoop(num_retries=25, backoff_start=.1, max_wait=15):
-                try:
-                    self._setup_event_client()
-                    _logger.warning("Reconnect successful.")
-                    break
-                except Exception as e:
-                    _logger.warning("Error '%s' during websocket reconnect.", e)
-            if tries_left == 0:
-                _logger.exception("EventClient thread could not contact websocket server.")
-                self.is_closed.set()
-                _thread.interrupt_main()
-                return
+    def run(self) -> None:
+        """Run the client loop
 
-    def run_forever(self):
-        # Have to poll here to let KeyboardInterrupt get raised.
-        while not self.is_closed.wait(1):
-            pass
+        This method runs in a separate thread to receive and process events
+        from the server.
+        """
+        self.setName(f'ArvadosWebsockets-{self.ident}')
+        while self._client_ok and not self.is_closed.is_set():
+            try:
+                with self._subscribe_lock:
+                    for f in self.filters:
+                        self._subscribe(f, self.last_log_id)
+                for msg_s in self._client:
+                    if not self.is_closed.is_set():
+                        msg = json.loads(msg_s)
+                        self.on_event(msg)
+            except ws_exc.ConnectionClosed:
+                self._client_ok = False
+                self.on_closed()
 
 
 class PollClient(threading.Thread):
-    def __init__(self, api, filters, on_event, poll_time, last_log_id):
+    """Follow Arvados events via polling logs
+
+    PollClient follows events on Arvados cluster by periodically running
+    logs list API calls. Users can select the events they want to follow and
+    run their own callback function on each.
+    """
+    def __init__(
+            self,
+            api: 'arvados.api_resources.ArvadosAPIClient',
+            filters: Optional[Filter],
+            on_event: EventCallback,
+            poll_time: float=15,
+            last_log_id: Optional[int]=None,
+    ) -> None:
+        """Initialize a polling client
+
+        Constructor arguments:
+
+        * api: arvados.api_resources.ArvadosAPIClient --- The Arvados API
+          client used to query logs. It will be used in a separate thread,
+          so if it is not an instance of `arvados.safeapi.ThreadSafeApiCache`
+          it should not be reused after the thread is started.
+
+        * filters: arvados.events.Filter | None --- One event filter to
+          subscribe to after connecting to the WebSocket server. If not
+          specified, the client will subscribe to all events.
+
+        * on_event: arvados.events.EventCallback --- When the client
+          receives an event from the WebSocket server, it calls this
+          function with the event object.
+
+        * poll_time: float --- The number of seconds to wait between querying
+          logs. Default 15.
+
+        * last_log_id: int | None --- If specified, queries will include a
+          filter for logs with an `id` at least this value.
+        """
         super(PollClient, self).__init__()
         self.api = api
         if filters:
@@ -174,6 +341,11 @@ class PollClient(threading.Thread):
             self._skip_old_events = False
 
     def run(self):
+        """Run the client loop
+
+        This method runs in a separate thread to poll and process events
+        from the server.
+        """
         self.on_event({'status': 200})
 
         while not self._closing.is_set():
@@ -262,23 +434,29 @@ class PollClient(threading.Thread):
                 self._closing.wait(self.poll_time)
 
     def run_forever(self):
+        """Run the polling client indefinitely
+
+        This method blocks until the `close` method is called (e.g., from
+        another thread) or the client permanently loses its connection.
+        """
         # Have to poll here, otherwise KeyboardInterrupt will never get processed.
         while not self._closing.is_set():
             self._closing.wait(1)
 
-    def close(self, code=None, reason=None, timeout=0):
-        """Close poll client and optionally wait for it to finish.
+    def close(self, code: Optional[int]=None, reason: Optional[str]=None, timeout: float=0) -> None:
+        """Stop polling and processing events
 
-        If an :on_event: handler is running in a different thread,
-        first wait (indefinitely) for it to return.
+        Arguments:
 
-        After closing, wait up to :timeout: seconds for the thread to
-        finish the poll request in progress (if any).
+        * code: Optional[int] --- Ignored; this argument exists for API
+          compatibility with `EventClient.close`.
 
-        :code: and :reason: are ignored. They are present for
-        interface compatibility with EventClient.
-        """
+        * reason: Optional[str] --- Ignored; this argument exists for API
+          compatibility with `EventClient.close`.
 
+        * timeout: float --- How long to wait for the client thread to finish
+          processing events. Default 0, which means no timeout.
+        """
         with self._closing_lock:
             self._closing.set()
         try:
@@ -290,11 +468,27 @@ class PollClient(threading.Thread):
             # to do so raises the same exception."
             pass
 
-    def subscribe(self, f):
+    def subscribe(self, f: Filter, last_log_id: Optional[int]=None) -> None:
+        """Subscribe to another set of events from the server
+
+        Arguments:
+
+        * f: arvados.events.Filter | None --- One filter to subscribe to.
+
+        * last_log_id: Optional[int] --- Ignored; this argument exists for
+          API compatibility with `EventClient.subscribe`.
+        """
         self.on_event({'status': 200})
         self.filters.append(f)
 
     def unsubscribe(self, f):
+        """Unsubscribe from an event stream
+
+        Arguments:
+
+        * f: arvados.events.Filter | None --- One event filter to stop
+        receiving events for.
+        """
         del self.filters[self.filters.index(f)]
 
 
@@ -312,21 +506,42 @@ def _subscribe_websocket(api, filters, on_event, last_log_id=None):
     else:
         return client
 
-
-def subscribe(api, filters, on_event, poll_fallback=15, last_log_id=None):
+def subscribe(
+        api: 'arvados.api_resources.ArvadosAPIClient',
+        filters: Optional[Filter],
+        on_event: EventCallback,
+        poll_fallback: float=15,
+        last_log_id: Optional[int]=None,
+) -> Union[EventClient, PollClient]:
+    """Start a thread to monitor events
+
+    This method tries to construct an `EventClient` to process Arvados
+    events via WebSockets. If that fails, or the
+    `ARVADOS_DISABLE_WEBSOCKETS` flag is set in user configuration, it falls
+    back to constructing a `PollClient` to process the events via API
+    polling.
+
+    Arguments:
+
+    * api: arvados.api_resources.ArvadosAPIClient --- The Arvados API
+      client used to query logs. It may be used in a separate thread,
+      so if it is not an instance of `arvados.safeapi.ThreadSafeApiCache`
+      it should not be reused after this method returns.
+
+    * filters: arvados.events.Filter | None --- One event filter to
+      subscribe to after initializing the client. If not specified, the
+      client will subscribe to all events.
+
+    * on_event: arvados.events.EventCallback --- When the client receives an
+      event, it calls this function with the event object.
+
+    * poll_time: float --- The number of seconds to wait between querying
+      logs. If 0, this function will refuse to construct a `PollClient`.
+      Default 15.
+
+    * last_log_id: int | None --- If specified, start processing events with
+      at least this `id` value.
     """
-    :api:
-      a client object retrieved from arvados.api(). The caller should not use this client object for anything else after calling subscribe().
-    :filters:
-      Initial subscription filters.
-    :on_event:
-      The callback when a message is received.
-    :poll_fallback:
-      If websockets are not available, fall back to polling every N seconds.  If poll_fallback=False, this will return None if websockets are not available.
-    :last_log_id:
-      Log rows that are newer than the log id
-    """
-
     if not poll_fallback:
         return _subscribe_websocket(api, filters, on_event, last_log_id)
 
diff --git a/sdk/python/arvados/http_to_keep.py b/sdk/python/arvados/http_to_keep.py
new file mode 100644 (file)
index 0000000..f247afe
--- /dev/null
@@ -0,0 +1,374 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import calendar
+import dataclasses
+import datetime
+import email.utils
+import logging
+import re
+import time
+import typing
+import urllib.parse
+
+import pycurl
+
+import arvados
+import arvados.collection
+from arvados._pycurlhelper import PyCurlHelper
+
+logger = logging.getLogger('arvados.http_import')
+
+def _my_formatdate(dt):
+    return email.utils.formatdate(timeval=calendar.timegm(dt.timetuple()),
+                                  localtime=False, usegmt=True)
+
+def _my_parsedate(text):
+    parsed = email.utils.parsedate_tz(text)
+    if parsed:
+        if parsed[9]:
+            # Adjust to UTC
+            return datetime.datetime(*parsed[:6]) + datetime.timedelta(seconds=parsed[9])
+        else:
+            # TZ is zero or missing, assume UTC.
+            return datetime.datetime(*parsed[:6])
+    else:
+        return datetime.datetime(1970, 1, 1)
+
+def _fresh_cache(url, properties, now):
+    pr = properties[url]
+    expires = None
+
+    logger.debug("Checking cache freshness for %s using %s", url, pr)
+
+    if "Cache-Control" in pr:
+        if re.match(r"immutable", pr["Cache-Control"]):
+            return True
+
+        g = re.match(r"(s-maxage|max-age)=(\d+)", pr["Cache-Control"])
+        if g:
+            expires = _my_parsedate(pr["Date"]) + datetime.timedelta(seconds=int(g.group(2)))
+
+    if expires is None and "Expires" in pr:
+        expires = _my_parsedate(pr["Expires"])
+
+    if expires is None:
+        # Use a default cache time of 24 hours if upstream didn't set
+        # any cache headers, to reduce redundant downloads.
+        expires = _my_parsedate(pr["Date"]) + datetime.timedelta(hours=24)
+
+    if not expires:
+        return False
+
+    return (now < expires)
+
+def _remember_headers(url, properties, headers, now):
+    properties.setdefault(url, {})
+    for h in ("Cache-Control", "Etag", "Expires", "Date", "Content-Length"):
+        if h in headers:
+            properties[url][h] = headers[h]
+    if "Date" not in headers:
+        properties[url]["Date"] = _my_formatdate(now)
+
+@dataclasses.dataclass
+class _Response:
+    status_code: int
+    headers: typing.Mapping[str, str]
+
+
+class _Downloader(PyCurlHelper):
+    # Wait up to 60 seconds for connection
+    # How long it can be in "low bandwidth" state before it gives up
+    # Low bandwidth threshold is 32 KiB/s
+    DOWNLOADER_TIMEOUT = (60, 300, 32768)
+
+    def __init__(self, apiclient):
+        super(_Downloader, self).__init__(title_case_headers=True)
+        self.curl = pycurl.Curl()
+        self.curl.setopt(pycurl.NOSIGNAL, 1)
+        self.curl.setopt(pycurl.OPENSOCKETFUNCTION,
+                    lambda *args, **kwargs: self._socket_open(*args, **kwargs))
+        self.target = None
+        self.apiclient = apiclient
+
+    def head(self, url):
+        get_headers = {'Accept': 'application/octet-stream'}
+        self._headers = {}
+
+        self.curl.setopt(pycurl.URL, url.encode('utf-8'))
+        self.curl.setopt(pycurl.HTTPHEADER, [
+            '{}: {}'.format(k,v) for k,v in get_headers.items()])
+
+        self.curl.setopt(pycurl.HEADERFUNCTION, self._headerfunction)
+        self.curl.setopt(pycurl.CAINFO, arvados.util.ca_certs_path())
+        self.curl.setopt(pycurl.NOBODY, True)
+        self.curl.setopt(pycurl.FOLLOWLOCATION, True)
+
+        self._setcurltimeouts(self.curl, self.DOWNLOADER_TIMEOUT, True)
+
+        try:
+            self.curl.perform()
+        except Exception as e:
+            raise arvados.errors.HttpError(0, str(e))
+        finally:
+            if self._socket:
+                self._socket.close()
+                self._socket = None
+
+        return _Response(self.curl.getinfo(pycurl.RESPONSE_CODE), self._headers)
+
+    def download(self, url, headers):
+        self.count = 0
+        self.start = time.time()
+        self.checkpoint = self.start
+        self._headers = {}
+        self._first_chunk = True
+        self.collection = None
+        self.parsedurl = urllib.parse.urlparse(url)
+
+        get_headers = {'Accept': 'application/octet-stream'}
+        get_headers.update(headers)
+
+        self.curl.setopt(pycurl.URL, url.encode('utf-8'))
+        self.curl.setopt(pycurl.HTTPHEADER, [
+            '{}: {}'.format(k,v) for k,v in get_headers.items()])
+
+        self.curl.setopt(pycurl.WRITEFUNCTION, self.body_write)
+        self.curl.setopt(pycurl.HEADERFUNCTION, self._headerfunction)
+
+        self.curl.setopt(pycurl.CAINFO, arvados.util.ca_certs_path())
+        self.curl.setopt(pycurl.HTTPGET, True)
+        self.curl.setopt(pycurl.FOLLOWLOCATION, True)
+
+        self._setcurltimeouts(self.curl, self.DOWNLOADER_TIMEOUT, False)
+
+        try:
+            self.curl.perform()
+        except Exception as e:
+            raise arvados.errors.HttpError(0, str(e))
+        finally:
+            if self._socket:
+                self._socket.close()
+                self._socket = None
+
+        return _Response(self.curl.getinfo(pycurl.RESPONSE_CODE), self._headers)
+
+    def headers_received(self):
+        self.collection = arvados.collection.Collection(api_client=self.apiclient)
+
+        if "Content-Length" in self._headers:
+            self.contentlength = int(self._headers["Content-Length"])
+            logger.info("File size is %s bytes", self.contentlength)
+        else:
+            self.contentlength = None
+
+        if self._headers.get("Content-Disposition"):
+            grp = re.search(r'filename=("((\"|[^"])+)"|([^][()<>@,;:\"/?={} ]+))',
+                            self._headers["Content-Disposition"])
+            if grp.group(2):
+                self.name = grp.group(2)
+            else:
+                self.name = grp.group(4)
+        else:
+            self.name = self.parsedurl.path.split("/")[-1]
+
+        # Can't call curl.getinfo(pycurl.RESPONSE_CODE) until
+        # perform() is done but we need to know the status before that
+        # so we have to parse the status line ourselves.
+        mt = re.match(r'^HTTP\/(\d(\.\d)?) ([1-5]\d\d) ([^\r\n\x00-\x08\x0b\x0c\x0e-\x1f\x7f]*)\r\n$', self._headers["x-status-line"])
+        code = int(mt.group(3))
+
+        if not self.name:
+            logger.error("Cannot determine filename from URL or headers")
+            return
+
+        if code == 200:
+            self.target = self.collection.open(self.name, "wb")
+
+    def body_write(self, chunk):
+        if self._first_chunk:
+            self.headers_received()
+            self._first_chunk = False
+
+        self.count += len(chunk)
+
+        if self.target is None:
+            # "If this number is not equal to the size of the byte
+            # string, this signifies an error and libcurl will abort
+            # the request."
+            return 0
+
+        self.target.write(chunk)
+        loopnow = time.time()
+        if (loopnow - self.checkpoint) < 20:
+            return
+
+        bps = self.count / (loopnow - self.start)
+        if self.contentlength is not None:
+            logger.info("%2.1f%% complete, %6.2f MiB/s, %1.0f seconds left",
+                        ((self.count * 100) / self.contentlength),
+                        (bps / (1024.0*1024.0)),
+                        ((self.contentlength-self.count) // bps))
+        else:
+            logger.info("%d downloaded, %6.2f MiB/s", count, (bps / (1024.0*1024.0)))
+        self.checkpoint = loopnow
+
+
+def _changed(url, clean_url, properties, now, curldownloader):
+    req = curldownloader.head(url)
+
+    if req.status_code != 200:
+        # Sometimes endpoints are misconfigured and will deny HEAD but
+        # allow GET so instead of failing here, we'll try GET If-None-Match
+        return True
+
+    # previous version of this code used "ETag", now we are
+    # normalizing to "Etag", check for both.
+    etag = properties[url].get("Etag") or properties[url].get("ETag")
+
+    if url in properties:
+        del properties[url]
+    _remember_headers(clean_url, properties, req.headers, now)
+
+    if "Etag" in req.headers and etag == req.headers["Etag"]:
+        # Didn't change
+        return False
+
+    return True
+
+def _etag_quote(etag):
+    # if it already has leading and trailing quotes, do nothing
+    if etag[0] == '"' and etag[-1] == '"':
+        return etag
+    else:
+        # Add quotes.
+        return '"' + etag + '"'
+
+
+def check_cached_url(api, project_uuid, url, etags,
+                     utcnow=datetime.datetime.utcnow,
+                     varying_url_params="",
+                     prefer_cached_downloads=False):
+
+    logger.info("Checking Keep for %s", url)
+
+    varying_params = [s.strip() for s in varying_url_params.split(",")]
+
+    parsed = urllib.parse.urlparse(url)
+    query = [q for q in urllib.parse.parse_qsl(parsed.query)
+             if q[0] not in varying_params]
+
+    clean_url = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params,
+                                         urllib.parse.urlencode(query, safe="/"),  parsed.fragment))
+
+    r1 = api.collections().list(filters=[["properties", "exists", url]]).execute()
+
+    if clean_url == url:
+        items = r1["items"]
+    else:
+        r2 = api.collections().list(filters=[["properties", "exists", clean_url]]).execute()
+        items = r1["items"] + r2["items"]
+
+    now = utcnow()
+
+    curldownloader = _Downloader(api)
+
+    for item in items:
+        properties = item["properties"]
+
+        if clean_url in properties:
+            cache_url = clean_url
+        elif url in properties:
+            cache_url = url
+        else:
+            raise Exception("Shouldn't happen, got an API result for %s that doesn't have the URL in properties" % item["uuid"])
+
+        if prefer_cached_downloads or _fresh_cache(cache_url, properties, now):
+            # HTTP caching rules say we should use the cache
+            cr = arvados.collection.CollectionReader(item["portable_data_hash"], api_client=api)
+            return (item["portable_data_hash"], next(iter(cr.keys())), item["uuid"], clean_url, now)
+
+        if not _changed(cache_url, clean_url, properties, now, curldownloader):
+            # Etag didn't change, same content, just update headers
+            api.collections().update(uuid=item["uuid"], body={"collection":{"properties": properties}}).execute()
+            cr = arvados.collection.CollectionReader(item["portable_data_hash"], api_client=api)
+            return (item["portable_data_hash"], next(iter(cr.keys())), item["uuid"], clean_url, now)
+
+        for etagstr in ("Etag", "ETag"):
+            if etagstr in properties[cache_url] and len(properties[cache_url][etagstr]) > 2:
+                etags[properties[cache_url][etagstr]] = item
+
+    logger.debug("Found ETag values %s", etags)
+
+    return (None, None, None, clean_url, now)
+
+
+def http_to_keep(api, project_uuid, url,
+                 utcnow=datetime.datetime.utcnow, varying_url_params="",
+                 prefer_cached_downloads=False):
+    """Download a file over HTTP and upload it to keep, with HTTP headers as metadata.
+
+    Before downloading the URL, checks to see if the URL already
+    exists in Keep and applies HTTP caching policy, the
+    varying_url_params and prefer_cached_downloads flags in order to
+    decide whether to use the version in Keep or re-download it.
+    """
+
+    etags = {}
+    cache_result = check_cached_url(api, project_uuid, url, etags,
+                                    utcnow, varying_url_params,
+                                    prefer_cached_downloads)
+
+    if cache_result[0] is not None:
+        return cache_result
+
+    clean_url = cache_result[3]
+    now = cache_result[4]
+
+    properties = {}
+    headers = {}
+    if etags:
+        headers['If-None-Match'] = ', '.join([_etag_quote(k) for k,v in etags.items()])
+    logger.debug("Sending GET request with headers %s", headers)
+
+    logger.info("Beginning download of %s", url)
+
+    curldownloader = _Downloader(api)
+
+    req = curldownloader.download(url, headers)
+
+    c = curldownloader.collection
+
+    if req.status_code not in (200, 304):
+        raise Exception("Failed to download '%s' got status %s " % (url, req.status_code))
+
+    if curldownloader.target is not None:
+        curldownloader.target.close()
+
+    _remember_headers(clean_url, properties, req.headers, now)
+
+    if req.status_code == 304 and "Etag" in req.headers and req.headers["Etag"] in etags:
+        item = etags[req.headers["Etag"]]
+        item["properties"].update(properties)
+        api.collections().update(uuid=item["uuid"], body={"collection":{"properties": item["properties"]}}).execute()
+        cr = arvados.collection.CollectionReader(item["portable_data_hash"], api_client=api)
+        return (item["portable_data_hash"], list(cr.keys())[0], item["uuid"], clean_url, now)
+
+    logger.info("Download complete")
+
+    collectionname = "Downloaded from %s" % urllib.parse.quote(clean_url, safe='')
+
+    # max length - space to add a timestamp used by ensure_unique_name
+    max_name_len = 254 - 28
+
+    if len(collectionname) > max_name_len:
+        over = len(collectionname) - max_name_len
+        split = int(max_name_len/2)
+        collectionname = collectionname[0:split] + "…" + collectionname[split+over:]
+
+    c.save_new(name=collectionname, owner_uuid=project_uuid, ensure_unique_name=True)
+
+    api.collections().update(uuid=c.manifest_locator(), body={"collection":{"properties": properties}}).execute()
+
+    return (c.portable_data_hash(), curldownloader.name, c.manifest_locator(), clean_url, now)
index cbe96ffa2f1c8d038e95f6174b3dc44ec739770a..d1be6b931e7b0ea1ae8009076a0c684aedaa3a2b 100644 (file)
@@ -44,6 +44,7 @@ import arvados.errors
 import arvados.retry as retry
 import arvados.util
 import arvados.diskcache
+from arvados._pycurlhelper import PyCurlHelper
 
 _logger = logging.getLogger('arvados.keep')
 global_client_object = None
@@ -181,7 +182,7 @@ class Keep(object):
 class KeepBlockCache(object):
     def __init__(self, cache_max=0, max_slots=0, disk_cache=False, disk_cache_dir=None):
         self.cache_max = cache_max
-        self._cache = []
+        self._cache = collections.OrderedDict()
         self._cache_lock = threading.Lock()
         self._max_slots = max_slots
         self._disk_cache = disk_cache
@@ -232,11 +233,13 @@ class KeepBlockCache(object):
 
         self.cache_max = max(self.cache_max, 64 * 1024 * 1024)
 
+        self.cache_total = 0
         if self._disk_cache:
             self._cache = arvados.diskcache.DiskCacheSlot.init_cache(self._disk_cache_dir, self._max_slots)
+            for slot in self._cache.values():
+                self.cache_total += slot.size()
             self.cap_cache()
 
-
     class CacheSlot(object):
         __slots__ = ("locator", "ready", "content")
 
@@ -250,8 +253,11 @@ class KeepBlockCache(object):
             return self.content
 
         def set(self, value):
+            if self.content is not None:
+                return False
             self.content = value
             self.ready.set()
+            return True
 
         def size(self):
             if self.content is None:
@@ -261,42 +267,25 @@ class KeepBlockCache(object):
 
         def evict(self):
             self.content = None
-            return self.gone()
 
-        def gone(self):
-            return (self.content is None)
 
     def _resize_cache(self, cache_max, max_slots):
         # Try and make sure the contents of the cache do not exceed
         # the supplied maximums.
 
-        # Select all slots except those where ready.is_set() and content is
-        # None (that means there was an error reading the block).
-        self._cache = [c for c in self._cache if not (c.ready.is_set() and c.content is None)]
-        sm = sum([slot.size() for slot in self._cache])
-        while len(self._cache) > 0 and (sm > cache_max or len(self._cache) > max_slots):
-            for i in range(len(self._cache)-1, -1, -1):
-                # start from the back, find a slot that is a candidate to evict
-                if self._cache[i].ready.is_set():
-                    sz = self._cache[i].size()
-
-                    # If evict returns false it means the
-                    # underlying disk cache couldn't lock the file
-                    # for deletion because another process was using
-                    # it. Don't count it as reducing the amount
-                    # of data in the cache, find something else to
-                    # throw out.
-                    if self._cache[i].evict():
-                        sm -= sz
-
-                    # check to make sure the underlying data is gone
-                    if self._cache[i].gone():
-                        # either way we forget about it.  either the
-                        # other process will delete it, or if we need
-                        # it again and it is still there, we'll find
-                        # it on disk.
-                        del self._cache[i]
-                    break
+        if self.cache_total <= cache_max and len(self._cache) <= max_slots:
+            return
+
+        _evict_candidates = collections.deque(self._cache.values())
+        while _evict_candidates and (self.cache_total > cache_max or len(self._cache) > max_slots):
+            slot = _evict_candidates.popleft()
+            if not slot.ready.is_set():
+                continue
+
+            sz = slot.size()
+            slot.evict()
+            self.cache_total -= sz
+            del self._cache[slot.locator]
 
 
     def cap_cache(self):
@@ -307,19 +296,19 @@ class KeepBlockCache(object):
 
     def _get(self, locator):
         # Test if the locator is already in the cache
-        for i in range(0, len(self._cache)):
-            if self._cache[i].locator == locator:
-                n = self._cache[i]
-                if i != 0:
-                    # move it to the front
-                    del self._cache[i]
-                    self._cache.insert(0, n)
-                return n
+        if locator in self._cache:
+            n = self._cache[locator]
+            if n.ready.is_set() and n.content is None:
+                del self._cache[n.locator]
+                return None
+            self._cache.move_to_end(locator)
+            return n
         if self._disk_cache:
             # see if it exists on disk
             n = arvados.diskcache.DiskCacheSlot.get_from_disk(locator, self._disk_cache_dir)
             if n is not None:
-                self._cache.insert(0, n)
+                self._cache[n.locator] = n
+                self.cache_total += n.size()
                 return n
         return None
 
@@ -349,12 +338,13 @@ class KeepBlockCache(object):
                     n = arvados.diskcache.DiskCacheSlot(locator, self._disk_cache_dir)
                 else:
                     n = KeepBlockCache.CacheSlot(locator)
-                self._cache.insert(0, n)
+                self._cache[n.locator] = n
                 return n, True
 
     def set(self, slot, blob):
         try:
-            slot.set(blob)
+            if slot.set(blob):
+                self.cache_total += slot.size()
             return
         except OSError as e:
             if e.errno == errno.ENOMEM:
@@ -364,7 +354,7 @@ class KeepBlockCache(object):
             elif e.errno == errno.ENOSPC:
                 # Reduce disk max space to current - 256 MiB, cap cache and retry
                 with self._cache_lock:
-                    sm = sum([st.size() for st in self._cache])
+                    sm = sum(st.size() for st in self._cache.values())
                     self.cache_max = max((256 * 1024 * 1024), sm - (256 * 1024 * 1024))
             elif e.errno == errno.ENODEV:
                 _logger.error("Unable to use disk cache: The underlying filesystem does not support memory mapping.")
@@ -382,7 +372,8 @@ class KeepBlockCache(object):
             # exception handler adjusts limits downward in some cases
             # to free up resources, which would make the operation
             # succeed.
-            slot.set(blob)
+            if slot.set(blob):
+                self.cache_total += slot.size()
         except Exception as e:
             # It failed again.  Give up.
             slot.set(None)
@@ -405,18 +396,10 @@ class Counter(object):
 
 
 class KeepClient(object):
+    DEFAULT_TIMEOUT = PyCurlHelper.DEFAULT_TIMEOUT
+    DEFAULT_PROXY_TIMEOUT = PyCurlHelper.DEFAULT_PROXY_TIMEOUT
 
-    # Default Keep server connection timeout:  2 seconds
-    # Default Keep server read timeout:       256 seconds
-    # Default Keep server bandwidth minimum:  32768 bytes per second
-    # Default Keep proxy connection timeout:  20 seconds
-    # Default Keep proxy read timeout:        256 seconds
-    # Default Keep proxy bandwidth minimum:   32768 bytes per second
-    DEFAULT_TIMEOUT = (2, 256, 32768)
-    DEFAULT_PROXY_TIMEOUT = (20, 256, 32768)
-
-
-    class KeepService(object):
+    class KeepService(PyCurlHelper):
         """Make requests to a single Keep service, and track results.
 
         A KeepService is intended to last long enough to perform one
@@ -439,6 +422,7 @@ class KeepClient(object):
                      download_counter=None,
                      headers={},
                      insecure=False):
+            super(KeepClient.KeepService, self).__init__()
             self.root = root
             self._user_agent_pool = user_agent_pool
             self._result = {'error': None}
@@ -476,30 +460,6 @@ class KeepClient(object):
             except:
                 ua.close()
 
-        def _socket_open(self, *args, **kwargs):
-            if len(args) + len(kwargs) == 2:
-                return self._socket_open_pycurl_7_21_5(*args, **kwargs)
-            else:
-                return self._socket_open_pycurl_7_19_3(*args, **kwargs)
-
-        def _socket_open_pycurl_7_19_3(self, family, socktype, protocol, address=None):
-            return self._socket_open_pycurl_7_21_5(
-                purpose=None,
-                address=collections.namedtuple(
-                    'Address', ['family', 'socktype', 'protocol', 'addr'],
-                )(family, socktype, protocol, address))
-
-        def _socket_open_pycurl_7_21_5(self, purpose, address):
-            """Because pycurl doesn't have CURLOPT_TCP_KEEPALIVE"""
-            s = socket.socket(address.family, address.socktype, address.protocol)
-            s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
-            # Will throw invalid protocol error on mac. This test prevents that.
-            if hasattr(socket, 'TCP_KEEPIDLE'):
-                s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 75)
-            s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 75)
-            self._socket = s
-            return s
-
         def get(self, locator, method="GET", timeout=None):
             # locator is a KeepLocator object.
             url = self.root + str(locator)
@@ -525,6 +485,8 @@ class KeepClient(object):
                         curl.setopt(pycurl.CAINFO, arvados.util.ca_certs_path())
                     if method == "HEAD":
                         curl.setopt(pycurl.NOBODY, True)
+                    else:
+                        curl.setopt(pycurl.HTTPGET, True)
                     self._setcurltimeouts(curl, timeout, method=="HEAD")
 
                     try:
@@ -669,43 +631,6 @@ class KeepClient(object):
                 self.upload_counter.add(len(body))
             return True
 
-        def _setcurltimeouts(self, curl, timeouts, ignore_bandwidth=False):
-            if not timeouts:
-                return
-            elif isinstance(timeouts, tuple):
-                if len(timeouts) == 2:
-                    conn_t, xfer_t = timeouts
-                    bandwidth_bps = KeepClient.DEFAULT_TIMEOUT[2]
-                else:
-                    conn_t, xfer_t, bandwidth_bps = timeouts
-            else:
-                conn_t, xfer_t = (timeouts, timeouts)
-                bandwidth_bps = KeepClient.DEFAULT_TIMEOUT[2]
-            curl.setopt(pycurl.CONNECTTIMEOUT_MS, int(conn_t*1000))
-            if not ignore_bandwidth:
-                curl.setopt(pycurl.LOW_SPEED_TIME, int(math.ceil(xfer_t)))
-                curl.setopt(pycurl.LOW_SPEED_LIMIT, int(math.ceil(bandwidth_bps)))
-
-        def _headerfunction(self, header_line):
-            if isinstance(header_line, bytes):
-                header_line = header_line.decode('iso-8859-1')
-            if ':' in header_line:
-                name, value = header_line.split(':', 1)
-                name = name.strip().lower()
-                value = value.strip()
-            elif self._headers:
-                name = self._lastheadername
-                value = self._headers[name] + ' ' + header_line.strip()
-            elif header_line.startswith('HTTP/'):
-                name = 'x-status-line'
-                value = header_line
-            else:
-                _logger.error("Unexpected header line: %s", header_line)
-                return
-            self._lastheadername = name
-            self._headers[name] = value
-            # Returning None implies all bytes were written
-
 
     class KeepWriterQueue(queue.Queue):
         def __init__(self, copies, classes=[]):
@@ -900,7 +825,7 @@ class KeepClient(object):
     def __init__(self, api_client=None, proxy=None,
                  timeout=DEFAULT_TIMEOUT, proxy_timeout=DEFAULT_PROXY_TIMEOUT,
                  api_token=None, local_store=None, block_cache=None,
-                 num_retries=0, session=None):
+                 num_retries=10, session=None, num_prefetch_threads=None):
         """Initialize a new KeepClient.
 
         Arguments:
@@ -953,7 +878,7 @@ class KeepClient(object):
         :num_retries:
           The default number of times to retry failed requests.
           This will be used as the default num_retries value when get() and
-          put() are called.  Default 0.
+          put() are called.  Default 10.
         """
         self.lock = threading.Lock()
         if proxy is None:
@@ -989,6 +914,12 @@ class KeepClient(object):
         self.misses_counter = Counter()
         self._storage_classes_unsupported_warning = False
         self._default_classes = []
+        if num_prefetch_threads is not None:
+            self.num_prefetch_threads = num_prefetch_threads
+        else:
+            self.num_prefetch_threads = 2
+        self._prefetch_queue = None
+        self._prefetch_threads = None
 
         if local_store:
             self.local_store = local_store
@@ -1239,21 +1170,39 @@ class KeepClient(object):
         try:
             locator = KeepLocator(loc_s)
             if method == "GET":
-                slot, first = self.block_cache.reserve_cache(locator.md5sum)
-                if not first:
+                while slot is None:
+                    slot, first = self.block_cache.reserve_cache(locator.md5sum)
+                    if first:
+                        # Fresh and empty "first time it is used" slot
+                        break
                     if prefetch:
-                        # this is request for a prefetch, if it is
-                        # already in flight, return immediately.
-                        # clear 'slot' to prevent finally block from
-                        # calling slot.set()
+                        # this is request for a prefetch to fill in
+                        # the cache, don't need to wait for the
+                        # result, so if it is already in flight return
+                        # immediately.  Clear 'slot' to prevent
+                        # finally block from calling slot.set()
+                        if slot.ready.is_set():
+                            slot.get()
                         slot = None
                         return None
-                    self.hits_counter.add(1)
+
                     blob = slot.get()
-                    if blob is None:
-                        raise arvados.errors.KeepReadError(
-                            "failed to read {}".format(loc_s))
-                    return blob
+                    if blob is not None:
+                        self.hits_counter.add(1)
+                        return blob
+
+                    # If blob is None, this means either
+                    #
+                    # (a) another thread was fetching this block and
+                    # failed with an error or
+                    #
+                    # (b) cache thrashing caused the slot to be
+                    # evicted (content set to None) by another thread
+                    # between the call to reserve_cache() and get().
+                    #
+                    # We'll handle these cases by reserving a new slot
+                    # and then doing a full GET request.
+                    slot = None
 
             self.misses_counter.add(1)
 
@@ -1440,6 +1389,54 @@ class KeepClient(object):
                 "[{}] failed to write {} after {} (wanted {} copies but wrote {})".format(
                     request_id, data_hash, loop.attempts_str(), (copies, classes), writer_pool.done()), service_errors, label="service")
 
+    def _block_prefetch_worker(self):
+        """The background downloader thread."""
+        while True:
+            try:
+                b = self._prefetch_queue.get()
+                if b is None:
+                    return
+                self.get(b, prefetch=True)
+            except Exception:
+                _logger.exception("Exception doing block prefetch")
+
+    def _start_prefetch_threads(self):
+        if self._prefetch_threads is None:
+            with self.lock:
+                if self._prefetch_threads is not None:
+                    return
+                self._prefetch_queue = queue.Queue()
+                self._prefetch_threads = []
+                for i in range(0, self.num_prefetch_threads):
+                    thread = threading.Thread(target=self._block_prefetch_worker)
+                    self._prefetch_threads.append(thread)
+                    thread.daemon = True
+                    thread.start()
+
+    def block_prefetch(self, locator):
+        """
+        This relies on the fact that KeepClient implements a block cache,
+        so repeated requests for the same block will not result in repeated
+        downloads (unless the block is evicted from the cache.)  This method
+        does not block.
+        """
+
+        if self.block_cache.get(locator) is not None:
+            return
+
+        self._start_prefetch_threads()
+        self._prefetch_queue.put(locator)
+
+    def stop_prefetch_threads(self):
+        with self.lock:
+            if self._prefetch_threads is not None:
+                for t in self._prefetch_threads:
+                    self._prefetch_queue.put(None)
+                for t in self._prefetch_threads:
+                    t.join()
+            self._prefetch_threads = None
+            self._prefetch_queue = None
+
     def local_store_put(self, data, copies=1, num_retries=None, classes=[]):
         """A stub for put().
 
diff --git a/sdk/python/arvados/logging.py b/sdk/python/arvados/logging.py
new file mode 100644 (file)
index 0000000..c6371f4
--- /dev/null
@@ -0,0 +1,31 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+"""Logging utilities for Arvados clients"""
+
+import logging
+
+log_format = '%(asctime)s %(name)s[%(process)d] %(levelname)s: %(message)s'
+log_date_format = '%Y-%m-%d %H:%M:%S'
+log_handler = logging.StreamHandler()
+log_handler.setFormatter(logging.Formatter(log_format, log_date_format))
+
+class GoogleHTTPClientFilter:
+    """Common googleapiclient.http log filters for Arvados clients
+
+    This filter makes `googleapiclient.http` log messages more useful for
+    typical Arvados applications. Currently it only changes the level of
+    retry messages (to INFO by default), but its functionality may be
+    extended in the future. Typical usage looks like:
+
+        logging.getLogger('googleapiclient.http').addFilter(GoogleHTTPClientFilter())
+    """
+    def __init__(self, *, retry_level='INFO'):
+        self.retry_levelname = retry_level
+        self.retry_levelno = getattr(logging, retry_level)
+
+    def filter(self, record):
+        if record.msg.startswith(('Sleeping ', 'Retry ')):
+            record.levelname = self.retry_levelname
+            record.levelno = self.retry_levelno
+        return True
index e93624a5d110fa8f935dde6adc83dca477bd9341..e9e574f5df912b1591d44a488bde91b697e48030 100644 (file)
@@ -15,21 +15,28 @@ It also provides utility functions for common operations with `RetryLoop`:
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from builtins import range
-from builtins import object
 import functools
 import inspect
 import pycurl
 import time
 
 from collections import deque
+from typing import (
+    Callable,
+    Generic,
+    Optional,
+    TypeVar,
+)
 
 import arvados.errors
 
 _HTTP_SUCCESSES = set(range(200, 300))
-_HTTP_CAN_RETRY = set([408, 409, 422, 423, 500, 502, 503, 504])
+_HTTP_CAN_RETRY = set([408, 409, 423, 500, 502, 503, 504])
 
-class RetryLoop(object):
+CT = TypeVar('CT', bound=Callable)
+T = TypeVar('T')
+
+class RetryLoop(Generic[T]):
     """Coordinate limited retries of code.
 
     `RetryLoop` coordinates a loop that runs until it records a
@@ -49,38 +56,39 @@ class RetryLoop(object):
 
     Arguments:
 
-    num_retries: int
-    : The maximum number of times to retry the loop if it
-      doesn't succeed.  This means the loop body could run at most
+    * num_retries: int --- The maximum number of times to retry the loop if
+      it doesn't succeed.  This means the loop body could run at most
       `num_retries + 1` times.
 
-    success_check: Callable
-    : This is a function that will be called each
-      time the loop saves a result.  The function should return
-      `True` if the result indicates the code succeeded, `False` if it
-      represents a permanent failure, and `None` if it represents a
-      temporary failure.  If no function is provided, the loop will
-      end after any result is saved.
-
-    backoff_start: float
-    : The number of seconds that must pass before the loop's second
-      iteration.  Default 0, which disables all waiting.
-
-    backoff_growth: float
-    : The wait time multiplier after each iteration.
-      Default 2 (i.e., double the wait time each time).
-
-    save_results: int
-    : Specify a number to store that many saved results from the loop.
-      These are available through the `results` attribute, oldest first.
-      Default 1.
-
-    max_wait: float
-    : Maximum number of seconds to wait between retries. Default 60.
+    * success_check: Callable[[T], bool | None] --- This is a function that
+      will be called each time the loop saves a result.  The function should
+      return `True` if the result indicates the code succeeded, `False` if
+      it represents a permanent failure, and `None` if it represents a
+      temporary failure.  If no function is provided, the loop will end
+      after any result is saved.
+
+    * backoff_start: float --- The number of seconds that must pass before
+      the loop's second iteration.  Default 0, which disables all waiting.
+
+    * backoff_growth: float --- The wait time multiplier after each
+      iteration.  Default 2 (i.e., double the wait time each time).
+
+    * save_results: int --- Specify a number to store that many saved
+      results from the loop.  These are available through the `results`
+      attribute, oldest first.  Default 1.
+
+    * max_wait: float --- Maximum number of seconds to wait between
+      retries. Default 60.
     """
-    def __init__(self, num_retries, success_check=lambda r: True,
-                 backoff_start=0, backoff_growth=2, save_results=1,
-                 max_wait=60):
+    def __init__(
+            self,
+            num_retries: int,
+            success_check: Callable[[T], Optional[bool]]=lambda r: True,
+            backoff_start: float=0,
+            backoff_growth: float=2,
+            save_results: int=1,
+            max_wait: float=60
+    ) -> None:
         self.tries_left = num_retries + 1
         self.check_result = success_check
         self.backoff_wait = backoff_start
@@ -92,11 +100,11 @@ class RetryLoop(object):
         self._running = None
         self._success = None
 
-    def __iter__(self):
+    def __iter__(self) -> 'RetryLoop':
         """Return an iterator of retries."""
         return self
 
-    def running(self):
+    def running(self) -> Optional[bool]:
         """Return whether this loop is running.
 
         Returns `None` if the loop has never run, `True` if it is still running,
@@ -105,7 +113,7 @@ class RetryLoop(object):
         """
         return self._running and (self._success is None)
 
-    def __next__(self):
+    def __next__(self) -> int:
         """Record a loop attempt.
 
         If the loop is still running, decrements the number of tries left and
@@ -126,7 +134,7 @@ class RetryLoop(object):
         self.tries_left -= 1
         return self.tries_left
 
-    def save_result(self, result):
+    def save_result(self, result: T) -> None:
         """Record a loop result.
 
         Save the given result, and end the loop if it indicates
@@ -138,8 +146,7 @@ class RetryLoop(object):
 
         Arguments:
 
-        result: Any
-        : The result from this loop attempt to check and save.
+        * result: T --- The result from this loop attempt to check and save.
         """
         if not self.running():
             raise arvados.errors.AssertionError(
@@ -148,7 +155,7 @@ class RetryLoop(object):
         self._success = self.check_result(result)
         self._attempts += 1
 
-    def success(self):
+    def success(self) -> Optional[bool]:
         """Return the loop's end state.
 
         Returns `True` if the loop recorded a successful result, `False` if it
@@ -156,7 +163,7 @@ class RetryLoop(object):
         """
         return self._success
 
-    def last_result(self):
+    def last_result(self) -> T:
         """Return the most recent result the loop saved.
 
         Raises `arvados.errors.AssertionError` if called before any result has
@@ -168,7 +175,7 @@ class RetryLoop(object):
             raise arvados.errors.AssertionError(
                 "queried loop results before any were recorded")
 
-    def attempts(self):
+    def attempts(self) -> int:
         """Return the number of results that have been saved.
 
         This count includes all kinds of results: success, permanent failure,
@@ -176,7 +183,7 @@ class RetryLoop(object):
         """
         return self._attempts
 
-    def attempts_str(self):
+    def attempts_str(self) -> str:
         """Return a human-friendly string counting saved results.
 
         This method returns '1 attempt' or 'N attempts', where the number
@@ -188,7 +195,7 @@ class RetryLoop(object):
             return '{} attempts'.format(self._attempts)
 
 
-def check_http_response_success(status_code):
+def check_http_response_success(status_code: int) -> Optional[bool]:
     """Convert a numeric HTTP status code to a loop control flag.
 
     This method takes a numeric HTTP status code and returns `True` if
@@ -200,10 +207,6 @@ def check_http_response_success(status_code):
     * Any 2xx result returns `True`.
 
     * A select few status codes, or any malformed responses, return `None`.
-      422 Unprocessable Entity is in this category.  This may not meet the
-      letter of the HTTP specification, but the Arvados API server will
-      use it for various server-side problems like database connection
-      errors.
 
     * Everything else returns `False`.  Note that this includes 1xx and
       3xx status codes.  They don't indicate success, and you can't
@@ -211,8 +214,7 @@ def check_http_response_success(status_code):
 
     Arguments:
 
-    status_code: int
-    : A numeric HTTP response code
+    * status_code: int --- A numeric HTTP response code
     """
     if status_code in _HTTP_SUCCESSES:
         return True
@@ -223,7 +225,7 @@ def check_http_response_success(status_code):
     else:
         return None  # Get well soon, server.
 
-def retry_method(orig_func):
+def retry_method(orig_func: CT) -> CT:
     """Provide a default value for a method's num_retries argument.
 
     This is a decorator for instance and class methods that accept a
@@ -233,8 +235,8 @@ def retry_method(orig_func):
 
     Arguments:
 
-    orig_func: Callable
-    : A class or instance method that accepts a `num_retries` keyword argument
+    * orig_func: Callable --- A class or instance method that accepts a
+    `num_retries` keyword argument
     """
     @functools.wraps(orig_func)
     def num_retries_setter(self, *args, **kwargs):
index e9dde196254b311bbe7387567a4080d853c7a589..56b92e8f08ea38990de09c60394fb49b78b8f2a6 100644 (file)
@@ -7,16 +7,21 @@ This module provides `ThreadSafeApiCache`, a thread-safe, API-compatible
 Arvados API client.
 """
 
-from __future__ import absolute_import
-
-from builtins import object
+import sys
 import threading
 
-from . import api
+from typing import (
+    Any,
+    Mapping,
+    Optional,
+)
+
 from . import config
 from . import keep
 from . import util
 
+api = sys.modules['arvados.api']
+
 class ThreadSafeApiCache(object):
     """Thread-safe wrapper for an Arvados API client
 
@@ -28,27 +33,31 @@ class ThreadSafeApiCache(object):
 
     Arguments:
 
-    apiconfig: Mapping[str, str] | None
-    : A mapping with entries for `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`,
-      and optionally `ARVADOS_API_HOST_INSECURE`. If not provided, uses
+    * apiconfig: Mapping[str, str] | None --- A mapping with entries for
+      `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally
+      `ARVADOS_API_HOST_INSECURE`. If not provided, uses
       `arvados.config.settings` to get these parameters from user
       configuration.  You can pass an empty mapping to build the client
       solely from `api_params`.
 
-    keep_params: Mapping[str, Any]
-    : Keyword arguments used to construct an associated
-      `arvados.keep.KeepClient`.
+    * keep_params: Mapping[str, Any] --- Keyword arguments used to construct
+      an associated `arvados.keep.KeepClient`.
 
-    api_params: Mapping[str, Any]
-    : Keyword arguments used to construct each thread's API client. These
-      have the same meaning as in the `arvados.api.api` function.
+    * api_params: Mapping[str, Any] --- Keyword arguments used to construct
+      each thread's API client. These have the same meaning as in the
+      `arvados.api.api` function.
 
-    version: str | None
-    : A string naming the version of the Arvados API to use. If not specified,
-      the code will log a warning and fall back to 'v1'.
+    * version: str | None --- A string naming the version of the Arvados API
+      to use. If not specified, the code will log a warning and fall back to
+      `'v1'`.
     """
-
-    def __init__(self, apiconfig=None, keep_params={}, api_params={}, version=None):
+    def __init__(
+            self,
+            apiconfig: Optional[Mapping[str, str]]=None,
+            keep_params: Optional[Mapping[str, Any]]={},
+            api_params: Optional[Mapping[str, Any]]={},
+            version: Optional[str]=None,
+    ) -> None:
         if apiconfig or apiconfig is None:
             self._api_kwargs = api.api_kwargs_from_config(version, apiconfig, **api_params)
         else:
@@ -58,7 +67,7 @@ class ThreadSafeApiCache(object):
         self.local = threading.local()
         self.keep = keep.KeepClient(api_client=self, **keep_params)
 
-    def localapi(self):
+    def localapi(self) -> 'googleapiclient.discovery.Resource':
         try:
             client = self.local.api
         except AttributeError:
@@ -67,6 +76,6 @@ class ThreadSafeApiCache(object):
             self.local.api = client
         return client
 
-    def __getattr__(self, name):
+    def __getattr__(self, name: str) -> Any:
         # Proxy nonexistent attributes to the thread-local API client.
         return getattr(self.localapi(), name)
index edfb7711b829a100688f82bff203ebfec986096d..37cd5d7db89f626c24560e0e965408d7de1191aa 100644 (file)
@@ -20,11 +20,13 @@ from arvados.retry import retry_method
 from arvados.keep import *
 from . import config
 from . import errors
+from . import util
 from ._normalize_stream import normalize_stream
 
 class StreamReader(object):
+    @util._deprecated('3.0', 'arvados.collection.Collecttion')
     def __init__(self, tokens, keep=None, debug=False, _empty=False,
-                 num_retries=0):
+                 num_retries=10):
         self._stream_name = None
         self._data_locators = []
         self._files = collections.OrderedDict()
index a4b7e64a05e2c74b8c330bb5b66f76d9974aeab7..050c67f68d082bc26706d8e5f3e4af69e094a0d4 100644 (file)
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+"""Arvados utilities
 
-from __future__ import division
-from builtins import range
+This module provides functions and constants that are useful across a variety
+of Arvados resource types, or extend the Arvados API client (see `arvados.api`).
+"""
 
+import errno
 import fcntl
+import functools
 import hashlib
 import httplib2
 import os
 import random
 import re
 import subprocess
-import errno
 import sys
+import warnings
+
+import arvados.errors
 
-import arvados
-from arvados.collection import CollectionReader
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Iterator,
+    TypeVar,
+    Union,
+)
+
+T = TypeVar('T')
 
 HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
+"""Regular expression to match a hexadecimal string (case-insensitive)"""
 CR_UNCOMMITTED = 'Uncommitted'
+"""Constant `state` value for uncommited container requests"""
 CR_COMMITTED = 'Committed'
+"""Constant `state` value for committed container requests"""
 CR_FINAL = 'Final'
+"""Constant `state` value for finalized container requests"""
+
+keep_locator_pattern = re.compile(r'[0-9a-f]{32}\+[0-9]+(\+\S+)*')
+"""Regular expression to match any Keep block locator"""
+signed_locator_pattern = re.compile(r'[0-9a-f]{32}\+[0-9]+(\+\S+)*\+A\S+(\+\S+)*')
+"""Regular expression to match any Keep block locator with an access token hint"""
+portable_data_hash_pattern = re.compile(r'[0-9a-f]{32}\+[0-9]+')
+"""Regular expression to match any collection portable data hash"""
+manifest_pattern = re.compile(r'((\S+)( +[a-f0-9]{32}(\+[0-9]+)(\+\S+)*)+( +[0-9]+:[0-9]+:\S+)+$)+', flags=re.MULTILINE)
+"""Regular expression to match an Arvados collection manifest text"""
+keep_file_locator_pattern = re.compile(r'([0-9a-f]{32}\+[0-9]+)/(.*)')
+"""Regular expression to match a file path from a collection identified by portable data hash"""
+keepuri_pattern = re.compile(r'keep:([0-9a-f]{32}\+[0-9]+)/(.*)')
+"""Regular expression to match a `keep:` URI with a collection identified by portable data hash"""
 
-keep_locator_pattern = re.compile(r'[0-9a-f]{32}\+\d+(\+\S+)*')
-signed_locator_pattern = re.compile(r'[0-9a-f]{32}\+\d+(\+\S+)*\+A\S+(\+\S+)*')
-portable_data_hash_pattern = re.compile(r'[0-9a-f]{32}\+\d+')
 uuid_pattern = re.compile(r'[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}')
+"""Regular expression to match any Arvados object UUID"""
 collection_uuid_pattern = re.compile(r'[a-z0-9]{5}-4zz18-[a-z0-9]{15}')
+"""Regular expression to match any Arvados collection UUID"""
+container_uuid_pattern = re.compile(r'[a-z0-9]{5}-dz642-[a-z0-9]{15}')
+"""Regular expression to match any Arvados container UUID"""
 group_uuid_pattern = re.compile(r'[a-z0-9]{5}-j7d0g-[a-z0-9]{15}')
-user_uuid_pattern = re.compile(r'[a-z0-9]{5}-tpzed-[a-z0-9]{15}')
+"""Regular expression to match any Arvados group UUID"""
 link_uuid_pattern = re.compile(r'[a-z0-9]{5}-o0j2j-[a-z0-9]{15}')
+"""Regular expression to match any Arvados link UUID"""
+user_uuid_pattern = re.compile(r'[a-z0-9]{5}-tpzed-[a-z0-9]{15}')
+"""Regular expression to match any Arvados user UUID"""
 job_uuid_pattern = re.compile(r'[a-z0-9]{5}-8i9sb-[a-z0-9]{15}')
-container_uuid_pattern = re.compile(r'[a-z0-9]{5}-dz642-[a-z0-9]{15}')
-manifest_pattern = re.compile(r'((\S+)( +[a-f0-9]{32}(\+\d+)(\+\S+)*)+( +\d+:\d+:\S+)+$)+', flags=re.MULTILINE)
+"""Regular expression to match any Arvados job UUID
+
+.. WARNING:: Deprecated
+   Arvados job resources are deprecated and will be removed in a future
+   release. Prefer the containers API instead.
+"""
+
+def _deprecated(version=None, preferred=None):
+    """Mark a callable as deprecated in the SDK
+
+    This will wrap the callable to emit as a DeprecationWarning
+    and add a deprecation notice to its docstring.
+
+    If the following arguments are given, they'll be included in the
+    notices:
+
+    * preferred: str | None --- The name of an alternative that users should
+      use instead.
+
+    * version: str | None --- The version of Arvados when the callable is
+      scheduled to be removed.
+    """
+    if version is None:
+        version = ''
+    else:
+        version = f' and scheduled to be removed in Arvados {version}'
+    if preferred is None:
+        preferred = ''
+    else:
+        preferred = f' Prefer {preferred} instead.'
+    def deprecated_decorator(func):
+        fullname = f'{func.__module__}.{func.__qualname__}'
+        parent, _, name = fullname.rpartition('.')
+        if name == '__init__':
+            fullname = parent
+        warning_msg = f'{fullname} is deprecated{version}.{preferred}'
+        @functools.wraps(func)
+        def deprecated_wrapper(*args, **kwargs):
+            warnings.warn(warning_msg, DeprecationWarning, 2)
+            return func(*args, **kwargs)
+        # Get func's docstring without any trailing newline or empty lines.
+        func_doc = re.sub(r'\n\s*$', '', func.__doc__ or '')
+        match = re.search(r'\n([ \t]+)\S', func_doc)
+        indent = '' if match is None else match.group(1)
+        warning_doc = f'\n\n{indent}.. WARNING:: Deprecated\n{indent}   {warning_msg}'
+        # Make the deprecation notice the second "paragraph" of the
+        # docstring if possible. Otherwise append it.
+        docstring, count = re.subn(
+            rf'\n[ \t]*\n{indent}',
+            f'{warning_doc}\n\n{indent}',
+            func_doc,
+            count=1,
+        )
+        if not count:
+            docstring = f'{func_doc.lstrip()}{warning_doc}'
+        deprecated_wrapper.__doc__ = docstring
+        return deprecated_wrapper
+    return deprecated_decorator
+
+def is_hex(s: str, *length_args: int) -> bool:
+    """Indicate whether a string is a hexadecimal number
+
+    This method returns true if all characters in the string are hexadecimal
+    digits. It is case-insensitive.
+
+    You can also pass optional length arguments to check that the string has
+    the expected number of digits. If you pass one integer, the string must
+    have that length exactly, otherwise the method returns False. If you
+    pass two integers, the string's length must fall within that minimum and
+    maximum (inclusive), otherwise the method returns False.
+
+    Arguments:
+
+    * s: str --- The string to check
+
+    * length_args: int --- Optional length limit(s) for the string to check
+    """
+    num_length_args = len(length_args)
+    if num_length_args > 2:
+        raise arvados.errors.ArgumentError(
+            "is_hex accepts up to 3 arguments ({} given)".format(1 + num_length_args))
+    elif num_length_args == 2:
+        good_len = (length_args[0] <= len(s) <= length_args[1])
+    elif num_length_args == 1:
+        good_len = (len(s) == length_args[0])
+    else:
+        good_len = True
+    return bool(good_len and HEX_RE.match(s))
+
+def keyset_list_all(
+        fn: Callable[..., 'arvados.api_resources.ArvadosAPIRequest'],
+        order_key: str="created_at",
+        num_retries: int=0,
+        ascending: bool=True,
+        **kwargs: Any,
+) -> Iterator[Dict[str, Any]]:
+    """Iterate all Arvados resources from an API list call
+
+    This method takes a method that represents an Arvados API list call, and
+    iterates the objects returned by the API server. It can make multiple API
+    calls to retrieve and iterate all objects available from the API server.
+
+    Arguments:
+
+    * fn: Callable[..., arvados.api_resources.ArvadosAPIRequest] --- A
+      function that wraps an Arvados API method that returns a list of
+      objects. If you have an Arvados API client named `arv`, examples
+      include `arv.collections().list` and `arv.groups().contents`. Note
+      that you should pass the function *without* calling it.
+
+    * order_key: str --- The name of the primary object field that objects
+      should be sorted by. This name is used to build an `order` argument
+      for `fn`. Default `'created_at'`.
+
+    * num_retries: int --- This argument is passed through to
+      `arvados.api_resources.ArvadosAPIRequest.execute` for each API call. See
+      that method's docstring for details. Default 0 (meaning API calls will
+      use the `num_retries` value set when the Arvados API client was
+      constructed).
+
+    * ascending: bool --- Used to build an `order` argument for `fn`. If True,
+      all fields will be sorted in `'asc'` (ascending) order. Otherwise, all
+      fields will be sorted in `'desc'` (descending) order.
+
+    Additional keyword arguments will be passed directly to `fn` for each API
+    call. Note that this function sets `count`, `limit`, and `order` as part of
+    its work.
+    """
+    pagesize = 1000
+    kwargs["limit"] = pagesize
+    kwargs["count"] = 'none'
+    asc = "asc" if ascending else "desc"
+    kwargs["order"] = ["%s %s" % (order_key, asc), "uuid %s" % asc]
+    other_filters = kwargs.get("filters", [])
+
+    try:
+        select = set(kwargs['select'])
+    except KeyError:
+        pass
+    else:
+        select.add(order_key)
+        select.add('uuid')
+        kwargs['select'] = list(select)
+
+    nextpage = []
+    tot = 0
+    expect_full_page = True
+    seen_prevpage = set()
+    seen_thispage = set()
+    lastitem = None
+    prev_page_all_same_order_key = False
+
+    while True:
+        kwargs["filters"] = nextpage+other_filters
+        items = fn(**kwargs).execute(num_retries=num_retries)
+
+        if len(items["items"]) == 0:
+            if prev_page_all_same_order_key:
+                nextpage = [[order_key, ">" if ascending else "<", lastitem[order_key]]]
+                prev_page_all_same_order_key = False
+                continue
+            else:
+                return
+
+        seen_prevpage = seen_thispage
+        seen_thispage = set()
+
+        for i in items["items"]:
+            # In cases where there's more than one record with the
+            # same order key, the result could include records we
+            # already saw in the last page.  Skip them.
+            if i["uuid"] in seen_prevpage:
+                continue
+            seen_thispage.add(i["uuid"])
+            yield i
+
+        firstitem = items["items"][0]
+        lastitem = items["items"][-1]
 
+        if firstitem[order_key] == lastitem[order_key]:
+            # Got a page where every item has the same order key.
+            # Switch to using uuid for paging.
+            nextpage = [[order_key, "=", lastitem[order_key]], ["uuid", ">" if ascending else "<", lastitem["uuid"]]]
+            prev_page_all_same_order_key = True
+        else:
+            # Start from the last order key seen, but skip the last
+            # known uuid to avoid retrieving the same row twice.  If
+            # there are multiple rows with the same order key it is
+            # still likely we'll end up retrieving duplicate rows.
+            # That's handled by tracking the "seen" rows for each page
+            # so they can be skipped if they show up on the next page.
+            nextpage = [[order_key, ">=" if ascending else "<=", lastitem[order_key]], ["uuid", "!=", lastitem["uuid"]]]
+            prev_page_all_same_order_key = False
+
+def ca_certs_path(fallback: T=httplib2.CA_CERTS) -> Union[str, T]:
+    """Return the path of the best available source of CA certificates
+
+    This function checks various known paths that provide trusted CA
+    certificates, and returns the first one that exists. It checks:
+
+    * the path in the `SSL_CERT_FILE` environment variable (used by OpenSSL)
+    * `/etc/arvados/ca-certificates.crt`, respected by all Arvados software
+    * `/etc/ssl/certs/ca-certificates.crt`, the default store on Debian-based
+      distributions
+    * `/etc/pki/tls/certs/ca-bundle.crt`, the default store on Red Hat-based
+      distributions
+
+    If none of these paths exist, this function returns the value of `fallback`.
+
+    Arguments:
+
+    * fallback: T --- The value to return if none of the known paths exist.
+      The default value is the certificate store of Mozilla's trusted CAs
+      included with the Python [certifi][] package.
+
+    [certifi]: https://pypi.org/project/certifi/
+    """
+    for ca_certs_path in [
+        # SSL_CERT_FILE and SSL_CERT_DIR are openssl overrides - note
+        # that httplib2 itself also supports HTTPLIB2_CA_CERTS.
+        os.environ.get('SSL_CERT_FILE'),
+        # Arvados specific:
+        '/etc/arvados/ca-certificates.crt',
+        # Debian:
+        '/etc/ssl/certs/ca-certificates.crt',
+        # Red Hat:
+        '/etc/pki/tls/certs/ca-bundle.crt',
+        ]:
+        if ca_certs_path and os.path.exists(ca_certs_path):
+            return ca_certs_path
+    return fallback
+
+def new_request_id() -> str:
+    """Return a random request ID
+
+    This function generates and returns a random string suitable for use as a
+    `X-Request-Id` header value in the Arvados API.
+    """
+    rid = "req-"
+    # 2**104 > 36**20 > 2**103
+    n = random.getrandbits(104)
+    for _ in range(20):
+        c = n % 36
+        if c < 10:
+            rid += chr(c+ord('0'))
+        else:
+            rid += chr(c+ord('a')-10)
+        n = n // 36
+    return rid
+
+def get_config_once(svc: 'arvados.api_resources.ArvadosAPIClient') -> Dict[str, Any]:
+    """Return an Arvados cluster's configuration, with caching
+
+    This function gets and returns the Arvados configuration from the API
+    server. It caches the result on the client object and reuses it on any
+    future calls.
+
+    Arguments:
+
+    * svc: arvados.api_resources.ArvadosAPIClient --- The Arvados API client
+      object to use to retrieve and cache the Arvados cluster configuration.
+    """
+    if not svc._rootDesc.get('resources').get('configs', False):
+        # Old API server version, no config export endpoint
+        return {}
+    if not hasattr(svc, '_cached_config'):
+        svc._cached_config = svc.configs().get().execute()
+    return svc._cached_config
+
+def get_vocabulary_once(svc: 'arvados.api_resources.ArvadosAPIClient') -> Dict[str, Any]:
+    """Return an Arvados cluster's vocabulary, with caching
+
+    This function gets and returns the Arvados vocabulary from the API
+    server. It caches the result on the client object and reuses it on any
+    future calls.
+
+    .. HINT:: Low-level method
+       This is a relatively low-level wrapper around the Arvados API. Most
+       users will prefer to use `arvados.vocabulary.load_vocabulary`.
+
+    Arguments:
+
+    * svc: arvados.api_resources.ArvadosAPIClient --- The Arvados API client
+      object to use to retrieve and cache the Arvados cluster vocabulary.
+    """
+    if not svc._rootDesc.get('resources').get('vocabularies', False):
+        # Old API server version, no vocabulary export endpoint
+        return {}
+    if not hasattr(svc, '_cached_vocabulary'):
+        svc._cached_vocabulary = svc.vocabularies().get().execute()
+    return svc._cached_vocabulary
+
+def trim_name(collectionname: str) -> str:
+    """Limit the length of a name to fit within Arvados API limits
+
+    This function ensures that a string is short enough to use as an object
+    name in the Arvados API, leaving room for text that may be added by the
+    `ensure_unique_name` argument. If the source name is short enough, it is
+    returned unchanged. Otherwise, this function returns a string with excess
+    characters removed from the middle of the source string and replaced with
+    an ellipsis.
+
+    Arguments:
+
+    * collectionname: str --- The desired source name
+    """
+    max_name_len = 254 - 28
+
+    if len(collectionname) > max_name_len:
+        over = len(collectionname) - max_name_len
+        split = int(max_name_len/2)
+        collectionname = collectionname[0:split] + "…" + collectionname[split+over:]
+
+    return collectionname
+
+@_deprecated('3.0', 'arvados.util.keyset_list_all')
+def list_all(fn, num_retries=0, **kwargs):
+    # Default limit to (effectively) api server's MAX_LIMIT
+    kwargs.setdefault('limit', sys.maxsize)
+    items = []
+    offset = 0
+    items_available = sys.maxsize
+    while len(items) < items_available:
+        c = fn(offset=offset, **kwargs).execute(num_retries=num_retries)
+        items += c['items']
+        items_available = c['items_available']
+        offset = c['offset'] + len(c['items'])
+    return items
+
+@_deprecated('3.0')
 def clear_tmpdir(path=None):
     """
     Ensure the given directory (or TASK_TMPDIR if none given)
     exists and is empty.
     """
+    from arvados import current_task
     if path is None:
-        path = arvados.current_task().tmpdir
+        path = current_task().tmpdir
     if os.path.exists(path):
         p = subprocess.Popen(['rm', '-rf', path])
         stdout, stderr = p.communicate(None)
@@ -49,6 +412,7 @@ def clear_tmpdir(path=None):
             raise Exception('rm -rf %s: %s' % (path, stderr))
     os.mkdir(path)
 
+@_deprecated('3.0', 'subprocess.run')
 def run_command(execargs, **kwargs):
     kwargs.setdefault('stdin', subprocess.PIPE)
     kwargs.setdefault('stdout', subprocess.PIPE)
@@ -63,9 +427,11 @@ def run_command(execargs, **kwargs):
             (execargs, p.returncode, stderrdata))
     return stdoutdata, stderrdata
 
+@_deprecated('3.0')
 def git_checkout(url, version, path):
+    from arvados import current_job
     if not re.search('^/', path):
-        path = os.path.join(arvados.current_job().tmpdir, path)
+        path = os.path.join(current_job().tmpdir, path)
     if not os.path.exists(path):
         run_command(["git", "clone", url, path],
                     cwd=os.path.dirname(path))
@@ -73,6 +439,7 @@ def git_checkout(url, version, path):
                 cwd=path)
     return path
 
+@_deprecated('3.0')
 def tar_extractor(path, decompress_flag):
     return subprocess.Popen(["tar",
                              "-C", path,
@@ -82,6 +449,7 @@ def tar_extractor(path, decompress_flag):
                             stdin=subprocess.PIPE, stderr=sys.stderr,
                             shell=False, close_fds=True)
 
+@_deprecated('3.0', 'arvados.collection.Collection.open and the tarfile module')
 def tarball_extract(tarball, path):
     """Retrieve a tarball from Keep and extract it to a local
     directory.  Return the absolute path where the tarball was
@@ -92,8 +460,10 @@ def tarball_extract(tarball, path):
     tarball -- collection locator
     path -- where to extract the tarball: absolute, or relative to job tmp
     """
+    from arvados import current_job
+    from arvados.collection import CollectionReader
     if not re.search('^/', path):
-        path = os.path.join(arvados.current_job().tmpdir, path)
+        path = os.path.join(current_job().tmpdir, path)
     lockfile = open(path + '.lock', 'w')
     fcntl.flock(lockfile, fcntl.LOCK_EX)
     try:
@@ -116,11 +486,12 @@ def tarball_extract(tarball, path):
                 os.unlink(os.path.join(path, '.locator'))
 
         for f in CollectionReader(tarball).all_files():
-            if re.search('\.(tbz|tar.bz2)$', f.name()):
+            f_name = f.name()
+            if f_name.endswith(('.tbz', '.tar.bz2')):
                 p = tar_extractor(path, 'j')
-            elif re.search('\.(tgz|tar.gz)$', f.name()):
+            elif f_name.endswith(('.tgz', '.tar.gz')):
                 p = tar_extractor(path, 'z')
-            elif re.search('\.tar$', f.name()):
+            elif f_name.endswith('.tar'):
                 p = tar_extractor(path, '')
             else:
                 raise arvados.errors.AssertionError(
@@ -143,6 +514,7 @@ def tarball_extract(tarball, path):
         return os.path.join(path, tld_extracts[0])
     return path
 
+@_deprecated('3.0', 'arvados.collection.Collection.open and the zipfile module')
 def zipball_extract(zipball, path):
     """Retrieve a zip archive from Keep and extract it to a local
     directory.  Return the absolute path where the archive was
@@ -153,8 +525,10 @@ def zipball_extract(zipball, path):
     zipball -- collection locator
     path -- where to extract the archive: absolute, or relative to job tmp
     """
+    from arvados import current_job
+    from arvados.collection import CollectionReader
     if not re.search('^/', path):
-        path = os.path.join(arvados.current_job().tmpdir, path)
+        path = os.path.join(current_job().tmpdir, path)
     lockfile = open(path + '.lock', 'w')
     fcntl.flock(lockfile, fcntl.LOCK_EX)
     try:
@@ -177,7 +551,7 @@ def zipball_extract(zipball, path):
                 os.unlink(os.path.join(path, '.locator'))
 
         for f in CollectionReader(zipball).all_files():
-            if not re.search('\.zip$', f.name()):
+            if not f.name().endswith('.zip'):
                 raise arvados.errors.NotImplementedError(
                     "zipball_extract cannot handle filename %s" % f.name())
             zip_filename = os.path.join(path, os.path.basename(f.name()))
@@ -209,6 +583,7 @@ def zipball_extract(zipball, path):
         return os.path.join(path, tld_extracts[0])
     return path
 
+@_deprecated('3.0', 'arvados.collection.Collection')
 def collection_extract(collection, path, files=[], decompress=True):
     """Retrieve a collection from Keep and extract it to a local
     directory.  Return the absolute path where the collection was
@@ -217,13 +592,15 @@ def collection_extract(collection, path, files=[], decompress=True):
     collection -- collection locator
     path -- where to extract: absolute, or relative to job tmp
     """
+    from arvados import current_job
+    from arvados.collection import CollectionReader
     matches = re.search(r'^([0-9a-f]+)(\+[\w@]+)*$', collection)
     if matches:
         collection_hash = matches.group(1)
     else:
         collection_hash = hashlib.md5(collection).hexdigest()
     if not re.search('^/', path):
-        path = os.path.join(arvados.current_job().tmpdir, path)
+        path = os.path.join(current_job().tmpdir, path)
     lockfile = open(path + '.lock', 'w')
     fcntl.flock(lockfile, fcntl.LOCK_EX)
     try:
@@ -272,6 +649,7 @@ def collection_extract(collection, path, files=[], decompress=True):
     lockfile.close()
     return path
 
+@_deprecated('3.0', 'pathlib.Path().mkdir(parents=True, exist_ok=True)')
 def mkdir_dash_p(path):
     if not os.path.isdir(path):
         try:
@@ -284,6 +662,7 @@ def mkdir_dash_p(path):
             else:
                 raise
 
+@_deprecated('3.0', 'arvados.collection.Collection')
 def stream_extract(stream, path, files=[], decompress=True):
     """Retrieve a stream from Keep and extract it to a local
     directory.  Return the absolute path where the stream was
@@ -292,8 +671,9 @@ def stream_extract(stream, path, files=[], decompress=True):
     stream -- StreamReader object
     path -- where to extract: absolute, or relative to job tmp
     """
+    from arvados import current_job
     if not re.search('^/', path):
-        path = os.path.join(arvados.current_job().tmpdir, path)
+        path = os.path.join(current_job().tmpdir, path)
     lockfile = open(path + '.lock', 'w')
     fcntl.flock(lockfile, fcntl.LOCK_EX)
     try:
@@ -324,6 +704,7 @@ def stream_extract(stream, path, files=[], decompress=True):
     lockfile.close()
     return path
 
+@_deprecated('3.0', 'os.walk')
 def listdir_recursive(dirname, base=None, max_depth=None):
     """listdir_recursive(dirname, base, max_depth)
 
@@ -352,168 +733,3 @@ def listdir_recursive(dirname, base=None, max_depth=None):
         else:
             allfiles += [ent_base]
     return allfiles
-
-def is_hex(s, *length_args):
-    """is_hex(s[, length[, max_length]]) -> boolean
-
-    Return True if s is a string of hexadecimal digits.
-    If one length argument is given, the string must contain exactly
-    that number of digits.
-    If two length arguments are given, the string must contain a number of
-    digits between those two lengths, inclusive.
-    Return False otherwise.
-    """
-    num_length_args = len(length_args)
-    if num_length_args > 2:
-        raise arvados.errors.ArgumentError(
-            "is_hex accepts up to 3 arguments ({} given)".format(1 + num_length_args))
-    elif num_length_args == 2:
-        good_len = (length_args[0] <= len(s) <= length_args[1])
-    elif num_length_args == 1:
-        good_len = (len(s) == length_args[0])
-    else:
-        good_len = True
-    return bool(good_len and HEX_RE.match(s))
-
-def list_all(fn, num_retries=0, **kwargs):
-    # Default limit to (effectively) api server's MAX_LIMIT
-    kwargs.setdefault('limit', sys.maxsize)
-    items = []
-    offset = 0
-    items_available = sys.maxsize
-    while len(items) < items_available:
-        c = fn(offset=offset, **kwargs).execute(num_retries=num_retries)
-        items += c['items']
-        items_available = c['items_available']
-        offset = c['offset'] + len(c['items'])
-    return items
-
-def keyset_list_all(fn, order_key="created_at", num_retries=0, ascending=True, **kwargs):
-    pagesize = 1000
-    kwargs["limit"] = pagesize
-    kwargs["count"] = 'none'
-    asc = "asc" if ascending else "desc"
-    kwargs["order"] = ["%s %s" % (order_key, asc), "uuid %s" % asc]
-    other_filters = kwargs.get("filters", [])
-
-    if "select" in kwargs and "uuid" not in kwargs["select"]:
-        kwargs["select"].append("uuid")
-
-    nextpage = []
-    tot = 0
-    expect_full_page = True
-    seen_prevpage = set()
-    seen_thispage = set()
-    lastitem = None
-    prev_page_all_same_order_key = False
-
-    while True:
-        kwargs["filters"] = nextpage+other_filters
-        items = fn(**kwargs).execute(num_retries=num_retries)
-
-        if len(items["items"]) == 0:
-            if prev_page_all_same_order_key:
-                nextpage = [[order_key, ">" if ascending else "<", lastitem[order_key]]]
-                prev_page_all_same_order_key = False
-                continue
-            else:
-                return
-
-        seen_prevpage = seen_thispage
-        seen_thispage = set()
-
-        for i in items["items"]:
-            # In cases where there's more than one record with the
-            # same order key, the result could include records we
-            # already saw in the last page.  Skip them.
-            if i["uuid"] in seen_prevpage:
-                continue
-            seen_thispage.add(i["uuid"])
-            yield i
-
-        firstitem = items["items"][0]
-        lastitem = items["items"][-1]
-
-        if firstitem[order_key] == lastitem[order_key]:
-            # Got a page where every item has the same order key.
-            # Switch to using uuid for paging.
-            nextpage = [[order_key, "=", lastitem[order_key]], ["uuid", ">" if ascending else "<", lastitem["uuid"]]]
-            prev_page_all_same_order_key = True
-        else:
-            # Start from the last order key seen, but skip the last
-            # known uuid to avoid retrieving the same row twice.  If
-            # there are multiple rows with the same order key it is
-            # still likely we'll end up retrieving duplicate rows.
-            # That's handled by tracking the "seen" rows for each page
-            # so they can be skipped if they show up on the next page.
-            nextpage = [[order_key, ">=" if ascending else "<=", lastitem[order_key]], ["uuid", "!=", lastitem["uuid"]]]
-            prev_page_all_same_order_key = False
-
-
-def ca_certs_path(fallback=httplib2.CA_CERTS):
-    """Return the path of the best available CA certs source.
-
-    This function searches for various distribution sources of CA
-    certificates, and returns the first it finds.  If it doesn't find any,
-    it returns the value of `fallback` (httplib2's CA certs by default).
-    """
-    for ca_certs_path in [
-        # SSL_CERT_FILE and SSL_CERT_DIR are openssl overrides - note
-        # that httplib2 itself also supports HTTPLIB2_CA_CERTS.
-        os.environ.get('SSL_CERT_FILE'),
-        # Arvados specific:
-        '/etc/arvados/ca-certificates.crt',
-        # Debian:
-        '/etc/ssl/certs/ca-certificates.crt',
-        # Red Hat:
-        '/etc/pki/tls/certs/ca-bundle.crt',
-        ]:
-        if ca_certs_path and os.path.exists(ca_certs_path):
-            return ca_certs_path
-    return fallback
-
-def new_request_id():
-    rid = "req-"
-    # 2**104 > 36**20 > 2**103
-    n = random.getrandbits(104)
-    for _ in range(20):
-        c = n % 36
-        if c < 10:
-            rid += chr(c+ord('0'))
-        else:
-            rid += chr(c+ord('a')-10)
-        n = n // 36
-    return rid
-
-def get_config_once(svc):
-    if not svc._rootDesc.get('resources').get('configs', False):
-        # Old API server version, no config export endpoint
-        return {}
-    if not hasattr(svc, '_cached_config'):
-        svc._cached_config = svc.configs().get().execute()
-    return svc._cached_config
-
-def get_vocabulary_once(svc):
-    if not svc._rootDesc.get('resources').get('vocabularies', False):
-        # Old API server version, no vocabulary export endpoint
-        return {}
-    if not hasattr(svc, '_cached_vocabulary'):
-        svc._cached_vocabulary = svc.vocabularies().get().execute()
-    return svc._cached_vocabulary
-
-def trim_name(collectionname):
-    """
-    trim_name takes a record name (collection name, project name, etc)
-    and trims it to fit the 255 character name limit, with additional
-    space for the timestamp added by ensure_unique_name, by removing
-    excess characters from the middle and inserting an ellipse
-    """
-
-    max_name_len = 254 - 28
-
-    if len(collectionname) > max_name_len:
-        over = len(collectionname) - max_name_len
-        split = int(max_name_len/2)
-        collectionname = collectionname[0:split] + "…" + collectionname[split+over:]
-
-    return collectionname
index 092131d930aeddf880eae21a521d59f4122b7404..794b6afe4261cba9c6bfc4c5dd3fee9d6bb6c19b 100644 (file)
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+#
+# This file runs in one of three modes:
+#
+# 1. If the ARVADOS_BUILDING_VERSION environment variable is set, it writes
+#    _version.py and generates dependencies based on that value.
+# 2. If running from an arvados Git checkout, it writes _version.py
+#    and generates dependencies from Git.
+# 3. Otherwise, we expect this is source previously generated from Git, and
+#    it reads _version.py and generates dependencies from it.
 
-import subprocess
-import time
 import os
 import re
+import runpy
+import subprocess
 import sys
 
-SETUP_DIR = os.path.dirname(os.path.abspath(__file__))
-VERSION_PATHS = {
-        SETUP_DIR,
-        os.path.abspath(os.path.join(SETUP_DIR, "../../build/version-at-commit.sh"))
-        }
+from pathlib import Path
+
+# These maps explain the relationships between different Python modules in
+# the arvados repository. We use these to help generate setup.py.
+PACKAGE_DEPENDENCY_MAP = {
+    'arvados-cwl-runner': ['arvados-python-client', 'crunchstat_summary'],
+    'arvados-user-activity': ['arvados-python-client'],
+    'arvados_fuse': ['arvados-python-client'],
+    'crunchstat_summary': ['arvados-python-client'],
+}
+PACKAGE_MODULE_MAP = {
+    'arvados-cwl-runner': 'arvados_cwl',
+    'arvados-docker-cleaner': 'arvados_docker',
+    'arvados-python-client': 'arvados',
+    'arvados-user-activity': 'arvados_user_activity',
+    'arvados_fuse': 'arvados_fuse',
+    'crunchstat_summary': 'crunchstat_summary',
+}
+PACKAGE_SRCPATH_MAP = {
+    'arvados-cwl-runner': Path('sdk', 'cwl'),
+    'arvados-docker-cleaner': Path('services', 'dockercleaner'),
+    'arvados-python-client': Path('sdk', 'python'),
+    'arvados-user-activity': Path('tools', 'user-activity'),
+    'arvados_fuse': Path('services', 'fuse'),
+    'crunchstat_summary': Path('tools', 'crunchstat-summary'),
+}
+
+ENV_VERSION = os.environ.get("ARVADOS_BUILDING_VERSION")
+SETUP_DIR = Path(__file__).absolute().parent
+try:
+    REPO_PATH = Path(subprocess.check_output(
+        ['git', '-C', str(SETUP_DIR), 'rev-parse', '--show-toplevel'],
+        stderr=subprocess.DEVNULL,
+        text=True,
+    ).rstrip('\n'))
+except (subprocess.CalledProcessError, OSError):
+    REPO_PATH = None
+else:
+    # Verify this is the arvados monorepo
+    if all((REPO_PATH / path).exists() for path in PACKAGE_SRCPATH_MAP.values()):
+        PACKAGE_NAME, = (
+            pkg_name for pkg_name, path in PACKAGE_SRCPATH_MAP.items()
+            if (REPO_PATH / path) == SETUP_DIR
+        )
+        MODULE_NAME = PACKAGE_MODULE_MAP[PACKAGE_NAME]
+        VERSION_SCRIPT_PATH = Path(REPO_PATH, 'build', 'version-at-commit.sh')
+    else:
+        REPO_PATH = None
+if REPO_PATH is None:
+    (PACKAGE_NAME, MODULE_NAME), = (
+        (pkg_name, mod_name)
+        for pkg_name, mod_name in PACKAGE_MODULE_MAP.items()
+        if (SETUP_DIR / mod_name).is_dir()
+    )
+
+def short_tests_only(arglist=sys.argv):
+    try:
+        arglist.remove('--short-tests-only')
+    except ValueError:
+        return False
+    else:
+        return True
+
+def git_log_output(path, *args):
+    return subprocess.check_output(
+        ['git', '-C', str(REPO_PATH),
+         'log', '--first-parent', '--max-count=1',
+         *args, str(path)],
+        text=True,
+    ).rstrip('\n')
 
 def choose_version_from():
-    ts = {}
-    for path in VERSION_PATHS:
-        ts[subprocess.check_output(
-            ['git', 'log', '--first-parent', '--max-count=1',
-             '--format=format:%ct', path]).strip()] = path
-
-    sorted_ts = sorted(ts.items())
-    getver = sorted_ts[-1][1]
-    print("Using "+getver+" for version number calculation of "+SETUP_DIR, file=sys.stderr)
+    ver_paths = [SETUP_DIR, VERSION_SCRIPT_PATH, *(
+        PACKAGE_SRCPATH_MAP[pkg]
+        for pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ())
+    )]
+    getver = max(ver_paths, key=lambda path: git_log_output(path, '--format=format:%ct'))
+    print(f"Using {getver} for version number calculation of {SETUP_DIR}", file=sys.stderr)
     return getver
 
 def git_version_at_commit():
     curdir = choose_version_from()
-    myhash = subprocess.check_output(['git', 'log', '-n1', '--first-parent',
-                                       '--format=%H', curdir]).strip()
-    myversion = subprocess.check_output([SETUP_DIR+'/../../build/version-at-commit.sh', myhash]).strip().decode()
-    return myversion
+    myhash = git_log_output(curdir, '--format=%H')
+    return subprocess.check_output(
+        [str(VERSION_SCRIPT_PATH), myhash],
+        text=True,
+    ).rstrip('\n')
 
 def save_version(setup_dir, module, v):
-    v = v.replace("~dev", ".dev").replace("~rc", "rc")
-    with open(os.path.join(setup_dir, module, "_version.py"), 'wt') as fp:
-        return fp.write("__version__ = '%s'\n" % v)
+    with Path(setup_dir, module, '_version.py').open('w') as fp:
+        print(f"__version__ = {v!r}", file=fp)
 
 def read_version(setup_dir, module):
-    with open(os.path.join(setup_dir, module, "_version.py"), 'rt') as fp:
-        return re.match("__version__ = '(.*)'$", fp.read()).groups()[0]
-
-def get_version(setup_dir, module):
-    env_version = os.environ.get("ARVADOS_BUILDING_VERSION")
+    file_vars = runpy.run_path(Path(setup_dir, module, '_version.py'))
+    return file_vars['__version__']
 
-    if env_version:
-        save_version(setup_dir, module, env_version)
+def get_version(setup_dir=SETUP_DIR, module=MODULE_NAME):
+    if ENV_VERSION:
+        version = ENV_VERSION
+    elif REPO_PATH is None:
+        return read_version(setup_dir, module)
     else:
-        try:
-            save_version(setup_dir, module, git_version_at_commit())
-        except (subprocess.CalledProcessError, OSError) as err:
-            print("ERROR: {0}".format(err), file=sys.stderr)
-            pass
+        version = git_version_at_commit()
+    version = version.replace("~dev", ".dev").replace("~rc", "rc")
+    save_version(setup_dir, module, version)
+    return version
 
-    return read_version(setup_dir, module)
+def iter_dependencies(version=None):
+    if version is None:
+        version = get_version()
+    # A packaged development release should be installed with other
+    # development packages built from the same source, but those
+    # dependencies may have earlier "dev" versions (read: less recent
+    # Git commit timestamps). This compatible version dependency
+    # expresses that as closely as possible. Allowing versions
+    # compatible with .dev0 allows any development release.
+    # Regular expression borrowed partially from
+    # <https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers-regex>
+    dep_ver, match_count = re.subn(r'\.dev(0|[1-9][0-9]*)$', '.dev0', version, 1)
+    dep_op = '~=' if match_count else '=='
+    for dep_pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ()):
+        yield f'{dep_pkg}{dep_op}{dep_ver}'
 
 # Called from calculate_python_sdk_cwl_package_versions() in run-library.sh
 if __name__ == '__main__':
-    print(get_version(SETUP_DIR, "arvados"))
+    print(get_version())
diff --git a/sdk/python/discovery2pydoc.py b/sdk/python/discovery2pydoc.py
new file mode 100755 (executable)
index 0000000..70a5137
--- /dev/null
@@ -0,0 +1,431 @@
+#!/usr/bin/env python3
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+"""discovery2pydoc - Build skeleton Python from the Arvados discovery document
+
+This tool reads the Arvados discovery document and writes a Python source file
+with classes and methods that correspond to the resources that
+google-api-python-client builds dynamically. This source does not include any
+implementation, but it does include real method signatures and documentation
+strings, so it's useful as documentation for tools that read Python source,
+including pydoc and pdoc.
+
+If you run this tool with the path to a discovery document, it uses no
+dependencies outside the Python standard library. If it needs to read
+configuration to find the discovery document dynamically, it'll load the
+`arvados` module to do that.
+"""
+
+import argparse
+import inspect
+import json
+import keyword
+import operator
+import os
+import pathlib
+import re
+import sys
+import urllib.parse
+import urllib.request
+
+from typing import (
+    Any,
+    Callable,
+    Mapping,
+    Optional,
+    Sequence,
+)
+
+LOWERCASE = operator.methodcaller('lower')
+NAME_KEY = operator.attrgetter('name')
+STDSTREAM_PATH = pathlib.Path('-')
+TITLECASE = operator.methodcaller('title')
+
+_ALIASED_METHODS = frozenset([
+    'destroy',
+    'index',
+    'show',
+])
+_DEPRECATED_NOTICE = '''
+
+.. WARNING:: Deprecated
+   This resource is deprecated in the Arvados API.
+'''
+_DEPRECATED_RESOURCES = frozenset([
+    'Humans',
+    'JobTasks',
+    'Jobs',
+    'KeepDisks',
+    'Nodes',
+    'PipelineInstances',
+    'PipelineTemplates',
+    'Specimens'
+    'Traits',
+])
+_DEPRECATED_SCHEMAS = frozenset([
+    *(name[:-1] for name in _DEPRECATED_RESOURCES),
+    *(f'{name[:-1]}List' for name in _DEPRECATED_RESOURCES),
+])
+
+_LIST_PYDOC = '''
+
+This is the dictionary object returned when you call `{cls_name}s.list`.
+If you just want to iterate all objects that match your search criteria,
+consider using `arvados.util.keyset_list_all`.
+If you work with this raw object, the keys of the dictionary are documented
+below, along with their types. The `items` key maps to a list of matching
+`{cls_name}` objects.
+'''
+_MODULE_PYDOC = '''Arvados API client reference documentation
+
+This module provides reference documentation for the interface of the
+Arvados API client, including method signatures and type information for
+returned objects. However, the functions in `arvados.api` will return
+different classes at runtime that are generated dynamically from the Arvados
+API discovery document. The classes in this module do not have any
+implementation, and you should not instantiate them in your code.
+
+If you're just starting out, `ArvadosAPIClient` documents the methods
+available from the client object. From there, you can follow the trail into
+resource methods, request objects, and finally the data dictionaries returned
+by the API server.
+'''
+_SCHEMA_PYDOC = '''
+
+This is the dictionary object that represents a single {cls_name} in Arvados
+and is returned by most `{cls_name}s` methods.
+The keys of the dictionary are documented below, along with their types.
+Not every key may appear in every dictionary returned by an API call.
+When a method doesn't return all the data, you can use its `select` parameter
+to list the specific keys you need. Refer to the API documentation for details.
+'''
+
+_MODULE_PRELUDE = '''
+import googleapiclient.discovery
+import googleapiclient.http
+import httplib2
+import sys
+from typing import Any, Dict, Generic, List, Optional, TypeVar
+if sys.version_info < (3, 8):
+    from typing_extensions import TypedDict
+else:
+    from typing import TypedDict
+
+# ST represents an API response type
+ST = TypeVar('ST', bound=TypedDict)
+'''
+_REQUEST_CLASS = '''
+class ArvadosAPIRequest(googleapiclient.http.HttpRequest, Generic[ST]):
+    """Generic API request object
+
+    When you call an API method in the Arvados Python SDK, it returns a
+    request object. You usually call `execute()` on this object to submit the
+    request to your Arvados API server and retrieve the response. `execute()`
+    will return the type of object annotated in the subscript of
+    `ArvadosAPIRequest`.
+    """
+
+    def execute(self, http: Optional[httplib2.Http]=None, num_retries: int=0) -> ST:
+        """Execute this request and return the response
+
+        Arguments:
+
+        * http: httplib2.Http | None --- The HTTP client object to use to
+          execute the request. If not specified, uses the HTTP client object
+          created with the API client object.
+
+        * num_retries: int --- The maximum number of times to retry this
+          request if the server returns a retryable failure. The API client
+          object also has a maximum number of retries specified when it is
+          instantiated (see `arvados.api.api_client`). This request is run
+          with the larger of that number and this argument. Default 0.
+        """
+
+'''
+
+# Annotation represents a valid Python type annotation. Future development
+# could expand this to include other valid types like `type`.
+Annotation = str
+_TYPE_MAP: Mapping[str, Annotation] = {
+    # Map the API's JavaScript-based type names to Python annotations.
+    # Some of these may disappear after Arvados issue #19795 is fixed.
+    'Array': 'List',
+    'array': 'List',
+    'boolean': 'bool',
+    # datetime fields are strings in ISO 8601 format.
+    'datetime': 'str',
+    'Hash': 'Dict[str, Any]',
+    'integer': 'int',
+    'object': 'Dict[str, Any]',
+    'string': 'str',
+    'text': 'str',
+}
+
+def get_type_annotation(name: str) -> str:
+    return _TYPE_MAP.get(name, name)
+
+def to_docstring(s: str, indent: int) -> str:
+    prefix = ' ' * indent
+    s = s.replace('"""', '""\"')
+    s = re.sub(r'(\n+)', r'\1' + prefix, s)
+    s = s.strip()
+    if '\n' in s:
+        return f'{prefix}"""{s}\n{prefix}"""'
+    else:
+        return f'{prefix}"""{s}"""'
+
+def transform_name(s: str, sep: str, fix_part: Callable[[str], str]) -> str:
+    return sep.join(fix_part(part) for part in s.split('_'))
+
+def classify_name(s: str) -> str:
+    return transform_name(s, '', TITLECASE)
+
+def humanize_name(s: str) -> str:
+    return transform_name(s, ' ', LOWERCASE)
+
+class Parameter(inspect.Parameter):
+    def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
+        self.api_name = name
+        self._spec = spec
+        if keyword.iskeyword(name):
+            name += '_'
+        super().__init__(
+            name,
+            inspect.Parameter.KEYWORD_ONLY,
+            annotation=get_type_annotation(self._spec['type']),
+            # In normal Python the presence of a default tells you whether or
+            # not an argument is required. In the API the `required` flag tells
+            # us that, and defaults are specified inconsistently. Don't show
+            # defaults in the signature: it adds noise and makes things more
+            # confusing for the reader about what's required and what's
+            # optional. The docstring can explain in better detail, including
+            # the default value.
+            default=inspect.Parameter.empty,
+        )
+
+    def default_value(self) -> object:
+        try:
+            src_value: str = self._spec['default']
+        except KeyError:
+            return None
+        if src_value == 'true':
+            return True
+        elif src_value == 'false':
+            return False
+        elif src_value.isdigit():
+            return int(src_value)
+        else:
+            return src_value
+
+    def is_required(self) -> bool:
+        return self._spec['required']
+
+    def doc(self) -> str:
+        default_value = self.default_value()
+        if default_value is None:
+            default_doc = ''
+        else:
+            default_doc = f"Default {default_value!r}."
+        description = self._spec['description']
+        doc_parts = [f'{self.api_name}: {self.annotation}']
+        if description or default_doc:
+            doc_parts.append('---')
+            if description:
+                doc_parts.append(description)
+            if default_doc:
+                doc_parts.append(default_doc)
+        return f'''
+* {' '.join(doc_parts)}
+'''
+
+
+class Method:
+    def __init__(
+            self,
+            name: str,
+            spec: Mapping[str, Any],
+            annotate: Callable[[Annotation], Annotation]=str,
+    ) -> None:
+        self.name = name
+        self._spec = spec
+        self._annotate = annotate
+        self._required_params = []
+        self._optional_params = []
+        for param_name, param_spec in spec['parameters'].items():
+            param = Parameter(param_name, param_spec)
+            if param.is_required():
+                param_list = self._required_params
+            else:
+                param_list = self._optional_params
+            param_list.append(param)
+        self._required_params.sort(key=NAME_KEY)
+        self._optional_params.sort(key=NAME_KEY)
+
+    def signature(self) -> inspect.Signature:
+        parameters = [
+            inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD),
+            *self._required_params,
+            *self._optional_params,
+        ]
+        try:
+            returns = get_type_annotation(self._spec['response']['$ref'])
+        except KeyError:
+            returns = 'Dict[str, Any]'
+        returns = self._annotate(returns)
+        return inspect.Signature(parameters, return_annotation=returns)
+
+    def doc(self, doc_slice: slice=slice(None)) -> str:
+        doc_lines = self._spec['description'].splitlines(keepends=True)[doc_slice]
+        if not doc_lines[-1].endswith('\n'):
+            doc_lines.append('\n')
+        if self._required_params:
+            doc_lines.append("\nRequired parameters:\n")
+            doc_lines.extend(param.doc() for param in self._required_params)
+        if self._optional_params:
+            doc_lines.append("\nOptional parameters:\n")
+            doc_lines.extend(param.doc() for param in self._optional_params)
+        return f'''
+    def {self.name}{self.signature()}:
+{to_docstring(''.join(doc_lines), 8)}
+'''
+
+
+def document_schema(name: str, spec: Mapping[str, Any]) -> str:
+    description = spec['description']
+    if name in _DEPRECATED_SCHEMAS:
+        description += _DEPRECATED_NOTICE
+    if name.endswith('List'):
+        desc_fmt = _LIST_PYDOC
+        cls_name = name[:-4]
+    else:
+        desc_fmt = _SCHEMA_PYDOC
+        cls_name = name
+    description += desc_fmt.format(cls_name=cls_name)
+    lines = [
+        f"class {name}(TypedDict, total=False):",
+        to_docstring(description, 4),
+    ]
+    for field_name, field_spec in spec['properties'].items():
+        field_type = get_type_annotation(field_spec['type'])
+        try:
+            subtype = field_spec['items']['$ref']
+        except KeyError:
+            pass
+        else:
+            field_type += f"[{get_type_annotation(subtype)}]"
+
+        field_line = f"    {field_name}: {field_type!r}"
+        try:
+            field_line += f" = {field_spec['default']!r}"
+        except KeyError:
+            pass
+        lines.append(field_line)
+
+        field_doc: str = field_spec.get('description', '')
+        if field_spec['type'] == 'datetime':
+            field_doc += "\n\nString in ISO 8601 datetime format. Pass it to `ciso8601.parse_datetime` to build a `datetime.datetime`."
+        if field_doc:
+            lines.append(to_docstring(field_doc, 4))
+    lines.append('\n')
+    return '\n'.join(lines)
+
+def document_resource(name: str, spec: Mapping[str, Any]) -> str:
+    class_name = classify_name(name)
+    docstring = f"Methods to query and manipulate Arvados {humanize_name(name)}"
+    if class_name in _DEPRECATED_RESOURCES:
+        docstring += _DEPRECATED_NOTICE
+    methods = [
+        Method(key, meth_spec, 'ArvadosAPIRequest[{}]'.format)
+        for key, meth_spec in spec['methods'].items()
+        if key not in _ALIASED_METHODS
+    ]
+    return f'''class {class_name}:
+{to_docstring(docstring, 4)}
+{''.join(method.doc(slice(1)) for method in sorted(methods, key=NAME_KEY))}
+'''
+
+def parse_arguments(arglist: Optional[Sequence[str]]) -> argparse.Namespace:
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        '--output-file', '-O',
+        type=pathlib.Path,
+        metavar='PATH',
+        default=STDSTREAM_PATH,
+        help="""Path to write output. Specify `-` to use stdout (the default)
+""")
+    parser.add_argument(
+        'discovery_url',
+        nargs=argparse.OPTIONAL,
+        metavar='URL',
+        help="""URL or file path of a discovery document to load.
+Specify `-` to use stdin.
+If not provided, retrieved dynamically from Arvados client configuration.
+""")
+    args = parser.parse_args(arglist)
+    if args.discovery_url is None:
+        from arvados.api import api_kwargs_from_config
+        discovery_fmt = api_kwargs_from_config('v1')['discoveryServiceUrl']
+        args.discovery_url = discovery_fmt.format(api='arvados', apiVersion='v1')
+    elif args.discovery_url == '-':
+        args.discovery_url = 'file:///dev/stdin'
+    else:
+        parts = urllib.parse.urlsplit(args.discovery_url)
+        if not (parts.scheme or parts.netloc):
+            args.discovery_url = pathlib.Path(args.discovery_url).resolve().as_uri()
+    # Our output is Python source, so it should be UTF-8 regardless of locale.
+    if args.output_file == STDSTREAM_PATH:
+        args.out_file = open(sys.stdout.fileno(), 'w', encoding='utf-8', closefd=False)
+    else:
+        args.out_file = args.output_file.open('w', encoding='utf-8')
+    return args
+
+def main(arglist: Optional[Sequence[str]]=None) -> int:
+    args = parse_arguments(arglist)
+    with urllib.request.urlopen(args.discovery_url) as discovery_file:
+        status = discovery_file.getcode()
+        if not (status is None or 200 <= status < 300):
+            print(
+                f"error getting {args.discovery_url}: server returned {discovery_file.status}",
+                file=sys.stderr,
+            )
+            return os.EX_IOERR
+        discovery_document = json.load(discovery_file)
+    print(
+        to_docstring(_MODULE_PYDOC, indent=0),
+        _MODULE_PRELUDE,
+        sep='\n', file=args.out_file,
+    )
+
+    schemas = sorted(discovery_document['schemas'].items())
+    for name, schema_spec in schemas:
+        print(document_schema(name, schema_spec), file=args.out_file)
+
+    resources = sorted(discovery_document['resources'].items())
+    for name, resource_spec in resources:
+        print(document_resource(name, resource_spec), file=args.out_file)
+
+    print(
+        _REQUEST_CLASS,
+        '''class ArvadosAPIClient(googleapiclient.discovery.Resource):''',
+        sep='\n', file=args.out_file,
+    )
+    for name, _ in resources:
+        class_name = classify_name(name)
+        docstring = f"Return an instance of `{class_name}` to call methods via this client"
+        if class_name in _DEPRECATED_RESOURCES:
+            docstring += _DEPRECATED_NOTICE
+        method_spec = {
+            'description': docstring,
+            'parameters': {},
+            'response': {
+                '$ref': class_name,
+            },
+        }
+        print(Method(name, method_spec).doc(), file=args.out_file)
+
+    args.out_file.close()
+    return os.EX_OK
+
+if __name__ == '__main__':
+    sys.exit(main())
index 1c65c4ced8ac8a61b6552279aee9584ef48c3b66..e13e51609a56d6fcf811716e062d346ee9ceac8c 100644 (file)
@@ -8,18 +8,78 @@ import os
 import sys
 import re
 
+from pathlib import Path
 from setuptools import setup, find_packages
-
-SETUP_DIR = os.path.dirname(__file__) or '.'
-README = os.path.join(SETUP_DIR, 'README.rst')
+from setuptools.command import build_py
 
 import arvados_version
-version = arvados_version.get_version(SETUP_DIR, "arvados")
+version = arvados_version.get_version()
+short_tests_only = arvados_version.short_tests_only()
+README = os.path.join(arvados_version.SETUP_DIR, 'README.rst')
+
+class BuildPython(build_py.build_py):
+    """Extend setuptools `build_py` to generate API documentation
+
+    This class implements a setuptools subcommand, so it follows
+    [the SubCommand protocol][1]. Most of these methods are required by that
+    protocol, except `should_run`, which we register as the subcommand
+    predicate.
+
+    [1]: https://setuptools.pypa.io/en/latest/userguide/extension.html#setuptools.command.build.SubCommand
+    """
+    # This is implemented as functionality on top of `build_py`, rather than a
+    # dedicated subcommand, because that's the only way I can find to run this
+    # code during both `build` and `install`. setuptools' `install` command
+    # normally calls specific `build` subcommands directly, rather than calling
+    # the entire command, so it skips custom subcommands.
+    user_options = build_py.build_py.user_options + [
+        ('discovery-json=', 'J', 'JSON discovery document used to build pydoc'),
+        ('discovery-output=', 'O', 'relative path to write discovery document pydoc'),
+    ]
+
+    def initialize_options(self):
+        super().initialize_options()
+        self.discovery_json = 'arvados-v1-discovery.json'
+        self.discovery_output = str(Path('arvados', 'api_resources.py'))
+
+    def _relative_path(self, src, optname):
+        retval = Path(src)
+        if retval.is_absolute():
+            raise Exception(f"--{optname} should be a relative path")
+        else:
+            return retval
+
+    def finalize_options(self):
+        super().finalize_options()
+        self.json_path = self._relative_path(self.discovery_json, 'discovery-json')
+        self.out_path = Path(
+            self.build_lib,
+            self._relative_path(self.discovery_output, 'discovery-output'),
+        )
+
+    def run(self):
+        super().run()
+        import discovery2pydoc
+        arglist = ['--output-file', str(self.out_path), str(self.json_path)]
+        returncode = discovery2pydoc.main(arglist)
+        if returncode != 0:
+            raise Exception(f"discovery2pydoc exited {returncode}")
+
+    def get_outputs(self):
+        retval = super().get_outputs()
+        retval.append(str(self.out_path))
+        return retval
+
+    def get_source_files(self):
+        retval = super().get_source_files()
+        retval.append(str(self.json_path))
+        return retval
+
+    def get_output_mapping(self):
+        retval = super().get_output_mapping()
+        retval[str(self.json_path)] = str(self.out_path)
+        return retval
 
-short_tests_only = False
-if '--short-tests-only' in sys.argv:
-    short_tests_only = True
-    sys.argv.remove('--short-tests-only')
 
 setup(name='arvados-python-client',
       version=version,
@@ -30,6 +90,9 @@ setup(name='arvados-python-client',
       url="https://arvados.org",
       download_url="https://github.com/arvados/arvados.git",
       license='Apache 2.0',
+      cmdclass={
+          'build_py': BuildPython,
+      },
       packages=find_packages(),
       scripts=[
           'bin/arv-copy',
@@ -46,20 +109,21 @@ setup(name='arvados-python-client',
           ('share/doc/arvados-python-client', ['LICENSE-2.0.txt', 'README.rst']),
       ],
       install_requires=[
+          *arvados_version.iter_dependencies(version),
           'ciso8601 >=2.0.0',
           'future',
           'google-api-core <2.11.0', # 2.11.0rc1 is incompatible with google-auth<2
           'google-api-python-client >=2.1.0',
-          'google-auth<2',
+          'google-auth <2',
           'httplib2 >=0.9.2, <0.20.2',
+          'protobuf <4.0.0dev',
           'pycurl >=7.19.5.1, <7.45.0',
+          'pyparsing <3',
           'ruamel.yaml >=0.15.54, <0.17.22',
-          'setuptools',
-          'ws4py >=0.4.2',
-          'protobuf<4.0.0dev',
-          'pyparsing<3',
-          'setuptools>=40.3.0',
+          'setuptools >=40.3.0',
+          'websockets >=11.0',
       ],
+      python_requires="~=3.8",
       classifiers=[
           'Programming Language :: Python :: 3',
       ],
index 00356597965fb09e61c12b84781475aa681aa46e..35e85d11951e83d82d4156c703466bb92df12340 100644 (file)
@@ -60,10 +60,10 @@ def mock_responses(body, *codes, **headers):
     return mock.patch('httplib2.Http.request', side_effect=queue_with((
         (fake_httplib2_response(code, **headers), body) for code in codes)))
 
-def mock_api_responses(api_client, body, codes, headers={}):
+def mock_api_responses(api_client, body, codes, headers={}, method='request'):
     if not isinstance(body, bytes) and hasattr(body, 'encode'):
         body = body.encode()
-    return mock.patch.object(api_client._http, 'request', side_effect=queue_with((
+    return mock.patch.object(api_client._http, method, side_effect=queue_with((
         (fake_httplib2_response(code, **headers), body) for code in codes)))
 
 def str_keep_locator(s):
diff --git a/sdk/python/tests/data/hello-world-ManifestV2-OCILayout.tar b/sdk/python/tests/data/hello-world-ManifestV2-OCILayout.tar
new file mode 100644 (file)
index 0000000..a4b3d86
Binary files /dev/null and b/sdk/python/tests/data/hello-world-ManifestV2-OCILayout.tar differ
diff --git a/sdk/python/tests/data/hello-world-ManifestV2.tar b/sdk/python/tests/data/hello-world-ManifestV2.tar
new file mode 100644 (file)
index 0000000..b98e7c7
Binary files /dev/null and b/sdk/python/tests/data/hello-world-ManifestV2.tar differ
diff --git a/sdk/python/tests/data/hello-world-README.txt b/sdk/python/tests/data/hello-world-README.txt
new file mode 100644 (file)
index 0000000..8c6a7de
--- /dev/null
@@ -0,0 +1,25 @@
+The hello-world-*.tar files are archived from the official Docker
+hello-world:latest image available on 2024-02-01,
+sha256:d2c94e258dcb3c5ac2798d32e1249e42ef01cba4841c2234249495f87264ac5a.
+<https://github.com/docker-library/hello-world/tree/a2269bdb107d086851a5e3d448cf47770b50bff7>
+
+Copyright (c) 2014 Docker, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
index e5dd8aa913892db7c7413d1820cc659e5d38c7fd..46981e5016a31892739f7d03ae1ac0ba16490536 100755 (executable)
@@ -1,4 +1,7 @@
 #!/bin/bash
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
 
 if test -z "$WORKSPACE" ; then
     echo "WORKSPACE unset"
@@ -11,20 +14,13 @@ docker rm fedbox1-data fedbox2-data fedbox3-data
 
 set -ex
 
-mkdir -p $WORKSPACE/tmp
-cd $WORKSPACE/tmp
-virtualenv --python python3 venv3
-. venv3/bin/activate
-
-cd $WORKSPACE/sdk/python
-pip install -e .
-
-cd $WORKSPACE/sdk/cwl
-pip install -e .
+mkdir -p "$WORKSPACE/tmp/arvbox"
+python3 -m venv "$WORKSPACE/tmp/venv3"
+"$WORKSPACE/tmp/venv3/bin/pip" install -e "$WORKSPACE/sdk/python" "$WORKSPACE/sdk/cwl"
+alias cwltool='"$WORKSPACE/tmp/venv3/bin/cwltool"'
 
 export PATH=$PATH:$WORKSPACE/tools/arvbox/bin
 
-mkdir -p $WORKSPACE/tmp/arvbox
 cd $WORKSPACE/sdk/python/tests/fed-migrate
 cwltool arvbox-make-federation.cwl \
        --arvbox_base $WORKSPACE/tmp/arvbox \
index 1716291fe828c3ec824b2b0cc56206de5fde3371..446b95ca42c61400640a67ed632c492b64f8238a 100644 (file)
@@ -160,30 +160,73 @@ http {
       proxy_request_buffering off;
     }
   }
-  upstream workbench1 {
-    server {{UPSTREAMHOST}}:{{WORKBENCH1PORT}};
-  }
-  server {
-    listen {{LISTENHOST}}:{{WORKBENCH1SSLPORT}} ssl;
-    server_name workbench1 workbench1.* workbench.*;
-    ssl_certificate "{{SSLCERT}}";
-    ssl_certificate_key "{{SSLKEY}}";
-    location  / {
-      proxy_pass http://workbench1;
-      proxy_set_header Host $http_host;
-      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-      proxy_set_header X-Forwarded-Proto https;
-      proxy_redirect off;
-    }
+  # wb1->wb2 redirects copied from
+  # /tools/salt-install/config_examples/multi_host/aws/pillars/nginx_workbench_configuration.sls
+  map $request_uri $wb1_redirect {
+    default                        0;
+
+    ~^/actions\?uuid=(.*-4zz18-.*) /collections/$1;
+    ~^/actions\?uuid=(.*-j7d0g-.*) /projects/$1;
+    ~^/actions\?uuid=(.*-tpzed-.*) /projects/$1;
+    ~^/actions\?uuid=(.*-7fd4e-.*) /workflows/$1;
+    ~^/actions\?uuid=(.*-xvhdp-.*) /processes/$1;
+    ~^/actions\?uuid=(.*)          /;
+
+    ^/work_units/(.*)              /processes/$1;
+    ^/container_requests/(.*)      /processes/$1;
+    ^/users/(.*)                   /user/$1;
+    ^/groups/(.*)                  /group/$1;
+
+    ^/virtual_machines.*           /virtual-machines-admin;
+    ^/users/.*/virtual_machines    /virtual-machines-user;
+    ^/authorized_keys.*            /ssh-keys-admin;
+    ^/users/.*/ssh_keys            /ssh-keys-user;
+    ^/containers.*                 /all_processes;
+    ^/container_requests           /all_processes;
+    ^/job.*                        /all_processes;
+    ^/users/link_account           /link_account;
+    ^/keep_services.*              /keep-services;
+    ^/trash_items.*                /trash;
+
+    ^/themes.*                     /;
+    ^/keep_disks.*                 /;
+    ^/user_agreements.*            /;
+    ^/nodes.*                      /;
+    ^/humans.*                     /;
+    ^/traits.*                     /;
+    ^/sessions.*                   /;
+    ^/logout.*                     /;
+    ^/logged_out.*                 /;
+    ^/current_token                /;
+    ^/logs.*                       /;
+    ^/factory_jobs.*               /;
+    ^/uploaded_datasets.*          /;
+    ^/specimens.*                  /;
+    ^/pipeline_templates.*         /;
+    ^/pipeline_instances.*         /;
   }
   upstream workbench2 {
     server {{UPSTREAMHOST}}:{{WORKBENCH2PORT}};
   }
   server {
     listen {{LISTENHOST}}:{{WORKBENCH2SSLPORT}} ssl;
-    server_name workbench2 workbench2.*;
+    listen {{LISTENHOST}}:{{WORKBENCH1SSLPORT}} ssl;
+    server_name workbench2 workbench2.* workbench1 workbench1.* workbench workbench.*;
     ssl_certificate "{{SSLCERT}}";
     ssl_certificate_key "{{SSLKEY}}";
+
+    if ($wb1_redirect) {
+      return 301 $wb1_redirect;
+    }
+
+    # file download redirects
+    if ($arg_disposition = attachment) {
+      rewrite ^/collections/([^/]*)/(.*) /?redirectToDownload=/c=$1/$2? redirect;
+    }
+    if ($arg_disposition = inline) {
+      rewrite ^/collections/([^/]*)/(.*) /?redirectToPreview=/c=$1/$2? redirect;
+    }
+
     location / {
       proxy_pass http://workbench2;
       proxy_set_header Host $http_host;
index f9fb36a01407f75c489958f55bbfc33491ec342e..787837b72334cbd58f52981d9b1d45ee216bdffb 100644 (file)
@@ -2,23 +2,18 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from __future__ import print_function
-from __future__ import division
-from builtins import str
-from builtins import range
 import argparse
 import atexit
 import errno
 import glob
 import httplib2
 import os
-import pipes
 import random
 import re
+import shlex
 import shutil
 import signal
 import socket
-import string
 import subprocess
 import sys
 import tempfile
@@ -26,10 +21,7 @@ import time
 import unittest
 import yaml
 
-try:
-    from urllib.parse import urlparse
-except ImportError:
-    from urlparse import urlparse
+from urllib.parse import urlparse
 
 MY_DIRNAME = os.path.dirname(os.path.realpath(__file__))
 if __name__ == '__main__' and os.path.exists(
@@ -41,6 +33,15 @@ if __name__ == '__main__' and os.path.exists(
 import arvados
 import arvados.config
 
+# This module starts subprocesses and records them in pidfiles so they
+# can be managed by other processes (incl. after this process
+# exits). But if we don't keep a reference to each subprocess object
+# somewhere, the subprocess destructor runs, and we get a lot of
+# ResourceWarning noise in test logs. This is our bucket of subprocess
+# objects whose destructors we don't want to run but are otherwise
+# unneeded.
+_detachedSubprocesses = []
+
 ARVADOS_DIR = os.path.realpath(os.path.join(MY_DIRNAME, '../../..'))
 SERVICES_SRC_DIR = os.path.join(ARVADOS_DIR, 'services')
 
@@ -248,14 +249,17 @@ def _logfilename(label):
         stdbuf+['cat', fifo],
         stdin=open('/dev/null'),
         stdout=subprocess.PIPE)
+    _detachedSubprocesses.append(cat)
     tee = subprocess.Popen(
         stdbuf+['tee', '-a', logfilename],
         stdin=cat.stdout,
         stdout=subprocess.PIPE)
-    subprocess.Popen(
+    _detachedSubprocesses.append(tee)
+    sed = subprocess.Popen(
         stdbuf+['sed', '-e', 's/^/['+label+'] /'],
         stdin=tee.stdout,
         stdout=sys.stderr)
+    _detachedSubprocesses.append(sed)
     return fifo
 
 def run(leave_running_atexit=False):
@@ -338,7 +342,7 @@ def run(leave_running_atexit=False):
     resdir = subprocess.check_output(['bundle', 'exec', 'passenger-config', 'about', 'resourcesdir']).decode().rstrip()
     with open(resdir + '/templates/standalone/config.erb') as f:
         template = f.read()
-    newtemplate = re.sub('http {', 'http {\n        passenger_stat_throttle_rate 0;', template)
+    newtemplate = re.sub(r'http \{', 'http {\n        passenger_stat_throttle_rate 0;', template)
     if newtemplate == template:
         raise "template edit failed"
     with open('tmp/passenger-nginx.conf.erb', 'w') as f:
@@ -367,6 +371,7 @@ def run(leave_running_atexit=False):
          '--ssl-certificate', 'tmp/self-signed.pem',
          '--ssl-certificate-key', 'tmp/self-signed.key'],
         env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
+    _detachedSubprocesses.append(railsapi)
 
     if not leave_running_atexit:
         atexit.register(kill_server_pid, pid_file, passenger_root=api_src_dir)
@@ -444,6 +449,7 @@ def run_controller():
     controller = subprocess.Popen(
         ["arvados-server", "controller"],
         stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
+    _detachedSubprocesses.append(controller)
     with open(_pidfile('controller'), 'w') as f:
         f.write(str(controller.pid))
     _wait_until_port_listens(port)
@@ -463,6 +469,7 @@ def run_ws():
     ws = subprocess.Popen(
         ["arvados-server", "ws"],
         stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
+    _detachedSubprocesses.append(ws)
     with open(_pidfile('ws'), 'w') as f:
         f.write(str(ws.pid))
     _wait_until_port_listens(port)
@@ -496,6 +503,7 @@ def _start_keep(n, blob_signing=False):
         with open('/dev/null') as _stdin:
             child = subprocess.Popen(
                 keep_cmd, stdin=_stdin, stdout=logf, stderr=logf, close_fds=True)
+            _detachedSubprocesses.append(child)
 
     print('child.pid is %d'%child.pid, file=sys.stderr)
     with open(_pidfile('keep{}'.format(n)), 'w') as f:
@@ -562,6 +570,7 @@ def run_keep_proxy():
     logf = open(_logfilename('keepproxy'), WRITE_MODE)
     kp = subprocess.Popen(
         ['arvados-server', 'keepproxy'], env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
+    _detachedSubprocesses.append(kp)
 
     with open(_pidfile('keepproxy'), 'w') as f:
         f.write(str(kp.pid))
@@ -601,6 +610,7 @@ def run_arv_git_httpd():
     logf = open(_logfilename('githttpd'), WRITE_MODE)
     agh = subprocess.Popen(['arvados-server', 'git-httpd'],
         env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
+    _detachedSubprocesses.append(agh)
     with open(_pidfile('githttpd'), 'w') as f:
         f.write(str(agh.pid))
     _wait_until_port_listens(gitport)
@@ -621,6 +631,7 @@ def run_keep_web():
     keepweb = subprocess.Popen(
         ['arvados-server', 'keep-web'],
         env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
+    _detachedSubprocesses.append(keepweb)
     with open(_pidfile('keep-web'), 'w') as f:
         f.write(str(keepweb.pid))
     _wait_until_port_listens(keepwebport)
@@ -651,7 +662,6 @@ def run_nginx():
     nginxconf['HEALTHSSLPORT'] = external_port_from_config("Health")
     nginxconf['WSPORT'] = internal_port_from_config("Websocket")
     nginxconf['WSSSLPORT'] = external_port_from_config("Websocket")
-    nginxconf['WORKBENCH1PORT'] = internal_port_from_config("Workbench1")
     nginxconf['WORKBENCH1SSLPORT'] = external_port_from_config("Workbench1")
     nginxconf['WORKBENCH2PORT'] = internal_port_from_config("Workbench2")
     nginxconf['WORKBENCH2SSLPORT'] = external_port_from_config("Workbench2")
@@ -678,6 +688,7 @@ def run_nginx():
          '-g', 'error_log stderr info; pid '+_pidfile('nginx')+';',
          '-c', conffile],
         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
+    _detachedSubprocesses.append(nginx)
     _wait_until_port_listens(nginxconf['CONTROLLERSSLPORT'])
 
 def setup_config():
@@ -686,7 +697,6 @@ def setup_config():
     controller_external_port = find_available_port()
     websocket_port = find_available_port()
     websocket_external_port = find_available_port()
-    workbench1_port = find_available_port()
     workbench1_external_port = find_available_port()
     workbench2_port = find_available_port()
     workbench2_external_port = find_available_port()
@@ -738,9 +748,6 @@ def setup_config():
         },
         "Workbench1": {
             "ExternalURL": "https://%s:%s/" % (localhost, workbench1_external_port),
-            "InternalURLs": {
-                "http://%s:%s"%(localhost, workbench1_port): {},
-            },
         },
         "Workbench2": {
             "ExternalURL": "https://%s:%s/" % (localhost, workbench2_external_port),
@@ -989,8 +996,8 @@ if __name__ == "__main__":
         host = os.environ['ARVADOS_API_HOST']
         if args.auth is not None:
             token = auth_token(args.auth)
-            print("export ARVADOS_API_TOKEN={}".format(pipes.quote(token)))
-            print("export ARVADOS_API_HOST={}".format(pipes.quote(host)))
+            print("export ARVADOS_API_TOKEN={}".format(shlex.quote(token)))
+            print("export ARVADOS_API_HOST={}".format(shlex.quote(host)))
             print("export ARVADOS_API_HOST_INSECURE=true")
         else:
             print(host)
index 20c4f346a9363f501e6ed305eb3a48aa7612c9e1..0f85e5520c821dcaa7bf6690e7702cb857e3ac54 100644 (file)
@@ -7,13 +7,16 @@ from builtins import str
 from builtins import range
 import arvados
 import collections
+import contextlib
 import httplib2
 import itertools
 import json
+import logging
 import mimetypes
 import os
 import socket
 import string
+import sys
 import unittest
 import urllib.parse as urlparse
 
@@ -27,11 +30,10 @@ from arvados.api import (
     normalize_api_kwargs,
     api_kwargs_from_config,
     OrderedJsonModel,
-    RETRY_DELAY_INITIAL,
-    RETRY_DELAY_BACKOFF,
-    RETRY_COUNT,
+    _googleapiclient_log_lock,
 )
-from .arvados_testutil import fake_httplib2_response, queue_with
+from .arvados_testutil import fake_httplib2_response, mock_api_responses, queue_with
+import httplib2.error
 
 if not mimetypes.inited:
     mimetypes.init()
@@ -39,6 +41,7 @@ if not mimetypes.inited:
 class ArvadosApiTest(run_test_server.TestCaseWithServers):
     MAIN_SERVER = {}
     ERROR_HEADERS = {'Content-Type': mimetypes.types_map['.json']}
+    RETRIED_4XX = frozenset([408, 409, 423])
 
     def api_error_response(self, code, *errors):
         return (fake_httplib2_response(code, **self.ERROR_HEADERS),
@@ -150,6 +153,57 @@ class ArvadosApiTest(run_test_server.TestCaseWithServers):
         self.assertEqual(api._http.timeout, 1234,
             "Requested timeout value was 1234")
 
+    def test_4xx_retried(self):
+        client = arvados.api('v1')
+        for code in self.RETRIED_4XX:
+            name = f'retried #{code}'
+            with self.subTest(name), mock.patch('time.sleep'):
+                expected = {'username': name}
+                with mock_api_responses(
+                        client,
+                        json.dumps(expected),
+                        [code, code, 200],
+                        self.ERROR_HEADERS,
+                        'orig_http_request',
+                ):
+                    actual = client.users().current().execute()
+                self.assertEqual(actual, expected)
+
+    def test_4xx_not_retried(self):
+        client = arvados.api('v1', num_retries=3)
+        # Note that googleapiclient does retry 403 *if* the response JSON
+        # includes flags that say the request was denied by rate limiting.
+        # An empty JSON response like we use here should not be retried.
+        for code in [400, 401, 403, 404, 422]:
+            with self.subTest(f'error {code}'), mock.patch('time.sleep'):
+                with mock_api_responses(
+                        client,
+                        b'{}',
+                        [code, 200],
+                        self.ERROR_HEADERS,
+                        'orig_http_request',
+                ), self.assertRaises(arvados.errors.ApiError) as exc_check:
+                    client.users().current().execute()
+                response = exc_check.exception.args[0]
+                self.assertEqual(response.status, code)
+                self.assertEqual(response.get('status'), str(code))
+
+    def test_4xx_raised_after_retry_exhaustion(self):
+        client = arvados.api('v1', num_retries=1)
+        for code in self.RETRIED_4XX:
+            with self.subTest(f'failed {code}'), mock.patch('time.sleep'):
+                with mock_api_responses(
+                        client,
+                        b'{}',
+                        [code, code, code, 200],
+                        self.ERROR_HEADERS,
+                        'orig_http_request',
+                ), self.assertRaises(arvados.errors.ApiError) as exc_check:
+                    client.users().current().execute()
+                response = exc_check.exception.args[0]
+                self.assertEqual(response.status, code)
+                self.assertEqual(response.get('status'), str(code))
+
     def test_ordered_json_model(self):
         mock_responses = {
             'arvados.humans.get': (
@@ -340,8 +394,119 @@ class ArvadosApiTest(run_test_server.TestCaseWithServers):
                 args[arg_index] = arg_value
                 api_client(*args, insecure=True)
 
-
-class RetryREST(unittest.TestCase):
+    def test_initial_retry_logs(self):
+        try:
+            _googleapiclient_log_lock.release()
+        except RuntimeError:
+            # Lock was never acquired - that's the state we want anyway
+            pass
+        real_logger = logging.getLogger('googleapiclient.http')
+        mock_logger = mock.Mock(wraps=real_logger)
+        mock_logger.handlers = logging.getLogger('googleapiclient').handlers
+        mock_logger.level = logging.NOTSET
+        with mock.patch('logging.getLogger', return_value=mock_logger), \
+             mock.patch('time.sleep'), \
+             self.assertLogs(real_logger, 'INFO') as actual_logs:
+            try:
+                api_client('v1', 'https://test.invalid/', 'NoToken', num_retries=1)
+            except httplib2.error.ServerNotFoundError:
+                pass
+        mock_logger.addFilter.assert_called()
+        mock_logger.addHandler.assert_called()
+        mock_logger.setLevel.assert_called()
+        mock_logger.removeHandler.assert_called()
+        mock_logger.removeFilter.assert_called()
+        self.assertRegex(actual_logs.output[0], r'^INFO:googleapiclient\.http:Sleeping \d')
+
+    def test_configured_logger_untouched(self):
+        real_logger = logging.getLogger('googleapiclient.http')
+        mock_logger = mock.Mock(wraps=real_logger)
+        mock_logger.handlers = logging.getLogger().handlers
+        with mock.patch('logging.getLogger', return_value=mock_logger), \
+             mock.patch('time.sleep'):
+            try:
+                api_client('v1', 'https://test.invalid/', 'NoToken', num_retries=1)
+            except httplib2.error.ServerNotFoundError:
+                pass
+        mock_logger.addFilter.assert_not_called()
+        mock_logger.addHandler.assert_not_called()
+        mock_logger.setLevel.assert_not_called()
+        mock_logger.removeHandler.assert_not_called()
+        mock_logger.removeFilter.assert_not_called()
+
+
+class ConstructNumRetriesTestCase(unittest.TestCase):
+    @staticmethod
+    def _fake_retry_request(http, num_retries, req_type, sleep, rand, uri, method, *args, **kwargs):
+        return http.request(uri, method, *args, **kwargs)
+
+    @contextlib.contextmanager
+    def patch_retry(self):
+        # We have this dedicated context manager that goes through `sys.modules`
+        # instead of just using `mock.patch` because of the unfortunate
+        # `arvados.api` name collision.
+        orig_func = sys.modules['arvados.api']._orig_retry_request
+        expect_name = 'googleapiclient.http._retry_request'
+        self.assertEqual(
+            '{0.__module__}.{0.__name__}'.format(orig_func), expect_name,
+            f"test setup problem: {expect_name} not at arvados.api._orig_retry_request",
+        )
+        retry_mock = mock.Mock(wraps=self._fake_retry_request)
+        sys.modules['arvados.api']._orig_retry_request = retry_mock
+        try:
+            yield retry_mock
+        finally:
+            sys.modules['arvados.api']._orig_retry_request = orig_func
+
+    def _iter_num_retries(self, retry_mock):
+        for call in retry_mock.call_args_list:
+            try:
+                yield call.args[1]
+            except IndexError:
+                yield call.kwargs['num_retries']
+
+    def test_default_num_retries(self):
+        with self.patch_retry() as retry_mock:
+            client = arvados.api('v1')
+        actual = set(self._iter_num_retries(retry_mock))
+        self.assertEqual(len(actual), 1)
+        self.assertTrue(actual.pop() > 6, "num_retries lower than expected")
+
+    def _test_calls(self, init_arg, call_args, expected):
+        with self.patch_retry() as retry_mock:
+            client = arvados.api('v1', num_retries=init_arg)
+            for num_retries in call_args:
+                client.users().current().execute(num_retries=num_retries)
+        actual = self._iter_num_retries(retry_mock)
+        # The constructor makes two requests with its num_retries argument:
+        # one for the discovery document, and one for the config.
+        self.assertEqual(next(actual, None), init_arg)
+        self.assertEqual(next(actual, None), init_arg)
+        self.assertEqual(list(actual), expected)
+
+    def test_discovery_num_retries(self):
+        for num_retries in [0, 5, 55]:
+            with self.subTest(f"num_retries={num_retries}"):
+                self._test_calls(num_retries, [], [])
+
+    def test_num_retries_called_le_init(self):
+        for n in [6, 10]:
+            with self.subTest(f"init_arg={n}"):
+                call_args = [n - 4, n - 2, n]
+                expected = [n] * 3
+                self._test_calls(n, call_args, expected)
+
+    def test_num_retries_called_ge_init(self):
+        for n in [0, 10]:
+            with self.subTest(f"init_arg={n}"):
+                call_args = [n, n + 4, n + 8]
+                self._test_calls(n, call_args, call_args)
+
+    def test_num_retries_called_mixed(self):
+        self._test_calls(5, [2, 6, 4, 8], [5, 6, 5, 8])
+
+
+class PreCloseSocketTestCase(unittest.TestCase):
     def setUp(self):
         self.api = arvados.api('v1')
         self.assertTrue(hasattr(self.api._http, 'orig_http_request'),
@@ -353,59 +518,6 @@ class RetryREST(unittest.TestCase):
         # All requests succeed by default. Tests override as needed.
         self.api._http.orig_http_request.return_value = self.request_success
 
-    @mock.patch('time.sleep')
-    def test_socket_error_retry_get(self, sleep):
-        self.api._http.orig_http_request.side_effect = (
-            socket.error('mock error'),
-            self.request_success,
-        )
-        self.assertEqual(self.api.users().current().execute(),
-                         self.mock_response)
-        self.assertGreater(self.api._http.orig_http_request.call_count, 1,
-                           "client got the right response without retrying")
-        self.assertEqual(sleep.call_args_list,
-                         [mock.call(RETRY_DELAY_INITIAL)])
-
-    @mock.patch('time.sleep')
-    def test_same_automatic_request_id_on_retry(self, sleep):
-        self.api._http.orig_http_request.side_effect = (
-            socket.error('mock error'),
-            self.request_success,
-        )
-        self.api.users().current().execute()
-        calls = self.api._http.orig_http_request.call_args_list
-        self.assertEqual(len(calls), 2)
-        self.assertEqual(
-            calls[0][1]['headers']['X-Request-Id'],
-            calls[1][1]['headers']['X-Request-Id'])
-        self.assertRegex(calls[0][1]['headers']['X-Request-Id'], r'^req-[a-z0-9]{20}$')
-
-    @mock.patch('time.sleep')
-    def test_provided_request_id_on_retry(self, sleep):
-        self.api.request_id='fake-request-id'
-        self.api._http.orig_http_request.side_effect = (
-            socket.error('mock error'),
-            self.request_success,
-        )
-        self.api.users().current().execute()
-        calls = self.api._http.orig_http_request.call_args_list
-        self.assertEqual(len(calls), 2)
-        for call in calls:
-            self.assertEqual(call[1]['headers']['X-Request-Id'], 'fake-request-id')
-
-    @mock.patch('time.sleep')
-    def test_socket_error_retry_delay(self, sleep):
-        self.api._http.orig_http_request.side_effect = socket.error('mock')
-        self.api._http._retry_count = 3
-        with self.assertRaises(socket.error):
-            self.api.users().current().execute()
-        self.assertEqual(self.api._http.orig_http_request.call_count, 4)
-        self.assertEqual(sleep.call_args_list, [
-            mock.call(RETRY_DELAY_INITIAL),
-            mock.call(RETRY_DELAY_INITIAL * RETRY_DELAY_BACKOFF),
-            mock.call(RETRY_DELAY_INITIAL * RETRY_DELAY_BACKOFF**2),
-        ])
-
     @mock.patch('time.time', side_effect=[i*2**20 for i in range(99)])
     def test_close_old_connections_non_retryable(self, sleep):
         self._test_connection_close(expect=1)
@@ -429,18 +541,6 @@ class RetryREST(unittest.TestCase):
         for c in mock_conns.values():
             self.assertEqual(c.close.call_count, expect)
 
-    @mock.patch('time.sleep')
-    def test_socket_error_no_retry_post(self, sleep):
-        self.api._http.orig_http_request.side_effect = (
-            socket.error('mock error'),
-            self.request_success,
-        )
-        with self.assertRaises(socket.error):
-            self.api.users().create(body={}).execute()
-        self.assertEqual(self.api._http.orig_http_request.call_count, 1,
-                         "client should try non-retryable method exactly once")
-        self.assertEqual(sleep.call_args_list, [])
-
 
 if __name__ == '__main__':
     unittest.main()
index 73ef2475b98a8bd24a5ba9d9cc067f667bde895d..d12739f6f69235defbfccdf0ab1701a89e6bb8a4 100644 (file)
@@ -88,7 +88,7 @@ class ArvadosGetTestCase(run_test_server.TestCaseWithServers,
 
     def test_get_block(self):
         # Get raw data using a block locator
-        blk = re.search(' (acbd18\S+\+A\S+) ', self.col_manifest).group(1)
+        blk = re.search(r' (acbd18\S+\+A\S+) ', self.col_manifest).group(1)
         r = self.run_get([blk, '-'])
         self.assertEqual(0, r)
         self.assertEqual(b'foo', self.stdout.getvalue())
index 526fd68727bb3833761b84c08d4eb5ae28a7ea44..9aebc0350424e0b4051d14687cf7c4376135c18c 100644 (file)
@@ -2,23 +2,24 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from __future__ import absolute_import
 import arvados
 import collections
+import collections.abc
 import copy
 import hashlib
+import logging
 import mock
 import os
 import subprocess
 import sys
 import tempfile
 import unittest
-import logging
+from pathlib import Path
+
+import parameterized
 
 import arvados.commands.keepdocker as arv_keepdocker
 from . import arvados_testutil as tutil
-from . import run_test_server
-
 
 class StopTest(Exception):
     pass
@@ -226,3 +227,30 @@ class ArvKeepdockerTestCase(unittest.TestCase, tutil.VersionChecker):
         api().collections().update.assert_called_with(
             uuid=mocked_collection['uuid'],
             body={'properties': updated_properties})
+
+
+@parameterized.parameterized_class(('filename',), [
+    ('hello-world-ManifestV2.tar',),
+    ('hello-world-ManifestV2-OCILayout.tar',),
+])
+class ImageMetadataTestCase(unittest.TestCase):
+    DATA_PATH = Path(__file__).parent / 'data'
+
+    @classmethod
+    def setUpClass(cls):
+        cls.image_file = (cls.DATA_PATH / cls.filename).open('rb')
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.image_file.close()
+
+    def setUp(self):
+        self.manifest, self.config = arv_keepdocker.load_image_metadata(self.image_file)
+
+    def test_image_manifest(self):
+        self.assertIsInstance(self.manifest, collections.abc.Mapping)
+        self.assertEqual(self.manifest.get('RepoTags'), ['hello-world:latest'])
+
+    def test_image_config(self):
+        self.assertIsInstance(self.config, collections.abc.Mapping)
+        self.assertEqual(self.config.get('created'), '2023-05-02T16:49:27Z')
index b45a592ecd0fbd1b1d4722bd63f6e1e0b25514dd..600f17baadb3bbf82fb79c02ad29ade34a3a53d3 100644 (file)
@@ -27,6 +27,7 @@ class ArvadosFileWriterTestCase(unittest.TestCase):
         def __init__(self, blocks):
             self.blocks = blocks
             self.requests = []
+            self.num_prefetch_threads = 1
         def get(self, locator, num_retries=0, prefetch=False):
             self.requests.append(locator)
             return self.blocks.get(locator)
@@ -37,6 +38,8 @@ class ArvadosFileWriterTestCase(unittest.TestCase):
             pdh = tutil.str_keep_locator(data)
             self.blocks[pdh] = bytes(data)
             return pdh
+        def block_prefetch(self, loc):
+            self.requests.append(loc)
 
     class MockApi(object):
         def __init__(self, b, r):
@@ -414,7 +417,7 @@ class ArvadosFileWriterTestCase(unittest.TestCase):
         keep = ArvadosFileWriterTestCase.MockKeep({})
         api = ArvadosFileWriterTestCase.MockApi({}, {})
         for r in [[0, 1, 2, 3, 4], [4, 3, 2, 1, 0], [3, 2, 0, 4, 1]]:
-            with Collection() as c:
+            with Collection(api_client=api, keep_client=keep) as c:
                 writer = c.open("count.txt", "rb+")
                 self.assertEqual(writer.size(), 0)
 
@@ -429,7 +432,7 @@ class ArvadosFileWriterTestCase(unittest.TestCase):
         keep = ArvadosFileWriterTestCase.MockKeep({})
         api = ArvadosFileWriterTestCase.MockApi({}, {})
         for r in [[0, 1, 2, 4], [4, 2, 1, 0], [2, 0, 4, 1]]:
-            with Collection() as c:
+            with Collection(api_client=api, keep_client=keep) as c:
                 writer = c.open("count.txt", "rb+")
                 self.assertEqual(writer.size(), 0)
 
@@ -627,7 +630,8 @@ class ArvadosFileReaderTestCase(StreamFileReaderTestCase):
             def __init__(self, blocks, nocache):
                 self.blocks = blocks
                 self.nocache = nocache
-                self.num_get_threads = 1
+                self._keep = ArvadosFileWriterTestCase.MockKeep({})
+                self.prefetch_lookahead = 0
 
             def block_prefetch(self, loc):
                 pass
@@ -689,8 +693,60 @@ class ArvadosFileReaderTestCase(StreamFileReaderTestCase):
         with Collection(". 2e9ec317e197819358fbc43afca7d837+8 e8dc4081b13434b45189a720b77b6818+8 0:16:count.txt\n", keep_client=keep) as c:
             r = c.open("count.txt", "rb")
             self.assertEqual(b"0123", r.read(4))
-        self.assertIn("2e9ec317e197819358fbc43afca7d837+8", keep.requests)
-        self.assertIn("e8dc4081b13434b45189a720b77b6818+8", keep.requests)
+        self.assertEqual(["2e9ec317e197819358fbc43afca7d837+8",
+                          "e8dc4081b13434b45189a720b77b6818+8"], keep.requests)
+
+    def test_prefetch_disabled(self):
+        keep = ArvadosFileWriterTestCase.MockKeep({
+            "2e9ec317e197819358fbc43afca7d837+8": b"01234567",
+            "e8dc4081b13434b45189a720b77b6818+8": b"abcdefgh",
+        })
+        keep.num_prefetch_threads = 0
+        with Collection(". 2e9ec317e197819358fbc43afca7d837+8 e8dc4081b13434b45189a720b77b6818+8 0:16:count.txt\n", keep_client=keep) as c:
+            r = c.open("count.txt", "rb")
+            self.assertEqual(b"0123", r.read(4))
+
+        self.assertEqual(["2e9ec317e197819358fbc43afca7d837+8"], keep.requests)
+
+    def test_prefetch_first_read_only(self):
+        # test behavior that prefetch only happens every 128 reads
+        # check that it doesn't make a prefetch request on the second read
+        keep = ArvadosFileWriterTestCase.MockKeep({
+            "2e9ec317e197819358fbc43afca7d837+8": b"01234567",
+            "e8dc4081b13434b45189a720b77b6818+8": b"abcdefgh",
+        })
+        with Collection(". 2e9ec317e197819358fbc43afca7d837+8 e8dc4081b13434b45189a720b77b6818+8 0:16:count.txt\n", keep_client=keep) as c:
+            r = c.open("count.txt", "rb")
+            self.assertEqual(b"0123", r.read(4))
+            self.assertEqual(b"45", r.read(2))
+        self.assertEqual(["2e9ec317e197819358fbc43afca7d837+8",
+                          "e8dc4081b13434b45189a720b77b6818+8",
+                          "2e9ec317e197819358fbc43afca7d837+8"], keep.requests)
+        self.assertEqual(3, len(keep.requests))
+
+    def test_prefetch_again(self):
+        # test behavior that prefetch only happens every 128 reads
+        # check that it does make another prefetch request after 128 reads
+        keep = ArvadosFileWriterTestCase.MockKeep({
+            "2e9ec317e197819358fbc43afca7d837+8": b"01234567",
+            "e8dc4081b13434b45189a720b77b6818+8": b"abcdefgh",
+        })
+        with Collection(". 2e9ec317e197819358fbc43afca7d837+8 e8dc4081b13434b45189a720b77b6818+8 0:16:count.txt\n", keep_client=keep) as c:
+            r = c.open("count.txt", "rb")
+            for i in range(0, 129):
+                r.seek(0)
+                self.assertEqual(b"0123", r.read(4))
+        self.assertEqual(["2e9ec317e197819358fbc43afca7d837+8",
+                          "e8dc4081b13434b45189a720b77b6818+8",
+                          "2e9ec317e197819358fbc43afca7d837+8",
+                          "2e9ec317e197819358fbc43afca7d837+8"], keep.requests[0:4])
+        self.assertEqual(["2e9ec317e197819358fbc43afca7d837+8",
+                          "2e9ec317e197819358fbc43afca7d837+8",
+                          "2e9ec317e197819358fbc43afca7d837+8",
+                          "e8dc4081b13434b45189a720b77b6818+8"], keep.requests[127:131])
+        # gets the 1st block 129 times from keep (cache),
+        # and the 2nd block twice to get 131 requests
+        self.assertEqual(131, len(keep.requests))
 
     def test__eq__from_manifest(self):
         with Collection('. 781e5e245d69b566979b86e28d23f2c7+10 0:10:count1.txt') as c1:
diff --git a/sdk/python/tests/test_cmd_util.py b/sdk/python/tests/test_cmd_util.py
new file mode 100644 (file)
index 0000000..ffd45aa
--- /dev/null
@@ -0,0 +1,194 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import contextlib
+import copy
+import itertools
+import json
+import os
+import tempfile
+import unittest
+
+from pathlib import Path
+
+from parameterized import parameterized
+
+import arvados.commands._util as cmd_util
+
+FILE_PATH = Path(__file__)
+
+class ValidateFiltersTestCase(unittest.TestCase):
+    NON_FIELD_TYPES = [
+        None,
+        123,
+        ('name', '=', 'tuple'),
+        {'filters': ['name', '=', 'object']},
+    ]
+    NON_FILTER_TYPES = NON_FIELD_TYPES + ['string']
+    VALID_FILTERS = [
+        ['owner_uuid', '=', 'zzzzz-tpzed-12345abcde67890'],
+        ['name', 'in', ['foo', 'bar']],
+        '(replication_desired > replication_cofirmed)',
+        '(replication_confirmed>=replication_desired)',
+    ]
+
+    @parameterized.expand(itertools.combinations(VALID_FILTERS, 2))
+    def test_valid_filters(self, f1, f2):
+        expected = [f1, f2]
+        actual = cmd_util.validate_filters(copy.deepcopy(expected))
+        self.assertEqual(actual, expected)
+
+    @parameterized.expand([(t,) for t in NON_FILTER_TYPES])
+    def test_filters_wrong_type(self, value):
+        with self.assertRaisesRegex(ValueError, r'^filters are not a list\b'):
+            cmd_util.validate_filters(value)
+
+    @parameterized.expand([(t,) for t in NON_FIELD_TYPES])
+    def test_single_filter_wrong_type(self, value):
+        with self.assertRaisesRegex(ValueError, r'^filter at index 0 is not a string or list\b'):
+            cmd_util.validate_filters([value])
+
+    @parameterized.expand([
+        ([],),
+        (['owner_uuid'],),
+        (['owner_uuid', 'zzzzz-tpzed-12345abcde67890'],),
+        (['name', 'not in', 'foo', 'bar'],),
+        (['name', 'in', 'foo', 'bar', 'baz'],),
+    ])
+    def test_filters_wrong_arity(self, value):
+        with self.assertRaisesRegex(ValueError, r'^filter at index 0 does not have three items\b'):
+            cmd_util.validate_filters([value])
+
+    @parameterized.expand(itertools.product(
+        [0, 1],
+        NON_FIELD_TYPES,
+    ))
+    def test_filter_definition_wrong_type(self, index, bad_value):
+        value = ['owner_uuid', '=', 'zzzzz-tpzed-12345abcde67890']
+        value[index] = bad_value
+        name = ('field name', 'operator')[index]
+        with self.assertRaisesRegex(ValueError, rf'^filter at index 0 {name} is not a string\b'):
+            cmd_util.validate_filters([value])
+
+    @parameterized.expand([
+        # Not enclosed in parentheses
+        'foo = bar',
+        '(foo) < bar',
+        'foo > (bar)',
+        # Not exactly one operator
+        '(a >= b >= c)',
+        '(foo)',
+        '(file_count version)',
+        # Invalid field identifiers
+        '(version = 1)',
+        '(2 = file_count)',
+        '(replication.desired <= replication.confirmed)',
+        # Invalid whitespace
+        '(file_count\t=\tversion)',
+        '(file_count >= version\n)',
+    ])
+    def test_invalid_string_filter(self, value):
+        with self.assertRaisesRegex(ValueError, r'^filter at index 0 has invalid syntax\b'):
+            cmd_util.validate_filters([value])
+
+
+class JSONArgumentTestCase(unittest.TestCase):
+    JSON_OBJECTS = [
+        None,
+        123,
+        456.789,
+        'string',
+        ['list', 1],
+        {'object': True, 'yaml': False},
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        cls.json_file = tempfile.NamedTemporaryFile(
+            'w+',
+            encoding='utf-8',
+            prefix='argtest',
+            suffix='.json',
+        )
+        cls.parser = cmd_util.JSONArgument()
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.json_file.close()
+
+    def setUp(self):
+        self.json_file.seek(0)
+        self.json_file.truncate()
+
+    @parameterized.expand((obj,) for obj in JSON_OBJECTS)
+    def test_valid_argument_string(self, obj):
+        actual = self.parser(json.dumps(obj))
+        self.assertEqual(actual, obj)
+
+    @parameterized.expand((obj,) for obj in JSON_OBJECTS)
+    def test_valid_argument_path(self, obj):
+        json.dump(obj, self.json_file)
+        self.json_file.flush()
+        actual = self.parser(self.json_file.name)
+        self.assertEqual(actual, obj)
+
+    @parameterized.expand([
+        '',
+        '\0',
+        None,
+    ])
+    def test_argument_not_json_or_path(self, value):
+        if value is None:
+            with tempfile.NamedTemporaryFile() as gone_file:
+                value = gone_file.name
+        with self.assertRaisesRegex(ValueError, r'\bnot a valid JSON string or file path\b'):
+            self.parser(value)
+
+    @parameterized.expand([
+        FILE_PATH.parent,
+        FILE_PATH / 'nonexistent.json',
+        None,
+    ])
+    def test_argument_path_unreadable(self, path):
+        if path is None:
+            bad_file = tempfile.NamedTemporaryFile()
+            os.chmod(bad_file.fileno(), 0o000)
+            path = bad_file.name
+            @contextlib.contextmanager
+            def ctx():
+                try:
+                    yield
+                finally:
+                    os.chmod(bad_file.fileno(), 0o600)
+        else:
+            ctx = contextlib.nullcontext
+        with self.assertRaisesRegex(ValueError, rf'^error reading JSON file path {str(path)!r}: '), ctx():
+            self.parser(str(path))
+
+    @parameterized.expand([
+        FILE_PATH,
+        None,
+    ])
+    def test_argument_path_not_json(self, path):
+        if path is None:
+            path = self.json_file.name
+        with self.assertRaisesRegex(ValueError, rf'^error decoding JSON from file {str(path)!r}'):
+            self.parser(str(path))
+
+
+class JSONArgumentValidationTestCase(unittest.TestCase):
+    @parameterized.expand((obj,) for obj in JSONArgumentTestCase.JSON_OBJECTS)
+    def test_object_returned_from_validator(self, value):
+        parser = cmd_util.JSONArgument(lambda _: copy.deepcopy(value))
+        self.assertEqual(parser('{}'), value)
+
+    @parameterized.expand((obj,) for obj in JSONArgumentTestCase.JSON_OBJECTS)
+    def test_exception_raised_from_validator(self, value):
+        json_value = json.dumps(value)
+        def raise_func(_):
+            raise ValueError(json_value)
+        parser = cmd_util.JSONArgument(raise_func)
+        with self.assertRaises(ValueError) as exc_check:
+            parser(json_value)
+        self.assertEqual(exc_check.exception.args, (json_value,))
index 8986cf225840054bc5cd4161f7edd0b2c3f58b32..9e753506b3550d66b8939f355496c64f6abfb031 100644 (file)
@@ -323,6 +323,7 @@ class ArvadosCollectionsTest(run_test_server.TestCaseWithServers,
     class MockKeep(object):
         def __init__(self, content, num_retries=0):
             self.content = content
+            self.num_prefetch_threads = 1
 
         def get(self, locator, num_retries=0, prefetch=False):
             return self.content[locator]
@@ -538,11 +539,11 @@ class CollectionReaderTestCase(unittest.TestCase, CollectionTestMixin):
         self.mock_get_collection(client, status, 'foo_file')
         return client
 
-    def test_init_no_default_retries(self):
+    def test_init_default_retries(self):
         client = self.api_client_mock(200)
         reader = arvados.CollectionReader(self.DEFAULT_UUID, api_client=client)
         reader.manifest_text()
-        client.collections().get().execute.assert_called_with(num_retries=0)
+        client.collections().get().execute.assert_called_with(num_retries=10)
 
     def test_uuid_init_success(self):
         client = self.api_client_mock(200)
@@ -592,7 +593,7 @@ class CollectionReaderTestCase(unittest.TestCase, CollectionTestMixin):
         # Ensure stripped_manifest() doesn't mangle our manifest in
         # any way other than stripping hints.
         self.assertEqual(
-            re.sub('\+[^\d\s\+]+', '', nonnormal),
+            re.sub(r'\+[^\d\s\+]+', '', nonnormal),
             reader.stripped_manifest())
         # Ensure stripped_manifest() didn't mutate our reader.
         self.assertEqual(nonnormal, reader.manifest_text())
index f5192160f3e5fad01d080b0eb16bf834bdfd1ed6..b4e6a0b1cd88204b2dc8f699c85d01e2c805abe1 100644 (file)
@@ -2,13 +2,7 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from __future__ import print_function
-from __future__ import absolute_import
-from __future__ import division
-from future import standard_library
-standard_library.install_aliases()
-from builtins import range
-from builtins import object
+import json
 import logging
 import mock
 import queue
@@ -17,10 +11,62 @@ import threading
 import time
 import unittest
 
+import websockets.exceptions as ws_exc
+
 import arvados
 from . import arvados_testutil as tutil
 from . import run_test_server
 
+class FakeWebsocketClient:
+    """Fake self-contained version of websockets.sync.client.ClientConnection
+
+    This provides enough of the API to test EventClient. It loosely mimics
+    the Arvados WebSocket API by acknowledging subscribe messages. You can use
+    `mock_wrapper` to test calls. You can set `_check_lock` to test that the
+    given lock is acquired before `send` is called.
+    """
+
+    def __init__(self):
+        self._check_lock = None
+        self._closed = threading.Event()
+        self._messages = queue.Queue()
+
+    def mock_wrapper(self):
+        wrapper = mock.Mock(wraps=self)
+        wrapper.__iter__ = lambda _: self.__iter__()
+        return wrapper
+
+    def __iter__(self):
+        while True:
+            msg = self._messages.get()
+            self._messages.task_done()
+            if isinstance(msg, Exception):
+                raise msg
+            else:
+                yield msg
+
+    def close(self, code=1000, reason=''):
+        if not self._closed.is_set():
+            self._closed.set()
+            self.force_disconnect()
+
+    def force_disconnect(self):
+        self._messages.put(ws_exc.ConnectionClosed(None, None))
+
+    def send(self, msg):
+        if self._check_lock is not None and self._check_lock.acquire(blocking=False):
+            self._check_lock.release()
+            raise AssertionError(f"called ws_client.send() without lock")
+        elif self._closed.is_set():
+            raise ws_exc.ConnectionClosed(None, None)
+        try:
+            msg = json.loads(msg)
+        except ValueError:
+            status = 400
+        else:
+            status = 200
+        self._messages.put(json.dumps({'status': status}))
+
 
 class WebsocketTest(run_test_server.TestCaseWithServers):
     MAIN_SERVER = {}
@@ -201,7 +247,7 @@ class WebsocketTest(run_test_server.TestCaseWithServers):
 
         # close (im)properly
         if close_unexpected:
-            self.ws.ec.close_connection()
+            self.ws._client.close()
         else:
             self.ws.close()
 
@@ -240,69 +286,115 @@ class WebsocketTest(run_test_server.TestCaseWithServers):
         self._test_websocket_reconnect(False)
 
     # Test websocket reconnection retry
-    @mock.patch('arvados.events._EventClient.connect')
-    def test_websocket_reconnect_retry(self, event_client_connect):
-        event_client_connect.side_effect = [None, Exception('EventClient.connect error'), None]
-
+    @mock.patch('arvados.events.ws_client.connect')
+    def test_websocket_reconnect_retry(self, ws_conn):
         logstream = tutil.StringIO()
         rootLogger = logging.getLogger()
         streamHandler = logging.StreamHandler(logstream)
         rootLogger.addHandler(streamHandler)
-
-        run_test_server.authorize_with('active')
-        events = queue.Queue(100)
-
-        filters = [['object_uuid', 'is_a', 'arvados#human']]
-        self.ws = arvados.events.subscribe(
-            arvados.api('v1'), filters,
-            events.put_nowait,
-            poll_fallback=False,
-            last_log_id=None)
-        self.assertIsInstance(self.ws, arvados.events.EventClient)
-
-        # simulate improper close
-        self.ws.on_closed()
-
-        # verify log messages to ensure retry happened
-        log_messages = logstream.getvalue()
-        found = log_messages.find("Error 'EventClient.connect error' during websocket reconnect.")
-        self.assertNotEqual(found, -1)
-        rootLogger.removeHandler(streamHandler)
-
-    @mock.patch('arvados.events._EventClient')
-    def test_subscribe_method(self, websocket_client):
-        filters = [['object_uuid', 'is_a', 'arvados#human']]
-        client = arvados.events.EventClient(
-            self.MOCK_WS_URL, [], lambda event: None, None)
-        client.subscribe(filters[:], 99)
-        websocket_client().subscribe.assert_called_with(filters, 99)
-
-    @mock.patch('arvados.events._EventClient')
-    def test_unsubscribe(self, websocket_client):
-        filters = [['object_uuid', 'is_a', 'arvados#human']]
-        client = arvados.events.EventClient(
-            self.MOCK_WS_URL, filters[:], lambda event: None, None)
-        client.unsubscribe(filters[:])
-        websocket_client().unsubscribe.assert_called_with(filters)
-
-    @mock.patch('arvados.events._EventClient')
+        try:
+            msg_event, wss_client, self.ws = self.fake_client(ws_conn)
+            self.assertTrue(msg_event.wait(timeout=1), "timed out waiting for setup callback")
+            msg_event.clear()
+            ws_conn.side_effect = [Exception('EventClient.connect error'), wss_client]
+            wss_client.force_disconnect()
+            self.assertTrue(msg_event.wait(timeout=1), "timed out waiting for reconnect callback")
+            # verify log messages to ensure retry happened
+            self.assertIn("Error 'EventClient.connect error' during websocket reconnect.", logstream.getvalue())
+            self.assertEqual(ws_conn.call_count, 3)
+        finally:
+            rootLogger.removeHandler(streamHandler)
+
+    @mock.patch('arvados.events.ws_client.connect')
     def test_run_forever_survives_reconnects(self, websocket_client):
-        connected = threading.Event()
-        websocket_client().connect.side_effect = connected.set
         client = arvados.events.EventClient(
             self.MOCK_WS_URL, [], lambda event: None, None)
         forever_thread = threading.Thread(target=client.run_forever)
         forever_thread.start()
         # Simulate an unexpected disconnect, and wait for reconnect.
-        close_thread = threading.Thread(target=client.on_closed)
-        close_thread.start()
-        self.assertTrue(connected.wait(timeout=self.TEST_TIMEOUT))
-        close_thread.join()
-        run_forever_alive = forever_thread.is_alive()
-        client.close()
-        forever_thread.join()
-        self.assertTrue(run_forever_alive)
-        self.assertEqual(2, websocket_client().connect.call_count)
+        try:
+            client.on_closed()
+            self.assertTrue(forever_thread.is_alive())
+            self.assertEqual(2, websocket_client.call_count)
+        finally:
+            client.close()
+            forever_thread.join()
+
+    @staticmethod
+    def fake_client(conn_patch, filters=None, url=MOCK_WS_URL):
+        """Set up EventClient test infrastructure
+
+        Given a patch of `arvados.events.ws_client.connect`,
+        this returns a 3-tuple:
+
+        * `msg_event` is a `threading.Event` that is set as the test client
+          event callback. You can wait for this event to confirm that a
+          sent message has been acknowledged and processed.
+
+        * `mock_client` is a `mock.Mock` wrapper around `FakeWebsocketClient`.
+          Use this to assert `EventClient` calls the right methods. It tests
+          that `EventClient` acquires a lock before calling `send`.
+
+        * `client` is the `EventClient` that uses `mock_client` under the hood
+          that you exercise methods of.
+
+        Other arguments are passed to initialize `EventClient`.
+        """
+        msg_event = threading.Event()
+        fake_client = FakeWebsocketClient()
+        mock_client = fake_client.mock_wrapper()
+        conn_patch.return_value = mock_client
+        client = arvados.events.EventClient(url, filters, lambda _: msg_event.set())
+        fake_client._check_lock = client._subscribe_lock
+        return msg_event, mock_client, client
+
+    @mock.patch('arvados.events.ws_client.connect')
+    def test_subscribe_locking(self, ws_conn):
+        f = [['created_at', '>=', '2023-12-01T00:00:00.000Z']]
+        msg_event, wss_client, self.ws = self.fake_client(ws_conn)
+        self.assertTrue(msg_event.wait(timeout=1), "timed out waiting for setup callback")
+        msg_event.clear()
+        wss_client.send.reset_mock()
+        self.ws.subscribe(f)
+        self.assertTrue(msg_event.wait(timeout=1), "timed out waiting for subscribe callback")
+        wss_client.send.assert_called()
+        (msg,), _ = wss_client.send.call_args
+        self.assertEqual(
+            json.loads(msg),
+            {'method': 'subscribe', 'filters': f},
+        )
+
+    @mock.patch('arvados.events.ws_client.connect')
+    def test_unsubscribe_locking(self, ws_conn):
+        f = [['created_at', '>=', '2023-12-01T01:00:00.000Z']]
+        msg_event, wss_client, self.ws = self.fake_client(ws_conn, f)
+        self.assertTrue(msg_event.wait(timeout=1), "timed out waiting for setup callback")
+        msg_event.clear()
+        wss_client.send.reset_mock()
+        self.ws.unsubscribe(f)
+        self.assertTrue(msg_event.wait(timeout=1), "timed out waiting for unsubscribe callback")
+        wss_client.send.assert_called()
+        (msg,), _ = wss_client.send.call_args
+        self.assertEqual(
+            json.loads(msg),
+            {'method': 'unsubscribe', 'filters': f},
+        )
+
+    @mock.patch('arvados.events.ws_client.connect')
+    def test_resubscribe_locking(self, ws_conn):
+        f = [['created_at', '>=', '2023-12-01T02:00:00.000Z']]
+        msg_event, wss_client, self.ws = self.fake_client(ws_conn, f)
+        self.assertTrue(msg_event.wait(timeout=1), "timed out waiting for setup callback")
+        msg_event.clear()
+        wss_client.send.reset_mock()
+        wss_client.force_disconnect()
+        self.assertTrue(msg_event.wait(timeout=1), "timed out waiting for resubscribe callback")
+        wss_client.send.assert_called()
+        (msg,), _ = wss_client.send.call_args
+        self.assertEqual(
+            json.loads(msg),
+            {'method': 'subscribe', 'filters': f},
+        )
 
 
 class PollClientTestCase(unittest.TestCase):
diff --git a/sdk/python/tests/test_http.py b/sdk/python/tests/test_http.py
new file mode 100644 (file)
index 0000000..bce57ed
--- /dev/null
@@ -0,0 +1,497 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from future import standard_library
+standard_library.install_aliases()
+
+import copy
+import io
+import functools
+import hashlib
+import json
+import logging
+import mock
+import sys
+import unittest
+import datetime
+
+import arvados
+import arvados.collection
+import arvados.keep
+import pycurl
+
+from arvados.http_to_keep import http_to_keep
+
+import ruamel.yaml as yaml
+
+# Turns out there was already "FakeCurl" that serves the same purpose, but
+# I wrote this before I knew that.  Whoops.
+class CurlMock:
+    def __init__(self, headers = {}):
+        self.perform_was_called = False
+        self.headers = headers
+        self.get_response = 200
+        self.head_response = 200
+        self.req_headers = []
+
+    def setopt(self, op, *args):
+        if op == pycurl.URL:
+            self.url = args[0]
+        if op == pycurl.WRITEFUNCTION:
+            self.writefn = args[0]
+        if op == pycurl.HEADERFUNCTION:
+            self.headerfn = args[0]
+        if op == pycurl.NOBODY:
+            self.head = True
+        if op == pycurl.HTTPGET:
+            self.head = False
+        if op == pycurl.HTTPHEADER:
+            self.req_headers = args[0]
+
+    def getinfo(self, op):
+        if op == pycurl.RESPONSE_CODE:
+            if self.head:
+                return self.head_response
+            else:
+                return self.get_response
+
+    def perform(self):
+        self.perform_was_called = True
+
+        if self.head:
+            self.headerfn("HTTP/1.1 {} Status\r\n".format(self.head_response))
+        else:
+            self.headerfn("HTTP/1.1 {} Status\r\n".format(self.get_response))
+
+        for k,v in self.headers.items():
+            self.headerfn("%s: %s" % (k,v))
+
+        if not self.head and self.get_response == 200:
+            self.writefn(self.chunk)
+
+
+class TestHttpToKeep(unittest.TestCase):
+
+    @mock.patch("pycurl.Curl")
+    @mock.patch("arvados.collection.Collection")
+    def test_http_get(self, collectionmock, curlmock):
+        api = mock.MagicMock()
+
+        api.collections().list().execute.return_value = {
+            "items": []
+        }
+
+        cm = mock.MagicMock()
+        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
+        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
+        collectionmock.return_value = cm
+
+        mockobj = CurlMock()
+        mockobj.chunk = b'abc'
+        def init():
+            return mockobj
+        curlmock.side_effect = init
+
+        utcnow = mock.MagicMock()
+        utcnow.return_value = datetime.datetime(2018, 5, 15)
+
+        r = http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
+        self.assertEqual(r, ("99999999999999999999999999999998+99", "file1.txt",
+                             'zzzzz-4zz18-zzzzzzzzzzzzzz3', 'http://example.com/file1.txt',
+                             datetime.datetime(2018, 5, 15, 0, 0)))
+
+        assert mockobj.url == b"http://example.com/file1.txt"
+        assert mockobj.perform_was_called is True
+
+        cm.open.assert_called_with("file1.txt", "wb")
+        cm.save_new.assert_called_with(name="Downloaded from http%3A%2F%2Fexample.com%2Ffile1.txt",
+                                       owner_uuid=None, ensure_unique_name=True)
+
+        api.collections().update.assert_has_calls([
+            mock.call(uuid=cm.manifest_locator(),
+                      body={"collection":{"properties": {'http://example.com/file1.txt': {'Date': 'Tue, 15 May 2018 00:00:00 GMT'}}}})
+        ])
+
+
+    @mock.patch("pycurl.Curl")
+    @mock.patch("arvados.collection.CollectionReader")
+    def test_http_expires(self, collectionmock, curlmock):
+        api = mock.MagicMock()
+
+        api.collections().list().execute.return_value = {
+            "items": [{
+                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
+                "portable_data_hash": "99999999999999999999999999999998+99",
+                "properties": {
+                    'http://example.com/file1.txt': {
+                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
+                        'Expires': 'Tue, 17 May 2018 00:00:00 GMT'
+                    }
+                }
+            }]
+        }
+
+        cm = mock.MagicMock()
+        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
+        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
+        cm.keys.return_value = ["file1.txt"]
+        collectionmock.return_value = cm
+
+        mockobj = CurlMock()
+        mockobj.chunk = b'abc'
+        def init():
+            return mockobj
+        curlmock.side_effect = init
+
+        utcnow = mock.MagicMock()
+        utcnow.return_value = datetime.datetime(2018, 5, 16)
+
+        r = http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
+        self.assertEqual(r, ("99999999999999999999999999999998+99", "file1.txt",
+                             'zzzzz-4zz18-zzzzzzzzzzzzzz3', 'http://example.com/file1.txt',
+                             datetime.datetime(2018, 5, 16, 0, 0)))
+
+        assert mockobj.perform_was_called is False
+
+
+    @mock.patch("pycurl.Curl")
+    @mock.patch("arvados.collection.CollectionReader")
+    def test_http_cache_control(self, collectionmock, curlmock):
+        api = mock.MagicMock()
+
+        api.collections().list().execute.return_value = {
+            "items": [{
+                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
+                "portable_data_hash": "99999999999999999999999999999998+99",
+                "properties": {
+                    'http://example.com/file1.txt': {
+                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
+                        'Cache-Control': 'max-age=172800'
+                    }
+                }
+            }]
+        }
+
+        cm = mock.MagicMock()
+        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
+        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
+        cm.keys.return_value = ["file1.txt"]
+        collectionmock.return_value = cm
+
+        mockobj = CurlMock()
+        mockobj.chunk = b'abc'
+        def init():
+            return mockobj
+        curlmock.side_effect = init
+
+        utcnow = mock.MagicMock()
+        utcnow.return_value = datetime.datetime(2018, 5, 16)
+
+        r = http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
+        self.assertEqual(r, ("99999999999999999999999999999998+99", "file1.txt", 'zzzzz-4zz18-zzzzzzzzzzzzzz3',
+                             'http://example.com/file1.txt', datetime.datetime(2018, 5, 16, 0, 0)))
+
+        assert mockobj.perform_was_called is False
+
+
+    @mock.patch("pycurl.Curl")
+    @mock.patch("arvados.collection.Collection")
+    def test_http_expired(self, collectionmock, curlmock):
+        api = mock.MagicMock()
+
+        api.collections().list().execute.return_value = {
+            "items": [{
+                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
+                "portable_data_hash": "99999999999999999999999999999998+99",
+                "properties": {
+                    'http://example.com/file1.txt': {
+                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
+                        'Expires': 'Wed, 16 May 2018 00:00:00 GMT'
+                    }
+                }
+            }]
+        }
+
+        cm = mock.MagicMock()
+        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz4"
+        cm.portable_data_hash.return_value = "99999999999999999999999999999997+99"
+        cm.keys.return_value = ["file1.txt"]
+        collectionmock.return_value = cm
+
+        mockobj = CurlMock({'Date': 'Thu, 17 May 2018 00:00:00 GMT'})
+        mockobj.chunk = b'def'
+        def init():
+            return mockobj
+        curlmock.side_effect = init
+
+        utcnow = mock.MagicMock()
+        utcnow.return_value = datetime.datetime(2018, 5, 17)
+
+        r = http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
+        self.assertEqual(r, ("99999999999999999999999999999997+99", "file1.txt",
+                             'zzzzz-4zz18-zzzzzzzzzzzzzz4',
+                             'http://example.com/file1.txt', datetime.datetime(2018, 5, 17, 0, 0)))
+
+
+        assert mockobj.url == b"http://example.com/file1.txt"
+        assert mockobj.perform_was_called is True
+
+        cm.open.assert_called_with("file1.txt", "wb")
+        cm.save_new.assert_called_with(name="Downloaded from http%3A%2F%2Fexample.com%2Ffile1.txt",
+                                       owner_uuid=None, ensure_unique_name=True)
+
+        api.collections().update.assert_has_calls([
+            mock.call(uuid=cm.manifest_locator(),
+                      body={"collection":{"properties": {'http://example.com/file1.txt': {'Date': 'Thu, 17 May 2018 00:00:00 GMT'}}}})
+        ])
+
+
+    @mock.patch("pycurl.Curl")
+    @mock.patch("arvados.collection.CollectionReader")
+    def test_http_etag(self, collectionmock, curlmock):
+        api = mock.MagicMock()
+
+        api.collections().list().execute.return_value = {
+            "items": [{
+                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
+                "portable_data_hash": "99999999999999999999999999999998+99",
+                "properties": {
+                    'http://example.com/file1.txt': {
+                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
+                        'Expires': 'Wed, 16 May 2018 00:00:00 GMT',
+                        'Etag': '"123456"'
+                    }
+                }
+            }]
+        }
+
+        cm = mock.MagicMock()
+        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
+        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
+        cm.keys.return_value = ["file1.txt"]
+        collectionmock.return_value = cm
+
+        mockobj = CurlMock({
+            'Date': 'Thu, 17 May 2018 00:00:00 GMT',
+            'Expires': 'Sat, 19 May 2018 00:00:00 GMT',
+            'Etag': '"123456"'
+        })
+        mockobj.chunk = None
+        def init():
+            return mockobj
+        curlmock.side_effect = init
+
+        utcnow = mock.MagicMock()
+        utcnow.return_value = datetime.datetime(2018, 5, 17)
+
+        r = http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
+        self.assertEqual(r, ("99999999999999999999999999999998+99", "file1.txt",
+                             'zzzzz-4zz18-zzzzzzzzzzzzzz3', 'http://example.com/file1.txt',
+                             datetime.datetime(2018, 5, 17, 0, 0)))
+
+        cm.open.assert_not_called()
+
+        api.collections().update.assert_has_calls([
+            mock.call(uuid=cm.manifest_locator(),
+                      body={"collection":{"properties": {'http://example.com/file1.txt': {
+                          'Date': 'Thu, 17 May 2018 00:00:00 GMT',
+                          'Expires': 'Sat, 19 May 2018 00:00:00 GMT',
+                          'Etag': '"123456"'
+                      }}}})
+                      ])
+
+    @mock.patch("pycurl.Curl")
+    @mock.patch("arvados.collection.Collection")
+    def test_http_content_disp(self, collectionmock, curlmock):
+        api = mock.MagicMock()
+
+        api.collections().list().execute.return_value = {
+            "items": []
+        }
+
+        cm = mock.MagicMock()
+        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
+        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
+        collectionmock.return_value = cm
+
+        mockobj = CurlMock({"Content-Disposition": "attachment; filename=file1.txt"})
+        mockobj.chunk = "abc"
+        def init():
+            return mockobj
+        curlmock.side_effect = init
+
+        utcnow = mock.MagicMock()
+        utcnow.return_value = datetime.datetime(2018, 5, 15)
+
+        r = http_to_keep(api, None, "http://example.com/download?fn=/file1.txt", utcnow=utcnow)
+        self.assertEqual(r, ("99999999999999999999999999999998+99", "file1.txt",
+                             'zzzzz-4zz18-zzzzzzzzzzzzzz3',
+                             'http://example.com/download?fn=/file1.txt',
+                             datetime.datetime(2018, 5, 15, 0, 0)))
+
+        assert mockobj.url == b"http://example.com/download?fn=/file1.txt"
+
+        cm.open.assert_called_with("file1.txt", "wb")
+        cm.save_new.assert_called_with(name="Downloaded from http%3A%2F%2Fexample.com%2Fdownload%3Ffn%3D%2Ffile1.txt",
+                                       owner_uuid=None, ensure_unique_name=True)
+
+        api.collections().update.assert_has_calls([
+            mock.call(uuid=cm.manifest_locator(),
+                      body={"collection":{"properties": {"http://example.com/download?fn=/file1.txt": {'Date': 'Tue, 15 May 2018 00:00:00 GMT'}}}})
+        ])
+
+    @mock.patch("pycurl.Curl")
+    @mock.patch("arvados.collection.CollectionReader")
+    def test_http_etag_if_none_match(self, collectionmock, curlmock):
+        api = mock.MagicMock()
+
+        api.collections().list().execute.return_value = {
+            "items": [{
+                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
+                "portable_data_hash": "99999999999999999999999999999998+99",
+                "properties": {
+                    'http://example.com/file1.txt': {
+                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
+                        'Expires': 'Tue, 16 May 2018 00:00:00 GMT',
+                        'Etag': '"123456"'
+                    }
+                }
+            }]
+        }
+
+        cm = mock.MagicMock()
+        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
+        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
+        cm.keys.return_value = ["file1.txt"]
+        collectionmock.return_value = cm
+
+        mockobj = CurlMock({
+            'Date': 'Tue, 17 May 2018 00:00:00 GMT',
+            'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
+            'Etag': '"123456"'
+        })
+        mockobj.chunk = None
+        mockobj.head_response = 403
+        mockobj.get_response = 304
+        def init():
+            return mockobj
+        curlmock.side_effect = init
+
+        utcnow = mock.MagicMock()
+        utcnow.return_value = datetime.datetime(2018, 5, 17)
+
+        r = http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow)
+        self.assertEqual(r, ("99999999999999999999999999999998+99", "file1.txt",
+                             'zzzzz-4zz18-zzzzzzzzzzzzzz3', 'http://example.com/file1.txt',
+                             datetime.datetime(2018, 5, 17, 0, 0)))
+
+        print(mockobj.req_headers)
+        assert mockobj.req_headers == ["Accept: application/octet-stream", "If-None-Match: \"123456\""]
+        cm.open.assert_not_called()
+
+        api.collections().update.assert_has_calls([
+            mock.call(uuid=cm.manifest_locator(),
+                      body={"collection":{"properties": {'http://example.com/file1.txt': {
+                          'Date': 'Tue, 17 May 2018 00:00:00 GMT',
+                          'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
+                          'Etag': '"123456"'
+                      }}}})
+                      ])
+
+    @mock.patch("pycurl.Curl")
+    @mock.patch("arvados.collection.CollectionReader")
+    def test_http_prefer_cached_downloads(self, collectionmock, curlmock):
+        api = mock.MagicMock()
+
+        api.collections().list().execute.return_value = {
+            "items": [{
+                "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
+                "portable_data_hash": "99999999999999999999999999999998+99",
+                "properties": {
+                    'http://example.com/file1.txt': {
+                        'Date': 'Tue, 15 May 2018 00:00:00 GMT',
+                        'Expires': 'Tue, 16 May 2018 00:00:00 GMT',
+                        'Etag': '"123456"'
+                    }
+                }
+            }]
+        }
+
+        cm = mock.MagicMock()
+        cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
+        cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
+        cm.keys.return_value = ["file1.txt"]
+        collectionmock.return_value = cm
+
+        mockobj = CurlMock()
+        def init():
+            return mockobj
+        curlmock.side_effect = init
+
+        utcnow = mock.MagicMock()
+        utcnow.return_value = datetime.datetime(2018, 5, 17)
+
+        r = http_to_keep(api, None, "http://example.com/file1.txt", utcnow=utcnow, prefer_cached_downloads=True)
+        self.assertEqual(r, ("99999999999999999999999999999998+99", "file1.txt", 'zzzzz-4zz18-zzzzzzzzzzzzzz3',
+                             'http://example.com/file1.txt', datetime.datetime(2018, 5, 17, 0, 0)))
+
+        assert mockobj.perform_was_called is False
+        cm.open.assert_not_called()
+        api.collections().update.assert_not_called()
+
+    @mock.patch("pycurl.Curl")
+    @mock.patch("arvados.collection.CollectionReader")
+    def test_http_varying_url_params(self, collectionmock, curlmock):
+        for prurl in ("http://example.com/file1.txt", "http://example.com/file1.txt?KeyId=123&Signature=456&Expires=789"):
+            api = mock.MagicMock()
+
+            api.collections().list().execute.return_value = {
+                "items": [{
+                    "uuid": "zzzzz-4zz18-zzzzzzzzzzzzzz3",
+                    "portable_data_hash": "99999999999999999999999999999998+99",
+                    "properties": {
+                        prurl: {
+                            'Date': 'Tue, 15 May 2018 00:00:00 GMT',
+                            'Expires': 'Tue, 16 May 2018 00:00:00 GMT',
+                            'Etag': '"123456"'
+                        }
+                    }
+                }]
+            }
+
+            cm = mock.MagicMock()
+            cm.manifest_locator.return_value = "zzzzz-4zz18-zzzzzzzzzzzzzz3"
+            cm.portable_data_hash.return_value = "99999999999999999999999999999998+99"
+            cm.keys.return_value = ["file1.txt"]
+            collectionmock.return_value = cm
+
+            mockobj = CurlMock({
+                'Date': 'Tue, 17 May 2018 00:00:00 GMT',
+                'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
+                'Etag': '"123456"'
+            })
+            mockobj.chunk = None
+            def init():
+                return mockobj
+            curlmock.side_effect = init
+
+            utcnow = mock.MagicMock()
+            utcnow.return_value = datetime.datetime(2018, 5, 17)
+
+            r = http_to_keep(api, None, "http://example.com/file1.txt?KeyId=123&Signature=456&Expires=789",
+                                              utcnow=utcnow, varying_url_params="KeyId,Signature,Expires")
+            self.assertEqual(r, ("99999999999999999999999999999998+99", "file1.txt", 'zzzzz-4zz18-zzzzzzzzzzzzzz3',
+                                 'http://example.com/file1.txt', datetime.datetime(2018, 5, 17, 0, 0)))
+
+            assert mockobj.perform_was_called is True
+            cm.open.assert_not_called()
+
+            api.collections().update.assert_has_calls([
+                mock.call(uuid=cm.manifest_locator(),
+                          body={"collection":{"properties": {'http://example.com/file1.txt': {
+                              'Date': 'Tue, 17 May 2018 00:00:00 GMT',
+                              'Expires': 'Tue, 19 May 2018 00:00:00 GMT',
+                              'Etag': '"123456"'
+                          }}}})
+                          ])
index 0fe396113644740b37dd2e4292c2a9d56c04605d..8c0f096b616986211438c5365fc405dcba1c322d 100644 (file)
@@ -11,6 +11,7 @@ from builtins import range
 from builtins import object
 import hashlib
 import mock
+from mock import patch
 import os
 import errno
 import pycurl
@@ -24,6 +25,7 @@ import tempfile
 import time
 import unittest
 import urllib.parse
+import mmap
 
 import parameterized
 
@@ -167,30 +169,30 @@ class KeepPermissionTestCase(run_test_server.TestCaseWithServers, DiskCacheBase)
                          b'foo',
                          'wrong content from Keep.get(md5("foo"))')
 
-        # GET with an unsigned locator => NotFound
+        # GET with an unsigned locator => bad request
         bar_locator = keep_client.put('bar')
         unsigned_bar_locator = "37b51d194a7513e45b56f6524f2d51f2+3"
         self.assertRegex(
             bar_locator,
             r'^37b51d194a7513e45b56f6524f2d51f2\+3\+A[a-f0-9]+@[a-f0-9]+$',
             'invalid locator from Keep.put("bar"): ' + bar_locator)
-        self.assertRaises(arvados.errors.NotFoundError,
+        self.assertRaises(arvados.errors.KeepReadError,
                           keep_client.get,
                           unsigned_bar_locator)
 
-        # GET from a different user => NotFound
+        # GET from a different user => bad request
         run_test_server.authorize_with('spectator')
-        self.assertRaises(arvados.errors.NotFoundError,
+        self.assertRaises(arvados.errors.KeepReadError,
                           arvados.Keep.get,
                           bar_locator)
 
-        # Unauthenticated GET for a signed locator => NotFound
-        # Unauthenticated GET for an unsigned locator => NotFound
+        # Unauthenticated GET for a signed locator => bad request
+        # Unauthenticated GET for an unsigned locator => bad request
         keep_client.api_token = ''
-        self.assertRaises(arvados.errors.NotFoundError,
+        self.assertRaises(arvados.errors.KeepReadError,
                           keep_client.get,
                           bar_locator)
-        self.assertRaises(arvados.errors.NotFoundError,
+        self.assertRaises(arvados.errors.KeepReadError,
                           keep_client.get,
                           unsigned_bar_locator)
 
@@ -276,7 +278,7 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         try:
             # this will fail, but it ensures we get the service
             # discovery response
-            keep_client.put('baz2')
+            keep_client.put('baz2', num_retries=0)
         except:
             pass
         self.assertTrue(keep_client.using_proxy)
@@ -338,7 +340,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         api_client = self.mock_keep_services(count=1)
         force_timeout = socket.timeout("timed out")
         with tutil.mock_keep_responses(force_timeout, 0) as mock:
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             with self.assertRaises(arvados.errors.KeepReadError):
                 keep_client.get('ffffffffffffffffffffffffffffffff')
             self.assertEqual(
@@ -355,7 +361,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         api_client = self.mock_keep_services(count=1)
         force_timeout = socket.timeout("timed out")
         with tutil.mock_keep_responses(force_timeout, 0) as mock:
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             with self.assertRaises(arvados.errors.KeepWriteError):
                 keep_client.put(b'foo')
             self.assertEqual(
@@ -372,7 +382,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         api_client = self.mock_keep_services(count=1)
         force_timeout = socket.timeout("timed out")
         with tutil.mock_keep_responses(force_timeout, 0) as mock:
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             with self.assertRaises(arvados.errors.KeepReadError):
                 keep_client.head('ffffffffffffffffffffffffffffffff')
             self.assertEqual(
@@ -389,7 +403,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         api_client = self.mock_keep_services(service_type='proxy', count=1)
         force_timeout = socket.timeout("timed out")
         with tutil.mock_keep_responses(force_timeout, 0) as mock:
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             with self.assertRaises(arvados.errors.KeepReadError):
                 keep_client.get('ffffffffffffffffffffffffffffffff')
             self.assertEqual(
@@ -406,7 +424,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         api_client = self.mock_keep_services(service_type='proxy', count=1)
         force_timeout = socket.timeout("timed out")
         with tutil.mock_keep_responses(force_timeout, 0) as mock:
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             with self.assertRaises(arvados.errors.KeepReadError):
                 keep_client.head('ffffffffffffffffffffffffffffffff')
             self.assertEqual(
@@ -424,7 +446,10 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         api_client = self.mock_keep_services(service_type='proxy', count=1)
         force_timeout = socket.timeout("timed out")
         with tutil.mock_keep_responses(force_timeout, 0) as mock:
-            keep_client = arvados.KeepClient(api_client=api_client)
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                num_retries=0,
+            )
             with self.assertRaises(arvados.errors.KeepWriteError):
                 keep_client.put('foo')
             self.assertEqual(
@@ -441,7 +466,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         api_client = mock.MagicMock(name='api_client')
         api_client.keep_services().accessible().execute.side_effect = (
             arvados.errors.ApiError)
-        keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+        keep_client = arvados.KeepClient(
+            api_client=api_client,
+            block_cache=self.make_block_cache(self.disk_cache),
+            num_retries=0,
+        )
         with self.assertRaises(exc_class) as err_check:
             getattr(keep_client, verb)('d41d8cd98f00b204e9800998ecf8427e+0')
         self.assertEqual(0, len(err_check.exception.request_errors()))
@@ -461,7 +490,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
             "retry error reporting test", 500, 500, 500, 500, 500, 500, 502, 502)
         with req_mock, tutil.skip_sleep, \
                 self.assertRaises(exc_class) as err_check:
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             getattr(keep_client, verb)('d41d8cd98f00b204e9800998ecf8427e+0',
                                        num_retries=3)
         self.assertEqual([502, 502], [
@@ -484,7 +517,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         api_client = self.mock_keep_services(count=3)
         with tutil.mock_keep_responses(data_loc, 200, 500, 500) as req_mock, \
                 self.assertRaises(arvados.errors.KeepWriteError) as exc_check:
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             keep_client.put(data)
         self.assertEqual(2, len(exc_check.exception.request_errors()))
 
@@ -494,8 +531,12 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         api_client = self.mock_keep_services(service_type='proxy', read_only=True, count=1)
         with tutil.mock_keep_responses(data_loc, 200, 500, 500) as req_mock, \
                 self.assertRaises(arvados.errors.KeepWriteError) as exc_check:
-          keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
-          keep_client.put(data)
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
+            keep_client.put(data)
         self.assertEqual(True, ("no Keep services available" in str(exc_check.exception)))
         self.assertEqual(0, len(exc_check.exception.request_errors()))
 
@@ -503,7 +544,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         body = b'oddball service get'
         api_client = self.mock_keep_services(service_type='fancynewblobstore')
         with tutil.mock_keep_responses(body, 200):
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             actual = keep_client.get(tutil.str_keep_locator(body))
         self.assertEqual(body, actual)
 
@@ -512,7 +557,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         pdh = tutil.str_keep_locator(body)
         api_client = self.mock_keep_services(service_type='fancynewblobstore')
         with tutil.mock_keep_responses(pdh, 200):
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             actual = keep_client.put(body, copies=1)
         self.assertEqual(pdh, actual)
 
@@ -524,7 +573,11 @@ class KeepClientServiceTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCach
         headers = {'x-keep-replicas-stored': 3}
         with tutil.mock_keep_responses(pdh, 200, 418, 418, 418,
                                        **headers) as req_mock:
-            keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+            keep_client = arvados.KeepClient(
+                api_client=api_client,
+                block_cache=self.make_block_cache(self.disk_cache),
+                num_retries=0,
+            )
             actual = keep_client.put(body, copies=2)
         self.assertEqual(pdh, actual)
         self.assertEqual(1, req_mock.call_count)
@@ -574,122 +627,6 @@ class KeepClientCacheTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCacheB
 
 
 
-@tutil.skip_sleep
-@parameterized.parameterized_class([{"disk_cache": True}, {"disk_cache": False}])
-class KeepStorageClassesTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCacheBase):
-    disk_cache = False
-
-    def setUp(self):
-        self.api_client = self.mock_keep_services(count=2)
-        self.keep_client = arvados.KeepClient(api_client=self.api_client, block_cache=self.make_block_cache(self.disk_cache))
-        self.data = b'xyzzy'
-        self.locator = '1271ed5ef305aadabc605b1609e24c52'
-
-    def tearDown(self):
-        DiskCacheBase.tearDown(self)
-
-    def test_multiple_default_storage_classes_req_header(self):
-        api_mock = self.api_client_mock()
-        api_mock.config.return_value = {
-            'StorageClasses': {
-                'foo': { 'Default': True },
-                'bar': { 'Default': True },
-                'baz': { 'Default': False }
-            }
-        }
-        api_client = self.mock_keep_services(api_mock=api_mock, count=2)
-        keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
-        resp_hdr = {
-            'x-keep-storage-classes-confirmed': 'foo=1, bar=1',
-            'x-keep-replicas-stored': 1
-        }
-        with tutil.mock_keep_responses(self.locator, 200, **resp_hdr) as mock:
-            keep_client.put(self.data, copies=1)
-            req_hdr = mock.responses[0]
-            self.assertIn(
-                'X-Keep-Storage-Classes: bar, foo', req_hdr.getopt(pycurl.HTTPHEADER))
-
-    def test_storage_classes_req_header(self):
-        self.assertEqual(
-            self.api_client.config()['StorageClasses'],
-            {'default': {'Default': True}})
-        cases = [
-            # requested, expected
-            [['foo'], 'X-Keep-Storage-Classes: foo'],
-            [['bar', 'foo'], 'X-Keep-Storage-Classes: bar, foo'],
-            [[], 'X-Keep-Storage-Classes: default'],
-            [None, 'X-Keep-Storage-Classes: default'],
-        ]
-        for req_classes, expected_header in cases:
-            headers = {'x-keep-replicas-stored': 1}
-            if req_classes is None or len(req_classes) == 0:
-                confirmed_hdr = 'default=1'
-            elif len(req_classes) > 0:
-                confirmed_hdr = ', '.join(["{}=1".format(cls) for cls in req_classes])
-            headers.update({'x-keep-storage-classes-confirmed': confirmed_hdr})
-            with tutil.mock_keep_responses(self.locator, 200, **headers) as mock:
-                self.keep_client.put(self.data, copies=1, classes=req_classes)
-                req_hdr = mock.responses[0]
-                self.assertIn(expected_header, req_hdr.getopt(pycurl.HTTPHEADER))
-
-    def test_partial_storage_classes_put(self):
-        headers = {
-            'x-keep-replicas-stored': 1,
-            'x-keep-storage-classes-confirmed': 'foo=1'}
-        with tutil.mock_keep_responses(self.locator, 200, 503, **headers) as mock:
-            with self.assertRaises(arvados.errors.KeepWriteError):
-                self.keep_client.put(self.data, copies=1, classes=['foo', 'bar'])
-            # 1st request, both classes pending
-            req1_headers = mock.responses[0].getopt(pycurl.HTTPHEADER)
-            self.assertIn('X-Keep-Storage-Classes: bar, foo', req1_headers)
-            # 2nd try, 'foo' class already satisfied
-            req2_headers = mock.responses[1].getopt(pycurl.HTTPHEADER)
-            self.assertIn('X-Keep-Storage-Classes: bar', req2_headers)
-
-    def test_successful_storage_classes_put_requests(self):
-        cases = [
-            # wanted_copies, wanted_classes, confirmed_copies, confirmed_classes, expected_requests
-            [ 1, ['foo'], 1, 'foo=1', 1],
-            [ 1, ['foo'], 2, 'foo=2', 1],
-            [ 2, ['foo'], 2, 'foo=2', 1],
-            [ 2, ['foo'], 1, 'foo=1', 2],
-            [ 1, ['foo', 'bar'], 1, 'foo=1, bar=1', 1],
-            [ 1, ['foo', 'bar'], 2, 'foo=2, bar=2', 1],
-            [ 2, ['foo', 'bar'], 2, 'foo=2, bar=2', 1],
-            [ 2, ['foo', 'bar'], 1, 'foo=1, bar=1', 2],
-            [ 1, ['foo', 'bar'], 1, None, 1],
-            [ 1, ['foo'], 1, None, 1],
-            [ 2, ['foo'], 2, None, 1],
-            [ 2, ['foo'], 1, None, 2],
-        ]
-        for w_copies, w_classes, c_copies, c_classes, e_reqs in cases:
-            headers = {'x-keep-replicas-stored': c_copies}
-            if c_classes is not None:
-                headers.update({'x-keep-storage-classes-confirmed': c_classes})
-            with tutil.mock_keep_responses(self.locator, 200, 200, **headers) as mock:
-                case_desc = 'wanted_copies={}, wanted_classes="{}", confirmed_copies={}, confirmed_classes="{}", expected_requests={}'.format(w_copies, ', '.join(w_classes), c_copies, c_classes, e_reqs)
-                self.assertEqual(self.locator,
-                    self.keep_client.put(self.data, copies=w_copies, classes=w_classes),
-                    case_desc)
-                self.assertEqual(e_reqs, mock.call_count, case_desc)
-
-    def test_failed_storage_classes_put_requests(self):
-        cases = [
-            # wanted_copies, wanted_classes, confirmed_copies, confirmed_classes, return_code
-            [ 1, ['foo'], 1, 'bar=1', 200],
-            [ 1, ['foo'], 1, None, 503],
-            [ 2, ['foo'], 1, 'bar=1, foo=0', 200],
-            [ 3, ['foo'], 1, 'bar=1, foo=1', 200],
-            [ 3, ['foo', 'bar'], 1, 'bar=2, foo=1', 200],
-        ]
-        for w_copies, w_classes, c_copies, c_classes, return_code in cases:
-            headers = {'x-keep-replicas-stored': c_copies}
-            if c_classes is not None:
-                headers.update({'x-keep-storage-classes-confirmed': c_classes})
-            with tutil.mock_keep_responses(self.locator, return_code, return_code, **headers):
-                case_desc = 'wanted_copies={}, wanted_classes="{}", confirmed_copies={}, confirmed_classes="{}"'.format(w_copies, ', '.join(w_classes), c_copies, c_classes)
-                with self.assertRaises(arvados.errors.KeepWriteError, msg=case_desc):
-                    self.keep_client.put(self.data, copies=w_copies, classes=w_classes)
 
 @tutil.skip_sleep
 @parameterized.parameterized_class([{"disk_cache": True}, {"disk_cache": False}])
@@ -1250,10 +1187,6 @@ class KeepClientRetryTestMixin(object):
         with self.TEST_PATCHER(self.DEFAULT_EXPECT, Exception('mock err'), 200):
             self.check_success(num_retries=3)
 
-    def test_no_default_retry(self):
-        with self.TEST_PATCHER(self.DEFAULT_EXPECT, 500, 200):
-            self.check_exception()
-
     def test_no_retry_after_permanent_error(self):
         with self.TEST_PATCHER(self.DEFAULT_EXPECT, 403, 200):
             self.check_exception(num_retries=3)
@@ -1293,7 +1226,7 @@ class KeepClientRetryGetTestCase(KeepClientRetryTestMixin, unittest.TestCase, Di
         # and a high threshold of servers report that it's not found.
         # This test rigs up 50/50 disagreement between two servers, and
         # checks that it does not become a NotFoundError.
-        client = self.new_client()
+        client = self.new_client(num_retries=0)
         with tutil.mock_keep_responses(self.DEFAULT_EXPECT, 404, 500):
             with self.assertRaises(arvados.errors.KeepReadError) as exc_check:
                 client.get(self.HINTED_LOCATOR)
@@ -1341,7 +1274,7 @@ class KeepClientRetryHeadTestCase(KeepClientRetryTestMixin, unittest.TestCase, D
         # and a high threshold of servers report that it's not found.
         # This test rigs up 50/50 disagreement between two servers, and
         # checks that it does not become a NotFoundError.
-        client = self.new_client()
+        client = self.new_client(num_retries=0)
         with tutil.mock_keep_responses(self.DEFAULT_EXPECT, 404, 500):
             with self.assertRaises(arvados.errors.KeepReadError) as exc_check:
                 client.head(self.HINTED_LOCATOR)
@@ -1710,21 +1643,31 @@ class KeepDiskCacheTestCase(unittest.TestCase, tutil.ApiClientMock):
                 keep_client.get(self.locator)
 
 
-    @mock.patch('mmap.mmap')
-    def test_disk_cache_retry_write_error(self, mockmmap):
+    def test_disk_cache_retry_write_error(self):
         block_cache = arvados.keep.KeepBlockCache(disk_cache=True,
                                                   disk_cache_dir=self.disk_cache_dir)
 
         keep_client = arvados.KeepClient(api_client=self.api_client, block_cache=block_cache)
 
-        mockmmap.side_effect = (OSError(errno.ENOSPC, "no space"), self.data)
+        called = False
+        realmmap = mmap.mmap
+        def sideeffect_mmap(*args, **kwargs):
+            nonlocal called
+            if not called:
+                called = True
+                raise OSError(errno.ENOSPC, "no space")
+            else:
+                return realmmap(*args, **kwargs)
+
+        with patch('mmap.mmap') as mockmmap:
+            mockmmap.side_effect = sideeffect_mmap
 
-        cache_max_before = block_cache.cache_max
+            cache_max_before = block_cache.cache_max
 
-        with tutil.mock_keep_responses(self.data, 200) as mock:
-            self.assertTrue(tutil.binary_compare(keep_client.get(self.locator), self.data))
+            with tutil.mock_keep_responses(self.data, 200) as mock:
+                self.assertTrue(tutil.binary_compare(keep_client.get(self.locator), self.data))
 
-        self.assertIsNotNone(keep_client.get_from_cache(self.locator))
+            self.assertIsNotNone(keep_client.get_from_cache(self.locator))
 
         with open(os.path.join(self.disk_cache_dir, self.locator[0:3], self.locator+".keepcacheblock"), "rb") as f:
             self.assertTrue(tutil.binary_compare(f.read(), self.data))
@@ -1733,21 +1676,31 @@ class KeepDiskCacheTestCase(unittest.TestCase, tutil.ApiClientMock):
         self.assertTrue(cache_max_before > block_cache.cache_max)
 
 
-    @mock.patch('mmap.mmap')
-    def test_disk_cache_retry_write_error2(self, mockmmap):
+    def test_disk_cache_retry_write_error2(self):
         block_cache = arvados.keep.KeepBlockCache(disk_cache=True,
                                                   disk_cache_dir=self.disk_cache_dir)
 
         keep_client = arvados.KeepClient(api_client=self.api_client, block_cache=block_cache)
 
-        mockmmap.side_effect = (OSError(errno.ENOMEM, "no memory"), self.data)
+        called = False
+        realmmap = mmap.mmap
+        def sideeffect_mmap(*args, **kwargs):
+            nonlocal called
+            if not called:
+                called = True
+                raise OSError(errno.ENOMEM, "no memory")
+            else:
+                return realmmap(*args, **kwargs)
 
-        slots_before = block_cache._max_slots
+        with patch('mmap.mmap') as mockmmap:
+            mockmmap.side_effect = sideeffect_mmap
 
-        with tutil.mock_keep_responses(self.data, 200) as mock:
-            self.assertTrue(tutil.binary_compare(keep_client.get(self.locator), self.data))
+            slots_before = block_cache._max_slots
 
-        self.assertIsNotNone(keep_client.get_from_cache(self.locator))
+            with tutil.mock_keep_responses(self.data, 200) as mock:
+                self.assertTrue(tutil.binary_compare(keep_client.get(self.locator), self.data))
+
+            self.assertIsNotNone(keep_client.get_from_cache(self.locator))
 
         with open(os.path.join(self.disk_cache_dir, self.locator[0:3], self.locator+".keepcacheblock"), "rb") as f:
             self.assertTrue(tutil.binary_compare(f.read(), self.data))
index 2d020059374b3dc3f42fd95010ebd675605ec929..bcf784d13003a1de826e87d10f04d0a2f4a8d7d5 100644 (file)
@@ -174,14 +174,14 @@ class CheckHTTPResponseSuccessTestCase(unittest.TestCase):
         self.check_is(True, *list(range(200, 207)))
 
     def test_obvious_stops(self):
-        self.check_is(False, 424, 426, 428, 431,
+        self.check_is(False, 422, 424, 426, 428, 431,
                       *list(range(400, 408)) + list(range(410, 420)))
 
     def test_obvious_retries(self):
         self.check_is(None, 500, 502, 503, 504)
 
     def test_4xx_retries(self):
-        self.check_is(None, 408, 409, 422, 423)
+        self.check_is(None, 408, 409, 423)
 
     def test_5xx_failures(self):
         self.check_is(False, 501, *list(range(505, 512)))
index 76c62cb0ce9a5c5424db155bfec1c2ace5ac6df8..9389b25c88e10840b979f874369fbcbdb38f7540 100644 (file)
@@ -28,7 +28,7 @@ class ApiClientRetryTestMixin(object):
     def setUp(self):
         # Patch arvados.api() to return our mock API, so we can mock
         # its http requests.
-        self.api_client = arvados.api('v1', cache=False)
+        self.api_client = arvados.api('v1', cache=False, num_retries=0)
         self.api_patch = mock.patch('arvados.api', return_value=self.api_client)
         self.api_patch.start()
 
diff --git a/sdk/python/tests/test_storage_classes.py b/sdk/python/tests/test_storage_classes.py
new file mode 100644 (file)
index 0000000..21bacc3
--- /dev/null
@@ -0,0 +1,128 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import arvados
+import pycurl
+
+import unittest
+import parameterized
+from . import arvados_testutil as tutil
+from .arvados_testutil import DiskCacheBase
+
+@tutil.skip_sleep
+@parameterized.parameterized_class([{"disk_cache": True}, {"disk_cache": False}])
+class KeepStorageClassesTestCase(unittest.TestCase, tutil.ApiClientMock, DiskCacheBase):
+    disk_cache = False
+
+    def setUp(self):
+        self.api_client = self.mock_keep_services(count=2)
+        self.keep_client = arvados.KeepClient(api_client=self.api_client, block_cache=self.make_block_cache(self.disk_cache))
+        self.data = b'xyzzy'
+        self.locator = '1271ed5ef305aadabc605b1609e24c52'
+
+    def tearDown(self):
+        DiskCacheBase.tearDown(self)
+
+    def test_multiple_default_storage_classes_req_header(self):
+        api_mock = self.api_client_mock()
+        api_mock.config.return_value = {
+            'StorageClasses': {
+                'foo': { 'Default': True },
+                'bar': { 'Default': True },
+                'baz': { 'Default': False }
+            }
+        }
+        api_client = self.mock_keep_services(api_mock=api_mock, count=2)
+        keep_client = arvados.KeepClient(api_client=api_client, block_cache=self.make_block_cache(self.disk_cache))
+        resp_hdr = {
+            'x-keep-storage-classes-confirmed': 'foo=1, bar=1',
+            'x-keep-replicas-stored': 1
+        }
+        with tutil.mock_keep_responses(self.locator, 200, **resp_hdr) as mock:
+            keep_client.put(self.data, copies=1)
+            req_hdr = mock.responses[0]
+            self.assertIn(
+                'X-Keep-Storage-Classes: bar, foo', req_hdr.getopt(pycurl.HTTPHEADER))
+
+    def test_storage_classes_req_header(self):
+        self.assertEqual(
+            self.api_client.config()['StorageClasses'],
+            {'default': {'Default': True}})
+        cases = [
+            # requested, expected
+            [['foo'], 'X-Keep-Storage-Classes: foo'],
+            [['bar', 'foo'], 'X-Keep-Storage-Classes: bar, foo'],
+            [[], 'X-Keep-Storage-Classes: default'],
+            [None, 'X-Keep-Storage-Classes: default'],
+        ]
+        for req_classes, expected_header in cases:
+            headers = {'x-keep-replicas-stored': 1}
+            if req_classes is None or len(req_classes) == 0:
+                confirmed_hdr = 'default=1'
+            elif len(req_classes) > 0:
+                confirmed_hdr = ', '.join(["{}=1".format(cls) for cls in req_classes])
+            headers.update({'x-keep-storage-classes-confirmed': confirmed_hdr})
+            with tutil.mock_keep_responses(self.locator, 200, **headers) as mock:
+                self.keep_client.put(self.data, copies=1, classes=req_classes)
+                req_hdr = mock.responses[0]
+                self.assertIn(expected_header, req_hdr.getopt(pycurl.HTTPHEADER))
+
+    def test_partial_storage_classes_put(self):
+        headers = {
+            'x-keep-replicas-stored': 1,
+            'x-keep-storage-classes-confirmed': 'foo=1'}
+        with tutil.mock_keep_responses(self.locator, 200, 503, **headers) as mock:
+            with self.assertRaises(arvados.errors.KeepWriteError):
+                self.keep_client.put(self.data, copies=1, classes=['foo', 'bar'], num_retries=0)
+            # 1st request, both classes pending
+            req1_headers = mock.responses[0].getopt(pycurl.HTTPHEADER)
+            self.assertIn('X-Keep-Storage-Classes: bar, foo', req1_headers)
+            # 2nd try, 'foo' class already satisfied
+            req2_headers = mock.responses[1].getopt(pycurl.HTTPHEADER)
+            self.assertIn('X-Keep-Storage-Classes: bar', req2_headers)
+
+    def test_successful_storage_classes_put_requests(self):
+        cases = [
+            # wanted_copies, wanted_classes, confirmed_copies, confirmed_classes, expected_requests
+            [ 1, ['foo'], 1, 'foo=1', 1],
+            [ 1, ['foo'], 2, 'foo=2', 1],
+            [ 2, ['foo'], 2, 'foo=2', 1],
+            [ 2, ['foo'], 1, 'foo=1', 2],
+            [ 1, ['foo', 'bar'], 1, 'foo=1, bar=1', 1],
+            [ 1, ['foo', 'bar'], 2, 'foo=2, bar=2', 1],
+            [ 2, ['foo', 'bar'], 2, 'foo=2, bar=2', 1],
+            [ 2, ['foo', 'bar'], 1, 'foo=1, bar=1', 2],
+            [ 1, ['foo', 'bar'], 1, None, 1],
+            [ 1, ['foo'], 1, None, 1],
+            [ 2, ['foo'], 2, None, 1],
+            [ 2, ['foo'], 1, None, 2],
+        ]
+        for w_copies, w_classes, c_copies, c_classes, e_reqs in cases:
+            headers = {'x-keep-replicas-stored': c_copies}
+            if c_classes is not None:
+                headers.update({'x-keep-storage-classes-confirmed': c_classes})
+            with tutil.mock_keep_responses(self.locator, 200, 200, **headers) as mock:
+                case_desc = 'wanted_copies={}, wanted_classes="{}", confirmed_copies={}, confirmed_classes="{}", expected_requests={}'.format(w_copies, ', '.join(w_classes), c_copies, c_classes, e_reqs)
+                self.assertEqual(self.locator,
+                    self.keep_client.put(self.data, copies=w_copies, classes=w_classes),
+                    case_desc)
+                self.assertEqual(e_reqs, mock.call_count, case_desc)
+
+    def test_failed_storage_classes_put_requests(self):
+        cases = [
+            # wanted_copies, wanted_classes, confirmed_copies, confirmed_classes, return_code
+            [ 1, ['foo'], 1, 'bar=1', 200],
+            [ 1, ['foo'], 1, None, 503],
+            [ 2, ['foo'], 1, 'bar=1, foo=0', 200],
+            [ 3, ['foo'], 1, 'bar=1, foo=1', 200],
+            [ 3, ['foo', 'bar'], 1, 'bar=2, foo=1', 200],
+        ]
+        for w_copies, w_classes, c_copies, c_classes, return_code in cases:
+            headers = {'x-keep-replicas-stored': c_copies}
+            if c_classes is not None:
+                headers.update({'x-keep-storage-classes-confirmed': c_classes})
+            with tutil.mock_keep_responses(self.locator, return_code, return_code, **headers):
+                case_desc = 'wanted_copies={}, wanted_classes="{}", confirmed_copies={}, confirmed_classes="{}"'.format(w_copies, ', '.join(w_classes), c_copies, c_classes)
+                with self.assertRaises(arvados.errors.KeepWriteError, msg=case_desc):
+                    self.keep_client.put(self.data, copies=w_copies, classes=w_classes, num_retries=0)
index dc84a037f85947baa43896e58aec65d9a370c297..12a3340eab55e25593a1b9d29e2c9296e71039fe 100644 (file)
@@ -223,13 +223,6 @@ class StreamRetryTestMixin(object):
             reader = self.reader_for('bar_file')
             self.assertEqual(b'bar', self.read_for_test(reader, 3))
 
-    @tutil.skip_sleep
-    def test_read_no_default_retry(self):
-        with tutil.mock_keep_responses('', 500):
-            reader = self.reader_for('user_agreement')
-            with self.assertRaises(arvados.errors.KeepReadError):
-                self.read_for_test(reader, 10)
-
     @tutil.skip_sleep
     def test_read_with_instance_retries(self):
         with tutil.mock_keep_responses('foo', 500, 200):
index 4dba9ce3dc7a5105533a526fe3ee304ed60d784c..75d4a89e30ea77ee061908f601377d24ba74201a 100644 (file)
@@ -2,10 +2,14 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+import itertools
 import os
+import parameterized
 import subprocess
 import unittest
 
+from unittest import mock
+
 import arvados
 import arvados.util
 
@@ -54,6 +58,12 @@ class KeysetTestHelper:
         self.n += 1
         return self.expect[self.n-1][1]
 
+_SELECT_FAKE_ITEM = {
+    'uuid': 'zzzzz-zyyyz-zzzzzyyyyywwwww',
+    'name': 'KeysetListAllTestCase.test_select mock',
+    'created_at': '2023-08-28T12:34:56.123456Z',
+}
+
 class KeysetListAllTestCase(unittest.TestCase):
     def test_empty(self):
         ks = KeysetTestHelper([[
@@ -163,7 +173,6 @@ class KeysetListAllTestCase(unittest.TestCase):
         ls = list(arvados.util.keyset_list_all(ks.fn, filters=[["foo", ">", "bar"]]))
         self.assertEqual(ls, [{"created_at": "1", "uuid": "1"}, {"created_at": "2", "uuid": "2"}])
 
-
     def test_onepage_desc(self):
         ks = KeysetTestHelper([[
             {"limit": 1000, "count": "none", "order": ["created_at desc", "uuid desc"], "filters": []},
@@ -175,3 +184,35 @@ class KeysetListAllTestCase(unittest.TestCase):
 
         ls = list(arvados.util.keyset_list_all(ks.fn, ascending=False))
         self.assertEqual(ls, [{"created_at": "2", "uuid": "2"}, {"created_at": "1", "uuid": "1"}])
+
+    @parameterized.parameterized.expand(zip(
+        itertools.cycle(_SELECT_FAKE_ITEM),
+        itertools.chain.from_iterable(
+            itertools.combinations(_SELECT_FAKE_ITEM, count)
+            for count in range(len(_SELECT_FAKE_ITEM) + 1)
+        ),
+    ))
+    def test_select(self, order_key, select):
+        # keyset_list_all must have both uuid and order_key to function.
+        # Test that it selects those fields along with user-specified ones.
+        expect_select = {'uuid', order_key, *select}
+        item = {
+            key: value
+            for key, value in _SELECT_FAKE_ITEM.items()
+            if key in expect_select
+        }
+        list_func = mock.Mock()
+        list_func().execute = mock.Mock(
+            side_effect=[
+                {'items': [item]},
+                {'items': []},
+                {'items': []},
+            ],
+        )
+        list_func.reset_mock()
+        actual = list(arvados.util.keyset_list_all(list_func, order_key, select=list(select)))
+        self.assertEqual(actual, [item])
+        calls = list_func.call_args_list
+        self.assertTrue(len(calls) >= 2, "list_func() not called enough to exhaust items")
+        for args, kwargs in calls:
+            self.assertEqual(set(kwargs.get('select', ())), expect_select)
diff --git a/sdk/ruby-google-api-client/.gitignore b/sdk/ruby-google-api-client/.gitignore
new file mode 100644 (file)
index 0000000..fb4875a
--- /dev/null
@@ -0,0 +1,20 @@
+._*
+.DS_Store
+.yardoc
+.bundle
+.rvmrc
+Gemfile.lock
+coverage
+doc
+heckling
+pkg
+specdoc
+wiki
+.google-api.yaml
+*.log
+
+#IntelliJ
+.idea
+*.iml
+atlassian*
+
diff --git a/sdk/ruby-google-api-client/.rspec b/sdk/ruby-google-api-client/.rspec
new file mode 100644 (file)
index 0000000..7438fbe
--- /dev/null
@@ -0,0 +1,2 @@
+--colour
+--format documentation
diff --git a/sdk/ruby-google-api-client/.travis.yml b/sdk/ruby-google-api-client/.travis.yml
new file mode 100644 (file)
index 0000000..2a45372
--- /dev/null
@@ -0,0 +1,23 @@
+language: ruby
+rvm:
+  - 2.2
+  - 2.0.0
+  - 2.1
+  - 1.9.3
+  - rbx-2
+  - jruby
+env:
+  - RAILS_VERSION="~>3.2"
+  - RAILS_VERSION="~>4.0.0"
+  - RAILS_VERSION="~>4.1.0"
+  - RAILS_VERSION="~>4.2.0"
+script: "bundle exec rake spec:all"
+before_install:
+ - sudo apt-get update
+ - sudo apt-get install idn
+notifications:
+  email:
+    recipients:
+      - sbazyl@google.com
+    on_success: change
+    on_failure: change
diff --git a/sdk/ruby-google-api-client/.yardopts b/sdk/ruby-google-api-client/.yardopts
new file mode 100644 (file)
index 0000000..fa8f29d
--- /dev/null
@@ -0,0 +1,7 @@
+--markup markdown
+lib/**/*.rb
+ext/**/*.c
+-
+README.md
+CHANGELOG.md
+LICENSE
diff --git a/sdk/ruby-google-api-client/CHANGELOG.md b/sdk/ruby-google-api-client/CHANGELOG.md
new file mode 100644 (file)
index 0000000..34e7dfa
--- /dev/null
@@ -0,0 +1,178 @@
+# 0.8.8
+* Do not put CR/LF in http headers
+
+# 0.8.7
+* Lock activesupport version to < 5.0
+
+# 0.8.6
+* Use discovered 'rootUrl' as base URI for services
+* Respect discovered methods with colons in path
+
+# 0.8.5
+* Corrects the regression Rails 4 support in the 0.8.4 release.
+
+# 0.8.4
+* Fixes a file permission issues with the 0.8.3 release
+* Fixes warnings when the library is used
+
+# 0.8.3
+* Adds support for authorization via Application Default Credentials.
+# Adds support for tracking coverage on coveralls.io
+
+# 0.8.2
+* Fixes for file storage and missing cacerts file
+
+# 0.8.1
+* Fix logger in rails
+
+# 0.8.0
+* Refactored credential storage, added support for redis
+* Update gem depdendencies
+* Fixed retry logic to allow for auth retries independent of the overall number of retries
+* Added `:force_encoding` option to set body content encoding based on the Content-Type header
+* Batch requests with the service interface now inherit the service's connection
+* `register_discover_document` now returns the API instance
+* Added `:proxy` option to set Faraday's HTTP proxy setting
+* Added `:faraday_options` option to allow passthrough settings to Faraday connection
+* Drop 1.8.x support
+* This will be the last release with 1.9.x support
+
+# 0.7.1
+* Minor fix to update gem dependencies
+
+# 0.7.0
+* Remove CLI
+* Support for automatic retires & backoff. Off by default, enable by setting `retries` on `APIClient`
+* Experimental new interface (see `Google::APIClient::Service`)
+* Fix warnings when using Faraday separately
+* Support Google Compute Engine service accounts
+* Enable gzip compression for responses
+* Upgrade to Faraday 0.9.0. Resolves multiple issues with query parameter encodings.
+* Use bundled root certificates for verifying SSL certificates
+* Rewind media when retrying uploads
+
+# 0.6.4
+* Pin signet version to 0.4.x
+
+# 0.6.3
+
+* Update autoparse to 0.3.3 to fix cases where results aren't correctly parsed.
+* Fix railtie loading for compatibility with rails < 3.0
+* Fix refresh of access token when passing credentials as parameter to execute
+* Fix URI processing in batch requests to allow query parameters
+
+# 0.6.2
+
+* Update signet to 0.4.6 to support server side continuation of postmessage
+  auth flows.
+
+# 0.6.1
+
+* Fix impersonation with service accounts
+
+# 0.6
+
+* Apps strongly encouraged to set :application_name & :application_version when
+  initializing a client
+* JWT/service accounts moved to signet
+* Added helper class for installed app OAuth flows, updated samples & CLI
+* Initial logging support for client
+* Fix PKCS12 loading on windows
+* Allow disabling auto-refresh of OAuth 2 access tokens
+* Compatibility with MultiJson >= 1.0.0 & Rails 3.2.8
+* Fix for body serialization when body doesn't respond to to_json
+* Remove OAuth 1.0 logins from CLI
+
+
+# 0.5.0
+
+* Beta candidate, potential incompatible changes with how requests are processed.
+    * All requests should be made using execute() or execute!()
+    * :api_method in request can no longer be a string
+    * Deprecated ResumableUpload.send_* methods.
+* Reduce memory utilization when uploading large files
+* Automatic refresh of OAuth 2 credentials & retry of request when 401 errors
+  are returned
+* Simplify internal request processing.
+
+# 0.4.7
+
+* Added the ability to convert client secrets to an authorization object
+
+# 0.4.6
+
+* Backwards compatibility for MultiJson
+
+# 0.4.5
+
+* Updated Launchy dependency
+* Updated Faraday dependency
+* Updated Addressable dependency
+* Updated Autoparse dependency
+* Removed Sinatra development dependency
+
+# 0.4.4
+
+* Added batch execution
+* Added service accounts
+* Can now supply authorization on a per-request basis.
+
+# 0.4.3
+
+* Added media upload capabilities
+* Support serializing OAuth credentials to client_secrets.json
+* Fixed OS name/version string on JRuby
+
+# 0.4.2
+
+* Fixed incompatibility with Ruby 1.8.7
+
+# 0.4.1
+
+* Fixed ancestor checking issue when assigning Autoparse identifiers
+* Renamed discovery methods to avoid collisions with some APIs
+* Updated autoparse dependency to avoid JSON bug
+
+# 0.4.0
+
+* Replaced httpadapter gem dependency with faraday
+* Replaced json gem dependency with multi_json
+* Fixed /dev/null issues on Windows
+* Repeated parameters now work
+
+# 0.3.0
+
+* Updated to use v1 of the discovery API
+* Updated to use httpadapter 1.0.0
+* Added OAuth 2 support to the command line tool
+* Renamed some switches in the command line tool
+* Added additional configuration capabilities
+* Fixed a few deprecation warnings from dependencies
+* Added gemspec to source control
+
+# 0.2.0
+
+* Updated to use v1 of the discovery API
+* Updated to use httpadapter 1.0.0
+* Added OAuth 2 support to the command line tool
+* Renamed some switches in the command line tool
+* Added additional configuration capabilities
+
+# 0.1.3
+
+* Added support for manual overrides of the discovery URI
+* Added support for manual overrides of the API base
+* Added support for xoauth_requestor_id
+
+# 0.1.2
+
+* Added support for two-legged OAuth
+* Moved some development dependencies into runtime
+
+# 0.1.1
+
+* Substantial improvements to the command line interface
+
+# 0.1.0
+
+* Initial release
diff --git a/sdk/ruby-google-api-client/CONTRIBUTING.md b/sdk/ruby-google-api-client/CONTRIBUTING.md
new file mode 100644 (file)
index 0000000..1e65911
--- /dev/null
@@ -0,0 +1,32 @@
+# How to become a contributor and submit your own code
+
+## Contributor License Agreements
+
+We'd love to accept your sample apps and patches! Before we can take them, we 
+have to jump a couple of legal hurdles.
+
+Please fill out either the individual or corporate Contributor License Agreement
+(CLA).
+
+  * If you are an individual writing original source code and you're sure you
+    own the intellectual property, then you'll need to sign an [individual CLA]
+    (http://code.google.com/legal/individual-cla-v1.0.html).
+  * If you work for a company that wants to allow you to contribute your work,
+    then you'll need to sign a [corporate CLA]
+    (http://code.google.com/legal/corporate-cla-v1.0.html).
+
+Follow either of the two links above to access the appropriate CLA and
+instructions for how to sign and return it. Once we receive it, we'll be able to
+accept your pull requests.
+
+## Contributing A Patch
+
+1. Submit an issue describing your proposed change to the repo in question.
+1. The repo owner will respond to your issue promptly.
+1. If your proposed change is accepted, and you haven't already done so, sign a
+   Contributor License Agreement (see details above).
+1. Fork the desired repo, develop and test your code changes.
+1. Ensure that your code is clear and comprehensible.
+1. Ensure that your code has an appropriate set of unit tests which all pass.
+1. Submit a pull request.
+
diff --git a/sdk/ruby-google-api-client/Gemfile b/sdk/ruby-google-api-client/Gemfile
new file mode 100644 (file)
index 0000000..9e6d43a
--- /dev/null
@@ -0,0 +1,9 @@
+source 'https://rubygems.org'
+
+gemspec
+
+gem 'jruby-openssl', :platforms => :jruby
+
+if ENV['RAILS_VERSION']
+  gem 'rails', ENV['RAILS_VERSION']
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/LICENSE b/sdk/ruby-google-api-client/LICENSE
new file mode 100644 (file)
index 0000000..ef51da2
--- /dev/null
@@ -0,0 +1,202 @@
+
+                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/sdk/ruby-google-api-client/README.md b/sdk/ruby-google-api-client/README.md
new file mode 100644 (file)
index 0000000..e0b95ad
--- /dev/null
@@ -0,0 +1,7 @@
+# Arvados Google API Client
+
+This is a fork of the google-api-client gem, based on https://github.com/google/google-api-ruby-client version 0.8.6.
+
+It adds compatibility fixes for newer versions of dependencies (Ruby, faraday, etc.) while avoiding the breaking API changes that have been made in the upstream project.
+
+It is entirely focused on the use cases needed by the Arvados Ruby SDK and is not intended or expected to work elsewhere.
diff --git a/sdk/ruby-google-api-client/Rakefile b/sdk/ruby-google-api-client/Rakefile
new file mode 100644 (file)
index 0000000..dca3b09
--- /dev/null
@@ -0,0 +1,41 @@
+# -*- ruby -*-
+lib_dir = File.expand_path('../lib', __FILE__)
+$LOAD_PATH.unshift(lib_dir)
+$LOAD_PATH.uniq!
+
+require 'bundler/gem_tasks'
+require 'rubygems'
+require 'rake'
+
+require File.join(File.dirname(__FILE__), 'lib/google/api_client', 'version')
+
+PKG_DISPLAY_NAME   = 'Google API Client'
+PKG_NAME           = PKG_DISPLAY_NAME.downcase.gsub(/\s/, '-')
+PKG_VERSION        = Google::APIClient::VERSION::STRING
+PKG_FILE_NAME      = "#{PKG_NAME}-#{PKG_VERSION}"
+PKG_HOMEPAGE       = 'https://github.com/google/google-api-ruby-client'
+
+RELEASE_NAME       = "REL #{PKG_VERSION}"
+
+PKG_AUTHOR         = ["Bob Aman", "Steve Bazyl"]
+PKG_AUTHOR_EMAIL   = "sbazyl@google.com"
+PKG_SUMMARY        = 'Package Summary'
+PKG_DESCRIPTION    = <<-TEXT
+The Google API Ruby Client makes it trivial to discover and access supported
+APIs.
+TEXT
+
+list = FileList[
+    'lib/**/*', 'spec/**/*', 'vendor/**/*',
+    'tasks/**/*', 'website/**/*',
+    '[A-Z]*', 'Rakefile'
+].exclude(/[_\.]git$/)
+(open(".gitignore") { |file| file.read }).split("\n").each do |pattern|
+  list.exclude(pattern)
+end
+PKG_FILES = list
+
+task :default => 'spec'
+
+WINDOWS = (RUBY_PLATFORM =~ /mswin|win32|mingw|bccwin|cygwin/) rescue false
+SUDO = WINDOWS ? '' : ('sudo' unless ENV['SUDOLESS'])
diff --git a/sdk/ruby-google-api-client/arvados-google-api-client.gemspec b/sdk/ruby-google-api-client/arvados-google-api-client.gemspec
new file mode 100644 (file)
index 0000000..123180a
--- /dev/null
@@ -0,0 +1,51 @@
+# -*- encoding: utf-8 -*-
+require File.join(File.dirname(__FILE__), 'lib/google/api_client', 'version')
+
+Gem::Specification.new do |s|
+  s.name = "arvados-google-api-client"
+  s.version = Google::APIClient::VERSION::STRING
+
+  s.required_ruby_version = '>= 2.7.0'
+  s.required_rubygems_version = ">= 1.3.5"
+  s.require_paths = ["lib"]
+  s.authors = ["Bob Aman", "Steven Bazyl"]
+  s.license = "Apache-2.0"
+  s.description = "Fork of google-api-client used by Ruby-based Arvados components."
+  s.email = "dev@arvados.org"
+  s.extra_rdoc_files = ["README.md"]
+  s.files = %w(arvados-google-api-client.gemspec Rakefile LICENSE CHANGELOG.md README.md Gemfile)
+  s.files += Dir.glob("lib/**/*.rb")
+  s.files += Dir.glob("lib/cacerts.pem")
+  s.files += Dir.glob("spec/**/*.{rb,opts}")
+  s.files += Dir.glob("vendor/**/*.rb")
+  s.files += Dir.glob("tasks/**/*")
+  s.files += Dir.glob("website/**/*")
+  s.homepage = "https://github.com/arvados/arvados/tree/main/sdk/ruby-google-api-client"
+  s.rdoc_options = ["--main", "README.md"]
+  s.summary = "Fork of google-api-client used by Ruby-based Arvados components."
+
+  s.add_runtime_dependency 'addressable', '~> 2.3'
+  s.add_runtime_dependency 'signet', '~> 0.16.0'
+  # faraday requires Ruby 3.0 starting with 2.9.0. If you install this gem
+  # on Ruby 2.7, the dependency resolver asks you to resolve the conflict
+  # manually. Instead of teaching all our tooling to do that, we prefer to
+  # require the latest version that supports Ruby 2.7 here. This requirement
+  # can be relaxed to '~> 2.0' when we drop support for Ruby 2.7.
+  s.add_runtime_dependency 'faraday', '~> 2.8.0'
+  s.add_runtime_dependency 'faraday-multipart', '~> 1.0'
+  s.add_runtime_dependency 'faraday-gzip', '~> 2.0'
+  s.add_runtime_dependency 'googleauth', '~> 1.0'
+  s.add_runtime_dependency 'multi_json', '~> 1.10'
+  s.add_runtime_dependency 'autoparse', '~> 0.3'
+  s.add_runtime_dependency 'extlib', '~> 0.9'
+  s.add_runtime_dependency 'launchy', '~> 2.4'
+  s.add_runtime_dependency 'retriable', '~> 1.4'
+  s.add_runtime_dependency 'activesupport', '>= 3.2', '< 8.0'
+
+  s.add_development_dependency 'rake', '~> 10.0'
+  s.add_development_dependency 'yard', '~> 0.8'
+  s.add_development_dependency 'rspec', '~> 3.1'
+  s.add_development_dependency 'kramdown', '~> 1.5'
+  s.add_development_dependency 'simplecov', '~> 0.9.2'
+  s.add_development_dependency 'coveralls', '~> 0.7.11'
+end
diff --git a/sdk/ruby-google-api-client/lib/cacerts.pem b/sdk/ruby-google-api-client/lib/cacerts.pem
new file mode 100644 (file)
index 0000000..70990f1
--- /dev/null
@@ -0,0 +1,2183 @@
+# Issuer: CN=GTE CyberTrust Global Root O=GTE Corporation OU=GTE CyberTrust Solutions, Inc.
+# Subject: CN=GTE CyberTrust Global Root O=GTE Corporation OU=GTE CyberTrust Solutions, Inc.
+# Label: "GTE CyberTrust Global Root"
+# Serial: 421
+# MD5 Fingerprint: ca:3d:d3:68:f1:03:5c:d0:32:fa:b8:2b:59:e8:5a:db
+# SHA1 Fingerprint: 97:81:79:50:d8:1c:96:70:cc:34:d8:09:cf:79:44:31:36:7e:f4:74
+# SHA256 Fingerprint: a5:31:25:18:8d:21:10:aa:96:4b:02:c7:b7:c6:da:32:03:17:08:94:e5:fb:71:ff:fb:66:67:d5:e6:81:0a:36
+-----BEGIN CERTIFICATE-----
+MIICWjCCAcMCAgGlMA0GCSqGSIb3DQEBBAUAMHUxCzAJBgNVBAYTAlVTMRgwFgYD
+VQQKEw9HVEUgQ29ycG9yYXRpb24xJzAlBgNVBAsTHkdURSBDeWJlclRydXN0IFNv
+bHV0aW9ucywgSW5jLjEjMCEGA1UEAxMaR1RFIEN5YmVyVHJ1c3QgR2xvYmFsIFJv
+b3QwHhcNOTgwODEzMDAyOTAwWhcNMTgwODEzMjM1OTAwWjB1MQswCQYDVQQGEwJV
+UzEYMBYGA1UEChMPR1RFIENvcnBvcmF0aW9uMScwJQYDVQQLEx5HVEUgQ3liZXJU
+cnVzdCBTb2x1dGlvbnMsIEluYy4xIzAhBgNVBAMTGkdURSBDeWJlclRydXN0IEds
+b2JhbCBSb290MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCVD6C28FCc6HrH
+iM3dFw4usJTQGz0O9pTAipTHBsiQl8i4ZBp6fmw8U+E3KHNgf7KXUwefU/ltWJTS
+r41tiGeA5u2ylc9yMcqlHHK6XALnZELn+aks1joNrI1CqiQBOeacPwGFVw1Yh0X4
+04Wqk2kmhXBIgD8SFcd5tB8FLztimQIDAQABMA0GCSqGSIb3DQEBBAUAA4GBAG3r
+GwnpXtlR22ciYaQqPEh346B8pt5zohQDhT37qw4wxYMWM4ETCJ57NE7fQMh017l9
+3PR2VX2bY1QY6fDq81yx2YtCHrnAlU66+tXifPVoYb+O7AWXX1uw16OFNMQkpw0P
+lZPvy5TYnh+dXIVtx6quTx8itc2VrbqnzPmrC3p/
+-----END CERTIFICATE-----
+
+# Issuer: CN=Thawte Server CA O=Thawte Consulting cc OU=Certification Services Division
+# Subject: CN=Thawte Server CA O=Thawte Consulting cc OU=Certification Services Division
+# Label: "Thawte Server CA"
+# Serial: 1
+# MD5 Fingerprint: c5:70:c4:a2:ed:53:78:0c:c8:10:53:81:64:cb:d0:1d
+# SHA1 Fingerprint: 23:e5:94:94:51:95:f2:41:48:03:b4:d5:64:d2:a3:a3:f5:d8:8b:8c
+# SHA256 Fingerprint: b4:41:0b:73:e2:e6:ea:ca:47:fb:c4:2f:8f:a4:01:8a:f4:38:1d:c5:4c:fa:a8:44:50:46:1e:ed:09:45:4d:e9
+-----BEGIN CERTIFICATE-----
+MIIDEzCCAnygAwIBAgIBATANBgkqhkiG9w0BAQQFADCBxDELMAkGA1UEBhMCWkEx
+FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYD
+VQQKExRUaGF3dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlv
+biBTZXJ2aWNlcyBEaXZpc2lvbjEZMBcGA1UEAxMQVGhhd3RlIFNlcnZlciBDQTEm
+MCQGCSqGSIb3DQEJARYXc2VydmVyLWNlcnRzQHRoYXd0ZS5jb20wHhcNOTYwODAx
+MDAwMDAwWhcNMjAxMjMxMjM1OTU5WjCBxDELMAkGA1UEBhMCWkExFTATBgNVBAgT
+DFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYDVQQKExRUaGF3
+dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlvbiBTZXJ2aWNl
+cyBEaXZpc2lvbjEZMBcGA1UEAxMQVGhhd3RlIFNlcnZlciBDQTEmMCQGCSqGSIb3
+DQEJARYXc2VydmVyLWNlcnRzQHRoYXd0ZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQAD
+gY0AMIGJAoGBANOkUG7I/1Zr5s9dtuoMaHVHoqrC2oQl/Kj0R1HahbUgdJSGHg91
+yekIYfUGbTBuFRkC6VLAYttNmZ7iagxEOM3+vuNkCXDF/rFrKbYvScg71CcEJRCX
+L+eQbcAoQpnXTEPew/UhbVSfXcNY4cDk2VuwuNy0e982OsK1ZiIS1ocNAgMBAAGj
+EzARMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAB/pMaVz7lcxG
+7oWDTSEwjsrZqG9JGubaUeNgcGyEYRGhGshIPllDfU+VPaGLtwtimHp1it2ITk6e
+QNuozDJ0uW8NxuOzRAvZim+aKZuZGCg70eNAKJpaPNW15yAbi8qkq43pUdniTCxZ
+qdq5snUb9kLy78fyGPmJvKP/iiMucEc=
+-----END CERTIFICATE-----
+
+# Issuer: CN=Thawte Premium Server CA O=Thawte Consulting cc OU=Certification Services Division
+# Subject: CN=Thawte Premium Server CA O=Thawte Consulting cc OU=Certification Services Division
+# Label: "Thawte Premium Server CA"
+# Serial: 1
+# MD5 Fingerprint: 06:9f:69:79:16:66:90:02:1b:8c:8c:a2:c3:07:6f:3a
+# SHA1 Fingerprint: 62:7f:8d:78:27:65:63:99:d2:7d:7f:90:44:c9:fe:b3:f3:3e:fa:9a
+# SHA256 Fingerprint: ab:70:36:36:5c:71:54:aa:29:c2:c2:9f:5d:41:91:16:3b:16:2a:22:25:01:13:57:d5:6d:07:ff:a7:bc:1f:72
+-----BEGIN CERTIFICATE-----
+MIIDJzCCApCgAwIBAgIBATANBgkqhkiG9w0BAQQFADCBzjELMAkGA1UEBhMCWkEx
+FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYD
+VQQKExRUaGF3dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlv
+biBTZXJ2aWNlcyBEaXZpc2lvbjEhMB8GA1UEAxMYVGhhd3RlIFByZW1pdW0gU2Vy
+dmVyIENBMSgwJgYJKoZIhvcNAQkBFhlwcmVtaXVtLXNlcnZlckB0aGF3dGUuY29t
+MB4XDTk2MDgwMTAwMDAwMFoXDTIwMTIzMTIzNTk1OVowgc4xCzAJBgNVBAYTAlpB
+MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxEjAQBgNVBAcTCUNhcGUgVG93bjEdMBsG
+A1UEChMUVGhhd3RlIENvbnN1bHRpbmcgY2MxKDAmBgNVBAsTH0NlcnRpZmljYXRp
+b24gU2VydmljZXMgRGl2aXNpb24xITAfBgNVBAMTGFRoYXd0ZSBQcmVtaXVtIFNl
+cnZlciBDQTEoMCYGCSqGSIb3DQEJARYZcHJlbWl1bS1zZXJ2ZXJAdGhhd3RlLmNv
+bTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA0jY2aovXwlue2oFBYo847kkE
+VdbQ7xwblRZH7xhINTpS9CtqBo87L+pW46+GjZ4X9560ZXUCTe/LCaIhUdib0GfQ
+ug2SBhRz1JPLlyoAnFxODLz6FVL88kRu2hFKbgifLy3j+ao6hnO2RlNYyIkFvYMR
+uHM/qgeN9EJN50CdHDcCAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG
+9w0BAQQFAAOBgQAmSCwWwlj66BZ0DKqqX1Q/8tfJeGBeXm43YyJ3Nn6yF8Q0ufUI
+hfzJATj/Tb7yFkJD57taRvvBxhEf8UqwKEbJw8RCfbz6q1lu1bdRiBHjpIUZa4JM
+pAwSremkrj/xw0llmozFyD4lt5SZu5IycQfwhl7tUCemDaYj+bvLpgcUQg==
+-----END CERTIFICATE-----
+
+# Issuer: O=Equifax OU=Equifax Secure Certificate Authority
+# Subject: O=Equifax OU=Equifax Secure Certificate Authority
+# Label: "Equifax Secure CA"
+# Serial: 903804111
+# MD5 Fingerprint: 67:cb:9d:c0:13:24:8a:82:9b:b2:17:1e:d1:1b:ec:d4
+# SHA1 Fingerprint: d2:32:09:ad:23:d3:14:23:21:74:e4:0d:7f:9d:62:13:97:86:63:3a
+# SHA256 Fingerprint: 08:29:7a:40:47:db:a2:36:80:c7:31:db:6e:31:76:53:ca:78:48:e1:be:bd:3a:0b:01:79:a7:07:f9:2c:f1:78
+-----BEGIN CERTIFICATE-----
+MIIDIDCCAomgAwIBAgIENd70zzANBgkqhkiG9w0BAQUFADBOMQswCQYDVQQGEwJV
+UzEQMA4GA1UEChMHRXF1aWZheDEtMCsGA1UECxMkRXF1aWZheCBTZWN1cmUgQ2Vy
+dGlmaWNhdGUgQXV0aG9yaXR5MB4XDTk4MDgyMjE2NDE1MVoXDTE4MDgyMjE2NDE1
+MVowTjELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0VxdWlmYXgxLTArBgNVBAsTJEVx
+dWlmYXggU2VjdXJlIENlcnRpZmljYXRlIEF1dGhvcml0eTCBnzANBgkqhkiG9w0B
+AQEFAAOBjQAwgYkCgYEAwV2xWGcIYu6gmi0fCG2RFGiYCh7+2gRvE4RiIcPRfM6f
+BeC4AfBONOziipUEZKzxa1NfBbPLZ4C/QgKO/t0BCezhABRP/PvwDN1Dulsr4R+A
+cJkVV5MW8Q+XarfCaCMczE1ZMKxRHjuvK9buY0V7xdlfUNLjUA86iOe/FP3gx7kC
+AwEAAaOCAQkwggEFMHAGA1UdHwRpMGcwZaBjoGGkXzBdMQswCQYDVQQGEwJVUzEQ
+MA4GA1UEChMHRXF1aWZheDEtMCsGA1UECxMkRXF1aWZheCBTZWN1cmUgQ2VydGlm
+aWNhdGUgQXV0aG9yaXR5MQ0wCwYDVQQDEwRDUkwxMBoGA1UdEAQTMBGBDzIwMTgw
+ODIyMTY0MTUxWjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUSOZo+SvSspXXR9gj
+IBBPM5iQn9QwHQYDVR0OBBYEFEjmaPkr0rKV10fYIyAQTzOYkJ/UMAwGA1UdEwQF
+MAMBAf8wGgYJKoZIhvZ9B0EABA0wCxsFVjMuMGMDAgbAMA0GCSqGSIb3DQEBBQUA
+A4GBAFjOKer89961zgK5F7WF0bnj4JXMJTENAKaSbn+2kmOeUJXRmm/kEd5jhW6Y
+7qj/WsjTVbJmcVfewCHrPSqnI0kBBIZCe/zuf6IWUrVnZ9NA2zsmWLIodz2uFHdh
+1voqZiegDfqnc1zqcPGUIWVEX/r87yloqaKHee9570+sB3c4
+-----END CERTIFICATE-----
+
+# Issuer: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
+# Subject: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
+# Label: "Verisign Class 3 Public Primary Certification Authority"
+# Serial: 149843929435818692848040365716851702463
+# MD5 Fingerprint: 10:fc:63:5d:f6:26:3e:0d:f3:25:be:5f:79:cd:67:67
+# SHA1 Fingerprint: 74:2c:31:92:e6:07:e4:24:eb:45:49:54:2b:e1:bb:c5:3e:61:74:e2
+# SHA256 Fingerprint: e7:68:56:34:ef:ac:f6:9a:ce:93:9a:6b:25:5b:7b:4f:ab:ef:42:93:5b:50:a2:65:ac:b5:cb:60:27:e4:4e:70
+-----BEGIN CERTIFICATE-----
+MIICPDCCAaUCEHC65B0Q2Sk0tjjKewPMur8wDQYJKoZIhvcNAQECBQAwXzELMAkG
+A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz
+cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2
+MDEyOTAwMDAwMFoXDTI4MDgwMTIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV
+BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt
+YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN
+ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE
+BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is
+I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G
+CSqGSIb3DQEBAgUAA4GBALtMEivPLCYATxQT3ab7/AoRhIzzKBxnki98tsX63/Do
+lbwdj2wsqFHMc9ikwFPwTtYmwHYBV4GSXiHx0bH/59AhWM1pF+NEHJwZRDmJXNyc
+AA9WjQKZ7aKQRUzkuxCkPfAyAw7xzvjoyVGM5mKf5p/AfbdynMk2OmufTqj/ZA1k
+-----END CERTIFICATE-----
+
+# Issuer: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority - G2/(c) 1998 VeriSign, Inc. - For authorized use only/VeriSign Trust Network
+# Subject: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority - G2/(c) 1998 VeriSign, Inc. - For authorized use only/VeriSign Trust Network
+# Label: "Verisign Class 3 Public Primary Certification Authority - G2"
+# Serial: 167285380242319648451154478808036881606
+# MD5 Fingerprint: a2:33:9b:4c:74:78:73:d4:6c:e7:c1:f3:8d:cb:5c:e9
+# SHA1 Fingerprint: 85:37:1c:a6:e5:50:14:3d:ce:28:03:47:1b:de:3a:09:e8:f8:77:0f
+# SHA256 Fingerprint: 83:ce:3c:12:29:68:8a:59:3d:48:5f:81:97:3c:0f:91:95:43:1e:da:37:cc:5e:36:43:0e:79:c7:a8:88:63:8b
+-----BEGIN CERTIFICATE-----
+MIIDAjCCAmsCEH3Z/gfPqB63EHln+6eJNMYwDQYJKoZIhvcNAQEFBQAwgcExCzAJ
+BgNVBAYTAlVTMRcwFQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xh
+c3MgMyBQdWJsaWMgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcy
+MTowOAYDVQQLEzEoYykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3Jp
+emVkIHVzZSBvbmx5MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMB4X
+DTk4MDUxODAwMDAwMFoXDTI4MDgwMTIzNTk1OVowgcExCzAJBgNVBAYTAlVTMRcw
+FQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xhc3MgMyBQdWJsaWMg
+UHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcyMTowOAYDVQQLEzEo
+YykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5
+MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMIGfMA0GCSqGSIb3DQEB
+AQUAA4GNADCBiQKBgQDMXtERXVxp0KvTuWpMmR9ZmDCOFoUgRm1HP9SFIIThbbP4
+pO0M8RcPO/mn+SXXwc+EY/J8Y8+iR/LGWzOOZEAEaMGAuWQcRXfH2G71lSk8UOg0
+13gfqLptQ5GVj0VXXn7F+8qkBOvqlzdUMG+7AUcyM83cV5tkaWH4mx0ciU9cZwID
+AQABMA0GCSqGSIb3DQEBBQUAA4GBAFFNzb5cy5gZnBWyATl4Lk0PZ3BwmcYQWpSk
+U01UbSuvDV1Ai2TT1+7eVmGSX6bEHRBhNtMsJzzoKQm5EWR0zLVznxxIqbxhAe7i
+F6YM40AIOw7n60RzKprxaZLvcRTDOaxxp5EJb+RxBrO6WVcmeQD2+A2iMzAo1KpY
+oJ2daZH9
+-----END CERTIFICATE-----
+
+# Issuer: CN=GlobalSign Root CA O=GlobalSign nv-sa OU=Root CA
+# Subject: CN=GlobalSign Root CA O=GlobalSign nv-sa OU=Root CA
+# Label: "GlobalSign Root CA"
+# Serial: 4835703278459707669005204
+# MD5 Fingerprint: 3e:45:52:15:09:51:92:e1:b7:5d:37:9f:b1:87:29:8a
+# SHA1 Fingerprint: b1:bc:96:8b:d4:f4:9d:62:2a:a8:9a:81:f2:15:01:52:a4:1d:82:9c
+# SHA256 Fingerprint: eb:d4:10:40:e4:bb:3e:c7:42:c9:e3:81:d3:1e:f2:a4:1a:48:b6:68:5c:96:e7:ce:f3:c1:df:6c:d4:33:1c:99
+-----BEGIN CERTIFICATE-----
+MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG
+A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv
+b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw
+MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i
+YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT
+aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ
+jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp
+xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp
+1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG
+snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ
+U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8
+9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E
+BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B
+AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz
+yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE
+38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP
+AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad
+DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME
+HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==
+-----END CERTIFICATE-----
+
+# Issuer: CN=GlobalSign O=GlobalSign OU=GlobalSign Root CA - R2
+# Subject: CN=GlobalSign O=GlobalSign OU=GlobalSign Root CA - R2
+# Label: "GlobalSign Root CA - R2"
+# Serial: 4835703278459682885658125
+# MD5 Fingerprint: 94:14:77:7e:3e:5e:fd:8f:30:bd:41:b0:cf:e7:d0:30
+# SHA1 Fingerprint: 75:e0:ab:b6:13:85:12:27:1c:04:f8:5f:dd:de:38:e4:b7:24:2e:fe
+# SHA256 Fingerprint: ca:42:dd:41:74:5f:d0:b8:1e:b9:02:36:2c:f9:d8:bf:71:9d:a1:bd:1b:1e:fc:94:6f:5b:4c:99:f4:2c:1b:9e
+-----BEGIN CERTIFICATE-----
+MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
+A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
+Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
+MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
+A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
+hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
+v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
+eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
+tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
+C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
+zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
+mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
+V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
+bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
+3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
+J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
+291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
+ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
+AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
+TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
+-----END CERTIFICATE-----
+
+# Issuer: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 1 Policy Validation Authority
+# Subject: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 1 Policy Validation Authority
+# Label: "ValiCert Class 1 VA"
+# Serial: 1
+# MD5 Fingerprint: 65:58:ab:15:ad:57:6c:1e:a8:a7:b5:69:ac:bf:ff:eb
+# SHA1 Fingerprint: e5:df:74:3c:b6:01:c4:9b:98:43:dc:ab:8c:e8:6a:81:10:9f:e4:8e
+# SHA256 Fingerprint: f4:c1:49:55:1a:30:13:a3:5b:c7:bf:fe:17:a7:f3:44:9b:c1:ab:5b:5a:0a:e7:4b:06:c2:3b:90:00:4c:01:04
+-----BEGIN CERTIFICATE-----
+MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0
+IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAz
+BgNVBAsTLFZhbGlDZXJ0IENsYXNzIDEgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9y
+aXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG
+9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNTIyMjM0OFoXDTE5MDYy
+NTIyMjM0OFowgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29y
+azEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENs
+YXNzIDEgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRw
+Oi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNl
+cnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDYWYJ6ibiWuqYvaG9Y
+LqdUHAZu9OqNSLwxlBfw8068srg1knaw0KWlAdcAAxIiGQj4/xEjm84H9b9pGib+
+TunRf50sQB1ZaG6m+FiwnRqP0z/x3BkGgagO4DrdyFNFCQbmD3DD+kCmDuJWBQ8Y
+TfwggtFzVXSNdnKgHZ0dwN0/cQIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAFBoPUn0
+LBwGlN+VYH+Wexf+T3GtZMjdd9LvWVXoP+iOBSoh8gfStadS/pyxtuJbdxdA6nLW
+I8sogTLDAHkY7FkXicnGah5xyf23dKUlRWnFSKsZ4UWKJWsZ7uW7EvV/96aNUcPw
+nXS3qT6gpf+2SQMT2iLM7XGCK5nPOrf1LXLI
+-----END CERTIFICATE-----
+
+# Issuer: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 2 Policy Validation Authority
+# Subject: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 2 Policy Validation Authority
+# Label: "ValiCert Class 2 VA"
+# Serial: 1
+# MD5 Fingerprint: a9:23:75:9b:ba:49:36:6e:31:c2:db:f2:e7:66:ba:87
+# SHA1 Fingerprint: 31:7a:2a:d0:7f:2b:33:5e:f5:a1:c3:4e:4b:57:e8:b7:d8:f1:fc:a6
+# SHA256 Fingerprint: 58:d0:17:27:9c:d4:dc:63:ab:dd:b1:96:a6:c9:90:6c:30:c4:e0:87:83:ea:e8:c1:60:99:54:d6:93:55:59:6b
+-----BEGIN CERTIFICATE-----
+MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0
+IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAz
+BgNVBAsTLFZhbGlDZXJ0IENsYXNzIDIgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9y
+aXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG
+9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNjAwMTk1NFoXDTE5MDYy
+NjAwMTk1NFowgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29y
+azEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENs
+YXNzIDIgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRw
+Oi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNl
+cnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOOnHK5avIWZJV16vY
+dA757tn2VUdZZUcOBVXc65g2PFxTXdMwzzjsvUGJ7SVCCSRrCl6zfN1SLUzm1NZ9
+WlmpZdRJEy0kTRxQb7XBhVQ7/nHk01xC+YDgkRoKWzk2Z/M/VXwbP7RfZHM047QS
+v4dk+NoS/zcnwbNDu+97bi5p9wIDAQABMA0GCSqGSIb3DQEBBQUAA4GBADt/UG9v
+UJSZSWI4OB9L+KXIPqeCgfYrx+jFzug6EILLGACOTb2oWH+heQC1u+mNr0HZDzTu
+IYEZoDJJKPTEjlbVUjP9UNV+mWwD5MlM/Mtsq2azSiGM5bUMMj4QssxsodyamEwC
+W/POuZ6lcg5Ktz885hZo+L7tdEy8W9ViH0Pd
+-----END CERTIFICATE-----
+
+# Issuer: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 3 Policy Validation Authority
+# Subject: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 3 Policy Validation Authority
+# Label: "RSA Root Certificate 1"
+# Serial: 1
+# MD5 Fingerprint: a2:6f:53:b7:ee:40:db:4a:68:e7:fa:18:d9:10:4b:72
+# SHA1 Fingerprint: 69:bd:8c:f4:9c:d3:00:fb:59:2e:17:93:ca:55:6a:f3:ec:aa:35:fb
+# SHA256 Fingerprint: bc:23:f9:8a:31:3c:b9:2d:e3:bb:fc:3a:5a:9f:44:61:ac:39:49:4c:4a:e1:5a:9e:9d:f1:31:e9:9b:73:01:9a
+-----BEGIN CERTIFICATE-----
+MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0
+IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAz
+BgNVBAsTLFZhbGlDZXJ0IENsYXNzIDMgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9y
+aXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG
+9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNjAwMjIzM1oXDTE5MDYy
+NjAwMjIzM1owgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29y
+azEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENs
+YXNzIDMgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRw
+Oi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNl
+cnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDjmFGWHOjVsQaBalfD
+cnWTq8+epvzzFlLWLU2fNUSoLgRNB0mKOCn1dzfnt6td3zZxFJmP3MKS8edgkpfs
+2Ejcv8ECIMYkpChMMFp2bbFc893enhBxoYjHW5tBbcqwuI4V7q0zK89HBFx1cQqY
+JJgpp0lZpd34t0NiYfPT4tBVPwIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAFa7AliE
+Zwgs3x/be0kz9dNnnfS0ChCzycUs4pJqcXgn8nCDQtM+z6lU9PHYkhaM0QTLS6vJ
+n0WuPIqpsHEzXcjFV9+vqDWzf4mH6eglkrh/hXqu1rweN1gqZ8mRzyqBPu3GOd/A
+PhmcGcwTTYJBtYze4D1gCCAPRX5ron+jjBXu
+-----END CERTIFICATE-----
+
+# Issuer: CN=VeriSign Class 3 Public Primary Certification Authority - G3 O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 1999 VeriSign, Inc. - For authorized use only
+# Subject: CN=VeriSign Class 3 Public Primary Certification Authority - G3 O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 1999 VeriSign, Inc. - For authorized use only
+# Label: "Verisign Class 3 Public Primary Certification Authority - G3"
+# Serial: 206684696279472310254277870180966723415
+# MD5 Fingerprint: cd:68:b6:a7:c7:c4:ce:75:e0:1d:4f:57:44:61:92:09
+# SHA1 Fingerprint: 13:2d:0d:45:53:4b:69:97:cd:b2:d5:c3:39:e2:55:76:60:9b:5c:c6
+# SHA256 Fingerprint: eb:04:cf:5e:b1:f3:9a:fa:76:2f:2b:b1:20:f2:96:cb:a5:20:c1:b9:7d:b1:58:95:65:b8:1c:b9:a1:7b:72:44
+-----BEGIN CERTIFICATE-----
+MIIEGjCCAwICEQCbfgZJoz5iudXukEhxKe9XMA0GCSqGSIb3DQEBBQUAMIHKMQsw
+CQYDVQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZl
+cmlTaWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWdu
+LCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlT
+aWduIENsYXNzIDMgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3Jp
+dHkgLSBHMzAeFw05OTEwMDEwMDAwMDBaFw0zNjA3MTYyMzU5NTlaMIHKMQswCQYD
+VQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlT
+aWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWduLCBJ
+bmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlTaWdu
+IENsYXNzIDMgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg
+LSBHMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMu6nFL8eB8aHm8b
+N3O9+MlrlBIwT/A2R/XQkQr1F8ilYcEWQE37imGQ5XYgwREGfassbqb1EUGO+i2t
+KmFZpGcmTNDovFJbcCAEWNF6yaRpvIMXZK0Fi7zQWM6NjPXr8EJJC52XJ2cybuGu
+kxUccLwgTS8Y3pKI6GyFVxEa6X7jJhFUokWWVYPKMIno3Nij7SqAP395ZVc+FSBm
+CC+Vk7+qRy+oRpfwEuL+wgorUeZ25rdGt+INpsyow0xZVYnm6FNcHOqd8GIWC6fJ
+Xwzw3sJ2zq/3avL6QaaiMxTJ5Xpj055iN9WFZZ4O5lMkdBteHRJTW8cs54NJOxWu
+imi5V5cCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAERSWwauSCPc/L8my/uRan2Te
+2yFPhpk0djZX3dAVL8WtfxUfN2JzPtTnX84XA9s1+ivbrmAJXx5fj267Cz3qWhMe
+DGBvtcC1IyIuBwvLqXTLR7sdwdela8wv0kL9Sd2nic9TutoAWii/gt/4uhMdUIaC
+/Y4wjylGsB49Ndo4YhYYSq3mtlFs3q9i6wHQHiT+eo8SGhJouPtmmRQURVyu565p
+F4ErWjfJXir0xuKhXFSbplQAz/DxwceYMBo7Nhbbo27q/a2ywtrvAkcTisDxszGt
+TxzhT5yvDwyd93gN2PQ1VoDat20Xj50egWTh/sVFuq1ruQp6Tk9LhO5L8X3dEQ==
+-----END CERTIFICATE-----
+
+# Issuer: CN=VeriSign Class 4 Public Primary Certification Authority - G3 O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 1999 VeriSign, Inc. - For authorized use only
+# Subject: CN=VeriSign Class 4 Public Primary Certification Authority - G3 O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 1999 VeriSign, Inc. - For authorized use only
+# Label: "Verisign Class 4 Public Primary Certification Authority - G3"
+# Serial: 314531972711909413743075096039378935511
+# MD5 Fingerprint: db:c8:f2:27:2e:b1:ea:6a:29:23:5d:fe:56:3e:33:df
+# SHA1 Fingerprint: c8:ec:8c:87:92:69:cb:4b:ab:39:e9:8d:7e:57:67:f3:14:95:73:9d
+# SHA256 Fingerprint: e3:89:36:0d:0f:db:ae:b3:d2:50:58:4b:47:30:31:4e:22:2f:39:c1:56:a0:20:14:4e:8d:96:05:61:79:15:06
+-----BEGIN CERTIFICATE-----
+MIIEGjCCAwICEQDsoKeLbnVqAc/EfMwvlF7XMA0GCSqGSIb3DQEBBQUAMIHKMQsw
+CQYDVQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZl
+cmlTaWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWdu
+LCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlT
+aWduIENsYXNzIDQgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3Jp
+dHkgLSBHMzAeFw05OTEwMDEwMDAwMDBaFw0zNjA3MTYyMzU5NTlaMIHKMQswCQYD
+VQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlT
+aWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWduLCBJ
+bmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlTaWdu
+IENsYXNzIDQgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg
+LSBHMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK3LpRFpxlmr8Y+1
+GQ9Wzsy1HyDkniYlS+BzZYlZ3tCD5PUPtbut8XzoIfzk6AzufEUiGXaStBO3IFsJ
++mGuqPKljYXCKtbeZjbSmwL0qJJgfJxptI8kHtCGUvYynEFYHiK9zUVilQhu0Gbd
+U6LM8BDcVHOLBKFGMzNcF0C5nk3T875Vg+ixiY5afJqWIpA7iCXy0lOIAgwLePLm
+NxdLMEYH5IBtptiWLugs+BGzOA1mppvqySNb247i8xOOGlktqgLw7KSHZtzBP/XY
+ufTsgsbSPZUd5cBPhMnZo0QoBmrXRazwa2rvTl/4EYIeOGM0ZlDUPpNz+jDDZq3/
+ky2X7wMCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAj/ola09b5KROJ1WrIhVZPMq1
+CtRK26vdoV9TxaBXOcLORyu+OshWv8LZJxA6sQU8wHcxuzrTBXttmhwwjIDLk5Mq
+g6sFUYICABFna/OIYUdfA5PVWw3g8dShMjWFsjrbsIKr0csKvE+MW8VLADsfKoKm
+fjaF3H48ZwC15DtS4KjrXRX5xm3wrR0OhbepmnMUWluPQSjA1egtTaRezarZ7c7c
+2NU8Qh0XwRJdRTjDOPP8hS6DRkiy1yBfkjaP53kPmF6Z6PDQpLv1U70qzlmwr25/
+bLvSHgCwIe34QWKCudiyxLtGUPMxxY8BqHTr9Xgn2uf3ZkPznoM+IKrDNWCRzg==
+-----END CERTIFICATE-----
+
+# Issuer: CN=Entrust.net Secure Server Certification Authority O=Entrust.net OU=www.entrust.net/CPS incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
+# Subject: CN=Entrust.net Secure Server Certification Authority O=Entrust.net OU=www.entrust.net/CPS incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
+# Label: "Entrust.net Secure Server CA"
+# Serial: 927650371
+# MD5 Fingerprint: df:f2:80:73:cc:f1:e6:61:73:fc:f5:42:e9:c5:7c:ee
+# SHA1 Fingerprint: 99:a6:9b:e6:1a:fe:88:6b:4d:2b:82:00:7c:b8:54:fc:31:7e:15:39
+# SHA256 Fingerprint: 62:f2:40:27:8c:56:4c:4d:d8:bf:7d:9d:4f:6f:36:6e:a8:94:d2:2f:5f:34:d9:89:a9:83:ac:ec:2f:ff:ed:50
+-----BEGIN CERTIFICATE-----
+MIIE2DCCBEGgAwIBAgIEN0rSQzANBgkqhkiG9w0BAQUFADCBwzELMAkGA1UEBhMC
+VVMxFDASBgNVBAoTC0VudHJ1c3QubmV0MTswOQYDVQQLEzJ3d3cuZW50cnVzdC5u
+ZXQvQ1BTIGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxpYWIuKTElMCMGA1UECxMc
+KGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDE6MDgGA1UEAxMxRW50cnVzdC5u
+ZXQgU2VjdXJlIFNlcnZlciBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw05OTA1
+MjUxNjA5NDBaFw0xOTA1MjUxNjM5NDBaMIHDMQswCQYDVQQGEwJVUzEUMBIGA1UE
+ChMLRW50cnVzdC5uZXQxOzA5BgNVBAsTMnd3dy5lbnRydXN0Lm5ldC9DUFMgaW5j
+b3JwLiBieSByZWYuIChsaW1pdHMgbGlhYi4pMSUwIwYDVQQLExwoYykgMTk5OSBF
+bnRydXN0Lm5ldCBMaW1pdGVkMTowOAYDVQQDEzFFbnRydXN0Lm5ldCBTZWN1cmUg
+U2VydmVyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGdMA0GCSqGSIb3DQEBAQUA
+A4GLADCBhwKBgQDNKIM0VBuJ8w+vN5Ex/68xYMmo6LIQaO2f55M28Qpku0f1BBc/
+I0dNxScZgSYMVHINiC3ZH5oSn7yzcdOAGT9HZnuMNSjSuQrfJNqc1lB5gXpa0zf3
+wkrYKZImZNHkmGw6AIr1NJtl+O3jEP/9uElY3KDegjlrgbEWGWG5VLbmQwIBA6OC
+AdcwggHTMBEGCWCGSAGG+EIBAQQEAwIABzCCARkGA1UdHwSCARAwggEMMIHeoIHb
+oIHYpIHVMIHSMQswCQYDVQQGEwJVUzEUMBIGA1UEChMLRW50cnVzdC5uZXQxOzA5
+BgNVBAsTMnd3dy5lbnRydXN0Lm5ldC9DUFMgaW5jb3JwLiBieSByZWYuIChsaW1p
+dHMgbGlhYi4pMSUwIwYDVQQLExwoYykgMTk5OSBFbnRydXN0Lm5ldCBMaW1pdGVk
+MTowOAYDVQQDEzFFbnRydXN0Lm5ldCBTZWN1cmUgU2VydmVyIENlcnRpZmljYXRp
+b24gQXV0aG9yaXR5MQ0wCwYDVQQDEwRDUkwxMCmgJ6AlhiNodHRwOi8vd3d3LmVu
+dHJ1c3QubmV0L0NSTC9uZXQxLmNybDArBgNVHRAEJDAigA8xOTk5MDUyNTE2MDk0
+MFqBDzIwMTkwNTI1MTYwOTQwWjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAU8Bdi
+E1U9s/8KAGv7UISX8+1i0BowHQYDVR0OBBYEFPAXYhNVPbP/CgBr+1CEl/PtYtAa
+MAwGA1UdEwQFMAMBAf8wGQYJKoZIhvZ9B0EABAwwChsEVjQuMAMCBJAwDQYJKoZI
+hvcNAQEFBQADgYEAkNwwAvpkdMKnCqV8IY00F6j7Rw7/JXyNEwr75Ji174z4xRAN
+95K+8cPV1ZVqBLssziY2ZcgxxufuP+NXdYR6Ee9GTxj005i7qIcyunL2POI9n9cd
+2cNgQ4xYDiKWL2KjLB+6rQXvqzJ4h6BUcxm1XAX5Uj5tLUUL9wqT6u0G+bI=
+-----END CERTIFICATE-----
+
+# Issuer: CN=Entrust.net Certification Authority (2048) O=Entrust.net OU=www.entrust.net/CPS_2048 incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
+# Subject: CN=Entrust.net Certification Authority (2048) O=Entrust.net OU=www.entrust.net/CPS_2048 incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
+# Label: "Entrust.net Premium 2048 Secure Server CA"
+# Serial: 946059622
+# MD5 Fingerprint: ba:21:ea:20:d6:dd:db:8f:c1:57:8b:40:ad:a1:fc:fc
+# SHA1 Fingerprint: 80:1d:62:d0:7b:44:9d:5c:5c:03:5c:98:ea:61:fa:44:3c:2a:58:fe
+# SHA256 Fingerprint: d1:c3:39:ea:27:84:eb:87:0f:93:4f:c5:63:4e:4a:a9:ad:55:05:01:64:01:f2:64:65:d3:7a:57:46:63:35:9f
+-----BEGIN CERTIFICATE-----
+MIIEXDCCA0SgAwIBAgIEOGO5ZjANBgkqhkiG9w0BAQUFADCBtDEUMBIGA1UEChML
+RW50cnVzdC5uZXQxQDA+BgNVBAsUN3d3dy5lbnRydXN0Lm5ldC9DUFNfMjA0OCBp
+bmNvcnAuIGJ5IHJlZi4gKGxpbWl0cyBsaWFiLikxJTAjBgNVBAsTHChjKSAxOTk5
+IEVudHJ1c3QubmV0IExpbWl0ZWQxMzAxBgNVBAMTKkVudHJ1c3QubmV0IENlcnRp
+ZmljYXRpb24gQXV0aG9yaXR5ICgyMDQ4KTAeFw05OTEyMjQxNzUwNTFaFw0xOTEy
+MjQxODIwNTFaMIG0MRQwEgYDVQQKEwtFbnRydXN0Lm5ldDFAMD4GA1UECxQ3d3d3
+LmVudHJ1c3QubmV0L0NQU18yMDQ4IGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxp
+YWIuKTElMCMGA1UECxMcKGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDEzMDEG
+A1UEAxMqRW50cnVzdC5uZXQgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgKDIwNDgp
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArU1LqRKGsuqjIAcVFmQq
+K0vRvwtKTY7tgHalZ7d4QMBzQshowNtTK91euHaYNZOLGp18EzoOH1u3Hs/lJBQe
+sYGpjX24zGtLA/ECDNyrpUAkAH90lKGdCCmziAv1h3edVc3kw37XamSrhRSGlVuX
+MlBvPci6Zgzj/L24ScF2iUkZ/cCovYmjZy/Gn7xxGWC4LeksyZB2ZnuU4q941mVT
+XTzWnLLPKQP5L6RQstRIzgUyVYr9smRMDuSYB3Xbf9+5CFVghTAp+XtIpGmG4zU/
+HoZdenoVve8AjhUiVBcAkCaTvA5JaJG/+EfTnZVCwQ5N328mz8MYIWJmQ3DW1cAH
+4QIDAQABo3QwcjARBglghkgBhvhCAQEEBAMCAAcwHwYDVR0jBBgwFoAUVeSB0RGA
+vtiJuQijMfmhJAkWuXAwHQYDVR0OBBYEFFXkgdERgL7YibkIozH5oSQJFrlwMB0G
+CSqGSIb2fQdBAAQQMA4bCFY1LjA6NC4wAwIEkDANBgkqhkiG9w0BAQUFAAOCAQEA
+WUesIYSKF8mciVMeuoCFGsY8Tj6xnLZ8xpJdGGQC49MGCBFhfGPjK50xA3B20qMo
+oPS7mmNz7W3lKtvtFKkrxjYR0CvrB4ul2p5cGZ1WEvVUKcgF7bISKo30Axv/55IQ
+h7A6tcOdBTcSo8f0FbnVpDkWm1M6I5HxqIKiaohowXkCIryqptau37AUX7iH0N18
+f3v/rxzP5tsHrV7bhZ3QKw0z2wTR5klAEyt2+z7pnIkPFc4YsIV4IU9rTw76NmfN
+B/L/CNDi3tm/Kq+4h4YhPATKt5Rof8886ZjXOP/swNlQ8C5LWK5Gb9Auw2DaclVy
+vUxFnmG6v4SBkgPR0ml8xQ==
+-----END CERTIFICATE-----
+
+# Issuer: CN=Baltimore CyberTrust Root O=Baltimore OU=CyberTrust
+# Subject: CN=Baltimore CyberTrust Root O=Baltimore OU=CyberTrust
+# Label: "Baltimore CyberTrust Root"
+# Serial: 33554617
+# MD5 Fingerprint: ac:b6:94:a5:9c:17:e0:d7:91:52:9b:b1:97:06:a6:e4
+# SHA1 Fingerprint: d4:de:20:d0:5e:66:fc:53:fe:1a:50:88:2c:78:db:28:52:ca:e4:74
+# SHA256 Fingerprint: 16:af:57:a9:f6:76:b0:ab:12:60:95:aa:5e:ba:de:f2:2a:b3:11:19:d6:44:ac:95:cd:4b:93:db:f3:f2:6a:eb
+-----BEGIN CERTIFICATE-----
+MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJ
+RTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYD
+VQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoX
+DTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMCSUUxEjAQBgNVBAoTCUJhbHRpbW9y
+ZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFsdGltb3JlIEN5YmVy
+VHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKMEuyKr
+mD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjr
+IZ3AQSsBUnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeK
+mpYcqWe4PwzV9/lSEy/CG9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSu
+XmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9XbIGevOF6uvUA65ehD5f/xXtabz5OTZy
+dc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjprl3RjM71oGDHweI12v/ye
+jl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoIVDaGezq1
+BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3
+DQEBBQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT92
+9hkTI7gQCvlYpNRhcL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3Wgx
+jkzSswF07r51XgdIGn9w/xZchMB5hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0
+Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsaY71k5h+3zvDyny67G7fyUIhz
+ksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9HRCwBXbsdtTLS
+R9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp
+-----END CERTIFICATE-----
+
+# Issuer: CN=Equifax Secure Global eBusiness CA-1 O=Equifax Secure Inc.
+# Subject: CN=Equifax Secure Global eBusiness CA-1 O=Equifax Secure Inc.
+# Label: "Equifax Secure Global eBusiness CA"
+# Serial: 1
+# MD5 Fingerprint: 8f:5d:77:06:27:c4:98:3c:5b:93:78:e7:d7:7d:9b:cc
+# SHA1 Fingerprint: 7e:78:4a:10:1c:82:65:cc:2d:e1:f1:6d:47:b4:40:ca:d9:0a:19:45
+# SHA256 Fingerprint: 5f:0b:62:ea:b5:e3:53:ea:65:21:65:16:58:fb:b6:53:59:f4:43:28:0a:4a:fb:d1:04:d7:7d:10:f9:f0:4c:07
+-----BEGIN CERTIFICATE-----
+MIICkDCCAfmgAwIBAgIBATANBgkqhkiG9w0BAQQFADBaMQswCQYDVQQGEwJVUzEc
+MBoGA1UEChMTRXF1aWZheCBTZWN1cmUgSW5jLjEtMCsGA1UEAxMkRXF1aWZheCBT
+ZWN1cmUgR2xvYmFsIGVCdXNpbmVzcyBDQS0xMB4XDTk5MDYyMTA0MDAwMFoXDTIw
+MDYyMTA0MDAwMFowWjELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE0VxdWlmYXggU2Vj
+dXJlIEluYy4xLTArBgNVBAMTJEVxdWlmYXggU2VjdXJlIEdsb2JhbCBlQnVzaW5l
+c3MgQ0EtMTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAuucXkAJlsTRVPEnC
+UdXfp9E3j9HngXNBUmCbnaEXJnitx7HoJpQytd4zjTov2/KaelpzmKNc6fuKcxtc
+58O/gGzNqfTWK8D3+ZmqY6KxRwIP1ORROhI8bIpaVIRw28HFkM9yRcuoWcDNM50/
+o5brhTMhHD4ePmBudpxnhcXIw2ECAwEAAaNmMGQwEQYJYIZIAYb4QgEBBAQDAgAH
+MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUvqigdHJQa0S3ySPY+6j/s1dr
+aGwwHQYDVR0OBBYEFL6ooHRyUGtEt8kj2Puo/7NXa2hsMA0GCSqGSIb3DQEBBAUA
+A4GBADDiAVGqx+pf2rnQZQ8w1j7aDRRJbpGTJxQx78T3LUX47Me/okENI7SS+RkA
+Z70Br83gcfxaz2TE4JaY0KNA4gGK7ycH8WUBikQtBmV1UsCGECAhX2xrD2yuCRyv
+8qIYNMR1pHMc8Y3c7635s3a0kr/clRAevsvIO1qEYBlWlKlV
+-----END CERTIFICATE-----
+
+# Issuer: CN=Equifax Secure eBusiness CA-1 O=Equifax Secure Inc.
+# Subject: CN=Equifax Secure eBusiness CA-1 O=Equifax Secure Inc.
+# Label: "Equifax Secure eBusiness CA 1"
+# Serial: 4
+# MD5 Fingerprint: 64:9c:ef:2e:44:fc:c6:8f:52:07:d0:51:73:8f:cb:3d
+# SHA1 Fingerprint: da:40:18:8b:91:89:a3:ed:ee:ae:da:97:fe:2f:9d:f5:b7:d1:8a:41
+# SHA256 Fingerprint: cf:56:ff:46:a4:a1:86:10:9d:d9:65:84:b5:ee:b5:8a:51:0c:42:75:b0:e5:f9:4f:40:bb:ae:86:5e:19:f6:73
+-----BEGIN CERTIFICATE-----
+MIICgjCCAeugAwIBAgIBBDANBgkqhkiG9w0BAQQFADBTMQswCQYDVQQGEwJVUzEc
+MBoGA1UEChMTRXF1aWZheCBTZWN1cmUgSW5jLjEmMCQGA1UEAxMdRXF1aWZheCBT
+ZWN1cmUgZUJ1c2luZXNzIENBLTEwHhcNOTkwNjIxMDQwMDAwWhcNMjAwNjIxMDQw
+MDAwWjBTMQswCQYDVQQGEwJVUzEcMBoGA1UEChMTRXF1aWZheCBTZWN1cmUgSW5j
+LjEmMCQGA1UEAxMdRXF1aWZheCBTZWN1cmUgZUJ1c2luZXNzIENBLTEwgZ8wDQYJ
+KoZIhvcNAQEBBQADgY0AMIGJAoGBAM4vGbwXt3fek6lfWg0XTzQaDJj0ItlZ1MRo
+RvC0NcWFAyDGr0WlIVFFQesWWDYyb+JQYmT5/VGcqiTZ9J2DKocKIdMSODRsjQBu
+WqDZQu4aIZX5UkxVWsUPOE9G+m34LjXWHXzr4vCwdYDIqROsvojvOm6rXyo4YgKw
+Env+j6YDAgMBAAGjZjBkMBEGCWCGSAGG+EIBAQQEAwIABzAPBgNVHRMBAf8EBTAD
+AQH/MB8GA1UdIwQYMBaAFEp4MlIR21kWNl7fwRQ2QGpHfEyhMB0GA1UdDgQWBBRK
+eDJSEdtZFjZe38EUNkBqR3xMoTANBgkqhkiG9w0BAQQFAAOBgQB1W6ibAxHm6VZM
+zfmpTMANmvPMZWnmJXbMWbfWVMMdzZmsGd20hdXgPfxiIKeES1hl8eL5lSE/9dR+
+WB5Hh1Q+WKG1tfgq73HnvMP2sUlG4tega+VWeponmHxGYhTnyfxuAxJ5gDgdSIKN
+/Bf+KpYrtWKmpj29f5JZzVoqgrI3eQ==
+-----END CERTIFICATE-----
+
+# Issuer: O=Equifax Secure OU=Equifax Secure eBusiness CA-2
+# Subject: O=Equifax Secure OU=Equifax Secure eBusiness CA-2
+# Label: "Equifax Secure eBusiness CA 2"
+# Serial: 930140085
+# MD5 Fingerprint: aa:bf:bf:64:97:da:98:1d:6f:c6:08:3a:95:70:33:ca
+# SHA1 Fingerprint: 39:4f:f6:85:0b:06:be:52:e5:18:56:cc:10:e1:80:e8:82:b3:85:cc
+# SHA256 Fingerprint: 2f:27:4e:48:ab:a4:ac:7b:76:59:33:10:17:75:50:6d:c3:0e:e3:8e:f6:ac:d5:c0:49:32:cf:e0:41:23:42:20
+-----BEGIN CERTIFICATE-----
+MIIDIDCCAomgAwIBAgIEN3DPtTANBgkqhkiG9w0BAQUFADBOMQswCQYDVQQGEwJV
+UzEXMBUGA1UEChMORXF1aWZheCBTZWN1cmUxJjAkBgNVBAsTHUVxdWlmYXggU2Vj
+dXJlIGVCdXNpbmVzcyBDQS0yMB4XDTk5MDYyMzEyMTQ0NVoXDTE5MDYyMzEyMTQ0
+NVowTjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkVxdWlmYXggU2VjdXJlMSYwJAYD
+VQQLEx1FcXVpZmF4IFNlY3VyZSBlQnVzaW5lc3MgQ0EtMjCBnzANBgkqhkiG9w0B
+AQEFAAOBjQAwgYkCgYEA5Dk5kx5SBhsoNviyoynF7Y6yEb3+6+e0dMKP/wXn2Z0G
+vxLIPw7y1tEkshHe0XMJitSxLJgJDR5QRrKDpkWNYmi7hRsgcDKqQM2mll/EcTc/
+BPO3QSQ5BxoeLmFYoBIL5aXfxavqN3HMHMg3OrmXUqesxWoklE6ce8/AatbfIb0C
+AwEAAaOCAQkwggEFMHAGA1UdHwRpMGcwZaBjoGGkXzBdMQswCQYDVQQGEwJVUzEX
+MBUGA1UEChMORXF1aWZheCBTZWN1cmUxJjAkBgNVBAsTHUVxdWlmYXggU2VjdXJl
+IGVCdXNpbmVzcyBDQS0yMQ0wCwYDVQQDEwRDUkwxMBoGA1UdEAQTMBGBDzIwMTkw
+NjIzMTIxNDQ1WjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUUJ4L6q9euSBIplBq
+y/3YIHqngnYwHQYDVR0OBBYEFFCeC+qvXrkgSKZQasv92CB6p4J2MAwGA1UdEwQF
+MAMBAf8wGgYJKoZIhvZ9B0EABA0wCxsFVjMuMGMDAgbAMA0GCSqGSIb3DQEBBQUA
+A4GBAAyGgq3oThr1jokn4jVYPSm0B482UJW/bsGe68SQsoWou7dC4A8HOd/7npCy
+0cE+U58DRLB+S/Rv5Hwf5+Kx5Lia78O9zt4LMjTZ3ijtM2vE1Nc9ElirfQkty3D1
+E4qUoSek1nDFbZS1yX2doNLGCEnZZpum0/QL3MUmV+GRMOrN
+-----END CERTIFICATE-----
+
+# Issuer: CN=AddTrust Class 1 CA Root O=AddTrust AB OU=AddTrust TTP Network
+# Subject: CN=AddTrust Class 1 CA Root O=AddTrust AB OU=AddTrust TTP Network
+# Label: "AddTrust Low-Value Services Root"
+# Serial: 1
+# MD5 Fingerprint: 1e:42:95:02:33:92:6b:b9:5f:c0:7f:da:d6:b2:4b:fc
+# SHA1 Fingerprint: cc:ab:0e:a0:4c:23:01:d6:69:7b:dd:37:9f:cd:12:eb:24:e3:94:9d
+# SHA256 Fingerprint: 8c:72:09:27:9a:c0:4e:27:5e:16:d0:7f:d3:b7:75:e8:01:54:b5:96:80:46:e3:1f:52:dd:25:76:63:24:e9:a7
+-----BEGIN CERTIFICATE-----
+MIIEGDCCAwCgAwIBAgIBATANBgkqhkiG9w0BAQUFADBlMQswCQYDVQQGEwJTRTEU
+MBIGA1UEChMLQWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFkZFRydXN0IFRUUCBOZXR3
+b3JrMSEwHwYDVQQDExhBZGRUcnVzdCBDbGFzcyAxIENBIFJvb3QwHhcNMDAwNTMw
+MTAzODMxWhcNMjAwNTMwMTAzODMxWjBlMQswCQYDVQQGEwJTRTEUMBIGA1UEChML
+QWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFkZFRydXN0IFRUUCBOZXR3b3JrMSEwHwYD
+VQQDExhBZGRUcnVzdCBDbGFzcyAxIENBIFJvb3QwggEiMA0GCSqGSIb3DQEBAQUA
+A4IBDwAwggEKAoIBAQCWltQhSWDia+hBBwzexODcEyPNwTXH+9ZOEQpnXvUGW2ul
+CDtbKRY654eyNAbFvAWlA3yCyykQruGIgb3WntP+LVbBFc7jJp0VLhD7Bo8wBN6n
+tGO0/7Gcrjyvd7ZWxbWroulpOj0OM3kyP3CCkplhbY0wCI9xP6ZIVxn4JdxLZlyl
+dI+Yrsj5wAYi56xz36Uu+1LcsRVlIPo1Zmne3yzxbrww2ywkEtvrNTVokMsAsJch
+PXQhI2U0K7t4WaPW4XY5mqRJjox0r26kmqPZm9I4XJuiGMx1I4S+6+JNM3GOGvDC
++Mcdoq0Dlyz4zyXG9rgkMbFjXZJ/Y/AlyVMuH79NAgMBAAGjgdIwgc8wHQYDVR0O
+BBYEFJWxtPCUtr3H2tERCSG+wa9J/RB7MAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8E
+BTADAQH/MIGPBgNVHSMEgYcwgYSAFJWxtPCUtr3H2tERCSG+wa9J/RB7oWmkZzBl
+MQswCQYDVQQGEwJTRTEUMBIGA1UEChMLQWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFk
+ZFRydXN0IFRUUCBOZXR3b3JrMSEwHwYDVQQDExhBZGRUcnVzdCBDbGFzcyAxIENB
+IFJvb3SCAQEwDQYJKoZIhvcNAQEFBQADggEBACxtZBsfzQ3duQH6lmM0MkhHma6X
+7f1yFqZzR1r0693p9db7RcwpiURdv0Y5PejuvE1Uhh4dbOMXJ0PhiVYrqW9yTkkz
+43J8KiOavD7/KCrto/8cI7pDVwlnTUtiBi34/2ydYB7YHEt9tTEv2dB8Xfjea4MY
+eDdXL+gzB2ffHsdrKpV2ro9Xo/D0UrSpUwjP4E/TelOL/bscVjby/rK25Xa71SJl
+pz/+0WatC7xrmYbvP33zGDLKe8bjq2RGlfgmadlVg3sslgf/WSxEo8bl6ancoWOA
+WiFeIc9TVPC6b4nbqKqVz4vjccweGyBECMB6tkD9xOQ14R0WHNC8K47Wcdk=
+-----END CERTIFICATE-----
+
+# Issuer: CN=AddTrust External CA Root O=AddTrust AB OU=AddTrust External TTP Network
+# Subject: CN=AddTrust External CA Root O=AddTrust AB OU=AddTrust External TTP Network
+# Label: "AddTrust External Root"
+# Serial: 1
+# MD5 Fingerprint: 1d:35:54:04:85:78:b0:3f:42:42:4d:bf:20:73:0a:3f
+# SHA1 Fingerprint: 02:fa:f3:e2:91:43:54:68:60:78:57:69:4d:f5:e4:5b:68:85:18:68
+# SHA256 Fingerprint: 68:7f:a4:51:38:22:78:ff:f0:c8:b1:1f:8d:43:d5:76:67:1c:6e:b2:bc:ea:b4:13:fb:83:d9:65:d0:6d:2f:f2
+-----BEGIN CERTIFICATE-----
+MIIENjCCAx6gAwIBAgIBATANBgkqhkiG9w0BAQUFADBvMQswCQYDVQQGEwJTRTEU
+MBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFkZFRydXN0IEV4dGVybmFs
+IFRUUCBOZXR3b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBFeHRlcm5hbCBDQSBSb290
+MB4XDTAwMDUzMDEwNDgzOFoXDTIwMDUzMDEwNDgzOFowbzELMAkGA1UEBhMCU0Ux
+FDASBgNVBAoTC0FkZFRydXN0IEFCMSYwJAYDVQQLEx1BZGRUcnVzdCBFeHRlcm5h
+bCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRkVHJ1c3QgRXh0ZXJuYWwgQ0EgUm9v
+dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALf3GjPm8gAELTngTlvt
+H7xsD821+iO2zt6bETOXpClMfZOfvUq8k+0DGuOPz+VtUFrWlymUWoCwSXrbLpX9
+uMq/NzgtHj6RQa1wVsfwTz/oMp50ysiQVOnGXw94nZpAPA6sYapeFI+eh6FqUNzX
+mk6vBbOmcZSccbNQYArHE504B4YCqOmoaSYYkKtMsE8jqzpPhNjfzp/haW+710LX
+a0Tkx63ubUFfclpxCDezeWWkWaCUN/cALw3CknLa0Dhy2xSoRcRdKn23tNbE7qzN
+E0S3ySvdQwAl+mG5aWpYIxG3pzOPVnVZ9c0p10a3CitlttNCbxWyuHv77+ldU9U0
+WicCAwEAAaOB3DCB2TAdBgNVHQ4EFgQUrb2YejS0Jvf6xCZU7wO94CTLVBowCwYD
+VR0PBAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wgZkGA1UdIwSBkTCBjoAUrb2YejS0
+Jvf6xCZU7wO94CTLVBqhc6RxMG8xCzAJBgNVBAYTAlNFMRQwEgYDVQQKEwtBZGRU
+cnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQIE5ldHdvcmsx
+IjAgBgNVBAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3SCAQEwDQYJKoZIhvcN
+AQEFBQADggEBALCb4IUlwtYj4g+WBpKdQZic2YR5gdkeWxQHIzZlj7DYd7usQWxH
+YINRsPkyPef89iYTx4AWpb9a/IfPeHmJIZriTAcKhjW88t5RxNKWt9x+Tu5w/Rw5
+6wwCURQtjr0W4MHfRnXnJK3s9EK0hZNwEGe6nQY1ShjTK3rMUUKhemPR5ruhxSvC
+Nr4TDea9Y355e6cJDUCrat2PisP29owaQgVR1EX1n6diIWgVIEM8med8vSTYqZEX
+c4g/VhsxOBi0cQ+azcgOno4uG+GMmIPLHzHxREzGBHNJdmAPx/i9F4BrLunMTA5a
+mnkPIAou1Z5jJh5VkpTYghdae9C8x49OhgQ=
+-----END CERTIFICATE-----
+
+# Issuer: CN=AddTrust Public CA Root O=AddTrust AB OU=AddTrust TTP Network
+# Subject: CN=AddTrust Public CA Root O=AddTrust AB OU=AddTrust TTP Network
+# Label: "AddTrust Public Services Root"
+# Serial: 1
+# MD5 Fingerprint: c1:62:3e:23:c5:82:73:9c:03:59:4b:2b:e9:77:49:7f
+# SHA1 Fingerprint: 2a:b6:28:48:5e:78:fb:f3:ad:9e:79:10:dd:6b:df:99:72:2c:96:e5
+# SHA256 Fingerprint: 07:91:ca:07:49:b2:07:82:aa:d3:c7:d7:bd:0c:df:c9:48:58:35:84:3e:b2:d7:99:60:09:ce:43:ab:6c:69:27
+-----BEGIN CERTIFICATE-----
+MIIEFTCCAv2gAwIBAgIBATANBgkqhkiG9w0BAQUFADBkMQswCQYDVQQGEwJTRTEU
+MBIGA1UEChMLQWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFkZFRydXN0IFRUUCBOZXR3
+b3JrMSAwHgYDVQQDExdBZGRUcnVzdCBQdWJsaWMgQ0EgUm9vdDAeFw0wMDA1MzAx
+MDQxNTBaFw0yMDA1MzAxMDQxNTBaMGQxCzAJBgNVBAYTAlNFMRQwEgYDVQQKEwtB
+ZGRUcnVzdCBBQjEdMBsGA1UECxMUQWRkVHJ1c3QgVFRQIE5ldHdvcmsxIDAeBgNV
+BAMTF0FkZFRydXN0IFB1YmxpYyBDQSBSb290MIIBIjANBgkqhkiG9w0BAQEFAAOC
+AQ8AMIIBCgKCAQEA6Rowj4OIFMEg2Dybjxt+A3S72mnTRqX4jsIMEZBRpS9mVEBV
+6tsfSlbunyNu9DnLoblv8n75XYcmYZ4c+OLspoH4IcUkzBEMP9smcnrHAZcHF/nX
+GCwwfQ56HmIexkvA/X1id9NEHif2P0tEs7c42TkfYNVRknMDtABp4/MUTu7R3AnP
+dzRGULD4EfL+OHn3Bzn+UZKXC1sIXzSGAa2Il+tmzV7R/9x98oTaunet3IAIx6eH
+1lWfl2royBFkuucZKT8Rs3iQhCBSWxHveNCD9tVIkNAwHM+A+WD+eeSI8t0A65RF
+62WUaUC6wNW0uLp9BBGo6zEFlpROWCGOn9Bg/QIDAQABo4HRMIHOMB0GA1UdDgQW
+BBSBPjfYkrAfd59ctKtzquf2NGAv+jALBgNVHQ8EBAMCAQYwDwYDVR0TAQH/BAUw
+AwEB/zCBjgYDVR0jBIGGMIGDgBSBPjfYkrAfd59ctKtzquf2NGAv+qFopGYwZDEL
+MAkGA1UEBhMCU0UxFDASBgNVBAoTC0FkZFRydXN0IEFCMR0wGwYDVQQLExRBZGRU
+cnVzdCBUVFAgTmV0d29yazEgMB4GA1UEAxMXQWRkVHJ1c3QgUHVibGljIENBIFJv
+b3SCAQEwDQYJKoZIhvcNAQEFBQADggEBAAP3FUr4JNojVhaTdt02KLmuG7jD8WS6
+IBh4lSknVwW8fCr0uVFV2ocC3g8WFzH4qnkuCRO7r7IgGRLlk/lL+YPoRNWyQSW/
+iHVv/xD8SlTQX/D67zZzfRs2RcYhbbQVuE7PnFylPVoAjgbjPGsye/Kf8Lb93/Ao
+GEjwxrzQvzSAlsJKsW2Ox5BF3i9nrEUEo3rcVZLJR2bYGozH7ZxOmuASu7VqTITh
+4SINhwBk/ox9Yjllpu9CtoAlEmEBqCQTcAARJl/6NVDFSMwGR+gn2HCNX2TmoUQm
+XiLsks3/QppEIW1cxeMiHV9HEufOX1362KqxMy3ZdvJOOjMMK7MtkAY=
+-----END CERTIFICATE-----
+
+# Issuer: CN=AddTrust Qualified CA Root O=AddTrust AB OU=AddTrust TTP Network
+# Subject: CN=AddTrust Qualified CA Root O=AddTrust AB OU=AddTrust TTP Network
+# Label: "AddTrust Qualified Certificates Root"
+# Serial: 1
+# MD5 Fingerprint: 27:ec:39:47:cd:da:5a:af:e2:9a:01:65:21:a9:4c:bb
+# SHA1 Fingerprint: 4d:23:78:ec:91:95:39:b5:00:7f:75:8f:03:3b:21:1e:c5:4d:8b:cf
+# SHA256 Fingerprint: 80:95:21:08:05:db:4b:bc:35:5e:44:28:d8:fd:6e:c2:cd:e3:ab:5f:b9:7a:99:42:98:8e:b8:f4:dc:d0:60:16
+-----BEGIN CERTIFICATE-----
+MIIEHjCCAwagAwIBAgIBATANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJTRTEU
+MBIGA1UEChMLQWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFkZFRydXN0IFRUUCBOZXR3
+b3JrMSMwIQYDVQQDExpBZGRUcnVzdCBRdWFsaWZpZWQgQ0EgUm9vdDAeFw0wMDA1
+MzAxMDQ0NTBaFw0yMDA1MzAxMDQ0NTBaMGcxCzAJBgNVBAYTAlNFMRQwEgYDVQQK
+EwtBZGRUcnVzdCBBQjEdMBsGA1UECxMUQWRkVHJ1c3QgVFRQIE5ldHdvcmsxIzAh
+BgNVBAMTGkFkZFRydXN0IFF1YWxpZmllZCBDQSBSb290MIIBIjANBgkqhkiG9w0B
+AQEFAAOCAQ8AMIIBCgKCAQEA5B6a/twJWoekn0e+EV+vhDTbYjx5eLfpMLXsDBwq
+xBb/4Oxx64r1EW7tTw2R0hIYLUkVAcKkIhPHEWT/IhKauY5cLwjPcWqzZwFZ8V1G
+87B4pfYOQnrjfxvM0PC3KP0q6p6zsLkEqv32x7SxuCqg+1jxGaBvcCV+PmlKfw8i
+2O+tCBGaKZnhqkRFmhJePp1tUvznoD1oL/BLcHwTOK28FSXx1s6rosAx1i+f4P8U
+WfyEk9mHfExUE+uf0S0R+Bg6Ot4l2ffTQO2kBhLEO+GRwVY18BTcZTYJbqukB8c1
+0cIDMzZbdSZtQvESa0NvS3GU+jQd7RNuyoB/mC9suWXY6QIDAQABo4HUMIHRMB0G
+A1UdDgQWBBQ5lYtii1zJ1IC6WA+XPxUIQ8yYpzALBgNVHQ8EBAMCAQYwDwYDVR0T
+AQH/BAUwAwEB/zCBkQYDVR0jBIGJMIGGgBQ5lYtii1zJ1IC6WA+XPxUIQ8yYp6Fr
+pGkwZzELMAkGA1UEBhMCU0UxFDASBgNVBAoTC0FkZFRydXN0IEFCMR0wGwYDVQQL
+ExRBZGRUcnVzdCBUVFAgTmV0d29yazEjMCEGA1UEAxMaQWRkVHJ1c3QgUXVhbGlm
+aWVkIENBIFJvb3SCAQEwDQYJKoZIhvcNAQEFBQADggEBABmrder4i2VhlRO6aQTv
+hsoToMeqT2QbPxj2qC0sVY8FtzDqQmodwCVRLae/DLPt7wh/bDxGGuoYQ992zPlm
+hpwsaPXpF/gxsxjE1kh9I0xowX67ARRvxdlu3rsEQmr49lx95dr6h+sNNVJn0J6X
+dgWTP5XHAeZpVTh/EGGZyeNfpso+gmNIquIISD6q8rKFYqa0p9m9N5xotS1WfbC3
+P6CxB9bpT9zeRXEwMn8bLgn5v1Kh7sKAPgZcLlVAwRv1cEWw3F369nJad9Jjzc9Y
+iQBCYz95OdBEsIJuQRno3eDBiFrRHnGTHyQwdOUeqN48Jzd/g66ed8/wMLH/S5no
+xqE=
+-----END CERTIFICATE-----
+
+# Issuer: CN=Entrust Root Certification Authority O=Entrust, Inc. OU=www.entrust.net/CPS is incorporated by reference/(c) 2006 Entrust, Inc.
+# Subject: CN=Entrust Root Certification Authority O=Entrust, Inc. OU=www.entrust.net/CPS is incorporated by reference/(c) 2006 Entrust, Inc.
+# Label: "Entrust Root Certification Authority"
+# Serial: 1164660820
+# MD5 Fingerprint: d6:a5:c3:ed:5d:dd:3e:00:c1:3d:87:92:1f:1d:3f:e4
+# SHA1 Fingerprint: b3:1e:b1:b7:40:e3:6c:84:02:da:dc:37:d4:4d:f5:d4:67:49:52:f9
+# SHA256 Fingerprint: 73:c1:76:43:4f:1b:c6:d5:ad:f4:5b:0e:76:e7:27:28:7c:8d:e5:76:16:c1:e6:e6:14:1a:2b:2c:bc:7d:8e:4c
+-----BEGIN CERTIFICATE-----
+MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMC
+VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0
+Lm5ldC9DUFMgaXMgaW5jb3Jwb3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMW
+KGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsGA1UEAxMkRW50cnVzdCBSb290IENl
+cnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0MloXDTI2MTEyNzIw
+NTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMTkw
+NwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSBy
+ZWZlcmVuY2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNV
+BAMTJEVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJ
+KoZIhvcNAQEBBQADggEPADCCAQoCggEBALaVtkNC+sZtKm9I35RMOVcF7sN5EUFo
+Nu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYszA9u3g3s+IIRe7bJWKKf4
+4LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOwwCj0Yzfv9
+KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGI
+rb68j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi
+94DkZfs0Nw4pgHBNrziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOB
+sDCBrTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAi
+gA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1MzQyWjAfBgNVHSMEGDAWgBRo
+kORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DHhmak8fdLQ/uE
+vW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA
+A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9t
+O1KzKtvn1ISMY/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6Zua
+AGAT/3B+XxFNSRuzFVJ7yVTav52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP
+9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTSW3iDVuycNsMm4hH2Z0kdkquM++v/
+eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0tHuu2guQOHXvgR1m
+0vdXcDazv/wor3ElhVsT/h5/WrQ8
+-----END CERTIFICATE-----
+
+# Issuer: CN=GeoTrust Global CA O=GeoTrust Inc.
+# Subject: CN=GeoTrust Global CA O=GeoTrust Inc.
+# Label: "GeoTrust Global CA"
+# Serial: 144470
+# MD5 Fingerprint: f7:75:ab:29:fb:51:4e:b7:77:5e:ff:05:3c:99:8e:f5
+# SHA1 Fingerprint: de:28:f4:a4:ff:e5:b9:2f:a3:c5:03:d1:a3:49:a7:f9:96:2a:82:12
+# SHA256 Fingerprint: ff:85:6a:2d:25:1d:cd:88:d3:66:56:f4:50:12:67:98:cf:ab:aa:de:40:79:9c:72:2d:e4:d2:b5:db:36:a7:3a
+-----BEGIN CERTIFICATE-----
+MIIDVDCCAjygAwIBAgIDAjRWMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT
+MRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9i
+YWwgQ0EwHhcNMDIwNTIxMDQwMDAwWhcNMjIwNTIxMDQwMDAwWjBCMQswCQYDVQQG
+EwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEbMBkGA1UEAxMSR2VvVHJ1c3Qg
+R2xvYmFsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2swYYzD9
+9BcjGlZ+W988bDjkcbd4kdS8odhM+KhDtgPpTSEHCIjaWC9mOSm9BXiLnTjoBbdq
+fnGk5sRgprDvgOSJKA+eJdbtg/OtppHHmMlCGDUUna2YRpIuT8rxh0PBFpVXLVDv
+iS2Aelet8u5fa9IAjbkU+BQVNdnARqN7csiRv8lVK83Qlz6cJmTM386DGXHKTubU
+1XupGc1V3sjs0l44U+VcT4wt/lAjNvxm5suOpDkZALeVAjmRCw7+OC7RHQWa9k0+
+bw8HHa8sHo9gOeL6NlMTOdReJivbPagUvTLrGAMoUgRx5aszPeE4uwc2hGKceeoW
+MPRfwCvocWvk+QIDAQABo1MwUTAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTA
+ephojYn7qwVkDBF9qn1luMrMTjAfBgNVHSMEGDAWgBTAephojYn7qwVkDBF9qn1l
+uMrMTjANBgkqhkiG9w0BAQUFAAOCAQEANeMpauUvXVSOKVCUn5kaFOSPeCpilKIn
+Z57QzxpeR+nBsqTP3UEaBU6bS+5Kb1VSsyShNwrrZHYqLizz/Tt1kL/6cdjHPTfS
+tQWVYrmm3ok9Nns4d0iXrKYgjy6myQzCsplFAMfOEVEiIuCl6rYVSAlk6l5PdPcF
+PseKUgzbFbS9bZvlxrFUaKnjaZC2mqUPuLk/IH2uSrW4nOQdtqvmlKXBx4Ot2/Un
+hw4EbNX/3aBd7YdStysVAq45pmp06drE57xNNB6pXE0zX5IJL4hmXXeXxx12E6nV
+5fEWCRE11azbJHFwLJhWC9kXtNHjUStedejV0NxPNO3CBWaAocvmMw==
+-----END CERTIFICATE-----
+
+# Issuer: CN=GeoTrust Global CA 2 O=GeoTrust Inc.
+# Subject: CN=GeoTrust Global CA 2 O=GeoTrust Inc.
+# Label: "GeoTrust Global CA 2"
+# Serial: 1
+# MD5 Fingerprint: 0e:40:a7:6c:de:03:5d:8f:d1:0f:e4:d1:8d:f9:6c:a9
+# SHA1 Fingerprint: a9:e9:78:08:14:37:58:88:f2:05:19:b0:6d:2b:0d:2b:60:16:90:7d
+# SHA256 Fingerprint: ca:2d:82:a0:86:77:07:2f:8a:b6:76:4f:f0:35:67:6c:fe:3e:5e:32:5e:01:21:72:df:3f:92:09:6d:b7:9b:85
+-----BEGIN CERTIFICATE-----
+MIIDZjCCAk6gAwIBAgIBATANBgkqhkiG9w0BAQUFADBEMQswCQYDVQQGEwJVUzEW
+MBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEdMBsGA1UEAxMUR2VvVHJ1c3QgR2xvYmFs
+IENBIDIwHhcNMDQwMzA0MDUwMDAwWhcNMTkwMzA0MDUwMDAwWjBEMQswCQYDVQQG
+EwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEdMBsGA1UEAxMUR2VvVHJ1c3Qg
+R2xvYmFsIENBIDIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDvPE1A
+PRDfO1MA4Wf+lGAVPoWI8YkNkMgoI5kF6CsgncbzYEbYwbLVjDHZ3CB5JIG/NTL8
+Y2nbsSpr7iFY8gjpeMtvy/wWUsiRxP89c96xPqfCfWbB9X5SJBri1WeR0IIQ13hL
+TytCOb1kLUCgsBDTOEhGiKEMuzozKmKY+wCdE1l/bztyqu6mD4b5BWHqZ38MN5aL
+5mkWRxHCJ1kDs6ZgwiFAVvqgx306E+PsV8ez1q6diYD3Aecs9pYrEw15LNnA5IZ7
+S4wMcoKK+xfNAGw6EzywhIdLFnopsk/bHdQL82Y3vdj2V7teJHq4PIu5+pIaGoSe
+2HSPqht/XvT+RSIhAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
+FHE4NvICMVNHK266ZUapEBVYIAUJMB8GA1UdIwQYMBaAFHE4NvICMVNHK266ZUap
+EBVYIAUJMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQUFAAOCAQEAA/e1K6td
+EPx7srJerJsOflN4WT5CBP51o62sgU7XAotexC3IUnbHLB/8gTKY0UvGkpMzNTEv
+/NgdRN3ggX+d6YvhZJFiCzkIjKx0nVnZellSlxG5FntvRdOW2TF9AjYPnDtuzywN
+A0ZF66D0f0hExghAzN4bcLUprbqLOzRldRtxIR0sFAqwlpW41uryZfspuk/qkZN0
+abby/+Ea0AzRdoXLiiW9l14sbxWZJue2Kf8i7MkCx1YAzUm5s2x7UwQa4qjJqhIF
+I8LO57sEAszAR6LkxCkvW0VXiVHuPOtSCP8HNR6fNWpHSlaY0VqFH4z1Ir+rzoPz
+4iIprn2DQKi6bA==
+-----END CERTIFICATE-----
+
+# Issuer: CN=GeoTrust Universal CA O=GeoTrust Inc.
+# Subject: CN=GeoTrust Universal CA O=GeoTrust Inc.
+# Label: "GeoTrust Universal CA"
+# Serial: 1
+# MD5 Fingerprint: 92:65:58:8b:a2:1a:31:72:73:68:5c:b4:a5:7a:07:48
+# SHA1 Fingerprint: e6:21:f3:35:43:79:05:9a:4b:68:30:9d:8a:2f:74:22:15:87:ec:79
+# SHA256 Fingerprint: a0:45:9b:9f:63:b2:25:59:f5:fa:5d:4c:6d:b3:f9:f7:2f:f1:93:42:03:35:78:f0:73:bf:1d:1b:46:cb:b9:12
+-----BEGIN CERTIFICATE-----
+MIIFaDCCA1CgAwIBAgIBATANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQGEwJVUzEW
+MBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEeMBwGA1UEAxMVR2VvVHJ1c3QgVW5pdmVy
+c2FsIENBMB4XDTA0MDMwNDA1MDAwMFoXDTI5MDMwNDA1MDAwMFowRTELMAkGA1UE
+BhMCVVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xHjAcBgNVBAMTFUdlb1RydXN0
+IFVuaXZlcnNhbCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKYV
+VaCjxuAfjJ0hUNfBvitbtaSeodlyWL0AG0y/YckUHUWCq8YdgNY96xCcOq9tJPi8
+cQGeBvV8Xx7BDlXKg5pZMK4ZyzBIle0iN430SppyZj6tlcDgFgDgEB8rMQ7XlFTT
+QjOgNB0eRXbdT8oYN+yFFXoZCPzVx5zw8qkuEKmS5j1YPakWaDwvdSEYfyh3peFh
+F7em6fgemdtzbvQKoiFs7tqqhZJmr/Z6a4LauiIINQ/PQvE1+mrufislzDoR5G2v
+c7J2Ha3QsnhnGqQ5HFELZ1aD/ThdDc7d8Lsrlh/eezJS/R27tQahsiFepdaVaH/w
+mZ7cRQg+59IJDTWU3YBOU5fXtQlEIGQWFwMCTFMNaN7VqnJNk22CDtucvc+081xd
+VHppCZbW2xHBjXWotM85yM48vCR85mLK4b19p71XZQvk/iXttmkQ3CgaRr0BHdCX
+teGYO8A3ZNY9lO4L4fUorgtWv3GLIylBjobFS1J72HGrH4oVpjuDWtdYAVHGTEHZ
+f9hBZ3KiKN9gg6meyHv8U3NyWfWTehd2Ds735VzZC1U0oqpbtWpU5xPKV+yXbfRe
+Bi9Fi1jUIxaS5BZuKGNZMN9QAZxjiRqf2xeUgnA3wySemkfWWspOqGmJch+RbNt+
+nhutxx9z3SxPGWX9f5NAEC7S8O08ni4oPmkmM8V7AgMBAAGjYzBhMA8GA1UdEwEB
+/wQFMAMBAf8wHQYDVR0OBBYEFNq7LqqwDLiIJlF0XG0D08DYj3rWMB8GA1UdIwQY
+MBaAFNq7LqqwDLiIJlF0XG0D08DYj3rWMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG
+9w0BAQUFAAOCAgEAMXjmx7XfuJRAyXHEqDXsRh3ChfMoWIawC/yOsjmPRFWrZIRc
+aanQmjg8+uUfNeVE44B5lGiku8SfPeE0zTBGi1QrlaXv9z+ZhP015s8xxtxqv6fX
+IwjhmF7DWgh2qaavdy+3YL1ERmrvl/9zlcGO6JP7/TG37FcREUWbMPEaiDnBTzyn
+ANXH/KttgCJwpQzgXQQpAvvLoJHRfNbDflDVnVi+QTjruXU8FdmbyUqDWcDaU/0z
+uzYYm4UPFd3uLax2k7nZAY1IEKj79TiG8dsKxr2EoyNB3tZ3b4XUhRxQ4K5RirqN
+Pnbiucon8l+f725ZDQbYKxek0nxru18UGkiPGkzns0ccjkxFKyDuSN/n3QmOGKja
+QI2SJhFTYXNd673nxE0pN2HrrDktZy4W1vUAg4WhzH92xH3kt0tm7wNFYGm2DFKW
+koRepqO1pD4r2czYG0eq8kTaT/kD6PAUyz/zg97QwVTjt+gKN02LIFkDMBmhLMi9
+ER/frslKxfMnZmaGrGiR/9nmUxwPi1xpZQomyB40w11Re9epnAahNt3ViZS82eQt
+DF4JbAiXfKM9fJP/P6EUp8+1Xevb2xzEdt+Iub1FBZUbrvxGakyvSOPOrg/Sfuvm
+bJxPgWp6ZKy7PtXny3YuxadIwVyQD8vIP/rmMuGNG2+k5o7Y+SlIis5z/iw=
+-----END CERTIFICATE-----
+
+# Issuer: CN=GeoTrust Universal CA 2 O=GeoTrust Inc.
+# Subject: CN=GeoTrust Universal CA 2 O=GeoTrust Inc.
+# Label: "GeoTrust Universal CA 2"
+# Serial: 1
+# MD5 Fingerprint: 34:fc:b8:d0:36:db:9e:14:b3:c2:f2:db:8f:e4:94:c7
+# SHA1 Fingerprint: 37:9a:19:7b:41:85:45:35:0c:a6:03:69:f3:3c:2e:af:47:4f:20:79
+# SHA256 Fingerprint: a0:23:4f:3b:c8:52:7c:a5:62:8e:ec:81:ad:5d:69:89:5d:a5:68:0d:c9:1d:1c:b8:47:7f:33:f8:78:b9:5b:0b
+-----BEGIN CERTIFICATE-----
+MIIFbDCCA1SgAwIBAgIBATANBgkqhkiG9w0BAQUFADBHMQswCQYDVQQGEwJVUzEW
+MBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEgMB4GA1UEAxMXR2VvVHJ1c3QgVW5pdmVy
+c2FsIENBIDIwHhcNMDQwMzA0MDUwMDAwWhcNMjkwMzA0MDUwMDAwWjBHMQswCQYD
+VQQGEwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEgMB4GA1UEAxMXR2VvVHJ1
+c3QgVW5pdmVyc2FsIENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC
+AQCzVFLByT7y2dyxUxpZKeexw0Uo5dfR7cXFS6GqdHtXr0om/Nj1XqduGdt0DE81
+WzILAePb63p3NeqqWuDW6KFXlPCQo3RWlEQwAx5cTiuFJnSCegx2oG9NzkEtoBUG
+FF+3Qs17j1hhNNwqCPkuwwGmIkQcTAeC5lvO0Ep8BNMZcyfwqph/Lq9O64ceJHdq
+XbboW0W63MOhBW9Wjo8QJqVJwy7XQYci4E+GymC16qFjwAGXEHm9ADwSbSsVsaxL
+se4YuU6W3Nx2/zu+z18DwPw76L5GG//aQMJS9/7jOvdqdzXQ2o3rXhhqMcceujwb
+KNZrVMaqW9eiLBsZzKIC9ptZvTdrhrVtgrrY6slWvKk2WP0+GfPtDCapkzj4T8Fd
+IgbQl+rhrcZV4IErKIM6+vR7IVEAvlI4zs1meaj0gVbi0IMJR1FbUGrP20gaXT73
+y/Zl92zxlfgCOzJWgjl6W70viRu/obTo/3+NjN8D8WBOWBFM66M/ECuDmgFz2ZRt
+hAAnZqzwcEAJQpKtT5MNYQlRJNiS1QuUYbKHsu3/mjX/hVTK7URDrBs8FmtISgoc
+QIgfksILAAX/8sgCSqSqqcyZlpwvWOB94b67B9xfBHJcMTTD7F8t4D1kkCLm0ey4
+Lt1ZrtmhN79UNdxzMk+MBB4zsslG8dhcyFVQyWi9qLo2CQIDAQABo2MwYTAPBgNV
+HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR281Xh+qQ2+/CfXGJx7Tz0RzgQKzAfBgNV
+HSMEGDAWgBR281Xh+qQ2+/CfXGJx7Tz0RzgQKzAOBgNVHQ8BAf8EBAMCAYYwDQYJ
+KoZIhvcNAQEFBQADggIBAGbBxiPz2eAubl/oz66wsCVNK/g7WJtAJDday6sWSf+z
+dXkzoS9tcBc0kf5nfo/sm+VegqlVHy/c1FEHEv6sFj4sNcZj/NwQ6w2jqtB8zNHQ
+L1EuxBRa3ugZ4T7GzKQp5y6EqgYweHZUcyiYWTjgAA1i00J9IZ+uPTqM1fp3DRgr
+Fg5fNuH8KrUwJM/gYwx7WBr+mbpCErGR9Hxo4sjoryzqyX6uuyo9DRXcNJW2GHSo
+ag/HtPQTxORb7QrSpJdMKu0vbBKJPfEncKpqA1Ihn0CoZ1Dy81of398j9tx4TuaY
+T1U6U+Pv8vSfx3zYWK8pIpe44L2RLrB27FcRz+8pRPPphXpgY+RdM4kX2TGq2tbz
+GDVyz4crL2MjhF2EjD9XoIj8mZEoJmmZ1I+XRL6O1UixpCgp8RW04eWe3fiPpm8m
+1wk8OhwRDqZsN/etRIcsKMfYdIKz0G9KV7s1KSegi+ghp4dkNl3M2Basx7InQJJV
+OCiNUW7dFGdTbHFcJoRNdVq2fmBWqU2t+5sel/MN2dKXVHfaPRK34B7vCAas+YWH
+6aLcr34YEoP9VhdBLtUpgn2Z9DH2canPLAEnpQW5qrJITirvn5NSUZU8UnOOVkwX
+QMAJKOSLakhT2+zNVVXxxvjpoixMptEmX36vWkzaH6byHCx+rgIW0lbQL1dTR+iS
+-----END CERTIFICATE-----
+
+# Issuer: CN=America Online Root Certification Authority 1 O=America Online Inc.
+# Subject: CN=America Online Root Certification Authority 1 O=America Online Inc.
+# Label: "America Online Root Certification Authority 1"
+# Serial: 1
+# MD5 Fingerprint: 14:f1:08:ad:9d:fa:64:e2:89:e7:1c:cf:a8:ad:7d:5e
+# SHA1 Fingerprint: 39:21:c1:15:c1:5d:0e:ca:5c:cb:5b:c4:f0:7d:21:d8:05:0b:56:6a
+# SHA256 Fingerprint: 77:40:73:12:c6:3a:15:3d:5b:c0:0b:4e:51:75:9c:df:da:c2:37:dc:2a:33:b6:79:46:e9:8e:9b:fa:68:0a:e3
+-----BEGIN CERTIFICATE-----
+MIIDpDCCAoygAwIBAgIBATANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEc
+MBoGA1UEChMTQW1lcmljYSBPbmxpbmUgSW5jLjE2MDQGA1UEAxMtQW1lcmljYSBP
+bmxpbmUgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAxMB4XDTAyMDUyODA2
+MDAwMFoXDTM3MTExOTIwNDMwMFowYzELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE0Ft
+ZXJpY2EgT25saW5lIEluYy4xNjA0BgNVBAMTLUFtZXJpY2EgT25saW5lIFJvb3Qg
+Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkgMTCCASIwDQYJKoZIhvcNAQEBBQADggEP
+ADCCAQoCggEBAKgv6KRpBgNHw+kqmP8ZonCaxlCyfqXfaE0bfA+2l2h9LaaLl+lk
+hsmj76CGv2BlnEtUiMJIxUo5vxTjWVXlGbR0yLQFOVwWpeKVBeASrlmLojNoWBym
+1BW32J/X3HGrfpq/m44zDyL9Hy7nBzbvYjnF3cu6JRQj3gzGPTzOggjmZj7aUTsW
+OqMFf6Dch9Wc/HKpoH145LcxVR5lu9RhsCFg7RAycsWSJR74kEoYeEfffjA3PlAb
+2xzTa5qGUwew76wGePiEmf4hjUyAtgyC9mZweRrTT6PP8c9GsEsPPt2IYriMqQko
+O3rHl+Ee5fSfwMCuJKDIodkP1nsmgmkyPacCAwEAAaNjMGEwDwYDVR0TAQH/BAUw
+AwEB/zAdBgNVHQ4EFgQUAK3Zo/Z59m50qX8zPYEX10zPM94wHwYDVR0jBBgwFoAU
+AK3Zo/Z59m50qX8zPYEX10zPM94wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB
+BQUAA4IBAQB8itEfGDeC4Liwo+1WlchiYZwFos3CYiZhzRAW18y0ZTTQEYqtqKkF
+Zu90821fnZmv9ov761KyBZiibyrFVL0lvV+uyIbqRizBs73B6UlwGBaXCBOMIOAb
+LjpHyx7kADCVW/RFo8AasAFOq73AI25jP4BKxQft3OJvx8Fi8eNy1gTIdGcL+oir
+oQHIb/AUr9KZzVGTfu0uOMe9zkZQPXLjeSWdm4grECDdpbgyn43gKd8hdIaC2y+C
+MMbHNYaz+ZZfRtsMRf3zUMNvxsNIrUam4SdHCh0Om7bCd39j8uB9Gr784N/Xx6ds
+sPmuujz9dLQR6FgNgLzTqIA6me11zEZ7
+-----END CERTIFICATE-----
+
+# Issuer: CN=America Online Root Certification Authority 2 O=America Online Inc.
+# Subject: CN=America Online Root Certification Authority 2 O=America Online Inc.
+# Label: "America Online Root Certification Authority 2"
+# Serial: 1
+# MD5 Fingerprint: d6:ed:3c:ca:e2:66:0f:af:10:43:0d:77:9b:04:09:bf
+# SHA1 Fingerprint: 85:b5:ff:67:9b:0c:79:96:1f:c8:6e:44:22:00:46:13:db:17:92:84
+# SHA256 Fingerprint: 7d:3b:46:5a:60:14:e5:26:c0:af:fc:ee:21:27:d2:31:17:27:ad:81:1c:26:84:2d:00:6a:f3:73:06:cc:80:bd
+-----BEGIN CERTIFICATE-----
+MIIFpDCCA4ygAwIBAgIBATANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEc
+MBoGA1UEChMTQW1lcmljYSBPbmxpbmUgSW5jLjE2MDQGA1UEAxMtQW1lcmljYSBP
+bmxpbmUgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAyMB4XDTAyMDUyODA2
+MDAwMFoXDTM3MDkyOTE0MDgwMFowYzELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE0Ft
+ZXJpY2EgT25saW5lIEluYy4xNjA0BgNVBAMTLUFtZXJpY2EgT25saW5lIFJvb3Qg
+Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIP
+ADCCAgoCggIBAMxBRR3pPU0Q9oyxQcngXssNt79Hc9PwVU3dxgz6sWYFas14tNwC
+206B89enfHG8dWOgXeMHDEjsJcQDIPT/DjsS/5uN4cbVG7RtIuOx238hZK+GvFci
+KtZHgVdEglZTvYYUAQv8f3SkWq7xuhG1m1hagLQ3eAkzfDJHA1zEpYNI9FdWboE2
+JxhP7JsowtS013wMPgwr38oE18aO6lhOqKSlGBxsRZijQdEt0sdtjRnxrXm3gT+9
+BoInLRBYBbV4Bbkv2wxrkJB+FFk4u5QkE+XRnRTf04JNRvCAOVIyD+OEsnpD8l7e
+Xz8d3eOyG6ChKiMDbi4BFYdcpnV1x5dhvt6G3NRI270qv0pV2uh9UPu0gBe4lL8B
+PeraunzgWGcXuVjgiIZGZ2ydEEdYMtA1fHkqkKJaEBEjNa0vzORKW6fIJ/KD3l67
+Xnfn6KVuY8INXWHQjNJsWiEOyiijzirplcdIz5ZvHZIlyMbGwcEMBawmxNJ10uEq
+Z8A9W6Wa6897GqidFEXlD6CaZd4vKL3Ob5Rmg0gp2OpljK+T2WSfVVcmv2/LNzGZ
+o2C7HK2JNDJiuEMhBnIMoVxtRsX6Kc8w3onccVvdtjc+31D1uAclJuW8tf48ArO3
++L5DwYcRlJ4jbBeKuIonDFRH8KmzwICMoCfrHRnjB453cMor9H124HhnAgMBAAGj
+YzBhMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFE1FwWg4u3OpaaEg5+31IqEj
+FNeeMB8GA1UdIwQYMBaAFE1FwWg4u3OpaaEg5+31IqEjFNeeMA4GA1UdDwEB/wQE
+AwIBhjANBgkqhkiG9w0BAQUFAAOCAgEAZ2sGuV9FOypLM7PmG2tZTiLMubekJcmn
+xPBUlgtk87FYT15R/LKXeydlwuXK5w0MJXti4/qftIe3RUavg6WXSIylvfEWK5t2
+LHo1YGwRgJfMqZJS5ivmae2p+DYtLHe/YUjRYwu5W1LtGLBDQiKmsXeu3mnFzccc
+obGlHBD7GL4acN3Bkku+KVqdPzW+5X1R+FXgJXUjhx5c3LqdsKyzadsXg8n33gy8
+CNyRnqjQ1xU3c6U1uPx+xURABsPr+CKAXEfOAuMRn0T//ZoyzH1kUQ7rVyZ2OuMe
+IjzCpjbdGe+n/BLzJsBZMYVMnNjP36TMzCmT/5RtdlwTCJfy7aULTd3oyWgOZtMA
+DjMSW7yV5TKQqLPGbIOtd+6Lfn6xqavT4fG2wLHqiMDn05DpKJKUe2h7lyoKZy2F
+AjgQ5ANh1NolNscIWC2hp1GvMApJ9aZphwctREZ2jirlmjvXGKL8nDgQzMY70rUX
+Om/9riW99XJZZLF0KjhfGEzfz3EEWjbUvy+ZnOjZurGV5gJLIaFb1cFPj65pbVPb
+AZO1XB4Y3WRayhgoPmMEEf0cjQAPuDffZ4qdZqkCapH/E8ovXYO8h5Ns3CRRFgQl
+Zvqz2cK6Kb6aSDiCmfS/O0oxGfm/jiEzFMpPVF/7zvuPcX/9XhmgD0uRuMRUvAaw
+RY8mkaKO/qk=
+-----END CERTIFICATE-----
+
+# Issuer: CN=AAA Certificate Services O=Comodo CA Limited
+# Subject: CN=AAA Certificate Services O=Comodo CA Limited
+# Label: "Comodo AAA Services root"
+# Serial: 1
+# MD5 Fingerprint: 49:79:04:b0:eb:87:19:ac:47:b0:bc:11:51:9b:74:d0
+# SHA1 Fingerprint: d1:eb:23:a4:6d:17:d6:8f:d9:25:64:c2:f1:f1:60:17:64:d8:e3:49
+# SHA256 Fingerprint: d7:a7:a0:fb:5d:7e:27:31:d7:71:e9:48:4e:bc:de:f7:1d:5f:0c:3e:0a:29:48:78:2b:c8:3e:e0:ea:69:9e:f4
+-----BEGIN CERTIFICATE-----
+MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
+MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
+GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
+YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
+MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
+BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
+GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
+ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
+BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
+3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
+YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
+rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
+ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
+oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
+MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
+QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
+b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
+AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
+GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
+Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
+G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
+l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
+smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
+-----END CERTIFICATE-----
+
+# Issuer: CN=Secure Certificate Services O=Comodo CA Limited
+# Subject: CN=Secure Certificate Services O=Comodo CA Limited
+# Label: "Comodo Secure Services root"
+# Serial: 1
+# MD5 Fingerprint: d3:d9:bd:ae:9f:ac:67:24:b3:c8:1b:52:e1:b9:a9:bd
+# SHA1 Fingerprint: 4a:65:d5:f4:1d:ef:39:b8:b8:90:4a:4a:d3:64:81:33:cf:c7:a1:d1
+# SHA256 Fingerprint: bd:81:ce:3b:4f:65:91:d1:1a:67:b5:fc:7a:47:fd:ef:25:52:1b:f9:aa:4e:18:b9:e3:df:2e:34:a7:80:3b:e8
+-----BEGIN CERTIFICATE-----
+MIIEPzCCAyegAwIBAgIBATANBgkqhkiG9w0BAQUFADB+MQswCQYDVQQGEwJHQjEb
+MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
+GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEkMCIGA1UEAwwbU2VjdXJlIENlcnRp
+ZmljYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVow
+fjELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
+A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxJDAiBgNV
+BAMMG1NlY3VyZSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEB
+BQADggEPADCCAQoCggEBAMBxM4KK0HDrc4eCQNUd5MvJDkKQ+d40uaG6EfQlhfPM
+cm3ye5drswfxdySRXyWP9nQ95IDC+DwN879A6vfIUtFyb+/Iq0G4bi4XKpVpDM3S
+HpR7LZQdqnXXs5jLrLxkU0C8j6ysNstcrbvd4JQX7NFc0L/vpZXJkMWwrPsbQ996
+CF23uPJAGysnnlDOXmWCiIxe004MeuoIkbY2qitC++rCoznl2yY4rYsK7hljxxwk
+3wN42ubqwUcaCwtGCd0C/N7Lh1/XMGNooa7cMqG6vv5Eq2i2pRcV/b3Vp6ea5EQz
+6YiO/O1R65NxTq0B50SOqy3LqP4BSUjwwN3HaNiS/j0CAwEAAaOBxzCBxDAdBgNV
+HQ4EFgQUPNiTiMLAggnMAZkGkyDpnnAJY08wDgYDVR0PAQH/BAQDAgEGMA8GA1Ud
+EwEB/wQFMAMBAf8wgYEGA1UdHwR6MHgwO6A5oDeGNWh0dHA6Ly9jcmwuY29tb2Rv
+Y2EuY29tL1NlY3VyZUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDmgN6A1hjNodHRw
+Oi8vY3JsLmNvbW9kby5uZXQvU2VjdXJlQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmww
+DQYJKoZIhvcNAQEFBQADggEBAIcBbSMdflsXfcFhMs+P5/OKlFlm4J4oqF7Tt/Q0
+5qo5spcWxYJvMqTpjOev/e/C6LlLqqP05tqNZSH7uoDrJiiFGv45jN5bBAS0VPmj
+Z55B+glSzAVIqMk/IQQezkhr/IXownuvf7fM+F86/TXGDe+X3EyrEeFryzHRbPtI
+gKvcnDe4IRRLDXE97IMzbtFuMhbsmMcWi1mmNKsFVy2T96oTy9IT4rcuO81rUBcJ
+aD61JlfutuC23bkpgHl9j6PwpCikFcSF9CfUa7/lXORlAnZUtOM3ZiTTGWHIUhDl
+izeauan5Hb/qmZJhlv8BzaFfDbxxvA6sCx1HRR3B7Hzs/Sk=
+-----END CERTIFICATE-----
+
+# Issuer: CN=Trusted Certificate Services O=Comodo CA Limited
+# Subject: CN=Trusted Certificate Services O=Comodo CA Limited
+# Label: "Comodo Trusted Services root"
+# Serial: 1
+# MD5 Fingerprint: 91:1b:3f:6e:cd:9e:ab:ee:07:fe:1f:71:d2:b3:61:27
+# SHA1 Fingerprint: e1:9f:e3:0e:8b:84:60:9e:80:9b:17:0d:72:a8:c5:ba:6e:14:09:bd
+# SHA256 Fingerprint: 3f:06:e5:56:81:d4:96:f5:be:16:9e:b5:38:9f:9f:2b:8f:f6:1e:17:08:df:68:81:72:48:49:cd:5d:27:cb:69
+-----BEGIN CERTIFICATE-----
+MIIEQzCCAyugAwIBAgIBATANBgkqhkiG9w0BAQUFADB/MQswCQYDVQQGEwJHQjEb
+MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
+GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDElMCMGA1UEAwwcVHJ1c3RlZCBDZXJ0
+aWZpY2F0ZSBTZXJ2aWNlczAeFw0wNDAxMDEwMDAwMDBaFw0yODEyMzEyMzU5NTla
+MH8xCzAJBgNVBAYTAkdCMRswGQYDVQQIDBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAO
+BgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoMEUNvbW9kbyBDQSBMaW1pdGVkMSUwIwYD
+VQQDDBxUcnVzdGVkIENlcnRpZmljYXRlIFNlcnZpY2VzMIIBIjANBgkqhkiG9w0B
+AQEFAAOCAQ8AMIIBCgKCAQEA33FvNlhTWvI2VFeAxHQIIO0Yfyod5jWaHiWsnOWW
+fnJSoBVC21ndZHoa0Lh73TkVvFVIxO06AOoxEbrycXQaZ7jPM8yoMa+j49d/vzMt
+TGo87IvDktJTdyR0nAducPy9C1t2ul/y/9c3S0pgePfw+spwtOpZqqPOSC+pw7IL
+fhdyFgymBwwbOM/JYrc/oJOlh0Hyt3BAd9i+FHzjqMB6juljatEPmsbS9Is6FARW
+1O24zG71++IsWL1/T2sr92AkWCTOJu80kTrV44HQsvAEAtdbtz6SrGsSivnkBbA7
+kUlcsutT6vifR4buv5XAwAaf0lteERv0xwQ1KdJVXOTt6wIDAQABo4HJMIHGMB0G
+A1UdDgQWBBTFe1i97doladL3WRaoszLAeydb9DAOBgNVHQ8BAf8EBAMCAQYwDwYD
+VR0TAQH/BAUwAwEB/zCBgwYDVR0fBHwwejA8oDqgOIY2aHR0cDovL2NybC5jb21v
+ZG9jYS5jb20vVHJ1c3RlZENlcnRpZmljYXRlU2VydmljZXMuY3JsMDqgOKA2hjRo
+dHRwOi8vY3JsLmNvbW9kby5uZXQvVHJ1c3RlZENlcnRpZmljYXRlU2VydmljZXMu
+Y3JsMA0GCSqGSIb3DQEBBQUAA4IBAQDIk4E7ibSvuIQSTI3S8NtwuleGFTQQuS9/
+HrCoiWChisJ3DFBKmwCL2Iv0QeLQg4pKHBQGsKNoBXAxMKdTmw7pSqBYaWcOrp32
+pSxBvzwGa+RZzG0Q8ZZvH9/0BAKkn0U+yNj6NkZEUD+Cl5EfKNsYEYwq5GWDVxIS
+jBc/lDb+XbDABHcTuPQV1T84zJQ6VdCsmPW6AF/ghhmBeC8owH7TzEIK9a5QoNE+
+xqFx7D+gIIxmOom0jtTYsU0lR+4viMi14QVFwL4Ucd56/Y57fU0IlqUSc/Atyjcn
+dBInTMu2l+nZrghtWjlA3QVHdWpaIbOjGM9O9y5Xt5hwXsjEeLBi
+-----END CERTIFICATE-----
+
+# Issuer: CN=UTN - DATACorp SGC O=The USERTRUST Network OU=http://www.usertrust.com
+# Subject: CN=UTN - DATACorp SGC O=The USERTRUST Network OU=http://www.usertrust.com
+# Label: "UTN DATACorp SGC Root CA"
+# Serial: 91374294542884689855167577680241077609
+# MD5 Fingerprint: b3:a5:3e:77:21:6d:ac:4a:c0:c9:fb:d5:41:3d:ca:06
+# SHA1 Fingerprint: 58:11:9f:0e:12:82:87:ea:50:fd:d9:87:45:6f:4f:78:dc:fa:d6:d4
+# SHA256 Fingerprint: 85:fb:2f:91:dd:12:27:5a:01:45:b6:36:53:4f:84:02:4a:d6:8b:69:b8:ee:88:68:4f:f7:11:37:58:05:b3:48
+-----BEGIN CERTIFICATE-----
+MIIEXjCCA0agAwIBAgIQRL4Mi1AAIbQR0ypoBqmtaTANBgkqhkiG9w0BAQUFADCB
+kzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAlVUMRcwFQYDVQQHEw5TYWx0IExha2Ug
+Q2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMSEwHwYDVQQLExho
+dHRwOi8vd3d3LnVzZXJ0cnVzdC5jb20xGzAZBgNVBAMTElVUTiAtIERBVEFDb3Jw
+IFNHQzAeFw05OTA2MjQxODU3MjFaFw0xOTA2MjQxOTA2MzBaMIGTMQswCQYDVQQG
+EwJVUzELMAkGA1UECBMCVVQxFzAVBgNVBAcTDlNhbHQgTGFrZSBDaXR5MR4wHAYD
+VQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxITAfBgNVBAsTGGh0dHA6Ly93d3cu
+dXNlcnRydXN0LmNvbTEbMBkGA1UEAxMSVVROIC0gREFUQUNvcnAgU0dDMIIBIjAN
+BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3+5YEKIrblXEjr8uRgnn4AgPLit6
+E5Qbvfa2gI5lBZMAHryv4g+OGQ0SR+ysraP6LnD43m77VkIVni5c7yPeIbkFdicZ
+D0/Ww5y0vpQZY/KmEQrrU0icvvIpOxboGqBMpsn0GFlowHDyUwDAXlCCpVZvNvlK
+4ESGoE1O1kduSUrLZ9emxAW5jh70/P/N5zbgnAVssjMiFdC04MwXwLLA9P4yPykq
+lXvY8qdOD1R8oQ2AswkDwf9c3V6aPryuvEeKaq5xyh+xKrhfQgUL7EYw0XILyulW
+bfXv33i+Ybqypa4ETLyorGkVl73v67SMvzX41MPRKA5cOp9wGDMgd8SirwIDAQAB
+o4GrMIGoMAsGA1UdDwQEAwIBxjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRT
+MtGzz3/64PGgXYVOktKeRR20TzA9BgNVHR8ENjA0MDKgMKAuhixodHRwOi8vY3Js
+LnVzZXJ0cnVzdC5jb20vVVROLURBVEFDb3JwU0dDLmNybDAqBgNVHSUEIzAhBggr
+BgEFBQcDAQYKKwYBBAGCNwoDAwYJYIZIAYb4QgQBMA0GCSqGSIb3DQEBBQUAA4IB
+AQAnNZcAiosovcYzMB4p/OL31ZjUQLtgyr+rFywJNn9Q+kHcrpY6CiM+iVnJowft
+Gzet/Hy+UUla3joKVAgWRcKZsYfNjGjgaQPpxE6YsjuMFrMOoAyYUJuTqXAJyCyj
+j98C5OBxOvG0I3KgqgHf35g+FFCgMSa9KOlaMCZ1+XtgHI3zzVAmbQQnmt/VDUVH
+KWss5nbZqSl9Mt3JNjy9rjXxEZ4du5A/EkdOjtd+D2JzHVImOBwYSf0wdJrE5SIv
+2MCN7ZF6TACPcn9d2t0bi0Vr591pl6jFVkwPDPafepE39peC4N1xaf92P2BNPM/3
+mfnGV/TJVTl4uix5yaaIK/QI
+-----END CERTIFICATE-----
+
+# Issuer: CN=UTN-USERFirst-Hardware O=The USERTRUST Network OU=http://www.usertrust.com
+# Subject: CN=UTN-USERFirst-Hardware O=The USERTRUST Network OU=http://www.usertrust.com
+# Label: "UTN USERFirst Hardware Root CA"
+# Serial: 91374294542884704022267039221184531197
+# MD5 Fingerprint: 4c:56:41:e5:0d:bb:2b:e8:ca:a3:ed:18:08:ad:43:39
+# SHA1 Fingerprint: 04:83:ed:33:99:ac:36:08:05:87:22:ed:bc:5e:46:00:e3:be:f9:d7
+# SHA256 Fingerprint: 6e:a5:47:41:d0:04:66:7e:ed:1b:48:16:63:4a:a3:a7:9e:6e:4b:96:95:0f:82:79:da:fc:8d:9b:d8:81:21:37
+-----BEGIN CERTIFICATE-----
+MIIEdDCCA1ygAwIBAgIQRL4Mi1AAJLQR0zYq/mUK/TANBgkqhkiG9w0BAQUFADCB
+lzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAlVUMRcwFQYDVQQHEw5TYWx0IExha2Ug
+Q2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMSEwHwYDVQQLExho
+dHRwOi8vd3d3LnVzZXJ0cnVzdC5jb20xHzAdBgNVBAMTFlVUTi1VU0VSRmlyc3Qt
+SGFyZHdhcmUwHhcNOTkwNzA5MTgxMDQyWhcNMTkwNzA5MTgxOTIyWjCBlzELMAkG
+A1UEBhMCVVMxCzAJBgNVBAgTAlVUMRcwFQYDVQQHEw5TYWx0IExha2UgQ2l0eTEe
+MBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMSEwHwYDVQQLExhodHRwOi8v
+d3d3LnVzZXJ0cnVzdC5jb20xHzAdBgNVBAMTFlVUTi1VU0VSRmlyc3QtSGFyZHdh
+cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCx98M4P7Sof885glFn
+0G2f0v9Y8+efK+wNiVSZuTiZFvfgIXlIwrthdBKWHTxqctU8EGc6Oe0rE81m65UJ
+M6Rsl7HoxuzBdXmcRl6Nq9Bq/bkqVRcQVLMZ8Jr28bFdtqdt++BxF2uiiPsA3/4a
+MXcMmgF6sTLjKwEHOG7DpV4jvEWbe1DByTCP2+UretNb+zNAHqDVmBe8i4fDidNd
+oI6yqqr2jmmIBsX6iSHzCJ1pLgkzmykNRg+MzEk0sGlRvfkGzWitZky8PqxhvQqI
+DsjfPe58BEydCl5rkdbux+0ojatNh4lz0G6k0B4WixThdkQDf2Os5M1JnMWS9Ksy
+oUhbAgMBAAGjgbkwgbYwCwYDVR0PBAQDAgHGMA8GA1UdEwEB/wQFMAMBAf8wHQYD
+VR0OBBYEFKFyXyYbKJhDlV0HN9WFlp1L0sNFMEQGA1UdHwQ9MDswOaA3oDWGM2h0
+dHA6Ly9jcmwudXNlcnRydXN0LmNvbS9VVE4tVVNFUkZpcnN0LUhhcmR3YXJlLmNy
+bDAxBgNVHSUEKjAoBggrBgEFBQcDAQYIKwYBBQUHAwUGCCsGAQUFBwMGBggrBgEF
+BQcDBzANBgkqhkiG9w0BAQUFAAOCAQEARxkP3nTGmZev/K0oXnWO6y1n7k57K9cM
+//bey1WiCuFMVGWTYGufEpytXoMs61quwOQt9ABjHbjAbPLPSbtNk28Gpgoiskli
+CE7/yMgUsogWXecB5BKV5UU0s4tpvc+0hY91UZ59Ojg6FEgSxvunOxqNDYJAB+gE
+CJChicsZUN/KHAG8HQQZexB2lzvukJDKxA4fFm517zP4029bHpbj4HR3dHuKom4t
+3XbWOTCC8KucUvIqx69JXn7HaOWCgchqJ/kniCrVWFCVH/A7HFe7fRQ5YiuayZSS
+KqMiDP+JJn1fIytH1xUdqWqeUQ0qUZ6B+dQ7XnASfxAynB67nfhmqA==
+-----END CERTIFICATE-----
+
+# Issuer: CN=XRamp Global Certification Authority O=XRamp Security Services Inc OU=www.xrampsecurity.com
+# Subject: CN=XRamp Global Certification Authority O=XRamp Security Services Inc OU=www.xrampsecurity.com
+# Label: "XRamp Global CA Root"
+# Serial: 107108908803651509692980124233745014957
+# MD5 Fingerprint: a1:0b:44:b3:ca:10:d8:00:6e:9d:0f:d8:0f:92:0a:d1
+# SHA1 Fingerprint: b8:01:86:d1:eb:9c:86:a5:41:04:cf:30:54:f3:4c:52:b7:e5:58:c6
+# SHA256 Fingerprint: ce:cd:dc:90:50:99:d8:da:df:c5:b1:d2:09:b7:37:cb:e2:c1:8c:fb:2c:10:c0:ff:0b:cf:0d:32:86:fc:1a:a2
+-----BEGIN CERTIFICATE-----
+MIIEMDCCAxigAwIBAgIQUJRs7Bjq1ZxN1ZfvdY+grTANBgkqhkiG9w0BAQUFADCB
+gjELMAkGA1UEBhMCVVMxHjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEk
+MCIGA1UEChMbWFJhbXAgU2VjdXJpdHkgU2VydmljZXMgSW5jMS0wKwYDVQQDEyRY
+UmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQxMTAxMTcx
+NDA0WhcNMzUwMTAxMDUzNzE5WjCBgjELMAkGA1UEBhMCVVMxHjAcBgNVBAsTFXd3
+dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2VjdXJpdHkgU2Vy
+dmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBB
+dXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYJB69FbS6
+38eMpSe2OAtp87ZOqCwuIR1cRN8hXX4jdP5efrRKt6atH67gBhbim1vZZ3RrXYCP
+KZ2GG9mcDZhtdhAoWORlsH9KmHmf4MMxfoArtYzAQDsRhtDLooY2YKTVMIJt2W7Q
+DxIEM5dfT2Fa8OT5kavnHTu86M/0ay00fOJIYRyO82FEzG+gSqmUsE3a56k0enI4
+qEHMPJQRfevIpoy3hsvKMzvZPTeL+3o+hiznc9cKV6xkmxnr9A8ECIqsAxcZZPRa
+JSKNNCyy9mgdEm3Tih4U2sSPpuIjhdV6Db1q4Ons7Be7QhtnqiXtRYMh/MHJfNVi
+PvryxS3T/dRlAgMBAAGjgZ8wgZwwEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0P
+BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMZPoj0GY4QJnM5i5ASs
+jVy16bYbMDYGA1UdHwQvMC0wK6ApoCeGJWh0dHA6Ly9jcmwueHJhbXBzZWN1cml0
+eS5jb20vWEdDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQEwDQYJKoZIhvcNAQEFBQAD
+ggEBAJEVOQMBG2f7Shz5CmBbodpNl2L5JFMn14JkTpAuw0kbK5rc/Kh4ZzXxHfAR
+vbdI4xD2Dd8/0sm2qlWkSLoC295ZLhVbO50WfUfXN+pfTXYSNrsf16GBBEYgoyxt
+qZ4Bfj8pzgCT3/3JknOJiWSe5yvkHJEs0rnOfc5vMZnT5r7SHpDwCRR5XCOrTdLa
+IR9NmXmd4c8nnxCbHIgNsIpkQTG4DmyQJKSbXHGPurt+HBvbaoAPIbzp26a3QPSy
+i6mx5O+aGtA9aZnuqCij4Tyz8LIRnM98QObd50N9otg6tamN8jSZxNQQ4Qb9CYQQ
+O+7ETPTsJ3xCwnR8gooJybQDJbw=
+-----END CERTIFICATE-----
+
+# Issuer: O=The Go Daddy Group, Inc. OU=Go Daddy Class 2 Certification Authority
+# Subject: O=The Go Daddy Group, Inc. OU=Go Daddy Class 2 Certification Authority
+# Label: "Go Daddy Class 2 CA"
+# Serial: 0
+# MD5 Fingerprint: 91:de:06:25:ab:da:fd:32:17:0c:bb:25:17:2a:84:67
+# SHA1 Fingerprint: 27:96:ba:e6:3f:18:01:e2:77:26:1b:a0:d7:77:70:02:8f:20:ee:e4
+# SHA256 Fingerprint: c3:84:6b:f2:4b:9e:93:ca:64:27:4c:0e:c6:7c:1e:cc:5e:02:4f:fc:ac:d2:d7:40:19:35:0e:81:fe:54:6a:e4
+-----BEGIN CERTIFICATE-----
+MIIEADCCAuigAwIBAgIBADANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEh
+MB8GA1UEChMYVGhlIEdvIERhZGR5IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBE
+YWRkeSBDbGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA0MDYyOTE3
+MDYyMFoXDTM0MDYyOTE3MDYyMFowYzELMAkGA1UEBhMCVVMxITAfBgNVBAoTGFRo
+ZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28gRGFkZHkgQ2xhc3Mg
+MiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQADggEN
+ADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XCA
+PVYYYwhv2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux6w
+wdhFJ2+qN1j3hybX2C32qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLOtXi
+EqITLdiOr18SPaAIBQi2XKVlOARFmR6jYGB0xUGlcmIbYsUfb18aQr4CUWWoriMY
+avx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmYvLEHZ6IVDd2gWMZEewo+
+YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjgcAwgb0wHQYDVR0OBBYEFNLE
+sNKR1EwRcbNhyz2h/t2oatTjMIGNBgNVHSMEgYUwgYKAFNLEsNKR1EwRcbNhyz2h
+/t2oatTjoWekZTBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYVGhlIEdvIERhZGR5
+IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRpZmlj
+YXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD
+ggEBADJL87LKPpH8EsahB4yOd6AzBhRckB4Y9wimPQoZ+YeAEW5p5JYXMP80kWNy
+OO7MHAGjHZQopDH2esRU1/blMVgDoszOYtuURXO1v0XJJLXVggKtI3lpjbi2Tc7P
+TMozI+gciKqdi0FuFskg5YmezTvacPd+mSYgFFQlq25zheabIZ0KbIIOqPjCDPoQ
+HmyW74cNxA9hi63ugyuV+I6ShHI56yDqg+2DzZduCLzrTia2cyvk0/ZM/iZx4mER
+dEr/VxqHD3VILs9RaRegAhJhldXRQLIQTO7ErBBDpqWeCtWVYpoNz4iCxTIM5Cuf
+ReYNnyicsbkqWletNw+vHX/bvZ8=
+-----END CERTIFICATE-----
+
+# Issuer: O=Starfield Technologies, Inc. OU=Starfield Class 2 Certification Authority
+# Subject: O=Starfield Technologies, Inc. OU=Starfield Class 2 Certification Authority
+# Label: "Starfield Class 2 CA"
+# Serial: 0
+# MD5 Fingerprint: 32:4a:4b:bb:c8:63:69:9b:be:74:9a:c6:dd:1d:46:24
+# SHA1 Fingerprint: ad:7e:1c:28:b0:64:ef:8f:60:03:40:20:14:c3:d0:e3:37:0e:b5:8a
+# SHA256 Fingerprint: 14:65:fa:20:53:97:b8:76:fa:a6:f0:a9:95:8e:55:90:e4:0f:cc:7f:aa:4f:b7:c2:c8:67:75:21:fb:5f:b6:58
+-----BEGIN CERTIFICATE-----
+MIIEDzCCAvegAwIBAgIBADANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJVUzEl
+MCMGA1UEChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMp
+U3RhcmZpZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQw
+NjI5MTczOTE2WhcNMzQwNjI5MTczOTE2WjBoMQswCQYDVQQGEwJVUzElMCMGA1UE
+ChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMpU3RhcmZp
+ZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEgMA0GCSqGSIb3
+DQEBAQUAA4IBDQAwggEIAoIBAQC3Msj+6XGmBIWtDBFk385N78gDGIc/oav7PKaf
+8MOh2tTYbitTkPskpD6E8J7oX+zlJ0T1KKY/e97gKvDIr1MvnsoFAZMej2YcOadN
++lq2cwQlZut3f+dZxkqZJRRU6ybH838Z1TBwj6+wRir/resp7defqgSHo9T5iaU0
+X9tDkYI22WY8sbi5gv2cOj4QyDvvBmVmepsZGD3/cVE8MC5fvj13c7JdBmzDI1aa
+K4UmkhynArPkPw2vCHmCuDY96pzTNbO8acr1zJ3o/WSNF4Azbl5KXZnJHoe0nRrA
+1W4TNSNe35tfPe/W93bC6j67eA0cQmdrBNj41tpvi/JEoAGrAgEDo4HFMIHCMB0G
+A1UdDgQWBBS/X7fRzt0fhvRbVazc1xDCDqmI5zCBkgYDVR0jBIGKMIGHgBS/X7fR
+zt0fhvRbVazc1xDCDqmI56FspGowaDELMAkGA1UEBhMCVVMxJTAjBgNVBAoTHFN0
+YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAsTKVN0YXJmaWVsZCBD
+bGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8w
+DQYJKoZIhvcNAQEFBQADggEBAAWdP4id0ckaVaGsafPzWdqbAYcaT1epoXkJKtv3
+L7IezMdeatiDh6GX70k1PncGQVhiv45YuApnP+yz3SFmH8lU+nLMPUxA2IGvd56D
+eruix/U0F47ZEUD0/CwqTRV/p2JdLiXTAAsgGh1o+Re49L2L7ShZ3U0WixeDyLJl
+xy16paq8U4Zt3VekyvggQQto8PT7dL5WXXp59fkdheMtlb71cZBDzI0fmgAKhynp
+VSJYACPq4xJDKVtHCN2MQWplBqjlIapBtJUhlbl90TSrE9atvNziPTnNvT51cKEY
+WQPJIrSPnNVeKtelttQKbfi3QBFGmh95DmK/D5fs4C8fF5Q=
+-----END CERTIFICATE-----
+
+# Issuer: CN=StartCom Certification Authority O=StartCom Ltd. OU=Secure Digital Certificate Signing
+# Subject: CN=StartCom Certification Authority O=StartCom Ltd. OU=Secure Digital Certificate Signing
+# Label: "StartCom Certification Authority"
+# Serial: 1
+# MD5 Fingerprint: 22:4d:8f:8a:fc:f7:35:c2:bb:57:34:90:7b:8b:22:16
+# SHA1 Fingerprint: 3e:2b:f7:f2:03:1b:96:f3:8c:e6:c4:d8:a8:5d:3e:2d:58:47:6a:0f
+# SHA256 Fingerprint: c7:66:a9:be:f2:d4:07:1c:86:3a:31:aa:49:20:e8:13:b2:d1:98:60:8c:b7:b7:cf:e2:11:43:b8:36:df:09:ea
+-----BEGIN CERTIFICATE-----
+MIIHyTCCBbGgAwIBAgIBATANBgkqhkiG9w0BAQUFADB9MQswCQYDVQQGEwJJTDEW
+MBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0YWwg
+Q2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMgU3RhcnRDb20gQ2VydGlmaWNh
+dGlvbiBBdXRob3JpdHkwHhcNMDYwOTE3MTk0NjM2WhcNMzYwOTE3MTk0NjM2WjB9
+MQswCQYDVQQGEwJJTDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMi
+U2VjdXJlIERpZ2l0YWwgQ2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMgU3Rh
+cnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUA
+A4ICDwAwggIKAoICAQDBiNsJvGxGfHiflXu1M5DycmLWwTYgIiRezul38kMKogZk
+pMyONvg45iPwbm2xPN1yo4UcodM9tDMr0y+v/uqwQVlntsQGfQqedIXWeUyAN3rf
+OQVSWff0G0ZDpNKFhdLDcfN1YjS6LIp/Ho/u7TTQEceWzVI9ujPW3U3eCztKS5/C
+Ji/6tRYccjV3yjxd5srhJosaNnZcAdt0FCX+7bWgiA/deMotHweXMAEtcnn6RtYT
+Kqi5pquDSR3l8u/d5AGOGAqPY1MWhWKpDhk6zLVmpsJrdAfkK+F2PrRt2PZE4XNi
+HzvEvqBTViVsUQn3qqvKv3b9bZvzndu/PWa8DFaqr5hIlTpL36dYUNk4dalb6kMM
+Av+Z6+hsTXBbKWWc3apdzK8BMewM69KN6Oqce+Zu9ydmDBpI125C4z/eIT574Q1w
++2OqqGwaVLRcJXrJosmLFqa7LH4XXgVNWG4SHQHuEhANxjJ/GP/89PrNbpHoNkm+
+Gkhpi8KWTRoSsmkXwQqQ1vp5Iki/untp+HDH+no32NgN0nZPV/+Qt+OR0t3vwmC3
+Zzrd/qqc8NSLf3Iizsafl7b4r4qgEKjZ+xjGtrVcUjyJthkqcwEKDwOzEmDyei+B
+26Nu/yYwl/WL3YlXtq09s68rxbd2AvCl1iuahhQqcvbjM4xdCUsT37uMdBNSSwID
+AQABo4ICUjCCAk4wDAYDVR0TBAUwAwEB/zALBgNVHQ8EBAMCAa4wHQYDVR0OBBYE
+FE4L7xqkQFulF2mHMMo0aEPQQa7yMGQGA1UdHwRdMFswLKAqoCiGJmh0dHA6Ly9j
+ZXJ0LnN0YXJ0Y29tLm9yZy9zZnNjYS1jcmwuY3JsMCugKaAnhiVodHRwOi8vY3Js
+LnN0YXJ0Y29tLm9yZy9zZnNjYS1jcmwuY3JsMIIBXQYDVR0gBIIBVDCCAVAwggFM
+BgsrBgEEAYG1NwEBATCCATswLwYIKwYBBQUHAgEWI2h0dHA6Ly9jZXJ0LnN0YXJ0
+Y29tLm9yZy9wb2xpY3kucGRmMDUGCCsGAQUFBwIBFilodHRwOi8vY2VydC5zdGFy
+dGNvbS5vcmcvaW50ZXJtZWRpYXRlLnBkZjCB0AYIKwYBBQUHAgIwgcMwJxYgU3Rh
+cnQgQ29tbWVyY2lhbCAoU3RhcnRDb20pIEx0ZC4wAwIBARqBl0xpbWl0ZWQgTGlh
+YmlsaXR5LCByZWFkIHRoZSBzZWN0aW9uICpMZWdhbCBMaW1pdGF0aW9ucyogb2Yg
+dGhlIFN0YXJ0Q29tIENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFBvbGljeSBhdmFp
+bGFibGUgYXQgaHR0cDovL2NlcnQuc3RhcnRjb20ub3JnL3BvbGljeS5wZGYwEQYJ
+YIZIAYb4QgEBBAQDAgAHMDgGCWCGSAGG+EIBDQQrFilTdGFydENvbSBGcmVlIFNT
+TCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTANBgkqhkiG9w0BAQUFAAOCAgEAFmyZ
+9GYMNPXQhV59CuzaEE44HF7fpiUFS5Eyweg78T3dRAlbB0mKKctmArexmvclmAk8
+jhvh3TaHK0u7aNM5Zj2gJsfyOZEdUauCe37Vzlrk4gNXcGmXCPleWKYK34wGmkUW
+FjgKXlf2Ysd6AgXmvB618p70qSmD+LIU424oh0TDkBreOKk8rENNZEXO3SipXPJz
+ewT4F+irsfMuXGRuczE6Eri8sxHkfY+BUZo7jYn0TZNmezwD7dOaHZrzZVD1oNB1
+ny+v8OqCQ5j4aZyJecRDjkZy42Q2Eq/3JR44iZB3fsNrarnDy0RLrHiQi+fHLB5L
+EUTINFInzQpdn4XBidUaePKVEFMy3YCEZnXZtWgo+2EuvoSoOMCZEoalHmdkrQYu
+L6lwhceWD3yJZfWOQ1QOq92lgDmUYMA0yZZwLKMS9R9Ie70cfmu3nZD0Ijuu+Pwq
+yvqCUqDvr0tVk+vBtfAii6w0TiYiBKGHLHVKt+V9E9e4DGTANtLJL4YSjCMJwRuC
+O3NJo2pXh5Tl1njFmUNj403gdy3hZZlyaQQaRwnmDwFWJPsfvw55qVguucQJAX6V
+um0ABj6y6koQOdjQK/W/7HW/lwLFCRsI3FU34oH7N4RDYiDK51ZLZer+bMEkkySh
+NOsF/5oirpt9P/FlUQqmMGqz9IgcgA38corog14=
+-----END CERTIFICATE-----
+
+# Issuer: CN=DigiCert Assured ID Root CA O=DigiCert Inc OU=www.digicert.com
+# Subject: CN=DigiCert Assured ID Root CA O=DigiCert Inc OU=www.digicert.com
+# Label: "DigiCert Assured ID Root CA"
+# Serial: 17154717934120587862167794914071425081
+# MD5 Fingerprint: 87:ce:0b:7b:2a:0e:49:00:e1:58:71:9b:37:a8:93:72
+# SHA1 Fingerprint: 05:63:b8:63:0d:62:d7:5a:bb:c8:ab:1e:4b:df:b5:a8:99:b2:4d:43
+# SHA256 Fingerprint: 3e:90:99:b5:01:5e:8f:48:6c:00:bc:ea:9d:11:1e:e7:21:fa:ba:35:5a:89:bc:f1:df:69:56:1e:3d:c6:32:5c
+-----BEGIN CERTIFICATE-----
+MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl
+MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
+d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv
+b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG
+EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl
+cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c
+JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP
+mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+
+wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4
+VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/
+AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB
+AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
+BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun
+pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC
+dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf
+fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm
+NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx
+H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe
++o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g==
+-----END CERTIFICATE-----
+
+# Issuer: CN=DigiCert Global Root CA O=DigiCert Inc OU=www.digicert.com
+# Subject: CN=DigiCert Global Root CA O=DigiCert Inc OU=www.digicert.com
+# Label: "DigiCert Global Root CA"
+# Serial: 10944719598952040374951832963794454346
+# MD5 Fingerprint: 79:e4:a9:84:0d:7d:3a:96:d7:c0:4f:e2:43:4c:89:2e
+# SHA1 Fingerprint: a8:98:5d:3a:65:e5:e5:c4:b2:d7:d6:6d:40:c6:dd:2f:b1:9c:54:36
+# SHA256 Fingerprint: 43:48:a0:e9:44:4c:78:cb:26:5e:05:8d:5e:89:44:b4:d8:4f:96:62:bd:26:db:25:7f:89:34:a4:43:c7:01:61
+-----BEGIN CERTIFICATE-----
+MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh
+MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
+d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD
+QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT
+MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
+b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG
+9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB
+CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97
+nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt
+43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P
+T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4
+gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO
+BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR
+TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw
+DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr
+hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg
+06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF
+PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls
+YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
+CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
+-----END CERTIFICATE-----
+
+# Issuer: CN=DigiCert High Assurance EV Root CA O=DigiCert Inc OU=www.digicert.com
+# Subject: CN=DigiCert High Assurance EV Root CA O=DigiCert Inc OU=www.digicert.com
+# Label: "DigiCert High Assurance EV Root CA"
+# Serial: 3553400076410547919724730734378100087
+# MD5 Fingerprint: d4:74:de:57:5c:39:b2:d3:9c:85:83:c5:c0:65:49:8a
+# SHA1 Fingerprint: 5f:b7:ee:06:33:e2:59:db:ad:0c:4c:9a:e6:d3:8f:1a:61:c7:dc:25
+# SHA256 Fingerprint: 74:31:e5:f4:c3:c1:ce:46:90:77:4f:0b:61:e0:54:40:88:3b:a9:a0:1e:d0:0b:a6:ab:d7:80:6e:d3:b1:18:cf
+-----BEGIN CERTIFICATE-----
+MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs
+MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
+d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j
+ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL
+MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3
+LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug
+RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm
++9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW
+PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM
+xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB
+Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3
+hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg
+EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF
+MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA
+FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec
+nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z
+eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF
+hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2
+Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe
+vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep
++OkuE6N36B9K
+-----END CERTIFICATE-----
+
+# Issuer: CN=GeoTrust Primary Certification Authority O=GeoTrust Inc.
+# Subject: CN=GeoTrust Primary Certification Authority O=GeoTrust Inc.
+# Label: "GeoTrust Primary Certification Authority"
+# Serial: 32798226551256963324313806436981982369
+# MD5 Fingerprint: 02:26:c3:01:5e:08:30:37:43:a9:d0:7d:cf:37:e6:bf
+# SHA1 Fingerprint: 32:3c:11:8e:1b:f7:b8:b6:52:54:e2:e2:10:0d:d6:02:90:37:f0:96
+# SHA256 Fingerprint: 37:d5:10:06:c5:12:ea:ab:62:64:21:f1:ec:8c:92:01:3f:c5:f8:2a:e9:8e:e5:33:eb:46:19:b8:de:b4:d0:6c
+-----BEGIN CERTIFICATE-----
+MIIDfDCCAmSgAwIBAgIQGKy1av1pthU6Y2yv2vrEoTANBgkqhkiG9w0BAQUFADBY
+MQswCQYDVQQGEwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjExMC8GA1UEAxMo
+R2VvVHJ1c3QgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNjEx
+MjcwMDAwMDBaFw0zNjA3MTYyMzU5NTlaMFgxCzAJBgNVBAYTAlVTMRYwFAYDVQQK
+Ew1HZW9UcnVzdCBJbmMuMTEwLwYDVQQDEyhHZW9UcnVzdCBQcmltYXJ5IENlcnRp
+ZmljYXRpb24gQXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
+AQEAvrgVe//UfH1nrYNke8hCUy3f9oQIIGHWAVlqnEQRr+92/ZV+zmEwu3qDXwK9
+AWbK7hWNb6EwnL2hhZ6UOvNWiAAxz9juapYC2e0DjPt1befquFUWBRaa9OBesYjA
+ZIVcFU2Ix7e64HXprQU9nceJSOC7KMgD4TCTZF5SwFlwIjVXiIrxlQqD17wxcwE0
+7e9GceBrAqg1cmuXm2bgyxx5X9gaBGgeRwLmnWDiNpcB3841kt++Z8dtd1k7j53W
+kBWUvEI0EME5+bEnPn7WinXFsq+W06Lem+SYvn3h6YGttm/81w7a4DSwDRp35+MI
+mO9Y+pyEtzavwt+s0vQQBnBxNQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4G
+A1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQULNVQQZcVi/CPNmFbSvtr2ZnJM5IwDQYJ
+KoZIhvcNAQEFBQADggEBAFpwfyzdtzRP9YZRqSa+S7iq8XEN3GHHoOo0Hnp3DwQ1
+6CePbJC/kRYkRj5KTs4rFtULUh38H2eiAkUxT87z+gOneZ1TatnaYzr4gNfTmeGl
+4b7UVXGYNTq+k+qurUKykG/g/CFNNWMziUnWm07Kx+dOCQD32sfvmWKZd7aVIl6K
+oKv0uHiYyjgZmclynnjNS6yvGaBzEi38wkG6gZHaFloxt/m0cYASSJlyc1pZU8Fj
+UjPtp8nSOQJw+uCxQmYpqptR7TBUIhRf2asdweSU8Pj1K/fqynhG1riR/aYNKxoU
+AT6A8EKglQdebc3MS6RFjasS6LPeWuWgfOgPIh1a6Vk=
+-----END CERTIFICATE-----
+
+# Issuer: CN=thawte Primary Root CA O=thawte, Inc. OU=Certification Services Division/(c) 2006 thawte, Inc. - For authorized use only
+# Subject: CN=thawte Primary Root CA O=thawte, Inc. OU=Certification Services Division/(c) 2006 thawte, Inc. - For authorized use only
+# Label: "thawte Primary Root CA"
+# Serial: 69529181992039203566298953787712940909
+# MD5 Fingerprint: 8c:ca:dc:0b:22:ce:f5:be:72:ac:41:1a:11:a8:d8:12
+# SHA1 Fingerprint: 91:c6:d6:ee:3e:8a:c8:63:84:e5:48:c2:99:29:5c:75:6c:81:7b:81
+# SHA256 Fingerprint: 8d:72:2f:81:a9:c1:13:c0:79:1d:f1:36:a2:96:6d:b2:6c:95:0a:97:1d:b4:6b:41:99:f4:ea:54:b7:8b:fb:9f
+-----BEGIN CERTIFICATE-----
+MIIEIDCCAwigAwIBAgIQNE7VVyDV7exJ9C/ON9srbTANBgkqhkiG9w0BAQUFADCB
+qTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5jLjEoMCYGA1UECxMf
+Q2VydGlmaWNhdGlvbiBTZXJ2aWNlcyBEaXZpc2lvbjE4MDYGA1UECxMvKGMpIDIw
+MDYgdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxHzAdBgNV
+BAMTFnRoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EwHhcNMDYxMTE3MDAwMDAwWhcNMzYw
+NzE2MjM1OTU5WjCBqTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5j
+LjEoMCYGA1UECxMfQ2VydGlmaWNhdGlvbiBTZXJ2aWNlcyBEaXZpc2lvbjE4MDYG
+A1UECxMvKGMpIDIwMDYgdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNl
+IG9ubHkxHzAdBgNVBAMTFnRoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EwggEiMA0GCSqG
+SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCsoPD7gFnUnMekz52hWXMJEEUMDSxuaPFs
+W0hoSVk3/AszGcJ3f8wQLZU0HObrTQmnHNK4yZc2AreJ1CRfBsDMRJSUjQJib+ta
+3RGNKJpchJAQeg29dGYvajig4tVUROsdB58Hum/u6f1OCyn1PoSgAfGcq/gcfomk
+6KHYcWUNo1F77rzSImANuVud37r8UVsLr5iy6S7pBOhih94ryNdOwUxkHt3Ph1i6
+Sk/KaAcdHJ1KxtUvkcx8cXIcxcBn6zL9yZJclNqFwJu/U30rCfSMnZEfl2pSy94J
+NqR32HuHUETVPm4pafs5SSYeCaWAe0At6+gnhcn+Yf1+5nyXHdWdAgMBAAGjQjBA
+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBR7W0XP
+r87Lev0xkhpqtvNG61dIUDANBgkqhkiG9w0BAQUFAAOCAQEAeRHAS7ORtvzw6WfU
+DW5FvlXok9LOAz/t2iWwHVfLHjp2oEzsUHboZHIMpKnxuIvW1oeEuzLlQRHAd9mz
+YJ3rG9XRbkREqaYB7FViHXe4XI5ISXycO1cRrK1zN44veFyQaEfZYGDm/Ac9IiAX
+xPcW6cTYcvnIc3zfFi8VqT79aie2oetaupgf1eNNZAqdE8hhuvU5HIe6uL17In/2
+/qxAeeWsEG89jxt5dovEN7MhGITlNgDrYyCZuen+MwS7QcjBAvlEYyCegc5C09Y/
+LHbTY5xZ3Y+m4Q6gLkH3LpVHz7z9M/P2C2F+fpErgUfCJzDupxBdN49cOSvkBPB7
+jVaMaA==
+-----END CERTIFICATE-----
+
+# Issuer: CN=VeriSign Class 3 Public Primary Certification Authority - G5 O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 2006 VeriSign, Inc. - For authorized use only
+# Subject: CN=VeriSign Class 3 Public Primary Certification Authority - G5 O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 2006 VeriSign, Inc. - For authorized use only
+# Label: "VeriSign Class 3 Public Primary Certification Authority - G5"
+# Serial: 33037644167568058970164719475676101450
+# MD5 Fingerprint: cb:17:e4:31:67:3e:e2:09:fe:45:57:93:f3:0a:fa:1c
+# SHA1 Fingerprint: 4e:b6:d5:78:49:9b:1c:cf:5f:58:1e:ad:56:be:3d:9b:67:44:a5:e5
+# SHA256 Fingerprint: 9a:cf:ab:7e:43:c8:d8:80:d0:6b:26:2a:94:de:ee:e4:b4:65:99:89:c3:d0:ca:f1:9b:af:64:05:e4:1a:b7:df
+-----BEGIN CERTIFICATE-----
+MIIE0zCCA7ugAwIBAgIQGNrRniZ96LtKIVjNzGs7SjANBgkqhkiG9w0BAQUFADCB
+yjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL
+ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJp
+U2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxW
+ZXJpU2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0
+aG9yaXR5IC0gRzUwHhcNMDYxMTA4MDAwMDAwWhcNMzYwNzE2MjM1OTU5WjCByjEL
+MAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZW
+ZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJpU2ln
+biwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJp
+U2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9y
+aXR5IC0gRzUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvJAgIKXo1
+nmAMqudLO07cfLw8RRy7K+D+KQL5VwijZIUVJ/XxrcgxiV0i6CqqpkKzj/i5Vbex
+t0uz/o9+B1fs70PbZmIVYc9gDaTY3vjgw2IIPVQT60nKWVSFJuUrjxuf6/WhkcIz
+SdhDY2pSS9KP6HBRTdGJaXvHcPaz3BJ023tdS1bTlr8Vd6Gw9KIl8q8ckmcY5fQG
+BO+QueQA5N06tRn/Arr0PO7gi+s3i+z016zy9vA9r911kTMZHRxAy3QkGSGT2RT+
+rCpSx4/VBEnkjWNHiDxpg8v+R70rfk/Fla4OndTRQ8Bnc+MUCH7lP59zuDMKz10/
+NIeWiu5T6CUVAgMBAAGjgbIwga8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E
+BAMCAQYwbQYIKwYBBQUHAQwEYTBfoV2gWzBZMFcwVRYJaW1hZ2UvZ2lmMCEwHzAH
+BgUrDgMCGgQUj+XTGoasjY5rw8+AatRIGCx7GS4wJRYjaHR0cDovL2xvZ28udmVy
+aXNpZ24uY29tL3ZzbG9nby5naWYwHQYDVR0OBBYEFH/TZafC3ey78DAJ80M5+gKv
+MzEzMA0GCSqGSIb3DQEBBQUAA4IBAQCTJEowX2LP2BqYLz3q3JktvXf2pXkiOOzE
+p6B4Eq1iDkVwZMXnl2YtmAl+X6/WzChl8gGqCBpH3vn5fJJaCGkgDdk+bW48DW7Y
+5gaRQBi5+MHt39tBquCWIMnNZBU4gcmU7qKEKQsTb47bDN0lAtukixlE0kF6BWlK
+WE9gyn6CagsCqiUXObXbf+eEZSqVir2G3l6BFoMtEMze/aiCKm0oHw0LxOXnGiYZ
+4fQRbxC1lfznQgUy286dUV4otp6F01vvpX1FQHKOtw5rDgb7MzVIcbidJ4vEZV8N
+hnacRHr2lVz2XTIIM6RUthg/aFzyQkqFOFSDX9HoLPKsEdao7WNq
+-----END CERTIFICATE-----
+
+# Issuer: CN=COMODO Certification Authority O=COMODO CA Limited
+# Subject: CN=COMODO Certification Authority O=COMODO CA Limited
+# Label: "COMODO Certification Authority"
+# Serial: 104350513648249232941998508985834464573
+# MD5 Fingerprint: 5c:48:dc:f7:42:72:ec:56:94:6d:1c:cc:71:35:80:75
+# SHA1 Fingerprint: 66:31:bf:9e:f7:4f:9e:b6:c9:d5:a6:0c:ba:6a:be:d1:f7:bd:ef:7b
+# SHA256 Fingerprint: 0c:2c:d6:3d:f7:80:6f:a3:99:ed:e8:09:11:6b:57:5b:f8:79:89:f0:65:18:f9:80:8c:86:05:03:17:8b:af:66
+-----BEGIN CERTIFICATE-----
+MIIEHTCCAwWgAwIBAgIQToEtioJl4AsC7j41AkblPTANBgkqhkiG9w0BAQUFADCB
+gTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
+A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNV
+BAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNjEyMDEwMDAw
+MDBaFw0yOTEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3Jl
+YXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01P
+RE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0
+aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3
+UcEbVASY06m/weaKXTuH+7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI
+2GqGd0S7WWaXUF601CxwRM/aN5VCaTwwxHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8
+Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV4EajcNxo2f8ESIl33rXp
++2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA1KGzqSX+
+DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5O
+nKVIrLsm9wIDAQABo4GOMIGLMB0GA1UdDgQWBBQLWOWLxkwVN6RAqTCpIb5HNlpW
+/zAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zBJBgNVHR8EQjBAMD6g
+PKA6hjhodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9DT01PRE9DZXJ0aWZpY2F0aW9u
+QXV0aG9yaXR5LmNybDANBgkqhkiG9w0BAQUFAAOCAQEAPpiem/Yb6dc5t3iuHXIY
+SdOH5EOC6z/JqvWote9VfCFSZfnVDeFs9D6Mk3ORLgLETgdxb8CPOGEIqB6BCsAv
+IC9Bi5HcSEW88cbeunZrM8gALTFGTO3nnc+IlP8zwFboJIYmuNg4ON8qa90SzMc/
+RxdMosIGlgnW2/4/PEZB31jiVg88O8EckzXZOFKs7sjsLjBOlDW0JB9LeGna8gI4
+zJVSk/BwJVmcIGfE7vmLV2H0knZ9P4SNVbfo5azV8fUZVqZa+5Acr5Pr5RzUZ5dd
+BA6+C4OmF4O5MBKgxTMVBbkN+8cFduPYSo38NBejxiEovjBFMR7HeL5YYTisO+IB
+ZQ==
+-----END CERTIFICATE-----
+
+# Issuer: CN=Network Solutions Certificate Authority O=Network Solutions L.L.C.
+# Subject: CN=Network Solutions Certificate Authority O=Network Solutions L.L.C.
+# Label: "Network Solutions Certificate Authority"
+# Serial: 116697915152937497490437556386812487904
+# MD5 Fingerprint: d3:f3:a6:16:c0:fa:6b:1d:59:b1:2d:96:4d:0e:11:2e
+# SHA1 Fingerprint: 74:f8:a3:c3:ef:e7:b3:90:06:4b:83:90:3c:21:64:60:20:e5:df:ce
+# SHA256 Fingerprint: 15:f0:ba:00:a3:ac:7a:f3:ac:88:4c:07:2b:10:11:a0:77:bd:77:c0:97:f4:01:64:b2:f8:59:8a:bd:83:86:0c
+-----BEGIN CERTIFICATE-----
+MIID5jCCAs6gAwIBAgIQV8szb8JcFuZHFhfjkDFo4DANBgkqhkiG9w0BAQUFADBi
+MQswCQYDVQQGEwJVUzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMu
+MTAwLgYDVQQDEydOZXR3b3JrIFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3Jp
+dHkwHhcNMDYxMjAxMDAwMDAwWhcNMjkxMjMxMjM1OTU5WjBiMQswCQYDVQQGEwJV
+UzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMuMTAwLgYDVQQDEydO
+ZXR3b3JrIFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwggEiMA0GCSqG
+SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkvH6SMG3G2I4rC7xGzuAnlt7e+foS0zwz
+c7MEL7xxjOWftiJgPl9dzgn/ggwbmlFQGiaJ3dVhXRncEg8tCqJDXRfQNJIg6nPP
+OCwGJgl6cvf6UDL4wpPTaaIjzkGxzOTVHzbRijr4jGPiFFlp7Q3Tf2vouAPlT2rl
+mGNpSAW+Lv8ztumXWWn4Zxmuk2GWRBXTcrA/vGp97Eh/jcOrqnErU2lBUzS1sLnF
+BgrEsEX1QV1uiUV7PTsmjHTC5dLRfbIR1PtYMiKagMnc/Qzpf14Dl847ABSHJ3A4
+qY5usyd2mFHgBeMhqxrVhSI8KbWaFsWAqPS7azCPL0YCorEMIuDTAgMBAAGjgZcw
+gZQwHQYDVR0OBBYEFCEwyfsA106Y2oeqKtCnLrFAMadMMA4GA1UdDwEB/wQEAwIB
+BjAPBgNVHRMBAf8EBTADAQH/MFIGA1UdHwRLMEkwR6BFoEOGQWh0dHA6Ly9jcmwu
+bmV0c29sc3NsLmNvbS9OZXR3b3JrU29sdXRpb25zQ2VydGlmaWNhdGVBdXRob3Jp
+dHkuY3JsMA0GCSqGSIb3DQEBBQUAA4IBAQC7rkvnt1frf6ott3NHhWrB5KUd5Oc8
+6fRZZXe1eltajSU24HqXLjjAV2CDmAaDn7l2em5Q4LqILPxFzBiwmZVRDuwduIj/
+h1AcgsLj4DKAv6ALR8jDMe+ZZzKATxcheQxpXN5eNK4CtSbqUN9/GGUsyfJj4akH
+/nxxH2szJGoeBfcFaMBqEssuXmHLrijTfsK0ZpEmXzwuJF/LWA/rKOyvEZbz3Htv
+wKeI8lN3s2Berq4o2jUsbzRF0ybh3uxbTydrFny9RAQYgrOJeRcQcT16ohZO9QHN
+pGxlaKFJdlxDydi8NmdspZS11My5vWo1ViHe2MPr+8ukYEywVaCge1ey
+-----END CERTIFICATE-----
+
+# Issuer: CN=COMODO ECC Certification Authority O=COMODO CA Limited
+# Subject: CN=COMODO ECC Certification Authority O=COMODO CA Limited
+# Label: "COMODO ECC Certification Authority"
+# Serial: 41578283867086692638256921589707938090
+# MD5 Fingerprint: 7c:62:ff:74:9d:31:53:5e:68:4a:d5:78:aa:1e:bf:23
+# SHA1 Fingerprint: 9f:74:4e:9f:2b:4d:ba:ec:0f:31:2c:50:b6:56:3b:8e:2d:93:c3:11
+# SHA256 Fingerprint: 17:93:92:7a:06:14:54:97:89:ad:ce:2f:8f:34:f7:f0:b6:6d:0f:3a:e3:a3:b8:4d:21:ec:15:db:ba:4f:ad:c7
+-----BEGIN CERTIFICATE-----
+MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTEL
+MAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
+BxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMT
+IkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDgwMzA2MDAw
+MDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdy
+ZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09N
+T0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlv
+biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSR
+FtSrYpn1PlILBs5BAH+X4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0J
+cfRK9ChQtP6IHG4/bC8vCVlbpVsLM5niwz2J+Wos77LTBumjQjBAMB0GA1UdDgQW
+BBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/
+BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VGFAkK+qDm
+fQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdv
+GDeAU/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY=
+-----END CERTIFICATE-----
+
+# Issuer: CN=TC TrustCenter Class 2 CA II O=TC TrustCenter GmbH OU=TC TrustCenter Class 2 CA
+# Subject: CN=TC TrustCenter Class 2 CA II O=TC TrustCenter GmbH OU=TC TrustCenter Class 2 CA
+# Label: "TC TrustCenter Class 2 CA II"
+# Serial: 941389028203453866782103406992443
+# MD5 Fingerprint: ce:78:33:5c:59:78:01:6e:18:ea:b9:36:a0:b9:2e:23
+# SHA1 Fingerprint: ae:50:83:ed:7c:f4:5c:bc:8f:61:c6:21:fe:68:5d:79:42:21:15:6e
+# SHA256 Fingerprint: e6:b8:f8:76:64:85:f8:07:ae:7f:8d:ac:16:70:46:1f:07:c0:a1:3e:ef:3a:1f:f7:17:53:8d:7a:ba:d3:91:b4
+-----BEGIN CERTIFICATE-----
+MIIEqjCCA5KgAwIBAgIOLmoAAQACH9dSISwRXDswDQYJKoZIhvcNAQEFBQAwdjEL
+MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxIjAgBgNV
+BAsTGVRDIFRydXN0Q2VudGVyIENsYXNzIDIgQ0ExJTAjBgNVBAMTHFRDIFRydXN0
+Q2VudGVyIENsYXNzIDIgQ0EgSUkwHhcNMDYwMTEyMTQzODQzWhcNMjUxMjMxMjI1
+OTU5WjB2MQswCQYDVQQGEwJERTEcMBoGA1UEChMTVEMgVHJ1c3RDZW50ZXIgR21i
+SDEiMCAGA1UECxMZVEMgVHJ1c3RDZW50ZXIgQ2xhc3MgMiBDQTElMCMGA1UEAxMc
+VEMgVHJ1c3RDZW50ZXIgQ2xhc3MgMiBDQSBJSTCCASIwDQYJKoZIhvcNAQEBBQAD
+ggEPADCCAQoCggEBAKuAh5uO8MN8h9foJIIRszzdQ2Lu+MNF2ujhoF/RKrLqk2jf
+tMjWQ+nEdVl//OEd+DFwIxuInie5e/060smp6RQvkL4DUsFJzfb95AhmC1eKokKg
+uNV/aVyQMrKXDcpK3EY+AlWJU+MaWss2xgdW94zPEfRMuzBwBJWl9jmM/XOBCH2J
+XjIeIqkiRUuwZi4wzJ9l/fzLganx4Duvo4bRierERXlQXa7pIXSSTYtZgo+U4+lK
+8edJsBTj9WLL1XK9H7nSn6DNqPoByNkN39r8R52zyFTfSUrxIan+GE7uSNQZu+99
+5OKdy1u2bv/jzVrndIIFuoAlOMvkaZ6vQaoahPUCAwEAAaOCATQwggEwMA8GA1Ud
+EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTjq1RMgKHbVkO3
+kUrL84J6E1wIqzCB7QYDVR0fBIHlMIHiMIHfoIHcoIHZhjVodHRwOi8vd3d3LnRy
+dXN0Y2VudGVyLmRlL2NybC92Mi90Y19jbGFzc18yX2NhX0lJLmNybIaBn2xkYXA6
+Ly93d3cudHJ1c3RjZW50ZXIuZGUvQ049VEMlMjBUcnVzdENlbnRlciUyMENsYXNz
+JTIwMiUyMENBJTIwSUksTz1UQyUyMFRydXN0Q2VudGVyJTIwR21iSCxPVT1yb290
+Y2VydHMsREM9dHJ1c3RjZW50ZXIsREM9ZGU/Y2VydGlmaWNhdGVSZXZvY2F0aW9u
+TGlzdD9iYXNlPzANBgkqhkiG9w0BAQUFAAOCAQEAjNfffu4bgBCzg/XbEeprS6iS
+GNn3Bzn1LL4GdXpoUxUc6krtXvwjshOg0wn/9vYua0Fxec3ibf2uWWuFHbhOIprt
+ZjluS5TmVfwLG4t3wVMTZonZKNaL80VKY7f9ewthXbhtvsPcW3nS7Yblok2+XnR8
+au0WOB9/WIFaGusyiC2y8zl3gK9etmF1KdsjTYjKUCjLhdLTEKJZbtOTVAB6okaV
+hgWcqRmY5TFyDADiZ9lA4CQze28suVyrZZ0srHbqNZn1l7kPJOzHdiEoZa5X6AeI
+dUpWoNIFOqTmjZKILPPy4cHGYdtBxceb9w4aUUXCYWvcZCcXjFq32nQozZfkvQ==
+-----END CERTIFICATE-----
+
+# Issuer: CN=TC TrustCenter Class 3 CA II O=TC TrustCenter GmbH OU=TC TrustCenter Class 3 CA
+# Subject: CN=TC TrustCenter Class 3 CA II O=TC TrustCenter GmbH OU=TC TrustCenter Class 3 CA
+# Label: "TC TrustCenter Class 3 CA II"
+# Serial: 1506523511417715638772220530020799
+# MD5 Fingerprint: 56:5f:aa:80:61:12:17:f6:67:21:e6:2b:6d:61:56:8e
+# SHA1 Fingerprint: 80:25:ef:f4:6e:70:c8:d4:72:24:65:84:fe:40:3b:8a:8d:6a:db:f5
+# SHA256 Fingerprint: 8d:a0:84:fc:f9:9c:e0:77:22:f8:9b:32:05:93:98:06:fa:5c:b8:11:e1:c8:13:f6:a1:08:c7:d3:36:b3:40:8e
+-----BEGIN CERTIFICATE-----
+MIIEqjCCA5KgAwIBAgIOSkcAAQAC5aBd1j8AUb8wDQYJKoZIhvcNAQEFBQAwdjEL
+MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxIjAgBgNV
+BAsTGVRDIFRydXN0Q2VudGVyIENsYXNzIDMgQ0ExJTAjBgNVBAMTHFRDIFRydXN0
+Q2VudGVyIENsYXNzIDMgQ0EgSUkwHhcNMDYwMTEyMTQ0MTU3WhcNMjUxMjMxMjI1
+OTU5WjB2MQswCQYDVQQGEwJERTEcMBoGA1UEChMTVEMgVHJ1c3RDZW50ZXIgR21i
+SDEiMCAGA1UECxMZVEMgVHJ1c3RDZW50ZXIgQ2xhc3MgMyBDQTElMCMGA1UEAxMc
+VEMgVHJ1c3RDZW50ZXIgQ2xhc3MgMyBDQSBJSTCCASIwDQYJKoZIhvcNAQEBBQAD
+ggEPADCCAQoCggEBALTgu1G7OVyLBMVMeRwjhjEQY0NVJz/GRcekPewJDRoeIMJW
+Ht4bNwcwIi9v8Qbxq63WyKthoy9DxLCyLfzDlml7forkzMA5EpBCYMnMNWju2l+Q
+Vl/NHE1bWEnrDgFPZPosPIlY2C8u4rBo6SI7dYnWRBpl8huXJh0obazovVkdKyT2
+1oQDZogkAHhg8fir/gKya/si+zXmFtGt9i4S5Po1auUZuV3bOx4a+9P/FRQI2Alq
+ukWdFHlgfa9Aigdzs5OW03Q0jTo3Kd5c7PXuLjHCINy+8U9/I1LZW+Jk2ZyqBwi1
+Rb3R0DHBq1SfqdLDYmAD8bs5SpJKPQq5ncWg/jcCAwEAAaOCATQwggEwMA8GA1Ud
+EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTUovyfs8PYA9NX
+XAek0CSnwPIA1DCB7QYDVR0fBIHlMIHiMIHfoIHcoIHZhjVodHRwOi8vd3d3LnRy
+dXN0Y2VudGVyLmRlL2NybC92Mi90Y19jbGFzc18zX2NhX0lJLmNybIaBn2xkYXA6
+Ly93d3cudHJ1c3RjZW50ZXIuZGUvQ049VEMlMjBUcnVzdENlbnRlciUyMENsYXNz
+JTIwMyUyMENBJTIwSUksTz1UQyUyMFRydXN0Q2VudGVyJTIwR21iSCxPVT1yb290
+Y2VydHMsREM9dHJ1c3RjZW50ZXIsREM9ZGU/Y2VydGlmaWNhdGVSZXZvY2F0aW9u
+TGlzdD9iYXNlPzANBgkqhkiG9w0BAQUFAAOCAQEANmDkcPcGIEPZIxpC8vijsrlN
+irTzwppVMXzEO2eatN9NDoqTSheLG43KieHPOh6sHfGcMrSOWXaiQYUlN6AT0PV8
+TtXqluJucsG7Kv5sbviRmEb8yRtXW+rIGjs/sFGYPAfaLFkB2otE6OF0/ado3VS6
+g0bsyEa1+K+XwDsJHI/OcpY9M1ZwvJbL2NV9IJqDnxrcOfHFcqMRA/07QlIp2+gB
+95tejNaNhk4Z+rwcvsUhpYeeeC422wlxo3I0+GzjBgnyXlal092Y+tTmBvTwtiBj
+S+opvaqCZh77gaqnN60TGOaSw4HBM7uIHqHn4rS9MWwOUT1v+5ZWgOI2F9Hc5A==
+-----END CERTIFICATE-----
+
+# Issuer: CN=TC TrustCenter Universal CA I O=TC TrustCenter GmbH OU=TC TrustCenter Universal CA
+# Subject: CN=TC TrustCenter Universal CA I O=TC TrustCenter GmbH OU=TC TrustCenter Universal CA
+# Label: "TC TrustCenter Universal CA I"
+# Serial: 601024842042189035295619584734726
+# MD5 Fingerprint: 45:e1:a5:72:c5:a9:36:64:40:9e:f5:e4:58:84:67:8c
+# SHA1 Fingerprint: 6b:2f:34:ad:89:58:be:62:fd:b0:6b:5c:ce:bb:9d:d9:4f:4e:39:f3
+# SHA256 Fingerprint: eb:f3:c0:2a:87:89:b1:fb:7d:51:19:95:d6:63:b7:29:06:d9:13:ce:0d:5e:10:56:8a:8a:77:e2:58:61:67:e7
+-----BEGIN CERTIFICATE-----
+MIID3TCCAsWgAwIBAgIOHaIAAQAC7LdggHiNtgYwDQYJKoZIhvcNAQEFBQAweTEL
+MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxJDAiBgNV
+BAsTG1RDIFRydXN0Q2VudGVyIFVuaXZlcnNhbCBDQTEmMCQGA1UEAxMdVEMgVHJ1
+c3RDZW50ZXIgVW5pdmVyc2FsIENBIEkwHhcNMDYwMzIyMTU1NDI4WhcNMjUxMjMx
+MjI1OTU5WjB5MQswCQYDVQQGEwJERTEcMBoGA1UEChMTVEMgVHJ1c3RDZW50ZXIg
+R21iSDEkMCIGA1UECxMbVEMgVHJ1c3RDZW50ZXIgVW5pdmVyc2FsIENBMSYwJAYD
+VQQDEx1UQyBUcnVzdENlbnRlciBVbml2ZXJzYWwgQ0EgSTCCASIwDQYJKoZIhvcN
+AQEBBQADggEPADCCAQoCggEBAKR3I5ZEr5D0MacQ9CaHnPM42Q9e3s9B6DGtxnSR
+JJZ4Hgmgm5qVSkr1YnwCqMqs+1oEdjneX/H5s7/zA1hV0qq34wQi0fiU2iIIAI3T
+fCZdzHd55yx4Oagmcw6iXSVphU9VDprvxrlE4Vc93x9UIuVvZaozhDrzznq+VZeu
+jRIPFDPiUHDDSYcTvFHe15gSWu86gzOSBnWLknwSaHtwag+1m7Z3W0hZneTvWq3z
+wZ7U10VOylY0Ibw+F1tvdwxIAUMpsN0/lm7mlaoMwCC2/T42J5zjXM9OgdwZu5GQ
+fezmlwQek8wiSdeXhrYTCjxDI3d+8NzmzSQfO4ObNDqDNOMCAwEAAaNjMGEwHwYD
+VR0jBBgwFoAUkqR1LKSevoFE63n8isWVpesQdXMwDwYDVR0TAQH/BAUwAwEB/zAO
+BgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFJKkdSyknr6BROt5/IrFlaXrEHVzMA0G
+CSqGSIb3DQEBBQUAA4IBAQAo0uCG1eb4e/CX3CJrO5UUVg8RMKWaTzqwOuAGy2X1
+7caXJ/4l8lfmXpWMPmRgFVp/Lw0BxbFg/UU1z/CyvwbZ71q+s2IhtNerNXxTPqYn
+8aEt2hojnczd7Dwtnic0XQ/CNnm8yUpiLe1r2X1BQ3y2qsrtYbE3ghUJGooWMNjs
+ydZHcnhLEEYUjl8Or+zHL6sQ17bxbuyGssLoDZJz3KL0Dzq/YSMQiZxIQG5wALPT
+ujdEWBF6AmqI8Dc08BnprNRlc/ZpjGSUOnmFKbAWKwyCPwacx/0QK54PLLae4xW/
+2TYcuiUaUj0a7CIMHOCkoj3w6DnPgcB77V0fb8XQC9eY
+-----END CERTIFICATE-----
+
+# Issuer: CN=Cybertrust Global Root O=Cybertrust, Inc
+# Subject: CN=Cybertrust Global Root O=Cybertrust, Inc
+# Label: "Cybertrust Global Root"
+# Serial: 4835703278459682877484360
+# MD5 Fingerprint: 72:e4:4a:87:e3:69:40:80:77:ea:bc:e3:f4:ff:f0:e1
+# SHA1 Fingerprint: 5f:43:e5:b1:bf:f8:78:8c:ac:1c:c7:ca:4a:9a:c6:22:2b:cc:34:c6
+# SHA256 Fingerprint: 96:0a:df:00:63:e9:63:56:75:0c:29:65:dd:0a:08:67:da:0b:9c:bd:6e:77:71:4a:ea:fb:23:49:ab:39:3d:a3
+-----BEGIN CERTIFICATE-----
+MIIDoTCCAomgAwIBAgILBAAAAAABD4WqLUgwDQYJKoZIhvcNAQEFBQAwOzEYMBYG
+A1UEChMPQ3liZXJ0cnVzdCwgSW5jMR8wHQYDVQQDExZDeWJlcnRydXN0IEdsb2Jh
+bCBSb290MB4XDTA2MTIxNTA4MDAwMFoXDTIxMTIxNTA4MDAwMFowOzEYMBYGA1UE
+ChMPQ3liZXJ0cnVzdCwgSW5jMR8wHQYDVQQDExZDeWJlcnRydXN0IEdsb2JhbCBS
+b290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+Mi8vRRQZhP/8NN5
+7CPytxrHjoXxEnOmGaoQ25yiZXRadz5RfVb23CO21O1fWLE3TdVJDm71aofW0ozS
+J8bi/zafmGWgE07GKmSb1ZASzxQG9Dvj1Ci+6A74q05IlG2OlTEQXO2iLb3VOm2y
+HLtgwEZLAfVJrn5GitB0jaEMAs7u/OePuGtm839EAL9mJRQr3RAwHQeWP032a7iP
+t3sMpTjr3kfb1V05/Iin89cqdPHoWqI7n1C6poxFNcJQZZXcY4Lv3b93TZxiyWNz
+FtApD0mpSPCzqrdsxacwOUBdrsTiXSZT8M4cIwhhqJQZugRiQOwfOHB3EgZxpzAY
+XSUnpQIDAQABo4GlMIGiMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/
+MB0GA1UdDgQWBBS2CHsNesysIEyGVjJez6tuhS1wVzA/BgNVHR8EODA2MDSgMqAw
+hi5odHRwOi8vd3d3Mi5wdWJsaWMtdHJ1c3QuY29tL2NybC9jdC9jdHJvb3QuY3Js
+MB8GA1UdIwQYMBaAFLYIew16zKwgTIZWMl7Pq26FLXBXMA0GCSqGSIb3DQEBBQUA
+A4IBAQBW7wojoFROlZfJ+InaRcHUowAl9B8Tq7ejhVhpwjCt2BWKLePJzYFa+HMj
+Wqd8BfP9IjsO0QbE2zZMcwSO5bAi5MXzLqXZI+O4Tkogp24CJJ8iYGd7ix1yCcUx
+XOl5n4BHPa2hCwcUPUf/A2kaDAtE52Mlp3+yybh2hO0j9n0Hq0V+09+zv+mKts2o
+omcrUtW3ZfA5TGOgkXmTUg9U3YO7n9GPp1Nzw8v/MOx8BLjYRB+TX3EJIrduPuoc
+A06dGiBh+4E37F78CkWr1+cXVdCg6mCbpvbjjFspwgZgFJ0tl0ypkxWdYcQBX0jW
+WL1WMRJOEcgh4LMRkWXbtKaIOM5V
+-----END CERTIFICATE-----
+
+# Issuer: CN=GeoTrust Primary Certification Authority - G3 O=GeoTrust Inc. OU=(c) 2008 GeoTrust Inc. - For authorized use only
+# Subject: CN=GeoTrust Primary Certification Authority - G3 O=GeoTrust Inc. OU=(c) 2008 GeoTrust Inc. - For authorized use only
+# Label: "GeoTrust Primary Certification Authority - G3"
+# Serial: 28809105769928564313984085209975885599
+# MD5 Fingerprint: b5:e8:34:36:c9:10:44:58:48:70:6d:2e:83:d4:b8:05
+# SHA1 Fingerprint: 03:9e:ed:b8:0b:e7:a0:3c:69:53:89:3b:20:d2:d9:32:3a:4c:2a:fd
+# SHA256 Fingerprint: b4:78:b8:12:25:0d:f8:78:63:5c:2a:a7:ec:7d:15:5e:aa:62:5e:e8:29:16:e2:cd:29:43:61:88:6c:d1:fb:d4
+-----BEGIN CERTIFICATE-----
+MIID/jCCAuagAwIBAgIQFaxulBmyeUtB9iepwxgPHzANBgkqhkiG9w0BAQsFADCB
+mDELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xOTA3BgNVBAsT
+MChjKSAyMDA4IEdlb1RydXN0IEluYy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25s
+eTE2MDQGA1UEAxMtR2VvVHJ1c3QgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhv
+cml0eSAtIEczMB4XDTA4MDQwMjAwMDAwMFoXDTM3MTIwMTIzNTk1OVowgZgxCzAJ
+BgNVBAYTAlVTMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMTkwNwYDVQQLEzAoYykg
+MjAwOCBHZW9UcnVzdCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxNjA0
+BgNVBAMTLUdlb1RydXN0IFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg
+LSBHMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANziXmJYHTNXOTIz
++uvLh4yn1ErdBojqZI4xmKU4kB6Yzy5jK/BGvESyiaHAKAxJcCGVn2TAppMSAmUm
+hsalifD614SgcK9PGpc/BkTVyetyEH3kMSj7HGHmKAdEc5IiaacDiGydY8hS2pgn
+5whMcD60yRLBxWeDXTPzAxHsatBT4tG6NmCUgLthY2xbF37fQJQeqw3CIShwiP/W
+JmxsYAQlTlV+fe+/lEjetx3dcI0FX4ilm/LC7urRQEFtYjgdVgbFA0dRIBn8exAL
+DmKudlW/X3e+PkkBUz2YJQN2JFodtNuJ6nnltrM7P7pMKEF/BqxqjsHQ9gUdfeZC
+huOl1UcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
+HQYDVR0OBBYEFMR5yo6hTgMdHNxr2zFblD4/MH8tMA0GCSqGSIb3DQEBCwUAA4IB
+AQAtxRPPVoB7eni9n64smefv2t+UXglpp+duaIy9cr5HqQ6XErhK8WTTOd8lNNTB
+zU6B8A8ExCSzNJbGpqow32hhc9f5joWJ7w5elShKKiePEI4ufIbEAp7aDHdlDkQN
+kv39sxY2+hENHYwOB4lqKVb3cvTdFZx3NWZXqxNT2I7BQMXXExZacse3aQHEerGD
+AWh9jUGhlBjBJVz88P6DAod8DQ3PLghcSkANPuyBYeYk28rgDi0Hsj5W3I31QYUH
+SJsMC8tJP33st/3LjWeJGqvtux6jAAgIFyqCXDFdRootD4abdNlF+9RAsXqqaC2G
+spki4cErx5z481+oghLrGREt
+-----END CERTIFICATE-----
+
+# Issuer: CN=thawte Primary Root CA - G2 O=thawte, Inc. OU=(c) 2007 thawte, Inc. - For authorized use only
+# Subject: CN=thawte Primary Root CA - G2 O=thawte, Inc. OU=(c) 2007 thawte, Inc. - For authorized use only
+# Label: "thawte Primary Root CA - G2"
+# Serial: 71758320672825410020661621085256472406
+# MD5 Fingerprint: 74:9d:ea:60:24:c4:fd:22:53:3e:cc:3a:72:d9:29:4f
+# SHA1 Fingerprint: aa:db:bc:22:23:8f:c4:01:a1:27:bb:38:dd:f4:1d:db:08:9e:f0:12
+# SHA256 Fingerprint: a4:31:0d:50:af:18:a6:44:71:90:37:2a:86:af:af:8b:95:1f:fb:43:1d:83:7f:1e:56:88:b4:59:71:ed:15:57
+-----BEGIN CERTIFICATE-----
+MIICiDCCAg2gAwIBAgIQNfwmXNmET8k9Jj1Xm67XVjAKBggqhkjOPQQDAzCBhDEL
+MAkGA1UEBhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5jLjE4MDYGA1UECxMvKGMp
+IDIwMDcgdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxJDAi
+BgNVBAMTG3RoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EgLSBHMjAeFw0wNzExMDUwMDAw
+MDBaFw0zODAxMTgyMzU5NTlaMIGEMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMdGhh
+d3RlLCBJbmMuMTgwNgYDVQQLEy8oYykgMjAwNyB0aGF3dGUsIEluYy4gLSBGb3Ig
+YXV0aG9yaXplZCB1c2Ugb25seTEkMCIGA1UEAxMbdGhhd3RlIFByaW1hcnkgUm9v
+dCBDQSAtIEcyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEotWcgnuVnfFSeIf+iha/
+BebfowJPDQfGAFG6DAJSLSKkQjnE/o/qycG+1E3/n3qe4rF8mq2nhglzh9HnmuN6
+papu+7qzcMBniKI11KOasf2twu8x+qi58/sIxpHR+ymVo0IwQDAPBgNVHRMBAf8E
+BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUmtgAMADna3+FGO6Lts6K
+DPgR4bswCgYIKoZIzj0EAwMDaQAwZgIxAN344FdHW6fmCsO99YCKlzUNG4k8VIZ3
+KMqh9HneteY4sPBlcIx/AlTCv//YoT7ZzwIxAMSNlPzcU9LcnXgWHxUzI1NS41ox
+XZ3Krr0TKUQNJ1uo52icEvdYPy5yAlejj6EULg==
+-----END CERTIFICATE-----
+
+# Issuer: CN=thawte Primary Root CA - G3 O=thawte, Inc. OU=Certification Services Division/(c) 2008 thawte, Inc. - For authorized use only
+# Subject: CN=thawte Primary Root CA - G3 O=thawte, Inc. OU=Certification Services Division/(c) 2008 thawte, Inc. - For authorized use only
+# Label: "thawte Primary Root CA - G3"
+# Serial: 127614157056681299805556476275995414779
+# MD5 Fingerprint: fb:1b:5d:43:8a:94:cd:44:c6:76:f2:43:4b:47:e7:31
+# SHA1 Fingerprint: f1:8b:53:8d:1b:e9:03:b6:a6:f0:56:43:5b:17:15:89:ca:f3:6b:f2
+# SHA256 Fingerprint: 4b:03:f4:58:07:ad:70:f2:1b:fc:2c:ae:71:c9:fd:e4:60:4c:06:4c:f5:ff:b6:86:ba:e5:db:aa:d7:fd:d3:4c
+-----BEGIN CERTIFICATE-----
+MIIEKjCCAxKgAwIBAgIQYAGXt0an6rS0mtZLL/eQ+zANBgkqhkiG9w0BAQsFADCB
+rjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5jLjEoMCYGA1UECxMf
+Q2VydGlmaWNhdGlvbiBTZXJ2aWNlcyBEaXZpc2lvbjE4MDYGA1UECxMvKGMpIDIw
+MDggdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxJDAiBgNV
+BAMTG3RoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EgLSBHMzAeFw0wODA0MDIwMDAwMDBa
+Fw0zNzEyMDEyMzU5NTlaMIGuMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMdGhhd3Rl
+LCBJbmMuMSgwJgYDVQQLEx9DZXJ0aWZpY2F0aW9uIFNlcnZpY2VzIERpdmlzaW9u
+MTgwNgYDVQQLEy8oYykgMjAwOCB0aGF3dGUsIEluYy4gLSBGb3IgYXV0aG9yaXpl
+ZCB1c2Ugb25seTEkMCIGA1UEAxMbdGhhd3RlIFByaW1hcnkgUm9vdCBDQSAtIEcz
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsr8nLPvb2FvdeHsbnndm
+gcs+vHyu86YnmjSjaDFxODNi5PNxZnmxqWWjpYvVj2AtP0LMqmsywCPLLEHd5N/8
+YZzic7IilRFDGF/Eth9XbAoFWCLINkw6fKXRz4aviKdEAhN0cXMKQlkC+BsUa0Lf
+b1+6a4KinVvnSr0eAXLbS3ToO39/fR8EtCab4LRarEc9VbjXsCZSKAExQGbY2SS9
+9irY7CFJXJv2eul/VTV+lmuNk5Mny5K76qxAwJ/C+IDPXfRa3M50hqY+bAtTyr2S
+zhkGcuYMXDhpxwTWvGzOW/b3aJzcJRVIiKHpqfiYnODz1TEoYRFsZ5aNOZnLwkUk
+OQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNV
+HQ4EFgQUrWyqlGCc7eT/+j4KdCtjA/e2Wb8wDQYJKoZIhvcNAQELBQADggEBABpA
+2JVlrAmSicY59BDlqQ5mU1143vokkbvnRFHfxhY0Cu9qRFHqKweKA3rD6z8KLFIW
+oCtDuSWQP3CpMyVtRRooOyfPqsMpQhvfO0zAMzRbQYi/aytlryjvsvXDqmbOe1bu
+t8jLZ8HJnBoYuMTDSQPxYA5QzUbF83d597YV4Djbxy8ooAw/dyZ02SUS2jHaGh7c
+KUGRIjxpp7sC8rZcJwOJ9Abqm+RyguOhCcHpABnTPtRwa7pxpqpYrvS76Wy274fM
+m7v/OeZWYdMKp8RcTGB7BXcmer/YB1IsYvdwY9k5vG8cwnncdimvzsUsZAReiDZu
+MdRAGmI0Nj81Aa6sY6A=
+-----END CERTIFICATE-----
+
+# Issuer: CN=GeoTrust Primary Certification Authority - G2 O=GeoTrust Inc. OU=(c) 2007 GeoTrust Inc. - For authorized use only
+# Subject: CN=GeoTrust Primary Certification Authority - G2 O=GeoTrust Inc. OU=(c) 2007 GeoTrust Inc. - For authorized use only
+# Label: "GeoTrust Primary Certification Authority - G2"
+# Serial: 80682863203381065782177908751794619243
+# MD5 Fingerprint: 01:5e:d8:6b:bd:6f:3d:8e:a1:31:f8:12:e0:98:73:6a
+# SHA1 Fingerprint: 8d:17:84:d5:37:f3:03:7d:ec:70:fe:57:8b:51:9a:99:e6:10:d7:b0
+# SHA256 Fingerprint: 5e:db:7a:c4:3b:82:a0:6a:87:61:e8:d7:be:49:79:eb:f2:61:1f:7d:d7:9b:f9:1c:1c:6b:56:6a:21:9e:d7:66
+-----BEGIN CERTIFICATE-----
+MIICrjCCAjWgAwIBAgIQPLL0SAoA4v7rJDteYD7DazAKBggqhkjOPQQDAzCBmDEL
+MAkGA1UEBhMCVVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xOTA3BgNVBAsTMChj
+KSAyMDA3IEdlb1RydXN0IEluYy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25seTE2
+MDQGA1UEAxMtR2VvVHJ1c3QgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0
+eSAtIEcyMB4XDTA3MTEwNTAwMDAwMFoXDTM4MDExODIzNTk1OVowgZgxCzAJBgNV
+BAYTAlVTMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMTkwNwYDVQQLEzAoYykgMjAw
+NyBHZW9UcnVzdCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxNjA0BgNV
+BAMTLUdlb1RydXN0IFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBH
+MjB2MBAGByqGSM49AgEGBSuBBAAiA2IABBWx6P0DFUPlrOuHNxFi79KDNlJ9RVcL
+So17VDs6bl8VAsBQps8lL33KSLjHUGMcKiEIfJo22Av+0SbFWDEwKCXzXV2juLal
+tJLtbCyf691DiaI8S0iRHVDsJt/WYC69IaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO
+BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFBVfNVdRVfslsq0DafwBo/q+EVXVMAoG
+CCqGSM49BAMDA2cAMGQCMGSWWaboCd6LuvpaiIjwH5HTRqjySkwCY/tsXzjbLkGT
+qQ7mndwxHLKgpxgceeHHNgIwOlavmnRs9vuD4DPTCF+hnMJbn0bWtsuRBmOiBucz
+rD6ogRLQy7rQkgu2npaqBA+K
+-----END CERTIFICATE-----
+
+# Issuer: CN=VeriSign Universal Root Certification Authority O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 2008 VeriSign, Inc. - For authorized use only
+# Subject: CN=VeriSign Universal Root Certification Authority O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 2008 VeriSign, Inc. - For authorized use only
+# Label: "VeriSign Universal Root Certification Authority"
+# Serial: 85209574734084581917763752644031726877
+# MD5 Fingerprint: 8e:ad:b5:01:aa:4d:81:e4:8c:1d:d1:e1:14:00:95:19
+# SHA1 Fingerprint: 36:79:ca:35:66:87:72:30:4d:30:a5:fb:87:3b:0f:a7:7b:b7:0d:54
+# SHA256 Fingerprint: 23:99:56:11:27:a5:71:25:de:8c:ef:ea:61:0d:df:2f:a0:78:b5:c8:06:7f:4e:82:82:90:bf:b8:60:e8:4b:3c
+-----BEGIN CERTIFICATE-----
+MIIEuTCCA6GgAwIBAgIQQBrEZCGzEyEDDrvkEhrFHTANBgkqhkiG9w0BAQsFADCB
+vTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL
+ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwOCBWZXJp
+U2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MTgwNgYDVQQDEy9W
+ZXJpU2lnbiBVbml2ZXJzYWwgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAe
+Fw0wODA0MDIwMDAwMDBaFw0zNzEyMDEyMzU5NTlaMIG9MQswCQYDVQQGEwJVUzEX
+MBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlTaWduIFRydXN0
+IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAyMDA4IFZlcmlTaWduLCBJbmMuIC0gRm9y
+IGF1dGhvcml6ZWQgdXNlIG9ubHkxODA2BgNVBAMTL1ZlcmlTaWduIFVuaXZlcnNh
+bCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAx2E3XrEBNNti1xWb/1hajCMj1mCOkdeQmIN65lgZOIzF
+9uVkhbSicfvtvbnazU0AtMgtc6XHaXGVHzk8skQHnOgO+k1KxCHfKWGPMiJhgsWH
+H26MfF8WIFFE0XBPV+rjHOPMee5Y2A7Cs0WTwCznmhcrewA3ekEzeOEz4vMQGn+H
+LL729fdC4uW/h2KJXwBL38Xd5HVEMkE6HnFuacsLdUYI0crSK5XQz/u5QGtkjFdN
+/BMReYTtXlT2NJ8IAfMQJQYXStrxHXpma5hgZqTZ79IugvHw7wnqRMkVauIDbjPT
+rJ9VAMf2CGqUuV/c4DPxhGD5WycRtPwW8rtWaoAljQIDAQABo4GyMIGvMA8GA1Ud
+EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMG0GCCsGAQUFBwEMBGEwX6FdoFsw
+WTBXMFUWCWltYWdlL2dpZjAhMB8wBwYFKw4DAhoEFI/l0xqGrI2Oa8PPgGrUSBgs
+exkuMCUWI2h0dHA6Ly9sb2dvLnZlcmlzaWduLmNvbS92c2xvZ28uZ2lmMB0GA1Ud
+DgQWBBS2d/ppSEefUxLVwuoHMnYH0ZcHGTANBgkqhkiG9w0BAQsFAAOCAQEASvj4
+sAPmLGd75JR3Y8xuTPl9Dg3cyLk1uXBPY/ok+myDjEedO2Pzmvl2MpWRsXe8rJq+
+seQxIcaBlVZaDrHC1LGmWazxY8u4TB1ZkErvkBYoH1quEPuBUDgMbMzxPcP1Y+Oz
+4yHJJDnp/RVmRvQbEdBNc6N9Rvk97ahfYtTxP/jgdFcrGJ2BtMQo2pSXpXDrrB2+
+BxHw1dvd5Yzw1TKwg+ZX4o+/vqGqvz0dtdQ46tewXDpPaj+PwGZsY6rp2aQW9IHR
+lRQOfc2VNNnSj3BzgXucfr2YYdhFh5iQxeuGMMY1v/D/w1WIg0vvBZIGcfK4mJO3
+7M2CYfE45k+XmCpajQ==
+-----END CERTIFICATE-----
+
+# Issuer: CN=VeriSign Class 3 Public Primary Certification Authority - G4 O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 2007 VeriSign, Inc. - For authorized use only
+# Subject: CN=VeriSign Class 3 Public Primary Certification Authority - G4 O=VeriSign, Inc. OU=VeriSign Trust Network/(c) 2007 VeriSign, Inc. - For authorized use only
+# Label: "VeriSign Class 3 Public Primary Certification Authority - G4"
+# Serial: 63143484348153506665311985501458640051
+# MD5 Fingerprint: 3a:52:e1:e7:fd:6f:3a:e3:6f:f3:6f:99:1b:f9:22:41
+# SHA1 Fingerprint: 22:d5:d8:df:8f:02:31:d1:8d:f7:9d:b7:cf:8a:2d:64:c9:3f:6c:3a
+# SHA256 Fingerprint: 69:dd:d7:ea:90:bb:57:c9:3e:13:5d:c8:5e:a6:fc:d5:48:0b:60:32:39:bd:c4:54:fc:75:8b:2a:26:cf:7f:79
+-----BEGIN CERTIFICATE-----
+MIIDhDCCAwqgAwIBAgIQL4D+I4wOIg9IZxIokYesszAKBggqhkjOPQQDAzCByjEL
+MAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZW
+ZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNyBWZXJpU2ln
+biwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJp
+U2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9y
+aXR5IC0gRzQwHhcNMDcxMTA1MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCByjELMAkG
+A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJp
+U2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNyBWZXJpU2lnbiwg
+SW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJpU2ln
+biBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5
+IC0gRzQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASnVnp8Utpkmw4tXNherJI9/gHm
+GUo9FANL+mAnINmDiWn6VMaaGF5VKmTeBvaNSjutEDxlPZCIBIngMGGzrl0Bp3ve
+fLK+ymVhAIau2o970ImtTR1ZmkGxvEeA3J5iw/mjgbIwga8wDwYDVR0TAQH/BAUw
+AwEB/zAOBgNVHQ8BAf8EBAMCAQYwbQYIKwYBBQUHAQwEYTBfoV2gWzBZMFcwVRYJ
+aW1hZ2UvZ2lmMCEwHzAHBgUrDgMCGgQUj+XTGoasjY5rw8+AatRIGCx7GS4wJRYj
+aHR0cDovL2xvZ28udmVyaXNpZ24uY29tL3ZzbG9nby5naWYwHQYDVR0OBBYEFLMW
+kf3upm7ktS5Jj4d4gYDs5bG1MAoGCCqGSM49BAMDA2gAMGUCMGYhDBgmYFo4e1ZC
+4Kf8NoRRkSAsdk1DPcQdhCPQrNZ8NQbOzWm9kA3bbEhCHQ6qQgIxAJw9SDkjOVga
+FRJZap7v1VmyHVIsmXHNxynfGyphe3HR3vPA5Q06Sqotp9iGKt0uEA==
+-----END CERTIFICATE-----
+
+# Issuer: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
+# Subject: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
+# Label: "Verisign Class 3 Public Primary Certification Authority"
+# Serial: 80507572722862485515306429940691309246
+# MD5 Fingerprint: ef:5a:f1:33:ef:f1:cd:bb:51:02:ee:12:14:4b:96:c4
+# SHA1 Fingerprint: a1:db:63:93:91:6f:17:e4:18:55:09:40:04:15:c7:02:40:b0:ae:6b
+# SHA256 Fingerprint: a4:b6:b3:99:6f:c2:f3:06:b3:fd:86:81:bd:63:41:3d:8c:50:09:cc:4f:a3:29:c2:cc:f0:e2:fa:1b:14:03:05
+-----BEGIN CERTIFICATE-----
+MIICPDCCAaUCEDyRMcsf9tAbDpq40ES/Er4wDQYJKoZIhvcNAQEFBQAwXzELMAkG
+A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz
+cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2
+MDEyOTAwMDAwMFoXDTI4MDgwMjIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV
+BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt
+YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN
+ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE
+BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is
+I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G
+CSqGSIb3DQEBBQUAA4GBABByUqkFFBkyCEHwxWsKzH4PIRnN5GfcX6kb5sroc50i
+2JhucwNhkcV8sEVAbkSdjbCxlnRhLQ2pRdKkkirWmnWXbj9T/UWZYB2oK0z5XqcJ
+2HUw19JlYD1n1khVdWk/kfVIC0dpImmClr7JyDiGSnoscxlIaU5rfGW/D/xwzoiQ
+-----END CERTIFICATE-----
+
+# Issuer: CN=GlobalSign O=GlobalSign OU=GlobalSign Root CA - R3
+# Subject: CN=GlobalSign O=GlobalSign OU=GlobalSign Root CA - R3
+# Label: "GlobalSign Root CA - R3"
+# Serial: 4835703278459759426209954
+# MD5 Fingerprint: c5:df:b8:49:ca:05:13:55:ee:2d:ba:1a:c3:3e:b0:28
+# SHA1 Fingerprint: d6:9b:56:11:48:f0:1c:77:c5:45:78:c1:09:26:df:5b:85:69:76:ad
+# SHA256 Fingerprint: cb:b5:22:d7:b7:f1:27:ad:6a:01:13:86:5b:df:1c:d4:10:2e:7d:07:59:af:63:5a:7c:f4:72:0d:c9:63:c5:3b
+-----BEGIN CERTIFICATE-----
+MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G
+A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp
+Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4
+MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG
+A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
+hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8
+RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT
+gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm
+KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd
+QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ
+XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw
+DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o
+LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU
+RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp
+jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK
+6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX
+mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs
+Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH
+WD9f
+-----END CERTIFICATE-----
+
+# Issuer: CN=TC TrustCenter Universal CA III O=TC TrustCenter GmbH OU=TC TrustCenter Universal CA
+# Subject: CN=TC TrustCenter Universal CA III O=TC TrustCenter GmbH OU=TC TrustCenter Universal CA
+# Label: "TC TrustCenter Universal CA III"
+# Serial: 2010889993983507346460533407902964
+# MD5 Fingerprint: 9f:dd:db:ab:ff:8e:ff:45:21:5f:f0:6c:9d:8f:fe:2b
+# SHA1 Fingerprint: 96:56:cd:7b:57:96:98:95:d0:e1:41:46:68:06:fb:b8:c6:11:06:87
+# SHA256 Fingerprint: 30:9b:4a:87:f6:ca:56:c9:31:69:aa:a9:9c:6d:98:88:54:d7:89:2b:d5:43:7e:2d:07:b2:9c:be:da:55:d3:5d
+-----BEGIN CERTIFICATE-----
+MIID4TCCAsmgAwIBAgIOYyUAAQACFI0zFQLkbPQwDQYJKoZIhvcNAQEFBQAwezEL
+MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxJDAiBgNV
+BAsTG1RDIFRydXN0Q2VudGVyIFVuaXZlcnNhbCBDQTEoMCYGA1UEAxMfVEMgVHJ1
+c3RDZW50ZXIgVW5pdmVyc2FsIENBIElJSTAeFw0wOTA5MDkwODE1MjdaFw0yOTEy
+MzEyMzU5NTlaMHsxCzAJBgNVBAYTAkRFMRwwGgYDVQQKExNUQyBUcnVzdENlbnRl
+ciBHbWJIMSQwIgYDVQQLExtUQyBUcnVzdENlbnRlciBVbml2ZXJzYWwgQ0ExKDAm
+BgNVBAMTH1RDIFRydXN0Q2VudGVyIFVuaXZlcnNhbCBDQSBJSUkwggEiMA0GCSqG
+SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDC2pxisLlxErALyBpXsq6DFJmzNEubkKLF
+5+cvAqBNLaT6hdqbJYUtQCggbergvbFIgyIpRJ9Og+41URNzdNW88jBmlFPAQDYv
+DIRlzg9uwliT6CwLOunBjvvya8o84pxOjuT5fdMnnxvVZ3iHLX8LR7PH6MlIfK8v
+zArZQe+f/prhsq75U7Xl6UafYOPfjdN/+5Z+s7Vy+EutCHnNaYlAJ/Uqwa1D7KRT
+yGG299J5KmcYdkhtWyUB0SbFt1dpIxVbYYqt8Bst2a9c8SaQaanVDED1M4BDj5yj
+dipFtK+/fz6HP3bFzSreIMUWWMv5G/UPyw0RUmS40nZid4PxWJ//AgMBAAGjYzBh
+MB8GA1UdIwQYMBaAFFbn4VslQ4Dg9ozhcbyO5YAvxEjiMA8GA1UdEwEB/wQFMAMB
+Af8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRW5+FbJUOA4PaM4XG8juWAL8RI
+4jANBgkqhkiG9w0BAQUFAAOCAQEAg8ev6n9NCjw5sWi+e22JLumzCecYV42Fmhfz
+dkJQEw/HkG8zrcVJYCtsSVgZ1OK+t7+rSbyUyKu+KGwWaODIl0YgoGhnYIg5IFHY
+aAERzqf2EQf27OysGh+yZm5WZ2B6dF7AbZc2rrUNXWZzwCUyRdhKBgePxLcHsU0G
+DeGl6/R1yrqc0L2z0zIkTO5+4nYES0lT2PLpVDP85XEfPRRclkvxOvIAu2y0+pZV
+CIgJwcyRGSmwIC3/yzikQOEXvnlhgP8HA4ZMTnsGnxGGjYnuJ8Tb4rwZjgvDwxPH
+LQNjO9Po5KIqwoIIlBZU8O8fJ5AluA0OKBtHd0e9HKgl8ZS0Zg==
+-----END CERTIFICATE-----
+
+# Issuer: CN=Go Daddy Root Certificate Authority - G2 O=GoDaddy.com, Inc.
+# Subject: CN=Go Daddy Root Certificate Authority - G2 O=GoDaddy.com, Inc.
+# Label: "Go Daddy Root Certificate Authority - G2"
+# Serial: 0
+# MD5 Fingerprint: 80:3a:bc:22:c1:e6:fb:8d:9b:3b:27:4a:32:1b:9a:01
+# SHA1 Fingerprint: 47:be:ab:c9:22:ea:e8:0e:78:78:34:62:a7:9f:45:c2:54:fd:e6:8b
+# SHA256 Fingerprint: 45:14:0b:32:47:eb:9c:c8:c5:b4:f0:d7:b5:30:91:f7:32:92:08:9e:6e:5a:63:e2:74:9d:d3:ac:a9:19:8e:da
+-----BEGIN CERTIFICATE-----
+MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMx
+EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoT
+EUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRp
+ZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIz
+NTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQH
+EwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UE
+AxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIw
+DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKD
+E6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH
+/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7Rnwy
+DfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlRJQVh
+GkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGR
+tDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEA
+AaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE
+FDqahQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmX
+WWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu
+9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTr
+gIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo
+2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO
+LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI
+4uJEvlz36hz1
+-----END CERTIFICATE-----
+
+# Issuer: CN=Starfield Root Certificate Authority - G2 O=Starfield Technologies, Inc.
+# Subject: CN=Starfield Root Certificate Authority - G2 O=Starfield Technologies, Inc.
+# Label: "Starfield Root Certificate Authority - G2"
+# Serial: 0
+# MD5 Fingerprint: d6:39:81:c6:52:7e:96:69:fc:fc:ca:66:ed:05:f2:96
+# SHA1 Fingerprint: b5:1c:06:7c:ee:2b:0c:3d:f8:55:ab:2d:92:f4:fe:39:d4:e7:0f:0e
+# SHA256 Fingerprint: 2c:e1:cb:0b:f9:d2:f9:e1:02:99:3f:be:21:51:52:c3:b2:dd:0c:ab:de:1c:68:e5:31:9b:83:91:54:db:b7:f5
+-----BEGIN CERTIFICATE-----
+MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMx
+EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT
+HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVs
+ZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAw
+MFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6
+b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVj
+aG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZp
+Y2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
+ggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMg
+nLRJdzIpVv257IzdIvpy3Cdhl+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1
+HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNMYq/N
+Hwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dN
+dloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0
+HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO
+BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0G
+CSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjU
+sHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu3
+4jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg
+8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K
+pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1
+mMpYjn0q7pBZc2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0
+-----END CERTIFICATE-----
+
+# Issuer: CN=Starfield Services Root Certificate Authority - G2 O=Starfield Technologies, Inc.
+# Subject: CN=Starfield Services Root Certificate Authority - G2 O=Starfield Technologies, Inc.
+# Label: "Starfield Services Root Certificate Authority - G2"
+# Serial: 0
+# MD5 Fingerprint: 17:35:74:af:7b:61:1c:eb:f4:f9:3c:e2:ee:40:f9:a2
+# SHA1 Fingerprint: 92:5a:8f:8d:2c:6d:04:e0:66:5f:59:6a:ff:22:d8:63:e8:25:6f:3f
+# SHA256 Fingerprint: 56:8d:69:05:a2:c8:87:08:a4:b3:02:51:90:ed:cf:ed:b1:97:4a:60:6a:13:c6:e5:29:0f:cb:2a:e6:3e:da:b5
+-----BEGIN CERTIFICATE-----
+MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMx
+EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT
+HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVs
+ZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5
+MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYD
+VQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFy
+ZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2Vy
+dmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI
+hvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p
+OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm2
+8xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1K
+Ts9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufe
+hRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk
+6mFBrMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAw
+DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+q
+AdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMI
+bw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynVv/heyNXB
+ve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z
+qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd
+iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn
+0q23KXB56jzaYyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCN
+sSi6
+-----END CERTIFICATE-----
+
+# Issuer: CN=AffirmTrust Commercial O=AffirmTrust
+# Subject: CN=AffirmTrust Commercial O=AffirmTrust
+# Label: "AffirmTrust Commercial"
+# Serial: 8608355977964138876
+# MD5 Fingerprint: 82:92:ba:5b:ef:cd:8a:6f:a6:3d:55:f9:84:f6:d6:b7
+# SHA1 Fingerprint: f9:b5:b6:32:45:5f:9c:be:ec:57:5f:80:dc:e9:6e:2c:c7:b2:78:b7
+# SHA256 Fingerprint: 03:76:ab:1d:54:c5:f9:80:3c:e4:b2:e2:01:a0:ee:7e:ef:7b:57:b6:36:e8:a9:3c:9b:8d:48:60:c9:6f:5f:a7
+-----BEGIN CERTIFICATE-----
+MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UE
+BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVz
+dCBDb21tZXJjaWFsMB4XDTEwMDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDEL
+MAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZp
+cm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
+AQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6EqdbDuKP
+Hx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yr
+ba0F8PrVC8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPAL
+MeIrJmqbTFeurCA+ukV6BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1
+yHp52UKqK39c/s4mT6NmgTWvRLpUHhwwMmWd5jyTXlBOeuM61G7MGvv50jeuJCqr
+VwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNVHQ4EFgQUnZPGU4teyq8/
+nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJ
+KoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYG
+XUPGhi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNj
+vbz4YYCanrHOQnDiqX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivt
+Z8SOyUOyXGsViQK8YvxO8rUzqrJv0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9g
+N53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0khsUlHRUe072o0EclNmsxZt9YC
+nlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8=
+-----END CERTIFICATE-----
+
+# Issuer: CN=AffirmTrust Networking O=AffirmTrust
+# Subject: CN=AffirmTrust Networking O=AffirmTrust
+# Label: "AffirmTrust Networking"
+# Serial: 8957382827206547757
+# MD5 Fingerprint: 42:65:ca:be:01:9a:9a:4c:a9:8c:41:49:cd:c0:d5:7f
+# SHA1 Fingerprint: 29:36:21:02:8b:20:ed:02:f5:66:c5:32:d1:d6:ed:90:9f:45:00:2f
+# SHA256 Fingerprint: 0a:81:ec:5a:92:97:77:f1:45:90:4a:f3:8d:5d:50:9f:66:b5:e2:c5:8f:cd:b5:31:05:8b:0e:17:f3:f0:b4:1b
+-----BEGIN CERTIFICATE-----
+MIIDTDCCAjSgAwIBAgIIfE8EORzUmS0wDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UE
+BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVz
+dCBOZXR3b3JraW5nMB4XDTEwMDEyOTE0MDgyNFoXDTMwMTIzMTE0MDgyNFowRDEL
+MAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZp
+cm1UcnVzdCBOZXR3b3JraW5nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
+AQEAtITMMxcua5Rsa2FSoOujz3mUTOWUgJnLVWREZY9nZOIG41w3SfYvm4SEHi3y
+YJ0wTsyEheIszx6e/jarM3c1RNg1lho9Nuh6DtjVR6FqaYvZ/Ls6rnla1fTWcbua
+kCNrmreIdIcMHl+5ni36q1Mr3Lt2PpNMCAiMHqIjHNRqrSK6mQEubWXLviRmVSRL
+QESxG9fhwoXA3hA/Pe24/PHxI1Pcv2WXb9n5QHGNfb2V1M6+oF4nI979ptAmDgAp
+6zxG8D1gvz9Q0twmQVGeFDdCBKNwV6gbh+0t+nvujArjqWaJGctB+d1ENmHP4ndG
+yH329JKBNv3bNPFyfvMMFr20FQIDAQABo0IwQDAdBgNVHQ4EFgQUBx/S55zawm6i
+QLSwelAQUHTEyL0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJ
+KoZIhvcNAQEFBQADggEBAIlXshZ6qML91tmbmzTCnLQyFE2npN/svqe++EPbkTfO
+tDIuUFUaNU52Q3Eg75N3ThVwLofDwR1t3Mu1J9QsVtFSUzpE0nPIxBsFZVpikpzu
+QY0x2+c06lkh1QF612S4ZDnNye2v7UsDSKegmQGA3GWjNq5lWUhPgkvIZfFXHeVZ
+Lgo/bNjR9eUJtGxUAArgFU2HdW23WJZa3W3SAKD0m0i+wzekujbgfIeFlxoVot4u
+olu9rxj5kFDNcFn4J2dHy8egBzp90SxdbBk6ZrV9/ZFvgrG+CJPbFEfxojfHRZ48
+x3evZKiT3/Zpg4Jg8klCNO1aAFSFHBY2kgxc+qatv9s=
+-----END CERTIFICATE-----
+
+# Issuer: CN=AffirmTrust Premium O=AffirmTrust
+# Subject: CN=AffirmTrust Premium O=AffirmTrust
+# Label: "AffirmTrust Premium"
+# Serial: 7893706540734352110
+# MD5 Fingerprint: c4:5d:0e:48:b6:ac:28:30:4e:0a:bc:f9:38:16:87:57
+# SHA1 Fingerprint: d8:a6:33:2c:e0:03:6f:b1:85:f6:63:4f:7d:6a:06:65:26:32:28:27
+# SHA256 Fingerprint: 70:a7:3f:7f:37:6b:60:07:42:48:90:45:34:b1:14:82:d5:bf:0e:69:8e:cc:49:8d:f5:25:77:eb:f2:e9:3b:9a
+-----BEGIN CERTIFICATE-----
+MIIFRjCCAy6gAwIBAgIIbYwURrGmCu4wDQYJKoZIhvcNAQEMBQAwQTELMAkGA1UE
+BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVz
+dCBQcmVtaXVtMB4XDTEwMDEyOTE0MTAzNloXDTQwMTIzMTE0MTAzNlowQTELMAkG
+A1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1U
+cnVzdCBQcmVtaXVtMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxBLf
+qV/+Qd3d9Z+K4/as4Tx4mrzY8H96oDMq3I0gW64tb+eT2TZwamjPjlGjhVtnBKAQ
+JG9dKILBl1fYSCkTtuG+kU3fhQxTGJoeJKJPj/CihQvL9Cl/0qRY7iZNyaqoe5rZ
++jjeRFcV5fiMyNlI4g0WJx0eyIOFJbe6qlVBzAMiSy2RjYvmia9mx+n/K+k8rNrS
+s8PhaJyJ+HoAVt70VZVs+7pk3WKL3wt3MutizCaam7uqYoNMtAZ6MMgpv+0GTZe5
+HMQxK9VfvFMSF5yZVylmd2EhMQcuJUmdGPLu8ytxjLW6OQdJd/zvLpKQBY0tL3d7
+70O/Nbua2Plzpyzy0FfuKE4mX4+QaAkvuPjcBukumj5Rp9EixAqnOEhss/n/fauG
+V+O61oV4d7pD6kh/9ti+I20ev9E2bFhc8e6kGVQa9QPSdubhjL08s9NIS+LI+H+S
+qHZGnEJlPqQewQcDWkYtuJfzt9WyVSHvutxMAJf7FJUnM7/oQ0dG0giZFmA7mn7S
+5u046uwBHjxIVkkJx0w3AJ6IDsBz4W9m6XJHMD4Q5QsDyZpCAGzFlH5hxIrff4Ia
+C1nEWTJ3s7xgaVY5/bQGeyzWZDbZvUjthB9+pSKPKrhC9IK31FOQeE4tGv2Bb0TX
+OwF0lkLgAOIua+rF7nKsu7/+6qqo+Nz2snmKtmcCAwEAAaNCMEAwHQYDVR0OBBYE
+FJ3AZ6YMItkm9UWrpmVSESfYRaxjMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/
+BAQDAgEGMA0GCSqGSIb3DQEBDAUAA4ICAQCzV00QYk465KzquByvMiPIs0laUZx2
+KI15qldGF9X1Uva3ROgIRL8YhNILgM3FEv0AVQVhh0HctSSePMTYyPtwni94loMg
+Nt58D2kTiKV1NpgIpsbfrM7jWNa3Pt668+s0QNiigfV4Py/VpfzZotReBA4Xrf5B
+8OWycvpEgjNC6C1Y91aMYj+6QrCcDFx+LmUmXFNPALJ4fqENmS2NuB2OosSw/WDQ
+MKSOyARiqcTtNd56l+0OOF6SL5Nwpamcb6d9Ex1+xghIsV5n61EIJenmJWtSKZGc
+0jlzCFfemQa0W50QBuHCAKi4HEoCChTQwUHK+4w1IX2COPKpVJEZNZOUbWo6xbLQ
+u4mGk+ibyQ86p3q4ofB4Rvr8Ny/lioTz3/4E2aFooC8k4gmVBtWVyuEklut89pMF
+u+1z6S3RdTnX5yTb2E5fQ4+e0BQ5v1VwSJlXMbSc7kqYA5YwH2AG7hsj/oFgIxpH
+YoWlzBk0gG+zrBrjn/B7SK3VAdlntqlyk+otZrWyuOQ9PLLvTIzq6we/qzWaVYa8
+GKa1qF60g2xraUDTn9zxw2lrueFtCfTxqlB2Cnp9ehehVZZCmTEJ3WARjQUwfuaO
+RtGdFNrHF+QFlozEJLUbzxQHskD4o55BhrwE0GuWyCqANP2/7waj3VjFhT0+j/6e
+KeC2uAloGRwYQw==
+-----END CERTIFICATE-----
+
+# Issuer: CN=AffirmTrust Premium ECC O=AffirmTrust
+# Subject: CN=AffirmTrust Premium ECC O=AffirmTrust
+# Label: "AffirmTrust Premium ECC"
+# Serial: 8401224907861490260
+# MD5 Fingerprint: 64:b0:09:55:cf:b1:d5:99:e2:be:13:ab:a6:5d:ea:4d
+# SHA1 Fingerprint: b8:23:6b:00:2f:1d:16:86:53:01:55:6c:11:a4:37:ca:eb:ff:c3:bb
+# SHA256 Fingerprint: bd:71:fd:f6:da:97:e4:cf:62:d1:64:7a:dd:25:81:b0:7d:79:ad:f8:39:7e:b4:ec:ba:9c:5e:84:88:82:14:23
+-----BEGIN CERTIFICATE-----
+MIIB/jCCAYWgAwIBAgIIdJclisc/elQwCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMC
+VVMxFDASBgNVBAoMC0FmZmlybVRydXN0MSAwHgYDVQQDDBdBZmZpcm1UcnVzdCBQ
+cmVtaXVtIEVDQzAeFw0xMDAxMjkxNDIwMjRaFw00MDEyMzExNDIwMjRaMEUxCzAJ
+BgNVBAYTAlVTMRQwEgYDVQQKDAtBZmZpcm1UcnVzdDEgMB4GA1UEAwwXQWZmaXJt
+VHJ1c3QgUHJlbWl1bSBFQ0MwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQNMF4bFZ0D
+0KF5Nbc6PJJ6yhUczWLznCZcBz3lVPqj1swS6vQUX+iOGasvLkjmrBhDeKzQN8O9
+ss0s5kfiGuZjuD0uL3jET9v0D6RoTFVya5UdThhClXjMNzyR4ptlKymjQjBAMB0G
+A1UdDgQWBBSaryl6wBE1NSZRMADDav5A1a7WPDAPBgNVHRMBAf8EBTADAQH/MA4G
+A1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAXCfOHiFBar8jAQr9HX/Vs
+aobgxCd05DhT1wV/GzTjxi+zygk8N53X57hG8f2h4nECMEJZh0PUUd+60wkyWs6I
+flc9nF9Ca/UHLbXwgpP5WW+uZPpY5Yse42O+tYHNbwKMeQ==
+-----END CERTIFICATE-----
+
+# Issuer: CN=StartCom Certification Authority O=StartCom Ltd. OU=Secure Digital Certificate Signing
+# Subject: CN=StartCom Certification Authority O=StartCom Ltd. OU=Secure Digital Certificate Signing
+# Label: "StartCom Certification Authority"
+# Serial: 45
+# MD5 Fingerprint: c9:3b:0d:84:41:fc:a4:76:79:23:08:57:de:10:19:16
+# SHA1 Fingerprint: a3:f1:33:3f:e2:42:bf:cf:c5:d1:4e:8f:39:42:98:40:68:10:d1:a0
+# SHA256 Fingerprint: e1:78:90:ee:09:a3:fb:f4:f4:8b:9c:41:4a:17:d6:37:b7:a5:06:47:e9:bc:75:23:22:72:7f:cc:17:42:a9:11
+-----BEGIN CERTIFICATE-----
+MIIHhzCCBW+gAwIBAgIBLTANBgkqhkiG9w0BAQsFADB9MQswCQYDVQQGEwJJTDEW
+MBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0YWwg
+Q2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMgU3RhcnRDb20gQ2VydGlmaWNh
+dGlvbiBBdXRob3JpdHkwHhcNMDYwOTE3MTk0NjM3WhcNMzYwOTE3MTk0NjM2WjB9
+MQswCQYDVQQGEwJJTDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMi
+U2VjdXJlIERpZ2l0YWwgQ2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMgU3Rh
+cnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUA
+A4ICDwAwggIKAoICAQDBiNsJvGxGfHiflXu1M5DycmLWwTYgIiRezul38kMKogZk
+pMyONvg45iPwbm2xPN1yo4UcodM9tDMr0y+v/uqwQVlntsQGfQqedIXWeUyAN3rf
+OQVSWff0G0ZDpNKFhdLDcfN1YjS6LIp/Ho/u7TTQEceWzVI9ujPW3U3eCztKS5/C
+Ji/6tRYccjV3yjxd5srhJosaNnZcAdt0FCX+7bWgiA/deMotHweXMAEtcnn6RtYT
+Kqi5pquDSR3l8u/d5AGOGAqPY1MWhWKpDhk6zLVmpsJrdAfkK+F2PrRt2PZE4XNi
+HzvEvqBTViVsUQn3qqvKv3b9bZvzndu/PWa8DFaqr5hIlTpL36dYUNk4dalb6kMM
+Av+Z6+hsTXBbKWWc3apdzK8BMewM69KN6Oqce+Zu9ydmDBpI125C4z/eIT574Q1w
++2OqqGwaVLRcJXrJosmLFqa7LH4XXgVNWG4SHQHuEhANxjJ/GP/89PrNbpHoNkm+
+Gkhpi8KWTRoSsmkXwQqQ1vp5Iki/untp+HDH+no32NgN0nZPV/+Qt+OR0t3vwmC3
+Zzrd/qqc8NSLf3Iizsafl7b4r4qgEKjZ+xjGtrVcUjyJthkqcwEKDwOzEmDyei+B
+26Nu/yYwl/WL3YlXtq09s68rxbd2AvCl1iuahhQqcvbjM4xdCUsT37uMdBNSSwID
+AQABo4ICEDCCAgwwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD
+VR0OBBYEFE4L7xqkQFulF2mHMMo0aEPQQa7yMB8GA1UdIwQYMBaAFE4L7xqkQFul
+F2mHMMo0aEPQQa7yMIIBWgYDVR0gBIIBUTCCAU0wggFJBgsrBgEEAYG1NwEBATCC
+ATgwLgYIKwYBBQUHAgEWImh0dHA6Ly93d3cuc3RhcnRzc2wuY29tL3BvbGljeS5w
+ZGYwNAYIKwYBBQUHAgEWKGh0dHA6Ly93d3cuc3RhcnRzc2wuY29tL2ludGVybWVk
+aWF0ZS5wZGYwgc8GCCsGAQUFBwICMIHCMCcWIFN0YXJ0IENvbW1lcmNpYWwgKFN0
+YXJ0Q29tKSBMdGQuMAMCAQEagZZMaW1pdGVkIExpYWJpbGl0eSwgcmVhZCB0aGUg
+c2VjdGlvbiAqTGVnYWwgTGltaXRhdGlvbnMqIG9mIHRoZSBTdGFydENvbSBDZXJ0
+aWZpY2F0aW9uIEF1dGhvcml0eSBQb2xpY3kgYXZhaWxhYmxlIGF0IGh0dHA6Ly93
+d3cuc3RhcnRzc2wuY29tL3BvbGljeS5wZGYwEQYJYIZIAYb4QgEBBAQDAgAHMDgG
+CWCGSAGG+EIBDQQrFilTdGFydENvbSBGcmVlIFNTTCBDZXJ0aWZpY2F0aW9uIEF1
+dGhvcml0eTANBgkqhkiG9w0BAQsFAAOCAgEAjo/n3JR5fPGFf59Jb2vKXfuM/gTF
+wWLRfUKKvFO3lANmMD+x5wqnUCBVJX92ehQN6wQOQOY+2IirByeDqXWmN3PH/UvS
+Ta0XQMhGvjt/UfzDtgUx3M2FIk5xt/JxXrAaxrqTi3iSSoX4eA+D/i+tLPfkpLst
+0OcNOrg+zvZ49q5HJMqjNTbOx8aHmNrs++myziebiMMEofYLWWivydsQD032ZGNc
+pRJvkrKTlMeIFw6Ttn5ii5B/q06f/ON1FE8qMt9bDeD1e5MNq6HPh+GlBEXoPBKl
+CcWw0bdT82AUuoVpaiF8H3VhFyAXe2w7QSlc4axa0c2Mm+tgHRns9+Ww2vl5GKVF
+P0lDV9LdJNUso/2RjSe15esUBppMeyG7Oq0wBhjA2MFrLH9ZXF2RsXAiV+uKa0hK
+1Q8p7MZAwC+ITGgBF3f0JBlPvfrhsiAhS90a2Cl9qrjeVOwhVYBsHvUwyKMQ5bLm
+KhQxw4UtjJixhlpPiVktucf3HMiKf8CdBUrmQk9io20ppB+Fq9vlgcitKj1MXVuE
+JnHEhV5xJMqlG2zYYdMa4FTbzrqpMrUi9nNBCV24F10OD5mQ1kfabwo6YigUZ4LZ
+8dCAWZvLMdibD4x3TrVoivJs9iQOLWxwxXPR3hTQcY+203sC9uO41Alua551hDnm
+fyWl8kgAwKQB2j8=
+-----END CERTIFICATE-----
+
+# Issuer: CN=StartCom Certification Authority G2 O=StartCom Ltd.
+# Subject: CN=StartCom Certification Authority G2 O=StartCom Ltd.
+# Label: "StartCom Certification Authority G2"
+# Serial: 59
+# MD5 Fingerprint: 78:4b:fb:9e:64:82:0a:d3:b8:4c:62:f3:64:f2:90:64
+# SHA1 Fingerprint: 31:f1:fd:68:22:63:20:ee:c6:3b:3f:9d:ea:4a:3e:53:7c:7c:39:17
+# SHA256 Fingerprint: c7:ba:65:67:de:93:a7:98:ae:1f:aa:79:1e:71:2d:37:8f:ae:1f:93:c4:39:7f:ea:44:1b:b7:cb:e6:fd:59:95
+-----BEGIN CERTIFICATE-----
+MIIFYzCCA0ugAwIBAgIBOzANBgkqhkiG9w0BAQsFADBTMQswCQYDVQQGEwJJTDEW
+MBQGA1UEChMNU3RhcnRDb20gTHRkLjEsMCoGA1UEAxMjU3RhcnRDb20gQ2VydGlm
+aWNhdGlvbiBBdXRob3JpdHkgRzIwHhcNMTAwMTAxMDEwMDAxWhcNMzkxMjMxMjM1
+OTAxWjBTMQswCQYDVQQGEwJJTDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjEsMCoG
+A1UEAxMjU3RhcnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgRzIwggIiMA0G
+CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2iTZbB7cgNr2Cu+EWIAOVeq8Oo1XJ
+JZlKxdBWQYeQTSFgpBSHO839sj60ZwNq7eEPS8CRhXBF4EKe3ikj1AENoBB5uNsD
+vfOpL9HG4A/LnooUCri99lZi8cVytjIl2bLzvWXFDSxu1ZJvGIsAQRSCb0AgJnoo
+D/Uefyf3lLE3PbfHkffiAez9lInhzG7TNtYKGXmu1zSCZf98Qru23QumNK9LYP5/
+Q0kGi4xDuFby2X8hQxfqp0iVAXV16iulQ5XqFYSdCI0mblWbq9zSOdIxHWDirMxW
+RST1HFSr7obdljKF+ExP6JV2tgXdNiNnvP8V4so75qbsO+wmETRIjfaAKxojAuuK
+HDp2KntWFhxyKrOq42ClAJ8Em+JvHhRYW6Vsi1g8w7pOOlz34ZYrPu8HvKTlXcxN
+nw3h3Kq74W4a7I/htkxNeXJdFzULHdfBR9qWJODQcqhaX2YtENwvKhOuJv4KHBnM
+0D4LnMgJLvlblnpHnOl68wVQdJVznjAJ85eCXuaPOQgeWeU1FEIT/wCc976qUM/i
+UUjXuG+v+E5+M5iSFGI6dWPPe/regjupuznixL0sAA7IF6wT700ljtizkC+p2il9
+Ha90OrInwMEePnWjFqmveiJdnxMaz6eg6+OGCtP95paV1yPIN93EfKo2rJgaErHg
+TuixO/XWb/Ew1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE
+AwIBBjAdBgNVHQ4EFgQUS8W0QGutHLOlHGVuRjaJhwUMDrYwDQYJKoZIhvcNAQEL
+BQADggIBAHNXPyzVlTJ+N9uWkusZXn5T50HsEbZH77Xe7XRcxfGOSeD8bpkTzZ+K
+2s06Ctg6Wgk/XzTQLwPSZh0avZyQN8gMjgdalEVGKua+etqhqaRpEpKwfTbURIfX
+UfEpY9Z1zRbkJ4kd+MIySP3bmdCPX1R0zKxnNBFi2QwKN4fRoxdIjtIXHfbX/dtl
+6/2o1PXWT6RbdejF0mCy2wl+JYt7ulKSnj7oxXehPOBKc2thz4bcQ///If4jXSRK
+9dNtD2IEBVeC2m6kMyV5Sy5UGYvMLD0w6dEG/+gyRr61M3Z3qAFdlsHB1b6uJcDJ
+HgoJIIihDsnzb02CVAAgp9KP5DlUFy6NHrgbuxu9mk47EDTcnIhT76IxW1hPkWLI
+wpqazRVdOKnWvvgTtZ8SafJQYqz7Fzf07rh1Z2AQ+4NQ+US1dZxAF7L+/XldblhY
+XzD8AK6vM8EOTmy6p6ahfzLbOOCxchcKK5HsamMm7YnUeMx0HgX4a/6ManY5Ka5l
+IxKVCCIcl85bBu4M4ru8H0ST9tg4RQUh7eStqxK2A6RCLi3ECToDZ2mEmuFZkIoo
+hdVddLHRDiBYmxOlsGOm7XtH/UVVMKTumtTm4ofvmMkyghEpIrwACjFeLQ/Ajulr
+so8uBtjRkcfGEvRM/TAXw8HaOFvjqermobp573PYtlNXLfbQ4ddI
+-----END CERTIFICATE-----
diff --git a/sdk/ruby-google-api-client/lib/compat/multi_json.rb b/sdk/ruby-google-api-client/lib/compat/multi_json.rb
new file mode 100644 (file)
index 0000000..3974f08
--- /dev/null
@@ -0,0 +1,19 @@
+require 'multi_json'
+
+if !MultiJson.respond_to?(:load) || [
+  Kernel,
+  defined?(ActiveSupport::Dependencies::Loadable) && ActiveSupport::Dependencies::Loadable
+].compact.include?(MultiJson.method(:load).owner)
+  module MultiJson
+    class <<self
+      alias :load :decode
+    end
+  end
+end
+if !MultiJson.respond_to?(:dump)
+  module MultiJson
+    class <<self
+      alias :dump :encode
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client.rb b/sdk/ruby-google-api-client/lib/google/api_client.rb
new file mode 100644 (file)
index 0000000..1c69a4a
--- /dev/null
@@ -0,0 +1,756 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+require 'faraday'
+require 'faraday/gzip'
+require 'multi_json'
+require 'compat/multi_json'
+require 'stringio'
+require 'retriable'
+
+require 'google/api_client/version'
+require 'google/api_client/logging'
+require 'google/api_client/errors'
+require 'google/api_client/environment'
+require 'google/api_client/discovery'
+require 'google/api_client/request'
+require 'google/api_client/reference'
+require 'google/api_client/result'
+require 'google/api_client/media'
+require 'google/api_client/service_account'
+require 'google/api_client/batch'
+require 'google/api_client/charset'
+require 'google/api_client/client_secrets'
+require 'google/api_client/railtie' if defined?(Rails)
+
+module Google
+
+  ##
+  # This class manages APIs communication.
+  class APIClient
+    include Google::APIClient::Logging
+
+    ##
+    # Creates a new Google API client.
+    #
+    # @param [Hash] options The configuration parameters for the client.
+    # @option options [Symbol, #generate_authenticated_request] :authorization
+    #   (:oauth_1)
+    #   The authorization mechanism used by the client.  The following
+    #   mechanisms are supported out-of-the-box:
+    #   <ul>
+    #     <li><code>:two_legged_oauth_1</code></li>
+    #     <li><code>:oauth_1</code></li>
+    #     <li><code>:oauth_2</code></li>
+    #     <li><code>:google_app_default</code></li>
+    #   </ul>
+    # @option options [Boolean] :auto_refresh_token (true)
+    #   The setting that controls whether or not the api client attempts to
+    #   refresh authorization when a 401 is hit in #execute. If the token does
+    #   not support it, this option is ignored.
+    # @option options [String] :application_name
+    #   The name of the application using the client.
+    # @option options [String | Array | nil] :scope
+    #   The scope(s) used when using google application default credentials
+    # @option options [String] :application_version
+    #   The version number of the application using the client.
+    # @option options [String] :user_agent
+    #   ("{app_name} google-api-ruby-client/{version} {os_name}/{os_version}")
+    #   The user agent used by the client.  Most developers will want to
+    #   leave this value alone and use the `:application_name` option instead.
+    # @option options [String] :host ("www.googleapis.com")
+    #   The API hostname used by the client. This rarely needs to be changed.
+    # @option options [String] :port (443)
+    #   The port number used by the client. This rarely needs to be changed.
+    # @option options [String] :discovery_path ("/discovery/v1")
+    #   The discovery base path. This rarely needs to be changed.
+    # @option options [String] :ca_file
+    #   Optional set of root certificates to use when validating SSL connections.
+    #   By default, a bundled set of trusted roots will be used.
+    # @options options[Hash] :force_encoding
+    #   Experimental option. True if response body should be force encoded into the charset
+    #   specified in the Content-Type header. Mostly intended for compressed content.
+    # @options options[Hash] :faraday_options
+    #   Pass through of options to set on the Faraday connection
+    def initialize(options={})
+      logger.debug { "#{self.class} - Initializing client with options #{options}" }
+
+      # Normalize key to String to allow indifferent access.
+      options = options.inject({}) do |accu, (key, value)|
+        accu[key.to_sym] = value
+        accu
+      end
+      # Almost all API usage will have a host of 'www.googleapis.com'.
+      self.host = options[:host] || 'www.googleapis.com'
+      self.port = options[:port] || 443
+      self.discovery_path = options[:discovery_path] || '/discovery/v1'
+
+      # Most developers will want to leave this value alone and use the
+      # application_name option.
+      if options[:application_name]
+        app_name = options[:application_name]
+        app_version = options[:application_version]
+        application_string = "#{app_name}/#{app_version || '0.0.0'}"
+      else
+        logger.warn { "#{self.class} - Please provide :application_name and :application_version when initializing the client" }
+      end
+
+      proxy = options[:proxy] || Object::ENV["http_proxy"]
+
+      self.user_agent = options[:user_agent] || (
+        "#{application_string} " +
+        "google-api-ruby-client/#{Google::APIClient::VERSION::STRING} #{ENV::OS_VERSION}".strip + " (gzip)"
+      ).strip
+      # The writer method understands a few Symbols and will generate useful
+      # default authentication mechanisms.
+      self.authorization =
+        options.key?(:authorization) ? options[:authorization] : :oauth_2
+      if !options['scope'].nil? and self.authorization.respond_to?(:scope=)
+        self.authorization.scope = options['scope']
+      end
+      self.auto_refresh_token = options.fetch(:auto_refresh_token) { true }
+      self.key = options[:key]
+      self.user_ip = options[:user_ip]
+      self.retries = options.fetch(:retries) { 0 }
+      self.expired_auth_retry = options.fetch(:expired_auth_retry) { true }
+      @discovery_uris = {}
+      @discovery_documents = {}
+      @discovered_apis = {}
+      ca_file = options[:ca_file] || File.expand_path('../../cacerts.pem', __FILE__)
+      self.connection = Faraday.new do |faraday|
+        faraday.request :gzip
+        faraday.response :charset if options[:force_encoding]
+        faraday.options.params_encoder = Faraday::FlatParamsEncoder
+        faraday.ssl.ca_file = ca_file
+        faraday.ssl.verify = true
+        if faraday.respond_to?(:proxy=)
+          # faraday >= 0.6.2
+          faraday.proxy = proxy
+        else
+          # older versions of faraday
+          faraday.proxy proxy
+        end
+        faraday.adapter Faraday.default_adapter
+        if options[:faraday_option].is_a?(Hash)
+          options[:faraday_option].each_pair do |option, value|
+            faraday.options.send("#{option}=", value)
+          end
+        end
+      end
+      return self
+    end
+
+    ##
+    # Returns the authorization mechanism used by the client.
+    #
+    # @return [#generate_authenticated_request] The authorization mechanism.
+    attr_reader :authorization
+
+    ##
+    # Sets the authorization mechanism used by the client.
+    #
+    # @param [#generate_authenticated_request] new_authorization
+    #   The new authorization mechanism.
+    def authorization=(new_authorization)
+      case new_authorization
+      when :oauth_1, :oauth
+        require 'signet/oauth_1/client'
+        # NOTE: Do not rely on this default value, as it may change
+        new_authorization = Signet::OAuth1::Client.new(
+          :temporary_credential_uri =>
+            'https://www.google.com/accounts/OAuthGetRequestToken',
+          :authorization_uri =>
+            'https://www.google.com/accounts/OAuthAuthorizeToken',
+          :token_credential_uri =>
+            'https://www.google.com/accounts/OAuthGetAccessToken',
+          :client_credential_key => 'anonymous',
+          :client_credential_secret => 'anonymous'
+        )
+      when :two_legged_oauth_1, :two_legged_oauth
+        require 'signet/oauth_1/client'
+        # NOTE: Do not rely on this default value, as it may change
+        new_authorization = Signet::OAuth1::Client.new(
+          :client_credential_key => nil,
+          :client_credential_secret => nil,
+          :two_legged => true
+        )
+      when :google_app_default
+        require 'googleauth'
+        new_authorization = Google::Auth.get_application_default
+
+      when :oauth_2
+        require 'signet/oauth_2/client'
+        # NOTE: Do not rely on this default value, as it may change
+        new_authorization = Signet::OAuth2::Client.new(
+          :authorization_uri =>
+            'https://accounts.google.com/o/oauth2/auth',
+          :token_credential_uri =>
+            'https://accounts.google.com/o/oauth2/token'
+        )
+      when nil
+        # No authorization mechanism
+      else
+        if !new_authorization.respond_to?(:generate_authenticated_request)
+          raise TypeError,
+            'Expected authorization mechanism to respond to ' +
+            '#generate_authenticated_request.'
+        end
+      end
+      @authorization = new_authorization
+      return @authorization
+    end
+
+    ##
+    # Default Faraday/HTTP connection.
+    #
+    # @return [Faraday::Connection]
+    attr_accessor :connection
+
+    ##
+    # The setting that controls whether or not the api client attempts to
+    # refresh authorization when a 401 is hit in #execute.
+    #
+    # @return [Boolean]
+    attr_accessor :auto_refresh_token
+
+    ##
+    # The application's API key issued by the API console.
+    #
+    # @return [String] The API key.
+    attr_accessor :key
+
+    ##
+    # The IP address of the user this request is being performed on behalf of.
+    #
+    # @return [String] The user's IP address.
+    attr_accessor :user_ip
+
+    ##
+    # The user agent used by the client.
+    #
+    # @return [String]
+    #   The user agent string used in the User-Agent header.
+    attr_accessor :user_agent
+
+    ##
+    # The API hostname used by the client.
+    #
+    # @return [String]
+    #   The API hostname. Should almost always be 'www.googleapis.com'.
+    attr_accessor :host
+
+    ##
+    # The port number used by the client.
+    #
+    # @return [String]
+    #   The port number. Should almost always be 443.
+    attr_accessor :port
+
+    ##
+    # The base path used by the client for discovery.
+    #
+    # @return [String]
+    #   The base path. Should almost always be '/discovery/v1'.
+    attr_accessor :discovery_path
+
+    ##
+    # Number of times to retry on recoverable errors
+    #
+    # @return [FixNum]
+    #  Number of retries
+    attr_accessor :retries
+
+    ##
+    # Whether or not an expired auth token should be re-acquired
+    # (and the operation retried) regardless of retries setting
+    # @return [Boolean]
+    #  Auto retry on auth expiry
+    attr_accessor :expired_auth_retry
+
+    ##
+    # Returns the URI for the directory document.
+    #
+    # @return [Addressable::URI] The URI of the directory document.
+    def directory_uri
+      return resolve_uri(self.discovery_path + '/apis')
+    end
+
+    ##
+    # Manually registers a URI as a discovery document for a specific version
+    # of an API.
+    #
+    # @param [String, Symbol] api The API name.
+    # @param [String] version The desired version of the API.
+    # @param [Addressable::URI] uri The URI of the discovery document.
+    # @return [Google::APIClient::API] The service object.
+    def register_discovery_uri(api, version, uri)
+      api = api.to_s
+      version = version || 'v1'
+      @discovery_uris["#{api}:#{version}"] = uri
+      discovered_api(api, version)
+    end
+
+    ##
+    # Returns the URI for the discovery document.
+    #
+    # @param [String, Symbol] api The API name.
+    # @param [String] version The desired version of the API.
+    # @return [Addressable::URI] The URI of the discovery document.
+    def discovery_uri(api, version=nil)
+      api = api.to_s
+      version = version || 'v1'
+      return @discovery_uris["#{api}:#{version}"] ||= (
+        resolve_uri(
+          self.discovery_path + '/apis/{api}/{version}/rest',
+          'api' => api,
+          'version' => version
+        )
+      )
+    end
+
+    ##
+    # Manually registers a pre-loaded discovery document for a specific version
+    # of an API.
+    #
+    # @param [String, Symbol] api The API name.
+    # @param [String] version The desired version of the API.
+    # @param [String, StringIO] discovery_document
+    #   The contents of the discovery document.
+    # @return [Google::APIClient::API] The service object.
+    def register_discovery_document(api, version, discovery_document)
+      api = api.to_s
+      version = version || 'v1'
+      if discovery_document.kind_of?(StringIO)
+        discovery_document.rewind
+        discovery_document = discovery_document.string
+      elsif discovery_document.respond_to?(:to_str)
+        discovery_document = discovery_document.to_str
+      else
+        raise TypeError,
+          "Expected String or StringIO, got #{discovery_document.class}."
+      end
+      @discovery_documents["#{api}:#{version}"] =
+        MultiJson.load(discovery_document)
+      discovered_api(api, version)
+    end
+
+    ##
+    # Returns the parsed directory document.
+    #
+    # @return [Hash] The parsed JSON from the directory document.
+    def directory_document
+      return @directory_document ||= (begin
+        response = self.execute!(
+          :http_method => :get,
+          :uri => self.directory_uri,
+          :authenticated => false
+        )
+        response.data
+      end)
+    end
+
+    ##
+    # Returns the parsed discovery document.
+    #
+    # @param [String, Symbol] api The API name.
+    # @param [String] version The desired version of the API.
+    # @return [Hash] The parsed JSON from the discovery document.
+    def discovery_document(api, version=nil)
+      api = api.to_s
+      version = version || 'v1'
+      return @discovery_documents["#{api}:#{version}"] ||= (begin
+        response = self.execute!(
+          :http_method => :get,
+          :uri => self.discovery_uri(api, version),
+          :authenticated => false
+        )
+        response.data
+      end)
+    end
+
+    ##
+    # Returns all APIs published in the directory document.
+    #
+    # @return [Array] The list of available APIs.
+    def discovered_apis
+      @directory_apis ||= (begin
+        document_base = self.directory_uri
+        if self.directory_document && self.directory_document['items']
+          self.directory_document['items'].map do |discovery_document|
+            Google::APIClient::API.new(
+              document_base,
+              discovery_document
+            )
+          end
+        else
+          []
+        end
+      end)
+    end
+
+    ##
+    # Returns the service object for a given service name and service version.
+    #
+    # @param [String, Symbol] api The API name.
+    # @param [String] version The desired version of the API.
+    #
+    # @return [Google::APIClient::API] The service object.
+    def discovered_api(api, version=nil)
+      if !api.kind_of?(String) && !api.kind_of?(Symbol)
+        raise TypeError,
+          "Expected String or Symbol, got #{api.class}."
+      end
+      api = api.to_s
+      version = version || 'v1'
+      return @discovered_apis["#{api}:#{version}"] ||= begin
+        document_base = self.discovery_uri(api, version)
+        discovery_document = self.discovery_document(api, version)
+        if document_base && discovery_document
+          Google::APIClient::API.new(
+            document_base,
+            discovery_document
+          )
+        else
+          nil
+        end
+      end
+    end
+
+    ##
+    # Returns the method object for a given RPC name and service version.
+    #
+    # @param [String, Symbol] rpc_name The RPC name of the desired method.
+    # @param [String, Symbol] api The API the method is within.
+    # @param [String] version The desired version of the API.
+    #
+    # @return [Google::APIClient::Method] The method object.
+    def discovered_method(rpc_name, api, version=nil)
+      if !rpc_name.kind_of?(String) && !rpc_name.kind_of?(Symbol)
+        raise TypeError,
+          "Expected String or Symbol, got #{rpc_name.class}."
+      end
+      rpc_name = rpc_name.to_s
+      api = api.to_s
+      version = version || 'v1'
+      service = self.discovered_api(api, version)
+      if service.to_h[rpc_name]
+        return service.to_h[rpc_name]
+      else
+        return nil
+      end
+    end
+
+    ##
+    # Returns the service object with the highest version number.
+    #
+    # @note <em>Warning</em>: This method should be used with great care.
+    # As APIs are updated, minor differences between versions may cause
+    # incompatibilities. Requesting a specific version will avoid this issue.
+    #
+    # @param [String, Symbol] api The name of the service.
+    #
+    # @return [Google::APIClient::API] The service object.
+    def preferred_version(api)
+      if !api.kind_of?(String) && !api.kind_of?(Symbol)
+        raise TypeError,
+          "Expected String or Symbol, got #{api.class}."
+      end
+      api = api.to_s
+      return self.discovered_apis.detect do |a|
+        a.name == api && a.preferred == true
+      end
+    end
+
+    ##
+    # Verifies an ID token against a server certificate. Used to ensure that
+    # an ID token supplied by an untrusted client-side mechanism is valid.
+    # Raises an error if the token is invalid or missing.
+    #
+    # @deprecated Use the google-id-token gem for verifying JWTs
+    def verify_id_token!
+      require 'jwt'
+      require 'openssl'
+      @certificates ||= {}
+      if !self.authorization.respond_to?(:id_token)
+        raise ArgumentError, (
+          "Current authorization mechanism does not support ID tokens: " +
+          "#{self.authorization.class.to_s}"
+        )
+      elsif !self.authorization.id_token
+        raise ArgumentError, (
+          "Could not verify ID token, ID token missing. " +
+          "Scopes were: #{self.authorization.scope.inspect}"
+        )
+      else
+        check_cached_certs = lambda do
+          valid = false
+          for _key, cert in @certificates
+            begin
+              self.authorization.decoded_id_token(cert.public_key)
+              valid = true
+            rescue JWT::DecodeError, Signet::UnsafeOperationError
+              # Expected exception. Ignore, ID token has not been validated.
+            end
+          end
+          valid
+        end
+        if check_cached_certs.call()
+          return true
+        end
+        response = self.execute!(
+          :http_method => :get,
+          :uri => 'https://www.googleapis.com/oauth2/v1/certs',
+          :authenticated => false
+        )
+        @certificates.merge!(
+          Hash[MultiJson.load(response.body).map do |key, cert|
+            [key, OpenSSL::X509::Certificate.new(cert)]
+          end]
+        )
+        if check_cached_certs.call()
+          return true
+        else
+          raise InvalidIDTokenError,
+            "Could not verify ID token against any available certificate."
+        end
+      end
+      return nil
+    end
+
+    ##
+    # Generates a request.
+    #
+    # @option options [Google::APIClient::Method] :api_method
+    #   The method object or the RPC name of the method being executed.
+    # @option options [Hash, Array] :parameters
+    #   The parameters to send to the method.
+    # @option options [Hash, Array] :headers The HTTP headers for the request.
+    # @option options [String] :body The body of the request.
+    # @option options [String] :version ("v1")
+    #   The service version. Only used if `api_method` is a `String`.
+    # @option options [#generate_authenticated_request] :authorization
+    #   The authorization mechanism for the response. Used only if
+    #   `:authenticated` is `true`.
+    # @option options [TrueClass, FalseClass] :authenticated (true)
+    #   `true` if the request must be signed or somehow
+    #   authenticated, `false` otherwise.
+    #
+    # @return [Google::APIClient::Reference] The generated request.
+    #
+    # @example
+    #   request = client.generate_request(
+    #     :api_method => 'plus.activities.list',
+    #     :parameters =>
+    #       {'collection' => 'public', 'userId' => 'me'}
+    #   )
+    def generate_request(options={})
+      options = {
+        :api_client => self
+      }.merge(options)
+      return Google::APIClient::Request.new(options)
+    end
+
+    ##
+    # Executes a request, wrapping it in a Result object.
+    #
+    # @param [Google::APIClient::Request, Hash, Array] params
+    #   Either a Google::APIClient::Request, a Hash, or an Array.
+    #
+    #   If a Google::APIClient::Request, no other parameters are expected.
+    #
+    #   If a Hash, the below parameters are handled. If an Array, the
+    #   parameters are assumed to be in the below order:
+    #
+    #   - (Google::APIClient::Method) api_method:
+    #     The method object or the RPC name of the method being executed.
+    #   - (Hash, Array) parameters:
+    #     The parameters to send to the method.
+    #   - (String) body: The body of the request.
+    #   - (Hash, Array) headers: The HTTP headers for the request.
+    #   - (Hash) options: A set of options for the request, of which:
+    #     - (#generate_authenticated_request) :authorization (default: true) -
+    #       The authorization mechanism for the response. Used only if
+    #       `:authenticated` is `true`.
+    #     - (TrueClass, FalseClass) :authenticated (default: true) -
+    #       `true` if the request must be signed or somehow
+    #       authenticated, `false` otherwise.
+    #     - (TrueClass, FalseClass) :gzip (default: true) -
+    #       `true` if gzip enabled, `false` otherwise.
+    #     - (FixNum) :retries -
+    #       # of times to retry on recoverable errors
+    #
+    # @return [Google::APIClient::Result] The result from the API, nil if batch.
+    #
+    # @example
+    #   result = client.execute(batch_request)
+    #
+    # @example
+    #   plus = client.discovered_api('plus')
+    #   result = client.execute(
+    #     :api_method => plus.activities.list,
+    #     :parameters => {'collection' => 'public', 'userId' => 'me'}
+    #   )
+    #
+    # @see Google::APIClient#generate_request
+    def execute!(*params)
+      if params.first.kind_of?(Google::APIClient::Request)
+        request = params.shift
+        options = params.shift || {}
+      else
+        # This block of code allows us to accept multiple parameter passing
+        # styles, and maintaining some backwards compatibility.
+        #
+        # Note: I'm extremely tempted to deprecate this style of execute call.
+        if params.last.respond_to?(:to_hash) && params.size == 1
+          options = params.pop
+        else
+          options = {}
+        end
+
+        options[:api_method] = params.shift if params.size > 0
+        options[:parameters] = params.shift if params.size > 0
+        options[:body] = params.shift if params.size > 0
+        options[:headers] = params.shift if params.size > 0
+        options.update(params.shift) if params.size > 0
+        request = self.generate_request(options)
+      end
+
+      request.headers['User-Agent'] ||= '' + self.user_agent unless self.user_agent.nil?
+      request.headers['Accept-Encoding'] ||= 'gzip' unless options[:gzip] == false
+      request.headers['Content-Type'] ||= ''
+      request.parameters['key'] ||= self.key unless self.key.nil?
+      request.parameters['userIp'] ||= self.user_ip unless self.user_ip.nil?
+
+      connection = options[:connection] || self.connection
+      request.authorization = options[:authorization] || self.authorization unless options[:authenticated] == false
+
+      tries = 1 + (options[:retries] || self.retries)
+      attempt = 0
+
+      Retriable.retriable :tries => tries,
+                          :on => [TransmissionError],
+                          :on_retry => client_error_handler,
+                          :interval => lambda {|attempts| (2 ** attempts) + rand} do
+        attempt += 1
+
+        # This 2nd level retriable only catches auth errors, and supports 1 retry, which allows
+        # auth to be re-attempted without having to retry all sorts of other failures like
+        # NotFound, etc
+        Retriable.retriable :tries => ((expired_auth_retry || tries > 1) && attempt == 1) ? 2 : 1,
+                            :on => [AuthorizationError],
+                            :on_retry => authorization_error_handler(request.authorization) do
+          result = request.send(connection, true)
+
+          case result.status
+            when 200...300
+              result
+            when 301, 302, 303, 307
+              request = generate_request(request.to_hash.merge({
+                :uri => result.headers['location'],
+                :api_method => nil
+              }))
+              raise RedirectError.new(result.headers['location'], result)
+            when 401
+              raise AuthorizationError.new(result.error_message || 'Invalid/Expired Authentication', result)
+            when 400, 402...500
+              raise ClientError.new(result.error_message || "A client error has occurred", result)
+            when 500...600
+              raise ServerError.new(result.error_message || "A server error has occurred", result)
+            else
+              raise TransmissionError.new(result.error_message || "A transmission error has occurred", result)
+          end
+        end
+      end
+    end
+
+    ##
+    # Same as Google::APIClient#execute!, but does not raise an exception for
+    # normal API errros.
+    #
+    # @see Google::APIClient#execute
+    def execute(*params)
+      begin
+        return self.execute!(*params)
+      rescue TransmissionError => e
+        return e.result
+      end
+    end
+
+    protected
+
+    ##
+    # Resolves a URI template against the client's configured base.
+    #
+    # @api private
+    # @param [String, Addressable::URI, Addressable::Template] template
+    #   The template to resolve.
+    # @param [Hash] mapping The mapping that corresponds to the template.
+    # @return [Addressable::URI] The expanded URI.
+    def resolve_uri(template, mapping={})
+      @base_uri ||= Addressable::URI.new(
+        :scheme => 'https',
+        :host => self.host,
+        :port => self.port
+      ).normalize
+      template = if template.kind_of?(Addressable::Template)
+        template.pattern
+      elsif template.respond_to?(:to_str)
+        template.to_str
+      else
+        raise TypeError,
+          "Expected String, Addressable::URI, or Addressable::Template, " +
+          "got #{template.class}."
+      end
+      return Addressable::Template.new(@base_uri + template).expand(mapping)
+    end
+
+
+    ##
+    # Returns on proc for special processing of retries for authorization errors
+    # Only 401s should be retried and only if the credentials are refreshable
+    #
+    # @param [#fetch_access_token!] authorization
+    #   OAuth 2 credentials
+    # @return [Proc]
+    def authorization_error_handler(authorization)
+      can_refresh = authorization.respond_to?(:refresh_token) && auto_refresh_token
+      Proc.new do |exception, tries|
+        next unless exception.kind_of?(AuthorizationError)
+        if can_refresh
+          begin
+            logger.debug("Attempting refresh of access token & retry of request")
+            authorization.fetch_access_token!
+            next
+          rescue Signet::AuthorizationError
+          end
+        end
+        raise exception
+      end
+    end
+
+    ##
+    # Returns on proc for special processing of retries as not all client errors
+    # are recoverable. Only 401s should be retried (via authorization_error_handler)
+    #
+    # @return [Proc]
+    def client_error_handler
+      Proc.new do |exception, tries|
+        raise exception if exception.kind_of?(ClientError)
+      end
+    end
+
+  end
+
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/auth/compute_service_account.rb b/sdk/ruby-google-api-client/lib/google/api_client/auth/compute_service_account.rb
new file mode 100644 (file)
index 0000000..118f1e6
--- /dev/null
@@ -0,0 +1,28 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'faraday'
+require 'signet/oauth_2/client'
+
+module Google
+  class APIClient
+    class ComputeServiceAccount < Signet::OAuth2::Client
+      def fetch_access_token(options={})
+        connection = options[:connection] || Faraday.default_connection
+        response = connection.get 'http://metadata/computeMetadata/v1beta1/instance/service-accounts/default/token'
+        Signet::OAuth2.parse_credentials(response.body, response.headers['content-type'])
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/auth/file_storage.rb b/sdk/ruby-google-api-client/lib/google/api_client/auth/file_storage.rb
new file mode 100644 (file)
index 0000000..b3d0171
--- /dev/null
@@ -0,0 +1,59 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'signet/oauth_2/client'
+require_relative 'storage'
+require_relative 'storages/file_store'
+
+module Google
+  class APIClient
+
+    ##
+    # Represents cached OAuth 2 tokens stored on local disk in a
+    # JSON serialized file. Meant to resemble the serialized format
+    # http://google-api-python-client.googlecode.com/hg/docs/epy/oauth2client.file.Storage-class.html
+    #
+    # @deprecated
+    #  Use {Google::APIClient::Storage} and {Google::APIClient::FileStore} instead
+    #
+    class FileStorage
+
+      attr_accessor :storage
+
+      def initialize(path)
+        store = Google::APIClient::FileStore.new(path)
+        @storage = Google::APIClient::Storage.new(store)
+        @storage.authorize
+      end
+
+      def load_credentials
+        storage.authorize
+      end
+
+      def authorization
+        storage.authorization
+      end
+
+      ##
+      # Write the credentials to the specified file.
+      #
+      # @param [Signet::OAuth2::Client] authorization
+      #    Optional authorization instance. If not provided, the authorization
+      #    already associated with this instance will be written.
+      def write_credentials(auth=nil)
+        storage.write_credentials(auth)
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/auth/installed_app.rb b/sdk/ruby-google-api-client/lib/google/api_client/auth/installed_app.rb
new file mode 100644 (file)
index 0000000..bdbb655
--- /dev/null
@@ -0,0 +1,126 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'webrick'
+require 'launchy'
+
+module Google
+  class APIClient
+
+    # Small helper for the sample apps for performing OAuth 2.0 flows from the command
+    # line or in any other installed app environment.
+    #
+    # @example
+    #
+    #    client = Google::APIClient.new
+    #    flow = Google::APIClient::InstalledAppFlow.new(
+    #      :client_id => '691380668085.apps.googleusercontent.com',
+    #      :client_secret => '...',
+    #      :scope => 'https://www.googleapis.com/auth/drive'
+    #    )
+    #    client.authorization = flow.authorize
+    #
+    class InstalledAppFlow
+
+      RESPONSE_BODY = <<-HTML
+        <html>
+          <head>
+            <script>
+              function closeWindow() {
+                window.open('', '_self', '');
+                window.close();
+              }
+              setTimeout(closeWindow, 10);
+            </script>
+          </head>
+          <body>You may close this window.</body>
+        </html>
+      HTML
+
+      ##
+      # Configure the flow
+      #
+      # @param [Hash] options The configuration parameters for the client.
+      # @option options [Fixnum] :port
+      #   Port to run the embedded server on. Defaults to 9292
+      # @option options [String] :client_id
+      #   A unique identifier issued to the client to identify itself to the
+      #   authorization server.
+      # @option options [String] :client_secret
+      #   A shared symmetric secret issued by the authorization server,
+      #   which is used to authenticate the client.
+      # @option options [String] :scope
+      #   The scope of the access request, expressed either as an Array
+      #   or as a space-delimited String.
+      #
+      # @see Signet::OAuth2::Client
+      def initialize(options)
+        @port = options[:port] || 9292
+        @authorization = Signet::OAuth2::Client.new({
+          :authorization_uri => 'https://accounts.google.com/o/oauth2/auth',
+          :token_credential_uri => 'https://accounts.google.com/o/oauth2/token',
+          :redirect_uri => "http://localhost:#{@port}/"}.update(options)
+        )
+      end
+
+      ##
+      # Request authorization. Opens a browser and waits for response.
+      #
+      # @param [Google::APIClient::Storage] storage
+      #  Optional object that responds to :write_credentials, used to serialize
+      #  the OAuth 2 credentials after completing the flow.
+      #
+      # @return [Signet::OAuth2::Client]
+      #  Authorization instance, nil if user cancelled.
+      def authorize(storage=nil)
+        auth = @authorization
+
+        server = WEBrick::HTTPServer.new(
+          :Port => @port,
+          :BindAddress =>"localhost",
+          :Logger => WEBrick::Log.new(STDOUT, 0),
+          :AccessLog => []
+        )
+        begin
+          trap("INT") { server.shutdown }
+
+          server.mount_proc '/' do |req, res|
+            auth.code = req.query['code']
+            if auth.code
+              auth.fetch_access_token!
+            end
+            res.status = WEBrick::HTTPStatus::RC_ACCEPTED
+            res.body = RESPONSE_BODY
+            server.stop
+          end
+
+          Launchy.open(auth.authorization_uri.to_s)
+          server.start
+        ensure
+          server.shutdown
+        end
+        if @authorization.access_token
+          if storage.respond_to?(:write_credentials)
+            storage.write_credentials(@authorization)
+          end
+          return @authorization
+        else
+          return nil
+        end
+      end
+    end
+
+  end
+end
+
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/auth/jwt_asserter.rb b/sdk/ruby-google-api-client/lib/google/api_client/auth/jwt_asserter.rb
new file mode 100644 (file)
index 0000000..35ad6ec
--- /dev/null
@@ -0,0 +1,126 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'jwt'
+require 'signet/oauth_2/client'
+require 'delegate'
+
+module Google
+  class APIClient
+    ##
+    # Generates access tokens using the JWT assertion profile. Requires a
+    # service account & access to the private key.
+    #
+    # @example Using Signet
+    #
+    #   key = Google::APIClient::KeyUtils.load_from_pkcs12('client.p12', 'notasecret')
+    #   client.authorization = Signet::OAuth2::Client.new(
+    #     :token_credential_uri => 'https://accounts.google.com/o/oauth2/token',
+    #     :audience => 'https://accounts.google.com/o/oauth2/token',
+    #     :scope => 'https://www.googleapis.com/auth/prediction',
+    #     :issuer => '123456-abcdef@developer.gserviceaccount.com',
+    #     :signing_key => key)
+    #   client.authorization.fetch_access_token!
+    #   client.execute(...)
+    #
+    # @deprecated
+    #  Service accounts are now supported directly in Signet
+    # @see https://developers.google.com/accounts/docs/OAuth2ServiceAccount
+    class JWTAsserter
+      # @return [String] ID/email of the issuing party
+      attr_accessor :issuer
+      # @return [Fixnum] How long, in seconds, the assertion is valid for
+      attr_accessor :expiry
+      # @return [Fixnum] Seconds to expand the issued at/expiry window to account for clock skew
+      attr_accessor :skew
+      # @return [String] Scopes to authorize
+      attr_reader :scope
+      # @return [String,OpenSSL::PKey] key for signing assertions
+      attr_writer :key
+      # @return [String] Algorithm used for signing
+      attr_accessor :algorithm
+      
+      ##
+      # Initializes the asserter for a service account.
+      #
+      # @param [String] issuer
+      #    Name/ID of the client issuing the assertion
+      # @param [String, Array] scope
+      #   Scopes to authorize. May be a space delimited string or array of strings
+      # @param [String,OpenSSL::PKey] key
+      #   Key for signing assertions
+      # @param [String] algorithm
+      #   Algorithm to use, either 'RS256' for RSA with SHA-256 
+      #   or 'HS256' for HMAC with SHA-256
+      def initialize(issuer, scope, key, algorithm = "RS256")
+        self.issuer = issuer
+        self.scope = scope
+        self.expiry = 60 # 1 min default 
+        self.skew = 60      
+        self.key = key
+        self.algorithm = algorithm
+      end
+
+      ##
+      # Set the scopes to authorize
+      #
+      # @param [String, Array] new_scope
+      #   Scopes to authorize. May be a space delimited string or array of strings
+      def scope=(new_scope)
+        case new_scope
+        when Array
+          @scope = new_scope.join(' ')
+        when String
+          @scope = new_scope
+        when nil
+          @scope = ''
+        else
+          raise TypeError, "Expected Array or String, got #{new_scope.class}"
+        end
+      end
+      
+      ##
+      # Request a new access token.
+      # 
+      # @param [String] person
+      #   Email address of a user, if requesting a token to act on their behalf
+      # @param [Hash] options
+      #   Pass through to Signet::OAuth2::Client.fetch_access_token
+      # @return [Signet::OAuth2::Client] Access token 
+      #
+      # @see Signet::OAuth2::Client.fetch_access_token!
+      def authorize(person = nil, options={})
+        authorization = self.to_authorization(person)
+        authorization.fetch_access_token!(options)
+        return authorization
+      end
+      
+      ##
+      # Builds a Signet OAuth2 client
+      #
+      # @return [Signet::OAuth2::Client] Access token 
+      def to_authorization(person = nil)
+        return Signet::OAuth2::Client.new(
+          :token_credential_uri => 'https://accounts.google.com/o/oauth2/token',
+          :audience => 'https://accounts.google.com/o/oauth2/token',
+          :scope => self.scope,
+          :issuer => @issuer,
+          :signing_key => @key,
+          :signing_algorithm => @algorithm,
+          :person => person
+        )
+      end      
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/auth/key_utils.rb b/sdk/ruby-google-api-client/lib/google/api_client/auth/key_utils.rb
new file mode 100644 (file)
index 0000000..6b6e0cf
--- /dev/null
@@ -0,0 +1,93 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+module Google
+  class APIClient
+    ##
+    # Helper for loading keys from the PKCS12 files downloaded when
+    # setting up service accounts at the APIs Console.
+    #
+    module KeyUtils
+      ##
+      # Loads a key from PKCS12 file, assuming a single private key
+      # is present.
+      #
+      # @param [String] keyfile
+      #    Path of the PKCS12 file to load. If not a path to an actual file,
+      #    assumes the string is the content of the file itself.
+      # @param [String] passphrase
+      #   Passphrase for unlocking the private key
+      #
+      # @return [OpenSSL::PKey] The private key for signing assertions.
+      def self.load_from_pkcs12(keyfile, passphrase)
+        load_key(keyfile, passphrase) do |content, pass_phrase|
+          OpenSSL::PKCS12.new(content, pass_phrase).key
+        end
+      end
+
+
+      ##
+      # Loads a key from a PEM file.
+      #
+      # @param [String] keyfile
+      #    Path of the PEM file to load. If not a path to an actual file,
+      #    assumes the string is the content of the file itself.
+      # @param [String] passphrase
+      #   Passphrase for unlocking the private key
+      #
+      # @return [OpenSSL::PKey] The private key for signing assertions.
+      #
+      def self.load_from_pem(keyfile, passphrase)
+        load_key(keyfile, passphrase) do | content, pass_phrase|
+          OpenSSL::PKey::RSA.new(content, pass_phrase)
+        end
+      end
+
+      private
+
+      ##
+      # Helper for loading keys from file or memory. Accepts a block
+      # to handle the specific file format.
+      #
+      # @param [String] keyfile
+      #    Path of thefile to load. If not a path to an actual file,
+      #    assumes the string is the content of the file itself.
+      # @param [String] passphrase
+      #   Passphrase for unlocking the private key
+      #
+      # @yield [String, String]
+      #   Key file & passphrase to extract key from
+      # @yieldparam [String] keyfile
+      #   Contents of the file
+      # @yieldparam [String] passphrase
+      #   Passphrase to unlock key
+      # @yieldreturn [OpenSSL::PKey]
+      #   Private key
+      #
+      # @return [OpenSSL::PKey] The private key for signing assertions.
+      def self.load_key(keyfile, passphrase, &block)
+        begin
+          begin
+            content = File.open(keyfile, 'rb') { |io| io.read }
+          rescue
+            content = keyfile
+          end
+          block.call(content, passphrase)
+        rescue OpenSSL::OpenSSLError
+          raise ArgumentError.new("Invalid keyfile or passphrase")
+        end
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/auth/pkcs12.rb b/sdk/ruby-google-api-client/lib/google/api_client/auth/pkcs12.rb
new file mode 100644 (file)
index 0000000..94c4318
--- /dev/null
@@ -0,0 +1,41 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'google/api_client/auth/key_utils'
+module Google
+  class APIClient
+    ##
+    # Helper for loading keys from the PKCS12 files downloaded when
+    # setting up service accounts at the APIs Console.
+    #
+    module PKCS12
+      ##
+      # Loads a key from PKCS12 file, assuming a single private key
+      # is present.
+      #
+      # @param [String] keyfile
+      #    Path of the PKCS12 file to load. If not a path to an actual file,
+      #    assumes the string is the content of the file itself. 
+      # @param [String] passphrase
+      #   Passphrase for unlocking the private key
+      #
+      # @return [OpenSSL::PKey] The private key for signing assertions.
+      # @deprecated 
+      #  Use {Google::APIClient::KeyUtils} instead
+      def self.load_key(keyfile, passphrase)
+        KeyUtils.load_from_pkcs12(keyfile, passphrase)
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/auth/storage.rb b/sdk/ruby-google-api-client/lib/google/api_client/auth/storage.rb
new file mode 100644 (file)
index 0000000..c762316
--- /dev/null
@@ -0,0 +1,102 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'signet/oauth_2/client'
+
+module Google
+  class APIClient
+    ##
+    # Represents cached OAuth 2 tokens stored on local disk in a
+    # JSON serialized file. Meant to resemble the serialized format
+    # http://google-api-python-client.googlecode.com/hg/docs/epy/oauth2client.file.Storage-class.html
+    #
+    class Storage
+
+      AUTHORIZATION_URI = 'https://accounts.google.com/o/oauth2/auth'
+      TOKEN_CREDENTIAL_URI = 'https://accounts.google.com/o/oauth2/token'
+
+      # @return [Object] Storage object.
+      attr_accessor :store
+
+      # @return [Signet::OAuth2::Client]
+      attr_reader :authorization
+
+      ##
+      # Initializes the Storage object.
+      #
+      # @params [Object] Storage object
+      def initialize(store)
+        @store= store
+        @authorization = nil
+      end
+
+      ##
+      # Write the credentials to the specified store.
+      #
+      # @params [Signet::OAuth2::Client] authorization
+      #    Optional authorization instance. If not provided, the authorization
+      #    already associated with this instance will be written.
+      def write_credentials(authorization=nil)
+        @authorization = authorization if authorization
+        if @authorization.respond_to?(:refresh_token) && @authorization.refresh_token
+          store.write_credentials(credentials_hash)
+        end
+      end
+
+      ##
+      # Loads credentials and authorizes an client.
+      # @return [Object] Signet::OAuth2::Client or NIL
+      def authorize
+        @authorization = nil
+        cached_credentials = load_credentials
+        if cached_credentials && cached_credentials.size > 0
+          @authorization = Signet::OAuth2::Client.new(cached_credentials)
+          @authorization.issued_at = Time.at(cached_credentials['issued_at'].to_i)
+          self.refresh_authorization if @authorization.expired?
+        end
+        return @authorization
+      end
+
+      ##
+      # refresh credentials and save them to store
+      def refresh_authorization
+        authorization.refresh!
+        self.write_credentials
+      end
+
+      private
+
+      ##
+      # Attempt to read in credentials from the specified store.
+      def load_credentials
+        store.load_credentials
+      end
+
+      ##
+      # @return [Hash] with credentials
+      def credentials_hash
+        {
+          :access_token          => authorization.access_token,
+          :authorization_uri     => AUTHORIZATION_URI,
+          :client_id             => authorization.client_id,
+          :client_secret         => authorization.client_secret,
+          :expires_in            => authorization.expires_in,
+          :refresh_token         => authorization.refresh_token,
+          :token_credential_uri  => TOKEN_CREDENTIAL_URI,
+          :issued_at             => authorization.issued_at.to_i
+        }
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/auth/storages/file_store.rb b/sdk/ruby-google-api-client/lib/google/api_client/auth/storages/file_store.rb
new file mode 100644 (file)
index 0000000..cd3eae7
--- /dev/null
@@ -0,0 +1,58 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'json'
+
+module Google
+  class APIClient
+    ##
+    # Represents cached OAuth 2 tokens stored on local disk in a
+    # JSON serialized file. Meant to resemble the serialized format
+    # http://google-api-python-client.googlecode.com/hg/docs/epy/oauth2client.file.Storage-class.html
+    #
+    class FileStore
+
+      attr_accessor :path
+
+      ##
+      # Initializes the FileStorage object.
+      #
+      # @param [String] path
+      #    Path to the credentials file.
+      def initialize(path)
+        @path= path
+      end
+
+      ##
+      # Attempt to read in credentials from the specified file.
+      def load_credentials
+        open(path, 'r') { |f| JSON.parse(f.read) }
+      rescue
+        nil
+      end
+
+      ##
+      # Write the credentials to the specified file.
+      #
+      # @param [Signet::OAuth2::Client] authorization
+      #    Optional authorization instance. If not provided, the authorization
+      #    already associated with this instance will be written.
+      def write_credentials(credentials_hash)
+        open(self.path, 'w+') do |f|
+          f.write(credentials_hash.to_json)
+        end
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/auth/storages/redis_store.rb b/sdk/ruby-google-api-client/lib/google/api_client/auth/storages/redis_store.rb
new file mode 100644 (file)
index 0000000..3f76f7c
--- /dev/null
@@ -0,0 +1,54 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'json'
+
+module Google
+  class APIClient
+    class RedisStore
+
+      DEFAULT_REDIS_CREDENTIALS_KEY = "google_api_credentials"
+
+      attr_accessor :redis
+
+      ##
+      # Initializes the RedisStore object.
+      #
+      # @params [Object] Redis instance
+      def initialize(redis, key = nil)
+        @redis= redis
+        @redis_credentials_key = key
+      end
+
+      ##
+      # Attempt to read in credentials from redis.
+      def load_credentials
+        credentials = redis.get redis_credentials_key
+        JSON.parse(credentials) if credentials
+      end
+
+      def redis_credentials_key
+        @redis_credentials_key || DEFAULT_REDIS_CREDENTIALS_KEY
+      end
+
+      ##
+      # Write the credentials to redis.
+      #
+      # @params [Hash] credentials
+      def write_credentials(credentials_hash)
+        redis.set(redis_credentials_key, credentials_hash.to_json)
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/batch.rb b/sdk/ruby-google-api-client/lib/google/api_client/batch.rb
new file mode 100644 (file)
index 0000000..45a2e31
--- /dev/null
@@ -0,0 +1,326 @@
+# Copyright 2012 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'addressable/uri'
+require 'google/api_client/reference'
+require 'securerandom'
+
+module Google
+  class APIClient
+
+    ##
+    # Helper class to contain a response to an individual batched call.
+    #
+    # @api private
+    class BatchedCallResponse
+      # @return [String] UUID of the call
+      attr_reader :call_id
+      # @return [Fixnum] HTTP status code
+      attr_accessor :status
+      # @return [Hash] HTTP response headers
+      attr_accessor :headers
+      # @return [String] HTTP response body
+      attr_accessor :body
+
+      ##
+      # Initialize the call response
+      #
+      # @param [String] call_id
+      #   UUID of the original call
+      # @param [Fixnum] status
+      #   HTTP status
+      # @param [Hash] headers
+      #   HTTP response headers
+      # @param [#read, #to_str] body
+      #   Response body
+      def initialize(call_id, status = nil, headers = nil, body = nil)
+        @call_id, @status, @headers, @body = call_id, status, headers, body
+      end
+    end
+
+    # Wraps multiple API calls into a single over-the-wire HTTP request.
+    #
+    # @example
+    #
+    #     client = Google::APIClient.new
+    #     urlshortener = client.discovered_api('urlshortener')
+    #     batch = Google::APIClient::BatchRequest.new do |result|
+    #        puts result.data
+    #     end
+    #
+    #     batch.add(:api_method => urlshortener.url.insert, :body_object => { 'longUrl' => 'http://example.com/foo' })
+    #     batch.add(:api_method => urlshortener.url.insert, :body_object => { 'longUrl' => 'http://example.com/bar' })
+    #
+    #     client.execute(batch)
+    #
+    class BatchRequest < Request
+      BATCH_BOUNDARY = "-----------RubyApiBatchRequest".freeze
+
+      # @api private
+      # @return [Array<(String,Google::APIClient::Request,Proc)] List of API calls in the batch
+      attr_reader :calls
+
+      ##
+      # Creates a new batch request.
+      #
+      # @param [Hash] options
+      #   Set of options for this request.
+      # @param [Proc] block
+      #   Callback for every call's response. Won't be called if a call defined
+      #   a callback of its own.
+      #
+      # @return [Google::APIClient::BatchRequest]
+      #   The constructed object.
+      #
+      # @yield [Google::APIClient::Result]
+      #   block to be called when result ready
+      def initialize(options = {}, &block)
+        @calls = []
+        @global_callback = nil
+        @global_callback = block if block_given?
+        @last_auto_id = 0
+
+        @base_id = SecureRandom.uuid
+
+        options[:uri] ||= 'https://www.googleapis.com/batch'
+        options[:http_method] ||= 'POST'
+
+        super options
+      end
+
+      ##
+      # Add a new call to the batch request.
+      # Each call must have its own call ID; if not provided, one will
+      # automatically be generated, avoiding collisions. If duplicate call IDs
+      # are provided, an error will be thrown.
+      #
+      # @param [Hash, Google::APIClient::Request] call
+      #   the call to be added.
+      # @param [String] call_id
+      #   the ID to be used for this call. Must be unique
+      # @param [Proc] block
+      #   callback for this call's response.
+      #
+      # @return [Google::APIClient::BatchRequest]
+      #   the BatchRequest, for chaining
+      #
+      # @yield [Google::APIClient::Result]
+      #   block to be called when result ready
+      def add(call, call_id = nil, &block)
+        unless call.kind_of?(Google::APIClient::Reference)
+          call = Google::APIClient::Reference.new(call)
+        end
+        call_id ||= new_id
+        if @calls.assoc(call_id)
+          raise BatchError,
+              'A call with this ID already exists: %s' % call_id
+        end
+        callback = block_given? ? block : @global_callback
+        @calls << [call_id, call, callback]
+        return self
+      end
+
+      ##
+      # Processes the HTTP response to the batch request, issuing callbacks.
+      #
+      # @api private
+      #
+      # @param [Faraday::Response] response
+      #   the HTTP response.
+      def process_http_response(response)
+        content_type = find_header('Content-Type', response.headers)
+        m = /.*boundary=(.+)/.match(content_type)
+        if m
+          boundary = m[1]
+          parts = response.body.split(/--#{Regexp.escape(boundary)}/)
+          parts = parts[1...-1]
+          parts.each do |part|
+            call_response = deserialize_call_response(part)
+            _, call, callback = @calls.assoc(call_response.call_id)
+            result = Google::APIClient::Result.new(call, call_response)
+            callback.call(result) if callback
+          end
+        end
+        Google::APIClient::Result.new(self, response)
+      end
+
+      ##
+      # Return the request body for the BatchRequest's HTTP request.
+      #
+      # @api private
+      #
+      # @return [String]
+      #   the request body.
+      def to_http_request
+        if @calls.nil? || @calls.empty?
+          raise BatchError, 'Cannot make an empty batch request'
+        end
+        parts = @calls.map {|(call_id, call, _callback)| serialize_call(call_id, call)}
+        build_multipart(parts, 'multipart/mixed', BATCH_BOUNDARY)
+        super
+      end
+
+
+      protected
+
+      ##
+      # Helper method to find a header from its name, regardless of case.
+      #
+      # @api private
+      #
+      # @param [String] name
+      #   the name of the header to find.
+      # @param [Hash] headers
+      #   the hash of headers and their values.
+      #
+      # @return [String]
+      #   the value of the desired header.
+      def find_header(name, headers)
+        _, header = headers.detect do |h, v|
+          h.downcase == name.downcase
+        end
+        return header
+      end
+
+      ##
+      # Create a new call ID. Uses an auto-incrementing, conflict-avoiding ID.
+      #
+      # @api private
+      #
+      # @return [String]
+      #  the new, unique ID.
+      def new_id
+        @last_auto_id += 1
+        while @calls.assoc(@last_auto_id)
+          @last_auto_id += 1
+        end
+        return @last_auto_id.to_s
+      end
+
+      ##
+      # Convert a Content-ID header value to an id. Presumes the Content-ID
+      # header conforms to the format that id_to_header() returns.
+      #
+      # @api private
+      #
+      # @param [String] header
+      #   Content-ID header value.
+      #
+      # @return [String]
+      #   The extracted ID value.
+      def header_to_id(header)
+        if !header.start_with?('<') || !header.end_with?('>') ||
+            !header.include?('+')
+          raise BatchError, 'Invalid value for Content-ID: "%s"' % header
+        end
+
+        _base, call_id = header[1...-1].split('+')
+        return Addressable::URI.unencode(call_id)
+      end
+
+      ##
+      # Auxiliary method to split the headers from the body in an HTTP response.
+      #
+      # @api private
+      #
+      # @param [String] response
+      #   the response to parse.
+      #
+      # @return [Array<Hash>, String]
+      #   the headers and the body, separately.
+      def split_headers_and_body(response)
+        headers = {}
+        payload = response.lstrip
+        while payload
+          line, payload = payload.split("\n", 2)
+          line.sub!(/\s+\z/, '')
+          break if line.empty?
+          match = /\A([^:]+):\s*/.match(line)
+          if match
+            headers[match[1]] = match.post_match
+          else
+            raise BatchError, 'Invalid header line in response: %s' % line
+          end
+        end
+        return headers, payload
+      end
+
+      ##
+      # Convert a single batched response into a BatchedCallResponse object.
+      #
+      # @api private
+      #
+      # @param [String] call_response
+      #   the request to deserialize.
+      #
+      # @return [Google::APIClient::BatchedCallResponse]
+      #   the parsed and converted response.
+      def deserialize_call_response(call_response)
+        outer_headers, outer_body = split_headers_and_body(call_response)
+        status_line, payload = outer_body.split("\n", 2)
+        _protocol, status, _reason = status_line.split(' ', 3)
+
+        headers, body = split_headers_and_body(payload)
+        content_id = find_header('Content-ID', outer_headers)
+        call_id = header_to_id(content_id)
+        return BatchedCallResponse.new(call_id, status.to_i, headers, body)
+      end
+
+      ##
+      # Serialize a single batched call for assembling the multipart message
+      #
+      # @api private
+      #
+      # @param [Google::APIClient::Request] call
+      #   the call to serialize.
+      #
+      # @return [Faraday::UploadIO]
+      #   the serialized request
+      def serialize_call(call_id, call)
+        method, uri, headers, body = call.to_http_request
+        request = "#{method.to_s.upcase} #{Addressable::URI.parse(uri).request_uri} HTTP/1.1"
+        headers.each do |header, value|
+          request << "\r\n%s: %s" % [header, value]
+        end
+        if body
+          # TODO - CompositeIO if body is a stream
+          request << "\r\n\r\n"
+          if body.respond_to?(:read)
+            request << body.read
+          else
+            request << body.to_s
+          end
+        end
+        Faraday::UploadIO.new(StringIO.new(request), 'application/http', 'ruby-api-request', 'Content-ID' => id_to_header(call_id))
+      end
+
+      ##
+      # Convert an id to a Content-ID header value.
+      #
+      # @api private
+      #
+      # @param [String] call_id
+      #   identifier of individual call.
+      #
+      # @return [String]
+      #   A Content-ID header with the call_id encoded into it. A UUID is
+      #   prepended to the value because Content-ID headers are supposed to be
+      #   universally unique.
+      def id_to_header(call_id)
+        return '<%s+%s>' % [@base_id, Addressable::URI.encode(call_id)]
+      end
+
+    end
+  end
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/charset.rb b/sdk/ruby-google-api-client/lib/google/api_client/charset.rb
new file mode 100644 (file)
index 0000000..9668aee
--- /dev/null
@@ -0,0 +1,33 @@
+require 'faraday'
+require 'zlib'
+module Google
+  class APIClient
+    class Charset < Faraday::Middleware
+      include Google::APIClient::Logging
+
+      def charset_for_content_type(type)
+        if type
+          m = type.match(/(?:charset|encoding)="?([a-z0-9-]+)"?/i)
+          if m
+            return Encoding.find(m[1])
+          end
+        end
+        nil
+      end
+
+      def adjust_encoding(env)
+        charset = charset_for_content_type(env[:response_headers]['content-type'])
+        if charset && env[:body].encoding != charset
+          env[:body].force_encoding(charset)
+        end
+      end
+      
+      def on_complete(env)
+        adjust_encoding(env)
+      end
+    end
+  end
+end
+Faraday::Response.register_middleware :charset => Google::APIClient::Charset
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/client_secrets.rb b/sdk/ruby-google-api-client/lib/google/api_client/client_secrets.rb
new file mode 100644 (file)
index 0000000..a9cc241
--- /dev/null
@@ -0,0 +1,179 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+require 'compat/multi_json'
+
+
+module Google
+  class APIClient
+    ##
+    # Manages the persistence of client configuration data and secrets. Format
+    # inspired by the Google API Python client.
+    #
+    # @see https://developers.google.com/api-client-library/python/guide/aaa_client_secrets
+    #
+    # @example
+    #   {
+    #     "web": {
+    #       "client_id": "asdfjasdljfasdkjf",
+    #       "client_secret": "1912308409123890",
+    #       "redirect_uris": ["https://www.example.com/oauth2callback"],
+    #       "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+    #       "token_uri": "https://accounts.google.com/o/oauth2/token"
+    #     }
+    #   }
+    #
+    # @example
+    #   {
+    #     "installed": {
+    #       "client_id": "837647042410-75ifg...usercontent.com",
+    #       "client_secret":"asdlkfjaskd",
+    #       "redirect_uris": ["http://localhost", "urn:ietf:oauth:2.0:oob"],
+    #       "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+    #       "token_uri": "https://accounts.google.com/o/oauth2/token"
+    #     }
+    #   }
+    class ClientSecrets
+      
+      ##
+      # Reads client configuration from a file
+      #
+      # @param [String] filename
+      #   Path to file to load
+      #
+      # @return [Google::APIClient::ClientSecrets]
+      #   OAuth client settings
+      def self.load(filename=nil)
+        if filename && File.directory?(filename)
+          search_path = File.expand_path(filename)
+          filename = nil
+        end
+        while filename == nil
+          search_path ||= File.expand_path('.')
+          if File.exists?(File.join(search_path, 'client_secrets.json'))
+            filename = File.join(search_path, 'client_secrets.json')
+          elsif search_path == '/' || search_path =~ /[a-zA-Z]:[\/\\]/
+            raise ArgumentError,
+              'No client_secrets.json filename supplied ' +
+              'and/or could not be found in search path.'
+          else
+            search_path = File.expand_path(File.join(search_path, '..'))
+          end
+        end
+        data = File.open(filename, 'r') { |file| MultiJson.load(file.read) }
+        return self.new(data)
+      end
+
+      ##
+      # Intialize OAuth client settings.
+      #
+      # @param [Hash] options
+      #   Parsed client secrets files
+      def initialize(options={})
+        # Client auth configuration
+        @flow = options[:flow] || options.keys.first.to_s || 'web'
+        fdata = options[@flow]
+        @client_id = fdata[:client_id] || fdata["client_id"]
+        @client_secret = fdata[:client_secret] || fdata["client_secret"]
+        @redirect_uris = fdata[:redirect_uris] || fdata["redirect_uris"]
+        @redirect_uris ||= [fdata[:redirect_uri] || fdata["redirect_uri"]].compact
+        @javascript_origins = (
+          fdata[:javascript_origins] ||
+          fdata["javascript_origins"]
+        )
+        @javascript_origins ||= [fdata[:javascript_origin] || fdata["javascript_origin"]].compact
+        @authorization_uri = fdata[:auth_uri] || fdata["auth_uri"]
+        @authorization_uri ||= fdata[:authorization_uri]
+        @token_credential_uri = fdata[:token_uri] || fdata["token_uri"]
+        @token_credential_uri ||= fdata[:token_credential_uri]
+
+        # Associated token info
+        @access_token = fdata[:access_token] || fdata["access_token"]
+        @refresh_token = fdata[:refresh_token] || fdata["refresh_token"]
+        @id_token = fdata[:id_token] || fdata["id_token"]
+        @expires_in = fdata[:expires_in] || fdata["expires_in"]
+        @expires_at = fdata[:expires_at] || fdata["expires_at"]
+        @issued_at = fdata[:issued_at] || fdata["issued_at"]
+      end
+
+      attr_reader(
+        :flow, :client_id, :client_secret, :redirect_uris, :javascript_origins,
+        :authorization_uri, :token_credential_uri, :access_token,
+        :refresh_token, :id_token, :expires_in, :expires_at, :issued_at
+      )
+
+      ##
+      # Serialize back to the original JSON form
+      #
+      # @return [String]
+      #   JSON
+      def to_json
+        return MultiJson.dump(to_hash)
+      end
+      
+      def to_hash
+        {
+          self.flow => ({
+            'client_id' => self.client_id,
+            'client_secret' => self.client_secret,
+            'redirect_uris' => self.redirect_uris,
+            'javascript_origins' => self.javascript_origins,
+            'auth_uri' => self.authorization_uri,
+            'token_uri' => self.token_credential_uri,
+            'access_token' => self.access_token,
+            'refresh_token' => self.refresh_token,
+            'id_token' => self.id_token,
+            'expires_in' => self.expires_in,
+            'expires_at' => self.expires_at,
+            'issued_at' => self.issued_at
+          }).inject({}) do |accu, (k, v)|
+            # Prunes empty values from JSON output.
+            unless v == nil || (v.respond_to?(:empty?) && v.empty?)
+              accu[k] = v
+            end
+            accu
+          end
+        }
+      end
+      
+      def to_authorization
+        gem 'signet', '>= 0.4.0'
+        require 'signet/oauth_2/client'
+        # NOTE: Do not rely on this default value, as it may change
+        new_authorization = Signet::OAuth2::Client.new
+        new_authorization.client_id = self.client_id
+        new_authorization.client_secret = self.client_secret
+        new_authorization.authorization_uri = (
+          self.authorization_uri ||
+          'https://accounts.google.com/o/oauth2/auth'
+        )
+        new_authorization.token_credential_uri = (
+          self.token_credential_uri ||
+          'https://accounts.google.com/o/oauth2/token'
+        )
+        new_authorization.redirect_uri = self.redirect_uris.first
+
+        # These are supported, but unlikely.
+        new_authorization.access_token = self.access_token
+        new_authorization.refresh_token = self.refresh_token
+        new_authorization.id_token = self.id_token
+        new_authorization.expires_in = self.expires_in
+        new_authorization.issued_at = self.issued_at if self.issued_at
+        new_authorization.expires_at = self.expires_at if self.expires_at
+        return new_authorization
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/discovery.rb b/sdk/ruby-google-api-client/lib/google/api_client/discovery.rb
new file mode 100644 (file)
index 0000000..bb01d67
--- /dev/null
@@ -0,0 +1,19 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+require 'google/api_client/discovery/api'
+require 'google/api_client/discovery/resource'
+require 'google/api_client/discovery/method'
+require 'google/api_client/discovery/schema'
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/discovery/api.rb b/sdk/ruby-google-api-client/lib/google/api_client/discovery/api.rb
new file mode 100644 (file)
index 0000000..3bbc90d
--- /dev/null
@@ -0,0 +1,310 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+require 'addressable/uri'
+require 'multi_json'
+require 'active_support/inflector'
+require 'google/api_client/discovery/resource'
+require 'google/api_client/discovery/method'
+require 'google/api_client/discovery/media'
+
+module Google
+  class APIClient
+    ##
+    # A service that has been described by a discovery document.
+    class API
+
+      ##
+      # Creates a description of a particular version of a service.
+      #
+      # @param [String] document_base
+      #   Base URI for the discovery document.
+      # @param [Hash] discovery_document
+      #   The section of the discovery document that applies to this service
+      #   version.
+      #
+      # @return [Google::APIClient::API] The constructed service object.
+      def initialize(document_base, discovery_document)
+        @document_base = Addressable::URI.parse(document_base)
+        @discovery_document = discovery_document
+        metaclass = (class << self; self; end)
+        self.discovered_resources.each do |resource|
+          method_name = ActiveSupport::Inflector.underscore(resource.name).to_sym
+          if !self.respond_to?(method_name)
+            metaclass.send(:define_method, method_name) { resource }
+          end
+        end
+        self.discovered_methods.each do |method|
+          method_name = ActiveSupport::Inflector.underscore(method.name).to_sym
+          if !self.respond_to?(method_name)
+            metaclass.send(:define_method, method_name) { method }
+          end
+        end
+      end
+      
+      # @return [String] unparsed discovery document for the API
+      attr_reader :discovery_document
+
+      ##
+      # Returns the id of the service.
+      #
+      # @return [String] The service id.
+      def id
+        return (
+          @discovery_document['id'] ||
+          "#{self.name}:#{self.version}"
+        )
+      end
+
+      ##
+      # Returns the identifier for the service.
+      #
+      # @return [String] The service identifier.
+      def name
+        return @discovery_document['name']
+      end
+
+      ##
+      # Returns the version of the service.
+      #
+      # @return [String] The service version.
+      def version
+        return @discovery_document['version']
+      end
+
+      ##
+      # Returns a human-readable title for the API.
+      #
+      # @return [Hash] The API title.
+      def title
+        return @discovery_document['title']
+      end
+
+      ##
+      # Returns a human-readable description of the API.
+      #
+      # @return [Hash] The API description.
+      def description
+        return @discovery_document['description']
+      end
+
+      ##
+      # Returns a URI for the API documentation.
+      #
+      # @return [Hash] The API documentation.
+      def documentation
+        return Addressable::URI.parse(@discovery_document['documentationLink'])
+      end
+
+      ##
+      # Returns true if this is the preferred version of this API.
+      #
+      # @return [TrueClass, FalseClass]
+      #   Whether or not this is the preferred version of this API.
+      def preferred
+        return !!@discovery_document['preferred']
+      end
+
+      ##
+      # Returns the list of API features.
+      #
+      # @return [Array]
+      #   The features supported by this API.
+      def features
+        return @discovery_document['features'] || []
+      end
+
+      ##
+      # Returns the root URI for this service.
+      #
+      # @return [Addressable::URI] The root URI.
+      def root_uri
+        return @root_uri ||= (
+          Addressable::URI.parse(self.discovery_document['rootUrl'])
+        )
+      end
+
+      ##
+      # Returns true if this API uses a data wrapper.
+      #
+      # @return [TrueClass, FalseClass]
+      #   Whether or not this API uses a data wrapper.
+      def data_wrapper?
+        return self.features.include?('dataWrapper')
+      end
+
+      ##
+      # Returns the base URI for the discovery document.
+      #
+      # @return [Addressable::URI] The base URI.
+      attr_reader :document_base
+
+      ##
+      # Returns the base URI for this version of the service.
+      #
+      # @return [Addressable::URI] The base URI that methods are joined to.
+      def method_base
+        if @discovery_document['basePath']
+          return @method_base ||= (
+            self.root_uri.join(Addressable::URI.parse(@discovery_document['basePath']))
+          ).normalize
+        else
+          return nil
+        end
+      end
+
+      ##
+      # Updates the hierarchy of resources and methods with the new base.
+      #
+      # @param [Addressable::URI, #to_str, String] new_method_base
+      #   The new base URI to use for the service.
+      def method_base=(new_method_base)
+        @method_base = Addressable::URI.parse(new_method_base)
+        self.discovered_resources.each do |resource|
+          resource.method_base = @method_base
+        end
+        self.discovered_methods.each do |method|
+          method.method_base = @method_base
+        end
+      end
+
+      ##
+      # Returns the base URI for batch calls to this service.
+      #
+      # @return [Addressable::URI] The base URI that methods are joined to.
+      def batch_path
+        if @discovery_document['batchPath']
+          return @batch_path ||= (
+            self.document_base.join(Addressable::URI.parse('/' +
+                @discovery_document['batchPath']))
+          ).normalize
+        else
+          return nil
+        end
+      end
+
+      ##
+      # A list of schemas available for this version of the API.
+      #
+      # @return [Hash] A list of {Google::APIClient::Schema} objects.
+      def schemas
+        return @schemas ||= (
+          (@discovery_document['schemas'] || []).inject({}) do |accu, (k, v)|
+            accu[k] = Google::APIClient::Schema.parse(self, v)
+            accu
+          end
+        )
+      end
+
+      ##
+      # Returns a schema for a kind value.
+      #
+      # @return [Google::APIClient::Schema] The associated Schema object.
+      def schema_for_kind(kind)
+        api_name, schema_name = kind.split('#', 2)
+        if api_name != self.name
+          raise ArgumentError,
+            "The kind does not match this API. " +
+            "Expected '#{self.name}', got '#{api_name}'."
+        end
+        for k, v in self.schemas
+          return v if k.downcase == schema_name.downcase
+        end
+        return nil
+      end
+
+      ##
+      # A list of resources available at the root level of this version of the
+      # API.
+      #
+      # @return [Array] A list of {Google::APIClient::Resource} objects.
+      def discovered_resources
+        return @discovered_resources ||= (
+          (@discovery_document['resources'] || []).inject([]) do |accu, (k, v)|
+            accu << Google::APIClient::Resource.new(
+              self, self.method_base, k, v
+            )
+            accu
+          end
+        )
+      end
+
+      ##
+      # A list of methods available at the root level of this version of the
+      # API.
+      #
+      # @return [Array] A list of {Google::APIClient::Method} objects.
+      def discovered_methods
+        return @discovered_methods ||= (
+          (@discovery_document['methods'] || []).inject([]) do |accu, (k, v)|
+            accu << Google::APIClient::Method.new(self, self.method_base, k, v)
+            accu
+          end
+        )
+      end
+
+      ##
+      # Allows deep inspection of the discovery document.
+      def [](key)
+        return @discovery_document[key]
+      end
+
+      ##
+      # Converts the service to a flat mapping of RPC names and method objects.
+      #
+      # @return [Hash] All methods available on the service.
+      #
+      # @example
+      #   # Discover available methods
+      #   method_names = client.discovered_api('buzz').to_h.keys
+      def to_h
+        return @hash ||= (begin
+          methods_hash = {}
+          self.discovered_methods.each do |method|
+            methods_hash[method.id] = method
+          end
+          self.discovered_resources.each do |resource|
+            methods_hash.merge!(resource.to_h)
+          end
+          methods_hash
+        end)
+      end
+
+      ##
+      # Returns a <code>String</code> representation of the service's state.
+      #
+      # @return [String] The service's state, as a <code>String</code>.
+      def inspect
+        sprintf(
+          "#<%s:%#0x ID:%s>", self.class.to_s, self.object_id, self.id
+        )
+      end
+      
+      ##
+      # Marshalling support - serialize the API to a string (doc base + original 
+      # discovery document).
+      def _dump(level)
+        MultiJson.dump([@document_base.to_s, @discovery_document])
+      end
+      
+      ##
+      # Marshalling support - Restore an API instance from serialized form
+      def self._load(obj)
+        new(*MultiJson.load(obj)) 
+      end
+
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/discovery/media.rb b/sdk/ruby-google-api-client/lib/google/api_client/discovery/media.rb
new file mode 100644 (file)
index 0000000..ffa7e87
--- /dev/null
@@ -0,0 +1,77 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+require 'addressable/uri'
+require 'addressable/template'
+
+require 'google/api_client/errors'
+
+
+module Google
+  class APIClient
+    ##
+    # Media upload elements for discovered methods
+    class MediaUpload
+
+      ##
+      # Creates a description of a particular method.
+      #
+      # @param [Google::APIClient::API] api
+      #    Base discovery document for the API
+      # @param [Addressable::URI] method_base
+      #   The base URI for the service.
+      # @param [Hash] discovery_document
+      #   The media upload section of the discovery document.
+      #
+      # @return [Google::APIClient::Method] The constructed method object.
+      def initialize(api, method_base, discovery_document)
+        @api = api
+        @method_base = method_base
+        @discovery_document = discovery_document
+      end
+
+      ##
+      # List of acceptable mime types
+      #
+      # @return [Array]
+      #   List of acceptable mime types for uploaded content
+      def accepted_types
+        @discovery_document['accept']
+      end
+
+      ##
+      # Maximum size of an uplad
+      # TODO: Parse & convert to numeric value
+      #
+      # @return [String]
+      def max_size
+        @discovery_document['maxSize']
+      end
+
+      ##
+      # Returns the URI template for the method.  A parameter list can be
+      # used to expand this into a URI.
+      #
+      # @return [Addressable::Template] The URI template.
+      def uri_template
+        return @uri_template ||= Addressable::Template.new(
+          @api.method_base.join(Addressable::URI.parse(@discovery_document['protocols']['simple']['path']))
+        )
+      end
+
+    end
+
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/discovery/method.rb b/sdk/ruby-google-api-client/lib/google/api_client/discovery/method.rb
new file mode 100644 (file)
index 0000000..3a06857
--- /dev/null
@@ -0,0 +1,363 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+require 'addressable/uri'
+require 'addressable/template'
+
+require 'google/api_client/errors'
+
+
+module Google
+  class APIClient
+    ##
+    # A method that has been described by a discovery document.
+    class Method
+
+      ##
+      # Creates a description of a particular method.
+      #
+      # @param [Google::APIClient::API] api
+      #   The API this method belongs to.
+      # @param [Addressable::URI] method_base
+      #   The base URI for the service.
+      # @param [String] method_name
+      #   The identifier for the method.
+      # @param [Hash] discovery_document
+      #   The section of the discovery document that applies to this method.
+      #
+      # @return [Google::APIClient::Method] The constructed method object.
+      def initialize(api, method_base, method_name, discovery_document)
+        @api = api
+        @method_base = method_base
+        @name = method_name
+        @discovery_document = discovery_document
+      end
+
+      # @return [String] unparsed discovery document for the method
+      attr_reader :discovery_document
+
+      ##
+      # Returns the API this method belongs to.
+      #
+      # @return [Google::APIClient::API] The API this method belongs to.
+      attr_reader :api
+
+      ##
+      # Returns the identifier for the method.
+      #
+      # @return [String] The method identifier.
+      attr_reader :name
+
+      ##
+      # Returns the base URI for the method.
+      #
+      # @return [Addressable::URI]
+      #   The base URI that this method will be joined to.
+      attr_reader :method_base
+
+      ##
+      # Updates the method with the new base.
+      #
+      # @param [Addressable::URI, #to_str, String] new_method_base
+      #   The new base URI to use for the method.
+      def method_base=(new_method_base)
+        @method_base = Addressable::URI.parse(new_method_base)
+        @uri_template = nil
+      end
+
+      ##
+      # Returns a human-readable description of the method.
+      #
+      # @return [Hash] The API description.
+      def description
+        return @discovery_document['description']
+      end
+      
+      ##
+      # Returns the method ID.
+      #
+      # @return [String] The method identifier.
+      def id
+        return @discovery_document['id']
+      end
+
+      ##
+      # Returns the HTTP method or 'GET' if none is specified.
+      #
+      # @return [String] The HTTP method that will be used in the request.
+      def http_method
+        return @discovery_document['httpMethod'] || 'GET'
+      end
+
+      ##
+      # Returns the URI template for the method.  A parameter list can be
+      # used to expand this into a URI.
+      #
+      # @return [Addressable::Template] The URI template.
+      def uri_template
+        return @uri_template ||= Addressable::Template.new(
+          self.method_base.join(Addressable::URI.parse("./" + @discovery_document['path']))
+        )
+      end
+
+      ##
+      # Returns media upload information for this method, if supported
+      #
+      # @return [Google::APIClient::MediaUpload] Description of upload endpoints
+      def media_upload
+        if @discovery_document['mediaUpload']
+          return @media_upload ||= Google::APIClient::MediaUpload.new(self, self.method_base, @discovery_document['mediaUpload'])
+        else
+          return nil
+        end
+      end
+
+      ##
+      # Returns the Schema object for the method's request, if any.
+      #
+      # @return [Google::APIClient::Schema] The request schema.
+      def request_schema
+        if @discovery_document['request']
+          schema_name = @discovery_document['request']['$ref']
+          return @api.schemas[schema_name]
+        else
+          return nil
+        end
+      end
+
+      ##
+      # Returns the Schema object for the method's response, if any.
+      #
+      # @return [Google::APIClient::Schema] The response schema.
+      def response_schema
+        if @discovery_document['response']
+          schema_name = @discovery_document['response']['$ref']
+          return @api.schemas[schema_name]
+        else
+          return nil
+        end
+      end
+
+      ##
+      # Normalizes parameters, converting to the appropriate types.
+      #
+      # @param [Hash, Array] parameters
+      #   The parameters to normalize.
+      #
+      # @return [Hash] The normalized parameters.
+      def normalize_parameters(parameters={})
+        # Convert keys to Strings when appropriate
+        if parameters.kind_of?(Hash) || parameters.kind_of?(Array)
+          # Returning an array since parameters can be repeated (ie, Adsense Management API)
+          parameters = parameters.inject([]) do |accu, (k, v)|
+            k = k.to_s if k.kind_of?(Symbol)
+            k = k.to_str if k.respond_to?(:to_str)
+            unless k.kind_of?(String)
+              raise TypeError, "Expected String, got #{k.class}."
+            end
+            accu << [k, v]
+            accu
+          end
+        else
+          raise TypeError,
+            "Expected Hash or Array, got #{parameters.class}."
+        end
+        return parameters
+      end
+
+      ##
+      # Expands the method's URI template using a parameter list.
+      #
+      # @api private
+      # @param [Hash, Array] parameters
+      #   The parameter list to use.
+      #
+      # @return [Addressable::URI] The URI after expansion.
+      def generate_uri(parameters={})
+        parameters = self.normalize_parameters(parameters)
+        
+        self.validate_parameters(parameters)
+        template_variables = self.uri_template.variables
+        upload_type = parameters.assoc('uploadType') || parameters.assoc('upload_type')
+        if upload_type
+          unless self.media_upload
+            raise ArgumentException, "Media upload not supported for this method"
+          end
+          case upload_type.last
+          when 'media', 'multipart', 'resumable'
+            uri = self.media_upload.uri_template.expand(parameters)
+          else
+            raise ArgumentException, "Invalid uploadType '#{upload_type}'"
+          end
+        else
+          uri = self.uri_template.expand(parameters)
+        end
+        query_parameters = parameters.reject do |k, v|
+          template_variables.include?(k)
+        end
+        # encode all non-template parameters
+        params = ""
+        unless query_parameters.empty?
+          params = "?" + Addressable::URI.form_encode(query_parameters.sort)
+        end
+        # Normalization is necessary because of undesirable percent-escaping
+        # during URI template expansion
+        return uri.normalize + params
+      end
+
+      ##
+      # Generates an HTTP request for this method.
+      #
+      # @api private
+      # @param [Hash, Array] parameters
+      #   The parameters to send.
+      # @param [String, StringIO] body The body for the HTTP request.
+      # @param [Hash, Array] headers The HTTP headers for the request.
+      # @option options [Faraday::Connection] :connection
+      #   The HTTP connection to use.
+      #
+      # @return [Array] The generated HTTP request.
+      def generate_request(parameters={}, body='', headers={}, options={})
+        if !headers.kind_of?(Array) && !headers.kind_of?(Hash)
+          raise TypeError, "Expected Hash or Array, got #{headers.class}."
+        end
+        method = self.http_method.to_s.downcase.to_sym
+        uri = self.generate_uri(parameters)
+        headers = Faraday::Utils::Headers.new(headers)
+        return [method, uri, headers, body]
+      end
+
+
+      ##
+      # Returns a <code>Hash</code> of the parameter descriptions for
+      # this method.
+      #
+      # @return [Hash] The parameter descriptions.
+      def parameter_descriptions
+        @parameter_descriptions ||= (
+          @discovery_document['parameters'] || {}
+        ).inject({}) { |h,(k,v)| h[k]=v; h }
+      end
+
+      ##
+      # Returns an <code>Array</code> of the parameters for this method.
+      #
+      # @return [Array] The parameters.
+      def parameters
+        @parameters ||= ((
+          @discovery_document['parameters'] || {}
+        ).inject({}) { |h,(k,v)| h[k]=v; h }).keys
+      end
+
+      ##
+      # Returns an <code>Array</code> of the required parameters for this
+      # method.
+      #
+      # @return [Array] The required parameters.
+      #
+      # @example
+      #   # A list of all required parameters.
+      #   method.required_parameters
+      def required_parameters
+        @required_parameters ||= ((self.parameter_descriptions.select do |k, v|
+          v['required']
+        end).inject({}) { |h,(k,v)| h[k]=v; h }).keys
+      end
+
+      ##
+      # Returns an <code>Array</code> of the optional parameters for this
+      # method.
+      #
+      # @return [Array] The optional parameters.
+      #
+      # @example
+      #   # A list of all optional parameters.
+      #   method.optional_parameters
+      def optional_parameters
+        @optional_parameters ||= ((self.parameter_descriptions.reject do |k, v|
+          v['required']
+        end).inject({}) { |h,(k,v)| h[k]=v; h }).keys
+      end
+
+      ##
+      # Verifies that the parameters are valid for this method.  Raises an
+      # exception if validation fails.
+      #
+      # @api private
+      # @param [Hash, Array] parameters
+      #   The parameters to verify.
+      #
+      # @return [NilClass] <code>nil</code> if validation passes.
+      def validate_parameters(parameters={})
+        parameters = self.normalize_parameters(parameters)
+        required_variables = ((self.parameter_descriptions.select do |k, v|
+          v['required']
+        end).inject({}) { |h,(k,v)| h[k]=v; h }).keys
+        missing_variables = required_variables - parameters.map { |(k, _)| k }
+        if missing_variables.size > 0
+          raise ArgumentError,
+            "Missing required parameters: #{missing_variables.join(', ')}."
+        end
+        parameters.each do |k, v|
+          # Handle repeated parameters.
+          if self.parameter_descriptions[k] &&
+              self.parameter_descriptions[k]['repeated'] &&
+              v.kind_of?(Array)
+            # If this is a repeated parameter and we've got an array as a
+            # value, just provide the whole array to the loop below.
+            items = v
+          else
+            # If this is not a repeated parameter, or if it is but we're
+            # being given a single value, wrap the value in an array, so that
+            # the loop below still works for the single element.
+            items = [v]
+          end
+
+          items.each do |item|
+            if self.parameter_descriptions[k]
+              enum = self.parameter_descriptions[k]['enum']
+              if enum && !enum.include?(item)
+                raise ArgumentError,
+                  "Parameter '#{k}' has an invalid value: #{item}. " +
+                  "Must be one of #{enum.inspect}."
+              end
+              pattern = self.parameter_descriptions[k]['pattern']
+              if pattern
+                regexp = Regexp.new("^#{pattern}$")
+                if item !~ regexp
+                  raise ArgumentError,
+                    "Parameter '#{k}' has an invalid value: #{item}. " +
+                    "Must match: /^#{pattern}$/."
+                end
+              end
+            end
+          end
+        end
+        return nil
+      end
+
+      ##
+      # Returns a <code>String</code> representation of the method's state.
+      #
+      # @return [String] The method's state, as a <code>String</code>.
+      def inspect
+        sprintf(
+          "#<%s:%#0x ID:%s>",
+          self.class.to_s, self.object_id, self.id
+        )
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/discovery/resource.rb b/sdk/ruby-google-api-client/lib/google/api_client/discovery/resource.rb
new file mode 100644 (file)
index 0000000..9b757c6
--- /dev/null
@@ -0,0 +1,156 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+require 'addressable/uri'
+
+require 'active_support/inflector'
+require 'google/api_client/discovery/method'
+
+
+module Google
+  class APIClient
+    ##
+    # A resource that has been described by a discovery document.
+    class Resource
+
+      ##
+      # Creates a description of a particular version of a resource.
+      #
+      # @param [Google::APIClient::API] api
+      #   The API this resource belongs to.
+      # @param [Addressable::URI] method_base
+      #   The base URI for the service.
+      # @param [String] resource_name
+      #   The identifier for the resource.
+      # @param [Hash] discovery_document
+      #   The section of the discovery document that applies to this resource.
+      #
+      # @return [Google::APIClient::Resource] The constructed resource object.
+      def initialize(api, method_base, resource_name, discovery_document)
+        @api = api
+        @method_base = method_base
+        @name = resource_name
+        @discovery_document = discovery_document
+        metaclass = (class <<self; self; end)
+        self.discovered_resources.each do |resource|
+          method_name = ActiveSupport::Inflector.underscore(resource.name).to_sym
+          if !self.respond_to?(method_name)
+            metaclass.send(:define_method, method_name) { resource }
+          end
+        end
+        self.discovered_methods.each do |method|
+          method_name = ActiveSupport::Inflector.underscore(method.name).to_sym
+          if !self.respond_to?(method_name)
+            metaclass.send(:define_method, method_name) { method }
+          end
+        end
+      end
+
+      # @return [String] unparsed discovery document for the resource
+      attr_reader :discovery_document
+
+      ##
+      # Returns the identifier for the resource.
+      #
+      # @return [String] The resource identifier.
+      attr_reader :name
+
+      ##
+      # Returns the base URI for this resource.
+      #
+      # @return [Addressable::URI] The base URI that methods are joined to.
+      attr_reader :method_base
+
+      ##
+      # Returns a human-readable description of the resource.
+      #
+      # @return [Hash] The API description.
+      def description
+        return @discovery_document['description']
+      end
+
+      ##
+      # Updates the hierarchy of resources and methods with the new base.
+      #
+      # @param [Addressable::URI, #to_str, String] new_method_base
+      #   The new base URI to use for the resource.
+      def method_base=(new_method_base)
+        @method_base = Addressable::URI.parse(new_method_base)
+        self.discovered_resources.each do |resource|
+          resource.method_base = @method_base
+        end
+        self.discovered_methods.each do |method|
+          method.method_base = @method_base
+        end
+      end
+
+      ##
+      # A list of sub-resources available on this resource.
+      #
+      # @return [Array] A list of {Google::APIClient::Resource} objects.
+      def discovered_resources
+        return @discovered_resources ||= (
+          (@discovery_document['resources'] || []).inject([]) do |accu, (k, v)|
+            accu << Google::APIClient::Resource.new(
+              @api, self.method_base, k, v
+            )
+            accu
+          end
+        )
+      end
+
+      ##
+      # A list of methods available on this resource.
+      #
+      # @return [Array] A list of {Google::APIClient::Method} objects.
+      def discovered_methods
+        return @discovered_methods ||= (
+          (@discovery_document['methods'] || []).inject([]) do |accu, (k, v)|
+            accu << Google::APIClient::Method.new(@api, self.method_base, k, v)
+            accu
+          end
+        )
+      end
+
+      ##
+      # Converts the resource to a flat mapping of RPC names and method
+      # objects.
+      #
+      # @return [Hash] All methods available on the resource.
+      def to_h
+        return @hash ||= (begin
+          methods_hash = {}
+          self.discovered_methods.each do |method|
+            methods_hash[method.id] = method
+          end
+          self.discovered_resources.each do |resource|
+            methods_hash.merge!(resource.to_h)
+          end
+          methods_hash
+        end)
+      end
+
+      ##
+      # Returns a <code>String</code> representation of the resource's state.
+      #
+      # @return [String] The resource's state, as a <code>String</code>.
+      def inspect
+        sprintf(
+          "#<%s:%#0x NAME:%s>", self.class.to_s, self.object_id, self.name
+        )
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/discovery/schema.rb b/sdk/ruby-google-api-client/lib/google/api_client/discovery/schema.rb
new file mode 100644 (file)
index 0000000..57666e6
--- /dev/null
@@ -0,0 +1,117 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+require 'time'
+require 'multi_json'
+require 'compat/multi_json'
+require 'base64'
+require 'autoparse'
+require 'addressable/uri'
+require 'addressable/template'
+
+require 'active_support/inflector'
+require 'google/api_client/errors'
+
+
+module Google
+  class APIClient
+    ##
+    # @api private
+    module Schema
+      def self.parse(api, schema_data)
+        # This method is super-long, but hard to break up due to the
+        # unavoidable dependence on closures and execution context.
+        schema_name = schema_data['id']
+
+        # Due to an oversight, schema IDs may not be URI references.
+        # TODO(bobaman): Remove this code once this has been resolved.
+        schema_uri = (
+          api.document_base +
+          (schema_name[0..0] != '#' ? '#' + schema_name : schema_name)
+        )
+
+        # Due to an oversight, schema IDs may not be URI references.
+        # TODO(bobaman): Remove this whole lambda once this has been resolved.
+        reformat_references = lambda do |data|
+          # This code is not particularly efficient due to recursive traversal
+          # and excess object creation, but this hopefully shouldn't be an
+          # issue since it should only be called only once per schema per
+          # process.
+          if data.kind_of?(Hash) &&
+              data['$ref'] && !data['$ref'].kind_of?(Hash)
+            if data['$ref'].respond_to?(:to_str)
+              reference = data['$ref'].to_str
+            else
+              raise TypeError, "Expected String, got #{data['$ref'].class}"
+            end
+            reference = '#' + reference if reference[0..0] != '#'
+            data.merge({
+              '$ref' => reference
+            })
+          elsif data.kind_of?(Hash)
+            data.inject({}) do |accu, (key, value)|
+              if value.kind_of?(Hash)
+                accu[key] = reformat_references.call(value)
+              else
+                accu[key] = value
+              end
+              accu
+            end
+          else
+            data
+          end
+        end
+        schema_data = reformat_references.call(schema_data)
+
+        if schema_name
+          api_name_string = ActiveSupport::Inflector.camelize(api.name)
+          api_version_string = ActiveSupport::Inflector.camelize(api.version).gsub('.', '_')
+          # This is for compatibility with Ruby 1.8.7.
+          # TODO(bobaman) Remove this when we eventually stop supporting 1.8.7.
+          args = []
+          args << false if Class.method(:const_defined?).arity != 1
+          if Google::APIClient::Schema.const_defined?(api_name_string, *args)
+            api_name = Google::APIClient::Schema.const_get(
+              api_name_string, *args
+            )
+          else
+            api_name = Google::APIClient::Schema.const_set(
+              api_name_string, Module.new
+            )
+          end
+          if api_name.const_defined?(api_version_string, *args)
+            api_version = api_name.const_get(api_version_string, *args)
+          else
+            api_version = api_name.const_set(api_version_string, Module.new)
+          end
+          if api_version.const_defined?(schema_name, *args)
+            schema_class = api_version.const_get(schema_name, *args)
+          end
+        end
+
+        # It's possible the schema has already been defined. If so, don't
+        # redefine it. This means that reloading a schema which has already
+        # been loaded into memory is not possible.
+        unless schema_class
+          schema_class = AutoParse.generate(schema_data, :uri => schema_uri)
+          if schema_name
+            api_version.const_set(schema_name, schema_class)
+          end
+        end
+        return schema_class
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/environment.rb b/sdk/ruby-google-api-client/lib/google/api_client/environment.rb
new file mode 100644 (file)
index 0000000..50c84fe
--- /dev/null
@@ -0,0 +1,42 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+module Google
+  class APIClient
+    module ENV
+      OS_VERSION = begin
+        if RUBY_PLATFORM =~ /mswin|win32|mingw|bccwin|cygwin/
+          # TODO(bobaman)
+          # Confirm that all of these Windows environments actually have access
+          # to the `ver` command.
+          `ver`.sub(/\s*\[Version\s*/, '/').sub(']', '').strip
+        elsif RUBY_PLATFORM =~ /darwin/i
+          "Mac OS X/#{`sw_vers -productVersion`}"
+        elsif RUBY_PLATFORM == 'java'
+          # Get the information from java system properties to avoid spawning a
+          # sub-process, which is not friendly in some contexts (web servers).
+          require 'java'
+          name = java.lang.System.getProperty('os.name')
+          version = java.lang.System.getProperty('os.version')
+          "#{name}/#{version}"
+        else
+          `uname -sr`.sub(' ', '/')
+        end
+      rescue Exception
+        RUBY_PLATFORM
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/errors.rb b/sdk/ruby-google-api-client/lib/google/api_client/errors.rb
new file mode 100644 (file)
index 0000000..9644c69
--- /dev/null
@@ -0,0 +1,65 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+module Google
+  class APIClient
+    ##
+    # An error which is raised when there is an unexpected response or other
+    # transport error that prevents an operation from succeeding.
+    class TransmissionError < StandardError
+      attr_reader :result
+      def initialize(message = nil, result = nil)
+        super(message)
+        @result = result
+      end
+    end
+
+    ##
+    # An exception that is raised if a redirect is required
+    #
+    class RedirectError < TransmissionError
+    end
+
+    ##
+    # An exception that is raised if a method is called with missing or
+    # invalid parameter values.
+    class ValidationError < StandardError
+    end
+
+    ##
+    # A 4xx class HTTP error occurred.
+    class ClientError < TransmissionError
+    end
+
+    ##
+    # A 401 HTTP error occurred.
+    class AuthorizationError < ClientError
+    end
+
+    ##
+    # A 5xx class HTTP error occurred.
+    class ServerError < TransmissionError
+    end
+
+    ##
+    # An exception that is raised if an ID token could not be validated.
+    class InvalidIDTokenError < StandardError
+    end
+
+    # Error class for problems in batch requests.
+    class BatchError < StandardError
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/logging.rb b/sdk/ruby-google-api-client/lib/google/api_client/logging.rb
new file mode 100644 (file)
index 0000000..09a075b
--- /dev/null
@@ -0,0 +1,32 @@
+require 'logger'
+
+module Google
+  class APIClient
+    
+    class << self
+      ##
+      # Logger for the API client
+      #
+      # @return [Logger] logger instance.
+      attr_accessor :logger
+    end
+
+    self.logger = Logger.new(STDOUT)
+    self.logger.level = Logger::WARN  
+
+    ##
+    # Module to make accessing the logger simpler
+    module Logging
+      ##
+      # Logger for the API client
+      #
+      # @return [Logger] logger instance.
+      def logger
+        Google::APIClient.logger
+      end
+    end
+
+  end
+  
+  
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/media.rb b/sdk/ruby-google-api-client/lib/google/api_client/media.rb
new file mode 100644 (file)
index 0000000..96816d0
--- /dev/null
@@ -0,0 +1,260 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+require 'google/api_client/reference'
+require 'faraday/multipart'
+
+module Google
+  class APIClient
+    ##
+    # Uploadable media support.  Holds an IO stream & content type.
+    #
+    # @see Faraday::UploadIO
+    # @example
+    #   media = Google::APIClient::UploadIO.new('mymovie.m4v', 'video/mp4')
+    class UploadIO < Faraday::Multipart::FilePart
+      
+      # @return [Fixnum] Size of chunks to upload. Default is nil, meaning upload the entire file in a single request
+      attr_accessor :chunk_size
+            
+      ##
+      # Get the length of the stream
+      #
+      # @return [Fixnum]
+      #   Length of stream, in bytes
+      def length
+        io.respond_to?(:length) ? io.length : File.size(local_path)
+      end
+    end
+    
+    ##
+    # Wraps an input stream and limits data to a given range
+    #
+    # @example
+    #   chunk = Google::APIClient::RangedIO.new(io, 0, 1000)
+    class RangedIO 
+      ##
+      # Bind an input stream to a specific range.
+      #
+      # @param [IO] io
+      #   Source input stream
+      # @param [Fixnum] offset
+      #   Starting offset of the range
+      # @param [Fixnum] length
+      #   Length of range
+      def initialize(io, offset, length)
+        @io = io
+        @offset = offset
+        @length = length
+        self.rewind
+      end
+      
+      ##
+      # @see IO#read
+      def read(amount = nil, buf = nil)
+        buffer = buf || ''
+        if amount.nil?
+          size = @length - @pos
+          done = ''
+        elsif amount == 0
+          size = 0
+          done = ''
+        else 
+          size = [@length - @pos, amount].min
+          done = nil
+        end
+
+        if size > 0
+          result = @io.read(size)
+          result.force_encoding("BINARY") if result.respond_to?(:force_encoding)
+          buffer << result if result
+          @pos = @pos + size
+        end
+
+        if buffer.length > 0
+          buffer
+        else
+          done
+        end
+      end
+
+      ##
+      # @see IO#rewind
+      def rewind
+        self.pos = 0
+      end
+
+      ##
+      # @see IO#pos
+      def pos
+        @pos
+      end
+
+      ##
+      # @see IO#pos=
+      def pos=(pos)
+        @pos = pos
+        @io.pos = @offset + pos
+      end
+    end
+    
+    ##
+    # Resumable uploader.
+    #
+    class ResumableUpload < Request
+      # @return [Fixnum] Max bytes to send in a single request
+      attr_accessor :chunk_size
+  
+      ##
+      # Creates a new uploader.
+      #
+      # @param [Hash] options
+      #   Request options
+      def initialize(options={})
+        super options
+        self.uri = options[:uri]
+        self.http_method = :put
+        @offset = options[:offset] || 0
+        @complete = false
+        @expired = false
+      end
+      
+      ##
+      # Sends all remaining chunks to the server
+      #
+      # @deprecated Pass the instance to {Google::APIClient#execute} instead
+      #
+      # @param [Google::APIClient] api_client
+      #   API Client instance to use for sending
+      def send_all(api_client)
+        result = nil
+        until complete?
+          result = send_chunk(api_client)
+          break unless result.status == 308
+        end
+        return result
+      end
+      
+      
+      ##
+      # Sends the next chunk to the server
+      #
+      # @deprecated Pass the instance to {Google::APIClient#execute} instead
+      #
+      # @param [Google::APIClient] api_client
+      #   API Client instance to use for sending
+      def send_chunk(api_client)
+        return api_client.execute(self)
+      end
+
+      ##
+      # Check if upload is complete
+      #
+      # @return [TrueClass, FalseClass]
+      #   Whether or not the upload complete successfully
+      def complete?
+        return @complete
+      end
+
+      ##
+      # Check if the upload URL expired (upload not completed in alotted time.)
+      # Expired uploads must be restarted from the beginning
+      #
+      # @return [TrueClass, FalseClass]
+      #   Whether or not the upload has expired and can not be resumed
+      def expired?
+        return @expired
+      end
+      
+      ##
+      # Check if upload is resumable. That is, neither complete nor expired
+      #
+      # @return [TrueClass, FalseClass] True if upload can be resumed
+      def resumable?
+        return !(self.complete? or self.expired?)
+      end
+      
+      ##
+      # Convert to an HTTP request. Returns components in order of method, URI,
+      # request headers, and body
+      #
+      # @api private
+      #
+      # @return [Array<(Symbol, Addressable::URI, Hash, [#read,#to_str])>]
+      def to_http_request
+        if @complete
+          raise Google::APIClient::ClientError, "Upload already complete"
+        elsif @offset.nil?
+          self.headers.update({ 
+            'Content-Length' => "0", 
+            'Content-Range' => "bytes */#{media.length}" })
+        else
+          start_offset = @offset
+          remaining = self.media.length - start_offset
+          chunk_size = self.media.chunk_size || self.chunk_size || self.media.length
+          content_length = [remaining, chunk_size].min
+          chunk = RangedIO.new(self.media.io, start_offset, content_length)
+          end_offset = start_offset + content_length - 1
+          self.headers.update({
+            'Content-Length' => "#{content_length}",
+            'Content-Type' => self.media.content_type, 
+            'Content-Range' => "bytes #{start_offset}-#{end_offset}/#{media.length}" })
+          self.body = chunk
+        end
+        super
+      end
+      
+      ##
+      # Check the result from the server, updating the offset and/or location
+      # if available.
+      #
+      # @api private
+      #
+      # @param [Faraday::Response] response
+      #   HTTP response
+      #
+      # @return [Google::APIClient::Result]
+      #   Processed API response
+      def process_http_response(response)
+        case response.status
+        when 200...299
+          @complete = true
+        when 308
+          range = response.headers['range']
+          if range
+            @offset = range.scan(/\d+/).collect{|x| Integer(x)}.last + 1
+          end
+          if response.headers['location']
+            self.uri = response.headers['location']
+          end
+        when 400...499
+          @expired = true
+        when 500...599
+          # Invalidate the offset to mark it needs to be queried on the
+          # next request
+          @offset = nil
+        end
+        return Google::APIClient::Result.new(self, response)
+      end
+      
+      ##
+      # Hashified verison of the API request
+      #
+      # @return [Hash]
+      def to_hash
+        super.merge(:offset => @offset)
+      end
+      
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/railtie.rb b/sdk/ruby-google-api-client/lib/google/api_client/railtie.rb
new file mode 100644 (file)
index 0000000..86d9a6b
--- /dev/null
@@ -0,0 +1,18 @@
+require 'rails/railtie'
+require 'google/api_client/logging'
+
+module Google
+  class APIClient
+    
+    ##
+    # Optional support class for Rails. Currently replaces the built-in logger
+    # with Rails' application log.
+    #
+    class Railtie < Rails::Railtie
+      initializer 'google-api-client' do |app|
+        logger = app.config.logger || Rails.logger
+        Google::APIClient.logger = logger unless logger.nil?
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/reference.rb b/sdk/ruby-google-api-client/lib/google/api_client/reference.rb
new file mode 100644 (file)
index 0000000..15b3425
--- /dev/null
@@ -0,0 +1,27 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'google/api_client/request'
+
+module Google
+  class APIClient
+    ##
+    # Subclass of Request for backwards compatibility with pre-0.5.0 versions of the library
+    # 
+    # @deprecated
+    #   use {Google::APIClient::Request} instead
+    class Reference < Request
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/request.rb b/sdk/ruby-google-api-client/lib/google/api_client/request.rb
new file mode 100644 (file)
index 0000000..3d6cc34
--- /dev/null
@@ -0,0 +1,318 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'faraday'
+require 'compat/multi_json'
+require 'addressable/uri'
+require 'stringio'
+require 'google/api_client/discovery'
+require 'google/api_client/logging'
+
+module Google
+  class APIClient
+
+    ##
+    # Represents an API request.
+    class Request
+      include Google::APIClient::Logging
+
+      MULTIPART_BOUNDARY = "-----------RubyApiMultipartPost".freeze
+
+      # @return [Hash] Request parameters
+      attr_reader :parameters
+      # @return [Hash] Additional HTTP headers
+      attr_reader :headers
+      # @return [Google::APIClient::Method] API method to invoke
+      attr_reader :api_method
+      # @return [Google::APIClient::UploadIO] File to upload
+      attr_accessor :media
+      # @return [#generated_authenticated_request] User credentials
+      attr_accessor :authorization
+      # @return [TrueClass,FalseClass] True if request should include credentials
+      attr_accessor :authenticated
+      # @return [#read, #to_str] Request body
+      attr_accessor :body
+
+      ##
+      # Build a request
+      #
+      # @param [Hash] options
+      # @option options [Hash, Array] :parameters
+      #   Request parameters for the API method.
+      # @option options [Google::APIClient::Method] :api_method
+      #   API method to invoke. Either :api_method or :uri must be specified
+      # @option options [TrueClass, FalseClass] :authenticated
+      #   True if request should include credentials. Implicitly true if
+      #   unspecified and :authorization present
+      # @option options [#generate_signed_request] :authorization
+      #   OAuth credentials
+      # @option options [Google::APIClient::UploadIO] :media
+      #   File to upload, if media upload request
+      # @option options [#to_json, #to_hash] :body_object
+      #   Main body of the API request. Typically hash or object that can
+      #   be serialized to JSON
+      # @option options [#read, #to_str] :body
+      #   Raw body to send in POST/PUT requests
+      # @option options [String, Addressable::URI] :uri
+      #   URI to request. Either :api_method or :uri must be specified
+      # @option options [String, Symbol] :http_method
+      #   HTTP method when requesting a URI
+      def initialize(options={})
+        @parameters = Faraday::Utils::ParamsHash.new
+        @headers = Faraday::Utils::Headers.new
+
+        self.parameters.merge!(options[:parameters]) unless options[:parameters].nil?
+        self.headers.merge!(options[:headers]) unless options[:headers].nil?
+        self.api_method = options[:api_method]
+        self.authenticated = options[:authenticated]
+        self.authorization = options[:authorization]
+
+        # These parameters are handled differently because they're not
+        # parameters to the API method, but rather to the API system.
+        self.parameters['key'] ||= options[:key] if options[:key]
+        self.parameters['userIp'] ||= options[:user_ip] if options[:user_ip]
+
+        if options[:media]
+          self.initialize_media_upload(options)
+        elsif options[:body]
+          self.body = options[:body]
+        elsif options[:body_object]
+          self.headers['Content-Type'] ||= 'application/json'
+          self.body = serialize_body(options[:body_object])
+        else
+          self.body = ''
+        end
+
+        unless self.api_method
+          self.http_method = options[:http_method] || 'GET'
+          self.uri = options[:uri]
+        end
+      end
+
+      # @!attribute [r] upload_type
+      # @return [String] protocol used for upload
+      def upload_type
+        return self.parameters['uploadType'] || self.parameters['upload_type']
+      end
+
+      # @!attribute http_method
+      # @return [Symbol] HTTP method if invoking a URI
+      def http_method
+        return @http_method ||= self.api_method.http_method.to_s.downcase.to_sym
+      end
+
+      def http_method=(new_http_method)
+        if new_http_method.kind_of?(Symbol)
+          @http_method = new_http_method.to_s.downcase.to_sym
+        elsif new_http_method.respond_to?(:to_str)
+          @http_method = new_http_method.to_s.downcase.to_sym
+        else
+          raise TypeError,
+            "Expected String or Symbol, got #{new_http_method.class}."
+        end
+      end
+
+      def api_method=(new_api_method)
+        if new_api_method.nil? || new_api_method.kind_of?(Google::APIClient::Method)
+          @api_method = new_api_method
+        else
+          raise TypeError,
+            "Expected Google::APIClient::Method, got #{new_api_method.class}."
+        end
+      end
+
+      # @!attribute uri
+      # @return [Addressable::URI] URI to send request
+      def uri
+        return @uri ||= self.api_method.generate_uri(self.parameters)
+      end
+
+      def uri=(new_uri)
+        @uri = Addressable::URI.parse(new_uri)
+        @parameters.update(@uri.query_values) unless @uri.query_values.nil?
+      end
+
+
+      # Transmits the request with the given connection
+      #
+      # @api private
+      #
+      # @param [Faraday::Connection] connection
+      #   the connection to transmit with
+      # @param [TrueValue,FalseValue] is_retry
+      #   True if request has been previous sent
+      #
+      # @return [Google::APIClient::Result]
+      #   result of API request
+      def send(connection, is_retry = false)
+        self.body.rewind if is_retry && self.body.respond_to?(:rewind)
+        env = self.to_env(connection)
+        logger.debug  { "#{self.class} Sending API request #{env[:method]} #{env[:url].to_s} #{env[:request_headers]}" }
+        http_response = connection.app.call(env)
+        result = self.process_http_response(http_response)
+
+        logger.debug { "#{self.class} Result: #{result.status} #{result.headers}" }
+
+        # Resumamble slightly different than other upload protocols in that it requires at least
+        # 2 requests.
+        if result.status == 200 && self.upload_type == 'resumable' && self.media
+          upload = result.resumable_upload
+          unless upload.complete?
+            logger.debug { "#{self.class} Sending upload body" }
+            result = upload.send(connection)
+          end
+        end
+        return result
+      end
+
+      # Convert to an HTTP request. Returns components in order of method, URI,
+      # request headers, and body
+      #
+      # @api private
+      #
+      # @return [Array<(Symbol, Addressable::URI, Hash, [#read,#to_str])>]
+      def to_http_request
+        request = (
+          if self.api_method
+            self.api_method.generate_request(self.parameters, self.body, self.headers)
+          elsif self.uri
+            unless self.parameters.empty?
+              self.uri.query = Addressable::URI.form_encode(self.parameters)
+            end
+            [self.http_method, self.uri.to_s, self.headers, self.body]
+          end)
+        return request
+      end
+
+      ##
+      # Hashified verison of the API request
+      #
+      # @return [Hash]
+      def to_hash
+        options = {}
+        if self.api_method
+          options[:api_method] = self.api_method
+          options[:parameters] = self.parameters
+        else
+          options[:http_method] = self.http_method
+          options[:uri] = self.uri
+        end
+        options[:headers] = self.headers
+        options[:body] = self.body
+        options[:media] = self.media
+        unless self.authorization.nil?
+          options[:authorization] = self.authorization
+        end
+        return options
+      end
+
+      ##
+      # Prepares the request for execution, building a hash of parts
+      # suitable for sending to Faraday::Connection.
+      #
+      # @api private
+      #
+      # @param [Faraday::Connection] connection
+      #   Connection for building the request
+      #
+      # @return [Hash]
+      #   Encoded request
+      def to_env(connection)
+        method, uri, headers, body = self.to_http_request
+        http_request = connection.build_request(method) do |req|
+          req.url(uri.to_s)
+          req.headers.update(headers)
+          req.body = body
+        end
+
+        if self.authorization.respond_to?(:generate_authenticated_request)
+          http_request = self.authorization.generate_authenticated_request(
+            :request => http_request,
+            :connection => connection
+          )
+        end
+
+        http_request.to_env(connection)
+      end
+
+      ##
+      # Convert HTTP response to an API Result
+      #
+      # @api private
+      #
+      # @param [Faraday::Response] response
+      #   HTTP response
+      #
+      # @return [Google::APIClient::Result]
+      #   Processed API response
+      def process_http_response(response)
+        Result.new(self, response)
+      end
+
+      protected
+
+      ##
+      # Adjust headers & body for media uploads
+      #
+      # @api private
+      #
+      # @param [Hash] options
+      # @option options [Hash, Array] :parameters
+      #   Request parameters for the API method.
+      # @option options [Google::APIClient::UploadIO] :media
+      #   File to upload, if media upload request
+      # @option options [#to_json, #to_hash] :body_object
+      #   Main body of the API request. Typically hash or object that can
+      #   be serialized to JSON
+      # @option options [#read, #to_str] :body
+      #   Raw body to send in POST/PUT requests
+      def initialize_media_upload(options)
+        raise "media upload not supported by arvados-google-api-client"
+      end
+
+      ##
+      # Assemble a multipart message from a set of parts
+      #
+      # @api private
+      #
+      # @param [Array<[#read,#to_str]>] parts
+      #   Array of parts to encode.
+      # @param [String] mime_type
+      #   MIME type of the message
+      # @param [String] boundary
+      #   Boundary for separating each part of the message
+      def build_multipart(parts, mime_type = 'multipart/related', boundary = MULTIPART_BOUNDARY)
+        raise "multipart upload not supported by arvados-google-api-client"
+      end
+
+      ##
+      # Serialize body object to JSON
+      #
+      # @api private
+      #
+      # @param [#to_json,#to_hash] body
+      #   object to serialize
+      #
+      # @return [String]
+      #   JSON
+      def serialize_body(body)
+        return body.to_json if body.respond_to?(:to_json)
+        return MultiJson.dump(body.to_hash) if body.respond_to?(:to_hash)
+        raise TypeError, 'Could not convert body object to JSON.' +
+                         'Must respond to :to_json or :to_hash.'
+      end
+
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/result.rb b/sdk/ruby-google-api-client/lib/google/api_client/result.rb
new file mode 100644 (file)
index 0000000..c48bec0
--- /dev/null
@@ -0,0 +1,255 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+module Google
+  class APIClient
+    ##
+    # This class wraps a result returned by an API call.
+    class Result
+      extend Forwardable
+      
+      ##
+      # Init the result
+      #
+      # @param [Google::APIClient::Request] request
+      #   The original request
+      # @param [Faraday::Response] response
+      #   Raw HTTP Response
+      def initialize(request, response)
+        @request = request
+        @response = response
+        @media_upload = reference if reference.kind_of?(ResumableUpload)
+      end
+
+      # @return [Google::APIClient::Request] Original request object
+      attr_reader :request
+      # @return [Faraday::Response] HTTP response
+      attr_reader :response
+      # @!attribute [r] reference
+      #   @return [Google::APIClient::Request] Original request object
+      #   @deprecated See {#request}
+      alias_method :reference, :request # For compatibility with pre-beta clients
+
+      # @!attribute [r] status
+      #   @return [Fixnum] HTTP status code
+      # @!attribute [r] headers
+      #   @return [Hash] HTTP response headers
+      # @!attribute [r] body
+      #   @return [String] HTTP response body
+      def_delegators :@response, :status, :headers, :body
+
+      # @!attribute [r] resumable_upload
+      # @return [Google::APIClient::ResumableUpload] For resuming media uploads
+      def resumable_upload        
+        @media_upload ||= (
+          options = self.reference.to_hash.merge(
+            :uri => self.headers['location'],
+            :media => self.reference.media
+          )
+          Google::APIClient::ResumableUpload.new(options)
+        )
+      end
+      
+      ##
+      # Get the content type of the response
+      # @!attribute [r] media_type
+      # @return [String]
+      #  Value of content-type header
+      def media_type
+        _, content_type = self.headers.detect do |h, v|
+          h.downcase == 'Content-Type'.downcase
+        end
+        if content_type
+          return content_type[/^([^;]*);?.*$/, 1].strip.downcase
+        else
+          return nil
+        end
+      end
+      
+      ##
+      # Check if request failed
+      #
+      # @!attribute [r] error?
+      # @return [TrueClass, FalseClass]
+      #   true if result of operation is an error
+      def error?
+        return self.response.status >= 400
+      end
+
+      ##
+      # Check if request was successful
+      #
+      # @!attribute [r] success?
+      # @return [TrueClass, FalseClass]
+      #   true if result of operation was successful
+      def success?
+        return !self.error?
+      end
+      
+      ##
+      # Extracts error messages from the response body
+      #
+      # @!attribute [r] error_message
+      # @return [String]
+      #   error message, if available
+      def error_message
+        if self.data?
+          if self.data.respond_to?(:error) &&
+             self.data.error.respond_to?(:message)
+            # You're going to get a terrible error message if the response isn't
+            # parsed successfully as an error.
+            return self.data.error.message
+          elsif self.data['error'] && self.data['error']['message']
+            return self.data['error']['message']
+          end
+        end
+        return self.body
+      end
+
+      ##
+      # Check for parsable data in response
+      #
+      # @!attribute [r] data?
+      # @return [TrueClass, FalseClass]
+      #   true if body can be parsed
+      def data?
+        !(self.body.nil? || self.body.empty? || self.media_type != 'application/json')
+      end
+      
+      ##
+      # Return parsed version of the response body.
+      #
+      # @!attribute [r] data
+      # @return [Object, Hash, String]
+      #   Object if body parsable from API schema, Hash if JSON, raw body if unable to parse
+      def data
+        return @data ||= (begin
+          if self.data?
+            media_type = self.media_type
+            data = self.body
+            case media_type
+            when 'application/json'
+              data = MultiJson.load(data)
+              # Strip data wrapper, if present
+              data = data['data'] if data.has_key?('data')
+            else
+              raise ArgumentError,
+                "Content-Type not supported for parsing: #{media_type}"
+            end
+            if @request.api_method && @request.api_method.response_schema
+              # Automatically parse using the schema designated for the
+              # response of this API method.
+              data = @request.api_method.response_schema.new(data)
+              data
+            else
+              # Otherwise, return the raw unparsed value.
+              # This value must be indexable like a Hash.
+              data
+            end
+          end
+        end)
+      end
+
+      ##
+      # Get the token used for requesting the next page of data
+      #
+      # @!attribute [r] next_page_token
+      # @return [String]
+      #   next page token
+      def next_page_token
+        if self.data.respond_to?(:next_page_token)
+          return self.data.next_page_token
+        elsif self.data.respond_to?(:[])
+          return self.data["nextPageToken"]
+        else
+          raise TypeError, "Data object did not respond to #next_page_token."
+        end
+      end
+
+      ##
+      # Build a request for fetching the next page of data
+      # 
+      # @return [Google::APIClient::Request]
+      #   API request for retrieving next page, nil if no page token available
+      def next_page
+        return nil unless self.next_page_token
+        merged_parameters = Hash[self.reference.parameters].merge({
+          self.page_token_param => self.next_page_token
+        })
+        # Because Requests can be coerced to Hashes, we can merge them,
+        # preserving all context except the API method parameters that we're
+        # using for pagination.
+        return Google::APIClient::Request.new(
+          Hash[self.reference].merge(:parameters => merged_parameters)
+        )
+      end
+
+      ##
+      # Get the token used for requesting the previous page of data
+      #
+      # @!attribute [r] prev_page_token
+      # @return [String]
+      #   previous page token
+      def prev_page_token
+        if self.data.respond_to?(:prev_page_token)
+          return self.data.prev_page_token
+        elsif self.data.respond_to?(:[])
+          return self.data["prevPageToken"]
+        else
+          raise TypeError, "Data object did not respond to #next_page_token."
+        end
+      end
+
+      ##
+      # Build a request for fetching the previous page of data
+      # 
+      # @return [Google::APIClient::Request]
+      #   API request for retrieving previous page, nil if no page token available
+      def prev_page
+        return nil unless self.prev_page_token
+        merged_parameters = Hash[self.reference.parameters].merge({
+          self.page_token_param => self.prev_page_token
+        })
+        # Because Requests can be coerced to Hashes, we can merge them,
+        # preserving all context except the API method parameters that we're
+        # using for pagination.
+        return Google::APIClient::Request.new(
+          Hash[self.reference].merge(:parameters => merged_parameters)
+        )
+      end
+      
+      ##
+      # Pagination scheme used by this request/response
+      #
+      # @!attribute [r] pagination_type
+      # @return [Symbol]
+      #  currently always :token
+      def pagination_type
+        return :token
+      end
+
+      ##
+      # Name of the field that contains the pagination token
+      #
+      # @!attribute [r] page_token_param
+      # @return [String]
+      #  currently always 'pageToken'
+      def page_token_param
+        return "pageToken"
+      end
+
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/service.rb b/sdk/ruby-google-api-client/lib/google/api_client/service.rb
new file mode 100755 (executable)
index 0000000..28f2605
--- /dev/null
@@ -0,0 +1,233 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'google/api_client'
+require 'google/api_client/service/stub_generator'
+require 'google/api_client/service/resource'
+require 'google/api_client/service/request'
+require 'google/api_client/service/result'
+require 'google/api_client/service/batch'
+require 'google/api_client/service/simple_file_store'
+
+module Google
+  class APIClient
+
+    ##
+    # Experimental new programming interface at the API level.
+    # Hides Google::APIClient. Designed to be easier to use, with less code.
+    #
+    # @example
+    #   calendar = Google::APIClient::Service.new('calendar', 'v3')
+    #   result = calendar.events.list('calendarId' => 'primary').execute()
+    class Service
+      include Google::APIClient::Service::StubGenerator
+      extend Forwardable
+
+      DEFAULT_CACHE_FILE = 'discovery.cache'
+
+      # Cache for discovered APIs.
+      @@discovered = {}
+
+      ##
+      # Creates a new Service.
+      #
+      # @param [String, Symbol] api_name
+      #   The name of the API this service will access.
+      # @param [String, Symbol] api_version
+      #   The version of the API this service will access.
+      # @param [Hash] options
+      #   The configuration parameters for the service.
+      # @option options [Symbol, #generate_authenticated_request] :authorization
+      #   (:oauth_1)
+      #   The authorization mechanism used by the client.  The following
+      #   mechanisms are supported out-of-the-box:
+      #   <ul>
+      #     <li><code>:two_legged_oauth_1</code></li>
+      #     <li><code>:oauth_1</code></li>
+      #     <li><code>:oauth_2</code></li>
+      #   </ul>
+      # @option options [Boolean] :auto_refresh_token (true)
+      #   The setting that controls whether or not the api client attempts to
+      #   refresh authorization when a 401 is hit in #execute. If the token does
+      #   not support it, this option is ignored.
+      # @option options [String] :application_name
+      #   The name of the application using the client.
+      # @option options [String] :application_version
+      #   The version number of the application using the client.
+      # @option options [String] :host ("www.googleapis.com")
+      #   The API hostname used by the client. This rarely needs to be changed.
+      # @option options [String] :port (443)
+      #   The port number used by the client. This rarely needs to be changed.
+      # @option options [String] :discovery_path ("/discovery/v1")
+      #   The discovery base path. This rarely needs to be changed.
+      # @option options [String] :ca_file
+      #   Optional set of root certificates to use when validating SSL connections.
+      #   By default, a bundled set of trusted roots will be used.
+      # @option options [#generate_authenticated_request] :authorization
+      #   The authorization mechanism for requests. Used only if
+      #   `:authenticated` is `true`.
+      # @option options [TrueClass, FalseClass] :authenticated (default: true)
+      #   `true` if requests must be signed or somehow
+      #   authenticated, `false` otherwise.
+      # @option options [TrueClass, FalseClass] :gzip (default: true)
+      #   `true` if gzip enabled, `false` otherwise.
+      # @option options [Faraday::Connection] :connection
+      #   A custom connection to be used for all requests.
+      # @option options [ActiveSupport::Cache::Store, :default] :discovery_cache
+      #   A cache store to place the discovery documents for loaded APIs.
+      #   Avoids unnecessary roundtrips to the discovery service.
+      #   :default loads the default local file cache store.
+      def initialize(api_name, api_version, options = {})
+        @api_name = api_name.to_s
+        if api_version.nil?
+          raise ArgumentError,
+            "API version must be set"
+        end
+        @api_version = api_version.to_s
+        if options && !options.respond_to?(:to_hash)
+          raise ArgumentError,
+            "expected options Hash, got #{options.class}"
+        end
+
+        params = {}
+        [:application_name, :application_version, :authorization, :host, :port,
+         :discovery_path, :auto_refresh_token, :key, :user_ip,
+         :ca_file].each do |option|
+          if options.include? option
+            params[option] = options[option]
+          end
+        end
+
+        @client = Google::APIClient.new(params)
+
+        @connection = options[:connection] || @client.connection
+
+        @options = options
+
+        # Initialize cache store. Default to SimpleFileStore if :cache_store
+        # is not provided and we have write permissions.
+        if options.include? :cache_store
+          @cache_store = options[:cache_store]
+        else
+          cache_exists = File.exists?(DEFAULT_CACHE_FILE)
+          if (cache_exists && File.writable?(DEFAULT_CACHE_FILE)) ||
+             (!cache_exists && File.writable?(Dir.pwd))
+            @cache_store = Google::APIClient::Service::SimpleFileStore.new(
+              DEFAULT_CACHE_FILE)
+          end
+        end
+
+        # Attempt to read API definition from memory cache.
+        # Not thread-safe, but the worst that can happen is a cache miss.
+        unless @api = @@discovered[[api_name, api_version]]
+          # Attempt to read API definition from cache store, if there is one.
+          # If there's a miss or no cache store, call discovery service.
+          if !@cache_store.nil?
+            @api = @cache_store.fetch("%s/%s" % [api_name, api_version]) do
+              @client.discovered_api(api_name, api_version)
+            end
+          else
+            @api = @client.discovered_api(api_name, api_version)
+          end
+          @@discovered[[api_name, api_version]] = @api
+        end
+
+        generate_call_stubs(self, @api)
+      end
+
+      ##
+      # Returns the authorization mechanism used by the service.
+      #
+      # @return [#generate_authenticated_request] The authorization mechanism.
+      def_delegators :@client, :authorization, :authorization=
+
+      ##
+      # The setting that controls whether or not the service attempts to
+      # refresh authorization when a 401 is hit during an API call.
+      #
+      # @return [Boolean]
+      def_delegators :@client, :auto_refresh_token, :auto_refresh_token=
+
+      ##
+      # The application's API key issued by the API console.
+      #
+      # @return [String] The API key.
+      def_delegators :@client, :key, :key=
+
+      ##
+      # The Faraday/HTTP connection used by this service.
+      #
+      # @return [Faraday::Connection]
+      attr_accessor :connection
+
+      ##
+      # The cache store used for storing discovery documents.
+      #
+      # @return [ActiveSupport::Cache::Store,
+      #          Google::APIClient::Service::SimpleFileStore,
+      #          nil]
+      attr_reader :cache_store
+
+      ##
+      # Prepares a Google::APIClient::BatchRequest object to make batched calls.
+      # @param [Array] calls
+      #   Optional array of Google::APIClient::Service::Request to initialize
+      #   the batch request with.
+      # @param [Proc] block
+      #   Callback for every call's response. Won't be called if a call defined
+      #   a callback of its own.
+      #
+      # @yield [Google::APIClient::Service::Result]
+      #   block to be called when result ready
+      def batch(calls = nil, &block)
+        Google::APIClient::Service::BatchRequest.new(self, calls, &block)
+      end
+
+      ##
+      # Executes an API request.
+      # Do not call directly; this method is only used by Request objects when
+      # executing.
+      #
+      # @param [Google::APIClient::Service::Request,
+      #         Google::APIClient::Service::BatchCall] request
+      #   The request to be executed.
+      def execute(request)
+        if request.instance_of? Google::APIClient::Service::Request
+          params = {:api_method => request.method,
+            :parameters => request.parameters,
+            :connection => @connection}
+          if request.respond_to? :body
+            if request.body.respond_to? :to_hash
+              params[:body_object] = request.body
+            else
+              params[:body] = request.body
+            end
+          end
+          if request.respond_to? :media
+            params[:media] = request.media
+          end
+          [:authenticated, :gzip].each do |option|
+            if @options.include? option
+              params[option] = @options[option]
+            end
+          end
+          result = @client.execute(params)
+          return Google::APIClient::Service::Result.new(request, result)
+        elsif request.instance_of? Google::APIClient::Service::BatchRequest
+          @client.execute(request.base_batch, {:connection => @connection})
+        end
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/service/batch.rb b/sdk/ruby-google-api-client/lib/google/api_client/service/batch.rb
new file mode 100644 (file)
index 0000000..7ba406e
--- /dev/null
@@ -0,0 +1,110 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'google/api_client/service/result'
+require 'google/api_client/batch'
+
+module Google
+  class APIClient
+    class Service
+
+      ##
+      # Helper class to contain the result of an individual batched call.
+      #
+      class BatchedCallResult < Result
+        # @return [Fixnum] Index of the call
+        def call_index
+          return @base_result.response.call_id.to_i - 1
+        end
+      end
+
+      ##
+      #
+      #
+      class BatchRequest
+        ##
+        # Creates a new batch request.
+        # This class shouldn't be instantiated directly, but rather through
+        # Service.batch.
+        #
+        # @param [Array] calls
+        #   List of Google::APIClient::Service::Request to be made.
+        # @param [Proc] block
+        #   Callback for every call's response. Won't be called if a call
+        #   defined a callback of its own.
+        #
+        # @yield [Google::APIClient::Service::Result]
+        #   block to be called when result ready
+        def initialize(service, calls, &block)
+          @service = service
+          @base_batch = Google::APIClient::BatchRequest.new
+          @global_callback = block if block_given?
+
+          if calls && calls.length > 0
+            calls.each do |call|
+              add(call)
+            end
+          end
+        end
+
+        ##
+        # Add a new call to the batch request.
+        #
+        # @param [Google::APIClient::Service::Request] call
+        #   the call to be added.
+        # @param [Proc] block
+        #   callback for this call's response.
+        #
+        # @return [Google::APIClient::Service::BatchRequest]
+        #   the BatchRequest, for chaining
+        #
+        # @yield [Google::APIClient::Service::Result]
+        #   block to be called when result ready
+        def add(call, &block)
+          if !block_given? && @global_callback.nil?
+            raise BatchError, 'Request needs a block'
+          end
+          callback = block || @global_callback
+          base_call = {
+            :api_method => call.method,
+            :parameters => call.parameters
+          }
+          if call.respond_to? :body
+            if call.body.respond_to? :to_hash
+              base_call[:body_object] = call.body
+            else
+              base_call[:body] = call.body
+            end
+          end
+          @base_batch.add(base_call) do |base_result|
+            result = Google::APIClient::Service::BatchedCallResult.new(
+                call, base_result)
+            callback.call(result)
+          end
+          return self
+        end
+
+        ##
+        # Executes the batch request.
+        def execute
+          @service.execute(self)
+        end
+
+        attr_reader :base_batch
+
+      end
+
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/service/request.rb b/sdk/ruby-google-api-client/lib/google/api_client/service/request.rb
new file mode 100755 (executable)
index 0000000..dcbc7e3
--- /dev/null
@@ -0,0 +1,144 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+module Google
+  class APIClient
+    class Service
+      ##
+      # Handles an API request.
+      # This contains a full definition of the request to be made (including
+      # method name, parameters, body and media). The remote API call can be
+      # invoked with execute().
+      class Request
+        ##
+        # Build a request.
+        # This class should not be directly instantiated in user code;
+        # instantiation is handled by the stub methods created on Service and
+        # Resource objects.
+        #
+        # @param [Google::APIClient::Service] service
+        #   The parent Service instance that will execute the request.
+        # @param [Google::APIClient::Method] method
+        #   The Method instance that describes the API method invoked by the
+        #   request.
+        # @param [Hash] parameters
+        #   A Hash of parameter names and values to be sent in the API call.
+        def initialize(service, method, parameters)
+          @service = service
+          @method = method
+          @parameters = parameters
+          @body = nil
+          @media = nil
+
+          metaclass = (class << self; self; end)
+
+          # If applicable, add "body", "body=" and resource-named methods for
+          # retrieving and setting the HTTP body for this request.
+          # Examples of setting the body for files.insert in the Drive API:
+          #   request.body = object
+          #   request.execute
+          #  OR
+          #   request.file = object
+          #   request.execute
+          #  OR
+          #   request.body(object).execute
+          #  OR
+          #   request.file(object).execute
+          # Examples of retrieving the body for files.insert in the Drive API:
+          #   object = request.body
+          #  OR
+          #   object = request.file
+          if method.request_schema
+            body_name = method.request_schema.data['id'].dup
+            body_name[0] = body_name[0].chr.downcase
+            body_name_equals = (body_name + '=').to_sym
+            body_name = body_name.to_sym
+
+            metaclass.send(:define_method, :body) do |*args|
+              if args.length == 1
+                @body = args.first
+                return self
+              elsif args.length == 0
+                return @body
+              else
+                raise ArgumentError,
+                  "wrong number of arguments (#{args.length}; expecting 0 or 1)"
+              end
+            end
+
+            metaclass.send(:define_method, :body=) do |body|
+              @body = body
+            end
+
+            metaclass.send(:alias_method, body_name, :body)
+            metaclass.send(:alias_method, body_name_equals, :body=)
+          end
+
+          # If applicable, add "media" and "media=" for retrieving and setting
+          # the media object for this request.
+          # Examples of setting the media object:
+          #   request.media = object
+          #   request.execute
+          #  OR
+          #   request.media(object).execute
+          # Example of retrieving the media object:
+          #   object = request.media
+          if method.media_upload
+            metaclass.send(:define_method, :media) do |*args|
+              if args.length == 1
+                @media = args.first
+                return self
+              elsif args.length == 0
+                return @media
+              else
+                raise ArgumentError,
+                  "wrong number of arguments (#{args.length}; expecting 0 or 1)"
+              end
+            end
+
+            metaclass.send(:define_method, :media=) do |media|
+              @media = media
+            end
+          end
+        end
+
+        ##
+        # Returns the parent service capable of executing this request.
+        #
+        # @return [Google::APIClient::Service] The parent service.
+        attr_reader :service
+
+        ##
+        # Returns the Method instance that describes the API method invoked by
+        # the request.
+        #
+        # @return [Google::APIClient::Method] The API method description.
+        attr_reader :method
+
+        ##
+        # Contains the Hash of parameter names and values to be sent as the
+        # parameters for the API call.
+        #
+        # @return [Hash] The request parameters.
+        attr_accessor :parameters
+
+        ##
+        # Executes the request.
+        def execute
+          @service.execute(self)
+        end
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/service/resource.rb b/sdk/ruby-google-api-client/lib/google/api_client/service/resource.rb
new file mode 100755 (executable)
index 0000000..b493769
--- /dev/null
@@ -0,0 +1,40 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+module Google
+  class APIClient
+    class Service
+      ##
+      # Handles an API resource.
+      # Simple class that contains API methods and/or child resources.
+      class Resource
+        include Google::APIClient::Service::StubGenerator
+
+        ##
+        # Build a resource.
+        # This class should not be directly instantiated in user code; resources
+        # are instantiated by the stub generation mechanism on Service creation.
+        #
+        # @param [Google::APIClient::Service] service
+        #   The Service instance this resource belongs to.
+        # @param [Google::APIClient::API, Google::APIClient::Resource] root
+        #   The node corresponding to this resource.
+        def initialize(service, root)
+          @service = service
+          generate_call_stubs(service, root)
+        end
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/service/result.rb b/sdk/ruby-google-api-client/lib/google/api_client/service/result.rb
new file mode 100755 (executable)
index 0000000..7957ea6
--- /dev/null
@@ -0,0 +1,162 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+module Google
+  class APIClient
+    class Service
+      ##
+      # Handles an API result.
+      # Wraps around the Google::APIClient::Result class, making it easier to
+      # handle the result (e.g. pagination) and keeping it in line with the rest
+      # of the Service programming interface.
+      class Result
+        extend Forwardable
+
+        ##
+        # Init the result.
+        #
+        # @param [Google::APIClient::Service::Request] request
+        #   The original request
+        # @param [Google::APIClient::Result] base_result
+        #   The base result to be wrapped
+        def initialize(request, base_result)
+          @request = request
+          @base_result = base_result
+        end
+
+        # @!attribute [r] status
+        #   @return [Fixnum] HTTP status code
+        # @!attribute [r] headers
+        #   @return [Hash] HTTP response headers
+        # @!attribute [r] body
+        #   @return [String] HTTP response body
+        def_delegators :@base_result, :status, :headers, :body
+
+        # @return [Google::APIClient::Service::Request] Original request object
+        attr_reader :request
+
+        ##
+        # Get the content type of the response
+        # @!attribute [r] media_type
+        # @return [String]
+        #  Value of content-type header
+        def_delegators :@base_result, :media_type
+
+        ##
+        # Check if request failed
+        #
+        # @!attribute [r] error?
+        # @return [TrueClass, FalseClass]
+        #   true if result of operation is an error
+        def_delegators :@base_result, :error?
+
+        ##
+        # Check if request was successful
+        #
+        # @!attribute [r] success?
+        # @return [TrueClass, FalseClass]
+        #   true if result of operation was successful
+        def_delegators :@base_result, :success?
+
+        ##
+        # Extracts error messages from the response body
+        #
+        # @!attribute [r] error_message
+        # @return [String]
+        #   error message, if available
+        def_delegators :@base_result, :error_message
+
+        ##
+        # Check for parsable data in response
+        #
+        # @!attribute [r] data?
+        # @return [TrueClass, FalseClass]
+        #   true if body can be parsed
+        def_delegators :@base_result, :data?
+
+        ##
+        # Return parsed version of the response body.
+        #
+        # @!attribute [r] data
+        # @return [Object, Hash, String]
+        #   Object if body parsable from API schema, Hash if JSON, raw body if unable to parse
+        def_delegators :@base_result, :data
+
+        ##
+        # Pagination scheme used by this request/response
+        #
+        # @!attribute [r] pagination_type
+        # @return [Symbol]
+        #  currently always :token
+        def_delegators :@base_result, :pagination_type
+
+        ##
+        # Name of the field that contains the pagination token
+        #
+        # @!attribute [r] page_token_param
+        # @return [String]
+        #  currently always 'pageToken'
+        def_delegators :@base_result, :page_token_param
+
+        ##
+        # Get the token used for requesting the next page of data
+        #
+        # @!attribute [r] next_page_token
+        # @return [String]
+        #   next page tokenx =
+        def_delegators :@base_result, :next_page_token
+
+        ##
+        # Get the token used for requesting the previous page of data
+        #
+        # @!attribute [r] prev_page_token
+        # @return [String]
+        #   previous page token
+        def_delegators :@base_result, :prev_page_token
+
+        # @!attribute [r] resumable_upload
+        def resumable_upload
+          # TODO(sgomes): implement resumable_upload for Service::Result
+          raise NotImplementedError
+        end
+
+        ##
+        # Build a request for fetching the next page of data
+        #
+        # @return [Google::APIClient::Service::Request]
+        #   API request for retrieving next page
+        def next_page
+          request = @request.clone
+          # Make a deep copy of the parameters.
+          request.parameters = Marshal.load(Marshal.dump(request.parameters))
+          request.parameters[page_token_param] = self.next_page_token
+          return request
+        end
+
+        ##
+        # Build a request for fetching the previous page of data
+        #
+        # @return [Google::APIClient::Service::Request]
+        #   API request for retrieving previous page
+        def prev_page
+          request = @request.clone
+          # Make a deep copy of the parameters.
+          request.parameters = Marshal.load(Marshal.dump(request.parameters))
+          request.parameters[page_token_param] = self.prev_page_token
+          return request
+        end
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/service/simple_file_store.rb b/sdk/ruby-google-api-client/lib/google/api_client/service/simple_file_store.rb
new file mode 100644 (file)
index 0000000..216b3fa
--- /dev/null
@@ -0,0 +1,151 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+module Google
+  class APIClient
+    class Service
+
+      # Simple file store to be used in the event no ActiveSupport cache store
+      # is provided. This is not thread-safe, and does not support a number of
+      # features (such as expiration), but it's useful for the simple purpose of
+      # caching discovery documents to disk.
+      # Implements the basic cache methods of ActiveSupport::Cache::Store in a
+      # limited fashion.
+      class SimpleFileStore
+
+        # Creates a new SimpleFileStore.
+        #
+        # @param [String] file_path
+        #   The path to the cache file on disk.
+        # @param [Object] options
+        #   The options to be used with this SimpleFileStore. Not implemented.
+        def initialize(file_path, options = nil)
+          @file_path = file_path.to_s
+        end
+
+        # Returns true if a key exists in the cache.
+        #
+        # @param [String] name
+        #   The name of the key. Will always be converted to a string.
+        # @param [Object] options
+        #   The options to be used with this query. Not implemented.
+        def exist?(name, options = nil)
+          read_file
+          @cache.nil? ? nil : @cache.include?(name.to_s)
+        end
+
+        # Fetches data from the cache and returns it, using the given key.
+        # If the key is missing and no block is passed, returns nil.
+        # If the key is missing and a block is passed, executes the block, sets
+        # the key to its value, and returns it.
+        #
+        # @param [String] name
+        #   The name of the key. Will always be converted to a string.
+        # @param [Object] options
+        #   The options to be used with this query. Not implemented.
+        # @yield [String]
+        #   optional block with the default value if the key is missing
+        def fetch(name, options = nil)
+          read_file
+          if block_given?
+            entry = read(name.to_s, options)
+            if entry.nil?
+              value = yield name.to_s
+              write(name.to_s, value)
+              return value
+            else
+              return entry
+            end
+          else
+            return read(name.to_s, options)
+          end
+        end
+
+        # Fetches data from the cache, using the given key.
+        # Returns nil if the key is missing.
+        #
+        # @param [String] name
+        #   The name of the key. Will always be converted to a string.
+        # @param [Object] options
+        #   The options to be used with this query. Not implemented.
+        def read(name, options = nil)
+          read_file
+          @cache.nil? ? nil : @cache[name.to_s]
+        end
+
+        # Writes the value to the cache, with the key.
+        #
+        # @param [String] name
+        #   The name of the key. Will always be converted to a string.
+        # @param [Object] value
+        #   The value to be written.
+        # @param [Object] options
+        #   The options to be used with this query. Not implemented.
+        def write(name, value, options = nil)
+          read_file
+          @cache = {} if @cache.nil?
+          @cache[name.to_s] = value
+          write_file
+          return nil
+        end
+
+        # Deletes an entry in the cache.
+        # Returns true if an entry is deleted.
+        #
+        # @param [String] name
+        #   The name of the key. Will always be converted to a string.
+        # @param [Object] options
+        #   The options to be used with this query. Not implemented.
+        def delete(name, options = nil)
+          read_file
+          return nil if @cache.nil?
+          if @cache.include? name.to_s
+            @cache.delete name.to_s
+            write_file
+            return true
+          else
+            return nil
+          end
+        end
+
+        protected
+
+        # Read the entire cache file from disk.
+        # Will avoid reading if there have been no changes.
+        def read_file
+          if !File.exist? @file_path
+            @cache = nil
+          else
+            # Check for changes after our last read or write.
+            if @last_change.nil? || File.mtime(@file_path) > @last_change
+              File.open(@file_path) do |file|
+                @cache = Marshal.load(file)
+                @last_change = file.mtime
+              end
+            end
+          end
+          return @cache
+        end
+
+        # Write the entire cache contents to disk.
+        def write_file
+          File.open(@file_path, 'w') do |file|
+            Marshal.dump(@cache, file)
+          end
+          @last_change = File.mtime(@file_path)
+        end
+      end
+    end
+  end
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/service/stub_generator.rb b/sdk/ruby-google-api-client/lib/google/api_client/service/stub_generator.rb
new file mode 100755 (executable)
index 0000000..3c84ddd
--- /dev/null
@@ -0,0 +1,61 @@
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'active_support/inflector'
+
+module Google
+  class APIClient
+    class Service
+      ##
+      # Auxiliary mixin to generate resource and method stubs.
+      # Used by the Service and Service::Resource classes to generate both
+      # top-level and nested resources and methods.
+      module StubGenerator
+        def generate_call_stubs(service, root)
+          metaclass = (class << self; self; end)
+
+          # Handle resources.
+          root.discovered_resources.each do |resource|
+            method_name = ActiveSupport::Inflector.underscore(resource.name).to_sym
+            if !self.respond_to?(method_name)
+              metaclass.send(:define_method, method_name) do
+                Google::APIClient::Service::Resource.new(service, resource)
+              end
+            end
+          end
+
+          # Handle methods.
+          root.discovered_methods.each do |method|
+            method_name = ActiveSupport::Inflector.underscore(method.name).to_sym
+            if !self.respond_to?(method_name)
+              metaclass.send(:define_method, method_name) do |*args|
+                if args.length > 1
+                  raise ArgumentError,
+                    "wrong number of arguments (#{args.length} for 1)"
+                elsif !args.first.respond_to?(:to_hash) && !args.first.nil?
+                  raise ArgumentError,
+                    "expected parameter Hash, got #{args.first.class}"
+                else
+                  return Google::APIClient::Service::Request.new(
+                    service, method, args.first
+                  )
+                end
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/service_account.rb b/sdk/ruby-google-api-client/lib/google/api_client/service_account.rb
new file mode 100644 (file)
index 0000000..3d941ae
--- /dev/null
@@ -0,0 +1,21 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'google/api_client/auth/pkcs12'
+require 'google/api_client/auth/jwt_asserter'
+require 'google/api_client/auth/key_utils'
+require 'google/api_client/auth/compute_service_account'
+require 'google/api_client/auth/storage'
+require 'google/api_client/auth/storages/redis_store'
+require 'google/api_client/auth/storages/file_store'
diff --git a/sdk/ruby-google-api-client/lib/google/api_client/version.rb b/sdk/ruby-google-api-client/lib/google/api_client/version.rb
new file mode 100644 (file)
index 0000000..3f78e4a
--- /dev/null
@@ -0,0 +1,26 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+module Google
+  class APIClient
+    module VERSION
+      MAJOR = 0
+      MINOR = 8
+      TINY  = 7
+      PATCH = 6
+      STRING = [MAJOR, MINOR, TINY, PATCH].compact.join('.')
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/rakelib/gem.rake b/sdk/ruby-google-api-client/rakelib/gem.rake
new file mode 100644 (file)
index 0000000..71edc7f
--- /dev/null
@@ -0,0 +1,34 @@
+require "rubygems/package_task"
+
+namespace :gem do
+
+  desc "Build the gem"
+  task :build do
+    system "gem build signet.gemspec"
+  end
+
+  desc "Install the gem"
+  task :install => ["clobber", "gem:package"] do
+    sh "#{SUDO} gem install --local pkg/#{GEM_SPEC.full_name}"
+  end
+
+  desc "Uninstall the gem"
+  task :uninstall do
+    installed_list = Gem.source_index.find_name(PKG_NAME)
+    if installed_list &&
+        (installed_list.collect { |s| s.version.to_s}.include?(PKG_VERSION))
+      sh(
+        "#{SUDO} gem uninstall --version '#{PKG_VERSION}' " +
+        "--ignore-dependencies --executables #{PKG_NAME}"
+      )
+    end
+  end
+
+  desc "Reinstall the gem"
+  task :reinstall => [:uninstall, :install]
+end
+
+desc "Alias to gem:package"
+task "gem" => "gem:package"
+
+task "clobber" => ["gem:clobber_package"]
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/rakelib/git.rake b/sdk/ruby-google-api-client/rakelib/git.rake
new file mode 100644 (file)
index 0000000..ac3f1c2
--- /dev/null
@@ -0,0 +1,45 @@
+namespace :git do
+  namespace :tag do
+    desc 'List tags from the Git repository'
+    task :list do
+      tags = `git tag -l`
+      tags.gsub!("\r", '')
+      tags = tags.split("\n").sort {|a, b| b <=> a }
+      puts tags.join("\n")
+    end
+
+    desc 'Create a new tag in the Git repository'
+    task :create do
+      changelog = File.open('CHANGELOG.md', 'r') { |file| file.read }
+      puts '-' * 80
+      puts changelog
+      puts '-' * 80
+      puts
+
+      v = ENV['VERSION'] or abort 'Must supply VERSION=x.y.z'
+      abort "Versions don't match #{v} vs #{PKG_VERSION}" if v != PKG_VERSION
+
+      git_status = `git status`
+      if git_status !~ /nothing to commit \(working directory clean\)/
+        abort "Working directory isn't clean."
+      end
+
+      tag = "#{PKG_NAME}-#{PKG_VERSION}"
+      msg = "Release #{PKG_NAME}-#{PKG_VERSION}"
+
+      existing_tags = `git tag -l #{PKG_NAME}-*`.split('\n')
+      if existing_tags.include?(tag)
+        warn('Tag already exists, deleting...')
+        unless system "git tag -d #{tag}"
+          abort 'Tag deletion failed.'
+        end
+      end
+      puts "Creating git tag '#{tag}'..."
+      unless system "git tag -a -m \"#{msg}\" #{tag}"
+        abort 'Tag creation failed.'
+      end
+    end
+  end
+end
+
+task 'gem:release' => 'git:tag:create'
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/rakelib/metrics.rake b/sdk/ruby-google-api-client/rakelib/metrics.rake
new file mode 100644 (file)
index 0000000..67cb4eb
--- /dev/null
@@ -0,0 +1,22 @@
+namespace :metrics do
+  task :lines do
+    lines, codelines, total_lines, total_codelines = 0, 0, 0, 0
+    for file_name in FileList['lib/**/*.rb']
+      f = File.open(file_name)
+      while line = f.gets
+        lines += 1
+        next if line =~ /^\s*$/
+        next if line =~ /^\s*#/
+        codelines += 1
+      end
+      puts "L: #{sprintf('%4d', lines)}, " +
+        "LOC #{sprintf('%4d', codelines)} | #{file_name}"
+      total_lines     += lines
+      total_codelines += codelines
+
+      lines, codelines = 0, 0
+    end
+
+    puts "Total: Lines #{total_lines}, LOC #{total_codelines}"
+  end
+end
diff --git a/sdk/ruby-google-api-client/rakelib/spec.rake b/sdk/ruby-google-api-client/rakelib/spec.rake
new file mode 100644 (file)
index 0000000..102e9a9
--- /dev/null
@@ -0,0 +1,22 @@
+require 'rake/clean'
+require 'rspec/core/rake_task'
+
+CLOBBER.include('coverage', 'specdoc')
+
+namespace :spec do
+  RSpec::Core::RakeTask.new(:all) do |t|
+    t.pattern = FileList['spec/**/*_spec.rb']
+    t.rspec_opts = ['--color', '--format', 'documentation']
+  end
+
+  desc 'Generate HTML Specdocs for all specs.'
+  RSpec::Core::RakeTask.new(:specdoc) do |t|
+    specdoc_path = File.expand_path('../../specdoc', __FILE__)
+
+    t.rspec_opts = %W( --format html --out #{File.join(specdoc_path, 'index.html')} )
+    t.fail_on_error = false
+  end
+end
+
+desc 'Alias to spec:all'
+task 'spec' => 'spec:all'
diff --git a/sdk/ruby-google-api-client/rakelib/wiki.rake b/sdk/ruby-google-api-client/rakelib/wiki.rake
new file mode 100644 (file)
index 0000000..3e0d97d
--- /dev/null
@@ -0,0 +1,82 @@
+require 'rake'
+require 'rake/clean'
+
+CLOBBER.include('wiki')
+
+CACHE_PREFIX =
+  "http://www.gmodules.com/gadgets/proxy/container=default&debug=0&nocache=0/"
+
+namespace :wiki do
+  desc 'Autogenerate wiki pages'
+  task :supported_apis do
+    output = <<-WIKI
+#summary The list of supported APIs
+
+The Google API Client for Ruby is a small flexible client library for accessing
+the following Google APIs.
+
+WIKI
+    preferred_apis = {}
+    require 'google/api_client'
+    client = Google::APIClient.new
+    for api in client.discovered_apis
+      if !preferred_apis.has_key?(api.name)
+        preferred_apis[api.name] = api
+      elsif api.preferred
+        preferred_apis[api.name] = api
+      end
+    end
+    for api_name, api in preferred_apis
+      if api.documentation.to_s != "" && api.title != ""
+        output += (
+          "||#{CACHE_PREFIX}#{api['icons']['x16']}||" +
+          "[#{api.documentation} #{api.title}]||" +
+          "#{api.description}||\n"
+        )
+      end
+    end
+    output.gsub!(/-32\./, "-16.")
+    wiki_path = File.expand_path(
+      File.join(File.dirname(__FILE__), '../wiki/'))
+    Dir.mkdir(wiki_path) unless File.exists?(wiki_path)
+    File.open(File.join(wiki_path, 'SupportedAPIs.wiki'), 'w') do |file|
+      file.write(output)
+    end
+  end
+
+  task 'generate' => ['wiki:supported_apis']
+end
+
+begin
+  $LOAD_PATH.unshift(
+    File.expand_path(File.join(File.dirname(__FILE__), '../yard/lib'))
+  )
+  $LOAD_PATH.unshift(File.expand_path('.'))
+  $LOAD_PATH.uniq!
+
+  require 'yard'
+  require 'yard/rake/wikidoc_task'
+
+  namespace :wiki do
+    desc 'Generate Wiki Documentation with YARD'
+    YARD::Rake::WikidocTask.new do |yardoc|
+      yardoc.name = 'reference'
+      yardoc.options = [
+        '--verbose',
+        '--markup', 'markdown',
+        '-e', 'yard/lib/yard-google-code.rb',
+        '-p', 'yard/templates',
+        '-f', 'wiki',
+        '-o', 'wiki'
+      ]
+      yardoc.files = [
+        'lib/**/*.rb', 'ext/**/*.c', '-', 'README.md', 'CHANGELOG.md'
+      ]
+    end
+
+    task 'generate' => ['wiki:reference', 'wiki:supported_apis']
+  end
+rescue LoadError
+  # If yard isn't available, it's not the end of the world
+  warn('YARD unavailable. Cannot fully generate wiki.')
+end
diff --git a/sdk/ruby-google-api-client/rakelib/yard.rake b/sdk/ruby-google-api-client/rakelib/yard.rake
new file mode 100644 (file)
index 0000000..be0ff65
--- /dev/null
@@ -0,0 +1,29 @@
+require 'rake'
+require 'rake/clean'
+
+CLOBBER.include('doc', '.yardoc')
+CLOBBER.uniq!
+
+begin
+  require 'yard'
+  require 'yard/rake/yardoc_task'
+
+  namespace :doc do
+    desc 'Generate Yardoc documentation'
+    YARD::Rake::YardocTask.new do |yardoc|
+      yardoc.name = 'yard'
+      yardoc.options = ['--verbose', '--markup', 'markdown']
+      yardoc.files = [
+        'lib/**/*.rb', 'ext/**/*.c', '-',
+        'README.md', 'CONTRIB.md', 'CHANGELOG.md', 'LICENSE'
+      ]
+    end
+  end
+
+  desc 'Alias to doc:yard'
+  task 'doc' => 'doc:yard'
+rescue LoadError
+  # If yard isn't available, it's not the end of the world
+  desc 'Alias to doc:rdoc'
+  task 'doc' => 'doc:rdoc'
+end
diff --git a/sdk/ruby-google-api-client/script/package b/sdk/ruby-google-api-client/script/package
new file mode 100755 (executable)
index 0000000..3f59b50
--- /dev/null
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Usage: script/gem
+# Updates the gemspec and builds a new gem in the pkg directory.
+
+mkdir -p pkg
+gem build *.gemspec
+mv *.gem pkg
+
diff --git a/sdk/ruby-google-api-client/script/release b/sdk/ruby-google-api-client/script/release
new file mode 100755 (executable)
index 0000000..1a26a42
--- /dev/null
@@ -0,0 +1,14 @@
+age: script/release
+# Build the package, tag a commit, push it to origin, and then release the
+# package publicly.
+
+set -e
+
+version="$(script/package | grep Version: | awk '{print $2}')"
+[ -n "$version" ] || exit 1
+
+git commit --allow-empty -a -m "Release $version"
+git tag "$version"
+git push --tags origin
+gem push pkg/*-${version}.gem
+
diff --git a/sdk/ruby-google-api-client/spec/fixtures/files/auth_stored_credentials.json b/sdk/ruby-google-api-client/spec/fixtures/files/auth_stored_credentials.json
new file mode 100644 (file)
index 0000000..4cd786e
--- /dev/null
@@ -0,0 +1,8 @@
+{   "access_token":"access_token_123456789",
+    "authorization_uri":"https://accounts.google.com/o/oauth2/auth",
+    "client_id":"123456789p.apps.googleusercontent.com",
+    "client_secret":"very_secret",
+    "expires_in":3600,
+    "refresh_token":"refresh_token_12345679",
+    "token_credential_uri":"https://accounts.google.com/o/oauth2/token",
+    "issued_at":1386053761}
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/spec/fixtures/files/client_secrets.json b/sdk/ruby-google-api-client/spec/fixtures/files/client_secrets.json
new file mode 100644 (file)
index 0000000..05fa7cb
--- /dev/null
@@ -0,0 +1 @@
+{"installed":{"auth_uri":"https://accounts.google.com/o/oauth2/auth","client_secret":"i8YaXdGgiQ4_KrTVNGsB7QP1","token_uri":"https://accounts.google.com/o/oauth2/token","client_email":"","client_x509_cert_url":"","client_id":"898243283568.apps.googleusercontent.com","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs"}}
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/spec/fixtures/files/privatekey.p12 b/sdk/ruby-google-api-client/spec/fixtures/files/privatekey.p12
new file mode 100644 (file)
index 0000000..1e737a9
Binary files /dev/null and b/sdk/ruby-google-api-client/spec/fixtures/files/privatekey.p12 differ
diff --git a/sdk/ruby-google-api-client/spec/fixtures/files/sample.txt b/sdk/ruby-google-api-client/spec/fixtures/files/sample.txt
new file mode 100644 (file)
index 0000000..fe9a30d
--- /dev/null
@@ -0,0 +1,33 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus posuere urna bibendum diam vulputate fringilla. Fusce elementum fermentum justo id aliquam. Integer vel felis ut arcu elementum lacinia. Duis congue urna eget nisl dapibus tristique molestie turpis sollicitudin. Vivamus in justo quam. Proin condimentum mollis tortor at molestie. Cras luctus, nunc a convallis iaculis, est risus consequat nisi, sit amet sollicitudin metus mi a urna. Aliquam accumsan, massa quis condimentum varius, sapien massa faucibus nibh, a dignissim magna nibh a lacus. Nunc aliquet, nunc ac pulvinar consectetur, sapien lacus hendrerit enim, nec dapibus lorem mi eget risus. Praesent vitae justo eget dolor blandit ullamcorper. Duis id nibh vitae sem aliquam vehicula et ac massa. In neque elit, molestie pulvinar viverra at, vestibulum quis velit.
+
+Mauris sit amet placerat enim. Duis vel tellus ac dui auctor tincidunt id nec augue. Donec ut blandit turpis. Mauris dictum urna id urna vestibulum accumsan. Maecenas sagittis urna vitae erat facilisis gravida. Phasellus tellus augue, commodo ut iaculis vitae, interdum ut dolor. Proin at dictum lorem. Quisque pellentesque neque ante, vitae rutrum elit. Pellentesque sit amet erat orci. Praesent justo diam, tristique eu tempus ut, vestibulum eget dui. Maecenas et elementum justo. Cras a augue a elit porttitor placerat eget ut magna.
+
+Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nam adipiscing tellus in arcu bibendum volutpat. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed laoreet faucibus tristique. Duis metus eros, molestie eget dignissim in, imperdiet fermentum nulla. Vestibulum laoreet lorem eu justo vestibulum lobortis. Praesent pharetra leo vel mauris rhoncus commodo sollicitudin ante auctor. Ut sagittis, tortor nec placerat rutrum, neque ipsum cursus nisl, ut lacinia magna risus ac risus. Sed volutpat commodo orci, sodales fermentum dui accumsan eu. Donec egestas ullamcorper elit at condimentum. In euismod sodales posuere. Nullam lacinia tempus molestie. Etiam vitae ullamcorper dui. Fusce congue suscipit arcu, at consectetur diam gravida id. Quisque augue urna, commodo eleifend volutpat vitae, tincidunt ac ligula. Curabitur eget orci nisl, vel placerat ipsum.
+
+Curabitur rutrum euismod nisi, consectetur varius tortor condimentum non. Pellentesque rhoncus nisi eu purus ultricies suscipit. Morbi ante nisi, varius nec molestie bibendum, pharetra quis enim. Proin eget nunc ante. Cras aliquam enim vel nunc laoreet ut facilisis nunc interdum. Fusce libero ipsum, posuere eget blandit quis, bibendum vitae quam. Integer dictum faucibus lacus eget facilisis. Duis adipiscing tortor magna, vel tincidunt risus. In non augue eu nisl sodales cursus vel eget nisi. Maecenas dignissim lectus elementum eros fermentum gravida et eget leo. Aenean quis cursus arcu. Mauris posuere purus non diam mattis vehicula. Integer nec orci velit.
+
+Integer ac justo ac magna adipiscing condimentum vitae tincidunt dui. Morbi augue arcu, blandit nec interdum sit amet, condimentum vel nisl. Nulla vehicula tincidunt laoreet. Aliquam ornare elementum urna, sed vehicula magna porta id. Vestibulum dictum ultrices tortor sit amet tincidunt. Praesent bibendum, metus vel volutpat interdum, nisl nunc cursus libero, vel congue ligula mi et felis. Nulla mollis elementum nulla, in accumsan risus consequat at. Suspendisse potenti. Vestibulum enim lorem, dignissim ut porta vestibulum, porta eget mi. Fusce a elit ac dui sodales gravida. Pellentesque sed elit at dui dapibus mattis a non arcu.
+
+Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In nec posuere augue. Praesent non suscipit arcu. Sed nibh risus, lacinia ut molestie vitae, tristique eget turpis. Sed pretium volutpat arcu, non rutrum leo volutpat sed. Maecenas quis neque nisl, sit amet ornare dolor. Nulla pharetra pulvinar tellus sed eleifend. Aliquam eget mattis nulla. Nulla dictum vehicula velit, non facilisis lorem volutpat id. Fusce scelerisque sem vitae purus dapibus lobortis. Mauris ac turpis nec nibh consequat porttitor. Ut sit amet iaculis lorem. Vivamus blandit erat ac odio venenatis fringilla a sit amet ante. Quisque ut urna sed augue laoreet sagittis.
+
+Integer nisl urna, bibendum id lobortis in, tempor non velit. Fusce sed volutpat quam. Suspendisse eu placerat purus. Maecenas quis feugiat lectus. Sed accumsan malesuada dui, a pretium purus facilisis quis. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nunc ac purus id lacus malesuada placerat et in nunc. Ut imperdiet tincidunt est, at consectetur augue egestas hendrerit. Pellentesque eu erat a dui dignissim adipiscing. Integer quis leo non felis placerat eleifend. Fusce luctus mi a lorem mattis eget accumsan libero posuere. Sed pellentesque, odio id pharetra tempus, enim quam placerat metus, auctor aliquam elit mi facilisis quam. Nam at velit et eros rhoncus accumsan.
+
+Donec tellus diam, fringilla ac viverra fringilla, rhoncus sit amet purus. Cras et ligula sed nibh tempor gravida. Aliquam id tempus mauris. Ut convallis quam sed arcu varius eget mattis magna tincidunt. Aliquam et suscipit est. Sed metus augue, tristique sed accumsan eget, euismod et augue. Nam augue sapien, placerat vel facilisis eu, tempor id risus. Aliquam mollis egestas mi. Fusce scelerisque convallis mauris quis blandit. Mauris nec ante id lacus sagittis tincidunt ornare vehicula dui. Curabitur tristique mattis nunc, vel cursus libero viverra feugiat. Suspendisse at sapien velit, a lacinia dolor. Vivamus in est non odio feugiat lacinia sodales ut magna.
+
+Donec interdum ligula id ipsum dapibus consectetur. Pellentesque vitae posuere ligula. Morbi rhoncus bibendum eleifend. Suspendisse fringilla nunc at elit malesuada vitae ullamcorper lorem laoreet. Suspendisse a ante at ipsum iaculis cursus. Duis accumsan ligula quis nibh luctus pretium. Duis ultrices scelerisque dolor, et vulputate lectus commodo ut.
+
+Vestibulum ac tincidunt lorem. Vestibulum lorem massa, dictum a scelerisque ut, convallis vitae eros. Morbi ipsum nisl, lacinia non tempor nec, lobortis id diam. Fusce quis magna nunc. Proin ultricies congue justo sed mattis. Vestibulum sit amet arcu tellus. Quisque ultricies porta massa iaculis vehicula. Vestibulum sollicitudin tempor urna vel sodales. Pellentesque ultricies tellus vel metus porta nec iaculis sapien mollis. Maecenas ullamcorper, metus eget imperdiet sagittis, odio orci dapibus neque, in vulputate nunc nibh non libero. Donec velit quam, lobortis quis tempus a, hendrerit id arcu.
+
+Donec nec ante at tortor dignissim mattis. Curabitur vehicula tincidunt magna id sagittis. Proin euismod dignissim porta. Curabitur non turpis purus, in rutrum nulla. Nam turpis nulla, tincidunt et hendrerit non, posuere nec enim. Curabitur leo enim, lobortis ut placerat id, condimentum nec massa. In bibendum, lectus sit amet molestie commodo, felis massa rutrum nisl, ac fermentum ligula lacus in ipsum.
+
+Pellentesque mi nulla, scelerisque vitae tempus id, consequat a augue. Quisque vel nisi sit amet ipsum faucibus laoreet sed vitae lorem. Praesent nunc tortor, volutpat ac commodo non, pharetra sed neque. Curabitur nec felis at mi blandit aliquet eu ornare justo. Mauris dignissim purus quis nisl porttitor interdum. Aenean id ipsum enim, blandit commodo justo. Quisque facilisis elit quis velit commodo scelerisque lobortis sapien condimentum. Cras sit amet porttitor velit. Praesent nec tempor arcu.
+
+Donec varius mi adipiscing elit semper vel feugiat ipsum dictum. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non quam nisl, ac mattis justo. Vestibulum sed massa eget velit tristique auctor ut ac sapien. Curabitur aliquet ligula eget dui ornare at scelerisque mauris faucibus. Vestibulum id mauris metus, sed vestibulum nibh. Nulla egestas dictum blandit. Mauris vitae nibh at dui mollis lobortis. Phasellus sem leo, euismod at fringilla quis, mollis in nibh. Aenean vel lacus et elit pharetra elementum. Aliquam at ligula id sem bibendum volutpat. Pellentesque quis elit a massa dapibus viverra ut et lorem. Donec nulla eros, iaculis nec commodo vel, suscipit sit amet tortor. Integer tempor, elit at viverra imperdiet, velit sapien laoreet nunc, id laoreet ligula risus vel risus. Nullam sed tortor metus.
+
+In nunc orci, tempor vulputate pretium vel, suscipit quis risus. Suspendisse accumsan facilisis felis eget posuere. Donec a faucibus felis. Proin nibh erat, sollicitudin quis vestibulum id, tincidunt quis justo. In sed purus eu nisi dignissim condimentum. Sed mattis dapibus lorem id vulputate. Suspendisse nec elit a augue interdum consequat quis id magna. In eleifend aliquam tempor. In in lacus augue.
+
+Ut euismod sollicitudin lorem, id aliquam magna dictum sed. Nunc fringilla lobortis nisi sed consectetur. Nulla facilisi. Aenean nec lobortis augue. Curabitur ullamcorper dapibus libero, vel pellentesque arcu sollicitudin non. Praesent varius, turpis nec sollicitudin bibendum, elit tortor rhoncus lacus, gravida luctus leo nisi in felis. Ut metus eros, molestie non faucibus vel, condimentum ac elit.
+
+Suspendisse nisl justo, lacinia sit amet interdum nec, tincidunt placerat urna. Suspendisse potenti. In et odio sed purus malesuada cursus sed nec lectus. Cras commodo, orci sit amet hendrerit iaculis, nunc urna facilisis tellus, vel laoreet odio nulla quis nibh. Maecenas ut justo ut lacus posuere sodales. Vestibulum facilisis fringilla diam at volutpat. Proin a hendrerit urna. Aenean placerat pulvinar arcu, sit amet lobortis neque eleifend in. Aenean risus nulla, facilisis ut tincidunt vitae, fringilla at ligula. Praesent eleifend est at sem lacinia auctor. Nulla ornare nunc in erat laoreet blandit.
+
+Suspendisse pharetra leo ac est porta consequat. Nunc sem nibh, gravida vel aliquam a, ornare in tortor. Nulla vel sapien et felis placerat pellentesque id scelerisque nisl. Praesent et posuere.
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/spec/fixtures/files/secret.pem b/sdk/ruby-google-api-client/spec/fixtures/files/secret.pem
new file mode 100644 (file)
index 0000000..28b8d12
--- /dev/null
@@ -0,0 +1,19 @@
+Bag Attributes
+    friendlyName: privatekey
+    localKeyID: 54 69 6D 65 20 31 33 35 31 38 38 38 31 37 38 36 39 36 
+Key Attributes: <No Attributes>
+-----BEGIN RSA PRIVATE KEY-----
+MIICXAIBAAKBgQDYDyPb3GhyFx5i/wxS/jFsO6wSLys1ehAk6QZoBXGlg7ETVrIJ
+HYh9gXQUno4tJiQoaO8wOvleIRrqI0LkiftCXKWVSrzOiV+O9GkKx1byw1yAIZus
+QdwMT7X0O9hrZLZwhICWC9s6cGhnlCVxLIP/+JkVK7hxEq/LxoSszNV77wIDAQAB
+AoGAa2G69L7quil7VMBmI6lqbtyJfNAsrXtpIq8eG/z4qsZ076ObAKTI/XeldcoH
+57CZL+xXVKU64umZMt0rleJuGXdlauEUbsSx+biGewRfGTgC4rUSjmE539rBvmRW
+gaKliorepPMp/+B9CcG/2YfDPRvG/2cgTXJHVvneo+xHL4ECQQD2Jx5Mvs8z7s2E
+jY1mkpRKqh4Z7rlitkAwe1NXcVC8hz5ASu7ORyTl8EPpKAfRMYl1ofK/ozT1URXf
+kL5nChPfAkEA4LPUJ6cqrY4xrrtdGaM4iGIxzen5aZlKz/YNlq5LuQKbnLLHMuXU
+ohp/ynpqNWbcAFbmtGSMayxGKW5+fJgZ8QJAUBOZv82zCmn9YcnK3juBEmkVMcp/
+dKVlbGAyVJgAc9RrY+78kQ6D6mmnLgpfwKYk2ae9mKo3aDbgrsIfrtWQcQJAfFGi
+CEpJp3orbLQG319ZsMM7MOTJdC42oPZOMFbAWFzkAX88DKHx0bn9h+XQizkccSej
+Ppz+v3DgZJ3YZ1Cz0QJBALiqIokZ+oa3AY6oT0aiec6txrGvNPPbwOsrBpFqGNbu
+AByzWWBoBi40eKMSIR30LqN9H8YnJ91Aoy1njGYyQaw=
+-----END RSA PRIVATE KEY-----
diff --git a/sdk/ruby-google-api-client/spec/fixtures/files/zoo.json b/sdk/ruby-google-api-client/spec/fixtures/files/zoo.json
new file mode 100644 (file)
index 0000000..4abd957
--- /dev/null
@@ -0,0 +1,584 @@
+{
+ "kind": "discovery#describeItem",
+ "name": "zoo",
+ "version": "v1",
+ "description": "Zoo API used for testing",
+ "basePath": "/zoo/",
+ "rootUrl": "https://www.googleapis.com/",
+ "servicePath": "zoo/v1/",
+ "rpcPath": "/rpc",
+ "parameters": {
+  "alt": {
+   "type": "string",
+   "description": "Data format for the response.",
+   "default": "json",
+   "enum": [
+    "json"
+   ],
+   "enumDescriptions": [
+    "Responses with Content-Type of application/json"
+   ],
+   "location": "query"
+  },
+  "fields": {
+   "type": "string",
+   "description": "Selector specifying which fields to include in a partial response.",
+   "location": "query"
+  },
+  "key": {
+   "type": "string",
+   "description": "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.",
+   "location": "query"
+  },
+  "oauth_token": {
+   "type": "string",
+   "description": "OAuth 2.0 token for the current user.",
+   "location": "query"
+  },
+  "prettyPrint": {
+   "type": "boolean",
+   "description": "Returns response with indentations and line breaks.",
+   "default": "true",
+   "location": "query"
+  },
+  "quotaUser": {
+   "type": "string",
+   "description": "Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters. Overrides userIp if both are provided.",
+   "location": "query"
+  },
+  "userIp": {
+   "type": "string",
+   "description": "IP address of the site where the request originates. Use this if you want to enforce per-user limits.",
+   "location": "query"
+  }
+ },
+ "features": [
+  "dataWrapper"
+ ],
+ "schemas": {
+  "Animal": {
+   "id": "Animal",
+   "type": "object",
+   "properties": {
+    "etag": {
+     "type": "string"
+    },
+    "kind": {
+     "type": "string",
+     "default": "zoo#animal"
+    },
+    "name": {
+     "type": "string"
+    },
+    "photo": {
+     "type": "object",
+     "properties": {
+      "filename": {
+       "type": "string"
+      },
+      "hash": {
+       "type": "string"
+      },
+      "hashAlgorithm": {
+       "type": "string"
+      },
+      "size": {
+       "type": "integer"
+      },
+      "type": {
+       "type": "string"
+      }
+     }
+    }
+   }
+  },
+  "Animal2": {
+   "id": "Animal2",
+   "type": "object",
+   "properties": {
+    "kind": {
+     "type": "string",
+     "default": "zoo#animal"
+    },
+    "name": {
+     "type": "string"
+    }
+   }
+  },
+  "AnimalFeed": {
+   "id": "AnimalFeed",
+   "type": "object",
+   "properties": {
+    "etag": {
+     "type": "string"
+    },
+    "items": {
+     "type": "array",
+     "items": {
+      "$ref": "Animal"
+     }
+    },
+    "kind": {
+     "type": "string",
+     "default": "zoo#animalFeed"
+    }
+   }
+  },
+  "AnimalMap": {
+   "id": "AnimalMap",
+   "type": "object",
+   "properties": {
+    "etag": {
+     "type": "string"
+    },
+    "animals": {
+     "type": "object",
+     "description": "Map of animal id to animal data",
+     "additionalProperties": {
+      "$ref": "Animal"
+     }
+    },
+    "kind": {
+     "type": "string",
+     "default": "zoo#animalMap"
+    }
+   }
+  },
+  "LoadFeed": {
+   "id": "LoadFeed",
+   "type": "object",
+   "properties": {
+    "items": {
+     "type": "array",
+     "items": {
+      "type": "object",
+      "properties": {
+       "doubleVal": {
+        "type": "number"
+       },
+       "nullVal": {
+        "type": "null"
+       },
+       "booleanVal": {
+        "type": "boolean",
+        "description": "True or False."
+       },
+       "anyVal": {
+        "type": "any",
+        "description": "Anything will do."
+       },
+       "enumVal": {
+        "type": "string"
+       },
+       "kind": {
+        "type": "string",
+        "default": "zoo#loadValue"
+       },
+       "longVal": {
+        "type": "integer"
+       },
+       "stringVal": {
+        "type": "string"
+       }
+      }
+     }
+    },
+    "kind": {
+     "type": "string",
+     "default": "zoo#loadFeed"
+    }
+   }
+  }
+ },
+ "methods": {
+  "query": {
+   "path": "query",
+   "id": "bigquery.query",
+   "httpMethod": "GET",
+   "parameters": {
+    "q": {
+     "type": "string",
+     "location": "query",
+     "required": false,
+     "repeated": false
+    },
+    "i": {
+     "type": "integer",
+     "location": "query",
+     "required": false,
+     "repeated": false,
+     "minimum": "0",
+     "maximum": "4294967295",
+     "default": "20"
+    },
+    "n": {
+     "type": "number",
+     "location": "query",
+     "required": false,
+     "repeated": false
+    },
+    "b": {
+     "type": "boolean",
+     "location": "query",
+     "required": false,
+     "repeated": false
+    },
+    "a": {
+     "type": "any",
+     "location": "query",
+     "required": false,
+     "repeated": false
+    },
+    "o": {
+     "type": "object",
+     "location": "query",
+     "required": false,
+     "repeated": false
+    },
+    "e": {
+     "type": "string",
+     "location": "query",
+     "required": false,
+     "repeated": false,
+     "enum": [
+       "foo",
+       "bar"
+     ]
+    },
+    "er": {
+      "type": "string",
+      "location": "query",
+      "required": false,
+      "repeated": true,
+      "enum": [
+        "one",
+        "two",
+        "three"
+      ]
+    },
+    "rr": {
+     "type": "string",
+     "location": "query",
+     "required": false,
+     "repeated": true,
+     "pattern": "[a-z]+"
+    }
+   }
+  }
+ },
+ "resources": {
+  "my": {
+   "resources": {
+    "favorites": {
+     "methods": {
+      "list": {
+       "path": "favorites/@me/mine",
+       "id": "zoo.animals.mine",
+       "httpMethod": "GET",
+       "parameters": {
+        "max-results": {
+          "location": "query",
+          "required": false
+        }
+       }
+      }
+     }
+    }
+   }
+  },
+  "global": {
+   "resources": {
+    "print": {
+     "methods": {
+      "assert": {
+       "path": "global/print/assert",
+       "id": "zoo.animals.mine",
+       "httpMethod": "GET",
+       "parameters": {
+        "max-results": {
+          "location": "query",
+          "required": false
+        }
+       }
+      }
+     }
+    }
+   }
+  },
+  "animals": {
+   "methods": {
+    "crossbreed": {
+     "path": "animals/crossbreed",
+     "id": "zoo.animals.crossbreed",
+     "httpMethod": "POST",
+     "description": "Cross-breed animals",
+     "response": {
+      "$ref": "Animal2"
+     },
+     "mediaUpload": {
+      "accept": [
+       "image/png"
+      ],
+      "protocols": {
+       "simple": {
+        "multipart": true,
+        "path": "upload/activities/{userId}/@self"
+       },
+       "resumable": {
+        "multipart": true,
+        "path": "upload/activities/{userId}/@self"
+       }
+      }
+     }
+    },
+    "delete": {
+     "path": "animals/{name}",
+     "id": "zoo.animals.delete",
+     "httpMethod": "DELETE",
+     "description": "Delete animals",
+     "parameters": {
+      "name": {
+       "location": "path",
+       "required": true,
+       "description": "Name of the animal to delete",
+       "type": "string"
+      }
+     },
+     "parameterOrder": [
+      "name"
+     ]
+    },
+    "get": {
+     "path": "animals/{name}",
+     "id": "zoo.animals.get",
+     "httpMethod": "GET",
+     "description": "Get animals",
+     "supportsMediaDownload": true,
+     "parameters": {
+      "name": {
+       "location": "path",
+       "required": true,
+       "description": "Name of the animal to load",
+       "type": "string"
+      },
+      "projection": {
+       "location": "query",
+       "type": "string",
+       "enum": [
+        "full"
+       ],
+       "enumDescriptions": [
+        "Include everything"
+       ]
+      }
+     },
+     "parameterOrder": [
+      "name"
+     ],
+     "response": {
+      "$ref": "Animal"
+     }
+    },
+    "getmedia": {
+     "path": "animals/{name}",
+     "id": "zoo.animals.get",
+     "httpMethod": "GET",
+     "description": "Get animals",
+     "parameters": {
+      "name": {
+       "location": "path",
+       "required": true,
+       "description": "Name of the animal to load",
+       "type": "string"
+      },
+      "projection": {
+       "location": "query",
+       "type": "string",
+       "enum": [
+        "full"
+       ],
+       "enumDescriptions": [
+        "Include everything"
+       ]
+      }
+     },
+     "parameterOrder": [
+      "name"
+     ]
+    },
+    "insert": {
+     "path": "animals",
+     "id": "zoo.animals.insert",
+     "httpMethod": "POST",
+     "description": "Insert animals",
+     "request": {
+      "$ref": "Animal"
+     },
+     "response": {
+      "$ref": "Animal"
+     },
+     "mediaUpload": {
+      "accept": [
+       "image/png"
+      ],
+      "maxSize": "1KB",
+      "protocols": {
+       "simple": {
+        "multipart": true,
+        "path": "upload/activities/{userId}/@self"
+       },
+       "resumable": {
+        "multipart": true,
+        "path": "upload/activities/{userId}/@self"
+       }
+      }
+     }
+    },
+    "list": {
+     "path": "animals",
+     "id": "zoo.animals.list",
+     "httpMethod": "GET",
+     "description": "List animals",
+     "parameters": {
+      "max-results": {
+       "location": "query",
+       "description": "Maximum number of results to return",
+       "type": "integer",
+       "minimum": "0"
+      },
+      "name": {
+       "location": "query",
+       "description": "Restrict result to animals with this name",
+       "type": "string"
+      },
+      "projection": {
+       "location": "query",
+       "type": "string",
+       "enum": [
+        "full"
+       ],
+       "enumDescriptions": [
+        "Include absolutely everything"
+       ]
+      },
+      "start-token": {
+       "location": "query",
+       "description": "Pagination token",
+       "type": "string"
+      }
+     },
+     "response": {
+      "$ref": "AnimalFeed"
+     }
+    },
+    "patch": {
+     "path": "animals/{name}",
+     "id": "zoo.animals.patch",
+     "httpMethod": "PATCH",
+     "description": "Update animals",
+     "parameters": {
+      "name": {
+       "location": "path",
+       "required": true,
+       "description": "Name of the animal to update",
+       "type": "string"
+      }
+     },
+     "parameterOrder": [
+      "name"
+     ],
+     "request": {
+      "$ref": "Animal"
+     },
+     "response": {
+      "$ref": "Animal"
+     }
+    },
+    "update": {
+     "path": "animals/{name}",
+     "id": "zoo.animals.update",
+     "httpMethod": "PUT",
+     "description": "Update animals",
+     "parameters": {
+      "name": {
+       "location": "path",
+       "description": "Name of the animal to update",
+       "type": "string"
+      }
+     },
+     "parameterOrder": [
+      "name"
+     ],
+     "request": {
+      "$ref": "Animal"
+     },
+     "response": {
+      "$ref": "Animal"
+     }
+    }
+   }
+  },
+  "load": {
+   "methods": {
+    "list": {
+     "path": "load",
+     "id": "zoo.load.list",
+     "httpMethod": "GET",
+     "response": {
+      "$ref": "LoadFeed"
+     }
+    }
+   }
+  },
+  "loadNoTemplate": {
+   "methods": {
+    "list": {
+     "path": "loadNoTemplate",
+     "id": "zoo.loadNoTemplate.list",
+     "httpMethod": "GET"
+    }
+   }
+  },
+  "scopedAnimals": {
+   "methods": {
+    "list": {
+     "path": "scopedanimals",
+     "id": "zoo.scopedAnimals.list",
+     "httpMethod": "GET",
+     "description": "List animals (scoped)",
+     "parameters": {
+      "max-results": {
+       "location": "query",
+       "description": "Maximum number of results to return",
+       "type": "integer",
+       "minimum": "0"
+      },
+      "name": {
+       "location": "query",
+       "description": "Restrict result to animals with this name",
+       "type": "string"
+      },
+      "projection": {
+       "location": "query",
+       "type": "string",
+       "enum": [
+        "full"
+       ],
+       "enumDescriptions": [
+        "Include absolutely everything"
+       ]
+      },
+      "start-token": {
+       "location": "query",
+       "description": "Pagination token",
+       "type": "string"
+      }
+     },
+     "response": {
+      "$ref": "AnimalFeed"
+     }
+    }
+   }
+  }
+ }
+}
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/auth/storage_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/auth/storage_spec.rb
new file mode 100644 (file)
index 0000000..d8e5b96
--- /dev/null
@@ -0,0 +1,122 @@
+require 'spec_helper'
+
+require 'google/api_client'
+require 'google/api_client/version'
+
+describe Google::APIClient::Storage do
+  let(:client) { Google::APIClient.new(:application_name => 'API Client Tests') }
+  let(:root_path) { File.expand_path(File.join(__FILE__, '..', '..', '..')) }
+  let(:json_file) { File.expand_path(File.join(root_path, 'fixtures', 'files', 'auth_stored_credentials.json')) }
+
+  let(:store) { double }
+  let(:client_stub) { double }
+  subject { Google::APIClient::Storage.new(store) }
+
+  describe 'authorize' do
+    it 'should authorize' do
+      expect(subject).to respond_to(:authorization)
+      expect(subject.store).to be == store
+    end
+  end
+
+  describe 'authorize' do
+    describe 'with credentials' do
+
+      it 'should initialize a new OAuth Client' do
+        expect(subject).to receive(:load_credentials).and_return({:first => 'a dummy'})
+        expect(client_stub).to receive(:issued_at=)
+        expect(client_stub).to receive(:expired?).and_return(false)
+        expect(Signet::OAuth2::Client).to receive(:new).and_return(client_stub)
+        expect(subject).not_to receive(:refresh_authorization)
+        subject.authorize
+      end
+
+      it 'should refresh authorization' do
+        expect(subject).to receive(:load_credentials).and_return({:first => 'a dummy'})
+        expect(client_stub).to receive(:issued_at=)
+        expect(client_stub).to receive(:expired?).and_return(true)
+        expect(Signet::OAuth2::Client).to receive(:new).and_return(client_stub)
+        expect(subject).to receive(:refresh_authorization)
+        auth = subject.authorize
+        expect(auth).to be == subject.authorization
+        expect(auth).not_to be_nil
+      end
+    end
+
+    describe 'without credentials' do
+
+      it 'should return nil' do
+        expect(subject.authorization).to be_nil
+        expect(subject).to receive(:load_credentials).and_return({})
+        expect(subject.authorize).to be_nil
+        expect(subject.authorization).to be_nil
+      end
+    end
+  end
+
+  describe 'write_credentials' do
+    it 'should call store to write credentials' do
+      authorization_stub = double
+      expect(authorization_stub).to receive(:refresh_token).and_return(true)
+      expect(subject).to receive(:credentials_hash)
+      expect(subject.store).to receive(:write_credentials)
+      subject.write_credentials(authorization_stub)
+      expect(subject.authorization).to be == authorization_stub
+    end
+
+    it 'should not call store to write credentials' do
+      expect(subject).not_to receive(:credentials_hash)
+      expect(subject.store).not_to receive(:write_credentials)
+      expect {
+        subject.write_credentials()
+      }.not_to raise_error
+    end
+    it 'should not call store to write credentials' do
+      expect(subject).not_to receive(:credentials_hash)
+      expect(subject.store).not_to receive(:write_credentials)
+      expect {
+        subject.write_credentials('something')
+      }.not_to raise_error
+    end
+
+  end
+
+  describe 'refresh_authorization' do
+    it 'should call refresh and write credentials' do
+      expect(subject).to receive(:write_credentials)
+      authorization_stub = double
+      expect(subject).to receive(:authorization).and_return(authorization_stub)
+      expect(authorization_stub).to receive(:refresh!).and_return(true)
+      subject.refresh_authorization
+    end
+  end
+
+  describe 'load_credentials' do
+    it 'should call store to load credentials' do
+      expect(subject.store).to receive(:load_credentials)
+      subject.send(:load_credentials)
+    end
+  end
+
+  describe 'credentials_hash' do
+    it 'should return an hash' do
+      authorization_stub = double
+      expect(authorization_stub).to receive(:access_token)
+      expect(authorization_stub).to receive(:client_id)
+      expect(authorization_stub).to receive(:client_secret)
+      expect(authorization_stub).to receive(:expires_in)
+      expect(authorization_stub).to receive(:refresh_token)
+      expect(authorization_stub).to receive(:issued_at).and_return('100')
+      allow(subject).to receive(:authorization).and_return(authorization_stub)
+      credentials = subject.send(:credentials_hash)
+      expect(credentials).to include(:access_token)
+      expect(credentials).to include(:authorization_uri)
+      expect(credentials).to include(:client_id)
+      expect(credentials).to include(:client_secret)
+      expect(credentials).to include(:expires_in)
+      expect(credentials).to include(:refresh_token)
+      expect(credentials).to include(:token_credential_uri)
+      expect(credentials).to include(:issued_at)
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/auth/storages/file_store_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/auth/storages/file_store_spec.rb
new file mode 100644 (file)
index 0000000..2963b1d
--- /dev/null
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+require 'google/api_client'
+require 'google/api_client/version'
+
+describe Google::APIClient::FileStore do
+  let(:root_path) { File.expand_path(File.join(__FILE__, '..','..','..', '..','..')) }
+  let(:json_file) { File.expand_path(File.join(root_path, 'fixtures', 'files', 'auth_stored_credentials.json')) }
+
+  let(:credentials_hash) {{
+      "access_token"=>"my_access_token",
+      "authorization_uri"=>"https://accounts.google.com/o/oauth2/auth",
+      "client_id"=>"123456_test_client_id@.apps.googleusercontent.com",
+      "client_secret"=>"123456_client_secret",
+      "expires_in"=>3600,
+      "refresh_token"=>"my_refresh_token",
+      "token_credential_uri"=>"https://accounts.google.com/o/oauth2/token",
+      "issued_at"=>1384440275
+  }}
+
+  subject{Google::APIClient::FileStore.new('a file path')}
+
+  it 'should have a path' do
+    expect(subject.path).to be == 'a file path'
+    subject.path = 'an other file path'
+    expect(subject.path).to be == 'an other file path'
+  end
+
+  it 'should load credentials' do
+    subject.path = json_file
+    credentials = subject.load_credentials
+    expect(credentials).to include('access_token', 'authorization_uri', 'refresh_token')
+  end
+
+  it 'should write credentials' do
+    io_stub = StringIO.new
+    expect(subject).to receive(:open).and_return(io_stub)
+    subject.write_credentials(credentials_hash)
+  end
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/auth/storages/redis_store_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/auth/storages/redis_store_spec.rb
new file mode 100644 (file)
index 0000000..de5abc4
--- /dev/null
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+require 'google/api_client'
+require 'google/api_client/version'
+
+
+describe Google::APIClient::RedisStore do
+  let(:root_path) { File.expand_path(File.join(__FILE__, '..', '..', '..', '..', '..')) }
+  let(:json_file) { File.expand_path(File.join(root_path, 'fixtures', 'files', 'auth_stored_credentials.json')) }
+  let(:redis) {double}
+
+  let(:credentials_hash) { {
+      "access_token" => "my_access_token",
+      "authorization_uri" => "https://accounts.google.com/o/oauth2/auth",
+      "client_id" => "123456_test_client_id@.apps.googleusercontent.com",
+      "client_secret" => "123456_client_secret",
+      "expires_in" => 3600,
+      "refresh_token" => "my_refresh_token",
+      "token_credential_uri" => "https://accounts.google.com/o/oauth2/token",
+      "issued_at" => 1384440275
+  } }
+
+  subject { Google::APIClient::RedisStore.new('a redis instance') }
+
+  it 'should have a redis instance' do
+    expect(subject.redis).to be == 'a redis instance'
+    subject.redis = 'an other redis instance'
+    expect(subject.redis).to be == 'an other redis instance'
+  end
+
+  describe 'load_credentials' do
+
+    it 'should load credentials' do
+      subject.redis= redis
+      expect(redis).to receive(:get).and_return(credentials_hash.to_json)
+      expect(subject.load_credentials).to be == credentials_hash
+    end
+
+    it 'should return nil' do
+      subject.redis= redis
+      expect(redis).to receive(:get).and_return(nil)
+      expect(subject.load_credentials).to be_nil
+    end
+  end
+
+  describe 'redis_credentials_key' do
+    context 'without given key' do
+      it 'should return default key' do
+        expect(subject.redis_credentials_key).to be == "google_api_credentials"
+      end
+    end
+    context 'with given key' do
+      let(:redis_store) { Google::APIClient::RedisStore.new('a redis instance', 'another_google_api_credentials') }
+      it 'should use given key' do
+        expect(redis_store.redis_credentials_key).to be == "another_google_api_credentials"
+      end
+    end
+
+  end
+
+  describe 'write credentials' do
+
+    it 'should write credentials' do
+      subject.redis= redis
+      expect(redis).to receive(:set).and_return('ok')
+      expect(subject.write_credentials(credentials_hash)).to be_truthy
+    end
+  end
+
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/batch_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/batch_spec.rb
new file mode 100644 (file)
index 0000000..3aa95a8
--- /dev/null
@@ -0,0 +1,248 @@
+# Copyright 2012 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+require 'google/api_client'
+
+RSpec.describe Google::APIClient::BatchRequest do
+  CLIENT = Google::APIClient.new(:application_name => 'API Client Tests') unless defined?(CLIENT)
+
+  after do
+    # Reset client to not-quite-pristine state
+    CLIENT.key = nil
+    CLIENT.user_ip = nil
+  end
+
+  it 'should raise an error if making an empty batch request' do
+    batch = Google::APIClient::BatchRequest.new
+
+    expect(lambda do
+      CLIENT.execute(batch)
+    end).to raise_error(Google::APIClient::BatchError)
+  end
+
+  it 'should allow query parameters in batch requests' do
+    batch = Google::APIClient::BatchRequest.new
+    batch.add(:uri => 'https://example.com', :parameters => {
+      'a' => '12345'
+    })
+    method, uri, headers, body = batch.to_http_request
+    expect(body.read).to include("/?a=12345")
+  end
+
+  describe 'with the discovery API' do
+    before do
+      CLIENT.authorization = nil
+      @discovery = CLIENT.discovered_api('discovery', 'v1')
+    end
+
+    describe 'with two valid requests' do
+      before do
+        @call1 = {
+          :api_method => @discovery.apis.get_rest,
+          :parameters => {
+            'api' => 'plus',
+            'version' => 'v1'
+          }
+        }
+
+        @call2 = {
+          :api_method => @discovery.apis.get_rest,
+          :parameters => {
+            'api' => 'discovery',
+            'version' => 'v1'
+          }
+        }
+      end
+
+      it 'should execute both when using a global callback' do
+        block_called = 0
+        ids = ['first_call', 'second_call']
+        expected_ids = ids.clone
+        batch = Google::APIClient::BatchRequest.new do |result|
+          block_called += 1
+          expect(result.status).to eq(200)
+          expect(expected_ids).to include(result.response.call_id)
+          expected_ids.delete(result.response.call_id)
+        end
+
+        batch.add(@call1, ids[0])
+        batch.add(@call2, ids[1])
+
+        CLIENT.execute(batch)
+        expect(block_called).to eq(2)
+      end
+
+      it 'should execute both when using individual callbacks' do
+        batch = Google::APIClient::BatchRequest.new
+
+        call1_returned, call2_returned = false, false
+        batch.add(@call1) do |result|
+          call1_returned = true
+          expect(result.status).to eq(200)
+        end
+        batch.add(@call2) do |result|
+          call2_returned = true
+          expect(result.status).to eq(200)
+        end
+
+        CLIENT.execute(batch)
+        expect(call1_returned).to be_truthy
+        expect(call2_returned).to be_truthy
+      end
+
+      it 'should raise an error if using the same call ID more than once' do
+        batch = Google::APIClient::BatchRequest.new
+
+        expect(lambda do
+          batch.add(@call1, 'my_id')
+          batch.add(@call2, 'my_id')
+        end).to raise_error(Google::APIClient::BatchError)
+      end
+    end
+
+    describe 'with a valid request and an invalid one' do
+      before do
+        @call1 = {
+          :api_method => @discovery.apis.get_rest,
+          :parameters => {
+            'api' => 'plus',
+            'version' => 'v1'
+          }
+        }
+
+        @call2 = {
+          :api_method => @discovery.apis.get_rest,
+          :parameters => {
+            'api' => 0,
+            'version' => 1
+          }
+        }
+      end
+
+      it 'should execute both when using a global callback' do
+        block_called = 0
+        ids = ['first_call', 'second_call']
+        expected_ids = ids.clone
+        batch = Google::APIClient::BatchRequest.new do |result|
+          block_called += 1
+          expect(expected_ids).to include(result.response.call_id)
+          expected_ids.delete(result.response.call_id)
+          if result.response.call_id == ids[0]
+            expect(result.status).to eq(200)
+          else
+            expect(result.status).to be >= 400
+            expect(result.status).to be < 500
+          end
+        end
+
+        batch.add(@call1, ids[0])
+        batch.add(@call2, ids[1])
+
+        CLIENT.execute(batch)
+        expect(block_called).to eq(2)
+      end
+
+      it 'should execute both when using individual callbacks' do
+        batch = Google::APIClient::BatchRequest.new
+
+        call1_returned, call2_returned = false, false
+        batch.add(@call1) do |result|
+          call1_returned = true
+          expect(result.status).to eq(200)
+        end
+        batch.add(@call2) do |result|
+          call2_returned = true
+          expect(result.status).to be >= 400
+          expect(result.status).to be < 500
+        end
+
+        CLIENT.execute(batch)
+        expect(call1_returned).to be_truthy
+        expect(call2_returned).to be_truthy
+      end
+    end
+  end
+
+  describe 'with the calendar API' do
+    before do
+      CLIENT.authorization = nil
+      @calendar = CLIENT.discovered_api('calendar', 'v3')
+    end
+
+    describe 'with two valid requests' do
+      before do
+        event1 = {
+          'summary' => 'Appointment 1',
+          'location' => 'Somewhere',
+          'start' => {
+            'dateTime' => '2011-01-01T10:00:00.000-07:00'
+          },
+          'end' => {
+            'dateTime' => '2011-01-01T10:25:00.000-07:00'
+          },
+          'attendees' => [
+            {
+              'email' => 'myemail@mydomain.tld'
+            }
+          ]
+        }
+
+        event2 = {
+          'summary' => 'Appointment 2',
+          'location' => 'Somewhere as well',
+          'start' => {
+            'dateTime' => '2011-01-02T10:00:00.000-07:00'
+          },
+          'end' => {
+            'dateTime' => '2011-01-02T10:25:00.000-07:00'
+          },
+          'attendees' => [
+            {
+              'email' => 'myemail@mydomain.tld'
+            }
+          ]
+        }
+
+        @call1 = {
+          :api_method => @calendar.events.insert,
+          :parameters => {'calendarId' => 'myemail@mydomain.tld'},
+          :body => MultiJson.dump(event1),
+          :headers => {'Content-Type' => 'application/json'}
+        }
+
+        @call2 = {
+          :api_method => @calendar.events.insert,
+          :parameters => {'calendarId' => 'myemail@mydomain.tld'},
+          :body => MultiJson.dump(event2),
+          :headers => {'Content-Type' => 'application/json'}
+        }
+      end
+
+      it 'should convert to a correct HTTP request' do
+        batch = Google::APIClient::BatchRequest.new { |result| }
+        batch.add(@call1, '1').add(@call2, '2')
+        request = batch.to_env(CLIENT.connection)
+        boundary = Google::APIClient::BatchRequest::BATCH_BOUNDARY
+        expect(request[:method].to_s.downcase).to eq('post')
+        expect(request[:url].to_s).to eq('https://www.googleapis.com/batch')
+        expect(request[:request_headers]['Content-Type']).to eq("multipart/mixed;boundary=#{boundary}")
+        body = request[:body].read
+        expect(body).to include(@call1[:body])
+        expect(body).to include(@call2[:body])
+      end
+    end
+
+  end
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/client_secrets_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/client_secrets_spec.rb
new file mode 100644 (file)
index 0000000..ead9bf7
--- /dev/null
@@ -0,0 +1,53 @@
+# encoding:utf-8
+
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+
+require 'google/api_client/client_secrets'
+
+FIXTURES_PATH = File.expand_path('../../../fixtures', __FILE__)
+
+RSpec.describe Google::APIClient::ClientSecrets do
+  
+  context 'with JSON file' do
+    let(:file) { File.join(FIXTURES_PATH, 'files', 'client_secrets.json') }
+    subject(:secrets) { Google::APIClient::ClientSecrets.load(file)}
+  
+    it 'should load the correct client ID' do
+      expect(secrets.client_id).to be == '898243283568.apps.googleusercontent.com'
+    end
+
+    it 'should load the correct client secret' do
+      expect(secrets.client_secret).to be == 'i8YaXdGgiQ4_KrTVNGsB7QP1'
+    end
+    
+    context 'serialzed to hash' do
+      subject(:hash) { secrets.to_hash }
+      it 'should contain the flow as the first key' do
+        expect(hash).to have_key "installed"
+      end
+
+      it 'should contain the client ID' do
+        expect(hash["installed"]["client_id"]).to be == '898243283568.apps.googleusercontent.com'
+      end
+
+      it 'should contain the client secret' do
+        expect(hash["installed"]["client_secret"]).to be == 'i8YaXdGgiQ4_KrTVNGsB7QP1'
+      end
+
+    end
+  end
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/discovery_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/discovery_spec.rb
new file mode 100644 (file)
index 0000000..d596538
--- /dev/null
@@ -0,0 +1,708 @@
+# encoding:utf-8
+
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+require 'spec_helper'
+
+require 'faraday'
+require 'multi_json'
+require 'compat/multi_json'
+require 'signet/oauth_1/client'
+require 'google/api_client'
+
+fixtures_path = File.expand_path('../../../fixtures', __FILE__)
+
+RSpec.describe Google::APIClient do
+  include ConnectionHelpers
+  CLIENT = Google::APIClient.new(:application_name => 'API Client Tests') unless defined?(CLIENT)
+
+  after do
+    # Reset client to not-quite-pristine state
+    CLIENT.key = nil
+    CLIENT.user_ip = nil
+  end
+
+  it 'should raise a type error for bogus authorization' do
+    expect(lambda do
+      Google::APIClient.new(:application_name => 'API Client Tests', :authorization => 42)
+    end).to raise_error(TypeError)
+  end
+
+  it 'should not be able to retrieve the discovery document for a bogus API' do
+    expect(lambda do
+      CLIENT.discovery_document('bogus')
+    end).to raise_error(Google::APIClient::TransmissionError)
+    expect(lambda do
+      CLIENT.discovered_api('bogus')
+    end).to raise_error(Google::APIClient::TransmissionError)
+  end
+
+  it 'should raise an error for bogus services' do
+    expect(lambda do
+      CLIENT.discovered_api(42)
+    end).to raise_error(TypeError)
+  end
+
+  it 'should raise an error for bogus services' do
+    expect(lambda do
+      CLIENT.preferred_version(42)
+    end).to raise_error(TypeError)
+  end
+
+  it 'should raise an error for bogus methods' do
+    expect(lambda do
+      CLIENT.execute(42)
+    end).to raise_error(TypeError)
+  end
+
+  it 'should not return a preferred version for bogus service names' do
+    expect(CLIENT.preferred_version('bogus')).to eq(nil)
+  end
+
+  describe 'with zoo API' do
+    it 'should return API instance registered from file' do
+      zoo_json = File.join(fixtures_path, 'files', 'zoo.json')
+      contents = File.open(zoo_json, 'rb') { |io| io.read }
+      api = CLIENT.register_discovery_document('zoo', 'v1', contents)
+      expect(api).to be_kind_of(Google::APIClient::API)
+    end
+  end
+  
+  describe 'with the prediction API' do
+    before do
+      CLIENT.authorization = nil
+      # The prediction API no longer exposes a v1, so we have to be
+      # careful about looking up the wrong API version.
+      @prediction = CLIENT.discovered_api('prediction', 'v1.2')
+    end
+
+    it 'should correctly determine the discovery URI' do
+      expect(CLIENT.discovery_uri('prediction')).to be ===
+        'https://www.googleapis.com/discovery/v1/apis/prediction/v1/rest'
+    end
+
+    it 'should correctly determine the discovery URI if :user_ip is set' do
+      CLIENT.user_ip = '127.0.0.1'
+
+      conn = stub_connection do |stub|
+        stub.get('/discovery/v1/apis/prediction/v1.2/rest?userIp=127.0.0.1') do |env|
+          [200, {}, '{}']
+        end
+      end
+      CLIENT.execute(
+        :http_method => 'GET',
+        :uri => CLIENT.discovery_uri('prediction', 'v1.2'),
+        :authenticated => false,
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should correctly determine the discovery URI if :key is set' do
+      CLIENT.key = 'qwerty'
+      conn = stub_connection do |stub|
+        stub.get('/discovery/v1/apis/prediction/v1.2/rest?key=qwerty') do |env|
+          [200, {}, '{}']
+        end
+      end
+      request = CLIENT.execute(
+        :http_method => 'GET',
+        :uri => CLIENT.discovery_uri('prediction', 'v1.2'),
+        :authenticated => false,
+        :connection => conn
+        )
+        conn.verify
+    end
+
+    it 'should correctly determine the discovery URI if both are set' do
+      CLIENT.key = 'qwerty'
+      CLIENT.user_ip = '127.0.0.1'
+      conn = stub_connection do |stub|
+        stub.get('/discovery/v1/apis/prediction/v1.2/rest?key=qwerty&userIp=127.0.0.1') do |env|
+          [200, {}, '{}']
+        end
+      end
+      request = CLIENT.execute(
+        :http_method => 'GET',
+        :uri => CLIENT.discovery_uri('prediction', 'v1.2'),
+        :authenticated => false,
+        :connection => conn
+        )
+        conn.verify
+    end
+
+    it 'should correctly generate API objects' do
+      expect(CLIENT.discovered_api('prediction', 'v1.2').name).to eq('prediction')
+      expect(CLIENT.discovered_api('prediction', 'v1.2').version).to eq('v1.2')
+      expect(CLIENT.discovered_api(:prediction, 'v1.2').name).to eq('prediction')
+      expect(CLIENT.discovered_api(:prediction, 'v1.2').version).to eq('v1.2')
+    end
+
+    it 'should discover methods' do
+      expect(CLIENT.discovered_method(
+        'prediction.training.insert', 'prediction', 'v1.2'
+      ).name).to eq('insert')
+      expect(CLIENT.discovered_method(
+        :'prediction.training.insert', :prediction, 'v1.2'
+      ).name).to eq('insert')
+      expect(CLIENT.discovered_method(
+        'prediction.training.delete', 'prediction', 'v1.2'
+      ).name).to eq('delete')
+    end
+
+    it 'should define the origin API in discovered methods' do
+      expect(CLIENT.discovered_method(
+        'prediction.training.insert', 'prediction', 'v1.2'
+      ).api.name).to eq('prediction')
+    end
+
+    it 'should not find methods that are not in the discovery document' do
+      expect(CLIENT.discovered_method(
+        'prediction.bogus', 'prediction', 'v1.2'
+      )).to eq(nil)
+    end
+
+    it 'should raise an error for bogus methods' do
+      expect(lambda do
+        CLIENT.discovered_method(42, 'prediction', 'v1.2')
+      end).to raise_error(TypeError)
+    end
+
+    it 'should raise an error for bogus methods' do
+      expect(lambda do
+        CLIENT.execute(:api_method => CLIENT.discovered_api('prediction', 'v1.2'))
+      end).to raise_error(TypeError)
+    end
+
+    it 'should correctly determine the preferred version' do
+      expect(CLIENT.preferred_version('prediction').version).not_to eq('v1')
+      expect(CLIENT.preferred_version(:prediction).version).not_to eq('v1')
+    end
+
+    it 'should return a batch path' do
+      expect(CLIENT.discovered_api('prediction', 'v1.2').batch_path).not_to be_nil
+    end
+
+    it 'should generate valid requests' do
+      conn = stub_connection do |stub|
+        stub.post('/prediction/v1.2/training?data=12345') do |env|
+          expect(env[:body]).to eq('')
+          [200, {}, '{}']
+        end
+      end
+      request = CLIENT.execute(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => '12345'},
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should generate valid requests when parameter value includes semicolon' do
+      conn = stub_connection do |stub|
+        # semicolon (;) in parameter value was being converted to
+        # bare ampersand (&) in 0.4.7. ensure that it gets converted
+        # to a CGI-escaped semicolon (%3B) instead.
+        stub.post('/prediction/v1.2/training?data=12345%3B67890') do |env|
+          expect(env[:body]).to eq('')
+          [200, {}, '{}']
+        end
+      end
+      request = CLIENT.execute(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => '12345;67890'},
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should generate valid requests when multivalued parameters are passed' do
+      conn = stub_connection do |stub|
+         stub.post('/prediction/v1.2/training?data=1&data=2') do |env|
+           expect(env.params['data']).to include('1', '2')
+          [200, {}, '{}']
+         end
+       end
+      request = CLIENT.execute(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => ['1', '2']},
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should generate requests against the correct URIs' do
+      conn = stub_connection do |stub|
+         stub.post('/prediction/v1.2/training?data=12345') do |env|
+          [200, {}, '{}']
+         end
+       end
+      request = CLIENT.execute(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => '12345'},
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should generate requests against the correct URIs' do
+      conn = stub_connection do |stub|
+        stub.post('/prediction/v1.2/training?data=12345') do |env|
+          [200, {}, '{}']
+        end
+      end
+      request = CLIENT.execute(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => '12345'},
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should allow modification to the base URIs for testing purposes' do
+      # Using a new client instance here to avoid caching rebased discovery doc
+      prediction_rebase =
+        Google::APIClient.new(:application_name => 'API Client Tests').discovered_api('prediction', 'v1.2')
+      prediction_rebase.method_base =
+        'https://testing-domain.example.com/prediction/v1.2/'
+
+      conn = stub_connection do |stub|
+        stub.post('/prediction/v1.2/training') do |env|
+          expect(env[:url].host).to eq('testing-domain.example.com')
+          [200, {}, '{}']          
+        end
+      end
+
+      request = CLIENT.execute(
+        :api_method => prediction_rebase.training.insert,
+        :parameters => {'data' => '123'},
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should generate OAuth 1 requests' do
+      CLIENT.authorization = :oauth_1
+      CLIENT.authorization.token_credential_key = '12345'
+      CLIENT.authorization.token_credential_secret = '12345'
+
+      conn = stub_connection do |stub|
+        stub.post('/prediction/v1.2/training?data=12345') do |env|
+          expect(env[:request_headers]).to have_key('Authorization')
+          expect(env[:request_headers]['Authorization']).to match(/^OAuth/)
+          [200, {}, '{}']
+        end
+      end
+
+      request = CLIENT.execute(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => '12345'},
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should generate OAuth 2 requests' do
+      CLIENT.authorization = :oauth_2
+      CLIENT.authorization.access_token = '12345'
+
+      conn = stub_connection do |stub|
+        stub.post('/prediction/v1.2/training?data=12345') do |env|
+          expect(env[:request_headers]).to have_key('Authorization')
+          expect(env[:request_headers]['Authorization']).to match(/^Bearer/)
+          [200, {}, '{}']          
+        end
+      end
+
+      request = CLIENT.execute(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => '12345'},
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should not be able to execute improperly authorized requests' do
+      CLIENT.authorization = :oauth_1
+      CLIENT.authorization.token_credential_key = '12345'
+      CLIENT.authorization.token_credential_secret = '12345'
+      result = CLIENT.execute(
+        @prediction.training.insert,
+        {'data' => '12345'}
+      )
+      expect(result.response.status).to eq(401)
+    end
+
+    it 'should not be able to execute improperly authorized requests' do
+      CLIENT.authorization = :oauth_2
+      CLIENT.authorization.access_token = '12345'
+      result = CLIENT.execute(
+        @prediction.training.insert,
+        {'data' => '12345'}
+      )
+      expect(result.response.status).to eq(401)
+    end
+
+    it 'should not be able to execute improperly authorized requests' do
+      expect(lambda do
+        CLIENT.authorization = :oauth_1
+        CLIENT.authorization.token_credential_key = '12345'
+        CLIENT.authorization.token_credential_secret = '12345'
+        result = CLIENT.execute!(
+          @prediction.training.insert,
+          {'data' => '12345'}
+        )
+      end).to raise_error(Google::APIClient::ClientError)
+    end
+
+    it 'should not be able to execute improperly authorized requests' do
+      expect(lambda do
+        CLIENT.authorization = :oauth_2
+        CLIENT.authorization.access_token = '12345'
+        result = CLIENT.execute!(
+          @prediction.training.insert,
+          {'data' => '12345'}
+        )
+      end).to raise_error(Google::APIClient::ClientError)
+    end
+
+    it 'should correctly handle unnamed parameters' do
+      conn = stub_connection do |stub|
+        stub.post('/prediction/v1.2/training') do |env|
+          expect(env[:request_headers]).to have_key('Content-Type')
+          expect(env[:request_headers]['Content-Type']).to eq('application/json')
+          [200, {}, '{}']
+        end
+      end
+      CLIENT.authorization = :oauth_2
+      CLIENT.authorization.access_token = '12345'
+      CLIENT.execute(
+        :api_method => @prediction.training.insert,
+        :body => MultiJson.dump({"id" => "bucket/object"}),
+        :headers => {'Content-Type' => 'application/json'},
+        :connection => conn
+      )
+      conn.verify
+    end
+  end
+
+  describe 'with the plus API' do
+    before do
+      CLIENT.authorization = nil
+      @plus = CLIENT.discovered_api('plus')
+    end
+
+    it 'should correctly determine the discovery URI' do
+      expect(CLIENT.discovery_uri('plus')).to be ===
+        'https://www.googleapis.com/discovery/v1/apis/plus/v1/rest'
+    end
+
+    it 'should find APIs that are in the discovery document' do
+      expect(CLIENT.discovered_api('plus').name).to eq('plus')
+      expect(CLIENT.discovered_api('plus').version).to eq('v1')
+      expect(CLIENT.discovered_api(:plus).name).to eq('plus')
+      expect(CLIENT.discovered_api(:plus).version).to eq('v1')
+    end
+
+    it 'should find methods that are in the discovery document' do
+      # TODO(bobaman) Fix this when the RPC names are correct
+      expect(CLIENT.discovered_method(
+        'plus.activities.list', 'plus'
+      ).name).to eq('list')
+    end
+
+    it 'should define the origin API in discovered methods' do
+      expect(CLIENT.discovered_method(
+        'plus.activities.list', 'plus'
+      ).api.name).to eq('plus')
+    end
+
+    it 'should not find methods that are not in the discovery document' do
+      expect(CLIENT.discovered_method('plus.bogus', 'plus')).to eq(nil)
+    end
+
+    it 'should generate requests against the correct URIs' do
+      conn = stub_connection do |stub|
+        stub.get('/plus/v1/people/107807692475771887386/activities/public') do |env|
+          [200, {}, '{}']
+        end
+      end
+
+      request = CLIENT.execute(
+        :api_method => @plus.activities.list,
+        :parameters => {
+          'userId' => '107807692475771887386', 'collection' => 'public'
+        },
+        :authenticated => false,
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should correctly validate parameters' do
+      expect(lambda do
+        CLIENT.execute(
+          :api_method => @plus.activities.list,
+          :parameters => {'alt' => 'json'},
+          :authenticated => false
+        )
+      end).to raise_error(ArgumentError)
+    end
+
+    it 'should correctly validate parameters' do
+      expect(lambda do
+        CLIENT.execute(
+          :api_method => @plus.activities.list,
+          :parameters => {
+            'userId' => '107807692475771887386', 'collection' => 'bogus'
+          },
+          :authenticated => false
+        ).to_env(CLIENT.connection)
+      end).to raise_error(ArgumentError)
+    end
+
+    it 'should correctly determine the service root_uri' do
+      expect(@plus.root_uri.to_s).to eq('https://www.googleapis.com/')
+    end
+  end
+
+  describe 'with the adsense API' do
+    before do
+      CLIENT.authorization = nil
+      @adsense = CLIENT.discovered_api('adsense', 'v1.3')
+    end
+
+    it 'should correctly determine the discovery URI' do
+      expect(CLIENT.discovery_uri('adsense', 'v1.3').to_s).to be ===
+        'https://www.googleapis.com/discovery/v1/apis/adsense/v1.3/rest'
+    end
+
+    it 'should find APIs that are in the discovery document' do
+      expect(CLIENT.discovered_api('adsense', 'v1.3').name).to eq('adsense')
+      expect(CLIENT.discovered_api('adsense', 'v1.3').version).to eq('v1.3')
+    end
+
+    it 'should return a batch path' do
+      expect(CLIENT.discovered_api('adsense', 'v1.3').batch_path).not_to be_nil
+    end
+
+    it 'should find methods that are in the discovery document' do
+      expect(CLIENT.discovered_method(
+        'adsense.reports.generate', 'adsense', 'v1.3'
+      ).name).to eq('generate')
+    end
+
+    it 'should not find methods that are not in the discovery document' do
+      expect(CLIENT.discovered_method('adsense.bogus', 'adsense', 'v1.3')).to eq(nil)
+    end
+
+    it 'should generate requests against the correct URIs' do
+      conn = stub_connection do |stub|
+        stub.get('/adsense/v1.3/adclients') do |env|
+          [200, {}, '{}']
+        end
+      end
+      request = CLIENT.execute(
+        :api_method => @adsense.adclients.list,
+        :authenticated => false,
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should not be able to execute requests without authorization' do
+      result = CLIENT.execute(
+        :api_method => @adsense.adclients.list,
+        :authenticated => false
+      )
+      expect(result.response.status).to eq(401)
+    end
+
+    it 'should fail when validating missing required parameters' do
+      expect(lambda do
+        CLIENT.execute(
+          :api_method => @adsense.reports.generate,
+          :authenticated => false
+        )
+      end).to raise_error(ArgumentError)
+    end
+
+    it 'should succeed when validating parameters in a correct call' do
+      conn = stub_connection do |stub|
+        stub.get('/adsense/v1.3/reports?dimension=DATE&endDate=2010-01-01&metric=PAGE_VIEWS&startDate=2000-01-01') do |env|
+          [200, {}, '{}']
+        end
+      end
+      expect(lambda do
+        CLIENT.execute(
+          :api_method => @adsense.reports.generate,
+          :parameters => {
+            'startDate' => '2000-01-01',
+            'endDate' => '2010-01-01',
+            'dimension' => 'DATE',
+            'metric' => 'PAGE_VIEWS'
+          },
+          :authenticated => false,
+          :connection => conn
+        )
+      end).not_to raise_error
+      conn.verify
+    end
+
+    it 'should fail when validating parameters with invalid values' do
+      expect(lambda do
+        CLIENT.execute(
+          :api_method => @adsense.reports.generate,
+          :parameters => {
+            'startDate' => '2000-01-01',
+            'endDate' => '2010-01-01',
+            'dimension' => 'BAD_CHARACTERS=-&*(£&',
+            'metric' => 'PAGE_VIEWS'
+          },
+          :authenticated => false
+        )
+      end).to raise_error(ArgumentError)
+    end
+
+    it 'should succeed when validating repeated parameters in a correct call' do
+      conn = stub_connection do |stub|
+        stub.get('/adsense/v1.3/reports?dimension=DATE&dimension=PRODUCT_CODE'+
+                 '&endDate=2010-01-01&metric=CLICKS&metric=PAGE_VIEWS&'+
+                 'startDate=2000-01-01') do |env|
+          [200, {}, '{}']
+        end
+      end
+      expect(lambda do
+        CLIENT.execute(
+          :api_method => @adsense.reports.generate,
+          :parameters => {
+            'startDate' => '2000-01-01',
+            'endDate' => '2010-01-01',
+            'dimension' => ['DATE', 'PRODUCT_CODE'],
+            'metric' => ['PAGE_VIEWS', 'CLICKS']
+          },
+          :authenticated => false,
+          :connection => conn
+        )
+      end).not_to raise_error
+      conn.verify
+    end
+
+    it 'should fail when validating incorrect repeated parameters' do
+      expect(lambda do
+        CLIENT.execute(
+          :api_method => @adsense.reports.generate,
+          :parameters => {
+            'startDate' => '2000-01-01',
+            'endDate' => '2010-01-01',
+            'dimension' => ['DATE', 'BAD_CHARACTERS=-&*(£&'],
+            'metric' => ['PAGE_VIEWS', 'CLICKS']
+          },
+          :authenticated => false
+        )
+      end).to raise_error(ArgumentError)
+    end
+
+    it 'should generate valid requests when multivalued parameters are passed' do
+      conn = stub_connection do |stub|
+         stub.get('/adsense/v1.3/reports?dimension=DATE&dimension=PRODUCT_CODE'+
+                 '&endDate=2010-01-01&metric=CLICKS&metric=PAGE_VIEWS&'+
+                 'startDate=2000-01-01') do |env|
+           expect(env.params['dimension']).to include('DATE', 'PRODUCT_CODE')
+           expect(env.params['metric']).to include('CLICKS', 'PAGE_VIEWS')
+          [200, {}, '{}']
+         end
+       end
+      request = CLIENT.execute(
+        :api_method => @adsense.reports.generate,
+          :parameters => {
+            'startDate' => '2000-01-01',
+            'endDate' => '2010-01-01',
+            'dimension' => ['DATE', 'PRODUCT_CODE'],
+            'metric' => ['PAGE_VIEWS', 'CLICKS']
+          },
+          :authenticated => false,
+          :connection => conn
+      )
+      conn.verify
+    end
+  end
+
+  describe 'with the Drive API' do
+    before do
+      CLIENT.authorization = nil
+      @drive = CLIENT.discovered_api('drive', 'v2')
+    end
+
+    it 'should include media upload info methods' do
+      expect(@drive.files.insert.media_upload).not_to eq(nil)
+    end
+
+    it 'should include accepted media types' do
+      expect(@drive.files.insert.media_upload.accepted_types).not_to be_empty
+    end
+
+    it 'should have an upload path' do
+      expect(@drive.files.insert.media_upload.uri_template).not_to eq(nil)
+    end
+
+    it 'should have a max file size' do
+      expect(@drive.files.insert.media_upload.max_size).not_to eq(nil)
+    end
+  end
+
+  describe 'with the Pub/Sub API' do
+    before do
+      CLIENT.authorization = nil
+      @pubsub = CLIENT.discovered_api('pubsub', 'v1beta2')
+    end
+
+    it 'should generate requests against the correct URIs' do
+      conn = stub_connection do |stub|
+        stub.get('/v1beta2/projects/12345/topics') do |env|
+          expect(env[:url].host).to eq('pubsub.googleapis.com')
+          [200, {}, '{}']
+        end
+      end
+      request = CLIENT.execute(
+        :api_method => @pubsub.projects.topics.list,
+        :parameters => {'project' => 'projects/12345'},
+        :connection => conn
+      )
+      conn.verify
+    end
+
+    it 'should correctly determine the service root_uri' do
+      expect(@pubsub.root_uri.to_s).to eq('https://pubsub.googleapis.com/')
+    end
+
+    it 'should discover correct method URIs' do
+      list = CLIENT.discovered_method(
+        "pubsub.projects.topics.list", "pubsub", "v1beta2"
+      )
+      expect(list.uri_template.pattern).to eq(
+        "https://pubsub.googleapis.com/v1beta2/{+project}/topics"
+      )
+
+      publish = CLIENT.discovered_method(
+        "pubsub.projects.topics.publish", "pubsub", "v1beta2"
+      )
+      expect(publish.uri_template.pattern).to eq(
+        "https://pubsub.googleapis.com/v1beta2/{+topic}:publish"
+      )
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/gzip_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/gzip_spec.rb
new file mode 100644 (file)
index 0000000..0539b97
--- /dev/null
@@ -0,0 +1,98 @@
+# Encoding: utf-8
+# Copyright 2012 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+
+require 'google/api_client'
+
+RSpec.describe Google::APIClient::Gzip do
+
+  def create_connection(&block)
+    Faraday.new do |b|
+      b.response :charset
+      b.response :gzip
+      b.adapter :test do |stub|
+        stub.get '/', &block
+      end
+    end
+  end  
+
+  it 'should ignore non-zipped content' do
+    conn = create_connection do |env|
+      [200, {}, 'Hello world']
+    end
+    result = conn.get('/')
+    expect(result.body).to eq("Hello world")
+  end
+
+  it 'should decompress gziped content' do
+    conn = create_connection do |env|
+      [200, { 'Content-Encoding' => 'gzip'}, Base64.decode64('H4sICLVGwlEAA3RtcADzSM3JyVcozy/KSeECANXgObcMAAAA')]
+    end
+    result = conn.get('/')
+    expect(result.body).to eq("Hello world\n")
+  end
+  
+  it 'should inflate with the correct charset encoding' do
+    conn = create_connection do |env|
+      [200, 
+        { 'Content-Encoding' => 'deflate', 'Content-Type' => 'application/json;charset=BIG5'}, 
+        Base64.decode64('eJxb8nLp7t2VAA8fBCI=')]
+    end
+    result = conn.get('/')
+    expect(result.body.encoding).to eq(Encoding::BIG5)
+    expect(result.body).to eq('日本語'.encode("BIG5"))
+  end
+
+  describe 'with API Client' do
+
+    before do
+      @client = Google::APIClient.new(:application_name => 'test')
+      @client.authorization = nil
+    end
+    
+    
+    it 'should send gzip in user agent' do
+      conn = create_connection do |env|
+        agent = env[:request_headers]['User-Agent']
+        expect(agent).not_to be_nil
+        expect(agent).to include 'gzip'
+        [200, {}, 'Hello world']
+      end
+      @client.execute(:uri => 'http://www.example.com/', :connection => conn)
+    end
+
+    it 'should send gzip in accept-encoding' do
+      conn = create_connection do |env|
+        encoding = env[:request_headers]['Accept-Encoding']
+        expect(encoding).not_to be_nil
+        expect(encoding).to include 'gzip'
+        [200, {}, 'Hello world']
+      end
+      @client.execute(:uri => 'http://www.example.com/', :connection => conn)
+    end
+    
+    it 'should not send gzip in accept-encoding if disabled for request' do
+      conn = create_connection do |env|
+        encoding = env[:request_headers]['Accept-Encoding']
+        expect(encoding).not_to include('gzip') unless encoding.nil?
+        [200, {}, 'Hello world']
+      end
+      response = @client.execute(:uri => 'http://www.example.com/', :gzip => false, :connection => conn)
+      puts response.status
+    end
+    
+  end
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/media_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/media_spec.rb
new file mode 100644 (file)
index 0000000..944981b
--- /dev/null
@@ -0,0 +1,178 @@
+# Copyright 2012 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+
+require 'google/api_client'
+
+fixtures_path = File.expand_path('../../../fixtures', __FILE__)
+
+RSpec.describe Google::APIClient::UploadIO do
+  it 'should reject invalid file paths' do
+    expect(lambda do
+      media = Google::APIClient::UploadIO.new('doesnotexist', 'text/plain')
+    end).to raise_error
+  end
+
+  describe 'with a file' do
+    before do
+      @file = File.expand_path('files/sample.txt', fixtures_path)
+      @media = Google::APIClient::UploadIO.new(@file, 'text/plain')
+    end
+
+    it 'should report the correct file length' do
+      expect(@media.length).to eq(File.size(@file))
+    end
+
+    it 'should have a mime type' do
+      expect(@media.content_type).to eq('text/plain')
+    end
+  end
+
+  describe 'with StringIO' do
+    before do
+      @content = "hello world"
+      @media = Google::APIClient::UploadIO.new(StringIO.new(@content), 'text/plain', 'test.txt')
+    end
+
+    it 'should report the correct file length' do
+      expect(@media.length).to eq(@content.length)
+    end
+
+    it 'should have a mime type' do
+      expect(@media.content_type).to eq('text/plain')
+    end
+  end
+end
+
+RSpec.describe Google::APIClient::RangedIO do
+  before do
+    @source = StringIO.new("1234567890abcdef")
+    @io = Google::APIClient::RangedIO.new(@source, 1, 5)
+  end
+  
+  it 'should return the correct range when read entirely' do
+    expect(@io.read).to eq("23456")
+  end
+  
+  it 'should maintain position' do
+    expect(@io.read(1)).to eq('2')
+    expect(@io.read(2)).to eq('34')
+    expect(@io.read(2)).to eq('56')
+  end
+  
+  it 'should allow rewinds' do
+    expect(@io.read(2)).to eq('23')
+    @io.rewind()
+    expect(@io.read(2)).to eq('23')
+  end
+  
+  it 'should allow setting position' do
+    @io.pos = 3
+    expect(@io.read).to eq('56')
+  end
+  
+  it 'should not allow position to be set beyond range' do
+    @io.pos = 10
+    expect(@io.read).to eq('')
+  end
+  
+  it 'should return empty string when read amount is zero' do
+    expect(@io.read(0)).to eq('')
+  end
+  
+  it 'should return empty string at EOF if amount is nil' do
+    @io.read
+    expect(@io.read).to eq('')
+  end
+  
+  it 'should return nil at EOF if amount is positive int' do
+    @io.read
+    expect(@io.read(1)).to eq(nil)
+  end
+    
+end
+
+RSpec.describe Google::APIClient::ResumableUpload do
+  CLIENT = Google::APIClient.new(:application_name => 'API Client Tests') unless defined?(CLIENT)
+
+  after do
+    # Reset client to not-quite-pristine state
+    CLIENT.key = nil
+    CLIENT.user_ip = nil
+  end
+
+  before do
+    @drive = CLIENT.discovered_api('drive', 'v2')
+    @file = File.expand_path('files/sample.txt', fixtures_path)
+    @media = Google::APIClient::UploadIO.new(@file, 'text/plain')
+    @uploader = Google::APIClient::ResumableUpload.new(
+      :media => @media,
+      :api_method => @drive.files.insert,
+      :uri => 'https://www.googleapis.com/upload/drive/v1/files/12345')
+  end
+
+  it 'should consider 20x status as complete' do
+    request = @uploader.to_http_request
+    @uploader.process_http_response(mock_result(200))
+    expect(@uploader.complete?).to eq(true)
+  end
+
+  it 'should consider 30x status as incomplete' do
+    request = @uploader.to_http_request
+    @uploader.process_http_response(mock_result(308))
+    expect(@uploader.complete?).to eq(false)
+    expect(@uploader.expired?).to eq(false)
+  end
+
+  it 'should consider 40x status as fatal' do
+    request = @uploader.to_http_request
+    @uploader.process_http_response(mock_result(404))
+    expect(@uploader.expired?).to eq(true)
+  end
+
+  it 'should detect changes to location' do
+    request = @uploader.to_http_request
+    @uploader.process_http_response(mock_result(308, 'location' => 'https://www.googleapis.com/upload/drive/v1/files/abcdef'))
+    expect(@uploader.uri.to_s).to eq('https://www.googleapis.com/upload/drive/v1/files/abcdef')
+  end
+
+  it 'should resume from the saved range reported by the server' do    
+    @uploader.chunk_size = 200
+    @uploader.to_http_request # Send bytes 0-199, only 0-99 saved
+    @uploader.process_http_response(mock_result(308, 'range' => '0-99'))
+    method, url, headers, body = @uploader.to_http_request # Send bytes 100-299
+    expect(headers['Content-Range']).to eq("bytes 100-299/#{@media.length}")
+    expect(headers['Content-length']).to eq("200")
+  end
+
+  it 'should resync the offset after 5xx errors' do
+    @uploader.chunk_size = 200
+    @uploader.to_http_request
+    @uploader.process_http_response(mock_result(500)) # Invalidates range
+    method, url, headers, body = @uploader.to_http_request # Resync
+    expect(headers['Content-Range']).to eq("bytes */#{@media.length}")
+    expect(headers['Content-length']).to eq("0")
+    @uploader.process_http_response(mock_result(308, 'range' => '0-99'))
+    method, url, headers, body = @uploader.to_http_request # Send next chunk at correct range
+    expect(headers['Content-Range']).to eq("bytes 100-299/#{@media.length}")
+    expect(headers['Content-length']).to eq("200")
+  end
+
+  def mock_result(status, headers = {})
+    reference = Google::APIClient::Reference.new(:api_method => @drive.files.insert)
+    double('result', :status => status, :headers => headers, :reference => reference)
+  end
+
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/request_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/request_spec.rb
new file mode 100644 (file)
index 0000000..c63f750
--- /dev/null
@@ -0,0 +1,29 @@
+# Copyright 2012 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+
+require 'google/api_client'
+
+RSpec.describe Google::APIClient::Request do
+  CLIENT = Google::APIClient.new(:application_name => 'API Client Tests') unless defined?(CLIENT)
+
+  it 'should normalize parameter names to strings' do
+    request = Google::APIClient::Request.new(:uri => 'https://www.google.com', :parameters => {
+      :a => '1', 'b' => '2'
+    })
+    expect(request.parameters['a']).to eq('1')
+    expect(request.parameters['b']).to eq('2')
+  end
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/result_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/result_spec.rb
new file mode 100644 (file)
index 0000000..67c63b7
--- /dev/null
@@ -0,0 +1,207 @@
+# Copyright 2012 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+
+require 'google/api_client'
+
+RSpec.describe Google::APIClient::Result do
+  CLIENT = Google::APIClient.new(:application_name => 'API Client Tests') unless defined?(CLIENT)
+
+  describe 'with the plus API' do
+    before do
+      CLIENT.authorization = nil
+      @plus = CLIENT.discovered_api('plus', 'v1')
+      @reference = Google::APIClient::Reference.new({
+        :api_method => @plus.activities.list,
+        :parameters => {
+          'userId' => 'me',
+          'collection' => 'public',
+          'maxResults' => 20
+        }
+      })
+      @request = @reference.to_http_request
+
+      # Response double
+      @response = double("response")
+      allow(@response).to receive(:status).and_return(200)
+      allow(@response).to receive(:headers).and_return({
+        'etag' => '12345',
+        'x-google-apiary-auth-scopes' =>
+          'https://www.googleapis.com/auth/plus.me',
+        'content-type' => 'application/json; charset=UTF-8',
+        'date' => 'Mon, 23 Apr 2012 00:00:00 GMT',
+        'cache-control' => 'private, max-age=0, must-revalidate, no-transform',
+        'server' => 'GSE',
+        'connection' => 'close'
+      })
+    end
+
+    describe 'with a next page token' do
+      before do
+        allow(@response).to receive(:body).and_return(
+          <<-END_OF_STRING
+          {
+            "kind": "plus#activityFeed",
+            "etag": "FOO",
+            "nextPageToken": "NEXT+PAGE+TOKEN",
+            "selfLink": "https://www.googleapis.com/plus/v1/people/foo/activities/public?",
+            "nextLink": "https://www.googleapis.com/plus/v1/people/foo/activities/public?maxResults=20&pageToken=NEXT%2BPAGE%2BTOKEN",
+            "title": "Plus Public Activity Feed for ",
+            "updated": "2012-04-23T00:00:00.000Z",
+            "id": "123456790",
+            "items": []
+          }
+          END_OF_STRING
+        )
+        @result = Google::APIClient::Result.new(@reference, @response)
+      end
+
+      it 'should indicate a successful response' do
+        expect(@result.error?).to be_falsey
+      end
+
+      it 'should return the correct next page token' do
+        expect(@result.next_page_token).to eq('NEXT+PAGE+TOKEN')
+      end
+
+      it 'should escape the next page token when calling next_page' do
+        reference = @result.next_page
+        expect(Hash[reference.parameters]).to include('pageToken')
+        expect(Hash[reference.parameters]['pageToken']).to eq('NEXT+PAGE+TOKEN')
+        url = reference.to_env(CLIENT.connection)[:url]
+        expect(url.to_s).to include('pageToken=NEXT%2BPAGE%2BTOKEN')
+      end
+
+      it 'should return content type correctly' do
+        expect(@result.media_type).to eq('application/json')
+      end
+
+      it 'should return the result data correctly' do
+        expect(@result.data?).to be_truthy
+        expect(@result.data.class.to_s).to eq(
+            'Google::APIClient::Schema::Plus::V1::ActivityFeed'
+        )
+        expect(@result.data.kind).to eq('plus#activityFeed')
+        expect(@result.data.etag).to eq('FOO')
+        expect(@result.data.nextPageToken).to eq('NEXT+PAGE+TOKEN')
+        expect(@result.data.selfLink).to eq(
+            'https://www.googleapis.com/plus/v1/people/foo/activities/public?'
+        )
+        expect(@result.data.nextLink).to eq(
+            'https://www.googleapis.com/plus/v1/people/foo/activities/public?' +
+            'maxResults=20&pageToken=NEXT%2BPAGE%2BTOKEN'
+        )
+        expect(@result.data.title).to eq('Plus Public Activity Feed for ')
+        expect(@result.data.id).to eq("123456790")
+        expect(@result.data.items).to be_empty
+      end
+    end
+
+    describe 'without a next page token' do
+      before do
+        allow(@response).to receive(:body).and_return(
+          <<-END_OF_STRING
+          {
+            "kind": "plus#activityFeed",
+            "etag": "FOO",
+            "selfLink": "https://www.googleapis.com/plus/v1/people/foo/activities/public?",
+            "title": "Plus Public Activity Feed for ",
+            "updated": "2012-04-23T00:00:00.000Z",
+            "id": "123456790",
+            "items": []
+          }
+          END_OF_STRING
+        )
+        @result = Google::APIClient::Result.new(@reference, @response)
+      end
+
+      it 'should not return a next page token' do
+        expect(@result.next_page_token).to eq(nil)
+      end
+
+      it 'should return content type correctly' do
+        expect(@result.media_type).to eq('application/json')
+      end
+
+      it 'should return the result data correctly' do
+        expect(@result.data?).to be_truthy
+        expect(@result.data.class.to_s).to eq(
+            'Google::APIClient::Schema::Plus::V1::ActivityFeed'
+        )
+        expect(@result.data.kind).to eq('plus#activityFeed')
+        expect(@result.data.etag).to eq('FOO')
+        expect(@result.data.selfLink).to eq(
+            'https://www.googleapis.com/plus/v1/people/foo/activities/public?'
+        )
+        expect(@result.data.title).to eq('Plus Public Activity Feed for ')
+        expect(@result.data.id).to eq("123456790")
+        expect(@result.data.items).to be_empty
+      end
+    end
+
+    describe 'with JSON error response' do
+      before do
+        allow(@response).to receive(:body).and_return(
+         <<-END_OF_STRING
+         {
+          "error": {
+           "errors": [
+            {
+             "domain": "global",
+             "reason": "parseError",
+             "message": "Parse Error"
+            }
+           ],
+           "code": 400,
+           "message": "Parse Error"
+          }
+         }
+         END_OF_STRING
+        )
+        allow(@response).to receive(:status).and_return(400)
+        @result = Google::APIClient::Result.new(@reference, @response)
+      end
+
+      it 'should return error status correctly' do
+        expect(@result.error?).to be_truthy
+      end
+
+      it 'should return the correct error message' do
+        expect(@result.error_message).to eq('Parse Error')
+      end
+    end
+
+    describe 'with 204 No Content response' do
+      before do
+        allow(@response).to receive(:body).and_return('')
+        allow(@response).to receive(:status).and_return(204)
+        allow(@response).to receive(:headers).and_return({})
+        @result = Google::APIClient::Result.new(@reference, @response)
+      end
+
+      it 'should indicate no data is available' do
+        expect(@result.data?).to be_falsey
+      end
+
+      it 'should return nil for data' do
+        expect(@result.data).to eq(nil)
+      end
+
+      it 'should return nil for media_type' do
+        expect(@result.media_type).to eq(nil)
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/service_account_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/service_account_spec.rb
new file mode 100644 (file)
index 0000000..6314cea
--- /dev/null
@@ -0,0 +1,169 @@
+# Copyright 2012 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+
+require 'google/api_client'
+
+fixtures_path = File.expand_path('../../../fixtures', __FILE__)
+
+RSpec.describe Google::APIClient::KeyUtils do
+  it 'should read PKCS12 files from the filesystem' do
+    if RUBY_PLATFORM == 'java' && RUBY_VERSION.start_with?('1.8')
+      pending "Reading from PKCS12 not supported on jruby 1.8.x"
+    end
+    path =  File.expand_path('files/privatekey.p12', fixtures_path)
+    key = Google::APIClient::KeyUtils.load_from_pkcs12(path, 'notasecret')
+    expect(key).not_to eq(nil)
+  end
+
+  it 'should read PKCS12 files from loaded files' do
+    if RUBY_PLATFORM == 'java' && RUBY_VERSION.start_with?('1.8')
+      pending "Reading from PKCS12 not supported on jruby 1.8.x"
+    end
+    path =  File.expand_path('files/privatekey.p12', fixtures_path)
+    content = File.read(path)
+    key = Google::APIClient::KeyUtils.load_from_pkcs12(content, 'notasecret')
+    expect(key).not_to eq(nil)
+  end
+
+  it 'should read PEM files from the filesystem' do
+    path =  File.expand_path('files/secret.pem', fixtures_path)
+    key = Google::APIClient::KeyUtils.load_from_pem(path, 'notasecret')
+    expect(key).not_to eq(nil)
+  end
+
+  it 'should read PEM files from loaded files' do
+    path =  File.expand_path('files/secret.pem', fixtures_path)
+    content = File.read(path)
+    key = Google::APIClient::KeyUtils.load_from_pem(content, 'notasecret')
+    expect(key).not_to eq(nil)
+  end
+
+end
+
+RSpec.describe Google::APIClient::JWTAsserter do
+  include ConnectionHelpers
+
+  before do
+    @key = OpenSSL::PKey::RSA.new 2048
+  end
+
+  it 'should generate valid JWTs' do
+    asserter = Google::APIClient::JWTAsserter.new('client1', 'scope1 scope2', @key)
+    jwt = asserter.to_authorization.to_jwt
+    expect(jwt).not_to eq(nil)
+
+    claim = JWT.decode(jwt, @key.public_key, true)
+    claim = claim[0] if claim[0]
+    expect(claim["iss"]).to eq('client1')
+    expect(claim["scope"]).to eq('scope1 scope2')
+  end
+
+  it 'should allow impersonation' do
+    conn = stub_connection do |stub|
+      stub.post('/o/oauth2/token') do |env|
+        params = Addressable::URI.form_unencode(env[:body])
+        JWT.decode(params.assoc("assertion").last, @key.public_key)
+        expect(params.assoc("grant_type")).to eq(['grant_type','urn:ietf:params:oauth:grant-type:jwt-bearer'])
+        [200, {'content-type' => 'application/json'}, '{
+          "access_token" : "1/abcdef1234567890",
+          "token_type" : "Bearer",
+          "expires_in" : 3600
+        }']
+      end
+    end
+    asserter = Google::APIClient::JWTAsserter.new('client1', 'scope1 scope2', @key)
+    auth = asserter.authorize('user1@email.com', { :connection => conn })
+    expect(auth).not_to eq(nil?)
+    expect(auth.person).to eq('user1@email.com')
+    conn.verify
+  end
+
+  it 'should send valid access token request' do
+    conn = stub_connection do |stub|
+      stub.post('/o/oauth2/token') do |env|
+        params = Addressable::URI.form_unencode(env[:body])
+        JWT.decode(params.assoc("assertion").last, @key.public_key)
+        expect(params.assoc("grant_type")).to eq(['grant_type','urn:ietf:params:oauth:grant-type:jwt-bearer'])
+        [200, {'content-type' => 'application/json'}, '{
+          "access_token" : "1/abcdef1234567890",
+          "token_type" : "Bearer",
+          "expires_in" : 3600
+        }']
+      end
+    end
+    asserter = Google::APIClient::JWTAsserter.new('client1', 'scope1 scope2', @key)
+    auth = asserter.authorize(nil, { :connection => conn })
+    expect(auth).not_to eq(nil?)
+    expect(auth.access_token).to eq("1/abcdef1234567890")
+    conn.verify
+  end
+
+  it 'should be refreshable' do
+    conn = stub_connection do |stub|
+      stub.post('/o/oauth2/token') do |env|
+        params = Addressable::URI.form_unencode(env[:body])
+        JWT.decode(params.assoc("assertion").last, @key.public_key)
+        expect(params.assoc("grant_type")).to eq(['grant_type','urn:ietf:params:oauth:grant-type:jwt-bearer'])
+        [200, {'content-type' => 'application/json'}, '{
+          "access_token" : "1/abcdef1234567890",
+          "token_type" : "Bearer",
+          "expires_in" : 3600
+        }']
+      end
+      stub.post('/o/oauth2/token') do |env|
+        params = Addressable::URI.form_unencode(env[:body])
+        JWT.decode(params.assoc("assertion").last, @key.public_key)
+        expect(params.assoc("grant_type")).to eq(['grant_type','urn:ietf:params:oauth:grant-type:jwt-bearer'])
+        [200, {'content-type' => 'application/json'}, '{
+          "access_token" : "1/0987654321fedcba",
+          "token_type" : "Bearer",
+          "expires_in" : 3600
+        }']
+      end
+    end
+    asserter = Google::APIClient::JWTAsserter.new('client1', 'scope1 scope2', @key)
+    auth = asserter.authorize(nil, { :connection => conn })
+    expect(auth).not_to eq(nil?)
+    expect(auth.access_token).to eq("1/abcdef1234567890")
+
+    auth.fetch_access_token!(:connection => conn)
+    expect(auth.access_token).to eq("1/0987654321fedcba")
+
+    conn.verify
+  end
+end
+
+RSpec.describe Google::APIClient::ComputeServiceAccount do
+  include ConnectionHelpers
+
+  it 'should query metadata server' do
+    conn = stub_connection do |stub|
+      stub.get('/computeMetadata/v1beta1/instance/service-accounts/default/token') do |env|
+        expect(env.url.host).to eq('metadata')
+        [200, {'content-type' => 'application/json'}, '{
+          "access_token" : "1/abcdef1234567890",
+          "token_type" : "Bearer",
+          "expires_in" : 3600
+        }']
+      end
+    end
+    service_account = Google::APIClient::ComputeServiceAccount.new
+    auth = service_account.fetch_access_token!({ :connection => conn })
+    expect(auth).not_to eq(nil?)
+    expect(auth["access_token"]).to eq("1/abcdef1234567890")
+    conn.verify
+  end
+end
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/service_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/service_spec.rb
new file mode 100644 (file)
index 0000000..fbbdd53
--- /dev/null
@@ -0,0 +1,618 @@
+# encoding:utf-8
+
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+
+require 'google/api_client'
+require 'google/api_client/service'
+
+fixtures_path = File.expand_path('../../../fixtures', __FILE__)
+
+RSpec.describe Google::APIClient::Service do
+  include ConnectionHelpers
+
+  APPLICATION_NAME = 'API Client Tests'
+
+  it 'should error out when called without an API name or version' do
+    expect(lambda do
+      Google::APIClient::Service.new
+    end).to raise_error(ArgumentError)
+  end
+
+  it 'should error out when called without an API version' do
+    expect(lambda do
+      Google::APIClient::Service.new('foo')
+    end).to raise_error(ArgumentError)
+  end
+
+  it 'should error out when the options hash is not a hash' do
+    expect(lambda do
+      Google::APIClient::Service.new('foo', 'v1', 42)
+    end).to raise_error(ArgumentError)
+  end
+
+  describe 'with the AdSense Management API' do
+
+    it 'should make a valid call for a method with no parameters' do
+      conn = stub_connection do |stub|
+        stub.get('/adsense/v1.3/adclients') do |env|
+          [200, {}, '{}']
+        end
+      end
+      adsense = Google::APIClient::Service.new(
+        'adsense',
+        'v1.3',
+        {
+          :application_name => APPLICATION_NAME,
+          :authenticated => false,
+          :connection => conn,
+          :cache_store => nil
+        }
+      )
+
+      req = adsense.adclients.list.execute()
+      conn.verify
+    end
+
+    it 'should make a valid call for a method with parameters' do
+      conn = stub_connection do |stub|
+        stub.get('/adsense/v1.3/adclients/1/adunits') do |env|
+          [200, {}, '{}']
+        end
+      end
+      adsense = Google::APIClient::Service.new(
+        'adsense',
+        'v1.3',
+        {
+          :application_name => APPLICATION_NAME,
+          :authenticated => false,
+          :connection => conn,
+          :cache_store => nil
+        }
+      )
+      req = adsense.adunits.list(:adClientId => '1').execute()
+    end
+
+    it 'should make a valid call for a deep method' do
+      conn = stub_connection do |stub|
+        stub.get('/adsense/v1.3/accounts/1/adclients') do |env|
+          [200, {}, '{}']
+        end
+      end
+      adsense = Google::APIClient::Service.new(
+        'adsense',
+        'v1.3',
+        {
+          :application_name => APPLICATION_NAME,
+          :authenticated => false,
+          :connection => conn,
+          :cache_store => nil
+        }
+      )
+      req = adsense.accounts.adclients.list(:accountId => '1').execute()
+    end
+
+    describe 'with no connection' do
+      before do
+        @adsense = Google::APIClient::Service.new('adsense', 'v1.3',
+          {:application_name => APPLICATION_NAME, :cache_store => nil})
+      end
+
+      it 'should return a resource when using a valid resource name' do
+        expect(@adsense.accounts).to be_a(Google::APIClient::Service::Resource)
+      end
+
+      it 'should throw an error when using an invalid resource name' do
+        expect(lambda do
+           @adsense.invalid_resource
+        end).to raise_error
+      end
+
+      it 'should return a request when using a valid method name' do
+        req = @adsense.adclients.list
+        expect(req).to be_a(Google::APIClient::Service::Request)
+        expect(req.method.id).to eq('adsense.adclients.list')
+        expect(req.parameters).to be_nil
+      end
+
+      it 'should throw an error when using an invalid method name' do
+        expect(lambda do
+           @adsense.adclients.invalid_method
+        end).to raise_error
+      end
+
+      it 'should return a valid request with parameters' do
+        req = @adsense.adunits.list(:adClientId => '1')
+        expect(req).to be_a(Google::APIClient::Service::Request)
+        expect(req.method.id).to eq('adsense.adunits.list')
+        expect(req.parameters).not_to be_nil
+        expect(req.parameters[:adClientId]).to eq('1')
+      end
+    end
+  end
+
+  describe 'with the Prediction API' do
+
+    it 'should make a valid call with an object body' do
+      conn = stub_connection do |stub|
+        stub.post('/prediction/v1.5/trainedmodels?project=1') do |env|
+          expect(env.body).to eq('{"id":"1"}')
+          [200, {}, '{}']
+        end
+      end
+      prediction = Google::APIClient::Service.new(
+        'prediction',
+        'v1.5',
+        {
+          :application_name => APPLICATION_NAME,
+          :authenticated => false,
+          :connection => conn,
+          :cache_store => nil
+        }
+      )
+      req = prediction.trainedmodels.insert(:project => '1').body({'id' => '1'}).execute()
+      conn.verify
+    end
+
+    it 'should make a valid call with a text body' do
+      conn = stub_connection do |stub|
+        stub.post('/prediction/v1.5/trainedmodels?project=1') do |env|
+          expect(env.body).to eq('{"id":"1"}')
+          [200, {}, '{}']
+        end
+      end
+      prediction = Google::APIClient::Service.new(
+        'prediction',
+        'v1.5',
+        {
+          :application_name => APPLICATION_NAME,
+          :authenticated => false,
+          :connection => conn,
+          :cache_store => nil
+        }
+      )
+      req = prediction.trainedmodels.insert(:project => '1').body('{"id":"1"}').execute()
+      conn.verify
+    end
+
+    describe 'with no connection' do
+      before do
+        @prediction = Google::APIClient::Service.new('prediction', 'v1.5',
+          {:application_name => APPLICATION_NAME, :cache_store => nil})
+      end
+
+      it 'should return a valid request with a body' do
+        req = @prediction.trainedmodels.insert(:project => '1').body({'id' => '1'})
+        expect(req).to be_a(Google::APIClient::Service::Request)
+        expect(req.method.id).to eq('prediction.trainedmodels.insert')
+        expect(req.body).to eq({'id' => '1'})
+        expect(req.parameters).not_to be_nil
+        expect(req.parameters[:project]).to eq('1')
+      end
+
+      it 'should return a valid request with a body when using resource name' do
+        req = @prediction.trainedmodels.insert(:project => '1').training({'id' => '1'})
+        expect(req).to be_a(Google::APIClient::Service::Request)
+        expect(req.method.id).to eq('prediction.trainedmodels.insert')
+        expect(req.training).to eq({'id' => '1'})
+        expect(req.parameters).not_to be_nil
+        expect(req.parameters[:project]).to eq('1')
+      end
+    end
+  end
+
+  describe 'with the Drive API' do
+
+    before do
+      @metadata = {
+        'title' => 'My movie',
+        'description' => 'The best home movie ever made'
+      }
+      @file = File.expand_path('files/sample.txt', fixtures_path)
+      @media = Google::APIClient::UploadIO.new(@file, 'text/plain')
+    end
+
+    it 'should make a valid call with an object body and media upload' do
+      conn = stub_connection do |stub|
+        stub.post('/upload/drive/v2/files?uploadType=multipart') do |env|
+          expect(env.body).to be_a Faraday::CompositeReadIO
+          [200, {}, '{}']
+        end
+      end
+      drive = Google::APIClient::Service.new(
+        'drive',
+        'v2',
+        {
+          :application_name => APPLICATION_NAME,
+          :authenticated => false,
+          :connection => conn,
+          :cache_store => nil
+        }
+      )
+      req = drive.files.insert(:uploadType => 'multipart').body(@metadata).media(@media).execute()
+      conn.verify
+    end
+
+    describe 'with no connection' do
+      before do
+        @drive = Google::APIClient::Service.new('drive', 'v2',
+          {:application_name => APPLICATION_NAME, :cache_store => nil})
+      end
+
+      it 'should return a valid request with a body and media upload' do
+        req = @drive.files.insert(:uploadType => 'multipart').body(@metadata).media(@media)
+        expect(req).to be_a(Google::APIClient::Service::Request)
+        expect(req.method.id).to eq('drive.files.insert')
+        expect(req.body).to eq(@metadata)
+        expect(req.media).to eq(@media)
+        expect(req.parameters).not_to be_nil
+        expect(req.parameters[:uploadType]).to eq('multipart')
+      end
+
+      it 'should return a valid request with a body and media upload when using resource name' do
+        req = @drive.files.insert(:uploadType => 'multipart').file(@metadata).media(@media)
+        expect(req).to be_a(Google::APIClient::Service::Request)
+        expect(req.method.id).to eq('drive.files.insert')
+        expect(req.file).to eq(@metadata)
+        expect(req.media).to eq(@media)
+        expect(req.parameters).not_to be_nil
+        expect(req.parameters[:uploadType]).to eq('multipart')
+      end
+    end
+  end
+
+  describe 'with the Discovery API' do
+    it 'should make a valid end-to-end request' do
+      discovery = Google::APIClient::Service.new('discovery', 'v1',
+          {:application_name => APPLICATION_NAME, :authenticated => false,
+           :cache_store => nil})
+      result = discovery.apis.get_rest(:api => 'discovery', :version => 'v1').execute
+      expect(result).not_to be_nil
+      expect(result.data.name).to eq('discovery')
+      expect(result.data.version).to eq('v1')
+    end
+  end
+end
+
+
+RSpec.describe Google::APIClient::Service::Result do
+
+  describe 'with the plus API' do
+    before do
+      @plus = Google::APIClient::Service.new('plus', 'v1',
+          {:application_name => APPLICATION_NAME, :cache_store => nil})
+      @reference = Google::APIClient::Reference.new({
+        :api_method => @plus.activities.list.method,
+        :parameters => {
+          'userId' => 'me',
+          'collection' => 'public',
+          'maxResults' => 20
+        }
+      })
+      @request = @plus.activities.list(:userId => 'me', :collection => 'public',
+        :maxResults => 20)
+
+      # Response double
+      @response = double("response")
+      allow(@response).to receive(:status).and_return(200)
+      allow(@response).to receive(:headers).and_return({
+        'etag' => '12345',
+        'x-google-apiary-auth-scopes' =>
+          'https://www.googleapis.com/auth/plus.me',
+        'content-type' => 'application/json; charset=UTF-8',
+        'date' => 'Mon, 23 Apr 2012 00:00:00 GMT',
+        'cache-control' => 'private, max-age=0, must-revalidate, no-transform',
+        'server' => 'GSE',
+        'connection' => 'close'
+      })
+    end
+
+    describe 'with a next page token' do
+      before do
+        @body = <<-END_OF_STRING
+          {
+            "kind": "plus#activityFeed",
+            "etag": "FOO",
+            "nextPageToken": "NEXT+PAGE+TOKEN",
+            "selfLink": "https://www.googleapis.com/plus/v1/people/foo/activities/public?",
+            "nextLink": "https://www.googleapis.com/plus/v1/people/foo/activities/public?maxResults=20&pageToken=NEXT%2BPAGE%2BTOKEN",
+            "title": "Plus Public Activity Feed for ",
+            "updated": "2012-04-23T00:00:00.000Z",
+            "id": "123456790",
+            "items": []
+          }
+          END_OF_STRING
+        allow(@response).to receive(:body).and_return(@body)
+        base_result = Google::APIClient::Result.new(@reference, @response)
+        @result = Google::APIClient::Service::Result.new(@request, base_result)
+      end
+
+      it 'should indicate a successful response' do
+        expect(@result.error?).to be_falsey
+      end
+
+      it 'should return the correct next page token' do
+        expect(@result.next_page_token).to eq('NEXT+PAGE+TOKEN')
+      end
+
+      it 'generate a correct request when calling next_page' do
+        next_page_request = @result.next_page
+        expect(next_page_request.parameters).to include('pageToken')
+        expect(next_page_request.parameters['pageToken']).to eq('NEXT+PAGE+TOKEN')
+        @request.parameters.each_pair do |param, value|
+          expect(next_page_request.parameters[param]).to eq(value)
+        end
+      end
+
+      it 'should return content type correctly' do
+        expect(@result.media_type).to eq('application/json')
+      end
+
+      it 'should return the body correctly' do
+        expect(@result.body).to eq(@body)
+      end
+
+      it 'should return the result data correctly' do
+        expect(@result.data?).to be_truthy
+        expect(@result.data.class.to_s).to eq(
+            'Google::APIClient::Schema::Plus::V1::ActivityFeed'
+        )
+        expect(@result.data.kind).to eq('plus#activityFeed')
+        expect(@result.data.etag).to eq('FOO')
+        expect(@result.data.nextPageToken).to eq('NEXT+PAGE+TOKEN')
+        expect(@result.data.selfLink).to eq(
+            'https://www.googleapis.com/plus/v1/people/foo/activities/public?'
+        )
+        expect(@result.data.nextLink).to eq(
+            'https://www.googleapis.com/plus/v1/people/foo/activities/public?' +
+            'maxResults=20&pageToken=NEXT%2BPAGE%2BTOKEN'
+        )
+        expect(@result.data.title).to eq('Plus Public Activity Feed for ')
+        expect(@result.data.id).to eq("123456790")
+        expect(@result.data.items).to be_empty
+      end
+    end
+
+    describe 'without a next page token' do
+      before do
+        @body = <<-END_OF_STRING
+          {
+            "kind": "plus#activityFeed",
+            "etag": "FOO",
+            "selfLink": "https://www.googleapis.com/plus/v1/people/foo/activities/public?",
+            "title": "Plus Public Activity Feed for ",
+            "updated": "2012-04-23T00:00:00.000Z",
+            "id": "123456790",
+            "items": []
+          }
+          END_OF_STRING
+        allow(@response).to receive(:body).and_return(@body)
+        base_result = Google::APIClient::Result.new(@reference, @response)
+        @result = Google::APIClient::Service::Result.new(@request, base_result)
+      end
+
+      it 'should not return a next page token' do
+        expect(@result.next_page_token).to eq(nil)
+      end
+
+      it 'should return content type correctly' do
+        expect(@result.media_type).to eq('application/json')
+      end
+
+      it 'should return the body correctly' do
+        expect(@result.body).to eq(@body)
+      end
+
+      it 'should return the result data correctly' do
+        expect(@result.data?).to be_truthy
+        expect(@result.data.class.to_s).to eq(
+            'Google::APIClient::Schema::Plus::V1::ActivityFeed'
+        )
+        expect(@result.data.kind).to eq('plus#activityFeed')
+        expect(@result.data.etag).to eq('FOO')
+        expect(@result.data.selfLink).to eq(
+            'https://www.googleapis.com/plus/v1/people/foo/activities/public?'
+        )
+        expect(@result.data.title).to eq('Plus Public Activity Feed for ')
+        expect(@result.data.id).to eq("123456790")
+        expect(@result.data.items).to be_empty
+      end
+    end
+
+    describe 'with JSON error response' do
+      before do
+        @body = <<-END_OF_STRING
+         {
+          "error": {
+           "errors": [
+            {
+             "domain": "global",
+             "reason": "parseError",
+             "message": "Parse Error"
+            }
+           ],
+           "code": 400,
+           "message": "Parse Error"
+          }
+         }
+         END_OF_STRING
+        allow(@response).to receive(:body).and_return(@body)
+        allow(@response).to receive(:status).and_return(400)
+        base_result = Google::APIClient::Result.new(@reference, @response)
+        @result = Google::APIClient::Service::Result.new(@request, base_result)
+      end
+
+      it 'should return error status correctly' do
+        expect(@result.error?).to be_truthy
+      end
+
+      it 'should return the correct error message' do
+        expect(@result.error_message).to eq('Parse Error')
+      end
+
+      it 'should return the body correctly' do
+        expect(@result.body).to eq(@body)
+      end
+    end
+
+    describe 'with 204 No Content response' do
+      before do
+        allow(@response).to receive(:body).and_return('')
+        allow(@response).to receive(:status).and_return(204)
+        allow(@response).to receive(:headers).and_return({})
+        base_result = Google::APIClient::Result.new(@reference, @response)
+        @result = Google::APIClient::Service::Result.new(@request, base_result)
+      end
+
+      it 'should indicate no data is available' do
+        expect(@result.data?).to be_falsey
+      end
+
+      it 'should return nil for data' do
+        expect(@result.data).to eq(nil)
+      end
+
+      it 'should return nil for media_type' do
+        expect(@result.media_type).to eq(nil)
+      end
+    end
+  end
+end
+
+RSpec.describe Google::APIClient::Service::BatchRequest do
+  
+  include ConnectionHelpers
+  
+  context 'with a service connection' do
+    before do
+      @conn = stub_connection do |stub|
+        stub.post('/batch') do |env|
+          [500, {'Content-Type' => 'application/json'}, '{}']
+        end
+      end
+      @discovery = Google::APIClient::Service.new('discovery', 'v1',
+          {:application_name => APPLICATION_NAME, :authorization => nil,
+           :cache_store => nil, :connection => @conn})
+      @calls = [
+        @discovery.apis.get_rest(:api => 'plus', :version => 'v1'),
+        @discovery.apis.get_rest(:api => 'discovery', :version => 'v1')
+      ]
+    end
+
+    it 'should use the service connection' do
+      batch = @discovery.batch(@calls) do
+      end
+      batch.execute
+      @conn.verify
+    end  
+  end
+  
+  describe 'with the discovery API' do
+    before do
+      @discovery = Google::APIClient::Service.new('discovery', 'v1',
+          {:application_name => APPLICATION_NAME, :authorization => nil,
+           :cache_store => nil})
+    end
+
+    describe 'with two valid requests' do
+      before do
+        @calls = [
+          @discovery.apis.get_rest(:api => 'plus', :version => 'v1'),
+          @discovery.apis.get_rest(:api => 'discovery', :version => 'v1')
+        ]
+      end
+
+      it 'should execute both when using a global callback' do
+        block_called = 0
+        batch = @discovery.batch(@calls) do |result|
+          block_called += 1
+          expect(result.status).to eq(200)
+        end
+
+        batch.execute
+        expect(block_called).to eq(2)
+      end
+
+      it 'should execute both when using individual callbacks' do
+        call1_returned, call2_returned = false, false
+        batch = @discovery.batch
+
+        batch.add(@calls[0]) do |result|
+          call1_returned = true
+          expect(result.status).to eq(200)
+          expect(result.call_index).to eq(0)
+        end
+
+        batch.add(@calls[1]) do |result|
+          call2_returned = true
+          expect(result.status).to eq(200)
+          expect(result.call_index).to eq(1)
+        end
+
+        batch.execute
+        expect(call1_returned).to eq(true)
+        expect(call2_returned).to eq(true)
+      end
+    end
+
+    describe 'with a valid request and an invalid one' do
+      before do
+        @calls = [
+          @discovery.apis.get_rest(:api => 'plus', :version => 'v1'),
+          @discovery.apis.get_rest(:api => 'invalid', :version => 'invalid')
+        ]
+      end
+
+      it 'should execute both when using a global callback' do
+        block_called = 0
+        batch = @discovery.batch(@calls) do |result|
+          block_called += 1
+          if result.call_index == 0
+            expect(result.status).to eq(200)
+          else
+            expect(result.status).to be >= 400
+            expect(result.status).to be < 500
+          end
+        end
+
+        batch.execute
+        expect(block_called).to eq(2)
+      end
+
+      it 'should execute both when using individual callbacks' do
+        call1_returned, call2_returned = false, false
+        batch = @discovery.batch
+
+        batch.add(@calls[0]) do |result|
+          call1_returned = true
+          expect(result.status).to eq(200)
+          expect(result.call_index).to eq(0)
+        end
+
+        batch.add(@calls[1]) do |result|
+          call2_returned = true
+          expect(result.status).to be >= 400
+          expect(result.status).to be < 500
+          expect(result.call_index).to eq(1)
+        end
+
+        batch.execute
+        expect(call1_returned).to eq(true)
+        expect(call2_returned).to eq(true)
+      end      
+    end
+  end
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/spec/google/api_client/simple_file_store_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client/simple_file_store_spec.rb
new file mode 100644 (file)
index 0000000..cb7d898
--- /dev/null
@@ -0,0 +1,133 @@
+# encoding:utf-8
+
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+
+require 'google/api_client/service/simple_file_store'
+
+RSpec.describe Google::APIClient::Service::SimpleFileStore do
+
+  FILE_NAME = 'test.cache'
+
+  describe 'with no cache file' do
+    before(:each) do
+      File.delete(FILE_NAME) if File.exists?(FILE_NAME)
+      @cache = Google::APIClient::Service::SimpleFileStore.new(FILE_NAME)
+    end
+
+    it 'should return nil when asked if a key exists' do
+      expect(@cache.exist?('invalid')).to be_nil
+      expect(File.exists?(FILE_NAME)).to be_falsey
+    end
+
+    it 'should return nil when asked to read a key' do
+      expect(@cache.read('invalid')).to be_nil
+      expect(File.exists?(FILE_NAME)).to be_falsey
+    end
+
+    it 'should return nil when asked to fetch a key' do
+      expect(@cache.fetch('invalid')).to be_nil
+      expect(File.exists?(FILE_NAME)).to be_falsey
+    end
+
+    it 'should create a cache file when asked to fetch a key with a default' do
+      expect(@cache.fetch('new_key') do
+        'value'
+      end).to eq('value')
+      expect(File.exists?(FILE_NAME)).to be_truthy
+    end
+
+    it 'should create a cache file when asked to write a key' do
+      @cache.write('new_key', 'value')
+      expect(File.exists?(FILE_NAME)).to be_truthy
+    end
+
+    it 'should return nil when asked to delete a key' do
+      expect(@cache.delete('invalid')).to be_nil
+      expect(File.exists?(FILE_NAME)).to be_falsey
+    end
+  end
+
+  describe 'with an existing cache' do
+    before(:each) do
+      File.delete(FILE_NAME) if File.exists?(FILE_NAME)
+      @cache = Google::APIClient::Service::SimpleFileStore.new(FILE_NAME)
+      @cache.write('existing_key', 'existing_value')
+    end
+
+    it 'should return true when asked if an existing key exists' do
+      expect(@cache.exist?('existing_key')).to be_truthy
+    end
+
+    it 'should return false when asked if a nonexistent key exists' do
+      expect(@cache.exist?('invalid')).to be_falsey
+    end
+
+    it 'should return the value for an existing key when asked to read it' do
+      expect(@cache.read('existing_key')).to eq('existing_value')
+    end
+
+    it 'should return nil for a nonexistent key when asked to read it' do
+      expect(@cache.read('invalid')).to be_nil
+    end
+
+    it 'should return the value for an existing key when asked to read it' do
+      expect(@cache.read('existing_key')).to eq('existing_value')
+    end
+
+    it 'should return nil for a nonexistent key when asked to fetch it' do
+      expect(@cache.fetch('invalid')).to be_nil
+    end
+
+    it 'should return and save the default value for a nonexistent key when asked to fetch it with a default' do
+      expect(@cache.fetch('new_key') do
+        'value'
+      end).to eq('value')
+      expect(@cache.read('new_key')).to eq('value')
+    end
+
+    it 'should remove an existing value and return true when asked to delete it' do
+      expect(@cache.delete('existing_key')).to be_truthy
+      expect(@cache.read('existing_key')).to be_nil
+    end
+
+    it 'should return false when asked to delete a nonexistent key' do
+      expect(@cache.delete('invalid')).to be_falsey
+    end
+
+    it 'should convert keys to strings when storing them' do
+      @cache.write(:symbol_key, 'value')
+      expect(@cache.read('symbol_key')).to eq('value')
+    end
+
+    it 'should convert keys to strings when reading them' do
+      expect(@cache.read(:existing_key)).to eq('existing_value')
+    end
+
+    it 'should convert keys to strings when fetching them' do
+      expect(@cache.fetch(:existing_key)).to eq('existing_value')
+    end
+
+    it 'should convert keys to strings when deleting them' do
+      expect(@cache.delete(:existing_key)).to be_truthy
+      expect(@cache.read('existing_key')).to be_nil
+    end
+  end
+
+  after(:all) do
+    File.delete(FILE_NAME) if File.exists?(FILE_NAME)
+  end
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/spec/google/api_client_spec.rb b/sdk/ruby-google-api-client/spec/google/api_client_spec.rb
new file mode 100644 (file)
index 0000000..eb9a59a
--- /dev/null
@@ -0,0 +1,352 @@
+# Copyright 2010 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+require 'spec_helper'
+
+require 'faraday'
+require 'signet/oauth_1/client'
+require 'google/api_client'
+
+shared_examples_for 'configurable user agent' do
+  include ConnectionHelpers
+
+  it 'should allow the user agent to be modified' do
+    client.user_agent = 'Custom User Agent/1.2.3'
+    expect(client.user_agent).to eq('Custom User Agent/1.2.3')
+  end
+
+  it 'should allow the user agent to be set to nil' do
+    client.user_agent = nil
+    expect(client.user_agent).to eq(nil)
+  end
+
+  it 'should not allow the user agent to be used with bogus values' do
+    expect(lambda do
+      client.user_agent = 42
+      client.execute(:uri=>'https://www.google.com/')
+    end).to raise_error(TypeError)
+  end
+
+  it 'should transmit a User-Agent header when sending requests' do
+    client.user_agent = 'Custom User Agent/1.2.3'
+
+    conn = stub_connection do |stub|
+      stub.get('/') do |env|
+        headers = env[:request_headers]
+        expect(headers).to have_key('User-Agent')
+        expect(headers['User-Agent']).to eq(client.user_agent)
+        [200, {}, ['']]
+      end
+    end
+    client.execute(:uri=>'https://www.google.com/', :connection => conn)
+    conn.verify
+  end
+end
+
+RSpec.describe Google::APIClient do
+  include ConnectionHelpers
+
+  let(:client) { Google::APIClient.new(:application_name => 'API Client Tests') }
+
+  it "should pass the faraday options provided on initialization to FaraDay configuration block" do
+    client = Google::APIClient.new(faraday_option: {timeout: 999})
+    expect(client.connection.options.timeout).to be == 999
+  end
+
+  it 'should make its version number available' do
+    expect(Google::APIClient::VERSION::STRING).to be_instance_of(String)
+  end
+
+  it 'should default to OAuth 2' do
+    expect(Signet::OAuth2::Client).to be === client.authorization
+  end
+
+  describe 'configure for no authentication' do
+    before do
+      client.authorization = nil
+    end
+    it_should_behave_like 'configurable user agent'
+  end
+
+  describe 'configured for OAuth 1' do
+    before do
+      client.authorization = :oauth_1
+      client.authorization.token_credential_key = 'abc'
+      client.authorization.token_credential_secret = '123'
+    end
+
+    it 'should use the default OAuth1 client configuration' do
+      expect(client.authorization.temporary_credential_uri.to_s).to eq(
+        'https://www.google.com/accounts/OAuthGetRequestToken'
+      )
+      expect(client.authorization.authorization_uri.to_s).to include(
+        'https://www.google.com/accounts/OAuthAuthorizeToken'
+      )
+      expect(client.authorization.token_credential_uri.to_s).to eq(
+        'https://www.google.com/accounts/OAuthGetAccessToken'
+      )
+      expect(client.authorization.client_credential_key).to eq('anonymous')
+      expect(client.authorization.client_credential_secret).to eq('anonymous')
+    end
+
+    it_should_behave_like 'configurable user agent'
+  end
+
+  describe 'configured for OAuth 2' do
+    before do
+      client.authorization = :oauth_2
+      client.authorization.access_token = '12345'
+    end
+
+    # TODO
+    it_should_behave_like 'configurable user agent'
+  end
+
+  describe 'when executing requests' do
+    before do
+      @prediction = client.discovered_api('prediction', 'v1.2')
+      client.authorization = :oauth_2
+      @connection = stub_connection do |stub|
+        stub.post('/prediction/v1.2/training?data=12345') do |env|
+          expect(env[:request_headers]['Authorization']).to eq('Bearer 12345')
+          [200, {}, '{}']
+        end
+      end
+    end
+
+    after do
+      @connection.verify
+    end
+
+    it 'should use default authorization' do
+      client.authorization.access_token = "12345"
+      client.execute(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => '12345'},
+        :connection => @connection
+      )
+    end
+
+    it 'should use request scoped authorization when provided' do
+      client.authorization.access_token = "abcdef"
+      new_auth = Signet::OAuth2::Client.new(:access_token => '12345')
+      client.execute(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => '12345'},
+        :authorization => new_auth,
+        :connection => @connection
+      )
+    end
+
+    it 'should accept options with batch/request style execute' do
+      client.authorization.access_token = "abcdef"
+      new_auth = Signet::OAuth2::Client.new(:access_token => '12345')
+      request = client.generate_request(
+        :api_method => @prediction.training.insert,
+        :parameters => {'data' => '12345'}
+      )
+      client.execute(
+        request,
+        :authorization => new_auth,
+        :connection => @connection
+      )
+    end
+
+
+    it 'should accept options in array style execute' do
+       client.authorization.access_token = "abcdef"
+       new_auth = Signet::OAuth2::Client.new(:access_token => '12345')
+       client.execute(
+         @prediction.training.insert, {'data' => '12345'}, '', {},
+         { :authorization => new_auth, :connection => @connection }
+       )
+     end
+  end
+
+  describe 'when retries enabled' do
+    before do
+      client.retries = 2
+    end
+
+    after do
+      @connection.verify
+    end
+
+    it 'should follow redirects' do
+      client.authorization = nil
+      @connection = stub_connection do |stub|
+        stub.get('/foo') do |env|
+          [302, {'location' => 'https://www.google.com/bar'}, '{}']
+        end
+        stub.get('/bar') do |env|
+          [200, {}, '{}']
+        end
+      end
+
+      client.execute(  
+        :uri => 'https://www.google.com/foo',
+        :connection => @connection
+      )
+    end
+
+    it 'should refresh tokens on 401 errors' do
+      client.authorization.access_token = '12345'
+      expect(client.authorization).to receive(:fetch_access_token!)
+
+      @connection = stub_connection do |stub|
+        stub.get('/foo') do |env|
+          [401, {}, '{}']
+        end
+        stub.get('/foo') do |env|
+          [200, {}, '{}']
+        end
+      end
+
+      client.execute(  
+        :uri => 'https://www.google.com/foo',
+        :connection => @connection
+      )
+    end
+
+
+    it 'should not attempt multiple token refreshes' do
+      client.authorization.access_token = '12345'
+      expect(client.authorization).to receive(:fetch_access_token!).once
+
+      @connection = stub_connection do |stub|
+        stub.get('/foo') do |env|
+          [401, {}, '{}']
+        end
+      end
+
+      client.execute(  
+        :uri => 'https://www.google.com/foo',
+        :connection => @connection
+      )
+    end
+
+    it 'should not retry on client errors' do
+      count = 0
+      @connection = stub_connection do |stub|
+        stub.get('/foo') do |env|
+          expect(count).to eq(0)
+          count += 1
+          [403, {}, '{}']
+        end
+      end
+
+      client.execute(  
+        :uri => 'https://www.google.com/foo',
+        :connection => @connection,
+        :authenticated => false
+      )
+    end
+
+    it 'should retry on 500 errors' do
+      client.authorization = nil
+
+      @connection = stub_connection do |stub|
+        stub.get('/foo') do |env|
+          [500, {}, '{}']
+        end
+        stub.get('/foo') do |env|
+          [200, {}, '{}']
+        end
+      end
+
+      expect(client.execute(  
+        :uri => 'https://www.google.com/foo',
+        :connection => @connection
+      ).status).to eq(200)
+
+    end
+
+    it 'should fail after max retries' do
+      client.authorization = nil
+      count = 0
+      @connection = stub_connection do |stub|
+        stub.get('/foo') do |env|
+          count += 1
+          [500, {}, '{}']
+        end
+      end
+
+      expect(client.execute(  
+        :uri => 'https://www.google.com/foo',
+        :connection => @connection
+      ).status).to eq(500)
+      expect(count).to eq(3)
+    end
+
+  end
+
+  describe 'when retries disabled and expired_auth_retry on (default)' do
+    before do
+      client.retries = 0
+    end
+
+    after do
+      @connection.verify
+    end
+
+    it 'should refresh tokens on 401 errors' do
+      client.authorization.access_token = '12345'
+      expect(client.authorization).to receive(:fetch_access_token!)
+
+      @connection = stub_connection do |stub|
+        stub.get('/foo') do |env|
+          [401, {}, '{}']
+        end
+        stub.get('/foo') do |env|
+          [200, {}, '{}']
+        end
+      end
+
+      client.execute(
+        :uri => 'https://www.gogole.com/foo',
+        :connection => @connection
+      )
+    end
+
+  end
+
+  describe 'when retries disabled and expired_auth_retry off' do
+    before do
+      client.retries = 0
+      client.expired_auth_retry = false
+    end
+
+    it 'should not refresh tokens on 401 errors' do
+      client.authorization.access_token = '12345'
+      expect(client.authorization).not_to receive(:fetch_access_token!)
+
+      @connection = stub_connection do |stub|
+        stub.get('/foo') do |env|
+          [401, {}, '{}']
+        end
+        stub.get('/foo') do |env|
+          [200, {}, '{}']
+        end
+      end
+
+      resp = client.execute(
+        :uri => 'https://www.gogole.com/foo',
+        :connection => @connection
+      )
+
+      expect(resp.response.status).to be == 401
+    end
+
+  end
+end
diff --git a/sdk/ruby-google-api-client/spec/spec_helper.rb b/sdk/ruby-google-api-client/spec/spec_helper.rb
new file mode 100644 (file)
index 0000000..1c64a4e
--- /dev/null
@@ -0,0 +1,66 @@
+$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.uniq!
+
+require 'rspec'
+require 'faraday'
+
+begin
+  require 'simplecov'
+  require 'coveralls'
+
+  SimpleCov.formatter = Coveralls::SimpleCov::Formatter
+  SimpleCov.start
+rescue LoadError
+  # SimpleCov missing, so just run specs with no coverage.
+end
+
+Faraday::Adapter.load_middleware(:test)
+
+module Faraday
+  class Connection
+    def verify
+      if app.kind_of?(Faraday::Adapter::Test)
+        app.stubs.verify_stubbed_calls
+      else
+        raise TypeError, "Expected test adapter"
+      end
+    end
+  end
+end
+
+module ConnectionHelpers
+  def stub_connection(&block)
+    stubs = Faraday::Adapter::Test::Stubs.new do |stub|
+      block.call(stub)
+    end
+    connection = Faraday.new do |builder|
+      builder.options.params_encoder = Faraday::FlatParamsEncoder
+      builder.adapter(:test, stubs)
+    end
+  end
+end
+
+module JSONMatchers
+  class EqualsJson
+    def initialize(expected)
+      @expected = JSON.parse(expected)
+    end
+    def matches?(target)
+      @target = JSON.parse(target)
+      @target.eql?(@expected)
+    end
+    def failure_message
+      "expected #{@target.inspect} to be #{@expected}"
+    end
+    def negative_failure_message
+      "expected #{@target.inspect} not to be #{@expected}"
+    end
+  end
+
+  def be_json(expected)
+    EqualsJson.new(expected)
+  end
+end
+
+RSpec.configure do |config|
+end
diff --git a/sdk/ruby-google-api-client/yard/bin/yard-wiki b/sdk/ruby-google-api-client/yard/bin/yard-wiki
new file mode 100755 (executable)
index 0000000..6141675
--- /dev/null
@@ -0,0 +1,9 @@
+#!/usr/bin/env ruby
+$LOAD_PATH.unshift(
+  File.expand_path(File.join(File.dirname(__FILE__), '../lib'))
+)
+$LOAD_PATH.uniq!
+
+require 'yard/cli/wiki'
+
+YARD::CLI::Wiki.run(*ARGV)
diff --git a/sdk/ruby-google-api-client/yard/lib/yard-google-code.rb b/sdk/ruby-google-api-client/yard/lib/yard-google-code.rb
new file mode 100644 (file)
index 0000000..cd4eba8
--- /dev/null
@@ -0,0 +1,12 @@
+$LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__)))
+$LOAD_PATH.uniq!
+
+YARD::Templates::Engine.register_template_path File.dirname(__FILE__) + '/../templates'
+require 'yard/templates/template'
+require 'yard/templates/helpers/wiki_helper'
+
+::YARD::Templates::Template.extra_includes |= [
+  YARD::Templates::Helpers::WikiHelper
+]
+
+require 'yard/serializers/wiki_serializer'
diff --git a/sdk/ruby-google-api-client/yard/lib/yard/cli/wiki.rb b/sdk/ruby-google-api-client/yard/lib/yard/cli/wiki.rb
new file mode 100644 (file)
index 0000000..2c17393
--- /dev/null
@@ -0,0 +1,44 @@
+require 'yard'
+require 'yard/serializers/wiki_serializer'
+require 'yard/cli/yardoc'
+
+module YARD
+  module CLI
+    class Wiki < Yardoc
+      # Creates a new instance of the commandline utility
+      def initialize
+        super
+        @options = SymbolHash.new(false)
+        @options.update(
+          :format => :html,
+          :template => :default,
+          :markup => :rdoc, # default is :rdoc but falls back on :none
+          :serializer => YARD::Serializers::WikiSerializer.new, # Sigh. :-(
+          :default_return => "Object",
+          :hide_void_return => false,
+          :no_highlight => false,
+          :files => [],
+          :verifier => Verifier.new
+        )
+        @visibilities = [:public]
+        @assets = {}
+        @excluded = []
+        @files = []
+        @hidden_tags = []
+        @use_cache = false
+        @use_yardopts_file = true
+        @use_document_file = true
+        @generate = true
+        @options_file = DEFAULT_YARDOPTS_FILE
+        @statistics = true
+        @list = false
+        @save_yardoc = true
+        @has_markup = false
+
+        if defined?(::Encoding) && ::Encoding.respond_to?(:default_external=)
+          ::Encoding.default_external, ::Encoding.default_internal = 'utf-8', 'utf-8'
+        end
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/yard/lib/yard/rake/wikidoc_task.rb b/sdk/ruby-google-api-client/yard/lib/yard/rake/wikidoc_task.rb
new file mode 100644 (file)
index 0000000..573bfb4
--- /dev/null
@@ -0,0 +1,27 @@
+require 'rake'
+require 'rake/tasklib'
+require 'yard/rake/yardoc_task'
+require 'yard/cli/wiki'
+
+module YARD
+  module Rake
+    # The rake task to run {CLI::Yardoc} and generate documentation.
+    class WikidocTask < YardocTask
+      protected
+
+      # Defines the rake task
+      # @return [void]
+      def define
+        desc "Generate Wiki Documentation with YARD"
+        task(name) do
+          before.call if before.is_a?(Proc)
+          yardoc = YARD::CLI::Wiki.new
+          yardoc.parse_arguments *(options + files)
+          yardoc.options[:verifier] = verifier if verifier
+          yardoc.run
+          after.call if after.is_a?(Proc)
+        end
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/yard/lib/yard/serializers/wiki_serializer.rb b/sdk/ruby-google-api-client/yard/lib/yard/serializers/wiki_serializer.rb
new file mode 100644 (file)
index 0000000..469c473
--- /dev/null
@@ -0,0 +1,68 @@
+# encoding: utf-8
+
+require 'yard/serializers/file_system_serializer'
+
+module YARD
+  module Serializers
+    ##
+    # Subclass required to get correct filename for the top level namespace.
+    # :-(
+    class WikiSerializer < FileSystemSerializer
+      # Post-process the data before serializing.
+      # Strip unnecessary whitespace.
+      # Convert stuff into more wiki-friendly stuff.
+      # FULL OF HACKS!
+      def serialize(object, data)
+        data = data.encode("UTF-8")
+        if object == "Sidebar.wiki"
+          data = data.gsub(/^#sidebar Sidebar\n/, "")
+        end
+        data = data.gsub(/\n\s*\n/, "\n")
+        # ASCII/UTF-8 erb error work-around.
+        data = data.gsub(/--/, "—")
+        data = data.gsub(/——/, "----")
+        data = data.gsub(/----\n----/, "----")
+        # HACK! Google Code Wiki treats <code> blocks like <pre> blocks.
+        data = data.gsub(/\<code\>(.+)\<\/code\>/, "`\\1`")
+        super(object, data)
+      end
+
+      def serialized_path(object)
+        return object if object.is_a?(String)
+
+        if object.is_a?(CodeObjects::ExtraFileObject)
+          fspath = ['file.' + object.name + (extension.empty? ? '' : ".#{extension}")]
+        else
+          # This line is the only change of significance.
+          # Changed from 'top-level-namespace' to 'TopLevelNamespace' to
+          # conform to wiki word page naming convention.
+          objname = object != YARD::Registry.root ? object.name.to_s : "TopLevelNamespace"
+          objname += '_' + object.scope.to_s[0,1] if object.is_a?(CodeObjects::MethodObject)
+          fspath = [objname + (extension.empty? ? '' : ".#{extension}")]
+          if object.namespace && object.namespace.path != ""
+            fspath.unshift(*object.namespace.path.split(CodeObjects::NSEP))
+          end
+        end
+
+        # Don't change the filenames, it just makes it more complicated
+        # to figure out the original name.
+        #fspath.map! do |p|
+        #  p.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
+        #end
+
+        # Remove special chars from filenames.
+        # Windows disallows \ / : * ? " < > | but we will just remove any
+        # non alphanumeric (plus period, underscore and dash).
+        fspath.map! do |p|
+          p.gsub(/[^\w\.-]/) do |x|
+            encoded = '_'
+
+            x.each_byte { |b| encoded << ("%X" % b) }
+            encoded
+          end
+        end
+        fspath.join("")
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/yard/lib/yard/templates/helpers/wiki_helper.rb b/sdk/ruby-google-api-client/yard/lib/yard/templates/helpers/wiki_helper.rb
new file mode 100644 (file)
index 0000000..e03dfb6
--- /dev/null
@@ -0,0 +1,502 @@
+require 'cgi'
+require 'rdiscount'
+
+module YARD
+  module Templates::Helpers
+    # The helper module for HTML templates.
+    module WikiHelper
+      include MarkupHelper
+
+      # @return [String] escapes text
+      def h(text)
+        out = ""
+        text = text.split(/\n/)
+        text.each_with_index do |line, i|
+          out <<
+          case line
+          when /^\s*$/; "\n\n"
+          when /^\s+\S/, /^=/; line + "\n"
+          else; line + (text[i + 1] =~ /^\s+\S/ ? "\n" : " ")
+          end
+        end
+        out.strip
+      end
+
+      # @return [String] wraps text at +col+ columns.
+      def wrap(text, col = 72)
+        text.strip.gsub(/(.{1,#{col}})( +|$\n?)|(.{1,#{col}})/, "\\1\\3\n")
+      end
+
+      # Escapes a URL
+      # 
+      # @param [String] text the URL
+      # @return [String] the escaped URL
+      def urlencode(text)
+        CGI.escape(text.to_s)
+      end
+
+      def indent(text, len = 2)
+        text.gsub(/^/, ' ' * len)
+      end
+
+      def unindent(text)
+        lines = text.split("\n", -1)
+        min_indent_size = text.size
+        for line in lines
+          indent_size = (line.gsub("\t", "  ") =~ /[^\s]/) || text.size
+          min_indent_size = indent_size if indent_size < min_indent_size
+        end
+        text.gsub("\t", "  ").gsub(Regexp.new("^" + " " * min_indent_size), '')
+      end
+
+      # @group Converting Markup to HTML
+
+      # Turns text into HTML using +markup+ style formatting.
+      #
+      # @param [String] text the text to format
+      # @param [Symbol] markup examples are +:markdown+, +:textile+, +:rdoc+.
+      #   To add a custom markup type, see {MarkupHelper}
+      # @return [String] the HTML
+      def htmlify(text, markup = options[:markup])
+        markup_meth = "html_markup_#{markup}"
+        return text unless respond_to?(markup_meth)
+        return "" unless text
+        return text unless markup
+        load_markup_provider(markup)
+        html = send(markup_meth, text)
+        if html.respond_to?(:encode)
+          html = html.force_encoding(text.encoding) # for libs that mess with encoding
+          html = html.encode(:invalid => :replace, :replace => '?')
+        end
+        html = resolve_links(html)
+        html = html.gsub(/<pre>(?:\s*<code>)?(.+?)(?:<\/code>\s*)?<\/pre>/m) do
+          str = unindent($1).strip
+          str = html_syntax_highlight(CGI.unescapeHTML(str)) unless options[:no_highlight]
+          str
+        end unless markup == :text
+        html
+      end
+
+      # Converts Markdown to HTML
+      # @param [String] text input Markdown text
+      # @return [String] output HTML
+      # @since 0.6.0
+      def html_markup_markdown(text)
+        Markdown.new(text).to_html
+      end
+
+      # Converts Textile to HTML
+      # @param [String] text the input Textile text
+      # @return [String] output HTML
+      # @since 0.6.0
+      def html_markup_textile(text)
+        doc = markup_class(:textile).new(text)
+        doc.hard_breaks = false if doc.respond_to?(:hard_breaks=)
+        doc.to_html
+      end
+
+      # Converts plaintext to HTML
+      # @param [String] text the input text
+      # @return [String] the output HTML
+      # @since 0.6.0
+      def html_markup_text(text)
+        "<pre>" + text + "</pre>"
+      end
+
+      # Converts HTML to HTML
+      # @param [String] text input html
+      # @return [String] output HTML
+      # @since 0.6.0
+      def html_markup_html(text)
+        text
+      end
+
+      # @return [String] HTMLified text as a single line (paragraphs removed)
+      def htmlify_line(*args)
+        htmlify(*args)
+      end
+
+      # Fixes RDoc behaviour with ++ only supporting alphanumeric text.
+      #
+      # @todo Refactor into own SimpleMarkup subclass
+      def fix_typewriter(text)
+        text.gsub(/\+(?! )([^\n\+]{1,900})(?! )\+/) do
+          type_text, pre_text, no_match = $1, $`, $&
+          pre_match = pre_text.scan(%r(</?(?:pre|tt|code).*?>))
+          if pre_match.last.nil? || pre_match.last.include?('/')
+            '`' + h(type_text) + '`'
+          else
+            no_match
+          end
+        end
+      end
+
+      # Don't allow -- to turn into &#8212; element. The chances of this being
+      # some --option is far more likely than the typographical meaning.
+      #
+      # @todo Refactor into own SimpleMarkup subclass
+      def fix_dash_dash(text)
+        text.gsub(/&#8212;(?=\S)/, '--')
+      end
+
+      # @group Syntax Highlighting Source Code
+
+      # Syntax highlights +source+ in language +type+.
+      #
+      # @note To support a specific language +type+, implement the method
+      #   +html_syntax_highlight_TYPE+ in this class.
+      #
+      # @param [String] source the source code to highlight
+      # @param [Symbol] type the language type (:ruby, :plain, etc). Use
+      #   :plain for no syntax highlighting.
+      # @return [String] the highlighted source
+      def html_syntax_highlight(source, type = nil)
+        return "" unless source
+        return "{{{\n#{source}\n}}}"
+      end
+
+      # @return [String] unhighlighted source
+      def html_syntax_highlight_plain(source)
+        return "" unless source
+        return "{{{\n#{source}\n}}}"
+      end
+
+      # @group Linking Objects and URLs
+
+      # Resolves any text in the form of +{Name}+ to the object specified by
+      # Name. Also supports link titles in the form +{Name title}+.
+      #
+      # @example Linking to an instance method
+      #   resolve_links("{MyClass#method}") # => "<a href='...'>MyClass#method</a>"
+      # @example Linking to a class with a title
+      #   resolve_links("{A::B::C the C class}") # => "<a href='...'>the c class</a>"
+      # @param [String] text the text to resolve links in
+      # @return [String] HTML with linkified references
+      def resolve_links(text)
+        code_tags = 0
+        text.gsub(/<(\/)?(pre|code|tt)|\{(\S+?)(?:\s(.*?\S))?\}(?=[\W<]|.+<\/|$)/) do |str|
+          closed, tag, name, title, match = $1, $2, $3, $4, $&
+          if tag
+            code_tags += (closed ? -1 : 1)
+            next str
+          end
+          next str unless code_tags == 0
+
+          next(match) if name[0,1] == '|'
+          if object.is_a?(String)
+            object
+          else
+            link = linkify(name, title)
+            if link == name || link == title
+              match = /(.+)?(\{#{Regexp.quote name}(?:\s.*?)?\})(.+)?/.match(text)
+              file = (@file ? @file : object.file) || '(unknown)'
+              line = (@file ? 1 : (object.docstring.line_range ? object.docstring.line_range.first : 1)) + (match ? $`.count("\n") : 0)
+              log.warn "In file `#{file}':#{line}: Cannot resolve link to #{name} from text" + (match ? ":" : ".")
+              log.warn((match[1] ? '...' : '') + match[2].gsub("\n","") + (match[3] ? '...' : '')) if match
+            end
+
+            link
+          end
+        end
+      end
+
+      def unlink(value)
+        value.gsub(/\b(([A-Z][a-z]+){2,99})\b/, "!\\1")
+      end
+
+      # (see BaseHelper#link_file)
+      def link_file(filename, title = nil, anchor = nil)
+        link_url(url_for_file(filename, anchor), title)
+      end
+
+      # (see BaseHelper#link_include_object)
+      def link_include_object(obj)
+        htmlify(obj.docstring)
+      end
+
+      # (see BaseHelper#link_object)
+      def link_object(obj, otitle = nil, anchor = nil, relative = true)
+        return otitle if obj.nil?
+        obj = Registry.resolve(object, obj, true, true) if obj.is_a?(String)
+        if !otitle && obj.root?
+          title = "Top Level Namespace"
+        elsif otitle
+          # title = "`" + otitle.to_s + "`"
+          title = otitle.to_s
+        elsif object.is_a?(CodeObjects::Base)
+          # title = "`" + h(object.relative_path(obj)) + "`"
+          title = h(object.relative_path(obj))
+        else
+          # title = "`" + h(obj.to_s) + "`"
+          title = h(obj.to_s)
+        end
+        unless serializer
+          return unlink(title)
+        end
+        return unlink(title) if obj.is_a?(CodeObjects::Proxy)
+
+        link = url_for(obj, anchor, relative)
+        if link
+          link_url(link, title, :formatted => false)
+        else
+          unlink(title)
+        end
+      end
+
+      # (see BaseHelper#link_url)
+      def link_url(url, title = nil, params = {})
+        title ||= url
+        if url.to_s == ""
+          title
+        else
+          if params[:formatted]
+            "<a href=\"#{url}\">#{title}</a>"
+          else
+            "[#{url} #{title}]"
+          end
+        end
+      end
+
+      # @group URL Helpers
+
+      # @param [CodeObjects::Base] object the object to get an anchor for
+      # @return [String] the anchor for a specific object
+      def anchor_for(object)
+        # Method:_Google::APIClient#execute!
+        case object
+        when CodeObjects::MethodObject
+          if object.scope == :instance
+            "Method:_#{object.path}"
+          elsif object.scope == :class
+            "Method:_#{object.path}"
+          end
+        when CodeObjects::ClassVariableObject
+          "#{object.name.to_s.gsub('@@', '')}-#{object.type}"
+        when CodeObjects::Base
+          "#{object.name}-#{object.type}"
+        when CodeObjects::Proxy
+          object.path
+        else
+          object.to_s
+        end
+      end
+
+      # Returns the URL for an object.
+      #
+      # @param [String, CodeObjects::Base] obj the object (or object path) to link to
+      # @param [String] anchor the anchor to link to
+      # @param [Boolean] relative use a relative or absolute link
+      # @return [String] the URL location of the object
+      def url_for(obj, anchor = nil, relative = true)
+        link = nil
+        return link unless serializer
+        if obj.kind_of?(CodeObjects::Base) && obj.root?
+          return 'TopLevelNamespace'
+        end
+
+        if obj.is_a?(CodeObjects::Base) && !obj.is_a?(CodeObjects::NamespaceObject)
+          # If the obj is not a namespace obj make it the anchor.
+          anchor, obj = obj, obj.namespace
+        end
+
+        objpath = serializer.serialized_path(obj)
+        return link unless objpath
+
+        if relative
+          fromobj = object
+          if object.is_a?(CodeObjects::Base) &&
+              !object.is_a?(CodeObjects::NamespaceObject)
+            fromobj = fromobj.namespace
+          end
+
+          from = serializer.serialized_path(fromobj)
+          link = File.relative_path(from, objpath)
+        else
+          link = objpath
+        end
+
+        return (
+          link.gsub(/\.html$/, '').gsub(/\.wiki$/, '') +
+          (anchor ? '#' + urlencode(anchor_for(anchor)) : '')
+        )
+      end
+
+      # Returns the URL for a specific file
+      #
+      # @param [String] filename the filename to link to
+      # @param [String] anchor optional anchor
+      # @return [String] the URL pointing to the file
+      def url_for_file(filename, anchor = nil)
+        fromobj = object
+        if CodeObjects::Base === fromobj && !fromobj.is_a?(CodeObjects::NamespaceObject)
+          fromobj = fromobj.namespace
+        end
+        from = serializer.serialized_path(fromobj)
+        if filename == options[:readme]
+          filename = 'Documentation'
+        else
+          filename = File.basename(filename).gsub(/\.[^.]+$/, '').capitalize
+        end
+        link = File.relative_path(from, filename)
+        return (
+          link.gsub(/\.html$/, '').gsub(/\.wiki$/, '') +
+          (anchor ? '#' + urlencode(anchor) : '')
+        )
+      end
+
+      # @group Formatting Objects and Attributes
+
+      # Formats a list of objects and links them
+      # @return [String] a formatted list of objects
+      def format_object_name_list(objects)
+        objects.sort_by {|o| o.name.to_s.downcase }.map do |o|
+          "<span class='name'>" + linkify(o, o.name) + "</span>"
+        end.join(", ")
+      end
+
+      # Formats a list of types from a tag.
+      #
+      # @param [Array<String>, FalseClass] typelist
+      #   the list of types to be formatted.
+      #
+      # @param [Boolean] brackets omits the surrounding
+      #   brackets if +brackets+ is set to +false+.
+      #
+      # @return [String] the list of types formatted
+      #   as [Type1, Type2, ...] with the types linked
+      #   to their respective descriptions.
+      #
+      def format_types(typelist, brackets = true)
+        return unless typelist.is_a?(Array)
+        list = typelist.map do |type|
+          type = type.gsub(/([<>])/) { h($1) }
+          type = type.gsub(/([\w:]+)/) do
+            $1 == "lt" || $1 == "gt" ? "`#{$1}`" : linkify($1, $1)
+          end
+        end
+        list.empty? ? "" : (brackets ? "(#{list.join(", ")})" : list.join(", "))
+      end
+
+      # Get the return types for a method signature.
+      #
+      # @param [CodeObjects::MethodObject] meth the method object
+      # @param [Boolean] link whether to link the types
+      # @return [String] the signature types
+      # @since 0.5.3
+      def signature_types(meth, link = true)
+        meth = convert_method_to_overload(meth)
+
+        type = options[:default_return] || ""
+        if meth.tag(:return) && meth.tag(:return).types
+          types = meth.tags(:return).map {|t| t.types ? t.types : [] }.flatten.uniq
+          first = link ? h(types.first) : format_types([types.first], false)
+          if types.size == 2 && types.last == 'nil'
+            type = first + '<sup>?</sup>'
+          elsif types.size == 2 && types.last =~ /^(Array)?<#{Regexp.quote types.first}>$/
+            type = first + '<sup>+</sup>'
+          elsif types.size > 2
+            type = [first, '...'].join(', ')
+          elsif types == ['void'] && options[:hide_void_return]
+            type = ""
+          else
+            type = link ? h(types.join(", ")) : format_types(types, false)
+          end
+        elsif !type.empty?
+          type = link ? h(type) : format_types([type], false)
+        end
+        type = "(#{type.to_s.strip}) " unless type.empty?
+        type
+      end
+
+      # Formats the signature of method +meth+.
+      #
+      # @param [CodeObjects::MethodObject] meth the method object to list
+      #   the signature of
+      # @param [Boolean] link whether to link the method signature to the details view
+      # @param [Boolean] show_extras whether to show extra meta-data (visibility, attribute info)
+      # @param [Boolean] full_attr_name whether to show the full attribute name
+      #   ("name=" instead of "name")
+      # @return [String] the formatted method signature
+      def signature(meth, link = true, show_extras = true, full_attr_name = true)
+        meth = convert_method_to_overload(meth)
+
+        type = signature_types(meth, link)
+        name = full_attr_name ? meth.name : meth.name.to_s.gsub(/^(\w+)=$/, '\1')
+        blk = format_block(meth)
+        args = !full_attr_name && meth.writer? ? "" : format_args(meth)
+        extras = []
+        extras_text = ''
+        if show_extras
+          if rw = meth.attr_info
+            attname = [rw[:read] ? 'read' : nil, rw[:write] ? 'write' : nil].compact
+            attname = attname.size == 1 ? attname.join('') + 'only' : nil
+            extras << attname if attname
+          end
+          extras << meth.visibility if meth.visibility != :public
+          extras_text = ' <span class="extras">(' + extras.join(", ") + ')</span>' unless extras.empty?
+        end
+        title = "%s *`%s`* `%s` `%s`" % [type, h(name.to_s).strip, args, blk]
+        title.gsub!(/<tt>/, "")
+        title.gsub!(/<\/tt>/, "")
+        title.gsub!(/`\s*`/, "")
+        title.strip!
+        if link
+          if meth.is_a?(YARD::CodeObjects::MethodObject)
+            link_title =
+              "#{h meth.name(true)} (#{meth.scope} #{meth.type})"
+          else
+            link_title = "#{h name} (#{meth.type})"
+          end
+          # This has to be raw HTML, can't wiki-format a link title otherwise.
+          "<a href=\"#{url_for(meth)}\">#{title}</a>#{extras_text}"
+        else
+          title + extras_text
+        end
+      end
+
+      # @group Getting the Character Encoding
+
+      # Returns the current character set. The default value can be overridden
+      # by setting the +LANG+ environment variable or by overriding this
+      # method. In Ruby 1.9 you can also modify this value by setting
+      # +Encoding.default_external+.
+      #
+      # @return [String] the current character set
+      # @since 0.5.4
+      def charset
+        return 'utf-8' unless RUBY19 || lang = ENV['LANG']
+        if RUBY19
+          lang = Encoding.default_external.name.downcase
+        else
+          lang = lang.downcase.split('.').last
+        end
+        case lang
+        when "ascii-8bit", "us-ascii", "ascii-7bit"; 'iso-8859-1'
+        else; lang
+        end
+      end
+
+      # @endgroup
+
+      private
+
+      # Converts a set of hash options into HTML attributes for a tag
+      #
+      # @param [Hash{String => String}] opts the tag options
+      # @return [String] the tag attributes of an HTML tag
+      def tag_attrs(opts = {})
+        opts.sort_by {|k, v| k.to_s }.map {|k,v| "#{k}=#{v.to_s.inspect}" if v }.join(" ")
+      end
+
+      # Converts a {CodeObjects::MethodObject} into an overload object
+      # @since 0.5.3
+      def convert_method_to_overload(meth)
+        # use first overload tag if it has a return type and method itself does not
+        if !meth.tag(:return) && meth.tags(:overload).size == 1 && meth.tag(:overload).tag(:return)
+          return meth.tag(:overload)
+        end
+        meth
+      end
+    end
+  end
+end
diff --git a/sdk/ruby-google-api-client/yard/templates/default/class/setup.rb b/sdk/ruby-google-api-client/yard/templates/default/class/setup.rb
new file mode 100644 (file)
index 0000000..0b4dc12
--- /dev/null
@@ -0,0 +1,43 @@
+lib_dir = File.expand_path(File.join(File.dirname(__FILE__), '../../../lib'))
+$LOAD_PATH.unshift(lib_dir)
+$LOAD_PATH.uniq!
+require 'yard-google-code'
+
+include T('default/module')
+
+def init
+  super
+  sections.place(:subclasses).before(:children)
+  sections.place(:constructor_details, [T('method_details')]).before(:methodmissing)
+  # Weird bug w/ doubled sections
+  sections.uniq!
+end
+
+def constructor_details
+  ctors = object.meths(:inherited => true, :included => true)
+  return unless @ctor = ctors.find {|o| o.name == :initialize }
+  return if prune_method_listing([@ctor]).empty?
+  erb(:constructor_details)
+end
+
+def subclasses
+  return if object.path == "Object" # don't show subclasses for Object
+  unless globals.subclasses
+    globals.subclasses = {}
+    list = run_verifier Registry.all(:class)
+    list.each do |o|
+      (globals.subclasses[o.superclass.path] ||= []) << o if o.superclass
+    end
+  end
+
+  @subclasses = globals.subclasses[object.path]
+  return if @subclasses.nil? || @subclasses.empty?
+  @subclasses = @subclasses.sort_by {|o| o.path }.map do |child|
+    name = child.path
+    if object.namespace
+      name = object.relative_path(child)
+    end
+    [name, child]
+  end
+  erb(:subclasses)
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/yard/templates/default/docstring/setup.rb b/sdk/ruby-google-api-client/yard/templates/default/docstring/setup.rb
new file mode 100644 (file)
index 0000000..63a5877
--- /dev/null
@@ -0,0 +1,54 @@
+lib_dir = File.expand_path(File.join(File.dirname(__FILE__), '../../../lib'))
+$LOAD_PATH.unshift(lib_dir)
+$LOAD_PATH.uniq!
+require 'yard-google-code'
+
+def init
+  return if object.docstring.blank? && !object.has_tag?(:api)
+  sections :index, [:private, :deprecated, :abstract, :todo, :note, :returns_void, :text], T('tags')
+end
+
+def private
+  return unless object.has_tag?(:api) && object.tag(:api).text == 'private'
+  erb(:private)
+end
+
+def abstract
+  return unless object.has_tag?(:abstract)
+  erb(:abstract)
+end
+
+def deprecated
+  return unless object.has_tag?(:deprecated)
+  erb(:deprecated)
+end
+
+def todo
+  return unless object.has_tag?(:todo)
+  erb(:todo)
+end
+
+def note
+  return unless object.has_tag?(:note)
+  erb(:note)
+end
+
+def returns_void
+  return unless object.type == :method
+  return if object.name == :initialize && object.scope == :instance
+  return unless object.tags(:return).size == 1 && object.tag(:return).types == ['void']
+  erb(:returns_void)
+end
+
+def docstring_text
+  text = ""
+  unless object.tags(:overload).size == 1 && object.docstring.empty?
+    text = object.docstring
+  end
+
+  if text.strip.empty? && object.tags(:return).size == 1 && object.tag(:return).text
+    text = object.tag(:return).text.gsub(/\A([a-z])/) {|x| x.upcase }
+  end
+
+  text.strip
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/yard/templates/default/method/setup.rb b/sdk/ruby-google-api-client/yard/templates/default/method/setup.rb
new file mode 100644 (file)
index 0000000..a6ed299
--- /dev/null
@@ -0,0 +1,8 @@
+lib_dir = File.expand_path(File.join(File.dirname(__FILE__), '../../../lib'))
+$LOAD_PATH.unshift(lib_dir)
+$LOAD_PATH.uniq!
+require 'yard-google-code'
+
+def init
+  sections :header, [T('method_details')]
+end
\ No newline at end of file
diff --git a/sdk/ruby-google-api-client/yard/templates/default/method_details/setup.rb b/sdk/ruby-google-api-client/yard/templates/default/method_details/setup.rb
new file mode 100644 (file)
index 0000000..e3bfea0
--- /dev/null
@@ -0,0 +1,8 @@
+lib_dir = File.expand_path(File.join(File.dirname(__FILE__), '../../../lib'))
+$LOAD_PATH.unshift(lib_dir)
+$LOAD_PATH.uniq!
+require 'yard-google-code'
+
+def init
+  sections :header, [:method_signature, T('docstring')]
+end
diff --git a/sdk/ruby-google-api-client/yard/templates/default/module/setup.rb b/sdk/ruby-google-api-client/yard/templates/default/module/setup.rb
new file mode 100644 (file)
index 0000000..d2559ea
--- /dev/null
@@ -0,0 +1,133 @@
+lib_dir = File.expand_path(File.join(File.dirname(__FILE__), '../../../lib'))
+$LOAD_PATH.unshift(lib_dir)
+$LOAD_PATH.uniq!
+require 'yard-google-code'
+
+include Helpers::ModuleHelper
+
+def init
+  sections :header, :box_info, :pre_docstring, T('docstring'), :children,
+    :constant_summary, [T('docstring')], :inherited_constants,
+    :inherited_methods,
+    :methodmissing, [T('method_details')],
+    :attribute_details, [T('method_details')],
+    :method_details_list, [T('method_details')]
+end
+
+def pre_docstring
+  return if object.docstring.blank?
+  erb(:pre_docstring)
+end
+
+def children
+  @inner = [[:modules, []], [:classes, []]]
+  object.children.each do |child|
+    @inner[0][1] << child if child.type == :module
+    @inner[1][1] << child if child.type == :class
+  end
+  @inner.map! {|v| [v[0], run_verifier(v[1].sort_by {|o| o.name.to_s })] }
+  return if (@inner[0][1].size + @inner[1][1].size) == 0
+  erb(:children)
+end
+
+def methodmissing
+  mms = object.meths(:inherited => true, :included => true)
+  return unless @mm = mms.find {|o| o.name == :method_missing && o.scope == :instance }
+  erb(:methodmissing)
+end
+
+def method_listing(include_specials = true)
+  return @smeths ||= method_listing.reject {|o| special_method?(o) } unless include_specials
+  return @meths if @meths
+  @meths = object.meths(:inherited => false, :included => false)
+  @meths = sort_listing(prune_method_listing(@meths))
+  @meths
+end
+
+def special_method?(meth)
+  return true if meth.name(true) == '#method_missing'
+  return true if meth.constructor?
+  false
+end
+
+def attr_listing
+  return @attrs if @attrs
+  @attrs = []
+  [:class, :instance].each do |scope|
+    object.attributes[scope].each do |name, rw|
+      @attrs << (rw[:read] || rw[:write])
+    end
+  end
+  @attrs = sort_listing(prune_method_listing(@attrs, false))
+end
+
+def constant_listing
+  return @constants if @constants
+  @constants = object.constants(:included => false, :inherited => false)
+  @constants += object.cvars
+  @constants = run_verifier(@constants)
+  @constants
+end
+
+def sort_listing(list)
+  list.sort_by {|o| [o.scope.to_s, o.name.to_s.downcase] }
+end
+
+def docstring_full(obj)
+  docstring = ""
+  if obj.tags(:overload).size == 1 && obj.docstring.empty?
+    docstring = obj.tag(:overload).docstring
+  else
+    docstring = obj.docstring
+  end
+
+  if docstring.summary.empty? && obj.tags(:return).size == 1 && obj.tag(:return).text
+    docstring = Docstring.new(obj.tag(:return).text.gsub(/\A([a-z])/) {|x| x.upcase }.strip)
+  end
+
+  docstring
+end
+
+def docstring_summary(obj)
+  docstring_full(obj).summary
+end
+
+def groups(list, type = "Method")
+  if groups_data = object.groups
+    others = list.select {|m| !m.group }
+    groups_data.each do |name|
+      items = list.select {|m| m.group == name }
+      yield(items, name) unless items.empty?
+    end
+  else
+    others = []
+    group_data = {}
+    list.each do |meth|
+      if meth.group
+        (group_data[meth.group] ||= []) << meth
+      else
+        others << meth
+      end
+    end
+    group_data.each {|group, items| yield(items, group) unless items.empty? }
+  end
+
+  scopes(others) {|items, scope| yield(items, "#{scope.to_s.capitalize} #{type} Summary") }
+end
+
+def scopes(list)
+  [:class, :instance].each do |scope|
+    items = list.select {|m| m.scope == scope }
+    yield(items, scope) unless items.empty?
+  end
+end
+
+def mixed_into(object)
+  unless globals.mixed_into
+    globals.mixed_into = {}
+    list = run_verifier Registry.all(:class, :module)
+    list.each {|o| o.mixins.each {|m| (globals.mixed_into[m.path] ||= []) << o } }
+  end
+
+  globals.mixed_into[object.path] || []
+end
diff --git a/sdk/ruby-google-api-client/yard/templates/default/tags/setup.rb b/sdk/ruby-google-api-client/yard/templates/default/tags/setup.rb
new file mode 100644 (file)
index 0000000..33dc42c
--- /dev/null
@@ -0,0 +1,55 @@
+lib_dir = File.expand_path(File.join(File.dirname(__FILE__), '../../../lib'))
+$LOAD_PATH.unshift(lib_dir)
+$LOAD_PATH.uniq!
+require 'yard-google-code'
+
+def init
+  tags = Tags::Library.visible_tags - [:abstract, :deprecated, :note, :todo]
+  create_tag_methods(tags - [:example, :option, :overload, :see])
+  sections :index, tags
+  sections.any(:overload).push(T('docstring'))
+end
+
+def return
+  if object.type == :method
+    return if object.name == :initialize && object.scope == :instance
+    return if object.tags(:return).size == 1 && object.tag(:return).types == ['void']
+  end
+  tag(:return)
+end
+
+private
+
+def tag(name, opts = nil)
+  return unless object.has_tag?(name)
+  opts ||= options_for_tag(name)
+  @no_names = true if opts[:no_names]
+  @no_types = true if opts[:no_types]
+  @name = name
+  out = erb('tag')
+  @no_names, @no_types = nil, nil
+  out
+end
+
+def create_tag_methods(tags)
+  tags.each do |tag|
+    next if respond_to?(tag)
+    instance_eval(<<-eof, __FILE__, __LINE__ + 1)
+      def #{tag}; tag(#{tag.inspect}) end
+    eof
+  end
+end
+
+def options_for_tag(tag)
+  opts = {:no_types => true, :no_names => true}
+  case Tags::Library.factory_method_for(tag)
+  when :with_types
+    opts[:no_types] = false
+  when :with_types_and_name
+    opts[:no_types] = false
+    opts[:no_names] = false
+  when :with_name
+    opts[:no_names] = false
+  end
+  opts
+end
index 1972df614e02a65234e24d8153454f1bf3bda8fb..ca9ab24d78ad2fab8da5fbeaa4d90abc7f315724 100644 (file)
@@ -5,6 +5,5 @@
 source 'https://rubygems.org'
 gemspec
 gem 'rake'
-gem 'minitest', '>= 5.0.0'
-gem 'mocha', require: false
-gem 'signet', '<= 0.11'
+gem 'minitest', '>= 5'
+gem 'mocha', '>= 2.1', require: false
index b196a1c33e9feb7278ac5513afcd1b75740a9075..ea5ff8c7c5caec9456e4501998a7412c2054539c 100644 (file)
@@ -37,18 +37,15 @@ Gem::Specification.new do |s|
   s.files       = ["lib/arvados.rb", "lib/arvados/google_api_client.rb",
                    "lib/arvados/collection.rb", "lib/arvados/keep.rb",
                    "README", "LICENSE-2.0.txt"]
-  s.required_ruby_version = '>= 1.8.7'
+  s.required_ruby_version = '>= 2.7.0'
   s.add_dependency('activesupport', '>= 3')
   s.add_dependency('andand', '~> 1.3', '>= 1.3.3')
-  # Our google-api-client dependency used to be < 0.9, but that could be
-  # satisfied by the buggy 0.9.pre*, cf. https://dev.arvados.org/issues/9213
-  # We need at least version 0.8.7.3, cf. https://dev.arvados.org/issues/15673
-  s.add_dependency('arvados-google-api-client', '>= 0.8.7.3', '< 0.8.9')
+  # arvados fork of google-api-client gem with old API and new
+  # compatibility fixes, built from ../ruby-google-api-client/
+  s.add_dependency('arvados-google-api-client', '>= 0.8.7.5', '< 0.8.8')
   # work around undeclared dependency on i18n in some activesupport 3.x.x:
-  s.add_dependency('i18n', '~> 0')
+  s.add_dependency('i18n')
   s.add_dependency('json', '>= 1.7.7', '<3')
-  # Avoid warning on Ruby 2.7, cf. https://dev.arvados.org/issues/18247
-  s.add_dependency('faraday', '>= 0.17.4')
   s.add_runtime_dependency('jwt', '<2', '>= 0.1.5')
   s.homepage    =
     'https://arvados.org'
index 7b99ba5788943581b4c0a2da469ebd60f32a1c03..63550cd37c71840fed8bdd5a7e90734810a488b1 100644 (file)
@@ -21,7 +21,7 @@ class Arvados
     attr_reader :request_id
 
     def execute(*args)
-      @request_id = "req-" + Random::DEFAULT.rand(2**128).to_s(36)[0..19]
+      @request_id = "req-" + Random.new.rand(2**128).to_s(36)[0..19]
       if args.last.is_a? Hash
         args.last[:headers] ||= {}
         args.last[:headers]['X-Request-Id'] = @request_id
@@ -76,7 +76,7 @@ class Arvados
     _arvados = self
     namespace_class = Arvados.const_set "A#{self.object_id}", Class.new
     self.arvados_api.schemas.each do |classname, schema|
-      next if classname.match /List$/
+      next if classname.match(/List$/)
       klass = Class.new(Arvados::Model) do
         def self.arvados
           @arvados
@@ -137,7 +137,7 @@ class Arvados
   end
 
   def debuglog *args
-    self.class.debuglog *args
+    self.class.debuglog(*args)
   end
 
   def config(config_file_path="~/.config/arvados/settings.conf")
@@ -206,10 +206,10 @@ class Arvados
       arvados.client
     end
     def self.debuglog(*args)
-      arvados.class.debuglog *args
+      arvados.class.debuglog(*args)
     end
     def debuglog(*args)
-      self.class.arvados.class.debuglog *args
+      self.class.arvados.class.debuglog(*args)
     end
     def self.api_exec(method, parameters={})
       api_method = arvados_api.send(api_models_sym).send(method.name.to_sym)
index 0f385e221877a38075571479057282f73147f51f..28f12b0b02f82d9931af280ec6784b8238b61ab3 100644 (file)
@@ -33,7 +33,7 @@ module SDKFixtures
         file = IO.read(path)
         trim_index = file.index('# Test Helper trims the rest of the file')
         file = file[0, trim_index] if trim_index
-        YAML.load(file)
+        YAML.safe_load(file, permitted_classes: [Time])
       end
   end
 
index eee8b39699078681d89ad331fe6c74cfe6f47079..ff0cab6ef922dad8cd66beec0a0fc2add067c62b 100644 (file)
@@ -357,8 +357,6 @@ class ManifestTest < Minitest::Test
       "invalid file token \"0:0:a/./bc.txt\""],
     [false, ". d41d8cd98f00b204e9800998ecf8427e 0:0:a/../bc.txt\n",
       "invalid file token \"0:0:a/../bc.txt\""],
-    [false, "./abc/./foo d41d8cd98f00b204e9800998ecf8427e 0:0:abc.txt\n",
-      "invalid stream name \"./abc/./foo\""],
     [false, "d41d8cd98f00b204e9800998ecf8427e+0 0:0:abc.txt\n",
       "invalid stream name \"d41d8cd98f00b204e9800998ecf8427e+0\""],
     [false, ". d41d8cd98f00b204e9800998ecf8427 0:0:abc.txt\n",
index 2e25210ba706a792817a4fe2559416d070d8aed2..641b442751797a24218e5844892f79bd1c04a8e3 100644 (file)
@@ -17,6 +17,6 @@ class RequestIdTest < Minitest::Test
             arv.collection.get(uuid: "zzzzz-4zz18-zzzzzzzzzzzzzzz")
         end
         assert clnt.request_id != nil
-        assert_match /Uh-oh.*\(Request ID: req-[0-9a-zA-Z]{20}\)/, err.message
+        assert_match(/Uh-oh.*\(Request ID: req-[0-9a-zA-Z]{20}\)/, err.message)
     end
-end
\ No newline at end of file
+end
index 9b401cc6ac86a1e9dd6e1af147c1b97565e484aa..9cc5f1b7bc175c421eb18a12ed41052ade1928c1 100644 (file)
@@ -4,25 +4,19 @@
 
 source 'https://rubygems.org'
 
-gem 'rails', '~> 5.2.0'
-gem 'responders', '~> 2.0'
-
-# Pin sprockets to < 4.0 to avoid issues when upgrading rails to 5.2
-# See: https://github.com/rails/sprockets-rails/issues/443
-gem 'sprockets', '~> 3.0'
+gem 'rails', '~> 7.0.0'
+gem 'responders'
+gem 'i18n'
+gem 'sprockets-rails'
 
 group :test, :development do
   gem 'factory_bot_rails'
-
-  # As of now (2019-03-27) There's an open issue about incompatibilities with
-  # newer versions of this gem: https://github.com/rails/rails-perftest/issues/38
-  gem 'ruby-prof', '~> 0.15.0'
-
+  gem 'ruby-prof'
   # Note: "require: false" here tells bunder not to automatically
   # 'require' the packages during application startup. Installation is
   # still mandatory.
-  gem 'test-unit', '~> 3.0', require: false
-  gem 'simplecov', '~> 0.7.1', require: false
+  gem 'test-unit', require: false
+  gem 'simplecov', require: false
   gem 'simplecov-rcov', require: false
   gem 'mocha', require: false
   gem 'byebug'
@@ -49,12 +43,9 @@ gem 'optimist'
 
 gem 'themes_for_rails', git: 'https://github.com/arvados/themes_for_rails'
 
-# Import arvados gem.
-gem 'arvados', '~> 2.1.5'
+gem 'arvados', '~> 2.7.0.rc1'
 gem 'httpclient'
 
-gem 'sshkey'
-gem 'safe_yaml'
 gem 'lograge'
 gem 'logstash-event'
 
@@ -63,9 +54,9 @@ gem 'rails-observers'
 gem 'rails-perftest'
 gem 'rails-controller-testing'
 
-# arvados-google-api-client and googleauth depend on signet, but
-# signet 0.12 is incompatible with ruby 2.3.
-gem 'signet', '< 0.12'
+gem 'webrick'
+
+gem 'mini_portile2', '~> 2.8', '>= 2.8.1'
 
 # Install any plugin gems
 Dir.glob(File.join(File.dirname(__FILE__), 'lib', '**', "Gemfile")) do |f|
index 031bd9267e8a39764f745064e420804a0e8688c7..0fe91e0a1867bb8ce9d219bb74679f64f9534add 100644 (file)
@@ -8,225 +8,280 @@ GIT
 GEM
   remote: https://rubygems.org/
   specs:
-    actioncable (5.2.8.1)
-      actionpack (= 5.2.8.1)
+    actioncable (7.0.8.1)
+      actionpack (= 7.0.8.1)
+      activesupport (= 7.0.8.1)
       nio4r (~> 2.0)
       websocket-driver (>= 0.6.1)
-    actionmailer (5.2.8.1)
-      actionpack (= 5.2.8.1)
-      actionview (= 5.2.8.1)
-      activejob (= 5.2.8.1)
+    actionmailbox (7.0.8.1)
+      actionpack (= 7.0.8.1)
+      activejob (= 7.0.8.1)
+      activerecord (= 7.0.8.1)
+      activestorage (= 7.0.8.1)
+      activesupport (= 7.0.8.1)
+      mail (>= 2.7.1)
+      net-imap
+      net-pop
+      net-smtp
+    actionmailer (7.0.8.1)
+      actionpack (= 7.0.8.1)
+      actionview (= 7.0.8.1)
+      activejob (= 7.0.8.1)
+      activesupport (= 7.0.8.1)
       mail (~> 2.5, >= 2.5.4)
+      net-imap
+      net-pop
+      net-smtp
       rails-dom-testing (~> 2.0)
-    actionpack (5.2.8.1)
-      actionview (= 5.2.8.1)
-      activesupport (= 5.2.8.1)
-      rack (~> 2.0, >= 2.0.8)
+    actionpack (7.0.8.1)
+      actionview (= 7.0.8.1)
+      activesupport (= 7.0.8.1)
+      rack (~> 2.0, >= 2.2.4)
       rack-test (>= 0.6.3)
       rails-dom-testing (~> 2.0)
-      rails-html-sanitizer (~> 1.0, >= 1.0.2)
-    actionview (5.2.8.1)
-      activesupport (= 5.2.8.1)
+      rails-html-sanitizer (~> 1.0, >= 1.2.0)
+    actiontext (7.0.8.1)
+      actionpack (= 7.0.8.1)
+      activerecord (= 7.0.8.1)
+      activestorage (= 7.0.8.1)
+      activesupport (= 7.0.8.1)
+      globalid (>= 0.6.0)
+      nokogiri (>= 1.8.5)
+    actionview (7.0.8.1)
+      activesupport (= 7.0.8.1)
       builder (~> 3.1)
       erubi (~> 1.4)
       rails-dom-testing (~> 2.0)
-      rails-html-sanitizer (~> 1.0, >= 1.0.3)
-    activejob (5.2.8.1)
-      activesupport (= 5.2.8.1)
+      rails-html-sanitizer (~> 1.1, >= 1.2.0)
+    activejob (7.0.8.1)
+      activesupport (= 7.0.8.1)
       globalid (>= 0.3.6)
-    activemodel (5.2.8.1)
-      activesupport (= 5.2.8.1)
-    activerecord (5.2.8.1)
-      activemodel (= 5.2.8.1)
-      activesupport (= 5.2.8.1)
-      arel (>= 9.0)
-    activestorage (5.2.8.1)
-      actionpack (= 5.2.8.1)
-      activerecord (= 5.2.8.1)
-      marcel (~> 1.0.0)
-    activesupport (5.2.8.1)
+    activemodel (7.0.8.1)
+      activesupport (= 7.0.8.1)
+    activerecord (7.0.8.1)
+      activemodel (= 7.0.8.1)
+      activesupport (= 7.0.8.1)
+    activestorage (7.0.8.1)
+      actionpack (= 7.0.8.1)
+      activejob (= 7.0.8.1)
+      activerecord (= 7.0.8.1)
+      activesupport (= 7.0.8.1)
+      marcel (~> 1.0)
+      mini_mime (>= 1.1.0)
+    activesupport (7.0.8.1)
       concurrent-ruby (~> 1.0, >= 1.0.2)
-      i18n (>= 0.7, < 2)
-      minitest (~> 5.1)
-      tzinfo (~> 1.1)
+      i18n (>= 1.6, < 2)
+      minitest (>= 5.1)
+      tzinfo (~> 2.0)
     acts_as_api (1.0.1)
       activemodel (>= 3.0.0)
       activesupport (>= 3.0.0)
       rack (>= 1.1.0)
-    addressable (2.8.0)
-      public_suffix (>= 2.0.2, < 5.0)
+    addressable (2.8.6)
+      public_suffix (>= 2.0.2, < 6.0)
     andand (1.3.3)
-    arel (9.0.0)
-    arvados (2.1.5)
+    arvados (2.7.0.rc2)
       activesupport (>= 3)
       andand (~> 1.3, >= 1.3.3)
-      arvados-google-api-client (>= 0.7, < 0.8.9)
-      faraday (< 0.16)
-      i18n (~> 0)
+      arvados-google-api-client (>= 0.8.7.5, < 0.8.8)
+      i18n
       json (>= 1.7.7, < 3)
       jwt (>= 0.1.5, < 2)
-    arvados-google-api-client (0.8.7.4)
-      activesupport (>= 3.2, < 5.3)
+    arvados-google-api-client (0.8.7.6)
+      activesupport (>= 3.2, < 8.0)
       addressable (~> 2.3)
       autoparse (~> 0.3)
       extlib (~> 0.9)
-      faraday (~> 0.9)
-      googleauth (~> 0.3)
+      faraday (~> 2.8.0)
+      faraday-gzip (~> 2.0)
+      faraday-multipart (~> 1.0)
+      googleauth (~> 1.0)
       launchy (~> 2.4)
       multi_json (~> 1.10)
       retriable (~> 1.4)
-      signet (~> 0.6)
+      signet (~> 0.16.0)
     autoparse (0.3.3)
       addressable (>= 2.3.1)
       extlib (>= 0.9.15)
       multi_json (>= 1.0.0)
+    base64 (0.2.0)
     builder (3.2.4)
-    byebug (11.0.1)
-    concurrent-ruby (1.1.10)
+    byebug (11.1.3)
+    concurrent-ruby (1.2.3)
     crass (1.0.6)
-    erubi (1.10.0)
+    date (3.3.4)
+    docile (1.4.0)
+    erubi (1.12.0)
     extlib (0.9.16)
-    factory_bot (5.0.2)
-      activesupport (>= 4.2.0)
-    factory_bot_rails (5.0.1)
-      factory_bot (~> 5.0.0)
-      railties (>= 4.2.0)
-    faraday (0.15.4)
-      multipart-post (>= 1.2, < 3)
-    ffi (1.9.25)
-    globalid (1.0.0)
-      activesupport (>= 5.0)
-    googleauth (0.9.0)
-      faraday (~> 0.12)
+    factory_bot (6.2.1)
+      activesupport (>= 5.0.0)
+    factory_bot_rails (6.2.0)
+      factory_bot (~> 6.2.0)
+      railties (>= 5.0.0)
+    faraday (2.8.1)
+      base64
+      faraday-net_http (>= 2.0, < 3.1)
+      ruby2_keywords (>= 0.0.4)
+    faraday-gzip (2.0.1)
+      faraday (>= 1.0)
+      zlib (~> 3.0)
+    faraday-multipart (1.0.4)
+      multipart-post (~> 2)
+    faraday-net_http (3.0.2)
+    ffi (1.15.5)
+    globalid (1.2.1)
+      activesupport (>= 6.1)
+    google-cloud-env (2.1.1)
+      faraday (>= 1.0, < 3.a)
+    googleauth (1.9.2)
+      faraday (>= 1.0, < 3.a)
+      google-cloud-env (~> 2.1)
       jwt (>= 1.4, < 3.0)
-      memoist (~> 0.16)
       multi_json (~> 1.11)
       os (>= 0.9, < 2.0)
-      signet (~> 0.7)
+      signet (>= 0.16, < 2.a)
     httpclient (2.8.3)
-    i18n (0.9.5)
+    i18n (1.14.4)
       concurrent-ruby (~> 1.0)
-    jquery-rails (4.3.3)
+    jquery-rails (4.6.0)
       rails-dom-testing (>= 1, < 3)
       railties (>= 4.2.0)
       thor (>= 0.14, < 2.0)
-    json (2.5.1)
+    json (2.6.3)
     jwt (1.5.6)
-    launchy (2.5.0)
-      addressable (~> 2.7)
-    listen (3.2.1)
+    launchy (2.5.2)
+      addressable (~> 2.8)
+    listen (3.8.0)
       rb-fsevent (~> 0.10, >= 0.10.3)
       rb-inotify (~> 0.9, >= 0.9.10)
-    lograge (0.10.0)
+    lograge (0.13.0)
       actionpack (>= 4)
       activesupport (>= 4)
       railties (>= 4)
       request_store (~> 1.0)
     logstash-event (1.2.02)
-    loofah (2.19.1)
+    loofah (2.22.0)
       crass (~> 1.0.2)
-      nokogiri (>= 1.5.9)
-    mail (2.7.1)
+      nokogiri (>= 1.12.0)
+    mail (2.8.1)
       mini_mime (>= 0.1.1)
-    marcel (1.0.2)
-    memoist (0.16.2)
-    metaclass (0.0.4)
+      net-imap
+      net-pop
+      net-smtp
+    marcel (1.0.4)
     method_source (1.0.0)
-    mini_mime (1.1.2)
-    mini_portile2 (2.8.0)
+    mini_mime (1.1.5)
+    mini_portile2 (2.8.5)
     minitest (5.10.3)
-    mocha (1.8.0)
-      metaclass (~> 0.0.1)
+    mocha (2.1.0)
+      ruby2_keywords (>= 0.0.5)
     multi_json (1.15.0)
-    multipart-post (2.1.1)
-    nio4r (2.5.8)
-    nokogiri (1.13.10)
-      mini_portile2 (~> 2.8.0)
+    multipart-post (2.4.0)
+    net-imap (0.3.7)
+      date
+      net-protocol
+    net-pop (0.1.2)
+      net-protocol
+    net-protocol (0.2.2)
+      timeout
+    net-smtp (0.5.0)
+      net-protocol
+    nio4r (2.7.1)
+    nokogiri (1.15.6)
+      mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
-    oj (3.9.2)
-    optimist (3.0.0)
-    os (1.1.1)
-    passenger (6.0.15)
+    oj (3.16.1)
+    optimist (3.1.0)
+    os (1.1.4)
+    passenger (6.0.18)
       rack
       rake (>= 0.8.1)
-    pg (1.1.4)
-    power_assert (1.1.4)
-    public_suffix (4.0.6)
-    racc (1.6.1)
-    rack (2.2.4)
-    rack-test (2.0.2)
+    pg (1.5.4)
+    power_assert (2.0.3)
+    public_suffix (5.0.4)
+    racc (1.7.3)
+    rack (2.2.9)
+    rack-test (2.1.0)
       rack (>= 1.3)
-    rails (5.2.8.1)
-      actioncable (= 5.2.8.1)
-      actionmailer (= 5.2.8.1)
-      actionpack (= 5.2.8.1)
-      actionview (= 5.2.8.1)
-      activejob (= 5.2.8.1)
-      activemodel (= 5.2.8.1)
-      activerecord (= 5.2.8.1)
-      activestorage (= 5.2.8.1)
-      activesupport (= 5.2.8.1)
-      bundler (>= 1.3.0)
-      railties (= 5.2.8.1)
-      sprockets-rails (>= 2.0.0)
-    rails-controller-testing (1.0.4)
-      actionpack (>= 5.0.1.x)
-      actionview (>= 5.0.1.x)
-      activesupport (>= 5.0.1.x)
-    rails-dom-testing (2.0.3)
-      activesupport (>= 4.2.0)
+    rails (7.0.8.1)
+      actioncable (= 7.0.8.1)
+      actionmailbox (= 7.0.8.1)
+      actionmailer (= 7.0.8.1)
+      actionpack (= 7.0.8.1)
+      actiontext (= 7.0.8.1)
+      actionview (= 7.0.8.1)
+      activejob (= 7.0.8.1)
+      activemodel (= 7.0.8.1)
+      activerecord (= 7.0.8.1)
+      activestorage (= 7.0.8.1)
+      activesupport (= 7.0.8.1)
+      bundler (>= 1.15.0)
+      railties (= 7.0.8.1)
+    rails-controller-testing (1.0.5)
+      actionpack (>= 5.0.1.rc1)
+      actionview (>= 5.0.1.rc1)
+      activesupport (>= 5.0.1.rc1)
+    rails-dom-testing (2.2.0)
+      activesupport (>= 5.0.0)
+      minitest
       nokogiri (>= 1.6)
-    rails-html-sanitizer (1.4.4)
-      loofah (~> 2.19, >= 2.19.1)
+    rails-html-sanitizer (1.6.0)
+      loofah (~> 2.21)
+      nokogiri (~> 1.14)
     rails-observers (0.1.5)
       activemodel (>= 4.0)
     rails-perftest (0.0.7)
-    railties (5.2.8.1)
-      actionpack (= 5.2.8.1)
-      activesupport (= 5.2.8.1)
+    railties (7.0.8.1)
+      actionpack (= 7.0.8.1)
+      activesupport (= 7.0.8.1)
       method_source
-      rake (>= 0.8.7)
-      thor (>= 0.19.0, < 2.0)
-    rake (13.0.6)
-    rb-fsevent (0.10.3)
-    rb-inotify (0.9.10)
-      ffi (>= 0.5.0, < 2)
-    request_store (1.4.1)
+      rake (>= 12.2)
+      thor (~> 1.0)
+      zeitwerk (~> 2.5)
+    rake (13.2.1)
+    rb-fsevent (0.11.2)
+    rb-inotify (0.10.1)
+      ffi (~> 1.0)
+    request_store (1.5.1)
       rack (>= 1.4)
-    responders (2.4.1)
-      actionpack (>= 4.2.0, < 6.0)
-      railties (>= 4.2.0, < 6.0)
+    responders (3.1.0)
+      actionpack (>= 5.2)
+      railties (>= 5.2)
     retriable (1.4.1)
-    ruby-prof (0.15.9)
-    safe_yaml (1.0.5)
-    signet (0.11.0)
-      addressable (~> 2.3)
-      faraday (~> 0.9)
+    ruby-prof (1.6.3)
+    ruby2_keywords (0.0.5)
+    signet (0.16.1)
+      addressable (~> 2.8)
+      faraday (>= 0.17.5, < 3.0)
       jwt (>= 1.5, < 3.0)
       multi_json (~> 1.10)
-    simplecov (0.7.1)
-      multi_json (~> 1.0)
-      simplecov-html (~> 0.7.1)
-    simplecov-html (0.7.1)
-    simplecov-rcov (0.2.3)
+    simplecov (0.22.0)
+      docile (~> 1.1)
+      simplecov-html (~> 0.11)
+      simplecov_json_formatter (~> 0.1)
+    simplecov-html (0.12.3)
+    simplecov-rcov (0.3.1)
       simplecov (>= 0.4.1)
-    sprockets (3.7.2)
+    simplecov_json_formatter (0.1.4)
+    sprockets (4.2.1)
       concurrent-ruby (~> 1.0)
-      rack (> 1, < 3)
+      rack (>= 2.2.4, < 4)
     sprockets-rails (3.4.2)
       actionpack (>= 5.2)
       activesupport (>= 5.2)
       sprockets (>= 3.0.0)
-    sshkey (2.0.0)
-    test-unit (3.3.1)
+    test-unit (3.6.1)
       power_assert
-    thor (1.2.1)
-    thread_safe (0.3.6)
-    tzinfo (1.2.10)
-      thread_safe (~> 0.1)
-    websocket-driver (0.7.5)
+    thor (1.3.1)
+    timeout (0.4.1)
+    tzinfo (2.0.6)
+      concurrent-ruby (~> 1.0)
+    webrick (1.8.1)
+    websocket-driver (0.7.6)
       websocket-extensions (>= 0.1.0)
     websocket-extensions (0.1.5)
+    zeitwerk (2.6.13)
+    zlib (3.1.0)
 
 PLATFORMS
   ruby
@@ -234,14 +289,16 @@ PLATFORMS
 DEPENDENCIES
   acts_as_api
   andand
-  arvados (~> 2.1.5)
+  arvados (~> 2.7.0.rc1)
   byebug
   factory_bot_rails
   httpclient
+  i18n
   jquery-rails
   listen
   lograge
   logstash-event
+  mini_portile2 (~> 2.8, >= 2.8.1)
   minitest (= 5.10.3)
   mocha
   multi_json
@@ -249,20 +306,18 @@ DEPENDENCIES
   optimist
   passenger
   pg (~> 1.0)
-  rails (~> 5.2.0)
+  rails (~> 7.0.0)
   rails-controller-testing
   rails-observers
   rails-perftest
-  responders (~> 2.0)
-  ruby-prof (~> 0.15.0)
-  safe_yaml
-  signet (< 0.12)
-  simplecov (~> 0.7.1)
+  responders
+  ruby-prof
+  simplecov
   simplecov-rcov
-  sprockets (~> 3.0)
-  sshkey
-  test-unit (~> 3.0)
+  sprockets-rails
+  test-unit
   themes_for_rails!
+  webrick
 
 BUNDLED WITH
-   2.2.19
+   2.4.19
diff --git a/services/api/app/assets/config/manifest.js b/services/api/app/assets/config/manifest.js
new file mode 100644 (file)
index 0000000..ceb2338
--- /dev/null
@@ -0,0 +1,7 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+//= link_tree ../images
+//= link_directory ../javascripts .js
+//= link_directory ../stylesheets .css
index cf7271bbffa12bcf113a46ba7555010d125ef43a..b1e2a4008fe8943c29a08dcfc5be25e302dfc280 100644 (file)
@@ -29,6 +29,9 @@ class ApplicationController < ActionController::Base
   include DbCurrentTime
 
   respond_to :json
+
+  # Although CSRF protection is already enabled by default, this is
+  # still needed to reposition CSRF checks later in callback order.
   protect_from_forgery
 
   ERROR_ACTIONS = [:render_error, :render_not_found]
@@ -101,7 +104,7 @@ class ApplicationController < ActionController::Base
   end
 
   def show
-    send_json @object.as_api_response(nil, select: @select)
+    send_json @object.as_api_response(nil, select: select_for_klass(@select, model_class))
   end
 
   def create
@@ -120,7 +123,7 @@ class ApplicationController < ActionController::Base
     attrs_to_update = resource_attrs.reject { |k,v|
       [:kind, :etag, :href].index k
     }
-    @object.update_attributes! attrs_to_update
+    @object.update! attrs_to_update
     show
   end
 
@@ -228,6 +231,24 @@ class ApplicationController < ActionController::Base
     @objects = model_class.apply_filters(@objects, @filters)
   end
 
+  def select_for_klass sel, model_class, raise_unknown=true
+    return nil if sel.nil?
+    # Filter the select fields to only the ones that apply to the
+    # given class.
+    sel.map do |column|
+      sp = column.split(".")
+      if sp.length == 2 && sp[0] == model_class.table_name && model_class.selectable_attributes.include?(sp[1])
+        sp[1]
+      elsif model_class.selectable_attributes.include? column
+        column
+      elsif raise_unknown
+        raise ArgumentError.new("Invalid attribute '#{column}' of #{model_class.name} in select parameter")
+      else
+        nil
+      end
+    end.compact
+  end
+
   def apply_where_limit_order_params model_class=nil
     model_class ||= self.model_class
     apply_filters model_class
@@ -291,7 +312,7 @@ class ApplicationController < ActionController::Base
         # Map attribute names in @select to real column names, resolve
         # those to fully-qualified SQL column names, and pass the
         # resulting string to the select method.
-        columns_list = model_class.columns_for_attributes(@select).
+        columns_list = model_class.columns_for_attributes(select_for_klass @select, model_class).
           map { |s| "#{ar_table_name}.#{ActiveRecord::Base.connection.quote_column_name s}" }
         @objects = @objects.select(columns_list.join(", "))
       end
@@ -317,7 +338,7 @@ class ApplicationController < ActionController::Base
     return if @limit == 0 || @limit == 1
     model_class ||= self.model_class
     limit_columns = model_class.limit_index_columns_read
-    limit_columns &= model_class.columns_for_attributes(@select) if @select
+    limit_columns &= model_class.columns_for_attributes(select_for_klass @select, model_class) if @select
     return if limit_columns.empty?
     model_class.transaction do
       limit_query = @objects.
@@ -479,12 +500,23 @@ class ApplicationController < ActionController::Base
     @orders = []
     @filters = []
     @objects = nil
+
+    # This is a little hacky but sometimes the fields the user wants
+    # to selecting on are unrelated to the object being loaded here,
+    # for example groups#contents, so filter the fields that will be
+    # used in find_objects_for_index and then reset afterwards.  In
+    # some cases, code that modifies the @select list needs to set
+    # @preserve_select.
+    @preserve_select = @select
+    @select = select_for_klass(@select, self.model_class, false)
+
     find_objects_for_index
     if with_lock && Rails.configuration.API.LockBeforeUpdate
       @object = @objects.lock.first
     else
       @object = @objects.first
     end
+    @select = @preserve_select
   end
 
   def nullable_attributes
index 07b8098f5ba8749c7aff875ccf78347e745ecef4..f99a0a55a92671c0d455b6704f733551658a7fb4 100644 (file)
@@ -2,6 +2,8 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+require 'update_priorities'
+
 class Arvados::V1::ContainerRequestsController < ApplicationController
   accept_attribute_as_json :environment, Hash
   accept_attribute_as_json :mounts, Hash
@@ -28,4 +30,36 @@ class Arvados::V1::ContainerRequestsController < ApplicationController
         },
       })
   end
+
+  def self._container_status_requires_parameters
+    (super rescue {}).
+      merge({
+        uuid: {
+          type: 'string', required: true, description: "The UUID of the ContainerRequest in question.",
+        },
+      })
+  end
+
+  # This API is handled entirely by controller, so this method is
+  # never called -- it's only here for the sake of adding the API to
+  # the generated discovery document.
+  def container_status
+    send_json({"errors" => "controller-only API, not handled by rails"}, status: 400)
+  end
+
+  def update
+    if (resource_attrs.keys.map(&:to_sym) - [:owner_uuid, :name, :description, :properties]).empty? or @object.container_uuid.nil?
+      # If no attributes are being updated besides these, there are no
+      # cascading changes to other rows/tables, the only lock will be
+      # the single row lock on SQL UPDATE.
+      super
+    else
+      # Get locks ahead of time to avoid deadlock in cascading priority
+      # update
+      Container.transaction do
+        row_lock_for_priority_update @object.container_uuid
+        super
+      end
+    end
+  end
 end
index 20e7d6272e1e1c1d0deafa94272fddb39b755e6f..13aa478d26b15bc882adb6b38e4f012db5fa1145 100644 (file)
@@ -2,6 +2,8 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+require 'update_priorities'
+
 class Arvados::V1::ContainersController < ApplicationController
   accept_attribute_as_json :environment, Hash
   accept_attribute_as_json :mounts, Hash
@@ -29,8 +31,18 @@ class Arvados::V1::ContainersController < ApplicationController
   end
 
   def update
-    @object.with_lock do
+    if (resource_attrs.keys.map(&:to_sym) - [:cost, :gateway_address, :output_properties, :progress, :runtime_status]).empty?
+      # If no attributes are being updated besides these, there are no
+      # cascading changes to other rows/tables, the only lock will the
+      # single row lock on SQL UPDATE.
       super
+    else
+      Container.transaction do
+        # Get locks ahead of time to avoid deadlock in cascading priority
+        # update
+        row_lock_for_priority_update @object.uuid
+        super
+      end
     end
   end
 
@@ -39,11 +51,16 @@ class Arvados::V1::ContainersController < ApplicationController
     if action_name == 'lock' || action_name == 'unlock'
       # Avoid loading more fields than we need
       @objects = @objects.select(:id, :uuid, :state, :priority, :auth_uuid, :locked_by_uuid, :lock_count)
-      @select = %w(uuid state priority auth_uuid locked_by_uuid)
+      # This gets called from within find_object_by_uuid.
+      # find_object_by_uuid stores the original value of @select in
+      # @preserve_select, edits the value of @select, calls
+      # find_objects_for_index, then restores @select from the value
+      # of @preserve_select.  So if we want our updated value of
+      # @select here to stick, we have to set @preserve_select.
+      @select = @preserve_select = %w(uuid state priority auth_uuid locked_by_uuid)
     elsif action_name == 'update_priority'
-      # We're going to reload(lock: true) in the handler, which will
-      # select all attributes, but will fail if we don't select :id
-      # now.
+      # We're going to reload in update_priority!, which will select
+      # all attributes, but will fail if we don't select :id now.
       @objects = @objects.select(:id, :uuid)
     end
   end
@@ -59,7 +76,6 @@ class Arvados::V1::ContainersController < ApplicationController
   end
 
   def update_priority
-    @object.reload(lock: true)
     @object.update_priority!
     show
   end
index e9bc006a36664bb1a929bf72dccc9730fa9b049c..c362cf32d7e271c35891f10a33cf2f970503c09d 100644 (file)
@@ -46,7 +46,6 @@ class Arvados::V1::GroupsController < ApplicationController
                 type: 'boolean', required: false, default: false, description: 'Include past collection versions.',
               }
             })
-    params.delete(:select)
     params
   end
 
@@ -93,7 +92,7 @@ class Arvados::V1::GroupsController < ApplicationController
       attrs_to_update = resource_attrs.reject { |k, v|
         [:kind, :etag, :href].index k
       }.merge({async_permissions_update: true})
-      @object.update_attributes!(attrs_to_update)
+      @object.update!(attrs_to_update)
       @object.save!
       render_accepted
     else
@@ -260,6 +259,20 @@ class Arvados::V1::GroupsController < ApplicationController
       end
     end
 
+    # Check that any fields in @select are valid for at least one class
+    if @select
+      all_attributes = []
+      klasses.each do |klass|
+        all_attributes.concat klass.selectable_attributes
+      end
+      @select.each do |check|
+        if !all_attributes.include? check
+          raise ArgumentError.new "Invalid attribute '#{check}' in select"
+        end
+      end
+    end
+    any_selections = @select
+
     included_by_uuid = {}
 
     seen_last_class = false
@@ -291,14 +304,21 @@ class Arvados::V1::GroupsController < ApplicationController
         request_orders.andand.find { |r| r =~ /^#{klass.table_name}\./i || r !~ /\./ } ||
         klass.default_orders.join(", ")
 
-      @select = nil
+      @select = select_for_klass any_selections, klass, false
+
       where_conds = filter_by_owner
-      if klass == Collection
+      if klass == Collection && @select.nil?
         @select = klass.selectable_attributes - ["manifest_text", "unsigned_manifest_text"]
       elsif klass == Group
         where_conds = where_conds.merge(group_class: ["project","filter"])
       end
 
+      # Make signed manifest_text not selectable because controller
+      # currently doesn't know to sign it.
+      if @select
+        @select = @select - ["manifest_text"]
+      end
+
       @filters = request_filters.map do |col, op, val|
         if !col.index('.')
           [col, op, val]
index eb72b7096de8776b7d930739417d9d50dab789f9..2510fd49fa9f0860d25e7a5e5af238c872581b96 100644 (file)
@@ -37,7 +37,7 @@ class Arvados::V1::NodesController < ApplicationController
     attrs_to_update = resource_attrs.reject { |k,v|
       [:kind, :etag, :href].index k
     }
-    @object.update_attributes!(attrs_to_update)
+    @object.update!(attrs_to_update)
     @object.assign_slot if params[:assign_slot]
     @object.save!
     show
index 0300b750755ed89cc05de639d527391d9e24a039..74aa4078cbea66a6da3138cfd4a46f9ece81e350 100644 (file)
@@ -24,213 +24,212 @@ class Arvados::V1::SchemaController < ApplicationController
   protected
 
   def discovery_doc
-    Rails.cache.fetch 'arvados_v1_rest_discovery' do
-      Rails.application.eager_load!
-      remoteHosts = {}
-      Rails.configuration.RemoteClusters.each {|k,v| if k != :"*" then remoteHosts[k] = v["Host"] end }
-      discovery = {
-        kind: "discovery#restDescription",
-        discoveryVersion: "v1",
-        id: "arvados:v1",
-        name: "arvados",
-        version: "v1",
-        # format is YYYYMMDD, must be fixed width (needs to be lexically
-        # sortable), updated manually, may be used by clients to
-        # determine availability of API server features.
-        revision: "20220510",
-        source_version: AppVersion.hash,
-        sourceVersion: AppVersion.hash, # source_version should be deprecated in the future
-        packageVersion: AppVersion.package_version,
-        generatedAt: db_current_time.iso8601,
-        title: "Arvados API",
-        description: "The API to interact with Arvados.",
-        documentationLink: "http://doc.arvados.org/api/index.html",
-        defaultCollectionReplication: Rails.configuration.Collections.DefaultReplication,
-        protocol: "rest",
-        baseUrl: root_url + "arvados/v1/",
-        basePath: "/arvados/v1/",
-        rootUrl: root_url,
-        servicePath: "arvados/v1/",
-        batchPath: "batch",
-        uuidPrefix: Rails.configuration.ClusterID,
-        defaultTrashLifetime: Rails.configuration.Collections.DefaultTrashLifetime,
-        blobSignatureTtl: Rails.configuration.Collections.BlobSigningTTL,
-        maxRequestSize: Rails.configuration.API.MaxRequestSize,
-        maxItemsPerResponse: Rails.configuration.API.MaxItemsPerResponse,
-        dockerImageFormats: Rails.configuration.Containers.SupportedDockerImageFormats.keys,
-        crunchLogBytesPerEvent: Rails.configuration.Containers.Logging.LogBytesPerEvent,
-        crunchLogSecondsBetweenEvents: Rails.configuration.Containers.Logging.LogSecondsBetweenEvents,
-        crunchLogThrottlePeriod: Rails.configuration.Containers.Logging.LogThrottlePeriod,
-        crunchLogThrottleBytes: Rails.configuration.Containers.Logging.LogThrottleBytes,
-        crunchLogThrottleLines: Rails.configuration.Containers.Logging.LogThrottleLines,
-        crunchLimitLogBytesPerJob: Rails.configuration.Containers.Logging.LimitLogBytesPerJob,
-        crunchLogPartialLineThrottlePeriod: Rails.configuration.Containers.Logging.LogPartialLineThrottlePeriod,
-        crunchLogUpdatePeriod: Rails.configuration.Containers.Logging.LogUpdatePeriod,
-        crunchLogUpdateSize: Rails.configuration.Containers.Logging.LogUpdateSize,
-        remoteHosts: remoteHosts,
-        remoteHostsViaDNS: Rails.configuration.RemoteClusters["*"].Proxy,
-        websocketUrl: Rails.configuration.Services.Websocket.ExternalURL.to_s,
-        workbenchUrl: Rails.configuration.Services.Workbench1.ExternalURL.to_s,
-        workbench2Url: Rails.configuration.Services.Workbench2.ExternalURL.to_s,
-        keepWebServiceUrl: Rails.configuration.Services.WebDAV.ExternalURL.to_s,
-        gitUrl: Rails.configuration.Services.GitHTTP.ExternalURL.to_s,
-        parameters: {
-          alt: {
+    Rails.application.eager_load!
+    remoteHosts = {}
+    Rails.configuration.RemoteClusters.each {|k,v| if k != :"*" then remoteHosts[k] = v["Host"] end }
+    discovery = {
+      kind: "discovery#restDescription",
+      discoveryVersion: "v1",
+      id: "arvados:v1",
+      name: "arvados",
+      version: "v1",
+      # format is YYYYMMDD, must be fixed width (needs to be lexically
+      # sortable), updated manually, may be used by clients to
+      # determine availability of API server features.
+      revision: "20231117",
+      source_version: AppVersion.hash,
+      sourceVersion: AppVersion.hash, # source_version should be deprecated in the future
+      packageVersion: AppVersion.package_version,
+      generatedAt: db_current_time.iso8601,
+      title: "Arvados API",
+      description: "The API to interact with Arvados.",
+      documentationLink: "http://doc.arvados.org/api/index.html",
+      defaultCollectionReplication: Rails.configuration.Collections.DefaultReplication,
+      protocol: "rest",
+      baseUrl: root_url + "arvados/v1/",
+      basePath: "/arvados/v1/",
+      rootUrl: root_url,
+      servicePath: "arvados/v1/",
+      batchPath: "batch",
+      uuidPrefix: Rails.configuration.ClusterID,
+      defaultTrashLifetime: Rails.configuration.Collections.DefaultTrashLifetime,
+      blobSignatureTtl: Rails.configuration.Collections.BlobSigningTTL,
+      maxRequestSize: Rails.configuration.API.MaxRequestSize,
+      maxItemsPerResponse: Rails.configuration.API.MaxItemsPerResponse,
+      dockerImageFormats: Rails.configuration.Containers.SupportedDockerImageFormats.keys,
+      crunchLogBytesPerEvent: Rails.configuration.Containers.Logging.LogBytesPerEvent,
+      crunchLogSecondsBetweenEvents: Rails.configuration.Containers.Logging.LogSecondsBetweenEvents,
+      crunchLogThrottlePeriod: Rails.configuration.Containers.Logging.LogThrottlePeriod,
+      crunchLogThrottleBytes: Rails.configuration.Containers.Logging.LogThrottleBytes,
+      crunchLogThrottleLines: Rails.configuration.Containers.Logging.LogThrottleLines,
+      crunchLimitLogBytesPerJob: Rails.configuration.Containers.Logging.LimitLogBytesPerJob,
+      crunchLogPartialLineThrottlePeriod: Rails.configuration.Containers.Logging.LogPartialLineThrottlePeriod,
+      crunchLogUpdatePeriod: Rails.configuration.Containers.Logging.LogUpdatePeriod,
+      crunchLogUpdateSize: Rails.configuration.Containers.Logging.LogUpdateSize,
+      remoteHosts: remoteHosts,
+      remoteHostsViaDNS: Rails.configuration.RemoteClusters["*"].Proxy,
+      websocketUrl: Rails.configuration.Services.Websocket.ExternalURL.to_s,
+      workbenchUrl: Rails.configuration.Services.Workbench1.ExternalURL.to_s,
+      workbench2Url: Rails.configuration.Services.Workbench2.ExternalURL.to_s,
+      keepWebServiceUrl: Rails.configuration.Services.WebDAV.ExternalURL.to_s,
+      gitUrl: Rails.configuration.Services.GitHTTP.ExternalURL.to_s,
+      parameters: {
+        alt: {
+          type: "string",
+          description: "Data format for the response.",
+          default: "json",
+          enum: [
+            "json"
+          ],
+          enumDescriptions: [
+            "Responses with Content-Type of application/json"
+          ],
+          location: "query"
+        },
+        fields: {
+          type: "string",
+          description: "Selector specifying which fields to include in a partial response.",
+          location: "query"
+        },
+        key: {
+          type: "string",
+          description: "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.",
+          location: "query"
+        },
+        oauth_token: {
+          type: "string",
+          description: "OAuth 2.0 token for the current user.",
+          location: "query"
+        }
+      },
+      auth: {
+        oauth2: {
+          scopes: {
+            "https://api.arvados.org/auth/arvados" => {
+              description: "View and manage objects"
+            },
+            "https://api.arvados.org/auth/arvados.readonly" => {
+              description: "View objects"
+            }
+          }
+        }
+      },
+      schemas: {},
+      resources: {}
+    }
+
+    ActiveRecord::Base.descendants.reject(&:abstract_class?).sort_by(&:to_s).each do |k|
+      begin
+        ctl_class = "Arvados::V1::#{k.to_s.pluralize}Controller".constantize
+      rescue
+        # No controller -> no discovery.
+        next
+      end
+      object_properties = {}
+      k.columns.
+        select { |col| k.selectable_attributes.include? col.name }.
+        collect do |col|
+        if k.serialized_attributes.has_key? col.name
+          object_properties[col.name] = {
+            type: k.serialized_attributes[col.name].object_class.to_s
+          }
+        elsif k.attribute_types[col.name].is_a? JsonbType::Hash
+          object_properties[col.name] = {
+            type: Hash.to_s
+          }
+        elsif k.attribute_types[col.name].is_a? JsonbType::Array
+          object_properties[col.name] = {
+            type: Array.to_s
+          }
+        else
+          object_properties[col.name] = {
+            type: col.type
+          }
+        end
+      end
+      discovery[:schemas][k.to_s + 'List'] = {
+        id: k.to_s + 'List',
+        description: k.to_s + ' list',
+        type: "object",
+        properties: {
+          kind: {
             type: "string",
-            description: "Data format for the response.",
-            default: "json",
-            enum: [
-                   "json"
-                  ],
-            enumDescriptions: [
-                               "Responses with Content-Type of application/json"
-                              ],
-            location: "query"
+            description: "Object type. Always arvados##{k.to_s.camelcase(:lower)}List.",
+            default: "arvados##{k.to_s.camelcase(:lower)}List"
+          },
+          etag: {
+            type: "string",
+            description: "List version."
+          },
+          items: {
+            type: "array",
+            description: "The list of #{k.to_s.pluralize}.",
+            items: {
+              "$ref" => k.to_s
+            }
           },
-          fields: {
+          next_link: {
             type: "string",
-            description: "Selector specifying which fields to include in a partial response.",
-            location: "query"
+            description: "A link to the next page of #{k.to_s.pluralize}."
           },
-          key: {
+          next_page_token: {
             type: "string",
-            description: "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.",
-            location: "query"
+            description: "The page token for the next page of #{k.to_s.pluralize}."
           },
-          oauth_token: {
+          selfLink: {
             type: "string",
-            description: "OAuth 2.0 token for the current user.",
-            location: "query"
+            description: "A link back to this list."
           }
-        },
-        auth: {
-          oauth2: {
-            scopes: {
-              "https://api.arvados.org/auth/arvados" => {
-                description: "View and manage objects"
-              },
-              "https://api.arvados.org/auth/arvados.readonly" => {
-                description: "View objects"
-              }
-            }
+        }
+      }
+      discovery[:schemas][k.to_s] = {
+        id: k.to_s,
+        description: k.to_s,
+        type: "object",
+        uuidPrefix: (k.respond_to?(:uuid_prefix) ? k.uuid_prefix : nil),
+        properties: {
+          uuid: {
+            type: "string",
+            description: "Object ID."
+          },
+          etag: {
+            type: "string",
+            description: "Object version."
           }
-        },
-        schemas: {},
-        resources: {}
+        }.merge(object_properties)
       }
-
-      ActiveRecord::Base.descendants.reject(&:abstract_class?).each do |k|
-        begin
-          ctl_class = "Arvados::V1::#{k.to_s.pluralize}Controller".constantize
-        rescue
-          # No controller -> no discovery.
-          next
-        end
-        object_properties = {}
-        k.columns.
-          select { |col| col.name != 'id' && !col.name.start_with?('secret_') }.
-          collect do |col|
-          if k.serialized_attributes.has_key? col.name
-            object_properties[col.name] = {
-              type: k.serialized_attributes[col.name].object_class.to_s
-            }
-          elsif k.attribute_types[col.name].is_a? JsonbType::Hash
-            object_properties[col.name] = {
-              type: Hash.to_s
-            }
-          elsif k.attribute_types[col.name].is_a? JsonbType::Array
-            object_properties[col.name] = {
-              type: Array.to_s
-            }
-          else
-            object_properties[col.name] = {
-              type: col.type
-            }
-          end
-        end
-        discovery[:schemas][k.to_s + 'List'] = {
-          id: k.to_s + 'List',
-          description: k.to_s + ' list',
-          type: "object",
-          properties: {
-            kind: {
-              type: "string",
-              description: "Object type. Always arvados##{k.to_s.camelcase(:lower)}List.",
-              default: "arvados##{k.to_s.camelcase(:lower)}List"
-            },
-            etag: {
-              type: "string",
-              description: "List version."
-            },
-            items: {
-              type: "array",
-              description: "The list of #{k.to_s.pluralize}.",
-              items: {
-                "$ref" => k.to_s
+      discovery[:resources][k.to_s.underscore.pluralize] = {
+        methods: {
+          get: {
+            id: "arvados.#{k.to_s.underscore.pluralize}.get",
+            path: "#{k.to_s.underscore.pluralize}/{uuid}",
+            httpMethod: "GET",
+            description: "Gets a #{k.to_s}'s metadata by UUID.",
+            parameters: {
+              uuid: {
+                type: "string",
+                description: "The UUID of the #{k.to_s} in question.",
+                required: true,
+                location: "path"
               }
             },
-            next_link: {
-              type: "string",
-              description: "A link to the next page of #{k.to_s.pluralize}."
-            },
-            next_page_token: {
-              type: "string",
-              description: "The page token for the next page of #{k.to_s.pluralize}."
-            },
-            selfLink: {
-              type: "string",
-              description: "A link back to this list."
-            }
-          }
-        }
-        discovery[:schemas][k.to_s] = {
-          id: k.to_s,
-          description: k.to_s,
-          type: "object",
-          uuidPrefix: (k.respond_to?(:uuid_prefix) ? k.uuid_prefix : nil),
-          properties: {
-            uuid: {
-              type: "string",
-              description: "Object ID."
-            },
-            etag: {
-              type: "string",
-              description: "Object version."
-            }
-          }.merge(object_properties)
-        }
-        discovery[:resources][k.to_s.underscore.pluralize] = {
-          methods: {
-            get: {
-              id: "arvados.#{k.to_s.underscore.pluralize}.get",
-              path: "#{k.to_s.underscore.pluralize}/{uuid}",
-              httpMethod: "GET",
-              description: "Gets a #{k.to_s}'s metadata by UUID.",
-              parameters: {
-                uuid: {
-                  type: "string",
-                  description: "The UUID of the #{k.to_s} in question.",
-                  required: true,
-                  location: "path"
-                }
-              },
-              parameterOrder: [
-                               "uuid"
-                              ],
-              response: {
-                "$ref" => k.to_s
-              },
-              scopes: [
-                       "https://api.arvados.org/auth/arvados",
-                       "https://api.arvados.org/auth/arvados.readonly"
-                      ]
+            parameterOrder: [
+              "uuid"
+            ],
+            response: {
+              "$ref" => k.to_s
             },
-            index: {
-              id: "arvados.#{k.to_s.underscore.pluralize}.index",
-              path: k.to_s.underscore.pluralize,
-              httpMethod: "GET",
-              description:
-                 %|Index #{k.to_s.pluralize}.
+            scopes: [
+              "https://api.arvados.org/auth/arvados",
+              "https://api.arvados.org/auth/arvados.readonly"
+            ]
+          },
+          index: {
+            id: "arvados.#{k.to_s.underscore.pluralize}.index",
+            path: k.to_s.underscore.pluralize,
+            httpMethod: "GET",
+            description:
+              %|Index #{k.to_s.pluralize}.
 
                    The <code>index</code> method returns a
                    <a href="/api/resources.html">resource list</a> of
@@ -251,243 +250,242 @@ class Arvados::V1::SchemaController < ApplicationController
                      "request_time":0.157236317
                     }
                     </pre>|,
-              parameters: {
-              },
-              response: {
-                "$ref" => "#{k.to_s}List"
-              },
-              scopes: [
-                       "https://api.arvados.org/auth/arvados",
-                       "https://api.arvados.org/auth/arvados.readonly"
-                      ]
+            parameters: {
             },
-            create: {
-              id: "arvados.#{k.to_s.underscore.pluralize}.create",
-              path: "#{k.to_s.underscore.pluralize}",
-              httpMethod: "POST",
-              description: "Create a new #{k.to_s}.",
-              parameters: {},
-              request: {
-                required: true,
-                properties: {
-                  k.to_s.underscore => {
-                    "$ref" => k.to_s
-                  }
-                }
-              },
-              response: {
-                "$ref" => k.to_s
-              },
-              scopes: [
-                       "https://api.arvados.org/auth/arvados"
-                      ]
+            response: {
+              "$ref" => "#{k.to_s}List"
             },
-            update: {
-              id: "arvados.#{k.to_s.underscore.pluralize}.update",
-              path: "#{k.to_s.underscore.pluralize}/{uuid}",
-              httpMethod: "PUT",
-              description: "Update attributes of an existing #{k.to_s}.",
-              parameters: {
-                uuid: {
-                  type: "string",
-                  description: "The UUID of the #{k.to_s} in question.",
-                  required: true,
-                  location: "path"
+            scopes: [
+              "https://api.arvados.org/auth/arvados",
+              "https://api.arvados.org/auth/arvados.readonly"
+            ]
+          },
+          create: {
+            id: "arvados.#{k.to_s.underscore.pluralize}.create",
+            path: "#{k.to_s.underscore.pluralize}",
+            httpMethod: "POST",
+            description: "Create a new #{k.to_s}.",
+            parameters: {},
+            request: {
+              required: true,
+              properties: {
+                k.to_s.underscore => {
+                  "$ref" => k.to_s
                 }
-              },
-              request: {
+              }
+            },
+            response: {
+              "$ref" => k.to_s
+            },
+            scopes: [
+              "https://api.arvados.org/auth/arvados"
+            ]
+          },
+          update: {
+            id: "arvados.#{k.to_s.underscore.pluralize}.update",
+            path: "#{k.to_s.underscore.pluralize}/{uuid}",
+            httpMethod: "PUT",
+            description: "Update attributes of an existing #{k.to_s}.",
+            parameters: {
+              uuid: {
+                type: "string",
+                description: "The UUID of the #{k.to_s} in question.",
                 required: true,
-                properties: {
-                  k.to_s.underscore => {
-                    "$ref" => k.to_s
-                  }
+                location: "path"
+              }
+            },
+            request: {
+              required: true,
+              properties: {
+                k.to_s.underscore => {
+                  "$ref" => k.to_s
                 }
-              },
+              }
+            },
+            response: {
+              "$ref" => k.to_s
+            },
+            scopes: [
+              "https://api.arvados.org/auth/arvados"
+            ]
+          },
+          delete: {
+            id: "arvados.#{k.to_s.underscore.pluralize}.delete",
+            path: "#{k.to_s.underscore.pluralize}/{uuid}",
+            httpMethod: "DELETE",
+            description: "Delete an existing #{k.to_s}.",
+            parameters: {
+              uuid: {
+                type: "string",
+                description: "The UUID of the #{k.to_s} in question.",
+                required: true,
+                location: "path"
+              }
+            },
+            response: {
+              "$ref" => k.to_s
+            },
+            scopes: [
+              "https://api.arvados.org/auth/arvados"
+            ]
+          }
+        }
+      }
+      # Check for Rails routes that don't match the usual actions
+      # listed above
+      d_methods = discovery[:resources][k.to_s.underscore.pluralize][:methods]
+      Rails.application.routes.routes.each do |route|
+        action = route.defaults[:action]
+        httpMethod = ['GET', 'POST', 'PUT', 'DELETE'].map { |method|
+          method if route.verb.match(method)
+        }.compact.first
+        if httpMethod and
+          route.defaults[:controller] == 'arvados/v1/' + k.to_s.underscore.pluralize and
+          ctl_class.action_methods.include? action
+          if !d_methods[action.to_sym]
+            method = {
+              id: "arvados.#{k.to_s.underscore.pluralize}.#{action}",
+              path: route.path.spec.to_s.sub('/arvados/v1/','').sub('(.:format)','').sub(/:(uu)?id/,'{uuid}'),
+              httpMethod: httpMethod,
+              description: "#{action} #{k.to_s.underscore.pluralize}",
+              parameters: {},
               response: {
-                "$ref" => k.to_s
+                "$ref" => (action == 'index' ? "#{k.to_s}List" : k.to_s)
               },
               scopes: [
-                       "https://api.arvados.org/auth/arvados"
-                      ]
-            },
-            delete: {
-              id: "arvados.#{k.to_s.underscore.pluralize}.delete",
-              path: "#{k.to_s.underscore.pluralize}/{uuid}",
-              httpMethod: "DELETE",
-              description: "Delete an existing #{k.to_s}.",
-              parameters: {
-                uuid: {
+                "https://api.arvados.org/auth/arvados"
+              ]
+            }
+            route.segment_keys.each do |key|
+              if key != :format
+                key = :uuid if key == :id
+                method[:parameters][key] = {
                   type: "string",
-                  description: "The UUID of the #{k.to_s} in question.",
+                  description: "",
                   required: true,
                   location: "path"
                 }
-              },
-              response: {
-                "$ref" => k.to_s
-              },
-              scopes: [
-                       "https://api.arvados.org/auth/arvados"
-                      ]
-            }
-          }
-        }
-        # Check for Rails routes that don't match the usual actions
-        # listed above
-        d_methods = discovery[:resources][k.to_s.underscore.pluralize][:methods]
-        Rails.application.routes.routes.each do |route|
-          action = route.defaults[:action]
-          httpMethod = ['GET', 'POST', 'PUT', 'DELETE'].map { |method|
-            method if route.verb.match(method)
-          }.compact.first
-          if httpMethod and
-              route.defaults[:controller] == 'arvados/v1/' + k.to_s.underscore.pluralize and
-              ctl_class.action_methods.include? action
-            if !d_methods[action.to_sym]
-              method = {
-                id: "arvados.#{k.to_s.underscore.pluralize}.#{action}",
-                path: route.path.spec.to_s.sub('/arvados/v1/','').sub('(.:format)','').sub(/:(uu)?id/,'{uuid}'),
-                httpMethod: httpMethod,
-                description: "#{action} #{k.to_s.underscore.pluralize}",
-                parameters: {},
-                response: {
-                  "$ref" => (action == 'index' ? "#{k.to_s}List" : k.to_s)
-                },
-                scopes: [
-                         "https://api.arvados.org/auth/arvados"
-                        ]
-              }
-              route.segment_keys.each do |key|
-                if key != :format
-                  key = :uuid if key == :id
-                  method[:parameters][key] = {
-                    type: "string",
-                    description: "",
-                    required: true,
-                    location: "path"
-                  }
-                end
               end
-            else
-              # We already built a generic method description, but we
-              # might find some more required parameters through
-              # introspection.
-              method = d_methods[action.to_sym]
             end
-            if ctl_class.respond_to? "_#{action}_requires_parameters".to_sym
-              ctl_class.send("_#{action}_requires_parameters".to_sym).each do |l, v|
-                if v.is_a? Hash
-                  method[:parameters][l] = v
-                else
-                  method[:parameters][l] = {}
-                end
-                if !method[:parameters][l][:default].nil?
-                  # The JAVA SDK is sensitive to all values being strings
-                  method[:parameters][l][:default] = method[:parameters][l][:default].to_s
-                end
-                method[:parameters][l][:type] ||= 'string'
-                method[:parameters][l][:description] ||= ''
-                method[:parameters][l][:location] = (route.segment_keys.include?(l) ? 'path' : 'query')
-                if method[:parameters][l][:required].nil?
-                  method[:parameters][l][:required] = v != false
-                end
+          else
+            # We already built a generic method description, but we
+            # might find some more required parameters through
+            # introspection.
+            method = d_methods[action.to_sym]
+          end
+          if ctl_class.respond_to? "_#{action}_requires_parameters".to_sym
+            ctl_class.send("_#{action}_requires_parameters".to_sym).each do |l, v|
+              if v.is_a? Hash
+                method[:parameters][l] = v
+              else
+                method[:parameters][l] = {}
+              end
+              if !method[:parameters][l][:default].nil?
+                # The JAVA SDK is sensitive to all values being strings
+                method[:parameters][l][:default] = method[:parameters][l][:default].to_s
+              end
+              method[:parameters][l][:type] ||= 'string'
+              method[:parameters][l][:description] ||= ''
+              method[:parameters][l][:location] = (route.segment_keys.include?(l) ? 'path' : 'query')
+              if method[:parameters][l][:required].nil?
+                method[:parameters][l][:required] = v != false
               end
             end
-            d_methods[action.to_sym] = method
+          end
+          d_methods[action.to_sym] = method
 
-            if action == 'index'
-              list_method = method.dup
-              list_method[:id].sub!('index', 'list')
-              list_method[:description].sub!('Index', 'List')
-              list_method[:description].sub!('index', 'list')
-              d_methods[:list] = list_method
-            end
+          if action == 'index'
+            list_method = method.dup
+            list_method[:id].sub!('index', 'list')
+            list_method[:description].sub!('Index', 'List')
+            list_method[:description].sub!('index', 'list')
+            d_methods[:list] = list_method
           end
         end
       end
+    end
 
-      # The 'replace_files' option is implemented in lib/controller,
-      # not Rails -- we just need to add it here so discovery-aware
-      # clients know how to validate it.
-      [:create, :update].each do |action|
-        discovery[:resources]['collections'][:methods][action][:parameters]['replace_files'] = {
-          type: 'object',
-          description: 'Files and directories to initialize/replace with content from other collections.',
-          required: false,
-          location: 'query',
-          properties: {},
-          additionalProperties: {type: 'string'},
-        }
-      end
+    # The 'replace_files' option is implemented in lib/controller,
+    # not Rails -- we just need to add it here so discovery-aware
+    # clients know how to validate it.
+    [:create, :update].each do |action|
+      discovery[:resources]['collections'][:methods][action][:parameters]['replace_files'] = {
+        type: 'object',
+        description: 'Files and directories to initialize/replace with content from other collections.',
+        required: false,
+        location: 'query',
+        properties: {},
+        additionalProperties: {type: 'string'},
+      }
+    end
 
-      discovery[:resources]['configs'] = {
-        methods: {
-          get: {
-            id: "arvados.configs.get",
-            path: "config",
-            httpMethod: "GET",
-            description: "Get public config",
-            parameters: {
-            },
-            parameterOrder: [
-            ],
-            response: {
-            },
-            scopes: [
-              "https://api.arvados.org/auth/arvados",
-              "https://api.arvados.org/auth/arvados.readonly"
-            ]
+    discovery[:resources]['configs'] = {
+      methods: {
+        get: {
+          id: "arvados.configs.get",
+          path: "config",
+          httpMethod: "GET",
+          description: "Get public config",
+          parameters: {
           },
-        }
+          parameterOrder: [
+          ],
+          response: {
+          },
+          scopes: [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
       }
+    }
 
-      discovery[:resources]['vocabularies'] = {
-        methods: {
-          get: {
-            id: "arvados.vocabularies.get",
-            path: "vocabulary",
-            httpMethod: "GET",
-            description: "Get vocabulary definition",
-            parameters: {
-            },
-            parameterOrder: [
-            ],
-            response: {
-            },
-            scopes: [
-              "https://api.arvados.org/auth/arvados",
-              "https://api.arvados.org/auth/arvados.readonly"
-            ]
+    discovery[:resources]['vocabularies'] = {
+      methods: {
+        get: {
+          id: "arvados.vocabularies.get",
+          path: "vocabulary",
+          httpMethod: "GET",
+          description: "Get vocabulary definition",
+          parameters: {
           },
-        }
+          parameterOrder: [
+          ],
+          response: {
+          },
+          scopes: [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
       }
+    }
 
-      discovery[:resources]['sys'] = {
-        methods: {
-          get: {
-            id: "arvados.sys.trash_sweep",
-            path: "sys/trash_sweep",
-            httpMethod: "POST",
-            description: "apply scheduled trash and delete operations",
-            parameters: {
-            },
-            parameterOrder: [
-            ],
-            response: {
-            },
-            scopes: [
-              "https://api.arvados.org/auth/arvados",
-              "https://api.arvados.org/auth/arvados.readonly"
-            ]
+    discovery[:resources]['sys'] = {
+      methods: {
+        get: {
+          id: "arvados.sys.trash_sweep",
+          path: "sys/trash_sweep",
+          httpMethod: "POST",
+          description: "apply scheduled trash and delete operations",
+          parameters: {
           },
-        }
+          parameterOrder: [
+          ],
+          response: {
+          },
+          scopes: [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        },
       }
+    }
 
-      Rails.configuration.API.DisabledAPIs.each do |method, _|
-        ctrl, action = method.to_s.split('.', 2)
-        discovery[:resources][ctrl][:methods].delete(action.to_sym)
-      end
-      discovery
+    Rails.configuration.API.DisabledAPIs.each do |method, _|
+      ctrl, action = method.to_s.split('.', 2)
+      discovery[:resources][ctrl][:methods].delete(action.to_sym)
     end
+    discovery
   end
 end
index 507cb4ac339fe5fd63fbbf7fb3013411fc44b5e9..031dd2e4f92ba7c1764756027cef95db0afa5714 100644 (file)
@@ -16,37 +16,13 @@ class Arvados::V1::UsersController < ApplicationController
   # records from LoginCluster.
   def batch_update
     @objects = []
-    params[:updates].andand.each do |uuid, attrs|
-      begin
-        u = User.find_or_create_by(uuid: uuid)
-      rescue ActiveRecord::RecordNotUnique
-        retry
-      end
-      needupdate = {}
-      nullify_attrs(attrs).each do |k,v|
-        if !v.nil? && u.send(k) != v
-          needupdate[k] = v
-        end
-      end
-      if needupdate.length > 0
-        begin
-          u.update_attributes!(needupdate)
-        rescue ActiveRecord::RecordInvalid
-          loginCluster = Rails.configuration.Login.LoginCluster
-          if u.uuid[0..4] == loginCluster && !needupdate[:username].nil?
-            local_user = User.find_by_username(needupdate[:username])
-            # A cached user record from the LoginCluster is stale, reset its username
-            # and retry the update operation.
-            if local_user.andand.uuid[0..4] == loginCluster && local_user.uuid != u.uuid
-              new_username = "#{needupdate[:username]}conflict#{rand(99999999)}"
-              Rails.logger.warn("cached username '#{needupdate[:username]}' collision with user '#{local_user.uuid}' - renaming to '#{new_username}' before retrying")
-              local_user.update_attributes!({username: new_username})
-              retry
-            end
-          end
-          raise # Not the issue we're handling above
-        end
-      end
+    # update_remote_user takes a row lock on the User record, so sort
+    # the keys so we always lock them in the same order.
+    sorted = params[:updates].keys.sort
+    sorted.each do |uuid|
+      attrs = params[:updates][uuid]
+      attrs[:uuid] = uuid
+      u = User.update_remote_user nullify_attrs(attrs)
       @objects << u
     end
     @offset = 0
@@ -103,7 +79,7 @@ class Arvados::V1::UsersController < ApplicationController
           collect(&:head_uuid)
         todo_uuids = required_uuids - signed_uuids
         if todo_uuids.empty?
-          @object.update_attributes is_active: true
+          @object.update is_active: true
           logger.info "User #{@object.uuid} activated"
         else
           logger.warn "User #{@object.uuid} called users.activate " +
@@ -274,7 +250,7 @@ class Arvados::V1::UsersController < ApplicationController
     return super if @read_users.any?(&:is_admin)
     if params[:uuid] != current_user.andand.uuid
       # Non-admin index/show returns very basic information about readable users.
-      safe_attrs = ["uuid", "is_active", "email", "first_name", "last_name", "username", "can_write", "can_manage"]
+      safe_attrs = ["uuid", "is_active", "is_admin", "is_invited", "email", "first_name", "last_name", "username", "can_write", "can_manage", "kind"]
       if @select
         @select = @select & safe_attrs
       else
@@ -282,6 +258,13 @@ class Arvados::V1::UsersController < ApplicationController
       end
       @filters += [['is_active', '=', true]]
     end
+    # This gets called from within find_object_by_uuid.
+    # find_object_by_uuid stores the original value of @select in
+    # @preserve_select, edits the value of @select, calls
+    # find_objects_for_index, then restores @select from the value
+    # of @preserve_select.  So if we want our updated value of
+    # @select here to stick, we have to set @preserve_select.
+    @preserve_select = @select
     super
   end
 
index 69453959d262a792b7f09edca6b6557e8a5d8a4b..8e61d16fa8686d9ffb2373efae8bc6a502696b3c 100644 (file)
@@ -18,10 +18,10 @@ class DatabaseController < ApplicationController
     user_uuids = User.
       where('email is null or (email not like ? and email not like ?)', '%@example.com', '%.example.com').
       collect(&:uuid)
-    fixture_uuids =
-      YAML::load_file(File.expand_path('../../../test/fixtures/users.yml',
-                                       __FILE__)).
-      values.collect { |u| u['uuid'] }
+    fnm = File.expand_path('../../../test/fixtures/users.yml', __FILE__)
+    fixture_uuids = File.open(fnm) do |f|
+      YAML.safe_load(f, filename: fnm, permitted_classes: [Time]).values.collect { |u| u['uuid'] }
+    end
     unexpected_uuids = user_uuids - fixture_uuids
     if unexpected_uuids.any?
       logger.error("Running in test environment, but non-fixture users exist: " +
@@ -61,7 +61,7 @@ class DatabaseController < ApplicationController
         ActiveRecord::FixtureSet.
           create_fixtures(Rails.root.join('test', 'fixtures'), fixturesets)
 
-        # Dump cache of permissions etc.
+        # Reset cache and global state
         Rails.cache.clear
         ActiveRecord::Base.connection.clear_query_cache
 
index 4b2b985e023679b783bb358543eac8d7c8f54829..b7693f342055c00a7431c589d52afc91d2be86d2 100644 (file)
@@ -13,7 +13,7 @@ class StaticController < ApplicationController
     respond_to do |f|
       f.html do
         if !Rails.configuration.Services.Workbench1.ExternalURL.to_s.empty?
-          redirect_to Rails.configuration.Services.Workbench1.ExternalURL.to_s
+          redirect_to Rails.configuration.Services.Workbench1.ExternalURL.to_s, allow_other_host: true
         else
           render_not_found "Oops, this is an API endpoint. You probably want to point your browser to an Arvados Workbench site instead."
         end
index ae34fa76006aabe6c7866cee09d8249f58254567..0c67c9c9d8fdebb77cc1328ab3d0dce24391e4a2 100644 (file)
@@ -11,24 +11,33 @@ class UserSessionsController < ApplicationController
 
   respond_to :html
 
+  def login
+    return send_error "Legacy code path no longer supported", status: 404
+  end
+
+  def logout
+    return send_error "Legacy code path no longer supported", status: 404
+  end
+
   # create a new session
   def create
-    if !Rails.configuration.Login.LoginCluster.empty? and Rails.configuration.Login.LoginCluster != Rails.configuration.ClusterID
-      raise "Local login disabled when LoginCluster is set"
-    end
-
-    max_expires_at = nil
-    if params[:provider] == 'controller'
-      if request.headers['Authorization'] != 'Bearer ' + Rails.configuration.SystemRootToken
-        return send_error('Invalid authorization header', status: 401)
-      end
-      # arvados-controller verified the user and is passing auth_info
-      # in request params.
-      authinfo = SafeJSON.load(params[:auth_info])
-      max_expires_at = authinfo["expires_at"]
-    else
+    remote, return_to_url = params[:return_to].split(',', 2)
+    if params[:provider] != 'controller' ||
+       return_to_url != 'https://controller.api.client.invalid'
       return send_error "Legacy code path no longer supported", status: 404
     end
+    if request.headers['Authorization'] != 'Bearer ' + Rails.configuration.SystemRootToken
+      return send_error('Invalid authorization header', status: 401)
+    end
+    if remote == ''
+      remote = nil
+    elsif remote !~ /^[0-9a-z]{5}$/
+      return send_error 'Invalid remote cluster id', status: 400
+    end
+    # arvados-controller verified the user and is passing auth_info
+    # in request params.
+    authinfo = SafeJSON.load(params[:auth_info])
+    max_expires_at = authinfo["expires_at"]
 
     if !authinfo['user_uuid'].blank?
       user = User.find_by_uuid(authinfo['user_uuid'])
@@ -49,40 +58,13 @@ class UserSessionsController < ApplicationController
     # For the benefit of functional and integration tests:
     @user = user
 
-    if user.uuid[0..4] != Rails.configuration.ClusterID
-      # Actually a remote user
-      # Send them to their home cluster's login
-      rh = Rails.configuration.RemoteClusters[user.uuid[0..4]]
-      remote, return_to_url = params[:return_to].split(',', 2)
-      @remotehomeurl = "#{rh.Scheme || "https"}://#{rh.Host}/login?remote=#{Rails.configuration.ClusterID}&return_to=#{return_to_url}"
-      render
-      return
-    end
-
     # prevent ArvadosModel#before_create and _update from throwing
     # "unauthorized":
     Thread.current[:user] = user
 
     user.save or raise Exception.new(user.errors.messages)
 
-    # Give the authenticated user a cookie for direct API access
-    session[:user_id] = user.id
-    session[:api_client_uuid] = nil
-    session[:api_client_trusted] = true # full permission to see user's secrets
-
-    @redirect_to = root_path
-    if params.has_key?(:return_to)
-      # return_to param's format is 'remote,return_to_url'. This comes from login()
-      # encoding the remote=zbbbb parameter passed by a client asking for a salted
-      # token.
-      remote, return_to_url = params[:return_to].split(',', 2)
-      if remote !~ /^[0-9a-z]{5}$/ && remote != ""
-        return send_error 'Invalid remote cluster id', status: 400
-      end
-      remote = nil if remote == ''
-      return send_api_token_to(return_to_url, user, remote, max_expires_at)
-    end
-    redirect_to @redirect_to
+    return send_api_token_to(return_to_url, user, remote, max_expires_at)
   end
 
   # Omniauth failure callback
@@ -90,52 +72,6 @@ class UserSessionsController < ApplicationController
     flash[:notice] = params[:message]
   end
 
-  # logout - this gets intercepted by controller, so this is probably
-  # mostly dead code at this point.
-  def logout
-    session[:user_id] = nil
-
-    flash[:notice] = 'You have logged off'
-    return_to = params[:return_to] || root_url
-    redirect_to return_to
-  end
-
-  # login.  Redirect to LoginCluster.
-  def login
-    if params[:remote] !~ /^[0-9a-z]{5}$/ && !params[:remote].nil?
-      return send_error 'Invalid remote cluster id', status: 400
-    end
-    if current_user and params[:return_to]
-      # Already logged in; just need to send a token to the requesting
-      # API client.
-      #
-      # FIXME: if current_user has never authorized this app before,
-      # ask for confirmation here!
-
-      return send_api_token_to(params[:return_to], current_user, params[:remote])
-    end
-    p = []
-    p << "auth_provider=#{CGI.escape(params[:auth_provider])}" if params[:auth_provider]
-
-    if !Rails.configuration.Login.LoginCluster.empty? and Rails.configuration.Login.LoginCluster != Rails.configuration.ClusterID
-      host = ApiClientAuthorization.remote_host(uuid_prefix: Rails.configuration.Login.LoginCluster)
-      if not host
-        raise "LoginCluster #{Rails.configuration.Login.LoginCluster} missing from RemoteClusters"
-      end
-      scheme = "https"
-      cluster = Rails.configuration.RemoteClusters[Rails.configuration.Login.LoginCluster]
-      if cluster and cluster['Scheme'] and !cluster['Scheme'].empty?
-        scheme = cluster['Scheme']
-      end
-      login_cluster = "#{scheme}://#{host}"
-      p << "remote=#{CGI.escape(params[:remote])}" if params[:remote]
-      p << "return_to=#{CGI.escape(params[:return_to])}" if params[:return_to]
-      redirect_to "#{login_cluster}/login?#{p.join('&')}"
-    else
-      return send_error "Legacy code path no longer supported", status: 404
-    end
-  end
-
   def send_api_token_to(callback_url, user, remote=nil, token_expiration=nil)
     # Give the API client a token for making API calls on behalf of
     # the authenticated user
@@ -173,7 +109,7 @@ class UserSessionsController < ApplicationController
       token = @api_client_auth.salted_token(remote: remote)
     end
     callback_url += 'api_token=' + token
-    redirect_to callback_url
+    redirect_to callback_url, allow_other_host: true
   end
 
   def cross_origin_forbidden
index 2c240984c6ee07fdf01760582f0e5a35855f81da..18140e57fe045cbc5711e6098fea581f119f8c4a 100644 (file)
@@ -42,18 +42,35 @@ class ArvadosApiToken
     # reader_tokens.
     accepted = false
     auth = nil
+    remote_errcodes = []
+    remote_errmsgs = []
     [params["api_token"],
      params["oauth_token"],
      env["HTTP_AUTHORIZATION"].andand.match(/(OAuth2|Bearer) ([!-~]+)/).andand[2],
      *reader_tokens,
     ].each do |supplied|
       next if !supplied
-      try_auth = ApiClientAuthorization.
-                 validate(token: supplied, remote: remote)
-      if try_auth.andand.user
-        auth = try_auth
-        accepted = supplied
-        break
+      begin
+        try_auth = ApiClientAuthorization.validate(token: supplied, remote: remote)
+      rescue => e
+        begin
+          remote_errcodes.append(e.http_status)
+        rescue NoMethodError
+          # The exception is an internal validation problem, not a remote error.
+          next
+        end
+        begin
+          errors = SafeJSON.load(e.res.content)["errors"]
+        rescue
+          errors = nil
+        end
+        remote_errmsgs += errors if errors.is_a?(Array)
+      else
+        if try_auth.andand.user
+          auth = try_auth
+          accepted = supplied
+          break
+        end
       end
     end
 
@@ -64,6 +81,24 @@ class ArvadosApiToken
     Thread.current[:token] = accepted
     Thread.current[:user] = auth.andand.user
 
-    @app.call env if @app
+    if auth.nil? and not remote_errcodes.empty?
+      # If we failed to validate any tokens because of remote validation
+      # errors, pass those on to the client. This code is functionally very
+      # similar to ApplicationController#render_error, but the implementation
+      # is very different because we're a Rack middleware, not in
+      # ActionDispatch land yet.
+      remote_errmsgs.prepend("failed to validate remote token")
+      error_content = {
+        error_token: "%d+%08x" % [Time.now.utc.to_i, rand(16 ** 8)],
+        errors: remote_errmsgs,
+      }
+      [
+        remote_errcodes.max,
+        {"Content-Type": "application/json"},
+        SafeJSON.dump(error_content).html_safe,
+      ]
+    else
+      @app.call env if @app
+    end
   end
 end
index 55a4c6706c7ccb802f50bdd8a2c2fbe3cee4fdee..791b9716802eb7eaa4ec35a197f6f94072a8b45c 100644 (file)
@@ -32,7 +32,13 @@ class ApiClient < ArvadosModel
     end
 
     Rails.configuration.Login.TrustedClients.keys.each do |url|
-      if norm_url_prefix == norm(url)
+      trusted = norm(url)
+      if norm_url_prefix == trusted
+        return true
+      end
+      if trusted.host.to_s.starts_with?("*.") &&
+         norm_url_prefix.to_s.starts_with?(trusted.scheme + "://") &&
+         norm_url_prefix.to_s.ends_with?(trusted.to_s[trusted.scheme.length + 4...])
         return true
       end
     end
@@ -49,6 +55,8 @@ class ApiClient < ArvadosModel
       url.port = "80"
     end
     url.path = "/"
+    url.query = nil
+    url.fragment = nil
     url
   end
 end
index 52922d32b1868fdb53d8bcd3f1197d149a93bb63..83112786764d64377f3e6b7983fb1e3310ca59db 100644 (file)
@@ -6,11 +6,12 @@ class ApiClientAuthorization < ArvadosModel
   include HasUuid
   include KindAndEtag
   include CommonApiTemplate
+  include Rails.application.routes.url_helpers
   extend CurrentApiClient
   extend DbCurrentTime
 
-  belongs_to :api_client
-  belongs_to :user
+  belongs_to :api_client, optional: true
+  belongs_to :user, optional: true
   after_initialize :assign_random_api_token
   serialize :scopes, Array
 
@@ -78,7 +79,9 @@ class ApiClientAuthorization < ArvadosModel
 
   def scopes_allow_request?(request)
     method = request.request_method
-    if method == 'HEAD'
+    if method == 'GET' and request.path == url_for(controller: 'arvados/v1/api_client_authorizations', action: 'current', only_path: true)
+      true
+    elsif method == 'HEAD'
       (scopes_allow?(['HEAD', request.path].join(' ')) ||
        scopes_allow?(['GET', request.path].join(' ')))
     else
@@ -271,136 +274,110 @@ class ApiClientAuthorization < ArvadosModel
       Rails.logger.warn "remote authentication rejected: no host for #{upstream_cluster_id.inspect}"
       return nil
     end
+    remote_url = URI::parse("https://#{host}/")
+    remote_query = {"remote" => Rails.configuration.ClusterID}
+    remote_headers = {"Authorization" => "Bearer #{token}"}
 
-    begin
-      remote_user = SafeJSON.load(
-        clnt.get_content('https://' + host + '/arvados/v1/users/current',
-                         {'remote' => Rails.configuration.ClusterID},
-                         {'Authorization' => 'Bearer ' + token}))
-    rescue => e
-      Rails.logger.warn "remote authentication with token #{token.inspect} failed: #{e}"
-      return nil
-    end
-
-    # Check the response is well formed.
-    if !remote_user.is_a?(Hash) || !remote_user['uuid'].is_a?(String)
-      Rails.logger.warn "remote authentication rejected: remote_user=#{remote_user.inspect}"
-      return nil
-    end
-
-    remote_user_prefix = remote_user['uuid'][0..4]
-
-    # Get token scope, and make sure we use the same UUID as the
-    # remote when caching the token.
+    # First get the current token. This query is not limited by token scopes,
+    # and tells us the user's UUID via owner_uuid, so this gives us enough
+    # information to load a local user record from the database if one exists.
     remote_token = nil
     begin
       remote_token = SafeJSON.load(
-        clnt.get_content('https://' + host + '/arvados/v1/api_client_authorizations/current',
-                         {'remote' => Rails.configuration.ClusterID},
-                         {'Authorization' => 'Bearer ' + token}))
+        clnt.get_content(
+          remote_url.merge("arvados/v1/api_client_authorizations/current"),
+          remote_query, remote_headers,
+        ))
       Rails.logger.debug "retrieved remote token #{remote_token.inspect}"
       token_uuid = remote_token['uuid']
       if !token_uuid.match(HasUuid::UUID_REGEX) || token_uuid[0..4] != upstream_cluster_id
         raise "remote cluster #{upstream_cluster_id} returned invalid token uuid #{token_uuid.inspect}"
       end
     rescue HTTPClient::BadResponseError => e
-      if e.res.status != 401
-        raise
+      if e.res.status_code >= 400 && e.res.status_code < 500
+        # Remote cluster does not accept this token.
+        return nil
       end
-      rev = SafeJSON.load(clnt.get_content('https://' + host + '/discovery/v1/apis/arvados/v1/rest'))['revision']
-      if rev >= '20010101' && rev < '20210503'
-        Rails.logger.warn "remote cluster #{upstream_cluster_id} at #{host} with api rev #{rev} does not provide token expiry and scopes; using scopes=['all']"
-      else
-        # remote server is new enough that it should have accepted
-        # this request if the token was valid
-        raise
+      # CurrentApiToken#call and ApplicationController#render_error will
+      # propagate the status code from the #http_status method, so define
+      # that here.
+      def e.http_status
+        self.res.status_code
       end
+      raise
+    # TODO #20927: Catch network exceptions and assign a 5xx status to them so
+    # the client knows they're a temporary problem.
     rescue => e
       Rails.logger.warn "error getting remote token details for #{token.inspect}: #{e}"
       return nil
     end
 
-    # Clusters can only authenticate for their own users.
-    if remote_user_prefix != upstream_cluster_id
-      Rails.logger.warn "remote authentication rejected: claimed remote user #{remote_user_prefix} but token was issued by #{upstream_cluster_id}"
+    # Next, load the token's user record from the database (might be nil).
+    remote_user_prefix, remote_user_suffix = remote_token['owner_uuid'].split('-', 2)
+    if anonymous_user_uuid.end_with?(remote_user_suffix)
+      # Special case: map the remote anonymous user to local anonymous user
+      remote_user_uuid = anonymous_user_uuid
+    else
+      remote_user_uuid = remote_token['owner_uuid']
+    end
+    user = User.find_by_uuid(remote_user_uuid)
+
+    # Next, try to load the remote user. If this succeeds, we'll use this
+    # information to update/create the local database record as necessary.
+    # If this fails for any reason, but we successfully loaded a user record
+    # from the database, we'll just rely on that information.
+    remote_user = nil
+    begin
+      remote_user = SafeJSON.load(
+        clnt.get_content(
+          remote_url.merge("arvados/v1/users/current"),
+          remote_query, remote_headers,
+        ))
+    rescue HTTPClient::BadResponseError => e
+      # If user is defined, we will use that alone for auth, see below.
+      if user.nil?
+        # See rationale in the previous BadResponseError rescue.
+        def e.http_status
+          self.res.status_code
+        end
+        raise
+      end
+    # TODO #20927: Catch network exceptions and assign a 5xx status to them so
+    # the client knows they're a temporary problem.
+    rescue => e
+      Rails.logger.warn "getting remote user with token #{token.inspect} failed: #{e}"
+    else
+      # Check the response is well formed.
+      if !remote_user.is_a?(Hash) || !remote_user['uuid'].is_a?(String)
+        Rails.logger.warn "malformed remote user=#{remote_user.inspect}"
+        remote_user = nil
+      # Clusters can only authenticate for their own users.
+      elsif remote_user_prefix != upstream_cluster_id
+        Rails.logger.warn "remote user rejected: claimed remote user #{remote_user_prefix} but token was issued by #{upstream_cluster_id}"
+        remote_user = nil
+      # Force our local copy of a remote root to have a static name
+      elsif system_user_uuid.end_with?(remote_user_suffix)
+        remote_user.update(
+          "first_name" => "root",
+          "last_name" => "from cluster #{remote_user_prefix}",
+        )
+      end
+    end
+
+    if user.nil? and remote_user.nil?
+      Rails.logger.warn "remote token #{token.inspect} rejected: cannot get owner #{remote_user_uuid} from database or remote cluster"
       return nil
     end
 
     # Invariant:    remote_user_prefix == upstream_cluster_id
     # therefore:    remote_user_prefix != Rails.configuration.ClusterID
-
     # Add or update user and token in local database so we can
     # validate subsequent requests faster.
 
-    if remote_user['uuid'][-22..-1] == '-tpzed-anonymouspublic'
-      # Special case: map the remote anonymous user to local anonymous user
-      remote_user['uuid'] = anonymous_user_uuid
-    end
-
-    user = User.find_by_uuid(remote_user['uuid'])
-
-    if !user
-      # Create a new record for this user.
-      user = User.new(uuid: remote_user['uuid'],
-                      is_active: false,
-                      is_admin: false,
-                      email: remote_user['email'],
-                      owner_uuid: system_user_uuid)
-      user.set_initial_username(requested: remote_user['username'])
-    end
-
-    # Sync user record.
     act_as_system_user do
-      %w[first_name last_name email prefs].each do |attr|
-        user.send(attr+'=', remote_user[attr])
-      end
-
-      if remote_user['uuid'][-22..-1] == '-tpzed-000000000000000'
-        user.first_name = "root"
-        user.last_name = "from cluster #{remote_user_prefix}"
-      end
-
-      begin
-        user.save!
-      rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
-        Rails.logger.debug("remote user #{remote_user['uuid']} already exists, retrying...")
-        # Some other request won the race: retry fetching the user record.
-        user = User.find_by_uuid(remote_user['uuid'])
-        if !user
-          Rails.logger.warn("cannot find or create remote user #{remote_user['uuid']}")
-          return nil
-        end
-      end
-
-      if user.is_invited && !remote_user['is_invited']
-        # Remote user is not "invited" state, they should be unsetup, which
-        # also makes them inactive.
-        user.unsetup
-      else
-        if !user.is_invited && remote_user['is_invited'] and
-          (remote_user_prefix == Rails.configuration.Login.LoginCluster or
-           Rails.configuration.Users.AutoSetupNewUsers or
-           Rails.configuration.Users.NewUsersAreActive or
-           Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
-          user.setup
-        end
-
-        if !user.is_active && remote_user['is_active'] && user.is_invited and
-          (remote_user_prefix == Rails.configuration.Login.LoginCluster or
-           Rails.configuration.Users.NewUsersAreActive or
-           Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
-          user.update_attributes!(is_active: true)
-        elsif user.is_active && !remote_user['is_active']
-          user.update_attributes!(is_active: false)
-        end
-
-        if remote_user_prefix == Rails.configuration.Login.LoginCluster and
-          user.is_active and
-          user.is_admin != remote_user['is_admin']
-          # Remote cluster controls our user database, including the
-          # admin flag.
-          user.update_attributes!(is_admin: remote_user['is_admin'])
-        end
+      if remote_user && remote_user_uuid != anonymous_user_uuid
+        # Sync user record if we loaded a remote user.
+        user = User.update_remote_user remote_user
       end
 
       # If stored_secret is set, we save stored_secret in the database
@@ -426,15 +403,24 @@ class ApiClientAuthorization < ArvadosModel
         end
       rescue ActiveRecord::RecordNotUnique
         Rails.logger.debug("cached remote token #{token_uuid} already exists, retrying...")
-        # Some other request won the race: retry just once before erroring out
-        if (retries += 1) <= 1
+        # Another request won the race (trying to find_or_create the
+        # same token UUID) ...and/or... there is an expired entry with
+        # the same secret but a different UUID (e.g., the token is an
+        # OIDC access token and [a] our database has an expired cached
+        # row that was not used above, and [b] the remote cluster had
+        # deleted its expired cached row so it assigned a new UUID).
+        #
+        # Delete any conflicting row if any. Retry twice (in case we
+        # hit both of those situations at once), then give up.
+        if (retries += 1) <= 2
+          ApiClientAuthorization.where('api_token=? and uuid<>?', stored_secret, token_uuid).delete_all
           retry
         else
           Rails.logger.warn("cannot find or create cached remote token #{token_uuid}")
           return nil
         end
       end
-      auth.update_attributes!(user: user,
+      auth.update!(user: user,
                               api_token: stored_secret,
                               api_client_id: 0,
                               scopes: scopes,
index 33f950de3aede5b5b609436394561f256f8edaca..9ee2cca410effaba81fbb4ce9d207354c5f1b3f8 100644 (file)
@@ -24,6 +24,7 @@ class ArvadosModel < ApplicationRecord
   before_destroy :ensure_owner_uuid_is_permitted
   before_destroy :ensure_permission_to_destroy
   before_create :update_modified_by_fields
+  before_create :add_uuid_to_name, :if => Proc.new { @_add_uuid_to_name }
   before_update :maybe_update_modified_by_fields
   after_create :log_create
   after_update :log_update
@@ -37,9 +38,9 @@ class ArvadosModel < ApplicationRecord
   # user.uuid==object.owner_uuid.
   has_many(:permissions,
            ->{where(link_class: 'permission')},
-           foreign_key: :head_uuid,
+           foreign_key: 'head_uuid',
            class_name: 'Link',
-           primary_key: :uuid)
+           primary_key: 'uuid')
 
   # If async is true at create or update, permission graph
   # update is deferred allowing making multiple calls without the performance
@@ -145,7 +146,7 @@ class ArvadosModel < ApplicationRecord
     super(permit_attribute_params(raw_params), *args)
   end
 
-  def update_attributes raw_params={}, *args
+  def update raw_params={}, *args
     super(self.class.permit_attribute_params(raw_params), *args)
   end
 
@@ -156,7 +157,7 @@ class ArvadosModel < ApplicationRecord
   end
 
   def self.searchable_columns operator
-    textonly_operator = !operator.match(/[<=>]/)
+    textonly_operator = !operator.match(/[<=>]/) && !operator.in?(['in', 'not in'])
     self.columns.select do |col|
       case col.type
       when :string, :text
@@ -464,14 +465,13 @@ class ArvadosModel < ApplicationRecord
       end
     end
 
+    return self if sql_conds == nil
     self.where(sql_conds,
                user_uuids: all_user_uuids.collect{|c| c["target_uuid"]},
                permission_link_classes: ['permission'])
   end
 
   def save_with_unique_name!
-    uuid_was = uuid
-    name_was = name
     max_retries = 2
     transaction do
       conn = ActiveRecord::Base.connection
@@ -502,24 +502,20 @@ class ArvadosModel < ApplicationRecord
 
         conn.exec_query 'ROLLBACK TO SAVEPOINT save_with_unique_name'
 
-        new_name = "#{name_was} (#{db_current_time.utc.iso8601(3)})"
-        if new_name == name
-          # If the database is fast enough to do two attempts in the
-          # same millisecond, we need to wait to ensure we try a
-          # different timestamp on each attempt.
-          sleep 0.002
-          new_name = "#{name_was} (#{db_current_time.utc.iso8601(3)})"
-        end
-
-        self[:name] = new_name
-        if uuid_was.nil? && !uuid.nil?
+        if uuid_was.nil?
+          # new record, the uuid caused a name collision (very
+          # unlikely but possible), so generate new uuid
           self[:uuid] = nil
           if self.is_a? Collection
-            # Reset so that is assigned to the new UUID
+            # Also needs to be reset
             self[:current_version_uuid] = nil
           end
+          # need to adjust the name after the uuid has been generated
+          add_uuid_to_make_unique_name
+        else
+          # existing record, just update the name directly.
+          add_uuid_to_name
         end
-
         retry
       end
     end
@@ -580,6 +576,26 @@ class ArvadosModel < ApplicationRecord
                           *ft[:param_out])
   end
 
+  @_add_uuid_to_name = false
+  def add_uuid_to_make_unique_name
+    @_add_uuid_to_name = true
+  end
+
+  def add_uuid_to_name
+    # Incorporate the random part of the UUID into the name.  This
+    # lets us prevent name collision but the part we add to the name
+    # is still somewhat meaningful (instead of generating a second
+    # random meaningless string).
+    #
+    # Because ArvadosModel is an abstract class and assign_uuid is
+    # part of HasUuid (which is included by the other concrete
+    # classes) the assign_uuid hook gets added (and run) after this
+    # one.  So we need to call assign_uuid here to make sure we have a
+    # uuid.
+    assign_uuid
+    self.name = "#{self.name[0..236]} (#{self.uuid[-15..-1]})"
+  end
+
   protected
 
   def self.deep_sort_hash(x)
@@ -938,8 +954,6 @@ class ArvadosModel < ApplicationRecord
   # hook.
   def fill_container_defaults_after_find
     fill_container_defaults
-    set_attribute_was('runtime_constraints', runtime_constraints)
-    set_attribute_was('scheduling_parameters', scheduling_parameters)
     clear_changes_information
   end
 
index a5c5081c40afc97ee40e88e1cd5d2247d6117444..8aefa8db8114bb8493300fd52d2d52ee42ec3ee4 100644 (file)
@@ -9,7 +9,11 @@ class AuthorizedKey < ArvadosModel
   before_create :permission_to_set_authorized_user_uuid
   before_update :permission_to_set_authorized_user_uuid
 
-  belongs_to :authorized_user, :foreign_key => :authorized_user_uuid, :class_name => 'User', :primary_key => :uuid
+  belongs_to :authorized_user,
+             foreign_key: 'authorized_user_uuid',
+             class_name: 'User',
+             primary_key: 'uuid',
+             optional: true
 
   validate :public_key_must_be_unique
 
@@ -37,17 +41,11 @@ class AuthorizedKey < ArvadosModel
 
   def public_key_must_be_unique
     if self.public_key
-      valid_key = SSHKey.valid_ssh_public_key? self.public_key
-
-      if not valid_key
-        errors.add(:public_key, "does not appear to be a valid ssh-rsa or dsa public key")
-      else
-        # Valid if no other rows have this public key
-        if self.class.where('uuid != ? and public_key like ?',
-                            uuid || '', "%#{self.public_key}%").any?
-          errors.add(:public_key, "already exists in the database, use a different key.")
-          return false
-        end
+      # Valid if no other rows have this public key
+      if self.class.where('uuid != ? and public_key like ?',
+                          uuid || '', "%#{self.public_key}%").any?
+        errors.add(:public_key, "already exists in the database, use a different key.")
+        return false
       end
     end
     return true
index b4660dbd355de72261d4584977b88533f77f829e..16e85c0dd9af575d93fc1aeda8279e798b60e56c 100644 (file)
@@ -329,17 +329,7 @@ class Collection < ArvadosModel
   end
 
   def sync_past_versions
-    updates = self.syncable_updates
-    Collection.where('current_version_uuid = ? AND uuid != ?', self.uuid_before_last_save, self.uuid_before_last_save).each do |c|
-      c.attributes = updates
-      # Use a different validation context to skip the 'past_versions_cannot_be_updated'
-      # validator, as on this case it is legal to update some fields.
-      leave_modified_by_user_alone do
-        leave_modified_at_alone do
-          c.save(context: :update_old_versions)
-        end
-      end
-    end
+    Collection.where('current_version_uuid = ? AND uuid != ?', self.uuid_before_last_save, self.uuid_before_last_save).update_all self.syncable_updates
   end
 
   def versionable_updates?(attrs)
index 0eaf640b6c37fda7ccf0379f02fb683f4f5eabe2..ee338b81ffedad646854cd4b55998937551ffa59 100644 (file)
@@ -5,6 +5,7 @@
 require 'log_reuse_info'
 require 'whitelist_update'
 require 'safe_json'
+require 'update_priorities'
 
 class Container < ArvadosModel
   include ArvadosModelUpdates
@@ -49,10 +50,16 @@ class Container < ArvadosModel
   before_save :clear_runtime_status_when_queued
   after_save :update_cr_logs
   after_save :handle_completed
-  after_save :propagate_priority
 
-  has_many :container_requests, :foreign_key => :container_uuid, :class_name => 'ContainerRequest', :primary_key => :uuid
-  belongs_to :auth, :class_name => 'ApiClientAuthorization', :foreign_key => :auth_uuid, :primary_key => :uuid
+  has_many :container_requests,
+           class_name: 'ContainerRequest',
+           foreign_key: 'container_uuid',
+           primary_key: 'uuid'
+  belongs_to :auth,
+             class_name: 'ApiClientAuthorization',
+             foreign_key: 'auth_uuid',
+             primary_key: 'uuid',
+             optional: true
 
   api_accessible :user, extend: :common do |t|
     t.add :command
@@ -129,34 +136,8 @@ class Container < ArvadosModel
   # priority of a user-submitted request is a function of
   # user-assigned priority and request creation time.
   def update_priority!
-    return if ![Queued, Locked, Running].include?(state)
-    p = ContainerRequest.
-          where('container_uuid=? and priority>0', uuid).
-          select("priority, requesting_container_uuid, created_at").
-          lock(true).
-          map do |cr|
-      if cr.requesting_container_uuid
-        Container.where(uuid: cr.requesting_container_uuid).pluck(:priority).first
-      else
-        (cr.priority << 50) - (cr.created_at.to_time.to_f * 1000).to_i
-      end
-    end.max || 0
-    update_attributes!(priority: p)
-  end
-
-  def propagate_priority
-    return true unless saved_change_to_priority?
-    act_as_system_user do
-      # Update the priority of child container requests to match new
-      # priority of the parent container (ignoring requests with no
-      # container assigned, because their priority doesn't matter).
-      ContainerRequest.
-        where('requesting_container_uuid = ? and state = ? and container_uuid is not null',
-              self.uuid, ContainerRequest::Committed).
-        pluck(:container_uuid).each do |container_uuid|
-        Container.find_by_uuid(container_uuid).update_priority!
-      end
-    end
+    update_priorities uuid
+    reload
   end
 
   # Create a new container (or find an existing one) to satisfy the
@@ -338,7 +319,7 @@ class Container < ArvadosModel
         resolved_runtime_constraints.delete('cuda')
       ].uniq
     end
-    reusable_runtime_constraints = hash_product(runtime_constraint_variations)
+    reusable_runtime_constraints = hash_product(**runtime_constraint_variations)
                                      .map { |v| resolved_runtime_constraints.merge(v) }
 
     candidates = candidates.where_serialized(:runtime_constraints, reusable_runtime_constraints, md5: true, multivalue: true)
@@ -369,7 +350,7 @@ class Container < ArvadosModel
     # Check for non-failing Running candidates and return the most likely to finish sooner.
     log_reuse_info { "checking for state=Running..." }
     running = candidates.where(state: Running).
-              where("(runtime_status->'error') is null").
+              where("(runtime_status->'error') is null and priority > 0").
               order('progress desc, started_at asc').
               limit(1).first
     if running
@@ -383,10 +364,15 @@ class Container < ArvadosModel
     locked_or_queued = candidates.
                        where("state IN (?)", [Locked, Queued]).
                        order('state asc, priority desc, created_at asc').
-                       limit(1).first
-    if locked_or_queued
-      log_reuse_info { "done, reusing container #{locked_or_queued.uuid} with state=#{locked_or_queued.state}" }
-      return locked_or_queued
+                       limit(1)
+    if !attrs[:scheduling_parameters]['preemptible']
+      locked_or_queued = locked_or_queued.
+                           where("not ((scheduling_parameters::jsonb)->>'preemptible')::boolean")
+    end
+    chosen = locked_or_queued.first
+    if chosen
+      log_reuse_info { "done, reusing container #{chosen.uuid} with state=#{chosen.state}" }
+      return chosen
     else
       log_reuse_info { "have no containers in Locked or Queued state" }
     end
@@ -400,7 +386,7 @@ class Container < ArvadosModel
       if self.state != Queued
         raise LockFailedError.new("cannot lock when #{self.state}")
       end
-      self.update_attributes!(state: Locked)
+      self.update!(state: Locked)
     end
   end
 
@@ -418,7 +404,7 @@ class Container < ArvadosModel
       if self.state != Locked
         raise InvalidStateTransitionError.new("cannot unlock when #{self.state}")
       end
-      self.update_attributes!(state: Queued)
+      self.update!(state: Queued)
     end
   end
 
@@ -645,7 +631,7 @@ class Container < ArvadosModel
     # each requesting CR.
     return if self.final? || !saved_change_to_log?
     leave_modified_by_user_alone do
-      ContainerRequest.where(container_uuid: self.uuid).each do |cr|
+      ContainerRequest.where(container_uuid: self.uuid, state: ContainerRequest::Committed).each do |cr|
         cr.update_collections(container: self, collections: ['log'])
         cr.save!
       end
@@ -663,7 +649,7 @@ class Container < ArvadosModel
       # ensure the token doesn't validate later in the same
       # transaction (e.g., in a test case) by satisfying expires_at >
       # transaction timestamp.
-      self.auth.andand.update_attributes(expires_at: db_transaction_time)
+      self.auth.andand.update(expires_at: db_transaction_time)
       self.auth = nil
       return
     elsif self.auth
@@ -756,7 +742,22 @@ class Container < ArvadosModel
       self.with_lock do
         act_as_system_user do
           if self.state == Cancelled
-            retryable_requests = ContainerRequest.where("container_uuid = ? and priority > 0 and state = 'Committed' and container_count < container_count_max", uuid)
+            # Cancelled means the container didn't run to completion.
+            # This happens either because it was cancelled by the user
+            # or because there was an infrastructure failure.  We want
+            # to retry infrastructure failures automatically.
+            #
+            # Seach for live container requests to determine if we
+            # should retry the container.
+            retryable_requests = ContainerRequest.
+                                   joins('left outer join containers as requesting_container on container_requests.requesting_container_uuid = requesting_container.uuid').
+                                   where("container_requests.container_uuid = ? and "+
+                                         "container_requests.priority > 0 and "+
+                                         "container_requests.owner_uuid not in (select group_uuid from trashed_groups) and "+
+                                         "(requesting_container.priority is null or (requesting_container.state = 'Running' and requesting_container.priority > 0)) and "+
+                                         "container_requests.state = 'Committed' and "+
+                                         "container_requests.container_count < container_requests.container_count_max", uuid).
+                                   order('container_requests.uuid asc')
           else
             retryable_requests = []
           end
@@ -841,7 +842,7 @@ class Container < ArvadosModel
                 # Queued with priority 0.  (OTOH, if the child is already
                 # running, leave it alone so it can get cancelled the
                 # usual way, get a copy of the log collection, etc.)
-                cr.update_attributes!(state: ContainerRequest::Final)
+                cr.update!(state: ContainerRequest::Final)
               end
             end
           end
index 09da141eae67e3cab6f8d964f819d6fd26cad017..f5789f31f684f89b2ac553de09cc199dfba005aa 100644 (file)
@@ -12,12 +12,15 @@ class ContainerRequest < ArvadosModel
   include CommonApiTemplate
   include WhitelistUpdate
 
-  belongs_to :container, foreign_key: :container_uuid, primary_key: :uuid
-  belongs_to :requesting_container, {
-               class_name: 'Container',
-               foreign_key: :requesting_container_uuid,
-               primary_key: :uuid,
-             }
+  belongs_to :container,
+             foreign_key: 'container_uuid',
+             primary_key: 'uuid',
+             optional: true
+  belongs_to :requesting_container,
+             class_name: 'Container',
+             foreign_key: 'requesting_container_uuid',
+             primary_key: 'uuid',
+             optional: true
 
   # Posgresql JSONB columns should NOT be declared as serialized, Rails 5
   # already know how to properly treat them.
@@ -164,7 +167,7 @@ class ContainerRequest < ArvadosModel
         end
       elsif state == Committed
         # Behave as if the container is cancelled
-        update_attributes!(state: Final)
+        update!(state: Final)
       end
       return true
     end
@@ -228,10 +231,17 @@ class ContainerRequest < ArvadosModel
         end
       end
     end
-    update_attributes!(state: Final)
+    update!(state: Final)
   end
 
   def update_collections(container:, collections: ['log', 'output'])
+
+    # Check if parent is frozen or trashed, in which case it isn't
+    # valid to create new collections in the project, so return
+    # without creating anything.
+    owner = Group.find_by_uuid(self.owner_uuid)
+    return if owner && !owner.admin_change_permitted
+
     collections.each do |out_type|
       pdh = container.send(out_type)
       next if pdh.nil?
@@ -301,7 +311,7 @@ class ContainerRequest < ArvadosModel
   end
 
   def set_priority_zero
-    self.update_attributes!(priority: 0) if self.priority > 0 && self.state != Final
+    self.update!(priority: 0) if self.priority > 0 && self.state != Final
   end
 
   protected
@@ -460,8 +470,9 @@ class ContainerRequest < ArvadosModel
 
   def validate_scheduling_parameters
     if self.state == Committed
-      if scheduling_parameters.include? 'partitions' and
-         (!scheduling_parameters['partitions'].is_a?(Array) ||
+      if scheduling_parameters.include?('partitions') and
+        !scheduling_parameters['partitions'].nil? and
+        (!scheduling_parameters['partitions'].is_a?(Array) ||
           scheduling_parameters['partitions'].reject{|x| !x.is_a?(String)}.size !=
             scheduling_parameters['partitions'].size)
             errors.add :scheduling_parameters, "partitions must be an array of strings"
@@ -562,11 +573,8 @@ class ContainerRequest < ArvadosModel
 
   def update_priority
     return unless saved_change_to_state? || saved_change_to_priority? || saved_change_to_container_uuid?
-    act_as_system_user do
-      Container.
-        where('uuid in (?)', [container_uuid_before_last_save, self.container_uuid].compact).
-        map(&:update_priority!)
-    end
+    update_priorities container_uuid_before_last_save if !container_uuid_before_last_save.nil? and container_uuid_before_last_save != self.container_uuid
+    update_priorities self.container_uuid if self.container_uuid
   end
 
   def set_requesting_container_uuid
index 85855fda97271a2cbfc855fef5d0862fa2a7122e..d4c81fe9d1d9cf2c558d644bf45bf2f495dc9ef6 100644 (file)
@@ -4,6 +4,7 @@
 
 require 'can_be_an_owner'
 require 'trashable'
+require 'update_priorities'
 
 class Group < ArvadosModel
   include HasUuid
@@ -48,12 +49,19 @@ class Group < ArvadosModel
     t.add :can_manage
   end
 
+  # check if admins are allowed to make changes to the project, e.g. it
+  # isn't trashed or frozen.
+  def admin_change_permitted
+    !(FrozenGroup.where(uuid: self.uuid).any? || TrashedGroup.where(group_uuid: self.uuid).any?)
+  end
+
   protected
 
   def self.attributes_required_columns
     super.merge(
                 'can_write' => ['owner_uuid', 'uuid'],
                 'can_manage' => ['owner_uuid', 'uuid'],
+                'writable_by' => ['owner_uuid', 'uuid'],
                 )
   end
 
@@ -155,56 +163,70 @@ class Group < ArvadosModel
     #   Remove groups that don't belong from trash
     #   Add/update groups that do belong in the trash
 
-    temptable = "group_subtree_#{rand(2**64).to_s(10)}"
-    ActiveRecord::Base.connection.exec_query(
-      "create temporary table #{temptable} on commit drop " +
-      "as select * from project_subtree_with_trash_at($1, LEAST($2, $3)::timestamp)",
+    frozen_descendants = ActiveRecord::Base.connection.exec_query(%{
+with temptable as (select * from project_subtree_with_trash_at($1, LEAST($2, $3)::timestamp))
+      select uuid from frozen_groups, temptable where uuid = target_uuid
+},
       "Group.update_trash.select",
-      [[nil, self.uuid],
-       [nil, TrashedGroup.find_by_group_uuid(self.owner_uuid).andand.trash_at],
-       [nil, self.trash_at]])
-    frozen_descendants = ActiveRecord::Base.connection.exec_query(
-      "select uuid from frozen_groups, #{temptable} where uuid = target_uuid",
-      "Group.update_trash.check_frozen")
+      [self.uuid,
+       TrashedGroup.find_by_group_uuid(self.owner_uuid).andand.trash_at,
+       self.trash_at])
     if frozen_descendants.any?
       raise ArgumentError.new("cannot trash project containing frozen project #{frozen_descendants[0]["uuid"]}")
     end
-    ActiveRecord::Base.connection.exec_delete(
-      "delete from trashed_groups where group_uuid in (select target_uuid from #{temptable} where trash_at is NULL)",
-      "Group.update_trash.delete")
-    ActiveRecord::Base.connection.exec_query(
-      "insert into trashed_groups (group_uuid, trash_at) "+
-      "select target_uuid as group_uuid, trash_at from #{temptable} where trash_at is not NULL " +
-      "on conflict (group_uuid) do update set trash_at=EXCLUDED.trash_at",
-      "Group.update_trash.insert")
+
+    ActiveRecord::Base.connection.exec_query(%{
+with temptable as (select * from project_subtree_with_trash_at($1, LEAST($2, $3)::timestamp)),
+
+delete_rows as (delete from trashed_groups where group_uuid in (select target_uuid from temptable where trash_at is NULL)),
+
+insert_rows as (insert into trashed_groups (group_uuid, trash_at)
+  select target_uuid as group_uuid, trash_at from temptable where trash_at is not NULL
+  on conflict (group_uuid) do update set trash_at=EXCLUDED.trash_at)
+
+select container_uuid from container_requests where
+  owner_uuid in (select target_uuid from temptable) and
+  requesting_container_uuid is NULL and state = 'Committed' and container_uuid is not NULL
+},
+      "Group.update_trash.select",
+      [self.uuid,
+       TrashedGroup.find_by_group_uuid(self.owner_uuid).andand.trash_at,
+       self.trash_at]).each do |container_uuid|
+      update_priorities container_uuid["container_uuid"]
+    end
   end
 
   def update_frozen
     return unless saved_change_to_frozen_by_uuid? || saved_change_to_owner_uuid?
-    temptable = "group_subtree_#{rand(2**64).to_s(10)}"
-    ActiveRecord::Base.connection.exec_query(
-      "create temporary table #{temptable} on commit drop as select * from project_subtree_with_is_frozen($1,$2)",
-      "Group.update_frozen.select",
-      [[nil, self.uuid],
-       [nil, !self.frozen_by_uuid.nil?]])
+
     if frozen_by_uuid
-      rows = ActiveRecord::Base.connection.exec_query(
-        "select cr.uuid, cr.state from container_requests cr, #{temptable} frozen " +
-        "where cr.owner_uuid = frozen.uuid and frozen.is_frozen " +
-        "and cr.state not in ($1, $2) limit 1",
-        "Group.update_frozen.check_container_requests",
-        [[nil, ContainerRequest::Uncommitted],
-         [nil, ContainerRequest::Final]])
+      rows = ActiveRecord::Base.connection.exec_query(%{
+with temptable as (select * from project_subtree_with_is_frozen($1,$2))
+
+select cr.uuid, cr.state from container_requests cr, temptable frozen
+  where cr.owner_uuid = frozen.uuid and frozen.is_frozen
+  and cr.state not in ($3, $4) limit 1
+},
+                                                      "Group.update_frozen.check_container_requests",
+                                                      [self.uuid,
+                                                       !self.frozen_by_uuid.nil?,
+                                                       ContainerRequest::Uncommitted,
+                                                       ContainerRequest::Final])
       if rows.any?
         raise ArgumentError.new("cannot freeze project containing container request #{rows.first['uuid']} with state = #{rows.first['state']}")
       end
     end
-    ActiveRecord::Base.connection.exec_delete(
-      "delete from frozen_groups where uuid in (select uuid from #{temptable} where not is_frozen)",
-      "Group.update_frozen.delete")
-    ActiveRecord::Base.connection.exec_query(
-      "insert into frozen_groups (uuid) select uuid from #{temptable} where is_frozen on conflict do nothing",
-      "Group.update_frozen.insert")
+
+ActiveRecord::Base.connection.exec_query(%{
+with temptable as (select * from project_subtree_with_is_frozen($1,$2)),
+
+delete_rows as (delete from frozen_groups where uuid in (select uuid from temptable where not is_frozen))
+
+insert into frozen_groups (uuid) select uuid from temptable where is_frozen on conflict do nothing
+}, "Group.update_frozen.update",
+                                         [self.uuid,
+                                          !self.frozen_by_uuid.nil?])
+
   end
 
   def before_ownership_change
@@ -225,11 +247,11 @@ class Group < ArvadosModel
     ActiveRecord::Base.connection.exec_delete(
       "delete from trashed_groups where group_uuid=$1",
       "Group.clear_permissions_trash_frozen",
-      [[nil, self.uuid]])
+      [self.uuid])
     ActiveRecord::Base.connection.exec_delete(
       "delete from frozen_groups where uuid=$1",
       "Group.clear_permissions_trash_frozen",
-      [[nil, self.uuid]])
+      [self.uuid])
   end
 
   def assign_name
index 37e5f455dffe73b61c783afc219913a6daf8313f..029a3132856ea608497ae54330fba74f4ce5f682 100644 (file)
@@ -50,7 +50,7 @@ class Job < ArvadosModel
   before_create :create_disabled
   before_update :update_disabled
 
-  has_many(:nodes, foreign_key: :job_uuid, primary_key: :uuid)
+  has_many(:nodes, foreign_key: 'job_uuid', primary_key: 'uuid')
 
   class SubmitIdReused < RequestError
   end
@@ -107,7 +107,7 @@ class Job < ArvadosModel
   end
 
   def assert_finished
-    update_attributes(finished_at: finished_at || db_current_time,
+    update(finished_at: finished_at || db_current_time,
                       success: success.nil? ? false : success,
                       running: false)
   end
index 5751c135d8f9cdd57cc3f2f3ed5d77723c7abe6c..589936f84521d1fa66460b088fccbc8e31468b7a 100644 (file)
@@ -40,7 +40,7 @@ class KeepDisk < ArvadosModel
     end
 
     @bypass_arvados_authorization = true
-    self.update_attributes!(o.select { |k,v|
+    self.update!(o.select { |k,v|
                              [:bytes_total,
                               :bytes_free,
                               :is_readable,
index 4d4c2832bbd62e3d8cb5c731f5fcb65895ca9b7a..2eb6b88a0c864a5ea33f8dbe7a5a4c9fe4cd2a1f 100644 (file)
@@ -17,11 +17,12 @@ class Link < ArvadosModel
   before_update :apply_max_overlapping_permissions
   before_create :apply_max_overlapping_permissions
   after_update :delete_overlapping_permissions
-  after_update :call_update_permissions
-  after_create :call_update_permissions
+  after_update :call_update_permissions, :if => Proc.new { @need_update_permissions }
+  after_create :call_update_permissions, :if => Proc.new { @need_update_permissions }
   before_destroy :clear_permissions
   after_destroy :delete_overlapping_permissions
   after_destroy :check_permissions
+  before_save :check_need_update_permissions
 
   api_accessible :user, extend: :common do |t|
     t.add :tail_uuid
@@ -189,11 +190,13 @@ class Link < ArvadosModel
     'can_manage' => 3,
   }
 
+  def check_need_update_permissions
+    @need_update_permissions = self.link_class == 'permission' && (name != name_was || new_record?)
+  end
+
   def call_update_permissions
-    if self.link_class == 'permission'
       update_permissions tail_uuid, head_uuid, PERM_LEVEL[name], self.uuid
       current_user.forget_cached_group_perms
-    end
   end
 
   def clear_permissions
index c8a606e2b808d63a714d41d362e11e34121eebac..f384ba582bc34a1ec69c7cc7b943bd649fed8328 100644 (file)
@@ -20,7 +20,10 @@ class Node < ArvadosModel
   # Only a controller can figure out whether or not the current API tokens
   # have access to the associated Job.  They're expected to set
   # job_readable=true if the Job UUID can be included in the API response.
-  belongs_to(:job, foreign_key: :job_uuid, primary_key: :uuid)
+  belongs_to :job,
+             foreign_key: 'job_uuid',
+             primary_key: 'uuid',
+             optional: true
   attr_accessor :job_readable
 
   UNUSED_NODE_IP = '127.40.4.0'
@@ -159,8 +162,8 @@ class Node < ArvadosModel
                           LIMIT 1',
                           # query label:
                           'Node.available_slot_number',
-                          # [col_id, val] for $1 vars:
-                          [[nil, MAX_VMS]],
+                          # bind vars:
+                          [MAX_VMS],
                          ).rows.first.andand.first
   end
 
@@ -176,7 +179,7 @@ class Node < ArvadosModel
         # as the new node. Clear the ip_address field on the stale
         # nodes. Otherwise, we (via SLURM) might inadvertently connect
         # to the new node using the old node's hostname.
-        stale_node.update_attributes!(ip_address: nil)
+        stale_node.update!(ip_address: nil)
       end
     end
     if hostname_before_last_save && saved_change_to_hostname?
index 271b155aafa140b9ed7bef5d5d7dc5c8de43c549..0b0af8b87d4e52eff8e28d7920f600ed6d883852 100644 (file)
@@ -9,7 +9,10 @@ class PipelineInstance < ArvadosModel
   serialize :components, Hash
   serialize :properties, Hash
   serialize :components_summary, Hash
-  belongs_to :pipeline_template, :foreign_key => :pipeline_template_uuid, :primary_key => :uuid
+  belongs_to :pipeline_template,
+             foreign_key: 'pipeline_template_uuid',
+             primary_key: 'uuid',
+             optional: true
 
   before_validation :bootstrap_components
   before_validation :update_state
index bbdd9c2843d4d810439e1f9ecafce1b0835b02ae..5a95fb0b88e41efc593495bc50efeb5bd13b51b9 100644 (file)
@@ -31,9 +31,10 @@ class User < ArvadosModel
   after_update :setup_on_activate
 
   before_create :check_auto_admin
-  before_create :set_initial_username, :if => Proc.new {
-    username.nil? and email
+  before_validation :set_initial_username, :if => Proc.new {
+    new_record? && email
   }
+  before_create :active_is_not_nil
   after_create :after_ownership_change
   after_create :setup_on_activate
   after_create :add_system_group_permission_link
@@ -56,8 +57,8 @@ class User < ArvadosModel
   before_destroy :clear_permissions
   after_destroy :remove_self_from_permissions
 
-  has_many :authorized_keys, :foreign_key => :authorized_user_uuid, :primary_key => :uuid
-  has_many :repositories, foreign_key: :owner_uuid, primary_key: :uuid
+  has_many :authorized_keys, foreign_key: 'authorized_user_uuid', primary_key: 'uuid'
+  has_many :repositories, foreign_key: 'owner_uuid', primary_key: 'uuid'
 
   default_scope { where('redirect_to_user_uuid is null') }
 
@@ -104,6 +105,10 @@ class User < ArvadosModel
        self.groups_i_can(:read).select { |x| x.match(/-f+$/) }.first)
   end
 
+  def self.ignored_select_attributes
+    super + ["full_name", "is_invited"]
+  end
+
   def groups_i_can(verb)
     my_groups = self.group_permissions(VAL_FOR_PERM[verb]).keys
     if verb == :read
@@ -145,10 +150,10 @@ SELECT 1 FROM #{PERMISSION_VIEW}
 },
                   # "name" arg is a query label that appears in logs:
                    "user_can_query",
-                   [[nil, self.uuid],
-                    [nil, target_uuid],
-                    [nil, VAL_FOR_PERM[action]],
-                    [nil, target_owner_uuid]]
+                   [self.uuid,
+                    target_uuid,
+                    VAL_FOR_PERM[action],
+                    target_owner_uuid]
                   ).any?
         return false
       end
@@ -237,7 +242,7 @@ SELECT target_uuid, perm_level
                    # "name" arg is a query label that appears in logs:
                    "User.group_permissions",
                    # "binds" arg is an array of [col_id, value] for '$1' vars:
-                   [[nil, uuid]]).
+                   [uuid]).
         rows.each do |group_uuid, max_p_val|
         @group_perms[group_uuid] = PERMS_FOR_VAL[max_p_val.to_i]
       end
@@ -259,8 +264,7 @@ SELECT target_uuid, perm_level
   def setup(repo_name: nil, vm_uuid: nil, send_notification_email: nil)
     newly_invited = Link.where(tail_uuid: self.uuid,
                               head_uuid: all_users_group_uuid,
-                              link_class: 'permission',
-                              name: 'can_read').empty?
+                              link_class: 'permission').empty?
 
     # Add can_read link from this user to "all users" which makes this
     # user "invited", and (depending on config) a link in the opposite
@@ -382,7 +386,11 @@ SELECT target_uuid, perm_level
   end
 
   def set_initial_username(requested: false)
-    if !requested.is_a?(String) || requested.empty?
+    if new_record? and requested == false and self.username != nil and self.username != ""
+      requested = self.username
+    end
+
+    if (!requested.is_a?(String) || requested.empty?) and email
       email_parts = email.partition("@")
       local_parts = email_parts.first.partition("+")
       if email_parts.any?(&:empty?)
@@ -393,13 +401,20 @@ SELECT target_uuid, perm_level
         requested = email_parts.first
       end
     end
-    requested.sub!(/^[^A-Za-z]+/, "")
-    requested.gsub!(/[^A-Za-z0-9]/, "")
-    unless requested.empty?
+    if requested
+      requested.sub!(/^[^A-Za-z]+/, "")
+      requested.gsub!(/[^A-Za-z0-9]/, "")
+    end
+    unless !requested || requested.empty?
       self.username = find_usable_username_from(requested)
     end
   end
 
+  def active_is_not_nil
+    self.is_active = false if self.is_active.nil?
+    self.is_admin = false if self.is_admin.nil?
+  end
+
   # Move this user's (i.e., self's) owned items to new_owner_uuid and
   # new_user_uuid (for things normally owned directly by the user).
   #
@@ -497,14 +512,14 @@ SELECT target_uuid, perm_level
       end
 
       if redirect_to_new_user
-        update_attributes!(redirect_to_user_uuid: new_user.uuid, username: nil)
+        update!(redirect_to_user_uuid: new_user.uuid, username: nil)
       end
       skip_check_permissions_against_full_refresh do
-        update_permissions self.uuid, self.uuid, CAN_MANAGE_PERM
-        update_permissions new_user.uuid, new_user.uuid, CAN_MANAGE_PERM
-        update_permissions new_user.owner_uuid, new_user.uuid, CAN_MANAGE_PERM
+        update_permissions self.uuid, self.uuid, CAN_MANAGE_PERM, nil, true
+        update_permissions new_user.uuid, new_user.uuid, CAN_MANAGE_PERM, nil, true
+        update_permissions new_user.owner_uuid, new_user.uuid, CAN_MANAGE_PERM, nil, true
       end
-      update_permissions self.owner_uuid, self.uuid, CAN_MANAGE_PERM
+      update_permissions self.owner_uuid, self.uuid, CAN_MANAGE_PERM, nil, true
     end
   end
 
@@ -592,6 +607,151 @@ SELECT target_uuid, perm_level
     primary_user
   end
 
+  def self.update_remote_user remote_user
+    remote_user = remote_user.symbolize_keys
+    remote_user_prefix = remote_user[:uuid][0..4]
+
+    # interaction between is_invited and is_active
+    #
+    # either can flag can be nil, true or false
+    #
+    # in all cases, we create the user if they don't exist.
+    #
+    # invited nil, active nil: don't call setup or unsetup.
+    #
+    # invited nil, active false: call unsetup
+    #
+    # invited nil, active true: call setup and activate them.
+    #
+    #
+    # invited false, active nil: call unsetup
+    #
+    # invited false, active false: call unsetup
+    #
+    # invited false, active true: call unsetup
+    #
+    #
+    # invited true, active nil: call setup but don't change is_active
+    #
+    # invited true, active false: call setup but don't change is_active
+    #
+    # invited true, active true: call setup and activate them.
+
+    should_setup = (remote_user_prefix == Rails.configuration.Login.LoginCluster or
+                    Rails.configuration.Users.AutoSetupNewUsers or
+                    Rails.configuration.Users.NewUsersAreActive or
+                    Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
+
+    should_activate = (remote_user_prefix == Rails.configuration.Login.LoginCluster or
+                       Rails.configuration.Users.NewUsersAreActive or
+                       Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
+
+    remote_should_be_unsetup = (remote_user[:is_invited] == nil && remote_user[:is_active] == false) ||
+                               (remote_user[:is_invited] == false)
+
+    remote_should_be_setup = should_setup && (
+      (remote_user[:is_invited] == nil && remote_user[:is_active] == true) ||
+      (remote_user[:is_invited] == false && remote_user[:is_active] == true) ||
+      (remote_user[:is_invited] == true))
+
+    remote_should_be_active = should_activate && remote_user[:is_invited] != false && remote_user[:is_active] == true
+
+    # Make sure blank username is nil
+    remote_user[:username] = nil if remote_user[:username] == ""
+
+    begin
+      user = User.create_with(email: remote_user[:email],
+                              username: remote_user[:username],
+                              first_name: remote_user[:first_name],
+                              last_name: remote_user[:last_name],
+                              is_active: remote_should_be_active,
+                             ).find_or_create_by(uuid: remote_user[:uuid])
+    rescue ActiveRecord::RecordNotUnique
+      retry
+    end
+
+    user.with_lock do
+      needupdate = {}
+      [:email, :username, :first_name, :last_name, :prefs].each do |k|
+        v = remote_user[k]
+        if !v.nil? && user.send(k) != v
+          needupdate[k] = v
+        end
+      end
+
+      user.email = needupdate[:email] if needupdate[:email]
+
+      loginCluster = Rails.configuration.Login.LoginCluster
+      if user.username.nil? || user.username == ""
+        # Don't have a username yet, try to set one
+        initial_username = user.set_initial_username(requested: remote_user[:username])
+        needupdate[:username] = initial_username if !initial_username.nil?
+      elsif remote_user_prefix != loginCluster
+        # Upstream is not login cluster, don't try to change the
+        # username once set.
+        needupdate.delete :username
+      end
+
+      if needupdate.length > 0
+        begin
+          user.update!(needupdate)
+        rescue ActiveRecord::RecordInvalid
+          if remote_user_prefix == loginCluster && !needupdate[:username].nil?
+            local_user = User.find_by_username(needupdate[:username])
+            # The username of this record conflicts with an existing,
+            # different user record.  This can happen because the
+            # username changed upstream on the login cluster, or
+            # because we're federated with another cluster with a user
+            # by the same username.  The login cluster is the source
+            # of truth, so change the username on the conflicting
+            # record and retry the update operation.
+            if local_user.uuid != user.uuid
+              new_username = "#{needupdate[:username]}#{rand(99999999)}"
+              Rails.logger.warn("cached username '#{needupdate[:username]}' collision with user '#{local_user.uuid}' - renaming to '#{new_username}' before retrying")
+              local_user.update!({username: new_username})
+              retry
+            end
+          end
+          raise # Not the issue we're handling above
+        end
+      elsif user.new_record?
+        begin
+          user.save!
+        rescue => e
+          Rails.logger.debug "Error saving user record: #{$!}"
+          Rails.logger.debug "Backtrace:\n\t#{e.backtrace.join("\n\t")}"
+          raise
+        end
+      end
+
+      if remote_should_be_unsetup
+        # Remote user is not "invited" or "active" state on their home
+        # cluster, so they should be unsetup, which also makes them
+        # inactive.
+        user.unsetup
+      else
+        if !user.is_invited && remote_should_be_setup
+          user.setup
+        end
+
+        if !user.is_active && remote_should_be_active
+          # remote user is active and invited, we need to activate them
+          user.update!(is_active: true)
+        end
+
+        if remote_user_prefix == Rails.configuration.Login.LoginCluster and
+          user.is_active and
+          !remote_user[:is_admin].nil? and
+          user.is_admin != remote_user[:is_admin]
+          # Remote cluster controls our user database, including the
+          # admin flag.
+          user.update!(is_admin: remote_user[:is_admin])
+        end
+      end
+    end
+    user
+  end
+
   protected
 
   def self.attributes_required_columns
@@ -810,8 +970,9 @@ SELECT target_uuid, perm_level
 
   # Send admin notifications
   def send_admin_notifications
-    AdminNotifier.new_user(self).deliver_now
-    if not self.is_active then
+    if self.is_invited then
+      AdminNotifier.new_user(self).deliver_now
+    else
       AdminNotifier.new_inactive_user(self).deliver_now
     end
   end
index 0b3557eef6c68a802c977436dc979c3c138fedfa..09687385cad88b29c4a388e713d805a67003de2d 100644 (file)
@@ -9,9 +9,9 @@ class VirtualMachine < ArvadosModel
 
   has_many(:login_permissions,
            -> { where("link_class = 'permission' and name = 'can_login'") },
-           foreign_key: :head_uuid,
+           foreign_key: 'head_uuid',
            class_name: 'Link',
-           primary_key: :uuid)
+           primary_key: 'uuid')
 
   api_accessible :user, extend: :common do |t|
     t.add :hostname
index 94890c6632e3b9a8ea6eadf914edd9552ec618e5..0268c4e9797195c79668bd9b6b16244468f3502d 100644 (file)
@@ -18,7 +18,7 @@ class Workflow < ArvadosModel
 
   def validate_definition
     begin
-      @definition_yaml = YAML.load self.definition if !definition.nil?
+      @definition_yaml = YAML.safe_load self.definition if !definition.nil?
     rescue => e
       errors.add :definition, "is not valid yaml: #{e.message}"
     end
@@ -27,7 +27,7 @@ class Workflow < ArvadosModel
   def set_name_and_description
     old_wf = {}
     begin
-      old_wf = YAML.load self.definition_was if !self.definition_was.nil?
+      old_wf = YAML.safe_load self.definition_was if !self.definition_was.nil?
     rescue => e
       logger.warn "set_name_and_description error: #{e.message}"
       return
index afcf34da714e591066a7e5f706cbaa23d130db11..22298b1ce7807561d3bacc54b05d8c71167c28aa 100644 (file)
@@ -2,15 +2,16 @@
 
 SPDX-License-Identifier: AGPL-3.0 %>
 
+A new user has been created, but not set up.
 
-A new user landed on the inactive user page:
+  <%= @user.full_name %> <<%= @user.email %>> (<%= @user.username %>)
 
-  <%= @user.full_name %> <<%= @user.email %>>
+They will not be able to use Arvados unless set up by an admin.
 
 <% if Rails.configuration.Services.Workbench1.ExternalURL -%>
-Please see workbench for more information:
+Please see Workbench for more information:
 
-  <%= Rails.configuration.Services.Workbench1.ExternalURL %>
+  <%= URI::join(Rails.configuration.Services.Workbench1.ExternalURL, "user/#{@user.uuid}") %>
 
 <% end -%>
 Thanks,
index 670b84b7c11dd874b1eaaf976cffe3b495633fab..920906d83367878473ac1a60ae250ccd8f442ae5 100644 (file)
@@ -2,22 +2,16 @@
 
 SPDX-License-Identifier: AGPL-3.0 %>
 
-<%
-  add_to_message = ''
-  if Rails.configuration.Users.AutoSetupNewUsers
-    add_to_message = @user.is_invited ? ' and setup' : ', but not setup'
-  end
-%>
-A new user has been created<%=add_to_message%>:
+A new user has been created and set up.
 
-  <%= @user.full_name %> <<%= @user.email %>>
+  <%= @user.full_name %> <<%= @user.email %>> (<%= @user.username %>)
 
-This user is <%= @user.is_active ? '' : 'NOT ' %>active.
+They are able to use Arvados.
 
 <% if Rails.configuration.Services.Workbench1.ExternalURL -%>
-Please see workbench for more information:
+Please see Workbench for more information:
 
-  <%= Rails.configuration.Services.Workbench1.ExternalURL %>
+  <%= URI::join(Rails.configuration.Services.Workbench1.ExternalURL, "user/#{@user.uuid}") %>
 
 <% end -%>
 Thanks,
index 352ee7754e1e6855c1b2ffc946dad5c24d0962fe..3f04db8517fb216727a632a3af03e770b3bc4146 100644 (file)
@@ -2,4 +2,4 @@
 
 SPDX-License-Identifier: AGPL-3.0 %>
 
-<%= ERB.new(Rails.configuration.Users.UserSetupMailText, 0, "-").result(binding) %>
+<%= ERB.new(Rails.configuration.Users.UserSetupMailText, trim_mode: "-").result(binding) %>
index 5f594d1186a41b49419e7bcb8a8cae680a82d0a4..efc0377492f7e0ec9f6cedf7b5e1f6119bbbd24e 100755 (executable)
@@ -1,9 +1,4 @@
 #!/usr/bin/env ruby
-
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-APP_PATH = File.expand_path('../config/application', __dir__)
-require_relative '../config/boot'
-require 'rails/commands'
+APP_PATH = File.expand_path("../config/application", __dir__)
+require_relative "../config/boot"
+require "rails/commands"
index 87484df469df441d64def584e483fc4076313b95..4fbf10b960ef780b748861e6a616a4d88b00b50a 100755 (executable)
@@ -1,9 +1,4 @@
 #!/usr/bin/env ruby
-
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require_relative '../config/boot'
-require 'rake'
+require_relative "../config/boot"
+require "rake"
 Rake.application.run
index c9142b942ed12a848a4497a01ad7393dfd78d370..ec47b79b3b3a002be18adafe9a5fcd070bcd808d 100755 (executable)
@@ -1,38 +1,33 @@
 #!/usr/bin/env ruby
-
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'fileutils'
-include FileUtils
+require "fileutils"
 
 # path to your application root.
-APP_ROOT = File.expand_path('..', __dir__)
+APP_ROOT = File.expand_path("..", __dir__)
 
 def system!(*args)
   system(*args) || abort("\n== Command #{args} failed ==")
 end
 
-chdir APP_ROOT do
-  # This script is a starting point to setup your application.
+FileUtils.chdir APP_ROOT do
+  # This script is a way to set up or update your development environment automatically.
+  # This script is idempotent, so that you can run it at any time and get an expectable outcome.
   # Add necessary setup steps to this file.
 
-  puts '== Installing dependencies =='
-  system! 'gem install bundler --conservative'
-  system('bundle check') || system!('bundle install')
+  puts "== Installing dependencies =="
+  system! "gem install bundler --conservative"
+  system("bundle check") || system!("bundle install")
 
   # puts "\n== Copying sample files =="
-  # unless File.exist?('config/database.yml')
-  #   cp 'config/database.yml.sample', 'config/database.yml'
+  # unless File.exist?("config/database.yml")
+  #   FileUtils.cp "config/database.yml.sample", "config/database.yml"
   # end
 
   puts "\n== Preparing database =="
-  system! 'bin/rails db:setup'
+  system! "bin/rails db:prepare"
 
   puts "\n== Removing old logs and tempfiles =="
-  system! 'bin/rails log:clear tmp:clear'
+  system! "bin/rails log:clear tmp:clear"
 
   puts "\n== Restarting application server =="
-  system! 'bin/rails restart'
+  system! "bin/rails restart"
 end
index 30e82818434e742d37818196b903cb4cfa72b986..4a3c09a6889a97a54af8cbcc1a47e03e58cce376 100644 (file)
@@ -1,8 +1,6 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
 # This file is used by Rack-based servers to start the application.
 
-require ::File.expand_path('../config/environment',  __FILE__)
-run Server::Application
+require_relative "config/environment"
+
+run Rails.application
+Rails.application.load_server
index b28ae0e0718e2ddabc472f61be8cf8c07a53232f..716383f2035e63095055116f1e103dda82e9b40c 100644 (file)
@@ -2,44 +2,26 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-require_relative 'boot'
+require_relative "boot"
 
 require "rails"
-# Pick only the frameworks we need:
+# Pick the frameworks you want:
 require "active_model/railtie"
 require "active_job/railtie"
 require "active_record/railtie"
+# require "active_storage/engine"
 require "action_controller/railtie"
 require "action_mailer/railtie"
+# require "action_mailbox/engine"
+# require "action_text/engine"
 require "action_view/railtie"
+# require "action_cable/engine"
 require "sprockets/railtie"
 require "rails/test_unit/railtie"
-# Skipping the following:
-# * ActionCable (new in Rails 5.0) as it adds '/cable' routes that we're not using
-# * ActiveStorage (new in Rails 5.1)
 
-require 'digest'
-
-module Kernel
-  def suppress_warnings
-    verbose_orig = $VERBOSE
-    begin
-      $VERBOSE = nil
-      yield
-    ensure
-      $VERBOSE = verbose_orig
-    end
-  end
-end
-
-if defined?(Bundler)
-  suppress_warnings do
-    # If you precompile assets before deploying to production, use this line
-    Bundler.require(*Rails.groups(:assets => %w(development test)))
-    # If you want your assets lazily compiled in production, use this line
-    # Bundler.require(:default, :assets, Rails.env)
-  end
-end
+# Require the gems listed in Gemfile, including any gems
+# you've limited to :test, :development, or :production.
+Bundler.require(*Rails.groups)
 
 if ENV["ARVADOS_RAILS_LOG_TO_STDOUT"]
   Rails.logger = ActiveSupport::TaggedLogging.new(Logger.new(STDOUT))
@@ -47,38 +29,29 @@ end
 
 module Server
   class Application < Rails::Application
-    # The following is to avoid SafeYAML's warning message
-    SafeYAML::OPTIONS[:default_mode] = :safe
 
     require_relative "arvados_config.rb"
 
-    # Settings in config/environments/* take precedence over those specified here.
-    # Application configuration should go into files in config/initializers
-    # -- all .rb files in that directory are automatically loaded.
-
-    # Custom directories with classes and modules you want to be autoloadable.
-    # config.autoload_paths += %W(#{config.root}/extras)
+    # Initialize configuration defaults for specified Rails version.
+    config.load_defaults 7.0
 
-    # Only load the plugins named here, in the order given (default is alphabetical).
-    # :all can be used as a placeholder for all plugins not explicitly named.
-    # config.plugins = [ :exception_notification, :ssl_requirement, :all ]
+    # Configuration for the application, engines, and railties goes here.
+    #
+    # These settings can be overridden in specific environments using the files
+    # in config/environments, which are processed later.
+    #
+    # config.time_zone = "Central Time (US & Canada)"
+    # config.eager_load_paths << Rails.root.join("extras")
 
-    # Activate observers that should always be running.
-    # config.active_record.observers = :cacher, :garbage_collector, :forum_observer
+    # We use db/structure.sql instead of db/schema.rb.
     config.active_record.schema_format = :sql
 
-    # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
-    # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
-    # config.i18n.default_locale = :de
-
-    # Configure sensitive parameters which will be filtered from the log file.
-    config.filter_parameters += [:password]
-
-    # Load entire application at startup.
     config.eager_load = true
 
     config.active_support.test_order = :sorted
 
+    # container_request records can contain arbitrary data structures
+    # in mounts.*.content, so rails must not munge them.
     config.action_dispatch.perform_deep_munge = false
 
     # force_ssl's redirect-to-https feature doesn't work when the
@@ -86,7 +59,10 @@ module Server
     # from connecting to Rails internally via plain http.
     config.ssl_options = {redirect: false}
 
-    I18n.enforce_available_locales = false
+    # This will change to 7.0 in a future release when there is no
+    # longer a possibility of rolling back to Arvados 2.7 (Rails 5.2)
+    # which cannot read 7.0-format cache files.
+    config.active_support.cache_format_version = 6.1
 
     # Before using the filesystem backend for Rails.cache, check
     # whether we own the relevant directory. If we don't, using it is
index d928d592c93f7b01f58145a37d08b380941d1720..f8b9ff8ecdd650ceb0a556565fd600001087168d 100644 (file)
@@ -36,7 +36,7 @@ if !status.success?
   puts stderr
   raise "error loading config: #{status}"
 end
-confs = YAML.load(defaultYAML, deserialize_symbols: false)
+confs = YAML.safe_load(defaultYAML)
 clusterID, clusterConfig = confs["Clusters"].first
 $arvados_config_defaults = clusterConfig
 $arvados_config_defaults["ClusterID"] = clusterID
@@ -50,7 +50,7 @@ if ENV["ARVADOS_CONFIG"] == "none"
 else
   # Load the global config file
   Open3.popen2("arvados-server", "config-dump", "-skip-legacy") do |stdin, stdout, status_thread|
-    confs = YAML.load(stdout, deserialize_symbols: false)
+    confs = YAML.safe_load(stdout)
     if confs && !confs.empty?
       # config-dump merges defaults with user configuration, so every
       # key should be set.
@@ -198,7 +198,7 @@ application_config = {}
   path = "#{::Rails.root.to_s}/config/#{cfgfile}.yml"
   confs = ConfigLoader.load(path, erb: true)
   # Ignore empty YAML file:
-  next if confs == false
+  next if confs == nil
   application_config.deep_merge!(confs['common'] || {})
   application_config.deep_merge!(confs[::Rails.env.to_s] || {})
 end
index 8087911837bf64f32323b6fa568f49a32575c927..282011619d92227924de59017a1eda9c5b555047 100644 (file)
@@ -1,8 +1,3 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
 
-# Set up gems listed in the Gemfile.
-ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
-
-require 'bundler/setup' # Set up gems listed in the Gemfile.
+require "bundler/setup" # Set up gems listed in the Gemfile.
index cd706940a389752fd6263bb32fc82a057fc3c583..cac5315775258a68f5e18885605d3fb1b758319e 100644 (file)
@@ -1,9 +1,5 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
+# Load the Rails application.
+require_relative "application"
 
-# Load the rails application
-require_relative 'application'
-
-# Initialize the rails application
+# Initialize the Rails application.
 Rails.application.initialize!
index 525d6adf95f5ef587601c75817132c6c2b4af1fb..89d2efab2ba659d7814a7665a99f7f8d7429a072 100644 (file)
@@ -1,7 +1,3 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
 # Be sure to restart your server when you modify this file.
 
 # ActiveSupport::Reloader.to_prepare do
index f02c87b73143fc0e01427ca2ff56e198c5cd2611..2eeef966fe8720932a80b0eca296eafdf53d71aa 100644 (file)
@@ -1,15 +1,12 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
 # Be sure to restart your server when you modify this file.
 
 # Version of your assets, change this if you want to expire all your assets.
-Rails.application.config.assets.version = '1.0'
+Rails.application.config.assets.version = "1.0"
 
-# Add additional assets to the asset load path
+# Add additional assets to the asset load path.
 # Rails.application.config.assets.paths << Emoji.images_path
 
 # Precompile additional assets.
-# application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
-# Rails.application.config.assets.precompile += %w( search.js )
+# application.js, application.css, and all non-JS/CSS in the app/assets
+# folder are already added.
+# Rails.application.config.assets.precompile += %w( admin.js admin.css )
index ec80048c8f44bddc8fa5f2e24b35f44c258a9259..71d555744514816d6bed799d0366915e0947f156 100644 (file)
@@ -2,6 +2,8 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+require_relative "../../app/middlewares/arvados_api_token"
+
 Server::Application.configure do
   config.middleware.delete ActionDispatch::RemoteIp
   config.middleware.insert 0, ActionDispatch::RemoteIp
index b9c6bceef5fc33dd25446258850994ade8862115..33699c30910b95ab124dc40bb9a244dac0093578 100644 (file)
@@ -1,11 +1,8 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
 # Be sure to restart your server when you modify this file.
 
 # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
-# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
+# Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) }
 
-# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
-# Rails.backtrace_cleaner.remove_silencers!
+# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code
+# by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'".
+Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"]
diff --git a/services/api/config/initializers/clear_empty_content_type.rb b/services/api/config/initializers/clear_empty_content_type.rb
new file mode 100644 (file)
index 0000000..3e501be
--- /dev/null
@@ -0,0 +1,26 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+# Rails handler stack crashes if the request Content-Type header value
+# is "", which is sometimes the case in GET requests from
+# ruby-google-api-client (which have no body content anyway).
+#
+# This middleware deletes such headers, so a request with an empty
+# Content-Type value is equivalent to a missing Content-Type header.
+class ClearEmptyContentType
+  def initialize(app=nil, options=nil)
+    @app = app
+  end
+
+  def call(env)
+    if env["CONTENT_TYPE"] == ""
+      env.delete("CONTENT_TYPE")
+    end
+    @app.call(env) if @app.respond_to?(:call)
+  end
+end
+
+Server::Application.configure do
+  config.middleware.use ClearEmptyContentType
+end
index 853ecdeec47d9ca9b6b5f4160e13d6c27fd14130..54f47cf15fe5026bede1bd6a9acb4ef815bf22ab 100644 (file)
@@ -1,29 +1,25 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
 # Be sure to restart your server when you modify this file.
 
-# Define an application-wide content security policy
-# For further information see the following documentation
-# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
+# Define an application-wide content security policy.
+# See the Securing Rails Applications Guide for more information:
+# https://guides.rubyonrails.org/security.html#content-security-policy-header
 
-# Rails.application.config.content_security_policy do |policy|
-#   policy.default_src :self, :https
-#   policy.font_src    :self, :https, :data
-#   policy.img_src     :self, :https, :data
-#   policy.object_src  :none
-#   policy.script_src  :self, :https
-#   policy.style_src   :self, :https
-
-#   # Specify URI for violation reports
-#   # policy.report_uri "/csp-violation-report-endpoint"
+# Rails.application.configure do
+#   config.content_security_policy do |policy|
+#     policy.default_src :self, :https
+#     policy.font_src    :self, :https, :data
+#     policy.img_src     :self, :https, :data
+#     policy.object_src  :none
+#     policy.script_src  :self, :https
+#     policy.style_src   :self, :https
+#     # Specify URI for violation reports
+#     # policy.report_uri "/csp-violation-report-endpoint"
+#   end
+#
+#   # Generate session nonces for permitted importmap and inline scripts
+#   config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
+#   config.content_security_policy_nonce_directives = %w(script-src)
+#
+#   # Report violations without enforcing the policy.
+#   # config.content_security_policy_report_only = true
 # end
-
-# If you are using UJS then enable automatic nonce generation
-# Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
-
-# Report CSP violations to a specified URI
-# For further information see the following documentation:
-# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
-# Rails.application.config.content_security_policy_report_only = true
index 5409f55c0be0f2a0f98088e7bcc32f4e7cbf195d..5a6a32d371fe575acf9f87e2ab7e8ae4d0f11e69 100644 (file)
@@ -1,9 +1,5 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
 # Be sure to restart your server when you modify this file.
 
 # Specify a serializer for the signed and encrypted cookie jars.
 # Valid options are :json, :marshal, and :hybrid.
-Rails.application.config.action_dispatch.cookies_serializer = :marshal
+Rails.application.config.action_dispatch.cookies_serializer = :json
index aecd4cfd4bb8528230361acc6399164445611d2c..9d909e6cbbf9a4ec325a7132553de8dbe11a6711 100644 (file)
@@ -2,6 +2,8 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+require_relative "../../app/models/jsonb_type"
+
 # JSONB backed Hash & Array types that default to their empty versions when
 # reading NULL from the database, or get nil passed by parameter.
 ActiveRecord::Type.register(:jsonbHash, JsonbType::Hash)
diff --git a/services/api/config/initializers/eventbus.rb b/services/api/config/initializers/eventbus.rb
deleted file mode 100644 (file)
index eb5561a..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-if ENV['ARVADOS_WEBSOCKETS']
-  Server::Application.configure do
-    Rails.logger.error "Built-in websocket server is disabled. See note (2017-03-23, e8cc0d7) at https://dev.arvados.org/projects/arvados/wiki/Upgrading_to_master"
-
-    class EventBusRemoved
-      def overloaded?
-        false
-      end
-      def on_connect ws
-        ws.on :open do |e|
-          EM::Timer.new 1 do
-            ws.send(SafeJSON.dump({status: 501, message: "Server misconfigured? see http://doc.arvados.org/install/install-ws.html"}))
-          end
-          EM::Timer.new 3 do
-            ws.close
-          end
-        end
-      end
-    end
-
-    config.middleware.insert_after(ArvadosApiToken, RackSocket, {
-                                     handler: EventBusRemoved,
-                                     mount: "/websocket",
-                                     websocket_only: (ENV['ARVADOS_WEBSOCKETS'] == "ws-only")
-                                   })
-  end
-end
index f26d0ad223aeb2768f27cc906a739275d008409d..adc6568ce83724d2b01d7232b0873bda7c249b11 100644 (file)
@@ -1,8 +1,8 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
 # Be sure to restart your server when you modify this file.
 
-# Configure sensitive parameters which will be filtered from the log file.
-Rails.application.config.filter_parameters += [:password]
+# Configure parameters to be filtered from the log file. Use this to limit dissemination of
+# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
+# notations and behaviors.
+Rails.application.config.filter_parameters += [
+  :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
+]
index 50bd0d5f557b6eae56d654bf5a700c82933760d1..bd92f2fd76a721f361dd39815bd7fbdff478f9d2 100644 (file)
@@ -4,15 +4,21 @@
 
 # Be sure to restart your server when you modify this file.
 
-# Add new inflection rules using the following format
-# (all these examples are active by default):
-# ActiveSupport::Inflector.inflections do |inflect|
-#   inflect.plural /^(ox)$/i, '\1en'
-#   inflect.singular /^(ox)en/i, '\1'
-#   inflect.irregular 'person', 'people'
+# Add new inflection rules using the following format. Inflections
+# are locale specific, and you may define rules for as many different
+# locales as you wish. All of these examples are active by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+#   inflect.plural /^(ox)$/i, "\\1en"
+#   inflect.singular /^(ox)en/i, "\\1"
+#   inflect.irregular "person", "people"
 #   inflect.uncountable %w( fish sheep )
 # end
 
+# These inflection rules are supported but not enabled by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+#   inflect.acronym "RESTful"
+# end
+
 ActiveSupport::Inflector.inflections do |inflect|
   inflect.plural(/^([Ss]pecimen)$/i, '\1s')
   inflect.singular(/^([Ss]pecimen)s?/i, '\1')
index 36683cc2469f7e5ed9ce5ee12b87c6b659b36e6f..dc1899682b01c3a6d9673faf746e235fb64fc4d2 100644 (file)
@@ -1,9 +1,4 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
 # Be sure to restart your server when you modify this file.
 
 # Add new mime types for use in respond_to blocks:
 # Mime::Type.register "text/richtext", :rtf
-# Mime::Type.register_alias "text/html", :iphone
diff --git a/services/api/config/initializers/new_framework_defaults.rb b/services/api/config/initializers/new_framework_defaults.rb
deleted file mode 100644 (file)
index 2e2f0b1..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-#
-# This file contains migration options to ease your Rails 5.0 upgrade.
-#
-# Once upgraded flip defaults one by one to migrate to the new default.
-#
-# Read the Guide for Upgrading Ruby on Rails for more info on each option.
-
-Rails.application.config.action_controller.raise_on_unfiltered_parameters = true
-
-# Enable per-form CSRF tokens. Previous versions had false.
-Rails.application.config.action_controller.per_form_csrf_tokens = false
-
-# Enable origin-checking CSRF mitigation. Previous versions had false.
-Rails.application.config.action_controller.forgery_protection_origin_check = false
-
-# Make Ruby 2.4 preserve the timezone of the receiver when calling `to_time`.
-# Previous versions had false.
-ActiveSupport.to_time_preserves_timezone = false
-
-# Require `belongs_to` associations by default. Previous versions had false.
-Rails.application.config.active_record.belongs_to_required_by_default = false
diff --git a/services/api/config/initializers/new_framework_defaults_5_2.rb b/services/api/config/initializers/new_framework_defaults_5_2.rb
deleted file mode 100644 (file)
index 93a8d52..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Be sure to restart your server when you modify this file.
-#
-# This file contains migration options to ease your Rails 5.2 upgrade.
-#
-# Once upgraded flip defaults one by one to migrate to the new default.
-#
-# Read the Guide for Upgrading Ruby on Rails for more info on each option.
-
-# Make Active Record use stable #cache_key alongside new #cache_version method.
-# This is needed for recyclable cache keys.
-# Rails.application.config.active_record.cache_versioning = true
-
-# Use AES-256-GCM authenticated encryption for encrypted cookies.
-# Also, embed cookie expiry in signed or encrypted cookies for increased security.
-#
-# This option is not backwards compatible with earlier Rails versions.
-# It's best enabled when your entire app is migrated and stable on 5.2.
-#
-# Existing cookies will be converted on read then written with the new scheme.
-# Rails.application.config.action_dispatch.use_authenticated_cookie_encryption = true
-
-# Use AES-256-GCM authenticated encryption as default cipher for encrypting messages
-# instead of AES-256-CBC, when use_authenticated_message_encryption is set to true.
-# Rails.application.config.active_support.use_authenticated_message_encryption = true
-
-# Add default protection from forgery to ActionController::Base instead of in
-# ApplicationController.
-# Rails.application.config.action_controller.default_protect_from_forgery = true
-
-# Store boolean values are in sqlite3 databases as 1 and 0 instead of 't' and
-# 'f' after migrating old data.
-# Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true
-
-# Use SHA-1 instead of MD5 to generate non-sensitive digests, such as the ETag header.
-# Rails.application.config.active_support.use_sha1_digests = true
-
-# Make `form_with` generate id attributes for any generated HTML tags.
-# Rails.application.config.action_view.form_with_generates_ids = true
diff --git a/services/api/config/initializers/permissions_policy.rb b/services/api/config/initializers/permissions_policy.rb
new file mode 100644 (file)
index 0000000..00f64d7
--- /dev/null
@@ -0,0 +1,11 @@
+# Define an application-wide HTTP permissions policy. For further
+# information see https://developers.google.com/web/updates/2018/06/feature-policy
+#
+# Rails.application.config.permissions_policy do |f|
+#   f.camera      :none
+#   f.gyroscope   :none
+#   f.microphone  :none
+#   f.usb         :none
+#   f.fullscreen  :self
+#   f.payment     :self, "https://secure.example.com"
+# end
index b54e3bcf87e764aec3508aec0ff5444cc7de9c37..f6ef8af963b434bc53f573c107c50aa56b29c2d4 100644 (file)
@@ -2,9 +2,6 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-# When updating this, please make the same changes in
-# apps/workbench/config/initializers/reload_config.rb as well.
-
 def start_reload_thread
   Thread.new do
     lockfile = Rails.root.join('tmp', 'reload_config.lock')
@@ -29,7 +26,7 @@ def start_reload_thread
         # precision cannot represent multiple updates per second.
         if t.to_f != t_lastload.to_f || Time.now.to_f - t.to_f < 5
           Open3.popen2("arvados-server", "config-dump", "-skip-legacy") do |stdin, stdout, status_thread|
-            confs = YAML.load(stdout, deserialize_symbols: false)
+            confs = YAML.safe_load(stdout)
             hash = confs["SourceSHA256"]
           rescue => e
             Rails.logger.info("reload_config: config file updated but could not be loaded: #{e}")
index e2158801e7618ba47dd756c55d497f17c6616679..cfb018ca9705dcc18a2ee53defbd9468a33d2a90 100644 (file)
@@ -14,7 +14,7 @@ module CustomRequestId
   end
 
   def internal_request_id
-    "req-" + Random::DEFAULT.rand(2**128).to_s(36)[0..19]
+    "req-" + Random.new.rand(2**128).to_s(36)[0..19]
   end
 end
 
@@ -22,4 +22,4 @@ class ActionDispatch::RequestId
   # Instead of using the default UUID-like format for X-Request-Id headers,
   # use our own.
   prepend CustomRequestId
-end
\ No newline at end of file
+end
diff --git a/services/api/config/initializers/schema_discovery_cache.rb b/services/api/config/initializers/schema_discovery_cache.rb
deleted file mode 100644 (file)
index c2cb8de..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-# Delete the cached discovery document during startup. Otherwise we
-# might still serve an old discovery document after updating the
-# schema and restarting the server.
-
-Rails.cache.delete 'arvados_v1_rest_discovery'
index 6fb9786504ea5247982f342ac7dfc6d426486b46..bbfc3961bffef15dabb35fe0de4c409d6efb58c5 100644 (file)
@@ -1,9 +1,5 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
 # Be sure to restart your server when you modify this file.
-#
+
 # This file contains settings for ActionController::ParamsWrapper which
 # is enabled by default.
 
@@ -12,7 +8,7 @@ ActiveSupport.on_load(:action_controller) do
   wrap_parameters format: [:json]
 end
 
-# Disable root element in JSON by default.
-ActiveSupport.on_load(:active_record) do
-  self.include_root_in_json = false
-end
+# To enable root element in JSON for ActiveRecord objects.
+ActiveSupport.on_load(:active_record) do
+#   self.include_root_in_json = true
+end
index e6a62cb837cd8776b1542b1ef11a151f3fb7ddee..cf9b342d0aebfa248437d33d7a720b1a1116608a 100644 (file)
@@ -1,9 +1,33 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
+# Files in the config/locales directory are used for internationalization
+# and are automatically loaded by Rails. If you want to use locales other
+# than English, add the necessary files in this directory.
 #
-# SPDX-License-Identifier: AGPL-3.0
-
-# Sample localization file for English. Add more files in this directory for other locales.
-# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
+# To use the locales, use `I18n.t`:
+#
+#     I18n.t 'hello'
+#
+# In views, this is aliased to just `t`:
+#
+#     <%= t('hello') %>
+#
+# To use a different locale, set it with `I18n.locale`:
+#
+#     I18n.locale = :es
+#
+# This would use the information in config/locales/es.yml.
+#
+# The following keys must be escaped otherwise they will not be retrieved by
+# the default I18n backend:
+#
+# true, false, on, off, yes, no
+#
+# Instead, surround them with single quotes.
+#
+# en:
+#   'true': 'foo'
+#
+# To learn more, please read the Rails Internationalization guide
+# available at https://guides.rubyonrails.org/i18n.html.
 
 en:
   hello: "Hello world"
index 87e2737575675e2d37fc9c2b778771be89001193..b87e86f664de7e3230331e8233744ac589e4a169 100644 (file)
@@ -44,7 +44,9 @@ Rails.application.routes.draw do
         get 'secret_mounts', on: :member
         get 'current', on: :collection
       end
-      resources :container_requests
+      resources :container_requests do
+        get 'container_status', on: :member
+      end
       resources :jobs do
         get 'queue', on: :collection
         get 'queue_size', on: :collection
index 049b5e2d639baa303ee36f404defb73482402ed4..2c1b4060007914778625cb0c31001cbec1b11219 100644 (file)
@@ -17,7 +17,7 @@ class RenameMetadataAttributes < ActiveRecord::Migration[4.2]
       Metadatum.where('head like ?', 'orvos#%').each do |m|
         kind_uuid = m.head.match /^(orvos\#.*)\#([-0-9a-z]+)$/
         if kind_uuid
-          m.update_attributes(head_kind: kind_uuid[1],
+          m.update(head_kind: kind_uuid[1],
                               head: kind_uuid[2])
         end
       end
@@ -28,7 +28,7 @@ class RenameMetadataAttributes < ActiveRecord::Migration[4.2]
   def down
     begin
       Metadatum.where('head_kind is not null and head_kind <> ? and head is not null', '').each do |m|
-        m.update_attributes(head: m.head_kind + '#' + m.head)
+        m.update(head: m.head_kind + '#' + m.head)
       end
     rescue
     end
index 71f769c157c96ca4ba60d6b94244d99d54c810ad..0a05718fdd5970308b410e6b03b7cdbf25f76c9d 100644 (file)
@@ -6,13 +6,13 @@ class SetGroupClassOnAnonymousGroup < ActiveRecord::Migration[4.2]
   include CurrentApiClient
   def up
     act_as_system_user do
-      anonymous_group.update_attributes group_class: 'role', name: 'Anonymous users', description: 'Anonymous users'
+      anonymous_group.update group_class: 'role', name: 'Anonymous users', description: 'Anonymous users'
     end
   end
 
   def down
     act_as_system_user do
-      anonymous_group.update_attributes group_class: nil, name: 'Anonymous group', description: 'Anonymous group'
+      anonymous_group.update group_class: nil, name: 'Anonymous group', description: 'Anonymous group'
     end
   end
 end
index 8814fc87d330875c14f53ccf9d4e6cd5983c247f..1d3a6ed1b4ab47576f02660cc63728fbc5cabd6f 100644 (file)
@@ -107,7 +107,7 @@ class FixCollectionPortableDataHashWithHintedManifest < ActiveRecord::Migration[
       attributes[:properties]["migrated_from"] ||= coll.uuid
       coll_copy = Collection.create!(attributes)
       Log.log_create(coll_copy)
-      coll.update_attributes(portable_data_hash: stripped_pdh)
+      coll.update(portable_data_hash: stripped_pdh)
       Log.log_update(coll, start_log)
     end
   end
index b321422143b9e7df21ed6c7cfb55a3193ba6ca80..ed6be3bfe1cd0f208a055cce603528341190b586 100644 (file)
@@ -8,7 +8,7 @@ class RecomputeFileNamesIndex < ActiveRecord::Migration[4.2]
     Collection.select(:portable_data_hash, :manifest_text).where(portable_data_hash: pdhs).distinct(:portable_data_hash).each do |c|
       ActiveRecord::Base.connection.exec_query("update collections set file_names=$1 where portable_data_hash=$2",
                                                "update file_names index",
-                                               [[nil, c.manifest_files], [nil, c.portable_data_hash]])
+                                               [c.manifest_files, c.portable_data_hash])
     end
     ActiveRecord::Base.connection.exec_query('COMMIT')
   end
index 30e6463beb62c795485cded973c52501b14ee0ce..f1280597f9db71cc9504a065c923347bec173b7a 100644 (file)
@@ -14,11 +14,11 @@ class WriteViaAllUsers < ActiveRecord::Migration[5.2]
     ActiveRecord::Base.connection.exec_query(
       "update links set name=$1 where link_class=$2 and name=$3 and tail_uuid like $4 and head_uuid = $5",
       "migrate", [
-        [nil, to],
-        [nil, "permission"],
-        [nil, from],
-        [nil, "_____-tpzed-_______________"],
-        [nil, all_users_group_uuid],
+        to,
+        "permission",
+        from,
+        "_____-tpzed-_______________",
+        all_users_group_uuid,
       ])
   end
 end
index ec9ea591d45c22d23e730fabe40db79519d757cf..6aef343f1c906edd8f365e604afc07aa80dcac57 100644 (file)
@@ -2,37 +2,41 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+require 'update_permissions'
+
 class DedupPermissionLinks < ActiveRecord::Migration[5.2]
   include CurrentApiClient
   def up
     act_as_system_user do
-      rows = ActiveRecord::Base.connection.select_all("SELECT MIN(uuid) AS uuid, COUNT(uuid) AS n FROM links
-        WHERE tail_uuid IS NOT NULL
-         AND head_uuid IS NOT NULL
-         AND link_class = 'permission'
-         AND name in ('can_read', 'can_write', 'can_manage')
-        GROUP BY (tail_uuid, head_uuid)
-        HAVING COUNT(uuid) > 1")
-      rows.each do |row|
-        Rails.logger.debug "DedupPermissionLinks: consolidating #{row['n']} links into #{row['uuid']}"
-        link = Link.find_by_uuid(row['uuid'])
-        # This no-op update has the side effect that the update hooks
-        # will merge the highest available permission into this one
-        # and then delete the others.
-        link.update_attributes!(properties: link.properties.dup)
-      end
+      batch_update_permissions do
+        rows = ActiveRecord::Base.connection.select_all("SELECT MIN(uuid) AS uuid, COUNT(uuid) AS n FROM links
+          WHERE tail_uuid IS NOT NULL
+           AND head_uuid IS NOT NULL
+           AND link_class = 'permission'
+           AND name in ('can_read', 'can_write', 'can_manage')
+          GROUP BY (tail_uuid, head_uuid)
+          HAVING COUNT(uuid) > 1")
+        rows.each do |row|
+          Rails.logger.debug "DedupPermissionLinks: consolidating #{row['n']} links into #{row['uuid']}"
+          link = Link.find_by_uuid(row['uuid'])
+          # This no-op update has the side effect that the update hooks
+          # will merge the highest available permission into this one
+          # and then delete the others.
+          link.update!(properties: link.properties.dup)
+        end
 
-      rows = ActiveRecord::Base.connection.select_all("SELECT MIN(uuid) AS uuid, COUNT(uuid) AS n FROM links
-        WHERE tail_uuid IS NOT NULL
-         AND head_uuid IS NOT NULL
-         AND link_class = 'permission'
-         AND name = 'can_login'
-        GROUP BY (tail_uuid, head_uuid, properties)
-        HAVING COUNT(uuid) > 1")
-      rows.each do |row|
-        Rails.logger.debug "DedupPermissionLinks: consolidating #{row['n']} links into #{row['uuid']}"
-        link = Link.find_by_uuid(row['uuid'])
-        link.update_attributes!(properties: link.properties.dup)
+        rows = ActiveRecord::Base.connection.select_all("SELECT MIN(uuid) AS uuid, COUNT(uuid) AS n FROM links
+          WHERE tail_uuid IS NOT NULL
+           AND head_uuid IS NOT NULL
+           AND link_class = 'permission'
+           AND name = 'can_login'
+          GROUP BY (tail_uuid, head_uuid, properties)
+          HAVING COUNT(uuid) > 1")
+        rows.each do |row|
+          Rails.logger.debug "DedupPermissionLinks: consolidating #{row['n']} links into #{row['uuid']}"
+          link = Link.find_by_uuid(row['uuid'])
+          link.update!(properties: link.properties.dup)
+        end
       end
     end
   end
diff --git a/services/api/db/migrate/20230421142716_add_name_index_to_collections_and_groups.rb b/services/api/db/migrate/20230421142716_add_name_index_to_collections_and_groups.rb
new file mode 100644 (file)
index 0000000..5fe450d
--- /dev/null
@@ -0,0 +1,14 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class AddNameIndexToCollectionsAndGroups < ActiveRecord::Migration[5.2]
+  def up
+    ActiveRecord::Base.connection.execute 'CREATE INDEX index_groups_on_name on groups USING gin (name gin_trgm_ops)'
+    ActiveRecord::Base.connection.execute 'CREATE INDEX index_collections_on_name on collections USING gin (name gin_trgm_ops)'
+  end
+  def down
+    ActiveRecord::Base.connection.execute 'DROP INDEX index_collections_on_name'
+    ActiveRecord::Base.connection.execute 'DROP INDEX index_groups_on_name'
+  end
+end
diff --git a/services/api/db/migrate/20230503224107_priority_update_functions.rb b/services/api/db/migrate/20230503224107_priority_update_functions.rb
new file mode 100644 (file)
index 0000000..3504a10
--- /dev/null
@@ -0,0 +1,69 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class PriorityUpdateFunctions < ActiveRecord::Migration[5.2]
+  def up
+    ActiveRecord::Base.connection.execute %{
+CREATE OR REPLACE FUNCTION container_priority(for_container_uuid character varying, inherited bigint, inherited_from character varying) returns bigint
+    LANGUAGE sql
+    AS $$
+/* Determine the priority of an individual container.
+   The "inherited" priority comes from the path we followed from the root, the parent container
+   priority hasn't been updated in the table yet but we need to behave it like it has been.
+*/
+select coalesce(max(case when containers.uuid = inherited_from then inherited
+                         when containers.priority is not NULL then containers.priority
+                         else container_requests.priority * 1125899906842624::bigint - (extract(epoch from container_requests.created_at)*1000)::bigint
+                    end), 0) from
+    container_requests left outer join containers on container_requests.requesting_container_uuid = containers.uuid
+    where container_requests.container_uuid = for_container_uuid and container_requests.state = 'Committed' and container_requests.priority > 0;
+$$;
+}
+
+    ActiveRecord::Base.connection.execute %{
+CREATE OR REPLACE FUNCTION container_tree_priorities(for_container_uuid character varying) returns table (pri_container_uuid character varying, upd_priority bigint)
+    LANGUAGE sql
+    AS $$
+/* Calculate the priorities of all containers starting from for_container_uuid.
+   This traverses the process tree downward and calls container_priority for each container
+   and returns a table of container uuids and their new priorities.
+*/
+with recursive tab(upd_container_uuid, upd_priority) as (
+  select for_container_uuid, container_priority(for_container_uuid, 0, '')
+union
+  select containers.uuid, container_priority(containers.uuid, child_requests.upd_priority, child_requests.upd_container_uuid)
+  from (tab join container_requests on tab.upd_container_uuid = container_requests.requesting_container_uuid) as child_requests
+  join containers on child_requests.container_uuid = containers.uuid
+  where containers.state in ('Queued', 'Locked', 'Running')
+)
+select upd_container_uuid, upd_priority from tab;
+$$;
+}
+
+    ActiveRecord::Base.connection.execute %{
+CREATE OR REPLACE FUNCTION container_tree(for_container_uuid character varying) returns table (pri_container_uuid character varying)
+    LANGUAGE sql
+    AS $$
+/* A lighter weight version of the update_priorities query that only returns the containers in a tree,
+   used by SELECT FOR UPDATE.
+*/
+with recursive tab(upd_container_uuid) as (
+  select for_container_uuid
+union
+  select containers.uuid
+  from (tab join container_requests on tab.upd_container_uuid = container_requests.requesting_container_uuid) as child_requests
+  join containers on child_requests.container_uuid = containers.uuid
+  where containers.state in ('Queued', 'Locked', 'Running')
+)
+select upd_container_uuid from tab;
+$$;
+}
+  end
+
+  def down
+    ActiveRecord::Base.connection.execute "DROP FUNCTION container_priority"
+    ActiveRecord::Base.connection.execute "DROP FUNCTION container_tree_priorities"
+    ActiveRecord::Base.connection.execute "DROP FUNCTION container_tree"
+  end
+end
diff --git a/services/api/db/migrate/20230815160000_jsonb_exists_functions.rb b/services/api/db/migrate/20230815160000_jsonb_exists_functions.rb
new file mode 100644 (file)
index 0000000..751babf
--- /dev/null
@@ -0,0 +1,50 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class JsonbExistsFunctions < ActiveRecord::Migration[5.2]
+  def up
+
+    # Define functions for the "?" and "?&" operators.  We can't use
+    # "?" and "?&" directly in ActiveRecord queries because "?" is
+    # used for parameter substitution.
+    #
+    # We used to use jsonb_exists() and jsonb_exists_all() but
+    # apparently Postgres associates indexes with operators but not
+    # with functions, so while a query using an operator can use the
+    # index, the equivalent clause using the function will always
+    # perform a full row scan.
+    #
+    # See ticket https://dev.arvados.org/issues/20858 for examples.
+    #
+    # As a workaround, we can define IMMUTABLE functions, which are
+    # directly inlined into the query, which then uses the index as
+    # intended.
+    #
+    # Huge shout out to this stack overflow post that explained what
+    # is going on and provides the workaround used here.
+    #
+    # https://dba.stackexchange.com/questions/90002/postgresql-operator-uses-index-but-underlying-function-does-not
+
+    ActiveRecord::Base.connection.execute %{
+CREATE OR REPLACE FUNCTION jsonb_exists_inline_op(jsonb, text)
+RETURNS bool
+LANGUAGE sql
+IMMUTABLE
+AS $$SELECT $1 ? $2$$
+}
+
+    ActiveRecord::Base.connection.execute %{
+CREATE OR REPLACE FUNCTION jsonb_exists_all_inline_op(jsonb, text[])
+RETURNS bool
+LANGUAGE sql
+IMMUTABLE
+AS 'SELECT $1 ?& $2'
+}
+  end
+
+  def down
+    ActiveRecord::Base.connection.execute "DROP FUNCTION jsonb_exists_inline_op"
+    ActiveRecord::Base.connection.execute "DROP FUNCTION jsonb_exists_all_inline_op"
+  end
+end
diff --git a/services/api/db/migrate/20230821000000_priority_update_fix.rb b/services/api/db/migrate/20230821000000_priority_update_fix.rb
new file mode 100644 (file)
index 0000000..514f0d4
--- /dev/null
@@ -0,0 +1,30 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class PriorityUpdateFix < ActiveRecord::Migration[5.2]
+  def up
+    ActiveRecord::Base.connection.execute %{
+CREATE OR REPLACE FUNCTION container_priority(for_container_uuid character varying, inherited bigint, inherited_from character varying) returns bigint
+    LANGUAGE sql
+    AS $$
+/* Determine the priority of an individual container.
+   The "inherited" priority comes from the path we followed from the root, the parent container
+   priority hasn't been updated in the table yet but we need to behave it like it has been.
+*/
+select coalesce(max(case when containers.uuid = inherited_from then inherited
+                         when containers.priority is not NULL then containers.priority
+                         else container_requests.priority * 1125899906842624::bigint - (extract(epoch from container_requests.created_at)*1000)::bigint
+                    end), 0) from
+    container_requests left outer join containers on container_requests.requesting_container_uuid = containers.uuid
+    where container_requests.container_uuid = for_container_uuid and
+          container_requests.state = 'Committed' and
+          container_requests.priority > 0 and
+          container_requests.owner_uuid not in (select group_uuid from trashed_groups);
+$$;
+}
+  end
+
+  def down
+  end
+end
diff --git a/services/api/db/migrate/20230922000000_add_btree_name_index_to_collections_and_groups.rb b/services/api/db/migrate/20230922000000_add_btree_name_index_to_collections_and_groups.rb
new file mode 100644 (file)
index 0000000..7e6e725
--- /dev/null
@@ -0,0 +1,24 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class AddBtreeNameIndexToCollectionsAndGroups < ActiveRecord::Migration[5.2]
+  #
+  # We previously added 'index_groups_on_name' and
+  # 'index_collections_on_name' but those are 'gin_trgm_ops' which is
+  # used with 'ilike' searches but despite documentation suggesting
+  # they would be, experience has shown these indexes don't get used
+  # for '=' (and/or they are much slower than the btree for exact
+  # matches).
+  #
+  # So we want to add a regular btree index.
+  #
+  def up
+    ActiveRecord::Base.connection.execute 'CREATE INDEX index_groups_on_name_btree on groups USING btree (name)'
+    ActiveRecord::Base.connection.execute 'CREATE INDEX index_collections_on_name_btree on collections USING btree (name)'
+  end
+  def down
+    ActiveRecord::Base.connection.execute 'DROP INDEX IF EXISTS index_collections_on_name_btree'
+    ActiveRecord::Base.connection.execute 'DROP INDEX IF EXISTS index_groups_on_name_btree'
+  end
+end
diff --git a/services/api/db/migrate/20231013000000_compute_permission_index.rb b/services/api/db/migrate/20231013000000_compute_permission_index.rb
new file mode 100644 (file)
index 0000000..ecd85ef
--- /dev/null
@@ -0,0 +1,27 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class ComputePermissionIndex < ActiveRecord::Migration[5.2]
+  def up
+    # The inner part of compute_permission_subgraph has a query clause like this:
+    #
+    #    where u.perm_origin_uuid = m.target_uuid AND m.traverse_owned
+    #         AND (m.user_uuid = m.target_uuid or m.target_uuid not like '_____-tpzed-_______________')
+    #
+    # This will end up doing a sequential scan on
+    # materialized_permissions, which can easily have millions of
+    # rows, unless we fully index the table for this query.  In one test,
+    # this brought the compute_permission_subgraph query from over 6
+    # seconds down to 250ms.
+    #
+    ActiveRecord::Base.connection.execute "drop index if exists index_materialized_permissions_target_is_not_user"
+    ActiveRecord::Base.connection.execute %{
+create index index_materialized_permissions_target_is_not_user on materialized_permissions (target_uuid, traverse_owned, (user_uuid = target_uuid or target_uuid not like '_____-tpzed-_______________'));
+}
+  end
+
+  def down
+    ActiveRecord::Base.connection.execute "drop index if exists index_materialized_permissions_target_is_not_user"
+  end
+end
index 002b470b120c81fd100b86d701496826cc074eda..c0d4263d97aa6bdde258688d091b5cf69fdd47af 100644 (file)
@@ -62,10 +62,10 @@ with
      permission (permission origin is self).
   */
   perm_from_start(perm_origin_uuid, target_uuid, val, traverse_owned) as (
-    
+
 WITH RECURSIVE
         traverse_graph(origin_uuid, target_uuid, val, traverse_owned, starting_set) as (
-            
+
              values (perm_origin_uuid, starting_uuid, starting_perm,
                     should_traverse_owned(starting_uuid, starting_perm),
                     (perm_origin_uuid = starting_uuid or starting_uuid not like '_____-tpzed-_______________'))
@@ -107,10 +107,10 @@ case (edges.edge_id = perm_edge_id)
        can_manage permission granted by ownership.
   */
   additional_perms(perm_origin_uuid, target_uuid, val, traverse_owned) as (
-    
+
 WITH RECURSIVE
         traverse_graph(origin_uuid, target_uuid, val, traverse_owned, starting_set) as (
-            
+
     select edges.tail_uuid as origin_uuid, edges.head_uuid as target_uuid, edges.val,
            should_traverse_owned(edges.head_uuid, edges.val),
            edges.head_uuid like '_____-j7d0g-_______________'
@@ -190,6 +190,92 @@ case (edges.edge_id = perm_edge_id)
 $$;
 
 
+--
+-- Name: container_priority(character varying, bigint, character varying); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.container_priority(for_container_uuid character varying, inherited bigint, inherited_from character varying) RETURNS bigint
+    LANGUAGE sql
+    AS $$
+/* Determine the priority of an individual container.
+   The "inherited" priority comes from the path we followed from the root, the parent container
+   priority hasn't been updated in the table yet but we need to behave it like it has been.
+*/
+select coalesce(max(case when containers.uuid = inherited_from then inherited
+                         when containers.priority is not NULL then containers.priority
+                         else container_requests.priority * 1125899906842624::bigint - (extract(epoch from container_requests.created_at)*1000)::bigint
+                    end), 0) from
+    container_requests left outer join containers on container_requests.requesting_container_uuid = containers.uuid
+    where container_requests.container_uuid = for_container_uuid and
+          container_requests.state = 'Committed' and
+          container_requests.priority > 0 and
+          container_requests.owner_uuid not in (select group_uuid from trashed_groups);
+$$;
+
+
+--
+-- Name: container_tree(character varying); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.container_tree(for_container_uuid character varying) RETURNS TABLE(pri_container_uuid character varying)
+    LANGUAGE sql
+    AS $$
+/* A lighter weight version of the update_priorities query that only returns the containers in a tree,
+   used by SELECT FOR UPDATE.
+*/
+with recursive tab(upd_container_uuid) as (
+  select for_container_uuid
+union
+  select containers.uuid
+  from (tab join container_requests on tab.upd_container_uuid = container_requests.requesting_container_uuid) as child_requests
+  join containers on child_requests.container_uuid = containers.uuid
+  where containers.state in ('Queued', 'Locked', 'Running')
+)
+select upd_container_uuid from tab;
+$$;
+
+
+--
+-- Name: container_tree_priorities(character varying); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.container_tree_priorities(for_container_uuid character varying) RETURNS TABLE(pri_container_uuid character varying, upd_priority bigint)
+    LANGUAGE sql
+    AS $$
+/* Calculate the priorities of all containers starting from for_container_uuid.
+   This traverses the process tree downward and calls container_priority for each container
+   and returns a table of container uuids and their new priorities.
+*/
+with recursive tab(upd_container_uuid, upd_priority) as (
+  select for_container_uuid, container_priority(for_container_uuid, 0, '')
+union
+  select containers.uuid, container_priority(containers.uuid, child_requests.upd_priority, child_requests.upd_container_uuid)
+  from (tab join container_requests on tab.upd_container_uuid = container_requests.requesting_container_uuid) as child_requests
+  join containers on child_requests.container_uuid = containers.uuid
+  where containers.state in ('Queued', 'Locked', 'Running')
+)
+select upd_container_uuid, upd_priority from tab;
+$$;
+
+
+--
+-- Name: jsonb_exists_all_inline_op(jsonb, text[]); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.jsonb_exists_all_inline_op(jsonb, text[]) RETURNS boolean
+    LANGUAGE sql IMMUTABLE
+    AS $_$SELECT $1 ?& $2$_$;
+
+
+--
+-- Name: jsonb_exists_inline_op(jsonb, text); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.jsonb_exists_inline_op(jsonb, text) RETURNS boolean
+    LANGUAGE sql IMMUTABLE
+    AS $_$SELECT $1 ? $2$_$;
+
+
 --
 -- Name: project_subtree_with_is_frozen(character varying, boolean); Type: FUNCTION; Schema: public; Owner: -
 --
@@ -254,6 +340,8 @@ $$;
 
 SET default_tablespace = '';
 
+SET default_with_oids = false;
+
 --
 -- Name: api_client_authorizations; Type: TABLE; Schema: public; Owner: -
 --
@@ -1942,6 +2030,20 @@ CREATE INDEX index_collections_on_is_trashed ON public.collections USING btree (
 CREATE INDEX index_collections_on_modified_at_and_uuid ON public.collections USING btree (modified_at, uuid);
 
 
+--
+-- Name: index_collections_on_name; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_collections_on_name ON public.collections USING gin (name public.gin_trgm_ops);
+
+
+--
+-- Name: index_collections_on_name_btree; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_collections_on_name_btree ON public.collections USING btree (name);
+
+
 --
 -- Name: index_collections_on_owner_uuid; Type: INDEX; Schema: public; Owner: -
 --
@@ -2131,6 +2233,20 @@ CREATE INDEX index_groups_on_is_trashed ON public.groups USING btree (is_trashed
 CREATE INDEX index_groups_on_modified_at_and_uuid ON public.groups USING btree (modified_at, uuid);
 
 
+--
+-- Name: index_groups_on_name; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_groups_on_name ON public.groups USING gin (name public.gin_trgm_ops);
+
+
+--
+-- Name: index_groups_on_name_btree; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_groups_on_name_btree ON public.groups USING btree (name);
+
+
 --
 -- Name: index_groups_on_owner_uuid; Type: INDEX; Schema: public; Owner: -
 --
@@ -2474,6 +2590,13 @@ CREATE INDEX index_logs_on_summary ON public.logs USING btree (summary);
 CREATE UNIQUE INDEX index_logs_on_uuid ON public.logs USING btree (uuid);
 
 
+--
+-- Name: index_materialized_permissions_target_is_not_user; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_materialized_permissions_target_is_not_user ON public.materialized_permissions USING btree (target_uuid, traverse_owned, ((((user_uuid)::text = (target_uuid)::text) OR ((target_uuid)::text !~~ '_____-tpzed-_______________'::text))));
+
+
 --
 -- Name: index_nodes_on_created_at; Type: INDEX; Schema: public; Owner: -
 --
@@ -3187,6 +3310,10 @@ INSERT INTO "schema_migrations" (version) VALUES
 ('20220726034131'),
 ('20220804133317'),
 ('20221219165512'),
-('20221230155924');
-
-
+('20221230155924'),
+('20230421142716'),
+('20230503224107'),
+('20230815160000'),
+('20230821000000'),
+('20230922000000'),
+('20231013000000');
index 570f4601c5d9bdeaf7220c6b4676be25a316875d..cccbc1b56b19639a903eec1d238d12174b5cb0e9 100644 (file)
@@ -5,13 +5,9 @@
 fpm_depends+=('git >= 1.7.10')
 
 case "$TARGET" in
-    centos*)
+    centos*|rocky*)
         fpm_depends+=(libcurl-devel postgresql-devel bison make automake gcc gcc-c++ postgresql shared-mime-info)
         ;;
-    ubuntu1804)
-        fpm_depends+=(libcurl-ssl-dev libpq-dev g++ bison zlib1g-dev make postgresql-client shared-mime-info)
-        fpm_conflicts+=(ruby-bundler)
-        ;;
     debian* | ubuntu*)
         fpm_depends+=(libcurl-ssl-dev libpq-dev g++ bison zlib1g-dev make postgresql-client shared-mime-info)
         ;;
index 335608b2b6611eaac1eba516219d457f549c6862..95685ea5fe65d5db24e605de66c331e1f910f7fa 100644 (file)
@@ -2,9 +2,6 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-# If you change this file, you'll probably also want to make the same
-# changes in apps/workbench/lib/app_version.rb.
-
 class AppVersion
   def self.git(*args, &block)
     IO.popen(["git", "--git-dir", ".git"] + args, "r",
index 6f30f5ae33801afabd6c6a5e3fa6805c05e628fa..e09037819c64fff413df491ce9e74e6d5febfaef 100644 (file)
@@ -22,8 +22,8 @@ module CanBeAnOwner
       klass = t.classify.constantize
       next unless klass and 'owner_uuid'.in?(klass.columns.collect(&:name))
       base.has_many(t.to_sym,
-                    foreign_key: :owner_uuid,
-                    primary_key: :uuid,
+                    foreign_key: 'owner_uuid',
+                    primary_key: 'uuid',
                     dependent: :restrict_with_exception)
     end
     # We need custom protection for changing an owner's primary
@@ -62,7 +62,7 @@ module CanBeAnOwner
                   # "name" arg is a query label that appears in logs:
                   "descendant_project_uuids for #{self.uuid}",
                   # "binds" arg is an array of [col_id, value] for '$1' vars:
-                  [[nil, self.uuid], [nil, 'project']],
+                  [self.uuid, 'project'],
                   ).rows.map do |project_uuid,|
       project_uuid
     end
@@ -75,7 +75,7 @@ module CanBeAnOwner
 
     # Check for objects that have my old uuid listed as their owner.
     self.class.reflect_on_all_associations(:has_many).each do |assoc|
-      next unless assoc.foreign_key == :owner_uuid
+      next unless assoc.foreign_key == 'owner_uuid'
       if assoc.klass.where(owner_uuid: uuid_was).any?
         errors.add(:uuid,
                    "cannot be changed on a #{self.class} that owns objects")
index f421fb5b2a07817905c28bbd1a463da9be936ac5..1d897b39bf4de9af98b4710035199ce831a97d5c 100644 (file)
@@ -2,6 +2,16 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+# When loading YAML, deserialize :foo as ":foo", rather than raising
+# "Psych::DisallowedClass: Tried to load unspecified class: Symbol"
+class Psych::ScalarScanner
+  alias :orig_tokenize :tokenize
+  def tokenize string
+    return string if string =~ /^:[a-zA-Z]/
+    orig_tokenize(string)
+  end
+end
+
 module Psych
   module Visitors
     class YAMLTree < Psych::Visitors::Visitor
@@ -226,7 +236,7 @@ class ConfigLoader
       if erb
         yaml = ERB.new(yaml).result(binding)
       end
-      YAML.load(yaml, deserialize_symbols: false)
+      YAML.safe_load(yaml)
     else
       {}
     end
index ee666b77ab78632f843211fc9e510f9dd11f564c..7c99c911f8d58d563d0902fb190cb4f87b01726c 100644 (file)
@@ -2,16 +2,6 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-$system_user = nil
-$system_group = nil
-$all_users_group = nil
-$anonymous_user = nil
-$anonymous_group = nil
-$anonymous_group_read_permission = nil
-$empty_collection = nil
-$public_project_group = nil
-$public_project_group_read_permission = nil
-
 module CurrentApiClient
   def current_user
     Thread.current[:user]
@@ -74,26 +64,26 @@ module CurrentApiClient
   end
 
   def system_user
-    $system_user = check_cache $system_user do
-      real_current_user = Thread.current[:user]
-      begin
-        Thread.current[:user] = User.new(is_admin: true,
-                                         is_active: true,
-                                         uuid: system_user_uuid)
+    real_current_user = Thread.current[:user]
+    begin
+      Thread.current[:user] = User.new(is_admin: true,
+                                       is_active: true,
+                                       uuid: system_user_uuid)
+      $system_user = check_cache($system_user) do
         User.where(uuid: system_user_uuid).
           first_or_create!(is_active: true,
                            is_admin: true,
                            email: 'root',
                            first_name: 'root',
                            last_name: '')
-      ensure
-        Thread.current[:user] = real_current_user
       end
+    ensure
+      Thread.current[:user] = real_current_user
     end
   end
 
   def system_group
-    $system_group = check_cache $system_group do
+    $system_group = check_cache($system_group) do
       act_as_system_user do
         ActiveRecord::Base.transaction do
           Group.where(uuid: system_group_uuid).
@@ -120,7 +110,7 @@ module CurrentApiClient
   end
 
   def all_users_group
-    $all_users_group = check_cache $all_users_group do
+    $all_users_group = check_cache($all_users_group) do
       act_as_system_user do
         ActiveRecord::Base.transaction do
           Group.where(uuid: all_users_group_uuid).
@@ -156,7 +146,7 @@ module CurrentApiClient
   end
 
   def anonymous_group
-    $anonymous_group = check_cache $anonymous_group do
+    $anonymous_group = check_cache($anonymous_group) do
       act_as_system_user do
         ActiveRecord::Base.transaction do
           Group.where(uuid: anonymous_group_uuid).
@@ -169,8 +159,7 @@ module CurrentApiClient
   end
 
   def anonymous_group_read_permission
-    $anonymous_group_read_permission =
-        check_cache $anonymous_group_read_permission do
+    $anonymous_group_read_permission = check_cache($anonymous_group_read_permission) do
       act_as_system_user do
         Link.where(tail_uuid: all_users_group.uuid,
                    head_uuid: anonymous_group.uuid,
@@ -181,7 +170,7 @@ module CurrentApiClient
   end
 
   def anonymous_user
-    $anonymous_user = check_cache $anonymous_user do
+    $anonymous_user = check_cache($anonymous_user) do
       act_as_system_user do
         User.where(uuid: anonymous_user_uuid).
           first_or_create!(is_active: false,
@@ -201,7 +190,7 @@ module CurrentApiClient
   end
 
   def public_project_group
-    $public_project_group = check_cache $public_project_group do
+    $public_project_group = check_cache($public_project_group) do
       act_as_system_user do
         ActiveRecord::Base.transaction do
           Group.where(uuid: public_project_uuid).
@@ -214,8 +203,7 @@ module CurrentApiClient
   end
 
   def public_project_read_permission
-    $public_project_group_read_permission =
-        check_cache $public_project_group_read_permission do
+    $public_project_group_read_permission = check_cache($public_project_group_read_permission) do
       act_as_system_user do
         Link.where(tail_uuid: anonymous_group.uuid,
                    head_uuid: public_project_group.uuid,
@@ -226,7 +214,7 @@ module CurrentApiClient
   end
 
   def anonymous_user_token_api_client
-    $anonymous_user_token_api_client = check_cache $anonymous_user_token_api_client do
+    $anonymous_user_token_api_client = check_cache($anonymous_user_token_api_client) do
       act_as_system_user do
         ActiveRecord::Base.transaction do
           ApiClient.find_or_create_by!(is_trusted: false, url_prefix: "", name: "AnonymousUserToken")
@@ -236,7 +224,7 @@ module CurrentApiClient
   end
 
   def system_root_token_api_client
-    $system_root_token_api_client = check_cache $system_root_token_api_client do
+    $system_root_token_api_client = check_cache($system_root_token_api_client) do
       act_as_system_user do
         ActiveRecord::Base.transaction do
           ApiClient.find_or_create_by!(is_trusted: true, url_prefix: "", name: "SystemRootToken")
@@ -250,7 +238,7 @@ module CurrentApiClient
   end
 
   def empty_collection
-    $empty_collection = check_cache $empty_collection do
+    $empty_collection = check_cache($empty_collection) do
       act_as_system_user do
         ActiveRecord::Base.transaction do
           Collection.
@@ -269,31 +257,41 @@ module CurrentApiClient
     end
   end
 
-  private
-
-  # If the given value is nil, or the cache has been cleared since it
-  # was set, yield. Otherwise, return the given value.
-  def check_cache value
-    if not Rails.env.test? and
-        ActionController::Base.cache_store.is_a? ActiveSupport::Cache::FileStore and
-        not File.owned? ActionController::Base.cache_store.cache_path
-      # If we don't own the cache dir, we're probably
-      # crunch-dispatch. Whoever we are, using this cache is likely to
-      # either fail or screw up the cache for someone else. So we'll
-      # just assume the $globals are OK to live forever.
-      #
-      # The reason for making the globals expire with the cache in the
-      # first place is to avoid leaking state between test cases: in
-      # production, we don't expect the database seeds to ever go away
-      # even when the cache is cleared, so there's no particular
-      # reason to expire our global variables.
+  # Purge the module globals if necessary. If the cached value is
+  # non-nil and the globals weren't purged, return the cached
+  # value. Otherwise, call the block.
+  #
+  # Purge is only done in test mode.
+  def check_cache(cached)
+    if Rails.env != 'test'
+      return (cached || yield)
+    end
+    t = Rails.cache.fetch "CurrentApiClient.$system_globals_reset" do
+      Time.now.to_f
+    end
+    if t != $system_globals_reset
+      reset_system_globals(t)
+      yield
     else
-      Rails.cache.fetch "CurrentApiClient.$globals" do
-        value = nil
-        true
-      end
+      cached || yield
     end
-    return value unless value.nil?
-    yield
   end
+
+  def reset_system_globals(t)
+    $system_globals_reset = t
+    $system_user = nil
+    $system_group = nil
+    $all_users_group = nil
+    $anonymous_group = nil
+    $anonymous_group_read_permission = nil
+    $anonymous_user = nil
+    $public_project_group = nil
+    $public_project_group_read_permission = nil
+    $anonymous_user_token_api_client = nil
+    $system_root_token_api_client = nil
+    $empty_collection = nil
+  end
+  module_function :reset_system_globals
 end
+
+CurrentApiClient.reset_system_globals(0)
index 5e1634ecb96f17661002afc3eb62dea44100adad..2d58e3c3894fc0ccf35732fa8d28096672bfdf9c 100644 (file)
@@ -6,10 +6,10 @@ module DbCurrentTime
   CURRENT_TIME_SQL = "SELECT clock_timestamp() AT TIME ZONE 'UTC'"
 
   def db_current_time
-    Time.parse(ActiveRecord::Base.connection.select_value(CURRENT_TIME_SQL) + " +0000")
+    ActiveRecord::Base.connection.select_value(CURRENT_TIME_SQL)
   end
 
   def db_transaction_time
-    Time.parse(ActiveRecord::Base.connection.select_value("SELECT current_timestamp AT TIME ZONE 'UTC'") + " +0000")
+    ActiveRecord::Base.connection.select_value("SELECT current_timestamp AT TIME ZONE 'UTC'")
   end
 end
index cef76f08a5c93342f2ba02bf4ec699d2bf98bdd3..6718d384ee1b0b184f0fa0332121bb23e094a6e2 100644 (file)
@@ -47,7 +47,7 @@ def check_enable_legacy_jobs_api
 
   if Rails.configuration.Containers.JobsAPI.Enable == "false" ||
      (Rails.configuration.Containers.JobsAPI.Enable == "auto" &&
-      Job.count == 0)
+      ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM jobs LIMIT 1") == 0)
     Rails.configuration.API.DisabledAPIs.merge! Disable_jobs_api_method_list
   end
 end
index 2074566941fec7c6a79903df712b82541e9a5853..217113beec34d8193bce89c7cf5c9517ada014fa 100644 (file)
@@ -14,14 +14,14 @@ module HasUuid
     base.has_many(:links_via_head,
                   -> { where("not (link_class = 'permission')") },
                   class_name: 'Link',
-                  foreign_key: :head_uuid,
-                  primary_key: :uuid,
+                  foreign_key: 'head_uuid',
+                  primary_key: 'uuid',
                   dependent: :destroy)
     base.has_many(:links_via_tail,
                   -> { where("not (link_class = 'permission')") },
                   class_name: 'Link',
-                  foreign_key: :tail_uuid,
-                  primary_key: :uuid,
+                  foreign_key: 'tail_uuid',
+                  primary_key: 'uuid',
                   dependent: :destroy)
   end
 
index 1db7ed0113e27cf8fad51e5c83a2d7153f5ccca0..8987f3364c245a04a988697c9c45510ef75bd9a8 100644 (file)
@@ -8,7 +8,7 @@ module MigrateYAMLToJSON
     n = conn.update(
       "UPDATE #{table} SET #{column}=$1 WHERE #{column}=$2",
       "#{table}.#{column} convert YAML to JSON",
-      [[nil, "{}"], [nil, "--- {}\n"]])
+      ["{}", "--- {}\n"])
     Rails.logger.info("#{table}.#{column}: #{n} rows updated using empty hash")
     finished = false
     while !finished
@@ -16,14 +16,14 @@ module MigrateYAMLToJSON
       conn.exec_query(
         "SELECT id, #{column} FROM #{table} WHERE #{column} LIKE $1 LIMIT 100",
         "#{table}.#{column} check for YAML",
-        [[nil, '---%']],
+        ['---%'],
       ).rows.map do |id, yaml|
         n += 1
-        json = SafeJSON.dump(YAML.load(yaml))
+        json = SafeJSON.dump(YAML.safe_load(yaml))
         conn.exec_query(
           "UPDATE #{table} SET #{column}=$1 WHERE id=$2 AND #{column}=$3",
           "#{table}.#{column} convert YAML to JSON",
-          [[nil, json], [nil, id], [nil, yaml]])
+          [json, id, yaml])
       end
       Rails.logger.info("#{table}.#{column}: #{n} rows updated")
       finished = (n == 0)
index 65c25810acf2e3ef98422a1de3a5c8503e75edfe..e51223254f7d21a34b5910e2f68a59abee4c22cb 100644 (file)
@@ -121,9 +121,9 @@ module RecordFilters
             end
           when 'exists'
             if operand == true
-              cond_out << "jsonb_exists(#{attr_table_name}.#{attr}, ?)"
+              cond_out << "jsonb_exists_inline_op(#{attr_table_name}.#{attr}, ?)"
             elsif operand == false
-              cond_out << "(NOT jsonb_exists(#{attr_table_name}.#{attr}, ?)) OR #{attr_table_name}.#{attr} is NULL"
+              cond_out << "(NOT jsonb_exists_inline_op(#{attr_table_name}.#{attr}, ?)) OR #{attr_table_name}.#{attr} is NULL"
             else
               raise ArgumentError.new("Invalid operand '#{operand}' for '#{operator}' must be true or false")
             end
@@ -140,7 +140,7 @@ module RecordFilters
             raise ArgumentError.new("Invalid attribute '#{attr}' for operator '#{operator}' in filter")
           end
 
-          cond_out << "jsonb_exists(#{attr_table_name}.#{attr}, ?)"
+          cond_out << "jsonb_exists_inline_op(#{attr_table_name}.#{attr}, ?)"
           param_out << operand
         elsif expr = /^ *\( *(\w+) *(<=?|>=?|=) *(\w+) *\) *$/.match(attr)
           if operator != '=' || ![true,"true"].index(operand)
@@ -164,10 +164,10 @@ module RecordFilters
              !(col.andand.type == :jsonb && ['contains', '=', '<>', '!='].index(operator))
             raise ArgumentError.new("Invalid attribute '#{attr}' in filter")
           end
+          attr_type = attr_model_class.attribute_column(attr).type
 
           case operator
           when '=', '<', '<=', '>', '>=', '!=', 'like', 'ilike'
-            attr_type = attr_model_class.attribute_column(attr).type
             operator = '<>' if operator == '!='
             if operand.is_a? String
               if attr_type == :boolean
@@ -181,8 +181,8 @@ module RecordFilters
                 when '0', 'f', 'false', 'n', 'no'
                   operand = false
                 else
-                  raise ArgumentError("Invalid operand '#{operand}' for " \
-                                      "boolean attribute '#{attr}'")
+                  raise ArgumentError.new("Invalid operand '#{operand}' for " \
+                                          "boolean attribute '#{attr}'")
                 end
               end
               if operator == '<>'
@@ -206,6 +206,10 @@ module RecordFilters
               cond_out << "#{attr_table_name}.#{attr} #{operator} ?"
               param_out << operand
             elsif (attr_type == :integer)
+              if !operand.is_a?(Integer) || operand.bit_length > 64
+                raise ArgumentError.new("Invalid operand '#{operand}' "\
+                                        "for integer attribute '#{attr}'")
+              end
               cond_out << "#{attr_table_name}.#{attr} #{operator} ?"
               param_out << operand
             else
@@ -213,17 +217,24 @@ module RecordFilters
                                       "for '#{operator}' operator in filters")
             end
           when 'in', 'not in'
-            if operand.is_a? Array
-              cond_out << "#{attr_table_name}.#{attr} #{operator} (?)"
-              param_out << operand
-              if operator == 'not in' and not operand.include?(nil)
-                # explicitly allow NULL
-                cond_out[-1] = "(#{cond_out[-1]} OR #{attr_table_name}.#{attr} IS NULL)"
-              end
-            else
+            if !operand.is_a? Array
               raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
                                       "for '#{operator}' operator in filters")
             end
+            if attr_type == :integer
+              operand.each do |el|
+                if !el.is_a?(Integer) || el.bit_length > 64
+                  raise ArgumentError.new("Invalid element '#{el}' in array "\
+                                          "for integer attribute '#{attr}'")
+                end
+              end
+            end
+            cond_out << "#{attr_table_name}.#{attr} #{operator} (?)"
+            param_out << operand
+            if operator == 'not in' and not operand.include?(nil)
+              # explicitly allow NULL
+              cond_out[-1] = "(#{cond_out[-1]} OR #{attr_table_name}.#{attr} IS NULL)"
+            end
           when 'is_a'
             operand = [operand] unless operand.is_a? Array
             cond = []
@@ -259,13 +270,18 @@ module RecordFilters
                 raise ArgumentError.new("Invalid element #{operand.inspect} in operand for #{operator.inspect} operator (operand must be a string or array of strings)")
               end
             end
-            # We use jsonb_exists_all(a,b) instead of "a ?& b" because
-            # the pg gem thinks "?" is a bind var. And we use string
-            # interpolation instead of param_out because the pg gem
-            # flattens param_out / doesn't support passing arrays as
-            # bind vars.
+            # We use jsonb_exists_all_inline_op(a,b) instead of "a ?&
+            # b" because the pg gem thinks "?" is a bind var.
+            #
+            # See note in migration
+            # 20230815160000_jsonb_exists_functions about _inline_op
+            # functions.
+            #
+            # We use string interpolation instead of param_out
+            # because the pg gem flattens param_out / doesn't support
+            # passing arrays as bind vars.
             q = operand.map { |s| ActiveRecord::Base.connection.quote(s) }.join(',')
-            cond_out << "jsonb_exists_all(#{attr_table_name}.#{attr}, array[#{q}])"
+            cond_out << "jsonb_exists_all_inline_op(#{attr_table_name}.#{attr}, array[#{q}])"
           else
             raise ArgumentError.new("Invalid operator '#{operator}'")
           end
index 37734e0bb41dce88500b143fab0a71102b1b8b33..c25b9060b4100871e2ec832e318120829db9ef4e 100644 (file)
@@ -16,7 +16,7 @@ class Serializer
   end
 
   def self.legacy_load(s)
-    val = Psych.safe_load(s)
+    val = Psych.safe_load(s, permitted_classes: [Time])
     if val.is_a? String
       # If apiserver was downgraded to a YAML-only version after
       # storing JSON in the database, the old code would have loaded
diff --git a/services/api/lib/simulate_job_log.rb b/services/api/lib/simulate_job_log.rb
deleted file mode 100644 (file)
index abcf42e..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'current_api_client'
-
-module SimulateJobLog
-  include CurrentApiClient
-  def replay(filename, multiplier = 1, simulated_job_uuid = nil)
-    raise "Environment must be development or test" unless [ 'test', 'development' ].include? ENV['RAILS_ENV']
-
-    multiplier = multiplier.to_f
-    multiplier = 1.0 if multiplier <= 0
-
-    actual_start_time = Time.now
-    log_start_time = nil
-
-    if simulated_job_uuid and (job = Job.where(uuid: simulated_job_uuid).first)
-      job_owner_uuid = job.owner_uuid
-    else
-      job_owner_uuid = system_user_uuid
-    end
-
-    act_as_system_user do
-      File.open(filename).each.with_index do |line, index|
-        cols = {}
-        cols[:timestamp], rest_of_line = line.split(' ', 2)
-        begin
-          cols[:timestamp] = Time.strptime( cols[:timestamp], "%Y-%m-%d_%H:%M:%S" )
-        rescue ArgumentError
-          if line =~ /^((?:Sun|Mon|Tue|Wed|Thu|Fri|Sat) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{1,2} \d\d:\d\d:\d\d \d{4}) (.*)/
-            # Wed Nov 19 07:12:39 2014
-            cols[:timestamp] = Time.strptime( $1, "%a %b %d %H:%M:%S %Y" )
-            rest_of_line = $2
-          else
-              STDERR.puts "Ignoring log line because of unknown time format: #{line}"
-          end
-        end
-        cols[:job_uuid], cols[:pid], cols[:task], cols[:event_type], cols[:message] = rest_of_line.split(' ', 5)
-        # Override job uuid with a simulated one if specified
-        cols[:job_uuid] = simulated_job_uuid || cols[:job_uuid]
-        # determine when we want to simulate this log being created, based on the time multiplier
-        log_start_time = cols[:timestamp] if log_start_time.nil?
-        log_time = cols[:timestamp]
-        actual_elapsed_time = Time.now - actual_start_time
-        log_elapsed_time = log_time - log_start_time
-        modified_elapsed_time = log_elapsed_time / multiplier
-        pause_time = modified_elapsed_time - actual_elapsed_time
-        sleep pause_time if pause_time > 0
-
-        Log.new({
-          owner_uuid:  job_owner_uuid,
-          event_at:    Time.zone.local_to_utc(cols[:timestamp]),
-          object_uuid: cols[:job_uuid],
-          event_type:  cols[:event_type],
-          properties:  { 'text' => line }
-        }).save!
-      end
-    end
-
-  end
-end
index 7a665ff7e77d81eee221534a547591d5750ac1b6..70a0f242848a9c6a143f3120b520cb534e90bb0c 100644 (file)
@@ -31,7 +31,7 @@ namespace :db do
       end
       if (auth.user.uuid =~ /-tpzed-000000000000000/).nil? and (auth.user.uuid =~ /-tpzed-anonymouspublic/).nil?
         CurrentApiClientHelper.act_as_system_user do
-          auth.update_attributes!(expires_at: exp_date)
+          auth.update!(expires_at: exp_date)
         end
         token_count += 1
       end
diff --git a/services/api/lib/tasks/replay_job_log.rake b/services/api/lib/tasks/replay_job_log.rake
deleted file mode 100644 (file)
index 9c0f005..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'simulate_job_log'
-desc 'Simulate job logging from a file. Three arguments: log filename, time multipler (optional), simulated job uuid (optional). E.g. (use quotation marks if using spaces between args): rake "replay_job_log[log.txt, 2.0, qr1hi-8i9sb-nf3qk0xzwwz3lre]"'
-task :replay_job_log, [:filename, :multiplier, :uuid] => :environment do |t, args|
-  include SimulateJobLog
-  abort("No filename specified.") if args[:filename].blank?
-  replay( args[:filename], args[:multiplier].to_f, args[:uuid] )
-end
index c99b08513b64a57b046dccea7905ca032bd3b916..50611c305ded5d1d166914b1066f6d348a2af51b 100644 (file)
@@ -93,19 +93,19 @@ end
 module TrashableController
   def destroy
     if !@object.is_trashed
-      @object.update_attributes!(trash_at: db_current_time)
+      @object.update!(trash_at: db_current_time)
     end
     earliest_delete = (@object.trash_at +
                        Rails.configuration.Collections.BlobSigningTTL)
     if @object.delete_at > earliest_delete
-      @object.update_attributes!(delete_at: earliest_delete)
+      @object.update!(delete_at: earliest_delete)
     end
     show
   end
 
   def trash
     if !@object.is_trashed
-      @object.update_attributes!(trash_at: db_current_time)
+      @object.update!(trash_at: db_current_time)
     end
     show
   end
index b7e5476404869f6a89603302eafe828397acd1c5..52e3e0c0814a66d0b44d2fced9bd9a9c109f691f 100644 (file)
@@ -2,12 +2,12 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-require '20200501150153_permission_table_constants'
+require_relative '20200501150153_permission_table_constants'
 
 REVOKE_PERM = 0
 CAN_MANAGE_PERM = 3
 
-def update_permissions perm_origin_uuid, starting_uuid, perm_level, edge_id=nil
+def update_permissions perm_origin_uuid, starting_uuid, perm_level, edge_id=nil, update_all_users=false
   return if Thread.current[:suppress_update_permissions]
 
   #
@@ -100,44 +100,105 @@ def update_permissions perm_origin_uuid, starting_uuid, perm_level, edge_id=nil
     # tested this on Postgres 9.6, so in the future we should reevaluate
     # the performance & query plan on Postgres 12.
     #
+    # Update: as of 2023-10-13, incorrect merge join behavior is still
+    # observed on at least one major user installation that is using
+    # Postgres 14, so it seems this workaround is still needed.
+    #
     # https://git.furworks.de/opensourcemirror/postgresql/commit/a314c34079cf06d05265623dd7c056f8fa9d577f
     #
     # Disable merge join for just this query (also local for this transaction), then reenable it.
     ActiveRecord::Base.connection.exec_query "SET LOCAL enable_mergejoin to false;"
 
-    temptable_perms = "temp_perms_#{rand(2**64).to_s(10)}"
-    ActiveRecord::Base.connection.exec_query %{
-create temporary table #{temptable_perms} on commit drop
-as select * from compute_permission_subgraph($1, $2, $3, $4)
-},
-                                             'update_permissions.select',
-                                             [[nil, perm_origin_uuid],
-                                              [nil, starting_uuid],
-                                              [nil, perm_level],
-                                              [nil, edge_id]]
-
-    ActiveRecord::Base.connection.exec_query "SET LOCAL enable_mergejoin to true;"
-
-    # Now that we have recomputed a set of permissions, delete any
-    # rows from the materialized_permissions table where (target_uuid,
-    # user_uuid) is not present or has perm_level=0 in the recomputed
-    # set.
-    ActiveRecord::Base.connection.exec_delete %{
-delete from #{PERMISSION_VIEW} where
-  target_uuid in (select target_uuid from #{temptable_perms}) and
-  not exists (select 1 from #{temptable_perms}
-              where target_uuid=#{PERMISSION_VIEW}.target_uuid and
-                    user_uuid=#{PERMISSION_VIEW}.user_uuid and
-                    val>0)
+    if perm_origin_uuid[5..11] == '-tpzed-' && !update_all_users
+      # Modifying permission granted to a user, recompute the all permissions for that user
+
+      ActiveRecord::Base.connection.exec_query %{
+with origin_user_perms as (
+    select pq.origin_uuid as user_uuid, target_uuid, pq.val, pq.traverse_owned from (
+    #{PERM_QUERY_TEMPLATE % {:base_case => %{
+        select '#{perm_origin_uuid}'::varchar(255), '#{perm_origin_uuid}'::varchar(255), 3, true, true
+               where exists (select uuid from users where uuid='#{perm_origin_uuid}')
 },
-                                              "update_permissions.delete"
+:edge_perm => %{
+case (edges.edge_id = '#{edge_id}')
+                               when true then #{perm_level}
+                               else edges.val
+                            end
+}
+} }) as pq),
+
+/*
+     Because users always have permission on themselves, this
+     query also makes sure those permission rows are always
+     returned.
+*/
+temptable_perms as (
+      select * from origin_user_perms
+    union all
+      select target_uuid as user_uuid, target_uuid, 3, true
+        from origin_user_perms
+        where origin_user_perms.target_uuid like '_____-tpzed-_______________' and
+              origin_user_perms.target_uuid != '#{perm_origin_uuid}'
+),
+
+/*
+    Now that we have recomputed a set of permissions, delete any
+    rows from the materialized_permissions table where (target_uuid,
+    user_uuid) is not present or has perm_level=0 in the recomputed
+    set.
+*/
+delete_rows as (
+  delete from #{PERMISSION_VIEW} where
+    user_uuid='#{perm_origin_uuid}' and
+    not exists (select 1 from temptable_perms
+                where target_uuid=#{PERMISSION_VIEW}.target_uuid and
+                      user_uuid='#{perm_origin_uuid}' and
+                      val>0)
+)
+
+/*
+  Now insert-or-update permissions in the recomputed set.  The
+  WHERE clause is important to avoid redundantly updating rows
+  that haven't actually changed.
+*/
+insert into #{PERMISSION_VIEW} (user_uuid, target_uuid, perm_level, traverse_owned)
+  select user_uuid, target_uuid, val as perm_level, traverse_owned from temptable_perms where val>0
+on conflict (user_uuid, target_uuid) do update
+set perm_level=EXCLUDED.perm_level, traverse_owned=EXCLUDED.traverse_owned
+where #{PERMISSION_VIEW}.user_uuid=EXCLUDED.user_uuid and
+      #{PERMISSION_VIEW}.target_uuid=EXCLUDED.target_uuid and
+       (#{PERMISSION_VIEW}.perm_level != EXCLUDED.perm_level or
+        #{PERMISSION_VIEW}.traverse_owned != EXCLUDED.traverse_owned);
 
-    # Now insert-or-update permissions in the recomputed set.  The
-    # WHERE clause is important to avoid redundantly updating rows
-    # that haven't actually changed.
+}
+    else
+      # Modifying permission granted to a group, recompute permissions for everything accessible through that group
     ActiveRecord::Base.connection.exec_query %{
+with temptable_perms as (
+  select * from compute_permission_subgraph($1, $2, $3, $4)),
+
+/*
+    Now that we have recomputed a set of permissions, delete any
+    rows from the materialized_permissions table where (target_uuid,
+    user_uuid) is not present or has perm_level=0 in the recomputed
+    set.
+*/
+delete_rows as (
+  delete from #{PERMISSION_VIEW} where
+    target_uuid in (select target_uuid from temptable_perms) and
+    not exists (select 1 from temptable_perms
+                where target_uuid=#{PERMISSION_VIEW}.target_uuid and
+                      user_uuid=#{PERMISSION_VIEW}.user_uuid and
+                      val>0)
+)
+
+/*
+  Now insert-or-update permissions in the recomputed set.  The
+  WHERE clause is important to avoid redundantly updating rows
+  that haven't actually changed.
+*/
 insert into #{PERMISSION_VIEW} (user_uuid, target_uuid, perm_level, traverse_owned)
-  select user_uuid, target_uuid, val as perm_level, traverse_owned from #{temptable_perms} where val>0
+  select user_uuid, target_uuid, val as perm_level, traverse_owned from temptable_perms where val>0
 on conflict (user_uuid, target_uuid) do update
 set perm_level=EXCLUDED.perm_level, traverse_owned=EXCLUDED.traverse_owned
 where #{PERMISSION_VIEW}.user_uuid=EXCLUDED.user_uuid and
@@ -145,7 +206,12 @@ where #{PERMISSION_VIEW}.user_uuid=EXCLUDED.user_uuid and
        (#{PERMISSION_VIEW}.perm_level != EXCLUDED.perm_level or
         #{PERMISSION_VIEW}.traverse_owned != EXCLUDED.traverse_owned);
 },
-                                             "update_permissions.insert"
+                                             'update_permissions.select',
+                                             [perm_origin_uuid,
+                                              starting_uuid,
+                                              perm_level,
+                                              edge_id]
+    end
 
     if perm_level>0
       check_permissions_against_full_refresh
diff --git a/services/api/lib/update_priorities.rb b/services/api/lib/update_priorities.rb
new file mode 100644 (file)
index 0000000..9411534
--- /dev/null
@@ -0,0 +1,31 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+def row_lock_for_priority_update container_uuid
+  # Locks all the containers under this container, and also any
+  # immediate parent containers.  This ensures we have locked
+  # everything that gets touched by either a priority update or state
+  # update.
+  ActiveRecord::Base.connection.exec_query %{
+        select 1 from containers where containers.uuid in (
+  select pri_container_uuid from container_tree($1)
+UNION
+  select container_requests.requesting_container_uuid from container_requests
+    where container_requests.container_uuid = $1
+          and container_requests.state = 'Committed'
+          and container_requests.requesting_container_uuid is not NULL
+)
+        order by containers.uuid for update
+  }, 'select_for_update_priorities', [container_uuid]
+end
+
+def update_priorities starting_container_uuid
+  # Ensure the row locks were taken in order
+  row_lock_for_priority_update starting_container_uuid
+
+  ActiveRecord::Base.connection.exec_query %{
+update containers set priority=computed.upd_priority from container_tree_priorities($1) as computed
+ where containers.uuid = computed.pri_container_uuid and priority != computed.upd_priority
+}, 'update_priorities', [starting_container_uuid]
+end
index ad6aaf9eb567205498a5e5c1b8dd4b61202e0f52..9f8f050c1096e2a030e704f9b51c5820a74e45f4 100755 (executable)
@@ -26,7 +26,9 @@ DEBUG = 1
 # if present, overriding base config parameters as specified
 path = File.absolute_path('../../config/arvados-clients.yml', __FILE__)
 if File.exist?(path) then
-  cp_config = YAML.load_file(path)[ENV['RAILS_ENV']]
+  cp_config = File.open(path) do |f|
+    YAML.safe_load(f, filename: path)[ENV['RAILS_ENV']]
+  end
 else
   puts "Please create a\n #{path}\n file"
   exit 1
index 91acf3e256c96ced3262f946e36c37868507b1b2..98f25ca5378f5d84ea0f19407fd878c75a8a01f2 100755 (executable)
@@ -40,7 +40,9 @@ DEBUG = 1
 # if present, overriding base config parameters as specified
 path = File.dirname(__FILE__) + '/config/arvados-clients.yml'
 if File.exist?(path) then
-  cp_config = YAML.load_file(path)[ENV['RAILS_ENV']]
+  cp_config = File.open(path) do |f|
+    YAML.safe_load(f, filename: path)[ENV['RAILS_ENV']]
+  end
 else
   puts "Please create a\n " + File.dirname(__FILE__) + "/config/arvados-clients.yml\n file"
   exit 1
index 1c14204d986cb916874101c42552a11bb3d49cd8..b2b2c8be1b66c6d716d2057fc2c55c4fad6df53d 100644 (file)
@@ -5,6 +5,7 @@
 active:
   uuid: zzzzz-fngyi-12nc9ov4osp8nae
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   authorized_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   key_type: SSH
   name: active
@@ -13,6 +14,7 @@ active:
 admin:
   uuid: zzzzz-fngyi-g290j3i3u701duh
   owner_uuid: zzzzz-tpzed-d9tiejq69daie8f
+  modified_by_user_uuid: zzzzz-tpzed-d9tiejq69daie8f
   authorized_user_uuid: zzzzz-tpzed-d9tiejq69daie8f
   key_type: SSH
   name: admin
@@ -21,6 +23,7 @@ admin:
 spectator:
   uuid: zzzzz-fngyi-3uze1ipbnz2c2c2
   owner_uuid: zzzzz-tpzed-l1s2piq4t4mps8r
+  modified_by_user_uuid: zzzzz-tpzed-l1s2piq4t4mps8r
   authorized_user_uuid: zzzzz-tpzed-l1s2piq4t4mps8r
   key_type: SSH
   name: spectator
@@ -29,6 +32,7 @@ spectator:
 project_viewer:
   uuid: zzzzz-fngyi-5d3av1396niwcej
   owner_uuid: zzzzz-tpzed-projectviewer1a
+  modified_by_user_uuid: zzzzz-tpzed-projectviewer1a
   authorized_user_uuid: zzzzz-tpzed-projectviewer1a
   key_type: SSH
   name: project_viewer
index a5c3e63dde65ffcd935b016b7f255d6effffbe0e..72aad1d68ee3f28e7fe853cf911ac4841cf896e0 100644 (file)
@@ -220,6 +220,51 @@ foo_collection_in_aproject:
   manifest_text: ". acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:foo\n"
   name: "zzzzz-4zz18-fy296fx3hot09f7 added sometime"
 
+fuse_filters_test_foo:
+  uuid: zzzzz-4zz18-4e2kjqv891jl3p3
+  current_version_uuid: zzzzz-4zz18-4e2kjqv891jl3p3
+  portable_data_hash: 1f4b0bc7583c2a7f9102c395f4ffc5e3+45
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-000000000000000
+  owner_uuid: zzzzz-tpzed-fusefiltertest1
+  created_at: 2024-02-09T12:01:00Z
+  modified_at: 2024-02-09T12:01:01Z
+  updated_at: 2024-02-09T12:01:01Z
+  manifest_text: ". acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:foo\n"
+  name: foo
+  properties:
+    MainFile: foo
+
+fuse_filters_test_bar:
+  uuid: zzzzz-4zz18-qpxqtq2wbjnu630
+  current_version_uuid: zzzzz-4zz18-qpxqtq2wbjnu630
+  portable_data_hash: fa7aeb5140e2848d39b416daeef4ffc5+45
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-d9tiejq69daie8f
+  owner_uuid: zzzzz-tpzed-fusefiltertest1
+  created_at: 2024-02-09T12:02:00Z
+  modified_at: 2024-02-09T12:02:01Z
+  updated_at: 2024-02-09T12:02:01Z
+  manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+  name: bar
+  properties:
+    MainFile: bar
+
+fuse_filters_test_baz:
+  uuid: zzzzz-4zz18-ls97ezovrkkpfxz
+  current_version_uuid: zzzzz-4zz18-ls97ezovrkkpfxz
+  portable_data_hash: ea10d51bcf88862dbcc36eb292017dfd+45
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-d9tiejq69daie8f
+  owner_uuid: zzzzz-tpzed-fusefiltertest1
+  created_at: 2024-02-09T12:03:00Z
+  modified_at: 2024-02-09T12:03:01Z
+  updated_at: 2024-02-09T12:03:01Z
+  manifest_text: ". 73feffa4b7f6bb68e44cf984c85f6e88+3 0:3:baz\n"
+  name: baz
+  properties:
+    MainFile: baz
+
 user_agreement_in_anonymously_accessible_project:
   uuid: zzzzz-4zz18-uukreo9rbgwsujr
   current_version_uuid: zzzzz-4zz18-uukreo9rbgwsujr
@@ -1128,8 +1173,8 @@ collection_<%=i%>_of_10:
   uuid: zzzzz-4zz18-10gneyn6brkx<%= i.to_s.rjust(3, '0') %>
   current_version_uuid: zzzzz-4zz18-10gneyn6brkx<%= i.to_s.rjust(3, '0') %>
   owner_uuid: zzzzz-j7d0g-0010collections
-  created_at: <%= i.minute.ago.to_s(:db) %>
-  modified_at: <%= i.minute.ago.to_s(:db) %>
+  created_at: <%= i.minute.ago.to_fs(:db) %>
+  modified_at: <%= i.minute.ago.to_fs(:db) %>
 <% end %>
 
 # collections in project_with_201_collections
@@ -1141,8 +1186,8 @@ collection_<%=i%>_of_201:
   uuid: zzzzz-4zz18-201gneyn6brd<%= i.to_s.rjust(3, '0') %>
   current_version_uuid: zzzzz-4zz18-201gneyn6brd<%= i.to_s.rjust(3, '0') %>
   owner_uuid: zzzzz-j7d0g-0201collections
-  created_at: <%= i.minute.ago.to_s(:db) %>
-  modified_at: <%= i.minute.ago.to_s(:db) %>
+  created_at: <%= i.minute.ago.to_fs(:db) %>
+  modified_at: <%= i.minute.ago.to_fs(:db) %>
 <% end %>
 
 # Do not add your fixtures below this line as the rest of this file will be trimmed by test_helper
index dca89f56d3bb511612c93b6ce38ce750c630a2f2..71c7a54df3981eb531f0b36d28788aa3a6d29247 100644 (file)
@@ -8,9 +8,9 @@ queued:
   name: queued
   state: Committed
   priority: 1
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  updated_at: <%= 1.minute.ago.to_s(:db) %>
-  modified_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  updated_at: <%= 1.minute.ago.to_fs(:db) %>
+  modified_at: <%= 1.minute.ago.to_fs(:db) %>
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   container_image: test
   cwd: test
@@ -32,9 +32,9 @@ running:
   name: running
   state: Committed
   priority: 501
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  updated_at: <%= 1.minute.ago.to_s(:db) %>
-  modified_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  updated_at: <%= 1.minute.ago.to_fs(:db) %>
+  modified_at: <%= 1.minute.ago.to_fs(:db) %>
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   container_image: test
   cwd: test
@@ -55,9 +55,9 @@ requester_for_running:
   name: requester_for_running_cr
   state: Committed
   priority: 1
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  updated_at: <%= 2.minute.ago.to_s(:db) %>
-  modified_at: <%= 2.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  updated_at: <%= 2.minute.ago.to_fs(:db) %>
+  modified_at: <%= 2.minute.ago.to_fs(:db) %>
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   container_image: test
   cwd: test
@@ -102,9 +102,9 @@ completed:
   name: completed container request
   state: Final
   priority: 1
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  updated_at: <%= 1.minute.ago.to_s(:db) %>
-  modified_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  updated_at: <%= 1.minute.ago.to_fs(:db) %>
+  modified_at: <%= 1.minute.ago.to_fs(:db) %>
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   container_image: test
   cwd: test
@@ -124,7 +124,7 @@ completed-older:
   name: completed
   state: Final
   priority: 1
-  created_at: <%= 30.minute.ago.to_s(:db) %>
+  created_at: <%= 30.minute.ago.to_fs(:db) %>
   updated_at: 2016-01-11 11:11:11.111111111 Z
   modified_at: 2016-01-11 11:11:11.111111111 Z
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
@@ -413,7 +413,7 @@ cr_for_requester2:
   name: requester_cr2
   state: Final
   priority: 1
-  created_at: <%= 30.minute.ago.to_s(:db) %>
+  created_at: <%= 30.minute.ago.to_fs(:db) %>
   updated_at: 2016-01-11 11:11:11.111111111 Z
   modified_at: 2016-01-11 11:11:11.111111111 Z
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
@@ -535,13 +535,13 @@ canceled_with_running_container:
 
 running_to_be_deleted:
   uuid: zzzzz-xvhdp-cr5runningcntnr
-  owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  owner_uuid: zzzzz-j7d0g-rew6elm53kancon
   name: running to be deleted
   state: Committed
   priority: 1
-  created_at: <%= 2.days.ago.to_s(:db) %>
-  updated_at: <%= 1.days.ago.to_s(:db) %>
-  modified_at: <%= 1.days.ago.to_s(:db) %>
+  created_at: <%= 2.days.ago.to_fs(:db) %>
+  updated_at: <%= 1.days.ago.to_fs(:db) %>
+  modified_at: <%= 1.days.ago.to_fs(:db) %>
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   container_image: test
   cwd: test
@@ -562,9 +562,9 @@ completed_with_input_mounts:
   name: completed container request
   state: Final
   priority: 1
-  created_at: <%= 24.hour.ago.to_s(:db) %>
-  updated_at: <%= 24.hour.ago.to_s(:db) %>
-  modified_at: <%= 24.hour.ago.to_s(:db) %>
+  created_at: <%= 24.hour.ago.to_fs(:db) %>
+  updated_at: <%= 24.hour.ago.to_fs(:db) %>
+  modified_at: <%= 24.hour.ago.to_fs(:db) %>
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   container_image: test
   cwd: test
@@ -598,9 +598,9 @@ uncommitted:
   uuid: zzzzz-xvhdp-cr4uncommittedc
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   name: uncommitted
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  updated_at: <%= 1.minute.ago.to_s(:db) %>
-  modified_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  updated_at: <%= 1.minute.ago.to_fs(:db) %>
+  modified_at: <%= 1.minute.ago.to_fs(:db) %>
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   command: ["arvados-cwl-runner", "--local", "--api=containers",
             "/var/lib/cwl/workflow.json", "/var/lib/cwl/cwl.input.json"]
@@ -1019,9 +1019,9 @@ cr_in_trashed_project:
   name: completed container request
   state: Final
   priority: 1
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  updated_at: <%= 1.minute.ago.to_s(:db) %>
-  modified_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  updated_at: <%= 1.minute.ago.to_fs(:db) %>
+  modified_at: <%= 1.minute.ago.to_fs(:db) %>
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   container_image: test
   cwd: test
@@ -1041,9 +1041,9 @@ runtime_token:
   name: queued
   state: Committed
   priority: 1
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  updated_at: <%= 1.minute.ago.to_s(:db) %>
-  modified_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  updated_at: <%= 1.minute.ago.to_fs(:db) %>
+  modified_at: <%= 1.minute.ago.to_fs(:db) %>
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   container_image: test
   cwd: test
@@ -1065,7 +1065,7 @@ runtime_token:
 <% for i in 1..60 do %>
 cr_<%=i%>_of_60:
   uuid: zzzzz-xvhdp-oneof60crs<%= i.to_s.rjust(5, '0') %>
-  created_at: <%= ((i+5)/5).hour.ago.to_s(:db) %>
+  created_at: <%= ((i+5)/5).hour.ago.to_fs(:db) %>
   owner_uuid: zzzzz-j7d0g-nnncrspipelines
   name: cr-<%= i.to_s %>
   output_path: test
index 703d2aafbe5edeeda830cd36cf250ee13e831477..46bc1e50f9da3bdbc417fef55116c74601e7c319 100644 (file)
@@ -33,9 +33,9 @@ running:
   owner_uuid: zzzzz-tpzed-000000000000000
   state: Running
   priority: 12
-  created_at: <%= 1.minute.ago.to_s(:db) %>
-  updated_at: <%= 1.minute.ago.to_s(:db) %>
-  started_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 1.minute.ago.to_fs(:db) %>
+  updated_at: <%= 1.minute.ago.to_fs(:db) %>
+  started_at: <%= 1.minute.ago.to_fs(:db) %>
   container_image: test
   cwd: /tmp
   output_path: /tmp
@@ -59,9 +59,9 @@ running_older:
   owner_uuid: zzzzz-tpzed-000000000000000
   state: Running
   priority: 1
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  updated_at: <%= 2.minute.ago.to_s(:db) %>
-  started_at: <%= 2.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  updated_at: <%= 2.minute.ago.to_fs(:db) %>
+  started_at: <%= 2.minute.ago.to_fs(:db) %>
   container_image: test
   cwd: /tmp
   output_path: /tmp
@@ -82,9 +82,9 @@ locked:
   state: Locked
   locked_by_uuid: zzzzz-gj3su-k9dvestay1plssr
   priority: 0
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  updated_at: <%= 2.minute.ago.to_s(:db) %>
-  modified_at: <%= 2.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  updated_at: <%= 2.minute.ago.to_fs(:db) %>
+  modified_at: <%= 2.minute.ago.to_fs(:db) %>
   container_image: test
   cwd: test
   output_path: test
@@ -353,8 +353,8 @@ ancient_container_with_logs:
   state: Complete
   exit_code: 0
   priority: 1
-  created_at: <%= 2.year.ago.to_s(:db) %>
-  updated_at: <%= 2.year.ago.to_s(:db) %>
+  created_at: <%= 2.year.ago.to_fs(:db) %>
+  updated_at: <%= 2.year.ago.to_fs(:db) %>
   container_image: test
   cwd: test
   output_path: test
@@ -362,7 +362,7 @@ ancient_container_with_logs:
   runtime_constraints:
     ram: 12000000000
     vcpus: 4
-  finished_at: <%= 2.year.ago.to_s(:db) %>
+  finished_at: <%= 2.year.ago.to_fs(:db) %>
   log: ea10d51bcf88862dbcc36eb292017dfd+45
   output: test
   secret_mounts: {}
@@ -374,8 +374,8 @@ previous_container_with_logs:
   state: Complete
   exit_code: 0
   priority: 1
-  created_at: <%= 1.month.ago.to_s(:db) %>
-  updated_at: <%= 1.month.ago.to_s(:db) %>
+  created_at: <%= 1.month.ago.to_fs(:db) %>
+  updated_at: <%= 1.month.ago.to_fs(:db) %>
   container_image: test
   cwd: test
   output_path: test
@@ -383,7 +383,7 @@ previous_container_with_logs:
   runtime_constraints:
     ram: 12000000000
     vcpus: 4
-  finished_at: <%= 1.month.ago.to_s(:db) %>
+  finished_at: <%= 1.month.ago.to_fs(:db) %>
   log: ea10d51bcf88862dbcc36eb292017dfd+45
   output: test
   secret_mounts: {}
@@ -394,8 +394,8 @@ running_container_with_logs:
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   state: Running
   priority: 1
-  created_at: <%= 1.hour.ago.to_s(:db) %>
-  updated_at: <%= 1.hour.ago.to_s(:db) %>
+  created_at: <%= 1.hour.ago.to_fs(:db) %>
+  updated_at: <%= 1.hour.ago.to_fs(:db) %>
   container_image: test
   cwd: test
   output_path: test
@@ -416,9 +416,9 @@ running_to_be_deleted:
   owner_uuid: zzzzz-tpzed-000000000000000
   state: Running
   priority: 1
-  created_at: <%= 1.minute.ago.to_s(:db) %>
-  updated_at: <%= 1.minute.ago.to_s(:db) %>
-  started_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 1.minute.ago.to_fs(:db) %>
+  updated_at: <%= 1.minute.ago.to_fs(:db) %>
+  started_at: <%= 1.minute.ago.to_fs(:db) %>
   container_image: test
   cwd: test
   output_path: test
index 9a2dc169b63aec6ff8d624bf4128c69483e0ce3b..9034ac6ee7d2dd72928388b51b4461bff2814af8 100644 (file)
@@ -172,6 +172,17 @@ afiltergroup5:
   properties:
     filters: [["collections.properties.listprop","contains","elem1"],["uuid", "is_a", "arvados#collection"]]
 
+fuse_filters_test_project:
+  uuid: zzzzz-j7d0g-fusefiltertest1
+  owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  created_at: 2024-02-09T12:00:00Z
+  modified_at: 2024-02-09T12:00:01Z
+  updated_at: 2024-02-09T12:00:01Z
+  name: FUSE Filters Test Project 1
+  group_class: project
+
 future_project_viewing_group:
   uuid: zzzzz-j7d0g-futrprojviewgrp
   owner_uuid: zzzzz-tpzed-000000000000000
index 7131da6f5ee6dc54906745b1b2ac56d1d7223f7e..6a857a02f29dfe8f60286014cecc3191fd434f04 100644 (file)
@@ -5,11 +5,11 @@
 running_job_task_1:
   uuid: zzzzz-ot0gb-runningjobtask1
   owner_uuid: zzzzz-j7d0g-v955i6s2oi1cbso
-  created_at: <%= 3.minute.ago.to_s(:db) %>
+  created_at: <%= 3.minute.ago.to_fs(:db) %>
   job_uuid: zzzzz-8i9sb-with2components
 
 running_job_task_2:
   uuid: zzzzz-ot0gb-runningjobtask2
   owner_uuid: zzzzz-j7d0g-v955i6s2oi1cbso
-  created_at: <%= 3.minute.ago.to_s(:db) %>
+  created_at: <%= 3.minute.ago.to_fs(:db) %>
   job_uuid: zzzzz-8i9sb-with2components
index 9280aeab935e19748f25ea347592d3d704161f02..54b38259ba889aa84a28518738865ca5ea88bb5f 100644 (file)
@@ -8,8 +8,8 @@ running:
   cancelled_at: ~
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
-  created_at: <%= 2.7.minute.ago.to_s(:db) %>
-  started_at: <%= 2.7.minute.ago.to_s(:db) %>
+  created_at: <%= 2.7.minute.ago.to_fs(:db) %>
+  started_at: <%= 2.7.minute.ago.to_fs(:db) %>
   finished_at: ~
   script: hash
   repository: active/foo
@@ -32,11 +32,11 @@ running:
 running_cancelled:
   uuid: zzzzz-8i9sb-4cf0nhn6xte809j
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  cancelled_at: <%= 1.minute.ago.to_s(:db) %>
+  cancelled_at: <%= 1.minute.ago.to_fs(:db) %>
   cancelled_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   cancelled_by_client_uuid: zzzzz-ozdt8-obw7foaks3qjyej
-  created_at: <%= 4.minute.ago.to_s(:db) %>
-  started_at: <%= 3.minute.ago.to_s(:db) %>
+  created_at: <%= 4.minute.ago.to_fs(:db) %>
+  started_at: <%= 3.minute.ago.to_fs(:db) %>
   finished_at: ~
   script: hash
   repository: active/foo
@@ -63,9 +63,9 @@ uses_nonexistent_script_version:
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
   script_version: 7def43a4d3f20789dda4700f703b5514cc3ed250
-  created_at: <%= 5.minute.ago.to_s(:db) %>
-  started_at: <%= 3.minute.ago.to_s(:db) %>
-  finished_at: <%= 2.minute.ago.to_s(:db) %>
+  created_at: <%= 5.minute.ago.to_fs(:db) %>
+  started_at: <%= 3.minute.ago.to_fs(:db) %>
+  finished_at: <%= 2.minute.ago.to_fs(:db) %>
   script: hash
   repository: active/foo
   running: false
@@ -94,9 +94,9 @@ foobar:
   script_version: 7def43a4d3f20789dda4700f703b5514cc3ed250
   script_parameters:
     input: 1f4b0bc7583c2a7f9102c395f4ffc5e3+45
-  created_at: <%= 4.minute.ago.to_s(:db) %>
-  started_at: <%= 3.minute.ago.to_s(:db) %>
-  finished_at: <%= 2.minute.ago.to_s(:db) %>
+  created_at: <%= 4.minute.ago.to_fs(:db) %>
+  started_at: <%= 3.minute.ago.to_fs(:db) %>
+  finished_at: <%= 2.minute.ago.to_fs(:db) %>
   running: false
   success: true
   output: fa7aeb5140e2848d39b416daeef4ffc5+45
@@ -122,9 +122,9 @@ barbaz:
   script_parameters:
     input: fa7aeb5140e2848d39b416daeef4ffc5+45
     an_integer: 1
-  created_at: <%= 4.minute.ago.to_s(:db) %>
-  started_at: <%= 3.minute.ago.to_s(:db) %>
-  finished_at: <%= 2.minute.ago.to_s(:db) %>
+  created_at: <%= 4.minute.ago.to_fs(:db) %>
+  started_at: <%= 3.minute.ago.to_fs(:db) %>
+  finished_at: <%= 2.minute.ago.to_fs(:db) %>
   running: false
   success: true
   repository: active/foo
@@ -151,9 +151,9 @@ runningbarbaz:
   script_parameters:
     input: fa7aeb5140e2848d39b416daeef4ffc5+45
     an_integer: 1
-  created_at: <%= 4.minute.ago.to_s(:db) %>
-  started_at: <%= 3.minute.ago.to_s(:db) %>
-  finished_at: <%= 2.minute.ago.to_s(:db) %>
+  created_at: <%= 4.minute.ago.to_fs(:db) %>
+  started_at: <%= 3.minute.ago.to_fs(:db) %>
+  finished_at: <%= 2.minute.ago.to_fs(:db) %>
   running: true
   success: ~
   repository: active/foo
@@ -172,8 +172,8 @@ runningbarbaz:
 
 previous_job_run:
   uuid: zzzzz-8i9sb-cjs4pklxxjykqqq
-  created_at: <%= 14.minute.ago.to_s(:db) %>
-  finished_at: <%= 13.minutes.ago.to_s(:db) %>
+  created_at: <%= 14.minute.ago.to_fs(:db) %>
+  finished_at: <%= 13.minutes.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: active/foo
   script: hash
@@ -189,8 +189,8 @@ previous_job_run:
 
 previous_job_run_nil_log:
   uuid: zzzzz-8i9sb-cjs4pklxxjykqq3
-  created_at: <%= 14.minute.ago.to_s(:db) %>
-  finished_at: <%= 13.minutes.ago.to_s(:db) %>
+  created_at: <%= 14.minute.ago.to_fs(:db) %>
+  finished_at: <%= 13.minutes.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: active/foo
   script: hash
@@ -206,8 +206,8 @@ previous_job_run_nil_log:
 
 previous_ancient_job_run:
   uuid: zzzzz-8i9sb-ahd7cie8jah9qui
-  created_at: <%= 366.days.ago.to_s(:db) %>
-  finished_at: <%= 365.days.ago.to_s(:db) %>
+  created_at: <%= 366.days.ago.to_fs(:db) %>
+  finished_at: <%= 365.days.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: active/foo
   script: hash
@@ -223,7 +223,7 @@ previous_ancient_job_run:
 
 previous_docker_job_run:
   uuid: zzzzz-8i9sb-k6emstgk4kw4yhi
-  created_at: <%= 14.minute.ago.to_s(:db) %>
+  created_at: <%= 14.minute.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: active/foo
   script: hash
@@ -242,7 +242,7 @@ previous_docker_job_run:
 
 previous_ancient_docker_image_job_run:
   uuid: zzzzz-8i9sb-t3b460aolxxuldl
-  created_at: <%= 144.minute.ago.to_s(:db) %>
+  created_at: <%= 144.minute.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: active/foo
   script: hash
@@ -260,7 +260,7 @@ previous_ancient_docker_image_job_run:
 
 previous_job_run_with_arvados_sdk_version:
   uuid: zzzzz-8i9sb-eoo0321or2dw2jg
-  created_at: <%= 14.minute.ago.to_s(:db) %>
+  created_at: <%= 14.minute.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: active/foo
   script: hash
@@ -281,7 +281,7 @@ previous_job_run_with_arvados_sdk_version:
 
 previous_job_run_no_output:
   uuid: zzzzz-8i9sb-cjs4pklxxjykppp
-  created_at: <%= 14.minute.ago.to_s(:db) %>
+  created_at: <%= 14.minute.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: active/foo
   script: hash
@@ -297,7 +297,7 @@ previous_job_run_no_output:
 previous_job_run_superseded_by_hash_branch:
   # This supplied_script_version is a branch name with later commits.
   uuid: zzzzz-8i9sb-aeviezu5dahph3e
-  created_at: <%= 15.minute.ago.to_s(:db) %>
+  created_at: <%= 15.minute.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: active/shabranchnames
   script: testscript
@@ -311,7 +311,7 @@ previous_job_run_superseded_by_hash_branch:
 
 nondeterminisic_job_run:
   uuid: zzzzz-8i9sb-cjs4pklxxjykyyy
-  created_at: <%= 14.minute.ago.to_s(:db) %>
+  created_at: <%= 14.minute.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: active/foo
   script: hash2
@@ -326,14 +326,14 @@ nondeterminisic_job_run:
 
 nearly_finished_job:
   uuid: zzzzz-8i9sb-2gx6rz0pjl033w3
-  created_at: <%= 14.minute.ago.to_s(:db) %>
+  created_at: <%= 14.minute.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   repository: arvados
   script: doesnotexist
   script_version: 309e25a64fe994867db8459543af372f850e25b9
   script_parameters:
     input: b519d9cb706a29fc7ea24dbea2f05851+249025
-  started_at: <%= 3.minute.ago.to_s(:db) %>
+  started_at: <%= 3.minute.ago.to_fs(:db) %>
   finished_at: ~
   running: true
   success: ~
@@ -348,7 +348,7 @@ nearly_finished_job:
 
 queued:
   uuid: zzzzz-8i9sb-grx15v5mjnsyxk7
-  created_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 1.minute.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   cancelled_at: ~
   cancelled_by_user_uuid: ~
@@ -382,11 +382,11 @@ job_with_real_log:
 cancelled:
   uuid: zzzzz-8i9sb-4cf0abc123e809j
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  cancelled_at: <%= 1.minute.ago.to_s(:db) %>
+  cancelled_at: <%= 1.minute.ago.to_fs(:db) %>
   cancelled_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   cancelled_by_client_uuid: zzzzz-ozdt8-obw7foaks3qjyej
-  created_at: <%= 4.minute.ago.to_s(:db) %>
-  started_at: <%= 3.minute.ago.to_s(:db) %>
+  created_at: <%= 4.minute.ago.to_fs(:db) %>
+  started_at: <%= 3.minute.ago.to_fs(:db) %>
   finished_at: ~
   script_version: 1de84a854e2b440dc53bf42f8548afa4c17da332
   running: false
@@ -432,8 +432,8 @@ running_will_be_completed:
   cancelled_at: ~
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
-  created_at: <%= 3.minute.ago.to_s(:db) %>
-  started_at: <%= 3.minute.ago.to_s(:db) %>
+  created_at: <%= 3.minute.ago.to_fs(:db) %>
+  started_at: <%= 3.minute.ago.to_fs(:db) %>
   finished_at: ~
   script_version: 1de84a854e2b440dc53bf42f8548afa4c17da332
   running: true
@@ -499,9 +499,9 @@ job_with_latest_version:
   supplied_script_version: main
   script_parameters:
     input: 1f4b0bc7583c2a7f9102c395f4ffc5e3+45
-  created_at: <%= 3.minute.ago.to_s(:db) %>
-  started_at: <%= 2.minute.ago.to_s(:db) %>
-  finished_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 3.minute.ago.to_fs(:db) %>
+  started_at: <%= 2.minute.ago.to_fs(:db) %>
+  finished_at: <%= 1.minute.ago.to_fs(:db) %>
   running: false
   success: true
   output: fa7aeb5140e2848d39b416daeef4ffc5+45
@@ -544,8 +544,8 @@ completed_job_in_publicly_accessible_project:
   log: zzzzz-4zz18-4en62shvi99lxd4
   output: b519d9cb706a29fc7ea24dbea2f05851+93
   script_parameters_digest: 02a085407e751d00b5dc88f1bd5e8247
-  started_at: <%= 10.minute.ago.to_s(:db) %>
-  finished_at: <%= 5.minute.ago.to_s(:db) %>
+  started_at: <%= 10.minute.ago.to_fs(:db) %>
+  finished_at: <%= 5.minute.ago.to_fs(:db) %>
 
 job_in_publicly_accessible_project_but_other_objects_elsewhere:
   uuid: zzzzz-8i9sb-jyq01muyhgr4ofj
@@ -568,8 +568,8 @@ running_job_with_components:
   cancelled_at: ~
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
-  created_at: <%= 3.minute.ago.to_s(:db) %>
-  started_at: <%= 3.minute.ago.to_s(:db) %>
+  created_at: <%= 3.minute.ago.to_fs(:db) %>
+  started_at: <%= 3.minute.ago.to_fs(:db) %>
   finished_at: ~
   script: hash
   repository: active/foo
@@ -599,8 +599,8 @@ running_job_with_components_at_level_1:
   cancelled_at: ~
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
-  created_at: <%= 12.hour.ago.to_s(:db) %>
-  started_at: <%= 12.hour.ago.to_s(:db) %>
+  created_at: <%= 12.hour.ago.to_fs(:db) %>
+  started_at: <%= 12.hour.ago.to_fs(:db) %>
   finished_at: ~
   repository: active/foo
   script: hash
@@ -630,8 +630,8 @@ running_job_with_components_at_level_2:
   cancelled_at: ~
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
-  created_at: <%= 12.hour.ago.to_s(:db) %>
-  started_at: <%= 12.hour.ago.to_s(:db) %>
+  created_at: <%= 12.hour.ago.to_fs(:db) %>
+  started_at: <%= 12.hour.ago.to_fs(:db) %>
   finished_at: ~
   repository: active/foo
   script: hash
@@ -660,8 +660,8 @@ running_job_1_with_components_at_level_3:
   cancelled_at: ~
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
-  created_at: <%= 12.hour.ago.to_s(:db) %>
-  started_at: <%= 12.hour.ago.to_s(:db) %>
+  created_at: <%= 12.hour.ago.to_fs(:db) %>
+  started_at: <%= 12.hour.ago.to_fs(:db) %>
   finished_at: ~
   repository: active/foo
   script: hash
@@ -687,8 +687,8 @@ running_job_2_with_components_at_level_3:
   cancelled_at: ~
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
-  created_at: <%= 12.hour.ago.to_s(:db) %>
-  started_at: <%= 12.hour.ago.to_s(:db) %>
+  created_at: <%= 12.hour.ago.to_fs(:db) %>
+  started_at: <%= 12.hour.ago.to_fs(:db) %>
   finished_at: ~
   repository: active/foo
   script: hash
@@ -715,8 +715,8 @@ running_job_1_with_circular_component_relationship:
   cancelled_at: ~
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
-  created_at: <%= 12.hour.ago.to_s(:db) %>
-  started_at: <%= 12.hour.ago.to_s(:db) %>
+  created_at: <%= 12.hour.ago.to_fs(:db) %>
+  started_at: <%= 12.hour.ago.to_fs(:db) %>
   finished_at: ~
   repository: active/foo
   script: hash
@@ -744,8 +744,8 @@ running_job_2_with_circular_component_relationship:
   cancelled_at: ~
   cancelled_by_user_uuid: ~
   cancelled_by_client_uuid: ~
-  created_at: <%= 12.hour.ago.to_s(:db) %>
-  started_at: <%= 12.hour.ago.to_s(:db) %>
+  created_at: <%= 12.hour.ago.to_fs(:db) %>
+  started_at: <%= 12.hour.ago.to_fs(:db) %>
   finished_at: ~
   repository: active/foo
   script: hash
index e8424b26fa2487aff7ac9384304a581aaca7ac9f..5cccf498afa4b3b61ff55acbcb934868afc8150f 100644 (file)
@@ -7,9 +7,9 @@ nonfull:
   owner_uuid: zzzzz-tpzed-d9tiejq69daie8f
   node_uuid: zzzzz-7ekkf-53y36l1lu5ijveb
   keep_service_uuid: zzzzz-bi6l4-6zhilxar6r8ey90
-  last_read_at: <%= 1.minute.ago.to_s(:db) %>
-  last_write_at: <%= 2.minute.ago.to_s(:db) %>
-  last_ping_at: <%= 3.minute.ago.to_s(:db) %>
+  last_read_at: <%= 1.minute.ago.to_fs(:db) %>
+  last_write_at: <%= 2.minute.ago.to_fs(:db) %>
+  last_ping_at: <%= 3.minute.ago.to_fs(:db) %>
   ping_secret: z9xz2tc69dho51g1dmkdy5fnupdhsprahcwxdbjs0zms4eo6i
 
 full:
@@ -17,9 +17,9 @@ full:
   owner_uuid: zzzzz-tpzed-d9tiejq69daie8f
   node_uuid: zzzzz-7ekkf-53y36l1lu5ijveb
   keep_service_uuid: zzzzz-bi6l4-6zhilxar6r8ey90
-  last_read_at: <%= 1.minute.ago.to_s(:db) %>
-  last_write_at: <%= 2.day.ago.to_s(:db) %>
-  last_ping_at: <%= 3.minute.ago.to_s(:db) %>
+  last_read_at: <%= 1.minute.ago.to_fs(:db) %>
+  last_write_at: <%= 2.day.ago.to_fs(:db) %>
+  last_ping_at: <%= 3.minute.ago.to_fs(:db) %>
   ping_secret: xx3ieejcufbjy4lli6yt5ig4e8w5l2hhgmbyzpzuq38gri6lj
 
 nonfull2:
@@ -27,7 +27,7 @@ nonfull2:
   owner_uuid: zzzzz-tpzed-d9tiejq69daie8f
   node_uuid: zzzzz-7ekkf-2z3mc76g2q73aio
   keep_service_uuid: zzzzz-bi6l4-rsnj3c76ndxb7o0
-  last_read_at: <%= 1.minute.ago.to_s(:db) %>
-  last_write_at: <%= 2.minute.ago.to_s(:db) %>
-  last_ping_at: <%= 3.minute.ago.to_s(:db) %>
+  last_read_at: <%= 1.minute.ago.to_fs(:db) %>
+  last_write_at: <%= 2.minute.ago.to_fs(:db) %>
+  last_ping_at: <%= 3.minute.ago.to_fs(:db) %>
   ping_secret: 4rs260ibhdum1d242xy23qv320rlerc0j7qg9vyqnchbgmjeek
index 99b97510db99951575052e88703b5d962d6716c7..00d597153486ae391c255640d44dfaf93e8a71dd 100644 (file)
@@ -1139,3 +1139,17 @@ public_favorites_permission_link:
   name: can_read
   head_uuid: zzzzz-j7d0g-publicfavorites
   properties: {}
+
+future_project_user_member_of_all_users_group:
+  uuid: zzzzz-o0j2j-cdnq6627g0h0r2a
+  owner_uuid: zzzzz-tpzed-000000000000000
+  created_at: 2015-07-28T21:34:41.361747000Z
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-000000000000000
+  modified_at: 2015-07-28T21:34:41.361747000Z
+  updated_at: 2015-07-28T21:34:41.361747000Z
+  tail_uuid: zzzzz-tpzed-futureprojview2
+  link_class: permission
+  name: can_write
+  head_uuid: zzzzz-j7d0g-fffffffffffffff
+  properties: {}
index 25f1efff62c8f71246749a3b3c189d297eb7ed82..3b41550ae784802948e33c82b2ced53930718a6f 100644 (file)
@@ -8,8 +8,8 @@ noop: # nothing happened ...to the 'spectator' user
   owner_uuid: zzzzz-tpzed-000000000000000
   object_uuid: zzzzz-tpzed-l1s2piq4t4mps8r
   object_owner_uuid: zzzzz-tpzed-000000000000000
-  event_at: <%= 1.minute.ago.to_s(:db) %>
-  created_at: <%= 1.minute.ago.to_s(:db) %>
+  event_at: <%= 1.minute.ago.to_fs(:db) %>
+  created_at: <%= 1.minute.ago.to_fs(:db) %>
 
 admin_changes_repository2: # admin changes repository2, which is owned by active user
   id: 2
@@ -17,8 +17,8 @@ admin_changes_repository2: # admin changes repository2, which is owned by active
   owner_uuid: zzzzz-tpzed-d9tiejq69daie8f # admin user
   object_uuid: zzzzz-2x53u-382brsig8rp3667 # repository foo
   object_owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz # active user
-  created_at: <%= 2.minute.ago.to_s(:db) %>
-  event_at: <%= 2.minute.ago.to_s(:db) %>
+  created_at: <%= 2.minute.ago.to_fs(:db) %>
+  event_at: <%= 2.minute.ago.to_fs(:db) %>
   event_type: update
 
 admin_changes_specimen: # admin changes specimen owned_by_spectator
@@ -27,8 +27,8 @@ admin_changes_specimen: # admin changes specimen owned_by_spectator
   owner_uuid: zzzzz-tpzed-d9tiejq69daie8f # admin user
   object_uuid: zzzzz-2x53u-3b0xxwzlbzxq5yr # specimen owned_by_spectator
   object_owner_uuid: zzzzz-tpzed-l1s2piq4t4mps8r # spectator user
-  created_at: <%= 3.minute.ago.to_s(:db) %>
-  event_at: <%= 3.minute.ago.to_s(:db) %>
+  created_at: <%= 3.minute.ago.to_fs(:db) %>
+  event_at: <%= 3.minute.ago.to_fs(:db) %>
   event_type: update
 
 system_adds_foo_file: # foo collection added, readable by active through link
@@ -37,8 +37,8 @@ system_adds_foo_file: # foo collection added, readable by active through link
   owner_uuid: zzzzz-tpzed-000000000000000 # system user
   object_uuid: zzzzz-4zz18-znfnqtbbv4spc3w # foo file
   object_owner_uuid: zzzzz-tpzed-000000000000000 # system user
-  created_at: <%= 4.minute.ago.to_s(:db) %>
-  event_at: <%= 4.minute.ago.to_s(:db) %>
+  created_at: <%= 4.minute.ago.to_fs(:db) %>
+  event_at: <%= 4.minute.ago.to_fs(:db) %>
   event_type: create
 
 system_adds_baz: # baz collection added, readable by active and spectator through group 'all users' group membership
@@ -47,8 +47,8 @@ system_adds_baz: # baz collection added, readable by active and spectator throug
   owner_uuid: zzzzz-tpzed-000000000000000 # system user
   object_uuid: zzzzz-4zz18-y9vne9npefyxh8g # baz file
   object_owner_uuid: zzzzz-tpzed-000000000000000 # system user
-  created_at: <%= 5.minute.ago.to_s(:db) %>
-  event_at: <%= 5.minute.ago.to_s(:db) %>
+  created_at: <%= 5.minute.ago.to_fs(:db) %>
+  event_at: <%= 5.minute.ago.to_fs(:db) %>
   event_type: create
 
 log_owned_by_active:
@@ -57,7 +57,7 @@ log_owned_by_active:
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz # active user
   object_uuid: zzzzz-2x53u-382brsig8rp3667 # repository foo
   object_owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz # active user
-  event_at: <%= 2.minute.ago.to_s(:db) %>
+  event_at: <%= 2.minute.ago.to_fs(:db) %>
   summary: non-admin use can read own logs
 
 crunchstat_for_running_job:
@@ -162,16 +162,16 @@ stderr_for_ancient_container:
   modified_by_client_uuid: zzzzz-ozdt8-obw7foaks3qjyej
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   object_uuid: zzzzz-dz642-logscontainer01
-  event_at: <%= 2.year.ago.to_s(:db) %>
+  event_at: <%= 2.year.ago.to_fs(:db) %>
   event_type: stderr
   summary: ~
   properties:
     text: '2013-11-07_23:33:41 zzzzz-8i9sb-ahd7cie8jah9qui 29610 1 stderr crunchstat:
       cpu 1935.4300 user 59.4100 sys 8 cpus -- interval 10.0002 seconds 12.9900 user
       0.9900 sys'
-  created_at: <%= 2.year.ago.to_s(:db) %>
-  updated_at: <%= 2.year.ago.to_s(:db) %>
-  modified_at: <%= 2.year.ago.to_s(:db) %>
+  created_at: <%= 2.year.ago.to_fs(:db) %>
+  updated_at: <%= 2.year.ago.to_fs(:db) %>
+  modified_at: <%= 2.year.ago.to_fs(:db) %>
   object_owner_uuid: zzzzz-j7d0g-xurymjxw79nv3jz
 
 crunchstat_for_ancient_container:
@@ -181,16 +181,16 @@ crunchstat_for_ancient_container:
   modified_by_client_uuid: zzzzz-ozdt8-obw7foaks3qjyej
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   object_uuid: zzzzz-dz642-logscontainer01
-  event_at: <%= 2.year.ago.to_s(:db) %>
+  event_at: <%= 2.year.ago.to_fs(:db) %>
   event_type: crunchstat
   summary: ~
   properties:
     text: '2013-11-07_23:33:41 zzzzz-8i9sb-ahd7cie8jah9qui 29610 1 stderr crunchstat:
       cpu 1935.4300 user 59.4100 sys 8 cpus -- interval 10.0002 seconds 12.9900 user
       0.9900 sys'
-  created_at: <%= 2.year.ago.to_s(:db) %>
-  updated_at: <%= 2.year.ago.to_s(:db) %>
-  modified_at: <%= 2.year.ago.to_s(:db) %>
+  created_at: <%= 2.year.ago.to_fs(:db) %>
+  updated_at: <%= 2.year.ago.to_fs(:db) %>
+  modified_at: <%= 2.year.ago.to_fs(:db) %>
   object_owner_uuid: zzzzz-j7d0g-xurymjxw79nv3jz
 
 stderr_for_previous_container:
@@ -200,16 +200,16 @@ stderr_for_previous_container:
   modified_by_client_uuid: zzzzz-ozdt8-obw7foaks3qjyej
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   object_uuid: zzzzz-dz642-logscontainer02
-  event_at: <%= 1.month.ago.to_s(:db) %>
+  event_at: <%= 1.month.ago.to_fs(:db) %>
   event_type: stderr
   summary: ~
   properties:
     text: '2013-11-07_23:33:41 zzzzz-8i9sb-ahd7cie8jah9qui 29610 1 stderr crunchstat:
       cpu 1935.4300 user 59.4100 sys 8 cpus -- interval 10.0002 seconds 12.9900 user
       0.9900 sys'
-  created_at: <%= 1.month.ago.to_s(:db) %>
-  updated_at: <%= 1.month.ago.to_s(:db) %>
-  modified_at: <%= 1.month.ago.to_s(:db) %>
+  created_at: <%= 1.month.ago.to_fs(:db) %>
+  updated_at: <%= 1.month.ago.to_fs(:db) %>
+  modified_at: <%= 1.month.ago.to_fs(:db) %>
   object_owner_uuid: zzzzz-j7d0g-xurymjxw79nv3jz
 
 crunchstat_for_previous_container:
@@ -219,16 +219,16 @@ crunchstat_for_previous_container:
   modified_by_client_uuid: zzzzz-ozdt8-obw7foaks3qjyej
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   object_uuid: zzzzz-dz642-logscontainer02
-  event_at: <%= 1.month.ago.to_s(:db) %>
+  event_at: <%= 1.month.ago.to_fs(:db) %>
   event_type: crunchstat
   summary: ~
   properties:
     text: '2013-11-07_23:33:41 zzzzz-8i9sb-ahd7cie8jah9qui 29610 1 stderr crunchstat:
       cpu 1935.4300 user 59.4100 sys 8 cpus -- interval 10.0002 seconds 12.9900 user
       0.9900 sys'
-  created_at: <%= 1.month.ago.to_s(:db) %>
-  updated_at: <%= 1.month.ago.to_s(:db) %>
-  modified_at: <%= 1.month.ago.to_s(:db) %>
+  created_at: <%= 1.month.ago.to_fs(:db) %>
+  updated_at: <%= 1.month.ago.to_fs(:db) %>
+  modified_at: <%= 1.month.ago.to_fs(:db) %>
   object_owner_uuid: zzzzz-j7d0g-xurymjxw79nv3jz
 
 stderr_for_running_container:
@@ -238,16 +238,16 @@ stderr_for_running_container:
   modified_by_client_uuid: zzzzz-ozdt8-obw7foaks3qjyej
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   object_uuid: zzzzz-dz642-logscontainer03
-  event_at: <%= 1.hour.ago.to_s(:db) %>
+  event_at: <%= 1.hour.ago.to_fs(:db) %>
   event_type: crunchstat
   summary: ~
   properties:
     text: '2013-11-07_23:33:41 zzzzz-8i9sb-ahd7cie8jah9qui 29610 1 stderr crunchstat:
       cpu 1935.4300 user 59.4100 sys 8 cpus -- interval 10.0002 seconds 12.9900 user
       0.9900 sys'
-  created_at: <%= 1.hour.ago.to_s(:db) %>
-  updated_at: <%= 1.hour.ago.to_s(:db) %>
-  modified_at: <%= 1.hour.ago.to_s(:db) %>
+  created_at: <%= 1.hour.ago.to_fs(:db) %>
+  updated_at: <%= 1.hour.ago.to_fs(:db) %>
+  modified_at: <%= 1.hour.ago.to_fs(:db) %>
   object_owner_uuid: zzzzz-j7d0g-xurymjxw79nv3jz
 
 crunchstat_for_running_container:
@@ -257,14 +257,14 @@ crunchstat_for_running_container:
   modified_by_client_uuid: zzzzz-ozdt8-obw7foaks3qjyej
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   object_uuid: zzzzz-dz642-logscontainer03
-  event_at: <%= 1.hour.ago.to_s(:db) %>
+  event_at: <%= 1.hour.ago.to_fs(:db) %>
   event_type: crunchstat
   summary: ~
   properties:
     text: '2013-11-07_23:33:41 zzzzz-8i9sb-ahd7cie8jah9qui 29610 1 stderr crunchstat:
       cpu 1935.4300 user 59.4100 sys 8 cpus -- interval 10.0002 seconds 12.9900 user
       0.9900 sys'
-  created_at: <%= 1.hour.ago.to_s(:db) %>
-  updated_at: <%= 1.hour.ago.to_s(:db) %>
-  modified_at: <%= 1.hour.ago.to_s(:db) %>
+  created_at: <%= 1.hour.ago.to_fs(:db) %>
+  updated_at: <%= 1.hour.ago.to_fs(:db) %>
+  modified_at: <%= 1.hour.ago.to_fs(:db) %>
   object_owner_uuid: zzzzz-j7d0g-xurymjxw79nv3jz
index 821a6b5e4221d50edfc5b8de871e7ee31657a070..d4589ed705b9fa51c11c6e65212a02e0b5d4a92f 100644 (file)
@@ -9,8 +9,8 @@ busy:
   slot_number: 0
   domain: ""
   ip_address: 172.17.2.172
-  last_ping_at: <%= 1.minute.ago.to_s(:db) %>
-  first_ping_at: <%= 23.hour.ago.to_s(:db) %>
+  last_ping_at: <%= 1.minute.ago.to_fs(:db) %>
+  first_ping_at: <%= 23.hour.ago.to_fs(:db) %>
   job_uuid: zzzzz-8i9sb-2gx6rz0pjl033w3  # nearly_finished_job
   properties: {}
   info:
@@ -24,8 +24,8 @@ down:
   slot_number: 1
   domain: ""
   ip_address: 172.17.2.173
-  last_ping_at: <%= 1.hour.ago.to_s(:db) %>
-  first_ping_at: <%= 23.hour.ago.to_s(:db) %>
+  last_ping_at: <%= 1.hour.ago.to_fs(:db) %>
+  first_ping_at: <%= 23.hour.ago.to_fs(:db) %>
   job_uuid: ~
   properties: {}
   info:
@@ -38,8 +38,8 @@ idle:
   slot_number: 2
   domain: ""
   ip_address: 172.17.2.174
-  last_ping_at: <%= 2.minute.ago.to_s(:db) %>
-  first_ping_at: <%= 23.hour.ago.to_s(:db) %>
+  last_ping_at: <%= 2.minute.ago.to_fs(:db) %>
+  first_ping_at: <%= 23.hour.ago.to_fs(:db) %>
   job_uuid: ~
   info:
     ping_secret: "69udawxvn3zzj45hs8bumvndricrha4lcpi23pd69e44soanc0"
@@ -54,8 +54,8 @@ was_idle_now_down:
   slot_number: ~
   domain: ""
   ip_address: 172.17.2.174
-  last_ping_at: <%= 1.hour.ago.to_s(:db) %>
-  first_ping_at: <%= 23.hour.ago.to_s(:db) %>
+  last_ping_at: <%= 1.hour.ago.to_fs(:db) %>
+  first_ping_at: <%= 23.hour.ago.to_fs(:db) %>
   job_uuid: ~
   info:
     ping_secret: "1bd1yi0x4lb5q4gzqqtrnq30oyj08r8dtdimmanbqw49z1anz2"
index a504c9fadd790cb21e2410cb95cefd082c32cfbf..714fc60771692cee93f9e4c654a22cb3e6de7ea9 100644 (file)
@@ -6,19 +6,19 @@ new_pipeline:
   state: New
   uuid: zzzzz-d1hrv-f4gneyn6br1xize
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 1.minute.ago.to_fs(:db) %>
 
 new_pipeline_in_subproject:
   state: New
   uuid: zzzzz-d1hrv-subprojpipeline
   owner_uuid: zzzzz-j7d0g-axqo7eu9pwvna1x
-  created_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 1.minute.ago.to_fs(:db) %>
 
 has_component_with_no_script_parameters:
   state: Ready
   uuid: zzzzz-d1hrv-1xfj6xkicf2muk2
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 10.minute.ago.to_s(:db) %>
+  created_at: <%= 10.minute.ago.to_fs(:db) %>
   components:
    foo:
     script: foo
@@ -29,7 +29,7 @@ has_component_with_empty_script_parameters:
   state: Ready
   uuid: zzzzz-d1hrv-jq16l10gcsnyumo
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 3.minute.ago.to_s(:db) %>
+  created_at: <%= 3.minute.ago.to_fs(:db) %>
   components:
    foo:
     script: foo
@@ -46,9 +46,9 @@ has_component_with_completed_jobs:
   state: Complete
   uuid: zzzzz-d1hrv-i3e77t9z5y8j9cc
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 11.minute.ago.to_s(:db) %>
-  started_at: <%= 10.minute.ago.to_s(:db) %>
-  finished_at: <%= 9.minute.ago.to_s(:db) %>
+  created_at: <%= 11.minute.ago.to_fs(:db) %>
+  started_at: <%= 10.minute.ago.to_fs(:db) %>
+  finished_at: <%= 9.minute.ago.to_fs(:db) %>
   components:
    foo:
     script: foo
@@ -57,9 +57,9 @@ has_component_with_completed_jobs:
     job:
       uuid: zzzzz-8i9sb-rft1xdewxkwgxnz
       script_version: main
-      created_at: <%= 10.minute.ago.to_s(:db) %>
-      started_at: <%= 10.minute.ago.to_s(:db) %>
-      finished_at: <%= 9.minute.ago.to_s(:db) %>
+      created_at: <%= 10.minute.ago.to_fs(:db) %>
+      started_at: <%= 10.minute.ago.to_fs(:db) %>
+      finished_at: <%= 9.minute.ago.to_fs(:db) %>
       state: Complete
       tasks_summary:
         failed: 0
@@ -73,8 +73,8 @@ has_component_with_completed_jobs:
     job:
       uuid: zzzzz-8i9sb-r2dtbzr6bfread7
       script_version: main
-      created_at: <%= 9.minute.ago.to_s(:db) %>
-      started_at: <%= 9.minute.ago.to_s(:db) %>
+      created_at: <%= 9.minute.ago.to_fs(:db) %>
+      started_at: <%= 9.minute.ago.to_fs(:db) %>
       state: Running
       tasks_summary:
         failed: 0
@@ -88,7 +88,7 @@ has_component_with_completed_jobs:
     job:
       uuid: zzzzz-8i9sb-c7408rni11o7r6s
       script_version: main
-      created_at: <%= 9.minute.ago.to_s(:db) %>
+      created_at: <%= 9.minute.ago.to_fs(:db) %>
       state: Queued
       tasks_summary: {}
 
@@ -97,7 +97,7 @@ has_job:
   state: Ready
   uuid: zzzzz-d1hrv-1yfj6xkidf2muk3
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 2.9.minute.ago.to_s(:db) %>
+  created_at: <%= 2.9.minute.ago.to_fs(:db) %>
   components:
    foo:
     script: foo
@@ -112,7 +112,7 @@ components_is_jobspec:
   # Helps test that clients cope with funny-shaped components.
   # For an example, see #3321.
   uuid: zzzzz-d1hrv-1yfj61234abcdk4
-  created_at: <%= 4.minute.ago.to_s(:db) %>
+  created_at: <%= 4.minute.ago.to_fs(:db) %>
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
@@ -132,7 +132,7 @@ pipeline_with_tagged_collection_input:
   state: Ready
   uuid: zzzzz-d1hrv-1yfj61234abcdk3
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 3.2.minute.ago.to_s(:db) %>
+  created_at: <%= 3.2.minute.ago.to_fs(:db) %>
   components:
     part-one:
       script_parameters:
@@ -145,7 +145,7 @@ pipeline_to_merge_params:
   uuid: zzzzz-d1hrv-1yfj6dcba4321k3
   pipeline_template_uuid: zzzzz-p5p6p-aox0k0ofxrystgw
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 3.3.minute.ago.to_s(:db) %>
+  created_at: <%= 3.3.minute.ago.to_fs(:db) %>
   components:
     part-one:
       script_parameters:
@@ -260,7 +260,7 @@ pipeline_in_publicly_accessible_project:
   name: Pipeline in publicly accessible project
   pipeline_template_uuid: zzzzz-p5p6p-tmpltpublicproj
   state: Complete
-  created_at: <%= 30.minute.ago.to_s(:db) %>
+  created_at: <%= 30.minute.ago.to_fs(:db) %>
   components:
     foo:
       script: foo
@@ -363,8 +363,8 @@ pipeline_in_running_state:
   name: running_with_job
   uuid: zzzzz-d1hrv-runningpipeline
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 2.8.minute.ago.to_s(:db) %>
-  started_at: <%= 2.8.minute.ago.to_s(:db) %>
+  created_at: <%= 2.8.minute.ago.to_fs(:db) %>
+  started_at: <%= 2.8.minute.ago.to_fs(:db) %>
   state: RunningOnServer
   components:
    foo:
@@ -379,7 +379,7 @@ running_pipeline_with_complete_job:
   uuid: zzzzz-d1hrv-partdonepipelin
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   state: RunningOnServer
-  created_at: <%= 15.minute.ago.to_s(:db) %>
+  created_at: <%= 15.minute.ago.to_fs(:db) %>
   components:
    previous:
     job:
@@ -393,9 +393,9 @@ complete_pipeline_with_two_jobs:
   uuid: zzzzz-d1hrv-twodonepipeline
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   state: Complete
-  created_at: <%= 2.5.minute.ago.to_s(:db) %>
-  started_at: <%= 2.minute.ago.to_s(:db) %>
-  finished_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 2.5.minute.ago.to_fs(:db) %>
+  started_at: <%= 2.minute.ago.to_fs(:db) %>
+  finished_at: <%= 1.minute.ago.to_fs(:db) %>
   components:
    ancient:
     job:
@@ -409,7 +409,7 @@ complete_pipeline_with_two_jobs:
 failed_pipeline_with_two_jobs:
   uuid: zzzzz-d1hrv-twofailpipeline
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 55.minute.ago.to_s(:db) %>
+  created_at: <%= 55.minute.ago.to_fs(:db) %>
   state: Failed
   components:
    ancient:
@@ -426,8 +426,8 @@ job_child_pipeline_with_components_at_level_2:
   state: RunningOnServer
   uuid: zzzzz-d1hrv-picomponentsl02
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
-  created_at: <%= 12.hour.ago.to_s(:db) %>
-  started_at: <%= 12.hour.ago.to_s(:db) %>
+  created_at: <%= 12.hour.ago.to_fs(:db) %>
+  started_at: <%= 12.hour.ago.to_fs(:db) %>
   components:
    foo:
     script: foo
@@ -436,8 +436,8 @@ job_child_pipeline_with_components_at_level_2:
     job:
       uuid: zzzzz-8i9sb-job1atlevel3noc
       script_version: main
-      created_at: <%= 12.hour.ago.to_s(:db) %>
-      started_at: <%= 12.hour.ago.to_s(:db) %>
+      created_at: <%= 12.hour.ago.to_fs(:db) %>
+      started_at: <%= 12.hour.ago.to_fs(:db) %>
       state: Running
       tasks_summary:
         failed: 0
@@ -451,8 +451,8 @@ job_child_pipeline_with_components_at_level_2:
     job:
       uuid: zzzzz-8i9sb-job2atlevel3noc
       script_version: main
-      created_at: <%= 12.hour.ago.to_s(:db) %>
-      started_at: <%= 12.hour.ago.to_s(:db) %>
+      created_at: <%= 12.hour.ago.to_fs(:db) %>
+      started_at: <%= 12.hour.ago.to_fs(:db) %>
       state: Running
       tasks_summary:
         failed: 0
@@ -470,9 +470,9 @@ pipeline_<%=i%>_of_10:
   name: pipeline_<%= i %>
   uuid: zzzzz-d1hrv-10pipelines0<%= i.to_s.rjust(3, '0') %>
   owner_uuid: zzzzz-j7d0g-000010pipelines
-  created_at: <%= (2*(i-1)).hour.ago.to_s(:db) %>
-  started_at: <%= (2*(i-1)).hour.ago.to_s(:db) %>
-  finished_at: <%= (i-1).minute.ago.to_s(:db) %>
+  created_at: <%= (2*(i-1)).hour.ago.to_fs(:db) %>
+  started_at: <%= (2*(i-1)).hour.ago.to_fs(:db) %>
+  finished_at: <%= (i-1).minute.ago.to_fs(:db) %>
   state: Failed
   components:
     foo:
@@ -494,7 +494,7 @@ pipeline_<%=i%>_of_2_pipelines_and_60_crs:
   state: New
   uuid: zzzzz-d1hrv-abcgneyn6brx<%= i.to_s.rjust(3, '0') %>
   owner_uuid: zzzzz-j7d0g-nnncrspipelines
-  created_at: <%= i.minute.ago.to_s(:db) %>
+  created_at: <%= i.minute.ago.to_fs(:db) %>
   components:
     foo:
       script: foo
@@ -513,9 +513,9 @@ pipeline_<%=i%>_of_25:
   state: Failed
   uuid: zzzzz-d1hrv-25pipelines0<%= i.to_s.rjust(3, '0') %>
   owner_uuid: zzzzz-j7d0g-000025pipelines
-  created_at: <%= i.hour.ago.to_s(:db) %>
-  started_at: <%= i.hour.ago.to_s(:db) %>
-  finished_at: <%= i.minute.ago.to_s(:db) %>
+  created_at: <%= i.hour.ago.to_fs(:db) %>
+  started_at: <%= i.hour.ago.to_fs(:db) %>
+  finished_at: <%= i.minute.ago.to_fs(:db) %>
   components:
     foo:
       script: foo
index 29b76abb456a7012325a7d79f14e25116cb6fc8f..ad9c7d267683668559aed7a355983d00d72b3fc1 100644 (file)
@@ -28,7 +28,7 @@ workflow_with_input_specifications:
   owner_uuid: zzzzz-j7d0g-zhxawtyetzwc5f0
   name: Workflow with input specifications
   description: this workflow has inputs specified
-  created_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 1.minute.ago.to_fs(:db) %>
   definition: |
     cwlVersion: v1.0
     class: CommandLineTool
@@ -54,7 +54,7 @@ workflow_with_input_defaults:
   owner_uuid: zzzzz-j7d0g-zhxawtyetzwc5f0
   name: Workflow with default input specifications
   description: this workflow has inputs specified
-  created_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 1.minute.ago.to_fs(:db) %>
   definition: |
     cwlVersion: v1.0
     class: CommandLineTool
@@ -73,7 +73,7 @@ workflow_with_wrr:
   owner_uuid: zzzzz-j7d0g-zhxawtyetzwc5f0
   name: Workflow with WorkflowRunnerResources
   description: this workflow has WorkflowRunnerResources
-  created_at: <%= 1.minute.ago.to_s(:db) %>
+  created_at: <%= 1.minute.ago.to_fs(:db) %>
   definition: |
     cwlVersion: v1.0
     class: CommandLineTool
index 9c70f6f417b6a654710b2d79e70bfa88b91ecd0b..60b4133f9a8ab9fc2dc977fb2d03caf98ea67ab0 100644 (file)
@@ -199,6 +199,19 @@ class Arvados::V1::ApiClientAuthorizationsControllerTest < ActionController::Tes
     assert_not_empty(json_response['uuid'])
   end
 
+  [
+    :active_noscope,
+    :active_all_collections,
+    :active_userlist,
+    :foo_collection_sharing_token,
+  ].each do |auth|
+    test "#{auth} can get current token without the appropriate scope" do
+      authorize_with auth
+      get :current
+      assert_response :success
+    end
+  end
+
   test "get current token, no auth" do
     get :current
     assert_response 401
index 8a1d044d6a760fca9ec969114382eef77b71d2ef..43797035bce8c1531717006245b62ea2e89af9d8 100644 (file)
@@ -409,7 +409,7 @@ EOS
         ensure_unique_name: true
       }
       assert_response :success
-      assert_match /^owned_by_active \(\d{4}-\d\d-\d\d.*?Z\)$/, json_response['name']
+      assert_match /^owned_by_active \(#{json_response['uuid'][-15..-1]}\)$/, json_response['name']
     end
   end
 
@@ -1222,6 +1222,20 @@ EOS
     assert_nil json_response['trash_at']
   end
 
+  test 'untrash a trashed collection by assigning nil to trash_at' do
+    authorize_with :active
+    post :update, params: {
+           id: collections(:expired_collection).uuid,
+           collection: {
+             trash_at: nil,
+           },
+           include_trash: true,
+    }
+    assert_response 200
+    assert_equal false, json_response['is_trashed']
+    assert_nil json_response['trash_at']
+  end
+
   test 'untrash error on not trashed collection' do
     authorize_with :active
     post :untrash, params: {
@@ -1271,7 +1285,7 @@ EOS
     assert_equal false, json_response['is_trashed']
     assert_nil json_response['trash_at']
     assert_nil json_response['delete_at']
-    assert_match /^same name for trashed and persisted collections \(\d{4}-\d\d-\d\d.*?Z\)$/, json_response['name']
+    assert_match /^same name for trashed and persisted collections \(#{json_response['uuid'][-15..-1]}\)$/, json_response['name']
   end
 
   test 'cannot show collection in trashed subproject' do
index f287a11fafe6d0bc95917647624de813bbf4e3d4..87eb37cde732b220fe17cd719e299b6c723bcdfc 100644 (file)
@@ -103,7 +103,7 @@ class Arvados::V1::ContainerRequestsControllerTest < ActionController::TestCase
   test "update without deleting secret_mounts" do
     authorize_with :active
     req = container_requests(:uncommitted)
-    req.update_attributes!(secret_mounts: {'/foo' => {'kind' => 'json', 'content' => 'bar'}})
+    req.update!(secret_mounts: {'/foo' => {'kind' => 'json', 'content' => 'bar'}})
 
     patch :update, params: {
             id: req.uuid,
@@ -169,7 +169,7 @@ class Arvados::V1::ContainerRequestsControllerTest < ActionController::TestCase
   test "filter on container subproperty runtime_status[foo] = bar" do
     ctr = containers(:running)
     act_as_system_user do
-      ctr.update_attributes!(runtime_status: {foo: 'bar'})
+      ctr.update!(runtime_status: {foo: 'bar'})
     end
     authorize_with :active
     get :index, params: {
index 8c2919b97102db2e3007938e1fe32405c4251928..07fa5c3211c3b74f3ba5ccfa0756ecf57566322f 100644 (file)
@@ -168,4 +168,25 @@ class Arvados::V1::ContainersControllerTest < ActionController::TestCase
     assert_response :success
     assert_not_equal 0, Container.find_by_uuid(containers(:running).uuid).priority
   end
+
+  test 'update runtime_status, runtime_status is toplevel key' do
+    authorize_with :dispatch1
+    c = containers(:running)
+    patch :update, params: {id: containers(:running).uuid, runtime_status: {activity: "foo", activityDetail: "bar"}}
+    assert_response :success
+  end
+
+  test 'update runtime_status, container is toplevel key' do
+    authorize_with :dispatch1
+    c = containers(:running)
+    patch :update, params: {id: containers(:running).uuid, container: {runtime_status: {activity: "foo", activityDetail: "bar"}}}
+    assert_response :success
+  end
+
+  test 'update state, state is toplevel key' do
+    authorize_with :dispatch1
+    c = containers(:running)
+    patch :update, params: {id: containers(:running).uuid, state: "Complete", runtime_status: {activity: "finishing"}}
+    assert_response :success
+  end
 end
index 3916d63c5ed1cce10cca11182b23682db512d8d1..5d343314cea88fcaa29bf1009b4d8b459f8c378a 100644 (file)
@@ -39,6 +39,41 @@ class Arvados::V1::FiltersTest < ActionController::TestCase
     assert_match(/no longer supported/, json_response['errors'].join(' '))
   end
 
+  test 'error message for int64 overflow' do
+    # some versions of ActiveRecord cast >64-bit ints to postgres
+    # numeric type, but this is never useful because database content
+    # is 64 bit.
+    @controller = Arvados::V1::LogsController.new
+    authorize_with :active
+    get :index, params: {
+      filters: [['id', '=', 123412341234123412341234]],
+    }
+    assert_response 422
+    assert_match(/Invalid operand .* integer attribute/, json_response['errors'].join(' '))
+  end
+
+  ['in', 'not in'].each do |operator|
+    test "error message for int64 overflow ('#{operator}' filter)" do
+      @controller = Arvados::V1::ContainerRequestsController.new
+      authorize_with :active
+      get :index, params: {
+            filters: [['priority', operator, [9, 123412341234123412341234]]],
+          }
+      assert_response 422
+      assert_match(/Invalid element .* integer attribute/, json_response['errors'].join(' '))
+    end
+  end
+
+  test 'error message for invalid boolean operand' do
+    @controller = Arvados::V1::GroupsController.new
+    authorize_with :active
+    get :index, params: {
+      filters: [['is_trashed', '=', 'fourty']],
+    }
+    assert_response 422
+    assert_match(/Invalid operand .* boolean attribute/, json_response['errors'].join(' '))
+  end
+
   test 'api responses provide timestamps with nanoseconds' do
     @controller = Arvados::V1::CollectionsController.new
     authorize_with :active
index cfcb33d40a743c21cbbd8ae0ff1ec7dd15c5945e..ee7f716c806ba77c7f449c5f970b16051cf56661 100644 (file)
@@ -330,6 +330,38 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
     assert_equal 0, json_response['items'].count
   end
 
+  test 'get group-owned objects with select' do
+    authorize_with :active
+    get :contents, params: {
+      id: groups(:aproject).uuid,
+      limit: 100,
+      format: :json,
+      select: ["uuid", "storage_classes_desired"]
+    }
+    assert_response :success
+    assert_equal 17, json_response['items_available']
+    assert_equal 17, json_response['items'].count
+    json_response['items'].each do |item|
+      # Expect collections to have a storage_classes field, other items should not.
+      if item["kind"] == "arvados#collection"
+        assert !item["storage_classes_desired"].nil?
+      else
+        assert item["storage_classes_desired"].nil?
+      end
+    end
+  end
+
+  test 'get group-owned objects with invalid field in select' do
+    authorize_with :active
+    get :contents, params: {
+      id: groups(:aproject).uuid,
+      limit: 100,
+      format: :json,
+      select: ["uuid", "storage_classes_desire"]
+    }
+    assert_response 422
+  end
+
   test 'get group-owned objects with additional filter matching nothing' do
     authorize_with :active
     get :contents, params: {
@@ -442,7 +474,7 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
     assert_not_equal(new_project['uuid'],
                      groups(:aproject).uuid,
                      "create returned same uuid as existing project")
-    assert_match(/^A Project \(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\)$/,
+    assert_match(/^A Project \(#{new_project['uuid'][-15..-1]}\)$/,
                  new_project['name'])
   end
 
@@ -768,7 +800,7 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
             ensure_unique_name: true
            }
       assert_response :success
-      assert_match /^trashed subproject 3 \(\d{4}-\d\d-\d\d.*?Z\)$/, json_response['name']
+      assert_match /^trashed subproject 3 \(#{json_response['uuid'][-15..-1]}\)$/, json_response['name']
     end
 
     test "move trashed subproject to new owner #{auth}" do
@@ -952,7 +984,7 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
     innertrash = Collection.create!(name: 'inner-trashed', owner_uuid: innerproj.uuid, trash_at: trashtime)
     innertrashproj = Group.create!(group_class: 'project', name: 'inner-trashed-proj', owner_uuid: innerproj.uuid, trash_at: trashtime)
     outertrash = Collection.create!(name: 'outer-trashed', owner_uuid: outerproj.uuid, trash_at: trashtime)
-    innerproj.update_attributes!(frozen_by_uuid: users(:active).uuid)
+    innerproj.update!(frozen_by_uuid: users(:active).uuid)
     get :contents, params: {id: outerproj.uuid, include_trash: true, recursive: true}
     assert_response :success
     uuids = json_response['items'].collect { |item| item['uuid'] }
index 6d27bccfc48e55635e062d10293e22c0a0288b50..d8d2d52c89699f56ea615283650efd4741ec6eb2 100644 (file)
@@ -39,7 +39,8 @@ class Arvados::V1::ManagementControllerTest < ActionController::TestCase
     @request.headers['Authorization'] = "Bearer configuredmanagementtoken"
     get :metrics
     assert_response :success
-    assert_equal 'text/plain', @response.content_type
+    assert_equal 'text/plain', @response.media_type
+    assert_equal 'utf-8', @response.charset
 
     assert_match /\narvados_config_source_timestamp_seconds{sha256="#{hash}"} #{Regexp.escape mtime.utc.to_f.to_s}\n/, @response.body
 
index 89feecb454a9fa74541b7328cf282287ee46da6e..65a2b64b8a4be509aee4eba716e310477d88a43b 100644 (file)
@@ -9,7 +9,6 @@ class Arvados::V1::SchemaControllerTest < ActionController::TestCase
   setup do forget end
   teardown do forget end
   def forget
-    Rails.cache.delete 'arvados_v1_rest_discovery'
     AppVersion.forget
   end
 
@@ -84,7 +83,7 @@ class Arvados::V1::SchemaControllerTest < ActionController::TestCase
     group_index_params = discovery_doc['resources']['groups']['methods']['index']['parameters']
     group_contents_params = discovery_doc['resources']['groups']['methods']['contents']['parameters']
 
-    assert_equal group_contents_params.keys.sort, (group_index_params.keys - ['select'] + ['uuid', 'recursive', 'include', 'include_old_versions']).sort
+    assert_equal group_contents_params.keys.sort, (group_index_params.keys + ['uuid', 'recursive', 'include', 'include_old_versions']).sort
 
     recursive_param = group_contents_params['recursive']
     assert_equal 'boolean', recursive_param['type']
index b7d683df29b16df8eeb31b5443252a60b1742be0..cc0b5e1320988b1098f698528fb6e892f4b11ea1 100644 (file)
@@ -68,7 +68,7 @@ class Arvados::V1::UsersControllerTest < ActionController::TestCase
 
   test "respond 401 if given token exists but user record is missing" do
     authorize_with :valid_token_deleted_user
-    get :current, {format: :json}
+    get :current, format: :json
     assert_response 401
   end
 
@@ -889,7 +889,7 @@ The Arvados team.
    ['dst', :project_viewer_trustedclient]].each do |which_scoped, auth|
     test "refuse to merge with scoped #{which_scoped} token" do
       act_as_system_user do
-        api_client_authorizations(auth).update_attributes(scopes: ["GET /", "POST /", "PUT /"])
+        api_client_authorizations(auth).update(scopes: ["GET /", "POST /", "PUT /"])
       end
       authorize_with(:active_trustedclient)
       post(:merge, params: {
@@ -1043,12 +1043,16 @@ The Arvados team.
     existinguuid = 'remot-tpzed-foobarbazwazqux'
     newuuid = 'remot-tpzed-newnarnazwazqux'
     unchanginguuid = 'remot-tpzed-nochangingattrs'
+    conflictinguuid1 = 'remot-tpzed-conflictingnam1'
+    conflictinguuid2 = 'remot-tpzed-conflictingnam2'
     act_as_system_user do
       User.create!(uuid: existinguuid, email: 'root@existing.example.com')
       User.create!(uuid: unchanginguuid, email: 'root@unchanging.example.com', prefs: {'foo' => {'bar' => 'baz'}})
     end
     assert_equal(1, Log.where(object_uuid: unchanginguuid).count)
 
+    Rails.configuration.Login.LoginCluster = 'remot'
+
     authorize_with(:admin)
     patch(:batch_update,
           params: {
@@ -1059,15 +1063,28 @@ The Arvados team.
                 'is_active' => true,
                 'is_admin' => true,
                 'prefs' => {'foo' => 'bar'},
+                'is_invited' => true
               },
               newuuid => {
                 'first_name' => 'noot',
                 'email' => 'root@remot.example.com',
                 'username' => '',
+                'is_invited' => true
               },
               unchanginguuid => {
                 'email' => 'root@unchanging.example.com',
                 'prefs' => {'foo' => {'bar' => 'baz'}},
+                'is_invited' => true
+              },
+              conflictinguuid1 => {
+                'email' => 'root@conflictingname1.example.com',
+                'username' => 'active',
+                'is_invited' => true
+              },
+              conflictinguuid2 => {
+                'email' => 'root@conflictingname2.example.com',
+                'username' => 'federatedactive',
+                'is_invited' => true
               },
             }})
     assert_response(:success)
@@ -1084,7 +1101,38 @@ The Arvados team.
     assert_equal(1, Log.where(object_uuid: unchanginguuid).count)
   end
 
-  NON_ADMIN_USER_DATA = ["uuid", "kind", "is_active", "email", "first_name",
+  test 'batch update does not produce spurious log events' do
+    # test for bug #21304
+
+    existinguuid = 'remot-tpzed-foobarbazwazqux'
+    act_as_system_user do
+      User.create!(uuid: existinguuid,
+                   first_name: 'root',
+                   is_active: true,
+                  )
+    end
+    assert_equal(1, Log.where(object_uuid: existinguuid).count)
+
+    Rails.configuration.Login.LoginCluster = 'remot'
+
+    authorize_with(:admin)
+    patch(:batch_update,
+          params: {
+            updates: {
+              existinguuid => {
+                'first_name' => 'root',
+                'email' => '',
+                'username' => '',
+                'is_active' => true,
+                'is_invited' => true
+              },
+            }})
+    assert_response(:success)
+
+    assert_equal(1, Log.where(object_uuid: existinguuid).count)
+  end
+
+  NON_ADMIN_USER_DATA = ["uuid", "kind", "is_active", "is_admin", "is_invited", "email", "first_name",
                          "last_name", "username", "can_write", "can_manage"].sort
 
   def check_non_admin_index
index 66aff787bd78ecba8f5d897aa29c9d0a99265575..cf4c6e8b4de1ba101791d914f0c2b2837a9f21e2 100644 (file)
@@ -6,124 +6,30 @@ require 'test_helper'
 
 class UserSessionsControllerTest < ActionController::TestCase
 
-  test "redirect to joshid" do
-    api_client_page = 'http://client.example.com/home'
-    get :login, params: {return_to: api_client_page}
-    # Not supported any more
-    assert_response 404
-  end
-
-  test "send token when user is already logged in" do
-    authorize_with :inactive
-    api_client_page = 'http://client.example.com/home'
-    get :login, params: {return_to: api_client_page}
-    assert_response :redirect
-    assert_equal(0, @response.redirect_url.index(api_client_page + '?'),
-                 'Redirect url ' + @response.redirect_url +
-                 ' should start with ' + api_client_page + '?')
-    assert_not_nil assigns(:api_client)
-  end
-
-  test "login creates token without expiration by default" do
-    assert_equal Rails.configuration.Login.TokenLifetime, 0
-    authorize_with :inactive
-    api_client_page = 'http://client.example.com/home'
-    get :login, params: {return_to: api_client_page}
-    assert_response :redirect
-    assert_not_nil assigns(:api_client)
-    assert_nil assigns(:api_client_auth).expires_at
-  end
-
-  test "login creates token with configured lifetime" do
-    token_lifetime = 1.hour
-    Rails.configuration.Login.TokenLifetime = token_lifetime
-    authorize_with :inactive
-    api_client_page = 'http://client.example.com/home'
-    get :login, params: {return_to: api_client_page}
-    assert_response :redirect
-    assert_not_nil assigns(:api_client)
-    api_client_auth = assigns(:api_client_auth)
-    assert_in_delta(api_client_auth.expires_at,
-                    api_client_auth.updated_at + token_lifetime,
-                    1.second)
-  end
-
-  [[0, 1.hour, 1.hour],
-  [1.hour, 2.hour, 1.hour],
-  [2.hour, 1.hour, 1.hour],
-  [2.hour, nil, 2.hour],
-  ].each do |config_lifetime, request_lifetime, expect_lifetime|
-    test "login with TokenLifetime=#{config_lifetime} and request has expires_at=#{ request_lifetime.nil? ? "nil" : request_lifetime }" do
-      Rails.configuration.Login.TokenLifetime = config_lifetime
-      expected_expiration_time =  Time.now() + expect_lifetime
-      authorize_with :inactive
-      @request.headers['Authorization'] = 'Bearer '+Rails.configuration.SystemRootToken
-      if request_lifetime.nil?
-        get :create, params: {provider: 'controller', auth_info: {email: "foo@bar.com"}, return_to: ',https://app.example'}
-      else
-        get :create, params: {provider: 'controller', auth_info: {email: "foo@bar.com", expires_at: Time.now() + request_lifetime}, return_to: ',https://app.example'}
-      end
-      assert_response :redirect
-      api_client_auth = assigns(:api_client_auth)
-      assert_not_nil api_client_auth
-      assert_not_nil assigns(:api_client)
-      assert_in_delta(api_client_auth.expires_at,
-                      expected_expiration_time,
-                      1.second)
-    end
-  end
-
-  test "login with remote param returns a salted token" do
-    authorize_with :inactive
-    api_client_page = 'http://client.example.com/home'
-    remote_prefix = 'zbbbb'
-    get :login, params: {return_to: api_client_page, remote: remote_prefix}
-    assert_response :redirect
-    api_client_auth = assigns(:api_client_auth)
-    assert_not_nil api_client_auth
-    assert_includes(@response.redirect_url, 'api_token='+api_client_auth.salted_token(remote: remote_prefix))
+  setup do
+    @allowed_return_to = ",https://controller.api.client.invalid"
   end
 
-  test "login with malformed remote param returns an error" do
-    authorize_with :inactive
-    api_client_page = 'http://client.example.com/home'
-    remote_prefix = 'invalid_cluster_id'
-    get :login, params: {return_to: api_client_page, remote: remote_prefix}
-    assert_response 400
-  end
-
-  test "login to LoginCluster" do
-    Rails.configuration.Login.LoginCluster = 'zbbbb'
-    Rails.configuration.RemoteClusters['zbbbb'] = ConfigLoader.to_OrderedOptions({'Host' => 'zbbbb.example.com'})
-    api_client_page = 'http://client.example.com/home'
-    get :login, params: {return_to: api_client_page}
-    assert_response :redirect
-    assert_equal("https://zbbbb.example.com/login?return_to=http%3A%2F%2Fclient.example.com%2Fhome", @response.redirect_url)
-    assert_nil assigns(:api_client)
-  end
-
-  test "don't go into redirect loop if LoginCluster is self" do
-    Rails.configuration.Login.LoginCluster = 'zzzzz'
-    api_client_page = 'http://client.example.com/home'
-    get :login, params: {return_to: api_client_page}
-    # Doesn't redirect, just fail.
+  test "login route deleted" do
+    @request.headers['Authorization'] = 'Bearer '+Rails.configuration.SystemRootToken
+    get :login, params: {provider: 'controller', return_to: @allowed_return_to}
     assert_response 404
   end
 
   test "controller cannot create session without SystemRootToken" do
-    get :create, params: {provider: 'controller', auth_info: {email: "foo@bar.com"}, return_to: ',https://app.example'}
+    get :create, params: {provider: 'controller', auth_info: {email: "foo@bar.com"}, return_to: @allowed_return_to}
     assert_response 401
   end
 
   test "controller cannot create session with wrong SystemRootToken" do
     @request.headers['Authorization'] = 'Bearer blah'
-    get :create, params: {provider: 'controller', auth_info: {email: "foo@bar.com"}, return_to: ',https://app.example'}
+    get :create, params: {provider: 'controller', auth_info: {email: "foo@bar.com"}, return_to: @allowed_return_to}
     assert_response 401
   end
 
   test "controller can create session using SystemRootToken" do
     @request.headers['Authorization'] = 'Bearer '+Rails.configuration.SystemRootToken
-    get :create, params: {provider: 'controller', auth_info: {email: "foo@bar.com"}, return_to: ',https://app.example'}
+    get :create, params: {provider: 'controller', auth_info: {email: "foo@bar.com"}, return_to: @allowed_return_to}
     assert_response :redirect
     api_client_auth = assigns(:api_client_auth)
     assert_not_nil api_client_auth
index 405e4bf687cee646c06e1c22d189802c4039d848..1b5c5639626644515ad1d7cfca7408a457460bb3 100644 (file)
@@ -77,93 +77,49 @@ class ApiClientAuthorizationsApiTest < ActionDispatch::IntegrationTest
   end
 
   [nil, db_current_time + 2.hours].each do |desired_expiration|
-    test "expires_at gets clamped on non-admins when API.MaxTokenLifetime is set and desired expires_at #{desired_expiration.nil? ? 'is not set' : 'exceeds the limit'}" do
-      Rails.configuration.API.MaxTokenLifetime = 1.hour
-
-      # Test token creation
-      start_t = db_current_time
-      post "/arvados/v1/api_client_authorizations",
-        params: {
-          :format => :json,
-          :api_client_authorization => {
-            :owner_uuid => users(:active).uuid,
-            :expires_at => desired_expiration,
-          }
-        },
-        headers: {'HTTP_AUTHORIZATION' => "OAuth2 #{api_client_authorizations(:active_trustedclient).api_token}"}
-      end_t = db_current_time
-      assert_response 200
-      expiration_t = json_response['expires_at'].to_time
-      assert_operator expiration_t.to_f, :>, (start_t + Rails.configuration.API.MaxTokenLifetime).to_f
-      if !desired_expiration.nil?
-        assert_operator expiration_t.to_f, :<, desired_expiration.to_f
-      else
-        assert_operator expiration_t.to_f, :<, (end_t + Rails.configuration.API.MaxTokenLifetime).to_f
-      end
-
-      # Test token update
-      previous_expiration = expiration_t
-      token_uuid = json_response["uuid"]
-      start_t = db_current_time
-      put "/arvados/v1/api_client_authorizations/#{token_uuid}",
-        params: {
-          :api_client_authorization => {
-            :expires_at => desired_expiration
-          }
-        },
-        headers: {'HTTP_AUTHORIZATION' => "OAuth2 #{api_client_authorizations(:active_trustedclient).api_token}"}
-      end_t = db_current_time
-      assert_response 200
-      expiration_t = json_response['expires_at'].to_time
-      assert_operator previous_expiration.to_f, :<, expiration_t.to_f
-      assert_operator expiration_t.to_f, :>, (start_t + Rails.configuration.API.MaxTokenLifetime).to_f
-      if !desired_expiration.nil?
-        assert_operator expiration_t.to_f, :<, desired_expiration.to_f
-      else
-        assert_operator expiration_t.to_f, :<, (end_t + Rails.configuration.API.MaxTokenLifetime).to_f
-      end
-    end
-
-    test "behavior when expires_at is set to #{desired_expiration.nil? ? 'nil' : 'exceed the limit'} by admins when API.MaxTokenLifetime is set" do
-      Rails.configuration.API.MaxTokenLifetime = 1.hour
-
-      # Test token creation
-      post "/arvados/v1/api_client_authorizations",
-        params: {
-          :format => :json,
-          :api_client_authorization => {
-            :owner_uuid => users(:admin).uuid,
-            :expires_at => desired_expiration,
-          }
-        },
-        headers: {'HTTP_AUTHORIZATION' => "OAuth2 #{api_client_authorizations(:admin_trustedclient).api_token}"}
-      assert_response 200
-      if desired_expiration.nil?
-        # When expires_at is nil, default to MaxTokenLifetime
-        assert_operator (json_response['expires_at'].to_time.to_i - (db_current_time + Rails.configuration.API.MaxTokenLifetime).to_i).abs, :<, 2
-      else
-        assert_equal json_response['expires_at'].to_time.to_i, desired_expiration.to_i
-      end
-
-      # Test token update (reverse the above behavior)
-      token_uuid = json_response['uuid']
-      if desired_expiration.nil?
-        submitted_updated_expiration = db_current_time + Rails.configuration.API.MaxTokenLifetime + 1.hour
-      else
-        submitted_updated_expiration = nil
-      end
-      put "/arvados/v1/api_client_authorizations/#{token_uuid}",
-        params: {
-          :api_client_authorization => {
-            :expires_at => submitted_updated_expiration,
-          }
-        },
-        headers: {'HTTP_AUTHORIZATION' => "OAuth2 #{api_client_authorizations(:admin_trustedclient).api_token}"}
-      assert_response 200
-      if submitted_updated_expiration.nil?
-        assert_operator (json_response['expires_at'].to_time.to_i - (db_current_time + Rails.configuration.API.MaxTokenLifetime).to_i).abs, :<, 2
-      else
-        assert_equal json_response['expires_at'].to_time.to_i, submitted_updated_expiration.to_i
+    [false, true].each do |admin|
+      test "expires_at gets clamped on #{admin ? 'admins' : 'non-admins'} when API.MaxTokenLifetime is set and desired expires_at #{desired_expiration.nil? ? 'is not set' : 'exceeds the limit'}" do
+        Rails.configuration.API.MaxTokenLifetime = 1.hour
+        token = api_client_authorizations(admin ? :admin_trustedclient : :active_trustedclient).api_token
+
+        # Test token creation
+        start_t = db_current_time
+        post "/arvados/v1/api_client_authorizations",
+             params: {
+               :format => :json,
+               :api_client_authorization => {
+                 :owner_uuid => users(admin ? :admin : :active).uuid,
+                 :expires_at => desired_expiration,
+               }
+             },
+             headers: {'HTTP_AUTHORIZATION' => "OAuth2 #{token}"}
+        assert_response 200
+        expiration_t = json_response['expires_at'].to_time
+        if admin && desired_expiration
+          assert_in_delta desired_expiration.to_f, expiration_t.to_f, 1
+        else
+          assert_in_delta (start_t + Rails.configuration.API.MaxTokenLifetime).to_f, expiration_t.to_f, 2
+        end
+
+        # Test token update
+        previous_expiration = expiration_t
+        token_uuid = json_response["uuid"]
+
+        start_t = db_current_time
+        patch "/arvados/v1/api_client_authorizations/#{token_uuid}",
+            params: {
+              :api_client_authorization => {
+                :expires_at => desired_expiration
+              }
+            },
+            headers: {'HTTP_AUTHORIZATION' => "OAuth2 #{token}"}
+        assert_response 200
+        expiration_t = json_response['expires_at'].to_time
+        if admin && desired_expiration
+          assert_in_delta desired_expiration.to_f, expiration_t.to_f, 1
+        else
+          assert_in_delta (start_t + Rails.configuration.API.MaxTokenLifetime).to_f, expiration_t.to_f, 2
+        end
       end
     end
   end
index d015e450a610df0cbcddefe138393a598eca4296..3b28a3163feef85b026345a39b061db3e9c8372c 100644 (file)
@@ -16,40 +16,43 @@ class ApiTokensScopeTest < ActionDispatch::IntegrationTest
   end
 
   test "user list token can only list users" do
-    get_args = [params: {}, headers: auth(:active_userlist)]
-    get(v1_url('users'), *get_args)
+    get_args = {params: {}, headers: auth(:active_userlist)}
+    get(v1_url('users'), **get_args)
     assert_response :success
-    get(v1_url('users', ''), *get_args)  # Add trailing slash.
+    get(v1_url('users', ''), **get_args)  # Add trailing slash.
     assert_response :success
-    get(v1_url('users', 'current'), *get_args)
+    get(v1_url('users', 'current'), **get_args)
     assert_response 403
-    get(v1_url('virtual_machines'), *get_args)
+    get(v1_url('virtual_machines'), **get_args)
     assert_response 403
   end
 
   test "narrow + wide scoped tokens for different users" do
-    get_args = [params: {
-                  reader_tokens: [api_client_authorizations(:anonymous).api_token]
-                }, headers: auth(:active_userlist)]
-    get(v1_url('users'), *get_args)
+    get_args = {
+      params: {
+        reader_tokens: [api_client_authorizations(:anonymous).api_token]
+      },
+      headers: auth(:active_userlist),
+    }
+    get(v1_url('users'), **get_args)
     assert_response :success
-    get(v1_url('users', ''), *get_args)  # Add trailing slash.
+    get(v1_url('users', ''), **get_args)  # Add trailing slash.
     assert_response :success
-    get(v1_url('users', 'current'), *get_args)
+    get(v1_url('users', 'current'), **get_args)
     assert_response 403
-    get(v1_url('virtual_machines'), *get_args)
+    get(v1_url('virtual_machines'), **get_args)
     assert_response 403
    end
 
   test "specimens token can see exactly owned specimens" do
-    get_args = [params: {}, headers: auth(:active_specimens)]
-    get(v1_url('specimens'), *get_args)
+    get_args = {params: {}, headers: auth(:active_specimens)}
+    get(v1_url('specimens'), **get_args)
     assert_response 403
-    get(v1_url('specimens', specimens(:owned_by_active_user).uuid), *get_args)
+    get(v1_url('specimens', specimens(:owned_by_active_user).uuid), **get_args)
     assert_response :success
-    head(v1_url('specimens', specimens(:owned_by_active_user).uuid), *get_args)
+    head(v1_url('specimens', specimens(:owned_by_active_user).uuid), **get_args)
     assert_response :success
-    get(v1_url('specimens', specimens(:owned_by_spectator).uuid), *get_args)
+    get(v1_url('specimens', specimens(:owned_by_spectator).uuid), **get_args)
     assert_includes(403..404, @response.status)
   end
 
@@ -82,12 +85,12 @@ class ApiTokensScopeTest < ActionDispatch::IntegrationTest
   test "token without scope has no access" do
     # Logs are good for this test, because logs have relatively
     # few access controls enforced at the model level.
-    req_args = [params: {}, headers: auth(:admin_noscope)]
-    get(v1_url('logs'), *req_args)
+    req_args = {params: {}, headers: auth(:admin_noscope)}
+    get(v1_url('logs'), **req_args)
     assert_response 403
-    get(v1_url('logs', logs(:noop).uuid), *req_args)
+    get(v1_url('logs', logs(:noop).uuid), **req_args)
     assert_response 403
-    post(v1_url('logs'), *req_args)
+    post(v1_url('logs'), **req_args)
     assert_response 403
   end
 
@@ -97,10 +100,10 @@ class ApiTokensScopeTest < ActionDispatch::IntegrationTest
     def vm_logins_url(name)
       v1_url('virtual_machines', virtual_machines(name).uuid, 'logins')
     end
-    get_args = [params: {}, headers: auth(:admin_vm)]
-    get(vm_logins_url(:testvm), *get_args)
+    get_args = {params: {}, headers: auth(:admin_vm)}
+    get(vm_logins_url(:testvm), **get_args)
     assert_response :success
-    get(vm_logins_url(:testvm2), *get_args)
+    get(vm_logins_url(:testvm2), **get_args)
     assert_includes(400..419, @response.status,
                     "getting testvm2 logins should have failed")
   end
index e3099f15735dec3d9ebf688b251553aad3964463..6a3db89fc43a43ca1eaa49c5ae1164422ca33c48 100644 (file)
@@ -5,10 +5,10 @@
 require 'test_helper'
 
 class CrossOriginTest < ActionDispatch::IntegrationTest
-  def options *args
+  def options path, **kwargs
     # Rails doesn't support OPTIONS the same way as GET, POST, etc.
     reset! unless integration_session
-    integration_session.__send__(:process, :options, *args).tap do
+    integration_session.__send__(:process, :options, path, **kwargs).tap do
       copy_session_variables!
     end
   end
diff --git a/services/api/test/integration/discovery_document_test.rb b/services/api/test/integration/discovery_document_test.rb
new file mode 100644 (file)
index 0000000..37e7750
--- /dev/null
@@ -0,0 +1,58 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+require 'test_helper'
+
+class DiscoveryDocumentTest < ActionDispatch::IntegrationTest
+  CANONICAL_FIELDS = [
+    "auth",
+    "basePath",
+    "batchPath",
+    "description",
+    "discoveryVersion",
+    "documentationLink",
+    "id",
+    "kind",
+    "name",
+    "parameters",
+    "protocol",
+    "resources",
+    "revision",
+    "schemas",
+    "servicePath",
+    "title",
+    "version",
+  ]
+
+  test "canonical discovery document is saved to checkout" do
+    get "/discovery/v1/apis/arvados/v1/rest"
+    assert_response :success
+    canonical = Hash[CANONICAL_FIELDS.map { |key| [key, json_response[key]] }]
+    missing = canonical.select { |key| canonical[key].nil? }
+    assert(missing.empty?, "discovery document missing required fields")
+    actual_json = JSON.pretty_generate(canonical)
+
+    # Currently the Python SDK is the only component using this copy of the
+    # discovery document, and storing it with the source simplifies the build
+    # process, so it lives there. If another component wants to use it later,
+    # we might consider moving it to a more general subdirectory, but then the
+    # Python build process will need to be extended to accommodate that.
+    src_path = Rails.root.join("../../sdk/python/arvados-v1-discovery.json")
+    begin
+      expected_json = File.open(src_path) { |f| f.read }
+    rescue Errno::ENOENT
+      expected_json = "(#{src_path} not found)"
+    end
+
+    out_path = Rails.root.join("tmp", "test-arvados-v1-discovery.json")
+    if expected_json != actual_json
+      File.open(out_path, "w") { |f| f.write(actual_json) }
+    end
+    assert_equal(expected_json, actual_json, [
+                   "#{src_path} did not match the live discovery document",
+                   "Current live version saved to #{out_path}",
+                   "Commit that to #{src_path} to regenerate documentation",
+                 ].join(". "))
+  end
+end
diff --git a/services/api/test/integration/http_quirks_test.rb b/services/api/test/integration/http_quirks_test.rb
new file mode 100644 (file)
index 0000000..107e6a6
--- /dev/null
@@ -0,0 +1,16 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+require 'test_helper'
+
+class HttpQuirksTest < ActionDispatch::IntegrationTest
+  fixtures :all
+
+  test "GET request with empty Content-Type header" do
+    authorize_with :active
+    get "/arvados/v1/collections",
+        headers: auth(:active).merge("Content-Type" => "")
+    assert_response :success
+  end
+end
index 179d30f3cbf3c255a1570ba3227b732603dd8ef9..98250a62424383863de2bd81c39b33e5b204981c 100644 (file)
@@ -55,7 +55,6 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
         SSLCertName: [["CN", WEBrick::Utils::getservername]],
         StartCallback: lambda { ready.push(true) })
       srv.mount_proc '/discovery/v1/apis/arvados/v1/rest' do |req, res|
-        Rails.cache.delete 'arvados_v1_rest_discovery'
         res.body = Arvados::V1::SchemaController.new.send(:discovery_doc).to_json
       end
       srv.mount_proc '/arvados/v1/users/current' do |req, res|
@@ -75,10 +74,15 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
         end
         res.status = @stub_token_status
         if res.status == 200
-          res.body = {
-            uuid: api_client_authorizations(:active).uuid.sub('zzzzz', clusterid),
+          body = {
+            uuid: @stub_token_uuid || api_client_authorizations(:active).uuid.sub('zzzzz', clusterid),
+            owner_uuid: "#{clusterid}-tpzed-00000000000000z",
             scopes: @stub_token_scopes,
-          }.to_json
+          }
+          if @stub_content.is_a?(Hash) and owner_uuid = @stub_content[:uuid]
+            body[:owner_uuid] = owner_uuid
+          end
+          res.body = body.to_json
         end
       end
       Thread.new do
@@ -96,12 +100,16 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
       uuid: 'zbbbb-tpzed-000000000000001',
       email: 'foo@example.com',
       username: 'barney',
+      first_name: "Barney",
+      last_name: "Foo",
       is_admin: true,
       is_active: true,
       is_invited: true,
     }
     @stub_token_status = 200
     @stub_token_scopes = ["all"]
+    @stub_token_uuid = nil
+    ActionMailer::Base.deliveries = []
   end
 
   teardown do
@@ -110,6 +118,15 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
     end
   end
 
+  def uncache_token(src)
+    if match = src.match(/\b(?:[a-z0-9]{5}-){2}[a-z0-9]{15}\b/)
+      tokens = ApiClientAuthorization.where(uuid: match[0])
+    else
+      tokens = ApiClientAuthorization.where("uuid like ?", "#{src}-%")
+    end
+    tokens.update_all(expires_at: "1995-05-15T01:02:03Z")
+  end
+
   test 'authenticate with remote token that has limited scope' do
     get '/arvados/v1/collections',
         params: {format: 'json'},
@@ -124,10 +141,7 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
         headers: auth(remote: 'zbbbb')
     assert_response :success
 
-    # simulate cache expiry
-    ApiClientAuthorization.where('uuid like ?', 'zbbbb-%').
-      update_all(expires_at: db_current_time - 1.minute)
-
+    uncache_token('zbbbb')
     # re-authorize after cache expires
     get '/arvados/v1/collections',
         params: {format: 'json'},
@@ -135,6 +149,14 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
     assert_response 403
   end
 
+  test "authenticate with remote token with limited initial scope" do
+    @stub_token_scopes = ["GET /arvados/v1/users/"]
+    get "/arvados/v1/users/#{@stub_content[:uuid]}",
+        params: {format: "json"},
+        headers: auth(remote: "zbbbb")
+    assert_response :success
+  end
+
   test 'authenticate with remote token' do
     get '/arvados/v1/users/current',
       params: {format: 'json'},
@@ -147,7 +169,7 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
     assert_equal 'barney', json_response['username']
 
     # revoke original token
-    @stub_status = 401
+    @stub_token_status = 401
 
     # re-authorize before cache expires
     get '/arvados/v1/users/current',
@@ -155,10 +177,7 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
       headers: auth(remote: 'zbbbb')
     assert_response :success
 
-    # simulate cache expiry
-    ApiClientAuthorization.where('uuid like ?', 'zbbbb-%').
-      update_all(expires_at: db_current_time - 1.minute)
-
+    uncache_token('zbbbb')
     # re-authorize after cache expires
     get '/arvados/v1/users/current',
       params: {format: 'json'},
@@ -173,7 +192,7 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
       update_all(user_id: users(:active).id)
 
     # revive original token and re-authorize
-    @stub_status = 200
+    @stub_token_status = 200
     @stub_content[:username] = 'blarney'
     @stub_content[:email] = 'blarney@example.com'
     get '/arvados/v1/users/current',
@@ -196,11 +215,7 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
     @stub_content[:is_active] = false
     @stub_content[:is_invited] = false
 
-    # simulate cache expiry
-    ApiClientAuthorization.where(
-      uuid: salted_active_token(remote: 'zbbbb').split('/')[1]).
-      update_all(expires_at: db_current_time - 1.minute)
-
+    uncache_token('zbbbb')
     # re-authorize after cache expires
     get '/arvados/v1/users/current',
       params: {format: 'json'},
@@ -227,6 +242,40 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
     assert_equal 'foo', json_response['username']
   end
 
+  test 'authenticate with remote token with secret part identical to previously cached token' do
+    get '/arvados/v1/users/current',
+      params: {format: 'json'},
+      headers: auth(remote: 'zbbbb')
+    assert_response :success
+    get '/arvados/v1/api_client_authorizations/current',
+      params: {format: 'json'},
+      headers: auth(remote: 'zbbbb')
+    assert_response :success
+
+    # Expire the cached token.
+    @cached_token_uuid = json_response['uuid']
+    act_as_system_user do
+      ApiClientAuthorization.where(uuid: @cached_token_uuid).update_all(expires_at: db_current_time() - 1.day)
+    end
+
+    # Now use the same bare token, but set up the remote cluster to
+    # return a different UUID this time.
+    @stub_token_uuid = 'zbbbb-gj3su-123451234512345'
+    get '/arvados/v1/users/current',
+      params: {format: 'json'},
+      headers: auth(remote: 'zbbbb')
+    assert_response :success
+
+    # Confirm that we actually retrieved the new UUID from the stub
+    # cluster -- otherwise we didn't really test the conflicting-UUID
+    # case.
+    get '/arvados/v1/api_client_authorizations/current',
+      params: {format: 'json'},
+      headers: auth(remote: 'zbbbb')
+    assert_response :success
+    assert_equal @stub_token_uuid, json_response['uuid']
+  end
+
   test 'authenticate with remote token from misbehaving remote cluster' do
     get '/arvados/v1/users/current',
       params: {format: 'json'},
@@ -355,6 +404,12 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
 
   test 'get user from Login cluster' do
     Rails.configuration.Login.LoginCluster = 'zbbbb'
+    email_dest = ActiveSupport::OrderedOptions.new
+    email_dest[:'arvados-admin@example.com'] = ActiveSupport::OrderedOptions.new
+    Rails.configuration.Users.UserNotifierEmailBcc = email_dest
+    Rails.configuration.Users.NewUserNotificationRecipients = email_dest
+    Rails.configuration.Users.NewInactiveUserNotificationRecipients = email_dest
+
     get '/arvados/v1/users/current',
       params: {format: 'json'},
       headers: auth(remote: 'zbbbb')
@@ -364,14 +419,18 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
     assert_equal true, json_response['is_active']
     assert_equal 'foo@example.com', json_response['email']
     assert_equal 'barney', json_response['username']
+
+    assert_equal 2, ActionMailer::Base.deliveries.length
+    assert_equal "Welcome to Arvados - account enabled", ActionMailer::Base.deliveries[0].subject
+    assert_equal "[ARVADOS] New user created notification", ActionMailer::Base.deliveries[1].subject
   end
 
   [true, false].each do |trusted|
     [true, false].each do |logincluster|
-      [true, false].each do |admin|
-        [true, false].each do |active|
+      [true, false, nil].each do |admin|
+        [true, false, nil].each do |active|
           [true, false].each do |autosetup|
-            [true, false].each do |invited|
+            [true, false, nil].each do |invited|
               test "get invited=#{invited}, active=#{active}, admin=#{admin} user from #{if logincluster then "Login" else "peer" end} cluster when AutoSetupNewUsers=#{autosetup} ActivateUsers=#{trusted}" do
                 Rails.configuration.Login.LoginCluster = 'zbbbb' if logincluster
                 Rails.configuration.RemoteClusters['zbbbb'].ActivateUsers = trusted
@@ -389,9 +448,9 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
                     headers: auth(remote: 'zbbbb')
                 assert_response :success
                 assert_equal 'zbbbb-tpzed-000000000000001', json_response['uuid']
-                assert_equal (logincluster && admin && invited && active), json_response['is_admin']
-                assert_equal (invited and (logincluster || trusted || autosetup)), json_response['is_invited']
-                assert_equal (invited and (logincluster || trusted) and active), json_response['is_active']
+                assert_equal (logincluster && !!admin && (invited != false) && !!active), json_response['is_admin']
+                assert_equal ((invited == true || (invited == nil && !!active)) && (logincluster || trusted || autosetup)), json_response['is_invited']
+                assert_equal ((invited != false) && (logincluster || trusted) && !!active), json_response['is_active']
                 assert_equal 'foo@example.com', json_response['email']
                 assert_equal 'barney', json_response['username']
               end
@@ -446,11 +505,8 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
     assert_equal 'foo@example.com', json_response['email']
     assert_equal 'barney', json_response['username']
 
-    # Delete cached value.  User should be inactive now.
-    act_as_system_user do
-      ApiClientAuthorization.delete_all
-    end
-
+    uncache_token('zbbbb')
+    # User should be inactive now.
     get '/arvados/v1/users/current',
       params: {format: 'json'},
       headers: auth(remote: 'zbbbb')
@@ -572,5 +628,68 @@ class RemoteUsersTest < ActionDispatch::IntegrationTest
     assert_equal 'zzzzz-tpzed-anonymouspublic', json_response['uuid']
   end
 
+  [400, 401, 403, 422, 500, 502, 503].each do |status|
+    test "handle #{status} response when checking remote-provided v2 token" do
+      @stub_token_status = status
+      get "/arvados/v1/users/#{@stub_content[:uuid]}",
+          params: {format: "json"},
+          headers: auth(remote: "zbbbb")
+      assert_response(status < 500 ? 401 : status)
+    end
+
+    test "handle #{status} response when checking remote-provided v2 token at anonymously accessible endpoint" do
+      @stub_token_status = status
+      get "/arvados/v1/keep_services/accessible",
+          params: {format: "json"},
+          headers: auth(remote: "zbbbb")
+      assert_response(status < 500 ? :success : status)
+    end
 
+    test "handle #{status} response when checking token issued by login cluster" do
+      @stub_token_status = status
+      Rails.configuration.Login.LoginCluster = "zbbbb"
+      get "/arvados/v1/users/current",
+          params: {format: "json"},
+          headers: {'HTTP_AUTHORIZATION' => "Bearer badtoken"}
+      assert_response(status < 500 ? 401 : status)
+    end
+
+    test "handle #{status} response when checking token issued by login cluster at anonymously accessible endpoint" do
+      @stub_token_status = status
+      Rails.configuration.Login.LoginCluster = "zbbbb"
+      get "/arvados/v1/keep_services/accessible",
+          params: {format: "json"},
+          headers: {'HTTP_AUTHORIZATION' => "Bearer badtoken"}
+      assert_response(status < 500 ? :success : status)
+    end
+  end
+
+  [401, 403, 422, 500, 502, 503].each do |status|
+    test "propagate #{status} response from getting uncached user" do
+      @stub_status = status
+      get "/arvados/v1/users/#{@stub_content[:uuid]}",
+          params: {format: "json"},
+          headers: auth(remote: "zbbbb")
+      assert_response status
+    end
+
+    test "use cached user after getting #{status} response" do
+      url_path = "/arvados/v1/users/#{@stub_content[:uuid]}"
+      params = {format: "json"}
+      headers = auth(remote: "zbbbb")
+
+      get url_path, params: params, headers: headers
+      assert_response :success
+
+      uncache_token(headers["HTTP_AUTHORIZATION"])
+      expect_email = @stub_content[:email]
+      @stub_content[:email] = "new#{expect_email}"
+      @stub_status = status
+      get url_path, params: params, headers: headers
+      assert_response :success
+      user = User.find_by_uuid(@stub_content[:uuid])
+      assert_not_nil user
+      assert_equal expect_email, user.email
+    end
+  end
 end
index 76659f3207fff6b7470e6d85ca95dcbbc936e10b..eb49cf832e2fe9b2e86a6228a1ea7dc0b7eef59a 100644 (file)
@@ -8,7 +8,7 @@ class UserSessionsApiTest < ActionDispatch::IntegrationTest
   # remote prefix & return url packed into the return_to param passed around
   # between API and SSO provider.
   def client_url(remote: nil)
-    url = ',https://wb.example.com'
+    url = ',https://controller.api.client.invalid'
     url = "#{remote}#{url}" unless remote.nil?
     url
   end
index ca143363892cad7065e65d704d1c76bbd7551c83..f8956b21e24a0772899b5c796dd2b2e650fc1e6e 100644 (file)
@@ -303,15 +303,15 @@ class UsersTest < ActionDispatch::IntegrationTest
     assert_response :success
     rp = json_response
     assert_not_nil rp["uuid"]
-    assert_not_nil rp["is_active"]
-    assert_nil rp["is_admin"]
+    assert_equal true, rp["is_active"]
+    assert_equal false, rp["is_admin"]
 
     get "/arvados/v1/users/#{rp['uuid']}",
       params: {format: 'json'},
       headers: auth(:admin)
     assert_response :success
     assert_equal rp["uuid"], json_response['uuid']
-    assert_nil json_response['is_admin']
+    assert_equal false, json_response['is_admin']
     assert_equal true, json_response['is_active']
     assert_equal 'foo@example.com', json_response['email']
     assert_equal 'barney', json_response['username']
index 843d4f1b23fccfb8777883c3021ab54187b3cf57..0255d8907da1ad7ee0e036509c9a19b693bdfe9c 100644 (file)
@@ -2,7 +2,7 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-require 'update_permissions'
+require_relative '../lib/update_permissions'
 
 ENV["RAILS_ENV"] = "test"
 unless ENV["NO_COVERAGE_TEST"]
@@ -179,21 +179,21 @@ class ActionController::TestCase
   end
 
   [:get, :post, :put, :patch, :delete].each do |method|
-    define_method method do |action, *args|
+    define_method method do |action, **kwargs|
       check_counter action
       # After Rails 5.0 upgrade, some params don't get properly serialized.
       # One case are filters: [['attr', 'op', 'val']] become [['attr'], ['op'], ['val']]
       # if not passed upstream as a JSON string.
-      if args[0].is_a?(Hash) && args[0][:params].is_a?(Hash)
-        args[0][:params].each do |key, _|
+      if kwargs[:params].is_a?(Hash)
+        kwargs[:params].each do |key, _|
           next if key == :exclude_script_versions # Job Reuse tests
           # Keys could be: :filters, :where, etc
-          if [Array, Hash].include?(args[0][:params][key].class)
-            args[0][:params][key] = SafeJSON.dump(args[0][:params][key])
+          if [Array, Hash].include?(kwargs[:params][key].class)
+            kwargs[:params][key] = SafeJSON.dump(kwargs[:params][key])
           end
         end
       end
-      super action, *args
+      super action, **kwargs
     end
   end
 
index a0eacfd13bb65ad2f6ff4f77cfa59d0b8fafd402..dbe9c863671bb34807b7ffe136095dc67c2d70cb 100644 (file)
@@ -40,4 +40,31 @@ class ApiClientTest < ActiveSupport::TestCase
       end
     end
   end
+
+  [
+    [true, "https://ok.example", "https://ok.example"],
+    [true, "https://ok.example:443/", "https://ok.example"],
+    [true, "https://ok.example", "https://ok.example:443/"],
+    [true, "https://ok.example", "https://ok.example/foo/bar"],
+    [true, "https://ok.example", "https://ok.example?foo/bar"],
+    [true, "https://ok.example/waz?quux", "https://ok.example/foo?bar#baz"],
+    [false, "https://ok.example", "http://ok.example"],
+    [false, "https://ok.example", "http://ok.example:443"],
+
+    [true, "https://*.wildcard.example", "https://ok.wildcard.example"],
+    [true, "https://*.wildcard.example", "https://ok.ok.ok.wildcard.example"],
+    [false, "https://*.wildcard.example", "http://wrongscheme.wildcard.example"],
+    [false, "https://*.wildcard.example", "https://wrongport.wildcard.example:80"],
+    [false, "https://*.wildcard.example", "https://ok.wildcard.example.attacker.example/"],
+    [false, "https://*.wildcard.example", "https://attacker.example/https://ok.wildcard.example/"],
+    [false, "https://*.wildcard.example", "https://attacker.example/?https://ok.wildcard.example/"],
+    [false, "https://*.wildcard.example", "https://attacker.example/#https://ok.wildcard.example/"],
+    [false, "https://*-wildcard.example", "https://notsupported-wildcard.example"],
+  ].each do |pass, trusted, current|
+    test "is_trusted(#{current}) returns #{pass} based on #{trusted} in TrustedClients" do
+      Rails.configuration.Login.TrustedClients = ActiveSupport::OrderedOptions.new
+      Rails.configuration.Login.TrustedClients[trusted.to_sym] = ActiveSupport::OrderedOptions.new
+      assert_equal pass, ApiClient.new(url_prefix: current).is_trusted
+    end
+  end
 end
index 1e2e08059ef92c75827bcea9baa5d95edc2945c4..69a2710bb954caa04f3c3e86da8c13f3719bb10b 100644 (file)
@@ -217,13 +217,13 @@ class ArvadosModelTest < ActiveSupport::TestCase
     assert group.valid?, "group is not valid"
 
     # update 1
-    group.update_attributes!(name: "test create and update name 1")
+    group.update!(name: "test create and update name 1")
     results = Group.where(uuid: group.uuid)
     assert_equal "test create and update name 1", results.first.name, "Expected name to be updated to 1"
     updated_at_1 = results.first.updated_at.to_f
 
     # update 2
-    group.update_attributes!(name: "test create and update name 2")
+    group.update!(name: "test create and update name 2")
     results = Group.where(uuid: group.uuid)
     assert_equal "test create and update name 2", results.first.name, "Expected name to be updated to 2"
     updated_at_2 = results.first.updated_at.to_f
@@ -237,15 +237,15 @@ class ArvadosModelTest < ActiveSupport::TestCase
     c = Collection.create!(properties: {})
     assert_equal({}, c.properties)
 
-    c.update_attributes(properties: {'foo' => 'foo'})
+    c.update(properties: {'foo' => 'foo'})
     c.reload
     assert_equal({'foo' => 'foo'}, c.properties)
 
-    c.update_attributes(properties: nil)
+    c.update(properties: nil)
     c.reload
     assert_equal({}, c.properties)
 
-    c.update_attributes(properties: {foo: 'bar'})
+    c.update(properties: {foo: 'bar'})
     assert_equal({'foo' => 'bar'}, c.properties)
     c.reload
     assert_equal({'foo' => 'bar'}, c.properties)
index e7134a5be581f7b8efd69f1be04919631e7d98ed..f3b48dbf70a60b196ef635469bb8b1a6b6e59100 100644 (file)
@@ -91,19 +91,19 @@ class CollectionTest < ActiveSupport::TestCase
       assert_equal 34, c.file_size_total
 
       # Updating the manifest should change file stats
-      c.update_attributes(manifest_text: ". d41d8cd98f00b204e9800998ecf8427e 0:34:foo.txt 0:34:foo2.txt\n")
+      c.update(manifest_text: ". d41d8cd98f00b204e9800998ecf8427e 0:34:foo.txt 0:34:foo2.txt\n")
       assert c.valid?
       assert_equal 2, c.file_count
       assert_equal 68, c.file_size_total
 
       # Updating file stats and the manifest should use manifest values
-      c.update_attributes(manifest_text: ". d41d8cd98f00b204e9800998ecf8427e 0:34:foo.txt\n", file_count:10, file_size_total: 10)
+      c.update(manifest_text: ". d41d8cd98f00b204e9800998ecf8427e 0:34:foo.txt\n", file_count:10, file_size_total: 10)
       assert c.valid?
       assert_equal 1, c.file_count
       assert_equal 34, c.file_size_total
 
       # Updating just the file stats should be ignored
-      c.update_attributes(file_count: 10, file_size_total: 10)
+      c.update(file_count: 10, file_size_total: 10)
       assert c.valid?
       assert_equal 1, c.file_count
       assert_equal 34, c.file_size_total
@@ -166,7 +166,7 @@ class CollectionTest < ActiveSupport::TestCase
       assert_equal 1, c.version
       assert_equal false, c.preserve_version
       # Make a versionable update, it shouldn't create a new version yet
-      c.update_attributes!({'name' => 'bar'})
+      c.update!({'name' => 'bar'})
       c.reload
       assert_equal 'bar', c.name
       assert_equal 1, c.version
@@ -175,12 +175,12 @@ class CollectionTest < ActiveSupport::TestCase
       c.update_column('modified_at', fifteen_min_ago) # Update without validations/callbacks
       c.reload
       assert_equal fifteen_min_ago.to_i, c.modified_at.to_i
-      c.update_attributes!({'name' => 'baz'})
+      c.update!({'name' => 'baz'})
       c.reload
       assert_equal 'baz', c.name
       assert_equal 2, c.version
       # Make another update, no new version should be created
-      c.update_attributes!({'name' => 'foobar'})
+      c.update!({'name' => 'foobar'})
       c.reload
       assert_equal 'foobar', c.name
       assert_equal 2, c.version
@@ -197,7 +197,7 @@ class CollectionTest < ActiveSupport::TestCase
       assert_not_nil c.replication_confirmed_at
       assert_not_nil c.replication_confirmed
       # Make the versionable update
-      c.update_attributes!({'name' => 'foobarbaz'})
+      c.update!({'name' => 'foobarbaz'})
       c.reload
       assert_equal 'foobarbaz', c.name
       assert_equal 3, c.version
@@ -214,7 +214,7 @@ class CollectionTest < ActiveSupport::TestCase
       assert_equal 1, c.version
       assert_equal false, c.preserve_version
       # This update shouldn't produce a new version, as the idle time is not up
-      c.update_attributes!({
+      c.update!({
         'name' => 'bar'
       })
       c.reload
@@ -223,7 +223,7 @@ class CollectionTest < ActiveSupport::TestCase
       assert_equal false, c.preserve_version
       # This update should produce a new version, even if the idle time is not up
       # and also keep the preserve_version=true flag to persist it.
-      c.update_attributes!({
+      c.update!({
         'name' => 'baz',
         'preserve_version' => true
       })
@@ -234,7 +234,7 @@ class CollectionTest < ActiveSupport::TestCase
       # Make sure preserve_version is not disabled after being enabled, unless
       # a new version is created.
       # This is a non-versionable update
-      c.update_attributes!({
+      c.update!({
         'preserve_version' => false,
         'replication_desired' => 2
       })
@@ -243,7 +243,7 @@ class CollectionTest < ActiveSupport::TestCase
       assert_equal 2, c.replication_desired
       assert_equal true, c.preserve_version
       # This is a versionable update
-      c.update_attributes!({
+      c.update!({
         'preserve_version' => false,
         'name' => 'foobar'
       })
@@ -252,7 +252,7 @@ class CollectionTest < ActiveSupport::TestCase
       assert_equal false, c.preserve_version
       assert_equal 'foobar', c.name
       # Flipping only 'preserve_version' to true doesn't create a new version
-      c.update_attributes!({'preserve_version' => true})
+      c.update!({'preserve_version' => true})
       c.reload
       assert_equal 3, c.version
       assert_equal true, c.preserve_version
@@ -265,7 +265,7 @@ class CollectionTest < ActiveSupport::TestCase
       assert c.valid?
       assert_equal false, c.preserve_version
       modified_at = c.modified_at.to_f
-      c.update_attributes!({'preserve_version' => true})
+      c.update!({'preserve_version' => true})
       c.reload
       assert_equal true, c.preserve_version
       assert_equal modified_at, c.modified_at.to_f,
@@ -285,7 +285,7 @@ class CollectionTest < ActiveSupport::TestCase
         assert_equal 1, c.version
 
         assert_raises(ActiveRecord::RecordInvalid) do
-          c.update_attributes!({
+          c.update!({
             name => new_value
           })
         end
@@ -302,14 +302,14 @@ class CollectionTest < ActiveSupport::TestCase
       assert c.valid?
       assert_equal 1, c.version
       # Make changes so that a new version is created
-      c.update_attributes!({'name' => 'bar'})
+      c.update!({'name' => 'bar'})
       c.reload
       assert_equal 2, c.version
       assert_equal 2, Collection.where(current_version_uuid: c.uuid).count
       new_uuid = 'zzzzz-4zz18-somefakeuuidnow'
       assert_empty Collection.where(uuid: new_uuid)
       # Update UUID on current version, check that both collections point to it
-      c.update_attributes!({'uuid' => new_uuid})
+      c.update!({'uuid' => new_uuid})
       c.reload
       assert_equal new_uuid, c.uuid
       assert_equal 2, Collection.where(current_version_uuid: new_uuid).count
@@ -364,7 +364,7 @@ class CollectionTest < ActiveSupport::TestCase
         # Set up initial collection
         c = create_collection 'foo', Encoding::US_ASCII
         assert c.valid?
-        c.update_attributes!({'properties' => value_1})
+        c.update!({'properties' => value_1})
         c.reload
         assert c.changes.keys.empty?
         c.properties = value_2
@@ -386,7 +386,7 @@ class CollectionTest < ActiveSupport::TestCase
       assert c.valid?
       original_version_modified_at = c.modified_at.to_f
       # Make changes so that a new version is created
-      c.update_attributes!({'name' => 'bar'})
+      c.update!({'name' => 'bar'})
       c.reload
       assert_equal 2, c.version
       # Get the old version
@@ -400,7 +400,7 @@ class CollectionTest < ActiveSupport::TestCase
       # Make update on current version so old version get the attribute synced;
       # its modified_at should not change.
       new_replication = 3
-      c.update_attributes!({'replication_desired' => new_replication})
+      c.update!({'replication_desired' => new_replication})
       c.reload
       assert_equal new_replication, c.replication_desired
       c_old.reload
@@ -441,7 +441,7 @@ class CollectionTest < ActiveSupport::TestCase
       c = create_collection 'foo', Encoding::US_ASCII
       assert c.valid?
       # Make changes so that a new version is created
-      c.update_attributes!({'name' => 'bar'})
+      c.update!({'name' => 'bar'})
       c.reload
       assert_equal 2, c.version
       # Get the old version
@@ -479,7 +479,7 @@ class CollectionTest < ActiveSupport::TestCase
         assert_not_equal first_val, c.attributes[attr]
         # Make changes so that a new version is created and a synced field is
         # updated on both
-        c.update_attributes!({'name' => 'bar', attr => first_val})
+        c.update!({'name' => 'bar', attr => first_val})
         c.reload
         assert_equal 2, c.version
         assert_equal first_val, c.attributes[attr]
@@ -487,7 +487,7 @@ class CollectionTest < ActiveSupport::TestCase
         assert_equal first_val, Collection.where(current_version_uuid: c.uuid, version: 1).first.attributes[attr]
         # Only make an update on the same synced field & check that the previously
         # created version also gets it.
-        c.update_attributes!({attr => second_val})
+        c.update!({attr => second_val})
         c.reload
         assert_equal 2, c.version
         assert_equal second_val, c.attributes[attr]
@@ -525,7 +525,7 @@ class CollectionTest < ActiveSupport::TestCase
 
         # Update attribute and check if version number should be incremented
         old_value = c.attributes[attr]
-        c.update_attributes!({attr => val})
+        c.update!({attr => val})
         assert_equal new_version_expected, c.version == 2
         assert_equal val, c.attributes[attr]
 
@@ -559,11 +559,11 @@ class CollectionTest < ActiveSupport::TestCase
       col2 = create_collection 'bar', Encoding::US_ASCII
       assert col2.valid?
       assert_equal 1, col2.version
-      col2.update_attributes({name: 'baz'})
+      col2.update({name: 'baz'})
       assert_equal 2, col2.version
 
       # Try to make col2 a past version of col1. It shouldn't be possible
-      col2.update_attributes({current_version_uuid: col1.uuid})
+      col2.update({current_version_uuid: col1.uuid})
       assert col2.invalid?
       col2.reload
       assert_not_equal col1.uuid, col2.current_version_uuid
@@ -725,10 +725,10 @@ class CollectionTest < ActiveSupport::TestCase
   test "storage_classes_desired cannot be empty" do
     act_as_user users(:active) do
       c = collections(:collection_owned_by_active)
-      c.update_attributes storage_classes_desired: ["hot"]
+      c.update storage_classes_desired: ["hot"]
       assert_equal ["hot"], c.storage_classes_desired
       assert_raise ArvadosModel::InvalidStateTransitionError do
-        c.update_attributes storage_classes_desired: []
+        c.update storage_classes_desired: []
       end
     end
   end
@@ -736,7 +736,7 @@ class CollectionTest < ActiveSupport::TestCase
   test "storage classes lists should only contain non-empty strings" do
     c = collections(:storage_classes_desired_default_unconfirmed)
     act_as_user users(:admin) do
-      assert c.update_attributes(storage_classes_desired: ["default", "a_string"],
+      assert c.update(storage_classes_desired: ["default", "a_string"],
                                  storage_classes_confirmed: ["another_string"])
       [
         ["storage_classes_desired", ["default", 42]],
@@ -745,7 +745,7 @@ class CollectionTest < ActiveSupport::TestCase
         ["storage_classes_confirmed", [""]],
       ].each do |attr, val|
         assert_raise ArvadosModel::InvalidStateTransitionError do
-          assert c.update_attributes({attr => val})
+          assert c.update({attr => val})
         end
       end
     end
@@ -754,7 +754,7 @@ class CollectionTest < ActiveSupport::TestCase
   test "storage_classes_confirmed* can be set by admin user" do
     c = collections(:storage_classes_desired_default_unconfirmed)
     act_as_user users(:admin) do
-      assert c.update_attributes(storage_classes_confirmed: ["default"],
+      assert c.update(storage_classes_confirmed: ["default"],
                                  storage_classes_confirmed_at: Time.now)
     end
   end
@@ -764,16 +764,16 @@ class CollectionTest < ActiveSupport::TestCase
       c = collections(:storage_classes_desired_default_unconfirmed)
       # Cannot set just one at a time.
       assert_raise ArvadosModel::PermissionDeniedError do
-        c.update_attributes storage_classes_confirmed: ["default"]
+        c.update storage_classes_confirmed: ["default"]
       end
       c.reload
       assert_raise ArvadosModel::PermissionDeniedError do
-        c.update_attributes storage_classes_confirmed_at: Time.now
+        c.update storage_classes_confirmed_at: Time.now
       end
       # Cannot set bot at once, either.
       c.reload
       assert_raise ArvadosModel::PermissionDeniedError do
-        assert c.update_attributes(storage_classes_confirmed: ["default"],
+        assert c.update(storage_classes_confirmed: ["default"],
                                    storage_classes_confirmed_at: Time.now)
       end
     end
@@ -784,15 +784,15 @@ class CollectionTest < ActiveSupport::TestCase
       c = collections(:storage_classes_desired_default_confirmed_default)
       # Cannot clear just one at a time.
       assert_raise ArvadosModel::PermissionDeniedError do
-        c.update_attributes storage_classes_confirmed: []
+        c.update storage_classes_confirmed: []
       end
       c.reload
       assert_raise ArvadosModel::PermissionDeniedError do
-        c.update_attributes storage_classes_confirmed_at: nil
+        c.update storage_classes_confirmed_at: nil
       end
       # Can clear both at once.
       c.reload
-      assert c.update_attributes(storage_classes_confirmed: [],
+      assert c.update(storage_classes_confirmed: [],
                                  storage_classes_confirmed_at: nil)
     end
   end
@@ -802,7 +802,7 @@ class CollectionTest < ActiveSupport::TestCase
       Rails.configuration.Collections.DefaultReplication = 2
       act_as_user users(:active) do
         c = collections(:replication_undesired_unconfirmed)
-        c.update_attributes replication_desired: ask
+        c.update replication_desired: ask
         assert_equal ask, c.replication_desired
       end
     end
@@ -811,7 +811,7 @@ class CollectionTest < ActiveSupport::TestCase
   test "replication_confirmed* can be set by admin user" do
     c = collections(:replication_desired_2_unconfirmed)
     act_as_user users(:admin) do
-      assert c.update_attributes(replication_confirmed: 2,
+      assert c.update(replication_confirmed: 2,
                                  replication_confirmed_at: Time.now)
     end
   end
@@ -821,14 +821,14 @@ class CollectionTest < ActiveSupport::TestCase
       c = collections(:replication_desired_2_unconfirmed)
       # Cannot set just one at a time.
       assert_raise ArvadosModel::PermissionDeniedError do
-        c.update_attributes replication_confirmed: 1
+        c.update replication_confirmed: 1
       end
       assert_raise ArvadosModel::PermissionDeniedError do
-        c.update_attributes replication_confirmed_at: Time.now
+        c.update replication_confirmed_at: Time.now
       end
       # Cannot set both at once, either.
       assert_raise ArvadosModel::PermissionDeniedError do
-        c.update_attributes(replication_confirmed: 1,
+        c.update(replication_confirmed: 1,
                             replication_confirmed_at: Time.now)
       end
     end
@@ -839,15 +839,15 @@ class CollectionTest < ActiveSupport::TestCase
       c = collections(:replication_desired_2_confirmed_2)
       # Cannot clear just one at a time.
       assert_raise ArvadosModel::PermissionDeniedError do
-        c.update_attributes replication_confirmed: nil
+        c.update replication_confirmed: nil
       end
       c.reload
       assert_raise ArvadosModel::PermissionDeniedError do
-        c.update_attributes replication_confirmed_at: nil
+        c.update replication_confirmed_at: nil
       end
       # Can clear both at once.
       c.reload
-      assert c.update_attributes(replication_confirmed: nil,
+      assert c.update(replication_confirmed: nil,
                                  replication_confirmed_at: nil)
     end
   end
@@ -855,7 +855,7 @@ class CollectionTest < ActiveSupport::TestCase
   test "clear replication_confirmed* when introducing a new block in manifest" do
     c = collections(:replication_desired_2_confirmed_2)
     act_as_user users(:active) do
-      assert c.update_attributes(manifest_text: collections(:user_agreement).signed_manifest_text_only_for_tests)
+      assert c.update(manifest_text: collections(:user_agreement).signed_manifest_text_only_for_tests)
       assert_nil c.replication_confirmed
       assert_nil c.replication_confirmed_at
     end
@@ -865,7 +865,7 @@ class CollectionTest < ActiveSupport::TestCase
     c = collections(:replication_desired_2_confirmed_2)
     act_as_user users(:active) do
       new_manifest = c.signed_manifest_text_only_for_tests.sub(':bar', ':foo')
-      assert c.update_attributes(manifest_text: new_manifest)
+      assert c.update(manifest_text: new_manifest)
       assert_equal 2, c.replication_confirmed
       assert_not_nil c.replication_confirmed_at
     end
@@ -882,7 +882,7 @@ class CollectionTest < ActiveSupport::TestCase
       # not, this test would pass without testing the relevant case):
       assert_operator new_manifest.length+40, :<, c.signed_manifest_text_only_for_tests.length
 
-      assert c.update_attributes(manifest_text: new_manifest)
+      assert c.update(manifest_text: new_manifest)
       assert_equal 2, c.replication_confirmed
       assert_not_nil c.replication_confirmed_at
     end
@@ -892,7 +892,7 @@ class CollectionTest < ActiveSupport::TestCase
     act_as_user users(:active) do
       t0 = db_current_time
       c = Collection.create!(manifest_text: ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:x\n", name: 'foo')
-      c.update_attributes! trash_at: (t0 + 1.hours)
+      c.update! trash_at: (t0 + 1.hours)
       c.reload
       sig_exp = /\+A[0-9a-f]{40}\@([0-9]+)/.match(c.signed_manifest_text_only_for_tests)[1].to_i
       assert_operator sig_exp.to_i, :<=, (t0 + 1.hours).to_i
@@ -932,7 +932,7 @@ class CollectionTest < ActiveSupport::TestCase
       assert_not_empty c, 'Should be able to find live collection'
 
       # mark collection as expired
-      c.first.update_attributes!(trash_at: Time.new.strftime("%Y-%m-%d"))
+      c.first.update!(trash_at: Time.new.strftime("%Y-%m-%d"))
       c = Collection.readable_by(current_user).where(uuid: uuid)
       assert_empty c, 'Should not be able to find expired collection'
 
@@ -947,7 +947,7 @@ class CollectionTest < ActiveSupport::TestCase
     act_as_user users(:active) do
       t0 = db_current_time
       c = Collection.create!(manifest_text: '', name: 'foo')
-      c.update_attributes! trash_at: (t0 - 2.weeks)
+      c.update! trash_at: (t0 - 2.weeks)
       c.reload
       assert_operator c.trash_at, :>, t0
     end
@@ -1002,7 +1002,7 @@ class CollectionTest < ActiveSupport::TestCase
         else
           c = collections(fixture_name)
         end
-        updates_ok = c.update_attributes(updates)
+        updates_ok = c.update(updates)
         expect_valid = expect[:state] != :invalid
         assert_equal expect_valid, updates_ok, c.errors.full_messages.to_s
         case expect[:state]
@@ -1039,13 +1039,13 @@ class CollectionTest < ActiveSupport::TestCase
     start = db_current_time
     act_as_user users(:active) do
       c = Collection.create!(manifest_text: '', name: 'foo')
-      c.update_attributes!(trash_at: start + 86400.seconds)
+      c.update!(trash_at: start + 86400.seconds)
       assert_operator c.delete_at, :>=, start + (86400*22).seconds
       assert_operator c.delete_at, :<, start + (86400*22 + 30).seconds
       c.destroy
 
       c = Collection.create!(manifest_text: '', name: 'foo')
-      c.update_attributes!(is_trashed: true)
+      c.update!(is_trashed: true)
       assert_operator c.delete_at, :>=, start + (86400*21).seconds
     end
   end
index 006bb7941ff2208d6466955183b17ce7af4425fc..d25c08a579efa650c4f5ec57d2d597b48483a507 100644 (file)
@@ -34,8 +34,8 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
   def lock_and_run(ctr)
       act_as_system_user do
-        ctr.update_attributes!(state: Container::Locked)
-        ctr.update_attributes!(state: Container::Running)
+        ctr.update!(state: Container::Locked)
+        ctr.update!(state: Container::Running)
       end
   end
 
@@ -129,7 +129,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
       cr.save!
       assert_raises(ActiveRecord::RecordInvalid) do
         cr = ContainerRequest.find_by_uuid cr.uuid
-        cr.update_attributes!({state: "Committed",
+        cr.update!({state: "Committed",
                                priority: 1}.merge(value))
       end
     end
@@ -138,7 +138,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
   test "Update from fixture" do
     set_user_from_auth :active
     cr = ContainerRequest.find_by_uuid(container_requests(:running).uuid)
-    cr.update_attributes!(description: "New description")
+    cr.update!(description: "New description")
     assert_equal "New description", cr.description
   end
 
@@ -147,7 +147,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
       cr = create_minimal_req!(state: "Uncommitted", priority: 1)
       cr.save!
       cr = ContainerRequest.find_by_uuid cr.uuid
-      cr.update_attributes!(state: "Committed",
+      cr.update!(state: "Committed",
                             runtime_constraints: {"vcpus" => 1, "ram" => 23})
       assert_not_nil cr.container_uuid
   end
@@ -217,7 +217,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     assert_operator c1.priority, :<, c2.priority
     c2priority_was = c2.priority
 
-    cr1.update_attributes!(priority: 0)
+    cr1.update!(priority: 0)
 
     c1.reload
     assert_equal 0, c1.priority
@@ -233,7 +233,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
     act_as_system_user do
       Container.find_by_uuid(cr.container_uuid).
-        update_attributes!(state: Container::Cancelled, cost: 1.25)
+        update!(state: Container::Cancelled, cost: 1.25)
     end
 
     cr.reload
@@ -252,8 +252,8 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
     c = act_as_system_user do
       c = Container.find_by_uuid(cr.container_uuid)
-      c.update_attributes!(state: Container::Locked)
-      c.update_attributes!(state: Container::Running)
+      c.update!(state: Container::Locked)
+      c.update!(state: Container::Running)
       c
     end
 
@@ -263,7 +263,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     output_pdh = '1f4b0bc7583c2a7f9102c395f4ffc5e3+45'
     log_pdh = 'fa7aeb5140e2848d39b416daeef4ffc5+45'
     act_as_system_user do
-      c.update_attributes!(state: Container::Complete,
+      c.update!(state: Container::Complete,
                            cost: 1.25,
                            output: output_pdh,
                            log: log_pdh)
@@ -302,8 +302,8 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
     c = act_as_system_user do
       c = Container.find_by_uuid(cr.container_uuid)
-      c.update_attributes!(state: Container::Locked)
-      c.update_attributes!(state: Container::Running,
+      c.update!(state: Container::Locked)
+      c.update!(state: Container::Running,
                            output: output_pdh,
                            log: log_pdh)
       c
@@ -315,7 +315,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     act_as_system_user do
       Collection.where(portable_data_hash: output_pdh).delete_all
       Collection.where(portable_data_hash: log_pdh).delete_all
-      c.update_attributes!(state: Container::Complete)
+      c.update!(state: Container::Complete)
     end
 
     cr.reload
@@ -333,8 +333,8 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
     c = act_as_system_user do
       c = Container.find_by_uuid(cr.container_uuid)
-      c.update_attributes!(state: Container::Locked)
-      c.update_attributes!(state: Container::Running)
+      c.update!(state: Container::Locked)
+      c.update!(state: Container::Running)
       c
     end
 
@@ -394,14 +394,15 @@ class ContainerRequestTest < ActiveSupport::TestCase
     ]
     parents = toplevel_crs.map(&findctr)
 
-    children = parents.map do |parent|
+    children_crs = parents.map do |parent|
       lock_and_run(parent)
       with_container_auth(parent) do
         create_minimal_req!(state: "Committed",
                             priority: 1,
                             environment: {"child" => parent.environment["workflow"]})
       end
-    end.map(&findctr)
+    end
+    children = children_crs.map(&findctr)
 
     grandchildren = children.reverse.map do |child|
       lock_and_run(child)
@@ -453,7 +454,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     # increasing priority of the most recent toplevel container should
     # reprioritize all of its descendants (including the shared
     # grandchild) above everything else.
-    toplevel_crs[2].update_attributes!(priority: 72)
+    toplevel_crs[2].update!(priority: 72)
     (parents + children + grandchildren + [shared_grandchild]).map(&:reload)
     assert_operator shared_grandchild.priority, :>, grandchildren[0].priority
     assert_operator shared_grandchild.priority, :>, children[0].priority
@@ -466,6 +467,36 @@ class ContainerRequestTest < ActiveSupport::TestCase
     assert_operator shared_grandchild.priority, :<=, grandchildren[2].priority
     assert_operator shared_grandchild.priority, :<=, children[2].priority
     assert_operator shared_grandchild.priority, :<=, parents[2].priority
+
+    # cancelling the most recent toplevel container should
+    # reprioritize all of its descendants (except the shared
+    # grandchild) to zero
+    toplevel_crs[2].update!(priority: 0)
+    (parents + children + grandchildren + [shared_grandchild]).map(&:reload)
+    assert_operator 0, :==, parents[2].priority
+    assert_operator 0, :==, children[2].priority
+    assert_operator 0, :==, grandchildren[2].priority
+    assert_operator shared_grandchild.priority, :==, grandchildren[0].priority
+
+    # cancel a child request, the parent should be > 0 but
+    # the child and grandchild go to 0.
+    children_crs[1].update!(priority: 0)
+    (parents + children + grandchildren + [shared_grandchild]).map(&:reload)
+    assert_operator 0, :<, parents[1].priority
+    assert_operator parents[0].priority, :>, parents[1].priority
+    assert_operator 0, :==, children[1].priority
+    assert_operator 0, :==, grandchildren[1].priority
+    assert_operator shared_grandchild.priority, :==, grandchildren[0].priority
+
+    # update the parent, it should get a higher priority but the children and
+    # grandchildren should remain at 0
+    toplevel_crs[1].update!(priority: 6)
+    (parents + children + grandchildren + [shared_grandchild]).map(&:reload)
+    assert_operator 0, :<, parents[1].priority
+    assert_operator parents[0].priority, :<, parents[1].priority
+    assert_operator 0, :==, children[1].priority
+    assert_operator 0, :==, grandchildren[1].priority
+    assert_operator shared_grandchild.priority, :==, grandchildren[0].priority
   end
 
   [
@@ -774,7 +805,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
       #   should be assigned.
       # * When use_existing is false, a different container should be assigned.
       # * When env1 and env2 are different, a different container should be assigned.
-      cr2.update_attributes!({state: ContainerRequest::Committed})
+      cr2.update!({state: ContainerRequest::Committed})
       assert_equal (cr2.use_existing == true and (env1 == env2)),
                    (cr1.container_uuid == cr2.container_uuid)
     end
@@ -795,8 +826,8 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
     c = act_as_system_user do
       c = Container.find_by_uuid(cr.container_uuid)
-      c.update_attributes!(state: Container::Locked)
-      c.update_attributes!(state: Container::Running)
+      c.update!(state: Container::Locked)
+      c.update!(state: Container::Running)
       c
     end
 
@@ -808,8 +839,8 @@ class ContainerRequestTest < ActiveSupport::TestCase
     prev_container_uuid = cr.container_uuid
 
     act_as_system_user do
-      c.update_attributes!(cost: 0.5, subrequests_cost: 1.25)
-      c.update_attributes!(state: Container::Cancelled)
+      c.update!(cost: 0.5, subrequests_cost: 1.25)
+      c.update!(state: Container::Cancelled)
     end
 
     cr.reload
@@ -821,10 +852,10 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
     c = act_as_system_user do
       c = Container.find_by_uuid(cr.container_uuid)
-      c.update_attributes!(state: Container::Locked)
-      c.update_attributes!(state: Container::Running)
-      c.update_attributes!(cost: 0.125)
-      c.update_attributes!(state: Container::Cancelled)
+      c.update!(state: Container::Locked)
+      c.update!(state: Container::Running)
+      c.update!(cost: 0.125)
+      c.update!(state: Container::Cancelled)
       c
     end
 
@@ -847,8 +878,8 @@ class ContainerRequestTest < ActiveSupport::TestCase
     c = act_as_system_user do
       c = Container.find_by_uuid(cr.container_uuid)
       assert_equal spec.token, c.runtime_token
-      c.update_attributes!(state: Container::Locked)
-      c.update_attributes!(state: Container::Running)
+      c.update!(state: Container::Locked)
+      c.update!(state: Container::Running)
       c
     end
 
@@ -858,7 +889,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     prev_container_uuid = cr.container_uuid
 
     act_as_system_user do
-      c.update_attributes!(state: Container::Cancelled)
+      c.update!(state: Container::Cancelled)
     end
 
     cr.reload
@@ -869,7 +900,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     c = act_as_system_user do
       c = Container.find_by_uuid(cr.container_uuid)
       assert_equal spec.token, c.runtime_token
-      c.update_attributes!(state: Container::Cancelled)
+      c.update!(state: Container::Cancelled)
       c
     end
 
@@ -885,8 +916,8 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
     c = act_as_system_user do
       c = Container.find_by_uuid(cr.container_uuid)
-      c.update_attributes!(state: Container::Locked)
-      c.update_attributes!(state: Container::Running)
+      c.update!(state: Container::Locked)
+      c.update!(state: Container::Running)
       c
     end
 
@@ -901,7 +932,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
         logc = Collection.new(manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n")
         logc.save!
         c = Container.find_by_uuid(cr.container_uuid)
-        c.update_attributes!(state: Container::Cancelled, log: logc.portable_data_hash)
+        c.update!(state: Container::Cancelled, log: logc.portable_data_hash)
         c
       end
     end
@@ -919,6 +950,174 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
   end
 
+  test "Retry sub-request on error" do
+    set_user_from_auth :active
+    cr1 = create_minimal_req!(priority: 1, state: "Committed", container_count_max: 2, command: ["echo", "foo1"])
+    c1 = Container.find_by_uuid(cr1.container_uuid)
+    act_as_system_user do
+      c1.update!(state: Container::Locked)
+      c1.update!(state: Container::Running)
+    end
+
+    cr2 = with_container_auth(c1) do
+      create_minimal_req!(priority: 10, state: "Committed", container_count_max: 2, command: ["echo", "foo2"])
+    end
+    c2 = Container.find_by_uuid(cr2.container_uuid)
+    act_as_system_user do
+      c2.update!(state: Container::Locked)
+      c2.update!(state: Container::Running)
+    end
+
+    cr3 = with_container_auth(c2) do
+      create_minimal_req!(priority: 10, state: "Committed", container_count_max: 2, command: ["echo", "foo3"])
+    end
+    c3 = Container.find_by_uuid(cr3.container_uuid)
+
+    act_as_system_user do
+      c3.update!(state: Container::Locked)
+      c3.update!(state: Container::Running)
+    end
+
+    # All the containers are in running state
+
+    c3.reload
+    cr3.reload
+
+    # c3 still running
+    assert_equal 'Running', c3.state
+    assert_equal 1, cr3.container_count
+    assert_equal 'Committed', cr3.state
+
+    # c3 goes to cancelled state
+    act_as_system_user do
+      c3.state = "Cancelled"
+      c3.save!
+    end
+
+    cr3.reload
+
+    # Because the parent request is still live, it should
+    # be retried.
+    assert_equal 2, cr3.container_count
+    assert_equal 'Committed', cr3.state
+  end
+
+  test "Do not retry sub-request when process tree is cancelled" do
+    set_user_from_auth :active
+    cr1 = create_minimal_req!(priority: 1, state: "Committed", container_count_max: 2, command: ["echo", "foo1"])
+    c1 = Container.find_by_uuid(cr1.container_uuid)
+    act_as_system_user do
+      c1.update!(state: Container::Locked)
+      c1.update!(state: Container::Running)
+    end
+
+    cr2 = with_container_auth(c1) do
+      create_minimal_req!(priority: 10, state: "Committed", container_count_max: 2, command: ["echo", "foo2"])
+    end
+    c2 = Container.find_by_uuid(cr2.container_uuid)
+    act_as_system_user do
+      c2.update!(state: Container::Locked)
+      c2.update!(state: Container::Running)
+    end
+
+    cr3 = with_container_auth(c2) do
+      create_minimal_req!(priority: 10, state: "Committed", container_count_max: 2, command: ["echo", "foo3"])
+    end
+    c3 = Container.find_by_uuid(cr3.container_uuid)
+
+    act_as_system_user do
+      c3.update!(state: Container::Locked)
+      c3.update!(state: Container::Running)
+    end
+
+    # All the containers are in running state
+
+    # Now cancel the toplevel container request
+    act_as_system_user do
+      cr1.priority = 0
+      cr1.save!
+    end
+
+    c3.reload
+    cr3.reload
+
+    # c3 still running
+    assert_equal 'Running', c3.state
+    assert_equal 1, cr3.container_count
+    assert_equal 'Committed', cr3.state
+
+    # c3 goes to cancelled state
+    act_as_system_user do
+      assert_equal 0, c3.priority
+      c3.state = "Cancelled"
+      c3.save!
+    end
+
+    cr3.reload
+
+    # Because the parent process was cancelled, it _should not_ be
+    # retried.
+    assert_equal 1, cr3.container_count
+    assert_equal 'Final', cr3.state
+  end
+
+  test "Retry process tree on error" do
+    set_user_from_auth :active
+    cr1 = create_minimal_req!(priority: 1, state: "Committed", container_count_max: 2, command: ["echo", "foo1"])
+    c1 = Container.find_by_uuid(cr1.container_uuid)
+    act_as_system_user do
+      c1.update!(state: Container::Locked)
+      c1.update!(state: Container::Running)
+    end
+
+    cr2 = with_container_auth(c1) do
+      create_minimal_req!(priority: 10, state: "Committed", container_count_max: 2, command: ["echo", "foo2"])
+    end
+    c2 = Container.find_by_uuid(cr2.container_uuid)
+    act_as_system_user do
+      c2.update!(state: Container::Locked)
+      c2.update!(state: Container::Running)
+    end
+
+    cr3 = with_container_auth(c2) do
+      create_minimal_req!(priority: 10, state: "Committed", container_count_max: 2, command: ["echo", "foo3"])
+    end
+    c3 = Container.find_by_uuid(cr3.container_uuid)
+
+    act_as_system_user do
+      c3.update!(state: Container::Locked)
+      c3.update!(state: Container::Running)
+    end
+
+    # All the containers are in running state
+
+    c1.reload
+
+    # c1 goes to cancelled state
+    act_as_system_user do
+      c1.state = "Cancelled"
+      c1.save!
+    end
+
+    cr1.reload
+    cr2.reload
+    cr3.reload
+
+    # Because the root request is still live, it should be retried.
+    # Assumes the root is something like arvados-cwl-runner where
+    # container reuse enables it to more or less pick up where it left
+    # off.
+    assert_equal 2, cr1.container_count
+    assert_equal 'Committed', cr1.state
+
+    # These keep running.
+    assert_equal 1, cr2.container_count
+    assert_equal 'Committed', cr2.state
+
+    assert_equal 1, cr3.container_count
+    assert_equal 'Committed', cr3.state
+  end
+
   test "Output collection name setting using output_name with name collision resolution" do
     set_user_from_auth :active
     output_name = 'unimaginative name'
@@ -932,13 +1131,13 @@ class ContainerRequestTest < ActiveSupport::TestCase
     assert_equal ContainerRequest::Final, cr.state
     output_coll = Collection.find_by_uuid(cr.output_uuid)
     # Make sure the resulting output collection name include the original name
-    # plus the date
+    # plus the last 15 characters of uuid
     assert_not_equal output_name, output_coll.name,
                      "more than one collection with the same owner and name"
     assert output_coll.name.include?(output_name),
            "New name should include original name"
-    assert_match /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/, output_coll.name,
-                 "New name should include ISO8601 date"
+    assert_match /#{output_coll.uuid[-15..-1]}/, output_coll.name,
+                 "New name should include last 15 characters of uuid"
   end
 
   [[0, :check_output_ttl_0],
@@ -986,9 +1185,9 @@ class ContainerRequestTest < ActiveSupport::TestCase
       logc.save!
 
       c = Container.find_by_uuid(cr.container_uuid)
-      c.update_attributes!(state: Container::Locked)
-      c.update_attributes!(state: Container::Running)
-      c.update_attributes!(state: final_state,
+      c.update!(state: Container::Locked)
+      c.update!(state: Container::Running)
+      c.update!(state: final_state,
                            exit_code: exit_code,
                            output: '1f4b0bc7583c2a7f9102c395f4ffc5e3+45',
                            log: logc.portable_data_hash)
@@ -1012,7 +1211,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
 
     cr3 = create_minimal_req!(priority: 1, state: ContainerRequest::Uncommitted)
     assert_equal ContainerRequest::Uncommitted, cr3.state
-    cr3.update_attributes!(state: ContainerRequest::Committed)
+    cr3.update!(state: ContainerRequest::Committed)
     assert_equal cr.container_uuid, cr3.container_uuid
     assert_equal ContainerRequest::Final, cr3.state
   end
@@ -1108,7 +1307,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
       # Even though preemptible is not allowed, we should be able to
       # commit a CR that was created earlier when preemptible was the
       # default.
-      commit_later.update_attributes!(priority: 1, state: "Committed")
+      commit_later.update!(priority: 1, state: "Committed")
       expect[false].push commit_later
     end
 
@@ -1124,7 +1323,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
       # Cancelling the parent used to fail while updating the child
       # containers' priority, because the child containers' unchanged
       # preemptible fields caused validation to fail.
-      parent.update_attributes!(state: 'Cancelled')
+      parent.update!(state: 'Cancelled')
 
       [false, true].each do |pflag|
         expect[pflag].each do |cr|
@@ -1251,7 +1450,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
         when 'Final'
           act_as_system_user do
             Container.find_by_uuid(cr.container_uuid).
-              update_attributes!(state: Container::Cancelled)
+              update!(state: Container::Cancelled)
           end
           cr.reload
         else
@@ -1259,10 +1458,10 @@ class ContainerRequestTest < ActiveSupport::TestCase
         end
         assert_equal state, cr.state
         if permitted
-          assert cr.update_attributes!(updates)
+          assert cr.update!(updates)
         else
           assert_raises(ActiveRecord::RecordInvalid) do
-            cr.update_attributes!(updates)
+            cr.update!(updates)
           end
         end
       end
@@ -1280,8 +1479,41 @@ class ContainerRequestTest < ActiveSupport::TestCase
       cr.destroy
 
       # the cr's container now has priority of 0
+      c.reload
+      assert_equal 0, c.priority
+    end
+  end
+
+  test "trash the project containing a container_request and check its container's priority" do
+    act_as_user users(:active) do
+      cr = ContainerRequest.find_by_uuid container_requests(:running_to_be_deleted).uuid
+
+      # initially the cr's container has priority > 0
       c = Container.find_by_uuid(cr.container_uuid)
+      assert_equal 1, c.priority
+
+      prj = Group.find_by_uuid cr.owner_uuid
+      prj.update!(trash_at: db_current_time)
+
+      # the cr's container now has priority of 0
+      c.reload
       assert_equal 0, c.priority
+
+      assert_equal c.state, 'Running'
+      assert_equal cr.state, 'Committed'
+
+      # mark the container as cancelled, this should cause the
+      # container request to go to final state and run the finalize
+      # function
+      act_as_system_user do
+        c.update!(state: 'Cancelled', log: 'fa7aeb5140e2848d39b416daeef4ffc5+45')
+      end
+      c.reload
+      cr.reload
+
+      assert_equal c.state, 'Cancelled'
+      assert_equal cr.state, 'Final'
+      assert_equal nil, cr.log_uuid
     end
   end
 
@@ -1383,7 +1615,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     sm = {'/secret/foo' => {'kind' => 'text', 'content' => secret_string}}
     set_user_from_auth :active
     cr = create_minimal_req!
-    assert_equal false, cr.update_attributes(state: "Committed",
+    assert_equal false, cr.update(state: "Committed",
                                              priority: 1,
                                              mounts: cr.mounts.merge(sm),
                                              secret_mounts: sm)
@@ -1403,7 +1635,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     assert_not_nil ApiClientAuthorization.find_by_uuid(spec.uuid)
 
     act_as_system_user do
-      c.update_attributes!(state: Container::Complete,
+      c.update!(state: Container::Complete,
                            exit_code: 0,
                            output: '1f4b0bc7583c2a7f9102c395f4ffc5e3+45',
                            log: 'fa7aeb5140e2848d39b416daeef4ffc5+45')
@@ -1482,7 +1714,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     assert_nil cr2.container_uuid
 
     # Update cr2 to commited state, check for reuse, then run it
-    cr2.update_attributes!({state: ContainerRequest::Committed})
+    cr2.update!({state: ContainerRequest::Committed})
     assert_equal cr1.container_uuid, cr2.container_uuid
 
     cr2.reload
@@ -1516,12 +1748,12 @@ class ContainerRequestTest < ActiveSupport::TestCase
           logc.save!
 
           c = Container.find_by_uuid(cr.container_uuid)
-          c.update_attributes!(state: Container::Locked)
-          c.update_attributes!(state: Container::Running)
+          c.update!(state: Container::Locked)
+          c.update!(state: Container::Running)
 
-          c.update_attributes!(output_properties: container_prop)
+          c.update!(output_properties: container_prop)
 
-          c.update_attributes!(state: Container::Complete,
+          c.update!(state: Container::Complete,
                                exit_code: 0,
                                output: '1f4b0bc7583c2a7f9102c395f4ffc5e3+45',
                                log: logc.portable_data_hash)
@@ -1541,9 +1773,9 @@ class ContainerRequestTest < ActiveSupport::TestCase
     cr = create_minimal_req!(priority: 5, state: "Committed", container_count_max: 3)
     c = Container.find_by_uuid cr.container_uuid
     act_as_system_user do
-      c.update_attributes!(state: Container::Locked)
-      c.update_attributes!(state: Container::Running)
-      c.update_attributes!(state: Container::Cancelled, cost: 3)
+      c.update!(state: Container::Locked)
+      c.update!(state: Container::Running)
+      c.update!(state: Container::Cancelled, cost: 3)
     end
     cr.reload
     assert_equal 3, cr.cumulative_cost
@@ -1560,12 +1792,12 @@ class ContainerRequestTest < ActiveSupport::TestCase
     assert_equal c.uuid, cr2.requesting_container_uuid
     c2 = Container.find_by_uuid cr2.container_uuid
     act_as_system_user do
-      c2.update_attributes!(state: Container::Locked)
-      c2.update_attributes!(state: Container::Running)
+      c2.update!(state: Container::Locked)
+      c2.update!(state: Container::Running)
       logc = Collection.new(owner_uuid: system_user_uuid,
                             manifest_text: ". ef772b2f28e2c8ca84de45466ed19ee9+7815 0:0:arv-mount.txt\n")
       logc.save!
-      c2.update_attributes!(state: Container::Complete,
+      c2.update!(state: Container::Complete,
                             exit_code: 0,
                             output: '1f4b0bc7583c2a7f9102c395f4ffc5e3+45',
                             log: logc.portable_data_hash,
@@ -1586,7 +1818,7 @@ class ContainerRequestTest < ActiveSupport::TestCase
     assert_equal 7, c.subrequests_cost
 
     act_as_system_user do
-      c.update_attributes!(state: Container::Complete, exit_code: 0, cost: 9)
+      c.update!(state: Container::Complete, exit_code: 0, cost: 9)
     end
 
     c.reload
index 286aa32ae209a98f3930076cb76d5b9ec0988700..09b885b391efc4faa83b5f50cb01ac838ada0d8a 100644 (file)
@@ -37,7 +37,8 @@ class ContainerTest < ActiveSupport::TestCase
     },
     secret_mounts: {},
     runtime_user_uuid: "zzzzz-tpzed-xurymjxw79nv3jz",
-    runtime_auth_scopes: ["all"]
+    runtime_auth_scopes: ["all"],
+    scheduling_parameters: {},
   }
 
   REUSABLE_ATTRS_SLIM = {
@@ -57,6 +58,7 @@ class ContainerTest < ActiveSupport::TestCase
     },
     runtime_user_uuid: "zzzzz-tpzed-xurymjxw79nv3jz",
     secret_mounts: {},
+    scheduling_parameters: {},
   }
 
   def request_only attrs
@@ -74,7 +76,7 @@ class ContainerTest < ActiveSupport::TestCase
 
   def check_illegal_updates c, bad_updates
     bad_updates.each do |u|
-      refute c.update_attributes(u), u.inspect
+      refute c.update(u), u.inspect
       refute c.valid?, u.inspect
       c.reload
     end
@@ -171,15 +173,15 @@ class ContainerTest < ActiveSupport::TestCase
     assert_equal Container::Queued, c.state
 
     set_user_from_auth :dispatch1
-    c.update_attributes! state: Container::Locked
-    c.update_attributes! state: Container::Running
+    c.update! state: Container::Locked
+    c.update! state: Container::Running
 
     [
       'error', 'errorDetail', 'warning', 'warningDetail', 'activity'
     ].each do |k|
       # String type is allowed
       string_val = 'A string is accepted'
-      c.update_attributes! runtime_status: {k => string_val}
+      c.update! runtime_status: {k => string_val}
       assert_equal string_val, c.runtime_status[k]
 
       # Other types aren't allowed
@@ -187,7 +189,7 @@ class ContainerTest < ActiveSupport::TestCase
         42, false, [], {}, nil
       ].each do |unallowed_val|
         assert_raises ActiveRecord::RecordInvalid do
-          c.update_attributes! runtime_status: {k => unallowed_val}
+          c.update! runtime_status: {k => unallowed_val}
         end
       end
     end
@@ -207,41 +209,41 @@ class ContainerTest < ActiveSupport::TestCase
 
     assert_equal Container::Queued, c1.state
     assert_raises ArvadosModel::PermissionDeniedError do
-      c1.update_attributes! runtime_status: {'error' => 'Oops!'}
+      c1.update! runtime_status: {'error' => 'Oops!'}
     end
 
     set_user_from_auth :dispatch1
 
     # Allow updates when state = Locked
-    c1.update_attributes! state: Container::Locked
-    c1.update_attributes! runtime_status: {'error' => 'Oops!'}
+    c1.update! state: Container::Locked
+    c1.update! runtime_status: {'error' => 'Oops!'}
     assert c1.runtime_status.key? 'error'
 
     # Reset when transitioning from Locked to Queued
-    c1.update_attributes! state: Container::Queued
+    c1.update! state: Container::Queued
     assert_equal c1.runtime_status, {}
 
     # Allow updates when state = Running
-    c1.update_attributes! state: Container::Locked
-    c1.update_attributes! state: Container::Running
-    c1.update_attributes! runtime_status: {'error' => 'Oops!'}
+    c1.update! state: Container::Locked
+    c1.update! state: Container::Running
+    c1.update! runtime_status: {'error' => 'Oops!'}
     assert c1.runtime_status.key? 'error'
 
     # Don't allow updates on other states
-    c1.update_attributes! state: Container::Complete
+    c1.update! state: Container::Complete
     assert_raises ActiveRecord::RecordInvalid do
-      c1.update_attributes! runtime_status: {'error' => 'Some other error'}
+      c1.update! runtime_status: {'error' => 'Some other error'}
     end
 
     set_user_from_auth :active
     c2, _ = minimal_new(attrs)
     assert_equal c2.runtime_status, {}
     set_user_from_auth :dispatch1
-    c2.update_attributes! state: Container::Locked
-    c2.update_attributes! state: Container::Running
-    c2.update_attributes! state: Container::Cancelled
+    c2.update! state: Container::Locked
+    c2.update! state: Container::Running
+    c2.update! state: Container::Cancelled
     assert_raises ActiveRecord::RecordInvalid do
-      c2.update_attributes! runtime_status: {'error' => 'Oops!'}
+      c2.update! runtime_status: {'error' => 'Oops!'}
     end
   end
 
@@ -292,13 +294,13 @@ class ContainerTest < ActiveSupport::TestCase
     assert_not_equal c_older.uuid, c_recent.uuid
 
     set_user_from_auth :dispatch1
-    c_older.update_attributes!({state: Container::Locked})
-    c_older.update_attributes!({state: Container::Running})
-    c_older.update_attributes!(completed_attrs)
+    c_older.update!({state: Container::Locked})
+    c_older.update!({state: Container::Running})
+    c_older.update!(completed_attrs)
 
-    c_recent.update_attributes!({state: Container::Locked})
-    c_recent.update_attributes!({state: Container::Running})
-    c_recent.update_attributes!(completed_attrs)
+    c_recent.update!({state: Container::Locked})
+    c_recent.update!({state: Container::Running})
+    c_recent.update!(completed_attrs)
 
     reused = Container.find_reusable(common_attrs)
     assert_not_nil reused
@@ -332,14 +334,14 @@ class ContainerTest < ActiveSupport::TestCase
 
     out1 = '1f4b0bc7583c2a7f9102c395f4ffc5e3+45'
     log1 = collections(:real_log_collection).portable_data_hash
-    c_output1.update_attributes!({state: Container::Locked})
-    c_output1.update_attributes!({state: Container::Running})
-    c_output1.update_attributes!(completed_attrs.merge({log: log1, output: out1}))
+    c_output1.update!({state: Container::Locked})
+    c_output1.update!({state: Container::Running})
+    c_output1.update!(completed_attrs.merge({log: log1, output: out1}))
 
     out2 = 'fa7aeb5140e2848d39b416daeef4ffc5+45'
-    c_output2.update_attributes!({state: Container::Locked})
-    c_output2.update_attributes!({state: Container::Running})
-    c_output2.update_attributes!(completed_attrs.merge({log: log1, output: out2}))
+    c_output2.update!({state: Container::Locked})
+    c_output2.update!({state: Container::Running})
+    c_output2.update!(completed_attrs.merge({log: log1, output: out2}))
 
     set_user_from_auth :active
     reused = Container.resolve(ContainerRequest.new(request_only(common_attrs)))
@@ -355,14 +357,14 @@ class ContainerTest < ActiveSupport::TestCase
     # Confirm the 3 container UUIDs are different.
     assert_equal 3, [c_slower.uuid, c_faster_started_first.uuid, c_faster_started_second.uuid].uniq.length
     set_user_from_auth :dispatch1
-    c_slower.update_attributes!({state: Container::Locked})
-    c_slower.update_attributes!({state: Container::Running,
+    c_slower.update!({state: Container::Locked})
+    c_slower.update!({state: Container::Running,
                                  progress: 0.1})
-    c_faster_started_first.update_attributes!({state: Container::Locked})
-    c_faster_started_first.update_attributes!({state: Container::Running,
+    c_faster_started_first.update!({state: Container::Locked})
+    c_faster_started_first.update!({state: Container::Running,
                                                progress: 0.15})
-    c_faster_started_second.update_attributes!({state: Container::Locked})
-    c_faster_started_second.update_attributes!({state: Container::Running,
+    c_faster_started_second.update!({state: Container::Locked})
+    c_faster_started_second.update!({state: Container::Running,
                                                 progress: 0.15})
     reused = Container.find_reusable(common_attrs)
     assert_not_nil reused
@@ -379,14 +381,14 @@ class ContainerTest < ActiveSupport::TestCase
     # Confirm the 3 container UUIDs are different.
     assert_equal 3, [c_slower.uuid, c_faster_started_first.uuid, c_faster_started_second.uuid].uniq.length
     set_user_from_auth :dispatch1
-    c_slower.update_attributes!({state: Container::Locked})
-    c_slower.update_attributes!({state: Container::Running,
+    c_slower.update!({state: Container::Locked})
+    c_slower.update!({state: Container::Running,
                                  progress: 0.1})
-    c_faster_started_first.update_attributes!({state: Container::Locked})
-    c_faster_started_first.update_attributes!({state: Container::Running,
+    c_faster_started_first.update!({state: Container::Locked})
+    c_faster_started_first.update!({state: Container::Running,
                                                progress: 0.15})
-    c_faster_started_second.update_attributes!({state: Container::Locked})
-    c_faster_started_second.update_attributes!({state: Container::Running,
+    c_faster_started_second.update!({state: Container::Locked})
+    c_faster_started_second.update!({state: Container::Running,
                                                 progress: 0.2})
     reused = Container.find_reusable(common_attrs)
     assert_not_nil reused
@@ -403,16 +405,16 @@ class ContainerTest < ActiveSupport::TestCase
     # Confirm the 3 container UUIDs are different.
     assert_equal 3, [c_slower.uuid, c_faster_started_first.uuid, c_faster_started_second.uuid].uniq.length
     set_user_from_auth :dispatch1
-    c_slower.update_attributes!({state: Container::Locked})
-    c_slower.update_attributes!({state: Container::Running,
+    c_slower.update!({state: Container::Locked})
+    c_slower.update!({state: Container::Running,
                                  progress: 0.1})
-    c_faster_started_first.update_attributes!({state: Container::Locked})
-    c_faster_started_first.update_attributes!({state: Container::Running,
+    c_faster_started_first.update!({state: Container::Locked})
+    c_faster_started_first.update!({state: Container::Running,
                                                runtime_status: {'warning' => 'This is not an error'},
                                                progress: 0.15})
-    c_faster_started_second.update_attributes!({state: Container::Locked})
+    c_faster_started_second.update!({state: Container::Locked})
     assert_equal 0, Container.where("runtime_status->'error' is not null").count
-    c_faster_started_second.update_attributes!({state: Container::Running,
+    c_faster_started_second.update!({state: Container::Running,
                                                 runtime_status: {'error' => 'Something bad happened'},
                                                 progress: 0.2})
     assert_equal 1, Container.where("runtime_status->'error' is not null").count
@@ -431,11 +433,11 @@ class ContainerTest < ActiveSupport::TestCase
     # Confirm the 3 container UUIDs are different.
     assert_equal 3, [c_low_priority.uuid, c_high_priority_older.uuid, c_high_priority_newer.uuid].uniq.length
     set_user_from_auth :dispatch1
-    c_low_priority.update_attributes!({state: Container::Locked,
+    c_low_priority.update!({state: Container::Locked,
                                        priority: 1})
-    c_high_priority_older.update_attributes!({state: Container::Locked,
+    c_high_priority_older.update!({state: Container::Locked,
                                               priority: 2})
-    c_high_priority_newer.update_attributes!({state: Container::Locked,
+    c_high_priority_newer.update!({state: Container::Locked,
                                               priority: 2})
     reused = Container.find_reusable(common_attrs)
     assert_not_nil reused
@@ -449,14 +451,14 @@ class ContainerTest < ActiveSupport::TestCase
     c_running, _ = minimal_new(common_attrs.merge({use_existing: false}))
     assert_not_equal c_failed.uuid, c_running.uuid
     set_user_from_auth :dispatch1
-    c_failed.update_attributes!({state: Container::Locked})
-    c_failed.update_attributes!({state: Container::Running})
-    c_failed.update_attributes!({state: Container::Complete,
+    c_failed.update!({state: Container::Locked})
+    c_failed.update!({state: Container::Running})
+    c_failed.update!({state: Container::Complete,
                                  exit_code: 42,
                                  log: 'ea10d51bcf88862dbcc36eb292017dfd+45',
                                  output: 'ea10d51bcf88862dbcc36eb292017dfd+45'})
-    c_running.update_attributes!({state: Container::Locked})
-    c_running.update_attributes!({state: Container::Running,
+    c_running.update!({state: Container::Locked})
+    c_running.update!({state: Container::Running,
                                   progress: 0.15})
     reused = Container.find_reusable(common_attrs)
     assert_not_nil reused
@@ -470,14 +472,14 @@ class ContainerTest < ActiveSupport::TestCase
     c_running, _ = minimal_new(common_attrs.merge({use_existing: false}))
     assert_not_equal c_completed.uuid, c_running.uuid
     set_user_from_auth :dispatch1
-    c_completed.update_attributes!({state: Container::Locked})
-    c_completed.update_attributes!({state: Container::Running})
-    c_completed.update_attributes!({state: Container::Complete,
+    c_completed.update!({state: Container::Locked})
+    c_completed.update!({state: Container::Running})
+    c_completed.update!({state: Container::Complete,
                                     exit_code: 0,
                                     log: 'ea10d51bcf88862dbcc36eb292017dfd+45',
                                     output: '1f4b0bc7583c2a7f9102c395f4ffc5e3+45'})
-    c_running.update_attributes!({state: Container::Locked})
-    c_running.update_attributes!({state: Container::Running,
+    c_running.update!({state: Container::Locked})
+    c_running.update!({state: Container::Running,
                                   progress: 0.15})
     reused = Container.find_reusable(common_attrs)
     assert_not_nil reused
@@ -491,9 +493,9 @@ class ContainerTest < ActiveSupport::TestCase
     c_running, _ = minimal_new(common_attrs.merge({use_existing: false}))
     assert_not_equal c_running.uuid, c_locked.uuid
     set_user_from_auth :dispatch1
-    c_locked.update_attributes!({state: Container::Locked})
-    c_running.update_attributes!({state: Container::Locked})
-    c_running.update_attributes!({state: Container::Running,
+    c_locked.update!({state: Container::Locked})
+    c_running.update!({state: Container::Locked})
+    c_running.update!({state: Container::Running,
                                   progress: 0.15})
     reused = Container.find_reusable(common_attrs)
     assert_not_nil reused
@@ -507,7 +509,7 @@ class ContainerTest < ActiveSupport::TestCase
     c_queued, _ = minimal_new(common_attrs.merge({use_existing: false}))
     assert_not_equal c_queued.uuid, c_locked.uuid
     set_user_from_auth :dispatch1
-    c_locked.update_attributes!({state: Container::Locked})
+    c_locked.update!({state: Container::Locked})
     reused = Container.find_reusable(common_attrs)
     assert_not_nil reused
     assert_equal reused.uuid, c_locked.uuid
@@ -518,14 +520,42 @@ class ContainerTest < ActiveSupport::TestCase
     attrs = REUSABLE_COMMON_ATTRS.merge({environment: {"var" => "failed"}})
     c, _ = minimal_new(attrs)
     set_user_from_auth :dispatch1
-    c.update_attributes!({state: Container::Locked})
-    c.update_attributes!({state: Container::Running})
-    c.update_attributes!({state: Container::Complete,
+    c.update!({state: Container::Locked})
+    c.update!({state: Container::Running})
+    c.update!({state: Container::Complete,
                           exit_code: 33})
     reused = Container.find_reusable(attrs)
     assert_nil reused
   end
 
+  [[false, false, true],
+   [false, true, true],
+   [true, false, false],
+   [true, true, true]
+  ].each do |c1_preemptible, c2_preemptible, should_reuse|
+    [[Container::Queued, 1],
+     [Container::Locked, 1],
+     [Container::Running, 0],   # not cancelled yet, but obviously will be soon
+    ].each do |c1_state, c1_priority|
+      test "find_reusable for #{c2_preemptible ? '' : 'non-'}preemptible req should #{should_reuse ? '' : 'not'} reuse a #{c1_state} #{c1_preemptible ? '' : 'non-'}preemptible container with priority #{c1_priority}" do
+        configure_preemptible_instance_type
+        set_user_from_auth :active
+        c1_attrs = REUSABLE_COMMON_ATTRS.merge({environment: {"test" => name, "state" => c1_state}, scheduling_parameters: {"preemptible" => c1_preemptible}})
+        c1, _ = minimal_new(c1_attrs)
+        set_user_from_auth :dispatch1
+        c1.update!({state: Container::Locked}) if c1_state != Container::Queued
+        c1.update!({state: Container::Running, priority: c1_priority}) if c1_state == Container::Running
+        c2_attrs = c1_attrs.merge({scheduling_parameters: {"preemptible" => c2_preemptible}})
+        reused = Container.find_reusable(c2_attrs)
+        if should_reuse && c1_priority > 0
+          assert_not_nil reused
+        else
+          assert_nil reused
+        end
+      end
+    end
+  end
+
   test "find_reusable with logging disabled" do
     set_user_from_auth :active
     Rails.logger.expects(:info).never
@@ -646,7 +676,7 @@ class ContainerTest < ActiveSupport::TestCase
                               {state: Container::Complete}]
 
     c.lock
-    c.update_attributes! state: Container::Running
+    c.update! state: Container::Running
 
     check_illegal_modify c
     check_bogus_states c
@@ -654,7 +684,7 @@ class ContainerTest < ActiveSupport::TestCase
     check_illegal_updates c, [{state: Container::Queued}]
     c.reload
 
-    c.update_attributes! priority: 3
+    c.update! priority: 3
   end
 
   test "Lock and unlock" do
@@ -669,11 +699,11 @@ class ContainerTest < ActiveSupport::TestCase
       c.lock
     end
     c.reload
-    assert cr.update_attributes priority: 1
+    assert cr.update priority: 1
 
-    refute c.update_attributes(state: Container::Running), "not locked"
+    refute c.update(state: Container::Running), "not locked"
     c.reload
-    refute c.update_attributes(state: Container::Complete), "not locked"
+    refute c.update(state: Container::Complete), "not locked"
     c.reload
 
     assert c.lock, show_errors(c)
@@ -687,13 +717,13 @@ class ContainerTest < ActiveSupport::TestCase
     refute c.locked_by_uuid
     refute c.auth_uuid
 
-    refute c.update_attributes(state: Container::Running), "not locked"
+    refute c.update(state: Container::Running), "not locked"
     c.reload
     refute c.locked_by_uuid
     refute c.auth_uuid
 
     assert c.lock, show_errors(c)
-    assert c.update_attributes(state: Container::Running), show_errors(c)
+    assert c.update(state: Container::Running), show_errors(c)
     assert c.locked_by_uuid
     assert c.auth_uuid
 
@@ -710,7 +740,7 @@ class ContainerTest < ActiveSupport::TestCase
     end
     c.reload
 
-    assert c.update_attributes(state: Container::Complete), show_errors(c)
+    assert c.update(state: Container::Complete), show_errors(c)
     refute c.locked_by_uuid
     refute c.auth_uuid
 
@@ -770,7 +800,7 @@ class ContainerTest < ActiveSupport::TestCase
     set_user_from_auth :active
     c, cr = minimal_new({container_count_max: 1})
     set_user_from_auth :dispatch1
-    assert c.update_attributes(state: Container::Cancelled), show_errors(c)
+    assert c.update(state: Container::Cancelled), show_errors(c)
     check_no_change_from_cancelled c
     cr.reload
     assert_equal ContainerRequest::Final, cr.state
@@ -793,7 +823,7 @@ class ContainerTest < ActiveSupport::TestCase
     c, _ = minimal_new
     set_user_from_auth :dispatch1
     assert c.lock, show_errors(c)
-    assert c.update_attributes(state: Container::Cancelled), show_errors(c)
+    assert c.update(state: Container::Cancelled), show_errors(c)
     check_no_change_from_cancelled c
   end
 
@@ -813,7 +843,7 @@ class ContainerTest < ActiveSupport::TestCase
     c, _ = minimal_new
     set_user_from_auth :dispatch1
     assert c.lock, show_errors(c)
-    assert c.update_attributes(
+    assert c.update(
              state: Container::Cancelled,
              log: collections(:real_log_collection).portable_data_hash,
            ), show_errors(c)
@@ -825,8 +855,8 @@ class ContainerTest < ActiveSupport::TestCase
     c, _ = minimal_new
     set_user_from_auth :dispatch1
     c.lock
-    c.update_attributes! state: Container::Running
-    c.update_attributes! state: Container::Cancelled
+    c.update! state: Container::Running
+    c.update! state: Container::Cancelled
     check_no_change_from_cancelled c
   end
 
@@ -876,16 +906,16 @@ class ContainerTest < ActiveSupport::TestCase
         set_user_from_auth :dispatch1
         c.lock
         if start_state != Container::Locked
-          c.update_attributes! state: Container::Running
+          c.update! state: Container::Running
           if start_state != Container::Running
-            c.update_attributes! state: start_state
+            c.update! state: start_state
           end
         end
       end
       assert_equal c.state, start_state
       set_user_from_auth :active
       assert_raises(ArvadosModel::PermissionDeniedError) do
-        c.update_attributes! updates
+        c.update! updates
       end
     end
   end
@@ -896,9 +926,9 @@ class ContainerTest < ActiveSupport::TestCase
     set_user_from_auth :dispatch1
     c.lock
     check_illegal_updates c, [{exit_code: 1}]
-    c.update_attributes! state: Container::Running
-    assert c.update_attributes(exit_code: 1)
-    assert c.update_attributes(exit_code: 1, state: Container::Complete)
+    c.update! state: Container::Running
+    assert c.update(exit_code: 1)
+    assert c.update(exit_code: 1, state: Container::Complete)
   end
 
   test "locked_by_uuid can update log when locked/running, and output when running" do
@@ -917,8 +947,8 @@ class ContainerTest < ActiveSupport::TestCase
     set_user_from_auth :dispatch1
     c.lock
     assert_equal c.locked_by_uuid, Thread.current[:api_client_authorization].uuid
-    c.update_attributes!(log: logpdh_time1)
-    c.update_attributes!(state: Container::Running)
+    c.update!(log: logpdh_time1)
+    c.update!(state: Container::Running)
     cr1.reload
     cr2.reload
     cr1log_uuid = cr1.log_uuid
@@ -929,17 +959,17 @@ class ContainerTest < ActiveSupport::TestCase
     assert_not_equal logcoll.uuid, cr2log_uuid
     assert_not_equal cr1log_uuid, cr2log_uuid
 
-    logcoll.update_attributes!(manifest_text: logcoll.manifest_text + ". acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:foo.txt\n")
+    logcoll.update!(manifest_text: logcoll.manifest_text + ". acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:foo.txt\n")
     logpdh_time2 = logcoll.portable_data_hash
 
-    assert c.update_attributes(output: collections(:collection_owned_by_active).portable_data_hash)
-    assert c.update_attributes(log: logpdh_time2)
-    assert c.update_attributes(state: Container::Complete, log: logcoll.portable_data_hash)
+    assert c.update(output: collections(:collection_owned_by_active).portable_data_hash)
+    assert c.update(log: logpdh_time2)
+    assert c.update(state: Container::Complete, log: logcoll.portable_data_hash)
     c.reload
     assert_equal collections(:collection_owned_by_active).portable_data_hash, c.output
     assert_equal logpdh_time2, c.log
-    refute c.update_attributes(output: nil)
-    refute c.update_attributes(log: nil)
+    refute c.update(output: nil)
+    refute c.update(log: nil)
     cr1.reload
     cr2.reload
     assert_equal cr1log_uuid, cr1.log_uuid
@@ -962,7 +992,7 @@ class ContainerTest < ActiveSupport::TestCase
       end
       set_user_from_auth :dispatch1
       c.lock
-      c.update_attributes! state: Container::Running
+      c.update! state: Container::Running
 
       if tok == "runtime_token"
         auth = ApiClientAuthorization.validate(token: c.runtime_token)
@@ -978,14 +1008,14 @@ class ContainerTest < ActiveSupport::TestCase
         Thread.current[:user] = auth.user
       end
 
-      assert c.update_attributes(gateway_address: "127.0.0.1:9")
-      assert c.update_attributes(output: collections(:collection_owned_by_active).portable_data_hash)
-      assert c.update_attributes(runtime_status: {'warning' => 'something happened'})
-      assert c.update_attributes(progress: 0.5)
-      assert c.update_attributes(exit_code: 0)
-      refute c.update_attributes(log: collections(:real_log_collection).portable_data_hash)
+      assert c.update(gateway_address: "127.0.0.1:9")
+      assert c.update(output: collections(:collection_owned_by_active).portable_data_hash)
+      assert c.update(runtime_status: {'warning' => 'something happened'})
+      assert c.update(progress: 0.5)
+      assert c.update(exit_code: 0)
+      refute c.update(log: collections(:real_log_collection).portable_data_hash)
       c.reload
-      assert c.update_attributes(state: Container::Complete, exit_code: 0)
+      assert c.update(state: Container::Complete, exit_code: 0)
     end
   end
 
@@ -994,13 +1024,13 @@ class ContainerTest < ActiveSupport::TestCase
     c, _ = minimal_new
     set_user_from_auth :dispatch1
     c.lock
-    c.update_attributes! state: Container::Running
+    c.update! state: Container::Running
 
     Thread.current[:api_client_authorization] = ApiClientAuthorization.find_by_uuid(c.auth_uuid)
     Thread.current[:user] = User.find_by_id(Thread.current[:api_client_authorization].user_id)
 
     assert_raises ActiveRecord::RecordInvalid do
-      c.update_attributes! output: collections(:collection_not_readable_by_active).portable_data_hash
+      c.update! output: collections(:collection_not_readable_by_active).portable_data_hash
     end
   end
 
@@ -1009,11 +1039,11 @@ class ContainerTest < ActiveSupport::TestCase
     c, _ = minimal_new
     set_user_from_auth :dispatch1
     c.lock
-    c.update_attributes! state: Container::Running
+    c.update! state: Container::Running
 
     set_user_from_auth :running_to_be_deleted_container_auth
     assert_raises(ArvadosModel::PermissionDeniedError) do
-      c.update_attributes(output: collections(:foo_file).portable_data_hash)
+      c.update(output: collections(:foo_file).portable_data_hash)
     end
   end
 
@@ -1022,13 +1052,13 @@ class ContainerTest < ActiveSupport::TestCase
     c, _ = minimal_new
     set_user_from_auth :dispatch1
     c.lock
-    c.update_attributes! state: Container::Running
+    c.update! state: Container::Running
 
     output = Collection.find_by_uuid('zzzzz-4zz18-mto52zx1s7sn3jk')
 
     assert output.is_trashed
-    assert c.update_attributes output: output.portable_data_hash
-    assert c.update_attributes! state: Container::Complete
+    assert c.update output: output.portable_data_hash
+    assert c.update! state: Container::Complete
   end
 
   test "not allowed to set trashed output that is not readable by current user" do
@@ -1036,7 +1066,7 @@ class ContainerTest < ActiveSupport::TestCase
     c, _ = minimal_new
     set_user_from_auth :dispatch1
     c.lock
-    c.update_attributes! state: Container::Running
+    c.update! state: Container::Running
 
     output = Collection.find_by_uuid('zzzzz-4zz18-mto52zx1s7sn3jr')
 
@@ -1044,7 +1074,7 @@ class ContainerTest < ActiveSupport::TestCase
     Thread.current[:user] = User.find_by_id(Thread.current[:api_client_authorization].user_id)
 
     assert_raises ActiveRecord::RecordInvalid do
-      c.update_attributes! output: output.portable_data_hash
+      c.update! output: output.portable_data_hash
     end
   end
 
@@ -1067,12 +1097,12 @@ class ContainerTest < ActiveSupport::TestCase
                           container_count_max: 1, runtime_token: api_client_authorizations(:active).token)
       set_user_from_auth :dispatch1
       c.lock
-      c.update_attributes!(state: Container::Running)
+      c.update!(state: Container::Running)
       c.reload
       assert c.secret_mounts.has_key?('/secret')
       assert_equal api_client_authorizations(:active).token, c.runtime_token
 
-      c.update_attributes!(final_attrs)
+      c.update!(final_attrs)
       c.reload
       assert_equal({}, c.secret_mounts)
       assert_nil c.runtime_token
@@ -1120,7 +1150,7 @@ class ContainerTest < ActiveSupport::TestCase
     assert_equal(1, containers.length)
     _, container1 = containers.shift
     container1.lock
-    container1.update_attributes!(state: Container::Cancelled)
+    container1.update!(state: Container::Cancelled)
     container1.reload
     request1 = requests.shift
     request1.reload
@@ -1139,6 +1169,12 @@ class ContainerTest < ActiveSupport::TestCase
     preemptible_values.product(preemptible_values),
     preemptible_values.product(preemptible_values, preemptible_values),
   ).each do |preemptible_a|
+    # If the first req has preemptible=true but a subsequent req
+    # doesn't, we want to avoid reusing the first container, so this
+    # test isn't appropriate.
+    next if preemptible_a[0] &&
+            ((preemptible_a.length > 1 && !preemptible_a[1]) ||
+             (preemptible_a.length > 2 && !preemptible_a[2]))
     test "retry requests scheduled with preemptible=#{preemptible_a}" do
       configure_preemptible_instance_type
       param_hashes = vary_parameters(preemptible: preemptible_a)
@@ -1187,14 +1223,14 @@ class ContainerTest < ActiveSupport::TestCase
     configure_preemptible_instance_type
     param_hashes = [{
                      "partitions": ["alpha", "bravo"],
-                     "preemptible": true,
+                     "preemptible": false,
                      "max_run_time": 10,
                     }, {
                      "partitions": ["alpha", "charlie"],
                      "max_run_time": 20,
                     }, {
                      "partitions": ["bravo", "charlie"],
-                     "preemptible": false,
+                     "preemptible": true,
                      "max_run_time": 30,
                     }]
     container = retry_with_scheduling_parameters(param_hashes)
@@ -1241,8 +1277,8 @@ class ContainerTest < ActiveSupport::TestCase
     end
     container, request = minimal_new(request_params)
     container.lock
-    container.update_attributes!(state: Container::Running)
-    container.update_attributes!(final_attrs)
+    container.update!(state: Container::Running)
+    container.update!(final_attrs)
     return container, request
   end
 
index 3c6dcbdbbc51e12f952bb949474c166a576c9665..86ba78cb99832fe52b6c1715ee20185444306434 100644 (file)
@@ -54,7 +54,7 @@ class CreateSuperUserTokenTest < ActiveSupport::TestCase
     apiClientAuth = ApiClientAuthorization.where(api_token: 'atesttoken').first
     refute_nil apiClientAuth
     Thread.current[:user] = users(:admin)
-    apiClientAuth.update_attributes expires_at: '2000-10-10'
+    apiClientAuth.update expires_at: '2000-10-10'
 
     token2 = create_superuser_token
     assert_not_nil token2
index a0c375a6f93c431dfe26c27e75a3deeff850cd90..36f42006ff11511c3549286e31bdb21897cc1fbf 100644 (file)
@@ -82,7 +82,7 @@ class GroupTest < ActiveSupport::TestCase
     set_user_from_auth :active_trustedclient
     g = Group.create!(name: "foo", group_class: "role")
     assert_raises(ActiveRecord::RecordInvalid) do
-      g.update_attributes!(group_class: "project")
+      g.update!(group_class: "project")
     end
   end
 
@@ -95,7 +95,7 @@ class GroupTest < ActiveSupport::TestCase
 
     c = Collection.create!(name: "bzzz124")
     assert_raises(ArvadosModel::PermissionDeniedError) do
-      c.update_attributes!(owner_uuid: role.uuid)
+      c.update!(owner_uuid: role.uuid)
     end
   end
 
@@ -336,7 +336,7 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
 
       # Cannot set frozen_by_uuid to a different user
       assert_raises do
-        proj.update_attributes!(frozen_by_uuid: users(:spectator).uuid)
+        proj.update!(frozen_by_uuid: users(:spectator).uuid)
       end
       proj.reload
 
@@ -348,7 +348,7 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
         # First confirm we have write permission
         assert Collection.create(name: 'bar', owner_uuid: proj.uuid)
         assert_raises(ArvadosModel::PermissionDeniedError) do
-          proj.update_attributes!(frozen_by_uuid: users(:spectator).uuid)
+          proj.update!(frozen_by_uuid: users(:spectator).uuid)
         end
       end
       proj.reload
@@ -356,12 +356,12 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
       # Cannot set frozen_by_uuid without description (if so configured)
       Rails.configuration.API.FreezeProjectRequiresDescription = true
       err = assert_raises do
-        proj.update_attributes!(frozen_by_uuid: users(:active).uuid)
+        proj.update!(frozen_by_uuid: users(:active).uuid)
       end
       assert_match /can only be set if description is non-empty/, err.inspect
       proj.reload
       err = assert_raises do
-        proj.update_attributes!(frozen_by_uuid: users(:active).uuid, description: '')
+        proj.update!(frozen_by_uuid: users(:active).uuid, description: '')
       end
       assert_match /can only be set if description is non-empty/, err.inspect
       proj.reload
@@ -369,7 +369,7 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
       # Cannot set frozen_by_uuid without properties (if so configured)
       Rails.configuration.API.FreezeProjectRequiresProperties['frobity'] = true
       err = assert_raises do
-        proj.update_attributes!(
+        proj.update!(
           frozen_by_uuid: users(:active).uuid,
           description: 'ready to freeze')
       end
@@ -379,20 +379,20 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
       # Cannot set frozen_by_uuid while project or its parent is
       # trashed
       [parent, proj].each do |trashed|
-        trashed.update_attributes!(trash_at: db_current_time)
+        trashed.update!(trash_at: db_current_time)
         err = assert_raises do
-          proj.update_attributes!(
+          proj.update!(
             frozen_by_uuid: users(:active).uuid,
             description: 'ready to freeze',
             properties: {'frobity' => 'bar baz'})
         end
         assert_match /cannot be set on a trashed project/, err.inspect
         proj.reload
-        trashed.update_attributes!(trash_at: nil)
+        trashed.update!(trash_at: nil)
       end
 
       # Can set frozen_by_uuid if all conditions are met
-      ok = proj.update_attributes(
+      ok = proj.update(
         frozen_by_uuid: users(:active).uuid,
         description: 'ready to freeze',
         properties: {'frobity' => 'bar baz'})
@@ -404,7 +404,7 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
           # its descendants
           [proj, proj_inner].each do |frozen|
             assert_raises do
-              collections(:collection_owned_by_active).update_attributes!(owner_uuid: frozen.uuid)
+              collections(:collection_owned_by_active).update!(owner_uuid: frozen.uuid)
             end
             assert_raises do
               Collection.create!(owner_uuid: frozen.uuid, name: 'inside-frozen-project')
@@ -427,31 +427,31 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
           # trash, or delete the project or anything beneath it
           [proj, proj_inner, coll].each do |frozen|
             assert_raises(StandardError, "should reject rename of #{frozen.uuid} (#{frozen.name}) with parent #{frozen.owner_uuid}") do
-              frozen.update_attributes!(name: 'foo2')
+              frozen.update!(name: 'foo2')
             end
             frozen.reload
 
             if frozen.is_a?(Collection)
               assert_raises(StandardError, "should reject manifest change of #{frozen.uuid}") do
-                frozen.update_attributes!(manifest_text: ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:foo\n")
+                frozen.update!(manifest_text: ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:foo\n")
               end
             else
               assert_raises(StandardError, "should reject moving a project into #{frozen.uuid}") do
-                groups(:private).update_attributes!(owner_uuid: frozen.uuid)
+                groups(:private).update!(owner_uuid: frozen.uuid)
               end
             end
             frozen.reload
 
             assert_raises(StandardError, "should reject moving #{frozen.uuid} to a different parent project") do
-              frozen.update_attributes!(owner_uuid: groups(:private).uuid)
+              frozen.update!(owner_uuid: groups(:private).uuid)
             end
             frozen.reload
             assert_raises(StandardError, "should reject setting trash_at of #{frozen.uuid}") do
-              frozen.update_attributes!(trash_at: db_current_time)
+              frozen.update!(trash_at: db_current_time)
             end
             frozen.reload
             assert_raises(StandardError, "should reject setting delete_at of #{frozen.uuid}") do
-              frozen.update_attributes!(delete_at: db_current_time)
+              frozen.update!(delete_at: db_current_time)
             end
             frozen.reload
             assert_raises(StandardError, "should reject delete of #{frozen.uuid}") do
@@ -470,35 +470,35 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
         # First confirm we have write permission on the parent project
         assert Collection.create(name: 'bar', owner_uuid: parent.uuid)
         assert_raises(ArvadosModel::PermissionDeniedError) do
-          proj.update_attributes!(frozen_by_uuid: nil)
+          proj.update!(frozen_by_uuid: nil)
         end
       end
       proj.reload
 
       # User with manage permission can unfreeze, then create items
       # inside it and its children
-      assert proj.update_attributes(frozen_by_uuid: nil)
+      assert proj.update(frozen_by_uuid: nil)
       assert Collection.create!(owner_uuid: proj.uuid, name: 'inside-unfrozen-project')
       assert Collection.create!(owner_uuid: proj_inner.uuid, name: 'inside-inner-unfrozen-project')
 
       # Re-freeze, and reconfigure so only admins can unfreeze.
-      assert proj.update_attributes(frozen_by_uuid: users(:active).uuid)
+      assert proj.update(frozen_by_uuid: users(:active).uuid)
       Rails.configuration.API.UnfreezeProjectRequiresAdmin = true
 
       # Owner cannot unfreeze, because not admin.
       err = assert_raises do
-        proj.update_attributes!(frozen_by_uuid: nil)
+        proj.update!(frozen_by_uuid: nil)
       end
       assert_match /can only be changed by an admin user, once set/, err.inspect
       proj.reload
 
       # Cannot trash or delete a frozen project's ancestor
       assert_raises(StandardError, "should not be able to set trash_at on parent of frozen project") do
-        parent.update_attributes!(trash_at: db_current_time)
+        parent.update!(trash_at: db_current_time)
       end
       parent.reload
       assert_raises(StandardError, "should not be able to set delete_at on parent of frozen project") do
-        parent.update_attributes!(delete_at: db_current_time)
+        parent.update!(delete_at: db_current_time)
       end
       parent.reload
       assert_nil parent.frozen_by_uuid
@@ -506,13 +506,13 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
       act_as_user users(:admin) do
         # Even admin cannot change frozen_by_uuid to someone else's UUID.
         err = assert_raises do
-          proj.update_attributes!(frozen_by_uuid: users(:project_viewer).uuid)
+          proj.update!(frozen_by_uuid: users(:project_viewer).uuid)
         end
         assert_match /can only be set to the current user's UUID/, err.inspect
         proj.reload
 
         # Admin can unfreeze.
-        assert proj.update_attributes(frozen_by_uuid: nil), proj.errors.messages
+        assert proj.update(frozen_by_uuid: nil), proj.errors.messages
       end
 
       # Cannot freeze a project if it contains container requests in
@@ -521,15 +521,15 @@ update links set tail_uuid='#{g5}' where uuid='#{l1.uuid}'
       creq_uncommitted = ContainerRequest.create!(test_cr_attrs.merge(owner_uuid: proj_inner.uuid))
       creq_committed = ContainerRequest.create!(test_cr_attrs.merge(owner_uuid: proj_inner.uuid, state: 'Committed'))
       err = assert_raises do
-        proj.update_attributes!(frozen_by_uuid: users(:active).uuid)
+        proj.update!(frozen_by_uuid: users(:active).uuid)
       end
       assert_match /container request zzzzz-xvhdp-.* with state = Committed/, err.inspect
       proj.reload
 
       # Can freeze once all container requests are in Uncommitted or
       # Final state
-      creq_committed.update_attributes!(state: ContainerRequest::Final)
-      assert proj.update_attributes(frozen_by_uuid: users(:active).uuid)
+      creq_committed.update!(state: ContainerRequest::Final)
+      assert proj.update(frozen_by_uuid: users(:active).uuid)
     end
   end
 
index 5d36653a569f82315cf642ffb6206438340a553a..b9806486adb381596b024a2ff2258d2d91b164b1 100644 (file)
@@ -109,7 +109,7 @@ class LinkTest < ActiveSupport::TestCase
 
   test "updating permission causes any conflicting links to be deleted" do
     link1, link2 = create_overlapping_permissions(['can_read', 'can_manage'])
-    Link.find_by_uuid(link2).update_attributes!(name: 'can_write')
+    Link.find_by_uuid(link2).update!(name: 'can_write')
     assert_empty Link.where(uuid: link1)
   end
 
@@ -121,8 +121,8 @@ class LinkTest < ActiveSupport::TestCase
 
   test "updating login permission causes any conflicting links to be deleted" do
     link1, link2 = create_overlapping_permissions(['can_login', 'can_login'], {properties: {username: 'foo1'}})
-    Link.find_by_uuid(link1).update_attributes!(properties: {'username' => 'foo2'})
-    Link.find_by_uuid(link2).update_attributes!(properties: {'username' => 'foo2'})
+    Link.find_by_uuid(link1).update!(properties: {'username' => 'foo2'})
+    Link.find_by_uuid(link2).update!(properties: {'username' => 'foo2'})
     assert_empty Link.where(uuid: link1)
   end
 
index 66c8c8d923d06f9f745f0c393f29ff99ce605f84..d3a1b618d5e8d7ff9ed1c160627a468641997f5e 100644 (file)
@@ -319,7 +319,7 @@ class LogTest < ActiveSupport::TestCase
       assert_logged(coll, :create) do |props|
         assert_equal(txt, props['new_attributes']['manifest_text'])
       end
-      coll.update_attributes!(name: "testing")
+      coll.update!(name: "testing")
       assert_logged(coll, :update) do |props|
         assert_equal(txt, props['old_attributes']['manifest_text'])
         assert_equal(txt, props['new_attributes']['manifest_text'])
index aa0ac5f361154b55e90e46932b0839793bbd62f8..1c1bd93b8169484b78ffe4948c69512c1c89bc4b 100644 (file)
@@ -63,7 +63,7 @@ class OwnerTest < ActiveSupport::TestCase
 
         assert(Specimen.where(uuid: i.uuid).any?,
                "new item should really be in DB")
-        assert(i.update_attributes(owner_uuid: new_o.uuid),
+        assert(i.update(owner_uuid: new_o.uuid),
                "should change owner_uuid from #{o.uuid} to #{new_o.uuid}")
       end
     end
@@ -92,7 +92,7 @@ class OwnerTest < ActiveSupport::TestCase
              "new #{o_class} should really be in DB")
       old_uuid = o.uuid
       new_uuid = o.uuid.sub(/..........$/, rand(2**256).to_s(36)[0..9])
-      assert(o.update_attributes(uuid: new_uuid),
+      assert(o.update(uuid: new_uuid),
               "should change #{o_class} uuid from #{old_uuid} to #{new_uuid}")
       assert_equal(false, o_class.where(uuid: old_uuid).any?,
                    "#{old_uuid} should disappear when renamed to #{new_uuid}")
@@ -118,7 +118,7 @@ class OwnerTest < ActiveSupport::TestCase
       assert_equal(true, Specimen.where(owner_uuid: o.uuid).any?,
                    "need something to be owned by #{o.uuid} for this test")
       new_uuid = o.uuid.sub(/..........$/, rand(2**256).to_s(36)[0..9])
-      assert(!o.update_attributes(uuid: new_uuid),
+      assert(!o.update(uuid: new_uuid),
              "should not change uuid of #{ofixt} that owns objects")
     end
   end
@@ -126,7 +126,7 @@ class OwnerTest < ActiveSupport::TestCase
   test "delete User that owns self" do
     o = User.create!
     assert User.where(uuid: o.uuid).any?, "new User should really be in DB"
-    assert_equal(true, o.update_attributes(owner_uuid: o.uuid),
+    assert_equal(true, o.update(owner_uuid: o.uuid),
                  "setting owner to self should work")
 
     skip_check_permissions_against_full_refresh do
index db60b4e6e1f21ef1517ac9ab43f163e8124c747b..14c810d81ae363e4b3f45f166f48a8a11a7898fd 100644 (file)
@@ -84,7 +84,7 @@ class PermissionTest < ActiveSupport::TestCase
     assert users(:active).can?(write: ob)
     assert users(:active).can?(read: ob)
 
-    l1.update_attributes!(name: 'can_read')
+    l1.update!(name: 'can_read')
 
     assert !users(:active).can?(write: ob)
     assert users(:active).can?(read: ob)
@@ -293,7 +293,7 @@ class PermissionTest < ActiveSupport::TestCase
                    "manager saw the minion's private stuff")
       assert_raises(ArvadosModel::PermissionDeniedError,
                    "manager could update minion's private stuff") do
-        minions_specimen.update_attributes(properties: {'x' => 'y'})
+        minions_specimen.update(properties: {'x' => 'y'})
       end
     end
 
@@ -310,7 +310,7 @@ class PermissionTest < ActiveSupport::TestCase
                          .where(uuid: minions_specimen.uuid),
                        "manager could not find minion's specimen by uuid")
       assert_equal(true,
-                   minions_specimen.update_attributes(properties: {'x' => 'y'}),
+                   minions_specimen.update(properties: {'x' => 'y'}),
                    "manager could not update minion's specimen object")
     end
   end
@@ -355,17 +355,17 @@ class PermissionTest < ActiveSupport::TestCase
                    "OTHER can see #{u.first_name} in the user list")
       act_as_user u do
         assert_raises ArvadosModel::PermissionDeniedError, "wrote without perm" do
-          other.update_attributes!(prefs: {'pwned' => true})
+          other.update!(prefs: {'pwned' => true})
         end
-        assert_equal(true, u.update_attributes!(prefs: {'thisisme' => true}),
+        assert_equal(true, u.update!(prefs: {'thisisme' => true}),
                      "#{u.first_name} can't update its own prefs")
       end
       act_as_user other do
         assert_raises(ArvadosModel::PermissionDeniedError,
                         "OTHER wrote #{u.first_name} without perm") do
-          u.update_attributes!(prefs: {'pwned' => true})
+          u.update!(prefs: {'pwned' => true})
         end
-        assert_equal(true, other.update_attributes!(prefs: {'thisisme' => true}),
+        assert_equal(true, other.update!(prefs: {'thisisme' => true}),
                      "OTHER can't update its own prefs")
       end
     end
@@ -382,7 +382,7 @@ class PermissionTest < ActiveSupport::TestCase
     set_user_from_auth :rominiadmin
     ob = Collection.create!
     assert_raises ArvadosModel::PermissionDeniedError, "changed owner to unwritable user" do
-      ob.update_attributes!(owner_uuid: users(:active).uuid)
+      ob.update!(owner_uuid: users(:active).uuid)
     end
   end
 
@@ -397,7 +397,7 @@ class PermissionTest < ActiveSupport::TestCase
     set_user_from_auth :rominiadmin
     ob = Collection.create!
     assert_raises ArvadosModel::PermissionDeniedError, "changed owner to unwritable group" do
-      ob.update_attributes!(owner_uuid: groups(:aproject).uuid)
+      ob.update!(owner_uuid: groups(:aproject).uuid)
     end
   end
 
index cb562ef977200740e3d116889dad9ed1b9f55cb8..674a34ffd8defb5d368c9818598f73e7cabe15ae 100644 (file)
@@ -263,7 +263,7 @@ class RepositoryTest < ActiveSupport::TestCase
 
   test "non-admin can rename own repo" do
     act_as_user users(:active) do
-      assert repositories(:foo).update_attributes(name: 'active/foo12345')
+      assert repositories(:foo).update(name: 'active/foo12345')
     end
   end
 
index 7e19ad5821eef83be9fa0de565a7f407521bf087..810e5b45ecb2e77ad5394d20ffc807fb46d458ea 100644 (file)
@@ -153,12 +153,12 @@ class UserTest < ActiveSupport::TestCase
     assert_equal("active/foo", repositories(:foo).name)
   end
 
-  [[false, 'foo@example.com', true, nil],
-   [false, 'bar@example.com', nil, true],
-   [true, 'foo@example.com', true, nil],
+  [[false, 'foo@example.com', true, false],
+   [false, 'bar@example.com', false, true],
+   [true, 'foo@example.com', true, false],
    [true, 'bar@example.com', true, true],
-   [false, '', nil, nil],
-   [true, '', true, nil]
+   [false, '', false, false],
+   [true, '', true, false]
   ].each do |auto_admin_first_user_config, auto_admin_user_config, foo_should_be_admin, bar_should_be_admin|
     # In each case, 'foo' is created first, then 'bar', then 'bar2', then 'baz'.
     test "auto admin with auto_admin_first=#{auto_admin_first_user_config} auto_admin=#{auto_admin_user_config}" do
@@ -166,7 +166,7 @@ class UserTest < ActiveSupport::TestCase
       if auto_admin_first_user_config
         # This test requires no admin users exist (except for the system user)
         act_as_system_user do
-          users(:admin).update_attributes!(is_admin: false)
+          users(:admin).update!(is_admin: false)
         end
         @all_users = User.where("uuid not like '%-000000000000000'").where(:is_admin => true)
         assert_equal 0, @all_users.count, "No admin users should exist (except for the system user)"
@@ -347,10 +347,12 @@ class UserTest < ActiveSupport::TestCase
   test "create new user with notifications" do
     set_user_from_auth :admin
 
+    Rails.configuration.Users.AutoSetupNewUsers = false
+
     create_user_and_verify_setup_and_notifications true, active_notify_list, inactive_notify_list, nil, nil
     create_user_and_verify_setup_and_notifications true, active_notify_list, empty_notify_list, nil, nil
     create_user_and_verify_setup_and_notifications true, empty_notify_list, empty_notify_list, nil, nil
-    create_user_and_verify_setup_and_notifications false, active_notify_list, inactive_notify_list, nil, nil
+    create_user_and_verify_setup_and_notifications false, empty_notify_list, inactive_notify_list, nil, nil
     create_user_and_verify_setup_and_notifications false, empty_notify_list, inactive_notify_list, nil, nil
     create_user_and_verify_setup_and_notifications false, empty_notify_list, empty_notify_list, nil, nil
   end
@@ -379,13 +381,13 @@ class UserTest < ActiveSupport::TestCase
     [false, empty_notify_list, empty_notify_list, "arvados@example.com", false, false, "arvados2"],
     [true, active_notify_list, inactive_notify_list, "arvados@example.com", false, false, "arvados2"],
     [true, active_notify_list, inactive_notify_list, "root@example.com", true, false, "root2"],
-    [false, active_notify_list, inactive_notify_list, "root@example.com", true, false, "root2"],
+    [false, active_notify_list, empty_notify_list, "root@example.com", true, false, "root2"],
     [true, active_notify_list, inactive_notify_list, "roo_t@example.com", false, true, "root2"],
     [false, empty_notify_list, empty_notify_list, "^^incorrect_format@example.com", true, true, "incorrectformat"],
     [true, active_notify_list, inactive_notify_list, "&4a_d9.@example.com", true, true, "ad9"],
     [true, active_notify_list, inactive_notify_list, "&4a_d9.@example.com", false, false, "ad9"],
-    [false, active_notify_list, inactive_notify_list, "&4a_d9.@example.com", true, true, "ad9"],
-    [false, active_notify_list, inactive_notify_list, "&4a_d9.@example.com", false, false, "ad9"],
+    [false, active_notify_list, empty_notify_list, "&4a_d9.@example.com", true, true, "ad9"],
+    [false, active_notify_list, empty_notify_list, "&4a_d9.@example.com", false, false, "ad9"],
   ].each do |active, new_user_recipients, inactive_recipients, email, auto_setup_vm, auto_setup_repo, expect_username|
     test "create new user with auto setup active=#{active} email=#{email} vm=#{auto_setup_vm} repo=#{auto_setup_repo}" do
       set_user_from_auth :admin
@@ -800,7 +802,7 @@ class UserTest < ActiveSupport::TestCase
   test "empty identity_url saves as null" do
     set_user_from_auth :admin
     user = users(:active)
-    assert user.update_attributes(identity_url: '')
+    assert user.update(identity_url: '')
     user.reload
     assert_nil user.identity_url
   end
index 26cd7f215ed557077bf84ac34c78219fcccda00c..4b3e6095d944faa25b534eea050170690c04ff43 100644 (file)
@@ -60,7 +60,7 @@ class WorkflowTest < ActiveSupport::TestCase
     definition = "k1:\n v1: x\n  v2: y"
 
     assert_raises(ActiveRecord::RecordInvalid) do
-      w.update_attributes!(definition: definition)
+      w.update!(definition: definition)
     end
   end
 
@@ -71,7 +71,7 @@ class WorkflowTest < ActiveSupport::TestCase
     # when it does not already have custom values for these fields
     w = Workflow.find_by_uuid(workflows(:workflow_with_no_name_and_desc).uuid)
     definition = "name: test name 1\ndescription: test desc 1\nother: some more"
-    w.update_attributes!(definition: definition)
+    w.update!(definition: definition)
     w.reload
     assert_equal "test name 1", w.name
     assert_equal "test desc 1", w.description
@@ -79,7 +79,7 @@ class WorkflowTest < ActiveSupport::TestCase
     # Workflow name and desc should be set with values from definition yaml
     # when it does not already have custom values for these fields
     definition = "name: test name 2\ndescription: test desc 2\nother: some more"
-    w.update_attributes!(definition: definition)
+    w.update!(definition: definition)
     w.reload
     assert_equal "test name 2", w.name
     assert_equal "test desc 2", w.description
@@ -87,7 +87,7 @@ class WorkflowTest < ActiveSupport::TestCase
     # Workflow name and desc should be set with values from definition yaml
     # even if it means emptying them out
     definition = "more: etc"
-    w.update_attributes!(definition: definition)
+    w.update!(definition: definition)
     w.reload
     assert_nil w.name
     assert_nil w.description
@@ -95,17 +95,17 @@ class WorkflowTest < ActiveSupport::TestCase
     # Workflow name and desc set using definition yaml should be cleared
     # if definition yaml is cleared
     definition = "name: test name 2\ndescription: test desc 2\nother: some more"
-    w.update_attributes!(definition: definition)
+    w.update!(definition: definition)
     w.reload
     definition = nil
-    w.update_attributes!(definition: definition)
+    w.update!(definition: definition)
     w.reload
     assert_nil w.name
     assert_nil w.description
 
     # Workflow name and desc should be set to provided custom values
     definition = "name: test name 3\ndescription: test desc 3\nother: some more"
-    w.update_attributes!(name: "remains", description: "remains", definition: definition)
+    w.update!(name: "remains", description: "remains", definition: definition)
     w.reload
     assert_equal "remains", w.name
     assert_equal "remains", w.description
@@ -113,7 +113,7 @@ class WorkflowTest < ActiveSupport::TestCase
     # Workflow name and desc should retain provided custom values
     # and should not be overwritten by values from yaml
     definition = "name: test name 4\ndescription: test desc 4\nother: some more"
-    w.update_attributes!(definition: definition)
+    w.update!(definition: definition)
     w.reload
     assert_equal "remains", w.name
     assert_equal "remains", w.description
@@ -121,7 +121,7 @@ class WorkflowTest < ActiveSupport::TestCase
     # Workflow name and desc should retain provided custom values
     # and not be affected by the clearing of the definition yaml
     definition = nil
-    w.update_attributes!(definition: definition)
+    w.update!(definition: definition)
     w.reload
     assert_equal "remains", w.name
     assert_equal "remains", w.description
index e3dd113c710cd8cf5b1cede361d794ad7fd67839..b4fc10f83ee02232b411cb101d1e7ea6b938f7cf 100644 (file)
@@ -5,8 +5,6 @@
 Description=Arvados Crunch Dispatcher for LOCAL service
 Documentation=https://doc.arvados.org/
 After=network.target
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -19,8 +17,5 @@ Restart=always
 RestartSec=1
 LimitNOFILE=1000000
 
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
-
 [Install]
 WantedBy=multi-user.target
index 1c0f6ad28f5ba7b6d20bcc0cadbef0fb87fec634..5a9ef91c3d23784e7e1a020a60ef09ecaea1e538 100644 (file)
@@ -197,14 +197,16 @@ func (disp *Dispatcher) sbatchArgs(container arvados.Container) ([]string, error
        if disp.cluster == nil {
                // no instance types configured
                args = append(args, disp.slurmConstraintArgs(container)...)
-       } else if it, err := dispatchcloud.ChooseInstanceType(disp.cluster, &container); err == dispatchcloud.ErrInstanceTypesNotConfigured {
+       } else if types, err := dispatchcloud.ChooseInstanceType(disp.cluster, &container); err == dispatchcloud.ErrInstanceTypesNotConfigured {
                // ditto
                args = append(args, disp.slurmConstraintArgs(container)...)
        } else if err != nil {
                return nil, err
        } else {
-               // use instancetype constraint instead of slurm mem/cpu/tmp specs
-               args = append(args, "--constraint=instancetype="+it.Name)
+               // use instancetype constraint instead of slurm
+               // mem/cpu/tmp specs (note types[0] is the lowest-cost
+               // suitable instance type)
+               args = append(args, "--constraint=instancetype="+types[0].Name)
        }
 
        if len(container.SchedulingParameters.Partitions) > 0 {
diff --git a/services/crunchstat/.gitignore b/services/crunchstat/.gitignore
deleted file mode 100644 (file)
index c26270a..0000000
+++ /dev/null
@@ -1 +0,0 @@
-crunchstat
diff --git a/services/crunchstat/crunchstat.go b/services/crunchstat/crunchstat.go
deleted file mode 100644 (file)
index d28bee0..0000000
+++ /dev/null
@@ -1,192 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package main
-
-import (
-       "bufio"
-       "flag"
-       "fmt"
-       "io"
-       "log"
-       "os"
-       "os/exec"
-       "os/signal"
-       "syscall"
-       "time"
-
-       "git.arvados.org/arvados.git/lib/cmd"
-       "git.arvados.org/arvados.git/lib/crunchstat"
-)
-
-const MaxLogLine = 1 << 14 // Child stderr lines >16KiB will be split
-
-var (
-       signalOnDeadPPID  int = 15
-       ppidCheckInterval     = time.Second
-       version               = "dev"
-)
-
-type logger interface {
-       Printf(string, ...interface{})
-}
-
-func main() {
-       reporter := crunchstat.Reporter{
-               Logger: log.New(os.Stderr, "crunchstat: ", 0),
-       }
-
-       flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
-       flags.StringVar(&reporter.CgroupRoot, "cgroup-root", "", "Root of cgroup tree")
-       flags.StringVar(&reporter.CgroupParent, "cgroup-parent", "", "Name of container parent under cgroup")
-       flags.StringVar(&reporter.CIDFile, "cgroup-cid", "", "Path to container id file")
-       flags.IntVar(&signalOnDeadPPID, "signal-on-dead-ppid", signalOnDeadPPID, "Signal to send child if crunchstat's parent process disappears (0 to disable)")
-       flags.DurationVar(&ppidCheckInterval, "ppid-check-interval", ppidCheckInterval, "Time between checks for parent process disappearance")
-       pollMsec := flags.Int64("poll", 1000, "Reporting interval, in milliseconds")
-       getVersion := flags.Bool("version", false, "Print version information and exit.")
-
-       if ok, code := cmd.ParseFlags(flags, os.Args[0], os.Args[1:], "program [args ...]", os.Stderr); !ok {
-               os.Exit(code)
-       } else if *getVersion {
-               fmt.Printf("crunchstat %s\n", version)
-               return
-       } else if flags.NArg() == 0 {
-               fmt.Fprintf(os.Stderr, "missing required argument: program (try -help)\n")
-               os.Exit(2)
-       }
-
-       reporter.Logger.Printf("crunchstat %s started", version)
-
-       if reporter.CgroupRoot == "" {
-               reporter.Logger.Printf("error: must provide -cgroup-root")
-               os.Exit(2)
-       } else if signalOnDeadPPID < 0 {
-               reporter.Logger.Printf("-signal-on-dead-ppid=%d is invalid (use a positive signal number, or 0 to disable)", signalOnDeadPPID)
-               os.Exit(2)
-       }
-       reporter.PollPeriod = time.Duration(*pollMsec) * time.Millisecond
-
-       reporter.Start()
-       err := runCommand(flags.Args(), reporter.Logger)
-       reporter.Stop()
-
-       if err, ok := err.(*exec.ExitError); ok {
-               // The program has exited with an exit code != 0
-
-               // This works on both Unix and Windows. Although
-               // package syscall is generally platform dependent,
-               // WaitStatus is defined for both Unix and Windows and
-               // in both cases has an ExitStatus() method with the
-               // same signature.
-               if status, ok := err.Sys().(syscall.WaitStatus); ok {
-                       os.Exit(status.ExitStatus())
-               } else {
-                       reporter.Logger.Printf("ExitError without WaitStatus: %v", err)
-                       os.Exit(1)
-               }
-       } else if err != nil {
-               reporter.Logger.Printf("error running command: %v", err)
-               os.Exit(1)
-       }
-}
-
-func runCommand(argv []string, logger logger) error {
-       cmd := exec.Command(argv[0], argv[1:]...)
-
-       logger.Printf("Running %v", argv)
-
-       // Child process will use our stdin and stdout pipes
-       // (we close our copies below)
-       cmd.Stdin = os.Stdin
-       cmd.Stdout = os.Stdout
-
-       // Forward SIGINT and SIGTERM to child process
-       sigChan := make(chan os.Signal, 1)
-       go func(sig <-chan os.Signal) {
-               catch := <-sig
-               if cmd.Process != nil {
-                       cmd.Process.Signal(catch)
-               }
-               logger.Printf("notice: caught signal: %v", catch)
-       }(sigChan)
-       signal.Notify(sigChan, syscall.SIGTERM)
-       signal.Notify(sigChan, syscall.SIGINT)
-
-       // Kill our child proc if our parent process disappears
-       if signalOnDeadPPID != 0 {
-               go sendSignalOnDeadPPID(ppidCheckInterval, signalOnDeadPPID, os.Getppid(), cmd, logger)
-       }
-
-       // Funnel stderr through our channel
-       stderrPipe, err := cmd.StderrPipe()
-       if err != nil {
-               logger.Printf("error in StderrPipe: %v", err)
-               return err
-       }
-
-       // Run subprocess
-       if err := cmd.Start(); err != nil {
-               logger.Printf("error in cmd.Start: %v", err)
-               return err
-       }
-
-       // Close stdin/stdout in this (parent) process
-       os.Stdin.Close()
-       os.Stdout.Close()
-
-       err = copyPipeToChildLog(stderrPipe, log.New(os.Stderr, "", 0))
-       if err != nil {
-               cmd.Process.Kill()
-               return err
-       }
-
-       return cmd.Wait()
-}
-
-func sendSignalOnDeadPPID(intvl time.Duration, signum, ppidOrig int, cmd *exec.Cmd, logger logger) {
-       ticker := time.NewTicker(intvl)
-       for range ticker.C {
-               ppid := os.Getppid()
-               if ppid == ppidOrig {
-                       continue
-               }
-               if cmd.Process == nil {
-                       // Child process isn't running yet
-                       continue
-               }
-               logger.Printf("notice: crunchstat ppid changed from %d to %d -- killing child pid %d with signal %d", ppidOrig, ppid, cmd.Process.Pid, signum)
-               err := cmd.Process.Signal(syscall.Signal(signum))
-               if err != nil {
-                       logger.Printf("error: sending signal: %s", err)
-                       continue
-               }
-               ticker.Stop()
-               break
-       }
-}
-
-func copyPipeToChildLog(in io.ReadCloser, logger logger) error {
-       reader := bufio.NewReaderSize(in, MaxLogLine)
-       var prefix string
-       for {
-               line, isPrefix, err := reader.ReadLine()
-               if err == io.EOF {
-                       break
-               } else if err != nil {
-                       return fmt.Errorf("error reading child stderr: %w", err)
-               }
-               var suffix string
-               if isPrefix {
-                       suffix = "[...]"
-               }
-               logger.Printf("%s%s%s", prefix, string(line), suffix)
-               // Set up prefix for following line
-               if isPrefix {
-                       prefix = "[...]"
-               } else {
-                       prefix = ""
-               }
-       }
-       return in.Close()
-}
diff --git a/services/crunchstat/crunchstat_test.go b/services/crunchstat/crunchstat_test.go
deleted file mode 100644 (file)
index eb02395..0000000
+++ /dev/null
@@ -1,238 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package main
-
-import (
-       "bufio"
-       "bytes"
-       "fmt"
-       "io"
-       "io/ioutil"
-       "log"
-       "math/rand"
-       "os"
-       "os/exec"
-       "sync"
-       "syscall"
-       "testing"
-       "time"
-)
-
-// Test that CopyPipeToChildLog works even on lines longer than
-// bufio.MaxScanTokenSize.
-func TestCopyPipeToChildLogLongLines(t *testing.T) {
-       logger, logBuf := bufLogger()
-
-       pipeIn, pipeOut := io.Pipe()
-       copied := make(chan bool)
-       go func() {
-               copyPipeToChildLog(pipeIn, logger)
-               close(copied)
-       }()
-
-       sentBytes := make([]byte, bufio.MaxScanTokenSize+MaxLogLine+(1<<22))
-       go func() {
-               pipeOut.Write([]byte("before\n"))
-
-               for i := range sentBytes {
-                       // Some bytes that aren't newlines:
-                       sentBytes[i] = byte((rand.Int() & 0xff) | 0x80)
-               }
-               sentBytes[len(sentBytes)-1] = '\n'
-               pipeOut.Write(sentBytes)
-
-               pipeOut.Write([]byte("after"))
-               pipeOut.Close()
-       }()
-
-       if before, err := logBuf.ReadBytes('\n'); err != nil || string(before) != "before\n" {
-               t.Fatalf("\"before\n\" not received (got \"%s\", %s)", before, err)
-       }
-
-       var receivedBytes []byte
-       done := false
-       for !done {
-               line, err := logBuf.ReadBytes('\n')
-               if err != nil {
-                       t.Fatal(err)
-               }
-               if len(line) >= 5 && string(line[0:5]) == "[...]" {
-                       if receivedBytes == nil {
-                               t.Fatal("Beginning of line reported as continuation")
-                       }
-                       line = line[5:]
-               }
-               if len(line) >= 6 && string(line[len(line)-6:]) == "[...]\n" {
-                       line = line[:len(line)-6]
-               } else {
-                       done = true
-               }
-               receivedBytes = append(receivedBytes, line...)
-       }
-       if bytes.Compare(receivedBytes, sentBytes) != 0 {
-               t.Fatalf("sent %d bytes, got %d different bytes", len(sentBytes), len(receivedBytes))
-       }
-
-       if after, err := logBuf.ReadBytes('\n'); err != nil || string(after) != "after\n" {
-               t.Fatalf("\"after\n\" not received (got \"%s\", %s)", after, err)
-       }
-
-       select {
-       case <-time.After(time.Second):
-               t.Fatal("Timeout")
-       case <-copied:
-               // Done.
-       }
-}
-
-func bufLogger() (*log.Logger, *bufio.Reader) {
-       r, w := io.Pipe()
-       logger := log.New(w, "", 0)
-       return logger, bufio.NewReader(r)
-}
-
-func TestSignalOnDeadPPID(t *testing.T) {
-       if !testDeadParent(t, 0) {
-               t.Fatal("child should still be alive after parent dies")
-       }
-       if testDeadParent(t, 15) {
-               t.Fatal("child should have been killed when parent died")
-       }
-}
-
-// testDeadParent returns true if crunchstat's child proc is still
-// alive after its parent dies.
-func testDeadParent(t *testing.T, signum int) bool {
-       var err error
-       var bin, childlockfile, parentlockfile *os.File
-       for _, f := range []**os.File{&bin, &childlockfile, &parentlockfile} {
-               *f, err = ioutil.TempFile("", "crunchstat_")
-               if err != nil {
-                       t.Fatal(err)
-               }
-               defer (*f).Close()
-               defer os.Remove((*f).Name())
-       }
-
-       bin.Close()
-       err = exec.Command("go", "build", "-o", bin.Name()).Run()
-       if err != nil {
-               t.Fatal(err)
-       }
-
-       err = syscall.Flock(int(parentlockfile.Fd()), syscall.LOCK_EX)
-       if err != nil {
-               t.Fatal(err)
-       }
-
-       cmd := exec.Command("bash", "-c", `
-set -e
-"$BINFILE" -cgroup-root=/none -ppid-check-interval=10ms -signal-on-dead-ppid="$SIGNUM" bash -c '
-    set -e
-    unlock() {
-        flock --unlock "$CHILDLOCKFD"
-        kill %1
-    }
-    trap unlock TERM
-    flock --exclusive "$CHILDLOCKFD"
-    echo -n "$$" > "$CHILDLOCKFILE"
-    flock --unlock "$PARENTLOCKFD"
-    sleep 20 </dev/null >/dev/null 2>/dev/null &
-    wait %1
-    unlock
-' &
-
-# wait for inner bash to start, to ensure $BINFILE has seen this bash proc as its initial PPID
-flock --exclusive "$PARENTLOCKFILE" true
-`)
-       cmd.Env = append(os.Environ(),
-               "SIGNUM="+fmt.Sprintf("%d", signum),
-               "PARENTLOCKFD=3",
-               "PARENTLOCKFILE="+parentlockfile.Name(),
-               "CHILDLOCKFD=4",
-               "CHILDLOCKFILE="+childlockfile.Name(),
-               "BINFILE="+bin.Name())
-       cmd.ExtraFiles = []*os.File{parentlockfile, childlockfile}
-       stderr, err := cmd.StderrPipe()
-       if err != nil {
-               t.Fatal(err)
-       }
-       stdout, err := cmd.StdoutPipe()
-       if err != nil {
-               t.Fatal(err)
-       }
-       cmd.Start()
-       defer cmd.Wait()
-
-       var wg sync.WaitGroup
-       wg.Add(2)
-       defer wg.Wait()
-       for _, rdr := range []io.ReadCloser{stderr, stdout} {
-               go func(rdr io.ReadCloser) {
-                       defer wg.Done()
-                       buf := make([]byte, 1024)
-                       for {
-                               n, err := rdr.Read(buf)
-                               if n > 0 {
-                                       t.Logf("%s", buf[:n])
-                               }
-                               if err != nil {
-                                       return
-                               }
-                       }
-               }(rdr)
-       }
-
-       // Wait until inner bash process releases parentlockfile
-       // (which means it has locked childlockfile and written its
-       // PID)
-       err = exec.Command("flock", "--exclusive", parentlockfile.Name(), "true").Run()
-       if err != nil {
-               t.Fatal(err)
-       }
-
-       childDone := make(chan bool)
-       go func() {
-               // Notify the main thread when the inner bash process
-               // releases its lock on childlockfile (which means
-               // either its sleep process ended or it received a
-               // TERM signal).
-               t0 := time.Now()
-               err = exec.Command("flock", "--exclusive", childlockfile.Name(), "true").Run()
-               if err != nil {
-                       t.Fatal(err)
-               }
-               t.Logf("child done after %s", time.Since(t0))
-               close(childDone)
-       }()
-
-       select {
-       case <-time.After(500 * time.Millisecond):
-               // Inner bash process is still alive after the timeout
-               // period. Kill it now, so our stdout and stderr pipes
-               // can finish and we don't leave a mess of child procs
-               // behind.
-               buf, err := ioutil.ReadFile(childlockfile.Name())
-               if err != nil {
-                       t.Fatal(err)
-               }
-               var childPID int
-               _, err = fmt.Sscanf(string(buf), "%d", &childPID)
-               if err != nil {
-                       t.Fatal(err)
-               }
-               child, err := os.FindProcess(childPID)
-               if err != nil {
-                       t.Fatal(err)
-               }
-               child.Signal(syscall.Signal(15))
-               return true
-
-       case <-childDone:
-               // Inner bash process ended soon after its grandparent
-               // ended.
-               return false
-       }
-}
index 2aab42b2a37c7c4be9a6ff6907a6b6c38c3373cf..819c920ff2a307a7e1c86ddf62fb9009501082f0 100644 (file)
@@ -6,8 +6,6 @@
 Description=Arvados Docker Image Cleaner
 Documentation=https://doc.arvados.org/
 After=network.target
-
-# systemd>=230 (debian:9) obeys StartLimitIntervalSec in the [Unit] section
 StartLimitIntervalSec=0
 
 [Service]
@@ -15,14 +13,7 @@ Type=simple
 Restart=always
 RestartSec=10s
 RestartPreventExitStatus=2
-#
-# This unwieldy ExecStart command detects at runtime whether
-# arvados-docker-cleaner is installed with the Python 3.3 Software
-# Collection, and if so, invokes it with the "scl" wrapper.
-ExecStart=/bin/sh -c 'if [ -e /opt/rh/rh-python36/root/bin/arvados-docker-cleaner ]; then exec scl enable rh-python36 arvados-docker-cleaner; else exec arvados-docker-cleaner; fi'
-
-# systemd<=219 (centos:7, debian:8, ubuntu:trusty) obeys StartLimitInterval in the [Service] section
-StartLimitInterval=0
+ExecStart=/usr/bin/arvados-docker-cleaner
 
 [Install]
 WantedBy=multi-user.target
index 2a0e8b9108608df05dd3fe38e55f8311d82747f7..df624698ba4407f7a2a61aacd578eed5ca3c6cee 100755 (executable)
@@ -362,7 +362,7 @@ def main(arguments=sys.argv[1:]):
     config = load_config(arguments)
     configure_logging(config)
     try:
-        run(config, docker.Client(version='1.14'))
+        run(config, docker.APIClient(version='1.35'))
     except KeyboardInterrupt:
         sys.exit(1)
 
index 38e6f564e717d23dc217d66f59465ad584deb4b7..794b6afe4261cba9c6bfc4c5dd3fee9d6bb6c19b 100644 (file)
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+#
+# This file runs in one of three modes:
+#
+# 1. If the ARVADOS_BUILDING_VERSION environment variable is set, it writes
+#    _version.py and generates dependencies based on that value.
+# 2. If running from an arvados Git checkout, it writes _version.py
+#    and generates dependencies from Git.
+# 3. Otherwise, we expect this is source previously generated from Git, and
+#    it reads _version.py and generates dependencies from it.
 
-import subprocess
-import time
 import os
 import re
+import runpy
+import subprocess
 import sys
 
-SETUP_DIR = os.path.dirname(os.path.abspath(__file__))
-VERSION_PATHS = {
-        SETUP_DIR,
-        os.path.abspath(os.path.join(SETUP_DIR, "../../build/version-at-commit.sh"))
-        }
+from pathlib import Path
+
+# These maps explain the relationships between different Python modules in
+# the arvados repository. We use these to help generate setup.py.
+PACKAGE_DEPENDENCY_MAP = {
+    'arvados-cwl-runner': ['arvados-python-client', 'crunchstat_summary'],
+    'arvados-user-activity': ['arvados-python-client'],
+    'arvados_fuse': ['arvados-python-client'],
+    'crunchstat_summary': ['arvados-python-client'],
+}
+PACKAGE_MODULE_MAP = {
+    'arvados-cwl-runner': 'arvados_cwl',
+    'arvados-docker-cleaner': 'arvados_docker',
+    'arvados-python-client': 'arvados',
+    'arvados-user-activity': 'arvados_user_activity',
+    'arvados_fuse': 'arvados_fuse',
+    'crunchstat_summary': 'crunchstat_summary',
+}
+PACKAGE_SRCPATH_MAP = {
+    'arvados-cwl-runner': Path('sdk', 'cwl'),
+    'arvados-docker-cleaner': Path('services', 'dockercleaner'),
+    'arvados-python-client': Path('sdk', 'python'),
+    'arvados-user-activity': Path('tools', 'user-activity'),
+    'arvados_fuse': Path('services', 'fuse'),
+    'crunchstat_summary': Path('tools', 'crunchstat-summary'),
+}
+
+ENV_VERSION = os.environ.get("ARVADOS_BUILDING_VERSION")
+SETUP_DIR = Path(__file__).absolute().parent
+try:
+    REPO_PATH = Path(subprocess.check_output(
+        ['git', '-C', str(SETUP_DIR), 'rev-parse', '--show-toplevel'],
+        stderr=subprocess.DEVNULL,
+        text=True,
+    ).rstrip('\n'))
+except (subprocess.CalledProcessError, OSError):
+    REPO_PATH = None
+else:
+    # Verify this is the arvados monorepo
+    if all((REPO_PATH / path).exists() for path in PACKAGE_SRCPATH_MAP.values()):
+        PACKAGE_NAME, = (
+            pkg_name for pkg_name, path in PACKAGE_SRCPATH_MAP.items()
+            if (REPO_PATH / path) == SETUP_DIR
+        )
+        MODULE_NAME = PACKAGE_MODULE_MAP[PACKAGE_NAME]
+        VERSION_SCRIPT_PATH = Path(REPO_PATH, 'build', 'version-at-commit.sh')
+    else:
+        REPO_PATH = None
+if REPO_PATH is None:
+    (PACKAGE_NAME, MODULE_NAME), = (
+        (pkg_name, mod_name)
+        for pkg_name, mod_name in PACKAGE_MODULE_MAP.items()
+        if (SETUP_DIR / mod_name).is_dir()
+    )
+
+def short_tests_only(arglist=sys.argv):
+    try:
+        arglist.remove('--short-tests-only')
+    except ValueError:
+        return False
+    else:
+        return True
+
+def git_log_output(path, *args):
+    return subprocess.check_output(
+        ['git', '-C', str(REPO_PATH),
+         'log', '--first-parent', '--max-count=1',
+         *args, str(path)],
+        text=True,
+    ).rstrip('\n')
 
 def choose_version_from():
-    ts = {}
-    for path in VERSION_PATHS:
-        ts[subprocess.check_output(
-            ['git', 'log', '--first-parent', '--max-count=1',
-             '--format=format:%ct', path]).strip()] = path
-
-    sorted_ts = sorted(ts.items())
-    getver = sorted_ts[-1][1]
-    print("Using "+getver+" for version number calculation of "+SETUP_DIR, file=sys.stderr)
+    ver_paths = [SETUP_DIR, VERSION_SCRIPT_PATH, *(
+        PACKAGE_SRCPATH_MAP[pkg]
+        for pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ())
+    )]
+    getver = max(ver_paths, key=lambda path: git_log_output(path, '--format=format:%ct'))
+    print(f"Using {getver} for version number calculation of {SETUP_DIR}", file=sys.stderr)
     return getver
 
 def git_version_at_commit():
     curdir = choose_version_from()
-    myhash = subprocess.check_output(['git', 'log', '-n1', '--first-parent',
-                                       '--format=%H', curdir]).strip()
-    myversion = subprocess.check_output([SETUP_DIR+'/../../build/version-at-commit.sh', myhash]).strip().decode()
-    return myversion
+    myhash = git_log_output(curdir, '--format=%H')
+    return subprocess.check_output(
+        [str(VERSION_SCRIPT_PATH), myhash],
+        text=True,
+    ).rstrip('\n')
 
 def save_version(setup_dir, module, v):
-    v = v.replace("~dev", ".dev").replace("~rc", "rc")
-    with open(os.path.join(setup_dir, module, "_version.py"), 'wt') as fp:
-        return fp.write("__version__ = '%s'\n" % v)
+    with Path(setup_dir, module, '_version.py').open('w') as fp:
+        print(f"__version__ = {v!r}", file=fp)
 
 def read_version(setup_dir, module):
-    with open(os.path.join(setup_dir, module, "_version.py"), 'rt') as fp:
-        return re.match("__version__ = '(.*)'$", fp.read()).groups()[0]
-
-def get_version(setup_dir, module):
-    env_version = os.environ.get("ARVADOS_BUILDING_VERSION")
+    file_vars = runpy.run_path(Path(setup_dir, module, '_version.py'))
+    return file_vars['__version__']
 
-    if env_version:
-        save_version(setup_dir, module, env_version)
+def get_version(setup_dir=SETUP_DIR, module=MODULE_NAME):
+    if ENV_VERSION:
+        version = ENV_VERSION
+    elif REPO_PATH is None:
+        return read_version(setup_dir, module)
     else:
-        try:
-            save_version(setup_dir, module, git_version_at_commit())
-        except (subprocess.CalledProcessError, OSError) as err:
-            print("ERROR: {0}".format(err), file=sys.stderr)
-            pass
+        version = git_version_at_commit()
+    version = version.replace("~dev", ".dev").replace("~rc", "rc")
+    save_version(setup_dir, module, version)
+    return version
+
+def iter_dependencies(version=None):
+    if version is None:
+        version = get_version()
+    # A packaged development release should be installed with other
+    # development packages built from the same source, but those
+    # dependencies may have earlier "dev" versions (read: less recent
+    # Git commit timestamps). This compatible version dependency
+    # expresses that as closely as possible. Allowing versions
+    # compatible with .dev0 allows any development release.
+    # Regular expression borrowed partially from
+    # <https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers-regex>
+    dep_ver, match_count = re.subn(r'\.dev(0|[1-9][0-9]*)$', '.dev0', version, 1)
+    dep_op = '~=' if match_count else '=='
+    for dep_pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ()):
+        yield f'{dep_pkg}{dep_op}{dep_ver}'
 
-    return read_version(setup_dir, module)
+# Called from calculate_python_sdk_cwl_package_versions() in run-library.sh
+if __name__ == '__main__':
+    print(get_version())
index 3bafe9ba86de55447258a2c364d9feb44b0b195b..9c69879b45b581a7c5ab49f64ef0045a8f7177e6 100644 (file)
@@ -10,16 +10,10 @@ import re
 
 from setuptools import setup, find_packages
 
-SETUP_DIR = os.path.dirname(__file__) or '.'
-README = os.path.join(SETUP_DIR, 'README.rst')
-
 import arvados_version
-version = arvados_version.get_version(SETUP_DIR, "arvados_docker")
-
-short_tests_only = False
-if '--short-tests-only' in sys.argv:
-    short_tests_only = True
-    sys.argv.remove('--short-tests-only')
+version = arvados_version.get_version()
+short_tests_only = arvados_version.short_tests_only()
+README = os.path.join(arvados_version.SETUP_DIR, 'README.rst')
 
 setup(name="arvados-docker-cleaner",
       version=version,
@@ -37,13 +31,11 @@ setup(name="arvados-docker-cleaner",
           ('share/doc/arvados-docker-cleaner', ['agpl-3.0.txt', 'arvados-docker-cleaner.service']),
       ],
       install_requires=[
-          'docker-py==1.7.2',
+          *arvados_version.iter_dependencies(version),
+          'docker>=6.1.0',
           'setuptools',
       ],
-      tests_require=[
-          'pbr<1.7.0',
-          'mock',
-      ],
+      python_requires="~=3.8",
       test_suite='tests',
       zip_safe=False
 )
index 7580b0128a0382af6bd803be3da94ae162b31b62..cd03538fcd07f2181b44abef3ed91fb0bb64e8f1 100644 (file)
@@ -13,7 +13,7 @@ import time
 import unittest
 
 import docker
-import mock
+from unittest import mock
 
 from arvados_docker import cleaner
 
@@ -394,7 +394,7 @@ class RunTestCase(unittest.TestCase):
         self.assertEqual(event_kwargs[0]['until'], event_kwargs[1]['since'])
 
 
-@mock.patch('docker.Client', name='docker_client')
+@mock.patch('docker.APIClient', name='docker_client')
 @mock.patch('arvados_docker.cleaner.run', name='cleaner_run')
 class MainTestCase(unittest.TestCase):
 
@@ -404,11 +404,9 @@ class MainTestCase(unittest.TestCase):
             cf.flush()
             cleaner.main(['--config', cf.name])
         self.assertEqual(1, docker_client.call_count)
-        # 1.14 is the first version that's well defined, going back to
-        # Docker 1.2, and still supported up to at least Docker 1.9.
-        # See
-        # <https://docs.docker.com/engine/reference/api/docker_remote_api/>.
-        self.assertEqual('1.14',
+        # We are standardized on Docker API version 1.35.
+        # See DockerAPIVersion in lib/crunchrun/docker.go.
+        self.assertEqual('1.35',
                          docker_client.call_args[1].get('version'))
         self.assertEqual(1, run_mock.call_count)
         self.assertIs(run_mock.call_args[0][1], docker_client())
index e0d5046ae25e9cb7058e9bd85ba6673d2cbc8de8..12c6ae6ca187748b95868b0bc93a11f8169a5083 100644 (file)
@@ -21,17 +21,29 @@ Installation
 Installing under your user account
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-This method lets you install the package without root access.
-However, other users on the same system won't be able to use it.
+This method lets you install the package without root access.  However,
+other users on the same system will need to reconfigure their shell in order
+to be able to use it. Run the following to install the package in an
+environment at ``~/arvclients``::
 
-1. Run ``pip install --user arvados_fuse``.
+  python3 -m venv ~/arvclients
+  ~/arvclients/bin/pip install arvados_fuse
 
-2. In your shell configuration, make sure you add ``$HOME/.local/bin``
-   to your PATH environment variable.  For example, you could add the
-   command ``PATH=$PATH:$HOME/.local/bin`` to your ``.bashrc`` file.
+Command line tools will be installed under ``~/arvclients/bin``. You can
+test one by running::
 
-3. Reload your shell configuration.  For example, bash users could run
-   ``source ~/.bashrc``.
+  ~/arvclients/bin/arv-mount --version
+
+You can run these tools by specifying the full path every time, or you can
+add the directory to your shell's search path by running::
+
+  export PATH="$PATH:$HOME/arvclients/bin"
+
+You can make this search path change permanent by adding this command to
+your shell's configuration, for example ``~/.bashrc`` if you're using bash.
+You can test the change by running::
+
+  arv-mount --version
 
 Installing on Debian systems
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
index 31afcda8d12267970631372014706793ef95c9f3..d827aefab70a3292780799721766c6fea002c52e 100644 (file)
@@ -47,16 +47,15 @@ The general FUSE operation flow is as follows:
 The FUSE driver supports the Arvados event bus.  When an event is received for
 an object that is live in the inode cache, that object is immediately updated.
 
+Implementation note: in the code, the terms 'object', 'entry' and
+'inode' are used somewhat interchangeably, but generally mean an
+arvados_fuse.File or arvados_fuse.Directory object which has numeric
+inode assigned to it and appears in the Inodes._entries dictionary.
+
 """
 
 from __future__ import absolute_import
 from __future__ import division
-from future.utils import viewitems
-from future.utils import native
-from future.utils import listvalues
-from future.utils import listitems
-from future import standard_library
-standard_library.install_aliases()
 from builtins import next
 from builtins import str
 from builtins import object
@@ -76,22 +75,11 @@ import functools
 import arvados.keep
 from prometheus_client import Summary
 import queue
-
-# Default _notify_queue has a limit of 1000 items, but it really needs to be
-# unlimited to avoid deadlocks, see https://arvados.org/issues/3198#note-43 for
-# details.
-
-if hasattr(llfuse, 'capi'):
-    # llfuse < 0.42
-    llfuse.capi._notify_queue = queue.Queue()
-else:
-    # llfuse >= 0.42
-    llfuse._notify_queue = queue.Queue()
-
-LLFUSE_VERSION_0 = llfuse.__version__.startswith('0')
+from dataclasses import dataclass
+import typing
 
 from .fusedir import Directory, CollectionDirectory, TmpCollectionDirectory, MagicDirectory, TagsDirectory, ProjectDirectory, SharedDirectory, CollectionDirectoryBase
-from .fusefile import StringFile, FuseArvadosFile
+from .fusefile import File, StringFile, FuseArvadosFile
 
 _logger = logging.getLogger('arvados.arvados_fuse')
 
@@ -128,28 +116,47 @@ class FileHandle(Handle):
 
 class DirectoryHandle(Handle):
     """Connects a numeric file handle to a Directory object that has
-    been opened by the client."""
+    been opened by the client.
+
+    DirectoryHandle is used by opendir() and readdir() to get
+    directory listings.  Entries returned by readdir() don't increment
+    the lookup count (kernel references), so increment our internal
+    "use count" to avoid having an item being removed mid-read.
+
+    """
 
     def __init__(self, fh, dirobj, entries):
         super(DirectoryHandle, self).__init__(fh, dirobj)
         self.entries = entries
 
+        for ent in self.entries:
+            ent[1].inc_use()
+
+    def release(self):
+        for ent in self.entries:
+            ent[1].dec_use()
+        super(DirectoryHandle, self).release()
+
 
 class InodeCache(object):
     """Records the memory footprint of objects and when they are last used.
 
-    When the cache limit is exceeded, the least recently used objects are
-    cleared.  Clearing the object means discarding its contents to release
-    memory.  The next time the object is accessed, it must be re-fetched from
-    the server.  Note that the inode cache limit is a soft limit; the cache
-    limit may be exceeded if necessary to load very large objects, it may also
-    be exceeded if open file handles prevent objects from being cleared.
+    When the cache limit is exceeded, the least recently used objects
+    are cleared.  Clearing the object means discarding its contents to
+    release memory.  The next time the object is accessed, it must be
+    re-fetched from the server.  Note that the inode cache limit is a
+    soft limit; the cache limit may be exceeded if necessary to load
+    very large projects or collections, it may also be exceeded if an
+    inode can't be safely discarded based on kernel lookups
+    (has_ref()) or internal use count (in_use()).
 
     """
 
     def __init__(self, cap, min_entries=4):
-        self._entries = collections.OrderedDict()
-        self._by_uuid = {}
+        # Standard dictionaries are ordered, but OrderedDict is still better here, see
+        # https://docs.python.org/3.11/library/collections.html#ordereddict-objects
+        # specifically we use move_to_end() which standard dicts don't have.
+        self._cache_entries = collections.OrderedDict()
         self.cap = cap
         self._total = 0
         self.min_entries = min_entries
@@ -157,104 +164,148 @@ class InodeCache(object):
     def total(self):
         return self._total
 
-    def _remove(self, obj, clear):
-        if clear:
-            # Kernel behavior seems to be that if a file is
-            # referenced, its parents remain referenced too. This
-            # means has_ref() exits early when a collection is not
-            # candidate for eviction.
-            #
-            # By contrast, in_use() doesn't increment references on
-            # parents, so it requires a full tree walk to determine if
-            # a collection is a candidate for eviction.  This takes
-            # .07s for 240000 files, which becomes a major drag when
-            # cap_cache is being called several times a second and
-            # there are multiple non-evictable collections in the
-            # cache.
-            #
-            # So it is important for performance that we do the
-            # has_ref() check first.
-
-            if obj.has_ref(True):
-                _logger.debug("InodeCache cannot clear inode %i, still referenced", obj.inode)
-                return
+    def evict_candidates(self):
+        """Yield entries that are candidates to be evicted
+        and stop when the cache total has shrunk sufficiently.
 
-            if obj.in_use():
-                _logger.debug("InodeCache cannot clear inode %i, in use", obj.inode)
-                return
+        Implements a LRU cache, when an item is added or touch()ed it
+        goes to the back of the OrderedDict, so items in the front are
+        oldest.  The Inodes._remove() function determines if the entry
+        can actually be removed safely.
 
-            obj.kernel_invalidate()
-            _logger.debug("InodeCache sent kernel invalidate inode %i", obj.inode)
-            obj.clear()
+        """
 
-        # The llfuse lock is released in del_entry(), which is called by
-        # Directory.clear().  While the llfuse lock is released, it can happen
-        # that a reentrant call removes this entry before this call gets to it.
-        # Ensure that the entry is still valid before trying to remove it.
-        if obj.inode not in self._entries:
+        if self._total <= self.cap:
             return
 
-        self._total -= obj.cache_size
-        del self._entries[obj.inode]
-        if obj.cache_uuid:
-            self._by_uuid[obj.cache_uuid].remove(obj)
-            if not self._by_uuid[obj.cache_uuid]:
-                del self._by_uuid[obj.cache_uuid]
-            obj.cache_uuid = None
-        if clear:
-            _logger.debug("InodeCache cleared inode %i total now %i", obj.inode, self._total)
+        _logger.debug("InodeCache evict_candidates total %i cap %i entries %i", self._total, self.cap, len(self._cache_entries))
 
-    def cap_cache(self):
-        if self._total > self.cap:
-            for ent in listvalues(self._entries):
-                if self._total < self.cap or len(self._entries) < self.min_entries:
-                    break
-                self._remove(ent, True)
-
-    def manage(self, obj):
-        if obj.persisted():
-            obj.cache_size = obj.objsize()
-            self._entries[obj.inode] = obj
-            obj.cache_uuid = obj.uuid()
-            if obj.cache_uuid:
-                if obj.cache_uuid not in self._by_uuid:
-                    self._by_uuid[obj.cache_uuid] = [obj]
-                else:
-                    if obj not in self._by_uuid[obj.cache_uuid]:
-                        self._by_uuid[obj.cache_uuid].append(obj)
-            self._total += obj.objsize()
-            _logger.debug("InodeCache touched inode %i (size %i) (uuid %s) total now %i (%i entries)",
-                          obj.inode, obj.objsize(), obj.cache_uuid, self._total, len(self._entries))
-            self.cap_cache()
+        # Copy this into a deque for two reasons:
+        #
+        # 1. _cache_entries is modified by unmanage() which is called
+        # by _remove
+        #
+        # 2. popping off the front means the reference goes away
+        # immediately intead of sticking around for the lifetime of
+        # "values"
+        values = collections.deque(self._cache_entries.values())
 
-    def touch(self, obj):
-        if obj.persisted():
-            if obj.inode in self._entries:
-                self._remove(obj, False)
-            self.manage(obj)
+        while values:
+            if self._total < self.cap or len(self._cache_entries) < self.min_entries:
+                break
+            yield values.popleft()
 
-    def unmanage(self, obj):
-        if obj.persisted() and obj.inode in self._entries:
-            self._remove(obj, True)
+    def unmanage(self, entry):
+        """Stop managing an object in the cache.
 
-    def find_by_uuid(self, uuid):
-        return self._by_uuid.get(uuid, [])
+        This happens when an object is being removed from the inode
+        entries table.
+
+        """
+
+        if entry.inode not in self._cache_entries:
+            return
+
+        # manage cache size running sum
+        self._total -= entry.cache_size
+        entry.cache_size = 0
+
+        # Now forget about it
+        del self._cache_entries[entry.inode]
+
+    def update_cache_size(self, obj):
+        """Update the cache total in response to the footprint of an
+        object changing (usually because it has been loaded or
+        cleared).
+
+        Adds or removes entries to the cache list based on the object
+        cache size.
+
+        """
+
+        if not obj.persisted():
+            return
+
+        if obj.inode in self._cache_entries:
+            self._total -= obj.cache_size
+
+        obj.cache_size = obj.objsize()
+
+        if obj.cache_size > 0 or obj.parent_inode is None:
+            self._total += obj.cache_size
+            self._cache_entries[obj.inode] = obj
+        elif obj.cache_size == 0 and obj.inode in self._cache_entries:
+            del self._cache_entries[obj.inode]
+
+    def touch(self, obj):
+        """Indicate an object was used recently, making it low
+        priority to be removed from the cache.
+
+        """
+        if obj.inode in self._cache_entries:
+            self._cache_entries.move_to_end(obj.inode)
+            return True
+        return False
 
     def clear(self):
-        self._entries.clear()
-        self._by_uuid.clear()
+        self._cache_entries.clear()
         self._total = 0
 
+@dataclass
+class RemoveInode:
+    entry: typing.Union[Directory, File]
+    def inode_op(self, inodes, locked_ops):
+        if locked_ops is None:
+            inodes._remove(self.entry)
+            return True
+        else:
+            locked_ops.append(self)
+            return False
+
+@dataclass
+class InvalidateInode:
+    inode: int
+    def inode_op(self, inodes, locked_ops):
+        llfuse.invalidate_inode(self.inode)
+        return True
+
+@dataclass
+class InvalidateEntry:
+    inode: int
+    name: str
+    def inode_op(self, inodes, locked_ops):
+        llfuse.invalidate_entry(self.inode, self.name)
+        return True
+
+@dataclass
+class EvictCandidates:
+    def inode_op(self, inodes, locked_ops):
+        return True
+
+
 class Inodes(object):
-    """Manage the set of inodes.  This is the mapping from a numeric id
-    to a concrete File or Directory object"""
+    """Manage the set of inodes.
+
+    This is the mapping from a numeric id to a concrete File or
+    Directory object
 
-    def __init__(self, inode_cache, encoding="utf-8"):
+    """
+
+    def __init__(self, inode_cache, encoding="utf-8", fsns=None, shutdown_started=None):
         self._entries = {}
         self._counter = itertools.count(llfuse.ROOT_INODE)
         self.inode_cache = inode_cache
         self.encoding = encoding
-        self.deferred_invalidations = []
+        self._fsns = fsns
+        self._shutdown_started = shutdown_started or threading.Event()
+
+        self._inode_remove_queue = queue.Queue()
+        self._inode_remove_thread = threading.Thread(None, self._inode_remove)
+        self._inode_remove_thread.daemon = True
+        self._inode_remove_thread.start()
+
+        self.cap_cache_event = threading.Event()
+        self._by_uuid = collections.defaultdict(list)
 
     def __getitem__(self, item):
         return self._entries[item]
@@ -266,50 +317,196 @@ class Inodes(object):
         return iter(self._entries.keys())
 
     def items(self):
-        return viewitems(self._entries.items())
+        return self._entries.items()
 
     def __contains__(self, k):
         return k in self._entries
 
     def touch(self, entry):
+        """Update the access time, adjust the cache position, and
+        notify the _inode_remove thread to recheck the cache.
+
+        """
+
         entry._atime = time.time()
-        self.inode_cache.touch(entry)
+        if self.inode_cache.touch(entry):
+            self.cap_cache()
+
+    def cap_cache(self):
+        """Notify the _inode_remove thread to recheck the cache."""
+        if not self.cap_cache_event.is_set():
+            self.cap_cache_event.set()
+            self._inode_remove_queue.put(EvictCandidates())
+
+    def update_uuid(self, entry):
+        """Update the Arvados uuid associated with an inode entry.
+
+        This is used to look up inodes that need to be invalidated
+        when a websocket event indicates the object has changed on the
+        API server.
+
+        """
+        if entry.cache_uuid and entry in self._by_uuid[entry.cache_uuid]:
+            self._by_uuid[entry.cache_uuid].remove(entry)
+
+        entry.cache_uuid = entry.uuid()
+        if entry.cache_uuid and entry not in self._by_uuid[entry.cache_uuid]:
+            self._by_uuid[entry.cache_uuid].append(entry)
+
+        if not self._by_uuid[entry.cache_uuid]:
+            del self._by_uuid[entry.cache_uuid]
 
     def add_entry(self, entry):
+        """Assign a numeric inode to a new entry."""
+
         entry.inode = next(self._counter)
         if entry.inode == llfuse.ROOT_INODE:
             entry.inc_ref()
         self._entries[entry.inode] = entry
-        self.inode_cache.manage(entry)
+
+        self.update_uuid(entry)
+        self.inode_cache.update_cache_size(entry)
+        self.cap_cache()
         return entry
 
     def del_entry(self, entry):
-        if entry.ref_count == 0:
-            self.inode_cache.unmanage(entry)
-            del self._entries[entry.inode]
+        """Remove entry from the inode table.
+
+        Indicate this inode entry is pending deletion by setting
+        parent_inode to None.  Notify the _inode_remove thread to try
+        and remove it.
+
+        """
+
+        entry.parent_inode = None
+        self._inode_remove_queue.put(RemoveInode(entry))
+        _logger.debug("del_entry on inode %i with refcount %i", entry.inode, entry.ref_count)
+
+    def _inode_remove(self):
+        """Background thread to handle tasks related to invalidating
+        inodes in the kernel, and removing objects from the inodes
+        table entirely.
+
+        """
+
+        locked_ops = collections.deque()
+        while True:
+            blocking_get = True
+            while True:
+                try:
+                    qentry = self._inode_remove_queue.get(blocking_get)
+                except queue.Empty:
+                    break
+                blocking_get = False
+                if qentry is None:
+                    return
+
+                if self._shutdown_started.is_set():
+                    continue
+
+                # Process this entry
+                if qentry.inode_op(self, locked_ops):
+                    self._inode_remove_queue.task_done()
+
+                # Give up the reference
+                qentry = None
+
+            with llfuse.lock:
+                while locked_ops:
+                    if locked_ops.popleft().inode_op(self, None):
+                        self._inode_remove_queue.task_done()
+                self.cap_cache_event.clear()
+                for entry in self.inode_cache.evict_candidates():
+                    self._remove(entry)
+
+    def wait_remove_queue_empty(self):
+        # used by tests
+        self._inode_remove_queue.join()
+
+    def _remove(self, entry):
+        """Remove an inode entry if possible.
+
+        If the entry is still referenced or in use, don't do anything.
+        If this is not referenced but the parent is still referenced,
+        clear any data held by the object (which may include directory
+        entries under the object) but don't remove it from the inode
+        table.
+
+        """
+        try:
+            if entry.inode is None:
+                # Removed already
+                return
+
+            if entry.inode == llfuse.ROOT_INODE:
+                return
+
+            if entry.in_use():
+                # referenced internally, stay pinned
+                #_logger.debug("InodeCache cannot clear inode %i, in use", entry.inode)
+                return
+
+            # Tell the kernel it should forget about it
+            entry.kernel_invalidate()
+
+            if entry.has_ref():
+                # has kernel reference, could still be accessed.
+                # when the kernel forgets about it, we can delete it.
+                #_logger.debug("InodeCache cannot clear inode %i, is referenced", entry.inode)
+                return
+
+            # commit any pending changes
             with llfuse.lock_released:
                 entry.finalize()
-            entry.inode = None
-        else:
-            entry.dead = True
-            _logger.debug("del_entry on inode %i with refcount %i", entry.inode, entry.ref_count)
+
+            # Clear the contents
+            entry.clear()
+
+            if entry.parent_inode is None:
+                _logger.debug("InodeCache forgetting inode %i, object cache_size %i, cache total %i, forget_inode True, inode entries %i, type %s",
+                              entry.inode, entry.cache_size, self.inode_cache.total(),
+                              len(self._entries), type(entry))
+
+                if entry.cache_uuid:
+                    self._by_uuid[entry.cache_uuid].remove(entry)
+                    if not self._by_uuid[entry.cache_uuid]:
+                        del self._by_uuid[entry.cache_uuid]
+                    entry.cache_uuid = None
+
+                self.inode_cache.unmanage(entry)
+
+                del self._entries[entry.inode]
+                entry.inode = None
+
+        except Exception as e:
+            _logger.exception("failed remove")
 
     def invalidate_inode(self, entry):
-        if entry.has_ref(False):
+        if entry.has_ref():
             # Only necessary if the kernel has previously done a lookup on this
             # inode and hasn't yet forgotten about it.
-            llfuse.invalidate_inode(entry.inode)
+            self._inode_remove_queue.put(InvalidateInode(entry.inode))
 
     def invalidate_entry(self, entry, name):
-        if entry.has_ref(False):
+        if entry.has_ref():
             # Only necessary if the kernel has previously done a lookup on this
             # inode and hasn't yet forgotten about it.
-            llfuse.invalidate_entry(entry.inode, native(name.encode(self.encoding)))
+            self._inode_remove_queue.put(InvalidateEntry(entry.inode, name.encode(self.encoding)))
+
+    def begin_shutdown(self):
+        self._inode_remove_queue.put(None)
+        if self._inode_remove_thread is not None:
+            self._inode_remove_thread.join()
+        self._inode_remove_thread = None
 
     def clear(self):
+        with llfuse.lock_released:
+            self.begin_shutdown()
+
         self.inode_cache.clear()
+        self._by_uuid.clear()
 
-        for k,v in viewitems(self._entries):
+        for k,v in self._entries.items():
             try:
                 v.finalize()
             except Exception as e:
@@ -317,6 +514,14 @@ class Inodes(object):
 
         self._entries.clear()
 
+    def forward_slash_subst(self):
+        return self._fsns
+
+    def find_by_uuid(self, uuid):
+        """Return a list of zero or more inode entries corresponding
+        to this Arvados UUID."""
+        return self._by_uuid.get(uuid, [])
+
 
 def catch_exceptions(orig_func):
     """Catch uncaught exceptions and log them consistently."""
@@ -377,14 +582,32 @@ class Operations(llfuse.Operations):
     rename_time = fuse_time.labels(op='rename')
     flush_time = fuse_time.labels(op='flush')
 
-    def __init__(self, uid, gid, api_client, encoding="utf-8", inode_cache=None, num_retries=4, enable_write=False):
+    def __init__(self, uid, gid, api_client, encoding="utf-8", inode_cache=None, num_retries=4, enable_write=False, fsns=None):
         super(Operations, self).__init__()
 
         self._api_client = api_client
 
         if not inode_cache:
             inode_cache = InodeCache(cap=256*1024*1024)
-        self.inodes = Inodes(inode_cache, encoding=encoding)
+
+        if fsns is None:
+            try:
+                fsns = self._api_client.config()["Collections"]["ForwardSlashNameSubstitution"]
+            except KeyError:
+                # old API server with no FSNS config
+                fsns = '_'
+            else:
+                if fsns == '' or fsns == '/':
+                    fsns = None
+
+        # If we get overlapping shutdown events (e.g., fusermount -u
+        # -z and operations.destroy()) llfuse calls forget() on inodes
+        # that have already been deleted. To avoid this, we make
+        # forget() a no-op if called after destroy().
+        self._shutdown_started = threading.Event()
+
+        self.inodes = Inodes(inode_cache, encoding=encoding, fsns=fsns,
+                             shutdown_started=self._shutdown_started)
         self.uid = uid
         self.gid = gid
         self.enable_write = enable_write
@@ -397,12 +620,6 @@ class Operations(llfuse.Operations):
         # is fully initialized should wait() on this event object.
         self.initlock = threading.Event()
 
-        # If we get overlapping shutdown events (e.g., fusermount -u
-        # -z and operations.destroy()) llfuse calls forget() on inodes
-        # that have already been deleted. To avoid this, we make
-        # forget() a no-op if called after destroy().
-        self._shutdown_started = threading.Event()
-
         self.num_retries = num_retries
 
         self.read_counter = arvados.keep.Counter()
@@ -438,23 +655,26 @@ class Operations(llfuse.Operations):
     def metric_count_func(self, opname):
         return lambda: int(self.metric_value(opname, "arvmount_fuse_operations_seconds_count"))
 
+    def begin_shutdown(self):
+        self._shutdown_started.set()
+        self.inodes.begin_shutdown()
+
     @destroy_time.time()
     @catch_exceptions
     def destroy(self):
-        self._shutdown_started.set()
+        _logger.debug("arv-mount destroy: start")
+
+        with llfuse.lock_released:
+            self.begin_shutdown()
+
         if self.events:
             self.events.close()
             self.events = None
 
-        # Different versions of llfuse require and forbid us to
-        # acquire the lock here. See #8345#note-37, #10805#note-9.
-        if LLFUSE_VERSION_0 and llfuse.lock.acquire():
-            # llfuse < 0.42
-            self.inodes.clear()
-            llfuse.lock.release()
-        else:
-            # llfuse >= 0.42
-            self.inodes.clear()
+        self.inodes.clear()
+
+        _logger.debug("arv-mount destroy: complete")
+
 
     def access(self, inode, mode, ctx):
         return True
@@ -475,28 +695,34 @@ class Operations(llfuse.Operations):
             old_attrs = properties.get("old_attributes") or {}
             new_attrs = properties.get("new_attributes") or {}
 
-            for item in self.inodes.inode_cache.find_by_uuid(ev["object_uuid"]):
+            for item in self.inodes.find_by_uuid(ev["object_uuid"]):
                 item.invalidate()
 
             oldowner = old_attrs.get("owner_uuid")
             newowner = ev.get("object_owner_uuid")
             for parent in (
-                    self.inodes.inode_cache.find_by_uuid(oldowner) +
-                    self.inodes.inode_cache.find_by_uuid(newowner)):
+                    self.inodes.find_by_uuid(oldowner) +
+                    self.inodes.find_by_uuid(newowner)):
                 parent.invalidate()
 
     @getattr_time.time()
     @catch_exceptions
     def getattr(self, inode, ctx=None):
         if inode not in self.inodes:
+            _logger.debug("arv-mount getattr: inode %i missing", inode)
             raise llfuse.FUSEError(errno.ENOENT)
 
         e = self.inodes[inode]
+        self.inodes.touch(e)
+        parent = None
+        if e.parent_inode:
+            parent = self.inodes[e.parent_inode]
+            self.inodes.touch(parent)
 
         entry = llfuse.EntryAttributes()
         entry.st_ino = inode
         entry.generation = 0
-        entry.entry_timeout = 0
+        entry.entry_timeout = parent.time_to_next_poll() if parent is not None else 0
         entry.attr_timeout = e.time_to_next_poll() if e.allow_attr_cache else 0
 
         entry.st_mode = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH
@@ -564,18 +790,23 @@ class Operations(llfuse.Operations):
 
         if name == '.':
             inode = parent_inode
-        else:
-            if parent_inode in self.inodes:
-                p = self.inodes[parent_inode]
-                self.inodes.touch(p)
-                if name == '..':
-                    inode = p.parent_inode
-                elif isinstance(p, Directory) and name in p:
-                    inode = p[name].inode
+        elif parent_inode in self.inodes:
+            p = self.inodes[parent_inode]
+            self.inodes.touch(p)
+            if name == '..':
+                inode = p.parent_inode
+            elif isinstance(p, Directory) and name in p:
+                if p[name].inode is None:
+                    _logger.debug("arv-mount lookup: parent_inode %i name '%s' found but inode was None",
+                                  parent_inode, name)
+                    raise llfuse.FUSEError(errno.ENOENT)
+
+                inode = p[name].inode
 
         if inode != None:
             _logger.debug("arv-mount lookup: parent_inode %i name '%s' inode %i",
                       parent_inode, name, inode)
+            self.inodes.touch(self.inodes[inode])
             self.inodes[inode].inc_ref()
             return self.getattr(inode)
         else:
@@ -591,7 +822,7 @@ class Operations(llfuse.Operations):
         for inode, nlookup in inodes:
             ent = self.inodes[inode]
             _logger.debug("arv-mount forget: inode %i nlookup %i ref_count %i", inode, nlookup, ent.ref_count)
-            if ent.dec_ref(nlookup) == 0 and ent.dead:
+            if ent.dec_ref(nlookup) == 0 and ent.parent_inode is None:
                 self.inodes.del_entry(ent)
 
     @open_time.time()
@@ -600,6 +831,7 @@ class Operations(llfuse.Operations):
         if inode in self.inodes:
             p = self.inodes[inode]
         else:
+            _logger.debug("arv-mount open: inode %i missing", inode)
             raise llfuse.FUSEError(errno.ENOENT)
 
         if isinstance(p, Directory):
@@ -681,7 +913,7 @@ class Operations(llfuse.Operations):
             finally:
                 self._filehandles[fh].release()
                 del self._filehandles[fh]
-        self.inodes.inode_cache.cap_cache()
+        self.inodes.cap_cache()
 
     def releasedir(self, fh):
         self.release(fh)
@@ -694,6 +926,7 @@ class Operations(llfuse.Operations):
         if inode in self.inodes:
             p = self.inodes[inode]
         else:
+            _logger.debug("arv-mount opendir: called with unknown or removed inode %i", inode)
             raise llfuse.FUSEError(errno.ENOENT)
 
         if not isinstance(p, Directory):
@@ -703,11 +936,16 @@ class Operations(llfuse.Operations):
         if p.parent_inode in self.inodes:
             parent = self.inodes[p.parent_inode]
         else:
+            _logger.warning("arv-mount opendir: parent inode %i of %i is missing", p.parent_inode, inode)
             raise llfuse.FUSEError(errno.EIO)
 
+        _logger.debug("arv-mount opendir: inode %i fh %i ", inode, fh)
+
         # update atime
+        p.inc_use()
+        self._filehandles[fh] = DirectoryHandle(fh, p, [('.', p), ('..', parent)] + p.items())
+        p.dec_use()
         self.inodes.touch(p)
-        self._filehandles[fh] = DirectoryHandle(fh, p, [('.', p), ('..', parent)] + listitems(p))
         return fh
 
     @readdir_time.time()
@@ -722,8 +960,9 @@ class Operations(llfuse.Operations):
 
         e = off
         while e < len(handle.entries):
-            if handle.entries[e][1].inode in self.inodes:
-                yield (handle.entries[e][0].encode(self.inodes.encoding), self.getattr(handle.entries[e][1].inode), e+1)
+            ent = handle.entries[e]
+            if ent[1].inode in self.inodes:
+                yield (ent[0].encode(self.inodes.encoding), self.getattr(ent[1].inode), e+1)
             e += 1
 
     @statfs_time.time()
index e275825a6109126b15dca1941a55b306ba98b8b9..f52121d862b60ed1e16ba94dc594a5f1a32feffc 100644 (file)
@@ -28,100 +28,336 @@ class ArgumentParser(argparse.ArgumentParser):
     def __init__(self):
         super(ArgumentParser, self).__init__(
             parents=[arv_cmd.retry_opt],
-            description='''Mount Keep data under the local filesystem.  Default mode is --home''',
-            epilog="""
-    Note: When using the --exec feature, you must either specify the
-    mountpoint before --exec, or mark the end of your --exec arguments
-    with "--".
-            """)
-        self.add_argument('--version', action='version',
-                          version=u"%s %s" % (sys.argv[0], __version__),
-                          help='Print version and exit.')
-        self.add_argument('mountpoint', type=str, help="""Mount point.""")
-        self.add_argument('--allow-other', action='store_true',
-                            help="""Let other users read the mount""")
-        self.add_argument('--subtype', type=str, metavar='STRING',
-                            help="""Report mounted filesystem type as "fuse.STRING", instead of just "fuse".""")
-
-        mode = self.add_mutually_exclusive_group()
-
-        mode.add_argument('--all', action='store_const', const='all', dest='mode',
-                                help="""Mount a subdirectory for each mode: home, shared, by_tag, by_id (default if no --mount-* arguments are given).""")
-        mode.add_argument('--custom', action='store_const', const=None, dest='mode',
-                                help="""Mount a top level meta-directory with subdirectories as specified by additional --mount-* arguments (default if any --mount-* arguments are given).""")
-        mode.add_argument('--home', action='store_const', const='home', dest='mode',
-                                help="""Mount only the user's home project.""")
-        mode.add_argument('--shared', action='store_const', const='shared', dest='mode',
-                                help="""Mount only list of projects shared with the user.""")
-        mode.add_argument('--by-tag', action='store_const', const='by_tag', dest='mode',
-                                help="""Mount subdirectories listed by tag.""")
-        mode.add_argument('--by-id', action='store_const', const='by_id', dest='mode',
-                                help="""Mount subdirectories listed by portable data hash or uuid.""")
-        mode.add_argument('--by-pdh', action='store_const', const='by_pdh', dest='mode',
-                                help="""Mount subdirectories listed by portable data hash.""")
-        mode.add_argument('--project', type=str, metavar='UUID',
-                                help="""Mount the specified project.""")
-        mode.add_argument('--collection', type=str, metavar='UUID_or_PDH',
-                                help="""Mount only the specified collection.""")
-
-        mounts = self.add_argument_group('Custom mount options')
-        mounts.add_argument('--mount-by-pdh',
-                            type=str, metavar='PATH', action='append', default=[],
-                            help="Mount each readable collection at mountpoint/PATH/P where P is the collection's portable data hash.")
-        mounts.add_argument('--mount-by-id',
-                            type=str, metavar='PATH', action='append', default=[],
-                            help="Mount each readable collection at mountpoint/PATH/UUID and mountpoint/PATH/PDH where PDH is the collection's portable data hash and UUID is its UUID.")
-        mounts.add_argument('--mount-by-tag',
-                            type=str, metavar='PATH', action='append', default=[],
-                            help="Mount all collections with tag TAG at mountpoint/PATH/TAG/UUID.")
-        mounts.add_argument('--mount-home',
-                            type=str, metavar='PATH', action='append', default=[],
-                            help="Mount the current user's home project at mountpoint/PATH.")
-        mounts.add_argument('--mount-shared',
-                            type=str, metavar='PATH', action='append', default=[],
-                            help="Mount projects shared with the current user at mountpoint/PATH.")
-        mounts.add_argument('--mount-tmp',
-                            type=str, metavar='PATH', action='append', default=[],
-                            help="Create a new collection, mount it in read/write mode at mountpoint/PATH, and delete it when unmounting.")
-
-
-        self.add_argument('--debug', action='store_true', help="""Debug mode""")
-        self.add_argument('--logfile', help="""Write debug logs and errors to the specified file (default stderr).""")
-        self.add_argument('--foreground', action='store_true', help="""Run in foreground (default is to daemonize unless --exec specified)""", default=False)
-        self.add_argument('--encoding', type=str, help="Character encoding to use for filesystem, default is utf-8 (see Python codec registry for list of available encodings)", default="utf-8")
-
-        self.add_argument('--file-cache', type=int, help="File data cache size, in bytes (default 8 GiB for disk-based cache or 256 MiB with RAM-only cache)", default=0)
-        self.add_argument('--directory-cache', type=int, help="Directory data cache size, in bytes (default 128 MiB)", default=128*1024*1024)
-
-        cachetype = self.add_mutually_exclusive_group()
-        cachetype.add_argument('--ram-cache', action='store_false', dest='disk_cache', help="Use in-memory caching only", default=True)
-        cachetype.add_argument('--disk-cache', action='store_true', dest='disk_cache', help="Use disk based caching (default)", default=True)
-
-        self.add_argument('--disk-cache-dir', type=str, help="Disk cache location (default ~/.cache/arvados/keep)", default=None)
-
-        self.add_argument('--disable-event-listening', action='store_true', help="Don't subscribe to events on the API server", dest="disable_event_listening", default=False)
-
-        self.add_argument('--read-only', action='store_false', help="Mount will be read only (default)", dest="enable_write", default=False)
-        self.add_argument('--read-write', action='store_true', help="Mount will be read-write", dest="enable_write", default=False)
-        self.add_argument('--storage-classes', type=str, metavar='CLASSES', help="Specify comma separated list of storage classes to be used when saving data of new collections", default=None)
-
-        self.add_argument('--crunchstat-interval', type=float, help="Write stats to stderr every N seconds (default disabled)", default=0)
-
-        unmount = self.add_mutually_exclusive_group()
-        unmount.add_argument('--unmount', action='store_true', default=False,
-                             help="Forcefully unmount the specified mountpoint (if it's a fuse mount) and exit. If --subtype is given, unmount only if the mount has the specified subtype. WARNING: This command can affect any kind of fuse mount, not just arv-mount.")
-        unmount.add_argument('--unmount-all', action='store_true', default=False,
-                             help="Forcefully unmount every fuse mount at or below the specified path and exit. If --subtype is given, unmount only mounts that have the specified subtype. Exit non-zero if any other types of mounts are found at or below the given path. WARNING: This command can affect any kind of fuse mount, not just arv-mount.")
-        unmount.add_argument('--replace', action='store_true', default=False,
-                             help="If a fuse mount is already present at mountpoint, forcefully unmount it before mounting")
-        self.add_argument('--unmount-timeout',
-                          type=float, default=2.0,
-                          help="Time to wait for graceful shutdown after --exec program exits and filesystem is unmounted")
-
-        self.add_argument('--exec', type=str, nargs=argparse.REMAINDER,
-                            dest="exec_args", metavar=('command', 'args', '...', '--'),
-                            help="""Mount, run a command, then unmount and exit""")
-
+            description="Interact with Arvados data through a local filesystem",
+        )
+        self.add_argument(
+            '--version',
+            action='version',
+            version=u"%s %s" % (sys.argv[0], __version__),
+            help="Print version and exit",
+        )
+        self.add_argument(
+            'mountpoint',
+            metavar='MOUNT_DIR',
+            help="Directory path to mount data",
+        )
+
+        mode_group = self.add_argument_group("Mount contents")
+        mode = mode_group.add_mutually_exclusive_group()
+        mode.add_argument(
+            '--all',
+            action='store_const',
+            const='all',
+            dest='mode',
+            help="""
+Mount a subdirectory for each mode: `home`, `shared`, `by_id`, and `by_tag`
+(default if no `--mount-*` options are given)
+""",
+        )
+        mode.add_argument(
+            '--custom',
+            action='store_const',
+            const=None,
+            dest='mode',
+            help="""
+Mount a subdirectory for each mode specified by a `--mount-*` option
+(default if any `--mount-*` options are given;
+see "Mount custom layout and filtering" section)
+""",
+        )
+        mode.add_argument(
+            '--collection',
+            metavar='UUID_OR_PDH',
+            help="Mount the specified collection",
+        )
+        mode.add_argument(
+            '--home',
+            action='store_const',
+            const='home',
+            dest='mode',
+            help="Mount your home project",
+        )
+        mode.add_argument(
+            '--project',
+            metavar='UUID',
+            help="Mount the specified project",
+        )
+        mode.add_argument(
+            '--shared',
+            action='store_const',
+            const='shared',
+            dest='mode',
+            help="Mount a subdirectory for each project shared with you",
+        )
+        mode.add_argument(
+            '--by-id',
+            action='store_const',
+            const='by_id',
+            dest='mode',
+            help="""
+Mount a magic directory where collections and projects are accessible through
+subdirectories named after their UUID or portable data hash
+""",
+        )
+        mode.add_argument(
+            '--by-pdh',
+            action='store_const',
+            const='by_pdh',
+            dest='mode',
+            help="""
+Mount a magic directory where collections are accessible through
+subdirectories named after their portable data hash
+""",
+        )
+        mode.add_argument(
+            '--by-tag',
+            action='store_const',
+            const='by_tag',
+            dest='mode',
+            help="Mount a subdirectory for each tag attached to a collection or project",
+        )
+
+        mounts = self.add_argument_group("Mount custom layout and filtering")
+        mounts.add_argument(
+            '--filters',
+            type=arv_cmd.JSONArgument(arv_cmd.validate_filters),
+            help="""
+Filters to apply to all project, shared, and tag directory contents.
+Pass filters as either a JSON string or a path to a JSON file.
+The JSON object should be a list of filters in Arvados API list filter syntax.
+""",
+        )
+        mounts.add_argument(
+            '--mount-home',
+            metavar='PATH',
+            action='append',
+            default=[],
+            help="Make your home project available under the mount at `PATH`",
+        )
+        mounts.add_argument(
+            '--mount-shared',
+            metavar='PATH',
+            action='append',
+            default=[],
+            help="Make projects shared with you available under the mount at `PATH`",
+        )
+        mounts.add_argument(
+            '--mount-tmp',
+            metavar='PATH',
+            action='append',
+            default=[],
+            help="""
+Make a new temporary writable collection available under the mount at `PATH`.
+This collection is deleted when the mount is unmounted.
+""",
+        )
+        mounts.add_argument(
+            '--mount-by-id',
+            metavar='PATH',
+            action='append',
+            default=[],
+            help="""
+Make a magic directory available under the mount at `PATH` where collections and
+projects are accessible through subdirectories named after their UUID or
+portable data hash
+""",
+        )
+        mounts.add_argument(
+            '--mount-by-pdh',
+            metavar='PATH',
+            action='append',
+            default=[],
+            help="""
+Make a magic directory available under the mount at `PATH` where collections
+are accessible through subdirectories named after portable data hash
+""",
+        )
+        mounts.add_argument(
+            '--mount-by-tag',
+            metavar='PATH',
+            action='append',
+            default=[],
+            help="""
+Make a subdirectory for each tag attached to a collection or project available
+under the mount at `PATH`
+""" ,
+        )
+
+        perms = self.add_argument_group("Mount access and permissions")
+        perms.add_argument(
+            '--allow-other',
+            action='store_true',
+            help="Let other users on this system read mounted data (default false)",
+        )
+        perms.add_argument(
+            '--read-only',
+            action='store_false',
+            default=False,
+            dest='enable_write',
+            help="Mounted data cannot be modified from the mount (default)",
+        )
+        perms.add_argument(
+            '--read-write',
+            action='store_true',
+            default=False,
+            dest='enable_write',
+            help="Mounted data can be modified from the mount",
+        )
+
+        lifecycle = self.add_argument_group("Mount lifecycle management")
+        lifecycle.add_argument(
+            '--exec',
+            nargs=argparse.REMAINDER,
+            dest="exec_args",
+            help="""
+Mount data, run the specified command, then unmount and exit.
+`--exec` reads all remaining options as the command to run,
+so it must be the last option you specify.
+Either end your command arguments (and other options) with a `--` argument,
+or specify `--exec` after your mount point.
+""",
+        )
+        lifecycle.add_argument(
+            '--foreground',
+            action='store_true',
+            default=False,
+            help="Run mount process in the foreground instead of daemonizing (default false)",
+        )
+        lifecycle.add_argument(
+            '--subtype',
+            help="Set mounted filesystem type to `fuse.SUBTYPE` (default is just `fuse`)",
+        )
+        unmount = lifecycle.add_mutually_exclusive_group()
+        unmount.add_argument(
+            '--replace',
+            action='store_true',
+            default=False,
+            help="""
+If a FUSE mount is already mounted at the given directory,
+unmount it before mounting the requested data.
+If `--subtype` is specified, unmount only if the mount has that subtype.
+WARNING: This command can affect any kind of FUSE mount, not just arv-mount.
+""",
+        )
+        unmount.add_argument(
+            '--unmount',
+            action='store_true',
+            default=False,
+            help="""
+If a FUSE mount is already mounted at the given directory, unmount it and exit.
+If `--subtype` is specified, unmount only if the mount has that subtype.
+WARNING: This command can affect any kind of FUSE mount, not just arv-mount.
+""",
+        )
+        unmount.add_argument(
+            '--unmount-all',
+            action='store_true',
+            default=False,
+            help="""
+Unmount all FUSE mounts at or below the given directory, then exit.
+If `--subtype` is specified, unmount only if the mount has that subtype.
+WARNING: This command can affect any kind of FUSE mount, not just arv-mount.
+""",
+        )
+        lifecycle.add_argument(
+            '--unmount-timeout',
+            type=float,
+            default=2.0,
+            metavar='SECONDS',
+            help="""
+The number of seconds to wait for a clean unmount after an `--exec` command has
+exited (default %(default).01f).
+After this time, the mount will be forcefully unmounted.
+""",
+        )
+
+        reporting = self.add_argument_group("Mount logging and statistics")
+        reporting.add_argument(
+            '--crunchstat-interval',
+            type=float,
+            default=0.0,
+            metavar='SECONDS',
+            help="Write stats to stderr every N seconds (default disabled)",
+        )
+        reporting.add_argument(
+            '--debug',
+            action='store_true',
+            help="Log debug information",
+        )
+        reporting.add_argument(
+            '--logfile',
+            help="Write debug logs and errors to the specified file (default stderr)",
+        )
+
+        cache = self.add_argument_group("Mount local cache setup")
+        cachetype = cache.add_mutually_exclusive_group()
+        cachetype.add_argument(
+            '--disk-cache',
+            action='store_true',
+            default=True,
+            dest='disk_cache',
+            help="Cache data on the local filesystem (default)",
+        )
+        cachetype.add_argument(
+            '--ram-cache',
+            action='store_false',
+            default=True,
+            dest='disk_cache',
+            help="Cache data in memory",
+        )
+        cache.add_argument(
+            '--disk-cache-dir',
+            metavar="DIRECTORY",
+            help="Filesystem cache location (default `~/.cache/arvados/keep`)",
+        )
+        cache.add_argument(
+            '--directory-cache',
+            type=int,
+            default=128*1024*1024,
+            metavar='BYTES',
+            help="Size of directory data cache in bytes (default 128 MiB)",
+        )
+        cache.add_argument(
+            '--file-cache',
+            type=int,
+            default=0,
+            metavar='BYTES',
+            help="""
+Size of file data cache in bytes
+(default 8 GiB for filesystem cache, 256 MiB for memory cache)
+""",
+        )
+
+        plumbing = self.add_argument_group("Mount interactions with Arvados and Linux")
+        plumbing.add_argument(
+            '--disable-event-listening',
+            action='store_true',
+            dest='disable_event_listening',
+            default=False,
+            help="Don't subscribe to events on the API server to update mount contents",
+        )
+        plumbing.add_argument(
+            '--encoding',
+            default="utf-8",
+            help="""
+Filesystem character encoding
+(default %(default)r; specify a name from the Python codec registry)
+""",
+        )
+        plumbing.add_argument(
+            '--storage-classes',
+            metavar='CLASSES',
+            help="Comma-separated list of storage classes to request for new collections",
+        )
+        # This is a hidden argument used by tests.  Normally this
+        # value will be extracted from the cluster config, but mocking
+        # the cluster config under the presence of multiple threads
+        # and processes turned out to be too complicated and brittle.
+        plumbing.add_argument(
+            '--fsns',
+            type=str,
+            default=None,
+            help=argparse.SUPPRESS)
 
 class Mount(object):
     def __init__(self, args, logger=logging.getLogger('arvados.arv-mount')):
@@ -134,21 +370,40 @@ class Mount(object):
         if self.args.logfile:
             self.args.logfile = os.path.realpath(self.args.logfile)
 
+        try:
+            self._setup_logging()
+        except Exception as e:
+            self.logger.exception("exception during setup: %s", e)
+            exit(1)
+
         try:
             nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
-            if nofile_limit[0] < 10240:
-                resource.setrlimit(resource.RLIMIT_NOFILE, (min(10240, nofile_limit[1]), nofile_limit[1]))
+
+            minlimit = 10240
+            if self.args.file_cache:
+                # Adjust the file handle limit so it can meet
+                # the desired cache size. Multiply by 8 because the
+                # number of 64 MiB cache slots that keepclient
+                # allocates is RLIMIT_NOFILE / 8
+                minlimit = int((self.args.file_cache/(64*1024*1024)) * 8)
+
+            if nofile_limit[0] < minlimit:
+                resource.setrlimit(resource.RLIMIT_NOFILE, (min(minlimit, nofile_limit[1]), nofile_limit[1]))
+
+            if minlimit > nofile_limit[1]:
+                self.logger.warning("file handles required to meet --file-cache (%s) exceeds hard file handle limit (%s), cache size will be smaller than requested", minlimit, nofile_limit[1])
+
         except Exception as e:
-            self.logger.warning("arv-mount: unable to adjust file handle limit: %s", e)
+            self.logger.warning("unable to adjust file handle limit: %s", e)
 
-        self.logger.debug("arv-mount: file handle limit is %s", resource.getrlimit(resource.RLIMIT_NOFILE))
+        nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
+        self.logger.info("file cache capped at %s bytes or less based on available disk (RLIMIT_NOFILE is %s)", ((nofile_limit[0]//8)*64*1024*1024), nofile_limit)
 
         try:
-            self._setup_logging()
             self._setup_api()
             self._setup_mount()
         except Exception as e:
-            self.logger.exception("arv-mount: exception during setup: %s", e)
+            self.logger.exception("exception during setup: %s", e)
             exit(1)
 
     def __enter__(self):
@@ -228,14 +483,20 @@ class Mount(object):
 
     def _setup_api(self):
         try:
+            # default value of file_cache is 0, this tells KeepBlockCache to
+            # choose a default based on whether disk_cache is enabled or not.
+
+            block_cache = arvados.keep.KeepBlockCache(cache_max=self.args.file_cache,
+                                                      disk_cache=self.args.disk_cache,
+                                                      disk_cache_dir=self.args.disk_cache_dir)
+
             self.api = arvados.safeapi.ThreadSafeApiCache(
                 apiconfig=arvados.config.settings(),
-                # default value of file_cache is 0, this tells KeepBlockCache to
-                # choose a default based on whether disk_cache is enabled or not.
+                api_params={
+                    'num_retries': self.args.retries,
+                },
                 keep_params={
-                    'block_cache': arvados.keep.KeepBlockCache(cache_max=self.args.file_cache,
-                                                               disk_cache=self.args.disk_cache,
-                                                               disk_cache_dir=self.args.disk_cache_dir),
+                    'block_cache': block_cache,
                     'num_retries': self.args.retries,
                 },
                 version='v1',
@@ -253,7 +514,8 @@ class Mount(object):
             api_client=self.api,
             encoding=self.args.encoding,
             inode_cache=InodeCache(cap=self.args.directory_cache),
-            enable_write=self.args.enable_write)
+            enable_write=self.args.enable_write,
+            fsns=self.args.fsns)
 
         if self.args.crunchstat_interval:
             statsthread = threading.Thread(
@@ -267,7 +529,14 @@ class Mount(object):
         usr = self.api.users().current().execute(num_retries=self.args.retries)
         now = time.time()
         dir_class = None
-        dir_args = [llfuse.ROOT_INODE, self.operations.inodes, self.api, self.args.retries, self.args.enable_write]
+        dir_args = [
+            llfuse.ROOT_INODE,
+            self.operations.inodes,
+            self.api,
+            self.args.retries,
+            self.args.enable_write,
+            self.args.filters,
+        ]
         mount_readme = False
 
         storage_classes = None
@@ -333,7 +602,11 @@ class Mount(object):
             return
 
         e = self.operations.inodes.add_entry(Directory(
-            llfuse.ROOT_INODE, self.operations.inodes, self.api.config, self.args.enable_write))
+            llfuse.ROOT_INODE,
+            self.operations.inodes,
+            self.args.enable_write,
+            self.args.filters,
+        ))
         dir_args[0] = e.inode
 
         for name in self.args.mount_by_id:
@@ -415,8 +688,9 @@ From here, the following directories are available:
 
     def _llfuse_main(self):
         try:
-            llfuse.main()
+            llfuse.main(workers=10)
         except:
             llfuse.close(unmount=False)
             raise
+        self.operations.begin_shutdown()
         llfuse.close()
index 53214ee94d70b214f79e3cca5c5193a41ebe2567..508ee7fb73cd578dadd1afe74675531ca20af6ef 100644 (file)
@@ -62,7 +62,7 @@ class FreshBase(object):
     """
 
     __slots__ = ("_stale", "_poll", "_last_update", "_atime", "_poll_time", "use_count",
-                 "ref_count", "dead", "cache_size", "cache_uuid", "allow_attr_cache")
+                 "ref_count", "cache_size", "cache_uuid", "allow_attr_cache")
 
     def __init__(self):
         self._stale = True
@@ -72,7 +72,6 @@ class FreshBase(object):
         self._poll_time = 60
         self.use_count = 0
         self.ref_count = 0
-        self.dead = False
         self.cache_size = 0
         self.cache_uuid = None
 
@@ -125,17 +124,11 @@ class FreshBase(object):
         self.ref_count -= n
         return self.ref_count
 
-    def has_ref(self, only_children):
+    def has_ref(self):
         """Determine if there are any kernel references to this
-        object or its children.
-
-        If only_children is True, ignore refcount of self and only consider
-        children.
+        object.
         """
-        if only_children:
-            return False
-        else:
-            return self.ref_count > 0
+        return self.ref_count > 0
 
     def objsize(self):
         return 0
index f3816c0d3e783b6272c5abcc424641a4bb39d6dc..9c78805107358dadf8b2f87221154753399b2c63 100644 (file)
@@ -26,7 +26,7 @@ _logger = logging.getLogger('arvados.arvados_fuse')
 # Match any character which FUSE or Linux cannot accommodate as part
 # of a filename. (If present in a collection filename, they will
 # appear as underscores in the fuse mount.)
-_disallowed_filename_characters = re.compile('[\x00/]')
+_disallowed_filename_characters = re.compile(r'[\x00/]')
 
 
 class Directory(FreshBase):
@@ -36,7 +36,9 @@ class Directory(FreshBase):
     and the value referencing a File or Directory object.
     """
 
-    def __init__(self, parent_inode, inodes, apiconfig, enable_write):
+    __slots__ = ("inode", "parent_inode", "inodes", "_entries", "_mtime", "_enable_write", "_filters")
+
+    def __init__(self, parent_inode, inodes, enable_write, filters):
         """parent_inode is the integer inode number"""
 
         super(Directory, self).__init__()
@@ -46,28 +48,26 @@ class Directory(FreshBase):
             raise Exception("parent_inode should be an int")
         self.parent_inode = parent_inode
         self.inodes = inodes
-        self.apiconfig = apiconfig
         self._entries = {}
         self._mtime = time.time()
         self._enable_write = enable_write
-
-    def forward_slash_subst(self):
-        if not hasattr(self, '_fsns'):
-            self._fsns = None
-            config = self.apiconfig()
-            try:
-                self._fsns = config["Collections"]["ForwardSlashNameSubstitution"]
-            except KeyError:
-                # old API server with no FSNS config
-                self._fsns = '_'
+        self._filters = filters or []
+
+    def _filters_for(self, subtype, *, qualified):
+        for f in self._filters:
+            f_type, _, f_name = f[0].partition('.')
+            if not f_name:
+                yield f
+            elif f_type != subtype:
+                pass
+            elif qualified:
+                yield f
             else:
-                if self._fsns == '' or self._fsns == '/':
-                    self._fsns = None
-        return self._fsns
+                yield [f_name, *f[1:]]
 
     def unsanitize_filename(self, incoming):
         """Replace ForwardSlashNameSubstitution value with /"""
-        fsns = self.forward_slash_subst()
+        fsns = self.inodes.forward_slash_subst()
         if isinstance(fsns, str):
             return incoming.replace(fsns, '/')
         else:
@@ -86,7 +86,7 @@ class Directory(FreshBase):
         elif dirty == '..':
             return '__'
         else:
-            fsns = self.forward_slash_subst()
+            fsns = self.inodes.forward_slash_subst()
             if isinstance(fsns, str):
                 dirty = dirty.replace('/', fsns)
             return _disallowed_filename_characters.sub('_', dirty)
@@ -137,6 +137,10 @@ class Directory(FreshBase):
         self.inodes.touch(self)
         super(Directory, self).fresh()
 
+    def objsize(self):
+        # Rough estimate of memory footprint based on using pympler
+        return len(self._entries) * 1024
+
     def merge(self, items, fn, same, new_entry):
         """Helper method for updating the contents of the directory.
 
@@ -144,16 +148,17 @@ class Directory(FreshBase):
         entries that are the same in both the old and new lists, create new
         entries, and delete old entries missing from the new list.
 
-        :items: iterable with new directory contents
+        Arguments:
+        * items: Iterable --- New directory contents
 
-        :fn: function to take an entry in 'items' and return the desired file or
+        * fn: Callable --- Takes an entry in 'items' and return the desired file or
         directory name, or None if this entry should be skipped
 
-        :same: function to compare an existing entry (a File or Directory
+        * same: Callable --- Compare an existing entry (a File or Directory
         object) with an entry in the items list to determine whether to keep
         the existing entry.
 
-        :new_entry: function to create a new directory entry (File or Directory
+        * new_entry: Callable --- Create a new directory entry (File or Directory
         object) from an entry in the items list.
 
         """
@@ -163,29 +168,43 @@ class Directory(FreshBase):
         changed = False
         for i in items:
             name = self.sanitize_filename(fn(i))
-            if name:
-                if name in oldentries and same(oldentries[name], i):
+            if not name:
+                continue
+            if name in oldentries:
+                ent = oldentries[name]
+                if same(ent, i) and ent.parent_inode == self.inode:
                     # move existing directory entry over
-                    self._entries[name] = oldentries[name]
+                    self._entries[name] = ent
                     del oldentries[name]
-                else:
-                    _logger.debug("Adding entry '%s' to inode %i", name, self.inode)
-                    # create new directory entry
-                    ent = new_entry(i)
-                    if ent is not None:
-                        self._entries[name] = self.inodes.add_entry(ent)
-                        changed = True
+                    self.inodes.inode_cache.touch(ent)
+
+        for i in items:
+            name = self.sanitize_filename(fn(i))
+            if not name:
+                continue
+            if name not in self._entries:
+                # create new directory entry
+                ent = new_entry(i)
+                if ent is not None:
+                    self._entries[name] = self.inodes.add_entry(ent)
+                    # need to invalidate this just in case there was a
+                    # previous entry that couldn't be moved over or a
+                    # lookup that returned file not found and cached
+                    # a negative result
+                    self.inodes.invalidate_entry(self, name)
+                    changed = True
+                _logger.debug("Added entry '%s' as inode %i to parent inode %i", name, ent.inode, self.inode)
 
         # delete any other directory entries that were not in found in 'items'
-        for i in oldentries:
-            _logger.debug("Forgetting about entry '%s' on inode %i", i, self.inode)
-            self.inodes.invalidate_entry(self, i)
-            self.inodes.del_entry(oldentries[i])
+        for name, ent in oldentries.items():
+            _logger.debug("Detaching entry '%s' from parent_inode %i", name, self.inode)
+            self.inodes.invalidate_entry(self, name)
+            self.inodes.del_entry(ent)
             changed = True
 
         if changed:
-            self.inodes.invalidate_inode(self)
             self._mtime = time.time()
+            self.inodes.inode_cache.update_cache_size(self)
 
         self.fresh()
 
@@ -197,27 +216,27 @@ class Directory(FreshBase):
                 return True
         return False
 
-    def has_ref(self, only_children):
-        if super(Directory, self).has_ref(only_children):
-            return True
-        for v in self._entries.values():
-            if v.has_ref(False):
-                return True
-        return False
-
     def clear(self):
         """Delete all entries"""
+        if not self._entries:
+            return
         oldentries = self._entries
         self._entries = {}
-        for n in oldentries:
-            oldentries[n].clear()
-            self.inodes.del_entry(oldentries[n])
         self.invalidate()
+        for name, ent in oldentries.items():
+            ent.clear()
+            self.inodes.invalidate_entry(self, name)
+            self.inodes.del_entry(ent)
+        self.inodes.inode_cache.update_cache_size(self)
 
     def kernel_invalidate(self):
         # Invalidating the dentry on the parent implies invalidating all paths
         # below it as well.
-        parent = self.inodes[self.parent_inode]
+        if self.parent_inode in self.inodes:
+            parent = self.inodes[self.parent_inode]
+        else:
+            # parent was removed already.
+            return
 
         # Find self on the parent in order to invalidate this path.
         # Calling the public items() method might trigger a refresh,
@@ -270,9 +289,10 @@ class CollectionDirectoryBase(Directory):
 
     """
 
-    def __init__(self, parent_inode, inodes, apiconfig, enable_write, collection, collection_root):
-        super(CollectionDirectoryBase, self).__init__(parent_inode, inodes, apiconfig, enable_write)
-        self.apiconfig = apiconfig
+    __slots__ = ("collection", "collection_root", "collection_record_file")
+
+    def __init__(self, parent_inode, inodes, enable_write, filters, collection, collection_root):
+        super(CollectionDirectoryBase, self).__init__(parent_inode, inodes, enable_write, filters)
         self.collection = collection
         self.collection_root = collection_root
         self.collection_record_file = None
@@ -280,14 +300,21 @@ class CollectionDirectoryBase(Directory):
     def new_entry(self, name, item, mtime):
         name = self.sanitize_filename(name)
         if hasattr(item, "fuse_entry") and item.fuse_entry is not None:
-            if item.fuse_entry.dead is not True:
-                raise Exception("Can only reparent dead inode entry")
+            if item.fuse_entry.parent_inode is not None:
+                raise Exception("Can only reparent unparented inode entry")
             if item.fuse_entry.inode is None:
                 raise Exception("Reparented entry must still have valid inode")
-            item.fuse_entry.dead = False
+            item.fuse_entry.parent_inode = self.inode
             self._entries[name] = item.fuse_entry
         elif isinstance(item, arvados.collection.RichCollectionBase):
-            self._entries[name] = self.inodes.add_entry(CollectionDirectoryBase(self.inode, self.inodes, self.apiconfig, self._enable_write, item, self.collection_root))
+            self._entries[name] = self.inodes.add_entry(CollectionDirectoryBase(
+                self.inode,
+                self.inodes,
+                self._enable_write,
+                self._filters,
+                item,
+                self.collection_root,
+            ))
             self._entries[name].populate(mtime)
         else:
             self._entries[name] = self.inodes.add_entry(FuseArvadosFile(self.inode, item, mtime, self._enable_write))
@@ -428,14 +455,23 @@ class CollectionDirectoryBase(Directory):
 
     def clear(self):
         super(CollectionDirectoryBase, self).clear()
+        if self.collection is not None:
+            self.collection.unsubscribe()
         self.collection = None
 
+    def objsize(self):
+        # objsize for the whole collection is represented at the root,
+        # don't double-count it
+        return 0
 
 class CollectionDirectory(CollectionDirectoryBase):
     """Represents the root of a directory tree representing a collection."""
 
-    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, collection_record=None, explicit_collection=None):
-        super(CollectionDirectory, self).__init__(parent_inode, inodes, api.config, enable_write, None, self)
+    __slots__ = ("api", "num_retries", "collection_locator",
+                 "_manifest_size", "_writable", "_updating_lock")
+
+    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, filters=None, collection_record=None, explicit_collection=None):
+        super(CollectionDirectory, self).__init__(parent_inode, inodes, enable_write, filters, None, self)
         self.api = api
         self.num_retries = num_retries
         self._poll = True
@@ -493,7 +529,10 @@ class CollectionDirectory(CollectionDirectoryBase):
         if self.collection_record_file is not None:
             self.collection_record_file.invalidate()
             self.inodes.invalidate_inode(self.collection_record_file)
-            _logger.debug("%s invalidated collection record file", self)
+            _logger.debug("parent_inode %s invalidated collection record file inode %s", self.inode,
+                          self.collection_record_file.inode)
+        self.inodes.update_uuid(self)
+        self.inodes.inode_cache.update_cache_size(self)
         self.fresh()
 
     def uuid(self):
@@ -525,23 +564,15 @@ class CollectionDirectory(CollectionDirectoryBase):
                         self.collection.update()
                         new_collection_record = self.collection.api_response()
                     else:
-                        # If there's too many prefetch threads and you
-                        # max out the CPU, delivering data to the FUSE
-                        # layer actually ends up being slower.
-                        # Experimentally, capping 7 threads seems to
-                        # be a sweet spot.
-                        get_threads = min(max((self.api.keep.block_cache.cache_max // (64 * 1024 * 1024)) - 1, 1), 7)
                         # Create a new collection object
                         if uuid_pattern.match(self.collection_locator):
                             coll_reader = arvados.collection.Collection(
                                 self.collection_locator, self.api, self.api.keep,
-                                num_retries=self.num_retries,
-                                get_threads=get_threads)
+                                num_retries=self.num_retries)
                         else:
                             coll_reader = arvados.collection.CollectionReader(
                                 self.collection_locator, self.api, self.api.keep,
-                                num_retries=self.num_retries,
-                                get_threads=get_threads)
+                                num_retries=self.num_retries)
                         new_collection_record = coll_reader.api_response() or {}
                         # If the Collection only exists in Keep, there will be no API
                         # response.  Fill in the fields we need.
@@ -579,6 +610,7 @@ class CollectionDirectory(CollectionDirectoryBase):
         return False
 
     @use_counter
+    @check_update
     def collection_record(self):
         self.flush()
         return self.collection.api_response()
@@ -612,22 +644,32 @@ class CollectionDirectory(CollectionDirectoryBase):
         return (self.collection_locator is not None)
 
     def objsize(self):
-        # This is an empirically-derived heuristic to estimate the memory used
-        # to store this collection's metadata.  Calculating the memory
-        # footprint directly would be more accurate, but also more complicated.
-        return self._manifest_size * 128
+        # This is a rough guess of the amount of overhead involved for
+        # a collection; the assumptions are that that each file
+        # averages 128 bytes in the manifest, but consume 1024 bytes
+        # of Python data structures, so 1024/128=8 means we estimate
+        # the RAM footprint at 8 times the size of bare manifest text.
+        return self._manifest_size * 8
 
     def finalize(self):
-        if self.collection is not None:
-            if self.writable():
+        if self.collection is None:
+            return
+
+        if self.writable():
+            try:
                 self.collection.save()
-            self.collection.stop_threads()
+            except Exception as e:
+                _logger.exception("Failed to save collection %s", self.collection_locator)
+        self.collection.stop_threads()
 
     def clear(self):
         if self.collection is not None:
             self.collection.stop_threads()
-        super(CollectionDirectory, self).clear()
         self._manifest_size = 0
+        super(CollectionDirectory, self).clear()
+        if self.collection_record_file is not None:
+            self.inodes.del_entry(self.collection_record_file)
+        self.collection_record_file = None
 
 
 class TmpCollectionDirectory(CollectionDirectoryBase):
@@ -645,7 +687,7 @@ class TmpCollectionDirectory(CollectionDirectoryBase):
         def save_new(self):
             pass
 
-    def __init__(self, parent_inode, inodes, api_client, num_retries, enable_write, storage_classes=None):
+    def __init__(self, parent_inode, inodes, api_client, num_retries, enable_write, filters=None, storage_classes=None):
         collection = self.UnsaveableCollection(
             api_client=api_client,
             keep_client=api_client.keep,
@@ -654,7 +696,7 @@ class TmpCollectionDirectory(CollectionDirectoryBase):
         # This is always enable_write=True because it never tries to
         # save to the backend
         super(TmpCollectionDirectory, self).__init__(
-            parent_inode, inodes, api_client.config, True, collection, self)
+            parent_inode, inodes, True, filters, collection, self)
         self.populate(self.mtime())
 
     def on_event(self, *args, **kwargs):
@@ -676,7 +718,7 @@ class TmpCollectionDirectory(CollectionDirectoryBase):
                 with self.collection.lock:
                     self.collection_record_file.invalidate()
                     self.inodes.invalidate_inode(self.collection_record_file)
-                    _logger.debug("%s invalidated collection record", self)
+                    _logger.debug("%s invalidated collection record", self.inode)
         finally:
             while lockcount > 0:
                 self.collection.lock.acquire()
@@ -750,8 +792,8 @@ and the directory will appear if it exists.
 
 """.lstrip()
 
-    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, pdh_only=False, storage_classes=None):
-        super(MagicDirectory, self).__init__(parent_inode, inodes, api.config, enable_write)
+    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, filters, pdh_only=False, storage_classes=None):
+        super(MagicDirectory, self).__init__(parent_inode, inodes, enable_write, filters)
         self.api = api
         self.num_retries = num_retries
         self.pdh_only = pdh_only
@@ -767,8 +809,14 @@ and the directory will appear if it exists.
             # If we're the root directory, add an identical by_id subdirectory.
             if self.inode == llfuse.ROOT_INODE:
                 self._entries['by_id'] = self.inodes.add_entry(MagicDirectory(
-                    self.inode, self.inodes, self.api, self.num_retries, self._enable_write,
-                    self.pdh_only))
+                    self.inode,
+                    self.inodes,
+                    self.api,
+                    self.num_retries,
+                    self._enable_write,
+                    self._filters,
+                    self.pdh_only,
+                ))
 
     def __contains__(self, k):
         if k in self._entries:
@@ -782,15 +830,34 @@ and the directory will appear if it exists.
 
             if group_uuid_pattern.match(k):
                 project = self.api.groups().list(
-                    filters=[['group_class', 'in', ['project','filter']], ["uuid", "=", k]]).execute(num_retries=self.num_retries)
+                    filters=[
+                        ['group_class', 'in', ['project','filter']],
+                        ["uuid", "=", k],
+                        *self._filters_for('groups', qualified=False),
+                    ],
+                ).execute(num_retries=self.num_retries)
                 if project[u'items_available'] == 0:
                     return False
                 e = self.inodes.add_entry(ProjectDirectory(
-                    self.inode, self.inodes, self.api, self.num_retries, self._enable_write,
-                    project[u'items'][0], storage_classes=self.storage_classes))
+                    self.inode,
+                    self.inodes,
+                    self.api,
+                    self.num_retries,
+                    self._enable_write,
+                    self._filters,
+                    project[u'items'][0],
+                    storage_classes=self.storage_classes,
+                ))
             else:
                 e = self.inodes.add_entry(CollectionDirectory(
-                        self.inode, self.inodes, self.api, self.num_retries, self._enable_write, k))
+                    self.inode,
+                    self.inodes,
+                    self.api,
+                    self.num_retries,
+                    self._enable_write,
+                    self._filters,
+                    k,
+                ))
 
             if e.update():
                 if k not in self._entries:
@@ -824,8 +891,8 @@ and the directory will appear if it exists.
 class TagsDirectory(Directory):
     """A special directory that contains as subdirectories all tags visible to the user."""
 
-    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, poll_time=60):
-        super(TagsDirectory, self).__init__(parent_inode, inodes, api.config, enable_write)
+    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, filters, poll_time=60):
+        super(TagsDirectory, self).__init__(parent_inode, inodes, enable_write, filters)
         self.api = api
         self.num_retries = num_retries
         self._poll = True
@@ -839,15 +906,32 @@ class TagsDirectory(Directory):
     def update(self):
         with llfuse.lock_released:
             tags = self.api.links().list(
-                filters=[['link_class', '=', 'tag'], ["name", "!=", ""]],
-                select=['name'], distinct=True, limit=1000
-                ).execute(num_retries=self.num_retries)
+                filters=[
+                    ['link_class', '=', 'tag'],
+                    ['name', '!=', ''],
+                    *self._filters_for('links', qualified=False),
+                ],
+                select=['name'],
+                distinct=True,
+                limit=1000,
+            ).execute(num_retries=self.num_retries)
         if "items" in tags:
-            self.merge(tags['items']+[{"name": n} for n in self._extra],
-                       lambda i: i['name'],
-                       lambda a, i: a.tag == i['name'],
-                       lambda i: TagDirectory(self.inode, self.inodes, self.api, self.num_retries, self._enable_write,
-                                              i['name'], poll=self._poll, poll_time=self._poll_time))
+            self.merge(
+                tags['items']+[{"name": n} for n in self._extra],
+                lambda i: i['name'],
+                lambda a, i: a.tag == i['name'],
+                lambda i: TagDirectory(
+                    self.inode,
+                    self.inodes,
+                    self.api,
+                    self.num_retries,
+                    self._enable_write,
+                    self._filters,
+                    i['name'],
+                    poll=self._poll,
+                    poll_time=self._poll_time,
+                ),
+            )
 
     @use_counter
     @check_update
@@ -856,7 +940,12 @@ class TagsDirectory(Directory):
             return super(TagsDirectory, self).__getitem__(item)
         with llfuse.lock_released:
             tags = self.api.links().list(
-                filters=[['link_class', '=', 'tag'], ['name', '=', item]], limit=1
+                filters=[
+                    ['link_class', '=', 'tag'],
+                    ['name', '=', item],
+                    *self._filters_for('links', qualified=False),
+                ],
+                limit=1,
             ).execute(num_retries=self.num_retries)
         if tags["items"]:
             self._extra.add(item)
@@ -881,9 +970,9 @@ class TagDirectory(Directory):
     to the user that are tagged with a particular tag.
     """
 
-    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, tag,
+    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, filters, tag,
                  poll=False, poll_time=60):
-        super(TagDirectory, self).__init__(parent_inode, inodes, api.config, enable_write)
+        super(TagDirectory, self).__init__(parent_inode, inodes, enable_write, filters)
         self.api = api
         self.num_retries = num_retries
         self.tag = tag
@@ -897,23 +986,40 @@ class TagDirectory(Directory):
     def update(self):
         with llfuse.lock_released:
             taggedcollections = self.api.links().list(
-                filters=[['link_class', '=', 'tag'],
-                         ['name', '=', self.tag],
-                         ['head_uuid', 'is_a', 'arvados#collection']],
-                select=['head_uuid']
-                ).execute(num_retries=self.num_retries)
-        self.merge(taggedcollections['items'],
-                   lambda i: i['head_uuid'],
-                   lambda a, i: a.collection_locator == i['head_uuid'],
-                   lambda i: CollectionDirectory(self.inode, self.inodes, self.api, self.num_retries, self._enable_write, i['head_uuid']))
+                filters=[
+                    ['link_class', '=', 'tag'],
+                    ['name', '=', self.tag],
+                    ['head_uuid', 'is_a', 'arvados#collection'],
+                    *self._filters_for('links', qualified=False),
+                ],
+                select=['head_uuid'],
+            ).execute(num_retries=self.num_retries)
+        self.merge(
+            taggedcollections['items'],
+            lambda i: i['head_uuid'],
+            lambda a, i: a.collection_locator == i['head_uuid'],
+            lambda i: CollectionDirectory(
+                self.inode,
+                self.inodes,
+                self.api,
+                self.num_retries,
+                self._enable_write,
+                self._filters,
+                i['head_uuid'],
+            ),
+        )
 
 
 class ProjectDirectory(Directory):
     """A special directory that contains the contents of a project."""
 
-    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, project_object,
-                 poll=True, poll_time=3, storage_classes=None):
-        super(ProjectDirectory, self).__init__(parent_inode, inodes, api.config, enable_write)
+    __slots__ = ("api", "num_retries", "project_object", "project_object_file",
+                 "project_uuid", "_updating_lock",
+                 "_current_user", "_full_listing", "storage_classes", "recursively_contained")
+
+    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, filters,
+                 project_object, poll=True, poll_time=3, storage_classes=None):
+        super(ProjectDirectory, self).__init__(parent_inode, inodes, enable_write, filters)
         self.api = api
         self.num_retries = num_retries
         self.project_object = project_object
@@ -925,19 +1031,32 @@ class ProjectDirectory(Directory):
         self._current_user = None
         self._full_listing = False
         self.storage_classes = storage_classes
+        self.recursively_contained = False
+
+        # Filter groups can contain themselves, which causes tools
+        # that walk the filesystem to get stuck in an infinite loop,
+        # so suppress returning a listing in that case.
+        if self.project_object.get("group_class") == "filter":
+            iter_parent_inode = parent_inode
+            while iter_parent_inode != llfuse.ROOT_INODE:
+                parent_dir = self.inodes[iter_parent_inode]
+                if isinstance(parent_dir, ProjectDirectory) and parent_dir.project_uuid == self.project_uuid:
+                    self.recursively_contained = True
+                    break
+                iter_parent_inode = parent_dir.parent_inode
 
     def want_event_subscribe(self):
         return True
 
     def createDirectory(self, i):
+        common_args = (self.inode, self.inodes, self.api, self.num_retries, self._enable_write, self._filters)
         if collection_uuid_pattern.match(i['uuid']):
-            return CollectionDirectory(self.inode, self.inodes, self.api, self.num_retries, self._enable_write, i)
+            return CollectionDirectory(*common_args, i)
         elif group_uuid_pattern.match(i['uuid']):
-            return ProjectDirectory(self.inode, self.inodes, self.api, self.num_retries, self._enable_write,
-                                    i, self._poll, self._poll_time, self.storage_classes)
+            return ProjectDirectory(*common_args, i, self._poll, self._poll_time, self.storage_classes)
         elif link_uuid_pattern.match(i['uuid']):
             if i['head_kind'] == 'arvados#collection' or portable_data_hash_pattern.match(i['head_uuid']):
-                return CollectionDirectory(self.inode, self.inodes, self.api, self.num_retries, self._enable_write, i['head_uuid'])
+                return CollectionDirectory(*common_args, i['head_uuid'])
             else:
                 return None
         elif uuid_pattern.match(i['uuid']):
@@ -975,7 +1094,7 @@ class ProjectDirectory(Directory):
             self.project_object_file = ObjectFile(self.inode, self.project_object)
             self.inodes.add_entry(self.project_object_file)
 
-        if not self._full_listing:
+        if self.recursively_contained or not self._full_listing:
             return True
 
         def samefn(a, i):
@@ -998,20 +1117,27 @@ class ProjectDirectory(Directory):
                     self.project_object = self.api.users().get(
                         uuid=self.project_uuid).execute(num_retries=self.num_retries)
                 # do this in 2 steps until #17424 is fixed
-                contents = list(arvados.util.keyset_list_all(self.api.groups().contents,
-                                                        order_key="uuid",
-                                                        num_retries=self.num_retries,
-                                                        uuid=self.project_uuid,
-                                                        filters=[["uuid", "is_a", "arvados#group"],
-                                                                 ["groups.group_class", "in", ["project","filter"]]]))
-                contents.extend(filter(lambda i: i["current_version_uuid"] == i["uuid"],
-                                       arvados.util.keyset_list_all(self.api.groups().contents,
-                                                             order_key="uuid",
-                                                             num_retries=self.num_retries,
-                                                             uuid=self.project_uuid,
-                                                             filters=[["uuid", "is_a", "arvados#collection"]])))
-
-
+                contents = list(arvados.util.keyset_list_all(
+                    self.api.groups().contents,
+                    order_key='uuid',
+                    num_retries=self.num_retries,
+                    uuid=self.project_uuid,
+                    filters=[
+                        ['uuid', 'is_a', 'arvados#group'],
+                        ['groups.group_class', 'in', ['project', 'filter']],
+                        *self._filters_for('groups', qualified=True),
+                    ],
+                ))
+                contents.extend(obj for obj in arvados.util.keyset_list_all(
+                    self.api.groups().contents,
+                    order_key='uuid',
+                    num_retries=self.num_retries,
+                    uuid=self.project_uuid,
+                    filters=[
+                        ['uuid', 'is_a', 'arvados#collection'],
+                        *self._filters_for('collections', qualified=True),
+                    ],
+                ) if obj['current_version_uuid'] == obj['uuid'])
             # end with llfuse.lock_released, re-acquire lock
 
             self.merge(contents,
@@ -1040,14 +1166,24 @@ class ProjectDirectory(Directory):
                 namefilter = ["name", "=", k]
             else:
                 namefilter = ["name", "in", [k, k2]]
-            contents = self.api.groups().list(filters=[["owner_uuid", "=", self.project_uuid],
-                                                       ["group_class", "in", ["project","filter"]],
-                                                       namefilter],
-                                              limit=2).execute(num_retries=self.num_retries)["items"]
+            contents = self.api.groups().list(
+                filters=[
+                    ["owner_uuid", "=", self.project_uuid],
+                    ["group_class", "in", ["project","filter"]],
+                    namefilter,
+                    *self._filters_for('groups', qualified=False),
+                ],
+                limit=2,
+            ).execute(num_retries=self.num_retries)["items"]
             if not contents:
-                contents = self.api.collections().list(filters=[["owner_uuid", "=", self.project_uuid],
-                                                                namefilter],
-                                                       limit=2).execute(num_retries=self.num_retries)["items"]
+                contents = self.api.collections().list(
+                    filters=[
+                        ["owner_uuid", "=", self.project_uuid],
+                        namefilter,
+                        *self._filters_for('collections', qualified=False),
+                    ],
+                    limit=2,
+                ).execute(num_retries=self.num_retries)["items"]
         if contents:
             if len(contents) > 1 and contents[1]['name'] == k:
                 # If "foo/bar" and "foo[SUBST]bar" both exist, use
@@ -1084,6 +1220,12 @@ class ProjectDirectory(Directory):
     def persisted(self):
         return True
 
+    def clear(self):
+        super(ProjectDirectory, self).clear()
+        if self.project_object_file is not None:
+            self.inodes.del_entry(self.project_object_file)
+        self.project_object_file = None
+
     @use_counter
     @check_update
     def mkdir(self, name):
@@ -1201,9 +1343,9 @@ class ProjectDirectory(Directory):
 class SharedDirectory(Directory):
     """A special directory that represents users or groups who have shared projects with me."""
 
-    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, exclude,
-                 poll=False, poll_time=60, storage_classes=None):
-        super(SharedDirectory, self).__init__(parent_inode, inodes, api.config, enable_write)
+    def __init__(self, parent_inode, inodes, api, num_retries, enable_write, filters,
+                 exclude, poll=False, poll_time=60, storage_classes=None):
+        super(SharedDirectory, self).__init__(parent_inode, inodes, enable_write, filters)
         self.api = api
         self.num_retries = num_retries
         self.current_user = api.users().current().execute(num_retries=num_retries)
@@ -1229,11 +1371,17 @@ class SharedDirectory(Directory):
                 if 'httpMethod' in methods.get('shared', {}):
                     page = []
                     while True:
-                        resp = self.api.groups().shared(filters=[['group_class', 'in', ['project','filter']]]+page,
-                                                        order="uuid",
-                                                        limit=10000,
-                                                        count="none",
-                                                        include="owner_uuid").execute()
+                        resp = self.api.groups().shared(
+                            filters=[
+                                ['group_class', 'in', ['project','filter']],
+                                *page,
+                                *self._filters_for('groups', qualified=False),
+                            ],
+                            order="uuid",
+                            limit=10000,
+                            count="none",
+                            include="owner_uuid",
+                        ).execute()
                         if not resp["items"]:
                             break
                         page = [["uuid", ">", resp["items"][len(resp["items"])-1]["uuid"]]]
@@ -1248,8 +1396,12 @@ class SharedDirectory(Directory):
                         self.api.groups().list,
                         order_key="uuid",
                         num_retries=self.num_retries,
-                        filters=[['group_class','in',['project','filter']]],
-                        select=["uuid", "owner_uuid"]))
+                        filters=[
+                            ['group_class', 'in', ['project','filter']],
+                            *self._filters_for('groups', qualified=False),
+                        ],
+                        select=["uuid", "owner_uuid"],
+                    ))
                     for ob in all_projects:
                         objects[ob['uuid']] = ob
 
@@ -1263,13 +1415,20 @@ class SharedDirectory(Directory):
                         self.api.users().list,
                         order_key="uuid",
                         num_retries=self.num_retries,
-                        filters=[['uuid','in', list(root_owners)]])
+                        filters=[
+                            ['uuid', 'in', list(root_owners)],
+                            *self._filters_for('users', qualified=False),
+                        ],
+                    )
                     lgroups = arvados.util.keyset_list_all(
                         self.api.groups().list,
                         order_key="uuid",
                         num_retries=self.num_retries,
-                        filters=[['uuid','in', list(root_owners)+roots]])
-
+                        filters=[
+                            ['uuid', 'in', list(root_owners)+roots],
+                            *self._filters_for('groups', qualified=False),
+                        ],
+                    )
                     for l in lusers:
                         objects[l["uuid"]] = l
                     for l in lgroups:
@@ -1291,11 +1450,23 @@ class SharedDirectory(Directory):
 
             # end with llfuse.lock_released, re-acquire lock
 
-            self.merge(contents.items(),
-                       lambda i: i[0],
-                       lambda a, i: a.uuid() == i[1]['uuid'],
-                       lambda i: ProjectDirectory(self.inode, self.inodes, self.api, self.num_retries, self._enable_write,
-                                                  i[1], poll=self._poll, poll_time=self._poll_time, storage_classes=self.storage_classes))
+            self.merge(
+                contents.items(),
+                lambda i: i[0],
+                lambda a, i: a.uuid() == i[1]['uuid'],
+                lambda i: ProjectDirectory(
+                    self.inode,
+                    self.inodes,
+                    self.api,
+                    self.num_retries,
+                    self._enable_write,
+                    self._filters,
+                    i[1],
+                    poll=self._poll,
+                    poll_time=self._poll_time,
+                    storage_classes=self.storage_classes,
+                ),
+            )
         except Exception:
             _logger.exception("arv-mount shared dir error")
         finally:
index 45d3db16fe00d7edb802f8d279334b312d8fcc48..9279f7d99dbc01c1dc8d23cd4fbe01d3fb6bf23c 100644 (file)
@@ -80,9 +80,17 @@ class FuseArvadosFile(File):
             if self.writable():
                 self.arvfile.parent.root_collection().save()
 
+    def clear(self):
+        if self.parent_inode is None:
+            self.arvfile.fuse_entry = None
+            self.arvfile = None
+
 
 class StringFile(File):
     """Wrap a simple string as a file"""
+
+    __slots__ = ("contents",)
+
     def __init__(self, parent_inode, contents, _mtime):
         super(StringFile, self).__init__(parent_inode, _mtime)
         self.contents = contents
@@ -97,6 +105,8 @@ class StringFile(File):
 class ObjectFile(StringFile):
     """Wrap a dict as a serialized json object."""
 
+    __slots__ = ("object_uuid",)
+
     def __init__(self, parent_inode, obj):
         super(ObjectFile, self).__init__(parent_inode, "", 0)
         self.object_uuid = obj['uuid']
@@ -125,6 +135,9 @@ class FuncToJSONFile(StringFile):
     The function is called at the time the file is read. The result is
     cached until invalidate() is called.
     """
+
+    __slots__ = ("func",)
+
     def __init__(self, parent_inode, func):
         super(FuncToJSONFile, self).__init__(parent_inode, "", 0)
         self.func = func
index 12d047a8f35d00fc682e846ba20bce466b93dd21..144c582ddce52017ad14f47e660a9584f086b84c 100644 (file)
@@ -154,6 +154,16 @@ def unmount(path, subtype=None, timeout=10, recursive=False):
             path = os.path.realpath(path)
             continue
         elif not mounted:
+            if was_mounted:
+                # This appears to avoid a race condition where we
+                # return control to the caller after running
+                # "fusermount -u -z" (see below), the caller (e.g.,
+                # arv-mount --replace) immediately tries to attach a
+                # new fuse mount at the same mount point, the
+                # lazy-unmount process unmounts that _new_ mount while
+                # it is being initialized, and the setup code waits
+                # forever for the new mount to be initialized.
+                time.sleep(1)
             return was_mounted
 
         if attempted:
index d8eec3d9ee98bcdf1bd2ea603d237c5265c1750d..794b6afe4261cba9c6bfc4c5dd3fee9d6bb6c19b 100644 (file)
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+#
+# This file runs in one of three modes:
+#
+# 1. If the ARVADOS_BUILDING_VERSION environment variable is set, it writes
+#    _version.py and generates dependencies based on that value.
+# 2. If running from an arvados Git checkout, it writes _version.py
+#    and generates dependencies from Git.
+# 3. Otherwise, we expect this is source previously generated from Git, and
+#    it reads _version.py and generates dependencies from it.
 
-import subprocess
-import time
 import os
 import re
+import runpy
+import subprocess
 import sys
 
-SETUP_DIR = os.path.dirname(os.path.abspath(__file__))
-VERSION_PATHS = {
-        SETUP_DIR,
-        os.path.abspath(os.path.join(SETUP_DIR, "../../sdk/python")),
-        os.path.abspath(os.path.join(SETUP_DIR, "../../build/version-at-commit.sh"))
-        }
+from pathlib import Path
+
+# These maps explain the relationships between different Python modules in
+# the arvados repository. We use these to help generate setup.py.
+PACKAGE_DEPENDENCY_MAP = {
+    'arvados-cwl-runner': ['arvados-python-client', 'crunchstat_summary'],
+    'arvados-user-activity': ['arvados-python-client'],
+    'arvados_fuse': ['arvados-python-client'],
+    'crunchstat_summary': ['arvados-python-client'],
+}
+PACKAGE_MODULE_MAP = {
+    'arvados-cwl-runner': 'arvados_cwl',
+    'arvados-docker-cleaner': 'arvados_docker',
+    'arvados-python-client': 'arvados',
+    'arvados-user-activity': 'arvados_user_activity',
+    'arvados_fuse': 'arvados_fuse',
+    'crunchstat_summary': 'crunchstat_summary',
+}
+PACKAGE_SRCPATH_MAP = {
+    'arvados-cwl-runner': Path('sdk', 'cwl'),
+    'arvados-docker-cleaner': Path('services', 'dockercleaner'),
+    'arvados-python-client': Path('sdk', 'python'),
+    'arvados-user-activity': Path('tools', 'user-activity'),
+    'arvados_fuse': Path('services', 'fuse'),
+    'crunchstat_summary': Path('tools', 'crunchstat-summary'),
+}
+
+ENV_VERSION = os.environ.get("ARVADOS_BUILDING_VERSION")
+SETUP_DIR = Path(__file__).absolute().parent
+try:
+    REPO_PATH = Path(subprocess.check_output(
+        ['git', '-C', str(SETUP_DIR), 'rev-parse', '--show-toplevel'],
+        stderr=subprocess.DEVNULL,
+        text=True,
+    ).rstrip('\n'))
+except (subprocess.CalledProcessError, OSError):
+    REPO_PATH = None
+else:
+    # Verify this is the arvados monorepo
+    if all((REPO_PATH / path).exists() for path in PACKAGE_SRCPATH_MAP.values()):
+        PACKAGE_NAME, = (
+            pkg_name for pkg_name, path in PACKAGE_SRCPATH_MAP.items()
+            if (REPO_PATH / path) == SETUP_DIR
+        )
+        MODULE_NAME = PACKAGE_MODULE_MAP[PACKAGE_NAME]
+        VERSION_SCRIPT_PATH = Path(REPO_PATH, 'build', 'version-at-commit.sh')
+    else:
+        REPO_PATH = None
+if REPO_PATH is None:
+    (PACKAGE_NAME, MODULE_NAME), = (
+        (pkg_name, mod_name)
+        for pkg_name, mod_name in PACKAGE_MODULE_MAP.items()
+        if (SETUP_DIR / mod_name).is_dir()
+    )
+
+def short_tests_only(arglist=sys.argv):
+    try:
+        arglist.remove('--short-tests-only')
+    except ValueError:
+        return False
+    else:
+        return True
+
+def git_log_output(path, *args):
+    return subprocess.check_output(
+        ['git', '-C', str(REPO_PATH),
+         'log', '--first-parent', '--max-count=1',
+         *args, str(path)],
+        text=True,
+    ).rstrip('\n')
 
 def choose_version_from():
-    ts = {}
-    for path in VERSION_PATHS:
-        ts[subprocess.check_output(
-            ['git', 'log', '--first-parent', '--max-count=1',
-             '--format=format:%ct', path]).strip()] = path
-
-    sorted_ts = sorted(ts.items())
-    getver = sorted_ts[-1][1]
-    print("Using "+getver+" for version number calculation of "+SETUP_DIR, file=sys.stderr)
+    ver_paths = [SETUP_DIR, VERSION_SCRIPT_PATH, *(
+        PACKAGE_SRCPATH_MAP[pkg]
+        for pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ())
+    )]
+    getver = max(ver_paths, key=lambda path: git_log_output(path, '--format=format:%ct'))
+    print(f"Using {getver} for version number calculation of {SETUP_DIR}", file=sys.stderr)
     return getver
 
 def git_version_at_commit():
     curdir = choose_version_from()
-    myhash = subprocess.check_output(['git', 'log', '-n1', '--first-parent',
-                                       '--format=%H', curdir]).strip()
-    myversion = subprocess.check_output([SETUP_DIR+'/../../build/version-at-commit.sh', myhash]).strip().decode()
-    return myversion
+    myhash = git_log_output(curdir, '--format=%H')
+    return subprocess.check_output(
+        [str(VERSION_SCRIPT_PATH), myhash],
+        text=True,
+    ).rstrip('\n')
 
 def save_version(setup_dir, module, v):
-    v = v.replace("~dev", ".dev").replace("~rc", "rc")
-    with open(os.path.join(setup_dir, module, "_version.py"), 'wt') as fp:
-        return fp.write("__version__ = '%s'\n" % v)
+    with Path(setup_dir, module, '_version.py').open('w') as fp:
+        print(f"__version__ = {v!r}", file=fp)
 
 def read_version(setup_dir, module):
-    with open(os.path.join(setup_dir, module, "_version.py"), 'rt') as fp:
-        return re.match("__version__ = '(.*)'$", fp.read()).groups()[0]
-
-def get_version(setup_dir, module):
-    env_version = os.environ.get("ARVADOS_BUILDING_VERSION")
+    file_vars = runpy.run_path(Path(setup_dir, module, '_version.py'))
+    return file_vars['__version__']
 
-    if env_version:
-        save_version(setup_dir, module, env_version)
+def get_version(setup_dir=SETUP_DIR, module=MODULE_NAME):
+    if ENV_VERSION:
+        version = ENV_VERSION
+    elif REPO_PATH is None:
+        return read_version(setup_dir, module)
     else:
-        try:
-            save_version(setup_dir, module, git_version_at_commit())
-        except (subprocess.CalledProcessError, OSError) as err:
-            print("ERROR: {0}".format(err), file=sys.stderr)
-            pass
+        version = git_version_at_commit()
+    version = version.replace("~dev", ".dev").replace("~rc", "rc")
+    save_version(setup_dir, module, version)
+    return version
+
+def iter_dependencies(version=None):
+    if version is None:
+        version = get_version()
+    # A packaged development release should be installed with other
+    # development packages built from the same source, but those
+    # dependencies may have earlier "dev" versions (read: less recent
+    # Git commit timestamps). This compatible version dependency
+    # expresses that as closely as possible. Allowing versions
+    # compatible with .dev0 allows any development release.
+    # Regular expression borrowed partially from
+    # <https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers-regex>
+    dep_ver, match_count = re.subn(r'\.dev(0|[1-9][0-9]*)$', '.dev0', version, 1)
+    dep_op = '~=' if match_count else '=='
+    for dep_pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ()):
+        yield f'{dep_pkg}{dep_op}{dep_ver}'
 
-    return read_version(setup_dir, module)
+# Called from calculate_python_sdk_cwl_package_versions() in run-library.sh
+if __name__ == '__main__':
+    print(get_version())
index f789abe69270c024e73a5294666bc06169b45026..4d98172f8db647ddbd5f40a49315d001a1c96065 100644 (file)
@@ -5,7 +5,7 @@
 fpm_depends+=(fuse)
 
 case "$TARGET" in
-    centos*)
+    centos*|rocky*)
         fpm_depends+=(fuse-libs)
         ;;
     debian* | ubuntu*)
index d0c46f132040aa400645473ddf347c53be135d23..77dbd036d06d82499ce4a9f4da640842ce840852 100644 (file)
@@ -10,21 +10,10 @@ import re
 
 from setuptools import setup, find_packages
 
-SETUP_DIR = os.path.dirname(__file__) or '.'
-README = os.path.join(SETUP_DIR, 'README.rst')
-
 import arvados_version
-version = arvados_version.get_version(SETUP_DIR, "arvados_fuse")
-if os.environ.get('ARVADOS_BUILDING_VERSION', False):
-    pysdk_dep = "=={}".format(version)
-else:
-    # On dev releases, arvados-python-client may have a different timestamp
-    pysdk_dep = "<={}".format(version)
-
-short_tests_only = False
-if '--short-tests-only' in sys.argv:
-    short_tests_only = True
-    sys.argv.remove('--short-tests-only')
+version = arvados_version.get_version()
+short_tests_only = arvados_version.short_tests_only()
+README = os.path.join(arvados_version.SETUP_DIR, 'README.rst')
 
 setup(name='arvados_fuse',
       version=version,
@@ -43,19 +32,16 @@ setup(name='arvados_fuse',
           ('share/doc/arvados_fuse', ['agpl-3.0.txt', 'README.rst']),
       ],
       install_requires=[
-        'arvados-python-client{}'.format(pysdk_dep),
-        'llfuse >= 1.3.6',
+        *arvados_version.iter_dependencies(version),
+        'arvados-llfuse >= 1.5.1',
         'future',
         'python-daemon',
         'ciso8601 >= 2.0.0',
         'setuptools',
         "prometheus_client"
         ],
-      extras_require={
-          ':python_version<"3"': ['pytz'],
-      },
+      python_requires="~=3.8",
       classifiers=[
-          'Programming Language :: Python :: 2',
           'Programming Language :: Python :: 3',
       ],
       test_suite='tests',
index 89b39dbc87e10677c3024d4566c9325cae756048..e80b6983a154337c687ebc62218aed2a152efa63 100644 (file)
@@ -86,7 +86,7 @@ class IntegrationTest(unittest.TestCase):
                     with arvados_fuse.command.Mount(
                             arvados_fuse.command.ArgumentParser().parse_args(
                                 argv + ['--foreground',
-                                        '--unmount-timeout=2',
+                                        '--unmount-timeout=60',
                                         self.mnt])) as self.mount:
                         return func(self, *args, **kwargs)
                 finally:
index c316010f6c48b17b5d7aa35b4fe96d1021bfb49d..02f40097240b6a8d5933e61376197dfa91fd9610 100644 (file)
@@ -72,15 +72,22 @@ class MountTestBase(unittest.TestCase):
         llfuse.close()
 
     def make_mount(self, root_class, **root_kwargs):
-        enable_write = True
-        if 'enable_write' in root_kwargs:
-            enable_write = root_kwargs.pop('enable_write')
+        enable_write = root_kwargs.pop('enable_write', True)
         self.operations = fuse.Operations(
-            os.getuid(), os.getgid(),
+            os.getuid(),
+            os.getgid(),
             api_client=self.api,
-            enable_write=enable_write)
+            enable_write=enable_write,
+        )
         self.operations.inodes.add_entry(root_class(
-            llfuse.ROOT_INODE, self.operations.inodes, self.api, 0, enable_write, **root_kwargs))
+            llfuse.ROOT_INODE,
+            self.operations.inodes,
+            self.api,
+            0,
+            enable_write,
+            root_kwargs.pop('filters', None),
+            **root_kwargs,
+        ))
         llfuse.init(self.operations, self.mounttmp, [])
         self.llfuse_thread = threading.Thread(None, lambda: self._llfuse_main())
         self.llfuse_thread.daemon = True
@@ -95,10 +102,10 @@ class MountTestBase(unittest.TestCase):
                 self.operations.events.close(timeout=10)
             subprocess.call(["fusermount", "-u", "-z", self.mounttmp])
             t0 = time.time()
-            self.llfuse_thread.join(timeout=10)
+            self.llfuse_thread.join(timeout=60)
             if self.llfuse_thread.is_alive():
                 logger.warning("MountTestBase.tearDown():"
-                               " llfuse thread still alive 10s after umount"
+                               " llfuse thread still alive 60s after umount"
                                " -- exiting with SIGKILL")
                 os.kill(os.getpid(), signal.SIGKILL)
             waited = time.time() - t0
index ed59029628a5cdf0a5d7e5420d8680cb11039086..b08ab19335758be4c7ee4f72b91b4f3e26d04cea 100644 (file)
@@ -20,6 +20,7 @@ from . import run_test_server
 import sys
 import tempfile
 import unittest
+import resource
 
 def noexit(func):
     """If argparse or arvados_fuse tries to exit, fail the test instead"""
@@ -261,6 +262,50 @@ class MountArgsTest(unittest.TestCase):
                         '--foreground', self.mntdir])
                     arvados_fuse.command.Mount(args)
 
+    @noexit
+    @mock.patch('resource.setrlimit')
+    @mock.patch('resource.getrlimit')
+    def test_default_file_cache(self, getrlimit, setrlimit):
+        args = arvados_fuse.command.ArgumentParser().parse_args([
+            '--foreground', self.mntdir])
+        self.assertEqual(args.mode, None)
+        getrlimit.return_value = (1024, 1048576)
+        self.mnt = arvados_fuse.command.Mount(args)
+        setrlimit.assert_called_with(resource.RLIMIT_NOFILE, (10240, 1048576))
+
+    @noexit
+    @mock.patch('resource.setrlimit')
+    @mock.patch('resource.getrlimit')
+    def test_small_file_cache(self, getrlimit, setrlimit):
+        args = arvados_fuse.command.ArgumentParser().parse_args([
+            '--foreground', '--file-cache=256000000', self.mntdir])
+        self.assertEqual(args.mode, None)
+        getrlimit.return_value = (1024, 1048576)
+        self.mnt = arvados_fuse.command.Mount(args)
+        setrlimit.assert_not_called()
+
+    @noexit
+    @mock.patch('resource.setrlimit')
+    @mock.patch('resource.getrlimit')
+    def test_large_file_cache(self, getrlimit, setrlimit):
+        args = arvados_fuse.command.ArgumentParser().parse_args([
+            '--foreground', '--file-cache=256000000000', self.mntdir])
+        self.assertEqual(args.mode, None)
+        getrlimit.return_value = (1024, 1048576)
+        self.mnt = arvados_fuse.command.Mount(args)
+        setrlimit.assert_called_with(resource.RLIMIT_NOFILE, (30517, 1048576))
+
+    @noexit
+    @mock.patch('resource.setrlimit')
+    @mock.patch('resource.getrlimit')
+    def test_file_cache_hard_limit(self, getrlimit, setrlimit):
+        args = arvados_fuse.command.ArgumentParser().parse_args([
+            '--foreground', '--file-cache=256000000000', self.mntdir])
+        self.assertEqual(args.mode, None)
+        getrlimit.return_value = (1024, 2048)
+        self.mnt = arvados_fuse.command.Mount(args)
+        setrlimit.assert_called_with(resource.RLIMIT_NOFILE, (2048, 2048))
+
 class MountErrorTest(unittest.TestCase):
     def setUp(self):
         self.mntdir = tempfile.mkdtemp()
@@ -292,7 +337,7 @@ class MountErrorTest(unittest.TestCase):
 
     def test_bogus_host(self):
         arvados.config._settings["ARVADOS_API_HOST"] = "100::"
-        with self.assertRaises(SystemExit) as ex:
+        with self.assertRaises(SystemExit) as ex, mock.patch('time.sleep'):
             args = arvados_fuse.command.ArgumentParser().parse_args([self.mntdir])
             arvados_fuse.command.Mount(args, logger=self.logger).run()
         self.assertEqual(1, ex.exception.code)
index 6af60302bc788c796b50d5b57e33ee2bf3274332..f977990026a99e0cb7856e49be80abc390a24b64 100644 (file)
@@ -9,16 +9,12 @@ import json
 import multiprocessing
 import os
 from . import run_test_server
+import shlex
 import tempfile
 import unittest
 
 from .integration_test import workerPool
 
-try:
-    from shlex import quote
-except:
-    from pipes import quote
-
 def try_exec(mnt, cmd):
     try:
         os.environ['KEEP_LOCAL_STORE'] = tempfile.mkdtemp()
@@ -56,11 +52,11 @@ class ExecMode(unittest.TestCase):
 
     def test_exec(self):
         workerPool().apply(try_exec, (self.mnt, [
-            'sh', '-c',
-            'echo -n foo >{}; cp {} {}'.format(
-                quote(os.path.join(self.mnt, 'zzz', 'foo.txt')),
-                quote(os.path.join(self.mnt, 'zzz', '.arvados#collection')),
-                quote(os.path.join(self.okfile)))]))
+            'sh', '-c', 'echo -n foo >{}; cp {} {}'.format(
+                shlex.quote(os.path.join(self.mnt, 'zzz', 'foo.txt')),
+                shlex.quote(os.path.join(self.mnt, 'zzz', '.arvados#collection')),
+                shlex.quote(os.path.join(self.okfile)),
+            )]))
         with open(self.okfile) as f:
             assertRegex(
                 self,
index 07e6036d08752ae6993bb5c2e8156aeb47454d65..c5c92a9b3f15adb9bc13406b8cf215c3fef45b73 100644 (file)
@@ -9,9 +9,14 @@ import llfuse
 import logging
 
 class InodeTests(unittest.TestCase):
+
+    # The following tests call next(inodes._counter) because inode 1
+    # (the root directory) gets special treatment.
+
     def test_inodes_basic(self):
         cache = arvados_fuse.InodeCache(1000, 4)
         inodes = arvados_fuse.Inodes(cache)
+        next(inodes._counter)
 
         # Check that ent1 gets added to inodes
         ent1 = mock.MagicMock()
@@ -27,6 +32,7 @@ class InodeTests(unittest.TestCase):
     def test_inodes_not_persisted(self):
         cache = arvados_fuse.InodeCache(1000, 4)
         inodes = arvados_fuse.Inodes(cache)
+        next(inodes._counter)
 
         ent1 = mock.MagicMock()
         ent1.in_use.return_value = False
@@ -48,6 +54,7 @@ class InodeTests(unittest.TestCase):
     def test_inode_cleared(self):
         cache = arvados_fuse.InodeCache(1000, 4)
         inodes = arvados_fuse.Inodes(cache)
+        next(inodes._counter)
 
         # Check that ent1 gets added to inodes
         ent1 = mock.MagicMock()
@@ -68,25 +75,31 @@ class InodeTests(unittest.TestCase):
         inodes.add_entry(ent3)
 
         # Won't clear anything because min_entries = 4
-        self.assertEqual(2, len(cache._entries))
+        self.assertEqual(2, len(cache._cache_entries))
         self.assertFalse(ent1.clear.called)
         self.assertEqual(1100, cache.total())
 
         # Change min_entries
         cache.min_entries = 1
-        cache.cap_cache()
+        ent1.parent_inode = None
+        inodes.cap_cache()
+        inodes.wait_remove_queue_empty()
         self.assertEqual(600, cache.total())
         self.assertTrue(ent1.clear.called)
 
         # Touching ent1 should cause ent3 to get cleared
+        ent3.parent_inode = None
         self.assertFalse(ent3.clear.called)
-        cache.touch(ent1)
+        inodes.inode_cache.update_cache_size(ent1)
+        inodes.touch(ent1)
+        inodes.wait_remove_queue_empty()
         self.assertTrue(ent3.clear.called)
         self.assertEqual(500, cache.total())
 
     def test_clear_in_use(self):
         cache = arvados_fuse.InodeCache(1000, 4)
         inodes = arvados_fuse.Inodes(cache)
+        next(inodes._counter)
 
         ent1 = mock.MagicMock()
         ent1.in_use.return_value = True
@@ -109,10 +122,12 @@ class InodeTests(unittest.TestCase):
         ent3.clear.called = False
         self.assertFalse(ent1.clear.called)
         self.assertFalse(ent3.clear.called)
-        cache.touch(ent3)
+        inodes.touch(ent3)
+        inodes.wait_remove_queue_empty()
         self.assertFalse(ent1.clear.called)
         self.assertFalse(ent3.clear.called)
-        self.assertFalse(ent3.kernel_invalidate.called)
+        # kernel invalidate gets called anyway
+        self.assertTrue(ent3.kernel_invalidate.called)
         self.assertEqual(1100, cache.total())
 
         # ent1 still in use, ent3 doesn't have ref,
@@ -120,14 +135,17 @@ class InodeTests(unittest.TestCase):
         ent3.has_ref.return_value = False
         ent1.clear.called = False
         ent3.clear.called = False
-        cache.touch(ent3)
+        ent3.parent_inode = None
+        inodes.touch(ent3)
+        inodes.wait_remove_queue_empty()
         self.assertFalse(ent1.clear.called)
         self.assertTrue(ent3.clear.called)
         self.assertEqual(500, cache.total())
 
     def test_delete(self):
-        cache = arvados_fuse.InodeCache(1000, 4)
+        cache = arvados_fuse.InodeCache(1000, 0)
         inodes = arvados_fuse.Inodes(cache)
+        next(inodes._counter)
 
         ent1 = mock.MagicMock()
         ent1.in_use.return_value = False
@@ -147,6 +165,9 @@ class InodeTests(unittest.TestCase):
         ent1.ref_count = 0
         with llfuse.lock:
             inodes.del_entry(ent1)
+        inodes.wait_remove_queue_empty()
         self.assertEqual(0, cache.total())
-        cache.touch(ent3)
+
+        inodes.add_entry(ent3)
+        inodes.wait_remove_queue_empty()
         self.assertEqual(600, cache.total())
index f4e5138e2ce0fd5d1559046754f2c50a4f1c2ddb..b3bec39cc584124d42c51a7bbc292f5d492317bd 100644 (file)
@@ -1126,7 +1126,10 @@ class MagicDirApiError(FuseMagicTest):
 
 class SanitizeFilenameTest(MountTestBase):
     def test_sanitize_filename(self):
-        pdir = fuse.ProjectDirectory(1, {}, self.api, 0, False, project_object=self.api.users().current().execute())
+        pdir = fuse.ProjectDirectory(
+            1, fuse.Inodes(None), self.api, 0, False, None,
+            project_object=self.api.users().current().execute(),
+        )
         acceptable = [
             "foo.txt",
             ".foo",
@@ -1224,23 +1227,22 @@ class SlashSubstitutionTest(IntegrationTest):
     mnt_args = [
         '--read-write',
         '--mount-home', 'zzz',
+        '--fsns', '[SLASH]'
     ]
 
     def setUp(self):
         super(SlashSubstitutionTest, self).setUp()
+
         self.api = arvados.safeapi.ThreadSafeApiCache(
             arvados.config.settings(),
-            version='v1',
+            version='v1'
         )
-        self.api.config = lambda: {"Collections": {"ForwardSlashNameSubstitution": "[SLASH]"}}
         self.testcoll = self.api.collections().create(body={"name": "foo/bar/baz"}).execute()
         self.testcolleasy = self.api.collections().create(body={"name": "foo-bar-baz"}).execute()
         self.fusename = 'foo[SLASH]bar[SLASH]baz'
 
     @IntegrationTest.mount(argv=mnt_args)
-    @mock.patch('arvados.util.get_config_once')
-    def test_slash_substitution_before_listing(self, get_config_once):
-        get_config_once.return_value = {"Collections": {"ForwardSlashNameSubstitution": "[SLASH]"}}
+    def test_slash_substitution_before_listing(self):
         self.pool_test(os.path.join(self.mnt, 'zzz'), self.fusename)
         self.checkContents()
     @staticmethod
diff --git a/services/fuse/tests/test_mount_filters.py b/services/fuse/tests/test_mount_filters.py
new file mode 100644 (file)
index 0000000..5f32453
--- /dev/null
@@ -0,0 +1,223 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+import collections
+import itertools
+import json
+import re
+import unittest
+
+from pathlib import Path
+
+from parameterized import parameterized
+
+from arvados_fuse import fusedir
+
+from .integration_test import IntegrationTest
+from .mount_test_base import MountTestBase
+from .run_test_server import fixture
+
+_COLLECTIONS = fixture('collections')
+_GROUPS = fixture('groups')
+_LINKS = fixture('links')
+_USERS = fixture('users')
+
+class DirectoryFiltersTestCase(MountTestBase):
+    DEFAULT_ROOT_KWARGS = {
+        'enable_write': False,
+        'filters': [
+            ['collections.name', 'like', 'zzzzz-4zz18-%'],
+            # This matches both "A Project" (which we use as the test root)
+            # and "A Subproject" (which we assert is found under it).
+            ['groups.name', 'like', 'A %roject'],
+        ],
+    }
+    EXPECTED_PATHS = frozenset([
+        _COLLECTIONS['foo_collection_in_aproject']['name'],
+        _GROUPS['asubproject']['name'],
+    ])
+    CHECKED_PATHS = EXPECTED_PATHS.union([
+        _COLLECTIONS['collection_to_move_around_in_aproject']['name'],
+        _GROUPS['subproject_in_active_user_home_project_to_test_unique_key_violation']['name'],
+    ])
+
+    @parameterized.expand([
+        (fusedir.MagicDirectory, {}, _GROUPS['aproject']['uuid']),
+        (fusedir.ProjectDirectory, {'project_object': _GROUPS['aproject']}, '.'),
+        (fusedir.SharedDirectory, {'exclude': None}, Path(
+            '{first_name} {last_name}'.format_map(_USERS['active']),
+            _GROUPS['aproject']['name'],
+        )),
+    ])
+    def test_filtered_path_exists(self, root_class, root_kwargs, subdir):
+        root_kwargs = collections.ChainMap(root_kwargs, self.DEFAULT_ROOT_KWARGS)
+        self.make_mount(root_class, **root_kwargs)
+        dir_path = Path(self.mounttmp, subdir)
+        actual = frozenset(
+            basename
+            for basename in self.CHECKED_PATHS
+            if (dir_path / basename).exists()
+        )
+        self.assertEqual(
+            actual,
+            self.EXPECTED_PATHS,
+            "mount existence checks did not match expected results",
+        )
+
+    @parameterized.expand([
+        (fusedir.MagicDirectory, {}, _GROUPS['aproject']['uuid']),
+        (fusedir.ProjectDirectory, {'project_object': _GROUPS['aproject']}, '.'),
+        (fusedir.SharedDirectory, {'exclude': None}, Path(
+            '{first_name} {last_name}'.format_map(_USERS['active']),
+            _GROUPS['aproject']['name'],
+        )),
+    ])
+    def test_filtered_path_listing(self, root_class, root_kwargs, subdir):
+        root_kwargs = collections.ChainMap(root_kwargs, self.DEFAULT_ROOT_KWARGS)
+        self.make_mount(root_class, **root_kwargs)
+        actual = frozenset(path.name for path in Path(self.mounttmp, subdir).iterdir())
+        self.assertEqual(
+            actual & self.EXPECTED_PATHS,
+            self.EXPECTED_PATHS,
+            "mount listing did not include minimum matches",
+        )
+        extra = frozenset(
+            name
+            for name in actual
+            if not (name.startswith('zzzzz-4zz18-') or name.endswith('roject'))
+        )
+        self.assertFalse(
+            extra,
+            "mount listing included results outside filters",
+        )
+
+
+class TagFiltersTestCase(MountTestBase):
+    COLL_UUID = _COLLECTIONS['foo_collection_in_aproject']['uuid']
+    TAG_NAME = _LINKS['foo_collection_tag']['name']
+
+    @parameterized.expand([
+        '=',
+        '!=',
+    ])
+    def test_tag_directory_filters(self, op):
+        self.make_mount(
+            fusedir.TagDirectory,
+            enable_write=False,
+            filters=[
+                ['links.head_uuid', op, self.COLL_UUID],
+            ],
+            tag=self.TAG_NAME,
+        )
+        checked_path = Path(self.mounttmp, self.COLL_UUID)
+        self.assertEqual(checked_path.exists(), op == '=')
+
+    @parameterized.expand(itertools.product(
+        ['in', 'not in'],
+        ['=', '!='],
+    ))
+    def test_tags_directory_filters(self, coll_op, link_op):
+        self.make_mount(
+            fusedir.TagsDirectory,
+            enable_write=False,
+            filters=[
+                ['links.head_uuid', coll_op, [self.COLL_UUID]],
+                ['links.name', link_op, self.TAG_NAME],
+            ],
+        )
+        if link_op == '!=':
+            filtered_path = Path(self.mounttmp, self.TAG_NAME)
+        elif coll_op == 'not in':
+            # As of 2024-02-09, foo tag only applies to the single collection.
+            # If you filter it out via head_uuid, then it disappears completely
+            # from the TagsDirectory. Hence we set that tag directory as
+            # filtered_path. If any of this changes in the future,
+            # it would be fine to append self.COLL_UUID to filtered_path here.
+            filtered_path = Path(self.mounttmp, self.TAG_NAME)
+        else:
+            filtered_path = Path(self.mounttmp, self.TAG_NAME, self.COLL_UUID, 'foo', 'nonexistent')
+        expect_path = filtered_path.parent
+        self.assertTrue(
+            expect_path.exists(),
+            f"path not found but should exist: {expect_path}",
+        )
+        self.assertFalse(
+            filtered_path.exists(),
+            f"path was found but should be filtered out: {filtered_path}",
+        )
+
+
+class FiltersIntegrationTest(IntegrationTest):
+    COLLECTIONS_BY_PROP = {
+        coll['properties']['MainFile']: coll
+        for coll in _COLLECTIONS.values()
+        if coll['owner_uuid'] == _GROUPS['fuse_filters_test_project']['uuid']
+    }
+    PROP_VALUES = list(COLLECTIONS_BY_PROP)
+
+    for test_n, query in enumerate(['foo', 'ba?']):
+        @IntegrationTest.mount([
+            '--filters', json.dumps([
+                ['collections.properties.MainFile', 'like', query],
+            ]),
+            '--mount-by-pdh', 'by_pdh',
+            '--mount-by-id', 'by_id',
+            '--mount-home', 'home',
+        ])
+        def _test_func(self, query=query):
+            pdh_path = Path(self.mnt, 'by_pdh')
+            id_path = Path(self.mnt, 'by_id')
+            home_path = Path(self.mnt, 'home')
+            query_re = re.compile(query.replace('?', '.'))
+            for prop_val, coll in self.COLLECTIONS_BY_PROP.items():
+                should_exist = query_re.fullmatch(prop_val) is not None
+                for path in [
+                        pdh_path / coll['portable_data_hash'],
+                        id_path / coll['portable_data_hash'],
+                        id_path / coll['uuid'],
+                        home_path / coll['name'],
+                ]:
+                    self.assertEqual(
+                        path.exists(),
+                        should_exist,
+                        f"{path} from MainFile={prop_val} exists!={should_exist}",
+                    )
+        exec(f"test_collection_properties_filters_{test_n} = _test_func")
+
+    for test_n, mount_opts in enumerate([
+            ['--home'],
+            ['--project', _GROUPS['aproject']['uuid']],
+    ]):
+        @IntegrationTest.mount([
+            '--filters', json.dumps([
+                ['collections.name', 'like', 'zzzzz-4zz18-%'],
+                ['groups.name', 'like', 'A %roject'],
+            ]),
+            *mount_opts,
+        ])
+        def _test_func(self, mount_opts=mount_opts):
+            root_path = Path(self.mnt)
+            root_depth = len(root_path.parts)
+            max_depth = 0
+            name_re = re.compile(r'(zzzzz-4zz18-.*|A .*roject)')
+            dir_queue = [root_path]
+            while dir_queue:
+                root_path = dir_queue.pop()
+                max_depth = max(max_depth, len(root_path.parts))
+                for child in root_path.iterdir():
+                    if not child.is_dir():
+                        continue
+                    match = name_re.fullmatch(child.name)
+                    self.assertIsNotNone(
+                        match,
+                        "found directory with name that should've been filtered",
+                    )
+                    if not match.group(1).startswith('zzzzz-4zz18-'):
+                        dir_queue.append(child)
+            self.assertGreaterEqual(
+                max_depth,
+                root_depth + (2 if mount_opts[0] == '--home' else 1),
+                "test descended fewer subdirectories than expected",
+            )
+        exec(f"test_multiple_name_filters_{test_n} = _test_func")
index b69707af4fde2e0069529ed2d5b575d89d7c4728..44ab5cce91a4f9d3a746b7f2f2a21151d83871a4 100644 (file)
@@ -38,8 +38,8 @@ class KeepClientRetry(unittest.TestCase):
             pass
         self.assertEqual(num_retries, kc.call_args[1].get('num_retries'))
 
-    def test_default_retry_3(self):
-        self._test_retry(3, [])
+    def test_default_retry_10(self):
+        self._test_retry(10, [])
 
     def test_retry_2(self):
         self._test_retry(2, ['--retries=2'])
index e89571087e5eaf885ce47e41e10603fb805d11de..6a19b3345473259fc84fbc180df390b23991ad11 100644 (file)
@@ -31,11 +31,11 @@ class UnmountTest(IntegrationTest):
              self.mnt])
         subprocess.check_call(
             ['./bin/arv-mount', '--subtype', 'test', '--replace',
-             '--unmount-timeout', '10',
+             '--unmount-timeout', '60',
              self.mnt])
         subprocess.check_call(
             ['./bin/arv-mount', '--subtype', 'test', '--replace',
-             '--unmount-timeout', '10',
+             '--unmount-timeout', '60',
              self.mnt,
              '--exec', 'true'])
         for m in subprocess.check_output(['mount']).splitlines():
index 33c907c2031ac97dbcabe8742ad2313ead157f5f..e71eb07efa6979fa005c4a59faf70f7c3187519b 100644 (file)
@@ -137,7 +137,7 @@ func (bal *Balancer) Run(ctx context.Context, client *arvados.Client, cluster *a
        client.Timeout = 0
 
        rs := bal.rendezvousState()
-       if runOptions.CommitTrash && rs != runOptions.SafeRendezvousState {
+       if cluster.Collections.BalanceTrashLimit > 0 && rs != runOptions.SafeRendezvousState {
                if runOptions.SafeRendezvousState != "" {
                        bal.logf("notice: KeepServices list has changed since last run")
                }
@@ -155,6 +155,7 @@ func (bal *Balancer) Run(ctx context.Context, client *arvados.Client, cluster *a
        if err = bal.GetCurrentState(ctx, client, cluster.Collections.BalanceCollectionBatch, cluster.Collections.BalanceCollectionBuffers); err != nil {
                return
        }
+       bal.setupLookupTables(cluster)
        bal.ComputeChangeSets()
        bal.PrintStatistics()
        if err = bal.CheckSanityLate(); err != nil {
@@ -171,14 +172,14 @@ func (bal *Balancer) Run(ctx context.Context, client *arvados.Client, cluster *a
                }
                lbFile = nil
        }
-       if runOptions.CommitPulls {
+       if cluster.Collections.BalancePullLimit > 0 {
                err = bal.CommitPulls(ctx, client)
                if err != nil {
                        // Skip trash if we can't pull. (Too cautious?)
                        return
                }
        }
-       if runOptions.CommitTrash {
+       if cluster.Collections.BalanceTrashLimit > 0 {
                err = bal.CommitTrash(ctx, client)
                if err != nil {
                        return
@@ -227,7 +228,7 @@ func (bal *Balancer) cleanupMounts() {
        rwdev := map[string]*KeepService{}
        for _, srv := range bal.KeepServices {
                for _, mnt := range srv.mounts {
-                       if !mnt.ReadOnly {
+                       if mnt.AllowWrite {
                                rwdev[mnt.UUID] = srv
                        }
                }
@@ -237,7 +238,7 @@ func (bal *Balancer) cleanupMounts() {
        for _, srv := range bal.KeepServices {
                var dedup []*KeepMount
                for _, mnt := range srv.mounts {
-                       if mnt.ReadOnly && rwdev[mnt.UUID] != nil {
+                       if !mnt.AllowWrite && rwdev[mnt.UUID] != nil {
                                bal.logf("skipping srv %s readonly mount %q because same volume is mounted read-write on srv %s", srv, mnt.UUID, rwdev[mnt.UUID])
                        } else {
                                dedup = append(dedup, mnt)
@@ -542,7 +543,6 @@ func (bal *Balancer) ComputeChangeSets() {
        // This just calls balanceBlock() once for each block, using a
        // pool of worker goroutines.
        defer bal.time("changeset_compute", "wall clock time to compute changesets")()
-       bal.setupLookupTables()
 
        type balanceTask struct {
                blkid arvados.SizedDigest
@@ -577,7 +577,7 @@ func (bal *Balancer) ComputeChangeSets() {
        bal.collectStatistics(results)
 }
 
-func (bal *Balancer) setupLookupTables() {
+func (bal *Balancer) setupLookupTables(cluster *arvados.Cluster) {
        bal.serviceRoots = make(map[string]string)
        bal.classes = defaultClasses
        bal.mountsByClass = map[string]map[*KeepMount]bool{"default": {}}
@@ -587,9 +587,11 @@ func (bal *Balancer) setupLookupTables() {
                for _, mnt := range srv.mounts {
                        bal.mounts++
 
-                       // All mounts on a read-only service are
-                       // effectively read-only.
-                       mnt.ReadOnly = mnt.ReadOnly || srv.ReadOnly
+                       if srv.ReadOnly {
+                               // All mounts on a read-only service
+                               // are effectively read-only.
+                               mnt.AllowWrite = false
+                       }
 
                        for class := range mnt.StorageClasses {
                                if mbc := bal.mountsByClass[class]; mbc == nil {
@@ -607,6 +609,13 @@ func (bal *Balancer) setupLookupTables() {
        // class" case in balanceBlock depends on the order classes
        // are considered.
        sort.Strings(bal.classes)
+
+       for _, srv := range bal.KeepServices {
+               srv.ChangeSet = &ChangeSet{
+                       PullLimit:  cluster.Collections.BalancePullLimit,
+                       TrashLimit: cluster.Collections.BalanceTrashLimit,
+               }
+       }
 }
 
 const (
@@ -667,7 +676,7 @@ func (bal *Balancer) balanceBlock(blkid arvados.SizedDigest, blk *BlockState) ba
                        slots = append(slots, slot{
                                mnt:  mnt,
                                repl: repl,
-                               want: repl != nil && mnt.ReadOnly,
+                               want: repl != nil && !mnt.AllowTrash,
                        })
                }
        }
@@ -756,7 +765,7 @@ func (bal *Balancer) balanceBlock(blkid arvados.SizedDigest, blk *BlockState) ba
                                protMnt[slot.mnt] = true
                                replProt += slot.mnt.Replication
                        }
-                       if replWant < desired && (slot.repl != nil || !slot.mnt.ReadOnly) {
+                       if replWant < desired && (slot.repl != nil || slot.mnt.AllowWrite) {
                                slots[i].want = true
                                wantSrv[slot.mnt.KeepService] = true
                                wantMnt[slot.mnt] = true
@@ -829,23 +838,53 @@ func (bal *Balancer) balanceBlock(blkid arvados.SizedDigest, blk *BlockState) ba
        }
        blockState := computeBlockState(slots, nil, len(blk.Replicas), 0)
 
-       var lost bool
-       var changes []string
+       // Sort the slots by rendezvous order. This ensures "trash the
+       // first of N replicas with identical timestamps" is
+       // predictable (helpful for testing) and well distributed
+       // across servers.
+       sort.Slice(slots, func(i, j int) bool {
+               si, sj := slots[i], slots[j]
+               if orderi, orderj := srvRendezvous[si.mnt.KeepService], srvRendezvous[sj.mnt.KeepService]; orderi != orderj {
+                       return orderi < orderj
+               } else {
+                       return rendezvousLess(si.mnt.UUID, sj.mnt.UUID, blkid)
+               }
+       })
+
+       var (
+               lost         bool
+               changes      []string
+               trashedMtime = make(map[int64]bool, len(slots))
+       )
        for _, slot := range slots {
                // TODO: request a Touch if Mtime is duplicated.
                var change int
                switch {
                case !slot.want && slot.repl != nil && slot.repl.Mtime < bal.MinMtime:
-                       slot.mnt.KeepService.AddTrash(Trash{
-                               SizedDigest: blkid,
-                               Mtime:       slot.repl.Mtime,
-                               From:        slot.mnt,
-                       })
-                       change = changeTrash
+                       if trashedMtime[slot.repl.Mtime] {
+                               // Don't trash multiple replicas with
+                               // identical timestamps. If they are
+                               // multiple views of the same backing
+                               // storage, asking both servers to
+                               // trash is redundant and can cause
+                               // races (see #20242). If they are
+                               // distinct replicas that happen to
+                               // have identical timestamps, we'll
+                               // get this one on the next sweep.
+                               change = changeNone
+                       } else {
+                               slot.mnt.KeepService.AddTrash(Trash{
+                                       SizedDigest: blkid,
+                                       Mtime:       slot.repl.Mtime,
+                                       From:        slot.mnt,
+                               })
+                               change = changeTrash
+                               trashedMtime[slot.repl.Mtime] = true
+                       }
                case slot.repl == nil && slot.want && len(blk.Replicas) == 0:
                        lost = true
                        change = changeNone
-               case slot.repl == nil && slot.want && !slot.mnt.ReadOnly:
+               case slot.repl == nil && slot.want && slot.mnt.AllowWrite:
                        slot.mnt.KeepService.AddPull(Pull{
                                SizedDigest: blkid,
                                From:        blk.Replicas[0].KeepMount.KeepService,
@@ -925,19 +964,21 @@ type replicationStats struct {
 }
 
 type balancerStats struct {
-       lost          blocksNBytes
-       overrep       blocksNBytes
-       unref         blocksNBytes
-       garbage       blocksNBytes
-       underrep      blocksNBytes
-       unachievable  blocksNBytes
-       justright     blocksNBytes
-       desired       blocksNBytes
-       current       blocksNBytes
-       pulls         int
-       trashes       int
-       replHistogram []int
-       classStats    map[string]replicationStats
+       lost            blocksNBytes
+       overrep         blocksNBytes
+       unref           blocksNBytes
+       garbage         blocksNBytes
+       underrep        blocksNBytes
+       unachievable    blocksNBytes
+       justright       blocksNBytes
+       desired         blocksNBytes
+       current         blocksNBytes
+       pulls           int
+       pullsDeferred   int
+       trashes         int
+       trashesDeferred int
+       replHistogram   []int
+       classStats      map[string]replicationStats
 
        // collectionBytes / collectionBlockBytes = deduplication ratio
        collectionBytes      int64 // sum(bytes in referenced blocks) across all collections
@@ -1060,7 +1101,9 @@ func (bal *Balancer) collectStatistics(results <-chan balanceResult) {
        }
        for _, srv := range bal.KeepServices {
                s.pulls += len(srv.ChangeSet.Pulls)
+               s.pullsDeferred += srv.ChangeSet.PullsDeferred
                s.trashes += len(srv.ChangeSet.Trashes)
+               s.trashesDeferred += srv.ChangeSet.TrashesDeferred
        }
        bal.stats = s
        bal.Metrics.UpdateStats(s)
index fb1c74d2fe4adfb178fcd1a44a2ceba3a0e1e4e9..81e4c7b86757a603089d87e2d0b2d8996d2cecf0 100644 (file)
@@ -5,7 +5,6 @@
 package keepbalance
 
 import (
-       "bytes"
        "context"
        "encoding/json"
        "fmt"
@@ -16,6 +15,7 @@ import (
        "os"
        "strings"
        "sync"
+       "syscall"
        "time"
 
        "git.arvados.org/arvados.git/lib/config"
@@ -24,7 +24,6 @@ import (
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "github.com/jmoiron/sqlx"
        "github.com/prometheus/client_golang/prometheus"
-       "github.com/prometheus/common/expfmt"
        check "gopkg.in/check.v1"
 )
 
@@ -91,21 +90,29 @@ var stubMounts = map[string][]arvados.KeepMount{
                UUID:           "zzzzz-ivpuk-000000000000000",
                DeviceID:       "keep0-vol0",
                StorageClasses: map[string]bool{"default": true},
+               AllowWrite:     true,
+               AllowTrash:     true,
        }},
        "keep1.zzzzz.arvadosapi.com:25107": {{
                UUID:           "zzzzz-ivpuk-100000000000000",
                DeviceID:       "keep1-vol0",
                StorageClasses: map[string]bool{"default": true},
+               AllowWrite:     true,
+               AllowTrash:     true,
        }},
        "keep2.zzzzz.arvadosapi.com:25107": {{
                UUID:           "zzzzz-ivpuk-200000000000000",
                DeviceID:       "keep2-vol0",
                StorageClasses: map[string]bool{"default": true},
+               AllowWrite:     true,
+               AllowTrash:     true,
        }},
        "keep3.zzzzz.arvadosapi.com:25107": {{
                UUID:           "zzzzz-ivpuk-300000000000000",
                DeviceID:       "keep3-vol0",
                StorageClasses: map[string]bool{"default": true},
+               AllowWrite:     true,
+               AllowTrash:     true,
        }},
 }
 
@@ -390,9 +397,7 @@ func (s *runSuite) TestRefuseZeroCollections(c *check.C) {
        _, err := s.db.Exec(`delete from collections`)
        c.Assert(err, check.IsNil)
        opts := RunOptions{
-               CommitPulls: true,
-               CommitTrash: true,
-               Logger:      ctxlog.TestLogger(c),
+               Logger: ctxlog.TestLogger(c),
        }
        s.stub.serveCurrentUserAdmin()
        s.stub.serveZeroCollections()
@@ -410,8 +415,6 @@ func (s *runSuite) TestRefuseZeroCollections(c *check.C) {
 
 func (s *runSuite) TestRefuseBadIndex(c *check.C) {
        opts := RunOptions{
-               CommitPulls: true,
-               CommitTrash: true,
                ChunkPrefix: "abc",
                Logger:      ctxlog.TestLogger(c),
        }
@@ -433,9 +436,7 @@ func (s *runSuite) TestRefuseBadIndex(c *check.C) {
 
 func (s *runSuite) TestRefuseNonAdmin(c *check.C) {
        opts := RunOptions{
-               CommitPulls: true,
-               CommitTrash: true,
-               Logger:      ctxlog.TestLogger(c),
+               Logger: ctxlog.TestLogger(c),
        }
        s.stub.serveCurrentUserNotAdmin()
        s.stub.serveZeroCollections()
@@ -462,8 +463,6 @@ func (s *runSuite) TestInvalidChunkPrefix(c *check.C) {
                s.SetUpTest(c)
                c.Logf("trying invalid prefix %q", trial.prefix)
                opts := RunOptions{
-                       CommitPulls: true,
-                       CommitTrash: true,
                        ChunkPrefix: trial.prefix,
                        Logger:      ctxlog.TestLogger(c),
                }
@@ -483,9 +482,7 @@ func (s *runSuite) TestInvalidChunkPrefix(c *check.C) {
 
 func (s *runSuite) TestRefuseSameDeviceDifferentVolumes(c *check.C) {
        opts := RunOptions{
-               CommitPulls: true,
-               CommitTrash: true,
-               Logger:      ctxlog.TestLogger(c),
+               Logger: ctxlog.TestLogger(c),
        }
        s.stub.serveCurrentUserAdmin()
        s.stub.serveZeroCollections()
@@ -513,9 +510,7 @@ func (s *runSuite) TestWriteLostBlocks(c *check.C) {
        s.config.Collections.BlobMissingReport = lostf.Name()
        defer os.Remove(lostf.Name())
        opts := RunOptions{
-               CommitPulls: true,
-               CommitTrash: true,
-               Logger:      ctxlog.TestLogger(c),
+               Logger: ctxlog.TestLogger(c),
        }
        s.stub.serveCurrentUserAdmin()
        s.stub.serveFooBarFileCollections()
@@ -534,10 +529,10 @@ func (s *runSuite) TestWriteLostBlocks(c *check.C) {
 }
 
 func (s *runSuite) TestDryRun(c *check.C) {
+       s.config.Collections.BalanceTrashLimit = 0
+       s.config.Collections.BalancePullLimit = 0
        opts := RunOptions{
-               CommitPulls: false,
-               CommitTrash: false,
-               Logger:      ctxlog.TestLogger(c),
+               Logger: ctxlog.TestLogger(c),
        }
        s.stub.serveCurrentUserAdmin()
        collReqs := s.stub.serveFooBarFileCollections()
@@ -555,19 +550,24 @@ func (s *runSuite) TestDryRun(c *check.C) {
        }
        c.Check(trashReqs.Count(), check.Equals, 0)
        c.Check(pullReqs.Count(), check.Equals, 0)
-       c.Check(bal.stats.pulls, check.Not(check.Equals), 0)
+       c.Check(bal.stats.pulls, check.Equals, 0)
+       c.Check(bal.stats.pullsDeferred, check.Not(check.Equals), 0)
+       c.Check(bal.stats.trashes, check.Equals, 0)
+       c.Check(bal.stats.trashesDeferred, check.Not(check.Equals), 0)
        c.Check(bal.stats.underrep.replicas, check.Not(check.Equals), 0)
        c.Check(bal.stats.overrep.replicas, check.Not(check.Equals), 0)
+
+       metrics := arvadostest.GatherMetricsAsString(srv.Metrics.reg)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_trash_entries_deferred_count [1-9].*`)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_pull_entries_deferred_count [1-9].*`)
 }
 
 func (s *runSuite) TestCommit(c *check.C) {
        s.config.Collections.BlobMissingReport = c.MkDir() + "/keep-balance-lost-blocks-test-"
        s.config.ManagementToken = "xyzzy"
        opts := RunOptions{
-               CommitPulls: true,
-               CommitTrash: true,
-               Logger:      ctxlog.TestLogger(c),
-               Dumper:      ctxlog.TestLogger(c),
+               Logger: ctxlog.TestLogger(c),
+               Dumper: ctxlog.TestLogger(c),
        }
        s.stub.serveCurrentUserAdmin()
        s.stub.serveFooBarFileCollections()
@@ -591,21 +591,47 @@ func (s *runSuite) TestCommit(c *check.C) {
        c.Assert(err, check.IsNil)
        c.Check(string(lost), check.Not(check.Matches), `(?ms).*acbd18db4cc2f85cedef654fccc4a4d8.*`)
 
-       buf, err := s.getMetrics(c, srv)
-       c.Check(err, check.IsNil)
-       bufstr := buf.String()
-       c.Check(bufstr, check.Matches, `(?ms).*\narvados_keep_total_bytes 15\n.*`)
-       c.Check(bufstr, check.Matches, `(?ms).*\narvados_keepbalance_changeset_compute_seconds_sum [0-9\.]+\n.*`)
-       c.Check(bufstr, check.Matches, `(?ms).*\narvados_keepbalance_changeset_compute_seconds_count 1\n.*`)
-       c.Check(bufstr, check.Matches, `(?ms).*\narvados_keep_dedup_byte_ratio [1-9].*`)
-       c.Check(bufstr, check.Matches, `(?ms).*\narvados_keep_dedup_block_ratio [1-9].*`)
+       metrics := arvadostest.GatherMetricsAsString(srv.Metrics.reg)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_total_bytes 15\n.*`)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keepbalance_changeset_compute_seconds_sum [0-9\.]+\n.*`)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keepbalance_changeset_compute_seconds_count 1\n.*`)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_dedup_byte_ratio [1-9].*`)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_dedup_block_ratio [1-9].*`)
+
+       for _, cat := range []string{
+               "dedup_byte_ratio", "dedup_block_ratio", "collection_bytes",
+               "referenced_bytes", "referenced_blocks", "reference_count",
+               "pull_entries_sent_count",
+               "trash_entries_sent_count",
+       } {
+               c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_`+cat+` [1-9].*`)
+       }
+
+       for _, cat := range []string{
+               "pull_entries_deferred_count",
+               "trash_entries_deferred_count",
+       } {
+               c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_`+cat+` 0\n.*`)
+       }
+
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_replicated_block_count{replicas="0"} [1-9].*`)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_replicated_block_count{replicas="1"} [1-9].*`)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_replicated_block_count{replicas="9"} 0\n.*`)
+
+       for _, sub := range []string{"replicas", "blocks", "bytes"} {
+               for _, cat := range []string{"needed", "unneeded", "unachievable", "pulling"} {
+                       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_usage_`+sub+`{status="`+cat+`",storage_class="default"} [1-9].*`)
+               }
+               for _, cat := range []string{"total", "garbage", "transient", "overreplicated", "underreplicated", "unachievable", "balanced", "desired", "lost"} {
+                       c.Check(metrics, check.Matches, `(?ms).*\narvados_keep_`+cat+`_`+sub+` [0-9].*`)
+               }
+       }
+       c.Logf("%s", metrics)
 }
 
 func (s *runSuite) TestChunkPrefix(c *check.C) {
        s.config.Collections.BlobMissingReport = c.MkDir() + "/keep-balance-lost-blocks-test-"
        opts := RunOptions{
-               CommitPulls: true,
-               CommitTrash: true,
                ChunkPrefix: "ac", // catch "foo" but not "bar"
                Logger:      ctxlog.TestLogger(c),
                Dumper:      ctxlog.TestLogger(c),
@@ -632,13 +658,11 @@ func (s *runSuite) TestChunkPrefix(c *check.C) {
        c.Check(string(lost), check.Equals, "")
 }
 
-func (s *runSuite) TestRunForever(c *check.C) {
+func (s *runSuite) TestRunForever_TriggeredByTimer(c *check.C) {
        s.config.ManagementToken = "xyzzy"
        opts := RunOptions{
-               CommitPulls: true,
-               CommitTrash: true,
-               Logger:      ctxlog.TestLogger(c),
-               Dumper:      ctxlog.TestLogger(c),
+               Logger: ctxlog.TestLogger(c),
+               Dumper: ctxlog.TestLogger(c),
        }
        s.stub.serveCurrentUserAdmin()
        s.stub.serveFooBarFileCollections()
@@ -650,7 +674,7 @@ func (s *runSuite) TestRunForever(c *check.C) {
 
        ctx, cancel := context.WithCancel(context.Background())
        defer cancel()
-       s.config.Collections.BalancePeriod = arvados.Duration(time.Millisecond)
+       s.config.Collections.BalancePeriod = arvados.Duration(10 * time.Millisecond)
        srv := s.newServer(&opts)
 
        done := make(chan bool)
@@ -661,33 +685,82 @@ func (s *runSuite) TestRunForever(c *check.C) {
 
        // Each run should send 4 pull lists + 4 trash lists. The
        // first run should also send 4 empty trash lists at
-       // startup. We should complete all four runs in much less than
-       // a second.
-       for t0 := time.Now(); pullReqs.Count() < 16 && time.Since(t0) < 10*time.Second; {
+       // startup. We should complete at least four runs in much less
+       // than 10s.
+       for t0 := time.Now(); time.Since(t0) < 10*time.Second; {
+               pulls := pullReqs.Count()
+               if pulls >= 16 && trashReqs.Count() == pulls+4 {
+                       break
+               }
                time.Sleep(time.Millisecond)
        }
        cancel()
        <-done
        c.Check(pullReqs.Count() >= 16, check.Equals, true)
-       c.Check(trashReqs.Count(), check.Equals, pullReqs.Count()+4)
+       c.Check(trashReqs.Count() >= 20, check.Equals, true)
 
-       buf, err := s.getMetrics(c, srv)
-       c.Check(err, check.IsNil)
-       c.Check(buf, check.Matches, `(?ms).*\narvados_keepbalance_changeset_compute_seconds_count `+fmt.Sprintf("%d", pullReqs.Count()/4)+`\n.*`)
+       // We should have completed 4 runs before calling cancel().
+       // But the next run might also have started before we called
+       // cancel(), in which case the extra run will be included in
+       // the changeset_compute_seconds_count metric.
+       completed := pullReqs.Count() / 4
+       metrics := arvadostest.GatherMetricsAsString(srv.Metrics.reg)
+       c.Check(metrics, check.Matches, fmt.Sprintf(`(?ms).*\narvados_keepbalance_changeset_compute_seconds_count (%d|%d)\n.*`, completed, completed+1))
 }
 
-func (s *runSuite) getMetrics(c *check.C, srv *Server) (*bytes.Buffer, error) {
-       mfs, err := srv.Metrics.reg.Gather()
-       if err != nil {
-               return nil, err
+func (s *runSuite) TestRunForever_TriggeredBySignal(c *check.C) {
+       s.config.ManagementToken = "xyzzy"
+       opts := RunOptions{
+               Logger: ctxlog.TestLogger(c),
+               Dumper: ctxlog.TestLogger(c),
        }
+       s.stub.serveCurrentUserAdmin()
+       s.stub.serveFooBarFileCollections()
+       s.stub.serveKeepServices(stubServices)
+       s.stub.serveKeepstoreMounts()
+       s.stub.serveKeepstoreIndexFoo4Bar1()
+       trashReqs := s.stub.serveKeepstoreTrash()
+       pullReqs := s.stub.serveKeepstorePull()
+
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+       s.config.Collections.BalancePeriod = arvados.Duration(time.Minute)
+       srv := s.newServer(&opts)
+
+       done := make(chan bool)
+       go func() {
+               srv.runForever(ctx)
+               close(done)
+       }()
+
+       procself, err := os.FindProcess(os.Getpid())
+       c.Assert(err, check.IsNil)
 
-       var buf bytes.Buffer
-       for _, mf := range mfs {
-               if _, err := expfmt.MetricFamilyToText(&buf, mf); err != nil {
-                       return nil, err
+       // Each run should send 4 pull lists + 4 trash lists. The
+       // first run should also send 4 empty trash lists at
+       // startup. We should be able to complete four runs in much
+       // less than 10s.
+       completedRuns := 0
+       for t0 := time.Now(); time.Since(t0) < 10*time.Second; {
+               pulls := pullReqs.Count()
+               if pulls >= 16 && trashReqs.Count() == pulls+4 {
+                       break
+               }
+               // Once the 1st run has started automatically, we
+               // start sending a single SIGUSR1 at the end of each
+               // run, to ensure we get exactly 4 runs in total.
+               if pulls > 0 && pulls%4 == 0 && pulls <= 12 && pulls/4 > completedRuns {
+                       completedRuns = pulls / 4
+                       c.Logf("completed run %d, sending SIGUSR1 to trigger next run", completedRuns)
+                       procself.Signal(syscall.SIGUSR1)
                }
+               time.Sleep(time.Millisecond)
        }
+       cancel()
+       <-done
+       c.Check(pullReqs.Count(), check.Equals, 16)
+       c.Check(trashReqs.Count(), check.Equals, 20)
 
-       return &buf, nil
+       metrics := arvadostest.GatherMetricsAsString(srv.Metrics.reg)
+       c.Check(metrics, check.Matches, `(?ms).*\narvados_keepbalance_changeset_compute_seconds_count 4\n.*`)
 }
index 6626609b5769f55bdb7d32385afffc443df8712c..85d4ff8b5d9f484746463260eb589109cc06660c 100644 (file)
@@ -12,6 +12,7 @@ import (
        "testing"
        "time"
 
+       "git.arvados.org/arvados.git/lib/config"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
        check "gopkg.in/check.v1"
@@ -26,6 +27,7 @@ var _ = check.Suite(&balancerSuite{})
 
 type balancerSuite struct {
        Balancer
+       config          *arvados.Cluster
        srvs            []*KeepService
        blks            map[string]tester
        knownRendezvous [][]int
@@ -72,6 +74,11 @@ func (bal *balancerSuite) SetUpSuite(c *check.C) {
 
        bal.signatureTTL = 3600
        bal.Logger = ctxlog.TestLogger(c)
+
+       cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+       c.Assert(err, check.Equals, nil)
+       bal.config, err = cfg.GetCluster("")
+       c.Assert(err, check.Equals, nil)
 }
 
 func (bal *balancerSuite) SetUpTest(c *check.C) {
@@ -87,6 +94,8 @@ func (bal *balancerSuite) SetUpTest(c *check.C) {
                        KeepMount: arvados.KeepMount{
                                UUID:           fmt.Sprintf("zzzzz-mount-%015x", i),
                                StorageClasses: map[string]bool{"default": true},
+                               AllowWrite:     true,
+                               AllowTrash:     true,
                        },
                        KeepService: srv,
                }}
@@ -153,15 +162,53 @@ func (bal *balancerSuite) TestSkipReadonly(c *check.C) {
                }})
 }
 
+func (bal *balancerSuite) TestAllowTrashWhenReadOnly(c *check.C) {
+       srvs := bal.srvList(0, slots{3})
+       srvs[0].mounts[0].KeepMount.AllowWrite = false
+       srvs[0].mounts[0].KeepMount.AllowTrash = true
+       // can't pull to slot 3, so pull to slot 4 instead
+       bal.try(c, tester{
+               desired:    map[string]int{"default": 4},
+               current:    slots{0, 1},
+               shouldPull: slots{2, 4},
+               expectBlockState: &balancedBlockState{
+                       needed:  2,
+                       pulling: 2,
+               }})
+       // expect to be able to trash slot 3 in future, so pull to
+       // slot 1
+       bal.try(c, tester{
+               desired:    map[string]int{"default": 2},
+               current:    slots{0, 3},
+               shouldPull: slots{1},
+               expectBlockState: &balancedBlockState{
+                       needed:  2,
+                       pulling: 1,
+               }})
+       // trash excess from slot 3
+       bal.try(c, tester{
+               desired:     map[string]int{"default": 2},
+               current:     slots{0, 1, 3},
+               shouldTrash: slots{3},
+               expectBlockState: &balancedBlockState{
+                       needed:   2,
+                       unneeded: 1,
+               }})
+}
+
 func (bal *balancerSuite) TestMultipleViewsReadOnly(c *check.C) {
-       bal.testMultipleViews(c, true)
+       bal.testMultipleViews(c, false, false)
+}
+
+func (bal *balancerSuite) TestMultipleViewsReadOnlyAllowTrash(c *check.C) {
+       bal.testMultipleViews(c, false, true)
 }
 
 func (bal *balancerSuite) TestMultipleViews(c *check.C) {
-       bal.testMultipleViews(c, false)
+       bal.testMultipleViews(c, true, true)
 }
 
-func (bal *balancerSuite) testMultipleViews(c *check.C, readonly bool) {
+func (bal *balancerSuite) testMultipleViews(c *check.C, allowWrite, allowTrash bool) {
        for i, srv := range bal.srvs {
                // Add a mount to each service
                srv.mounts[0].KeepMount.DeviceID = fmt.Sprintf("writable-by-srv-%x", i)
@@ -169,7 +216,8 @@ func (bal *balancerSuite) testMultipleViews(c *check.C, readonly bool) {
                        KeepMount: arvados.KeepMount{
                                DeviceID:       bal.srvs[(i+1)%len(bal.srvs)].mounts[0].KeepMount.DeviceID,
                                UUID:           bal.srvs[(i+1)%len(bal.srvs)].mounts[0].KeepMount.UUID,
-                               ReadOnly:       readonly,
+                               AllowWrite:     allowWrite,
+                               AllowTrash:     allowTrash,
                                Replication:    1,
                                StorageClasses: map[string]bool{"default": true},
                        },
@@ -188,11 +236,12 @@ func (bal *balancerSuite) testMultipleViews(c *check.C, readonly bool) {
                                desired:     map[string]int{"default": 1},
                                current:     slots{0, i, i},
                                shouldTrash: slots{i}})
-               } else if readonly {
+               } else if !allowTrash {
                        // Timestamps are all different, and the third
                        // replica can't be trashed because it's on a
-                       // read-only mount, so the first two replicas
-                       // should be trashed.
+                       // read-only mount (with
+                       // AllowTrashWhenReadOnly=false), so the first
+                       // two replicas should be trashed.
                        bal.try(c, tester{
                                desired:     map[string]int{"default": 1},
                                current:     slots{0, i, i},
@@ -321,6 +370,35 @@ func (bal *balancerSuite) TestDecreaseReplTimestampCollision(c *check.C) {
                desired:    map[string]int{"default": 2},
                current:    slots{0, 1, 2},
                timestamps: []int64{12345678, 10000000, 10000000}})
+       bal.try(c, tester{
+               desired:     map[string]int{"default": 0},
+               current:     slots{0, 1, 2},
+               timestamps:  []int64{12345678, 12345678, 12345678},
+               shouldTrash: slots{0},
+               shouldTrashMounts: []string{
+                       bal.srvs[bal.knownRendezvous[0][0]].mounts[0].UUID}})
+       bal.try(c, tester{
+               desired:     map[string]int{"default": 2},
+               current:     slots{0, 1, 2, 5, 6},
+               timestamps:  []int64{12345678, 12345679, 10000000, 10000000, 10000000},
+               shouldTrash: slots{2},
+               shouldTrashMounts: []string{
+                       bal.srvs[bal.knownRendezvous[0][2]].mounts[0].UUID}})
+       bal.try(c, tester{
+               desired:     map[string]int{"default": 2},
+               current:     slots{0, 1, 2, 5, 6},
+               timestamps:  []int64{12345678, 12345679, 12345671, 10000000, 10000000},
+               shouldTrash: slots{2, 5},
+               shouldTrashMounts: []string{
+                       bal.srvs[bal.knownRendezvous[0][2]].mounts[0].UUID,
+                       bal.srvs[bal.knownRendezvous[0][5]].mounts[0].UUID}})
+       bal.try(c, tester{
+               desired:     map[string]int{"default": 2},
+               current:     slots{0, 1, 2, 5, 6},
+               timestamps:  []int64{12345678, 12345679, 12345679, 10000000, 10000000},
+               shouldTrash: slots{5},
+               shouldTrashMounts: []string{
+                       bal.srvs[bal.knownRendezvous[0][5]].mounts[0].UUID}})
 }
 
 func (bal *balancerSuite) TestDecreaseReplBlockTooNew(c *check.C) {
@@ -345,7 +423,7 @@ func (bal *balancerSuite) TestDecreaseReplBlockTooNew(c *check.C) {
 }
 
 func (bal *balancerSuite) TestCleanupMounts(c *check.C) {
-       bal.srvs[3].mounts[0].KeepMount.ReadOnly = true
+       bal.srvs[3].mounts[0].KeepMount.AllowWrite = false
        bal.srvs[3].mounts[0].KeepMount.DeviceID = "abcdef"
        bal.srvs[14].mounts[0].KeepMount.UUID = bal.srvs[3].mounts[0].KeepMount.UUID
        bal.srvs[14].mounts[0].KeepMount.DeviceID = "abcdef"
@@ -554,6 +632,8 @@ func (bal *balancerSuite) TestChangeStorageClasses(c *check.C) {
        // classes=[special,special2].
        bal.srvs[9].mounts = []*KeepMount{{
                KeepMount: arvados.KeepMount{
+                       AllowWrite:     true,
+                       AllowTrash:     true,
                        Replication:    1,
                        StorageClasses: map[string]bool{"special": true},
                        UUID:           "zzzzz-mount-special00000009",
@@ -562,6 +642,8 @@ func (bal *balancerSuite) TestChangeStorageClasses(c *check.C) {
                KeepService: bal.srvs[9],
        }, {
                KeepMount: arvados.KeepMount{
+                       AllowWrite:     true,
+                       AllowTrash:     true,
                        Replication:    1,
                        StorageClasses: map[string]bool{"special": true, "special2": true},
                        UUID:           "zzzzz-mount-special20000009",
@@ -574,6 +656,8 @@ func (bal *balancerSuite) TestChangeStorageClasses(c *check.C) {
        // classes=[special3], one with classes=[default].
        bal.srvs[13].mounts = []*KeepMount{{
                KeepMount: arvados.KeepMount{
+                       AllowWrite:     true,
+                       AllowTrash:     true,
                        Replication:    1,
                        StorageClasses: map[string]bool{"special2": true},
                        UUID:           "zzzzz-mount-special2000000d",
@@ -582,6 +666,8 @@ func (bal *balancerSuite) TestChangeStorageClasses(c *check.C) {
                KeepService: bal.srvs[13],
        }, {
                KeepMount: arvados.KeepMount{
+                       AllowWrite:     true,
+                       AllowTrash:     true,
                        Replication:    1,
                        StorageClasses: map[string]bool{"default": true},
                        UUID:           "zzzzz-mount-00000000000000d",
@@ -664,7 +750,7 @@ func (bal *balancerSuite) TestChangeStorageClasses(c *check.C) {
 // the appropriate changes for that block have been added to the
 // changesets.
 func (bal *balancerSuite) try(c *check.C, t tester) {
-       bal.setupLookupTables()
+       bal.setupLookupTables(bal.config)
        blk := &BlockState{
                Replicas: bal.replList(t.known, t.current),
                Desired:  t.desired,
@@ -672,9 +758,6 @@ func (bal *balancerSuite) try(c *check.C, t tester) {
        for i, t := range t.timestamps {
                blk.Replicas[i].Mtime = t
        }
-       for _, srv := range bal.srvs {
-               srv.ChangeSet = &ChangeSet{}
-       }
        result := bal.balanceBlock(knownBlkid(t.known), blk)
 
        var didPull, didTrash slots
index 8e0ba028acd801e182a9b475f47b658c86e250e1..771e277d60a4befe5367bfc2299ec80da145b2bb 100644 (file)
@@ -10,6 +10,7 @@ import (
        "sync"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/services/keepstore"
 )
 
 // Pull is a request to retrieve a block from a remote server, and
@@ -23,13 +24,8 @@ type Pull struct {
 // MarshalJSON formats a pull request the way keepstore wants to see
 // it.
 func (p Pull) MarshalJSON() ([]byte, error) {
-       type KeepstorePullRequest struct {
-               Locator   string   `json:"locator"`
-               Servers   []string `json:"servers"`
-               MountUUID string   `json:"mount_uuid"`
-       }
-       return json.Marshal(KeepstorePullRequest{
-               Locator:   string(p.SizedDigest[:32]),
+       return json.Marshal(keepstore.PullListItem{
+               Locator:   string(p.SizedDigest),
                Servers:   []string{p.From.URLBase()},
                MountUUID: p.To.KeepMount.UUID,
        })
@@ -45,13 +41,8 @@ type Trash struct {
 // MarshalJSON formats a trash request the way keepstore wants to see
 // it, i.e., as a bare locator with no +size hint.
 func (t Trash) MarshalJSON() ([]byte, error) {
-       type KeepstoreTrashRequest struct {
-               Locator    string `json:"locator"`
-               BlockMtime int64  `json:"block_mtime"`
-               MountUUID  string `json:"mount_uuid"`
-       }
-       return json.Marshal(KeepstoreTrashRequest{
-               Locator:    string(t.SizedDigest[:32]),
+       return json.Marshal(keepstore.TrashListItem{
+               Locator:    string(t.SizedDigest),
                BlockMtime: t.Mtime,
                MountUUID:  t.From.KeepMount.UUID,
        })
@@ -60,22 +51,35 @@ func (t Trash) MarshalJSON() ([]byte, error) {
 // ChangeSet is a set of change requests that will be sent to a
 // keepstore server.
 type ChangeSet struct {
-       Pulls   []Pull
-       Trashes []Trash
-       mutex   sync.Mutex
+       PullLimit  int
+       TrashLimit int
+
+       Pulls           []Pull
+       PullsDeferred   int // number that weren't added because of PullLimit
+       Trashes         []Trash
+       TrashesDeferred int // number that weren't added because of TrashLimit
+       mutex           sync.Mutex
 }
 
 // AddPull adds a Pull operation.
 func (cs *ChangeSet) AddPull(p Pull) {
        cs.mutex.Lock()
-       cs.Pulls = append(cs.Pulls, p)
+       if len(cs.Pulls) < cs.PullLimit {
+               cs.Pulls = append(cs.Pulls, p)
+       } else {
+               cs.PullsDeferred++
+       }
        cs.mutex.Unlock()
 }
 
 // AddTrash adds a Trash operation
 func (cs *ChangeSet) AddTrash(t Trash) {
        cs.mutex.Lock()
-       cs.Trashes = append(cs.Trashes, t)
+       if len(cs.Trashes) < cs.TrashLimit {
+               cs.Trashes = append(cs.Trashes, t)
+       } else {
+               cs.TrashesDeferred++
+       }
        cs.mutex.Unlock()
 }
 
@@ -83,5 +87,5 @@ func (cs *ChangeSet) AddTrash(t Trash) {
 func (cs *ChangeSet) String() string {
        cs.mutex.Lock()
        defer cs.mutex.Unlock()
-       return fmt.Sprintf("ChangeSet{Pulls:%d, Trashes:%d}", len(cs.Pulls), len(cs.Trashes))
+       return fmt.Sprintf("ChangeSet{Pulls:%d, Trashes:%d} Deferred{Pulls:%d Trashes:%d}", len(cs.Pulls), len(cs.Trashes), cs.PullsDeferred, cs.TrashesDeferred)
 }
index 5474d29fb57e2d64a67286382b1d53907afe3ae7..f2b9429017cf52a4b21398a4d8106b70a3e34757 100644 (file)
@@ -33,12 +33,12 @@ func (s *changeSetSuite) TestJSONFormat(c *check.C) {
                To:          mnt,
                From:        srv}})
        c.Check(err, check.IsNil)
-       c.Check(string(buf), check.Equals, `[{"locator":"acbd18db4cc2f85cedef654fccc4a4d8","servers":["http://keep1.zzzzz.arvadosapi.com:25107"],"mount_uuid":"zzzzz-mount-abcdefghijklmno"}]`)
+       c.Check(string(buf), check.Equals, `[{"locator":"acbd18db4cc2f85cedef654fccc4a4d8+3","servers":["http://keep1.zzzzz.arvadosapi.com:25107"],"mount_uuid":"zzzzz-mount-abcdefghijklmno"}]`)
 
        buf, err = json.Marshal([]Trash{{
                SizedDigest: arvados.SizedDigest("acbd18db4cc2f85cedef654fccc4a4d8+3"),
                From:        mnt,
                Mtime:       123456789}})
        c.Check(err, check.IsNil)
-       c.Check(string(buf), check.Equals, `[{"locator":"acbd18db4cc2f85cedef654fccc4a4d8","block_mtime":123456789,"mount_uuid":"zzzzz-mount-abcdefghijklmno"}]`)
+       c.Check(string(buf), check.Equals, `[{"locator":"acbd18db4cc2f85cedef654fccc4a4d8+3","block_mtime":123456789,"mount_uuid":"zzzzz-mount-abcdefghijklmno"}]`)
 }
index 42463a002a5ec73652f7f7ef6f00f8a8c4fb44a1..20d0040b1fa999acc53ae8f9d793581f369a0e0e 100644 (file)
@@ -47,6 +47,7 @@ func (s *integrationSuite) SetUpSuite(c *check.C) {
 
        s.keepClient, err = keepclient.MakeKeepClient(arv)
        c.Assert(err, check.IsNil)
+       s.keepClient.DiskCacheSize = keepclient.DiskCacheDisabled
        s.putReplicas(c, "foo", 4)
        s.putReplicas(c, "bar", 1)
 }
@@ -87,8 +88,6 @@ func (s *integrationSuite) TestBalanceAPIFixtures(c *check.C) {
                logger := logrus.New()
                logger.Out = io.MultiWriter(&logBuf, os.Stderr)
                opts := RunOptions{
-                       CommitPulls:           true,
-                       CommitTrash:           true,
                        CommitConfirmedFields: true,
                        Logger:                logger,
                }
@@ -101,11 +100,10 @@ func (s *integrationSuite) TestBalanceAPIFixtures(c *check.C) {
                nextOpts, err := bal.Run(context.Background(), s.client, s.config, opts)
                c.Check(err, check.IsNil)
                c.Check(nextOpts.SafeRendezvousState, check.Not(check.Equals), "")
-               c.Check(nextOpts.CommitPulls, check.Equals, true)
                if iter == 0 {
                        c.Check(logBuf.String(), check.Matches, `(?ms).*ChangeSet{Pulls:1.*`)
                        c.Check(logBuf.String(), check.Not(check.Matches), `(?ms).*ChangeSet{.*Trashes:[^0]}*`)
-               } else if strings.Contains(logBuf.String(), "ChangeSet{Pulls:0") {
+               } else if !strings.Contains(logBuf.String(), "ChangeSet{Pulls:1") {
                        break
                }
                time.Sleep(200 * time.Millisecond)
index 6bc998958979fc41288cd051bb40eaae4a10de4e..ec1cb18ee1087063a57d63f73c2c00b5a17eab33 100644 (file)
@@ -32,10 +32,10 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
        flags := flag.NewFlagSet(prog, flag.ContinueOnError)
        flags.BoolVar(&options.Once, "once", false,
                "balance once and then exit")
-       flags.BoolVar(&options.CommitPulls, "commit-pulls", false,
-               "send pull requests (make more replicas of blocks that are underreplicated or are not in optimal rendezvous probe order)")
-       flags.BoolVar(&options.CommitTrash, "commit-trash", false,
-               "send trash requests (delete unreferenced old blocks, and excess replicas of overreplicated blocks)")
+       deprCommitPulls := flags.Bool("commit-pulls", true,
+               "send pull requests (must be true -- configure Collections.BalancePullLimit = 0 to disable.)")
+       deprCommitTrash := flags.Bool("commit-trash", true,
+               "send trash requests (must be true -- configure Collections.BalanceTrashLimit = 0 to disable.)")
        flags.BoolVar(&options.CommitConfirmedFields, "commit-confirmed-fields", true,
                "update collection fields (replicas_confirmed, storage_classes_confirmed, etc.)")
        flags.StringVar(&options.ChunkPrefix, "chunk-prefix", "",
@@ -55,6 +55,13 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
                return code
        }
 
+       if !*deprCommitPulls || !*deprCommitTrash {
+               fmt.Fprint(stderr,
+                       "Usage error: the -commit-pulls or -commit-trash command line flags are no longer supported.\n",
+                       "Use Collections.BalancePullLimit and Collections.BalanceTrashLimit instead.\n")
+               return cmd.EXIT_INVALIDARGUMENT
+       }
+
        // Drop our custom args that would be rejected by the generic
        // service.Command
        args = nil
index 4683b67b9860052d97d8fa77e92141ae29bdcef1..02cee3955f70e372924c15a3bb2ed8345db6bebf 100644 (file)
@@ -7,6 +7,7 @@ package keepbalance
 import (
        "fmt"
        "net/http"
+       "strconv"
        "sync"
 
        "github.com/prometheus/client_golang/prometheus"
@@ -17,18 +18,20 @@ type observer interface{ Observe(float64) }
 type setter interface{ Set(float64) }
 
 type metrics struct {
-       reg         *prometheus.Registry
-       statsGauges map[string]setter
-       observers   map[string]observer
-       setupOnce   sync.Once
-       mtx         sync.Mutex
+       reg            *prometheus.Registry
+       statsGauges    map[string]setter
+       statsGaugeVecs map[string]*prometheus.GaugeVec
+       observers      map[string]observer
+       setupOnce      sync.Once
+       mtx            sync.Mutex
 }
 
 func newMetrics(registry *prometheus.Registry) *metrics {
        return &metrics{
-               reg:         registry,
-               statsGauges: map[string]setter{},
-               observers:   map[string]observer{},
+               reg:            registry,
+               statsGauges:    map[string]setter{},
+               statsGaugeVecs: map[string]*prometheus.GaugeVec{},
+               observers:      map[string]observer{},
        }
 }
 
@@ -63,9 +66,24 @@ func (m *metrics) UpdateStats(s balancerStats) {
                "transient":         {s.unref, "transient (unreferenced, new)"},
                "overreplicated":    {s.overrep, "overreplicated"},
                "underreplicated":   {s.underrep, "underreplicated"},
+               "unachievable":      {s.unachievable, "unachievable"},
+               "balanced":          {s.justright, "optimally balanced"},
+               "desired":           {s.desired, "desired"},
                "lost":              {s.lost, "lost"},
                "dedup_byte_ratio":  {s.dedupByteRatio(), "deduplication ratio, bytes referenced / bytes stored"},
                "dedup_block_ratio": {s.dedupBlockRatio(), "deduplication ratio, blocks referenced / blocks stored"},
+               "collection_bytes":  {s.collectionBytes, "total apparent size of all collections"},
+               "referenced_bytes":  {s.collectionBlockBytes, "total size of unique referenced blocks"},
+               "reference_count":   {s.collectionBlockRefs, "block references in all collections"},
+               "referenced_blocks": {s.collectionBlocks, "blocks referenced by any collection"},
+
+               "pull_entries_sent_count":      {s.pulls, "total entries sent in pull lists"},
+               "pull_entries_deferred_count":  {s.pullsDeferred, "total entries deferred (not sent) in pull lists"},
+               "trash_entries_sent_count":     {s.trashes, "total entries sent in trash lists"},
+               "trash_entries_deferred_count": {s.trashesDeferred, "total entries deferred (not sent) in trash lists"},
+
+               "replicated_block_count": {s.replHistogram, "blocks with indicated number of replicas at last count"},
+               "usage":                  {s.classStats, "stored in indicated storage class"},
        }
        m.setupOnce.Do(func() {
                // Register gauge(s) for each balancerStats field.
@@ -87,6 +105,29 @@ func (m *metrics) UpdateStats(s balancerStats) {
                                }
                        case int, int64, float64:
                                addGauge(name, gauge.Help)
+                       case []int:
+                               // replHistogram
+                               gv := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+                                       Namespace: "arvados",
+                                       Name:      name,
+                                       Subsystem: "keep",
+                                       Help:      gauge.Help,
+                               }, []string{"replicas"})
+                               m.reg.MustRegister(gv)
+                               m.statsGaugeVecs[name] = gv
+                       case map[string]replicationStats:
+                               // classStats
+                               for _, sub := range []string{"blocks", "bytes", "replicas"} {
+                                       name := name + "_" + sub
+                                       gv := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+                                               Namespace: "arvados",
+                                               Name:      name,
+                                               Subsystem: "keep",
+                                               Help:      gauge.Help,
+                                       }, []string{"storage_class", "status"})
+                                       m.reg.MustRegister(gv)
+                                       m.statsGaugeVecs[name] = gv
+                               }
                        default:
                                panic(fmt.Sprintf("bad gauge type %T", gauge.Value))
                        }
@@ -105,6 +146,38 @@ func (m *metrics) UpdateStats(s balancerStats) {
                        m.statsGauges[name].Set(float64(val))
                case float64:
                        m.statsGauges[name].Set(float64(val))
+               case []int:
+                       // replHistogram
+                       for r, n := range val {
+                               m.statsGaugeVecs[name].WithLabelValues(strconv.Itoa(r)).Set(float64(n))
+                       }
+                       // Record zero for higher-than-max-replication
+                       // metrics, so we don't incorrectly continue
+                       // to report stale metrics.
+                       //
+                       // For example, if we previously reported n=1
+                       // for repl=6, but have since restarted
+                       // keep-balance and the most replicated block
+                       // now has repl=5, then the repl=6 gauge will
+                       // still say n=1 until we clear it explicitly
+                       // here.
+                       for r := len(val); r < len(val)+4 || r < len(val)*2; r++ {
+                               m.statsGaugeVecs[name].WithLabelValues(strconv.Itoa(r)).Set(0)
+                       }
+               case map[string]replicationStats:
+                       // classStats
+                       for class, cs := range val {
+                               for label, val := range map[string]blocksNBytes{
+                                       "needed":       cs.needed,
+                                       "unneeded":     cs.unneeded,
+                                       "pulling":      cs.pulling,
+                                       "unachievable": cs.unachievable,
+                               } {
+                                       m.statsGaugeVecs[name+"_blocks"].WithLabelValues(class, label).Set(float64(val.blocks))
+                                       m.statsGaugeVecs[name+"_bytes"].WithLabelValues(class, label).Set(float64(val.bytes))
+                                       m.statsGaugeVecs[name+"_replicas"].WithLabelValues(class, label).Set(float64(val.replicas))
+                               }
+                       }
                default:
                        panic(fmt.Sprintf("bad gauge type %T", gauge.Value))
                }
index 9bcaec43d86aba56190438c02810f19b612d1937..7a59c1e8c0edace3a8cb7637c6759a4962d44a99 100644 (file)
@@ -27,8 +27,6 @@ import (
 // RunOptions fields are controlled by command line flags.
 type RunOptions struct {
        Once                  bool
-       CommitPulls           bool
-       CommitTrash           bool
        CommitConfirmedFields bool
        ChunkPrefix           string
        Logger                logrus.FieldLogger
@@ -100,10 +98,9 @@ func (srv *Server) runForever(ctx context.Context) error {
 
        ticker := time.NewTicker(time.Duration(srv.Cluster.Collections.BalancePeriod))
 
-       // The unbuffered channel here means we only hear SIGUSR1 if
-       // it arrives while we're waiting in select{}.
-       sigUSR1 := make(chan os.Signal)
+       sigUSR1 := make(chan os.Signal, 1)
        signal.Notify(sigUSR1, syscall.SIGUSR1)
+       defer signal.Stop(sigUSR1)
 
        logger.Info("acquiring service lock")
        dblock.KeepBalanceService.Lock(ctx, func(context.Context) (*sqlx.DB, error) { return srv.DB, nil })
@@ -112,9 +109,9 @@ func (srv *Server) runForever(ctx context.Context) error {
        logger.Printf("starting up: will scan every %v and on SIGUSR1", srv.Cluster.Collections.BalancePeriod)
 
        for {
-               if !srv.RunOptions.CommitPulls && !srv.RunOptions.CommitTrash {
+               if srv.Cluster.Collections.BalancePullLimit < 1 && srv.Cluster.Collections.BalanceTrashLimit < 1 {
                        logger.Print("WARNING: Will scan periodically, but no changes will be committed.")
-                       logger.Print("=======  Consider using -commit-pulls and -commit-trash flags.")
+                       logger.Print("=======  To commit changes, set BalancePullLimit and BalanceTrashLimit values greater than zero.")
                }
 
                if !dblock.KeepBalanceService.Check() {
@@ -130,7 +127,6 @@ func (srv *Server) runForever(ctx context.Context) error {
 
                select {
                case <-ctx.Done():
-                       signal.Stop(sigUSR1)
                        return nil
                case <-ticker.C:
                        logger.Print("timer went off")
@@ -139,8 +135,7 @@ func (srv *Server) runForever(ctx context.Context) error {
                        // Reset the timer so we don't start the N+1st
                        // run too soon after the Nth run is triggered
                        // by SIGUSR1.
-                       ticker.Stop()
-                       ticker = time.NewTicker(time.Duration(srv.Cluster.Collections.BalancePeriod))
+                       ticker.Reset(time.Duration(srv.Cluster.Collections.BalancePeriod))
                }
                logger.Print("starting next run")
        }
index c73191103e01e0916a08f1314e19d02f53cb61b3..d443bc0829f81098715254e4b0e08cd7f5ac87bb 100644 (file)
@@ -7,14 +7,13 @@ package keepweb
 import (
        "errors"
        "net/http"
+       "sort"
        "sync"
-       "sync/atomic"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadosclient"
        "git.arvados.org/arvados.git/sdk/go/keepclient"
-       lru "github.com/hashicorp/golang-lru"
        "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
 )
@@ -26,8 +25,9 @@ type cache struct {
        logger    logrus.FieldLogger
        registry  *prometheus.Registry
        metrics   cacheMetrics
-       sessions  *lru.TwoQueueCache
+       sessions  map[string]*cachedSession
        setupOnce sync.Once
+       mtx       sync.Mutex
 
        chPruneSessions chan struct{}
 }
@@ -72,17 +72,69 @@ func (m *cacheMetrics) setup(reg *prometheus.Registry) {
 }
 
 type cachedSession struct {
+       cache         *cache
        expire        time.Time
-       fs            atomic.Value
        client        *arvados.Client
        arvadosclient *arvadosclient.ArvadosClient
        keepclient    *keepclient.KeepClient
-       user          atomic.Value
+
+       // Each session uses a system of three mutexes (plus the
+       // cache-wide mutex) to enable the following semantics:
+       //
+       // - There are never multiple sessions in use for a given
+       // token.
+       //
+       // - If the cached in-memory filesystems/user records are
+       // older than the configured cache TTL when a request starts,
+       // the request will use new ones.
+       //
+       // - Unused sessions are garbage-collected.
+       //
+       // In particular, when it is necessary to reset a session's
+       // filesystem/user record (to save memory or respect the
+       // configured cache TTL), any operations that are already
+       // using the existing filesystem/user record are allowed to
+       // finish before the new filesystem is constructed.
+       //
+       // The locks must be acquired in the following order:
+       // cache.mtx, session.mtx, session.refresh, session.inuse.
+
+       // mtx is RLocked while session is not safe to evict from
+       // cache -- i.e., a checkout() has decided to use it, and its
+       // caller is not finished with it. When locking or rlocking
+       // this mtx, the cache mtx MUST already be held.
+       //
+       // This mutex enables pruneSessions to detect when it is safe
+       // to completely remove the session entry from the cache.
+       mtx sync.RWMutex
+       // refresh must be locked in order to read or write the
+       // fs/user/userLoaded/lastuse fields. This mutex enables
+       // GetSession and pruneSessions to remove/replace fs and user
+       // values safely.
+       refresh sync.Mutex
+       // inuse must be RLocked while the session is in use by a
+       // caller. This mutex enables pruneSessions() to wait for all
+       // existing usage to finish by calling inuse.Lock().
+       inuse sync.RWMutex
+
+       fs         arvados.CustomFileSystem
+       user       arvados.User
+       userLoaded bool
+       lastuse    time.Time
+}
+
+func (sess *cachedSession) Release() {
+       sess.inuse.RUnlock()
+       sess.mtx.RUnlock()
+       select {
+       case sess.cache.chPruneSessions <- struct{}{}:
+       default:
+       }
 }
 
 func (c *cache) setup() {
        var err error
-       c.sessions, err = lru.New2Q(c.cluster.Collections.WebDAVCache.MaxSessions)
+       c.sessions = map[string]*cachedSession{}
        if err != nil {
                panic(err)
        }
@@ -106,126 +158,232 @@ func (c *cache) setup() {
 }
 
 func (c *cache) updateGauges() {
-       c.metrics.collectionBytes.Set(float64(c.collectionBytes()))
-       c.metrics.sessionEntries.Set(float64(c.sessions.Len()))
+       n, size := c.sessionsSize()
+       c.metrics.collectionBytes.Set(float64(size))
+       c.metrics.sessionEntries.Set(float64(n))
 }
 
 var selectPDH = map[string]interface{}{
        "select": []string{"portable_data_hash"},
 }
 
-// ResetSession unloads any potentially stale state. Should be called
-// after write operations, so subsequent reads don't return stale
-// data.
-func (c *cache) ResetSession(token string) {
-       c.setupOnce.Do(c.setup)
-       c.sessions.Remove(token)
-}
-
-// Get a long-lived CustomFileSystem suitable for doing a read operation
-// with the given token.
-func (c *cache) GetSession(token string) (arvados.CustomFileSystem, *cachedSession, *arvados.User, error) {
+func (c *cache) checkout(token string) (*cachedSession, error) {
        c.setupOnce.Do(c.setup)
-       now := time.Now()
-       ent, _ := c.sessions.Get(token)
-       sess, _ := ent.(*cachedSession)
-       expired := false
+       c.mtx.Lock()
+       defer c.mtx.Unlock()
+       sess := c.sessions[token]
        if sess == nil {
-               c.metrics.sessionMisses.Inc()
-               sess = &cachedSession{
-                       expire: now.Add(c.cluster.Collections.WebDAVCache.TTL.Duration()),
-               }
-               var err error
-               sess.client, err = arvados.NewClientFromConfig(c.cluster)
+               client, err := arvados.NewClientFromConfig(c.cluster)
                if err != nil {
-                       return nil, nil, nil, err
+                       return nil, err
                }
-               sess.client.AuthToken = token
-               sess.arvadosclient, err = arvadosclient.New(sess.client)
+               client.AuthToken = token
+               client.Timeout = time.Minute
+               // A non-empty origin header tells controller to
+               // prioritize our traffic as interactive, which is
+               // true most of the time.
+               origin := c.cluster.Services.WebDAVDownload.ExternalURL
+               client.SendHeader = http.Header{"Origin": {origin.Scheme + "://" + origin.Host}}
+               arvadosclient, err := arvadosclient.New(client)
                if err != nil {
-                       return nil, nil, nil, err
+                       return nil, err
                }
-               sess.keepclient = keepclient.New(sess.arvadosclient)
-               c.sessions.Add(token, sess)
-       } else if sess.expire.Before(now) {
-               c.metrics.sessionMisses.Inc()
-               expired = true
-       } else {
-               c.metrics.sessionHits.Inc()
-       }
-       select {
-       case c.chPruneSessions <- struct{}{}:
-       default:
+               sess = &cachedSession{
+                       cache:         c,
+                       client:        client,
+                       arvadosclient: arvadosclient,
+                       keepclient:    keepclient.New(arvadosclient),
+               }
+               c.sessions[token] = sess
        }
+       sess.mtx.RLock()
+       return sess, nil
+}
 
-       fs, _ := sess.fs.Load().(arvados.CustomFileSystem)
-       if fs == nil || expired {
-               fs = sess.client.SiteFileSystem(sess.keepclient)
-               fs.ForwardSlashNameSubstitution(c.cluster.Collections.ForwardSlashNameSubstitution)
-               sess.fs.Store(fs)
+// Get a long-lived CustomFileSystem suitable for doing a read or
+// write operation with the given token.
+//
+// If the returned error is nil, the caller must call Release() on the
+// returned session when finished using it.
+func (c *cache) GetSession(token string) (arvados.CustomFileSystem, *cachedSession, *arvados.User, error) {
+       sess, err := c.checkout(token)
+       if err != nil {
+               return nil, nil, nil, err
        }
+       sess.refresh.Lock()
+       defer sess.refresh.Unlock()
+       now := time.Now()
+       sess.lastuse = now
+       refresh := sess.expire.Before(now)
+       if sess.fs == nil || !sess.userLoaded || refresh {
+               // Wait for all active users to finish (otherwise they
+               // might make changes to an old fs after we start
+               // using the new fs).
+               sess.inuse.Lock()
+               if !sess.userLoaded || refresh {
+                       err := sess.client.RequestAndDecode(&sess.user, "GET", "arvados/v1/users/current", nil, nil)
+                       if he := errorWithHTTPStatus(nil); errors.As(err, &he) && he.HTTPStatus() == http.StatusForbidden {
+                               // token is OK, but "get user id" api is out
+                               // of scope -- use existing/expired info if
+                               // any, or leave empty for unknown user
+                       } else if err != nil {
+                               sess.inuse.Unlock()
+                               sess.mtx.RUnlock()
+                               return nil, nil, nil, err
+                       }
+                       sess.userLoaded = true
+               }
 
-       user, _ := sess.user.Load().(*arvados.User)
-       if user == nil || expired {
-               user = new(arvados.User)
-               err := sess.client.RequestAndDecode(user, "GET", "/arvados/v1/users/current", nil, nil)
-               if he := errorWithHTTPStatus(nil); errors.As(err, &he) && he.HTTPStatus() == http.StatusForbidden {
-                       // token is OK, but "get user id" api is out
-                       // of scope -- return nil, signifying unknown
-                       // user
-               } else if err != nil {
-                       return nil, nil, nil, err
+               if sess.fs == nil || refresh {
+                       sess.fs = sess.client.SiteFileSystem(sess.keepclient)
+                       sess.fs.ForwardSlashNameSubstitution(c.cluster.Collections.ForwardSlashNameSubstitution)
+                       sess.expire = now.Add(c.cluster.Collections.WebDAVCache.TTL.Duration())
+                       c.metrics.sessionMisses.Inc()
+               } else {
+                       c.metrics.sessionHits.Inc()
                }
-               sess.user.Store(user)
+               sess.inuse.Unlock()
+       } else {
+               c.metrics.sessionHits.Inc()
        }
+       sess.inuse.RLock()
+       return sess.fs, sess, &sess.user, nil
+}
 
-       return fs, sess, user, nil
+type sessionSnapshot struct {
+       token   string
+       sess    *cachedSession
+       lastuse time.Time
+       fs      arvados.CustomFileSystem
+       size    int64
+       prune   bool
 }
 
-// Remove all expired session cache entries, then remove more entries
-// until approximate remaining size <= maxsize/2
+// Remove all expired idle session cache entries, and remove in-memory
+// filesystems until approximate remaining size <= maxsize
 func (c *cache) pruneSessions() {
        now := time.Now()
-       keys := c.sessions.Keys()
-       sizes := make([]int64, len(keys))
+       c.mtx.Lock()
+       snaps := make([]sessionSnapshot, 0, len(c.sessions))
+       for token, sess := range c.sessions {
+               snaps = append(snaps, sessionSnapshot{
+                       token: token,
+                       sess:  sess,
+               })
+       }
+       c.mtx.Unlock()
+
+       // Load lastuse/fs/expire data from sessions. Note we do this
+       // after unlocking c.mtx because sess.refresh.Lock sometimes
+       // waits for another goroutine to finish "[re]fetch user
+       // record".
+       for i := range snaps {
+               snaps[i].sess.refresh.Lock()
+               snaps[i].lastuse = snaps[i].sess.lastuse
+               snaps[i].fs = snaps[i].sess.fs
+               snaps[i].prune = snaps[i].sess.expire.Before(now)
+               snaps[i].sess.refresh.Unlock()
+       }
+
+       // Sort sessions with oldest first.
+       sort.Slice(snaps, func(i, j int) bool {
+               return snaps[i].lastuse.Before(snaps[j].lastuse)
+       })
+
+       // Add up size of sessions that aren't already marked for
+       // pruning based on expire time.
        var size int64
-       for i, token := range keys {
-               ent, ok := c.sessions.Peek(token)
-               if !ok {
-                       continue
+       for i, snap := range snaps {
+               if !snap.prune && snap.fs != nil {
+                       size := snap.fs.MemorySize()
+                       snaps[i].size = size
+                       size += size
+               }
+       }
+       // Mark more sessions for deletion until reaching desired
+       // memory size limit, starting with the oldest entries.
+       for i, snap := range snaps {
+               if size <= int64(c.cluster.Collections.WebDAVCache.MaxCollectionBytes) {
+                       break
                }
-               s := ent.(*cachedSession)
-               if s.expire.Before(now) {
-                       c.sessions.Remove(token)
+               if snap.prune {
                        continue
                }
-               if fs, ok := s.fs.Load().(arvados.CustomFileSystem); ok {
-                       sizes[i] = fs.MemorySize()
-                       size += sizes[i]
+               snaps[i].prune = true
+               size -= snap.size
+       }
+
+       // Mark more sessions for deletion until reaching desired
+       // session count limit.
+       mustprune := len(snaps) - c.cluster.Collections.WebDAVCache.MaxSessions
+       for i := range snaps {
+               if snaps[i].prune {
+                       mustprune--
                }
        }
-       // Remove tokens until reaching size limit, starting with the
-       // least frequently used entries (which Keys() returns last).
-       for i := len(keys) - 1; i >= 0 && size > c.cluster.Collections.WebDAVCache.MaxCollectionBytes; i-- {
-               if sizes[i] > 0 {
-                       c.sessions.Remove(keys[i])
-                       size -= sizes[i]
+       for i := range snaps {
+               if mustprune < 1 {
+                       break
+               } else if !snaps[i].prune {
+                       snaps[i].prune = true
+                       mustprune--
                }
        }
-}
 
-// collectionBytes returns the approximate combined memory size of the
-// collection cache and session filesystem cache.
-func (c *cache) collectionBytes() uint64 {
-       var size uint64
-       for _, token := range c.sessions.Keys() {
-               ent, ok := c.sessions.Peek(token)
-               if !ok {
+       c.mtx.Lock()
+       defer c.mtx.Unlock()
+       for _, snap := range snaps {
+               if !snap.prune {
                        continue
                }
-               if fs, ok := ent.(*cachedSession).fs.Load().(arvados.CustomFileSystem); ok {
-                       size += uint64(fs.MemorySize())
+               sess := snap.sess
+               if sess.mtx.TryLock() {
+                       delete(c.sessions, snap.token)
+                       continue
+               }
+               // We can't remove a session that's been checked out
+               // -- that would allow another session to be created
+               // for the same token using a different in-memory
+               // filesystem. Instead, we wait for active requests to
+               // finish and then "unload" it. After this, either the
+               // next GetSession will reload fs/user, or a
+               // subsequent pruneSessions will remove the session.
+               go func() {
+                       // Ensure nobody is mid-GetSession() (note we
+                       // already know nobody is mid-checkout()
+                       // because we have c.mtx locked)
+                       sess.refresh.Lock()
+                       defer sess.refresh.Unlock()
+                       // Wait for current usage to finish (i.e.,
+                       // anyone who has decided to use the current
+                       // values of sess.fs and sess.user, and hasn't
+                       // called Release() yet)
+                       sess.inuse.Lock()
+                       defer sess.inuse.Unlock()
+                       // Release memory
+                       sess.fs = nil
+                       // Next GetSession will make a new fs
+               }()
+       }
+}
+
+// sessionsSize returns the number and approximate total memory size
+// of all cached sessions.
+func (c *cache) sessionsSize() (n int, size int64) {
+       c.mtx.Lock()
+       n = len(c.sessions)
+       sessions := make([]*cachedSession, 0, n)
+       for _, sess := range c.sessions {
+               sessions = append(sessions, sess)
+       }
+       c.mtx.Unlock()
+       for _, sess := range sessions {
+               sess.refresh.Lock()
+               fs := sess.fs
+               sess.refresh.Unlock()
+               if fs != nil {
+                       size += fs.MemorySize()
                }
        }
-       return size
+       return
 }
index 010e29a0b876105d49756d736adefd316878a12e..e95ebcf8467c1d385710834300109c9b8dd86ecc 100644 (file)
@@ -5,7 +5,6 @@
 package keepweb
 
 import (
-       "bytes"
        "net/http"
        "net/http/httptest"
        "regexp"
@@ -14,21 +13,12 @@ import (
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
-       "github.com/prometheus/common/expfmt"
        "gopkg.in/check.v1"
 )
 
 func (s *IntegrationSuite) checkCacheMetrics(c *check.C, regs ...string) {
        s.handler.Cache.updateGauges()
-       reg := s.handler.Cache.registry
-       mfs, err := reg.Gather()
-       c.Check(err, check.IsNil)
-       buf := &bytes.Buffer{}
-       enc := expfmt.NewEncoder(buf, expfmt.FmtText)
-       for _, mf := range mfs {
-               c.Check(enc.Encode(mf), check.IsNil)
-       }
-       mm := buf.String()
+       mm := arvadostest.GatherMetricsAsString(s.handler.Cache.registry)
        // Remove comments to make the "value vs. regexp" failure
        // output easier to read.
        mm = regexp.MustCompile(`(?m)^#.*\n`).ReplaceAllString(mm, "")
index 742140f7f347d34f1c815f1712a3b39a84a8bf13..026deeb5ee4d52dddd71981d1fb7823802b4c31f 100644 (file)
@@ -142,6 +142,11 @@ func (s *IntegrationSuite) testCadaver(c *check.C, password string, pathFunc fun
                        cmd:   "move \"test &#!%20 file\" testfile\n",
                        match: `(?ms).*Moving .* succeeded.*`,
                },
+               {
+                       path:  writePath,
+                       cmd:   "mkcol newdir0/\n",
+                       match: `(?ms).*Creating .* succeeded.*`,
+               },
                {
                        path:  writePath,
                        cmd:   "move testfile newdir0/\n",
index d2b4c7eb548cd5df534ddd674aeac513e5b3256e..4f7d2ca968bfd12834639b6d560f9c7752f0491c 100644 (file)
 //
 // See http://doc.arvados.org/install/install-keep-web.html.
 //
-// Configuration
+// Configuration
 //
 // The default cluster configuration file location is
 // /etc/arvados/config.yml.
 //
 // Example configuration file
 //
-//   Clusters:
-//     zzzzz:
-//       SystemRootToken: ""
-//       Services:
-//         Controller:
-//           ExternalURL: "https://example.com"
-//           Insecure: false
-//         WebDAV:
-//           InternalURLs:
-//             "http://:1234/": {}
-//         WebDAVDownload:
-//           InternalURLs:
-//             "http://:1234/": {}
-//           ExternalURL: "https://download.example.com/"
-//       Users:
-//         AnonymousUserToken: "xxxxxxxxxxxxxxxxxxxx"
-//       Collections:
-//         TrustAllContent: false
-//
-// Starting the server
+//     Clusters:
+//       zzzzz:
+//         SystemRootToken: ""
+//         Services:
+//           Controller:
+//             ExternalURL: "https://example.com"
+//             Insecure: false
+//           WebDAV:
+//             InternalURLs:
+//               "http://:1234/": {}
+//           WebDAVDownload:
+//             InternalURLs:
+//               "http://:1234/": {}
+//             ExternalURL: "https://download.example.com/"
+//         Users:
+//           AnonymousUserToken: "xxxxxxxxxxxxxxxxxxxx"
+//         Collections:
+//           TrustAllContent: false
+//
+// Starting the server
 //
 // Start a server using the default config file
 // /etc/arvados/config.yml:
 //
-//   keep-web
+//     keep-web
 //
 // Start a server using the config file /path/to/config.yml:
 //
-//   keep-web -config /path/to/config.yml
+//     keep-web -config /path/to/config.yml
 //
-// Proxy configuration
+// Proxy configuration
 //
 // Typically, keep-web is installed behind a proxy like nginx.
 //
 // proxy. However, if TLS is not used between nginx and keep-web, the
 // intervening networks must be secured by other means.
 //
-// Anonymous downloads
+// Anonymous downloads
 //
 // The "Users.AnonymousUserToken" configuration entry used when
 // when processing anonymous requests, i.e., whenever a web client
 // does not supply its own Arvados API token via path, query string,
 // cookie, or request header.
 //
-//   Clusters:
-//     zzzzz:
-//       Users:
-//         AnonymousUserToken: "xxxxxxxxxxxxxxxxxxxxxxx"
+//     Clusters:
+//       zzzzz:
+//         Users:
+//           AnonymousUserToken: "xxxxxxxxxxxxxxxxxxxxxxx"
 //
 // See http://doc.arvados.org/install/install-keep-web.html for examples.
 //
-// Download URLs
+// Download URLs
 //
 // See http://doc.arvados.org/api/keep-web-urls.html
 //
-// Attachment-Only host
+// Attachment-Only host
 //
 // It is possible to serve untrusted content and accept user
 // credentials at the same origin as long as the content is only
 // only when the designated origin matches exactly the Host header
 // provided by the client or downstream proxy.
 //
-//   Clusters:
-//     zzzzz:
-//       Services:
-//         WebDAVDownload:
-//           ExternalURL: "https://domain.example:9999"
+//     Clusters:
+//       zzzzz:
+//         Services:
+//           WebDAVDownload:
+//             ExternalURL: "https://domain.example:9999"
 //
-// Trust All Content mode
+// Trust All Content mode
 //
 // In TrustAllContent mode, Keep-web will accept credentials (API
 // tokens) and serve any collection X at
 //
 // In such cases you can enable trust-all-content mode.
 //
-//   Clusters:
-//     zzzzz:
-//       Collections:
-//         TrustAllContent: true
+//     Clusters:
+//       zzzzz:
+//         Collections:
+//           TrustAllContent: true
 //
 // When TrustAllContent is enabled, the only effect of the
 // Attachment-Only host setting is to add a "Content-Disposition:
 // attachment" header.
 //
-//   Clusters:
-//     zzzzz:
-//       Services:
-//         WebDAVDownload:
-//           ExternalURL: "https://domain.example:9999"
-//       Collections:
-//         TrustAllContent: true
+//     Clusters:
+//       zzzzz:
+//         Services:
+//           WebDAVDownload:
+//             ExternalURL: "https://domain.example:9999"
+//         Collections:
+//           TrustAllContent: true
 //
 // Depending on your site configuration, you might also want to enable
 // the "trust all content" setting in Workbench. Normally, Workbench
 // avoids redirecting requests to keep-web if they depend on
 // TrustAllContent being enabled.
 //
-// Metrics
+// Metrics
 //
 // Keep-web exposes request metrics in Prometheus text-based format at
 // /metrics. The same information is also available as JSON at
 // /metrics.json.
-//
 package keepweb
index 6bcbf67fe0eb42cde628665455bc3c874a012951..41d020efe5208b34f3f842df7eb30e2f495836f6 100644 (file)
@@ -3,7 +3,7 @@
 # SPDX-License-Identifier: AGPL-3.0
 
 case "$TARGET" in
-    centos*)
+    centos*|rocky*)
         fpm_depends+=(mailcap)
         ;;
     debian* | ubuntu*)
index a321fbc00a08a95118ddc2e587873ccaccc96a5b..e0da14e774525d9b860e6c92c62a010653e25d06 100644 (file)
@@ -18,23 +18,27 @@ import (
        "strconv"
        "strings"
        "sync"
+       "time"
 
        "git.arvados.org/arvados.git/lib/cmd"
+       "git.arvados.org/arvados.git/lib/webdavfs"
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadosclient"
        "git.arvados.org/arvados.git/sdk/go/auth"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "git.arvados.org/arvados.git/sdk/go/httpserver"
-       "git.arvados.org/arvados.git/sdk/go/keepclient"
        "github.com/sirupsen/logrus"
        "golang.org/x/net/webdav"
 )
 
 type handler struct {
-       Cache     cache
-       Cluster   *arvados.Cluster
-       setupOnce sync.Once
-       webdavLS  webdav.LockSystem
+       Cache   cache
+       Cluster *arvados.Cluster
+       metrics *metrics
+
+       lockMtx    sync.Mutex
+       lock       map[string]*sync.RWMutex
+       lockTidied time.Time
 }
 
 var urlPDHDecoder = strings.NewReplacer(" ", "+", "-", "+")
@@ -55,14 +59,6 @@ func parseCollectionIDFromURL(s string) string {
        return ""
 }
 
-func (h *handler) setup() {
-       keepclient.DefaultBlockCache.MaxBlocks = h.Cluster.Collections.WebDAVCache.MaxBlockEntries
-
-       // Even though we don't accept LOCK requests, every webdav
-       // handler must have a non-nil LockSystem.
-       h.webdavLS = &noLockSystem{}
-}
-
 func (h *handler) serveStatus(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(struct{ Version string }{cmd.Version.String()})
 }
@@ -178,23 +174,13 @@ func (h *handler) Done() <-chan struct{} {
 
 // ServeHTTP implements http.Handler.
 func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
-       h.setupOnce.Do(h.setup)
-
        if xfp := r.Header.Get("X-Forwarded-Proto"); xfp != "" && xfp != "http" {
                r.URL.Scheme = xfp
        }
 
        w := httpserver.WrapResponseWriter(wOrig)
 
-       if method := r.Header.Get("Access-Control-Request-Method"); method != "" && r.Method == "OPTIONS" {
-               if !browserMethod[method] && !webdavMethod[method] {
-                       w.WriteHeader(http.StatusMethodNotAllowed)
-                       return
-               }
-               w.Header().Set("Access-Control-Allow-Headers", corsAllowHeadersHeader)
-               w.Header().Set("Access-Control-Allow-Methods", "COPY, DELETE, GET, LOCK, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PROPPATCH, PUT, RMCOL, UNLOCK")
-               w.Header().Set("Access-Control-Allow-Origin", "*")
-               w.Header().Set("Access-Control-Max-Age", "86400")
+       if r.Method == "OPTIONS" && ServeCORSPreflight(w, r.Header) {
                return
        }
 
@@ -217,7 +203,26 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                return
        }
 
-       pathParts := strings.Split(r.URL.Path[1:], "/")
+       webdavPrefix := ""
+       arvPath := r.URL.Path
+       if prefix := r.Header.Get("X-Webdav-Prefix"); prefix != "" {
+               // Enable a proxy (e.g., container log handler in
+               // controller) to satisfy a request for path
+               // "/foo/bar/baz.txt" using content from
+               // "//abc123-4.internal/bar/baz.txt", by adding a
+               // request header "X-Webdav-Prefix: /foo"
+               if !strings.HasPrefix(arvPath, prefix) {
+                       http.Error(w, "X-Webdav-Prefix header is not a prefix of the requested path", http.StatusBadRequest)
+                       return
+               }
+               arvPath = r.URL.Path[len(prefix):]
+               if arvPath == "" {
+                       arvPath = "/"
+               }
+               w.Header().Set("Vary", "X-Webdav-Prefix, "+w.Header().Get("Vary"))
+               webdavPrefix = prefix
+       }
+       pathParts := strings.Split(arvPath[1:], "/")
 
        var stripParts int
        var collectionID string
@@ -281,12 +286,18 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                reqTokens = auth.CredentialsFromRequest(r).Tokens
        }
 
-       formToken := r.FormValue("api_token")
+       r.ParseForm()
        origin := r.Header.Get("Origin")
        cors := origin != "" && !strings.HasSuffix(origin, "://"+r.Host)
        safeAjax := cors && (r.Method == http.MethodGet || r.Method == http.MethodHead)
-       safeAttachment := attachment && r.URL.Query().Get("api_token") == ""
-       if formToken == "" {
+       // Important distinction: safeAttachment checks whether api_token exists
+       // as a query parameter. haveFormTokens checks whether api_token exists
+       // as request form data *or* a query parameter. Different checks are
+       // necessary because both the request disposition and the location of
+       // the API token affect whether or not the request needs to be
+       // redirected. The different branch comments below explain further.
+       safeAttachment := attachment && !r.URL.Query().Has("api_token")
+       if formTokens, haveFormTokens := r.Form["api_token"]; !haveFormTokens {
                // No token to use or redact.
        } else if safeAjax || safeAttachment {
                // If this is a cross-origin request, the URL won't
@@ -301,7 +312,9 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                // form?" problem, so provided the token isn't
                // embedded in the URL, there's no reason to do
                // redirect-with-cookie in this case either.
-               reqTokens = append(reqTokens, formToken)
+               for _, tok := range formTokens {
+                       reqTokens = append(reqTokens, tok)
+               }
        } else if browserMethod[r.Method] {
                // If this is a page view, and the client provided a
                // token via query string or POST body, we must put
@@ -329,7 +342,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
        fsprefix := ""
        if useSiteFS {
                if writeMethod[r.Method] {
-                       http.Error(w, errReadOnly.Error(), http.StatusMethodNotAllowed)
+                       http.Error(w, webdavfs.ErrReadOnly.Error(), http.StatusMethodNotAllowed)
                        return
                }
                if len(reqTokens) == 0 {
@@ -345,6 +358,10 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                fsprefix = "by_id/" + collectionID + "/"
        }
 
+       if src := r.Header.Get("X-Webdav-Source"); strings.HasPrefix(src, "/") && !strings.Contains(src, "//") && !strings.Contains(src, "/../") {
+               fsprefix += src[1:]
+       }
+
        if tokens == nil {
                tokens = reqTokens
                if h.Cluster.Users.AnonymousUserToken != "" {
@@ -395,16 +412,20 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                        // collection id is outside scope of supplied
                        // token
                        tokenScopeProblem = true
+                       sess.Release()
                        continue
                } else if os.IsNotExist(err) {
                        // collection does not exist or is not
                        // readable using this token
+                       sess.Release()
                        continue
                } else if err != nil {
                        http.Error(w, err.Error(), http.StatusInternalServerError)
+                       sess.Release()
                        return
                }
                defer f.Close()
+               defer sess.Release()
 
                collectionDir, sessionFS, session, tokenUser = f, fs, sess, user
                break
@@ -510,7 +531,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                basename = targetPath[len(targetPath)-1]
        }
        if arvadosclient.PDHMatch(collectionID) && writeMethod[r.Method] {
-               http.Error(w, errReadOnly.Error(), http.StatusMethodNotAllowed)
+               http.Error(w, webdavfs.ErrReadOnly.Error(), http.StatusMethodNotAllowed)
                return
        }
        if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
@@ -519,7 +540,11 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
        }
        h.logUploadOrDownload(r, session.arvadosclient, sessionFS, fsprefix+strings.Join(targetPath, "/"), nil, tokenUser)
 
-       if writeMethod[r.Method] {
+       writing := writeMethod[r.Method]
+       locker := h.collectionLock(collectionID, writing)
+       defer locker.Unlock()
+
+       if writing {
                // Save the collection only if/when all
                // webdav->filesystem operations succeed --
                // and send a 500 error if the modified
@@ -565,22 +590,25 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
        if r.Method == http.MethodGet {
                applyContentDispositionHdr(w, r, basename, attachment)
        }
-       wh := webdav.Handler{
-               Prefix: "/" + strings.Join(pathParts[:stripParts], "/"),
-               FileSystem: &webdavFS{
-                       collfs:        sessionFS,
-                       prefix:        fsprefix,
-                       writing:       writeMethod[r.Method],
-                       alwaysReadEOF: r.Method == "PROPFIND",
+       if webdavPrefix == "" {
+               webdavPrefix = "/" + strings.Join(pathParts[:stripParts], "/")
+       }
+       wh := &webdav.Handler{
+               Prefix: webdavPrefix,
+               FileSystem: &webdavfs.FS{
+                       FileSystem:    sessionFS,
+                       Prefix:        fsprefix,
+                       Writing:       writeMethod[r.Method],
+                       AlwaysReadEOF: r.Method == "PROPFIND",
                },
-               LockSystem: h.webdavLS,
+               LockSystem: webdavfs.NoLockSystem,
                Logger: func(r *http.Request, err error) {
-                       if err != nil {
+                       if err != nil && !os.IsNotExist(err) {
                                ctxlog.FromContext(r.Context()).WithError(err).Error("error reported by webdav handler")
                        }
                },
        }
-       wh.ServeHTTP(w, r)
+       h.metrics.track(wh, w, r)
        if r.Method == http.MethodGet && w.WroteStatus() == http.StatusOK {
                wrote := int64(w.WroteBodyBytes())
                fnm := strings.Join(pathParts[stripParts:], "/")
@@ -749,7 +777,7 @@ func applyContentDispositionHdr(w http.ResponseWriter, r *http.Request, filename
 }
 
 func (h *handler) seeOtherWithCookie(w http.ResponseWriter, r *http.Request, location string, credentialsOK bool) {
-       if formToken := r.FormValue("api_token"); formToken != "" {
+       if formTokens, haveFormTokens := r.Form["api_token"]; haveFormTokens {
                if !credentialsOK {
                        // It is not safe to copy the provided token
                        // into a cookie unless the current vhost
@@ -770,13 +798,19 @@ func (h *handler) seeOtherWithCookie(w http.ResponseWriter, r *http.Request, loc
                // bar, and in the case of a POST request to avoid
                // raising warnings when the user refreshes the
                // resulting page.
-               http.SetCookie(w, &http.Cookie{
-                       Name:     "arvados_api_token",
-                       Value:    auth.EncodeTokenCookie([]byte(formToken)),
-                       Path:     "/",
-                       HttpOnly: true,
-                       SameSite: http.SameSiteLaxMode,
-               })
+               for _, tok := range formTokens {
+                       if tok == "" {
+                               continue
+                       }
+                       http.SetCookie(w, &http.Cookie{
+                               Name:     "arvados_api_token",
+                               Value:    auth.EncodeTokenCookie([]byte(tok)),
+                               Path:     "/",
+                               HttpOnly: true,
+                               SameSite: http.SameSiteLaxMode,
+                       })
+                       break
+               }
        }
 
        // Propagate query parameters (except api_token) from
@@ -927,3 +961,54 @@ func (h *handler) determineCollection(fs arvados.CustomFileSystem, path string)
        }
        return nil, ""
 }
+
+var lockTidyInterval = time.Minute * 10
+
+// Lock the specified collection for reading or writing. Caller must
+// call Unlock() on the returned Locker when the operation is
+// finished.
+func (h *handler) collectionLock(collectionID string, writing bool) sync.Locker {
+       h.lockMtx.Lock()
+       defer h.lockMtx.Unlock()
+       if time.Since(h.lockTidied) > lockTidyInterval {
+               // Periodically delete all locks that aren't in use.
+               h.lockTidied = time.Now()
+               for id, locker := range h.lock {
+                       if locker.TryLock() {
+                               locker.Unlock()
+                               delete(h.lock, id)
+                       }
+               }
+       }
+       locker := h.lock[collectionID]
+       if locker == nil {
+               locker = new(sync.RWMutex)
+               if h.lock == nil {
+                       h.lock = map[string]*sync.RWMutex{}
+               }
+               h.lock[collectionID] = locker
+       }
+       if writing {
+               locker.Lock()
+               return locker
+       } else {
+               locker.RLock()
+               return locker.RLocker()
+       }
+}
+
+func ServeCORSPreflight(w http.ResponseWriter, header http.Header) bool {
+       method := header.Get("Access-Control-Request-Method")
+       if method == "" {
+               return false
+       }
+       if !browserMethod[method] && !webdavMethod[method] {
+               w.WriteHeader(http.StatusMethodNotAllowed)
+               return true
+       }
+       w.Header().Set("Access-Control-Allow-Headers", corsAllowHeadersHeader)
+       w.Header().Set("Access-Control-Allow-Methods", "COPY, DELETE, GET, LOCK, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PROPPATCH, PUT, RMCOL, UNLOCK")
+       w.Header().Set("Access-Control-Allow-Origin", "*")
+       w.Header().Set("Access-Control-Max-Age", "86400")
+       return true
+}
index 9228c36289752fafce5ed87d37a2a9b4f21b70f7..07c7016d3a8e485e8b1267d73fd0b547b14662bc 100644 (file)
@@ -18,6 +18,7 @@ import (
        "path/filepath"
        "regexp"
        "strings"
+       "sync"
        "time"
 
        "git.arvados.org/arvados.git/lib/config"
@@ -59,6 +60,7 @@ func (s *UnitSuite) SetUpTest(c *check.C) {
                        logger:   logger,
                        registry: prometheus.NewRegistry(),
                },
+               metrics: newMetrics(prometheus.NewRegistry()),
        }
 }
 
@@ -93,7 +95,125 @@ func (s *UnitSuite) TestCORSPreflight(c *check.C) {
        c.Check(resp.Code, check.Equals, http.StatusMethodNotAllowed)
 }
 
+func (s *UnitSuite) TestWebdavPrefixAndSource(c *check.C) {
+       for _, trial := range []struct {
+               method   string
+               path     string
+               prefix   string
+               source   string
+               notFound bool
+               seeOther bool
+       }{
+               {
+                       method: "PROPFIND",
+                       path:   "/",
+               },
+               {
+                       method: "PROPFIND",
+                       path:   "/dir1",
+               },
+               {
+                       method: "PROPFIND",
+                       path:   "/dir1/",
+               },
+               {
+                       method: "PROPFIND",
+                       path:   "/dir1/foo",
+                       prefix: "/dir1",
+                       source: "/dir1",
+               },
+               {
+                       method: "PROPFIND",
+                       path:   "/prefix/dir1/foo",
+                       prefix: "/prefix/",
+                       source: "",
+               },
+               {
+                       method: "PROPFIND",
+                       path:   "/prefix/dir1/foo",
+                       prefix: "/prefix",
+                       source: "",
+               },
+               {
+                       method: "PROPFIND",
+                       path:   "/prefix/dir1/foo",
+                       prefix: "/prefix/",
+                       source: "/",
+               },
+               {
+                       method: "PROPFIND",
+                       path:   "/prefix/foo",
+                       prefix: "/prefix/",
+                       source: "/dir1/",
+               },
+               {
+                       method: "GET",
+                       path:   "/prefix/foo",
+                       prefix: "/prefix/",
+                       source: "/dir1/",
+               },
+               {
+                       method: "PROPFIND",
+                       path:   "/prefix/",
+                       prefix: "/prefix",
+                       source: "/dir1",
+               },
+               {
+                       method: "PROPFIND",
+                       path:   "/prefix",
+                       prefix: "/prefix",
+                       source: "/dir1/",
+               },
+               {
+                       method:   "GET",
+                       path:     "/prefix",
+                       prefix:   "/prefix",
+                       source:   "/dir1",
+                       seeOther: true,
+               },
+               {
+                       method:   "PROPFIND",
+                       path:     "/dir1/foo",
+                       prefix:   "",
+                       source:   "/dir1",
+                       notFound: true,
+               },
+       } {
+               c.Logf("trial %+v", trial)
+               u := mustParseURL("http://" + arvadostest.FooBarDirCollection + ".keep-web.example" + trial.path)
+               req := &http.Request{
+                       Method:     trial.method,
+                       Host:       u.Host,
+                       URL:        u,
+                       RequestURI: u.RequestURI(),
+                       Header: http.Header{
+                               "Authorization":   {"Bearer " + arvadostest.ActiveTokenV2},
+                               "X-Webdav-Prefix": {trial.prefix},
+                               "X-Webdav-Source": {trial.source},
+                       },
+                       Body: ioutil.NopCloser(bytes.NewReader(nil)),
+               }
+
+               resp := httptest.NewRecorder()
+               s.handler.ServeHTTP(resp, req)
+               if trial.notFound {
+                       c.Check(resp.Code, check.Equals, http.StatusNotFound)
+               } else if trial.method == "PROPFIND" {
+                       c.Check(resp.Code, check.Equals, http.StatusMultiStatus)
+                       c.Check(resp.Body.String(), check.Matches, `(?ms).*>\n?$`)
+               } else if trial.seeOther {
+                       c.Check(resp.Code, check.Equals, http.StatusSeeOther)
+               } else {
+                       c.Check(resp.Code, check.Equals, http.StatusOK)
+               }
+       }
+}
+
 func (s *UnitSuite) TestEmptyResponse(c *check.C) {
+       // Ensure we start with an empty cache
+       defer os.Setenv("HOME", os.Getenv("HOME"))
+       os.Setenv("HOME", c.MkDir())
+
        for _, trial := range []struct {
                dataExists    bool
                sendIMSHeader bool
@@ -213,9 +333,10 @@ func (s *IntegrationSuite) TestVhostViaAuthzHeaderOAuth2(c *check.C) {
        s.doVhostRequests(c, authzViaAuthzHeaderOAuth2)
 }
 func authzViaAuthzHeaderOAuth2(r *http.Request, tok string) int {
-       r.Header.Add("Authorization", "Bearer "+tok)
+       r.Header.Add("Authorization", "OAuth2 "+tok)
        return http.StatusUnauthorized
 }
+
 func (s *IntegrationSuite) TestVhostViaAuthzHeaderBearer(c *check.C) {
        s.doVhostRequests(c, authzViaAuthzHeaderBearer)
 }
@@ -235,6 +356,27 @@ func authzViaCookieValue(r *http.Request, tok string) int {
        return http.StatusUnauthorized
 }
 
+func (s *IntegrationSuite) TestVhostViaHTTPBasicAuth(c *check.C) {
+       s.doVhostRequests(c, authzViaHTTPBasicAuth)
+}
+func authzViaHTTPBasicAuth(r *http.Request, tok string) int {
+       r.AddCookie(&http.Cookie{
+               Name:  "arvados_api_token",
+               Value: auth.EncodeTokenCookie([]byte(tok)),
+       })
+       return http.StatusUnauthorized
+}
+
+func (s *IntegrationSuite) TestVhostViaHTTPBasicAuthWithExtraSpaceChars(c *check.C) {
+       s.doVhostRequests(c, func(r *http.Request, tok string) int {
+               r.AddCookie(&http.Cookie{
+                       Name:  "arvados_api_token",
+                       Value: auth.EncodeTokenCookie([]byte(" " + tok + "\n")),
+               })
+               return http.StatusUnauthorized
+       })
+}
+
 func (s *IntegrationSuite) TestVhostViaPath(c *check.C) {
        s.doVhostRequests(c, authzViaPath)
 }
@@ -682,6 +824,34 @@ func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *chec
        c.Check(resp.Header().Get("Content-Disposition"), check.Equals, "attachment")
 }
 
+func (s *IntegrationSuite) TestVhostRedirectMultipleTokens(c *check.C) {
+       baseUrl := arvadostest.FooCollection + ".example.com/foo"
+       query := url.Values{}
+
+       // The intent of these tests is to check that requests are redirected
+       // correctly in the presence of multiple API tokens. The exact response
+       // codes and content are not closely considered: they're just how
+       // keep-web responded when we made the smallest possible fix. Changing
+       // those responses may be okay, but you should still test all these
+       // different cases and the associated redirect logic.
+       query["api_token"] = []string{arvadostest.ActiveToken, arvadostest.AnonymousToken}
+       s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusOK, "foo")
+       query["api_token"] = []string{arvadostest.ActiveToken, arvadostest.AnonymousToken, ""}
+       s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusOK, "foo")
+       query["api_token"] = []string{arvadostest.ActiveToken, "", arvadostest.AnonymousToken}
+       s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusOK, "foo")
+       query["api_token"] = []string{"", arvadostest.ActiveToken}
+       s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusOK, "foo")
+
+       expectContent := regexp.QuoteMeta(unauthorizedMessage + "\n")
+       query["api_token"] = []string{arvadostest.AnonymousToken, "invalidtoo"}
+       s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusUnauthorized, expectContent)
+       query["api_token"] = []string{arvadostest.AnonymousToken, ""}
+       s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusUnauthorized, expectContent)
+       query["api_token"] = []string{"", arvadostest.AnonymousToken}
+       s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusUnauthorized, expectContent)
+}
+
 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie(c *check.C) {
        s.testVhostRedirectTokenToCookie(c, "POST",
                arvadostest.FooCollection+".example.com/foo",
@@ -884,20 +1054,36 @@ func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, ho
 
        s.handler.ServeHTTP(resp, req)
        if resp.Code != http.StatusSeeOther {
+               attachment, _ := regexp.MatchString(`^attachment(;|$)`, resp.Header().Get("Content-Disposition"))
+               // Since we're not redirecting, check that any api_token in the URL is
+               // handled safely.
+               // If there is no token in the URL, then we're good.
+               // Otherwise, if the response code is an error, the body is expected to
+               // be static content, and nothing that might maliciously introspect the
+               // URL. It's considered safe and allowed.
+               // Otherwise, if the response content has attachment disposition,
+               // that's considered safe for all the reasons explained in the
+               // safeAttachment comment in handler.go.
+               c.Check(!u.Query().Has("api_token") || resp.Code >= 400 || attachment, check.Equals, true)
                return resp
        }
+
+       loc, err := url.Parse(resp.Header().Get("Location"))
+       c.Assert(err, check.IsNil)
+       c.Check(loc.Scheme, check.Equals, u.Scheme)
+       c.Check(loc.Host, check.Equals, u.Host)
+       c.Check(loc.RawPath, check.Equals, u.RawPath)
+       // If the response was a redirect, it should never include an API token.
+       c.Check(loc.Query().Has("api_token"), check.Equals, false)
        c.Check(resp.Body.String(), check.Matches, `.*href="http://`+regexp.QuoteMeta(html.EscapeString(hostPath))+`(\?[^"]*)?".*`)
-       c.Check(strings.Split(resp.Header().Get("Location"), "?")[0], check.Equals, "http://"+hostPath)
        cookies := (&http.Response{Header: resp.Header()}).Cookies()
 
-       u, err := u.Parse(resp.Header().Get("Location"))
-       c.Assert(err, check.IsNil)
        c.Logf("following redirect to %s", u)
        req = &http.Request{
                Method:     "GET",
-               Host:       u.Host,
-               URL:        u,
-               RequestURI: u.RequestURI(),
+               Host:       loc.Host,
+               URL:        loc,
+               RequestURI: loc.RequestURI(),
                Header:     reqHeader,
        }
        for _, c := range cookies {
@@ -924,6 +1110,17 @@ func (s *IntegrationSuite) TestDirectoryListingWithNoAnonymousToken(c *check.C)
 }
 
 func (s *IntegrationSuite) testDirectoryListing(c *check.C) {
+       // The "ownership cycle" test fixtures are reachable from the
+       // "filter group without filters" group, causing webdav's
+       // walkfs to recurse indefinitely. Avoid that by deleting one
+       // of the bogus fixtures.
+       arv := arvados.NewClientFromEnv()
+       err := arv.RequestAndDecode(nil, "DELETE", "arvados/v1/groups/zzzzz-j7d0g-cx2al9cqkmsf1hs", nil, nil)
+       if err != nil {
+               c.Assert(err, check.FitsTypeOf, &arvados.TransactionError{})
+               c.Check(err.(*arvados.TransactionError).StatusCode, check.Equals, 404)
+       }
+
        s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "download.example.com"
        authHeader := http.Header{
                "Authorization": {"OAuth2 " + arvadostest.ActiveToken},
@@ -1060,8 +1257,32 @@ func (s *IntegrationSuite) testDirectoryListing(c *check.C) {
                        expect:  []string{"waz"},
                        cutDirs: 2,
                },
+               {
+                       uri:     "download.example.com/users/active/This filter group/",
+                       header:  authHeader,
+                       expect:  []string{"A Subproject/"},
+                       cutDirs: 3,
+               },
+               {
+                       uri:     "download.example.com/users/active/This filter group/A Subproject",
+                       header:  authHeader,
+                       expect:  []string{"baz_file/"},
+                       cutDirs: 4,
+               },
+               {
+                       uri:     "download.example.com/by_id/" + arvadostest.AFilterGroupUUID,
+                       header:  authHeader,
+                       expect:  []string{"A Subproject/"},
+                       cutDirs: 2,
+               },
+               {
+                       uri:     "download.example.com/by_id/" + arvadostest.AFilterGroupUUID + "/A Subproject",
+                       header:  authHeader,
+                       expect:  []string{"baz_file/"},
+                       cutDirs: 3,
+               },
        } {
-               comment := check.Commentf("HTML: %q => %q", trial.uri, trial.expect)
+               comment := check.Commentf("HTML: %q redir %q => %q", trial.uri, trial.redirect, trial.expect)
                resp := httptest.NewRecorder()
                u := mustParseURL("//" + trial.uri)
                req := &http.Request{
@@ -1097,6 +1318,7 @@ func (s *IntegrationSuite) testDirectoryListing(c *check.C) {
                } else {
                        c.Check(resp.Code, check.Equals, http.StatusOK, comment)
                        for _, e := range trial.expect {
+                               e = strings.Replace(e, " ", "%20", -1)
                                c.Check(resp.Body.String(), check.Matches, `(?ms).*href="./`+e+`".*`, comment)
                        }
                        c.Check(resp.Body.String(), check.Matches, `(?ms).*--cut-dirs=`+fmt.Sprintf("%d", trial.cutDirs)+` .*`, comment)
@@ -1129,6 +1351,12 @@ func (s *IntegrationSuite) testDirectoryListing(c *check.C) {
                }
                resp = httptest.NewRecorder()
                s.handler.ServeHTTP(resp, req)
+               // This check avoids logging a big XML document in the
+               // event webdav throws a 500 error after sending
+               // headers for a 207.
+               if !c.Check(strings.HasSuffix(resp.Body.String(), "Internal Server Error"), check.Equals, false) {
+                       continue
+               }
                if trial.expect == nil {
                        c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
                } else {
@@ -1139,6 +1367,7 @@ func (s *IntegrationSuite) testDirectoryListing(c *check.C) {
                                } else {
                                        e = filepath.Join(u.Path, e)
                                }
+                               e = strings.Replace(e, " ", "%20", -1)
                                c.Check(resp.Body.String(), check.Matches, `(?ms).*<D:href>`+e+`</D:href>.*`, comment)
                        }
                }
@@ -1245,20 +1474,14 @@ func (s *IntegrationSuite) TestFileContentType(c *check.C) {
        }
 }
 
-func (s *IntegrationSuite) TestKeepClientBlockCache(c *check.C) {
-       s.handler.Cluster.Collections.WebDAVCache.MaxBlockEntries = 42
-       c.Check(keepclient.DefaultBlockCache.MaxBlocks, check.Not(check.Equals), 42)
-       u := mustParseURL("http://keep-web.example/c=" + arvadostest.FooCollection + "/t=" + arvadostest.ActiveToken + "/foo")
-       req := &http.Request{
-               Method:     "GET",
-               Host:       u.Host,
-               URL:        u,
-               RequestURI: u.RequestURI(),
-       }
+func (s *IntegrationSuite) TestCacheSize(c *check.C) {
+       req, err := http.NewRequest("GET", "http://"+arvadostest.FooCollection+".example.com/foo", nil)
+       req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveTokenV2)
+       c.Assert(err, check.IsNil)
        resp := httptest.NewRecorder()
        s.handler.ServeHTTP(resp, req)
-       c.Check(resp.Code, check.Equals, http.StatusOK)
-       c.Check(keepclient.DefaultBlockCache.MaxBlocks, check.Equals, 42)
+       c.Assert(resp.Code, check.Equals, http.StatusOK)
+       c.Check(s.handler.Cache.sessions[arvadostest.ActiveTokenV2].client.DiskCacheSize.Percent(), check.Equals, int64(10))
 }
 
 // Writing to a collection shouldn't affect its entry in the
@@ -1510,3 +1733,72 @@ func (s *IntegrationSuite) TestUploadLoggingPermission(c *check.C) {
                }
        }
 }
+
+func (s *IntegrationSuite) TestConcurrentWrites(c *check.C) {
+       s.handler.Cluster.Collections.WebDAVCache.TTL = arvados.Duration(time.Second * 2)
+       lockTidyInterval = time.Second
+       client := arvados.NewClientFromEnv()
+       client.AuthToken = arvadostest.ActiveTokenV2
+       // Start small, and increase concurrency (2^2, 4^2, ...)
+       // only until hitting failure. Avoids unnecessarily long
+       // failure reports.
+       for n := 2; n < 16 && !c.Failed(); n = n * 2 {
+               c.Logf("%s: n=%d", c.TestName(), n)
+
+               var coll arvados.Collection
+               err := client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, nil)
+               c.Assert(err, check.IsNil)
+               defer client.RequestAndDecode(&coll, "DELETE", "arvados/v1/collections/"+coll.UUID, nil, nil)
+
+               var wg sync.WaitGroup
+               for i := 0; i < n && !c.Failed(); i++ {
+                       i := i
+                       wg.Add(1)
+                       go func() {
+                               defer wg.Done()
+                               u := mustParseURL(fmt.Sprintf("http://%s.collections.example.com/i=%d", coll.UUID, i))
+                               resp := httptest.NewRecorder()
+                               req, err := http.NewRequest("MKCOL", u.String(), nil)
+                               c.Assert(err, check.IsNil)
+                               req.Header.Set("Authorization", "Bearer "+client.AuthToken)
+                               s.handler.ServeHTTP(resp, req)
+                               c.Assert(resp.Code, check.Equals, http.StatusCreated)
+                               for j := 0; j < n && !c.Failed(); j++ {
+                                       j := j
+                                       wg.Add(1)
+                                       go func() {
+                                               defer wg.Done()
+                                               content := fmt.Sprintf("i=%d/j=%d", i, j)
+                                               u := mustParseURL("http://" + coll.UUID + ".collections.example.com/" + content)
+
+                                               resp := httptest.NewRecorder()
+                                               req, err := http.NewRequest("PUT", u.String(), strings.NewReader(content))
+                                               c.Assert(err, check.IsNil)
+                                               req.Header.Set("Authorization", "Bearer "+client.AuthToken)
+                                               s.handler.ServeHTTP(resp, req)
+                                               c.Check(resp.Code, check.Equals, http.StatusCreated)
+
+                                               time.Sleep(time.Second)
+                                               resp = httptest.NewRecorder()
+                                               req, err = http.NewRequest("GET", u.String(), nil)
+                                               c.Assert(err, check.IsNil)
+                                               req.Header.Set("Authorization", "Bearer "+client.AuthToken)
+                                               s.handler.ServeHTTP(resp, req)
+                                               c.Check(resp.Code, check.Equals, http.StatusOK)
+                                               c.Check(resp.Body.String(), check.Equals, content)
+                                       }()
+                               }
+                       }()
+               }
+               wg.Wait()
+               for i := 0; i < n; i++ {
+                       u := mustParseURL(fmt.Sprintf("http://%s.collections.example.com/i=%d", coll.UUID, i))
+                       resp := httptest.NewRecorder()
+                       req, err := http.NewRequest("PROPFIND", u.String(), &bytes.Buffer{})
+                       c.Assert(err, check.IsNil)
+                       req.Header.Set("Authorization", "Bearer "+client.AuthToken)
+                       s.handler.ServeHTTP(resp, req)
+                       c.Assert(resp.Code, check.Equals, http.StatusMultiStatus)
+               }
+       }
+}
index cd379dc6bd667df887b410edce26abd6eff209e7..690e75a2514b15bb0c644f8d085c2a57b068f1fd 100644 (file)
@@ -41,5 +41,6 @@ func newHandler(ctx context.Context, cluster *arvados.Cluster, token string, reg
                        logger:   logger,
                        registry: reg,
                },
+               metrics: newMetrics(reg),
        }, nil
 }
diff --git a/services/keep-web/metrics.go b/services/keep-web/metrics.go
new file mode 100644 (file)
index 0000000..b989988
--- /dev/null
@@ -0,0 +1,155 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package keepweb
+
+import (
+       "io"
+       "math"
+       "net/http"
+       "time"
+
+       "github.com/prometheus/client_golang/prometheus"
+)
+
+type metrics struct {
+       mDownloadSpeed        *prometheus.HistogramVec
+       mDownloadBackendSpeed *prometheus.HistogramVec
+       mUploadSpeed          *prometheus.HistogramVec
+       mUploadSyncDelay      *prometheus.HistogramVec
+}
+
+func newMetrics(reg *prometheus.Registry) *metrics {
+       m := &metrics{
+               mDownloadSpeed: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+                       Namespace: "arvados",
+                       Subsystem: "keepweb",
+                       Name:      "download_speed",
+                       Help:      "Download speed (bytes per second) bucketed by transfer size range",
+                       Buckets:   []float64{10_000, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000, math.Inf(+1)},
+               }, []string{"size_range"}),
+               mDownloadBackendSpeed: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+                       Namespace: "arvados",
+                       Subsystem: "keepweb",
+                       Name:      "download_apparent_backend_speed",
+                       Help:      "Apparent download speed from the backend (bytes per second) when serving file downloads, bucketed by transfer size range (see https://dev.arvados.org/projects/arvados/wiki/WebDAV_performance_metrics for explanation)",
+                       Buckets:   []float64{10_000, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000, math.Inf(+1)},
+               }, []string{"size_range"}),
+               mUploadSpeed: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+                       Namespace: "arvados",
+                       Subsystem: "keepweb",
+                       Name:      "upload_speed",
+                       Help:      "Upload speed (bytes per second) bucketed by transfer size range",
+                       Buckets:   []float64{10_000, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000, math.Inf(+1)},
+               }, []string{"size_range"}),
+               mUploadSyncDelay: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+                       Namespace: "arvados",
+                       Subsystem: "keepweb",
+                       Name:      "upload_sync_delay_seconds",
+                       Help:      "Upload sync delay (time from last byte received to HTTP response)",
+               }, []string{"size_range"}),
+       }
+       reg.MustRegister(m.mDownloadSpeed)
+       reg.MustRegister(m.mDownloadBackendSpeed)
+       reg.MustRegister(m.mUploadSpeed)
+       reg.MustRegister(m.mUploadSyncDelay)
+       return m
+}
+
+// run handler(w,r) and record upload/download metrics as applicable.
+func (m *metrics) track(handler http.Handler, w http.ResponseWriter, r *http.Request) {
+       switch r.Method {
+       case http.MethodGet:
+               dt := newDownloadTracker(w)
+               handler.ServeHTTP(dt, r)
+               size := dt.bytesOut
+               if size == 0 {
+                       return
+               }
+               bucket := sizeRange(size)
+               m.mDownloadSpeed.WithLabelValues(bucket).Observe(float64(dt.bytesOut) / time.Since(dt.t0).Seconds())
+               m.mDownloadBackendSpeed.WithLabelValues(bucket).Observe(float64(size) / (dt.backendWait + time.Since(dt.lastByte)).Seconds())
+       case http.MethodPut:
+               ut := newUploadTracker(r)
+               handler.ServeHTTP(w, r)
+               d := ut.lastByte.Sub(ut.t0)
+               if d <= 0 {
+                       // Read() was not called, or did not return
+                       // any data
+                       return
+               }
+               size := ut.bytesIn
+               bucket := sizeRange(size)
+               m.mUploadSpeed.WithLabelValues(bucket).Observe(float64(ut.bytesIn) / d.Seconds())
+               m.mUploadSyncDelay.WithLabelValues(bucket).Observe(time.Since(ut.lastByte).Seconds())
+       default:
+               handler.ServeHTTP(w, r)
+       }
+}
+
+// Assign a sizeRange based on number of bytes transferred (not the
+// same as file size in the case of a Range request or interrupted
+// transfer).
+func sizeRange(size int64) string {
+       switch {
+       case size < 1_000_000:
+               return "0"
+       case size < 10_000_000:
+               return "1M"
+       case size < 100_000_000:
+               return "10M"
+       default:
+               return "100M"
+       }
+}
+
+type downloadTracker struct {
+       http.ResponseWriter
+       t0 time.Time
+
+       firstByte   time.Time     // time of first call to Write
+       lastByte    time.Time     // time of most recent call to Write
+       bytesOut    int64         // bytes sent to client so far
+       backendWait time.Duration // total of intervals between Write calls
+}
+
+func newDownloadTracker(w http.ResponseWriter) *downloadTracker {
+       return &downloadTracker{ResponseWriter: w, t0: time.Now()}
+}
+
+func (dt *downloadTracker) Write(p []byte) (int, error) {
+       if dt.lastByte.IsZero() {
+               dt.backendWait += time.Since(dt.t0)
+       } else {
+               dt.backendWait += time.Since(dt.lastByte)
+       }
+       if dt.firstByte.IsZero() {
+               dt.firstByte = time.Now()
+       }
+       n, err := dt.ResponseWriter.Write(p)
+       dt.bytesOut += int64(n)
+       dt.lastByte = time.Now()
+       return n, err
+}
+
+type uploadTracker struct {
+       io.ReadCloser
+       t0       time.Time
+       lastByte time.Time
+       bytesIn  int64
+}
+
+func newUploadTracker(r *http.Request) *uploadTracker {
+       now := time.Now()
+       ut := &uploadTracker{ReadCloser: r.Body, t0: now}
+       r.Body = ut
+       return ut
+}
+
+func (ut *uploadTracker) Read(p []byte) (int, error) {
+       n, err := ut.ReadCloser.Read(p)
+       ut.lastByte = time.Now()
+       ut.bytesIn += int64(n)
+       return n, err
+}
index f98efd8fdfcdf39febe29e84610aea474ccda02d..3e60f3006db843ee108bb17effe9392e0142c0e5 100644 (file)
@@ -28,7 +28,6 @@ import (
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
-       "github.com/AdRoll/goamz/s3"
 )
 
 const (
@@ -42,11 +41,17 @@ type commonPrefix struct {
 }
 
 type listV1Resp struct {
-       XMLName string `xml:"http://s3.amazonaws.com/doc/2006-03-01/ ListBucketResult"`
-       s3.ListResp
-       // s3.ListResp marshals an empty tag when
-       // CommonPrefixes is nil, which confuses some clients.
-       // Fix by using this nested struct instead.
+       XMLName     string `xml:"http://s3.amazonaws.com/doc/2006-03-01/ ListBucketResult"`
+       Name        string
+       Prefix      string
+       Delimiter   string
+       Marker      string
+       MaxKeys     int
+       IsTruncated bool
+       Contents    []s3Key
+       // If we use a []string here, xml marshals an empty tag when
+       // CommonPrefixes is nil, which confuses some clients.  Fix by
+       // using this nested struct instead.
        CommonPrefixes []commonPrefix
        // Similarly, we need omitempty here, because an empty
        // tag confuses some clients (e.g.,
@@ -60,7 +65,7 @@ type listV1Resp struct {
 type listV2Resp struct {
        XMLName               string `xml:"http://s3.amazonaws.com/doc/2006-03-01/ ListBucketResult"`
        IsTruncated           bool
-       Contents              []s3.Key
+       Contents              []s3Key
        Name                  string
        Prefix                string
        Delimiter             string
@@ -73,6 +78,21 @@ type listV2Resp struct {
        StartAfter            string `xml:",omitempty"`
 }
 
+type s3Key struct {
+       Key          string
+       LastModified string
+       Size         int64
+       // The following fields are not populated, but are here in
+       // case clients rely on the keys being present in xml
+       // responses.
+       ETag         string
+       StorageClass string
+       Owner        struct {
+               ID          string
+               DisplayName string
+       }
+}
+
 func hmacstring(msg string, key []byte) []byte {
        h := hmac.New(sha256.New, key)
        io.WriteString(h, msg)
@@ -315,6 +335,7 @@ func (h *handler) serveS3(w http.ResponseWriter, r *http.Request) bool {
                s3ErrorResponse(w, InternalError, err.Error(), r.URL.Path, http.StatusInternalServerError)
                return true
        }
+       defer sess.Release()
        readfs := fs
        if writeMethod[r.Method] {
                // Create a FileSystem for this request, to avoid
@@ -752,6 +773,9 @@ func (h *handler) s3list(bucket string, w http.ResponseWriter, r *http.Request,
                        http.Error(w, "invalid continuation token", http.StatusBadRequest)
                        return
                }
+               // marker and start-after perform the same function,
+               // but we keep them separate so we can repeat them
+               // back to the client in the response.
                params.marker = string(marker)
                params.startAfter = r.FormValue("start-after")
                switch r.FormValue("encoding-type") {
@@ -763,9 +787,17 @@ func (h *handler) s3list(bucket string, w http.ResponseWriter, r *http.Request,
                        return
                }
        } else {
+               // marker is functionally equivalent to start-after.
                params.marker = r.FormValue("marker")
        }
 
+       // startAfter is params.marker or params.startAfter, whichever
+       // comes last.
+       startAfter := params.startAfter
+       if startAfter < params.marker {
+               startAfter = params.marker
+       }
+
        bucketdir := "by_id/" + bucket
        // walkpath is the directory (relative to bucketdir) we need
        // to walk: the innermost directory that is guaranteed to
@@ -789,9 +821,15 @@ func (h *handler) s3list(bucket string, w http.ResponseWriter, r *http.Request,
                ContinuationToken: r.FormValue("continuation-token"),
                StartAfter:        params.startAfter,
        }
+
+       // nextMarker will be the last path we add to either
+       // resp.Contents or commonPrefixes.  It will be included in
+       // the response as NextMarker or NextContinuationToken if
+       // needed.
        nextMarker := ""
 
        commonPrefixes := map[string]bool{}
+       full := false
        err := walkFS(fs, strings.TrimSuffix(bucketdir+"/"+walkpath, "/"), true, func(path string, fi os.FileInfo) error {
                if path == bucketdir {
                        return nil
@@ -802,36 +840,29 @@ func (h *handler) s3list(bucket string, w http.ResponseWriter, r *http.Request,
                        path += "/"
                        filesize = 0
                }
-               if len(path) <= len(params.prefix) {
-                       if path > params.prefix[:len(path)] {
-                               // with prefix "foobar", walking "fooz" means we're done
-                               return errDone
-                       }
-                       if path < params.prefix[:len(path)] {
-                               // with prefix "foobar", walking "foobag" is pointless
-                               return filepath.SkipDir
-                       }
-                       if fi.IsDir() && !strings.HasPrefix(params.prefix+"/", path) {
-                               // with prefix "foo/bar", walking "fo"
-                               // is pointless (but walking "foo" or
-                               // "foo/bar" is necessary)
-                               return filepath.SkipDir
-                       }
-                       if len(path) < len(params.prefix) {
-                               // can't skip anything, and this entry
-                               // isn't in the results, so just
-                               // continue descent
-                               return nil
-                       }
-               } else {
-                       if path[:len(params.prefix)] > params.prefix {
-                               // with prefix "foobar", nothing we
-                               // see after "foozzz" is relevant
-                               return errDone
-                       }
-               }
-               if path < params.marker || path < params.prefix || path <= params.startAfter {
+               if strings.HasPrefix(params.prefix, path) && params.prefix != path {
+                       // Descend into subtree until we reach desired prefix
+                       return nil
+               } else if path < params.prefix {
+                       // Not an ancestor or descendant of desired
+                       // prefix, therefore none of its descendants
+                       // can be either -- skip
+                       return filepath.SkipDir
+               } else if path > params.prefix && !strings.HasPrefix(path, params.prefix) {
+                       // We must have traversed everything under
+                       // desired prefix
+                       return errDone
+               } else if path == startAfter {
+                       // Skip startAfter itself, just descend into
+                       // subtree
+                       return nil
+               } else if strings.HasPrefix(startAfter, path) {
+                       // Descend into subtree in case it contains
+                       // something after startAfter
                        return nil
+               } else if path < startAfter {
+                       // Skip ahead until we reach startAfter
+                       return filepath.SkipDir
                }
                if fi.IsDir() && !h.Cluster.Collections.S3FolderObjects {
                        // Note we don't add anything to
@@ -841,13 +872,6 @@ func (h *handler) s3list(bucket string, w http.ResponseWriter, r *http.Request,
                        // finding a regular file inside it.
                        return nil
                }
-               if len(resp.Contents)+len(commonPrefixes) >= params.maxKeys {
-                       resp.IsTruncated = true
-                       if params.delimiter != "" || params.v2 {
-                               nextMarker = path
-                       }
-                       return errDone
-               }
                if params.delimiter != "" {
                        idx := strings.Index(path[len(params.prefix):], params.delimiter)
                        if idx >= 0 {
@@ -855,21 +879,42 @@ func (h *handler) s3list(bucket string, w http.ResponseWriter, r *http.Request,
                                // "z", when we hit "foobar/baz", we
                                // add "/baz" to commonPrefixes and
                                // stop descending.
-                               commonPrefixes[path[:len(params.prefix)+idx+1]] = true
-                               return filepath.SkipDir
+                               prefix := path[:len(params.prefix)+idx+1]
+                               if prefix == startAfter {
+                                       return nil
+                               } else if prefix < startAfter && !strings.HasPrefix(startAfter, prefix) {
+                                       return nil
+                               } else if full {
+                                       resp.IsTruncated = true
+                                       return errDone
+                               } else {
+                                       commonPrefixes[prefix] = true
+                                       nextMarker = prefix
+                                       full = len(resp.Contents)+len(commonPrefixes) >= params.maxKeys
+                                       return filepath.SkipDir
+                               }
                        }
                }
-               resp.Contents = append(resp.Contents, s3.Key{
+               if full {
+                       resp.IsTruncated = true
+                       return errDone
+               }
+               resp.Contents = append(resp.Contents, s3Key{
                        Key:          path,
                        LastModified: fi.ModTime().UTC().Format("2006-01-02T15:04:05.999") + "Z",
                        Size:         filesize,
                })
+               nextMarker = path
+               full = len(resp.Contents)+len(commonPrefixes) >= params.maxKeys
                return nil
        })
        if err != nil && err != errDone {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
        }
+       if params.delimiter == "" && !params.v2 || !resp.IsTruncated {
+               nextMarker = ""
+       }
        if params.delimiter != "" {
                resp.CommonPrefixes = make([]commonPrefix, 0, len(commonPrefixes))
                for prefix := range commonPrefixes {
@@ -923,15 +968,13 @@ func (h *handler) s3list(bucket string, w http.ResponseWriter, r *http.Request,
                        CommonPrefixes: resp.CommonPrefixes,
                        NextMarker:     nextMarker,
                        KeyCount:       resp.KeyCount,
-                       ListResp: s3.ListResp{
-                               IsTruncated: resp.IsTruncated,
-                               Name:        bucket,
-                               Prefix:      params.prefix,
-                               Delimiter:   params.delimiter,
-                               Marker:      params.marker,
-                               MaxKeys:     params.maxKeys,
-                               Contents:    resp.Contents,
-                       },
+                       IsTruncated:    resp.IsTruncated,
+                       Name:           bucket,
+                       Prefix:         params.prefix,
+                       Delimiter:      params.delimiter,
+                       Marker:         params.marker,
+                       MaxKeys:        params.maxKeys,
+                       Contents:       resp.Contents,
                }
        }
 
index aa91d82ae36ab6c01cb50ef7b7dd944af16b4236..79b3712c6b7a766efeb0b0c66ff2c8024e9771b0 100644 (file)
@@ -17,6 +17,7 @@ import (
        "net/url"
        "os"
        "os/exec"
+       "sort"
        "strings"
        "sync"
        "time"
@@ -324,6 +325,11 @@ func (s *IntegrationSuite) TestS3ProjectPutObjectSuccess(c *check.C) {
        s.testS3PutObjectSuccess(c, stage.projbucket, stage.coll.Name+"/", stage.coll.UUID)
 }
 func (s *IntegrationSuite) testS3PutObjectSuccess(c *check.C, bucket *s3.Bucket, prefix string, collUUID string) {
+       // We insert a delay between test cases to ensure we exercise
+       // rollover of expired sessions.
+       sleep := time.Second / 100
+       s.handler.Cluster.Collections.WebDAVCache.TTL = arvados.Duration(sleep * 3)
+
        for _, trial := range []struct {
                path        string
                size        int
@@ -359,6 +365,7 @@ func (s *IntegrationSuite) testS3PutObjectSuccess(c *check.C, bucket *s3.Bucket,
                        contentType: "application/x-directory",
                },
        } {
+               time.Sleep(sleep)
                c.Logf("=== %v", trial)
 
                objname := prefix + trial.path
@@ -817,8 +824,8 @@ func (s *IntegrationSuite) TestS3CollectionList(c *check.C) {
 
        var markers int
        for markers, s.handler.Cluster.Collections.S3FolderObjects = range []bool{false, true} {
-               dirs := 2
-               filesPerDir := 1001
+               dirs := 2000
+               filesPerDir := 2
                stage.writeBigDirs(c, dirs, filesPerDir)
                // Total # objects is:
                //                 2 file entries from s3setup (emptyfile and sailboat.txt)
@@ -827,6 +834,7 @@ func (s *IntegrationSuite) TestS3CollectionList(c *check.C) {
                // +filesPerDir*dirs file entries from writeBigDirs (dir0/file0.txt, etc.)
                s.testS3List(c, stage.collbucket, "", 4000, markers+2+(filesPerDir+markers)*dirs)
                s.testS3List(c, stage.collbucket, "", 131, markers+2+(filesPerDir+markers)*dirs)
+               s.testS3List(c, stage.collbucket, "", 51, markers+2+(filesPerDir+markers)*dirs)
                s.testS3List(c, stage.collbucket, "dir0/", 71, filesPerDir+markers)
        }
 }
@@ -849,6 +857,9 @@ func (s *IntegrationSuite) testS3List(c *check.C, bucket *s3.Bucket, prefix stri
                        break
                }
                for _, key := range resp.Contents {
+                       if _, dup := gotKeys[key.Key]; dup {
+                               c.Errorf("got duplicate key %q on page %d", key.Key, pages)
+                       }
                        gotKeys[key.Key] = key
                        if strings.Contains(key.Key, "sailboat.txt") {
                                c.Check(key.Size, check.Equals, int64(4))
@@ -863,7 +874,16 @@ func (s *IntegrationSuite) testS3List(c *check.C, bucket *s3.Bucket, prefix stri
                }
                nextMarker = resp.NextMarker
        }
-       c.Check(len(gotKeys), check.Equals, expectFiles)
+       if !c.Check(len(gotKeys), check.Equals, expectFiles) {
+               var sorted []string
+               for k := range gotKeys {
+                       sorted = append(sorted, k)
+               }
+               sort.Strings(sorted)
+               for _, k := range sorted {
+                       c.Logf("got %s", k)
+               }
+       }
 }
 
 func (s *IntegrationSuite) TestS3CollectionListRollup(c *check.C) {
@@ -929,7 +949,8 @@ func (s *IntegrationSuite) testS3CollectionListRollup(c *check.C) {
                {"dir0", "", ""},
                {"dir0/", "", ""},
                {"dir0/f", "", ""},
-               {"dir0", "/", "dir0/file14.txt"},       // no commonprefixes
+               {"dir0", "/", "dir0/file14.txt"},       // one commonprefix, "dir0/"
+               {"dir0", "/", "dir0/zzzzfile.txt"},     // no commonprefixes
                {"", "", "dir0/file14.txt"},            // middle page, skip walking dir1
                {"", "", "dir1/file14.txt"},            // middle page, skip walking dir0
                {"", "", "dir1/file498.txt"},           // last page of results
@@ -960,28 +981,31 @@ func (s *IntegrationSuite) testS3CollectionListRollup(c *check.C) {
                var expectTruncated bool
                for _, key := range allfiles {
                        full := len(expectKeys)+len(expectPrefixes) >= maxKeys
-                       if !strings.HasPrefix(key, trial.prefix) || key < trial.marker {
+                       if !strings.HasPrefix(key, trial.prefix) || key <= trial.marker {
                                continue
                        } else if idx := strings.Index(key[len(trial.prefix):], trial.delimiter); trial.delimiter != "" && idx >= 0 {
                                prefix := key[:len(trial.prefix)+idx+1]
                                if len(expectPrefixes) > 0 && expectPrefixes[len(expectPrefixes)-1] == prefix {
                                        // same prefix as previous key
                                } else if full {
-                                       expectNextMarker = key
                                        expectTruncated = true
                                } else {
                                        expectPrefixes = append(expectPrefixes, prefix)
+                                       expectNextMarker = prefix
                                }
                        } else if full {
-                               if trial.delimiter != "" {
-                                       expectNextMarker = key
-                               }
                                expectTruncated = true
                                break
                        } else {
                                expectKeys = append(expectKeys, key)
+                               if trial.delimiter != "" {
+                                       expectNextMarker = key
+                               }
                        }
                }
+               if !expectTruncated {
+                       expectNextMarker = ""
+               }
 
                var gotKeys []string
                for _, key := range resp.Contents {
@@ -1000,6 +1024,61 @@ func (s *IntegrationSuite) testS3CollectionListRollup(c *check.C) {
        }
 }
 
+func (s *IntegrationSuite) TestS3ListObjectsV2ManySubprojects(c *check.C) {
+       stage := s.s3setup(c)
+       defer stage.teardown(c)
+       projects := 50
+       collectionsPerProject := 2
+       for i := 0; i < projects; i++ {
+               var subproj arvados.Group
+               err := stage.arv.RequestAndDecode(&subproj, "POST", "arvados/v1/groups", nil, map[string]interface{}{
+                       "group": map[string]interface{}{
+                               "owner_uuid":  stage.subproj.UUID,
+                               "group_class": "project",
+                               "name":        fmt.Sprintf("keep-web s3 test subproject %d", i),
+                       },
+               })
+               c.Assert(err, check.IsNil)
+               for j := 0; j < collectionsPerProject; j++ {
+                       err = stage.arv.RequestAndDecode(nil, "POST", "arvados/v1/collections", nil, map[string]interface{}{"collection": map[string]interface{}{
+                               "owner_uuid":    subproj.UUID,
+                               "name":          fmt.Sprintf("keep-web s3 test collection %d", j),
+                               "manifest_text": ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:emptyfile\n./emptydir d41d8cd98f00b204e9800998ecf8427e+0 0:0:.\n",
+                       }})
+                       c.Assert(err, check.IsNil)
+               }
+       }
+       c.Logf("setup complete")
+
+       sess := aws_session.Must(aws_session.NewSession(&aws_aws.Config{
+               Region:           aws_aws.String("auto"),
+               Endpoint:         aws_aws.String(s.testServer.URL),
+               Credentials:      aws_credentials.NewStaticCredentials(url.QueryEscape(arvadostest.ActiveTokenV2), url.QueryEscape(arvadostest.ActiveTokenV2), ""),
+               S3ForcePathStyle: aws_aws.Bool(true),
+       }))
+       client := aws_s3.New(sess)
+       ctx := context.Background()
+       params := aws_s3.ListObjectsV2Input{
+               Bucket:    aws_aws.String(stage.proj.UUID),
+               Delimiter: aws_aws.String("/"),
+               Prefix:    aws_aws.String("keep-web s3 test subproject/"),
+               MaxKeys:   aws_aws.Int64(int64(projects / 2)),
+       }
+       for page := 1; ; page++ {
+               t0 := time.Now()
+               result, err := client.ListObjectsV2WithContext(ctx, &params)
+               if !c.Check(err, check.IsNil) {
+                       break
+               }
+               c.Logf("got page %d in %v with len(Contents) == %d, len(CommonPrefixes) == %d", page, time.Since(t0), len(result.Contents), len(result.CommonPrefixes))
+               if !*result.IsTruncated {
+                       break
+               }
+               params.ContinuationToken = result.NextContinuationToken
+               *params.MaxKeys = *params.MaxKeys/2 + 1
+       }
+}
+
 func (s *IntegrationSuite) TestS3ListObjectsV2(c *check.C) {
        stage := s.s3setup(c)
        defer stage.teardown(c)
@@ -1216,7 +1295,11 @@ func (s *IntegrationSuite) TestS3cmd(c *check.C) {
        cmd = exec.Command("s3cmd", "--no-ssl", "--host="+s.testServer.URL[7:], "--host-bucket="+s.testServer.URL[7:], "--access_key="+arvadostest.ActiveTokenUUID, "--secret_key="+arvadostest.ActiveToken, "get", "s3://"+arvadostest.FooCollection+"/foo,;$[|]bar", tmpfile)
        buf, err = cmd.CombinedOutput()
        c.Check(err, check.NotNil)
-       c.Check(string(buf), check.Matches, `(?ms).*NoSuchKey.*\n`)
+       // As of commit b7520e5c25e1bf25c1a8bf5aa2eadb299be8f606
+       // (between debian bullseye and bookworm versions), s3cmd
+       // started catching the NoSuchKey error code and replacing it
+       // with "Source object '%s' does not exist.".
+       c.Check(string(buf), check.Matches, `(?ms).*(NoSuchKey|Source object.*does not exist).*\n`)
 }
 
 func (s *IntegrationSuite) TestS3BucketInHost(c *check.C) {
index b3d0b9b418533110ce550da62946c1651af2245b..0308f949f4cbd0c4d3b47e6ab6e599100a0f03aa 100644 (file)
@@ -412,6 +412,24 @@ func (s *IntegrationSuite) TestMetrics(c *check.C) {
                resp.Body.Close()
        }
 
+       var coll arvados.Collection
+       arv, err := arvadosclient.MakeArvadosClient()
+       c.Assert(err, check.IsNil)
+       arv.ApiToken = arvadostest.ActiveTokenV2
+       err = arv.Create("collections", map[string]interface{}{"ensure_unique_name": true}, &coll)
+       c.Assert(err, check.IsNil)
+       defer arv.Delete("collections", coll.UUID, nil, nil)
+       for i := 0; i < 2; i++ {
+               size := 1 << (i * 12)
+               req, _ = http.NewRequest("PUT", srvaddr+"/zero-"+fmt.Sprintf("%d", size), bytes.NewReader(make([]byte, size)))
+               req.Host = coll.UUID + ".example.com"
+               req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
+               resp, err = http.DefaultClient.Do(req)
+               c.Assert(err, check.IsNil)
+               c.Check(resp.StatusCode, check.Equals, http.StatusCreated)
+               resp.Body.Close()
+       }
+
        time.Sleep(metricsUpdateInterval * 2)
 
        req, _ = http.NewRequest("GET", srvaddr+"/metrics.json", nil)
@@ -476,7 +494,7 @@ func (s *IntegrationSuite) TestMetrics(c *check.C) {
        c.Check(summaries["request_duration_seconds/get/200"].SampleCount, check.Equals, "3")
        c.Check(summaries["request_duration_seconds/get/404"].SampleCount, check.Equals, "1")
        c.Check(summaries["time_to_status_seconds/get/404"].SampleCount, check.Equals, "1")
-       c.Check(gauges["arvados_keepweb_sessions_cached_session_bytes//"].Value, check.Equals, float64(469))
+       c.Check(gauges["arvados_keepweb_sessions_cached_session_bytes//"].Value, check.Equals, float64(1208))
 
        // If the Host header indicates a collection, /metrics.json
        // refers to a file in the collection -- the metrics handler
@@ -490,6 +508,22 @@ func (s *IntegrationSuite) TestMetrics(c *check.C) {
                c.Assert(err, check.IsNil)
                c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
        }
+
+       req, _ = http.NewRequest("GET", srvaddr+"/metrics", nil)
+       req.Host = cluster.Services.WebDAVDownload.ExternalURL.Host
+       req.Header.Set("Authorization", "Bearer "+arvadostest.ManagementToken)
+       resp, err = http.DefaultClient.Do(req)
+       c.Assert(err, check.IsNil)
+       c.Check(resp.StatusCode, check.Equals, http.StatusOK)
+       allmetrics, err := ioutil.ReadAll(resp.Body)
+       c.Check(err, check.IsNil)
+
+       c.Check(string(allmetrics), check.Matches, `(?ms).*\narvados_keepweb_download_apparent_backend_speed_bucket{size_range="0",le="\+Inf"} 4\n.*`)
+       c.Check(string(allmetrics), check.Matches, `(?ms).*\narvados_keepweb_download_speed_bucket{size_range="0",le="\+Inf"} 4\n.*`)
+       c.Check(string(allmetrics), check.Matches, `(?ms).*\narvados_keepweb_upload_speed_bucket{size_range="0",le="\+Inf"} 2\n.*`)
+       c.Check(string(allmetrics), check.Matches, `(?ms).*\narvados_keepweb_upload_sync_delay_seconds_bucket{size_range="0",le="10"} 2\n.*`)
+
+       c.Logf("%s", allmetrics)
 }
 
 func (s *IntegrationSuite) SetUpSuite(c *check.C) {
diff --git a/services/keep-web/webdav.go b/services/keep-web/webdav.go
deleted file mode 100644 (file)
index 0039f04..0000000
+++ /dev/null
@@ -1,201 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepweb
-
-import (
-       "crypto/rand"
-       "errors"
-       "fmt"
-       "io"
-       prand "math/rand"
-       "os"
-       "path"
-       "strings"
-       "sync/atomic"
-       "time"
-
-       "git.arvados.org/arvados.git/sdk/go/arvados"
-
-       "golang.org/x/net/context"
-       "golang.org/x/net/webdav"
-)
-
-var (
-       lockPrefix     string = uuid()
-       nextLockSuffix int64  = prand.Int63()
-       errReadOnly           = errors.New("read-only filesystem")
-)
-
-// webdavFS implements a webdav.FileSystem by wrapping an
-// arvados.CollectionFilesystem.
-//
-// Collections don't preserve empty directories, so Mkdir is
-// effectively a no-op, and we need to make parent dirs spring into
-// existence automatically so sequences like "mkcol foo; put foo/bar"
-// work as expected.
-type webdavFS struct {
-       collfs arvados.FileSystem
-       // prefix works like fs.Sub: Stat(name) calls
-       // Stat(prefix+name) in the wrapped filesystem.
-       prefix  string
-       writing bool
-       // webdav PROPFIND reads the first few bytes of each file
-       // whose filename extension isn't recognized, which is
-       // prohibitively expensive: we end up fetching multiple 64MiB
-       // blocks. Avoid this by returning EOF on all reads when
-       // handling a PROPFIND.
-       alwaysReadEOF bool
-}
-
-func (fs *webdavFS) makeparents(name string) {
-       if !fs.writing {
-               return
-       }
-       dir, _ := path.Split(name)
-       if dir == "" || dir == "/" {
-               return
-       }
-       dir = dir[:len(dir)-1]
-       fs.makeparents(dir)
-       fs.collfs.Mkdir(fs.prefix+dir, 0755)
-}
-
-func (fs *webdavFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
-       if !fs.writing {
-               return errReadOnly
-       }
-       name = strings.TrimRight(name, "/")
-       fs.makeparents(name)
-       return fs.collfs.Mkdir(fs.prefix+name, 0755)
-}
-
-func (fs *webdavFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (f webdav.File, err error) {
-       writing := flag&(os.O_WRONLY|os.O_RDWR|os.O_TRUNC) != 0
-       if writing {
-               fs.makeparents(name)
-       }
-       f, err = fs.collfs.OpenFile(fs.prefix+name, flag, perm)
-       if !fs.writing {
-               // webdav module returns 404 on all OpenFile errors,
-               // but returns 405 Method Not Allowed if OpenFile()
-               // succeeds but Write() or Close() fails. We'd rather
-               // have 405. writeFailer ensures Close() fails if the
-               // file is opened for writing *or* Write() is called.
-               var err error
-               if writing {
-                       err = errReadOnly
-               }
-               f = writeFailer{File: f, err: err}
-       }
-       if fs.alwaysReadEOF {
-               f = readEOF{File: f}
-       }
-       return
-}
-
-func (fs *webdavFS) RemoveAll(ctx context.Context, name string) error {
-       return fs.collfs.RemoveAll(fs.prefix + name)
-}
-
-func (fs *webdavFS) Rename(ctx context.Context, oldName, newName string) error {
-       if !fs.writing {
-               return errReadOnly
-       }
-       if strings.HasSuffix(oldName, "/") {
-               // WebDAV "MOVE foo/ bar/" means rename foo to bar.
-               oldName = oldName[:len(oldName)-1]
-               newName = strings.TrimSuffix(newName, "/")
-       }
-       fs.makeparents(newName)
-       return fs.collfs.Rename(fs.prefix+oldName, fs.prefix+newName)
-}
-
-func (fs *webdavFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
-       if fs.writing {
-               fs.makeparents(name)
-       }
-       return fs.collfs.Stat(fs.prefix + name)
-}
-
-type writeFailer struct {
-       webdav.File
-       err error
-}
-
-func (wf writeFailer) Write([]byte) (int, error) {
-       wf.err = errReadOnly
-       return 0, wf.err
-}
-
-func (wf writeFailer) Close() error {
-       err := wf.File.Close()
-       if err != nil {
-               wf.err = err
-       }
-       return wf.err
-}
-
-type readEOF struct {
-       webdav.File
-}
-
-func (readEOF) Read(p []byte) (int, error) {
-       return 0, io.EOF
-}
-
-// noLockSystem implements webdav.LockSystem by returning success for
-// every possible locking operation, even though it has no side
-// effects such as actually locking anything. This works for a
-// read-only webdav filesystem because webdav locks only apply to
-// writes.
-//
-// This is more suitable than webdav.NewMemLS() for two reasons:
-// First, it allows keep-web to use one locker for all collections
-// even though coll1.vhost/foo and coll2.vhost/foo have the same path
-// but represent different resources. Additionally, it returns valid
-// tokens (rfc2518 specifies that tokens are represented as URIs and
-// are unique across all resources for all time), which might improve
-// client compatibility.
-//
-// However, it does also permit impossible operations, like acquiring
-// conflicting locks and releasing non-existent locks.  This might
-// confuse some clients if they try to probe for correctness.
-//
-// Currently this is a moot point: the LOCK and UNLOCK methods are not
-// accepted by keep-web, so it suffices to implement the
-// webdav.LockSystem interface.
-type noLockSystem struct{}
-
-func (*noLockSystem) Confirm(time.Time, string, string, ...webdav.Condition) (func(), error) {
-       return noop, nil
-}
-
-func (*noLockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
-       return fmt.Sprintf("opaquelocktoken:%s-%x", lockPrefix, atomic.AddInt64(&nextLockSuffix, 1)), nil
-}
-
-func (*noLockSystem) Refresh(now time.Time, token string, duration time.Duration) (webdav.LockDetails, error) {
-       return webdav.LockDetails{}, nil
-}
-
-func (*noLockSystem) Unlock(now time.Time, token string) error {
-       return nil
-}
-
-func noop() {}
-
-// Return a version 1 variant 4 UUID, meaning all bits are random
-// except the ones indicating the version and variant.
-func uuid() string {
-       var data [16]byte
-       if _, err := rand.Read(data[:]); err != nil {
-               panic(err)
-       }
-       // variant 1: N=10xx
-       data[8] = data[8]&0x3f | 0x80
-       // version 4: M=0100
-       data[6] = data[6]&0x0f | 0x40
-       return fmt.Sprintf("%x-%x-%x-%x-%x", data[0:4], data[4:6], data[6:8], data[8:10], data[10:])
-}
diff --git a/services/keep-web/webdav_test.go b/services/keep-web/webdav_test.go
deleted file mode 100644 (file)
index a450906..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepweb
-
-import "golang.org/x/net/webdav"
-
-var _ webdav.FileSystem = &webdavFS{}
index f857ed3e4ebf4859e1355260ce85bc52fa50de90..39ffd45cbe37b69f663dc6093acd4dfe221c74a1 100644 (file)
@@ -175,13 +175,18 @@ func (h *proxyHandler) checkAuthorizationHeader(req *http.Request) (pass bool, t
        return true, tok, user
 }
 
-// We need to make a private copy of the default http transport early
-// in initialization, then make copies of our private copy later. It
-// won't be safe to copy http.DefaultTransport itself later, because
-// its private mutexes might have already been used. (Without this,
-// the test suite sometimes panics "concurrent map writes" in
-// net/http.(*Transport).removeIdleConnLocked().)
-var defaultTransport = *(http.DefaultTransport.(*http.Transport))
+// We can't copy the default http transport because http.Transport has
+// a mutex field, so we make our own using the values of the exported
+// fields.
+var defaultTransport = http.Transport{
+       Proxy:                 http.DefaultTransport.(*http.Transport).Proxy,
+       DialContext:           http.DefaultTransport.(*http.Transport).DialContext,
+       ForceAttemptHTTP2:     http.DefaultTransport.(*http.Transport).ForceAttemptHTTP2,
+       MaxIdleConns:          http.DefaultTransport.(*http.Transport).MaxIdleConns,
+       IdleConnTimeout:       http.DefaultTransport.(*http.Transport).IdleConnTimeout,
+       TLSHandshakeTimeout:   http.DefaultTransport.(*http.Transport).TLSHandshakeTimeout,
+       ExpectContinueTimeout: http.DefaultTransport.(*http.Transport).ExpectContinueTimeout,
+}
 
 type proxyHandler struct {
        http.Handler
@@ -195,14 +200,23 @@ type proxyHandler struct {
 func newHandler(ctx context.Context, kc *keepclient.KeepClient, timeout time.Duration, cluster *arvados.Cluster) (service.Handler, error) {
        rest := mux.NewRouter()
 
-       transport := defaultTransport
-       transport.DialContext = (&net.Dialer{
-               Timeout:   keepclient.DefaultConnectTimeout,
-               KeepAlive: keepclient.DefaultKeepAlive,
-               DualStack: true,
-       }).DialContext
-       transport.TLSClientConfig = arvadosclient.MakeTLSConfig(kc.Arvados.ApiInsecure)
-       transport.TLSHandshakeTimeout = keepclient.DefaultTLSHandshakeTimeout
+       // We can't copy the default http transport because
+       // http.Transport has a mutex field, so we copy the fields
+       // that we know have non-zero values in http.DefaultTransport.
+       transport := &http.Transport{
+               Proxy:                 http.DefaultTransport.(*http.Transport).Proxy,
+               ForceAttemptHTTP2:     http.DefaultTransport.(*http.Transport).ForceAttemptHTTP2,
+               MaxIdleConns:          http.DefaultTransport.(*http.Transport).MaxIdleConns,
+               IdleConnTimeout:       http.DefaultTransport.(*http.Transport).IdleConnTimeout,
+               ExpectContinueTimeout: http.DefaultTransport.(*http.Transport).ExpectContinueTimeout,
+               DialContext: (&net.Dialer{
+                       Timeout:   keepclient.DefaultConnectTimeout,
+                       KeepAlive: keepclient.DefaultKeepAlive,
+                       DualStack: true,
+               }).DialContext,
+               TLSClientConfig:     arvadosclient.MakeTLSConfig(kc.Arvados.ApiInsecure),
+               TLSHandshakeTimeout: keepclient.DefaultTLSHandshakeTimeout,
+       }
 
        cacheQ, err := lru.New2Q(500)
        if err != nil {
@@ -213,7 +227,7 @@ func newHandler(ctx context.Context, kc *keepclient.KeepClient, timeout time.Dur
                Handler:    rest,
                KeepClient: kc,
                timeout:    timeout,
-               transport:  &transport,
+               transport:  transport,
                apiTokenCache: &apiTokenCache{
                        tokens:     cacheQ,
                        expireTime: 300,
@@ -290,7 +304,6 @@ func (h *proxyHandler) Get(resp http.ResponseWriter, req *http.Request) {
        var err error
        var status int
        var expectLength, responseLength int64
-       var proxiedURI = "-"
 
        logger := ctxlog.FromContext(req.Context())
        defer func() {
@@ -298,7 +311,6 @@ func (h *proxyHandler) Get(resp http.ResponseWriter, req *http.Request) {
                        "locator":        locator,
                        "expectLength":   expectLength,
                        "responseLength": responseLength,
-                       "proxiedURI":     proxiedURI,
                        "err":            err,
                })
                if status != http.StatusOK {
@@ -307,6 +319,7 @@ func (h *proxyHandler) Get(resp http.ResponseWriter, req *http.Request) {
        }()
 
        kc := h.makeKeepClient(req)
+       kc.DiskCacheSize = keepclient.DiskCacheDisabled
 
        var pass bool
        var tok string
@@ -331,9 +344,9 @@ func (h *proxyHandler) Get(resp http.ResponseWriter, req *http.Request) {
 
        switch req.Method {
        case "HEAD":
-               expectLength, proxiedURI, err = kc.Ask(locator)
+               expectLength, _, err = kc.Ask(locator)
        case "GET":
-               reader, expectLength, proxiedURI, err = kc.Get(locator)
+               reader, expectLength, _, err = kc.Get(locator)
                if reader != nil {
                        defer reader.Close()
                }
@@ -509,9 +522,9 @@ func (h *proxyHandler) Put(resp http.ResponseWriter, req *http.Request) {
 // ServeHTTP implementation for IndexHandler
 // Supports only GET requests for /index/{prefix:[0-9a-f]{0,32}}
 // For each keep server found in LocalRoots:
-//   Invokes GetIndex using keepclient
-//   Expects "complete" response (terminating with blank new line)
-//   Aborts on any errors
+// - Invokes GetIndex using keepclient
+// - Expects "complete" response (terminating with blank new line)
+// - Aborts on any errors
 // Concatenates responses from all those keep servers and returns
 func (h *proxyHandler) Index(resp http.ResponseWriter, req *http.Request) {
        setCORSHeaders(resp)
@@ -566,7 +579,7 @@ func (h *proxyHandler) Index(resp http.ResponseWriter, req *http.Request) {
 }
 
 func (h *proxyHandler) makeKeepClient(req *http.Request) *keepclient.KeepClient {
-       kc := *h.KeepClient
+       kc := h.KeepClient.Clone()
        kc.RequestID = req.Header.Get("X-Request-Id")
        kc.HTTPClient = &proxyClient{
                client: &http.Client{
@@ -575,5 +588,5 @@ func (h *proxyHandler) makeKeepClient(req *http.Request) *keepclient.KeepClient
                },
                proto: req.Proto,
        }
-       return &kc
+       return kc
 }
index 8c4a649f69d64d4872fb11efb7083403d87094da..2c73e2d1040d1b37df4e77375b5a859d3187565e 100644 (file)
@@ -32,8 +32,8 @@ import (
        . "gopkg.in/check.v1"
 )
 
-// Gocheck boilerplate
 func Test(t *testing.T) {
+       keepclient.DefaultRetryDelay = time.Millisecond
        TestingT(t)
 }
 
@@ -142,6 +142,7 @@ func runProxy(c *C, bogusClientToken bool, loadKeepstoresFromConfig bool, kp *ar
                arv.ApiToken = "bogus-token"
        }
        kc := keepclient.New(arv)
+       kc.DiskCacheSize = keepclient.DiskCacheDisabled
        sr := map[string]string{
                TestProxyUUID: "http://" + srv.Addr,
        }
@@ -345,7 +346,7 @@ func (s *ServerRequiredSuite) TestPutAskGet(c *C) {
        }
 
        {
-               reader, _, _, err := kc.Get(hash)
+               reader, _, _, err := kc.Get(hash + "+3")
                c.Check(reader, Equals, nil)
                c.Check(err, Equals, keepclient.BlockNotFound)
                c.Log("Finished Get (expected BlockNotFound)")
@@ -406,7 +407,7 @@ func (s *ServerRequiredSuite) TestPutAskGet(c *C) {
 
        {
                reader, blocklen, _, err := kc.Get("d41d8cd98f00b204e9800998ecf8427e")
-               c.Assert(err, Equals, nil)
+               c.Assert(err, IsNil)
                all, err := ioutil.ReadAll(reader)
                c.Check(err, IsNil)
                c.Check(all, DeepEquals, []byte(""))
@@ -607,22 +608,22 @@ func (s *ServerRequiredSuite) TestStripHint(c *C) {
 }
 
 // Test GetIndex
-//   Put one block, with 2 replicas
-//   With no prefix (expect the block locator, twice)
-//   With an existing prefix (expect the block locator, twice)
-//   With a valid but non-existing prefix (expect "\n")
-//   With an invalid prefix (expect error)
+// - Put one block, with 2 replicas
+// - With no prefix (expect the block locator, twice)
+// - With an existing prefix (expect the block locator, twice)
+// - With a valid but non-existing prefix (expect "\n")
+// - With an invalid prefix (expect error)
 func (s *ServerRequiredSuite) TestGetIndex(c *C) {
        getIndexWorker(c, false)
 }
 
 // Test GetIndex
-//   Uses config.yml
-//   Put one block, with 2 replicas
-//   With no prefix (expect the block locator, twice)
-//   With an existing prefix (expect the block locator, twice)
-//   With a valid but non-existing prefix (expect "\n")
-//   With an invalid prefix (expect error)
+// - Uses config.yml
+// - Put one block, with 2 replicas
+// - With no prefix (expect the block locator, twice)
+// - With an existing prefix (expect the block locator, twice)
+// - With a valid but non-existing prefix (expect "\n")
+// - With an invalid prefix (expect error)
 func (s *ServerRequiredConfigYmlSuite) TestGetIndex(c *C) {
        getIndexWorker(c, true)
 }
@@ -640,7 +641,7 @@ func getIndexWorker(c *C, useConfig bool) {
        c.Check(rep, Equals, 2)
        c.Check(err, Equals, nil)
 
-       reader, blocklen, _, err := kc.Get(hash)
+       reader, blocklen, _, err := kc.Get(hash2)
        c.Assert(err, IsNil)
        c.Check(blocklen, Equals, int64(10))
        all, err := ioutil.ReadAll(reader)
@@ -782,10 +783,12 @@ func (s *NoKeepServerSuite) TestAskGetNoKeepServerError(c *C) {
                },
        } {
                err := f()
-               c.Assert(err, NotNil)
+               c.Check(err, NotNil)
                errNotFound, _ := err.(*keepclient.ErrNotFound)
-               c.Check(errNotFound.Temporary(), Equals, true)
-               c.Check(err, ErrorMatches, `.*HTTP 502.*`)
+               if c.Check(errNotFound, NotNil) {
+                       c.Check(errNotFound.Temporary(), Equals, true)
+                       c.Check(err, ErrorMatches, `.*HTTP 502.*`)
+               }
        }
 }
 
index f9b383e70e5a1d531c414f7c180e1d623e7c55b2..2c8a79350c86b02e08eea2007c58a8f2e632ca47 100644 (file)
@@ -5,13 +5,11 @@
 package keepstore
 
 import (
-       "bytes"
        "context"
        "encoding/json"
        "errors"
        "fmt"
        "io"
-       "io/ioutil"
        "net/http"
        "os"
        "regexp"
@@ -32,17 +30,18 @@ func init() {
        driver["Azure"] = newAzureBlobVolume
 }
 
-func newAzureBlobVolume(cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) (Volume, error) {
-       v := &AzureBlobVolume{
+func newAzureBlobVolume(params newVolumeParams) (volume, error) {
+       v := &azureBlobVolume{
                RequestTimeout:    azureDefaultRequestTimeout,
                WriteRaceInterval: azureDefaultWriteRaceInterval,
                WriteRacePollTime: azureDefaultWriteRacePollTime,
-               cluster:           cluster,
-               volume:            volume,
-               logger:            logger,
-               metrics:           metrics,
+               cluster:           params.Cluster,
+               volume:            params.ConfigVolume,
+               logger:            params.Logger,
+               metrics:           params.MetricsVecs,
+               bufferPool:        params.BufferPool,
        }
-       err := json.Unmarshal(volume.DriverParameters, &v)
+       err := json.Unmarshal(params.ConfigVolume.DriverParameters, &v)
        if err != nil {
                return nil, err
        }
@@ -80,8 +79,8 @@ func newAzureBlobVolume(cluster *arvados.Cluster, volume arvados.Volume, logger
        return v, v.check()
 }
 
-func (v *AzureBlobVolume) check() error {
-       lbls := prometheus.Labels{"device_id": v.GetDeviceID()}
+func (v *azureBlobVolume) check() error {
+       lbls := prometheus.Labels{"device_id": v.DeviceID()}
        v.container.stats.opsCounters, v.container.stats.errCounters, v.container.stats.ioBytes = v.metrics.getCounterVecsFor(lbls)
        return nil
 }
@@ -94,9 +93,9 @@ const (
        azureDefaultWriteRacePollTime    = arvados.Duration(time.Second)
 )
 
-// An AzureBlobVolume stores and retrieves blocks in an Azure Blob
+// An azureBlobVolume stores and retrieves blocks in an Azure Blob
 // container.
-type AzureBlobVolume struct {
+type azureBlobVolume struct {
        StorageAccountName   string
        StorageAccountKey    string
        StorageBaseURL       string // "" means default, "core.windows.net"
@@ -108,12 +107,13 @@ type AzureBlobVolume struct {
        WriteRaceInterval    arvados.Duration
        WriteRacePollTime    arvados.Duration
 
-       cluster   *arvados.Cluster
-       volume    arvados.Volume
-       logger    logrus.FieldLogger
-       metrics   *volumeMetricsVecs
-       azClient  storage.Client
-       container *azureContainer
+       cluster    *arvados.Cluster
+       volume     arvados.Volume
+       logger     logrus.FieldLogger
+       metrics    *volumeMetricsVecs
+       bufferPool *bufferPool
+       azClient   storage.Client
+       container  *azureContainer
 }
 
 // singleSender is a single-attempt storage.Sender.
@@ -124,18 +124,13 @@ func (*singleSender) Send(c *storage.Client, req *http.Request) (resp *http.Resp
        return c.HTTPClient.Do(req)
 }
 
-// Type implements Volume.
-func (v *AzureBlobVolume) Type() string {
-       return "Azure"
-}
-
-// GetDeviceID returns a globally unique ID for the storage container.
-func (v *AzureBlobVolume) GetDeviceID() string {
+// DeviceID returns a globally unique ID for the storage container.
+func (v *azureBlobVolume) DeviceID() string {
        return "azure://" + v.StorageBaseURL + "/" + v.StorageAccountName + "/" + v.ContainerName
 }
 
 // Return true if expires_at metadata attribute is found on the block
-func (v *AzureBlobVolume) checkTrashed(loc string) (bool, map[string]string, error) {
+func (v *azureBlobVolume) checkTrashed(loc string) (bool, map[string]string, error) {
        metadata, err := v.container.GetBlobMetadata(loc)
        if err != nil {
                return false, metadata, v.translateError(err)
@@ -146,30 +141,34 @@ func (v *AzureBlobVolume) checkTrashed(loc string) (bool, map[string]string, err
        return false, metadata, nil
 }
 
-// Get reads a Keep block that has been stored as a block blob in the
-// container.
+// BlockRead reads a Keep block that has been stored as a block blob
+// in the container.
 //
 // If the block is younger than azureWriteRaceInterval and is
-// unexpectedly empty, assume a PutBlob operation is in progress, and
-// wait for it to finish writing.
-func (v *AzureBlobVolume) Get(ctx context.Context, loc string, buf []byte) (int, error) {
-       trashed, _, err := v.checkTrashed(loc)
+// unexpectedly empty, assume a BlockWrite operation is in progress,
+// and wait for it to finish writing.
+func (v *azureBlobVolume) BlockRead(ctx context.Context, hash string, w io.WriterAt) error {
+       trashed, _, err := v.checkTrashed(hash)
        if err != nil {
-               return 0, err
+               return err
        }
        if trashed {
-               return 0, os.ErrNotExist
+               return os.ErrNotExist
        }
+       buf, err := v.bufferPool.GetContext(ctx)
+       if err != nil {
+               return err
+       }
+       defer v.bufferPool.Put(buf)
        var deadline time.Time
-       haveDeadline := false
-       size, err := v.get(ctx, loc, buf)
-       for err == nil && size == 0 && loc != "d41d8cd98f00b204e9800998ecf8427e" {
+       wrote, err := v.get(ctx, hash, w)
+       for err == nil && wrote == 0 && hash != "d41d8cd98f00b204e9800998ecf8427e" {
                // Seeing a brand new empty block probably means we're
                // in a race with CreateBlob, which under the hood
                // (apparently) does "CreateEmpty" and "CommitData"
                // with no additional transaction locking.
-               if !haveDeadline {
-                       t, err := v.Mtime(loc)
+               if deadline.IsZero() {
+                       t, err := v.Mtime(hash)
                        if err != nil {
                                ctxlog.FromContext(ctx).Print("Got empty block (possible race) but Mtime failed: ", err)
                                break
@@ -178,25 +177,24 @@ func (v *AzureBlobVolume) Get(ctx context.Context, loc string, buf []byte) (int,
                        if time.Now().After(deadline) {
                                break
                        }
-                       ctxlog.FromContext(ctx).Printf("Race? Block %s is 0 bytes, %s old. Polling until %s", loc, time.Since(t), deadline)
-                       haveDeadline = true
+                       ctxlog.FromContext(ctx).Printf("Race? Block %s is 0 bytes, %s old. Polling until %s", hash, time.Since(t), deadline)
                } else if time.Now().After(deadline) {
                        break
                }
                select {
                case <-ctx.Done():
-                       return 0, ctx.Err()
+                       return ctx.Err()
                case <-time.After(v.WriteRacePollTime.Duration()):
                }
-               size, err = v.get(ctx, loc, buf)
+               wrote, err = v.get(ctx, hash, w)
        }
-       if haveDeadline {
-               ctxlog.FromContext(ctx).Printf("Race ended with size==%d", size)
+       if !deadline.IsZero() {
+               ctxlog.FromContext(ctx).Printf("Race ended with size==%d", wrote)
        }
-       return size, err
+       return err
 }
 
-func (v *AzureBlobVolume) get(ctx context.Context, loc string, buf []byte) (int, error) {
+func (v *azureBlobVolume) get(ctx context.Context, hash string, dst io.WriterAt) (int, error) {
        ctx, cancel := context.WithCancel(ctx)
        defer cancel()
 
@@ -206,28 +204,30 @@ func (v *AzureBlobVolume) get(ctx context.Context, loc string, buf []byte) (int,
        }
 
        pieces := 1
-       expectSize := len(buf)
+       expectSize := BlockSize
+       sizeKnown := false
        if pieceSize < BlockSize {
-               // Unfortunately the handler doesn't tell us how long the blob
-               // is expected to be, so we have to ask Azure.
-               props, err := v.container.GetBlobProperties(loc)
+               // Unfortunately the handler doesn't tell us how long
+               // the blob is expected to be, so we have to ask
+               // Azure.
+               props, err := v.container.GetBlobProperties(hash)
                if err != nil {
                        return 0, v.translateError(err)
                }
                if props.ContentLength > int64(BlockSize) || props.ContentLength < 0 {
-                       return 0, fmt.Errorf("block %s invalid size %d (max %d)", loc, props.ContentLength, BlockSize)
+                       return 0, fmt.Errorf("block %s invalid size %d (max %d)", hash, props.ContentLength, BlockSize)
                }
                expectSize = int(props.ContentLength)
                pieces = (expectSize + pieceSize - 1) / pieceSize
+               sizeKnown = true
        }
 
        if expectSize == 0 {
                return 0, nil
        }
 
-       // We'll update this actualSize if/when we get the last piece.
-       actualSize := -1
        errors := make(chan error, pieces)
+       var wrote atomic.Int64
        var wg sync.WaitGroup
        wg.Add(pieces)
        for p := 0; p < pieces; p++ {
@@ -252,9 +252,9 @@ func (v *AzureBlobVolume) get(ctx context.Context, loc string, buf []byte) (int,
                        go func() {
                                defer close(gotRdr)
                                if startPos == 0 && endPos == expectSize {
-                                       rdr, err = v.container.GetBlob(loc)
+                                       rdr, err = v.container.GetBlob(hash)
                                } else {
-                                       rdr, err = v.container.GetBlobRange(loc, startPos, endPos-1, nil)
+                                       rdr, err = v.container.GetBlobRange(hash, startPos, endPos-1, nil)
                                }
                        }()
                        select {
@@ -282,86 +282,44 @@ func (v *AzureBlobVolume) get(ctx context.Context, loc string, buf []byte) (int,
                                <-ctx.Done()
                                rdr.Close()
                        }()
-                       n, err := io.ReadFull(rdr, buf[startPos:endPos])
-                       if pieces == 1 && (err == io.ErrUnexpectedEOF || err == io.EOF) {
+                       n, err := io.CopyN(io.NewOffsetWriter(dst, int64(startPos)), rdr, int64(endPos-startPos))
+                       wrote.Add(n)
+                       if pieces == 1 && !sizeKnown && (err == io.ErrUnexpectedEOF || err == io.EOF) {
                                // If we don't know the actual size,
                                // and just tried reading 64 MiB, it's
                                // normal to encounter EOF.
                        } else if err != nil {
-                               if ctx.Err() == nil {
-                                       errors <- err
-                               }
+                               errors <- err
                                cancel()
                                return
                        }
-                       if p == pieces-1 {
-                               actualSize = startPos + n
-                       }
                }(p)
        }
        wg.Wait()
        close(errors)
        if len(errors) > 0 {
-               return 0, v.translateError(<-errors)
-       }
-       if ctx.Err() != nil {
-               return 0, ctx.Err()
+               return int(wrote.Load()), v.translateError(<-errors)
        }
-       return actualSize, nil
+       return int(wrote.Load()), ctx.Err()
 }
 
-// Compare the given data with existing stored data.
-func (v *AzureBlobVolume) Compare(ctx context.Context, loc string, expect []byte) error {
-       trashed, _, err := v.checkTrashed(loc)
-       if err != nil {
-               return err
-       }
-       if trashed {
-               return os.ErrNotExist
-       }
-       var rdr io.ReadCloser
-       gotRdr := make(chan struct{})
-       go func() {
-               defer close(gotRdr)
-               rdr, err = v.container.GetBlob(loc)
-       }()
-       select {
-       case <-ctx.Done():
-               go func() {
-                       <-gotRdr
-                       if err == nil {
-                               rdr.Close()
-                       }
-               }()
-               return ctx.Err()
-       case <-gotRdr:
-       }
-       if err != nil {
-               return v.translateError(err)
-       }
-       defer rdr.Close()
-       return compareReaderWithBuf(ctx, rdr, expect, loc[:32])
-}
-
-// Put stores a Keep block as a block blob in the container.
-func (v *AzureBlobVolume) Put(ctx context.Context, loc string, block []byte) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
+// BlockWrite stores a block on the volume. If it already exists, its
+// timestamp is updated.
+func (v *azureBlobVolume) BlockWrite(ctx context.Context, hash string, data []byte) error {
        // Send the block data through a pipe, so that (if we need to)
        // we can close the pipe early and abandon our
        // CreateBlockBlobFromReader() goroutine, without worrying
-       // about CreateBlockBlobFromReader() accessing our block
+       // about CreateBlockBlobFromReader() accessing our data
        // buffer after we release it.
        bufr, bufw := io.Pipe()
        go func() {
-               io.Copy(bufw, bytes.NewReader(block))
+               bufw.Write(data)
                bufw.Close()
        }()
-       errChan := make(chan error)
+       errChan := make(chan error, 1)
        go func() {
                var body io.Reader = bufr
-               if len(block) == 0 {
+               if len(data) == 0 {
                        // We must send a "Content-Length: 0" header,
                        // but the http client interprets
                        // ContentLength==0 as "unknown" unless it can
@@ -370,18 +328,15 @@ func (v *AzureBlobVolume) Put(ctx context.Context, loc string, block []byte) err
                        body = http.NoBody
                        bufr.Close()
                }
-               errChan <- v.container.CreateBlockBlobFromReader(loc, len(block), body, nil)
+               errChan <- v.container.CreateBlockBlobFromReader(hash, len(data), body, nil)
        }()
        select {
        case <-ctx.Done():
                ctxlog.FromContext(ctx).Debugf("%s: taking CreateBlockBlobFromReader's input away: %s", v, ctx.Err())
-               // Our pipe might be stuck in Write(), waiting for
-               // io.Copy() to read. If so, un-stick it. This means
-               // CreateBlockBlobFromReader will get corrupt data,
-               // but that's OK: the size won't match, so the write
-               // will fail.
-               go io.Copy(ioutil.Discard, bufr)
-               // CloseWithError() will return once pending I/O is done.
+               // bufw.CloseWithError() interrupts bufw.Write() if
+               // necessary, ensuring CreateBlockBlobFromReader can't
+               // read any more of our data slice via bufr after we
+               // return.
                bufw.CloseWithError(ctx.Err())
                ctxlog.FromContext(ctx).Debugf("%s: abandoning CreateBlockBlobFromReader goroutine", v)
                return ctx.Err()
@@ -390,12 +345,9 @@ func (v *AzureBlobVolume) Put(ctx context.Context, loc string, block []byte) err
        }
 }
 
-// Touch updates the last-modified property of a block blob.
-func (v *AzureBlobVolume) Touch(loc string) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       trashed, metadata, err := v.checkTrashed(loc)
+// BlockTouch updates the last-modified property of a block blob.
+func (v *azureBlobVolume) BlockTouch(hash string) error {
+       trashed, metadata, err := v.checkTrashed(hash)
        if err != nil {
                return err
        }
@@ -404,12 +356,12 @@ func (v *AzureBlobVolume) Touch(loc string) error {
        }
 
        metadata["touch"] = fmt.Sprintf("%d", time.Now().Unix())
-       return v.container.SetBlobMetadata(loc, metadata, nil)
+       return v.container.SetBlobMetadata(hash, metadata, nil)
 }
 
 // Mtime returns the last-modified property of a block blob.
-func (v *AzureBlobVolume) Mtime(loc string) (time.Time, error) {
-       trashed, _, err := v.checkTrashed(loc)
+func (v *azureBlobVolume) Mtime(hash string) (time.Time, error) {
+       trashed, _, err := v.checkTrashed(hash)
        if err != nil {
                return time.Time{}, err
        }
@@ -417,21 +369,25 @@ func (v *AzureBlobVolume) Mtime(loc string) (time.Time, error) {
                return time.Time{}, os.ErrNotExist
        }
 
-       props, err := v.container.GetBlobProperties(loc)
+       props, err := v.container.GetBlobProperties(hash)
        if err != nil {
                return time.Time{}, err
        }
        return time.Time(props.LastModified), nil
 }
 
-// IndexTo writes a list of Keep blocks that are stored in the
+// Index writes a list of Keep blocks that are stored in the
 // container.
-func (v *AzureBlobVolume) IndexTo(prefix string, writer io.Writer) error {
+func (v *azureBlobVolume) Index(ctx context.Context, prefix string, writer io.Writer) error {
        params := storage.ListBlobsParameters{
                Prefix:  prefix,
                Include: &storage.IncludeBlobDataset{Metadata: true},
        }
        for page := 1; ; page++ {
+               err := ctx.Err()
+               if err != nil {
+                       return err
+               }
                resp, err := v.listBlobs(page, params)
                if err != nil {
                        return err
@@ -463,11 +419,11 @@ func (v *AzureBlobVolume) IndexTo(prefix string, writer io.Writer) error {
 }
 
 // call v.container.ListBlobs, retrying if needed.
-func (v *AzureBlobVolume) listBlobs(page int, params storage.ListBlobsParameters) (resp storage.BlobListResponse, err error) {
+func (v *azureBlobVolume) listBlobs(page int, params storage.ListBlobsParameters) (resp storage.BlobListResponse, err error) {
        for i := 0; i < v.ListBlobsMaxAttempts; i++ {
                resp, err = v.container.ListBlobs(params)
                err = v.translateError(err)
-               if err == VolumeBusyError {
+               if err == errVolumeUnavailable {
                        v.logger.Printf("ListBlobs: will retry page %d in %s after error: %s", page, v.ListBlobsRetryDelay, err)
                        time.Sleep(time.Duration(v.ListBlobsRetryDelay))
                        continue
@@ -479,11 +435,7 @@ func (v *AzureBlobVolume) listBlobs(page int, params storage.ListBlobsParameters
 }
 
 // Trash a Keep block.
-func (v *AzureBlobVolume) Trash(loc string) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-
+func (v *azureBlobVolume) BlockTrash(loc string) error {
        // Ideally we would use If-Unmodified-Since, but that
        // particular condition seems to be ignored by Azure. Instead,
        // we get the Etag before checking Mtime, and use If-Match to
@@ -514,11 +466,11 @@ func (v *AzureBlobVolume) Trash(loc string) error {
        })
 }
 
-// Untrash a Keep block.
-// Delete the expires_at metadata attribute
-func (v *AzureBlobVolume) Untrash(loc string) error {
+// BlockUntrash deletes the expires_at metadata attribute for the
+// specified block blob.
+func (v *azureBlobVolume) BlockUntrash(hash string) error {
        // if expires_at does not exist, return NotFoundError
-       metadata, err := v.container.GetBlobMetadata(loc)
+       metadata, err := v.container.GetBlobMetadata(hash)
        if err != nil {
                return v.translateError(err)
        }
@@ -528,33 +480,19 @@ func (v *AzureBlobVolume) Untrash(loc string) error {
 
        // reset expires_at metadata attribute
        metadata["expires_at"] = ""
-       err = v.container.SetBlobMetadata(loc, metadata, nil)
+       err = v.container.SetBlobMetadata(hash, metadata, nil)
        return v.translateError(err)
 }
 
-// Status returns a VolumeStatus struct with placeholder data.
-func (v *AzureBlobVolume) Status() *VolumeStatus {
-       return &VolumeStatus{
-               DeviceNum: 1,
-               BytesFree: BlockSize * 1000,
-               BytesUsed: 1,
-       }
-}
-
-// String returns a volume label, including the container name.
-func (v *AzureBlobVolume) String() string {
-       return fmt.Sprintf("azure-storage-container:%+q", v.ContainerName)
-}
-
 // If possible, translate an Azure SDK error to a recognizable error
 // like os.ErrNotExist.
-func (v *AzureBlobVolume) translateError(err error) error {
+func (v *azureBlobVolume) translateError(err error) error {
        switch {
        case err == nil:
                return err
        case strings.Contains(err.Error(), "StatusCode=503"):
                // "storage: service returned error: StatusCode=503, ErrorCode=ServerBusy, ErrorMessage=The server is busy" (See #14804)
-               return VolumeBusyError
+               return errVolumeUnavailable
        case strings.Contains(err.Error(), "Not Found"):
                // "storage: service returned without a response body (404 Not Found)"
                return os.ErrNotExist
@@ -568,17 +506,13 @@ func (v *AzureBlobVolume) translateError(err error) error {
 
 var keepBlockRegexp = regexp.MustCompile(`^[0-9a-f]{32}$`)
 
-func (v *AzureBlobVolume) isKeepBlock(s string) bool {
+func (v *azureBlobVolume) isKeepBlock(s string) bool {
        return keepBlockRegexp.MatchString(s)
 }
 
 // EmptyTrash looks for trashed blocks that exceeded BlobTrashLifetime
 // and deletes them from the volume.
-func (v *AzureBlobVolume) EmptyTrash() {
-       if v.cluster.Collections.BlobDeleteConcurrency < 1 {
-               return
-       }
-
+func (v *azureBlobVolume) EmptyTrash() {
        var bytesDeleted, bytesInTrash int64
        var blocksDeleted, blocksInTrash int64
 
@@ -642,11 +576,11 @@ func (v *AzureBlobVolume) EmptyTrash() {
        close(todo)
        wg.Wait()
 
-       v.logger.Printf("EmptyTrash stats for %v: Deleted %v bytes in %v blocks. Remaining in trash: %v bytes in %v blocks.", v.String(), bytesDeleted, blocksDeleted, bytesInTrash-bytesDeleted, blocksInTrash-blocksDeleted)
+       v.logger.Printf("EmptyTrash stats for %v: Deleted %v bytes in %v blocks. Remaining in trash: %v bytes in %v blocks.", v.DeviceID(), bytesDeleted, blocksDeleted, bytesInTrash-bytesDeleted, blocksInTrash-blocksDeleted)
 }
 
 // InternalStats returns bucket I/O and API call counters.
-func (v *AzureBlobVolume) InternalStats() interface{} {
+func (v *azureBlobVolume) InternalStats() interface{} {
        return &v.container.stats
 }
 
@@ -713,7 +647,7 @@ func (c *azureContainer) GetBlob(bname string) (io.ReadCloser, error) {
        b := c.ctr.GetBlobReference(bname)
        rdr, err := b.Get(nil)
        c.stats.TickErr(err)
-       return NewCountingReader(rdr, c.stats.TickInBytes), err
+       return newCountingReader(rdr, c.stats.TickInBytes), err
 }
 
 func (c *azureContainer) GetBlobRange(bname string, start, end int, opts *storage.GetBlobOptions) (io.ReadCloser, error) {
@@ -728,7 +662,7 @@ func (c *azureContainer) GetBlobRange(bname string, start, end int, opts *storag
                GetBlobOptions: opts,
        })
        c.stats.TickErr(err)
-       return NewCountingReader(rdr, c.stats.TickInBytes), err
+       return newCountingReader(rdr, c.stats.TickInBytes), err
 }
 
 // If we give it an io.Reader that doesn't also have a Len() int
@@ -749,7 +683,7 @@ func (c *azureContainer) CreateBlockBlobFromReader(bname string, size int, rdr i
        c.stats.Tick(&c.stats.Ops, &c.stats.CreateOps)
        if size != 0 {
                rdr = &readerWithAzureLen{
-                       Reader: NewCountingReader(rdr, c.stats.TickOutBytes),
+                       Reader: newCountingReader(rdr, c.stats.TickOutBytes),
                        len:    size,
                }
        }
index 48d58ee9bfc454e5b2972e6d36867a578c29e6bb..b8acd980a1c6a57c8537ded3f2d4a90bb51ef331 100644 (file)
@@ -87,7 +87,7 @@ func (h *azStubHandler) TouchWithDate(container, hash string, t time.Time) {
        blob.Mtime = t
 }
 
-func (h *azStubHandler) PutRaw(container, hash string, data []byte) {
+func (h *azStubHandler) BlockWriteRaw(container, hash string, data []byte) {
        h.Lock()
        defer h.Unlock()
        h.blobs[container+"|"+hash] = &azBlob{
@@ -221,7 +221,7 @@ func (h *azStubHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
                rw.WriteHeader(http.StatusCreated)
        case r.Method == "PUT" && r.Form.Get("comp") == "metadata":
                // "Set Metadata Headers" API. We don't bother
-               // stubbing "Get Metadata Headers": AzureBlobVolume
+               // stubbing "Get Metadata Headers": azureBlobVolume
                // sets metadata headers only as a way to bump Etag
                // and Last-Modified.
                if !blobExists {
@@ -365,14 +365,14 @@ func (d *azStubDialer) Dial(network, address string) (net.Conn, error) {
        return d.Dialer.Dial(network, address)
 }
 
-type TestableAzureBlobVolume struct {
-       *AzureBlobVolume
+type testableAzureBlobVolume struct {
+       *azureBlobVolume
        azHandler *azStubHandler
        azStub    *httptest.Server
        t         TB
 }
 
-func (s *StubbedAzureBlobSuite) newTestableAzureBlobVolume(t TB, cluster *arvados.Cluster, volume arvados.Volume, metrics *volumeMetricsVecs) *TestableAzureBlobVolume {
+func (s *stubbedAzureBlobSuite) newTestableAzureBlobVolume(t TB, params newVolumeParams) *testableAzureBlobVolume {
        azHandler := newAzStubHandler(t.(*check.C))
        azStub := httptest.NewServer(azHandler)
 
@@ -396,7 +396,7 @@ func (s *StubbedAzureBlobSuite) newTestableAzureBlobVolume(t TB, cluster *arvado
        azClient.Sender = &singleSender{}
 
        bs := azClient.GetBlobService()
-       v := &AzureBlobVolume{
+       v := &azureBlobVolume{
                ContainerName:        container,
                WriteRaceInterval:    arvados.Duration(time.Millisecond),
                WriteRacePollTime:    arvados.Duration(time.Nanosecond),
@@ -404,65 +404,72 @@ func (s *StubbedAzureBlobSuite) newTestableAzureBlobVolume(t TB, cluster *arvado
                ListBlobsRetryDelay:  arvados.Duration(time.Millisecond),
                azClient:             azClient,
                container:            &azureContainer{ctr: bs.GetContainerReference(container)},
-               cluster:              cluster,
-               volume:               volume,
+               cluster:              params.Cluster,
+               volume:               params.ConfigVolume,
                logger:               ctxlog.TestLogger(t),
-               metrics:              metrics,
+               metrics:              params.MetricsVecs,
+               bufferPool:           params.BufferPool,
        }
        if err = v.check(); err != nil {
                t.Fatal(err)
        }
 
-       return &TestableAzureBlobVolume{
-               AzureBlobVolume: v,
+       return &testableAzureBlobVolume{
+               azureBlobVolume: v,
                azHandler:       azHandler,
                azStub:          azStub,
                t:               t,
        }
 }
 
-var _ = check.Suite(&StubbedAzureBlobSuite{})
+var _ = check.Suite(&stubbedAzureBlobSuite{})
 
-type StubbedAzureBlobSuite struct {
+type stubbedAzureBlobSuite struct {
        origHTTPTransport http.RoundTripper
 }
 
-func (s *StubbedAzureBlobSuite) SetUpTest(c *check.C) {
+func (s *stubbedAzureBlobSuite) SetUpSuite(c *check.C) {
        s.origHTTPTransport = http.DefaultTransport
        http.DefaultTransport = &http.Transport{
                Dial: (&azStubDialer{logger: ctxlog.TestLogger(c)}).Dial,
        }
 }
 
-func (s *StubbedAzureBlobSuite) TearDownTest(c *check.C) {
+func (s *stubbedAzureBlobSuite) TearDownSuite(c *check.C) {
        http.DefaultTransport = s.origHTTPTransport
 }
 
-func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeWithGeneric(c *check.C) {
-       DoGenericVolumeTests(c, false, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               return s.newTestableAzureBlobVolume(t, cluster, volume, metrics)
+func (s *stubbedAzureBlobSuite) TestAzureBlobVolumeWithGeneric(c *check.C) {
+       DoGenericVolumeTests(c, false, func(t TB, params newVolumeParams) TestableVolume {
+               return s.newTestableAzureBlobVolume(t, params)
        })
 }
 
-func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeConcurrentRanges(c *check.C) {
+func (s *stubbedAzureBlobSuite) TestAzureBlobVolumeConcurrentRanges(c *check.C) {
        // Test (BlockSize mod azureMaxGetBytes)==0 and !=0 cases
-       for _, b := range []int{2 << 22, 2<<22 - 1} {
-               DoGenericVolumeTests(c, false, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-                       v := s.newTestableAzureBlobVolume(t, cluster, volume, metrics)
+       for _, b := range []int{2<<22 - 1, 2<<22 - 1} {
+               c.Logf("=== MaxGetBytes=%d", b)
+               DoGenericVolumeTests(c, false, func(t TB, params newVolumeParams) TestableVolume {
+                       v := s.newTestableAzureBlobVolume(t, params)
                        v.MaxGetBytes = b
                        return v
                })
        }
 }
 
-func (s *StubbedAzureBlobSuite) TestReadonlyAzureBlobVolumeWithGeneric(c *check.C) {
-       DoGenericVolumeTests(c, false, func(c TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               return s.newTestableAzureBlobVolume(c, cluster, volume, metrics)
+func (s *stubbedAzureBlobSuite) TestReadonlyAzureBlobVolumeWithGeneric(c *check.C) {
+       DoGenericVolumeTests(c, false, func(c TB, params newVolumeParams) TestableVolume {
+               return s.newTestableAzureBlobVolume(c, params)
        })
 }
 
-func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeRangeFenceposts(c *check.C) {
-       v := s.newTestableAzureBlobVolume(c, testCluster(c), arvados.Volume{Replication: 3}, newVolumeMetricsVecs(prometheus.NewRegistry()))
+func (s *stubbedAzureBlobSuite) TestAzureBlobVolumeRangeFenceposts(c *check.C) {
+       v := s.newTestableAzureBlobVolume(c, newVolumeParams{
+               Cluster:      testCluster(c),
+               ConfigVolume: arvados.Volume{Replication: 3},
+               MetricsVecs:  newVolumeMetricsVecs(prometheus.NewRegistry()),
+               BufferPool:   newBufferPool(ctxlog.TestLogger(c), 8, prometheus.NewRegistry()),
+       })
        defer v.Teardown()
 
        for _, size := range []int{
@@ -478,27 +485,30 @@ func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeRangeFenceposts(c *check.C) {
                        data[i] = byte((i + 7) & 0xff)
                }
                hash := fmt.Sprintf("%x", md5.Sum(data))
-               err := v.Put(context.Background(), hash, data)
+               err := v.BlockWrite(context.Background(), hash, data)
                if err != nil {
                        c.Error(err)
                }
-               gotData := make([]byte, len(data))
-               gotLen, err := v.Get(context.Background(), hash, gotData)
+               gotData := &brbuffer{}
+               err = v.BlockRead(context.Background(), hash, gotData)
                if err != nil {
                        c.Error(err)
                }
-               gotHash := fmt.Sprintf("%x", md5.Sum(gotData))
-               if gotLen != size {
-                       c.Errorf("length mismatch: got %d != %d", gotLen, size)
-               }
+               gotHash := fmt.Sprintf("%x", md5.Sum(gotData.Bytes()))
+               c.Check(gotData.Len(), check.Equals, size)
                if gotHash != hash {
                        c.Errorf("hash mismatch: got %s != %s", gotHash, hash)
                }
        }
 }
 
-func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeCreateBlobRace(c *check.C) {
-       v := s.newTestableAzureBlobVolume(c, testCluster(c), arvados.Volume{Replication: 3}, newVolumeMetricsVecs(prometheus.NewRegistry()))
+func (s *stubbedAzureBlobSuite) TestAzureBlobVolumeCreateBlobRace(c *check.C) {
+       v := s.newTestableAzureBlobVolume(c, newVolumeParams{
+               Cluster:      testCluster(c),
+               ConfigVolume: arvados.Volume{Replication: 3},
+               MetricsVecs:  newVolumeMetricsVecs(prometheus.NewRegistry()),
+               BufferPool:   newBufferPool(ctxlog.TestLogger(c), 8, prometheus.NewRegistry()),
+       })
        defer v.Teardown()
 
        var wg sync.WaitGroup
@@ -508,42 +518,46 @@ func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeCreateBlobRace(c *check.C) {
        wg.Add(1)
        go func() {
                defer wg.Done()
-               err := v.Put(context.Background(), TestHash, TestBlock)
+               err := v.BlockWrite(context.Background(), TestHash, TestBlock)
                if err != nil {
                        c.Error(err)
                }
        }()
-       continuePut := make(chan struct{})
-       // Wait for the stub's Put to create the empty blob
-       v.azHandler.race <- continuePut
+       continueBlockWrite := make(chan struct{})
+       // Wait for the stub's BlockWrite to create the empty blob
+       v.azHandler.race <- continueBlockWrite
        wg.Add(1)
        go func() {
                defer wg.Done()
-               buf := make([]byte, len(TestBlock))
-               _, err := v.Get(context.Background(), TestHash, buf)
+               err := v.BlockRead(context.Background(), TestHash, brdiscard)
                if err != nil {
                        c.Error(err)
                }
        }()
-       // Wait for the stub's Get to get the empty blob
+       // Wait for the stub's BlockRead to get the empty blob
        close(v.azHandler.race)
-       // Allow stub's Put to continue, so the real data is ready
-       // when the volume's Get retries
-       <-continuePut
-       // Wait for Get() and Put() to finish
+       // Allow stub's BlockWrite to continue, so the real data is ready
+       // when the volume's BlockRead retries
+       <-continueBlockWrite
+       // Wait for BlockRead() and BlockWrite() to finish
        wg.Wait()
 }
 
-func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeCreateBlobRaceDeadline(c *check.C) {
-       v := s.newTestableAzureBlobVolume(c, testCluster(c), arvados.Volume{Replication: 3}, newVolumeMetricsVecs(prometheus.NewRegistry()))
-       v.AzureBlobVolume.WriteRaceInterval.Set("2s")
-       v.AzureBlobVolume.WriteRacePollTime.Set("5ms")
+func (s *stubbedAzureBlobSuite) TestAzureBlobVolumeCreateBlobRaceDeadline(c *check.C) {
+       v := s.newTestableAzureBlobVolume(c, newVolumeParams{
+               Cluster:      testCluster(c),
+               ConfigVolume: arvados.Volume{Replication: 3},
+               MetricsVecs:  newVolumeMetricsVecs(prometheus.NewRegistry()),
+               BufferPool:   newBufferPool(ctxlog.TestLogger(c), 8, prometheus.NewRegistry()),
+       })
+       v.azureBlobVolume.WriteRaceInterval.Set("2s")
+       v.azureBlobVolume.WriteRacePollTime.Set("5ms")
        defer v.Teardown()
 
-       v.PutRaw(TestHash, nil)
+       v.BlockWriteRaw(TestHash, nil)
 
        buf := new(bytes.Buffer)
-       v.IndexTo("", buf)
+       v.Index(context.Background(), "", buf)
        if buf.Len() != 0 {
                c.Errorf("Index %+q should be empty", buf.Bytes())
        }
@@ -553,52 +567,47 @@ func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeCreateBlobRaceDeadline(c *che
        allDone := make(chan struct{})
        go func() {
                defer close(allDone)
-               buf := make([]byte, BlockSize)
-               n, err := v.Get(context.Background(), TestHash, buf)
+               buf := &brbuffer{}
+               err := v.BlockRead(context.Background(), TestHash, buf)
                if err != nil {
                        c.Error(err)
                        return
                }
-               if n != 0 {
-                       c.Errorf("Got %+q, expected empty buf", buf[:n])
-               }
+               c.Check(buf.String(), check.Equals, "")
        }()
        select {
        case <-allDone:
        case <-time.After(time.Second):
-               c.Error("Get should have stopped waiting for race when block was 2s old")
+               c.Error("BlockRead should have stopped waiting for race when block was 2s old")
        }
 
        buf.Reset()
-       v.IndexTo("", buf)
+       v.Index(context.Background(), "", buf)
        if !bytes.HasPrefix(buf.Bytes(), []byte(TestHash+"+0")) {
                c.Errorf("Index %+q should have %+q", buf.Bytes(), TestHash+"+0")
        }
 }
 
-func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeContextCancelGet(c *check.C) {
-       s.testAzureBlobVolumeContextCancel(c, func(ctx context.Context, v *TestableAzureBlobVolume) error {
-               v.PutRaw(TestHash, TestBlock)
-               _, err := v.Get(ctx, TestHash, make([]byte, BlockSize))
-               return err
+func (s *stubbedAzureBlobSuite) TestAzureBlobVolumeContextCancelBlockRead(c *check.C) {
+       s.testAzureBlobVolumeContextCancel(c, func(ctx context.Context, v *testableAzureBlobVolume) error {
+               v.BlockWriteRaw(TestHash, TestBlock)
+               return v.BlockRead(ctx, TestHash, brdiscard)
        })
 }
 
-func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeContextCancelPut(c *check.C) {
-       s.testAzureBlobVolumeContextCancel(c, func(ctx context.Context, v *TestableAzureBlobVolume) error {
-               return v.Put(ctx, TestHash, make([]byte, BlockSize))
+func (s *stubbedAzureBlobSuite) TestAzureBlobVolumeContextCancelBlockWrite(c *check.C) {
+       s.testAzureBlobVolumeContextCancel(c, func(ctx context.Context, v *testableAzureBlobVolume) error {
+               return v.BlockWrite(ctx, TestHash, make([]byte, BlockSize))
        })
 }
 
-func (s *StubbedAzureBlobSuite) TestAzureBlobVolumeContextCancelCompare(c *check.C) {
-       s.testAzureBlobVolumeContextCancel(c, func(ctx context.Context, v *TestableAzureBlobVolume) error {
-               v.PutRaw(TestHash, TestBlock)
-               return v.Compare(ctx, TestHash, TestBlock2)
+func (s *stubbedAzureBlobSuite) testAzureBlobVolumeContextCancel(c *check.C, testFunc func(context.Context, *testableAzureBlobVolume) error) {
+       v := s.newTestableAzureBlobVolume(c, newVolumeParams{
+               Cluster:      testCluster(c),
+               ConfigVolume: arvados.Volume{Replication: 3},
+               MetricsVecs:  newVolumeMetricsVecs(prometheus.NewRegistry()),
+               BufferPool:   newBufferPool(ctxlog.TestLogger(c), 8, prometheus.NewRegistry()),
        })
-}
-
-func (s *StubbedAzureBlobSuite) testAzureBlobVolumeContextCancel(c *check.C, testFunc func(context.Context, *TestableAzureBlobVolume) error) {
-       v := s.newTestableAzureBlobVolume(c, testCluster(c), arvados.Volume{Replication: 3}, newVolumeMetricsVecs(prometheus.NewRegistry()))
        defer v.Teardown()
        v.azHandler.race = make(chan chan struct{})
 
@@ -633,8 +642,13 @@ func (s *StubbedAzureBlobSuite) testAzureBlobVolumeContextCancel(c *check.C, tes
        }()
 }
 
-func (s *StubbedAzureBlobSuite) TestStats(c *check.C) {
-       volume := s.newTestableAzureBlobVolume(c, testCluster(c), arvados.Volume{Replication: 3}, newVolumeMetricsVecs(prometheus.NewRegistry()))
+func (s *stubbedAzureBlobSuite) TestStats(c *check.C) {
+       volume := s.newTestableAzureBlobVolume(c, newVolumeParams{
+               Cluster:      testCluster(c),
+               ConfigVolume: arvados.Volume{Replication: 3},
+               MetricsVecs:  newVolumeMetricsVecs(prometheus.NewRegistry()),
+               BufferPool:   newBufferPool(ctxlog.TestLogger(c), 8, prometheus.NewRegistry()),
+       })
        defer volume.Teardown()
 
        stats := func() string {
@@ -647,38 +661,38 @@ func (s *StubbedAzureBlobSuite) TestStats(c *check.C) {
        c.Check(stats(), check.Matches, `.*"Errors":0,.*`)
 
        loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       _, err := volume.Get(context.Background(), loc, make([]byte, 3))
+       err := volume.BlockRead(context.Background(), loc, brdiscard)
        c.Check(err, check.NotNil)
        c.Check(stats(), check.Matches, `.*"Ops":[^0],.*`)
        c.Check(stats(), check.Matches, `.*"Errors":[^0],.*`)
        c.Check(stats(), check.Matches, `.*"storage\.AzureStorageServiceError 404 \(404 Not Found\)":[^0].*`)
        c.Check(stats(), check.Matches, `.*"InBytes":0,.*`)
 
-       err = volume.Put(context.Background(), loc, []byte("foo"))
+       err = volume.BlockWrite(context.Background(), loc, []byte("foo"))
        c.Check(err, check.IsNil)
        c.Check(stats(), check.Matches, `.*"OutBytes":3,.*`)
        c.Check(stats(), check.Matches, `.*"CreateOps":1,.*`)
 
-       _, err = volume.Get(context.Background(), loc, make([]byte, 3))
+       err = volume.BlockRead(context.Background(), loc, brdiscard)
        c.Check(err, check.IsNil)
-       _, err = volume.Get(context.Background(), loc, make([]byte, 3))
+       err = volume.BlockRead(context.Background(), loc, brdiscard)
        c.Check(err, check.IsNil)
        c.Check(stats(), check.Matches, `.*"InBytes":6,.*`)
 }
 
-func (v *TestableAzureBlobVolume) PutRaw(locator string, data []byte) {
-       v.azHandler.PutRaw(v.ContainerName, locator, data)
+func (v *testableAzureBlobVolume) BlockWriteRaw(locator string, data []byte) {
+       v.azHandler.BlockWriteRaw(v.ContainerName, locator, data)
 }
 
-func (v *TestableAzureBlobVolume) TouchWithDate(locator string, lastPut time.Time) {
-       v.azHandler.TouchWithDate(v.ContainerName, locator, lastPut)
+func (v *testableAzureBlobVolume) TouchWithDate(locator string, lastBlockWrite time.Time) {
+       v.azHandler.TouchWithDate(v.ContainerName, locator, lastBlockWrite)
 }
 
-func (v *TestableAzureBlobVolume) Teardown() {
+func (v *testableAzureBlobVolume) Teardown() {
        v.azStub.Close()
 }
 
-func (v *TestableAzureBlobVolume) ReadWriteOperationLabelValues() (r, w string) {
+func (v *testableAzureBlobVolume) ReadWriteOperationLabelValues() (r, w string) {
        return "get", "create"
 }
 
index b4cc5d38e1670034212816bd96b95cdc838a2cfb..811715b191c7384cfe904744221cbffcd3ac1b46 100644 (file)
@@ -5,13 +5,17 @@
 package keepstore
 
 import (
+       "context"
        "sync"
        "sync/atomic"
        "time"
 
+       "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
 )
 
+var bufferPoolBlockSize = BlockSize // modified by tests
+
 type bufferPool struct {
        log logrus.FieldLogger
        // limiter has a "true" placeholder for each in-use buffer.
@@ -22,17 +26,67 @@ type bufferPool struct {
        sync.Pool
 }
 
-func newBufferPool(log logrus.FieldLogger, count int, bufSize int) *bufferPool {
+func newBufferPool(log logrus.FieldLogger, count int, reg *prometheus.Registry) *bufferPool {
        p := bufferPool{log: log}
        p.Pool.New = func() interface{} {
-               atomic.AddUint64(&p.allocated, uint64(bufSize))
-               return make([]byte, bufSize)
+               atomic.AddUint64(&p.allocated, uint64(bufferPoolBlockSize))
+               return make([]byte, bufferPoolBlockSize)
        }
        p.limiter = make(chan bool, count)
+       if reg != nil {
+               reg.MustRegister(prometheus.NewGaugeFunc(
+                       prometheus.GaugeOpts{
+                               Namespace: "arvados",
+                               Subsystem: "keepstore",
+                               Name:      "bufferpool_allocated_bytes",
+                               Help:      "Number of bytes allocated to buffers",
+                       },
+                       func() float64 { return float64(p.Alloc()) },
+               ))
+               reg.MustRegister(prometheus.NewGaugeFunc(
+                       prometheus.GaugeOpts{
+                               Namespace: "arvados",
+                               Subsystem: "keepstore",
+                               Name:      "bufferpool_max_buffers",
+                               Help:      "Maximum number of buffers allowed",
+                       },
+                       func() float64 { return float64(p.Cap()) },
+               ))
+               reg.MustRegister(prometheus.NewGaugeFunc(
+                       prometheus.GaugeOpts{
+                               Namespace: "arvados",
+                               Subsystem: "keepstore",
+                               Name:      "bufferpool_inuse_buffers",
+                               Help:      "Number of buffers in use",
+                       },
+                       func() float64 { return float64(p.Len()) },
+               ))
+       }
        return &p
 }
 
-func (p *bufferPool) Get(size int) []byte {
+// GetContext gets a buffer from the pool -- but gives up and returns
+// ctx.Err() if ctx ends before a buffer is available.
+func (p *bufferPool) GetContext(ctx context.Context) ([]byte, error) {
+       bufReady := make(chan []byte)
+       go func() {
+               bufReady <- p.Get()
+       }()
+       select {
+       case buf := <-bufReady:
+               return buf, nil
+       case <-ctx.Done():
+               go func() {
+                       // Even if closeNotifier happened first, we
+                       // need to keep waiting for our buf so we can
+                       // return it to the pool.
+                       p.Put(<-bufReady)
+               }()
+               return nil, ctx.Err()
+       }
+}
+
+func (p *bufferPool) Get() []byte {
        select {
        case p.limiter <- true:
        default:
@@ -42,14 +96,14 @@ func (p *bufferPool) Get(size int) []byte {
                p.log.Printf("waited %v for a buffer", time.Since(t0))
        }
        buf := p.Pool.Get().([]byte)
-       if cap(buf) < size {
-               p.log.Fatalf("bufferPool Get(size=%d) but max=%d", size, cap(buf))
+       if len(buf) < bufferPoolBlockSize {
+               p.log.Fatalf("bufferPoolBlockSize=%d but cap(buf)=%d", bufferPoolBlockSize, len(buf))
        }
-       return buf[:size]
+       return buf
 }
 
 func (p *bufferPool) Put(buf []byte) {
-       p.Pool.Put(buf)
+       p.Pool.Put(buf[:cap(buf)])
        <-p.limiter
 }
 
index 13e1cb4f332ba180857aef747b3086e9251466ee..8ecc833228f5b07218a6b0ea8ba58f2f44616c7e 100644 (file)
@@ -5,55 +5,54 @@
 package keepstore
 
 import (
-       "context"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "github.com/prometheus/client_golang/prometheus"
        . "gopkg.in/check.v1"
 )
 
 var _ = Suite(&BufferPoolSuite{})
 
+var bufferPoolTestSize = 10
+
 type BufferPoolSuite struct{}
 
-// Initialize a default-sized buffer pool for the benefit of test
-// suites that don't run main().
-func init() {
-       bufs = newBufferPool(ctxlog.FromContext(context.Background()), 12, BlockSize)
+func (s *BufferPoolSuite) SetUpTest(c *C) {
+       bufferPoolBlockSize = bufferPoolTestSize
 }
 
-// Restore sane default after bufferpool's own tests
 func (s *BufferPoolSuite) TearDownTest(c *C) {
-       bufs = newBufferPool(ctxlog.FromContext(context.Background()), 12, BlockSize)
+       bufferPoolBlockSize = BlockSize
 }
 
 func (s *BufferPoolSuite) TestBufferPoolBufSize(c *C) {
-       bufs := newBufferPool(ctxlog.TestLogger(c), 2, 10)
-       b1 := bufs.Get(1)
-       bufs.Get(2)
+       bufs := newBufferPool(ctxlog.TestLogger(c), 2, prometheus.NewRegistry())
+       b1 := bufs.Get()
+       bufs.Get()
        bufs.Put(b1)
-       b3 := bufs.Get(3)
-       c.Check(len(b3), Equals, 3)
+       b3 := bufs.Get()
+       c.Check(len(b3), Equals, bufferPoolTestSize)
 }
 
 func (s *BufferPoolSuite) TestBufferPoolUnderLimit(c *C) {
-       bufs := newBufferPool(ctxlog.TestLogger(c), 3, 10)
-       b1 := bufs.Get(10)
-       bufs.Get(10)
+       bufs := newBufferPool(ctxlog.TestLogger(c), 3, prometheus.NewRegistry())
+       b1 := bufs.Get()
+       bufs.Get()
        testBufferPoolRace(c, bufs, b1, "Get")
 }
 
 func (s *BufferPoolSuite) TestBufferPoolAtLimit(c *C) {
-       bufs := newBufferPool(ctxlog.TestLogger(c), 2, 10)
-       b1 := bufs.Get(10)
-       bufs.Get(10)
+       bufs := newBufferPool(ctxlog.TestLogger(c), 2, prometheus.NewRegistry())
+       b1 := bufs.Get()
+       bufs.Get()
        testBufferPoolRace(c, bufs, b1, "Put")
 }
 
 func testBufferPoolRace(c *C, bufs *bufferPool, unused []byte, expectWin string) {
        race := make(chan string)
        go func() {
-               bufs.Get(10)
+               bufs.Get()
                time.Sleep(time.Millisecond)
                race <- "Get"
        }()
@@ -68,9 +67,9 @@ func testBufferPoolRace(c *C, bufs *bufferPool, unused []byte, expectWin string)
 }
 
 func (s *BufferPoolSuite) TestBufferPoolReuse(c *C) {
-       bufs := newBufferPool(ctxlog.TestLogger(c), 2, 10)
-       bufs.Get(10)
-       last := bufs.Get(10)
+       bufs := newBufferPool(ctxlog.TestLogger(c), 2, prometheus.NewRegistry())
+       bufs.Get()
+       last := bufs.Get()
        // The buffer pool is allowed to throw away unused buffers
        // (e.g., during sync.Pool's garbage collection hook, in the
        // the current implementation). However, if unused buffers are
@@ -81,7 +80,7 @@ func (s *BufferPoolSuite) TestBufferPoolReuse(c *C) {
        reuses := 0
        for i := 0; i < allocs; i++ {
                bufs.Put(last)
-               next := bufs.Get(10)
+               next := bufs.Get()
                copy(last, []byte("last"))
                copy(next, []byte("next"))
                if last[0] == 'n' {
diff --git a/services/keepstore/collision.go b/services/keepstore/collision.go
deleted file mode 100644 (file)
index 16f2d09..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "bytes"
-       "context"
-       "crypto/md5"
-       "fmt"
-       "io"
-)
-
-// Compute the MD5 digest of a data block (consisting of buf1 + buf2 +
-// all bytes readable from rdr). If all data is read successfully,
-// return DiskHashError or CollisionError depending on whether it
-// matches expectMD5. If an error occurs while reading, return that
-// error.
-//
-// "content has expected MD5" is called a collision because this
-// function is used in cases where we have another block in hand with
-// the given MD5 but different content.
-func collisionOrCorrupt(expectMD5 string, buf1, buf2 []byte, rdr io.Reader) error {
-       outcome := make(chan error)
-       data := make(chan []byte, 1)
-       go func() {
-               h := md5.New()
-               for b := range data {
-                       h.Write(b)
-               }
-               if fmt.Sprintf("%x", h.Sum(nil)) == expectMD5 {
-                       outcome <- CollisionError
-               } else {
-                       outcome <- DiskHashError
-               }
-       }()
-       data <- buf1
-       if buf2 != nil {
-               data <- buf2
-       }
-       var err error
-       for rdr != nil && err == nil {
-               buf := make([]byte, 1<<18)
-               var n int
-               n, err = rdr.Read(buf)
-               data <- buf[:n]
-       }
-       close(data)
-       if rdr != nil && err != io.EOF {
-               <-outcome
-               return err
-       }
-       return <-outcome
-}
-
-func compareReaderWithBuf(ctx context.Context, rdr io.Reader, expect []byte, hash string) error {
-       bufLen := 1 << 20
-       if bufLen > len(expect) && len(expect) > 0 {
-               // No need for bufLen to be longer than
-               // expect, except that len(buf)==0 would
-               // prevent us from handling empty readers the
-               // same way as non-empty readers: reading 0
-               // bytes at a time never reaches EOF.
-               bufLen = len(expect)
-       }
-       buf := make([]byte, bufLen)
-       cmp := expect
-
-       // Loop invariants: all data read so far matched what
-       // we expected, and the first N bytes of cmp are
-       // expected to equal the next N bytes read from
-       // rdr.
-       for {
-               ready := make(chan bool)
-               var n int
-               var err error
-               go func() {
-                       n, err = rdr.Read(buf)
-                       close(ready)
-               }()
-               select {
-               case <-ready:
-               case <-ctx.Done():
-                       return ctx.Err()
-               }
-               if n > len(cmp) || bytes.Compare(cmp[:n], buf[:n]) != 0 {
-                       return collisionOrCorrupt(hash, expect[:len(expect)-len(cmp)], buf[:n], rdr)
-               }
-               cmp = cmp[n:]
-               if err == io.EOF {
-                       if len(cmp) != 0 {
-                               return collisionOrCorrupt(hash, expect[:len(expect)-len(cmp)], nil, nil)
-                       }
-                       return nil
-               } else if err != nil {
-                       return err
-               }
-       }
-}
diff --git a/services/keepstore/collision_test.go b/services/keepstore/collision_test.go
deleted file mode 100644 (file)
index aa8f0cb..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "bytes"
-       "testing/iotest"
-
-       check "gopkg.in/check.v1"
-)
-
-var _ = check.Suite(&CollisionSuite{})
-
-type CollisionSuite struct{}
-
-func (s *CollisionSuite) TestCollisionOrCorrupt(c *check.C) {
-       fooMD5 := "acbd18db4cc2f85cedef654fccc4a4d8"
-
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f'}, []byte{'o'}, bytes.NewBufferString("o")),
-               check.Equals, CollisionError)
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f'}, nil, bytes.NewBufferString("oo")),
-               check.Equals, CollisionError)
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f'}, []byte{'o', 'o'}, nil),
-               check.Equals, CollisionError)
-       c.Check(collisionOrCorrupt(fooMD5, nil, []byte{}, bytes.NewBufferString("foo")),
-               check.Equals, CollisionError)
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f', 'o', 'o'}, nil, bytes.NewBufferString("")),
-               check.Equals, CollisionError)
-       c.Check(collisionOrCorrupt(fooMD5, nil, nil, iotest.NewReadLogger("foo: ", iotest.DataErrReader(iotest.OneByteReader(bytes.NewBufferString("foo"))))),
-               check.Equals, CollisionError)
-
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f', 'o', 'o'}, nil, bytes.NewBufferString("bar")),
-               check.Equals, DiskHashError)
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f', 'o'}, nil, nil),
-               check.Equals, DiskHashError)
-       c.Check(collisionOrCorrupt(fooMD5, []byte{}, nil, bytes.NewBufferString("")),
-               check.Equals, DiskHashError)
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f', 'O'}, nil, bytes.NewBufferString("o")),
-               check.Equals, DiskHashError)
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f', 'O', 'o'}, nil, nil),
-               check.Equals, DiskHashError)
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f', 'o'}, []byte{'O'}, nil),
-               check.Equals, DiskHashError)
-       c.Check(collisionOrCorrupt(fooMD5, []byte{'f', 'o'}, nil, bytes.NewBufferString("O")),
-               check.Equals, DiskHashError)
-
-       c.Check(collisionOrCorrupt(fooMD5, []byte{}, nil, iotest.TimeoutReader(iotest.OneByteReader(bytes.NewBufferString("foo")))),
-               check.Equals, iotest.ErrTimeout)
-}
index 555f16dfe1f290edbd1797437efd3842ad29dd4e..9f14c13384e4f6045fc70d103ba3ada8a808c6b2 100644 (file)
@@ -7,210 +7,27 @@ package keepstore
 import (
        "context"
        "errors"
-       "flag"
-       "fmt"
-       "io"
-       "math/rand"
-       "net/http"
-       "os"
-       "sync"
 
-       "git.arvados.org/arvados.git/lib/cmd"
-       "git.arvados.org/arvados.git/lib/config"
        "git.arvados.org/arvados.git/lib/service"
        "git.arvados.org/arvados.git/sdk/go/arvados"
-       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
-       "git.arvados.org/arvados.git/sdk/go/ctxlog"
-       "git.arvados.org/arvados.git/sdk/go/keepclient"
        "github.com/prometheus/client_golang/prometheus"
-       "github.com/sirupsen/logrus"
 )
 
 var (
        Command = service.Command(arvados.ServiceNameKeepstore, newHandlerOrErrorHandler)
 )
 
-func runCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
-       args, ok, code := convertKeepstoreFlagsToServiceFlags(prog, args, ctxlog.FromContext(context.Background()), stderr)
-       if !ok {
-               return code
-       }
-       return Command.RunCommand(prog, args, stdin, stdout, stderr)
-}
-
-// Parse keepstore command line flags, and return equivalent
-// service.Command flags. If the second return value ("ok") is false,
-// the program should exit, and the third return value is a suitable
-// exit code.
-func convertKeepstoreFlagsToServiceFlags(prog string, args []string, lgr logrus.FieldLogger, stderr io.Writer) ([]string, bool, int) {
-       flags := flag.NewFlagSet("", flag.ContinueOnError)
-       flags.String("listen", "", "Services.Keepstore.InternalURLs")
-       flags.Int("max-buffers", 0, "API.MaxKeepBlobBuffers")
-       flags.Int("max-requests", 0, "API.MaxConcurrentRequests")
-       flags.Bool("never-delete", false, "Collections.BlobTrash")
-       flags.Bool("enforce-permissions", false, "Collections.BlobSigning")
-       flags.String("permission-key-file", "", "Collections.BlobSigningKey")
-       flags.String("blob-signing-key-file", "", "Collections.BlobSigningKey")
-       flags.String("data-manager-token-file", "", "SystemRootToken")
-       flags.Int("permission-ttl", 0, "Collections.BlobSigningTTL")
-       flags.Int("blob-signature-ttl", 0, "Collections.BlobSigningTTL")
-       flags.String("trash-lifetime", "", "Collections.BlobTrashLifetime")
-       flags.Bool("serialize", false, "Volumes.*.DriverParameters.Serialize")
-       flags.Bool("readonly", false, "Volumes.*.ReadOnly")
-       flags.String("pid", "", "-")
-       flags.String("trash-check-interval", "", "Collections.BlobTrashCheckInterval")
-
-       flags.String("azure-storage-container-volume", "", "Volumes.*.Driver")
-       flags.String("azure-storage-account-name", "", "Volumes.*.DriverParameters.StorageAccountName")
-       flags.String("azure-storage-account-key-file", "", "Volumes.*.DriverParameters.StorageAccountKey")
-       flags.String("azure-storage-replication", "", "Volumes.*.Replication")
-       flags.String("azure-max-get-bytes", "", "Volumes.*.DriverParameters.MaxDataReadSize")
-
-       flags.String("s3-bucket-volume", "", "Volumes.*.DriverParameters.Bucket")
-       flags.String("s3-region", "", "Volumes.*.DriverParameters.Region")
-       flags.String("s3-endpoint", "", "Volumes.*.DriverParameters.Endpoint")
-       flags.String("s3-access-key-file", "", "Volumes.*.DriverParameters.AccessKeyID")
-       flags.String("s3-secret-key-file", "", "Volumes.*.DriverParameters.SecretAccessKey")
-       flags.String("s3-race-window", "", "Volumes.*.DriverParameters.RaceWindow")
-       flags.String("s3-replication", "", "Volumes.*.Replication")
-       flags.String("s3-unsafe-delete", "", "Volumes.*.DriverParameters.UnsafeDelete")
-
-       flags.String("volume", "", "Volumes")
-
-       flags.Bool("version", false, "")
-       flags.String("config", "", "")
-       flags.String("legacy-keepstore-config", "", "")
-
-       if ok, code := cmd.ParseFlags(flags, prog, args, "", stderr); !ok {
-               return nil, false, code
-       }
-
-       args = nil
-       ok := true
-       flags.Visit(func(f *flag.Flag) {
-               if f.Name == "config" || f.Name == "legacy-keepstore-config" || f.Name == "version" {
-                       args = append(args, "-"+f.Name, f.Value.String())
-               } else if f.Usage == "-" {
-                       ok = false
-                       lgr.Errorf("command line flag -%s is no longer supported", f.Name)
-               } else {
-                       ok = false
-                       lgr.Errorf("command line flag -%s is no longer supported -- use Clusters.*.%s in cluster config file instead", f.Name, f.Usage)
-               }
-       })
-       if !ok {
-               return nil, false, 2
-       }
-
-       flags = flag.NewFlagSet("", flag.ContinueOnError)
-       loader := config.NewLoader(nil, lgr)
-       loader.SetupFlags(flags)
-       return loader.MungeLegacyConfigArgs(lgr, args, "-legacy-keepstore-config"), true, 0
-}
-
-type handler struct {
-       http.Handler
-       Cluster *arvados.Cluster
-       Logger  logrus.FieldLogger
-
-       pullq      *WorkQueue
-       trashq     *WorkQueue
-       volmgr     *RRVolumeManager
-       keepClient *keepclient.KeepClient
-
-       err       error
-       setupOnce sync.Once
-}
-
-func (h *handler) CheckHealth() error {
-       return h.err
-}
-
-func (h *handler) Done() <-chan struct{} {
-       return nil
-}
-
 func newHandlerOrErrorHandler(ctx context.Context, cluster *arvados.Cluster, token string, reg *prometheus.Registry) service.Handler {
-       var h handler
        serviceURL, ok := service.URLFromContext(ctx)
        if !ok {
                return service.ErrorHandler(ctx, cluster, errors.New("BUG: no URL from service.URLFromContext"))
        }
-       err := h.setup(ctx, cluster, token, reg, serviceURL)
+       ks, err := newKeepstore(ctx, cluster, token, reg, serviceURL)
        if err != nil {
                return service.ErrorHandler(ctx, cluster, err)
        }
-       return &h
-}
-
-func (h *handler) setup(ctx context.Context, cluster *arvados.Cluster, token string, reg *prometheus.Registry, serviceURL arvados.URL) error {
-       h.Cluster = cluster
-       h.Logger = ctxlog.FromContext(ctx)
-       if h.Cluster.API.MaxKeepBlobBuffers <= 0 {
-               return fmt.Errorf("API.MaxKeepBlobBuffers must be greater than zero")
-       }
-       bufs = newBufferPool(h.Logger, h.Cluster.API.MaxKeepBlobBuffers, BlockSize)
-
-       if h.Cluster.API.MaxConcurrentRequests > 0 && h.Cluster.API.MaxConcurrentRequests < h.Cluster.API.MaxKeepBlobBuffers {
-               h.Logger.Warnf("Possible configuration mistake: not useful to set API.MaxKeepBlobBuffers (%d) higher than API.MaxConcurrentRequests (%d)", h.Cluster.API.MaxKeepBlobBuffers, h.Cluster.API.MaxConcurrentRequests)
-       }
-
-       if h.Cluster.Collections.BlobSigningKey != "" {
-       } else if h.Cluster.Collections.BlobSigning {
-               return errors.New("cannot enable Collections.BlobSigning with no Collections.BlobSigningKey")
-       } else {
-               h.Logger.Warn("Running without a blob signing key. Block locators returned by this server will not be signed, and will be rejected by a server that enforces permissions. To fix this, configure Collections.BlobSigning and Collections.BlobSigningKey.")
-       }
-
-       if len(h.Cluster.Volumes) == 0 {
-               return errors.New("no volumes configured")
-       }
-
-       h.Logger.Printf("keepstore %s starting, pid %d", cmd.Version.String(), os.Getpid())
-
-       // Start a round-robin VolumeManager with the configured volumes.
-       vm, err := makeRRVolumeManager(h.Logger, h.Cluster, serviceURL, newVolumeMetricsVecs(reg))
-       if err != nil {
-               return err
-       }
-       if len(vm.readables) == 0 {
-               return fmt.Errorf("no volumes configured for %s", serviceURL)
-       }
-       h.volmgr = vm
-
-       // Initialize the pullq and workers
-       h.pullq = NewWorkQueue()
-       for i := 0; i < 1 || i < h.Cluster.Collections.BlobReplicateConcurrency; i++ {
-               go h.runPullWorker(h.pullq)
-       }
-
-       // Initialize the trashq and workers
-       h.trashq = NewWorkQueue()
-       for i := 0; i < 1 || i < h.Cluster.Collections.BlobTrashConcurrency; i++ {
-               go RunTrashWorker(h.volmgr, h.Logger, h.Cluster, h.trashq)
-       }
-
-       // Set up routes and metrics
-       h.Handler = MakeRESTRouter(ctx, cluster, reg, vm, h.pullq, h.trashq)
-
-       // Initialize keepclient for pull workers
-       c, err := arvados.NewClientFromConfig(cluster)
-       if err != nil {
-               return err
-       }
-       ac, err := arvadosclient.New(c)
-       if err != nil {
-               return err
-       }
-       h.keepClient = &keepclient.KeepClient{
-               Arvados:       ac,
-               Want_replicas: 1,
-       }
-       h.keepClient.Arvados.ApiToken = fmt.Sprintf("%x", rand.Int63())
-
-       if d := h.Cluster.Collections.BlobTrashCheckInterval.Duration(); d > 0 {
-               go emptyTrash(h.volmgr.writables, d)
-       }
-
-       return nil
+       puller := newPuller(ctx, ks, reg)
+       trasher := newTrasher(ctx, ks, reg)
+       _ = newTrashEmptier(ctx, ks, reg)
+       return newRouter(ks, puller, trasher)
 }
index bbfae52f69e1feb2a4b109e7eedd1a16da984023..942c01a7798f97e70b0998994baba5b0bb2d1705 100644 (file)
@@ -23,7 +23,7 @@ func (*CommandSuite) TestLegacyConfigPath(c *check.C) {
        defer os.Remove(tmp.Name())
        tmp.Write([]byte("Listen: \"1.2.3.4.5:invalidport\"\n"))
        tmp.Close()
-       exited := runCommand("keepstore", []string{"-config", tmp.Name()}, &stdin, &stdout, &stderr)
+       exited := Command.RunCommand("keepstore", []string{"-config", tmp.Name()}, &stdin, &stdout, &stderr)
        c.Check(exited, check.Equals, 1)
        c.Check(stderr.String(), check.Matches, `(?ms).*unable to migrate Listen value.*`)
 }
index 700ca19dec958cbf978bc875e2b1f8e71f24bf49..51434a803e681d1fc2b5d9276dcb9ddbcddd6cca 100644 (file)
@@ -8,21 +8,21 @@ import (
        "io"
 )
 
-func NewCountingWriter(w io.Writer, f func(uint64)) io.WriteCloser {
+func newCountingWriter(w io.Writer, f func(uint64)) io.WriteCloser {
        return &countingReadWriter{
                writer:  w,
                counter: f,
        }
 }
 
-func NewCountingReader(r io.Reader, f func(uint64)) io.ReadCloser {
+func newCountingReader(r io.Reader, f func(uint64)) io.ReadCloser {
        return &countingReadWriter{
                reader:  r,
                counter: f,
        }
 }
 
-func NewCountingReaderAtSeeker(r readerAtSeeker, f func(uint64)) *countingReaderAtSeeker {
+func newCountingReaderAtSeeker(r readerAtSeeker, f func(uint64)) *countingReaderAtSeeker {
        return &countingReaderAtSeeker{readerAtSeeker: r, counter: f}
 }
 
diff --git a/services/keepstore/gocheck_test.go b/services/keepstore/gocheck_test.go
deleted file mode 100644 (file)
index 90076db..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "gopkg.in/check.v1"
-       "testing"
-)
-
-func TestGocheck(t *testing.T) {
-       check.TestingT(t)
-}
diff --git a/services/keepstore/handler_test.go b/services/keepstore/handler_test.go
deleted file mode 100644 (file)
index d545bde..0000000
+++ /dev/null
@@ -1,1411 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-// Tests for Keep HTTP handlers:
-//
-//     GetBlockHandler
-//     PutBlockHandler
-//     IndexHandler
-//
-// The HTTP handlers are responsible for enforcing permission policy,
-// so these tests must exercise all possible permission permutations.
-
-package keepstore
-
-import (
-       "bytes"
-       "context"
-       "encoding/json"
-       "fmt"
-       "net/http"
-       "net/http/httptest"
-       "os"
-       "sort"
-       "strings"
-       "sync/atomic"
-       "time"
-
-       "git.arvados.org/arvados.git/lib/config"
-       "git.arvados.org/arvados.git/sdk/go/arvados"
-       "git.arvados.org/arvados.git/sdk/go/arvadostest"
-       "git.arvados.org/arvados.git/sdk/go/ctxlog"
-       "github.com/prometheus/client_golang/prometheus"
-       check "gopkg.in/check.v1"
-)
-
-var testServiceURL = func() arvados.URL {
-       return arvados.URL{Host: "localhost:12345", Scheme: "http"}
-}()
-
-func testCluster(t TB) *arvados.Cluster {
-       cfg, err := config.NewLoader(bytes.NewBufferString("Clusters: {zzzzz: {}}"), ctxlog.TestLogger(t)).Load()
-       if err != nil {
-               t.Fatal(err)
-       }
-       cluster, err := cfg.GetCluster("")
-       if err != nil {
-               t.Fatal(err)
-       }
-       cluster.SystemRootToken = arvadostest.SystemRootToken
-       cluster.ManagementToken = arvadostest.ManagementToken
-       cluster.Collections.BlobSigning = false
-       return cluster
-}
-
-var _ = check.Suite(&HandlerSuite{})
-
-type HandlerSuite struct {
-       cluster *arvados.Cluster
-       handler *handler
-}
-
-func (s *HandlerSuite) SetUpTest(c *check.C) {
-       s.cluster = testCluster(c)
-       s.cluster.Volumes = map[string]arvados.Volume{
-               "zzzzz-nyw5e-000000000000000": {Replication: 1, Driver: "mock"},
-               "zzzzz-nyw5e-111111111111111": {Replication: 1, Driver: "mock"},
-       }
-       s.handler = &handler{}
-}
-
-// A RequestTester represents the parameters for an HTTP request to
-// be issued on behalf of a unit test.
-type RequestTester struct {
-       uri            string
-       apiToken       string
-       method         string
-       requestBody    []byte
-       storageClasses string
-}
-
-// Test GetBlockHandler on the following situations:
-//   - permissions off, unauthenticated request, unsigned locator
-//   - permissions on, authenticated request, signed locator
-//   - permissions on, authenticated request, unsigned locator
-//   - permissions on, unauthenticated request, signed locator
-//   - permissions on, authenticated request, expired locator
-//   - permissions on, authenticated request, signed locator, transient error from backend
-//
-func (s *HandlerSuite) TestGetHandler(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       vols := s.handler.volmgr.AllWritable()
-       err := vols[0].Put(context.Background(), TestHash, TestBlock)
-       c.Check(err, check.IsNil)
-
-       // Create locators for testing.
-       // Turn on permission settings so we can generate signed locators.
-       s.cluster.Collections.BlobSigning = true
-       s.cluster.Collections.BlobSigningKey = knownKey
-       s.cluster.Collections.BlobSigningTTL.Set("5m")
-
-       var (
-               unsignedLocator  = "/" + TestHash
-               validTimestamp   = time.Now().Add(s.cluster.Collections.BlobSigningTTL.Duration())
-               expiredTimestamp = time.Now().Add(-time.Hour)
-               signedLocator    = "/" + SignLocator(s.cluster, TestHash, knownToken, validTimestamp)
-               expiredLocator   = "/" + SignLocator(s.cluster, TestHash, knownToken, expiredTimestamp)
-       )
-
-       // -----------------
-       // Test unauthenticated request with permissions off.
-       s.cluster.Collections.BlobSigning = false
-
-       // Unauthenticated request, unsigned locator
-       // => OK
-       response := IssueRequest(s.handler,
-               &RequestTester{
-                       method: "GET",
-                       uri:    unsignedLocator,
-               })
-       ExpectStatusCode(c,
-               "Unauthenticated request, unsigned locator", http.StatusOK, response)
-       ExpectBody(c,
-               "Unauthenticated request, unsigned locator",
-               string(TestBlock),
-               response)
-
-       receivedLen := response.Header().Get("Content-Length")
-       expectedLen := fmt.Sprintf("%d", len(TestBlock))
-       if receivedLen != expectedLen {
-               c.Errorf("expected Content-Length %s, got %s", expectedLen, receivedLen)
-       }
-
-       // ----------------
-       // Permissions: on.
-       s.cluster.Collections.BlobSigning = true
-
-       // Authenticated request, signed locator
-       // => OK
-       response = IssueRequest(s.handler, &RequestTester{
-               method:   "GET",
-               uri:      signedLocator,
-               apiToken: knownToken,
-       })
-       ExpectStatusCode(c,
-               "Authenticated request, signed locator", http.StatusOK, response)
-       ExpectBody(c,
-               "Authenticated request, signed locator", string(TestBlock), response)
-
-       receivedLen = response.Header().Get("Content-Length")
-       expectedLen = fmt.Sprintf("%d", len(TestBlock))
-       if receivedLen != expectedLen {
-               c.Errorf("expected Content-Length %s, got %s", expectedLen, receivedLen)
-       }
-
-       // Authenticated request, unsigned locator
-       // => PermissionError
-       response = IssueRequest(s.handler, &RequestTester{
-               method:   "GET",
-               uri:      unsignedLocator,
-               apiToken: knownToken,
-       })
-       ExpectStatusCode(c, "unsigned locator", PermissionError.HTTPCode, response)
-
-       // Unauthenticated request, signed locator
-       // => PermissionError
-       response = IssueRequest(s.handler, &RequestTester{
-               method: "GET",
-               uri:    signedLocator,
-       })
-       ExpectStatusCode(c,
-               "Unauthenticated request, signed locator",
-               PermissionError.HTTPCode, response)
-
-       // Authenticated request, expired locator
-       // => ExpiredError
-       response = IssueRequest(s.handler, &RequestTester{
-               method:   "GET",
-               uri:      expiredLocator,
-               apiToken: knownToken,
-       })
-       ExpectStatusCode(c,
-               "Authenticated request, expired locator",
-               ExpiredError.HTTPCode, response)
-
-       // Authenticated request, signed locator
-       // => 503 Server busy (transient error)
-
-       // Set up the block owning volume to respond with errors
-       vols[0].Volume.(*MockVolume).Bad = true
-       vols[0].Volume.(*MockVolume).BadVolumeError = VolumeBusyError
-       response = IssueRequest(s.handler, &RequestTester{
-               method:   "GET",
-               uri:      signedLocator,
-               apiToken: knownToken,
-       })
-       // A transient error from one volume while the other doesn't find the block
-       // should make the service return a 503 so that clients can retry.
-       ExpectStatusCode(c,
-               "Volume backend busy",
-               503, response)
-}
-
-// Test PutBlockHandler on the following situations:
-//   - no server key
-//   - with server key, authenticated request, unsigned locator
-//   - with server key, unauthenticated request, unsigned locator
-//
-func (s *HandlerSuite) TestPutHandler(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       // --------------
-       // No server key.
-
-       s.cluster.Collections.BlobSigningKey = ""
-
-       // Unauthenticated request, no server key
-       // => OK (unsigned response)
-       unsignedLocator := "/" + TestHash
-       response := IssueRequest(s.handler,
-               &RequestTester{
-                       method:      "PUT",
-                       uri:         unsignedLocator,
-                       requestBody: TestBlock,
-               })
-
-       ExpectStatusCode(c,
-               "Unauthenticated request, no server key", http.StatusOK, response)
-       ExpectBody(c,
-               "Unauthenticated request, no server key",
-               TestHashPutResp, response)
-
-       // ------------------
-       // With a server key.
-
-       s.cluster.Collections.BlobSigningKey = knownKey
-       s.cluster.Collections.BlobSigningTTL.Set("5m")
-
-       // When a permission key is available, the locator returned
-       // from an authenticated PUT request will be signed.
-
-       // Authenticated PUT, signed locator
-       // => OK (signed response)
-       response = IssueRequest(s.handler,
-               &RequestTester{
-                       method:      "PUT",
-                       uri:         unsignedLocator,
-                       requestBody: TestBlock,
-                       apiToken:    knownToken,
-               })
-
-       ExpectStatusCode(c,
-               "Authenticated PUT, signed locator, with server key",
-               http.StatusOK, response)
-       responseLocator := strings.TrimSpace(response.Body.String())
-       if VerifySignature(s.cluster, responseLocator, knownToken) != nil {
-               c.Errorf("Authenticated PUT, signed locator, with server key:\n"+
-                       "response '%s' does not contain a valid signature",
-                       responseLocator)
-       }
-
-       // Unauthenticated PUT, unsigned locator
-       // => OK
-       response = IssueRequest(s.handler,
-               &RequestTester{
-                       method:      "PUT",
-                       uri:         unsignedLocator,
-                       requestBody: TestBlock,
-               })
-
-       ExpectStatusCode(c,
-               "Unauthenticated PUT, unsigned locator, with server key",
-               http.StatusOK, response)
-       ExpectBody(c,
-               "Unauthenticated PUT, unsigned locator, with server key",
-               TestHashPutResp, response)
-}
-
-func (s *HandlerSuite) TestPutAndDeleteSkipReadonlyVolumes(c *check.C) {
-       s.cluster.Volumes["zzzzz-nyw5e-000000000000000"] = arvados.Volume{Driver: "mock", ReadOnly: true}
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       s.cluster.SystemRootToken = "fake-data-manager-token"
-       IssueRequest(s.handler,
-               &RequestTester{
-                       method:      "PUT",
-                       uri:         "/" + TestHash,
-                       requestBody: TestBlock,
-               })
-
-       s.cluster.Collections.BlobTrash = true
-       IssueRequest(s.handler,
-               &RequestTester{
-                       method:      "DELETE",
-                       uri:         "/" + TestHash,
-                       requestBody: TestBlock,
-                       apiToken:    s.cluster.SystemRootToken,
-               })
-       type expect struct {
-               volid     string
-               method    string
-               callcount int
-       }
-       for _, e := range []expect{
-               {"zzzzz-nyw5e-000000000000000", "Get", 0},
-               {"zzzzz-nyw5e-000000000000000", "Compare", 0},
-               {"zzzzz-nyw5e-000000000000000", "Touch", 0},
-               {"zzzzz-nyw5e-000000000000000", "Put", 0},
-               {"zzzzz-nyw5e-000000000000000", "Delete", 0},
-               {"zzzzz-nyw5e-111111111111111", "Get", 0},
-               {"zzzzz-nyw5e-111111111111111", "Compare", 1},
-               {"zzzzz-nyw5e-111111111111111", "Touch", 1},
-               {"zzzzz-nyw5e-111111111111111", "Put", 1},
-               {"zzzzz-nyw5e-111111111111111", "Delete", 1},
-       } {
-               if calls := s.handler.volmgr.mountMap[e.volid].Volume.(*MockVolume).CallCount(e.method); calls != e.callcount {
-                       c.Errorf("Got %d %s() on vol %s, expect %d", calls, e.method, e.volid, e.callcount)
-               }
-       }
-}
-
-func (s *HandlerSuite) TestReadsOrderedByStorageClassPriority(c *check.C) {
-       s.cluster.Volumes = map[string]arvados.Volume{
-               "zzzzz-nyw5e-111111111111111": {
-                       Driver:         "mock",
-                       Replication:    1,
-                       StorageClasses: map[string]bool{"class1": true}},
-               "zzzzz-nyw5e-222222222222222": {
-                       Driver:         "mock",
-                       Replication:    1,
-                       StorageClasses: map[string]bool{"class2": true, "class3": true}},
-       }
-
-       for _, trial := range []struct {
-               priority1 int // priority of class1, thus vol1
-               priority2 int // priority of class2
-               priority3 int // priority of class3 (vol2 priority will be max(priority2, priority3))
-               get1      int // expected number of "get" ops on vol1
-               get2      int // expected number of "get" ops on vol2
-       }{
-               {100, 50, 50, 1, 0},   // class1 has higher priority => try vol1 first, no need to try vol2
-               {100, 100, 100, 1, 0}, // same priority, vol1 is first lexicographically => try vol1 first and succeed
-               {66, 99, 33, 1, 1},    // class2 has higher priority => try vol2 first, then try vol1
-               {66, 33, 99, 1, 1},    // class3 has highest priority => vol2 has highest => try vol2 first, then try vol1
-       } {
-               c.Logf("%+v", trial)
-               s.cluster.StorageClasses = map[string]arvados.StorageClassConfig{
-                       "class1": {Priority: trial.priority1},
-                       "class2": {Priority: trial.priority2},
-                       "class3": {Priority: trial.priority3},
-               }
-               c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-               IssueRequest(s.handler,
-                       &RequestTester{
-                               method:         "PUT",
-                               uri:            "/" + TestHash,
-                               requestBody:    TestBlock,
-                               storageClasses: "class1",
-                       })
-               IssueRequest(s.handler,
-                       &RequestTester{
-                               method: "GET",
-                               uri:    "/" + TestHash,
-                       })
-               c.Check(s.handler.volmgr.mountMap["zzzzz-nyw5e-111111111111111"].Volume.(*MockVolume).CallCount("Get"), check.Equals, trial.get1)
-               c.Check(s.handler.volmgr.mountMap["zzzzz-nyw5e-222222222222222"].Volume.(*MockVolume).CallCount("Get"), check.Equals, trial.get2)
-       }
-}
-
-func (s *HandlerSuite) TestPutWithNoWritableVolumes(c *check.C) {
-       s.cluster.Volumes = map[string]arvados.Volume{
-               "zzzzz-nyw5e-111111111111111": {
-                       Driver:         "mock",
-                       Replication:    1,
-                       ReadOnly:       true,
-                       StorageClasses: map[string]bool{"class1": true}},
-       }
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-       resp := IssueRequest(s.handler,
-               &RequestTester{
-                       method:         "PUT",
-                       uri:            "/" + TestHash,
-                       requestBody:    TestBlock,
-                       storageClasses: "class1",
-               })
-       c.Check(resp.Code, check.Equals, FullError.HTTPCode)
-       c.Check(s.handler.volmgr.mountMap["zzzzz-nyw5e-111111111111111"].Volume.(*MockVolume).CallCount("Put"), check.Equals, 0)
-}
-
-func (s *HandlerSuite) TestConcurrentWritesToMultipleStorageClasses(c *check.C) {
-       s.cluster.Volumes = map[string]arvados.Volume{
-               "zzzzz-nyw5e-111111111111111": {
-                       Driver:         "mock",
-                       Replication:    1,
-                       StorageClasses: map[string]bool{"class1": true}},
-               "zzzzz-nyw5e-121212121212121": {
-                       Driver:         "mock",
-                       Replication:    1,
-                       StorageClasses: map[string]bool{"class1": true, "class2": true}},
-               "zzzzz-nyw5e-222222222222222": {
-                       Driver:         "mock",
-                       Replication:    1,
-                       StorageClasses: map[string]bool{"class2": true}},
-       }
-
-       for _, trial := range []struct {
-               setCounter uint32 // value to stuff vm.counter, to control offset
-               classes    string // desired classes
-               put111     int    // expected number of "put" ops on 11111... after 2x put reqs
-               put121     int    // expected number of "put" ops on 12121...
-               put222     int    // expected number of "put" ops on 22222...
-               cmp111     int    // expected number of "compare" ops on 11111... after 2x put reqs
-               cmp121     int    // expected number of "compare" ops on 12121...
-               cmp222     int    // expected number of "compare" ops on 22222...
-       }{
-               {0, "class1",
-                       1, 0, 0,
-                       2, 1, 0}, // first put compares on all vols with class2; second put succeeds after checking 121
-               {0, "class2",
-                       0, 1, 0,
-                       0, 2, 1}, // first put compares on all vols with class2; second put succeeds after checking 121
-               {0, "class1,class2",
-                       1, 1, 0,
-                       2, 2, 1}, // first put compares on all vols; second put succeeds after checking 111 and 121
-               {1, "class1,class2",
-                       0, 1, 0, // vm.counter offset is 1 so the first volume attempted is 121
-                       2, 2, 1}, // first put compares on all vols; second put succeeds after checking 111 and 121
-               {0, "class1,class2,class404",
-                       1, 1, 0,
-                       2, 2, 1}, // first put compares on all vols; second put doesn't compare on 222 because it already satisfied class2 on 121
-       } {
-               c.Logf("%+v", trial)
-               s.cluster.StorageClasses = map[string]arvados.StorageClassConfig{
-                       "class1": {},
-                       "class2": {},
-                       "class3": {},
-               }
-               c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-               atomic.StoreUint32(&s.handler.volmgr.counter, trial.setCounter)
-               for i := 0; i < 2; i++ {
-                       IssueRequest(s.handler,
-                               &RequestTester{
-                                       method:         "PUT",
-                                       uri:            "/" + TestHash,
-                                       requestBody:    TestBlock,
-                                       storageClasses: trial.classes,
-                               })
-               }
-               c.Check(s.handler.volmgr.mountMap["zzzzz-nyw5e-111111111111111"].Volume.(*MockVolume).CallCount("Put"), check.Equals, trial.put111)
-               c.Check(s.handler.volmgr.mountMap["zzzzz-nyw5e-121212121212121"].Volume.(*MockVolume).CallCount("Put"), check.Equals, trial.put121)
-               c.Check(s.handler.volmgr.mountMap["zzzzz-nyw5e-222222222222222"].Volume.(*MockVolume).CallCount("Put"), check.Equals, trial.put222)
-               c.Check(s.handler.volmgr.mountMap["zzzzz-nyw5e-111111111111111"].Volume.(*MockVolume).CallCount("Compare"), check.Equals, trial.cmp111)
-               c.Check(s.handler.volmgr.mountMap["zzzzz-nyw5e-121212121212121"].Volume.(*MockVolume).CallCount("Compare"), check.Equals, trial.cmp121)
-               c.Check(s.handler.volmgr.mountMap["zzzzz-nyw5e-222222222222222"].Volume.(*MockVolume).CallCount("Compare"), check.Equals, trial.cmp222)
-       }
-}
-
-// Test TOUCH requests.
-func (s *HandlerSuite) TestTouchHandler(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-       vols := s.handler.volmgr.AllWritable()
-       vols[0].Put(context.Background(), TestHash, TestBlock)
-       vols[0].Volume.(*MockVolume).TouchWithDate(TestHash, time.Now().Add(-time.Hour))
-       afterPut := time.Now()
-       t, err := vols[0].Mtime(TestHash)
-       c.Assert(err, check.IsNil)
-       c.Assert(t.Before(afterPut), check.Equals, true)
-
-       ExpectStatusCode(c,
-               "touch with no credentials",
-               http.StatusUnauthorized,
-               IssueRequest(s.handler, &RequestTester{
-                       method: "TOUCH",
-                       uri:    "/" + TestHash,
-               }))
-
-       ExpectStatusCode(c,
-               "touch with non-root credentials",
-               http.StatusUnauthorized,
-               IssueRequest(s.handler, &RequestTester{
-                       method:   "TOUCH",
-                       uri:      "/" + TestHash,
-                       apiToken: arvadostest.ActiveTokenV2,
-               }))
-
-       ExpectStatusCode(c,
-               "touch non-existent block",
-               http.StatusNotFound,
-               IssueRequest(s.handler, &RequestTester{
-                       method:   "TOUCH",
-                       uri:      "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
-                       apiToken: s.cluster.SystemRootToken,
-               }))
-
-       beforeTouch := time.Now()
-       ExpectStatusCode(c,
-               "touch block",
-               http.StatusOK,
-               IssueRequest(s.handler, &RequestTester{
-                       method:   "TOUCH",
-                       uri:      "/" + TestHash,
-                       apiToken: s.cluster.SystemRootToken,
-               }))
-       t, err = vols[0].Mtime(TestHash)
-       c.Assert(err, check.IsNil)
-       c.Assert(t.After(beforeTouch), check.Equals, true)
-}
-
-// Test /index requests:
-//   - unauthenticated /index request
-//   - unauthenticated /index/prefix request
-//   - authenticated   /index request        | non-superuser
-//   - authenticated   /index/prefix request | non-superuser
-//   - authenticated   /index request        | superuser
-//   - authenticated   /index/prefix request | superuser
-//
-// The only /index requests that should succeed are those issued by the
-// superuser. They should pass regardless of the value of BlobSigning.
-//
-func (s *HandlerSuite) TestIndexHandler(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       // Include multiple blocks on different volumes, and
-       // some metadata files (which should be omitted from index listings)
-       vols := s.handler.volmgr.AllWritable()
-       vols[0].Put(context.Background(), TestHash, TestBlock)
-       vols[1].Put(context.Background(), TestHash2, TestBlock2)
-       vols[0].Put(context.Background(), TestHash+".meta", []byte("metadata"))
-       vols[1].Put(context.Background(), TestHash2+".meta", []byte("metadata"))
-
-       s.cluster.SystemRootToken = "DATA MANAGER TOKEN"
-
-       unauthenticatedReq := &RequestTester{
-               method: "GET",
-               uri:    "/index",
-       }
-       authenticatedReq := &RequestTester{
-               method:   "GET",
-               uri:      "/index",
-               apiToken: knownToken,
-       }
-       superuserReq := &RequestTester{
-               method:   "GET",
-               uri:      "/index",
-               apiToken: s.cluster.SystemRootToken,
-       }
-       unauthPrefixReq := &RequestTester{
-               method: "GET",
-               uri:    "/index/" + TestHash[0:3],
-       }
-       authPrefixReq := &RequestTester{
-               method:   "GET",
-               uri:      "/index/" + TestHash[0:3],
-               apiToken: knownToken,
-       }
-       superuserPrefixReq := &RequestTester{
-               method:   "GET",
-               uri:      "/index/" + TestHash[0:3],
-               apiToken: s.cluster.SystemRootToken,
-       }
-       superuserNoSuchPrefixReq := &RequestTester{
-               method:   "GET",
-               uri:      "/index/abcd",
-               apiToken: s.cluster.SystemRootToken,
-       }
-       superuserInvalidPrefixReq := &RequestTester{
-               method:   "GET",
-               uri:      "/index/xyz",
-               apiToken: s.cluster.SystemRootToken,
-       }
-
-       // -------------------------------------------------------------
-       // Only the superuser should be allowed to issue /index requests.
-
-       // ---------------------------
-       // BlobSigning enabled
-       // This setting should not affect tests passing.
-       s.cluster.Collections.BlobSigning = true
-
-       // unauthenticated /index request
-       // => UnauthorizedError
-       response := IssueRequest(s.handler, unauthenticatedReq)
-       ExpectStatusCode(c,
-               "permissions on, unauthenticated request",
-               UnauthorizedError.HTTPCode,
-               response)
-
-       // unauthenticated /index/prefix request
-       // => UnauthorizedError
-       response = IssueRequest(s.handler, unauthPrefixReq)
-       ExpectStatusCode(c,
-               "permissions on, unauthenticated /index/prefix request",
-               UnauthorizedError.HTTPCode,
-               response)
-
-       // authenticated /index request, non-superuser
-       // => UnauthorizedError
-       response = IssueRequest(s.handler, authenticatedReq)
-       ExpectStatusCode(c,
-               "permissions on, authenticated request, non-superuser",
-               UnauthorizedError.HTTPCode,
-               response)
-
-       // authenticated /index/prefix request, non-superuser
-       // => UnauthorizedError
-       response = IssueRequest(s.handler, authPrefixReq)
-       ExpectStatusCode(c,
-               "permissions on, authenticated /index/prefix request, non-superuser",
-               UnauthorizedError.HTTPCode,
-               response)
-
-       // superuser /index request
-       // => OK
-       response = IssueRequest(s.handler, superuserReq)
-       ExpectStatusCode(c,
-               "permissions on, superuser request",
-               http.StatusOK,
-               response)
-
-       // ----------------------------
-       // BlobSigning disabled
-       // Valid Request should still pass.
-       s.cluster.Collections.BlobSigning = false
-
-       // superuser /index request
-       // => OK
-       response = IssueRequest(s.handler, superuserReq)
-       ExpectStatusCode(c,
-               "permissions on, superuser request",
-               http.StatusOK,
-               response)
-
-       expected := `^` + TestHash + `\+\d+ \d+\n` +
-               TestHash2 + `\+\d+ \d+\n\n$`
-       c.Check(response.Body.String(), check.Matches, expected, check.Commentf(
-               "permissions on, superuser request"))
-
-       // superuser /index/prefix request
-       // => OK
-       response = IssueRequest(s.handler, superuserPrefixReq)
-       ExpectStatusCode(c,
-               "permissions on, superuser request",
-               http.StatusOK,
-               response)
-
-       expected = `^` + TestHash + `\+\d+ \d+\n\n$`
-       c.Check(response.Body.String(), check.Matches, expected, check.Commentf(
-               "permissions on, superuser /index/prefix request"))
-
-       // superuser /index/{no-such-prefix} request
-       // => OK
-       response = IssueRequest(s.handler, superuserNoSuchPrefixReq)
-       ExpectStatusCode(c,
-               "permissions on, superuser request",
-               http.StatusOK,
-               response)
-
-       if "\n" != response.Body.String() {
-               c.Errorf("Expected empty response for %s. Found %s", superuserNoSuchPrefixReq.uri, response.Body.String())
-       }
-
-       // superuser /index/{invalid-prefix} request
-       // => StatusBadRequest
-       response = IssueRequest(s.handler, superuserInvalidPrefixReq)
-       ExpectStatusCode(c,
-               "permissions on, superuser request",
-               http.StatusBadRequest,
-               response)
-}
-
-// TestDeleteHandler
-//
-// Cases tested:
-//
-//   With no token and with a non-data-manager token:
-//   * Delete existing block
-//     (test for 403 Forbidden, confirm block not deleted)
-//
-//   With data manager token:
-//
-//   * Delete existing block
-//     (test for 200 OK, response counts, confirm block deleted)
-//
-//   * Delete nonexistent block
-//     (test for 200 OK, response counts)
-//
-//   TODO(twp):
-//
-//   * Delete block on read-only and read-write volume
-//     (test for 200 OK, response with copies_deleted=1,
-//     copies_failed=1, confirm block deleted only on r/w volume)
-//
-//   * Delete block on read-only volume only
-//     (test for 200 OK, response with copies_deleted=0, copies_failed=1,
-//     confirm block not deleted)
-//
-func (s *HandlerSuite) TestDeleteHandler(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       vols := s.handler.volmgr.AllWritable()
-       vols[0].Put(context.Background(), TestHash, TestBlock)
-
-       // Explicitly set the BlobSigningTTL to 0 for these
-       // tests, to ensure the MockVolume deletes the blocks
-       // even though they have just been created.
-       s.cluster.Collections.BlobSigningTTL = arvados.Duration(0)
-
-       var userToken = "NOT DATA MANAGER TOKEN"
-       s.cluster.SystemRootToken = "DATA MANAGER TOKEN"
-
-       s.cluster.Collections.BlobTrash = true
-
-       unauthReq := &RequestTester{
-               method: "DELETE",
-               uri:    "/" + TestHash,
-       }
-
-       userReq := &RequestTester{
-               method:   "DELETE",
-               uri:      "/" + TestHash,
-               apiToken: userToken,
-       }
-
-       superuserExistingBlockReq := &RequestTester{
-               method:   "DELETE",
-               uri:      "/" + TestHash,
-               apiToken: s.cluster.SystemRootToken,
-       }
-
-       superuserNonexistentBlockReq := &RequestTester{
-               method:   "DELETE",
-               uri:      "/" + TestHash2,
-               apiToken: s.cluster.SystemRootToken,
-       }
-
-       // Unauthenticated request returns PermissionError.
-       var response *httptest.ResponseRecorder
-       response = IssueRequest(s.handler, unauthReq)
-       ExpectStatusCode(c,
-               "unauthenticated request",
-               PermissionError.HTTPCode,
-               response)
-
-       // Authenticated non-admin request returns PermissionError.
-       response = IssueRequest(s.handler, userReq)
-       ExpectStatusCode(c,
-               "authenticated non-admin request",
-               PermissionError.HTTPCode,
-               response)
-
-       // Authenticated admin request for nonexistent block.
-       type deletecounter struct {
-               Deleted int `json:"copies_deleted"`
-               Failed  int `json:"copies_failed"`
-       }
-       var responseDc, expectedDc deletecounter
-
-       response = IssueRequest(s.handler, superuserNonexistentBlockReq)
-       ExpectStatusCode(c,
-               "data manager request, nonexistent block",
-               http.StatusNotFound,
-               response)
-
-       // Authenticated admin request for existing block while BlobTrash is false.
-       s.cluster.Collections.BlobTrash = false
-       response = IssueRequest(s.handler, superuserExistingBlockReq)
-       ExpectStatusCode(c,
-               "authenticated request, existing block, method disabled",
-               MethodDisabledError.HTTPCode,
-               response)
-       s.cluster.Collections.BlobTrash = true
-
-       // Authenticated admin request for existing block.
-       response = IssueRequest(s.handler, superuserExistingBlockReq)
-       ExpectStatusCode(c,
-               "data manager request, existing block",
-               http.StatusOK,
-               response)
-       // Expect response {"copies_deleted":1,"copies_failed":0}
-       expectedDc = deletecounter{1, 0}
-       json.NewDecoder(response.Body).Decode(&responseDc)
-       if responseDc != expectedDc {
-               c.Errorf("superuserExistingBlockReq\nexpected: %+v\nreceived: %+v",
-                       expectedDc, responseDc)
-       }
-       // Confirm the block has been deleted
-       buf := make([]byte, BlockSize)
-       _, err := vols[0].Get(context.Background(), TestHash, buf)
-       var blockDeleted = os.IsNotExist(err)
-       if !blockDeleted {
-               c.Error("superuserExistingBlockReq: block not deleted")
-       }
-
-       // A DELETE request on a block newer than BlobSigningTTL
-       // should return success but leave the block on the volume.
-       vols[0].Put(context.Background(), TestHash, TestBlock)
-       s.cluster.Collections.BlobSigningTTL = arvados.Duration(time.Hour)
-
-       response = IssueRequest(s.handler, superuserExistingBlockReq)
-       ExpectStatusCode(c,
-               "data manager request, existing block",
-               http.StatusOK,
-               response)
-       // Expect response {"copies_deleted":1,"copies_failed":0}
-       expectedDc = deletecounter{1, 0}
-       json.NewDecoder(response.Body).Decode(&responseDc)
-       if responseDc != expectedDc {
-               c.Errorf("superuserExistingBlockReq\nexpected: %+v\nreceived: %+v",
-                       expectedDc, responseDc)
-       }
-       // Confirm the block has NOT been deleted.
-       _, err = vols[0].Get(context.Background(), TestHash, buf)
-       if err != nil {
-               c.Errorf("testing delete on new block: %s\n", err)
-       }
-}
-
-// TestPullHandler
-//
-// Test handling of the PUT /pull statement.
-//
-// Cases tested: syntactically valid and invalid pull lists, from the
-// data manager and from unprivileged users:
-//
-//   1. Valid pull list from an ordinary user
-//      (expected result: 401 Unauthorized)
-//
-//   2. Invalid pull request from an ordinary user
-//      (expected result: 401 Unauthorized)
-//
-//   3. Valid pull request from the data manager
-//      (expected result: 200 OK with request body "Received 3 pull
-//      requests"
-//
-//   4. Invalid pull request from the data manager
-//      (expected result: 400 Bad Request)
-//
-// Test that in the end, the pull manager received a good pull list with
-// the expected number of requests.
-//
-// TODO(twp): test concurrency: launch 100 goroutines to update the
-// pull list simultaneously.  Make sure that none of them return 400
-// Bad Request and that pullq.GetList() returns a valid list.
-//
-func (s *HandlerSuite) TestPullHandler(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       // Replace the router's pullq -- which the worker goroutines
-       // started by setup() are now receiving from -- with a new
-       // one, so we can see what the handler sends to it.
-       pullq := NewWorkQueue()
-       s.handler.Handler.(*router).pullq = pullq
-
-       var userToken = "USER TOKEN"
-       s.cluster.SystemRootToken = "DATA MANAGER TOKEN"
-
-       goodJSON := []byte(`[
-               {
-                       "locator":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+12345",
-                       "servers":[
-                               "http://server1",
-                               "http://server2"
-                       ]
-               },
-               {
-                       "locator":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb+12345",
-                       "servers":[]
-               },
-               {
-                       "locator":"cccccccccccccccccccccccccccccccc+12345",
-                       "servers":["http://server1"]
-               }
-       ]`)
-
-       badJSON := []byte(`{ "key":"I'm a little teapot" }`)
-
-       type pullTest struct {
-               name         string
-               req          RequestTester
-               responseCode int
-               responseBody string
-       }
-       var testcases = []pullTest{
-               {
-                       "Valid pull list from an ordinary user",
-                       RequestTester{"/pull", userToken, "PUT", goodJSON, ""},
-                       http.StatusUnauthorized,
-                       "Unauthorized\n",
-               },
-               {
-                       "Invalid pull request from an ordinary user",
-                       RequestTester{"/pull", userToken, "PUT", badJSON, ""},
-                       http.StatusUnauthorized,
-                       "Unauthorized\n",
-               },
-               {
-                       "Valid pull request from the data manager",
-                       RequestTester{"/pull", s.cluster.SystemRootToken, "PUT", goodJSON, ""},
-                       http.StatusOK,
-                       "Received 3 pull requests\n",
-               },
-               {
-                       "Invalid pull request from the data manager",
-                       RequestTester{"/pull", s.cluster.SystemRootToken, "PUT", badJSON, ""},
-                       http.StatusBadRequest,
-                       "",
-               },
-       }
-
-       for _, tst := range testcases {
-               response := IssueRequest(s.handler, &tst.req)
-               ExpectStatusCode(c, tst.name, tst.responseCode, response)
-               ExpectBody(c, tst.name, tst.responseBody, response)
-       }
-
-       // The Keep pull manager should have received one good list with 3
-       // requests on it.
-       for i := 0; i < 3; i++ {
-               var item interface{}
-               select {
-               case item = <-pullq.NextItem:
-               case <-time.After(time.Second):
-                       c.Error("timed out")
-               }
-               if _, ok := item.(PullRequest); !ok {
-                       c.Errorf("item %v could not be parsed as a PullRequest", item)
-               }
-       }
-
-       expectChannelEmpty(c, pullq.NextItem)
-}
-
-// TestTrashHandler
-//
-// Test cases:
-//
-// Cases tested: syntactically valid and invalid trash lists, from the
-// data manager and from unprivileged users:
-//
-//   1. Valid trash list from an ordinary user
-//      (expected result: 401 Unauthorized)
-//
-//   2. Invalid trash list from an ordinary user
-//      (expected result: 401 Unauthorized)
-//
-//   3. Valid trash list from the data manager
-//      (expected result: 200 OK with request body "Received 3 trash
-//      requests"
-//
-//   4. Invalid trash list from the data manager
-//      (expected result: 400 Bad Request)
-//
-// Test that in the end, the trash collector received a good list
-// trash list with the expected number of requests.
-//
-// TODO(twp): test concurrency: launch 100 goroutines to update the
-// pull list simultaneously.  Make sure that none of them return 400
-// Bad Request and that replica.Dump() returns a valid list.
-//
-func (s *HandlerSuite) TestTrashHandler(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-       // Replace the router's trashq -- which the worker goroutines
-       // started by setup() are now receiving from -- with a new
-       // one, so we can see what the handler sends to it.
-       trashq := NewWorkQueue()
-       s.handler.Handler.(*router).trashq = trashq
-
-       var userToken = "USER TOKEN"
-       s.cluster.SystemRootToken = "DATA MANAGER TOKEN"
-
-       goodJSON := []byte(`[
-               {
-                       "locator":"block1",
-                       "block_mtime":1409082153
-               },
-               {
-                       "locator":"block2",
-                       "block_mtime":1409082153
-               },
-               {
-                       "locator":"block3",
-                       "block_mtime":1409082153
-               }
-       ]`)
-
-       badJSON := []byte(`I am not a valid JSON string`)
-
-       type trashTest struct {
-               name         string
-               req          RequestTester
-               responseCode int
-               responseBody string
-       }
-
-       var testcases = []trashTest{
-               {
-                       "Valid trash list from an ordinary user",
-                       RequestTester{"/trash", userToken, "PUT", goodJSON, ""},
-                       http.StatusUnauthorized,
-                       "Unauthorized\n",
-               },
-               {
-                       "Invalid trash list from an ordinary user",
-                       RequestTester{"/trash", userToken, "PUT", badJSON, ""},
-                       http.StatusUnauthorized,
-                       "Unauthorized\n",
-               },
-               {
-                       "Valid trash list from the data manager",
-                       RequestTester{"/trash", s.cluster.SystemRootToken, "PUT", goodJSON, ""},
-                       http.StatusOK,
-                       "Received 3 trash requests\n",
-               },
-               {
-                       "Invalid trash list from the data manager",
-                       RequestTester{"/trash", s.cluster.SystemRootToken, "PUT", badJSON, ""},
-                       http.StatusBadRequest,
-                       "",
-               },
-       }
-
-       for _, tst := range testcases {
-               response := IssueRequest(s.handler, &tst.req)
-               ExpectStatusCode(c, tst.name, tst.responseCode, response)
-               ExpectBody(c, tst.name, tst.responseBody, response)
-       }
-
-       // The trash collector should have received one good list with 3
-       // requests on it.
-       for i := 0; i < 3; i++ {
-               item := <-trashq.NextItem
-               if _, ok := item.(TrashRequest); !ok {
-                       c.Errorf("item %v could not be parsed as a TrashRequest", item)
-               }
-       }
-
-       expectChannelEmpty(c, trashq.NextItem)
-}
-
-// ====================
-// Helper functions
-// ====================
-
-// IssueTestRequest executes an HTTP request described by rt, to a
-// REST router.  It returns the HTTP response to the request.
-func IssueRequest(handler http.Handler, rt *RequestTester) *httptest.ResponseRecorder {
-       response := httptest.NewRecorder()
-       body := bytes.NewReader(rt.requestBody)
-       req, _ := http.NewRequest(rt.method, rt.uri, body)
-       if rt.apiToken != "" {
-               req.Header.Set("Authorization", "OAuth2 "+rt.apiToken)
-       }
-       if rt.storageClasses != "" {
-               req.Header.Set("X-Keep-Storage-Classes", rt.storageClasses)
-       }
-       handler.ServeHTTP(response, req)
-       return response
-}
-
-func IssueHealthCheckRequest(handler http.Handler, rt *RequestTester) *httptest.ResponseRecorder {
-       response := httptest.NewRecorder()
-       body := bytes.NewReader(rt.requestBody)
-       req, _ := http.NewRequest(rt.method, rt.uri, body)
-       if rt.apiToken != "" {
-               req.Header.Set("Authorization", "Bearer "+rt.apiToken)
-       }
-       handler.ServeHTTP(response, req)
-       return response
-}
-
-// ExpectStatusCode checks whether a response has the specified status code,
-// and reports a test failure if not.
-func ExpectStatusCode(
-       c *check.C,
-       testname string,
-       expectedStatus int,
-       response *httptest.ResponseRecorder) {
-       c.Check(response.Code, check.Equals, expectedStatus, check.Commentf("%s", testname))
-}
-
-func ExpectBody(
-       c *check.C,
-       testname string,
-       expectedBody string,
-       response *httptest.ResponseRecorder) {
-       if expectedBody != "" && response.Body.String() != expectedBody {
-               c.Errorf("%s: expected response body '%s', got %+v",
-                       testname, expectedBody, response)
-       }
-}
-
-// See #7121
-func (s *HandlerSuite) TestPutNeedsOnlyOneBuffer(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       defer func(orig *bufferPool) {
-               bufs = orig
-       }(bufs)
-       bufs = newBufferPool(ctxlog.TestLogger(c), 1, BlockSize)
-
-       ok := make(chan struct{})
-       go func() {
-               for i := 0; i < 2; i++ {
-                       response := IssueRequest(s.handler,
-                               &RequestTester{
-                                       method:      "PUT",
-                                       uri:         "/" + TestHash,
-                                       requestBody: TestBlock,
-                               })
-                       ExpectStatusCode(c,
-                               "TestPutNeedsOnlyOneBuffer", http.StatusOK, response)
-               }
-               ok <- struct{}{}
-       }()
-
-       select {
-       case <-ok:
-       case <-time.After(time.Second):
-               c.Fatal("PUT deadlocks with MaxKeepBlobBuffers==1")
-       }
-}
-
-// Invoke the PutBlockHandler a bunch of times to test for bufferpool resource
-// leak.
-func (s *HandlerSuite) TestPutHandlerNoBufferleak(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       ok := make(chan bool)
-       go func() {
-               for i := 0; i < s.cluster.API.MaxKeepBlobBuffers+1; i++ {
-                       // Unauthenticated request, no server key
-                       // => OK (unsigned response)
-                       unsignedLocator := "/" + TestHash
-                       response := IssueRequest(s.handler,
-                               &RequestTester{
-                                       method:      "PUT",
-                                       uri:         unsignedLocator,
-                                       requestBody: TestBlock,
-                               })
-                       ExpectStatusCode(c,
-                               "TestPutHandlerBufferleak", http.StatusOK, response)
-                       ExpectBody(c,
-                               "TestPutHandlerBufferleak",
-                               TestHashPutResp, response)
-               }
-               ok <- true
-       }()
-       select {
-       case <-time.After(20 * time.Second):
-               // If the buffer pool leaks, the test goroutine hangs.
-               c.Fatal("test did not finish, assuming pool leaked")
-       case <-ok:
-       }
-}
-
-func (s *HandlerSuite) TestGetHandlerClientDisconnect(c *check.C) {
-       s.cluster.Collections.BlobSigning = false
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       defer func(orig *bufferPool) {
-               bufs = orig
-       }(bufs)
-       bufs = newBufferPool(ctxlog.TestLogger(c), 1, BlockSize)
-       defer bufs.Put(bufs.Get(BlockSize))
-
-       err := s.handler.volmgr.AllWritable()[0].Put(context.Background(), TestHash, TestBlock)
-       c.Assert(err, check.IsNil)
-
-       resp := httptest.NewRecorder()
-       ok := make(chan struct{})
-       go func() {
-               ctx, cancel := context.WithCancel(context.Background())
-               req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("/%s+%d", TestHash, len(TestBlock)), nil)
-               cancel()
-               s.handler.ServeHTTP(resp, req)
-               ok <- struct{}{}
-       }()
-
-       select {
-       case <-time.After(20 * time.Second):
-               c.Fatal("request took >20s, close notifier must be broken")
-       case <-ok:
-       }
-
-       ExpectStatusCode(c, "client disconnect", http.StatusServiceUnavailable, resp)
-       for i, v := range s.handler.volmgr.AllWritable() {
-               if calls := v.Volume.(*MockVolume).called["GET"]; calls != 0 {
-                       c.Errorf("volume %d got %d calls, expected 0", i, calls)
-               }
-       }
-}
-
-// Invoke the GetBlockHandler a bunch of times to test for bufferpool resource
-// leak.
-func (s *HandlerSuite) TestGetHandlerNoBufferLeak(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       vols := s.handler.volmgr.AllWritable()
-       if err := vols[0].Put(context.Background(), TestHash, TestBlock); err != nil {
-               c.Error(err)
-       }
-
-       ok := make(chan bool)
-       go func() {
-               for i := 0; i < s.cluster.API.MaxKeepBlobBuffers+1; i++ {
-                       // Unauthenticated request, unsigned locator
-                       // => OK
-                       unsignedLocator := "/" + TestHash
-                       response := IssueRequest(s.handler,
-                               &RequestTester{
-                                       method: "GET",
-                                       uri:    unsignedLocator,
-                               })
-                       ExpectStatusCode(c,
-                               "Unauthenticated request, unsigned locator", http.StatusOK, response)
-                       ExpectBody(c,
-                               "Unauthenticated request, unsigned locator",
-                               string(TestBlock),
-                               response)
-               }
-               ok <- true
-       }()
-       select {
-       case <-time.After(20 * time.Second):
-               // If the buffer pool leaks, the test goroutine hangs.
-               c.Fatal("test did not finish, assuming pool leaked")
-       case <-ok:
-       }
-}
-
-func (s *HandlerSuite) TestPutStorageClasses(c *check.C) {
-       s.cluster.Volumes = map[string]arvados.Volume{
-               "zzzzz-nyw5e-000000000000000": {Replication: 1, Driver: "mock"}, // "default" is implicit
-               "zzzzz-nyw5e-111111111111111": {Replication: 1, Driver: "mock", StorageClasses: map[string]bool{"special": true, "extra": true}},
-               "zzzzz-nyw5e-222222222222222": {Replication: 1, Driver: "mock", StorageClasses: map[string]bool{"readonly": true}, ReadOnly: true},
-       }
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-       rt := RequestTester{
-               method:      "PUT",
-               uri:         "/" + TestHash,
-               requestBody: TestBlock,
-       }
-
-       for _, trial := range []struct {
-               ask    string
-               expect string
-       }{
-               {"", ""},
-               {"default", "default=1"},
-               {" , default , default , ", "default=1"},
-               {"special", "extra=1, special=1"},
-               {"special, readonly", "extra=1, special=1"},
-               {"special, nonexistent", "extra=1, special=1"},
-               {"extra, special", "extra=1, special=1"},
-               {"default, special", "default=1, extra=1, special=1"},
-       } {
-               c.Logf("success case %#v", trial)
-               rt.storageClasses = trial.ask
-               resp := IssueRequest(s.handler, &rt)
-               if trial.expect == "" {
-                       // any non-empty value is correct
-                       c.Check(resp.Header().Get("X-Keep-Storage-Classes-Confirmed"), check.Not(check.Equals), "")
-               } else {
-                       c.Check(sortCommaSeparated(resp.Header().Get("X-Keep-Storage-Classes-Confirmed")), check.Equals, trial.expect)
-               }
-       }
-
-       for _, trial := range []struct {
-               ask string
-       }{
-               {"doesnotexist"},
-               {"doesnotexist, readonly"},
-               {"readonly"},
-       } {
-               c.Logf("failure case %#v", trial)
-               rt.storageClasses = trial.ask
-               resp := IssueRequest(s.handler, &rt)
-               c.Check(resp.Code, check.Equals, http.StatusServiceUnavailable)
-       }
-}
-
-func sortCommaSeparated(s string) string {
-       slice := strings.Split(s, ", ")
-       sort.Strings(slice)
-       return strings.Join(slice, ", ")
-}
-
-func (s *HandlerSuite) TestPutResponseHeader(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       resp := IssueRequest(s.handler, &RequestTester{
-               method:      "PUT",
-               uri:         "/" + TestHash,
-               requestBody: TestBlock,
-       })
-       c.Logf("%#v", resp)
-       c.Check(resp.Header().Get("X-Keep-Replicas-Stored"), check.Equals, "1")
-       c.Check(resp.Header().Get("X-Keep-Storage-Classes-Confirmed"), check.Equals, "default=1")
-}
-
-func (s *HandlerSuite) TestUntrashHandler(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       // Set up Keep volumes
-       vols := s.handler.volmgr.AllWritable()
-       vols[0].Put(context.Background(), TestHash, TestBlock)
-
-       s.cluster.SystemRootToken = "DATA MANAGER TOKEN"
-
-       // unauthenticatedReq => UnauthorizedError
-       unauthenticatedReq := &RequestTester{
-               method: "PUT",
-               uri:    "/untrash/" + TestHash,
-       }
-       response := IssueRequest(s.handler, unauthenticatedReq)
-       ExpectStatusCode(c,
-               "Unauthenticated request",
-               UnauthorizedError.HTTPCode,
-               response)
-
-       // notDataManagerReq => UnauthorizedError
-       notDataManagerReq := &RequestTester{
-               method:   "PUT",
-               uri:      "/untrash/" + TestHash,
-               apiToken: knownToken,
-       }
-
-       response = IssueRequest(s.handler, notDataManagerReq)
-       ExpectStatusCode(c,
-               "Non-datamanager token",
-               UnauthorizedError.HTTPCode,
-               response)
-
-       // datamanagerWithBadHashReq => StatusBadRequest
-       datamanagerWithBadHashReq := &RequestTester{
-               method:   "PUT",
-               uri:      "/untrash/thisisnotalocator",
-               apiToken: s.cluster.SystemRootToken,
-       }
-       response = IssueRequest(s.handler, datamanagerWithBadHashReq)
-       ExpectStatusCode(c,
-               "Bad locator in untrash request",
-               http.StatusBadRequest,
-               response)
-
-       // datamanagerWrongMethodReq => StatusBadRequest
-       datamanagerWrongMethodReq := &RequestTester{
-               method:   "GET",
-               uri:      "/untrash/" + TestHash,
-               apiToken: s.cluster.SystemRootToken,
-       }
-       response = IssueRequest(s.handler, datamanagerWrongMethodReq)
-       ExpectStatusCode(c,
-               "Only PUT method is supported for untrash",
-               http.StatusMethodNotAllowed,
-               response)
-
-       // datamanagerReq => StatusOK
-       datamanagerReq := &RequestTester{
-               method:   "PUT",
-               uri:      "/untrash/" + TestHash,
-               apiToken: s.cluster.SystemRootToken,
-       }
-       response = IssueRequest(s.handler, datamanagerReq)
-       ExpectStatusCode(c,
-               "",
-               http.StatusOK,
-               response)
-       c.Check(response.Body.String(), check.Equals, "Successfully untrashed on: [MockVolume], [MockVolume]\n")
-}
-
-func (s *HandlerSuite) TestUntrashHandlerWithNoWritableVolumes(c *check.C) {
-       // Change all volumes to read-only
-       for uuid, v := range s.cluster.Volumes {
-               v.ReadOnly = true
-               s.cluster.Volumes[uuid] = v
-       }
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       // datamanagerReq => StatusOK
-       datamanagerReq := &RequestTester{
-               method:   "PUT",
-               uri:      "/untrash/" + TestHash,
-               apiToken: s.cluster.SystemRootToken,
-       }
-       response := IssueRequest(s.handler, datamanagerReq)
-       ExpectStatusCode(c,
-               "No writable volumes",
-               http.StatusNotFound,
-               response)
-}
-
-func (s *HandlerSuite) TestHealthCheckPing(c *check.C) {
-       s.cluster.ManagementToken = arvadostest.ManagementToken
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-       pingReq := &RequestTester{
-               method:   "GET",
-               uri:      "/_health/ping",
-               apiToken: arvadostest.ManagementToken,
-       }
-       response := IssueHealthCheckRequest(s.handler, pingReq)
-       ExpectStatusCode(c,
-               "",
-               http.StatusOK,
-               response)
-       want := `{"health":"OK"}`
-       if !strings.Contains(response.Body.String(), want) {
-               c.Errorf("expected response to include %s: got %s", want, response.Body.String())
-       }
-}
diff --git a/services/keepstore/handlers.go b/services/keepstore/handlers.go
deleted file mode 100644 (file)
index 63a2368..0000000
+++ /dev/null
@@ -1,1050 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "container/list"
-       "context"
-       "crypto/md5"
-       "encoding/json"
-       "fmt"
-       "io"
-       "net/http"
-       "os"
-       "regexp"
-       "runtime"
-       "strconv"
-       "strings"
-       "sync"
-       "sync/atomic"
-       "time"
-
-       "git.arvados.org/arvados.git/lib/cmd"
-       "git.arvados.org/arvados.git/sdk/go/arvados"
-       "git.arvados.org/arvados.git/sdk/go/ctxlog"
-       "git.arvados.org/arvados.git/sdk/go/health"
-       "git.arvados.org/arvados.git/sdk/go/httpserver"
-       "github.com/gorilla/mux"
-       "github.com/prometheus/client_golang/prometheus"
-       "github.com/sirupsen/logrus"
-)
-
-type router struct {
-       *mux.Router
-       cluster     *arvados.Cluster
-       logger      logrus.FieldLogger
-       remoteProxy remoteProxy
-       metrics     *nodeMetrics
-       volmgr      *RRVolumeManager
-       pullq       *WorkQueue
-       trashq      *WorkQueue
-}
-
-// MakeRESTRouter returns a new router that forwards all Keep requests
-// to the appropriate handlers.
-func MakeRESTRouter(ctx context.Context, cluster *arvados.Cluster, reg *prometheus.Registry, volmgr *RRVolumeManager, pullq, trashq *WorkQueue) http.Handler {
-       rtr := &router{
-               Router:  mux.NewRouter(),
-               cluster: cluster,
-               logger:  ctxlog.FromContext(ctx),
-               metrics: &nodeMetrics{reg: reg},
-               volmgr:  volmgr,
-               pullq:   pullq,
-               trashq:  trashq,
-       }
-
-       rtr.HandleFunc(
-               `/{hash:[0-9a-f]{32}}`, rtr.handleGET).Methods("GET", "HEAD")
-       rtr.HandleFunc(
-               `/{hash:[0-9a-f]{32}}+{hints}`,
-               rtr.handleGET).Methods("GET", "HEAD")
-
-       rtr.HandleFunc(`/{hash:[0-9a-f]{32}}`, rtr.handlePUT).Methods("PUT")
-       rtr.HandleFunc(`/{hash:[0-9a-f]{32}}`, rtr.handleDELETE).Methods("DELETE")
-       // List all blocks stored here. Privileged client only.
-       rtr.HandleFunc(`/index`, rtr.handleIndex).Methods("GET", "HEAD")
-       // List blocks stored here whose hash has the given prefix.
-       // Privileged client only.
-       rtr.HandleFunc(`/index/{prefix:[0-9a-f]{0,32}}`, rtr.handleIndex).Methods("GET", "HEAD")
-       // Update timestamp on existing block. Privileged client only.
-       rtr.HandleFunc(`/{hash:[0-9a-f]{32}}`, rtr.handleTOUCH).Methods("TOUCH")
-
-       // Internals/debugging info (runtime.MemStats)
-       rtr.HandleFunc(`/debug.json`, rtr.DebugHandler).Methods("GET", "HEAD")
-
-       // List volumes: path, device number, bytes used/avail.
-       rtr.HandleFunc(`/status.json`, rtr.StatusHandler).Methods("GET", "HEAD")
-
-       // List mounts: UUID, readonly, tier, device ID, ...
-       rtr.HandleFunc(`/mounts`, rtr.MountsHandler).Methods("GET")
-       rtr.HandleFunc(`/mounts/{uuid}/blocks`, rtr.handleIndex).Methods("GET")
-       rtr.HandleFunc(`/mounts/{uuid}/blocks/`, rtr.handleIndex).Methods("GET")
-
-       // Replace the current pull queue.
-       rtr.HandleFunc(`/pull`, rtr.handlePull).Methods("PUT")
-
-       // Replace the current trash queue.
-       rtr.HandleFunc(`/trash`, rtr.handleTrash).Methods("PUT")
-
-       // Untrash moves blocks from trash back into store
-       rtr.HandleFunc(`/untrash/{hash:[0-9a-f]{32}}`, rtr.handleUntrash).Methods("PUT")
-
-       rtr.Handle("/_health/{check}", &health.Handler{
-               Token:  cluster.ManagementToken,
-               Prefix: "/_health/",
-       }).Methods("GET")
-
-       // Any request which does not match any of these routes gets
-       // 400 Bad Request.
-       rtr.NotFoundHandler = http.HandlerFunc(BadRequestHandler)
-
-       rtr.metrics.setupBufferPoolMetrics(bufs)
-       rtr.metrics.setupWorkQueueMetrics(rtr.pullq, "pull")
-       rtr.metrics.setupWorkQueueMetrics(rtr.trashq, "trash")
-
-       return rtr
-}
-
-// BadRequestHandler is a HandleFunc to address bad requests.
-func BadRequestHandler(w http.ResponseWriter, r *http.Request) {
-       http.Error(w, BadRequestError.Error(), BadRequestError.HTTPCode)
-}
-
-func (rtr *router) handleGET(resp http.ResponseWriter, req *http.Request) {
-       locator := req.URL.Path[1:]
-       if strings.Contains(locator, "+R") && !strings.Contains(locator, "+A") {
-               rtr.remoteProxy.Get(req.Context(), resp, req, rtr.cluster, rtr.volmgr)
-               return
-       }
-
-       if rtr.cluster.Collections.BlobSigning {
-               locator := req.URL.Path[1:] // strip leading slash
-               if err := VerifySignature(rtr.cluster, locator, GetAPIToken(req)); err != nil {
-                       http.Error(resp, err.Error(), err.(*KeepError).HTTPCode)
-                       return
-               }
-       }
-
-       // TODO: Probe volumes to check whether the block _might_
-       // exist. Some volumes/types could support a quick existence
-       // check without causing other operations to suffer. If all
-       // volumes support that, and assure us the block definitely
-       // isn't here, we can return 404 now instead of waiting for a
-       // buffer.
-
-       buf, err := getBufferWithContext(req.Context(), bufs, BlockSize)
-       if err != nil {
-               http.Error(resp, err.Error(), http.StatusServiceUnavailable)
-               return
-       }
-       defer bufs.Put(buf)
-
-       size, err := GetBlock(req.Context(), rtr.volmgr, mux.Vars(req)["hash"], buf, resp)
-       if err != nil {
-               code := http.StatusInternalServerError
-               if err, ok := err.(*KeepError); ok {
-                       code = err.HTTPCode
-               }
-               http.Error(resp, err.Error(), code)
-               return
-       }
-
-       resp.Header().Set("Content-Length", strconv.Itoa(size))
-       resp.Header().Set("Content-Type", "application/octet-stream")
-       resp.Write(buf[:size])
-}
-
-// Get a buffer from the pool -- but give up and return a non-nil
-// error if ctx ends before we get a buffer.
-func getBufferWithContext(ctx context.Context, bufs *bufferPool, bufSize int) ([]byte, error) {
-       bufReady := make(chan []byte)
-       go func() {
-               bufReady <- bufs.Get(bufSize)
-       }()
-       select {
-       case buf := <-bufReady:
-               return buf, nil
-       case <-ctx.Done():
-               go func() {
-                       // Even if closeNotifier happened first, we
-                       // need to keep waiting for our buf so we can
-                       // return it to the pool.
-                       bufs.Put(<-bufReady)
-               }()
-               return nil, ErrClientDisconnect
-       }
-}
-
-func (rtr *router) handleTOUCH(resp http.ResponseWriter, req *http.Request) {
-       if !rtr.isSystemAuth(GetAPIToken(req)) {
-               http.Error(resp, UnauthorizedError.Error(), UnauthorizedError.HTTPCode)
-               return
-       }
-       hash := mux.Vars(req)["hash"]
-       vols := rtr.volmgr.AllWritable()
-       if len(vols) == 0 {
-               http.Error(resp, "no volumes", http.StatusNotFound)
-               return
-       }
-       var err error
-       for _, mnt := range vols {
-               err = mnt.Touch(hash)
-               if err == nil {
-                       break
-               }
-       }
-       switch {
-       case err == nil:
-               return
-       case os.IsNotExist(err):
-               http.Error(resp, err.Error(), http.StatusNotFound)
-       default:
-               http.Error(resp, err.Error(), http.StatusInternalServerError)
-       }
-}
-
-func (rtr *router) handlePUT(resp http.ResponseWriter, req *http.Request) {
-       hash := mux.Vars(req)["hash"]
-
-       // Detect as many error conditions as possible before reading
-       // the body: avoid transmitting data that will not end up
-       // being written anyway.
-
-       if req.ContentLength == -1 {
-               http.Error(resp, SizeRequiredError.Error(), SizeRequiredError.HTTPCode)
-               return
-       }
-
-       if req.ContentLength > BlockSize {
-               http.Error(resp, TooLongError.Error(), TooLongError.HTTPCode)
-               return
-       }
-
-       if len(rtr.volmgr.AllWritable()) == 0 {
-               http.Error(resp, FullError.Error(), FullError.HTTPCode)
-               return
-       }
-
-       var wantStorageClasses []string
-       if hdr := req.Header.Get("X-Keep-Storage-Classes"); hdr != "" {
-               wantStorageClasses = strings.Split(hdr, ",")
-               for i, sc := range wantStorageClasses {
-                       wantStorageClasses[i] = strings.TrimSpace(sc)
-               }
-       } else {
-               // none specified -- use configured default
-               for class, cfg := range rtr.cluster.StorageClasses {
-                       if cfg.Default {
-                               wantStorageClasses = append(wantStorageClasses, class)
-                       }
-               }
-       }
-
-       buf, err := getBufferWithContext(req.Context(), bufs, int(req.ContentLength))
-       if err != nil {
-               http.Error(resp, err.Error(), http.StatusServiceUnavailable)
-               return
-       }
-
-       _, err = io.ReadFull(req.Body, buf)
-       if err != nil {
-               http.Error(resp, err.Error(), 500)
-               bufs.Put(buf)
-               return
-       }
-
-       result, err := PutBlock(req.Context(), rtr.volmgr, buf, hash, wantStorageClasses)
-       bufs.Put(buf)
-
-       if err != nil {
-               code := http.StatusInternalServerError
-               if err, ok := err.(*KeepError); ok {
-                       code = err.HTTPCode
-               }
-               http.Error(resp, err.Error(), code)
-               return
-       }
-
-       // Success; add a size hint, sign the locator if possible, and
-       // return it to the client.
-       returnHash := fmt.Sprintf("%s+%d", hash, req.ContentLength)
-       apiToken := GetAPIToken(req)
-       if rtr.cluster.Collections.BlobSigningKey != "" && apiToken != "" {
-               expiry := time.Now().Add(rtr.cluster.Collections.BlobSigningTTL.Duration())
-               returnHash = SignLocator(rtr.cluster, returnHash, apiToken, expiry)
-       }
-       resp.Header().Set("X-Keep-Replicas-Stored", result.TotalReplication())
-       resp.Header().Set("X-Keep-Storage-Classes-Confirmed", result.ClassReplication())
-       resp.Write([]byte(returnHash + "\n"))
-}
-
-// IndexHandler responds to "/index", "/index/{prefix}", and
-// "/mounts/{uuid}/blocks" requests.
-func (rtr *router) handleIndex(resp http.ResponseWriter, req *http.Request) {
-       if !rtr.isSystemAuth(GetAPIToken(req)) {
-               http.Error(resp, UnauthorizedError.Error(), UnauthorizedError.HTTPCode)
-               return
-       }
-
-       prefix := mux.Vars(req)["prefix"]
-       if prefix == "" {
-               req.ParseForm()
-               prefix = req.Form.Get("prefix")
-       }
-
-       uuid := mux.Vars(req)["uuid"]
-
-       var vols []*VolumeMount
-       if uuid == "" {
-               vols = rtr.volmgr.AllReadable()
-       } else if mnt := rtr.volmgr.Lookup(uuid, false); mnt == nil {
-               http.Error(resp, "mount not found", http.StatusNotFound)
-               return
-       } else {
-               vols = []*VolumeMount{mnt}
-       }
-
-       for _, v := range vols {
-               if err := v.IndexTo(prefix, resp); err != nil {
-                       // We can't send an error status/message to
-                       // the client because IndexTo() might have
-                       // already written body content. All we can do
-                       // is log the error in our own logs.
-                       //
-                       // The client must notice the lack of trailing
-                       // newline as an indication that the response
-                       // is incomplete.
-                       ctxlog.FromContext(req.Context()).WithError(err).Errorf("truncating index response after error from volume %s", v)
-                       return
-               }
-       }
-       // An empty line at EOF is the only way the client can be
-       // assured the entire index was received.
-       resp.Write([]byte{'\n'})
-}
-
-// MountsHandler responds to "GET /mounts" requests.
-func (rtr *router) MountsHandler(resp http.ResponseWriter, req *http.Request) {
-       err := json.NewEncoder(resp).Encode(rtr.volmgr.Mounts())
-       if err != nil {
-               httpserver.Error(resp, err.Error(), http.StatusInternalServerError)
-       }
-}
-
-// PoolStatus struct
-type PoolStatus struct {
-       Alloc uint64 `json:"BytesAllocatedCumulative"`
-       Cap   int    `json:"BuffersMax"`
-       Len   int    `json:"BuffersInUse"`
-}
-
-type volumeStatusEnt struct {
-       Label         string
-       Status        *VolumeStatus `json:",omitempty"`
-       VolumeStats   *ioStats      `json:",omitempty"`
-       InternalStats interface{}   `json:",omitempty"`
-}
-
-// NodeStatus struct
-type NodeStatus struct {
-       Volumes         []*volumeStatusEnt
-       BufferPool      PoolStatus
-       PullQueue       WorkQueueStatus
-       TrashQueue      WorkQueueStatus
-       RequestsCurrent int
-       RequestsMax     int
-       Version         string
-}
-
-var st NodeStatus
-var stLock sync.Mutex
-
-// DebugHandler addresses /debug.json requests.
-func (rtr *router) DebugHandler(resp http.ResponseWriter, req *http.Request) {
-       type debugStats struct {
-               MemStats runtime.MemStats
-       }
-       var ds debugStats
-       runtime.ReadMemStats(&ds.MemStats)
-       data, err := json.Marshal(&ds)
-       if err != nil {
-               http.Error(resp, err.Error(), http.StatusInternalServerError)
-               return
-       }
-       resp.Write(data)
-}
-
-// StatusHandler addresses /status.json requests.
-func (rtr *router) StatusHandler(resp http.ResponseWriter, req *http.Request) {
-       stLock.Lock()
-       rtr.readNodeStatus(&st)
-       data, err := json.Marshal(&st)
-       stLock.Unlock()
-       if err != nil {
-               http.Error(resp, err.Error(), http.StatusInternalServerError)
-               return
-       }
-       resp.Write(data)
-}
-
-// populate the given NodeStatus struct with current values.
-func (rtr *router) readNodeStatus(st *NodeStatus) {
-       st.Version = strings.SplitN(cmd.Version.String(), " ", 2)[0]
-       vols := rtr.volmgr.AllReadable()
-       if cap(st.Volumes) < len(vols) {
-               st.Volumes = make([]*volumeStatusEnt, len(vols))
-       }
-       st.Volumes = st.Volumes[:0]
-       for _, vol := range vols {
-               var internalStats interface{}
-               if vol, ok := vol.Volume.(InternalStatser); ok {
-                       internalStats = vol.InternalStats()
-               }
-               st.Volumes = append(st.Volumes, &volumeStatusEnt{
-                       Label:         vol.String(),
-                       Status:        vol.Status(),
-                       InternalStats: internalStats,
-                       //VolumeStats: rtr.volmgr.VolumeStats(vol),
-               })
-       }
-       st.BufferPool.Alloc = bufs.Alloc()
-       st.BufferPool.Cap = bufs.Cap()
-       st.BufferPool.Len = bufs.Len()
-       st.PullQueue = getWorkQueueStatus(rtr.pullq)
-       st.TrashQueue = getWorkQueueStatus(rtr.trashq)
-}
-
-// return a WorkQueueStatus for the given queue. If q is nil (which
-// should never happen except in test suites), return a zero status
-// value instead of crashing.
-func getWorkQueueStatus(q *WorkQueue) WorkQueueStatus {
-       if q == nil {
-               // This should only happen during tests.
-               return WorkQueueStatus{}
-       }
-       return q.Status()
-}
-
-// handleDELETE processes DELETE requests.
-//
-// DELETE /{hash:[0-9a-f]{32} will delete the block with the specified hash
-// from all connected volumes.
-//
-// Only the Data Manager, or an Arvados admin with scope "all", are
-// allowed to issue DELETE requests.  If a DELETE request is not
-// authenticated or is issued by a non-admin user, the server returns
-// a PermissionError.
-//
-// Upon receiving a valid request from an authorized user,
-// handleDELETE deletes all copies of the specified block on local
-// writable volumes.
-//
-// Response format:
-//
-// If the requested blocks was not found on any volume, the response
-// code is HTTP 404 Not Found.
-//
-// Otherwise, the response code is 200 OK, with a response body
-// consisting of the JSON message
-//
-//    {"copies_deleted":d,"copies_failed":f}
-//
-// where d and f are integers representing the number of blocks that
-// were successfully and unsuccessfully deleted.
-//
-func (rtr *router) handleDELETE(resp http.ResponseWriter, req *http.Request) {
-       hash := mux.Vars(req)["hash"]
-
-       // Confirm that this user is an admin and has a token with unlimited scope.
-       var tok = GetAPIToken(req)
-       if tok == "" || !rtr.canDelete(tok) {
-               http.Error(resp, PermissionError.Error(), PermissionError.HTTPCode)
-               return
-       }
-
-       if !rtr.cluster.Collections.BlobTrash {
-               http.Error(resp, MethodDisabledError.Error(), MethodDisabledError.HTTPCode)
-               return
-       }
-
-       // Delete copies of this block from all available volumes.
-       // Report how many blocks were successfully deleted, and how
-       // many were found on writable volumes but not deleted.
-       var result struct {
-               Deleted int `json:"copies_deleted"`
-               Failed  int `json:"copies_failed"`
-       }
-       for _, vol := range rtr.volmgr.AllWritable() {
-               if err := vol.Trash(hash); err == nil {
-                       result.Deleted++
-               } else if os.IsNotExist(err) {
-                       continue
-               } else {
-                       result.Failed++
-                       ctxlog.FromContext(req.Context()).WithError(err).Errorf("Trash(%s) failed on volume %s", hash, vol)
-               }
-       }
-       if result.Deleted == 0 && result.Failed == 0 {
-               resp.WriteHeader(http.StatusNotFound)
-               return
-       }
-       body, err := json.Marshal(result)
-       if err != nil {
-               http.Error(resp, err.Error(), http.StatusInternalServerError)
-               return
-       }
-       resp.Write(body)
-}
-
-/* PullHandler processes "PUT /pull" requests for the data manager.
-   The request body is a JSON message containing a list of pull
-   requests in the following format:
-
-   [
-      {
-         "locator":"e4d909c290d0fb1ca068ffaddf22cbd0+4985",
-         "servers":[
-                       "keep0.qr1hi.arvadosapi.com:25107",
-                       "keep1.qr1hi.arvadosapi.com:25108"
-                ]
-         },
-         {
-                "locator":"55ae4d45d2db0793d53f03e805f656e5+658395",
-                "servers":[
-                       "10.0.1.5:25107",
-                       "10.0.1.6:25107",
-                       "10.0.1.7:25108"
-                ]
-         },
-         ...
-   ]
-
-   Each pull request in the list consists of a block locator string
-   and an ordered list of servers.  Keepstore should try to fetch the
-   block from each server in turn.
-
-   If the request has not been sent by the Data Manager, return 401
-   Unauthorized.
-
-   If the JSON unmarshalling fails, return 400 Bad Request.
-*/
-
-// PullRequest consists of a block locator and an ordered list of servers
-type PullRequest struct {
-       Locator string   `json:"locator"`
-       Servers []string `json:"servers"`
-
-       // Destination mount, or "" for "anywhere"
-       MountUUID string `json:"mount_uuid"`
-}
-
-// PullHandler processes "PUT /pull" requests for the data manager.
-func (rtr *router) handlePull(resp http.ResponseWriter, req *http.Request) {
-       // Reject unauthorized requests.
-       if !rtr.isSystemAuth(GetAPIToken(req)) {
-               http.Error(resp, UnauthorizedError.Error(), UnauthorizedError.HTTPCode)
-               return
-       }
-
-       // Parse the request body.
-       var pr []PullRequest
-       r := json.NewDecoder(req.Body)
-       if err := r.Decode(&pr); err != nil {
-               http.Error(resp, err.Error(), BadRequestError.HTTPCode)
-               return
-       }
-
-       // We have a properly formatted pull list sent from the data
-       // manager.  Report success and send the list to the pull list
-       // manager for further handling.
-       resp.WriteHeader(http.StatusOK)
-       resp.Write([]byte(
-               fmt.Sprintf("Received %d pull requests\n", len(pr))))
-
-       plist := list.New()
-       for _, p := range pr {
-               plist.PushBack(p)
-       }
-       rtr.pullq.ReplaceQueue(plist)
-}
-
-// TrashRequest consists of a block locator and its Mtime
-type TrashRequest struct {
-       Locator    string `json:"locator"`
-       BlockMtime int64  `json:"block_mtime"`
-
-       // Target mount, or "" for "everywhere"
-       MountUUID string `json:"mount_uuid"`
-}
-
-// TrashHandler processes /trash requests.
-func (rtr *router) handleTrash(resp http.ResponseWriter, req *http.Request) {
-       // Reject unauthorized requests.
-       if !rtr.isSystemAuth(GetAPIToken(req)) {
-               http.Error(resp, UnauthorizedError.Error(), UnauthorizedError.HTTPCode)
-               return
-       }
-
-       // Parse the request body.
-       var trash []TrashRequest
-       r := json.NewDecoder(req.Body)
-       if err := r.Decode(&trash); err != nil {
-               http.Error(resp, err.Error(), BadRequestError.HTTPCode)
-               return
-       }
-
-       // We have a properly formatted trash list sent from the data
-       // manager.  Report success and send the list to the trash work
-       // queue for further handling.
-       resp.WriteHeader(http.StatusOK)
-       resp.Write([]byte(
-               fmt.Sprintf("Received %d trash requests\n", len(trash))))
-
-       tlist := list.New()
-       for _, t := range trash {
-               tlist.PushBack(t)
-       }
-       rtr.trashq.ReplaceQueue(tlist)
-}
-
-// UntrashHandler processes "PUT /untrash/{hash:[0-9a-f]{32}}" requests for the data manager.
-func (rtr *router) handleUntrash(resp http.ResponseWriter, req *http.Request) {
-       // Reject unauthorized requests.
-       if !rtr.isSystemAuth(GetAPIToken(req)) {
-               http.Error(resp, UnauthorizedError.Error(), UnauthorizedError.HTTPCode)
-               return
-       }
-
-       log := ctxlog.FromContext(req.Context())
-       hash := mux.Vars(req)["hash"]
-
-       if len(rtr.volmgr.AllWritable()) == 0 {
-               http.Error(resp, "No writable volumes", http.StatusNotFound)
-               return
-       }
-
-       var untrashedOn, failedOn []string
-       var numNotFound int
-       for _, vol := range rtr.volmgr.AllWritable() {
-               err := vol.Untrash(hash)
-
-               if os.IsNotExist(err) {
-                       numNotFound++
-               } else if err != nil {
-                       log.WithError(err).Errorf("Error untrashing %v on volume %s", hash, vol)
-                       failedOn = append(failedOn, vol.String())
-               } else {
-                       log.Infof("Untrashed %v on volume %v", hash, vol.String())
-                       untrashedOn = append(untrashedOn, vol.String())
-               }
-       }
-
-       if numNotFound == len(rtr.volmgr.AllWritable()) {
-               http.Error(resp, "Block not found on any of the writable volumes", http.StatusNotFound)
-       } else if len(failedOn) == len(rtr.volmgr.AllWritable()) {
-               http.Error(resp, "Failed to untrash on all writable volumes", http.StatusInternalServerError)
-       } else {
-               respBody := "Successfully untrashed on: " + strings.Join(untrashedOn, ", ")
-               if len(failedOn) > 0 {
-                       respBody += "; Failed to untrash on: " + strings.Join(failedOn, ", ")
-                       http.Error(resp, respBody, http.StatusInternalServerError)
-               } else {
-                       fmt.Fprintln(resp, respBody)
-               }
-       }
-}
-
-// GetBlock and PutBlock implement lower-level code for handling
-// blocks by rooting through volumes connected to the local machine.
-// Once the handler has determined that system policy permits the
-// request, it calls these methods to perform the actual operation.
-//
-// TODO(twp): this code would probably be better located in the
-// VolumeManager interface. As an abstraction, the VolumeManager
-// should be the only part of the code that cares about which volume a
-// block is stored on, so it should be responsible for figuring out
-// which volume to check for fetching blocks, storing blocks, etc.
-
-// GetBlock fetches the block identified by "hash" into the provided
-// buf, and returns the data size.
-//
-// If the block cannot be found on any volume, returns NotFoundError.
-//
-// If the block found does not have the correct MD5 hash, returns
-// DiskHashError.
-//
-func GetBlock(ctx context.Context, volmgr *RRVolumeManager, hash string, buf []byte, resp http.ResponseWriter) (int, error) {
-       log := ctxlog.FromContext(ctx)
-
-       // Attempt to read the requested hash from a keep volume.
-       errorToCaller := NotFoundError
-
-       for _, vol := range volmgr.AllReadable() {
-               size, err := vol.Get(ctx, hash, buf)
-               select {
-               case <-ctx.Done():
-                       return 0, ErrClientDisconnect
-               default:
-               }
-               if err != nil {
-                       // IsNotExist is an expected error and may be
-                       // ignored. All other errors are logged. In
-                       // any case we continue trying to read other
-                       // volumes. If all volumes report IsNotExist,
-                       // we return a NotFoundError.
-                       if !os.IsNotExist(err) {
-                               log.WithError(err).Errorf("Get(%s) failed on %s", hash, vol)
-                       }
-                       // If some volume returns a transient error, return it to the caller
-                       // instead of "Not found" so it can retry.
-                       if err == VolumeBusyError {
-                               errorToCaller = err.(*KeepError)
-                       }
-                       continue
-               }
-               // Check the file checksum.
-               filehash := fmt.Sprintf("%x", md5.Sum(buf[:size]))
-               if filehash != hash {
-                       // TODO: Try harder to tell a sysadmin about
-                       // this.
-                       log.Errorf("checksum mismatch for block %s (actual %s), size %d on %s", hash, filehash, size, vol)
-                       errorToCaller = DiskHashError
-                       continue
-               }
-               if errorToCaller == DiskHashError {
-                       log.Warn("after checksum mismatch for block %s on a different volume, a good copy was found on volume %s and returned", hash, vol)
-               }
-               return size, nil
-       }
-       return 0, errorToCaller
-}
-
-type putProgress struct {
-       classNeeded      map[string]bool
-       classTodo        map[string]bool
-       mountUsed        map[*VolumeMount]bool
-       totalReplication int
-       classDone        map[string]int
-}
-
-// Number of distinct replicas stored. "2" can mean the block was
-// stored on 2 different volumes with replication 1, or on 1 volume
-// with replication 2.
-func (pr putProgress) TotalReplication() string {
-       return strconv.Itoa(pr.totalReplication)
-}
-
-// Number of replicas satisfying each storage class, formatted like
-// "default=2; special=1".
-func (pr putProgress) ClassReplication() string {
-       s := ""
-       for k, v := range pr.classDone {
-               if len(s) > 0 {
-                       s += ", "
-               }
-               s += k + "=" + strconv.Itoa(v)
-       }
-       return s
-}
-
-func (pr *putProgress) Add(mnt *VolumeMount) {
-       if pr.mountUsed[mnt] {
-               logrus.Warnf("BUG? superfluous extra write to mount %s", mnt.UUID)
-               return
-       }
-       pr.mountUsed[mnt] = true
-       pr.totalReplication += mnt.Replication
-       for class := range mnt.StorageClasses {
-               pr.classDone[class] += mnt.Replication
-               delete(pr.classTodo, class)
-       }
-}
-
-func (pr *putProgress) Sub(mnt *VolumeMount) {
-       if !pr.mountUsed[mnt] {
-               logrus.Warnf("BUG? Sub called with no prior matching Add: %s", mnt.UUID)
-               return
-       }
-       pr.mountUsed[mnt] = false
-       pr.totalReplication -= mnt.Replication
-       for class := range mnt.StorageClasses {
-               pr.classDone[class] -= mnt.Replication
-               if pr.classNeeded[class] {
-                       pr.classTodo[class] = true
-               }
-       }
-}
-
-func (pr *putProgress) Done() bool {
-       return len(pr.classTodo) == 0 && pr.totalReplication > 0
-}
-
-func (pr *putProgress) Want(mnt *VolumeMount) bool {
-       if pr.Done() || pr.mountUsed[mnt] {
-               return false
-       }
-       if len(pr.classTodo) == 0 {
-               // none specified == "any"
-               return true
-       }
-       for class := range mnt.StorageClasses {
-               if pr.classTodo[class] {
-                       return true
-               }
-       }
-       return false
-}
-
-func (pr *putProgress) Copy() *putProgress {
-       cp := putProgress{
-               classNeeded:      pr.classNeeded,
-               classTodo:        make(map[string]bool, len(pr.classTodo)),
-               classDone:        make(map[string]int, len(pr.classDone)),
-               mountUsed:        make(map[*VolumeMount]bool, len(pr.mountUsed)),
-               totalReplication: pr.totalReplication,
-       }
-       for k, v := range pr.classTodo {
-               cp.classTodo[k] = v
-       }
-       for k, v := range pr.classDone {
-               cp.classDone[k] = v
-       }
-       for k, v := range pr.mountUsed {
-               cp.mountUsed[k] = v
-       }
-       return &cp
-}
-
-func newPutProgress(classes []string) putProgress {
-       pr := putProgress{
-               classNeeded: make(map[string]bool, len(classes)),
-               classTodo:   make(map[string]bool, len(classes)),
-               classDone:   map[string]int{},
-               mountUsed:   map[*VolumeMount]bool{},
-       }
-       for _, c := range classes {
-               if c != "" {
-                       pr.classNeeded[c] = true
-                       pr.classTodo[c] = true
-               }
-       }
-       return pr
-}
-
-// PutBlock stores the given block on one or more volumes.
-//
-// The MD5 checksum of the block must match the given hash.
-//
-// The block is written to each writable volume (ordered by priority
-// and then UUID, see volume.go) until at least one replica has been
-// stored in each of the requested storage classes.
-//
-// The returned error, if any, is a KeepError with one of the
-// following codes:
-//
-// 500 Collision
-//        A different block with the same hash already exists on this
-//        Keep server.
-// 422 MD5Fail
-//        The MD5 hash of the BLOCK does not match the argument HASH.
-// 503 Full
-//        There was not enough space left in any Keep volume to store
-//        the object.
-// 500 Fail
-//        The object could not be stored for some other reason (e.g.
-//        all writes failed). The text of the error message should
-//        provide as much detail as possible.
-func PutBlock(ctx context.Context, volmgr *RRVolumeManager, block []byte, hash string, wantStorageClasses []string) (putProgress, error) {
-       log := ctxlog.FromContext(ctx)
-
-       // Check that BLOCK's checksum matches HASH.
-       blockhash := fmt.Sprintf("%x", md5.Sum(block))
-       if blockhash != hash {
-               log.Printf("%s: MD5 checksum %s did not match request", hash, blockhash)
-               return putProgress{}, RequestHashError
-       }
-
-       result := newPutProgress(wantStorageClasses)
-
-       // If we already have this data, it's intact on disk, and we
-       // can update its timestamp, return success. If we have
-       // different data with the same hash, return failure.
-       if err := CompareAndTouch(ctx, volmgr, hash, block, &result); err != nil || result.Done() {
-               return result, err
-       }
-       if ctx.Err() != nil {
-               return result, ErrClientDisconnect
-       }
-
-       writables := volmgr.NextWritable()
-       if len(writables) == 0 {
-               log.Error("no writable volumes")
-               return result, FullError
-       }
-
-       var wg sync.WaitGroup
-       var mtx sync.Mutex
-       cond := sync.Cond{L: &mtx}
-       // pending predicts what result will be if all pending writes
-       // succeed.
-       pending := result.Copy()
-       var allFull atomic.Value
-       allFull.Store(true)
-
-       // We hold the lock for the duration of the "each volume" loop
-       // below, except when it is released during cond.Wait().
-       mtx.Lock()
-
-       for _, mnt := range writables {
-               // Wait until our decision to use this mount does not
-               // depend on the outcome of pending writes.
-               for result.Want(mnt) && !pending.Want(mnt) {
-                       cond.Wait()
-               }
-               if !result.Want(mnt) {
-                       continue
-               }
-               mnt := mnt
-               pending.Add(mnt)
-               wg.Add(1)
-               go func() {
-                       log.Debugf("PutBlock: start write to %s", mnt.UUID)
-                       defer wg.Done()
-                       err := mnt.Put(ctx, hash, block)
-
-                       mtx.Lock()
-                       if err != nil {
-                               log.Debugf("PutBlock: write to %s failed", mnt.UUID)
-                               pending.Sub(mnt)
-                       } else {
-                               log.Debugf("PutBlock: write to %s succeeded", mnt.UUID)
-                               result.Add(mnt)
-                       }
-                       cond.Broadcast()
-                       mtx.Unlock()
-
-                       if err != nil && err != FullError && ctx.Err() == nil {
-                               // The volume is not full but the
-                               // write did not succeed.  Report the
-                               // error and continue trying.
-                               allFull.Store(false)
-                               log.WithError(err).Errorf("%s: Put(%s) failed", mnt.Volume, hash)
-                       }
-               }()
-       }
-       mtx.Unlock()
-       wg.Wait()
-       if ctx.Err() != nil {
-               return result, ErrClientDisconnect
-       }
-       if result.Done() {
-               return result, nil
-       }
-
-       if result.totalReplication > 0 {
-               // Some, but not all, of the storage classes were
-               // satisfied. This qualifies as success.
-               return result, nil
-       } else if allFull.Load().(bool) {
-               log.Error("all volumes with qualifying storage classes are full")
-               return putProgress{}, FullError
-       } else {
-               // Already logged the non-full errors.
-               return putProgress{}, GenericError
-       }
-}
-
-// CompareAndTouch looks for volumes where the given content already
-// exists and its modification time can be updated (i.e., it is
-// protected from garbage collection), and updates result accordingly.
-// It returns when the result is Done() or all volumes have been
-// checked.
-func CompareAndTouch(ctx context.Context, volmgr *RRVolumeManager, hash string, buf []byte, result *putProgress) error {
-       log := ctxlog.FromContext(ctx)
-       for _, mnt := range volmgr.AllWritable() {
-               if !result.Want(mnt) {
-                       continue
-               }
-               err := mnt.Compare(ctx, hash, buf)
-               if ctx.Err() != nil {
-                       return nil
-               } else if err == CollisionError {
-                       // Stop if we have a block with same hash but
-                       // different content. (It will be impossible
-                       // to tell which one is wanted if we have
-                       // both, so there's no point writing it even
-                       // on a different volume.)
-                       log.Errorf("collision in Compare(%s) on volume %s", hash, mnt.Volume)
-                       return CollisionError
-               } else if os.IsNotExist(err) {
-                       // Block does not exist. This is the only
-                       // "normal" error: we don't log anything.
-                       continue
-               } else if err != nil {
-                       // Couldn't open file, data is corrupt on
-                       // disk, etc.: log this abnormal condition,
-                       // and try the next volume.
-                       log.WithError(err).Warnf("error in Compare(%s) on volume %s", hash, mnt.Volume)
-                       continue
-               }
-               if err := mnt.Touch(hash); err != nil {
-                       log.WithError(err).Errorf("error in Touch(%s) on volume %s", hash, mnt.Volume)
-                       continue
-               }
-               // Compare and Touch both worked --> done.
-               result.Add(mnt)
-               if result.Done() {
-                       return nil
-               }
-       }
-       return nil
-}
-
-var validLocatorRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
-
-// IsValidLocator returns true if the specified string is a valid Keep locator.
-//   When Keep is extended to support hash types other than MD5,
-//   this should be updated to cover those as well.
-//
-func IsValidLocator(loc string) bool {
-       return validLocatorRe.MatchString(loc)
-}
-
-var authRe = regexp.MustCompile(`^(OAuth2|Bearer)\s+(.*)`)
-
-// GetAPIToken returns the OAuth2 token from the Authorization
-// header of a HTTP request, or an empty string if no matching
-// token is found.
-func GetAPIToken(req *http.Request) string {
-       if auth, ok := req.Header["Authorization"]; ok {
-               if match := authRe.FindStringSubmatch(auth[0]); match != nil {
-                       return match[2]
-               }
-       }
-       return ""
-}
-
-// canDelete returns true if the user identified by apiToken is
-// allowed to delete blocks.
-func (rtr *router) canDelete(apiToken string) bool {
-       if apiToken == "" {
-               return false
-       }
-       // Blocks may be deleted only when Keep has been configured with a
-       // data manager.
-       if rtr.isSystemAuth(apiToken) {
-               return true
-       }
-       // TODO(twp): look up apiToken with the API server
-       // return true if is_admin is true and if the token
-       // has unlimited scope
-       return false
-}
-
-// isSystemAuth returns true if the given token is allowed to perform
-// system level actions like deleting data.
-func (rtr *router) isSystemAuth(token string) bool {
-       return token != "" && token == rtr.cluster.SystemRootToken
-}
diff --git a/services/keepstore/hashcheckwriter.go b/services/keepstore/hashcheckwriter.go
new file mode 100644 (file)
index 0000000..f191c98
--- /dev/null
@@ -0,0 +1,68 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package keepstore
+
+import (
+       "fmt"
+       "hash"
+       "io"
+)
+
+type hashCheckWriter struct {
+       writer       io.Writer
+       hash         hash.Hash
+       expectSize   int64
+       expectDigest string
+
+       offset int64
+}
+
+// newHashCheckWriter returns a writer that writes through to w, but
+// stops short if the written content reaches expectSize bytes and
+// does not match expectDigest according to the given hash
+// function.
+//
+// It returns a write error if more than expectSize bytes are written.
+//
+// Thus, in case of a hash mismatch, fewer than expectSize will be
+// written through.
+func newHashCheckWriter(writer io.Writer, hash hash.Hash, expectSize int64, expectDigest string) io.Writer {
+       return &hashCheckWriter{
+               writer:       writer,
+               hash:         hash,
+               expectSize:   expectSize,
+               expectDigest: expectDigest,
+       }
+}
+
+func (hcw *hashCheckWriter) Write(p []byte) (int, error) {
+       if todo := hcw.expectSize - hcw.offset - int64(len(p)); todo < 0 {
+               // Writing beyond expected size returns a checksum
+               // error without even checking the hash.
+               return 0, errChecksum
+       } else if todo > 0 {
+               // This isn't the last write, so we pass it through.
+               _, err := hcw.hash.Write(p)
+               if err != nil {
+                       return 0, err
+               }
+               n, err := hcw.writer.Write(p)
+               hcw.offset += int64(n)
+               return n, err
+       } else {
+               // This is the last write, so we check the hash before
+               // writing through.
+               _, err := hcw.hash.Write(p)
+               if err != nil {
+                       return 0, err
+               }
+               if digest := fmt.Sprintf("%x", hcw.hash.Sum(nil)); digest != hcw.expectDigest {
+                       return 0, errChecksum
+               }
+               // Ensure subsequent write will fail
+               hcw.offset = hcw.expectSize + 1
+               return hcw.writer.Write(p)
+       }
+}
index b9dbe2777e5d3ccca440b1c7632e6a344b83fa35..60d062e1e3467b113a137126296f983f969a9f01 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+// Package keepstore implements the keepstore service component and
+// back-end storage drivers.
+//
+// It is an internal module, only intended to be imported by
+// /cmd/arvados-server and other server-side components in this
+// repository.
 package keepstore
 
 import (
+       "bytes"
+       "context"
+       "crypto/md5"
+       "errors"
+       "fmt"
+       "io"
+       "net/http"
+       "os"
+       "sort"
+       "strconv"
+       "strings"
+       "sync"
+       "sync/atomic"
        "time"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "git.arvados.org/arvados.git/sdk/go/keepclient"
+       "github.com/prometheus/client_golang/prometheus"
+       "github.com/sirupsen/logrus"
+)
+
+// Maximum size of a keep block is 64 MiB.
+const BlockSize = 1 << 26
+
+var (
+       errChecksum          = httpserver.ErrorWithStatus(errors.New("checksum mismatch in stored data"), http.StatusBadGateway)
+       errNoTokenProvided   = httpserver.ErrorWithStatus(errors.New("no token provided in Authorization header"), http.StatusUnauthorized)
+       errMethodNotAllowed  = httpserver.ErrorWithStatus(errors.New("method not allowed"), http.StatusMethodNotAllowed)
+       errVolumeUnavailable = httpserver.ErrorWithStatus(errors.New("volume unavailable"), http.StatusServiceUnavailable)
+       errCollision         = httpserver.ErrorWithStatus(errors.New("hash collision"), http.StatusInternalServerError)
+       errExpiredSignature  = httpserver.ErrorWithStatus(errors.New("expired signature"), http.StatusUnauthorized)
+       errInvalidSignature  = httpserver.ErrorWithStatus(errors.New("invalid signature"), http.StatusBadRequest)
+       errInvalidLocator    = httpserver.ErrorWithStatus(errors.New("invalid locator"), http.StatusBadRequest)
+       errFull              = httpserver.ErrorWithStatus(errors.New("insufficient storage"), http.StatusInsufficientStorage)
+       errTooLarge          = httpserver.ErrorWithStatus(errors.New("request entity too large"), http.StatusRequestEntityTooLarge)
+       driver               = make(map[string]volumeDriver)
 )
 
-// BlockSize for a Keep "block" is 64MB.
-const BlockSize = 64 * 1024 * 1024
+type indexOptions struct {
+       MountUUID string
+       Prefix    string
+       WriteTo   io.Writer
+}
+
+type mount struct {
+       arvados.KeepMount
+       volume
+       priority int
+}
+
+type keepstore struct {
+       cluster    *arvados.Cluster
+       logger     logrus.FieldLogger
+       serviceURL arvados.URL
+       mounts     map[string]*mount
+       mountsR    []*mount
+       mountsW    []*mount
+       bufferPool *bufferPool
+
+       iostats map[volume]*ioStats
+
+       remoteClients    map[string]*keepclient.KeepClient
+       remoteClientsMtx sync.Mutex
+}
+
+func newKeepstore(ctx context.Context, cluster *arvados.Cluster, token string, reg *prometheus.Registry, serviceURL arvados.URL) (*keepstore, error) {
+       logger := ctxlog.FromContext(ctx)
+
+       if cluster.API.MaxConcurrentRequests > 0 && cluster.API.MaxConcurrentRequests < cluster.API.MaxKeepBlobBuffers {
+               logger.Warnf("Possible configuration mistake: not useful to set API.MaxKeepBlobBuffers (%d) higher than API.MaxConcurrentRequests (%d)", cluster.API.MaxKeepBlobBuffers, cluster.API.MaxConcurrentRequests)
+       }
+
+       if cluster.Collections.BlobSigningKey != "" {
+       } else if cluster.Collections.BlobSigning {
+               return nil, errors.New("cannot enable Collections.BlobSigning with no Collections.BlobSigningKey")
+       } else {
+               logger.Warn("Running without a blob signing key. Block locators returned by this server will not be signed, and will be rejected by a server that enforces permissions. To fix this, configure Collections.BlobSigning and Collections.BlobSigningKey.")
+       }
+
+       if cluster.API.MaxKeepBlobBuffers <= 0 {
+               return nil, fmt.Errorf("API.MaxKeepBlobBuffers must be greater than zero")
+       }
+       bufferPool := newBufferPool(logger, cluster.API.MaxKeepBlobBuffers, reg)
+
+       ks := &keepstore{
+               cluster:       cluster,
+               logger:        logger,
+               serviceURL:    serviceURL,
+               bufferPool:    bufferPool,
+               remoteClients: make(map[string]*keepclient.KeepClient),
+       }
+
+       err := ks.setupMounts(newVolumeMetricsVecs(reg))
+       if err != nil {
+               return nil, err
+       }
+
+       return ks, nil
+}
+
+func (ks *keepstore) setupMounts(metrics *volumeMetricsVecs) error {
+       ks.mounts = make(map[string]*mount)
+       if len(ks.cluster.Volumes) == 0 {
+               return errors.New("no volumes configured")
+       }
+       for uuid, cfgvol := range ks.cluster.Volumes {
+               va, ok := cfgvol.AccessViaHosts[ks.serviceURL]
+               if !ok && len(cfgvol.AccessViaHosts) > 0 {
+                       continue
+               }
+               dri, ok := driver[cfgvol.Driver]
+               if !ok {
+                       return fmt.Errorf("volume %s: invalid driver %q", uuid, cfgvol.Driver)
+               }
+               vol, err := dri(newVolumeParams{
+                       UUID:         uuid,
+                       Cluster:      ks.cluster,
+                       ConfigVolume: cfgvol,
+                       Logger:       ks.logger,
+                       MetricsVecs:  metrics,
+                       BufferPool:   ks.bufferPool,
+               })
+               if err != nil {
+                       return fmt.Errorf("error initializing volume %s: %s", uuid, err)
+               }
+               sc := cfgvol.StorageClasses
+               if len(sc) == 0 {
+                       sc = map[string]bool{"default": true}
+               }
+               repl := cfgvol.Replication
+               if repl < 1 {
+                       repl = 1
+               }
+               pri := 0
+               for class, in := range cfgvol.StorageClasses {
+                       p := ks.cluster.StorageClasses[class].Priority
+                       if in && p > pri {
+                               pri = p
+                       }
+               }
+               mnt := &mount{
+                       volume:   vol,
+                       priority: pri,
+                       KeepMount: arvados.KeepMount{
+                               UUID:           uuid,
+                               DeviceID:       vol.DeviceID(),
+                               AllowWrite:     !va.ReadOnly && !cfgvol.ReadOnly,
+                               AllowTrash:     !va.ReadOnly && (!cfgvol.ReadOnly || cfgvol.AllowTrashWhenReadOnly),
+                               Replication:    repl,
+                               StorageClasses: sc,
+                       },
+               }
+               ks.mounts[uuid] = mnt
+               ks.logger.Printf("started volume %s (%s), AllowWrite=%v, AllowTrash=%v", uuid, vol.DeviceID(), mnt.AllowWrite, mnt.AllowTrash)
+       }
+       if len(ks.mounts) == 0 {
+               return fmt.Errorf("no volumes configured for %s", ks.serviceURL)
+       }
+
+       ks.mountsR = nil
+       ks.mountsW = nil
+       for _, mnt := range ks.mounts {
+               ks.mountsR = append(ks.mountsR, mnt)
+               if mnt.AllowWrite {
+                       ks.mountsW = append(ks.mountsW, mnt)
+               }
+       }
+       // Sorting mounts by UUID makes behavior more predictable, and
+       // is convenient for testing -- for example, "index all
+       // volumes" and "trash block on all volumes" will visit
+       // volumes in predictable order.
+       sort.Slice(ks.mountsR, func(i, j int) bool { return ks.mountsR[i].UUID < ks.mountsR[j].UUID })
+       sort.Slice(ks.mountsW, func(i, j int) bool { return ks.mountsW[i].UUID < ks.mountsW[j].UUID })
+       return nil
+}
+
+// checkLocatorSignature checks that locator has a valid signature.
+// If the BlobSigning config is false, it returns nil even if the
+// signature is invalid or missing.
+func (ks *keepstore) checkLocatorSignature(ctx context.Context, locator string) error {
+       if !ks.cluster.Collections.BlobSigning {
+               return nil
+       }
+       token := ctxToken(ctx)
+       if token == "" {
+               return errNoTokenProvided
+       }
+       err := arvados.VerifySignature(locator, token, ks.cluster.Collections.BlobSigningTTL.Duration(), []byte(ks.cluster.Collections.BlobSigningKey))
+       if err == arvados.ErrSignatureExpired {
+               return errExpiredSignature
+       } else if err != nil {
+               return errInvalidSignature
+       }
+       return nil
+}
 
-// MinFreeKilobytes is the amount of space a Keep volume must have available
-// in order to permit writes.
-const MinFreeKilobytes = BlockSize / 1024
+// signLocator signs the locator for the given token, if possible.
+// Note this signs if the BlobSigningKey config is available, even if
+// the BlobSigning config is false.
+func (ks *keepstore) signLocator(token, locator string) string {
+       if token == "" || len(ks.cluster.Collections.BlobSigningKey) == 0 {
+               return locator
+       }
+       ttl := ks.cluster.Collections.BlobSigningTTL.Duration()
+       return arvados.SignLocator(locator, token, time.Now().Add(ttl), ttl, []byte(ks.cluster.Collections.BlobSigningKey))
+}
+
+func (ks *keepstore) BlockRead(ctx context.Context, opts arvados.BlockReadOptions) (n int, err error) {
+       li, err := getLocatorInfo(opts.Locator)
+       if err != nil {
+               return 0, err
+       }
+       out := opts.WriteTo
+       if rw, ok := out.(http.ResponseWriter); ok && li.size > 0 {
+               out = &setSizeOnWrite{ResponseWriter: rw, size: li.size}
+       }
+       if li.remote && !li.signed {
+               return ks.blockReadRemote(ctx, opts)
+       }
+       if err := ks.checkLocatorSignature(ctx, opts.Locator); err != nil {
+               return 0, err
+       }
+       hashcheck := md5.New()
+       if li.size > 0 {
+               out = newHashCheckWriter(out, hashcheck, int64(li.size), li.hash)
+       } else {
+               out = io.MultiWriter(out, hashcheck)
+       }
+
+       buf, err := ks.bufferPool.GetContext(ctx)
+       if err != nil {
+               return 0, err
+       }
+       defer ks.bufferPool.Put(buf)
+       streamer := newStreamWriterAt(out, 65536, buf)
+       defer streamer.Close()
 
-var bufs *bufferPool
+       var errToCaller error = os.ErrNotExist
+       for _, mnt := range ks.rendezvous(li.hash, ks.mountsR) {
+               if ctx.Err() != nil {
+                       return 0, ctx.Err()
+               }
+               err := mnt.BlockRead(ctx, li.hash, streamer)
+               if err != nil {
+                       if streamer.WroteAt() != 0 {
+                               // BlockRead encountered an error
+                               // after writing some data, so it's
+                               // too late to try another
+                               // volume. Flush streamer before
+                               // calling Wrote() to ensure our
+                               // return value accurately reflects
+                               // the number of bytes written to
+                               // opts.WriteTo.
+                               streamer.Close()
+                               return streamer.Wrote(), err
+                       }
+                       if !os.IsNotExist(err) {
+                               errToCaller = err
+                       }
+                       continue
+               }
+               if li.size == 0 {
+                       // hashCheckingWriter isn't in use because we
+                       // don't know the expected size. All we can do
+                       // is check after writing all the data, and
+                       // trust the caller is doing a HEAD request so
+                       // it's not too late to set an error code in
+                       // the response header.
+                       err = streamer.Close()
+                       if hash := fmt.Sprintf("%x", hashcheck.Sum(nil)); hash != li.hash && err == nil {
+                               err = errChecksum
+                       }
+                       if rw, ok := opts.WriteTo.(http.ResponseWriter); ok {
+                               // We didn't set the content-length header
+                               // above because we didn't know the block size
+                               // until now.
+                               rw.Header().Set("Content-Length", fmt.Sprintf("%d", streamer.WroteAt()))
+                       }
+                       return streamer.WroteAt(), err
+               } else if streamer.WroteAt() != li.size {
+                       // If the backend read fewer bytes than
+                       // expected but returns no error, we can
+                       // classify this as a checksum error (even
+                       // though hashCheckWriter doesn't know that
+                       // yet, it's just waiting for the next
+                       // write). If our caller is serving a GET
+                       // request it's too late to do anything about
+                       // it anyway, but if it's a HEAD request the
+                       // caller can still change the response status
+                       // code.
+                       return streamer.WroteAt(), errChecksum
+               }
+               // Ensure streamer flushes all buffered data without
+               // errors.
+               err = streamer.Close()
+               return streamer.Wrote(), err
+       }
+       return 0, errToCaller
+}
+
+func (ks *keepstore) blockReadRemote(ctx context.Context, opts arvados.BlockReadOptions) (int, error) {
+       token := ctxToken(ctx)
+       if token == "" {
+               return 0, errNoTokenProvided
+       }
+       var remoteClient *keepclient.KeepClient
+       var parts []string
+       li, err := getLocatorInfo(opts.Locator)
+       if err != nil {
+               return 0, err
+       }
+       for i, part := range strings.Split(opts.Locator, "+") {
+               switch {
+               case i == 0:
+                       // don't try to parse hash part as hint
+               case strings.HasPrefix(part, "A"):
+                       // drop local permission hint
+                       continue
+               case len(part) > 7 && part[0] == 'R' && part[6] == '-':
+                       remoteID := part[1:6]
+                       remote, ok := ks.cluster.RemoteClusters[remoteID]
+                       if !ok {
+                               return 0, httpserver.ErrorWithStatus(errors.New("remote cluster not configured"), http.StatusBadRequest)
+                       }
+                       kc, err := ks.remoteClient(remoteID, remote, token)
+                       if err == auth.ErrObsoleteToken {
+                               return 0, httpserver.ErrorWithStatus(err, http.StatusBadRequest)
+                       } else if err != nil {
+                               return 0, err
+                       }
+                       remoteClient = kc
+                       part = "A" + part[7:]
+               }
+               parts = append(parts, part)
+       }
+       if remoteClient == nil {
+               return 0, httpserver.ErrorWithStatus(errors.New("invalid remote hint"), http.StatusBadRequest)
+       }
+       locator := strings.Join(parts, "+")
+       if opts.LocalLocator == nil {
+               // Read from remote cluster and stream response back
+               // to caller
+               if rw, ok := opts.WriteTo.(http.ResponseWriter); ok && li.size > 0 {
+                       rw.Header().Set("Content-Length", fmt.Sprintf("%d", li.size))
+               }
+               return remoteClient.BlockRead(ctx, arvados.BlockReadOptions{
+                       Locator: locator,
+                       WriteTo: opts.WriteTo,
+               })
+       }
+       // We must call LocalLocator before writing any data to
+       // opts.WriteTo, otherwise the caller can't put the local
+       // locator in a response header.  So we copy into memory,
+       // generate the local signature, then copy from memory to
+       // opts.WriteTo.
+       buf, err := ks.bufferPool.GetContext(ctx)
+       if err != nil {
+               return 0, err
+       }
+       defer ks.bufferPool.Put(buf)
+       writebuf := bytes.NewBuffer(buf[:0])
+       ks.logger.Infof("blockReadRemote(%s): remote read(%s)", opts.Locator, locator)
+       _, err = remoteClient.BlockRead(ctx, arvados.BlockReadOptions{
+               Locator: locator,
+               WriteTo: writebuf,
+       })
+       if err != nil {
+               return 0, err
+       }
+       resp, err := ks.BlockWrite(ctx, arvados.BlockWriteOptions{
+               Hash: locator,
+               Data: writebuf.Bytes(),
+       })
+       if err != nil {
+               return 0, err
+       }
+       opts.LocalLocator(resp.Locator)
+       if rw, ok := opts.WriteTo.(http.ResponseWriter); ok {
+               rw.Header().Set("Content-Length", fmt.Sprintf("%d", writebuf.Len()))
+       }
+       n, err := io.Copy(opts.WriteTo, bytes.NewReader(writebuf.Bytes()))
+       return int(n), err
+}
 
-// KeepError types.
+func (ks *keepstore) remoteClient(remoteID string, remoteCluster arvados.RemoteCluster, token string) (*keepclient.KeepClient, error) {
+       ks.remoteClientsMtx.Lock()
+       kc, ok := ks.remoteClients[remoteID]
+       ks.remoteClientsMtx.Unlock()
+       if !ok {
+               c := &arvados.Client{
+                       APIHost:   remoteCluster.Host,
+                       AuthToken: "xxx",
+                       Insecure:  remoteCluster.Insecure,
+               }
+               ac, err := arvadosclient.New(c)
+               if err != nil {
+                       return nil, err
+               }
+               kc, err = keepclient.MakeKeepClient(ac)
+               if err != nil {
+                       return nil, err
+               }
+               kc.DiskCacheSize = keepclient.DiskCacheDisabled
+
+               ks.remoteClientsMtx.Lock()
+               ks.remoteClients[remoteID] = kc
+               ks.remoteClientsMtx.Unlock()
+       }
+       accopy := *kc.Arvados
+       accopy.ApiToken = token
+       kccopy := kc.Clone()
+       kccopy.Arvados = &accopy
+       token, err := auth.SaltToken(token, remoteID)
+       if err != nil {
+               return nil, err
+       }
+       kccopy.Arvados.ApiToken = token
+       return kccopy, nil
+}
+
+// BlockWrite writes a block to one or more volumes.
+func (ks *keepstore) BlockWrite(ctx context.Context, opts arvados.BlockWriteOptions) (arvados.BlockWriteResponse, error) {
+       var resp arvados.BlockWriteResponse
+       var hash string
+       if opts.Data == nil {
+               buf, err := ks.bufferPool.GetContext(ctx)
+               if err != nil {
+                       return resp, err
+               }
+               defer ks.bufferPool.Put(buf)
+               w := bytes.NewBuffer(buf[:0])
+               h := md5.New()
+               limitedReader := &io.LimitedReader{R: opts.Reader, N: BlockSize}
+               n, err := io.Copy(io.MultiWriter(w, h), limitedReader)
+               if err != nil {
+                       return resp, err
+               }
+               if limitedReader.N == 0 {
+                       // Data size is either exactly BlockSize, or too big.
+                       n, err := opts.Reader.Read(make([]byte, 1))
+                       if n > 0 {
+                               return resp, httpserver.ErrorWithStatus(err, http.StatusRequestEntityTooLarge)
+                       }
+                       if err != io.EOF {
+                               return resp, err
+                       }
+               }
+               opts.Data = buf[:n]
+               if opts.DataSize != 0 && int(n) != opts.DataSize {
+                       return resp, httpserver.ErrorWithStatus(fmt.Errorf("content length %d did not match specified data size %d", n, opts.DataSize), http.StatusBadRequest)
+               }
+               hash = fmt.Sprintf("%x", h.Sum(nil))
+       } else {
+               hash = fmt.Sprintf("%x", md5.Sum(opts.Data))
+       }
+       if opts.Hash != "" && !strings.HasPrefix(opts.Hash, hash) {
+               return resp, httpserver.ErrorWithStatus(fmt.Errorf("content hash %s did not match specified locator %s", hash, opts.Hash), http.StatusBadRequest)
+       }
+       rvzmounts := ks.rendezvous(hash, ks.mountsW)
+       result := newPutProgress(opts.StorageClasses)
+       for _, mnt := range rvzmounts {
+               if !result.Want(mnt) {
+                       continue
+               }
+               cmp := &checkEqual{Expect: opts.Data}
+               if err := mnt.BlockRead(ctx, hash, cmp); err == nil {
+                       if !cmp.Equal() {
+                               return resp, errCollision
+                       }
+                       err := mnt.BlockTouch(hash)
+                       if err == nil {
+                               result.Add(mnt)
+                       }
+               }
+       }
+       var allFull atomic.Bool
+       allFull.Store(true)
+       // pending tracks what result will be if all outstanding
+       // writes succeed.
+       pending := result.Copy()
+       cond := sync.NewCond(new(sync.Mutex))
+       cond.L.Lock()
+       var wg sync.WaitGroup
+nextmnt:
+       for _, mnt := range rvzmounts {
+               for {
+                       if result.Done() || ctx.Err() != nil {
+                               break nextmnt
+                       }
+                       if !result.Want(mnt) {
+                               continue nextmnt
+                       }
+                       if pending.Want(mnt) {
+                               break
+                       }
+                       // This mount might not be needed, depending
+                       // on the outcome of pending writes. Wait for
+                       // a pending write to finish, then check
+                       // again.
+                       cond.Wait()
+               }
+               mnt := mnt
+               logger := ks.logger.WithField("mount", mnt.UUID)
+               pending.Add(mnt)
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       logger.Debug("start write")
+                       err := mnt.BlockWrite(ctx, hash, opts.Data)
+                       cond.L.Lock()
+                       defer cond.L.Unlock()
+                       defer cond.Broadcast()
+                       if err != nil {
+                               logger.Debug("write failed")
+                               pending.Sub(mnt)
+                               if err != errFull {
+                                       allFull.Store(false)
+                               }
+                       } else {
+                               result.Add(mnt)
+                               pending.Sub(mnt)
+                       }
+               }()
+       }
+       cond.L.Unlock()
+       wg.Wait()
+       if ctx.Err() != nil {
+               return resp, ctx.Err()
+       }
+       if result.Done() || result.totalReplication > 0 {
+               resp = arvados.BlockWriteResponse{
+                       Locator:        ks.signLocator(ctxToken(ctx), fmt.Sprintf("%s+%d", hash, len(opts.Data))),
+                       Replicas:       result.totalReplication,
+                       StorageClasses: result.classDone,
+               }
+               return resp, nil
+       }
+       if allFull.Load() {
+               return resp, errFull
+       }
+       return resp, errVolumeUnavailable
+}
+
+// rendezvous sorts the given mounts by descending priority, then by
+// rendezvous order for the given locator.
+func (*keepstore) rendezvous(locator string, mnts []*mount) []*mount {
+       hash := locator
+       if len(hash) > 32 {
+               hash = hash[:32]
+       }
+       // copy the provided []*mount before doing an in-place sort
+       mnts = append([]*mount(nil), mnts...)
+       weight := make(map[*mount]string)
+       for _, mnt := range mnts {
+               uuidpart := mnt.UUID
+               if len(uuidpart) == 27 {
+                       // strip zzzzz-yyyyy- prefixes
+                       uuidpart = uuidpart[12:]
+               }
+               weight[mnt] = fmt.Sprintf("%x", md5.Sum([]byte(hash+uuidpart)))
+       }
+       sort.Slice(mnts, func(i, j int) bool {
+               if p := mnts[i].priority - mnts[j].priority; p != 0 {
+                       return p > 0
+               }
+               return weight[mnts[i]] < weight[mnts[j]]
+       })
+       return mnts
+}
+
+// checkEqual reports whether the data written to it (via io.WriterAt
+// interface) is equal to the expected data.
 //
-type KeepError struct {
-       HTTPCode int
-       ErrMsg   string
+// Expect should not be changed after the first Write.
+//
+// Results are undefined if WriteAt is called with overlapping ranges.
+type checkEqual struct {
+       Expect   []byte
+       equal    atomic.Int64
+       notequal atomic.Bool
 }
 
-var (
-       BadRequestError     = &KeepError{400, "Bad Request"}
-       UnauthorizedError   = &KeepError{401, "Unauthorized"}
-       CollisionError      = &KeepError{500, "Collision"}
-       RequestHashError    = &KeepError{422, "Hash mismatch in request"}
-       PermissionError     = &KeepError{403, "Forbidden"}
-       DiskHashError       = &KeepError{500, "Hash mismatch in stored data"}
-       ExpiredError        = &KeepError{401, "Expired permission signature"}
-       NotFoundError       = &KeepError{404, "Not Found"}
-       VolumeBusyError     = &KeepError{503, "Volume backend busy"}
-       GenericError        = &KeepError{500, "Fail"}
-       FullError           = &KeepError{503, "Full"}
-       SizeRequiredError   = &KeepError{411, "Missing Content-Length"}
-       TooLongError        = &KeepError{413, "Block is too large"}
-       MethodDisabledError = &KeepError{405, "Method disabled"}
-       ErrNotImplemented   = &KeepError{500, "Unsupported configuration"}
-       ErrClientDisconnect = &KeepError{503, "Client disconnected"}
-)
+func (ce *checkEqual) Equal() bool {
+       return !ce.notequal.Load() && ce.equal.Load() == int64(len(ce.Expect))
+}
 
-func (e *KeepError) Error() string {
-       return e.ErrMsg
+func (ce *checkEqual) WriteAt(p []byte, offset int64) (int, error) {
+       endpos := int(offset) + len(p)
+       if offset >= 0 && endpos <= len(ce.Expect) && bytes.Equal(p, ce.Expect[int(offset):endpos]) {
+               ce.equal.Add(int64(len(p)))
+       } else {
+               ce.notequal.Store(true)
+       }
+       return len(p), nil
 }
 
-// Periodically (once per interval) invoke EmptyTrash on all volumes.
-func emptyTrash(mounts []*VolumeMount, interval time.Duration) {
-       for range time.NewTicker(interval).C {
-               for _, v := range mounts {
-                       v.EmptyTrash()
+func (ks *keepstore) BlockUntrash(ctx context.Context, locator string) error {
+       li, err := getLocatorInfo(locator)
+       if err != nil {
+               return err
+       }
+       var errToCaller error = os.ErrNotExist
+       for _, mnt := range ks.mountsW {
+               if ctx.Err() != nil {
+                       return ctx.Err()
+               }
+               err := mnt.BlockUntrash(li.hash)
+               if err == nil {
+                       errToCaller = nil
+               } else if !os.IsNotExist(err) && errToCaller != nil {
+                       errToCaller = err
+               }
+       }
+       return errToCaller
+}
+
+func (ks *keepstore) BlockTouch(ctx context.Context, locator string) error {
+       li, err := getLocatorInfo(locator)
+       if err != nil {
+               return err
+       }
+       var errToCaller error = os.ErrNotExist
+       for _, mnt := range ks.mountsW {
+               if ctx.Err() != nil {
+                       return ctx.Err()
+               }
+               err := mnt.BlockTouch(li.hash)
+               if err == nil {
+                       return nil
+               }
+               if !os.IsNotExist(err) {
+                       errToCaller = err
+               }
+       }
+       return errToCaller
+}
+
+func (ks *keepstore) BlockTrash(ctx context.Context, locator string) error {
+       if !ks.cluster.Collections.BlobTrash {
+               return errMethodNotAllowed
+       }
+       li, err := getLocatorInfo(locator)
+       if err != nil {
+               return err
+       }
+       var errToCaller error = os.ErrNotExist
+       for _, mnt := range ks.mounts {
+               if !mnt.AllowTrash {
+                       continue
+               }
+               if ctx.Err() != nil {
+                       return ctx.Err()
+               }
+               t, err := mnt.Mtime(li.hash)
+               if err == nil && time.Now().Sub(t) > ks.cluster.Collections.BlobSigningTTL.Duration() {
+                       err = mnt.BlockTrash(li.hash)
+               }
+               if os.IsNotExist(errToCaller) || (errToCaller == nil && !os.IsNotExist(err)) {
+                       errToCaller = err
+               }
+       }
+       return errToCaller
+}
+
+func (ks *keepstore) Mounts() []*mount {
+       return ks.mountsR
+}
+
+func (ks *keepstore) Index(ctx context.Context, opts indexOptions) error {
+       mounts := ks.mountsR
+       if opts.MountUUID != "" {
+               mnt, ok := ks.mounts[opts.MountUUID]
+               if !ok {
+                       return os.ErrNotExist
+               }
+               mounts = []*mount{mnt}
+       }
+       for _, mnt := range mounts {
+               err := mnt.Index(ctx, opts.Prefix, opts.WriteTo)
+               if err != nil {
+                       return err
+               }
+       }
+       return nil
+}
+
+func ctxToken(ctx context.Context) string {
+       if c, ok := auth.FromContext(ctx); ok && len(c.Tokens) > 0 {
+               return c.Tokens[0]
+       } else {
+               return ""
+       }
+}
+
+// locatorInfo expresses the attributes of a locator that are relevant
+// for keepstore decision-making.
+type locatorInfo struct {
+       hash   string
+       size   int
+       remote bool // locator has a +R hint
+       signed bool // locator has a +A hint
+}
+
+func getLocatorInfo(loc string) (locatorInfo, error) {
+       var li locatorInfo
+       plus := 0    // number of '+' chars seen so far
+       partlen := 0 // chars since last '+'
+       for i, c := range loc + "+" {
+               if c == '+' {
+                       if partlen == 0 {
+                               // double/leading/trailing '+'
+                               return li, errInvalidLocator
+                       }
+                       if plus == 0 {
+                               if i != 32 {
+                                       return li, errInvalidLocator
+                               }
+                               li.hash = loc[:i]
+                       }
+                       if plus == 1 {
+                               if size, err := strconv.Atoi(loc[i-partlen : i]); err == nil {
+                                       li.size = size
+                               }
+                       }
+                       plus++
+                       partlen = 0
+                       continue
+               }
+               partlen++
+               if partlen == 1 {
+                       if c == 'A' {
+                               li.signed = true
+                       }
+                       if c == 'R' {
+                               li.remote = true
+                       }
+                       if plus > 1 && c >= '0' && c <= '9' {
+                               // size, if present at all, must come first
+                               return li, errInvalidLocator
+                       }
+               }
+               if plus == 0 && !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
+                       // non-hexadecimal char in hash part
+                       return li, errInvalidLocator
                }
        }
+       return li, nil
 }
diff --git a/services/keepstore/keepstore_test.go b/services/keepstore/keepstore_test.go
new file mode 100644 (file)
index 0000000..f9d9888
--- /dev/null
@@ -0,0 +1,892 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package keepstore
+
+import (
+       "bytes"
+       "context"
+       "crypto/md5"
+       "errors"
+       "fmt"
+       "io"
+       "net/http"
+       "os"
+       "sort"
+       "strings"
+       "sync"
+       "testing"
+       "time"
+
+       "git.arvados.org/arvados.git/lib/config"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "github.com/prometheus/client_golang/prometheus"
+       . "gopkg.in/check.v1"
+)
+
+func TestGocheck(t *testing.T) {
+       TestingT(t)
+}
+
+const (
+       fooHash = "acbd18db4cc2f85cedef654fccc4a4d8"
+       barHash = "37b51d194a7513e45b56f6524f2d51f2"
+)
+
+var testServiceURL = func() arvados.URL {
+       return arvados.URL{Host: "localhost:12345", Scheme: "http"}
+}()
+
+func authContext(token string) context.Context {
+       return auth.NewContext(context.TODO(), &auth.Credentials{Tokens: []string{token}})
+}
+
+func testCluster(t TB) *arvados.Cluster {
+       cfg, err := config.NewLoader(bytes.NewBufferString("Clusters: {zzzzz: {}}"), ctxlog.TestLogger(t)).Load()
+       if err != nil {
+               t.Fatal(err)
+       }
+       cluster, err := cfg.GetCluster("")
+       if err != nil {
+               t.Fatal(err)
+       }
+       cluster.SystemRootToken = arvadostest.SystemRootToken
+       cluster.ManagementToken = arvadostest.ManagementToken
+       return cluster
+}
+
+func testKeepstore(t TB, cluster *arvados.Cluster, reg *prometheus.Registry) (*keepstore, context.CancelFunc) {
+       if reg == nil {
+               reg = prometheus.NewRegistry()
+       }
+       ctx, cancel := context.WithCancel(context.Background())
+       ctx = ctxlog.Context(ctx, ctxlog.TestLogger(t))
+       ks, err := newKeepstore(ctx, cluster, cluster.SystemRootToken, reg, testServiceURL)
+       if err != nil {
+               t.Fatal(err)
+       }
+       return ks, cancel
+}
+
+var _ = Suite(&keepstoreSuite{})
+
+type keepstoreSuite struct {
+       cluster *arvados.Cluster
+}
+
+func (s *keepstoreSuite) SetUpTest(c *C) {
+       s.cluster = testCluster(c)
+       s.cluster.Volumes = map[string]arvados.Volume{
+               "zzzzz-nyw5e-000000000000000": {Replication: 1, Driver: "stub"},
+               "zzzzz-nyw5e-111111111111111": {Replication: 1, Driver: "stub"},
+       }
+}
+
+func (s *keepstoreSuite) TestBlockRead_ChecksumMismatch(c *C) {
+       ks, cancel := testKeepstore(c, s.cluster, nil)
+       defer cancel()
+
+       ctx := authContext(arvadostest.ActiveTokenV2)
+
+       fooHash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       err := ks.mountsW[0].BlockWrite(ctx, fooHash, []byte("bar"))
+       c.Assert(err, IsNil)
+
+       _, err = ks.BlockWrite(ctx, arvados.BlockWriteOptions{
+               Hash: fooHash,
+               Data: []byte("foo"),
+       })
+       c.Check(err, ErrorMatches, "hash collision")
+
+       buf := bytes.NewBuffer(nil)
+       _, err = ks.BlockRead(ctx, arvados.BlockReadOptions{
+               Locator: ks.signLocator(arvadostest.ActiveTokenV2, fooHash+"+3"),
+               WriteTo: buf,
+       })
+       c.Check(err, ErrorMatches, "checksum mismatch in stored data")
+       c.Check(buf.String(), Not(Equals), "foo")
+       c.Check(buf.Len() < 3, Equals, true)
+
+       err = ks.mountsW[1].BlockWrite(ctx, fooHash, []byte("foo"))
+       c.Assert(err, IsNil)
+
+       buf = bytes.NewBuffer(nil)
+       _, err = ks.BlockRead(ctx, arvados.BlockReadOptions{
+               Locator: ks.signLocator(arvadostest.ActiveTokenV2, fooHash+"+3"),
+               WriteTo: buf,
+       })
+       c.Check(err, ErrorMatches, "checksum mismatch in stored data")
+       c.Check(buf.Len() < 3, Equals, true)
+}
+
+func (s *keepstoreSuite) TestBlockReadWrite_SigningDisabled(c *C) {
+       origKey := s.cluster.Collections.BlobSigningKey
+       s.cluster.Collections.BlobSigning = false
+       s.cluster.Collections.BlobSigningKey = ""
+       ks, cancel := testKeepstore(c, s.cluster, nil)
+       defer cancel()
+
+       resp, err := ks.BlockWrite(authContext("abcde"), arvados.BlockWriteOptions{
+               Hash: fooHash,
+               Data: []byte("foo"),
+       })
+       c.Assert(err, IsNil)
+       c.Check(resp.Locator, Equals, fooHash+"+3")
+       locUnsigned := resp.Locator
+       ttl := time.Hour
+       locSigned := arvados.SignLocator(locUnsigned, arvadostest.ActiveTokenV2, time.Now().Add(ttl), ttl, []byte(origKey))
+       c.Assert(locSigned, Not(Equals), locUnsigned)
+
+       for _, locator := range []string{locUnsigned, locSigned} {
+               for _, token := range []string{"", "xyzzy", arvadostest.ActiveTokenV2} {
+                       c.Logf("=== locator %q token %q", locator, token)
+                       ctx := authContext(token)
+                       buf := bytes.NewBuffer(nil)
+                       _, err := ks.BlockRead(ctx, arvados.BlockReadOptions{
+                               Locator: locator,
+                               WriteTo: buf,
+                       })
+                       c.Check(err, IsNil)
+                       c.Check(buf.String(), Equals, "foo")
+               }
+       }
+}
+
+func (s *keepstoreSuite) TestBlockRead_OrderedByStorageClassPriority(c *C) {
+       s.cluster.Volumes = map[string]arvados.Volume{
+               "zzzzz-nyw5e-111111111111111": {
+                       Driver:         "stub",
+                       Replication:    1,
+                       StorageClasses: map[string]bool{"class1": true}},
+               "zzzzz-nyw5e-222222222222222": {
+                       Driver:         "stub",
+                       Replication:    1,
+                       StorageClasses: map[string]bool{"class2": true, "class3": true}},
+       }
+
+       // "foobar" is just some data that happens to result in
+       // rendezvous order {111, 222}
+       data := []byte("foobar")
+       hash := fmt.Sprintf("%x", md5.Sum(data))
+
+       for _, trial := range []struct {
+               priority1 int // priority of class1, thus vol1
+               priority2 int // priority of class2
+               priority3 int // priority of class3 (vol2 priority will be max(priority2, priority3))
+               expectLog string
+       }{
+               {100, 50, 50, "111 read 385\n"},              // class1 has higher priority => try vol1 first, no need to try vol2
+               {100, 100, 100, "111 read 385\n"},            // same priority, vol2 is first in rendezvous order => try vol1 first and succeed
+               {66, 99, 33, "222 read 385\n111 read 385\n"}, // class2 has higher priority => try vol2 first, then try vol1
+               {66, 33, 99, "222 read 385\n111 read 385\n"}, // class3 has highest priority => vol2 has highest => try vol2 first, then try vol1
+       } {
+               c.Logf("=== %+v", trial)
+
+               s.cluster.StorageClasses = map[string]arvados.StorageClassConfig{
+                       "class1": {Priority: trial.priority1},
+                       "class2": {Priority: trial.priority2},
+                       "class3": {Priority: trial.priority3},
+               }
+               ks, cancel := testKeepstore(c, s.cluster, nil)
+               defer cancel()
+
+               ctx := authContext(arvadostest.ActiveTokenV2)
+               resp, err := ks.BlockWrite(ctx, arvados.BlockWriteOptions{
+                       Hash:           hash,
+                       Data:           data,
+                       StorageClasses: []string{"class1"},
+               })
+               c.Assert(err, IsNil)
+
+               // Combine logs into one. (We only want the logs from
+               // the BlockRead below, not from BlockWrite above.)
+               stubLog := &stubLog{}
+               for _, mnt := range ks.mounts {
+                       mnt.volume.(*stubVolume).stubLog = stubLog
+               }
+
+               n, err := ks.BlockRead(ctx, arvados.BlockReadOptions{
+                       Locator: resp.Locator,
+                       WriteTo: io.Discard,
+               })
+               c.Assert(n, Equals, len(data))
+               c.Assert(err, IsNil)
+               c.Check(stubLog.String(), Equals, trial.expectLog)
+       }
+}
+
+func (s *keepstoreSuite) TestBlockWrite_NoWritableVolumes(c *C) {
+       for uuid, v := range s.cluster.Volumes {
+               v.ReadOnly = true
+               s.cluster.Volumes[uuid] = v
+       }
+       ks, cancel := testKeepstore(c, s.cluster, nil)
+       defer cancel()
+       for _, mnt := range ks.mounts {
+               mnt.volume.(*stubVolume).blockWrite = func(context.Context, string, []byte) error {
+                       c.Error("volume BlockWrite called")
+                       return errors.New("fail")
+               }
+       }
+       ctx := authContext(arvadostest.ActiveTokenV2)
+
+       _, err := ks.BlockWrite(ctx, arvados.BlockWriteOptions{
+               Hash: fooHash,
+               Data: []byte("foo")})
+       c.Check(err, NotNil)
+       c.Check(err.(interface{ HTTPStatus() int }).HTTPStatus(), Equals, http.StatusInsufficientStorage)
+}
+
+func (s *keepstoreSuite) TestBlockWrite_MultipleStorageClasses(c *C) {
+       s.cluster.Volumes = map[string]arvados.Volume{
+               "zzzzz-nyw5e-111111111111111": {
+                       Driver:         "stub",
+                       Replication:    1,
+                       StorageClasses: map[string]bool{"class1": true}},
+               "zzzzz-nyw5e-121212121212121": {
+                       Driver:         "stub",
+                       Replication:    1,
+                       StorageClasses: map[string]bool{"class1": true, "class2": true}},
+               "zzzzz-nyw5e-222222222222222": {
+                       Driver:         "stub",
+                       Replication:    1,
+                       StorageClasses: map[string]bool{"class2": true}},
+       }
+
+       // testData is a block that happens to have rendezvous order 111, 121, 222
+       testData := []byte("qux")
+       testHash := fmt.Sprintf("%x+%d", md5.Sum(testData), len(testData))
+
+       s.cluster.StorageClasses = map[string]arvados.StorageClassConfig{
+               "class1": {},
+               "class2": {},
+               "class3": {},
+       }
+
+       ctx := authContext(arvadostest.ActiveTokenV2)
+       for idx, trial := range []struct {
+               classes   string // desired classes
+               expectLog string
+       }{
+               {"class1", "" +
+                       "111 read d85\n" +
+                       "121 read d85\n" +
+                       "111 write d85\n" +
+                       "111 read d85\n" +
+                       "111 touch d85\n"},
+               {"class2", "" +
+                       "121 read d85\n" + // write#1
+                       "222 read d85\n" +
+                       "121 write d85\n" +
+                       "121 read d85\n" + // write#2
+                       "121 touch d85\n"},
+               {"class1,class2", "" +
+                       "111 read d85\n" + // write#1
+                       "121 read d85\n" +
+                       "222 read d85\n" +
+                       "121 write d85\n" +
+                       "111 write d85\n" +
+                       "111 read d85\n" + // write#2
+                       "111 touch d85\n" +
+                       "121 read d85\n" +
+                       "121 touch d85\n"},
+               {"class1,class2,class404", "" +
+                       "111 read d85\n" + // write#1
+                       "121 read d85\n" +
+                       "222 read d85\n" +
+                       "121 write d85\n" +
+                       "111 write d85\n" +
+                       "111 read d85\n" + // write#2
+                       "111 touch d85\n" +
+                       "121 read d85\n" +
+                       "121 touch d85\n"},
+       } {
+               c.Logf("=== %d: %+v", idx, trial)
+
+               ks, cancel := testKeepstore(c, s.cluster, nil)
+               defer cancel()
+               stubLog := &stubLog{}
+               for _, mnt := range ks.mounts {
+                       mnt.volume.(*stubVolume).stubLog = stubLog
+               }
+
+               // Check that we chose the right block data
+               rvz := ks.rendezvous(testHash, ks.mountsW)
+               c.Assert(rvz[0].UUID[24:], Equals, "111")
+               c.Assert(rvz[1].UUID[24:], Equals, "121")
+               c.Assert(rvz[2].UUID[24:], Equals, "222")
+
+               for i := 0; i < 2; i++ {
+                       _, err := ks.BlockWrite(ctx, arvados.BlockWriteOptions{
+                               Hash:           testHash,
+                               Data:           testData,
+                               StorageClasses: strings.Split(trial.classes, ","),
+                       })
+                       c.Check(err, IsNil)
+               }
+               c.Check(stubLog.String(), Equals, trial.expectLog)
+       }
+}
+
+func (s *keepstoreSuite) TestBlockTrash(c *C) {
+       s.cluster.Volumes = map[string]arvados.Volume{
+               "zzzzz-nyw5e-000000000000000": {Replication: 1, Driver: "stub"},
+               "zzzzz-nyw5e-111111111111111": {Replication: 1, Driver: "stub"},
+               "zzzzz-nyw5e-222222222222222": {Replication: 1, Driver: "stub", ReadOnly: true},
+               "zzzzz-nyw5e-333333333333333": {Replication: 1, Driver: "stub", ReadOnly: true, AllowTrashWhenReadOnly: true},
+       }
+       ks, cancel := testKeepstore(c, s.cluster, nil)
+       defer cancel()
+
+       var vol []*stubVolume
+       for _, mount := range ks.mountsR {
+               vol = append(vol, mount.volume.(*stubVolume))
+       }
+       sort.Slice(vol, func(i, j int) bool {
+               return vol[i].params.UUID < vol[j].params.UUID
+       })
+
+       ctx := context.Background()
+       loc := fooHash + "+3"
+       tOld := time.Now().Add(-s.cluster.Collections.BlobSigningTTL.Duration() - time.Second)
+
+       clear := func() {
+               for _, vol := range vol {
+                       err := vol.BlockTrash(fooHash)
+                       if !os.IsNotExist(err) {
+                               c.Assert(err, IsNil)
+                       }
+               }
+       }
+       writeit := func(volidx int) {
+               err := vol[volidx].BlockWrite(ctx, fooHash, []byte("foo"))
+               c.Assert(err, IsNil)
+               err = vol[volidx].blockTouchWithTime(fooHash, tOld)
+               c.Assert(err, IsNil)
+       }
+       trashit := func() error {
+               return ks.BlockTrash(ctx, loc)
+       }
+       checkexists := func(volidx int) bool {
+               err := vol[volidx].BlockRead(ctx, fooHash, brdiscard)
+               if !os.IsNotExist(err) {
+                       c.Check(err, IsNil)
+               }
+               return err == nil
+       }
+
+       clear()
+       c.Check(trashit(), Equals, os.ErrNotExist)
+
+       // one old replica => trash it
+       clear()
+       writeit(0)
+       c.Check(trashit(), IsNil)
+       c.Check(checkexists(0), Equals, false)
+
+       // one old replica + one new replica => keep new, trash old
+       clear()
+       writeit(0)
+       writeit(1)
+       c.Check(vol[1].blockTouchWithTime(fooHash, time.Now()), IsNil)
+       c.Check(trashit(), IsNil)
+       c.Check(checkexists(0), Equals, false)
+       c.Check(checkexists(1), Equals, true)
+
+       // two old replicas => trash both
+       clear()
+       writeit(0)
+       writeit(1)
+       c.Check(trashit(), IsNil)
+       c.Check(checkexists(0), Equals, false)
+       c.Check(checkexists(1), Equals, false)
+
+       // four old replicas => trash all except readonly volume with
+       // AllowTrashWhenReadOnly==false
+       clear()
+       writeit(0)
+       writeit(1)
+       writeit(2)
+       writeit(3)
+       c.Check(trashit(), IsNil)
+       c.Check(checkexists(0), Equals, false)
+       c.Check(checkexists(1), Equals, false)
+       c.Check(checkexists(2), Equals, true)
+       c.Check(checkexists(3), Equals, false)
+
+       // two old replicas but one returns an error => return the
+       // only non-404 backend error
+       clear()
+       vol[0].blockTrash = func(hash string) error {
+               return errors.New("fake error")
+       }
+       writeit(0)
+       writeit(3)
+       c.Check(trashit(), ErrorMatches, "fake error")
+       c.Check(checkexists(0), Equals, true)
+       c.Check(checkexists(1), Equals, false)
+       c.Check(checkexists(2), Equals, false)
+       c.Check(checkexists(3), Equals, false)
+}
+
+func (s *keepstoreSuite) TestBlockWrite_OnlyOneBuffer(c *C) {
+       s.cluster.API.MaxKeepBlobBuffers = 1
+       ks, cancel := testKeepstore(c, s.cluster, nil)
+       defer cancel()
+       ok := make(chan struct{})
+       go func() {
+               defer close(ok)
+               ctx := authContext(arvadostest.ActiveTokenV2)
+               _, err := ks.BlockWrite(ctx, arvados.BlockWriteOptions{
+                       Hash: fooHash,
+                       Data: []byte("foo")})
+               c.Check(err, IsNil)
+       }()
+       select {
+       case <-ok:
+       case <-time.After(time.Second):
+               c.Fatal("PUT deadlocks with MaxKeepBlobBuffers==1")
+       }
+}
+
+func (s *keepstoreSuite) TestBufferPoolLeak(c *C) {
+       s.cluster.API.MaxKeepBlobBuffers = 4
+       ks, cancel := testKeepstore(c, s.cluster, nil)
+       defer cancel()
+
+       ctx := authContext(arvadostest.ActiveTokenV2)
+       var wg sync.WaitGroup
+       for range make([]int, 20) {
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       resp, err := ks.BlockWrite(ctx, arvados.BlockWriteOptions{
+                               Hash: fooHash,
+                               Data: []byte("foo")})
+                       c.Check(err, IsNil)
+                       _, err = ks.BlockRead(ctx, arvados.BlockReadOptions{
+                               Locator: resp.Locator,
+                               WriteTo: io.Discard})
+                       c.Check(err, IsNil)
+               }()
+       }
+       ok := make(chan struct{})
+       go func() {
+               wg.Wait()
+               close(ok)
+       }()
+       select {
+       case <-ok:
+       case <-time.After(time.Second):
+               c.Fatal("read/write sequence deadlocks, likely buffer pool leak")
+       }
+}
+
+func (s *keepstoreSuite) TestPutStorageClasses(c *C) {
+       s.cluster.Volumes = map[string]arvados.Volume{
+               "zzzzz-nyw5e-000000000000000": {Replication: 1, Driver: "stub"}, // "default" is implicit
+               "zzzzz-nyw5e-111111111111111": {Replication: 1, Driver: "stub", StorageClasses: map[string]bool{"special": true, "extra": true}},
+               "zzzzz-nyw5e-222222222222222": {Replication: 1, Driver: "stub", StorageClasses: map[string]bool{"readonly": true}, ReadOnly: true},
+       }
+       ks, cancel := testKeepstore(c, s.cluster, nil)
+       defer cancel()
+       ctx := authContext(arvadostest.ActiveTokenV2)
+
+       for _, trial := range []struct {
+               ask            []string
+               expectReplicas int
+               expectClasses  map[string]int
+       }{
+               {nil,
+                       1,
+                       map[string]int{"default": 1}},
+               {[]string{},
+                       1,
+                       map[string]int{"default": 1}},
+               {[]string{"default"},
+                       1,
+                       map[string]int{"default": 1}},
+               {[]string{"default", "default"},
+                       1,
+                       map[string]int{"default": 1}},
+               {[]string{"special"},
+                       1,
+                       map[string]int{"extra": 1, "special": 1}},
+               {[]string{"special", "readonly"},
+                       1,
+                       map[string]int{"extra": 1, "special": 1}},
+               {[]string{"special", "nonexistent"},
+                       1,
+                       map[string]int{"extra": 1, "special": 1}},
+               {[]string{"extra", "special"},
+                       1,
+                       map[string]int{"extra": 1, "special": 1}},
+               {[]string{"default", "special"},
+                       2,
+                       map[string]int{"default": 1, "extra": 1, "special": 1}},
+       } {
+               c.Logf("success case %#v", trial)
+               resp, err := ks.BlockWrite(ctx, arvados.BlockWriteOptions{
+                       Hash:           fooHash,
+                       Data:           []byte("foo"),
+                       StorageClasses: trial.ask,
+               })
+               if !c.Check(err, IsNil) {
+                       continue
+               }
+               c.Check(resp.Replicas, Equals, trial.expectReplicas)
+               if len(trial.expectClasses) == 0 {
+                       // any non-empty value is correct
+                       c.Check(resp.StorageClasses, Not(HasLen), 0)
+               } else {
+                       c.Check(resp.StorageClasses, DeepEquals, trial.expectClasses)
+               }
+       }
+
+       for _, ask := range [][]string{
+               {"doesnotexist"},
+               {"doesnotexist", "readonly"},
+               {"readonly"},
+       } {
+               c.Logf("failure case %s", ask)
+               _, err := ks.BlockWrite(ctx, arvados.BlockWriteOptions{
+                       Hash:           fooHash,
+                       Data:           []byte("foo"),
+                       StorageClasses: ask,
+               })
+               c.Check(err, NotNil)
+       }
+}
+
+func (s *keepstoreSuite) TestUntrashHandlerWithNoWritableVolumes(c *C) {
+       for uuid, v := range s.cluster.Volumes {
+               v.ReadOnly = true
+               s.cluster.Volumes[uuid] = v
+       }
+       ks, cancel := testKeepstore(c, s.cluster, nil)
+       defer cancel()
+
+       for _, mnt := range ks.mounts {
+               err := mnt.BlockWrite(context.Background(), fooHash, []byte("foo"))
+               c.Assert(err, IsNil)
+               err = mnt.BlockRead(context.Background(), fooHash, brdiscard)
+               c.Assert(err, IsNil)
+       }
+
+       err := ks.BlockUntrash(context.Background(), fooHash)
+       c.Check(os.IsNotExist(err), Equals, true)
+
+       for _, mnt := range ks.mounts {
+               err := mnt.BlockRead(context.Background(), fooHash, brdiscard)
+               c.Assert(err, IsNil)
+       }
+}
+
+func (s *keepstoreSuite) TestBlockWrite_SkipReadOnly(c *C) {
+       s.cluster.Volumes = map[string]arvados.Volume{
+               "zzzzz-nyw5e-000000000000000": {Replication: 1, Driver: "stub"},
+               "zzzzz-nyw5e-111111111111111": {Replication: 1, Driver: "stub", ReadOnly: true},
+               "zzzzz-nyw5e-222222222222222": {Replication: 1, Driver: "stub", ReadOnly: true, AllowTrashWhenReadOnly: true},
+       }
+       ks, cancel := testKeepstore(c, s.cluster, nil)
+       defer cancel()
+       ctx := authContext(arvadostest.ActiveTokenV2)
+
+       for i := range make([]byte, 32) {
+               data := []byte(fmt.Sprintf("block %d", i))
+               _, err := ks.BlockWrite(ctx, arvados.BlockWriteOptions{Data: data})
+               c.Assert(err, IsNil)
+       }
+       c.Check(ks.mounts["zzzzz-nyw5e-000000000000000"].volume.(*stubVolume).stubLog.String(), Matches, "(?ms).*write.*")
+       c.Check(ks.mounts["zzzzz-nyw5e-111111111111111"].volume.(*stubVolume).stubLog.String(), HasLen, 0)
+       c.Check(ks.mounts["zzzzz-nyw5e-222222222222222"].volume.(*stubVolume).stubLog.String(), HasLen, 0)
+}
+
+func (s *keepstoreSuite) TestGetLocatorInfo(c *C) {
+       for _, trial := range []struct {
+               locator string
+               ok      bool
+               expect  locatorInfo
+       }{
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+                       ok: true},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+1234",
+                       ok: true, expect: locatorInfo{size: 1234}},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+1234+Abcdef@abcdef",
+                       ok: true, expect: locatorInfo{size: 1234, signed: true}},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+1234+Rzzzzz-abcdef",
+                       ok: true, expect: locatorInfo{size: 1234, remote: true}},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+12345+Zexample+Rzzzzz-abcdef",
+                       ok: true, expect: locatorInfo{size: 12345, remote: true}},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+123456+👶🦈+Rzzzzz-abcdef",
+                       ok: true, expect: locatorInfo{size: 123456, remote: true}},
+               // invalid: bad hash char
+               {locator: "aaaaaaaaaaaaaazaaaaaaaaaaaaaaaaa+1234",
+                       ok: false},
+               {locator: "aaaaaaaaaaaaaaFaaaaaaaaaaaaaaaaa+1234",
+                       ok: false},
+               {locator: "aaaaaaaaaaaaaa⛵aaaaaaaaaaaaaaaaa+1234",
+                       ok: false},
+               // invalid: hash length != 32
+               {locator: "",
+                       ok: false},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+                       ok: false},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+1234",
+                       ok: false},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabb",
+                       ok: false},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabb+1234",
+                       ok: false},
+               // invalid: first hint is not size
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+Abcdef+1234",
+                       ok: false},
+               // invalid: leading/trailing/double +
+               {locator: "+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+1234",
+                       ok: false},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+1234+",
+                       ok: false},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa++1234",
+                       ok: false},
+               {locator: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+1234++Abcdef@abcdef",
+                       ok: false},
+       } {
+               c.Logf("=== %s", trial.locator)
+               li, err := getLocatorInfo(trial.locator)
+               if !trial.ok {
+                       c.Check(err, NotNil)
+                       continue
+               }
+               c.Check(err, IsNil)
+               c.Check(li.hash, Equals, trial.locator[:32])
+               c.Check(li.size, Equals, trial.expect.size)
+               c.Check(li.signed, Equals, trial.expect.signed)
+               c.Check(li.remote, Equals, trial.expect.remote)
+       }
+}
+
+func init() {
+       driver["stub"] = func(params newVolumeParams) (volume, error) {
+               v := &stubVolume{
+                       params:  params,
+                       data:    make(map[string]stubData),
+                       stubLog: &stubLog{},
+               }
+               return v, nil
+       }
+}
+
+type stubLog struct {
+       sync.Mutex
+       bytes.Buffer
+}
+
+func (sl *stubLog) Printf(format string, args ...interface{}) {
+       if sl == nil {
+               return
+       }
+       sl.Lock()
+       defer sl.Unlock()
+       fmt.Fprintf(sl, format+"\n", args...)
+}
+
+type stubData struct {
+       mtime time.Time
+       data  []byte
+       trash time.Time
+}
+
+type stubVolume struct {
+       params  newVolumeParams
+       data    map[string]stubData
+       stubLog *stubLog
+       mtx     sync.Mutex
+
+       // The following funcs enable tests to insert delays and
+       // failures. Each volume operation begins by calling the
+       // corresponding func (if non-nil). If the func returns an
+       // error, that error is returned to caller. Otherwise, the
+       // stub continues normally.
+       blockRead    func(ctx context.Context, hash string, writeTo io.WriterAt) error
+       blockWrite   func(ctx context.Context, hash string, data []byte) error
+       deviceID     func() string
+       blockTouch   func(hash string) error
+       blockTrash   func(hash string) error
+       blockUntrash func(hash string) error
+       index        func(ctx context.Context, prefix string, writeTo io.Writer) error
+       mtime        func(hash string) (time.Time, error)
+       emptyTrash   func()
+}
+
+func (v *stubVolume) log(op, hash string) {
+       // Note this intentionally crashes if UUID or hash is short --
+       // if keepstore ever does that, tests should fail.
+       v.stubLog.Printf("%s %s %s", v.params.UUID[24:27], op, hash[:3])
+}
+
+func (v *stubVolume) BlockRead(ctx context.Context, hash string, writeTo io.WriterAt) error {
+       v.log("read", hash)
+       if v.blockRead != nil {
+               err := v.blockRead(ctx, hash, writeTo)
+               if err != nil {
+                       return err
+               }
+       }
+       v.mtx.Lock()
+       ent, ok := v.data[hash]
+       v.mtx.Unlock()
+       if !ok || !ent.trash.IsZero() {
+               return os.ErrNotExist
+       }
+       wrote := 0
+       for writesize := 1000; wrote < len(ent.data); writesize = writesize * 2 {
+               data := ent.data[wrote:]
+               if len(data) > writesize {
+                       data = data[:writesize]
+               }
+               n, err := writeTo.WriteAt(data, int64(wrote))
+               wrote += n
+               if err != nil {
+                       return err
+               }
+       }
+       return nil
+}
+
+func (v *stubVolume) BlockWrite(ctx context.Context, hash string, data []byte) error {
+       v.log("write", hash)
+       if v.blockWrite != nil {
+               if err := v.blockWrite(ctx, hash, data); err != nil {
+                       return err
+               }
+       }
+       v.mtx.Lock()
+       defer v.mtx.Unlock()
+       v.data[hash] = stubData{
+               mtime: time.Now(),
+               data:  append([]byte(nil), data...),
+       }
+       return nil
+}
+
+func (v *stubVolume) DeviceID() string {
+       return fmt.Sprintf("%p", v)
+}
+
+func (v *stubVolume) BlockTouch(hash string) error {
+       v.log("touch", hash)
+       if v.blockTouch != nil {
+               if err := v.blockTouch(hash); err != nil {
+                       return err
+               }
+       }
+       v.mtx.Lock()
+       defer v.mtx.Unlock()
+       ent, ok := v.data[hash]
+       if !ok || !ent.trash.IsZero() {
+               return os.ErrNotExist
+       }
+       ent.mtime = time.Now()
+       v.data[hash] = ent
+       return nil
+}
+
+// Set mtime to the (presumably old) specified time.
+func (v *stubVolume) blockTouchWithTime(hash string, t time.Time) error {
+       v.log("touchwithtime", hash)
+       v.mtx.Lock()
+       defer v.mtx.Unlock()
+       ent, ok := v.data[hash]
+       if !ok {
+               return os.ErrNotExist
+       }
+       ent.mtime = t
+       v.data[hash] = ent
+       return nil
+}
+
+func (v *stubVolume) BlockTrash(hash string) error {
+       v.log("trash", hash)
+       if v.blockTrash != nil {
+               if err := v.blockTrash(hash); err != nil {
+                       return err
+               }
+       }
+       v.mtx.Lock()
+       defer v.mtx.Unlock()
+       ent, ok := v.data[hash]
+       if !ok || !ent.trash.IsZero() {
+               return os.ErrNotExist
+       }
+       ent.trash = time.Now().Add(v.params.Cluster.Collections.BlobTrashLifetime.Duration())
+       v.data[hash] = ent
+       return nil
+}
+
+func (v *stubVolume) BlockUntrash(hash string) error {
+       v.log("untrash", hash)
+       if v.blockUntrash != nil {
+               if err := v.blockUntrash(hash); err != nil {
+                       return err
+               }
+       }
+       v.mtx.Lock()
+       defer v.mtx.Unlock()
+       ent, ok := v.data[hash]
+       if !ok || ent.trash.IsZero() {
+               return os.ErrNotExist
+       }
+       ent.trash = time.Time{}
+       v.data[hash] = ent
+       return nil
+}
+
+func (v *stubVolume) Index(ctx context.Context, prefix string, writeTo io.Writer) error {
+       v.stubLog.Printf("%s index %s", v.params.UUID, prefix)
+       if v.index != nil {
+               if err := v.index(ctx, prefix, writeTo); err != nil {
+                       return err
+               }
+       }
+       buf := &bytes.Buffer{}
+       v.mtx.Lock()
+       for hash, ent := range v.data {
+               if ent.trash.IsZero() && strings.HasPrefix(hash, prefix) {
+                       fmt.Fprintf(buf, "%s+%d %d\n", hash, len(ent.data), ent.mtime.UnixNano())
+               }
+       }
+       v.mtx.Unlock()
+       _, err := io.Copy(writeTo, buf)
+       return err
+}
+
+func (v *stubVolume) Mtime(hash string) (time.Time, error) {
+       v.log("mtime", hash)
+       if v.mtime != nil {
+               if t, err := v.mtime(hash); err != nil {
+                       return t, err
+               }
+       }
+       v.mtx.Lock()
+       defer v.mtx.Unlock()
+       ent, ok := v.data[hash]
+       if !ok || !ent.trash.IsZero() {
+               return time.Time{}, os.ErrNotExist
+       }
+       return ent.mtime, nil
+}
+
+func (v *stubVolume) EmptyTrash() {
+       v.stubLog.Printf("%s emptytrash", v.params.UUID)
+       v.mtx.Lock()
+       defer v.mtx.Unlock()
+       for hash, ent := range v.data {
+               if !ent.trash.IsZero() && time.Now().After(ent.trash) {
+                       delete(v.data, hash)
+               }
+       }
+}
index d04601fbec84128ff47cf65ea15588aa6212b9c5..4638de544482e18721a6eb9b714f22fdc17a9dba 100644 (file)
@@ -5,66 +5,9 @@
 package keepstore
 
 import (
-       "fmt"
-
        "github.com/prometheus/client_golang/prometheus"
 )
 
-type nodeMetrics struct {
-       reg *prometheus.Registry
-}
-
-func (m *nodeMetrics) setupBufferPoolMetrics(b *bufferPool) {
-       m.reg.MustRegister(prometheus.NewGaugeFunc(
-               prometheus.GaugeOpts{
-                       Namespace: "arvados",
-                       Subsystem: "keepstore",
-                       Name:      "bufferpool_allocated_bytes",
-                       Help:      "Number of bytes allocated to buffers",
-               },
-               func() float64 { return float64(b.Alloc()) },
-       ))
-       m.reg.MustRegister(prometheus.NewGaugeFunc(
-               prometheus.GaugeOpts{
-                       Namespace: "arvados",
-                       Subsystem: "keepstore",
-                       Name:      "bufferpool_max_buffers",
-                       Help:      "Maximum number of buffers allowed",
-               },
-               func() float64 { return float64(b.Cap()) },
-       ))
-       m.reg.MustRegister(prometheus.NewGaugeFunc(
-               prometheus.GaugeOpts{
-                       Namespace: "arvados",
-                       Subsystem: "keepstore",
-                       Name:      "bufferpool_inuse_buffers",
-                       Help:      "Number of buffers in use",
-               },
-               func() float64 { return float64(b.Len()) },
-       ))
-}
-
-func (m *nodeMetrics) setupWorkQueueMetrics(q *WorkQueue, qName string) {
-       m.reg.MustRegister(prometheus.NewGaugeFunc(
-               prometheus.GaugeOpts{
-                       Namespace: "arvados",
-                       Subsystem: "keepstore",
-                       Name:      fmt.Sprintf("%s_queue_inprogress_entries", qName),
-                       Help:      fmt.Sprintf("Number of %s requests in progress", qName),
-               },
-               func() float64 { return float64(getWorkQueueStatus(q).InProgress) },
-       ))
-       m.reg.MustRegister(prometheus.NewGaugeFunc(
-               prometheus.GaugeOpts{
-                       Namespace: "arvados",
-                       Subsystem: "keepstore",
-                       Name:      fmt.Sprintf("%s_queue_pending_entries", qName),
-                       Help:      fmt.Sprintf("Number of queued %s requests", qName),
-               },
-               func() float64 { return float64(getWorkQueueStatus(q).Queued) },
-       ))
-}
-
 type volumeMetricsVecs struct {
        ioBytes     *prometheus.CounterVec
        errCounters *prometheus.CounterVec
diff --git a/services/keepstore/metrics_test.go b/services/keepstore/metrics_test.go
new file mode 100644 (file)
index 0000000..0c8f1e6
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package keepstore
+
+import (
+       "context"
+       "encoding/json"
+       "net/http"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "github.com/prometheus/client_golang/prometheus"
+       . "gopkg.in/check.v1"
+)
+
+func (s *routerSuite) TestMetrics(c *C) {
+       reg := prometheus.NewRegistry()
+       router, cancel := testRouter(c, s.cluster, reg)
+       defer cancel()
+       instrumented := httpserver.Instrument(reg, ctxlog.TestLogger(c), router)
+       handler := instrumented.ServeAPI(s.cluster.ManagementToken, instrumented)
+
+       router.keepstore.BlockWrite(context.Background(), arvados.BlockWriteOptions{
+               Hash: fooHash,
+               Data: []byte("foo"),
+       })
+       router.keepstore.BlockWrite(context.Background(), arvados.BlockWriteOptions{
+               Hash: barHash,
+               Data: []byte("bar"),
+       })
+
+       // prime the metrics by doing a no-op request
+       resp := call(handler, "GET", "/", "", nil, nil)
+
+       resp = call(handler, "GET", "/metrics.json", "", nil, nil)
+       c.Check(resp.Code, Equals, http.StatusUnauthorized)
+       resp = call(handler, "GET", "/metrics.json", "foobar", nil, nil)
+       c.Check(resp.Code, Equals, http.StatusForbidden)
+       resp = call(handler, "GET", "/metrics.json", arvadostest.ManagementToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       var j []struct {
+               Name   string
+               Help   string
+               Type   string
+               Metric []struct {
+                       Label []struct {
+                               Name  string
+                               Value string
+                       }
+                       Summary struct {
+                               SampleCount string
+                               SampleSum   float64
+                       }
+               }
+       }
+       json.NewDecoder(resp.Body).Decode(&j)
+       found := make(map[string]bool)
+       names := map[string]bool{}
+       for _, g := range j {
+               names[g.Name] = true
+               for _, m := range g.Metric {
+                       if len(m.Label) == 2 && m.Label[0].Name == "code" && m.Label[0].Value == "200" && m.Label[1].Name == "method" && m.Label[1].Value == "put" {
+                               c.Check(m.Summary.SampleCount, Equals, "2")
+                               found[g.Name] = true
+                       }
+               }
+       }
+
+       metricsNames := []string{
+               "arvados_keepstore_bufferpool_inuse_buffers",
+               "arvados_keepstore_bufferpool_max_buffers",
+               "arvados_keepstore_bufferpool_allocated_bytes",
+               "arvados_keepstore_pull_queue_inprogress_entries",
+               "arvados_keepstore_pull_queue_pending_entries",
+               "arvados_keepstore_trash_queue_inprogress_entries",
+               "arvados_keepstore_trash_queue_pending_entries",
+               "request_duration_seconds",
+       }
+       for _, m := range metricsNames {
+               _, ok := names[m]
+               c.Check(ok, Equals, true, Commentf("checking metric %q", m))
+       }
+}
diff --git a/services/keepstore/mock_mutex_for_test.go b/services/keepstore/mock_mutex_for_test.go
deleted file mode 100644 (file)
index daf0ef0..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-type MockMutex struct {
-       AllowLock   chan struct{}
-       AllowUnlock chan struct{}
-}
-
-func NewMockMutex() *MockMutex {
-       return &MockMutex{
-               AllowLock:   make(chan struct{}),
-               AllowUnlock: make(chan struct{}),
-       }
-}
-
-// Lock waits for someone to send to AllowLock.
-func (m *MockMutex) Lock() {
-       <-m.AllowLock
-}
-
-// Unlock waits for someone to send to AllowUnlock.
-func (m *MockMutex) Unlock() {
-       <-m.AllowUnlock
-}
index e8c248219f77785458110107922983b0917fa51d..d29d5f6dc048e86e76d1498c80e96bb4f9b058e9 100644 (file)
@@ -5,28 +5,24 @@
 package keepstore
 
 import (
-       "bytes"
        "context"
        "encoding/json"
        "net/http"
-       "net/http/httptest"
 
-       "git.arvados.org/arvados.git/sdk/go/arvadostest"
-       "git.arvados.org/arvados.git/sdk/go/ctxlog"
-       "git.arvados.org/arvados.git/sdk/go/httpserver"
-       "github.com/prometheus/client_golang/prometheus"
-       check "gopkg.in/check.v1"
+       . "gopkg.in/check.v1"
 )
 
-func (s *HandlerSuite) TestMounts(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
+func (s *routerSuite) TestMounts(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
 
-       vols := s.handler.volmgr.AllWritable()
-       vols[0].Put(context.Background(), TestHash, TestBlock)
-       vols[1].Put(context.Background(), TestHash2, TestBlock2)
+       router.keepstore.mountsW[0].BlockWrite(context.Background(), fooHash, []byte("foo"))
+       router.keepstore.mountsW[1].BlockWrite(context.Background(), barHash, []byte("bar"))
+
+       resp := call(router, "GET", "/mounts", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Log(resp.Body.String())
 
-       resp := s.call("GET", "/mounts", "", nil)
-       c.Check(resp.Code, check.Equals, http.StatusOK)
        var mntList []struct {
                UUID           string          `json:"uuid"`
                DeviceID       string          `json:"device_id"`
@@ -34,119 +30,56 @@ func (s *HandlerSuite) TestMounts(c *check.C) {
                Replication    int             `json:"replication"`
                StorageClasses map[string]bool `json:"storage_classes"`
        }
-       c.Log(resp.Body.String())
        err := json.Unmarshal(resp.Body.Bytes(), &mntList)
-       c.Assert(err, check.IsNil)
-       c.Assert(len(mntList), check.Equals, 2)
+       c.Assert(err, IsNil)
+       c.Assert(mntList, HasLen, 2)
+
        for _, m := range mntList {
-               c.Check(len(m.UUID), check.Equals, 27)
-               c.Check(m.UUID[:12], check.Equals, "zzzzz-nyw5e-")
-               c.Check(m.DeviceID, check.Equals, "mock-device-id")
-               c.Check(m.ReadOnly, check.Equals, false)
-               c.Check(m.Replication, check.Equals, 1)
-               c.Check(m.StorageClasses, check.DeepEquals, map[string]bool{"default": true})
+               c.Check(len(m.UUID), Equals, 27)
+               c.Check(m.UUID[:12], Equals, "zzzzz-nyw5e-")
+               c.Check(m.DeviceID, Matches, "0x[0-9a-f]+")
+               c.Check(m.ReadOnly, Equals, false)
+               c.Check(m.Replication, Equals, 1)
+               c.Check(m.StorageClasses, HasLen, 1)
+               for k := range m.StorageClasses {
+                       c.Check(k, Matches, "testclass.*")
+               }
        }
-       c.Check(mntList[0].UUID, check.Not(check.Equals), mntList[1].UUID)
+       c.Check(mntList[0].UUID, Not(Equals), mntList[1].UUID)
 
-       // Bad auth
+       c.Logf("=== bad auth")
        for _, tok := range []string{"", "xyzzy"} {
-               resp = s.call("GET", "/mounts/"+mntList[1].UUID+"/blocks", tok, nil)
-               c.Check(resp.Code, check.Equals, http.StatusUnauthorized)
-               c.Check(resp.Body.String(), check.Equals, "Unauthorized\n")
-       }
-
-       tok := arvadostest.SystemRootToken
-
-       // Nonexistent mount UUID
-       resp = s.call("GET", "/mounts/X/blocks", tok, nil)
-       c.Check(resp.Code, check.Equals, http.StatusNotFound)
-       c.Check(resp.Body.String(), check.Equals, "mount not found\n")
-
-       // Complete index of first mount
-       resp = s.call("GET", "/mounts/"+mntList[0].UUID+"/blocks", tok, nil)
-       c.Check(resp.Code, check.Equals, http.StatusOK)
-       c.Check(resp.Body.String(), check.Matches, TestHash+`\+[0-9]+ [0-9]+\n\n`)
-
-       // Partial index of first mount (one block matches prefix)
-       resp = s.call("GET", "/mounts/"+mntList[0].UUID+"/blocks?prefix="+TestHash[:2], tok, nil)
-       c.Check(resp.Code, check.Equals, http.StatusOK)
-       c.Check(resp.Body.String(), check.Matches, TestHash+`\+[0-9]+ [0-9]+\n\n`)
-
-       // Complete index of second mount (note trailing slash)
-       resp = s.call("GET", "/mounts/"+mntList[1].UUID+"/blocks/", tok, nil)
-       c.Check(resp.Code, check.Equals, http.StatusOK)
-       c.Check(resp.Body.String(), check.Matches, TestHash2+`\+[0-9]+ [0-9]+\n\n`)
-
-       // Partial index of second mount (no blocks match prefix)
-       resp = s.call("GET", "/mounts/"+mntList[1].UUID+"/blocks/?prefix="+TestHash[:2], tok, nil)
-       c.Check(resp.Code, check.Equals, http.StatusOK)
-       c.Check(resp.Body.String(), check.Equals, "\n")
-}
-
-func (s *HandlerSuite) TestMetrics(c *check.C) {
-       reg := prometheus.NewRegistry()
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", reg, testServiceURL), check.IsNil)
-       instrumented := httpserver.Instrument(reg, ctxlog.TestLogger(c), s.handler.Handler)
-       s.handler.Handler = instrumented.ServeAPI(s.cluster.ManagementToken, instrumented)
-
-       s.call("PUT", "/"+TestHash, "", TestBlock)
-       s.call("PUT", "/"+TestHash2, "", TestBlock2)
-       resp := s.call("GET", "/metrics.json", "", nil)
-       c.Check(resp.Code, check.Equals, http.StatusUnauthorized)
-       resp = s.call("GET", "/metrics.json", "foobar", nil)
-       c.Check(resp.Code, check.Equals, http.StatusForbidden)
-       resp = s.call("GET", "/metrics.json", arvadostest.ManagementToken, nil)
-       c.Check(resp.Code, check.Equals, http.StatusOK)
-       var j []struct {
-               Name   string
-               Help   string
-               Type   string
-               Metric []struct {
-                       Label []struct {
-                               Name  string
-                               Value string
-                       }
-                       Summary struct {
-                               SampleCount string
-                               SampleSum   float64
-                       }
-               }
-       }
-       json.NewDecoder(resp.Body).Decode(&j)
-       found := make(map[string]bool)
-       names := map[string]bool{}
-       for _, g := range j {
-               names[g.Name] = true
-               for _, m := range g.Metric {
-                       if len(m.Label) == 2 && m.Label[0].Name == "code" && m.Label[0].Value == "200" && m.Label[1].Name == "method" && m.Label[1].Value == "put" {
-                               c.Check(m.Summary.SampleCount, check.Equals, "2")
-                               found[g.Name] = true
-                       }
+               resp = call(router, "GET", "/mounts/"+mntList[1].UUID+"/blocks", tok, nil, nil)
+               if tok == "" {
+                       c.Check(resp.Code, Equals, http.StatusUnauthorized)
+                       c.Check(resp.Body.String(), Equals, "Unauthorized\n")
+               } else {
+                       c.Check(resp.Code, Equals, http.StatusForbidden)
+                       c.Check(resp.Body.String(), Equals, "Forbidden\n")
                }
        }
 
-       metricsNames := []string{
-               "arvados_keepstore_bufferpool_inuse_buffers",
-               "arvados_keepstore_bufferpool_max_buffers",
-               "arvados_keepstore_bufferpool_allocated_bytes",
-               "arvados_keepstore_pull_queue_inprogress_entries",
-               "arvados_keepstore_pull_queue_pending_entries",
-               "arvados_keepstore_trash_queue_inprogress_entries",
-               "arvados_keepstore_trash_queue_pending_entries",
-               "request_duration_seconds",
-       }
-       for _, m := range metricsNames {
-               _, ok := names[m]
-               c.Check(ok, check.Equals, true, check.Commentf("checking metric %q", m))
-       }
-}
-
-func (s *HandlerSuite) call(method, path, tok string, body []byte) *httptest.ResponseRecorder {
-       resp := httptest.NewRecorder()
-       req, _ := http.NewRequest(method, path, bytes.NewReader(body))
-       if tok != "" {
-               req.Header.Set("Authorization", "Bearer "+tok)
-       }
-       s.handler.ServeHTTP(resp, req)
-       return resp
+       c.Logf("=== nonexistent mount UUID")
+       resp = call(router, "GET", "/mounts/X/blocks", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusNotFound)
+
+       c.Logf("=== complete index of first mount")
+       resp = call(router, "GET", "/mounts/"+mntList[0].UUID+"/blocks", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Matches, fooHash+`\+[0-9]+ [0-9]+\n\n`)
+
+       c.Logf("=== partial index of first mount (one block matches prefix)")
+       resp = call(router, "GET", "/mounts/"+mntList[0].UUID+"/blocks?prefix="+fooHash[:2], s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Matches, fooHash+`\+[0-9]+ [0-9]+\n\n`)
+
+       c.Logf("=== complete index of second mount (note trailing slash)")
+       resp = call(router, "GET", "/mounts/"+mntList[1].UUID+"/blocks/", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Matches, barHash+`\+[0-9]+ [0-9]+\n\n`)
+
+       c.Logf("=== partial index of second mount (no blocks match prefix)")
+       resp = call(router, "GET", "/mounts/"+mntList[1].UUID+"/blocks/?prefix="+fooHash[:2], s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Equals, "\n")
 }
diff --git a/services/keepstore/perms.go b/services/keepstore/perms.go
deleted file mode 100644 (file)
index 7205a45..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "time"
-
-       "git.arvados.org/arvados.git/sdk/go/arvados"
-       "git.arvados.org/arvados.git/sdk/go/keepclient"
-)
-
-// SignLocator takes a blobLocator, an apiToken and an expiry time, and
-// returns a signed locator string.
-func SignLocator(cluster *arvados.Cluster, blobLocator, apiToken string, expiry time.Time) string {
-       return keepclient.SignLocator(blobLocator, apiToken, expiry, cluster.Collections.BlobSigningTTL.Duration(), []byte(cluster.Collections.BlobSigningKey))
-}
-
-// VerifySignature returns nil if the signature on the signedLocator
-// can be verified using the given apiToken. Otherwise it returns
-// either ExpiredError (if the timestamp has expired, which is
-// something the client could have figured out independently) or
-// PermissionError.
-func VerifySignature(cluster *arvados.Cluster, signedLocator, apiToken string) error {
-       err := keepclient.VerifySignature(signedLocator, apiToken, cluster.Collections.BlobSigningTTL.Duration(), []byte(cluster.Collections.BlobSigningKey))
-       if err == keepclient.ErrSignatureExpired {
-               return ExpiredError
-       } else if err != nil {
-               return PermissionError
-       }
-       return nil
-}
diff --git a/services/keepstore/perms_test.go b/services/keepstore/perms_test.go
deleted file mode 100644 (file)
index 1322374..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "strconv"
-       "time"
-
-       "git.arvados.org/arvados.git/sdk/go/arvados"
-       check "gopkg.in/check.v1"
-)
-
-const (
-       knownHash    = "acbd18db4cc2f85cedef654fccc4a4d8"
-       knownLocator = knownHash + "+3"
-       knownToken   = "hocfupkn2pjhrpgp2vxv8rsku7tvtx49arbc9s4bvu7p7wxqvk"
-       knownKey     = "13u9fkuccnboeewr0ne3mvapk28epf68a3bhj9q8sb4l6e4e5mkk" +
-               "p6nhj2mmpscgu1zze5h5enydxfe3j215024u16ij4hjaiqs5u4pzsl3nczmaoxnc" +
-               "ljkm4875xqn4xv058koz3vkptmzhyheiy6wzevzjmdvxhvcqsvr5abhl15c2d4o4" +
-               "jhl0s91lojy1mtrzqqvprqcverls0xvy9vai9t1l1lvvazpuadafm71jl4mrwq2y" +
-               "gokee3eamvjy8qq1fvy238838enjmy5wzy2md7yvsitp5vztft6j4q866efym7e6" +
-               "vu5wm9fpnwjyxfldw3vbo01mgjs75rgo7qioh8z8ij7jpyp8508okhgbbex3ceei" +
-               "786u5rw2a9gx743dj3fgq2irk"
-       knownSignatureTTL  = arvados.Duration(24 * 14 * time.Hour)
-       knownSignature     = "89118b78732c33104a4d6231e8b5a5fa1e4301e3"
-       knownTimestamp     = "7fffffff"
-       knownSigHint       = "+A" + knownSignature + "@" + knownTimestamp
-       knownSignedLocator = knownLocator + knownSigHint
-)
-
-func (s *HandlerSuite) TestSignLocator(c *check.C) {
-       tsInt, err := strconv.ParseInt(knownTimestamp, 16, 0)
-       if err != nil {
-               c.Fatal(err)
-       }
-       t0 := time.Unix(tsInt, 0)
-
-       s.cluster.Collections.BlobSigningTTL = knownSignatureTTL
-       s.cluster.Collections.BlobSigningKey = knownKey
-       if x := SignLocator(s.cluster, knownLocator, knownToken, t0); x != knownSignedLocator {
-               c.Fatalf("Got %+q, expected %+q", x, knownSignedLocator)
-       }
-
-       s.cluster.Collections.BlobSigningKey = "arbitrarykey"
-       if x := SignLocator(s.cluster, knownLocator, knownToken, t0); x == knownSignedLocator {
-               c.Fatalf("Got same signature %+q, even though blobSigningKey changed", x)
-       }
-}
-
-func (s *HandlerSuite) TestVerifyLocator(c *check.C) {
-       s.cluster.Collections.BlobSigningTTL = knownSignatureTTL
-       s.cluster.Collections.BlobSigningKey = knownKey
-       if err := VerifySignature(s.cluster, knownSignedLocator, knownToken); err != nil {
-               c.Fatal(err)
-       }
-
-       s.cluster.Collections.BlobSigningKey = "arbitrarykey"
-       if err := VerifySignature(s.cluster, knownSignedLocator, knownToken); err == nil {
-               c.Fatal("Verified signature even with wrong blobSigningKey")
-       }
-}
diff --git a/services/keepstore/pipe_adapters.go b/services/keepstore/pipe_adapters.go
deleted file mode 100644 (file)
index 6b55505..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "bytes"
-       "context"
-       "io"
-       "io/ioutil"
-)
-
-// getWithPipe invokes getter and copies the resulting data into
-// buf. If ctx is done before all data is copied, getWithPipe closes
-// the pipe with an error, and returns early with an error.
-func getWithPipe(ctx context.Context, loc string, buf []byte, br BlockReader) (int, error) {
-       piper, pipew := io.Pipe()
-       go func() {
-               pipew.CloseWithError(br.ReadBlock(ctx, loc, pipew))
-       }()
-       done := make(chan struct{})
-       var size int
-       var err error
-       go func() {
-               size, err = io.ReadFull(piper, buf)
-               if err == io.EOF || err == io.ErrUnexpectedEOF {
-                       err = nil
-               }
-               close(done)
-       }()
-       select {
-       case <-ctx.Done():
-               piper.CloseWithError(ctx.Err())
-               return 0, ctx.Err()
-       case <-done:
-               piper.Close()
-               return size, err
-       }
-}
-
-// putWithPipe invokes putter with a new pipe, and copies data
-// from buf into the pipe. If ctx is done before all data is copied,
-// putWithPipe closes the pipe with an error, and returns early with
-// an error.
-func putWithPipe(ctx context.Context, loc string, buf []byte, bw BlockWriter) error {
-       piper, pipew := io.Pipe()
-       copyErr := make(chan error)
-       go func() {
-               _, err := io.Copy(pipew, bytes.NewReader(buf))
-               copyErr <- err
-               close(copyErr)
-       }()
-
-       putErr := make(chan error, 1)
-       go func() {
-               putErr <- bw.WriteBlock(ctx, loc, piper)
-               close(putErr)
-       }()
-
-       var err error
-       select {
-       case err = <-copyErr:
-       case err = <-putErr:
-       case <-ctx.Done():
-               err = ctx.Err()
-       }
-
-       // Ensure io.Copy goroutine isn't blocked writing to pipew
-       // (otherwise, io.Copy is still using buf so it isn't safe to
-       // return). This can cause pipew to receive corrupt data if
-       // err came from copyErr or ctx.Done() before the copy
-       // finished. That's OK, though: in that case err != nil, and
-       // CloseWithErr(err) ensures putter() will get an error from
-       // piper.Read() before seeing EOF.
-       go pipew.CloseWithError(err)
-       go io.Copy(ioutil.Discard, piper)
-       <-copyErr
-
-       // Note: io.Copy() is finished now, but putter() might still
-       // be running. If we encounter an error before putter()
-       // returns, we return right away without waiting for putter().
-
-       if err != nil {
-               return err
-       }
-       select {
-       case <-ctx.Done():
-               return ctx.Err()
-       case err = <-putErr:
-               return err
-       }
-}
diff --git a/services/keepstore/proxy_remote.go b/services/keepstore/proxy_remote.go
deleted file mode 100644 (file)
index 526bc25..0000000
+++ /dev/null
@@ -1,211 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "context"
-       "errors"
-       "io"
-       "net/http"
-       "regexp"
-       "strings"
-       "sync"
-       "time"
-
-       "git.arvados.org/arvados.git/sdk/go/arvados"
-       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
-       "git.arvados.org/arvados.git/sdk/go/auth"
-       "git.arvados.org/arvados.git/sdk/go/keepclient"
-)
-
-type remoteProxy struct {
-       clients map[string]*keepclient.KeepClient
-       mtx     sync.Mutex
-}
-
-func (rp *remoteProxy) Get(ctx context.Context, w http.ResponseWriter, r *http.Request, cluster *arvados.Cluster, volmgr *RRVolumeManager) {
-       // Intervening proxies must not return a cached GET response
-       // to a prior request if a X-Keep-Signature request header has
-       // been added or changed.
-       w.Header().Add("Vary", "X-Keep-Signature")
-
-       token := GetAPIToken(r)
-       if token == "" {
-               http.Error(w, "no token provided in Authorization header", http.StatusUnauthorized)
-               return
-       }
-       if strings.SplitN(r.Header.Get("X-Keep-Signature"), ",", 2)[0] == "local" {
-               buf, err := getBufferWithContext(ctx, bufs, BlockSize)
-               if err != nil {
-                       http.Error(w, err.Error(), http.StatusServiceUnavailable)
-                       return
-               }
-               defer bufs.Put(buf)
-               rrc := &remoteResponseCacher{
-                       Locator:        r.URL.Path[1:],
-                       Token:          token,
-                       Buffer:         buf[:0],
-                       ResponseWriter: w,
-                       Context:        ctx,
-                       Cluster:        cluster,
-                       VolumeManager:  volmgr,
-               }
-               defer rrc.Close()
-               w = rrc
-       }
-       var remoteClient *keepclient.KeepClient
-       var parts []string
-       for i, part := range strings.Split(r.URL.Path[1:], "+") {
-               switch {
-               case i == 0:
-                       // don't try to parse hash part as hint
-               case strings.HasPrefix(part, "A"):
-                       // drop local permission hint
-                       continue
-               case len(part) > 7 && part[0] == 'R' && part[6] == '-':
-                       remoteID := part[1:6]
-                       remote, ok := cluster.RemoteClusters[remoteID]
-                       if !ok {
-                               http.Error(w, "remote cluster not configured", http.StatusBadRequest)
-                               return
-                       }
-                       kc, err := rp.remoteClient(remoteID, remote, token)
-                       if err == auth.ErrObsoleteToken {
-                               http.Error(w, err.Error(), http.StatusBadRequest)
-                               return
-                       } else if err != nil {
-                               http.Error(w, err.Error(), http.StatusInternalServerError)
-                               return
-                       }
-                       remoteClient = kc
-                       part = "A" + part[7:]
-               }
-               parts = append(parts, part)
-       }
-       if remoteClient == nil {
-               http.Error(w, "bad request", http.StatusBadRequest)
-               return
-       }
-       locator := strings.Join(parts, "+")
-       rdr, _, _, err := remoteClient.Get(locator)
-       switch err.(type) {
-       case nil:
-               defer rdr.Close()
-               io.Copy(w, rdr)
-       case *keepclient.ErrNotFound:
-               http.Error(w, err.Error(), http.StatusNotFound)
-       default:
-               http.Error(w, err.Error(), http.StatusBadGateway)
-       }
-}
-
-func (rp *remoteProxy) remoteClient(remoteID string, remoteCluster arvados.RemoteCluster, token string) (*keepclient.KeepClient, error) {
-       rp.mtx.Lock()
-       kc, ok := rp.clients[remoteID]
-       rp.mtx.Unlock()
-       if !ok {
-               c := &arvados.Client{
-                       APIHost:   remoteCluster.Host,
-                       AuthToken: "xxx",
-                       Insecure:  remoteCluster.Insecure,
-               }
-               ac, err := arvadosclient.New(c)
-               if err != nil {
-                       return nil, err
-               }
-               kc, err = keepclient.MakeKeepClient(ac)
-               if err != nil {
-                       return nil, err
-               }
-
-               rp.mtx.Lock()
-               if rp.clients == nil {
-                       rp.clients = map[string]*keepclient.KeepClient{remoteID: kc}
-               } else {
-                       rp.clients[remoteID] = kc
-               }
-               rp.mtx.Unlock()
-       }
-       accopy := *kc.Arvados
-       accopy.ApiToken = token
-       kccopy := *kc
-       kccopy.Arvados = &accopy
-       token, err := auth.SaltToken(token, remoteID)
-       if err != nil {
-               return nil, err
-       }
-       kccopy.Arvados.ApiToken = token
-       return &kccopy, nil
-}
-
-var localOrRemoteSignature = regexp.MustCompile(`\+[AR][^\+]*`)
-
-// remoteResponseCacher wraps http.ResponseWriter. It buffers the
-// response data in the provided buffer, writes/touches a copy on a
-// local volume, adds a response header with a locally-signed locator,
-// and finally writes the data through.
-type remoteResponseCacher struct {
-       Locator       string
-       Token         string
-       Buffer        []byte
-       Context       context.Context
-       Cluster       *arvados.Cluster
-       VolumeManager *RRVolumeManager
-       http.ResponseWriter
-       statusCode int
-}
-
-func (rrc *remoteResponseCacher) Write(p []byte) (int, error) {
-       if len(rrc.Buffer)+len(p) > cap(rrc.Buffer) {
-               return 0, errors.New("buffer full")
-       }
-       rrc.Buffer = append(rrc.Buffer, p...)
-       return len(p), nil
-}
-
-func (rrc *remoteResponseCacher) WriteHeader(statusCode int) {
-       rrc.statusCode = statusCode
-}
-
-func (rrc *remoteResponseCacher) Close() error {
-       if rrc.statusCode == 0 {
-               rrc.statusCode = http.StatusOK
-       } else if rrc.statusCode != http.StatusOK {
-               rrc.ResponseWriter.WriteHeader(rrc.statusCode)
-               rrc.ResponseWriter.Write(rrc.Buffer)
-               return nil
-       }
-       _, err := PutBlock(rrc.Context, rrc.VolumeManager, rrc.Buffer, rrc.Locator[:32], nil)
-       if rrc.Context.Err() != nil {
-               // If caller hung up, log that instead of subsequent/misleading errors.
-               http.Error(rrc.ResponseWriter, rrc.Context.Err().Error(), http.StatusGatewayTimeout)
-               return err
-       }
-       if err == RequestHashError {
-               http.Error(rrc.ResponseWriter, "checksum mismatch in remote response", http.StatusBadGateway)
-               return err
-       }
-       if err, ok := err.(*KeepError); ok {
-               http.Error(rrc.ResponseWriter, err.Error(), err.HTTPCode)
-               return err
-       }
-       if err != nil {
-               http.Error(rrc.ResponseWriter, err.Error(), http.StatusBadGateway)
-               return err
-       }
-
-       unsigned := localOrRemoteSignature.ReplaceAllLiteralString(rrc.Locator, "")
-       expiry := time.Now().Add(rrc.Cluster.Collections.BlobSigningTTL.Duration())
-       signed := SignLocator(rrc.Cluster, unsigned, rrc.Token, expiry)
-       if signed == unsigned {
-               err = errors.New("could not sign locator")
-               http.Error(rrc.ResponseWriter, err.Error(), http.StatusInternalServerError)
-               return err
-       }
-       rrc.Header().Set("X-Keep-Locator", signed)
-       rrc.ResponseWriter.WriteHeader(rrc.statusCode)
-       _, err = rrc.ResponseWriter.Write(rrc.Buffer)
-       return err
-}
index 534371cc0ece83ef3a0cead670d1612ec8f57172..886754e14a422d226ccc34c316a608a10bf36f27 100644 (file)
@@ -5,7 +5,6 @@
 package keepstore
 
 import (
-       "context"
        "crypto/md5"
        "encoding/json"
        "fmt"
@@ -20,16 +19,18 @@ import (
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
        "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
        "git.arvados.org/arvados.git/sdk/go/keepclient"
        "github.com/prometheus/client_golang/prometheus"
        check "gopkg.in/check.v1"
 )
 
-var _ = check.Suite(&ProxyRemoteSuite{})
+var _ = check.Suite(&proxyRemoteSuite{})
 
-type ProxyRemoteSuite struct {
+type proxyRemoteSuite struct {
        cluster *arvados.Cluster
-       handler *handler
+       handler *router
 
        remoteClusterID      string
        remoteBlobSigningKey []byte
@@ -40,7 +41,7 @@ type ProxyRemoteSuite struct {
        remoteAPI            *httptest.Server
 }
 
-func (s *ProxyRemoteSuite) remoteKeepproxyHandler(w http.ResponseWriter, r *http.Request) {
+func (s *proxyRemoteSuite) remoteKeepproxyHandler(w http.ResponseWriter, r *http.Request) {
        expectToken, err := auth.SaltToken(arvadostest.ActiveTokenV2, s.remoteClusterID)
        if err != nil {
                panic(err)
@@ -57,7 +58,7 @@ func (s *ProxyRemoteSuite) remoteKeepproxyHandler(w http.ResponseWriter, r *http
        http.Error(w, "404", 404)
 }
 
-func (s *ProxyRemoteSuite) remoteAPIHandler(w http.ResponseWriter, r *http.Request) {
+func (s *proxyRemoteSuite) remoteAPIHandler(w http.ResponseWriter, r *http.Request) {
        host, port, _ := net.SplitHostPort(strings.Split(s.remoteKeepproxy.URL, "//")[1])
        portnum, _ := strconv.Atoi(port)
        if r.URL.Path == "/arvados/v1/discovery/v1/rest" {
@@ -81,15 +82,13 @@ func (s *ProxyRemoteSuite) remoteAPIHandler(w http.ResponseWriter, r *http.Reque
        http.Error(w, "404", 404)
 }
 
-func (s *ProxyRemoteSuite) SetUpTest(c *check.C) {
+func (s *proxyRemoteSuite) SetUpTest(c *check.C) {
        s.remoteClusterID = "z0000"
        s.remoteBlobSigningKey = []byte("3b6df6fb6518afe12922a5bc8e67bf180a358bc8")
-       s.remoteKeepproxy = httptest.NewServer(http.HandlerFunc(s.remoteKeepproxyHandler))
+       s.remoteKeepproxy = httptest.NewServer(httpserver.LogRequests(http.HandlerFunc(s.remoteKeepproxyHandler)))
        s.remoteAPI = httptest.NewUnstartedServer(http.HandlerFunc(s.remoteAPIHandler))
        s.remoteAPI.StartTLS()
        s.cluster = testCluster(c)
-       s.cluster.Collections.BlobSigningKey = knownKey
-       s.cluster.SystemRootToken = arvadostest.SystemRootToken
        s.cluster.RemoteClusters = map[string]arvados.RemoteCluster{
                s.remoteClusterID: {
                        Host:     strings.Split(s.remoteAPI.URL, "//")[1],
@@ -98,17 +97,21 @@ func (s *ProxyRemoteSuite) SetUpTest(c *check.C) {
                        Insecure: true,
                },
        }
-       s.cluster.Volumes = map[string]arvados.Volume{"zzzzz-nyw5e-000000000000000": {Driver: "mock"}}
-       s.handler = &handler{}
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
+       s.cluster.Volumes = map[string]arvados.Volume{"zzzzz-nyw5e-000000000000000": {Driver: "stub"}}
 }
 
-func (s *ProxyRemoteSuite) TearDownTest(c *check.C) {
+func (s *proxyRemoteSuite) TearDownTest(c *check.C) {
        s.remoteAPI.Close()
        s.remoteKeepproxy.Close()
 }
 
-func (s *ProxyRemoteSuite) TestProxyRemote(c *check.C) {
+func (s *proxyRemoteSuite) TestProxyRemote(c *check.C) {
+       reg := prometheus.NewRegistry()
+       router, cancel := testRouter(c, s.cluster, reg)
+       defer cancel()
+       instrumented := httpserver.Instrument(reg, ctxlog.TestLogger(c), router)
+       handler := httpserver.LogRequests(instrumented.ServeAPI(s.cluster.ManagementToken, instrumented))
+
        data := []byte("foo bar")
        s.remoteKeepData = data
        locator := fmt.Sprintf("%x+%d", md5.Sum(data), len(data))
@@ -172,7 +175,7 @@ func (s *ProxyRemoteSuite) TestProxyRemote(c *check.C) {
                        expectSignature:  true,
                },
        } {
-               c.Logf("trial: %s", trial.label)
+               c.Logf("=== trial: %s", trial.label)
 
                s.remoteKeepRequests = 0
 
@@ -184,11 +187,18 @@ func (s *ProxyRemoteSuite) TestProxyRemote(c *check.C) {
                        req.Header.Set("X-Keep-Signature", trial.xKeepSignature)
                }
                resp = httptest.NewRecorder()
-               s.handler.ServeHTTP(resp, req)
+               handler.ServeHTTP(resp, req)
                c.Check(s.remoteKeepRequests, check.Equals, trial.expectRemoteReqs)
-               c.Check(resp.Code, check.Equals, trial.expectCode)
+               if !c.Check(resp.Code, check.Equals, trial.expectCode) {
+                       c.Logf("resp.Code %d came with resp.Body %q", resp.Code, resp.Body.String())
+               }
                if resp.Code == http.StatusOK {
-                       c.Check(resp.Body.String(), check.Equals, string(data))
+                       if trial.method == "HEAD" {
+                               c.Check(resp.Body.String(), check.Equals, "")
+                               c.Check(resp.Result().ContentLength, check.Equals, int64(len(data)))
+                       } else {
+                               c.Check(resp.Body.String(), check.Equals, string(data))
+                       }
                } else {
                        c.Check(resp.Body.String(), check.Not(check.Equals), string(data))
                }
@@ -203,13 +213,13 @@ func (s *ProxyRemoteSuite) TestProxyRemote(c *check.C) {
 
                c.Check(locHdr, check.Not(check.Equals), "")
                c.Check(locHdr, check.Not(check.Matches), `.*\+R.*`)
-               c.Check(VerifySignature(s.cluster, locHdr, trial.token), check.IsNil)
+               c.Check(arvados.VerifySignature(locHdr, trial.token, s.cluster.Collections.BlobSigningTTL.Duration(), []byte(s.cluster.Collections.BlobSigningKey)), check.IsNil)
 
                // Ensure block can be requested using new signature
                req = httptest.NewRequest("GET", "/"+locHdr, nil)
                req.Header.Set("Authorization", "Bearer "+trial.token)
                resp = httptest.NewRecorder()
-               s.handler.ServeHTTP(resp, req)
+               handler.ServeHTTP(resp, req)
                c.Check(resp.Code, check.Equals, http.StatusOK)
                c.Check(s.remoteKeepRequests, check.Equals, trial.expectRemoteReqs)
        }
index abe3dc3857d5a1652562f29bc361b4f2c95e49ca..dc5eabaa15bbc0b4c5e94add8b5bc461aad998ed 100644 (file)
 package keepstore
 
 import (
+       "bytes"
        "context"
-       "fmt"
-       "io"
-       "io/ioutil"
-       "time"
+       "sync"
+       "sync/atomic"
 
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
        "git.arvados.org/arvados.git/sdk/go/keepclient"
+       "github.com/prometheus/client_golang/prometheus"
 )
 
-// RunPullWorker receives PullRequests from pullq, invokes
-// PullItemAndProcess on each one. After each PR, it logs a message
-// indicating whether the pull was successful.
-func (h *handler) runPullWorker(pullq *WorkQueue) {
-       for item := range pullq.NextItem {
-               pr := item.(PullRequest)
-               err := h.pullItemAndProcess(pr)
-               pullq.DoneItem <- struct{}{}
-               if err == nil {
-                       h.Logger.Printf("Pull %s success", pr)
-               } else {
-                       h.Logger.Printf("Pull %s error: %s", pr, err)
-               }
-       }
+type PullListItem struct {
+       Locator   string   `json:"locator"`
+       Servers   []string `json:"servers"`
+       MountUUID string   `json:"mount_uuid"` // Destination mount, or "" for "anywhere"
 }
 
-// PullItemAndProcess executes a pull request by retrieving the
-// specified block from one of the specified servers, and storing it
-// on a local volume.
-//
-// If the PR specifies a non-blank mount UUID, PullItemAndProcess will
-// only attempt to write the data to the corresponding
-// volume. Otherwise it writes to any local volume, as a PUT request
-// would.
-func (h *handler) pullItemAndProcess(pullRequest PullRequest) error {
-       var vol *VolumeMount
-       if uuid := pullRequest.MountUUID; uuid != "" {
-               vol = h.volmgr.Lookup(pullRequest.MountUUID, true)
-               if vol == nil {
-                       return fmt.Errorf("pull req has nonexistent mount: %v", pullRequest)
-               }
-       }
+type puller struct {
+       keepstore  *keepstore
+       todo       []PullListItem
+       cond       *sync.Cond // lock guards todo accesses; cond broadcasts when todo becomes non-empty
+       inprogress atomic.Int64
+}
 
-       // Make a private copy of keepClient so we can set
-       // ServiceRoots to the source servers specified in the pull
-       // request.
-       keepClient := *h.keepClient
-       serviceRoots := make(map[string]string)
-       for _, addr := range pullRequest.Servers {
-               serviceRoots[addr] = addr
+func newPuller(ctx context.Context, keepstore *keepstore, reg *prometheus.Registry) *puller {
+       p := &puller{
+               keepstore: keepstore,
+               cond:      sync.NewCond(&sync.Mutex{}),
        }
-       keepClient.SetServiceRoots(serviceRoots, nil, nil)
+       reg.MustRegister(prometheus.NewGaugeFunc(
+               prometheus.GaugeOpts{
+                       Namespace: "arvados",
+                       Subsystem: "keepstore",
+                       Name:      "pull_queue_pending_entries",
+                       Help:      "Number of queued pull requests",
+               },
+               func() float64 {
+                       p.cond.L.Lock()
+                       defer p.cond.L.Unlock()
+                       return float64(len(p.todo))
+               },
+       ))
+       reg.MustRegister(prometheus.NewGaugeFunc(
+               prometheus.GaugeOpts{
+                       Namespace: "arvados",
+                       Subsystem: "keepstore",
+                       Name:      "pull_queue_inprogress_entries",
+                       Help:      "Number of pull requests in progress",
+               },
+               func() float64 {
+                       return float64(p.inprogress.Load())
+               },
+       ))
+       if len(p.keepstore.mountsW) == 0 {
+               keepstore.logger.Infof("not running pull worker because there are no writable volumes")
+               return p
+       }
+       for i := 0; i < 1 || i < keepstore.cluster.Collections.BlobReplicateConcurrency; i++ {
+               go p.runWorker(ctx)
+       }
+       return p
+}
 
-       signedLocator := SignLocator(h.Cluster, pullRequest.Locator, keepClient.Arvados.ApiToken, time.Now().Add(time.Minute))
+func (p *puller) SetPullList(newlist []PullListItem) {
+       p.cond.L.Lock()
+       p.todo = newlist
+       p.cond.L.Unlock()
+       p.cond.Broadcast()
+}
 
-       reader, contentLen, _, err := GetContent(signedLocator, &keepClient)
-       if err != nil {
-               return err
+func (p *puller) runWorker(ctx context.Context) {
+       if len(p.keepstore.mountsW) == 0 {
+               p.keepstore.logger.Infof("not running pull worker because there are no writable volumes")
+               return
        }
-       if reader == nil {
-               return fmt.Errorf("No reader found for : %s", signedLocator)
+       c, err := arvados.NewClientFromConfig(p.keepstore.cluster)
+       if err != nil {
+               p.keepstore.logger.Errorf("error setting up pull worker: %s", err)
+               return
        }
-       defer reader.Close()
-
-       readContent, err := ioutil.ReadAll(reader)
+       c.AuthToken = "keepstore-token-used-for-pulling-data-from-same-cluster"
+       ac, err := arvadosclient.New(c)
        if err != nil {
-               return err
+               p.keepstore.logger.Errorf("error setting up pull worker: %s", err)
+               return
        }
-
-       if (readContent == nil) || (int64(len(readContent)) != contentLen) {
-               return fmt.Errorf("Content not found for: %s", signedLocator)
+       keepClient := &keepclient.KeepClient{
+               Arvados:       ac,
+               Want_replicas: 1,
+               DiskCacheSize: keepclient.DiskCacheDisabled,
        }
+       // Ensure the loop below wakes up and returns when ctx
+       // cancels, even if pull list is empty.
+       go func() {
+               <-ctx.Done()
+               p.cond.Broadcast()
+       }()
+       for {
+               p.cond.L.Lock()
+               for len(p.todo) == 0 && ctx.Err() == nil {
+                       p.cond.Wait()
+               }
+               if ctx.Err() != nil {
+                       return
+               }
+               item := p.todo[0]
+               p.todo = p.todo[1:]
+               p.inprogress.Add(1)
+               p.cond.L.Unlock()
 
-       return writePulledBlock(h.volmgr, vol, readContent, pullRequest.Locator)
-}
+               func() {
+                       defer p.inprogress.Add(-1)
 
-// GetContent fetches the content for the given locator using keepclient.
-var GetContent = func(signedLocator string, keepClient *keepclient.KeepClient) (io.ReadCloser, int64, string, error) {
-       return keepClient.Get(signedLocator)
-}
+                       logger := p.keepstore.logger.WithField("locator", item.Locator)
+
+                       li, err := getLocatorInfo(item.Locator)
+                       if err != nil {
+                               logger.Warn("ignoring pull request for invalid locator")
+                               return
+                       }
+
+                       var dst *mount
+                       if item.MountUUID != "" {
+                               dst = p.keepstore.mounts[item.MountUUID]
+                               if dst == nil {
+                                       logger.Warnf("ignoring pull list entry for nonexistent mount %s", item.MountUUID)
+                                       return
+                               } else if !dst.AllowWrite {
+                                       logger.Warnf("ignoring pull list entry for readonly mount %s", item.MountUUID)
+                                       return
+                               }
+                       } else {
+                               dst = p.keepstore.rendezvous(item.Locator, p.keepstore.mountsW)[0]
+                       }
+
+                       serviceRoots := make(map[string]string)
+                       for _, addr := range item.Servers {
+                               serviceRoots[addr] = addr
+                       }
+                       keepClient.SetServiceRoots(serviceRoots, nil, nil)
+
+                       signedLocator := p.keepstore.signLocator(c.AuthToken, item.Locator)
 
-var writePulledBlock = func(volmgr *RRVolumeManager, volume Volume, data []byte, locator string) error {
-       if volume != nil {
-               return volume.Put(context.Background(), locator, data)
+                       buf := bytes.NewBuffer(nil)
+                       _, err = keepClient.BlockRead(ctx, arvados.BlockReadOptions{
+                               Locator: signedLocator,
+                               WriteTo: buf,
+                       })
+                       if err != nil {
+                               logger.WithError(err).Warnf("error pulling data from remote servers (%s)", item.Servers)
+                               return
+                       }
+                       err = dst.BlockWrite(ctx, li.hash, buf.Bytes())
+                       if err != nil {
+                               logger.WithError(err).Warnf("error writing data to %s", dst.UUID)
+                               return
+                       }
+                       logger.Info("block pulled")
+               }()
        }
-       _, err := PutBlock(context.Background(), volmgr, data, locator, nil)
-       return err
 }
diff --git a/services/keepstore/pull_worker_integration_test.go b/services/keepstore/pull_worker_integration_test.go
deleted file mode 100644 (file)
index 3855b4e..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "bytes"
-       "context"
-       "errors"
-       "io"
-       "io/ioutil"
-       "strings"
-
-       "git.arvados.org/arvados.git/sdk/go/arvadostest"
-       "git.arvados.org/arvados.git/sdk/go/keepclient"
-       "github.com/prometheus/client_golang/prometheus"
-       check "gopkg.in/check.v1"
-)
-
-type PullWorkIntegrationTestData struct {
-       Name     string
-       Locator  string
-       Content  string
-       GetError string
-}
-
-func (s *HandlerSuite) setupPullWorkerIntegrationTest(c *check.C, testData PullWorkIntegrationTestData, wantData bool) PullRequest {
-       arvadostest.StartKeep(2, false)
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-       // Put content if the test needs it
-       if wantData {
-               locator, _, err := s.handler.keepClient.PutB([]byte(testData.Content))
-               if err != nil {
-                       c.Errorf("Error putting test data in setup for %s %s %v", testData.Content, locator, err)
-               }
-               if locator == "" {
-                       c.Errorf("No locator found after putting test data")
-               }
-       }
-
-       // Create pullRequest for the test
-       pullRequest := PullRequest{
-               Locator: testData.Locator,
-       }
-       return pullRequest
-}
-
-// Do a get on a block that is not existing in any of the keep servers.
-// Expect "block not found" error.
-func (s *HandlerSuite) TestPullWorkerIntegration_GetNonExistingLocator(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-       testData := PullWorkIntegrationTestData{
-               Name:     "TestPullWorkerIntegration_GetLocator",
-               Locator:  "5d41402abc4b2a76b9719d911017c592",
-               Content:  "hello",
-               GetError: "Block not found",
-       }
-
-       pullRequest := s.setupPullWorkerIntegrationTest(c, testData, false)
-       defer arvadostest.StopKeep(2)
-
-       s.performPullWorkerIntegrationTest(testData, pullRequest, c)
-}
-
-// Do a get on a block that exists on one of the keep servers.
-// The setup method will create this block before doing the get.
-func (s *HandlerSuite) TestPullWorkerIntegration_GetExistingLocator(c *check.C) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-       testData := PullWorkIntegrationTestData{
-               Name:     "TestPullWorkerIntegration_GetLocator",
-               Locator:  "5d41402abc4b2a76b9719d911017c592",
-               Content:  "hello",
-               GetError: "",
-       }
-
-       pullRequest := s.setupPullWorkerIntegrationTest(c, testData, true)
-       defer arvadostest.StopKeep(2)
-
-       s.performPullWorkerIntegrationTest(testData, pullRequest, c)
-}
-
-// Perform the test.
-// The test directly invokes the "PullItemAndProcess" rather than
-// putting an item on the pullq so that the errors can be verified.
-func (s *HandlerSuite) performPullWorkerIntegrationTest(testData PullWorkIntegrationTestData, pullRequest PullRequest, c *check.C) {
-
-       // Override writePulledBlock to mock PutBlock functionality
-       defer func(orig func(*RRVolumeManager, Volume, []byte, string) error) { writePulledBlock = orig }(writePulledBlock)
-       writePulledBlock = func(_ *RRVolumeManager, _ Volume, content []byte, _ string) error {
-               c.Check(string(content), check.Equals, testData.Content)
-               return nil
-       }
-
-       // Override GetContent to mock keepclient Get functionality
-       defer func(orig func(string, *keepclient.KeepClient) (io.ReadCloser, int64, string, error)) {
-               GetContent = orig
-       }(GetContent)
-       GetContent = func(signedLocator string, keepClient *keepclient.KeepClient) (reader io.ReadCloser, contentLength int64, url string, err error) {
-               if testData.GetError != "" {
-                       return nil, 0, "", errors.New(testData.GetError)
-               }
-               rdr := ioutil.NopCloser(bytes.NewBufferString(testData.Content))
-               return rdr, int64(len(testData.Content)), "", nil
-       }
-
-       err := s.handler.pullItemAndProcess(pullRequest)
-
-       if len(testData.GetError) > 0 {
-               if (err == nil) || (!strings.Contains(err.Error(), testData.GetError)) {
-                       c.Errorf("Got error %v, expected %v", err, testData.GetError)
-               }
-       } else {
-               if err != nil {
-                       c.Errorf("Got error %v, expected nil", err)
-               }
-       }
-}
index 2626e66d8898745b9f29c42d9beda9ee580626a4..d109b56df3cee8e2ac3259ebb784fe4cfdacc20b 100644 (file)
@@ -7,309 +7,130 @@ package keepstore
 import (
        "bytes"
        "context"
+       "crypto/md5"
+       "encoding/json"
        "errors"
+       "fmt"
        "io"
-       "io/ioutil"
        "net/http"
+       "net/http/httptest"
+       "sort"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
-       "git.arvados.org/arvados.git/sdk/go/keepclient"
-       "github.com/prometheus/client_golang/prometheus"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "github.com/sirupsen/logrus"
        . "gopkg.in/check.v1"
-       check "gopkg.in/check.v1"
 )
 
-var _ = Suite(&PullWorkerTestSuite{})
-
-type PullWorkerTestSuite struct {
-       cluster *arvados.Cluster
-       handler *handler
-
-       testPullLists map[string]string
-       readContent   string
-       readError     error
-       putContent    []byte
-       putError      error
-}
-
-func (s *PullWorkerTestSuite) SetUpTest(c *C) {
-       s.cluster = testCluster(c)
-       s.cluster.Volumes = map[string]arvados.Volume{
-               "zzzzz-nyw5e-000000000000000": {Driver: "mock"},
-               "zzzzz-nyw5e-111111111111111": {Driver: "mock"},
+func (s *routerSuite) TestPullList_Execute(c *C) {
+       remotecluster := testCluster(c)
+       remotecluster.Volumes = map[string]arvados.Volume{
+               "zzzzz-nyw5e-rrrrrrrrrrrrrrr": {Replication: 1, Driver: "stub"},
        }
-       s.cluster.Collections.BlobReplicateConcurrency = 1
-
-       s.handler = &handler{}
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-
-       s.readContent = ""
-       s.readError = nil
-       s.putContent = []byte{}
-       s.putError = nil
-
-       // When a new pull request arrives, the old one will be overwritten.
-       // This behavior is verified using these two maps in the
-       // "TestPullWorkerPullList_with_two_items_latest_replacing_old"
-       s.testPullLists = make(map[string]string)
-}
-
-var firstPullList = []byte(`[
-               {
-                       "locator":"acbd18db4cc2f85cedef654fccc4a4d8+3",
-                       "servers":[
-                               "server_1",
-                               "server_2"
-                       ]
-               },{
-                       "locator":"37b51d194a7513e45b56f6524f2d51f2+3",
-                       "servers":[
-                               "server_3"
-                       ]
-               }
-       ]`)
-
-var secondPullList = []byte(`[
-               {
-                       "locator":"73feffa4b7f6bb68e44cf984c85f6e88+3",
-                       "servers":[
-                               "server_1",
-                               "server_2"
-                       ]
-               }
-       ]`)
-
-type PullWorkerTestData struct {
-       name         string
-       req          RequestTester
-       responseCode int
-       responseBody string
-       readContent  string
-       readError    bool
-       putError     bool
-}
-
-// Ensure MountUUID in a pull list is correctly translated to a Volume
-// argument passed to writePulledBlock().
-func (s *PullWorkerTestSuite) TestSpecifyMountUUID(c *C) {
-       defer func(f func(*RRVolumeManager, Volume, []byte, string) error) {
-               writePulledBlock = f
-       }(writePulledBlock)
-       pullq := s.handler.Handler.(*router).pullq
-
-       for _, spec := range []struct {
-               sendUUID     string
-               expectVolume Volume
-       }{
-               {
-                       sendUUID:     "",
-                       expectVolume: nil,
-               },
-               {
-                       sendUUID:     s.handler.volmgr.Mounts()[0].UUID,
-                       expectVolume: s.handler.volmgr.Mounts()[0].Volume,
-               },
-       } {
-               writePulledBlock = func(_ *RRVolumeManager, v Volume, _ []byte, _ string) error {
-                       c.Check(v, Equals, spec.expectVolume)
-                       return nil
+       remoterouter, cancel := testRouter(c, remotecluster, nil)
+       defer cancel()
+       remoteserver := httptest.NewServer(remoterouter)
+       defer remoteserver.Close()
+
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       executePullList := func(pullList []PullListItem) string {
+               var logbuf bytes.Buffer
+               logger := logrus.New()
+               logger.Out = &logbuf
+               router.keepstore.logger = logger
+
+               listjson, err := json.Marshal(pullList)
+               c.Assert(err, IsNil)
+               resp := call(router, "PUT", "http://example/pull", s.cluster.SystemRootToken, listjson, nil)
+               c.Check(resp.Code, Equals, http.StatusOK)
+               for {
+                       router.puller.cond.L.Lock()
+                       todolen := len(router.puller.todo)
+                       router.puller.cond.L.Unlock()
+                       if todolen == 0 && router.puller.inprogress.Load() == 0 {
+                               break
+                       }
+                       time.Sleep(time.Millisecond)
                }
-
-               resp := IssueRequest(s.handler, &RequestTester{
-                       uri:      "/pull",
-                       apiToken: s.cluster.SystemRootToken,
-                       method:   "PUT",
-                       requestBody: []byte(`[{
-                               "locator":"acbd18db4cc2f85cedef654fccc4a4d8+3",
-                               "servers":["server_1","server_2"],
-                               "mount_uuid":"` + spec.sendUUID + `"}]`),
-               })
-               c.Assert(resp.Code, Equals, http.StatusOK)
-               expectEqualWithin(c, time.Second, 0, func() interface{} {
-                       st := pullq.Status()
-                       return st.InProgress + st.Queued
-               })
-       }
-}
-
-func (s *PullWorkerTestSuite) TestPullWorkerPullList_with_two_locators(c *C) {
-       testData := PullWorkerTestData{
-               name:         "TestPullWorkerPullList_with_two_locators",
-               req:          RequestTester{"/pull", s.cluster.SystemRootToken, "PUT", firstPullList, ""},
-               responseCode: http.StatusOK,
-               responseBody: "Received 2 pull requests\n",
-               readContent:  "hello",
-               readError:    false,
-               putError:     false,
-       }
-
-       s.performTest(testData, c)
-}
-
-func (s *PullWorkerTestSuite) TestPullWorkerPullList_with_one_locator(c *C) {
-       testData := PullWorkerTestData{
-               name:         "TestPullWorkerPullList_with_one_locator",
-               req:          RequestTester{"/pull", s.cluster.SystemRootToken, "PUT", secondPullList, ""},
-               responseCode: http.StatusOK,
-               responseBody: "Received 1 pull requests\n",
-               readContent:  "hola",
-               readError:    false,
-               putError:     false,
-       }
-
-       s.performTest(testData, c)
-}
-
-func (s *PullWorkerTestSuite) TestPullWorker_error_on_get_one_locator(c *C) {
-       testData := PullWorkerTestData{
-               name:         "TestPullWorker_error_on_get_one_locator",
-               req:          RequestTester{"/pull", s.cluster.SystemRootToken, "PUT", secondPullList, ""},
-               responseCode: http.StatusOK,
-               responseBody: "Received 1 pull requests\n",
-               readContent:  "unused",
-               readError:    true,
-               putError:     false,
+               return logbuf.String()
        }
 
-       s.performTest(testData, c)
-}
-
-func (s *PullWorkerTestSuite) TestPullWorker_error_on_get_two_locators(c *C) {
-       testData := PullWorkerTestData{
-               name:         "TestPullWorker_error_on_get_two_locators",
-               req:          RequestTester{"/pull", s.cluster.SystemRootToken, "PUT", firstPullList, ""},
-               responseCode: http.StatusOK,
-               responseBody: "Received 2 pull requests\n",
-               readContent:  "unused",
-               readError:    true,
-               putError:     false,
-       }
-
-       s.performTest(testData, c)
-}
-
-func (s *PullWorkerTestSuite) TestPullWorker_error_on_put_one_locator(c *C) {
-       testData := PullWorkerTestData{
-               name:         "TestPullWorker_error_on_put_one_locator",
-               req:          RequestTester{"/pull", s.cluster.SystemRootToken, "PUT", secondPullList, ""},
-               responseCode: http.StatusOK,
-               responseBody: "Received 1 pull requests\n",
-               readContent:  "hello hello",
-               readError:    false,
-               putError:     true,
-       }
-
-       s.performTest(testData, c)
-}
-
-func (s *PullWorkerTestSuite) TestPullWorker_error_on_put_two_locators(c *C) {
-       testData := PullWorkerTestData{
-               name:         "TestPullWorker_error_on_put_two_locators",
-               req:          RequestTester{"/pull", s.cluster.SystemRootToken, "PUT", firstPullList, ""},
-               responseCode: http.StatusOK,
-               responseBody: "Received 2 pull requests\n",
-               readContent:  "hello again",
-               readError:    false,
-               putError:     true,
-       }
-
-       s.performTest(testData, c)
-}
-
-// In this case, the item will not be placed on pullq
-func (s *PullWorkerTestSuite) TestPullWorker_invalidToken(c *C) {
-       testData := PullWorkerTestData{
-               name:         "TestPullWorkerPullList_with_two_locators",
-               req:          RequestTester{"/pull", "invalidToken", "PUT", firstPullList, ""},
-               responseCode: http.StatusUnauthorized,
-               responseBody: "Unauthorized\n",
-               readContent:  "hello",
-               readError:    false,
-               putError:     false,
-       }
-
-       s.performTest(testData, c)
-}
-
-func (s *PullWorkerTestSuite) performTest(testData PullWorkerTestData, c *C) {
-       pullq := s.handler.Handler.(*router).pullq
-
-       s.testPullLists[testData.name] = testData.responseBody
-
-       processedPullLists := make(map[string]string)
-
-       // Override GetContent to mock keepclient Get functionality
-       defer func(orig func(string, *keepclient.KeepClient) (io.ReadCloser, int64, string, error)) {
-               GetContent = orig
-       }(GetContent)
-       GetContent = func(signedLocator string, keepClient *keepclient.KeepClient) (reader io.ReadCloser, contentLength int64, url string, err error) {
-               c.Assert(getStatusItem(s.handler, "PullQueue", "InProgress"), Equals, float64(1))
-               processedPullLists[testData.name] = testData.responseBody
-               if testData.readError {
-                       err = errors.New("Error getting data")
-                       s.readError = err
-                       return
-               }
-               s.readContent = testData.readContent
-               reader = ioutil.NopCloser(bytes.NewBufferString(testData.readContent))
-               contentLength = int64(len(testData.readContent))
-               return
+       newRemoteBlock := func(datastring string) string {
+               data := []byte(datastring)
+               hash := fmt.Sprintf("%x", md5.Sum(data))
+               locator := fmt.Sprintf("%s+%d", hash, len(data))
+               _, err := remoterouter.keepstore.BlockWrite(context.Background(), arvados.BlockWriteOptions{
+                       Hash: hash,
+                       Data: data,
+               })
+               c.Assert(err, IsNil)
+               return locator
        }
 
-       // Override writePulledBlock to mock PutBlock functionality
-       defer func(orig func(*RRVolumeManager, Volume, []byte, string) error) { writePulledBlock = orig }(writePulledBlock)
-       writePulledBlock = func(_ *RRVolumeManager, v Volume, content []byte, locator string) error {
-               if testData.putError {
-                       s.putError = errors.New("Error putting data")
-                       return s.putError
-               }
-               s.putContent = content
-               return nil
+       mounts := append([]*mount(nil), router.keepstore.mountsR...)
+       sort.Slice(mounts, func(i, j int) bool { return mounts[i].UUID < mounts[j].UUID })
+       var vols []*stubVolume
+       for _, mount := range mounts {
+               vols = append(vols, mount.volume.(*stubVolume))
        }
 
-       c.Check(getStatusItem(s.handler, "PullQueue", "InProgress"), Equals, float64(0))
-       c.Check(getStatusItem(s.handler, "PullQueue", "Queued"), Equals, float64(0))
-       c.Check(getStatusItem(s.handler, "Version"), Not(Equals), "")
-
-       response := IssueRequest(s.handler, &testData.req)
-       c.Assert(response.Code, Equals, testData.responseCode)
-       c.Assert(response.Body.String(), Equals, testData.responseBody)
+       ctx := authContext(arvadostest.ActiveTokenV2)
 
-       expectEqualWithin(c, time.Second, 0, func() interface{} {
-               st := pullq.Status()
-               return st.InProgress + st.Queued
-       })
+       locator := newRemoteBlock("pull available block to unspecified volume")
+       executePullList([]PullListItem{{
+               Locator: locator,
+               Servers: []string{remoteserver.URL}}})
+       _, err := router.keepstore.BlockRead(ctx, arvados.BlockReadOptions{
+               Locator: router.keepstore.signLocator(arvadostest.ActiveTokenV2, locator),
+               WriteTo: io.Discard})
+       c.Check(err, IsNil)
 
-       if testData.name == "TestPullWorkerPullList_with_two_items_latest_replacing_old" {
-               c.Assert(len(s.testPullLists), Equals, 2)
-               c.Assert(len(processedPullLists), Equals, 1)
-               c.Assert(s.testPullLists["Added_before_actual_test_item"], NotNil)
-               c.Assert(s.testPullLists["TestPullWorkerPullList_with_two_items_latest_replacing_old"], NotNil)
-               c.Assert(processedPullLists["TestPullWorkerPullList_with_two_items_latest_replacing_old"], NotNil)
-       } else {
-               if testData.responseCode == http.StatusOK {
-                       c.Assert(len(s.testPullLists), Equals, 1)
-                       c.Assert(len(processedPullLists), Equals, 1)
-                       c.Assert(s.testPullLists[testData.name], NotNil)
-               } else {
-                       c.Assert(len(s.testPullLists), Equals, 1)
-                       c.Assert(len(processedPullLists), Equals, 0)
-               }
-       }
-
-       if testData.readError {
-               c.Assert(s.readError, NotNil)
-       } else if testData.responseCode == http.StatusOK {
-               c.Assert(s.readError, IsNil)
-               c.Assert(s.readContent, Equals, testData.readContent)
-               if testData.putError {
-                       c.Assert(s.putError, NotNil)
-               } else {
-                       c.Assert(s.putError, IsNil)
-                       c.Assert(string(s.putContent), Equals, testData.readContent)
-               }
-       }
-
-       expectChannelEmpty(c, pullq.NextItem)
+       locator0 := newRemoteBlock("pull available block to specified volume 0")
+       locator1 := newRemoteBlock("pull available block to specified volume 1")
+       executePullList([]PullListItem{
+               {
+                       Locator:   locator0,
+                       Servers:   []string{remoteserver.URL},
+                       MountUUID: vols[0].params.UUID},
+               {
+                       Locator:   locator1,
+                       Servers:   []string{remoteserver.URL},
+                       MountUUID: vols[1].params.UUID}})
+       c.Check(vols[0].data[locator0[:32]].data, NotNil)
+       c.Check(vols[1].data[locator1[:32]].data, NotNil)
+
+       locator = fooHash + "+3"
+       logs := executePullList([]PullListItem{{
+               Locator: locator,
+               Servers: []string{remoteserver.URL}}})
+       c.Check(logs, Matches, ".*error pulling data from remote servers.*Block not found.*locator=acbd.*\n")
+
+       locator = fooHash + "+3"
+       logs = executePullList([]PullListItem{{
+               Locator: locator,
+               Servers: []string{"http://0.0.0.0:9/"}}})
+       c.Check(logs, Matches, ".*error pulling data from remote servers.*connection refused.*locator=acbd.*\n")
+
+       locator = newRemoteBlock("log error writing to local volume")
+       vols[0].blockWrite = func(context.Context, string, []byte) error { return errors.New("test error") }
+       vols[1].blockWrite = vols[0].blockWrite
+       logs = executePullList([]PullListItem{{
+               Locator: locator,
+               Servers: []string{remoteserver.URL}}})
+       c.Check(logs, Matches, ".*error writing data to zzzzz-nyw5e-.*error=\"test error\".*locator=.*\n")
+       vols[0].blockWrite = nil
+       vols[1].blockWrite = nil
+
+       locator = newRemoteBlock("log error when destination mount does not exist")
+       logs = executePullList([]PullListItem{{
+               Locator:   locator,
+               Servers:   []string{remoteserver.URL},
+               MountUUID: "bogus-mount-uuid"}})
+       c.Check(logs, Matches, ".*ignoring pull list entry for nonexistent mount bogus-mount-uuid.*locator=.*\n")
+
+       logs = executePullList([]PullListItem{})
+       c.Logf("%s", logs)
 }
diff --git a/services/keepstore/putprogress.go b/services/keepstore/putprogress.go
new file mode 100644 (file)
index 0000000..e02b2d0
--- /dev/null
@@ -0,0 +1,101 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package keepstore
+
+import (
+       "github.com/sirupsen/logrus"
+)
+
+type putProgress struct {
+       classNeeded      map[string]bool
+       classTodo        map[string]bool
+       mountUsed        map[*mount]bool
+       totalReplication int
+       classDone        map[string]int
+}
+
+func (pr *putProgress) Add(mnt *mount) {
+       if pr.mountUsed[mnt] {
+               logrus.Warnf("BUG? superfluous extra write to mount %s", mnt.UUID)
+               return
+       }
+       pr.mountUsed[mnt] = true
+       pr.totalReplication += mnt.Replication
+       for class := range mnt.StorageClasses {
+               pr.classDone[class] += mnt.Replication
+               delete(pr.classTodo, class)
+       }
+}
+
+func (pr *putProgress) Sub(mnt *mount) {
+       if !pr.mountUsed[mnt] {
+               logrus.Warnf("BUG? Sub called with no prior matching Add: %s", mnt.UUID)
+               return
+       }
+       pr.mountUsed[mnt] = false
+       pr.totalReplication -= mnt.Replication
+       for class := range mnt.StorageClasses {
+               pr.classDone[class] -= mnt.Replication
+               if pr.classNeeded[class] {
+                       pr.classTodo[class] = true
+               }
+       }
+}
+
+func (pr *putProgress) Done() bool {
+       return len(pr.classTodo) == 0 && pr.totalReplication > 0
+}
+
+func (pr *putProgress) Want(mnt *mount) bool {
+       if pr.Done() || pr.mountUsed[mnt] {
+               return false
+       }
+       if len(pr.classTodo) == 0 {
+               // none specified == "any"
+               return true
+       }
+       for class := range mnt.StorageClasses {
+               if pr.classTodo[class] {
+                       return true
+               }
+       }
+       return false
+}
+
+func (pr *putProgress) Copy() *putProgress {
+       cp := putProgress{
+               classNeeded:      pr.classNeeded,
+               classTodo:        make(map[string]bool, len(pr.classTodo)),
+               classDone:        make(map[string]int, len(pr.classDone)),
+               mountUsed:        make(map[*mount]bool, len(pr.mountUsed)),
+               totalReplication: pr.totalReplication,
+       }
+       for k, v := range pr.classTodo {
+               cp.classTodo[k] = v
+       }
+       for k, v := range pr.classDone {
+               cp.classDone[k] = v
+       }
+       for k, v := range pr.mountUsed {
+               cp.mountUsed[k] = v
+       }
+       return &cp
+}
+
+func newPutProgress(classes []string) putProgress {
+       pr := putProgress{
+               classNeeded: make(map[string]bool, len(classes)),
+               classTodo:   make(map[string]bool, len(classes)),
+               classDone:   map[string]int{},
+               mountUsed:   map[*mount]bool{},
+       }
+       for _, c := range classes {
+               if c != "" {
+                       pr.classNeeded[c] = true
+                       pr.classTodo[c] = true
+               }
+       }
+       return pr
+}
diff --git a/services/keepstore/router.go b/services/keepstore/router.go
new file mode 100644 (file)
index 0000000..0c8182c
--- /dev/null
@@ -0,0 +1,276 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package keepstore
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "net/http"
+       "os"
+       "strconv"
+       "strings"
+       "sync/atomic"
+
+       "git.arvados.org/arvados.git/lib/service"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "github.com/gorilla/mux"
+)
+
+type router struct {
+       http.Handler
+       keepstore *keepstore
+       puller    *puller
+       trasher   *trasher
+}
+
+func newRouter(keepstore *keepstore, puller *puller, trasher *trasher) service.Handler {
+       rtr := &router{
+               keepstore: keepstore,
+               puller:    puller,
+               trasher:   trasher,
+       }
+       adminonly := func(h http.HandlerFunc) http.HandlerFunc {
+               return auth.RequireLiteralToken(keepstore.cluster.SystemRootToken, h).ServeHTTP
+       }
+
+       r := mux.NewRouter()
+       locatorPath := `/{locator:[0-9a-f]{32}.*}`
+       get := r.Methods(http.MethodGet, http.MethodHead).Subrouter()
+       get.HandleFunc(locatorPath, rtr.handleBlockRead)
+       get.HandleFunc(`/index`, adminonly(rtr.handleIndex))
+       get.HandleFunc(`/index/{prefix:[0-9a-f]{0,32}}`, adminonly(rtr.handleIndex))
+       get.HandleFunc(`/mounts`, adminonly(rtr.handleMounts))
+       get.HandleFunc(`/mounts/{uuid}/blocks`, adminonly(rtr.handleIndex))
+       get.HandleFunc(`/mounts/{uuid}/blocks/{prefix:[0-9a-f]{0,32}}`, adminonly(rtr.handleIndex))
+       put := r.Methods(http.MethodPut).Subrouter()
+       put.HandleFunc(locatorPath, rtr.handleBlockWrite)
+       put.HandleFunc(`/pull`, adminonly(rtr.handlePullList))
+       put.HandleFunc(`/trash`, adminonly(rtr.handleTrashList))
+       put.HandleFunc(`/untrash`+locatorPath, adminonly(rtr.handleUntrash))
+       touch := r.Methods("TOUCH").Subrouter()
+       touch.HandleFunc(locatorPath, adminonly(rtr.handleBlockTouch))
+       delete := r.Methods(http.MethodDelete).Subrouter()
+       delete.HandleFunc(locatorPath, adminonly(rtr.handleBlockTrash))
+       r.NotFoundHandler = http.HandlerFunc(rtr.handleBadRequest)
+       r.MethodNotAllowedHandler = http.HandlerFunc(rtr.handleBadRequest)
+       rtr.Handler = auth.LoadToken(r)
+       return rtr
+}
+
+func (rtr *router) CheckHealth() error {
+       return nil
+}
+
+func (rtr *router) Done() <-chan struct{} {
+       return nil
+}
+
+func (rtr *router) handleBlockRead(w http.ResponseWriter, req *http.Request) {
+       // Intervening proxies must not return a cached GET response
+       // to a prior request if a X-Keep-Signature request header has
+       // been added or changed.
+       w.Header().Add("Vary", "X-Keep-Signature")
+       var localLocator func(string)
+       if strings.SplitN(req.Header.Get("X-Keep-Signature"), ",", 2)[0] == "local" {
+               localLocator = func(locator string) {
+                       w.Header().Set("X-Keep-Locator", locator)
+               }
+       }
+       out := w
+       if req.Method == http.MethodHead {
+               out = discardWrite{ResponseWriter: w}
+       } else if li, err := getLocatorInfo(mux.Vars(req)["locator"]); err != nil {
+               rtr.handleError(w, req, err)
+               return
+       } else if li.size == 0 && li.hash != "d41d8cd98f00b204e9800998ecf8427e" {
+               // GET {hash} (with no size hint) is not allowed
+               // because we can't report md5 mismatches.
+               rtr.handleError(w, req, errMethodNotAllowed)
+               return
+       }
+       n, err := rtr.keepstore.BlockRead(req.Context(), arvados.BlockReadOptions{
+               Locator:      mux.Vars(req)["locator"],
+               WriteTo:      out,
+               LocalLocator: localLocator,
+       })
+       if err != nil && (n == 0 || req.Method == http.MethodHead) {
+               rtr.handleError(w, req, err)
+               return
+       }
+}
+
+func (rtr *router) handleBlockWrite(w http.ResponseWriter, req *http.Request) {
+       dataSize, _ := strconv.Atoi(req.Header.Get("Content-Length"))
+       replicas, _ := strconv.Atoi(req.Header.Get("X-Arvados-Replicas-Desired"))
+       resp, err := rtr.keepstore.BlockWrite(req.Context(), arvados.BlockWriteOptions{
+               Hash:           mux.Vars(req)["locator"],
+               Reader:         req.Body,
+               DataSize:       dataSize,
+               RequestID:      req.Header.Get("X-Request-Id"),
+               StorageClasses: trimSplit(req.Header.Get("X-Keep-Storage-Classes"), ","),
+               Replicas:       replicas,
+       })
+       if err != nil {
+               rtr.handleError(w, req, err)
+               return
+       }
+       w.Header().Set("X-Keep-Replicas-Stored", fmt.Sprintf("%d", resp.Replicas))
+       scc := ""
+       for k, n := range resp.StorageClasses {
+               if n > 0 {
+                       if scc != "" {
+                               scc += "; "
+                       }
+                       scc += fmt.Sprintf("%s=%d", k, n)
+               }
+       }
+       w.Header().Set("X-Keep-Storage-Classes-Confirmed", scc)
+       w.WriteHeader(http.StatusOK)
+       fmt.Fprintln(w, resp.Locator)
+}
+
+func (rtr *router) handleBlockTouch(w http.ResponseWriter, req *http.Request) {
+       err := rtr.keepstore.BlockTouch(req.Context(), mux.Vars(req)["locator"])
+       rtr.handleError(w, req, err)
+}
+
+func (rtr *router) handleBlockTrash(w http.ResponseWriter, req *http.Request) {
+       err := rtr.keepstore.BlockTrash(req.Context(), mux.Vars(req)["locator"])
+       rtr.handleError(w, req, err)
+}
+
+func (rtr *router) handleMounts(w http.ResponseWriter, req *http.Request) {
+       json.NewEncoder(w).Encode(rtr.keepstore.Mounts())
+}
+
+func (rtr *router) handleIndex(w http.ResponseWriter, req *http.Request) {
+       prefix := req.FormValue("prefix")
+       if prefix == "" {
+               prefix = mux.Vars(req)["prefix"]
+       }
+       cw := &countingWriter{writer: w}
+       err := rtr.keepstore.Index(req.Context(), indexOptions{
+               MountUUID: mux.Vars(req)["uuid"],
+               Prefix:    prefix,
+               WriteTo:   cw,
+       })
+       if err != nil && cw.n.Load() == 0 {
+               // Nothing was written, so it's not too late to report
+               // an error via http response header. (Otherwise, all
+               // we can do is omit the trailing newline below to
+               // indicate something went wrong.)
+               rtr.handleError(w, req, err)
+               return
+       }
+       if err == nil {
+               // A trailing blank line signals to the caller that
+               // the response is complete.
+               w.Write([]byte("\n"))
+       }
+}
+
+func (rtr *router) handlePullList(w http.ResponseWriter, req *http.Request) {
+       var pl []PullListItem
+       err := json.NewDecoder(req.Body).Decode(&pl)
+       if err != nil {
+               rtr.handleError(w, req, err)
+               return
+       }
+       req.Body.Close()
+       if len(pl) > 0 && len(pl[0].Locator) == 32 {
+               rtr.handleError(w, req, httpserver.ErrorWithStatus(errors.New("rejecting pull list containing a locator without a size hint -- this probably means keep-balance needs to be upgraded"), http.StatusBadRequest))
+               return
+       }
+       rtr.puller.SetPullList(pl)
+}
+
+func (rtr *router) handleTrashList(w http.ResponseWriter, req *http.Request) {
+       var tl []TrashListItem
+       err := json.NewDecoder(req.Body).Decode(&tl)
+       if err != nil {
+               rtr.handleError(w, req, err)
+               return
+       }
+       req.Body.Close()
+       rtr.trasher.SetTrashList(tl)
+}
+
+func (rtr *router) handleUntrash(w http.ResponseWriter, req *http.Request) {
+       err := rtr.keepstore.BlockUntrash(req.Context(), mux.Vars(req)["locator"])
+       rtr.handleError(w, req, err)
+}
+
+func (rtr *router) handleBadRequest(w http.ResponseWriter, req *http.Request) {
+       http.Error(w, "Bad Request", http.StatusBadRequest)
+}
+
+func (rtr *router) handleError(w http.ResponseWriter, req *http.Request, err error) {
+       if req.Context().Err() != nil {
+               w.WriteHeader(499)
+               return
+       }
+       if err == nil {
+               return
+       } else if os.IsNotExist(err) {
+               w.WriteHeader(http.StatusNotFound)
+       } else if statusErr := interface{ HTTPStatus() int }(nil); errors.As(err, &statusErr) {
+               w.WriteHeader(statusErr.HTTPStatus())
+       } else {
+               w.WriteHeader(http.StatusInternalServerError)
+       }
+       fmt.Fprintln(w, err.Error())
+}
+
+type countingWriter struct {
+       writer io.Writer
+       n      atomic.Int64
+}
+
+func (cw *countingWriter) Write(p []byte) (int, error) {
+       n, err := cw.writer.Write(p)
+       cw.n.Add(int64(n))
+       return n, err
+}
+
+// Split s by sep, trim whitespace from each part, and drop empty
+// parts.
+func trimSplit(s, sep string) []string {
+       var r []string
+       for _, part := range strings.Split(s, sep) {
+               part = strings.TrimSpace(part)
+               if part != "" {
+                       r = append(r, part)
+               }
+       }
+       return r
+}
+
+// setSizeOnWrite sets the Content-Length header to the given size on
+// first write.
+type setSizeOnWrite struct {
+       http.ResponseWriter
+       size  int
+       wrote bool
+}
+
+func (ss *setSizeOnWrite) Write(p []byte) (int, error) {
+       if !ss.wrote {
+               ss.Header().Set("Content-Length", fmt.Sprintf("%d", ss.size))
+               ss.wrote = true
+       }
+       return ss.ResponseWriter.Write(p)
+}
+
+type discardWrite struct {
+       http.ResponseWriter
+}
+
+func (discardWrite) Write(p []byte) (int, error) {
+       return len(p), nil
+}
diff --git a/services/keepstore/router_test.go b/services/keepstore/router_test.go
new file mode 100644 (file)
index 0000000..15a055d
--- /dev/null
@@ -0,0 +1,517 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package keepstore
+
+import (
+       "bytes"
+       "context"
+       "crypto/md5"
+       "errors"
+       "fmt"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "os"
+       "sort"
+       "strings"
+       "time"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "github.com/prometheus/client_golang/prometheus"
+       . "gopkg.in/check.v1"
+)
+
+// routerSuite tests that the router correctly translates HTTP
+// requests to the appropriate keepstore functionality, and translates
+// the results to HTTP responses.
+type routerSuite struct {
+       cluster *arvados.Cluster
+}
+
+var _ = Suite(&routerSuite{})
+
+func testRouter(t TB, cluster *arvados.Cluster, reg *prometheus.Registry) (*router, context.CancelFunc) {
+       if reg == nil {
+               reg = prometheus.NewRegistry()
+       }
+       ctx, cancel := context.WithCancel(context.Background())
+       ks, kcancel := testKeepstore(t, cluster, reg)
+       go func() {
+               <-ctx.Done()
+               kcancel()
+       }()
+       puller := newPuller(ctx, ks, reg)
+       trasher := newTrasher(ctx, ks, reg)
+       return newRouter(ks, puller, trasher).(*router), cancel
+}
+
+func (s *routerSuite) SetUpTest(c *C) {
+       s.cluster = testCluster(c)
+       s.cluster.Volumes = map[string]arvados.Volume{
+               "zzzzz-nyw5e-000000000000000": {Replication: 1, Driver: "stub", StorageClasses: map[string]bool{"testclass1": true}},
+               "zzzzz-nyw5e-111111111111111": {Replication: 1, Driver: "stub", StorageClasses: map[string]bool{"testclass2": true}},
+       }
+       s.cluster.StorageClasses = map[string]arvados.StorageClassConfig{
+               "testclass1": arvados.StorageClassConfig{
+                       Default: true,
+               },
+               "testclass2": arvados.StorageClassConfig{
+                       Default: true,
+               },
+       }
+}
+
+func (s *routerSuite) TestBlockRead_Token(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       err := router.keepstore.mountsW[0].BlockWrite(context.Background(), fooHash, []byte("foo"))
+       c.Assert(err, IsNil)
+       locSigned := router.keepstore.signLocator(arvadostest.ActiveTokenV2, fooHash+"+3")
+       c.Assert(locSigned, Not(Equals), fooHash+"+3")
+
+       // No token provided
+       resp := call(router, "GET", "http://example/"+locSigned, "", nil, nil)
+       c.Check(resp.Code, Equals, http.StatusUnauthorized)
+       c.Check(resp.Body.String(), Matches, "no token provided in Authorization header\n")
+
+       // Different token => invalid signature
+       resp = call(router, "GET", "http://example/"+locSigned, "badtoken", nil, nil)
+       c.Check(resp.Code, Equals, http.StatusBadRequest)
+       c.Check(resp.Body.String(), Equals, "invalid signature\n")
+
+       // Correct token
+       resp = call(router, "GET", "http://example/"+locSigned, arvadostest.ActiveTokenV2, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Equals, "foo")
+
+       // HEAD
+       resp = call(router, "HEAD", "http://example/"+locSigned, arvadostest.ActiveTokenV2, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Result().ContentLength, Equals, int64(3))
+       c.Check(resp.Body.String(), Equals, "")
+}
+
+// As a special case we allow HEAD requests that only provide a hash
+// without a size hint. This accommodates uses of keep-block-check
+// where it's inconvenient to attach size hints to known hashes.
+//
+// GET requests must provide a size hint -- otherwise we can't
+// propagate a checksum mismatch error.
+func (s *routerSuite) TestBlockRead_NoSizeHint(c *C) {
+       s.cluster.Collections.BlobSigning = true
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+       err := router.keepstore.mountsW[0].BlockWrite(context.Background(), fooHash, []byte("foo"))
+       c.Assert(err, IsNil)
+
+       // hash+signature
+       hashSigned := router.keepstore.signLocator(arvadostest.ActiveTokenV2, fooHash)
+       resp := call(router, "GET", "http://example/"+hashSigned, arvadostest.ActiveTokenV2, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusMethodNotAllowed)
+
+       resp = call(router, "HEAD", "http://example/"+fooHash, "", nil, nil)
+       c.Check(resp.Code, Equals, http.StatusUnauthorized)
+       resp = call(router, "HEAD", "http://example/"+fooHash+"+3", "", nil, nil)
+       c.Check(resp.Code, Equals, http.StatusUnauthorized)
+
+       s.cluster.Collections.BlobSigning = false
+       router, cancel = testRouter(c, s.cluster, nil)
+       defer cancel()
+       err = router.keepstore.mountsW[0].BlockWrite(context.Background(), fooHash, []byte("foo"))
+       c.Assert(err, IsNil)
+
+       resp = call(router, "GET", "http://example/"+fooHash, "", nil, nil)
+       c.Check(resp.Code, Equals, http.StatusMethodNotAllowed)
+
+       resp = call(router, "HEAD", "http://example/"+fooHash, "", nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Equals, "")
+       c.Check(resp.Result().ContentLength, Equals, int64(3))
+       c.Check(resp.Header().Get("Content-Length"), Equals, "3")
+}
+
+// By the time we discover the checksum mismatch, it's too late to
+// change the response code, but the expected block size is given in
+// the Content-Length response header, so a generic http client can
+// detect the problem.
+func (s *routerSuite) TestBlockRead_ChecksumMismatch(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       gooddata := make([]byte, 10_000_000)
+       gooddata[0] = 'a'
+       hash := fmt.Sprintf("%x", md5.Sum(gooddata))
+       locSigned := router.keepstore.signLocator(arvadostest.ActiveTokenV2, fmt.Sprintf("%s+%d", hash, len(gooddata)))
+
+       for _, baddata := range [][]byte{
+               make([]byte, 3),
+               make([]byte, len(gooddata)),
+               make([]byte, len(gooddata)-1),
+               make([]byte, len(gooddata)+1),
+               make([]byte, len(gooddata)*2),
+       } {
+               c.Logf("=== baddata len %d", len(baddata))
+               err := router.keepstore.mountsW[0].BlockWrite(context.Background(), hash, baddata)
+               c.Assert(err, IsNil)
+
+               resp := call(router, "GET", "http://example/"+locSigned, arvadostest.ActiveTokenV2, nil, nil)
+               if !c.Check(resp.Code, Equals, http.StatusOK) {
+                       c.Logf("resp.Body: %s", resp.Body.String())
+               }
+               c.Check(resp.Body.Len(), Not(Equals), len(gooddata))
+               c.Check(resp.Result().ContentLength, Equals, int64(len(gooddata)))
+
+               resp = call(router, "HEAD", "http://example/"+locSigned, arvadostest.ActiveTokenV2, nil, nil)
+               c.Check(resp.Code, Equals, http.StatusBadGateway)
+
+               hashSigned := router.keepstore.signLocator(arvadostest.ActiveTokenV2, hash)
+               resp = call(router, "HEAD", "http://example/"+hashSigned, arvadostest.ActiveTokenV2, nil, nil)
+               c.Check(resp.Code, Equals, http.StatusBadGateway)
+       }
+}
+
+func (s *routerSuite) TestBlockWrite(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       resp := call(router, "PUT", "http://example/"+fooHash, arvadostest.ActiveTokenV2, []byte("foo"), nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       locator := strings.TrimSpace(resp.Body.String())
+
+       resp = call(router, "GET", "http://example/"+locator, arvadostest.ActiveTokenV2, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Equals, "foo")
+}
+
+func (s *routerSuite) TestBlockWrite_Headers(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       resp := call(router, "PUT", "http://example/"+fooHash, arvadostest.ActiveTokenV2, []byte("foo"), http.Header{"X-Arvados-Replicas-Desired": []string{"2"}})
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Header().Get("X-Keep-Replicas-Stored"), Equals, "1")
+       c.Check(sortCommaSeparated(resp.Header().Get("X-Keep-Storage-Classes-Confirmed")), Equals, "testclass1=1")
+
+       resp = call(router, "PUT", "http://example/"+fooHash, arvadostest.ActiveTokenV2, []byte("foo"), http.Header{"X-Keep-Storage-Classes": []string{"testclass1"}})
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Header().Get("X-Keep-Replicas-Stored"), Equals, "1")
+       c.Check(resp.Header().Get("X-Keep-Storage-Classes-Confirmed"), Equals, "testclass1=1")
+
+       resp = call(router, "PUT", "http://example/"+fooHash, arvadostest.ActiveTokenV2, []byte("foo"), http.Header{"X-Keep-Storage-Classes": []string{" , testclass2 , "}})
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Header().Get("X-Keep-Replicas-Stored"), Equals, "1")
+       c.Check(resp.Header().Get("X-Keep-Storage-Classes-Confirmed"), Equals, "testclass2=1")
+}
+
+func sortCommaSeparated(s string) string {
+       slice := strings.Split(s, ", ")
+       sort.Strings(slice)
+       return strings.Join(slice, ", ")
+}
+
+func (s *routerSuite) TestBlockTouch(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       resp := call(router, "TOUCH", "http://example/"+fooHash+"+3", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusNotFound)
+
+       vol0 := router.keepstore.mountsW[0].volume.(*stubVolume)
+       err := vol0.BlockWrite(context.Background(), fooHash, []byte("foo"))
+       c.Assert(err, IsNil)
+       vol1 := router.keepstore.mountsW[1].volume.(*stubVolume)
+       err = vol1.BlockWrite(context.Background(), fooHash, []byte("foo"))
+       c.Assert(err, IsNil)
+
+       t1 := time.Now()
+       resp = call(router, "TOUCH", "http://example/"+fooHash+"+3", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       t2 := time.Now()
+
+       // Unauthorized request is a no-op
+       resp = call(router, "TOUCH", "http://example/"+fooHash+"+3", arvadostest.ActiveTokenV2, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusForbidden)
+
+       // Volume 0 mtime should be updated
+       t, err := vol0.Mtime(fooHash)
+       c.Check(err, IsNil)
+       c.Check(t.After(t1), Equals, true)
+       c.Check(t.Before(t2), Equals, true)
+
+       // Volume 1 mtime should not be updated
+       t, err = vol1.Mtime(fooHash)
+       c.Check(err, IsNil)
+       c.Check(t.Before(t1), Equals, true)
+
+       err = vol0.BlockTrash(fooHash)
+       c.Assert(err, IsNil)
+       err = vol1.BlockTrash(fooHash)
+       c.Assert(err, IsNil)
+       resp = call(router, "TOUCH", "http://example/"+fooHash+"+3", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusNotFound)
+}
+
+func (s *routerSuite) TestBlockTrash(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       vol0 := router.keepstore.mountsW[0].volume.(*stubVolume)
+       err := vol0.BlockWrite(context.Background(), fooHash, []byte("foo"))
+       c.Assert(err, IsNil)
+       err = vol0.blockTouchWithTime(fooHash, time.Now().Add(-s.cluster.Collections.BlobSigningTTL.Duration()))
+       c.Assert(err, IsNil)
+       resp := call(router, "DELETE", "http://example/"+fooHash+"+3", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(vol0.stubLog.String(), Matches, `(?ms).* trash .*`)
+       err = vol0.BlockRead(context.Background(), fooHash, brdiscard)
+       c.Assert(err, Equals, os.ErrNotExist)
+}
+
+func (s *routerSuite) TestBlockUntrash(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       vol0 := router.keepstore.mountsW[0].volume.(*stubVolume)
+       err := vol0.BlockWrite(context.Background(), fooHash, []byte("foo"))
+       c.Assert(err, IsNil)
+       err = vol0.BlockTrash(fooHash)
+       c.Assert(err, IsNil)
+       err = vol0.BlockRead(context.Background(), fooHash, brdiscard)
+       c.Assert(err, Equals, os.ErrNotExist)
+       resp := call(router, "PUT", "http://example/untrash/"+fooHash+"+3", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(vol0.stubLog.String(), Matches, `(?ms).* untrash .*`)
+       err = vol0.BlockRead(context.Background(), fooHash, brdiscard)
+       c.Check(err, IsNil)
+}
+
+func (s *routerSuite) TestBadRequest(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       for _, trial := range []string{
+               "GET /",
+               "GET /xyz",
+               "GET /aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabcdefg",
+               "GET /untrash",
+               "GET /mounts/blocks/123",
+               "GET /trash",
+               "GET /pull",
+               "GET /debug.json",  // old endpoint, no longer exists
+               "GET /status.json", // old endpoint, no longer exists
+               "POST /",
+               "POST /aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+               "POST /trash",
+               "PROPFIND /",
+               "MAKE-COFFEE /",
+       } {
+               c.Logf("=== %s", trial)
+               methodpath := strings.Split(trial, " ")
+               req := httptest.NewRequest(methodpath[0], "http://example"+methodpath[1], nil)
+               resp := httptest.NewRecorder()
+               router.ServeHTTP(resp, req)
+               c.Check(resp.Code, Equals, http.StatusBadRequest)
+       }
+}
+
+func (s *routerSuite) TestRequireAdminMgtToken(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       for _, token := range []string{"badtoken", ""} {
+               for _, trial := range []string{
+                       "PUT /pull",
+                       "PUT /trash",
+                       "GET /index",
+                       "GET /index/",
+                       "GET /index/1234",
+                       "PUT /untrash/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+               } {
+                       c.Logf("=== %s", trial)
+                       methodpath := strings.Split(trial, " ")
+                       req := httptest.NewRequest(methodpath[0], "http://example"+methodpath[1], nil)
+                       if token != "" {
+                               req.Header.Set("Authorization", "Bearer "+token)
+                       }
+                       resp := httptest.NewRecorder()
+                       router.ServeHTTP(resp, req)
+                       if token == "" {
+                               c.Check(resp.Code, Equals, http.StatusUnauthorized)
+                       } else {
+                               c.Check(resp.Code, Equals, http.StatusForbidden)
+                       }
+               }
+       }
+       req := httptest.NewRequest("TOUCH", "http://example/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
+       resp := httptest.NewRecorder()
+       router.ServeHTTP(resp, req)
+       c.Check(resp.Code, Equals, http.StatusUnauthorized)
+}
+
+func (s *routerSuite) TestVolumeErrorStatusCode(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+       router.keepstore.mountsW[0].volume.(*stubVolume).blockRead = func(_ context.Context, hash string, w io.WriterAt) error {
+               return httpserver.ErrorWithStatus(errors.New("test error"), http.StatusBadGateway)
+       }
+
+       // To test whether we fall back to volume 1 after volume 0
+       // returns an error, we need to use a block whose rendezvous
+       // order has volume 0 first. Luckily "bar" is such a block.
+       c.Assert(router.keepstore.rendezvous(barHash, router.keepstore.mountsR)[0].UUID, DeepEquals, router.keepstore.mountsR[0].UUID)
+
+       locSigned := router.keepstore.signLocator(arvadostest.ActiveTokenV2, barHash+"+3")
+
+       // Volume 0 fails with an error that specifies an HTTP status
+       // code, so that code should be propagated to caller.
+       resp := call(router, "GET", "http://example/"+locSigned, arvadostest.ActiveTokenV2, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusBadGateway)
+       c.Check(resp.Body.String(), Equals, "test error\n")
+
+       router.keepstore.mountsW[0].volume.(*stubVolume).blockRead = func(_ context.Context, hash string, w io.WriterAt) error {
+               return errors.New("no http status provided")
+       }
+       resp = call(router, "GET", "http://example/"+locSigned, arvadostest.ActiveTokenV2, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusInternalServerError)
+       c.Check(resp.Body.String(), Equals, "no http status provided\n")
+
+       c.Assert(router.keepstore.mountsW[1].volume.BlockWrite(context.Background(), barHash, []byte("bar")), IsNil)
+
+       // If the requested block is available on the second volume,
+       // it doesn't matter that the first volume failed.
+       resp = call(router, "GET", "http://example/"+locSigned, arvadostest.ActiveTokenV2, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Equals, "bar")
+}
+
+func (s *routerSuite) TestIndex(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       resp := call(router, "GET", "http://example/index", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Equals, "\n")
+
+       resp = call(router, "GET", "http://example/index?prefix=fff", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(resp.Body.String(), Equals, "\n")
+
+       t0 := time.Now().Add(-time.Hour)
+       vol0 := router.keepstore.mounts["zzzzz-nyw5e-000000000000000"].volume.(*stubVolume)
+       err := vol0.BlockWrite(context.Background(), fooHash, []byte("foo"))
+       c.Assert(err, IsNil)
+       err = vol0.blockTouchWithTime(fooHash, t0)
+       c.Assert(err, IsNil)
+       err = vol0.BlockWrite(context.Background(), barHash, []byte("bar"))
+       c.Assert(err, IsNil)
+       err = vol0.blockTouchWithTime(barHash, t0)
+       c.Assert(err, IsNil)
+       t1 := time.Now().Add(-time.Minute)
+       vol1 := router.keepstore.mounts["zzzzz-nyw5e-111111111111111"].volume.(*stubVolume)
+       err = vol1.BlockWrite(context.Background(), barHash, []byte("bar"))
+       c.Assert(err, IsNil)
+       err = vol1.blockTouchWithTime(barHash, t1)
+       c.Assert(err, IsNil)
+
+       for _, path := range []string{
+               "/index?prefix=acb",
+               "/index/acb",
+               "/index/?prefix=acb",
+               "/mounts/zzzzz-nyw5e-000000000000000/blocks?prefix=acb",
+               "/mounts/zzzzz-nyw5e-000000000000000/blocks/?prefix=acb",
+               "/mounts/zzzzz-nyw5e-000000000000000/blocks/acb",
+       } {
+               c.Logf("=== %s", path)
+               resp = call(router, "GET", "http://example"+path, s.cluster.SystemRootToken, nil, nil)
+               c.Check(resp.Code, Equals, http.StatusOK)
+               c.Check(resp.Body.String(), Equals, fooHash+"+3 "+fmt.Sprintf("%d", t0.UnixNano())+"\n\n")
+       }
+
+       for _, path := range []string{
+               "/index?prefix=37",
+               "/index/37",
+               "/index/?prefix=37",
+       } {
+               c.Logf("=== %s", path)
+               resp = call(router, "GET", "http://example"+path, s.cluster.SystemRootToken, nil, nil)
+               c.Check(resp.Code, Equals, http.StatusOK)
+               c.Check(resp.Body.String(), Equals, ""+
+                       barHash+"+3 "+fmt.Sprintf("%d", t0.UnixNano())+"\n"+
+                       barHash+"+3 "+fmt.Sprintf("%d", t1.UnixNano())+"\n\n")
+       }
+
+       for _, path := range []string{
+               "/mounts/zzzzz-nyw5e-111111111111111/blocks",
+               "/mounts/zzzzz-nyw5e-111111111111111/blocks/",
+               "/mounts/zzzzz-nyw5e-111111111111111/blocks?prefix=37",
+               "/mounts/zzzzz-nyw5e-111111111111111/blocks/?prefix=37",
+               "/mounts/zzzzz-nyw5e-111111111111111/blocks/37",
+       } {
+               c.Logf("=== %s", path)
+               resp = call(router, "GET", "http://example"+path, s.cluster.SystemRootToken, nil, nil)
+               c.Check(resp.Code, Equals, http.StatusOK)
+               c.Check(resp.Body.String(), Equals, barHash+"+3 "+fmt.Sprintf("%d", t1.UnixNano())+"\n\n")
+       }
+
+       for _, path := range []string{
+               "/index",
+               "/index?prefix=",
+               "/index/",
+               "/index/?prefix=",
+       } {
+               c.Logf("=== %s", path)
+               resp = call(router, "GET", "http://example"+path, s.cluster.SystemRootToken, nil, nil)
+               c.Check(resp.Code, Equals, http.StatusOK)
+               c.Check(strings.Split(resp.Body.String(), "\n"), HasLen, 5)
+       }
+
+}
+
+// Check that the context passed to a volume method gets cancelled
+// when the http client hangs up.
+func (s *routerSuite) TestCancelOnDisconnect(c *C) {
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       unblock := make(chan struct{})
+       router.keepstore.mountsW[0].volume.(*stubVolume).blockRead = func(ctx context.Context, hash string, w io.WriterAt) error {
+               <-unblock
+               c.Check(ctx.Err(), NotNil)
+               return ctx.Err()
+       }
+       go func() {
+               time.Sleep(time.Second / 10)
+               cancel()
+               close(unblock)
+       }()
+       locSigned := router.keepstore.signLocator(arvadostest.ActiveTokenV2, fooHash+"+3")
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+       req, err := http.NewRequestWithContext(ctx, "GET", "http://example/"+locSigned, nil)
+       c.Assert(err, IsNil)
+       req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveTokenV2)
+       resp := httptest.NewRecorder()
+       router.ServeHTTP(resp, req)
+       c.Check(resp.Code, Equals, 499)
+}
+
+func call(handler http.Handler, method, path, tok string, body []byte, hdr http.Header) *httptest.ResponseRecorder {
+       resp := httptest.NewRecorder()
+       req, err := http.NewRequest(method, path, bytes.NewReader(body))
+       if err != nil {
+               panic(err)
+       }
+       for k := range hdr {
+               req.Header.Set(k, hdr.Get(k))
+       }
+       if tok != "" {
+               req.Header.Set("Authorization", "Bearer "+tok)
+       }
+       handler.ServeHTTP(resp, req)
+       return resp
+}
index 78737640045db53691f03f27742ffc4f495debd0..2e2e97a974efa2ddbb7b5e60f67160da85181980 100644 (file)
@@ -5,18 +5,14 @@
 package keepstore
 
 import (
-       "bufio"
        "bytes"
        "context"
-       "crypto/sha256"
        "encoding/base64"
        "encoding/hex"
        "encoding/json"
        "errors"
        "fmt"
        "io"
-       "io/ioutil"
-       "net/http"
        "os"
        "regexp"
        "strings"
@@ -25,821 +21,262 @@ import (
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
-       "github.com/AdRoll/goamz/aws"
-       "github.com/AdRoll/goamz/s3"
+       "github.com/aws/aws-sdk-go-v2/aws"
+       "github.com/aws/aws-sdk-go-v2/aws/awserr"
+       "github.com/aws/aws-sdk-go-v2/aws/defaults"
+       "github.com/aws/aws-sdk-go-v2/aws/ec2metadata"
+       "github.com/aws/aws-sdk-go-v2/aws/ec2rolecreds"
+       "github.com/aws/aws-sdk-go-v2/aws/endpoints"
+       "github.com/aws/aws-sdk-go-v2/service/s3"
+       "github.com/aws/aws-sdk-go-v2/service/s3/s3manager"
        "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
 )
 
 func init() {
-       driver["S3"] = chooseS3VolumeDriver
-}
-
-func newS3Volume(cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) (Volume, error) {
-       v := &S3Volume{cluster: cluster, volume: volume, metrics: metrics}
-       err := json.Unmarshal(volume.DriverParameters, v)
-       if err != nil {
-               return nil, err
-       }
-       v.logger = logger.WithField("Volume", v.String())
-       return v, v.check()
-}
-
-func (v *S3Volume) check() error {
-       if v.Bucket == "" {
-               return errors.New("DriverParameters: Bucket must be provided")
-       }
-       if v.IndexPageSize == 0 {
-               v.IndexPageSize = 1000
-       }
-       if v.RaceWindow < 0 {
-               return errors.New("DriverParameters: RaceWindow must not be negative")
-       }
-
-       if v.Endpoint == "" {
-               r, ok := aws.Regions[v.Region]
-               if !ok {
-                       return fmt.Errorf("unrecognized region %+q; try specifying endpoint instead", v.Region)
-               }
-               v.region = r
-       } else {
-               v.region = aws.Region{
-                       Name:                 v.Region,
-                       S3Endpoint:           v.Endpoint,
-                       S3LocationConstraint: v.LocationConstraint,
-               }
-       }
-
-       // Zero timeouts mean "wait forever", which is a bad
-       // default. Default to long timeouts instead.
-       if v.ConnectTimeout == 0 {
-               v.ConnectTimeout = s3DefaultConnectTimeout
-       }
-       if v.ReadTimeout == 0 {
-               v.ReadTimeout = s3DefaultReadTimeout
-       }
-
-       v.bucket = &s3bucket{
-               bucket: &s3.Bucket{
-                       S3:   v.newS3Client(),
-                       Name: v.Bucket,
-               },
-       }
-       // Set up prometheus metrics
-       lbls := prometheus.Labels{"device_id": v.GetDeviceID()}
-       v.bucket.stats.opsCounters, v.bucket.stats.errCounters, v.bucket.stats.ioBytes = v.metrics.getCounterVecsFor(lbls)
-
-       err := v.bootstrapIAMCredentials()
-       if err != nil {
-               return fmt.Errorf("error getting IAM credentials: %s", err)
-       }
-
-       return nil
+       driver["S3"] = news3Volume
 }
 
 const (
-       s3DefaultReadTimeout    = arvados.Duration(10 * time.Minute)
-       s3DefaultConnectTimeout = arvados.Duration(time.Minute)
+       s3DefaultReadTimeout        = arvados.Duration(10 * time.Minute)
+       s3DefaultConnectTimeout     = arvados.Duration(time.Minute)
+       maxClockSkew                = 600 * time.Second
+       nearlyRFC1123               = "Mon, 2 Jan 2006 15:04:05 GMT"
+       s3downloaderPartSize        = 6 * 1024 * 1024
+       s3downloaderReadConcurrency = 11
+       s3uploaderPartSize          = 5 * 1024 * 1024
+       s3uploaderWriteConcurrency  = 5
 )
 
 var (
-       // ErrS3TrashDisabled is returned by Trash if that operation
-       // is impossible with the current config.
-       ErrS3TrashDisabled = fmt.Errorf("trash function is disabled because Collections.BlobTrashLifetime=0 and DriverParameters.UnsafeDelete=false")
-
-       s3ACL = s3.Private
-
-       zeroTime time.Time
+       errS3TrashDisabled   = fmt.Errorf("trash function is disabled because Collections.BlobTrashLifetime=0 and DriverParameters.UnsafeDelete=false")
+       s3AWSKeepBlockRegexp = regexp.MustCompile(`^[0-9a-f]{32}$`)
+       s3AWSZeroTime        time.Time
 )
 
-const (
-       maxClockSkew  = 600 * time.Second
-       nearlyRFC1123 = "Mon, 2 Jan 2006 15:04:05 GMT"
-)
-
-func s3regions() (okList []string) {
-       for r := range aws.Regions {
-               okList = append(okList, r)
-       }
-       return
-}
-
-// S3Volume implements Volume using an S3 bucket.
-type S3Volume struct {
+// s3Volume implements Volume using an S3 bucket.
+type s3Volume struct {
        arvados.S3VolumeDriverParameters
        AuthToken      string    // populated automatically when IAMRole is used
        AuthExpiration time.Time // populated automatically when IAMRole is used
 
-       cluster   *arvados.Cluster
-       volume    arvados.Volume
-       logger    logrus.FieldLogger
-       metrics   *volumeMetricsVecs
-       bucket    *s3bucket
-       region    aws.Region
-       startOnce sync.Once
-}
-
-// GetDeviceID returns a globally unique ID for the storage bucket.
-func (v *S3Volume) GetDeviceID() string {
-       return "s3://" + v.Endpoint + "/" + v.Bucket
+       cluster    *arvados.Cluster
+       volume     arvados.Volume
+       logger     logrus.FieldLogger
+       metrics    *volumeMetricsVecs
+       bufferPool *bufferPool
+       bucket     *s3Bucket
+       region     string
+       startOnce  sync.Once
 }
 
-func (v *S3Volume) bootstrapIAMCredentials() error {
-       if v.AccessKeyID != "" || v.SecretAccessKey != "" {
-               if v.IAMRole != "" {
-                       return errors.New("invalid DriverParameters: AccessKeyID and SecretAccessKey must be blank if IAMRole is specified")
-               }
-               return nil
-       }
-       ttl, err := v.updateIAMCredentials()
-       if err != nil {
-               return err
-       }
-       go func() {
-               for {
-                       time.Sleep(ttl)
-                       ttl, err = v.updateIAMCredentials()
-                       if err != nil {
-                               v.logger.WithError(err).Warnf("failed to update credentials for IAM role %q", v.IAMRole)
-                               ttl = time.Second
-                       } else if ttl < time.Second {
-                               v.logger.WithField("TTL", ttl).Warnf("received stale credentials for IAM role %q", v.IAMRole)
-                               ttl = time.Second
-                       }
-               }
-       }()
-       return nil
+// s3bucket wraps s3.bucket and counts I/O and API usage stats. The
+// wrapped bucket can be replaced atomically with SetBucket in order
+// to update credentials.
+type s3Bucket struct {
+       bucket string
+       svc    *s3.Client
+       stats  s3awsbucketStats
+       mu     sync.Mutex
 }
 
-func (v *S3Volume) newS3Client() *s3.S3 {
-       auth := aws.NewAuth(v.AccessKeyID, v.SecretAccessKey, v.AuthToken, v.AuthExpiration)
-       client := s3.New(*auth, v.region)
-       if !v.V2Signature {
-               client.Signature = aws.V4Signature
+func (v *s3Volume) isKeepBlock(s string) (string, bool) {
+       if v.PrefixLength > 0 && len(s) == v.PrefixLength+33 && s[:v.PrefixLength] == s[v.PrefixLength+1:v.PrefixLength*2+1] {
+               s = s[v.PrefixLength+1:]
        }
-       client.ConnectTimeout = time.Duration(v.ConnectTimeout)
-       client.ReadTimeout = time.Duration(v.ReadTimeout)
-       return client
+       return s, s3AWSKeepBlockRegexp.MatchString(s)
 }
 
-// returned by AWS metadata endpoint .../security-credentials/${rolename}
-type iamCredentials struct {
-       Code            string
-       LastUpdated     time.Time
-       Type            string
-       AccessKeyID     string
-       SecretAccessKey string
-       Token           string
-       Expiration      time.Time
-}
-
-// Returns TTL of updated credentials, i.e., time to sleep until next
-// update.
-func (v *S3Volume) updateIAMCredentials() (time.Duration, error) {
-       ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
-       defer cancel()
-
-       metadataBaseURL := "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
-
-       var url string
-       if strings.Contains(v.IAMRole, "://") {
-               // Configuration provides complete URL (used by tests)
-               url = v.IAMRole
-       } else if v.IAMRole != "" {
-               // Configuration provides IAM role name and we use the
-               // AWS metadata endpoint
-               url = metadataBaseURL + v.IAMRole
+// Return the key used for a given loc. If PrefixLength==0 then
+// key("abcdef0123") is "abcdef0123", if PrefixLength==3 then key is
+// "abc/abcdef0123", etc.
+func (v *s3Volume) key(loc string) string {
+       if v.PrefixLength > 0 && v.PrefixLength < len(loc)-1 {
+               return loc[:v.PrefixLength] + "/" + loc
        } else {
-               url = metadataBaseURL
-               v.logger.WithField("URL", url).Debug("looking up IAM role name")
-               req, err := http.NewRequest("GET", url, nil)
-               if err != nil {
-                       return 0, fmt.Errorf("error setting up request %s: %s", url, err)
-               }
-               resp, err := http.DefaultClient.Do(req.WithContext(ctx))
-               if err != nil {
-                       return 0, fmt.Errorf("error getting %s: %s", url, err)
-               }
-               defer resp.Body.Close()
-               if resp.StatusCode == http.StatusNotFound {
-                       return 0, fmt.Errorf("this instance does not have an IAM role assigned -- either assign a role, or configure AccessKeyID and SecretAccessKey explicitly in DriverParameters (error getting %s: HTTP status %s)", url, resp.Status)
-               } else if resp.StatusCode != http.StatusOK {
-                       return 0, fmt.Errorf("error getting %s: HTTP status %s", url, resp.Status)
-               }
-               body := bufio.NewReader(resp.Body)
-               var role string
-               _, err = fmt.Fscanf(body, "%s\n", &role)
-               if err != nil {
-                       return 0, fmt.Errorf("error reading response from %s: %s", url, err)
-               }
-               if n, _ := body.Read(make([]byte, 64)); n > 0 {
-                       v.logger.Warnf("ignoring additional data returned by metadata endpoint %s after the single role name that we expected", url)
-               }
-               v.logger.WithField("Role", role).Debug("looked up IAM role name")
-               url = url + role
-       }
-
-       v.logger.WithField("URL", url).Debug("getting credentials")
-       req, err := http.NewRequest("GET", url, nil)
-       if err != nil {
-               return 0, fmt.Errorf("error setting up request %s: %s", url, err)
-       }
-       resp, err := http.DefaultClient.Do(req.WithContext(ctx))
-       if err != nil {
-               return 0, fmt.Errorf("error getting %s: %s", url, err)
-       }
-       defer resp.Body.Close()
-       if resp.StatusCode != http.StatusOK {
-               return 0, fmt.Errorf("error getting %s: HTTP status %s", url, resp.Status)
-       }
-       var cred iamCredentials
-       err = json.NewDecoder(resp.Body).Decode(&cred)
-       if err != nil {
-               return 0, fmt.Errorf("error decoding credentials from %s: %s", url, err)
-       }
-       v.AccessKeyID, v.SecretAccessKey, v.AuthToken, v.AuthExpiration = cred.AccessKeyID, cred.SecretAccessKey, cred.Token, cred.Expiration
-       v.bucket.SetBucket(&s3.Bucket{
-               S3:   v.newS3Client(),
-               Name: v.Bucket,
-       })
-       // TTL is time from now to expiration, minus 5m.  "We make new
-       // credentials available at least five minutes before the
-       // expiration of the old credentials."  --
-       // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials
-       // (If that's not true, the returned ttl might be zero or
-       // negative, which the caller can handle.)
-       ttl := cred.Expiration.Sub(time.Now()) - 5*time.Minute
-       v.logger.WithFields(logrus.Fields{
-               "AccessKeyID": cred.AccessKeyID,
-               "LastUpdated": cred.LastUpdated,
-               "Expiration":  cred.Expiration,
-               "TTL":         arvados.Duration(ttl),
-       }).Debug("updated credentials")
-       return ttl, nil
-}
-
-func (v *S3Volume) getReaderWithContext(ctx context.Context, key string) (rdr io.ReadCloser, err error) {
-       ready := make(chan bool)
-       go func() {
-               rdr, err = v.getReader(key)
-               close(ready)
-       }()
-       select {
-       case <-ready:
-               return
-       case <-ctx.Done():
-               v.logger.Debugf("s3: abandoning getReader(%s): %s", key, ctx.Err())
-               go func() {
-                       <-ready
-                       if err == nil {
-                               rdr.Close()
-                       }
-               }()
-               return nil, ctx.Err()
+               return loc
        }
 }
 
-// getReader wraps (Bucket)GetReader.
-//
-// In situations where (Bucket)GetReader would fail because the block
-// disappeared in a Trash race, getReader calls fixRace to recover the
-// data, and tries again.
-func (v *S3Volume) getReader(key string) (rdr io.ReadCloser, err error) {
-       rdr, err = v.bucket.GetReader(key)
-       err = v.translateError(err)
-       if err == nil || !os.IsNotExist(err) {
-               return
+func news3Volume(params newVolumeParams) (volume, error) {
+       v := &s3Volume{
+               cluster:    params.Cluster,
+               volume:     params.ConfigVolume,
+               metrics:    params.MetricsVecs,
+               bufferPool: params.BufferPool,
        }
-
-       _, err = v.bucket.Head("recent/"+key, nil)
-       err = v.translateError(err)
+       err := json.Unmarshal(params.ConfigVolume.DriverParameters, v)
        if err != nil {
-               // If we can't read recent/X, there's no point in
-               // trying fixRace. Give up.
-               return
-       }
-       if !v.fixRace(key) {
-               err = os.ErrNotExist
-               return
-       }
-
-       rdr, err = v.bucket.GetReader(key)
-       if err != nil {
-               v.logger.Warnf("reading %s after successful fixRace: %s", key, err)
-               err = v.translateError(err)
+               return nil, err
        }
-       return
+       v.logger = params.Logger.WithField("Volume", v.DeviceID())
+       return v, v.check("")
 }
 
-// Get a block: copy the block data into buf, and return the number of
-// bytes copied.
-func (v *S3Volume) Get(ctx context.Context, loc string, buf []byte) (int, error) {
-       key := v.key(loc)
-       rdr, err := v.getReaderWithContext(ctx, key)
-       if err != nil {
-               return 0, err
-       }
-
-       var n int
-       ready := make(chan bool)
-       go func() {
-               defer close(ready)
-
-               defer rdr.Close()
-               n, err = io.ReadFull(rdr, buf)
-
-               switch err {
-               case nil, io.EOF, io.ErrUnexpectedEOF:
-                       err = nil
-               default:
-                       err = v.translateError(err)
+func (v *s3Volume) translateError(err error) error {
+       if _, ok := err.(*aws.RequestCanceledError); ok {
+               return context.Canceled
+       } else if aerr, ok := err.(awserr.Error); ok {
+               if aerr.Code() == "NotFound" {
+                       return os.ErrNotExist
+               } else if aerr.Code() == "NoSuchKey" {
+                       return os.ErrNotExist
                }
-       }()
-       select {
-       case <-ctx.Done():
-               v.logger.Debugf("s3: interrupting ReadFull() with Close() because %s", ctx.Err())
-               rdr.Close()
-               // Must wait for ReadFull to return, to ensure it
-               // doesn't write to buf after we return.
-               v.logger.Debug("s3: waiting for ReadFull() to fail")
-               <-ready
-               return 0, ctx.Err()
-       case <-ready:
-               return n, err
-       }
-}
-
-// Compare the given data with the stored data.
-func (v *S3Volume) Compare(ctx context.Context, loc string, expect []byte) error {
-       key := v.key(loc)
-       errChan := make(chan error, 1)
-       go func() {
-               _, err := v.bucket.Head("recent/"+key, nil)
-               errChan <- err
-       }()
-       var err error
-       select {
-       case <-ctx.Done():
-               return ctx.Err()
-       case err = <-errChan:
-       }
-       if err != nil {
-               // Checking for "loc" itself here would interfere with
-               // future GET requests.
-               //
-               // On AWS, if X doesn't exist, a HEAD or GET request
-               // for X causes X's non-existence to be cached. Thus,
-               // if we test for X, then create X and return a
-               // signature to our client, the client might still get
-               // 404 from all keepstores when trying to read it.
-               //
-               // To avoid this, we avoid doing HEAD X or GET X until
-               // we know X has been written.
-               //
-               // Note that X might exist even though recent/X
-               // doesn't: for example, the response to HEAD recent/X
-               // might itself come from a stale cache. In such
-               // cases, we will return a false negative and
-               // PutHandler might needlessly create another replica
-               // on a different volume. That's not ideal, but it's
-               // better than passing the eventually-consistent
-               // problem on to our clients.
-               return v.translateError(err)
        }
-       rdr, err := v.getReaderWithContext(ctx, key)
-       if err != nil {
-               return err
-       }
-       defer rdr.Close()
-       return v.translateError(compareReaderWithBuf(ctx, rdr, expect, loc[:32]))
+       return err
 }
 
-// Put writes a block.
-func (v *S3Volume) Put(ctx context.Context, loc string, block []byte) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       var opts s3.Options
-       size := len(block)
-       if size > 0 {
-               md5, err := hex.DecodeString(loc)
-               if err != nil {
-                       return err
-               }
-               opts.ContentMD5 = base64.StdEncoding.EncodeToString(md5)
-               // In AWS regions that use V4 signatures, we need to
-               // provide ContentSHA256 up front. Otherwise, the S3
-               // library reads the request body (from our buffer)
-               // into another new buffer in order to compute the
-               // SHA256 before sending the request -- which would
-               // mean consuming 128 MiB of memory for the duration
-               // of a 64 MiB write.
-               opts.ContentSHA256 = fmt.Sprintf("%x", sha256.Sum256(block))
-       }
-
-       key := v.key(loc)
-
-       // Send the block data through a pipe, so that (if we need to)
-       // we can close the pipe early and abandon our PutReader()
-       // goroutine, without worrying about PutReader() accessing our
-       // block buffer after we release it.
-       bufr, bufw := io.Pipe()
-       go func() {
-               io.Copy(bufw, bytes.NewReader(block))
-               bufw.Close()
-       }()
-
-       var err error
-       ready := make(chan bool)
-       go func() {
-               defer func() {
-                       if ctx.Err() != nil {
-                               v.logger.Debugf("abandoned PutReader goroutine finished with err: %s", err)
-                       }
-               }()
-               defer close(ready)
-               err = v.bucket.PutReader(key, bufr, int64(size), "application/octet-stream", s3ACL, opts)
-               if err != nil {
-                       return
-               }
-               err = v.bucket.PutReader("recent/"+key, nil, 0, "application/octet-stream", s3ACL, s3.Options{})
-       }()
-       select {
-       case <-ctx.Done():
-               v.logger.Debugf("taking PutReader's input away: %s", ctx.Err())
-               // Our pipe might be stuck in Write(), waiting for
-               // PutReader() to read. If so, un-stick it. This means
-               // PutReader will get corrupt data, but that's OK: the
-               // size and MD5 won't match, so the write will fail.
-               go io.Copy(ioutil.Discard, bufr)
-               // CloseWithError() will return once pending I/O is done.
-               bufw.CloseWithError(ctx.Err())
-               v.logger.Debugf("abandoning PutReader goroutine")
-               return ctx.Err()
-       case <-ready:
-               // Unblock pipe in case PutReader did not consume it.
-               io.Copy(ioutil.Discard, bufr)
-               return v.translateError(err)
+// safeCopy calls CopyObjectRequest, and checks the response to make
+// sure the copy succeeded and updated the timestamp on the
+// destination object
+//
+// (If something goes wrong during the copy, the error will be
+// embedded in the 200 OK response)
+func (v *s3Volume) safeCopy(dst, src string) error {
+       input := &s3.CopyObjectInput{
+               Bucket:      aws.String(v.bucket.bucket),
+               ContentType: aws.String("application/octet-stream"),
+               CopySource:  aws.String(v.bucket.bucket + "/" + src),
+               Key:         aws.String(dst),
        }
-}
 
-// Touch sets the timestamp for the given locator to the current time.
-func (v *S3Volume) Touch(loc string) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       key := v.key(loc)
-       _, err := v.bucket.Head(key, nil)
-       err = v.translateError(err)
-       if os.IsNotExist(err) && v.fixRace(key) {
-               // The data object got trashed in a race, but fixRace
-               // rescued it.
-       } else if err != nil {
-               return err
-       }
-       err = v.bucket.PutReader("recent/"+key, nil, 0, "application/octet-stream", s3ACL, s3.Options{})
-       return v.translateError(err)
-}
+       req := v.bucket.svc.CopyObjectRequest(input)
+       resp, err := req.Send(context.Background())
 
-// Mtime returns the stored timestamp for the given locator.
-func (v *S3Volume) Mtime(loc string) (time.Time, error) {
-       key := v.key(loc)
-       _, err := v.bucket.Head(key, nil)
-       if err != nil {
-               return zeroTime, v.translateError(err)
-       }
-       resp, err := v.bucket.Head("recent/"+key, nil)
        err = v.translateError(err)
        if os.IsNotExist(err) {
-               // The data object X exists, but recent/X is missing.
-               err = v.bucket.PutReader("recent/"+key, nil, 0, "application/octet-stream", s3ACL, s3.Options{})
-               if err != nil {
-                       v.logger.WithError(err).Errorf("error creating %q", "recent/"+key)
-                       return zeroTime, v.translateError(err)
-               }
-               v.logger.Infof("created %q to migrate existing block to new storage scheme", "recent/"+key)
-               resp, err = v.bucket.Head("recent/"+key, nil)
-               if err != nil {
-                       v.logger.WithError(err).Errorf("HEAD failed after creating %q", "recent/"+key)
-                       return zeroTime, v.translateError(err)
-               }
-       } else if err != nil {
-               // HEAD recent/X failed for some other reason.
-               return zeroTime, err
-       }
-       return v.lastModified(resp)
-}
-
-// IndexTo writes a complete list of locators with the given prefix
-// for which Get() can retrieve data.
-func (v *S3Volume) IndexTo(prefix string, writer io.Writer) error {
-       // Use a merge sort to find matching sets of X and recent/X.
-       dataL := s3Lister{
-               Logger:   v.logger,
-               Bucket:   v.bucket.Bucket(),
-               Prefix:   v.key(prefix),
-               PageSize: v.IndexPageSize,
-               Stats:    &v.bucket.stats,
-       }
-       recentL := s3Lister{
-               Logger:   v.logger,
-               Bucket:   v.bucket.Bucket(),
-               Prefix:   "recent/" + v.key(prefix),
-               PageSize: v.IndexPageSize,
-               Stats:    &v.bucket.stats,
-       }
-       for data, recent := dataL.First(), recentL.First(); data != nil && dataL.Error() == nil; data = dataL.Next() {
-               if data.Key >= "g" {
-                       // Conveniently, "recent/*" and "trash/*" are
-                       // lexically greater than all hex-encoded data
-                       // hashes, so stopping here avoids iterating
-                       // over all of them needlessly with dataL.
-                       break
-               }
-               loc, isBlk := v.isKeepBlock(data.Key)
-               if !isBlk {
-                       continue
-               }
-
-               // stamp is the list entry we should use to report the
-               // last-modified time for this data block: it will be
-               // the recent/X entry if one exists, otherwise the
-               // entry for the data block itself.
-               stamp := data
-
-               // Advance to the corresponding recent/X marker, if any
-               for recent != nil && recentL.Error() == nil {
-                       if cmp := strings.Compare(recent.Key[7:], data.Key); cmp < 0 {
-                               recent = recentL.Next()
-                               continue
-                       } else if cmp == 0 {
-                               stamp = recent
-                               recent = recentL.Next()
-                               break
-                       } else {
-                               // recent/X marker is missing: we'll
-                               // use the timestamp on the data
-                               // object.
-                               break
-                       }
-               }
-               if err := recentL.Error(); err != nil {
-                       return err
-               }
-               t, err := time.Parse(time.RFC3339, stamp.LastModified)
-               if err != nil {
-                       return err
-               }
-               // We truncate sub-second precision here. Otherwise
-               // timestamps will never match the RFC1123-formatted
-               // Last-Modified values parsed by Mtime().
-               fmt.Fprintf(writer, "%s+%d %d\n", loc, data.Size, t.Unix()*1000000000)
-       }
-       return dataL.Error()
-}
-
-// Trash a Keep block.
-func (v *S3Volume) Trash(loc string) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       if t, err := v.Mtime(loc); err != nil {
                return err
-       } else if time.Since(t) < v.cluster.Collections.BlobSigningTTL.Duration() {
-               return nil
-       }
-       key := v.key(loc)
-       if v.cluster.Collections.BlobTrashLifetime == 0 {
-               if !v.UnsafeDelete {
-                       return ErrS3TrashDisabled
-               }
-               return v.translateError(v.bucket.Del(key))
-       }
-       err := v.checkRaceWindow(key)
-       if err != nil {
-               return err
-       }
-       err = v.safeCopy("trash/"+key, key)
-       if err != nil {
-               return err
-       }
-       return v.translateError(v.bucket.Del(key))
-}
-
-// checkRaceWindow returns a non-nil error if trash/key is, or might
-// be, in the race window (i.e., it's not safe to trash key).
-func (v *S3Volume) checkRaceWindow(key string) error {
-       resp, err := v.bucket.Head("trash/"+key, nil)
-       err = v.translateError(err)
-       if os.IsNotExist(err) {
-               // OK, trash/X doesn't exist so we're not in the race
-               // window
-               return nil
        } else if err != nil {
-               // Error looking up trash/X. We don't know whether
-               // we're in the race window
-               return err
+               return fmt.Errorf("PutCopy(%q ← %q): %s", dst, v.bucket.bucket+"/"+src, err)
        }
-       t, err := v.lastModified(resp)
-       if err != nil {
-               // Can't parse timestamp
-               return err
-       }
-       safeWindow := t.Add(v.cluster.Collections.BlobTrashLifetime.Duration()).Sub(time.Now().Add(time.Duration(v.RaceWindow)))
-       if safeWindow <= 0 {
-               // We can't count on "touch trash/X" to prolong
-               // trash/X's lifetime. The new timestamp might not
-               // become visible until now+raceWindow, and EmptyTrash
-               // is allowed to delete trash/X before then.
-               return fmt.Errorf("%s: same block is already in trash, and safe window ended %s ago", key, -safeWindow)
-       }
-       // trash/X exists, but it won't be eligible for deletion until
-       // after now+raceWindow, so it's safe to overwrite it.
-       return nil
-}
 
-// safeCopy calls PutCopy, and checks the response to make sure the
-// copy succeeded and updated the timestamp on the destination object
-// (PutCopy returns 200 OK if the request was received, even if the
-// copy failed).
-func (v *S3Volume) safeCopy(dst, src string) error {
-       resp, err := v.bucket.Bucket().PutCopy(dst, s3ACL, s3.CopyOptions{
-               ContentType:       "application/octet-stream",
-               MetadataDirective: "REPLACE",
-       }, v.bucket.Bucket().Name+"/"+src)
-       err = v.translateError(err)
-       if os.IsNotExist(err) {
-               return err
-       } else if err != nil {
-               return fmt.Errorf("PutCopy(%q ← %q): %s", dst, v.bucket.Bucket().Name+"/"+src, err)
-       }
-       if t, err := time.Parse(time.RFC3339Nano, resp.LastModified); err != nil {
-               return fmt.Errorf("PutCopy succeeded but did not return a timestamp: %q: %s", resp.LastModified, err)
-       } else if time.Now().Sub(t) > maxClockSkew {
-               return fmt.Errorf("PutCopy succeeded but returned an old timestamp: %q: %s", resp.LastModified, t)
+       if resp.CopyObjectResult.LastModified == nil {
+               return fmt.Errorf("PutCopy succeeded but did not return a timestamp: %q: %s", resp.CopyObjectResult.LastModified, err)
+       } else if time.Now().Sub(*resp.CopyObjectResult.LastModified) > maxClockSkew {
+               return fmt.Errorf("PutCopy succeeded but returned an old timestamp: %q: %s", resp.CopyObjectResult.LastModified, resp.CopyObjectResult.LastModified)
        }
        return nil
 }
 
-// Get the LastModified header from resp, and parse it as RFC1123 or
-// -- if it isn't valid RFC1123 -- as Amazon's variant of RFC1123.
-func (v *S3Volume) lastModified(resp *http.Response) (t time.Time, err error) {
-       s := resp.Header.Get("Last-Modified")
-       t, err = time.Parse(time.RFC1123, s)
-       if err != nil && s != "" {
-               // AWS example is "Sun, 1 Jan 2006 12:00:00 GMT",
-               // which isn't quite "Sun, 01 Jan 2006 12:00:00 GMT"
-               // as required by HTTP spec. If it's not a valid HTTP
-               // header value, it's probably AWS (or s3test) giving
-               // us a nearly-RFC1123 timestamp.
-               t, err = time.Parse(nearlyRFC1123, s)
+func (v *s3Volume) check(ec2metadataHostname string) error {
+       if v.Bucket == "" {
+               return errors.New("DriverParameters: Bucket must be provided")
        }
-       return
-}
-
-// Untrash moves block from trash back into store
-func (v *S3Volume) Untrash(loc string) error {
-       key := v.key(loc)
-       err := v.safeCopy(key, "trash/"+key)
-       if err != nil {
-               return err
+       if v.IndexPageSize == 0 {
+               v.IndexPageSize = 1000
        }
-       err = v.bucket.PutReader("recent/"+key, nil, 0, "application/octet-stream", s3ACL, s3.Options{})
-       return v.translateError(err)
-}
-
-// Status returns a *VolumeStatus representing the current in-use
-// storage capacity and a fake available capacity that doesn't make
-// the volume seem full or nearly-full.
-func (v *S3Volume) Status() *VolumeStatus {
-       return &VolumeStatus{
-               DeviceNum: 1,
-               BytesFree: BlockSize * 1000,
-               BytesUsed: 1,
+       if v.RaceWindow < 0 {
+               return errors.New("DriverParameters: RaceWindow must not be negative")
        }
-}
-
-// InternalStats returns bucket I/O and API call counters.
-func (v *S3Volume) InternalStats() interface{} {
-       return &v.bucket.stats
-}
-
-// String implements fmt.Stringer.
-func (v *S3Volume) String() string {
-       return fmt.Sprintf("s3-bucket:%+q", v.Bucket)
-}
 
-var s3KeepBlockRegexp = regexp.MustCompile(`^[0-9a-f]{32}$`)
-
-func (v *S3Volume) isKeepBlock(s string) (string, bool) {
-       if v.PrefixLength > 0 && len(s) == v.PrefixLength+33 && s[:v.PrefixLength] == s[v.PrefixLength+1:v.PrefixLength*2+1] {
-               s = s[v.PrefixLength+1:]
+       if v.V2Signature {
+               return errors.New("DriverParameters: V2Signature is not supported")
        }
-       return s, s3KeepBlockRegexp.MatchString(s)
-}
 
-// Return the key used for a given loc. If PrefixLength==0 then
-// key("abcdef0123") is "abcdef0123", if PrefixLength==3 then key is
-// "abc/abcdef0123", etc.
-func (v *S3Volume) key(loc string) string {
-       if v.PrefixLength > 0 && v.PrefixLength < len(loc)-1 {
-               return loc[:v.PrefixLength] + "/" + loc
-       } else {
-               return loc
-       }
-}
+       defaultResolver := endpoints.NewDefaultResolver()
 
-// fixRace(X) is called when "recent/X" exists but "X" doesn't
-// exist. If the timestamps on "recent/X" and "trash/X" indicate there
-// was a race between Put and Trash, fixRace recovers from the race by
-// Untrashing the block.
-func (v *S3Volume) fixRace(key string) bool {
-       trash, err := v.bucket.Head("trash/"+key, nil)
-       if err != nil {
-               if !os.IsNotExist(v.translateError(err)) {
-                       v.logger.WithError(err).Errorf("fixRace: HEAD %q failed", "trash/"+key)
+       cfg := defaults.Config()
+
+       if v.Endpoint == "" && v.Region == "" {
+               return fmt.Errorf("AWS region or endpoint must be specified")
+       } else if v.Endpoint != "" || ec2metadataHostname != "" {
+               myCustomResolver := func(service, region string) (aws.Endpoint, error) {
+                       if v.Endpoint != "" && service == "s3" {
+                               return aws.Endpoint{
+                                       URL:           v.Endpoint,
+                                       SigningRegion: region,
+                               }, nil
+                       } else if service == "ec2metadata" && ec2metadataHostname != "" {
+                               return aws.Endpoint{
+                                       URL: ec2metadataHostname,
+                               }, nil
+                       } else {
+                               return defaultResolver.ResolveEndpoint(service, region)
+                       }
                }
-               return false
+               cfg.EndpointResolver = aws.EndpointResolverFunc(myCustomResolver)
        }
-       trashTime, err := v.lastModified(trash)
-       if err != nil {
-               v.logger.WithError(err).Errorf("fixRace: error parsing time %q", trash.Header.Get("Last-Modified"))
-               return false
+       if v.Region == "" {
+               // Endpoint is already specified (otherwise we would
+               // have errored out above), but Region is also
+               // required by the aws sdk, in order to determine
+               // SignatureVersions.
+               v.Region = "us-east-1"
        }
+       cfg.Region = v.Region
 
-       recent, err := v.bucket.Head("recent/"+key, nil)
-       if err != nil {
-               v.logger.WithError(err).Errorf("fixRace: HEAD %q failed", "recent/"+key)
-               return false
+       // Zero timeouts mean "wait forever", which is a bad
+       // default. Default to long timeouts instead.
+       if v.ConnectTimeout == 0 {
+               v.ConnectTimeout = s3DefaultConnectTimeout
        }
-       recentTime, err := v.lastModified(recent)
-       if err != nil {
-               v.logger.WithError(err).Errorf("fixRace: error parsing time %q", recent.Header.Get("Last-Modified"))
-               return false
+       if v.ReadTimeout == 0 {
+               v.ReadTimeout = s3DefaultReadTimeout
        }
 
-       ageWhenTrashed := trashTime.Sub(recentTime)
-       if ageWhenTrashed >= v.cluster.Collections.BlobSigningTTL.Duration() {
-               // No evidence of a race: block hasn't been written
-               // since it became eligible for Trash. No fix needed.
-               return false
-       }
+       creds := aws.NewChainProvider(
+               []aws.CredentialsProvider{
+                       aws.NewStaticCredentialsProvider(v.AccessKeyID, v.SecretAccessKey, v.AuthToken),
+                       ec2rolecreds.New(ec2metadata.New(cfg), func(opts *ec2rolecreds.ProviderOptions) {
+                               // (from aws-sdk-go-v2 comments)
+                               // "allow the credentials to trigger
+                               // refreshing prior to the credentials
+                               // actually expiring. This is
+                               // beneficial so race conditions with
+                               // expiring credentials do not cause
+                               // request to fail unexpectedly due to
+                               // ExpiredTokenException exceptions."
+                               //
+                               // (from
+                               // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)
+                               // "We make new credentials available
+                               // at least five minutes before the
+                               // expiration of the old credentials."
+                               opts.ExpiryWindow = 5 * time.Minute
+                       }),
+               })
 
-       v.logger.Infof("fixRace: %q: trashed at %s but touched at %s (age when trashed = %s < %s)", key, trashTime, recentTime, ageWhenTrashed, v.cluster.Collections.BlobSigningTTL)
-       v.logger.Infof("fixRace: copying %q to %q to recover from race between Put/Touch and Trash", "recent/"+key, key)
-       err = v.safeCopy(key, "trash/"+key)
-       if err != nil {
-               v.logger.WithError(err).Error("fixRace: copy failed")
-               return false
+       cfg.Credentials = creds
+
+       v.bucket = &s3Bucket{
+               bucket: v.Bucket,
+               svc:    s3.New(cfg),
        }
-       return true
+
+       // Set up prometheus metrics
+       lbls := prometheus.Labels{"device_id": v.DeviceID()}
+       v.bucket.stats.opsCounters, v.bucket.stats.errCounters, v.bucket.stats.ioBytes = v.metrics.getCounterVecsFor(lbls)
+
+       return nil
 }
 
-func (v *S3Volume) translateError(err error) error {
-       switch err := err.(type) {
-       case *s3.Error:
-               if (err.StatusCode == http.StatusNotFound && err.Code == "NoSuchKey") ||
-                       strings.Contains(err.Error(), "Not Found") {
-                       return os.ErrNotExist
-               }
-               // Other 404 errors like NoSuchVersion and
-               // NoSuchBucket are different problems which should
-               // get called out downstream, so we don't convert them
-               // to os.ErrNotExist.
-       }
-       return err
+// DeviceID returns a globally unique ID for the storage bucket.
+func (v *s3Volume) DeviceID() string {
+       return "s3://" + v.Endpoint + "/" + v.Bucket
 }
 
 // EmptyTrash looks for trashed blocks that exceeded BlobTrashLifetime
 // and deletes them from the volume.
-func (v *S3Volume) EmptyTrash() {
-       if v.cluster.Collections.BlobDeleteConcurrency < 1 {
-               return
-       }
-
+func (v *s3Volume) EmptyTrash() {
        var bytesInTrash, blocksInTrash, bytesDeleted, blocksDeleted int64
 
        // Define "ready to delete" as "...when EmptyTrash started".
        startT := time.Now()
 
-       emptyOneKey := func(trash *s3.Key) {
-               key := trash.Key[6:]
-               loc, isBlk := v.isKeepBlock(key)
-               if !isBlk {
+       emptyOneKey := func(trash *s3.Object) {
+               key := strings.TrimPrefix(*trash.Key, "trash/")
+               loc, isblk := v.isKeepBlock(key)
+               if !isblk {
                        return
                }
-               atomic.AddInt64(&bytesInTrash, trash.Size)
+               atomic.AddInt64(&bytesInTrash, *trash.Size)
                atomic.AddInt64(&blocksInTrash, 1)
 
-               trashT, err := time.Parse(time.RFC3339, trash.LastModified)
-               if err != nil {
-                       v.logger.Warnf("EmptyTrash: %q: parse %q: %s", trash.Key, trash.LastModified, err)
-                       return
-               }
-               recent, err := v.bucket.Head("recent/"+key, nil)
+               trashT := *trash.LastModified
+               recent, err := v.head("recent/" + key)
                if err != nil && os.IsNotExist(v.translateError(err)) {
-                       v.logger.Warnf("EmptyTrash: found trash marker %q but no %q (%s); calling Untrash", trash.Key, "recent/"+loc, err)
-                       err = v.Untrash(loc)
+                       v.logger.Warnf("EmptyTrash: found trash marker %q but no %q (%s); calling Untrash", *trash.Key, "recent/"+key, err)
+                       err = v.BlockUntrash(loc)
                        if err != nil {
                                v.logger.WithError(err).Errorf("EmptyTrash: Untrash(%q) failed", loc)
                        }
@@ -848,14 +285,9 @@ func (v *S3Volume) EmptyTrash() {
                        v.logger.WithError(err).Warnf("EmptyTrash: HEAD %q failed", "recent/"+key)
                        return
                }
-               recentT, err := v.lastModified(recent)
-               if err != nil {
-                       v.logger.WithError(err).Warnf("EmptyTrash: %q: error parsing %q", "recent/"+key, recent.Header.Get("Last-Modified"))
-                       return
-               }
-               if trashT.Sub(recentT) < v.cluster.Collections.BlobSigningTTL.Duration() {
-                       if age := startT.Sub(recentT); age >= v.cluster.Collections.BlobSigningTTL.Duration()-time.Duration(v.RaceWindow) {
-                               // recent/loc is too old to protect
+               if trashT.Sub(*recent.LastModified) < v.cluster.Collections.BlobSigningTTL.Duration() {
+                       if age := startT.Sub(*recent.LastModified); age >= v.cluster.Collections.BlobSigningTTL.Duration()-time.Duration(v.RaceWindow) {
+                               // recent/key is too old to protect
                                // loc from being Trashed again during
                                // the raceWindow that starts if we
                                // delete trash/X now.
@@ -865,10 +297,10 @@ func (v *S3Volume) EmptyTrash() {
                                // necessary to avoid starvation.
                                v.logger.Infof("EmptyTrash: detected old race for %q, calling fixRace + Touch", loc)
                                v.fixRace(key)
-                               v.Touch(loc)
+                               v.BlockTouch(loc)
                                return
                        }
-                       _, err := v.bucket.Head(key, nil)
+                       _, err := v.head(key)
                        if os.IsNotExist(err) {
                                v.logger.Infof("EmptyTrash: detected recent race for %q, calling fixRace", loc)
                                v.fixRace(key)
@@ -881,17 +313,17 @@ func (v *S3Volume) EmptyTrash() {
                if startT.Sub(trashT) < v.cluster.Collections.BlobTrashLifetime.Duration() {
                        return
                }
-               err = v.bucket.Del(trash.Key)
+               err = v.bucket.Del(*trash.Key)
                if err != nil {
-                       v.logger.WithError(err).Errorf("EmptyTrash: error deleting %q", trash.Key)
+                       v.logger.WithError(err).Errorf("EmptyTrash: error deleting %q", *trash.Key)
                        return
                }
-               atomic.AddInt64(&bytesDeleted, trash.Size)
+               atomic.AddInt64(&bytesDeleted, *trash.Size)
                atomic.AddInt64(&blocksDeleted, 1)
 
-               _, err = v.bucket.Head(key, nil)
+               _, err = v.head(*trash.Key)
                if err == nil {
-                       v.logger.Warnf("EmptyTrash: HEAD %q succeeded immediately after deleting %q", key, key)
+                       v.logger.Warnf("EmptyTrash: HEAD %q succeeded immediately after deleting %q", loc, loc)
                        return
                }
                if !os.IsNotExist(v.translateError(err)) {
@@ -905,7 +337,7 @@ func (v *S3Volume) EmptyTrash() {
        }
 
        var wg sync.WaitGroup
-       todo := make(chan *s3.Key, v.cluster.Collections.BlobDeleteConcurrency)
+       todo := make(chan *s3.Object, v.cluster.Collections.BlobDeleteConcurrency)
        for i := 0; i < v.cluster.Collections.BlobDeleteConcurrency; i++ {
                wg.Add(1)
                go func() {
@@ -916,9 +348,9 @@ func (v *S3Volume) EmptyTrash() {
                }()
        }
 
-       trashL := s3Lister{
+       trashL := s3awsLister{
                Logger:   v.logger,
-               Bucket:   v.bucket.Bucket(),
+               Bucket:   v.bucket,
                Prefix:   "trash/",
                PageSize: v.IndexPageSize,
                Stats:    &v.bucket.stats,
@@ -932,23 +364,193 @@ func (v *S3Volume) EmptyTrash() {
        if err := trashL.Error(); err != nil {
                v.logger.WithError(err).Error("EmptyTrash: lister failed")
        }
-       v.logger.Infof("EmptyTrash stats for %v: Deleted %v bytes in %v blocks. Remaining in trash: %v bytes in %v blocks.", v.String(), bytesDeleted, blocksDeleted, bytesInTrash-bytesDeleted, blocksInTrash-blocksDeleted)
+       v.logger.Infof("EmptyTrash: stats for %v: Deleted %v bytes in %v blocks. Remaining in trash: %v bytes in %v blocks.", v.DeviceID(), bytesDeleted, blocksDeleted, bytesInTrash-bytesDeleted, blocksInTrash-blocksDeleted)
+}
+
+// fixRace(X) is called when "recent/X" exists but "X" doesn't
+// exist. If the timestamps on "recent/X" and "trash/X" indicate there
+// was a race between Put and Trash, fixRace recovers from the race by
+// Untrashing the block.
+func (v *s3Volume) fixRace(key string) bool {
+       trash, err := v.head("trash/" + key)
+       if err != nil {
+               if !os.IsNotExist(v.translateError(err)) {
+                       v.logger.WithError(err).Errorf("fixRace: HEAD %q failed", "trash/"+key)
+               }
+               return false
+       }
+
+       recent, err := v.head("recent/" + key)
+       if err != nil {
+               v.logger.WithError(err).Errorf("fixRace: HEAD %q failed", "recent/"+key)
+               return false
+       }
+
+       recentTime := *recent.LastModified
+       trashTime := *trash.LastModified
+       ageWhenTrashed := trashTime.Sub(recentTime)
+       if ageWhenTrashed >= v.cluster.Collections.BlobSigningTTL.Duration() {
+               // No evidence of a race: block hasn't been written
+               // since it became eligible for Trash. No fix needed.
+               return false
+       }
+
+       v.logger.Infof("fixRace: %q: trashed at %s but touched at %s (age when trashed = %s < %s)", key, trashTime, recentTime, ageWhenTrashed, v.cluster.Collections.BlobSigningTTL)
+       v.logger.Infof("fixRace: copying %q to %q to recover from race between Put/Touch and Trash", "recent/"+key, key)
+       err = v.safeCopy(key, "trash/"+key)
+       if err != nil {
+               v.logger.WithError(err).Error("fixRace: copy failed")
+               return false
+       }
+       return true
+}
+
+func (v *s3Volume) head(key string) (result *s3.HeadObjectOutput, err error) {
+       input := &s3.HeadObjectInput{
+               Bucket: aws.String(v.bucket.bucket),
+               Key:    aws.String(key),
+       }
+
+       req := v.bucket.svc.HeadObjectRequest(input)
+       res, err := req.Send(context.TODO())
+
+       v.bucket.stats.TickOps("head")
+       v.bucket.stats.Tick(&v.bucket.stats.Ops, &v.bucket.stats.HeadOps)
+       v.bucket.stats.TickErr(err)
+
+       if err != nil {
+               return nil, v.translateError(err)
+       }
+       result = res.HeadObjectOutput
+       return
+}
+
+// BlockRead reads a Keep block that has been stored as a block blob
+// in the S3 bucket.
+func (v *s3Volume) BlockRead(ctx context.Context, hash string, w io.WriterAt) error {
+       key := v.key(hash)
+       err := v.readWorker(ctx, key, w)
+       if err != nil {
+               err = v.translateError(err)
+               if !os.IsNotExist(err) {
+                       return err
+               }
+
+               _, err = v.head("recent/" + key)
+               err = v.translateError(err)
+               if err != nil {
+                       // If we can't read recent/X, there's no point in
+                       // trying fixRace. Give up.
+                       return err
+               }
+               if !v.fixRace(key) {
+                       err = os.ErrNotExist
+                       return err
+               }
+
+               err = v.readWorker(ctx, key, w)
+               if err != nil {
+                       v.logger.Warnf("reading %s after successful fixRace: %s", hash, err)
+                       err = v.translateError(err)
+                       return err
+               }
+       }
+       return nil
+}
+
+func (v *s3Volume) readWorker(ctx context.Context, key string, dst io.WriterAt) error {
+       downloader := s3manager.NewDownloaderWithClient(v.bucket.svc, func(u *s3manager.Downloader) {
+               u.PartSize = s3downloaderPartSize
+               u.Concurrency = s3downloaderReadConcurrency
+       })
+       count, err := downloader.DownloadWithContext(ctx, dst, &s3.GetObjectInput{
+               Bucket: aws.String(v.bucket.bucket),
+               Key:    aws.String(key),
+       })
+       v.bucket.stats.TickOps("get")
+       v.bucket.stats.Tick(&v.bucket.stats.Ops, &v.bucket.stats.GetOps)
+       v.bucket.stats.TickErr(err)
+       v.bucket.stats.TickInBytes(uint64(count))
+       return v.translateError(err)
+}
+
+func (v *s3Volume) writeObject(ctx context.Context, key string, r io.Reader) error {
+       if r == nil {
+               // r == nil leads to a memory violation in func readFillBuf in
+               // aws-sdk-go-v2@v0.23.0/service/s3/s3manager/upload.go
+               r = bytes.NewReader(nil)
+       }
+
+       uploadInput := s3manager.UploadInput{
+               Bucket: aws.String(v.bucket.bucket),
+               Key:    aws.String(key),
+               Body:   r,
+       }
+
+       if loc, ok := v.isKeepBlock(key); ok {
+               var contentMD5 string
+               md5, err := hex.DecodeString(loc)
+               if err != nil {
+                       return v.translateError(err)
+               }
+               contentMD5 = base64.StdEncoding.EncodeToString(md5)
+               uploadInput.ContentMD5 = &contentMD5
+       }
+
+       // Experimentation indicated that using concurrency 5 yields the best
+       // throughput, better than higher concurrency (10 or 13) by ~5%.
+       // Defining u.BufferProvider = s3manager.NewBufferedReadSeekerWriteToPool(64 * 1024 * 1024)
+       // is detrimental to throughput (minus ~15%).
+       uploader := s3manager.NewUploaderWithClient(v.bucket.svc, func(u *s3manager.Uploader) {
+               u.PartSize = s3uploaderPartSize
+               u.Concurrency = s3uploaderWriteConcurrency
+       })
+
+       // Unlike the goamz S3 driver, we don't need to precompute ContentSHA256:
+       // the aws-sdk-go v2 SDK uses a ReadSeeker to avoid having to copy the
+       // block, so there is no extra memory use to be concerned about. See
+       // makeSha256Reader in aws/signer/v4/v4.go. In fact, we explicitly disable
+       // calculating the Sha-256 because we don't need it; we already use md5sum
+       // hashes that match the name of the block.
+       _, err := uploader.UploadWithContext(ctx, &uploadInput, s3manager.WithUploaderRequestOptions(func(r *aws.Request) {
+               r.HTTPRequest.Header.Set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD")
+       }))
+
+       v.bucket.stats.TickOps("put")
+       v.bucket.stats.Tick(&v.bucket.stats.Ops, &v.bucket.stats.PutOps)
+       v.bucket.stats.TickErr(err)
+
+       return v.translateError(err)
+}
+
+// Put writes a block.
+func (v *s3Volume) BlockWrite(ctx context.Context, hash string, data []byte) error {
+       // Do not use putWithPipe here; we want to pass an io.ReadSeeker to the S3
+       // sdk to avoid memory allocation there. See #17339 for more information.
+       rdr := bytes.NewReader(data)
+       r := newCountingReaderAtSeeker(rdr, v.bucket.stats.TickOutBytes)
+       key := v.key(hash)
+       err := v.writeObject(ctx, key, r)
+       if err != nil {
+               return err
+       }
+       return v.writeObject(ctx, "recent/"+key, nil)
 }
 
-type s3Lister struct {
-       Logger     logrus.FieldLogger
-       Bucket     *s3.Bucket
-       Prefix     string
-       PageSize   int
-       Stats      *s3bucketStats
-       nextMarker string
-       buf        []s3.Key
-       err        error
+type s3awsLister struct {
+       Logger            logrus.FieldLogger
+       Bucket            *s3Bucket
+       Prefix            string
+       PageSize          int
+       Stats             *s3awsbucketStats
+       ContinuationToken string
+       buf               []s3.Object
+       err               error
 }
 
 // First fetches the first page and returns the first item. It returns
 // nil if the response is the empty set or an error occurs.
-func (lister *s3Lister) First() *s3.Key {
+func (lister *s3awsLister) First() *s3.Object {
        lister.getPage()
        return lister.pop()
 }
@@ -956,41 +558,65 @@ func (lister *s3Lister) First() *s3.Key {
 // Next returns the next item, fetching the next page if necessary. It
 // returns nil if the last available item has already been fetched, or
 // an error occurs.
-func (lister *s3Lister) Next() *s3.Key {
-       if len(lister.buf) == 0 && lister.nextMarker != "" {
+func (lister *s3awsLister) Next() *s3.Object {
+       if len(lister.buf) == 0 && lister.ContinuationToken != "" {
                lister.getPage()
        }
        return lister.pop()
 }
 
 // Return the most recent error encountered by First or Next.
-func (lister *s3Lister) Error() error {
+func (lister *s3awsLister) Error() error {
        return lister.err
 }
 
-func (lister *s3Lister) getPage() {
+func (lister *s3awsLister) getPage() {
        lister.Stats.TickOps("list")
        lister.Stats.Tick(&lister.Stats.Ops, &lister.Stats.ListOps)
-       resp, err := lister.Bucket.List(lister.Prefix, "", lister.nextMarker, lister.PageSize)
-       lister.nextMarker = ""
+
+       var input *s3.ListObjectsV2Input
+       if lister.ContinuationToken == "" {
+               input = &s3.ListObjectsV2Input{
+                       Bucket:  aws.String(lister.Bucket.bucket),
+                       MaxKeys: aws.Int64(int64(lister.PageSize)),
+                       Prefix:  aws.String(lister.Prefix),
+               }
+       } else {
+               input = &s3.ListObjectsV2Input{
+                       Bucket:            aws.String(lister.Bucket.bucket),
+                       MaxKeys:           aws.Int64(int64(lister.PageSize)),
+                       Prefix:            aws.String(lister.Prefix),
+                       ContinuationToken: &lister.ContinuationToken,
+               }
+       }
+
+       req := lister.Bucket.svc.ListObjectsV2Request(input)
+       resp, err := req.Send(context.Background())
        if err != nil {
-               lister.err = err
+               if aerr, ok := err.(awserr.Error); ok {
+                       lister.err = aerr
+               } else {
+                       lister.err = err
+               }
                return
        }
-       if resp.IsTruncated {
-               lister.nextMarker = resp.NextMarker
+
+       if *resp.IsTruncated {
+               lister.ContinuationToken = *resp.NextContinuationToken
+       } else {
+               lister.ContinuationToken = ""
        }
-       lister.buf = make([]s3.Key, 0, len(resp.Contents))
+       lister.buf = make([]s3.Object, 0, len(resp.Contents))
        for _, key := range resp.Contents {
-               if !strings.HasPrefix(key.Key, lister.Prefix) {
-                       lister.Logger.Warnf("s3Lister: S3 Bucket.List(prefix=%q) returned key %q", lister.Prefix, key.Key)
+               if !strings.HasPrefix(*key.Key, lister.Prefix) {
+                       lister.Logger.Warnf("s3awsLister: S3 Bucket.List(prefix=%q) returned key %q", lister.Prefix, *key.Key)
                        continue
                }
                lister.buf = append(lister.buf, key)
        }
 }
 
-func (lister *s3Lister) pop() (k *s3.Key) {
+func (lister *s3awsLister) pop() (k *s3.Object) {
        if len(lister.buf) > 0 {
                k = &lister.buf[0]
                lister.buf = lister.buf[1:]
@@ -998,71 +624,201 @@ func (lister *s3Lister) pop() (k *s3.Key) {
        return
 }
 
-// s3bucket wraps s3.bucket and counts I/O and API usage stats. The
-// wrapped bucket can be replaced atomically with SetBucket in order
-// to update credentials.
-type s3bucket struct {
-       bucket *s3.Bucket
-       stats  s3bucketStats
-       mu     sync.Mutex
-}
+// Index writes a complete list of locators with the given prefix
+// for which Get() can retrieve data.
+func (v *s3Volume) Index(ctx context.Context, prefix string, writer io.Writer) error {
+       prefix = v.key(prefix)
+       // Use a merge sort to find matching sets of X and recent/X.
+       dataL := s3awsLister{
+               Logger:   v.logger,
+               Bucket:   v.bucket,
+               Prefix:   prefix,
+               PageSize: v.IndexPageSize,
+               Stats:    &v.bucket.stats,
+       }
+       recentL := s3awsLister{
+               Logger:   v.logger,
+               Bucket:   v.bucket,
+               Prefix:   "recent/" + prefix,
+               PageSize: v.IndexPageSize,
+               Stats:    &v.bucket.stats,
+       }
+       for data, recent := dataL.First(), recentL.First(); data != nil && dataL.Error() == nil; data = dataL.Next() {
+               if ctx.Err() != nil {
+                       return ctx.Err()
+               }
+               if *data.Key >= "g" {
+                       // Conveniently, "recent/*" and "trash/*" are
+                       // lexically greater than all hex-encoded data
+                       // hashes, so stopping here avoids iterating
+                       // over all of them needlessly with dataL.
+                       break
+               }
+               loc, isblk := v.isKeepBlock(*data.Key)
+               if !isblk {
+                       continue
+               }
+
+               // stamp is the list entry we should use to report the
+               // last-modified time for this data block: it will be
+               // the recent/X entry if one exists, otherwise the
+               // entry for the data block itself.
+               stamp := data
 
-func (b *s3bucket) Bucket() *s3.Bucket {
-       b.mu.Lock()
-       defer b.mu.Unlock()
-       return b.bucket
+               // Advance to the corresponding recent/X marker, if any
+               for recent != nil && recentL.Error() == nil {
+                       if cmp := strings.Compare((*recent.Key)[7:], *data.Key); cmp < 0 {
+                               recent = recentL.Next()
+                               continue
+                       } else if cmp == 0 {
+                               stamp = recent
+                               recent = recentL.Next()
+                               break
+                       } else {
+                               // recent/X marker is missing: we'll
+                               // use the timestamp on the data
+                               // object.
+                               break
+                       }
+               }
+               if err := recentL.Error(); err != nil {
+                       return err
+               }
+               // We truncate sub-second precision here. Otherwise
+               // timestamps will never match the RFC1123-formatted
+               // Last-Modified values parsed by Mtime().
+               fmt.Fprintf(writer, "%s+%d %d\n", loc, *data.Size, stamp.LastModified.Unix()*1000000000)
+       }
+       return dataL.Error()
 }
 
-func (b *s3bucket) SetBucket(bucket *s3.Bucket) {
-       b.mu.Lock()
-       defer b.mu.Unlock()
-       b.bucket = bucket
+// Mtime returns the stored timestamp for the given locator.
+func (v *s3Volume) Mtime(loc string) (time.Time, error) {
+       key := v.key(loc)
+       _, err := v.head(key)
+       if err != nil {
+               return s3AWSZeroTime, v.translateError(err)
+       }
+       resp, err := v.head("recent/" + key)
+       err = v.translateError(err)
+       if os.IsNotExist(err) {
+               // The data object X exists, but recent/X is missing.
+               err = v.writeObject(context.Background(), "recent/"+key, nil)
+               if err != nil {
+                       v.logger.WithError(err).Errorf("error creating %q", "recent/"+key)
+                       return s3AWSZeroTime, v.translateError(err)
+               }
+               v.logger.Infof("Mtime: created %q to migrate existing block to new storage scheme", "recent/"+key)
+               resp, err = v.head("recent/" + key)
+               if err != nil {
+                       v.logger.WithError(err).Errorf("HEAD failed after creating %q", "recent/"+key)
+                       return s3AWSZeroTime, v.translateError(err)
+               }
+       } else if err != nil {
+               // HEAD recent/X failed for some other reason.
+               return s3AWSZeroTime, err
+       }
+       return *resp.LastModified, err
 }
 
-func (b *s3bucket) GetReader(path string) (io.ReadCloser, error) {
-       rdr, err := b.Bucket().GetReader(path)
-       b.stats.TickOps("get")
-       b.stats.Tick(&b.stats.Ops, &b.stats.GetOps)
-       b.stats.TickErr(err)
-       return NewCountingReader(rdr, b.stats.TickInBytes), err
+// InternalStats returns bucket I/O and API call counters.
+func (v *s3Volume) InternalStats() interface{} {
+       return &v.bucket.stats
 }
 
-func (b *s3bucket) Head(path string, headers map[string][]string) (*http.Response, error) {
-       resp, err := b.Bucket().Head(path, headers)
-       b.stats.TickOps("head")
-       b.stats.Tick(&b.stats.Ops, &b.stats.HeadOps)
-       b.stats.TickErr(err)
-       return resp, err
+// BlockTouch sets the timestamp for the given locator to the current time.
+func (v *s3Volume) BlockTouch(hash string) error {
+       key := v.key(hash)
+       _, err := v.head(key)
+       err = v.translateError(err)
+       if os.IsNotExist(err) && v.fixRace(key) {
+               // The data object got trashed in a race, but fixRace
+               // rescued it.
+       } else if err != nil {
+               return err
+       }
+       err = v.writeObject(context.Background(), "recent/"+key, nil)
+       return v.translateError(err)
 }
 
-func (b *s3bucket) PutReader(path string, r io.Reader, length int64, contType string, perm s3.ACL, options s3.Options) error {
-       if length == 0 {
-               // goamz will only send Content-Length: 0 when reader
-               // is nil due to net.http.Request.ContentLength
-               // behavior.  Otherwise, Content-Length header is
-               // omitted which will cause some S3 services
-               // (including AWS and Ceph RadosGW) to fail to create
-               // empty objects.
-               r = nil
-       } else {
-               r = NewCountingReader(r, b.stats.TickOutBytes)
+// checkRaceWindow returns a non-nil error if trash/key is, or might
+// be, in the race window (i.e., it's not safe to trash key).
+func (v *s3Volume) checkRaceWindow(key string) error {
+       resp, err := v.head("trash/" + key)
+       err = v.translateError(err)
+       if os.IsNotExist(err) {
+               // OK, trash/X doesn't exist so we're not in the race
+               // window
+               return nil
+       } else if err != nil {
+               // Error looking up trash/X. We don't know whether
+               // we're in the race window
+               return err
        }
-       err := b.Bucket().PutReader(path, r, length, contType, perm, options)
-       b.stats.TickOps("put")
-       b.stats.Tick(&b.stats.Ops, &b.stats.PutOps)
-       b.stats.TickErr(err)
-       return err
+       t := resp.LastModified
+       safeWindow := t.Add(v.cluster.Collections.BlobTrashLifetime.Duration()).Sub(time.Now().Add(time.Duration(v.RaceWindow)))
+       if safeWindow <= 0 {
+               // We can't count on "touch trash/X" to prolong
+               // trash/X's lifetime. The new timestamp might not
+               // become visible until now+raceWindow, and EmptyTrash
+               // is allowed to delete trash/X before then.
+               return fmt.Errorf("%s: same block is already in trash, and safe window ended %s ago", key, -safeWindow)
+       }
+       // trash/X exists, but it won't be eligible for deletion until
+       // after now+raceWindow, so it's safe to overwrite it.
+       return nil
 }
 
-func (b *s3bucket) Del(path string) error {
-       err := b.Bucket().Del(path)
+func (b *s3Bucket) Del(path string) error {
+       input := &s3.DeleteObjectInput{
+               Bucket: aws.String(b.bucket),
+               Key:    aws.String(path),
+       }
+       req := b.svc.DeleteObjectRequest(input)
+       _, err := req.Send(context.Background())
        b.stats.TickOps("delete")
        b.stats.Tick(&b.stats.Ops, &b.stats.DelOps)
        b.stats.TickErr(err)
        return err
 }
 
-type s3bucketStats struct {
+// Trash a Keep block.
+func (v *s3Volume) BlockTrash(loc string) error {
+       if t, err := v.Mtime(loc); err != nil {
+               return err
+       } else if time.Since(t) < v.cluster.Collections.BlobSigningTTL.Duration() {
+               return nil
+       }
+       key := v.key(loc)
+       if v.cluster.Collections.BlobTrashLifetime == 0 {
+               if !v.UnsafeDelete {
+                       return errS3TrashDisabled
+               }
+               return v.translateError(v.bucket.Del(key))
+       }
+       err := v.checkRaceWindow(key)
+       if err != nil {
+               return err
+       }
+       err = v.safeCopy("trash/"+key, key)
+       if err != nil {
+               return err
+       }
+       return v.translateError(v.bucket.Del(key))
+}
+
+// BlockUntrash moves block from trash back into store
+func (v *s3Volume) BlockUntrash(hash string) error {
+       key := v.key(hash)
+       err := v.safeCopy(key, "trash/"+key)
+       if err != nil {
+               return err
+       }
+       err = v.writeObject(context.Background(), "recent/"+key, nil)
+       return v.translateError(err)
+}
+
+type s3awsbucketStats struct {
        statsTicker
        Ops     uint64
        GetOps  uint64
@@ -1072,13 +828,18 @@ type s3bucketStats struct {
        ListOps uint64
 }
 
-func (s *s3bucketStats) TickErr(err error) {
+func (s *s3awsbucketStats) TickErr(err error) {
        if err == nil {
                return
        }
        errType := fmt.Sprintf("%T", err)
-       if err, ok := err.(*s3.Error); ok {
-               errType = errType + fmt.Sprintf(" %d %s", err.StatusCode, err.Code)
+       if aerr, ok := err.(awserr.Error); ok {
+               if reqErr, ok := err.(awserr.RequestFailure); ok {
+                       // A service error occurred
+                       errType = errType + fmt.Sprintf(" %d %s", reqErr.StatusCode(), aerr.Code())
+               } else {
+                       errType = errType + fmt.Sprintf(" 000 %s", aerr.Code())
+               }
        }
        s.statsTicker.TickErr(err, errType)
 }
index a82098356859cb3cc481d20df453efb97e1726d0..fb68e1c0574c338e9c016404e456f623acdcb477 100644 (file)
@@ -19,39 +19,49 @@ import (
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
-       "github.com/AdRoll/goamz/s3"
-       "github.com/AdRoll/goamz/s3/s3test"
+
+       "github.com/aws/aws-sdk-go-v2/aws"
+       "github.com/aws/aws-sdk-go-v2/service/s3"
+       "github.com/aws/aws-sdk-go-v2/service/s3/s3manager"
+
+       "github.com/johannesboyne/gofakes3"
+       "github.com/johannesboyne/gofakes3/backend/s3mem"
        "github.com/prometheus/client_golang/prometheus"
        "github.com/sirupsen/logrus"
        check "gopkg.in/check.v1"
 )
 
 const (
-       TestBucketName = "testbucket"
+       s3TestBucketName = "testbucket"
 )
 
-type fakeClock struct {
+type s3AWSFakeClock struct {
        now *time.Time
 }
 
-func (c *fakeClock) Now() time.Time {
+func (c *s3AWSFakeClock) Now() time.Time {
        if c.now == nil {
-               return time.Now()
+               return time.Now().UTC()
        }
-       return *c.now
+       return c.now.UTC()
 }
 
-var _ = check.Suite(&StubbedS3Suite{})
+func (c *s3AWSFakeClock) Since(t time.Time) time.Duration {
+       return c.Now().Sub(t)
+}
+
+var _ = check.Suite(&stubbedS3Suite{})
+
+var srv httptest.Server
 
-type StubbedS3Suite struct {
+type stubbedS3Suite struct {
        s3server *httptest.Server
        metadata *httptest.Server
        cluster  *arvados.Cluster
-       handler  *handler
-       volumes  []*TestableS3Volume
+       volumes  []*testableS3Volume
 }
 
-func (s *StubbedS3Suite) SetUpTest(c *check.C) {
+func (s *stubbedS3Suite) SetUpTest(c *check.C) {
        s.s3server = nil
        s.metadata = nil
        s.cluster = testCluster(c)
@@ -59,36 +69,41 @@ func (s *StubbedS3Suite) SetUpTest(c *check.C) {
                "zzzzz-nyw5e-000000000000000": {Driver: "S3"},
                "zzzzz-nyw5e-111111111111111": {Driver: "S3"},
        }
-       s.handler = &handler{}
 }
 
-func (s *StubbedS3Suite) TestGeneric(c *check.C) {
-       DoGenericVolumeTests(c, false, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
+func (s *stubbedS3Suite) TestGeneric(c *check.C) {
+       DoGenericVolumeTests(c, false, func(t TB, params newVolumeParams) TestableVolume {
                // Use a negative raceWindow so s3test's 1-second
                // timestamp precision doesn't confuse fixRace.
-               return s.newTestableVolume(c, cluster, volume, metrics, -2*time.Second)
+               return s.newTestableVolume(c, params, -2*time.Second)
        })
 }
 
-func (s *StubbedS3Suite) TestGenericReadOnly(c *check.C) {
-       DoGenericVolumeTests(c, true, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               return s.newTestableVolume(c, cluster, volume, metrics, -2*time.Second)
+func (s *stubbedS3Suite) TestGenericReadOnly(c *check.C) {
+       DoGenericVolumeTests(c, true, func(t TB, params newVolumeParams) TestableVolume {
+               return s.newTestableVolume(c, params, -2*time.Second)
        })
 }
 
-func (s *StubbedS3Suite) TestGenericWithPrefix(c *check.C) {
-       DoGenericVolumeTests(c, false, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               v := s.newTestableVolume(c, cluster, volume, metrics, -2*time.Second)
+func (s *stubbedS3Suite) TestGenericWithPrefix(c *check.C) {
+       DoGenericVolumeTests(c, false, func(t TB, params newVolumeParams) TestableVolume {
+               v := s.newTestableVolume(c, params, -2*time.Second)
                v.PrefixLength = 3
                return v
        })
 }
 
-func (s *StubbedS3Suite) TestIndex(c *check.C) {
-       v := s.newTestableVolume(c, s.cluster, arvados.Volume{Replication: 2}, newVolumeMetricsVecs(prometheus.NewRegistry()), 0)
+func (s *stubbedS3Suite) TestIndex(c *check.C) {
+       v := s.newTestableVolume(c, newVolumeParams{
+               Cluster:      s.cluster,
+               ConfigVolume: arvados.Volume{Replication: 2},
+               MetricsVecs:  newVolumeMetricsVecs(prometheus.NewRegistry()),
+               BufferPool:   newBufferPool(ctxlog.TestLogger(c), 8, prometheus.NewRegistry()),
+       }, 0)
        v.IndexPageSize = 3
        for i := 0; i < 256; i++ {
-               v.PutRaw(fmt.Sprintf("%02x%030x", i, i), []byte{102, 111, 111})
+               err := v.blockWriteWithoutMD5Check(fmt.Sprintf("%02x%030x", i, i), []byte{102, 111, 111})
+               c.Assert(err, check.IsNil)
        }
        for _, spec := range []struct {
                prefix      string
@@ -100,7 +115,7 @@ func (s *StubbedS3Suite) TestIndex(c *check.C) {
                {"abc", 0},
        } {
                buf := new(bytes.Buffer)
-               err := v.IndexTo(spec.prefix, buf)
+               err := v.Index(context.Background(), spec.prefix, buf)
                c.Check(err, check.IsNil)
 
                idx := bytes.SplitAfter(buf.Bytes(), []byte{10})
@@ -109,15 +124,16 @@ func (s *StubbedS3Suite) TestIndex(c *check.C) {
        }
 }
 
-func (s *StubbedS3Suite) TestSignatureVersion(c *check.C) {
+func (s *stubbedS3Suite) TestSignature(c *check.C) {
        var header http.Header
        stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                header = r.Header
        }))
        defer stub.Close()
 
-       // Default V4 signature
-       vol := S3Volume{
+       // The aws-sdk-go-v2 driver only supports S3 V4 signatures. S3 v2 signatures are being phased out
+       // as of June 24, 2020. Cf. https://forums.aws.amazon.com/ann.jspa?annID=5816
+       vol := s3Volume{
                S3VolumeDriverParameters: arvados.S3VolumeDriverParameters{
                        AccessKeyID:     "xxx",
                        SecretAccessKey: "xxx",
@@ -129,34 +145,17 @@ func (s *StubbedS3Suite) TestSignatureVersion(c *check.C) {
                logger:  ctxlog.TestLogger(c),
                metrics: newVolumeMetricsVecs(prometheus.NewRegistry()),
        }
-       err := vol.check()
-       c.Check(err, check.IsNil)
-       err = vol.Put(context.Background(), "acbd18db4cc2f85cedef654fccc4a4d8", []byte("foo"))
-       c.Check(err, check.IsNil)
-       c.Check(header.Get("Authorization"), check.Matches, `AWS4-HMAC-SHA256 .*`)
+       err := vol.check("")
+       // Our test S3 server uses the older 'Path Style'
+       vol.bucket.svc.ForcePathStyle = true
 
-       // Force V2 signature
-       vol = S3Volume{
-               S3VolumeDriverParameters: arvados.S3VolumeDriverParameters{
-                       AccessKeyID:     "xxx",
-                       SecretAccessKey: "xxx",
-                       Endpoint:        stub.URL,
-                       Region:          "test-region-1",
-                       Bucket:          "test-bucket-name",
-                       V2Signature:     true,
-               },
-               cluster: s.cluster,
-               logger:  ctxlog.TestLogger(c),
-               metrics: newVolumeMetricsVecs(prometheus.NewRegistry()),
-       }
-       err = vol.check()
        c.Check(err, check.IsNil)
-       err = vol.Put(context.Background(), "acbd18db4cc2f85cedef654fccc4a4d8", []byte("foo"))
+       err = vol.BlockWrite(context.Background(), "acbd18db4cc2f85cedef654fccc4a4d8", []byte("foo"))
        c.Check(err, check.IsNil)
-       c.Check(header.Get("Authorization"), check.Matches, `AWS xxx:.*`)
+       c.Check(header.Get("Authorization"), check.Matches, `AWS4-HMAC-SHA256 .*`)
 }
 
-func (s *StubbedS3Suite) TestIAMRoleCredentials(c *check.C) {
+func (s *stubbedS3Suite) TestIAMRoleCredentials(c *check.C) {
        s.metadata = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                upd := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
                exp := time.Now().UTC().Add(time.Hour).Format(time.RFC3339)
@@ -167,16 +166,28 @@ func (s *StubbedS3Suite) TestIAMRoleCredentials(c *check.C) {
        }))
        defer s.metadata.Close()
 
-       v := s.newTestableVolume(c, s.cluster, arvados.Volume{Replication: 2}, newVolumeMetricsVecs(prometheus.NewRegistry()), 5*time.Minute)
-       c.Check(v.AccessKeyID, check.Equals, "ASIAIOSFODNN7EXAMPLE")
-       c.Check(v.SecretAccessKey, check.Equals, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
-       c.Check(v.bucket.bucket.S3.Auth.AccessKey, check.Equals, "ASIAIOSFODNN7EXAMPLE")
-       c.Check(v.bucket.bucket.S3.Auth.SecretKey, check.Equals, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
+       v := &s3Volume{
+               S3VolumeDriverParameters: arvados.S3VolumeDriverParameters{
+                       IAMRole:  s.metadata.URL + "/latest/api/token",
+                       Endpoint: "http://localhost:12345",
+                       Region:   "test-region-1",
+                       Bucket:   "test-bucket-name",
+               },
+               cluster: s.cluster,
+               logger:  ctxlog.TestLogger(c),
+               metrics: newVolumeMetricsVecs(prometheus.NewRegistry()),
+       }
+       err := v.check(s.metadata.URL + "/latest")
+       c.Check(err, check.IsNil)
+       creds, err := v.bucket.svc.Client.Config.Credentials.Retrieve(context.Background())
+       c.Check(err, check.IsNil)
+       c.Check(creds.AccessKeyID, check.Equals, "ASIAIOSFODNN7EXAMPLE")
+       c.Check(creds.SecretAccessKey, check.Equals, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
 
        s.metadata = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusNotFound)
        }))
-       deadv := &S3Volume{
+       deadv := &s3Volume{
                S3VolumeDriverParameters: arvados.S3VolumeDriverParameters{
                        IAMRole:  s.metadata.URL + "/fake-metadata/test-role",
                        Endpoint: "http://localhost:12345",
@@ -187,13 +198,20 @@ func (s *StubbedS3Suite) TestIAMRoleCredentials(c *check.C) {
                logger:  ctxlog.TestLogger(c),
                metrics: newVolumeMetricsVecs(prometheus.NewRegistry()),
        }
-       err := deadv.check()
-       c.Check(err, check.ErrorMatches, `.*/fake-metadata/test-role.*`)
-       c.Check(err, check.ErrorMatches, `.*404.*`)
+       err = deadv.check(s.metadata.URL + "/latest")
+       c.Check(err, check.IsNil)
+       _, err = deadv.bucket.svc.Client.Config.Credentials.Retrieve(context.Background())
+       c.Check(err, check.ErrorMatches, `(?s).*EC2RoleRequestError: no EC2 instance role found.*`)
+       c.Check(err, check.ErrorMatches, `(?s).*404.*`)
 }
 
-func (s *StubbedS3Suite) TestStats(c *check.C) {
-       v := s.newTestableVolume(c, s.cluster, arvados.Volume{Replication: 2}, newVolumeMetricsVecs(prometheus.NewRegistry()), 5*time.Minute)
+func (s *stubbedS3Suite) TestStats(c *check.C) {
+       v := s.newTestableVolume(c, newVolumeParams{
+               Cluster:      s.cluster,
+               ConfigVolume: arvados.Volume{Replication: 2},
+               MetricsVecs:  newVolumeMetricsVecs(prometheus.NewRegistry()),
+               BufferPool:   newBufferPool(ctxlog.TestLogger(c), 8, prometheus.NewRegistry()),
+       }, 5*time.Minute)
        stats := func() string {
                buf, err := json.Marshal(v.InternalStats())
                c.Check(err, check.IsNil)
@@ -203,30 +221,30 @@ func (s *StubbedS3Suite) TestStats(c *check.C) {
        c.Check(stats(), check.Matches, `.*"Ops":0,.*`)
 
        loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       _, err := v.Get(context.Background(), loc, make([]byte, 3))
+       err := v.BlockRead(context.Background(), loc, brdiscard)
        c.Check(err, check.NotNil)
        c.Check(stats(), check.Matches, `.*"Ops":[^0],.*`)
-       c.Check(stats(), check.Matches, `.*"\*s3.Error 404 [^"]*":[^0].*`)
+       c.Check(stats(), check.Matches, `.*"s3.requestFailure 404 NoSuchKey[^"]*":[^0].*`)
        c.Check(stats(), check.Matches, `.*"InBytes":0,.*`)
 
-       err = v.Put(context.Background(), loc, []byte("foo"))
+       err = v.BlockWrite(context.Background(), loc, []byte("foo"))
        c.Check(err, check.IsNil)
        c.Check(stats(), check.Matches, `.*"OutBytes":3,.*`)
        c.Check(stats(), check.Matches, `.*"PutOps":2,.*`)
 
-       _, err = v.Get(context.Background(), loc, make([]byte, 3))
+       err = v.BlockRead(context.Background(), loc, brdiscard)
        c.Check(err, check.IsNil)
-       _, err = v.Get(context.Background(), loc, make([]byte, 3))
+       err = v.BlockRead(context.Background(), loc, brdiscard)
        c.Check(err, check.IsNil)
        c.Check(stats(), check.Matches, `.*"InBytes":6,.*`)
 }
 
-type blockingHandler struct {
+type s3AWSBlockingHandler struct {
        requested chan *http.Request
        unblock   chan struct{}
 }
 
-func (h *blockingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (h *s3AWSBlockingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        if r.Method == "PUT" && !strings.Contains(strings.Trim(r.URL.Path, "/"), "/") {
                // Accept PutBucket ("PUT /bucketname/"), called by
                // newTestableVolume
@@ -241,40 +259,29 @@ func (h *blockingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        http.Error(w, "nothing here", http.StatusNotFound)
 }
 
-func (s *StubbedS3Suite) TestGetContextCancel(c *check.C) {
-       loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       buf := make([]byte, 3)
-
-       s.testContextCancel(c, func(ctx context.Context, v *TestableS3Volume) error {
-               _, err := v.Get(ctx, loc, buf)
-               return err
+func (s *stubbedS3Suite) TestGetContextCancel(c *check.C) {
+       s.testContextCancel(c, func(ctx context.Context, v *testableS3Volume) error {
+               return v.BlockRead(ctx, fooHash, brdiscard)
        })
 }
 
-func (s *StubbedS3Suite) TestCompareContextCancel(c *check.C) {
-       loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       buf := []byte("bar")
-
-       s.testContextCancel(c, func(ctx context.Context, v *TestableS3Volume) error {
-               return v.Compare(ctx, loc, buf)
+func (s *stubbedS3Suite) TestPutContextCancel(c *check.C) {
+       s.testContextCancel(c, func(ctx context.Context, v *testableS3Volume) error {
+               return v.BlockWrite(ctx, fooHash, []byte("foo"))
        })
 }
 
-func (s *StubbedS3Suite) TestPutContextCancel(c *check.C) {
-       loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       buf := []byte("foo")
-
-       s.testContextCancel(c, func(ctx context.Context, v *TestableS3Volume) error {
-               return v.Put(ctx, loc, buf)
-       })
-}
-
-func (s *StubbedS3Suite) testContextCancel(c *check.C, testFunc func(context.Context, *TestableS3Volume) error) {
-       handler := &blockingHandler{}
+func (s *stubbedS3Suite) testContextCancel(c *check.C, testFunc func(context.Context, *testableS3Volume) error) {
+       handler := &s3AWSBlockingHandler{}
        s.s3server = httptest.NewServer(handler)
        defer s.s3server.Close()
 
-       v := s.newTestableVolume(c, s.cluster, arvados.Volume{Replication: 2}, newVolumeMetricsVecs(prometheus.NewRegistry()), 5*time.Minute)
+       v := s.newTestableVolume(c, newVolumeParams{
+               Cluster:      s.cluster,
+               ConfigVolume: arvados.Volume{Replication: 2},
+               MetricsVecs:  newVolumeMetricsVecs(prometheus.NewRegistry()),
+               BufferPool:   newBufferPool(ctxlog.TestLogger(c), 8, prometheus.NewRegistry()),
+       }, 5*time.Minute)
 
        ctx, cancel := context.WithCancel(context.Background())
 
@@ -310,11 +317,17 @@ func (s *StubbedS3Suite) testContextCancel(c *check.C, testFunc func(context.Con
        }
 }
 
-func (s *StubbedS3Suite) TestBackendStates(c *check.C) {
+func (s *stubbedS3Suite) TestBackendStates(c *check.C) {
        s.cluster.Collections.BlobTrashLifetime.Set("1h")
        s.cluster.Collections.BlobSigningTTL.Set("1h")
 
-       v := s.newTestableVolume(c, s.cluster, arvados.Volume{Replication: 2}, newVolumeMetricsVecs(prometheus.NewRegistry()), 5*time.Minute)
+       v := s.newTestableVolume(c, newVolumeParams{
+               Cluster:      s.cluster,
+               ConfigVolume: arvados.Volume{Replication: 2},
+               Logger:       ctxlog.TestLogger(c),
+               MetricsVecs:  newVolumeMetricsVecs(prometheus.NewRegistry()),
+               BufferPool:   newBufferPool(ctxlog.TestLogger(c), 8, prometheus.NewRegistry()),
+       }, 5*time.Minute)
        var none time.Time
 
        putS3Obj := func(t time.Time, key string, data []byte) {
@@ -322,7 +335,20 @@ func (s *StubbedS3Suite) TestBackendStates(c *check.C) {
                        return
                }
                v.serverClock.now = &t
-               v.bucket.Bucket().Put(key, data, "application/octet-stream", s3ACL, s3.Options{})
+               uploader := s3manager.NewUploaderWithClient(v.bucket.svc)
+               _, err := uploader.UploadWithContext(context.Background(), &s3manager.UploadInput{
+                       Bucket: aws.String(v.bucket.bucket),
+                       Key:    aws.String(key),
+                       Body:   bytes.NewReader(data),
+               })
+               if err != nil {
+                       panic(err)
+               }
+               v.serverClock.now = nil
+               _, err = v.head(key)
+               if err != nil {
+                       panic(err)
+               }
        }
 
        t0 := time.Now()
@@ -443,7 +469,7 @@ func (s *StubbedS3Suite) TestBackendStates(c *check.C) {
                                if prefixLength > 0 {
                                        key = loc[:prefixLength] + "/" + loc
                                }
-                               c.Log("\t", loc)
+                               c.Log("\t", loc, "\t", key)
                                putS3Obj(scenario.dataT, key, blk)
                                putS3Obj(scenario.recentT, "recent/"+key, nil)
                                putS3Obj(scenario.trashT, "trash/"+key, blk)
@@ -453,8 +479,7 @@ func (s *StubbedS3Suite) TestBackendStates(c *check.C) {
 
                        // Check canGet
                        loc, blk := setupScenario()
-                       buf := make([]byte, len(blk))
-                       _, err := v.Get(context.Background(), loc, buf)
+                       err := v.BlockRead(context.Background(), loc, brdiscard)
                        c.Check(err == nil, check.Equals, scenario.canGet)
                        if err != nil {
                                c.Check(os.IsNotExist(err), check.Equals, true)
@@ -462,9 +487,9 @@ func (s *StubbedS3Suite) TestBackendStates(c *check.C) {
 
                        // Call Trash, then check canTrash and canGetAfterTrash
                        loc, _ = setupScenario()
-                       err = v.Trash(loc)
+                       err = v.BlockTrash(loc)
                        c.Check(err == nil, check.Equals, scenario.canTrash)
-                       _, err = v.Get(context.Background(), loc, buf)
+                       err = v.BlockRead(context.Background(), loc, brdiscard)
                        c.Check(err == nil, check.Equals, scenario.canGetAfterTrash)
                        if err != nil {
                                c.Check(os.IsNotExist(err), check.Equals, true)
@@ -472,14 +497,14 @@ func (s *StubbedS3Suite) TestBackendStates(c *check.C) {
 
                        // Call Untrash, then check canUntrash
                        loc, _ = setupScenario()
-                       err = v.Untrash(loc)
+                       err = v.BlockUntrash(loc)
                        c.Check(err == nil, check.Equals, scenario.canUntrash)
                        if scenario.dataT != none || scenario.trashT != none {
                                // In all scenarios where the data exists, we
                                // should be able to Get after Untrash --
                                // regardless of timestamps, errors, race
                                // conditions, etc.
-                               _, err = v.Get(context.Background(), loc, buf)
+                               err = v.BlockRead(context.Background(), loc, brdiscard)
                                c.Check(err, check.IsNil)
                        }
 
@@ -487,7 +512,7 @@ func (s *StubbedS3Suite) TestBackendStates(c *check.C) {
                        // freshAfterEmpty
                        loc, _ = setupScenario()
                        v.EmptyTrash()
-                       _, err = v.bucket.Head("trash/"+v.key(loc), nil)
+                       _, err = v.head("trash/" + v.key(loc))
                        c.Check(err == nil, check.Equals, scenario.haveTrashAfterEmpty)
                        if scenario.freshAfterEmpty {
                                t, err := v.Mtime(loc)
@@ -500,7 +525,7 @@ func (s *StubbedS3Suite) TestBackendStates(c *check.C) {
                        // Check for current Mtime after Put (applies to all
                        // scenarios)
                        loc, blk = setupScenario()
-                       err = v.Put(context.Background(), loc, blk)
+                       err = v.BlockWrite(context.Background(), loc, blk)
                        c.Check(err, check.IsNil)
                        t, err := v.Mtime(loc)
                        c.Check(err, check.IsNil)
@@ -509,18 +534,44 @@ func (s *StubbedS3Suite) TestBackendStates(c *check.C) {
        }
 }
 
-type TestableS3Volume struct {
-       *S3Volume
-       server      *s3test.Server
+type testableS3Volume struct {
+       *s3Volume
+       server      *httptest.Server
        c           *check.C
-       serverClock *fakeClock
+       serverClock *s3AWSFakeClock
 }
 
-func (s *StubbedS3Suite) newTestableVolume(c *check.C, cluster *arvados.Cluster, volume arvados.Volume, metrics *volumeMetricsVecs, raceWindow time.Duration) *TestableS3Volume {
-       clock := &fakeClock{}
-       srv, err := s3test.NewServer(&s3test.Config{Clock: clock})
-       c.Assert(err, check.IsNil)
-       endpoint := srv.URL()
+type LogrusLog struct {
+       log *logrus.FieldLogger
+}
+
+func (l LogrusLog) Print(level gofakes3.LogLevel, v ...interface{}) {
+       switch level {
+       case gofakes3.LogErr:
+               (*l.log).Errorln(v...)
+       case gofakes3.LogWarn:
+               (*l.log).Warnln(v...)
+       case gofakes3.LogInfo:
+               (*l.log).Infoln(v...)
+       default:
+               panic("unknown level")
+       }
+}
+
+func (s *stubbedS3Suite) newTestableVolume(c *check.C, params newVolumeParams, raceWindow time.Duration) *testableS3Volume {
+
+       clock := &s3AWSFakeClock{}
+       // fake s3
+       backend := s3mem.New(s3mem.WithTimeSource(clock))
+
+       // To enable GoFakeS3 debug logging, pass logger to gofakes3.WithLogger()
+       /* logger := new(LogrusLog)
+       ctxLogger := ctxlog.FromContext(context.Background())
+       logger.log = &ctxLogger */
+       faker := gofakes3.New(backend, gofakes3.WithTimeSource(clock), gofakes3.WithLogger(nil), gofakes3.WithTimeSkewLimit(0))
+       srv := httptest.NewServer(faker.Server())
+
+       endpoint := srv.URL
        if s.s3server != nil {
                endpoint = s.s3server.URL
        }
@@ -530,65 +581,96 @@ func (s *StubbedS3Suite) newTestableVolume(c *check.C, cluster *arvados.Cluster,
                iamRole, accessKey, secretKey = s.metadata.URL+"/fake-metadata/test-role", "", ""
        }
 
-       v := &TestableS3Volume{
-               S3Volume: &S3Volume{
+       v := &testableS3Volume{
+               s3Volume: &s3Volume{
                        S3VolumeDriverParameters: arvados.S3VolumeDriverParameters{
                                IAMRole:            iamRole,
                                AccessKeyID:        accessKey,
                                SecretAccessKey:    secretKey,
-                               Bucket:             TestBucketName,
+                               Bucket:             s3TestBucketName,
                                Endpoint:           endpoint,
                                Region:             "test-region-1",
                                LocationConstraint: true,
                                UnsafeDelete:       true,
                                IndexPageSize:      1000,
                        },
-                       cluster: cluster,
-                       volume:  volume,
-                       logger:  ctxlog.TestLogger(c),
-                       metrics: metrics,
+                       cluster:    params.Cluster,
+                       volume:     params.ConfigVolume,
+                       logger:     params.Logger,
+                       metrics:    params.MetricsVecs,
+                       bufferPool: params.BufferPool,
                },
                c:           c,
                server:      srv,
                serverClock: clock,
        }
-       c.Assert(v.S3Volume.check(), check.IsNil)
-       c.Assert(v.bucket.Bucket().PutBucket(s3.ACL("private")), check.IsNil)
+       c.Assert(v.s3Volume.check(""), check.IsNil)
+       // Our test S3 server uses the older 'Path Style'
+       v.s3Volume.bucket.svc.ForcePathStyle = true
+       // Create the testbucket
+       input := &s3.CreateBucketInput{
+               Bucket: aws.String(s3TestBucketName),
+       }
+       req := v.s3Volume.bucket.svc.CreateBucketRequest(input)
+       _, err := req.Send(context.Background())
+       c.Assert(err, check.IsNil)
        // We couldn't set RaceWindow until now because check()
        // rejects negative values.
-       v.S3Volume.RaceWindow = arvados.Duration(raceWindow)
+       v.s3Volume.RaceWindow = arvados.Duration(raceWindow)
        return v
 }
 
-// PutRaw skips the ContentMD5 test
-func (v *TestableS3Volume) PutRaw(loc string, block []byte) {
+func (v *testableS3Volume) blockWriteWithoutMD5Check(loc string, block []byte) error {
        key := v.key(loc)
-       err := v.bucket.Bucket().Put(key, block, "application/octet-stream", s3ACL, s3.Options{})
-       if err != nil {
-               v.logger.Printf("PutRaw: %s: %+v", loc, err)
-       }
-       err = v.bucket.Bucket().Put("recent/"+key, nil, "application/octet-stream", s3ACL, s3.Options{})
+       r := newCountingReader(bytes.NewReader(block), v.bucket.stats.TickOutBytes)
+
+       uploader := s3manager.NewUploaderWithClient(v.bucket.svc, func(u *s3manager.Uploader) {
+               u.PartSize = 5 * 1024 * 1024
+               u.Concurrency = 13
+       })
+
+       _, err := uploader.Upload(&s3manager.UploadInput{
+               Bucket: aws.String(v.bucket.bucket),
+               Key:    aws.String(key),
+               Body:   r,
+       })
        if err != nil {
-               v.logger.Printf("PutRaw: recent/%s: %+v", key, err)
+               return err
        }
+
+       empty := bytes.NewReader([]byte{})
+       _, err = uploader.Upload(&s3manager.UploadInput{
+               Bucket: aws.String(v.bucket.bucket),
+               Key:    aws.String("recent/" + key),
+               Body:   empty,
+       })
+       return err
 }
 
 // TouchWithDate turns back the clock while doing a Touch(). We assume
 // there are no other operations happening on the same s3test server
 // while we do this.
-func (v *TestableS3Volume) TouchWithDate(locator string, lastPut time.Time) {
+func (v *testableS3Volume) TouchWithDate(loc string, lastPut time.Time) {
        v.serverClock.now = &lastPut
-       err := v.bucket.Bucket().Put("recent/"+v.key(locator), nil, "application/octet-stream", s3ACL, s3.Options{})
+
+       uploader := s3manager.NewUploaderWithClient(v.bucket.svc)
+       empty := bytes.NewReader([]byte{})
+       _, err := uploader.UploadWithContext(context.Background(), &s3manager.UploadInput{
+               Bucket: aws.String(v.bucket.bucket),
+               Key:    aws.String("recent/" + v.key(loc)),
+               Body:   empty,
+       })
        if err != nil {
                panic(err)
        }
+
        v.serverClock.now = nil
 }
 
-func (v *TestableS3Volume) Teardown() {
-       v.server.Quit()
+func (v *testableS3Volume) Teardown() {
+       v.server.Close()
 }
 
-func (v *TestableS3Volume) ReadWriteOperationLabelValues() (r, w string) {
+func (v *testableS3Volume) ReadWriteOperationLabelValues() (r, w string) {
        return "get", "put"
 }
diff --git a/services/keepstore/s3aws_volume.go b/services/keepstore/s3aws_volume.go
deleted file mode 100644 (file)
index d068dde..0000000
+++ /dev/null
@@ -1,917 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "bytes"
-       "context"
-       "encoding/base64"
-       "encoding/hex"
-       "encoding/json"
-       "errors"
-       "fmt"
-       "io"
-       "os"
-       "regexp"
-       "strings"
-       "sync"
-       "sync/atomic"
-       "time"
-
-       "git.arvados.org/arvados.git/sdk/go/arvados"
-       "github.com/aws/aws-sdk-go-v2/aws"
-       "github.com/aws/aws-sdk-go-v2/aws/awserr"
-       "github.com/aws/aws-sdk-go-v2/aws/defaults"
-       "github.com/aws/aws-sdk-go-v2/aws/ec2metadata"
-       "github.com/aws/aws-sdk-go-v2/aws/ec2rolecreds"
-       "github.com/aws/aws-sdk-go-v2/aws/endpoints"
-       "github.com/aws/aws-sdk-go-v2/service/s3"
-       "github.com/aws/aws-sdk-go-v2/service/s3/s3manager"
-       "github.com/prometheus/client_golang/prometheus"
-       "github.com/sirupsen/logrus"
-)
-
-// S3AWSVolume implements Volume using an S3 bucket.
-type S3AWSVolume struct {
-       arvados.S3VolumeDriverParameters
-       AuthToken      string    // populated automatically when IAMRole is used
-       AuthExpiration time.Time // populated automatically when IAMRole is used
-
-       cluster   *arvados.Cluster
-       volume    arvados.Volume
-       logger    logrus.FieldLogger
-       metrics   *volumeMetricsVecs
-       bucket    *s3AWSbucket
-       region    string
-       startOnce sync.Once
-}
-
-// s3bucket wraps s3.bucket and counts I/O and API usage stats. The
-// wrapped bucket can be replaced atomically with SetBucket in order
-// to update credentials.
-type s3AWSbucket struct {
-       bucket string
-       svc    *s3.Client
-       stats  s3awsbucketStats
-       mu     sync.Mutex
-}
-
-// chooseS3VolumeDriver distinguishes between the old goamz driver and
-// aws-sdk-go based on the UseAWSS3v2Driver feature flag
-func chooseS3VolumeDriver(cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) (Volume, error) {
-       v := &S3Volume{cluster: cluster, volume: volume, metrics: metrics}
-       // Default value will be overriden if it happens to be defined in the config
-       v.S3VolumeDriverParameters.UseAWSS3v2Driver = true
-       err := json.Unmarshal(volume.DriverParameters, v)
-       if err != nil {
-               return nil, err
-       }
-       if v.UseAWSS3v2Driver {
-               logger.Debugln("Using AWS S3 v2 driver")
-               return newS3AWSVolume(cluster, volume, logger, metrics)
-       }
-       logger.Debugln("Using goamz S3 driver")
-       return newS3Volume(cluster, volume, logger, metrics)
-}
-
-const (
-       PartSize         = 5 * 1024 * 1024
-       ReadConcurrency  = 13
-       WriteConcurrency = 5
-)
-
-var s3AWSKeepBlockRegexp = regexp.MustCompile(`^[0-9a-f]{32}$`)
-var s3AWSZeroTime time.Time
-
-func (v *S3AWSVolume) isKeepBlock(s string) (string, bool) {
-       if v.PrefixLength > 0 && len(s) == v.PrefixLength+33 && s[:v.PrefixLength] == s[v.PrefixLength+1:v.PrefixLength*2+1] {
-               s = s[v.PrefixLength+1:]
-       }
-       return s, s3AWSKeepBlockRegexp.MatchString(s)
-}
-
-// Return the key used for a given loc. If PrefixLength==0 then
-// key("abcdef0123") is "abcdef0123", if PrefixLength==3 then key is
-// "abc/abcdef0123", etc.
-func (v *S3AWSVolume) key(loc string) string {
-       if v.PrefixLength > 0 && v.PrefixLength < len(loc)-1 {
-               return loc[:v.PrefixLength] + "/" + loc
-       } else {
-               return loc
-       }
-}
-
-func newS3AWSVolume(cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) (Volume, error) {
-       v := &S3AWSVolume{cluster: cluster, volume: volume, metrics: metrics}
-       err := json.Unmarshal(volume.DriverParameters, v)
-       if err != nil {
-               return nil, err
-       }
-       v.logger = logger.WithField("Volume", v.String())
-       return v, v.check("")
-}
-
-func (v *S3AWSVolume) translateError(err error) error {
-       if _, ok := err.(*aws.RequestCanceledError); ok {
-               return context.Canceled
-       } else if aerr, ok := err.(awserr.Error); ok {
-               if aerr.Code() == "NotFound" {
-                       return os.ErrNotExist
-               } else if aerr.Code() == "NoSuchKey" {
-                       return os.ErrNotExist
-               }
-       }
-       return err
-}
-
-// safeCopy calls CopyObjectRequest, and checks the response to make
-// sure the copy succeeded and updated the timestamp on the
-// destination object
-//
-// (If something goes wrong during the copy, the error will be
-// embedded in the 200 OK response)
-func (v *S3AWSVolume) safeCopy(dst, src string) error {
-       input := &s3.CopyObjectInput{
-               Bucket:      aws.String(v.bucket.bucket),
-               ContentType: aws.String("application/octet-stream"),
-               CopySource:  aws.String(v.bucket.bucket + "/" + src),
-               Key:         aws.String(dst),
-       }
-
-       req := v.bucket.svc.CopyObjectRequest(input)
-       resp, err := req.Send(context.Background())
-
-       err = v.translateError(err)
-       if os.IsNotExist(err) {
-               return err
-       } else if err != nil {
-               return fmt.Errorf("PutCopy(%q ← %q): %s", dst, v.bucket.bucket+"/"+src, err)
-       }
-
-       if resp.CopyObjectResult.LastModified == nil {
-               return fmt.Errorf("PutCopy succeeded but did not return a timestamp: %q: %s", resp.CopyObjectResult.LastModified, err)
-       } else if time.Now().Sub(*resp.CopyObjectResult.LastModified) > maxClockSkew {
-               return fmt.Errorf("PutCopy succeeded but returned an old timestamp: %q: %s", resp.CopyObjectResult.LastModified, resp.CopyObjectResult.LastModified)
-       }
-       return nil
-}
-
-func (v *S3AWSVolume) check(ec2metadataHostname string) error {
-       if v.Bucket == "" {
-               return errors.New("DriverParameters: Bucket must be provided")
-       }
-       if v.IndexPageSize == 0 {
-               v.IndexPageSize = 1000
-       }
-       if v.RaceWindow < 0 {
-               return errors.New("DriverParameters: RaceWindow must not be negative")
-       }
-
-       if v.V2Signature {
-               return errors.New("DriverParameters: V2Signature is not supported")
-       }
-
-       defaultResolver := endpoints.NewDefaultResolver()
-
-       cfg := defaults.Config()
-
-       if v.Endpoint == "" && v.Region == "" {
-               return fmt.Errorf("AWS region or endpoint must be specified")
-       } else if v.Endpoint != "" || ec2metadataHostname != "" {
-               myCustomResolver := func(service, region string) (aws.Endpoint, error) {
-                       if v.Endpoint != "" && service == "s3" {
-                               return aws.Endpoint{
-                                       URL:           v.Endpoint,
-                                       SigningRegion: region,
-                               }, nil
-                       } else if service == "ec2metadata" && ec2metadataHostname != "" {
-                               return aws.Endpoint{
-                                       URL: ec2metadataHostname,
-                               }, nil
-                       } else {
-                               return defaultResolver.ResolveEndpoint(service, region)
-                       }
-               }
-               cfg.EndpointResolver = aws.EndpointResolverFunc(myCustomResolver)
-       }
-       if v.Region == "" {
-               // Endpoint is already specified (otherwise we would
-               // have errored out above), but Region is also
-               // required by the aws sdk, in order to determine
-               // SignatureVersions.
-               v.Region = "us-east-1"
-       }
-       cfg.Region = v.Region
-
-       // Zero timeouts mean "wait forever", which is a bad
-       // default. Default to long timeouts instead.
-       if v.ConnectTimeout == 0 {
-               v.ConnectTimeout = s3DefaultConnectTimeout
-       }
-       if v.ReadTimeout == 0 {
-               v.ReadTimeout = s3DefaultReadTimeout
-       }
-
-       creds := aws.NewChainProvider(
-               []aws.CredentialsProvider{
-                       aws.NewStaticCredentialsProvider(v.AccessKeyID, v.SecretAccessKey, v.AuthToken),
-                       ec2rolecreds.New(ec2metadata.New(cfg)),
-               })
-
-       cfg.Credentials = creds
-
-       v.bucket = &s3AWSbucket{
-               bucket: v.Bucket,
-               svc:    s3.New(cfg),
-       }
-
-       // Set up prometheus metrics
-       lbls := prometheus.Labels{"device_id": v.GetDeviceID()}
-       v.bucket.stats.opsCounters, v.bucket.stats.errCounters, v.bucket.stats.ioBytes = v.metrics.getCounterVecsFor(lbls)
-
-       return nil
-}
-
-// String implements fmt.Stringer.
-func (v *S3AWSVolume) String() string {
-       return fmt.Sprintf("s3-bucket:%+q", v.Bucket)
-}
-
-// GetDeviceID returns a globally unique ID for the storage bucket.
-func (v *S3AWSVolume) GetDeviceID() string {
-       return "s3://" + v.Endpoint + "/" + v.Bucket
-}
-
-// Compare the given data with the stored data.
-func (v *S3AWSVolume) Compare(ctx context.Context, loc string, expect []byte) error {
-       key := v.key(loc)
-       errChan := make(chan error, 1)
-       go func() {
-               _, err := v.head("recent/" + key)
-               errChan <- err
-       }()
-       var err error
-       select {
-       case <-ctx.Done():
-               return ctx.Err()
-       case err = <-errChan:
-       }
-       if err != nil {
-               // Checking for the key itself here would interfere
-               // with future GET requests.
-               //
-               // On AWS, if X doesn't exist, a HEAD or GET request
-               // for X causes X's non-existence to be cached. Thus,
-               // if we test for X, then create X and return a
-               // signature to our client, the client might still get
-               // 404 from all keepstores when trying to read it.
-               //
-               // To avoid this, we avoid doing HEAD X or GET X until
-               // we know X has been written.
-               //
-               // Note that X might exist even though recent/X
-               // doesn't: for example, the response to HEAD recent/X
-               // might itself come from a stale cache. In such
-               // cases, we will return a false negative and
-               // PutHandler might needlessly create another replica
-               // on a different volume. That's not ideal, but it's
-               // better than passing the eventually-consistent
-               // problem on to our clients.
-               return v.translateError(err)
-       }
-
-       input := &s3.GetObjectInput{
-               Bucket: aws.String(v.bucket.bucket),
-               Key:    aws.String(key),
-       }
-
-       req := v.bucket.svc.GetObjectRequest(input)
-       result, err := req.Send(ctx)
-       if err != nil {
-               return v.translateError(err)
-       }
-       return v.translateError(compareReaderWithBuf(ctx, result.Body, expect, loc[:32]))
-}
-
-// EmptyTrash looks for trashed blocks that exceeded BlobTrashLifetime
-// and deletes them from the volume.
-func (v *S3AWSVolume) EmptyTrash() {
-       if v.cluster.Collections.BlobDeleteConcurrency < 1 {
-               return
-       }
-
-       var bytesInTrash, blocksInTrash, bytesDeleted, blocksDeleted int64
-
-       // Define "ready to delete" as "...when EmptyTrash started".
-       startT := time.Now()
-
-       emptyOneKey := func(trash *s3.Object) {
-               key := strings.TrimPrefix(*trash.Key, "trash/")
-               loc, isblk := v.isKeepBlock(key)
-               if !isblk {
-                       return
-               }
-               atomic.AddInt64(&bytesInTrash, *trash.Size)
-               atomic.AddInt64(&blocksInTrash, 1)
-
-               trashT := *trash.LastModified
-               recent, err := v.head("recent/" + key)
-               if err != nil && os.IsNotExist(v.translateError(err)) {
-                       v.logger.Warnf("EmptyTrash: found trash marker %q but no %q (%s); calling Untrash", *trash.Key, "recent/"+key, err)
-                       err = v.Untrash(loc)
-                       if err != nil {
-                               v.logger.WithError(err).Errorf("EmptyTrash: Untrash(%q) failed", loc)
-                       }
-                       return
-               } else if err != nil {
-                       v.logger.WithError(err).Warnf("EmptyTrash: HEAD %q failed", "recent/"+key)
-                       return
-               }
-               if trashT.Sub(*recent.LastModified) < v.cluster.Collections.BlobSigningTTL.Duration() {
-                       if age := startT.Sub(*recent.LastModified); age >= v.cluster.Collections.BlobSigningTTL.Duration()-time.Duration(v.RaceWindow) {
-                               // recent/key is too old to protect
-                               // loc from being Trashed again during
-                               // the raceWindow that starts if we
-                               // delete trash/X now.
-                               //
-                               // Note this means (TrashSweepInterval
-                               // < BlobSigningTTL - raceWindow) is
-                               // necessary to avoid starvation.
-                               v.logger.Infof("EmptyTrash: detected old race for %q, calling fixRace + Touch", loc)
-                               v.fixRace(key)
-                               v.Touch(loc)
-                               return
-                       }
-                       _, err := v.head(key)
-                       if os.IsNotExist(err) {
-                               v.logger.Infof("EmptyTrash: detected recent race for %q, calling fixRace", loc)
-                               v.fixRace(key)
-                               return
-                       } else if err != nil {
-                               v.logger.WithError(err).Warnf("EmptyTrash: HEAD %q failed", loc)
-                               return
-                       }
-               }
-               if startT.Sub(trashT) < v.cluster.Collections.BlobTrashLifetime.Duration() {
-                       return
-               }
-               err = v.bucket.Del(*trash.Key)
-               if err != nil {
-                       v.logger.WithError(err).Errorf("EmptyTrash: error deleting %q", *trash.Key)
-                       return
-               }
-               atomic.AddInt64(&bytesDeleted, *trash.Size)
-               atomic.AddInt64(&blocksDeleted, 1)
-
-               _, err = v.head(*trash.Key)
-               if err == nil {
-                       v.logger.Warnf("EmptyTrash: HEAD %q succeeded immediately after deleting %q", loc, loc)
-                       return
-               }
-               if !os.IsNotExist(v.translateError(err)) {
-                       v.logger.WithError(err).Warnf("EmptyTrash: HEAD %q failed", key)
-                       return
-               }
-               err = v.bucket.Del("recent/" + key)
-               if err != nil {
-                       v.logger.WithError(err).Warnf("EmptyTrash: error deleting %q", "recent/"+key)
-               }
-       }
-
-       var wg sync.WaitGroup
-       todo := make(chan *s3.Object, v.cluster.Collections.BlobDeleteConcurrency)
-       for i := 0; i < v.cluster.Collections.BlobDeleteConcurrency; i++ {
-               wg.Add(1)
-               go func() {
-                       defer wg.Done()
-                       for key := range todo {
-                               emptyOneKey(key)
-                       }
-               }()
-       }
-
-       trashL := s3awsLister{
-               Logger:   v.logger,
-               Bucket:   v.bucket,
-               Prefix:   "trash/",
-               PageSize: v.IndexPageSize,
-               Stats:    &v.bucket.stats,
-       }
-       for trash := trashL.First(); trash != nil; trash = trashL.Next() {
-               todo <- trash
-       }
-       close(todo)
-       wg.Wait()
-
-       if err := trashL.Error(); err != nil {
-               v.logger.WithError(err).Error("EmptyTrash: lister failed")
-       }
-       v.logger.Infof("EmptyTrash: stats for %v: Deleted %v bytes in %v blocks. Remaining in trash: %v bytes in %v blocks.", v.String(), bytesDeleted, blocksDeleted, bytesInTrash-bytesDeleted, blocksInTrash-blocksDeleted)
-}
-
-// fixRace(X) is called when "recent/X" exists but "X" doesn't
-// exist. If the timestamps on "recent/X" and "trash/X" indicate there
-// was a race between Put and Trash, fixRace recovers from the race by
-// Untrashing the block.
-func (v *S3AWSVolume) fixRace(key string) bool {
-       trash, err := v.head("trash/" + key)
-       if err != nil {
-               if !os.IsNotExist(v.translateError(err)) {
-                       v.logger.WithError(err).Errorf("fixRace: HEAD %q failed", "trash/"+key)
-               }
-               return false
-       }
-
-       recent, err := v.head("recent/" + key)
-       if err != nil {
-               v.logger.WithError(err).Errorf("fixRace: HEAD %q failed", "recent/"+key)
-               return false
-       }
-
-       recentTime := *recent.LastModified
-       trashTime := *trash.LastModified
-       ageWhenTrashed := trashTime.Sub(recentTime)
-       if ageWhenTrashed >= v.cluster.Collections.BlobSigningTTL.Duration() {
-               // No evidence of a race: block hasn't been written
-               // since it became eligible for Trash. No fix needed.
-               return false
-       }
-
-       v.logger.Infof("fixRace: %q: trashed at %s but touched at %s (age when trashed = %s < %s)", key, trashTime, recentTime, ageWhenTrashed, v.cluster.Collections.BlobSigningTTL)
-       v.logger.Infof("fixRace: copying %q to %q to recover from race between Put/Touch and Trash", "recent/"+key, key)
-       err = v.safeCopy(key, "trash/"+key)
-       if err != nil {
-               v.logger.WithError(err).Error("fixRace: copy failed")
-               return false
-       }
-       return true
-}
-
-func (v *S3AWSVolume) head(key string) (result *s3.HeadObjectOutput, err error) {
-       input := &s3.HeadObjectInput{
-               Bucket: aws.String(v.bucket.bucket),
-               Key:    aws.String(key),
-       }
-
-       req := v.bucket.svc.HeadObjectRequest(input)
-       res, err := req.Send(context.TODO())
-
-       v.bucket.stats.TickOps("head")
-       v.bucket.stats.Tick(&v.bucket.stats.Ops, &v.bucket.stats.HeadOps)
-       v.bucket.stats.TickErr(err)
-
-       if err != nil {
-               return nil, v.translateError(err)
-       }
-       result = res.HeadObjectOutput
-       return
-}
-
-// Get a block: copy the block data into buf, and return the number of
-// bytes copied.
-func (v *S3AWSVolume) Get(ctx context.Context, loc string, buf []byte) (int, error) {
-       // Do not use getWithPipe here: the BlockReader interface does not pass
-       // through 'buf []byte', and we don't want to allocate two buffers for each
-       // read request. Instead, use a version of ReadBlock that accepts 'buf []byte'
-       // as an input.
-       key := v.key(loc)
-       count, err := v.readWorker(ctx, key, buf)
-       if err == nil {
-               return count, err
-       }
-
-       err = v.translateError(err)
-       if !os.IsNotExist(err) {
-               return 0, err
-       }
-
-       _, err = v.head("recent/" + key)
-       err = v.translateError(err)
-       if err != nil {
-               // If we can't read recent/X, there's no point in
-               // trying fixRace. Give up.
-               return 0, err
-       }
-       if !v.fixRace(key) {
-               err = os.ErrNotExist
-               return 0, err
-       }
-
-       count, err = v.readWorker(ctx, key, buf)
-       if err != nil {
-               v.logger.Warnf("reading %s after successful fixRace: %s", loc, err)
-               err = v.translateError(err)
-               return 0, err
-       }
-       return count, err
-}
-
-func (v *S3AWSVolume) readWorker(ctx context.Context, key string, buf []byte) (int, error) {
-       awsBuf := aws.NewWriteAtBuffer(buf)
-       downloader := s3manager.NewDownloaderWithClient(v.bucket.svc, func(u *s3manager.Downloader) {
-               u.PartSize = PartSize
-               u.Concurrency = ReadConcurrency
-       })
-
-       v.logger.Debugf("Partsize: %d; Concurrency: %d\n", downloader.PartSize, downloader.Concurrency)
-
-       count, err := downloader.DownloadWithContext(ctx, awsBuf, &s3.GetObjectInput{
-               Bucket: aws.String(v.bucket.bucket),
-               Key:    aws.String(key),
-       })
-       v.bucket.stats.TickOps("get")
-       v.bucket.stats.Tick(&v.bucket.stats.Ops, &v.bucket.stats.GetOps)
-       v.bucket.stats.TickErr(err)
-       v.bucket.stats.TickInBytes(uint64(count))
-       return int(count), v.translateError(err)
-}
-
-func (v *S3AWSVolume) writeObject(ctx context.Context, key string, r io.Reader) error {
-       if r == nil {
-               // r == nil leads to a memory violation in func readFillBuf in
-               // aws-sdk-go-v2@v0.23.0/service/s3/s3manager/upload.go
-               r = bytes.NewReader(nil)
-       }
-
-       uploadInput := s3manager.UploadInput{
-               Bucket: aws.String(v.bucket.bucket),
-               Key:    aws.String(key),
-               Body:   r,
-       }
-
-       if loc, ok := v.isKeepBlock(key); ok {
-               var contentMD5 string
-               md5, err := hex.DecodeString(loc)
-               if err != nil {
-                       return v.translateError(err)
-               }
-               contentMD5 = base64.StdEncoding.EncodeToString(md5)
-               uploadInput.ContentMD5 = &contentMD5
-       }
-
-       // Experimentation indicated that using concurrency 5 yields the best
-       // throughput, better than higher concurrency (10 or 13) by ~5%.
-       // Defining u.BufferProvider = s3manager.NewBufferedReadSeekerWriteToPool(64 * 1024 * 1024)
-       // is detrimental to througput (minus ~15%).
-       uploader := s3manager.NewUploaderWithClient(v.bucket.svc, func(u *s3manager.Uploader) {
-               u.PartSize = PartSize
-               u.Concurrency = WriteConcurrency
-       })
-
-       // Unlike the goamz S3 driver, we don't need to precompute ContentSHA256:
-       // the aws-sdk-go v2 SDK uses a ReadSeeker to avoid having to copy the
-       // block, so there is no extra memory use to be concerned about. See
-       // makeSha256Reader in aws/signer/v4/v4.go. In fact, we explicitly disable
-       // calculating the Sha-256 because we don't need it; we already use md5sum
-       // hashes that match the name of the block.
-       _, err := uploader.UploadWithContext(ctx, &uploadInput, s3manager.WithUploaderRequestOptions(func(r *aws.Request) {
-               r.HTTPRequest.Header.Set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD")
-       }))
-
-       v.bucket.stats.TickOps("put")
-       v.bucket.stats.Tick(&v.bucket.stats.Ops, &v.bucket.stats.PutOps)
-       v.bucket.stats.TickErr(err)
-
-       return v.translateError(err)
-}
-
-// Put writes a block.
-func (v *S3AWSVolume) Put(ctx context.Context, loc string, block []byte) error {
-       // Do not use putWithPipe here; we want to pass an io.ReadSeeker to the S3
-       // sdk to avoid memory allocation there. See #17339 for more information.
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-
-       rdr := bytes.NewReader(block)
-       r := NewCountingReaderAtSeeker(rdr, v.bucket.stats.TickOutBytes)
-       key := v.key(loc)
-       err := v.writeObject(ctx, key, r)
-       if err != nil {
-               return err
-       }
-       return v.writeObject(ctx, "recent/"+key, nil)
-}
-
-type s3awsLister struct {
-       Logger            logrus.FieldLogger
-       Bucket            *s3AWSbucket
-       Prefix            string
-       PageSize          int
-       Stats             *s3awsbucketStats
-       ContinuationToken string
-       buf               []s3.Object
-       err               error
-}
-
-// First fetches the first page and returns the first item. It returns
-// nil if the response is the empty set or an error occurs.
-func (lister *s3awsLister) First() *s3.Object {
-       lister.getPage()
-       return lister.pop()
-}
-
-// Next returns the next item, fetching the next page if necessary. It
-// returns nil if the last available item has already been fetched, or
-// an error occurs.
-func (lister *s3awsLister) Next() *s3.Object {
-       if len(lister.buf) == 0 && lister.ContinuationToken != "" {
-               lister.getPage()
-       }
-       return lister.pop()
-}
-
-// Return the most recent error encountered by First or Next.
-func (lister *s3awsLister) Error() error {
-       return lister.err
-}
-
-func (lister *s3awsLister) getPage() {
-       lister.Stats.TickOps("list")
-       lister.Stats.Tick(&lister.Stats.Ops, &lister.Stats.ListOps)
-
-       var input *s3.ListObjectsV2Input
-       if lister.ContinuationToken == "" {
-               input = &s3.ListObjectsV2Input{
-                       Bucket:  aws.String(lister.Bucket.bucket),
-                       MaxKeys: aws.Int64(int64(lister.PageSize)),
-                       Prefix:  aws.String(lister.Prefix),
-               }
-       } else {
-               input = &s3.ListObjectsV2Input{
-                       Bucket:            aws.String(lister.Bucket.bucket),
-                       MaxKeys:           aws.Int64(int64(lister.PageSize)),
-                       Prefix:            aws.String(lister.Prefix),
-                       ContinuationToken: &lister.ContinuationToken,
-               }
-       }
-
-       req := lister.Bucket.svc.ListObjectsV2Request(input)
-       resp, err := req.Send(context.Background())
-       if err != nil {
-               if aerr, ok := err.(awserr.Error); ok {
-                       lister.err = aerr
-               } else {
-                       lister.err = err
-               }
-               return
-       }
-
-       if *resp.IsTruncated {
-               lister.ContinuationToken = *resp.NextContinuationToken
-       } else {
-               lister.ContinuationToken = ""
-       }
-       lister.buf = make([]s3.Object, 0, len(resp.Contents))
-       for _, key := range resp.Contents {
-               if !strings.HasPrefix(*key.Key, lister.Prefix) {
-                       lister.Logger.Warnf("s3awsLister: S3 Bucket.List(prefix=%q) returned key %q", lister.Prefix, *key.Key)
-                       continue
-               }
-               lister.buf = append(lister.buf, key)
-       }
-}
-
-func (lister *s3awsLister) pop() (k *s3.Object) {
-       if len(lister.buf) > 0 {
-               k = &lister.buf[0]
-               lister.buf = lister.buf[1:]
-       }
-       return
-}
-
-// IndexTo writes a complete list of locators with the given prefix
-// for which Get() can retrieve data.
-func (v *S3AWSVolume) IndexTo(prefix string, writer io.Writer) error {
-       prefix = v.key(prefix)
-       // Use a merge sort to find matching sets of X and recent/X.
-       dataL := s3awsLister{
-               Logger:   v.logger,
-               Bucket:   v.bucket,
-               Prefix:   prefix,
-               PageSize: v.IndexPageSize,
-               Stats:    &v.bucket.stats,
-       }
-       recentL := s3awsLister{
-               Logger:   v.logger,
-               Bucket:   v.bucket,
-               Prefix:   "recent/" + prefix,
-               PageSize: v.IndexPageSize,
-               Stats:    &v.bucket.stats,
-       }
-       for data, recent := dataL.First(), recentL.First(); data != nil && dataL.Error() == nil; data = dataL.Next() {
-               if *data.Key >= "g" {
-                       // Conveniently, "recent/*" and "trash/*" are
-                       // lexically greater than all hex-encoded data
-                       // hashes, so stopping here avoids iterating
-                       // over all of them needlessly with dataL.
-                       break
-               }
-               loc, isblk := v.isKeepBlock(*data.Key)
-               if !isblk {
-                       continue
-               }
-
-               // stamp is the list entry we should use to report the
-               // last-modified time for this data block: it will be
-               // the recent/X entry if one exists, otherwise the
-               // entry for the data block itself.
-               stamp := data
-
-               // Advance to the corresponding recent/X marker, if any
-               for recent != nil && recentL.Error() == nil {
-                       if cmp := strings.Compare((*recent.Key)[7:], *data.Key); cmp < 0 {
-                               recent = recentL.Next()
-                               continue
-                       } else if cmp == 0 {
-                               stamp = recent
-                               recent = recentL.Next()
-                               break
-                       } else {
-                               // recent/X marker is missing: we'll
-                               // use the timestamp on the data
-                               // object.
-                               break
-                       }
-               }
-               if err := recentL.Error(); err != nil {
-                       return err
-               }
-               // We truncate sub-second precision here. Otherwise
-               // timestamps will never match the RFC1123-formatted
-               // Last-Modified values parsed by Mtime().
-               fmt.Fprintf(writer, "%s+%d %d\n", loc, *data.Size, stamp.LastModified.Unix()*1000000000)
-       }
-       return dataL.Error()
-}
-
-// Mtime returns the stored timestamp for the given locator.
-func (v *S3AWSVolume) Mtime(loc string) (time.Time, error) {
-       key := v.key(loc)
-       _, err := v.head(key)
-       if err != nil {
-               return s3AWSZeroTime, v.translateError(err)
-       }
-       resp, err := v.head("recent/" + key)
-       err = v.translateError(err)
-       if os.IsNotExist(err) {
-               // The data object X exists, but recent/X is missing.
-               err = v.writeObject(context.Background(), "recent/"+key, nil)
-               if err != nil {
-                       v.logger.WithError(err).Errorf("error creating %q", "recent/"+key)
-                       return s3AWSZeroTime, v.translateError(err)
-               }
-               v.logger.Infof("Mtime: created %q to migrate existing block to new storage scheme", "recent/"+key)
-               resp, err = v.head("recent/" + key)
-               if err != nil {
-                       v.logger.WithError(err).Errorf("HEAD failed after creating %q", "recent/"+key)
-                       return s3AWSZeroTime, v.translateError(err)
-               }
-       } else if err != nil {
-               // HEAD recent/X failed for some other reason.
-               return s3AWSZeroTime, err
-       }
-       return *resp.LastModified, err
-}
-
-// Status returns a *VolumeStatus representing the current in-use
-// storage capacity and a fake available capacity that doesn't make
-// the volume seem full or nearly-full.
-func (v *S3AWSVolume) Status() *VolumeStatus {
-       return &VolumeStatus{
-               DeviceNum: 1,
-               BytesFree: BlockSize * 1000,
-               BytesUsed: 1,
-       }
-}
-
-// InternalStats returns bucket I/O and API call counters.
-func (v *S3AWSVolume) InternalStats() interface{} {
-       return &v.bucket.stats
-}
-
-// Touch sets the timestamp for the given locator to the current time.
-func (v *S3AWSVolume) Touch(loc string) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       key := v.key(loc)
-       _, err := v.head(key)
-       err = v.translateError(err)
-       if os.IsNotExist(err) && v.fixRace(key) {
-               // The data object got trashed in a race, but fixRace
-               // rescued it.
-       } else if err != nil {
-               return err
-       }
-       err = v.writeObject(context.Background(), "recent/"+key, nil)
-       return v.translateError(err)
-}
-
-// checkRaceWindow returns a non-nil error if trash/key is, or might
-// be, in the race window (i.e., it's not safe to trash key).
-func (v *S3AWSVolume) checkRaceWindow(key string) error {
-       resp, err := v.head("trash/" + key)
-       err = v.translateError(err)
-       if os.IsNotExist(err) {
-               // OK, trash/X doesn't exist so we're not in the race
-               // window
-               return nil
-       } else if err != nil {
-               // Error looking up trash/X. We don't know whether
-               // we're in the race window
-               return err
-       }
-       t := resp.LastModified
-       safeWindow := t.Add(v.cluster.Collections.BlobTrashLifetime.Duration()).Sub(time.Now().Add(time.Duration(v.RaceWindow)))
-       if safeWindow <= 0 {
-               // We can't count on "touch trash/X" to prolong
-               // trash/X's lifetime. The new timestamp might not
-               // become visible until now+raceWindow, and EmptyTrash
-               // is allowed to delete trash/X before then.
-               return fmt.Errorf("%s: same block is already in trash, and safe window ended %s ago", key, -safeWindow)
-       }
-       // trash/X exists, but it won't be eligible for deletion until
-       // after now+raceWindow, so it's safe to overwrite it.
-       return nil
-}
-
-func (b *s3AWSbucket) Del(path string) error {
-       input := &s3.DeleteObjectInput{
-               Bucket: aws.String(b.bucket),
-               Key:    aws.String(path),
-       }
-       req := b.svc.DeleteObjectRequest(input)
-       _, err := req.Send(context.Background())
-       b.stats.TickOps("delete")
-       b.stats.Tick(&b.stats.Ops, &b.stats.DelOps)
-       b.stats.TickErr(err)
-       return err
-}
-
-// Trash a Keep block.
-func (v *S3AWSVolume) Trash(loc string) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       if t, err := v.Mtime(loc); err != nil {
-               return err
-       } else if time.Since(t) < v.cluster.Collections.BlobSigningTTL.Duration() {
-               return nil
-       }
-       key := v.key(loc)
-       if v.cluster.Collections.BlobTrashLifetime == 0 {
-               if !v.UnsafeDelete {
-                       return ErrS3TrashDisabled
-               }
-               return v.translateError(v.bucket.Del(key))
-       }
-       err := v.checkRaceWindow(key)
-       if err != nil {
-               return err
-       }
-       err = v.safeCopy("trash/"+key, key)
-       if err != nil {
-               return err
-       }
-       return v.translateError(v.bucket.Del(key))
-}
-
-// Untrash moves block from trash back into store
-func (v *S3AWSVolume) Untrash(loc string) error {
-       key := v.key(loc)
-       err := v.safeCopy(key, "trash/"+key)
-       if err != nil {
-               return err
-       }
-       err = v.writeObject(context.Background(), "recent/"+key, nil)
-       return v.translateError(err)
-}
-
-type s3awsbucketStats struct {
-       statsTicker
-       Ops     uint64
-       GetOps  uint64
-       PutOps  uint64
-       HeadOps uint64
-       DelOps  uint64
-       ListOps uint64
-}
-
-func (s *s3awsbucketStats) TickErr(err error) {
-       if err == nil {
-               return
-       }
-       errType := fmt.Sprintf("%T", err)
-       if aerr, ok := err.(awserr.Error); ok {
-               if reqErr, ok := err.(awserr.RequestFailure); ok {
-                       // A service error occurred
-                       errType = errType + fmt.Sprintf(" %d %s", reqErr.StatusCode(), aerr.Code())
-               } else {
-                       errType = errType + fmt.Sprintf(" 000 %s", aerr.Code())
-               }
-       }
-       s.statsTicker.TickErr(err, errType)
-}
diff --git a/services/keepstore/s3aws_volume_test.go b/services/keepstore/s3aws_volume_test.go
deleted file mode 100644 (file)
index c7e2d48..0000000
+++ /dev/null
@@ -1,675 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "bytes"
-       "context"
-       "crypto/md5"
-       "encoding/json"
-       "fmt"
-       "io"
-       "net/http"
-       "net/http/httptest"
-       "os"
-       "strings"
-       "time"
-
-       "git.arvados.org/arvados.git/sdk/go/arvados"
-       "git.arvados.org/arvados.git/sdk/go/ctxlog"
-
-       "github.com/aws/aws-sdk-go-v2/aws"
-       "github.com/aws/aws-sdk-go-v2/service/s3"
-       "github.com/aws/aws-sdk-go-v2/service/s3/s3manager"
-
-       "github.com/johannesboyne/gofakes3"
-       "github.com/johannesboyne/gofakes3/backend/s3mem"
-       "github.com/prometheus/client_golang/prometheus"
-       "github.com/sirupsen/logrus"
-       check "gopkg.in/check.v1"
-)
-
-const (
-       S3AWSTestBucketName = "testbucket"
-)
-
-type s3AWSFakeClock struct {
-       now *time.Time
-}
-
-func (c *s3AWSFakeClock) Now() time.Time {
-       if c.now == nil {
-               return time.Now().UTC()
-       }
-       return c.now.UTC()
-}
-
-func (c *s3AWSFakeClock) Since(t time.Time) time.Duration {
-       return c.Now().Sub(t)
-}
-
-var _ = check.Suite(&StubbedS3AWSSuite{})
-
-var srv httptest.Server
-
-type StubbedS3AWSSuite struct {
-       s3server *httptest.Server
-       metadata *httptest.Server
-       cluster  *arvados.Cluster
-       handler  *handler
-       volumes  []*TestableS3AWSVolume
-}
-
-func (s *StubbedS3AWSSuite) SetUpTest(c *check.C) {
-       s.s3server = nil
-       s.metadata = nil
-       s.cluster = testCluster(c)
-       s.cluster.Volumes = map[string]arvados.Volume{
-               "zzzzz-nyw5e-000000000000000": {Driver: "S3"},
-               "zzzzz-nyw5e-111111111111111": {Driver: "S3"},
-       }
-       s.handler = &handler{}
-}
-
-func (s *StubbedS3AWSSuite) TestGeneric(c *check.C) {
-       DoGenericVolumeTests(c, false, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               // Use a negative raceWindow so s3test's 1-second
-               // timestamp precision doesn't confuse fixRace.
-               return s.newTestableVolume(c, cluster, volume, metrics, -2*time.Second)
-       })
-}
-
-func (s *StubbedS3AWSSuite) TestGenericReadOnly(c *check.C) {
-       DoGenericVolumeTests(c, true, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               return s.newTestableVolume(c, cluster, volume, metrics, -2*time.Second)
-       })
-}
-
-func (s *StubbedS3AWSSuite) TestGenericWithPrefix(c *check.C) {
-       DoGenericVolumeTests(c, false, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               v := s.newTestableVolume(c, cluster, volume, metrics, -2*time.Second)
-               v.PrefixLength = 3
-               return v
-       })
-}
-
-func (s *StubbedS3AWSSuite) TestIndex(c *check.C) {
-       v := s.newTestableVolume(c, s.cluster, arvados.Volume{Replication: 2}, newVolumeMetricsVecs(prometheus.NewRegistry()), 0)
-       v.IndexPageSize = 3
-       for i := 0; i < 256; i++ {
-               v.PutRaw(fmt.Sprintf("%02x%030x", i, i), []byte{102, 111, 111})
-       }
-       for _, spec := range []struct {
-               prefix      string
-               expectMatch int
-       }{
-               {"", 256},
-               {"c", 16},
-               {"bc", 1},
-               {"abc", 0},
-       } {
-               buf := new(bytes.Buffer)
-               err := v.IndexTo(spec.prefix, buf)
-               c.Check(err, check.IsNil)
-
-               idx := bytes.SplitAfter(buf.Bytes(), []byte{10})
-               c.Check(len(idx), check.Equals, spec.expectMatch+1)
-               c.Check(len(idx[len(idx)-1]), check.Equals, 0)
-       }
-}
-
-func (s *StubbedS3AWSSuite) TestSignature(c *check.C) {
-       var header http.Header
-       stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-               header = r.Header
-       }))
-       defer stub.Close()
-
-       // The aws-sdk-go-v2 driver only supports S3 V4 signatures. S3 v2 signatures are being phased out
-       // as of June 24, 2020. Cf. https://forums.aws.amazon.com/ann.jspa?annID=5816
-       vol := S3AWSVolume{
-               S3VolumeDriverParameters: arvados.S3VolumeDriverParameters{
-                       AccessKeyID:     "xxx",
-                       SecretAccessKey: "xxx",
-                       Endpoint:        stub.URL,
-                       Region:          "test-region-1",
-                       Bucket:          "test-bucket-name",
-               },
-               cluster: s.cluster,
-               logger:  ctxlog.TestLogger(c),
-               metrics: newVolumeMetricsVecs(prometheus.NewRegistry()),
-       }
-       err := vol.check("")
-       // Our test S3 server uses the older 'Path Style'
-       vol.bucket.svc.ForcePathStyle = true
-
-       c.Check(err, check.IsNil)
-       err = vol.Put(context.Background(), "acbd18db4cc2f85cedef654fccc4a4d8", []byte("foo"))
-       c.Check(err, check.IsNil)
-       c.Check(header.Get("Authorization"), check.Matches, `AWS4-HMAC-SHA256 .*`)
-}
-
-func (s *StubbedS3AWSSuite) TestIAMRoleCredentials(c *check.C) {
-       s.metadata = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-               upd := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
-               exp := time.Now().UTC().Add(time.Hour).Format(time.RFC3339)
-               // Literal example from
-               // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials
-               // but with updated timestamps
-               io.WriteString(w, `{"Code":"Success","LastUpdated":"`+upd+`","Type":"AWS-HMAC","AccessKeyId":"ASIAIOSFODNN7EXAMPLE","SecretAccessKey":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY","Token":"token","Expiration":"`+exp+`"}`)
-       }))
-       defer s.metadata.Close()
-
-       v := &S3AWSVolume{
-               S3VolumeDriverParameters: arvados.S3VolumeDriverParameters{
-                       IAMRole:  s.metadata.URL + "/latest/api/token",
-                       Endpoint: "http://localhost:12345",
-                       Region:   "test-region-1",
-                       Bucket:   "test-bucket-name",
-               },
-               cluster: s.cluster,
-               logger:  ctxlog.TestLogger(c),
-               metrics: newVolumeMetricsVecs(prometheus.NewRegistry()),
-       }
-       err := v.check(s.metadata.URL + "/latest")
-       c.Check(err, check.IsNil)
-       creds, err := v.bucket.svc.Client.Config.Credentials.Retrieve(context.Background())
-       c.Check(err, check.IsNil)
-       c.Check(creds.AccessKeyID, check.Equals, "ASIAIOSFODNN7EXAMPLE")
-       c.Check(creds.SecretAccessKey, check.Equals, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
-
-       s.metadata = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-               w.WriteHeader(http.StatusNotFound)
-       }))
-       deadv := &S3AWSVolume{
-               S3VolumeDriverParameters: arvados.S3VolumeDriverParameters{
-                       IAMRole:  s.metadata.URL + "/fake-metadata/test-role",
-                       Endpoint: "http://localhost:12345",
-                       Region:   "test-region-1",
-                       Bucket:   "test-bucket-name",
-               },
-               cluster: s.cluster,
-               logger:  ctxlog.TestLogger(c),
-               metrics: newVolumeMetricsVecs(prometheus.NewRegistry()),
-       }
-       err = deadv.check(s.metadata.URL + "/latest")
-       c.Check(err, check.IsNil)
-       _, err = deadv.bucket.svc.Client.Config.Credentials.Retrieve(context.Background())
-       c.Check(err, check.ErrorMatches, `(?s).*EC2RoleRequestError: no EC2 instance role found.*`)
-       c.Check(err, check.ErrorMatches, `(?s).*404.*`)
-}
-
-func (s *StubbedS3AWSSuite) TestStats(c *check.C) {
-       v := s.newTestableVolume(c, s.cluster, arvados.Volume{Replication: 2}, newVolumeMetricsVecs(prometheus.NewRegistry()), 5*time.Minute)
-       stats := func() string {
-               buf, err := json.Marshal(v.InternalStats())
-               c.Check(err, check.IsNil)
-               return string(buf)
-       }
-
-       c.Check(stats(), check.Matches, `.*"Ops":0,.*`)
-
-       loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       _, err := v.Get(context.Background(), loc, make([]byte, 3))
-       c.Check(err, check.NotNil)
-       c.Check(stats(), check.Matches, `.*"Ops":[^0],.*`)
-       c.Check(stats(), check.Matches, `.*"s3.requestFailure 404 NoSuchKey[^"]*":[^0].*`)
-       c.Check(stats(), check.Matches, `.*"InBytes":0,.*`)
-
-       err = v.Put(context.Background(), loc, []byte("foo"))
-       c.Check(err, check.IsNil)
-       c.Check(stats(), check.Matches, `.*"OutBytes":3,.*`)
-       c.Check(stats(), check.Matches, `.*"PutOps":2,.*`)
-
-       _, err = v.Get(context.Background(), loc, make([]byte, 3))
-       c.Check(err, check.IsNil)
-       _, err = v.Get(context.Background(), loc, make([]byte, 3))
-       c.Check(err, check.IsNil)
-       c.Check(stats(), check.Matches, `.*"InBytes":6,.*`)
-}
-
-type s3AWSBlockingHandler struct {
-       requested chan *http.Request
-       unblock   chan struct{}
-}
-
-func (h *s3AWSBlockingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-       if r.Method == "PUT" && !strings.Contains(strings.Trim(r.URL.Path, "/"), "/") {
-               // Accept PutBucket ("PUT /bucketname/"), called by
-               // newTestableVolume
-               return
-       }
-       if h.requested != nil {
-               h.requested <- r
-       }
-       if h.unblock != nil {
-               <-h.unblock
-       }
-       http.Error(w, "nothing here", http.StatusNotFound)
-}
-
-func (s *StubbedS3AWSSuite) TestGetContextCancel(c *check.C) {
-       loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       buf := make([]byte, 3)
-
-       s.testContextCancel(c, func(ctx context.Context, v *TestableS3AWSVolume) error {
-               _, err := v.Get(ctx, loc, buf)
-               return err
-       })
-}
-
-func (s *StubbedS3AWSSuite) TestCompareContextCancel(c *check.C) {
-       loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       buf := []byte("bar")
-
-       s.testContextCancel(c, func(ctx context.Context, v *TestableS3AWSVolume) error {
-               return v.Compare(ctx, loc, buf)
-       })
-}
-
-func (s *StubbedS3AWSSuite) TestPutContextCancel(c *check.C) {
-       loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       buf := []byte("foo")
-
-       s.testContextCancel(c, func(ctx context.Context, v *TestableS3AWSVolume) error {
-               return v.Put(ctx, loc, buf)
-       })
-}
-
-func (s *StubbedS3AWSSuite) testContextCancel(c *check.C, testFunc func(context.Context, *TestableS3AWSVolume) error) {
-       handler := &s3AWSBlockingHandler{}
-       s.s3server = httptest.NewServer(handler)
-       defer s.s3server.Close()
-
-       v := s.newTestableVolume(c, s.cluster, arvados.Volume{Replication: 2}, newVolumeMetricsVecs(prometheus.NewRegistry()), 5*time.Minute)
-
-       ctx, cancel := context.WithCancel(context.Background())
-
-       handler.requested = make(chan *http.Request)
-       handler.unblock = make(chan struct{})
-       defer close(handler.unblock)
-
-       doneFunc := make(chan struct{})
-       go func() {
-               err := testFunc(ctx, v)
-               c.Check(err, check.Equals, context.Canceled)
-               close(doneFunc)
-       }()
-
-       timeout := time.After(10 * time.Second)
-
-       // Wait for the stub server to receive a request, meaning
-       // Get() is waiting for an s3 operation.
-       select {
-       case <-timeout:
-               c.Fatal("timed out waiting for test func to call our handler")
-       case <-doneFunc:
-               c.Fatal("test func finished without even calling our handler!")
-       case <-handler.requested:
-       }
-
-       cancel()
-
-       select {
-       case <-timeout:
-               c.Fatal("timed out")
-       case <-doneFunc:
-       }
-}
-
-func (s *StubbedS3AWSSuite) TestBackendStates(c *check.C) {
-       s.cluster.Collections.BlobTrashLifetime.Set("1h")
-       s.cluster.Collections.BlobSigningTTL.Set("1h")
-
-       v := s.newTestableVolume(c, s.cluster, arvados.Volume{Replication: 2}, newVolumeMetricsVecs(prometheus.NewRegistry()), 5*time.Minute)
-       var none time.Time
-
-       putS3Obj := func(t time.Time, key string, data []byte) {
-               if t == none {
-                       return
-               }
-               v.serverClock.now = &t
-               uploader := s3manager.NewUploaderWithClient(v.bucket.svc)
-               _, err := uploader.UploadWithContext(context.Background(), &s3manager.UploadInput{
-                       Bucket: aws.String(v.bucket.bucket),
-                       Key:    aws.String(key),
-                       Body:   bytes.NewReader(data),
-               })
-               if err != nil {
-                       panic(err)
-               }
-               v.serverClock.now = nil
-               _, err = v.head(key)
-               if err != nil {
-                       panic(err)
-               }
-       }
-
-       t0 := time.Now()
-       nextKey := 0
-       for _, scenario := range []struct {
-               label               string
-               dataT               time.Time
-               recentT             time.Time
-               trashT              time.Time
-               canGet              bool
-               canTrash            bool
-               canGetAfterTrash    bool
-               canUntrash          bool
-               haveTrashAfterEmpty bool
-               freshAfterEmpty     bool
-       }{
-               {
-                       "No related objects",
-                       none, none, none,
-                       false, false, false, false, false, false,
-               },
-               {
-                       // Stored by older version, or there was a
-                       // race between EmptyTrash and Put: Trash is a
-                       // no-op even though the data object is very
-                       // old
-                       "No recent/X",
-                       t0.Add(-48 * time.Hour), none, none,
-                       true, true, true, false, false, false,
-               },
-               {
-                       "Not trash, but old enough to be eligible for trash",
-                       t0.Add(-24 * time.Hour), t0.Add(-2 * time.Hour), none,
-                       true, true, false, false, false, false,
-               },
-               {
-                       "Not trash, and not old enough to be eligible for trash",
-                       t0.Add(-24 * time.Hour), t0.Add(-30 * time.Minute), none,
-                       true, true, true, false, false, false,
-               },
-               {
-                       "Trashed + untrashed copies exist, due to recent race between Trash and Put",
-                       t0.Add(-24 * time.Hour), t0.Add(-3 * time.Minute), t0.Add(-2 * time.Minute),
-                       true, true, true, true, true, false,
-               },
-               {
-                       "Trashed + untrashed copies exist, trash nearly eligible for deletion: prone to Trash race",
-                       t0.Add(-24 * time.Hour), t0.Add(-12 * time.Hour), t0.Add(-59 * time.Minute),
-                       true, false, true, true, true, false,
-               },
-               {
-                       "Trashed + untrashed copies exist, trash is eligible for deletion: prone to Trash race",
-                       t0.Add(-24 * time.Hour), t0.Add(-12 * time.Hour), t0.Add(-61 * time.Minute),
-                       true, false, true, true, false, false,
-               },
-               {
-                       "Trashed + untrashed copies exist, due to old race between Put and unfinished Trash: emptying trash is unsafe",
-                       t0.Add(-24 * time.Hour), t0.Add(-12 * time.Hour), t0.Add(-12 * time.Hour),
-                       true, false, true, true, true, true,
-               },
-               {
-                       "Trashed + untrashed copies exist, used to be unsafe to empty, but since made safe by fixRace+Touch",
-                       t0.Add(-time.Second), t0.Add(-time.Second), t0.Add(-12 * time.Hour),
-                       true, true, true, true, false, false,
-               },
-               {
-                       "Trashed + untrashed copies exist because Trash operation was interrupted (no race)",
-                       t0.Add(-24 * time.Hour), t0.Add(-24 * time.Hour), t0.Add(-12 * time.Hour),
-                       true, false, true, true, false, false,
-               },
-               {
-                       "Trash, not yet eligible for deletion",
-                       none, t0.Add(-12 * time.Hour), t0.Add(-time.Minute),
-                       false, false, false, true, true, false,
-               },
-               {
-                       "Trash, not yet eligible for deletion, prone to races",
-                       none, t0.Add(-12 * time.Hour), t0.Add(-59 * time.Minute),
-                       false, false, false, true, true, false,
-               },
-               {
-                       "Trash, eligible for deletion",
-                       none, t0.Add(-12 * time.Hour), t0.Add(-2 * time.Hour),
-                       false, false, false, true, false, false,
-               },
-               {
-                       "Erroneously trashed during a race, detected before BlobTrashLifetime",
-                       none, t0.Add(-30 * time.Minute), t0.Add(-29 * time.Minute),
-                       true, false, true, true, true, false,
-               },
-               {
-                       "Erroneously trashed during a race, rescue during EmptyTrash despite reaching BlobTrashLifetime",
-                       none, t0.Add(-90 * time.Minute), t0.Add(-89 * time.Minute),
-                       true, false, true, true, true, false,
-               },
-               {
-                       "Trashed copy exists with no recent/* marker (cause unknown); repair by untrashing",
-                       none, none, t0.Add(-time.Minute),
-                       false, false, false, true, true, true,
-               },
-       } {
-               for _, prefixLength := range []int{0, 3} {
-                       v.PrefixLength = prefixLength
-                       c.Logf("Scenario: %q (prefixLength=%d)", scenario.label, prefixLength)
-
-                       // We have a few tests to run for each scenario, and
-                       // the tests are expected to change state. By calling
-                       // this setup func between tests, we (re)create the
-                       // scenario as specified, using a new unique block
-                       // locator to prevent interference from previous
-                       // tests.
-
-                       setupScenario := func() (string, []byte) {
-                               nextKey++
-                               blk := []byte(fmt.Sprintf("%d", nextKey))
-                               loc := fmt.Sprintf("%x", md5.Sum(blk))
-                               key := loc
-                               if prefixLength > 0 {
-                                       key = loc[:prefixLength] + "/" + loc
-                               }
-                               c.Log("\t", loc, "\t", key)
-                               putS3Obj(scenario.dataT, key, blk)
-                               putS3Obj(scenario.recentT, "recent/"+key, nil)
-                               putS3Obj(scenario.trashT, "trash/"+key, blk)
-                               v.serverClock.now = &t0
-                               return loc, blk
-                       }
-
-                       // Check canGet
-                       loc, blk := setupScenario()
-                       buf := make([]byte, len(blk))
-                       _, err := v.Get(context.Background(), loc, buf)
-                       c.Check(err == nil, check.Equals, scenario.canGet)
-                       if err != nil {
-                               c.Check(os.IsNotExist(err), check.Equals, true)
-                       }
-
-                       // Call Trash, then check canTrash and canGetAfterTrash
-                       loc, _ = setupScenario()
-                       err = v.Trash(loc)
-                       c.Check(err == nil, check.Equals, scenario.canTrash)
-                       _, err = v.Get(context.Background(), loc, buf)
-                       c.Check(err == nil, check.Equals, scenario.canGetAfterTrash)
-                       if err != nil {
-                               c.Check(os.IsNotExist(err), check.Equals, true)
-                       }
-
-                       // Call Untrash, then check canUntrash
-                       loc, _ = setupScenario()
-                       err = v.Untrash(loc)
-                       c.Check(err == nil, check.Equals, scenario.canUntrash)
-                       if scenario.dataT != none || scenario.trashT != none {
-                               // In all scenarios where the data exists, we
-                               // should be able to Get after Untrash --
-                               // regardless of timestamps, errors, race
-                               // conditions, etc.
-                               _, err = v.Get(context.Background(), loc, buf)
-                               c.Check(err, check.IsNil)
-                       }
-
-                       // Call EmptyTrash, then check haveTrashAfterEmpty and
-                       // freshAfterEmpty
-                       loc, _ = setupScenario()
-                       v.EmptyTrash()
-                       _, err = v.head("trash/" + v.key(loc))
-                       c.Check(err == nil, check.Equals, scenario.haveTrashAfterEmpty)
-                       if scenario.freshAfterEmpty {
-                               t, err := v.Mtime(loc)
-                               c.Check(err, check.IsNil)
-                               // new mtime must be current (with an
-                               // allowance for 1s timestamp precision)
-                               c.Check(t.After(t0.Add(-time.Second)), check.Equals, true)
-                       }
-
-                       // Check for current Mtime after Put (applies to all
-                       // scenarios)
-                       loc, blk = setupScenario()
-                       err = v.Put(context.Background(), loc, blk)
-                       c.Check(err, check.IsNil)
-                       t, err := v.Mtime(loc)
-                       c.Check(err, check.IsNil)
-                       c.Check(t.After(t0.Add(-time.Second)), check.Equals, true)
-               }
-       }
-}
-
-type TestableS3AWSVolume struct {
-       *S3AWSVolume
-       server      *httptest.Server
-       c           *check.C
-       serverClock *s3AWSFakeClock
-}
-
-type LogrusLog struct {
-       log *logrus.FieldLogger
-}
-
-func (l LogrusLog) Print(level gofakes3.LogLevel, v ...interface{}) {
-       switch level {
-       case gofakes3.LogErr:
-               (*l.log).Errorln(v...)
-       case gofakes3.LogWarn:
-               (*l.log).Warnln(v...)
-       case gofakes3.LogInfo:
-               (*l.log).Infoln(v...)
-       default:
-               panic("unknown level")
-       }
-}
-
-func (s *StubbedS3AWSSuite) newTestableVolume(c *check.C, cluster *arvados.Cluster, volume arvados.Volume, metrics *volumeMetricsVecs, raceWindow time.Duration) *TestableS3AWSVolume {
-
-       clock := &s3AWSFakeClock{}
-       // fake s3
-       backend := s3mem.New(s3mem.WithTimeSource(clock))
-
-       // To enable GoFakeS3 debug logging, pass logger to gofakes3.WithLogger()
-       /* logger := new(LogrusLog)
-       ctxLogger := ctxlog.FromContext(context.Background())
-       logger.log = &ctxLogger */
-       faker := gofakes3.New(backend, gofakes3.WithTimeSource(clock), gofakes3.WithLogger(nil), gofakes3.WithTimeSkewLimit(0))
-       srv := httptest.NewServer(faker.Server())
-
-       endpoint := srv.URL
-       if s.s3server != nil {
-               endpoint = s.s3server.URL
-       }
-
-       iamRole, accessKey, secretKey := "", "xxx", "xxx"
-       if s.metadata != nil {
-               iamRole, accessKey, secretKey = s.metadata.URL+"/fake-metadata/test-role", "", ""
-       }
-
-       v := &TestableS3AWSVolume{
-               S3AWSVolume: &S3AWSVolume{
-                       S3VolumeDriverParameters: arvados.S3VolumeDriverParameters{
-                               IAMRole:            iamRole,
-                               AccessKeyID:        accessKey,
-                               SecretAccessKey:    secretKey,
-                               Bucket:             S3AWSTestBucketName,
-                               Endpoint:           endpoint,
-                               Region:             "test-region-1",
-                               LocationConstraint: true,
-                               UnsafeDelete:       true,
-                               IndexPageSize:      1000,
-                       },
-                       cluster: cluster,
-                       volume:  volume,
-                       logger:  ctxlog.TestLogger(c),
-                       metrics: metrics,
-               },
-               c:           c,
-               server:      srv,
-               serverClock: clock,
-       }
-       c.Assert(v.S3AWSVolume.check(""), check.IsNil)
-       // Our test S3 server uses the older 'Path Style'
-       v.S3AWSVolume.bucket.svc.ForcePathStyle = true
-       // Create the testbucket
-       input := &s3.CreateBucketInput{
-               Bucket: aws.String(S3AWSTestBucketName),
-       }
-       req := v.S3AWSVolume.bucket.svc.CreateBucketRequest(input)
-       _, err := req.Send(context.Background())
-       c.Assert(err, check.IsNil)
-       // We couldn't set RaceWindow until now because check()
-       // rejects negative values.
-       v.S3AWSVolume.RaceWindow = arvados.Duration(raceWindow)
-       return v
-}
-
-// PutRaw skips the ContentMD5 test
-func (v *TestableS3AWSVolume) PutRaw(loc string, block []byte) {
-       key := v.key(loc)
-       r := NewCountingReader(bytes.NewReader(block), v.bucket.stats.TickOutBytes)
-
-       uploader := s3manager.NewUploaderWithClient(v.bucket.svc, func(u *s3manager.Uploader) {
-               u.PartSize = 5 * 1024 * 1024
-               u.Concurrency = 13
-       })
-
-       _, err := uploader.Upload(&s3manager.UploadInput{
-               Bucket: aws.String(v.bucket.bucket),
-               Key:    aws.String(key),
-               Body:   r,
-       })
-       if err != nil {
-               v.logger.Printf("PutRaw: %s: %+v", key, err)
-       }
-
-       empty := bytes.NewReader([]byte{})
-       _, err = uploader.Upload(&s3manager.UploadInput{
-               Bucket: aws.String(v.bucket.bucket),
-               Key:    aws.String("recent/" + key),
-               Body:   empty,
-       })
-       if err != nil {
-               v.logger.Printf("PutRaw: recent/%s: %+v", key, err)
-       }
-}
-
-// TouchWithDate turns back the clock while doing a Touch(). We assume
-// there are no other operations happening on the same s3test server
-// while we do this.
-func (v *TestableS3AWSVolume) TouchWithDate(loc string, lastPut time.Time) {
-       v.serverClock.now = &lastPut
-
-       uploader := s3manager.NewUploaderWithClient(v.bucket.svc)
-       empty := bytes.NewReader([]byte{})
-       _, err := uploader.UploadWithContext(context.Background(), &s3manager.UploadInput{
-               Bucket: aws.String(v.bucket.bucket),
-               Key:    aws.String("recent/" + v.key(loc)),
-               Body:   empty,
-       })
-       if err != nil {
-               panic(err)
-       }
-
-       v.serverClock.now = nil
-}
-
-func (v *TestableS3AWSVolume) Teardown() {
-       v.server.Close()
-}
-
-func (v *TestableS3AWSVolume) ReadWriteOperationLabelValues() (r, w string) {
-       return "get", "put"
-}
diff --git a/services/keepstore/status_test.go b/services/keepstore/status_test.go
deleted file mode 100644 (file)
index 80f98ad..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "encoding/json"
-)
-
-// We don't have isolated unit tests for /status.json yet, but we do
-// check (e.g., in pull_worker_test.go) that /status.json reports
-// specific statistics correctly at the appropriate times.
-
-// getStatusItem("foo","bar","baz") retrieves /status.json, decodes
-// the response body into resp, and returns resp["foo"]["bar"]["baz"].
-func getStatusItem(h *handler, keys ...string) interface{} {
-       resp := IssueRequest(h, &RequestTester{"/status.json", "", "GET", nil, ""})
-       var s interface{}
-       json.NewDecoder(resp.Body).Decode(&s)
-       for _, k := range keys {
-               s = s.(map[string]interface{})[k]
-       }
-       return s
-}
diff --git a/services/keepstore/streamwriterat.go b/services/keepstore/streamwriterat.go
new file mode 100644 (file)
index 0000000..02dce6e
--- /dev/null
@@ -0,0 +1,160 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package keepstore
+
+import (
+       "errors"
+       "fmt"
+       "io"
+       "sync"
+)
+
+// streamWriterAt translates random-access writes to sequential
+// writes. The caller is expected to use an arbitrary sequence of
+// non-overlapping WriteAt calls covering all positions between 0 and
+// N, for any N < len(buf), then call Close.
+//
+// streamWriterAt writes the data to the provided io.Writer in
+// sequential order.
+//
+// streamWriterAt can also be wrapped with an io.OffsetWriter to
+// provide an asynchronous buffer: the caller can use the io.Writer
+// interface to write into a memory buffer and return without waiting
+// for the wrapped writer to catch up.
+//
+// Close returns when all data has been written through.
+type streamWriterAt struct {
+       writer     io.Writer
+       buf        []byte
+       writepos   int         // target offset if Write is called
+       partsize   int         // size of each part written through to writer
+       endpos     int         // portion of buf actually used, judging by WriteAt calls so far
+       partfilled []int       // number of bytes written to each part so far
+       partready  chan []byte // parts of buf fully written / waiting for writer goroutine
+       partnext   int         // index of next part we will send to partready when it's ready
+       wroteAt    int         // bytes we copied to buf in WriteAt
+       wrote      int         // bytes successfully written through to writer
+       errWrite   chan error  // final outcome of writer goroutine
+       closed     bool        // streamWriterAt has been closed
+       mtx        sync.Mutex  // guard internal fields during concurrent calls to WriteAt and Close
+}
+
+// newStreamWriterAt creates a new streamWriterAt.
+func newStreamWriterAt(w io.Writer, partsize int, buf []byte) *streamWriterAt {
+       if partsize == 0 {
+               partsize = 65536
+       }
+       nparts := (len(buf) + partsize - 1) / partsize
+       swa := &streamWriterAt{
+               writer:     w,
+               partsize:   partsize,
+               buf:        buf,
+               partfilled: make([]int, nparts),
+               partready:  make(chan []byte, nparts),
+               errWrite:   make(chan error, 1),
+       }
+       go swa.writeToWriter()
+       return swa
+}
+
+// Wrote returns the number of bytes written through to the
+// io.Writer.
+//
+// Wrote must not be called until after Close.
+func (swa *streamWriterAt) Wrote() int {
+       return swa.wrote
+}
+
+// Wrote returns the number of bytes passed to WriteAt, regardless of
+// whether they were written through to the io.Writer.
+func (swa *streamWriterAt) WroteAt() int {
+       swa.mtx.Lock()
+       defer swa.mtx.Unlock()
+       return swa.wroteAt
+}
+
+func (swa *streamWriterAt) writeToWriter() {
+       defer close(swa.errWrite)
+       for p := range swa.partready {
+               n, err := swa.writer.Write(p)
+               if err != nil {
+                       swa.errWrite <- err
+                       return
+               }
+               swa.wrote += n
+       }
+}
+
+// WriteAt implements io.WriterAt. WriteAt is goroutine-safe.
+func (swa *streamWriterAt) WriteAt(p []byte, offset int64) (int, error) {
+       pos := int(offset)
+       n := 0
+       if pos <= len(swa.buf) {
+               n = copy(swa.buf[pos:], p)
+       }
+       if n < len(p) {
+               return n, fmt.Errorf("write beyond end of buffer: offset %d len %d buf %d", offset, len(p), len(swa.buf))
+       }
+       endpos := pos + n
+
+       swa.mtx.Lock()
+       defer swa.mtx.Unlock()
+       swa.wroteAt += len(p)
+       if swa.endpos < endpos {
+               swa.endpos = endpos
+       }
+       if swa.closed {
+               return 0, errors.New("invalid use of closed streamWriterAt")
+       }
+       // Track the number of bytes that landed in each of our
+       // (output) parts.
+       for i := pos; i < endpos; {
+               j := i + swa.partsize - (i % swa.partsize)
+               if j > endpos {
+                       j = endpos
+               }
+               pf := swa.partfilled[i/swa.partsize]
+               pf += j - i
+               if pf > swa.partsize {
+                       return 0, errors.New("streamWriterAt: overlapping WriteAt calls")
+               }
+               swa.partfilled[i/swa.partsize] = pf
+               i = j
+       }
+       // Flush filled parts to partready.
+       for swa.partnext < len(swa.partfilled) && swa.partfilled[swa.partnext] == swa.partsize {
+               offset := swa.partnext * swa.partsize
+               swa.partready <- swa.buf[offset : offset+swa.partsize]
+               swa.partnext++
+       }
+       return len(p), nil
+}
+
+// Close flushes all buffered data through to the io.Writer.
+func (swa *streamWriterAt) Close() error {
+       swa.mtx.Lock()
+       defer swa.mtx.Unlock()
+       if swa.closed {
+               return errors.New("invalid use of closed streamWriterAt")
+       }
+       swa.closed = true
+       // Flush last part if needed. If the input doesn't end on a
+       // part boundary, the last part never appears "filled" when we
+       // check in WriteAt.  But here, we know endpos is the end of
+       // the stream, so we can check whether the last part is ready.
+       if offset := swa.partnext * swa.partsize; offset < swa.endpos && offset+swa.partfilled[swa.partnext] == swa.endpos {
+               swa.partready <- swa.buf[offset:swa.endpos]
+               swa.partnext++
+       }
+       close(swa.partready)
+       err := <-swa.errWrite
+       if err != nil {
+               return err
+       }
+       if swa.wrote != swa.wroteAt {
+               return fmt.Errorf("streamWriterAt: detected hole in input: wrote %d but flushed %d", swa.wroteAt, swa.wrote)
+       }
+       return nil
+}
diff --git a/services/keepstore/streamwriterat_test.go b/services/keepstore/streamwriterat_test.go
new file mode 100644 (file)
index 0000000..fe6837e
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package keepstore
+
+import (
+       "bytes"
+       "sync"
+
+       . "gopkg.in/check.v1"
+)
+
+var _ = Suite(&streamWriterAtSuite{})
+
+type streamWriterAtSuite struct{}
+
+func (s *streamWriterAtSuite) TestPartSizes(c *C) {
+       for partsize := 1; partsize < 5; partsize++ {
+               for writesize := 1; writesize < 5; writesize++ {
+                       for datasize := 1; datasize < 100; datasize += 13 {
+                               for bufextra := 0; bufextra < 5; bufextra++ {
+                                       c.Logf("=== partsize %d writesize %d datasize %d bufextra %d", partsize, writesize, datasize, bufextra)
+                                       outbuf := bytes.NewBuffer(nil)
+                                       indata := make([]byte, datasize)
+                                       for i := range indata {
+                                               indata[i] = byte(i)
+                                       }
+                                       swa := newStreamWriterAt(outbuf, partsize, make([]byte, datasize+bufextra))
+                                       var wg sync.WaitGroup
+                                       for pos := 0; pos < datasize; pos += writesize {
+                                               pos := pos
+                                               wg.Add(1)
+                                               go func() {
+                                                       defer wg.Done()
+                                                       endpos := pos + writesize
+                                                       if endpos > datasize {
+                                                               endpos = datasize
+                                                       }
+                                                       swa.WriteAt(indata[pos:endpos], int64(pos))
+                                               }()
+                                       }
+                                       wg.Wait()
+                                       swa.Close()
+                                       c.Check(outbuf.Bytes(), DeepEquals, indata)
+                               }
+                       }
+               }
+       }
+}
+
+func (s *streamWriterAtSuite) TestOverflow(c *C) {
+       for offset := -1; offset < 2; offset++ {
+               buf := make([]byte, 50)
+               swa := newStreamWriterAt(bytes.NewBuffer(nil), 20, buf)
+               _, err := swa.WriteAt([]byte("foo"), int64(len(buf)+offset))
+               c.Check(err, NotNil)
+               err = swa.Close()
+               c.Check(err, IsNil)
+       }
+}
+
+func (s *streamWriterAtSuite) TestIncompleteWrite(c *C) {
+       for _, partsize := range []int{20, 25} {
+               for _, bufsize := range []int{50, 55, 60} {
+                       for offset := 0; offset < 3; offset++ {
+                               swa := newStreamWriterAt(bytes.NewBuffer(nil), partsize, make([]byte, bufsize))
+                               _, err := swa.WriteAt(make([]byte, 1), 49)
+                               c.Check(err, IsNil)
+                               _, err = swa.WriteAt(make([]byte, 46), int64(offset))
+                               c.Check(err, IsNil)
+                               err = swa.Close()
+                               c.Check(err, NotNil)
+                               c.Check(swa.WroteAt(), Equals, 47)
+                               if offset == 0 {
+                                       c.Check(swa.Wrote(), Equals, 40/partsize*partsize)
+                               } else {
+                                       c.Check(swa.Wrote(), Equals, 0)
+                               }
+                       }
+               }
+       }
+}
index 3909d90d9204d55a80252449abb88e953ace1b24..819c25acc1385d11202256bc73ac7e94ed92ab49 100644 (file)
 package keepstore
 
 import (
-       "errors"
+       "context"
+       "sync"
+       "sync/atomic"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
-       "github.com/sirupsen/logrus"
+       "github.com/prometheus/client_golang/prometheus"
 )
 
-// RunTrashWorker is used by Keepstore to initiate trash worker channel goroutine.
-//     The channel will process trash list.
-//             For each (next) trash request:
-//      Delete the block indicated by the trash request Locator
-//             Repeat
-//
-func RunTrashWorker(volmgr *RRVolumeManager, logger logrus.FieldLogger, cluster *arvados.Cluster, trashq *WorkQueue) {
-       for item := range trashq.NextItem {
-               trashRequest := item.(TrashRequest)
-               TrashItem(volmgr, logger, cluster, trashRequest)
-               trashq.DoneItem <- struct{}{}
-       }
+type TrashListItem struct {
+       Locator    string `json:"locator"`
+       BlockMtime int64  `json:"block_mtime"`
+       MountUUID  string `json:"mount_uuid"` // Target mount, or "" for "everywhere"
+}
+
+type trasher struct {
+       keepstore  *keepstore
+       todo       []TrashListItem
+       cond       *sync.Cond // lock guards todo accesses; cond broadcasts when todo becomes non-empty
+       inprogress atomic.Int64
 }
 
-// TrashItem deletes the indicated block from every writable volume.
-func TrashItem(volmgr *RRVolumeManager, logger logrus.FieldLogger, cluster *arvados.Cluster, trashRequest TrashRequest) {
-       reqMtime := time.Unix(0, trashRequest.BlockMtime)
-       if time.Since(reqMtime) < cluster.Collections.BlobSigningTTL.Duration() {
-               logger.Warnf("client asked to delete a %v old block %v (BlockMtime %d = %v), but my blobSignatureTTL is %v! Skipping.",
-                       arvados.Duration(time.Since(reqMtime)),
-                       trashRequest.Locator,
-                       trashRequest.BlockMtime,
-                       reqMtime,
-                       cluster.Collections.BlobSigningTTL)
-               return
+func newTrasher(ctx context.Context, keepstore *keepstore, reg *prometheus.Registry) *trasher {
+       t := &trasher{
+               keepstore: keepstore,
+               cond:      sync.NewCond(&sync.Mutex{}),
+       }
+       reg.MustRegister(prometheus.NewGaugeFunc(
+               prometheus.GaugeOpts{
+                       Namespace: "arvados",
+                       Subsystem: "keepstore",
+                       Name:      "trash_queue_pending_entries",
+                       Help:      "Number of queued trash requests",
+               },
+               func() float64 {
+                       t.cond.L.Lock()
+                       defer t.cond.L.Unlock()
+                       return float64(len(t.todo))
+               },
+       ))
+       reg.MustRegister(prometheus.NewGaugeFunc(
+               prometheus.GaugeOpts{
+                       Namespace: "arvados",
+                       Subsystem: "keepstore",
+                       Name:      "trash_queue_inprogress_entries",
+                       Help:      "Number of trash requests in progress",
+               },
+               func() float64 {
+                       return float64(t.inprogress.Load())
+               },
+       ))
+       if !keepstore.cluster.Collections.BlobTrash {
+               keepstore.logger.Info("not running trash worker because Collections.BlobTrash == false")
+               return t
        }
 
-       var volumes []*VolumeMount
-       if uuid := trashRequest.MountUUID; uuid == "" {
-               volumes = volmgr.AllWritable()
-       } else if mnt := volmgr.Lookup(uuid, true); mnt == nil {
-               logger.Warnf("trash request for nonexistent mount: %v", trashRequest)
-               return
+       var mntsAllowTrash []*mount
+       for _, mnt := range t.keepstore.mounts {
+               if mnt.AllowTrash {
+                       mntsAllowTrash = append(mntsAllowTrash, mnt)
+               }
+       }
+       if len(mntsAllowTrash) == 0 {
+               t.keepstore.logger.Info("not running trash worker because there are no writable or trashable volumes")
        } else {
-               volumes = []*VolumeMount{mnt}
+               for i := 0; i < keepstore.cluster.Collections.BlobTrashConcurrency; i++ {
+                       go t.runWorker(ctx, mntsAllowTrash)
+               }
        }
+       return t
+}
+
+func (t *trasher) SetTrashList(newlist []TrashListItem) {
+       t.cond.L.Lock()
+       t.todo = newlist
+       t.cond.L.Unlock()
+       t.cond.Broadcast()
+}
 
-       for _, volume := range volumes {
-               mtime, err := volume.Mtime(trashRequest.Locator)
-               if err != nil {
-                       logger.WithError(err).Errorf("%v Trash(%v)", volume, trashRequest.Locator)
-                       continue
+func (t *trasher) runWorker(ctx context.Context, mntsAllowTrash []*mount) {
+       go func() {
+               <-ctx.Done()
+               t.cond.Broadcast()
+       }()
+       for {
+               t.cond.L.Lock()
+               for len(t.todo) == 0 && ctx.Err() == nil {
+                       t.cond.Wait()
                }
-               if trashRequest.BlockMtime != mtime.UnixNano() {
-                       logger.Infof("%v Trash(%v): stored mtime %v does not match trash list value %v; skipping", volume, trashRequest.Locator, mtime.UnixNano(), trashRequest.BlockMtime)
-                       continue
+               if ctx.Err() != nil {
+                       t.cond.L.Unlock()
+                       return
                }
+               item := t.todo[0]
+               t.todo = t.todo[1:]
+               t.inprogress.Add(1)
+               t.cond.L.Unlock()
 
-               if !cluster.Collections.BlobTrash {
-                       err = errors.New("skipping because Collections.BlobTrash is false")
-               } else {
-                       err = volume.Trash(trashRequest.Locator)
-               }
+               func() {
+                       defer t.inprogress.Add(-1)
+                       logger := t.keepstore.logger.WithField("locator", item.Locator)
 
-               if err != nil {
-                       logger.WithError(err).Errorf("%v Trash(%v)", volume, trashRequest.Locator)
-               } else {
-                       logger.Infof("%v Trash(%v) OK", volume, trashRequest.Locator)
-               }
+                       li, err := getLocatorInfo(item.Locator)
+                       if err != nil {
+                               logger.Warn("ignoring trash request for invalid locator")
+                               return
+                       }
+
+                       reqMtime := time.Unix(0, item.BlockMtime)
+                       if time.Since(reqMtime) < t.keepstore.cluster.Collections.BlobSigningTTL.Duration() {
+                               logger.Warnf("client asked to delete a %v old block (BlockMtime %d = %v), but my blobSignatureTTL is %v! Skipping.",
+                                       arvados.Duration(time.Since(reqMtime)),
+                                       item.BlockMtime,
+                                       reqMtime,
+                                       t.keepstore.cluster.Collections.BlobSigningTTL)
+                               return
+                       }
+
+                       var mnts []*mount
+                       if item.MountUUID == "" {
+                               mnts = mntsAllowTrash
+                       } else if mnt := t.keepstore.mounts[item.MountUUID]; mnt == nil {
+                               logger.Warnf("ignoring trash request for nonexistent mount %s", item.MountUUID)
+                               return
+                       } else if !mnt.AllowTrash {
+                               logger.Warnf("ignoring trash request for readonly mount %s with AllowTrashWhenReadOnly==false", item.MountUUID)
+                               return
+                       } else {
+                               mnts = []*mount{mnt}
+                       }
+
+                       for _, mnt := range mnts {
+                               logger := logger.WithField("mount", mnt.UUID)
+                               mtime, err := mnt.Mtime(li.hash)
+                               if err != nil {
+                                       logger.WithError(err).Error("error getting stored mtime")
+                                       continue
+                               }
+                               if !mtime.Equal(reqMtime) {
+                                       logger.Infof("stored mtime (%v) does not match trash list mtime (%v); skipping", mtime, reqMtime)
+                                       continue
+                               }
+                               err = mnt.BlockTrash(li.hash)
+                               if err != nil {
+                                       logger.WithError(err).Info("error trashing block")
+                                       continue
+                               }
+                               logger.Info("block trashed")
+                       }
+               }()
        }
 }
+
+type trashEmptier struct{}
+
+func newTrashEmptier(ctx context.Context, ks *keepstore, reg *prometheus.Registry) *trashEmptier {
+       d := ks.cluster.Collections.BlobTrashCheckInterval.Duration()
+       if d <= 0 ||
+               !ks.cluster.Collections.BlobTrash ||
+               ks.cluster.Collections.BlobDeleteConcurrency <= 0 {
+               ks.logger.Infof("not running trash emptier because disabled by config (enabled=%t, interval=%v, concurrency=%d)", ks.cluster.Collections.BlobTrash, d, ks.cluster.Collections.BlobDeleteConcurrency)
+               return &trashEmptier{}
+       }
+       go func() {
+               ticker := time.NewTicker(d)
+               for {
+                       select {
+                       case <-ctx.Done():
+                               return
+                       case <-ticker.C:
+                       }
+                       for _, mnt := range ks.mounts {
+                               if mnt.KeepMount.AllowTrash {
+                                       mnt.volume.EmptyTrash()
+                               }
+                       }
+               }
+       }()
+       return &trashEmptier{}
+}
index 4e20c3feb451f1f3043008ae69813ee38a9bcf14..0c304dbadec5498d8f736bb83cfeab88cbda6de4 100644 (file)
 package keepstore
 
 import (
-       "container/list"
        "context"
+       "crypto/md5"
+       "encoding/json"
+       "fmt"
+       "net/http"
+       "sort"
        "time"
 
-       "git.arvados.org/arvados.git/sdk/go/ctxlog"
-       "github.com/prometheus/client_golang/prometheus"
-       check "gopkg.in/check.v1"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       . "gopkg.in/check.v1"
 )
 
-type TrashWorkerTestData struct {
-       Locator1    string
-       Block1      []byte
-       BlockMtime1 int64
-
-       Locator2    string
-       Block2      []byte
-       BlockMtime2 int64
-
-       CreateData      bool
-       CreateInVolume1 bool
-
-       UseTrashLifeTime bool
-       DifferentMtimes  bool
-
-       DeleteLocator    string
-       SpecifyMountUUID bool
-
-       ExpectLocator1 bool
-       ExpectLocator2 bool
-}
-
-/* Delete block that does not exist in any of the keep volumes.
-   Expect no errors.
-*/
-func (s *HandlerSuite) TestTrashWorkerIntegration_GetNonExistingLocator(c *check.C) {
-       s.cluster.Collections.BlobTrash = true
-       testData := TrashWorkerTestData{
-               Locator1: "5d41402abc4b2a76b9719d911017c592",
-               Block1:   []byte("hello"),
-
-               Locator2: "5d41402abc4b2a76b9719d911017c592",
-               Block2:   []byte("hello"),
-
-               CreateData: false,
-
-               DeleteLocator: "5d41402abc4b2a76b9719d911017c592",
-
-               ExpectLocator1: false,
-               ExpectLocator2: false,
-       }
-       s.performTrashWorkerTest(c, testData)
-}
-
-/* Delete a block that exists on volume 1 of the keep servers.
-   Expect the second locator in volume 2 to be unaffected.
-*/
-func (s *HandlerSuite) TestTrashWorkerIntegration_LocatorInVolume1(c *check.C) {
-       s.cluster.Collections.BlobTrash = true
-       testData := TrashWorkerTestData{
-               Locator1: TestHash,
-               Block1:   TestBlock,
-
-               Locator2: TestHash2,
-               Block2:   TestBlock2,
-
-               CreateData: true,
-
-               DeleteLocator: TestHash, // first locator
-
-               ExpectLocator1: false,
-               ExpectLocator2: true,
-       }
-       s.performTrashWorkerTest(c, testData)
-}
-
-/* Delete a block that exists on volume 2 of the keep servers.
-   Expect the first locator in volume 1 to be unaffected.
-*/
-func (s *HandlerSuite) TestTrashWorkerIntegration_LocatorInVolume2(c *check.C) {
-       s.cluster.Collections.BlobTrash = true
-       testData := TrashWorkerTestData{
-               Locator1: TestHash,
-               Block1:   TestBlock,
-
-               Locator2: TestHash2,
-               Block2:   TestBlock2,
-
-               CreateData: true,
-
-               DeleteLocator: TestHash2, // locator 2
-
-               ExpectLocator1: true,
-               ExpectLocator2: false,
-       }
-       s.performTrashWorkerTest(c, testData)
-}
-
-/* Delete a block with matching mtime for locator in both volumes.
-   Expect locator to be deleted from both volumes.
-*/
-func (s *HandlerSuite) TestTrashWorkerIntegration_LocatorInBothVolumes(c *check.C) {
-       s.cluster.Collections.BlobTrash = true
-       testData := TrashWorkerTestData{
-               Locator1: TestHash,
-               Block1:   TestBlock,
-
-               Locator2: TestHash,
-               Block2:   TestBlock,
-
-               CreateData: true,
-
-               DeleteLocator: TestHash,
-
-               ExpectLocator1: false,
-               ExpectLocator2: false,
-       }
-       s.performTrashWorkerTest(c, testData)
-}
-
-/* Same locator with different Mtimes exists in both volumes.
-   Delete the second and expect the first to be still around.
-*/
-func (s *HandlerSuite) TestTrashWorkerIntegration_MtimeMatchesForLocator1ButNotForLocator2(c *check.C) {
-       s.cluster.Collections.BlobTrash = true
-       testData := TrashWorkerTestData{
-               Locator1: TestHash,
-               Block1:   TestBlock,
-
-               Locator2: TestHash,
-               Block2:   TestBlock,
-
-               CreateData:      true,
-               DifferentMtimes: true,
-
-               DeleteLocator: TestHash,
-
-               ExpectLocator1: true,
-               ExpectLocator2: false,
-       }
-       s.performTrashWorkerTest(c, testData)
-}
-
-// Delete a block that exists on both volumes with matching mtimes,
-// but specify a MountUUID in the request so it only gets deleted from
-// the first volume.
-func (s *HandlerSuite) TestTrashWorkerIntegration_SpecifyMountUUID(c *check.C) {
-       s.cluster.Collections.BlobTrash = true
-       testData := TrashWorkerTestData{
-               Locator1: TestHash,
-               Block1:   TestBlock,
-
-               Locator2: TestHash,
-               Block2:   TestBlock,
-
-               CreateData: true,
-
-               DeleteLocator:    TestHash,
-               SpecifyMountUUID: true,
-
-               ExpectLocator1: true,
-               ExpectLocator2: true,
-       }
-       s.performTrashWorkerTest(c, testData)
-}
-
-/* Two different locators in volume 1.
-   Delete one of them.
-   Expect the other unaffected.
-*/
-func (s *HandlerSuite) TestTrashWorkerIntegration_TwoDifferentLocatorsInVolume1(c *check.C) {
-       s.cluster.Collections.BlobTrash = true
-       testData := TrashWorkerTestData{
-               Locator1: TestHash,
-               Block1:   TestBlock,
-
-               Locator2: TestHash2,
-               Block2:   TestBlock2,
-
-               CreateData:      true,
-               CreateInVolume1: true,
-
-               DeleteLocator: TestHash, // locator 1
-
-               ExpectLocator1: false,
-               ExpectLocator2: true,
-       }
-       s.performTrashWorkerTest(c, testData)
-}
-
-/* Allow default Trash Life time to be used. Thus, the newly created block
-   will not be deleted because its Mtime is within the trash life time.
-*/
-func (s *HandlerSuite) TestTrashWorkerIntegration_SameLocatorInTwoVolumesWithDefaultTrashLifeTime(c *check.C) {
-       s.cluster.Collections.BlobTrash = true
-       testData := TrashWorkerTestData{
-               Locator1: TestHash,
-               Block1:   TestBlock,
-
-               Locator2: TestHash2,
-               Block2:   TestBlock2,
-
-               CreateData:      true,
-               CreateInVolume1: true,
-
-               UseTrashLifeTime: true,
-
-               DeleteLocator: TestHash, // locator 1
-
-               // Since trash life time is in effect, block won't be deleted.
-               ExpectLocator1: true,
-               ExpectLocator2: true,
-       }
-       s.performTrashWorkerTest(c, testData)
-}
-
-/* Delete a block with matching mtime for locator in both volumes, but EnableDelete is false,
-   so block won't be deleted.
-*/
-func (s *HandlerSuite) TestTrashWorkerIntegration_DisabledDelete(c *check.C) {
+func (s *routerSuite) TestTrashList_Clear(c *C) {
        s.cluster.Collections.BlobTrash = false
-       testData := TrashWorkerTestData{
-               Locator1: TestHash,
-               Block1:   TestBlock,
-
-               Locator2: TestHash,
-               Block2:   TestBlock,
-
-               CreateData: true,
-
-               DeleteLocator: TestHash,
-
-               ExpectLocator1: true,
-               ExpectLocator2: true,
-       }
-       s.performTrashWorkerTest(c, testData)
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       resp := call(router, "PUT", "http://example/trash", s.cluster.SystemRootToken, []byte(`
+               [
+                {
+                 "locator":"acbd18db4cc2f85cedef654fccc4a4d8+3",
+                 "block_mtime":1707249451308502672,
+                 "mount_uuid":"zzzzz-nyw5e-000000000000000"
+                }
+               ]
+               `), nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(router.trasher.todo, DeepEquals, []TrashListItem{{
+               Locator:    "acbd18db4cc2f85cedef654fccc4a4d8+3",
+               BlockMtime: 1707249451308502672,
+               MountUUID:  "zzzzz-nyw5e-000000000000000",
+       }})
+
+       resp = call(router, "PUT", "http://example/trash", s.cluster.SystemRootToken, []byte("[]"), nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       c.Check(router.trasher.todo, HasLen, 0)
 }
 
-/* Perform the test */
-func (s *HandlerSuite) performTrashWorkerTest(c *check.C, testData TrashWorkerTestData) {
-       c.Assert(s.handler.setup(context.Background(), s.cluster, "", prometheus.NewRegistry(), testServiceURL), check.IsNil)
-       // Replace the router's trashq -- which the worker goroutines
-       // started by setup() are now receiving from -- with a new
-       // one, so we can see what the handler sends to it.
-       trashq := NewWorkQueue()
-       s.handler.Handler.(*router).trashq = trashq
-
-       // Put test content
-       mounts := s.handler.volmgr.AllWritable()
-       if testData.CreateData {
-               mounts[0].Put(context.Background(), testData.Locator1, testData.Block1)
-               mounts[0].Put(context.Background(), testData.Locator1+".meta", []byte("metadata"))
-
-               if testData.CreateInVolume1 {
-                       mounts[0].Put(context.Background(), testData.Locator2, testData.Block2)
-                       mounts[0].Put(context.Background(), testData.Locator2+".meta", []byte("metadata"))
-               } else {
-                       mounts[1].Put(context.Background(), testData.Locator2, testData.Block2)
-                       mounts[1].Put(context.Background(), testData.Locator2+".meta", []byte("metadata"))
-               }
-       }
-
-       oldBlockTime := time.Now().Add(-s.cluster.Collections.BlobSigningTTL.Duration() - time.Minute)
-
-       // Create TrashRequest for the test
-       trashRequest := TrashRequest{
-               Locator:    testData.DeleteLocator,
-               BlockMtime: oldBlockTime.UnixNano(),
-       }
-       if testData.SpecifyMountUUID {
-               trashRequest.MountUUID = s.handler.volmgr.Mounts()[0].UUID
-       }
-
-       // Run trash worker and put the trashRequest on trashq
-       trashList := list.New()
-       trashList.PushBack(trashRequest)
-
-       if !testData.UseTrashLifeTime {
-               // Trash worker would not delete block if its Mtime is
-               // within trash life time. Back-date the block to
-               // allow the deletion to succeed.
-               for _, mnt := range mounts {
-                       mnt.Volume.(*MockVolume).Timestamps[testData.DeleteLocator] = oldBlockTime
-                       if testData.DifferentMtimes {
-                               oldBlockTime = oldBlockTime.Add(time.Second)
+func (s *routerSuite) TestTrashList_Execute(c *C) {
+       s.cluster.Collections.BlobTrashConcurrency = 1
+       s.cluster.Volumes = map[string]arvados.Volume{
+               "zzzzz-nyw5e-000000000000000": {Replication: 1, Driver: "stub"},
+               "zzzzz-nyw5e-111111111111111": {Replication: 1, Driver: "stub"},
+               "zzzzz-nyw5e-222222222222222": {Replication: 1, Driver: "stub", ReadOnly: true},
+               "zzzzz-nyw5e-333333333333333": {Replication: 1, Driver: "stub", ReadOnly: true, AllowTrashWhenReadOnly: true},
+       }
+       router, cancel := testRouter(c, s.cluster, nil)
+       defer cancel()
+
+       var mounts []struct {
+               UUID     string
+               DeviceID string `json:"device_id"`
+       }
+       resp := call(router, "GET", "http://example/mounts", s.cluster.SystemRootToken, nil, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+       err := json.Unmarshal(resp.Body.Bytes(), &mounts)
+       c.Assert(err, IsNil)
+       c.Assert(mounts, HasLen, 4)
+
+       // Sort mounts by UUID
+       sort.Slice(mounts, func(i, j int) bool {
+               return mounts[i].UUID < mounts[j].UUID
+       })
+
+       // Make vols (stub volumes) in same order as mounts
+       var vols []*stubVolume
+       for _, mount := range mounts {
+               vols = append(vols, router.keepstore.mounts[mount.UUID].volume.(*stubVolume))
+       }
+
+       // The "trial" loop below will construct the trashList which
+       // we'll send to trasher via router, plus a slice of checks
+       // which we'll run after the trasher has finished executing
+       // the list.
+       var trashList []TrashListItem
+       var checks []func()
+
+       tNew := time.Now().Add(-s.cluster.Collections.BlobSigningTTL.Duration() / 2)
+       tOld := time.Now().Add(-s.cluster.Collections.BlobSigningTTL.Duration() - time.Second)
+
+       for _, trial := range []struct {
+               comment        string
+               storeMtime     []time.Time
+               trashListItems []TrashListItem
+               expectData     []bool
+       }{
+               {
+                       comment:    "timestamp matches, but is not old enough to trash => skip",
+                       storeMtime: []time.Time{tNew},
+                       trashListItems: []TrashListItem{
+                               {
+                                       BlockMtime: tNew.UnixNano(),
+                                       MountUUID:  mounts[0].UUID,
+                               },
+                       },
+                       expectData: []bool{true},
+               },
+               {
+                       comment:    "timestamp matches, and is old enough => trash",
+                       storeMtime: []time.Time{tOld},
+                       trashListItems: []TrashListItem{
+                               {
+                                       BlockMtime: tOld.UnixNano(),
+                                       MountUUID:  mounts[0].UUID,
+                               },
+                       },
+                       expectData: []bool{false},
+               },
+               {
+                       comment:    "timestamp matches and is old enough on mount 0, but the request specifies mount 1, where timestamp does not match => skip",
+                       storeMtime: []time.Time{tOld, tOld.Add(-time.Second)},
+                       trashListItems: []TrashListItem{
+                               {
+                                       BlockMtime: tOld.UnixNano(),
+                                       MountUUID:  mounts[1].UUID,
+                               },
+                       },
+                       expectData: []bool{true, true},
+               },
+               {
+                       comment:    "MountUUID unspecified => trash from any mount where timestamp matches, leave alone elsewhere",
+                       storeMtime: []time.Time{tOld, tOld.Add(-time.Second)},
+                       trashListItems: []TrashListItem{
+                               {
+                                       BlockMtime: tOld.UnixNano(),
+                               },
+                       },
+                       expectData: []bool{false, true},
+               },
+               {
+                       comment:    "MountUUID unspecified => trash from multiple mounts if timestamp matches, but skip readonly volumes unless AllowTrashWhenReadOnly",
+                       storeMtime: []time.Time{tOld, tOld, tOld, tOld},
+                       trashListItems: []TrashListItem{
+                               {
+                                       BlockMtime: tOld.UnixNano(),
+                               },
+                       },
+                       expectData: []bool{false, false, true, false},
+               },
+               {
+                       comment:    "readonly MountUUID specified => skip",
+                       storeMtime: []time.Time{tOld, tOld, tOld},
+                       trashListItems: []TrashListItem{
+                               {
+                                       BlockMtime: tOld.UnixNano(),
+                                       MountUUID:  mounts[2].UUID,
+                               },
+                       },
+                       expectData: []bool{true, true, true},
+               },
+       } {
+               trial := trial
+               data := []byte(fmt.Sprintf("trial %+v", trial))
+               hash := fmt.Sprintf("%x", md5.Sum(data))
+               for i, t := range trial.storeMtime {
+                       if t.IsZero() {
+                               continue
                        }
+                       err := vols[i].BlockWrite(context.Background(), hash, data)
+                       c.Assert(err, IsNil)
+                       err = vols[i].blockTouchWithTime(hash, t)
+                       c.Assert(err, IsNil)
                }
-       }
-       go RunTrashWorker(s.handler.volmgr, ctxlog.TestLogger(c), s.cluster, trashq)
-
-       // Install gate so all local operations block until we say go
-       gate := make(chan struct{})
-       for _, mnt := range mounts {
-               mnt.Volume.(*MockVolume).Gate = gate
-       }
-
-       assertStatusItem := func(k string, expect float64) {
-               if v := getStatusItem(s.handler, "TrashQueue", k); v != expect {
-                       c.Errorf("Got %s %v, expected %v", k, v, expect)
-               }
-       }
-
-       assertStatusItem("InProgress", 0)
-       assertStatusItem("Queued", 0)
-
-       listLen := trashList.Len()
-       trashq.ReplaceQueue(trashList)
-
-       // Wait for worker to take request(s)
-       expectEqualWithin(c, time.Second, listLen, func() interface{} { return trashq.Status().InProgress })
-
-       // Ensure status.json also reports work is happening
-       assertStatusItem("InProgress", float64(1))
-       assertStatusItem("Queued", float64(listLen-1))
-
-       // Let worker proceed
-       close(gate)
-
-       // Wait for worker to finish
-       expectEqualWithin(c, time.Second, 0, func() interface{} { return trashq.Status().InProgress })
-
-       // Verify Locator1 to be un/deleted as expected
-       buf := make([]byte, BlockSize)
-       size, err := GetBlock(context.Background(), s.handler.volmgr, testData.Locator1, buf, nil)
-       if testData.ExpectLocator1 {
-               if size == 0 || err != nil {
-                       c.Errorf("Expected Locator1 to be still present: %s", testData.Locator1)
+               for _, item := range trial.trashListItems {
+                       item.Locator = fmt.Sprintf("%s+%d", hash, len(data))
+                       trashList = append(trashList, item)
                }
-       } else {
-               if size > 0 || err == nil {
-                       c.Errorf("Expected Locator1 to be deleted: %s", testData.Locator1)
+               for i, expect := range trial.expectData {
+                       i, expect := i, expect
+                       checks = append(checks, func() {
+                               ent := vols[i].data[hash]
+                               dataPresent := ent.data != nil && ent.trash.IsZero()
+                               c.Check(dataPresent, Equals, expect, Commentf("%s mount %d (%s) expect present=%v but got len(ent.data)=%d ent.trash=%v // %s\nlog:\n%s", hash, i, vols[i].params.UUID, expect, len(ent.data), !ent.trash.IsZero(), trial.comment, vols[i].stubLog.String()))
+                       })
                }
        }
 
-       // Verify Locator2 to be un/deleted as expected
-       if testData.Locator1 != testData.Locator2 {
-               size, err = GetBlock(context.Background(), s.handler.volmgr, testData.Locator2, buf, nil)
-               if testData.ExpectLocator2 {
-                       if size == 0 || err != nil {
-                               c.Errorf("Expected Locator2 to be still present: %s", testData.Locator2)
-                       }
-               } else {
-                       if size > 0 || err == nil {
-                               c.Errorf("Expected Locator2 to be deleted: %s", testData.Locator2)
-                       }
+       listjson, err := json.Marshal(trashList)
+       resp = call(router, "PUT", "http://example/trash", s.cluster.SystemRootToken, listjson, nil)
+       c.Check(resp.Code, Equals, http.StatusOK)
+
+       for {
+               router.trasher.cond.L.Lock()
+               todolen := len(router.trasher.todo)
+               router.trasher.cond.L.Unlock()
+               if todolen == 0 && router.trasher.inprogress.Load() == 0 {
+                       break
                }
+               time.Sleep(time.Millisecond)
        }
 
-       // The DifferentMtimes test puts the same locator in two
-       // different volumes, but only one copy has an Mtime matching
-       // the trash request.
-       if testData.DifferentMtimes {
-               locatorFoundIn := 0
-               for _, volume := range s.handler.volmgr.AllReadable() {
-                       buf := make([]byte, BlockSize)
-                       if _, err := volume.Get(context.Background(), testData.Locator1, buf); err == nil {
-                               locatorFoundIn = locatorFoundIn + 1
-                       }
-               }
-               c.Check(locatorFoundIn, check.Equals, 1)
+       for _, check := range checks {
+               check()
        }
 }
index dd62cf1319318fb4f5dbe52869311bb55b6b8008..92cf12ac189803d4f72f120708aced520a252c7f 100644 (file)
@@ -28,20 +28,27 @@ import (
 )
 
 func init() {
-       driver["Directory"] = newDirectoryVolume
+       driver["Directory"] = newUnixVolume
 }
 
-func newDirectoryVolume(cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) (Volume, error) {
-       v := &UnixVolume{cluster: cluster, volume: volume, logger: logger, metrics: metrics}
-       err := json.Unmarshal(volume.DriverParameters, &v)
+func newUnixVolume(params newVolumeParams) (volume, error) {
+       v := &unixVolume{
+               uuid:       params.UUID,
+               cluster:    params.Cluster,
+               volume:     params.ConfigVolume,
+               logger:     params.Logger,
+               metrics:    params.MetricsVecs,
+               bufferPool: params.BufferPool,
+       }
+       err := json.Unmarshal(params.ConfigVolume.DriverParameters, &v)
        if err != nil {
                return nil, err
        }
-       v.logger = v.logger.WithField("Volume", v.String())
+       v.logger = v.logger.WithField("Volume", v.DeviceID())
        return v, v.check()
 }
 
-func (v *UnixVolume) check() error {
+func (v *unixVolume) check() error {
        if v.Root == "" {
                return errors.New("DriverParameters.Root was not provided")
        }
@@ -53,22 +60,24 @@ func (v *UnixVolume) check() error {
        }
 
        // Set up prometheus metrics
-       lbls := prometheus.Labels{"device_id": v.GetDeviceID()}
+       lbls := prometheus.Labels{"device_id": v.DeviceID()}
        v.os.stats.opsCounters, v.os.stats.errCounters, v.os.stats.ioBytes = v.metrics.getCounterVecsFor(lbls)
 
        _, err := v.os.Stat(v.Root)
        return err
 }
 
-// A UnixVolume stores and retrieves blocks in a local directory.
-type UnixVolume struct {
+// A unixVolume stores and retrieves blocks in a local directory.
+type unixVolume struct {
        Root      string // path to the volume's root directory
        Serialize bool
 
-       cluster *arvados.Cluster
-       volume  arvados.Volume
-       logger  logrus.FieldLogger
-       metrics *volumeMetricsVecs
+       uuid       string
+       cluster    *arvados.Cluster
+       volume     arvados.Volume
+       logger     logrus.FieldLogger
+       metrics    *volumeMetricsVecs
+       bufferPool *bufferPool
 
        // something to lock during IO, typically a sync.Mutex (or nil
        // to skip locking)
@@ -77,15 +86,16 @@ type UnixVolume struct {
        os osWithStats
 }
 
-// GetDeviceID returns a globally unique ID for the volume's root
+// DeviceID returns a globally unique ID for the volume's root
 // directory, consisting of the filesystem's UUID and the path from
 // filesystem root to storage directory, joined by "/". For example,
 // the device ID for a local directory "/mnt/xvda1/keep" might be
 // "fa0b6166-3b55-4994-bd3f-92f4e00a1bb0/keep".
-func (v *UnixVolume) GetDeviceID() string {
+func (v *unixVolume) DeviceID() string {
        giveup := func(f string, args ...interface{}) string {
-               v.logger.Infof(f+"; using blank DeviceID for volume %s", append(args, v)...)
-               return ""
+               v.logger.Infof(f+"; using hostname:path for volume %s", append(args, v.uuid)...)
+               host, _ := os.Hostname()
+               return host + ":" + v.Root
        }
        buf, err := exec.Command("findmnt", "--noheadings", "--target", v.Root).CombinedOutput()
        if err != nil {
@@ -154,12 +164,9 @@ func (v *UnixVolume) GetDeviceID() string {
        return giveup("could not find entry in %q matching %q", udir, dev)
 }
 
-// Touch sets the timestamp for the given locator to the current time
-func (v *UnixVolume) Touch(loc string) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       p := v.blockPath(loc)
+// BlockTouch sets the timestamp for the given locator to the current time
+func (v *unixVolume) BlockTouch(hash string) error {
+       p := v.blockPath(hash)
        f, err := v.os.OpenFile(p, os.O_RDWR|os.O_APPEND, 0644)
        if err != nil {
                return err
@@ -182,7 +189,7 @@ func (v *UnixVolume) Touch(loc string) error {
 }
 
 // Mtime returns the stored timestamp for the given locator.
-func (v *UnixVolume) Mtime(loc string) (time.Time, error) {
+func (v *unixVolume) Mtime(loc string) (time.Time, error) {
        p := v.blockPath(loc)
        fi, err := v.os.Stat(p)
        if err != nil {
@@ -191,94 +198,59 @@ func (v *UnixVolume) Mtime(loc string) (time.Time, error) {
        return fi.ModTime(), nil
 }
 
-// Lock the locker (if one is in use), open the file for reading, and
-// call the given function if and when the file is ready to read.
-func (v *UnixVolume) getFunc(ctx context.Context, path string, fn func(io.Reader) error) error {
-       if err := v.lock(ctx); err != nil {
-               return err
-       }
-       defer v.unlock()
-       f, err := v.os.Open(path)
-       if err != nil {
-               return err
-       }
-       defer f.Close()
-       return fn(NewCountingReader(ioutil.NopCloser(f), v.os.stats.TickInBytes))
-}
-
 // stat is os.Stat() with some extra sanity checks.
-func (v *UnixVolume) stat(path string) (os.FileInfo, error) {
+func (v *unixVolume) stat(path string) (os.FileInfo, error) {
        stat, err := v.os.Stat(path)
        if err == nil {
                if stat.Size() < 0 {
                        err = os.ErrInvalid
                } else if stat.Size() > BlockSize {
-                       err = TooLongError
+                       err = errTooLarge
                }
        }
        return stat, err
 }
 
-// Get retrieves a block, copies it to the given slice, and returns
-// the number of bytes copied.
-func (v *UnixVolume) Get(ctx context.Context, loc string, buf []byte) (int, error) {
-       return getWithPipe(ctx, loc, buf, v)
-}
-
-// ReadBlock implements BlockReader.
-func (v *UnixVolume) ReadBlock(ctx context.Context, loc string, w io.Writer) error {
-       path := v.blockPath(loc)
+// BlockRead reads a block from the volume.
+func (v *unixVolume) BlockRead(ctx context.Context, hash string, w io.WriterAt) error {
+       path := v.blockPath(hash)
        stat, err := v.stat(path)
        if err != nil {
                return v.translateError(err)
        }
-       return v.getFunc(ctx, path, func(rdr io.Reader) error {
-               n, err := io.Copy(w, rdr)
-               if err == nil && n != stat.Size() {
-                       err = io.ErrUnexpectedEOF
-               }
+       if err := v.lock(ctx); err != nil {
                return err
-       })
-}
-
-// Compare returns nil if Get(loc) would return the same content as
-// expect. It is functionally equivalent to Get() followed by
-// bytes.Compare(), but uses less memory.
-func (v *UnixVolume) Compare(ctx context.Context, loc string, expect []byte) error {
-       path := v.blockPath(loc)
-       if _, err := v.stat(path); err != nil {
-               return v.translateError(err)
        }
-       return v.getFunc(ctx, path, func(rdr io.Reader) error {
-               return compareReaderWithBuf(ctx, rdr, expect, loc[:32])
-       })
-}
-
-// Put stores a block of data identified by the locator string
-// "loc".  It returns nil on success.  If the volume is full, it
-// returns a FullError.  If the write fails due to some other error,
-// that error is returned.
-func (v *UnixVolume) Put(ctx context.Context, loc string, block []byte) error {
-       return putWithPipe(ctx, loc, block, v)
+       defer v.unlock()
+       f, err := v.os.Open(path)
+       if err != nil {
+               return err
+       }
+       defer f.Close()
+       src := newCountingReader(ioutil.NopCloser(f), v.os.stats.TickInBytes)
+       dst := io.NewOffsetWriter(w, 0)
+       n, err := io.Copy(dst, src)
+       if err == nil && n != stat.Size() {
+               err = io.ErrUnexpectedEOF
+       }
+       return err
 }
 
-// WriteBlock implements BlockWriter.
-func (v *UnixVolume) WriteBlock(ctx context.Context, loc string, rdr io.Reader) error {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
+// BlockWrite stores a block on the volume. If it already exists, its
+// timestamp is updated.
+func (v *unixVolume) BlockWrite(ctx context.Context, hash string, data []byte) error {
+       if v.isFull() {
+               return errFull
        }
-       if v.IsFull() {
-               return FullError
-       }
-       bdir := v.blockDir(loc)
+       bdir := v.blockDir(hash)
        if err := os.MkdirAll(bdir, 0755); err != nil {
                return fmt.Errorf("error creating directory %s: %s", bdir, err)
        }
 
-       bpath := v.blockPath(loc)
-       tmpfile, err := v.os.TempFile(bdir, "tmp"+loc)
+       bpath := v.blockPath(hash)
+       tmpfile, err := v.os.TempFile(bdir, "tmp"+hash)
        if err != nil {
-               return fmt.Errorf("TempFile(%s, tmp%s) failed: %s", bdir, loc, err)
+               return fmt.Errorf("TempFile(%s, tmp%s) failed: %s", bdir, hash, err)
        }
        defer v.os.Remove(tmpfile.Name())
        defer tmpfile.Close()
@@ -287,7 +259,7 @@ func (v *UnixVolume) WriteBlock(ctx context.Context, loc string, rdr io.Reader)
                return err
        }
        defer v.unlock()
-       n, err := io.Copy(tmpfile, rdr)
+       n, err := tmpfile.Write(data)
        v.os.stats.TickOutBytes(uint64(n))
        if err != nil {
                return fmt.Errorf("error writing %s: %s", bpath, err)
@@ -312,58 +284,10 @@ func (v *UnixVolume) WriteBlock(ctx context.Context, loc string, rdr io.Reader)
        return nil
 }
 
-// Status returns a VolumeStatus struct describing the volume's
-// current state, or nil if an error occurs.
-//
-func (v *UnixVolume) Status() *VolumeStatus {
-       fi, err := v.os.Stat(v.Root)
-       if err != nil {
-               v.logger.WithError(err).Error("stat failed")
-               return nil
-       }
-       // uint64() cast here supports GOOS=darwin where Dev is
-       // int32. If the device number is negative, the unsigned
-       // devnum won't be the real device number any more, but that's
-       // fine -- all we care about is getting the same number each
-       // time.
-       devnum := uint64(fi.Sys().(*syscall.Stat_t).Dev)
-
-       var fs syscall.Statfs_t
-       if err := syscall.Statfs(v.Root, &fs); err != nil {
-               v.logger.WithError(err).Error("statfs failed")
-               return nil
-       }
-       // These calculations match the way df calculates disk usage:
-       // "free" space is measured by fs.Bavail, but "used" space
-       // uses fs.Blocks - fs.Bfree.
-       free := fs.Bavail * uint64(fs.Bsize)
-       used := (fs.Blocks - fs.Bfree) * uint64(fs.Bsize)
-       return &VolumeStatus{
-               MountPoint: v.Root,
-               DeviceNum:  devnum,
-               BytesFree:  free,
-               BytesUsed:  used,
-       }
-}
-
 var blockDirRe = regexp.MustCompile(`^[0-9a-f]+$`)
 var blockFileRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
 
-// IndexTo writes (to the given Writer) a list of blocks found on this
-// volume which begin with the specified prefix. If the prefix is an
-// empty string, IndexTo writes a complete list of blocks.
-//
-// Each block is given in the format
-//
-//     locator+size modification-time {newline}
-//
-// e.g.:
-//
-//     e4df392f86be161ca6ed3773a962b8f3+67108864 1388894303
-//     e4d41e6fd68460e0e3fc18cc746959d2+67108864 1377796043
-//     e4de7a2810f5554cd39b36d8ddb132ff+67108864 1388701136
-//
-func (v *UnixVolume) IndexTo(prefix string, w io.Writer) error {
+func (v *unixVolume) Index(ctx context.Context, prefix string, w io.Writer) error {
        rootdir, err := v.os.Open(v.Root)
        if err != nil {
                return err
@@ -376,6 +300,9 @@ func (v *UnixVolume) IndexTo(prefix string, w io.Writer) error {
                return err
        }
        for _, subdir := range subdirs {
+               if ctx.Err() != nil {
+                       return ctx.Err()
+               }
                if !strings.HasPrefix(subdir, prefix) && !strings.HasPrefix(prefix, subdir) {
                        // prefix excludes all blocks stored in this dir
                        continue
@@ -390,7 +317,9 @@ func (v *UnixVolume) IndexTo(prefix string, w io.Writer) error {
                        v.os.stats.TickOps("readdir")
                        v.os.stats.Tick(&v.os.stats.ReaddirOps)
                        dirents, err = os.ReadDir(blockdirpath)
-                       if err == nil {
+                       if ctx.Err() != nil {
+                               return ctx.Err()
+                       } else if err == nil {
                                break
                        } else if attempt < 5 && strings.Contains(err.Error(), "errno 523") {
                                // EBADCOOKIE (NFS stopped accepting
@@ -404,6 +333,9 @@ func (v *UnixVolume) IndexTo(prefix string, w io.Writer) error {
                }
 
                for _, dirent := range dirents {
+                       if ctx.Err() != nil {
+                               return ctx.Err()
+                       }
                        fileInfo, err := dirent.Info()
                        if os.IsNotExist(err) {
                                // File disappeared between ReadDir() and now
@@ -432,11 +364,11 @@ func (v *UnixVolume) IndexTo(prefix string, w io.Writer) error {
        return nil
 }
 
-// Trash trashes the block data from the unix storage
-// If BlobTrashLifetime == 0, the block is deleted
-// Else, the block is renamed as path/{loc}.trash.{deadline},
-// where deadline = now + BlobTrashLifetime
-func (v *UnixVolume) Trash(loc string) error {
+// BlockTrash trashes the block data from the unix storage.  If
+// BlobTrashLifetime == 0, the block is deleted; otherwise, the block
+// is renamed as path/{loc}.trash.{deadline}, where deadline = now +
+// BlobTrashLifetime.
+func (v *unixVolume) BlockTrash(loc string) error {
        // Touch() must be called before calling Write() on a block.  Touch()
        // also uses lockfile().  This avoids a race condition between Write()
        // and Trash() because either (a) the file will be trashed and Touch()
@@ -444,10 +376,6 @@ func (v *UnixVolume) Trash(loc string) error {
        // be re-written), or (b) Touch() will update the file's timestamp and
        // Trash() will read the correct up-to-date timestamp and choose not to
        // trash the file.
-
-       if v.volume.ReadOnly || !v.cluster.Collections.BlobTrash {
-               return MethodDisabledError
-       }
        if err := v.lock(context.TODO()); err != nil {
                return err
        }
@@ -480,17 +408,13 @@ func (v *UnixVolume) Trash(loc string) error {
        return v.os.Rename(p, fmt.Sprintf("%v.trash.%d", p, time.Now().Add(v.cluster.Collections.BlobTrashLifetime.Duration()).Unix()))
 }
 
-// Untrash moves block from trash back into store
+// BlockUntrash moves block from trash back into store
 // Look for path/{loc}.trash.{deadline} in storage,
 // and rename the first such file as path/{loc}
-func (v *UnixVolume) Untrash(loc string) (err error) {
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-
+func (v *unixVolume) BlockUntrash(hash string) error {
        v.os.stats.TickOps("readdir")
        v.os.stats.Tick(&v.os.stats.ReaddirOps)
-       files, err := ioutil.ReadDir(v.blockDir(loc))
+       files, err := ioutil.ReadDir(v.blockDir(hash))
        if err != nil {
                return err
        }
@@ -500,11 +424,11 @@ func (v *UnixVolume) Untrash(loc string) (err error) {
        }
 
        foundTrash := false
-       prefix := fmt.Sprintf("%v.trash.", loc)
+       prefix := fmt.Sprintf("%v.trash.", hash)
        for _, f := range files {
                if strings.HasPrefix(f.Name(), prefix) {
                        foundTrash = true
-                       err = v.os.Rename(v.blockPath(f.Name()), v.blockPath(loc))
+                       err = v.os.Rename(v.blockPath(f.Name()), v.blockPath(hash))
                        if err == nil {
                                break
                        }
@@ -515,25 +439,24 @@ func (v *UnixVolume) Untrash(loc string) (err error) {
                return os.ErrNotExist
        }
 
-       return
+       return nil
 }
 
 // blockDir returns the fully qualified directory name for the directory
 // where loc is (or would be) stored on this volume.
-func (v *UnixVolume) blockDir(loc string) string {
+func (v *unixVolume) blockDir(loc string) string {
        return filepath.Join(v.Root, loc[0:3])
 }
 
 // blockPath returns the fully qualified pathname for the path to loc
 // on this volume.
-func (v *UnixVolume) blockPath(loc string) string {
+func (v *unixVolume) blockPath(loc string) string {
        return filepath.Join(v.blockDir(loc), loc)
 }
 
-// IsFull returns true if the free space on the volume is less than
+// isFull returns true if the free space on the volume is less than
 // MinFreeKilobytes.
-//
-func (v *UnixVolume) IsFull() (isFull bool) {
+func (v *unixVolume) isFull() (isFull bool) {
        fullSymlink := v.Root + "/full"
 
        // Check if the volume has been marked as full in the last hour.
@@ -547,9 +470,9 @@ func (v *UnixVolume) IsFull() (isFull bool) {
        }
 
        if avail, err := v.FreeDiskSpace(); err == nil {
-               isFull = avail < MinFreeKilobytes
+               isFull = avail < BlockSize
        } else {
-               v.logger.WithError(err).Errorf("%s: FreeDiskSpace failed", v)
+               v.logger.WithError(err).Errorf("%s: FreeDiskSpace failed", v.DeviceID())
                isFull = false
        }
 
@@ -563,31 +486,26 @@ func (v *UnixVolume) IsFull() (isFull bool) {
 
 // FreeDiskSpace returns the number of unused 1k blocks available on
 // the volume.
-//
-func (v *UnixVolume) FreeDiskSpace() (free uint64, err error) {
+func (v *unixVolume) FreeDiskSpace() (free uint64, err error) {
        var fs syscall.Statfs_t
        err = syscall.Statfs(v.Root, &fs)
        if err == nil {
                // Statfs output is not guaranteed to measure free
                // space in terms of 1K blocks.
-               free = fs.Bavail * uint64(fs.Bsize) / 1024
+               free = fs.Bavail * uint64(fs.Bsize)
        }
        return
 }
 
-func (v *UnixVolume) String() string {
-       return fmt.Sprintf("[UnixVolume %s]", v.Root)
-}
-
 // InternalStats returns I/O and filesystem ops counters.
-func (v *UnixVolume) InternalStats() interface{} {
+func (v *unixVolume) InternalStats() interface{} {
        return &v.os.stats
 }
 
 // lock acquires the serialize lock, if one is in use. If ctx is done
 // before the lock is acquired, lock returns ctx.Err() instead of
 // acquiring the lock.
-func (v *UnixVolume) lock(ctx context.Context) error {
+func (v *unixVolume) lock(ctx context.Context) error {
        if v.locker == nil {
                return nil
        }
@@ -611,7 +529,7 @@ func (v *UnixVolume) lock(ctx context.Context) error {
 }
 
 // unlock releases the serialize lock, if one is in use.
-func (v *UnixVolume) unlock() {
+func (v *unixVolume) unlock() {
        if v.locker == nil {
                return
        }
@@ -619,7 +537,7 @@ func (v *UnixVolume) unlock() {
 }
 
 // lockfile and unlockfile use flock(2) to manage kernel file locks.
-func (v *UnixVolume) lockfile(f *os.File) error {
+func (v *unixVolume) lockfile(f *os.File) error {
        v.os.stats.TickOps("flock")
        v.os.stats.Tick(&v.os.stats.FlockOps)
        err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX)
@@ -627,7 +545,7 @@ func (v *UnixVolume) lockfile(f *os.File) error {
        return err
 }
 
-func (v *UnixVolume) unlockfile(f *os.File) error {
+func (v *unixVolume) unlockfile(f *os.File) error {
        err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
        v.os.stats.TickErr(err)
        return err
@@ -635,7 +553,7 @@ func (v *UnixVolume) unlockfile(f *os.File) error {
 
 // Where appropriate, translate a more specific filesystem error to an
 // error recognized by handlers, like os.ErrNotExist.
-func (v *UnixVolume) translateError(err error) error {
+func (v *unixVolume) translateError(err error) error {
        switch err.(type) {
        case *os.PathError:
                // stat() returns a PathError if the parent directory
@@ -650,11 +568,7 @@ var unixTrashLocRegexp = regexp.MustCompile(`/([0-9a-f]{32})\.trash\.(\d+)$`)
 
 // EmptyTrash walks hierarchy looking for {hash}.trash.*
 // and deletes those with deadline < now.
-func (v *UnixVolume) EmptyTrash() {
-       if v.cluster.Collections.BlobDeleteConcurrency < 1 {
-               return
-       }
-
+func (v *unixVolume) EmptyTrash() {
        var bytesDeleted, bytesInTrash int64
        var blocksDeleted, blocksInTrash int64
 
index 75d9b22de55604cc01a2d1f6f4ffaad7b9b585a7..bcdb5f6358652eb02fe8024b268d02b23f2eb8cf 100644 (file)
@@ -8,91 +8,82 @@ import (
        "bytes"
        "context"
        "encoding/json"
-       "errors"
        "fmt"
-       "io"
        "io/ioutil"
        "os"
        "sync"
        "syscall"
        "time"
 
-       "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "github.com/prometheus/client_golang/prometheus"
-       "github.com/sirupsen/logrus"
        check "gopkg.in/check.v1"
 )
 
-type TestableUnixVolume struct {
-       UnixVolume
+type testableUnixVolume struct {
+       unixVolume
        t TB
 }
 
-// PutRaw writes a Keep block directly into a UnixVolume, even if
-// the volume is readonly.
-func (v *TestableUnixVolume) PutRaw(locator string, data []byte) {
-       defer func(orig bool) {
-               v.volume.ReadOnly = orig
-       }(v.volume.ReadOnly)
-       v.volume.ReadOnly = false
-       err := v.Put(context.Background(), locator, data)
+func (v *testableUnixVolume) TouchWithDate(locator string, lastPut time.Time) {
+       err := syscall.Utime(v.blockPath(locator), &syscall.Utimbuf{Actime: lastPut.Unix(), Modtime: lastPut.Unix()})
        if err != nil {
                v.t.Fatal(err)
        }
 }
 
-func (v *TestableUnixVolume) TouchWithDate(locator string, lastPut time.Time) {
-       err := syscall.Utime(v.blockPath(locator), &syscall.Utimbuf{lastPut.Unix(), lastPut.Unix()})
-       if err != nil {
-               v.t.Fatal(err)
-       }
-}
-
-func (v *TestableUnixVolume) Teardown() {
+func (v *testableUnixVolume) Teardown() {
        if err := os.RemoveAll(v.Root); err != nil {
                v.t.Error(err)
        }
 }
 
-func (v *TestableUnixVolume) ReadWriteOperationLabelValues() (r, w string) {
+func (v *testableUnixVolume) ReadWriteOperationLabelValues() (r, w string) {
        return "open", "create"
 }
 
-var _ = check.Suite(&UnixVolumeSuite{})
+var _ = check.Suite(&unixVolumeSuite{})
 
-type UnixVolumeSuite struct {
-       cluster *arvados.Cluster
-       volumes []*TestableUnixVolume
-       metrics *volumeMetricsVecs
+type unixVolumeSuite struct {
+       params  newVolumeParams
+       volumes []*testableUnixVolume
 }
 
-func (s *UnixVolumeSuite) SetUpTest(c *check.C) {
-       s.cluster = testCluster(c)
-       s.metrics = newVolumeMetricsVecs(prometheus.NewRegistry())
+func (s *unixVolumeSuite) SetUpTest(c *check.C) {
+       logger := ctxlog.TestLogger(c)
+       reg := prometheus.NewRegistry()
+       s.params = newVolumeParams{
+               UUID:        "zzzzz-nyw5e-999999999999999",
+               Cluster:     testCluster(c),
+               Logger:      logger,
+               MetricsVecs: newVolumeMetricsVecs(reg),
+               BufferPool:  newBufferPool(logger, 8, reg),
+       }
 }
 
-func (s *UnixVolumeSuite) TearDownTest(c *check.C) {
+func (s *unixVolumeSuite) TearDownTest(c *check.C) {
        for _, v := range s.volumes {
                v.Teardown()
        }
 }
 
-func (s *UnixVolumeSuite) newTestableUnixVolume(c *check.C, cluster *arvados.Cluster, volume arvados.Volume, metrics *volumeMetricsVecs, serialize bool) *TestableUnixVolume {
+func (s *unixVolumeSuite) newTestableUnixVolume(c *check.C, params newVolumeParams, serialize bool) *testableUnixVolume {
        d, err := ioutil.TempDir("", "volume_test")
        c.Check(err, check.IsNil)
        var locker sync.Locker
        if serialize {
                locker = &sync.Mutex{}
        }
-       v := &TestableUnixVolume{
-               UnixVolume: UnixVolume{
-                       Root:    d,
-                       locker:  locker,
-                       cluster: cluster,
-                       logger:  ctxlog.TestLogger(c),
-                       volume:  volume,
-                       metrics: metrics,
+       v := &testableUnixVolume{
+               unixVolume: unixVolume{
+                       Root:       d,
+                       locker:     locker,
+                       uuid:       params.UUID,
+                       cluster:    params.Cluster,
+                       logger:     params.Logger,
+                       volume:     params.ConfigVolume,
+                       metrics:    params.MetricsVecs,
+                       bufferPool: params.BufferPool,
                },
                t: c,
        }
@@ -101,56 +92,45 @@ func (s *UnixVolumeSuite) newTestableUnixVolume(c *check.C, cluster *arvados.Clu
        return v
 }
 
-// serialize = false; readonly = false
-func (s *UnixVolumeSuite) TestUnixVolumeWithGenericTests(c *check.C) {
-       DoGenericVolumeTests(c, false, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               return s.newTestableUnixVolume(c, cluster, volume, metrics, false)
+func (s *unixVolumeSuite) TestUnixVolumeWithGenericTests(c *check.C) {
+       DoGenericVolumeTests(c, false, func(t TB, params newVolumeParams) TestableVolume {
+               return s.newTestableUnixVolume(c, params, false)
        })
 }
 
-// serialize = false; readonly = true
-func (s *UnixVolumeSuite) TestUnixVolumeWithGenericTestsReadOnly(c *check.C) {
-       DoGenericVolumeTests(c, true, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               return s.newTestableUnixVolume(c, cluster, volume, metrics, true)
+func (s *unixVolumeSuite) TestUnixVolumeWithGenericTests_ReadOnly(c *check.C) {
+       DoGenericVolumeTests(c, true, func(t TB, params newVolumeParams) TestableVolume {
+               return s.newTestableUnixVolume(c, params, false)
        })
 }
 
-// serialize = true; readonly = false
-func (s *UnixVolumeSuite) TestUnixVolumeWithGenericTestsSerialized(c *check.C) {
-       DoGenericVolumeTests(c, false, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               return s.newTestableUnixVolume(c, cluster, volume, metrics, false)
+func (s *unixVolumeSuite) TestUnixVolumeWithGenericTests_Serialized(c *check.C) {
+       DoGenericVolumeTests(c, false, func(t TB, params newVolumeParams) TestableVolume {
+               return s.newTestableUnixVolume(c, params, true)
        })
 }
 
-// serialize = true; readonly = true
-func (s *UnixVolumeSuite) TestUnixVolumeHandlersWithGenericVolumeTests(c *check.C) {
-       DoGenericVolumeTests(c, true, func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume {
-               return s.newTestableUnixVolume(c, cluster, volume, metrics, true)
+func (s *unixVolumeSuite) TestUnixVolumeWithGenericTests_Readonly_Serialized(c *check.C) {
+       DoGenericVolumeTests(c, true, func(t TB, params newVolumeParams) TestableVolume {
+               return s.newTestableUnixVolume(c, params, true)
        })
 }
 
-func (s *UnixVolumeSuite) TestGetNotFound(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
+func (s *unixVolumeSuite) TestGetNotFound(c *check.C) {
+       v := s.newTestableUnixVolume(c, s.params, true)
        defer v.Teardown()
-       v.Put(context.Background(), TestHash, TestBlock)
-
-       buf := make([]byte, BlockSize)
-       n, err := v.Get(context.Background(), TestHash2, buf)
-       switch {
-       case os.IsNotExist(err):
-               break
-       case err == nil:
-               c.Errorf("Read should have failed, returned %+q", buf[:n])
-       default:
-               c.Errorf("Read expected ErrNotExist, got: %s", err)
-       }
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
+
+       buf := &brbuffer{}
+       err := v.BlockRead(context.Background(), TestHash2, buf)
+       c.Check(err, check.FitsTypeOf, os.ErrNotExist)
 }
 
-func (s *UnixVolumeSuite) TestPut(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
+func (s *unixVolumeSuite) TestPut(c *check.C) {
+       v := s.newTestableUnixVolume(c, s.params, false)
        defer v.Teardown()
 
-       err := v.Put(context.Background(), TestHash, TestBlock)
+       err := v.BlockWrite(context.Background(), TestHash, TestBlock)
        if err != nil {
                c.Error(err)
        }
@@ -163,235 +143,85 @@ func (s *UnixVolumeSuite) TestPut(c *check.C) {
        }
 }
 
-func (s *UnixVolumeSuite) TestPutBadVolume(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
+func (s *unixVolumeSuite) TestPutBadVolume(c *check.C) {
+       v := s.newTestableUnixVolume(c, s.params, false)
        defer v.Teardown()
 
        err := os.RemoveAll(v.Root)
        c.Assert(err, check.IsNil)
-       err = v.Put(context.Background(), TestHash, TestBlock)
+       err = v.BlockWrite(context.Background(), TestHash, TestBlock)
        c.Check(err, check.IsNil)
 }
 
-func (s *UnixVolumeSuite) TestUnixVolumeReadonly(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{ReadOnly: true, Replication: 1}, s.metrics, false)
-       defer v.Teardown()
-
-       v.PutRaw(TestHash, TestBlock)
-
-       buf := make([]byte, BlockSize)
-       _, err := v.Get(context.Background(), TestHash, buf)
-       if err != nil {
-               c.Errorf("got err %v, expected nil", err)
-       }
-
-       err = v.Put(context.Background(), TestHash, TestBlock)
-       if err != MethodDisabledError {
-               c.Errorf("got err %v, expected MethodDisabledError", err)
-       }
-
-       err = v.Touch(TestHash)
-       if err != MethodDisabledError {
-               c.Errorf("got err %v, expected MethodDisabledError", err)
-       }
-
-       err = v.Trash(TestHash)
-       if err != MethodDisabledError {
-               c.Errorf("got err %v, expected MethodDisabledError", err)
-       }
-}
-
-func (s *UnixVolumeSuite) TestIsFull(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
+func (s *unixVolumeSuite) TestIsFull(c *check.C) {
+       v := s.newTestableUnixVolume(c, s.params, false)
        defer v.Teardown()
 
        fullPath := v.Root + "/full"
        now := fmt.Sprintf("%d", time.Now().Unix())
        os.Symlink(now, fullPath)
-       if !v.IsFull() {
-               c.Errorf("%s: claims not to be full", v)
+       if !v.isFull() {
+               c.Error("volume claims not to be full")
        }
        os.Remove(fullPath)
 
        // Test with an expired /full link.
        expired := fmt.Sprintf("%d", time.Now().Unix()-3605)
        os.Symlink(expired, fullPath)
-       if v.IsFull() {
-               c.Errorf("%s: should no longer be full", v)
+       if v.isFull() {
+               c.Error("volume should no longer be full")
        }
 }
 
-func (s *UnixVolumeSuite) TestNodeStatus(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
-       defer v.Teardown()
-
-       // Get node status and make a basic sanity check.
-       volinfo := v.Status()
-       if volinfo.MountPoint != v.Root {
-               c.Errorf("GetNodeStatus mount_point %s, expected %s", volinfo.MountPoint, v.Root)
-       }
-       if volinfo.DeviceNum == 0 {
-               c.Errorf("uninitialized device_num in %v", volinfo)
-       }
-       if volinfo.BytesFree == 0 {
-               c.Errorf("uninitialized bytes_free in %v", volinfo)
-       }
-       if volinfo.BytesUsed == 0 {
-               c.Errorf("uninitialized bytes_used in %v", volinfo)
-       }
-}
-
-func (s *UnixVolumeSuite) TestUnixVolumeGetFuncWorkerError(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
-       defer v.Teardown()
-
-       v.Put(context.Background(), TestHash, TestBlock)
-       mockErr := errors.New("Mock error")
-       err := v.getFunc(context.Background(), v.blockPath(TestHash), func(rdr io.Reader) error {
-               return mockErr
-       })
-       if err != mockErr {
-               c.Errorf("Got %v, expected %v", err, mockErr)
-       }
-}
-
-func (s *UnixVolumeSuite) TestUnixVolumeGetFuncFileError(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
-       defer v.Teardown()
-
-       funcCalled := false
-       err := v.getFunc(context.Background(), v.blockPath(TestHash), func(rdr io.Reader) error {
-               funcCalled = true
-               return nil
-       })
-       if err == nil {
-               c.Errorf("Expected error opening non-existent file")
-       }
-       if funcCalled {
-               c.Errorf("Worker func should not have been called")
-       }
-}
-
-func (s *UnixVolumeSuite) TestUnixVolumeGetFuncWorkerWaitsOnMutex(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
-       defer v.Teardown()
-
-       v.Put(context.Background(), TestHash, TestBlock)
-
-       mtx := NewMockMutex()
-       v.locker = mtx
-
-       funcCalled := make(chan struct{})
-       go v.getFunc(context.Background(), v.blockPath(TestHash), func(rdr io.Reader) error {
-               funcCalled <- struct{}{}
-               return nil
-       })
-       select {
-       case mtx.AllowLock <- struct{}{}:
-       case <-funcCalled:
-               c.Fatal("Function was called before mutex was acquired")
-       case <-time.After(5 * time.Second):
-               c.Fatal("Timed out before mutex was acquired")
-       }
-       select {
-       case <-funcCalled:
-       case mtx.AllowUnlock <- struct{}{}:
-               c.Fatal("Mutex was released before function was called")
-       case <-time.After(5 * time.Second):
-               c.Fatal("Timed out waiting for funcCalled")
-       }
-       select {
-       case mtx.AllowUnlock <- struct{}{}:
-       case <-time.After(5 * time.Second):
-               c.Fatal("Timed out waiting for getFunc() to release mutex")
-       }
-}
-
-func (s *UnixVolumeSuite) TestUnixVolumeCompare(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
-       defer v.Teardown()
-
-       v.Put(context.Background(), TestHash, TestBlock)
-       err := v.Compare(context.Background(), TestHash, TestBlock)
-       if err != nil {
-               c.Errorf("Got err %q, expected nil", err)
-       }
-
-       err = v.Compare(context.Background(), TestHash, []byte("baddata"))
-       if err != CollisionError {
-               c.Errorf("Got err %q, expected %q", err, CollisionError)
-       }
-
-       v.Put(context.Background(), TestHash, []byte("baddata"))
-       err = v.Compare(context.Background(), TestHash, TestBlock)
-       if err != DiskHashError {
-               c.Errorf("Got err %q, expected %q", err, DiskHashError)
-       }
-
-       if os.Getuid() == 0 {
-               c.Log("skipping 'permission denied' check when running as root")
-       } else {
-               p := fmt.Sprintf("%s/%s/%s", v.Root, TestHash[:3], TestHash)
-               err = os.Chmod(p, 000)
-               c.Assert(err, check.IsNil)
-               err = v.Compare(context.Background(), TestHash, TestBlock)
-               c.Check(err, check.ErrorMatches, ".*permission denied.*")
-       }
-}
-
-func (s *UnixVolumeSuite) TestUnixVolumeContextCancelPut(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, true)
+func (s *unixVolumeSuite) TestUnixVolumeContextCancelBlockWrite(c *check.C) {
+       v := s.newTestableUnixVolume(c, s.params, true)
        defer v.Teardown()
        v.locker.Lock()
+       defer v.locker.Unlock()
        ctx, cancel := context.WithCancel(context.Background())
        go func() {
                time.Sleep(50 * time.Millisecond)
                cancel()
-               time.Sleep(50 * time.Millisecond)
-               v.locker.Unlock()
        }()
-       err := v.Put(ctx, TestHash, TestBlock)
+       err := v.BlockWrite(ctx, TestHash, TestBlock)
        if err != context.Canceled {
-               c.Errorf("Put() returned %s -- expected short read / canceled", err)
+               c.Errorf("BlockWrite() returned %s -- expected short read / canceled", err)
        }
 }
 
-func (s *UnixVolumeSuite) TestUnixVolumeContextCancelGet(c *check.C) {
-       v := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
+func (s *unixVolumeSuite) TestUnixVolumeContextCancelBlockRead(c *check.C) {
+       v := s.newTestableUnixVolume(c, s.params, true)
        defer v.Teardown()
-       bpath := v.blockPath(TestHash)
-       v.PutRaw(TestHash, TestBlock)
-       os.Remove(bpath)
-       err := syscall.Mkfifo(bpath, 0600)
+       err := v.BlockWrite(context.Background(), TestHash, TestBlock)
        if err != nil {
-               c.Fatalf("Mkfifo %s: %s", bpath, err)
+               c.Fatal(err)
        }
-       defer os.Remove(bpath)
        ctx, cancel := context.WithCancel(context.Background())
+       v.locker.Lock()
+       defer v.locker.Unlock()
        go func() {
                time.Sleep(50 * time.Millisecond)
                cancel()
        }()
-       buf := make([]byte, len(TestBlock))
-       n, err := v.Get(ctx, TestHash, buf)
-       if n == len(TestBlock) || err != context.Canceled {
-               c.Errorf("Get() returned %d, %s -- expected short read / canceled", n, err)
+       buf := &brbuffer{}
+       err = v.BlockRead(ctx, TestHash, buf)
+       if buf.Len() != 0 || err != context.Canceled {
+               c.Errorf("BlockRead() returned %q, %s -- expected short read / canceled", buf.String(), err)
        }
 }
 
-func (s *UnixVolumeSuite) TestStats(c *check.C) {
-       vol := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
+func (s *unixVolumeSuite) TestStats(c *check.C) {
+       vol := s.newTestableUnixVolume(c, s.params, false)
        stats := func() string {
                buf, err := json.Marshal(vol.InternalStats())
                c.Check(err, check.IsNil)
                return string(buf)
        }
 
-       c.Check(stats(), check.Matches, `.*"StatOps":1,.*`) // (*UnixVolume)check() calls Stat() once
+       c.Check(stats(), check.Matches, `.*"StatOps":1,.*`) // (*unixVolume)check() calls Stat() once
        c.Check(stats(), check.Matches, `.*"Errors":0,.*`)
 
-       loc := "acbd18db4cc2f85cedef654fccc4a4d8"
-       _, err := vol.Get(context.Background(), loc, make([]byte, 3))
+       err := vol.BlockRead(context.Background(), fooHash, brdiscard)
        c.Check(err, check.NotNil)
        c.Check(stats(), check.Matches, `.*"StatOps":[^0],.*`)
        c.Check(stats(), check.Matches, `.*"Errors":[^0],.*`)
@@ -400,42 +230,42 @@ func (s *UnixVolumeSuite) TestStats(c *check.C) {
        c.Check(stats(), check.Matches, `.*"OpenOps":0,.*`)
        c.Check(stats(), check.Matches, `.*"CreateOps":0,.*`)
 
-       err = vol.Put(context.Background(), loc, []byte("foo"))
+       err = vol.BlockWrite(context.Background(), fooHash, []byte("foo"))
        c.Check(err, check.IsNil)
        c.Check(stats(), check.Matches, `.*"OutBytes":3,.*`)
        c.Check(stats(), check.Matches, `.*"CreateOps":1,.*`)
        c.Check(stats(), check.Matches, `.*"OpenOps":0,.*`)
        c.Check(stats(), check.Matches, `.*"UtimesOps":1,.*`)
 
-       err = vol.Touch(loc)
+       err = vol.BlockTouch(fooHash)
        c.Check(err, check.IsNil)
        c.Check(stats(), check.Matches, `.*"FlockOps":1,.*`)
        c.Check(stats(), check.Matches, `.*"OpenOps":1,.*`)
        c.Check(stats(), check.Matches, `.*"UtimesOps":2,.*`)
 
-       _, err = vol.Get(context.Background(), loc, make([]byte, 3))
-       c.Check(err, check.IsNil)
-       err = vol.Compare(context.Background(), loc, []byte("foo"))
+       buf := &brbuffer{}
+       err = vol.BlockRead(context.Background(), fooHash, buf)
        c.Check(err, check.IsNil)
-       c.Check(stats(), check.Matches, `.*"InBytes":6,.*`)
-       c.Check(stats(), check.Matches, `.*"OpenOps":3,.*`)
+       c.Check(buf.String(), check.Equals, "foo")
+       c.Check(stats(), check.Matches, `.*"InBytes":3,.*`)
+       c.Check(stats(), check.Matches, `.*"OpenOps":2,.*`)
 
-       err = vol.Trash(loc)
+       err = vol.BlockTrash(fooHash)
        c.Check(err, check.IsNil)
        c.Check(stats(), check.Matches, `.*"FlockOps":2,.*`)
 }
 
-func (s *UnixVolumeSuite) TestSkipUnusedDirs(c *check.C) {
-       vol := s.newTestableUnixVolume(c, s.cluster, arvados.Volume{Replication: 1}, s.metrics, false)
+func (s *unixVolumeSuite) TestSkipUnusedDirs(c *check.C) {
+       vol := s.newTestableUnixVolume(c, s.params, false)
 
-       err := os.Mkdir(vol.UnixVolume.Root+"/aaa", 0777)
+       err := os.Mkdir(vol.unixVolume.Root+"/aaa", 0777)
        c.Assert(err, check.IsNil)
-       err = os.Mkdir(vol.UnixVolume.Root+"/.aaa", 0777) // EmptyTrash should not look here
+       err = os.Mkdir(vol.unixVolume.Root+"/.aaa", 0777) // EmptyTrash should not look here
        c.Assert(err, check.IsNil)
-       deleteme := vol.UnixVolume.Root + "/aaa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.trash.1"
+       deleteme := vol.unixVolume.Root + "/aaa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.trash.1"
        err = ioutil.WriteFile(deleteme, []byte{1, 2, 3}, 0777)
        c.Assert(err, check.IsNil)
-       skipme := vol.UnixVolume.Root + "/.aaa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.trash.1"
+       skipme := vol.unixVolume.Root + "/.aaa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.trash.1"
        err = ioutil.WriteFile(skipme, []byte{1, 2, 3}, 0777)
        c.Assert(err, check.IsNil)
        vol.EmptyTrash()
index c3b8cd6283e0311c93fcc914ebd3b370045cfa0f..cd61804913253af6032b25b97892db858a3b0cb7 100644 (file)
@@ -6,426 +6,93 @@ package keepstore
 
 import (
        "context"
-       "crypto/rand"
-       "fmt"
        "io"
-       "math/big"
-       "sort"
-       "sync/atomic"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "github.com/sirupsen/logrus"
 )
 
-type BlockWriter interface {
-       // WriteBlock reads all data from r, writes it to a backing
-       // store as "loc", and returns the number of bytes written.
-       WriteBlock(ctx context.Context, loc string, r io.Reader) error
-}
-
-type BlockReader interface {
-       // ReadBlock retrieves data previously stored as "loc" and
-       // writes it to w.
-       ReadBlock(ctx context.Context, loc string, w io.Writer) error
-}
-
-var driver = map[string]func(*arvados.Cluster, arvados.Volume, logrus.FieldLogger, *volumeMetricsVecs) (Volume, error){}
-
-// A Volume is an interface representing a Keep back-end storage unit:
-// for example, a single mounted disk, a RAID array, an Amazon S3 volume,
-// etc.
-type Volume interface {
-       // Get a block: copy the block data into buf, and return the
-       // number of bytes copied.
-       //
-       // loc is guaranteed to consist of 32 or more lowercase hex
-       // digits.
-       //
-       // Get should not verify the integrity of the data: it should
-       // just return whatever was found in its backing
-       // store. (Integrity checking is the caller's responsibility.)
-       //
-       // If an error is encountered that prevents it from
-       // retrieving the data, that error should be returned so the
-       // caller can log (and send to the client) a more useful
-       // message.
-       //
-       // If the error is "not found", and there's no particular
-       // reason to expect the block to be found (other than that a
-       // caller is asking for it), the returned error should satisfy
-       // os.IsNotExist(err): this is a normal condition and will not
-       // be logged as an error (except that a 404 will appear in the
-       // access log if the block is not found on any other volumes
-       // either).
-       //
-       // If the data in the backing store is bigger than len(buf),
-       // then Get is permitted to return an error without reading
-       // any of the data.
-       //
-       // len(buf) will not exceed BlockSize.
-       Get(ctx context.Context, loc string, buf []byte) (int, error)
-
-       // Compare the given data with the stored data (i.e., what Get
-       // would return). If equal, return nil. If not, return
-       // CollisionError or DiskHashError (depending on whether the
-       // data on disk matches the expected hash), or whatever error
-       // was encountered opening/reading the stored data.
-       Compare(ctx context.Context, loc string, data []byte) error
-
-       // Put writes a block to an underlying storage device.
-       //
-       // loc is as described in Get.
-       //
-       // len(block) is guaranteed to be between 0 and BlockSize.
-       //
-       // If a block is already stored under the same name (loc) with
-       // different content, Put must either overwrite the existing
-       // data with the new data or return a non-nil error. When
-       // overwriting existing data, it must never leave the storage
-       // device in an inconsistent state: a subsequent call to Get
-       // must return either the entire old block, the entire new
-       // block, or an error. (An implementation that cannot peform
-       // atomic updates must leave the old data alone and return an
-       // error.)
-       //
-       // Put also sets the timestamp for the given locator to the
-       // current time.
-       //
-       // Put must return a non-nil error unless it can guarantee
-       // that the entire block has been written and flushed to
-       // persistent storage, and that its timestamp is current. Of
-       // course, this guarantee is only as good as the underlying
-       // storage device, but it is Put's responsibility to at least
-       // get whatever guarantee is offered by the storage device.
+// volume is the interface to a back-end storage device.
+type volume interface {
+       // Return a unique identifier for the backend device. If
+       // possible, this should be chosen such that keepstore
+       // processes running on different hosts, and accessing the
+       // same backend device, will return the same string.
        //
-       // Put should not verify that loc==hash(block): this is the
-       // caller's responsibility.
-       Put(ctx context.Context, loc string, block []byte) error
+       // This helps keep-balance avoid redundantly downloading
+       // multiple index listings for the same backend device.
+       DeviceID() string
 
-       // Touch sets the timestamp for the given locator to the
-       // current time.
-       //
-       // loc is as described in Get.
-       //
-       // If invoked at time t0, Touch must guarantee that a
-       // subsequent call to Mtime will return a timestamp no older
-       // than {t0 minus one second}. For example, if Touch is called
-       // at 2015-07-07T01:23:45.67890123Z, it is acceptable for a
-       // subsequent Mtime to return any of the following:
+       // Copy a block from the backend device to writeTo.
        //
-       //   - 2015-07-07T01:23:45.00000000Z
-       //   - 2015-07-07T01:23:45.67890123Z
-       //   - 2015-07-07T01:23:46.67890123Z
-       //   - 2015-07-08T00:00:00.00000000Z
+       // As with all volume methods, the hash argument is a
+       // 32-character hexadecimal string.
        //
-       // It is not acceptable for a subsequente Mtime to return
-       // either of the following:
+       // Data can be written to writeTo in any order, and concurrent
+       // calls to writeTo.WriteAt() are allowed.  However, BlockRead
+       // must not do multiple writes that intersect with any given
+       // byte offset.
        //
-       //   - 2015-07-07T00:00:00.00000000Z -- ERROR
-       //   - 2015-07-07T01:23:44.00000000Z -- ERROR
+       // BlockRead is not expected to verify data integrity.
        //
-       // Touch must return a non-nil error if the timestamp cannot
-       // be updated.
-       Touch(loc string) error
+       // If the indicated block does not exist, or has been trashed,
+       // BlockRead must return os.ErrNotExist.
+       BlockRead(ctx context.Context, hash string, writeTo io.WriterAt) error
 
-       // Mtime returns the stored timestamp for the given locator.
+       // Store a block on the backend device, and set its timestamp
+       // to the current time.
        //
-       // loc is as described in Get.
-       //
-       // Mtime must return a non-nil error if the given block is not
-       // found or the timestamp could not be retrieved.
-       Mtime(loc string) (time.Time, error)
+       // The implementation must ensure that regardless of any
+       // errors encountered while writing, a partially written block
+       // is not left behind: a subsequent BlockRead call must return
+       // either a) the data previously stored under the given hash,
+       // if any, or b) os.ErrNotExist.
+       BlockWrite(ctx context.Context, hash string, data []byte) error
 
-       // IndexTo writes a complete list of locators with the given
-       // prefix for which Get() can retrieve data.
-       //
-       // prefix consists of zero or more lowercase hexadecimal
-       // digits.
-       //
-       // Each locator must be written to the given writer using the
-       // following format:
-       //
-       //   loc "+" size " " timestamp "\n"
-       //
-       // where:
-       //
-       //   - size is the number of bytes of content, given as a
-       //     decimal number with one or more digits
-       //
-       //   - timestamp is the timestamp stored for the locator,
-       //     given as a decimal number of seconds after January 1,
-       //     1970 UTC.
-       //
-       // IndexTo must not write any other data to writer: for
-       // example, it must not write any blank lines.
-       //
-       // If an error makes it impossible to provide a complete
-       // index, IndexTo must return a non-nil error. It is
-       // acceptable to return a non-nil error after writing a
-       // partial index to writer.
-       //
-       // The resulting index is not expected to be sorted in any
-       // particular order.
-       IndexTo(prefix string, writer io.Writer) error
-
-       // Trash moves the block data from the underlying storage
-       // device to trash area. The block then stays in trash for
-       // BlobTrashLifetime before it is actually deleted.
-       //
-       // loc is as described in Get.
-       //
-       // If the timestamp for the given locator is newer than
-       // BlobSigningTTL, Trash must not trash the data.
-       //
-       // If a Trash operation overlaps with any Touch or Put
-       // operations on the same locator, the implementation must
-       // ensure one of the following outcomes:
-       //
-       //   - Touch and Put return a non-nil error, or
-       //   - Trash does not trash the block, or
-       //   - Both of the above.
-       //
-       // If it is possible for the storage device to be accessed by
-       // a different process or host, the synchronization mechanism
-       // should also guard against races with other processes and
-       // hosts. If such a mechanism is not available, there must be
-       // a mechanism for detecting unsafe configurations, alerting
-       // the operator, and aborting or falling back to a read-only
-       // state. In other words, running multiple keepstore processes
-       // with the same underlying storage device must either work
-       // reliably or fail outright.
-       //
-       // Corollary: A successful Touch or Put guarantees a block
-       // will not be trashed for at least BlobSigningTTL seconds.
-       Trash(loc string) error
+       // Update the indicated block's stored timestamp to the
+       // current time.
+       BlockTouch(hash string) error
 
-       // Untrash moves block from trash back into store
-       Untrash(loc string) error
+       // Return the indicated block's stored timestamp.
+       Mtime(hash string) (time.Time, error)
 
-       // Status returns a *VolumeStatus representing the current
-       // in-use and available storage capacity and an
-       // implementation-specific volume identifier (e.g., "mount
-       // point" for a UnixVolume).
-       Status() *VolumeStatus
+       // Mark the indicated block as trash, such that -- unless it
+       // is untrashed before time.Now() + BlobTrashLifetime --
+       // BlockRead returns os.ErrNotExist and the block is not
+       // listed by Index.
+       BlockTrash(hash string) error
 
-       // String returns an identifying label for this volume,
-       // suitable for including in log messages. It should contain
-       // enough information to uniquely identify the underlying
-       // storage device, but should not contain any credentials or
-       // secrets.
-       String() string
+       // Un-mark the indicated block as trash. If the block has not
+       // been trashed, return os.ErrNotExist.
+       BlockUntrash(hash string) error
 
-       // EmptyTrash looks for trashed blocks that exceeded
-       // BlobTrashLifetime and deletes them from the volume.
+       // Permanently delete all blocks that have been marked as
+       // trash for BlobTrashLifetime or longer.
        EmptyTrash()
 
-       // Return a globally unique ID of the underlying storage
-       // device if possible, otherwise "".
-       GetDeviceID() string
-}
-
-// A VolumeWithExamples provides example configs to display in the
-// -help message.
-type VolumeWithExamples interface {
-       Volume
-       Examples() []Volume
-}
-
-// A VolumeManager tells callers which volumes can read, which volumes
-// can write, and on which volume the next write should be attempted.
-type VolumeManager interface {
-       // Mounts returns all mounts (volume attachments).
-       Mounts() []*VolumeMount
-
-       // Lookup returns the mount with the given UUID. Returns nil
-       // if the mount does not exist. If write==true, returns nil if
-       // the mount is not writable.
-       Lookup(uuid string, write bool) *VolumeMount
-
-       // AllReadable returns all mounts.
-       AllReadable() []*VolumeMount
-
-       // AllWritable returns all mounts that aren't known to be in
-       // a read-only state. (There is no guarantee that a write to
-       // one will succeed, though.)
-       AllWritable() []*VolumeMount
-
-       // NextWritable returns the volume where the next new block
-       // should be written. A VolumeManager can select a volume in
-       // order to distribute activity across spindles, fill up disks
-       // with more free space, etc.
-       NextWritable() *VolumeMount
-
-       // VolumeStats returns the ioStats used for tracking stats for
-       // the given Volume.
-       VolumeStats(Volume) *ioStats
-
-       // Close shuts down the volume manager cleanly.
-       Close()
-}
-
-// A VolumeMount is an attachment of a Volume to a VolumeManager.
-type VolumeMount struct {
-       arvados.KeepMount
-       Volume
-}
-
-// Generate a UUID the way API server would for a "KeepVolumeMount"
-// object.
-func (*VolumeMount) generateUUID() string {
-       var max big.Int
-       _, ok := max.SetString("zzzzzzzzzzzzzzz", 36)
-       if !ok {
-               panic("big.Int parse failed")
-       }
-       r, err := rand.Int(rand.Reader, &max)
-       if err != nil {
-               panic(err)
-       }
-       return fmt.Sprintf("zzzzz-ivpuk-%015s", r.Text(36))
-}
-
-// RRVolumeManager is a round-robin VolumeManager: the Nth call to
-// NextWritable returns the (N % len(writables))th writable Volume
-// (where writables are all Volumes v where v.Writable()==true).
-type RRVolumeManager struct {
-       mounts    []*VolumeMount
-       mountMap  map[string]*VolumeMount
-       readables []*VolumeMount
-       writables []*VolumeMount
-       counter   uint32
-       iostats   map[Volume]*ioStats
-}
-
-func makeRRVolumeManager(logger logrus.FieldLogger, cluster *arvados.Cluster, myURL arvados.URL, metrics *volumeMetricsVecs) (*RRVolumeManager, error) {
-       vm := &RRVolumeManager{
-               iostats: make(map[Volume]*ioStats),
-       }
-       vm.mountMap = make(map[string]*VolumeMount)
-       for uuid, cfgvol := range cluster.Volumes {
-               va, ok := cfgvol.AccessViaHosts[myURL]
-               if !ok && len(cfgvol.AccessViaHosts) > 0 {
-                       continue
-               }
-               dri, ok := driver[cfgvol.Driver]
-               if !ok {
-                       return nil, fmt.Errorf("volume %s: invalid driver %q", uuid, cfgvol.Driver)
-               }
-               vol, err := dri(cluster, cfgvol, logger, metrics)
-               if err != nil {
-                       return nil, fmt.Errorf("error initializing volume %s: %s", uuid, err)
-               }
-               logger.Printf("started volume %s (%s), ReadOnly=%v", uuid, vol, cfgvol.ReadOnly || va.ReadOnly)
-
-               sc := cfgvol.StorageClasses
-               if len(sc) == 0 {
-                       sc = map[string]bool{"default": true}
-               }
-               repl := cfgvol.Replication
-               if repl < 1 {
-                       repl = 1
-               }
-               mnt := &VolumeMount{
-                       KeepMount: arvados.KeepMount{
-                               UUID:           uuid,
-                               DeviceID:       vol.GetDeviceID(),
-                               ReadOnly:       cfgvol.ReadOnly || va.ReadOnly,
-                               Replication:    repl,
-                               StorageClasses: sc,
-                       },
-                       Volume: vol,
-               }
-               vm.iostats[vol] = &ioStats{}
-               vm.mounts = append(vm.mounts, mnt)
-               vm.mountMap[uuid] = mnt
-               vm.readables = append(vm.readables, mnt)
-               if !mnt.KeepMount.ReadOnly {
-                       vm.writables = append(vm.writables, mnt)
-               }
-       }
-       // pri(mnt): return highest priority of any storage class
-       // offered by mnt
-       pri := func(mnt *VolumeMount) int {
-               any, best := false, 0
-               for class := range mnt.KeepMount.StorageClasses {
-                       if p := cluster.StorageClasses[class].Priority; !any || best < p {
-                               best = p
-                               any = true
-                       }
-               }
-               return best
-       }
-       // less(a,b): sort first by highest priority of any offered
-       // storage class (highest->lowest), then by volume UUID
-       less := func(a, b *VolumeMount) bool {
-               if pa, pb := pri(a), pri(b); pa != pb {
-                       return pa > pb
-               } else {
-                       return a.KeepMount.UUID < b.KeepMount.UUID
-               }
-       }
-       sort.Slice(vm.readables, func(i, j int) bool {
-               return less(vm.readables[i], vm.readables[j])
-       })
-       sort.Slice(vm.writables, func(i, j int) bool {
-               return less(vm.writables[i], vm.writables[j])
-       })
-       sort.Slice(vm.mounts, func(i, j int) bool {
-               return less(vm.mounts[i], vm.mounts[j])
-       })
-       return vm, nil
-}
-
-func (vm *RRVolumeManager) Mounts() []*VolumeMount {
-       return vm.mounts
-}
-
-func (vm *RRVolumeManager) Lookup(uuid string, needWrite bool) *VolumeMount {
-       if mnt, ok := vm.mountMap[uuid]; ok && (!needWrite || !mnt.ReadOnly) {
-               return mnt
-       }
-       return nil
-}
-
-// AllReadable returns an array of all readable volumes
-func (vm *RRVolumeManager) AllReadable() []*VolumeMount {
-       return vm.readables
-}
-
-// AllWritable returns writable volumes, sorted by priority/uuid. Used
-// by CompareAndTouch to ensure higher-priority volumes are checked
-// first.
-func (vm *RRVolumeManager) AllWritable() []*VolumeMount {
-       return vm.writables
-}
-
-// NextWritable returns writable volumes, rotated by vm.counter so
-// each volume gets a turn to be first. Used by PutBlock to distribute
-// new data across available volumes.
-func (vm *RRVolumeManager) NextWritable() []*VolumeMount {
-       if len(vm.writables) == 0 {
-               return nil
-       }
-       offset := (int(atomic.AddUint32(&vm.counter, 1)) - 1) % len(vm.writables)
-       return append(append([]*VolumeMount(nil), vm.writables[offset:]...), vm.writables[:offset]...)
-}
-
-// VolumeStats returns an ioStats for the given volume.
-func (vm *RRVolumeManager) VolumeStats(v Volume) *ioStats {
-       return vm.iostats[v]
+       // Write an index of all non-trashed blocks available on the
+       // backend device whose hash begins with the given prefix
+       // (prefix is a string of zero or more hexadecimal digits).
+       //
+       // Each block is written as "{hash}+{size} {timestamp}\n"
+       // where timestamp is a decimal-formatted number of
+       // nanoseconds since the UTC Unix epoch.
+       //
+       // Index should abort and return ctx.Err() if ctx is cancelled
+       // before indexing is complete.
+       Index(ctx context.Context, prefix string, writeTo io.Writer) error
 }
 
-// Close the RRVolumeManager
-func (vm *RRVolumeManager) Close() {
-}
+type volumeDriver func(newVolumeParams) (volume, error)
 
-// VolumeStatus describes the current condition of a volume
-type VolumeStatus struct {
-       MountPoint string
-       DeviceNum  uint64
-       BytesFree  uint64
-       BytesUsed  uint64
+type newVolumeParams struct {
+       UUID         string
+       Cluster      *arvados.Cluster
+       ConfigVolume arvados.Volume
+       Logger       logrus.FieldLogger
+       MetricsVecs  *volumeMetricsVecs
+       BufferPool   *bufferPool
 }
 
 // ioStats tracks I/O statistics for a volume or server
@@ -439,7 +106,3 @@ type ioStats struct {
        InBytes    uint64
        OutBytes   uint64
 }
-
-type InternalStatser interface {
-       InternalStats() interface{}
-}
index 0dd34e3af1be878e2602a9bf2e43fcfed29c4eb6..16084058b7d57f3e8cef4b1ec1063337d8e62f84 100644 (file)
@@ -14,6 +14,7 @@ import (
        "sort"
        "strconv"
        "strings"
+       "sync"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
@@ -39,7 +40,7 @@ type TB interface {
 // A TestableVolumeFactory returns a new TestableVolume. The factory
 // function, and the TestableVolume it returns, can use "t" to write
 // logs, fail the current test, etc.
-type TestableVolumeFactory func(t TB, cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) TestableVolume
+type TestableVolumeFactory func(t TB, params newVolumeParams) TestableVolume
 
 // DoGenericVolumeTests runs a set of tests that every TestableVolume
 // is expected to pass. It calls factory to create a new TestableVolume
@@ -51,16 +52,6 @@ func DoGenericVolumeTests(t TB, readonly bool, factory TestableVolumeFactory) {
        s.testGet(t, factory)
        s.testGetNoSuchBlock(t, factory)
 
-       s.testCompareNonexistent(t, factory)
-       s.testCompareSameContent(t, factory, TestHash, TestBlock)
-       s.testCompareSameContent(t, factory, EmptyHash, EmptyBlock)
-       s.testCompareWithCollision(t, factory, TestHash, TestBlock, []byte("baddata"))
-       s.testCompareWithCollision(t, factory, TestHash, TestBlock, EmptyBlock)
-       s.testCompareWithCollision(t, factory, EmptyHash, EmptyBlock, TestBlock)
-       s.testCompareWithCorruptStoredData(t, factory, TestHash, TestBlock, []byte("baddata"))
-       s.testCompareWithCorruptStoredData(t, factory, TestHash, TestBlock, EmptyBlock)
-       s.testCompareWithCorruptStoredData(t, factory, EmptyHash, EmptyBlock, []byte("baddata"))
-
        if !readonly {
                s.testPutBlockWithSameContent(t, factory, TestHash, TestBlock)
                s.testPutBlockWithSameContent(t, factory, EmptyHash, EmptyBlock)
@@ -76,7 +67,7 @@ func DoGenericVolumeTests(t TB, readonly bool, factory TestableVolumeFactory) {
 
        s.testMtimeNoSuchBlock(t, factory)
 
-       s.testIndexTo(t, factory)
+       s.testIndex(t, factory)
 
        if !readonly {
                s.testDeleteNewBlock(t, factory)
@@ -84,33 +75,24 @@ func DoGenericVolumeTests(t TB, readonly bool, factory TestableVolumeFactory) {
        }
        s.testDeleteNoSuchBlock(t, factory)
 
-       s.testStatus(t, factory)
-
        s.testMetrics(t, readonly, factory)
 
-       s.testString(t, factory)
-
-       if readonly {
-               s.testUpdateReadOnly(t, factory)
-       }
-
        s.testGetConcurrent(t, factory)
        if !readonly {
                s.testPutConcurrent(t, factory)
-
                s.testPutFullBlock(t, factory)
+               s.testTrashUntrash(t, readonly, factory)
+               s.testTrashEmptyTrashUntrash(t, factory)
        }
-
-       s.testTrashUntrash(t, readonly, factory)
-       s.testTrashEmptyTrashUntrash(t, factory)
 }
 
 type genericVolumeSuite struct {
-       cluster  *arvados.Cluster
-       volume   arvados.Volume
-       logger   logrus.FieldLogger
-       metrics  *volumeMetricsVecs
-       registry *prometheus.Registry
+       cluster    *arvados.Cluster
+       volume     arvados.Volume
+       logger     logrus.FieldLogger
+       metrics    *volumeMetricsVecs
+       registry   *prometheus.Registry
+       bufferPool *bufferPool
 }
 
 func (s *genericVolumeSuite) setup(t TB) {
@@ -118,10 +100,18 @@ func (s *genericVolumeSuite) setup(t TB) {
        s.logger = ctxlog.TestLogger(t)
        s.registry = prometheus.NewRegistry()
        s.metrics = newVolumeMetricsVecs(s.registry)
+       s.bufferPool = newBufferPool(s.logger, 8, s.registry)
 }
 
 func (s *genericVolumeSuite) newVolume(t TB, factory TestableVolumeFactory) TestableVolume {
-       return factory(t, s.cluster, s.volume, s.logger, s.metrics)
+       return factory(t, newVolumeParams{
+               UUID:         "zzzzz-nyw5e-999999999999999",
+               Cluster:      s.cluster,
+               ConfigVolume: s.volume,
+               Logger:       s.logger,
+               MetricsVecs:  s.metrics,
+               BufferPool:   s.bufferPool,
+       })
 }
 
 // Put a test block, get it and verify content
@@ -131,95 +121,30 @@ func (s *genericVolumeSuite) testGet(t TB, factory TestableVolumeFactory) {
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       v.PutRaw(TestHash, TestBlock)
-
-       buf := make([]byte, BlockSize)
-       n, err := v.Get(context.Background(), TestHash, buf)
+       err := v.BlockWrite(context.Background(), TestHash, TestBlock)
        if err != nil {
-               t.Fatal(err)
-       }
-
-       if bytes.Compare(buf[:n], TestBlock) != 0 {
-               t.Errorf("expected %s, got %s", string(TestBlock), string(buf))
-       }
-}
-
-// Invoke get on a block that does not exist in volume; should result in error
-// Test should pass for both writable and read-only volumes
-func (s *genericVolumeSuite) testGetNoSuchBlock(t TB, factory TestableVolumeFactory) {
-       s.setup(t)
-       v := s.newVolume(t, factory)
-       defer v.Teardown()
-
-       buf := make([]byte, BlockSize)
-       if _, err := v.Get(context.Background(), TestHash2, buf); err == nil {
-               t.Errorf("Expected error while getting non-existing block %v", TestHash2)
-       }
-}
-
-// Compare() should return os.ErrNotExist if the block does not exist.
-// Otherwise, writing new data causes CompareAndTouch() to generate
-// error logs even though everything is working fine.
-func (s *genericVolumeSuite) testCompareNonexistent(t TB, factory TestableVolumeFactory) {
-       s.setup(t)
-       v := s.newVolume(t, factory)
-       defer v.Teardown()
-
-       err := v.Compare(context.Background(), TestHash, TestBlock)
-       if err != os.ErrNotExist {
-               t.Errorf("Got err %T %q, expected os.ErrNotExist", err, err)
+               t.Error(err)
        }
-}
 
-// Put a test block and compare the locator with same content
-// Test should pass for both writable and read-only volumes
-func (s *genericVolumeSuite) testCompareSameContent(t TB, factory TestableVolumeFactory, testHash string, testData []byte) {
-       s.setup(t)
-       v := s.newVolume(t, factory)
-       defer v.Teardown()
-
-       v.PutRaw(testHash, testData)
-
-       // Compare the block locator with same content
-       err := v.Compare(context.Background(), testHash, testData)
+       buf := &brbuffer{}
+       err = v.BlockRead(context.Background(), TestHash, buf)
        if err != nil {
-               t.Errorf("Got err %q, expected nil", err)
+               t.Error(err)
        }
-}
-
-// Test behavior of Compare() when stored data matches expected
-// checksum but differs from new data we need to store. Requires
-// testHash = md5(testDataA).
-//
-// Test should pass for both writable and read-only volumes
-func (s *genericVolumeSuite) testCompareWithCollision(t TB, factory TestableVolumeFactory, testHash string, testDataA, testDataB []byte) {
-       s.setup(t)
-       v := s.newVolume(t, factory)
-       defer v.Teardown()
-
-       v.PutRaw(testHash, testDataA)
-
-       // Compare the block locator with different content; collision
-       err := v.Compare(context.Background(), TestHash, testDataB)
-       if err == nil {
-               t.Errorf("Got err nil, expected error due to collision")
+       if bytes.Compare(buf.Bytes(), TestBlock) != 0 {
+               t.Errorf("expected %s, got %s", "foo", buf.String())
        }
 }
 
-// Test behavior of Compare() when stored data has become
-// corrupted. Requires testHash = md5(testDataA) != md5(testDataB).
-//
+// Invoke get on a block that does not exist in volume; should result in error
 // Test should pass for both writable and read-only volumes
-func (s *genericVolumeSuite) testCompareWithCorruptStoredData(t TB, factory TestableVolumeFactory, testHash string, testDataA, testDataB []byte) {
+func (s *genericVolumeSuite) testGetNoSuchBlock(t TB, factory TestableVolumeFactory) {
        s.setup(t)
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       v.PutRaw(TestHash, testDataB)
-
-       err := v.Compare(context.Background(), testHash, testDataA)
-       if err == nil || err == CollisionError {
-               t.Errorf("Got err %+v, expected non-collision error", err)
+       if err := v.BlockRead(context.Background(), barHash, brdiscard); err == nil {
+               t.Errorf("Expected error while getting non-existing block %v", barHash)
        }
 }
 
@@ -230,12 +155,12 @@ func (s *genericVolumeSuite) testPutBlockWithSameContent(t TB, factory TestableV
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       err := v.Put(context.Background(), testHash, testData)
+       err := v.BlockWrite(context.Background(), testHash, testData)
        if err != nil {
                t.Errorf("Got err putting block %q: %q, expected nil", TestBlock, err)
        }
 
-       err = v.Put(context.Background(), testHash, testData)
+       err = v.BlockWrite(context.Background(), testHash, testData)
        if err != nil {
                t.Errorf("Got err putting block second time %q: %q, expected nil", TestBlock, err)
        }
@@ -248,23 +173,23 @@ func (s *genericVolumeSuite) testPutBlockWithDifferentContent(t TB, factory Test
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       v.PutRaw(testHash, testDataA)
+       v.BlockWrite(context.Background(), testHash, testDataA)
 
-       putErr := v.Put(context.Background(), testHash, testDataB)
-       buf := make([]byte, BlockSize)
-       n, getErr := v.Get(context.Background(), testHash, buf)
+       putErr := v.BlockWrite(context.Background(), testHash, testDataB)
+       buf := &brbuffer{}
+       getErr := v.BlockRead(context.Background(), testHash, buf)
        if putErr == nil {
                // Put must not return a nil error unless it has
                // overwritten the existing data.
-               if bytes.Compare(buf[:n], testDataB) != 0 {
-                       t.Errorf("Put succeeded but Get returned %+q, expected %+q", buf[:n], testDataB)
+               if buf.String() != string(testDataB) {
+                       t.Errorf("Put succeeded but Get returned %+q, expected %+q", buf, testDataB)
                }
        } else {
                // It is permissible for Put to fail, but it must
                // leave us with either the original data, the new
                // data, or nothing at all.
-               if getErr == nil && bytes.Compare(buf[:n], testDataA) != 0 && bytes.Compare(buf[:n], testDataB) != 0 {
-                       t.Errorf("Put failed but Get returned %+q, which is neither %+q nor %+q", buf[:n], testDataA, testDataB)
+               if getErr == nil && buf.String() != string(testDataA) && buf.String() != string(testDataB) {
+                       t.Errorf("Put failed but Get returned %+q, which is neither %+q nor %+q", buf, testDataA, testDataB)
                }
        }
 }
@@ -276,66 +201,67 @@ func (s *genericVolumeSuite) testPutMultipleBlocks(t TB, factory TestableVolumeF
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       err := v.Put(context.Background(), TestHash, TestBlock)
+       err := v.BlockWrite(context.Background(), TestHash, TestBlock)
        if err != nil {
                t.Errorf("Got err putting block %q: %q, expected nil", TestBlock, err)
        }
 
-       err = v.Put(context.Background(), TestHash2, TestBlock2)
+       err = v.BlockWrite(context.Background(), TestHash2, TestBlock2)
        if err != nil {
                t.Errorf("Got err putting block %q: %q, expected nil", TestBlock2, err)
        }
 
-       err = v.Put(context.Background(), TestHash3, TestBlock3)
+       err = v.BlockWrite(context.Background(), TestHash3, TestBlock3)
        if err != nil {
                t.Errorf("Got err putting block %q: %q, expected nil", TestBlock3, err)
        }
 
-       data := make([]byte, BlockSize)
-       n, err := v.Get(context.Background(), TestHash, data)
+       buf := &brbuffer{}
+       err = v.BlockRead(context.Background(), TestHash, buf)
        if err != nil {
                t.Error(err)
        } else {
-               if bytes.Compare(data[:n], TestBlock) != 0 {
-                       t.Errorf("Block present, but got %+q, expected %+q", data[:n], TestBlock)
+               if bytes.Compare(buf.Bytes(), TestBlock) != 0 {
+                       t.Errorf("Block present, but got %+q, expected %+q", buf, TestBlock)
                }
        }
 
-       n, err = v.Get(context.Background(), TestHash2, data)
+       buf.Reset()
+       err = v.BlockRead(context.Background(), TestHash2, buf)
        if err != nil {
                t.Error(err)
        } else {
-               if bytes.Compare(data[:n], TestBlock2) != 0 {
-                       t.Errorf("Block present, but got %+q, expected %+q", data[:n], TestBlock2)
+               if bytes.Compare(buf.Bytes(), TestBlock2) != 0 {
+                       t.Errorf("Block present, but got %+q, expected %+q", buf, TestBlock2)
                }
        }
 
-       n, err = v.Get(context.Background(), TestHash3, data)
+       buf.Reset()
+       err = v.BlockRead(context.Background(), TestHash3, buf)
        if err != nil {
                t.Error(err)
        } else {
-               if bytes.Compare(data[:n], TestBlock3) != 0 {
-                       t.Errorf("Block present, but to %+q, expected %+q", data[:n], TestBlock3)
+               if bytes.Compare(buf.Bytes(), TestBlock3) != 0 {
+                       t.Errorf("Block present, but to %+q, expected %+q", buf, TestBlock3)
                }
        }
 }
 
-// testPutAndTouch
-//   Test that when applying PUT to a block that already exists,
-//   the block's modification time is updated.
-// Test is intended for only writable volumes
+// testPutAndTouch checks that when applying PUT to a block that
+// already exists, the block's modification time is updated.  Intended
+// for only writable volumes.
 func (s *genericVolumeSuite) testPutAndTouch(t TB, factory TestableVolumeFactory) {
        s.setup(t)
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       if err := v.Put(context.Background(), TestHash, TestBlock); err != nil {
+       if err := v.BlockWrite(context.Background(), TestHash, TestBlock); err != nil {
                t.Error(err)
        }
 
        // We'll verify { t0 < threshold < t1 }, where t0 is the
-       // existing block's timestamp on disk before Put() and t1 is
-       // its timestamp after Put().
+       // existing block's timestamp on disk before BlockWrite() and t1 is
+       // its timestamp after BlockWrite().
        threshold := time.Now().Add(-time.Second)
 
        // Set the stored block's mtime far enough in the past that we
@@ -349,7 +275,7 @@ func (s *genericVolumeSuite) testPutAndTouch(t TB, factory TestableVolumeFactory
        }
 
        // Write the same block again.
-       if err := v.Put(context.Background(), TestHash, TestBlock); err != nil {
+       if err := v.BlockWrite(context.Background(), TestHash, TestBlock); err != nil {
                t.Error(err)
        }
 
@@ -368,7 +294,7 @@ func (s *genericVolumeSuite) testTouchNoSuchBlock(t TB, factory TestableVolumeFa
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       if err := v.Touch(TestHash); err == nil {
+       if err := v.BlockTouch(TestHash); err == nil {
                t.Error("Expected error when attempted to touch a non-existing block")
        }
 }
@@ -385,12 +311,12 @@ func (s *genericVolumeSuite) testMtimeNoSuchBlock(t TB, factory TestableVolumeFa
        }
 }
 
-// Put a few blocks and invoke IndexTo with:
+// Put a few blocks and invoke Index with:
 // * no prefix
 // * with a prefix
 // * with no such prefix
 // Test should pass for both writable and read-only volumes
-func (s *genericVolumeSuite) testIndexTo(t TB, factory TestableVolumeFactory) {
+func (s *genericVolumeSuite) testIndex(t TB, factory TestableVolumeFactory) {
        s.setup(t)
        v := s.newVolume(t, factory)
        defer v.Teardown()
@@ -401,9 +327,9 @@ func (s *genericVolumeSuite) testIndexTo(t TB, factory TestableVolumeFactory) {
        minMtime := time.Now().UTC().UnixNano()
        minMtime -= minMtime % 1e9
 
-       v.PutRaw(TestHash, TestBlock)
-       v.PutRaw(TestHash2, TestBlock2)
-       v.PutRaw(TestHash3, TestBlock3)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash2, TestBlock2)
+       v.BlockWrite(context.Background(), TestHash3, TestBlock3)
 
        maxMtime := time.Now().UTC().UnixNano()
        if maxMtime%1e9 > 0 {
@@ -413,13 +339,13 @@ func (s *genericVolumeSuite) testIndexTo(t TB, factory TestableVolumeFactory) {
 
        // Blocks whose names aren't Keep hashes should be omitted from
        // index
-       v.PutRaw("fffffffffnotreallyahashfffffffff", nil)
-       v.PutRaw("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", nil)
-       v.PutRaw("f0000000000000000000000000000000f", nil)
-       v.PutRaw("f00", nil)
+       v.BlockWrite(context.Background(), "fffffffffnotreallyahashfffffffff", nil)
+       v.BlockWrite(context.Background(), "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", nil)
+       v.BlockWrite(context.Background(), "f0000000000000000000000000000000f", nil)
+       v.BlockWrite(context.Background(), "f00", nil)
 
        buf := new(bytes.Buffer)
-       v.IndexTo("", buf)
+       v.Index(context.Background(), "", buf)
        indexRows := strings.Split(string(buf.Bytes()), "\n")
        sort.Strings(indexRows)
        sortedIndex := strings.Join(indexRows, "\n")
@@ -442,7 +368,7 @@ func (s *genericVolumeSuite) testIndexTo(t TB, factory TestableVolumeFactory) {
 
        for _, prefix := range []string{"f", "f15", "f15ac"} {
                buf = new(bytes.Buffer)
-               v.IndexTo(prefix, buf)
+               v.Index(context.Background(), prefix, buf)
 
                m, err := regexp.MatchString(`^`+TestHash2+`\+\d+ \d+\n$`, string(buf.Bytes()))
                if err != nil {
@@ -454,11 +380,11 @@ func (s *genericVolumeSuite) testIndexTo(t TB, factory TestableVolumeFactory) {
 
        for _, prefix := range []string{"zero", "zip", "zilch"} {
                buf = new(bytes.Buffer)
-               err := v.IndexTo(prefix, buf)
+               err := v.Index(context.Background(), prefix, buf)
                if err != nil {
-                       t.Errorf("Got error on IndexTo with no such prefix %v", err.Error())
+                       t.Errorf("Got error on Index with no such prefix %v", err.Error())
                } else if buf.Len() != 0 {
-                       t.Errorf("Expected empty list for IndexTo with no such prefix %s", prefix)
+                       t.Errorf("Expected empty list for Index with no such prefix %s", prefix)
                }
        }
 }
@@ -472,17 +398,17 @@ func (s *genericVolumeSuite) testDeleteNewBlock(t TB, factory TestableVolumeFact
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       v.Put(context.Background(), TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
 
-       if err := v.Trash(TestHash); err != nil {
+       if err := v.BlockTrash(TestHash); err != nil {
                t.Error(err)
        }
-       data := make([]byte, BlockSize)
-       n, err := v.Get(context.Background(), TestHash, data)
+       buf := &brbuffer{}
+       err := v.BlockRead(context.Background(), TestHash, buf)
        if err != nil {
                t.Error(err)
-       } else if bytes.Compare(data[:n], TestBlock) != 0 {
-               t.Errorf("Got data %+q, expected %+q", data[:n], TestBlock)
+       } else if buf.String() != string(TestBlock) {
+               t.Errorf("Got data %+q, expected %+q", buf.String(), TestBlock)
        }
 }
 
@@ -495,36 +421,30 @@ func (s *genericVolumeSuite) testDeleteOldBlock(t TB, factory TestableVolumeFact
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       v.Put(context.Background(), TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
        v.TouchWithDate(TestHash, time.Now().Add(-2*s.cluster.Collections.BlobSigningTTL.Duration()))
 
-       if err := v.Trash(TestHash); err != nil {
+       if err := v.BlockTrash(TestHash); err != nil {
                t.Error(err)
        }
-       data := make([]byte, BlockSize)
-       if _, err := v.Get(context.Background(), TestHash, data); err == nil || !os.IsNotExist(err) {
+       if err := v.BlockRead(context.Background(), TestHash, brdiscard); err == nil || !os.IsNotExist(err) {
                t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
 
        _, err := v.Mtime(TestHash)
        if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
-       }
-
-       err = v.Compare(context.Background(), TestHash, TestBlock)
-       if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
 
        indexBuf := new(bytes.Buffer)
-       v.IndexTo("", indexBuf)
+       v.Index(context.Background(), "", indexBuf)
        if strings.Contains(string(indexBuf.Bytes()), TestHash) {
-               t.Fatalf("Found trashed block in IndexTo")
+               t.Errorf("Found trashed block in Index")
        }
 
-       err = v.Touch(TestHash)
+       err = v.BlockTouch(TestHash)
        if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
 }
 
@@ -535,33 +455,11 @@ func (s *genericVolumeSuite) testDeleteNoSuchBlock(t TB, factory TestableVolumeF
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       if err := v.Trash(TestHash2); err == nil {
+       if err := v.BlockTrash(TestHash2); err == nil {
                t.Errorf("Expected error when attempting to delete a non-existing block")
        }
 }
 
-// Invoke Status and verify that VolumeStatus is returned
-// Test should pass for both writable and read-only volumes
-func (s *genericVolumeSuite) testStatus(t TB, factory TestableVolumeFactory) {
-       s.setup(t)
-       v := s.newVolume(t, factory)
-       defer v.Teardown()
-
-       // Get node status and make a basic sanity check.
-       status := v.Status()
-       if status.DeviceNum == 0 {
-               t.Errorf("uninitialized device_num in %v", status)
-       }
-
-       if status.BytesFree == 0 {
-               t.Errorf("uninitialized bytes_free in %v", status)
-       }
-
-       if status.BytesUsed == 0 {
-               t.Errorf("uninitialized bytes_used in %v", status)
-       }
-}
-
 func getValueFrom(cv *prometheus.CounterVec, lbls prometheus.Labels) float64 {
        c, _ := cv.GetMetricWith(lbls)
        pb := &dto.Metric{}
@@ -576,7 +474,7 @@ func (s *genericVolumeSuite) testMetrics(t TB, readonly bool, factory TestableVo
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       opsC, _, ioC := s.metrics.getCounterVecsFor(prometheus.Labels{"device_id": v.GetDeviceID()})
+       opsC, _, ioC := s.metrics.getCounterVecsFor(prometheus.Labels{"device_id": v.DeviceID()})
 
        if ioC == nil {
                t.Error("ioBytes CounterVec is nil")
@@ -601,7 +499,7 @@ func (s *genericVolumeSuite) testMetrics(t TB, readonly bool, factory TestableVo
 
        // Test Put if volume is writable
        if !readonly {
-               err = v.Put(context.Background(), TestHash, TestBlock)
+               err = v.BlockWrite(context.Background(), TestHash, TestBlock)
                if err != nil {
                        t.Errorf("Got err putting block %q: %q, expected nil", TestBlock, err)
                }
@@ -615,13 +513,12 @@ func (s *genericVolumeSuite) testMetrics(t TB, readonly bool, factory TestableVo
                        t.Error("ioBytes{direction=out} counter shouldn't be zero")
                }
        } else {
-               v.PutRaw(TestHash, TestBlock)
+               v.BlockWrite(context.Background(), TestHash, TestBlock)
        }
 
-       buf := make([]byte, BlockSize)
-       _, err = v.Get(context.Background(), TestHash, buf)
+       err = v.BlockRead(context.Background(), TestHash, brdiscard)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
        // Check that the operations counter increased
@@ -635,63 +532,6 @@ func (s *genericVolumeSuite) testMetrics(t TB, readonly bool, factory TestableVo
        }
 }
 
-// Invoke String for the volume; expect non-empty result
-// Test should pass for both writable and read-only volumes
-func (s *genericVolumeSuite) testString(t TB, factory TestableVolumeFactory) {
-       s.setup(t)
-       v := s.newVolume(t, factory)
-       defer v.Teardown()
-
-       if id := v.String(); len(id) == 0 {
-               t.Error("Got empty string for v.String()")
-       }
-}
-
-// Putting, updating, touching, and deleting blocks from a read-only volume result in error.
-// Test is intended for only read-only volumes
-func (s *genericVolumeSuite) testUpdateReadOnly(t TB, factory TestableVolumeFactory) {
-       s.setup(t)
-       v := s.newVolume(t, factory)
-       defer v.Teardown()
-
-       v.PutRaw(TestHash, TestBlock)
-       buf := make([]byte, BlockSize)
-
-       // Get from read-only volume should succeed
-       _, err := v.Get(context.Background(), TestHash, buf)
-       if err != nil {
-               t.Errorf("got err %v, expected nil", err)
-       }
-
-       // Put a new block to read-only volume should result in error
-       err = v.Put(context.Background(), TestHash2, TestBlock2)
-       if err == nil {
-               t.Errorf("Expected error when putting block in a read-only volume")
-       }
-       _, err = v.Get(context.Background(), TestHash2, buf)
-       if err == nil {
-               t.Errorf("Expected error when getting block whose put in read-only volume failed")
-       }
-
-       // Touch a block in read-only volume should result in error
-       err = v.Touch(TestHash)
-       if err == nil {
-               t.Errorf("Expected error when touching block in a read-only volume")
-       }
-
-       // Delete a block from a read-only volume should result in error
-       err = v.Trash(TestHash)
-       if err == nil {
-               t.Errorf("Expected error when deleting block from a read-only volume")
-       }
-
-       // Overwriting an existing block in read-only volume should result in error
-       err = v.Put(context.Background(), TestHash, TestBlock)
-       if err == nil {
-               t.Errorf("Expected error when putting block in a read-only volume")
-       }
-}
-
 // Launch concurrent Gets
 // Test should pass for both writable and read-only volumes
 func (s *genericVolumeSuite) testGetConcurrent(t TB, factory TestableVolumeFactory) {
@@ -699,43 +539,43 @@ func (s *genericVolumeSuite) testGetConcurrent(t TB, factory TestableVolumeFacto
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       v.PutRaw(TestHash, TestBlock)
-       v.PutRaw(TestHash2, TestBlock2)
-       v.PutRaw(TestHash3, TestBlock3)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash2, TestBlock2)
+       v.BlockWrite(context.Background(), TestHash3, TestBlock3)
 
        sem := make(chan int)
        go func() {
-               buf := make([]byte, BlockSize)
-               n, err := v.Get(context.Background(), TestHash, buf)
+               buf := &brbuffer{}
+               err := v.BlockRead(context.Background(), TestHash, buf)
                if err != nil {
                        t.Errorf("err1: %v", err)
                }
-               if bytes.Compare(buf[:n], TestBlock) != 0 {
-                       t.Errorf("buf should be %s, is %s", string(TestBlock), string(buf[:n]))
+               if buf.String() != string(TestBlock) {
+                       t.Errorf("buf should be %s, is %s", TestBlock, buf)
                }
                sem <- 1
        }()
 
        go func() {
-               buf := make([]byte, BlockSize)
-               n, err := v.Get(context.Background(), TestHash2, buf)
+               buf := &brbuffer{}
+               err := v.BlockRead(context.Background(), TestHash2, buf)
                if err != nil {
                        t.Errorf("err2: %v", err)
                }
-               if bytes.Compare(buf[:n], TestBlock2) != 0 {
-                       t.Errorf("buf should be %s, is %s", string(TestBlock2), string(buf[:n]))
+               if buf.String() != string(TestBlock2) {
+                       t.Errorf("buf should be %s, is %s", TestBlock2, buf)
                }
                sem <- 1
        }()
 
        go func() {
-               buf := make([]byte, BlockSize)
-               n, err := v.Get(context.Background(), TestHash3, buf)
+               buf := &brbuffer{}
+               err := v.BlockRead(context.Background(), TestHash3, buf)
                if err != nil {
                        t.Errorf("err3: %v", err)
                }
-               if bytes.Compare(buf[:n], TestBlock3) != 0 {
-                       t.Errorf("buf should be %s, is %s", string(TestBlock3), string(buf[:n]))
+               if buf.String() != string(TestBlock3) {
+                       t.Errorf("buf should be %s, is %s", TestBlock3, buf)
                }
                sem <- 1
        }()
@@ -753,60 +593,38 @@ func (s *genericVolumeSuite) testPutConcurrent(t TB, factory TestableVolumeFacto
        v := s.newVolume(t, factory)
        defer v.Teardown()
 
-       sem := make(chan int)
-       go func(sem chan int) {
-               err := v.Put(context.Background(), TestHash, TestBlock)
-               if err != nil {
-                       t.Errorf("err1: %v", err)
-               }
-               sem <- 1
-       }(sem)
-
-       go func(sem chan int) {
-               err := v.Put(context.Background(), TestHash2, TestBlock2)
+       blks := []struct {
+               hash string
+               data []byte
+       }{
+               {hash: TestHash, data: TestBlock},
+               {hash: TestHash2, data: TestBlock2},
+               {hash: TestHash3, data: TestBlock3},
+       }
+
+       var wg sync.WaitGroup
+       for _, blk := range blks {
+               blk := blk
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       err := v.BlockWrite(context.Background(), blk.hash, blk.data)
+                       if err != nil {
+                               t.Errorf("%s: %v", blk.hash, err)
+                       }
+               }()
+       }
+       wg.Wait()
+
+       // Check that we actually wrote the blocks.
+       for _, blk := range blks {
+               buf := &brbuffer{}
+               err := v.BlockRead(context.Background(), blk.hash, buf)
                if err != nil {
-                       t.Errorf("err2: %v", err)
+                       t.Errorf("get %s: %v", blk.hash, err)
+               } else if buf.String() != string(blk.data) {
+                       t.Errorf("get %s: expected %s, got %s", blk.hash, blk.data, buf)
                }
-               sem <- 1
-       }(sem)
-
-       go func(sem chan int) {
-               err := v.Put(context.Background(), TestHash3, TestBlock3)
-               if err != nil {
-                       t.Errorf("err3: %v", err)
-               }
-               sem <- 1
-       }(sem)
-
-       // Wait for all goroutines to finish
-       for done := 0; done < 3; done++ {
-               <-sem
-       }
-
-       // Double check that we actually wrote the blocks we expected to write.
-       buf := make([]byte, BlockSize)
-       n, err := v.Get(context.Background(), TestHash, buf)
-       if err != nil {
-               t.Errorf("Get #1: %v", err)
-       }
-       if bytes.Compare(buf[:n], TestBlock) != 0 {
-               t.Errorf("Get #1: expected %s, got %s", string(TestBlock), string(buf[:n]))
-       }
-
-       n, err = v.Get(context.Background(), TestHash2, buf)
-       if err != nil {
-               t.Errorf("Get #2: %v", err)
-       }
-       if bytes.Compare(buf[:n], TestBlock2) != 0 {
-               t.Errorf("Get #2: expected %s, got %s", string(TestBlock2), string(buf[:n]))
-       }
-
-       n, err = v.Get(context.Background(), TestHash3, buf)
-       if err != nil {
-               t.Errorf("Get #3: %v", err)
-       }
-       if bytes.Compare(buf[:n], TestBlock3) != 0 {
-               t.Errorf("Get #3: expected %s, got %s", string(TestBlock3), string(buf[:n]))
        }
 }
 
@@ -820,17 +638,18 @@ func (s *genericVolumeSuite) testPutFullBlock(t TB, factory TestableVolumeFactor
        wdata[0] = 'a'
        wdata[BlockSize-1] = 'z'
        hash := fmt.Sprintf("%x", md5.Sum(wdata))
-       err := v.Put(context.Background(), hash, wdata)
+       err := v.BlockWrite(context.Background(), hash, wdata)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
-       buf := make([]byte, BlockSize)
-       n, err := v.Get(context.Background(), hash, buf)
+
+       buf := &brbuffer{}
+       err = v.BlockRead(context.Background(), hash, buf)
        if err != nil {
                t.Error(err)
        }
-       if bytes.Compare(buf[:n], wdata) != 0 {
-               t.Error("buf %+q != wdata %+q", buf[:n], wdata)
+       if buf.String() != string(wdata) {
+               t.Errorf("buf (len %d) != wdata (len %d)", buf.Len(), len(wdata))
        }
 }
 
@@ -845,48 +664,44 @@ func (s *genericVolumeSuite) testTrashUntrash(t TB, readonly bool, factory Testa
        defer v.Teardown()
 
        // put block and backdate it
-       v.PutRaw(TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
        v.TouchWithDate(TestHash, time.Now().Add(-2*s.cluster.Collections.BlobSigningTTL.Duration()))
 
-       buf := make([]byte, BlockSize)
-       n, err := v.Get(context.Background(), TestHash, buf)
+       buf := &brbuffer{}
+       err := v.BlockRead(context.Background(), TestHash, buf)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
-       if bytes.Compare(buf[:n], TestBlock) != 0 {
-               t.Errorf("Got data %+q, expected %+q", buf[:n], TestBlock)
+       if buf.String() != string(TestBlock) {
+               t.Errorf("Got data %+q, expected %+q", buf, TestBlock)
        }
 
        // Trash
-       err = v.Trash(TestHash)
-       if readonly {
-               if err != MethodDisabledError {
-                       t.Fatal(err)
-               }
-       } else if err != nil {
-               if err != ErrNotImplemented {
-                       t.Fatal(err)
-               }
-       } else {
-               _, err = v.Get(context.Background(), TestHash, buf)
-               if err == nil || !os.IsNotExist(err) {
-                       t.Errorf("os.IsNotExist(%v) should have been true", err)
-               }
+       err = v.BlockTrash(TestHash)
+       if err != nil {
+               t.Error(err)
+               return
+       }
+       buf.Reset()
+       err = v.BlockRead(context.Background(), TestHash, buf)
+       if err == nil || !os.IsNotExist(err) {
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
+       }
 
-               // Untrash
-               err = v.Untrash(TestHash)
-               if err != nil {
-                       t.Fatal(err)
-               }
+       // Untrash
+       err = v.BlockUntrash(TestHash)
+       if err != nil {
+               t.Error(err)
        }
 
        // Get the block - after trash and untrash sequence
-       n, err = v.Get(context.Background(), TestHash, buf)
+       buf.Reset()
+       err = v.BlockRead(context.Background(), TestHash, buf)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
-       if bytes.Compare(buf[:n], TestBlock) != 0 {
-               t.Errorf("Got data %+q, expected %+q", buf[:n], TestBlock)
+       if buf.String() != string(TestBlock) {
+               t.Errorf("Got data %+q, expected %+q", buf, TestBlock)
        }
 }
 
@@ -896,13 +711,13 @@ func (s *genericVolumeSuite) testTrashEmptyTrashUntrash(t TB, factory TestableVo
        defer v.Teardown()
 
        checkGet := func() error {
-               buf := make([]byte, BlockSize)
-               n, err := v.Get(context.Background(), TestHash, buf)
+               buf := &brbuffer{}
+               err := v.BlockRead(context.Background(), TestHash, buf)
                if err != nil {
                        return err
                }
-               if bytes.Compare(buf[:n], TestBlock) != 0 {
-                       t.Fatalf("Got data %+q, expected %+q", buf[:n], TestBlock)
+               if buf.String() != string(TestBlock) {
+                       t.Errorf("Got data %+q, expected %+q", buf, TestBlock)
                }
 
                _, err = v.Mtime(TestHash)
@@ -910,13 +725,8 @@ func (s *genericVolumeSuite) testTrashEmptyTrashUntrash(t TB, factory TestableVo
                        return err
                }
 
-               err = v.Compare(context.Background(), TestHash, TestBlock)
-               if err != nil {
-                       return err
-               }
-
                indexBuf := new(bytes.Buffer)
-               v.IndexTo("", indexBuf)
+               v.Index(context.Background(), "", indexBuf)
                if !strings.Contains(string(indexBuf.Bytes()), TestHash) {
                        return os.ErrNotExist
                }
@@ -928,50 +738,47 @@ func (s *genericVolumeSuite) testTrashEmptyTrashUntrash(t TB, factory TestableVo
 
        s.cluster.Collections.BlobTrashLifetime.Set("1h")
 
-       v.PutRaw(TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
        v.TouchWithDate(TestHash, time.Now().Add(-2*s.cluster.Collections.BlobSigningTTL.Duration()))
 
        err := checkGet()
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
        // Trash the block
-       err = v.Trash(TestHash)
-       if err == MethodDisabledError || err == ErrNotImplemented {
-               // Skip the trash tests for read-only volumes, and
-               // volume types that don't support
-               // BlobTrashLifetime>0.
-               return
+       err = v.BlockTrash(TestHash)
+       if err != nil {
+               t.Error(err)
        }
 
        err = checkGet()
        if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
 
-       err = v.Touch(TestHash)
+       err = v.BlockTouch(TestHash)
        if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
 
        v.EmptyTrash()
 
        // Even after emptying the trash, we can untrash our block
        // because the deadline hasn't been reached.
-       err = v.Untrash(TestHash)
+       err = v.BlockUntrash(TestHash)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
        err = checkGet()
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
-       err = v.Touch(TestHash)
+       err = v.BlockTouch(TestHash)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
        // Because we Touch'ed, need to backdate again for next set of tests
@@ -980,16 +787,16 @@ func (s *genericVolumeSuite) testTrashEmptyTrashUntrash(t TB, factory TestableVo
        // If the only block in the trash has already been untrashed,
        // most volumes will fail a subsequent Untrash with a 404, but
        // it's also acceptable for Untrash to succeed.
-       err = v.Untrash(TestHash)
+       err = v.BlockUntrash(TestHash)
        if err != nil && !os.IsNotExist(err) {
-               t.Fatalf("Expected success or os.IsNotExist(), but got: %v", err)
+               t.Errorf("Expected success or os.IsNotExist(), but got: %v", err)
        }
 
        // The additional Untrash should not interfere with our
        // already-untrashed copy.
        err = checkGet()
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
        // Untrash might have updated the timestamp, so backdate again
@@ -999,74 +806,74 @@ func (s *genericVolumeSuite) testTrashEmptyTrashUntrash(t TB, factory TestableVo
 
        s.cluster.Collections.BlobTrashLifetime.Set("1ns")
 
-       err = v.Trash(TestHash)
+       err = v.BlockTrash(TestHash)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
        err = checkGet()
        if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
 
        // Even though 1ns has passed, we can untrash because we
        // haven't called EmptyTrash yet.
-       err = v.Untrash(TestHash)
+       err = v.BlockUntrash(TestHash)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
        err = checkGet()
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
        // Trash it again, and this time call EmptyTrash so it really
        // goes away.
        // (In Azure volumes, un/trash changes Mtime, so first backdate again)
        v.TouchWithDate(TestHash, time.Now().Add(-2*s.cluster.Collections.BlobSigningTTL.Duration()))
-       _ = v.Trash(TestHash)
+       _ = v.BlockTrash(TestHash)
        err = checkGet()
        if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
        v.EmptyTrash()
 
        // Untrash won't find it
-       err = v.Untrash(TestHash)
+       err = v.BlockUntrash(TestHash)
        if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
 
        // Get block won't find it
        err = checkGet()
        if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
 
        // Third set: If the same data block gets written again after
        // being trashed, and then the trash gets emptied, the newer
        // un-trashed copy doesn't get deleted along with it.
 
-       v.PutRaw(TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
        v.TouchWithDate(TestHash, time.Now().Add(-2*s.cluster.Collections.BlobSigningTTL.Duration()))
 
        s.cluster.Collections.BlobTrashLifetime.Set("1ns")
-       err = v.Trash(TestHash)
+       err = v.BlockTrash(TestHash)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
        err = checkGet()
        if err == nil || !os.IsNotExist(err) {
-               t.Fatalf("os.IsNotExist(%v) should have been true", err)
+               t.Errorf("os.IsNotExist(%v) should have been true", err)
        }
 
-       v.PutRaw(TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
        v.TouchWithDate(TestHash, time.Now().Add(-2*s.cluster.Collections.BlobSigningTTL.Duration()))
 
        // EmptyTrash should not delete the untrashed copy.
        v.EmptyTrash()
        err = checkGet()
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
        // Fourth set: If the same data block gets trashed twice with
@@ -1074,33 +881,33 @@ func (s *genericVolumeSuite) testTrashEmptyTrashUntrash(t TB, factory TestableVo
        // at intermediate time B (A < B < C), it is still possible to
        // untrash the block whose deadline is "C".
 
-       v.PutRaw(TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
        v.TouchWithDate(TestHash, time.Now().Add(-2*s.cluster.Collections.BlobSigningTTL.Duration()))
 
        s.cluster.Collections.BlobTrashLifetime.Set("1ns")
-       err = v.Trash(TestHash)
+       err = v.BlockTrash(TestHash)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
-       v.PutRaw(TestHash, TestBlock)
+       v.BlockWrite(context.Background(), TestHash, TestBlock)
        v.TouchWithDate(TestHash, time.Now().Add(-2*s.cluster.Collections.BlobSigningTTL.Duration()))
 
        s.cluster.Collections.BlobTrashLifetime.Set("1h")
-       err = v.Trash(TestHash)
+       err = v.BlockTrash(TestHash)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 
        // EmptyTrash should not prevent us from recovering the
        // time.Hour ("C") trash
        v.EmptyTrash()
-       err = v.Untrash(TestHash)
+       err = v.BlockUntrash(TestHash)
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
        err = checkGet()
        if err != nil {
-               t.Fatal(err)
+               t.Error(err)
        }
 }
index 950b3989aa0f6a72e20553f8505f6575a91b39c4..f64041b04852e7fa9ca71235b2716d84843771f3 100644 (file)
@@ -5,25 +5,13 @@
 package keepstore
 
 import (
-       "bytes"
-       "context"
-       "crypto/md5"
-       "errors"
-       "fmt"
-       "io"
-       "os"
-       "strings"
        "sync"
        "time"
-
-       "git.arvados.org/arvados.git/sdk/go/arvados"
-       "github.com/sirupsen/logrus"
 )
 
 var (
-       TestBlock       = []byte("The quick brown fox jumps over the lazy dog.")
-       TestHash        = "e4d909c290d0fb1ca068ffaddf22cbd0"
-       TestHashPutResp = "e4d909c290d0fb1ca068ffaddf22cbd0+44\n"
+       TestBlock = []byte("The quick brown fox jumps over the lazy dog.")
+       TestHash  = "e4d909c290d0fb1ca068ffaddf22cbd0"
 
        TestBlock2 = []byte("Pack my box with five dozen liquor jugs.")
        TestHash2  = "f15ac516f788aec4f30932ffb6395c39"
@@ -31,10 +19,6 @@ var (
        TestBlock3 = []byte("Now is the time for all good men to come to the aid of their country.")
        TestHash3  = "eed29bbffbc2dbe5e5ee0bb71888e61f"
 
-       // BadBlock is used to test collisions and corruption.
-       // It must not match any test hashes.
-       BadBlock = []byte("The magic words are squeamish ossifrage.")
-
        EmptyHash  = "d41d8cd98f00b204e9800998ecf8427e"
        EmptyBlock = []byte("")
 )
@@ -43,230 +27,64 @@ var (
 // underlying Volume, in order to test behavior in cases that are
 // impractical to achieve with a sequence of normal Volume operations.
 type TestableVolume interface {
-       Volume
-
-       // [Over]write content for a locator with the given data,
-       // bypassing all constraints like readonly and serialize.
-       PutRaw(locator string, data []byte)
+       volume
 
        // Returns the strings that a driver uses to record read/write operations.
        ReadWriteOperationLabelValues() (r, w string)
 
        // Specify the value Mtime() should return, until the next
-       // call to Touch, TouchWithDate, or Put.
-       TouchWithDate(locator string, lastPut time.Time)
+       // call to Touch, TouchWithDate, or BlockWrite.
+       TouchWithDate(locator string, lastBlockWrite time.Time)
 
        // Clean up, delete temporary files.
        Teardown()
 }
 
-func init() {
-       driver["mock"] = newMockVolume
-}
-
-// MockVolumes are test doubles for Volumes, used to test handlers.
-type MockVolume struct {
-       Store      map[string][]byte
-       Timestamps map[string]time.Time
-
-       // Bad volumes return an error for every operation.
-       Bad            bool
-       BadVolumeError error
-
-       // Touchable volumes' Touch() method succeeds for a locator
-       // that has been Put().
-       Touchable bool
-
-       // Gate is a "starting gate", allowing test cases to pause
-       // volume operations long enough to inspect state. Every
-       // operation (except Status) starts by receiving from
-       // Gate. Sending one value unblocks one operation; closing the
-       // channel unblocks all operations. By default, Gate is a
-       // closed channel, so all operations proceed without
-       // blocking. See trash_worker_test.go for an example.
-       Gate chan struct{} `json:"-"`
-
-       cluster *arvados.Cluster
-       volume  arvados.Volume
-       logger  logrus.FieldLogger
-       metrics *volumeMetricsVecs
-       called  map[string]int
-       mutex   sync.Mutex
-}
-
-// newMockVolume returns a non-Bad, non-Readonly, Touchable mock
-// volume.
-func newMockVolume(cluster *arvados.Cluster, volume arvados.Volume, logger logrus.FieldLogger, metrics *volumeMetricsVecs) (Volume, error) {
-       gate := make(chan struct{})
-       close(gate)
-       return &MockVolume{
-               Store:      make(map[string][]byte),
-               Timestamps: make(map[string]time.Time),
-               Bad:        false,
-               Touchable:  true,
-               called:     map[string]int{},
-               Gate:       gate,
-               cluster:    cluster,
-               volume:     volume,
-               logger:     logger,
-               metrics:    metrics,
-       }, nil
+// brbuffer is like bytes.Buffer, but it implements io.WriterAt.
+// Convenient for testing (volume)BlockRead implementations.
+type brbuffer struct {
+       mtx sync.Mutex
+       buf []byte
 }
 
-// CallCount returns how many times the named method has been called.
-func (v *MockVolume) CallCount(method string) int {
-       v.mutex.Lock()
-       defer v.mutex.Unlock()
-       c, ok := v.called[method]
-       if !ok {
-               return 0
+func (b *brbuffer) WriteAt(p []byte, offset int64) (int, error) {
+       b.mtx.Lock()
+       defer b.mtx.Unlock()
+       if short := int(offset) + len(p) - len(b.buf); short > 0 {
+               b.buf = append(b.buf, make([]byte, short)...)
        }
-       return c
+       return copy(b.buf[offset:], p), nil
 }
 
-func (v *MockVolume) gotCall(method string) {
-       v.mutex.Lock()
-       defer v.mutex.Unlock()
-       if _, ok := v.called[method]; !ok {
-               v.called[method] = 1
-       } else {
-               v.called[method]++
-       }
+func (b *brbuffer) Bytes() []byte {
+       b.mtx.Lock()
+       defer b.mtx.Unlock()
+       return b.buf
 }
 
-func (v *MockVolume) Compare(ctx context.Context, loc string, buf []byte) error {
-       v.gotCall("Compare")
-       <-v.Gate
-       if v.Bad {
-               return v.BadVolumeError
-       } else if block, ok := v.Store[loc]; ok {
-               if fmt.Sprintf("%x", md5.Sum(block)) != loc {
-                       return DiskHashError
-               }
-               if bytes.Compare(buf, block) != 0 {
-                       return CollisionError
-               }
-               return nil
-       } else {
-               return os.ErrNotExist
-       }
+func (b *brbuffer) String() string {
+       b.mtx.Lock()
+       defer b.mtx.Unlock()
+       return string(b.buf)
 }
 
-func (v *MockVolume) Get(ctx context.Context, loc string, buf []byte) (int, error) {
-       v.gotCall("Get")
-       <-v.Gate
-       if v.Bad {
-               return 0, v.BadVolumeError
-       } else if block, ok := v.Store[loc]; ok {
-               copy(buf[:len(block)], block)
-               return len(block), nil
-       }
-       return 0, os.ErrNotExist
+func (b *brbuffer) Len() int {
+       b.mtx.Lock()
+       defer b.mtx.Unlock()
+       return len(b.buf)
 }
 
-func (v *MockVolume) Put(ctx context.Context, loc string, block []byte) error {
-       v.gotCall("Put")
-       <-v.Gate
-       if v.Bad {
-               return v.BadVolumeError
-       }
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       v.Store[loc] = block
-       return v.Touch(loc)
+func (b *brbuffer) Reset() {
+       b.mtx.Lock()
+       defer b.mtx.Unlock()
+       b.buf = nil
 }
 
-func (v *MockVolume) Touch(loc string) error {
-       return v.TouchWithDate(loc, time.Now())
-}
+// a brdiscarder is like io.Discard, but it implements
+// io.WriterAt. Convenient for testing (volume)BlockRead
+// implementations when the output is not checked.
+type brdiscarder struct{}
 
-func (v *MockVolume) TouchWithDate(loc string, t time.Time) error {
-       v.gotCall("Touch")
-       <-v.Gate
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       if _, exists := v.Store[loc]; !exists {
-               return os.ErrNotExist
-       }
-       if v.Touchable {
-               v.Timestamps[loc] = t
-               return nil
-       }
-       return errors.New("Touch failed")
-}
+func (brdiscarder) WriteAt(p []byte, offset int64) (int, error) { return len(p), nil }
 
-func (v *MockVolume) Mtime(loc string) (time.Time, error) {
-       v.gotCall("Mtime")
-       <-v.Gate
-       var mtime time.Time
-       var err error
-       if v.Bad {
-               err = v.BadVolumeError
-       } else if t, ok := v.Timestamps[loc]; ok {
-               mtime = t
-       } else {
-               err = os.ErrNotExist
-       }
-       return mtime, err
-}
-
-func (v *MockVolume) IndexTo(prefix string, w io.Writer) error {
-       v.gotCall("IndexTo")
-       <-v.Gate
-       for loc, block := range v.Store {
-               if !IsValidLocator(loc) || !strings.HasPrefix(loc, prefix) {
-                       continue
-               }
-               _, err := fmt.Fprintf(w, "%s+%d %d\n",
-                       loc, len(block), 123456789)
-               if err != nil {
-                       return err
-               }
-       }
-       return nil
-}
-
-func (v *MockVolume) Trash(loc string) error {
-       v.gotCall("Delete")
-       <-v.Gate
-       if v.volume.ReadOnly {
-               return MethodDisabledError
-       }
-       if _, ok := v.Store[loc]; ok {
-               if time.Since(v.Timestamps[loc]) < time.Duration(v.cluster.Collections.BlobSigningTTL) {
-                       return nil
-               }
-               delete(v.Store, loc)
-               return nil
-       }
-       return os.ErrNotExist
-}
-
-func (v *MockVolume) GetDeviceID() string {
-       return "mock-device-id"
-}
-
-func (v *MockVolume) Untrash(loc string) error {
-       return nil
-}
-
-func (v *MockVolume) Status() *VolumeStatus {
-       var used uint64
-       for _, block := range v.Store {
-               used = used + uint64(len(block))
-       }
-       return &VolumeStatus{"/bogo", 123, 1000000 - used, used}
-}
-
-func (v *MockVolume) String() string {
-       return "[MockVolume]"
-}
-
-func (v *MockVolume) EmptyTrash() {
-}
-
-func (v *MockVolume) GetStorageClasses() []string {
-       return nil
-}
+var brdiscard = brdiscarder{}
diff --git a/services/keepstore/work_queue.go b/services/keepstore/work_queue.go
deleted file mode 100644 (file)
index 4c46ec8..0000000
+++ /dev/null
@@ -1,212 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-/* A WorkQueue is an asynchronous thread-safe queue manager.  It
-   provides a channel from which items can be read off the queue, and
-   permits replacing the contents of the queue at any time.
-
-   The overall work flow for a WorkQueue is as follows:
-
-     1. A WorkQueue is created with NewWorkQueue().  This
-        function instantiates a new WorkQueue and starts a manager
-        goroutine.  The manager listens on an input channel
-        (manager.newlist) and an output channel (manager.NextItem).
-
-     2. The manager first waits for a new list of requests on the
-        newlist channel.  When another goroutine calls
-        manager.ReplaceQueue(lst), it sends lst over the newlist
-        channel to the manager.  The manager goroutine now has
-        ownership of the list.
-
-     3. Once the manager has this initial list, it listens on both the
-        input and output channels for one of the following to happen:
-
-          a. A worker attempts to read an item from the NextItem
-             channel.  The manager sends the next item from the list
-             over this channel to the worker, and loops.
-
-          b. New data is sent to the manager on the newlist channel.
-             This happens when another goroutine calls
-             manager.ReplaceItem() with a new list.  The manager
-             discards the current list, replaces it with the new one,
-             and begins looping again.
-
-          c. The input channel is closed.  The manager closes its
-             output channel (signalling any workers to quit) and
-             terminates.
-
-   Tasks currently handled by WorkQueue:
-     * the pull list
-     * the trash list
-
-   Example usage:
-
-        // Any kind of user-defined type can be used with the
-        // WorkQueue.
-               type FrobRequest struct {
-                       frob string
-               }
-
-               // Make a work list.
-               froblist := NewWorkQueue()
-
-               // Start a concurrent worker to read items from the NextItem
-               // channel until it is closed, deleting each one.
-               go func(list WorkQueue) {
-                       for i := range list.NextItem {
-                               req := i.(FrobRequest)
-                               frob.Run(req)
-                       }
-               }(froblist)
-
-               // Set up a HTTP handler for PUT /frob
-               router.HandleFunc(`/frob`,
-                       func(w http.ResponseWriter, req *http.Request) {
-                               // Parse the request body into a list.List
-                               // of FrobRequests, and give this list to the
-                               // frob manager.
-                               newfrobs := parseBody(req.Body)
-                               froblist.ReplaceQueue(newfrobs)
-                       }).Methods("PUT")
-
-   Methods available on a WorkQueue:
-
-               ReplaceQueue(list)
-                       Replaces the current item list with a new one.  The list
-            manager discards any unprocessed items on the existing
-            list and replaces it with the new one. If the worker is
-            processing a list item when ReplaceQueue is called, it
-            finishes processing before receiving items from the new
-            list.
-               Close()
-                       Shuts down the manager goroutine. When Close is called,
-                       the manager closes the NextItem channel.
-*/
-
-import "container/list"
-
-// WorkQueue definition
-type WorkQueue struct {
-       getStatus chan WorkQueueStatus
-       newlist   chan *list.List
-       // Workers get work items by reading from this channel.
-       NextItem <-chan interface{}
-       // Each worker must send struct{}{} to DoneItem exactly once
-       // for each work item received from NextItem, when it stops
-       // working on that item (regardless of whether the work was
-       // successful).
-       DoneItem chan<- struct{}
-}
-
-// WorkQueueStatus reflects the queue status.
-type WorkQueueStatus struct {
-       InProgress int
-       Queued     int
-}
-
-// NewWorkQueue returns a new empty WorkQueue.
-//
-func NewWorkQueue() *WorkQueue {
-       nextItem := make(chan interface{})
-       reportDone := make(chan struct{})
-       newList := make(chan *list.List)
-       b := WorkQueue{
-               getStatus: make(chan WorkQueueStatus),
-               newlist:   newList,
-               NextItem:  nextItem,
-               DoneItem:  reportDone,
-       }
-       go func() {
-               // Read new work lists from the newlist channel.
-               // Reply to "status" and "get next item" queries by
-               // sending to the getStatus and nextItem channels
-               // respectively. Return when the newlist channel
-               // closes.
-
-               todo := &list.List{}
-               status := WorkQueueStatus{}
-
-               // When we're done, close the output channel; workers will
-               // shut down next time they ask for new work.
-               defer close(nextItem)
-               defer close(b.getStatus)
-
-               // nextChan and nextVal are both nil when we have
-               // nothing to send; otherwise they are, respectively,
-               // the nextItem channel and the next work item to send
-               // to it.
-               var nextChan chan interface{}
-               var nextVal interface{}
-
-               for newList != nil || status.InProgress > 0 {
-                       select {
-                       case p, ok := <-newList:
-                               if !ok {
-                                       // Closed, stop receiving
-                                       newList = nil
-                               }
-                               todo = p
-                               if todo == nil {
-                                       todo = &list.List{}
-                               }
-                               status.Queued = todo.Len()
-                               if status.Queued == 0 {
-                                       // Stop sending work
-                                       nextChan = nil
-                                       nextVal = nil
-                               } else {
-                                       nextChan = nextItem
-                                       nextVal = todo.Front().Value
-                               }
-                       case nextChan <- nextVal:
-                               todo.Remove(todo.Front())
-                               status.InProgress++
-                               status.Queued--
-                               if status.Queued == 0 {
-                                       // Stop sending work
-                                       nextChan = nil
-                                       nextVal = nil
-                               } else {
-                                       nextVal = todo.Front().Value
-                               }
-                       case <-reportDone:
-                               status.InProgress--
-                       case b.getStatus <- status:
-                       }
-               }
-       }()
-       return &b
-}
-
-// ReplaceQueue abandons any work items left in the existing queue,
-// and starts giving workers items from the given list. After giving
-// it to ReplaceQueue, the caller must not read or write the given
-// list.
-//
-func (b *WorkQueue) ReplaceQueue(list *list.List) {
-       b.newlist <- list
-}
-
-// Close shuts down the manager and terminates the goroutine, which
-// abandons any pending requests, but allows any pull request already
-// in progress to continue.
-//
-// After Close, Status will return correct values, NextItem will be
-// closed, and ReplaceQueue will panic.
-//
-func (b *WorkQueue) Close() {
-       close(b.newlist)
-}
-
-// Status returns an up-to-date WorkQueueStatus reflecting the current
-// queue status.
-//
-func (b *WorkQueue) Status() WorkQueueStatus {
-       // If the channel is closed, we get the nil value of
-       // WorkQueueStatus, which is an accurate description of a
-       // finished queue.
-       return <-b.getStatus
-}
diff --git a/services/keepstore/work_queue_test.go b/services/keepstore/work_queue_test.go
deleted file mode 100644 (file)
index 254f96c..0000000
+++ /dev/null
@@ -1,244 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-package keepstore
-
-import (
-       "container/list"
-       "runtime"
-       "testing"
-       "time"
-)
-
-type fatalfer interface {
-       Fatalf(string, ...interface{})
-}
-
-func makeTestWorkList(ary []interface{}) *list.List {
-       l := list.New()
-       for _, n := range ary {
-               l.PushBack(n)
-       }
-       return l
-}
-
-func expectChannelEmpty(t fatalfer, c <-chan interface{}) {
-       select {
-       case item, ok := <-c:
-               if ok {
-                       t.Fatalf("Received value (%+v) from channel that we expected to be empty", item)
-               }
-       default:
-       }
-}
-
-func expectChannelNotEmpty(t fatalfer, c <-chan interface{}) interface{} {
-       select {
-       case item, ok := <-c:
-               if !ok {
-                       t.Fatalf("expected data on a closed channel")
-               }
-               return item
-       case <-time.After(time.Second):
-               t.Fatalf("expected data on an empty channel")
-               return nil
-       }
-}
-
-func expectChannelClosedWithin(t fatalfer, timeout time.Duration, c <-chan interface{}) {
-       select {
-       case received, ok := <-c:
-               if ok {
-                       t.Fatalf("Expected channel to be closed, but received %+v instead", received)
-               }
-       case <-time.After(timeout):
-               t.Fatalf("Expected channel to be closed, but it is still open after %v", timeout)
-       }
-}
-
-func doWorkItems(t fatalfer, q *WorkQueue, expected []interface{}) {
-       for i := range expected {
-               actual, ok := <-q.NextItem
-               if !ok {
-                       t.Fatalf("Expected %+v but channel was closed after receiving %+v as expected.", expected, expected[:i])
-               }
-               q.DoneItem <- struct{}{}
-               if actual.(int) != expected[i] {
-                       t.Fatalf("Expected %+v but received %+v after receiving %+v as expected.", expected[i], actual, expected[:i])
-               }
-       }
-}
-
-func expectEqualWithin(t fatalfer, timeout time.Duration, expect interface{}, f func() interface{}) {
-       ok := make(chan struct{})
-       giveup := false
-       go func() {
-               for f() != expect && !giveup {
-                       time.Sleep(time.Millisecond)
-               }
-               close(ok)
-       }()
-       select {
-       case <-ok:
-       case <-time.After(timeout):
-               giveup = true
-               _, file, line, _ := runtime.Caller(1)
-               t.Fatalf("Still getting %+v, timed out waiting for %+v\n%s:%d", f(), expect, file, line)
-       }
-}
-
-func expectQueued(t fatalfer, b *WorkQueue, expectQueued int) {
-       if l := b.Status().Queued; l != expectQueued {
-               t.Fatalf("Got Queued==%d, expected %d", l, expectQueued)
-       }
-}
-
-func TestWorkQueueDoneness(t *testing.T) {
-       b := NewWorkQueue()
-       defer b.Close()
-       b.ReplaceQueue(makeTestWorkList([]interface{}{1, 2, 3}))
-       expectQueued(t, b, 3)
-       gate := make(chan struct{})
-       go func() {
-               <-gate
-               for range b.NextItem {
-                       <-gate
-                       time.Sleep(time.Millisecond)
-                       b.DoneItem <- struct{}{}
-               }
-       }()
-       expectEqualWithin(t, time.Second, 0, func() interface{} { return b.Status().InProgress })
-       b.ReplaceQueue(makeTestWorkList([]interface{}{4, 5, 6}))
-       for i := 1; i <= 3; i++ {
-               gate <- struct{}{}
-               expectEqualWithin(t, time.Second, 3-i, func() interface{} { return b.Status().Queued })
-               expectEqualWithin(t, time.Second, 1, func() interface{} { return b.Status().InProgress })
-       }
-       close(gate)
-       expectEqualWithin(t, time.Second, 0, func() interface{} { return b.Status().InProgress })
-       expectChannelEmpty(t, b.NextItem)
-}
-
-// Create a WorkQueue, generate a list for it, and instantiate a worker.
-func TestWorkQueueReadWrite(t *testing.T) {
-       var input = []interface{}{1, 1, 2, 3, 5, 8, 13, 21, 34}
-
-       b := NewWorkQueue()
-       expectQueued(t, b, 0)
-
-       b.ReplaceQueue(makeTestWorkList(input))
-       expectQueued(t, b, len(input))
-
-       doWorkItems(t, b, input)
-       expectChannelEmpty(t, b.NextItem)
-       b.Close()
-}
-
-// Start a worker before the list has any input.
-func TestWorkQueueEarlyRead(t *testing.T) {
-       var input = []interface{}{1, 1, 2, 3, 5, 8, 13, 21, 34}
-
-       b := NewWorkQueue()
-       defer b.Close()
-
-       // First, demonstrate that nothing is available on the NextItem
-       // channel.
-       expectChannelEmpty(t, b.NextItem)
-
-       // Start a reader in a goroutine. The reader will block until the
-       // block work list has been initialized.
-       //
-       done := make(chan int)
-       go func() {
-               doWorkItems(t, b, input)
-               done <- 1
-       }()
-
-       // Feed the blocklist a new worklist, and wait for the worker to
-       // finish.
-       b.ReplaceQueue(makeTestWorkList(input))
-       <-done
-       expectQueued(t, b, 0)
-}
-
-// After Close(), NextItem closes, work finishes, then stats return zero.
-func TestWorkQueueClose(t *testing.T) {
-       b := NewWorkQueue()
-       input := []interface{}{1, 2, 3, 4, 5, 6, 7, 8}
-       mark := make(chan struct{})
-       go func() {
-               <-b.NextItem
-               mark <- struct{}{}
-               <-mark
-               b.DoneItem <- struct{}{}
-       }()
-       b.ReplaceQueue(makeTestWorkList(input))
-       // Wait for worker to take item 1
-       <-mark
-       b.Close()
-       expectEqualWithin(t, time.Second, 1, func() interface{} { return b.Status().InProgress })
-       // Tell worker to report done
-       mark <- struct{}{}
-       expectEqualWithin(t, time.Second, 0, func() interface{} { return b.Status().InProgress })
-       expectChannelClosedWithin(t, time.Second, b.NextItem)
-}
-
-// Show that a reader may block when the manager's list is exhausted,
-// and that the reader resumes automatically when new data is
-// available.
-func TestWorkQueueReaderBlocks(t *testing.T) {
-       var (
-               inputBeforeBlock = []interface{}{1, 2, 3, 4, 5}
-               inputAfterBlock  = []interface{}{6, 7, 8, 9, 10}
-       )
-
-       b := NewWorkQueue()
-       defer b.Close()
-       sendmore := make(chan int)
-       done := make(chan int)
-       go func() {
-               doWorkItems(t, b, inputBeforeBlock)
-
-               // Confirm that the channel is empty, so a subsequent read
-               // on it will block.
-               expectChannelEmpty(t, b.NextItem)
-
-               // Signal that we're ready for more input.
-               sendmore <- 1
-               doWorkItems(t, b, inputAfterBlock)
-               done <- 1
-       }()
-
-       // Write a slice of the first five elements and wait for the
-       // reader to signal that it's ready for us to send more input.
-       b.ReplaceQueue(makeTestWorkList(inputBeforeBlock))
-       <-sendmore
-
-       b.ReplaceQueue(makeTestWorkList(inputAfterBlock))
-
-       // Wait for the reader to complete.
-       <-done
-}
-
-// Replace one active work list with another.
-func TestWorkQueueReplaceQueue(t *testing.T) {
-       var firstInput = []interface{}{1, 1, 2, 3, 5, 8, 13, 21, 34}
-       var replaceInput = []interface{}{1, 4, 9, 16, 25, 36, 49, 64, 81}
-
-       b := NewWorkQueue()
-       b.ReplaceQueue(makeTestWorkList(firstInput))
-
-       // Read just the first five elements from the work list.
-       // Confirm that the channel is not empty.
-       doWorkItems(t, b, firstInput[0:5])
-       expectChannelNotEmpty(t, b.NextItem)
-
-       // Replace the work list and read five more elements.
-       // The old list should have been discarded and all new
-       // elements come from the new list.
-       b.ReplaceQueue(makeTestWorkList(replaceInput))
-       doWorkItems(t, b, replaceInput[0:5])
-
-       b.Close()
-}
index 420b1528618c10ec4f3b2f2b986060e25dfd2116..e49cd617f09f9fc132d1885eb535028c03b56401 100644 (file)
@@ -5,7 +5,7 @@
 source 'https://rubygems.org'
 gemspec
 group :test, :performance do
-  gem 'minitest', '>= 5.0.0'
-  gem 'mocha', '>= 1.5.0', require: false
+  gem 'minitest', '>= 5'
+  gem 'mocha', '>= 2.1', require: false
   gem 'rake'
 end
index 1f8252924b1ad30d46779a6384f2c8f71ce1e187..008f13d8b862662fd95b8e751cb4c1a13d2a2280 100644 (file)
@@ -36,18 +36,15 @@ Gem::Specification.new do |s|
   s.licenses    = ['AGPL-3.0']
   s.files       = ["bin/arvados-login-sync", "agpl-3.0.txt"]
   s.executables << "arvados-login-sync"
-  s.required_ruby_version = '>= 2.1.0'
-  # Note the letter 'a' at the end of the version dependency. This enables
-  # bundler's dependency resolver to include 'pre-release' versions, like the
-  # ones we build (but not publish) on every test pipeline job.
-  # See: https://github.com/rubygems/bundler/issues/4340
-  s.add_runtime_dependency 'arvados', '~> 2.4', '> 2.4.4a'
+  s.required_ruby_version = '>= 2.5.0'
+  # The minimum version's 'a' suffix is necessary to enable bundler
+  # to consider 'pre-release' versions.  See:
+  # https://github.com/rubygems/bundler/issues/4340
+  s.add_runtime_dependency 'arvados', '~> 2.8.a'
   s.add_runtime_dependency 'launchy', '< 2.5'
-  # We need at least version 0.8.7.3, cf. https://dev.arvados.org/issues/15673
-  s.add_dependency('arvados-google-api-client', '>= 0.8.7.3', '< 0.8.9')
-  # arvados-google-api-client (and thus arvados) gems
-  # depend on signet, but signet 0.12 is incompatible with ruby 2.3.
-  s.add_dependency('signet', '< 0.12')
+  # arvados fork of google-api-client gem with old API and new
+  # compatibility fixes, built from ../../sdk/ruby-google-api-client/
+  s.add_runtime_dependency('arvados-google-api-client', '>= 0.8.7.5', '< 0.8.9')
   s.homepage    =
     'https://arvados.org'
 end
index 915541baf5009df2a7d290b0f47654e3d1c256ae..cbe8520a002620e0a1520a1fde08552e6a183a3e 100755 (executable)
@@ -12,6 +12,18 @@ require 'yaml'
 require 'optparse'
 require 'open3'
 
+def ensure_dir(path, mode, owner, group)
+  begin
+    Dir.mkdir(path, mode)
+  rescue Errno::EEXIST
+    # No change needed
+    false
+  else
+    FileUtils.chown(owner, group, path)
+    true
+  end
+end
+
 req_envs = %w(ARVADOS_API_HOST ARVADOS_API_TOKEN ARVADOS_VIRTUAL_MACHINE_UUID)
 req_envs.each do |k|
   unless ENV[k]
@@ -34,6 +46,15 @@ exclusive_banner = "############################################################
 start_banner = "### BEGIN Arvados-managed keys -- changes between markers will be overwritten\n"
 end_banner = "### END Arvados-managed keys -- changes between markers will be overwritten\n"
 
+actions = {
+  # These names correspond to the names in the cluster Users configuration.
+  # Managing everything was the original behavior.
+  SyncUserAccounts: true,
+  SyncUserGroups: true,
+  SyncUserSSHKeys: true,
+  SyncUserAPITokens: true,
+}
+
 keys = ''
 
 begin
@@ -45,6 +66,17 @@ begin
   logincluster_host = ENV['ARVADOS_API_HOST']
   logincluster_name = arv.cluster_config['Login']['LoginCluster'] or ''
 
+  # Requiring the fuse group was previous hardcoded behavior
+  minimum_groups = arv.cluster_config['Users']['SyncRequiredGroups'] || ['fuse']
+  ignored_groups = arv.cluster_config['Users']['SyncIgnoredGroups'] || []
+  (minimum_groups & ignored_groups).each do |group_name|
+    STDERR.puts "WARNING: #{group_name} is listed in both SyncRequiredGroups and SyncIgnoredGroups. It will be ignored."
+  end
+
+  actions.each_pair do |key, default|
+    actions[key] = arv.cluster_config['Users'].fetch(key.to_s, default)
+  end
+
   if logincluster_name != '' and logincluster_name != arv.cluster_config['ClusterID']
     logincluster_host = arv.cluster_config['RemoteClusters'][logincluster_name]['Host']
   end
@@ -112,11 +144,12 @@ begin
 
   seen = Hash.new()
 
-  current_user_groups = Hash.new
+  all_groups = []
+  current_user_groups = Hash.new { |hash, key| hash[key] = [] }
   while (ent = Etc.getgrent()) do
+    all_groups << ent.name
     ent.mem.each do |member|
-      current_user_groups[member] ||= Array.new
-      current_user_groups[member].push ent.name
+      current_user_groups[member] << ent.name
     end
   end
   Etc.endgrent()
@@ -128,6 +161,10 @@ begin
     username = l[:username]
 
     unless pwnam[l[:username]]
+      unless actions[:SyncUserAccounts]
+        STDERR.puts "User #{username} does not exist and SyncUserAccounts=false. Skipping."
+        next
+      end
       STDERR.puts "Creating account #{l[:username]}"
       # Create new user
       out, st = Open3.capture2e("useradd", "-m",
@@ -146,15 +183,21 @@ begin
       end
     end
 
-    existing_groups = current_user_groups[username] || []
-    groups = l[:groups] || []
-    # Adding users to the FUSE group has long been hardcoded behavior.
-    groups << "fuse"
-    groups << username
-    groups.select! { |g| Etc.getgrnam(g) rescue false }
+    user_gid = pwnam[username].gid
+    homedir = pwnam[l[:username]].dir
+    if !File.exist?(homedir)
+      STDERR.puts "Cannot set up user #{username} because their home directory #{homedir} does not exist. Skipping."
+      next
+    end
+
+    if actions[:SyncUserGroups]
+      have_groups = current_user_groups[username] - ignored_groups
+      want_groups = l[:groups] || []
+      want_groups |= minimum_groups
+      want_groups -= ignored_groups
+      want_groups &= all_groups
 
-    groups.each do |addgroup|
-      if existing_groups.index(addgroup).nil?
+      (want_groups - have_groups).each do |addgroup|
         # User should be in group, but isn't, so add them.
         STDERR.puts "Add user #{username} to #{addgroup} group"
         out, st = Open3.capture2e("usermod", "-aG", addgroup, username)
@@ -162,10 +205,8 @@ begin
           STDERR.puts "Failed to add #{username} to #{addgroup} group:\n#{out}"
         end
       end
-    end
 
-    existing_groups.each do |removegroup|
-      if groups.index(removegroup).nil?
+      (have_groups - want_groups).each do |removegroup|
         # User is in a group, but shouldn't be, so remove them.
         STDERR.puts "Remove user #{username} from #{removegroup} group"
         out, st = Open3.capture2e("gpasswd", "-d", username, removegroup)
@@ -175,96 +216,86 @@ begin
       end
     end
 
-    homedir = pwnam[l[:username]].dir
-    userdotssh = File.join(homedir, ".ssh")
-    Dir.mkdir(userdotssh) if !File.exist?(userdotssh)
+    if actions[:SyncUserSSHKeys]
+      userdotssh = File.join(homedir, ".ssh")
+      ensure_dir(userdotssh, 0700, username, user_gid)
 
-    newkeys = "###\n###\n" + keys[l[:username]].join("\n") + "\n###\n###\n"
+      newkeys = "###\n###\n" + keys[l[:username]].join("\n") + "\n###\n###\n"
 
-    keysfile = File.join(userdotssh, "authorized_keys")
+      keysfile = File.join(userdotssh, "authorized_keys")
+      begin
+        oldkeys = File.read(keysfile)
+      rescue Errno::ENOENT
+        oldkeys = ""
+      end
 
-    if File.exist?(keysfile)
-      oldkeys = IO::read(keysfile)
-    else
-      oldkeys = ""
-    end
+      if options[:exclusive]
+        newkeys = exclusive_banner + newkeys
+      elsif oldkeys.start_with?(exclusive_banner)
+        newkeys = start_banner + newkeys + end_banner
+      elsif (m = /^(.*?\n|)#{start_banner}(.*?\n|)#{end_banner}(.*)/m.match(oldkeys))
+        newkeys = m[1] + start_banner + newkeys + end_banner + m[3]
+      else
+        newkeys = start_banner + newkeys + end_banner + oldkeys
+      end
 
-    if options[:exclusive]
-      newkeys = exclusive_banner + newkeys
-    elsif oldkeys.start_with?(exclusive_banner)
-      newkeys = start_banner + newkeys + end_banner
-    elsif (m = /^(.*?\n|)#{start_banner}(.*?\n|)#{end_banner}(.*)/m.match(oldkeys))
-      newkeys = m[1] + start_banner + newkeys + end_banner + m[3]
-    else
-      newkeys = start_banner + newkeys + end_banner + oldkeys
+      if oldkeys != newkeys then
+        File.open(keysfile, 'w', 0600) do |f|
+          f.write(newkeys)
+        end
+        FileUtils.chown(username, user_gid, keysfile)
+      end
     end
 
-    if oldkeys != newkeys then
-      f = File.new(keysfile, 'w')
-      f.write(newkeys)
-      f.close()
-    end
+    if actions[:SyncUserAPITokens]
+      userdotconfig = File.join(homedir, ".config")
+      ensure_dir(userdotconfig, 0755, username, user_gid)
+      configarvados = File.join(userdotconfig, "arvados")
+      ensure_dir(configarvados, 0700, username, user_gid)
 
-    userdotconfig = File.join(homedir, ".config")
-    if !File.exist?(userdotconfig)
-      Dir.mkdir(userdotconfig)
-    end
+      tokenfile = File.join(configarvados, "settings.conf")
 
-    configarvados = File.join(userdotconfig, "arvados")
-    Dir.mkdir(configarvados) if !File.exist?(configarvados)
-
-    tokenfile = File.join(configarvados, "settings.conf")
-
-    begin
-      STDERR.puts "Processing #{tokenfile} ..." if debug
-      newToken = false
-      if File.exist?(tokenfile)
-        # check if the token is still valid
-        myToken = ENV["ARVADOS_API_TOKEN"]
-        userEnv = IO::read(tokenfile)
-        if (m = /^ARVADOS_API_TOKEN=(.*?\n)/m.match(userEnv))
-          begin
-            tmp_arv = Arvados.new({ :api_host => logincluster_host,
-                                    :api_token => (m[1]),
-                                    :suppress_ssl_warnings => false })
-            tmp_arv.user.current
-          rescue Arvados::TransactionFailedError => e
-            if e.to_s =~ /401 Unauthorized/
-              STDERR.puts "Account #{l[:username]} token not valid, creating new token."
-              newToken = true
-            else
-              raise
+      begin
+        STDERR.puts "Processing #{tokenfile} ..." if debug
+        newToken = false
+        if File.exist?(tokenfile)
+          # check if the token is still valid
+          myToken = ENV["ARVADOS_API_TOKEN"]
+          userEnv = File.read(tokenfile)
+          if (m = /^ARVADOS_API_TOKEN=(.*?\n)/m.match(userEnv))
+            begin
+              tmp_arv = Arvados.new({ :api_host => logincluster_host,
+                                      :api_token => (m[1]),
+                                      :suppress_ssl_warnings => false })
+              tmp_arv.user.current
+            rescue Arvados::TransactionFailedError => e
+              if e.to_s =~ /401 Unauthorized/
+                STDERR.puts "Account #{l[:username]} token not valid, creating new token."
+                newToken = true
+              else
+                raise
+              end
             end
           end
+        elsif !File.exist?(tokenfile) || options[:"rotate-tokens"]
+          STDERR.puts "Account #{l[:username]} token file not found, creating new token."
+          newToken = true
         end
-      elsif !File.exist?(tokenfile) || options[:"rotate-tokens"]
-        STDERR.puts "Account #{l[:username]} token file not found, creating new token."
-        newToken = true
-      end
-      if newToken
-        aca_params = {owner_uuid: l[:user_uuid], api_client_id: 0}
-        if options[:"token-lifetime"] && options[:"token-lifetime"] > 0
-          aca_params.merge!(expires_at: (Time.now + options[:"token-lifetime"]))
+        if newToken
+          aca_params = {owner_uuid: l[:user_uuid], api_client_id: 0}
+          if options[:"token-lifetime"] && options[:"token-lifetime"] > 0
+            aca_params.merge!(expires_at: (Time.now + options[:"token-lifetime"]))
+          end
+          user_token = logincluster_arv.api_client_authorization.create(api_client_authorization: aca_params)
+          File.open(tokenfile, 'w', 0600) do |f|
+            f.write("ARVADOS_API_HOST=#{ENV['ARVADOS_API_HOST']}\n")
+            f.write("ARVADOS_API_TOKEN=v2/#{user_token[:uuid]}/#{user_token[:api_token]}\n")
+          end
+          FileUtils.chown(username, user_gid, tokenfile)
         end
-        user_token = logincluster_arv.api_client_authorization.create(api_client_authorization: aca_params)
-        f = File.new(tokenfile, 'w')
-        f.write("ARVADOS_API_HOST=#{ENV['ARVADOS_API_HOST']}\n")
-        f.write("ARVADOS_API_TOKEN=v2/#{user_token[:uuid]}/#{user_token[:api_token]}\n")
-        f.close()
+      rescue => e
+        STDERR.puts "Error setting token for #{l[:username]}: #{e}"
       end
-    rescue => e
-      STDERR.puts "Error setting token for #{l[:username]}: #{e}"
-    end
-
-    FileUtils.chown_R(l[:username], nil, userdotssh)
-    FileUtils.chown_R(l[:username], nil, userdotconfig)
-    File.chmod(0700, userdotssh)
-    File.chmod(0700, userdotconfig)
-    File.chmod(0700, configarvados)
-    File.chmod(0750, homedir)
-    File.chmod(0600, keysfile)
-    if File.exist?(tokenfile)
-      File.chmod(0600, tokenfile)
     end
   end
 
diff --git a/services/workbench2/.env b/services/workbench2/.env
new file mode 100644 (file)
index 0000000..fd91b99
--- /dev/null
@@ -0,0 +1,7 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+REACT_APP_ARVADOS_CONFIG_URL=/config.json
+REACT_APP_ARVADOS_API_HOST=c97qk.arvadosapi.com
+HTTPS=true
\ No newline at end of file
diff --git a/services/workbench2/.gitignore b/services/workbench2/.gitignore
new file mode 100644 (file)
index 0000000..7358d62
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+# See https://help.github.com/ignore-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+
+# vscode
+/.vs
+
+# testing
+/coverage
+/cypress/videos
+/cypress/screenshots
+/cypress/downloads
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+.npm.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+.idea
+.vscode
+/public/config.json
+/public/_health/
+
+# see https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
diff --git a/services/workbench2/.npmrc b/services/workbench2/.npmrc
new file mode 100644 (file)
index 0000000..cffe8cd
--- /dev/null
@@ -0,0 +1 @@
+save-exact=true
diff --git a/services/workbench2/.yarn/releases/yarn-3.2.0.cjs b/services/workbench2/.yarn/releases/yarn-3.2.0.cjs
new file mode 100755 (executable)
index 0000000..b30d065
--- /dev/null
@@ -0,0 +1,785 @@
+#!/usr/bin/env node
+/* eslint-disable */
+//prettier-ignore
+(()=>{var afe=Object.create,Oh=Object.defineProperty,Afe=Object.defineProperties,lfe=Object.getOwnPropertyDescriptor,cfe=Object.getOwnPropertyDescriptors,ufe=Object.getOwnPropertyNames,OE=Object.getOwnPropertySymbols,gfe=Object.getPrototypeOf,lQ=Object.prototype.hasOwnProperty,iM=Object.prototype.propertyIsEnumerable;var nM=(t,e,r)=>e in t?Oh(t,e,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[e]=r,N=(t,e)=>{for(var r in e||(e={}))lQ.call(e,r)&&nM(t,r,e[r]);if(OE)for(var r of OE(e))iM.call(e,r)&&nM(t,r,e[r]);return t},te=(t,e)=>Afe(t,cfe(e)),ffe=t=>Oh(t,"__esModule",{value:!0});var Tr=(t,e)=>{var r={};for(var i in t)lQ.call(t,i)&&e.indexOf(i)<0&&(r[i]=t[i]);if(t!=null&&OE)for(var i of OE(t))e.indexOf(i)<0&&iM.call(t,i)&&(r[i]=t[i]);return r},hfe=(t,e)=>()=>(t&&(e=t(t=0)),e),w=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports),ft=(t,e)=>{for(var r in e)Oh(t,r,{get:e[r],enumerable:!0})},pfe=(t,e,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of ufe(e))!lQ.call(t,i)&&i!=="default"&&Oh(t,i,{get:()=>e[i],enumerable:!(r=lfe(e,i))||r.enumerable});return t},ge=t=>pfe(ffe(Oh(t!=null?afe(gfe(t)):{},"default",t&&t.__esModule&&"default"in t?{get:()=>t.default,enumerable:!0}:{value:t,enumerable:!0})),t);var PM=w(($Xe,vM)=>{vM.exports=SM;SM.sync=Rfe;var kM=require("fs");function Ffe(t,e){var r=e.pathExt!==void 0?e.pathExt:process.env.PATHEXT;if(!r||(r=r.split(";"),r.indexOf("")!==-1))return!0;for(var i=0;i<r.length;i++){var n=r[i].toLowerCase();if(n&&t.substr(-n.length).toLowerCase()===n)return!0}return!1}function xM(t,e,r){return!t.isSymbolicLink()&&!t.isFile()?!1:Ffe(e,r)}function SM(t,e,r){kM.stat(t,function(i,n){r(i,i?!1:xM(n,t,e))})}function Rfe(t,e){return xM(kM.statSync(t),t,e)}});var LM=w((eZe,DM)=>{DM.exports=RM;RM.sync=Nfe;var FM=require("fs");function RM(t,e,r){FM.stat(t,function(i,n){r(i,i?!1:NM(n,e))})}function Nfe(t,e){return NM(FM.statSync(t),e)}function NM(t,e){return t.isFile()&&Lfe(t,e)}function Lfe(t,e){var r=t.mode,i=t.uid,n=t.gid,s=e.uid!==void 0?e.uid:process.getuid&&process.getuid(),o=e.gid!==void 0?e.gid:process.getgid&&process.getgid(),a=parseInt("100",8),l=parseInt("010",8),c=parseInt("001",8),u=a|l,g=r&c||r&l&&n===o||r&a&&i===s||r&u&&s===0;return g}});var OM=w((rZe,TM)=>{var tZe=require("fs"),XE;process.platform==="win32"||global.TESTING_WINDOWS?XE=PM():XE=LM();TM.exports=vQ;vQ.sync=Tfe;function vQ(t,e,r){if(typeof e=="function"&&(r=e,e={}),!r){if(typeof Promise!="function")throw new TypeError("callback not provided");return new Promise(function(i,n){vQ(t,e||{},function(s,o){s?n(s):i(o)})})}XE(t,e||{},function(i,n){i&&(i.code==="EACCES"||e&&e.ignoreErrors)&&(i=null,n=!1),r(i,n)})}function Tfe(t,e){try{return XE.sync(t,e||{})}catch(r){if(e&&e.ignoreErrors||r.code==="EACCES")return!1;throw r}}});var YM=w((iZe,MM)=>{var Ju=process.platform==="win32"||process.env.OSTYPE==="cygwin"||process.env.OSTYPE==="msys",UM=require("path"),Ofe=Ju?";":":",KM=OM(),HM=t=>Object.assign(new Error(`not found: ${t}`),{code:"ENOENT"}),jM=(t,e)=>{let r=e.colon||Ofe,i=t.match(/\//)||Ju&&t.match(/\\/)?[""]:[...Ju?[process.cwd()]:[],...(e.path||process.env.PATH||"").split(r)],n=Ju?e.pathExt||process.env.PATHEXT||".EXE;.CMD;.BAT;.COM":"",s=Ju?n.split(r):[""];return Ju&&t.indexOf(".")!==-1&&s[0]!==""&&s.unshift(""),{pathEnv:i,pathExt:s,pathExtExe:n}},GM=(t,e,r)=>{typeof e=="function"&&(r=e,e={}),e||(e={});let{pathEnv:i,pathExt:n,pathExtExe:s}=jM(t,e),o=[],a=c=>new Promise((u,g)=>{if(c===i.length)return e.all&&o.length?u(o):g(HM(t));let f=i[c],h=/^".*"$/.test(f)?f.slice(1,-1):f,p=UM.join(h,t),m=!h&&/^\.[\\\/]/.test(t)?t.slice(0,2)+p:p;u(l(m,c,0))}),l=(c,u,g)=>new Promise((f,h)=>{if(g===n.length)return f(a(u+1));let p=n[g];KM(c+p,{pathExt:s},(m,y)=>{if(!m&&y)if(e.all)o.push(c+p);else return f(c+p);return f(l(c,u,g+1))})});return r?a(0).then(c=>r(null,c),r):a(0)},Mfe=(t,e)=>{e=e||{};let{pathEnv:r,pathExt:i,pathExtExe:n}=jM(t,e),s=[];for(let o=0;o<r.length;o++){let a=r[o],l=/^".*"$/.test(a)?a.slice(1,-1):a,c=UM.join(l,t),u=!l&&/^\.[\\\/]/.test(t)?t.slice(0,2)+c:c;for(let g=0;g<i.length;g++){let f=u+i[g];try{if(KM.sync(f,{pathExt:n}))if(e.all)s.push(f);else return f}catch(h){}}}if(e.all&&s.length)return s;if(e.nothrow)return null;throw HM(t)};MM.exports=GM;GM.sync=Mfe});var JM=w((nZe,SQ)=>{"use strict";var qM=(t={})=>{let e=t.env||process.env;return(t.platform||process.platform)!=="win32"?"PATH":Object.keys(e).reverse().find(i=>i.toUpperCase()==="PATH")||"Path"};SQ.exports=qM;SQ.exports.default=qM});var VM=w((sZe,WM)=>{"use strict";var zM=require("path"),Ufe=YM(),Kfe=JM();function _M(t,e){let r=t.options.env||process.env,i=process.cwd(),n=t.options.cwd!=null,s=n&&process.chdir!==void 0&&!process.chdir.disabled;if(s)try{process.chdir(t.options.cwd)}catch(a){}let o;try{o=Ufe.sync(t.command,{path:r[Kfe({env:r})],pathExt:e?zM.delimiter:void 0})}catch(a){}finally{s&&process.chdir(i)}return o&&(o=zM.resolve(n?t.options.cwd:"",o)),o}function Hfe(t){return _M(t)||_M(t,!0)}WM.exports=Hfe});var XM=w((oZe,kQ)=>{"use strict";var xQ=/([()\][%!^"`<>&|;, *?])/g;function jfe(t){return t=t.replace(xQ,"^$1"),t}function Gfe(t,e){return t=`${t}`,t=t.replace(/(\\*)"/g,'$1$1\\"'),t=t.replace(/(\\*)$/,"$1$1"),t=`"${t}"`,t=t.replace(xQ,"^$1"),e&&(t=t.replace(xQ,"^$1")),t}kQ.exports.command=jfe;kQ.exports.argument=Gfe});var $M=w((aZe,ZM)=>{"use strict";ZM.exports=/^#!(.*)/});var t1=w((AZe,e1)=>{"use strict";var Yfe=$M();e1.exports=(t="")=>{let e=t.match(Yfe);if(!e)return null;let[r,i]=e[0].replace(/#! ?/,"").split(" "),n=r.split("/").pop();return n==="env"?i:i?`${n} ${i}`:n}});var i1=w((lZe,r1)=>{"use strict";var PQ=require("fs"),qfe=t1();function Jfe(t){let e=150,r=Buffer.alloc(e),i;try{i=PQ.openSync(t,"r"),PQ.readSync(i,r,0,e,0),PQ.closeSync(i)}catch(n){}return qfe(r.toString())}r1.exports=Jfe});var a1=w((cZe,n1)=>{"use strict";var Wfe=require("path"),s1=VM(),o1=XM(),zfe=i1(),_fe=process.platform==="win32",Vfe=/\.(?:com|exe)$/i,Xfe=/node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;function Zfe(t){t.file=s1(t);let e=t.file&&zfe(t.file);return e?(t.args.unshift(t.file),t.command=e,s1(t)):t.file}function $fe(t){if(!_fe)return t;let e=Zfe(t),r=!Vfe.test(e);if(t.options.forceShell||r){let i=Xfe.test(e);t.command=Wfe.normalize(t.command),t.command=o1.command(t.command),t.args=t.args.map(s=>o1.argument(s,i));let n=[t.command].concat(t.args).join(" ");t.args=["/d","/s","/c",`"${n}"`],t.command=process.env.comspec||"cmd.exe",t.options.windowsVerbatimArguments=!0}return t}function ehe(t,e,r){e&&!Array.isArray(e)&&(r=e,e=null),e=e?e.slice(0):[],r=Object.assign({},r);let i={command:t,args:e,options:r,file:void 0,original:{command:t,args:e}};return r.shell?i:$fe(i)}n1.exports=ehe});var c1=w((uZe,A1)=>{"use strict";var DQ=process.platform==="win32";function RQ(t,e){return Object.assign(new Error(`${e} ${t.command} ENOENT`),{code:"ENOENT",errno:"ENOENT",syscall:`${e} ${t.command}`,path:t.command,spawnargs:t.args})}function the(t,e){if(!DQ)return;let r=t.emit;t.emit=function(i,n){if(i==="exit"){let s=l1(n,e,"spawn");if(s)return r.call(t,"error",s)}return r.apply(t,arguments)}}function l1(t,e){return DQ&&t===1&&!e.file?RQ(e.original,"spawn"):null}function rhe(t,e){return DQ&&t===1&&!e.file?RQ(e.original,"spawnSync"):null}A1.exports={hookChildProcess:the,verifyENOENT:l1,verifyENOENTSync:rhe,notFoundError:RQ}});var LQ=w((gZe,Wu)=>{"use strict";var u1=require("child_process"),FQ=a1(),NQ=c1();function g1(t,e,r){let i=FQ(t,e,r),n=u1.spawn(i.command,i.args,i.options);return NQ.hookChildProcess(n,i),n}function ihe(t,e,r){let i=FQ(t,e,r),n=u1.spawnSync(i.command,i.args,i.options);return n.error=n.error||NQ.verifyENOENTSync(n.status,i),n}Wu.exports=g1;Wu.exports.spawn=g1;Wu.exports.sync=ihe;Wu.exports._parse=FQ;Wu.exports._enoent=NQ});var h1=w((fZe,f1)=>{"use strict";function nhe(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function nc(t,e,r,i){this.message=t,this.expected=e,this.found=r,this.location=i,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,nc)}nhe(nc,Error);nc.buildMessage=function(t,e){var r={literal:function(c){return'"'+n(c.text)+'"'},class:function(c){var u="",g;for(g=0;g<c.parts.length;g++)u+=c.parts[g]instanceof Array?s(c.parts[g][0])+"-"+s(c.parts[g][1]):s(c.parts[g]);return"["+(c.inverted?"^":"")+u+"]"},any:function(c){return"any character"},end:function(c){return"end of input"},other:function(c){return c.description}};function i(c){return c.charCodeAt(0).toString(16).toUpperCase()}function n(c){return c.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(u){return"\\x0"+i(u)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(u){return"\\x"+i(u)})}function s(c){return c.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(u){return"\\x0"+i(u)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(u){return"\\x"+i(u)})}function o(c){return r[c.type](c)}function a(c){var u=new Array(c.length),g,f;for(g=0;g<c.length;g++)u[g]=o(c[g]);if(u.sort(),u.length>0){for(g=1,f=1;g<u.length;g++)u[g-1]!==u[g]&&(u[f]=u[g],f++);u.length=f}switch(u.length){case 1:return u[0];case 2:return u[0]+" or "+u[1];default:return u.slice(0,-1).join(", ")+", or "+u[u.length-1]}}function l(c){return c?'"'+n(c)+'"':"end of input"}return"Expected "+a(t)+" but "+l(e)+" found."};function she(t,e){e=e!==void 0?e:{};var r={},i={Start:OA},n=OA,s=function(C){return C||[]},o=function(C,b,F){return[{command:C,type:b}].concat(F||[])},a=function(C,b){return[{command:C,type:b||";"}]},l=function(C){return C},c=";",u=Ce(";",!1),g="&",f=Ce("&",!1),h=function(C,b){return b?{chain:C,then:b}:{chain:C}},p=function(C,b){return{type:C,line:b}},m="&&",y=Ce("&&",!1),Q="||",S=Ce("||",!1),x=function(C,b){return b?te(N({},C),{then:b}):C},M=function(C,b){return{type:C,chain:b}},Y="|&",U=Ce("|&",!1),J="|",W=Ce("|",!1),ee="=",Z=Ce("=",!1),A=function(C,b){return{name:C,args:[b]}},ne=function(C){return{name:C,args:[]}},le="(",Ae=Ce("(",!1),T=")",L=Ce(")",!1),Ee=function(C,b){return{type:"subshell",subshell:C,args:b}},we="{",qe=Ce("{",!1),re="}",se=Ce("}",!1),Qe=function(C,b){return{type:"group",group:C,args:b}},he=function(C,b){return{type:"command",args:b,envs:C}},Fe=function(C){return{type:"envs",envs:C}},Ue=function(C){return C},xe=function(C){return C},ve=/^[0-9]/,pe=_e([["0","9"]],!1,!1),X=function(C,b,F){return{type:"redirection",subtype:b,fd:C!==null?parseInt(C):null,args:[F]}},be=">>",ce=Ce(">>",!1),fe=">&",gt=Ce(">&",!1),Ht=">",Mt=Ce(">",!1),mi="<<<",jt=Ce("<<<",!1),Qr="<&",Ti=Ce("<&",!1),_s="<",Un=Ce("<",!1),Kn=function(C){return{type:"argument",segments:[].concat(...C)}},vr=function(C){return C},Hn="$'",us=Ce("$'",!1),Ia="'",SA=Ce("'",!1),Du=function(C){return[{type:"text",text:C}]},gs='""',kA=Ce('""',!1),ya=function(){return{type:"text",text:""}},Ru='"',xA=Ce('"',!1),PA=function(C){return C},Sr=function(C){return{type:"arithmetic",arithmetic:C,quoted:!0}},jl=function(C){return{type:"shell",shell:C,quoted:!0}},Fu=function(C){return te(N({type:"variable"},C),{quoted:!0})},So=function(C){return{type:"text",text:C}},Nu=function(C){return{type:"arithmetic",arithmetic:C,quoted:!1}},Qh=function(C){return{type:"shell",shell:C,quoted:!1}},vh=function(C){return te(N({type:"variable"},C),{quoted:!1})},oe=function(C){return{type:"glob",pattern:C}},Oi=/^[^']/,ko=_e(["'"],!0,!1),jn=function(C){return C.join("")},Lu=/^[^$"]/,vt=_e(["$",'"'],!0,!1),Gl=`\\
+`,Gn=Ce(`\\
+`,!1),fs=function(){return""},hs="\\",pt=Ce("\\",!1),xo=/^[\\$"`]/,lt=_e(["\\","$",'"',"`"],!1,!1),mn=function(C){return C},v="\\a",Tt=Ce("\\a",!1),Tu=function(){return"a"},Yl="\\b",Sh=Ce("\\b",!1),kh=function(){return"\b"},xh=/^[Ee]/,Ph=_e(["E","e"],!1,!1),Dh=function(){return"\e"},G="\\f",yt=Ce("\\f",!1),DA=function(){return"\f"},$i="\\n",ql=Ce("\\n",!1),$e=function(){return`
+`},wa="\\r",Ou=Ce("\\r",!1),SE=function(){return"\r"},Rh="\\t",kE=Ce("\\t",!1),gr=function(){return"   "},Yn="\\v",Jl=Ce("\\v",!1),Fh=function(){return"\v"},Vs=/^[\\'"?]/,Ba=_e(["\\","'",'"',"?"],!1,!1),En=function(C){return String.fromCharCode(parseInt(C,16))},Oe="\\x",Mu=Ce("\\x",!1),Wl="\\u",Xs=Ce("\\u",!1),zl="\\U",RA=Ce("\\U",!1),Uu=function(C){return String.fromCodePoint(parseInt(C,16))},Ku=/^[0-7]/,ba=_e([["0","7"]],!1,!1),Qa=/^[0-9a-fA-f]/,it=_e([["0","9"],["a","f"],["A","f"]],!1,!1),Po=ot(),FA="-",_l=Ce("-",!1),Zs="+",Vl=Ce("+",!1),xE=".",Nh=Ce(".",!1),Hu=function(C,b,F){return{type:"number",value:(C==="-"?-1:1)*parseFloat(b.join("")+"."+F.join(""))}},Lh=function(C,b){return{type:"number",value:(C==="-"?-1:1)*parseInt(b.join(""))}},PE=function(C){return N({type:"variable"},C)},Xl=function(C){return{type:"variable",name:C}},DE=function(C){return C},ju="*",NA=Ce("*",!1),Lr="/",RE=Ce("/",!1),$s=function(C,b,F){return{type:b==="*"?"multiplication":"division",right:F}},eo=function(C,b){return b.reduce((F,H)=>N({left:F},H),C)},Gu=function(C,b,F){return{type:b==="+"?"addition":"subtraction",right:F}},LA="$((",R=Ce("$((",!1),q="))",de=Ce("))",!1),He=function(C){return C},Te="$(",Xe=Ce("$(",!1),Et=function(C){return C},Rt="${",qn=Ce("${",!1),Jb=":-",xO=Ce(":-",!1),PO=function(C,b){return{name:C,defaultValue:b}},Wb=":-}",DO=Ce(":-}",!1),RO=function(C){return{name:C,defaultValue:[]}},zb=":+",FO=Ce(":+",!1),NO=function(C,b){return{name:C,alternativeValue:b}},_b=":+}",LO=Ce(":+}",!1),TO=function(C){return{name:C,alternativeValue:[]}},Vb=function(C){return{name:C}},OO="$",MO=Ce("$",!1),UO=function(C){return e.isGlobPattern(C)},KO=function(C){return C},Xb=/^[a-zA-Z0-9_]/,Zb=_e([["a","z"],["A","Z"],["0","9"],"_"],!1,!1),$b=function(){return O()},eQ=/^[$@*?#a-zA-Z0-9_\-]/,tQ=_e(["$","@","*","?","#",["a","z"],["A","Z"],["0","9"],"_","-"],!1,!1),HO=/^[(){}<>$|&; \t"']/,Yu=_e(["(",")","{","}","<",">","$","|","&",";"," ","       ",'"',"'"],!1,!1),rQ=/^[<>&; \t"']/,iQ=_e(["<",">","&",";"," ","        ",'"',"'"],!1,!1),FE=/^[ \t]/,NE=_e([" ","      "],!1,!1),B=0,Ke=0,TA=[{line:1,column:1}],d=0,E=[],I=0,D;if("startRule"in e){if(!(e.startRule in i))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');n=i[e.startRule]}function O(){return t.substring(Ke,B)}function V(){return It(Ke,B)}function ie(C,b){throw b=b!==void 0?b:It(Ke,B),Mi([ut(C)],t.substring(Ke,B),b)}function Be(C,b){throw b=b!==void 0?b:It(Ke,B),Jn(C,b)}function Ce(C,b){return{type:"literal",text:C,ignoreCase:b}}function _e(C,b,F){return{type:"class",parts:C,inverted:b,ignoreCase:F}}function ot(){return{type:"any"}}function wt(){return{type:"end"}}function ut(C){return{type:"other",description:C}}function nt(C){var b=TA[C],F;if(b)return b;for(F=C-1;!TA[F];)F--;for(b=TA[F],b={line:b.line,column:b.column};F<C;)t.charCodeAt(F)===10?(b.line++,b.column=1):b.column++,F++;return TA[C]=b,b}function It(C,b){var F=nt(C),H=nt(b);return{start:{offset:C,line:F.line,column:F.column},end:{offset:b,line:H.line,column:H.column}}}function ke(C){B<d||(B>d&&(d=B,E=[]),E.push(C))}function Jn(C,b){return new nc(C,null,null,b)}function Mi(C,b,F){return new nc(nc.buildMessage(C,b),C,b,F)}function OA(){var C,b;return C=B,b=Gr(),b===r&&(b=null),b!==r&&(Ke=C,b=s(b)),C=b,C}function Gr(){var C,b,F,H,ue;if(C=B,b=Yr(),b!==r){for(F=[],H=je();H!==r;)F.push(H),H=je();F!==r?(H=va(),H!==r?(ue=ps(),ue===r&&(ue=null),ue!==r?(Ke=C,b=o(b,H,ue),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r)}else B=C,C=r;if(C===r)if(C=B,b=Yr(),b!==r){for(F=[],H=je();H!==r;)F.push(H),H=je();F!==r?(H=va(),H===r&&(H=null),H!==r?(Ke=C,b=a(b,H),C=b):(B=C,C=r)):(B=C,C=r)}else B=C,C=r;return C}function ps(){var C,b,F,H,ue;for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();if(b!==r)if(F=Gr(),F!==r){for(H=[],ue=je();ue!==r;)H.push(ue),ue=je();H!==r?(Ke=C,b=l(F),C=b):(B=C,C=r)}else B=C,C=r;else B=C,C=r;return C}function va(){var C;return t.charCodeAt(B)===59?(C=c,B++):(C=r,I===0&&ke(u)),C===r&&(t.charCodeAt(B)===38?(C=g,B++):(C=r,I===0&&ke(f))),C}function Yr(){var C,b,F;return C=B,b=jO(),b!==r?(F=Yge(),F===r&&(F=null),F!==r?(Ke=C,b=h(b,F),C=b):(B=C,C=r)):(B=C,C=r),C}function Yge(){var C,b,F,H,ue,De,Ct;for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();if(b!==r)if(F=qge(),F!==r){for(H=[],ue=je();ue!==r;)H.push(ue),ue=je();if(H!==r)if(ue=Yr(),ue!==r){for(De=[],Ct=je();Ct!==r;)De.push(Ct),Ct=je();De!==r?(Ke=C,b=p(F,ue),C=b):(B=C,C=r)}else B=C,C=r;else B=C,C=r}else B=C,C=r;else B=C,C=r;return C}function qge(){var C;return t.substr(B,2)===m?(C=m,B+=2):(C=r,I===0&&ke(y)),C===r&&(t.substr(B,2)===Q?(C=Q,B+=2):(C=r,I===0&&ke(S))),C}function jO(){var C,b,F;return C=B,b=zge(),b!==r?(F=Jge(),F===r&&(F=null),F!==r?(Ke=C,b=x(b,F),C=b):(B=C,C=r)):(B=C,C=r),C}function Jge(){var C,b,F,H,ue,De,Ct;for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();if(b!==r)if(F=Wge(),F!==r){for(H=[],ue=je();ue!==r;)H.push(ue),ue=je();if(H!==r)if(ue=jO(),ue!==r){for(De=[],Ct=je();Ct!==r;)De.push(Ct),Ct=je();De!==r?(Ke=C,b=M(F,ue),C=b):(B=C,C=r)}else B=C,C=r;else B=C,C=r}else B=C,C=r;else B=C,C=r;return C}function Wge(){var C;return t.substr(B,2)===Y?(C=Y,B+=2):(C=r,I===0&&ke(U)),C===r&&(t.charCodeAt(B)===124?(C=J,B++):(C=r,I===0&&ke(W))),C}function LE(){var C,b,F,H,ue,De;if(C=B,b=eM(),b!==r)if(t.charCodeAt(B)===61?(F=ee,B++):(F=r,I===0&&ke(Z)),F!==r)if(H=qO(),H!==r){for(ue=[],De=je();De!==r;)ue.push(De),De=je();ue!==r?(Ke=C,b=A(b,H),C=b):(B=C,C=r)}else B=C,C=r;else B=C,C=r;else B=C,C=r;if(C===r)if(C=B,b=eM(),b!==r)if(t.charCodeAt(B)===61?(F=ee,B++):(F=r,I===0&&ke(Z)),F!==r){for(H=[],ue=je();ue!==r;)H.push(ue),ue=je();H!==r?(Ke=C,b=ne(b),C=b):(B=C,C=r)}else B=C,C=r;else B=C,C=r;return C}function zge(){var C,b,F,H,ue,De,Ct,bt,Zr,Ei,ds;for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();if(b!==r)if(t.charCodeAt(B)===40?(F=le,B++):(F=r,I===0&&ke(Ae)),F!==r){for(H=[],ue=je();ue!==r;)H.push(ue),ue=je();if(H!==r)if(ue=Gr(),ue!==r){for(De=[],Ct=je();Ct!==r;)De.push(Ct),Ct=je();if(De!==r)if(t.charCodeAt(B)===41?(Ct=T,B++):(Ct=r,I===0&&ke(L)),Ct!==r){for(bt=[],Zr=je();Zr!==r;)bt.push(Zr),Zr=je();if(bt!==r){for(Zr=[],Ei=Th();Ei!==r;)Zr.push(Ei),Ei=Th();if(Zr!==r){for(Ei=[],ds=je();ds!==r;)Ei.push(ds),ds=je();Ei!==r?(Ke=C,b=Ee(ue,Zr),C=b):(B=C,C=r)}else B=C,C=r}else B=C,C=r}else B=C,C=r;else B=C,C=r}else B=C,C=r;else B=C,C=r}else B=C,C=r;else B=C,C=r;if(C===r){for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();if(b!==r)if(t.charCodeAt(B)===123?(F=we,B++):(F=r,I===0&&ke(qe)),F!==r){for(H=[],ue=je();ue!==r;)H.push(ue),ue=je();if(H!==r)if(ue=Gr(),ue!==r){for(De=[],Ct=je();Ct!==r;)De.push(Ct),Ct=je();if(De!==r)if(t.charCodeAt(B)===125?(Ct=re,B++):(Ct=r,I===0&&ke(se)),Ct!==r){for(bt=[],Zr=je();Zr!==r;)bt.push(Zr),Zr=je();if(bt!==r){for(Zr=[],Ei=Th();Ei!==r;)Zr.push(Ei),Ei=Th();if(Zr!==r){for(Ei=[],ds=je();ds!==r;)Ei.push(ds),ds=je();Ei!==r?(Ke=C,b=Qe(ue,Zr),C=b):(B=C,C=r)}else B=C,C=r}else B=C,C=r}else B=C,C=r;else B=C,C=r}else B=C,C=r;else B=C,C=r}else B=C,C=r;else B=C,C=r;if(C===r){for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();if(b!==r){for(F=[],H=LE();H!==r;)F.push(H),H=LE();if(F!==r){for(H=[],ue=je();ue!==r;)H.push(ue),ue=je();if(H!==r){if(ue=[],De=YO(),De!==r)for(;De!==r;)ue.push(De),De=YO();else ue=r;if(ue!==r){for(De=[],Ct=je();Ct!==r;)De.push(Ct),Ct=je();De!==r?(Ke=C,b=he(F,ue),C=b):(B=C,C=r)}else B=C,C=r}else B=C,C=r}else B=C,C=r}else B=C,C=r;if(C===r){for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();if(b!==r){if(F=[],H=LE(),H!==r)for(;H!==r;)F.push(H),H=LE();else F=r;if(F!==r){for(H=[],ue=je();ue!==r;)H.push(ue),ue=je();H!==r?(Ke=C,b=Fe(F),C=b):(B=C,C=r)}else B=C,C=r}else B=C,C=r}}}return C}function GO(){var C,b,F,H,ue;for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();if(b!==r){if(F=[],H=TE(),H!==r)for(;H!==r;)F.push(H),H=TE();else F=r;if(F!==r){for(H=[],ue=je();ue!==r;)H.push(ue),ue=je();H!==r?(Ke=C,b=Ue(F),C=b):(B=C,C=r)}else B=C,C=r}else B=C,C=r;return C}function YO(){var C,b,F;for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();if(b!==r?(F=Th(),F!==r?(Ke=C,b=xe(F),C=b):(B=C,C=r)):(B=C,C=r),C===r){for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();b!==r?(F=TE(),F!==r?(Ke=C,b=xe(F),C=b):(B=C,C=r)):(B=C,C=r)}return C}function Th(){var C,b,F,H,ue;for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();return b!==r?(ve.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(pe)),F===r&&(F=null),F!==r?(H=_ge(),H!==r?(ue=TE(),ue!==r?(Ke=C,b=X(F,H,ue),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r)):(B=C,C=r),C}function _ge(){var C;return t.substr(B,2)===be?(C=be,B+=2):(C=r,I===0&&ke(ce)),C===r&&(t.substr(B,2)===fe?(C=fe,B+=2):(C=r,I===0&&ke(gt)),C===r&&(t.charCodeAt(B)===62?(C=Ht,B++):(C=r,I===0&&ke(Mt)),C===r&&(t.substr(B,3)===mi?(C=mi,B+=3):(C=r,I===0&&ke(jt)),C===r&&(t.substr(B,2)===Qr?(C=Qr,B+=2):(C=r,I===0&&ke(Ti)),C===r&&(t.charCodeAt(B)===60?(C=_s,B++):(C=r,I===0&&ke(Un))))))),C}function TE(){var C,b,F;for(C=B,b=[],F=je();F!==r;)b.push(F),F=je();return b!==r?(F=qO(),F!==r?(Ke=C,b=xe(F),C=b):(B=C,C=r)):(B=C,C=r),C}function qO(){var C,b,F;if(C=B,b=[],F=JO(),F!==r)for(;F!==r;)b.push(F),F=JO();else b=r;return b!==r&&(Ke=C,b=Kn(b)),C=b,C}function JO(){var C,b;return C=B,b=Vge(),b!==r&&(Ke=C,b=vr(b)),C=b,C===r&&(C=B,b=Xge(),b!==r&&(Ke=C,b=vr(b)),C=b,C===r&&(C=B,b=Zge(),b!==r&&(Ke=C,b=vr(b)),C=b,C===r&&(C=B,b=$ge(),b!==r&&(Ke=C,b=vr(b)),C=b))),C}function Vge(){var C,b,F,H;return C=B,t.substr(B,2)===Hn?(b=Hn,B+=2):(b=r,I===0&&ke(us)),b!==r?(F=rfe(),F!==r?(t.charCodeAt(B)===39?(H=Ia,B++):(H=r,I===0&&ke(SA)),H!==r?(Ke=C,b=Du(F),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r),C}function Xge(){var C,b,F,H;return C=B,t.charCodeAt(B)===39?(b=Ia,B++):(b=r,I===0&&ke(SA)),b!==r?(F=efe(),F!==r?(t.charCodeAt(B)===39?(H=Ia,B++):(H=r,I===0&&ke(SA)),H!==r?(Ke=C,b=Du(F),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r),C}function Zge(){var C,b,F,H;if(C=B,t.substr(B,2)===gs?(b=gs,B+=2):(b=r,I===0&&ke(kA)),b!==r&&(Ke=C,b=ya()),C=b,C===r)if(C=B,t.charCodeAt(B)===34?(b=Ru,B++):(b=r,I===0&&ke(xA)),b!==r){for(F=[],H=WO();H!==r;)F.push(H),H=WO();F!==r?(t.charCodeAt(B)===34?(H=Ru,B++):(H=r,I===0&&ke(xA)),H!==r?(Ke=C,b=PA(F),C=b):(B=C,C=r)):(B=C,C=r)}else B=C,C=r;return C}function $ge(){var C,b,F;if(C=B,b=[],F=zO(),F!==r)for(;F!==r;)b.push(F),F=zO();else b=r;return b!==r&&(Ke=C,b=PA(b)),C=b,C}function WO(){var C,b;return C=B,b=ZO(),b!==r&&(Ke=C,b=Sr(b)),C=b,C===r&&(C=B,b=$O(),b!==r&&(Ke=C,b=jl(b)),C=b,C===r&&(C=B,b=aQ(),b!==r&&(Ke=C,b=Fu(b)),C=b,C===r&&(C=B,b=tfe(),b!==r&&(Ke=C,b=So(b)),C=b))),C}function zO(){var C,b;return C=B,b=ZO(),b!==r&&(Ke=C,b=Nu(b)),C=b,C===r&&(C=B,b=$O(),b!==r&&(Ke=C,b=Qh(b)),C=b,C===r&&(C=B,b=aQ(),b!==r&&(Ke=C,b=vh(b)),C=b,C===r&&(C=B,b=sfe(),b!==r&&(Ke=C,b=oe(b)),C=b,C===r&&(C=B,b=nfe(),b!==r&&(Ke=C,b=So(b)),C=b)))),C}function efe(){var C,b,F;for(C=B,b=[],Oi.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(ko));F!==r;)b.push(F),Oi.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(ko));return b!==r&&(Ke=C,b=jn(b)),C=b,C}function tfe(){var C,b,F;if(C=B,b=[],F=_O(),F===r&&(Lu.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(vt))),F!==r)for(;F!==r;)b.push(F),F=_O(),F===r&&(Lu.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(vt)));else b=r;return b!==r&&(Ke=C,b=jn(b)),C=b,C}function _O(){var C,b,F;return C=B,t.substr(B,2)===Gl?(b=Gl,B+=2):(b=r,I===0&&ke(Gn)),b!==r&&(Ke=C,b=fs()),C=b,C===r&&(C=B,t.charCodeAt(B)===92?(b=hs,B++):(b=r,I===0&&ke(pt)),b!==r?(xo.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(lt)),F!==r?(Ke=C,b=mn(F),C=b):(B=C,C=r)):(B=C,C=r)),C}function rfe(){var C,b,F;for(C=B,b=[],F=VO(),F===r&&(Oi.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(ko)));F!==r;)b.push(F),F=VO(),F===r&&(Oi.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(ko)));return b!==r&&(Ke=C,b=jn(b)),C=b,C}function VO(){var C,b,F;return C=B,t.substr(B,2)===v?(b=v,B+=2):(b=r,I===0&&ke(Tt)),b!==r&&(Ke=C,b=Tu()),C=b,C===r&&(C=B,t.substr(B,2)===Yl?(b=Yl,B+=2):(b=r,I===0&&ke(Sh)),b!==r&&(Ke=C,b=kh()),C=b,C===r&&(C=B,t.charCodeAt(B)===92?(b=hs,B++):(b=r,I===0&&ke(pt)),b!==r?(xh.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(Ph)),F!==r?(Ke=C,b=Dh(),C=b):(B=C,C=r)):(B=C,C=r),C===r&&(C=B,t.substr(B,2)===G?(b=G,B+=2):(b=r,I===0&&ke(yt)),b!==r&&(Ke=C,b=DA()),C=b,C===r&&(C=B,t.substr(B,2)===$i?(b=$i,B+=2):(b=r,I===0&&ke(ql)),b!==r&&(Ke=C,b=$e()),C=b,C===r&&(C=B,t.substr(B,2)===wa?(b=wa,B+=2):(b=r,I===0&&ke(Ou)),b!==r&&(Ke=C,b=SE()),C=b,C===r&&(C=B,t.substr(B,2)===Rh?(b=Rh,B+=2):(b=r,I===0&&ke(kE)),b!==r&&(Ke=C,b=gr()),C=b,C===r&&(C=B,t.substr(B,2)===Yn?(b=Yn,B+=2):(b=r,I===0&&ke(Jl)),b!==r&&(Ke=C,b=Fh()),C=b,C===r&&(C=B,t.charCodeAt(B)===92?(b=hs,B++):(b=r,I===0&&ke(pt)),b!==r?(Vs.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(Ba)),F!==r?(Ke=C,b=mn(F),C=b):(B=C,C=r)):(B=C,C=r),C===r&&(C=ife()))))))))),C}function ife(){var C,b,F,H,ue,De,Ct,bt,Zr,Ei,ds,AQ;return C=B,t.charCodeAt(B)===92?(b=hs,B++):(b=r,I===0&&ke(pt)),b!==r?(F=nQ(),F!==r?(Ke=C,b=En(F),C=b):(B=C,C=r)):(B=C,C=r),C===r&&(C=B,t.substr(B,2)===Oe?(b=Oe,B+=2):(b=r,I===0&&ke(Mu)),b!==r?(F=B,H=B,ue=nQ(),ue!==r?(De=Wn(),De!==r?(ue=[ue,De],H=ue):(B=H,H=r)):(B=H,H=r),H===r&&(H=nQ()),H!==r?F=t.substring(F,B):F=H,F!==r?(Ke=C,b=En(F),C=b):(B=C,C=r)):(B=C,C=r),C===r&&(C=B,t.substr(B,2)===Wl?(b=Wl,B+=2):(b=r,I===0&&ke(Xs)),b!==r?(F=B,H=B,ue=Wn(),ue!==r?(De=Wn(),De!==r?(Ct=Wn(),Ct!==r?(bt=Wn(),bt!==r?(ue=[ue,De,Ct,bt],H=ue):(B=H,H=r)):(B=H,H=r)):(B=H,H=r)):(B=H,H=r),H!==r?F=t.substring(F,B):F=H,F!==r?(Ke=C,b=En(F),C=b):(B=C,C=r)):(B=C,C=r),C===r&&(C=B,t.substr(B,2)===zl?(b=zl,B+=2):(b=r,I===0&&ke(RA)),b!==r?(F=B,H=B,ue=Wn(),ue!==r?(De=Wn(),De!==r?(Ct=Wn(),Ct!==r?(bt=Wn(),bt!==r?(Zr=Wn(),Zr!==r?(Ei=Wn(),Ei!==r?(ds=Wn(),ds!==r?(AQ=Wn(),AQ!==r?(ue=[ue,De,Ct,bt,Zr,Ei,ds,AQ],H=ue):(B=H,H=r)):(B=H,H=r)):(B=H,H=r)):(B=H,H=r)):(B=H,H=r)):(B=H,H=r)):(B=H,H=r)):(B=H,H=r),H!==r?F=t.substring(F,B):F=H,F!==r?(Ke=C,b=Uu(F),C=b):(B=C,C=r)):(B=C,C=r)))),C}function nQ(){var C;return Ku.test(t.charAt(B))?(C=t.charAt(B),B++):(C=r,I===0&&ke(ba)),C}function Wn(){var C;return Qa.test(t.charAt(B))?(C=t.charAt(B),B++):(C=r,I===0&&ke(it)),C}function nfe(){var C,b,F,H,ue;if(C=B,b=[],F=B,t.charCodeAt(B)===92?(H=hs,B++):(H=r,I===0&&ke(pt)),H!==r?(t.length>B?(ue=t.charAt(B),B++):(ue=r,I===0&&ke(Po)),ue!==r?(Ke=F,H=mn(ue),F=H):(B=F,F=r)):(B=F,F=r),F===r&&(F=B,H=B,I++,ue=tM(),I--,ue===r?H=void 0:(B=H,H=r),H!==r?(t.length>B?(ue=t.charAt(B),B++):(ue=r,I===0&&ke(Po)),ue!==r?(Ke=F,H=mn(ue),F=H):(B=F,F=r)):(B=F,F=r)),F!==r)for(;F!==r;)b.push(F),F=B,t.charCodeAt(B)===92?(H=hs,B++):(H=r,I===0&&ke(pt)),H!==r?(t.length>B?(ue=t.charAt(B),B++):(ue=r,I===0&&ke(Po)),ue!==r?(Ke=F,H=mn(ue),F=H):(B=F,F=r)):(B=F,F=r),F===r&&(F=B,H=B,I++,ue=tM(),I--,ue===r?H=void 0:(B=H,H=r),H!==r?(t.length>B?(ue=t.charAt(B),B++):(ue=r,I===0&&ke(Po)),ue!==r?(Ke=F,H=mn(ue),F=H):(B=F,F=r)):(B=F,F=r));else b=r;return b!==r&&(Ke=C,b=jn(b)),C=b,C}function sQ(){var C,b,F,H,ue,De;if(C=B,t.charCodeAt(B)===45?(b=FA,B++):(b=r,I===0&&ke(_l)),b===r&&(t.charCodeAt(B)===43?(b=Zs,B++):(b=r,I===0&&ke(Vl))),b===r&&(b=null),b!==r){if(F=[],ve.test(t.charAt(B))?(H=t.charAt(B),B++):(H=r,I===0&&ke(pe)),H!==r)for(;H!==r;)F.push(H),ve.test(t.charAt(B))?(H=t.charAt(B),B++):(H=r,I===0&&ke(pe));else F=r;if(F!==r)if(t.charCodeAt(B)===46?(H=xE,B++):(H=r,I===0&&ke(Nh)),H!==r){if(ue=[],ve.test(t.charAt(B))?(De=t.charAt(B),B++):(De=r,I===0&&ke(pe)),De!==r)for(;De!==r;)ue.push(De),ve.test(t.charAt(B))?(De=t.charAt(B),B++):(De=r,I===0&&ke(pe));else ue=r;ue!==r?(Ke=C,b=Hu(b,F,ue),C=b):(B=C,C=r)}else B=C,C=r;else B=C,C=r}else B=C,C=r;if(C===r){if(C=B,t.charCodeAt(B)===45?(b=FA,B++):(b=r,I===0&&ke(_l)),b===r&&(t.charCodeAt(B)===43?(b=Zs,B++):(b=r,I===0&&ke(Vl))),b===r&&(b=null),b!==r){if(F=[],ve.test(t.charAt(B))?(H=t.charAt(B),B++):(H=r,I===0&&ke(pe)),H!==r)for(;H!==r;)F.push(H),ve.test(t.charAt(B))?(H=t.charAt(B),B++):(H=r,I===0&&ke(pe));else F=r;F!==r?(Ke=C,b=Lh(b,F),C=b):(B=C,C=r)}else B=C,C=r;if(C===r&&(C=B,b=aQ(),b!==r&&(Ke=C,b=PE(b)),C=b,C===r&&(C=B,b=Zl(),b!==r&&(Ke=C,b=Xl(b)),C=b,C===r)))if(C=B,t.charCodeAt(B)===40?(b=le,B++):(b=r,I===0&&ke(Ae)),b!==r){for(F=[],H=je();H!==r;)F.push(H),H=je();if(F!==r)if(H=XO(),H!==r){for(ue=[],De=je();De!==r;)ue.push(De),De=je();ue!==r?(t.charCodeAt(B)===41?(De=T,B++):(De=r,I===0&&ke(L)),De!==r?(Ke=C,b=DE(H),C=b):(B=C,C=r)):(B=C,C=r)}else B=C,C=r;else B=C,C=r}else B=C,C=r}return C}function oQ(){var C,b,F,H,ue,De,Ct,bt;if(C=B,b=sQ(),b!==r){for(F=[],H=B,ue=[],De=je();De!==r;)ue.push(De),De=je();if(ue!==r)if(t.charCodeAt(B)===42?(De=ju,B++):(De=r,I===0&&ke(NA)),De===r&&(t.charCodeAt(B)===47?(De=Lr,B++):(De=r,I===0&&ke(RE))),De!==r){for(Ct=[],bt=je();bt!==r;)Ct.push(bt),bt=je();Ct!==r?(bt=sQ(),bt!==r?(Ke=H,ue=$s(b,De,bt),H=ue):(B=H,H=r)):(B=H,H=r)}else B=H,H=r;else B=H,H=r;for(;H!==r;){for(F.push(H),H=B,ue=[],De=je();De!==r;)ue.push(De),De=je();if(ue!==r)if(t.charCodeAt(B)===42?(De=ju,B++):(De=r,I===0&&ke(NA)),De===r&&(t.charCodeAt(B)===47?(De=Lr,B++):(De=r,I===0&&ke(RE))),De!==r){for(Ct=[],bt=je();bt!==r;)Ct.push(bt),bt=je();Ct!==r?(bt=sQ(),bt!==r?(Ke=H,ue=$s(b,De,bt),H=ue):(B=H,H=r)):(B=H,H=r)}else B=H,H=r;else B=H,H=r}F!==r?(Ke=C,b=eo(b,F),C=b):(B=C,C=r)}else B=C,C=r;return C}function XO(){var C,b,F,H,ue,De,Ct,bt;if(C=B,b=oQ(),b!==r){for(F=[],H=B,ue=[],De=je();De!==r;)ue.push(De),De=je();if(ue!==r)if(t.charCodeAt(B)===43?(De=Zs,B++):(De=r,I===0&&ke(Vl)),De===r&&(t.charCodeAt(B)===45?(De=FA,B++):(De=r,I===0&&ke(_l))),De!==r){for(Ct=[],bt=je();bt!==r;)Ct.push(bt),bt=je();Ct!==r?(bt=oQ(),bt!==r?(Ke=H,ue=Gu(b,De,bt),H=ue):(B=H,H=r)):(B=H,H=r)}else B=H,H=r;else B=H,H=r;for(;H!==r;){for(F.push(H),H=B,ue=[],De=je();De!==r;)ue.push(De),De=je();if(ue!==r)if(t.charCodeAt(B)===43?(De=Zs,B++):(De=r,I===0&&ke(Vl)),De===r&&(t.charCodeAt(B)===45?(De=FA,B++):(De=r,I===0&&ke(_l))),De!==r){for(Ct=[],bt=je();bt!==r;)Ct.push(bt),bt=je();Ct!==r?(bt=oQ(),bt!==r?(Ke=H,ue=Gu(b,De,bt),H=ue):(B=H,H=r)):(B=H,H=r)}else B=H,H=r;else B=H,H=r}F!==r?(Ke=C,b=eo(b,F),C=b):(B=C,C=r)}else B=C,C=r;return C}function ZO(){var C,b,F,H,ue,De;if(C=B,t.substr(B,3)===LA?(b=LA,B+=3):(b=r,I===0&&ke(R)),b!==r){for(F=[],H=je();H!==r;)F.push(H),H=je();if(F!==r)if(H=XO(),H!==r){for(ue=[],De=je();De!==r;)ue.push(De),De=je();ue!==r?(t.substr(B,2)===q?(De=q,B+=2):(De=r,I===0&&ke(de)),De!==r?(Ke=C,b=He(H),C=b):(B=C,C=r)):(B=C,C=r)}else B=C,C=r;else B=C,C=r}else B=C,C=r;return C}function $O(){var C,b,F,H;return C=B,t.substr(B,2)===Te?(b=Te,B+=2):(b=r,I===0&&ke(Xe)),b!==r?(F=Gr(),F!==r?(t.charCodeAt(B)===41?(H=T,B++):(H=r,I===0&&ke(L)),H!==r?(Ke=C,b=Et(F),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r),C}function aQ(){var C,b,F,H,ue,De;return C=B,t.substr(B,2)===Rt?(b=Rt,B+=2):(b=r,I===0&&ke(qn)),b!==r?(F=Zl(),F!==r?(t.substr(B,2)===Jb?(H=Jb,B+=2):(H=r,I===0&&ke(xO)),H!==r?(ue=GO(),ue!==r?(t.charCodeAt(B)===125?(De=re,B++):(De=r,I===0&&ke(se)),De!==r?(Ke=C,b=PO(F,ue),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r)):(B=C,C=r)):(B=C,C=r),C===r&&(C=B,t.substr(B,2)===Rt?(b=Rt,B+=2):(b=r,I===0&&ke(qn)),b!==r?(F=Zl(),F!==r?(t.substr(B,3)===Wb?(H=Wb,B+=3):(H=r,I===0&&ke(DO)),H!==r?(Ke=C,b=RO(F),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r),C===r&&(C=B,t.substr(B,2)===Rt?(b=Rt,B+=2):(b=r,I===0&&ke(qn)),b!==r?(F=Zl(),F!==r?(t.substr(B,2)===zb?(H=zb,B+=2):(H=r,I===0&&ke(FO)),H!==r?(ue=GO(),ue!==r?(t.charCodeAt(B)===125?(De=re,B++):(De=r,I===0&&ke(se)),De!==r?(Ke=C,b=NO(F,ue),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r)):(B=C,C=r)):(B=C,C=r),C===r&&(C=B,t.substr(B,2)===Rt?(b=Rt,B+=2):(b=r,I===0&&ke(qn)),b!==r?(F=Zl(),F!==r?(t.substr(B,3)===_b?(H=_b,B+=3):(H=r,I===0&&ke(LO)),H!==r?(Ke=C,b=TO(F),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r),C===r&&(C=B,t.substr(B,2)===Rt?(b=Rt,B+=2):(b=r,I===0&&ke(qn)),b!==r?(F=Zl(),F!==r?(t.charCodeAt(B)===125?(H=re,B++):(H=r,I===0&&ke(se)),H!==r?(Ke=C,b=Vb(F),C=b):(B=C,C=r)):(B=C,C=r)):(B=C,C=r),C===r&&(C=B,t.charCodeAt(B)===36?(b=OO,B++):(b=r,I===0&&ke(MO)),b!==r?(F=Zl(),F!==r?(Ke=C,b=Vb(F),C=b):(B=C,C=r)):(B=C,C=r)))))),C}function sfe(){var C,b,F;return C=B,b=ofe(),b!==r?(Ke=B,F=UO(b),F?F=void 0:F=r,F!==r?(Ke=C,b=KO(b),C=b):(B=C,C=r)):(B=C,C=r),C}function ofe(){var C,b,F,H,ue;if(C=B,b=[],F=B,H=B,I++,ue=rM(),I--,ue===r?H=void 0:(B=H,H=r),H!==r?(t.length>B?(ue=t.charAt(B),B++):(ue=r,I===0&&ke(Po)),ue!==r?(Ke=F,H=mn(ue),F=H):(B=F,F=r)):(B=F,F=r),F!==r)for(;F!==r;)b.push(F),F=B,H=B,I++,ue=rM(),I--,ue===r?H=void 0:(B=H,H=r),H!==r?(t.length>B?(ue=t.charAt(B),B++):(ue=r,I===0&&ke(Po)),ue!==r?(Ke=F,H=mn(ue),F=H):(B=F,F=r)):(B=F,F=r);else b=r;return b!==r&&(Ke=C,b=jn(b)),C=b,C}function eM(){var C,b,F;if(C=B,b=[],Xb.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(Zb)),F!==r)for(;F!==r;)b.push(F),Xb.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(Zb));else b=r;return b!==r&&(Ke=C,b=$b()),C=b,C}function Zl(){var C,b,F;if(C=B,b=[],eQ.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(tQ)),F!==r)for(;F!==r;)b.push(F),eQ.test(t.charAt(B))?(F=t.charAt(B),B++):(F=r,I===0&&ke(tQ));else b=r;return b!==r&&(Ke=C,b=$b()),C=b,C}function tM(){var C;return HO.test(t.charAt(B))?(C=t.charAt(B),B++):(C=r,I===0&&ke(Yu)),C}function rM(){var C;return rQ.test(t.charAt(B))?(C=t.charAt(B),B++):(C=r,I===0&&ke(iQ)),C}function je(){var C,b;if(C=[],FE.test(t.charAt(B))?(b=t.charAt(B),B++):(b=r,I===0&&ke(NE)),b!==r)for(;b!==r;)C.push(b),FE.test(t.charAt(B))?(b=t.charAt(B),B++):(b=r,I===0&&ke(NE));else C=r;return C}if(D=n(),D!==r&&B===t.length)return D;throw D!==r&&B<t.length&&ke(wt()),Mi(E,d<t.length?t.charAt(d):null,d<t.length?It(d,d+1):It(d,d))}f1.exports={SyntaxError:nc,parse:she}});var C1=w((SZe,d1)=>{"use strict";function ohe(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function oc(t,e,r,i){this.message=t,this.expected=e,this.found=r,this.location=i,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,oc)}ohe(oc,Error);oc.buildMessage=function(t,e){var r={literal:function(c){return'"'+n(c.text)+'"'},class:function(c){var u="",g;for(g=0;g<c.parts.length;g++)u+=c.parts[g]instanceof Array?s(c.parts[g][0])+"-"+s(c.parts[g][1]):s(c.parts[g]);return"["+(c.inverted?"^":"")+u+"]"},any:function(c){return"any character"},end:function(c){return"end of input"},other:function(c){return c.description}};function i(c){return c.charCodeAt(0).toString(16).toUpperCase()}function n(c){return c.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(u){return"\\x0"+i(u)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(u){return"\\x"+i(u)})}function s(c){return c.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(u){return"\\x0"+i(u)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(u){return"\\x"+i(u)})}function o(c){return r[c.type](c)}function a(c){var u=new Array(c.length),g,f;for(g=0;g<c.length;g++)u[g]=o(c[g]);if(u.sort(),u.length>0){for(g=1,f=1;g<u.length;g++)u[g-1]!==u[g]&&(u[f]=u[g],f++);u.length=f}switch(u.length){case 1:return u[0];case 2:return u[0]+" or "+u[1];default:return u.slice(0,-1).join(", ")+", or "+u[u.length-1]}}function l(c){return c?'"'+n(c)+'"':"end of input"}return"Expected "+a(t)+" but "+l(e)+" found."};function ahe(t,e){e=e!==void 0?e:{};var r={},i={resolution:he},n=he,s="/",o=le("/",!1),a=function(pe,X){return{from:pe,descriptor:X}},l=function(pe){return{descriptor:pe}},c="@",u=le("@",!1),g=function(pe,X){return{fullName:pe,description:X}},f=function(pe){return{fullName:pe}},h=function(){return ee()},p=/^[^\/@]/,m=Ae(["/","@"],!0,!1),y=/^[^\/]/,Q=Ae(["/"],!0,!1),S=0,x=0,M=[{line:1,column:1}],Y=0,U=[],J=0,W;if("startRule"in e){if(!(e.startRule in i))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');n=i[e.startRule]}function ee(){return t.substring(x,S)}function Z(){return qe(x,S)}function A(pe,X){throw X=X!==void 0?X:qe(x,S),Qe([Ee(pe)],t.substring(x,S),X)}function ne(pe,X){throw X=X!==void 0?X:qe(x,S),se(pe,X)}function le(pe,X){return{type:"literal",text:pe,ignoreCase:X}}function Ae(pe,X,be){return{type:"class",parts:pe,inverted:X,ignoreCase:be}}function T(){return{type:"any"}}function L(){return{type:"end"}}function Ee(pe){return{type:"other",description:pe}}function we(pe){var X=M[pe],be;if(X)return X;for(be=pe-1;!M[be];)be--;for(X=M[be],X={line:X.line,column:X.column};be<pe;)t.charCodeAt(be)===10?(X.line++,X.column=1):X.column++,be++;return M[pe]=X,X}function qe(pe,X){var be=we(pe),ce=we(X);return{start:{offset:pe,line:be.line,column:be.column},end:{offset:X,line:ce.line,column:ce.column}}}function re(pe){S<Y||(S>Y&&(Y=S,U=[]),U.push(pe))}function se(pe,X){return new oc(pe,null,null,X)}function Qe(pe,X,be){return new oc(oc.buildMessage(pe,X),pe,X,be)}function he(){var pe,X,be,ce;return pe=S,X=Fe(),X!==r?(t.charCodeAt(S)===47?(be=s,S++):(be=r,J===0&&re(o)),be!==r?(ce=Fe(),ce!==r?(x=pe,X=a(X,ce),pe=X):(S=pe,pe=r)):(S=pe,pe=r)):(S=pe,pe=r),pe===r&&(pe=S,X=Fe(),X!==r&&(x=pe,X=l(X)),pe=X),pe}function Fe(){var pe,X,be,ce;return pe=S,X=Ue(),X!==r?(t.charCodeAt(S)===64?(be=c,S++):(be=r,J===0&&re(u)),be!==r?(ce=ve(),ce!==r?(x=pe,X=g(X,ce),pe=X):(S=pe,pe=r)):(S=pe,pe=r)):(S=pe,pe=r),pe===r&&(pe=S,X=Ue(),X!==r&&(x=pe,X=f(X)),pe=X),pe}function Ue(){var pe,X,be,ce,fe;return pe=S,t.charCodeAt(S)===64?(X=c,S++):(X=r,J===0&&re(u)),X!==r?(be=xe(),be!==r?(t.charCodeAt(S)===47?(ce=s,S++):(ce=r,J===0&&re(o)),ce!==r?(fe=xe(),fe!==r?(x=pe,X=h(),pe=X):(S=pe,pe=r)):(S=pe,pe=r)):(S=pe,pe=r)):(S=pe,pe=r),pe===r&&(pe=S,X=xe(),X!==r&&(x=pe,X=h()),pe=X),pe}function xe(){var pe,X,be;if(pe=S,X=[],p.test(t.charAt(S))?(be=t.charAt(S),S++):(be=r,J===0&&re(m)),be!==r)for(;be!==r;)X.push(be),p.test(t.charAt(S))?(be=t.charAt(S),S++):(be=r,J===0&&re(m));else X=r;return X!==r&&(x=pe,X=h()),pe=X,pe}function ve(){var pe,X,be;if(pe=S,X=[],y.test(t.charAt(S))?(be=t.charAt(S),S++):(be=r,J===0&&re(Q)),be!==r)for(;be!==r;)X.push(be),y.test(t.charAt(S))?(be=t.charAt(S),S++):(be=r,J===0&&re(Q));else X=r;return X!==r&&(x=pe,X=h()),pe=X,pe}if(W=n(),W!==r&&S===t.length)return W;throw W!==r&&S<t.length&&re(L()),Qe(U,Y<t.length?t.charAt(Y):null,Y<t.length?qe(Y,Y+1):qe(Y,Y))}d1.exports={SyntaxError:oc,parse:ahe}});var Ac=w((xZe,ac)=>{"use strict";function E1(t){return typeof t=="undefined"||t===null}function Ahe(t){return typeof t=="object"&&t!==null}function lhe(t){return Array.isArray(t)?t:E1(t)?[]:[t]}function che(t,e){var r,i,n,s;if(e)for(s=Object.keys(e),r=0,i=s.length;r<i;r+=1)n=s[r],t[n]=e[n];return t}function uhe(t,e){var r="",i;for(i=0;i<e;i+=1)r+=t;return r}function ghe(t){return t===0&&Number.NEGATIVE_INFINITY===1/t}ac.exports.isNothing=E1;ac.exports.isObject=Ahe;ac.exports.toArray=lhe;ac.exports.repeat=uhe;ac.exports.isNegativeZero=ghe;ac.exports.extend=che});var Vu=w((PZe,I1)=>{"use strict";function ep(t,e){Error.call(this),this.name="YAMLException",this.reason=t,this.mark=e,this.message=(this.reason||"(unknown reason)")+(this.mark?" "+this.mark.toString():""),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack||""}ep.prototype=Object.create(Error.prototype);ep.prototype.constructor=ep;ep.prototype.toString=function(e){var r=this.name+": ";return r+=this.reason||"(unknown reason)",!e&&this.mark&&(r+=" "+this.mark.toString()),r};I1.exports=ep});var B1=w((DZe,y1)=>{"use strict";var w1=Ac();function HQ(t,e,r,i,n){this.name=t,this.buffer=e,this.position=r,this.line=i,this.column=n}HQ.prototype.getSnippet=function(e,r){var i,n,s,o,a;if(!this.buffer)return null;for(e=e||4,r=r||75,i="",n=this.position;n>0&&`\0\r
+\x85\u2028\u2029`.indexOf(this.buffer.charAt(n-1))===-1;)if(n-=1,this.position-n>r/2-1){i=" ... ",n+=5;break}for(s="",o=this.position;o<this.buffer.length&&`\0\r
+\x85\u2028\u2029`.indexOf(this.buffer.charAt(o))===-1;)if(o+=1,o-this.position>r/2-1){s=" ... ",o-=5;break}return a=this.buffer.slice(n,o),w1.repeat(" ",e)+i+a+s+`
+`+w1.repeat(" ",e+this.position-n+i.length)+"^"};HQ.prototype.toString=function(e){var r,i="";return this.name&&(i+='in "'+this.name+'" '),i+="at line "+(this.line+1)+", column "+(this.column+1),e||(r=this.getSnippet(),r&&(i+=`:
+`+r)),i};y1.exports=HQ});var li=w((RZe,b1)=>{"use strict";var Q1=Vu(),fhe=["kind","resolve","construct","instanceOf","predicate","represent","defaultStyle","styleAliases"],hhe=["scalar","sequence","mapping"];function phe(t){var e={};return t!==null&&Object.keys(t).forEach(function(r){t[r].forEach(function(i){e[String(i)]=r})}),e}function dhe(t,e){if(e=e||{},Object.keys(e).forEach(function(r){if(fhe.indexOf(r)===-1)throw new Q1('Unknown option "'+r+'" is met in definition of "'+t+'" YAML type.')}),this.tag=t,this.kind=e.kind||null,this.resolve=e.resolve||function(){return!0},this.construct=e.construct||function(r){return r},this.instanceOf=e.instanceOf||null,this.predicate=e.predicate||null,this.represent=e.represent||null,this.defaultStyle=e.defaultStyle||null,this.styleAliases=phe(e.styleAliases||null),hhe.indexOf(this.kind)===-1)throw new Q1('Unknown kind "'+this.kind+'" is specified for "'+t+'" YAML type.')}b1.exports=dhe});var lc=w((FZe,v1)=>{"use strict";var S1=Ac(),nI=Vu(),Che=li();function jQ(t,e,r){var i=[];return t.include.forEach(function(n){r=jQ(n,e,r)}),t[e].forEach(function(n){r.forEach(function(s,o){s.tag===n.tag&&s.kind===n.kind&&i.push(o)}),r.push(n)}),r.filter(function(n,s){return i.indexOf(s)===-1})}function mhe(){var t={scalar:{},sequence:{},mapping:{},fallback:{}},e,r;function i(n){t[n.kind][n.tag]=t.fallback[n.tag]=n}for(e=0,r=arguments.length;e<r;e+=1)arguments[e].forEach(i);return t}function Xu(t){this.include=t.include||[],this.implicit=t.implicit||[],this.explicit=t.explicit||[],this.implicit.forEach(function(e){if(e.loadKind&&e.loadKind!=="scalar")throw new nI("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.")}),this.compiledImplicit=jQ(this,"implicit",[]),this.compiledExplicit=jQ(this,"explicit",[]),this.compiledTypeMap=mhe(this.compiledImplicit,this.compiledExplicit)}Xu.DEFAULT=null;Xu.create=function(){var e,r;switch(arguments.length){case 1:e=Xu.DEFAULT,r=arguments[0];break;case 2:e=arguments[0],r=arguments[1];break;default:throw new nI("Wrong number of arguments for Schema.create function")}if(e=S1.toArray(e),r=S1.toArray(r),!e.every(function(i){return i instanceof Xu}))throw new nI("Specified list of super schemas (or a single Schema object) contains a non-Schema object.");if(!r.every(function(i){return i instanceof Che}))throw new nI("Specified list of YAML types (or a single Type object) contains a non-Type object.");return new Xu({include:e,explicit:r})};v1.exports=Xu});var x1=w((NZe,k1)=>{"use strict";var Ehe=li();k1.exports=new Ehe("tag:yaml.org,2002:str",{kind:"scalar",construct:function(t){return t!==null?t:""}})});var D1=w((LZe,P1)=>{"use strict";var Ihe=li();P1.exports=new Ihe("tag:yaml.org,2002:seq",{kind:"sequence",construct:function(t){return t!==null?t:[]}})});var F1=w((TZe,R1)=>{"use strict";var yhe=li();R1.exports=new yhe("tag:yaml.org,2002:map",{kind:"mapping",construct:function(t){return t!==null?t:{}}})});var sI=w((OZe,N1)=>{"use strict";var whe=lc();N1.exports=new whe({explicit:[x1(),D1(),F1()]})});var T1=w((MZe,L1)=>{"use strict";var Bhe=li();function bhe(t){if(t===null)return!0;var e=t.length;return e===1&&t==="~"||e===4&&(t==="null"||t==="Null"||t==="NULL")}function Qhe(){return null}function vhe(t){return t===null}L1.exports=new Bhe("tag:yaml.org,2002:null",{kind:"scalar",resolve:bhe,construct:Qhe,predicate:vhe,represent:{canonical:function(){return"~"},lowercase:function(){return"null"},uppercase:function(){return"NULL"},camelcase:function(){return"Null"}},defaultStyle:"lowercase"})});var M1=w((UZe,O1)=>{"use strict";var She=li();function khe(t){if(t===null)return!1;var e=t.length;return e===4&&(t==="true"||t==="True"||t==="TRUE")||e===5&&(t==="false"||t==="False"||t==="FALSE")}function xhe(t){return t==="true"||t==="True"||t==="TRUE"}function Phe(t){return Object.prototype.toString.call(t)==="[object Boolean]"}O1.exports=new She("tag:yaml.org,2002:bool",{kind:"scalar",resolve:khe,construct:xhe,predicate:Phe,represent:{lowercase:function(t){return t?"true":"false"},uppercase:function(t){return t?"TRUE":"FALSE"},camelcase:function(t){return t?"True":"False"}},defaultStyle:"lowercase"})});var K1=w((KZe,U1)=>{"use strict";var Dhe=Ac(),Rhe=li();function Fhe(t){return 48<=t&&t<=57||65<=t&&t<=70||97<=t&&t<=102}function Nhe(t){return 48<=t&&t<=55}function Lhe(t){return 48<=t&&t<=57}function The(t){if(t===null)return!1;var e=t.length,r=0,i=!1,n;if(!e)return!1;if(n=t[r],(n==="-"||n==="+")&&(n=t[++r]),n==="0"){if(r+1===e)return!0;if(n=t[++r],n==="b"){for(r++;r<e;r++)if(n=t[r],n!=="_"){if(n!=="0"&&n!=="1")return!1;i=!0}return i&&n!=="_"}if(n==="x"){for(r++;r<e;r++)if(n=t[r],n!=="_"){if(!Fhe(t.charCodeAt(r)))return!1;i=!0}return i&&n!=="_"}for(;r<e;r++)if(n=t[r],n!=="_"){if(!Nhe(t.charCodeAt(r)))return!1;i=!0}return i&&n!=="_"}if(n==="_")return!1;for(;r<e;r++)if(n=t[r],n!=="_"){if(n===":")break;if(!Lhe(t.charCodeAt(r)))return!1;i=!0}return!i||n==="_"?!1:n!==":"?!0:/^(:[0-5]?[0-9])+$/.test(t.slice(r))}function Ohe(t){var e=t,r=1,i,n,s=[];return e.indexOf("_")!==-1&&(e=e.replace(/_/g,"")),i=e[0],(i==="-"||i==="+")&&(i==="-"&&(r=-1),e=e.slice(1),i=e[0]),e==="0"?0:i==="0"?e[1]==="b"?r*parseInt(e.slice(2),2):e[1]==="x"?r*parseInt(e,16):r*parseInt(e,8):e.indexOf(":")!==-1?(e.split(":").forEach(function(o){s.unshift(parseInt(o,10))}),e=0,n=1,s.forEach(function(o){e+=o*n,n*=60}),r*e):r*parseInt(e,10)}function Mhe(t){return Object.prototype.toString.call(t)==="[object Number]"&&t%1==0&&!Dhe.isNegativeZero(t)}U1.exports=new Rhe("tag:yaml.org,2002:int",{kind:"scalar",resolve:The,construct:Ohe,predicate:Mhe,represent:{binary:function(t){return t>=0?"0b"+t.toString(2):"-0b"+t.toString(2).slice(1)},octal:function(t){return t>=0?"0"+t.toString(8):"-0"+t.toString(8).slice(1)},decimal:function(t){return t.toString(10)},hexadecimal:function(t){return t>=0?"0x"+t.toString(16).toUpperCase():"-0x"+t.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}})});var G1=w((HZe,H1)=>{"use strict";var j1=Ac(),Uhe=li(),Khe=new RegExp("^(?:[-+]?(?:0|[1-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]*|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");function Hhe(t){return!(t===null||!Khe.test(t)||t[t.length-1]==="_")}function jhe(t){var e,r,i,n;return e=t.replace(/_/g,"").toLowerCase(),r=e[0]==="-"?-1:1,n=[],"+-".indexOf(e[0])>=0&&(e=e.slice(1)),e===".inf"?r===1?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:e===".nan"?NaN:e.indexOf(":")>=0?(e.split(":").forEach(function(s){n.unshift(parseFloat(s,10))}),e=0,i=1,n.forEach(function(s){e+=s*i,i*=60}),r*e):r*parseFloat(e,10)}var Ghe=/^[-+]?[0-9]+e/;function Yhe(t,e){var r;if(isNaN(t))switch(e){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===t)switch(e){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===t)switch(e){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(j1.isNegativeZero(t))return"-0.0";return r=t.toString(10),Ghe.test(r)?r.replace("e",".e"):r}function qhe(t){return Object.prototype.toString.call(t)==="[object Number]"&&(t%1!=0||j1.isNegativeZero(t))}H1.exports=new Uhe("tag:yaml.org,2002:float",{kind:"scalar",resolve:Hhe,construct:jhe,predicate:qhe,represent:Yhe,defaultStyle:"lowercase"})});var GQ=w((jZe,Y1)=>{"use strict";var Jhe=lc();Y1.exports=new Jhe({include:[sI()],implicit:[T1(),M1(),K1(),G1()]})});var YQ=w((GZe,q1)=>{"use strict";var Whe=lc();q1.exports=new Whe({include:[GQ()]})});var _1=w((YZe,J1)=>{"use strict";var zhe=li(),W1=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),z1=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");function _he(t){return t===null?!1:W1.exec(t)!==null||z1.exec(t)!==null}function Vhe(t){var e,r,i,n,s,o,a,l=0,c=null,u,g,f;if(e=W1.exec(t),e===null&&(e=z1.exec(t)),e===null)throw new Error("Date resolve error");if(r=+e[1],i=+e[2]-1,n=+e[3],!e[4])return new Date(Date.UTC(r,i,n));if(s=+e[4],o=+e[5],a=+e[6],e[7]){for(l=e[7].slice(0,3);l.length<3;)l+="0";l=+l}return e[9]&&(u=+e[10],g=+(e[11]||0),c=(u*60+g)*6e4,e[9]==="-"&&(c=-c)),f=new Date(Date.UTC(r,i,n,s,o,a,l)),c&&f.setTime(f.getTime()-c),f}function Xhe(t){return t.toISOString()}J1.exports=new zhe("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:_he,construct:Vhe,instanceOf:Date,represent:Xhe})});var X1=w((qZe,V1)=>{"use strict";var Zhe=li();function $he(t){return t==="<<"||t===null}V1.exports=new Zhe("tag:yaml.org,2002:merge",{kind:"scalar",resolve:$he})});var eU=w((JZe,Z1)=>{"use strict";var cc;try{$1=require,cc=$1("buffer").Buffer}catch(t){}var $1,epe=li(),qQ=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=
+\r`;function tpe(t){if(t===null)return!1;var e,r,i=0,n=t.length,s=qQ;for(r=0;r<n;r++)if(e=s.indexOf(t.charAt(r)),!(e>64)){if(e<0)return!1;i+=6}return i%8==0}function rpe(t){var e,r,i=t.replace(/[\r\n=]/g,""),n=i.length,s=qQ,o=0,a=[];for(e=0;e<n;e++)e%4==0&&e&&(a.push(o>>16&255),a.push(o>>8&255),a.push(o&255)),o=o<<6|s.indexOf(i.charAt(e));return r=n%4*6,r===0?(a.push(o>>16&255),a.push(o>>8&255),a.push(o&255)):r===18?(a.push(o>>10&255),a.push(o>>2&255)):r===12&&a.push(o>>4&255),cc?cc.from?cc.from(a):new cc(a):a}function ipe(t){var e="",r=0,i,n,s=t.length,o=qQ;for(i=0;i<s;i++)i%3==0&&i&&(e+=o[r>>18&63],e+=o[r>>12&63],e+=o[r>>6&63],e+=o[r&63]),r=(r<<8)+t[i];return n=s%3,n===0?(e+=o[r>>18&63],e+=o[r>>12&63],e+=o[r>>6&63],e+=o[r&63]):n===2?(e+=o[r>>10&63],e+=o[r>>4&63],e+=o[r<<2&63],e+=o[64]):n===1&&(e+=o[r>>2&63],e+=o[r<<4&63],e+=o[64],e+=o[64]),e}function npe(t){return cc&&cc.isBuffer(t)}Z1.exports=new epe("tag:yaml.org,2002:binary",{kind:"scalar",resolve:tpe,construct:rpe,predicate:npe,represent:ipe})});var rU=w((WZe,tU)=>{"use strict";var spe=li(),ope=Object.prototype.hasOwnProperty,ape=Object.prototype.toString;function Ape(t){if(t===null)return!0;var e=[],r,i,n,s,o,a=t;for(r=0,i=a.length;r<i;r+=1){if(n=a[r],o=!1,ape.call(n)!=="[object Object]")return!1;for(s in n)if(ope.call(n,s))if(!o)o=!0;else return!1;if(!o)return!1;if(e.indexOf(s)===-1)e.push(s);else return!1}return!0}function lpe(t){return t!==null?t:[]}tU.exports=new spe("tag:yaml.org,2002:omap",{kind:"sequence",resolve:Ape,construct:lpe})});var nU=w((zZe,iU)=>{"use strict";var cpe=li(),upe=Object.prototype.toString;function gpe(t){if(t===null)return!0;var e,r,i,n,s,o=t;for(s=new Array(o.length),e=0,r=o.length;e<r;e+=1){if(i=o[e],upe.call(i)!=="[object Object]"||(n=Object.keys(i),n.length!==1))return!1;s[e]=[n[0],i[n[0]]]}return!0}function fpe(t){if(t===null)return[];var e,r,i,n,s,o=t;for(s=new Array(o.length),e=0,r=o.length;e<r;e+=1)i=o[e],n=Object.keys(i),s[e]=[n[0],i[n[0]]];return s}iU.exports=new cpe("tag:yaml.org,2002:pairs",{kind:"sequence",resolve:gpe,construct:fpe})});var oU=w((_Ze,sU)=>{"use strict";var hpe=li(),ppe=Object.prototype.hasOwnProperty;function dpe(t){if(t===null)return!0;var e,r=t;for(e in r)if(ppe.call(r,e)&&r[e]!==null)return!1;return!0}function Cpe(t){return t!==null?t:{}}sU.exports=new hpe("tag:yaml.org,2002:set",{kind:"mapping",resolve:dpe,construct:Cpe})});var Zu=w((VZe,aU)=>{"use strict";var mpe=lc();aU.exports=new mpe({include:[YQ()],implicit:[_1(),X1()],explicit:[eU(),rU(),nU(),oU()]})});var lU=w((XZe,AU)=>{"use strict";var Epe=li();function Ipe(){return!0}function ype(){}function wpe(){return""}function Bpe(t){return typeof t=="undefined"}AU.exports=new Epe("tag:yaml.org,2002:js/undefined",{kind:"scalar",resolve:Ipe,construct:ype,predicate:Bpe,represent:wpe})});var uU=w((ZZe,cU)=>{"use strict";var bpe=li();function Qpe(t){if(t===null||t.length===0)return!1;var e=t,r=/\/([gim]*)$/.exec(t),i="";return!(e[0]==="/"&&(r&&(i=r[1]),i.length>3||e[e.length-i.length-1]!=="/"))}function vpe(t){var e=t,r=/\/([gim]*)$/.exec(t),i="";return e[0]==="/"&&(r&&(i=r[1]),e=e.slice(1,e.length-i.length-1)),new RegExp(e,i)}function Spe(t){var e="/"+t.source+"/";return t.global&&(e+="g"),t.multiline&&(e+="m"),t.ignoreCase&&(e+="i"),e}function kpe(t){return Object.prototype.toString.call(t)==="[object RegExp]"}cU.exports=new bpe("tag:yaml.org,2002:js/regexp",{kind:"scalar",resolve:Qpe,construct:vpe,predicate:kpe,represent:Spe})});var hU=w(($Ze,gU)=>{"use strict";var oI;try{fU=require,oI=fU("esprima")}catch(t){typeof window!="undefined"&&(oI=window.esprima)}var fU,xpe=li();function Ppe(t){if(t===null)return!1;try{var e="("+t+")",r=oI.parse(e,{range:!0});return!(r.type!=="Program"||r.body.length!==1||r.body[0].type!=="ExpressionStatement"||r.body[0].expression.type!=="ArrowFunctionExpression"&&r.body[0].expression.type!=="FunctionExpression")}catch(i){return!1}}function Dpe(t){var e="("+t+")",r=oI.parse(e,{range:!0}),i=[],n;if(r.type!=="Program"||r.body.length!==1||r.body[0].type!=="ExpressionStatement"||r.body[0].expression.type!=="ArrowFunctionExpression"&&r.body[0].expression.type!=="FunctionExpression")throw new Error("Failed to resolve function");return r.body[0].expression.params.forEach(function(s){i.push(s.name)}),n=r.body[0].expression.body.range,r.body[0].expression.body.type==="BlockStatement"?new Function(i,e.slice(n[0]+1,n[1]-1)):new Function(i,"return "+e.slice(n[0],n[1]))}function Rpe(t){return t.toString()}function Fpe(t){return Object.prototype.toString.call(t)==="[object Function]"}gU.exports=new xpe("tag:yaml.org,2002:js/function",{kind:"scalar",resolve:Ppe,construct:Dpe,predicate:Fpe,represent:Rpe})});var tp=w((e$e,pU)=>{"use strict";var dU=lc();pU.exports=dU.DEFAULT=new dU({include:[Zu()],explicit:[lU(),uU(),hU()]})});var LU=w((t$e,rp)=>{"use strict";var Fa=Ac(),CU=Vu(),Npe=B1(),mU=Zu(),Lpe=tp(),HA=Object.prototype.hasOwnProperty,aI=1,EU=2,IU=3,AI=4,JQ=1,Tpe=2,yU=3,Ope=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,Mpe=/[\x85\u2028\u2029]/,Upe=/[,\[\]\{\}]/,wU=/^(?:!|!!|![a-z\-]+!)$/i,BU=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;function bU(t){return Object.prototype.toString.call(t)}function Ro(t){return t===10||t===13}function uc(t){return t===9||t===32}function yn(t){return t===9||t===32||t===10||t===13}function $u(t){return t===44||t===91||t===93||t===123||t===125}function Kpe(t){var e;return 48<=t&&t<=57?t-48:(e=t|32,97<=e&&e<=102?e-97+10:-1)}function Hpe(t){return t===120?2:t===117?4:t===85?8:0}function jpe(t){return 48<=t&&t<=57?t-48:-1}function QU(t){return t===48?"\0":t===97?"\x07":t===98?"\b":t===116||t===9?"  ":t===110?`
+`:t===118?"\v":t===102?"\f":t===114?"\r":t===101?"\e":t===32?" ":t===34?'"':t===47?"/":t===92?"\\":t===78?"\x85":t===95?"\xA0":t===76?"\u2028":t===80?"\u2029":""}function Gpe(t){return t<=65535?String.fromCharCode(t):String.fromCharCode((t-65536>>10)+55296,(t-65536&1023)+56320)}var vU=new Array(256),SU=new Array(256);for(var eg=0;eg<256;eg++)vU[eg]=QU(eg)?1:0,SU[eg]=QU(eg);function Ype(t,e){this.input=t,this.filename=e.filename||null,this.schema=e.schema||Lpe,this.onWarning=e.onWarning||null,this.legacy=e.legacy||!1,this.json=e.json||!1,this.listener=e.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=t.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.documents=[]}function kU(t,e){return new CU(e,new Npe(t.filename,t.input,t.position,t.line,t.position-t.lineStart))}function dt(t,e){throw kU(t,e)}function lI(t,e){t.onWarning&&t.onWarning.call(null,kU(t,e))}var xU={YAML:function(e,r,i){var n,s,o;e.version!==null&&dt(e,"duplication of %YAML directive"),i.length!==1&&dt(e,"YAML directive accepts exactly one argument"),n=/^([0-9]+)\.([0-9]+)$/.exec(i[0]),n===null&&dt(e,"ill-formed argument of the YAML directive"),s=parseInt(n[1],10),o=parseInt(n[2],10),s!==1&&dt(e,"unacceptable YAML version of the document"),e.version=i[0],e.checkLineBreaks=o<2,o!==1&&o!==2&&lI(e,"unsupported YAML version of the document")},TAG:function(e,r,i){var n,s;i.length!==2&&dt(e,"TAG directive accepts exactly two arguments"),n=i[0],s=i[1],wU.test(n)||dt(e,"ill-formed tag handle (first argument) of the TAG directive"),HA.call(e.tagMap,n)&&dt(e,'there is a previously declared suffix for "'+n+'" tag handle'),BU.test(s)||dt(e,"ill-formed tag prefix (second argument) of the TAG directive"),e.tagMap[n]=s}};function jA(t,e,r,i){var n,s,o,a;if(e<r){if(a=t.input.slice(e,r),i)for(n=0,s=a.length;n<s;n+=1)o=a.charCodeAt(n),o===9||32<=o&&o<=1114111||dt(t,"expected valid JSON character");else Ope.test(a)&&dt(t,"the stream contains non-printable characters");t.result+=a}}function PU(t,e,r,i){var n,s,o,a;for(Fa.isObject(r)||dt(t,"cannot merge mappings; the provided source object is unacceptable"),n=Object.keys(r),o=0,a=n.length;o<a;o+=1)s=n[o],HA.call(e,s)||(e[s]=r[s],i[s]=!0)}function tg(t,e,r,i,n,s,o,a){var l,c;if(Array.isArray(n))for(n=Array.prototype.slice.call(n),l=0,c=n.length;l<c;l+=1)Array.isArray(n[l])&&dt(t,"nested arrays are not supported inside keys"),typeof n=="object"&&bU(n[l])==="[object Object]"&&(n[l]="[object Object]");if(typeof n=="object"&&bU(n)==="[object Object]"&&(n="[object Object]"),n=String(n),e===null&&(e={}),i==="tag:yaml.org,2002:merge")if(Array.isArray(s))for(l=0,c=s.length;l<c;l+=1)PU(t,e,s[l],r);else PU(t,e,s,r);else!t.json&&!HA.call(r,n)&&HA.call(e,n)&&(t.line=o||t.line,t.position=a||t.position,dt(t,"duplicated mapping key")),e[n]=s,delete r[n];return e}function WQ(t){var e;e=t.input.charCodeAt(t.position),e===10?t.position++:e===13?(t.position++,t.input.charCodeAt(t.position)===10&&t.position++):dt(t,"a line break is expected"),t.line+=1,t.lineStart=t.position}function $r(t,e,r){for(var i=0,n=t.input.charCodeAt(t.position);n!==0;){for(;uc(n);)n=t.input.charCodeAt(++t.position);if(e&&n===35)do n=t.input.charCodeAt(++t.position);while(n!==10&&n!==13&&n!==0);if(Ro(n))for(WQ(t),n=t.input.charCodeAt(t.position),i++,t.lineIndent=0;n===32;)t.lineIndent++,n=t.input.charCodeAt(++t.position);else break}return r!==-1&&i!==0&&t.lineIndent<r&&lI(t,"deficient indentation"),i}function cI(t){var e=t.position,r;return r=t.input.charCodeAt(e),!!((r===45||r===46)&&r===t.input.charCodeAt(e+1)&&r===t.input.charCodeAt(e+2)&&(e+=3,r=t.input.charCodeAt(e),r===0||yn(r)))}function zQ(t,e){e===1?t.result+=" ":e>1&&(t.result+=Fa.repeat(`
+`,e-1))}function qpe(t,e,r){var i,n,s,o,a,l,c,u,g=t.kind,f=t.result,h;if(h=t.input.charCodeAt(t.position),yn(h)||$u(h)||h===35||h===38||h===42||h===33||h===124||h===62||h===39||h===34||h===37||h===64||h===96||(h===63||h===45)&&(n=t.input.charCodeAt(t.position+1),yn(n)||r&&$u(n)))return!1;for(t.kind="scalar",t.result="",s=o=t.position,a=!1;h!==0;){if(h===58){if(n=t.input.charCodeAt(t.position+1),yn(n)||r&&$u(n))break}else if(h===35){if(i=t.input.charCodeAt(t.position-1),yn(i))break}else{if(t.position===t.lineStart&&cI(t)||r&&$u(h))break;if(Ro(h))if(l=t.line,c=t.lineStart,u=t.lineIndent,$r(t,!1,-1),t.lineIndent>=e){a=!0,h=t.input.charCodeAt(t.position);continue}else{t.position=o,t.line=l,t.lineStart=c,t.lineIndent=u;break}}a&&(jA(t,s,o,!1),zQ(t,t.line-l),s=o=t.position,a=!1),uc(h)||(o=t.position+1),h=t.input.charCodeAt(++t.position)}return jA(t,s,o,!1),t.result?!0:(t.kind=g,t.result=f,!1)}function Jpe(t,e){var r,i,n;if(r=t.input.charCodeAt(t.position),r!==39)return!1;for(t.kind="scalar",t.result="",t.position++,i=n=t.position;(r=t.input.charCodeAt(t.position))!==0;)if(r===39)if(jA(t,i,t.position,!0),r=t.input.charCodeAt(++t.position),r===39)i=t.position,t.position++,n=t.position;else return!0;else Ro(r)?(jA(t,i,n,!0),zQ(t,$r(t,!1,e)),i=n=t.position):t.position===t.lineStart&&cI(t)?dt(t,"unexpected end of the document within a single quoted scalar"):(t.position++,n=t.position);dt(t,"unexpected end of the stream within a single quoted scalar")}function Wpe(t,e){var r,i,n,s,o,a;if(a=t.input.charCodeAt(t.position),a!==34)return!1;for(t.kind="scalar",t.result="",t.position++,r=i=t.position;(a=t.input.charCodeAt(t.position))!==0;){if(a===34)return jA(t,r,t.position,!0),t.position++,!0;if(a===92){if(jA(t,r,t.position,!0),a=t.input.charCodeAt(++t.position),Ro(a))$r(t,!1,e);else if(a<256&&vU[a])t.result+=SU[a],t.position++;else if((o=Hpe(a))>0){for(n=o,s=0;n>0;n--)a=t.input.charCodeAt(++t.position),(o=Kpe(a))>=0?s=(s<<4)+o:dt(t,"expected hexadecimal character");t.result+=Gpe(s),t.position++}else dt(t,"unknown escape sequence");r=i=t.position}else Ro(a)?(jA(t,r,i,!0),zQ(t,$r(t,!1,e)),r=i=t.position):t.position===t.lineStart&&cI(t)?dt(t,"unexpected end of the document within a double quoted scalar"):(t.position++,i=t.position)}dt(t,"unexpected end of the stream within a double quoted scalar")}function zpe(t,e){var r=!0,i,n=t.tag,s,o=t.anchor,a,l,c,u,g,f={},h,p,m,y;if(y=t.input.charCodeAt(t.position),y===91)l=93,g=!1,s=[];else if(y===123)l=125,g=!0,s={};else return!1;for(t.anchor!==null&&(t.anchorMap[t.anchor]=s),y=t.input.charCodeAt(++t.position);y!==0;){if($r(t,!0,e),y=t.input.charCodeAt(t.position),y===l)return t.position++,t.tag=n,t.anchor=o,t.kind=g?"mapping":"sequence",t.result=s,!0;r||dt(t,"missed comma between flow collection entries"),p=h=m=null,c=u=!1,y===63&&(a=t.input.charCodeAt(t.position+1),yn(a)&&(c=u=!0,t.position++,$r(t,!0,e))),i=t.line,rg(t,e,aI,!1,!0),p=t.tag,h=t.result,$r(t,!0,e),y=t.input.charCodeAt(t.position),(u||t.line===i)&&y===58&&(c=!0,y=t.input.charCodeAt(++t.position),$r(t,!0,e),rg(t,e,aI,!1,!0),m=t.result),g?tg(t,s,f,p,h,m):c?s.push(tg(t,null,f,p,h,m)):s.push(h),$r(t,!0,e),y=t.input.charCodeAt(t.position),y===44?(r=!0,y=t.input.charCodeAt(++t.position)):r=!1}dt(t,"unexpected end of the stream within a flow collection")}function _pe(t,e){var r,i,n=JQ,s=!1,o=!1,a=e,l=0,c=!1,u,g;if(g=t.input.charCodeAt(t.position),g===124)i=!1;else if(g===62)i=!0;else return!1;for(t.kind="scalar",t.result="";g!==0;)if(g=t.input.charCodeAt(++t.position),g===43||g===45)JQ===n?n=g===43?yU:Tpe:dt(t,"repeat of a chomping mode identifier");else if((u=jpe(g))>=0)u===0?dt(t,"bad explicit indentation width of a block scalar; it cannot be less than one"):o?dt(t,"repeat of an indentation width identifier"):(a=e+u-1,o=!0);else break;if(uc(g)){do g=t.input.charCodeAt(++t.position);while(uc(g));if(g===35)do g=t.input.charCodeAt(++t.position);while(!Ro(g)&&g!==0)}for(;g!==0;){for(WQ(t),t.lineIndent=0,g=t.input.charCodeAt(t.position);(!o||t.lineIndent<a)&&g===32;)t.lineIndent++,g=t.input.charCodeAt(++t.position);if(!o&&t.lineIndent>a&&(a=t.lineIndent),Ro(g)){l++;continue}if(t.lineIndent<a){n===yU?t.result+=Fa.repeat(`
+`,s?1+l:l):n===JQ&&s&&(t.result+=`
+`);break}for(i?uc(g)?(c=!0,t.result+=Fa.repeat(`
+`,s?1+l:l)):c?(c=!1,t.result+=Fa.repeat(`
+`,l+1)):l===0?s&&(t.result+=" "):t.result+=Fa.repeat(`
+`,l):t.result+=Fa.repeat(`
+`,s?1+l:l),s=!0,o=!0,l=0,r=t.position;!Ro(g)&&g!==0;)g=t.input.charCodeAt(++t.position);jA(t,r,t.position,!1)}return!0}function DU(t,e){var r,i=t.tag,n=t.anchor,s=[],o,a=!1,l;for(t.anchor!==null&&(t.anchorMap[t.anchor]=s),l=t.input.charCodeAt(t.position);l!==0&&!(l!==45||(o=t.input.charCodeAt(t.position+1),!yn(o)));){if(a=!0,t.position++,$r(t,!0,-1)&&t.lineIndent<=e){s.push(null),l=t.input.charCodeAt(t.position);continue}if(r=t.line,rg(t,e,IU,!1,!0),s.push(t.result),$r(t,!0,-1),l=t.input.charCodeAt(t.position),(t.line===r||t.lineIndent>e)&&l!==0)dt(t,"bad indentation of a sequence entry");else if(t.lineIndent<e)break}return a?(t.tag=i,t.anchor=n,t.kind="sequence",t.result=s,!0):!1}function Vpe(t,e,r){var i,n,s,o,a=t.tag,l=t.anchor,c={},u={},g=null,f=null,h=null,p=!1,m=!1,y;for(t.anchor!==null&&(t.anchorMap[t.anchor]=c),y=t.input.charCodeAt(t.position);y!==0;){if(i=t.input.charCodeAt(t.position+1),s=t.line,o=t.position,(y===63||y===58)&&yn(i))y===63?(p&&(tg(t,c,u,g,f,null),g=f=h=null),m=!0,p=!0,n=!0):p?(p=!1,n=!0):dt(t,"incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line"),t.position+=1,y=i;else if(rg(t,r,EU,!1,!0))if(t.line===s){for(y=t.input.charCodeAt(t.position);uc(y);)y=t.input.charCodeAt(++t.position);if(y===58)y=t.input.charCodeAt(++t.position),yn(y)||dt(t,"a whitespace character is expected after the key-value separator within a block mapping"),p&&(tg(t,c,u,g,f,null),g=f=h=null),m=!0,p=!1,n=!1,g=t.tag,f=t.result;else if(m)dt(t,"can not read an implicit mapping pair; a colon is missed");else return t.tag=a,t.anchor=l,!0}else if(m)dt(t,"can not read a block mapping entry; a multiline key may not be an implicit key");else return t.tag=a,t.anchor=l,!0;else break;if((t.line===s||t.lineIndent>e)&&(rg(t,e,AI,!0,n)&&(p?f=t.result:h=t.result),p||(tg(t,c,u,g,f,h,s,o),g=f=h=null),$r(t,!0,-1),y=t.input.charCodeAt(t.position)),t.lineIndent>e&&y!==0)dt(t,"bad indentation of a mapping entry");else if(t.lineIndent<e)break}return p&&tg(t,c,u,g,f,null),m&&(t.tag=a,t.anchor=l,t.kind="mapping",t.result=c),m}function Xpe(t){var e,r=!1,i=!1,n,s,o;if(o=t.input.charCodeAt(t.position),o!==33)return!1;if(t.tag!==null&&dt(t,"duplication of a tag property"),o=t.input.charCodeAt(++t.position),o===60?(r=!0,o=t.input.charCodeAt(++t.position)):o===33?(i=!0,n="!!",o=t.input.charCodeAt(++t.position)):n="!",e=t.position,r){do o=t.input.charCodeAt(++t.position);while(o!==0&&o!==62);t.position<t.length?(s=t.input.slice(e,t.position),o=t.input.charCodeAt(++t.position)):dt(t,"unexpected end of the stream within a verbatim tag")}else{for(;o!==0&&!yn(o);)o===33&&(i?dt(t,"tag suffix cannot contain exclamation marks"):(n=t.input.slice(e-1,t.position+1),wU.test(n)||dt(t,"named tag handle cannot contain such characters"),i=!0,e=t.position+1)),o=t.input.charCodeAt(++t.position);s=t.input.slice(e,t.position),Upe.test(s)&&dt(t,"tag suffix cannot contain flow indicator characters")}return s&&!BU.test(s)&&dt(t,"tag name cannot contain such characters: "+s),r?t.tag=s:HA.call(t.tagMap,n)?t.tag=t.tagMap[n]+s:n==="!"?t.tag="!"+s:n==="!!"?t.tag="tag:yaml.org,2002:"+s:dt(t,'undeclared tag handle "'+n+'"'),!0}function Zpe(t){var e,r;if(r=t.input.charCodeAt(t.position),r!==38)return!1;for(t.anchor!==null&&dt(t,"duplication of an anchor property"),r=t.input.charCodeAt(++t.position),e=t.position;r!==0&&!yn(r)&&!$u(r);)r=t.input.charCodeAt(++t.position);return t.position===e&&dt(t,"name of an anchor node must contain at least one character"),t.anchor=t.input.slice(e,t.position),!0}function $pe(t){var e,r,i;if(i=t.input.charCodeAt(t.position),i!==42)return!1;for(i=t.input.charCodeAt(++t.position),e=t.position;i!==0&&!yn(i)&&!$u(i);)i=t.input.charCodeAt(++t.position);return t.position===e&&dt(t,"name of an alias node must contain at least one character"),r=t.input.slice(e,t.position),HA.call(t.anchorMap,r)||dt(t,'unidentified alias "'+r+'"'),t.result=t.anchorMap[r],$r(t,!0,-1),!0}function rg(t,e,r,i,n){var s,o,a,l=1,c=!1,u=!1,g,f,h,p,m;if(t.listener!==null&&t.listener("open",t),t.tag=null,t.anchor=null,t.kind=null,t.result=null,s=o=a=AI===r||IU===r,i&&$r(t,!0,-1)&&(c=!0,t.lineIndent>e?l=1:t.lineIndent===e?l=0:t.lineIndent<e&&(l=-1)),l===1)for(;Xpe(t)||Zpe(t);)$r(t,!0,-1)?(c=!0,a=s,t.lineIndent>e?l=1:t.lineIndent===e?l=0:t.lineIndent<e&&(l=-1)):a=!1;if(a&&(a=c||n),(l===1||AI===r)&&(aI===r||EU===r?p=e:p=e+1,m=t.position-t.lineStart,l===1?a&&(DU(t,m)||Vpe(t,m,p))||zpe(t,p)?u=!0:(o&&_pe(t,p)||Jpe(t,p)||Wpe(t,p)?u=!0:$pe(t)?(u=!0,(t.tag!==null||t.anchor!==null)&&dt(t,"alias node should not have any properties")):qpe(t,p,aI===r)&&(u=!0,t.tag===null&&(t.tag="?")),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):l===0&&(u=a&&DU(t,m))),t.tag!==null&&t.tag!=="!")if(t.tag==="?"){for(t.result!==null&&t.kind!=="scalar"&&dt(t,'unacceptable node kind for !<?> tag; it should be "scalar", not "'+t.kind+'"'),g=0,f=t.implicitTypes.length;g<f;g+=1)if(h=t.implicitTypes[g],h.resolve(t.result)){t.result=h.construct(t.result),t.tag=h.tag,t.anchor!==null&&(t.anchorMap[t.anchor]=t.result);break}}else HA.call(t.typeMap[t.kind||"fallback"],t.tag)?(h=t.typeMap[t.kind||"fallback"][t.tag],t.result!==null&&h.kind!==t.kind&&dt(t,"unacceptable node kind for !<"+t.tag+'> tag; it should be "'+h.kind+'", not "'+t.kind+'"'),h.resolve(t.result)?(t.result=h.construct(t.result),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):dt(t,"cannot resolve a node with !<"+t.tag+"> explicit tag")):dt(t,"unknown tag !<"+t.tag+">");return t.listener!==null&&t.listener("close",t),t.tag!==null||t.anchor!==null||u}function ede(t){var e=t.position,r,i,n,s=!1,o;for(t.version=null,t.checkLineBreaks=t.legacy,t.tagMap={},t.anchorMap={};(o=t.input.charCodeAt(t.position))!==0&&($r(t,!0,-1),o=t.input.charCodeAt(t.position),!(t.lineIndent>0||o!==37));){for(s=!0,o=t.input.charCodeAt(++t.position),r=t.position;o!==0&&!yn(o);)o=t.input.charCodeAt(++t.position);for(i=t.input.slice(r,t.position),n=[],i.length<1&&dt(t,"directive name must not be less than one character in length");o!==0;){for(;uc(o);)o=t.input.charCodeAt(++t.position);if(o===35){do o=t.input.charCodeAt(++t.position);while(o!==0&&!Ro(o));break}if(Ro(o))break;for(r=t.position;o!==0&&!yn(o);)o=t.input.charCodeAt(++t.position);n.push(t.input.slice(r,t.position))}o!==0&&WQ(t),HA.call(xU,i)?xU[i](t,i,n):lI(t,'unknown document directive "'+i+'"')}if($r(t,!0,-1),t.lineIndent===0&&t.input.charCodeAt(t.position)===45&&t.input.charCodeAt(t.position+1)===45&&t.input.charCodeAt(t.position+2)===45?(t.position+=3,$r(t,!0,-1)):s&&dt(t,"directives end mark is expected"),rg(t,t.lineIndent-1,AI,!1,!0),$r(t,!0,-1),t.checkLineBreaks&&Mpe.test(t.input.slice(e,t.position))&&lI(t,"non-ASCII line breaks are interpreted as content"),t.documents.push(t.result),t.position===t.lineStart&&cI(t)){t.input.charCodeAt(t.position)===46&&(t.position+=3,$r(t,!0,-1));return}if(t.position<t.length-1)dt(t,"end of the stream or a document separator is expected");else return}function RU(t,e){t=String(t),e=e||{},t.length!==0&&(t.charCodeAt(t.length-1)!==10&&t.charCodeAt(t.length-1)!==13&&(t+=`
+`),t.charCodeAt(0)===65279&&(t=t.slice(1)));var r=new Ype(t,e),i=t.indexOf("\0");for(i!==-1&&(r.position=i,dt(r,"null byte is not allowed in input")),r.input+="\0";r.input.charCodeAt(r.position)===32;)r.lineIndent+=1,r.position+=1;for(;r.position<r.length-1;)ede(r);return r.documents}function FU(t,e,r){e!==null&&typeof e=="object"&&typeof r=="undefined"&&(r=e,e=null);var i=RU(t,r);if(typeof e!="function")return i;for(var n=0,s=i.length;n<s;n+=1)e(i[n])}function NU(t,e){var r=RU(t,e);if(r.length!==0){if(r.length===1)return r[0];throw new CU("expected a single document in the stream, but found more")}}function tde(t,e,r){return typeof e=="object"&&e!==null&&typeof r=="undefined"&&(r=e,e=null),FU(t,e,Fa.extend({schema:mU},r))}function rde(t,e){return NU(t,Fa.extend({schema:mU},e))}rp.exports.loadAll=FU;rp.exports.load=NU;rp.exports.safeLoadAll=tde;rp.exports.safeLoad=rde});var nK=w((r$e,_Q)=>{"use strict";var ip=Ac(),np=Vu(),ide=tp(),nde=Zu(),TU=Object.prototype.toString,OU=Object.prototype.hasOwnProperty,sde=9,sp=10,ode=13,ade=32,Ade=33,lde=34,MU=35,cde=37,ude=38,gde=39,fde=42,UU=44,hde=45,KU=58,pde=61,dde=62,Cde=63,mde=64,HU=91,jU=93,Ede=96,GU=123,Ide=124,YU=125,Ui={};Ui[0]="\\0";Ui[7]="\\a";Ui[8]="\\b";Ui[9]="\\t";Ui[10]="\\n";Ui[11]="\\v";Ui[12]="\\f";Ui[13]="\\r";Ui[27]="\\e";Ui[34]='\\"';Ui[92]="\\\\";Ui[133]="\\N";Ui[160]="\\_";Ui[8232]="\\L";Ui[8233]="\\P";var yde=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"];function wde(t,e){var r,i,n,s,o,a,l;if(e===null)return{};for(r={},i=Object.keys(e),n=0,s=i.length;n<s;n+=1)o=i[n],a=String(e[o]),o.slice(0,2)==="!!"&&(o="tag:yaml.org,2002:"+o.slice(2)),l=t.compiledTypeMap.fallback[o],l&&OU.call(l.styleAliases,a)&&(a=l.styleAliases[a]),r[o]=a;return r}function qU(t){var e,r,i;if(e=t.toString(16).toUpperCase(),t<=255)r="x",i=2;else if(t<=65535)r="u",i=4;else if(t<=4294967295)r="U",i=8;else throw new np("code point within a string may not be greater than 0xFFFFFFFF");return"\\"+r+ip.repeat("0",i-e.length)+e}function Bde(t){this.schema=t.schema||ide,this.indent=Math.max(1,t.indent||2),this.noArrayIndent=t.noArrayIndent||!1,this.skipInvalid=t.skipInvalid||!1,this.flowLevel=ip.isNothing(t.flowLevel)?-1:t.flowLevel,this.styleMap=wde(this.schema,t.styles||null),this.sortKeys=t.sortKeys||!1,this.lineWidth=t.lineWidth||80,this.noRefs=t.noRefs||!1,this.noCompatMode=t.noCompatMode||!1,this.condenseFlow=t.condenseFlow||!1,this.implicitTypes=this.schema.compiledImplicit,this.explicitTypes=this.schema.compiledExplicit,this.tag=null,this.result="",this.duplicates=[],this.usedDuplicates=null}function JU(t,e){for(var r=ip.repeat(" ",e),i=0,n=-1,s="",o,a=t.length;i<a;)n=t.indexOf(`
+`,i),n===-1?(o=t.slice(i),i=a):(o=t.slice(i,n+1),i=n+1),o.length&&o!==`
+`&&(s+=r),s+=o;return s}function VQ(t,e){return`
+`+ip.repeat(" ",t.indent*e)}function bde(t,e){var r,i,n;for(r=0,i=t.implicitTypes.length;r<i;r+=1)if(n=t.implicitTypes[r],n.resolve(e))return!0;return!1}function XQ(t){return t===ade||t===sde}function ig(t){return 32<=t&&t<=126||161<=t&&t<=55295&&t!==8232&&t!==8233||57344<=t&&t<=65533&&t!==65279||65536<=t&&t<=1114111}function Qde(t){return ig(t)&&!XQ(t)&&t!==65279&&t!==ode&&t!==sp}function WU(t,e){return ig(t)&&t!==65279&&t!==UU&&t!==HU&&t!==jU&&t!==GU&&t!==YU&&t!==KU&&(t!==MU||e&&Qde(e))}function vde(t){return ig(t)&&t!==65279&&!XQ(t)&&t!==hde&&t!==Cde&&t!==KU&&t!==UU&&t!==HU&&t!==jU&&t!==GU&&t!==YU&&t!==MU&&t!==ude&&t!==fde&&t!==Ade&&t!==Ide&&t!==pde&&t!==dde&&t!==gde&&t!==lde&&t!==cde&&t!==mde&&t!==Ede}function zU(t){var e=/^\n* /;return e.test(t)}var _U=1,VU=2,XU=3,ZU=4,uI=5;function Sde(t,e,r,i,n){var s,o,a,l=!1,c=!1,u=i!==-1,g=-1,f=vde(t.charCodeAt(0))&&!XQ(t.charCodeAt(t.length-1));if(e)for(s=0;s<t.length;s++){if(o=t.charCodeAt(s),!ig(o))return uI;a=s>0?t.charCodeAt(s-1):null,f=f&&WU(o,a)}else{for(s=0;s<t.length;s++){if(o=t.charCodeAt(s),o===sp)l=!0,u&&(c=c||s-g-1>i&&t[g+1]!==" ",g=s);else if(!ig(o))return uI;a=s>0?t.charCodeAt(s-1):null,f=f&&WU(o,a)}c=c||u&&s-g-1>i&&t[g+1]!==" "}return!l&&!c?f&&!n(t)?_U:VU:r>9&&zU(t)?uI:c?ZU:XU}function Pde(t,e,r,i){t.dump=function(){if(e.length===0)return"''";if(!t.noCompatMode&&yde.indexOf(e)!==-1)return"'"+e+"'";var n=t.indent*Math.max(1,r),s=t.lineWidth===-1?-1:Math.max(Math.min(t.lineWidth,40),t.lineWidth-n),o=i||t.flowLevel>-1&&r>=t.flowLevel;function a(l){return bde(t,l)}switch(Sde(e,o,t.indent,s,a)){case _U:return e;case VU:return"'"+e.replace(/'/g,"''")+"'";case XU:return"|"+$U(e,t.indent)+eK(JU(e,n));case ZU:return">"+$U(e,t.indent)+eK(JU(kde(e,s),n));case uI:return'"'+xde(e,s)+'"';default:throw new np("impossible error: invalid scalar style")}}()}function $U(t,e){var r=zU(t)?String(e):"",i=t[t.length-1]===`
+`,n=i&&(t[t.length-2]===`
+`||t===`
+`),s=n?"+":i?"":"-";return r+s+`
+`}function eK(t){return t[t.length-1]===`
+`?t.slice(0,-1):t}function kde(t,e){for(var r=/(\n+)([^\n]*)/g,i=function(){var c=t.indexOf(`
+`);return c=c!==-1?c:t.length,r.lastIndex=c,tK(t.slice(0,c),e)}(),n=t[0]===`
+`||t[0]===" ",s,o;o=r.exec(t);){var a=o[1],l=o[2];s=l[0]===" ",i+=a+(!n&&!s&&l!==""?`
+`:"")+tK(l,e),n=s}return i}function tK(t,e){if(t===""||t[0]===" ")return t;for(var r=/ [^ ]/g,i,n=0,s,o=0,a=0,l="";i=r.exec(t);)a=i.index,a-n>e&&(s=o>n?o:a,l+=`
+`+t.slice(n,s),n=s+1),o=a;return l+=`
+`,t.length-n>e&&o>n?l+=t.slice(n,o)+`
+`+t.slice(o+1):l+=t.slice(n),l.slice(1)}function xde(t){for(var e="",r,i,n,s=0;s<t.length;s++){if(r=t.charCodeAt(s),r>=55296&&r<=56319&&(i=t.charCodeAt(s+1),i>=56320&&i<=57343)){e+=qU((r-55296)*1024+i-56320+65536),s++;continue}n=Ui[r],e+=!n&&ig(r)?t[s]:n||qU(r)}return e}function Dde(t,e,r){var i="",n=t.tag,s,o;for(s=0,o=r.length;s<o;s+=1)gc(t,e,r[s],!1,!1)&&(s!==0&&(i+=","+(t.condenseFlow?"":" ")),i+=t.dump);t.tag=n,t.dump="["+i+"]"}function Rde(t,e,r,i){var n="",s=t.tag,o,a;for(o=0,a=r.length;o<a;o+=1)gc(t,e+1,r[o],!0,!0)&&((!i||o!==0)&&(n+=VQ(t,e)),t.dump&&sp===t.dump.charCodeAt(0)?n+="-":n+="- ",n+=t.dump);t.tag=s,t.dump=n||"[]"}function Fde(t,e,r){var i="",n=t.tag,s=Object.keys(r),o,a,l,c,u;for(o=0,a=s.length;o<a;o+=1)u="",o!==0&&(u+=", "),t.condenseFlow&&(u+='"'),l=s[o],c=r[l],!!gc(t,e,l,!1,!1)&&(t.dump.length>1024&&(u+="? "),u+=t.dump+(t.condenseFlow?'"':"")+":"+(t.condenseFlow?"":" "),!!gc(t,e,c,!1,!1)&&(u+=t.dump,i+=u));t.tag=n,t.dump="{"+i+"}"}function Nde(t,e,r,i){var n="",s=t.tag,o=Object.keys(r),a,l,c,u,g,f;if(t.sortKeys===!0)o.sort();else if(typeof t.sortKeys=="function")o.sort(t.sortKeys);else if(t.sortKeys)throw new np("sortKeys must be a boolean or a function");for(a=0,l=o.length;a<l;a+=1)f="",(!i||a!==0)&&(f+=VQ(t,e)),c=o[a],u=r[c],!!gc(t,e+1,c,!0,!0,!0)&&(g=t.tag!==null&&t.tag!=="?"||t.dump&&t.dump.length>1024,g&&(t.dump&&sp===t.dump.charCodeAt(0)?f+="?":f+="? "),f+=t.dump,g&&(f+=VQ(t,e)),!!gc(t,e+1,u,!0,g)&&(t.dump&&sp===t.dump.charCodeAt(0)?f+=":":f+=": ",f+=t.dump,n+=f));t.tag=s,t.dump=n||"{}"}function rK(t,e,r){var i,n,s,o,a,l;for(n=r?t.explicitTypes:t.implicitTypes,s=0,o=n.length;s<o;s+=1)if(a=n[s],(a.instanceOf||a.predicate)&&(!a.instanceOf||typeof e=="object"&&e instanceof a.instanceOf)&&(!a.predicate||a.predicate(e))){if(t.tag=r?a.tag:"?",a.represent){if(l=t.styleMap[a.tag]||a.defaultStyle,TU.call(a.represent)==="[object Function]")i=a.represent(e,l);else if(OU.call(a.represent,l))i=a.represent[l](e,l);else throw new np("!<"+a.tag+'> tag resolver accepts not "'+l+'" style');t.dump=i}return!0}return!1}function gc(t,e,r,i,n,s){t.tag=null,t.dump=r,rK(t,r,!1)||rK(t,r,!0);var o=TU.call(t.dump);i&&(i=t.flowLevel<0||t.flowLevel>e);var a=o==="[object Object]"||o==="[object Array]",l,c;if(a&&(l=t.duplicates.indexOf(r),c=l!==-1),(t.tag!==null&&t.tag!=="?"||c||t.indent!==2&&e>0)&&(n=!1),c&&t.usedDuplicates[l])t.dump="*ref_"+l;else{if(a&&c&&!t.usedDuplicates[l]&&(t.usedDuplicates[l]=!0),o==="[object Object]")i&&Object.keys(t.dump).length!==0?(Nde(t,e,t.dump,n),c&&(t.dump="&ref_"+l+t.dump)):(Fde(t,e,t.dump),c&&(t.dump="&ref_"+l+" "+t.dump));else if(o==="[object Array]"){var u=t.noArrayIndent&&e>0?e-1:e;i&&t.dump.length!==0?(Rde(t,u,t.dump,n),c&&(t.dump="&ref_"+l+t.dump)):(Dde(t,u,t.dump),c&&(t.dump="&ref_"+l+" "+t.dump))}else if(o==="[object String]")t.tag!=="?"&&Pde(t,t.dump,e,s);else{if(t.skipInvalid)return!1;throw new np("unacceptable kind of an object to dump "+o)}t.tag!==null&&t.tag!=="?"&&(t.dump="!<"+t.tag+"> "+t.dump)}return!0}function Lde(t,e){var r=[],i=[],n,s;for(ZQ(t,r,i),n=0,s=i.length;n<s;n+=1)e.duplicates.push(r[i[n]]);e.usedDuplicates=new Array(s)}function ZQ(t,e,r){var i,n,s;if(t!==null&&typeof t=="object")if(n=e.indexOf(t),n!==-1)r.indexOf(n)===-1&&r.push(n);else if(e.push(t),Array.isArray(t))for(n=0,s=t.length;n<s;n+=1)ZQ(t[n],e,r);else for(i=Object.keys(t),n=0,s=i.length;n<s;n+=1)ZQ(t[i[n]],e,r)}function iK(t,e){e=e||{};var r=new Bde(e);return r.noRefs||Lde(t,r),gc(r,0,t,!0,!0)?r.dump+`
+`:""}function Tde(t,e){return iK(t,ip.extend({schema:nde},e))}_Q.exports.dump=iK;_Q.exports.safeDump=Tde});var oK=w((i$e,Or)=>{"use strict";var gI=LU(),sK=nK();function fI(t){return function(){throw new Error("Function "+t+" is deprecated and cannot be used.")}}Or.exports.Type=li();Or.exports.Schema=lc();Or.exports.FAILSAFE_SCHEMA=sI();Or.exports.JSON_SCHEMA=GQ();Or.exports.CORE_SCHEMA=YQ();Or.exports.DEFAULT_SAFE_SCHEMA=Zu();Or.exports.DEFAULT_FULL_SCHEMA=tp();Or.exports.load=gI.load;Or.exports.loadAll=gI.loadAll;Or.exports.safeLoad=gI.safeLoad;Or.exports.safeLoadAll=gI.safeLoadAll;Or.exports.dump=sK.dump;Or.exports.safeDump=sK.safeDump;Or.exports.YAMLException=Vu();Or.exports.MINIMAL_SCHEMA=sI();Or.exports.SAFE_SCHEMA=Zu();Or.exports.DEFAULT_SCHEMA=tp();Or.exports.scan=fI("scan");Or.exports.parse=fI("parse");Or.exports.compose=fI("compose");Or.exports.addConstructor=fI("addConstructor")});var AK=w((n$e,aK)=>{"use strict";var Ode=oK();aK.exports=Ode});var cK=w((s$e,lK)=>{"use strict";function Mde(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function fc(t,e,r,i){this.message=t,this.expected=e,this.found=r,this.location=i,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,fc)}Mde(fc,Error);fc.buildMessage=function(t,e){var r={literal:function(c){return'"'+n(c.text)+'"'},class:function(c){var u="",g;for(g=0;g<c.parts.length;g++)u+=c.parts[g]instanceof Array?s(c.parts[g][0])+"-"+s(c.parts[g][1]):s(c.parts[g]);return"["+(c.inverted?"^":"")+u+"]"},any:function(c){return"any character"},end:function(c){return"end of input"},other:function(c){return c.description}};function i(c){return c.charCodeAt(0).toString(16).toUpperCase()}function n(c){return c.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(u){return"\\x0"+i(u)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(u){return"\\x"+i(u)})}function s(c){return c.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(u){return"\\x0"+i(u)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(u){return"\\x"+i(u)})}function o(c){return r[c.type](c)}function a(c){var u=new Array(c.length),g,f;for(g=0;g<c.length;g++)u[g]=o(c[g]);if(u.sort(),u.length>0){for(g=1,f=1;g<u.length;g++)u[g-1]!==u[g]&&(u[f]=u[g],f++);u.length=f}switch(u.length){case 1:return u[0];case 2:return u[0]+" or "+u[1];default:return u.slice(0,-1).join(", ")+", or "+u[u.length-1]}}function l(c){return c?'"'+n(c)+'"':"end of input"}return"Expected "+a(t)+" but "+l(e)+" found."};function Ude(t,e){e=e!==void 0?e:{};var r={},i={Start:Xs},n=Xs,s=function(R){return[].concat(...R)},o="-",a=gr("-",!1),l=function(R){return R},c=function(R){return Object.assign({},...R)},u="#",g=gr("#",!1),f=Jl(),h=function(){return{}},p=":",m=gr(":",!1),y=function(R,q){return{[R]:q}},Q=",",S=gr(",",!1),x=function(R,q){return q},M=function(R,q,de){return Object.assign({},...[R].concat(q).map(He=>({[He]:de})))},Y=function(R){return R},U=function(R){return R},J=Vs("correct indentation"),W=" ",ee=gr(" ",!1),Z=function(R){return R.length===LA*Gu},A=function(R){return R.length===(LA+1)*Gu},ne=function(){return LA++,!0},le=function(){return LA--,!0},Ae=function(){return Ou()},T=Vs("pseudostring"),L=/^[^\r\n\t ?:,\][{}#&*!|>'"%@`\-]/,Ee=Yn(["\r",`
+`,"    "," ","?",":",",","]","[","{","}","#","&","*","!","|",">","'",'"',"%","@","`","-"],!0,!1),we=/^[^\r\n\t ,\][{}:#"']/,qe=Yn(["\r",`
+`,"    "," ",",","]","[","{","}",":","#",'"',"'"],!0,!1),re=function(){return Ou().replace(/^ *| *$/g,"")},se="--",Qe=gr("--",!1),he=/^[a-zA-Z\/0-9]/,Fe=Yn([["a","z"],["A","Z"],"/",["0","9"]],!1,!1),Ue=/^[^\r\n\t :,]/,xe=Yn(["\r",`
+`,"    "," ",":",","],!0,!1),ve="null",pe=gr("null",!1),X=function(){return null},be="true",ce=gr("true",!1),fe=function(){return!0},gt="false",Ht=gr("false",!1),Mt=function(){return!1},mi=Vs("string"),jt='"',Qr=gr('"',!1),Ti=function(){return""},_s=function(R){return R},Un=function(R){return R.join("")},Kn=/^[^"\\\0-\x1F\x7F]/,vr=Yn(['"',"\\",["\0","\1f"],"\x7F"],!0,!1),Hn='\\"',us=gr('\\"',!1),Ia=function(){return'"'},SA="\\\\",Du=gr("\\\\",!1),gs=function(){return"\\"},kA="\\/",ya=gr("\\/",!1),Ru=function(){return"/"},xA="\\b",PA=gr("\\b",!1),Sr=function(){return"\b"},jl="\\f",Fu=gr("\\f",!1),So=function(){return"\f"},Nu="\\n",Qh=gr("\\n",!1),vh=function(){return`
+`},oe="\\r",Oi=gr("\\r",!1),ko=function(){return"\r"},jn="\\t",Lu=gr("\\t",!1),vt=function(){return"   "},Gl="\\u",Gn=gr("\\u",!1),fs=function(R,q,de,He){return String.fromCharCode(parseInt(`0x${R}${q}${de}${He}`))},hs=/^[0-9a-fA-F]/,pt=Yn([["0","9"],["a","f"],["A","F"]],!1,!1),xo=Vs("blank space"),lt=/^[ \t]/,mn=Yn([" ","   "],!1,!1),v=Vs("white space"),Tt=/^[ \t\n\r]/,Tu=Yn([" ","      ",`
+`,"\r"],!1,!1),Yl=`\r
+`,Sh=gr(`\r
+`,!1),kh=`
+`,xh=gr(`
+`,!1),Ph="\r",Dh=gr("\r",!1),G=0,yt=0,DA=[{line:1,column:1}],$i=0,ql=[],$e=0,wa;if("startRule"in e){if(!(e.startRule in i))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');n=i[e.startRule]}function Ou(){return t.substring(yt,G)}function SE(){return En(yt,G)}function Rh(R,q){throw q=q!==void 0?q:En(yt,G),Wl([Vs(R)],t.substring(yt,G),q)}function kE(R,q){throw q=q!==void 0?q:En(yt,G),Mu(R,q)}function gr(R,q){return{type:"literal",text:R,ignoreCase:q}}function Yn(R,q,de){return{type:"class",parts:R,inverted:q,ignoreCase:de}}function Jl(){return{type:"any"}}function Fh(){return{type:"end"}}function Vs(R){return{type:"other",description:R}}function Ba(R){var q=DA[R],de;if(q)return q;for(de=R-1;!DA[de];)de--;for(q=DA[de],q={line:q.line,column:q.column};de<R;)t.charCodeAt(de)===10?(q.line++,q.column=1):q.column++,de++;return DA[R]=q,q}function En(R,q){var de=Ba(R),He=Ba(q);return{start:{offset:R,line:de.line,column:de.column},end:{offset:q,line:He.line,column:He.column}}}function Oe(R){G<$i||(G>$i&&($i=G,ql=[]),ql.push(R))}function Mu(R,q){return new fc(R,null,null,q)}function Wl(R,q,de){return new fc(fc.buildMessage(R,q),R,q,de)}function Xs(){var R;return R=Uu(),R}function zl(){var R,q,de;for(R=G,q=[],de=RA();de!==r;)q.push(de),de=RA();return q!==r&&(yt=R,q=s(q)),R=q,R}function RA(){var R,q,de,He,Te;return R=G,q=Qa(),q!==r?(t.charCodeAt(G)===45?(de=o,G++):(de=r,$e===0&&Oe(a)),de!==r?(He=Lr(),He!==r?(Te=ba(),Te!==r?(yt=R,q=l(Te),R=q):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r),R}function Uu(){var R,q,de;for(R=G,q=[],de=Ku();de!==r;)q.push(de),de=Ku();return q!==r&&(yt=R,q=c(q)),R=q,R}function Ku(){var R,q,de,He,Te,Xe,Et,Rt,qn;if(R=G,q=Lr(),q===r&&(q=null),q!==r){if(de=G,t.charCodeAt(G)===35?(He=u,G++):(He=r,$e===0&&Oe(g)),He!==r){if(Te=[],Xe=G,Et=G,$e++,Rt=eo(),$e--,Rt===r?Et=void 0:(G=Et,Et=r),Et!==r?(t.length>G?(Rt=t.charAt(G),G++):(Rt=r,$e===0&&Oe(f)),Rt!==r?(Et=[Et,Rt],Xe=Et):(G=Xe,Xe=r)):(G=Xe,Xe=r),Xe!==r)for(;Xe!==r;)Te.push(Xe),Xe=G,Et=G,$e++,Rt=eo(),$e--,Rt===r?Et=void 0:(G=Et,Et=r),Et!==r?(t.length>G?(Rt=t.charAt(G),G++):(Rt=r,$e===0&&Oe(f)),Rt!==r?(Et=[Et,Rt],Xe=Et):(G=Xe,Xe=r)):(G=Xe,Xe=r);else Te=r;Te!==r?(He=[He,Te],de=He):(G=de,de=r)}else G=de,de=r;if(de===r&&(de=null),de!==r){if(He=[],Te=$s(),Te!==r)for(;Te!==r;)He.push(Te),Te=$s();else He=r;He!==r?(yt=R,q=h(),R=q):(G=R,R=r)}else G=R,R=r}else G=R,R=r;if(R===r&&(R=G,q=Qa(),q!==r?(de=_l(),de!==r?(He=Lr(),He===r&&(He=null),He!==r?(t.charCodeAt(G)===58?(Te=p,G++):(Te=r,$e===0&&Oe(m)),Te!==r?(Xe=Lr(),Xe===r&&(Xe=null),Xe!==r?(Et=ba(),Et!==r?(yt=R,q=y(de,Et),R=q):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r),R===r&&(R=G,q=Qa(),q!==r?(de=Zs(),de!==r?(He=Lr(),He===r&&(He=null),He!==r?(t.charCodeAt(G)===58?(Te=p,G++):(Te=r,$e===0&&Oe(m)),Te!==r?(Xe=Lr(),Xe===r&&(Xe=null),Xe!==r?(Et=ba(),Et!==r?(yt=R,q=y(de,Et),R=q):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r),R===r))){if(R=G,q=Qa(),q!==r)if(de=Zs(),de!==r)if(He=Lr(),He!==r)if(Te=xE(),Te!==r){if(Xe=[],Et=$s(),Et!==r)for(;Et!==r;)Xe.push(Et),Et=$s();else Xe=r;Xe!==r?(yt=R,q=y(de,Te),R=q):(G=R,R=r)}else G=R,R=r;else G=R,R=r;else G=R,R=r;else G=R,R=r;if(R===r)if(R=G,q=Qa(),q!==r)if(de=Zs(),de!==r){if(He=[],Te=G,Xe=Lr(),Xe===r&&(Xe=null),Xe!==r?(t.charCodeAt(G)===44?(Et=Q,G++):(Et=r,$e===0&&Oe(S)),Et!==r?(Rt=Lr(),Rt===r&&(Rt=null),Rt!==r?(qn=Zs(),qn!==r?(yt=Te,Xe=x(de,qn),Te=Xe):(G=Te,Te=r)):(G=Te,Te=r)):(G=Te,Te=r)):(G=Te,Te=r),Te!==r)for(;Te!==r;)He.push(Te),Te=G,Xe=Lr(),Xe===r&&(Xe=null),Xe!==r?(t.charCodeAt(G)===44?(Et=Q,G++):(Et=r,$e===0&&Oe(S)),Et!==r?(Rt=Lr(),Rt===r&&(Rt=null),Rt!==r?(qn=Zs(),qn!==r?(yt=Te,Xe=x(de,qn),Te=Xe):(G=Te,Te=r)):(G=Te,Te=r)):(G=Te,Te=r)):(G=Te,Te=r);else He=r;He!==r?(Te=Lr(),Te===r&&(Te=null),Te!==r?(t.charCodeAt(G)===58?(Xe=p,G++):(Xe=r,$e===0&&Oe(m)),Xe!==r?(Et=Lr(),Et===r&&(Et=null),Et!==r?(Rt=ba(),Rt!==r?(yt=R,q=M(de,He,Rt),R=q):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)}else G=R,R=r;else G=R,R=r}return R}function ba(){var R,q,de,He,Te,Xe,Et;if(R=G,q=G,$e++,de=G,He=eo(),He!==r?(Te=it(),Te!==r?(t.charCodeAt(G)===45?(Xe=o,G++):(Xe=r,$e===0&&Oe(a)),Xe!==r?(Et=Lr(),Et!==r?(He=[He,Te,Xe,Et],de=He):(G=de,de=r)):(G=de,de=r)):(G=de,de=r)):(G=de,de=r),$e--,de!==r?(G=q,q=void 0):q=r,q!==r?(de=$s(),de!==r?(He=Po(),He!==r?(Te=zl(),Te!==r?(Xe=FA(),Xe!==r?(yt=R,q=Y(Te),R=q):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r),R===r&&(R=G,q=eo(),q!==r?(de=Po(),de!==r?(He=Uu(),He!==r?(Te=FA(),Te!==r?(yt=R,q=Y(He),R=q):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r),R===r))if(R=G,q=Vl(),q!==r){if(de=[],He=$s(),He!==r)for(;He!==r;)de.push(He),He=$s();else de=r;de!==r?(yt=R,q=U(q),R=q):(G=R,R=r)}else G=R,R=r;return R}function Qa(){var R,q,de;for($e++,R=G,q=[],t.charCodeAt(G)===32?(de=W,G++):(de=r,$e===0&&Oe(ee));de!==r;)q.push(de),t.charCodeAt(G)===32?(de=W,G++):(de=r,$e===0&&Oe(ee));return q!==r?(yt=G,de=Z(q),de?de=void 0:de=r,de!==r?(q=[q,de],R=q):(G=R,R=r)):(G=R,R=r),$e--,R===r&&(q=r,$e===0&&Oe(J)),R}function it(){var R,q,de;for(R=G,q=[],t.charCodeAt(G)===32?(de=W,G++):(de=r,$e===0&&Oe(ee));de!==r;)q.push(de),t.charCodeAt(G)===32?(de=W,G++):(de=r,$e===0&&Oe(ee));return q!==r?(yt=G,de=A(q),de?de=void 0:de=r,de!==r?(q=[q,de],R=q):(G=R,R=r)):(G=R,R=r),R}function Po(){var R;return yt=G,R=ne(),R?R=void 0:R=r,R}function FA(){var R;return yt=G,R=le(),R?R=void 0:R=r,R}function _l(){var R;return R=Xl(),R===r&&(R=Nh()),R}function Zs(){var R,q,de;if(R=Xl(),R===r){if(R=G,q=[],de=Hu(),de!==r)for(;de!==r;)q.push(de),de=Hu();else q=r;q!==r&&(yt=R,q=Ae()),R=q}return R}function Vl(){var R;return R=Lh(),R===r&&(R=PE(),R===r&&(R=Xl(),R===r&&(R=Nh()))),R}function xE(){var R;return R=Lh(),R===r&&(R=Xl(),R===r&&(R=Hu())),R}function Nh(){var R,q,de,He,Te,Xe;if($e++,R=G,L.test(t.charAt(G))?(q=t.charAt(G),G++):(q=r,$e===0&&Oe(Ee)),q!==r){for(de=[],He=G,Te=Lr(),Te===r&&(Te=null),Te!==r?(we.test(t.charAt(G))?(Xe=t.charAt(G),G++):(Xe=r,$e===0&&Oe(qe)),Xe!==r?(Te=[Te,Xe],He=Te):(G=He,He=r)):(G=He,He=r);He!==r;)de.push(He),He=G,Te=Lr(),Te===r&&(Te=null),Te!==r?(we.test(t.charAt(G))?(Xe=t.charAt(G),G++):(Xe=r,$e===0&&Oe(qe)),Xe!==r?(Te=[Te,Xe],He=Te):(G=He,He=r)):(G=He,He=r);de!==r?(yt=R,q=re(),R=q):(G=R,R=r)}else G=R,R=r;return $e--,R===r&&(q=r,$e===0&&Oe(T)),R}function Hu(){var R,q,de,He,Te;if(R=G,t.substr(G,2)===se?(q=se,G+=2):(q=r,$e===0&&Oe(Qe)),q===r&&(q=null),q!==r)if(he.test(t.charAt(G))?(de=t.charAt(G),G++):(de=r,$e===0&&Oe(Fe)),de!==r){for(He=[],Ue.test(t.charAt(G))?(Te=t.charAt(G),G++):(Te=r,$e===0&&Oe(xe));Te!==r;)He.push(Te),Ue.test(t.charAt(G))?(Te=t.charAt(G),G++):(Te=r,$e===0&&Oe(xe));He!==r?(yt=R,q=re(),R=q):(G=R,R=r)}else G=R,R=r;else G=R,R=r;return R}function Lh(){var R,q;return R=G,t.substr(G,4)===ve?(q=ve,G+=4):(q=r,$e===0&&Oe(pe)),q!==r&&(yt=R,q=X()),R=q,R}function PE(){var R,q;return R=G,t.substr(G,4)===be?(q=be,G+=4):(q=r,$e===0&&Oe(ce)),q!==r&&(yt=R,q=fe()),R=q,R===r&&(R=G,t.substr(G,5)===gt?(q=gt,G+=5):(q=r,$e===0&&Oe(Ht)),q!==r&&(yt=R,q=Mt()),R=q),R}function Xl(){var R,q,de,He;return $e++,R=G,t.charCodeAt(G)===34?(q=jt,G++):(q=r,$e===0&&Oe(Qr)),q!==r?(t.charCodeAt(G)===34?(de=jt,G++):(de=r,$e===0&&Oe(Qr)),de!==r?(yt=R,q=Ti(),R=q):(G=R,R=r)):(G=R,R=r),R===r&&(R=G,t.charCodeAt(G)===34?(q=jt,G++):(q=r,$e===0&&Oe(Qr)),q!==r?(de=DE(),de!==r?(t.charCodeAt(G)===34?(He=jt,G++):(He=r,$e===0&&Oe(Qr)),He!==r?(yt=R,q=_s(de),R=q):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)),$e--,R===r&&(q=r,$e===0&&Oe(mi)),R}function DE(){var R,q,de;if(R=G,q=[],de=ju(),de!==r)for(;de!==r;)q.push(de),de=ju();else q=r;return q!==r&&(yt=R,q=Un(q)),R=q,R}function ju(){var R,q,de,He,Te,Xe;return Kn.test(t.charAt(G))?(R=t.charAt(G),G++):(R=r,$e===0&&Oe(vr)),R===r&&(R=G,t.substr(G,2)===Hn?(q=Hn,G+=2):(q=r,$e===0&&Oe(us)),q!==r&&(yt=R,q=Ia()),R=q,R===r&&(R=G,t.substr(G,2)===SA?(q=SA,G+=2):(q=r,$e===0&&Oe(Du)),q!==r&&(yt=R,q=gs()),R=q,R===r&&(R=G,t.substr(G,2)===kA?(q=kA,G+=2):(q=r,$e===0&&Oe(ya)),q!==r&&(yt=R,q=Ru()),R=q,R===r&&(R=G,t.substr(G,2)===xA?(q=xA,G+=2):(q=r,$e===0&&Oe(PA)),q!==r&&(yt=R,q=Sr()),R=q,R===r&&(R=G,t.substr(G,2)===jl?(q=jl,G+=2):(q=r,$e===0&&Oe(Fu)),q!==r&&(yt=R,q=So()),R=q,R===r&&(R=G,t.substr(G,2)===Nu?(q=Nu,G+=2):(q=r,$e===0&&Oe(Qh)),q!==r&&(yt=R,q=vh()),R=q,R===r&&(R=G,t.substr(G,2)===oe?(q=oe,G+=2):(q=r,$e===0&&Oe(Oi)),q!==r&&(yt=R,q=ko()),R=q,R===r&&(R=G,t.substr(G,2)===jn?(q=jn,G+=2):(q=r,$e===0&&Oe(Lu)),q!==r&&(yt=R,q=vt()),R=q,R===r&&(R=G,t.substr(G,2)===Gl?(q=Gl,G+=2):(q=r,$e===0&&Oe(Gn)),q!==r?(de=NA(),de!==r?(He=NA(),He!==r?(Te=NA(),Te!==r?(Xe=NA(),Xe!==r?(yt=R,q=fs(de,He,Te,Xe),R=q):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)):(G=R,R=r)))))))))),R}function NA(){var R;return hs.test(t.charAt(G))?(R=t.charAt(G),G++):(R=r,$e===0&&Oe(pt)),R}function Lr(){var R,q;if($e++,R=[],lt.test(t.charAt(G))?(q=t.charAt(G),G++):(q=r,$e===0&&Oe(mn)),q!==r)for(;q!==r;)R.push(q),lt.test(t.charAt(G))?(q=t.charAt(G),G++):(q=r,$e===0&&Oe(mn));else R=r;return $e--,R===r&&(q=r,$e===0&&Oe(xo)),R}function RE(){var R,q;if($e++,R=[],Tt.test(t.charAt(G))?(q=t.charAt(G),G++):(q=r,$e===0&&Oe(Tu)),q!==r)for(;q!==r;)R.push(q),Tt.test(t.charAt(G))?(q=t.charAt(G),G++):(q=r,$e===0&&Oe(Tu));else R=r;return $e--,R===r&&(q=r,$e===0&&Oe(v)),R}function $s(){var R,q,de,He,Te,Xe;if(R=G,q=eo(),q!==r){for(de=[],He=G,Te=Lr(),Te===r&&(Te=null),Te!==r?(Xe=eo(),Xe!==r?(Te=[Te,Xe],He=Te):(G=He,He=r)):(G=He,He=r);He!==r;)de.push(He),He=G,Te=Lr(),Te===r&&(Te=null),Te!==r?(Xe=eo(),Xe!==r?(Te=[Te,Xe],He=Te):(G=He,He=r)):(G=He,He=r);de!==r?(q=[q,de],R=q):(G=R,R=r)}else G=R,R=r;return R}function eo(){var R;return t.substr(G,2)===Yl?(R=Yl,G+=2):(R=r,$e===0&&Oe(Sh)),R===r&&(t.charCodeAt(G)===10?(R=kh,G++):(R=r,$e===0&&Oe(xh)),R===r&&(t.charCodeAt(G)===13?(R=Ph,G++):(R=r,$e===0&&Oe(Dh)))),R}let Gu=2,LA=0;if(wa=n(),wa!==r&&G===t.length)return wa;throw wa!==r&&G<t.length&&Oe(Fh()),Wl(ql,$i<t.length?t.charAt($i):null,$i<t.length?En($i,$i+1):En($i,$i))}lK.exports={SyntaxError:fc,parse:Ude}});var dK=w((c$e,tv)=>{"use strict";var Yde=t=>{let e=!1,r=!1,i=!1;for(let n=0;n<t.length;n++){let s=t[n];e&&/[a-zA-Z]/.test(s)&&s.toUpperCase()===s?(t=t.slice(0,n)+"-"+t.slice(n),e=!1,i=r,r=!0,n++):r&&i&&/[a-zA-Z]/.test(s)&&s.toLowerCase()===s?(t=t.slice(0,n-1)+"-"+t.slice(n-1),i=r,r=!1,e=!0):(e=s.toLowerCase()===s&&s.toUpperCase()!==s,i=r,r=s.toUpperCase()===s&&s.toLowerCase()!==s)}return t},pK=(t,e)=>{if(!(typeof t=="string"||Array.isArray(t)))throw new TypeError("Expected the input to be `string | string[]`");e=Object.assign({pascalCase:!1},e);let r=n=>e.pascalCase?n.charAt(0).toUpperCase()+n.slice(1):n;return Array.isArray(t)?t=t.map(n=>n.trim()).filter(n=>n.length).join("-"):t=t.trim(),t.length===0?"":t.length===1?e.pascalCase?t.toUpperCase():t.toLowerCase():(t!==t.toLowerCase()&&(t=Yde(t)),t=t.replace(/^[_.\- ]+/,"").toLowerCase().replace(/[_.\- ]+(\w|$)/g,(n,s)=>s.toUpperCase()).replace(/\d+(\w|$)/g,n=>n.toUpperCase()),r(t))};tv.exports=pK;tv.exports.default=pK});var mK=w((u$e,CK)=>{CK.exports=[{name:"AppVeyor",constant:"APPVEYOR",env:"APPVEYOR",pr:"APPVEYOR_PULL_REQUEST_NUMBER"},{name:"Azure Pipelines",constant:"AZURE_PIPELINES",env:"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI",pr:"SYSTEM_PULLREQUEST_PULLREQUESTID"},{name:"Appcircle",constant:"APPCIRCLE",env:"AC_APPCIRCLE"},{name:"Bamboo",constant:"BAMBOO",env:"bamboo_planKey"},{name:"Bitbucket Pipelines",constant:"BITBUCKET",env:"BITBUCKET_COMMIT",pr:"BITBUCKET_PR_ID"},{name:"Bitrise",constant:"BITRISE",env:"BITRISE_IO",pr:"BITRISE_PULL_REQUEST"},{name:"Buddy",constant:"BUDDY",env:"BUDDY_WORKSPACE_ID",pr:"BUDDY_EXECUTION_PULL_REQUEST_ID"},{name:"Buildkite",constant:"BUILDKITE",env:"BUILDKITE",pr:{env:"BUILDKITE_PULL_REQUEST",ne:"false"}},{name:"CircleCI",constant:"CIRCLE",env:"CIRCLECI",pr:"CIRCLE_PULL_REQUEST"},{name:"Cirrus CI",constant:"CIRRUS",env:"CIRRUS_CI",pr:"CIRRUS_PR"},{name:"AWS CodeBuild",constant:"CODEBUILD",env:"CODEBUILD_BUILD_ARN"},{name:"Codefresh",constant:"CODEFRESH",env:"CF_BUILD_ID",pr:{any:["CF_PULL_REQUEST_NUMBER","CF_PULL_REQUEST_ID"]}},{name:"Codeship",constant:"CODESHIP",env:{CI_NAME:"codeship"}},{name:"Drone",constant:"DRONE",env:"DRONE",pr:{DRONE_BUILD_EVENT:"pull_request"}},{name:"dsari",constant:"DSARI",env:"DSARI"},{name:"GitHub Actions",constant:"GITHUB_ACTIONS",env:"GITHUB_ACTIONS",pr:{GITHUB_EVENT_NAME:"pull_request"}},{name:"GitLab CI",constant:"GITLAB",env:"GITLAB_CI",pr:"CI_MERGE_REQUEST_ID"},{name:"GoCD",constant:"GOCD",env:"GO_PIPELINE_LABEL"},{name:"LayerCI",constant:"LAYERCI",env:"LAYERCI",pr:"LAYERCI_PULL_REQUEST"},{name:"Hudson",constant:"HUDSON",env:"HUDSON_URL"},{name:"Jenkins",constant:"JENKINS",env:["JENKINS_URL","BUILD_ID"],pr:{any:["ghprbPullId","CHANGE_ID"]}},{name:"Magnum CI",constant:"MAGNUM",env:"MAGNUM"},{name:"Netlify CI",constant:"NETLIFY",env:"NETLIFY",pr:{env:"PULL_REQUEST",ne:"false"}},{name:"Nevercode",constant:"NEVERCODE",env:"NEVERCODE",pr:{env:"NEVERCODE_PULL_REQUEST",ne:"false"}},{name:"Render",constant:"RENDER",env:"RENDER",pr:{IS_PULL_REQUEST:"true"}},{name:"Sail CI",constant:"SAIL",env:"SAILCI",pr:"SAIL_PULL_REQUEST_NUMBER"},{name:"Semaphore",constant:"SEMAPHORE",env:"SEMAPHORE",pr:"PULL_REQUEST_NUMBER"},{name:"Screwdriver",constant:"SCREWDRIVER",env:"SCREWDRIVER",pr:{env:"SD_PULL_REQUEST",ne:"false"}},{name:"Shippable",constant:"SHIPPABLE",env:"SHIPPABLE",pr:{IS_PULL_REQUEST:"true"}},{name:"Solano CI",constant:"SOLANO",env:"TDDIUM",pr:"TDDIUM_PR_ID"},{name:"Strider CD",constant:"STRIDER",env:"STRIDER"},{name:"TaskCluster",constant:"TASKCLUSTER",env:["TASK_ID","RUN_ID"]},{name:"TeamCity",constant:"TEAMCITY",env:"TEAMCITY_VERSION"},{name:"Travis CI",constant:"TRAVIS",env:"TRAVIS",pr:{env:"TRAVIS_PULL_REQUEST",ne:"false"}},{name:"Vercel",constant:"VERCEL",env:"NOW_BUILDER"},{name:"Visual Studio App Center",constant:"APPCENTER",env:"APPCENTER_BUILD_ID"}]});var hc=w(_n=>{"use strict";var EK=mK(),Fo=process.env;Object.defineProperty(_n,"_vendors",{value:EK.map(function(t){return t.constant})});_n.name=null;_n.isPR=null;EK.forEach(function(t){let r=(Array.isArray(t.env)?t.env:[t.env]).every(function(i){return IK(i)});if(_n[t.constant]=r,r)switch(_n.name=t.name,typeof t.pr){case"string":_n.isPR=!!Fo[t.pr];break;case"object":"env"in t.pr?_n.isPR=t.pr.env in Fo&&Fo[t.pr.env]!==t.pr.ne:"any"in t.pr?_n.isPR=t.pr.any.some(function(i){return!!Fo[i]}):_n.isPR=IK(t.pr);break;default:_n.isPR=null}});_n.isCI=!!(Fo.CI||Fo.CONTINUOUS_INTEGRATION||Fo.BUILD_NUMBER||Fo.RUN_ID||_n.name);function IK(t){return typeof t=="string"?!!Fo[t]:Object.keys(t).every(function(e){return Fo[e]===t[e]})}});var sg={};ft(sg,{KeyRelationship:()=>Cc,applyCascade:()=>fp,base64RegExp:()=>QK,colorStringAlphaRegExp:()=>bK,colorStringRegExp:()=>BK,computeKey:()=>GA,getPrintable:()=>ei,hasExactLength:()=>PK,hasForbiddenKeys:()=>wCe,hasKeyRelationship:()=>lv,hasMaxLength:()=>sCe,hasMinLength:()=>nCe,hasMutuallyExclusiveKeys:()=>BCe,hasRequiredKeys:()=>yCe,hasUniqueItems:()=>oCe,isArray:()=>Vde,isAtLeast:()=>lCe,isAtMost:()=>cCe,isBase64:()=>ECe,isBoolean:()=>Wde,isDate:()=>_de,isDict:()=>Zde,isEnum:()=>nn,isHexColor:()=>mCe,isISO8601:()=>CCe,isInExclusiveRange:()=>gCe,isInInclusiveRange:()=>uCe,isInstanceOf:()=>eCe,isInteger:()=>fCe,isJSON:()=>ICe,isLiteral:()=>qde,isLowerCase:()=>hCe,isNegative:()=>aCe,isNullable:()=>iCe,isNumber:()=>zde,isObject:()=>$de,isOneOf:()=>tCe,isOptional:()=>rCe,isPositive:()=>ACe,isString:()=>gp,isTuple:()=>Xde,isUUID4:()=>dCe,isUnknown:()=>xK,isUpperCase:()=>pCe,iso8601RegExp:()=>Av,makeCoercionFn:()=>dc,makeSetter:()=>kK,makeTrait:()=>SK,makeValidator:()=>St,matchesRegExp:()=>hp,plural:()=>CI,pushError:()=>mt,simpleKeyRegExp:()=>wK,uuid4RegExp:()=>vK});function St({test:t}){return SK(t)()}function ei(t){return t===null?"null":t===void 0?"undefined":t===""?"an empty string":JSON.stringify(t)}function GA(t,e){var r,i,n;return typeof e=="number"?`${(r=t==null?void 0:t.p)!==null&&r!==void 0?r:"."}[${e}]`:wK.test(e)?`${(i=t==null?void 0:t.p)!==null&&i!==void 0?i:""}.${e}`:`${(n=t==null?void 0:t.p)!==null&&n!==void 0?n:"."}[${JSON.stringify(e)}]`}function dc(t,e){return r=>{let i=t[e];return t[e]=r,dc(t,e).bind(null,i)}}function kK(t,e){return r=>{t[e]=r}}function CI(t,e,r){return t===1?e:r}function mt({errors:t,p:e}={},r){return t==null||t.push(`${e!=null?e:"."}: ${r}`),!1}function qde(t){return St({test:(e,r)=>e!==t?mt(r,`Expected a literal (got ${ei(t)})`):!0})}function nn(t){let e=Array.isArray(t)?t:Object.values(t),r=new Set(e);return St({test:(i,n)=>r.has(i)?!0:mt(n,`Expected a valid enumeration value (got ${ei(i)})`)})}var wK,BK,bK,QK,vK,Av,SK,xK,gp,Jde,Wde,zde,_de,Vde,Xde,Zde,$de,eCe,tCe,fp,rCe,iCe,nCe,sCe,PK,oCe,aCe,ACe,lCe,cCe,uCe,gCe,fCe,hp,hCe,pCe,dCe,CCe,mCe,ECe,ICe,yCe,wCe,BCe,Cc,bCe,lv,Es=hfe(()=>{wK=/^[a-zA-Z_][a-zA-Z0-9_]*$/,BK=/^#[0-9a-f]{6}$/i,bK=/^#[0-9a-f]{6}([0-9a-f]{2})?$/i,QK=/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/,vK=/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$/i,Av=/^(?:[1-9]\d{3}(-?)(?:(?:0[1-9]|1[0-2])\1(?:0[1-9]|1\d|2[0-8])|(?:0[13-9]|1[0-2])\1(?:29|30)|(?:0[13578]|1[02])(?:\1)31|00[1-9]|0[1-9]\d|[12]\d{2}|3(?:[0-5]\d|6[0-5]))|(?:[1-9]\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)(?:(-?)02(?:\2)29|-?366))T(?:[01]\d|2[0-3])(:?)[0-5]\d(?:\3[0-5]\d)?(?:Z|[+-][01]\d(?:\3[0-5]\d)?)$/,SK=t=>()=>t;xK=()=>St({test:(t,e)=>!0});gp=()=>St({test:(t,e)=>typeof t!="string"?mt(e,`Expected a string (got ${ei(t)})`):!0});Jde=new Map([["true",!0],["True",!0],["1",!0],[1,!0],["false",!1],["False",!1],["0",!1],[0,!1]]),Wde=()=>St({test:(t,e)=>{var r;if(typeof t!="boolean"){if(typeof(e==null?void 0:e.coercions)!="undefined"){if(typeof(e==null?void 0:e.coercion)=="undefined")return mt(e,"Unbound coercion result");let i=Jde.get(t);if(typeof i!="undefined")return e.coercions.push([(r=e.p)!==null&&r!==void 0?r:".",e.coercion.bind(null,i)]),!0}return mt(e,`Expected a boolean (got ${ei(t)})`)}return!0}}),zde=()=>St({test:(t,e)=>{var r;if(typeof t!="number"){if(typeof(e==null?void 0:e.coercions)!="undefined"){if(typeof(e==null?void 0:e.coercion)=="undefined")return mt(e,"Unbound coercion result");let i;if(typeof t=="string"){let n;try{n=JSON.parse(t)}catch(s){}if(typeof n=="number")if(JSON.stringify(n)===t)i=n;else return mt(e,`Received a number that can't be safely represented by the runtime (${t})`)}if(typeof i!="undefined")return e.coercions.push([(r=e.p)!==null&&r!==void 0?r:".",e.coercion.bind(null,i)]),!0}return mt(e,`Expected a number (got ${ei(t)})`)}return!0}}),_de=()=>St({test:(t,e)=>{var r;if(!(t instanceof Date)){if(typeof(e==null?void 0:e.coercions)!="undefined"){if(typeof(e==null?void 0:e.coercion)=="undefined")return mt(e,"Unbound coercion result");let i;if(typeof t=="string"&&Av.test(t))i=new Date(t);else{let n;if(typeof t=="string"){let s;try{s=JSON.parse(t)}catch(o){}typeof s=="number"&&(n=s)}else typeof t=="number"&&(n=t);if(typeof n!="undefined")if(Number.isSafeInteger(n)||!Number.isSafeInteger(n*1e3))i=new Date(n*1e3);else return mt(e,`Received a timestamp that can't be safely represented by the runtime (${t})`)}if(typeof i!="undefined")return e.coercions.push([(r=e.p)!==null&&r!==void 0?r:".",e.coercion.bind(null,i)]),!0}return mt(e,`Expected a date (got ${ei(t)})`)}return!0}}),Vde=(t,{delimiter:e}={})=>St({test:(r,i)=>{var n;if(typeof r=="string"&&typeof e!="undefined"&&typeof(i==null?void 0:i.coercions)!="undefined"){if(typeof(i==null?void 0:i.coercion)=="undefined")return mt(i,"Unbound coercion result");r=r.split(e),i.coercions.push([(n=i.p)!==null&&n!==void 0?n:".",i.coercion.bind(null,r)])}if(!Array.isArray(r))return mt(i,`Expected an array (got ${ei(r)})`);let s=!0;for(let o=0,a=r.length;o<a&&(s=t(r[o],Object.assign(Object.assign({},i),{p:GA(i,o),coercion:dc(r,o)}))&&s,!(!s&&(i==null?void 0:i.errors)==null));++o);return s}}),Xde=(t,{delimiter:e}={})=>{let r=PK(t.length);return St({test:(i,n)=>{var s;if(typeof i=="string"&&typeof e!="undefined"&&typeof(n==null?void 0:n.coercions)!="undefined"){if(typeof(n==null?void 0:n.coercion)=="undefined")return mt(n,"Unbound coercion result");i=i.split(e),n.coercions.push([(s=n.p)!==null&&s!==void 0?s:".",n.coercion.bind(null,i)])}if(!Array.isArray(i))return mt(n,`Expected a tuple (got ${ei(i)})`);let o=r(i,Object.assign({},n));for(let a=0,l=i.length;a<l&&a<t.length&&(o=t[a](i[a],Object.assign(Object.assign({},n),{p:GA(n,a),coercion:dc(i,a)}))&&o,!(!o&&(n==null?void 0:n.errors)==null));++a);return o}})},Zde=(t,{keys:e=null}={})=>St({test:(r,i)=>{if(typeof r!="object"||r===null)return mt(i,`Expected an object (got ${ei(r)})`);let n=Object.keys(r),s=!0;for(let o=0,a=n.length;o<a&&(s||(i==null?void 0:i.errors)!=null);++o){let l=n[o],c=r[l];if(l==="__proto__"||l==="constructor"){s=mt(Object.assign(Object.assign({},i),{p:GA(i,l)}),"Unsafe property name");continue}if(e!==null&&!e(l,i)){s=!1;continue}if(!t(c,Object.assign(Object.assign({},i),{p:GA(i,l),coercion:dc(r,l)}))){s=!1;continue}}return s}}),$de=(t,{extra:e=null}={})=>{let r=Object.keys(t);return St({test:(i,n)=>{if(typeof i!="object"||i===null)return mt(n,`Expected an object (got ${ei(i)})`);let s=new Set([...r,...Object.keys(i)]),o={},a=!0;for(let l of s){if(l==="constructor"||l==="__proto__")a=mt(Object.assign(Object.assign({},n),{p:GA(n,l)}),"Unsafe property name");else{let c=Object.prototype.hasOwnProperty.call(t,l)?t[l]:void 0,u=Object.prototype.hasOwnProperty.call(i,l)?i[l]:void 0;typeof c!="undefined"?a=c(u,Object.assign(Object.assign({},n),{p:GA(n,l),coercion:dc(i,l)}))&&a:e===null?a=mt(Object.assign(Object.assign({},n),{p:GA(n,l)}),`Extraneous property (got ${ei(u)})`):Object.defineProperty(o,l,{enumerable:!0,get:()=>u,set:kK(i,l)})}if(!a&&(n==null?void 0:n.errors)==null)break}return e!==null&&(a||(n==null?void 0:n.errors)!=null)&&(a=e(o,n)&&a),a}})},eCe=t=>St({test:(e,r)=>e instanceof t?!0:mt(r,`Expected an instance of ${t.name} (got ${ei(e)})`)}),tCe=(t,{exclusive:e=!1}={})=>St({test:(r,i)=>{var n,s,o;let a=[],l=typeof(i==null?void 0:i.errors)!="undefined"?[]:void 0;for(let c=0,u=t.length;c<u;++c){let g=typeof(i==null?void 0:i.errors)!="undefined"?[]:void 0,f=typeof(i==null?void 0:i.coercions)!="undefined"?[]:void 0;if(t[c](r,Object.assign(Object.assign({},i),{errors:g,coercions:f,p:`${(n=i==null?void 0:i.p)!==null&&n!==void 0?n:"."}#${c+1}`}))){if(a.push([`#${c+1}`,f]),!e)break}else l==null||l.push(g[0])}if(a.length===1){let[,c]=a[0];return typeof c!="undefined"&&((s=i==null?void 0:i.coercions)===null||s===void 0||s.push(...c)),!0}return a.length>1?mt(i,`Expected to match exactly a single predicate (matched ${a.join(", ")})`):(o=i==null?void 0:i.errors)===null||o===void 0||o.push(...l),!1}}),fp=(t,e)=>St({test:(r,i)=>{var n,s;let o={value:r},a=typeof(i==null?void 0:i.coercions)!="undefined"?dc(o,"value"):void 0,l=typeof(i==null?void 0:i.coercions)!="undefined"?[]:void 0;if(!t(r,Object.assign(Object.assign({},i),{coercion:a,coercions:l})))return!1;let c=[];if(typeof l!="undefined")for(let[,u]of l)c.push(u());try{if(typeof(i==null?void 0:i.coercions)!="undefined"){if(o.value!==r){if(typeof(i==null?void 0:i.coercion)=="undefined")return mt(i,"Unbound coercion result");i.coercions.push([(n=i.p)!==null&&n!==void 0?n:".",i.coercion.bind(null,o.value)])}(s=i==null?void 0:i.coercions)===null||s===void 0||s.push(...l)}return e.every(u=>u(o.value,i))}finally{for(let u of c)u()}}}),rCe=t=>St({test:(e,r)=>typeof e=="undefined"?!0:t(e,r)}),iCe=t=>St({test:(e,r)=>e===null?!0:t(e,r)}),nCe=t=>St({test:(e,r)=>e.length>=t?!0:mt(r,`Expected to have a length of at least ${t} elements (got ${e.length})`)}),sCe=t=>St({test:(e,r)=>e.length<=t?!0:mt(r,`Expected to have a length of at most ${t} elements (got ${e.length})`)}),PK=t=>St({test:(e,r)=>e.length!==t?mt(r,`Expected to have a length of exactly ${t} elements (got ${e.length})`):!0}),oCe=({map:t}={})=>St({test:(e,r)=>{let i=new Set,n=new Set;for(let s=0,o=e.length;s<o;++s){let a=e[s],l=typeof t!="undefined"?t(a):a;if(i.has(l)){if(n.has(l))continue;mt(r,`Expected to contain unique elements; got a duplicate with ${ei(e)}`),n.add(l)}else i.add(l)}return n.size===0}}),aCe=()=>St({test:(t,e)=>t<=0?!0:mt(e,`Expected to be negative (got ${t})`)}),ACe=()=>St({test:(t,e)=>t>=0?!0:mt(e,`Expected to be positive (got ${t})`)}),lCe=t=>St({test:(e,r)=>e>=t?!0:mt(r,`Expected to be at least ${t} (got ${e})`)}),cCe=t=>St({test:(e,r)=>e<=t?!0:mt(r,`Expected to be at most ${t} (got ${e})`)}),uCe=(t,e)=>St({test:(r,i)=>r>=t&&r<=e?!0:mt(i,`Expected to be in the [${t}; ${e}] range (got ${r})`)}),gCe=(t,e)=>St({test:(r,i)=>r>=t&&r<e?!0:mt(i,`Expected to be in the [${t}; ${e}[ range (got ${r})`)}),fCe=({unsafe:t=!1}={})=>St({test:(e,r)=>e!==Math.round(e)?mt(r,`Expected to be an integer (got ${e})`):Number.isSafeInteger(e)?!0:mt(r,`Expected to be a safe integer (got ${e})`)}),hp=t=>St({test:(e,r)=>t.test(e)?!0:mt(r,`Expected to match the pattern ${t.toString()} (got ${ei(e)})`)}),hCe=()=>St({test:(t,e)=>t!==t.toLowerCase()?mt(e,`Expected to be all-lowercase (got ${t})`):!0}),pCe=()=>St({test:(t,e)=>t!==t.toUpperCase()?mt(e,`Expected to be all-uppercase (got ${t})`):!0}),dCe=()=>St({test:(t,e)=>vK.test(t)?!0:mt(e,`Expected to be a valid UUID v4 (got ${ei(t)})`)}),CCe=()=>St({test:(t,e)=>Av.test(t)?!1:mt(e,`Expected to be a valid ISO 8601 date string (got ${ei(t)})`)}),mCe=({alpha:t=!1})=>St({test:(e,r)=>(t?BK.test(e):bK.test(e))?!0:mt(r,`Expected to be a valid hexadecimal color string (got ${ei(e)})`)}),ECe=()=>St({test:(t,e)=>QK.test(t)?!0:mt(e,`Expected to be a valid base 64 string (got ${ei(t)})`)}),ICe=(t=xK())=>St({test:(e,r)=>{let i;try{i=JSON.parse(e)}catch(n){return mt(r,`Expected to be a valid JSON string (got ${ei(e)})`)}return t(i,r)}}),yCe=t=>{let e=new Set(t);return St({test:(r,i)=>{let n=new Set(Object.keys(r)),s=[];for(let o of e)n.has(o)||s.push(o);return s.length>0?mt(i,`Missing required ${CI(s.length,"property","properties")} ${s.map(o=>`"${o}"`).join(", ")}`):!0}})},wCe=t=>{let e=new Set(t);return St({test:(r,i)=>{let n=new Set(Object.keys(r)),s=[];for(let o of e)n.has(o)&&s.push(o);return s.length>0?mt(i,`Forbidden ${CI(s.length,"property","properties")} ${s.map(o=>`"${o}"`).join(", ")}`):!0}})},BCe=t=>{let e=new Set(t);return St({test:(r,i)=>{let n=new Set(Object.keys(r)),s=[];for(let o of e)n.has(o)&&s.push(o);return s.length>1?mt(i,`Mutually exclusive properties ${s.map(o=>`"${o}"`).join(", ")}`):!0}})};(function(t){t.Forbids="Forbids",t.Requires="Requires"})(Cc||(Cc={}));bCe={[Cc.Forbids]:{expect:!1,message:"forbids using"},[Cc.Requires]:{expect:!0,message:"requires using"}},lv=(t,e,r,{ignore:i=[]}={})=>{let n=new Set(i),s=new Set(r),o=bCe[e];return St({test:(a,l)=>{let c=new Set(Object.keys(a));if(!c.has(t)||n.has(a[t]))return!0;let u=[];for(let g of s)(c.has(g)&&!n.has(a[g]))!==o.expect&&u.push(g);return u.length>=1?mt(l,`Property "${t}" ${o.message} ${CI(u.length,"property","properties")} ${u.map(g=>`"${g}"`).join(", ")}`):!0}})}});var _K=w((fet,zK)=>{"use strict";zK.exports=(t,...e)=>new Promise(r=>{r(t(...e))})});var ag=w((het,dv)=>{"use strict";var HCe=_K(),VK=t=>{if(t<1)throw new TypeError("Expected `concurrency` to be a number from 1 and up");let e=[],r=0,i=()=>{r--,e.length>0&&e.shift()()},n=(a,l,...c)=>{r++;let u=HCe(a,...c);l(u),u.then(i,i)},s=(a,l,...c)=>{r<t?n(a,l,...c):e.push(n.bind(null,a,l,...c))},o=(a,...l)=>new Promise(c=>s(a,c,...l));return Object.defineProperties(o,{activeCount:{get:()=>r},pendingCount:{get:()=>e.length}}),o};dv.exports=VK;dv.exports.default=VK});var mp=w((det,XK)=>{var jCe="2.0.0",GCe=256,YCe=Number.MAX_SAFE_INTEGER||9007199254740991,qCe=16;XK.exports={SEMVER_SPEC_VERSION:jCe,MAX_LENGTH:GCe,MAX_SAFE_INTEGER:YCe,MAX_SAFE_COMPONENT_LENGTH:qCe}});var Ep=w((Cet,ZK)=>{var JCe=typeof process=="object"&&process.env&&process.env.NODE_DEBUG&&/\bsemver\b/i.test(process.env.NODE_DEBUG)?(...t)=>console.error("SEMVER",...t):()=>{};ZK.exports=JCe});var mc=w((qA,$K)=>{var{MAX_SAFE_COMPONENT_LENGTH:Cv}=mp(),WCe=Ep();qA=$K.exports={};var zCe=qA.re=[],tt=qA.src=[],rt=qA.t={},_Ce=0,kt=(t,e,r)=>{let i=_Ce++;WCe(i,e),rt[t]=i,tt[i]=e,zCe[i]=new RegExp(e,r?"g":void 0)};kt("NUMERICIDENTIFIER","0|[1-9]\\d*");kt("NUMERICIDENTIFIERLOOSE","[0-9]+");kt("NONNUMERICIDENTIFIER","\\d*[a-zA-Z-][a-zA-Z0-9-]*");kt("MAINVERSION",`(${tt[rt.NUMERICIDENTIFIER]})\\.(${tt[rt.NUMERICIDENTIFIER]})\\.(${tt[rt.NUMERICIDENTIFIER]})`);kt("MAINVERSIONLOOSE",`(${tt[rt.NUMERICIDENTIFIERLOOSE]})\\.(${tt[rt.NUMERICIDENTIFIERLOOSE]})\\.(${tt[rt.NUMERICIDENTIFIERLOOSE]})`);kt("PRERELEASEIDENTIFIER",`(?:${tt[rt.NUMERICIDENTIFIER]}|${tt[rt.NONNUMERICIDENTIFIER]})`);kt("PRERELEASEIDENTIFIERLOOSE",`(?:${tt[rt.NUMERICIDENTIFIERLOOSE]}|${tt[rt.NONNUMERICIDENTIFIER]})`);kt("PRERELEASE",`(?:-(${tt[rt.PRERELEASEIDENTIFIER]}(?:\\.${tt[rt.PRERELEASEIDENTIFIER]})*))`);kt("PRERELEASELOOSE",`(?:-?(${tt[rt.PRERELEASEIDENTIFIERLOOSE]}(?:\\.${tt[rt.PRERELEASEIDENTIFIERLOOSE]})*))`);kt("BUILDIDENTIFIER","[0-9A-Za-z-]+");kt("BUILD",`(?:\\+(${tt[rt.BUILDIDENTIFIER]}(?:\\.${tt[rt.BUILDIDENTIFIER]})*))`);kt("FULLPLAIN",`v?${tt[rt.MAINVERSION]}${tt[rt.PRERELEASE]}?${tt[rt.BUILD]}?`);kt("FULL",`^${tt[rt.FULLPLAIN]}$`);kt("LOOSEPLAIN",`[v=\\s]*${tt[rt.MAINVERSIONLOOSE]}${tt[rt.PRERELEASELOOSE]}?${tt[rt.BUILD]}?`);kt("LOOSE",`^${tt[rt.LOOSEPLAIN]}$`);kt("GTLT","((?:<|>)?=?)");kt("XRANGEIDENTIFIERLOOSE",`${tt[rt.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`);kt("XRANGEIDENTIFIER",`${tt[rt.NUMERICIDENTIFIER]}|x|X|\\*`);kt("XRANGEPLAIN",`[v=\\s]*(${tt[rt.XRANGEIDENTIFIER]})(?:\\.(${tt[rt.XRANGEIDENTIFIER]})(?:\\.(${tt[rt.XRANGEIDENTIFIER]})(?:${tt[rt.PRERELEASE]})?${tt[rt.BUILD]}?)?)?`);kt("XRANGEPLAINLOOSE",`[v=\\s]*(${tt[rt.XRANGEIDENTIFIERLOOSE]})(?:\\.(${tt[rt.XRANGEIDENTIFIERLOOSE]})(?:\\.(${tt[rt.XRANGEIDENTIFIERLOOSE]})(?:${tt[rt.PRERELEASELOOSE]})?${tt[rt.BUILD]}?)?)?`);kt("XRANGE",`^${tt[rt.GTLT]}\\s*${tt[rt.XRANGEPLAIN]}$`);kt("XRANGELOOSE",`^${tt[rt.GTLT]}\\s*${tt[rt.XRANGEPLAINLOOSE]}$`);kt("COERCE",`(^|[^\\d])(\\d{1,${Cv}})(?:\\.(\\d{1,${Cv}}))?(?:\\.(\\d{1,${Cv}}))?(?:$|[^\\d])`);kt("COERCERTL",tt[rt.COERCE],!0);kt("LONETILDE","(?:~>?)");kt("TILDETRIM",`(\\s*)${tt[rt.LONETILDE]}\\s+`,!0);qA.tildeTrimReplace="$1~";kt("TILDE",`^${tt[rt.LONETILDE]}${tt[rt.XRANGEPLAIN]}$`);kt("TILDELOOSE",`^${tt[rt.LONETILDE]}${tt[rt.XRANGEPLAINLOOSE]}$`);kt("LONECARET","(?:\\^)");kt("CARETTRIM",`(\\s*)${tt[rt.LONECARET]}\\s+`,!0);qA.caretTrimReplace="$1^";kt("CARET",`^${tt[rt.LONECARET]}${tt[rt.XRANGEPLAIN]}$`);kt("CARETLOOSE",`^${tt[rt.LONECARET]}${tt[rt.XRANGEPLAINLOOSE]}$`);kt("COMPARATORLOOSE",`^${tt[rt.GTLT]}\\s*(${tt[rt.LOOSEPLAIN]})$|^$`);kt("COMPARATOR",`^${tt[rt.GTLT]}\\s*(${tt[rt.FULLPLAIN]})$|^$`);kt("COMPARATORTRIM",`(\\s*)${tt[rt.GTLT]}\\s*(${tt[rt.LOOSEPLAIN]}|${tt[rt.XRANGEPLAIN]})`,!0);qA.comparatorTrimReplace="$1$2$3";kt("HYPHENRANGE",`^\\s*(${tt[rt.XRANGEPLAIN]})\\s+-\\s+(${tt[rt.XRANGEPLAIN]})\\s*$`);kt("HYPHENRANGELOOSE",`^\\s*(${tt[rt.XRANGEPLAINLOOSE]})\\s+-\\s+(${tt[rt.XRANGEPLAINLOOSE]})\\s*$`);kt("STAR","(<|>)?=?\\s*\\*");kt("GTE0","^\\s*>=\\s*0.0.0\\s*$");kt("GTE0PRE","^\\s*>=\\s*0.0.0-0\\s*$")});var Ip=w((met,e2)=>{var VCe=["includePrerelease","loose","rtl"],XCe=t=>t?typeof t!="object"?{loose:!0}:VCe.filter(e=>t[e]).reduce((e,r)=>(e[r]=!0,e),{}):{};e2.exports=XCe});var bI=w((Eet,t2)=>{var r2=/^[0-9]+$/,i2=(t,e)=>{let r=r2.test(t),i=r2.test(e);return r&&i&&(t=+t,e=+e),t===e?0:r&&!i?-1:i&&!r?1:t<e?-1:1},ZCe=(t,e)=>i2(e,t);t2.exports={compareIdentifiers:i2,rcompareIdentifiers:ZCe}});var Hi=w((Iet,n2)=>{var QI=Ep(),{MAX_LENGTH:s2,MAX_SAFE_INTEGER:vI}=mp(),{re:o2,t:a2}=mc(),$Ce=Ip(),{compareIdentifiers:yp}=bI(),ys=class{constructor(e,r){if(r=$Ce(r),e instanceof ys){if(e.loose===!!r.loose&&e.includePrerelease===!!r.includePrerelease)return e;e=e.version}else if(typeof e!="string")throw new TypeError(`Invalid Version: ${e}`);if(e.length>s2)throw new TypeError(`version is longer than ${s2} characters`);QI("SemVer",e,r),this.options=r,this.loose=!!r.loose,this.includePrerelease=!!r.includePrerelease;let i=e.trim().match(r.loose?o2[a2.LOOSE]:o2[a2.FULL]);if(!i)throw new TypeError(`Invalid Version: ${e}`);if(this.raw=e,this.major=+i[1],this.minor=+i[2],this.patch=+i[3],this.major>vI||this.major<0)throw new TypeError("Invalid major version");if(this.minor>vI||this.minor<0)throw new TypeError("Invalid minor version");if(this.patch>vI||this.patch<0)throw new TypeError("Invalid patch version");i[4]?this.prerelease=i[4].split(".").map(n=>{if(/^[0-9]+$/.test(n)){let s=+n;if(s>=0&&s<vI)return s}return n}):this.prerelease=[],this.build=i[5]?i[5].split("."):[],this.format()}format(){return this.version=`${this.major}.${this.minor}.${this.patch}`,this.prerelease.length&&(this.version+=`-${this.prerelease.join(".")}`),this.version}toString(){return this.version}compare(e){if(QI("SemVer.compare",this.version,this.options,e),!(e instanceof ys)){if(typeof e=="string"&&e===this.version)return 0;e=new ys(e,this.options)}return e.version===this.version?0:this.compareMain(e)||this.comparePre(e)}compareMain(e){return e instanceof ys||(e=new ys(e,this.options)),yp(this.major,e.major)||yp(this.minor,e.minor)||yp(this.patch,e.patch)}comparePre(e){if(e instanceof ys||(e=new ys(e,this.options)),this.prerelease.length&&!e.prerelease.length)return-1;if(!this.prerelease.length&&e.prerelease.length)return 1;if(!this.prerelease.length&&!e.prerelease.length)return 0;let r=0;do{let i=this.prerelease[r],n=e.prerelease[r];if(QI("prerelease compare",r,i,n),i===void 0&&n===void 0)return 0;if(n===void 0)return 1;if(i===void 0)return-1;if(i===n)continue;return yp(i,n)}while(++r)}compareBuild(e){e instanceof ys||(e=new ys(e,this.options));let r=0;do{let i=this.build[r],n=e.build[r];if(QI("prerelease compare",r,i,n),i===void 0&&n===void 0)return 0;if(n===void 0)return 1;if(i===void 0)return-1;if(i===n)continue;return yp(i,n)}while(++r)}inc(e,r){switch(e){case"premajor":this.prerelease.length=0,this.patch=0,this.minor=0,this.major++,this.inc("pre",r);break;case"preminor":this.prerelease.length=0,this.patch=0,this.minor++,this.inc("pre",r);break;case"prepatch":this.prerelease.length=0,this.inc("patch",r),this.inc("pre",r);break;case"prerelease":this.prerelease.length===0&&this.inc("patch",r),this.inc("pre",r);break;case"major":(this.minor!==0||this.patch!==0||this.prerelease.length===0)&&this.major++,this.minor=0,this.patch=0,this.prerelease=[];break;case"minor":(this.patch!==0||this.prerelease.length===0)&&this.minor++,this.patch=0,this.prerelease=[];break;case"patch":this.prerelease.length===0&&this.patch++,this.prerelease=[];break;case"pre":if(this.prerelease.length===0)this.prerelease=[0];else{let i=this.prerelease.length;for(;--i>=0;)typeof this.prerelease[i]=="number"&&(this.prerelease[i]++,i=-2);i===-1&&this.prerelease.push(0)}r&&(this.prerelease[0]===r?isNaN(this.prerelease[1])&&(this.prerelease=[r,0]):this.prerelease=[r,0]);break;default:throw new Error(`invalid increment argument: ${e}`)}return this.format(),this.raw=this.version,this}};n2.exports=ys});var Ec=w((yet,A2)=>{var{MAX_LENGTH:eme}=mp(),{re:l2,t:c2}=mc(),u2=Hi(),tme=Ip(),rme=(t,e)=>{if(e=tme(e),t instanceof u2)return t;if(typeof t!="string"||t.length>eme||!(e.loose?l2[c2.LOOSE]:l2[c2.FULL]).test(t))return null;try{return new u2(t,e)}catch(i){return null}};A2.exports=rme});var f2=w((wet,g2)=>{var ime=Ec(),nme=(t,e)=>{let r=ime(t,e);return r?r.version:null};g2.exports=nme});var p2=w((Bet,h2)=>{var sme=Ec(),ome=(t,e)=>{let r=sme(t.trim().replace(/^[=v]+/,""),e);return r?r.version:null};h2.exports=ome});var C2=w((bet,d2)=>{var ame=Hi(),Ame=(t,e,r,i)=>{typeof r=="string"&&(i=r,r=void 0);try{return new ame(t,r).inc(e,i).version}catch(n){return null}};d2.exports=Ame});var ws=w((Qet,m2)=>{var E2=Hi(),lme=(t,e,r)=>new E2(t,r).compare(new E2(e,r));m2.exports=lme});var SI=w((vet,I2)=>{var cme=ws(),ume=(t,e,r)=>cme(t,e,r)===0;I2.exports=ume});var B2=w((ket,y2)=>{var w2=Ec(),gme=SI(),fme=(t,e)=>{if(gme(t,e))return null;{let r=w2(t),i=w2(e),n=r.prerelease.length||i.prerelease.length,s=n?"pre":"",o=n?"prerelease":"";for(let a in r)if((a==="major"||a==="minor"||a==="patch")&&r[a]!==i[a])return s+a;return o}};y2.exports=fme});var Q2=w((xet,b2)=>{var hme=Hi(),pme=(t,e)=>new hme(t,e).major;b2.exports=pme});var S2=w((Pet,v2)=>{var dme=Hi(),Cme=(t,e)=>new dme(t,e).minor;v2.exports=Cme});var x2=w((Det,k2)=>{var mme=Hi(),Eme=(t,e)=>new mme(t,e).patch;k2.exports=Eme});var D2=w((Ret,P2)=>{var Ime=Ec(),yme=(t,e)=>{let r=Ime(t,e);return r&&r.prerelease.length?r.prerelease:null};P2.exports=yme});var F2=w((Fet,R2)=>{var wme=ws(),Bme=(t,e,r)=>wme(e,t,r);R2.exports=Bme});var L2=w((Net,N2)=>{var bme=ws(),Qme=(t,e)=>bme(t,e,!0);N2.exports=Qme});var kI=w((Let,T2)=>{var O2=Hi(),vme=(t,e,r)=>{let i=new O2(t,r),n=new O2(e,r);return i.compare(n)||i.compareBuild(n)};T2.exports=vme});var U2=w((Tet,M2)=>{var Sme=kI(),kme=(t,e)=>t.sort((r,i)=>Sme(r,i,e));M2.exports=kme});var H2=w((Oet,K2)=>{var xme=kI(),Pme=(t,e)=>t.sort((r,i)=>xme(i,r,e));K2.exports=Pme});var wp=w((Met,j2)=>{var Dme=ws(),Rme=(t,e,r)=>Dme(t,e,r)>0;j2.exports=Rme});var xI=w((Uet,G2)=>{var Fme=ws(),Nme=(t,e,r)=>Fme(t,e,r)<0;G2.exports=Nme});var mv=w((Ket,Y2)=>{var Lme=ws(),Tme=(t,e,r)=>Lme(t,e,r)!==0;Y2.exports=Tme});var PI=w((Het,q2)=>{var Ome=ws(),Mme=(t,e,r)=>Ome(t,e,r)>=0;q2.exports=Mme});var DI=w((jet,J2)=>{var Ume=ws(),Kme=(t,e,r)=>Ume(t,e,r)<=0;J2.exports=Kme});var Ev=w((Get,W2)=>{var Hme=SI(),jme=mv(),Gme=wp(),Yme=PI(),qme=xI(),Jme=DI(),Wme=(t,e,r,i)=>{switch(e){case"===":return typeof t=="object"&&(t=t.version),typeof r=="object"&&(r=r.version),t===r;case"!==":return typeof t=="object"&&(t=t.version),typeof r=="object"&&(r=r.version),t!==r;case"":case"=":case"==":return Hme(t,r,i);case"!=":return jme(t,r,i);case">":return Gme(t,r,i);case">=":return Yme(t,r,i);case"<":return qme(t,r,i);case"<=":return Jme(t,r,i);default:throw new TypeError(`Invalid operator: ${e}`)}};W2.exports=Wme});var _2=w((Yet,z2)=>{var zme=Hi(),_me=Ec(),{re:RI,t:FI}=mc(),Vme=(t,e)=>{if(t instanceof zme)return t;if(typeof t=="number"&&(t=String(t)),typeof t!="string")return null;e=e||{};let r=null;if(!e.rtl)r=t.match(RI[FI.COERCE]);else{let i;for(;(i=RI[FI.COERCERTL].exec(t))&&(!r||r.index+r[0].length!==t.length);)(!r||i.index+i[0].length!==r.index+r[0].length)&&(r=i),RI[FI.COERCERTL].lastIndex=i.index+i[1].length+i[2].length;RI[FI.COERCERTL].lastIndex=-1}return r===null?null:_me(`${r[2]}.${r[3]||"0"}.${r[4]||"0"}`,e)};z2.exports=Vme});var X2=w((qet,V2)=>{"use strict";V2.exports=function(t){t.prototype[Symbol.iterator]=function*(){for(let e=this.head;e;e=e.next)yield e.value}}});var Bp=w((Jet,Z2)=>{"use strict";Z2.exports=Gt;Gt.Node=Ic;Gt.create=Gt;function Gt(t){var e=this;if(e instanceof Gt||(e=new Gt),e.tail=null,e.head=null,e.length=0,t&&typeof t.forEach=="function")t.forEach(function(n){e.push(n)});else if(arguments.length>0)for(var r=0,i=arguments.length;r<i;r++)e.push(arguments[r]);return e}Gt.prototype.removeNode=function(t){if(t.list!==this)throw new Error("removing node which does not belong to this list");var e=t.next,r=t.prev;return e&&(e.prev=r),r&&(r.next=e),t===this.head&&(this.head=e),t===this.tail&&(this.tail=r),t.list.length--,t.next=null,t.prev=null,t.list=null,e};Gt.prototype.unshiftNode=function(t){if(t!==this.head){t.list&&t.list.removeNode(t);var e=this.head;t.list=this,t.next=e,e&&(e.prev=t),this.head=t,this.tail||(this.tail=t),this.length++}};Gt.prototype.pushNode=function(t){if(t!==this.tail){t.list&&t.list.removeNode(t);var e=this.tail;t.list=this,t.prev=e,e&&(e.next=t),this.tail=t,this.head||(this.head=t),this.length++}};Gt.prototype.push=function(){for(var t=0,e=arguments.length;t<e;t++)Xme(this,arguments[t]);return this.length};Gt.prototype.unshift=function(){for(var t=0,e=arguments.length;t<e;t++)Zme(this,arguments[t]);return this.length};Gt.prototype.pop=function(){if(!!this.tail){var t=this.tail.value;return this.tail=this.tail.prev,this.tail?this.tail.next=null:this.head=null,this.length--,t}};Gt.prototype.shift=function(){if(!!this.head){var t=this.head.value;return this.head=this.head.next,this.head?this.head.prev=null:this.tail=null,this.length--,t}};Gt.prototype.forEach=function(t,e){e=e||this;for(var r=this.head,i=0;r!==null;i++)t.call(e,r.value,i,this),r=r.next};Gt.prototype.forEachReverse=function(t,e){e=e||this;for(var r=this.tail,i=this.length-1;r!==null;i--)t.call(e,r.value,i,this),r=r.prev};Gt.prototype.get=function(t){for(var e=0,r=this.head;r!==null&&e<t;e++)r=r.next;if(e===t&&r!==null)return r.value};Gt.prototype.getReverse=function(t){for(var e=0,r=this.tail;r!==null&&e<t;e++)r=r.prev;if(e===t&&r!==null)return r.value};Gt.prototype.map=function(t,e){e=e||this;for(var r=new Gt,i=this.head;i!==null;)r.push(t.call(e,i.value,this)),i=i.next;return r};Gt.prototype.mapReverse=function(t,e){e=e||this;for(var r=new Gt,i=this.tail;i!==null;)r.push(t.call(e,i.value,this)),i=i.prev;return r};Gt.prototype.reduce=function(t,e){var r,i=this.head;if(arguments.length>1)r=e;else if(this.head)i=this.head.next,r=this.head.value;else throw new TypeError("Reduce of empty list with no initial value");for(var n=0;i!==null;n++)r=t(r,i.value,n),i=i.next;return r};Gt.prototype.reduceReverse=function(t,e){var r,i=this.tail;if(arguments.length>1)r=e;else if(this.tail)i=this.tail.prev,r=this.tail.value;else throw new TypeError("Reduce of empty list with no initial value");for(var n=this.length-1;i!==null;n--)r=t(r,i.value,n),i=i.prev;return r};Gt.prototype.toArray=function(){for(var t=new Array(this.length),e=0,r=this.head;r!==null;e++)t[e]=r.value,r=r.next;return t};Gt.prototype.toArrayReverse=function(){for(var t=new Array(this.length),e=0,r=this.tail;r!==null;e++)t[e]=r.value,r=r.prev;return t};Gt.prototype.slice=function(t,e){e=e||this.length,e<0&&(e+=this.length),t=t||0,t<0&&(t+=this.length);var r=new Gt;if(e<t||e<0)return r;t<0&&(t=0),e>this.length&&(e=this.length);for(var i=0,n=this.head;n!==null&&i<t;i++)n=n.next;for(;n!==null&&i<e;i++,n=n.next)r.push(n.value);return r};Gt.prototype.sliceReverse=function(t,e){e=e||this.length,e<0&&(e+=this.length),t=t||0,t<0&&(t+=this.length);var r=new Gt;if(e<t||e<0)return r;t<0&&(t=0),e>this.length&&(e=this.length);for(var i=this.length,n=this.tail;n!==null&&i>e;i--)n=n.prev;for(;n!==null&&i>t;i--,n=n.prev)r.push(n.value);return r};Gt.prototype.splice=function(t,e,...r){t>this.length&&(t=this.length-1),t<0&&(t=this.length+t);for(var i=0,n=this.head;n!==null&&i<t;i++)n=n.next;for(var s=[],i=0;n&&i<e;i++)s.push(n.value),n=this.removeNode(n);n===null&&(n=this.tail),n!==this.head&&n!==this.tail&&(n=n.prev);for(var i=0;i<r.length;i++)n=$me(this,n,r[i]);return s};Gt.prototype.reverse=function(){for(var t=this.head,e=this.tail,r=t;r!==null;r=r.prev){var i=r.prev;r.prev=r.next,r.next=i}return this.head=e,this.tail=t,this};function $me(t,e,r){var i=e===t.head?new Ic(r,null,e,t):new Ic(r,e,e.next,t);return i.next===null&&(t.tail=i),i.prev===null&&(t.head=i),t.length++,i}function Xme(t,e){t.tail=new Ic(e,t.tail,null,t),t.head||(t.head=t.tail),t.length++}function Zme(t,e){t.head=new Ic(e,null,t.head,t),t.tail||(t.tail=t.head),t.length++}function Ic(t,e,r,i){if(!(this instanceof Ic))return new Ic(t,e,r,i);this.list=i,this.value=t,e?(e.next=this,this.prev=e):this.prev=null,r?(r.prev=this,this.next=r):this.next=null}try{X2()(Gt)}catch(t){}});var sH=w((Wet,$2)=>{"use strict";var eEe=Bp(),yc=Symbol("max"),Ta=Symbol("length"),Ag=Symbol("lengthCalculator"),bp=Symbol("allowStale"),wc=Symbol("maxAge"),Oa=Symbol("dispose"),eH=Symbol("noDisposeOnSet"),Ii=Symbol("lruList"),no=Symbol("cache"),tH=Symbol("updateAgeOnGet"),Iv=()=>1,rH=class{constructor(e){if(typeof e=="number"&&(e={max:e}),e||(e={}),e.max&&(typeof e.max!="number"||e.max<0))throw new TypeError("max must be a non-negative number");let r=this[yc]=e.max||Infinity,i=e.length||Iv;if(this[Ag]=typeof i!="function"?Iv:i,this[bp]=e.stale||!1,e.maxAge&&typeof e.maxAge!="number")throw new TypeError("maxAge must be a number");this[wc]=e.maxAge||0,this[Oa]=e.dispose,this[eH]=e.noDisposeOnSet||!1,this[tH]=e.updateAgeOnGet||!1,this.reset()}set max(e){if(typeof e!="number"||e<0)throw new TypeError("max must be a non-negative number");this[yc]=e||Infinity,Qp(this)}get max(){return this[yc]}set allowStale(e){this[bp]=!!e}get allowStale(){return this[bp]}set maxAge(e){if(typeof e!="number")throw new TypeError("maxAge must be a non-negative number");this[wc]=e,Qp(this)}get maxAge(){return this[wc]}set lengthCalculator(e){typeof e!="function"&&(e=Iv),e!==this[Ag]&&(this[Ag]=e,this[Ta]=0,this[Ii].forEach(r=>{r.length=this[Ag](r.value,r.key),this[Ta]+=r.length})),Qp(this)}get lengthCalculator(){return this[Ag]}get length(){return this[Ta]}get itemCount(){return this[Ii].length}rforEach(e,r){r=r||this;for(let i=this[Ii].tail;i!==null;){let n=i.prev;nH(this,e,i,r),i=n}}forEach(e,r){r=r||this;for(let i=this[Ii].head;i!==null;){let n=i.next;nH(this,e,i,r),i=n}}keys(){return this[Ii].toArray().map(e=>e.key)}values(){return this[Ii].toArray().map(e=>e.value)}reset(){this[Oa]&&this[Ii]&&this[Ii].length&&this[Ii].forEach(e=>this[Oa](e.key,e.value)),this[no]=new Map,this[Ii]=new eEe,this[Ta]=0}dump(){return this[Ii].map(e=>NI(this,e)?!1:{k:e.key,v:e.value,e:e.now+(e.maxAge||0)}).toArray().filter(e=>e)}dumpLru(){return this[Ii]}set(e,r,i){if(i=i||this[wc],i&&typeof i!="number")throw new TypeError("maxAge must be a number");let n=i?Date.now():0,s=this[Ag](r,e);if(this[no].has(e)){if(s>this[yc])return lg(this,this[no].get(e)),!1;let l=this[no].get(e).value;return this[Oa]&&(this[eH]||this[Oa](e,l.value)),l.now=n,l.maxAge=i,l.value=r,this[Ta]+=s-l.length,l.length=s,this.get(e),Qp(this),!0}let o=new iH(e,r,s,n,i);return o.length>this[yc]?(this[Oa]&&this[Oa](e,r),!1):(this[Ta]+=o.length,this[Ii].unshift(o),this[no].set(e,this[Ii].head),Qp(this),!0)}has(e){if(!this[no].has(e))return!1;let r=this[no].get(e).value;return!NI(this,r)}get(e){return yv(this,e,!0)}peek(e){return yv(this,e,!1)}pop(){let e=this[Ii].tail;return e?(lg(this,e),e.value):null}del(e){lg(this,this[no].get(e))}load(e){this.reset();let r=Date.now();for(let i=e.length-1;i>=0;i--){let n=e[i],s=n.e||0;if(s===0)this.set(n.k,n.v);else{let o=s-r;o>0&&this.set(n.k,n.v,o)}}}prune(){this[no].forEach((e,r)=>yv(this,r,!1))}},yv=(t,e,r)=>{let i=t[no].get(e);if(i){let n=i.value;if(NI(t,n)){if(lg(t,i),!t[bp])return}else r&&(t[tH]&&(i.value.now=Date.now()),t[Ii].unshiftNode(i));return n.value}},NI=(t,e)=>{if(!e||!e.maxAge&&!t[wc])return!1;let r=Date.now()-e.now;return e.maxAge?r>e.maxAge:t[wc]&&r>t[wc]},Qp=t=>{if(t[Ta]>t[yc])for(let e=t[Ii].tail;t[Ta]>t[yc]&&e!==null;){let r=e.prev;lg(t,e),e=r}},lg=(t,e)=>{if(e){let r=e.value;t[Oa]&&t[Oa](r.key,r.value),t[Ta]-=r.length,t[no].delete(r.key),t[Ii].removeNode(e)}},iH=class{constructor(e,r,i,n,s){this.key=e,this.value=r,this.length=i,this.now=n,this.maxAge=s||0}},nH=(t,e,r,i)=>{let n=r.value;NI(t,n)&&(lg(t,r),t[bp]||(n=void 0)),n&&e.call(i,n.value,n.key,t)};$2.exports=rH});var Bs=w((zet,oH)=>{var cg=class{constructor(e,r){if(r=tEe(r),e instanceof cg)return e.loose===!!r.loose&&e.includePrerelease===!!r.includePrerelease?e:new cg(e.raw,r);if(e instanceof wv)return this.raw=e.value,this.set=[[e]],this.format(),this;if(this.options=r,this.loose=!!r.loose,this.includePrerelease=!!r.includePrerelease,this.raw=e,this.set=e.split(/\s*\|\|\s*/).map(i=>this.parseRange(i.trim())).filter(i=>i.length),!this.set.length)throw new TypeError(`Invalid SemVer Range: ${e}`);if(this.set.length>1){let i=this.set[0];if(this.set=this.set.filter(n=>!AH(n[0])),this.set.length===0)this.set=[i];else if(this.set.length>1){for(let n of this.set)if(n.length===1&&oEe(n[0])){this.set=[n];break}}}this.format()}format(){return this.range=this.set.map(e=>e.join(" ").trim()).join("||").trim(),this.range}toString(){return this.range}parseRange(e){e=e.trim();let i=`parseRange:${Object.keys(this.options).join(",")}:${e}`,n=aH.get(i);if(n)return n;let s=this.options.loose,o=s?ji[ki.HYPHENRANGELOOSE]:ji[ki.HYPHENRANGE];e=e.replace(o,lEe(this.options.includePrerelease)),Wr("hyphen replace",e),e=e.replace(ji[ki.COMPARATORTRIM],iEe),Wr("comparator trim",e,ji[ki.COMPARATORTRIM]),e=e.replace(ji[ki.TILDETRIM],nEe),e=e.replace(ji[ki.CARETTRIM],sEe),e=e.split(/\s+/).join(" ");let a=s?ji[ki.COMPARATORLOOSE]:ji[ki.COMPARATOR],l=e.split(" ").map(f=>aEe(f,this.options)).join(" ").split(/\s+/).map(f=>AEe(f,this.options)).filter(this.options.loose?f=>!!f.match(a):()=>!0).map(f=>new wv(f,this.options)),c=l.length,u=new Map;for(let f of l){if(AH(f))return[f];u.set(f.value,f)}u.size>1&&u.has("")&&u.delete("");let g=[...u.values()];return aH.set(i,g),g}intersects(e,r){if(!(e instanceof cg))throw new TypeError("a Range is required");return this.set.some(i=>lH(i,r)&&e.set.some(n=>lH(n,r)&&i.every(s=>n.every(o=>s.intersects(o,r)))))}test(e){if(!e)return!1;if(typeof e=="string")try{e=new rEe(e,this.options)}catch(r){return!1}for(let r=0;r<this.set.length;r++)if(cEe(this.set[r],e,this.options))return!0;return!1}};oH.exports=cg;var uEe=sH(),aH=new uEe({max:1e3}),tEe=Ip(),wv=vp(),Wr=Ep(),rEe=Hi(),{re:ji,t:ki,comparatorTrimReplace:iEe,tildeTrimReplace:nEe,caretTrimReplace:sEe}=mc(),AH=t=>t.value==="<0.0.0-0",oEe=t=>t.value==="",lH=(t,e)=>{let r=!0,i=t.slice(),n=i.pop();for(;r&&i.length;)r=i.every(s=>n.intersects(s,e)),n=i.pop();return r},aEe=(t,e)=>(Wr("comp",t,e),t=fEe(t,e),Wr("caret",t),t=gEe(t,e),Wr("tildes",t),t=hEe(t,e),Wr("xrange",t),t=pEe(t,e),Wr("stars",t),t),on=t=>!t||t.toLowerCase()==="x"||t==="*",gEe=(t,e)=>t.trim().split(/\s+/).map(r=>dEe(r,e)).join(" "),dEe=(t,e)=>{let r=e.loose?ji[ki.TILDELOOSE]:ji[ki.TILDE];return t.replace(r,(i,n,s,o,a)=>{Wr("tilde",t,i,n,s,o,a);let l;return on(n)?l="":on(s)?l=`>=${n}.0.0 <${+n+1}.0.0-0`:on(o)?l=`>=${n}.${s}.0 <${n}.${+s+1}.0-0`:a?(Wr("replaceTilde pr",a),l=`>=${n}.${s}.${o}-${a} <${n}.${+s+1}.0-0`):l=`>=${n}.${s}.${o} <${n}.${+s+1}.0-0`,Wr("tilde return",l),l})},fEe=(t,e)=>t.trim().split(/\s+/).map(r=>CEe(r,e)).join(" "),CEe=(t,e)=>{Wr("caret",t,e);let r=e.loose?ji[ki.CARETLOOSE]:ji[ki.CARET],i=e.includePrerelease?"-0":"";return t.replace(r,(n,s,o,a,l)=>{Wr("caret",t,n,s,o,a,l);let c;return on(s)?c="":on(o)?c=`>=${s}.0.0${i} <${+s+1}.0.0-0`:on(a)?s==="0"?c=`>=${s}.${o}.0${i} <${s}.${+o+1}.0-0`:c=`>=${s}.${o}.0${i} <${+s+1}.0.0-0`:l?(Wr("replaceCaret pr",l),s==="0"?o==="0"?c=`>=${s}.${o}.${a}-${l} <${s}.${o}.${+a+1}-0`:c=`>=${s}.${o}.${a}-${l} <${s}.${+o+1}.0-0`:c=`>=${s}.${o}.${a}-${l} <${+s+1}.0.0-0`):(Wr("no pr"),s==="0"?o==="0"?c=`>=${s}.${o}.${a}${i} <${s}.${o}.${+a+1}-0`:c=`>=${s}.${o}.${a}${i} <${s}.${+o+1}.0-0`:c=`>=${s}.${o}.${a} <${+s+1}.0.0-0`),Wr("caret return",c),c})},hEe=(t,e)=>(Wr("replaceXRanges",t,e),t.split(/\s+/).map(r=>mEe(r,e)).join(" ")),mEe=(t,e)=>{t=t.trim();let r=e.loose?ji[ki.XRANGELOOSE]:ji[ki.XRANGE];return t.replace(r,(i,n,s,o,a,l)=>{Wr("xRange",t,i,n,s,o,a,l);let c=on(s),u=c||on(o),g=u||on(a),f=g;return n==="="&&f&&(n=""),l=e.includePrerelease?"-0":"",c?n===">"||n==="<"?i="<0.0.0-0":i="*":n&&f?(u&&(o=0),a=0,n===">"?(n=">=",u?(s=+s+1,o=0,a=0):(o=+o+1,a=0)):n==="<="&&(n="<",u?s=+s+1:o=+o+1),n==="<"&&(l="-0"),i=`${n+s}.${o}.${a}${l}`):u?i=`>=${s}.0.0${l} <${+s+1}.0.0-0`:g&&(i=`>=${s}.${o}.0${l} <${s}.${+o+1}.0-0`),Wr("xRange return",i),i})},pEe=(t,e)=>(Wr("replaceStars",t,e),t.trim().replace(ji[ki.STAR],"")),AEe=(t,e)=>(Wr("replaceGTE0",t,e),t.trim().replace(ji[e.includePrerelease?ki.GTE0PRE:ki.GTE0],"")),lEe=t=>(e,r,i,n,s,o,a,l,c,u,g,f,h)=>(on(i)?r="":on(n)?r=`>=${i}.0.0${t?"-0":""}`:on(s)?r=`>=${i}.${n}.0${t?"-0":""}`:o?r=`>=${r}`:r=`>=${r}${t?"-0":""}`,on(c)?l="":on(u)?l=`<${+c+1}.0.0-0`:on(g)?l=`<${c}.${+u+1}.0-0`:f?l=`<=${c}.${u}.${g}-${f}`:t?l=`<${c}.${u}.${+g+1}-0`:l=`<=${l}`,`${r} ${l}`.trim()),cEe=(t,e,r)=>{for(let i=0;i<t.length;i++)if(!t[i].test(e))return!1;if(e.prerelease.length&&!r.includePrerelease){for(let i=0;i<t.length;i++)if(Wr(t[i].semver),t[i].semver!==wv.ANY&&t[i].semver.prerelease.length>0){let n=t[i].semver;if(n.major===e.major&&n.minor===e.minor&&n.patch===e.patch)return!0}return!1}return!0}});var vp=w((_et,cH)=>{var Sp=Symbol("SemVer ANY"),kp=class{static get ANY(){return Sp}constructor(e,r){if(r=EEe(r),e instanceof kp){if(e.loose===!!r.loose)return e;e=e.value}bv("comparator",e,r),this.options=r,this.loose=!!r.loose,this.parse(e),this.semver===Sp?this.value="":this.value=this.operator+this.semver.version,bv("comp",this)}parse(e){let r=this.options.loose?uH[gH.COMPARATORLOOSE]:uH[gH.COMPARATOR],i=e.match(r);if(!i)throw new TypeError(`Invalid comparator: ${e}`);this.operator=i[1]!==void 0?i[1]:"",this.operator==="="&&(this.operator=""),i[2]?this.semver=new fH(i[2],this.options.loose):this.semver=Sp}toString(){return this.value}test(e){if(bv("Comparator.test",e,this.options.loose),this.semver===Sp||e===Sp)return!0;if(typeof e=="string")try{e=new fH(e,this.options)}catch(r){return!1}return Bv(e,this.operator,this.semver,this.options)}intersects(e,r){if(!(e instanceof kp))throw new TypeError("a Comparator is required");if((!r||typeof r!="object")&&(r={loose:!!r,includePrerelease:!1}),this.operator==="")return this.value===""?!0:new hH(e.value,r).test(this.value);if(e.operator==="")return e.value===""?!0:new hH(this.value,r).test(e.semver);let i=(this.operator===">="||this.operator===">")&&(e.operator===">="||e.operator===">"),n=(this.operator==="<="||this.operator==="<")&&(e.operator==="<="||e.operator==="<"),s=this.semver.version===e.semver.version,o=(this.operator===">="||this.operator==="<=")&&(e.operator===">="||e.operator==="<="),a=Bv(this.semver,"<",e.semver,r)&&(this.operator===">="||this.operator===">")&&(e.operator==="<="||e.operator==="<"),l=Bv(this.semver,">",e.semver,r)&&(this.operator==="<="||this.operator==="<")&&(e.operator===">="||e.operator===">");return i||n||s&&o||a||l}};cH.exports=kp;var EEe=Ip(),{re:uH,t:gH}=mc(),Bv=Ev(),bv=Ep(),fH=Hi(),hH=Bs()});var xp=w((Vet,pH)=>{var IEe=Bs(),yEe=(t,e,r)=>{try{e=new IEe(e,r)}catch(i){return!1}return e.test(t)};pH.exports=yEe});var CH=w((Xet,dH)=>{var wEe=Bs(),BEe=(t,e)=>new wEe(t,e).set.map(r=>r.map(i=>i.value).join(" ").trim().split(" "));dH.exports=BEe});var EH=w((Zet,mH)=>{var bEe=Hi(),QEe=Bs(),vEe=(t,e,r)=>{let i=null,n=null,s=null;try{s=new QEe(e,r)}catch(o){return null}return t.forEach(o=>{s.test(o)&&(!i||n.compare(o)===-1)&&(i=o,n=new bEe(i,r))}),i};mH.exports=vEe});var yH=w(($et,IH)=>{var SEe=Hi(),kEe=Bs(),xEe=(t,e,r)=>{let i=null,n=null,s=null;try{s=new kEe(e,r)}catch(o){return null}return t.forEach(o=>{s.test(o)&&(!i||n.compare(o)===1)&&(i=o,n=new SEe(i,r))}),i};IH.exports=xEe});var bH=w((ett,wH)=>{var Qv=Hi(),PEe=Bs(),BH=wp(),DEe=(t,e)=>{t=new PEe(t,e);let r=new Qv("0.0.0");if(t.test(r)||(r=new Qv("0.0.0-0"),t.test(r)))return r;r=null;for(let i=0;i<t.set.length;++i){let n=t.set[i],s=null;n.forEach(o=>{let a=new Qv(o.semver.version);switch(o.operator){case">":a.prerelease.length===0?a.patch++:a.prerelease.push(0),a.raw=a.format();case"":case">=":(!s||BH(a,s))&&(s=a);break;case"<":case"<=":break;default:throw new Error(`Unexpected operation: ${o.operator}`)}}),s&&(!r||BH(r,s))&&(r=s)}return r&&t.test(r)?r:null};wH.exports=DEe});var vH=w((ttt,QH)=>{var REe=Bs(),FEe=(t,e)=>{try{return new REe(t,e).range||"*"}catch(r){return null}};QH.exports=FEe});var LI=w((rtt,SH)=>{var NEe=Hi(),kH=vp(),{ANY:LEe}=kH,TEe=Bs(),OEe=xp(),xH=wp(),PH=xI(),MEe=DI(),UEe=PI(),KEe=(t,e,r,i)=>{t=new NEe(t,i),e=new TEe(e,i);let n,s,o,a,l;switch(r){case">":n=xH,s=MEe,o=PH,a=">",l=">=";break;case"<":n=PH,s=UEe,o=xH,a="<",l="<=";break;default:throw new TypeError('Must provide a hilo val of "<" or ">"')}if(OEe(t,e,i))return!1;for(let c=0;c<e.set.length;++c){let u=e.set[c],g=null,f=null;if(u.forEach(h=>{h.semver===LEe&&(h=new kH(">=0.0.0")),g=g||h,f=f||h,n(h.semver,g.semver,i)?g=h:o(h.semver,f.semver,i)&&(f=h)}),g.operator===a||g.operator===l||(!f.operator||f.operator===a)&&s(t,f.semver))return!1;if(f.operator===l&&o(t,f.semver))return!1}return!0};SH.exports=KEe});var RH=w((itt,DH)=>{var HEe=LI(),jEe=(t,e,r)=>HEe(t,e,">",r);DH.exports=jEe});var NH=w((ntt,FH)=>{var GEe=LI(),YEe=(t,e,r)=>GEe(t,e,"<",r);FH.exports=YEe});var OH=w((stt,LH)=>{var TH=Bs(),qEe=(t,e,r)=>(t=new TH(t,r),e=new TH(e,r),t.intersects(e));LH.exports=qEe});var UH=w((ott,MH)=>{var JEe=xp(),WEe=ws();MH.exports=(t,e,r)=>{let i=[],n=null,s=null,o=t.sort((u,g)=>WEe(u,g,r));for(let u of o)JEe(u,e,r)?(s=u,n||(n=u)):(s&&i.push([n,s]),s=null,n=null);n&&i.push([n,null]);let a=[];for(let[u,g]of i)u===g?a.push(u):!g&&u===o[0]?a.push("*"):g?u===o[0]?a.push(`<=${g}`):a.push(`${u} - ${g}`):a.push(`>=${u}`);let l=a.join(" || "),c=typeof e.raw=="string"?e.raw:String(e);return l.length<c.length?l:e}});var YH=w((att,KH)=>{var HH=Bs(),TI=vp(),{ANY:vv}=TI,Pp=xp(),Sv=ws(),_Ee=(t,e,r={})=>{if(t===e)return!0;t=new HH(t,r),e=new HH(e,r);let i=!1;e:for(let n of t.set){for(let s of e.set){let o=zEe(n,s,r);if(i=i||o!==null,o)continue e}if(i)return!1}return!0},zEe=(t,e,r)=>{if(t===e)return!0;if(t.length===1&&t[0].semver===vv){if(e.length===1&&e[0].semver===vv)return!0;r.includePrerelease?t=[new TI(">=0.0.0-0")]:t=[new TI(">=0.0.0")]}if(e.length===1&&e[0].semver===vv){if(r.includePrerelease)return!0;e=[new TI(">=0.0.0")]}let i=new Set,n,s;for(let h of t)h.operator===">"||h.operator===">="?n=jH(n,h,r):h.operator==="<"||h.operator==="<="?s=GH(s,h,r):i.add(h.semver);if(i.size>1)return null;let o;if(n&&s){if(o=Sv(n.semver,s.semver,r),o>0)return null;if(o===0&&(n.operator!==">="||s.operator!=="<="))return null}for(let h of i){if(n&&!Pp(h,String(n),r)||s&&!Pp(h,String(s),r))return null;for(let p of e)if(!Pp(h,String(p),r))return!1;return!0}let a,l,c,u,g=s&&!r.includePrerelease&&s.semver.prerelease.length?s.semver:!1,f=n&&!r.includePrerelease&&n.semver.prerelease.length?n.semver:!1;g&&g.prerelease.length===1&&s.operator==="<"&&g.prerelease[0]===0&&(g=!1);for(let h of e){if(u=u||h.operator===">"||h.operator===">=",c=c||h.operator==="<"||h.operator==="<=",n){if(f&&h.semver.prerelease&&h.semver.prerelease.length&&h.semver.major===f.major&&h.semver.minor===f.minor&&h.semver.patch===f.patch&&(f=!1),h.operator===">"||h.operator===">="){if(a=jH(n,h,r),a===h&&a!==n)return!1}else if(n.operator===">="&&!Pp(n.semver,String(h),r))return!1}if(s){if(g&&h.semver.prerelease&&h.semver.prerelease.length&&h.semver.major===g.major&&h.semver.minor===g.minor&&h.semver.patch===g.patch&&(g=!1),h.operator==="<"||h.operator==="<="){if(l=GH(s,h,r),l===h&&l!==s)return!1}else if(s.operator==="<="&&!Pp(s.semver,String(h),r))return!1}if(!h.operator&&(s||n)&&o!==0)return!1}return!(n&&c&&!s&&o!==0||s&&u&&!n&&o!==0||f||g)},jH=(t,e,r)=>{if(!t)return e;let i=Sv(t.semver,e.semver,r);return i>0?t:i<0||e.operator===">"&&t.operator===">="?e:t},GH=(t,e,r)=>{if(!t)return e;let i=Sv(t.semver,e.semver,r);return i<0?t:i>0||e.operator==="<"&&t.operator==="<="?e:t};KH.exports=_Ee});var ti=w((Att,qH)=>{var kv=mc();qH.exports={re:kv.re,src:kv.src,tokens:kv.t,SEMVER_SPEC_VERSION:mp().SEMVER_SPEC_VERSION,SemVer:Hi(),compareIdentifiers:bI().compareIdentifiers,rcompareIdentifiers:bI().rcompareIdentifiers,parse:Ec(),valid:f2(),clean:p2(),inc:C2(),diff:B2(),major:Q2(),minor:S2(),patch:x2(),prerelease:D2(),compare:ws(),rcompare:F2(),compareLoose:L2(),compareBuild:kI(),sort:U2(),rsort:H2(),gt:wp(),lt:xI(),eq:SI(),neq:mv(),gte:PI(),lte:DI(),cmp:Ev(),coerce:_2(),Comparator:vp(),Range:Bs(),satisfies:xp(),toComparators:CH(),maxSatisfying:EH(),minSatisfying:yH(),minVersion:bH(),validRange:vH(),outside:LI(),gtr:RH(),ltr:NH(),intersects:OH(),simplifyRange:UH(),subset:YH()}});var xv=w(OI=>{"use strict";Object.defineProperty(OI,"__esModule",{value:!0});OI.VERSION=void 0;OI.VERSION="9.1.0"});var Yt=w((exports,module)=>{"use strict";var __spreadArray=exports&&exports.__spreadArray||function(t,e,r){if(r||arguments.length===2)for(var i=0,n=e.length,s;i<n;i++)(s||!(i in e))&&(s||(s=Array.prototype.slice.call(e,0,i)),s[i]=e[i]);return t.concat(s||Array.prototype.slice.call(e))};Object.defineProperty(exports,"__esModule",{value:!0});exports.toFastProperties=exports.timer=exports.peek=exports.isES2015MapSupported=exports.PRINT_WARNING=exports.PRINT_ERROR=exports.packArray=exports.IDENTITY=exports.NOOP=exports.merge=exports.groupBy=exports.defaults=exports.assignNoOverwrite=exports.assign=exports.zipObject=exports.sortBy=exports.indexOf=exports.some=exports.difference=exports.every=exports.isObject=exports.isRegExp=exports.isArray=exports.partial=exports.uniq=exports.compact=exports.reduce=exports.findAll=exports.find=exports.cloneObj=exports.cloneArr=exports.contains=exports.has=exports.pick=exports.reject=exports.filter=exports.dropRight=exports.drop=exports.isFunction=exports.isUndefined=exports.isString=exports.forEach=exports.last=exports.first=exports.flatten=exports.map=exports.mapValues=exports.values=exports.keys=exports.isEmpty=void 0;exports.upperFirst=void 0;function isEmpty(t){return t&&t.length===0}exports.isEmpty=isEmpty;function keys(t){return t==null?[]:Object.keys(t)}exports.keys=keys;function values(t){for(var e=[],r=Object.keys(t),i=0;i<r.length;i++)e.push(t[r[i]]);return e}exports.values=values;function mapValues(t,e){for(var r=[],i=keys(t),n=0;n<i.length;n++){var s=i[n];r.push(e.call(null,t[s],s))}return r}exports.mapValues=mapValues;function map(t,e){for(var r=[],i=0;i<t.length;i++)r.push(e.call(null,t[i],i));return r}exports.map=map;function flatten(t){for(var e=[],r=0;r<t.length;r++){var i=t[r];Array.isArray(i)?e=e.concat(flatten(i)):e.push(i)}return e}exports.flatten=flatten;function first(t){return isEmpty(t)?void 0:t[0]}exports.first=first;function last(t){var e=t&&t.length;return e?t[e-1]:void 0}exports.last=last;function forEach(t,e){if(Array.isArray(t))for(var r=0;r<t.length;r++)e.call(null,t[r],r);else if(isObject(t))for(var i=keys(t),r=0;r<i.length;r++){var n=i[r],s=t[n];e.call(null,s,n)}else throw Error("non exhaustive match")}exports.forEach=forEach;function isString(t){return typeof t=="string"}exports.isString=isString;function isUndefined(t){return t===void 0}exports.isUndefined=isUndefined;function isFunction(t){return t instanceof Function}exports.isFunction=isFunction;function drop(t,e){return e===void 0&&(e=1),t.slice(e,t.length)}exports.drop=drop;function dropRight(t,e){return e===void 0&&(e=1),t.slice(0,t.length-e)}exports.dropRight=dropRight;function filter(t,e){var r=[];if(Array.isArray(t))for(var i=0;i<t.length;i++){var n=t[i];e.call(null,n)&&r.push(n)}return r}exports.filter=filter;function reject(t,e){return filter(t,function(r){return!e(r)})}exports.reject=reject;function pick(t,e){for(var r=Object.keys(t),i={},n=0;n<r.length;n++){var s=r[n],o=t[s];e(o)&&(i[s]=o)}return i}exports.pick=pick;function has(t,e){return isObject(t)?t.hasOwnProperty(e):!1}exports.has=has;function contains(t,e){return find(t,function(r){return r===e})!==void 0}exports.contains=contains;function cloneArr(t){for(var e=[],r=0;r<t.length;r++)e.push(t[r]);return e}exports.cloneArr=cloneArr;function cloneObj(t){var e={};for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r]);return e}exports.cloneObj=cloneObj;function find(t,e){for(var r=0;r<t.length;r++){var i=t[r];if(e.call(null,i))return i}}exports.find=find;function findAll(t,e){for(var r=[],i=0;i<t.length;i++){var n=t[i];e.call(null,n)&&r.push(n)}return r}exports.findAll=findAll;function reduce(t,e,r){for(var i=Array.isArray(t),n=i?t:values(t),s=i?[]:keys(t),o=r,a=0;a<n.length;a++)o=e.call(null,o,n[a],i?a:s[a]);return o}exports.reduce=reduce;function compact(t){return reject(t,function(e){return e==null})}exports.compact=compact;function uniq(t,e){e===void 0&&(e=function(i){return i});var r=[];return reduce(t,function(i,n){var s=e(n);return contains(r,s)?i:(r.push(s),i.concat(n))},[])}exports.uniq=uniq;function partial(t){for(var e=[],r=1;r<arguments.length;r++)e[r-1]=arguments[r];var i=[null],n=i.concat(e);return Function.bind.apply(t,n)}exports.partial=partial;function isArray(t){return Array.isArray(t)}exports.isArray=isArray;function isRegExp(t){return t instanceof RegExp}exports.isRegExp=isRegExp;function isObject(t){return t instanceof Object}exports.isObject=isObject;function every(t,e){for(var r=0;r<t.length;r++)if(!e(t[r],r))return!1;return!0}exports.every=every;function difference(t,e){return reject(t,function(r){return contains(e,r)})}exports.difference=difference;function some(t,e){for(var r=0;r<t.length;r++)if(e(t[r]))return!0;return!1}exports.some=some;function indexOf(t,e){for(var r=0;r<t.length;r++)if(t[r]===e)return r;return-1}exports.indexOf=indexOf;function sortBy(t,e){var r=cloneArr(t);return r.sort(function(i,n){return e(i)-e(n)}),r}exports.sortBy=sortBy;function zipObject(t,e){if(t.length!==e.length)throw Error("can't zipObject with different number of keys and values!");for(var r={},i=0;i<t.length;i++)r[t[i]]=e[i];return r}exports.zipObject=zipObject;function assign(t){for(var e=[],r=1;r<arguments.length;r++)e[r-1]=arguments[r];for(var i=0;i<e.length;i++)for(var n=e[i],s=keys(n),o=0;o<s.length;o++){var a=s[o];t[a]=n[a]}return t}exports.assign=assign;function assignNoOverwrite(t){for(var e=[],r=1;r<arguments.length;r++)e[r-1]=arguments[r];for(var i=0;i<e.length;i++)for(var n=e[i],s=keys(n),o=0;o<s.length;o++){var a=s[o];has(t,a)||(t[a]=n[a])}return t}exports.assignNoOverwrite=assignNoOverwrite;function defaults(){for(var t=[],e=0;e<arguments.length;e++)t[e]=arguments[e];return assignNoOverwrite.apply(void 0,__spreadArray([{}],t,!1))}exports.defaults=defaults;function groupBy(t,e){var r={};return forEach(t,function(i){var n=e(i),s=r[n];s?s.push(i):r[n]=[i]}),r}exports.groupBy=groupBy;function merge(t,e){for(var r=cloneObj(t),i=keys(e),n=0;n<i.length;n++){var s=i[n],o=e[s];r[s]=o}return r}exports.merge=merge;function NOOP(){}exports.NOOP=NOOP;function IDENTITY(t){return t}exports.IDENTITY=IDENTITY;function packArray(t){for(var e=[],r=0;r<t.length;r++){var i=t[r];e.push(i!==void 0?i:void 0)}return e}exports.packArray=packArray;function PRINT_ERROR(t){console&&console.error&&console.error("Error: "+t)}exports.PRINT_ERROR=PRINT_ERROR;function PRINT_WARNING(t){console&&console.warn&&console.warn("Warning: "+t)}exports.PRINT_WARNING=PRINT_WARNING;function isES2015MapSupported(){return typeof Map=="function"}exports.isES2015MapSupported=isES2015MapSupported;function peek(t){return t[t.length-1]}exports.peek=peek;function timer(t){var e=new Date().getTime(),r=t(),i=new Date().getTime(),n=i-e;return{time:n,value:r}}exports.timer=timer;function toFastProperties(toBecomeFast){function FakeConstructor(){}FakeConstructor.prototype=toBecomeFast;var fakeInstance=new FakeConstructor;function fakeAccess(){return typeof fakeInstance.bar}return fakeAccess(),fakeAccess(),toBecomeFast;eval(toBecomeFast)}exports.toFastProperties=toFastProperties;function upperFirst(t){if(!t)return t;var e=getCharacterFromCodePointAt(t,0);return e.toUpperCase()+t.substring(e.length)}exports.upperFirst=upperFirst;var surrogatePairPattern=/[\uD800-\uDBFF][\uDC00-\uDFFF]/;function getCharacterFromCodePointAt(t,e){var r=t.substring(e,e+1);return surrogatePairPattern.test(r)?r:t[e]}});var UI=w((JH,MI)=>{(function(t,e){typeof define=="function"&&define.amd?define([],e):typeof MI=="object"&&MI.exports?MI.exports=e():t.regexpToAst=e()})(typeof self!="undefined"?self:JH,function(){function t(){}t.prototype.saveState=function(){return{idx:this.idx,input:this.input,groupIdx:this.groupIdx}},t.prototype.restoreState=function(p){this.idx=p.idx,this.input=p.input,this.groupIdx=p.groupIdx},t.prototype.pattern=function(p){this.idx=0,this.input=p,this.groupIdx=0,this.consumeChar("/");var m=this.disjunction();this.consumeChar("/");for(var y={type:"Flags",loc:{begin:this.idx,end:p.length},global:!1,ignoreCase:!1,multiLine:!1,unicode:!1,sticky:!1};this.isRegExpFlag();)switch(this.popChar()){case"g":o(y,"global");break;case"i":o(y,"ignoreCase");break;case"m":o(y,"multiLine");break;case"u":o(y,"unicode");break;case"y":o(y,"sticky");break}if(this.idx!==this.input.length)throw Error("Redundant input: "+this.input.substring(this.idx));return{type:"Pattern",flags:y,value:m,loc:this.loc(0)}},t.prototype.disjunction=function(){var p=[],m=this.idx;for(p.push(this.alternative());this.peekChar()==="|";)this.consumeChar("|"),p.push(this.alternative());return{type:"Disjunction",value:p,loc:this.loc(m)}},t.prototype.alternative=function(){for(var p=[],m=this.idx;this.isTerm();)p.push(this.term());return{type:"Alternative",value:p,loc:this.loc(m)}},t.prototype.term=function(){return this.isAssertion()?this.assertion():this.atom()},t.prototype.assertion=function(){var p=this.idx;switch(this.popChar()){case"^":return{type:"StartAnchor",loc:this.loc(p)};case"$":return{type:"EndAnchor",loc:this.loc(p)};case"\\":switch(this.popChar()){case"b":return{type:"WordBoundary",loc:this.loc(p)};case"B":return{type:"NonWordBoundary",loc:this.loc(p)}}throw Error("Invalid Assertion Escape");case"(":this.consumeChar("?");var m;switch(this.popChar()){case"=":m="Lookahead";break;case"!":m="NegativeLookahead";break}a(m);var y=this.disjunction();return this.consumeChar(")"),{type:m,value:y,loc:this.loc(p)}}l()},t.prototype.quantifier=function(p){var m,y=this.idx;switch(this.popChar()){case"*":m={atLeast:0,atMost:Infinity};break;case"+":m={atLeast:1,atMost:Infinity};break;case"?":m={atLeast:0,atMost:1};break;case"{":var Q=this.integerIncludingZero();switch(this.popChar()){case"}":m={atLeast:Q,atMost:Q};break;case",":var S;this.isDigit()?(S=this.integerIncludingZero(),m={atLeast:Q,atMost:S}):m={atLeast:Q,atMost:Infinity},this.consumeChar("}");break}if(p===!0&&m===void 0)return;a(m);break}if(!(p===!0&&m===void 0))return a(m),this.peekChar(0)==="?"?(this.consumeChar("?"),m.greedy=!1):m.greedy=!0,m.type="Quantifier",m.loc=this.loc(y),m},t.prototype.atom=function(){var p,m=this.idx;switch(this.peekChar()){case".":p=this.dotAll();break;case"\\":p=this.atomEscape();break;case"[":p=this.characterClass();break;case"(":p=this.group();break}return p===void 0&&this.isPatternCharacter()&&(p=this.patternCharacter()),a(p),p.loc=this.loc(m),this.isQuantifier()&&(p.quantifier=this.quantifier()),p},t.prototype.dotAll=function(){return this.consumeChar("."),{type:"Set",complement:!0,value:[n(`
+`),n("\r"),n("\u2028"),n("\u2029")]}},t.prototype.atomEscape=function(){switch(this.consumeChar("\\"),this.peekChar()){case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":return this.decimalEscapeAtom();case"d":case"D":case"s":case"S":case"w":case"W":return this.characterClassEscape();case"f":case"n":case"r":case"t":case"v":return this.controlEscapeAtom();case"c":return this.controlLetterEscapeAtom();case"0":return this.nulCharacterAtom();case"x":return this.hexEscapeSequenceAtom();case"u":return this.regExpUnicodeEscapeSequenceAtom();default:return this.identityEscapeAtom()}},t.prototype.decimalEscapeAtom=function(){var p=this.positiveInteger();return{type:"GroupBackReference",value:p}},t.prototype.characterClassEscape=function(){var p,m=!1;switch(this.popChar()){case"d":p=u;break;case"D":p=u,m=!0;break;case"s":p=f;break;case"S":p=f,m=!0;break;case"w":p=g;break;case"W":p=g,m=!0;break}return a(p),{type:"Set",value:p,complement:m}},t.prototype.controlEscapeAtom=function(){var p;switch(this.popChar()){case"f":p=n("\f");break;case"n":p=n(`
+`);break;case"r":p=n("\r");break;case"t":p=n(" ");break;case"v":p=n("\v");break}return a(p),{type:"Character",value:p}},t.prototype.controlLetterEscapeAtom=function(){this.consumeChar("c");var p=this.popChar();if(/[a-zA-Z]/.test(p)===!1)throw Error("Invalid ");var m=p.toUpperCase().charCodeAt(0)-64;return{type:"Character",value:m}},t.prototype.nulCharacterAtom=function(){return this.consumeChar("0"),{type:"Character",value:n("\0")}},t.prototype.hexEscapeSequenceAtom=function(){return this.consumeChar("x"),this.parseHexDigits(2)},t.prototype.regExpUnicodeEscapeSequenceAtom=function(){return this.consumeChar("u"),this.parseHexDigits(4)},t.prototype.identityEscapeAtom=function(){var p=this.popChar();return{type:"Character",value:n(p)}},t.prototype.classPatternCharacterAtom=function(){switch(this.peekChar()){case`
+`:case"\r":case"\u2028":case"\u2029":case"\\":case"]":throw Error("TBD");default:var p=this.popChar();return{type:"Character",value:n(p)}}},t.prototype.characterClass=function(){var p=[],m=!1;for(this.consumeChar("["),this.peekChar(0)==="^"&&(this.consumeChar("^"),m=!0);this.isClassAtom();){var y=this.classAtom(),Q=y.type==="Character";if(Q&&this.isRangeDash()){this.consumeChar("-");var S=this.classAtom(),x=S.type==="Character";if(x){if(S.value<y.value)throw Error("Range out of order in character class");p.push({from:y.value,to:S.value})}else s(y.value,p),p.push(n("-")),s(S.value,p)}else s(y.value,p)}return this.consumeChar("]"),{type:"Set",complement:m,value:p}},t.prototype.classAtom=function(){switch(this.peekChar()){case"]":case`
+`:case"\r":case"\u2028":case"\u2029":throw Error("TBD");case"\\":return this.classEscape();default:return this.classPatternCharacterAtom()}},t.prototype.classEscape=function(){switch(this.consumeChar("\\"),this.peekChar()){case"b":return this.consumeChar("b"),{type:"Character",value:n("\b")};case"d":case"D":case"s":case"S":case"w":case"W":return this.characterClassEscape();case"f":case"n":case"r":case"t":case"v":return this.controlEscapeAtom();case"c":return this.controlLetterEscapeAtom();case"0":return this.nulCharacterAtom();case"x":return this.hexEscapeSequenceAtom();case"u":return this.regExpUnicodeEscapeSequenceAtom();default:return this.identityEscapeAtom()}},t.prototype.group=function(){var p=!0;switch(this.consumeChar("("),this.peekChar(0)){case"?":this.consumeChar("?"),this.consumeChar(":"),p=!1;break;default:this.groupIdx++;break}var m=this.disjunction();this.consumeChar(")");var y={type:"Group",capturing:p,value:m};return p&&(y.idx=this.groupIdx),y},t.prototype.positiveInteger=function(){var p=this.popChar();if(i.test(p)===!1)throw Error("Expecting a positive integer");for(;r.test(this.peekChar(0));)p+=this.popChar();return parseInt(p,10)},t.prototype.integerIncludingZero=function(){var p=this.popChar();if(r.test(p)===!1)throw Error("Expecting an integer");for(;r.test(this.peekChar(0));)p+=this.popChar();return parseInt(p,10)},t.prototype.patternCharacter=function(){var p=this.popChar();switch(p){case`
+`:case"\r":case"\u2028":case"\u2029":case"^":case"$":case"\\":case".":case"*":case"+":case"?":case"(":case")":case"[":case"|":throw Error("TBD");default:return{type:"Character",value:n(p)}}},t.prototype.isRegExpFlag=function(){switch(this.peekChar(0)){case"g":case"i":case"m":case"u":case"y":return!0;default:return!1}},t.prototype.isRangeDash=function(){return this.peekChar()==="-"&&this.isClassAtom(1)},t.prototype.isDigit=function(){return r.test(this.peekChar(0))},t.prototype.isClassAtom=function(p){switch(p===void 0&&(p=0),this.peekChar(p)){case"]":case`
+`:case"\r":case"\u2028":case"\u2029":return!1;default:return!0}},t.prototype.isTerm=function(){return this.isAtom()||this.isAssertion()},t.prototype.isAtom=function(){if(this.isPatternCharacter())return!0;switch(this.peekChar(0)){case".":case"\\":case"[":case"(":return!0;default:return!1}},t.prototype.isAssertion=function(){switch(this.peekChar(0)){case"^":case"$":return!0;case"\\":switch(this.peekChar(1)){case"b":case"B":return!0;default:return!1}case"(":return this.peekChar(1)==="?"&&(this.peekChar(2)==="="||this.peekChar(2)==="!");default:return!1}},t.prototype.isQuantifier=function(){var p=this.saveState();try{return this.quantifier(!0)!==void 0}catch(m){return!1}finally{this.restoreState(p)}},t.prototype.isPatternCharacter=function(){switch(this.peekChar()){case"^":case"$":case"\\":case".":case"*":case"+":case"?":case"(":case")":case"[":case"|":case"/":case`
+`:case"\r":case"\u2028":case"\u2029":return!1;default:return!0}},t.prototype.parseHexDigits=function(p){for(var m="",y=0;y<p;y++){var Q=this.popChar();if(e.test(Q)===!1)throw Error("Expecting a HexDecimal digits");m+=Q}var S=parseInt(m,16);return{type:"Character",value:S}},t.prototype.peekChar=function(p){return p===void 0&&(p=0),this.input[this.idx+p]},t.prototype.popChar=function(){var p=this.peekChar(0);return this.consumeChar(),p},t.prototype.consumeChar=function(p){if(p!==void 0&&this.input[this.idx]!==p)throw Error("Expected: '"+p+"' but found: '"+this.input[this.idx]+"' at offset: "+this.idx);if(this.idx>=this.input.length)throw Error("Unexpected end of input");this.idx++},t.prototype.loc=function(p){return{begin:p,end:this.idx}};var e=/[0-9a-fA-F]/,r=/[0-9]/,i=/[1-9]/;function n(p){return p.charCodeAt(0)}function s(p,m){p.length!==void 0?p.forEach(function(y){m.push(y)}):m.push(p)}function o(p,m){if(p[m]===!0)throw"duplicate flag "+m;p[m]=!0}function a(p){if(p===void 0)throw Error("Internal Error - Should never get here!")}function l(){throw Error("Internal Error - Should never get here!")}var c,u=[];for(c=n("0");c<=n("9");c++)u.push(c);var g=[n("_")].concat(u);for(c=n("a");c<=n("z");c++)g.push(c);for(c=n("A");c<=n("Z");c++)g.push(c);var f=[n(" "),n("\f"),n(`
+`),n("\r"),n(" "),n("\v"),n("  "),n("\xA0"),n("\u1680"),n("\u2000"),n("\u2001"),n("\u2002"),n("\u2003"),n("\u2004"),n("\u2005"),n("\u2006"),n("\u2007"),n("\u2008"),n("\u2009"),n("\u200A"),n("\u2028"),n("\u2029"),n("\u202F"),n("\u205F"),n("\u3000"),n("\uFEFF")];function h(){}return h.prototype.visitChildren=function(p){for(var m in p){var y=p[m];p.hasOwnProperty(m)&&(y.type!==void 0?this.visit(y):Array.isArray(y)&&y.forEach(function(Q){this.visit(Q)},this))}},h.prototype.visit=function(p){switch(p.type){case"Pattern":this.visitPattern(p);break;case"Flags":this.visitFlags(p);break;case"Disjunction":this.visitDisjunction(p);break;case"Alternative":this.visitAlternative(p);break;case"StartAnchor":this.visitStartAnchor(p);break;case"EndAnchor":this.visitEndAnchor(p);break;case"WordBoundary":this.visitWordBoundary(p);break;case"NonWordBoundary":this.visitNonWordBoundary(p);break;case"Lookahead":this.visitLookahead(p);break;case"NegativeLookahead":this.visitNegativeLookahead(p);break;case"Character":this.visitCharacter(p);break;case"Set":this.visitSet(p);break;case"Group":this.visitGroup(p);break;case"GroupBackReference":this.visitGroupBackReference(p);break;case"Quantifier":this.visitQuantifier(p);break}this.visitChildren(p)},h.prototype.visitPattern=function(p){},h.prototype.visitFlags=function(p){},h.prototype.visitDisjunction=function(p){},h.prototype.visitAlternative=function(p){},h.prototype.visitStartAnchor=function(p){},h.prototype.visitEndAnchor=function(p){},h.prototype.visitWordBoundary=function(p){},h.prototype.visitNonWordBoundary=function(p){},h.prototype.visitLookahead=function(p){},h.prototype.visitNegativeLookahead=function(p){},h.prototype.visitCharacter=function(p){},h.prototype.visitSet=function(p){},h.prototype.visitGroup=function(p){},h.prototype.visitGroupBackReference=function(p){},h.prototype.visitQuantifier=function(p){},{RegExpParser:t,BaseRegExpVisitor:h,VERSION:"0.5.0"}})});var HI=w(ug=>{"use strict";Object.defineProperty(ug,"__esModule",{value:!0});ug.clearRegExpParserCache=ug.getRegExpAst=void 0;var VEe=UI(),KI={},XEe=new VEe.RegExpParser;function ZEe(t){var e=t.toString();if(KI.hasOwnProperty(e))return KI[e];var r=XEe.pattern(e);return KI[e]=r,r}ug.getRegExpAst=ZEe;function $Ee(){KI={}}ug.clearRegExpParserCache=$Ee});var XH=w(Bn=>{"use strict";var eIe=Bn&&Bn.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(Bn,"__esModule",{value:!0});Bn.canMatchCharCode=Bn.firstCharOptimizedIndices=Bn.getOptimizedStartCodesIndices=Bn.failedOptimizationPrefixMsg=void 0;var WH=UI(),bs=Yt(),zH=HI(),Ma=Pv(),_H="Complement Sets are not supported for first char optimization";Bn.failedOptimizationPrefixMsg=`Unable to use "first char" lexer optimizations:
+`;function tIe(t,e){e===void 0&&(e=!1);try{var r=(0,zH.getRegExpAst)(t),i=jI(r.value,{},r.flags.ignoreCase);return i}catch(s){if(s.message===_H)e&&(0,bs.PRINT_WARNING)(""+Bn.failedOptimizationPrefixMsg+("   Unable to optimize: < "+t.toString()+` >
+`)+`   Complement Sets cannot be automatically optimized.
+       This will disable the lexer's first char optimizations.
+       See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#COMPLEMENT for details.`);else{var n="";e&&(n=`
+       This will disable the lexer's first char optimizations.
+       See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#REGEXP_PARSING for details.`),(0,bs.PRINT_ERROR)(Bn.failedOptimizationPrefixMsg+`
+`+("   Failed parsing: < "+t.toString()+` >
+`)+("  Using the regexp-to-ast library version: "+WH.VERSION+`
+`)+"   Please open an issue at: https://github.com/bd82/regexp-to-ast/issues"+n)}}return[]}Bn.getOptimizedStartCodesIndices=tIe;function jI(t,e,r){switch(t.type){case"Disjunction":for(var i=0;i<t.value.length;i++)jI(t.value[i],e,r);break;case"Alternative":for(var n=t.value,i=0;i<n.length;i++){var s=n[i];switch(s.type){case"EndAnchor":case"GroupBackReference":case"Lookahead":case"NegativeLookahead":case"StartAnchor":case"WordBoundary":case"NonWordBoundary":continue}var o=s;switch(o.type){case"Character":GI(o.value,e,r);break;case"Set":if(o.complement===!0)throw Error(_H);(0,bs.forEach)(o.value,function(c){if(typeof c=="number")GI(c,e,r);else{var u=c;if(r===!0)for(var g=u.from;g<=u.to;g++)GI(g,e,r);else{for(var g=u.from;g<=u.to&&g<Ma.minOptimizationVal;g++)GI(g,e,r);if(u.to>=Ma.minOptimizationVal)for(var f=u.from>=Ma.minOptimizationVal?u.from:Ma.minOptimizationVal,h=u.to,p=(0,Ma.charCodeToOptimizedIndex)(f),m=(0,Ma.charCodeToOptimizedIndex)(h),y=p;y<=m;y++)e[y]=y}}});break;case"Group":jI(o.value,e,r);break;default:throw Error("Non Exhaustive Match")}var a=o.quantifier!==void 0&&o.quantifier.atLeast===0;if(o.type==="Group"&&Dv(o)===!1||o.type!=="Group"&&a===!1)break}break;default:throw Error("non exhaustive match!")}return(0,bs.values)(e)}Bn.firstCharOptimizedIndices=jI;function GI(t,e,r){var i=(0,Ma.charCodeToOptimizedIndex)(t);e[i]=i,r===!0&&rIe(t,e)}function rIe(t,e){var r=String.fromCharCode(t),i=r.toUpperCase();if(i!==r){var n=(0,Ma.charCodeToOptimizedIndex)(i.charCodeAt(0));e[n]=n}else{var s=r.toLowerCase();if(s!==r){var n=(0,Ma.charCodeToOptimizedIndex)(s.charCodeAt(0));e[n]=n}}}function VH(t,e){return(0,bs.find)(t.value,function(r){if(typeof r=="number")return(0,bs.contains)(e,r);var i=r;return(0,bs.find)(e,function(n){return i.from<=n&&n<=i.to})!==void 0})}function Dv(t){return t.quantifier&&t.quantifier.atLeast===0?!0:t.value?(0,bs.isArray)(t.value)?(0,bs.every)(t.value,Dv):Dv(t.value):!1}var iIe=function(t){eIe(e,t);function e(r){var i=t.call(this)||this;return i.targetCharCodes=r,i.found=!1,i}return e.prototype.visitChildren=function(r){if(this.found!==!0){switch(r.type){case"Lookahead":this.visitLookahead(r);return;case"NegativeLookahead":this.visitNegativeLookahead(r);return}t.prototype.visitChildren.call(this,r)}},e.prototype.visitCharacter=function(r){(0,bs.contains)(this.targetCharCodes,r.value)&&(this.found=!0)},e.prototype.visitSet=function(r){r.complement?VH(r,this.targetCharCodes)===void 0&&(this.found=!0):VH(r,this.targetCharCodes)!==void 0&&(this.found=!0)},e}(WH.BaseRegExpVisitor);function nIe(t,e){if(e instanceof RegExp){var r=(0,zH.getRegExpAst)(e),i=new iIe(t);return i.visit(r),i.found}else return(0,bs.find)(e,function(n){return(0,bs.contains)(t,n.charCodeAt(0))})!==void 0}Bn.canMatchCharCode=nIe});var Pv=w(Ze=>{"use strict";var ZH=Ze&&Ze.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(Ze,"__esModule",{value:!0});Ze.charCodeToOptimizedIndex=Ze.minOptimizationVal=Ze.buildLineBreakIssueMessage=Ze.LineTerminatorOptimizedTester=Ze.isShortPattern=Ze.isCustomPattern=Ze.cloneEmptyGroups=Ze.performWarningRuntimeChecks=Ze.performRuntimeChecks=Ze.addStickyFlag=Ze.addStartOfInput=Ze.findUnreachablePatterns=Ze.findModesThatDoNotExist=Ze.findInvalidGroupType=Ze.findDuplicatePatterns=Ze.findUnsupportedFlags=Ze.findStartOfInputAnchor=Ze.findEmptyMatchRegExps=Ze.findEndOfInputAnchor=Ze.findInvalidPatterns=Ze.findMissingPatterns=Ze.validatePatterns=Ze.analyzeTokenTypes=Ze.enableSticky=Ze.disableSticky=Ze.SUPPORT_STICKY=Ze.MODES=Ze.DEFAULT_MODE=void 0;var $H=UI(),Ar=Dp(),Ne=Yt(),gg=XH(),ej=HI(),Lo="PATTERN";Ze.DEFAULT_MODE="defaultMode";Ze.MODES="modes";Ze.SUPPORT_STICKY=typeof new RegExp("(?:)").sticky=="boolean";function sIe(){Ze.SUPPORT_STICKY=!1}Ze.disableSticky=sIe;function oIe(){Ze.SUPPORT_STICKY=!0}Ze.enableSticky=oIe;function AIe(t,e){e=(0,Ne.defaults)(e,{useSticky:Ze.SUPPORT_STICKY,debug:!1,safeMode:!1,positionTracking:"full",lineTerminatorCharacters:["\r",`
+`],tracer:function(S,x){return x()}});var r=e.tracer;r("initCharCodeToOptimizedIndexMap",function(){aIe()});var i;r("Reject Lexer.NA",function(){i=(0,Ne.reject)(t,function(S){return S[Lo]===Ar.Lexer.NA})});var n=!1,s;r("Transform Patterns",function(){n=!1,s=(0,Ne.map)(i,function(S){var x=S[Lo];if((0,Ne.isRegExp)(x)){var M=x.source;return M.length===1&&M!=="^"&&M!=="$"&&M!=="."&&!x.ignoreCase?M:M.length===2&&M[0]==="\\"&&!(0,Ne.contains)(["d","D","s","S","t","r","n","t","0","c","b","B","f","v","w","W"],M[1])?M[1]:e.useSticky?Fv(x):Rv(x)}else{if((0,Ne.isFunction)(x))return n=!0,{exec:x};if((0,Ne.has)(x,"exec"))return n=!0,x;if(typeof x=="string"){if(x.length===1)return x;var Y=x.replace(/[\\^$.*+?()[\]{}|]/g,"\\$&"),U=new RegExp(Y);return e.useSticky?Fv(U):Rv(U)}else throw Error("non exhaustive match")}})});var o,a,l,c,u;r("misc mapping",function(){o=(0,Ne.map)(i,function(S){return S.tokenTypeIdx}),a=(0,Ne.map)(i,function(S){var x=S.GROUP;if(x!==Ar.Lexer.SKIPPED){if((0,Ne.isString)(x))return x;if((0,Ne.isUndefined)(x))return!1;throw Error("non exhaustive match")}}),l=(0,Ne.map)(i,function(S){var x=S.LONGER_ALT;if(x){var M=(0,Ne.isArray)(x)?(0,Ne.map)(x,function(Y){return(0,Ne.indexOf)(i,Y)}):[(0,Ne.indexOf)(i,x)];return M}}),c=(0,Ne.map)(i,function(S){return S.PUSH_MODE}),u=(0,Ne.map)(i,function(S){return(0,Ne.has)(S,"POP_MODE")})});var g;r("Line Terminator Handling",function(){var S=ij(e.lineTerminatorCharacters);g=(0,Ne.map)(i,function(x){return!1}),e.positionTracking!=="onlyOffset"&&(g=(0,Ne.map)(i,function(x){if((0,Ne.has)(x,"LINE_BREAKS"))return x.LINE_BREAKS;if(rj(x,S)===!1)return(0,gg.canMatchCharCode)(S,x.PATTERN)}))});var f,h,p,m;r("Misc Mapping #2",function(){f=(0,Ne.map)(i,Nv),h=(0,Ne.map)(s,tj),p=(0,Ne.reduce)(i,function(S,x){var M=x.GROUP;return(0,Ne.isString)(M)&&M!==Ar.Lexer.SKIPPED&&(S[M]=[]),S},{}),m=(0,Ne.map)(s,function(S,x){return{pattern:s[x],longerAlt:l[x],canLineTerminator:g[x],isCustom:f[x],short:h[x],group:a[x],push:c[x],pop:u[x],tokenTypeIdx:o[x],tokenType:i[x]}})});var y=!0,Q=[];return e.safeMode||r("First Char Optimization",function(){Q=(0,Ne.reduce)(i,function(S,x,M){if(typeof x.PATTERN=="string"){var Y=x.PATTERN.charCodeAt(0),U=Tv(Y);Lv(S,U,m[M])}else if((0,Ne.isArray)(x.START_CHARS_HINT)){var J;(0,Ne.forEach)(x.START_CHARS_HINT,function(ee){var Z=typeof ee=="string"?ee.charCodeAt(0):ee,A=Tv(Z);J!==A&&(J=A,Lv(S,A,m[M]))})}else if((0,Ne.isRegExp)(x.PATTERN))if(x.PATTERN.unicode)y=!1,e.ensureOptimizations&&(0,Ne.PRINT_ERROR)(""+gg.failedOptimizationPrefixMsg+("    Unable to analyze < "+x.PATTERN.toString()+` > pattern.
+`)+`   The regexp unicode flag is not currently supported by the regexp-to-ast library.
+       This will disable the lexer's first char optimizations.
+       For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNICODE_OPTIMIZE`);else{var W=(0,gg.getOptimizedStartCodesIndices)(x.PATTERN,e.ensureOptimizations);(0,Ne.isEmpty)(W)&&(y=!1),(0,Ne.forEach)(W,function(ee){Lv(S,ee,m[M])})}else e.ensureOptimizations&&(0,Ne.PRINT_ERROR)(""+gg.failedOptimizationPrefixMsg+("   TokenType: <"+x.name+`> is using a custom token pattern without providing <start_chars_hint> parameter.
+`)+`   This will disable the lexer's first char optimizations.
+       For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_OPTIMIZE`),y=!1;return S},[])}),r("ArrayPacking",function(){Q=(0,Ne.packArray)(Q)}),{emptyGroups:p,patternIdxToConfig:m,charCodeToPatternIdxToConfig:Q,hasCustom:n,canBeOptimized:y}}Ze.analyzeTokenTypes=AIe;function cIe(t,e){var r=[],i=nj(t);r=r.concat(i.errors);var n=sj(i.valid),s=n.valid;return r=r.concat(n.errors),r=r.concat(lIe(s)),r=r.concat(oj(s)),r=r.concat(aj(s,e)),r=r.concat(Aj(s)),r}Ze.validatePatterns=cIe;function lIe(t){var e=[],r=(0,Ne.filter)(t,function(i){return(0,Ne.isRegExp)(i[Lo])});return e=e.concat(lj(r)),e=e.concat(uj(r)),e=e.concat(gj(r)),e=e.concat(fj(r)),e=e.concat(cj(r)),e}function nj(t){var e=(0,Ne.filter)(t,function(n){return!(0,Ne.has)(n,Lo)}),r=(0,Ne.map)(e,function(n){return{message:"Token Type: ->"+n.name+"<- missing static 'PATTERN' property",type:Ar.LexerDefinitionErrorType.MISSING_PATTERN,tokenTypes:[n]}}),i=(0,Ne.difference)(t,e);return{errors:r,valid:i}}Ze.findMissingPatterns=nj;function sj(t){var e=(0,Ne.filter)(t,function(n){var s=n[Lo];return!(0,Ne.isRegExp)(s)&&!(0,Ne.isFunction)(s)&&!(0,Ne.has)(s,"exec")&&!(0,Ne.isString)(s)}),r=(0,Ne.map)(e,function(n){return{message:"Token Type: ->"+n.name+"<- static 'PATTERN' can only be a RegExp, a Function matching the {CustomPatternMatcherFunc} type or an Object matching the {ICustomPattern} interface.",type:Ar.LexerDefinitionErrorType.INVALID_PATTERN,tokenTypes:[n]}}),i=(0,Ne.difference)(t,e);return{errors:r,valid:i}}Ze.findInvalidPatterns=sj;var uIe=/[^\\][\$]/;function lj(t){var e=function(n){ZH(s,n);function s(){var o=n!==null&&n.apply(this,arguments)||this;return o.found=!1,o}return s.prototype.visitEndAnchor=function(o){this.found=!0},s}($H.BaseRegExpVisitor),r=(0,Ne.filter)(t,function(n){var s=n[Lo];try{var o=(0,ej.getRegExpAst)(s),a=new e;return a.visit(o),a.found}catch(l){return uIe.test(s.source)}}),i=(0,Ne.map)(r,function(n){return{message:`Unexpected RegExp Anchor Error:
+       Token Type: ->`+n.name+`<- static 'PATTERN' cannot contain end of input anchor '$'
+       See chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS        for details.`,type:Ar.LexerDefinitionErrorType.EOI_ANCHOR_FOUND,tokenTypes:[n]}});return i}Ze.findEndOfInputAnchor=lj;function cj(t){var e=(0,Ne.filter)(t,function(i){var n=i[Lo];return n.test("")}),r=(0,Ne.map)(e,function(i){return{message:"Token Type: ->"+i.name+"<- static 'PATTERN' must not match an empty string",type:Ar.LexerDefinitionErrorType.EMPTY_MATCH_PATTERN,tokenTypes:[i]}});return r}Ze.findEmptyMatchRegExps=cj;var gIe=/[^\\[][\^]|^\^/;function uj(t){var e=function(n){ZH(s,n);function s(){var o=n!==null&&n.apply(this,arguments)||this;return o.found=!1,o}return s.prototype.visitStartAnchor=function(o){this.found=!0},s}($H.BaseRegExpVisitor),r=(0,Ne.filter)(t,function(n){var s=n[Lo];try{var o=(0,ej.getRegExpAst)(s),a=new e;return a.visit(o),a.found}catch(l){return gIe.test(s.source)}}),i=(0,Ne.map)(r,function(n){return{message:`Unexpected RegExp Anchor Error:
+       Token Type: ->`+n.name+`<- static 'PATTERN' cannot contain start of input anchor '^'
+       See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS        for details.`,type:Ar.LexerDefinitionErrorType.SOI_ANCHOR_FOUND,tokenTypes:[n]}});return i}Ze.findStartOfInputAnchor=uj;function gj(t){var e=(0,Ne.filter)(t,function(i){var n=i[Lo];return n instanceof RegExp&&(n.multiline||n.global)}),r=(0,Ne.map)(e,function(i){return{message:"Token Type: ->"+i.name+"<- static 'PATTERN' may NOT contain global('g') or multiline('m')",type:Ar.LexerDefinitionErrorType.UNSUPPORTED_FLAGS_FOUND,tokenTypes:[i]}});return r}Ze.findUnsupportedFlags=gj;function fj(t){var e=[],r=(0,Ne.map)(t,function(s){return(0,Ne.reduce)(t,function(o,a){return s.PATTERN.source===a.PATTERN.source&&!(0,Ne.contains)(e,a)&&a.PATTERN!==Ar.Lexer.NA&&(e.push(a),o.push(a)),o},[])});r=(0,Ne.compact)(r);var i=(0,Ne.filter)(r,function(s){return s.length>1}),n=(0,Ne.map)(i,function(s){var o=(0,Ne.map)(s,function(l){return l.name}),a=(0,Ne.first)(s).PATTERN;return{message:"The same RegExp pattern ->"+a+"<-"+("has been used in all of the following Token Types: "+o.join(", ")+" <-"),type:Ar.LexerDefinitionErrorType.DUPLICATE_PATTERNS_FOUND,tokenTypes:s}});return n}Ze.findDuplicatePatterns=fj;function oj(t){var e=(0,Ne.filter)(t,function(i){if(!(0,Ne.has)(i,"GROUP"))return!1;var n=i.GROUP;return n!==Ar.Lexer.SKIPPED&&n!==Ar.Lexer.NA&&!(0,Ne.isString)(n)}),r=(0,Ne.map)(e,function(i){return{message:"Token Type: ->"+i.name+"<- static 'GROUP' can only be Lexer.SKIPPED/Lexer.NA/A String",type:Ar.LexerDefinitionErrorType.INVALID_GROUP_TYPE_FOUND,tokenTypes:[i]}});return r}Ze.findInvalidGroupType=oj;function aj(t,e){var r=(0,Ne.filter)(t,function(n){return n.PUSH_MODE!==void 0&&!(0,Ne.contains)(e,n.PUSH_MODE)}),i=(0,Ne.map)(r,function(n){var s="Token Type: ->"+n.name+"<- static 'PUSH_MODE' value cannot refer to a Lexer Mode ->"+n.PUSH_MODE+"<-which does not exist";return{message:s,type:Ar.LexerDefinitionErrorType.PUSH_MODE_DOES_NOT_EXIST,tokenTypes:[n]}});return i}Ze.findModesThatDoNotExist=aj;function Aj(t){var e=[],r=(0,Ne.reduce)(t,function(i,n,s){var o=n.PATTERN;return o===Ar.Lexer.NA||((0,Ne.isString)(o)?i.push({str:o,idx:s,tokenType:n}):(0,Ne.isRegExp)(o)&&hIe(o)&&i.push({str:o.source,idx:s,tokenType:n})),i},[]);return(0,Ne.forEach)(t,function(i,n){(0,Ne.forEach)(r,function(s){var o=s.str,a=s.idx,l=s.tokenType;if(n<a&&fIe(o,i.PATTERN)){var c="Token: ->"+l.name+`<- can never be matched.
+`+("Because it appears AFTER the Token Type ->"+i.name+"<-")+`in the lexer's definition.
+See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNREACHABLE`;e.push({message:c,type:Ar.LexerDefinitionErrorType.UNREACHABLE_PATTERN,tokenTypes:[i,l]})}})}),e}Ze.findUnreachablePatterns=Aj;function fIe(t,e){if((0,Ne.isRegExp)(e)){var r=e.exec(t);return r!==null&&r.index===0}else{if((0,Ne.isFunction)(e))return e(t,0,[],{});if((0,Ne.has)(e,"exec"))return e.exec(t,0,[],{});if(typeof e=="string")return e===t;throw Error("non exhaustive match")}}function hIe(t){var e=[".","\\","[","]","|","^","$","(",")","?","*","+","{"];return(0,Ne.find)(e,function(r){return t.source.indexOf(r)!==-1})===void 0}function Rv(t){var e=t.ignoreCase?"i":"";return new RegExp("^(?:"+t.source+")",e)}Ze.addStartOfInput=Rv;function Fv(t){var e=t.ignoreCase?"iy":"y";return new RegExp(""+t.source,e)}Ze.addStickyFlag=Fv;function pIe(t,e,r){var i=[];return(0,Ne.has)(t,Ze.DEFAULT_MODE)||i.push({message:"A MultiMode Lexer cannot be initialized without a <"+Ze.DEFAULT_MODE+`> property in its definition
+`,type:Ar.LexerDefinitionErrorType.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE}),(0,Ne.has)(t,Ze.MODES)||i.push({message:"A MultiMode Lexer cannot be initialized without a <"+Ze.MODES+`> property in its definition
+`,type:Ar.LexerDefinitionErrorType.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY}),(0,Ne.has)(t,Ze.MODES)&&(0,Ne.has)(t,Ze.DEFAULT_MODE)&&!(0,Ne.has)(t.modes,t.defaultMode)&&i.push({message:"A MultiMode Lexer cannot be initialized with a "+Ze.DEFAULT_MODE+": <"+t.defaultMode+`>which does not exist
+`,type:Ar.LexerDefinitionErrorType.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST}),(0,Ne.has)(t,Ze.MODES)&&(0,Ne.forEach)(t.modes,function(n,s){(0,Ne.forEach)(n,function(o,a){(0,Ne.isUndefined)(o)&&i.push({message:"A Lexer cannot be initialized using an undefined Token Type. Mode:"+("<"+s+"> at index: <"+a+`>
+`),type:Ar.LexerDefinitionErrorType.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED})})}),i}Ze.performRuntimeChecks=pIe;function dIe(t,e,r){var i=[],n=!1,s=(0,Ne.compact)((0,Ne.flatten)((0,Ne.mapValues)(t.modes,function(l){return l}))),o=(0,Ne.reject)(s,function(l){return l[Lo]===Ar.Lexer.NA}),a=ij(r);return e&&(0,Ne.forEach)(o,function(l){var c=rj(l,a);if(c!==!1){var u=hj(l,c),g={message:u,type:c.issue,tokenType:l};i.push(g)}else(0,Ne.has)(l,"LINE_BREAKS")?l.LINE_BREAKS===!0&&(n=!0):(0,gg.canMatchCharCode)(a,l.PATTERN)&&(n=!0)}),e&&!n&&i.push({message:`Warning: No LINE_BREAKS Found.
+       This Lexer has been defined to track line and column information,
+       But none of the Token Types can be identified as matching a line terminator.
+       See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#LINE_BREAKS 
+       for details.`,type:Ar.LexerDefinitionErrorType.NO_LINE_BREAKS_FLAGS}),i}Ze.performWarningRuntimeChecks=dIe;function CIe(t){var e={},r=(0,Ne.keys)(t);return(0,Ne.forEach)(r,function(i){var n=t[i];if((0,Ne.isArray)(n))e[i]=[];else throw Error("non exhaustive match")}),e}Ze.cloneEmptyGroups=CIe;function Nv(t){var e=t.PATTERN;if((0,Ne.isRegExp)(e))return!1;if((0,Ne.isFunction)(e))return!0;if((0,Ne.has)(e,"exec"))return!0;if((0,Ne.isString)(e))return!1;throw Error("non exhaustive match")}Ze.isCustomPattern=Nv;function tj(t){return(0,Ne.isString)(t)&&t.length===1?t.charCodeAt(0):!1}Ze.isShortPattern=tj;Ze.LineTerminatorOptimizedTester={test:function(t){for(var e=t.length,r=this.lastIndex;r<e;r++){var i=t.charCodeAt(r);if(i===10)return this.lastIndex=r+1,!0;if(i===13)return t.charCodeAt(r+1)===10?this.lastIndex=r+2:this.lastIndex=r+1,!0}return!1},lastIndex:0};function rj(t,e){if((0,Ne.has)(t,"LINE_BREAKS"))return!1;if((0,Ne.isRegExp)(t.PATTERN)){try{(0,gg.canMatchCharCode)(e,t.PATTERN)}catch(r){return{issue:Ar.LexerDefinitionErrorType.IDENTIFY_TERMINATOR,errMsg:r.message}}return!1}else{if((0,Ne.isString)(t.PATTERN))return!1;if(Nv(t))return{issue:Ar.LexerDefinitionErrorType.CUSTOM_LINE_BREAK};throw Error("non exhaustive match")}}function hj(t,e){if(e.issue===Ar.LexerDefinitionErrorType.IDENTIFY_TERMINATOR)return`Warning: unable to identify line terminator usage in pattern.
+`+("   The problem is in the <"+t.name+`> Token Type
+`)+("   Root cause: "+e.errMsg+`.
+`)+"   For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#IDENTIFY_TERMINATOR";if(e.issue===Ar.LexerDefinitionErrorType.CUSTOM_LINE_BREAK)return`Warning: A Custom Token Pattern should specify the <line_breaks> option.
+`+("   The problem is in the <"+t.name+`> Token Type
+`)+"   For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_LINE_BREAK";throw Error("non exhaustive match")}Ze.buildLineBreakIssueMessage=hj;function ij(t){var e=(0,Ne.map)(t,function(r){return(0,Ne.isString)(r)&&r.length>0?r.charCodeAt(0):r});return e}function Lv(t,e,r){t[e]===void 0?t[e]=[r]:t[e].push(r)}Ze.minOptimizationVal=256;var YI=[];function Tv(t){return t<Ze.minOptimizationVal?t:YI[t]}Ze.charCodeToOptimizedIndex=Tv;function aIe(){if((0,Ne.isEmpty)(YI)){YI=new Array(65536);for(var t=0;t<65536;t++)YI[t]=t>255?255+~~(t/255):t}}});var fg=w(Ft=>{"use strict";Object.defineProperty(Ft,"__esModule",{value:!0});Ft.isTokenType=Ft.hasExtendingTokensTypesMapProperty=Ft.hasExtendingTokensTypesProperty=Ft.hasCategoriesProperty=Ft.hasShortKeyProperty=Ft.singleAssignCategoriesToksMap=Ft.assignCategoriesMapProp=Ft.assignCategoriesTokensProp=Ft.assignTokenDefaultProps=Ft.expandCategories=Ft.augmentTokenTypes=Ft.tokenIdxToClass=Ft.tokenShortNameIdx=Ft.tokenStructuredMatcherNoCategories=Ft.tokenStructuredMatcher=void 0;var ri=Yt();function mIe(t,e){var r=t.tokenTypeIdx;return r===e.tokenTypeIdx?!0:e.isParent===!0&&e.categoryMatchesMap[r]===!0}Ft.tokenStructuredMatcher=mIe;function EIe(t,e){return t.tokenTypeIdx===e.tokenTypeIdx}Ft.tokenStructuredMatcherNoCategories=EIe;Ft.tokenShortNameIdx=1;Ft.tokenIdxToClass={};function IIe(t){var e=pj(t);dj(e),mj(e),Cj(e),(0,ri.forEach)(e,function(r){r.isParent=r.categoryMatches.length>0})}Ft.augmentTokenTypes=IIe;function pj(t){for(var e=(0,ri.cloneArr)(t),r=t,i=!0;i;){r=(0,ri.compact)((0,ri.flatten)((0,ri.map)(r,function(s){return s.CATEGORIES})));var n=(0,ri.difference)(r,e);e=e.concat(n),(0,ri.isEmpty)(n)?i=!1:r=n}return e}Ft.expandCategories=pj;function dj(t){(0,ri.forEach)(t,function(e){Ej(e)||(Ft.tokenIdxToClass[Ft.tokenShortNameIdx]=e,e.tokenTypeIdx=Ft.tokenShortNameIdx++),Ov(e)&&!(0,ri.isArray)(e.CATEGORIES)&&(e.CATEGORIES=[e.CATEGORIES]),Ov(e)||(e.CATEGORIES=[]),Ij(e)||(e.categoryMatches=[]),yj(e)||(e.categoryMatchesMap={})})}Ft.assignTokenDefaultProps=dj;function Cj(t){(0,ri.forEach)(t,function(e){e.categoryMatches=[],(0,ri.forEach)(e.categoryMatchesMap,function(r,i){e.categoryMatches.push(Ft.tokenIdxToClass[i].tokenTypeIdx)})})}Ft.assignCategoriesTokensProp=Cj;function mj(t){(0,ri.forEach)(t,function(e){Mv([],e)})}Ft.assignCategoriesMapProp=mj;function Mv(t,e){(0,ri.forEach)(t,function(r){e.categoryMatchesMap[r.tokenTypeIdx]=!0}),(0,ri.forEach)(e.CATEGORIES,function(r){var i=t.concat(e);(0,ri.contains)(i,r)||Mv(i,r)})}Ft.singleAssignCategoriesToksMap=Mv;function Ej(t){return(0,ri.has)(t,"tokenTypeIdx")}Ft.hasShortKeyProperty=Ej;function Ov(t){return(0,ri.has)(t,"CATEGORIES")}Ft.hasCategoriesProperty=Ov;function Ij(t){return(0,ri.has)(t,"categoryMatches")}Ft.hasExtendingTokensTypesProperty=Ij;function yj(t){return(0,ri.has)(t,"categoryMatchesMap")}Ft.hasExtendingTokensTypesMapProperty=yj;function yIe(t){return(0,ri.has)(t,"tokenTypeIdx")}Ft.isTokenType=yIe});var Uv=w(qI=>{"use strict";Object.defineProperty(qI,"__esModule",{value:!0});qI.defaultLexerErrorProvider=void 0;qI.defaultLexerErrorProvider={buildUnableToPopLexerModeMessage:function(t){return"Unable to pop Lexer Mode after encountering Token ->"+t.image+"<- The Mode Stack is empty"},buildUnexpectedCharactersMessage:function(t,e,r,i,n){return"unexpected character: ->"+t.charAt(e)+"<- at offset: "+e+","+(" skipped "+r+" characters.")}}});var Dp=w(Bc=>{"use strict";Object.defineProperty(Bc,"__esModule",{value:!0});Bc.Lexer=Bc.LexerDefinitionErrorType=void 0;var so=Pv(),lr=Yt(),wIe=fg(),BIe=Uv(),bIe=HI(),QIe;(function(t){t[t.MISSING_PATTERN=0]="MISSING_PATTERN",t[t.INVALID_PATTERN=1]="INVALID_PATTERN",t[t.EOI_ANCHOR_FOUND=2]="EOI_ANCHOR_FOUND",t[t.UNSUPPORTED_FLAGS_FOUND=3]="UNSUPPORTED_FLAGS_FOUND",t[t.DUPLICATE_PATTERNS_FOUND=4]="DUPLICATE_PATTERNS_FOUND",t[t.INVALID_GROUP_TYPE_FOUND=5]="INVALID_GROUP_TYPE_FOUND",t[t.PUSH_MODE_DOES_NOT_EXIST=6]="PUSH_MODE_DOES_NOT_EXIST",t[t.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE=7]="MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE",t[t.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY=8]="MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY",t[t.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST=9]="MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST",t[t.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED=10]="LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED",t[t.SOI_ANCHOR_FOUND=11]="SOI_ANCHOR_FOUND",t[t.EMPTY_MATCH_PATTERN=12]="EMPTY_MATCH_PATTERN",t[t.NO_LINE_BREAKS_FLAGS=13]="NO_LINE_BREAKS_FLAGS",t[t.UNREACHABLE_PATTERN=14]="UNREACHABLE_PATTERN",t[t.IDENTIFY_TERMINATOR=15]="IDENTIFY_TERMINATOR",t[t.CUSTOM_LINE_BREAK=16]="CUSTOM_LINE_BREAK"})(QIe=Bc.LexerDefinitionErrorType||(Bc.LexerDefinitionErrorType={}));var Rp={deferDefinitionErrorsHandling:!1,positionTracking:"full",lineTerminatorsPattern:/\n|\r\n?/g,lineTerminatorCharacters:[`
+`,"\r"],ensureOptimizations:!1,safeMode:!1,errorMessageProvider:BIe.defaultLexerErrorProvider,traceInitPerf:!1,skipValidations:!1};Object.freeze(Rp);var vIe=function(){function t(e,r){var i=this;if(r===void 0&&(r=Rp),this.lexerDefinition=e,this.lexerDefinitionErrors=[],this.lexerDefinitionWarning=[],this.patternIdxToConfig={},this.charCodeToPatternIdxToConfig={},this.modes=[],this.emptyGroups={},this.config=void 0,this.trackStartLines=!0,this.trackEndLines=!0,this.hasCustom=!1,this.canModeBeOptimized={},typeof r=="boolean")throw Error(`The second argument to the Lexer constructor is now an ILexerConfig Object.
+a boolean 2nd argument is no longer supported`);this.config=(0,lr.merge)(Rp,r);var n=this.config.traceInitPerf;n===!0?(this.traceInitMaxIdent=Infinity,this.traceInitPerf=!0):typeof n=="number"&&(this.traceInitMaxIdent=n,this.traceInitPerf=!0),this.traceInitIndent=-1,this.TRACE_INIT("Lexer Constructor",function(){var s,o=!0;i.TRACE_INIT("Lexer Config handling",function(){if(i.config.lineTerminatorsPattern===Rp.lineTerminatorsPattern)i.config.lineTerminatorsPattern=so.LineTerminatorOptimizedTester;else if(i.config.lineTerminatorCharacters===Rp.lineTerminatorCharacters)throw Error(`Error: Missing <lineTerminatorCharacters> property on the Lexer config.
+       For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#MISSING_LINE_TERM_CHARS`);if(r.safeMode&&r.ensureOptimizations)throw Error('"safeMode" and "ensureOptimizations" flags are mutually exclusive.');i.trackStartLines=/full|onlyStart/i.test(i.config.positionTracking),i.trackEndLines=/full/i.test(i.config.positionTracking),(0,lr.isArray)(e)?(s={modes:{}},s.modes[so.DEFAULT_MODE]=(0,lr.cloneArr)(e),s[so.DEFAULT_MODE]=so.DEFAULT_MODE):(o=!1,s=(0,lr.cloneObj)(e))}),i.config.skipValidations===!1&&(i.TRACE_INIT("performRuntimeChecks",function(){i.lexerDefinitionErrors=i.lexerDefinitionErrors.concat((0,so.performRuntimeChecks)(s,i.trackStartLines,i.config.lineTerminatorCharacters))}),i.TRACE_INIT("performWarningRuntimeChecks",function(){i.lexerDefinitionWarning=i.lexerDefinitionWarning.concat((0,so.performWarningRuntimeChecks)(s,i.trackStartLines,i.config.lineTerminatorCharacters))})),s.modes=s.modes?s.modes:{},(0,lr.forEach)(s.modes,function(u,g){s.modes[g]=(0,lr.reject)(u,function(f){return(0,lr.isUndefined)(f)})});var a=(0,lr.keys)(s.modes);if((0,lr.forEach)(s.modes,function(u,g){i.TRACE_INIT("Mode: <"+g+"> processing",function(){if(i.modes.push(g),i.config.skipValidations===!1&&i.TRACE_INIT("validatePatterns",function(){i.lexerDefinitionErrors=i.lexerDefinitionErrors.concat((0,so.validatePatterns)(u,a))}),(0,lr.isEmpty)(i.lexerDefinitionErrors)){(0,wIe.augmentTokenTypes)(u);var f;i.TRACE_INIT("analyzeTokenTypes",function(){f=(0,so.analyzeTokenTypes)(u,{lineTerminatorCharacters:i.config.lineTerminatorCharacters,positionTracking:r.positionTracking,ensureOptimizations:r.ensureOptimizations,safeMode:r.safeMode,tracer:i.TRACE_INIT.bind(i)})}),i.patternIdxToConfig[g]=f.patternIdxToConfig,i.charCodeToPatternIdxToConfig[g]=f.charCodeToPatternIdxToConfig,i.emptyGroups=(0,lr.merge)(i.emptyGroups,f.emptyGroups),i.hasCustom=f.hasCustom||i.hasCustom,i.canModeBeOptimized[g]=f.canBeOptimized}})}),i.defaultMode=s.defaultMode,!(0,lr.isEmpty)(i.lexerDefinitionErrors)&&!i.config.deferDefinitionErrorsHandling){var l=(0,lr.map)(i.lexerDefinitionErrors,function(u){return u.message}),c=l.join(`-----------------------
+`);throw new Error(`Errors detected in definition of Lexer:
+`+c)}(0,lr.forEach)(i.lexerDefinitionWarning,function(u){(0,lr.PRINT_WARNING)(u.message)}),i.TRACE_INIT("Choosing sub-methods implementations",function(){if(so.SUPPORT_STICKY?(i.chopInput=lr.IDENTITY,i.match=i.matchWithTest):(i.updateLastIndex=lr.NOOP,i.match=i.matchWithExec),o&&(i.handleModes=lr.NOOP),i.trackStartLines===!1&&(i.computeNewColumn=lr.IDENTITY),i.trackEndLines===!1&&(i.updateTokenEndLineColumnLocation=lr.NOOP),/full/i.test(i.config.positionTracking))i.createTokenInstance=i.createFullToken;else if(/onlyStart/i.test(i.config.positionTracking))i.createTokenInstance=i.createStartOnlyToken;else if(/onlyOffset/i.test(i.config.positionTracking))i.createTokenInstance=i.createOffsetOnlyToken;else throw Error('Invalid <positionTracking> config option: "'+i.config.positionTracking+'"');i.hasCustom?(i.addToken=i.addTokenUsingPush,i.handlePayload=i.handlePayloadWithCustom):(i.addToken=i.addTokenUsingMemberAccess,i.handlePayload=i.handlePayloadNoCustom)}),i.TRACE_INIT("Failed Optimization Warnings",function(){var u=(0,lr.reduce)(i.canModeBeOptimized,function(g,f,h){return f===!1&&g.push(h),g},[]);if(r.ensureOptimizations&&!(0,lr.isEmpty)(u))throw Error("Lexer Modes: < "+u.join(", ")+` > cannot be optimized.
+        Disable the "ensureOptimizations" lexer config flag to silently ignore this and run the lexer in an un-optimized mode.
+        Or inspect the console log for details on how to resolve these issues.`)}),i.TRACE_INIT("clearRegExpParserCache",function(){(0,bIe.clearRegExpParserCache)()}),i.TRACE_INIT("toFastProperties",function(){(0,lr.toFastProperties)(i)})})}return t.prototype.tokenize=function(e,r){if(r===void 0&&(r=this.defaultMode),!(0,lr.isEmpty)(this.lexerDefinitionErrors)){var i=(0,lr.map)(this.lexerDefinitionErrors,function(o){return o.message}),n=i.join(`-----------------------
+`);throw new Error(`Unable to Tokenize because Errors detected in definition of Lexer:
+`+n)}var s=this.tokenizeInternal(e,r);return s},t.prototype.tokenizeInternal=function(e,r){var i=this,n,s,o,a,l,c,u,g,f,h,p,m,y,Q,S,x,M=e,Y=M.length,U=0,J=0,W=this.hasCustom?0:Math.floor(e.length/10),ee=new Array(W),Z=[],A=this.trackStartLines?1:void 0,ne=this.trackStartLines?1:void 0,le=(0,so.cloneEmptyGroups)(this.emptyGroups),Ae=this.trackStartLines,T=this.config.lineTerminatorsPattern,L=0,Ee=[],we=[],qe=[],re=[];Object.freeze(re);var se=void 0;function Qe(){return Ee}function he(vr){var Hn=(0,so.charCodeToOptimizedIndex)(vr),us=we[Hn];return us===void 0?re:us}var Fe=function(vr){if(qe.length===1&&vr.tokenType.PUSH_MODE===void 0){var Hn=i.config.errorMessageProvider.buildUnableToPopLexerModeMessage(vr);Z.push({offset:vr.startOffset,line:vr.startLine!==void 0?vr.startLine:void 0,column:vr.startColumn!==void 0?vr.startColumn:void 0,length:vr.image.length,message:Hn})}else{qe.pop();var us=(0,lr.last)(qe);Ee=i.patternIdxToConfig[us],we=i.charCodeToPatternIdxToConfig[us],L=Ee.length;var Ia=i.canModeBeOptimized[us]&&i.config.safeMode===!1;we&&Ia?se=he:se=Qe}};function Ue(vr){qe.push(vr),we=this.charCodeToPatternIdxToConfig[vr],Ee=this.patternIdxToConfig[vr],L=Ee.length,L=Ee.length;var Hn=this.canModeBeOptimized[vr]&&this.config.safeMode===!1;we&&Hn?se=he:se=Qe}Ue.call(this,r);for(var xe;U<Y;){c=null;var ve=M.charCodeAt(U),pe=se(ve),X=pe.length;for(n=0;n<X;n++){xe=pe[n];var be=xe.pattern;u=null;var ce=xe.short;if(ce!==!1?ve===ce&&(c=be):xe.isCustom===!0?(x=be.exec(M,U,ee,le),x!==null?(c=x[0],x.payload!==void 0&&(u=x.payload)):c=null):(this.updateLastIndex(be,U),c=this.match(be,e,U)),c!==null){if(l=xe.longerAlt,l!==void 0){var fe=l.length;for(o=0;o<fe;o++){var gt=Ee[l[o]],Ht=gt.pattern;if(g=null,gt.isCustom===!0?(x=Ht.exec(M,U,ee,le),x!==null?(a=x[0],x.payload!==void 0&&(g=x.payload)):a=null):(this.updateLastIndex(Ht,U),a=this.match(Ht,e,U)),a&&a.length>c.length){c=a,u=g,xe=gt;break}}}break}}if(c!==null){if(f=c.length,h=xe.group,h!==void 0&&(p=xe.tokenTypeIdx,m=this.createTokenInstance(c,U,p,xe.tokenType,A,ne,f),this.handlePayload(m,u),h===!1?J=this.addToken(ee,J,m):le[h].push(m)),e=this.chopInput(e,f),U=U+f,ne=this.computeNewColumn(ne,f),Ae===!0&&xe.canLineTerminator===!0){var Mt=0,mi=void 0,jt=void 0;T.lastIndex=0;do mi=T.test(c),mi===!0&&(jt=T.lastIndex-1,Mt++);while(mi===!0);Mt!==0&&(A=A+Mt,ne=f-jt,this.updateTokenEndLineColumnLocation(m,h,jt,Mt,A,ne,f))}this.handleModes(xe,Fe,Ue,m)}else{for(var Qr=U,Ti=A,_s=ne,Un=!1;!Un&&U<Y;)for(Q=M.charCodeAt(U),e=this.chopInput(e,1),U++,s=0;s<L;s++){var Kn=Ee[s],be=Kn.pattern,ce=Kn.short;if(ce!==!1?M.charCodeAt(U)===ce&&(Un=!0):Kn.isCustom===!0?Un=be.exec(M,U,ee,le)!==null:(this.updateLastIndex(be,U),Un=be.exec(e)!==null),Un===!0)break}y=U-Qr,S=this.config.errorMessageProvider.buildUnexpectedCharactersMessage(M,Qr,y,Ti,_s),Z.push({offset:Qr,line:Ti,column:_s,length:y,message:S})}}return this.hasCustom||(ee.length=J),{tokens:ee,groups:le,errors:Z}},t.prototype.handleModes=function(e,r,i,n){if(e.pop===!0){var s=e.push;r(n),s!==void 0&&i.call(this,s)}else e.push!==void 0&&i.call(this,e.push)},t.prototype.chopInput=function(e,r){return e.substring(r)},t.prototype.updateLastIndex=function(e,r){e.lastIndex=r},t.prototype.updateTokenEndLineColumnLocation=function(e,r,i,n,s,o,a){var l,c;r!==void 0&&(l=i===a-1,c=l?-1:0,n===1&&l===!0||(e.endLine=s+c,e.endColumn=o-1+-c))},t.prototype.computeNewColumn=function(e,r){return e+r},t.prototype.createTokenInstance=function(){for(var e=[],r=0;r<arguments.length;r++)e[r]=arguments[r];return null},t.prototype.createOffsetOnlyToken=function(e,r,i,n){return{image:e,startOffset:r,tokenTypeIdx:i,tokenType:n}},t.prototype.createStartOnlyToken=function(e,r,i,n,s,o){return{image:e,startOffset:r,startLine:s,startColumn:o,tokenTypeIdx:i,tokenType:n}},t.prototype.createFullToken=function(e,r,i,n,s,o,a){return{image:e,startOffset:r,endOffset:r+a-1,startLine:s,endLine:s,startColumn:o,endColumn:o+a-1,tokenTypeIdx:i,tokenType:n}},t.prototype.addToken=function(e,r,i){return 666},t.prototype.addTokenUsingPush=function(e,r,i){return e.push(i),r},t.prototype.addTokenUsingMemberAccess=function(e,r,i){return e[r]=i,r++,r},t.prototype.handlePayload=function(e,r){},t.prototype.handlePayloadNoCustom=function(e,r){},t.prototype.handlePayloadWithCustom=function(e,r){r!==null&&(e.payload=r)},t.prototype.match=function(e,r,i){return null},t.prototype.matchWithTest=function(e,r,i){var n=e.test(r);return n===!0?r.substring(i,e.lastIndex):null},t.prototype.matchWithExec=function(e,r){var i=e.exec(r);return i!==null?i[0]:i},t.prototype.TRACE_INIT=function(e,r){if(this.traceInitPerf===!0){this.traceInitIndent++;var i=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent<this.traceInitMaxIdent&&console.log(i+"--> <"+e+">");var n=(0,lr.timer)(r),s=n.time,o=n.value,a=s>10?console.warn:console.log;return this.traceInitIndent<this.traceInitMaxIdent&&a(i+"<-- <"+e+"> time: "+s+"ms"),this.traceInitIndent--,o}else return r()},t.SKIPPED="This marks a skipped Token pattern, this means each token identified by it willbe consumed and then thrown into oblivion, this can be used to for example to completely ignore whitespace.",t.NA=/NOT_APPLICABLE/,t}();Bc.Lexer=vIe});var JA=w(xi=>{"use strict";Object.defineProperty(xi,"__esModule",{value:!0});xi.tokenMatcher=xi.createTokenInstance=xi.EOF=xi.createToken=xi.hasTokenLabel=xi.tokenName=xi.tokenLabel=void 0;var oo=Yt(),SIe=Dp(),Kv=fg();function kIe(t){return wj(t)?t.LABEL:t.name}xi.tokenLabel=kIe;function xIe(t){return t.name}xi.tokenName=xIe;function wj(t){return(0,oo.isString)(t.LABEL)&&t.LABEL!==""}xi.hasTokenLabel=wj;var PIe="parent",Bj="categories",bj="label",Qj="group",vj="push_mode",Sj="pop_mode",kj="longer_alt",xj="line_breaks",Pj="start_chars_hint";function Dj(t){return DIe(t)}xi.createToken=Dj;function DIe(t){var e=t.pattern,r={};if(r.name=t.name,(0,oo.isUndefined)(e)||(r.PATTERN=e),(0,oo.has)(t,PIe))throw`The parent property is no longer supported.
+See: https://github.com/chevrotain/chevrotain/issues/564#issuecomment-349062346 for details.`;return(0,oo.has)(t,Bj)&&(r.CATEGORIES=t[Bj]),(0,Kv.augmentTokenTypes)([r]),(0,oo.has)(t,bj)&&(r.LABEL=t[bj]),(0,oo.has)(t,Qj)&&(r.GROUP=t[Qj]),(0,oo.has)(t,Sj)&&(r.POP_MODE=t[Sj]),(0,oo.has)(t,vj)&&(r.PUSH_MODE=t[vj]),(0,oo.has)(t,kj)&&(r.LONGER_ALT=t[kj]),(0,oo.has)(t,xj)&&(r.LINE_BREAKS=t[xj]),(0,oo.has)(t,Pj)&&(r.START_CHARS_HINT=t[Pj]),r}xi.EOF=Dj({name:"EOF",pattern:SIe.Lexer.NA});(0,Kv.augmentTokenTypes)([xi.EOF]);function RIe(t,e,r,i,n,s,o,a){return{image:e,startOffset:r,endOffset:i,startLine:n,endLine:s,startColumn:o,endColumn:a,tokenTypeIdx:t.tokenTypeIdx,tokenType:t}}xi.createTokenInstance=RIe;function FIe(t,e){return(0,Kv.tokenStructuredMatcher)(t,e)}xi.tokenMatcher=FIe});var bn=w(Vt=>{"use strict";var Ua=Vt&&Vt.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(Vt,"__esModule",{value:!0});Vt.serializeProduction=Vt.serializeGrammar=Vt.Terminal=Vt.Alternation=Vt.RepetitionWithSeparator=Vt.Repetition=Vt.RepetitionMandatoryWithSeparator=Vt.RepetitionMandatory=Vt.Option=Vt.Alternative=Vt.Rule=Vt.NonTerminal=Vt.AbstractProduction=void 0;var fr=Yt(),NIe=JA(),To=function(){function t(e){this._definition=e}return Object.defineProperty(t.prototype,"definition",{get:function(){return this._definition},set:function(e){this._definition=e},enumerable:!1,configurable:!0}),t.prototype.accept=function(e){e.visit(this),(0,fr.forEach)(this.definition,function(r){r.accept(e)})},t}();Vt.AbstractProduction=To;var Rj=function(t){Ua(e,t);function e(r){var i=t.call(this,[])||this;return i.idx=1,(0,fr.assign)(i,(0,fr.pick)(r,function(n){return n!==void 0})),i}return Object.defineProperty(e.prototype,"definition",{get:function(){return this.referencedRule!==void 0?this.referencedRule.definition:[]},set:function(r){},enumerable:!1,configurable:!0}),e.prototype.accept=function(r){r.visit(this)},e}(To);Vt.NonTerminal=Rj;var Fj=function(t){Ua(e,t);function e(r){var i=t.call(this,r.definition)||this;return i.orgText="",(0,fr.assign)(i,(0,fr.pick)(r,function(n){return n!==void 0})),i}return e}(To);Vt.Rule=Fj;var Nj=function(t){Ua(e,t);function e(r){var i=t.call(this,r.definition)||this;return i.ignoreAmbiguities=!1,(0,fr.assign)(i,(0,fr.pick)(r,function(n){return n!==void 0})),i}return e}(To);Vt.Alternative=Nj;var Lj=function(t){Ua(e,t);function e(r){var i=t.call(this,r.definition)||this;return i.idx=1,(0,fr.assign)(i,(0,fr.pick)(r,function(n){return n!==void 0})),i}return e}(To);Vt.Option=Lj;var Tj=function(t){Ua(e,t);function e(r){var i=t.call(this,r.definition)||this;return i.idx=1,(0,fr.assign)(i,(0,fr.pick)(r,function(n){return n!==void 0})),i}return e}(To);Vt.RepetitionMandatory=Tj;var Oj=function(t){Ua(e,t);function e(r){var i=t.call(this,r.definition)||this;return i.idx=1,(0,fr.assign)(i,(0,fr.pick)(r,function(n){return n!==void 0})),i}return e}(To);Vt.RepetitionMandatoryWithSeparator=Oj;var Mj=function(t){Ua(e,t);function e(r){var i=t.call(this,r.definition)||this;return i.idx=1,(0,fr.assign)(i,(0,fr.pick)(r,function(n){return n!==void 0})),i}return e}(To);Vt.Repetition=Mj;var Uj=function(t){Ua(e,t);function e(r){var i=t.call(this,r.definition)||this;return i.idx=1,(0,fr.assign)(i,(0,fr.pick)(r,function(n){return n!==void 0})),i}return e}(To);Vt.RepetitionWithSeparator=Uj;var Kj=function(t){Ua(e,t);function e(r){var i=t.call(this,r.definition)||this;return i.idx=1,i.ignoreAmbiguities=!1,i.hasPredicates=!1,(0,fr.assign)(i,(0,fr.pick)(r,function(n){return n!==void 0})),i}return Object.defineProperty(e.prototype,"definition",{get:function(){return this._definition},set:function(r){this._definition=r},enumerable:!1,configurable:!0}),e}(To);Vt.Alternation=Kj;var JI=function(){function t(e){this.idx=1,(0,fr.assign)(this,(0,fr.pick)(e,function(r){return r!==void 0}))}return t.prototype.accept=function(e){e.visit(this)},t}();Vt.Terminal=JI;function LIe(t){return(0,fr.map)(t,Fp)}Vt.serializeGrammar=LIe;function Fp(t){function e(s){return(0,fr.map)(s,Fp)}if(t instanceof Rj){var r={type:"NonTerminal",name:t.nonTerminalName,idx:t.idx};return(0,fr.isString)(t.label)&&(r.label=t.label),r}else{if(t instanceof Nj)return{type:"Alternative",definition:e(t.definition)};if(t instanceof Lj)return{type:"Option",idx:t.idx,definition:e(t.definition)};if(t instanceof Tj)return{type:"RepetitionMandatory",idx:t.idx,definition:e(t.definition)};if(t instanceof Oj)return{type:"RepetitionMandatoryWithSeparator",idx:t.idx,separator:Fp(new JI({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof Uj)return{type:"RepetitionWithSeparator",idx:t.idx,separator:Fp(new JI({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof Mj)return{type:"Repetition",idx:t.idx,definition:e(t.definition)};if(t instanceof Kj)return{type:"Alternation",idx:t.idx,definition:e(t.definition)};if(t instanceof JI){var i={type:"Terminal",name:t.terminalType.name,label:(0,NIe.tokenLabel)(t.terminalType),idx:t.idx};(0,fr.isString)(t.label)&&(i.terminalLabel=t.label);var n=t.terminalType.PATTERN;return t.terminalType.PATTERN&&(i.pattern=(0,fr.isRegExp)(n)?n.source:n),i}else{if(t instanceof Fj)return{type:"Rule",name:t.name,orgText:t.orgText,definition:e(t.definition)};throw Error("non exhaustive match")}}}Vt.serializeProduction=Fp});var zI=w(WI=>{"use strict";Object.defineProperty(WI,"__esModule",{value:!0});WI.RestWalker=void 0;var Hv=Yt(),Qn=bn(),TIe=function(){function t(){}return t.prototype.walk=function(e,r){var i=this;r===void 0&&(r=[]),(0,Hv.forEach)(e.definition,function(n,s){var o=(0,Hv.drop)(e.definition,s+1);if(n instanceof Qn.NonTerminal)i.walkProdRef(n,o,r);else if(n instanceof Qn.Terminal)i.walkTerminal(n,o,r);else if(n instanceof Qn.Alternative)i.walkFlat(n,o,r);else if(n instanceof Qn.Option)i.walkOption(n,o,r);else if(n instanceof Qn.RepetitionMandatory)i.walkAtLeastOne(n,o,r);else if(n instanceof Qn.RepetitionMandatoryWithSeparator)i.walkAtLeastOneSep(n,o,r);else if(n instanceof Qn.RepetitionWithSeparator)i.walkManySep(n,o,r);else if(n instanceof Qn.Repetition)i.walkMany(n,o,r);else if(n instanceof Qn.Alternation)i.walkOr(n,o,r);else throw Error("non exhaustive match")})},t.prototype.walkTerminal=function(e,r,i){},t.prototype.walkProdRef=function(e,r,i){},t.prototype.walkFlat=function(e,r,i){var n=r.concat(i);this.walk(e,n)},t.prototype.walkOption=function(e,r,i){var n=r.concat(i);this.walk(e,n)},t.prototype.walkAtLeastOne=function(e,r,i){var n=[new Qn.Option({definition:e.definition})].concat(r,i);this.walk(e,n)},t.prototype.walkAtLeastOneSep=function(e,r,i){var n=Hj(e,r,i);this.walk(e,n)},t.prototype.walkMany=function(e,r,i){var n=[new Qn.Option({definition:e.definition})].concat(r,i);this.walk(e,n)},t.prototype.walkManySep=function(e,r,i){var n=Hj(e,r,i);this.walk(e,n)},t.prototype.walkOr=function(e,r,i){var n=this,s=r.concat(i);(0,Hv.forEach)(e.definition,function(o){var a=new Qn.Alternative({definition:[o]});n.walk(a,s)})},t}();WI.RestWalker=TIe;function Hj(t,e,r){var i=[new Qn.Option({definition:[new Qn.Terminal({terminalType:t.separator})].concat(t.definition)})],n=i.concat(e,r);return n}});var hg=w(_I=>{"use strict";Object.defineProperty(_I,"__esModule",{value:!0});_I.GAstVisitor=void 0;var Oo=bn(),OIe=function(){function t(){}return t.prototype.visit=function(e){var r=e;switch(r.constructor){case Oo.NonTerminal:return this.visitNonTerminal(r);case Oo.Alternative:return this.visitAlternative(r);case Oo.Option:return this.visitOption(r);case Oo.RepetitionMandatory:return this.visitRepetitionMandatory(r);case Oo.RepetitionMandatoryWithSeparator:return this.visitRepetitionMandatoryWithSeparator(r);case Oo.RepetitionWithSeparator:return this.visitRepetitionWithSeparator(r);case Oo.Repetition:return this.visitRepetition(r);case Oo.Alternation:return this.visitAlternation(r);case Oo.Terminal:return this.visitTerminal(r);case Oo.Rule:return this.visitRule(r);default:throw Error("non exhaustive match")}},t.prototype.visitNonTerminal=function(e){},t.prototype.visitAlternative=function(e){},t.prototype.visitOption=function(e){},t.prototype.visitRepetition=function(e){},t.prototype.visitRepetitionMandatory=function(e){},t.prototype.visitRepetitionMandatoryWithSeparator=function(e){},t.prototype.visitRepetitionWithSeparator=function(e){},t.prototype.visitAlternation=function(e){},t.prototype.visitTerminal=function(e){},t.prototype.visitRule=function(e){},t}();_I.GAstVisitor=OIe});var Lp=w(Gi=>{"use strict";var MIe=Gi&&Gi.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(Gi,"__esModule",{value:!0});Gi.collectMethods=Gi.DslMethodsCollectorVisitor=Gi.getProductionDslName=Gi.isBranchingProd=Gi.isOptionalProd=Gi.isSequenceProd=void 0;var Np=Yt(),kr=bn(),UIe=hg();function KIe(t){return t instanceof kr.Alternative||t instanceof kr.Option||t instanceof kr.Repetition||t instanceof kr.RepetitionMandatory||t instanceof kr.RepetitionMandatoryWithSeparator||t instanceof kr.RepetitionWithSeparator||t instanceof kr.Terminal||t instanceof kr.Rule}Gi.isSequenceProd=KIe;function jv(t,e){e===void 0&&(e=[]);var r=t instanceof kr.Option||t instanceof kr.Repetition||t instanceof kr.RepetitionWithSeparator;return r?!0:t instanceof kr.Alternation?(0,Np.some)(t.definition,function(i){return jv(i,e)}):t instanceof kr.NonTerminal&&(0,Np.contains)(e,t)?!1:t instanceof kr.AbstractProduction?(t instanceof kr.NonTerminal&&e.push(t),(0,Np.every)(t.definition,function(i){return jv(i,e)})):!1}Gi.isOptionalProd=jv;function HIe(t){return t instanceof kr.Alternation}Gi.isBranchingProd=HIe;function jIe(t){if(t instanceof kr.NonTerminal)return"SUBRULE";if(t instanceof kr.Option)return"OPTION";if(t instanceof kr.Alternation)return"OR";if(t instanceof kr.RepetitionMandatory)return"AT_LEAST_ONE";if(t instanceof kr.RepetitionMandatoryWithSeparator)return"AT_LEAST_ONE_SEP";if(t instanceof kr.RepetitionWithSeparator)return"MANY_SEP";if(t instanceof kr.Repetition)return"MANY";if(t instanceof kr.Terminal)return"CONSUME";throw Error("non exhaustive match")}Gi.getProductionDslName=jIe;var jj=function(t){MIe(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.separator="-",r.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]},r}return e.prototype.reset=function(){this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}},e.prototype.visitTerminal=function(r){var i=r.terminalType.name+this.separator+"Terminal";(0,Np.has)(this.dslMethods,i)||(this.dslMethods[i]=[]),this.dslMethods[i].push(r)},e.prototype.visitNonTerminal=function(r){var i=r.nonTerminalName+this.separator+"Terminal";(0,Np.has)(this.dslMethods,i)||(this.dslMethods[i]=[]),this.dslMethods[i].push(r)},e.prototype.visitOption=function(r){this.dslMethods.option.push(r)},e.prototype.visitRepetitionWithSeparator=function(r){this.dslMethods.repetitionWithSeparator.push(r)},e.prototype.visitRepetitionMandatory=function(r){this.dslMethods.repetitionMandatory.push(r)},e.prototype.visitRepetitionMandatoryWithSeparator=function(r){this.dslMethods.repetitionMandatoryWithSeparator.push(r)},e.prototype.visitRepetition=function(r){this.dslMethods.repetition.push(r)},e.prototype.visitAlternation=function(r){this.dslMethods.alternation.push(r)},e}(UIe.GAstVisitor);Gi.DslMethodsCollectorVisitor=jj;var VI=new jj;function GIe(t){VI.reset(),t.accept(VI);var e=VI.dslMethods;return VI.reset(),e}Gi.collectMethods=GIe});var Yv=w(Mo=>{"use strict";Object.defineProperty(Mo,"__esModule",{value:!0});Mo.firstForTerminal=Mo.firstForBranching=Mo.firstForSequence=Mo.first=void 0;var XI=Yt(),Gj=bn(),Gv=Lp();function ZI(t){if(t instanceof Gj.NonTerminal)return ZI(t.referencedRule);if(t instanceof Gj.Terminal)return Jj(t);if((0,Gv.isSequenceProd)(t))return Yj(t);if((0,Gv.isBranchingProd)(t))return qj(t);throw Error("non exhaustive match")}Mo.first=ZI;function Yj(t){for(var e=[],r=t.definition,i=0,n=r.length>i,s,o=!0;n&&o;)s=r[i],o=(0,Gv.isOptionalProd)(s),e=e.concat(ZI(s)),i=i+1,n=r.length>i;return(0,XI.uniq)(e)}Mo.firstForSequence=Yj;function qj(t){var e=(0,XI.map)(t.definition,function(r){return ZI(r)});return(0,XI.uniq)((0,XI.flatten)(e))}Mo.firstForBranching=qj;function Jj(t){return[t.terminalType]}Mo.firstForTerminal=Jj});var qv=w($I=>{"use strict";Object.defineProperty($I,"__esModule",{value:!0});$I.IN=void 0;$I.IN="_~IN~_"});var Xj=w(Qs=>{"use strict";var YIe=Qs&&Qs.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(Qs,"__esModule",{value:!0});Qs.buildInProdFollowPrefix=Qs.buildBetweenProdsFollowPrefix=Qs.computeAllProdsFollows=Qs.ResyncFollowsWalker=void 0;var qIe=zI(),JIe=Yv(),Wj=Yt(),zj=qv(),WIe=bn(),Vj=function(t){YIe(e,t);function e(r){var i=t.call(this)||this;return i.topProd=r,i.follows={},i}return e.prototype.startWalking=function(){return this.walk(this.topProd),this.follows},e.prototype.walkTerminal=function(r,i,n){},e.prototype.walkProdRef=function(r,i,n){var s=_j(r.referencedRule,r.idx)+this.topProd.name,o=i.concat(n),a=new WIe.Alternative({definition:o}),l=(0,JIe.first)(a);this.follows[s]=l},e}(qIe.RestWalker);Qs.ResyncFollowsWalker=Vj;function zIe(t){var e={};return(0,Wj.forEach)(t,function(r){var i=new Vj(r).startWalking();(0,Wj.assign)(e,i)}),e}Qs.computeAllProdsFollows=zIe;function _j(t,e){return t.name+e+zj.IN}Qs.buildBetweenProdsFollowPrefix=_j;function _Ie(t){var e=t.terminalType.name;return e+t.idx+zj.IN}Qs.buildInProdFollowPrefix=_Ie});var Tp=w(Ka=>{"use strict";Object.defineProperty(Ka,"__esModule",{value:!0});Ka.defaultGrammarValidatorErrorProvider=Ka.defaultGrammarResolverErrorProvider=Ka.defaultParserErrorProvider=void 0;var pg=JA(),VIe=Yt(),ao=Yt(),Jv=bn(),Zj=Lp();Ka.defaultParserErrorProvider={buildMismatchTokenMessage:function(t){var e=t.expected,r=t.actual,i=t.previous,n=t.ruleName,s=(0,pg.hasTokenLabel)(e),o=s?"--> "+(0,pg.tokenLabel)(e)+" <--":"token of type --> "+e.name+" <--",a="Expecting "+o+" but found --> '"+r.image+"' <--";return a},buildNotAllInputParsedMessage:function(t){var e=t.firstRedundant,r=t.ruleName;return"Redundant input, expecting EOF but found: "+e.image},buildNoViableAltMessage:function(t){var e=t.expectedPathsPerAlt,r=t.actual,i=t.previous,n=t.customUserDescription,s=t.ruleName,o="Expecting: ",a=(0,ao.first)(r).image,l=`
+but found: '`+a+"'";if(n)return o+n+l;var c=(0,ao.reduce)(e,function(h,p){return h.concat(p)},[]),u=(0,ao.map)(c,function(h){return"["+(0,ao.map)(h,function(p){return(0,pg.tokenLabel)(p)}).join(", ")+"]"}),g=(0,ao.map)(u,function(h,p){return"  "+(p+1)+". "+h}),f=`one of these possible Token sequences:
+`+g.join(`
+`);return o+f+l},buildEarlyExitMessage:function(t){var e=t.expectedIterationPaths,r=t.actual,i=t.customUserDescription,n=t.ruleName,s="Expecting: ",o=(0,ao.first)(r).image,a=`
+but found: '`+o+"'";if(i)return s+i+a;var l=(0,ao.map)(e,function(u){return"["+(0,ao.map)(u,function(g){return(0,pg.tokenLabel)(g)}).join(",")+"]"}),c=`expecting at least one iteration which starts with one of these possible Token sequences::
+  `+("<"+l.join(" ,")+">");return s+c+a}};Object.freeze(Ka.defaultParserErrorProvider);Ka.defaultGrammarResolverErrorProvider={buildRuleNotFoundError:function(t,e){var r="Invalid grammar, reference to a rule which is not defined: ->"+e.nonTerminalName+`<-
+inside top level rule: ->`+t.name+"<-";return r}};Ka.defaultGrammarValidatorErrorProvider={buildDuplicateFoundError:function(t,e){function r(u){return u instanceof Jv.Terminal?u.terminalType.name:u instanceof Jv.NonTerminal?u.nonTerminalName:""}var i=t.name,n=(0,ao.first)(e),s=n.idx,o=(0,Zj.getProductionDslName)(n),a=r(n),l=s>0,c="->"+o+(l?s:"")+"<- "+(a?"with argument: ->"+a+"<-":"")+`
+                  appears more than once (`+e.length+" times) in the top level rule: ->"+i+`<-.                  
+                  For further details see: https://chevrotain.io/docs/FAQ.html#NUMERICAL_SUFFIXES 
+                  `;return c=c.replace(/[ \t]+/g," "),c=c.replace(/\s\s+/g,`
+`),c},buildNamespaceConflictError:function(t){var e=`Namespace conflict found in grammar.
+`+("The grammar has both a Terminal(Token) and a Non-Terminal(Rule) named: <"+t.name+`>.
+`)+`To resolve this make sure each Terminal and Non-Terminal names are unique
+This is easy to accomplish by using the convention that Terminal names start with an uppercase letter
+and Non-Terminal names start with a lower case letter.`;return e},buildAlternationPrefixAmbiguityError:function(t){var e=(0,ao.map)(t.prefixPath,function(n){return(0,pg.tokenLabel)(n)}).join(", "),r=t.alternation.idx===0?"":t.alternation.idx,i="Ambiguous alternatives: <"+t.ambiguityIndices.join(" ,")+`> due to common lookahead prefix
+`+("in <OR"+r+"> inside <"+t.topLevelRule.name+`> Rule,
+`)+("<"+e+`> may appears as a prefix path in all these alternatives.
+`)+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#COMMON_PREFIX
+For Further details.`;return i},buildAlternationAmbiguityError:function(t){var e=(0,ao.map)(t.prefixPath,function(n){return(0,pg.tokenLabel)(n)}).join(", "),r=t.alternation.idx===0?"":t.alternation.idx,i="Ambiguous Alternatives Detected: <"+t.ambiguityIndices.join(" ,")+"> in <OR"+r+">"+(" inside <"+t.topLevelRule.name+`> Rule,
+`)+("<"+e+`> may appears as a prefix path in all these alternatives.
+`);return i=i+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES
+For Further details.`,i},buildEmptyRepetitionError:function(t){var e=(0,Zj.getProductionDslName)(t.repetition);t.repetition.idx!==0&&(e+=t.repetition.idx);var r="The repetition <"+e+"> within Rule <"+t.topLevelRule.name+`> can never consume any tokens.
+This could lead to an infinite loop.`;return r},buildTokenNameError:function(t){return"deprecated"},buildEmptyAlternationError:function(t){var e="Ambiguous empty alternative: <"+(t.emptyChoiceIdx+1)+">"+(" in <OR"+t.alternation.idx+"> inside <"+t.topLevelRule.name+`> Rule.
+`)+"Only the last alternative may be an empty alternative.";return e},buildTooManyAlternativesError:function(t){var e=`An Alternation cannot have more than 256 alternatives:
+`+("<OR"+t.alternation.idx+"> inside <"+t.topLevelRule.name+`> Rule.
+ has `+(t.alternation.definition.length+1)+" alternatives.");return e},buildLeftRecursionError:function(t){var e=t.topLevelRule.name,r=VIe.map(t.leftRecursionPath,function(s){return s.name}),i=e+" --> "+r.concat([e]).join(" --> "),n=`Left Recursion found in grammar.
+`+("rule: <"+e+`> can be invoked from itself (directly or indirectly)
+`)+(`without consuming any Tokens. The grammar path that causes this is: 
+ `+i+`
+`)+` To fix this refactor your grammar to remove the left recursion.
+see: https://en.wikipedia.org/wiki/LL_parser#Left_Factoring.`;return n},buildInvalidRuleNameError:function(t){return"deprecated"},buildDuplicateRuleNameError:function(t){var e;t.topLevelRule instanceof Jv.Rule?e=t.topLevelRule.name:e=t.topLevelRule;var r="Duplicate definition, rule: ->"+e+"<- is already defined in the grammar: ->"+t.grammarName+"<-";return r}}});var tG=w(WA=>{"use strict";var XIe=WA&&WA.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(WA,"__esModule",{value:!0});WA.GastRefResolverVisitor=WA.resolveGrammar=void 0;var ZIe=Xn(),$j=Yt(),$Ie=hg();function eye(t,e){var r=new eG(t,e);return r.resolveRefs(),r.errors}WA.resolveGrammar=eye;var eG=function(t){XIe(e,t);function e(r,i){var n=t.call(this)||this;return n.nameToTopRule=r,n.errMsgProvider=i,n.errors=[],n}return e.prototype.resolveRefs=function(){var r=this;(0,$j.forEach)((0,$j.values)(this.nameToTopRule),function(i){r.currTopLevel=i,i.accept(r)})},e.prototype.visitNonTerminal=function(r){var i=this.nameToTopRule[r.nonTerminalName];if(i)r.referencedRule=i;else{var n=this.errMsgProvider.buildRuleNotFoundError(this.currTopLevel,r);this.errors.push({message:n,type:ZIe.ParserDefinitionErrorType.UNRESOLVED_SUBRULE_REF,ruleName:this.currTopLevel.name,unresolvedRefName:r.nonTerminalName})}},e}($Ie.GAstVisitor);WA.GastRefResolverVisitor=eG});var Mp=w(Mr=>{"use strict";var bc=Mr&&Mr.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(Mr,"__esModule",{value:!0});Mr.nextPossibleTokensAfter=Mr.possiblePathsFrom=Mr.NextTerminalAfterAtLeastOneSepWalker=Mr.NextTerminalAfterAtLeastOneWalker=Mr.NextTerminalAfterManySepWalker=Mr.NextTerminalAfterManyWalker=Mr.AbstractNextTerminalAfterProductionWalker=Mr.NextAfterTokenWalker=Mr.AbstractNextPossibleTokensWalker=void 0;var rG=zI(),Ut=Yt(),tye=Yv(),Dt=bn(),iG=function(t){bc(e,t);function e(r,i){var n=t.call(this)||this;return n.topProd=r,n.path=i,n.possibleTokTypes=[],n.nextProductionName="",n.nextProductionOccurrence=0,n.found=!1,n.isAtEndOfPath=!1,n}return e.prototype.startWalking=function(){if(this.found=!1,this.path.ruleStack[0]!==this.topProd.name)throw Error("The path does not start with the walker's top Rule!");return this.ruleStack=(0,Ut.cloneArr)(this.path.ruleStack).reverse(),this.occurrenceStack=(0,Ut.cloneArr)(this.path.occurrenceStack).reverse(),this.ruleStack.pop(),this.occurrenceStack.pop(),this.updateExpectedNext(),this.walk(this.topProd),this.possibleTokTypes},e.prototype.walk=function(r,i){i===void 0&&(i=[]),this.found||t.prototype.walk.call(this,r,i)},e.prototype.walkProdRef=function(r,i,n){if(r.referencedRule.name===this.nextProductionName&&r.idx===this.nextProductionOccurrence){var s=i.concat(n);this.updateExpectedNext(),this.walk(r.referencedRule,s)}},e.prototype.updateExpectedNext=function(){(0,Ut.isEmpty)(this.ruleStack)?(this.nextProductionName="",this.nextProductionOccurrence=0,this.isAtEndOfPath=!0):(this.nextProductionName=this.ruleStack.pop(),this.nextProductionOccurrence=this.occurrenceStack.pop())},e}(rG.RestWalker);Mr.AbstractNextPossibleTokensWalker=iG;var rye=function(t){bc(e,t);function e(r,i){var n=t.call(this,r,i)||this;return n.path=i,n.nextTerminalName="",n.nextTerminalOccurrence=0,n.nextTerminalName=n.path.lastTok.name,n.nextTerminalOccurrence=n.path.lastTokOccurrence,n}return e.prototype.walkTerminal=function(r,i,n){if(this.isAtEndOfPath&&r.terminalType.name===this.nextTerminalName&&r.idx===this.nextTerminalOccurrence&&!this.found){var s=i.concat(n),o=new Dt.Alternative({definition:s});this.possibleTokTypes=(0,tye.first)(o),this.found=!0}},e}(iG);Mr.NextAfterTokenWalker=rye;var Op=function(t){bc(e,t);function e(r,i){var n=t.call(this)||this;return n.topRule=r,n.occurrence=i,n.result={token:void 0,occurrence:void 0,isEndOfRule:void 0},n}return e.prototype.startWalking=function(){return this.walk(this.topRule),this.result},e}(rG.RestWalker);Mr.AbstractNextTerminalAfterProductionWalker=Op;var iye=function(t){bc(e,t);function e(){return t!==null&&t.apply(this,arguments)||this}return e.prototype.walkMany=function(r,i,n){if(r.idx===this.occurrence){var s=(0,Ut.first)(i.concat(n));this.result.isEndOfRule=s===void 0,s instanceof Dt.Terminal&&(this.result.token=s.terminalType,this.result.occurrence=s.idx)}else t.prototype.walkMany.call(this,r,i,n)},e}(Op);Mr.NextTerminalAfterManyWalker=iye;var nye=function(t){bc(e,t);function e(){return t!==null&&t.apply(this,arguments)||this}return e.prototype.walkManySep=function(r,i,n){if(r.idx===this.occurrence){var s=(0,Ut.first)(i.concat(n));this.result.isEndOfRule=s===void 0,s instanceof Dt.Terminal&&(this.result.token=s.terminalType,this.result.occurrence=s.idx)}else t.prototype.walkManySep.call(this,r,i,n)},e}(Op);Mr.NextTerminalAfterManySepWalker=nye;var sye=function(t){bc(e,t);function e(){return t!==null&&t.apply(this,arguments)||this}return e.prototype.walkAtLeastOne=function(r,i,n){if(r.idx===this.occurrence){var s=(0,Ut.first)(i.concat(n));this.result.isEndOfRule=s===void 0,s instanceof Dt.Terminal&&(this.result.token=s.terminalType,this.result.occurrence=s.idx)}else t.prototype.walkAtLeastOne.call(this,r,i,n)},e}(Op);Mr.NextTerminalAfterAtLeastOneWalker=sye;var oye=function(t){bc(e,t);function e(){return t!==null&&t.apply(this,arguments)||this}return e.prototype.walkAtLeastOneSep=function(r,i,n){if(r.idx===this.occurrence){var s=(0,Ut.first)(i.concat(n));this.result.isEndOfRule=s===void 0,s instanceof Dt.Terminal&&(this.result.token=s.terminalType,this.result.occurrence=s.idx)}else t.prototype.walkAtLeastOneSep.call(this,r,i,n)},e}(Op);Mr.NextTerminalAfterAtLeastOneSepWalker=oye;function nG(t,e,r){r===void 0&&(r=[]),r=(0,Ut.cloneArr)(r);var i=[],n=0;function s(c){return c.concat((0,Ut.drop)(t,n+1))}function o(c){var u=nG(s(c),e,r);return i.concat(u)}for(;r.length<e&&n<t.length;){var a=t[n];if(a instanceof Dt.Alternative)return o(a.definition);if(a instanceof Dt.NonTerminal)return o(a.definition);if(a instanceof Dt.Option)i=o(a.definition);else if(a instanceof Dt.RepetitionMandatory){var l=a.definition.concat([new Dt.Repetition({definition:a.definition})]);return o(l)}else if(a instanceof Dt.RepetitionMandatoryWithSeparator){var l=[new Dt.Alternative({definition:a.definition}),new Dt.Repetition({definition:[new Dt.Terminal({terminalType:a.separator})].concat(a.definition)})];return o(l)}else if(a instanceof Dt.RepetitionWithSeparator){var l=a.definition.concat([new Dt.Repetition({definition:[new Dt.Terminal({terminalType:a.separator})].concat(a.definition)})]);i=o(l)}else if(a instanceof Dt.Repetition){var l=a.definition.concat([new Dt.Repetition({definition:a.definition})]);i=o(l)}else{if(a instanceof Dt.Alternation)return(0,Ut.forEach)(a.definition,function(c){(0,Ut.isEmpty)(c.definition)===!1&&(i=o(c.definition))}),i;if(a instanceof Dt.Terminal)r.push(a.terminalType);else throw Error("non exhaustive match")}n++}return i.push({partialPath:r,suffixDef:(0,Ut.drop)(t,n)}),i}Mr.possiblePathsFrom=nG;function Aye(t,e,r,i){var n="EXIT_NONE_TERMINAL",s=[n],o="EXIT_ALTERNATIVE",a=!1,l=e.length,c=l-i-1,u=[],g=[];for(g.push({idx:-1,def:t,ruleStack:[],occurrenceStack:[]});!(0,Ut.isEmpty)(g);){var f=g.pop();if(f===o){a&&(0,Ut.last)(g).idx<=c&&g.pop();continue}var h=f.def,p=f.idx,m=f.ruleStack,y=f.occurrenceStack;if(!(0,Ut.isEmpty)(h)){var Q=h[0];if(Q===n){var S={idx:p,def:(0,Ut.drop)(h),ruleStack:(0,Ut.dropRight)(m),occurrenceStack:(0,Ut.dropRight)(y)};g.push(S)}else if(Q instanceof Dt.Terminal)if(p<l-1){var x=p+1,M=e[x];if(r(M,Q.terminalType)){var S={idx:x,def:(0,Ut.drop)(h),ruleStack:m,occurrenceStack:y};g.push(S)}}else if(p===l-1)u.push({nextTokenType:Q.terminalType,nextTokenOccurrence:Q.idx,ruleStack:m,occurrenceStack:y}),a=!0;else throw Error("non exhaustive match");else if(Q instanceof Dt.NonTerminal){var Y=(0,Ut.cloneArr)(m);Y.push(Q.nonTerminalName);var U=(0,Ut.cloneArr)(y);U.push(Q.idx);var S={idx:p,def:Q.definition.concat(s,(0,Ut.drop)(h)),ruleStack:Y,occurrenceStack:U};g.push(S)}else if(Q instanceof Dt.Option){var J={idx:p,def:(0,Ut.drop)(h),ruleStack:m,occurrenceStack:y};g.push(J),g.push(o);var W={idx:p,def:Q.definition.concat((0,Ut.drop)(h)),ruleStack:m,occurrenceStack:y};g.push(W)}else if(Q instanceof Dt.RepetitionMandatory){var ee=new Dt.Repetition({definition:Q.definition,idx:Q.idx}),Z=Q.definition.concat([ee],(0,Ut.drop)(h)),S={idx:p,def:Z,ruleStack:m,occurrenceStack:y};g.push(S)}else if(Q instanceof Dt.RepetitionMandatoryWithSeparator){var A=new Dt.Terminal({terminalType:Q.separator}),ee=new Dt.Repetition({definition:[A].concat(Q.definition),idx:Q.idx}),Z=Q.definition.concat([ee],(0,Ut.drop)(h)),S={idx:p,def:Z,ruleStack:m,occurrenceStack:y};g.push(S)}else if(Q instanceof Dt.RepetitionWithSeparator){var J={idx:p,def:(0,Ut.drop)(h),ruleStack:m,occurrenceStack:y};g.push(J),g.push(o);var A=new Dt.Terminal({terminalType:Q.separator}),ne=new Dt.Repetition({definition:[A].concat(Q.definition),idx:Q.idx}),Z=Q.definition.concat([ne],(0,Ut.drop)(h)),W={idx:p,def:Z,ruleStack:m,occurrenceStack:y};g.push(W)}else if(Q instanceof Dt.Repetition){var J={idx:p,def:(0,Ut.drop)(h),ruleStack:m,occurrenceStack:y};g.push(J),g.push(o);var ne=new Dt.Repetition({definition:Q.definition,idx:Q.idx}),Z=Q.definition.concat([ne],(0,Ut.drop)(h)),W={idx:p,def:Z,ruleStack:m,occurrenceStack:y};g.push(W)}else if(Q instanceof Dt.Alternation)for(var le=Q.definition.length-1;le>=0;le--){var Ae=Q.definition[le],T={idx:p,def:Ae.definition.concat((0,Ut.drop)(h)),ruleStack:m,occurrenceStack:y};g.push(T),g.push(o)}else if(Q instanceof Dt.Alternative)g.push({idx:p,def:Q.definition.concat((0,Ut.drop)(h)),ruleStack:m,occurrenceStack:y});else if(Q instanceof Dt.Rule)g.push(aye(Q,p,m,y));else throw Error("non exhaustive match")}}return u}Mr.nextPossibleTokensAfter=Aye;function aye(t,e,r,i){var n=(0,Ut.cloneArr)(r);n.push(t.name);var s=(0,Ut.cloneArr)(i);return s.push(1),{idx:e,def:t.definition,ruleStack:n,occurrenceStack:s}}});var Up=w(tr=>{"use strict";var sG=tr&&tr.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(tr,"__esModule",{value:!0});tr.areTokenCategoriesNotUsed=tr.isStrictPrefixOfPath=tr.containsPath=tr.getLookaheadPathsForOptionalProd=tr.getLookaheadPathsForOr=tr.lookAheadSequenceFromAlternatives=tr.buildSingleAlternativeLookaheadFunction=tr.buildAlternativesLookAheadFunc=tr.buildLookaheadFuncForOptionalProd=tr.buildLookaheadFuncForOr=tr.getProdType=tr.PROD_TYPE=void 0;var cr=Yt(),oG=Mp(),lye=zI(),ey=fg(),zA=bn(),cye=hg(),ci;(function(t){t[t.OPTION=0]="OPTION",t[t.REPETITION=1]="REPETITION",t[t.REPETITION_MANDATORY=2]="REPETITION_MANDATORY",t[t.REPETITION_MANDATORY_WITH_SEPARATOR=3]="REPETITION_MANDATORY_WITH_SEPARATOR",t[t.REPETITION_WITH_SEPARATOR=4]="REPETITION_WITH_SEPARATOR",t[t.ALTERNATION=5]="ALTERNATION"})(ci=tr.PROD_TYPE||(tr.PROD_TYPE={}));function uye(t){if(t instanceof zA.Option)return ci.OPTION;if(t instanceof zA.Repetition)return ci.REPETITION;if(t instanceof zA.RepetitionMandatory)return ci.REPETITION_MANDATORY;if(t instanceof zA.RepetitionMandatoryWithSeparator)return ci.REPETITION_MANDATORY_WITH_SEPARATOR;if(t instanceof zA.RepetitionWithSeparator)return ci.REPETITION_WITH_SEPARATOR;if(t instanceof zA.Alternation)return ci.ALTERNATION;throw Error("non exhaustive match")}tr.getProdType=uye;function gye(t,e,r,i,n,s){var o=aG(t,e,r),a=Wv(o)?ey.tokenStructuredMatcherNoCategories:ey.tokenStructuredMatcher;return s(o,i,a,n)}tr.buildLookaheadFuncForOr=gye;function fye(t,e,r,i,n,s){var o=AG(t,e,n,r),a=Wv(o)?ey.tokenStructuredMatcherNoCategories:ey.tokenStructuredMatcher;return s(o[0],a,i)}tr.buildLookaheadFuncForOptionalProd=fye;function hye(t,e,r,i){var n=t.length,s=(0,cr.every)(t,function(l){return(0,cr.every)(l,function(c){return c.length===1})});if(e)return function(l){for(var c=(0,cr.map)(l,function(x){return x.GATE}),u=0;u<n;u++){var g=t[u],f=g.length,h=c[u];if(h!==void 0&&h.call(this)===!1)continue;e:for(var p=0;p<f;p++){for(var m=g[p],y=m.length,Q=0;Q<y;Q++){var S=this.LA(Q+1);if(r(S,m[Q])===!1)continue e}return u}}};if(s&&!i){var o=(0,cr.map)(t,function(l){return(0,cr.flatten)(l)}),a=(0,cr.reduce)(o,function(l,c,u){return(0,cr.forEach)(c,function(g){(0,cr.has)(l,g.tokenTypeIdx)||(l[g.tokenTypeIdx]=u),(0,cr.forEach)(g.categoryMatches,function(f){(0,cr.has)(l,f)||(l[f]=u)})}),l},[]);return function(){var l=this.LA(1);return a[l.tokenTypeIdx]}}else return function(){for(var l=0;l<n;l++){var c=t[l],u=c.length;e:for(var g=0;g<u;g++){for(var f=c[g],h=f.length,p=0;p<h;p++){var m=this.LA(p+1);if(r(m,f[p])===!1)continue e}return l}}}}tr.buildAlternativesLookAheadFunc=hye;function pye(t,e,r){var i=(0,cr.every)(t,function(c){return c.length===1}),n=t.length;if(i&&!r){var s=(0,cr.flatten)(t);if(s.length===1&&(0,cr.isEmpty)(s[0].categoryMatches)){var o=s[0],a=o.tokenTypeIdx;return function(){return this.LA(1).tokenTypeIdx===a}}else{var l=(0,cr.reduce)(s,function(c,u,g){return c[u.tokenTypeIdx]=!0,(0,cr.forEach)(u.categoryMatches,function(f){c[f]=!0}),c},[]);return function(){var c=this.LA(1);return l[c.tokenTypeIdx]===!0}}}else return function(){e:for(var c=0;c<n;c++){for(var u=t[c],g=u.length,f=0;f<g;f++){var h=this.LA(f+1);if(e(h,u[f])===!1)continue e}return!0}return!1}}tr.buildSingleAlternativeLookaheadFunction=pye;var dye=function(t){sG(e,t);function e(r,i,n){var s=t.call(this)||this;return s.topProd=r,s.targetOccurrence=i,s.targetProdType=n,s}return e.prototype.startWalking=function(){return this.walk(this.topProd),this.restDef},e.prototype.checkIsTarget=function(r,i,n,s){return r.idx===this.targetOccurrence&&this.targetProdType===i?(this.restDef=n.concat(s),!0):!1},e.prototype.walkOption=function(r,i,n){this.checkIsTarget(r,ci.OPTION,i,n)||t.prototype.walkOption.call(this,r,i,n)},e.prototype.walkAtLeastOne=function(r,i,n){this.checkIsTarget(r,ci.REPETITION_MANDATORY,i,n)||t.prototype.walkOption.call(this,r,i,n)},e.prototype.walkAtLeastOneSep=function(r,i,n){this.checkIsTarget(r,ci.REPETITION_MANDATORY_WITH_SEPARATOR,i,n)||t.prototype.walkOption.call(this,r,i,n)},e.prototype.walkMany=function(r,i,n){this.checkIsTarget(r,ci.REPETITION,i,n)||t.prototype.walkOption.call(this,r,i,n)},e.prototype.walkManySep=function(r,i,n){this.checkIsTarget(r,ci.REPETITION_WITH_SEPARATOR,i,n)||t.prototype.walkOption.call(this,r,i,n)},e}(lye.RestWalker),lG=function(t){sG(e,t);function e(r,i,n){var s=t.call(this)||this;return s.targetOccurrence=r,s.targetProdType=i,s.targetRef=n,s.result=[],s}return e.prototype.checkIsTarget=function(r,i){r.idx===this.targetOccurrence&&this.targetProdType===i&&(this.targetRef===void 0||r===this.targetRef)&&(this.result=r.definition)},e.prototype.visitOption=function(r){this.checkIsTarget(r,ci.OPTION)},e.prototype.visitRepetition=function(r){this.checkIsTarget(r,ci.REPETITION)},e.prototype.visitRepetitionMandatory=function(r){this.checkIsTarget(r,ci.REPETITION_MANDATORY)},e.prototype.visitRepetitionMandatoryWithSeparator=function(r){this.checkIsTarget(r,ci.REPETITION_MANDATORY_WITH_SEPARATOR)},e.prototype.visitRepetitionWithSeparator=function(r){this.checkIsTarget(r,ci.REPETITION_WITH_SEPARATOR)},e.prototype.visitAlternation=function(r){this.checkIsTarget(r,ci.ALTERNATION)},e}(cye.GAstVisitor);function cG(t){for(var e=new Array(t),r=0;r<t;r++)e[r]=[];return e}function zv(t){for(var e=[""],r=0;r<t.length;r++){for(var i=t[r],n=[],s=0;s<e.length;s++){var o=e[s];n.push(o+"_"+i.tokenTypeIdx);for(var a=0;a<i.categoryMatches.length;a++){var l="_"+i.categoryMatches[a];n.push(o+l)}}e=n}return e}function Cye(t,e,r){for(var i=0;i<t.length;i++)if(i!==r)for(var n=t[i],s=0;s<e.length;s++){var o=e[s];if(n[o]===!0)return!1}return!0}function _v(t,e){for(var r=(0,cr.map)(t,function(u){return(0,oG.possiblePathsFrom)([u],1)}),i=cG(r.length),n=(0,cr.map)(r,function(u){var g={};return(0,cr.forEach)(u,function(f){var h=zv(f.partialPath);(0,cr.forEach)(h,function(p){g[p]=!0})}),g}),s=r,o=1;o<=e;o++){var a=s;s=cG(a.length);for(var l=function(u){for(var g=a[u],f=0;f<g.length;f++){var h=g[f].partialPath,p=g[f].suffixDef,m=zv(h),y=Cye(n,m,u);if(y||(0,cr.isEmpty)(p)||h.length===e){var Q=i[u];if(uG(Q,h)===!1){Q.push(h);for(var S=0;S<m.length;S++){var x=m[S];n[u][x]=!0}}}else{var M=(0,oG.possiblePathsFrom)(p,o+1,h);s[u]=s[u].concat(M),(0,cr.forEach)(M,function(Y){var U=zv(Y.partialPath);(0,cr.forEach)(U,function(J){n[u][J]=!0})})}}},c=0;c<a.length;c++)l(c)}return i}tr.lookAheadSequenceFromAlternatives=_v;function aG(t,e,r,i){var n=new lG(t,ci.ALTERNATION,i);return e.accept(n),_v(n.result,r)}tr.getLookaheadPathsForOr=aG;function AG(t,e,r,i){var n=new lG(t,r);e.accept(n);var s=n.result,o=new dye(e,t,r),a=o.startWalking(),l=new zA.Alternative({definition:s}),c=new zA.Alternative({definition:a});return _v([l,c],i)}tr.getLookaheadPathsForOptionalProd=AG;function uG(t,e){e:for(var r=0;r<t.length;r++){var i=t[r];if(i.length===e.length){for(var n=0;n<i.length;n++){var s=e[n],o=i[n],a=s===o||o.categoryMatchesMap[s.tokenTypeIdx]!==void 0;if(a===!1)continue e}return!0}}return!1}tr.containsPath=uG;function mye(t,e){return t.length<e.length&&(0,cr.every)(t,function(r,i){var n=e[i];return r===n||n.categoryMatchesMap[r.tokenTypeIdx]})}tr.isStrictPrefixOfPath=mye;function Wv(t){return(0,cr.every)(t,function(e){return(0,cr.every)(e,function(r){return(0,cr.every)(r,function(i){return(0,cr.isEmpty)(i.categoryMatches)})})})}tr.areTokenCategoriesNotUsed=Wv});var tS=w(Xt=>{"use strict";var Vv=Xt&&Xt.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(Xt,"__esModule",{value:!0});Xt.checkPrefixAlternativesAmbiguities=Xt.validateSomeNonEmptyLookaheadPath=Xt.validateTooManyAlts=Xt.RepetionCollector=Xt.validateAmbiguousAlternationAlternatives=Xt.validateEmptyOrAlternative=Xt.getFirstNoneTerminal=Xt.validateNoLeftRecursion=Xt.validateRuleIsOverridden=Xt.validateRuleDoesNotAlreadyExist=Xt.OccurrenceValidationCollector=Xt.identifyProductionForDuplicates=Xt.validateGrammar=void 0;var nr=Yt(),xr=Yt(),Uo=Xn(),Xv=Lp(),dg=Up(),Eye=Mp(),Ao=bn(),Zv=hg();function wye(t,e,r,i,n){var s=nr.map(t,function(h){return Iye(h,i)}),o=nr.map(t,function(h){return $v(h,h,i)}),a=[],l=[],c=[];(0,xr.every)(o,xr.isEmpty)&&(a=(0,xr.map)(t,function(h){return fG(h,i)}),l=(0,xr.map)(t,function(h){return hG(h,e,i)}),c=dG(t,e,i));var u=yye(t,r,i),g=(0,xr.map)(t,function(h){return pG(h,i)}),f=(0,xr.map)(t,function(h){return gG(h,t,n,i)});return nr.flatten(s.concat(c,o,a,l,u,g,f))}Xt.validateGrammar=wye;function Iye(t,e){var r=new EG;t.accept(r);var i=r.allProductions,n=nr.groupBy(i,CG),s=nr.pick(n,function(a){return a.length>1}),o=nr.map(nr.values(s),function(a){var l=nr.first(a),c=e.buildDuplicateFoundError(t,a),u=(0,Xv.getProductionDslName)(l),g={message:c,type:Uo.ParserDefinitionErrorType.DUPLICATE_PRODUCTIONS,ruleName:t.name,dslName:u,occurrence:l.idx},f=mG(l);return f&&(g.parameter=f),g});return o}function CG(t){return(0,Xv.getProductionDslName)(t)+"_#_"+t.idx+"_#_"+mG(t)}Xt.identifyProductionForDuplicates=CG;function mG(t){return t instanceof Ao.Terminal?t.terminalType.name:t instanceof Ao.NonTerminal?t.nonTerminalName:""}var EG=function(t){Vv(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.allProductions=[],r}return e.prototype.visitNonTerminal=function(r){this.allProductions.push(r)},e.prototype.visitOption=function(r){this.allProductions.push(r)},e.prototype.visitRepetitionWithSeparator=function(r){this.allProductions.push(r)},e.prototype.visitRepetitionMandatory=function(r){this.allProductions.push(r)},e.prototype.visitRepetitionMandatoryWithSeparator=function(r){this.allProductions.push(r)},e.prototype.visitRepetition=function(r){this.allProductions.push(r)},e.prototype.visitAlternation=function(r){this.allProductions.push(r)},e.prototype.visitTerminal=function(r){this.allProductions.push(r)},e}(Zv.GAstVisitor);Xt.OccurrenceValidationCollector=EG;function gG(t,e,r,i){var n=[],s=(0,xr.reduce)(e,function(a,l){return l.name===t.name?a+1:a},0);if(s>1){var o=i.buildDuplicateRuleNameError({topLevelRule:t,grammarName:r});n.push({message:o,type:Uo.ParserDefinitionErrorType.DUPLICATE_RULE_NAME,ruleName:t.name})}return n}Xt.validateRuleDoesNotAlreadyExist=gG;function Bye(t,e,r){var i=[],n;return nr.contains(e,t)||(n="Invalid rule override, rule: ->"+t+"<- cannot be overridden in the grammar: ->"+r+"<-as it is not defined in any of the super grammars ",i.push({message:n,type:Uo.ParserDefinitionErrorType.INVALID_RULE_OVERRIDE,ruleName:t})),i}Xt.validateRuleIsOverridden=Bye;function $v(t,e,r,i){i===void 0&&(i=[]);var n=[],s=Kp(e.definition);if(nr.isEmpty(s))return[];var o=t.name,a=nr.contains(s,t);a&&n.push({message:r.buildLeftRecursionError({topLevelRule:t,leftRecursionPath:i}),type:Uo.ParserDefinitionErrorType.LEFT_RECURSION,ruleName:o});var l=nr.difference(s,i.concat([t])),c=nr.map(l,function(u){var g=nr.cloneArr(i);return g.push(u),$v(t,u,r,g)});return n.concat(nr.flatten(c))}Xt.validateNoLeftRecursion=$v;function Kp(t){var e=[];if(nr.isEmpty(t))return e;var r=nr.first(t);if(r instanceof Ao.NonTerminal)e.push(r.referencedRule);else if(r instanceof Ao.Alternative||r instanceof Ao.Option||r instanceof Ao.RepetitionMandatory||r instanceof Ao.RepetitionMandatoryWithSeparator||r instanceof Ao.RepetitionWithSeparator||r instanceof Ao.Repetition)e=e.concat(Kp(r.definition));else if(r instanceof Ao.Alternation)e=nr.flatten(nr.map(r.definition,function(o){return Kp(o.definition)}));else if(!(r instanceof Ao.Terminal))throw Error("non exhaustive match");var i=(0,Xv.isOptionalProd)(r),n=t.length>1;if(i&&n){var s=nr.drop(t);return e.concat(Kp(s))}else return e}Xt.getFirstNoneTerminal=Kp;var eS=function(t){Vv(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.alternations=[],r}return e.prototype.visitAlternation=function(r){this.alternations.push(r)},e}(Zv.GAstVisitor);function fG(t,e){var r=new eS;t.accept(r);var i=r.alternations,n=nr.reduce(i,function(s,o){var a=nr.dropRight(o.definition),l=nr.map(a,function(c,u){var g=(0,Eye.nextPossibleTokensAfter)([c],[],null,1);return nr.isEmpty(g)?{message:e.buildEmptyAlternationError({topLevelRule:t,alternation:o,emptyChoiceIdx:u}),type:Uo.ParserDefinitionErrorType.NONE_LAST_EMPTY_ALT,ruleName:t.name,occurrence:o.idx,alternative:u+1}:null});return s.concat(nr.compact(l))},[]);return n}Xt.validateEmptyOrAlternative=fG;function hG(t,e,r){var i=new eS;t.accept(i);var n=i.alternations;n=(0,xr.reject)(n,function(o){return o.ignoreAmbiguities===!0});var s=nr.reduce(n,function(o,a){var l=a.idx,c=a.maxLookahead||e,u=(0,dg.getLookaheadPathsForOr)(l,t,c,a),g=bye(u,a,t,r),f=IG(u,a,t,r);return o.concat(g,f)},[]);return s}Xt.validateAmbiguousAlternationAlternatives=hG;var yG=function(t){Vv(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.allProductions=[],r}return e.prototype.visitRepetitionWithSeparator=function(r){this.allProductions.push(r)},e.prototype.visitRepetitionMandatory=function(r){this.allProductions.push(r)},e.prototype.visitRepetitionMandatoryWithSeparator=function(r){this.allProductions.push(r)},e.prototype.visitRepetition=function(r){this.allProductions.push(r)},e}(Zv.GAstVisitor);Xt.RepetionCollector=yG;function pG(t,e){var r=new eS;t.accept(r);var i=r.alternations,n=nr.reduce(i,function(s,o){return o.definition.length>255&&s.push({message:e.buildTooManyAlternativesError({topLevelRule:t,alternation:o}),type:Uo.ParserDefinitionErrorType.TOO_MANY_ALTS,ruleName:t.name,occurrence:o.idx}),s},[]);return n}Xt.validateTooManyAlts=pG;function dG(t,e,r){var i=[];return(0,xr.forEach)(t,function(n){var s=new yG;n.accept(s);var o=s.allProductions;(0,xr.forEach)(o,function(a){var l=(0,dg.getProdType)(a),c=a.maxLookahead||e,u=a.idx,g=(0,dg.getLookaheadPathsForOptionalProd)(u,n,l,c),f=g[0];if((0,xr.isEmpty)((0,xr.flatten)(f))){var h=r.buildEmptyRepetitionError({topLevelRule:n,repetition:a});i.push({message:h,type:Uo.ParserDefinitionErrorType.NO_NON_EMPTY_LOOKAHEAD,ruleName:n.name})}})}),i}Xt.validateSomeNonEmptyLookaheadPath=dG;function bye(t,e,r,i){var n=[],s=(0,xr.reduce)(t,function(a,l,c){return e.definition[c].ignoreAmbiguities===!0||(0,xr.forEach)(l,function(u){var g=[c];(0,xr.forEach)(t,function(f,h){c!==h&&(0,dg.containsPath)(f,u)&&e.definition[h].ignoreAmbiguities!==!0&&g.push(h)}),g.length>1&&!(0,dg.containsPath)(n,u)&&(n.push(u),a.push({alts:g,path:u}))}),a},[]),o=nr.map(s,function(a){var l=(0,xr.map)(a.alts,function(u){return u+1}),c=i.buildAlternationAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:l,prefixPath:a.path});return{message:c,type:Uo.ParserDefinitionErrorType.AMBIGUOUS_ALTS,ruleName:r.name,occurrence:e.idx,alternatives:[a.alts]}});return o}function IG(t,e,r,i){var n=[],s=(0,xr.reduce)(t,function(o,a,l){var c=(0,xr.map)(a,function(u){return{idx:l,path:u}});return o.concat(c)},[]);return(0,xr.forEach)(s,function(o){var a=e.definition[o.idx];if(a.ignoreAmbiguities!==!0){var l=o.idx,c=o.path,u=(0,xr.findAll)(s,function(f){return e.definition[f.idx].ignoreAmbiguities!==!0&&f.idx<l&&(0,dg.isStrictPrefixOfPath)(f.path,c)}),g=(0,xr.map)(u,function(f){var h=[f.idx+1,l+1],p=e.idx===0?"":e.idx,m=i.buildAlternationPrefixAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:h,prefixPath:f.path});return{message:m,type:Uo.ParserDefinitionErrorType.AMBIGUOUS_PREFIX_ALTS,ruleName:r.name,occurrence:p,alternatives:h}});n=n.concat(g)}}),n}Xt.checkPrefixAlternativesAmbiguities=IG;function yye(t,e,r){var i=[],n=(0,xr.map)(e,function(s){return s.name});return(0,xr.forEach)(t,function(s){var o=s.name;if((0,xr.contains)(n,o)){var a=r.buildNamespaceConflictError(s);i.push({message:a,type:Uo.ParserDefinitionErrorType.CONFLICT_TOKENS_RULES_NAMESPACE,ruleName:o})}}),i}});var BG=w(Cg=>{"use strict";Object.defineProperty(Cg,"__esModule",{value:!0});Cg.validateGrammar=Cg.resolveGrammar=void 0;var rS=Yt(),Qye=tG(),vye=tS(),wG=Tp();function Sye(t){t=(0,rS.defaults)(t,{errMsgProvider:wG.defaultGrammarResolverErrorProvider});var e={};return(0,rS.forEach)(t.rules,function(r){e[r.name]=r}),(0,Qye.resolveGrammar)(e,t.errMsgProvider)}Cg.resolveGrammar=Sye;function kye(t){return t=(0,rS.defaults)(t,{errMsgProvider:wG.defaultGrammarValidatorErrorProvider}),(0,vye.validateGrammar)(t.rules,t.maxLookahead,t.tokenTypes,t.errMsgProvider,t.grammarName)}Cg.validateGrammar=kye});var mg=w(vn=>{"use strict";var Hp=vn&&vn.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(vn,"__esModule",{value:!0});vn.EarlyExitException=vn.NotAllInputParsedException=vn.NoViableAltException=vn.MismatchedTokenException=vn.isRecognitionException=void 0;var xye=Yt(),bG="MismatchedTokenException",QG="NoViableAltException",vG="EarlyExitException",SG="NotAllInputParsedException",kG=[bG,QG,vG,SG];Object.freeze(kG);function Pye(t){return(0,xye.contains)(kG,t.name)}vn.isRecognitionException=Pye;var ty=function(t){Hp(e,t);function e(r,i){var n=this.constructor,s=t.call(this,r)||this;return s.token=i,s.resyncedTokens=[],Object.setPrototypeOf(s,n.prototype),Error.captureStackTrace&&Error.captureStackTrace(s,s.constructor),s}return e}(Error),Dye=function(t){Hp(e,t);function e(r,i,n){var s=t.call(this,r,i)||this;return s.previousToken=n,s.name=bG,s}return e}(ty);vn.MismatchedTokenException=Dye;var Rye=function(t){Hp(e,t);function e(r,i,n){var s=t.call(this,r,i)||this;return s.previousToken=n,s.name=QG,s}return e}(ty);vn.NoViableAltException=Rye;var Fye=function(t){Hp(e,t);function e(r,i){var n=t.call(this,r,i)||this;return n.name=SG,n}return e}(ty);vn.NotAllInputParsedException=Fye;var Nye=function(t){Hp(e,t);function e(r,i,n){var s=t.call(this,r,i)||this;return s.previousToken=n,s.name=vG,s}return e}(ty);vn.EarlyExitException=Nye});var nS=w(Yi=>{"use strict";Object.defineProperty(Yi,"__esModule",{value:!0});Yi.attemptInRepetitionRecovery=Yi.Recoverable=Yi.InRuleRecoveryException=Yi.IN_RULE_RECOVERY_EXCEPTION=Yi.EOF_FOLLOW_KEY=void 0;var ry=JA(),vs=Yt(),Lye=mg(),Tye=qv(),Oye=Xn();Yi.EOF_FOLLOW_KEY={};Yi.IN_RULE_RECOVERY_EXCEPTION="InRuleRecoveryException";function iS(t){this.name=Yi.IN_RULE_RECOVERY_EXCEPTION,this.message=t}Yi.InRuleRecoveryException=iS;iS.prototype=Error.prototype;var Mye=function(){function t(){}return t.prototype.initRecoverable=function(e){this.firstAfterRepMap={},this.resyncFollows={},this.recoveryEnabled=(0,vs.has)(e,"recoveryEnabled")?e.recoveryEnabled:Oye.DEFAULT_PARSER_CONFIG.recoveryEnabled,this.recoveryEnabled&&(this.attemptInRepetitionRecovery=xG)},t.prototype.getTokenToInsert=function(e){var r=(0,ry.createTokenInstance)(e,"",NaN,NaN,NaN,NaN,NaN,NaN);return r.isInsertedInRecovery=!0,r},t.prototype.canTokenTypeBeInsertedInRecovery=function(e){return!0},t.prototype.tryInRepetitionRecovery=function(e,r,i,n){for(var s=this,o=this.findReSyncTokenType(),a=this.exportLexerState(),l=[],c=!1,u=this.LA(1),g=this.LA(1),f=function(){var h=s.LA(0),p=s.errorMessageProvider.buildMismatchTokenMessage({expected:n,actual:u,previous:h,ruleName:s.getCurrRuleFullName()}),m=new Lye.MismatchedTokenException(p,u,s.LA(0));m.resyncedTokens=(0,vs.dropRight)(l),s.SAVE_ERROR(m)};!c;)if(this.tokenMatcher(g,n)){f();return}else if(i.call(this)){f(),e.apply(this,r);return}else this.tokenMatcher(g,o)?c=!0:(g=this.SKIP_TOKEN(),this.addToResyncTokens(g,l));this.importLexerState(a)},t.prototype.shouldInRepetitionRecoveryBeTried=function(e,r,i){return!(i===!1||e===void 0||r===void 0||this.tokenMatcher(this.LA(1),e)||this.isBackTracking()||this.canPerformInRuleRecovery(e,this.getFollowsForInRuleRecovery(e,r)))},t.prototype.getFollowsForInRuleRecovery=function(e,r){var i=this.getCurrentGrammarPath(e,r),n=this.getNextPossibleTokenTypes(i);return n},t.prototype.tryInRuleRecovery=function(e,r){if(this.canRecoverWithSingleTokenInsertion(e,r)){var i=this.getTokenToInsert(e);return i}if(this.canRecoverWithSingleTokenDeletion(e)){var n=this.SKIP_TOKEN();return this.consumeToken(),n}throw new iS("sad sad panda")},t.prototype.canPerformInRuleRecovery=function(e,r){return this.canRecoverWithSingleTokenInsertion(e,r)||this.canRecoverWithSingleTokenDeletion(e)},t.prototype.canRecoverWithSingleTokenInsertion=function(e,r){var i=this;if(!this.canTokenTypeBeInsertedInRecovery(e)||(0,vs.isEmpty)(r))return!1;var n=this.LA(1),s=(0,vs.find)(r,function(o){return i.tokenMatcher(n,o)})!==void 0;return s},t.prototype.canRecoverWithSingleTokenDeletion=function(e){var r=this.tokenMatcher(this.LA(2),e);return r},t.prototype.isInCurrentRuleReSyncSet=function(e){var r=this.getCurrFollowKey(),i=this.getFollowSetFromFollowKey(r);return(0,vs.contains)(i,e)},t.prototype.findReSyncTokenType=function(){for(var e=this.flattenFollowSet(),r=this.LA(1),i=2;;){var n=r.tokenType;if((0,vs.contains)(e,n))return n;r=this.LA(i),i++}},t.prototype.getCurrFollowKey=function(){if(this.RULE_STACK.length===1)return Yi.EOF_FOLLOW_KEY;var e=this.getLastExplicitRuleShortName(),r=this.getLastExplicitRuleOccurrenceIndex(),i=this.getPreviousExplicitRuleShortName();return{ruleName:this.shortRuleNameToFullName(e),idxInCallingRule:r,inRule:this.shortRuleNameToFullName(i)}},t.prototype.buildFullFollowKeyStack=function(){var e=this,r=this.RULE_STACK,i=this.RULE_OCCURRENCE_STACK;return(0,vs.map)(r,function(n,s){return s===0?Yi.EOF_FOLLOW_KEY:{ruleName:e.shortRuleNameToFullName(n),idxInCallingRule:i[s],inRule:e.shortRuleNameToFullName(r[s-1])}})},t.prototype.flattenFollowSet=function(){var e=this,r=(0,vs.map)(this.buildFullFollowKeyStack(),function(i){return e.getFollowSetFromFollowKey(i)});return(0,vs.flatten)(r)},t.prototype.getFollowSetFromFollowKey=function(e){if(e===Yi.EOF_FOLLOW_KEY)return[ry.EOF];var r=e.ruleName+e.idxInCallingRule+Tye.IN+e.inRule;return this.resyncFollows[r]},t.prototype.addToResyncTokens=function(e,r){return this.tokenMatcher(e,ry.EOF)||r.push(e),r},t.prototype.reSyncTo=function(e){for(var r=[],i=this.LA(1);this.tokenMatcher(i,e)===!1;)i=this.SKIP_TOKEN(),this.addToResyncTokens(i,r);return(0,vs.dropRight)(r)},t.prototype.attemptInRepetitionRecovery=function(e,r,i,n,s,o,a){},t.prototype.getCurrentGrammarPath=function(e,r){var i=this.getHumanReadableRuleStack(),n=(0,vs.cloneArr)(this.RULE_OCCURRENCE_STACK),s={ruleStack:i,occurrenceStack:n,lastTok:e,lastTokOccurrence:r};return s},t.prototype.getHumanReadableRuleStack=function(){var e=this;return(0,vs.map)(this.RULE_STACK,function(r){return e.shortRuleNameToFullName(r)})},t}();Yi.Recoverable=Mye;function xG(t,e,r,i,n,s,o){var a=this.getKeyForAutomaticLookahead(i,n),l=this.firstAfterRepMap[a];if(l===void 0){var c=this.getCurrRuleFullName(),u=this.getGAstProductions()[c],g=new s(u,n);l=g.startWalking(),this.firstAfterRepMap[a]=l}var f=l.token,h=l.occurrence,p=l.isEndOfRule;this.RULE_STACK.length===1&&p&&f===void 0&&(f=ry.EOF,h=1),this.shouldInRepetitionRecoveryBeTried(f,h,o)&&this.tryInRepetitionRecovery(t,e,r,f)}Yi.attemptInRepetitionRecovery=xG});var iy=w(Jt=>{"use strict";Object.defineProperty(Jt,"__esModule",{value:!0});Jt.getKeyForAutomaticLookahead=Jt.AT_LEAST_ONE_SEP_IDX=Jt.MANY_SEP_IDX=Jt.AT_LEAST_ONE_IDX=Jt.MANY_IDX=Jt.OPTION_IDX=Jt.OR_IDX=Jt.BITS_FOR_ALT_IDX=Jt.BITS_FOR_RULE_IDX=Jt.BITS_FOR_OCCURRENCE_IDX=Jt.BITS_FOR_METHOD_TYPE=void 0;Jt.BITS_FOR_METHOD_TYPE=4;Jt.BITS_FOR_OCCURRENCE_IDX=8;Jt.BITS_FOR_RULE_IDX=12;Jt.BITS_FOR_ALT_IDX=8;Jt.OR_IDX=1<<Jt.BITS_FOR_OCCURRENCE_IDX;Jt.OPTION_IDX=2<<Jt.BITS_FOR_OCCURRENCE_IDX;Jt.MANY_IDX=3<<Jt.BITS_FOR_OCCURRENCE_IDX;Jt.AT_LEAST_ONE_IDX=4<<Jt.BITS_FOR_OCCURRENCE_IDX;Jt.MANY_SEP_IDX=5<<Jt.BITS_FOR_OCCURRENCE_IDX;Jt.AT_LEAST_ONE_SEP_IDX=6<<Jt.BITS_FOR_OCCURRENCE_IDX;function Uye(t,e,r){return r|e|t}Jt.getKeyForAutomaticLookahead=Uye;var Rtt=32-Jt.BITS_FOR_ALT_IDX});var DG=w(ny=>{"use strict";Object.defineProperty(ny,"__esModule",{value:!0});ny.LooksAhead=void 0;var Ha=Up(),lo=Yt(),PG=Xn(),ja=iy(),Qc=Lp(),Kye=function(){function t(){}return t.prototype.initLooksAhead=function(e){this.dynamicTokensEnabled=(0,lo.has)(e,"dynamicTokensEnabled")?e.dynamicTokensEnabled:PG.DEFAULT_PARSER_CONFIG.dynamicTokensEnabled,this.maxLookahead=(0,lo.has)(e,"maxLookahead")?e.maxLookahead:PG.DEFAULT_PARSER_CONFIG.maxLookahead,this.lookAheadFuncsCache=(0,lo.isES2015MapSupported)()?new Map:[],(0,lo.isES2015MapSupported)()?(this.getLaFuncFromCache=this.getLaFuncFromMap,this.setLaFuncCache=this.setLaFuncCacheUsingMap):(this.getLaFuncFromCache=this.getLaFuncFromObj,this.setLaFuncCache=this.setLaFuncUsingObj)},t.prototype.preComputeLookaheadFunctions=function(e){var r=this;(0,lo.forEach)(e,function(i){r.TRACE_INIT(i.name+" Rule Lookahead",function(){var n=(0,Qc.collectMethods)(i),s=n.alternation,o=n.repetition,a=n.option,l=n.repetitionMandatory,c=n.repetitionMandatoryWithSeparator,u=n.repetitionWithSeparator;(0,lo.forEach)(s,function(g){var f=g.idx===0?"":g.idx;r.TRACE_INIT(""+(0,Qc.getProductionDslName)(g)+f,function(){var h=(0,Ha.buildLookaheadFuncForOr)(g.idx,i,g.maxLookahead||r.maxLookahead,g.hasPredicates,r.dynamicTokensEnabled,r.lookAheadBuilderForAlternatives),p=(0,ja.getKeyForAutomaticLookahead)(r.fullRuleNameToShort[i.name],ja.OR_IDX,g.idx);r.setLaFuncCache(p,h)})}),(0,lo.forEach)(o,function(g){r.computeLookaheadFunc(i,g.idx,ja.MANY_IDX,Ha.PROD_TYPE.REPETITION,g.maxLookahead,(0,Qc.getProductionDslName)(g))}),(0,lo.forEach)(a,function(g){r.computeLookaheadFunc(i,g.idx,ja.OPTION_IDX,Ha.PROD_TYPE.OPTION,g.maxLookahead,(0,Qc.getProductionDslName)(g))}),(0,lo.forEach)(l,function(g){r.computeLookaheadFunc(i,g.idx,ja.AT_LEAST_ONE_IDX,Ha.PROD_TYPE.REPETITION_MANDATORY,g.maxLookahead,(0,Qc.getProductionDslName)(g))}),(0,lo.forEach)(c,function(g){r.computeLookaheadFunc(i,g.idx,ja.AT_LEAST_ONE_SEP_IDX,Ha.PROD_TYPE.REPETITION_MANDATORY_WITH_SEPARATOR,g.maxLookahead,(0,Qc.getProductionDslName)(g))}),(0,lo.forEach)(u,function(g){r.computeLookaheadFunc(i,g.idx,ja.MANY_SEP_IDX,Ha.PROD_TYPE.REPETITION_WITH_SEPARATOR,g.maxLookahead,(0,Qc.getProductionDslName)(g))})})})},t.prototype.computeLookaheadFunc=function(e,r,i,n,s,o){var a=this;this.TRACE_INIT(""+o+(r===0?"":r),function(){var l=(0,Ha.buildLookaheadFuncForOptionalProd)(r,e,s||a.maxLookahead,a.dynamicTokensEnabled,n,a.lookAheadBuilderForOptional),c=(0,ja.getKeyForAutomaticLookahead)(a.fullRuleNameToShort[e.name],i,r);a.setLaFuncCache(c,l)})},t.prototype.lookAheadBuilderForOptional=function(e,r,i){return(0,Ha.buildSingleAlternativeLookaheadFunction)(e,r,i)},t.prototype.lookAheadBuilderForAlternatives=function(e,r,i,n){return(0,Ha.buildAlternativesLookAheadFunc)(e,r,i,n)},t.prototype.getKeyForAutomaticLookahead=function(e,r){var i=this.getLastExplicitRuleShortName();return(0,ja.getKeyForAutomaticLookahead)(i,e,r)},t.prototype.getLaFuncFromCache=function(e){},t.prototype.getLaFuncFromMap=function(e){return this.lookAheadFuncsCache.get(e)},t.prototype.getLaFuncFromObj=function(e){return this.lookAheadFuncsCache[e]},t.prototype.setLaFuncCache=function(e,r){},t.prototype.setLaFuncCacheUsingMap=function(e,r){this.lookAheadFuncsCache.set(e,r)},t.prototype.setLaFuncUsingObj=function(e,r){this.lookAheadFuncsCache[e]=r},t}();ny.LooksAhead=Kye});var RG=w(Ko=>{"use strict";Object.defineProperty(Ko,"__esModule",{value:!0});Ko.addNoneTerminalToCst=Ko.addTerminalToCst=Ko.setNodeLocationFull=Ko.setNodeLocationOnlyOffset=void 0;function Hye(t,e){isNaN(t.startOffset)===!0?(t.startOffset=e.startOffset,t.endOffset=e.endOffset):t.endOffset<e.endOffset&&(t.endOffset=e.endOffset)}Ko.setNodeLocationOnlyOffset=Hye;function jye(t,e){isNaN(t.startOffset)===!0?(t.startOffset=e.startOffset,t.startColumn=e.startColumn,t.startLine=e.startLine,t.endOffset=e.endOffset,t.endColumn=e.endColumn,t.endLine=e.endLine):t.endOffset<e.endOffset&&(t.endOffset=e.endOffset,t.endColumn=e.endColumn,t.endLine=e.endLine)}Ko.setNodeLocationFull=jye;function Gye(t,e,r){t.children[r]===void 0?t.children[r]=[e]:t.children[r].push(e)}Ko.addTerminalToCst=Gye;function Yye(t,e,r){t.children[e]===void 0?t.children[e]=[r]:t.children[e].push(r)}Ko.addNoneTerminalToCst=Yye});var sS=w(_A=>{"use strict";Object.defineProperty(_A,"__esModule",{value:!0});_A.defineNameProp=_A.functionName=_A.classNameFromInstance=void 0;var qye=Yt();function Jye(t){return FG(t.constructor)}_A.classNameFromInstance=Jye;var NG="name";function FG(t){var e=t.name;return e||"anonymous"}_A.functionName=FG;function Wye(t,e){var r=Object.getOwnPropertyDescriptor(t,NG);return(0,qye.isUndefined)(r)||r.configurable?(Object.defineProperty(t,NG,{enumerable:!1,configurable:!0,writable:!1,value:e}),!0):!1}_A.defineNameProp=Wye});var UG=w(Pi=>{"use strict";Object.defineProperty(Pi,"__esModule",{value:!0});Pi.validateRedundantMethods=Pi.validateMissingCstMethods=Pi.validateVisitor=Pi.CstVisitorDefinitionError=Pi.createBaseVisitorConstructorWithDefaults=Pi.createBaseSemanticVisitorConstructor=Pi.defaultVisit=void 0;var Ss=Yt(),jp=sS();function LG(t,e){for(var r=(0,Ss.keys)(t),i=r.length,n=0;n<i;n++)for(var s=r[n],o=t[s],a=o.length,l=0;l<a;l++){var c=o[l];c.tokenTypeIdx===void 0&&this[c.name](c.children,e)}}Pi.defaultVisit=LG;function zye(t,e){var r=function(){};(0,jp.defineNameProp)(r,t+"BaseSemantics");var i={visit:function(n,s){if((0,Ss.isArray)(n)&&(n=n[0]),!(0,Ss.isUndefined)(n))return this[n.name](n.children,s)},validateVisitor:function(){var n=TG(this,e);if(!(0,Ss.isEmpty)(n)){var s=(0,Ss.map)(n,function(o){return o.msg});throw Error("Errors Detected in CST Visitor <"+(0,jp.functionName)(this.constructor)+`>:
+       `+(""+s.join(`
+
+`).replace(/\n/g,`
+       `)))}}};return r.prototype=i,r.prototype.constructor=r,r._RULE_NAMES=e,r}Pi.createBaseSemanticVisitorConstructor=zye;function _ye(t,e,r){var i=function(){};(0,jp.defineNameProp)(i,t+"BaseSemanticsWithDefaults");var n=Object.create(r.prototype);return(0,Ss.forEach)(e,function(s){n[s]=LG}),i.prototype=n,i.prototype.constructor=i,i}Pi.createBaseVisitorConstructorWithDefaults=_ye;var oS;(function(t){t[t.REDUNDANT_METHOD=0]="REDUNDANT_METHOD",t[t.MISSING_METHOD=1]="MISSING_METHOD"})(oS=Pi.CstVisitorDefinitionError||(Pi.CstVisitorDefinitionError={}));function TG(t,e){var r=OG(t,e),i=MG(t,e);return r.concat(i)}Pi.validateVisitor=TG;function OG(t,e){var r=(0,Ss.map)(e,function(i){if(!(0,Ss.isFunction)(t[i]))return{msg:"Missing visitor method: <"+i+"> on "+(0,jp.functionName)(t.constructor)+" CST Visitor.",type:oS.MISSING_METHOD,methodName:i}});return(0,Ss.compact)(r)}Pi.validateMissingCstMethods=OG;var Vye=["constructor","visit","validateVisitor"];function MG(t,e){var r=[];for(var i in t)(0,Ss.isFunction)(t[i])&&!(0,Ss.contains)(Vye,i)&&!(0,Ss.contains)(e,i)&&r.push({msg:"Redundant visitor method: <"+i+"> on "+(0,jp.functionName)(t.constructor)+` CST Visitor
+There is no Grammar Rule corresponding to this method's name.
+`,type:oS.REDUNDANT_METHOD,methodName:i});return r}Pi.validateRedundantMethods=MG});var HG=w(sy=>{"use strict";Object.defineProperty(sy,"__esModule",{value:!0});sy.TreeBuilder=void 0;var Eg=RG(),ii=Yt(),KG=UG(),Xye=Xn(),Zye=function(){function t(){}return t.prototype.initTreeBuilder=function(e){if(this.CST_STACK=[],this.outputCst=e.outputCst,this.nodeLocationTracking=(0,ii.has)(e,"nodeLocationTracking")?e.nodeLocationTracking:Xye.DEFAULT_PARSER_CONFIG.nodeLocationTracking,!this.outputCst)this.cstInvocationStateUpdate=ii.NOOP,this.cstFinallyStateUpdate=ii.NOOP,this.cstPostTerminal=ii.NOOP,this.cstPostNonTerminal=ii.NOOP,this.cstPostRule=ii.NOOP;else if(/full/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=Eg.setNodeLocationFull,this.setNodeLocationFromNode=Eg.setNodeLocationFull,this.cstPostRule=ii.NOOP,this.setInitialNodeLocation=this.setInitialNodeLocationFullRecovery):(this.setNodeLocationFromToken=ii.NOOP,this.setNodeLocationFromNode=ii.NOOP,this.cstPostRule=this.cstPostRuleFull,this.setInitialNodeLocation=this.setInitialNodeLocationFullRegular);else if(/onlyOffset/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=Eg.setNodeLocationOnlyOffset,this.setNodeLocationFromNode=Eg.setNodeLocationOnlyOffset,this.cstPostRule=ii.NOOP,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRecovery):(this.setNodeLocationFromToken=ii.NOOP,this.setNodeLocationFromNode=ii.NOOP,this.cstPostRule=this.cstPostRuleOnlyOffset,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRegular);else if(/none/i.test(this.nodeLocationTracking))this.setNodeLocationFromToken=ii.NOOP,this.setNodeLocationFromNode=ii.NOOP,this.cstPostRule=ii.NOOP,this.setInitialNodeLocation=ii.NOOP;else throw Error('Invalid <nodeLocationTracking> config option: "'+e.nodeLocationTracking+'"')},t.prototype.setInitialNodeLocationOnlyOffsetRecovery=function(e){e.location={startOffset:NaN,endOffset:NaN}},t.prototype.setInitialNodeLocationOnlyOffsetRegular=function(e){e.location={startOffset:this.LA(1).startOffset,endOffset:NaN}},t.prototype.setInitialNodeLocationFullRecovery=function(e){e.location={startOffset:NaN,startLine:NaN,startColumn:NaN,endOffset:NaN,endLine:NaN,endColumn:NaN}},t.prototype.setInitialNodeLocationFullRegular=function(e){var r=this.LA(1);e.location={startOffset:r.startOffset,startLine:r.startLine,startColumn:r.startColumn,endOffset:NaN,endLine:NaN,endColumn:NaN}},t.prototype.cstInvocationStateUpdate=function(e,r){var i={name:e,children:{}};this.setInitialNodeLocation(i),this.CST_STACK.push(i)},t.prototype.cstFinallyStateUpdate=function(){this.CST_STACK.pop()},t.prototype.cstPostRuleFull=function(e){var r=this.LA(0),i=e.location;i.startOffset<=r.startOffset?(i.endOffset=r.endOffset,i.endLine=r.endLine,i.endColumn=r.endColumn):(i.startOffset=NaN,i.startLine=NaN,i.startColumn=NaN)},t.prototype.cstPostRuleOnlyOffset=function(e){var r=this.LA(0),i=e.location;i.startOffset<=r.startOffset?i.endOffset=r.endOffset:i.startOffset=NaN},t.prototype.cstPostTerminal=function(e,r){var i=this.CST_STACK[this.CST_STACK.length-1];(0,Eg.addTerminalToCst)(i,r,e),this.setNodeLocationFromToken(i.location,r)},t.prototype.cstPostNonTerminal=function(e,r){var i=this.CST_STACK[this.CST_STACK.length-1];(0,Eg.addNoneTerminalToCst)(i,r,e),this.setNodeLocationFromNode(i.location,e.location)},t.prototype.getBaseCstVisitorConstructor=function(){if((0,ii.isUndefined)(this.baseCstVisitorConstructor)){var e=(0,KG.createBaseSemanticVisitorConstructor)(this.className,(0,ii.keys)(this.gastProductionsCache));return this.baseCstVisitorConstructor=e,e}return this.baseCstVisitorConstructor},t.prototype.getBaseCstVisitorConstructorWithDefaults=function(){if((0,ii.isUndefined)(this.baseCstVisitorWithDefaultsConstructor)){var e=(0,KG.createBaseVisitorConstructorWithDefaults)(this.className,(0,ii.keys)(this.gastProductionsCache),this.getBaseCstVisitorConstructor());return this.baseCstVisitorWithDefaultsConstructor=e,e}return this.baseCstVisitorWithDefaultsConstructor},t.prototype.getLastExplicitRuleShortName=function(){var e=this.RULE_STACK;return e[e.length-1]},t.prototype.getPreviousExplicitRuleShortName=function(){var e=this.RULE_STACK;return e[e.length-2]},t.prototype.getLastExplicitRuleOccurrenceIndex=function(){var e=this.RULE_OCCURRENCE_STACK;return e[e.length-1]},t}();sy.TreeBuilder=Zye});var GG=w(oy=>{"use strict";Object.defineProperty(oy,"__esModule",{value:!0});oy.LexerAdapter=void 0;var jG=Xn(),$ye=function(){function t(){}return t.prototype.initLexerAdapter=function(){this.tokVector=[],this.tokVectorLength=0,this.currIdx=-1},Object.defineProperty(t.prototype,"input",{get:function(){return this.tokVector},set:function(e){if(this.selfAnalysisDone!==!0)throw Error("Missing <performSelfAnalysis> invocation at the end of the Parser's constructor.");this.reset(),this.tokVector=e,this.tokVectorLength=e.length},enumerable:!1,configurable:!0}),t.prototype.SKIP_TOKEN=function(){return this.currIdx<=this.tokVector.length-2?(this.consumeToken(),this.LA(1)):jG.END_OF_FILE},t.prototype.LA=function(e){var r=this.currIdx+e;return r<0||this.tokVectorLength<=r?jG.END_OF_FILE:this.tokVector[r]},t.prototype.consumeToken=function(){this.currIdx++},t.prototype.exportLexerState=function(){return this.currIdx},t.prototype.importLexerState=function(e){this.currIdx=e},t.prototype.resetLexerState=function(){this.currIdx=-1},t.prototype.moveToTerminatedState=function(){this.currIdx=this.tokVector.length-1},t.prototype.getLexerPosition=function(){return this.exportLexerState()},t}();oy.LexerAdapter=$ye});var qG=w(ay=>{"use strict";Object.defineProperty(ay,"__esModule",{value:!0});ay.RecognizerApi=void 0;var YG=Yt(),ewe=mg(),aS=Xn(),twe=Tp(),rwe=tS(),iwe=bn(),nwe=function(){function t(){}return t.prototype.ACTION=function(e){return e.call(this)},t.prototype.consume=function(e,r,i){return this.consumeInternal(r,e,i)},t.prototype.subrule=function(e,r,i){return this.subruleInternal(r,e,i)},t.prototype.option=function(e,r){return this.optionInternal(r,e)},t.prototype.or=function(e,r){return this.orInternal(r,e)},t.prototype.many=function(e,r){return this.manyInternal(e,r)},t.prototype.atLeastOne=function(e,r){return this.atLeastOneInternal(e,r)},t.prototype.CONSUME=function(e,r){return this.consumeInternal(e,0,r)},t.prototype.CONSUME1=function(e,r){return this.consumeInternal(e,1,r)},t.prototype.CONSUME2=function(e,r){return this.consumeInternal(e,2,r)},t.prototype.CONSUME3=function(e,r){return this.consumeInternal(e,3,r)},t.prototype.CONSUME4=function(e,r){return this.consumeInternal(e,4,r)},t.prototype.CONSUME5=function(e,r){return this.consumeInternal(e,5,r)},t.prototype.CONSUME6=function(e,r){return this.consumeInternal(e,6,r)},t.prototype.CONSUME7=function(e,r){return this.consumeInternal(e,7,r)},t.prototype.CONSUME8=function(e,r){return this.consumeInternal(e,8,r)},t.prototype.CONSUME9=function(e,r){return this.consumeInternal(e,9,r)},t.prototype.SUBRULE=function(e,r){return this.subruleInternal(e,0,r)},t.prototype.SUBRULE1=function(e,r){return this.subruleInternal(e,1,r)},t.prototype.SUBRULE2=function(e,r){return this.subruleInternal(e,2,r)},t.prototype.SUBRULE3=function(e,r){return this.subruleInternal(e,3,r)},t.prototype.SUBRULE4=function(e,r){return this.subruleInternal(e,4,r)},t.prototype.SUBRULE5=function(e,r){return this.subruleInternal(e,5,r)},t.prototype.SUBRULE6=function(e,r){return this.subruleInternal(e,6,r)},t.prototype.SUBRULE7=function(e,r){return this.subruleInternal(e,7,r)},t.prototype.SUBRULE8=function(e,r){return this.subruleInternal(e,8,r)},t.prototype.SUBRULE9=function(e,r){return this.subruleInternal(e,9,r)},t.prototype.OPTION=function(e){return this.optionInternal(e,0)},t.prototype.OPTION1=function(e){return this.optionInternal(e,1)},t.prototype.OPTION2=function(e){return this.optionInternal(e,2)},t.prototype.OPTION3=function(e){return this.optionInternal(e,3)},t.prototype.OPTION4=function(e){return this.optionInternal(e,4)},t.prototype.OPTION5=function(e){return this.optionInternal(e,5)},t.prototype.OPTION6=function(e){return this.optionInternal(e,6)},t.prototype.OPTION7=function(e){return this.optionInternal(e,7)},t.prototype.OPTION8=function(e){return this.optionInternal(e,8)},t.prototype.OPTION9=function(e){return this.optionInternal(e,9)},t.prototype.OR=function(e){return this.orInternal(e,0)},t.prototype.OR1=function(e){return this.orInternal(e,1)},t.prototype.OR2=function(e){return this.orInternal(e,2)},t.prototype.OR3=function(e){return this.orInternal(e,3)},t.prototype.OR4=function(e){return this.orInternal(e,4)},t.prototype.OR5=function(e){return this.orInternal(e,5)},t.prototype.OR6=function(e){return this.orInternal(e,6)},t.prototype.OR7=function(e){return this.orInternal(e,7)},t.prototype.OR8=function(e){return this.orInternal(e,8)},t.prototype.OR9=function(e){return this.orInternal(e,9)},t.prototype.MANY=function(e){this.manyInternal(0,e)},t.prototype.MANY1=function(e){this.manyInternal(1,e)},t.prototype.MANY2=function(e){this.manyInternal(2,e)},t.prototype.MANY3=function(e){this.manyInternal(3,e)},t.prototype.MANY4=function(e){this.manyInternal(4,e)},t.prototype.MANY5=function(e){this.manyInternal(5,e)},t.prototype.MANY6=function(e){this.manyInternal(6,e)},t.prototype.MANY7=function(e){this.manyInternal(7,e)},t.prototype.MANY8=function(e){this.manyInternal(8,e)},t.prototype.MANY9=function(e){this.manyInternal(9,e)},t.prototype.MANY_SEP=function(e){this.manySepFirstInternal(0,e)},t.prototype.MANY_SEP1=function(e){this.manySepFirstInternal(1,e)},t.prototype.MANY_SEP2=function(e){this.manySepFirstInternal(2,e)},t.prototype.MANY_SEP3=function(e){this.manySepFirstInternal(3,e)},t.prototype.MANY_SEP4=function(e){this.manySepFirstInternal(4,e)},t.prototype.MANY_SEP5=function(e){this.manySepFirstInternal(5,e)},t.prototype.MANY_SEP6=function(e){this.manySepFirstInternal(6,e)},t.prototype.MANY_SEP7=function(e){this.manySepFirstInternal(7,e)},t.prototype.MANY_SEP8=function(e){this.manySepFirstInternal(8,e)},t.prototype.MANY_SEP9=function(e){this.manySepFirstInternal(9,e)},t.prototype.AT_LEAST_ONE=function(e){this.atLeastOneInternal(0,e)},t.prototype.AT_LEAST_ONE1=function(e){return this.atLeastOneInternal(1,e)},t.prototype.AT_LEAST_ONE2=function(e){this.atLeastOneInternal(2,e)},t.prototype.AT_LEAST_ONE3=function(e){this.atLeastOneInternal(3,e)},t.prototype.AT_LEAST_ONE4=function(e){this.atLeastOneInternal(4,e)},t.prototype.AT_LEAST_ONE5=function(e){this.atLeastOneInternal(5,e)},t.prototype.AT_LEAST_ONE6=function(e){this.atLeastOneInternal(6,e)},t.prototype.AT_LEAST_ONE7=function(e){this.atLeastOneInternal(7,e)},t.prototype.AT_LEAST_ONE8=function(e){this.atLeastOneInternal(8,e)},t.prototype.AT_LEAST_ONE9=function(e){this.atLeastOneInternal(9,e)},t.prototype.AT_LEAST_ONE_SEP=function(e){this.atLeastOneSepFirstInternal(0,e)},t.prototype.AT_LEAST_ONE_SEP1=function(e){this.atLeastOneSepFirstInternal(1,e)},t.prototype.AT_LEAST_ONE_SEP2=function(e){this.atLeastOneSepFirstInternal(2,e)},t.prototype.AT_LEAST_ONE_SEP3=function(e){this.atLeastOneSepFirstInternal(3,e)},t.prototype.AT_LEAST_ONE_SEP4=function(e){this.atLeastOneSepFirstInternal(4,e)},t.prototype.AT_LEAST_ONE_SEP5=function(e){this.atLeastOneSepFirstInternal(5,e)},t.prototype.AT_LEAST_ONE_SEP6=function(e){this.atLeastOneSepFirstInternal(6,e)},t.prototype.AT_LEAST_ONE_SEP7=function(e){this.atLeastOneSepFirstInternal(7,e)},t.prototype.AT_LEAST_ONE_SEP8=function(e){this.atLeastOneSepFirstInternal(8,e)},t.prototype.AT_LEAST_ONE_SEP9=function(e){this.atLeastOneSepFirstInternal(9,e)},t.prototype.RULE=function(e,r,i){if(i===void 0&&(i=aS.DEFAULT_RULE_CONFIG),(0,YG.contains)(this.definedRulesNames,e)){var n=twe.defaultGrammarValidatorErrorProvider.buildDuplicateRuleNameError({topLevelRule:e,grammarName:this.className}),s={message:n,type:aS.ParserDefinitionErrorType.DUPLICATE_RULE_NAME,ruleName:e};this.definitionErrors.push(s)}this.definedRulesNames.push(e);var o=this.defineRule(e,r,i);return this[e]=o,o},t.prototype.OVERRIDE_RULE=function(e,r,i){i===void 0&&(i=aS.DEFAULT_RULE_CONFIG);var n=[];n=n.concat((0,rwe.validateRuleIsOverridden)(e,this.definedRulesNames,this.className)),this.definitionErrors=this.definitionErrors.concat(n);var s=this.defineRule(e,r,i);return this[e]=s,s},t.prototype.BACKTRACK=function(e,r){return function(){this.isBackTrackingStack.push(1);var i=this.saveRecogState();try{return e.apply(this,r),!0}catch(n){if((0,ewe.isRecognitionException)(n))return!1;throw n}finally{this.reloadRecogState(i),this.isBackTrackingStack.pop()}}},t.prototype.getGAstProductions=function(){return this.gastProductionsCache},t.prototype.getSerializedGastProductions=function(){return(0,iwe.serializeGrammar)((0,YG.values)(this.gastProductionsCache))},t}();ay.RecognizerApi=nwe});var _G=w(Ay=>{"use strict";Object.defineProperty(Ay,"__esModule",{value:!0});Ay.RecognizerEngine=void 0;var Rr=Yt(),Zn=iy(),ly=mg(),JG=Up(),Ig=Mp(),WG=Xn(),swe=nS(),zG=JA(),Gp=fg(),owe=sS(),awe=function(){function t(){}return t.prototype.initRecognizerEngine=function(e,r){if(this.className=(0,owe.classNameFromInstance)(this),this.shortRuleNameToFull={},this.fullRuleNameToShort={},this.ruleShortNameIdx=256,this.tokenMatcher=Gp.tokenStructuredMatcherNoCategories,this.definedRulesNames=[],this.tokensMap={},this.isBackTrackingStack=[],this.RULE_STACK=[],this.RULE_OCCURRENCE_STACK=[],this.gastProductionsCache={},(0,Rr.has)(r,"serializedGrammar"))throw Error(`The Parser's configuration can no longer contain a <serializedGrammar> property.
+       See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_6-0-0
+       For Further details.`);if((0,Rr.isArray)(e)){if((0,Rr.isEmpty)(e))throw Error(`A Token Vocabulary cannot be empty.
+       Note that the first argument for the parser constructor
+       is no longer a Token vector (since v4.0).`);if(typeof e[0].startOffset=="number")throw Error(`The Parser constructor no longer accepts a token vector as the first argument.
+       See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_4-0-0
+       For Further details.`)}if((0,Rr.isArray)(e))this.tokensMap=(0,Rr.reduce)(e,function(o,a){return o[a.name]=a,o},{});else if((0,Rr.has)(e,"modes")&&(0,Rr.every)((0,Rr.flatten)((0,Rr.values)(e.modes)),Gp.isTokenType)){var i=(0,Rr.flatten)((0,Rr.values)(e.modes)),n=(0,Rr.uniq)(i);this.tokensMap=(0,Rr.reduce)(n,function(o,a){return o[a.name]=a,o},{})}else if((0,Rr.isObject)(e))this.tokensMap=(0,Rr.cloneObj)(e);else throw new Error("<tokensDictionary> argument must be An Array of Token constructors, A dictionary of Token constructors or an IMultiModeLexerDefinition");this.tokensMap.EOF=zG.EOF;var s=(0,Rr.every)((0,Rr.values)(e),function(o){return(0,Rr.isEmpty)(o.categoryMatches)});this.tokenMatcher=s?Gp.tokenStructuredMatcherNoCategories:Gp.tokenStructuredMatcher,(0,Gp.augmentTokenTypes)((0,Rr.values)(this.tokensMap))},t.prototype.defineRule=function(e,r,i){if(this.selfAnalysisDone)throw Error("Grammar rule <"+e+`> may not be defined after the 'performSelfAnalysis' method has been called'
+Make sure that all grammar rule definitions are done before 'performSelfAnalysis' is called.`);var n=(0,Rr.has)(i,"resyncEnabled")?i.resyncEnabled:WG.DEFAULT_RULE_CONFIG.resyncEnabled,s=(0,Rr.has)(i,"recoveryValueFunc")?i.recoveryValueFunc:WG.DEFAULT_RULE_CONFIG.recoveryValueFunc,o=this.ruleShortNameIdx<<Zn.BITS_FOR_METHOD_TYPE+Zn.BITS_FOR_OCCURRENCE_IDX;this.ruleShortNameIdx++,this.shortRuleNameToFull[o]=e,this.fullRuleNameToShort[e]=o;function a(u){try{if(this.outputCst===!0){r.apply(this,u);var g=this.CST_STACK[this.CST_STACK.length-1];return this.cstPostRule(g),g}else return r.apply(this,u)}catch(f){return this.invokeRuleCatch(f,n,s)}finally{this.ruleFinallyStateUpdate()}}var l=function(u,g){return u===void 0&&(u=0),this.ruleInvocationStateUpdate(o,e,u),a.call(this,g)},c="ruleName";return l[c]=e,l.originalGrammarAction=r,l},t.prototype.invokeRuleCatch=function(e,r,i){var n=this.RULE_STACK.length===1,s=r&&!this.isBackTracking()&&this.recoveryEnabled;if((0,ly.isRecognitionException)(e)){var o=e;if(s){var a=this.findReSyncTokenType();if(this.isInCurrentRuleReSyncSet(a))if(o.resyncedTokens=this.reSyncTo(a),this.outputCst){var l=this.CST_STACK[this.CST_STACK.length-1];return l.recoveredNode=!0,l}else return i();else{if(this.outputCst){var l=this.CST_STACK[this.CST_STACK.length-1];l.recoveredNode=!0,o.partialCstResult=l}throw o}}else{if(n)return this.moveToTerminatedState(),i();throw o}}else throw e},t.prototype.optionInternal=function(e,r){var i=this.getKeyForAutomaticLookahead(Zn.OPTION_IDX,r);return this.optionInternalLogic(e,r,i)},t.prototype.optionInternalLogic=function(e,r,i){var n=this,s=this.getLaFuncFromCache(i),o,a;if(e.DEF!==void 0){if(o=e.DEF,a=e.GATE,a!==void 0){var l=s;s=function(){return a.call(n)&&l.call(n)}}}else o=e;if(s.call(this)===!0)return o.call(this)},t.prototype.atLeastOneInternal=function(e,r){var i=this.getKeyForAutomaticLookahead(Zn.AT_LEAST_ONE_IDX,e);return this.atLeastOneInternalLogic(e,r,i)},t.prototype.atLeastOneInternalLogic=function(e,r,i){var n=this,s=this.getLaFuncFromCache(i),o,a;if(r.DEF!==void 0){if(o=r.DEF,a=r.GATE,a!==void 0){var l=s;s=function(){return a.call(n)&&l.call(n)}}}else o=r;if(s.call(this)===!0)for(var c=this.doSingleRepetition(o);s.call(this)===!0&&c===!0;)c=this.doSingleRepetition(o);else throw this.raiseEarlyExitException(e,JG.PROD_TYPE.REPETITION_MANDATORY,r.ERR_MSG);this.attemptInRepetitionRecovery(this.atLeastOneInternal,[e,r],s,Zn.AT_LEAST_ONE_IDX,e,Ig.NextTerminalAfterAtLeastOneWalker)},t.prototype.atLeastOneSepFirstInternal=function(e,r){var i=this.getKeyForAutomaticLookahead(Zn.AT_LEAST_ONE_SEP_IDX,e);this.atLeastOneSepFirstInternalLogic(e,r,i)},t.prototype.atLeastOneSepFirstInternalLogic=function(e,r,i){var n=this,s=r.DEF,o=r.SEP,a=this.getLaFuncFromCache(i);if(a.call(this)===!0){s.call(this);for(var l=function(){return n.tokenMatcher(n.LA(1),o)};this.tokenMatcher(this.LA(1),o)===!0;)this.CONSUME(o),s.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,o,l,s,Ig.NextTerminalAfterAtLeastOneSepWalker],l,Zn.AT_LEAST_ONE_SEP_IDX,e,Ig.NextTerminalAfterAtLeastOneSepWalker)}else throw this.raiseEarlyExitException(e,JG.PROD_TYPE.REPETITION_MANDATORY_WITH_SEPARATOR,r.ERR_MSG)},t.prototype.manyInternal=function(e,r){var i=this.getKeyForAutomaticLookahead(Zn.MANY_IDX,e);return this.manyInternalLogic(e,r,i)},t.prototype.manyInternalLogic=function(e,r,i){var n=this,s=this.getLaFuncFromCache(i),o,a;if(r.DEF!==void 0){if(o=r.DEF,a=r.GATE,a!==void 0){var l=s;s=function(){return a.call(n)&&l.call(n)}}}else o=r;for(var c=!0;s.call(this)===!0&&c===!0;)c=this.doSingleRepetition(o);this.attemptInRepetitionRecovery(this.manyInternal,[e,r],s,Zn.MANY_IDX,e,Ig.NextTerminalAfterManyWalker,c)},t.prototype.manySepFirstInternal=function(e,r){var i=this.getKeyForAutomaticLookahead(Zn.MANY_SEP_IDX,e);this.manySepFirstInternalLogic(e,r,i)},t.prototype.manySepFirstInternalLogic=function(e,r,i){var n=this,s=r.DEF,o=r.SEP,a=this.getLaFuncFromCache(i);if(a.call(this)===!0){s.call(this);for(var l=function(){return n.tokenMatcher(n.LA(1),o)};this.tokenMatcher(this.LA(1),o)===!0;)this.CONSUME(o),s.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,o,l,s,Ig.NextTerminalAfterManySepWalker],l,Zn.MANY_SEP_IDX,e,Ig.NextTerminalAfterManySepWalker)}},t.prototype.repetitionSepSecondInternal=function(e,r,i,n,s){for(;i();)this.CONSUME(r),n.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,r,i,n,s],i,Zn.AT_LEAST_ONE_SEP_IDX,e,s)},t.prototype.doSingleRepetition=function(e){var r=this.getLexerPosition();e.call(this);var i=this.getLexerPosition();return i>r},t.prototype.orInternal=function(e,r){var i=this.getKeyForAutomaticLookahead(Zn.OR_IDX,r),n=(0,Rr.isArray)(e)?e:e.DEF,s=this.getLaFuncFromCache(i),o=s.call(this,n);if(o!==void 0){var a=n[o];return a.ALT.call(this)}this.raiseNoAltException(r,e.ERR_MSG)},t.prototype.ruleFinallyStateUpdate=function(){if(this.RULE_STACK.pop(),this.RULE_OCCURRENCE_STACK.pop(),this.cstFinallyStateUpdate(),this.RULE_STACK.length===0&&this.isAtEndOfInput()===!1){var e=this.LA(1),r=this.errorMessageProvider.buildNotAllInputParsedMessage({firstRedundant:e,ruleName:this.getCurrRuleFullName()});this.SAVE_ERROR(new ly.NotAllInputParsedException(r,e))}},t.prototype.subruleInternal=function(e,r,i){var n;try{var s=i!==void 0?i.ARGS:void 0;return n=e.call(this,r,s),this.cstPostNonTerminal(n,i!==void 0&&i.LABEL!==void 0?i.LABEL:e.ruleName),n}catch(o){this.subruleInternalError(o,i,e.ruleName)}},t.prototype.subruleInternalError=function(e,r,i){throw(0,ly.isRecognitionException)(e)&&e.partialCstResult!==void 0&&(this.cstPostNonTerminal(e.partialCstResult,r!==void 0&&r.LABEL!==void 0?r.LABEL:i),delete e.partialCstResult),e},t.prototype.consumeInternal=function(e,r,i){var n;try{var s=this.LA(1);this.tokenMatcher(s,e)===!0?(this.consumeToken(),n=s):this.consumeInternalError(e,s,i)}catch(o){n=this.consumeInternalRecovery(e,r,o)}return this.cstPostTerminal(i!==void 0&&i.LABEL!==void 0?i.LABEL:e.name,n),n},t.prototype.consumeInternalError=function(e,r,i){var n,s=this.LA(0);throw i!==void 0&&i.ERR_MSG?n=i.ERR_MSG:n=this.errorMessageProvider.buildMismatchTokenMessage({expected:e,actual:r,previous:s,ruleName:this.getCurrRuleFullName()}),this.SAVE_ERROR(new ly.MismatchedTokenException(n,r,s))},t.prototype.consumeInternalRecovery=function(e,r,i){if(this.recoveryEnabled&&i.name==="MismatchedTokenException"&&!this.isBackTracking()){var n=this.getFollowsForInRuleRecovery(e,r);try{return this.tryInRuleRecovery(e,n)}catch(s){throw s.name===swe.IN_RULE_RECOVERY_EXCEPTION?i:s}}else throw i},t.prototype.saveRecogState=function(){var e=this.errors,r=(0,Rr.cloneArr)(this.RULE_STACK);return{errors:e,lexerState:this.exportLexerState(),RULE_STACK:r,CST_STACK:this.CST_STACK}},t.prototype.reloadRecogState=function(e){this.errors=e.errors,this.importLexerState(e.lexerState),this.RULE_STACK=e.RULE_STACK},t.prototype.ruleInvocationStateUpdate=function(e,r,i){this.RULE_OCCURRENCE_STACK.push(i),this.RULE_STACK.push(e),this.cstInvocationStateUpdate(r,e)},t.prototype.isBackTracking=function(){return this.isBackTrackingStack.length!==0},t.prototype.getCurrRuleFullName=function(){var e=this.getLastExplicitRuleShortName();return this.shortRuleNameToFull[e]},t.prototype.shortRuleNameToFullName=function(e){return this.shortRuleNameToFull[e]},t.prototype.isAtEndOfInput=function(){return this.tokenMatcher(this.LA(1),zG.EOF)},t.prototype.reset=function(){this.resetLexerState(),this.isBackTrackingStack=[],this.errors=[],this.RULE_STACK=[],this.CST_STACK=[],this.RULE_OCCURRENCE_STACK=[]},t}();Ay.RecognizerEngine=awe});var XG=w(cy=>{"use strict";Object.defineProperty(cy,"__esModule",{value:!0});cy.ErrorHandler=void 0;var AS=mg(),lS=Yt(),VG=Up(),Awe=Xn(),lwe=function(){function t(){}return t.prototype.initErrorHandler=function(e){this._errors=[],this.errorMessageProvider=(0,lS.has)(e,"errorMessageProvider")?e.errorMessageProvider:Awe.DEFAULT_PARSER_CONFIG.errorMessageProvider},t.prototype.SAVE_ERROR=function(e){if((0,AS.isRecognitionException)(e))return e.context={ruleStack:this.getHumanReadableRuleStack(),ruleOccurrenceStack:(0,lS.cloneArr)(this.RULE_OCCURRENCE_STACK)},this._errors.push(e),e;throw Error("Trying to save an Error which is not a RecognitionException")},Object.defineProperty(t.prototype,"errors",{get:function(){return(0,lS.cloneArr)(this._errors)},set:function(e){this._errors=e},enumerable:!1,configurable:!0}),t.prototype.raiseEarlyExitException=function(e,r,i){for(var n=this.getCurrRuleFullName(),s=this.getGAstProductions()[n],o=(0,VG.getLookaheadPathsForOptionalProd)(e,s,r,this.maxLookahead),a=o[0],l=[],c=1;c<=this.maxLookahead;c++)l.push(this.LA(c));var u=this.errorMessageProvider.buildEarlyExitMessage({expectedIterationPaths:a,actual:l,previous:this.LA(0),customUserDescription:i,ruleName:n});throw this.SAVE_ERROR(new AS.EarlyExitException(u,this.LA(1),this.LA(0)))},t.prototype.raiseNoAltException=function(e,r){for(var i=this.getCurrRuleFullName(),n=this.getGAstProductions()[i],s=(0,VG.getLookaheadPathsForOr)(e,n,this.maxLookahead),o=[],a=1;a<=this.maxLookahead;a++)o.push(this.LA(a));var l=this.LA(0),c=this.errorMessageProvider.buildNoViableAltMessage({expectedPathsPerAlt:s,actual:o,previous:l,customUserDescription:r,ruleName:this.getCurrRuleFullName()});throw this.SAVE_ERROR(new AS.NoViableAltException(c,this.LA(1),l))},t}();cy.ErrorHandler=lwe});var eY=w(uy=>{"use strict";Object.defineProperty(uy,"__esModule",{value:!0});uy.ContentAssist=void 0;var ZG=Mp(),$G=Yt(),cwe=function(){function t(){}return t.prototype.initContentAssist=function(){},t.prototype.computeContentAssist=function(e,r){var i=this.gastProductionsCache[e];if((0,$G.isUndefined)(i))throw Error("Rule ->"+e+"<- does not exist in this grammar.");return(0,ZG.nextPossibleTokensAfter)([i],r,this.tokenMatcher,this.maxLookahead)},t.prototype.getNextPossibleTokenTypes=function(e){var r=(0,$G.first)(e.ruleStack),i=this.getGAstProductions(),n=i[r],s=new ZG.NextAfterTokenWalker(n,e).startWalking();return s},t}();uy.ContentAssist=cwe});var AY=w(gy=>{"use strict";Object.defineProperty(gy,"__esModule",{value:!0});gy.GastRecorder=void 0;var Sn=Yt(),Ho=bn(),uwe=Dp(),tY=fg(),rY=JA(),gwe=Xn(),fwe=iy(),fy={description:"This Object indicates the Parser is during Recording Phase"};Object.freeze(fy);var iY=!0,nY=Math.pow(2,fwe.BITS_FOR_OCCURRENCE_IDX)-1,sY=(0,rY.createToken)({name:"RECORDING_PHASE_TOKEN",pattern:uwe.Lexer.NA});(0,tY.augmentTokenTypes)([sY]);var oY=(0,rY.createTokenInstance)(sY,`This IToken indicates the Parser is in Recording Phase
+       See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,-1,-1,-1,-1,-1,-1);Object.freeze(oY);var hwe={name:`This CSTNode indicates the Parser is in Recording Phase
+       See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,children:{}},dwe=function(){function t(){}return t.prototype.initGastRecorder=function(e){this.recordingProdStack=[],this.RECORDING_PHASE=!1},t.prototype.enableRecording=function(){var e=this;this.RECORDING_PHASE=!0,this.TRACE_INIT("Enable Recording",function(){for(var r=function(n){var s=n>0?n:"";e["CONSUME"+s]=function(o,a){return this.consumeInternalRecord(o,n,a)},e["SUBRULE"+s]=function(o,a){return this.subruleInternalRecord(o,n,a)},e["OPTION"+s]=function(o){return this.optionInternalRecord(o,n)},e["OR"+s]=function(o){return this.orInternalRecord(o,n)},e["MANY"+s]=function(o){this.manyInternalRecord(n,o)},e["MANY_SEP"+s]=function(o){this.manySepFirstInternalRecord(n,o)},e["AT_LEAST_ONE"+s]=function(o){this.atLeastOneInternalRecord(n,o)},e["AT_LEAST_ONE_SEP"+s]=function(o){this.atLeastOneSepFirstInternalRecord(n,o)}},i=0;i<10;i++)r(i);e.consume=function(n,s,o){return this.consumeInternalRecord(s,n,o)},e.subrule=function(n,s,o){return this.subruleInternalRecord(s,n,o)},e.option=function(n,s){return this.optionInternalRecord(s,n)},e.or=function(n,s){return this.orInternalRecord(s,n)},e.many=function(n,s){this.manyInternalRecord(n,s)},e.atLeastOne=function(n,s){this.atLeastOneInternalRecord(n,s)},e.ACTION=e.ACTION_RECORD,e.BACKTRACK=e.BACKTRACK_RECORD,e.LA=e.LA_RECORD})},t.prototype.disableRecording=function(){var e=this;this.RECORDING_PHASE=!1,this.TRACE_INIT("Deleting Recording methods",function(){for(var r=0;r<10;r++){var i=r>0?r:"";delete e["CONSUME"+i],delete e["SUBRULE"+i],delete e["OPTION"+i],delete e["OR"+i],delete e["MANY"+i],delete e["MANY_SEP"+i],delete e["AT_LEAST_ONE"+i],delete e["AT_LEAST_ONE_SEP"+i]}delete e.consume,delete e.subrule,delete e.option,delete e.or,delete e.many,delete e.atLeastOne,delete e.ACTION,delete e.BACKTRACK,delete e.LA})},t.prototype.ACTION_RECORD=function(e){},t.prototype.BACKTRACK_RECORD=function(e,r){return function(){return!0}},t.prototype.LA_RECORD=function(e){return gwe.END_OF_FILE},t.prototype.topLevelRuleRecord=function(e,r){try{var i=new Ho.Rule({definition:[],name:e});return i.name=e,this.recordingProdStack.push(i),r.call(this),this.recordingProdStack.pop(),i}catch(n){if(n.KNOWN_RECORDER_ERROR!==!0)try{n.message=n.message+`
+        This error was thrown during the "grammar recording phase" For more info see:
+       https://chevrotain.io/docs/guide/internals.html#grammar-recording`}catch(s){throw n}throw n}},t.prototype.optionInternalRecord=function(e,r){return Yp.call(this,Ho.Option,e,r)},t.prototype.atLeastOneInternalRecord=function(e,r){Yp.call(this,Ho.RepetitionMandatory,r,e)},t.prototype.atLeastOneSepFirstInternalRecord=function(e,r){Yp.call(this,Ho.RepetitionMandatoryWithSeparator,r,e,iY)},t.prototype.manyInternalRecord=function(e,r){Yp.call(this,Ho.Repetition,r,e)},t.prototype.manySepFirstInternalRecord=function(e,r){Yp.call(this,Ho.RepetitionWithSeparator,r,e,iY)},t.prototype.orInternalRecord=function(e,r){return pwe.call(this,e,r)},t.prototype.subruleInternalRecord=function(e,r,i){if(hy(r),!e||(0,Sn.has)(e,"ruleName")===!1){var n=new Error("<SUBRULE"+aY(r)+"> argument is invalid"+(" expecting a Parser method reference but got: <"+JSON.stringify(e)+">")+(`
+ inside top level rule: <`+this.recordingProdStack[0].name+">"));throw n.KNOWN_RECORDER_ERROR=!0,n}var s=(0,Sn.peek)(this.recordingProdStack),o=e.ruleName,a=new Ho.NonTerminal({idx:r,nonTerminalName:o,label:i==null?void 0:i.LABEL,referencedRule:void 0});return s.definition.push(a),this.outputCst?hwe:fy},t.prototype.consumeInternalRecord=function(e,r,i){if(hy(r),!(0,tY.hasShortKeyProperty)(e)){var n=new Error("<CONSUME"+aY(r)+"> argument is invalid"+(" expecting a TokenType reference but got: <"+JSON.stringify(e)+">")+(`
+ inside top level rule: <`+this.recordingProdStack[0].name+">"));throw n.KNOWN_RECORDER_ERROR=!0,n}var s=(0,Sn.peek)(this.recordingProdStack),o=new Ho.Terminal({idx:r,terminalType:e,label:i==null?void 0:i.LABEL});return s.definition.push(o),oY},t}();gy.GastRecorder=dwe;function Yp(t,e,r,i){i===void 0&&(i=!1),hy(r);var n=(0,Sn.peek)(this.recordingProdStack),s=(0,Sn.isFunction)(e)?e:e.DEF,o=new t({definition:[],idx:r});return i&&(o.separator=e.SEP),(0,Sn.has)(e,"MAX_LOOKAHEAD")&&(o.maxLookahead=e.MAX_LOOKAHEAD),this.recordingProdStack.push(o),s.call(this),n.definition.push(o),this.recordingProdStack.pop(),fy}function pwe(t,e){var r=this;hy(e);var i=(0,Sn.peek)(this.recordingProdStack),n=(0,Sn.isArray)(t)===!1,s=n===!1?t:t.DEF,o=new Ho.Alternation({definition:[],idx:e,ignoreAmbiguities:n&&t.IGNORE_AMBIGUITIES===!0});(0,Sn.has)(t,"MAX_LOOKAHEAD")&&(o.maxLookahead=t.MAX_LOOKAHEAD);var a=(0,Sn.some)(s,function(l){return(0,Sn.isFunction)(l.GATE)});return o.hasPredicates=a,i.definition.push(o),(0,Sn.forEach)(s,function(l){var c=new Ho.Alternative({definition:[]});o.definition.push(c),(0,Sn.has)(l,"IGNORE_AMBIGUITIES")?c.ignoreAmbiguities=l.IGNORE_AMBIGUITIES:(0,Sn.has)(l,"GATE")&&(c.ignoreAmbiguities=!0),r.recordingProdStack.push(c),l.ALT.call(r),r.recordingProdStack.pop()}),fy}function aY(t){return t===0?"":""+t}function hy(t){if(t<0||t>nY){var e=new Error("Invalid DSL Method idx value: <"+t+`>
+       `+("Idx value must be a none negative value smaller than "+(nY+1)));throw e.KNOWN_RECORDER_ERROR=!0,e}}});var cY=w(py=>{"use strict";Object.defineProperty(py,"__esModule",{value:!0});py.PerformanceTracer=void 0;var lY=Yt(),Cwe=Xn(),mwe=function(){function t(){}return t.prototype.initPerformanceTracer=function(e){if((0,lY.has)(e,"traceInitPerf")){var r=e.traceInitPerf,i=typeof r=="number";this.traceInitMaxIdent=i?r:Infinity,this.traceInitPerf=i?r>0:r}else this.traceInitMaxIdent=0,this.traceInitPerf=Cwe.DEFAULT_PARSER_CONFIG.traceInitPerf;this.traceInitIndent=-1},t.prototype.TRACE_INIT=function(e,r){if(this.traceInitPerf===!0){this.traceInitIndent++;var i=new Array(this.traceInitIndent+1).join("  ");this.traceInitIndent<this.traceInitMaxIdent&&console.log(i+"--> <"+e+">");var n=(0,lY.timer)(r),s=n.time,o=n.value,a=s>10?console.warn:console.log;return this.traceInitIndent<this.traceInitMaxIdent&&a(i+"<-- <"+e+"> time: "+s+"ms"),this.traceInitIndent--,o}else return r()},t}();py.PerformanceTracer=mwe});var uY=w(dy=>{"use strict";Object.defineProperty(dy,"__esModule",{value:!0});dy.applyMixins=void 0;function Ewe(t,e){e.forEach(function(r){var i=r.prototype;Object.getOwnPropertyNames(i).forEach(function(n){if(n!=="constructor"){var s=Object.getOwnPropertyDescriptor(i,n);s&&(s.get||s.set)?Object.defineProperty(t.prototype,n,s):t.prototype[n]=r.prototype[n]}})})}dy.applyMixins=Ewe});var Xn=w(Er=>{"use strict";var gY=Er&&Er.__extends||function(){var t=function(e,r){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},t(e,r)};return function(e,r){if(typeof r!="function"&&r!==null)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");t(e,r);function i(){this.constructor=e}e.prototype=r===null?Object.create(r):(i.prototype=r.prototype,new i)}}();Object.defineProperty(Er,"__esModule",{value:!0});Er.EmbeddedActionsParser=Er.CstParser=Er.Parser=Er.EMPTY_ALT=Er.ParserDefinitionErrorType=Er.DEFAULT_RULE_CONFIG=Er.DEFAULT_PARSER_CONFIG=Er.END_OF_FILE=void 0;var an=Yt(),Iwe=Xj(),fY=JA(),hY=Tp(),pY=BG(),ywe=nS(),wwe=DG(),Bwe=HG(),bwe=GG(),Qwe=qG(),vwe=_G(),Swe=XG(),kwe=eY(),xwe=AY(),Pwe=cY(),Dwe=uY();Er.END_OF_FILE=(0,fY.createTokenInstance)(fY.EOF,"",NaN,NaN,NaN,NaN,NaN,NaN);Object.freeze(Er.END_OF_FILE);Er.DEFAULT_PARSER_CONFIG=Object.freeze({recoveryEnabled:!1,maxLookahead:3,dynamicTokensEnabled:!1,outputCst:!0,errorMessageProvider:hY.defaultParserErrorProvider,nodeLocationTracking:"none",traceInitPerf:!1,skipValidations:!1});Er.DEFAULT_RULE_CONFIG=Object.freeze({recoveryValueFunc:function(){},resyncEnabled:!0});var Rwe;(function(t){t[t.INVALID_RULE_NAME=0]="INVALID_RULE_NAME",t[t.DUPLICATE_RULE_NAME=1]="DUPLICATE_RULE_NAME",t[t.INVALID_RULE_OVERRIDE=2]="INVALID_RULE_OVERRIDE",t[t.DUPLICATE_PRODUCTIONS=3]="DUPLICATE_PRODUCTIONS",t[t.UNRESOLVED_SUBRULE_REF=4]="UNRESOLVED_SUBRULE_REF",t[t.LEFT_RECURSION=5]="LEFT_RECURSION",t[t.NONE_LAST_EMPTY_ALT=6]="NONE_LAST_EMPTY_ALT",t[t.AMBIGUOUS_ALTS=7]="AMBIGUOUS_ALTS",t[t.CONFLICT_TOKENS_RULES_NAMESPACE=8]="CONFLICT_TOKENS_RULES_NAMESPACE",t[t.INVALID_TOKEN_NAME=9]="INVALID_TOKEN_NAME",t[t.NO_NON_EMPTY_LOOKAHEAD=10]="NO_NON_EMPTY_LOOKAHEAD",t[t.AMBIGUOUS_PREFIX_ALTS=11]="AMBIGUOUS_PREFIX_ALTS",t[t.TOO_MANY_ALTS=12]="TOO_MANY_ALTS"})(Rwe=Er.ParserDefinitionErrorType||(Er.ParserDefinitionErrorType={}));function Fwe(t){return t===void 0&&(t=void 0),function(){return t}}Er.EMPTY_ALT=Fwe;var Cy=function(){function t(e,r){this.definitionErrors=[],this.selfAnalysisDone=!1;var i=this;if(i.initErrorHandler(r),i.initLexerAdapter(),i.initLooksAhead(r),i.initRecognizerEngine(e,r),i.initRecoverable(r),i.initTreeBuilder(r),i.initContentAssist(),i.initGastRecorder(r),i.initPerformanceTracer(r),(0,an.has)(r,"ignoredIssues"))throw new Error(`The <ignoredIssues> IParserConfig property has been deprecated.
+       Please use the <IGNORE_AMBIGUITIES> flag on the relevant DSL method instead.
+       See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#IGNORING_AMBIGUITIES
+       For further details.`);this.skipValidations=(0,an.has)(r,"skipValidations")?r.skipValidations:Er.DEFAULT_PARSER_CONFIG.skipValidations}return t.performSelfAnalysis=function(e){throw Error("The **static** `performSelfAnalysis` method has been deprecated.   \nUse the **instance** method with the same name instead.")},t.prototype.performSelfAnalysis=function(){var e=this;this.TRACE_INIT("performSelfAnalysis",function(){var r;e.selfAnalysisDone=!0;var i=e.className;e.TRACE_INIT("toFastProps",function(){(0,an.toFastProperties)(e)}),e.TRACE_INIT("Grammar Recording",function(){try{e.enableRecording(),(0,an.forEach)(e.definedRulesNames,function(s){var o=e[s],a=o.originalGrammarAction,l=void 0;e.TRACE_INIT(s+" Rule",function(){l=e.topLevelRuleRecord(s,a)}),e.gastProductionsCache[s]=l})}finally{e.disableRecording()}});var n=[];if(e.TRACE_INIT("Grammar Resolving",function(){n=(0,pY.resolveGrammar)({rules:(0,an.values)(e.gastProductionsCache)}),e.definitionErrors=e.definitionErrors.concat(n)}),e.TRACE_INIT("Grammar Validations",function(){if((0,an.isEmpty)(n)&&e.skipValidations===!1){var s=(0,pY.validateGrammar)({rules:(0,an.values)(e.gastProductionsCache),maxLookahead:e.maxLookahead,tokenTypes:(0,an.values)(e.tokensMap),errMsgProvider:hY.defaultGrammarValidatorErrorProvider,grammarName:i});e.definitionErrors=e.definitionErrors.concat(s)}}),(0,an.isEmpty)(e.definitionErrors)&&(e.recoveryEnabled&&e.TRACE_INIT("computeAllProdsFollows",function(){var s=(0,Iwe.computeAllProdsFollows)((0,an.values)(e.gastProductionsCache));e.resyncFollows=s}),e.TRACE_INIT("ComputeLookaheadFunctions",function(){e.preComputeLookaheadFunctions((0,an.values)(e.gastProductionsCache))})),!t.DEFER_DEFINITION_ERRORS_HANDLING&&!(0,an.isEmpty)(e.definitionErrors))throw r=(0,an.map)(e.definitionErrors,function(s){return s.message}),new Error(`Parser Definition Errors detected:
+ `+r.join(`
+-------------------------------
+`))})},t.DEFER_DEFINITION_ERRORS_HANDLING=!1,t}();Er.Parser=Cy;(0,Dwe.applyMixins)(Cy,[ywe.Recoverable,wwe.LooksAhead,Bwe.TreeBuilder,bwe.LexerAdapter,vwe.RecognizerEngine,Qwe.RecognizerApi,Swe.ErrorHandler,kwe.ContentAssist,xwe.GastRecorder,Pwe.PerformanceTracer]);var Nwe=function(t){gY(e,t);function e(r,i){i===void 0&&(i=Er.DEFAULT_PARSER_CONFIG);var n=this,s=(0,an.cloneObj)(i);return s.outputCst=!0,n=t.call(this,r,s)||this,n}return e}(Cy);Er.CstParser=Nwe;var Lwe=function(t){gY(e,t);function e(r,i){i===void 0&&(i=Er.DEFAULT_PARSER_CONFIG);var n=this,s=(0,an.cloneObj)(i);return s.outputCst=!1,n=t.call(this,r,s)||this,n}return e}(Cy);Er.EmbeddedActionsParser=Lwe});var CY=w(my=>{"use strict";Object.defineProperty(my,"__esModule",{value:!0});my.createSyntaxDiagramsCode=void 0;var dY=xv();function Twe(t,e){var r=e===void 0?{}:e,i=r.resourceBase,n=i===void 0?"https://unpkg.com/chevrotain@"+dY.VERSION+"/diagrams/":i,s=r.css,o=s===void 0?"https://unpkg.com/chevrotain@"+dY.VERSION+"/diagrams/diagrams.css":s,a=`
+<!-- This is a generated file -->
+<!DOCTYPE html>
+<meta charset="utf-8">
+<style>
+  body {
+    background-color: hsl(30, 20%, 95%)
+  }
+</style>
+
+`,l=`
+<link rel='stylesheet' href='`+o+`'>
+`,c=`
+<script src='`+n+`vendor/railroad-diagrams.js'></script>
+<script src='`+n+`src/diagrams_builder.js'></script>
+<script src='`+n+`src/diagrams_behavior.js'></script>
+<script src='`+n+`src/main.js'></script>
+`,u=`
+<div id="diagrams" align="center"></div>    
+`,g=`
+<script>
+    window.serializedGrammar = `+JSON.stringify(t,null,"  ")+`;
+</script>
+`,f=`
+<script>
+    var diagramsDiv = document.getElementById("diagrams");
+    main.drawDiagramsFromSerializedGrammar(serializedGrammar, diagramsDiv);
+</script>
+`;return a+l+c+u+g+f}my.createSyntaxDiagramsCode=Twe});var IY=w(Ve=>{"use strict";Object.defineProperty(Ve,"__esModule",{value:!0});Ve.Parser=Ve.createSyntaxDiagramsCode=Ve.clearCache=Ve.GAstVisitor=Ve.serializeProduction=Ve.serializeGrammar=Ve.Terminal=Ve.Rule=Ve.RepetitionWithSeparator=Ve.RepetitionMandatoryWithSeparator=Ve.RepetitionMandatory=Ve.Repetition=Ve.Option=Ve.NonTerminal=Ve.Alternative=Ve.Alternation=Ve.defaultLexerErrorProvider=Ve.NoViableAltException=Ve.NotAllInputParsedException=Ve.MismatchedTokenException=Ve.isRecognitionException=Ve.EarlyExitException=Ve.defaultParserErrorProvider=Ve.tokenName=Ve.tokenMatcher=Ve.tokenLabel=Ve.EOF=Ve.createTokenInstance=Ve.createToken=Ve.LexerDefinitionErrorType=Ve.Lexer=Ve.EMPTY_ALT=Ve.ParserDefinitionErrorType=Ve.EmbeddedActionsParser=Ve.CstParser=Ve.VERSION=void 0;var Owe=xv();Object.defineProperty(Ve,"VERSION",{enumerable:!0,get:function(){return Owe.VERSION}});var Ey=Xn();Object.defineProperty(Ve,"CstParser",{enumerable:!0,get:function(){return Ey.CstParser}});Object.defineProperty(Ve,"EmbeddedActionsParser",{enumerable:!0,get:function(){return Ey.EmbeddedActionsParser}});Object.defineProperty(Ve,"ParserDefinitionErrorType",{enumerable:!0,get:function(){return Ey.ParserDefinitionErrorType}});Object.defineProperty(Ve,"EMPTY_ALT",{enumerable:!0,get:function(){return Ey.EMPTY_ALT}});var mY=Dp();Object.defineProperty(Ve,"Lexer",{enumerable:!0,get:function(){return mY.Lexer}});Object.defineProperty(Ve,"LexerDefinitionErrorType",{enumerable:!0,get:function(){return mY.LexerDefinitionErrorType}});var yg=JA();Object.defineProperty(Ve,"createToken",{enumerable:!0,get:function(){return yg.createToken}});Object.defineProperty(Ve,"createTokenInstance",{enumerable:!0,get:function(){return yg.createTokenInstance}});Object.defineProperty(Ve,"EOF",{enumerable:!0,get:function(){return yg.EOF}});Object.defineProperty(Ve,"tokenLabel",{enumerable:!0,get:function(){return yg.tokenLabel}});Object.defineProperty(Ve,"tokenMatcher",{enumerable:!0,get:function(){return yg.tokenMatcher}});Object.defineProperty(Ve,"tokenName",{enumerable:!0,get:function(){return yg.tokenName}});var Mwe=Tp();Object.defineProperty(Ve,"defaultParserErrorProvider",{enumerable:!0,get:function(){return Mwe.defaultParserErrorProvider}});var qp=mg();Object.defineProperty(Ve,"EarlyExitException",{enumerable:!0,get:function(){return qp.EarlyExitException}});Object.defineProperty(Ve,"isRecognitionException",{enumerable:!0,get:function(){return qp.isRecognitionException}});Object.defineProperty(Ve,"MismatchedTokenException",{enumerable:!0,get:function(){return qp.MismatchedTokenException}});Object.defineProperty(Ve,"NotAllInputParsedException",{enumerable:!0,get:function(){return qp.NotAllInputParsedException}});Object.defineProperty(Ve,"NoViableAltException",{enumerable:!0,get:function(){return qp.NoViableAltException}});var Uwe=Uv();Object.defineProperty(Ve,"defaultLexerErrorProvider",{enumerable:!0,get:function(){return Uwe.defaultLexerErrorProvider}});var jo=bn();Object.defineProperty(Ve,"Alternation",{enumerable:!0,get:function(){return jo.Alternation}});Object.defineProperty(Ve,"Alternative",{enumerable:!0,get:function(){return jo.Alternative}});Object.defineProperty(Ve,"NonTerminal",{enumerable:!0,get:function(){return jo.NonTerminal}});Object.defineProperty(Ve,"Option",{enumerable:!0,get:function(){return jo.Option}});Object.defineProperty(Ve,"Repetition",{enumerable:!0,get:function(){return jo.Repetition}});Object.defineProperty(Ve,"RepetitionMandatory",{enumerable:!0,get:function(){return jo.RepetitionMandatory}});Object.defineProperty(Ve,"RepetitionMandatoryWithSeparator",{enumerable:!0,get:function(){return jo.RepetitionMandatoryWithSeparator}});Object.defineProperty(Ve,"RepetitionWithSeparator",{enumerable:!0,get:function(){return jo.RepetitionWithSeparator}});Object.defineProperty(Ve,"Rule",{enumerable:!0,get:function(){return jo.Rule}});Object.defineProperty(Ve,"Terminal",{enumerable:!0,get:function(){return jo.Terminal}});var EY=bn();Object.defineProperty(Ve,"serializeGrammar",{enumerable:!0,get:function(){return EY.serializeGrammar}});Object.defineProperty(Ve,"serializeProduction",{enumerable:!0,get:function(){return EY.serializeProduction}});var Kwe=hg();Object.defineProperty(Ve,"GAstVisitor",{enumerable:!0,get:function(){return Kwe.GAstVisitor}});function Hwe(){console.warn(`The clearCache function was 'soft' removed from the Chevrotain API.
+        It performs no action other than printing this message.
+        Please avoid using it as it will be completely removed in the future`)}Ve.clearCache=Hwe;var jwe=CY();Object.defineProperty(Ve,"createSyntaxDiagramsCode",{enumerable:!0,get:function(){return jwe.createSyntaxDiagramsCode}});var Gwe=function(){function t(){throw new Error(`The Parser class has been deprecated, use CstParser or EmbeddedActionsParser instead.  
+See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_7-0-0`)}return t}();Ve.Parser=Gwe});var BY=w((Vtt,yY)=>{var Iy=IY(),Ga=Iy.createToken,wY=Iy.tokenMatcher,cS=Iy.Lexer,Ywe=Iy.EmbeddedActionsParser;yY.exports=t=>{let e=Ga({name:"LogicalOperator",pattern:cS.NA}),r=Ga({name:"Or",pattern:/\|/,categories:e}),i=Ga({name:"Xor",pattern:/\^/,categories:e}),n=Ga({name:"And",pattern:/&/,categories:e}),s=Ga({name:"Not",pattern:/!/}),o=Ga({name:"LParen",pattern:/\(/}),a=Ga({name:"RParen",pattern:/\)/}),l=Ga({name:"Query",pattern:t}),u=[Ga({name:"WhiteSpace",pattern:/\s+/,group:cS.SKIPPED}),r,i,n,o,a,s,e,l],g=new cS(u);class f extends Ywe{constructor(p){super(u);this.RULE("expression",()=>this.SUBRULE(this.logicalExpression)),this.RULE("logicalExpression",()=>{let y=this.SUBRULE(this.atomicExpression);return this.MANY(()=>{let Q=y,S=this.CONSUME(e),x=this.SUBRULE2(this.atomicExpression);wY(S,r)?y=M=>Q(M)||x(M):wY(S,i)?y=M=>!!(Q(M)^x(M)):y=M=>Q(M)&&x(M)}),y}),this.RULE("atomicExpression",()=>this.OR([{ALT:()=>this.SUBRULE(this.parenthesisExpression)},{ALT:()=>{let{image:m}=this.CONSUME(l);return y=>y(m)}},{ALT:()=>{this.CONSUME(s);let m=this.SUBRULE(this.atomicExpression);return y=>!m(y)}}])),this.RULE("parenthesisExpression",()=>{let m;return this.CONSUME(o),m=this.SUBRULE(this.expression),this.CONSUME(a),m}),this.performSelfAnalysis()}}return{TinylogicLexer:g,TinylogicParser:f}}});var bY=w(yy=>{var qwe=BY();yy.makeParser=(t=/[a-z]+/)=>{let{TinylogicLexer:e,TinylogicParser:r}=qwe(t),i=new r;return(n,s)=>{let o=e.tokenize(n);return i.input=o.tokens,i.expression()(s)}};yy.parse=yy.makeParser()});var vY=w((Ztt,QY)=>{"use strict";QY.exports={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}});var uS=w(($tt,SY)=>{var Jp=vY(),kY={};for(let t of Object.keys(Jp))kY[Jp[t]]=t;var at={rgb:{channels:3,labels:"rgb"},hsl:{channels:3,labels:"hsl"},hsv:{channels:3,labels:"hsv"},hwb:{channels:3,labels:"hwb"},cmyk:{channels:4,labels:"cmyk"},xyz:{channels:3,labels:"xyz"},lab:{channels:3,labels:"lab"},lch:{channels:3,labels:"lch"},hex:{channels:1,labels:["hex"]},keyword:{channels:1,labels:["keyword"]},ansi16:{channels:1,labels:["ansi16"]},ansi256:{channels:1,labels:["ansi256"]},hcg:{channels:3,labels:["h","c","g"]},apple:{channels:3,labels:["r16","g16","b16"]},gray:{channels:1,labels:["gray"]}};SY.exports=at;for(let t of Object.keys(at)){if(!("channels"in at[t]))throw new Error("missing channels property: "+t);if(!("labels"in at[t]))throw new Error("missing channel labels property: "+t);if(at[t].labels.length!==at[t].channels)throw new Error("channel and label counts mismatch: "+t);let{channels:e,labels:r}=at[t];delete at[t].channels,delete at[t].labels,Object.defineProperty(at[t],"channels",{value:e}),Object.defineProperty(at[t],"labels",{value:r})}at.rgb.hsl=function(t){let e=t[0]/255,r=t[1]/255,i=t[2]/255,n=Math.min(e,r,i),s=Math.max(e,r,i),o=s-n,a,l;s===n?a=0:e===s?a=(r-i)/o:r===s?a=2+(i-e)/o:i===s&&(a=4+(e-r)/o),a=Math.min(a*60,360),a<0&&(a+=360);let c=(n+s)/2;return s===n?l=0:c<=.5?l=o/(s+n):l=o/(2-s-n),[a,l*100,c*100]};at.rgb.hsv=function(t){let e,r,i,n,s,o=t[0]/255,a=t[1]/255,l=t[2]/255,c=Math.max(o,a,l),u=c-Math.min(o,a,l),g=function(f){return(c-f)/6/u+1/2};return u===0?(n=0,s=0):(s=u/c,e=g(o),r=g(a),i=g(l),o===c?n=i-r:a===c?n=1/3+e-i:l===c&&(n=2/3+r-e),n<0?n+=1:n>1&&(n-=1)),[n*360,s*100,c*100]};at.rgb.hwb=function(t){let e=t[0],r=t[1],i=t[2],n=at.rgb.hsl(t)[0],s=1/255*Math.min(e,Math.min(r,i));return i=1-1/255*Math.max(e,Math.max(r,i)),[n,s*100,i*100]};at.rgb.cmyk=function(t){let e=t[0]/255,r=t[1]/255,i=t[2]/255,n=Math.min(1-e,1-r,1-i),s=(1-e-n)/(1-n)||0,o=(1-r-n)/(1-n)||0,a=(1-i-n)/(1-n)||0;return[s*100,o*100,a*100,n*100]};function Jwe(t,e){return(t[0]-e[0])**2+(t[1]-e[1])**2+(t[2]-e[2])**2}at.rgb.keyword=function(t){let e=kY[t];if(e)return e;let r=Infinity,i;for(let n of Object.keys(Jp)){let s=Jp[n],o=Jwe(t,s);o<r&&(r=o,i=n)}return i};at.keyword.rgb=function(t){return Jp[t]};at.rgb.xyz=function(t){let e=t[0]/255,r=t[1]/255,i=t[2]/255;e=e>.04045?((e+.055)/1.055)**2.4:e/12.92,r=r>.04045?((r+.055)/1.055)**2.4:r/12.92,i=i>.04045?((i+.055)/1.055)**2.4:i/12.92;let n=e*.4124+r*.3576+i*.1805,s=e*.2126+r*.7152+i*.0722,o=e*.0193+r*.1192+i*.9505;return[n*100,s*100,o*100]};at.rgb.lab=function(t){let e=at.rgb.xyz(t),r=e[0],i=e[1],n=e[2];r/=95.047,i/=100,n/=108.883,r=r>.008856?r**(1/3):7.787*r+16/116,i=i>.008856?i**(1/3):7.787*i+16/116,n=n>.008856?n**(1/3):7.787*n+16/116;let s=116*i-16,o=500*(r-i),a=200*(i-n);return[s,o,a]};at.hsl.rgb=function(t){let e=t[0]/360,r=t[1]/100,i=t[2]/100,n,s,o;if(r===0)return o=i*255,[o,o,o];i<.5?n=i*(1+r):n=i+r-i*r;let a=2*i-n,l=[0,0,0];for(let c=0;c<3;c++)s=e+1/3*-(c-1),s<0&&s++,s>1&&s--,6*s<1?o=a+(n-a)*6*s:2*s<1?o=n:3*s<2?o=a+(n-a)*(2/3-s)*6:o=a,l[c]=o*255;return l};at.hsl.hsv=function(t){let e=t[0],r=t[1]/100,i=t[2]/100,n=r,s=Math.max(i,.01);i*=2,r*=i<=1?i:2-i,n*=s<=1?s:2-s;let o=(i+r)/2,a=i===0?2*n/(s+n):2*r/(i+r);return[e,a*100,o*100]};at.hsv.rgb=function(t){let e=t[0]/60,r=t[1]/100,i=t[2]/100,n=Math.floor(e)%6,s=e-Math.floor(e),o=255*i*(1-r),a=255*i*(1-r*s),l=255*i*(1-r*(1-s));switch(i*=255,n){case 0:return[i,l,o];case 1:return[a,i,o];case 2:return[o,i,l];case 3:return[o,a,i];case 4:return[l,o,i];case 5:return[i,o,a]}};at.hsv.hsl=function(t){let e=t[0],r=t[1]/100,i=t[2]/100,n=Math.max(i,.01),s,o;o=(2-r)*i;let a=(2-r)*n;return s=r*n,s/=a<=1?a:2-a,s=s||0,o/=2,[e,s*100,o*100]};at.hwb.rgb=function(t){let e=t[0]/360,r=t[1]/100,i=t[2]/100,n=r+i,s;n>1&&(r/=n,i/=n);let o=Math.floor(6*e),a=1-i;s=6*e-o,(o&1)!=0&&(s=1-s);let l=r+s*(a-r),c,u,g;switch(o){default:case 6:case 0:c=a,u=l,g=r;break;case 1:c=l,u=a,g=r;break;case 2:c=r,u=a,g=l;break;case 3:c=r,u=l,g=a;break;case 4:c=l,u=r,g=a;break;case 5:c=a,u=r,g=l;break}return[c*255,u*255,g*255]};at.cmyk.rgb=function(t){let e=t[0]/100,r=t[1]/100,i=t[2]/100,n=t[3]/100,s=1-Math.min(1,e*(1-n)+n),o=1-Math.min(1,r*(1-n)+n),a=1-Math.min(1,i*(1-n)+n);return[s*255,o*255,a*255]};at.xyz.rgb=function(t){let e=t[0]/100,r=t[1]/100,i=t[2]/100,n,s,o;return n=e*3.2406+r*-1.5372+i*-.4986,s=e*-.9689+r*1.8758+i*.0415,o=e*.0557+r*-.204+i*1.057,n=n>.0031308?1.055*n**(1/2.4)-.055:n*12.92,s=s>.0031308?1.055*s**(1/2.4)-.055:s*12.92,o=o>.0031308?1.055*o**(1/2.4)-.055:o*12.92,n=Math.min(Math.max(0,n),1),s=Math.min(Math.max(0,s),1),o=Math.min(Math.max(0,o),1),[n*255,s*255,o*255]};at.xyz.lab=function(t){let e=t[0],r=t[1],i=t[2];e/=95.047,r/=100,i/=108.883,e=e>.008856?e**(1/3):7.787*e+16/116,r=r>.008856?r**(1/3):7.787*r+16/116,i=i>.008856?i**(1/3):7.787*i+16/116;let n=116*r-16,s=500*(e-r),o=200*(r-i);return[n,s,o]};at.lab.xyz=function(t){let e=t[0],r=t[1],i=t[2],n,s,o;s=(e+16)/116,n=r/500+s,o=s-i/200;let a=s**3,l=n**3,c=o**3;return s=a>.008856?a:(s-16/116)/7.787,n=l>.008856?l:(n-16/116)/7.787,o=c>.008856?c:(o-16/116)/7.787,n*=95.047,s*=100,o*=108.883,[n,s,o]};at.lab.lch=function(t){let e=t[0],r=t[1],i=t[2],n;n=Math.atan2(i,r)*360/2/Math.PI,n<0&&(n+=360);let o=Math.sqrt(r*r+i*i);return[e,o,n]};at.lch.lab=function(t){let e=t[0],r=t[1],n=t[2]/360*2*Math.PI,s=r*Math.cos(n),o=r*Math.sin(n);return[e,s,o]};at.rgb.ansi16=function(t,e=null){let[r,i,n]=t,s=e===null?at.rgb.hsv(t)[2]:e;if(s=Math.round(s/50),s===0)return 30;let o=30+(Math.round(n/255)<<2|Math.round(i/255)<<1|Math.round(r/255));return s===2&&(o+=60),o};at.hsv.ansi16=function(t){return at.rgb.ansi16(at.hsv.rgb(t),t[2])};at.rgb.ansi256=function(t){let e=t[0],r=t[1],i=t[2];return e===r&&r===i?e<8?16:e>248?231:Math.round((e-8)/247*24)+232:16+36*Math.round(e/255*5)+6*Math.round(r/255*5)+Math.round(i/255*5)};at.ansi16.rgb=function(t){let e=t%10;if(e===0||e===7)return t>50&&(e+=3.5),e=e/10.5*255,[e,e,e];let r=(~~(t>50)+1)*.5,i=(e&1)*r*255,n=(e>>1&1)*r*255,s=(e>>2&1)*r*255;return[i,n,s]};at.ansi256.rgb=function(t){if(t>=232){let s=(t-232)*10+8;return[s,s,s]}t-=16;let e,r=Math.floor(t/36)/5*255,i=Math.floor((e=t%36)/6)/5*255,n=e%6/5*255;return[r,i,n]};at.rgb.hex=function(t){let r=(((Math.round(t[0])&255)<<16)+((Math.round(t[1])&255)<<8)+(Math.round(t[2])&255)).toString(16).toUpperCase();return"000000".substring(r.length)+r};at.hex.rgb=function(t){let e=t.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i);if(!e)return[0,0,0];let r=e[0];e[0].length===3&&(r=r.split("").map(a=>a+a).join(""));let i=parseInt(r,16),n=i>>16&255,s=i>>8&255,o=i&255;return[n,s,o]};at.rgb.hcg=function(t){let e=t[0]/255,r=t[1]/255,i=t[2]/255,n=Math.max(Math.max(e,r),i),s=Math.min(Math.min(e,r),i),o=n-s,a,l;return o<1?a=s/(1-o):a=0,o<=0?l=0:n===e?l=(r-i)/o%6:n===r?l=2+(i-e)/o:l=4+(e-r)/o,l/=6,l%=1,[l*360,o*100,a*100]};at.hsl.hcg=function(t){let e=t[1]/100,r=t[2]/100,i=r<.5?2*e*r:2*e*(1-r),n=0;return i<1&&(n=(r-.5*i)/(1-i)),[t[0],i*100,n*100]};at.hsv.hcg=function(t){let e=t[1]/100,r=t[2]/100,i=e*r,n=0;return i<1&&(n=(r-i)/(1-i)),[t[0],i*100,n*100]};at.hcg.rgb=function(t){let e=t[0]/360,r=t[1]/100,i=t[2]/100;if(r===0)return[i*255,i*255,i*255];let n=[0,0,0],s=e%1*6,o=s%1,a=1-o,l=0;switch(Math.floor(s)){case 0:n[0]=1,n[1]=o,n[2]=0;break;case 1:n[0]=a,n[1]=1,n[2]=0;break;case 2:n[0]=0,n[1]=1,n[2]=o;break;case 3:n[0]=0,n[1]=a,n[2]=1;break;case 4:n[0]=o,n[1]=0,n[2]=1;break;default:n[0]=1,n[1]=0,n[2]=a}return l=(1-r)*i,[(r*n[0]+l)*255,(r*n[1]+l)*255,(r*n[2]+l)*255]};at.hcg.hsv=function(t){let e=t[1]/100,r=t[2]/100,i=e+r*(1-e),n=0;return i>0&&(n=e/i),[t[0],n*100,i*100]};at.hcg.hsl=function(t){let e=t[1]/100,i=t[2]/100*(1-e)+.5*e,n=0;return i>0&&i<.5?n=e/(2*i):i>=.5&&i<1&&(n=e/(2*(1-i))),[t[0],n*100,i*100]};at.hcg.hwb=function(t){let e=t[1]/100,r=t[2]/100,i=e+r*(1-e);return[t[0],(i-e)*100,(1-i)*100]};at.hwb.hcg=function(t){let e=t[1]/100,r=t[2]/100,i=1-r,n=i-e,s=0;return n<1&&(s=(i-n)/(1-n)),[t[0],n*100,s*100]};at.apple.rgb=function(t){return[t[0]/65535*255,t[1]/65535*255,t[2]/65535*255]};at.rgb.apple=function(t){return[t[0]/255*65535,t[1]/255*65535,t[2]/255*65535]};at.gray.rgb=function(t){return[t[0]/100*255,t[0]/100*255,t[0]/100*255]};at.gray.hsl=function(t){return[0,0,t[0]]};at.gray.hsv=at.gray.hsl;at.gray.hwb=function(t){return[0,100,t[0]]};at.gray.cmyk=function(t){return[0,0,0,t[0]]};at.gray.lab=function(t){return[t[0],0,0]};at.gray.hex=function(t){let e=Math.round(t[0]/100*255)&255,i=((e<<16)+(e<<8)+e).toString(16).toUpperCase();return"000000".substring(i.length)+i};at.rgb.gray=function(t){return[(t[0]+t[1]+t[2])/3/255*100]}});var PY=w((ert,xY)=>{var wy=uS();function Wwe(){let t={},e=Object.keys(wy);for(let r=e.length,i=0;i<r;i++)t[e[i]]={distance:-1,parent:null};return t}function zwe(t){let e=Wwe(),r=[t];for(e[t].distance=0;r.length;){let i=r.pop(),n=Object.keys(wy[i]);for(let s=n.length,o=0;o<s;o++){let a=n[o],l=e[a];l.distance===-1&&(l.distance=e[i].distance+1,l.parent=i,r.unshift(a))}}return e}function _we(t,e){return function(r){return e(t(r))}}function Vwe(t,e){let r=[e[t].parent,t],i=wy[e[t].parent][t],n=e[t].parent;for(;e[n].parent;)r.unshift(e[n].parent),i=_we(wy[e[n].parent][n],i),n=e[n].parent;return i.conversion=r,i}xY.exports=function(t){let e=zwe(t),r={},i=Object.keys(e);for(let n=i.length,s=0;s<n;s++){let o=i[s];e[o].parent!==null&&(r[o]=Vwe(o,e))}return r}});var RY=w((trt,DY)=>{var gS=uS(),Xwe=PY(),wg={},Zwe=Object.keys(gS);function $we(t){let e=function(...r){let i=r[0];return i==null?i:(i.length>1&&(r=i),t(r))};return"conversion"in t&&(e.conversion=t.conversion),e}function eBe(t){let e=function(...r){let i=r[0];if(i==null)return i;i.length>1&&(r=i);let n=t(r);if(typeof n=="object")for(let s=n.length,o=0;o<s;o++)n[o]=Math.round(n[o]);return n};return"conversion"in t&&(e.conversion=t.conversion),e}Zwe.forEach(t=>{wg[t]={},Object.defineProperty(wg[t],"channels",{value:gS[t].channels}),Object.defineProperty(wg[t],"labels",{value:gS[t].labels});let e=Xwe(t);Object.keys(e).forEach(i=>{let n=e[i];wg[t][i]=eBe(n),wg[t][i].raw=$we(n)})});DY.exports=wg});var MY=w((rrt,FY)=>{"use strict";var NY=(t,e)=>(...r)=>`\e[${t(...r)+e}m`,LY=(t,e)=>(...r)=>{let i=t(...r);return`\e[${38+e};5;${i}m`},TY=(t,e)=>(...r)=>{let i=t(...r);return`\e[${38+e};2;${i[0]};${i[1]};${i[2]}m`},By=t=>t,OY=(t,e,r)=>[t,e,r],Bg=(t,e,r)=>{Object.defineProperty(t,e,{get:()=>{let i=r();return Object.defineProperty(t,e,{value:i,enumerable:!0,configurable:!0}),i},enumerable:!0,configurable:!0})},fS,bg=(t,e,r,i)=>{fS===void 0&&(fS=RY());let n=i?10:0,s={};for(let[o,a]of Object.entries(fS)){let l=o==="ansi16"?"ansi":o;o===e?s[l]=t(r,n):typeof a=="object"&&(s[l]=t(a[e],n))}return s};function tBe(){let t=new Map,e={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};e.color.gray=e.color.blackBright,e.bgColor.bgGray=e.bgColor.bgBlackBright,e.color.grey=e.color.blackBright,e.bgColor.bgGrey=e.bgColor.bgBlackBright;for(let[r,i]of Object.entries(e)){for(let[n,s]of Object.entries(i))e[n]={open:`\e[${s[0]}m`,close:`\e[${s[1]}m`},i[n]=e[n],t.set(s[0],s[1]);Object.defineProperty(e,r,{value:i,enumerable:!1})}return Object.defineProperty(e,"codes",{value:t,enumerable:!1}),e.color.close="\e[39m",e.bgColor.close="\e[49m",Bg(e.color,"ansi",()=>bg(NY,"ansi16",By,!1)),Bg(e.color,"ansi256",()=>bg(LY,"ansi256",By,!1)),Bg(e.color,"ansi16m",()=>bg(TY,"rgb",OY,!1)),Bg(e.bgColor,"ansi",()=>bg(NY,"ansi16",By,!0)),Bg(e.bgColor,"ansi256",()=>bg(LY,"ansi256",By,!0)),Bg(e.bgColor,"ansi16m",()=>bg(TY,"rgb",OY,!0)),e}Object.defineProperty(FY,"exports",{enumerable:!0,get:tBe})});var KY=w((irt,UY)=>{"use strict";UY.exports=(t,e=process.argv)=>{let r=t.startsWith("-")?"":t.length===1?"-":"--",i=e.indexOf(r+t),n=e.indexOf("--");return i!==-1&&(n===-1||i<n)}});var GY=w((nrt,HY)=>{"use strict";var rBe=require("os"),jY=require("tty"),ks=KY(),{env:ui}=process,VA;ks("no-color")||ks("no-colors")||ks("color=false")||ks("color=never")?VA=0:(ks("color")||ks("colors")||ks("color=true")||ks("color=always"))&&(VA=1);"FORCE_COLOR"in ui&&(ui.FORCE_COLOR==="true"?VA=1:ui.FORCE_COLOR==="false"?VA=0:VA=ui.FORCE_COLOR.length===0?1:Math.min(parseInt(ui.FORCE_COLOR,10),3));function hS(t){return t===0?!1:{level:t,hasBasic:!0,has256:t>=2,has16m:t>=3}}function pS(t,e){if(VA===0)return 0;if(ks("color=16m")||ks("color=full")||ks("color=truecolor"))return 3;if(ks("color=256"))return 2;if(t&&!e&&VA===void 0)return 0;let r=VA||0;if(ui.TERM==="dumb")return r;if(process.platform==="win32"){let i=rBe.release().split(".");return Number(i[0])>=10&&Number(i[2])>=10586?Number(i[2])>=14931?3:2:1}if("CI"in ui)return["TRAVIS","CIRCLECI","APPVEYOR","GITLAB_CI"].some(i=>i in ui)||ui.CI_NAME==="codeship"?1:r;if("TEAMCITY_VERSION"in ui)return/^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(ui.TEAMCITY_VERSION)?1:0;if("GITHUB_ACTIONS"in ui)return 1;if(ui.COLORTERM==="truecolor")return 3;if("TERM_PROGRAM"in ui){let i=parseInt((ui.TERM_PROGRAM_VERSION||"").split(".")[0],10);switch(ui.TERM_PROGRAM){case"iTerm.app":return i>=3?3:2;case"Apple_Terminal":return 2}}return/-256(color)?$/i.test(ui.TERM)?2:/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(ui.TERM)||"COLORTERM"in ui?1:r}function iBe(t){let e=pS(t,t&&t.isTTY);return hS(e)}HY.exports={supportsColor:iBe,stdout:hS(pS(!0,jY.isatty(1))),stderr:hS(pS(!0,jY.isatty(2)))}});var qY=w((srt,YY)=>{"use strict";var nBe=(t,e,r)=>{let i=t.indexOf(e);if(i===-1)return t;let n=e.length,s=0,o="";do o+=t.substr(s,i-s)+e+r,s=i+n,i=t.indexOf(e,s);while(i!==-1);return o+=t.substr(s),o},sBe=(t,e,r,i)=>{let n=0,s="";do{let o=t[i-1]==="\r";s+=t.substr(n,(o?i-1:i)-n)+e+(o?`\r
+`:`
+`)+r,n=i+1,i=t.indexOf(`
+`,n)}while(i!==-1);return s+=t.substr(n),s};YY.exports={stringReplaceAll:nBe,stringEncaseCRLFWithFirstIndex:sBe}});var VY=w((ort,JY)=>{"use strict";var oBe=/(?:\\(u(?:[a-f\d]{4}|\{[a-f\d]{1,6}\})|x[a-f\d]{2}|.))|(?:\{(~)?(\w+(?:\([^)]*\))?(?:\.\w+(?:\([^)]*\))?)*)(?:[ \t]|(?=\r?\n)))|(\})|((?:.|[\r\n\f])+?)/gi,WY=/(?:^|\.)(\w+)(?:\(([^)]*)\))?/g,aBe=/^(['"])((?:\\.|(?!\1)[^\\])*)\1$/,ABe=/\\(u(?:[a-f\d]{4}|\{[a-f\d]{1,6}\})|x[a-f\d]{2}|.)|([^\\])/gi,lBe=new Map([["n",`
+`],["r","\r"],["t","   "],["b","\b"],["f","\f"],["v","\v"],["0","\0"],["\\","\\"],["e","\e"],["a","\x07"]]);function zY(t){let e=t[0]==="u",r=t[1]==="{";return e&&!r&&t.length===5||t[0]==="x"&&t.length===3?String.fromCharCode(parseInt(t.slice(1),16)):e&&r?String.fromCodePoint(parseInt(t.slice(2,-1),16)):lBe.get(t)||t}function cBe(t,e){let r=[],i=e.trim().split(/\s*,\s*/g),n;for(let s of i){let o=Number(s);if(!Number.isNaN(o))r.push(o);else if(n=s.match(aBe))r.push(n[2].replace(ABe,(a,l,c)=>l?zY(l):c));else throw new Error(`Invalid Chalk template style argument: ${s} (in style '${t}')`)}return r}function uBe(t){WY.lastIndex=0;let e=[],r;for(;(r=WY.exec(t))!==null;){let i=r[1];if(r[2]){let n=cBe(i,r[2]);e.push([i].concat(n))}else e.push([i])}return e}function _Y(t,e){let r={};for(let n of e)for(let s of n.styles)r[s[0]]=n.inverse?null:s.slice(1);let i=t;for(let[n,s]of Object.entries(r))if(!!Array.isArray(s)){if(!(n in i))throw new Error(`Unknown Chalk style: ${n}`);i=s.length>0?i[n](...s):i[n]}return i}JY.exports=(t,e)=>{let r=[],i=[],n=[];if(e.replace(oBe,(s,o,a,l,c,u)=>{if(o)n.push(zY(o));else if(l){let g=n.join("");n=[],i.push(r.length===0?g:_Y(t,r)(g)),r.push({inverse:a,styles:uBe(l)})}else if(c){if(r.length===0)throw new Error("Found extraneous } in Chalk template literal");i.push(_Y(t,r)(n.join(""))),n=[],r.pop()}else n.push(u)}),i.push(n.join("")),r.length>0){let s=`Chalk template literal is missing ${r.length} closing bracket${r.length===1?"":"s"} (\`}\`)`;throw new Error(s)}return i.join("")}});var IS=w((art,XY)=>{"use strict";var Wp=MY(),{stdout:dS,stderr:CS}=GY(),{stringReplaceAll:gBe,stringEncaseCRLFWithFirstIndex:fBe}=qY(),ZY=["ansi","ansi","ansi256","ansi16m"],Qg=Object.create(null),hBe=(t,e={})=>{if(e.level>3||e.level<0)throw new Error("The `level` option should be an integer from 0 to 3");let r=dS?dS.level:0;t.level=e.level===void 0?r:e.level},$Y=class{constructor(e){return eq(e)}},eq=t=>{let e={};return hBe(e,t),e.template=(...r)=>pBe(e.template,...r),Object.setPrototypeOf(e,by.prototype),Object.setPrototypeOf(e.template,e),e.template.constructor=()=>{throw new Error("`chalk.constructor()` is deprecated. Use `new chalk.Instance()` instead.")},e.template.Instance=$Y,e.template};function by(t){return eq(t)}for(let[t,e]of Object.entries(Wp))Qg[t]={get(){let r=Qy(this,mS(e.open,e.close,this._styler),this._isEmpty);return Object.defineProperty(this,t,{value:r}),r}};Qg.visible={get(){let t=Qy(this,this._styler,!0);return Object.defineProperty(this,"visible",{value:t}),t}};var tq=["rgb","hex","keyword","hsl","hsv","hwb","ansi","ansi256"];for(let t of tq)Qg[t]={get(){let{level:e}=this;return function(...r){let i=mS(Wp.color[ZY[e]][t](...r),Wp.color.close,this._styler);return Qy(this,i,this._isEmpty)}}};for(let t of tq){let e="bg"+t[0].toUpperCase()+t.slice(1);Qg[e]={get(){let{level:r}=this;return function(...i){let n=mS(Wp.bgColor[ZY[r]][t](...i),Wp.bgColor.close,this._styler);return Qy(this,n,this._isEmpty)}}}}var dBe=Object.defineProperties(()=>{},te(N({},Qg),{level:{enumerable:!0,get(){return this._generator.level},set(t){this._generator.level=t}}})),mS=(t,e,r)=>{let i,n;return r===void 0?(i=t,n=e):(i=r.openAll+t,n=e+r.closeAll),{open:t,close:e,openAll:i,closeAll:n,parent:r}},Qy=(t,e,r)=>{let i=(...n)=>CBe(i,n.length===1?""+n[0]:n.join(" "));return i.__proto__=dBe,i._generator=t,i._styler=e,i._isEmpty=r,i},CBe=(t,e)=>{if(t.level<=0||!e)return t._isEmpty?"":e;let r=t._styler;if(r===void 0)return e;let{openAll:i,closeAll:n}=r;if(e.indexOf("\e")!==-1)for(;r!==void 0;)e=gBe(e,r.close,r.open),r=r.parent;let s=e.indexOf(`
+`);return s!==-1&&(e=fBe(e,n,i,s)),i+e+n},ES,pBe=(t,...e)=>{let[r]=e;if(!Array.isArray(r))return e.join(" ");let i=e.slice(1),n=[r.raw[0]];for(let s=1;s<r.length;s++)n.push(String(i[s-1]).replace(/[{}\\]/g,"\\$&"),String(r.raw[s]));return ES===void 0&&(ES=VY()),ES(t,n.join(""))};Object.defineProperties(by.prototype,Qg);var zp=by();zp.supportsColor=dS;zp.stderr=by({level:CS?CS.level:0});zp.stderr.supportsColor=CS;zp.Level={None:0,Basic:1,Ansi256:2,TrueColor:3,0:"None",1:"Basic",2:"Ansi256",3:"TrueColor"};XY.exports=zp});var vy=w(xs=>{"use strict";xs.isInteger=t=>typeof t=="number"?Number.isInteger(t):typeof t=="string"&&t.trim()!==""?Number.isInteger(Number(t)):!1;xs.find=(t,e)=>t.nodes.find(r=>r.type===e);xs.exceedsLimit=(t,e,r=1,i)=>i===!1||!xs.isInteger(t)||!xs.isInteger(e)?!1:(Number(e)-Number(t))/Number(r)>=i;xs.escapeNode=(t,e=0,r)=>{let i=t.nodes[e];!i||(r&&i.type===r||i.type==="open"||i.type==="close")&&i.escaped!==!0&&(i.value="\\"+i.value,i.escaped=!0)};xs.encloseBrace=t=>t.type!=="brace"?!1:t.commas>>0+t.ranges>>0==0?(t.invalid=!0,!0):!1;xs.isInvalidBrace=t=>t.type!=="brace"?!1:t.invalid===!0||t.dollar?!0:t.commas>>0+t.ranges>>0==0||t.open!==!0||t.close!==!0?(t.invalid=!0,!0):!1;xs.isOpenOrClose=t=>t.type==="open"||t.type==="close"?!0:t.open===!0||t.close===!0;xs.reduce=t=>t.reduce((e,r)=>(r.type==="text"&&e.push(r.value),r.type==="range"&&(r.type="text"),e),[]);xs.flatten=(...t)=>{let e=[],r=i=>{for(let n=0;n<i.length;n++){let s=i[n];Array.isArray(s)?r(s,e):s!==void 0&&e.push(s)}return e};return r(t),e}});var Sy=w((lrt,rq)=>{"use strict";var iq=vy();rq.exports=(t,e={})=>{let r=(i,n={})=>{let s=e.escapeInvalid&&iq.isInvalidBrace(n),o=i.invalid===!0&&e.escapeInvalid===!0,a="";if(i.value)return(s||o)&&iq.isOpenOrClose(i)?"\\"+i.value:i.value;if(i.value)return i.value;if(i.nodes)for(let l of i.nodes)a+=r(l);return a};return r(t)}});var sq=w((crt,nq)=>{"use strict";nq.exports=function(t){return typeof t=="number"?t-t==0:typeof t=="string"&&t.trim()!==""?Number.isFinite?Number.isFinite(+t):isFinite(+t):!1}});var hq=w((urt,oq)=>{"use strict";var aq=sq(),vc=(t,e,r)=>{if(aq(t)===!1)throw new TypeError("toRegexRange: expected the first argument to be a number");if(e===void 0||t===e)return String(t);if(aq(e)===!1)throw new TypeError("toRegexRange: expected the second argument to be a number.");let i=N({relaxZeros:!0},r);typeof i.strictZeros=="boolean"&&(i.relaxZeros=i.strictZeros===!1);let n=String(i.relaxZeros),s=String(i.shorthand),o=String(i.capture),a=String(i.wrap),l=t+":"+e+"="+n+s+o+a;if(vc.cache.hasOwnProperty(l))return vc.cache[l].result;let c=Math.min(t,e),u=Math.max(t,e);if(Math.abs(c-u)===1){let m=t+"|"+e;return i.capture?`(${m})`:i.wrap===!1?m:`(?:${m})`}let g=lq(t)||lq(e),f={min:t,max:e,a:c,b:u},h=[],p=[];if(g&&(f.isPadded=g,f.maxLen=String(f.max).length),c<0){let m=u<0?Math.abs(u):1;p=Aq(m,Math.abs(c),f,i),c=f.a=0}return u>=0&&(h=Aq(c,u,f,i)),f.negatives=p,f.positives=h,f.result=mBe(p,h,i),i.capture===!0?f.result=`(${f.result})`:i.wrap!==!1&&h.length+p.length>1&&(f.result=`(?:${f.result})`),vc.cache[l]=f,f.result};function mBe(t,e,r){let i=yS(t,e,"-",!1,r)||[],n=yS(e,t,"",!1,r)||[],s=yS(t,e,"-?",!0,r)||[];return i.concat(s).concat(n).join("|")}function IBe(t,e){let r=1,i=1,n=cq(t,r),s=new Set([e]);for(;t<=n&&n<=e;)s.add(n),r+=1,n=cq(t,r);for(n=uq(e+1,i)-1;t<n&&n<=e;)s.add(n),i+=1,n=uq(e+1,i)-1;return s=[...s],s.sort(EBe),s}function BBe(t,e,r){if(t===e)return{pattern:t,count:[],digits:0};let i=yBe(t,e),n=i.length,s="",o=0;for(let a=0;a<n;a++){let[l,c]=i[a];l===c?s+=l:l!=="0"||c!=="9"?s+=wBe(l,c,r):o++}return o&&(s+=r.shorthand===!0?"\\d":"[0-9]"),{pattern:s,count:[o],digits:n}}function Aq(t,e,r,i){let n=IBe(t,e),s=[],o=t,a;for(let l=0;l<n.length;l++){let c=n[l],u=BBe(String(o),String(c),i),g="";if(!r.isPadded&&a&&a.pattern===u.pattern){a.count.length>1&&a.count.pop(),a.count.push(u.count[0]),a.string=a.pattern+gq(a.count),o=c+1;continue}r.isPadded&&(g=bBe(c,r,i)),u.string=g+u.pattern+gq(u.count),s.push(u),o=c+1,a=u}return s}function yS(t,e,r,i,n){let s=[];for(let o of t){let{string:a}=o;!i&&!fq(e,"string",a)&&s.push(r+a),i&&fq(e,"string",a)&&s.push(r+a)}return s}function yBe(t,e){let r=[];for(let i=0;i<t.length;i++)r.push([t[i],e[i]]);return r}function EBe(t,e){return t>e?1:e>t?-1:0}function fq(t,e,r){return t.some(i=>i[e]===r)}function cq(t,e){return Number(String(t).slice(0,-e)+"9".repeat(e))}function uq(t,e){return t-t%Math.pow(10,e)}function gq(t){let[e=0,r=""]=t;return r||e>1?`{${e+(r?","+r:"")}}`:""}function wBe(t,e,r){return`[${t}${e-t==1?"":"-"}${e}]`}function lq(t){return/^-?(0+)\d/.test(t)}function bBe(t,e,r){if(!e.isPadded)return t;let i=Math.abs(e.maxLen-String(t).length),n=r.relaxZeros!==!1;switch(i){case 0:return"";case 1:return n?"0?":"0";case 2:return n?"0{0,2}":"00";default:return n?`0{0,${i}}`:`0{${i}}`}}vc.cache={};vc.clearCache=()=>vc.cache={};oq.exports=vc});var bS=w((grt,pq)=>{"use strict";var QBe=require("util"),dq=hq(),Cq=t=>t!==null&&typeof t=="object"&&!Array.isArray(t),vBe=t=>e=>t===!0?Number(e):String(e),wS=t=>typeof t=="number"||typeof t=="string"&&t!=="",_p=t=>Number.isInteger(+t),BS=t=>{let e=`${t}`,r=-1;if(e[0]==="-"&&(e=e.slice(1)),e==="0")return!1;for(;e[++r]==="0";);return r>0},SBe=(t,e,r)=>typeof t=="string"||typeof e=="string"?!0:r.stringify===!0,kBe=(t,e,r)=>{if(e>0){let i=t[0]==="-"?"-":"";i&&(t=t.slice(1)),t=i+t.padStart(i?e-1:e,"0")}return r===!1?String(t):t},mq=(t,e)=>{let r=t[0]==="-"?"-":"";for(r&&(t=t.slice(1),e--);t.length<e;)t="0"+t;return r?"-"+t:t},xBe=(t,e)=>{t.negatives.sort((o,a)=>o<a?-1:o>a?1:0),t.positives.sort((o,a)=>o<a?-1:o>a?1:0);let r=e.capture?"":"?:",i="",n="",s;return t.positives.length&&(i=t.positives.join("|")),t.negatives.length&&(n=`-(${r}${t.negatives.join("|")})`),i&&n?s=`${i}|${n}`:s=i||n,e.wrap?`(${r}${s})`:s},Eq=(t,e,r,i)=>{if(r)return dq(t,e,N({wrap:!1},i));let n=String.fromCharCode(t);if(t===e)return n;let s=String.fromCharCode(e);return`[${n}-${s}]`},Iq=(t,e,r)=>{if(Array.isArray(t)){let i=r.wrap===!0,n=r.capture?"":"?:";return i?`(${n}${t.join("|")})`:t.join("|")}return dq(t,e,r)},yq=(...t)=>new RangeError("Invalid range arguments: "+QBe.inspect(...t)),wq=(t,e,r)=>{if(r.strictRanges===!0)throw yq([t,e]);return[]},PBe=(t,e)=>{if(e.strictRanges===!0)throw new TypeError(`Expected step "${t}" to be a number`);return[]},DBe=(t,e,r=1,i={})=>{let n=Number(t),s=Number(e);if(!Number.isInteger(n)||!Number.isInteger(s)){if(i.strictRanges===!0)throw yq([t,e]);return[]}n===0&&(n=0),s===0&&(s=0);let o=n>s,a=String(t),l=String(e),c=String(r);r=Math.max(Math.abs(r),1);let u=BS(a)||BS(l)||BS(c),g=u?Math.max(a.length,l.length,c.length):0,f=u===!1&&SBe(t,e,i)===!1,h=i.transform||vBe(f);if(i.toRegex&&r===1)return Eq(mq(t,g),mq(e,g),!0,i);let p={negatives:[],positives:[]},m=S=>p[S<0?"negatives":"positives"].push(Math.abs(S)),y=[],Q=0;for(;o?n>=s:n<=s;)i.toRegex===!0&&r>1?m(n):y.push(kBe(h(n,Q),g,f)),n=o?n-r:n+r,Q++;return i.toRegex===!0?r>1?xBe(p,i):Iq(y,null,N({wrap:!1},i)):y},RBe=(t,e,r=1,i={})=>{if(!_p(t)&&t.length>1||!_p(e)&&e.length>1)return wq(t,e,i);let n=i.transform||(f=>String.fromCharCode(f)),s=`${t}`.charCodeAt(0),o=`${e}`.charCodeAt(0),a=s>o,l=Math.min(s,o),c=Math.max(s,o);if(i.toRegex&&r===1)return Eq(l,c,!1,i);let u=[],g=0;for(;a?s>=o:s<=o;)u.push(n(s,g)),s=a?s-r:s+r,g++;return i.toRegex===!0?Iq(u,null,{wrap:!1,options:i}):u},ky=(t,e,r,i={})=>{if(e==null&&wS(t))return[t];if(!wS(t)||!wS(e))return wq(t,e,i);if(typeof r=="function")return ky(t,e,1,{transform:r});if(Cq(r))return ky(t,e,0,r);let n=N({},i);return n.capture===!0&&(n.wrap=!0),r=r||n.step||1,_p(r)?_p(t)&&_p(e)?DBe(t,e,r,n):RBe(t,e,Math.max(Math.abs(r),1),n):r!=null&&!Cq(r)?PBe(r,n):ky(t,e,1,r)};pq.exports=ky});var Qq=w((frt,Bq)=>{"use strict";var FBe=bS(),bq=vy(),NBe=(t,e={})=>{let r=(i,n={})=>{let s=bq.isInvalidBrace(n),o=i.invalid===!0&&e.escapeInvalid===!0,a=s===!0||o===!0,l=e.escapeInvalid===!0?"\\":"",c="";if(i.isOpen===!0||i.isClose===!0)return l+i.value;if(i.type==="open")return a?l+i.value:"(";if(i.type==="close")return a?l+i.value:")";if(i.type==="comma")return i.prev.type==="comma"?"":a?i.value:"|";if(i.value)return i.value;if(i.nodes&&i.ranges>0){let u=bq.reduce(i.nodes),g=FBe(...u,te(N({},e),{wrap:!1,toRegex:!0}));if(g.length!==0)return u.length>1&&g.length>1?`(${g})`:g}if(i.nodes)for(let u of i.nodes)c+=r(u,i);return c};return r(t)};Bq.exports=NBe});var kq=w((hrt,vq)=>{"use strict";var LBe=bS(),Sq=Sy(),vg=vy(),Sc=(t="",e="",r=!1)=>{let i=[];if(t=[].concat(t),e=[].concat(e),!e.length)return t;if(!t.length)return r?vg.flatten(e).map(n=>`{${n}}`):e;for(let n of t)if(Array.isArray(n))for(let s of n)i.push(Sc(s,e,r));else for(let s of e)r===!0&&typeof s=="string"&&(s=`{${s}}`),i.push(Array.isArray(s)?Sc(n,s,r):n+s);return vg.flatten(i)},TBe=(t,e={})=>{let r=e.rangeLimit===void 0?1e3:e.rangeLimit,i=(n,s={})=>{n.queue=[];let o=s,a=s.queue;for(;o.type!=="brace"&&o.type!=="root"&&o.parent;)o=o.parent,a=o.queue;if(n.invalid||n.dollar){a.push(Sc(a.pop(),Sq(n,e)));return}if(n.type==="brace"&&n.invalid!==!0&&n.nodes.length===2){a.push(Sc(a.pop(),["{}"]));return}if(n.nodes&&n.ranges>0){let g=vg.reduce(n.nodes);if(vg.exceedsLimit(...g,e.step,r))throw new RangeError("expanded array length exceeds range limit. Use options.rangeLimit to increase or disable the limit.");let f=LBe(...g,e);f.length===0&&(f=Sq(n,e)),a.push(Sc(a.pop(),f)),n.nodes=[];return}let l=vg.encloseBrace(n),c=n.queue,u=n;for(;u.type!=="brace"&&u.type!=="root"&&u.parent;)u=u.parent,c=u.queue;for(let g=0;g<n.nodes.length;g++){let f=n.nodes[g];if(f.type==="comma"&&n.type==="brace"){g===1&&c.push(""),c.push("");continue}if(f.type==="close"){a.push(Sc(a.pop(),c,l));continue}if(f.value&&f.type!=="open"){c.push(Sc(c.pop(),f.value));continue}f.nodes&&i(f,n)}return c};return vg.flatten(i(t))};vq.exports=TBe});var Pq=w((prt,xq)=>{"use strict";xq.exports={MAX_LENGTH:1024*64,CHAR_0:"0",CHAR_9:"9",CHAR_UPPERCASE_A:"A",CHAR_LOWERCASE_A:"a",CHAR_UPPERCASE_Z:"Z",CHAR_LOWERCASE_Z:"z",CHAR_LEFT_PARENTHESES:"(",CHAR_RIGHT_PARENTHESES:")",CHAR_ASTERISK:"*",CHAR_AMPERSAND:"&",CHAR_AT:"@",CHAR_BACKSLASH:"\\",CHAR_BACKTICK:"`",CHAR_CARRIAGE_RETURN:"\r",CHAR_CIRCUMFLEX_ACCENT:"^",CHAR_COLON:":",CHAR_COMMA:",",CHAR_DOLLAR:"$",CHAR_DOT:".",CHAR_DOUBLE_QUOTE:'"',CHAR_EQUAL:"=",CHAR_EXCLAMATION_MARK:"!",CHAR_FORM_FEED:"\f",CHAR_FORWARD_SLASH:"/",CHAR_HASH:"#",CHAR_HYPHEN_MINUS:"-",CHAR_LEFT_ANGLE_BRACKET:"<",CHAR_LEFT_CURLY_BRACE:"{",CHAR_LEFT_SQUARE_BRACKET:"[",CHAR_LINE_FEED:`
+`,CHAR_NO_BREAK_SPACE:"\xA0",CHAR_PERCENT:"%",CHAR_PLUS:"+",CHAR_QUESTION_MARK:"?",CHAR_RIGHT_ANGLE_BRACKET:">",CHAR_RIGHT_CURLY_BRACE:"}",CHAR_RIGHT_SQUARE_BRACKET:"]",CHAR_SEMICOLON:";",CHAR_SINGLE_QUOTE:"'",CHAR_SPACE:" ",CHAR_TAB:"    ",CHAR_UNDERSCORE:"_",CHAR_VERTICAL_LINE:"|",CHAR_ZERO_WIDTH_NOBREAK_SPACE:"\uFEFF"}});var Lq=w((drt,Dq)=>{"use strict";var OBe=Sy(),{MAX_LENGTH:Rq,CHAR_BACKSLASH:QS,CHAR_BACKTICK:MBe,CHAR_COMMA:UBe,CHAR_DOT:KBe,CHAR_LEFT_PARENTHESES:HBe,CHAR_RIGHT_PARENTHESES:jBe,CHAR_LEFT_CURLY_BRACE:GBe,CHAR_RIGHT_CURLY_BRACE:YBe,CHAR_LEFT_SQUARE_BRACKET:Fq,CHAR_RIGHT_SQUARE_BRACKET:Nq,CHAR_DOUBLE_QUOTE:qBe,CHAR_SINGLE_QUOTE:JBe,CHAR_NO_BREAK_SPACE:WBe,CHAR_ZERO_WIDTH_NOBREAK_SPACE:zBe}=Pq(),_Be=(t,e={})=>{if(typeof t!="string")throw new TypeError("Expected a string");let r=e||{},i=typeof r.maxLength=="number"?Math.min(Rq,r.maxLength):Rq;if(t.length>i)throw new SyntaxError(`Input length (${t.length}), exceeds max characters (${i})`);let n={type:"root",input:t,nodes:[]},s=[n],o=n,a=n,l=0,c=t.length,u=0,g=0,f,h={},p=()=>t[u++],m=y=>{if(y.type==="text"&&a.type==="dot"&&(a.type="text"),a&&a.type==="text"&&y.type==="text"){a.value+=y.value;return}return o.nodes.push(y),y.parent=o,y.prev=a,a=y,y};for(m({type:"bos"});u<c;)if(o=s[s.length-1],f=p(),!(f===zBe||f===WBe)){if(f===QS){m({type:"text",value:(e.keepEscaping?f:"")+p()});continue}if(f===Nq){m({type:"text",value:"\\"+f});continue}if(f===Fq){l++;let y=!0,Q;for(;u<c&&(Q=p());){if(f+=Q,Q===Fq){l++;continue}if(Q===QS){f+=p();continue}if(Q===Nq&&(l--,l===0))break}m({type:"text",value:f});continue}if(f===HBe){o=m({type:"paren",nodes:[]}),s.push(o),m({type:"text",value:f});continue}if(f===jBe){if(o.type!=="paren"){m({type:"text",value:f});continue}o=s.pop(),m({type:"text",value:f}),o=s[s.length-1];continue}if(f===qBe||f===JBe||f===MBe){let y=f,Q;for(e.keepQuotes!==!0&&(f="");u<c&&(Q=p());){if(Q===QS){f+=Q+p();continue}if(Q===y){e.keepQuotes===!0&&(f+=Q);break}f+=Q}m({type:"text",value:f});continue}if(f===GBe){g++;let y=a.value&&a.value.slice(-1)==="$"||o.dollar===!0;o=m({type:"brace",open:!0,close:!1,dollar:y,depth:g,commas:0,ranges:0,nodes:[]}),s.push(o),m({type:"open",value:f});continue}if(f===YBe){if(o.type!=="brace"){m({type:"text",value:f});continue}let y="close";o=s.pop(),o.close=!0,m({type:y,value:f}),g--,o=s[s.length-1];continue}if(f===UBe&&g>0){if(o.ranges>0){o.ranges=0;let y=o.nodes.shift();o.nodes=[y,{type:"text",value:OBe(o)}]}m({type:"comma",value:f}),o.commas++;continue}if(f===KBe&&g>0&&o.commas===0){let y=o.nodes;if(g===0||y.length===0){m({type:"text",value:f});continue}if(a.type==="dot"){if(o.range=[],a.value+=f,a.type="range",o.nodes.length!==3&&o.nodes.length!==5){o.invalid=!0,o.ranges=0,a.type="text";continue}o.ranges++,o.args=[];continue}if(a.type==="range"){y.pop();let Q=y[y.length-1];Q.value+=a.value+f,a=Q,o.ranges--;continue}m({type:"dot",value:f});continue}m({type:"text",value:f})}do if(o=s.pop(),o.type!=="root"){o.nodes.forEach(S=>{S.nodes||(S.type==="open"&&(S.isOpen=!0),S.type==="close"&&(S.isClose=!0),S.nodes||(S.type="text"),S.invalid=!0)});let y=s[s.length-1],Q=y.nodes.indexOf(o);y.nodes.splice(Q,1,...o.nodes)}while(s.length>0);return m({type:"eos"}),n};Dq.exports=_Be});var Mq=w((Crt,Tq)=>{"use strict";var Oq=Sy(),VBe=Qq(),XBe=kq(),ZBe=Lq(),$n=(t,e={})=>{let r=[];if(Array.isArray(t))for(let i of t){let n=$n.create(i,e);Array.isArray(n)?r.push(...n):r.push(n)}else r=[].concat($n.create(t,e));return e&&e.expand===!0&&e.nodupes===!0&&(r=[...new Set(r)]),r};$n.parse=(t,e={})=>ZBe(t,e);$n.stringify=(t,e={})=>typeof t=="string"?Oq($n.parse(t,e),e):Oq(t,e);$n.compile=(t,e={})=>(typeof t=="string"&&(t=$n.parse(t,e)),VBe(t,e));$n.expand=(t,e={})=>{typeof t=="string"&&(t=$n.parse(t,e));let r=XBe(t,e);return e.noempty===!0&&(r=r.filter(Boolean)),e.nodupes===!0&&(r=[...new Set(r)]),r};$n.create=(t,e={})=>t===""||t.length<3?[t]:e.expand!==!0?$n.compile(t,e):$n.expand(t,e);Tq.exports=$n});var Vp=w((mrt,Uq)=>{"use strict";var $Be=require("path"),Go="\\\\/",Kq=`[^${Go}]`,Ya="\\.",e0e="\\+",t0e="\\?",xy="\\/",r0e="(?=.)",Hq="[^/]",vS=`(?:${xy}|$)`,jq=`(?:^|${xy})`,SS=`${Ya}{1,2}${vS}`,i0e=`(?!${Ya})`,n0e=`(?!${jq}${SS})`,s0e=`(?!${Ya}{0,1}${vS})`,o0e=`(?!${SS})`,a0e=`[^.${xy}]`,A0e=`${Hq}*?`,Gq={DOT_LITERAL:Ya,PLUS_LITERAL:e0e,QMARK_LITERAL:t0e,SLASH_LITERAL:xy,ONE_CHAR:r0e,QMARK:Hq,END_ANCHOR:vS,DOTS_SLASH:SS,NO_DOT:i0e,NO_DOTS:n0e,NO_DOT_SLASH:s0e,NO_DOTS_SLASH:o0e,QMARK_NO_DOT:a0e,STAR:A0e,START_ANCHOR:jq},l0e=te(N({},Gq),{SLASH_LITERAL:`[${Go}]`,QMARK:Kq,STAR:`${Kq}*?`,DOTS_SLASH:`${Ya}{1,2}(?:[${Go}]|$)`,NO_DOT:`(?!${Ya})`,NO_DOTS:`(?!(?:^|[${Go}])${Ya}{1,2}(?:[${Go}]|$))`,NO_DOT_SLASH:`(?!${Ya}{0,1}(?:[${Go}]|$))`,NO_DOTS_SLASH:`(?!${Ya}{1,2}(?:[${Go}]|$))`,QMARK_NO_DOT:`[^.${Go}]`,START_ANCHOR:`(?:^|[${Go}])`,END_ANCHOR:`(?:[${Go}]|$)`}),c0e={alnum:"a-zA-Z0-9",alpha:"a-zA-Z",ascii:"\\x00-\\x7F",blank:" \\t",cntrl:"\\x00-\\x1F\\x7F",digit:"0-9",graph:"\\x21-\\x7E",lower:"a-z",print:"\\x20-\\x7E ",punct:"\\-!\"#$%&'()\\*+,./:;<=>?@[\\]^_`{|}~",space:" \\t\\r\\n\\v\\f",upper:"A-Z",word:"A-Za-z0-9_",xdigit:"A-Fa-f0-9"};Uq.exports={MAX_LENGTH:1024*64,POSIX_REGEX_SOURCE:c0e,REGEX_BACKSLASH:/\\(?![*+?^${}(|)[\]])/g,REGEX_NON_SPECIAL_CHARS:/^[^@![\].,$*+?^{}()|\\/]+/,REGEX_SPECIAL_CHARS:/[-*+?.^${}(|)[\]]/,REGEX_SPECIAL_CHARS_BACKREF:/(\\?)((\W)(\3*))/g,REGEX_SPECIAL_CHARS_GLOBAL:/([-*+?.^${}(|)[\]])/g,REGEX_REMOVE_BACKSLASH:/(?:\[.*?[^\\]\]|\\(?=.))/g,REPLACEMENTS:{"***":"*","**/**":"**","**/**/**":"**"},CHAR_0:48,CHAR_9:57,CHAR_UPPERCASE_A:65,CHAR_LOWERCASE_A:97,CHAR_UPPERCASE_Z:90,CHAR_LOWERCASE_Z:122,CHAR_LEFT_PARENTHESES:40,CHAR_RIGHT_PARENTHESES:41,CHAR_ASTERISK:42,CHAR_AMPERSAND:38,CHAR_AT:64,CHAR_BACKWARD_SLASH:92,CHAR_CARRIAGE_RETURN:13,CHAR_CIRCUMFLEX_ACCENT:94,CHAR_COLON:58,CHAR_COMMA:44,CHAR_DOT:46,CHAR_DOUBLE_QUOTE:34,CHAR_EQUAL:61,CHAR_EXCLAMATION_MARK:33,CHAR_FORM_FEED:12,CHAR_FORWARD_SLASH:47,CHAR_GRAVE_ACCENT:96,CHAR_HASH:35,CHAR_HYPHEN_MINUS:45,CHAR_LEFT_ANGLE_BRACKET:60,CHAR_LEFT_CURLY_BRACE:123,CHAR_LEFT_SQUARE_BRACKET:91,CHAR_LINE_FEED:10,CHAR_NO_BREAK_SPACE:160,CHAR_PERCENT:37,CHAR_PLUS:43,CHAR_QUESTION_MARK:63,CHAR_RIGHT_ANGLE_BRACKET:62,CHAR_RIGHT_CURLY_BRACE:125,CHAR_RIGHT_SQUARE_BRACKET:93,CHAR_SEMICOLON:59,CHAR_SINGLE_QUOTE:39,CHAR_SPACE:32,CHAR_TAB:9,CHAR_UNDERSCORE:95,CHAR_VERTICAL_LINE:124,CHAR_ZERO_WIDTH_NOBREAK_SPACE:65279,SEP:$Be.sep,extglobChars(t){return{"!":{type:"negate",open:"(?:(?!(?:",close:`))${t.STAR})`},"?":{type:"qmark",open:"(?:",close:")?"},"+":{type:"plus",open:"(?:",close:")+"},"*":{type:"star",open:"(?:",close:")*"},"@":{type:"at",open:"(?:",close:")"}}},globChars(t){return t===!0?l0e:Gq}}});var Xp=w(kn=>{"use strict";var u0e=require("path"),g0e=process.platform==="win32",{REGEX_BACKSLASH:f0e,REGEX_REMOVE_BACKSLASH:h0e,REGEX_SPECIAL_CHARS:p0e,REGEX_SPECIAL_CHARS_GLOBAL:d0e}=Vp();kn.isObject=t=>t!==null&&typeof t=="object"&&!Array.isArray(t);kn.hasRegexChars=t=>p0e.test(t);kn.isRegexChar=t=>t.length===1&&kn.hasRegexChars(t);kn.escapeRegex=t=>t.replace(d0e,"\\$1");kn.toPosixSlashes=t=>t.replace(f0e,"/");kn.removeBackslashes=t=>t.replace(h0e,e=>e==="\\"?"":e);kn.supportsLookbehinds=()=>{let t=process.version.slice(1).split(".").map(Number);return t.length===3&&t[0]>=9||t[0]===8&&t[1]>=10};kn.isWindows=t=>t&&typeof t.windows=="boolean"?t.windows:g0e===!0||u0e.sep==="\\";kn.escapeLast=(t,e,r)=>{let i=t.lastIndexOf(e,r);return i===-1?t:t[i-1]==="\\"?kn.escapeLast(t,e,i-1):`${t.slice(0,i)}\\${t.slice(i)}`};kn.removePrefix=(t,e={})=>{let r=t;return r.startsWith("./")&&(r=r.slice(2),e.prefix="./"),r};kn.wrapOutput=(t,e={},r={})=>{let i=r.contains?"":"^",n=r.contains?"":"$",s=`${i}(?:${t})${n}`;return e.negated===!0&&(s=`(?:^(?!${s}).*$)`),s}});var Xq=w((Irt,Yq)=>{"use strict";var qq=Xp(),{CHAR_ASTERISK:kS,CHAR_AT:C0e,CHAR_BACKWARD_SLASH:Zp,CHAR_COMMA:m0e,CHAR_DOT:xS,CHAR_EXCLAMATION_MARK:PS,CHAR_FORWARD_SLASH:Jq,CHAR_LEFT_CURLY_BRACE:DS,CHAR_LEFT_PARENTHESES:RS,CHAR_LEFT_SQUARE_BRACKET:E0e,CHAR_PLUS:I0e,CHAR_QUESTION_MARK:Wq,CHAR_RIGHT_CURLY_BRACE:y0e,CHAR_RIGHT_PARENTHESES:zq,CHAR_RIGHT_SQUARE_BRACKET:w0e}=Vp(),_q=t=>t===Jq||t===Zp,Vq=t=>{t.isPrefix!==!0&&(t.depth=t.isGlobstar?Infinity:1)},B0e=(t,e)=>{let r=e||{},i=t.length-1,n=r.parts===!0||r.scanToEnd===!0,s=[],o=[],a=[],l=t,c=-1,u=0,g=0,f=!1,h=!1,p=!1,m=!1,y=!1,Q=!1,S=!1,x=!1,M=!1,Y=!1,U=0,J,W,ee={value:"",depth:0,isGlob:!1},Z=()=>c>=i,A=()=>l.charCodeAt(c+1),ne=()=>(J=W,l.charCodeAt(++c));for(;c<i;){W=ne();let Ee;if(W===Zp){S=ee.backslashes=!0,W=ne(),W===DS&&(Q=!0);continue}if(Q===!0||W===DS){for(U++;Z()!==!0&&(W=ne());){if(W===Zp){S=ee.backslashes=!0,ne();continue}if(W===DS){U++;continue}if(Q!==!0&&W===xS&&(W=ne())===xS){if(f=ee.isBrace=!0,p=ee.isGlob=!0,Y=!0,n===!0)continue;break}if(Q!==!0&&W===m0e){if(f=ee.isBrace=!0,p=ee.isGlob=!0,Y=!0,n===!0)continue;break}if(W===y0e&&(U--,U===0)){Q=!1,f=ee.isBrace=!0,Y=!0;break}}if(n===!0)continue;break}if(W===Jq){if(s.push(c),o.push(ee),ee={value:"",depth:0,isGlob:!1},Y===!0)continue;if(J===xS&&c===u+1){u+=2;continue}g=c+1;continue}if(r.noext!==!0&&(W===I0e||W===C0e||W===kS||W===Wq||W===PS)===!0&&A()===RS){if(p=ee.isGlob=!0,m=ee.isExtglob=!0,Y=!0,W===PS&&c===u&&(M=!0),n===!0){for(;Z()!==!0&&(W=ne());){if(W===Zp){S=ee.backslashes=!0,W=ne();continue}if(W===zq){p=ee.isGlob=!0,Y=!0;break}}continue}break}if(W===kS){if(J===kS&&(y=ee.isGlobstar=!0),p=ee.isGlob=!0,Y=!0,n===!0)continue;break}if(W===Wq){if(p=ee.isGlob=!0,Y=!0,n===!0)continue;break}if(W===E0e){for(;Z()!==!0&&(Ee=ne());){if(Ee===Zp){S=ee.backslashes=!0,ne();continue}if(Ee===w0e){h=ee.isBracket=!0,p=ee.isGlob=!0,Y=!0;break}}if(n===!0)continue;break}if(r.nonegate!==!0&&W===PS&&c===u){x=ee.negated=!0,u++;continue}if(r.noparen!==!0&&W===RS){if(p=ee.isGlob=!0,n===!0){for(;Z()!==!0&&(W=ne());){if(W===RS){S=ee.backslashes=!0,W=ne();continue}if(W===zq){Y=!0;break}}continue}break}if(p===!0){if(Y=!0,n===!0)continue;break}}r.noext===!0&&(m=!1,p=!1);let le=l,Ae="",T="";u>0&&(Ae=l.slice(0,u),l=l.slice(u),g-=u),le&&p===!0&&g>0?(le=l.slice(0,g),T=l.slice(g)):p===!0?(le="",T=l):le=l,le&&le!==""&&le!=="/"&&le!==l&&_q(le.charCodeAt(le.length-1))&&(le=le.slice(0,-1)),r.unescape===!0&&(T&&(T=qq.removeBackslashes(T)),le&&S===!0&&(le=qq.removeBackslashes(le)));let L={prefix:Ae,input:t,start:u,base:le,glob:T,isBrace:f,isBracket:h,isGlob:p,isExtglob:m,isGlobstar:y,negated:x,negatedExtglob:M};if(r.tokens===!0&&(L.maxDepth=0,_q(W)||o.push(ee),L.tokens=o),r.parts===!0||r.tokens===!0){let Ee;for(let we=0;we<s.length;we++){let qe=Ee?Ee+1:u,re=s[we],se=t.slice(qe,re);r.tokens&&(we===0&&u!==0?(o[we].isPrefix=!0,o[we].value=Ae):o[we].value=se,Vq(o[we]),L.maxDepth+=o[we].depth),(we!==0||se!=="")&&a.push(se),Ee=re}if(Ee&&Ee+1<t.length){let we=t.slice(Ee+1);a.push(we),r.tokens&&(o[o.length-1].value=we,Vq(o[o.length-1]),L.maxDepth+=o[o.length-1].depth)}L.slashes=s,L.parts=a}return L};Yq.exports=B0e});var tJ=w((yrt,Zq)=>{"use strict";var Py=Vp(),es=Xp(),{MAX_LENGTH:Dy,POSIX_REGEX_SOURCE:b0e,REGEX_NON_SPECIAL_CHARS:Q0e,REGEX_SPECIAL_CHARS_BACKREF:v0e,REPLACEMENTS:$q}=Py,S0e=(t,e)=>{if(typeof e.expandRange=="function")return e.expandRange(...t,e);t.sort();let r=`[${t.join("-")}]`;try{new RegExp(r)}catch(i){return t.map(n=>es.escapeRegex(n)).join("..")}return r},Sg=(t,e)=>`Missing ${t}: "${e}" - use "\\\\${e}" to match literal characters`,eJ=(t,e)=>{if(typeof t!="string")throw new TypeError("Expected a string");t=$q[t]||t;let r=N({},e),i=typeof r.maxLength=="number"?Math.min(Dy,r.maxLength):Dy,n=t.length;if(n>i)throw new SyntaxError(`Input length: ${n}, exceeds maximum allowed length: ${i}`);let s={type:"bos",value:"",output:r.prepend||""},o=[s],a=r.capture?"":"?:",l=es.isWindows(e),c=Py.globChars(l),u=Py.extglobChars(c),{DOT_LITERAL:g,PLUS_LITERAL:f,SLASH_LITERAL:h,ONE_CHAR:p,DOTS_SLASH:m,NO_DOT:y,NO_DOT_SLASH:Q,NO_DOTS_SLASH:S,QMARK:x,QMARK_NO_DOT:M,STAR:Y,START_ANCHOR:U}=c,J=X=>`(${a}(?:(?!${U}${X.dot?m:g}).)*?)`,W=r.dot?"":y,ee=r.dot?x:M,Z=r.bash===!0?J(r):Y;r.capture&&(Z=`(${Z})`),typeof r.noext=="boolean"&&(r.noextglob=r.noext);let A={input:t,index:-1,start:0,dot:r.dot===!0,consumed:"",output:"",prefix:"",backtrack:!1,negated:!1,brackets:0,braces:0,parens:0,quotes:0,globstar:!1,tokens:o};t=es.removePrefix(t,A),n=t.length;let ne=[],le=[],Ae=[],T=s,L,Ee=()=>A.index===n-1,we=A.peek=(X=1)=>t[A.index+X],qe=A.advance=()=>t[++A.index]||"",re=()=>t.slice(A.index+1),se=(X="",be=0)=>{A.consumed+=X,A.index+=be},Qe=X=>{A.output+=X.output!=null?X.output:X.value,se(X.value)},he=()=>{let X=1;for(;we()==="!"&&(we(2)!=="("||we(3)==="?");)qe(),A.start++,X++;return X%2==0?!1:(A.negated=!0,A.start++,!0)},Fe=X=>{A[X]++,Ae.push(X)},Ue=X=>{A[X]--,Ae.pop()},xe=X=>{if(T.type==="globstar"){let be=A.braces>0&&(X.type==="comma"||X.type==="brace"),ce=X.extglob===!0||ne.length&&(X.type==="pipe"||X.type==="paren");X.type!=="slash"&&X.type!=="paren"&&!be&&!ce&&(A.output=A.output.slice(0,-T.output.length),T.type="star",T.value="*",T.output=Z,A.output+=T.output)}if(ne.length&&X.type!=="paren"&&(ne[ne.length-1].inner+=X.value),(X.value||X.output)&&Qe(X),T&&T.type==="text"&&X.type==="text"){T.value+=X.value,T.output=(T.output||"")+X.value;return}X.prev=T,o.push(X),T=X},ve=(X,be)=>{let ce=te(N({},u[be]),{conditions:1,inner:""});ce.prev=T,ce.parens=A.parens,ce.output=A.output;let fe=(r.capture?"(":"")+ce.open;Fe("parens"),xe({type:X,value:be,output:A.output?"":p}),xe({type:"paren",extglob:!0,value:qe(),output:fe}),ne.push(ce)},pe=X=>{let be=X.close+(r.capture?")":""),ce;if(X.type==="negate"){let fe=Z;X.inner&&X.inner.length>1&&X.inner.includes("/")&&(fe=J(r)),(fe!==Z||Ee()||/^\)+$/.test(re()))&&(be=X.close=`)$))${fe}`),X.inner.includes("*")&&(ce=re())&&/^\.[^\\/.]+$/.test(ce)&&(be=X.close=`)${ce})${fe})`),X.prev.type==="bos"&&(A.negatedExtglob=!0)}xe({type:"paren",extglob:!0,value:L,output:be}),Ue("parens")};if(r.fastpaths!==!1&&!/(^[*!]|[/()[\]{}"])/.test(t)){let X=!1,be=t.replace(v0e,(ce,fe,gt,Ht,Mt,mi)=>Ht==="\\"?(X=!0,ce):Ht==="?"?fe?fe+Ht+(Mt?x.repeat(Mt.length):""):mi===0?ee+(Mt?x.repeat(Mt.length):""):x.repeat(gt.length):Ht==="."?g.repeat(gt.length):Ht==="*"?fe?fe+Ht+(Mt?Z:""):Z:fe?ce:`\\${ce}`);return X===!0&&(r.unescape===!0?be=be.replace(/\\/g,""):be=be.replace(/\\+/g,ce=>ce.length%2==0?"\\\\":ce?"\\":"")),be===t&&r.contains===!0?(A.output=t,A):(A.output=es.wrapOutput(be,A,e),A)}for(;!Ee();){if(L=qe(),L==="\0")continue;if(L==="\\"){let ce=we();if(ce==="/"&&r.bash!==!0||ce==="."||ce===";")continue;if(!ce){L+="\\",xe({type:"text",value:L});continue}let fe=/^\\+/.exec(re()),gt=0;if(fe&&fe[0].length>2&&(gt=fe[0].length,A.index+=gt,gt%2!=0&&(L+="\\")),r.unescape===!0?L=qe():L+=qe(),A.brackets===0){xe({type:"text",value:L});continue}}if(A.brackets>0&&(L!=="]"||T.value==="["||T.value==="[^")){if(r.posix!==!1&&L===":"){let ce=T.value.slice(1);if(ce.includes("[")&&(T.posix=!0,ce.includes(":"))){let fe=T.value.lastIndexOf("["),gt=T.value.slice(0,fe),Ht=T.value.slice(fe+2),Mt=b0e[Ht];if(Mt){T.value=gt+Mt,A.backtrack=!0,qe(),!s.output&&o.indexOf(T)===1&&(s.output=p);continue}}}(L==="["&&we()!==":"||L==="-"&&we()==="]")&&(L=`\\${L}`),L==="]"&&(T.value==="["||T.value==="[^")&&(L=`\\${L}`),r.posix===!0&&L==="!"&&T.value==="["&&(L="^"),T.value+=L,Qe({value:L});continue}if(A.quotes===1&&L!=='"'){L=es.escapeRegex(L),T.value+=L,Qe({value:L});continue}if(L==='"'){A.quotes=A.quotes===1?0:1,r.keepQuotes===!0&&xe({type:"text",value:L});continue}if(L==="("){Fe("parens"),xe({type:"paren",value:L});continue}if(L===")"){if(A.parens===0&&r.strictBrackets===!0)throw new SyntaxError(Sg("opening","("));let ce=ne[ne.length-1];if(ce&&A.parens===ce.parens+1){pe(ne.pop());continue}xe({type:"paren",value:L,output:A.parens?")":"\\)"}),Ue("parens");continue}if(L==="["){if(r.nobracket===!0||!re().includes("]")){if(r.nobracket!==!0&&r.strictBrackets===!0)throw new SyntaxError(Sg("closing","]"));L=`\\${L}`}else Fe("brackets");xe({type:"bracket",value:L});continue}if(L==="]"){if(r.nobracket===!0||T&&T.type==="bracket"&&T.value.length===1){xe({type:"text",value:L,output:`\\${L}`});continue}if(A.brackets===0){if(r.strictBrackets===!0)throw new SyntaxError(Sg("opening","["));xe({type:"text",value:L,output:`\\${L}`});continue}Ue("brackets");let ce=T.value.slice(1);if(T.posix!==!0&&ce[0]==="^"&&!ce.includes("/")&&(L=`/${L}`),T.value+=L,Qe({value:L}),r.literalBrackets===!1||es.hasRegexChars(ce))continue;let fe=es.escapeRegex(T.value);if(A.output=A.output.slice(0,-T.value.length),r.literalBrackets===!0){A.output+=fe,T.value=fe;continue}T.value=`(${a}${fe}|${T.value})`,A.output+=T.value;continue}if(L==="{"&&r.nobrace!==!0){Fe("braces");let ce={type:"brace",value:L,output:"(",outputIndex:A.output.length,tokensIndex:A.tokens.length};le.push(ce),xe(ce);continue}if(L==="}"){let ce=le[le.length-1];if(r.nobrace===!0||!ce){xe({type:"text",value:L,output:L});continue}let fe=")";if(ce.dots===!0){let gt=o.slice(),Ht=[];for(let Mt=gt.length-1;Mt>=0&&(o.pop(),gt[Mt].type!=="brace");Mt--)gt[Mt].type!=="dots"&&Ht.unshift(gt[Mt].value);fe=S0e(Ht,r),A.backtrack=!0}if(ce.comma!==!0&&ce.dots!==!0){let gt=A.output.slice(0,ce.outputIndex),Ht=A.tokens.slice(ce.tokensIndex);ce.value=ce.output="\\{",L=fe="\\}",A.output=gt;for(let Mt of Ht)A.output+=Mt.output||Mt.value}xe({type:"brace",value:L,output:fe}),Ue("braces"),le.pop();continue}if(L==="|"){ne.length>0&&ne[ne.length-1].conditions++,xe({type:"text",value:L});continue}if(L===","){let ce=L,fe=le[le.length-1];fe&&Ae[Ae.length-1]==="braces"&&(fe.comma=!0,ce="|"),xe({type:"comma",value:L,output:ce});continue}if(L==="/"){if(T.type==="dot"&&A.index===A.start+1){A.start=A.index+1,A.consumed="",A.output="",o.pop(),T=s;continue}xe({type:"slash",value:L,output:h});continue}if(L==="."){if(A.braces>0&&T.type==="dot"){T.value==="."&&(T.output=g);let ce=le[le.length-1];T.type="dots",T.output+=L,T.value+=L,ce.dots=!0;continue}if(A.braces+A.parens===0&&T.type!=="bos"&&T.type!=="slash"){xe({type:"text",value:L,output:g});continue}xe({type:"dot",value:L,output:g});continue}if(L==="?"){if(!(T&&T.value==="(")&&r.noextglob!==!0&&we()==="("&&we(2)!=="?"){ve("qmark",L);continue}if(T&&T.type==="paren"){let fe=we(),gt=L;if(fe==="<"&&!es.supportsLookbehinds())throw new Error("Node.js v10 or higher is required for regex lookbehinds");(T.value==="("&&!/[!=<:]/.test(fe)||fe==="<"&&!/<([!=]|\w+>)/.test(re()))&&(gt=`\\${L}`),xe({type:"text",value:L,output:gt});continue}if(r.dot!==!0&&(T.type==="slash"||T.type==="bos")){xe({type:"qmark",value:L,output:M});continue}xe({type:"qmark",value:L,output:x});continue}if(L==="!"){if(r.noextglob!==!0&&we()==="("&&(we(2)!=="?"||!/[!=<:]/.test(we(3)))){ve("negate",L);continue}if(r.nonegate!==!0&&A.index===0){he();continue}}if(L==="+"){if(r.noextglob!==!0&&we()==="("&&we(2)!=="?"){ve("plus",L);continue}if(T&&T.value==="("||r.regex===!1){xe({type:"plus",value:L,output:f});continue}if(T&&(T.type==="bracket"||T.type==="paren"||T.type==="brace")||A.parens>0){xe({type:"plus",value:L});continue}xe({type:"plus",value:f});continue}if(L==="@"){if(r.noextglob!==!0&&we()==="("&&we(2)!=="?"){xe({type:"at",extglob:!0,value:L,output:""});continue}xe({type:"text",value:L});continue}if(L!=="*"){(L==="$"||L==="^")&&(L=`\\${L}`);let ce=Q0e.exec(re());ce&&(L+=ce[0],A.index+=ce[0].length),xe({type:"text",value:L});continue}if(T&&(T.type==="globstar"||T.star===!0)){T.type="star",T.star=!0,T.value+=L,T.output=Z,A.backtrack=!0,A.globstar=!0,se(L);continue}let X=re();if(r.noextglob!==!0&&/^\([^?]/.test(X)){ve("star",L);continue}if(T.type==="star"){if(r.noglobstar===!0){se(L);continue}let ce=T.prev,fe=ce.prev,gt=ce.type==="slash"||ce.type==="bos",Ht=fe&&(fe.type==="star"||fe.type==="globstar");if(r.bash===!0&&(!gt||X[0]&&X[0]!=="/")){xe({type:"star",value:L,output:""});continue}let Mt=A.braces>0&&(ce.type==="comma"||ce.type==="brace"),mi=ne.length&&(ce.type==="pipe"||ce.type==="paren");if(!gt&&ce.type!=="paren"&&!Mt&&!mi){xe({type:"star",value:L,output:""});continue}for(;X.slice(0,3)==="/**";){let jt=t[A.index+4];if(jt&&jt!=="/")break;X=X.slice(3),se("/**",3)}if(ce.type==="bos"&&Ee()){T.type="globstar",T.value+=L,T.output=J(r),A.output=T.output,A.globstar=!0,se(L);continue}if(ce.type==="slash"&&ce.prev.type!=="bos"&&!Ht&&Ee()){A.output=A.output.slice(0,-(ce.output+T.output).length),ce.output=`(?:${ce.output}`,T.type="globstar",T.output=J(r)+(r.strictSlashes?")":"|$)"),T.value+=L,A.globstar=!0,A.output+=ce.output+T.output,se(L);continue}if(ce.type==="slash"&&ce.prev.type!=="bos"&&X[0]==="/"){let jt=X[1]!==void 0?"|$":"";A.output=A.output.slice(0,-(ce.output+T.output).length),ce.output=`(?:${ce.output}`,T.type="globstar",T.output=`${J(r)}${h}|${h}${jt})`,T.value+=L,A.output+=ce.output+T.output,A.globstar=!0,se(L+qe()),xe({type:"slash",value:"/",output:""});continue}if(ce.type==="bos"&&X[0]==="/"){T.type="globstar",T.value+=L,T.output=`(?:^|${h}|${J(r)}${h})`,A.output=T.output,A.globstar=!0,se(L+qe()),xe({type:"slash",value:"/",output:""});continue}A.output=A.output.slice(0,-T.output.length),T.type="globstar",T.output=J(r),T.value+=L,A.output+=T.output,A.globstar=!0,se(L);continue}let be={type:"star",value:L,output:Z};if(r.bash===!0){be.output=".*?",(T.type==="bos"||T.type==="slash")&&(be.output=W+be.output),xe(be);continue}if(T&&(T.type==="bracket"||T.type==="paren")&&r.regex===!0){be.output=L,xe(be);continue}(A.index===A.start||T.type==="slash"||T.type==="dot")&&(T.type==="dot"?(A.output+=Q,T.output+=Q):r.dot===!0?(A.output+=S,T.output+=S):(A.output+=W,T.output+=W),we()!=="*"&&(A.output+=p,T.output+=p)),xe(be)}for(;A.brackets>0;){if(r.strictBrackets===!0)throw new SyntaxError(Sg("closing","]"));A.output=es.escapeLast(A.output,"["),Ue("brackets")}for(;A.parens>0;){if(r.strictBrackets===!0)throw new SyntaxError(Sg("closing",")"));A.output=es.escapeLast(A.output,"("),Ue("parens")}for(;A.braces>0;){if(r.strictBrackets===!0)throw new SyntaxError(Sg("closing","}"));A.output=es.escapeLast(A.output,"{"),Ue("braces")}if(r.strictSlashes!==!0&&(T.type==="star"||T.type==="bracket")&&xe({type:"maybe_slash",value:"",output:`${h}?`}),A.backtrack===!0){A.output="";for(let X of A.tokens)A.output+=X.output!=null?X.output:X.value,X.suffix&&(A.output+=X.suffix)}return A};eJ.fastpaths=(t,e)=>{let r=N({},e),i=typeof r.maxLength=="number"?Math.min(Dy,r.maxLength):Dy,n=t.length;if(n>i)throw new SyntaxError(`Input length: ${n}, exceeds maximum allowed length: ${i}`);t=$q[t]||t;let s=es.isWindows(e),{DOT_LITERAL:o,SLASH_LITERAL:a,ONE_CHAR:l,DOTS_SLASH:c,NO_DOT:u,NO_DOTS:g,NO_DOTS_SLASH:f,STAR:h,START_ANCHOR:p}=Py.globChars(s),m=r.dot?g:u,y=r.dot?f:u,Q=r.capture?"":"?:",S={negated:!1,prefix:""},x=r.bash===!0?".*?":h;r.capture&&(x=`(${x})`);let M=W=>W.noglobstar===!0?x:`(${Q}(?:(?!${p}${W.dot?c:o}).)*?)`,Y=W=>{switch(W){case"*":return`${m}${l}${x}`;case".*":return`${o}${l}${x}`;case"*.*":return`${m}${x}${o}${l}${x}`;case"*/*":return`${m}${x}${a}${l}${y}${x}`;case"**":return m+M(r);case"**/*":return`(?:${m}${M(r)}${a})?${y}${l}${x}`;case"**/*.*":return`(?:${m}${M(r)}${a})?${y}${x}${o}${l}${x}`;case"**/.*":return`(?:${m}${M(r)}${a})?${o}${l}${x}`;default:{let ee=/^(.*?)\.(\w+)$/.exec(W);if(!ee)return;let Z=Y(ee[1]);return Z?Z+o+ee[2]:void 0}}},U=es.removePrefix(t,S),J=Y(U);return J&&r.strictSlashes!==!0&&(J+=`${a}?`),J};Zq.exports=eJ});var iJ=w((wrt,rJ)=>{"use strict";var k0e=require("path"),x0e=Xq(),FS=tJ(),NS=Xp(),P0e=Vp(),D0e=t=>t&&typeof t=="object"&&!Array.isArray(t),zr=(t,e,r=!1)=>{if(Array.isArray(t)){let u=t.map(f=>zr(f,e,r));return f=>{for(let h of u){let p=h(f);if(p)return p}return!1}}let i=D0e(t)&&t.tokens&&t.input;if(t===""||typeof t!="string"&&!i)throw new TypeError("Expected pattern to be a non-empty string");let n=e||{},s=NS.isWindows(e),o=i?zr.compileRe(t,e):zr.makeRe(t,e,!1,!0),a=o.state;delete o.state;let l=()=>!1;if(n.ignore){let u=te(N({},e),{ignore:null,onMatch:null,onResult:null});l=zr(n.ignore,u,r)}let c=(u,g=!1)=>{let{isMatch:f,match:h,output:p}=zr.test(u,o,e,{glob:t,posix:s}),m={glob:t,state:a,regex:o,posix:s,input:u,output:p,match:h,isMatch:f};return typeof n.onResult=="function"&&n.onResult(m),f===!1?(m.isMatch=!1,g?m:!1):l(u)?(typeof n.onIgnore=="function"&&n.onIgnore(m),m.isMatch=!1,g?m:!1):(typeof n.onMatch=="function"&&n.onMatch(m),g?m:!0)};return r&&(c.state=a),c};zr.test=(t,e,r,{glob:i,posix:n}={})=>{if(typeof t!="string")throw new TypeError("Expected input to be a string");if(t==="")return{isMatch:!1,output:""};let s=r||{},o=s.format||(n?NS.toPosixSlashes:null),a=t===i,l=a&&o?o(t):t;return a===!1&&(l=o?o(t):t,a=l===i),(a===!1||s.capture===!0)&&(s.matchBase===!0||s.basename===!0?a=zr.matchBase(t,e,r,n):a=e.exec(l)),{isMatch:Boolean(a),match:a,output:l}};zr.matchBase=(t,e,r,i=NS.isWindows(r))=>(e instanceof RegExp?e:zr.makeRe(e,r)).test(k0e.basename(t));zr.isMatch=(t,e,r)=>zr(e,r)(t);zr.parse=(t,e)=>Array.isArray(t)?t.map(r=>zr.parse(r,e)):FS(t,te(N({},e),{fastpaths:!1}));zr.scan=(t,e)=>x0e(t,e);zr.compileRe=(t,e,r=!1,i=!1)=>{if(r===!0)return t.output;let n=e||{},s=n.contains?"":"^",o=n.contains?"":"$",a=`${s}(?:${t.output})${o}`;t&&t.negated===!0&&(a=`^(?!${a}).*$`);let l=zr.toRegex(a,e);return i===!0&&(l.state=t),l};zr.makeRe=(t,e={},r=!1,i=!1)=>{if(!t||typeof t!="string")throw new TypeError("Expected a non-empty string");let n={negated:!1,fastpaths:!0};return e.fastpaths!==!1&&(t[0]==="."||t[0]==="*")&&(n.output=FS.fastpaths(t,e)),n.output||(n=FS(t,e)),zr.compileRe(n,e,r,i)};zr.toRegex=(t,e)=>{try{let r=e||{};return new RegExp(t,r.flags||(r.nocase?"i":""))}catch(r){if(e&&e.debug===!0)throw r;return/$^/}};zr.constants=P0e;rJ.exports=zr});var LS=w((Brt,nJ)=>{"use strict";nJ.exports=iJ()});var ts=w((brt,sJ)=>{"use strict";var oJ=require("util"),aJ=Mq(),Yo=LS(),TS=Xp(),AJ=t=>t===""||t==="./",Pr=(t,e,r)=>{e=[].concat(e),t=[].concat(t);let i=new Set,n=new Set,s=new Set,o=0,a=u=>{s.add(u.output),r&&r.onResult&&r.onResult(u)};for(let u=0;u<e.length;u++){let g=Yo(String(e[u]),te(N({},r),{onResult:a}),!0),f=g.state.negated||g.state.negatedExtglob;f&&o++;for(let h of t){let p=g(h,!0);!(f?!p.isMatch:p.isMatch)||(f?i.add(p.output):(i.delete(p.output),n.add(p.output)))}}let c=(o===e.length?[...s]:[...n]).filter(u=>!i.has(u));if(r&&c.length===0){if(r.failglob===!0)throw new Error(`No matches found for "${e.join(", ")}"`);if(r.nonull===!0||r.nullglob===!0)return r.unescape?e.map(u=>u.replace(/\\/g,"")):e}return c};Pr.match=Pr;Pr.matcher=(t,e)=>Yo(t,e);Pr.isMatch=(t,e,r)=>Yo(e,r)(t);Pr.any=Pr.isMatch;Pr.not=(t,e,r={})=>{e=[].concat(e).map(String);let i=new Set,n=[],s=a=>{r.onResult&&r.onResult(a),n.push(a.output)},o=Pr(t,e,te(N({},r),{onResult:s}));for(let a of n)o.includes(a)||i.add(a);return[...i]};Pr.contains=(t,e,r)=>{if(typeof t!="string")throw new TypeError(`Expected a string: "${oJ.inspect(t)}"`);if(Array.isArray(e))return e.some(i=>Pr.contains(t,i,r));if(typeof e=="string"){if(AJ(t)||AJ(e))return!1;if(t.includes(e)||t.startsWith("./")&&t.slice(2).includes(e))return!0}return Pr.isMatch(t,e,te(N({},r),{contains:!0}))};Pr.matchKeys=(t,e,r)=>{if(!TS.isObject(t))throw new TypeError("Expected the first argument to be an object");let i=Pr(Object.keys(t),e,r),n={};for(let s of i)n[s]=t[s];return n};Pr.some=(t,e,r)=>{let i=[].concat(t);for(let n of[].concat(e)){let s=Yo(String(n),r);if(i.some(o=>s(o)))return!0}return!1};Pr.every=(t,e,r)=>{let i=[].concat(t);for(let n of[].concat(e)){let s=Yo(String(n),r);if(!i.every(o=>s(o)))return!1}return!0};Pr.all=(t,e,r)=>{if(typeof t!="string")throw new TypeError(`Expected a string: "${oJ.inspect(t)}"`);return[].concat(e).every(i=>Yo(i,r)(t))};Pr.capture=(t,e,r)=>{let i=TS.isWindows(r),s=Yo.makeRe(String(t),te(N({},r),{capture:!0})).exec(i?TS.toPosixSlashes(e):e);if(s)return s.slice(1).map(o=>o===void 0?"":o)};Pr.makeRe=(...t)=>Yo.makeRe(...t);Pr.scan=(...t)=>Yo.scan(...t);Pr.parse=(t,e)=>{let r=[];for(let i of[].concat(t||[]))for(let n of aJ(String(i),e))r.push(Yo.parse(n,e));return r};Pr.braces=(t,e)=>{if(typeof t!="string")throw new TypeError("Expected a string");return e&&e.nobrace===!0||!/\{.*\}/.test(t)?[t]:aJ(t,e)};Pr.braceExpand=(t,e)=>{if(typeof t!="string")throw new TypeError("Expected a string");return Pr.braces(t,te(N({},e),{expand:!0}))};sJ.exports=Pr});var cJ=w((Qrt,lJ)=>{"use strict";lJ.exports=({onlyFirst:t=!1}={})=>{let e=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(e,t?void 0:"g")}});var gJ=w((vrt,uJ)=>{"use strict";var R0e=cJ();uJ.exports=t=>typeof t=="string"?t.replace(R0e(),""):t});var kJ=w((Yrt,SJ)=>{"use strict";SJ.exports=(...t)=>[...new Set([].concat(...t))]});var XS=w((qrt,xJ)=>{"use strict";var Y0e=require("stream"),PJ=Y0e.PassThrough,q0e=Array.prototype.slice;xJ.exports=J0e;function J0e(){let t=[],e=!1,r=q0e.call(arguments),i=r[r.length-1];i&&!Array.isArray(i)&&i.pipe==null?r.pop():i={};let n=i.end!==!1;i.objectMode==null&&(i.objectMode=!0),i.highWaterMark==null&&(i.highWaterMark=64*1024);let s=PJ(i);function o(){for(let c=0,u=arguments.length;c<u;c++)t.push(DJ(arguments[c],i));return a(),this}function a(){if(e)return;e=!0;let c=t.shift();if(!c){process.nextTick(l);return}Array.isArray(c)||(c=[c]);let u=c.length+1;function g(){--u>0||(e=!1,a())}function f(h){function p(){h.removeListener("merge2UnpipeEnd",p),h.removeListener("end",p),g()}if(h._readableState.endEmitted)return g();h.on("merge2UnpipeEnd",p),h.on("end",p),h.pipe(s,{end:!1}),h.resume()}for(let h=0;h<c.length;h++)f(c[h]);g()}function l(){return e=!1,s.emit("queueDrain"),n&&s.end()}return s.setMaxListeners(0),s.add=o,s.on("unpipe",function(c){c.emit("merge2UnpipeEnd")}),r.length&&o.apply(null,r),s}function DJ(t,e){if(Array.isArray(t))for(let r=0,i=t.length;r<i;r++)t[r]=DJ(t[r],e);else{if(!t._readableState&&t.pipe&&(t=t.pipe(PJ(e))),!t._readableState||!t.pause||!t.pipe)throw new Error("Only readable stream can be merged.");t.pause()}return t}});var RJ=w(Ty=>{"use strict";Object.defineProperty(Ty,"__esModule",{value:!0});function W0e(t){return t.reduce((e,r)=>[].concat(e,r),[])}Ty.flatten=W0e;function z0e(t,e){let r=[[]],i=0;for(let n of t)e(n)?(i++,r[i]=[]):r[i].push(n);return r}Ty.splitWhen=z0e});var FJ=w(ZS=>{"use strict";Object.defineProperty(ZS,"__esModule",{value:!0});function _0e(t){return t.code==="ENOENT"}ZS.isEnoentCodeError=_0e});var LJ=w($S=>{"use strict";Object.defineProperty($S,"__esModule",{value:!0});var NJ=class{constructor(e,r){this.name=e,this.isBlockDevice=r.isBlockDevice.bind(r),this.isCharacterDevice=r.isCharacterDevice.bind(r),this.isDirectory=r.isDirectory.bind(r),this.isFIFO=r.isFIFO.bind(r),this.isFile=r.isFile.bind(r),this.isSocket=r.isSocket.bind(r),this.isSymbolicLink=r.isSymbolicLink.bind(r)}};function V0e(t,e){return new NJ(t,e)}$S.createDirentFromStats=V0e});var TJ=w(Ng=>{"use strict";Object.defineProperty(Ng,"__esModule",{value:!0});var X0e=require("path"),Z0e=2,$0e=/(\\?)([()*?[\]{|}]|^!|[!+@](?=\())/g;function ebe(t){return t.replace(/\\/g,"/")}Ng.unixify=ebe;function tbe(t,e){return X0e.resolve(t,e)}Ng.makeAbsolute=tbe;function rbe(t){return t.replace($0e,"\\$2")}Ng.escape=rbe;function ibe(t){if(t.charAt(0)==="."){let e=t.charAt(1);if(e==="/"||e==="\\")return t.slice(Z0e)}return t}Ng.removeLeadingDotSegment=ibe});var MJ=w((Vrt,OJ)=>{OJ.exports=function(e){if(typeof e!="string"||e==="")return!1;for(var r;r=/(\\).|([@?!+*]\(.*\))/g.exec(e);){if(r[2])return!0;e=e.slice(r.index+r[0].length)}return!1}});var HJ=w((Xrt,UJ)=>{var nbe=MJ(),KJ={"{":"}","(":")","[":"]"},sbe=function(t){if(t[0]==="!")return!0;for(var e=0,r=-2,i=-2,n=-2,s=-2,o=-2;e<t.length;){if(t[e]==="*"||t[e+1]==="?"&&/[\].+)]/.test(t[e])||i!==-1&&t[e]==="["&&t[e+1]!=="]"&&(i<e&&(i=t.indexOf("]",e)),i>e&&(o===-1||o>i||(o=t.indexOf("\\",e),o===-1||o>i)))||n!==-1&&t[e]==="{"&&t[e+1]!=="}"&&(n=t.indexOf("}",e),n>e&&(o=t.indexOf("\\",e),o===-1||o>n))||s!==-1&&t[e]==="("&&t[e+1]==="?"&&/[:!=]/.test(t[e+2])&&t[e+3]!==")"&&(s=t.indexOf(")",e),s>e&&(o=t.indexOf("\\",e),o===-1||o>s))||r!==-1&&t[e]==="("&&t[e+1]!=="|"&&(r<e&&(r=t.indexOf("|",e)),r!==-1&&t[r+1]!==")"&&(s=t.indexOf(")",r),s>r&&(o=t.indexOf("\\",r),o===-1||o>s))))return!0;if(t[e]==="\\"){var a=t[e+1];e+=2;var l=KJ[a];if(l){var c=t.indexOf(l,e);c!==-1&&(e=c+1)}if(t[e]==="!")return!0}else e++}return!1},obe=function(t){if(t[0]==="!")return!0;for(var e=0;e<t.length;){if(/[*?{}()[\]]/.test(t[e]))return!0;if(t[e]==="\\"){var r=t[e+1];e+=2;var i=KJ[r];if(i){var n=t.indexOf(i,e);n!==-1&&(e=n+1)}if(t[e]==="!")return!0}else e++}return!1};UJ.exports=function(e,r){if(typeof e!="string"||e==="")return!1;if(nbe(e))return!0;var i=sbe;return r&&r.strict===!1&&(i=obe),i(e)}});var GJ=w((Zrt,jJ)=>{"use strict";var abe=HJ(),Abe=require("path").posix.dirname,lbe=require("os").platform()==="win32",ek="/",cbe=/\\/g,ube=/[\{\[].*[\}\]]$/,gbe=/(^|[^\\])([\{\[]|\([^\)]+$)/,fbe=/\\([\!\*\?\|\[\]\(\)\{\}])/g;jJ.exports=function(e,r){var i=Object.assign({flipBackslashes:!0},r);i.flipBackslashes&&lbe&&e.indexOf(ek)<0&&(e=e.replace(cbe,ek)),ube.test(e)&&(e+=ek),e+="a";do e=Abe(e);while(abe(e)||gbe.test(e));return e.replace(fbe,"$1")}});var ZJ=w(ni=>{"use strict";Object.defineProperty(ni,"__esModule",{value:!0});var hbe=require("path"),pbe=GJ(),YJ=ts(),dbe=LS(),qJ="**",Cbe="\\",mbe=/[*?]|^!/,Ebe=/\[.*]/,Ibe=/(?:^|[^!*+?@])\(.*\|.*\)/,ybe=/[!*+?@]\(.*\)/,wbe=/{.*(?:,|\.\.).*}/;function WJ(t,e={}){return!JJ(t,e)}ni.isStaticPattern=WJ;function JJ(t,e={}){return!!(e.caseSensitiveMatch===!1||t.includes(Cbe)||mbe.test(t)||Ebe.test(t)||Ibe.test(t)||e.extglob!==!1&&ybe.test(t)||e.braceExpansion!==!1&&wbe.test(t))}ni.isDynamicPattern=JJ;function Bbe(t){return Oy(t)?t.slice(1):t}ni.convertToPositivePattern=Bbe;function bbe(t){return"!"+t}ni.convertToNegativePattern=bbe;function Oy(t){return t.startsWith("!")&&t[1]!=="("}ni.isNegativePattern=Oy;function zJ(t){return!Oy(t)}ni.isPositivePattern=zJ;function Qbe(t){return t.filter(Oy)}ni.getNegativePatterns=Qbe;function vbe(t){return t.filter(zJ)}ni.getPositivePatterns=vbe;function Sbe(t){return pbe(t,{flipBackslashes:!1})}ni.getBaseDirectory=Sbe;function kbe(t){return t.includes(qJ)}ni.hasGlobStar=kbe;function _J(t){return t.endsWith("/"+qJ)}ni.endsWithSlashGlobStar=_J;function xbe(t){let e=hbe.basename(t);return _J(t)||WJ(e)}ni.isAffectDepthOfReadingPattern=xbe;function Pbe(t){return t.reduce((e,r)=>e.concat(VJ(r)),[])}ni.expandPatternsWithBraceExpansion=Pbe;function VJ(t){return YJ.braces(t,{expand:!0,nodupes:!0})}ni.expandBraceExpansion=VJ;function Dbe(t,e){let r=dbe.scan(t,Object.assign(Object.assign({},e),{parts:!0}));return r.parts.length===0?[t]:r.parts}ni.getPatternParts=Dbe;function XJ(t,e){return YJ.makeRe(t,e)}ni.makeRe=XJ;function Rbe(t,e){return t.map(r=>XJ(r,e))}ni.convertPatternsToRe=Rbe;function Fbe(t,e){return e.some(r=>r.test(t))}ni.matchAny=Fbe});var e3=w(tk=>{"use strict";Object.defineProperty(tk,"__esModule",{value:!0});var Nbe=XS();function Lbe(t){let e=Nbe(t);return t.forEach(r=>{r.once("error",i=>e.emit("error",i))}),e.once("close",()=>$J(t)),e.once("end",()=>$J(t)),e}tk.merge=Lbe;function $J(t){t.forEach(e=>e.emit("close"))}});var t3=w(My=>{"use strict";Object.defineProperty(My,"__esModule",{value:!0});function Tbe(t){return typeof t=="string"}My.isString=Tbe;function Obe(t){return t===""}My.isEmpty=Obe});var Wa=w(Ja=>{"use strict";Object.defineProperty(Ja,"__esModule",{value:!0});var Mbe=RJ();Ja.array=Mbe;var Ube=FJ();Ja.errno=Ube;var Kbe=LJ();Ja.fs=Kbe;var Hbe=TJ();Ja.path=Hbe;var jbe=ZJ();Ja.pattern=jbe;var Gbe=e3();Ja.stream=Gbe;var Ybe=t3();Ja.string=Ybe});var o3=w(za=>{"use strict";Object.defineProperty(za,"__esModule",{value:!0});var Rc=Wa();function qbe(t,e){let r=r3(t),i=i3(t,e.ignore),n=r.filter(l=>Rc.pattern.isStaticPattern(l,e)),s=r.filter(l=>Rc.pattern.isDynamicPattern(l,e)),o=rk(n,i,!1),a=rk(s,i,!0);return o.concat(a)}za.generate=qbe;function rk(t,e,r){let i=n3(t);return"."in i?[ik(".",t,e,r)]:s3(i,e,r)}za.convertPatternsToTasks=rk;function r3(t){return Rc.pattern.getPositivePatterns(t)}za.getPositivePatterns=r3;function i3(t,e){return Rc.pattern.getNegativePatterns(t).concat(e).map(Rc.pattern.convertToPositivePattern)}za.getNegativePatternsAsPositive=i3;function n3(t){let e={};return t.reduce((r,i)=>{let n=Rc.pattern.getBaseDirectory(i);return n in r?r[n].push(i):r[n]=[i],r},e)}za.groupPatternsByBaseDirectory=n3;function s3(t,e,r){return Object.keys(t).map(i=>ik(i,t[i],e,r))}za.convertPatternGroupsToTasks=s3;function ik(t,e,r,i){return{dynamic:i,positive:e,negative:r,base:t,patterns:[].concat(e,r.map(Rc.pattern.convertToNegativePattern))}}za.convertPatternGroupToTask=ik});var A3=w(Uy=>{"use strict";Object.defineProperty(Uy,"__esModule",{value:!0});Uy.read=void 0;function Jbe(t,e,r){e.fs.lstat(t,(i,n)=>{if(i!==null){a3(r,i);return}if(!n.isSymbolicLink()||!e.followSymbolicLink){nk(r,n);return}e.fs.stat(t,(s,o)=>{if(s!==null){if(e.throwErrorOnBrokenSymbolicLink){a3(r,s);return}nk(r,n);return}e.markSymbolicLink&&(o.isSymbolicLink=()=>!0),nk(r,o)})})}Uy.read=Jbe;function a3(t,e){t(e)}function nk(t,e){t(null,e)}});var l3=w(Ky=>{"use strict";Object.defineProperty(Ky,"__esModule",{value:!0});Ky.read=void 0;function Wbe(t,e){let r=e.fs.lstatSync(t);if(!r.isSymbolicLink()||!e.followSymbolicLink)return r;try{let i=e.fs.statSync(t);return e.markSymbolicLink&&(i.isSymbolicLink=()=>!0),i}catch(i){if(!e.throwErrorOnBrokenSymbolicLink)return r;throw i}}Ky.read=Wbe});var c3=w(XA=>{"use strict";Object.defineProperty(XA,"__esModule",{value:!0});XA.createFileSystemAdapter=XA.FILE_SYSTEM_ADAPTER=void 0;var Hy=require("fs");XA.FILE_SYSTEM_ADAPTER={lstat:Hy.lstat,stat:Hy.stat,lstatSync:Hy.lstatSync,statSync:Hy.statSync};function zbe(t){return t===void 0?XA.FILE_SYSTEM_ADAPTER:Object.assign(Object.assign({},XA.FILE_SYSTEM_ADAPTER),t)}XA.createFileSystemAdapter=zbe});var g3=w(sk=>{"use strict";Object.defineProperty(sk,"__esModule",{value:!0});var _be=c3(),u3=class{constructor(e={}){this._options=e,this.followSymbolicLink=this._getValue(this._options.followSymbolicLink,!0),this.fs=_be.createFileSystemAdapter(this._options.fs),this.markSymbolicLink=this._getValue(this._options.markSymbolicLink,!1),this.throwErrorOnBrokenSymbolicLink=this._getValue(this._options.throwErrorOnBrokenSymbolicLink,!0)}_getValue(e,r){return e!=null?e:r}};sk.default=u3});var Fc=w(ZA=>{"use strict";Object.defineProperty(ZA,"__esModule",{value:!0});ZA.statSync=ZA.stat=ZA.Settings=void 0;var f3=A3(),Vbe=l3(),ok=g3();ZA.Settings=ok.default;function Xbe(t,e,r){if(typeof e=="function"){f3.read(t,ak(),e);return}f3.read(t,ak(e),r)}ZA.stat=Xbe;function Zbe(t,e){let r=ak(e);return Vbe.read(t,r)}ZA.statSync=Zbe;function ak(t={}){return t instanceof ok.default?t:new ok.default(t)}});var p3=w((lit,h3)=>{h3.exports=$be;function $be(t,e){var r,i,n,s=!0;Array.isArray(t)?(r=[],i=t.length):(n=Object.keys(t),r={},i=n.length);function o(l){function c(){e&&e(l,r),e=null}s?process.nextTick(c):c()}function a(l,c,u){r[l]=u,(--i==0||c)&&o(c)}i?n?n.forEach(function(l){t[l](function(c,u){a(l,c,u)})}):t.forEach(function(l,c){l(function(u,g){a(c,u,g)})}):o(null),s=!1}});var Ak=w(jy=>{"use strict";Object.defineProperty(jy,"__esModule",{value:!0});jy.IS_SUPPORT_READDIR_WITH_FILE_TYPES=void 0;var Gy=process.versions.node.split(".");if(Gy[0]===void 0||Gy[1]===void 0)throw new Error(`Unexpected behavior. The 'process.versions.node' variable has invalid value: ${process.versions.node}`);var d3=Number.parseInt(Gy[0],10),eQe=Number.parseInt(Gy[1],10),C3=10,tQe=10,rQe=d3>C3,iQe=d3===C3&&eQe>=tQe;jy.IS_SUPPORT_READDIR_WITH_FILE_TYPES=rQe||iQe});var E3=w(Yy=>{"use strict";Object.defineProperty(Yy,"__esModule",{value:!0});Yy.createDirentFromStats=void 0;var m3=class{constructor(e,r){this.name=e,this.isBlockDevice=r.isBlockDevice.bind(r),this.isCharacterDevice=r.isCharacterDevice.bind(r),this.isDirectory=r.isDirectory.bind(r),this.isFIFO=r.isFIFO.bind(r),this.isFile=r.isFile.bind(r),this.isSocket=r.isSocket.bind(r),this.isSymbolicLink=r.isSymbolicLink.bind(r)}};function nQe(t,e){return new m3(t,e)}Yy.createDirentFromStats=nQe});var lk=w(qy=>{"use strict";Object.defineProperty(qy,"__esModule",{value:!0});qy.fs=void 0;var sQe=E3();qy.fs=sQe});var ck=w(Jy=>{"use strict";Object.defineProperty(Jy,"__esModule",{value:!0});Jy.joinPathSegments=void 0;function oQe(t,e,r){return t.endsWith(r)?t+e:t+r+e}Jy.joinPathSegments=oQe});var Q3=w($A=>{"use strict";Object.defineProperty($A,"__esModule",{value:!0});$A.readdir=$A.readdirWithFileTypes=$A.read=void 0;var aQe=Fc(),I3=p3(),AQe=Ak(),y3=lk(),w3=ck();function lQe(t,e,r){if(!e.stats&&AQe.IS_SUPPORT_READDIR_WITH_FILE_TYPES){B3(t,e,r);return}b3(t,e,r)}$A.read=lQe;function B3(t,e,r){e.fs.readdir(t,{withFileTypes:!0},(i,n)=>{if(i!==null){Wy(r,i);return}let s=n.map(a=>({dirent:a,name:a.name,path:w3.joinPathSegments(t,a.name,e.pathSegmentSeparator)}));if(!e.followSymbolicLinks){uk(r,s);return}let o=s.map(a=>cQe(a,e));I3(o,(a,l)=>{if(a!==null){Wy(r,a);return}uk(r,l)})})}$A.readdirWithFileTypes=B3;function cQe(t,e){return r=>{if(!t.dirent.isSymbolicLink()){r(null,t);return}e.fs.stat(t.path,(i,n)=>{if(i!==null){if(e.throwErrorOnBrokenSymbolicLink){r(i);return}r(null,t);return}t.dirent=y3.fs.createDirentFromStats(t.name,n),r(null,t)})}}function b3(t,e,r){e.fs.readdir(t,(i,n)=>{if(i!==null){Wy(r,i);return}let s=n.map(o=>{let a=w3.joinPathSegments(t,o,e.pathSegmentSeparator);return l=>{aQe.stat(a,e.fsStatSettings,(c,u)=>{if(c!==null){l(c);return}let g={name:o,path:a,dirent:y3.fs.createDirentFromStats(o,u)};e.stats&&(g.stats=u),l(null,g)})}});I3(s,(o,a)=>{if(o!==null){Wy(r,o);return}uk(r,a)})})}$A.readdir=b3;function Wy(t,e){t(e)}function uk(t,e){t(null,e)}});var P3=w(el=>{"use strict";Object.defineProperty(el,"__esModule",{value:!0});el.readdir=el.readdirWithFileTypes=el.read=void 0;var uQe=Fc(),gQe=Ak(),v3=lk(),S3=ck();function fQe(t,e){return!e.stats&&gQe.IS_SUPPORT_READDIR_WITH_FILE_TYPES?k3(t,e):x3(t,e)}el.read=fQe;function k3(t,e){return e.fs.readdirSync(t,{withFileTypes:!0}).map(i=>{let n={dirent:i,name:i.name,path:S3.joinPathSegments(t,i.name,e.pathSegmentSeparator)};if(n.dirent.isSymbolicLink()&&e.followSymbolicLinks)try{let s=e.fs.statSync(n.path);n.dirent=v3.fs.createDirentFromStats(n.name,s)}catch(s){if(e.throwErrorOnBrokenSymbolicLink)throw s}return n})}el.readdirWithFileTypes=k3;function x3(t,e){return e.fs.readdirSync(t).map(i=>{let n=S3.joinPathSegments(t,i,e.pathSegmentSeparator),s=uQe.statSync(n,e.fsStatSettings),o={name:i,path:n,dirent:v3.fs.createDirentFromStats(i,s)};return e.stats&&(o.stats=s),o})}el.readdir=x3});var D3=w(tl=>{"use strict";Object.defineProperty(tl,"__esModule",{value:!0});tl.createFileSystemAdapter=tl.FILE_SYSTEM_ADAPTER=void 0;var Lg=require("fs");tl.FILE_SYSTEM_ADAPTER={lstat:Lg.lstat,stat:Lg.stat,lstatSync:Lg.lstatSync,statSync:Lg.statSync,readdir:Lg.readdir,readdirSync:Lg.readdirSync};function hQe(t){return t===void 0?tl.FILE_SYSTEM_ADAPTER:Object.assign(Object.assign({},tl.FILE_SYSTEM_ADAPTER),t)}tl.createFileSystemAdapter=hQe});var F3=w(gk=>{"use strict";Object.defineProperty(gk,"__esModule",{value:!0});var pQe=require("path"),dQe=Fc(),CQe=D3(),R3=class{constructor(e={}){this._options=e,this.followSymbolicLinks=this._getValue(this._options.followSymbolicLinks,!1),this.fs=CQe.createFileSystemAdapter(this._options.fs),this.pathSegmentSeparator=this._getValue(this._options.pathSegmentSeparator,pQe.sep),this.stats=this._getValue(this._options.stats,!1),this.throwErrorOnBrokenSymbolicLink=this._getValue(this._options.throwErrorOnBrokenSymbolicLink,!0),this.fsStatSettings=new dQe.Settings({followSymbolicLink:this.followSymbolicLinks,fs:this.fs,throwErrorOnBrokenSymbolicLink:this.throwErrorOnBrokenSymbolicLink})}_getValue(e,r){return e!=null?e:r}};gk.default=R3});var zy=w(rl=>{"use strict";Object.defineProperty(rl,"__esModule",{value:!0});rl.Settings=rl.scandirSync=rl.scandir=void 0;var N3=Q3(),mQe=P3(),fk=F3();rl.Settings=fk.default;function EQe(t,e,r){if(typeof e=="function"){N3.read(t,hk(),e);return}N3.read(t,hk(e),r)}rl.scandir=EQe;function IQe(t,e){let r=hk(e);return mQe.read(t,r)}rl.scandirSync=IQe;function hk(t={}){return t instanceof fk.default?t:new fk.default(t)}});var T3=w((Eit,L3)=>{"use strict";function yQe(t){var e=new t,r=e;function i(){var s=e;return s.next?e=s.next:(e=new t,r=e),s.next=null,s}function n(s){r.next=s,r=s}return{get:i,release:n}}L3.exports=yQe});var M3=w((Iit,pk)=>{"use strict";var wQe=T3();function O3(t,e,r){if(typeof t=="function"&&(r=e,e=t,t=null),r<1)throw new Error("fastqueue concurrency must be greater than 1");var i=wQe(BQe),n=null,s=null,o=0,a=null,l={push:m,drain:Wo,saturated:Wo,pause:u,paused:!1,concurrency:r,running:c,resume:h,idle:p,length:g,getQueue:f,unshift:y,empty:Wo,kill:S,killAndDrain:x,error:M};return l;function c(){return o}function u(){l.paused=!0}function g(){for(var Y=n,U=0;Y;)Y=Y.next,U++;return U}function f(){for(var Y=n,U=[];Y;)U.push(Y.value),Y=Y.next;return U}function h(){if(!!l.paused){l.paused=!1;for(var Y=0;Y<l.concurrency;Y++)o++,Q()}}function p(){return o===0&&l.length()===0}function m(Y,U){var J=i.get();J.context=t,J.release=Q,J.value=Y,J.callback=U||Wo,J.errorHandler=a,o===l.concurrency||l.paused?s?(s.next=J,s=J):(n=J,s=J,l.saturated()):(o++,e.call(t,J.value,J.worked))}function y(Y,U){var J=i.get();J.context=t,J.release=Q,J.value=Y,J.callback=U||Wo,o===l.concurrency||l.paused?n?(J.next=n,n=J):(n=J,s=J,l.saturated()):(o++,e.call(t,J.value,J.worked))}function Q(Y){Y&&i.release(Y);var U=n;U?l.paused?o--:(s===n&&(s=null),n=U.next,U.next=null,e.call(t,U.value,U.worked),s===null&&l.empty()):--o==0&&l.drain()}function S(){n=null,s=null,l.drain=Wo}function x(){n=null,s=null,l.drain(),l.drain=Wo}function M(Y){a=Y}}function Wo(){}function BQe(){this.value=null,this.callback=Wo,this.next=null,this.release=Wo,this.context=null,this.errorHandler=null;var t=this;this.worked=function(r,i){var n=t.callback,s=t.errorHandler,o=t.value;t.value=null,t.callback=Wo,t.errorHandler&&s(r,o),n.call(t.context,r,i),t.release(t)}}function bQe(t,e,r){typeof t=="function"&&(r=e,e=t,t=null);function i(c,u){e.call(this,c).then(function(g){u(null,g)},u)}var n=O3(t,i,r),s=n.push,o=n.unshift;return n.push=a,n.unshift=l,n;function a(c){return new Promise(function(u,g){s(c,function(f,h){if(f){g(f);return}u(h)})})}function l(c){return new Promise(function(u,g){o(c,function(f,h){if(f){g(f);return}u(h)})})}}pk.exports=O3;pk.exports.promise=bQe});var _y=w(zo=>{"use strict";Object.defineProperty(zo,"__esModule",{value:!0});zo.joinPathSegments=zo.replacePathSegmentSeparator=zo.isAppliedFilter=zo.isFatalError=void 0;function QQe(t,e){return t.errorFilter===null?!0:!t.errorFilter(e)}zo.isFatalError=QQe;function vQe(t,e){return t===null||t(e)}zo.isAppliedFilter=vQe;function SQe(t,e){return t.split(/[/\\]/).join(e)}zo.replacePathSegmentSeparator=SQe;function kQe(t,e,r){return t===""?e:t.endsWith(r)?t+e:t+r+e}zo.joinPathSegments=kQe});var Ck=w(dk=>{"use strict";Object.defineProperty(dk,"__esModule",{value:!0});var xQe=_y(),U3=class{constructor(e,r){this._root=e,this._settings=r,this._root=xQe.replacePathSegmentSeparator(e,r.pathSegmentSeparator)}};dk.default=U3});var Ek=w(mk=>{"use strict";Object.defineProperty(mk,"__esModule",{value:!0});var PQe=require("events"),DQe=zy(),RQe=M3(),Vy=_y(),FQe=Ck(),K3=class extends FQe.default{constructor(e,r){super(e,r);this._settings=r,this._scandir=DQe.scandir,this._emitter=new PQe.EventEmitter,this._queue=RQe(this._worker.bind(this),this._settings.concurrency),this._isFatalError=!1,this._isDestroyed=!1,this._queue.drain=()=>{this._isFatalError||this._emitter.emit("end")}}read(){return this._isFatalError=!1,this._isDestroyed=!1,setImmediate(()=>{this._pushToQueue(this._root,this._settings.basePath)}),this._emitter}get isDestroyed(){return this._isDestroyed}destroy(){if(this._isDestroyed)throw new Error("The reader is already destroyed");this._isDestroyed=!0,this._queue.killAndDrain()}onEntry(e){this._emitter.on("entry",e)}onError(e){this._emitter.once("error",e)}onEnd(e){this._emitter.once("end",e)}_pushToQueue(e,r){let i={directory:e,base:r};this._queue.push(i,n=>{n!==null&&this._handleError(n)})}_worker(e,r){this._scandir(e.directory,this._settings.fsScandirSettings,(i,n)=>{if(i!==null){r(i,void 0);return}for(let s of n)this._handleEntry(s,e.base);r(null,void 0)})}_handleError(e){this._isDestroyed||!Vy.isFatalError(this._settings,e)||(this._isFatalError=!0,this._isDestroyed=!0,this._emitter.emit("error",e))}_handleEntry(e,r){if(this._isDestroyed||this._isFatalError)return;let i=e.path;r!==void 0&&(e.path=Vy.joinPathSegments(r,e.name,this._settings.pathSegmentSeparator)),Vy.isAppliedFilter(this._settings.entryFilter,e)&&this._emitEntry(e),e.dirent.isDirectory()&&Vy.isAppliedFilter(this._settings.deepFilter,e)&&this._pushToQueue(i,e.path)}_emitEntry(e){this._emitter.emit("entry",e)}};mk.default=K3});var j3=w(Ik=>{"use strict";Object.defineProperty(Ik,"__esModule",{value:!0});var NQe=Ek(),H3=class{constructor(e,r){this._root=e,this._settings=r,this._reader=new NQe.default(this._root,this._settings),this._storage=new Set}read(e){this._reader.onError(r=>{LQe(e,r)}),this._reader.onEntry(r=>{this._storage.add(r)}),this._reader.onEnd(()=>{TQe(e,[...this._storage])}),this._reader.read()}};Ik.default=H3;function LQe(t,e){t(e)}function TQe(t,e){t(null,e)}});var Y3=w(yk=>{"use strict";Object.defineProperty(yk,"__esModule",{value:!0});var OQe=require("stream"),MQe=Ek(),G3=class{constructor(e,r){this._root=e,this._settings=r,this._reader=new MQe.default(this._root,this._settings),this._stream=new OQe.Readable({objectMode:!0,read:()=>{},destroy:()=>{this._reader.isDestroyed||this._reader.destroy()}})}read(){return this._reader.onError(e=>{this._stream.emit("error",e)}),this._reader.onEntry(e=>{this._stream.push(e)}),this._reader.onEnd(()=>{this._stream.push(null)}),this._reader.read(),this._stream}};yk.default=G3});var J3=w(wk=>{"use strict";Object.defineProperty(wk,"__esModule",{value:!0});var UQe=zy(),Xy=_y(),KQe=Ck(),q3=class extends KQe.default{constructor(){super(...arguments);this._scandir=UQe.scandirSync,this._storage=new Set,this._queue=new Set}read(){return this._pushToQueue(this._root,this._settings.basePath),this._handleQueue(),[...this._storage]}_pushToQueue(e,r){this._queue.add({directory:e,base:r})}_handleQueue(){for(let e of this._queue.values())this._handleDirectory(e.directory,e.base)}_handleDirectory(e,r){try{let i=this._scandir(e,this._settings.fsScandirSettings);for(let n of i)this._handleEntry(n,r)}catch(i){this._handleError(i)}}_handleError(e){if(!!Xy.isFatalError(this._settings,e))throw e}_handleEntry(e,r){let i=e.path;r!==void 0&&(e.path=Xy.joinPathSegments(r,e.name,this._settings.pathSegmentSeparator)),Xy.isAppliedFilter(this._settings.entryFilter,e)&&this._pushToStorage(e),e.dirent.isDirectory()&&Xy.isAppliedFilter(this._settings.deepFilter,e)&&this._pushToQueue(i,e.path)}_pushToStorage(e){this._storage.add(e)}};wk.default=q3});var z3=w(Bk=>{"use strict";Object.defineProperty(Bk,"__esModule",{value:!0});var HQe=J3(),W3=class{constructor(e,r){this._root=e,this._settings=r,this._reader=new HQe.default(this._root,this._settings)}read(){return this._reader.read()}};Bk.default=W3});var V3=w(bk=>{"use strict";Object.defineProperty(bk,"__esModule",{value:!0});var jQe=require("path"),GQe=zy(),_3=class{constructor(e={}){this._options=e,this.basePath=this._getValue(this._options.basePath,void 0),this.concurrency=this._getValue(this._options.concurrency,Number.POSITIVE_INFINITY),this.deepFilter=this._getValue(this._options.deepFilter,null),this.entryFilter=this._getValue(this._options.entryFilter,null),this.errorFilter=this._getValue(this._options.errorFilter,null),this.pathSegmentSeparator=this._getValue(this._options.pathSegmentSeparator,jQe.sep),this.fsScandirSettings=new GQe.Settings({followSymbolicLinks:this._options.followSymbolicLinks,fs:this._options.fs,pathSegmentSeparator:this._options.pathSegmentSeparator,stats:this._options.stats,throwErrorOnBrokenSymbolicLink:this._options.throwErrorOnBrokenSymbolicLink})}_getValue(e,r){return e!=null?e:r}};bk.default=_3});var vk=w(_o=>{"use strict";Object.defineProperty(_o,"__esModule",{value:!0});_o.Settings=_o.walkStream=_o.walkSync=_o.walk=void 0;var X3=j3(),YQe=Y3(),qQe=z3(),Qk=V3();_o.Settings=Qk.default;function JQe(t,e,r){if(typeof e=="function"){new X3.default(t,Zy()).read(e);return}new X3.default(t,Zy(e)).read(r)}_o.walk=JQe;function WQe(t,e){let r=Zy(e);return new qQe.default(t,r).read()}_o.walkSync=WQe;function zQe(t,e){let r=Zy(e);return new YQe.default(t,r).read()}_o.walkStream=zQe;function Zy(t={}){return t instanceof Qk.default?t:new Qk.default(t)}});var kk=w(Sk=>{"use strict";Object.defineProperty(Sk,"__esModule",{value:!0});var _Qe=require("path"),VQe=Fc(),Z3=Wa(),$3=class{constructor(e){this._settings=e,this._fsStatSettings=new VQe.Settings({followSymbolicLink:this._settings.followSymbolicLinks,fs:this._settings.fs,throwErrorOnBrokenSymbolicLink:this._settings.followSymbolicLinks})}_getFullEntryPath(e){return _Qe.resolve(this._settings.cwd,e)}_makeEntry(e,r){let i={name:r,path:r,dirent:Z3.fs.createDirentFromStats(r,e)};return this._settings.stats&&(i.stats=e),i}_isFatalError(e){return!Z3.errno.isEnoentCodeError(e)&&!this._settings.suppressErrors}};Sk.default=$3});var Pk=w(xk=>{"use strict";Object.defineProperty(xk,"__esModule",{value:!0});var XQe=require("stream"),ZQe=Fc(),$Qe=vk(),eve=kk(),eW=class extends eve.default{constructor(){super(...arguments);this._walkStream=$Qe.walkStream,this._stat=ZQe.stat}dynamic(e,r){return this._walkStream(e,r)}static(e,r){let i=e.map(this._getFullEntryPath,this),n=new XQe.PassThrough({objectMode:!0});n._write=(s,o,a)=>this._getEntry(i[s],e[s],r).then(l=>{l!==null&&r.entryFilter(l)&&n.push(l),s===i.length-1&&n.end(),a()}).catch(a);for(let s=0;s<i.length;s++)n.write(s);return n}_getEntry(e,r,i){return this._getStat(e).then(n=>this._makeEntry(n,r)).catch(n=>{if(i.errorFilter(n))return null;throw n})}_getStat(e){return new Promise((r,i)=>{this._stat(e,this._fsStatSettings,(n,s)=>n===null?r(s):i(n))})}};xk.default=eW});var rW=w(Dk=>{"use strict";Object.defineProperty(Dk,"__esModule",{value:!0});var Tg=Wa(),tW=class{constructor(e,r,i){this._patterns=e,this._settings=r,this._micromatchOptions=i,this._storage=[],this._fillStorage()}_fillStorage(){let e=Tg.pattern.expandPatternsWithBraceExpansion(this._patterns);for(let r of e){let i=this._getPatternSegments(r),n=this._splitSegmentsIntoSections(i);this._storage.push({complete:n.length<=1,pattern:r,segments:i,sections:n})}}_getPatternSegments(e){return Tg.pattern.getPatternParts(e,this._micromatchOptions).map(i=>Tg.pattern.isDynamicPattern(i,this._settings)?{dynamic:!0,pattern:i,patternRe:Tg.pattern.makeRe(i,this._micromatchOptions)}:{dynamic:!1,pattern:i})}_splitSegmentsIntoSections(e){return Tg.array.splitWhen(e,r=>r.dynamic&&Tg.pattern.hasGlobStar(r.pattern))}};Dk.default=tW});var nW=w(Rk=>{"use strict";Object.defineProperty(Rk,"__esModule",{value:!0});var tve=rW(),iW=class extends tve.default{match(e){let r=e.split("/"),i=r.length,n=this._storage.filter(s=>!s.complete||s.segments.length>i);for(let s of n){let o=s.sections[0];if(!s.complete&&i>o.length||r.every((l,c)=>{let u=s.segments[c];return!!(u.dynamic&&u.patternRe.test(l)||!u.dynamic&&u.pattern===l)}))return!0}return!1}};Rk.default=iW});var oW=w(Fk=>{"use strict";Object.defineProperty(Fk,"__esModule",{value:!0});var $y=Wa(),rve=nW(),sW=class{constructor(e,r){this._settings=e,this._micromatchOptions=r}getFilter(e,r,i){let n=this._getMatcher(r),s=this._getNegativePatternsRe(i);return o=>this._filter(e,o,n,s)}_getMatcher(e){return new rve.default(e,this._settings,this._micromatchOptions)}_getNegativePatternsRe(e){let r=e.filter($y.pattern.isAffectDepthOfReadingPattern);return $y.pattern.convertPatternsToRe(r,this._micromatchOptions)}_filter(e,r,i,n){let s=this._getEntryLevel(e,r.path);if(this._isSkippedByDeep(s)||this._isSkippedSymbolicLink(r))return!1;let o=$y.path.removeLeadingDotSegment(r.path);return this._isSkippedByPositivePatterns(o,i)?!1:this._isSkippedByNegativePatterns(o,n)}_isSkippedByDeep(e){return e>=this._settings.deep}_isSkippedSymbolicLink(e){return!this._settings.followSymbolicLinks&&e.dirent.isSymbolicLink()}_getEntryLevel(e,r){let i=e.split("/").length;return r.split("/").length-(e===""?0:i)}_isSkippedByPositivePatterns(e,r){return!this._settings.baseNameMatch&&!r.match(e)}_isSkippedByNegativePatterns(e,r){return!$y.pattern.matchAny(e,r)}};Fk.default=sW});var AW=w(Nk=>{"use strict";Object.defineProperty(Nk,"__esModule",{value:!0});var sd=Wa(),aW=class{constructor(e,r){this._settings=e,this._micromatchOptions=r,this.index=new Map}getFilter(e,r){let i=sd.pattern.convertPatternsToRe(e,this._micromatchOptions),n=sd.pattern.convertPatternsToRe(r,this._micromatchOptions);return s=>this._filter(s,i,n)}_filter(e,r,i){if(this._settings.unique){if(this._isDuplicateEntry(e))return!1;this._createIndexRecord(e)}if(this._onlyFileFilter(e)||this._onlyDirectoryFilter(e)||this._isSkippedByAbsoluteNegativePatterns(e,i))return!1;let n=this._settings.baseNameMatch?e.name:e.path;return this._isMatchToPatterns(n,r)&&!this._isMatchToPatterns(e.path,i)}_isDuplicateEntry(e){return this.index.has(e.path)}_createIndexRecord(e){this.index.set(e.path,void 0)}_onlyFileFilter(e){return this._settings.onlyFiles&&!e.dirent.isFile()}_onlyDirectoryFilter(e){return this._settings.onlyDirectories&&!e.dirent.isDirectory()}_isSkippedByAbsoluteNegativePatterns(e,r){if(!this._settings.absolute)return!1;let i=sd.path.makeAbsolute(this._settings.cwd,e.path);return this._isMatchToPatterns(i,r)}_isMatchToPatterns(e,r){let i=sd.path.removeLeadingDotSegment(e);return sd.pattern.matchAny(i,r)}};Nk.default=aW});var cW=w(Lk=>{"use strict";Object.defineProperty(Lk,"__esModule",{value:!0});var ive=Wa(),lW=class{constructor(e){this._settings=e}getFilter(){return e=>this._isNonFatalError(e)}_isNonFatalError(e){return ive.errno.isEnoentCodeError(e)||this._settings.suppressErrors}};Lk.default=lW});var fW=w(Tk=>{"use strict";Object.defineProperty(Tk,"__esModule",{value:!0});var uW=Wa(),gW=class{constructor(e){this._settings=e}getTransformer(){return e=>this._transform(e)}_transform(e){let r=e.path;return this._settings.absolute&&(r=uW.path.makeAbsolute(this._settings.cwd,r),r=uW.path.unixify(r)),this._settings.markDirectories&&e.dirent.isDirectory()&&(r+="/"),this._settings.objectMode?Object.assign(Object.assign({},e),{path:r}):r}};Tk.default=gW});var ew=w(Ok=>{"use strict";Object.defineProperty(Ok,"__esModule",{value:!0});var nve=require("path"),sve=oW(),ove=AW(),ave=cW(),Ave=fW(),hW=class{constructor(e){this._settings=e,this.errorFilter=new ave.default(this._settings),this.entryFilter=new ove.default(this._settings,this._getMicromatchOptions()),this.deepFilter=new sve.default(this._settings,this._getMicromatchOptions()),this.entryTransformer=new Ave.default(this._settings)}_getRootDirectory(e){return nve.resolve(this._settings.cwd,e.base)}_getReaderOptions(e){let r=e.base==="."?"":e.base;return{basePath:r,pathSegmentSeparator:"/",concurrency:this._settings.concurrency,deepFilter:this.deepFilter.getFilter(r,e.positive,e.negative),entryFilter:this.entryFilter.getFilter(e.positive,e.negative),errorFilter:this.errorFilter.getFilter(),followSymbolicLinks:this._settings.followSymbolicLinks,fs:this._settings.fs,stats:this._settings.stats,throwErrorOnBrokenSymbolicLink:this._settings.throwErrorOnBrokenSymbolicLink,transform:this.entryTransformer.getTransformer()}}_getMicromatchOptions(){return{dot:this._settings.dot,matchBase:this._settings.baseNameMatch,nobrace:!this._settings.braceExpansion,nocase:!this._settings.caseSensitiveMatch,noext:!this._settings.extglob,noglobstar:!this._settings.globstar,posix:!0,strictSlashes:!1}}};Ok.default=hW});var dW=w(Mk=>{"use strict";Object.defineProperty(Mk,"__esModule",{value:!0});var lve=Pk(),cve=ew(),pW=class extends cve.default{constructor(){super(...arguments);this._reader=new lve.default(this._settings)}read(e){let r=this._getRootDirectory(e),i=this._getReaderOptions(e),n=[];return new Promise((s,o)=>{let a=this.api(r,e,i);a.once("error",o),a.on("data",l=>n.push(i.transform(l))),a.once("end",()=>s(n))})}api(e,r,i){return r.dynamic?this._reader.dynamic(e,i):this._reader.static(r.patterns,i)}};Mk.default=pW});var mW=w(Uk=>{"use strict";Object.defineProperty(Uk,"__esModule",{value:!0});var uve=require("stream"),gve=Pk(),fve=ew(),CW=class extends fve.default{constructor(){super(...arguments);this._reader=new gve.default(this._settings)}read(e){let r=this._getRootDirectory(e),i=this._getReaderOptions(e),n=this.api(r,e,i),s=new uve.Readable({objectMode:!0,read:()=>{}});return n.once("error",o=>s.emit("error",o)).on("data",o=>s.emit("data",i.transform(o))).once("end",()=>s.emit("end")),s.once("close",()=>n.destroy()),s}api(e,r,i){return r.dynamic?this._reader.dynamic(e,i):this._reader.static(r.patterns,i)}};Uk.default=CW});var IW=w(Kk=>{"use strict";Object.defineProperty(Kk,"__esModule",{value:!0});var hve=Fc(),pve=vk(),dve=kk(),EW=class extends dve.default{constructor(){super(...arguments);this._walkSync=pve.walkSync,this._statSync=hve.statSync}dynamic(e,r){return this._walkSync(e,r)}static(e,r){let i=[];for(let n of e){let s=this._getFullEntryPath(n),o=this._getEntry(s,n,r);o===null||!r.entryFilter(o)||i.push(o)}return i}_getEntry(e,r,i){try{let n=this._getStat(e);return this._makeEntry(n,r)}catch(n){if(i.errorFilter(n))return null;throw n}}_getStat(e){return this._statSync(e,this._fsStatSettings)}};Kk.default=EW});var wW=w(Hk=>{"use strict";Object.defineProperty(Hk,"__esModule",{value:!0});var Cve=IW(),mve=ew(),yW=class extends mve.default{constructor(){super(...arguments);this._reader=new Cve.default(this._settings)}read(e){let r=this._getRootDirectory(e),i=this._getReaderOptions(e);return this.api(r,e,i).map(i.transform)}api(e,r,i){return r.dynamic?this._reader.dynamic(e,i):this._reader.static(r.patterns,i)}};Hk.default=yW});var bW=w(od=>{"use strict";Object.defineProperty(od,"__esModule",{value:!0});var Og=require("fs"),Eve=require("os"),Ive=Eve.cpus().length;od.DEFAULT_FILE_SYSTEM_ADAPTER={lstat:Og.lstat,lstatSync:Og.lstatSync,stat:Og.stat,statSync:Og.statSync,readdir:Og.readdir,readdirSync:Og.readdirSync};var BW=class{constructor(e={}){this._options=e,this.absolute=this._getValue(this._options.absolute,!1),this.baseNameMatch=this._getValue(this._options.baseNameMatch,!1),this.braceExpansion=this._getValue(this._options.braceExpansion,!0),this.caseSensitiveMatch=this._getValue(this._options.caseSensitiveMatch,!0),this.concurrency=this._getValue(this._options.concurrency,Ive),this.cwd=this._getValue(this._options.cwd,process.cwd()),this.deep=this._getValue(this._options.deep,Infinity),this.dot=this._getValue(this._options.dot,!1),this.extglob=this._getValue(this._options.extglob,!0),this.followSymbolicLinks=this._getValue(this._options.followSymbolicLinks,!0),this.fs=this._getFileSystemMethods(this._options.fs),this.globstar=this._getValue(this._options.globstar,!0),this.ignore=this._getValue(this._options.ignore,[]),this.markDirectories=this._getValue(this._options.markDirectories,!1),this.objectMode=this._getValue(this._options.objectMode,!1),this.onlyDirectories=this._getValue(this._options.onlyDirectories,!1),this.onlyFiles=this._getValue(this._options.onlyFiles,!0),this.stats=this._getValue(this._options.stats,!1),this.suppressErrors=this._getValue(this._options.suppressErrors,!1),this.throwErrorOnBrokenSymbolicLink=this._getValue(this._options.throwErrorOnBrokenSymbolicLink,!1),this.unique=this._getValue(this._options.unique,!0),this.onlyDirectories&&(this.onlyFiles=!1),this.stats&&(this.objectMode=!0)}_getValue(e,r){return e===void 0?r:e}_getFileSystemMethods(e={}){return Object.assign(Object.assign({},od.DEFAULT_FILE_SYSTEM_ADAPTER),e)}};od.default=BW});var tw=w((Yit,QW)=>{"use strict";var vW=o3(),yve=dW(),wve=mW(),Bve=wW(),jk=bW(),Nc=Wa();async function Yk(t,e){Mg(t);let r=Gk(t,yve.default,e),i=await Promise.all(r);return Nc.array.flatten(i)}(function(t){function e(o,a){Mg(o);let l=Gk(o,Bve.default,a);return Nc.array.flatten(l)}t.sync=e;function r(o,a){Mg(o);let l=Gk(o,wve.default,a);return Nc.stream.merge(l)}t.stream=r;function i(o,a){Mg(o);let l=[].concat(o),c=new jk.default(a);return vW.generate(l,c)}t.generateTasks=i;function n(o,a){Mg(o);let l=new jk.default(a);return Nc.pattern.isDynamicPattern(o,l)}t.isDynamicPattern=n;function s(o){return Mg(o),Nc.path.escape(o)}t.escapePath=s})(Yk||(Yk={}));function Gk(t,e,r){let i=[].concat(t),n=new jk.default(r),s=vW.generate(i,n),o=new e(n);return s.map(o.read,o)}function Mg(t){if(![].concat(t).every(i=>Nc.string.isString(i)&&!Nc.string.isEmpty(i)))throw new TypeError("Patterns must be a string (non empty) or an array of strings")}QW.exports=Yk});var kW=w(Lc=>{"use strict";var{promisify:bve}=require("util"),SW=require("fs");async function qk(t,e,r){if(typeof r!="string")throw new TypeError(`Expected a string, got ${typeof r}`);try{return(await bve(SW[t])(r))[e]()}catch(i){if(i.code==="ENOENT")return!1;throw i}}function Jk(t,e,r){if(typeof r!="string")throw new TypeError(`Expected a string, got ${typeof r}`);try{return SW[t](r)[e]()}catch(i){if(i.code==="ENOENT")return!1;throw i}}Lc.isFile=qk.bind(null,"stat","isFile");Lc.isDirectory=qk.bind(null,"stat","isDirectory");Lc.isSymlink=qk.bind(null,"lstat","isSymbolicLink");Lc.isFileSync=Jk.bind(null,"statSync","isFile");Lc.isDirectorySync=Jk.bind(null,"statSync","isDirectory");Lc.isSymlinkSync=Jk.bind(null,"lstatSync","isSymbolicLink")});var FW=w((Jit,Wk)=>{"use strict";var Tc=require("path"),xW=kW(),PW=t=>t.length>1?`{${t.join(",")}}`:t[0],DW=(t,e)=>{let r=t[0]==="!"?t.slice(1):t;return Tc.isAbsolute(r)?r:Tc.join(e,r)},Qve=(t,e)=>Tc.extname(t)?`**/${t}`:`**/${t}.${PW(e)}`,RW=(t,e)=>{if(e.files&&!Array.isArray(e.files))throw new TypeError(`Expected \`files\` to be of type \`Array\` but received type \`${typeof e.files}\``);if(e.extensions&&!Array.isArray(e.extensions))throw new TypeError(`Expected \`extensions\` to be of type \`Array\` but received type \`${typeof e.extensions}\``);return e.files&&e.extensions?e.files.map(r=>Tc.posix.join(t,Qve(r,e.extensions))):e.files?e.files.map(r=>Tc.posix.join(t,`**/${r}`)):e.extensions?[Tc.posix.join(t,`**/*.${PW(e.extensions)}`)]:[Tc.posix.join(t,"**")]};Wk.exports=async(t,e)=>{if(e=N({cwd:process.cwd()},e),typeof e.cwd!="string")throw new TypeError(`Expected \`cwd\` to be of type \`string\` but received type \`${typeof e.cwd}\``);let r=await Promise.all([].concat(t).map(async i=>await xW.isDirectory(DW(i,e.cwd))?RW(i,e):i));return[].concat.apply([],r)};Wk.exports.sync=(t,e)=>{if(e=N({cwd:process.cwd()},e),typeof e.cwd!="string")throw new TypeError(`Expected \`cwd\` to be of type \`string\` but received type \`${typeof e.cwd}\``);let r=[].concat(t).map(i=>xW.isDirectorySync(DW(i,e.cwd))?RW(i,e):i);return[].concat.apply([],r)}});var GW=w((Wit,NW)=>{function LW(t){return Array.isArray(t)?t:[t]}var TW="",OW=" ",zk="\\",vve=/^\s+$/,Sve=/^\\!/,kve=/^\\#/,xve=/\r?\n/g,Pve=/^\.*\/|^\.+$/,_k="/",MW=typeof Symbol!="undefined"?Symbol.for("node-ignore"):"node-ignore",Dve=(t,e,r)=>Object.defineProperty(t,e,{value:r}),Rve=/([0-z])-([0-z])/g,Fve=t=>t.replace(Rve,(e,r,i)=>r.charCodeAt(0)<=i.charCodeAt(0)?e:TW),Nve=t=>{let{length:e}=t;return t.slice(0,e-e%2)},Lve=[[/\\?\s+$/,t=>t.indexOf("\\")===0?OW:TW],[/\\\s/g,()=>OW],[/[\\$.|*+(){^]/g,t=>`\\${t}`],[/(?!\\)\?/g,()=>"[^/]"],[/^\//,()=>"^"],[/\//g,()=>"\\/"],[/^\^*\\\*\\\*\\\//,()=>"^(?:.*\\/)?"],[/^(?=[^^])/,function(){return/\/(?!$)/.test(this)?"^":"(?:^|\\/)"}],[/\\\/\\\*\\\*(?=\\\/|$)/g,(t,e,r)=>e+6<r.length?"(?:\\/[^\\/]+)*":"\\/.+"],[/(^|[^\\]+)\\\*(?=.+)/g,(t,e)=>`${e}[^\\/]*`],[/\\\\\\(?=[$.|*+(){^])/g,()=>zk],[/\\\\/g,()=>zk],[/(\\)?\[([^\]/]*?)(\\*)($|\])/g,(t,e,r,i,n)=>e===zk?`\\[${r}${Nve(i)}${n}`:n==="]"&&i.length%2==0?`[${Fve(r)}${i}]`:"[]"],[/(?:[^*])$/,t=>/\/$/.test(t)?`${t}$`:`${t}(?=$|\\/$)`],[/(\^|\\\/)?\\\*$/,(t,e)=>`${e?`${e}[^/]+`:"[^/]*"}(?=$|\\/$)`]],UW=Object.create(null),Tve=(t,e)=>{let r=UW[t];return r||(r=Lve.reduce((i,n)=>i.replace(n[0],n[1].bind(t)),t),UW[t]=r),e?new RegExp(r,"i"):new RegExp(r)},Vk=t=>typeof t=="string",Ove=t=>t&&Vk(t)&&!vve.test(t)&&t.indexOf("#")!==0,Mve=t=>t.split(xve),KW=class{constructor(e,r,i,n){this.origin=e,this.pattern=r,this.negative=i,this.regex=n}},Uve=(t,e)=>{let r=t,i=!1;t.indexOf("!")===0&&(i=!0,t=t.substr(1)),t=t.replace(Sve,"!").replace(kve,"#");let n=Tve(t,e);return new KW(r,t,i,n)},Kve=(t,e)=>{throw new e(t)},_a=(t,e,r)=>Vk(t)?t?_a.isNotRelative(t)?r(`path should be a \`path.relative()\`d string, but got "${e}"`,RangeError):!0:r("path must not be empty",TypeError):r(`path must be a string, but got \`${e}\``,TypeError),HW=t=>Pve.test(t);_a.isNotRelative=HW;_a.convert=t=>t;var jW=class{constructor({ignorecase:e=!0}={}){Dve(this,MW,!0),this._rules=[],this._ignorecase=e,this._initCache()}_initCache(){this._ignoreCache=Object.create(null),this._testCache=Object.create(null)}_addPattern(e){if(e&&e[MW]){this._rules=this._rules.concat(e._rules),this._added=!0;return}if(Ove(e)){let r=Uve(e,this._ignorecase);this._added=!0,this._rules.push(r)}}add(e){return this._added=!1,LW(Vk(e)?Mve(e):e).forEach(this._addPattern,this),this._added&&this._initCache(),this}addPattern(e){return this.add(e)}_testOne(e,r){let i=!1,n=!1;return this._rules.forEach(s=>{let{negative:o}=s;if(n===o&&i!==n||o&&!i&&!n&&!r)return;s.regex.test(e)&&(i=!o,n=o)}),{ignored:i,unignored:n}}_test(e,r,i,n){let s=e&&_a.convert(e);return _a(s,e,Kve),this._t(s,r,i,n)}_t(e,r,i,n){if(e in r)return r[e];if(n||(n=e.split(_k)),n.pop(),!n.length)return r[e]=this._testOne(e,i);let s=this._t(n.join(_k)+_k,r,i,n);return r[e]=s.ignored?s:this._testOne(e,i)}ignores(e){return this._test(e,this._ignoreCache,!1).ignored}createFilter(){return e=>!this.ignores(e)}filter(e){return LW(e).filter(this.createFilter())}test(e){return this._test(e,this._testCache,!0)}},rw=t=>new jW(t),Hve=()=>!1,jve=t=>_a(t&&_a.convert(t),t,Hve);rw.isPathValid=jve;rw.default=rw;NW.exports=rw;if(typeof process!="undefined"&&(process.env&&process.env.IGNORE_TEST_WIN32||process.platform==="win32")){let t=r=>/^\\\\\?\\/.test(r)||/["<>|\u0000-\u001F]+/u.test(r)?r:r.replace(/\\/g,"/");_a.convert=t;let e=/^[a-z]:\//i;_a.isNotRelative=r=>e.test(r)||HW(r)}});var qW=w((zit,YW)=>{"use strict";YW.exports=t=>{let e=/^\\\\\?\\/.test(t),r=/[^\u0000-\u0080]+/.test(t);return e||r?t:t.replace(/\\/g,"/")}});var ZW=w((_it,Xk)=>{"use strict";var{promisify:Gve}=require("util"),JW=require("fs"),Va=require("path"),WW=tw(),Yve=GW(),ad=qW(),zW=["**/node_modules/**","**/flow-typed/**","**/coverage/**","**/.git"],qve=Gve(JW.readFile),Jve=t=>e=>e.startsWith("!")?"!"+Va.posix.join(t,e.slice(1)):Va.posix.join(t,e),Wve=(t,e)=>{let r=ad(Va.relative(e.cwd,Va.dirname(e.fileName)));return t.split(/\r?\n/).filter(Boolean).filter(i=>!i.startsWith("#")).map(Jve(r))},_W=t=>{let e=Yve();for(let r of t)e.add(Wve(r.content,{cwd:r.cwd,fileName:r.filePath}));return e},zve=(t,e)=>{if(t=ad(t),Va.isAbsolute(e)){if(ad(e).startsWith(t))return e;throw new Error(`Path ${e} is not in cwd ${t}`)}return Va.join(t,e)},VW=(t,e)=>r=>t.ignores(ad(Va.relative(e,zve(e,r.path||r)))),_ve=async(t,e)=>{let r=Va.join(e,t),i=await qve(r,"utf8");return{cwd:e,filePath:r,content:i}},Vve=(t,e)=>{let r=Va.join(e,t),i=JW.readFileSync(r,"utf8");return{cwd:e,filePath:r,content:i}},XW=({ignore:t=[],cwd:e=ad(process.cwd())}={})=>({ignore:t,cwd:e});Xk.exports=async t=>{t=XW(t);let e=await WW("**/.gitignore",{ignore:zW.concat(t.ignore),cwd:t.cwd}),r=await Promise.all(e.map(n=>_ve(n,t.cwd))),i=_W(r);return VW(i,t.cwd)};Xk.exports.sync=t=>{t=XW(t);let r=WW.sync("**/.gitignore",{ignore:zW.concat(t.ignore),cwd:t.cwd}).map(n=>Vve(n,t.cwd)),i=_W(r);return VW(i,t.cwd)}});var r8=w((Vit,$W)=>{"use strict";var{Transform:Xve}=require("stream"),Zk=class extends Xve{constructor(){super({objectMode:!0})}},e8=class extends Zk{constructor(e){super();this._filter=e}_transform(e,r,i){this._filter(e)&&this.push(e),i()}},t8=class extends Zk{constructor(){super();this._pushed=new Set}_transform(e,r,i){this._pushed.has(e)||(this.push(e),this._pushed.add(e)),i()}};$W.exports={FilterStream:e8,UniqueStream:t8}});var rx=w((Xit,Oc)=>{"use strict";var i8=require("fs"),iw=kJ(),Zve=XS(),nw=tw(),sw=FW(),$k=ZW(),{FilterStream:$ve,UniqueStream:eSe}=r8(),n8=()=>!1,s8=t=>t[0]==="!",tSe=t=>{if(!t.every(e=>typeof e=="string"))throw new TypeError("Patterns must be a string or an array of strings")},rSe=(t={})=>{if(!t.cwd)return;let e;try{e=i8.statSync(t.cwd)}catch{return}if(!e.isDirectory())throw new Error("The `cwd` option must be a path to a directory")},iSe=t=>t.stats instanceof i8.Stats?t.path:t,ow=(t,e)=>{t=iw([].concat(t)),tSe(t),rSe(e);let r=[];e=N({ignore:[],expandDirectories:!0},e);for(let[i,n]of t.entries()){if(s8(n))continue;let s=t.slice(i).filter(a=>s8(a)).map(a=>a.slice(1)),o=te(N({},e),{ignore:e.ignore.concat(s)});r.push({pattern:n,options:o})}return r},nSe=(t,e)=>{let r={};return t.options.cwd&&(r.cwd=t.options.cwd),Array.isArray(t.options.expandDirectories)?r=te(N({},r),{files:t.options.expandDirectories}):typeof t.options.expandDirectories=="object"&&(r=N(N({},r),t.options.expandDirectories)),e(t.pattern,r)},ex=(t,e)=>t.options.expandDirectories?nSe(t,e):[t.pattern],o8=t=>t&&t.gitignore?$k.sync({cwd:t.cwd,ignore:t.ignore}):n8,tx=t=>e=>{let{options:r}=t;return r.ignore&&Array.isArray(r.ignore)&&r.expandDirectories&&(r.ignore=sw.sync(r.ignore)),{pattern:e,options:r}};Oc.exports=async(t,e)=>{let r=ow(t,e),i=async()=>e&&e.gitignore?$k({cwd:e.cwd,ignore:e.ignore}):n8,n=async()=>{let l=await Promise.all(r.map(async c=>{let u=await ex(c,sw);return Promise.all(u.map(tx(c)))}));return iw(...l)},[s,o]=await Promise.all([i(),n()]),a=await Promise.all(o.map(l=>nw(l.pattern,l.options)));return iw(...a).filter(l=>!s(iSe(l)))};Oc.exports.sync=(t,e)=>{let r=ow(t,e),i=[];for(let o of r){let a=ex(o,sw.sync).map(tx(o));i.push(...a)}let n=o8(e),s=[];for(let o of i)s=iw(s,nw.sync(o.pattern,o.options));return s.filter(o=>!n(o))};Oc.exports.stream=(t,e)=>{let r=ow(t,e),i=[];for(let a of r){let l=ex(a,sw.sync).map(tx(a));i.push(...l)}let n=o8(e),s=new $ve(a=>!n(a)),o=new eSe;return Zve(i.map(a=>nw.stream(a.pattern,a.options))).pipe(s).pipe(o)};Oc.exports.generateGlobTasks=ow;Oc.exports.hasMagic=(t,e)=>[].concat(t).some(r=>nw.isDynamicPattern(r,e));Oc.exports.gitignore=$k});var Rn=w((bnt,y8)=>{function CSe(t){var e=typeof t;return t!=null&&(e=="object"||e=="function")}y8.exports=CSe});var ux=w((Qnt,w8)=>{var mSe=typeof global=="object"&&global&&global.Object===Object&&global;w8.exports=mSe});var Rs=w((vnt,B8)=>{var ESe=ux(),ISe=typeof self=="object"&&self&&self.Object===Object&&self,ySe=ESe||ISe||Function("return this")();B8.exports=ySe});var Q8=w((Snt,b8)=>{var wSe=Rs(),BSe=function(){return wSe.Date.now()};b8.exports=BSe});var S8=w((knt,v8)=>{var bSe=/\s/;function QSe(t){for(var e=t.length;e--&&bSe.test(t.charAt(e)););return e}v8.exports=QSe});var x8=w((xnt,k8)=>{var vSe=S8(),SSe=/^\s+/;function kSe(t){return t&&t.slice(0,vSe(t)+1).replace(SSe,"")}k8.exports=kSe});var Kc=w((Pnt,P8)=>{var xSe=Rs(),PSe=xSe.Symbol;P8.exports=PSe});var N8=w((Dnt,D8)=>{var R8=Kc(),F8=Object.prototype,DSe=F8.hasOwnProperty,RSe=F8.toString,Ed=R8?R8.toStringTag:void 0;function FSe(t){var e=DSe.call(t,Ed),r=t[Ed];try{t[Ed]=void 0;var i=!0}catch(s){}var n=RSe.call(t);return i&&(e?t[Ed]=r:delete t[Ed]),n}D8.exports=FSe});var T8=w((Rnt,L8)=>{var NSe=Object.prototype,LSe=NSe.toString;function TSe(t){return LSe.call(t)}L8.exports=TSe});var Hc=w((Fnt,O8)=>{var M8=Kc(),OSe=N8(),MSe=T8(),USe="[object Null]",KSe="[object Undefined]",U8=M8?M8.toStringTag:void 0;function HSe(t){return t==null?t===void 0?KSe:USe:U8&&U8 in Object(t)?OSe(t):MSe(t)}O8.exports=HSe});var Zo=w((Nnt,K8)=>{function jSe(t){return t!=null&&typeof t=="object"}K8.exports=jSe});var Id=w((Lnt,H8)=>{var GSe=Hc(),YSe=Zo(),qSe="[object Symbol]";function JSe(t){return typeof t=="symbol"||YSe(t)&&GSe(t)==qSe}H8.exports=JSe});var q8=w((Tnt,j8)=>{var WSe=x8(),G8=Rn(),zSe=Id(),Y8=0/0,_Se=/^[-+]0x[0-9a-f]+$/i,VSe=/^0b[01]+$/i,XSe=/^0o[0-7]+$/i,ZSe=parseInt;function $Se(t){if(typeof t=="number")return t;if(zSe(t))return Y8;if(G8(t)){var e=typeof t.valueOf=="function"?t.valueOf():t;t=G8(e)?e+"":e}if(typeof t!="string")return t===0?t:+t;t=WSe(t);var r=VSe.test(t);return r||XSe.test(t)?ZSe(t.slice(2),r?2:8):_Se.test(t)?Y8:+t}j8.exports=$Se});var z8=w((Ont,J8)=>{var eke=Rn(),gx=Q8(),W8=q8(),tke="Expected a function",rke=Math.max,ike=Math.min;function nke(t,e,r){var i,n,s,o,a,l,c=0,u=!1,g=!1,f=!0;if(typeof t!="function")throw new TypeError(tke);e=W8(e)||0,eke(r)&&(u=!!r.leading,g="maxWait"in r,s=g?rke(W8(r.maxWait)||0,e):s,f="trailing"in r?!!r.trailing:f);function h(U){var J=i,W=n;return i=n=void 0,c=U,o=t.apply(W,J),o}function p(U){return c=U,a=setTimeout(Q,e),u?h(U):o}function m(U){var J=U-l,W=U-c,ee=e-J;return g?ike(ee,s-W):ee}function y(U){var J=U-l,W=U-c;return l===void 0||J>=e||J<0||g&&W>=s}function Q(){var U=gx();if(y(U))return S(U);a=setTimeout(Q,m(U))}function S(U){return a=void 0,f&&i?h(U):(i=n=void 0,o)}function x(){a!==void 0&&clearTimeout(a),c=0,i=l=n=a=void 0}function M(){return a===void 0?o:S(gx())}function Y(){var U=gx(),J=y(U);if(i=arguments,n=this,l=U,J){if(a===void 0)return p(l);if(g)return clearTimeout(a),a=setTimeout(Q,e),h(l)}return a===void 0&&(a=setTimeout(Q,e)),o}return Y.cancel=x,Y.flush=M,Y}J8.exports=nke});var V8=w((Mnt,_8)=>{var ske=z8(),oke=Rn(),ake="Expected a function";function Ake(t,e,r){var i=!0,n=!0;if(typeof t!="function")throw new TypeError(ake);return oke(r)&&(i="leading"in r?!!r.leading:i,n="trailing"in r?!!r.trailing:n),ske(t,e,{leading:i,maxWait:e,trailing:n})}_8.exports=Ake});var $a=w((Za,vw)=>{"use strict";Object.defineProperty(Za,"__esModule",{value:!0});var nz=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","BigInt64Array","BigUint64Array"];function yke(t){return nz.includes(t)}var wke=["Function","Generator","AsyncGenerator","GeneratorFunction","AsyncGeneratorFunction","AsyncFunction","Observable","Array","Buffer","Object","RegExp","Date","Error","Map","Set","WeakMap","WeakSet","ArrayBuffer","SharedArrayBuffer","DataView","Promise","URL","FormData","URLSearchParams","HTMLElement",...nz];function Bke(t){return wke.includes(t)}var bke=["null","undefined","string","number","bigint","boolean","symbol"];function Qke(t){return bke.includes(t)}function Jg(t){return e=>typeof e===t}var{toString:sz}=Object.prototype,Sd=t=>{let e=sz.call(t).slice(8,-1);if(/HTML\w+Element/.test(e)&&_.domElement(t))return"HTMLElement";if(Bke(e))return e},hr=t=>e=>Sd(e)===t;function _(t){if(t===null)return"null";switch(typeof t){case"undefined":return"undefined";case"string":return"string";case"number":return"number";case"boolean":return"boolean";case"function":return"Function";case"bigint":return"bigint";case"symbol":return"symbol";default:}if(_.observable(t))return"Observable";if(_.array(t))return"Array";if(_.buffer(t))return"Buffer";let e=Sd(t);if(e)return e;if(t instanceof String||t instanceof Boolean||t instanceof Number)throw new TypeError("Please don't use object wrappers for primitive types");return"Object"}_.undefined=Jg("undefined");_.string=Jg("string");var vke=Jg("number");_.number=t=>vke(t)&&!_.nan(t);_.bigint=Jg("bigint");_.function_=Jg("function");_.null_=t=>t===null;_.class_=t=>_.function_(t)&&t.toString().startsWith("class ");_.boolean=t=>t===!0||t===!1;_.symbol=Jg("symbol");_.numericString=t=>_.string(t)&&!_.emptyStringOrWhitespace(t)&&!Number.isNaN(Number(t));_.array=(t,e)=>Array.isArray(t)?_.function_(e)?t.every(e):!0:!1;_.buffer=t=>{var e,r,i,n;return(n=(i=(r=(e=t)===null||e===void 0?void 0:e.constructor)===null||r===void 0?void 0:r.isBuffer)===null||i===void 0?void 0:i.call(r,t))!==null&&n!==void 0?n:!1};_.nullOrUndefined=t=>_.null_(t)||_.undefined(t);_.object=t=>!_.null_(t)&&(typeof t=="object"||_.function_(t));_.iterable=t=>{var e;return _.function_((e=t)===null||e===void 0?void 0:e[Symbol.iterator])};_.asyncIterable=t=>{var e;return _.function_((e=t)===null||e===void 0?void 0:e[Symbol.asyncIterator])};_.generator=t=>_.iterable(t)&&_.function_(t.next)&&_.function_(t.throw);_.asyncGenerator=t=>_.asyncIterable(t)&&_.function_(t.next)&&_.function_(t.throw);_.nativePromise=t=>hr("Promise")(t);var Ske=t=>{var e,r;return _.function_((e=t)===null||e===void 0?void 0:e.then)&&_.function_((r=t)===null||r===void 0?void 0:r.catch)};_.promise=t=>_.nativePromise(t)||Ske(t);_.generatorFunction=hr("GeneratorFunction");_.asyncGeneratorFunction=t=>Sd(t)==="AsyncGeneratorFunction";_.asyncFunction=t=>Sd(t)==="AsyncFunction";_.boundFunction=t=>_.function_(t)&&!t.hasOwnProperty("prototype");_.regExp=hr("RegExp");_.date=hr("Date");_.error=hr("Error");_.map=t=>hr("Map")(t);_.set=t=>hr("Set")(t);_.weakMap=t=>hr("WeakMap")(t);_.weakSet=t=>hr("WeakSet")(t);_.int8Array=hr("Int8Array");_.uint8Array=hr("Uint8Array");_.uint8ClampedArray=hr("Uint8ClampedArray");_.int16Array=hr("Int16Array");_.uint16Array=hr("Uint16Array");_.int32Array=hr("Int32Array");_.uint32Array=hr("Uint32Array");_.float32Array=hr("Float32Array");_.float64Array=hr("Float64Array");_.bigInt64Array=hr("BigInt64Array");_.bigUint64Array=hr("BigUint64Array");_.arrayBuffer=hr("ArrayBuffer");_.sharedArrayBuffer=hr("SharedArrayBuffer");_.dataView=hr("DataView");_.directInstanceOf=(t,e)=>Object.getPrototypeOf(t)===e.prototype;_.urlInstance=t=>hr("URL")(t);_.urlString=t=>{if(!_.string(t))return!1;try{return new URL(t),!0}catch(e){return!1}};_.truthy=t=>Boolean(t);_.falsy=t=>!t;_.nan=t=>Number.isNaN(t);_.primitive=t=>_.null_(t)||Qke(typeof t);_.integer=t=>Number.isInteger(t);_.safeInteger=t=>Number.isSafeInteger(t);_.plainObject=t=>{if(sz.call(t)!=="[object Object]")return!1;let e=Object.getPrototypeOf(t);return e===null||e===Object.getPrototypeOf({})};_.typedArray=t=>yke(Sd(t));var kke=t=>_.safeInteger(t)&&t>=0;_.arrayLike=t=>!_.nullOrUndefined(t)&&!_.function_(t)&&kke(t.length);_.inRange=(t,e)=>{if(_.number(e))return t>=Math.min(0,e)&&t<=Math.max(e,0);if(_.array(e)&&e.length===2)return t>=Math.min(...e)&&t<=Math.max(...e);throw new TypeError(`Invalid range: ${JSON.stringify(e)}`)};var xke=1,Pke=["innerHTML","ownerDocument","style","attributes","nodeValue"];_.domElement=t=>_.object(t)&&t.nodeType===xke&&_.string(t.nodeName)&&!_.plainObject(t)&&Pke.every(e=>e in t);_.observable=t=>{var e,r,i,n;return t?t===((r=(e=t)[Symbol.observable])===null||r===void 0?void 0:r.call(e))||t===((n=(i=t)["@@observable"])===null||n===void 0?void 0:n.call(i)):!1};_.nodeStream=t=>_.object(t)&&_.function_(t.pipe)&&!_.observable(t);_.infinite=t=>t===Infinity||t===-Infinity;var oz=t=>e=>_.integer(e)&&Math.abs(e%2)===t;_.evenInteger=oz(0);_.oddInteger=oz(1);_.emptyArray=t=>_.array(t)&&t.length===0;_.nonEmptyArray=t=>_.array(t)&&t.length>0;_.emptyString=t=>_.string(t)&&t.length===0;_.nonEmptyString=t=>_.string(t)&&t.length>0;var Dke=t=>_.string(t)&&!/\S/.test(t);_.emptyStringOrWhitespace=t=>_.emptyString(t)||Dke(t);_.emptyObject=t=>_.object(t)&&!_.map(t)&&!_.set(t)&&Object.keys(t).length===0;_.nonEmptyObject=t=>_.object(t)&&!_.map(t)&&!_.set(t)&&Object.keys(t).length>0;_.emptySet=t=>_.set(t)&&t.size===0;_.nonEmptySet=t=>_.set(t)&&t.size>0;_.emptyMap=t=>_.map(t)&&t.size===0;_.nonEmptyMap=t=>_.map(t)&&t.size>0;_.propertyKey=t=>_.any([_.string,_.number,_.symbol],t);_.formData=t=>hr("FormData")(t);_.urlSearchParams=t=>hr("URLSearchParams")(t);var az=(t,e,r)=>{if(!_.function_(e))throw new TypeError(`Invalid predicate: ${JSON.stringify(e)}`);if(r.length===0)throw new TypeError("Invalid number of values");return t.call(r,e)};_.any=(t,...e)=>(_.array(t)?t:[t]).some(i=>az(Array.prototype.some,i,e));_.all=(t,...e)=>az(Array.prototype.every,t,e);var We=(t,e,r,i={})=>{if(!t){let{multipleValues:n}=i,s=n?`received values of types ${[...new Set(r.map(o=>`\`${_(o)}\``))].join(", ")}`:`received value of type \`${_(r)}\``;throw new TypeError(`Expected value which is \`${e}\`, ${s}.`)}};Za.assert={undefined:t=>We(_.undefined(t),"undefined",t),string:t=>We(_.string(t),"string",t),number:t=>We(_.number(t),"number",t),bigint:t=>We(_.bigint(t),"bigint",t),function_:t=>We(_.function_(t),"Function",t),null_:t=>We(_.null_(t),"null",t),class_:t=>We(_.class_(t),"Class",t),boolean:t=>We(_.boolean(t),"boolean",t),symbol:t=>We(_.symbol(t),"symbol",t),numericString:t=>We(_.numericString(t),"string with a number",t),array:(t,e)=>{We(_.array(t),"Array",t),e&&t.forEach(e)},buffer:t=>We(_.buffer(t),"Buffer",t),nullOrUndefined:t=>We(_.nullOrUndefined(t),"null or undefined",t),object:t=>We(_.object(t),"Object",t),iterable:t=>We(_.iterable(t),"Iterable",t),asyncIterable:t=>We(_.asyncIterable(t),"AsyncIterable",t),generator:t=>We(_.generator(t),"Generator",t),asyncGenerator:t=>We(_.asyncGenerator(t),"AsyncGenerator",t),nativePromise:t=>We(_.nativePromise(t),"native Promise",t),promise:t=>We(_.promise(t),"Promise",t),generatorFunction:t=>We(_.generatorFunction(t),"GeneratorFunction",t),asyncGeneratorFunction:t=>We(_.asyncGeneratorFunction(t),"AsyncGeneratorFunction",t),asyncFunction:t=>We(_.asyncFunction(t),"AsyncFunction",t),boundFunction:t=>We(_.boundFunction(t),"Function",t),regExp:t=>We(_.regExp(t),"RegExp",t),date:t=>We(_.date(t),"Date",t),error:t=>We(_.error(t),"Error",t),map:t=>We(_.map(t),"Map",t),set:t=>We(_.set(t),"Set",t),weakMap:t=>We(_.weakMap(t),"WeakMap",t),weakSet:t=>We(_.weakSet(t),"WeakSet",t),int8Array:t=>We(_.int8Array(t),"Int8Array",t),uint8Array:t=>We(_.uint8Array(t),"Uint8Array",t),uint8ClampedArray:t=>We(_.uint8ClampedArray(t),"Uint8ClampedArray",t),int16Array:t=>We(_.int16Array(t),"Int16Array",t),uint16Array:t=>We(_.uint16Array(t),"Uint16Array",t),int32Array:t=>We(_.int32Array(t),"Int32Array",t),uint32Array:t=>We(_.uint32Array(t),"Uint32Array",t),float32Array:t=>We(_.float32Array(t),"Float32Array",t),float64Array:t=>We(_.float64Array(t),"Float64Array",t),bigInt64Array:t=>We(_.bigInt64Array(t),"BigInt64Array",t),bigUint64Array:t=>We(_.bigUint64Array(t),"BigUint64Array",t),arrayBuffer:t=>We(_.arrayBuffer(t),"ArrayBuffer",t),sharedArrayBuffer:t=>We(_.sharedArrayBuffer(t),"SharedArrayBuffer",t),dataView:t=>We(_.dataView(t),"DataView",t),urlInstance:t=>We(_.urlInstance(t),"URL",t),urlString:t=>We(_.urlString(t),"string with a URL",t),truthy:t=>We(_.truthy(t),"truthy",t),falsy:t=>We(_.falsy(t),"falsy",t),nan:t=>We(_.nan(t),"NaN",t),primitive:t=>We(_.primitive(t),"primitive",t),integer:t=>We(_.integer(t),"integer",t),safeInteger:t=>We(_.safeInteger(t),"integer",t),plainObject:t=>We(_.plainObject(t),"plain object",t),typedArray:t=>We(_.typedArray(t),"TypedArray",t),arrayLike:t=>We(_.arrayLike(t),"array-like",t),domElement:t=>We(_.domElement(t),"HTMLElement",t),observable:t=>We(_.observable(t),"Observable",t),nodeStream:t=>We(_.nodeStream(t),"Node.js Stream",t),infinite:t=>We(_.infinite(t),"infinite number",t),emptyArray:t=>We(_.emptyArray(t),"empty array",t),nonEmptyArray:t=>We(_.nonEmptyArray(t),"non-empty array",t),emptyString:t=>We(_.emptyString(t),"empty string",t),nonEmptyString:t=>We(_.nonEmptyString(t),"non-empty string",t),emptyStringOrWhitespace:t=>We(_.emptyStringOrWhitespace(t),"empty string or whitespace",t),emptyObject:t=>We(_.emptyObject(t),"empty object",t),nonEmptyObject:t=>We(_.nonEmptyObject(t),"non-empty object",t),emptySet:t=>We(_.emptySet(t),"empty set",t),nonEmptySet:t=>We(_.nonEmptySet(t),"non-empty set",t),emptyMap:t=>We(_.emptyMap(t),"empty map",t),nonEmptyMap:t=>We(_.nonEmptyMap(t),"non-empty map",t),propertyKey:t=>We(_.propertyKey(t),"PropertyKey",t),formData:t=>We(_.formData(t),"FormData",t),urlSearchParams:t=>We(_.urlSearchParams(t),"URLSearchParams",t),evenInteger:t=>We(_.evenInteger(t),"even integer",t),oddInteger:t=>We(_.oddInteger(t),"odd integer",t),directInstanceOf:(t,e)=>We(_.directInstanceOf(t,e),"T",t),inRange:(t,e)=>We(_.inRange(t,e),"in range",t),any:(t,...e)=>We(_.any(t,...e),"predicate returns truthy for any value",e,{multipleValues:!0}),all:(t,...e)=>We(_.all(t,...e),"predicate returns truthy for all values",e,{multipleValues:!0})};Object.defineProperties(_,{class:{value:_.class_},function:{value:_.function_},null:{value:_.null_}});Object.defineProperties(Za.assert,{class:{value:Za.assert.class_},function:{value:Za.assert.function_},null:{value:Za.assert.null_}});Za.default=_;vw.exports=_;vw.exports.default=_;vw.exports.assert=Za.assert});var Az=w((Gst,Rx)=>{"use strict";var Fx=class extends Error{constructor(e){super(e||"Promise was canceled");this.name="CancelError"}get isCanceled(){return!0}},kd=class{static fn(e){return(...r)=>new kd((i,n,s)=>{r.push(s),e(...r).then(i,n)})}constructor(e){this._cancelHandlers=[],this._isPending=!0,this._isCanceled=!1,this._rejectOnCancel=!0,this._promise=new Promise((r,i)=>{this._reject=i;let n=a=>{this._isPending=!1,r(a)},s=a=>{this._isPending=!1,i(a)},o=a=>{if(!this._isPending)throw new Error("The `onCancel` handler was attached after the promise settled.");this._cancelHandlers.push(a)};return Object.defineProperties(o,{shouldReject:{get:()=>this._rejectOnCancel,set:a=>{this._rejectOnCancel=a}}}),e(n,s,o)})}then(e,r){return this._promise.then(e,r)}catch(e){return this._promise.catch(e)}finally(e){return this._promise.finally(e)}cancel(e){if(!(!this._isPending||this._isCanceled)){if(this._cancelHandlers.length>0)try{for(let r of this._cancelHandlers)r()}catch(r){this._reject(r)}this._isCanceled=!0,this._rejectOnCancel&&this._reject(new Fx(e))}}get isCanceled(){return this._isCanceled}};Object.setPrototypeOf(kd.prototype,Promise.prototype);Rx.exports=kd;Rx.exports.CancelError=Fx});var lz=w((Nx,Lx)=>{"use strict";Object.defineProperty(Nx,"__esModule",{value:!0});var Rke=require("tls"),Tx=(t,e)=>{let r;typeof e=="function"?r={connect:e}:r=e;let i=typeof r.connect=="function",n=typeof r.secureConnect=="function",s=typeof r.close=="function",o=()=>{i&&r.connect(),t instanceof Rke.TLSSocket&&n&&(t.authorized?r.secureConnect():t.authorizationError||t.once("secureConnect",r.secureConnect)),s&&t.once("close",r.close)};t.writable&&!t.connecting?o():t.connecting?t.once("connect",o):t.destroyed&&s&&r.close(t._hadError)};Nx.default=Tx;Lx.exports=Tx;Lx.exports.default=Tx});var cz=w((Ox,Mx)=>{"use strict";Object.defineProperty(Ox,"__esModule",{value:!0});var Fke=lz(),Nke=Number(process.versions.node.split(".")[0]),Ux=t=>{let e={start:Date.now(),socket:void 0,lookup:void 0,connect:void 0,secureConnect:void 0,upload:void 0,response:void 0,end:void 0,error:void 0,abort:void 0,phases:{wait:void 0,dns:void 0,tcp:void 0,tls:void 0,request:void 0,firstByte:void 0,download:void 0,total:void 0}};t.timings=e;let r=o=>{let a=o.emit.bind(o);o.emit=(l,...c)=>(l==="error"&&(e.error=Date.now(),e.phases.total=e.error-e.start,o.emit=a),a(l,...c))};r(t),t.prependOnceListener("abort",()=>{e.abort=Date.now(),(!e.response||Nke>=13)&&(e.phases.total=Date.now()-e.start)});let i=o=>{e.socket=Date.now(),e.phases.wait=e.socket-e.start;let a=()=>{e.lookup=Date.now(),e.phases.dns=e.lookup-e.socket};o.prependOnceListener("lookup",a),Fke.default(o,{connect:()=>{e.connect=Date.now(),e.lookup===void 0&&(o.removeListener("lookup",a),e.lookup=e.connect,e.phases.dns=e.lookup-e.socket),e.phases.tcp=e.connect-e.lookup},secureConnect:()=>{e.secureConnect=Date.now(),e.phases.tls=e.secureConnect-e.connect}})};t.socket?i(t.socket):t.prependOnceListener("socket",i);let n=()=>{var o;e.upload=Date.now(),e.phases.request=e.upload-(o=e.secureConnect,o!=null?o:e.connect)};return(()=>typeof t.writableFinished=="boolean"?t.writableFinished:t.finished&&t.outputSize===0&&(!t.socket||t.socket.writableLength===0))()?n():t.prependOnceListener("finish",n),t.prependOnceListener("response",o=>{e.response=Date.now(),e.phases.firstByte=e.response-e.upload,o.timings=e,r(o),o.prependOnceListener("end",()=>{e.end=Date.now(),e.phases.download=e.end-e.response,e.phases.total=e.end-e.start})}),e};Ox.default=Ux;Mx.exports=Ux;Mx.exports.default=Ux});var Cz=w((Yst,Kx)=>{"use strict";var{V4MAPPED:Lke,ADDRCONFIG:Tke,ALL:uz,promises:{Resolver:gz},lookup:Oke}=require("dns"),{promisify:Hx}=require("util"),Mke=require("os"),Wg=Symbol("cacheableLookupCreateConnection"),jx=Symbol("cacheableLookupInstance"),fz=Symbol("expires"),Uke=typeof uz=="number",hz=t=>{if(!(t&&typeof t.createConnection=="function"))throw new Error("Expected an Agent instance as the first argument")},Kke=t=>{for(let e of t)e.family!==6&&(e.address=`::ffff:${e.address}`,e.family=6)},pz=()=>{let t=!1,e=!1;for(let r of Object.values(Mke.networkInterfaces()))for(let i of r)if(!i.internal&&(i.family==="IPv6"?e=!0:t=!0,t&&e))return{has4:t,has6:e};return{has4:t,has6:e}},Hke=t=>Symbol.iterator in t,dz={ttl:!0},jke={all:!0},Gx=class{constructor({cache:e=new Map,maxTtl:r=Infinity,fallbackDuration:i=3600,errorTtl:n=.15,resolver:s=new gz,lookup:o=Oke}={}){if(this.maxTtl=r,this.errorTtl=n,this._cache=e,this._resolver=s,this._dnsLookup=Hx(o),this._resolver instanceof gz?(this._resolve4=this._resolver.resolve4.bind(this._resolver),this._resolve6=this._resolver.resolve6.bind(this._resolver)):(this._resolve4=Hx(this._resolver.resolve4.bind(this._resolver)),this._resolve6=Hx(this._resolver.resolve6.bind(this._resolver))),this._iface=pz(),this._pending={},this._nextRemovalTime=!1,this._hostnamesToFallback=new Set,i<1)this._fallback=!1;else{this._fallback=!0;let a=setInterval(()=>{this._hostnamesToFallback.clear()},i*1e3);a.unref&&a.unref()}this.lookup=this.lookup.bind(this),this.lookupAsync=this.lookupAsync.bind(this)}set servers(e){this.clear(),this._resolver.setServers(e)}get servers(){return this._resolver.getServers()}lookup(e,r,i){if(typeof r=="function"?(i=r,r={}):typeof r=="number"&&(r={family:r}),!i)throw new Error("Callback must be a function.");this.lookupAsync(e,r).then(n=>{r.all?i(null,n):i(null,n.address,n.family,n.expires,n.ttl)},i)}async lookupAsync(e,r={}){typeof r=="number"&&(r={family:r});let i=await this.query(e);if(r.family===6){let n=i.filter(s=>s.family===6);r.hints&Lke&&(Uke&&r.hints&uz||n.length===0)?Kke(i):i=n}else r.family===4&&(i=i.filter(n=>n.family===4));if(r.hints&Tke){let{_iface:n}=this;i=i.filter(s=>s.family===6?n.has6:n.has4)}if(i.length===0){let n=new Error(`cacheableLookup ENOTFOUND ${e}`);throw n.code="ENOTFOUND",n.hostname=e,n}return r.all?i:i[0]}async query(e){let r=await this._cache.get(e);if(!r){let i=this._pending[e];if(i)r=await i;else{let n=this.queryAndCache(e);this._pending[e]=n,r=await n}}return r=r.map(i=>N({},i)),r}async _resolve(e){let r=async c=>{try{return await c}catch(u){if(u.code==="ENODATA"||u.code==="ENOTFOUND")return[];throw u}},[i,n]=await Promise.all([this._resolve4(e,dz),this._resolve6(e,dz)].map(c=>r(c))),s=0,o=0,a=0,l=Date.now();for(let c of i)c.family=4,c.expires=l+c.ttl*1e3,s=Math.max(s,c.ttl);for(let c of n)c.family=6,c.expires=l+c.ttl*1e3,o=Math.max(o,c.ttl);return i.length>0?n.length>0?a=Math.min(s,o):a=s:a=o,{entries:[...i,...n],cacheTtl:a}}async _lookup(e){try{return{entries:await this._dnsLookup(e,{all:!0}),cacheTtl:0}}catch(r){return{entries:[],cacheTtl:0}}}async _set(e,r,i){if(this.maxTtl>0&&i>0){i=Math.min(i,this.maxTtl)*1e3,r[fz]=Date.now()+i;try{await this._cache.set(e,r,i)}catch(n){this.lookupAsync=async()=>{let s=new Error("Cache Error. Please recreate the CacheableLookup instance.");throw s.cause=n,s}}Hke(this._cache)&&this._tick(i)}}async queryAndCache(e){if(this._hostnamesToFallback.has(e))return this._dnsLookup(e,jke);try{let r=await this._resolve(e);r.entries.length===0&&this._fallback&&(r=await this._lookup(e),r.entries.length!==0&&this._hostnamesToFallback.add(e));let i=r.entries.length===0?this.errorTtl:r.cacheTtl;return await this._set(e,r.entries,i),delete this._pending[e],r.entries}catch(r){throw delete this._pending[e],r}}_tick(e){let r=this._nextRemovalTime;(!r||e<r)&&(clearTimeout(this._removalTimeout),this._nextRemovalTime=e,this._removalTimeout=setTimeout(()=>{this._nextRemovalTime=!1;let i=Infinity,n=Date.now();for(let[s,o]of this._cache){let a=o[fz];n>=a?this._cache.delete(s):a<i&&(i=a)}i!==Infinity&&this._tick(i-n)},e),this._removalTimeout.unref&&this._removalTimeout.unref())}install(e){if(hz(e),Wg in e)throw new Error("CacheableLookup has been already installed");e[Wg]=e.createConnection,e[jx]=this,e.createConnection=(r,i)=>("lookup"in r||(r.lookup=this.lookup),e[Wg](r,i))}uninstall(e){if(hz(e),e[Wg]){if(e[jx]!==this)throw new Error("The agent is not owned by this CacheableLookup instance");e.createConnection=e[Wg],delete e[Wg],delete e[jx]}}updateInterfaceInfo(){let{_iface:e}=this;this._iface=pz(),(e.has4&&!this._iface.has4||e.has6&&!this._iface.has6)&&this._cache.clear()}clear(e){if(e){this._cache.delete(e);return}this._cache.clear()}};Kx.exports=Gx;Kx.exports.default=Gx});var Iz=w((qst,Yx)=>{"use strict";var Gke=typeof URL=="undefined"?require("url").URL:URL,Yke="text/plain",qke="us-ascii",mz=(t,e)=>e.some(r=>r instanceof RegExp?r.test(t):r===t),Jke=(t,{stripHash:e})=>{let r=t.match(/^data:([^,]*?),([^#]*?)(?:#(.*))?$/);if(!r)throw new Error(`Invalid URL: ${t}`);let i=r[1].split(";"),n=r[2],s=e?"":r[3],o=!1;i[i.length-1]==="base64"&&(i.pop(),o=!0);let a=(i.shift()||"").toLowerCase(),c=[...i.map(u=>{let[g,f=""]=u.split("=").map(h=>h.trim());return g==="charset"&&(f=f.toLowerCase(),f===qke)?"":`${g}${f?`=${f}`:""}`}).filter(Boolean)];return o&&c.push("base64"),(c.length!==0||a&&a!==Yke)&&c.unshift(a),`data:${c.join(";")},${o?n.trim():n}${s?`#${s}`:""}`},Ez=(t,e)=>{if(e=N({defaultProtocol:"http:",normalizeProtocol:!0,forceHttp:!1,forceHttps:!1,stripAuthentication:!0,stripHash:!1,stripWWW:!0,removeQueryParameters:[/^utm_\w+/i],removeTrailingSlash:!0,removeDirectoryIndex:!1,sortQueryParameters:!0},e),Reflect.has(e,"normalizeHttps"))throw new Error("options.normalizeHttps is renamed to options.forceHttp");if(Reflect.has(e,"normalizeHttp"))throw new Error("options.normalizeHttp is renamed to options.forceHttps");if(Reflect.has(e,"stripFragment"))throw new Error("options.stripFragment is renamed to options.stripHash");if(t=t.trim(),/^data:/i.test(t))return Jke(t,e);let r=t.startsWith("//");!r&&/^\.*\//.test(t)||(t=t.replace(/^(?!(?:\w+:)?\/\/)|^\/\//,e.defaultProtocol));let n=new Gke(t);if(e.forceHttp&&e.forceHttps)throw new Error("The `forceHttp` and `forceHttps` options cannot be used together");if(e.forceHttp&&n.protocol==="https:"&&(n.protocol="http:"),e.forceHttps&&n.protocol==="http:"&&(n.protocol="https:"),e.stripAuthentication&&(n.username="",n.password=""),e.stripHash&&(n.hash=""),n.pathname&&(n.pathname=n.pathname.replace(/((?!:).|^)\/{2,}/g,(s,o)=>/^(?!\/)/g.test(o)?`${o}/`:"/")),n.pathname&&(n.pathname=decodeURI(n.pathname)),e.removeDirectoryIndex===!0&&(e.removeDirectoryIndex=[/^index\.[a-z]+$/]),Array.isArray(e.removeDirectoryIndex)&&e.removeDirectoryIndex.length>0){let s=n.pathname.split("/"),o=s[s.length-1];mz(o,e.removeDirectoryIndex)&&(s=s.slice(0,s.length-1),n.pathname=s.slice(1).join("/")+"/")}if(n.hostname&&(n.hostname=n.hostname.replace(/\.$/,""),e.stripWWW&&/^www\.([a-z\-\d]{2,63})\.([a-z.]{2,5})$/.test(n.hostname)&&(n.hostname=n.hostname.replace(/^www\./,""))),Array.isArray(e.removeQueryParameters))for(let s of[...n.searchParams.keys()])mz(s,e.removeQueryParameters)&&n.searchParams.delete(s);return e.sortQueryParameters&&n.searchParams.sort(),e.removeTrailingSlash&&(n.pathname=n.pathname.replace(/\/$/,"")),t=n.toString(),(e.removeTrailingSlash||n.pathname==="/")&&n.hash===""&&(t=t.replace(/\/$/,"")),r&&!e.normalizeProtocol&&(t=t.replace(/^http:\/\//,"//")),e.stripProtocol&&(t=t.replace(/^(?:https?:)?\/\//,"")),t};Yx.exports=Ez;Yx.exports.default=Ez});var Bz=w((Jst,yz)=>{yz.exports=wz;function wz(t,e){if(t&&e)return wz(t)(e);if(typeof t!="function")throw new TypeError("need wrapper function");return Object.keys(t).forEach(function(i){r[i]=t[i]}),r;function r(){for(var i=new Array(arguments.length),n=0;n<i.length;n++)i[n]=arguments[n];var s=t.apply(this,i),o=i[i.length-1];return typeof s=="function"&&s!==o&&Object.keys(o).forEach(function(a){s[a]=o[a]}),s}}});var Jx=w((Wst,qx)=>{var bz=Bz();qx.exports=bz(Sw);qx.exports.strict=bz(Qz);Sw.proto=Sw(function(){Object.defineProperty(Function.prototype,"once",{value:function(){return Sw(this)},configurable:!0}),Object.defineProperty(Function.prototype,"onceStrict",{value:function(){return Qz(this)},configurable:!0})});function Sw(t){var e=function(){return e.called?e.value:(e.called=!0,e.value=t.apply(this,arguments))};return e.called=!1,e}function Qz(t){var e=function(){if(e.called)throw new Error(e.onceError);return e.called=!0,e.value=t.apply(this,arguments)},r=t.name||"Function wrapped with `once`";return e.onceError=r+" shouldn't be called more than once",e.called=!1,e}});var Wx=w((zst,vz)=>{var Wke=Jx(),zke=function(){},_ke=function(t){return t.setHeader&&typeof t.abort=="function"},Vke=function(t){return t.stdio&&Array.isArray(t.stdio)&&t.stdio.length===3},Sz=function(t,e,r){if(typeof e=="function")return Sz(t,null,e);e||(e={}),r=Wke(r||zke);var i=t._writableState,n=t._readableState,s=e.readable||e.readable!==!1&&t.readable,o=e.writable||e.writable!==!1&&t.writable,a=function(){t.writable||l()},l=function(){o=!1,s||r.call(t)},c=function(){s=!1,o||r.call(t)},u=function(p){r.call(t,p?new Error("exited with error code: "+p):null)},g=function(p){r.call(t,p)},f=function(){if(s&&!(n&&n.ended))return r.call(t,new Error("premature close"));if(o&&!(i&&i.ended))return r.call(t,new Error("premature close"))},h=function(){t.req.on("finish",l)};return _ke(t)?(t.on("complete",l),t.on("abort",f),t.req?h():t.on("request",h)):o&&!i&&(t.on("end",a),t.on("close",a)),Vke(t)&&t.on("exit",u),t.on("end",c),t.on("finish",l),e.error!==!1&&t.on("error",g),t.on("close",f),function(){t.removeListener("complete",l),t.removeListener("abort",f),t.removeListener("request",h),t.req&&t.req.removeListener("finish",l),t.removeListener("end",a),t.removeListener("close",a),t.removeListener("finish",l),t.removeListener("exit",u),t.removeListener("end",c),t.removeListener("error",g),t.removeListener("close",f)}};vz.exports=Sz});var Pz=w((_st,kz)=>{var Xke=Jx(),Zke=Wx(),zx=require("fs"),xd=function(){},$ke=/^v?\.0/.test(process.version),kw=function(t){return typeof t=="function"},exe=function(t){return!$ke||!zx?!1:(t instanceof(zx.ReadStream||xd)||t instanceof(zx.WriteStream||xd))&&kw(t.close)},txe=function(t){return t.setHeader&&kw(t.abort)},rxe=function(t,e,r,i){i=Xke(i);var n=!1;t.on("close",function(){n=!0}),Zke(t,{readable:e,writable:r},function(o){if(o)return i(o);n=!0,i()});var s=!1;return function(o){if(!n&&!s){if(s=!0,exe(t))return t.close(xd);if(txe(t))return t.abort();if(kw(t.destroy))return t.destroy();i(o||new Error("stream was destroyed"))}}},xz=function(t){t()},ixe=function(t,e){return t.pipe(e)},nxe=function(){var t=Array.prototype.slice.call(arguments),e=kw(t[t.length-1]||xd)&&t.pop()||xd;if(Array.isArray(t[0])&&(t=t[0]),t.length<2)throw new Error("pump requires two streams per minimum");var r,i=t.map(function(n,s){var o=s<t.length-1,a=s>0;return rxe(n,o,a,function(l){r||(r=l),l&&i.forEach(xz),!o&&(i.forEach(xz),e(r))})});return t.reduce(ixe)};kz.exports=nxe});var Rz=w((Vst,Dz)=>{"use strict";var{PassThrough:sxe}=require("stream");Dz.exports=t=>{t=N({},t);let{array:e}=t,{encoding:r}=t,i=r==="buffer",n=!1;e?n=!(r||i):r=r||"utf8",i&&(r=null);let s=new sxe({objectMode:n});r&&s.setEncoding(r);let o=0,a=[];return s.on("data",l=>{a.push(l),n?o=a.length:o+=l.length}),s.getBufferedValue=()=>e?a:i?Buffer.concat(a,o):a.join(""),s.getBufferedLength=()=>o,s}});var Fz=w((Xst,zg)=>{"use strict";var oxe=Pz(),axe=Rz(),_x=class extends Error{constructor(){super("maxBuffer exceeded");this.name="MaxBufferError"}};async function xw(t,e){if(!t)return Promise.reject(new Error("Expected a stream"));e=N({maxBuffer:Infinity},e);let{maxBuffer:r}=e,i;return await new Promise((n,s)=>{let o=a=>{a&&(a.bufferedData=i.getBufferedValue()),s(a)};i=oxe(t,axe(e),a=>{if(a){o(a);return}n()}),i.on("data",()=>{i.getBufferedLength()>r&&o(new _x)})}),i.getBufferedValue()}zg.exports=xw;zg.exports.default=xw;zg.exports.buffer=(t,e)=>xw(t,te(N({},e),{encoding:"buffer"}));zg.exports.array=(t,e)=>xw(t,te(N({},e),{array:!0}));zg.exports.MaxBufferError=_x});var Lz=w(($st,Nz)=>{"use strict";var Axe=[200,203,204,206,300,301,404,405,410,414,501],lxe=[200,203,204,300,301,302,303,307,308,404,405,410,414,501],cxe={date:!0,connection:!0,"keep-alive":!0,"proxy-authenticate":!0,"proxy-authorization":!0,te:!0,trailer:!0,"transfer-encoding":!0,upgrade:!0},uxe={"content-length":!0,"content-encoding":!0,"transfer-encoding":!0,"content-range":!0};function Vx(t){let e={};if(!t)return e;let r=t.trim().split(/\s*,\s*/);for(let i of r){let[n,s]=i.split(/\s*=\s*/,2);e[n]=s===void 0?!0:s.replace(/^"|"$/g,"")}return e}function gxe(t){let e=[];for(let r in t){let i=t[r];e.push(i===!0?r:r+"="+i)}if(!!e.length)return e.join(", ")}Nz.exports=class{constructor(e,r,{shared:i,cacheHeuristic:n,immutableMinTimeToLive:s,ignoreCargoCult:o,trustServerDate:a,_fromObject:l}={}){if(l){this._fromObject(l);return}if(!r||!r.headers)throw Error("Response headers missing");this._assertRequestHasHeaders(e),this._responseTime=this.now(),this._isShared=i!==!1,this._trustServerDate=a!==void 0?a:!0,this._cacheHeuristic=n!==void 0?n:.1,this._immutableMinTtl=s!==void 0?s:24*3600*1e3,this._status="status"in r?r.status:200,this._resHeaders=r.headers,this._rescc=Vx(r.headers["cache-control"]),this._method="method"in e?e.method:"GET",this._url=e.url,this._host=e.headers.host,this._noAuthorization=!e.headers.authorization,this._reqHeaders=r.headers.vary?e.headers:null,this._reqcc=Vx(e.headers["cache-control"]),o&&"pre-check"in this._rescc&&"post-check"in this._rescc&&(delete this._rescc["pre-check"],delete this._rescc["post-check"],delete this._rescc["no-cache"],delete this._rescc["no-store"],delete this._rescc["must-revalidate"],this._resHeaders=Object.assign({},this._resHeaders,{"cache-control":gxe(this._rescc)}),delete this._resHeaders.expires,delete this._resHeaders.pragma),!r.headers["cache-control"]&&/no-cache/.test(r.headers.pragma)&&(this._rescc["no-cache"]=!0)}now(){return Date.now()}storable(){return!!(!this._reqcc["no-store"]&&(this._method==="GET"||this._method==="HEAD"||this._method==="POST"&&this._hasExplicitExpiration())&&lxe.indexOf(this._status)!==-1&&!this._rescc["no-store"]&&(!this._isShared||!this._rescc.private)&&(!this._isShared||this._noAuthorization||this._allowsStoringAuthenticated())&&(this._resHeaders.expires||this._rescc.public||this._rescc["max-age"]||this._rescc["s-maxage"]||Axe.indexOf(this._status)!==-1))}_hasExplicitExpiration(){return this._isShared&&this._rescc["s-maxage"]||this._rescc["max-age"]||this._resHeaders.expires}_assertRequestHasHeaders(e){if(!e||!e.headers)throw Error("Request headers missing")}satisfiesWithoutRevalidation(e){this._assertRequestHasHeaders(e);let r=Vx(e.headers["cache-control"]);return r["no-cache"]||/no-cache/.test(e.headers.pragma)||r["max-age"]&&this.age()>r["max-age"]||r["min-fresh"]&&this.timeToLive()<1e3*r["min-fresh"]||this.stale()&&!(r["max-stale"]&&!this._rescc["must-revalidate"]&&(r["max-stale"]===!0||r["max-stale"]>this.age()-this.maxAge()))?!1:this._requestMatches(e,!1)}_requestMatches(e,r){return(!this._url||this._url===e.url)&&this._host===e.headers.host&&(!e.method||this._method===e.method||r&&e.method==="HEAD")&&this._varyMatches(e)}_allowsStoringAuthenticated(){return this._rescc["must-revalidate"]||this._rescc.public||this._rescc["s-maxage"]}_varyMatches(e){if(!this._resHeaders.vary)return!0;if(this._resHeaders.vary==="*")return!1;let r=this._resHeaders.vary.trim().toLowerCase().split(/\s*,\s*/);for(let i of r)if(e.headers[i]!==this._reqHeaders[i])return!1;return!0}_copyWithoutHopByHopHeaders(e){let r={};for(let i in e)cxe[i]||(r[i]=e[i]);if(e.connection){let i=e.connection.trim().split(/\s*,\s*/);for(let n of i)delete r[n]}if(r.warning){let i=r.warning.split(/,/).filter(n=>!/^\s*1[0-9][0-9]/.test(n));i.length?r.warning=i.join(",").trim():delete r.warning}return r}responseHeaders(){let e=this._copyWithoutHopByHopHeaders(this._resHeaders),r=this.age();return r>3600*24&&!this._hasExplicitExpiration()&&this.maxAge()>3600*24&&(e.warning=(e.warning?`${e.warning}, `:"")+'113 - "rfc7234 5.5.4"'),e.age=`${Math.round(r)}`,e.date=new Date(this.now()).toUTCString(),e}date(){return this._trustServerDate?this._serverDate():this._responseTime}_serverDate(){let e=Date.parse(this._resHeaders.date);if(isFinite(e)){let r=8*3600*1e3;if(Math.abs(this._responseTime-e)<r)return e}return this._responseTime}age(){let e=Math.max(0,(this._responseTime-this.date())/1e3);if(this._resHeaders.age){let i=this._ageValue();i>e&&(e=i)}let r=(this.now()-this._responseTime)/1e3;return e+r}_ageValue(){let e=parseInt(this._resHeaders.age);return isFinite(e)?e:0}maxAge(){if(!this.storable()||this._rescc["no-cache"]||this._isShared&&this._resHeaders["set-cookie"]&&!this._rescc.public&&!this._rescc.immutable||this._resHeaders.vary==="*")return 0;if(this._isShared){if(this._rescc["proxy-revalidate"])return 0;if(this._rescc["s-maxage"])return parseInt(this._rescc["s-maxage"],10)}if(this._rescc["max-age"])return parseInt(this._rescc["max-age"],10);let e=this._rescc.immutable?this._immutableMinTtl:0,r=this._serverDate();if(this._resHeaders.expires){let i=Date.parse(this._resHeaders.expires);return Number.isNaN(i)||i<r?0:Math.max(e,(i-r)/1e3)}if(this._resHeaders["last-modified"]){let i=Date.parse(this._resHeaders["last-modified"]);if(isFinite(i)&&r>i)return Math.max(e,(r-i)/1e3*this._cacheHeuristic)}return e}timeToLive(){return Math.max(0,this.maxAge()-this.age())*1e3}stale(){return this.maxAge()<=this.age()}static fromObject(e){return new this(void 0,void 0,{_fromObject:e})}_fromObject(e){if(this._responseTime)throw Error("Reinitialized");if(!e||e.v!==1)throw Error("Invalid serialization");this._responseTime=e.t,this._isShared=e.sh,this._cacheHeuristic=e.ch,this._immutableMinTtl=e.imm!==void 0?e.imm:24*3600*1e3,this._status=e.st,this._resHeaders=e.resh,this._rescc=e.rescc,this._method=e.m,this._url=e.u,this._host=e.h,this._noAuthorization=e.a,this._reqHeaders=e.reqh,this._reqcc=e.reqcc}toObject(){return{v:1,t:this._responseTime,sh:this._isShared,ch:this._cacheHeuristic,imm:this._immutableMinTtl,st:this._status,resh:this._resHeaders,rescc:this._rescc,m:this._method,u:this._url,h:this._host,a:this._noAuthorization,reqh:this._reqHeaders,reqcc:this._reqcc}}revalidationHeaders(e){this._assertRequestHasHeaders(e);let r=this._copyWithoutHopByHopHeaders(e.headers);if(delete r["if-range"],!this._requestMatches(e,!0)||!this.storable())return delete r["if-none-match"],delete r["if-modified-since"],r;if(this._resHeaders.etag&&(r["if-none-match"]=r["if-none-match"]?`${r["if-none-match"]}, ${this._resHeaders.etag}`:this._resHeaders.etag),r["accept-ranges"]||r["if-match"]||r["if-unmodified-since"]||this._method&&this._method!="GET"){if(delete r["if-modified-since"],r["if-none-match"]){let n=r["if-none-match"].split(/,/).filter(s=>!/^\s*W\//.test(s));n.length?r["if-none-match"]=n.join(",").trim():delete r["if-none-match"]}}else this._resHeaders["last-modified"]&&!r["if-modified-since"]&&(r["if-modified-since"]=this._resHeaders["last-modified"]);return r}revalidatedPolicy(e,r){if(this._assertRequestHasHeaders(e),!r||!r.headers)throw Error("Response headers missing");let i=!1;if(r.status!==void 0&&r.status!=304?i=!1:r.headers.etag&&!/^\s*W\//.test(r.headers.etag)?i=this._resHeaders.etag&&this._resHeaders.etag.replace(/^\s*W\//,"")===r.headers.etag:this._resHeaders.etag&&r.headers.etag?i=this._resHeaders.etag.replace(/^\s*W\//,"")===r.headers.etag.replace(/^\s*W\//,""):this._resHeaders["last-modified"]?i=this._resHeaders["last-modified"]===r.headers["last-modified"]:!this._resHeaders.etag&&!this._resHeaders["last-modified"]&&!r.headers.etag&&!r.headers["last-modified"]&&(i=!0),!i)return{policy:new this.constructor(e,r),modified:r.status!=304,matches:!1};let n={};for(let o in this._resHeaders)n[o]=o in r.headers&&!uxe[o]?r.headers[o]:this._resHeaders[o];let s=Object.assign({},r,{status:this._status,method:this._method,headers:n});return{policy:new this.constructor(e,s,{shared:this._isShared,cacheHeuristic:this._cacheHeuristic,immutableMinTimeToLive:this._immutableMinTtl,trustServerDate:this._trustServerDate}),modified:!1,matches:!0}}}});var Pw=w((eot,Tz)=>{"use strict";Tz.exports=t=>{let e={};for(let[r,i]of Object.entries(t))e[r.toLowerCase()]=i;return e}});var Uz=w((tot,Oz)=>{"use strict";var fxe=require("stream").Readable,hxe=Pw(),Mz=class extends fxe{constructor(e,r,i,n){if(typeof e!="number")throw new TypeError("Argument `statusCode` should be a number");if(typeof r!="object")throw new TypeError("Argument `headers` should be an object");if(!(i instanceof Buffer))throw new TypeError("Argument `body` should be a buffer");if(typeof n!="string")throw new TypeError("Argument `url` should be a string");super();this.statusCode=e,this.headers=hxe(r),this.body=i,this.url=n}_read(){this.push(this.body),this.push(null)}};Oz.exports=Mz});var Hz=w((rot,Kz)=>{"use strict";var pxe=["destroy","setTimeout","socket","headers","trailers","rawHeaders","statusCode","httpVersion","httpVersionMinor","httpVersionMajor","rawTrailers","statusMessage"];Kz.exports=(t,e)=>{let r=new Set(Object.keys(t).concat(pxe));for(let i of r)i in e||(e[i]=typeof t[i]=="function"?t[i].bind(t):t[i])}});var Gz=w((iot,jz)=>{"use strict";var dxe=require("stream").PassThrough,Cxe=Hz(),mxe=t=>{if(!(t&&t.pipe))throw new TypeError("Parameter `response` must be a response stream.");let e=new dxe;return Cxe(t,e),t.pipe(e)};jz.exports=mxe});var Yz=w(Xx=>{Xx.stringify=function t(e){if(typeof e=="undefined")return e;if(e&&Buffer.isBuffer(e))return JSON.stringify(":base64:"+e.toString("base64"));if(e&&e.toJSON&&(e=e.toJSON()),e&&typeof e=="object"){var r="",i=Array.isArray(e);r=i?"[":"{";var n=!0;for(var s in e){var o=typeof e[s]=="function"||!i&&typeof e[s]=="undefined";Object.hasOwnProperty.call(e,s)&&!o&&(n||(r+=","),n=!1,i?e[s]==null?r+="null":r+=t(e[s]):e[s]!==void 0&&(r+=t(s)+":"+t(e[s])))}return r+=i?"]":"}",r}else return typeof e=="string"?JSON.stringify(/^:/.test(e)?":"+e:e):typeof e=="undefined"?"null":JSON.stringify(e)};Xx.parse=function(t){return JSON.parse(t,function(e,r){return typeof r=="string"?/^:base64:/.test(r)?Buffer.from(r.substring(8),"base64"):/^:/.test(r)?r.substring(1):r:r})}});var zz=w((sot,qz)=>{"use strict";var Exe=require("events"),Jz=Yz(),Ixe=t=>{let e={redis:"@keyv/redis",mongodb:"@keyv/mongo",mongo:"@keyv/mongo",sqlite:"@keyv/sqlite",postgresql:"@keyv/postgres",postgres:"@keyv/postgres",mysql:"@keyv/mysql"};if(t.adapter||t.uri){let r=t.adapter||/^[^:]*/.exec(t.uri)[0];return new(require(e[r]))(t)}return new Map},Wz=class extends Exe{constructor(e,r){super();if(this.opts=Object.assign({namespace:"keyv",serialize:Jz.stringify,deserialize:Jz.parse},typeof e=="string"?{uri:e}:e,r),!this.opts.store){let i=Object.assign({},this.opts);this.opts.store=Ixe(i)}typeof this.opts.store.on=="function"&&this.opts.store.on("error",i=>this.emit("error",i)),this.opts.store.namespace=this.opts.namespace}_getKeyPrefix(e){return`${this.opts.namespace}:${e}`}get(e,r){e=this._getKeyPrefix(e);let{store:i}=this.opts;return Promise.resolve().then(()=>i.get(e)).then(n=>typeof n=="string"?this.opts.deserialize(n):n).then(n=>{if(n!==void 0){if(typeof n.expires=="number"&&Date.now()>n.expires){this.delete(e);return}return r&&r.raw?n:n.value}})}set(e,r,i){e=this._getKeyPrefix(e),typeof i=="undefined"&&(i=this.opts.ttl),i===0&&(i=void 0);let{store:n}=this.opts;return Promise.resolve().then(()=>{let s=typeof i=="number"?Date.now()+i:null;return r={value:r,expires:s},this.opts.serialize(r)}).then(s=>n.set(e,s,i)).then(()=>!0)}delete(e){e=this._getKeyPrefix(e);let{store:r}=this.opts;return Promise.resolve().then(()=>r.delete(e))}clear(){let{store:e}=this.opts;return Promise.resolve().then(()=>e.clear())}};qz.exports=Wz});var Xz=w((oot,_z)=>{"use strict";var yxe=require("events"),Dw=require("url"),wxe=Iz(),Bxe=Fz(),Zx=Lz(),Vz=Uz(),bxe=Pw(),Qxe=Gz(),vxe=zz(),ea=class{constructor(e,r){if(typeof e!="function")throw new TypeError("Parameter `request` must be a function");return this.cache=new vxe({uri:typeof r=="string"&&r,store:typeof r!="string"&&r,namespace:"cacheable-request"}),this.createCacheableRequest(e)}createCacheableRequest(e){return(r,i)=>{let n;if(typeof r=="string")n=$x(Dw.parse(r)),r={};else if(r instanceof Dw.URL)n=$x(Dw.parse(r.toString())),r={};else{let[g,...f]=(r.path||"").split("?"),h=f.length>0?`?${f.join("?")}`:"";n=$x(te(N({},r),{pathname:g,search:h}))}r=N(N({headers:{},method:"GET",cache:!0,strictTtl:!1,automaticFailover:!1},r),Sxe(n)),r.headers=bxe(r.headers);let s=new yxe,o=wxe(Dw.format(n),{stripWWW:!1,removeTrailingSlash:!1,stripAuthentication:!1}),a=`${r.method}:${o}`,l=!1,c=!1,u=g=>{c=!0;let f=!1,h,p=new Promise(y=>{h=()=>{f||(f=!0,y())}}),m=y=>{if(l&&!g.forceRefresh){y.status=y.statusCode;let S=Zx.fromObject(l.cachePolicy).revalidatedPolicy(g,y);if(!S.modified){let x=S.policy.responseHeaders();y=new Vz(l.statusCode,x,l.body,l.url),y.cachePolicy=S.policy,y.fromCache=!0}}y.fromCache||(y.cachePolicy=new Zx(g,y,g),y.fromCache=!1);let Q;g.cache&&y.cachePolicy.storable()?(Q=Qxe(y),(async()=>{try{let S=Bxe.buffer(y);if(await Promise.race([p,new Promise(U=>y.once("end",U))]),f)return;let x=await S,M={cachePolicy:y.cachePolicy.toObject(),url:y.url,statusCode:y.fromCache?l.statusCode:y.statusCode,body:x},Y=g.strictTtl?y.cachePolicy.timeToLive():void 0;g.maxTtl&&(Y=Y?Math.min(Y,g.maxTtl):g.maxTtl),await this.cache.set(a,M,Y)}catch(S){s.emit("error",new ea.CacheError(S))}})()):g.cache&&l&&(async()=>{try{await this.cache.delete(a)}catch(S){s.emit("error",new ea.CacheError(S))}})(),s.emit("response",Q||y),typeof i=="function"&&i(Q||y)};try{let y=e(g,m);y.once("error",h),y.once("abort",h),s.emit("request",y)}catch(y){s.emit("error",new ea.RequestError(y))}};return(async()=>{let g=async h=>{await Promise.resolve();let p=h.cache?await this.cache.get(a):void 0;if(typeof p=="undefined")return u(h);let m=Zx.fromObject(p.cachePolicy);if(m.satisfiesWithoutRevalidation(h)&&!h.forceRefresh){let y=m.responseHeaders(),Q=new Vz(p.statusCode,y,p.body,p.url);Q.cachePolicy=m,Q.fromCache=!0,s.emit("response",Q),typeof i=="function"&&i(Q)}else l=p,h.headers=m.revalidationHeaders(h),u(h)},f=h=>s.emit("error",new ea.CacheError(h));this.cache.once("error",f),s.on("response",()=>this.cache.removeListener("error",f));try{await g(r)}catch(h){r.automaticFailover&&!c&&u(r),s.emit("error",new ea.CacheError(h))}})(),s}}};function Sxe(t){let e=N({},t);return e.path=`${t.pathname||"/"}${t.search||""}`,delete e.pathname,delete e.search,e}function $x(t){return{protocol:t.protocol,auth:t.auth,hostname:t.hostname||t.host||"localhost",port:t.port,pathname:t.pathname,search:t.search}}ea.RequestError=class extends Error{constructor(t){super(t.message);this.name="RequestError",Object.assign(this,t)}};ea.CacheError=class extends Error{constructor(t){super(t.message);this.name="CacheError",Object.assign(this,t)}};_z.exports=ea});var $z=w((aot,Zz)=>{"use strict";var kxe=["aborted","complete","headers","httpVersion","httpVersionMinor","httpVersionMajor","method","rawHeaders","rawTrailers","setTimeout","socket","statusCode","statusMessage","trailers","url"];Zz.exports=(t,e)=>{if(e._readableState.autoDestroy)throw new Error("The second stream must have the `autoDestroy` option set to `false`");let r=new Set(Object.keys(t).concat(kxe)),i={};for(let n of r)n in e||(i[n]={get(){let s=t[n];return typeof s=="function"?s.bind(t):s},set(s){t[n]=s},enumerable:!0,configurable:!1});return Object.defineProperties(e,i),t.once("aborted",()=>{e.destroy(),e.emit("aborted")}),t.once("close",()=>{t.complete&&e.readable?e.once("end",()=>{e.emit("close")}):e.emit("close")}),e}});var t4=w((Aot,e4)=>{"use strict";var{Transform:xxe,PassThrough:Pxe}=require("stream"),eP=require("zlib"),Dxe=$z();e4.exports=t=>{let e=(t.headers["content-encoding"]||"").toLowerCase();if(!["gzip","deflate","br"].includes(e))return t;let r=e==="br";if(r&&typeof eP.createBrotliDecompress!="function")return t.destroy(new Error("Brotli is not supported on Node.js < 12")),t;let i=!0,n=new xxe({transform(a,l,c){i=!1,c(null,a)},flush(a){a()}}),s=new Pxe({autoDestroy:!1,destroy(a,l){t.destroy(),l(a)}}),o=r?eP.createBrotliDecompress():eP.createUnzip();return o.once("error",a=>{if(i&&!t.readable){s.end();return}s.destroy(a)}),Dxe(t,s),t.pipe(n).pipe(o).pipe(s),s}});var tP=w((lot,r4)=>{"use strict";var i4=class{constructor(e={}){if(!(e.maxSize&&e.maxSize>0))throw new TypeError("`maxSize` must be a number greater than 0");this.maxSize=e.maxSize,this.onEviction=e.onEviction,this.cache=new Map,this.oldCache=new Map,this._size=0}_set(e,r){if(this.cache.set(e,r),this._size++,this._size>=this.maxSize){if(this._size=0,typeof this.onEviction=="function")for(let[i,n]of this.oldCache.entries())this.onEviction(i,n);this.oldCache=this.cache,this.cache=new Map}}get(e){if(this.cache.has(e))return this.cache.get(e);if(this.oldCache.has(e)){let r=this.oldCache.get(e);return this.oldCache.delete(e),this._set(e,r),r}}set(e,r){return this.cache.has(e)?this.cache.set(e,r):this._set(e,r),this}has(e){return this.cache.has(e)||this.oldCache.has(e)}peek(e){if(this.cache.has(e))return this.cache.get(e);if(this.oldCache.has(e))return this.oldCache.get(e)}delete(e){let r=this.cache.delete(e);return r&&this._size--,this.oldCache.delete(e)||r}clear(){this.cache.clear(),this.oldCache.clear(),this._size=0}*keys(){for(let[e]of this)yield e}*values(){for(let[,e]of this)yield e}*[Symbol.iterator](){for(let e of this.cache)yield e;for(let e of this.oldCache){let[r]=e;this.cache.has(r)||(yield e)}}get size(){let e=0;for(let r of this.oldCache.keys())this.cache.has(r)||e++;return Math.min(this._size+e,this.maxSize)}};r4.exports=i4});var iP=w((cot,n4)=>{"use strict";var Rxe=require("events"),Fxe=require("tls"),Nxe=require("http2"),Lxe=tP(),gn=Symbol("currentStreamsCount"),s4=Symbol("request"),Fs=Symbol("cachedOriginSet"),_g=Symbol("gracefullyClosing"),Txe=["maxDeflateDynamicTableSize","maxSessionMemory","maxHeaderListPairs","maxOutstandingPings","maxReservedRemoteStreams","maxSendHeaderBlockLength","paddingStrategy","localAddress","path","rejectUnauthorized","minDHSize","ca","cert","clientCertEngine","ciphers","key","pfx","servername","minVersion","maxVersion","secureProtocol","crl","honorCipherOrder","ecdhCurve","dhparam","secureOptions","sessionIdContext"],Oxe=(t,e,r)=>{let i=0,n=t.length;for(;i<n;){let s=i+n>>>1;r(t[s],e)?i=s+1:n=s}return i},Mxe=(t,e)=>t.remoteSettings.maxConcurrentStreams>e.remoteSettings.maxConcurrentStreams,rP=(t,e)=>{for(let r of t)r[Fs].length<e[Fs].length&&r[Fs].every(i=>e[Fs].includes(i))&&r[gn]+e[gn]<=e.remoteSettings.maxConcurrentStreams&&o4(r)},Uxe=(t,e)=>{for(let r of t)e[Fs].length<r[Fs].length&&e[Fs].every(i=>r[Fs].includes(i))&&e[gn]+r[gn]<=r.remoteSettings.maxConcurrentStreams&&o4(e)},a4=({agent:t,isFree:e})=>{let r={};for(let i in t.sessions){let s=t.sessions[i].filter(o=>{let a=o[eA.kCurrentStreamsCount]<o.remoteSettings.maxConcurrentStreams;return e?a:!a});s.length!==0&&(r[i]=s)}return r},o4=t=>{t[_g]=!0,t[gn]===0&&t.close()},eA=class extends Rxe{constructor({timeout:e=6e4,maxSessions:r=Infinity,maxFreeSessions:i=10,maxCachedTlsSessions:n=100}={}){super();this.sessions={},this.queue={},this.timeout=e,this.maxSessions=r,this.maxFreeSessions=i,this._freeSessionsCount=0,this._sessionsCount=0,this.settings={enablePush:!1},this.tlsSessionCache=new Lxe({maxSize:n})}static normalizeOrigin(e,r){return typeof e=="string"&&(e=new URL(e)),r&&e.hostname!==r&&(e.hostname=r),e.origin}normalizeOptions(e){let r="";if(e)for(let i of Txe)e[i]&&(r+=`:${e[i]}`);return r}_tryToCreateNewSession(e,r){if(!(e in this.queue)||!(r in this.queue[e]))return;let i=this.queue[e][r];this._sessionsCount<this.maxSessions&&!i.completed&&(i.completed=!0,i())}getSession(e,r,i){return new Promise((n,s)=>{Array.isArray(i)?(i=[...i],n()):i=[{resolve:n,reject:s}];let o=this.normalizeOptions(r),a=eA.normalizeOrigin(e,r&&r.servername);if(a===void 0){for(let{reject:u}of i)u(new TypeError("The `origin` argument needs to be a string or an URL object"));return}if(o in this.sessions){let u=this.sessions[o],g=-1,f=-1,h;for(let p of u){let m=p.remoteSettings.maxConcurrentStreams;if(m<g)break;if(p[Fs].includes(a)){let y=p[gn];if(y>=m||p[_g]||p.destroyed)continue;h||(g=m),y>f&&(h=p,f=y)}}if(h){if(i.length!==1){for(let{reject:p}of i){let m=new Error(`Expected the length of listeners to be 1, got ${i.length}.
+Please report this to https://github.com/szmarczak/http2-wrapper/`);p(m)}return}i[0].resolve(h);return}}if(o in this.queue){if(a in this.queue[o]){this.queue[o][a].listeners.push(...i),this._tryToCreateNewSession(o,a);return}}else this.queue[o]={};let l=()=>{o in this.queue&&this.queue[o][a]===c&&(delete this.queue[o][a],Object.keys(this.queue[o]).length===0&&delete this.queue[o])},c=()=>{let u=`${a}:${o}`,g=!1;try{let f=Nxe.connect(e,N({createConnection:this.createConnection,settings:this.settings,session:this.tlsSessionCache.get(u)},r));f[gn]=0,f[_g]=!1;let h=()=>f[gn]<f.remoteSettings.maxConcurrentStreams,p=!0;f.socket.once("session",y=>{this.tlsSessionCache.set(u,y)}),f.once("error",y=>{for(let{reject:Q}of i)Q(y);this.tlsSessionCache.delete(u)}),f.setTimeout(this.timeout,()=>{f.destroy()}),f.once("close",()=>{if(g){p&&this._freeSessionsCount--,this._sessionsCount--;let y=this.sessions[o];y.splice(y.indexOf(f),1),y.length===0&&delete this.sessions[o]}else{let y=new Error("Session closed without receiving a SETTINGS frame");y.code="HTTP2WRAPPER_NOSETTINGS";for(let{reject:Q}of i)Q(y);l()}this._tryToCreateNewSession(o,a)});let m=()=>{if(!(!(o in this.queue)||!h())){for(let y of f[Fs])if(y in this.queue[o]){let{listeners:Q}=this.queue[o][y];for(;Q.length!==0&&h();)Q.shift().resolve(f);let S=this.queue[o];if(S[y].listeners.length===0&&(delete S[y],Object.keys(S).length===0)){delete this.queue[o];break}if(!h())break}}};f.on("origin",()=>{f[Fs]=f.originSet,!!h()&&(m(),rP(this.sessions[o],f))}),f.once("remoteSettings",()=>{if(f.ref(),f.unref(),this._sessionsCount++,c.destroyed){let y=new Error("Agent has been destroyed");for(let Q of i)Q.reject(y);f.destroy();return}f[Fs]=f.originSet;{let y=this.sessions;if(o in y){let Q=y[o];Q.splice(Oxe(Q,f,Mxe),0,f)}else y[o]=[f]}this._freeSessionsCount+=1,g=!0,this.emit("session",f),m(),l(),f[gn]===0&&this._freeSessionsCount>this.maxFreeSessions&&f.close(),i.length!==0&&(this.getSession(a,r,i),i.length=0),f.on("remoteSettings",()=>{m(),rP(this.sessions[o],f)})}),f[s4]=f.request,f.request=(y,Q)=>{if(f[_g])throw new Error("The session is gracefully closing. No new streams are allowed.");let S=f[s4](y,Q);return f.ref(),++f[gn],f[gn]===f.remoteSettings.maxConcurrentStreams&&this._freeSessionsCount--,S.once("close",()=>{if(p=h(),--f[gn],!f.destroyed&&!f.closed&&(Uxe(this.sessions[o],f),h()&&!f.closed)){p||(this._freeSessionsCount++,p=!0);let x=f[gn]===0;x&&f.unref(),x&&(this._freeSessionsCount>this.maxFreeSessions||f[_g])?f.close():(rP(this.sessions[o],f),m())}}),S}}catch(f){for(let h of i)h.reject(f);l()}};c.listeners=i,c.completed=!1,c.destroyed=!1,this.queue[o][a]=c,this._tryToCreateNewSession(o,a)})}request(e,r,i,n){return new Promise((s,o)=>{this.getSession(e,r,[{reject:o,resolve:a=>{try{s(a.request(i,n))}catch(l){o(l)}}}])})}createConnection(e,r){return eA.connect(e,r)}static connect(e,r){r.ALPNProtocols=["h2"];let i=e.port||443,n=e.hostname||e.host;return typeof r.servername=="undefined"&&(r.servername=n),Fxe.connect(i,n,r)}closeFreeSessions(){for(let e of Object.values(this.sessions))for(let r of e)r[gn]===0&&r.close()}destroy(e){for(let r of Object.values(this.sessions))for(let i of r)i.destroy(e);for(let r of Object.values(this.queue))for(let i of Object.values(r))i.destroyed=!0;this.queue={}}get freeSessions(){return a4({agent:this,isFree:!0})}get busySessions(){return a4({agent:this,isFree:!1})}};eA.kCurrentStreamsCount=gn;eA.kGracefullyClosing=_g;n4.exports={Agent:eA,globalAgent:new eA}});var nP=w((uot,A4)=>{"use strict";var{Readable:Kxe}=require("stream"),l4=class extends Kxe{constructor(e,r){super({highWaterMark:r,autoDestroy:!1});this.statusCode=null,this.statusMessage="",this.httpVersion="2.0",this.httpVersionMajor=2,this.httpVersionMinor=0,this.headers={},this.trailers={},this.req=null,this.aborted=!1,this.complete=!1,this.upgrade=null,this.rawHeaders=[],this.rawTrailers=[],this.socket=e,this.connection=e,this._dumped=!1}_destroy(e){this.req._request.destroy(e)}setTimeout(e,r){return this.req.setTimeout(e,r),this}_dump(){this._dumped||(this._dumped=!0,this.removeAllListeners("data"),this.resume())}_read(){this.req&&this.req._request.resume()}};A4.exports=l4});var sP=w((got,c4)=>{"use strict";c4.exports=t=>{let e={protocol:t.protocol,hostname:typeof t.hostname=="string"&&t.hostname.startsWith("[")?t.hostname.slice(1,-1):t.hostname,host:t.host,hash:t.hash,search:t.search,pathname:t.pathname,href:t.href,path:`${t.pathname||""}${t.search||""}`};return typeof t.port=="string"&&t.port.length!==0&&(e.port=Number(t.port)),(t.username||t.password)&&(e.auth=`${t.username||""}:${t.password||""}`),e}});var g4=w((fot,u4)=>{"use strict";u4.exports=(t,e,r)=>{for(let i of r)t.on(i,(...n)=>e.emit(i,...n))}});var h4=w((hot,f4)=>{"use strict";f4.exports=t=>{switch(t){case":method":case":scheme":case":authority":case":path":return!0;default:return!1}}});var d4=w((dot,p4)=>{"use strict";var Vg=(t,e,r)=>{p4.exports[e]=class extends t{constructor(...n){super(typeof r=="string"?r:r(n));this.name=`${super.name} [${e}]`,this.code=e}}};Vg(TypeError,"ERR_INVALID_ARG_TYPE",t=>{let e=t[0].includes(".")?"property":"argument",r=t[1],i=Array.isArray(r);return i&&(r=`${r.slice(0,-1).join(", ")} or ${r.slice(-1)}`),`The "${t[0]}" ${e} must be ${i?"one of":"of"} type ${r}. Received ${typeof t[2]}`});Vg(TypeError,"ERR_INVALID_PROTOCOL",t=>`Protocol "${t[0]}" not supported. Expected "${t[1]}"`);Vg(Error,"ERR_HTTP_HEADERS_SENT",t=>`Cannot ${t[0]} headers after they are sent to the client`);Vg(TypeError,"ERR_INVALID_HTTP_TOKEN",t=>`${t[0]} must be a valid HTTP token [${t[1]}]`);Vg(TypeError,"ERR_HTTP_INVALID_HEADER_VALUE",t=>`Invalid value "${t[0]} for header "${t[1]}"`);Vg(TypeError,"ERR_INVALID_CHAR",t=>`Invalid character in ${t[0]} [${t[1]}]`)});var lP=w((Cot,C4)=>{"use strict";var Hxe=require("http2"),{Writable:jxe}=require("stream"),{Agent:m4,globalAgent:Gxe}=iP(),Yxe=nP(),qxe=sP(),Jxe=g4(),Wxe=h4(),{ERR_INVALID_ARG_TYPE:oP,ERR_INVALID_PROTOCOL:zxe,ERR_HTTP_HEADERS_SENT:E4,ERR_INVALID_HTTP_TOKEN:_xe,ERR_HTTP_INVALID_HEADER_VALUE:Vxe,ERR_INVALID_CHAR:Xxe}=d4(),{HTTP2_HEADER_STATUS:I4,HTTP2_HEADER_METHOD:y4,HTTP2_HEADER_PATH:w4,HTTP2_METHOD_CONNECT:Zxe}=Hxe.constants,Wi=Symbol("headers"),aP=Symbol("origin"),AP=Symbol("session"),B4=Symbol("options"),Rw=Symbol("flushedHeaders"),Pd=Symbol("jobs"),$xe=/^[\^`\-\w!#$%&*+.|~]+$/,ePe=/[^\t\u0020-\u007E\u0080-\u00FF]/,b4=class extends jxe{constructor(e,r,i){super({autoDestroy:!1});let n=typeof e=="string"||e instanceof URL;if(n&&(e=qxe(e instanceof URL?e:new URL(e))),typeof r=="function"||r===void 0?(i=r,r=n?e:N({},e)):r=N(N({},e),r),r.h2session)this[AP]=r.h2session;else if(r.agent===!1)this.agent=new m4({maxFreeSessions:0});else if(typeof r.agent=="undefined"||r.agent===null)typeof r.createConnection=="function"?(this.agent=new m4({maxFreeSessions:0}),this.agent.createConnection=r.createConnection):this.agent=Gxe;else if(typeof r.agent.request=="function")this.agent=r.agent;else throw new oP("options.agent",["Agent-like Object","undefined","false"],r.agent);if(r.protocol&&r.protocol!=="https:")throw new zxe(r.protocol,"https:");let s=r.port||r.defaultPort||this.agent&&this.agent.defaultPort||443,o=r.hostname||r.host||"localhost";delete r.hostname,delete r.host,delete r.port;let{timeout:a}=r;if(r.timeout=void 0,this[Wi]=Object.create(null),this[Pd]=[],this.socket=null,this.connection=null,this.method=r.method||"GET",this.path=r.path,this.res=null,this.aborted=!1,this.reusedSocket=!1,r.headers)for(let[l,c]of Object.entries(r.headers))this.setHeader(l,c);r.auth&&!("authorization"in this[Wi])&&(this[Wi].authorization="Basic "+Buffer.from(r.auth).toString("base64")),r.session=r.tlsSession,r.path=r.socketPath,this[B4]=r,s===443?(this[aP]=`https://${o}`,":authority"in this[Wi]||(this[Wi][":authority"]=o)):(this[aP]=`https://${o}:${s}`,":authority"in this[Wi]||(this[Wi][":authority"]=`${o}:${s}`)),a&&this.setTimeout(a),i&&this.once("response",i),this[Rw]=!1}get method(){return this[Wi][y4]}set method(e){e&&(this[Wi][y4]=e.toUpperCase())}get path(){return this[Wi][w4]}set path(e){e&&(this[Wi][w4]=e)}get _mustNotHaveABody(){return this.method==="GET"||this.method==="HEAD"||this.method==="DELETE"}_write(e,r,i){if(this._mustNotHaveABody){i(new Error("The GET, HEAD and DELETE methods must NOT have a body"));return}this.flushHeaders();let n=()=>this._request.write(e,r,i);this._request?n():this[Pd].push(n)}_final(e){if(this.destroyed)return;this.flushHeaders();let r=()=>{if(this._mustNotHaveABody){e();return}this._request.end(e)};this._request?r():this[Pd].push(r)}abort(){this.res&&this.res.complete||(this.aborted||process.nextTick(()=>this.emit("abort")),this.aborted=!0,this.destroy())}_destroy(e,r){this.res&&this.res._dump(),this._request&&this._request.destroy(),r(e)}async flushHeaders(){if(this[Rw]||this.destroyed)return;this[Rw]=!0;let e=this.method===Zxe,r=i=>{if(this._request=i,this.destroyed){i.destroy();return}e||Jxe(i,this,["timeout","continue","close","error"]);let n=o=>(...a)=>{!this.writable&&!this.destroyed?o(...a):this.once("finish",()=>{o(...a)})};i.once("response",n((o,a,l)=>{let c=new Yxe(this.socket,i.readableHighWaterMark);this.res=c,c.req=this,c.statusCode=o[I4],c.headers=o,c.rawHeaders=l,c.once("end",()=>{this.aborted?(c.aborted=!0,c.emit("aborted")):(c.complete=!0,c.socket=null,c.connection=null)}),e?(c.upgrade=!0,this.emit("connect",c,i,Buffer.alloc(0))?this.emit("close"):i.destroy()):(i.on("data",u=>{!c._dumped&&!c.push(u)&&i.pause()}),i.once("end",()=>{c.push(null)}),this.emit("response",c)||c._dump())})),i.once("headers",n(o=>this.emit("information",{statusCode:o[I4]}))),i.once("trailers",n((o,a,l)=>{let{res:c}=this;c.trailers=o,c.rawTrailers=l}));let{socket:s}=i.session;this.socket=s,this.connection=s;for(let o of this[Pd])o();this.emit("socket",this.socket)};if(this[AP])try{r(this[AP].request(this[Wi]))}catch(i){this.emit("error",i)}else{this.reusedSocket=!0;try{r(await this.agent.request(this[aP],this[B4],this[Wi]))}catch(i){this.emit("error",i)}}}getHeader(e){if(typeof e!="string")throw new oP("name","string",e);return this[Wi][e.toLowerCase()]}get headersSent(){return this[Rw]}removeHeader(e){if(typeof e!="string")throw new oP("name","string",e);if(this.headersSent)throw new E4("remove");delete this[Wi][e.toLowerCase()]}setHeader(e,r){if(this.headersSent)throw new E4("set");if(typeof e!="string"||!$xe.test(e)&&!Wxe(e))throw new _xe("Header name",e);if(typeof r=="undefined")throw new Vxe(r,e);if(ePe.test(r))throw new Xxe("header content",e);this[Wi][e.toLowerCase()]=r}setNoDelay(){}setSocketKeepAlive(){}setTimeout(e,r){let i=()=>this._request.setTimeout(e,r);return this._request?i():this[Pd].push(i),this}get maxHeadersCount(){if(!this.destroyed&&this._request)return this._request.session.localSettings.maxHeaderListSize}set maxHeadersCount(e){}};C4.exports=b4});var v4=w((mot,Q4)=>{"use strict";var tPe=require("tls");Q4.exports=(t={})=>new Promise((e,r)=>{let i=tPe.connect(t,()=>{t.resolveSocket?(i.off("error",r),e({alpnProtocol:i.alpnProtocol,socket:i})):(i.destroy(),e({alpnProtocol:i.alpnProtocol}))});i.on("error",r)})});var k4=w((Eot,S4)=>{"use strict";var rPe=require("net");S4.exports=t=>{let e=t.host,r=t.headers&&t.headers.host;return r&&(r.startsWith("[")?r.indexOf("]")===-1?e=r:e=r.slice(1,-1):e=r.split(":",1)[0]),rPe.isIP(e)?"":e}});var D4=w((Iot,cP)=>{"use strict";var x4=require("http"),uP=require("https"),iPe=v4(),nPe=tP(),sPe=lP(),oPe=k4(),aPe=sP(),Fw=new nPe({maxSize:100}),Dd=new Map,P4=(t,e,r)=>{e._httpMessage={shouldKeepAlive:!0};let i=()=>{t.emit("free",e,r)};e.on("free",i);let n=()=>{t.removeSocket(e,r)};e.on("close",n);let s=()=>{t.removeSocket(e,r),e.off("close",n),e.off("free",i),e.off("agentRemove",s)};e.on("agentRemove",s),t.emit("free",e,r)},APe=async t=>{let e=`${t.host}:${t.port}:${t.ALPNProtocols.sort()}`;if(!Fw.has(e)){if(Dd.has(e))return(await Dd.get(e)).alpnProtocol;let{path:r,agent:i}=t;t.path=t.socketPath;let n=iPe(t);Dd.set(e,n);try{let{socket:s,alpnProtocol:o}=await n;if(Fw.set(e,o),t.path=r,o==="h2")s.destroy();else{let{globalAgent:a}=uP,l=uP.Agent.prototype.createConnection;i?i.createConnection===l?P4(i,s,t):s.destroy():a.createConnection===l?P4(a,s,t):s.destroy()}return Dd.delete(e),o}catch(s){throw Dd.delete(e),s}}return Fw.get(e)};cP.exports=async(t,e,r)=>{if((typeof t=="string"||t instanceof URL)&&(t=aPe(new URL(t))),typeof e=="function"&&(r=e,e=void 0),e=te(N(N({ALPNProtocols:["h2","http/1.1"]},t),e),{resolveSocket:!0}),!Array.isArray(e.ALPNProtocols)||e.ALPNProtocols.length===0)throw new Error("The `ALPNProtocols` option must be an Array with at least one entry");e.protocol=e.protocol||"https:";let i=e.protocol==="https:";e.host=e.hostname||e.host||"localhost",e.session=e.tlsSession,e.servername=e.servername||oPe(e),e.port=e.port||(i?443:80),e._defaultAgent=i?uP.globalAgent:x4.globalAgent;let n=e.agent;if(n){if(n.addRequest)throw new Error("The `options.agent` object can contain only `http`, `https` or `http2` properties");e.agent=n[i?"https":"http"]}return i&&await APe(e)==="h2"?(n&&(e.agent=n.http2),new sPe(e,r)):x4.request(e,r)};cP.exports.protocolCache=Fw});var F4=w((yot,R4)=>{"use strict";var lPe=require("http2"),cPe=iP(),gP=lP(),uPe=nP(),gPe=D4(),fPe=(t,e,r)=>new gP(t,e,r),hPe=(t,e,r)=>{let i=new gP(t,e,r);return i.end(),i};R4.exports=te(N(te(N({},lPe),{ClientRequest:gP,IncomingMessage:uPe}),cPe),{request:fPe,get:hPe,auto:gPe})});var hP=w(fP=>{"use strict";Object.defineProperty(fP,"__esModule",{value:!0});var N4=$a();fP.default=t=>N4.default.nodeStream(t)&&N4.default.function_(t.getBoundary)});var M4=w(pP=>{"use strict";Object.defineProperty(pP,"__esModule",{value:!0});var L4=require("fs"),T4=require("util"),O4=$a(),pPe=hP(),dPe=T4.promisify(L4.stat);pP.default=async(t,e)=>{if(e&&"content-length"in e)return Number(e["content-length"]);if(!t)return 0;if(O4.default.string(t))return Buffer.byteLength(t);if(O4.default.buffer(t))return t.length;if(pPe.default(t))return T4.promisify(t.getLength.bind(t))();if(t instanceof L4.ReadStream){let{size:r}=await dPe(t.path);return r===0?void 0:r}}});var CP=w(dP=>{"use strict";Object.defineProperty(dP,"__esModule",{value:!0});function CPe(t,e,r){let i={};for(let n of r)i[n]=(...s)=>{e.emit(n,...s)},t.on(n,i[n]);return()=>{for(let n of r)t.off(n,i[n])}}dP.default=CPe});var U4=w(mP=>{"use strict";Object.defineProperty(mP,"__esModule",{value:!0});mP.default=()=>{let t=[];return{once(e,r,i){e.once(r,i),t.push({origin:e,event:r,fn:i})},unhandleAll(){for(let e of t){let{origin:r,event:i,fn:n}=e;r.removeListener(i,n)}t.length=0}}}});var H4=w(Rd=>{"use strict";Object.defineProperty(Rd,"__esModule",{value:!0});Rd.TimeoutError=void 0;var mPe=require("net"),EPe=U4(),K4=Symbol("reentry"),IPe=()=>{},EP=class extends Error{constructor(e,r){super(`Timeout awaiting '${r}' for ${e}ms`);this.event=r,this.name="TimeoutError",this.code="ETIMEDOUT"}};Rd.TimeoutError=EP;Rd.default=(t,e,r)=>{if(K4 in t)return IPe;t[K4]=!0;let i=[],{once:n,unhandleAll:s}=EPe.default(),o=(g,f,h)=>{var p;let m=setTimeout(f,g,g,h);(p=m.unref)===null||p===void 0||p.call(m);let y=()=>{clearTimeout(m)};return i.push(y),y},{host:a,hostname:l}=r,c=(g,f)=>{t.destroy(new EP(g,f))},u=()=>{for(let g of i)g();s()};if(t.once("error",g=>{if(u(),t.listenerCount("error")===0)throw g}),t.once("close",u),n(t,"response",g=>{n(g,"end",u)}),typeof e.request!="undefined"&&o(e.request,c,"request"),typeof e.socket!="undefined"){let g=()=>{c(e.socket,"socket")};t.setTimeout(e.socket,g),i.push(()=>{t.removeListener("timeout",g)})}return n(t,"socket",g=>{var f;let{socketPath:h}=t;if(g.connecting){let p=Boolean(h!=null?h:mPe.isIP((f=l!=null?l:a)!==null&&f!==void 0?f:"")!==0);if(typeof e.lookup!="undefined"&&!p&&typeof g.address().address=="undefined"){let m=o(e.lookup,c,"lookup");n(g,"lookup",m)}if(typeof e.connect!="undefined"){let m=()=>o(e.connect,c,"connect");p?n(g,"connect",m()):n(g,"lookup",y=>{y===null&&n(g,"connect",m())})}typeof e.secureConnect!="undefined"&&r.protocol==="https:"&&n(g,"connect",()=>{let m=o(e.secureConnect,c,"secureConnect");n(g,"secureConnect",m)})}if(typeof e.send!="undefined"){let p=()=>o(e.send,c,"send");g.connecting?n(g,"connect",()=>{n(t,"upload-complete",p())}):n(t,"upload-complete",p())}}),typeof e.response!="undefined"&&n(t,"upload-complete",()=>{let g=o(e.response,c,"response");n(t,"response",g)}),u}});var G4=w(IP=>{"use strict";Object.defineProperty(IP,"__esModule",{value:!0});var j4=$a();IP.default=t=>{t=t;let e={protocol:t.protocol,hostname:j4.default.string(t.hostname)&&t.hostname.startsWith("[")?t.hostname.slice(1,-1):t.hostname,host:t.host,hash:t.hash,search:t.search,pathname:t.pathname,href:t.href,path:`${t.pathname||""}${t.search||""}`};return j4.default.string(t.port)&&t.port.length>0&&(e.port=Number(t.port)),(t.username||t.password)&&(e.auth=`${t.username||""}:${t.password||""}`),e}});var Y4=w(yP=>{"use strict";Object.defineProperty(yP,"__esModule",{value:!0});var yPe=require("url"),wPe=["protocol","host","hostname","port","pathname","search"];yP.default=(t,e)=>{var r,i;if(e.path){if(e.pathname)throw new TypeError("Parameters `path` and `pathname` are mutually exclusive.");if(e.search)throw new TypeError("Parameters `path` and `search` are mutually exclusive.");if(e.searchParams)throw new TypeError("Parameters `path` and `searchParams` are mutually exclusive.")}if(e.search&&e.searchParams)throw new TypeError("Parameters `search` and `searchParams` are mutually exclusive.");if(!t){if(!e.protocol)throw new TypeError("No URL protocol specified");t=`${e.protocol}//${(i=(r=e.hostname)!==null&&r!==void 0?r:e.host)!==null&&i!==void 0?i:""}`}let n=new yPe.URL(t);if(e.path){let s=e.path.indexOf("?");s===-1?e.pathname=e.path:(e.pathname=e.path.slice(0,s),e.search=e.path.slice(s+1)),delete e.path}for(let s of wPe)e[s]&&(n[s]=e[s].toString());return n}});var J4=w(wP=>{"use strict";Object.defineProperty(wP,"__esModule",{value:!0});var q4=class{constructor(){this.weakMap=new WeakMap,this.map=new Map}set(e,r){typeof e=="object"?this.weakMap.set(e,r):this.map.set(e,r)}get(e){return typeof e=="object"?this.weakMap.get(e):this.map.get(e)}has(e){return typeof e=="object"?this.weakMap.has(e):this.map.has(e)}};wP.default=q4});var bP=w(BP=>{"use strict";Object.defineProperty(BP,"__esModule",{value:!0});var BPe=async t=>{let e=[],r=0;for await(let i of t)e.push(i),r+=Buffer.byteLength(i);return Buffer.isBuffer(e[0])?Buffer.concat(e,r):Buffer.from(e.join(""))};BP.default=BPe});var z4=w(Yc=>{"use strict";Object.defineProperty(Yc,"__esModule",{value:!0});Yc.dnsLookupIpVersionToFamily=Yc.isDnsLookupIpVersion=void 0;var W4={auto:0,ipv4:4,ipv6:6};Yc.isDnsLookupIpVersion=t=>t in W4;Yc.dnsLookupIpVersionToFamily=t=>{if(Yc.isDnsLookupIpVersion(t))return W4[t];throw new Error("Invalid DNS lookup IP version")}});var QP=w(Nw=>{"use strict";Object.defineProperty(Nw,"__esModule",{value:!0});Nw.isResponseOk=void 0;Nw.isResponseOk=t=>{let{statusCode:e}=t,r=t.request.options.followRedirect?299:399;return e>=200&&e<=r||e===304}});var V4=w(vP=>{"use strict";Object.defineProperty(vP,"__esModule",{value:!0});var _4=new Set;vP.default=t=>{_4.has(t)||(_4.add(t),process.emitWarning(`Got: ${t}`,{type:"DeprecationWarning"}))}});var X4=w(SP=>{"use strict";Object.defineProperty(SP,"__esModule",{value:!0});var Ir=$a(),bPe=(t,e)=>{if(Ir.default.null_(t.encoding))throw new TypeError("To get a Buffer, set `options.responseType` to `buffer` instead");Ir.assert.any([Ir.default.string,Ir.default.undefined],t.encoding),Ir.assert.any([Ir.default.boolean,Ir.default.undefined],t.resolveBodyOnly),Ir.assert.any([Ir.default.boolean,Ir.default.undefined],t.methodRewriting),Ir.assert.any([Ir.default.boolean,Ir.default.undefined],t.isStream),Ir.assert.any([Ir.default.string,Ir.default.undefined],t.responseType),t.responseType===void 0&&(t.responseType="text");let{retry:r}=t;if(e?t.retry=N({},e.retry):t.retry={calculateDelay:i=>i.computedValue,limit:0,methods:[],statusCodes:[],errorCodes:[],maxRetryAfter:void 0},Ir.default.object(r)?(t.retry=N(N({},t.retry),r),t.retry.methods=[...new Set(t.retry.methods.map(i=>i.toUpperCase()))],t.retry.statusCodes=[...new Set(t.retry.statusCodes)],t.retry.errorCodes=[...new Set(t.retry.errorCodes)]):Ir.default.number(r)&&(t.retry.limit=r),Ir.default.undefined(t.retry.maxRetryAfter)&&(t.retry.maxRetryAfter=Math.min(...[t.timeout.request,t.timeout.connect].filter(Ir.default.number))),Ir.default.object(t.pagination)){e&&(t.pagination=N(N({},e.pagination),t.pagination));let{pagination:i}=t;if(!Ir.default.function_(i.transform))throw new Error("`options.pagination.transform` must be implemented");if(!Ir.default.function_(i.shouldContinue))throw new Error("`options.pagination.shouldContinue` must be implemented");if(!Ir.default.function_(i.filter))throw new TypeError("`options.pagination.filter` must be implemented");if(!Ir.default.function_(i.paginate))throw new Error("`options.pagination.paginate` must be implemented")}return t.responseType==="json"&&t.headers.accept===void 0&&(t.headers.accept="application/json"),t};SP.default=bPe});var Z4=w(Fd=>{"use strict";Object.defineProperty(Fd,"__esModule",{value:!0});Fd.retryAfterStatusCodes=void 0;Fd.retryAfterStatusCodes=new Set([413,429,503]);var QPe=({attemptCount:t,retryOptions:e,error:r,retryAfter:i})=>{if(t>e.limit)return 0;let n=e.methods.includes(r.options.method),s=e.errorCodes.includes(r.code),o=r.response&&e.statusCodes.includes(r.response.statusCode);if(!n||!s&&!o)return 0;if(r.response){if(i)return e.maxRetryAfter===void 0||i>e.maxRetryAfter?0:i;if(r.response.statusCode===413)return 0}let a=Math.random()*100;return 2**(t-1)*1e3+a};Fd.default=QPe});var Ld=w(qt=>{"use strict";Object.defineProperty(qt,"__esModule",{value:!0});qt.UnsupportedProtocolError=qt.ReadError=qt.TimeoutError=qt.UploadError=qt.CacheError=qt.HTTPError=qt.MaxRedirectsError=qt.RequestError=qt.setNonEnumerableProperties=qt.knownHookEvents=qt.withoutBody=qt.kIsNormalizedAlready=void 0;var $4=require("util"),e_=require("stream"),vPe=require("fs"),al=require("url"),t_=require("http"),kP=require("http"),SPe=require("https"),kPe=cz(),xPe=Cz(),r_=Xz(),PPe=t4(),DPe=F4(),RPe=Pw(),me=$a(),FPe=M4(),i_=hP(),NPe=CP(),n_=H4(),LPe=G4(),s_=Y4(),TPe=J4(),OPe=bP(),o_=z4(),MPe=QP(),Al=V4(),UPe=X4(),KPe=Z4(),xP,Ri=Symbol("request"),Lw=Symbol("response"),Xg=Symbol("responseSize"),Zg=Symbol("downloadedSize"),$g=Symbol("bodySize"),ef=Symbol("uploadedSize"),Tw=Symbol("serverResponsesPiped"),a_=Symbol("unproxyEvents"),A_=Symbol("isFromCache"),PP=Symbol("cancelTimeouts"),l_=Symbol("startedReading"),tf=Symbol("stopReading"),Ow=Symbol("triggerRead"),ll=Symbol("body"),Nd=Symbol("jobs"),c_=Symbol("originalResponse"),u_=Symbol("retryTimeout");qt.kIsNormalizedAlready=Symbol("isNormalizedAlready");var HPe=me.default.string(process.versions.brotli);qt.withoutBody=new Set(["GET","HEAD"]);qt.knownHookEvents=["init","beforeRequest","beforeRedirect","beforeError","beforeRetry","afterResponse"];function jPe(t){for(let e in t){let r=t[e];if(!me.default.string(r)&&!me.default.number(r)&&!me.default.boolean(r)&&!me.default.null_(r)&&!me.default.undefined(r))throw new TypeError(`The \`searchParams\` value '${String(r)}' must be a string, number, boolean or null`)}}function GPe(t){return me.default.object(t)&&!("statusCode"in t)}var DP=new TPe.default,YPe=async t=>new Promise((e,r)=>{let i=n=>{r(n)};t.pending||e(),t.once("error",i),t.once("ready",()=>{t.off("error",i),e()})}),qPe=new Set([300,301,302,303,304,307,308]),JPe=["context","body","json","form"];qt.setNonEnumerableProperties=(t,e)=>{let r={};for(let i of t)if(!!i)for(let n of JPe)n in i&&(r[n]={writable:!0,configurable:!0,enumerable:!1,value:i[n]});Object.defineProperties(e,r)};var fi=class extends Error{constructor(e,r,i){var n;super(e);if(Error.captureStackTrace(this,this.constructor),this.name="RequestError",this.code=r.code,i instanceof RP?(Object.defineProperty(this,"request",{enumerable:!1,value:i}),Object.defineProperty(this,"response",{enumerable:!1,value:i[Lw]}),Object.defineProperty(this,"options",{enumerable:!1,value:i.options})):Object.defineProperty(this,"options",{enumerable:!1,value:i}),this.timings=(n=this.request)===null||n===void 0?void 0:n.timings,me.default.string(r.stack)&&me.default.string(this.stack)){let s=this.stack.indexOf(this.message)+this.message.length,o=this.stack.slice(s).split(`
+`).reverse(),a=r.stack.slice(r.stack.indexOf(r.message)+r.message.length).split(`
+`).reverse();for(;a.length!==0&&a[0]===o[0];)o.shift();this.stack=`${this.stack.slice(0,s)}${o.reverse().join(`
+`)}${a.reverse().join(`
+`)}`}}};qt.RequestError=fi;var FP=class extends fi{constructor(e){super(`Redirected ${e.options.maxRedirects} times. Aborting.`,{},e);this.name="MaxRedirectsError"}};qt.MaxRedirectsError=FP;var NP=class extends fi{constructor(e){super(`Response code ${e.statusCode} (${e.statusMessage})`,{},e.request);this.name="HTTPError"}};qt.HTTPError=NP;var LP=class extends fi{constructor(e,r){super(e.message,e,r);this.name="CacheError"}};qt.CacheError=LP;var TP=class extends fi{constructor(e,r){super(e.message,e,r);this.name="UploadError"}};qt.UploadError=TP;var OP=class extends fi{constructor(e,r,i){super(e.message,e,i);this.name="TimeoutError",this.event=e.event,this.timings=r}};qt.TimeoutError=OP;var Mw=class extends fi{constructor(e,r){super(e.message,e,r);this.name="ReadError"}};qt.ReadError=Mw;var MP=class extends fi{constructor(e){super(`Unsupported protocol "${e.url.protocol}"`,{},e);this.name="UnsupportedProtocolError"}};qt.UnsupportedProtocolError=MP;var WPe=["socket","connect","continue","information","upgrade","timeout"],RP=class extends e_.Duplex{constructor(e,r={},i){super({autoDestroy:!1,highWaterMark:0});this[Zg]=0,this[ef]=0,this.requestInitialized=!1,this[Tw]=new Set,this.redirects=[],this[tf]=!1,this[Ow]=!1,this[Nd]=[],this.retryCount=0,this._progressCallbacks=[];let n=()=>this._unlockWrite(),s=()=>this._lockWrite();this.on("pipe",c=>{c.prependListener("data",n),c.on("data",s),c.prependListener("end",n),c.on("end",s)}),this.on("unpipe",c=>{c.off("data",n),c.off("data",s),c.off("end",n),c.off("end",s)}),this.on("pipe",c=>{c instanceof kP.IncomingMessage&&(this.options.headers=N(N({},c.headers),this.options.headers))});let{json:o,body:a,form:l}=r;if((o||a||l)&&this._lockWrite(),qt.kIsNormalizedAlready in r)this.options=r;else try{this.options=this.constructor.normalizeArguments(e,r,i)}catch(c){me.default.nodeStream(r.body)&&r.body.destroy(),this.destroy(c);return}(async()=>{var c;try{this.options.body instanceof vPe.ReadStream&&await YPe(this.options.body);let{url:u}=this.options;if(!u)throw new TypeError("Missing `url` property");if(this.requestUrl=u.toString(),decodeURI(this.requestUrl),await this._finalizeBody(),await this._makeRequest(),this.destroyed){(c=this[Ri])===null||c===void 0||c.destroy();return}for(let g of this[Nd])g();this[Nd].length=0,this.requestInitialized=!0}catch(u){if(u instanceof fi){this._beforeError(u);return}this.destroyed||this.destroy(u)}})()}static normalizeArguments(e,r,i){var n,s,o,a,l;let c=r;if(me.default.object(e)&&!me.default.urlInstance(e))r=N(N(N({},i),e),r);else{if(e&&r&&r.url!==void 0)throw new TypeError("The `url` option is mutually exclusive with the `input` argument");r=N(N({},i),r),e!==void 0&&(r.url=e),me.default.urlInstance(r.url)&&(r.url=new al.URL(r.url.toString()))}if(r.cache===!1&&(r.cache=void 0),r.dnsCache===!1&&(r.dnsCache=void 0),me.assert.any([me.default.string,me.default.undefined],r.method),me.assert.any([me.default.object,me.default.undefined],r.headers),me.assert.any([me.default.string,me.default.urlInstance,me.default.undefined],r.prefixUrl),me.assert.any([me.default.object,me.default.undefined],r.cookieJar),me.assert.any([me.default.object,me.default.string,me.default.undefined],r.searchParams),me.assert.any([me.default.object,me.default.string,me.default.undefined],r.cache),me.assert.any([me.default.object,me.default.number,me.default.undefined],r.timeout),me.assert.any([me.default.object,me.default.undefined],r.context),me.assert.any([me.default.object,me.default.undefined],r.hooks),me.assert.any([me.default.boolean,me.default.undefined],r.decompress),me.assert.any([me.default.boolean,me.default.undefined],r.ignoreInvalidCookies),me.assert.any([me.default.boolean,me.default.undefined],r.followRedirect),me.assert.any([me.default.number,me.default.undefined],r.maxRedirects),me.assert.any([me.default.boolean,me.default.undefined],r.throwHttpErrors),me.assert.any([me.default.boolean,me.default.undefined],r.http2),me.assert.any([me.default.boolean,me.default.undefined],r.allowGetBody),me.assert.any([me.default.string,me.default.undefined],r.localAddress),me.assert.any([o_.isDnsLookupIpVersion,me.default.undefined],r.dnsLookupIpVersion),me.assert.any([me.default.object,me.default.undefined],r.https),me.assert.any([me.default.boolean,me.default.undefined],r.rejectUnauthorized),r.https&&(me.assert.any([me.default.boolean,me.default.undefined],r.https.rejectUnauthorized),me.assert.any([me.default.function_,me.default.undefined],r.https.checkServerIdentity),me.assert.any([me.default.string,me.default.object,me.default.array,me.default.undefined],r.https.certificateAuthority),me.assert.any([me.default.string,me.default.object,me.default.array,me.default.undefined],r.https.key),me.assert.any([me.default.string,me.default.object,me.default.array,me.default.undefined],r.https.certificate),me.assert.any([me.default.string,me.default.undefined],r.https.passphrase),me.assert.any([me.default.string,me.default.buffer,me.default.array,me.default.undefined],r.https.pfx)),me.assert.any([me.default.object,me.default.undefined],r.cacheOptions),me.default.string(r.method)?r.method=r.method.toUpperCase():r.method="GET",r.headers===(i==null?void 0:i.headers)?r.headers=N({},r.headers):r.headers=RPe(N(N({},i==null?void 0:i.headers),r.headers)),"slashes"in r)throw new TypeError("The legacy `url.Url` has been deprecated. Use `URL` instead.");if("auth"in r)throw new TypeError("Parameter `auth` is deprecated. Use `username` / `password` instead.");if("searchParams"in r&&r.searchParams&&r.searchParams!==(i==null?void 0:i.searchParams)){let h;if(me.default.string(r.searchParams)||r.searchParams instanceof al.URLSearchParams)h=new al.URLSearchParams(r.searchParams);else{jPe(r.searchParams),h=new al.URLSearchParams;for(let p in r.searchParams){let m=r.searchParams[p];m===null?h.append(p,""):m!==void 0&&h.append(p,m)}}(n=i==null?void 0:i.searchParams)===null||n===void 0||n.forEach((p,m)=>{h.has(m)||h.append(m,p)}),r.searchParams=h}if(r.username=(s=r.username)!==null&&s!==void 0?s:"",r.password=(o=r.password)!==null&&o!==void 0?o:"",me.default.undefined(r.prefixUrl)?r.prefixUrl=(a=i==null?void 0:i.prefixUrl)!==null&&a!==void 0?a:"":(r.prefixUrl=r.prefixUrl.toString(),r.prefixUrl!==""&&!r.prefixUrl.endsWith("/")&&(r.prefixUrl+="/")),me.default.string(r.url)){if(r.url.startsWith("/"))throw new Error("`input` must not start with a slash when using `prefixUrl`");r.url=s_.default(r.prefixUrl+r.url,r)}else(me.default.undefined(r.url)&&r.prefixUrl!==""||r.protocol)&&(r.url=s_.default(r.prefixUrl,r));if(r.url){"port"in r&&delete r.port;let{prefixUrl:h}=r;Object.defineProperty(r,"prefixUrl",{set:m=>{let y=r.url;if(!y.href.startsWith(m))throw new Error(`Cannot change \`prefixUrl\` from ${h} to ${m}: ${y.href}`);r.url=new al.URL(m+y.href.slice(h.length)),h=m},get:()=>h});let{protocol:p}=r.url;if(p==="unix:"&&(p="http:",r.url=new al.URL(`http://unix${r.url.pathname}${r.url.search}`)),r.searchParams&&(r.url.search=r.searchParams.toString()),p!=="http:"&&p!=="https:")throw new MP(r);r.username===""?r.username=r.url.username:r.url.username=r.username,r.password===""?r.password=r.url.password:r.url.password=r.password}let{cookieJar:u}=r;if(u){let{setCookie:h,getCookieString:p}=u;me.assert.function_(h),me.assert.function_(p),h.length===4&&p.length===0&&(h=$4.promisify(h.bind(r.cookieJar)),p=$4.promisify(p.bind(r.cookieJar)),r.cookieJar={setCookie:h,getCookieString:p})}let{cache:g}=r;if(g&&(DP.has(g)||DP.set(g,new r_((h,p)=>{let m=h[Ri](h,p);return me.default.promise(m)&&(m.once=(y,Q)=>{if(y==="error")m.catch(Q);else if(y==="abort")(async()=>{try{(await m).once("abort",Q)}catch(S){}})();else throw new Error(`Unknown HTTP2 promise event: ${y}`);return m}),m},g))),r.cacheOptions=N({},r.cacheOptions),r.dnsCache===!0)xP||(xP=new xPe.default),r.dnsCache=xP;else if(!me.default.undefined(r.dnsCache)&&!r.dnsCache.lookup)throw new TypeError(`Parameter \`dnsCache\` must be a CacheableLookup instance or a boolean, got ${me.default(r.dnsCache)}`);me.default.number(r.timeout)?r.timeout={request:r.timeout}:i&&r.timeout!==i.timeout?r.timeout=N(N({},i.timeout),r.timeout):r.timeout=N({},r.timeout),r.context||(r.context={});let f=r.hooks===(i==null?void 0:i.hooks);r.hooks=N({},r.hooks);for(let h of qt.knownHookEvents)if(h in r.hooks)if(me.default.array(r.hooks[h]))r.hooks[h]=[...r.hooks[h]];else throw new TypeError(`Parameter \`${h}\` must be an Array, got ${me.default(r.hooks[h])}`);else r.hooks[h]=[];if(i&&!f)for(let h of qt.knownHookEvents)i.hooks[h].length>0&&(r.hooks[h]=[...i.hooks[h],...r.hooks[h]]);if("family"in r&&Al.default('"options.family" was never documented, please use "options.dnsLookupIpVersion"'),(i==null?void 0:i.https)&&(r.https=N(N({},i.https),r.https)),"rejectUnauthorized"in r&&Al.default('"options.rejectUnauthorized" is now deprecated, please use "options.https.rejectUnauthorized"'),"checkServerIdentity"in r&&Al.default('"options.checkServerIdentity" was never documented, please use "options.https.checkServerIdentity"'),"ca"in r&&Al.default('"options.ca" was never documented, please use "options.https.certificateAuthority"'),"key"in r&&Al.default('"options.key" was never documented, please use "options.https.key"'),"cert"in r&&Al.default('"options.cert" was never documented, please use "options.https.certificate"'),"passphrase"in r&&Al.default('"options.passphrase" was never documented, please use "options.https.passphrase"'),"pfx"in r&&Al.default('"options.pfx" was never documented, please use "options.https.pfx"'),"followRedirects"in r)throw new TypeError("The `followRedirects` option does not exist. Use `followRedirect` instead.");if(r.agent){for(let h in r.agent)if(h!=="http"&&h!=="https"&&h!=="http2")throw new TypeError(`Expected the \`options.agent\` properties to be \`http\`, \`https\` or \`http2\`, got \`${h}\``)}return r.maxRedirects=(l=r.maxRedirects)!==null&&l!==void 0?l:0,qt.setNonEnumerableProperties([i,c],r),UPe.default(r,i)}_lockWrite(){let e=()=>{throw new TypeError("The payload has been already provided")};this.write=e,this.end=e}_unlockWrite(){this.write=super.write,this.end=super.end}async _finalizeBody(){let{options:e}=this,{headers:r}=e,i=!me.default.undefined(e.form),n=!me.default.undefined(e.json),s=!me.default.undefined(e.body),o=i||n||s,a=qt.withoutBody.has(e.method)&&!(e.method==="GET"&&e.allowGetBody);if(this._cannotHaveBody=a,o){if(a)throw new TypeError(`The \`${e.method}\` method cannot be used with a body`);if([s,i,n].filter(l=>l).length>1)throw new TypeError("The `body`, `json` and `form` options are mutually exclusive");if(s&&!(e.body instanceof e_.Readable)&&!me.default.string(e.body)&&!me.default.buffer(e.body)&&!i_.default(e.body))throw new TypeError("The `body` option must be a stream.Readable, string or Buffer");if(i&&!me.default.object(e.form))throw new TypeError("The `form` option must be an Object");{let l=!me.default.string(r["content-type"]);s?(i_.default(e.body)&&l&&(r["content-type"]=`multipart/form-data; boundary=${e.body.getBoundary()}`),this[ll]=e.body):i?(l&&(r["content-type"]="application/x-www-form-urlencoded"),this[ll]=new al.URLSearchParams(e.form).toString()):(l&&(r["content-type"]="application/json"),this[ll]=e.stringifyJson(e.json));let c=await FPe.default(this[ll],e.headers);me.default.undefined(r["content-length"])&&me.default.undefined(r["transfer-encoding"])&&!a&&!me.default.undefined(c)&&(r["content-length"]=String(c))}}else a?this._lockWrite():this._unlockWrite();this[$g]=Number(r["content-length"])||void 0}async _onResponseBase(e){let{options:r}=this,{url:i}=r;this[c_]=e,r.decompress&&(e=PPe(e));let n=e.statusCode,s=e;s.statusMessage=s.statusMessage?s.statusMessage:t_.STATUS_CODES[n],s.url=r.url.toString(),s.requestUrl=this.requestUrl,s.redirectUrls=this.redirects,s.request=this,s.isFromCache=e.fromCache||!1,s.ip=this.ip,s.retryCount=this.retryCount,this[A_]=s.isFromCache,this[Xg]=Number(e.headers["content-length"])||void 0,this[Lw]=e,e.once("end",()=>{this[Xg]=this[Zg],this.emit("downloadProgress",this.downloadProgress)}),e.once("error",a=>{e.destroy(),this._beforeError(new Mw(a,this))}),e.once("aborted",()=>{this._beforeError(new Mw({name:"Error",message:"The server aborted pending request",code:"ECONNRESET"},this))}),this.emit("downloadProgress",this.downloadProgress);let o=e.headers["set-cookie"];if(me.default.object(r.cookieJar)&&o){let a=o.map(async l=>r.cookieJar.setCookie(l,i.toString()));r.ignoreInvalidCookies&&(a=a.map(async l=>l.catch(()=>{})));try{await Promise.all(a)}catch(l){this._beforeError(l);return}}if(r.followRedirect&&e.headers.location&&qPe.has(n)){if(e.resume(),this[Ri]&&(this[PP](),delete this[Ri],this[a_]()),(n===303&&r.method!=="GET"&&r.method!=="HEAD"||!r.methodRewriting)&&(r.method="GET","body"in r&&delete r.body,"json"in r&&delete r.json,"form"in r&&delete r.form,this[ll]=void 0,delete r.headers["content-length"]),this.redirects.length>=r.maxRedirects){this._beforeError(new FP(this));return}try{let l=Buffer.from(e.headers.location,"binary").toString(),c=new al.URL(l,i),u=c.toString();decodeURI(u),c.hostname!==i.hostname||c.port!==i.port?("host"in r.headers&&delete r.headers.host,"cookie"in r.headers&&delete r.headers.cookie,"authorization"in r.headers&&delete r.headers.authorization,(r.username||r.password)&&(r.username="",r.password="")):(c.username=r.username,c.password=r.password),this.redirects.push(u),r.url=c;for(let g of r.hooks.beforeRedirect)await g(r,s);this.emit("redirect",s,r),await this._makeRequest()}catch(l){this._beforeError(l);return}return}if(r.isStream&&r.throwHttpErrors&&!MPe.isResponseOk(s)){this._beforeError(new NP(s));return}e.on("readable",()=>{this[Ow]&&this._read()}),this.on("resume",()=>{e.resume()}),this.on("pause",()=>{e.pause()}),e.once("end",()=>{this.push(null)}),this.emit("response",e);for(let a of this[Tw])if(!a.headersSent){for(let l in e.headers){let c=r.decompress?l!=="content-encoding":!0,u=e.headers[l];c&&a.setHeader(l,u)}a.statusCode=n}}async _onResponse(e){try{await this._onResponseBase(e)}catch(r){this._beforeError(r)}}_onRequest(e){let{options:r}=this,{timeout:i,url:n}=r;kPe.default(e),this[PP]=n_.default(e,i,n);let s=r.cache?"cacheableResponse":"response";e.once(s,l=>{this._onResponse(l)}),e.once("error",l=>{var c;e.destroy(),(c=e.res)===null||c===void 0||c.removeAllListeners("end"),l=l instanceof n_.TimeoutError?new OP(l,this.timings,this):new fi(l.message,l,this),this._beforeError(l)}),this[a_]=NPe.default(e,this,WPe),this[Ri]=e,this.emit("uploadProgress",this.uploadProgress);let o=this[ll],a=this.redirects.length===0?this:e;me.default.nodeStream(o)?(o.pipe(a),o.once("error",l=>{this._beforeError(new TP(l,this))})):(this._unlockWrite(),me.default.undefined(o)?(this._cannotHaveBody||this._noPipe)&&(a.end(),this._lockWrite()):(this._writeRequest(o,void 0,()=>{}),a.end(),this._lockWrite())),this.emit("request",e)}async _createCacheableRequest(e,r){return new Promise((i,n)=>{Object.assign(r,LPe.default(e)),delete r.url;let s,o=DP.get(r.cache)(r,async a=>{a._readableState.autoDestroy=!1,s&&(await s).emit("cacheableResponse",a),i(a)});r.url=e,o.once("error",n),o.once("request",async a=>{s=a,i(s)})})}async _makeRequest(){var e,r,i,n,s;let{options:o}=this,{headers:a}=o;for(let Q in a)if(me.default.undefined(a[Q]))delete a[Q];else if(me.default.null_(a[Q]))throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${Q}\` header`);if(o.decompress&&me.default.undefined(a["accept-encoding"])&&(a["accept-encoding"]=HPe?"gzip, deflate, br":"gzip, deflate"),o.cookieJar){let Q=await o.cookieJar.getCookieString(o.url.toString());me.default.nonEmptyString(Q)&&(o.headers.cookie=Q)}for(let Q of o.hooks.beforeRequest){let S=await Q(o);if(!me.default.undefined(S)){o.request=()=>S;break}}o.body&&this[ll]!==o.body&&(this[ll]=o.body);let{agent:l,request:c,timeout:u,url:g}=o;if(o.dnsCache&&!("lookup"in o)&&(o.lookup=o.dnsCache.lookup),g.hostname==="unix"){let Q=/(?<socketPath>.+?):(?<path>.+)/.exec(`${g.pathname}${g.search}`);if(Q==null?void 0:Q.groups){let{socketPath:S,path:x}=Q.groups;Object.assign(o,{socketPath:S,path:x,host:""})}}let f=g.protocol==="https:",h;o.http2?h=DPe.auto:h=f?SPe.request:t_.request;let p=(e=o.request)!==null&&e!==void 0?e:h,m=o.cache?this._createCacheableRequest:p;l&&!o.http2&&(o.agent=l[f?"https":"http"]),o[Ri]=p,delete o.request,delete o.timeout;let y=o;if(y.shared=(r=o.cacheOptions)===null||r===void 0?void 0:r.shared,y.cacheHeuristic=(i=o.cacheOptions)===null||i===void 0?void 0:i.cacheHeuristic,y.immutableMinTimeToLive=(n=o.cacheOptions)===null||n===void 0?void 0:n.immutableMinTimeToLive,y.ignoreCargoCult=(s=o.cacheOptions)===null||s===void 0?void 0:s.ignoreCargoCult,o.dnsLookupIpVersion!==void 0)try{y.family=o_.dnsLookupIpVersionToFamily(o.dnsLookupIpVersion)}catch(Q){throw new Error("Invalid `dnsLookupIpVersion` option value")}o.https&&("rejectUnauthorized"in o.https&&(y.rejectUnauthorized=o.https.rejectUnauthorized),o.https.checkServerIdentity&&(y.checkServerIdentity=o.https.checkServerIdentity),o.https.certificateAuthority&&(y.ca=o.https.certificateAuthority),o.https.certificate&&(y.cert=o.https.certificate),o.https.key&&(y.key=o.https.key),o.https.passphrase&&(y.passphrase=o.https.passphrase),o.https.pfx&&(y.pfx=o.https.pfx));try{let Q=await m(g,y);me.default.undefined(Q)&&(Q=h(g,y)),o.request=c,o.timeout=u,o.agent=l,o.https&&("rejectUnauthorized"in o.https&&delete y.rejectUnauthorized,o.https.checkServerIdentity&&delete y.checkServerIdentity,o.https.certificateAuthority&&delete y.ca,o.https.certificate&&delete y.cert,o.https.key&&delete y.key,o.https.passphrase&&delete y.passphrase,o.https.pfx&&delete y.pfx),GPe(Q)?this._onRequest(Q):this.writable?(this.once("finish",()=>{this._onResponse(Q)}),this._unlockWrite(),this.end(),this._lockWrite()):this._onResponse(Q)}catch(Q){throw Q instanceof r_.CacheError?new LP(Q,this):new fi(Q.message,Q,this)}}async _error(e){try{for(let r of this.options.hooks.beforeError)e=await r(e)}catch(r){e=new fi(r.message,r,this)}this.destroy(e)}_beforeError(e){if(this[tf])return;let{options:r}=this,i=this.retryCount+1;this[tf]=!0,e instanceof fi||(e=new fi(e.message,e,this));let n=e,{response:s}=n;(async()=>{if(s&&!s.body){s.setEncoding(this._readableState.encoding);try{s.rawBody=await OPe.default(s),s.body=s.rawBody.toString()}catch(o){}}if(this.listenerCount("retry")!==0){let o;try{let a;s&&"retry-after"in s.headers&&(a=Number(s.headers["retry-after"]),Number.isNaN(a)?(a=Date.parse(s.headers["retry-after"])-Date.now(),a<=0&&(a=1)):a*=1e3),o=await r.retry.calculateDelay({attemptCount:i,retryOptions:r.retry,error:n,retryAfter:a,computedValue:KPe.default({attemptCount:i,retryOptions:r.retry,error:n,retryAfter:a,computedValue:0})})}catch(a){this._error(new fi(a.message,a,this));return}if(o){let a=async()=>{try{for(let l of this.options.hooks.beforeRetry)await l(this.options,n,i)}catch(l){this._error(new fi(l.message,e,this));return}this.destroyed||(this.destroy(),this.emit("retry",i,e))};this[u_]=setTimeout(a,o);return}}this._error(n)})()}_read(){this[Ow]=!0;let e=this[Lw];if(e&&!this[tf]){e.readableLength&&(this[Ow]=!1);let r;for(;(r=e.read())!==null;){this[Zg]+=r.length,this[l_]=!0;let i=this.downloadProgress;i.percent<1&&this.emit("downloadProgress",i),this.push(r)}}}_write(e,r,i){let n=()=>{this._writeRequest(e,r,i)};this.requestInitialized?n():this[Nd].push(n)}_writeRequest(e,r,i){this[Ri].destroyed||(this._progressCallbacks.push(()=>{this[ef]+=Buffer.byteLength(e,r);let n=this.uploadProgress;n.percent<1&&this.emit("uploadProgress",n)}),this[Ri].write(e,r,n=>{!n&&this._progressCallbacks.length>0&&this._progressCallbacks.shift()(),i(n)}))}_final(e){let r=()=>{for(;this._progressCallbacks.length!==0;)this._progressCallbacks.shift()();if(!(Ri in this)){e();return}if(this[Ri].destroyed){e();return}this[Ri].end(i=>{i||(this[$g]=this[ef],this.emit("uploadProgress",this.uploadProgress),this[Ri].emit("upload-complete")),e(i)})};this.requestInitialized?r():this[Nd].push(r)}_destroy(e,r){var i;this[tf]=!0,clearTimeout(this[u_]),Ri in this&&(this[PP](),((i=this[Lw])===null||i===void 0?void 0:i.complete)||this[Ri].destroy()),e!==null&&!me.default.undefined(e)&&!(e instanceof fi)&&(e=new fi(e.message,e,this)),r(e)}get _isAboutToError(){return this[tf]}get ip(){var e;return(e=this.socket)===null||e===void 0?void 0:e.remoteAddress}get aborted(){var e,r,i;return((r=(e=this[Ri])===null||e===void 0?void 0:e.destroyed)!==null&&r!==void 0?r:this.destroyed)&&!((i=this[c_])===null||i===void 0?void 0:i.complete)}get socket(){var e,r;return(r=(e=this[Ri])===null||e===void 0?void 0:e.socket)!==null&&r!==void 0?r:void 0}get downloadProgress(){let e;return this[Xg]?e=this[Zg]/this[Xg]:this[Xg]===this[Zg]?e=1:e=0,{percent:e,transferred:this[Zg],total:this[Xg]}}get uploadProgress(){let e;return this[$g]?e=this[ef]/this[$g]:this[$g]===this[ef]?e=1:e=0,{percent:e,transferred:this[ef],total:this[$g]}}get timings(){var e;return(e=this[Ri])===null||e===void 0?void 0:e.timings}get isFromCache(){return this[A_]}pipe(e,r){if(this[l_])throw new Error("Failed to pipe. The response has been emitted already.");return e instanceof kP.ServerResponse&&this[Tw].add(e),super.pipe(e,r)}unpipe(e){return e instanceof kP.ServerResponse&&this[Tw].delete(e),super.unpipe(e),this}};qt.default=RP});var Td=w(ho=>{"use strict";var zPe=ho&&ho.__createBinding||(Object.create?function(t,e,r,i){i===void 0&&(i=r),Object.defineProperty(t,i,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,i){i===void 0&&(i=r),t[i]=e[r]}),_Pe=ho&&ho.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&zPe(e,t,r)};Object.defineProperty(ho,"__esModule",{value:!0});ho.CancelError=ho.ParseError=void 0;var g_=Ld(),f_=class extends g_.RequestError{constructor(e,r){let{options:i}=r.request;super(`${e.message} in "${i.url.toString()}"`,e,r.request);this.name="ParseError"}};ho.ParseError=f_;var h_=class extends g_.RequestError{constructor(e){super("Promise was canceled",{},e);this.name="CancelError"}get isCanceled(){return!0}};ho.CancelError=h_;_Pe(Ld(),ho)});var d_=w(UP=>{"use strict";Object.defineProperty(UP,"__esModule",{value:!0});var p_=Td(),VPe=(t,e,r,i)=>{let{rawBody:n}=t;try{if(e==="text")return n.toString(i);if(e==="json")return n.length===0?"":r(n.toString());if(e==="buffer")return n;throw new p_.ParseError({message:`Unknown body type '${e}'`,name:"Error"},t)}catch(s){throw new p_.ParseError(s,t)}};UP.default=VPe});var KP=w(cl=>{"use strict";var XPe=cl&&cl.__createBinding||(Object.create?function(t,e,r,i){i===void 0&&(i=r),Object.defineProperty(t,i,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,i){i===void 0&&(i=r),t[i]=e[r]}),ZPe=cl&&cl.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&XPe(e,t,r)};Object.defineProperty(cl,"__esModule",{value:!0});var $Pe=require("events"),eDe=$a(),tDe=Az(),Uw=Td(),C_=d_(),m_=Ld(),rDe=CP(),iDe=bP(),E_=QP(),nDe=["request","response","redirect","uploadProgress","downloadProgress"];function I_(t){let e,r,i=new $Pe.EventEmitter,n=new tDe((o,a,l)=>{let c=u=>{let g=new m_.default(void 0,t);g.retryCount=u,g._noPipe=!0,l(()=>g.destroy()),l.shouldReject=!1,l(()=>a(new Uw.CancelError(g))),e=g,g.once("response",async p=>{var m;if(p.retryCount=u,p.request.aborted)return;let y;try{y=await iDe.default(g),p.rawBody=y}catch(M){return}if(g._isAboutToError)return;let Q=((m=p.headers["content-encoding"])!==null&&m!==void 0?m:"").toLowerCase(),S=["gzip","deflate","br"].includes(Q),{options:x}=g;if(S&&!x.decompress)p.body=y;else try{p.body=C_.default(p,x.responseType,x.parseJson,x.encoding)}catch(M){if(p.body=y.toString(),E_.isResponseOk(p)){g._beforeError(M);return}}try{for(let[M,Y]of x.hooks.afterResponse.entries())p=await Y(p,async U=>{let J=m_.default.normalizeArguments(void 0,te(N({},U),{retry:{calculateDelay:()=>0},throwHttpErrors:!1,resolveBodyOnly:!1}),x);J.hooks.afterResponse=J.hooks.afterResponse.slice(0,M);for(let ee of J.hooks.beforeRetry)await ee(J);let W=I_(J);return l(()=>{W.catch(()=>{}),W.cancel()}),W})}catch(M){g._beforeError(new Uw.RequestError(M.message,M,g));return}if(!E_.isResponseOk(p)){g._beforeError(new Uw.HTTPError(p));return}r=p,o(g.options.resolveBodyOnly?p.body:p)});let f=p=>{if(n.isCanceled)return;let{options:m}=g;if(p instanceof Uw.HTTPError&&!m.throwHttpErrors){let{response:y}=p;o(g.options.resolveBodyOnly?y.body:y);return}a(p)};g.once("error",f);let h=g.options.body;g.once("retry",(p,m)=>{var y,Q;if(h===((y=m.request)===null||y===void 0?void 0:y.options.body)&&eDe.default.nodeStream((Q=m.request)===null||Q===void 0?void 0:Q.options.body)){f(m);return}c(p)}),rDe.default(g,i,nDe)};c(0)});n.on=(o,a)=>(i.on(o,a),n);let s=o=>{let a=(async()=>{await n;let{options:l}=r.request;return C_.default(r,o,l.parseJson,l.encoding)})();return Object.defineProperties(a,Object.getOwnPropertyDescriptors(n)),a};return n.json=()=>{let{headers:o}=e.options;return!e.writableFinished&&o.accept===void 0&&(o.accept="application/json"),s("json")},n.buffer=()=>s("buffer"),n.text=()=>s("text"),n}cl.default=I_;ZPe(Td(),cl)});var y_=w(HP=>{"use strict";Object.defineProperty(HP,"__esModule",{value:!0});var sDe=Td();function oDe(t,...e){let r=(async()=>{if(t instanceof sDe.RequestError)try{for(let n of e)if(n)for(let s of n)t=await s(t)}catch(n){t=n}throw t})(),i=()=>r;return r.json=i,r.text=i,r.buffer=i,r.on=i,r}HP.default=oDe});var b_=w(jP=>{"use strict";Object.defineProperty(jP,"__esModule",{value:!0});var w_=$a();function B_(t){for(let e of Object.values(t))(w_.default.plainObject(e)||w_.default.array(e))&&B_(e);return Object.freeze(t)}jP.default=B_});var v_=w(Q_=>{"use strict";Object.defineProperty(Q_,"__esModule",{value:!0})});var GP=w(Ns=>{"use strict";var aDe=Ns&&Ns.__createBinding||(Object.create?function(t,e,r,i){i===void 0&&(i=r),Object.defineProperty(t,i,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,i){i===void 0&&(i=r),t[i]=e[r]}),ADe=Ns&&Ns.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&aDe(e,t,r)};Object.defineProperty(Ns,"__esModule",{value:!0});Ns.defaultHandler=void 0;var S_=$a(),Ls=KP(),lDe=y_(),Kw=Ld(),cDe=b_(),uDe={RequestError:Ls.RequestError,CacheError:Ls.CacheError,ReadError:Ls.ReadError,HTTPError:Ls.HTTPError,MaxRedirectsError:Ls.MaxRedirectsError,TimeoutError:Ls.TimeoutError,ParseError:Ls.ParseError,CancelError:Ls.CancelError,UnsupportedProtocolError:Ls.UnsupportedProtocolError,UploadError:Ls.UploadError},gDe=async t=>new Promise(e=>{setTimeout(e,t)}),{normalizeArguments:Hw}=Kw.default,k_=(...t)=>{let e;for(let r of t)e=Hw(void 0,r,e);return e},fDe=t=>t.isStream?new Kw.default(void 0,t):Ls.default(t),hDe=t=>"defaults"in t&&"options"in t.defaults,pDe=["get","post","put","patch","head","delete"];Ns.defaultHandler=(t,e)=>e(t);var x_=(t,e)=>{if(t)for(let r of t)r(e)},P_=t=>{t._rawHandlers=t.handlers,t.handlers=t.handlers.map(i=>(n,s)=>{let o,a=i(n,l=>(o=s(l),o));if(a!==o&&!n.isStream&&o){let l=a,{then:c,catch:u,finally:g}=l;Object.setPrototypeOf(l,Object.getPrototypeOf(o)),Object.defineProperties(l,Object.getOwnPropertyDescriptors(o)),l.then=c,l.catch=u,l.finally=g}return a});let e=(i,n={},s)=>{var o,a;let l=0,c=u=>t.handlers[l++](u,l===t.handlers.length?fDe:c);if(S_.default.plainObject(i)){let u=N(N({},i),n);Kw.setNonEnumerableProperties([i,n],u),n=u,i=void 0}try{let u;try{x_(t.options.hooks.init,n),x_((o=n.hooks)===null||o===void 0?void 0:o.init,n)}catch(f){u=f}let g=Hw(i,n,s!=null?s:t.options);if(g[Kw.kIsNormalizedAlready]=!0,u)throw new Ls.RequestError(u.message,u,g);return c(g)}catch(u){if(n.isStream)throw u;return lDe.default(u,t.options.hooks.beforeError,(a=n.hooks)===null||a===void 0?void 0:a.beforeError)}};e.extend=(...i)=>{let n=[t.options],s=[...t._rawHandlers],o;for(let a of i)hDe(a)?(n.push(a.defaults.options),s.push(...a.defaults._rawHandlers),o=a.defaults.mutableDefaults):(n.push(a),"handlers"in a&&s.push(...a.handlers),o=a.mutableDefaults);return s=s.filter(a=>a!==Ns.defaultHandler),s.length===0&&s.push(Ns.defaultHandler),P_({options:k_(...n),handlers:s,mutableDefaults:Boolean(o)})};let r=async function*(i,n){let s=Hw(i,n,t.options);s.resolveBodyOnly=!1;let o=s.pagination;if(!S_.default.object(o))throw new TypeError("`options.pagination` must be implemented");let a=[],{countLimit:l}=o,c=0;for(;c<o.requestLimit;){c!==0&&await gDe(o.backoff);let u=await e(void 0,void 0,s),g=await o.transform(u),f=[];for(let p of g)if(o.filter(p,a,f)&&(!o.shouldContinue(p,a,f)||(yield p,o.stackAllItems&&a.push(p),f.push(p),--l<=0)))return;let h=o.paginate(u,a,f);if(h===!1)return;h===u.request.options?s=u.request.options:h!==void 0&&(s=Hw(void 0,h,s)),c++}};e.paginate=r,e.paginate.all=async(i,n)=>{let s=[];for await(let o of r(i,n))s.push(o);return s},e.paginate.each=r,e.stream=(i,n)=>e(i,te(N({},n),{isStream:!0}));for(let i of pDe)e[i]=(n,s)=>e(n,te(N({},s),{method:i})),e.stream[i]=(n,s)=>e(n,te(N({},s),{method:i,isStream:!0}));return Object.assign(e,uDe),Object.defineProperty(e,"defaults",{value:t.mutableDefaults?t:cDe.default(t),writable:t.mutableDefaults,configurable:t.mutableDefaults,enumerable:!0}),e.mergeOptions=k_,e};Ns.default=P_;ADe(v_(),Ns)});var Gw=w((tA,jw)=>{"use strict";var dDe=tA&&tA.__createBinding||(Object.create?function(t,e,r,i){i===void 0&&(i=r),Object.defineProperty(t,i,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,i){i===void 0&&(i=r),t[i]=e[r]}),D_=tA&&tA.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&dDe(e,t,r)};Object.defineProperty(tA,"__esModule",{value:!0});var CDe=require("url"),R_=GP(),mDe={options:{method:"GET",retry:{limit:2,methods:["GET","PUT","HEAD","DELETE","OPTIONS","TRACE"],statusCodes:[408,413,429,500,502,503,504,521,522,524],errorCodes:["ETIMEDOUT","ECONNRESET","EADDRINUSE","ECONNREFUSED","EPIPE","ENOTFOUND","ENETUNREACH","EAI_AGAIN"],maxRetryAfter:void 0,calculateDelay:({computedValue:t})=>t},timeout:{},headers:{"user-agent":"got (https://github.com/sindresorhus/got)"},hooks:{init:[],beforeRequest:[],beforeRedirect:[],beforeRetry:[],beforeError:[],afterResponse:[]},cache:void 0,dnsCache:void 0,decompress:!0,throwHttpErrors:!0,followRedirect:!0,isStream:!1,responseType:"text",resolveBodyOnly:!1,maxRedirects:10,prefixUrl:"",methodRewriting:!0,ignoreInvalidCookies:!1,context:{},http2:!1,allowGetBody:!1,https:void 0,pagination:{transform:t=>t.request.options.responseType==="json"?t.body:JSON.parse(t.body),paginate:t=>{if(!Reflect.has(t.headers,"link"))return!1;let e=t.headers.link.split(","),r;for(let i of e){let n=i.split(";");if(n[1].includes("next")){r=n[0].trimStart().trim(),r=r.slice(1,-1);break}}return r?{url:new CDe.URL(r)}:!1},filter:()=>!0,shouldContinue:()=>!0,countLimit:Infinity,backoff:0,requestLimit:1e4,stackAllItems:!0},parseJson:t=>JSON.parse(t),stringifyJson:t=>JSON.stringify(t),cacheOptions:{}},handlers:[R_.defaultHandler],mutableDefaults:!1},YP=R_.default(mDe);tA.default=YP;jw.exports=YP;jw.exports.default=YP;jw.exports.__esModule=!0;D_(GP(),tA);D_(KP(),tA)});var T_=w(rf=>{"use strict";var Yot=require("net"),EDe=require("tls"),qP=require("http"),F_=require("https"),IDe=require("events"),qot=require("assert"),yDe=require("util");rf.httpOverHttp=wDe;rf.httpsOverHttp=BDe;rf.httpOverHttps=bDe;rf.httpsOverHttps=QDe;function wDe(t){var e=new rA(t);return e.request=qP.request,e}function BDe(t){var e=new rA(t);return e.request=qP.request,e.createSocket=N_,e.defaultPort=443,e}function bDe(t){var e=new rA(t);return e.request=F_.request,e}function QDe(t){var e=new rA(t);return e.request=F_.request,e.createSocket=N_,e.defaultPort=443,e}function rA(t){var e=this;e.options=t||{},e.proxyOptions=e.options.proxy||{},e.maxSockets=e.options.maxSockets||qP.Agent.defaultMaxSockets,e.requests=[],e.sockets=[],e.on("free",function(i,n,s,o){for(var a=L_(n,s,o),l=0,c=e.requests.length;l<c;++l){var u=e.requests[l];if(u.host===a.host&&u.port===a.port){e.requests.splice(l,1),u.request.onSocket(i);return}}i.destroy(),e.removeSocket(i)})}yDe.inherits(rA,IDe.EventEmitter);rA.prototype.addRequest=function(e,r,i,n){var s=this,o=JP({request:e},s.options,L_(r,i,n));if(s.sockets.length>=this.maxSockets){s.requests.push(o);return}s.createSocket(o,function(a){a.on("free",l),a.on("close",c),a.on("agentRemove",c),e.onSocket(a);function l(){s.emit("free",a,o)}function c(u){s.removeSocket(a),a.removeListener("free",l),a.removeListener("close",c),a.removeListener("agentRemove",c)}})};rA.prototype.createSocket=function(e,r){var i=this,n={};i.sockets.push(n);var s=JP({},i.proxyOptions,{method:"CONNECT",path:e.host+":"+e.port,agent:!1,headers:{host:e.host+":"+e.port}});e.localAddress&&(s.localAddress=e.localAddress),s.proxyAuth&&(s.headers=s.headers||{},s.headers["Proxy-Authorization"]="Basic "+new Buffer(s.proxyAuth).toString("base64")),ul("making CONNECT request");var o=i.request(s);o.useChunkedEncodingByDefault=!1,o.once("response",a),o.once("upgrade",l),o.once("connect",c),o.once("error",u),o.end();function a(g){g.upgrade=!0}function l(g,f,h){process.nextTick(function(){c(g,f,h)})}function c(g,f,h){if(o.removeAllListeners(),f.removeAllListeners(),g.statusCode!==200){ul("tunneling socket could not be established, statusCode=%d",g.statusCode),f.destroy();var p=new Error("tunneling socket could not be established, statusCode="+g.statusCode);p.code="ECONNRESET",e.request.emit("error",p),i.removeSocket(n);return}if(h.length>0){ul("got illegal response body from proxy"),f.destroy();var p=new Error("got illegal response body from proxy");p.code="ECONNRESET",e.request.emit("error",p),i.removeSocket(n);return}return ul("tunneling connection has established"),i.sockets[i.sockets.indexOf(n)]=f,r(f)}function u(g){o.removeAllListeners(),ul(`tunneling socket could not be established, cause=%s
+`,g.message,g.stack);var f=new Error("tunneling socket could not be established, cause="+g.message);f.code="ECONNRESET",e.request.emit("error",f),i.removeSocket(n)}};rA.prototype.removeSocket=function(e){var r=this.sockets.indexOf(e);if(r!==-1){this.sockets.splice(r,1);var i=this.requests.shift();i&&this.createSocket(i,function(n){i.request.onSocket(n)})}};function N_(t,e){var r=this;rA.prototype.createSocket.call(r,t,function(i){var n=t.request.getHeader("host"),s=JP({},r.options,{socket:i,servername:n?n.replace(/:.*$/,""):t.host}),o=EDe.connect(0,s);r.sockets[r.sockets.indexOf(i)]=o,e(o)})}function L_(t,e,r){return typeof t=="string"?{host:t,port:e,localAddress:r}:t}function JP(t){for(var e=1,r=arguments.length;e<r;++e){var i=arguments[e];if(typeof i=="object")for(var n=Object.keys(i),s=0,o=n.length;s<o;++s){var a=n[s];i[a]!==void 0&&(t[a]=i[a])}}return t}var ul;process.env.NODE_DEBUG&&/\btunnel\b/.test(process.env.NODE_DEBUG)?ul=function(){var t=Array.prototype.slice.call(arguments);typeof t[0]=="string"?t[0]="TUNNEL: "+t[0]:t.unshift("TUNNEL:"),console.error.apply(console,t)}:ul=function(){};rf.debug=ul});var M_=w((Wot,O_)=>{O_.exports=T_()});var z_=w((Jw,XP)=>{var W_=Object.assign({},require("fs")),ZP=function(){var t=typeof document!="undefined"&&document.currentScript?document.currentScript.src:void 0;return typeof __filename!="undefined"&&(t=t||__filename),function(e){e=e||{};var r=typeof e!="undefined"?e:{},i,n;r.ready=new Promise(function(d,E){i=d,n=E});var s={},o;for(o in r)r.hasOwnProperty(o)&&(s[o]=r[o]);var a=[],l="./this.program",c=function(d,E){throw E},u=!1,g=!0,f="";function h(d){return r.locateFile?r.locateFile(d,f):f+d}var p,m,y,Q;g&&(u?f=require("path").dirname(f)+"/":f=__dirname+"/",p=function(E,I){var D=ba(E);return D?I?D:D.toString():(y||(y=W_),Q||(Q=require("path")),E=Q.normalize(E),y.readFileSync(E,I?null:"utf8"))},m=function(E){var I=p(E,!0);return I.buffer||(I=new Uint8Array(I)),Ae(I.buffer),I},process.argv.length>1&&(l=process.argv[1].replace(/\\/g,"/")),a=process.argv.slice(2),c=function(d){process.exit(d)},r.inspect=function(){return"[Emscripten Module object]"});var S=r.print||console.log.bind(console),x=r.printErr||console.warn.bind(console);for(o in s)s.hasOwnProperty(o)&&(r[o]=s[o]);s=null,r.arguments&&(a=r.arguments),r.thisProgram&&(l=r.thisProgram),r.quit&&(c=r.quit);var M=16;function Y(d,E){return E||(E=M),Math.ceil(d/E)*E}var U=0,J=function(d){U=d},W;r.wasmBinary&&(W=r.wasmBinary);var ee=r.noExitRuntime||!0;typeof WebAssembly!="object"&&Sr("no native wasm support detected");function Z(d,E,I){switch(E=E||"i8",E.charAt(E.length-1)==="*"&&(E="i32"),E){case"i1":return pe[d>>0];case"i8":return pe[d>>0];case"i16":return be[d>>1];case"i32":return fe[d>>2];case"i64":return fe[d>>2];case"float":return Ht[d>>2];case"double":return Mt[d>>3];default:Sr("invalid type for getValue: "+E)}return null}var A,ne=!1,le;function Ae(d,E){d||Sr("Assertion failed: "+E)}function T(d){var E=r["_"+d];return Ae(E,"Cannot call unknown function "+d+", make sure it is exported"),E}function L(d,E,I,D,O){var V={string:function(nt){var It=0;if(nt!=null&&nt!==0){var ke=(nt.length<<2)+1;It=B(ke),Qe(nt,It,ke)}return It},array:function(nt){var It=B(nt.length);return Ue(nt,It),It}};function ie(nt){return E==="string"?re(nt):E==="boolean"?Boolean(nt):nt}var Be=T(d),Ce=[],_e=0;if(D)for(var ot=0;ot<D.length;ot++){var wt=V[I[ot]];wt?(_e===0&&(_e=FE()),Ce[ot]=wt(D[ot])):Ce[ot]=D[ot]}var ut=Be.apply(null,Ce);return ut=ie(ut),_e!==0&&NE(_e),ut}function Ee(d,E,I,D){I=I||[];var O=I.every(function(ie){return ie==="number"}),V=E!=="string";return V&&O&&!D?T(d):function(){return L(d,E,I,arguments,D)}}var we=typeof TextDecoder!="undefined"?new TextDecoder("utf8"):void 0;function qe(d,E,I){for(var D=E+I,O=E;d[O]&&!(O>=D);)++O;if(O-E>16&&d.subarray&&we)return we.decode(d.subarray(E,O));for(var V="";E<O;){var ie=d[E++];if(!(ie&128)){V+=String.fromCharCode(ie);continue}var Be=d[E++]&63;if((ie&224)==192){V+=String.fromCharCode((ie&31)<<6|Be);continue}var Ce=d[E++]&63;if((ie&240)==224?ie=(ie&15)<<12|Be<<6|Ce:ie=(ie&7)<<18|Be<<12|Ce<<6|d[E++]&63,ie<65536)V+=String.fromCharCode(ie);else{var _e=ie-65536;V+=String.fromCharCode(55296|_e>>10,56320|_e&1023)}}return V}function re(d,E){return d?qe(X,d,E):""}function se(d,E,I,D){if(!(D>0))return 0;for(var O=I,V=I+D-1,ie=0;ie<d.length;++ie){var Be=d.charCodeAt(ie);if(Be>=55296&&Be<=57343){var Ce=d.charCodeAt(++ie);Be=65536+((Be&1023)<<10)|Ce&1023}if(Be<=127){if(I>=V)break;E[I++]=Be}else if(Be<=2047){if(I+1>=V)break;E[I++]=192|Be>>6,E[I++]=128|Be&63}else if(Be<=65535){if(I+2>=V)break;E[I++]=224|Be>>12,E[I++]=128|Be>>6&63,E[I++]=128|Be&63}else{if(I+3>=V)break;E[I++]=240|Be>>18,E[I++]=128|Be>>12&63,E[I++]=128|Be>>6&63,E[I++]=128|Be&63}}return E[I]=0,I-O}function Qe(d,E,I){return se(d,X,E,I)}function he(d){for(var E=0,I=0;I<d.length;++I){var D=d.charCodeAt(I);D>=55296&&D<=57343&&(D=65536+((D&1023)<<10)|d.charCodeAt(++I)&1023),D<=127?++E:D<=2047?E+=2:D<=65535?E+=3:E+=4}return E}function Fe(d){var E=he(d)+1,I=Et(E);return I&&se(d,pe,I,E),I}function Ue(d,E){pe.set(d,E)}function xe(d,E){return d%E>0&&(d+=E-d%E),d}var ve,pe,X,be,ce,fe,gt,Ht,Mt;function mi(d){ve=d,r.HEAP8=pe=new Int8Array(d),r.HEAP16=be=new Int16Array(d),r.HEAP32=fe=new Int32Array(d),r.HEAPU8=X=new Uint8Array(d),r.HEAPU16=ce=new Uint16Array(d),r.HEAPU32=gt=new Uint32Array(d),r.HEAPF32=Ht=new Float32Array(d),r.HEAPF64=Mt=new Float64Array(d)}var jt=r.INITIAL_MEMORY||16777216,Qr,Ti=[],_s=[],Un=[],Kn=!1;function vr(){if(r.preRun)for(typeof r.preRun=="function"&&(r.preRun=[r.preRun]);r.preRun.length;)Ia(r.preRun.shift());ko(Ti)}function Hn(){Kn=!0,!r.noFSInit&&!v.init.initialized&&v.init(),fs.init(),ko(_s)}function us(){if(r.postRun)for(typeof r.postRun=="function"&&(r.postRun=[r.postRun]);r.postRun.length;)Du(r.postRun.shift());ko(Un)}function Ia(d){Ti.unshift(d)}function SA(d){_s.unshift(d)}function Du(d){Un.unshift(d)}var gs=0,kA=null,ya=null;function Ru(d){return d}function xA(d){gs++,r.monitorRunDependencies&&r.monitorRunDependencies(gs)}function PA(d){if(gs--,r.monitorRunDependencies&&r.monitorRunDependencies(gs),gs==0&&(kA!==null&&(clearInterval(kA),kA=null),ya)){var E=ya;ya=null,E()}}r.preloadedImages={},r.preloadedAudios={};function Sr(d){r.onAbort&&r.onAbort(d),d+="",x(d),ne=!0,le=1,d="abort("+d+"). Build with -s ASSERTIONS=1 for more info.";var E=new WebAssembly.RuntimeError(d);throw n(E),E}var jl="data:application/octet-stream;base64,";function Fu(d){return d.startsWith(jl)}var So="data:application/octet-stream;base64,AGFzbQEAAAABlAInYAF/AX9gA39/fwF/YAF/AGACf38Bf2ACf38AYAV/f39/fwF/YAR/f39/AX9gA39/fwBgBH9+f38Bf2AAAX9gBX9/f35/AX5gA39+fwF/YAF/AX5gAn9+AX9gBH9/fn8BfmADf35/AX5gA39/fgF/YAR/f35/AX9gBn9/f39/fwF/YAR/f39/AGADf39+AX5gAn5/AX9gA398fwBgBH9/f38BfmADf39/AX5gBn98f39/fwF/YAV/f35/fwF/YAV/fn9/fwF/YAV/f39/fwBgAn9+AGACf38BfmACf3wAYAh/fn5/f39+fwF/YAV/f39+fwBgAABgBX5+f35/AX5gBX9/f39/AX5gAnx/AXxgAn9+AX4CeRQBYQFhAAIBYQFiAAABYQFjAAMBYQFkAAYBYQFlAAEBYQFmAAABYQFnAAYBYQFoAAABYQFpAAMBYQFqAAMBYQFrAAMBYQFsAAEBYQFtAAABYQFuAAUBYQFvAAEBYQFwAAMBYQFxAAEBYQFyAAABYQFzAAMBYQF0AAADggKAAgcCAgQAAQECAgANBA4EBwICAhwLEw0AFA0dAAAMDAIHHgwQAgIDAwICAQAIAAcIFBUEBgAADAAECAgDAQYAAgIBBgAfFwEBAwITAiAPBgIFEQMFAxgBCAIBAAAHBQEYABoSAQIABwQDIREIAyIGAAEBAwMAIwUbASQHAQsVAQMABQMEAA0bFw0BBAALCwMDDAwAAwAHJQMBAAgaAQECBQMBAgMDAAcHBwICAgImEQsICAsECQoJAgAAAAAAAAkFAAUFBQEGAwYGBgUSBgYBARIBAAIJBgABDgABAQ8ACQEEGQkJCQAAAAMECgoBAQIQAAAAAgEDAwAEAQoFAA4ACQAEBQFwAR8fBQcBAYACgIACBgkBfwFB0KDBAgsHvgI8AXUCAAF2AIABAXcAkwIBeADjAQF5APEBAXoA0QEBQQDQAQFCAM8BAUMAzgEBRADMAQFFAMsBAUYAyQEBRwCSAgFIAJECAUkAjwIBSgCKAgFLAOkBAUwA4gEBTQDhAQFOADwBTwD8AQFQAPkBAVEA+AEBUgDwAQFTAPoBAVQA4AEBVQAVAVYAGAFXAMcBAVgAzQEBWQDfAQFaAN4BAV8A3QEBJADkAQJhYQDcAQJiYQDbAQJjYQDaAQJkYQDZAQJlYQDYAQJmYQDXAQJnYQDqAQJoYQCcAQJpYQDWAQJqYQDVAQJrYQDUAQJsYQAvAm1hABsCbmEAygECb2EASAJwYQEAAnFhAGcCcmEA0wECc2EA6AECdGEA0gECdWEA9wECdmEA9gECd2EA9QECeGEA5wECeWEA5gECemEA5QEJQQEAQQELHsgBkAKNAo4CjAKLArcBiQKIAocChgKFAoQCgwKCAoECgAL/Af4B/QH7AVv0AfMB8gHvAe4B7QHsAesBCu+QCYACQAEBfyMAQRBrIgMgADYCDCADIAE2AgggAyACNgIEIAMoAgwEQCADKAIMIAMoAgg2AgAgAygCDCADKAIENgIECwvMDAEHfwJAIABFDQAgAEEIayIDIABBBGsoAgAiAUF4cSIAaiEFAkAgAUEBcQ0AIAFBA3FFDQEgAyADKAIAIgFrIgNB9JsBKAIASQ0BIAAgAWohACADQfibASgCAEcEQCABQf8BTQRAIAMoAggiAiABQQN2IgRBA3RBjJwBakYaIAIgAygCDCIBRgRAQeSbAUHkmwEoAgBBfiAEd3E2AgAMAwsgAiABNgIMIAEgAjYCCAwCCyADKAIYIQYCQCADIAMoAgwiAUcEQCADKAIIIgIgATYCDCABIAI2AggMAQsCQCADQRRqIgIoAgAiBA0AIANBEGoiAigCACIEDQBBACEBDAELA0AgAiEHIAQiAUEUaiICKAIAIgQNACABQRBqIQIgASgCECIEDQALIAdBADYCAAsgBkUNAQJAIAMgAygCHCICQQJ0QZSeAWoiBCgCAEYEQCAEIAE2AgAgAQ0BQeibAUHomwEoAgBBfiACd3E2AgAMAwsgBkEQQRQgBigCECADRhtqIAE2AgAgAUUNAgsgASAGNgIYIAMoAhAiAgRAIAEgAjYCECACIAE2AhgLIAMoAhQiAkUNASABIAI2AhQgAiABNgIYDAELIAUoAgQiAUEDcUEDRw0AQeybASAANgIAIAUgAUF+cTYCBCADIABBAXI2AgQgACADaiAANgIADwsgAyAFTw0AIAUoAgQiAUEBcUUNAAJAIAFBAnFFBEAgBUH8mwEoAgBGBEBB/JsBIAM2AgBB8JsBQfCbASgCACAAaiIANgIAIAMgAEEBcjYCBCADQfibASgCAEcNA0HsmwFBADYCAEH4mwFBADYCAA8LIAVB+JsBKAIARgRAQfibASADNgIAQeybAUHsmwEoAgAgAGoiADYCACADIABBAXI2AgQgACADaiAANgIADwsgAUF4cSAAaiEAAkAgAUH/AU0EQCAFKAIIIgIgAUEDdiIEQQN0QYycAWpGGiACIAUoAgwiAUYEQEHkmwFB5JsBKAIAQX4gBHdxNgIADAILIAIgATYCDCABIAI2AggMAQsgBSgCGCEGAkAgBSAFKAIMIgFHBEAgBSgCCCICQfSbASgCAEkaIAIgATYCDCABIAI2AggMAQsCQCAFQRRqIgIoAgAiBA0AIAVBEGoiAigCACIEDQBBACEBDAELA0AgAiEHIAQiAUEUaiICKAIAIgQNACABQRBqIQIgASgCECIEDQALIAdBADYCAAsgBkUNAAJAIAUgBSgCHCICQQJ0QZSeAWoiBCgCAEYEQCAEIAE2AgAgAQ0BQeibAUHomwEoAgBBfiACd3E2AgAMAgsgBkEQQRQgBigCECAFRhtqIAE2AgAgAUUNAQsgASAGNgIYIAUoAhAiAgRAIAEgAjYCECACIAE2AhgLIAUoAhQiAkUNACABIAI2AhQgAiABNgIYCyADIABBAXI2AgQgACADaiAANgIAIANB+JsBKAIARw0BQeybASAANgIADwsgBSABQX5xNgIEIAMgAEEBcjYCBCAAIANqIAA2AgALIABB/wFNBEAgAEEDdiIBQQN0QYycAWohAAJ/QeSbASgCACICQQEgAXQiAXFFBEBB5JsBIAEgAnI2AgAgAAwBCyAAKAIICyECIAAgAzYCCCACIAM2AgwgAyAANgIMIAMgAjYCCA8LQR8hAiADQgA3AhAgAEH///8HTQRAIABBCHYiASABQYD+P2pBEHZBCHEiAXQiAiACQYDgH2pBEHZBBHEiAnQiBCAEQYCAD2pBEHZBAnEiBHRBD3YgASACciAEcmsiAUEBdCAAIAFBFWp2QQFxckEcaiECCyADIAI2AhwgAkECdEGUngFqIQECQAJAAkBB6JsBKAIAIgRBASACdCIHcUUEQEHomwEgBCAHcjYCACABIAM2AgAgAyABNgIYDAELIABBAEEZIAJBAXZrIAJBH0YbdCECIAEoAgAhAQNAIAEiBCgCBEF4cSAARg0CIAJBHXYhASACQQF0IQIgBCABQQRxaiIHQRBqKAIAIgENAAsgByADNgIQIAMgBDYCGAsgAyADNgIMIAMgAzYCCAwBCyAEKAIIIgAgAzYCDCAEIAM2AgggA0EANgIYIAMgBDYCDCADIAA2AggLQYScAUGEnAEoAgBBAWsiAEF/IAAbNgIACwtCAQF/IwBBEGsiASQAIAEgADYCDCABKAIMBEAgASgCDC0AAUEBcQRAIAEoAgwoAgQQFQsgASgCDBAVCyABQRBqJAALQwEBfyMAQRBrIgIkACACIAA2AgwgAiABNgIIIAIoAgwCfyMAQRBrIgAgAigCCDYCDCAAKAIMQQxqCxBFIAJBEGokAAuiLgEMfyMAQRBrIgwkAAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAAQfQBTQRAQeSbASgCACIFQRAgAEELakF4cSAAQQtJGyIIQQN2IgJ2IgFBA3EEQCABQX9zQQFxIAJqIgNBA3QiAUGUnAFqKAIAIgRBCGohAAJAIAQoAggiAiABQYycAWoiAUYEQEHkmwEgBUF+IAN3cTYCAAwBCyACIAE2AgwgASACNgIICyAEIANBA3QiAUEDcjYCBCABIARqIgEgASgCBEEBcjYCBAwNCyAIQeybASgCACIKTQ0BIAEEQAJAQQIgAnQiAEEAIABrciABIAJ0cSIAQQAgAGtxQQFrIgAgAEEMdkEQcSICdiIBQQV2QQhxIgAgAnIgASAAdiIBQQJ2QQRxIgByIAEgAHYiAUEBdkECcSIAciABIAB2IgFBAXZBAXEiAHIgASAAdmoiA0EDdCIAQZScAWooAgAiBCgCCCIBIABBjJwBaiIARgRAQeSbASAFQX4gA3dxIgU2AgAMAQsgASAANgIMIAAgATYCCAsgBEEIaiEAIAQgCEEDcjYCBCAEIAhqIgIgA0EDdCIBIAhrIgNBAXI2AgQgASAEaiADNgIAIAoEQCAKQQN2IgFBA3RBjJwBaiEHQfibASgCACEEAn8gBUEBIAF0IgFxRQRAQeSbASABIAVyNgIAIAcMAQsgBygCCAshASAHIAQ2AgggASAENgIMIAQgBzYCDCAEIAE2AggLQfibASACNgIAQeybASADNgIADA0LQeibASgCACIGRQ0BIAZBACAGa3FBAWsiACAAQQx2QRBxIgJ2IgFBBXZBCHEiACACciABIAB2IgFBAnZBBHEiAHIgASAAdiIBQQF2QQJxIgByIAEgAHYiAUEBdkEBcSIAciABIAB2akECdEGUngFqKAIAIgEoAgRBeHEgCGshAyABIQIDQAJAIAIoAhAiAEUEQCACKAIUIgBFDQELIAAoAgRBeHEgCGsiAiADIAIgA0kiAhshAyAAIAEgAhshASAAIQIMAQsLIAEgCGoiCSABTQ0CIAEoAhghCyABIAEoAgwiBEcEQCABKAIIIgBB9JsBKAIASRogACAENgIMIAQgADYCCAwMCyABQRRqIgIoAgAiAEUEQCABKAIQIgBFDQQgAUEQaiECCwNAIAIhByAAIgRBFGoiAigCACIADQAgBEEQaiECIAQoAhAiAA0ACyAHQQA2AgAMCwtBfyEIIABBv39LDQAgAEELaiIAQXhxIQhB6JsBKAIAIglFDQBBACAIayEDAkACQAJAAn9BACAIQYACSQ0AGkEfIAhB////B0sNABogAEEIdiIAIABBgP4/akEQdkEIcSICdCIAIABBgOAfakEQdkEEcSIBdCIAIABBgIAPakEQdkECcSIAdEEPdiABIAJyIAByayIAQQF0IAggAEEVanZBAXFyQRxqCyIFQQJ0QZSeAWooAgAiAkUEQEEAIQAMAQtBACEAIAhBAEEZIAVBAXZrIAVBH0YbdCEBA0ACQCACKAIEQXhxIAhrIgcgA08NACACIQQgByIDDQBBACEDIAIhAAwDCyAAIAIoAhQiByAHIAIgAUEddkEEcWooAhAiAkYbIAAgBxshACABQQF0IQEgAg0ACwsgACAEckUEQEECIAV0IgBBACAAa3IgCXEiAEUNAyAAQQAgAGtxQQFrIgAgAEEMdkEQcSICdiIBQQV2QQhxIgAgAnIgASAAdiIBQQJ2QQRxIgByIAEgAHYiAUEBdkECcSIAciABIAB2IgFBAXZBAXEiAHIgASAAdmpBAnRBlJ4BaigCACEACyAARQ0BCwNAIAAoAgRBeHEgCGsiASADSSECIAEgAyACGyEDIAAgBCACGyEEIAAoAhAiAQR/IAEFIAAoAhQLIgANAAsLIARFDQAgA0HsmwEoAgAgCGtPDQAgBCAIaiIGIARNDQEgBCgCGCEFIAQgBCgCDCIBRwRAIAQoAggiAEH0mwEoAgBJGiAAIAE2AgwgASAANgIIDAoLIARBFGoiAigCACIARQRAIAQoAhAiAEUNBCAEQRBqIQILA0AgAiEHIAAiAUEUaiICKAIAIgANACABQRBqIQIgASgCECIADQALIAdBADYCAAwJCyAIQeybASgCACICTQRAQfibASgCACEDAkAgAiAIayIBQRBPBEBB7JsBIAE2AgBB+JsBIAMgCGoiADYCACAAIAFBAXI2AgQgAiADaiABNgIAIAMgCEEDcjYCBAwBC0H4mwFBADYCAEHsmwFBADYCACADIAJBA3I2AgQgAiADaiIAIAAoAgRBAXI2AgQLIANBCGohAAwLCyAIQfCbASgCACIGSQRAQfCbASAGIAhrIgE2AgBB/JsBQfybASgCACICIAhqIgA2AgAgACABQQFyNgIEIAIgCEEDcjYCBCACQQhqIQAMCwtBACEAIAhBL2oiCQJ/QbyfASgCAARAQcSfASgCAAwBC0HInwFCfzcCAEHAnwFCgKCAgICABDcCAEG8nwEgDEEMakFwcUHYqtWqBXM2AgBB0J8BQQA2AgBBoJ8BQQA2AgBBgCALIgFqIgVBACABayIHcSICIAhNDQpBnJ8BKAIAIgQEQEGUnwEoAgAiAyACaiIBIANNDQsgASAESw0LC0GgnwEtAABBBHENBQJAAkBB/JsBKAIAIgMEQEGknwEhAANAIAMgACgCACIBTwRAIAEgACgCBGogA0sNAwsgACgCCCIADQALC0EAED4iAUF/Rg0GIAIhBUHAnwEoAgAiA0EBayIAIAFxBEAgAiABayAAIAFqQQAgA2txaiEFCyAFIAhNDQYgBUH+////B0sNBkGcnwEoAgAiBARAQZSfASgCACIDIAVqIgAgA00NByAAIARLDQcLIAUQPiIAIAFHDQEMCAsgBSAGayAHcSIFQf7///8HSw0FIAUQPiIBIAAoAgAgACgCBGpGDQQgASEACwJAIABBf0YNACAIQTBqIAVNDQBBxJ8BKAIAIgEgCSAFa2pBACABa3EiAUH+////B0sEQCAAIQEMCAsgARA+QX9HBEAgASAFaiEFIAAhAQwIC0EAIAVrED4aDAULIAAiAUF/Rw0GDAQLAAtBACEEDAcLQQAhAQwFCyABQX9HDQILQaCfAUGgnwEoAgBBBHI2AgALIAJB/v///wdLDQEgAhA+IQFBABA+IQAgAUF/Rg0BIABBf0YNASAAIAFNDQEgACABayIFIAhBKGpNDQELQZSfAUGUnwEoAgAgBWoiADYCAEGYnwEoAgAgAEkEQEGYnwEgADYCAAsCQAJAAkBB/JsBKAIAIgcEQEGknwEhAANAIAEgACgCACIDIAAoAgQiAmpGDQIgACgCCCIADQALDAILQfSbASgCACIAQQAgACABTRtFBEBB9JsBIAE2AgALQQAhAEGonwEgBTYCAEGknwEgATYCAEGEnAFBfzYCAEGInAFBvJ8BKAIANgIAQbCfAUEANgIAA0AgAEEDdCIDQZScAWogA0GMnAFqIgI2AgAgA0GYnAFqIAI2AgAgAEEBaiIAQSBHDQALQfCbASAFQShrIgNBeCABa0EHcUEAIAFBCGpBB3EbIgBrIgI2AgBB/JsBIAAgAWoiADYCACAAIAJBAXI2AgQgASADakEoNgIEQYCcAUHMnwEoAgA2AgAMAgsgAC0ADEEIcQ0AIAMgB0sNACABIAdNDQAgACACIAVqNgIEQfybASAHQXggB2tBB3FBACAHQQhqQQdxGyIAaiICNgIAQfCbAUHwmwEoAgAgBWoiASAAayIANgIAIAIgAEEBcjYCBCABIAdqQSg2AgRBgJwBQcyfASgCADYCAAwBC0H0mwEoAgAgAUsEQEH0mwEgATYCAAsgASAFaiECQaSfASEAAkACQAJAAkACQAJAA0AgAiAAKAIARwRAIAAoAggiAA0BDAILCyAALQAMQQhxRQ0BC0GknwEhAANAIAcgACgCACICTwRAIAIgACgCBGoiBCAHSw0DCyAAKAIIIQAMAAsACyAAIAE2AgAgACAAKAIEIAVqNgIEIAFBeCABa0EHcUEAIAFBCGpBB3EbaiIJIAhBA3I2AgQgAkF4IAJrQQdxQQAgAkEIakEHcRtqIgUgCCAJaiIGayECIAUgB0YEQEH8mwEgBjYCAEHwmwFB8JsBKAIAIAJqIgA2AgAgBiAAQQFyNgIEDAMLIAVB+JsBKAIARgRAQfibASAGNgIAQeybAUHsmwEoAgAgAmoiADYCACAGIABBAXI2AgQgACAGaiAANgIADAMLIAUoAgQiAEEDcUEBRgRAIABBeHEhBwJAIABB/wFNBEAgBSgCCCIDIABBA3YiAEEDdEGMnAFqRhogAyAFKAIMIgFGBEBB5JsBQeSbASgCAEF+IAB3cTYCAAwCCyADIAE2AgwgASADNgIIDAELIAUoAhghCAJAIAUgBSgCDCIBRwRAIAUoAggiACABNgIMIAEgADYCCAwBCwJAIAVBFGoiACgCACIDDQAgBUEQaiIAKAIAIgMNAEEAIQEMAQsDQCAAIQQgAyIBQRRqIgAoAgAiAw0AIAFBEGohACABKAIQIgMNAAsgBEEANgIACyAIRQ0AAkAgBSAFKAIcIgNBAnRBlJ4BaiIAKAIARgRAIAAgATYCACABDQFB6JsBQeibASgCAEF+IAN3cTYCAAwCCyAIQRBBFCAIKAIQIAVGG2ogATYCACABRQ0BCyABIAg2AhggBSgCECIABEAgASAANgIQIAAgATYCGAsgBSgCFCIARQ0AIAEgADYCFCAAIAE2AhgLIAUgB2ohBSACIAdqIQILIAUgBSgCBEF+cTYCBCAGIAJBAXI2AgQgAiAGaiACNgIAIAJB/wFNBEAgAkEDdiIAQQN0QYycAWohAgJ/QeSbASgCACIBQQEgAHQiAHFFBEBB5JsBIAAgAXI2AgAgAgwBCyACKAIICyEAIAIgBjYCCCAAIAY2AgwgBiACNgIMIAYgADYCCAwDC0EfIQAgAkH///8HTQRAIAJBCHYiACAAQYD+P2pBEHZBCHEiA3QiACAAQYDgH2pBEHZBBHEiAXQiACAAQYCAD2pBEHZBAnEiAHRBD3YgASADciAAcmsiAEEBdCACIABBFWp2QQFxckEcaiEACyAGIAA2AhwgBkIANwIQIABBAnRBlJ4BaiEEAkBB6JsBKAIAIgNBASAAdCIBcUUEQEHomwEgASADcjYCACAEIAY2AgAgBiAENgIYDAELIAJBAEEZIABBAXZrIABBH0YbdCEAIAQoAgAhAQNAIAEiAygCBEF4cSACRg0DIABBHXYhASAAQQF0IQAgAyABQQRxaiIEKAIQIgENAAsgBCAGNgIQIAYgAzYCGAsgBiAGNgIMIAYgBjYCCAwCC0HwmwEgBUEoayIDQXggAWtBB3FBACABQQhqQQdxGyIAayICNgIAQfybASAAIAFqIgA2AgAgACACQQFyNgIEIAEgA2pBKDYCBEGAnAFBzJ8BKAIANgIAIAcgBEEnIARrQQdxQQAgBEEna0EHcRtqQS9rIgAgACAHQRBqSRsiAkEbNgIEIAJBrJ8BKQIANwIQIAJBpJ8BKQIANwIIQayfASACQQhqNgIAQaifASAFNgIAQaSfASABNgIAQbCfAUEANgIAIAJBGGohAANAIABBBzYCBCAAQQhqIQEgAEEEaiEAIAEgBEkNAAsgAiAHRg0DIAIgAigCBEF+cTYCBCAHIAIgB2siBEEBcjYCBCACIAQ2AgAgBEH/AU0EQCAEQQN2IgBBA3RBjJwBaiECAn9B5JsBKAIAIgFBASAAdCIAcUUEQEHkmwEgACABcjYCACACDAELIAIoAggLIQAgAiAHNgIIIAAgBzYCDCAHIAI2AgwgByAANgIIDAQLQR8hACAHQgA3AhAgBEH///8HTQRAIARBCHYiACAAQYD+P2pBEHZBCHEiAnQiACAAQYDgH2pBEHZBBHEiAXQiACAAQYCAD2pBEHZBAnEiAHRBD3YgASACciAAcmsiAEEBdCAEIABBFWp2QQFxckEcaiEACyAHIAA2AhwgAEECdEGUngFqIQMCQEHomwEoAgAiAkEBIAB0IgFxRQRAQeibASABIAJyNgIAIAMgBzYCACAHIAM2AhgMAQsgBEEAQRkgAEEBdmsgAEEfRht0IQAgAygCACEBA0AgASICKAIEQXhxIARGDQQgAEEddiEBIABBAXQhACACIAFBBHFqIgMoAhAiAQ0ACyADIAc2AhAgByACNgIYCyAHIAc2AgwgByAHNgIIDAMLIAMoAggiACAGNgIMIAMgBjYCCCAGQQA2AhggBiADNgIMIAYgADYCCAsgCUEIaiEADAULIAIoAggiACAHNgIMIAIgBzYCCCAHQQA2AhggByACNgIMIAcgADYCCAtB8JsBKAIAIgAgCE0NAEHwmwEgACAIayIBNgIAQfybAUH8mwEoAgAiAiAIaiIANgIAIAAgAUEBcjYCBCACIAhBA3I2AgQgAkEIaiEADAMLQbSbAUEwNgIAQQAhAAwCCwJAIAVFDQACQCAEKAIcIgJBAnRBlJ4BaiIAKAIAIARGBEAgACABNgIAIAENAUHomwEgCUF+IAJ3cSIJNgIADAILIAVBEEEUIAUoAhAgBEYbaiABNgIAIAFFDQELIAEgBTYCGCAEKAIQIgAEQCABIAA2AhAgACABNgIYCyAEKAIUIgBFDQAgASAANgIUIAAgATYCGAsCQCADQQ9NBEAgBCADIAhqIgBBA3I2AgQgACAEaiIAIAAoAgRBAXI2AgQMAQsgBCAIQQNyNgIEIAYgA0EBcjYCBCADIAZqIAM2AgAgA0H/AU0EQCADQQN2IgBBA3RBjJwBaiECAn9B5JsBKAIAIgFBASAAdCIAcUUEQEHkmwEgACABcjYCACACDAELIAIoAggLIQAgAiAGNgIIIAAgBjYCDCAGIAI2AgwgBiAANgIIDAELQR8hACADQf///wdNBEAgA0EIdiIAIABBgP4/akEQdkEIcSICdCIAIABBgOAfakEQdkEEcSIBdCIAIABBgIAPakEQdkECcSIAdEEPdiABIAJyIAByayIAQQF0IAMgAEEVanZBAXFyQRxqIQALIAYgADYCHCAGQgA3AhAgAEECdEGUngFqIQICQAJAIAlBASAAdCIBcUUEQEHomwEgASAJcjYCACACIAY2AgAgBiACNgIYDAELIANBAEEZIABBAXZrIABBH0YbdCEAIAIoAgAhCANAIAgiASgCBEF4cSADRg0CIABBHXYhAiAAQQF0IQAgASACQQRxaiICKAIQIggNAAsgAiAGNgIQIAYgATYCGAsgBiAGNgIMIAYgBjYCCAwBCyABKAIIIgAgBjYCDCABIAY2AgggBkEANgIYIAYgATYCDCAGIAA2AggLIARBCGohAAwBCwJAIAtFDQACQCABKAIcIgJBAnRBlJ4BaiIAKAIAIAFGBEAgACAENgIAIAQNAUHomwEgBkF+IAJ3cTYCAAwCCyALQRBBFCALKAIQIAFGG2ogBDYCACAERQ0BCyAEIAs2AhggASgCECIABEAgBCAANgIQIAAgBDYCGAsgASgCFCIARQ0AIAQgADYCFCAAIAQ2AhgLAkAgA0EPTQRAIAEgAyAIaiIAQQNyNgIEIAAgAWoiACAAKAIEQQFyNgIEDAELIAEgCEEDcjYCBCAJIANBAXI2AgQgAyAJaiADNgIAIAoEQCAKQQN2IgBBA3RBjJwBaiEEQfibASgCACECAn9BASAAdCIAIAVxRQRAQeSbASAAIAVyNgIAIAQMAQsgBCgCCAshACAEIAI2AgggACACNgIMIAIgBDYCDCACIAA2AggLQfibASAJNgIAQeybASADNgIACyABQQhqIQALIAxBEGokACAAC4MEAQN/IAJBgARPBEAgACABIAIQCxogAA8LIAAgAmohAwJAIAAgAXNBA3FFBEACQCAAQQNxRQRAIAAhAgwBCyACQQFIBEAgACECDAELIAAhAgNAIAIgAS0AADoAACABQQFqIQEgAkEBaiICQQNxRQ0BIAIgA0kNAAsLAkAgA0F8cSIEQcAASQ0AIAIgBEFAaiIFSw0AA0AgAiABKAIANgIAIAIgASgCBDYCBCACIAEoAgg2AgggAiABKAIMNgIMIAIgASgCEDYCECACIAEoAhQ2AhQgAiABKAIYNgIYIAIgASgCHDYCHCACIAEoAiA2AiAgAiABKAIkNgIkIAIgASgCKDYCKCACIAEoAiw2AiwgAiABKAIwNgIwIAIgASgCNDYCNCACIAEoAjg2AjggAiABKAI8NgI8IAFBQGshASACQUBrIgIgBU0NAAsLIAIgBE8NAQNAIAIgASgCADYCACABQQRqIQEgAkEEaiICIARJDQALDAELIANBBEkEQCAAIQIMAQsgACADQQRrIgRLBEAgACECDAELIAAhAgNAIAIgAS0AADoAACACIAEtAAE6AAEgAiABLQACOgACIAIgAS0AAzoAAyABQQRqIQEgAkEEaiICIARNDQALCyACIANJBEADQCACIAEtAAA6AAAgAUEBaiEBIAJBAWoiAiADRw0ACwsgAAvBGAECfyMAQRBrIgQkACAEIAA2AgwgBCABNgIIIAQgAjYCBCAEKAIMIQAgBCgCCCECIAQoAgQhAyMAQSBrIgEkACABIAA2AhggASACNgIUIAEgAzYCEAJAIAEoAhRFBEAgAUEANgIcDAELIAFBATYCDCABLQAMBEAgASgCFCECIAEoAhAhAyMAQSBrIgAgASgCGDYCHCAAIAI2AhggACADNgIUIAAgACgCHDYCECAAIAAoAhBBf3M2AhADQCAAKAIUBH8gACgCGEEDcUEARwVBAAtBAXEEQCAAKAIQIQIgACAAKAIYIgNBAWo2AhggACADLQAAIAJzQf8BcUECdEGgGWooAgAgACgCEEEIdnM2AhAgACAAKAIUQQFrNgIUDAELCyAAIAAoAhg2AgwDQCAAKAIUQSBPBEAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGgGWooAgAgACgCEEEQdkH/AXFBAnRBoCFqKAIAIAAoAhBB/wFxQQJ0QaAxaigCACAAKAIQQQh2Qf8BcUECdEGgKWooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGgGWooAgAgACgCEEEQdkH/AXFBAnRBoCFqKAIAIAAoAhBB/wFxQQJ0QaAxaigCACAAKAIQQQh2Qf8BcUECdEGgKWooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGgGWooAgAgACgCEEEQdkH/AXFBAnRBoCFqKAIAIAAoAhBB/wFxQQJ0QaAxaigCACAAKAIQQQh2Qf8BcUECdEGgKWooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGgGWooAgAgACgCEEEQdkH/AXFBAnRBoCFqKAIAIAAoAhBB/wFxQQJ0QaAxaigCACAAKAIQQQh2Qf8BcUECdEGgKWooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGgGWooAgAgACgCEEEQdkH/AXFBAnRBoCFqKAIAIAAoAhBB/wFxQQJ0QaAxaigCACAAKAIQQQh2Qf8BcUECdEGgKWooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGgGWooAgAgACgCEEEQdkH/AXFBAnRBoCFqKAIAIAAoAhBB/wFxQQJ0QaAxaigCACAAKAIQQQh2Qf8BcUECdEGgKWooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGgGWooAgAgACgCEEEQdkH/AXFBAnRBoCFqKAIAIAAoAhBB/wFxQQJ0QaAxaigCACAAKAIQQQh2Qf8BcUECdEGgKWooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGgGWooAgAgACgCEEEQdkH/AXFBAnRBoCFqKAIAIAAoAhBB/wFxQQJ0QaAxaigCACAAKAIQQQh2Qf8BcUECdEGgKWooAgBzc3M2AhAgACAAKAIUQSBrNgIUDAELCwNAIAAoAhRBBE8EQCAAIAAoAgwiAkEEajYCDCAAIAIoAgAgACgCEHM2AhAgACAAKAIQQRh2QQJ0QaAZaigCACAAKAIQQRB2Qf8BcUECdEGgIWooAgAgACgCEEH/AXFBAnRBoDFqKAIAIAAoAhBBCHZB/wFxQQJ0QaApaigCAHNzczYCECAAIAAoAhRBBGs2AhQMAQsLIAAgACgCDDYCGCAAKAIUBEADQCAAKAIQIQIgACAAKAIYIgNBAWo2AhggACADLQAAIAJzQf8BcUECdEGgGWooAgAgACgCEEEIdnM2AhAgACAAKAIUQQFrIgI2AhQgAg0ACwsgACAAKAIQQX9zNgIQIAEgACgCEDYCHAwBCyABKAIUIQIgASgCECEDIwBBIGsiACABKAIYNgIcIAAgAjYCGCAAIAM2AhQgACAAKAIcQQh2QYD+A3EgACgCHEEYdmogACgCHEGA/gNxQQh0aiAAKAIcQf8BcUEYdGo2AhAgACAAKAIQQX9zNgIQA0AgACgCFAR/IAAoAhhBA3FBAEcFQQALQQFxBEAgACgCEEEYdiECIAAgACgCGCIDQQFqNgIYIAAgAy0AACACc0ECdEGgOWooAgAgACgCEEEIdHM2AhAgACAAKAIUQQFrNgIUDAELCyAAIAAoAhg2AgwDQCAAKAIUQSBPBEAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGg0QBqKAIAIAAoAhBBEHZB/wFxQQJ0QaDJAGooAgAgACgCEEH/AXFBAnRBoDlqKAIAIAAoAhBBCHZB/wFxQQJ0QaDBAGooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGg0QBqKAIAIAAoAhBBEHZB/wFxQQJ0QaDJAGooAgAgACgCEEH/AXFBAnRBoDlqKAIAIAAoAhBBCHZB/wFxQQJ0QaDBAGooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGg0QBqKAIAIAAoAhBBEHZB/wFxQQJ0QaDJAGooAgAgACgCEEH/AXFBAnRBoDlqKAIAIAAoAhBBCHZB/wFxQQJ0QaDBAGooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGg0QBqKAIAIAAoAhBBEHZB/wFxQQJ0QaDJAGooAgAgACgCEEH/AXFBAnRBoDlqKAIAIAAoAhBBCHZB/wFxQQJ0QaDBAGooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGg0QBqKAIAIAAoAhBBEHZB/wFxQQJ0QaDJAGooAgAgACgCEEH/AXFBAnRBoDlqKAIAIAAoAhBBCHZB/wFxQQJ0QaDBAGooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGg0QBqKAIAIAAoAhBBEHZB/wFxQQJ0QaDJAGooAgAgACgCEEH/AXFBAnRBoDlqKAIAIAAoAhBBCHZB/wFxQQJ0QaDBAGooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGg0QBqKAIAIAAoAhBBEHZB/wFxQQJ0QaDJAGooAgAgACgCEEH/AXFBAnRBoDlqKAIAIAAoAhBBCHZB/wFxQQJ0QaDBAGooAgBzc3M2AhAgACAAKAIMIgJBBGo2AgwgACACKAIAIAAoAhBzNgIQIAAgACgCEEEYdkECdEGg0QBqKAIAIAAoAhBBEHZB/wFxQQJ0QaDJAGooAgAgACgCEEH/AXFBAnRBoDlqKAIAIAAoAhBBCHZB/wFxQQJ0QaDBAGooAgBzc3M2AhAgACAAKAIUQSBrNgIUDAELCwNAIAAoAhRBBE8EQCAAIAAoAgwiAkEEajYCDCAAIAIoAgAgACgCEHM2AhAgACAAKAIQQRh2QQJ0QaDRAGooAgAgACgCEEEQdkH/AXFBAnRBoMkAaigCACAAKAIQQf8BcUECdEGgOWooAgAgACgCEEEIdkH/AXFBAnRBoMEAaigCAHNzczYCECAAIAAoAhRBBGs2AhQMAQsLIAAgACgCDDYCGCAAKAIUBEADQCAAKAIQQRh2IQIgACAAKAIYIgNBAWo2AhggACADLQAAIAJzQQJ0QaA5aigCACAAKAIQQQh0czYCECAAIAAoAhRBAWsiAjYCFCACDQALCyAAIAAoAhBBf3M2AhAgASAAKAIQQQh2QYD+A3EgACgCEEEYdmogACgCEEGA/gNxQQh0aiAAKAIQQf8BcUEYdGo2AhwLIAEoAhwhACABQSBqJAAgBEEQaiQAIAAL7AIBAn8jAEEQayIBJAAgASAANgIMAkAgASgCDEUNACABKAIMKAIwBEAgASgCDCIAIAAoAjBBAWs2AjALIAEoAgwoAjANACABKAIMKAIgBEAgASgCDEEBNgIgIAEoAgwQLxoLIAEoAgwoAiRBAUYEQCABKAIMEGILAkAgASgCDCgCLEUNACABKAIMLQAoQQFxDQAgASgCDCECIwBBEGsiACABKAIMKAIsNgIMIAAgAjYCCCAAQQA2AgQDQCAAKAIEIAAoAgwoAkRJBEAgACgCDCgCTCAAKAIEQQJ0aigCACAAKAIIRgRAIAAoAgwoAkwgACgCBEECdGogACgCDCgCTCAAKAIMKAJEQQFrQQJ0aigCADYCACAAKAIMIgAgACgCREEBazYCRAUgACAAKAIEQQFqNgIEDAILCwsLIAEoAgxBAEIAQQUQIBogASgCDCgCAARAIAEoAgwoAgAQGwsgASgCDBAVCyABQRBqJAALnwIBAn8jAEEQayIBJAAgASAANgIMIAEgASgCDCgCHDYCBCABKAIEIQIjAEEQayIAJAAgACACNgIMIAAoAgwQvAEgAEEQaiQAIAEgASgCBCgCFDYCCCABKAIIIAEoAgwoAhBLBEAgASABKAIMKAIQNgIICwJAIAEoAghFDQAgASgCDCgCDCABKAIEKAIQIAEoAggQGRogASgCDCIAIAEoAgggACgCDGo2AgwgASgCBCIAIAEoAgggACgCEGo2AhAgASgCDCIAIAEoAgggACgCFGo2AhQgASgCDCIAIAAoAhAgASgCCGs2AhAgASgCBCIAIAAoAhQgASgCCGs2AhQgASgCBCgCFA0AIAEoAgQgASgCBCgCCDYCEAsgAUEQaiQAC2ABAX8jAEEQayIBJAAgASAANgIIIAEgASgCCEICEB42AgQCQCABKAIERQRAIAFBADsBDgwBCyABIAEoAgQtAAAgASgCBC0AAUEIdGo7AQ4LIAEvAQ4hACABQRBqJAAgAAvpAQEBfyMAQSBrIgIkACACIAA2AhwgAiABNwMQIAIpAxAhASMAQSBrIgAgAigCHDYCGCAAIAE3AxACQAJAAkAgACgCGC0AAEEBcUUNACAAKQMQIAAoAhgpAxAgACkDEHxWDQAgACgCGCkDCCAAKAIYKQMQIAApAxB8Wg0BCyAAKAIYQQA6AAAgAEEANgIcDAELIAAgACgCGCgCBCAAKAIYKQMQp2o2AgwgACAAKAIMNgIcCyACIAAoAhw2AgwgAigCDARAIAIoAhwiACACKQMQIAApAxB8NwMQCyACKAIMIQAgAkEgaiQAIAALbwEBfyMAQRBrIgIkACACIAA2AgggAiABOwEGIAIgAigCCEICEB42AgACQCACKAIARQRAIAJBfzYCDAwBCyACKAIAIAIvAQY6AAAgAigCACACLwEGQQh2OgABIAJBADYCDAsgAigCDBogAkEQaiQAC7YCAQF/IwBBMGsiBCQAIAQgADYCJCAEIAE2AiAgBCACNwMYIAQgAzYCFAJAIAQoAiQpAxhCASAEKAIUrYaDUARAIAQoAiRBDGpBHEEAEBQgBEJ/NwMoDAELAkAgBCgCJCgCAEUEQCAEIAQoAiQoAgggBCgCICAEKQMYIAQoAhQgBCgCJCgCBBEOADcDCAwBCyAEIAQoAiQoAgAgBCgCJCgCCCAEKAIgIAQpAxggBCgCFCAEKAIkKAIEEQoANwMICyAEKQMIQgBTBEACQCAEKAIUQQRGDQAgBCgCFEEORg0AAkAgBCgCJCAEQghBBBAgQgBTBEAgBCgCJEEMakEUQQAQFAwBCyAEKAIkQQxqIAQoAgAgBCgCBBAUCwsLIAQgBCkDCDcDKAsgBCkDKCECIARBMGokACACC48BAQF/IwBBEGsiAiQAIAIgADYCCCACIAE2AgQgAiACKAIIQgQQHjYCAAJAIAIoAgBFBEAgAkF/NgIMDAELIAIoAgAgAigCBDoAACACKAIAIAIoAgRBCHY6AAEgAigCACACKAIEQRB2OgACIAIoAgAgAigCBEEYdjoAAyACQQA2AgwLIAIoAgwaIAJBEGokAAsXACAALQAAQSBxRQRAIAEgAiAAEHEaCwtQAQF/IwBBEGsiASQAIAEgADYCDANAIAEoAgwEQCABIAEoAgwoAgA2AgggASgCDCgCDBAVIAEoAgwQFSABIAEoAgg2AgwMAQsLIAFBEGokAAs+AQF/IwBBEGsiASQAIAEgADYCDCABKAIMBEAgASgCDCgCABAVIAEoAgwoAgwQFSABKAIMEBULIAFBEGokAAt9AQF/IwBBEGsiASQAIAEgADYCDCABKAIMBEAgAUIANwMAA0AgASkDACABKAIMKQMIWkUEQCABKAIMKAIAIAEpAwCnQQR0ahB3IAEgASkDAEIBfDcDAAwBCwsgASgCDCgCABAVIAEoAgwoAigQJCABKAIMEBULIAFBEGokAAtuAQF/IwBBgAJrIgUkAAJAIARBgMAEcQ0AIAIgA0wNACAFIAFB/wFxIAIgA2siAkGAAiACQYACSSIBGxAzIAFFBEADQCAAIAVBgAIQIiACQYACayICQf8BSw0ACwsgACAFIAIQIgsgBUGAAmokAAvRAQEBfyMAQTBrIgMkACADIAA2AiggAyABNwMgIAMgAjYCHAJAIAMoAigtAChBAXEEQCADQX82AiwMAQsCQCADKAIoKAIgBEAgAygCHEUNASADKAIcQQFGDQEgAygCHEECRg0BCyADKAIoQQxqQRJBABAUIANBfzYCLAwBCyADIAMpAyA3AwggAyADKAIcNgIQIAMoAiggA0EIakIQQQYQIEIAUwRAIANBfzYCLAwBCyADKAIoQQA6ADQgA0EANgIsCyADKAIsIQAgA0EwaiQAIAALmBcBAn8jAEEwayIEJAAgBCAANgIsIAQgATYCKCAEIAI2AiQgBCADNgIgIARBADYCFAJAIAQoAiwoAoQBQQBKBEAgBCgCLCgCACgCLEECRgRAIwBBEGsiACAEKAIsNgIIIABB/4D/n382AgQgAEEANgIAAkADQCAAKAIAQR9MBEACQCAAKAIEQQFxRQ0AIAAoAghBlAFqIAAoAgBBAnRqLwEARQ0AIABBADYCDAwDCyAAIAAoAgBBAWo2AgAgACAAKAIEQQF2NgIEDAELCwJAAkAgACgCCC8BuAENACAAKAIILwG8AQ0AIAAoAggvAcgBRQ0BCyAAQQE2AgwMAQsgAEEgNgIAA0AgACgCAEGAAkgEQCAAKAIIQZQBaiAAKAIAQQJ0ai8BAARAIABBATYCDAwDBSAAIAAoAgBBAWo2AgAMAgsACwsgAEEANgIMCyAAKAIMIQAgBCgCLCgCACAANgIsCyAEKAIsIAQoAixBmBZqEHogBCgCLCAEKAIsQaQWahB6IAQoAiwhASMAQRBrIgAkACAAIAE2AgwgACgCDCAAKAIMQZQBaiAAKAIMKAKcFhC6ASAAKAIMIAAoAgxBiBNqIAAoAgwoAqgWELoBIAAoAgwgACgCDEGwFmoQeiAAQRI2AggDQAJAIAAoAghBA0gNACAAKAIMQfwUaiAAKAIILQDgbEECdGovAQINACAAIAAoAghBAWs2AggMAQsLIAAoAgwiASABKAKoLSAAKAIIQQNsQRFqajYCqC0gACgCCCEBIABBEGokACAEIAE2AhQgBCAEKAIsKAKoLUEKakEDdjYCHCAEIAQoAiwoAqwtQQpqQQN2NgIYIAQoAhggBCgCHE0EQCAEIAQoAhg2AhwLDAELIAQgBCgCJEEFaiIANgIYIAQgADYCHAsCQAJAIAQoAhwgBCgCJEEEakkNACAEKAIoRQ0AIAQoAiwgBCgCKCAEKAIkIAQoAiAQXQwBCwJAAkAgBCgCLCgCiAFBBEcEQCAEKAIYIAQoAhxHDQELIARBAzYCEAJAIAQoAiwoArwtQRAgBCgCEGtKBEAgBCAEKAIgQQJqNgIMIAQoAiwiACAALwG4LSAEKAIMQf//A3EgBCgCLCgCvC10cjsBuC0gBCgCLC8BuC1B/wFxIQEgBCgCLCgCCCECIAQoAiwiAygCFCEAIAMgAEEBajYCFCAAIAJqIAE6AAAgBCgCLC8BuC1BCHYhASAEKAIsKAIIIQIgBCgCLCIDKAIUIQAgAyAAQQFqNgIUIAAgAmogAToAACAEKAIsIAQoAgxB//8DcUEQIAQoAiwoArwta3U7AbgtIAQoAiwiACAAKAK8LSAEKAIQQRBrajYCvC0MAQsgBCgCLCIAIAAvAbgtIAQoAiBBAmpB//8DcSAEKAIsKAK8LXRyOwG4LSAEKAIsIgAgBCgCECAAKAK8LWo2ArwtCyAEKAIsQZDgAEGQ6QAQuwEMAQsgBEEDNgIIAkAgBCgCLCgCvC1BECAEKAIIa0oEQCAEIAQoAiBBBGo2AgQgBCgCLCIAIAAvAbgtIAQoAgRB//8DcSAEKAIsKAK8LXRyOwG4LSAEKAIsLwG4LUH/AXEhASAEKAIsKAIIIQIgBCgCLCIDKAIUIQAgAyAAQQFqNgIUIAAgAmogAToAACAEKAIsLwG4LUEIdiEBIAQoAiwoAgghAiAEKAIsIgMoAhQhACADIABBAWo2AhQgACACaiABOgAAIAQoAiwgBCgCBEH//wNxQRAgBCgCLCgCvC1rdTsBuC0gBCgCLCIAIAAoArwtIAQoAghBEGtqNgK8LQwBCyAEKAIsIgAgAC8BuC0gBCgCIEEEakH//wNxIAQoAiwoArwtdHI7AbgtIAQoAiwiACAEKAIIIAAoArwtajYCvC0LIAQoAiwhASAEKAIsKAKcFkEBaiECIAQoAiwoAqgWQQFqIQMgBCgCFEEBaiEFIwBBQGoiACQAIAAgATYCPCAAIAI2AjggACADNgI0IAAgBTYCMCAAQQU2AigCQCAAKAI8KAK8LUEQIAAoAihrSgRAIAAgACgCOEGBAms2AiQgACgCPCIBIAEvAbgtIAAoAiRB//8DcSAAKAI8KAK8LXRyOwG4LSAAKAI8LwG4LUH/AXEhAiAAKAI8KAIIIQMgACgCPCIFKAIUIQEgBSABQQFqNgIUIAEgA2ogAjoAACAAKAI8LwG4LUEIdiECIAAoAjwoAgghAyAAKAI8IgUoAhQhASAFIAFBAWo2AhQgASADaiACOgAAIAAoAjwgACgCJEH//wNxQRAgACgCPCgCvC1rdTsBuC0gACgCPCIBIAEoArwtIAAoAihBEGtqNgK8LQwBCyAAKAI8IgEgAS8BuC0gACgCOEGBAmtB//8DcSAAKAI8KAK8LXRyOwG4LSAAKAI8IgEgACgCKCABKAK8LWo2ArwtCyAAQQU2AiACQCAAKAI8KAK8LUEQIAAoAiBrSgRAIAAgACgCNEEBazYCHCAAKAI8IgEgAS8BuC0gACgCHEH//wNxIAAoAjwoArwtdHI7AbgtIAAoAjwvAbgtQf8BcSECIAAoAjwoAgghAyAAKAI8IgUoAhQhASAFIAFBAWo2AhQgASADaiACOgAAIAAoAjwvAbgtQQh2IQIgACgCPCgCCCEDIAAoAjwiBSgCFCEBIAUgAUEBajYCFCABIANqIAI6AAAgACgCPCAAKAIcQf//A3FBECAAKAI8KAK8LWt1OwG4LSAAKAI8IgEgASgCvC0gACgCIEEQa2o2ArwtDAELIAAoAjwiASABLwG4LSAAKAI0QQFrQf//A3EgACgCPCgCvC10cjsBuC0gACgCPCIBIAAoAiAgASgCvC1qNgK8LQsgAEEENgIYAkAgACgCPCgCvC1BECAAKAIYa0oEQCAAIAAoAjBBBGs2AhQgACgCPCIBIAEvAbgtIAAoAhRB//8DcSAAKAI8KAK8LXRyOwG4LSAAKAI8LwG4LUH/AXEhAiAAKAI8KAIIIQMgACgCPCIFKAIUIQEgBSABQQFqNgIUIAEgA2ogAjoAACAAKAI8LwG4LUEIdiECIAAoAjwoAgghAyAAKAI8IgUoAhQhASAFIAFBAWo2AhQgASADaiACOgAAIAAoAjwgACgCFEH//wNxQRAgACgCPCgCvC1rdTsBuC0gACgCPCIBIAEoArwtIAAoAhhBEGtqNgK8LQwBCyAAKAI8IgEgAS8BuC0gACgCMEEEa0H//wNxIAAoAjwoArwtdHI7AbgtIAAoAjwiASAAKAIYIAEoArwtajYCvC0LIABBADYCLANAIAAoAiwgACgCMEgEQCAAQQM2AhACQCAAKAI8KAK8LUEQIAAoAhBrSgRAIAAgACgCPEH8FGogACgCLC0A4GxBAnRqLwECNgIMIAAoAjwiASABLwG4LSAAKAIMQf//A3EgACgCPCgCvC10cjsBuC0gACgCPC8BuC1B/wFxIQIgACgCPCgCCCEDIAAoAjwiBSgCFCEBIAUgAUEBajYCFCABIANqIAI6AAAgACgCPC8BuC1BCHYhAiAAKAI8KAIIIQMgACgCPCIFKAIUIQEgBSABQQFqNgIUIAEgA2ogAjoAACAAKAI8IAAoAgxB//8DcUEQIAAoAjwoArwta3U7AbgtIAAoAjwiASABKAK8LSAAKAIQQRBrajYCvC0MAQsgACgCPCIBIAEvAbgtIAAoAjxB/BRqIAAoAiwtAOBsQQJ0ai8BAiAAKAI8KAK8LXRyOwG4LSAAKAI8IgEgACgCECABKAK8LWo2ArwtCyAAIAAoAixBAWo2AiwMAQsLIAAoAjwgACgCPEGUAWogACgCOEEBaxC5ASAAKAI8IAAoAjxBiBNqIAAoAjRBAWsQuQEgAEFAayQAIAQoAiwgBCgCLEGUAWogBCgCLEGIE2oQuwELCyAEKAIsEL4BIAQoAiAEQCAEKAIsEL0BCyAEQTBqJAAL1AEBAX8jAEEgayICJAAgAiAANgIYIAIgATcDECACIAIoAhhFOgAPAkAgAigCGEUEQCACIAIpAxCnEBgiADYCGCAARQRAIAJBADYCHAwCCwsgAkEYEBgiADYCCCAARQRAIAItAA9BAXEEQCACKAIYEBULIAJBADYCHAwBCyACKAIIQQE6AAAgAigCCCACKAIYNgIEIAIoAgggAikDEDcDCCACKAIIQgA3AxAgAigCCCACLQAPQQFxOgABIAIgAigCCDYCHAsgAigCHCEAIAJBIGokACAAC3gBAX8jAEEQayIBJAAgASAANgIIIAEgASgCCEIEEB42AgQCQCABKAIERQRAIAFBADYCDAwBCyABIAEoAgQtAAAgASgCBC0AASABKAIELQACIAEoAgQtAANBCHRqQQh0akEIdGo2AgwLIAEoAgwhACABQRBqJAAgAAuHAwEBfyMAQTBrIgMkACADIAA2AiQgAyABNgIgIAMgAjcDGAJAIAMoAiQtAChBAXEEQCADQn83AygMAQsCQAJAIAMoAiQoAiBFDQAgAykDGEL///////////8AVg0AIAMpAxhQDQEgAygCIA0BCyADKAIkQQxqQRJBABAUIANCfzcDKAwBCyADKAIkLQA1QQFxBEAgA0J/NwMoDAELAn8jAEEQayIAIAMoAiQ2AgwgACgCDC0ANEEBcQsEQCADQgA3AygMAQsgAykDGFAEQCADQgA3AygMAQsgA0IANwMQA0AgAykDECADKQMYVARAIAMgAygCJCADKAIgIAMpAxCnaiADKQMYIAMpAxB9QQEQICICNwMIIAJCAFMEQCADKAIkQQE6ADUgAykDEFAEQCADQn83AygMBAsgAyADKQMQNwMoDAMLIAMpAwhQBEAgAygCJEEBOgA0BSADIAMpAwggAykDEHw3AxAMAgsLCyADIAMpAxA3AygLIAMpAyghAiADQTBqJAAgAgthAQF/IwBBEGsiAiAANgIIIAIgATcDAAJAIAIpAwAgAigCCCkDCFYEQCACKAIIQQA6AAAgAkF/NgIMDAELIAIoAghBAToAACACKAIIIAIpAwA3AxAgAkEANgIMCyACKAIMC+8BAQF/IwBBIGsiAiQAIAIgADYCGCACIAE3AxAgAiACKAIYQggQHjYCDAJAIAIoAgxFBEAgAkF/NgIcDAELIAIoAgwgAikDEEL/AYM8AAAgAigCDCACKQMQQgiIQv8BgzwAASACKAIMIAIpAxBCEIhC/wGDPAACIAIoAgwgAikDEEIYiEL/AYM8AAMgAigCDCACKQMQQiCIQv8BgzwABCACKAIMIAIpAxBCKIhC/wGDPAAFIAIoAgwgAikDEEIwiEL/AYM8AAYgAigCDCACKQMQQjiIQv8BgzwAByACQQA2AhwLIAIoAhwaIAJBIGokAAt/AQN/IAAhAQJAIABBA3EEQANAIAEtAABFDQIgAUEBaiIBQQNxDQALCwNAIAEiAkEEaiEBIAIoAgAiA0F/cyADQYGChAhrcUGAgYKEeHFFDQALIANB/wFxRQRAIAIgAGsPCwNAIAItAAEhAyACQQFqIgEhAiADDQALCyABIABrC6YBAQF/IwBBEGsiASQAIAEgADYCCAJAIAEoAggoAiBFBEAgASgCCEEMakESQQAQFCABQX82AgwMAQsgASgCCCIAIAAoAiBBAWs2AiAgASgCCCgCIEUEQCABKAIIQQBCAEECECAaIAEoAggoAgAEQCABKAIIKAIAEC9BAEgEQCABKAIIQQxqQRRBABAUCwsLIAFBADYCDAsgASgCDCEAIAFBEGokACAACzYBAX8jAEEQayIBIAA2AgwCfiABKAIMLQAAQQFxBEAgASgCDCkDCCABKAIMKQMQfQwBC0IACwuyAQIBfwF+IwBBEGsiASQAIAEgADYCBCABIAEoAgRCCBAeNgIAAkAgASgCAEUEQCABQgA3AwgMAQsgASABKAIALQAArSABKAIALQAHrUI4hiABKAIALQAGrUIwhnwgASgCAC0ABa1CKIZ8IAEoAgAtAAStQiCGfCABKAIALQADrUIYhnwgASgCAC0AAq1CEIZ8IAEoAgAtAAGtQgiGfHw3AwgLIAEpAwghAiABQRBqJAAgAgvcAQEBfyMAQRBrIgEkACABIAA2AgwgASgCDARAIAEoAgwoAigEQCABKAIMKAIoQQA2AiggASgCDCgCKEIANwMgIAEoAgwCfiABKAIMKQMYIAEoAgwpAyBWBEAgASgCDCkDGAwBCyABKAIMKQMgCzcDGAsgASABKAIMKQMYNwMAA0AgASkDACABKAIMKQMIWkUEQCABKAIMKAIAIAEpAwCnQQR0aigCABAVIAEgASkDAEIBfDcDAAwBCwsgASgCDCgCABAVIAEoAgwoAgQQFSABKAIMEBULIAFBEGokAAvwAgICfwF+AkAgAkUNACAAIAJqIgNBAWsgAToAACAAIAE6AAAgAkEDSQ0AIANBAmsgAToAACAAIAE6AAEgA0EDayABOgAAIAAgAToAAiACQQdJDQAgA0EEayABOgAAIAAgAToAAyACQQlJDQAgAEEAIABrQQNxIgRqIgMgAUH/AXFBgYKECGwiADYCACADIAIgBGtBfHEiAmoiAUEEayAANgIAIAJBCUkNACADIAA2AgggAyAANgIEIAFBCGsgADYCACABQQxrIAA2AgAgAkEZSQ0AIAMgADYCGCADIAA2AhQgAyAANgIQIAMgADYCDCABQRBrIAA2AgAgAUEUayAANgIAIAFBGGsgADYCACABQRxrIAA2AgAgAiADQQRxQRhyIgFrIgJBIEkNACAArUKBgICAEH4hBSABIANqIQEDQCABIAU3AxggASAFNwMQIAEgBTcDCCABIAU3AwAgAUEgaiEBIAJBIGsiAkEfSw0ACwsLawEBfyMAQSBrIgIgADYCHCACQgEgAigCHK2GNwMQIAJBDGogATYCAANAIAIgAigCDCIAQQRqNgIMIAIgACgCADYCCCACKAIIQQBIRQRAIAIgAikDEEIBIAIoAgithoQ3AxAMAQsLIAIpAxALYAIBfwF+IwBBEGsiASQAIAEgADYCBAJAIAEoAgQoAiRBAUcEQCABKAIEQQxqQRJBABAUIAFCfzcDCAwBCyABIAEoAgRBAEIAQQ0QIDcDCAsgASkDCCECIAFBEGokACACC6UCAQJ/IwBBIGsiAyQAIAMgADYCGCADIAE2AhQgAyACNwMIIAMoAhgoAgAhASADKAIUIQQgAykDCCECIwBBIGsiACQAIAAgATYCFCAAIAQ2AhAgACACNwMIAkACQCAAKAIUKAIkQQFGBEAgACkDCEL///////////8AWA0BCyAAKAIUQQxqQRJBABAUIABCfzcDGAwBCyAAIAAoAhQgACgCECAAKQMIQQsQIDcDGAsgACkDGCECIABBIGokACADIAI3AwACQCACQgBTBEAgAygCGEEIaiADKAIYKAIAEBcgA0F/NgIcDAELIAMpAwAgAykDCFIEQCADKAIYQQhqQQZBGxAUIANBfzYCHAwBCyADQQA2AhwLIAMoAhwhACADQSBqJAAgAAsxAQF/IwBBEGsiASQAIAEgADYCDCABKAIMBEAgASgCDBBSIAEoAgwQFQsgAUEQaiQACy8BAX8jAEEQayIBJAAgASAANgIMIAEoAgwoAggQFSABKAIMQQA2AgggAUEQaiQAC80BAQF/IwBBEGsiAiQAIAIgADYCCCACIAE2AgQCQCACKAIILQAoQQFxBEAgAkF/NgIMDAELIAIoAgRFBEAgAigCCEEMakESQQAQFCACQX82AgwMAQsgAigCBBA7IAIoAggoAgAEQCACKAIIKAIAIAIoAgQQOUEASARAIAIoAghBDGogAigCCCgCABAXIAJBfzYCDAwCCwsgAigCCCACKAIEQjhBAxAgQgBTBEAgAkF/NgIMDAELIAJBADYCDAsgAigCDCEAIAJBEGokACAAC98EAQF/IwBBIGsiAiAANgIYIAIgATYCFAJAIAIoAhhFBEAgAkEBNgIcDAELIAIgAigCGCgCADYCDAJAIAIoAhgoAggEQCACIAIoAhgoAgg2AhAMAQsgAkEBNgIQIAJBADYCCANAAkAgAigCCCACKAIYLwEETw0AAkAgAigCDCACKAIIai0AAEEfSwRAIAIoAgwgAigCCGotAABBgAFJDQELIAIoAgwgAigCCGotAABBDUYNACACKAIMIAIoAghqLQAAQQpGDQAgAigCDCACKAIIai0AAEEJRgRADAELIAJBAzYCEAJAIAIoAgwgAigCCGotAABB4AFxQcABRgRAIAJBATYCAAwBCwJAIAIoAgwgAigCCGotAABB8AFxQeABRgRAIAJBAjYCAAwBCwJAIAIoAgwgAigCCGotAABB+AFxQfABRgRAIAJBAzYCAAwBCyACQQQ2AhAMBAsLCyACKAIYLwEEIAIoAgggAigCAGpNBEAgAkEENgIQDAILIAJBATYCBANAIAIoAgQgAigCAE0EQCACKAIMIAIoAgggAigCBGpqLQAAQcABcUGAAUcEQCACQQQ2AhAMBgUgAiACKAIEQQFqNgIEDAILAAsLIAIgAigCACACKAIIajYCCAsgAiACKAIIQQFqNgIIDAELCwsgAigCGCACKAIQNgIIIAIoAhQEQAJAIAIoAhRBAkcNACACKAIQQQNHDQAgAkECNgIQIAIoAhhBAjYCCAsCQCACKAIUIAIoAhBGDQAgAigCEEEBRg0AIAJBBTYCHAwCCwsgAiACKAIQNgIcCyACKAIcC2oBAX8jAEEQayIBIAA2AgwgASgCDEIANwMAIAEoAgxBADYCCCABKAIMQn83AxAgASgCDEEANgIsIAEoAgxBfzYCKCABKAIMQgA3AxggASgCDEIANwMgIAEoAgxBADsBMCABKAIMQQA7ATILjQUBA38jAEEQayIBJAAgASAANgIMIAEoAgwEQCABKAIMKAIABEAgASgCDCgCABAvGiABKAIMKAIAEBsLIAEoAgwoAhwQFSABKAIMKAIgECQgASgCDCgCJBAkIAEoAgwoAlAhAiMAQRBrIgAkACAAIAI2AgwgACgCDARAIAAoAgwoAhAEQCAAQQA2AggDQCAAKAIIIAAoAgwoAgBJBEAgACgCDCgCECAAKAIIQQJ0aigCAARAIAAoAgwoAhAgACgCCEECdGooAgAhAyMAQRBrIgIkACACIAM2AgwDQCACKAIMBEAgAiACKAIMKAIYNgIIIAIoAgwQFSACIAIoAgg2AgwMAQsLIAJBEGokAAsgACAAKAIIQQFqNgIIDAELCyAAKAIMKAIQEBULIAAoAgwQFQsgAEEQaiQAIAEoAgwoAkAEQCABQgA3AwADQCABKQMAIAEoAgwpAzBUBEAgASgCDCgCQCABKQMAp0EEdGoQdyABIAEpAwBCAXw3AwAMAQsLIAEoAgwoAkAQFQsgAUIANwMAA0AgASkDACABKAIMKAJErVQEQCABKAIMKAJMIAEpAwCnQQJ0aigCACECIwBBEGsiACQAIAAgAjYCDCAAKAIMQQE6ACgCfyMAQRBrIgIgACgCDEEMajYCDCACKAIMKAIARQsEQCAAKAIMQQxqQQhBABAUCyAAQRBqJAAgASABKQMAQgF8NwMADAELCyABKAIMKAJMEBUgASgCDCgCVCECIwBBEGsiACQAIAAgAjYCDCAAKAIMBEAgACgCDCgCCARAIAAoAgwoAgwgACgCDCgCCBECAAsgACgCDBAVCyAAQRBqJAAgASgCDEEIahA4IAEoAgwQFQsgAUEQaiQAC48OAQF/IwBBEGsiAyQAIAMgADYCDCADIAE2AgggAyACNgIEIAMoAgghASADKAIEIQIjAEEgayIAIAMoAgw2AhggACABNgIUIAAgAjYCECAAIAAoAhhBEHY2AgwgACAAKAIYQf//A3E2AhgCQCAAKAIQQQFGBEAgACAAKAIULQAAIAAoAhhqNgIYIAAoAhhB8f8DTwRAIAAgACgCGEHx/wNrNgIYCyAAIAAoAhggACgCDGo2AgwgACgCDEHx/wNPBEAgACAAKAIMQfH/A2s2AgwLIAAgACgCGCAAKAIMQRB0cjYCHAwBCyAAKAIURQRAIABBATYCHAwBCyAAKAIQQRBJBEADQCAAIAAoAhAiAUEBazYCECABBEAgACAAKAIUIgFBAWo2AhQgACABLQAAIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDAwBCwsgACgCGEHx/wNPBEAgACAAKAIYQfH/A2s2AhgLIAAgACgCDEHx/wNwNgIMIAAgACgCGCAAKAIMQRB0cjYCHAwBCwNAIAAoAhBBsCtPBEAgACAAKAIQQbArazYCECAAQdsCNgIIA0AgACAAKAIULQAAIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAAEgACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0AAiAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQADIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAAQgACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0ABSAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQAGIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAAcgACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0ACCAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQAJIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAAogACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0ACyAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQAMIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAA0gACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0ADiAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQAPIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhRBEGo2AhQgACAAKAIIQQFrIgE2AgggAQ0ACyAAIAAoAhhB8f8DcDYCGCAAIAAoAgxB8f8DcDYCDAwBCwsgACgCEARAA0AgACgCEEEQTwRAIAAgACgCEEEQazYCECAAIAAoAhQtAAAgACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0AASAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQACIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAAMgACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0ABCAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQAFIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAAYgACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0AByAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQAIIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAAkgACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0ACiAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQALIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAAwgACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFC0ADSAAKAIYajYCGCAAIAAoAhggACgCDGo2AgwgACAAKAIULQAOIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDCAAIAAoAhQtAA8gACgCGGo2AhggACAAKAIYIAAoAgxqNgIMIAAgACgCFEEQajYCFAwBCwsDQCAAIAAoAhAiAUEBazYCECABBEAgACAAKAIUIgFBAWo2AhQgACABLQAAIAAoAhhqNgIYIAAgACgCGCAAKAIMajYCDAwBCwsgACAAKAIYQfH/A3A2AhggACAAKAIMQfH/A3A2AgwLIAAgACgCGCAAKAIMQRB0cjYCHAsgACgCHCEAIANBEGokACAAC1IBAn9BkJcBKAIAIgEgAEEDakF8cSICaiEAAkAgAkEAIAAgAU0bDQAgAD8AQRB0SwRAIAAQDEUNAQtBkJcBIAA2AgAgAQ8LQbSbAUEwNgIAQX8LvAIBAX8jAEEgayIEJAAgBCAANgIYIAQgATcDECAEIAI2AgwgBCADNgIIIAQoAghFBEAgBCAEKAIYQQhqNgIICwJAIAQpAxAgBCgCGCkDMFoEQCAEKAIIQRJBABAUIARBADYCHAwBCwJAIAQoAgxBCHFFBEAgBCgCGCgCQCAEKQMQp0EEdGooAgQNAQsgBCgCGCgCQCAEKQMQp0EEdGooAgBFBEAgBCgCCEESQQAQFCAEQQA2AhwMAgsCQCAEKAIYKAJAIAQpAxCnQQR0ai0ADEEBcUUNACAEKAIMQQhxDQAgBCgCCEEXQQAQFCAEQQA2AhwMAgsgBCAEKAIYKAJAIAQpAxCnQQR0aigCADYCHAwBCyAEIAQoAhgoAkAgBCkDEKdBBHRqKAIENgIcCyAEKAIcIQAgBEEgaiQAIAALhAEBAX8jAEEQayIBJAAgASAANgIIIAFB2AAQGCIANgIEAkAgAEUEQCABQQA2AgwMAQsCQCABKAIIBEAgASgCBCABKAIIQdgAEBkaDAELIAEoAgQQUwsgASgCBEEANgIAIAEoAgRBAToABSABIAEoAgQ2AgwLIAEoAgwhACABQRBqJAAgAAtvAQF/IwBBIGsiAyQAIAMgADYCGCADIAE2AhQgAyACNgIQIAMgAygCGCADKAIQrRAeNgIMAkAgAygCDEUEQCADQX82AhwMAQsgAygCDCADKAIUIAMoAhAQGRogA0EANgIcCyADKAIcGiADQSBqJAALogEBAX8jAEEgayIEJAAgBCAANgIYIAQgATcDECAEIAI2AgwgBCADNgIIIAQgBCgCDCAEKQMQECkiADYCBAJAIABFBEAgBCgCCEEOQQAQFCAEQQA2AhwMAQsgBCgCGCAEKAIEKAIEIAQpAxAgBCgCCBBkQQBIBEAgBCgCBBAWIARBADYCHAwBCyAEIAQoAgQ2AhwLIAQoAhwhACAEQSBqJAAgAAugAQEBfyMAQSBrIgMkACADIAA2AhQgAyABNgIQIAMgAjcDCCADIAMoAhA2AgQCQCADKQMIQghUBEAgA0J/NwMYDAELIwBBEGsiACADKAIUNgIMIAAoAgwoAgAhACADKAIEIAA2AgAjAEEQayIAIAMoAhQ2AgwgACgCDCgCBCEAIAMoAgQgADYCBCADQgg3AxgLIAMpAxghAiADQSBqJAAgAguDAQIDfwF+AkAgAEKAgICAEFQEQCAAIQUMAQsDQCABQQFrIgEgACAAQgqAIgVCCn59p0EwcjoAACAAQv////+fAVYhAiAFIQAgAg0ACwsgBaciAgRAA0AgAUEBayIBIAIgAkEKbiIDQQpsa0EwcjoAACACQQlLIQQgAyECIAQNAAsLIAELPwEBfyMAQRBrIgIgADYCDCACIAE2AgggAigCDARAIAIoAgwgAigCCCgCADYCACACKAIMIAIoAggoAgQ2AgQLC9IIAQJ/IwBBIGsiBCQAIAQgADYCGCAEIAE2AhQgBCACNgIQIAQgAzYCDAJAIAQoAhhFBEAgBCgCFARAIAQoAhRBADYCAAsgBEGVFTYCHAwBCyAEKAIQQcAAcUUEQCAEKAIYKAIIRQRAIAQoAhhBABA6GgsCQAJAAkAgBCgCEEGAAXFFDQAgBCgCGCgCCEEBRg0AIAQoAhgoAghBAkcNAQsgBCgCGCgCCEEERw0BCyAEKAIYKAIMRQRAIAQoAhgoAgAhASAEKAIYLwEEIQIgBCgCGEEQaiEDIAQoAgwhBSMAQTBrIgAkACAAIAE2AiggACACNgIkIAAgAzYCICAAIAU2AhwgACAAKAIoNgIYAkAgACgCJEUEQCAAKAIgBEAgACgCIEEANgIACyAAQQA2AiwMAQsgAEEBNgIQIABBADYCDANAIAAoAgwgACgCJEkEQCMAQRBrIgEgACgCGCAAKAIMai0AAEEBdEGgFWovAQA2AggCQCABKAIIQYABSQRAIAFBATYCDAwBCyABKAIIQYAQSQRAIAFBAjYCDAwBCyABKAIIQYCABEkEQCABQQM2AgwMAQsgAUEENgIMCyAAIAEoAgwgACgCEGo2AhAgACAAKAIMQQFqNgIMDAELCyAAIAAoAhAQGCIBNgIUIAFFBEAgACgCHEEOQQAQFCAAQQA2AiwMAQsgAEEANgIIIABBADYCDANAIAAoAgwgACgCJEkEQCAAKAIUIAAoAghqIQIjAEEQayIBIAAoAhggACgCDGotAABBAXRBoBVqLwEANgIIIAEgAjYCBAJAIAEoAghBgAFJBEAgASgCBCABKAIIOgAAIAFBATYCDAwBCyABKAIIQYAQSQRAIAEoAgQgASgCCEEGdkEfcUHAAXI6AAAgASgCBCABKAIIQT9xQYABcjoAASABQQI2AgwMAQsgASgCCEGAgARJBEAgASgCBCABKAIIQQx2QQ9xQeABcjoAACABKAIEIAEoAghBBnZBP3FBgAFyOgABIAEoAgQgASgCCEE/cUGAAXI6AAIgAUEDNgIMDAELIAEoAgQgASgCCEESdkEHcUHwAXI6AAAgASgCBCABKAIIQQx2QT9xQYABcjoAASABKAIEIAEoAghBBnZBP3FBgAFyOgACIAEoAgQgASgCCEE/cUGAAXI6AAMgAUEENgIMCyAAIAEoAgwgACgCCGo2AgggACAAKAIMQQFqNgIMDAELCyAAKAIUIAAoAhBBAWtqQQA6AAAgACgCIARAIAAoAiAgACgCEEEBazYCAAsgACAAKAIUNgIsCyAAKAIsIQEgAEEwaiQAIAQoAhggATYCDCABRQRAIARBADYCHAwECwsgBCgCFARAIAQoAhQgBCgCGCgCEDYCAAsgBCAEKAIYKAIMNgIcDAILCyAEKAIUBEAgBCgCFCAEKAIYLwEENgIACyAEIAQoAhgoAgA2AhwLIAQoAhwhACAEQSBqJAAgAAs5AQF/IwBBEGsiASAANgIMQQAhACABKAIMLQAAQQFxBH8gASgCDCkDECABKAIMKQMIUQVBAAtBAXEL7wIBAX8jAEEQayIBJAAgASAANgIIAkAgASgCCC0AKEEBcQRAIAFBfzYCDAwBCyABKAIIKAIkQQNGBEAgASgCCEEMakEXQQAQFCABQX82AgwMAQsCQCABKAIIKAIgBEACfyMAQRBrIgAgASgCCDYCDCAAKAIMKQMYQsAAg1ALBEAgASgCCEEMakEdQQAQFCABQX82AgwMAwsMAQsgASgCCCgCAARAIAEoAggoAgAQSEEASARAIAEoAghBDGogASgCCCgCABAXIAFBfzYCDAwDCwsgASgCCEEAQgBBABAgQgBTBEAgASgCCCgCAARAIAEoAggoAgAQLxoLIAFBfzYCDAwCCwsgASgCCEEAOgA0IAEoAghBADoANSMAQRBrIgAgASgCCEEMajYCDCAAKAIMBEAgACgCDEEANgIAIAAoAgxBADYCBAsgASgCCCIAIAAoAiBBAWo2AiAgAUEANgIMCyABKAIMIQAgAUEQaiQAIAALdQIBfwF+IwBBEGsiASQAIAEgADYCBAJAIAEoAgQtAChBAXEEQCABQn83AwgMAQsgASgCBCgCIEUEQCABKAIEQQxqQRJBABAUIAFCfzcDCAwBCyABIAEoAgRBAEIAQQcQIDcDCAsgASkDCCECIAFBEGokACACC50BAQF/IwBBEGsiASAANgIIAkACQAJAIAEoAghFDQAgASgCCCgCIEUNACABKAIIKAIkDQELIAFBATYCDAwBCyABIAEoAggoAhw2AgQCQAJAIAEoAgRFDQAgASgCBCgCACABKAIIRw0AIAEoAgQoAgRBtP4ASQ0AIAEoAgQoAgRB0/4ATQ0BCyABQQE2AgwMAQsgAUEANgIMCyABKAIMC4ABAQN/IwBBEGsiAiAANgIMIAIgATYCCCACKAIIQQh2IQEgAigCDCgCCCEDIAIoAgwiBCgCFCEAIAQgAEEBajYCFCAAIANqIAE6AAAgAigCCEH/AXEhASACKAIMKAIIIQMgAigCDCICKAIUIQAgAiAAQQFqNgIUIAAgA2ogAToAAAuZBQEBfyMAQUBqIgQkACAEIAA2AjggBCABNwMwIAQgAjYCLCAEIAM2AiggBEHIABAYIgA2AiQCQCAARQRAIARBADYCPAwBCyAEKAIkQgA3AzggBCgCJEIANwMYIAQoAiRCADcDMCAEKAIkQQA2AgAgBCgCJEEANgIEIAQoAiRCADcDCCAEKAIkQgA3AxAgBCgCJEEANgIoIAQoAiRCADcDIAJAIAQpAzBQBEBBCBAYIQAgBCgCJCAANgIEIABFBEAgBCgCJBAVIAQoAihBDkEAEBQgBEEANgI8DAMLIAQoAiQoAgRCADcDAAwBCyAEKAIkIAQpAzBBABDCAUEBcUUEQCAEKAIoQQ5BABAUIAQoAiQQMiAEQQA2AjwMAgsgBEIANwMIIARCADcDGCAEQgA3AxADQCAEKQMYIAQpAzBUBEAgBCgCOCAEKQMYp0EEdGopAwhQRQRAIAQoAjggBCkDGKdBBHRqKAIARQRAIAQoAihBEkEAEBQgBCgCJBAyIARBADYCPAwFCyAEKAIkKAIAIAQpAxCnQQR0aiAEKAI4IAQpAxinQQR0aigCADYCACAEKAIkKAIAIAQpAxCnQQR0aiAEKAI4IAQpAxinQQR0aikDCDcDCCAEKAIkKAIEIAQpAxinQQN0aiAEKQMINwMAIAQgBCgCOCAEKQMYp0EEdGopAwggBCkDCHw3AwggBCAEKQMQQgF8NwMQCyAEIAQpAxhCAXw3AxgMAQsLIAQoAiQgBCkDEDcDCCAEKAIkIAQoAiwEfkIABSAEKAIkKQMICzcDGCAEKAIkKAIEIAQoAiQpAwinQQN0aiAEKQMINwMAIAQoAiQgBCkDCDcDMAsgBCAEKAIkNgI8CyAEKAI8IQAgBEFAayQAIAALngEBAX8jAEEgayIEJAAgBCAANgIYIAQgATcDECAEIAI2AgwgBCADNgIIIAQgBCgCGCAEKQMQIAQoAgwgBCgCCBA/IgA2AgQCQCAARQRAIARBADYCHAwBCyAEIAQoAgQoAjBBACAEKAIMIAQoAggQRiIANgIAIABFBEAgBEEANgIcDAELIAQgBCgCADYCHAsgBCgCHCEAIARBIGokACAAC5wIAQt/IABFBEAgARAYDwsgAUFATwRAQbSbAUEwNgIAQQAPCwJ/QRAgAUELakF4cSABQQtJGyEGIABBCGsiBSgCBCIJQXhxIQQCQCAJQQNxRQRAQQAgBkGAAkkNAhogBkEEaiAETQRAIAUhAiAEIAZrQcSfASgCAEEBdE0NAgtBAAwCCyAEIAVqIQcCQCAEIAZPBEAgBCAGayIDQRBJDQEgBSAJQQFxIAZyQQJyNgIEIAUgBmoiAiADQQNyNgIEIAcgBygCBEEBcjYCBCACIAMQxgEMAQsgB0H8mwEoAgBGBEBB8JsBKAIAIARqIgQgBk0NAiAFIAlBAXEgBnJBAnI2AgQgBSAGaiIDIAQgBmsiAkEBcjYCBEHwmwEgAjYCAEH8mwEgAzYCAAwBCyAHQfibASgCAEYEQEHsmwEoAgAgBGoiAyAGSQ0CAkAgAyAGayICQRBPBEAgBSAJQQFxIAZyQQJyNgIEIAUgBmoiBCACQQFyNgIEIAMgBWoiAyACNgIAIAMgAygCBEF+cTYCBAwBCyAFIAlBAXEgA3JBAnI2AgQgAyAFaiICIAIoAgRBAXI2AgRBACECQQAhBAtB+JsBIAQ2AgBB7JsBIAI2AgAMAQsgBygCBCIDQQJxDQEgA0F4cSAEaiIKIAZJDQEgCiAGayEMAkAgA0H/AU0EQCAHKAIIIgQgA0EDdiICQQN0QYycAWpGGiAEIAcoAgwiA0YEQEHkmwFB5JsBKAIAQX4gAndxNgIADAILIAQgAzYCDCADIAQ2AggMAQsgBygCGCELAkAgByAHKAIMIghHBEAgBygCCCICQfSbASgCAEkaIAIgCDYCDCAIIAI2AggMAQsCQCAHQRRqIgQoAgAiAg0AIAdBEGoiBCgCACICDQBBACEIDAELA0AgBCEDIAIiCEEUaiIEKAIAIgINACAIQRBqIQQgCCgCECICDQALIANBADYCAAsgC0UNAAJAIAcgBygCHCIDQQJ0QZSeAWoiAigCAEYEQCACIAg2AgAgCA0BQeibAUHomwEoAgBBfiADd3E2AgAMAgsgC0EQQRQgCygCECAHRhtqIAg2AgAgCEUNAQsgCCALNgIYIAcoAhAiAgRAIAggAjYCECACIAg2AhgLIAcoAhQiAkUNACAIIAI2AhQgAiAINgIYCyAMQQ9NBEAgBSAJQQFxIApyQQJyNgIEIAUgCmoiAiACKAIEQQFyNgIEDAELIAUgCUEBcSAGckECcjYCBCAFIAZqIgMgDEEDcjYCBCAFIApqIgIgAigCBEEBcjYCBCADIAwQxgELIAUhAgsgAgsiAgRAIAJBCGoPCyABEBgiBUUEQEEADwsgBSAAQXxBeCAAQQRrKAIAIgJBA3EbIAJBeHFqIgIgASABIAJLGxAZGiAAEBUgBQtDAQN/AkAgAkUNAANAIAAtAAAiBCABLQAAIgVGBEAgAUEBaiEBIABBAWohACACQQFrIgINAQwCCwsgBCAFayEDCyADC4wDAQF/IwBBIGsiBCQAIAQgADYCGCAEIAE7ARYgBCACNgIQIAQgAzYCDAJAIAQvARZFBEAgBEEANgIcDAELAkACQAJAAkAgBCgCEEGAMHEiAARAIABBgBBGDQEgAEGAIEYNAgwDCyAEQQA2AgQMAwsgBEECNgIEDAILIARBBDYCBAwBCyAEKAIMQRJBABAUIARBADYCHAwBCyAEQRQQGCIANgIIIABFBEAgBCgCDEEOQQAQFCAEQQA2AhwMAQsgBC8BFkEBahAYIQAgBCgCCCAANgIAIABFBEAgBCgCCBAVIARBADYCHAwBCyAEKAIIKAIAIAQoAhggBC8BFhAZGiAEKAIIKAIAIAQvARZqQQA6AAAgBCgCCCAELwEWOwEEIAQoAghBADYCCCAEKAIIQQA2AgwgBCgCCEEANgIQIAQoAgQEQCAEKAIIIAQoAgQQOkEFRgRAIAQoAggQJCAEKAIMQRJBABAUIARBADYCHAwCCwsgBCAEKAIINgIcCyAEKAIcIQAgBEEgaiQAIAALNwEBfyMAQRBrIgEgADYCCAJAIAEoAghFBEAgAUEAOwEODAELIAEgASgCCC8BBDsBDgsgAS8BDguJAgEBfyMAQRBrIgEkACABIAA2AgwCQCABKAIMLQAFQQFxBEAgASgCDCgCAEECcUUNAQsgASgCDCgCMBAkIAEoAgxBADYCMAsCQCABKAIMLQAFQQFxBEAgASgCDCgCAEEIcUUNAQsgASgCDCgCNBAjIAEoAgxBADYCNAsCQCABKAIMLQAFQQFxBEAgASgCDCgCAEEEcUUNAQsgASgCDCgCOBAkIAEoAgxBADYCOAsCQCABKAIMLQAFQQFxBEAgASgCDCgCAEGAAXFFDQELIAEoAgwoAlQEQCABKAIMKAJUQQAgASgCDCgCVBAuEDMLIAEoAgwoAlQQFSABKAIMQQA2AlQLIAFBEGokAAvxAQEBfyMAQRBrIgEgADYCDCABKAIMQQA2AgAgASgCDEEAOgAEIAEoAgxBADoABSABKAIMQQE6AAYgASgCDEG/BjsBCCABKAIMQQo7AQogASgCDEEAOwEMIAEoAgxBfzYCECABKAIMQQA2AhQgASgCDEEANgIYIAEoAgxCADcDICABKAIMQgA3AyggASgCDEEANgIwIAEoAgxBADYCNCABKAIMQQA2AjggASgCDEEANgI8IAEoAgxBADsBQCABKAIMQYCA2I14NgJEIAEoAgxCADcDSCABKAIMQQA7AVAgASgCDEEAOwFSIAEoAgxBADYCVAvSEwEBfyMAQbABayIDJAAgAyAANgKoASADIAE2AqQBIAMgAjYCoAEgA0EANgKQASADIAMoAqQBKAIwQQAQOjYClAEgAyADKAKkASgCOEEAEDo2ApgBAkACQAJAAkAgAygClAFBAkYEQCADKAKYAUEBRg0BCyADKAKUAUEBRgRAIAMoApgBQQJGDQELIAMoApQBQQJHDQEgAygCmAFBAkcNAQsgAygCpAEiACAALwEMQYAQcjsBDAwBCyADKAKkASIAIAAvAQxB/+8DcTsBDCADKAKUAUECRgRAIANB9eABIAMoAqQBKAIwIAMoAqgBQQhqEI4BNgKQASADKAKQAUUEQCADQX82AqwBDAMLCwJAIAMoAqABQYACcQ0AIAMoApgBQQJHDQAgA0H1xgEgAygCpAEoAjggAygCqAFBCGoQjgE2AkggAygCSEUEQCADKAKQARAjIANBfzYCrAEMAwsgAygCSCADKAKQATYCACADIAMoAkg2ApABCwsCQCADKAKkAS8BUkUEQCADKAKkASIAIAAvAQxB/v8DcTsBDAwBCyADKAKkASIAIAAvAQxBAXI7AQwLIAMgAygCpAEgAygCoAEQZUEBcToAhgEgAyADKAKgAUGACnFBgApHBH8gAy0AhgEFQQELQQFxOgCHASADAn9BASADKAKkAS8BUkGBAkYNABpBASADKAKkAS8BUkGCAkYNABogAygCpAEvAVJBgwJGC0EBcToAhQEgAy0AhwFBAXEEQCADIANBIGpCHBApNgIcIAMoAhxFBEAgAygCqAFBCGpBDkEAEBQgAygCkAEQIyADQX82AqwBDAILAkAgAygCoAFBgAJxBEACQCADKAKgAUGACHENACADKAKkASkDIEL/////D1YNACADKAKkASkDKEL/////D1gNAgsgAygCHCADKAKkASkDKBAtIAMoAhwgAygCpAEpAyAQLQwBCwJAAkAgAygCoAFBgAhxDQAgAygCpAEpAyBC/////w9WDQAgAygCpAEpAyhC/////w9WDQAgAygCpAEpA0hC/////w9YDQELIAMoAqQBKQMoQv////8PWgRAIAMoAhwgAygCpAEpAygQLQsgAygCpAEpAyBC/////w9aBEAgAygCHCADKAKkASkDIBAtCyADKAKkASkDSEL/////D1oEQCADKAIcIAMoAqQBKQNIEC0LCwsCfyMAQRBrIgAgAygCHDYCDCAAKAIMLQAAQQFxRQsEQCADKAKoAUEIakEUQQAQFCADKAIcEBYgAygCkAEQIyADQX82AqwBDAILIANBAQJ/IwBBEGsiACADKAIcNgIMAn4gACgCDC0AAEEBcQRAIAAoAgwpAxAMAQtCAAunQf//A3ELIANBIGpBgAYQVTYCjAEgAygCHBAWIAMoAowBIAMoApABNgIAIAMgAygCjAE2ApABCyADLQCFAUEBcQRAIAMgA0EVakIHECk2AhAgAygCEEUEQCADKAKoAUEIakEOQQAQFCADKAKQARAjIANBfzYCrAEMAgsgAygCEEECEB8gAygCEEG9EkECEEEgAygCECADKAKkAS8BUkH/AXEQlgEgAygCECADKAKkASgCEEH//wNxEB8CfyMAQRBrIgAgAygCEDYCDCAAKAIMLQAAQQFxRQsEQCADKAKoAUEIakEUQQAQFCADKAIQEBYgAygCkAEQIyADQX82AqwBDAILIANBgbICQQcgA0EVakGABhBVNgIMIAMoAhAQFiADKAIMIAMoApABNgIAIAMgAygCDDYCkAELIAMgA0HQAGpCLhApIgA2AkwgAEUEQCADKAKoAUEIakEOQQAQFCADKAKQARAjIANBfzYCrAEMAQsgAygCTEHxEkH2EiADKAKgAUGAAnEbQQQQQSADKAKgAUGAAnFFBEAgAygCTCADLQCGAUEBcQR/QS0FIAMoAqQBLwEIC0H//wNxEB8LIAMoAkwgAy0AhgFBAXEEf0EtBSADKAKkAS8BCgtB//8DcRAfIAMoAkwgAygCpAEvAQwQHwJAIAMtAIUBQQFxBEAgAygCTEHjABAfDAELIAMoAkwgAygCpAEoAhBB//8DcRAfCyADKAKkASgCFCADQZ4BaiADQZwBahCNASADKAJMIAMvAZ4BEB8gAygCTCADLwGcARAfAkACQCADLQCFAUEBcUUNACADKAKkASkDKEIUWg0AIAMoAkxBABAhDAELIAMoAkwgAygCpAEoAhgQIQsCQAJAIAMoAqABQYACcUGAAkcNACADKAKkASkDIEL/////D1QEQCADKAKkASkDKEL/////D1QNAQsgAygCTEF/ECEgAygCTEF/ECEMAQsCQCADKAKkASkDIEL/////D1QEQCADKAJMIAMoAqQBKQMgpxAhDAELIAMoAkxBfxAhCwJAIAMoAqQBKQMoQv////8PVARAIAMoAkwgAygCpAEpAyinECEMAQsgAygCTEF/ECELCyADKAJMIAMoAqQBKAIwEFFB//8DcRAfIAMgAygCpAEoAjQgAygCoAEQkgFB//8DcSADKAKQAUGABhCSAUH//wNxajYCiAEgAygCTCADKAKIAUH//wNxEB8gAygCoAFBgAJxRQRAIAMoAkwgAygCpAEoAjgQUUH//wNxEB8gAygCTCADKAKkASgCPEH//wNxEB8gAygCTCADKAKkAS8BQBAfIAMoAkwgAygCpAEoAkQQIQJAIAMoAqQBKQNIQv////8PVARAIAMoAkwgAygCpAEpA0inECEMAQsgAygCTEF/ECELCwJ/IwBBEGsiACADKAJMNgIMIAAoAgwtAABBAXFFCwRAIAMoAqgBQQhqQRRBABAUIAMoAkwQFiADKAKQARAjIANBfzYCrAEMAQsgAygCqAEgA0HQAGoCfiMAQRBrIgAgAygCTDYCDAJ+IAAoAgwtAABBAXEEQCAAKAIMKQMQDAELQgALCxA2QQBIBEAgAygCTBAWIAMoApABECMgA0F/NgKsAQwBCyADKAJMEBYgAygCpAEoAjAEQCADKAKoASADKAKkASgCMBCFAUEASARAIAMoApABECMgA0F/NgKsAQwCCwsgAygCkAEEQCADKAKoASADKAKQAUGABhCRAUEASARAIAMoApABECMgA0F/NgKsAQwCCwsgAygCkAEQIyADKAKkASgCNARAIAMoAqgBIAMoAqQBKAI0IAMoAqABEJEBQQBIBEAgA0F/NgKsAQwCCwsgAygCoAFBgAJxRQRAIAMoAqQBKAI4BEAgAygCqAEgAygCpAEoAjgQhQFBAEgEQCADQX82AqwBDAMLCwsgAyADLQCHAUEBcTYCrAELIAMoAqwBIQAgA0GwAWokACAAC+ACAQF/IwBBIGsiBCQAIAQgADsBGiAEIAE7ARggBCACNgIUIAQgAzYCECAEQRAQGCIANgIMAkAgAEUEQCAEQQA2AhwMAQsgBCgCDEEANgIAIAQoAgwgBCgCEDYCBCAEKAIMIAQvARo7AQggBCgCDCAELwEYOwEKAkAgBC8BGARAIAQoAhQhASAELwEYIQIjAEEgayIAJAAgACABNgIYIAAgAjYCFCAAQQA2AhACQCAAKAIURQRAIABBADYCHAwBCyAAIAAoAhQQGDYCDCAAKAIMRQRAIAAoAhBBDkEAEBQgAEEANgIcDAELIAAoAgwgACgCGCAAKAIUEBkaIAAgACgCDDYCHAsgACgCHCEBIABBIGokACABIQAgBCgCDCAANgIMIABFBEAgBCgCDBAVIARBADYCHAwDCwwBCyAEKAIMQQA2AgwLIAQgBCgCDDYCHAsgBCgCHCEAIARBIGokACAAC5EBAQV/IAAoAkxBAE4hAyAAKAIAQQFxIgRFBEAgACgCNCIBBEAgASAAKAI4NgI4CyAAKAI4IgIEQCACIAE2AjQLIABBrKABKAIARgRAQaygASACNgIACwsgABClASEBIAAgACgCDBEAACECIAAoAmAiBQRAIAUQFQsCQCAERQRAIAAQFQwBCyADRQ0ACyABIAJyC/kBAQF/IwBBIGsiAiQAIAIgADYCHCACIAE5AxACQCACKAIcRQ0AIAICfAJ8IAIrAxBEAAAAAAAAAABkBEAgAisDEAwBC0QAAAAAAAAAAAtEAAAAAAAA8D9jBEACfCACKwMQRAAAAAAAAAAAZARAIAIrAxAMAQtEAAAAAAAAAAALDAELRAAAAAAAAPA/CyACKAIcKwMoIAIoAhwrAyChoiACKAIcKwMgoDkDCCACKAIcKwMQIAIrAwggAigCHCsDGKFjRQ0AIAIoAhwoAgAgAisDCCACKAIcKAIMIAIoAhwoAgQRFgAgAigCHCACKwMIOQMYCyACQSBqJAAL4QUCAn8BfiMAQTBrIgQkACAEIAA2AiQgBCABNgIgIAQgAjYCHCAEIAM2AhgCQCAEKAIkRQRAIARCfzcDKAwBCyAEKAIgRQRAIAQoAhhBEkEAEBQgBEJ/NwMoDAELIAQoAhxBgyBxBEAgBEEVQRYgBCgCHEEBcRs2AhQgBEIANwMAA0AgBCkDACAEKAIkKQMwVARAIAQgBCgCJCAEKQMAIAQoAhwgBCgCGBBNNgIQIAQoAhAEQCAEKAIcQQJxBEAgBAJ/IAQoAhAiARAuQQFqIQADQEEAIABFDQEaIAEgAEEBayIAaiICLQAAQS9HDQALIAILNgIMIAQoAgwEQCAEIAQoAgxBAWo2AhALCyAEKAIgIAQoAhAgBCgCFBEDAEUEQCMAQRBrIgAgBCgCGDYCDCAAKAIMBEAgACgCDEEANgIAIAAoAgxBADYCBAsgBCAEKQMANwMoDAULCyAEIAQpAwBCAXw3AwAMAQsLIAQoAhhBCUEAEBQgBEJ/NwMoDAELIAQoAiQoAlAhASAEKAIgIQIgBCgCHCEDIAQoAhghBSMAQTBrIgAkACAAIAE2AiQgACACNgIgIAAgAzYCHCAAIAU2AhgCQAJAIAAoAiQEQCAAKAIgDQELIAAoAhhBEkEAEBQgAEJ/NwMoDAELIAAoAiQpAwhCAFIEQCAAIAAoAiAQczYCFCAAIAAoAhQgACgCJCgCAHA2AhAgACAAKAIkKAIQIAAoAhBBAnRqKAIANgIMA0ACQCAAKAIMRQ0AIAAoAiAgACgCDCgCABBbBEAgACAAKAIMKAIYNgIMDAIFIAAoAhxBCHEEQCAAKAIMKQMIQn9SBEAgACAAKAIMKQMINwMoDAYLDAILIAAoAgwpAxBCf1IEQCAAIAAoAgwpAxA3AygMBQsLCwsLIAAoAhhBCUEAEBQgAEJ/NwMoCyAAKQMoIQYgAEEwaiQAIAQgBjcDKAsgBCkDKCEGIARBMGokACAGC9QDAQF/IwBBIGsiAyQAIAMgADYCGCADIAE2AhQgAyACNgIQAkACQCADKAIYBEAgAygCFA0BCyADKAIQQRJBABAUIANBADoAHwwBCyADKAIYKQMIQgBSBEAgAyADKAIUEHM2AgwgAyADKAIMIAMoAhgoAgBwNgIIIANBADYCACADIAMoAhgoAhAgAygCCEECdGooAgA2AgQDQCADKAIEBEACQCADKAIEKAIcIAMoAgxHDQAgAygCFCADKAIEKAIAEFsNAAJAIAMoAgQpAwhCf1EEQAJAIAMoAgAEQCADKAIAIAMoAgQoAhg2AhgMAQsgAygCGCgCECADKAIIQQJ0aiADKAIEKAIYNgIACyADKAIEEBUgAygCGCIAIAApAwhCAX03AwgCQCADKAIYIgApAwi6IAAoAgC4RHsUrkfheoQ/omNFDQAgAygCGCgCAEGAAk0NACADKAIYIAMoAhgoAgBBAXYgAygCEBBaQQFxRQRAIANBADoAHwwICwsMAQsgAygCBEJ/NwMQCyADQQE6AB8MBAsgAyADKAIENgIAIAMgAygCBCgCGDYCBAwBCwsLIAMoAhBBCUEAEBQgA0EAOgAfCyADLQAfQQFxIQAgA0EgaiQAIAAL3wIBAX8jAEEwayIDJAAgAyAANgIoIAMgATYCJCADIAI2AiACQCADKAIkIAMoAigoAgBGBEAgA0EBOgAvDAELIAMgAygCJEEEEH8iADYCHCAARQRAIAMoAiBBDkEAEBQgA0EAOgAvDAELIAMoAigpAwhCAFIEQCADQQA2AhgDQCADKAIYIAMoAigoAgBPRQRAIAMgAygCKCgCECADKAIYQQJ0aigCADYCFANAIAMoAhQEQCADIAMoAhQoAhg2AhAgAyADKAIUKAIcIAMoAiRwNgIMIAMoAhQgAygCHCADKAIMQQJ0aigCADYCGCADKAIcIAMoAgxBAnRqIAMoAhQ2AgAgAyADKAIQNgIUDAELCyADIAMoAhhBAWo2AhgMAQsLCyADKAIoKAIQEBUgAygCKCADKAIcNgIQIAMoAiggAygCJDYCACADQQE6AC8LIAMtAC9BAXEhACADQTBqJAAgAAtNAQJ/IAEtAAAhAgJAIAAtAAAiA0UNACACIANHDQADQCABLQABIQIgAC0AASIDRQ0BIAFBAWohASAAQQFqIQAgAiADRg0ACwsgAyACawvRCQECfyMAQSBrIgEkACABIAA2AhwgASABKAIcKAIsNgIQA0AgASABKAIcKAI8IAEoAhwoAnRrIAEoAhwoAmxrNgIUIAEoAhwoAmwgASgCECABKAIcKAIsQYYCa2pPBEAgASgCHCgCOCABKAIcKAI4IAEoAhBqIAEoAhAgASgCFGsQGRogASgCHCIAIAAoAnAgASgCEGs2AnAgASgCHCIAIAAoAmwgASgCEGs2AmwgASgCHCIAIAAoAlwgASgCEGs2AlwjAEEgayIAIAEoAhw2AhwgACAAKAIcKAIsNgIMIAAgACgCHCgCTDYCGCAAIAAoAhwoAkQgACgCGEEBdGo2AhADQCAAIAAoAhBBAmsiAjYCECAAIAIvAQA2AhQgACgCEAJ/IAAoAhQgACgCDE8EQCAAKAIUIAAoAgxrDAELQQALOwEAIAAgACgCGEEBayICNgIYIAINAAsgACAAKAIMNgIYIAAgACgCHCgCQCAAKAIYQQF0ajYCEANAIAAgACgCEEECayICNgIQIAAgAi8BADYCFCAAKAIQAn8gACgCFCAAKAIMTwRAIAAoAhQgACgCDGsMAQtBAAs7AQAgACAAKAIYQQFrIgI2AhggAg0ACyABIAEoAhAgASgCFGo2AhQLIAEoAhwoAgAoAgQEQCABIAEoAhwoAgAgASgCHCgCdCABKAIcKAI4IAEoAhwoAmxqaiABKAIUEHY2AhggASgCHCIAIAEoAhggACgCdGo2AnQgASgCHCgCdCABKAIcKAK0LWpBA08EQCABIAEoAhwoAmwgASgCHCgCtC1rNgIMIAEoAhwgASgCHCgCOCABKAIMai0AADYCSCABKAIcIAEoAhwoAlQgASgCHCgCOCABKAIMQQFqai0AACABKAIcKAJIIAEoAhwoAlh0c3E2AkgDQCABKAIcKAK0LQRAIAEoAhwgASgCHCgCVCABKAIcKAI4IAEoAgxBAmpqLQAAIAEoAhwoAkggASgCHCgCWHRzcTYCSCABKAIcKAJAIAEoAgwgASgCHCgCNHFBAXRqIAEoAhwoAkQgASgCHCgCSEEBdGovAQA7AQAgASgCHCgCRCABKAIcKAJIQQF0aiABKAIMOwEAIAEgASgCDEEBajYCDCABKAIcIgAgACgCtC1BAWs2ArQtIAEoAhwoAnQgASgCHCgCtC1qQQNPDQELCwsgASgCHCgCdEGGAkkEfyABKAIcKAIAKAIEQQBHBUEAC0EBcQ0BCwsgASgCHCgCwC0gASgCHCgCPEkEQCABIAEoAhwoAmwgASgCHCgCdGo2AggCQCABKAIcKALALSABKAIISQRAIAEgASgCHCgCPCABKAIIazYCBCABKAIEQYICSwRAIAFBggI2AgQLIAEoAhwoAjggASgCCGpBACABKAIEEDMgASgCHCABKAIIIAEoAgRqNgLALQwBCyABKAIcKALALSABKAIIQYICakkEQCABIAEoAghBggJqIAEoAhwoAsAtazYCBCABKAIEIAEoAhwoAjwgASgCHCgCwC1rSwRAIAEgASgCHCgCPCABKAIcKALALWs2AgQLIAEoAhwoAjggASgCHCgCwC1qQQAgASgCBBAzIAEoAhwiACABKAIEIAAoAsAtajYCwC0LCwsgAUEgaiQAC4YFAQF/IwBBIGsiBCQAIAQgADYCHCAEIAE2AhggBCACNgIUIAQgAzYCECAEQQM2AgwCQCAEKAIcKAK8LUEQIAQoAgxrSgRAIAQgBCgCEDYCCCAEKAIcIgAgAC8BuC0gBCgCCEH//wNxIAQoAhwoArwtdHI7AbgtIAQoAhwvAbgtQf8BcSEBIAQoAhwoAgghAiAEKAIcIgMoAhQhACADIABBAWo2AhQgACACaiABOgAAIAQoAhwvAbgtQQh2IQEgBCgCHCgCCCECIAQoAhwiAygCFCEAIAMgAEEBajYCFCAAIAJqIAE6AAAgBCgCHCAEKAIIQf//A3FBECAEKAIcKAK8LWt1OwG4LSAEKAIcIgAgACgCvC0gBCgCDEEQa2o2ArwtDAELIAQoAhwiACAALwG4LSAEKAIQQf//A3EgBCgCHCgCvC10cjsBuC0gBCgCHCIAIAQoAgwgACgCvC1qNgK8LQsgBCgCHBC9ASAEKAIUQf8BcSEBIAQoAhwoAgghAiAEKAIcIgMoAhQhACADIABBAWo2AhQgACACaiABOgAAIAQoAhRB//8DcUEIdiEBIAQoAhwoAgghAiAEKAIcIgMoAhQhACADIABBAWo2AhQgACACaiABOgAAIAQoAhRBf3NB/wFxIQEgBCgCHCgCCCECIAQoAhwiAygCFCEAIAMgAEEBajYCFCAAIAJqIAE6AAAgBCgCFEF/c0H//wNxQQh2IQEgBCgCHCgCCCECIAQoAhwiAygCFCEAIAMgAEEBajYCFCAAIAJqIAE6AAAgBCgCHCgCCCAEKAIcKAIUaiAEKAIYIAQoAhQQGRogBCgCHCIAIAQoAhQgACgCFGo2AhQgBEEgaiQAC6sBAQF/IwBBEGsiASQAIAEgADYCDCABKAIMKAIIBEAgASgCDCgCCBAbIAEoAgxBADYCCAsCQCABKAIMKAIERQ0AIAEoAgwoAgQoAgBBAXFFDQAgASgCDCgCBCgCEEF+Rw0AIAEoAgwoAgQiACAAKAIAQX5xNgIAIAEoAgwoAgQoAgBFBEAgASgCDCgCBBA3IAEoAgxBADYCBAsLIAEoAgxBADoADCABQRBqJAAL8QMBAX8jAEHQAGsiCCQAIAggADYCSCAIIAE3A0AgCCACNwM4IAggAzYCNCAIIAQ6ADMgCCAFNgIsIAggBjcDICAIIAc2AhwCQAJAAkAgCCgCSEUNACAIKQNAIAgpA0AgCCkDOHxWDQAgCCgCLA0BIAgpAyBQDQELIAgoAhxBEkEAEBQgCEEANgJMDAELIAhBgAEQGCIANgIYIABFBEAgCCgCHEEOQQAQFCAIQQA2AkwMAQsgCCgCGCAIKQNANwMAIAgoAhggCCkDQCAIKQM4fDcDCCAIKAIYQShqEDsgCCgCGCAILQAzOgBgIAgoAhggCCgCLDYCECAIKAIYIAgpAyA3AxgjAEEQayIAIAgoAhhB5ABqNgIMIAAoAgxBADYCACAAKAIMQQA2AgQgACgCDEEANgIIIwBBEGsiACAIKAJINgIMIAAoAgwpAxhC/4EBgyEBIAhBfzYCCCAIQQc2AgQgCEEONgIAQRAgCBA0IAGEIQEgCCgCGCABNwNwIAgoAhggCCgCGCkDcELAAINCAFI6AHggCCgCNARAIAgoAhhBKGogCCgCNCAIKAIcEIQBQQBIBEAgCCgCGBAVIAhBADYCTAwCCwsgCCAIKAJIQQEgCCgCGCAIKAIcEIEBNgJMCyAIKAJMIQAgCEHQAGokACAAC9MEAQJ/IwBBMGsiAyQAIAMgADYCJCADIAE3AxggAyACNgIUAkAgAygCJCgCQCADKQMYp0EEdGooAgBFBEAgAygCFEEUQQAQFCADQgA3AygMAQsgAyADKAIkKAJAIAMpAxinQQR0aigCACkDSDcDCCADKAIkKAIAIAMpAwhBABAnQQBIBEAgAygCFCADKAIkKAIAEBcgA0IANwMoDAELIAMoAiQoAgAhAiADKAIUIQQjAEEwayIAJAAgACACNgIoIABBgAI7ASYgACAENgIgIAAgAC8BJkGAAnFBAEc6ABsgAEEeQS4gAC0AG0EBcRs2AhwCQCAAKAIoQRpBHCAALQAbQQFxG6xBARAnQQBIBEAgACgCICAAKAIoEBcgAEF/NgIsDAELIAAgACgCKEEEQQYgAC0AG0EBcRusIABBDmogACgCIBBCIgI2AgggAkUEQCAAQX82AiwMAQsgAEEANgIUA0AgACgCFEECQQMgAC0AG0EBcRtIBEAgACAAKAIIEB1B//8DcSAAKAIcajYCHCAAIAAoAhRBAWo2AhQMAQsLIAAoAggQR0EBcUUEQCAAKAIgQRRBABAUIAAoAggQFiAAQX82AiwMAQsgACgCCBAWIAAgACgCHDYCLAsgACgCLCECIABBMGokACADIAIiADYCBCAAQQBIBEAgA0IANwMoDAELIAMpAwggAygCBK18Qv///////////wBWBEAgAygCFEEEQRYQFCADQgA3AygMAQsgAyADKQMIIAMoAgStfDcDKAsgAykDKCEBIANBMGokACABC20BAX8jAEEgayIEJAAgBCAANgIYIAQgATYCFCAEIAI2AhAgBCADNgIMAkAgBCgCGEUEQCAEQQA2AhwMAQsgBCAEKAIUIAQoAhAgBCgCDCAEKAIYQQhqEIEBNgIcCyAEKAIcIQAgBEEgaiQAIAALVQEBfyMAQRBrIgEkACABIAA2AgwCQAJAIAEoAgwoAiRBAUYNACABKAIMKAIkQQJGDQAMAQsgASgCDEEAQgBBChAgGiABKAIMQQA2AiQLIAFBEGokAAv/AgEBfyMAQTBrIgUkACAFIAA2AiggBSABNgIkIAUgAjYCICAFIAM6AB8gBSAENgIYAkACQCAFKAIgDQAgBS0AH0EBcQ0AIAVBADYCLAwBCyAFIAUoAiAgBS0AH0EBcWoQGDYCFCAFKAIURQRAIAUoAhhBDkEAEBQgBUEANgIsDAELAkAgBSgCKARAIAUgBSgCKCAFKAIgrRAeNgIQIAUoAhBFBEAgBSgCGEEOQQAQFCAFKAIUEBUgBUEANgIsDAMLIAUoAhQgBSgCECAFKAIgEBkaDAELIAUoAiQgBSgCFCAFKAIgrSAFKAIYEGRBAEgEQCAFKAIUEBUgBUEANgIsDAILCyAFLQAfQQFxBEAgBSgCFCAFKAIgakEAOgAAIAUgBSgCFDYCDANAIAUoAgwgBSgCFCAFKAIgakkEQCAFKAIMLQAARQRAIAUoAgxBIDoAAAsgBSAFKAIMQQFqNgIMDAELCwsgBSAFKAIUNgIsCyAFKAIsIQAgBUEwaiQAIAALwgEBAX8jAEEwayIEJAAgBCAANgIoIAQgATYCJCAEIAI3AxggBCADNgIUAkAgBCkDGEL///////////8AVgRAIAQoAhRBFEEAEBQgBEF/NgIsDAELIAQgBCgCKCAEKAIkIAQpAxgQKyICNwMIIAJCAFMEQCAEKAIUIAQoAigQFyAEQX82AiwMAQsgBCkDCCAEKQMYUwRAIAQoAhRBEUEAEBQgBEF/NgIsDAELIARBADYCLAsgBCgCLCEAIARBMGokACAAC3cBAX8jAEEQayICIAA2AgggAiABNgIEAkACQAJAIAIoAggpAyhC/////w9aDQAgAigCCCkDIEL/////D1oNACACKAIEQYAEcUUNASACKAIIKQNIQv////8PVA0BCyACQQE6AA8MAQsgAkEAOgAPCyACLQAPQQFxC/4BAQF/IwBBIGsiBSQAIAUgADYCGCAFIAE2AhQgBSACOwESIAVBADsBECAFIAM2AgwgBSAENgIIIAVBADYCBAJAA0AgBSgCGARAAkAgBSgCGC8BCCAFLwESRw0AIAUoAhgoAgQgBSgCDHFBgAZxRQ0AIAUoAgQgBS8BEEgEQCAFIAUoAgRBAWo2AgQMAQsgBSgCFARAIAUoAhQgBSgCGC8BCjsBAAsgBSgCGC8BCgRAIAUgBSgCGCgCDDYCHAwECyAFQZAVNgIcDAMLIAUgBSgCGCgCADYCGAwBCwsgBSgCCEEJQQAQFCAFQQA2AhwLIAUoAhwhACAFQSBqJAAgAAumAQEBfyMAQRBrIgIkACACIAA2AgggAiABNgIEAkAgAigCCC0AKEEBcQRAIAJBfzYCDAwBCyACKAIIKAIABEAgAigCCCgCACACKAIEEGdBAEgEQCACKAIIQQxqIAIoAggoAgAQFyACQX82AgwMAgsLIAIoAgggAkEEakIEQRMQIEIAUwRAIAJBfzYCDAwBCyACQQA2AgwLIAIoAgwhACACQRBqJAAgAAuNCAIBfwF+IwBBkAFrIgMkACADIAA2AoQBIAMgATYCgAEgAyACNgJ8IAMQUwJAIAMoAoABKQMIQgBSBEAgAyADKAKAASgCACgCACkDSDcDYCADIAMoAoABKAIAKAIAKQNINwNoDAELIANCADcDYCADQgA3A2gLIANCADcDcAJAA0AgAykDcCADKAKAASkDCFQEQCADKAKAASgCACADKQNwp0EEdGooAgApA0ggAykDaFQEQCADIAMoAoABKAIAIAMpA3CnQQR0aigCACkDSDcDaAsgAykDaCADKAKAASkDIFYEQCADKAJ8QRNBABAUIANCfzcDiAEMAwsgAyADKAKAASgCACADKQNwp0EEdGooAgApA0ggAygCgAEoAgAgAykDcKdBBHRqKAIAKQMgfCADKAKAASgCACADKQNwp0EEdGooAgAoAjAQUUH//wNxrXxCHnw3A1ggAykDWCADKQNgVgRAIAMgAykDWDcDYAsgAykDYCADKAKAASkDIFYEQCADKAJ8QRNBABAUIANCfzcDiAEMAwsgAygChAEoAgAgAygCgAEoAgAgAykDcKdBBHRqKAIAKQNIQQAQJ0EASARAIAMoAnwgAygChAEoAgAQFyADQn83A4gBDAMLIAMgAygChAEoAgBBAEEBIAMoAnwQjAFCf1EEQCADEFIgA0J/NwOIAQwDCwJ/IAMoAoABKAIAIAMpA3CnQQR0aigCACEBIwBBEGsiACQAIAAgATYCCCAAIAM2AgQCQAJAAkAgACgCCC8BCiAAKAIELwEKSA0AIAAoAggoAhAgACgCBCgCEEcNACAAKAIIKAIUIAAoAgQoAhRHDQAgACgCCCgCMCAAKAIEKAIwEIYBDQELIABBfzYCDAwBCwJAAkAgACgCCCgCGCAAKAIEKAIYRw0AIAAoAggpAyAgACgCBCkDIFINACAAKAIIKQMoIAAoAgQpAyhRDQELAkACQCAAKAIELwEMQQhxRQ0AIAAoAgQoAhgNACAAKAIEKQMgQgBSDQAgACgCBCkDKFANAQsgAEF/NgIMDAILCyAAQQA2AgwLIAAoAgwhASAAQRBqJAAgAQsEQCADKAJ8QRVBABAUIAMQUiADQn83A4gBDAMFIAMoAoABKAIAIAMpA3CnQQR0aigCACgCNCADKAI0EJUBIQAgAygCgAEoAgAgAykDcKdBBHRqKAIAIAA2AjQgAygCgAEoAgAgAykDcKdBBHRqKAIAQQE6AAQgA0EANgI0IAMQUiADIAMpA3BCAXw3A3AMAgsACwsgAwJ+IAMpA2AgAykDaH1C////////////AFQEQCADKQNgIAMpA2h9DAELQv///////////wALNwOIAQsgAykDiAEhBCADQZABaiQAIAQL1AQBAX8jAEEgayIDJAAgAyAANgIYIAMgATYCFCADIAI2AhAgAygCECEBIwBBEGsiACQAIAAgATYCCCAAQdgAEBg2AgQCQCAAKAIERQRAIAAoAghBDkEAEBQgAEEANgIMDAELIAAoAgghAiMAQRBrIgEkACABIAI2AgggAUEYEBgiAjYCBAJAIAJFBEAgASgCCEEOQQAQFCABQQA2AgwMAQsgASgCBEEANgIAIAEoAgRCADcDCCABKAIEQQA2AhAgASABKAIENgIMCyABKAIMIQIgAUEQaiQAIAAoAgQgAjYCUCACRQRAIAAoAgQQFSAAQQA2AgwMAQsgACgCBEEANgIAIAAoAgRBADYCBCMAQRBrIgEgACgCBEEIajYCDCABKAIMQQA2AgAgASgCDEEANgIEIAEoAgxBADYCCCAAKAIEQQA2AhggACgCBEEANgIUIAAoAgRBADYCHCAAKAIEQQA2AiQgACgCBEEANgIgIAAoAgRBADoAKCAAKAIEQgA3AzggACgCBEIANwMwIAAoAgRBADYCQCAAKAIEQQA2AkggACgCBEEANgJEIAAoAgRBADYCTCAAKAIEQQA2AlQgACAAKAIENgIMCyAAKAIMIQEgAEEQaiQAIAMgASIANgIMAkAgAEUEQCADQQA2AhwMAQsgAygCDCADKAIYNgIAIAMoAgwgAygCFDYCBCADKAIUQRBxBEAgAygCDCIAIAAoAhRBAnI2AhQgAygCDCIAIAAoAhhBAnI2AhgLIAMgAygCDDYCHAsgAygCHCEAIANBIGokACAAC9UBAQF/IwBBIGsiBCQAIAQgADYCGCAEIAE3AxAgBCACNgIMIAQgAzYCCAJAAkAgBCkDEEL///////////8AVwRAIAQpAxBCgICAgICAgICAf1kNAQsgBCgCCEEEQT0QFCAEQX82AhwMAQsCfyAEKQMQIQEgBCgCDCEAIAQoAhgiAigCTEF/TARAIAIgASAAEKABDAELIAIgASAAEKABC0EASARAIAQoAghBBEG0mwEoAgAQFCAEQX82AhwMAQsgBEEANgIcCyAEKAIcIQAgBEEgaiQAIAALJABBACAAEAUiACAAQRtGGyIABH9BtJsBIAA2AgBBAAVBAAsaC3ABAX8jAEEQayIDJAAgAwJ/IAFBwABxRQRAQQAgAUGAgIQCcUGAgIQCRw0BGgsgAyACQQRqNgIMIAIoAgALNgIAIAAgAUGAgAJyIAMQECIAQYFgTwRAQbSbAUEAIABrNgIAQX8hAAsgA0EQaiQAIAALMwEBfwJ/IAAQByIBQWFGBEAgABARIQELIAFBgWBPCwR/QbSbAUEAIAFrNgIAQX8FIAELC2kBAn8CQCAAKAIUIAAoAhxNDQAgAEEAQQAgACgCJBEBABogACgCFA0AQX8PCyAAKAIEIgEgACgCCCICSQRAIAAgASACa6xBASAAKAIoEQ8AGgsgAEEANgIcIABCADcDECAAQgA3AgRBAAvaAwEGfyMAQRBrIgUkACAFIAI2AgwjAEGgAWsiBCQAIARBCGpBkIcBQZABEBkaIAQgADYCNCAEIAA2AhwgBEF+IABrIgNB/////wcgA0H/////B0kbIgY2AjggBCAAIAZqIgA2AiQgBCAANgIYIARBCGohACMAQdABayIDJAAgAyACNgLMASADQaABakEAQSgQMyADIAMoAswBNgLIAQJAQQAgASADQcgBaiADQdAAaiADQaABahBwQQBIDQAgACgCTEEATiEHIAAoAgAhAiAALABKQQBMBEAgACACQV9xNgIACyACQSBxIQgCfyAAKAIwBEAgACABIANByAFqIANB0ABqIANBoAFqEHAMAQsgAEHQADYCMCAAIANB0ABqNgIQIAAgAzYCHCAAIAM2AhQgACgCLCECIAAgAzYCLCAAIAEgA0HIAWogA0HQAGogA0GgAWoQcCACRQ0AGiAAQQBBACAAKAIkEQEAGiAAQQA2AjAgACACNgIsIABBADYCHCAAQQA2AhAgACgCFBogAEEANgIUQQALGiAAIAAoAgAgCHI2AgAgB0UNAAsgA0HQAWokACAGBEAgBCgCHCIAIAAgBCgCGEZrQQA6AAALIARBoAFqJAAgBUEQaiQAC4wSAg9/AX4jAEHQAGsiBSQAIAUgATYCTCAFQTdqIRMgBUE4aiEQQQAhAQNAAkAgDUEASA0AQf////8HIA1rIAFIBEBBtJsBQT02AgBBfyENDAELIAEgDWohDQsgBSgCTCIHIQECQAJAAkACQAJAAkACQAJAIAUCfwJAIActAAAiBgRAA0ACQAJAIAZB/wFxIgZFBEAgASEGDAELIAZBJUcNASABIQYDQCABLQABQSVHDQEgBSABQQJqIgg2AkwgBkEBaiEGIAEtAAIhDiAIIQEgDkElRg0ACwsgBiAHayEBIAAEQCAAIAcgARAiCyABDQ0gBSgCTCEBIAUoAkwsAAFBMGtBCk8NAyABLQACQSRHDQMgASwAAUEwayEPQQEhESABQQNqDAQLIAUgAUEBaiIINgJMIAEtAAEhBiAIIQEMAAsACyANIQsgAA0IIBFFDQJBASEBA0AgBCABQQJ0aigCACIABEAgAyABQQN0aiAAIAIQqAFBASELIAFBAWoiAUEKRw0BDAoLC0EBIQsgAUEKTw0IA0AgBCABQQJ0aigCAA0IIAFBAWoiAUEKRw0ACwwIC0F/IQ8gAUEBagsiATYCTEEAIQgCQCABLAAAIgxBIGsiBkEfSw0AQQEgBnQiBkGJ0QRxRQ0AA0ACQCAFIAFBAWoiCDYCTCABLAABIgxBIGsiAUEgTw0AQQEgAXQiAUGJ0QRxRQ0AIAEgBnIhBiAIIQEMAQsLIAghASAGIQgLAkAgDEEqRgRAIAUCfwJAIAEsAAFBMGtBCk8NACAFKAJMIgEtAAJBJEcNACABLAABQQJ0IARqQcABa0EKNgIAIAEsAAFBA3QgA2pBgANrKAIAIQpBASERIAFBA2oMAQsgEQ0IQQAhEUEAIQogAARAIAIgAigCACIBQQRqNgIAIAEoAgAhCgsgBSgCTEEBagsiATYCTCAKQX9KDQFBACAKayEKIAhBgMAAciEIDAELIAVBzABqEKcBIgpBAEgNBiAFKAJMIQELQX8hCQJAIAEtAABBLkcNACABLQABQSpGBEACQCABLAACQTBrQQpPDQAgBSgCTCIBLQADQSRHDQAgASwAAkECdCAEakHAAWtBCjYCACABLAACQQN0IANqQYADaygCACEJIAUgAUEEaiIBNgJMDAILIBENByAABH8gAiACKAIAIgFBBGo2AgAgASgCAAVBAAshCSAFIAUoAkxBAmoiATYCTAwBCyAFIAFBAWo2AkwgBUHMAGoQpwEhCSAFKAJMIQELQQAhBgNAIAYhEkF/IQsgASwAAEHBAGtBOUsNByAFIAFBAWoiDDYCTCABLAAAIQYgDCEBIAYgEkE6bGpB74IBai0AACIGQQFrQQhJDQALIAZBE0YNAiAGRQ0GIA9BAE4EQCAEIA9BAnRqIAY2AgAgBSADIA9BA3RqKQMANwNADAQLIAANAQtBACELDAULIAVBQGsgBiACEKgBIAUoAkwhDAwCCyAPQX9KDQMLQQAhASAARQ0ECyAIQf//e3EiDiAIIAhBgMAAcRshBkEAIQtBpAghDyAQIQgCQAJAAkACfwJAAkACQAJAAn8CQAJAAkACQAJAAkACQCAMQQFrLAAAIgFBX3EgASABQQ9xQQNGGyABIBIbIgFB2ABrDiEEEhISEhISEhIOEg8GDg4OEgYSEhISAgUDEhIJEgESEgQACwJAIAFBwQBrDgcOEgsSDg4OAAsgAUHTAEYNCQwRCyAFKQNAIRRBpAgMBQtBACEBAkACQAJAAkACQAJAAkAgEkH/AXEOCAABAgMEFwUGFwsgBSgCQCANNgIADBYLIAUoAkAgDTYCAAwVCyAFKAJAIA2sNwMADBQLIAUoAkAgDTsBAAwTCyAFKAJAIA06AAAMEgsgBSgCQCANNgIADBELIAUoAkAgDaw3AwAMEAsgCUEIIAlBCEsbIQkgBkEIciEGQfgAIQELIBAhByABQSBxIQ4gBSkDQCIUUEUEQANAIAdBAWsiByAUp0EPcUGAhwFqLQAAIA5yOgAAIBRCD1YhDCAUQgSIIRQgDA0ACwsgBSkDQFANAyAGQQhxRQ0DIAFBBHZBpAhqIQ9BAiELDAMLIBAhASAFKQNAIhRQRQRAA0AgAUEBayIBIBSnQQdxQTByOgAAIBRCB1YhByAUQgOIIRQgBw0ACwsgASEHIAZBCHFFDQIgCSAQIAdrIgFBAWogASAJSBshCQwCCyAFKQNAIhRCf1cEQCAFQgAgFH0iFDcDQEEBIQtBpAgMAQsgBkGAEHEEQEEBIQtBpQgMAQtBpghBpAggBkEBcSILGwshDyAUIBAQRCEHCyAGQf//e3EgBiAJQX9KGyEGAkAgBSkDQCIUQgBSDQAgCQ0AQQAhCSAQIQcMCgsgCSAUUCAQIAdraiIBIAEgCUgbIQkMCQsgBSgCQCIBQdgSIAEbIgdBACAJEKsBIgEgByAJaiABGyEIIA4hBiABIAdrIAkgARshCQwICyAJBEAgBSgCQAwCC0EAIQEgAEEgIApBACAGECYMAgsgBUEANgIMIAUgBSkDQD4CCCAFIAVBCGo2AkBBfyEJIAVBCGoLIQhBACEBAkADQCAIKAIAIgdFDQECQCAFQQRqIAcQqgEiB0EASCIODQAgByAJIAFrSw0AIAhBBGohCCAJIAEgB2oiAUsNAQwCCwtBfyELIA4NBQsgAEEgIAogASAGECYgAUUEQEEAIQEMAQtBACEIIAUoAkAhDANAIAwoAgAiB0UNASAFQQRqIAcQqgEiByAIaiIIIAFKDQEgACAFQQRqIAcQIiAMQQRqIQwgASAISw0ACwsgAEEgIAogASAGQYDAAHMQJiAKIAEgASAKSBshAQwFCyAAIAUrA0AgCiAJIAYgAUEXERkAIQEMBAsgBSAFKQNAPAA3QQEhCSATIQcgDiEGDAILQX8hCwsgBUHQAGokACALDwsgAEEgIAsgCCAHayIOIAkgCSAOSBsiDGoiCCAKIAggCkobIgEgCCAGECYgACAPIAsQIiAAQTAgASAIIAZBgIAEcxAmIABBMCAMIA5BABAmIAAgByAOECIgAEEgIAEgCCAGQYDAAHMQJgwACwALkAIBA38CQCABIAIoAhAiBAR/IAQFQQAhBAJ/IAIgAi0ASiIDQQFrIANyOgBKIAIoAgAiA0EIcQRAIAIgA0EgcjYCAEF/DAELIAJCADcCBCACIAIoAiwiAzYCHCACIAM2AhQgAiADIAIoAjBqNgIQQQALDQEgAigCEAsgAigCFCIFa0sEQCACIAAgASACKAIkEQEADwsCfyACLABLQX9KBEAgASEEA0AgASAEIgNFDQIaIAAgA0EBayIEai0AAEEKRw0ACyACIAAgAyACKAIkEQEAIgQgA0kNAiAAIANqIQAgAigCFCEFIAEgA2sMAQsgAQshBCAFIAAgBBAZGiACIAIoAhQgBGo2AhQgASEECyAEC0gCAX8BfiMAQRBrIgMkACADIAA2AgwgAyABNgIIIAMgAjYCBCADKAIMIAMoAgggAygCBCADKAIMQQhqEFghBCADQRBqJAAgBAt3AQF/IwBBEGsiASAANgIIIAFChSo3AwACQCABKAIIRQRAIAFBADYCDAwBCwNAIAEoAggtAAAEQCABIAEoAggtAACtIAEpAwBCIX58Qv////8PgzcDACABIAEoAghBAWo2AggMAQsLIAEgASkDAD4CDAsgASgCDAuHBQEBfyMAQTBrIgUkACAFIAA2AiggBSABNgIkIAUgAjcDGCAFIAM2AhQgBSAENgIQAkACQAJAIAUoAihFDQAgBSgCJEUNACAFKQMYQv///////////wBYDQELIAUoAhBBEkEAEBQgBUEAOgAvDAELIAUoAigoAgBFBEAgBSgCKEGAAiAFKAIQEFpBAXFFBEAgBUEAOgAvDAILCyAFIAUoAiQQczYCDCAFIAUoAgwgBSgCKCgCAHA2AgggBSAFKAIoKAIQIAUoAghBAnRqKAIANgIEA0ACQCAFKAIERQ0AAkAgBSgCBCgCHCAFKAIMRw0AIAUoAiQgBSgCBCgCABBbDQACQAJAIAUoAhRBCHEEQCAFKAIEKQMIQn9SDQELIAUoAgQpAxBCf1ENAQsgBSgCEEEKQQAQFCAFQQA6AC8MBAsMAQsgBSAFKAIEKAIYNgIEDAELCyAFKAIERQRAIAVBIBAYIgA2AgQgAEUEQCAFKAIQQQ5BABAUIAVBADoALwwCCyAFKAIEIAUoAiQ2AgAgBSgCBCAFKAIoKAIQIAUoAghBAnRqKAIANgIYIAUoAigoAhAgBSgCCEECdGogBSgCBDYCACAFKAIEIAUoAgw2AhwgBSgCBEJ/NwMIIAUoAigiACAAKQMIQgF8NwMIAkAgBSgCKCIAKQMIuiAAKAIAuEQAAAAAAADoP6JkRQ0AIAUoAigoAgBBgICAgHhPDQAgBSgCKCAFKAIoKAIAQQF0IAUoAhAQWkEBcUUEQCAFQQA6AC8MAwsLCyAFKAIUQQhxBEAgBSgCBCAFKQMYNwMICyAFKAIEIAUpAxg3AxAgBUEBOgAvCyAFLQAvQQFxIQAgBUEwaiQAIAAL1BEBAX8jAEGwAWsiBiQAIAYgADYCqAEgBiABNgKkASAGIAI2AqABIAYgAzYCnAEgBiAENgKYASAGIAU2ApQBIAZBADYCkAEDQCAGKAKQAUEPS0UEQCAGQSBqIAYoApABQQF0akEAOwEAIAYgBigCkAFBAWo2ApABDAELCyAGQQA2AowBA0AgBigCjAEgBigCoAFPRQRAIAZBIGogBigCpAEgBigCjAFBAXRqLwEAQQF0aiIAIAAvAQBBAWo7AQAgBiAGKAKMAUEBajYCjAEMAQsLIAYgBigCmAEoAgA2AoABIAZBDzYChAEDQAJAIAYoAoQBQQFJDQAgBkEgaiAGKAKEAUEBdGovAQANACAGIAYoAoQBQQFrNgKEAQwBCwsgBigCgAEgBigChAFLBEAgBiAGKAKEATYCgAELAkAgBigChAFFBEAgBkHAADoAWCAGQQE6AFkgBkEAOwFaIAYoApwBIgEoAgAhACABIABBBGo2AgAgACAGQdgAaigBADYBACAGKAKcASIBKAIAIQAgASAAQQRqNgIAIAAgBkHYAGooAQA2AQAgBigCmAFBATYCACAGQQA2AqwBDAELIAZBATYCiAEDQAJAIAYoAogBIAYoAoQBTw0AIAZBIGogBigCiAFBAXRqLwEADQAgBiAGKAKIAUEBajYCiAEMAQsLIAYoAoABIAYoAogBSQRAIAYgBigCiAE2AoABCyAGQQE2AnQgBkEBNgKQAQNAIAYoApABQQ9NBEAgBiAGKAJ0QQF0NgJ0IAYgBigCdCAGQSBqIAYoApABQQF0ai8BAGs2AnQgBigCdEEASARAIAZBfzYCrAEMAwUgBiAGKAKQAUEBajYCkAEMAgsACwsCQCAGKAJ0QQBMDQAgBigCqAEEQCAGKAKEAUEBRg0BCyAGQX82AqwBDAELIAZBADsBAiAGQQE2ApABA0AgBigCkAFBD09FBEAgBigCkAFBAWpBAXQgBmogBigCkAFBAXQgBmovAQAgBkEgaiAGKAKQAUEBdGovAQBqOwEAIAYgBigCkAFBAWo2ApABDAELCyAGQQA2AowBA0AgBigCjAEgBigCoAFJBEAgBigCpAEgBigCjAFBAXRqLwEABEAgBigClAEhASAGKAKkASAGKAKMASICQQF0ai8BAEEBdCAGaiIDLwEAIQAgAyAAQQFqOwEAIABB//8DcUEBdCABaiACOwEACyAGIAYoAowBQQFqNgKMAQwBCwsCQAJAAkACQCAGKAKoAQ4CAAECCyAGIAYoApQBIgA2AkwgBiAANgJQIAZBFDYCSAwCCyAGQYDwADYCUCAGQcDwADYCTCAGQYECNgJIDAELIAZBgPEANgJQIAZBwPEANgJMIAZBADYCSAsgBkEANgJsIAZBADYCjAEgBiAGKAKIATYCkAEgBiAGKAKcASgCADYCVCAGIAYoAoABNgJ8IAZBADYCeCAGQX82AmAgBkEBIAYoAoABdDYCcCAGIAYoAnBBAWs2AlwCQAJAIAYoAqgBQQFGBEAgBigCcEHUBksNAQsgBigCqAFBAkcNASAGKAJwQdAETQ0BCyAGQQE2AqwBDAELA0AgBiAGKAKQASAGKAJ4azoAWQJAIAYoAkggBigClAEgBigCjAFBAXRqLwEAQQFqSwRAIAZBADoAWCAGIAYoApQBIAYoAowBQQF0ai8BADsBWgwBCwJAIAYoApQBIAYoAowBQQF0ai8BACAGKAJITwRAIAYgBigCTCAGKAKUASAGKAKMAUEBdGovAQAgBigCSGtBAXRqLwEAOgBYIAYgBigCUCAGKAKUASAGKAKMAUEBdGovAQAgBigCSGtBAXRqLwEAOwFaDAELIAZB4AA6AFggBkEAOwFaCwsgBkEBIAYoApABIAYoAnhrdDYCaCAGQQEgBigCfHQ2AmQgBiAGKAJkNgKIAQNAIAYgBigCZCAGKAJoazYCZCAGKAJUIAYoAmQgBigCbCAGKAJ4dmpBAnRqIAZB2ABqKAEANgEAIAYoAmQNAAsgBkEBIAYoApABQQFrdDYCaANAIAYoAmwgBigCaHEEQCAGIAYoAmhBAXY2AmgMAQsLAkAgBigCaARAIAYgBigCbCAGKAJoQQFrcTYCbCAGIAYoAmggBigCbGo2AmwMAQsgBkEANgJsCyAGIAYoAowBQQFqNgKMASAGQSBqIAYoApABQQF0aiIBLwEAQQFrIQAgASAAOwEAAkAgAEH//wNxRQRAIAYoApABIAYoAoQBRg0BIAYgBigCpAEgBigClAEgBigCjAFBAXRqLwEAQQF0ai8BADYCkAELAkAgBigCkAEgBigCgAFNDQAgBigCYCAGKAJsIAYoAlxxRg0AIAYoAnhFBEAgBiAGKAKAATYCeAsgBiAGKAJUIAYoAogBQQJ0ajYCVCAGIAYoApABIAYoAnhrNgJ8IAZBASAGKAJ8dDYCdANAAkAgBigChAEgBigCfCAGKAJ4ak0NACAGIAYoAnQgBkEgaiAGKAJ8IAYoAnhqQQF0ai8BAGs2AnQgBigCdEEATA0AIAYgBigCfEEBajYCfCAGIAYoAnRBAXQ2AnQMAQsLIAYgBigCcEEBIAYoAnx0ajYCcAJAAkAgBigCqAFBAUYEQCAGKAJwQdQGSw0BCyAGKAKoAUECRw0BIAYoAnBB0ARNDQELIAZBATYCrAEMBAsgBiAGKAJsIAYoAlxxNgJgIAYoApwBKAIAIAYoAmBBAnRqIAYoAnw6AAAgBigCnAEoAgAgBigCYEECdGogBigCgAE6AAEgBigCnAEoAgAgBigCYEECdGogBigCVCAGKAKcASgCAGtBAnU7AQILDAELCyAGKAJsBEAgBkHAADoAWCAGIAYoApABIAYoAnhrOgBZIAZBADsBWiAGKAJUIAYoAmxBAnRqIAZB2ABqKAEANgEACyAGKAKcASIAIAAoAgAgBigCcEECdGo2AgAgBigCmAEgBigCgAE2AgAgBkEANgKsAQsgBigCrAEhACAGQbABaiQAIAALsQIBAX8jAEEgayIDJAAgAyAANgIYIAMgATYCFCADIAI2AhAgAyADKAIYKAIENgIMIAMoAgwgAygCEEsEQCADIAMoAhA2AgwLAkAgAygCDEUEQCADQQA2AhwMAQsgAygCGCIAIAAoAgQgAygCDGs2AgQgAygCFCADKAIYKAIAIAMoAgwQGRoCQCADKAIYKAIcKAIYQQFGBEAgAygCGCgCMCADKAIUIAMoAgwQPSEAIAMoAhggADYCMAwBCyADKAIYKAIcKAIYQQJGBEAgAygCGCgCMCADKAIUIAMoAgwQGiEAIAMoAhggADYCMAsLIAMoAhgiACADKAIMIAAoAgBqNgIAIAMoAhgiACADKAIMIAAoAghqNgIIIAMgAygCDDYCHAsgAygCHCEAIANBIGokACAACzYBAX8jAEEQayIBJAAgASAANgIMIAEoAgwQXiABKAIMKAIAEDcgASgCDCgCBBA3IAFBEGokAAvtAQEBfyMAQRBrIgEgADYCCAJAAkACQCABKAIIRQ0AIAEoAggoAiBFDQAgASgCCCgCJA0BCyABQQE2AgwMAQsgASABKAIIKAIcNgIEAkACQCABKAIERQ0AIAEoAgQoAgAgASgCCEcNACABKAIEKAIEQSpGDQEgASgCBCgCBEE5Rg0BIAEoAgQoAgRBxQBGDQEgASgCBCgCBEHJAEYNASABKAIEKAIEQdsARg0BIAEoAgQoAgRB5wBGDQEgASgCBCgCBEHxAEYNASABKAIEKAIEQZoFRg0BCyABQQE2AgwMAQsgAUEANgIMCyABKAIMC9IEAQF/IwBBIGsiAyAANgIcIAMgATYCGCADIAI2AhQgAyADKAIcQdwWaiADKAIUQQJ0aigCADYCECADIAMoAhRBAXQ2AgwDQAJAIAMoAgwgAygCHCgC0ChKDQACQCADKAIMIAMoAhwoAtAoTg0AIAMoAhggAygCHCADKAIMQQJ0akHgFmooAgBBAnRqLwEAIAMoAhggAygCHEHcFmogAygCDEECdGooAgBBAnRqLwEATgRAIAMoAhggAygCHCADKAIMQQJ0akHgFmooAgBBAnRqLwEAIAMoAhggAygCHEHcFmogAygCDEECdGooAgBBAnRqLwEARw0BIAMoAhwgAygCDEECdGpB4BZqKAIAIAMoAhxB2Chqai0AACADKAIcQdwWaiADKAIMQQJ0aigCACADKAIcQdgoamotAABKDQELIAMgAygCDEEBajYCDAsgAygCGCADKAIQQQJ0ai8BACADKAIYIAMoAhxB3BZqIAMoAgxBAnRqKAIAQQJ0ai8BAEgNAAJAIAMoAhggAygCEEECdGovAQAgAygCGCADKAIcQdwWaiADKAIMQQJ0aigCAEECdGovAQBHDQAgAygCECADKAIcQdgoamotAAAgAygCHEHcFmogAygCDEECdGooAgAgAygCHEHYKGpqLQAASg0ADAELIAMoAhxB3BZqIAMoAhRBAnRqIAMoAhxB3BZqIAMoAgxBAnRqKAIANgIAIAMgAygCDDYCFCADIAMoAgxBAXQ2AgwMAQsLIAMoAhxB3BZqIAMoAhRBAnRqIAMoAhA2AgAL1xMBA38jAEEwayICJAAgAiAANgIsIAIgATYCKCACIAIoAigoAgA2AiQgAiACKAIoKAIIKAIANgIgIAIgAigCKCgCCCgCDDYCHCACQX82AhAgAigCLEEANgLQKCACKAIsQb0ENgLUKCACQQA2AhgDQCACKAIYIAIoAhxIBEACQCACKAIkIAIoAhhBAnRqLwEABEAgAiACKAIYIgE2AhAgAigCLEHcFmohAyACKAIsIgQoAtAoQQFqIQAgBCAANgLQKCAAQQJ0IANqIAE2AgAgAigCGCACKAIsQdgoampBADoAAAwBCyACKAIkIAIoAhhBAnRqQQA7AQILIAIgAigCGEEBajYCGAwBCwsDQCACKAIsKALQKEECSARAAkAgAigCEEECSARAIAIgAigCEEEBaiIANgIQDAELQQAhAAsgAigCLEHcFmohAyACKAIsIgQoAtAoQQFqIQEgBCABNgLQKCABQQJ0IANqIAA2AgAgAiAANgIMIAIoAiQgAigCDEECdGpBATsBACACKAIMIAIoAixB2ChqakEAOgAAIAIoAiwiACAAKAKoLUEBazYCqC0gAigCIARAIAIoAiwiACAAKAKsLSACKAIgIAIoAgxBAnRqLwECazYCrC0LDAELCyACKAIoIAIoAhA2AgQgAiACKAIsKALQKEECbTYCGANAIAIoAhhBAU4EQCACKAIsIAIoAiQgAigCGBB5IAIgAigCGEEBazYCGAwBCwsgAiACKAIcNgIMA0AgAiACKAIsKALgFjYCGCACKAIsQdwWaiEBIAIoAiwiAygC0CghACADIABBAWs2AtAoIAIoAiwgAEECdCABaigCADYC4BYgAigCLCACKAIkQQEQeSACIAIoAiwoAuAWNgIUIAIoAhghASACKAIsQdwWaiEDIAIoAiwiBCgC1ChBAWshACAEIAA2AtQoIABBAnQgA2ogATYCACACKAIUIQEgAigCLEHcFmohAyACKAIsIgQoAtQoQQFrIQAgBCAANgLUKCAAQQJ0IANqIAE2AgAgAigCJCACKAIMQQJ0aiACKAIkIAIoAhhBAnRqLwEAIAIoAiQgAigCFEECdGovAQBqOwEAIAIoAgwgAigCLEHYKGpqAn8gAigCGCACKAIsQdgoamotAAAgAigCFCACKAIsQdgoamotAABOBEAgAigCGCACKAIsQdgoamotAAAMAQsgAigCFCACKAIsQdgoamotAAALQQFqOgAAIAIoAiQgAigCFEECdGogAigCDCIAOwECIAIoAiQgAigCGEECdGogADsBAiACIAIoAgwiAEEBajYCDCACKAIsIAA2AuAWIAIoAiwgAigCJEEBEHkgAigCLCgC0ChBAk4NAAsgAigCLCgC4BYhASACKAIsQdwWaiEDIAIoAiwiBCgC1ChBAWshACAEIAA2AtQoIABBAnQgA2ogATYCACACKAIoIQEjAEFAaiIAIAIoAiw2AjwgACABNgI4IAAgACgCOCgCADYCNCAAIAAoAjgoAgQ2AjAgACAAKAI4KAIIKAIANgIsIAAgACgCOCgCCCgCBDYCKCAAIAAoAjgoAggoAgg2AiQgACAAKAI4KAIIKAIQNgIgIABBADYCBCAAQQA2AhADQCAAKAIQQQ9MBEAgACgCPEG8FmogACgCEEEBdGpBADsBACAAIAAoAhBBAWo2AhAMAQsLIAAoAjQgACgCPEHcFmogACgCPCgC1ChBAnRqKAIAQQJ0akEAOwECIAAgACgCPCgC1ChBAWo2AhwDQCAAKAIcQb0ESARAIAAgACgCPEHcFmogACgCHEECdGooAgA2AhggACAAKAI0IAAoAjQgACgCGEECdGovAQJBAnRqLwECQQFqNgIQIAAoAhAgACgCIEoEQCAAIAAoAiA2AhAgACAAKAIEQQFqNgIECyAAKAI0IAAoAhhBAnRqIAAoAhA7AQIgACgCGCAAKAIwTARAIAAoAjwgACgCEEEBdGpBvBZqIgEgAS8BAEEBajsBACAAQQA2AgwgACgCGCAAKAIkTgRAIAAgACgCKCAAKAIYIAAoAiRrQQJ0aigCADYCDAsgACAAKAI0IAAoAhhBAnRqLwEAOwEKIAAoAjwiASABKAKoLSAALwEKIAAoAhAgACgCDGpsajYCqC0gACgCLARAIAAoAjwiASABKAKsLSAALwEKIAAoAiwgACgCGEECdGovAQIgACgCDGpsajYCrC0LCyAAIAAoAhxBAWo2AhwMAQsLAkAgACgCBEUNAANAIAAgACgCIEEBazYCEANAIAAoAjxBvBZqIAAoAhBBAXRqLwEARQRAIAAgACgCEEEBazYCEAwBCwsgACgCPCAAKAIQQQF0akG8FmoiASABLwEAQQFrOwEAIAAoAjwgACgCEEEBdGpBvhZqIgEgAS8BAEECajsBACAAKAI8IAAoAiBBAXRqQbwWaiIBIAEvAQBBAWs7AQAgACAAKAIEQQJrNgIEIAAoAgRBAEoNAAsgACAAKAIgNgIQA0AgACgCEEUNASAAIAAoAjxBvBZqIAAoAhBBAXRqLwEANgIYA0AgACgCGARAIAAoAjxB3BZqIQEgACAAKAIcQQFrIgM2AhwgACADQQJ0IAFqKAIANgIUIAAoAhQgACgCMEoNASAAKAI0IAAoAhRBAnRqLwECIAAoAhBHBEAgACgCPCIBIAEoAqgtIAAoAjQgACgCFEECdGovAQAgACgCECAAKAI0IAAoAhRBAnRqLwECa2xqNgKoLSAAKAI0IAAoAhRBAnRqIAAoAhA7AQILIAAgACgCGEEBazYCGAwBCwsgACAAKAIQQQFrNgIQDAALAAsgAigCJCEBIAIoAhAhAyACKAIsQbwWaiEEIwBBQGoiACQAIAAgATYCPCAAIAM2AjggACAENgI0IABBADYCDCAAQQE2AggDQCAAKAIIQQ9MBEAgACAAKAIMIAAoAjQgACgCCEEBa0EBdGovAQBqQQF0NgIMIABBEGogACgCCEEBdGogACgCDDsBACAAIAAoAghBAWo2AggMAQsLIABBADYCBANAIAAoAgQgACgCOEwEQCAAIAAoAjwgACgCBEECdGovAQI2AgAgACgCAARAIABBEGogACgCAEEBdGoiAS8BACEDIAEgA0EBajsBACAAKAIAIQQjAEEQayIBIAM2AgwgASAENgIIIAFBADYCBANAIAEgASgCBCABKAIMQQFxcjYCBCABIAEoAgxBAXY2AgwgASABKAIEQQF0NgIEIAEgASgCCEEBayIDNgIIIANBAEoNAAsgASgCBEEBdiEBIAAoAjwgACgCBEECdGogATsBAAsgACAAKAIEQQFqNgIEDAELCyAAQUBrJAAgAkEwaiQAC04BAX8jAEEQayICIAA7AQogAiABNgIEAkAgAi8BCkEBRgRAIAIoAgRBAUYEQCACQQA2AgwMAgsgAkEENgIMDAELIAJBADYCDAsgAigCDAvOAgEBfyMAQTBrIgUkACAFIAA2AiwgBSABNgIoIAUgAjYCJCAFIAM3AxggBSAENgIUIAVCADcDCANAIAUpAwggBSkDGFQEQCAFIAUoAiQgBSkDCKdqLQAAOgAHIAUoAhRFBEAgBSAFKAIsKAIUQQJyOwESIAUgBS8BEiAFLwESQQFzbEEIdjsBEiAFIAUtAAcgBS8BEkH/AXFzOgAHCyAFKAIoBEAgBSgCKCAFKQMIp2ogBS0ABzoAAAsgBSgCLCgCDEF/cyAFQQdqQQEQGkF/cyEAIAUoAiwgADYCDCAFKAIsIAUoAiwoAhAgBSgCLCgCDEH/AXFqQYWIosAAbEEBajYCECAFIAUoAiwoAhBBGHY6AAcgBSgCLCgCFEF/cyAFQQdqQQEQGkF/cyEAIAUoAiwgADYCFCAFIAUpAwhCAXw3AwgMAQsLIAVBMGokAAttAQF/IwBBIGsiBCQAIAQgADYCGCAEIAE2AhQgBCACNwMIIAQgAzYCBAJAIAQoAhhFBEAgBEEANgIcDAELIAQgBCgCFCAEKQMIIAQoAgQgBCgCGEEIahDEATYCHAsgBCgCHCEAIARBIGokACAAC6cDAQF/IwBBIGsiBCQAIAQgADYCGCAEIAE3AxAgBCACNgIMIAQgAzYCCCAEIAQoAhggBCkDECAEKAIMQQAQPyIANgIAAkAgAEUEQCAEQX82AhwMAQsgBCAEKAIYIAQpAxAgBCgCDBDFASIANgIEIABFBEAgBEF/NgIcDAELAkACQCAEKAIMQQhxDQAgBCgCGCgCQCAEKQMQp0EEdGooAghFDQAgBCgCGCgCQCAEKQMQp0EEdGooAgggBCgCCBA5QQBIBEAgBCgCGEEIakEPQQAQFCAEQX82AhwMAwsMAQsgBCgCCBA7IAQoAgggBCgCACgCGDYCLCAEKAIIIAQoAgApAyg3AxggBCgCCCAEKAIAKAIUNgIoIAQoAgggBCgCACkDIDcDICAEKAIIIAQoAgAoAhA7ATAgBCgCCCAEKAIALwFSOwEyIAQoAghBIEEAIAQoAgAtAAZBAXEbQdwBcq03AwALIAQoAgggBCkDEDcDECAEKAIIIAQoAgQ2AgggBCgCCCIAIAApAwBCA4Q3AwAgBEEANgIcCyAEKAIcIQAgBEEgaiQAIAALWQIBfwF+AkACf0EAIABFDQAaIACtIAGtfiIDpyICIAAgAXJBgIAESQ0AGkF/IAIgA0IgiKcbCyICEBgiAEUNACAAQQRrLQAAQQNxRQ0AIABBACACEDMLIAALAwABC+oBAgF/AX4jAEEgayIEJAAgBCAANgIYIAQgATYCFCAEIAI2AhAgBCADNgIMIAQgBCgCDBCCASIANgIIAkAgAEUEQCAEQQA2AhwMAQsjAEEQayIAIAQoAhg2AgwgACgCDCIAIAAoAjBBAWo2AjAgBCgCCCAEKAIYNgIAIAQoAgggBCgCFDYCBCAEKAIIIAQoAhA2AgggBCgCGCAEKAIQQQBCAEEOIAQoAhQRCgAhBSAEKAIIIAU3AxggBCgCCCkDGEIAUwRAIAQoAghCPzcDGAsgBCAEKAIINgIcCyAEKAIcIQAgBEEgaiQAIAAL6gEBAX8jAEEQayIBJAAgASAANgIIIAFBOBAYIgA2AgQCQCAARQRAIAEoAghBDkEAEBQgAUEANgIMDAELIAEoAgRBADYCACABKAIEQQA2AgQgASgCBEEANgIIIAEoAgRBADYCICABKAIEQQA2AiQgASgCBEEAOgAoIAEoAgRBADYCLCABKAIEQQE2AjAjAEEQayIAIAEoAgRBDGo2AgwgACgCDEEANgIAIAAoAgxBADYCBCAAKAIMQQA2AgggASgCBEEAOgA0IAEoAgRBADoANSABIAEoAgQ2AgwLIAEoAgwhACABQRBqJAAgAAuwAQIBfwF+IwBBIGsiAyQAIAMgADYCGCADIAE2AhQgAyACNgIQIAMgAygCEBCCASIANgIMAkAgAEUEQCADQQA2AhwMAQsgAygCDCADKAIYNgIEIAMoAgwgAygCFDYCCCADKAIUQQBCAEEOIAMoAhgRDgAhBCADKAIMIAQ3AxggAygCDCkDGEIAUwRAIAMoAgxCPzcDGAsgAyADKAIMNgIcCyADKAIcIQAgA0EgaiQAIAALwwIBAX8jAEEQayIDIAA2AgwgAyABNgIIIAMgAjYCBCADKAIIKQMAQgKDQgBSBEAgAygCDCADKAIIKQMQNwMQCyADKAIIKQMAQgSDQgBSBEAgAygCDCADKAIIKQMYNwMYCyADKAIIKQMAQgiDQgBSBEAgAygCDCADKAIIKQMgNwMgCyADKAIIKQMAQhCDQgBSBEAgAygCDCADKAIIKAIoNgIoCyADKAIIKQMAQiCDQgBSBEAgAygCDCADKAIIKAIsNgIsCyADKAIIKQMAQsAAg0IAUgRAIAMoAgwgAygCCC8BMDsBMAsgAygCCCkDAEKAAYNCAFIEQCADKAIMIAMoAggvATI7ATILIAMoAggpAwBCgAKDQgBSBEAgAygCDCADKAIIKAI0NgI0CyADKAIMIgAgAygCCCkDACAAKQMAhDcDAEEAC10BAX8jAEEQayICJAAgAiAANgIIIAIgATYCBAJAIAIoAgRFBEAgAkEANgIMDAELIAIgAigCCCACKAIEKAIAIAIoAgQvAQStEDY2AgwLIAIoAgwhACACQRBqJAAgAAuPAQEBfyMAQRBrIgIkACACIAA2AgggAiABNgIEAkACQCACKAIIBEAgAigCBA0BCyACIAIoAgggAigCBEY2AgwMAQsgAigCCC8BBCACKAIELwEERwRAIAJBADYCDAwBCyACIAIoAggoAgAgAigCBCgCACACKAIILwEEEE9FNgIMCyACKAIMIQAgAkEQaiQAIAALVQEBfyMAQRBrIgEkACABIAA2AgwgAUEAQQBBABAaNgIIIAEoAgwEQCABIAEoAgggASgCDCgCACABKAIMLwEEEBo2AggLIAEoAgghACABQRBqJAAgAAufAgEBfyMAQUBqIgUkACAFIAA3AzAgBSABNwMoIAUgAjYCJCAFIAM3AxggBSAENgIUIAUCfyAFKQMYQhBUBEAgBSgCFEESQQAQFEEADAELIAUoAiQLNgIEAkAgBSgCBEUEQCAFQn83AzgMAQsCQAJAAkACQAJAIAUoAgQoAggOAwIAAQMLIAUgBSkDMCAFKAIEKQMAfDcDCAwDCyAFIAUpAyggBSgCBCkDAHw3AwgMAgsgBSAFKAIEKQMANwMIDAELIAUoAhRBEkEAEBQgBUJ/NwM4DAELAkAgBSkDCEIAWQRAIAUpAwggBSkDKFgNAQsgBSgCFEESQQAQFCAFQn83AzgMAQsgBSAFKQMINwM4CyAFKQM4IQAgBUFAayQAIAALoAEBAX8jAEEgayIFJAAgBSAANgIYIAUgATYCFCAFIAI7ARIgBSADOgARIAUgBDYCDCAFIAUoAhggBSgCFCAFLwESIAUtABFBAXEgBSgCDBBjIgA2AggCQCAARQRAIAVBADYCHAwBCyAFIAUoAgggBS8BEkEAIAUoAgwQUDYCBCAFKAIIEBUgBSAFKAIENgIcCyAFKAIcIQAgBUEgaiQAIAALpgEBAX8jAEEgayIFJAAgBSAANgIYIAUgATcDECAFIAI2AgwgBSADNgIIIAUgBDYCBCAFIAUoAhggBSkDECAFKAIMQQAQPyIANgIAAkAgAEUEQCAFQX82AhwMAQsgBSgCCARAIAUoAgggBSgCAC8BCEEIdjoAAAsgBSgCBARAIAUoAgQgBSgCACgCRDYCAAsgBUEANgIcCyAFKAIcIQAgBUEgaiQAIAALjQIBAX8jAEEwayIDJAAgAyAANgIoIAMgATsBJiADIAI2AiAgAyADKAIoKAI0IANBHmogAy8BJkGABkEAEGY2AhACQCADKAIQRQ0AIAMvAR5BBUkNAAJAIAMoAhAtAABBAUYNAAwBCyADIAMoAhAgAy8BHq0QKSIANgIUIABFBEAMAQsgAygCFBCXARogAyADKAIUECo2AhggAygCIBCHASADKAIYRgRAIAMgAygCFBAwPQEOIAMgAygCFCADLwEOrRAeIAMvAQ5BgBBBABBQNgIIIAMoAggEQCADKAIgECQgAyADKAIINgIgCwsgAygCFBAWCyADIAMoAiA2AiwgAygCLCEAIANBMGokACAAC9oXAgF/AX4jAEGAAWsiBSQAIAUgADYCdCAFIAE2AnAgBSACNgJsIAUgAzoAayAFIAQ2AmQgBSAFKAJsQQBHOgAdIAVBHkEuIAUtAGtBAXEbNgIoAkACQCAFKAJsBEAgBSgCbBAwIAUoAiitVARAIAUoAmRBE0EAEBQgBUJ/NwN4DAMLDAELIAUgBSgCcCAFKAIorSAFQTBqIAUoAmQQQiIANgJsIABFBEAgBUJ/NwN4DAILCyAFKAJsQgQQHiEAQfESQfYSIAUtAGtBAXEbKAAAIAAoAABHBEAgBSgCZEETQQAQFCAFLQAdQQFxRQRAIAUoAmwQFgsgBUJ/NwN4DAELIAUoAnQQUwJAIAUtAGtBAXFFBEAgBSgCbBAdIQAgBSgCdCAAOwEIDAELIAUoAnRBADsBCAsgBSgCbBAdIQAgBSgCdCAAOwEKIAUoAmwQHSEAIAUoAnQgADsBDCAFKAJsEB1B//8DcSEAIAUoAnQgADYCECAFIAUoAmwQHTsBLiAFIAUoAmwQHTsBLCAFLwEuIQEgBS8BLCECIwBBMGsiACQAIAAgATsBLiAAIAI7ASwgAEIANwIAIABBADYCKCAAQgA3AiAgAEIANwIYIABCADcCECAAQgA3AgggAEEANgIgIAAgAC8BLEEJdkHQAGo2AhQgACAALwEsQQV2QQ9xQQFrNgIQIAAgAC8BLEEfcTYCDCAAIAAvAS5BC3Y2AgggACAALwEuQQV2QT9xNgIEIAAgAC8BLkEBdEE+cTYCACAAEBMhASAAQTBqJAAgASEAIAUoAnQgADYCFCAFKAJsECohACAFKAJ0IAA2AhggBSgCbBAqrSEGIAUoAnQgBjcDICAFKAJsECqtIQYgBSgCdCAGNwMoIAUgBSgCbBAdOwEiIAUgBSgCbBAdOwEeAkAgBS0Aa0EBcQRAIAVBADsBICAFKAJ0QQA2AjwgBSgCdEEAOwFAIAUoAnRBADYCRCAFKAJ0QgA3A0gMAQsgBSAFKAJsEB07ASAgBSgCbBAdQf//A3EhACAFKAJ0IAA2AjwgBSgCbBAdIQAgBSgCdCAAOwFAIAUoAmwQKiEAIAUoAnQgADYCRCAFKAJsECqtIQYgBSgCdCAGNwNICwJ/IwBBEGsiACAFKAJsNgIMIAAoAgwtAABBAXFFCwRAIAUoAmRBFEEAEBQgBS0AHUEBcUUEQCAFKAJsEBYLIAVCfzcDeAwBCwJAIAUoAnQvAQxBAXEEQCAFKAJ0LwEMQcAAcQRAIAUoAnRB//8DOwFSDAILIAUoAnRBATsBUgwBCyAFKAJ0QQA7AVILIAUoAnRBADYCMCAFKAJ0QQA2AjQgBSgCdEEANgI4IAUgBS8BICAFLwEiIAUvAR5qajYCJAJAIAUtAB1BAXEEQCAFKAJsEDAgBSgCJK1UBEAgBSgCZEEVQQAQFCAFQn83A3gMAwsMAQsgBSgCbBAWIAUgBSgCcCAFKAIkrUEAIAUoAmQQQiIANgJsIABFBEAgBUJ/NwN4DAILCyAFLwEiBEAgBSgCbCAFKAJwIAUvASJBASAFKAJkEIkBIQAgBSgCdCAANgIwIAUoAnQoAjBFBEACfyMAQRBrIgAgBSgCZDYCDCAAKAIMKAIAQRFGCwRAIAUoAmRBFUEAEBQLIAUtAB1BAXFFBEAgBSgCbBAWCyAFQn83A3gMAgsgBSgCdC8BDEGAEHEEQCAFKAJ0KAIwQQIQOkEFRgRAIAUoAmRBFUEAEBQgBS0AHUEBcUUEQCAFKAJsEBYLIAVCfzcDeAwDCwsLIAUvAR4EQCAFIAUoAmwgBSgCcCAFLwEeQQAgBSgCZBBjNgIYIAUoAhhFBEAgBS0AHUEBcUUEQCAFKAJsEBYLIAVCfzcDeAwCCyAFKAIYIAUvAR5BgAJBgAQgBS0Aa0EBcRsgBSgCdEE0aiAFKAJkEJQBQQFxRQRAIAUoAhgQFSAFLQAdQQFxRQRAIAUoAmwQFgsgBUJ/NwN4DAILIAUoAhgQFSAFLQBrQQFxBEAgBSgCdEEBOgAECwsgBS8BIARAIAUoAmwgBSgCcCAFLwEgQQAgBSgCZBCJASEAIAUoAnQgADYCOCAFKAJ0KAI4RQRAIAUtAB1BAXFFBEAgBSgCbBAWCyAFQn83A3gMAgsgBSgCdC8BDEGAEHEEQCAFKAJ0KAI4QQIQOkEFRgRAIAUoAmRBFUEAEBQgBS0AHUEBcUUEQCAFKAJsEBYLIAVCfzcDeAwDCwsLIAUoAnRB9eABIAUoAnQoAjAQiwEhACAFKAJ0IAA2AjAgBSgCdEH1xgEgBSgCdCgCOBCLASEAIAUoAnQgADYCOAJAAkAgBSgCdCkDKEL/////D1ENACAFKAJ0KQMgQv////8PUQ0AIAUoAnQpA0hC/////w9SDQELIAUgBSgCdCgCNCAFQRZqQQFBgAJBgAQgBS0Aa0EBcRsgBSgCZBBmNgIMIAUoAgxFBEAgBS0AHUEBcUUEQCAFKAJsEBYLIAVCfzcDeAwCCyAFIAUoAgwgBS8BFq0QKSIANgIQIABFBEAgBSgCZEEOQQAQFCAFLQAdQQFxRQRAIAUoAmwQFgsgBUJ/NwN4DAILAkAgBSgCdCkDKEL/////D1EEQCAFKAIQEDEhBiAFKAJ0IAY3AygMAQsgBS0Aa0EBcQRAIAUoAhAhASMAQSBrIgAkACAAIAE2AhggAEIINwMQIAAgACgCGCkDECAAKQMQfDcDCAJAIAApAwggACgCGCkDEFQEQCAAKAIYQQA6AAAgAEF/NgIcDAELIAAgACgCGCAAKQMIECw2AhwLIAAoAhwaIABBIGokAAsLIAUoAnQpAyBC/////w9RBEAgBSgCEBAxIQYgBSgCdCAGNwMgCyAFLQBrQQFxRQRAIAUoAnQpA0hC/////w9RBEAgBSgCEBAxIQYgBSgCdCAGNwNICyAFKAJ0KAI8Qf//A0YEQCAFKAIQECohACAFKAJ0IAA2AjwLCyAFKAIQEEdBAXFFBEAgBSgCZEEVQQAQFCAFKAIQEBYgBS0AHUEBcUUEQCAFKAJsEBYLIAVCfzcDeAwCCyAFKAIQEBYLAn8jAEEQayIAIAUoAmw2AgwgACgCDC0AAEEBcUULBEAgBSgCZEEUQQAQFCAFLQAdQQFxRQRAIAUoAmwQFgsgBUJ/NwN4DAELIAUtAB1BAXFFBEAgBSgCbBAWCyAFKAJ0KQNIQv///////////wBWBEAgBSgCZEEEQRYQFCAFQn83A3gMAQsCfyAFKAJ0IQEgBSgCZCECIwBBIGsiACQAIAAgATYCGCAAIAI2AhQCQCAAKAIYKAIQQeMARwRAIABBAToAHwwBCyAAIAAoAhgoAjQgAEESakGBsgJBgAZBABBmNgIIAkAgACgCCARAIAAvARJBB08NAQsgACgCFEEVQQAQFCAAQQA6AB8MAQsgACAAKAIIIAAvARKtECkiATYCDCABRQRAIAAoAhRBFEEAEBQgAEEAOgAfDAELIABBAToABwJAAkACQCAAKAIMEB1BAWsOAgIAAQsgACgCGCkDKEIUVARAIABBADoABwsMAQsgACgCFEEYQQAQFCAAKAIMEBYgAEEAOgAfDAELIAAoAgxCAhAeLwAAQcGKAUcEQCAAKAIUQRhBABAUIAAoAgwQFiAAQQA6AB8MAQsCQAJAAkACQAJAIAAoAgwQlwFBAWsOAwABAgMLIABBgQI7AQQMAwsgAEGCAjsBBAwCCyAAQYMCOwEEDAELIAAoAhRBGEEAEBQgACgCDBAWIABBADoAHwwBCyAALwESQQdHBEAgACgCFEEVQQAQFCAAKAIMEBYgAEEAOgAfDAELIAAoAhggAC0AB0EBcToABiAAKAIYIAAvAQQ7AVIgACgCDBAdQf//A3EhASAAKAIYIAE2AhAgACgCDBAWIABBAToAHwsgAC0AH0EBcSEBIABBIGokACABQQFxRQsEQCAFQn83A3gMAQsgBSgCdCgCNBCTASEAIAUoAnQgADYCNCAFIAUoAiggBSgCJGqtNwN4CyAFKQN4IQYgBUGAAWokACAGC80BAQF/IwBBEGsiAyQAIAMgADYCDCADIAE2AgggAyACNgIEIAMgA0EMakG4mwEQEjYCAAJAIAMoAgBFBEAgAygCBEEhOwEAIAMoAghBADsBAAwBCyADKAIAKAIUQdAASARAIAMoAgBB0AA2AhQLIAMoAgQgAygCACgCDCADKAIAKAIUQQl0IAMoAgAoAhBBBXRqQeC/AmtqOwEAIAMoAgggAygCACgCCEELdCADKAIAKAIEQQV0aiADKAIAKAIAQQF1ajsBAAsgA0EQaiQAC4MDAQF/IwBBIGsiAyQAIAMgADsBGiADIAE2AhQgAyACNgIQIAMgAygCFCADQQhqQcAAQQAQRiIANgIMAkAgAEUEQCADQQA2AhwMAQsgAygCCEEFakH//wNLBEAgAygCEEESQQAQFCADQQA2AhwMAQsgA0EAIAMoAghBBWqtECkiADYCBCAARQRAIAMoAhBBDkEAEBQgA0EANgIcDAELIAMoAgRBARCWASADKAIEIAMoAhQQhwEQISADKAIEIAMoAgwgAygCCBBBAn8jAEEQayIAIAMoAgQ2AgwgACgCDC0AAEEBcUULBEAgAygCEEEUQQAQFCADKAIEEBYgA0EANgIcDAELIAMgAy8BGgJ/IwBBEGsiACADKAIENgIMAn4gACgCDC0AAEEBcQRAIAAoAgwpAxAMAQtCAAunQf//A3ELAn8jAEEQayIAIAMoAgQ2AgwgACgCDCgCBAtBgAYQVTYCACADKAIEEBYgAyADKAIANgIcCyADKAIcIQAgA0EgaiQAIAALtAIBAX8jAEEwayIDJAAgAyAANgIoIAMgATcDICADIAI2AhwCQCADKQMgUARAIANBAToALwwBCyADIAMoAigpAxAgAykDIHw3AwgCQCADKQMIIAMpAyBaBEAgAykDCEL/////AFgNAQsgAygCHEEOQQAQFCADQQA6AC8MAQsgAyADKAIoKAIAIAMpAwinQQR0EE4iADYCBCAARQRAIAMoAhxBDkEAEBQgA0EAOgAvDAELIAMoAiggAygCBDYCACADIAMoAigpAwg3AxADQCADKQMQIAMpAwhaRQRAIAMoAigoAgAgAykDEKdBBHRqELUBIAMgAykDEEIBfDcDEAwBCwsgAygCKCADKQMIIgE3AxAgAygCKCABNwMIIANBAToALwsgAy0AL0EBcSEAIANBMGokACAAC8wBAQF/IwBBIGsiAiQAIAIgADcDECACIAE2AgwgAkEwEBgiATYCCAJAIAFFBEAgAigCDEEOQQAQFCACQQA2AhwMAQsgAigCCEEANgIAIAIoAghCADcDECACKAIIQgA3AwggAigCCEIANwMgIAIoAghCADcDGCACKAIIQQA2AiggAigCCEEAOgAsIAIoAgggAikDECACKAIMEI8BQQFxRQRAIAIoAggQJSACQQA2AhwMAQsgAiACKAIINgIcCyACKAIcIQEgAkEgaiQAIAEL1gIBAX8jAEEgayIDJAAgAyAANgIYIAMgATYCFCADIAI2AhAgAyADQQxqQgQQKTYCCAJAIAMoAghFBEAgA0F/NgIcDAELA0AgAygCFARAIAMoAhQoAgQgAygCEHFBgAZxBEAgAygCCEIAECwaIAMoAgggAygCFC8BCBAfIAMoAgggAygCFC8BChAfAn8jAEEQayIAIAMoAgg2AgwgACgCDC0AAEEBcUULBEAgAygCGEEIakEUQQAQFCADKAIIEBYgA0F/NgIcDAQLIAMoAhggA0EMakIEEDZBAEgEQCADKAIIEBYgA0F/NgIcDAQLIAMoAhQvAQoEQCADKAIYIAMoAhQoAgwgAygCFC8BCq0QNkEASARAIAMoAggQFiADQX82AhwMBQsLCyADIAMoAhQoAgA2AhQMAQsLIAMoAggQFiADQQA2AhwLIAMoAhwhACADQSBqJAAgAAtoAQF/IwBBEGsiAiAANgIMIAIgATYCCCACQQA7AQYDQCACKAIMBEAgAigCDCgCBCACKAIIcUGABnEEQCACIAIoAgwvAQogAi8BBkEEamo7AQYLIAIgAigCDCgCADYCDAwBCwsgAi8BBgvwAQEBfyMAQRBrIgEkACABIAA2AgwgASABKAIMNgIIIAFBADYCBANAIAEoAgwEQAJAAkAgASgCDC8BCEH1xgFGDQAgASgCDC8BCEH14AFGDQAgASgCDC8BCEGBsgJGDQAgASgCDC8BCEEBRw0BCyABIAEoAgwoAgA2AgAgASgCCCABKAIMRgRAIAEgASgCADYCCAsgASgCDEEANgIAIAEoAgwQIyABKAIEBEAgASgCBCABKAIANgIACyABIAEoAgA2AgwMAgsgASABKAIMNgIEIAEgASgCDCgCADYCDAwBCwsgASgCCCEAIAFBEGokACAAC7IEAQF/IwBBQGoiBSQAIAUgADYCOCAFIAE7ATYgBSACNgIwIAUgAzYCLCAFIAQ2AiggBSAFKAI4IAUvATatECkiADYCJAJAIABFBEAgBSgCKEEOQQAQFCAFQQA6AD8MAQsgBUEANgIgIAVBADYCGANAAn8jAEEQayIAIAUoAiQ2AgwgACgCDC0AAEEBcQsEfyAFKAIkEDBCBFoFQQALQQFxBEAgBSAFKAIkEB07ARYgBSAFKAIkEB07ARQgBSAFKAIkIAUvARStEB42AhAgBSgCEEUEQCAFKAIoQRVBABAUIAUoAiQQFiAFKAIYECMgBUEAOgA/DAMLIAUgBS8BFiAFLwEUIAUoAhAgBSgCMBBVIgA2AhwgAEUEQCAFKAIoQQ5BABAUIAUoAiQQFiAFKAIYECMgBUEAOgA/DAMLAkAgBSgCGARAIAUoAiAgBSgCHDYCACAFIAUoAhw2AiAMAQsgBSAFKAIcIgA2AiAgBSAANgIYCwwBCwsgBSgCJBBHQQFxRQRAIAUgBSgCJBAwPgIMIAUgBSgCJCAFKAIMrRAeNgIIAkACQCAFKAIMQQRPDQAgBSgCCEUNACAFKAIIQZEVIAUoAgwQT0UNAQsgBSgCKEEVQQAQFCAFKAIkEBYgBSgCGBAjIAVBADoAPwwCCwsgBSgCJBAWAkAgBSgCLARAIAUoAiwgBSgCGDYCAAwBCyAFKAIYECMLIAVBAToAPwsgBS0AP0EBcSEAIAVBQGskACAAC+8CAQF/IwBBIGsiAiQAIAIgADYCGCACIAE2AhQCQCACKAIYRQRAIAIgAigCFDYCHAwBCyACIAIoAhg2AggDQCACKAIIKAIABEAgAiACKAIIKAIANgIIDAELCwNAIAIoAhQEQCACIAIoAhQoAgA2AhAgAkEANgIEIAIgAigCGDYCDANAAkAgAigCDEUNAAJAIAIoAgwvAQggAigCFC8BCEcNACACKAIMLwEKIAIoAhQvAQpHDQAgAigCDC8BCgRAIAIoAgwoAgwgAigCFCgCDCACKAIMLwEKEE8NAQsgAigCDCIAIAAoAgQgAigCFCgCBEGABnFyNgIEIAJBATYCBAwBCyACIAIoAgwoAgA2AgwMAQsLIAIoAhRBADYCAAJAIAIoAgQEQCACKAIUECMMAQsgAigCCCACKAIUIgA2AgAgAiAANgIICyACIAIoAhA2AhQMAQsLIAIgAigCGDYCHAsgAigCHCEAIAJBIGokACAAC18BAX8jAEEQayICJAAgAiAANgIIIAIgAToAByACIAIoAghCARAeNgIAAkAgAigCAEUEQCACQX82AgwMAQsgAigCACACLQAHOgAAIAJBADYCDAsgAigCDBogAkEQaiQAC1QBAX8jAEEQayIBJAAgASAANgIIIAEgASgCCEIBEB42AgQCQCABKAIERQRAIAFBADoADwwBCyABIAEoAgQtAAA6AA8LIAEtAA8hACABQRBqJAAgAAucBgECfyMAQSBrIgIkACACIAA2AhggAiABNwMQAkAgAikDECACKAIYKQMwWgRAIAIoAhhBCGpBEkEAEBQgAkF/NgIcDAELIAIoAhgoAhhBAnEEQCACKAIYQQhqQRlBABAUIAJBfzYCHAwBCyACIAIoAhggAikDEEEAIAIoAhhBCGoQTSIANgIMIABFBEAgAkF/NgIcDAELIAIoAhgoAlAgAigCDCACKAIYQQhqEFlBAXFFBEAgAkF/NgIcDAELAn8gAigCGCEDIAIpAxAhASMAQTBrIgAkACAAIAM2AiggACABNwMgIABBATYCHAJAIAApAyAgACgCKCkDMFoEQCAAKAIoQQhqQRJBABAUIABBfzYCLAwBCwJAIAAoAhwNACAAKAIoKAJAIAApAyCnQQR0aigCBEUNACAAKAIoKAJAIAApAyCnQQR0aigCBCgCAEECcUUNAAJAIAAoAigoAkAgACkDIKdBBHRqKAIABEAgACAAKAIoIAApAyBBCCAAKAIoQQhqEE0iAzYCDCADRQRAIABBfzYCLAwECyAAIAAoAiggACgCDEEAQQAQWDcDEAJAIAApAxBCAFMNACAAKQMQIAApAyBRDQAgACgCKEEIakEKQQAQFCAAQX82AiwMBAsMAQsgAEEANgIMCyAAIAAoAiggACkDIEEAIAAoAihBCGoQTSIDNgIIIANFBEAgAEF/NgIsDAILIAAoAgwEQCAAKAIoKAJQIAAoAgwgACkDIEEAIAAoAihBCGoQdEEBcUUEQCAAQX82AiwMAwsLIAAoAigoAlAgACgCCCAAKAIoQQhqEFlBAXFFBEAgACgCKCgCUCAAKAIMQQAQWRogAEF/NgIsDAILCyAAKAIoKAJAIAApAyCnQQR0aigCBBA3IAAoAigoAkAgACkDIKdBBHRqQQA2AgQgACgCKCgCQCAAKQMgp0EEdGoQXiAAQQA2AiwLIAAoAiwhAyAAQTBqJAAgAwsEQCACQX82AhwMAQsgAigCGCgCQCACKQMQp0EEdGpBAToADCACQQA2AhwLIAIoAhwhACACQSBqJAAgAAulBAEBfyMAQTBrIgUkACAFIAA2AiggBSABNwMgIAUgAjYCHCAFIAM6ABsgBSAENgIUAkAgBSgCKCAFKQMgQQBBABA/RQRAIAVBfzYCLAwBCyAFKAIoKAIYQQJxBEAgBSgCKEEIakEZQQAQFCAFQX82AiwMAQsgBSAFKAIoKAJAIAUpAyCnQQR0ajYCECAFAn8gBSgCECgCAARAIAUoAhAoAgAvAQhBCHYMAQtBAws6AAsgBQJ/IAUoAhAoAgAEQCAFKAIQKAIAKAJEDAELQYCA2I14CzYCBEEBIQAgBSAFLQAbIAUtAAtGBH8gBSgCFCAFKAIERwVBAQtBAXE2AgwCQCAFKAIMBEAgBSgCECgCBEUEQCAFKAIQKAIAEEAhACAFKAIQIAA2AgQgAEUEQCAFKAIoQQhqQQ5BABAUIAVBfzYCLAwECwsgBSgCECgCBCAFKAIQKAIELwEIQf8BcSAFLQAbQQh0cjsBCCAFKAIQKAIEIAUoAhQ2AkQgBSgCECgCBCIAIAAoAgBBEHI2AgAMAQsgBSgCECgCBARAIAUoAhAoAgQiACAAKAIAQW9xNgIAAkAgBSgCECgCBCgCAEUEQCAFKAIQKAIEEDcgBSgCEEEANgIEDAELIAUoAhAoAgQgBSgCECgCBC8BCEH/AXEgBS0AC0EIdHI7AQggBSgCECgCBCAFKAIENgJECwsLIAVBADYCLAsgBSgCLCEAIAVBMGokACAAC90PAgF/AX4jAEFAaiIEJAAgBCAANgI0IARCfzcDKCAEIAE2AiQgBCACNgIgIAQgAzYCHAJAIAQoAjQoAhhBAnEEQCAEKAI0QQhqQRlBABAUIARCfzcDOAwBCyAEIAQoAjQpAzA3AxAgBCkDKEJ/UQRAIARCfzcDCCAEKAIcQYDAAHEEQCAEIAQoAjQgBCgCJCAEKAIcQQAQWDcDCAsgBCkDCEJ/UQRAIAQoAjQhASMAQUBqIgAkACAAIAE2AjQCQCAAKAI0KQM4IAAoAjQpAzBCAXxYBEAgACAAKAI0KQM4NwMYIAAgACkDGEIBhjcDEAJAIAApAxBCEFQEQCAAQhA3AxAMAQsgACkDEEKACFYEQCAAQoAINwMQCwsgACAAKQMQIAApAxh8NwMYIAAgACkDGKdBBHStNwMIIAApAwggACgCNCkDOKdBBHStVARAIAAoAjRBCGpBDkEAEBQgAEJ/NwM4DAILIAAgACgCNCgCQCAAKQMYp0EEdBBONgIkIAAoAiRFBEAgACgCNEEIakEOQQAQFCAAQn83AzgMAgsgACgCNCAAKAIkNgJAIAAoAjQgACkDGDcDOAsgACgCNCIBKQMwIQUgASAFQgF8NwMwIAAgBTcDKCAAKAI0KAJAIAApAyinQQR0ahC1ASAAIAApAyg3AzgLIAApAzghBSAAQUBrJAAgBCAFNwMIIAVCAFMEQCAEQn83AzgMAwsLIAQgBCkDCDcDKAsCQCAEKAIkRQ0AIAQoAjQhASAEKQMoIQUgBCgCJCECIAQoAhwhAyMAQUBqIgAkACAAIAE2AjggACAFNwMwIAAgAjYCLCAAIAM2AigCQCAAKQMwIAAoAjgpAzBaBEAgACgCOEEIakESQQAQFCAAQX82AjwMAQsgACgCOCgCGEECcQRAIAAoAjhBCGpBGUEAEBQgAEF/NgI8DAELAkACQCAAKAIsRQ0AIAAoAiwsAABFDQAgACAAKAIsIAAoAiwQLkH//wNxIAAoAiggACgCOEEIahBQIgE2AiAgAUUEQCAAQX82AjwMAwsCQCAAKAIoQYAwcQ0AIAAoAiBBABA6QQNHDQAgACgCIEECNgIICwwBCyAAQQA2AiALIAAgACgCOCAAKAIsQQBBABBYIgU3AxACQCAFQgBTDQAgACkDECAAKQMwUQ0AIAAoAiAQJCAAKAI4QQhqQQpBABAUIABBfzYCPAwBCwJAIAApAxBCAFMNACAAKQMQIAApAzBSDQAgACgCIBAkIABBADYCPAwBCyAAIAAoAjgoAkAgACkDMKdBBHRqNgIkAkAgACgCJCgCAARAIAAgACgCJCgCACgCMCAAKAIgEIYBQQBHOgAfDAELIABBADoAHwsCQCAALQAfQQFxDQAgACgCJCgCBA0AIAAoAiQoAgAQQCEBIAAoAiQgATYCBCABRQRAIAAoAjhBCGpBDkEAEBQgACgCIBAkIABBfzYCPAwCCwsgAAJ/IAAtAB9BAXEEQCAAKAIkKAIAKAIwDAELIAAoAiALQQBBACAAKAI4QQhqEEYiATYCCCABRQRAIAAoAiAQJCAAQX82AjwMAQsCQCAAKAIkKAIEBEAgACAAKAIkKAIEKAIwNgIEDAELAkAgACgCJCgCAARAIAAgACgCJCgCACgCMDYCBAwBCyAAQQA2AgQLCwJAIAAoAgQEQCAAIAAoAgRBAEEAIAAoAjhBCGoQRiIBNgIMIAFFBEAgACgCIBAkIABBfzYCPAwDCwwBCyAAQQA2AgwLIAAoAjgoAlAgACgCCCAAKQMwQQAgACgCOEEIahB0QQFxRQRAIAAoAiAQJCAAQX82AjwMAQsgACgCDARAIAAoAjgoAlAgACgCDEEAEFkaCwJAIAAtAB9BAXEEQCAAKAIkKAIEBEAgACgCJCgCBCgCAEECcQRAIAAoAiQoAgQoAjAQJCAAKAIkKAIEIgEgASgCAEF9cTYCAAJAIAAoAiQoAgQoAgBFBEAgACgCJCgCBBA3IAAoAiRBADYCBAwBCyAAKAIkKAIEIAAoAiQoAgAoAjA2AjALCwsgACgCIBAkDAELIAAoAiQoAgQoAgBBAnEEQCAAKAIkKAIEKAIwECQLIAAoAiQoAgQiASABKAIAQQJyNgIAIAAoAiQoAgQgACgCIDYCMAsgAEEANgI8CyAAKAI8IQEgAEFAayQAIAFFDQAgBCgCNCkDMCAEKQMQUgRAIAQoAjQoAkAgBCkDKKdBBHRqEHcgBCgCNCAEKQMQNwMwCyAEQn83AzgMAQsgBCgCNCgCQCAEKQMop0EEdGoQXgJAIAQoAjQoAkAgBCkDKKdBBHRqKAIARQ0AIAQoAjQoAkAgBCkDKKdBBHRqKAIEBEAgBCgCNCgCQCAEKQMop0EEdGooAgQoAgBBAXENAQsgBCgCNCgCQCAEKQMop0EEdGooAgRFBEAgBCgCNCgCQCAEKQMop0EEdGooAgAQQCEAIAQoAjQoAkAgBCkDKKdBBHRqIAA2AgQgAEUEQCAEKAI0QQhqQQ5BABAUIARCfzcDOAwDCwsgBCgCNCgCQCAEKQMop0EEdGooAgRBfjYCECAEKAI0KAJAIAQpAyinQQR0aigCBCIAIAAoAgBBAXI2AgALIAQoAjQoAkAgBCkDKKdBBHRqIAQoAiA2AgggBCAEKQMoNwM4CyAEKQM4IQUgBEFAayQAIAULqgEBAX8jAEEwayICJAAgAiAANgIoIAIgATcDICACQQA2AhwCQAJAIAIoAigoAiRBAUYEQCACKAIcRQ0BIAIoAhxBAUYNASACKAIcQQJGDQELIAIoAihBDGpBEkEAEBQgAkF/NgIsDAELIAIgAikDIDcDCCACIAIoAhw2AhAgAkF/QQAgAigCKCACQQhqQhBBDBAgQgBTGzYCLAsgAigCLCEAIAJBMGokACAAC6UyAwZ/AX4BfCMAQeAAayIEJAAgBCAANgJYIAQgATYCVCAEIAI2AlACQAJAIAQoAlRBAE4EQCAEKAJYDQELIAQoAlBBEkEAEBQgBEEANgJcDAELIAQgBCgCVDYCTCMAQRBrIgAgBCgCWDYCDCAEIAAoAgwpAxg3A0BB4JoBKQMAQn9RBEAgBEF/NgIUIARBAzYCECAEQQc2AgwgBEEGNgIIIARBAjYCBCAEQQE2AgBB4JoBQQAgBBA0NwMAIARBfzYCNCAEQQ82AjAgBEENNgIsIARBDDYCKCAEQQo2AiQgBEEJNgIgQeiaAUEIIARBIGoQNDcDAAtB4JoBKQMAIAQpA0BB4JoBKQMAg1IEQCAEKAJQQRxBABAUIARBADYCXAwBC0HomgEpAwAgBCkDQEHomgEpAwCDUgRAIAQgBCgCTEEQcjYCTAsgBCgCTEEYcUEYRgRAIAQoAlBBGUEAEBQgBEEANgJcDAELIAQoAlghASAEKAJQIQIjAEHQAGsiACQAIAAgATYCSCAAIAI2AkQgAEEIahA7AkAgACgCSCAAQQhqEDkEQCMAQRBrIgEgACgCSDYCDCAAIAEoAgxBDGo2AgQjAEEQayIBIAAoAgQ2AgwCQCABKAIMKAIAQQVHDQAjAEEQayIBIAAoAgQ2AgwgASgCDCgCBEEsRw0AIABBADYCTAwCCyAAKAJEIAAoAgQQRSAAQX82AkwMAQsgAEEBNgJMCyAAKAJMIQEgAEHQAGokACAEIAE2AjwCQAJAAkAgBCgCPEEBag4CAAECCyAEQQA2AlwMAgsgBCgCTEEBcUUEQCAEKAJQQQlBABAUIARBADYCXAwCCyAEIAQoAlggBCgCTCAEKAJQEGk2AlwMAQsgBCgCTEECcQRAIAQoAlBBCkEAEBQgBEEANgJcDAELIAQoAlgQSEEASARAIAQoAlAgBCgCWBAXIARBADYCXAwBCwJAIAQoAkxBCHEEQCAEIAQoAlggBCgCTCAEKAJQEGk2AjgMAQsgBCgCWCEAIAQoAkwhASAEKAJQIQIjAEHwAGsiAyQAIAMgADYCaCADIAE2AmQgAyACNgJgIANBIGoQOwJAIAMoAmggA0EgahA5QQBIBEAgAygCYCADKAJoEBcgA0EANgJsDAELIAMpAyBCBINQBEAgAygCYEEEQYoBEBQgA0EANgJsDAELIAMgAykDODcDGCADIAMoAmggAygCZCADKAJgEGkiADYCXCAARQRAIANBADYCbAwBCwJAIAMpAxhQRQ0AIAMoAmgQngFBAXFFDQAgAyADKAJcNgJsDAELIAMoAlwhACADKQMYIQkjAEHgAGsiAiQAIAIgADYCWCACIAk3A1ACQCACKQNQQhZUBEAgAigCWEEIakETQQAQFCACQQA2AlwMAQsgAgJ+IAIpA1BCqoAEVARAIAIpA1AMAQtCqoAECzcDMCACKAJYKAIAQgAgAikDMH1BAhAnQQBIBEAjAEEQayIAIAIoAlgoAgA2AgwgAiAAKAIMQQxqNgIIAkACfyMAQRBrIgAgAigCCDYCDCAAKAIMKAIAQQRGCwRAIwBBEGsiACACKAIINgIMIAAoAgwoAgRBFkYNAQsgAigCWEEIaiACKAIIEEUgAkEANgJcDAILCyACIAIoAlgoAgAQSSIJNwM4IAlCAFMEQCACKAJYQQhqIAIoAlgoAgAQFyACQQA2AlwMAQsgAiACKAJYKAIAIAIpAzBBACACKAJYQQhqEEIiADYCDCAARQRAIAJBADYCXAwBCyACQn83AyAgAkEANgJMIAIpAzBCqoAEWgRAIAIoAgxCFBAsGgsgAkEQakETQQAQFCACIAIoAgxCABAeNgJEA0ACQCACKAJEIQEgAigCDBAwQhJ9pyEFIwBBIGsiACQAIAAgATYCGCAAIAU2AhQgAEHsEjYCECAAQQQ2AgwCQAJAIAAoAhQgACgCDE8EQCAAKAIMDQELIABBADYCHAwBCyAAIAAoAhhBAWs2AggDQAJAIAAgACgCCEEBaiAAKAIQLQAAIAAoAhggACgCCGsgACgCFCAAKAIMa2oQqwEiATYCCCABRQ0AIAAoAghBAWogACgCEEEBaiAAKAIMQQFrEE8NASAAIAAoAgg2AhwMAgsLIABBADYCHAsgACgCHCEBIABBIGokACACIAE2AkQgAUUNACACKAIMIAIoAkQCfyMAQRBrIgAgAigCDDYCDCAAKAIMKAIEC2usECwaIAIoAlghASACKAIMIQUgAikDOCEJIwBB8ABrIgAkACAAIAE2AmggACAFNgJkIAAgCTcDWCAAIAJBEGo2AlQjAEEQayIBIAAoAmQ2AgwgAAJ+IAEoAgwtAABBAXEEQCABKAIMKQMQDAELQgALNwMwAkAgACgCZBAwQhZUBEAgACgCVEETQQAQFCAAQQA2AmwMAQsgACgCZEIEEB4oAABB0JaVMEcEQCAAKAJUQRNBABAUIABBADYCbAwBCwJAAkAgACkDMEIUVA0AIwBBEGsiASAAKAJkNgIMIAEoAgwoAgQgACkDMKdqQRRrKAAAQdCWmThHDQAgACgCZCAAKQMwQhR9ECwaIAAoAmgoAgAhBSAAKAJkIQYgACkDWCEJIAAoAmgoAhQhByAAKAJUIQgjAEGwAWsiASQAIAEgBTYCqAEgASAGNgKkASABIAk3A5gBIAEgBzYClAEgASAINgKQASMAQRBrIgUgASgCpAE2AgwgAQJ+IAUoAgwtAABBAXEEQCAFKAIMKQMQDAELQgALNwMYIAEoAqQBQgQQHhogASABKAKkARAdQf//A3E2AhAgASABKAKkARAdQf//A3E2AgggASABKAKkARAxNwM4AkAgASkDOEL///////////8AVgRAIAEoApABQQRBFhAUIAFBADYCrAEMAQsgASkDOEI4fCABKQMYIAEpA5gBfFYEQCABKAKQAUEVQQAQFCABQQA2AqwBDAELAkACQCABKQM4IAEpA5gBVA0AIAEpAzhCOHwgASkDmAECfiMAQRBrIgUgASgCpAE2AgwgBSgCDCkDCAt8Vg0AIAEoAqQBIAEpAzggASkDmAF9ECwaIAFBADoAFwwBCyABKAKoASABKQM4QQAQJ0EASARAIAEoApABIAEoAqgBEBcgAUEANgKsAQwCCyABIAEoAqgBQjggAUFAayABKAKQARBCIgU2AqQBIAVFBEAgAUEANgKsAQwCCyABQQE6ABcLIAEoAqQBQgQQHigAAEHQlpkwRwRAIAEoApABQRVBABAUIAEtABdBAXEEQCABKAKkARAWCyABQQA2AqwBDAELIAEgASgCpAEQMTcDMAJAIAEoApQBQQRxRQ0AIAEpAzAgASkDOHxCDHwgASkDmAEgASkDGHxRDQAgASgCkAFBFUEAEBQgAS0AF0EBcQRAIAEoAqQBEBYLIAFBADYCrAEMAQsgASgCpAFCBBAeGiABIAEoAqQBECo2AgwgASABKAKkARAqNgIEIAEoAhBB//8DRgRAIAEgASgCDDYCEAsgASgCCEH//wNGBEAgASABKAIENgIICwJAIAEoApQBQQRxRQ0AIAEoAgggASgCBEYEQCABKAIQIAEoAgxGDQELIAEoApABQRVBABAUIAEtABdBAXEEQCABKAKkARAWCyABQQA2AqwBDAELAkAgASgCEEUEQCABKAIIRQ0BCyABKAKQAUEBQQAQFCABLQAXQQFxBEAgASgCpAEQFgsgAUEANgKsAQwBCyABIAEoAqQBEDE3AyggASABKAKkARAxNwMgIAEpAyggASkDIFIEQCABKAKQAUEBQQAQFCABLQAXQQFxBEAgASgCpAEQFgsgAUEANgKsAQwBCyABIAEoAqQBEDE3AzAgASABKAKkARAxNwOAAQJ/IwBBEGsiBSABKAKkATYCDCAFKAIMLQAAQQFxRQsEQCABKAKQAUEUQQAQFCABLQAXQQFxBEAgASgCpAEQFgsgAUEANgKsAQwBCyABLQAXQQFxBEAgASgCpAEQFgsCQCABKQOAAUL///////////8AWARAIAEpA4ABIAEpA4ABIAEpAzB8WA0BCyABKAKQAUEEQRYQFCABQQA2AqwBDAELIAEpA4ABIAEpAzB8IAEpA5gBIAEpAzh8VgRAIAEoApABQRVBABAUIAFBADYCrAEMAQsCQCABKAKUAUEEcUUNACABKQOAASABKQMwfCABKQOYASABKQM4fFENACABKAKQAUEVQQAQFCABQQA2AqwBDAELIAEpAyggASkDMEIugFYEQCABKAKQAUEVQQAQFCABQQA2AqwBDAELIAEgASkDKCABKAKQARCQASIFNgKMASAFRQRAIAFBADYCrAEMAQsgASgCjAFBAToALCABKAKMASABKQMwNwMYIAEoAowBIAEpA4ABNwMgIAEgASgCjAE2AqwBCyABKAKsASEFIAFBsAFqJAAgACAFNgJQDAELIAAoAmQgACkDMBAsGiAAKAJkIQUgACkDWCEJIAAoAmgoAhQhBiAAKAJUIQcjAEHQAGsiASQAIAEgBTYCSCABIAk3A0AgASAGNgI8IAEgBzYCOAJAIAEoAkgQMEIWVARAIAEoAjhBFUEAEBQgAUEANgJMDAELIwBBEGsiBSABKAJINgIMIAECfiAFKAIMLQAAQQFxBEAgBSgCDCkDEAwBC0IACzcDCCABKAJIQgQQHhogASgCSBAqBEAgASgCOEEBQQAQFCABQQA2AkwMAQsgASABKAJIEB1B//8Dca03AyggASABKAJIEB1B//8Dca03AyAgASkDICABKQMoUgRAIAEoAjhBE0EAEBQgAUEANgJMDAELIAEgASgCSBAqrTcDGCABIAEoAkgQKq03AxAgASkDECABKQMQIAEpAxh8VgRAIAEoAjhBBEEWEBQgAUEANgJMDAELIAEpAxAgASkDGHwgASkDQCABKQMIfFYEQCABKAI4QRVBABAUIAFBADYCTAwBCwJAIAEoAjxBBHFFDQAgASkDECABKQMYfCABKQNAIAEpAwh8UQ0AIAEoAjhBFUEAEBQgAUEANgJMDAELIAEgASkDICABKAI4EJABIgU2AjQgBUUEQCABQQA2AkwMAQsgASgCNEEAOgAsIAEoAjQgASkDGDcDGCABKAI0IAEpAxA3AyAgASABKAI0NgJMCyABKAJMIQUgAUHQAGokACAAIAU2AlALIAAoAlBFBEAgAEEANgJsDAELIAAoAmQgACkDMEIUfBAsGiAAIAAoAmQQHTsBTiAAKAJQKQMgIAAoAlApAxh8IAApA1ggACkDMHxWBEAgACgCVEEVQQAQFCAAKAJQECUgAEEANgJsDAELAkAgAC8BTkUEQCAAKAJoKAIEQQRxRQ0BCyAAKAJkIAApAzBCFnwQLBogACAAKAJkEDA3AyACQCAAKQMgIAAvAU6tWgRAIAAoAmgoAgRBBHFFDQEgACkDICAALwFOrVENAQsgACgCVEEVQQAQFCAAKAJQECUgAEEANgJsDAILIAAvAU4EQCAAKAJkIAAvAU6tEB4gAC8BTkEAIAAoAlQQUCEBIAAoAlAgATYCKCABRQRAIAAoAlAQJSAAQQA2AmwMAwsLCwJAIAAoAlApAyAgACkDWFoEQCAAKAJkIAAoAlApAyAgACkDWH0QLBogACAAKAJkIAAoAlApAxgQHiIBNgIcIAFFBEAgACgCVEEVQQAQFCAAKAJQECUgAEEANgJsDAMLIAAgACgCHCAAKAJQKQMYECkiATYCLCABRQRAIAAoAlRBDkEAEBQgACgCUBAlIABBADYCbAwDCwwBCyAAQQA2AiwgACgCaCgCACAAKAJQKQMgQQAQJ0EASARAIAAoAlQgACgCaCgCABAXIAAoAlAQJSAAQQA2AmwMAgsgACgCaCgCABBJIAAoAlApAyBSBEAgACgCVEETQQAQFCAAKAJQECUgAEEANgJsDAILCyAAIAAoAlApAxg3AzggAEIANwNAA0ACQCAAKQM4UA0AIABBADoAGyAAKQNAIAAoAlApAwhRBEAgACgCUC0ALEEBcQ0BIAApAzhCLlQNASAAKAJQQoCABCAAKAJUEI8BQQFxRQRAIAAoAlAQJSAAKAIsEBYgAEEANgJsDAQLIABBAToAGwsjAEEQayIBJAAgAUHYABAYIgU2AggCQCAFRQRAIAFBADYCDAwBCyABKAIIEFMgASABKAIINgIMCyABKAIMIQUgAUEQaiQAIAUhASAAKAJQKAIAIAApA0CnQQR0aiABNgIAAkAgAQRAIAAgACgCUCgCACAAKQNAp0EEdGooAgAgACgCaCgCACAAKAIsQQAgACgCVBCMASIJNwMQIAlCAFkNAQsCQCAALQAbQQFxRQ0AIwBBEGsiASAAKAJUNgIMIAEoAgwoAgBBE0cNACAAKAJUQRVBABAUCyAAKAJQECUgACgCLBAWIABBADYCbAwDCyAAIAApA0BCAXw3A0AgACAAKQM4IAApAxB9NwM4DAELCwJAIAApA0AgACgCUCkDCFEEQCAAKQM4UA0BCyAAKAJUQRVBABAUIAAoAiwQFiAAKAJQECUgAEEANgJsDAELIAAoAmgoAgRBBHEEQAJAIAAoAiwEQCAAIAAoAiwQR0EBcToADwwBCyAAIAAoAmgoAgAQSTcDACAAKQMAQgBTBEAgACgCVCAAKAJoKAIAEBcgACgCUBAlIABBADYCbAwDCyAAIAApAwAgACgCUCkDICAAKAJQKQMYfFE6AA8LIAAtAA9BAXFFBEAgACgCVEEVQQAQFCAAKAIsEBYgACgCUBAlIABBADYCbAwCCwsgACgCLBAWIAAgACgCUDYCbAsgACgCbCEBIABB8ABqJAAgAiABNgJIIAEEQAJAIAIoAkwEQCACKQMgQgBXBEAgAiACKAJYIAIoAkwgAkEQahBoNwMgCyACIAIoAlggAigCSCACQRBqEGg3AygCQCACKQMgIAIpAyhTBEAgAigCTBAlIAIgAigCSDYCTCACIAIpAyg3AyAMAQsgAigCSBAlCwwBCyACIAIoAkg2AkwCQCACKAJYKAIEQQRxBEAgAiACKAJYIAIoAkwgAkEQahBoNwMgDAELIAJCADcDIAsLIAJBADYCSAsgAiACKAJEQQFqNgJEIAIoAgwgAigCRAJ/IwBBEGsiACACKAIMNgIMIAAoAgwoAgQLa6wQLBoMAQsLIAIoAgwQFiACKQMgQgBTBEAgAigCWEEIaiACQRBqEEUgAigCTBAlIAJBADYCXAwBCyACIAIoAkw2AlwLIAIoAlwhACACQeAAaiQAIAMgADYCWCAARQRAIAMoAmAgAygCXEEIahBFIwBBEGsiACADKAJoNgIMIAAoAgwiACAAKAIwQQFqNgIwIAMoAlwQPCADQQA2AmwMAQsgAygCXCADKAJYKAIANgJAIAMoAlwgAygCWCkDCDcDMCADKAJcIAMoAlgpAxA3AzggAygCXCADKAJYKAIoNgIgIAMoAlgQFSADKAJcKAJQIQAgAygCXCkDMCEJIAMoAlxBCGohAiMAQSBrIgEkACABIAA2AhggASAJNwMQIAEgAjYCDAJAIAEpAxBQBEAgAUEBOgAfDAELIwBBIGsiACABKQMQNwMQIAAgACkDELpEAAAAAAAA6D+jOQMIAkAgACsDCEQAAOD////vQWQEQCAAQX82AgQMAQsgAAJ/IAArAwgiCkQAAAAAAADwQWMgCkQAAAAAAAAAAGZxBEAgCqsMAQtBAAs2AgQLAkAgACgCBEGAgICAeEsEQCAAQYCAgIB4NgIcDAELIAAgACgCBEEBazYCBCAAIAAoAgQgACgCBEEBdnI2AgQgACAAKAIEIAAoAgRBAnZyNgIEIAAgACgCBCAAKAIEQQR2cjYCBCAAIAAoAgQgACgCBEEIdnI2AgQgACAAKAIEIAAoAgRBEHZyNgIEIAAgACgCBEEBajYCBCAAIAAoAgQ2AhwLIAEgACgCHDYCCCABKAIIIAEoAhgoAgBNBEAgAUEBOgAfDAELIAEoAhggASgCCCABKAIMEFpBAXFFBEAgAUEAOgAfDAELIAFBAToAHwsgAS0AHxogAUEgaiQAIANCADcDEANAIAMpAxAgAygCXCkDMFQEQCADIAMoAlwoAkAgAykDEKdBBHRqKAIAKAIwQQBBACADKAJgEEY2AgwgAygCDEUEQCMAQRBrIgAgAygCaDYCDCAAKAIMIgAgACgCMEEBajYCMCADKAJcEDwgA0EANgJsDAMLIAMoAlwoAlAgAygCDCADKQMQQQggAygCXEEIahB0QQFxRQRAAkAgAygCXCgCCEEKRgRAIAMoAmRBBHFFDQELIAMoAmAgAygCXEEIahBFIwBBEGsiACADKAJoNgIMIAAoAgwiACAAKAIwQQFqNgIwIAMoAlwQPCADQQA2AmwMBAsLIAMgAykDEEIBfDcDEAwBCwsgAygCXCADKAJcKAIUNgIYIAMgAygCXDYCbAsgAygCbCEAIANB8ABqJAAgBCAANgI4CyAEKAI4RQRAIAQoAlgQLxogBEEANgJcDAELIAQgBCgCODYCXAsgBCgCXCEAIARB4ABqJAAgAAuOAQEBfyMAQRBrIgIkACACIAA2AgwgAiABNgIIIAJBADYCBCACKAIIBEAjAEEQayIAIAIoAgg2AgwgAiAAKAIMKAIANgIEIAIoAggQrAFBAUYEQCMAQRBrIgAgAigCCDYCDEG0mwEgACgCDCgCBDYCAAsLIAIoAgwEQCACKAIMIAIoAgQ2AgALIAJBEGokAAuVAQEBfyMAQRBrIgEkACABIAA2AggCQAJ/IwBBEGsiACABKAIINgIMIAAoAgwpAxhCgIAQg1ALBEAgASgCCCgCAARAIAEgASgCCCgCABCeAUEBcToADwwCCyABQQE6AA8MAQsgASABKAIIQQBCAEESECA+AgQgASABKAIEQQBHOgAPCyABLQAPQQFxIQAgAUEQaiQAIAALfwEBfyMAQSBrIgMkACADIAA2AhggAyABNwMQIANBADYCDCADIAI2AggCQCADKQMQQv///////////wBWBEAgAygCCEEEQT0QFCADQX82AhwMAQsgAyADKAIYIAMpAxAgAygCDCADKAIIEGo2AhwLIAMoAhwhACADQSBqJAAgAAt9ACACQQFGBEAgASAAKAIIIAAoAgRrrH0hAQsCQCAAKAIUIAAoAhxLBEAgAEEAQQAgACgCJBEBABogACgCFEUNAQsgAEEANgIcIABCADcDECAAIAEgAiAAKAIoEQ8AQgBTDQAgAEIANwIEIAAgACgCAEFvcTYCAEEADwtBfwvhAgECfyMAQSBrIgMkAAJ/AkACQEGnEiABLAAAEKIBRQRAQbSbAUEcNgIADAELQZgJEBgiAg0BC0EADAELIAJBAEGQARAzIAFBKxCiAUUEQCACQQhBBCABLQAAQfIARhs2AgALAkAgAS0AAEHhAEcEQCACKAIAIQEMAQsgAEEDQQAQBCIBQYAIcUUEQCADIAFBgAhyNgIQIABBBCADQRBqEAQaCyACIAIoAgBBgAFyIgE2AgALIAJB/wE6AEsgAkGACDYCMCACIAA2AjwgAiACQZgBajYCLAJAIAFBCHENACADIANBGGo2AgAgAEGTqAEgAxAODQAgAkEKOgBLCyACQRo2AiggAkEbNgIkIAJBHDYCICACQR02AgxB6J8BKAIARQRAIAJBfzYCTAsgAkGsoAEoAgA2AjhBrKABKAIAIgAEQCAAIAI2AjQLQaygASACNgIAIAILIQAgA0EgaiQAIAAL8AEBAn8CfwJAIAFB/wFxIgMEQCAAQQNxBEADQCAALQAAIgJFDQMgAiABQf8BcUYNAyAAQQFqIgBBA3ENAAsLAkAgACgCACICQX9zIAJBgYKECGtxQYCBgoR4cQ0AIANBgYKECGwhAwNAIAIgA3MiAkF/cyACQYGChAhrcUGAgYKEeHENASAAKAIEIQIgAEEEaiEAIAJBgYKECGsgAkF/c3FBgIGChHhxRQ0ACwsDQCAAIgItAAAiAwRAIAJBAWohACADIAFB/wFxRw0BCwsgAgwCCyAAEC4gAGoMAQsgAAsiAEEAIAAtAAAgAUH/AXFGGwsYACAAKAJMQX9MBEAgABCkAQ8LIAAQpAELYAIBfgJ/IAAoAighAkEBIQMgAEIAIAAtAABBgAFxBH9BAkEBIAAoAhQgACgCHEsbBUEBCyACEQ8AIgFCAFkEfiAAKAIUIAAoAhxrrCABIAAoAgggACgCBGusfXwFIAELC2sBAX8gAARAIAAoAkxBf0wEQCAAEG4PCyAAEG4PC0GwoAEoAgAEQEGwoAEoAgAQpQEhAQtBrKABKAIAIgAEQANAIAAoAkwaIAAoAhQgACgCHEsEQCAAEG4gAXIhAQsgACgCOCIADQALCyABCyIAIAAgARACIgBBgWBPBH9BtJsBQQAgAGs2AgBBfwUgAAsLUwEDfwJAIAAoAgAsAABBMGtBCk8NAANAIAAoAgAiAiwAACEDIAAgAkEBajYCACABIANqQTBrIQEgAiwAAUEwa0EKTw0BIAFBCmwhAQwACwALIAELuwIAAkAgAUEUSw0AAkACQAJAAkACQAJAAkACQAJAAkAgAUEJaw4KAAECAwQFBgcICQoLIAIgAigCACIBQQRqNgIAIAAgASgCADYCAA8LIAIgAigCACIBQQRqNgIAIAAgATQCADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATUCADcDAA8LIAIgAigCAEEHakF4cSIBQQhqNgIAIAAgASkDADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATIBADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATMBADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATAAADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATEAADcDAA8LIAIgAigCAEEHakF4cSIBQQhqNgIAIAAgASsDADkDAA8LIAAgAkEYEQQACwt/AgF/AX4gAL0iA0I0iKdB/w9xIgJB/w9HBHwgAkUEQCABIABEAAAAAAAAAABhBH9BAAUgAEQAAAAAAADwQ6IgARCpASEAIAEoAgBBQGoLNgIAIAAPCyABIAJB/gdrNgIAIANC/////////4eAf4NCgICAgICAgPA/hL8FIAALC5sCACAARQRAQQAPCwJ/AkAgAAR/IAFB/wBNDQECQEGQmQEoAgAoAgBFBEAgAUGAf3FBgL8DRg0DDAELIAFB/w9NBEAgACABQT9xQYABcjoAASAAIAFBBnZBwAFyOgAAQQIMBAsgAUGAsANPQQAgAUGAQHFBgMADRxtFBEAgACABQT9xQYABcjoAAiAAIAFBDHZB4AFyOgAAIAAgAUEGdkE/cUGAAXI6AAFBAwwECyABQYCABGtB//8/TQRAIAAgAUE/cUGAAXI6AAMgACABQRJ2QfABcjoAACAAIAFBBnZBP3FBgAFyOgACIAAgAUEMdkE/cUGAAXI6AAFBBAwECwtBtJsBQRk2AgBBfwVBAQsMAQsgACABOgAAQQELC+MBAQJ/IAJBAEchAwJAAkACQCAAQQNxRQ0AIAJFDQAgAUH/AXEhBANAIAAtAAAgBEYNAiACQQFrIgJBAEchAyAAQQFqIgBBA3FFDQEgAg0ACwsgA0UNAQsCQCAALQAAIAFB/wFxRg0AIAJBBEkNACABQf8BcUGBgoQIbCEDA0AgACgCACADcyIEQX9zIARBgYKECGtxQYCBgoR4cQ0BIABBBGohACACQQRrIgJBA0sNAAsLIAJFDQAgAUH/AXEhAQNAIAEgAC0AAEYEQCAADwsgAEEBaiEAIAJBAWsiAg0ACwtBAAtaAQF/IwBBEGsiASAANgIIAkACQCABKAIIKAIAQQBOBEAgASgCCCgCAEGAFCgCAEgNAQsgAUEANgIMDAELIAEgASgCCCgCAEECdEGQFGooAgA2AgwLIAEoAgwL+QIBAX8jAEEgayIEJAAgBCAANgIYIAQgATcDECAEIAI2AgwgBCADNgIIIAQgBCgCGCAEKAIYIAQpAxAgBCgCDCAEKAIIEK4BIgA2AgACQCAARQRAIARBADYCHAwBCyAEKAIAEEhBAEgEQCAEKAIYQQhqIAQoAgAQFyAEKAIAEBsgBEEANgIcDAELIAQoAhghAiMAQRBrIgAkACAAIAI2AgggAEEYEBgiAjYCBAJAIAJFBEAgACgCCEEIakEOQQAQFCAAQQA2AgwMAQsgACgCBCAAKAIINgIAIwBBEGsiAiAAKAIEQQRqNgIMIAIoAgxBADYCACACKAIMQQA2AgQgAigCDEEANgIIIAAoAgRBADoAECAAKAIEQQA2AhQgACAAKAIENgIMCyAAKAIMIQIgAEEQaiQAIAQgAjYCBCACRQRAIAQoAgAQGyAEQQA2AhwMAQsgBCgCBCAEKAIANgIUIAQgBCgCBDYCHAsgBCgCHCEAIARBIGokACAAC7cOAgN/AX4jAEHAAWsiBSQAIAUgADYCuAEgBSABNgK0ASAFIAI3A6gBIAUgAzYCpAEgBUIANwOYASAFQgA3A5ABIAUgBDYCjAECQCAFKAK4AUUEQCAFQQA2ArwBDAELAkAgBSgCtAEEQCAFKQOoASAFKAK0ASkDMFQNAQsgBSgCuAFBCGpBEkEAEBQgBUEANgK8AQwBCwJAIAUoAqQBQQhxDQAgBSgCtAEoAkAgBSkDqAGnQQR0aigCCEUEQCAFKAK0ASgCQCAFKQOoAadBBHRqLQAMQQFxRQ0BCyAFKAK4AUEIakEPQQAQFCAFQQA2ArwBDAELIAUoArQBIAUpA6gBIAUoAqQBQQhyIAVByABqEH5BAEgEQCAFKAK4AUEIakEUQQAQFCAFQQA2ArwBDAELIAUoAqQBQSBxBEAgBSAFKAKkAUEEcjYCpAELAkAgBSkDmAFQBEAgBSkDkAFQDQELIAUoAqQBQQRxRQ0AIAUoArgBQQhqQRJBABAUIAVBADYCvAEMAQsCQCAFKQOYAVAEQCAFKQOQAVANAQsgBSkDmAEgBSkDmAEgBSkDkAF8WARAIAUpA2AgBSkDmAEgBSkDkAF8Wg0BCyAFKAK4AUEIakESQQAQFCAFQQA2ArwBDAELIAUpA5ABUARAIAUgBSkDYCAFKQOYAX03A5ABCyAFIAUpA5ABIAUpA2BUOgBHIAUgBSgCpAFBIHEEf0EABSAFLwF6QQBHC0EBcToARSAFIAUoAqQBQQRxBH9BAAUgBS8BeEEARwtBAXE6AEQgBQJ/IAUoAqQBQQRxBEBBACAFLwF4DQEaCyAFLQBHQX9zC0EBcToARiAFLQBFQQFxBEAgBSgCjAFFBEAgBSAFKAK4ASgCHDYCjAELIAUoAowBRQRAIAUoArgBQQhqQRpBABAUIAVBADYCvAEMAgsLIAUpA2hQBEAgBSAFKAK4AUEAQgBBABB9NgK8AQwBCwJAAkAgBS0AR0EBcUUNACAFLQBFQQFxDQAgBS0AREEBcQ0AIAUgBSkDkAE3AyAgBSAFKQOQATcDKCAFQQA7ATggBSAFKAJwNgIwIAVC3AA3AwggBSAFKAK0ASgCACAFKQOYASAFKQOQASAFQQhqQQAgBSgCtAEgBSkDqAEgBSgCuAFBCGoQXyIANgKIAQwBCyAFIAUoArQBIAUpA6gBIAUoAqQBIAUoArgBQQhqED8iADYCBCAARQRAIAVBADYCvAEMAgsgBSAFKAK0ASgCAEIAIAUpA2ggBUHIAGogBSgCBC8BDEEBdkEDcSAFKAK0ASAFKQOoASAFKAK4AUEIahBfIgA2AogBCyAARQRAIAVBADYCvAEMAQsCfyAFKAKIASEAIAUoArQBIQMjAEEQayIBJAAgASAANgIMIAEgAzYCCCABKAIMIAEoAgg2AiwgASgCCCEDIAEoAgwhBCMAQSBrIgAkACAAIAM2AhggACAENgIUAkAgACgCGCgCSCAAKAIYKAJEQQFqTQRAIAAgACgCGCgCSEEKajYCDCAAIAAoAhgoAkwgACgCDEECdBBONgIQIAAoAhBFBEAgACgCGEEIakEOQQAQFCAAQX82AhwMAgsgACgCGCAAKAIMNgJIIAAoAhggACgCEDYCTAsgACgCFCEEIAAoAhgoAkwhBiAAKAIYIgcoAkQhAyAHIANBAWo2AkQgA0ECdCAGaiAENgIAIABBADYCHAsgACgCHCEDIABBIGokACABQRBqJAAgA0EASAsEQCAFKAKIARAbIAVBADYCvAEMAQsgBS0ARUEBcQRAIAUgBS8BekEAEHsiADYCACAARQRAIAUoArgBQQhqQRhBABAUIAVBADYCvAEMAgsgBSAFKAK4ASAFKAKIASAFLwF6QQAgBSgCjAEgBSgCABEFADYChAEgBSgCiAEQGyAFKAKEAUUEQCAFQQA2ArwBDAILIAUgBSgChAE2AogBCyAFLQBEQQFxBEAgBSAFKAK4ASAFKAKIASAFLwF4ELABNgKEASAFKAKIARAbIAUoAoQBRQRAIAVBADYCvAEMAgsgBSAFKAKEATYCiAELIAUtAEZBAXEEQCAFIAUoArgBIAUoAogBQQEQrwE2AoQBIAUoAogBEBsgBSgChAFFBEAgBUEANgK8AQwCCyAFIAUoAoQBNgKIAQsCQCAFLQBHQQFxRQ0AIAUtAEVBAXFFBEAgBS0AREEBcUUNAQsgBSgCuAEhASAFKAKIASEDIAUpA5gBIQIgBSkDkAEhCCMAQSBrIgAkACAAIAE2AhwgACADNgIYIAAgAjcDECAAIAg3AwggACgCGCAAKQMQIAApAwhBAEEAQQBCACAAKAIcQQhqEF8hASAAQSBqJAAgBSABNgKEASAFKAKIARAbIAUoAoQBRQRAIAVBADYCvAEMAgsgBSAFKAKEATYCiAELIAUgBSgCiAE2ArwBCyAFKAK8ASEAIAVBwAFqJAAgAAuEAgEBfyMAQSBrIgMkACADIAA2AhggAyABNgIUIAMgAjYCEAJAIAMoAhRFBEAgAygCGEEIakESQQAQFCADQQA2AhwMAQsgA0E4EBgiADYCDCAARQRAIAMoAhhBCGpBDkEAEBQgA0EANgIcDAELIwBBEGsiACADKAIMQQhqNgIMIAAoAgxBADYCACAAKAIMQQA2AgQgACgCDEEANgIIIAMoAgwgAygCEDYCACADKAIMQQA2AgQgAygCDEIANwMoQQBBAEEAEBohACADKAIMIAA2AjAgAygCDEIANwMYIAMgAygCGCADKAIUQRQgAygCDBBhNgIcCyADKAIcIQAgA0EgaiQAIAALQwEBfyMAQRBrIgMkACADIAA2AgwgAyABNgIIIAMgAjYCBCADKAIMIAMoAgggAygCBEEAQQAQsgEhACADQRBqJAAgAAtJAQF/IwBBEGsiASQAIAEgADYCDCABKAIMBEAgASgCDCgCrEAgASgCDCgCqEAoAgQRAgAgASgCDBA4IAEoAgwQFQsgAUEQaiQAC5QFAQF/IwBBMGsiBSQAIAUgADYCKCAFIAE2AiQgBSACNgIgIAUgAzoAHyAFIAQ2AhggBUEANgIMAkAgBSgCJEUEQCAFKAIoQQhqQRJBABAUIAVBADYCLAwBCyAFIAUoAiAgBS0AH0EBcRCzASIANgIMIABFBEAgBSgCKEEIakEQQQAQFCAFQQA2AiwMAQsgBSgCICEBIAUtAB9BAXEhAiAFKAIYIQMgBSgCDCEEIwBBIGsiACQAIAAgATYCGCAAIAI6ABcgACADNgIQIAAgBDYCDCAAQbDAABAYIgE2AggCQCABRQRAIABBADYCHAwBCyMAQRBrIgEgACgCCDYCDCABKAIMQQA2AgAgASgCDEEANgIEIAEoAgxBADYCCCAAKAIIAn8gAC0AF0EBcQRAIAAoAhhBf0cEfyAAKAIYQX5GBUEBC0EBcQwBC0EAC0EARzoADiAAKAIIIAAoAgw2AqhAIAAoAgggACgCGDYCFCAAKAIIIAAtABdBAXE6ABAgACgCCEEAOgAMIAAoAghBADoADSAAKAIIQQA6AA8gACgCCCgCqEAoAgAhAQJ/AkAgACgCGEF/RwRAIAAoAhhBfkcNAQtBCAwBCyAAKAIYC0H//wNxIAAoAhAgACgCCCABEQEAIQEgACgCCCABNgKsQCABRQRAIAAoAggQOCAAKAIIEBUgAEEANgIcDAELIAAgACgCCDYCHAsgACgCHCEBIABBIGokACAFIAE2AhQgAUUEQCAFKAIoQQhqQQ5BABAUIAVBADYCLAwBCyAFIAUoAiggBSgCJEETIAUoAhQQYSIANgIQIABFBEAgBSgCFBCxASAFQQA2AiwMAQsgBSAFKAIQNgIsCyAFKAIsIQAgBUEwaiQAIAALzAEBAX8jAEEgayICIAA2AhggAiABOgAXIAICfwJAIAIoAhhBf0cEQCACKAIYQX5HDQELQQgMAQsgAigCGAs7AQ4gAkEANgIQAkADQCACKAIQQdSXASgCAEkEQCACKAIQQQxsQdiXAWovAQAgAi8BDkYEQCACLQAXQQFxBEAgAiACKAIQQQxsQdiXAWooAgQ2AhwMBAsgAiACKAIQQQxsQdiXAWooAgg2AhwMAwUgAiACKAIQQQFqNgIQDAILAAsLIAJBADYCHAsgAigCHAvkAQEBfyMAQSBrIgMkACADIAA6ABsgAyABNgIUIAMgAjYCECADQcgAEBgiADYCDAJAIABFBEAgAygCEEEBQbSbASgCABAUIANBADYCHAwBCyADKAIMIAMoAhA2AgAgAygCDCADLQAbQQFxOgAEIAMoAgwgAygCFDYCCAJAIAMoAgwoAghBAU4EQCADKAIMKAIIQQlMDQELIAMoAgxBCTYCCAsgAygCDEEAOgAMIAMoAgxBADYCMCADKAIMQQA2AjQgAygCDEEANgI4IAMgAygCDDYCHAsgAygCHCEAIANBIGokACAACzgBAX8jAEEQayIBIAA2AgwgASgCDEEANgIAIAEoAgxBADYCBCABKAIMQQA2AgggASgCDEEAOgAMC+MIAQF/IwBBQGoiAiAANgI4IAIgATYCNCACIAIoAjgoAnw2AjAgAiACKAI4KAI4IAIoAjgoAmxqNgIsIAIgAigCOCgCeDYCICACIAIoAjgoApABNgIcIAICfyACKAI4KAJsIAIoAjgoAixBhgJrSwRAIAIoAjgoAmwgAigCOCgCLEGGAmtrDAELQQALNgIYIAIgAigCOCgCQDYCFCACIAIoAjgoAjQ2AhAgAiACKAI4KAI4IAIoAjgoAmxqQYICajYCDCACIAIoAiwgAigCIEEBa2otAAA6AAsgAiACKAIsIAIoAiBqLQAAOgAKIAIoAjgoAnggAigCOCgCjAFPBEAgAiACKAIwQQJ2NgIwCyACKAIcIAIoAjgoAnRLBEAgAiACKAI4KAJ0NgIcCwNAAkAgAiACKAI4KAI4IAIoAjRqNgIoAkAgAigCKCACKAIgai0AACACLQAKRw0AIAIoAiggAigCIEEBa2otAAAgAi0AC0cNACACKAIoLQAAIAIoAiwtAABHDQAgAiACKAIoIgBBAWo2AiggAC0AASACKAIsLQABRwRADAELIAIgAigCLEECajYCLCACIAIoAihBAWo2AigDQCACIAIoAiwiAEEBajYCLCAALQABIQEgAiACKAIoIgBBAWo2AigCf0EAIAAtAAEgAUcNABogAiACKAIsIgBBAWo2AiwgAC0AASEBIAIgAigCKCIAQQFqNgIoQQAgAC0AASABRw0AGiACIAIoAiwiAEEBajYCLCAALQABIQEgAiACKAIoIgBBAWo2AihBACAALQABIAFHDQAaIAIgAigCLCIAQQFqNgIsIAAtAAEhASACIAIoAigiAEEBajYCKEEAIAAtAAEgAUcNABogAiACKAIsIgBBAWo2AiwgAC0AASEBIAIgAigCKCIAQQFqNgIoQQAgAC0AASABRw0AGiACIAIoAiwiAEEBajYCLCAALQABIQEgAiACKAIoIgBBAWo2AihBACAALQABIAFHDQAaIAIgAigCLCIAQQFqNgIsIAAtAAEhASACIAIoAigiAEEBajYCKEEAIAAtAAEgAUcNABogAiACKAIsIgBBAWo2AiwgAC0AASEBIAIgAigCKCIAQQFqNgIoQQAgAC0AASABRw0AGiACKAIsIAIoAgxJC0EBcQ0ACyACQYICIAIoAgwgAigCLGtrNgIkIAIgAigCDEGCAms2AiwgAigCJCACKAIgSgRAIAIoAjggAigCNDYCcCACIAIoAiQ2AiAgAigCJCACKAIcTg0CIAIgAigCLCACKAIgQQFrai0AADoACyACIAIoAiwgAigCIGotAAA6AAoLCyACIAIoAhQgAigCNCACKAIQcUEBdGovAQAiATYCNEEAIQAgASACKAIYSwR/IAIgAigCMEEBayIANgIwIABBAEcFQQALQQFxDQELCwJAIAIoAiAgAigCOCgCdE0EQCACIAIoAiA2AjwMAQsgAiACKAI4KAJ0NgI8CyACKAI8C5IQAQF/IwBBMGsiAiQAIAIgADYCKCACIAE2AiQgAgJ/IAIoAigoAiwgAigCKCgCDEEFa0kEQCACKAIoKAIsDAELIAIoAigoAgxBBWsLNgIgIAJBADYCECACIAIoAigoAgAoAgQ2AgwDQAJAIAJB//8DNgIcIAIgAigCKCgCvC1BKmpBA3U2AhQgAigCKCgCACgCECACKAIUSQ0AIAIgAigCKCgCACgCECACKAIUazYCFCACIAIoAigoAmwgAigCKCgCXGs2AhggAigCHCACKAIYIAIoAigoAgAoAgRqSwRAIAIgAigCGCACKAIoKAIAKAIEajYCHAsgAigCHCACKAIUSwRAIAIgAigCFDYCHAsCQCACKAIcIAIoAiBPDQACQCACKAIcRQRAIAIoAiRBBEcNAQsgAigCJEUNACACKAIcIAIoAhggAigCKCgCACgCBGpGDQELDAELQQAhACACIAIoAiRBBEYEfyACKAIcIAIoAhggAigCKCgCACgCBGpGBUEAC0EBcTYCECACKAIoQQBBACACKAIQEF0gAigCKCgCCCACKAIoKAIUQQRraiACKAIcOgAAIAIoAigoAgggAigCKCgCFEEDa2ogAigCHEEIdjoAACACKAIoKAIIIAIoAigoAhRBAmtqIAIoAhxBf3M6AAAgAigCKCgCCCACKAIoKAIUQQFraiACKAIcQX9zQQh2OgAAIAIoAigoAgAQHCACKAIYBEAgAigCGCACKAIcSwRAIAIgAigCHDYCGAsgAigCKCgCACgCDCACKAIoKAI4IAIoAigoAlxqIAIoAhgQGRogAigCKCgCACIAIAIoAhggACgCDGo2AgwgAigCKCgCACIAIAAoAhAgAigCGGs2AhAgAigCKCgCACIAIAIoAhggACgCFGo2AhQgAigCKCIAIAIoAhggACgCXGo2AlwgAiACKAIcIAIoAhhrNgIcCyACKAIcBEAgAigCKCgCACACKAIoKAIAKAIMIAIoAhwQdhogAigCKCgCACIAIAIoAhwgACgCDGo2AgwgAigCKCgCACIAIAAoAhAgAigCHGs2AhAgAigCKCgCACIAIAIoAhwgACgCFGo2AhQLIAIoAhBFDQELCyACIAIoAgwgAigCKCgCACgCBGs2AgwgAigCDARAAkAgAigCDCACKAIoKAIsTwRAIAIoAihBAjYCsC0gAigCKCgCOCACKAIoKAIAKAIAIAIoAigoAixrIAIoAigoAiwQGRogAigCKCACKAIoKAIsNgJsDAELIAIoAgwgAigCKCgCPCACKAIoKAJsa08EQCACKAIoIgAgACgCbCACKAIoKAIsazYCbCACKAIoKAI4IAIoAigoAjggAigCKCgCLGogAigCKCgCbBAZGiACKAIoKAKwLUECSQRAIAIoAigiACAAKAKwLUEBajYCsC0LCyACKAIoKAI4IAIoAigoAmxqIAIoAigoAgAoAgAgAigCDGsgAigCDBAZGiACKAIoIgAgAigCDCAAKAJsajYCbAsgAigCKCACKAIoKAJsNgJcIAIoAigiAQJ/IAIoAgwgAigCKCgCLCACKAIoKAK0LWtLBEAgAigCKCgCLCACKAIoKAK0LWsMAQsgAigCDAsgASgCtC1qNgK0LQsgAigCKCgCwC0gAigCKCgCbEkEQCACKAIoIAIoAigoAmw2AsAtCwJAIAIoAhAEQCACQQM2AiwMAQsCQCACKAIkRQ0AIAIoAiRBBEYNACACKAIoKAIAKAIEDQAgAigCKCgCbCACKAIoKAJcRw0AIAJBATYCLAwBCyACIAIoAigoAjwgAigCKCgCbGtBAWs2AhQCQCACKAIoKAIAKAIEIAIoAhRNDQAgAigCKCgCXCACKAIoKAIsSA0AIAIoAigiACAAKAJcIAIoAigoAixrNgJcIAIoAigiACAAKAJsIAIoAigoAixrNgJsIAIoAigoAjggAigCKCgCOCACKAIoKAIsaiACKAIoKAJsEBkaIAIoAigoArAtQQJJBEAgAigCKCIAIAAoArAtQQFqNgKwLQsgAiACKAIoKAIsIAIoAhRqNgIUCyACKAIUIAIoAigoAgAoAgRLBEAgAiACKAIoKAIAKAIENgIUCyACKAIUBEAgAigCKCgCACACKAIoKAI4IAIoAigoAmxqIAIoAhQQdhogAigCKCIAIAIoAhQgACgCbGo2AmwLIAIoAigoAsAtIAIoAigoAmxJBEAgAigCKCACKAIoKAJsNgLALQsgAiACKAIoKAK8LUEqakEDdTYCFCACIAIoAigoAgwgAigCFGtB//8DSwR/Qf//AwUgAigCKCgCDCACKAIUaws2AhQgAgJ/IAIoAhQgAigCKCgCLEsEQCACKAIoKAIsDAELIAIoAhQLNgIgIAIgAigCKCgCbCACKAIoKAJcazYCGAJAIAIoAhggAigCIEkEQCACKAIYRQRAIAIoAiRBBEcNAgsgAigCJEUNASACKAIoKAIAKAIEDQEgAigCGCACKAIUSw0BCyACAn8gAigCGCACKAIUSwRAIAIoAhQMAQsgAigCGAs2AhwgAgJ/QQAgAigCJEEERw0AGkEAIAIoAigoAgAoAgQNABogAigCHCACKAIYRgtBAXE2AhAgAigCKCACKAIoKAI4IAIoAigoAlxqIAIoAhwgAigCEBBdIAIoAigiACACKAIcIAAoAlxqNgJcIAIoAigoAgAQHAsgAkECQQAgAigCEBs2AiwLIAIoAiwhACACQTBqJAAgAAuyAgEBfyMAQRBrIgEkACABIAA2AggCQCABKAIIEHgEQCABQX42AgwMAQsgASABKAIIKAIcKAIENgIEIAEoAggoAhwoAggEQCABKAIIKAIoIAEoAggoAhwoAgggASgCCCgCJBEEAAsgASgCCCgCHCgCRARAIAEoAggoAiggASgCCCgCHCgCRCABKAIIKAIkEQQACyABKAIIKAIcKAJABEAgASgCCCgCKCABKAIIKAIcKAJAIAEoAggoAiQRBAALIAEoAggoAhwoAjgEQCABKAIIKAIoIAEoAggoAhwoAjggASgCCCgCJBEEAAsgASgCCCgCKCABKAIIKAIcIAEoAggoAiQRBAAgASgCCEEANgIcIAFBfUEAIAEoAgRB8QBGGzYCDAsgASgCDCEAIAFBEGokACAAC+sXAQJ/IwBB8ABrIgMgADYCbCADIAE2AmggAyACNgJkIANBfzYCXCADIAMoAmgvAQI2AlQgA0EANgJQIANBBzYCTCADQQQ2AkggAygCVEUEQCADQYoBNgJMIANBAzYCSAsgA0EANgJgA0AgAygCYCADKAJkSkUEQCADIAMoAlQ2AlggAyADKAJoIAMoAmBBAWpBAnRqLwECNgJUIAMgAygCUEEBaiIANgJQAkACQCADKAJMIABMDQAgAygCWCADKAJURw0ADAELAkAgAygCUCADKAJISARAA0AgAyADKAJsQfwUaiADKAJYQQJ0ai8BAjYCRAJAIAMoAmwoArwtQRAgAygCRGtKBEAgAyADKAJsQfwUaiADKAJYQQJ0ai8BADYCQCADKAJsIgAgAC8BuC0gAygCQEH//wNxIAMoAmwoArwtdHI7AbgtIAMoAmwvAbgtQf8BcSEBIAMoAmwoAgghAiADKAJsIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAmwvAbgtQQh2IQEgAygCbCgCCCECIAMoAmwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCbCADKAJAQf//A3FBECADKAJsKAK8LWt1OwG4LSADKAJsIgAgACgCvC0gAygCREEQa2o2ArwtDAELIAMoAmwiACAALwG4LSADKAJsQfwUaiADKAJYQQJ0ai8BACADKAJsKAK8LXRyOwG4LSADKAJsIgAgAygCRCAAKAK8LWo2ArwtCyADIAMoAlBBAWsiADYCUCAADQALDAELAkAgAygCWARAIAMoAlggAygCXEcEQCADIAMoAmxB/BRqIAMoAlhBAnRqLwECNgI8AkAgAygCbCgCvC1BECADKAI8a0oEQCADIAMoAmxB/BRqIAMoAlhBAnRqLwEANgI4IAMoAmwiACAALwG4LSADKAI4Qf//A3EgAygCbCgCvC10cjsBuC0gAygCbC8BuC1B/wFxIQEgAygCbCgCCCECIAMoAmwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCbC8BuC1BCHYhASADKAJsKAIIIQIgAygCbCIEKAIUIQAgBCAAQQFqNgIUIAAgAmogAToAACADKAJsIAMoAjhB//8DcUEQIAMoAmwoArwta3U7AbgtIAMoAmwiACAAKAK8LSADKAI8QRBrajYCvC0MAQsgAygCbCIAIAAvAbgtIAMoAmxB/BRqIAMoAlhBAnRqLwEAIAMoAmwoArwtdHI7AbgtIAMoAmwiACADKAI8IAAoArwtajYCvC0LIAMgAygCUEEBazYCUAsgAyADKAJsLwG+FTYCNAJAIAMoAmwoArwtQRAgAygCNGtKBEAgAyADKAJsLwG8FTYCMCADKAJsIgAgAC8BuC0gAygCMEH//wNxIAMoAmwoArwtdHI7AbgtIAMoAmwvAbgtQf8BcSEBIAMoAmwoAgghAiADKAJsIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAmwvAbgtQQh2IQEgAygCbCgCCCECIAMoAmwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCbCADKAIwQf//A3FBECADKAJsKAK8LWt1OwG4LSADKAJsIgAgACgCvC0gAygCNEEQa2o2ArwtDAELIAMoAmwiACAALwG4LSADKAJsLwG8FSADKAJsKAK8LXRyOwG4LSADKAJsIgAgAygCNCAAKAK8LWo2ArwtCyADQQI2AiwCQCADKAJsKAK8LUEQIAMoAixrSgRAIAMgAygCUEEDazYCKCADKAJsIgAgAC8BuC0gAygCKEH//wNxIAMoAmwoArwtdHI7AbgtIAMoAmwvAbgtQf8BcSEBIAMoAmwoAgghAiADKAJsIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAmwvAbgtQQh2IQEgAygCbCgCCCECIAMoAmwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCbCADKAIoQf//A3FBECADKAJsKAK8LWt1OwG4LSADKAJsIgAgACgCvC0gAygCLEEQa2o2ArwtDAELIAMoAmwiACAALwG4LSADKAJQQQNrQf//A3EgAygCbCgCvC10cjsBuC0gAygCbCIAIAMoAiwgACgCvC1qNgK8LQsMAQsCQCADKAJQQQpMBEAgAyADKAJsLwHCFTYCJAJAIAMoAmwoArwtQRAgAygCJGtKBEAgAyADKAJsLwHAFTYCICADKAJsIgAgAC8BuC0gAygCIEH//wNxIAMoAmwoArwtdHI7AbgtIAMoAmwvAbgtQf8BcSEBIAMoAmwoAgghAiADKAJsIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAmwvAbgtQQh2IQEgAygCbCgCCCECIAMoAmwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCbCADKAIgQf//A3FBECADKAJsKAK8LWt1OwG4LSADKAJsIgAgACgCvC0gAygCJEEQa2o2ArwtDAELIAMoAmwiACAALwG4LSADKAJsLwHAFSADKAJsKAK8LXRyOwG4LSADKAJsIgAgAygCJCAAKAK8LWo2ArwtCyADQQM2AhwCQCADKAJsKAK8LUEQIAMoAhxrSgRAIAMgAygCUEEDazYCGCADKAJsIgAgAC8BuC0gAygCGEH//wNxIAMoAmwoArwtdHI7AbgtIAMoAmwvAbgtQf8BcSEBIAMoAmwoAgghAiADKAJsIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAmwvAbgtQQh2IQEgAygCbCgCCCECIAMoAmwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCbCADKAIYQf//A3FBECADKAJsKAK8LWt1OwG4LSADKAJsIgAgACgCvC0gAygCHEEQa2o2ArwtDAELIAMoAmwiACAALwG4LSADKAJQQQNrQf//A3EgAygCbCgCvC10cjsBuC0gAygCbCIAIAMoAhwgACgCvC1qNgK8LQsMAQsgAyADKAJsLwHGFTYCFAJAIAMoAmwoArwtQRAgAygCFGtKBEAgAyADKAJsLwHEFTYCECADKAJsIgAgAC8BuC0gAygCEEH//wNxIAMoAmwoArwtdHI7AbgtIAMoAmwvAbgtQf8BcSEBIAMoAmwoAgghAiADKAJsIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAmwvAbgtQQh2IQEgAygCbCgCCCECIAMoAmwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCbCADKAIQQf//A3FBECADKAJsKAK8LWt1OwG4LSADKAJsIgAgACgCvC0gAygCFEEQa2o2ArwtDAELIAMoAmwiACAALwG4LSADKAJsLwHEFSADKAJsKAK8LXRyOwG4LSADKAJsIgAgAygCFCAAKAK8LWo2ArwtCyADQQc2AgwCQCADKAJsKAK8LUEQIAMoAgxrSgRAIAMgAygCUEELazYCCCADKAJsIgAgAC8BuC0gAygCCEH//wNxIAMoAmwoArwtdHI7AbgtIAMoAmwvAbgtQf8BcSEBIAMoAmwoAgghAiADKAJsIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAmwvAbgtQQh2IQEgAygCbCgCCCECIAMoAmwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCbCADKAIIQf//A3FBECADKAJsKAK8LWt1OwG4LSADKAJsIgAgACgCvC0gAygCDEEQa2o2ArwtDAELIAMoAmwiACAALwG4LSADKAJQQQtrQf//A3EgAygCbCgCvC10cjsBuC0gAygCbCIAIAMoAgwgACgCvC1qNgK8LQsLCwsgA0EANgJQIAMgAygCWDYCXAJAIAMoAlRFBEAgA0GKATYCTCADQQM2AkgMAQsCQCADKAJYIAMoAlRGBEAgA0EGNgJMIANBAzYCSAwBCyADQQc2AkwgA0EENgJICwsLIAMgAygCYEEBajYCYAwBCwsLkQQBAX8jAEEwayIDIAA2AiwgAyABNgIoIAMgAjYCJCADQX82AhwgAyADKAIoLwECNgIUIANBADYCECADQQc2AgwgA0EENgIIIAMoAhRFBEAgA0GKATYCDCADQQM2AggLIAMoAiggAygCJEEBakECdGpB//8DOwECIANBADYCIANAIAMoAiAgAygCJEpFBEAgAyADKAIUNgIYIAMgAygCKCADKAIgQQFqQQJ0ai8BAjYCFCADIAMoAhBBAWoiADYCEAJAAkAgAygCDCAATA0AIAMoAhggAygCFEcNAAwBCwJAIAMoAhAgAygCCEgEQCADKAIsQfwUaiADKAIYQQJ0aiIAIAMoAhAgAC8BAGo7AQAMAQsCQCADKAIYBEAgAygCGCADKAIcRwRAIAMoAiwgAygCGEECdGpB/BRqIgAgAC8BAEEBajsBAAsgAygCLCIAIABBvBVqLwEAQQFqOwG8FQwBCwJAIAMoAhBBCkwEQCADKAIsIgAgAEHAFWovAQBBAWo7AcAVDAELIAMoAiwiACAAQcQVai8BAEEBajsBxBULCwsgA0EANgIQIAMgAygCGDYCHAJAIAMoAhRFBEAgA0GKATYCDCADQQM2AggMAQsCQCADKAIYIAMoAhRGBEAgA0EGNgIMIANBAzYCCAwBCyADQQc2AgwgA0EENgIICwsLIAMgAygCIEEBajYCIAwBCwsLpxIBAn8jAEHQAGsiAyAANgJMIAMgATYCSCADIAI2AkQgA0EANgI4IAMoAkwoAqAtBEADQCADIAMoAkwoAqQtIAMoAjhBAXRqLwEANgJAIAMoAkwoApgtIQAgAyADKAI4IgFBAWo2AjggAyAAIAFqLQAANgI8AkAgAygCQEUEQCADIAMoAkggAygCPEECdGovAQI2AiwCQCADKAJMKAK8LUEQIAMoAixrSgRAIAMgAygCSCADKAI8QQJ0ai8BADYCKCADKAJMIgAgAC8BuC0gAygCKEH//wNxIAMoAkwoArwtdHI7AbgtIAMoAkwvAbgtQf8BcSEBIAMoAkwoAgghAiADKAJMIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAkwvAbgtQQh2IQEgAygCTCgCCCECIAMoAkwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCTCADKAIoQf//A3FBECADKAJMKAK8LWt1OwG4LSADKAJMIgAgACgCvC0gAygCLEEQa2o2ArwtDAELIAMoAkwiACAALwG4LSADKAJIIAMoAjxBAnRqLwEAIAMoAkwoArwtdHI7AbgtIAMoAkwiACADKAIsIAAoArwtajYCvC0LDAELIAMgAygCPC0A0F02AjQgAyADKAJIIAMoAjRBgQJqQQJ0ai8BAjYCJAJAIAMoAkwoArwtQRAgAygCJGtKBEAgAyADKAJIIAMoAjRBgQJqQQJ0ai8BADYCICADKAJMIgAgAC8BuC0gAygCIEH//wNxIAMoAkwoArwtdHI7AbgtIAMoAkwvAbgtQf8BcSEBIAMoAkwoAgghAiADKAJMIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAkwvAbgtQQh2IQEgAygCTCgCCCECIAMoAkwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCTCADKAIgQf//A3FBECADKAJMKAK8LWt1OwG4LSADKAJMIgAgACgCvC0gAygCJEEQa2o2ArwtDAELIAMoAkwiACAALwG4LSADKAJIIAMoAjRBgQJqQQJ0ai8BACADKAJMKAK8LXRyOwG4LSADKAJMIgAgAygCJCAAKAK8LWo2ArwtCyADIAMoAjRBAnRBkOoAaigCADYCMCADKAIwBEAgAyADKAI8IAMoAjRBAnRBgO0AaigCAGs2AjwgAyADKAIwNgIcAkAgAygCTCgCvC1BECADKAIca0oEQCADIAMoAjw2AhggAygCTCIAIAAvAbgtIAMoAhhB//8DcSADKAJMKAK8LXRyOwG4LSADKAJMLwG4LUH/AXEhASADKAJMKAIIIQIgAygCTCIEKAIUIQAgBCAAQQFqNgIUIAAgAmogAToAACADKAJMLwG4LUEIdiEBIAMoAkwoAgghAiADKAJMIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAkwgAygCGEH//wNxQRAgAygCTCgCvC1rdTsBuC0gAygCTCIAIAAoArwtIAMoAhxBEGtqNgK8LQwBCyADKAJMIgAgAC8BuC0gAygCPEH//wNxIAMoAkwoArwtdHI7AbgtIAMoAkwiACADKAIcIAAoArwtajYCvC0LCyADIAMoAkBBAWs2AkAgAwJ/IAMoAkBBgAJJBEAgAygCQC0A0FkMAQsgAygCQEEHdkGAAmotANBZCzYCNCADIAMoAkQgAygCNEECdGovAQI2AhQCQCADKAJMKAK8LUEQIAMoAhRrSgRAIAMgAygCRCADKAI0QQJ0ai8BADYCECADKAJMIgAgAC8BuC0gAygCEEH//wNxIAMoAkwoArwtdHI7AbgtIAMoAkwvAbgtQf8BcSEBIAMoAkwoAgghAiADKAJMIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAkwvAbgtQQh2IQEgAygCTCgCCCECIAMoAkwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCTCADKAIQQf//A3FBECADKAJMKAK8LWt1OwG4LSADKAJMIgAgACgCvC0gAygCFEEQa2o2ArwtDAELIAMoAkwiACAALwG4LSADKAJEIAMoAjRBAnRqLwEAIAMoAkwoArwtdHI7AbgtIAMoAkwiACADKAIUIAAoArwtajYCvC0LIAMgAygCNEECdEGQ6wBqKAIANgIwIAMoAjAEQCADIAMoAkAgAygCNEECdEGA7gBqKAIAazYCQCADIAMoAjA2AgwCQCADKAJMKAK8LUEQIAMoAgxrSgRAIAMgAygCQDYCCCADKAJMIgAgAC8BuC0gAygCCEH//wNxIAMoAkwoArwtdHI7AbgtIAMoAkwvAbgtQf8BcSEBIAMoAkwoAgghAiADKAJMIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAkwvAbgtQQh2IQEgAygCTCgCCCECIAMoAkwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCTCADKAIIQf//A3FBECADKAJMKAK8LWt1OwG4LSADKAJMIgAgACgCvC0gAygCDEEQa2o2ArwtDAELIAMoAkwiACAALwG4LSADKAJAQf//A3EgAygCTCgCvC10cjsBuC0gAygCTCIAIAMoAgwgACgCvC1qNgK8LQsLCyADKAI4IAMoAkwoAqAtSQ0ACwsgAyADKAJILwGCCDYCBAJAIAMoAkwoArwtQRAgAygCBGtKBEAgAyADKAJILwGACDYCACADKAJMIgAgAC8BuC0gAygCAEH//wNxIAMoAkwoArwtdHI7AbgtIAMoAkwvAbgtQf8BcSEBIAMoAkwoAgghAiADKAJMIgQoAhQhACAEIABBAWo2AhQgACACaiABOgAAIAMoAkwvAbgtQQh2IQEgAygCTCgCCCECIAMoAkwiBCgCFCEAIAQgAEEBajYCFCAAIAJqIAE6AAAgAygCTCADKAIAQf//A3FBECADKAJMKAK8LWt1OwG4LSADKAJMIgAgACgCvC0gAygCBEEQa2o2ArwtDAELIAMoAkwiACAALwG4LSADKAJILwGACCADKAJMKAK8LXRyOwG4LSADKAJMIgAgAygCBCAAKAK8LWo2ArwtCwuXAgEEfyMAQRBrIgEgADYCDAJAIAEoAgwoArwtQRBGBEAgASgCDC8BuC1B/wFxIQIgASgCDCgCCCEDIAEoAgwiBCgCFCEAIAQgAEEBajYCFCAAIANqIAI6AAAgASgCDC8BuC1BCHYhAiABKAIMKAIIIQMgASgCDCIEKAIUIQAgBCAAQQFqNgIUIAAgA2ogAjoAACABKAIMQQA7AbgtIAEoAgxBADYCvC0MAQsgASgCDCgCvC1BCE4EQCABKAIMLwG4LSECIAEoAgwoAgghAyABKAIMIgQoAhQhACAEIABBAWo2AhQgACADaiACOgAAIAEoAgwiACAALwG4LUEIdjsBuC0gASgCDCIAIAAoArwtQQhrNgK8LQsLC+8BAQR/IwBBEGsiASAANgIMAkAgASgCDCgCvC1BCEoEQCABKAIMLwG4LUH/AXEhAiABKAIMKAIIIQMgASgCDCIEKAIUIQAgBCAAQQFqNgIUIAAgA2ogAjoAACABKAIMLwG4LUEIdiECIAEoAgwoAgghAyABKAIMIgQoAhQhACAEIABBAWo2AhQgACADaiACOgAADAELIAEoAgwoArwtQQBKBEAgASgCDC8BuC0hAiABKAIMKAIIIQMgASgCDCIEKAIUIQAgBCAAQQFqNgIUIAAgA2ogAjoAAAsLIAEoAgxBADsBuC0gASgCDEEANgK8LQv8AQEBfyMAQRBrIgEgADYCDCABQQA2AggDQCABKAIIQZ4CTkUEQCABKAIMQZQBaiABKAIIQQJ0akEAOwEAIAEgASgCCEEBajYCCAwBCwsgAUEANgIIA0AgASgCCEEeTkUEQCABKAIMQYgTaiABKAIIQQJ0akEAOwEAIAEgASgCCEEBajYCCAwBCwsgAUEANgIIA0AgASgCCEETTkUEQCABKAIMQfwUaiABKAIIQQJ0akEAOwEAIAEgASgCCEEBajYCCAwBCwsgASgCDEEBOwGUCSABKAIMQQA2AqwtIAEoAgxBADYCqC0gASgCDEEANgKwLSABKAIMQQA2AqAtCyIBAX8jAEEQayIBJAAgASAANgIMIAEoAgwQFSABQRBqJAAL6QEBAX8jAEEwayICIAA2AiQgAiABNwMYIAJCADcDECACIAIoAiQpAwhCAX03AwgCQANAIAIpAxAgAikDCFQEQCACIAIpAxAgAikDCCACKQMQfUIBiHw3AwACQCACKAIkKAIEIAIpAwCnQQN0aikDACACKQMYVgRAIAIgAikDAEIBfTcDCAwBCwJAIAIpAwAgAigCJCkDCFIEQCACKAIkKAIEIAIpAwBCAXynQQN0aikDACACKQMYWA0BCyACIAIpAwA3AygMBAsgAiACKQMAQgF8NwMQCwwBCwsgAiACKQMQNwMoCyACKQMoC6cBAQF/IwBBMGsiBCQAIAQgADYCKCAEIAE2AiQgBCACNwMYIAQgAzYCFCAEIAQoAigpAzggBCgCKCkDMCAEKAIkIAQpAxggBCgCFBCIATcDCAJAIAQpAwhCAFMEQCAEQX82AiwMAQsgBCgCKCAEKQMINwM4IAQoAiggBCgCKCkDOBDAASECIAQoAiggAjcDQCAEQQA2AiwLIAQoAiwhACAEQTBqJAAgAAvrAQEBfyMAQSBrIgMkACADIAA2AhggAyABNwMQIAMgAjYCDAJAIAMpAxAgAygCGCkDEFQEQCADQQE6AB8MAQsgAyADKAIYKAIAIAMpAxBCBIanEE4iADYCCCAARQRAIAMoAgxBDkEAEBQgA0EAOgAfDAELIAMoAhggAygCCDYCACADIAMoAhgoAgQgAykDEEIBfEIDhqcQTiIANgIEIABFBEAgAygCDEEOQQAQFCADQQA6AB8MAQsgAygCGCADKAIENgIEIAMoAhggAykDEDcDECADQQE6AB8LIAMtAB9BAXEhACADQSBqJAAgAAvOAgEBfyMAQTBrIgQkACAEIAA2AiggBCABNwMgIAQgAjYCHCAEIAM2AhgCQAJAIAQoAigNACAEKQMgUA0AIAQoAhhBEkEAEBQgBEEANgIsDAELIAQgBCgCKCAEKQMgIAQoAhwgBCgCGBBMIgA2AgwgAEUEQCAEQQA2AiwMAQsgBEEYEBgiADYCFCAARQRAIAQoAhhBDkEAEBQgBCgCDBAyIARBADYCLAwBCyAEKAIUIAQoAgw2AhAgBCgCFEEANgIUQQAQASEAIAQoAhQgADYCDCMAQRBrIgAgBCgCFDYCDCAAKAIMQQA2AgAgACgCDEEANgIEIAAoAgxBADYCCCAEQQIgBCgCFCAEKAIYEIMBIgA2AhAgAEUEQCAEKAIUKAIQEDIgBCgCFBAVIARBADYCLAwBCyAEIAQoAhA2AiwLIAQoAiwhACAEQTBqJAAgAAupAQEBfyMAQTBrIgQkACAEIAA2AiggBCABNwMgIAQgAjYCHCAEIAM2AhgCQCAEKAIoRQRAIAQpAyBCAFIEQCAEKAIYQRJBABAUIARBADYCLAwCCyAEQQBCACAEKAIcIAQoAhgQwwE2AiwMAQsgBCAEKAIoNgIIIAQgBCkDIDcDECAEIARBCGpCASAEKAIcIAQoAhgQwwE2AiwLIAQoAiwhACAEQTBqJAAgAAtGAQF/IwBBIGsiAyQAIAMgADYCHCADIAE3AxAgAyACNgIMIAMoAhwgAykDECADKAIMIAMoAhxBCGoQTSEAIANBIGokACAAC4sMAQZ/IAAgAWohBQJAAkAgACgCBCICQQFxDQAgAkEDcUUNASAAKAIAIgIgAWohAQJAIAAgAmsiAEH4mwEoAgBHBEAgAkH/AU0EQCAAKAIIIgQgAkEDdiICQQN0QYycAWpGGiAAKAIMIgMgBEcNAkHkmwFB5JsBKAIAQX4gAndxNgIADAMLIAAoAhghBgJAIAAgACgCDCIDRwRAIAAoAggiAkH0mwEoAgBJGiACIAM2AgwgAyACNgIIDAELAkAgAEEUaiICKAIAIgQNACAAQRBqIgIoAgAiBA0AQQAhAwwBCwNAIAIhByAEIgNBFGoiAigCACIEDQAgA0EQaiECIAMoAhAiBA0ACyAHQQA2AgALIAZFDQICQCAAIAAoAhwiBEECdEGUngFqIgIoAgBGBEAgAiADNgIAIAMNAUHomwFB6JsBKAIAQX4gBHdxNgIADAQLIAZBEEEUIAYoAhAgAEYbaiADNgIAIANFDQMLIAMgBjYCGCAAKAIQIgIEQCADIAI2AhAgAiADNgIYCyAAKAIUIgJFDQIgAyACNgIUIAIgAzYCGAwCCyAFKAIEIgJBA3FBA0cNAUHsmwEgATYCACAFIAJBfnE2AgQgACABQQFyNgIEIAUgATYCAA8LIAQgAzYCDCADIAQ2AggLAkAgBSgCBCICQQJxRQRAIAVB/JsBKAIARgRAQfybASAANgIAQfCbAUHwmwEoAgAgAWoiATYCACAAIAFBAXI2AgQgAEH4mwEoAgBHDQNB7JsBQQA2AgBB+JsBQQA2AgAPCyAFQfibASgCAEYEQEH4mwEgADYCAEHsmwFB7JsBKAIAIAFqIgE2AgAgACABQQFyNgIEIAAgAWogATYCAA8LIAJBeHEgAWohAQJAIAJB/wFNBEAgBSgCCCIEIAJBA3YiAkEDdEGMnAFqRhogBCAFKAIMIgNGBEBB5JsBQeSbASgCAEF+IAJ3cTYCAAwCCyAEIAM2AgwgAyAENgIIDAELIAUoAhghBgJAIAUgBSgCDCIDRwRAIAUoAggiAkH0mwEoAgBJGiACIAM2AgwgAyACNgIIDAELAkAgBUEUaiIEKAIAIgINACAFQRBqIgQoAgAiAg0AQQAhAwwBCwNAIAQhByACIgNBFGoiBCgCACICDQAgA0EQaiEEIAMoAhAiAg0ACyAHQQA2AgALIAZFDQACQCAFIAUoAhwiBEECdEGUngFqIgIoAgBGBEAgAiADNgIAIAMNAUHomwFB6JsBKAIAQX4gBHdxNgIADAILIAZBEEEUIAYoAhAgBUYbaiADNgIAIANFDQELIAMgBjYCGCAFKAIQIgIEQCADIAI2AhAgAiADNgIYCyAFKAIUIgJFDQAgAyACNgIUIAIgAzYCGAsgACABQQFyNgIEIAAgAWogATYCACAAQfibASgCAEcNAUHsmwEgATYCAA8LIAUgAkF+cTYCBCAAIAFBAXI2AgQgACABaiABNgIACyABQf8BTQRAIAFBA3YiAkEDdEGMnAFqIQECf0HkmwEoAgAiA0EBIAJ0IgJxRQRAQeSbASACIANyNgIAIAEMAQsgASgCCAshAiABIAA2AgggAiAANgIMIAAgATYCDCAAIAI2AggPC0EfIQIgAEIANwIQIAFB////B00EQCABQQh2IgIgAkGA/j9qQRB2QQhxIgR0IgIgAkGA4B9qQRB2QQRxIgN0IgIgAkGAgA9qQRB2QQJxIgJ0QQ92IAMgBHIgAnJrIgJBAXQgASACQRVqdkEBcXJBHGohAgsgACACNgIcIAJBAnRBlJ4BaiEHAkACQEHomwEoAgAiBEEBIAJ0IgNxRQRAQeibASADIARyNgIAIAcgADYCACAAIAc2AhgMAQsgAUEAQRkgAkEBdmsgAkEfRht0IQIgBygCACEDA0AgAyIEKAIEQXhxIAFGDQIgAkEddiEDIAJBAXQhAiAEIANBBHFqIgdBEGooAgAiAw0ACyAHIAA2AhAgACAENgIYCyAAIAA2AgwgACAANgIIDwsgBCgCCCIBIAA2AgwgBCAANgIIIABBADYCGCAAIAQ2AgwgACABNgIICwsGAEG0mwELtQkBAX8jAEHgwABrIgUkACAFIAA2AtRAIAUgATYC0EAgBSACNgLMQCAFIAM3A8BAIAUgBDYCvEAgBSAFKALQQDYCuEACQAJAAkACQAJAAkACQAJAAkACQAJAAkAgBSgCvEAOEQMEAAYBAgUJCgoKCgoKCAoHCgsgBUIANwPYQAwKCyAFIAUoArhAQeQAaiAFKALMQCAFKQPAQBBDNwPYQAwJCyAFKAK4QBAVIAVCADcD2EAMCAsgBSgCuEAoAhAEQCAFIAUoArhAKAIQIAUoArhAKQMYIAUoArhAQeQAahBgIgM3A5hAIANQBEAgBUJ/NwPYQAwJCyAFKAK4QCkDCCAFKAK4QCkDCCAFKQOYQHxWBEAgBSgCuEBB5ABqQRVBABAUIAVCfzcD2EAMCQsgBSgCuEAiACAFKQOYQCAAKQMAfDcDACAFKAK4QCIAIAUpA5hAIAApAwh8NwMIIAUoArhAQQA2AhALIAUoArhALQB4QQFxRQRAIAVCADcDqEADQCAFKQOoQCAFKAK4QCkDAFQEQCAFIAUoArhAKQMAIAUpA6hAfUKAwABWBH5CgMAABSAFKAK4QCkDACAFKQOoQH0LNwOgQCAFIAUoAtRAIAVBEGogBSkDoEAQKyIDNwOwQCADQgBTBEAgBSgCuEBB5ABqIAUoAtRAEBcgBUJ/NwPYQAwLCyAFKQOwQFAEQCAFKAK4QEHkAGpBEUEAEBQgBUJ/NwPYQAwLBSAFIAUpA7BAIAUpA6hAfDcDqEAMAgsACwsLIAUoArhAIAUoArhAKQMANwMgIAVCADcD2EAMBwsgBSkDwEAgBSgCuEApAwggBSgCuEApAyB9VgRAIAUgBSgCuEApAwggBSgCuEApAyB9NwPAQAsgBSkDwEBQBEAgBUIANwPYQAwHCyAFKAK4QC0AeEEBcQRAIAUoAtRAIAUoArhAKQMgQQAQJ0EASARAIAUoArhAQeQAaiAFKALUQBAXIAVCfzcD2EAMCAsLIAUgBSgC1EAgBSgCzEAgBSkDwEAQKyIDNwOwQCADQgBTBEAgBSgCuEBB5ABqQRFBABAUIAVCfzcD2EAMBwsgBSgCuEAiACAFKQOwQCAAKQMgfDcDICAFKQOwQFAEQCAFKAK4QCkDICAFKAK4QCkDCFQEQCAFKAK4QEHkAGpBEUEAEBQgBUJ/NwPYQAwICwsgBSAFKQOwQDcD2EAMBgsgBSAFKAK4QCkDICAFKAK4QCkDAH0gBSgCuEApAwggBSgCuEApAwB9IAUoAsxAIAUpA8BAIAUoArhAQeQAahCIATcDCCAFKQMIQgBTBEAgBUJ/NwPYQAwGCyAFKAK4QCAFKQMIIAUoArhAKQMAfDcDICAFQgA3A9hADAULIAUgBSgCzEA2AgQgBSgCBCAFKAK4QEEoaiAFKAK4QEHkAGoQhAFBAEgEQCAFQn83A9hADAULIAVCADcD2EAMBAsgBSAFKAK4QCwAYKw3A9hADAMLIAUgBSgCuEApA3A3A9hADAILIAUgBSgCuEApAyAgBSgCuEApAwB9NwPYQAwBCyAFKAK4QEHkAGpBHEEAEBQgBUJ/NwPYQAsgBSkD2EAhAyAFQeDAAGokACADCwgAQQFBDBB/CyIBAX8jAEEQayIBIAA2AgwgASgCDCIAIAAoAjBBAWo2AjALBwAgACgCLAsHACAAKAIoCxgBAX8jAEEQayIBIAA2AgwgASgCDEEMagsHACAAKAIYCwcAIAAoAhALBwAgACgCCAtFAEGgmwFCADcDAEGYmwFCADcDAEGQmwFCADcDAEGImwFCADcDAEGAmwFCADcDAEH4mgFCADcDAEHwmgFCADcDAEHwmgELFAAgACABrSACrUIghoQgAyAEEH4LEwEBfiAAEEkiAUIgiKcQACABpwsVACAAIAGtIAKtQiCGhCADIAQQxAELFAAgACABIAKtIAOtQiCGhCAEEH0LrQQBAX8jAEEgayIFJAAgBSAANgIYIAUgAa0gAq1CIIaENwMQIAUgAzYCDCAFIAQ2AggCQAJAIAUpAxAgBSgCGCkDMFQEQCAFKAIIQQlNDQELIAUoAhhBCGpBEkEAEBQgBUF/NgIcDAELIAUoAhgoAhhBAnEEQCAFKAIYQQhqQRlBABAUIAVBfzYCHAwBCwJ/IAUoAgwhASMAQRBrIgAkACAAIAE2AgggAEEBOgAHAkAgACgCCEUEQCAAQQE6AA8MAQsgACAAKAIIIAAtAAdBAXEQswFBAEc6AA8LIAAtAA9BAXEhASAAQRBqJAAgAUULBEAgBSgCGEEIakEQQQAQFCAFQX82AhwMAQsgBSAFKAIYKAJAIAUpAxCnQQR0ajYCBCAFIAUoAgQoAgAEfyAFKAIEKAIAKAIQBUF/CzYCAAJAIAUoAgwgBSgCAEYEQCAFKAIEKAIEBEAgBSgCBCgCBCIAIAAoAgBBfnE2AgAgBSgCBCgCBEEAOwFQIAUoAgQoAgQoAgBFBEAgBSgCBCgCBBA3IAUoAgRBADYCBAsLDAELIAUoAgQoAgRFBEAgBSgCBCgCABBAIQAgBSgCBCAANgIEIABFBEAgBSgCGEEIakEOQQAQFCAFQX82AhwMAwsLIAUoAgQoAgQgBSgCDDYCECAFKAIEKAIEIAUoAgg7AVAgBSgCBCgCBCIAIAAoAgBBAXI2AgALIAVBADYCHAsgBSgCHCEAIAVBIGokACAACxcBAX4gACABIAIQciIDQiCIpxAAIAOnCx8BAX4gACABIAKtIAOtQiCGhBArIgRCIIinEAAgBKcLrgECAX8BfgJ/IwBBIGsiAiAANgIUIAIgATYCEAJAIAIoAhRFBEAgAkJ/NwMYDAELIAIoAhBBCHEEQCACIAIoAhQpAzA3AwgDQCACKQMIQgBSBH8gAigCFCgCQCACKQMIQgF9p0EEdGooAgAFQQELRQRAIAIgAikDCEIBfTcDCAwBCwsgAiACKQMINwMYDAELIAIgAigCFCkDMDcDGAsgAikDGCIDQiCIpwsQACADpwsTACAAIAGtIAKtQiCGhCADEMUBC4gCAgF/AX4CfyMAQSBrIgQkACAEIAA2AhQgBCABNgIQIAQgAq0gA61CIIaENwMIAkAgBCgCFEUEQCAEQn83AxgMAQsgBCgCFCgCBARAIARCfzcDGAwBCyAEKQMIQv///////////wBWBEAgBCgCFEEEakESQQAQFCAEQn83AxgMAQsCQCAEKAIULQAQQQFxRQRAIAQpAwhQRQ0BCyAEQgA3AxgMAQsgBCAEKAIUKAIUIAQoAhAgBCkDCBArIgU3AwAgBUIAUwRAIAQoAhRBBGogBCgCFCgCFBAXIARCfzcDGAwBCyAEIAQpAwA3AxgLIAQpAxghBSAEQSBqJAAgBUIgiKcLEAAgBacLTwEBfyMAQSBrIgQkACAEIAA2AhwgBCABrSACrUIghoQ3AxAgBCADNgIMIAQoAhwgBCkDECAEKAIMIAQoAhwoAhwQrQEhACAEQSBqJAAgAAvZAwEBfyMAQSBrIgUkACAFIAA2AhggBSABrSACrUIghoQ3AxAgBSADNgIMIAUgBDYCCAJAIAUoAhggBSkDEEEAQQAQP0UEQCAFQX82AhwMAQsgBSgCGCgCGEECcQRAIAUoAhhBCGpBGUEAEBQgBUF/NgIcDAELIAUoAhgoAkAgBSkDEKdBBHRqKAIIBEAgBSgCGCgCQCAFKQMQp0EEdGooAgggBSgCDBBnQQBIBEAgBSgCGEEIakEPQQAQFCAFQX82AhwMAgsgBUEANgIcDAELIAUgBSgCGCgCQCAFKQMQp0EEdGo2AgQgBSAFKAIEKAIABH8gBSgCDCAFKAIEKAIAKAIURwVBAQtBAXE2AgACQCAFKAIABEAgBSgCBCgCBEUEQCAFKAIEKAIAEEAhACAFKAIEIAA2AgQgAEUEQCAFKAIYQQhqQQ5BABAUIAVBfzYCHAwECwsgBSgCBCgCBCAFKAIMNgIUIAUoAgQoAgQiACAAKAIAQSByNgIADAELIAUoAgQoAgQEQCAFKAIEKAIEIgAgACgCAEFfcTYCACAFKAIEKAIEKAIARQRAIAUoAgQoAgQQNyAFKAIEQQA2AgQLCwsgBUEANgIcCyAFKAIcIQAgBUEgaiQAIAALFwAgACABrSACrUIghoQgAyAEIAUQmQELEgAgACABrSACrUIghoQgAxAnC48BAgF/AX4CfyMAQSBrIgQkACAEIAA2AhQgBCABNgIQIAQgAjYCDCAEIAM2AggCQAJAIAQoAhAEQCAEKAIMDQELIAQoAhRBCGpBEkEAEBQgBEJ/NwMYDAELIAQgBCgCFCAEKAIQIAQoAgwgBCgCCBCaATcDGAsgBCkDGCEFIARBIGokACAFQiCIpwsQACAFpwuFBQIBfwF+An8jAEEwayIDJAAgAyAANgIkIAMgATYCICADIAI2AhwCQCADKAIkKAIYQQJxBEAgAygCJEEIakEZQQAQFCADQn83AygMAQsgAygCIEUEQCADKAIkQQhqQRJBABAUIANCfzcDKAwBCyADQQA2AgwgAyADKAIgEC42AhggAygCICADKAIYQQFraiwAAEEvRwRAIAMgAygCGEECahAYIgA2AgwgAEUEQCADKAIkQQhqQQ5BABAUIANCfzcDKAwCCwJAAkAgAygCDCIBIAMoAiAiAHNBA3ENACAAQQNxBEADQCABIAAtAAAiAjoAACACRQ0DIAFBAWohASAAQQFqIgBBA3ENAAsLIAAoAgAiAkF/cyACQYGChAhrcUGAgYKEeHENAANAIAEgAjYCACAAKAIEIQIgAUEEaiEBIABBBGohACACQYGChAhrIAJBf3NxQYCBgoR4cUUNAAsLIAEgAC0AACICOgAAIAJFDQADQCABIAAtAAEiAjoAASABQQFqIQEgAEEBaiEAIAINAAsLIAMoAgwgAygCGGpBLzoAACADKAIMIAMoAhhBAWpqQQA6AAALIAMgAygCJEEAQgBBABB9IgA2AgggAEUEQCADKAIMEBUgA0J/NwMoDAELIAMgAygCJAJ/IAMoAgwEQCADKAIMDAELIAMoAiALIAMoAgggAygCHBCaATcDECADKAIMEBUCQCADKQMQQgBTBEAgAygCCBAbDAELIAMoAiQgAykDEEEAQQNBgID8jwQQmQFBAEgEQCADKAIkIAMpAxAQmAEaIANCfzcDKAwCCwsgAyADKQMQNwMoCyADKQMoIQQgA0EwaiQAIARCIIinCxAAIASnCxEAIAAgAa0gAq1CIIaEEJgBCxcAIAAgAa0gAq1CIIaEIAMgBCAFEIoBC38CAX8BfiMAQSBrIgMkACADIAA2AhggAyABNgIUIAMgAjYCECADIAMoAhggAygCFCADKAIQEHIiBDcDCAJAIARCAFMEQCADQQA2AhwMAQsgAyADKAIYIAMpAwggAygCECADKAIYKAIcEK0BNgIcCyADKAIcIQAgA0EgaiQAIAALEAAjACAAa0FwcSIAJAAgAAsGACAAJAALBAAjAAuCAQIBfwF+IwBBIGsiBCQAIAQgADYCGCAEIAE2AhQgBCACNgIQIAQgAzYCDCAEIAQoAhggBCgCFCAEKAIQEHIiBTcDAAJAIAVCAFMEQCAEQX82AhwMAQsgBCAEKAIYIAQpAwAgBCgCECAEKAIMEH42AhwLIAQoAhwhACAEQSBqJAAgAAvQRQMGfwF+AnwjAEHgAGsiASQAIAEgADYCWAJAIAEoAlhFBEAgAUF/NgJcDAELIwBBIGsiACABKAJYNgIcIAAgAUFAazYCGCAAQQA2AhQgAEIANwMAAkAgACgCHC0AKEEBcUUEQCAAKAIcKAIYIAAoAhwoAhRGDQELIABBATYCFAsgAEIANwMIA0AgACkDCCAAKAIcKQMwVARAAkACQCAAKAIcKAJAIAApAwinQQR0aigCCA0AIAAoAhwoAkAgACkDCKdBBHRqLQAMQQFxDQAgACgCHCgCQCAAKQMIp0EEdGooAgRFDQEgACgCHCgCQCAAKQMIp0EEdGooAgQoAgBFDQELIABBATYCFAsgACgCHCgCQCAAKQMIp0EEdGotAAxBAXFFBEAgACAAKQMAQgF8NwMACyAAIAApAwhCAXw3AwgMAQsLIAAoAhgEQCAAKAIYIAApAwA3AwALIAEgACgCFDYCJCABKQNAUARAAkAgASgCWCgCBEEIcUUEQCABKAIkRQ0BCwJ/IAEoAlgoAgAhAiMAQRBrIgAkACAAIAI2AggCQCAAKAIIKAIkQQNGBEAgAEEANgIMDAELIAAoAggoAiAEQCAAKAIIEC9BAEgEQCAAQX82AgwMAgsLIAAoAggoAiQEQCAAKAIIEGILIAAoAghBAEIAQQ8QIEIAUwRAIABBfzYCDAwBCyAAKAIIQQM2AiQgAEEANgIMCyAAKAIMIQIgAEEQaiQAIAJBAEgLBEACQAJ/IwBBEGsiACABKAJYKAIANgIMIwBBEGsiAiAAKAIMQQxqNgIMIAIoAgwoAgBBFkYLBEAjAEEQayIAIAEoAlgoAgA2AgwjAEEQayICIAAoAgxBDGo2AgwgAigCDCgCBEEsRg0BCyABKAJYQQhqIAEoAlgoAgAQFyABQX82AlwMBAsLCyABKAJYEDwgAUEANgJcDAELIAEoAiRFBEAgASgCWBA8IAFBADYCXAwBCyABKQNAIAEoAlgpAzBWBEAgASgCWEEIakEUQQAQFCABQX82AlwMAQsgASABKQNAp0EDdBAYIgA2AiggAEUEQCABQX82AlwMAQsgAUJ/NwM4IAFCADcDSCABQgA3A1ADQCABKQNQIAEoAlgpAzBUBEACQCABKAJYKAJAIAEpA1CnQQR0aigCAEUNAAJAIAEoAlgoAkAgASkDUKdBBHRqKAIIDQAgASgCWCgCQCABKQNQp0EEdGotAAxBAXENACABKAJYKAJAIAEpA1CnQQR0aigCBEUNASABKAJYKAJAIAEpA1CnQQR0aigCBCgCAEUNAQsgAQJ+IAEpAzggASgCWCgCQCABKQNQp0EEdGooAgApA0hUBEAgASkDOAwBCyABKAJYKAJAIAEpA1CnQQR0aigCACkDSAs3AzgLIAEoAlgoAkAgASkDUKdBBHRqLQAMQQFxRQRAIAEpA0ggASkDQFoEQCABKAIoEBUgASgCWEEIakEUQQAQFCABQX82AlwMBAsgASgCKCABKQNIp0EDdGogASkDUDcDACABIAEpA0hCAXw3A0gLIAEgASkDUEIBfDcDUAwBCwsgASkDSCABKQNAVARAIAEoAigQFSABKAJYQQhqQRRBABAUIAFBfzYCXAwBCwJAAn8jAEEQayIAIAEoAlgoAgA2AgwgACgCDCkDGEKAgAiDUAsEQCABQgA3AzgMAQsgASkDOEJ/UQRAIAFCfzcDGCABQgA3AzggAUIANwNQA0AgASkDUCABKAJYKQMwVARAIAEoAlgoAkAgASkDUKdBBHRqKAIABEAgASgCWCgCQCABKQNQp0EEdGooAgApA0ggASkDOFoEQCABIAEoAlgoAkAgASkDUKdBBHRqKAIAKQNINwM4IAEgASkDUDcDGAsLIAEgASkDUEIBfDcDUAwBCwsgASkDGEJ/UgRAIAEoAlghAiABKQMYIQcgASgCWEEIaiEDIwBBMGsiACQAIAAgAjYCJCAAIAc3AxggACADNgIUIAAgACgCJCAAKQMYIAAoAhQQYCIHNwMIAkAgB1AEQCAAQgA3AygMAQsgACAAKAIkKAJAIAApAxinQQR0aigCADYCBAJAIAApAwggACkDCCAAKAIEKQMgfFgEQCAAKQMIIAAoAgQpAyB8Qv///////////wBYDQELIAAoAhRBBEEWEBQgAEIANwMoDAELIAAgACgCBCkDICAAKQMIfDcDCCAAKAIELwEMQQhxBEAgACgCJCgCACAAKQMIQQAQJ0EASARAIAAoAhQgACgCJCgCABAXIABCADcDKAwCCyAAKAIkKAIAIABCBBArQgRSBEAgACgCFCAAKAIkKAIAEBcgAEIANwMoDAILIAAoAABB0JadwABGBEAgACAAKQMIQgR8NwMICyAAIAApAwhCDHw3AwggACgCBEEAEGVBAXEEQCAAIAApAwhCCHw3AwgLIAApAwhC////////////AFYEQCAAKAIUQQRBFhAUIABCADcDKAwCCwsgACAAKQMINwMoCyAAKQMoIQcgAEEwaiQAIAEgBzcDOCAHUARAIAEoAigQFSABQX82AlwMBAsLCyABKQM4QgBSBEACfyABKAJYKAIAIQIgASkDOCEHIwBBEGsiACQAIAAgAjYCCCAAIAc3AwACQCAAKAIIKAIkQQFGBEAgACgCCEEMakESQQAQFCAAQX82AgwMAQsgACgCCEEAIAApAwBBERAgQgBTBEAgAEF/NgIMDAELIAAoAghBATYCJCAAQQA2AgwLIAAoAgwhAiAAQRBqJAAgAkEASAsEQCABQgA3AzgLCwsgASkDOFAEQAJ/IAEoAlgoAgAhAiMAQRBrIgAkACAAIAI2AggCQCAAKAIIKAIkQQFGBEAgACgCCEEMakESQQAQFCAAQX82AgwMAQsgACgCCEEAQgBBCBAgQgBTBEAgAEF/NgIMDAELIAAoAghBATYCJCAAQQA2AgwLIAAoAgwhAiAAQRBqJAAgAkEASAsEQCABKAJYQQhqIAEoAlgoAgAQFyABKAIoEBUgAUF/NgJcDAILCyABKAJYKAJUIQIjAEEQayIAJAAgACACNgIMIAAoAgwEQCAAKAIMRAAAAAAAAAAAOQMYIAAoAgwoAgBEAAAAAAAAAAAgACgCDCgCDCAAKAIMKAIEERYACyAAQRBqJAAgAUEANgIsIAFCADcDSANAAkAgASkDSCABKQNAWg0AIAEoAlgoAlQhAiABKQNIIge6IAEpA0C6IgijIQkjAEEgayIAJAAgACACNgIcIAAgCTkDECAAIAdCAXy6IAijOQMIIAAoAhwEQCAAKAIcIAArAxA5AyAgACgCHCAAKwMIOQMoIAAoAhxEAAAAAAAAAAAQVwsgAEEgaiQAIAEgASgCKCABKQNIp0EDdGopAwA3A1AgASABKAJYKAJAIAEpA1CnQQR0ajYCEAJAAkAgASgCECgCAEUNACABKAIQKAIAKQNIIAEpAzhaDQAMAQsgAQJ/QQEgASgCECgCCA0AGiABKAIQKAIEBEBBASABKAIQKAIEKAIAQQFxDQEaCyABKAIQKAIEBH8gASgCECgCBCgCAEHAAHFBAEcFQQALC0EBcTYCFCABKAIQKAIERQRAIAEoAhAoAgAQQCEAIAEoAhAgADYCBCAARQRAIAEoAlhBCGpBDkEAEBQgAUEBNgIsDAMLCyABIAEoAhAoAgQ2AgwCfyABKAJYIQIgASkDUCEHIwBBMGsiACQAIAAgAjYCKCAAIAc3AyACQCAAKQMgIAAoAigpAzBaBEAgACgCKEEIakESQQAQFCAAQX82AiwMAQsgACAAKAIoKAJAIAApAyCnQQR0ajYCHAJAIAAoAhwoAgAEQCAAKAIcKAIALQAEQQFxRQ0BCyAAQQA2AiwMAQsgACgCHCgCACkDSEIafEL///////////8AVgRAIAAoAihBCGpBBEEWEBQgAEF/NgIsDAELIAAoAigoAgAgACgCHCgCACkDSEIafEEAECdBAEgEQCAAKAIoQQhqIAAoAigoAgAQFyAAQX82AiwMAQsgACAAKAIoKAIAQgQgAEEYaiAAKAIoQQhqEEIiAjYCFCACRQRAIABBfzYCLAwBCyAAIAAoAhQQHTsBEiAAIAAoAhQQHTsBECAAKAIUEEdBAXFFBEAgACgCFBAWIAAoAihBCGpBFEEAEBQgAEF/NgIsDAELIAAoAhQQFiAALwEQBEAgACgCKCgCACAALwESrUEBECdBAEgEQCAAKAIoQQhqQQRBtJsBKAIAEBQgAEF/NgIsDAILIABBACAAKAIoKAIAIAAvARBBACAAKAIoQQhqEGM2AgggACgCCEUEQCAAQX82AiwMAgsgACgCCCAALwEQQYACIABBDGogACgCKEEIahCUAUEBcUUEQCAAKAIIEBUgAEF/NgIsDAILIAAoAggQFSAAKAIMBEAgACAAKAIMEJMBNgIMIAAoAhwoAgAoAjQgACgCDBCVASECIAAoAhwoAgAgAjYCNAsLIAAoAhwoAgBBAToABAJAIAAoAhwoAgRFDQAgACgCHCgCBC0ABEEBcQ0AIAAoAhwoAgQgACgCHCgCACgCNDYCNCAAKAIcKAIEQQE6AAQLIABBADYCLAsgACgCLCECIABBMGokACACQQBICwRAIAFBATYCLAwCCyABIAEoAlgoAgAQNSIHNwMwIAdCAFMEQCABQQE2AiwMAgsgASgCDCABKQMwNwNIAkAgASgCFARAIAFBADYCCCABKAIQKAIIRQRAIAEgASgCWCABKAJYIAEpA1BBCEEAEK4BIgA2AgggAEUEQCABQQE2AiwMBQsLAn8gASgCWCECAn8gASgCCARAIAEoAggMAQsgASgCECgCCAshAyABKAIMIQQjAEGgAWsiACQAIAAgAjYCmAEgACADNgKUASAAIAQ2ApABAkAgACgClAEgAEE4ahA5QQBIBEAgACgCmAFBCGogACgClAEQFyAAQX82ApwBDAELIAApAzhCwACDUARAIAAgACkDOELAAIQ3AzggAEEAOwFoCwJAAkAgACgCkAEoAhBBf0cEQCAAKAKQASgCEEF+Rw0BCyAALwFoRQ0AIAAoApABIAAvAWg2AhAMAQsCQAJAIAAoApABKAIQDQAgACkDOEIEg1ANACAAIAApAzhCCIQ3AzggACAAKQNQNwNYDAELIAAgACkDOEL3////D4M3AzgLCyAAKQM4QoABg1AEQCAAIAApAzhCgAGENwM4IABBADsBagsgAEGAAjYCJAJAIAApAzhCBINQBEAgACAAKAIkQYAIcjYCJCAAQn83A3AMAQsgACgCkAEgACkDUDcDKCAAIAApA1A3A3ACQCAAKQM4QgiDUARAAkACQAJAAkACQAJ/AkAgACgCkAEoAhBBf0cEQCAAKAKQASgCEEF+Rw0BC0EIDAELIAAoApABKAIQC0H//wNxDg0CAwMDAwMDAwEDAwMAAwsgAEKUwuTzDzcDEAwDCyAAQoODsP8PNwMQDAILIABC/////w83AxAMAQsgAEIANwMQCyAAKQNQIAApAxBWBEAgACAAKAIkQYAIcjYCJAsMAQsgACgCkAEgACkDWDcDIAsLIAAgACgCmAEoAgAQNSIHNwOIASAHQgBTBEAgACgCmAFBCGogACgCmAEoAgAQFyAAQX82ApwBDAELIAAoApABIgIgAi8BDEH3/wNxOwEMIAAgACgCmAEgACgCkAEgACgCJBBUIgI2AiggAkEASARAIABBfzYCnAEMAQsgACAALwFoAn8CQCAAKAKQASgCEEF/RwRAIAAoApABKAIQQX5HDQELQQgMAQsgACgCkAEoAhALQf//A3FHOgAiIAAgAC0AIkEBcQR/IAAvAWhBAEcFQQALQQFxOgAhIAAgAC8BaAR/IAAtACEFQQELQQFxOgAgIAAgAC0AIkEBcQR/IAAoApABKAIQQQBHBUEAC0EBcToAHyAAAn9BASAALQAiQQFxDQAaQQEgACgCkAEoAgBBgAFxDQAaIAAoApABLwFSIAAvAWpHC0EBcToAHiAAIAAtAB5BAXEEfyAALwFqQQBHBUEAC0EBcToAHSAAIAAtAB5BAXEEfyAAKAKQAS8BUkEARwVBAAtBAXE6ABwgACAAKAKUATYCNCMAQRBrIgIgACgCNDYCDCACKAIMIgIgAigCMEEBajYCMCAALQAdQQFxBEAgACAALwFqQQAQeyICNgIMIAJFBEAgACgCmAFBCGpBGEEAEBQgACgCNBAbIABBfzYCnAEMAgsgACAAKAKYASAAKAI0IAAvAWpBACAAKAKYASgCHCAAKAIMEQUAIgI2AjAgAkUEQCAAKAI0EBsgAEF/NgKcAQwCCyAAKAI0EBsgACAAKAIwNgI0CyAALQAhQQFxBEAgACAAKAKYASAAKAI0IAAvAWgQsAEiAjYCMCACRQRAIAAoAjQQGyAAQX82ApwBDAILIAAoAjQQGyAAIAAoAjA2AjQLIAAtACBBAXEEQCAAIAAoApgBIAAoAjRBABCvASICNgIwIAJFBEAgACgCNBAbIABBfzYCnAEMAgsgACgCNBAbIAAgACgCMDYCNAsgAC0AH0EBcQRAIAAoApgBIQMgACgCNCEEIAAoApABKAIQIQUgACgCkAEvAVAhBiMAQRBrIgIkACACIAM2AgwgAiAENgIIIAIgBTYCBCACIAY2AgAgAigCDCACKAIIIAIoAgRBASACKAIAELIBIQMgAkEQaiQAIAAgAyICNgIwIAJFBEAgACgCNBAbIABBfzYCnAEMAgsgACgCNBAbIAAgACgCMDYCNAsgAC0AHEEBcQRAIABBADYCBAJAIAAoApABKAJUBEAgACAAKAKQASgCVDYCBAwBCyAAKAKYASgCHARAIAAgACgCmAEoAhw2AgQLCyAAIAAoApABLwFSQQEQeyICNgIIIAJFBEAgACgCmAFBCGpBGEEAEBQgACgCNBAbIABBfzYCnAEMAgsgACAAKAKYASAAKAI0IAAoApABLwFSQQEgACgCBCAAKAIIEQUAIgI2AjAgAkUEQCAAKAI0EBsgAEF/NgKcAQwCCyAAKAI0EBsgACAAKAIwNgI0CyAAIAAoApgBKAIAEDUiBzcDgAEgB0IAUwRAIAAoApgBQQhqIAAoApgBKAIAEBcgAEF/NgKcAQwBCyAAKAKYASEDIAAoAjQhBCAAKQNwIQcjAEHAwABrIgIkACACIAM2ArhAIAIgBDYCtEAgAiAHNwOoQAJAIAIoArRAEEhBAEgEQCACKAK4QEEIaiACKAK0QBAXIAJBfzYCvEAMAQsgAkEANgIMIAJCADcDEANAAkAgAiACKAK0QCACQSBqQoDAABArIgc3AxggB0IAVw0AIAIoArhAIAJBIGogAikDGBA2QQBIBEAgAkF/NgIMBSACKQMYQoDAAFINAiACKAK4QCgCVEUNAiACKQOoQEIAVw0CIAIgAikDGCACKQMQfDcDECACKAK4QCgCVCACKQMQuSACKQOoQLmjEFcMAgsLCyACKQMYQgBTBEAgAigCuEBBCGogAigCtEAQFyACQX82AgwLIAIoArRAEC8aIAIgAigCDDYCvEALIAIoArxAIQMgAkHAwABqJAAgACADNgIsIAAoAjQgAEE4ahA5QQBIBEAgACgCmAFBCGogACgCNBAXIABBfzYCLAsgACgCNCEDIwBBEGsiAiQAIAIgAzYCCAJAA0AgAigCCARAIAIoAggpAxhCgIAEg0IAUgRAIAIgAigCCEEAQgBBEBAgNwMAIAIpAwBCAFMEQCACQf8BOgAPDAQLIAIpAwBCA1UEQCACKAIIQQxqQRRBABAUIAJB/wE6AA8MBAsgAiACKQMAPAAPDAMFIAIgAigCCCgCADYCCAwCCwALCyACQQA6AA8LIAIsAA8hAyACQRBqJAAgACADIgI6ACMgAkEYdEEYdUEASARAIAAoApgBQQhqIAAoAjQQFyAAQX82AiwLIAAoAjQQGyAAKAIsQQBIBEAgAEF/NgKcAQwBCyAAIAAoApgBKAIAEDUiBzcDeCAHQgBTBEAgACgCmAFBCGogACgCmAEoAgAQFyAAQX82ApwBDAELIAAoApgBKAIAIAApA4gBEJsBQQBIBEAgACgCmAFBCGogACgCmAEoAgAQFyAAQX82ApwBDAELIAApAzhC5ACDQuQAUgRAIAAoApgBQQhqQRRBABAUIABBfzYCnAEMAQsgACgCkAEoAgBBIHFFBEACQCAAKQM4QhCDQgBSBEAgACgCkAEgACgCYDYCFAwBCyAAKAKQAUEUahABGgsLIAAoApABIAAvAWg2AhAgACgCkAEgACgCZDYCGCAAKAKQASAAKQNQNwMoIAAoApABIAApA3ggACkDgAF9NwMgIAAoApABIAAoApABLwEMQfn/A3EgAC0AI0EBdHI7AQwgACgCkAEhAyAAKAIkQYAIcUEARyEEIwBBEGsiAiQAIAIgAzYCDCACIAQ6AAsCQCACKAIMKAIQQQ5GBEAgAigCDEE/OwEKDAELIAIoAgwoAhBBDEYEQCACKAIMQS47AQoMAQsCQCACLQALQQFxRQRAIAIoAgxBABBlQQFxRQ0BCyACKAIMQS07AQoMAQsCQCACKAIMKAIQQQhHBEAgAigCDC8BUkEBRw0BCyACKAIMQRQ7AQoMAQsgAiACKAIMKAIwEFEiAzsBCCADQf//A3EEQCACKAIMKAIwKAIAIAIvAQhBAWtqLQAAQS9GBEAgAigCDEEUOwEKDAILCyACKAIMQQo7AQoLIAJBEGokACAAIAAoApgBIAAoApABIAAoAiQQVCICNgIsIAJBAEgEQCAAQX82ApwBDAELIAAoAiggACgCLEcEQCAAKAKYAUEIakEUQQAQFCAAQX82ApwBDAELIAAoApgBKAIAIAApA3gQmwFBAEgEQCAAKAKYAUEIaiAAKAKYASgCABAXIABBfzYCnAEMAQsgAEEANgKcAQsgACgCnAEhAiAAQaABaiQAIAJBAEgLBEAgAUEBNgIsIAEoAggEQCABKAIIEBsLDAQLIAEoAggEQCABKAIIEBsLDAELIAEoAgwiACAALwEMQff/A3E7AQwgASgCWCABKAIMQYACEFRBAEgEQCABQQE2AiwMAwsgASABKAJYIAEpA1AgASgCWEEIahBgIgc3AwAgB1AEQCABQQE2AiwMAwsgASgCWCgCACABKQMAQQAQJ0EASARAIAEoAlhBCGogASgCWCgCABAXIAFBATYCLAwDCwJ/IAEoAlghAiABKAIMKQMgIQcjAEGgwABrIgAkACAAIAI2AphAIAAgBzcDkEAgACAAKQOQQLo5AwACQANAIAApA5BAUEUEQCAAIAApA5BAQoDAAFYEfkKAwAAFIAApA5BACz4CDCAAKAKYQCgCACAAQRBqIAAoAgytIAAoAphAQQhqEGRBAEgEQCAAQX82ApxADAMLIAAoAphAIABBEGogACgCDK0QNkEASARAIABBfzYCnEAMAwUgACAAKQOQQCAANQIMfTcDkEAgACgCmEAoAlQgACsDACAAKQOQQLqhIAArAwCjEFcMAgsACwsgAEEANgKcQAsgACgCnEAhAiAAQaDAAGokACACQQBICwRAIAFBATYCLAwDCwsLIAEgASkDSEIBfDcDSAwBCwsgASgCLEUEQAJ/IAEoAlghACABKAIoIQMgASkDQCEHIwBBMGsiAiQAIAIgADYCKCACIAM2AiQgAiAHNwMYIAIgAigCKCgCABA1Igc3AxACQCAHQgBTBEAgAkF/NgIsDAELIAIoAighAyACKAIkIQQgAikDGCEHIwBBwAFrIgAkACAAIAM2ArQBIAAgBDYCsAEgACAHNwOoASAAIAAoArQBKAIAEDUiBzcDIAJAIAdCAFMEQCAAKAK0AUEIaiAAKAK0ASgCABAXIABCfzcDuAEMAQsgACAAKQMgNwOgASAAQQA6ABcgAEIANwMYA0AgACkDGCAAKQOoAVQEQCAAIAAoArQBKAJAIAAoArABIAApAxinQQN0aikDAKdBBHRqNgIMIAAgACgCtAECfyAAKAIMKAIEBEAgACgCDCgCBAwBCyAAKAIMKAIAC0GABBBUIgM2AhAgA0EASARAIABCfzcDuAEMAwsgACgCEARAIABBAToAFwsgACAAKQMYQgF8NwMYDAELCyAAIAAoArQBKAIAEDUiBzcDICAHQgBTBEAgACgCtAFBCGogACgCtAEoAgAQFyAAQn83A7gBDAELIAAgACkDICAAKQOgAX03A5gBAkAgACkDoAFC/////w9YBEAgACkDqAFC//8DWA0BCyAAQQE6ABcLIAAgAEEwakLiABApIgM2AiwgA0UEQCAAKAK0AUEIakEOQQAQFCAAQn83A7gBDAELIAAtABdBAXEEQCAAKAIsQecSQQQQQSAAKAIsQiwQLSAAKAIsQS0QHyAAKAIsQS0QHyAAKAIsQQAQISAAKAIsQQAQISAAKAIsIAApA6gBEC0gACgCLCAAKQOoARAtIAAoAiwgACkDmAEQLSAAKAIsIAApA6ABEC0gACgCLEHiEkEEEEEgACgCLEEAECEgACgCLCAAKQOgASAAKQOYAXwQLSAAKAIsQQEQIQsgACgCLEHsEkEEEEEgACgCLEEAECEgACgCLCAAKQOoAUL//wNaBH5C//8DBSAAKQOoAQunQf//A3EQHyAAKAIsIAApA6gBQv//A1oEfkL//wMFIAApA6gBC6dB//8DcRAfIAAoAiwgACkDmAFC/////w9aBH9BfwUgACkDmAGnCxAhIAAoAiwgACkDoAFC/////w9aBH9BfwUgACkDoAGnCxAhIAACfyAAKAK0AS0AKEEBcQRAIAAoArQBKAIkDAELIAAoArQBKAIgCzYClAEgACgCLAJ/IAAoApQBBEAgACgClAEvAQQMAQtBAAtB//8DcRAfAn8jAEEQayIDIAAoAiw2AgwgAygCDC0AAEEBcUULBEAgACgCtAFBCGpBFEEAEBQgACgCLBAWIABCfzcDuAEMAQsgACgCtAECfyMAQRBrIgMgACgCLDYCDCADKAIMKAIECwJ+IwBBEGsiAyAAKAIsNgIMAn4gAygCDC0AAEEBcQRAIAMoAgwpAxAMAQtCAAsLEDZBAEgEQCAAKAIsEBYgAEJ/NwO4AQwBCyAAKAIsEBYgACgClAEEQCAAKAK0ASAAKAKUASgCACAAKAKUAS8BBK0QNkEASARAIABCfzcDuAEMAgsLIAAgACkDmAE3A7gBCyAAKQO4ASEHIABBwAFqJAAgAiAHNwMAIAdCAFMEQCACQX82AiwMAQsgAiACKAIoKAIAEDUiBzcDCCAHQgBTBEAgAkF/NgIsDAELIAJBADYCLAsgAigCLCEAIAJBMGokACAAQQBICwRAIAFBATYCLAsLIAEoAigQFSABKAIsRQRAAn8gASgCWCgCACECIwBBEGsiACQAIAAgAjYCCAJAIAAoAggoAiRBAUcEQCAAKAIIQQxqQRJBABAUIABBfzYCDAwBCyAAKAIIKAIgQQFLBEAgACgCCEEMakEdQQAQFCAAQX82AgwMAQsgACgCCCgCIARAIAAoAggQL0EASARAIABBfzYCDAwCCwsgACgCCEEAQgBBCRAgQgBTBEAgACgCCEECNgIkIABBfzYCDAwBCyAAKAIIQQA2AiQgAEEANgIMCyAAKAIMIQIgAEEQaiQAIAILBEAgASgCWEEIaiABKAJYKAIAEBcgAUEBNgIsCwsgASgCWCgCVCECIwBBEGsiACQAIAAgAjYCDCAAKAIMRAAAAAAAAPA/EFcgAEEQaiQAIAEoAiwEQCABKAJYKAIAEGIgAUF/NgJcDAELIAEoAlgQPCABQQA2AlwLIAEoAlwhACABQeAAaiQAIAAL0g4CB38CfiMAQTBrIgMkACADIAA2AiggAyABNgIkIAMgAjYCICMAQRBrIgAgA0EIajYCDCAAKAIMQQA2AgAgACgCDEEANgIEIAAoAgxBADYCCCADKAIoIQAjAEEgayIEJAAgBCAANgIYIARCADcDECAEQn83AwggBCADQQhqNgIEAkACQCAEKAIYBEAgBCkDCEJ/WQ0BCyAEKAIEQRJBABAUIARBADYCHAwBCyAEKAIYIQAgBCkDECEKIAQpAwghCyAEKAIEIQEjAEGgAWsiAiQAIAIgADYCmAEgAkEANgKUASACIAo3A4gBIAIgCzcDgAEgAkEANgJ8IAIgATYCeAJAAkAgAigClAENACACKAKYAQ0AIAIoAnhBEkEAEBQgAkEANgKcAQwBCyACKQOAAUIAUwRAIAJCADcDgAELAkAgAikDiAFC////////////AFgEQCACKQOIASACKQOIASACKQOAAXxYDQELIAIoAnhBEkEAEBQgAkEANgKcAQwBCyACQYgBEBgiADYCdCAARQRAIAIoAnhBDkEAEBQgAkEANgKcAQwBCyACKAJ0QQA2AhggAigCmAEEQCACKAKYASIAEC5BAWoiARAYIgUEfyAFIAAgARAZBUEACyEAIAIoAnQgADYCGCAARQRAIAIoAnhBDkEAEBQgAigCdBAVIAJBADYCnAEMAgsLIAIoAnQgAigClAE2AhwgAigCdCACKQOIATcDaCACKAJ0IAIpA4ABNwNwAkAgAigCfARAIAIoAnQiACACKAJ8IgEpAwA3AyAgACABKQMwNwNQIAAgASkDKDcDSCAAIAEpAyA3A0AgACABKQMYNwM4IAAgASkDEDcDMCAAIAEpAwg3AyggAigCdEEANgIoIAIoAnQiACAAKQMgQv7///8PgzcDIAwBCyACKAJ0QSBqEDsLIAIoAnQpA3BCAFIEQCACKAJ0IAIoAnQpA3A3AzggAigCdCIAIAApAyBCBIQ3AyALIwBBEGsiACACKAJ0QdgAajYCDCAAKAIMQQA2AgAgACgCDEEANgIEIAAoAgxBADYCCCACKAJ0QQA2AoABIAIoAnRBADYChAEjAEEQayIAIAIoAnQ2AgwgACgCDEEANgIAIAAoAgxBADYCBCAAKAIMQQA2AgggAkF/NgIEIAJBBzYCAEEOIAIQNEI/hCEKIAIoAnQgCjcDEAJAIAIoAnQoAhgEQCACIAIoAnQoAhggAkEYahCmAUEATjoAFyACLQAXQQFxRQRAAkAgAigCdCkDaFBFDQAgAigCdCkDcFBFDQAgAigCdEL//wM3AxALCwwBCwJAIAIoAnQoAhwiACgCTEEASA0ACyAAKAI8IQBBACEFIwBBIGsiBiQAAn8CQCAAIAJBGGoiCRAKIgFBeEYEQCMAQSBrIgckACAAIAdBCGoQCSIIBH9BtJsBIAg2AgBBAAVBAQshCCAHQSBqJAAgCA0BCyABQYFgTwR/QbSbAUEAIAFrNgIAQX8FIAELDAELA0AgBSAGaiIBIAVBxxJqLQAAOgAAIAVBDkchByAFQQFqIQUgBw0ACwJAIAAEQEEPIQUgACEBA0AgAUEKTwRAIAVBAWohBSABQQpuIQEMAQsLIAUgBmpBADoAAANAIAYgBUEBayIFaiAAIABBCm4iAUEKbGtBMHI6AAAgAEEJSyEHIAEhACAHDQALDAELIAFBMDoAACAGQQA6AA8LIAYgCRACIgBBgWBPBH9BtJsBQQAgAGs2AgBBfwUgAAsLIQAgBkEgaiQAIAIgAEEATjoAFwsCQCACLQAXQQFxRQRAIAIoAnRB2ABqQQVBtJsBKAIAEBQMAQsgAigCdCkDIEIQg1AEQCACKAJ0IAIoAlg2AkggAigCdCIAIAApAyBCEIQ3AyALIAIoAiRBgOADcUGAgAJGBEAgAigCdEL/gQE3AxAgAikDQCACKAJ0KQNoIAIoAnQpA3B8VARAIAIoAnhBEkEAEBQgAigCdCgCGBAVIAIoAnQQFSACQQA2ApwBDAMLIAIoAnQpA3BQBEAgAigCdCACKQNAIAIoAnQpA2h9NwM4IAIoAnQiACAAKQMgQgSENwMgAkAgAigCdCgCGEUNACACKQOIAVBFDQAgAigCdEL//wM3AxALCwsLIAIoAnQiACAAKQMQQoCAEIQ3AxAgAkEeIAIoAnQgAigCeBCDASIANgJwIABFBEAgAigCdCgCGBAVIAIoAnQQFSACQQA2ApwBDAELIAIgAigCcDYCnAELIAIoApwBIQAgAkGgAWokACAEIAA2AhwLIAQoAhwhACAEQSBqJAAgAyAANgIYAkAgAEUEQCADKAIgIANBCGoQnQEgA0EIahA4IANBADYCLAwBCyADIAMoAhggAygCJCADQQhqEJwBIgA2AhwgAEUEQCADKAIYEBsgAygCICADQQhqEJ0BIANBCGoQOCADQQA2AiwMAQsgA0EIahA4IAMgAygCHDYCLAsgAygCLCEAIANBMGokACAAC5IfAQZ/IwBB4ABrIgQkACAEIAA2AlQgBCABNgJQIAQgAjcDSCAEIAM2AkQgBCAEKAJUNgJAIAQgBCgCUDYCPAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAIAQoAkQOEwYHAgwEBQoOAQMJEAsPDQgREQARCyAEQgA3A1gMEQsgBCgCQCgCGEUEQCAEKAJAQRxBABAUIARCfzcDWAwRCyAEKAJAIQAjAEGAAWsiASQAIAEgADYCeCABIAEoAngoAhgQLkEIahAYIgA2AnQCQCAARQRAIAEoAnhBDkEAEBQgAUF/NgJ8DAELAkAgASgCeCgCGCABQRBqEKYBRQRAIAEgASgCHDYCbAwBCyABQX82AmwLIAEoAnQhACABIAEoAngoAhg2AgAgAEGrEiABEG8gASgCdCEDIAEoAmwhByMAQTBrIgAkACAAIAM2AiggACAHNgIkIABBADYCECAAIAAoAiggACgCKBAuajYCGCAAIAAoAhhBAWs2AhwDQCAAKAIcIAAoAihPBH8gACgCHCwAAEHYAEYFQQALQQFxBEAgACAAKAIQQQFqNgIQIAAgACgCHEEBazYCHAwBCwsCQCAAKAIQRQRAQbSbAUEcNgIAIABBfzYCLAwBCyAAIAAoAhxBAWo2AhwDQCMAQRBrIgckAAJAAn8jAEEQayIDJAAgAyAHQQhqNgIIIANBBDsBBiADQegLQQBBABBsIgU2AgACQCAFQQBIBEAgA0EAOgAPDAELAn8gAygCACEGIAMoAgghCCADLwEGIQkjAEEQayIFJAAgBSAJNgIMIAUgCDYCCCAGIAVBCGpBASAFQQRqEAYiBgR/QbSbASAGNgIAQX8FQQALIQYgBSgCBCEIIAVBEGokACADLwEGQX8gCCAGG0cLBEAgAygCABBrIANBADoADwwBCyADKAIAEGsgA0EBOgAPCyADLQAPQQFxIQUgA0EQaiQAIAULBEAgByAHKAIINgIMDAELQcCgAS0AAEEBcUUEQEEAEAEhBgJAQciZASgCACIDRQRAQcyZASgCACAGNgIADAELQdCZAUEDQQNBASADQQdGGyADQR9GGzYCAEG8oAFBADYCAEHMmQEoAgAhBSADQQFOBEAgBq0hAkEAIQYDQCAFIAZBAnRqIAJCrf7V5NSF/ajYAH5CAXwiAkIgiD4CACAGQQFqIgYgA0cNAAsLIAUgBSgCAEEBcjYCAAsLQcyZASgCACEDAkBByJkBKAIAIgVFBEAgAyADKAIAQe2cmY4EbEG54ABqQf////8HcSIDNgIADAELIANB0JkBKAIAIgZBAnRqIgggCCgCACADQbygASgCACIIQQJ0aigCAGoiAzYCAEG8oAFBACAIQQFqIgggBSAIRhs2AgBB0JkBQQAgBkEBaiIGIAUgBkYbNgIAIANBAXYhAwsgByADNgIMCyAHKAIMIQMgB0EQaiQAIAAgAzYCDCAAIAAoAhw2AhQDQCAAKAIUIAAoAhhJBEAgACAAKAIMQSRwOgALAn8gACwAC0EKSARAIAAsAAtBMGoMAQsgACwAC0HXAGoLIQMgACAAKAIUIgdBAWo2AhQgByADOgAAIAAgACgCDEEkbjYCDAwBCwsgACgCKCEDIAAgACgCJEF/RgR/QbYDBSAAKAIkCzYCACAAIANBwoEgIAAQbCIDNgIgIANBAE4EQCAAKAIkQX9HBEAgACgCKCAAKAIkEA8iA0GBYE8Ef0G0mwFBACADazYCAEEABSADCxoLIAAgACgCIDYCLAwCC0G0mwEoAgBBFEYNAAsgAEF/NgIsCyAAKAIsIQMgAEEwaiQAIAEgAyIANgJwIABBf0YEQCABKAJ4QQxBtJsBKAIAEBQgASgCdBAVIAFBfzYCfAwBCyABIAEoAnBBoxIQoQEiADYCaCAARQRAIAEoAnhBDEG0mwEoAgAQFCABKAJwEGsgASgCdBBtGiABKAJ0EBUgAUF/NgJ8DAELIAEoAnggASgCaDYChAEgASgCeCABKAJ0NgKAASABQQA2AnwLIAEoAnwhACABQYABaiQAIAQgAKw3A1gMEAsgBCgCQCgCGARAIAQoAkAoAhwQVhogBCgCQEEANgIcCyAEQgA3A1gMDwsgBCgCQCgChAEQVkEASARAIAQoAkBBADYChAEgBCgCQEEGQbSbASgCABAUCyAEKAJAQQA2AoQBIAQoAkAoAoABIAQoAkAoAhgQCCIAQYFgTwR/QbSbAUEAIABrNgIAQX8FIAALQQBIBEAgBCgCQEECQbSbASgCABAUIARCfzcDWAwPCyAEKAJAKAKAARAVIAQoAkBBADYCgAEgBEIANwNYDA4LIAQgBCgCQCAEKAJQIAQpA0gQQzcDWAwNCyAEKAJAKAIYEBUgBCgCQCgCgAEQFSAEKAJAKAIcBEAgBCgCQCgCHBBWGgsgBCgCQBAVIARCADcDWAwMCyAEKAJAKAIYBEAgBCgCQCgCGCEBIwBBIGsiACQAIAAgATYCGCAAQQA6ABcgAEGAgCA2AgwCQCAALQAXQQFxBEAgACAAKAIMQQJyNgIMDAELIAAgACgCDDYCDAsgACgCGCEBIAAoAgwhAyAAQbYDNgIAIAAgASADIAAQbCIBNgIQAkAgAUEASARAIABBADYCHAwBCyAAIAAoAhBBoxJBoBIgAC0AF0EBcRsQoQEiATYCCCABRQRAIABBADYCHAwBCyAAIAAoAgg2AhwLIAAoAhwhASAAQSBqJAAgBCgCQCABNgIcIAFFBEAgBCgCQEELQbSbASgCABAUIARCfzcDWAwNCwsgBCgCQCkDaEIAUgRAIAQoAkAoAhwgBCgCQCkDaCAEKAJAEJ8BQQBIBEAgBEJ/NwNYDA0LCyAEKAJAQgA3A3ggBEIANwNYDAsLAkAgBCgCQCkDcEIAUgRAIAQgBCgCQCkDcCAEKAJAKQN4fTcDMCAEKQMwIAQpA0hWBEAgBCAEKQNINwMwCwwBCyAEIAQpA0g3AzALIAQpAzBC/////w9WBEAgBEL/////DzcDMAsgBAJ/IAQoAjwhByAEKQMwpyEAIAQoAkAoAhwiAygCTBogAyADLQBKIgFBAWsgAXI6AEogAygCCCADKAIEIgVrIgFBAUgEfyAABSAHIAUgASAAIAAgAUsbIgEQGRogAyADKAIEIAFqNgIEIAEgB2ohByAAIAFrCyIBBEADQAJAAn8gAyADLQBKIgVBAWsgBXI6AEogAygCFCADKAIcSwRAIANBAEEAIAMoAiQRAQAaCyADQQA2AhwgA0IANwMQIAMoAgAiBUEEcQRAIAMgBUEgcjYCAEF/DAELIAMgAygCLCADKAIwaiIGNgIIIAMgBjYCBCAFQRt0QR91C0UEQCADIAcgASADKAIgEQEAIgVBAWpBAUsNAQsgACABawwDCyAFIAdqIQcgASAFayIBDQALCyAACyIANgIsIABFBEACfyAEKAJAKAIcIgAoAkxBf0wEQCAAKAIADAELIAAoAgALQQV2QQFxBEAgBCgCQEEFQbSbASgCABAUIARCfzcDWAwMCwsgBCgCQCIAIAApA3ggBCgCLK18NwN4IAQgBCgCLK03A1gMCgsgBCgCQCgCGBBtQQBIBEAgBCgCQEEWQbSbASgCABAUIARCfzcDWAwKCyAEQgA3A1gMCQsgBCgCQCgChAEEQCAEKAJAKAKEARBWGiAEKAJAQQA2AoQBCyAEKAJAKAKAARBtGiAEKAJAKAKAARAVIAQoAkBBADYCgAEgBEIANwNYDAgLIAQCfyAEKQNIQhBUBEAgBCgCQEESQQAQFEEADAELIAQoAlALNgIYIAQoAhhFBEAgBEJ/NwNYDAgLIARBATYCHAJAAkACQAJAAkAgBCgCGCgCCA4DAAIBAwsgBCAEKAIYKQMANwMgDAMLAkAgBCgCQCkDcFAEQCAEKAJAKAIcIAQoAhgpAwBBAiAEKAJAEGpBAEgEQCAEQn83A1gMDQsgBCAEKAJAKAIcEKMBIgI3AyAgAkIAUwRAIAQoAkBBBEG0mwEoAgAQFCAEQn83A1gMDQsgBCAEKQMgIAQoAkApA2h9NwMgIARBADYCHAwBCyAEIAQoAkApA3AgBCgCGCkDAHw3AyALDAILIAQgBCgCQCkDeCAEKAIYKQMAfDcDIAwBCyAEKAJAQRJBABAUIARCfzcDWAwICwJAAkAgBCkDIEIAUw0AIAQoAkApA3BCAFIEQCAEKQMgIAQoAkApA3BWDQELIAQoAkApA2ggBCkDICAEKAJAKQNofFgNAQsgBCgCQEESQQAQFCAEQn83A1gMCAsgBCgCQCAEKQMgNwN4IAQoAhwEQCAEKAJAKAIcIAQoAkApA3ggBCgCQCkDaHwgBCgCQBCfAUEASARAIARCfzcDWAwJCwsgBEIANwNYDAcLIAQCfyAEKQNIQhBUBEAgBCgCQEESQQAQFEEADAELIAQoAlALNgIUIAQoAhRFBEAgBEJ/NwNYDAcLIAQoAkAoAoQBIAQoAhQpAwAgBCgCFCgCCCAEKAJAEGpBAEgEQCAEQn83A1gMBwsgBEIANwNYDAYLIAQpA0hCOFQEQCAEQn83A1gMBgsCfyMAQRBrIgAgBCgCQEHYAGo2AgwgACgCDCgCAAsEQCAEKAJAAn8jAEEQayIAIAQoAkBB2ABqNgIMIAAoAgwoAgALAn8jAEEQayIAIAQoAkBB2ABqNgIMIAAoAgwoAgQLEBQgBEJ/NwNYDAYLIAQoAlAiACAEKAJAIgEpACA3AAAgACABKQBQNwAwIAAgASkASDcAKCAAIAEpAEA3ACAgACABKQA4NwAYIAAgASkAMDcAECAAIAEpACg3AAggBEI4NwNYDAULIAQgBCgCQCkDEDcDWAwECyAEIAQoAkApA3g3A1gMAwsgBCAEKAJAKAKEARCjATcDCCAEKQMIQgBTBEAgBCgCQEEeQbSbASgCABAUIARCfzcDWAwDCyAEIAQpAwg3A1gMAgsgBCgCQCgChAEiACgCTEEAThogACAAKAIAQU9xNgIAIAQCfyAEKAJQIQEgBCkDSKciACAAAn8gBCgCQCgChAEiAygCTEF/TARAIAEgACADEHEMAQsgASAAIAMQcQsiAUYNABogAQs2AgQCQCAEKQNIIAQoAgStUQRAAn8gBCgCQCgChAEiACgCTEF/TARAIAAoAgAMAQsgACgCAAtBBXZBAXFFDQELIAQoAkBBBkG0mwEoAgAQFCAEQn83A1gMAgsgBCAEKAIErTcDWAwBCyAEKAJAQRxBABAUIARCfzcDWAsgBCkDWCECIARB4ABqJAAgAgsJACAAKAI8EAUL5AEBBH8jAEEgayIDJAAgAyABNgIQIAMgAiAAKAIwIgRBAEdrNgIUIAAoAiwhBSADIAQ2AhwgAyAFNgIYQX8hBAJAAkAgACgCPCADQRBqQQIgA0EMahAGIgUEf0G0mwEgBTYCAEF/BUEAC0UEQCADKAIMIgRBAEoNAQsgACAAKAIAIARBMHFBEHNyNgIADAELIAQgAygCFCIGTQ0AIAAgACgCLCIFNgIEIAAgBSAEIAZrajYCCCAAKAIwBEAgACAFQQFqNgIEIAEgAmpBAWsgBS0AADoAAAsgAiEECyADQSBqJAAgBAv0AgEHfyMAQSBrIgMkACADIAAoAhwiBTYCECAAKAIUIQQgAyACNgIcIAMgATYCGCADIAQgBWsiATYCFCABIAJqIQVBAiEHIANBEGohAQJ/AkACQCAAKAI8IANBEGpBAiADQQxqEAMiBAR/QbSbASAENgIAQX8FQQALRQRAA0AgBSADKAIMIgRGDQIgBEF/TA0DIAEgBCABKAIEIghLIgZBA3RqIgkgBCAIQQAgBhtrIgggCSgCAGo2AgAgAUEMQQQgBhtqIgkgCSgCACAIazYCACAFIARrIQUgACgCPCABQQhqIAEgBhsiASAHIAZrIgcgA0EMahADIgQEf0G0mwEgBDYCAEF/BUEAC0UNAAsLIAVBf0cNAQsgACAAKAIsIgE2AhwgACABNgIUIAAgASAAKAIwajYCECACDAELIABBADYCHCAAQgA3AxAgACAAKAIAQSByNgIAQQAgB0ECRg0AGiACIAEoAgRrCyEAIANBIGokACAAC1IBAX8jAEEQayIDJAAgACgCPCABpyABQiCIpyACQf8BcSADQQhqEA0iAAR/QbSbASAANgIAQX8FQQALIQAgAykDCCEBIANBEGokAEJ/IAEgABsL1QQBBX8jAEGwAWsiASQAIAEgADYCqAEgASgCqAEQOAJAAkAgASgCqAEoAgBBAE4EQCABKAKoASgCAEGAFCgCAEgNAQsgASABKAKoASgCADYCECABQSBqQY8SIAFBEGoQbyABQQA2AqQBIAEgAUEgajYCoAEMAQsgASABKAKoASgCAEECdEGAE2ooAgA2AqQBAkACQAJAAkAgASgCqAEoAgBBAnRBkBRqKAIAQQFrDgIAAQILIAEoAqgBKAIEIQJBkJkBKAIAIQRBACEAAkACQANAIAIgAEGgiAFqLQAARwRAQdcAIQMgAEEBaiIAQdcARw0BDAILCyAAIgMNAEGAiQEhAgwBC0GAiQEhAANAIAAtAAAhBSAAQQFqIgIhACAFDQAgAiEAIANBAWsiAw0ACwsgBCgCFBogASACNgKgAQwCCyMAQRBrIgAgASgCqAEoAgQ2AgwgAUEAIAAoAgxrQQJ0QajZAGooAgA2AqABDAELIAFBADYCoAELCwJAIAEoAqABRQRAIAEgASgCpAE2AqwBDAELIAEgASgCoAEQLgJ/IAEoAqQBBEAgASgCpAEQLkECagwBC0EAC2pBAWoQGCIANgIcIABFBEAgAUG4EygCADYCrAEMAQsgASgCHCEAAn8gASgCpAEEQCABKAKkAQwBC0H6EgshA0HfEkH6EiABKAKkARshAiABIAEoAqABNgIIIAEgAjYCBCABIAM2AgAgAEG+CiABEG8gASgCqAEgASgCHDYCCCABIAEoAhw2AqwBCyABKAKsASEAIAFBsAFqJAAgAAsIAEEBQTgQfwszAQF/IAAoAhQiAyABIAIgACgCECADayIBIAEgAksbIgEQGRogACAAKAIUIAFqNgIUIAILjwUCBn4BfyABIAEoAgBBD2pBcHEiAUEQajYCACAAAnwgASkDACEDIAEpAwghBiMAQSBrIggkAAJAIAZC////////////AIMiBEKAgICAgIDAgDx9IARCgICAgICAwP/DAH1UBEAgBkIEhiADQjyIhCEEIANC//////////8PgyIDQoGAgICAgICACFoEQCAEQoGAgICAgICAwAB8IQIMAgsgBEKAgICAgICAgEB9IQIgA0KAgICAgICAgAiFQgBSDQEgAiAEQgGDfCECDAELIANQIARCgICAgICAwP//AFQgBEKAgICAgIDA//8AURtFBEAgBkIEhiADQjyIhEL/////////A4NCgICAgICAgPz/AIQhAgwBC0KAgICAgICA+P8AIQIgBEL///////+//8MAVg0AQgAhAiAEQjCIpyIAQZH3AEkNACADIQIgBkL///////8/g0KAgICAgIDAAIQiBSEHAkAgAEGB9wBrIgFBwABxBEAgAiABQUBqrYYhB0IAIQIMAQsgAUUNACAHIAGtIgSGIAJBwAAgAWutiIQhByACIASGIQILIAggAjcDECAIIAc3AxgCQEGB+AAgAGsiAEHAAHEEQCAFIABBQGqtiCEDQgAhBQwBCyAARQ0AIAVBwAAgAGuthiADIACtIgKIhCEDIAUgAoghBQsgCCADNwMAIAggBTcDCCAIKQMIQgSGIAgpAwAiA0I8iIQhAiAIKQMQIAgpAxiEQgBSrSADQv//////////D4OEIgNCgYCAgICAgIAIWgRAIAJCAXwhAgwBCyADQoCAgICAgICACIVCAFINACACQgGDIAJ8IQILIAhBIGokACACIAZCgICAgICAgICAf4OEvws5AwALrRcDEn8CfgF8IwBBsARrIgkkACAJQQA2AiwCQCABvSIYQn9XBEBBASESQa4IIRMgAZoiAb0hGAwBCyAEQYAQcQRAQQEhEkGxCCETDAELQbQIQa8IIARBAXEiEhshEyASRSEXCwJAIBhCgICAgICAgPj/AINCgICAgICAgPj/AFEEQCAAQSAgAiASQQNqIg0gBEH//3txECYgACATIBIQIiAAQeQLQbUSIAVBIHEiAxtBjw1BuRIgAxsgASABYhtBAxAiDAELIAlBEGohEAJAAn8CQCABIAlBLGoQqQEiASABoCIBRAAAAAAAAAAAYgRAIAkgCSgCLCIGQQFrNgIsIAVBIHIiFEHhAEcNAQwDCyAFQSByIhRB4QBGDQIgCSgCLCELQQYgAyADQQBIGwwBCyAJIAZBHWsiCzYCLCABRAAAAAAAALBBoiEBQQYgAyADQQBIGwshCiAJQTBqIAlB0AJqIAtBAEgbIg4hBwNAIAcCfyABRAAAAAAAAPBBYyABRAAAAAAAAAAAZnEEQCABqwwBC0EACyIDNgIAIAdBBGohByABIAO4oUQAAAAAZc3NQaIiAUQAAAAAAAAAAGINAAsCQCALQQFIBEAgCyEDIAchBiAOIQgMAQsgDiEIIAshAwNAIANBHSADQR1IGyEMAkAgB0EEayIGIAhJDQAgDK0hGUIAIRgDQCAGIAY1AgAgGYYgGHwiGCAYQoCU69wDgCIYQoCU69wDfn0+AgAgCCAGQQRrIgZNBEAgGEL/////D4MhGAwBCwsgGKciA0UNACAIQQRrIgggAzYCAAsDQCAIIAciBkkEQCAGQQRrIgcoAgBFDQELCyAJIAkoAiwgDGsiAzYCLCAGIQcgA0EASg0ACwsgCkEZakEJbSEHIANBf0wEQCAHQQFqIQ0gFEHmAEYhFQNAQQlBACADayADQXdIGyEWAkAgBiAISwRAQYCU69wDIBZ2IQ9BfyAWdEF/cyERQQAhAyAIIQcDQCAHIAMgBygCACIMIBZ2ajYCACAMIBFxIA9sIQMgB0EEaiIHIAZJDQALIAggCEEEaiAIKAIAGyEIIANFDQEgBiADNgIAIAZBBGohBgwBCyAIIAhBBGogCCgCABshCAsgCSAJKAIsIBZqIgM2AiwgDiAIIBUbIgcgDUECdGogBiAGIAdrQQJ1IA1KGyEGIANBAEgNAAsLQQAhBwJAIAYgCE0NACAOIAhrQQJ1QQlsIQcgCCgCACIMQQpJDQBB5AAhAwNAIAdBAWohByADIAxLDQEgA0EKbCEDDAALAAsgCkEAIAcgFEHmAEYbayAUQecARiAKQQBHcWsiAyAGIA5rQQJ1QQlsQQlrSARAIANBgMgAaiIRQQltIgxBAnQgCUEwakEEciAJQdQCaiALQQBIG2pBgCBrIQ1BCiEDAkAgESAMQQlsayIMQQdKDQBB5AAhAwNAIAxBAWoiDEEIRg0BIANBCmwhAwwACwALAkAgDSgCACIRIBEgA24iDCADbGsiD0EBIA1BBGoiCyAGRhtFDQBEAAAAAAAA4D9EAAAAAAAA8D9EAAAAAAAA+D8gBiALRhtEAAAAAAAA+D8gDyADQQF2IgtGGyALIA9LGyEaRAEAAAAAAEBDRAAAAAAAAEBDIAxBAXEbIQECQCAXDQAgEy0AAEEtRw0AIBqaIRogAZohAQsgDSARIA9rIgs2AgAgASAaoCABYQ0AIA0gAyALaiIDNgIAIANBgJTr3ANPBEADQCANQQA2AgAgCCANQQRrIg1LBEAgCEEEayIIQQA2AgALIA0gDSgCAEEBaiIDNgIAIANB/5Pr3ANLDQALCyAOIAhrQQJ1QQlsIQcgCCgCACILQQpJDQBB5AAhAwNAIAdBAWohByADIAtLDQEgA0EKbCEDDAALAAsgDUEEaiIDIAYgAyAGSRshBgsDQCAGIgsgCE0iDEUEQCALQQRrIgYoAgBFDQELCwJAIBRB5wBHBEAgBEEIcSEPDAELIAdBf3NBfyAKQQEgChsiBiAHSiAHQXtKcSIDGyAGaiEKQX9BfiADGyAFaiEFIARBCHEiDw0AQXchBgJAIAwNACALQQRrKAIAIgNFDQBBACEGIANBCnANAEEAIQxB5AAhBgNAIAMgBnBFBEAgDEEBaiEMIAZBCmwhBgwBCwsgDEF/cyEGCyALIA5rQQJ1QQlsIQMgBUFfcUHGAEYEQEEAIQ8gCiADIAZqQQlrIgNBACADQQBKGyIDIAMgCkobIQoMAQtBACEPIAogAyAHaiAGakEJayIDQQAgA0EAShsiAyADIApKGyEKCyAKIA9yQQBHIREgAEEgIAIgBUFfcSIMQcYARgR/IAdBACAHQQBKGwUgECAHIAdBH3UiA2ogA3OtIBAQRCIGa0EBTARAA0AgBkEBayIGQTA6AAAgECAGa0ECSA0ACwsgBkECayIVIAU6AAAgBkEBa0EtQSsgB0EASBs6AAAgECAVawsgCiASaiARampBAWoiDSAEECYgACATIBIQIiAAQTAgAiANIARBgIAEcxAmAkACQAJAIAxBxgBGBEAgCUEQakEIciEDIAlBEGpBCXIhByAOIAggCCAOSxsiBSEIA0AgCDUCACAHEEQhBgJAIAUgCEcEQCAGIAlBEGpNDQEDQCAGQQFrIgZBMDoAACAGIAlBEGpLDQALDAELIAYgB0cNACAJQTA6ABggAyEGCyAAIAYgByAGaxAiIAhBBGoiCCAOTQ0AC0EAIQYgEUUNAiAAQdYSQQEQIiAIIAtPDQEgCkEBSA0BA0AgCDUCACAHEEQiBiAJQRBqSwRAA0AgBkEBayIGQTA6AAAgBiAJQRBqSw0ACwsgACAGIApBCSAKQQlIGxAiIApBCWshBiAIQQRqIgggC08NAyAKQQlKIQMgBiEKIAMNAAsMAgsCQCAKQQBIDQAgCyAIQQRqIAggC0kbIQUgCUEQakEJciELIAlBEGpBCHIhAyAIIQcDQCALIAc1AgAgCxBEIgZGBEAgCUEwOgAYIAMhBgsCQCAHIAhHBEAgBiAJQRBqTQ0BA0AgBkEBayIGQTA6AAAgBiAJQRBqSw0ACwwBCyAAIAZBARAiIAZBAWohBkEAIApBAEwgDxsNACAAQdYSQQEQIgsgACAGIAsgBmsiBiAKIAYgCkgbECIgCiAGayEKIAdBBGoiByAFTw0BIApBf0oNAAsLIABBMCAKQRJqQRJBABAmIAAgFSAQIBVrECIMAgsgCiEGCyAAQTAgBkEJakEJQQAQJgsMAQsgE0EJaiATIAVBIHEiCxshCgJAIANBC0sNAEEMIANrIgZFDQBEAAAAAAAAIEAhGgNAIBpEAAAAAAAAMECiIRogBkEBayIGDQALIAotAABBLUYEQCAaIAGaIBqhoJohAQwBCyABIBqgIBqhIQELIBAgCSgCLCIGIAZBH3UiBmogBnOtIBAQRCIGRgRAIAlBMDoADyAJQQ9qIQYLIBJBAnIhDiAJKAIsIQcgBkECayIMIAVBD2o6AAAgBkEBa0EtQSsgB0EASBs6AAAgBEEIcSEHIAlBEGohCANAIAgiBQJ/IAGZRAAAAAAAAOBBYwRAIAGqDAELQYCAgIB4CyIGQYCHAWotAAAgC3I6AAAgASAGt6FEAAAAAAAAMECiIQECQCAFQQFqIgggCUEQamtBAUcNAAJAIAFEAAAAAAAAAABiDQAgA0EASg0AIAdFDQELIAVBLjoAASAFQQJqIQgLIAFEAAAAAAAAAABiDQALIABBICACIA4CfwJAIANFDQAgCCAJa0ESayADTg0AIAMgEGogDGtBAmoMAQsgECAJQRBqIAxqayAIagsiA2oiDSAEECYgACAKIA4QIiAAQTAgAiANIARBgIAEcxAmIAAgCUEQaiAIIAlBEGprIgUQIiAAQTAgAyAFIBAgDGsiA2prQQBBABAmIAAgDCADECILIABBICACIA0gBEGAwABzECYgCUGwBGokACACIA0gAiANShsLBgBB4J8BCwYAQdyfAQsGAEHUnwELGAEBfyMAQRBrIgEgADYCDCABKAIMQQRqCxgBAX8jAEEQayIBIAA2AgwgASgCDEEIagtpAQF/IwBBEGsiASQAIAEgADYCDCABKAIMKAIUBEAgASgCDCgCFBAbCyABQQA2AgggASgCDCgCBARAIAEgASgCDCgCBDYCCAsgASgCDEEEahA4IAEoAgwQFSABKAIIIQAgAUEQaiQAIAALqQEBA38CQCAALQAAIgJFDQADQCABLQAAIgRFBEAgAiEDDAILAkAgAiAERg0AIAJBIHIgAiACQcEAa0EaSRsgAS0AACICQSByIAIgAkHBAGtBGkkbRg0AIAAtAAAhAwwCCyABQQFqIQEgAC0AASECIABBAWohACACDQALCyADQf8BcSIAQSByIAAgAEHBAGtBGkkbIAEtAAAiAEEgciAAIABBwQBrQRpJG2sLiAEBAX8jAEEQayICJAAgAiAANgIMIAIgATYCCCMAQRBrIgAgAigCDDYCDCAAKAIMQQA2AgAgACgCDEEANgIEIAAoAgxBADYCCCACKAIMIAIoAgg2AgACQCACKAIMEKwBQQFGBEAgAigCDEG0mwEoAgA2AgQMAQsgAigCDEEANgIECyACQRBqJAAL2AkBAX8jAEGwAWsiBSQAIAUgADYCpAEgBSABNgKgASAFIAI2ApwBIAUgAzcDkAEgBSAENgKMASAFIAUoAqABNgKIAQJAAkACQAJAAkACQAJAAkACQAJAAkAgBSgCjAEODwABAgMEBQcICQkJCQkJBgkLIAUoAogBQgA3AyAgBUIANwOoAQwJCyAFIAUoAqQBIAUoApwBIAUpA5ABECsiAzcDgAEgA0IAUwRAIAUoAogBQQhqIAUoAqQBEBcgBUJ/NwOoAQwJCwJAIAUpA4ABUARAIAUoAogBKQMoIAUoAogBKQMgUQRAIAUoAogBQQE2AgQgBSgCiAEgBSgCiAEpAyA3AxggBSgCiAEoAgAEQCAFKAKkASAFQcgAahA5QQBIBEAgBSgCiAFBCGogBSgCpAEQFyAFQn83A6gBDA0LAkAgBSkDSEIgg1ANACAFKAJ0IAUoAogBKAIwRg0AIAUoAogBQQhqQQdBABAUIAVCfzcDqAEMDQsCQCAFKQNIQgSDUA0AIAUpA2AgBSgCiAEpAxhRDQAgBSgCiAFBCGpBFUEAEBQgBUJ/NwOoAQwNCwsLDAELAkAgBSgCiAEoAgQNACAFKAKIASkDICAFKAKIASkDKFYNACAFIAUoAogBKQMoIAUoAogBKQMgfTcDQANAIAUpA0AgBSkDgAFUBEAgBSAFKQOAASAFKQNAfUL/////D1YEfkL/////DwUgBSkDgAEgBSkDQH0LNwM4IAUoAogBKAIwIAUoApwBIAUpA0CnaiAFKQM4pxAaIQAgBSgCiAEgADYCMCAFKAKIASIAIAUpAzggACkDKHw3AyggBSAFKQM4IAUpA0B8NwNADAELCwsLIAUoAogBIgAgBSkDgAEgACkDIHw3AyAgBSAFKQOAATcDqAEMCAsgBUIANwOoAQwHCyAFIAUoApwBNgI0IAUoAogBKAIEBEAgBSgCNCAFKAKIASkDGDcDGCAFKAI0IAUoAogBKAIwNgIsIAUoAjQgBSgCiAEpAxg3AyAgBSgCNEEAOwEwIAUoAjRBADsBMiAFKAI0IgAgACkDAELsAYQ3AwALIAVCADcDqAEMBgsgBSAFKAKIAUEIaiAFKAKcASAFKQOQARBDNwOoAQwFCyAFKAKIARAVIAVCADcDqAEMBAsjAEEQayIAIAUoAqQBNgIMIAUgACgCDCkDGDcDKCAFKQMoQgBTBEAgBSgCiAFBCGogBSgCpAEQFyAFQn83A6gBDAQLIAUpAyghAyAFQX82AhggBUEQNgIUIAVBDzYCECAFQQ02AgwgBUEMNgIIIAVBCjYCBCAFQQk2AgAgBUEIIAUQNEJ/hSADgzcDqAEMAwsgBQJ/IAUpA5ABQhBUBEAgBSgCiAFBCGpBEkEAEBRBAAwBCyAFKAKcAQs2AhwgBSgCHEUEQCAFQn83A6gBDAMLAkAgBSgCpAEgBSgCHCkDACAFKAIcKAIIECdBAE4EQCAFIAUoAqQBEEkiAzcDICADQgBZDQELIAUoAogBQQhqIAUoAqQBEBcgBUJ/NwOoAQwDCyAFKAKIASAFKQMgNwMgIAVCADcDqAEMAgsgBSAFKAKIASkDIDcDqAEMAQsgBSgCiAFBCGpBHEEAEBQgBUJ/NwOoAQsgBSkDqAEhAyAFQbABaiQAIAMLnAwBAX8jAEEwayIFJAAgBSAANgIkIAUgATYCICAFIAI2AhwgBSADNwMQIAUgBDYCDCAFIAUoAiA2AggCQAJAAkACQAJAAkACQAJAAkACQCAFKAIMDhEAAQIDBQYICAgICAgICAcIBAgLIAUoAghCADcDGCAFKAIIQQA6AAwgBSgCCEEAOgANIAUoAghBADoADyAFKAIIQn83AyAgBSgCCCgCrEAgBSgCCCgCqEAoAgwRAABBAXFFBEAgBUJ/NwMoDAkLIAVCADcDKAwICyAFKAIkIQEgBSgCCCECIAUoAhwhBCAFKQMQIQMjAEFAaiIAJAAgACABNgI0IAAgAjYCMCAAIAQ2AiwgACADNwMgAkACfyMAQRBrIgEgACgCMDYCDCABKAIMKAIACwRAIABCfzcDOAwBCwJAIAApAyBQRQRAIAAoAjAtAA1BAXFFDQELIABCADcDOAwBCyAAQgA3AwggAEEAOgAbA0AgAC0AG0EBcQR/QQAFIAApAwggACkDIFQLQQFxBEAgACAAKQMgIAApAwh9NwMAIAAgACgCMCgCrEAgACgCLCAAKQMIp2ogACAAKAIwKAKoQCgCHBEBADYCHCAAKAIcQQJHBEAgACAAKQMAIAApAwh8NwMICwJAAkACQAJAIAAoAhxBAWsOAwACAQMLIAAoAjBBAToADQJAIAAoAjAtAAxBAXENAAsgACgCMCkDIEIAUwRAIAAoAjBBFEEAEBQgAEEBOgAbDAMLAkAgACgCMC0ADkEBcUUNACAAKAIwKQMgIAApAwhWDQAgACgCMEEBOgAPIAAoAjAgACgCMCkDIDcDGCAAKAIsIAAoAjBBKGogACgCMCkDGKcQGRogACAAKAIwKQMYNwM4DAYLIABBAToAGwwCCyAAKAIwLQAMQQFxBEAgAEEBOgAbDAILIAAgACgCNCAAKAIwQShqQoDAABArIgM3AxAgA0IAUwRAIAAoAjAgACgCNBAXIABBAToAGwwCCwJAIAApAxBQBEAgACgCMEEBOgAMIAAoAjAoAqxAIAAoAjAoAqhAKAIYEQIAIAAoAjApAyBCAFMEQCAAKAIwQgA3AyALDAELAkAgACgCMCkDIEIAWQRAIAAoAjBBADoADgwBCyAAKAIwIAApAxA3AyALIAAoAjAoAqxAIAAoAjBBKGogACkDECAAKAIwKAKoQCgCFBEQABoLDAELAn8jAEEQayIBIAAoAjA2AgwgASgCDCgCAEULBEAgACgCMEEUQQAQFAsgAEEBOgAbCwwBCwsgACkDCEIAUgRAIAAoAjBBADoADiAAKAIwIgEgACkDCCABKQMYfDcDGCAAIAApAwg3AzgMAQsgAEF/QQACfyMAQRBrIgEgACgCMDYCDCABKAIMKAIACxusNwM4CyAAKQM4IQMgAEFAayQAIAUgAzcDKAwHCyAFKAIIKAKsQCAFKAIIKAKoQCgCEBEAAEEBcUUEQCAFQn83AygMBwsgBUIANwMoDAYLIAUgBSgCHDYCBAJAIAUoAggtABBBAXEEQCAFKAIILQANQQFxBEAgBSgCBCAFKAIILQAPQQFxBH9BAAUCfwJAIAUoAggoAhRBf0cEQCAFKAIIKAIUQX5HDQELQQgMAQsgBSgCCCgCFAtB//8DcQs7ATAgBSgCBCAFKAIIKQMYNwMgIAUoAgQiACAAKQMAQsgAhDcDAAwCCyAFKAIEIgAgACkDAEK3////D4M3AwAMAQsgBSgCBEEAOwEwIAUoAgQiACAAKQMAQsAAhDcDAAJAIAUoAggtAA1BAXEEQCAFKAIEIAUoAggpAxg3AxggBSgCBCIAIAApAwBCBIQ3AwAMAQsgBSgCBCIAIAApAwBC+////w+DNwMACwsgBUIANwMoDAULIAUgBSgCCC0AD0EBcQR/QQAFIAUoAggoAqxAIAUoAggoAqhAKAIIEQAAC6w3AygMBAsgBSAFKAIIIAUoAhwgBSkDEBBDNwMoDAMLIAUoAggQsQEgBUIANwMoDAILIAVBfzYCACAFQRAgBRA0Qj+ENwMoDAELIAUoAghBFEEAEBQgBUJ/NwMoCyAFKQMoIQMgBUEwaiQAIAMLPAEBfyMAQRBrIgMkACADIAA7AQ4gAyABNgIIIAMgAjYCBEEAIAMoAgggAygCBBC0ASEAIANBEGokACAAC46nAQEEfyMAQSBrIgUkACAFIAA2AhggBSABNgIUIAUgAjYCECAFIAUoAhg2AgwgBSgCDCAFKAIQKQMAQv////8PVgR+Qv////8PBSAFKAIQKQMACz4CICAFKAIMIAUoAhQ2AhwCQCAFKAIMLQAEQQFxBEAgBSgCDEEQaiEBQQRBACAFKAIMLQAMQQFxGyECIwBBQGoiACQAIAAgATYCOCAAIAI2AjQCQAJAAkAgACgCOBB4DQAgACgCNEEFSg0AIAAoAjRBAE4NAQsgAEF+NgI8DAELIAAgACgCOCgCHDYCLAJAAkAgACgCOCgCDEUNACAAKAI4KAIEBEAgACgCOCgCAEUNAQsgACgCLCgCBEGaBUcNASAAKAI0QQRGDQELIAAoAjhBsNkAKAIANgIYIABBfjYCPAwBCyAAKAI4KAIQRQRAIAAoAjhBvNkAKAIANgIYIABBezYCPAwBCyAAIAAoAiwoAig2AjAgACgCLCAAKAI0NgIoAkAgACgCLCgCFARAIAAoAjgQHCAAKAI4KAIQRQRAIAAoAixBfzYCKCAAQQA2AjwMAwsMAQsCQCAAKAI4KAIEDQAgACgCNEEBdEEJQQAgACgCNEEEShtrIAAoAjBBAXRBCUEAIAAoAjBBBEoba0oNACAAKAI0QQRGDQAgACgCOEG82QAoAgA2AhggAEF7NgI8DAILCwJAIAAoAiwoAgRBmgVHDQAgACgCOCgCBEUNACAAKAI4QbzZACgCADYCGCAAQXs2AjwMAQsgACgCLCgCBEEqRgRAIAAgACgCLCgCMEEEdEH4AGtBCHQ2AigCQAJAIAAoAiwoAogBQQJIBEAgACgCLCgChAFBAk4NAQsgAEEANgIkDAELAkAgACgCLCgChAFBBkgEQCAAQQE2AiQMAQsCQCAAKAIsKAKEAUEGRgRAIABBAjYCJAwBCyAAQQM2AiQLCwsgACAAKAIoIAAoAiRBBnRyNgIoIAAoAiwoAmwEQCAAIAAoAihBIHI2AigLIAAgACgCKEEfIAAoAihBH3BrajYCKCAAKAIsIAAoAigQSyAAKAIsKAJsBEAgACgCLCAAKAI4KAIwQRB2EEsgACgCLCAAKAI4KAIwQf//A3EQSwtBAEEAQQAQPSEBIAAoAjggATYCMCAAKAIsQfEANgIEIAAoAjgQHCAAKAIsKAIUBEAgACgCLEF/NgIoIABBADYCPAwCCwsgACgCLCgCBEE5RgRAQQBBAEEAEBohASAAKAI4IAE2AjAgACgCLCgCCCECIAAoAiwiAygCFCEBIAMgAUEBajYCFCABIAJqQR86AAAgACgCLCgCCCECIAAoAiwiAygCFCEBIAMgAUEBajYCFCABIAJqQYsBOgAAIAAoAiwoAgghAiAAKAIsIgMoAhQhASADIAFBAWo2AhQgASACakEIOgAAAkAgACgCLCgCHEUEQCAAKAIsKAIIIQIgACgCLCIDKAIUIQEgAyABQQFqNgIUIAEgAmpBADoAACAAKAIsKAIIIQIgACgCLCIDKAIUIQEgAyABQQFqNgIUIAEgAmpBADoAACAAKAIsKAIIIQIgACgCLCIDKAIUIQEgAyABQQFqNgIUIAEgAmpBADoAACAAKAIsKAIIIQIgACgCLCIDKAIUIQEgAyABQQFqNgIUIAEgAmpBADoAACAAKAIsKAIIIQIgACgCLCIDKAIUIQEgAyABQQFqNgIUIAEgAmpBADoAACAAKAIsKAKEAUEJRgR/QQIFQQRBACAAKAIsKAKIAUECSAR/IAAoAiwoAoQBQQJIBUEBC0EBcRsLIQIgACgCLCgCCCEDIAAoAiwiBCgCFCEBIAQgAUEBajYCFCABIANqIAI6AAAgACgCLCgCCCECIAAoAiwiAygCFCEBIAMgAUEBajYCFCABIAJqQQM6AAAgACgCLEHxADYCBCAAKAI4EBwgACgCLCgCFARAIAAoAixBfzYCKCAAQQA2AjwMBAsMAQsgACgCLCgCHCgCAEVFQQJBACAAKAIsKAIcKAIsG2pBBEEAIAAoAiwoAhwoAhAbakEIQQAgACgCLCgCHCgCHBtqQRBBACAAKAIsKAIcKAIkG2ohAiAAKAIsKAIIIQMgACgCLCIEKAIUIQEgBCABQQFqNgIUIAEgA2ogAjoAACAAKAIsKAIcKAIEQf8BcSECIAAoAiwoAgghAyAAKAIsIgQoAhQhASAEIAFBAWo2AhQgASADaiACOgAAIAAoAiwoAhwoAgRBCHZB/wFxIQIgACgCLCgCCCEDIAAoAiwiBCgCFCEBIAQgAUEBajYCFCABIANqIAI6AAAgACgCLCgCHCgCBEEQdkH/AXEhAiAAKAIsKAIIIQMgACgCLCIEKAIUIQEgBCABQQFqNgIUIAEgA2ogAjoAACAAKAIsKAIcKAIEQRh2IQIgACgCLCgCCCEDIAAoAiwiBCgCFCEBIAQgAUEBajYCFCABIANqIAI6AAAgACgCLCgChAFBCUYEf0ECBUEEQQAgACgCLCgCiAFBAkgEfyAAKAIsKAKEAUECSAVBAQtBAXEbCyECIAAoAiwoAgghAyAAKAIsIgQoAhQhASAEIAFBAWo2AhQgASADaiACOgAAIAAoAiwoAhwoAgxB/wFxIQIgACgCLCgCCCEDIAAoAiwiBCgCFCEBIAQgAUEBajYCFCABIANqIAI6AAAgACgCLCgCHCgCEARAIAAoAiwoAhwoAhRB/wFxIQIgACgCLCgCCCEDIAAoAiwiBCgCFCEBIAQgAUEBajYCFCABIANqIAI6AAAgACgCLCgCHCgCFEEIdkH/AXEhAiAAKAIsKAIIIQMgACgCLCIEKAIUIQEgBCABQQFqNgIUIAEgA2ogAjoAAAsgACgCLCgCHCgCLARAIAAoAjgoAjAgACgCLCgCCCAAKAIsKAIUEBohASAAKAI4IAE2AjALIAAoAixBADYCICAAKAIsQcUANgIECwsgACgCLCgCBEHFAEYEQCAAKAIsKAIcKAIQBEAgACAAKAIsKAIUNgIgIAAgACgCLCgCHCgCFEH//wNxIAAoAiwoAiBrNgIcA0AgACgCLCgCDCAAKAIsKAIUIAAoAhxqSQRAIAAgACgCLCgCDCAAKAIsKAIUazYCGCAAKAIsKAIIIAAoAiwoAhRqIAAoAiwoAhwoAhAgACgCLCgCIGogACgCGBAZGiAAKAIsIAAoAiwoAgw2AhQCQCAAKAIsKAIcKAIsRQ0AIAAoAiwoAhQgACgCIE0NACAAKAI4KAIwIAAoAiwoAgggACgCIGogACgCLCgCFCAAKAIgaxAaIQEgACgCOCABNgIwCyAAKAIsIgEgACgCGCABKAIgajYCICAAKAI4EBwgACgCLCgCFARAIAAoAixBfzYCKCAAQQA2AjwMBQUgAEEANgIgIAAgACgCHCAAKAIYazYCHAwCCwALCyAAKAIsKAIIIAAoAiwoAhRqIAAoAiwoAhwoAhAgACgCLCgCIGogACgCHBAZGiAAKAIsIgEgACgCHCABKAIUajYCFAJAIAAoAiwoAhwoAixFDQAgACgCLCgCFCAAKAIgTQ0AIAAoAjgoAjAgACgCLCgCCCAAKAIgaiAAKAIsKAIUIAAoAiBrEBohASAAKAI4IAE2AjALIAAoAixBADYCIAsgACgCLEHJADYCBAsgACgCLCgCBEHJAEYEQCAAKAIsKAIcKAIcBEAgACAAKAIsKAIUNgIUA0AgACgCLCgCFCAAKAIsKAIMRgRAAkAgACgCLCgCHCgCLEUNACAAKAIsKAIUIAAoAhRNDQAgACgCOCgCMCAAKAIsKAIIIAAoAhRqIAAoAiwoAhQgACgCFGsQGiEBIAAoAjggATYCMAsgACgCOBAcIAAoAiwoAhQEQCAAKAIsQX82AiggAEEANgI8DAULIABBADYCFAsgACgCLCgCHCgCHCECIAAoAiwiAygCICEBIAMgAUEBajYCICAAIAEgAmotAAA2AhAgACgCECECIAAoAiwoAgghAyAAKAIsIgQoAhQhASAEIAFBAWo2AhQgASADaiACOgAAIAAoAhANAAsCQCAAKAIsKAIcKAIsRQ0AIAAoAiwoAhQgACgCFE0NACAAKAI4KAIwIAAoAiwoAgggACgCFGogACgCLCgCFCAAKAIUaxAaIQEgACgCOCABNgIwCyAAKAIsQQA2AiALIAAoAixB2wA2AgQLIAAoAiwoAgRB2wBGBEAgACgCLCgCHCgCJARAIAAgACgCLCgCFDYCDANAIAAoAiwoAhQgACgCLCgCDEYEQAJAIAAoAiwoAhwoAixFDQAgACgCLCgCFCAAKAIMTQ0AIAAoAjgoAjAgACgCLCgCCCAAKAIMaiAAKAIsKAIUIAAoAgxrEBohASAAKAI4IAE2AjALIAAoAjgQHCAAKAIsKAIUBEAgACgCLEF/NgIoIABBADYCPAwFCyAAQQA2AgwLIAAoAiwoAhwoAiQhAiAAKAIsIgMoAiAhASADIAFBAWo2AiAgACABIAJqLQAANgIIIAAoAgghAiAAKAIsKAIIIQMgACgCLCIEKAIUIQEgBCABQQFqNgIUIAEgA2ogAjoAACAAKAIIDQALAkAgACgCLCgCHCgCLEUNACAAKAIsKAIUIAAoAgxNDQAgACgCOCgCMCAAKAIsKAIIIAAoAgxqIAAoAiwoAhQgACgCDGsQGiEBIAAoAjggATYCMAsLIAAoAixB5wA2AgQLIAAoAiwoAgRB5wBGBEAgACgCLCgCHCgCLARAIAAoAiwoAgwgACgCLCgCFEECakkEQCAAKAI4EBwgACgCLCgCFARAIAAoAixBfzYCKCAAQQA2AjwMBAsLIAAoAjgoAjBB/wFxIQIgACgCLCgCCCEDIAAoAiwiBCgCFCEBIAQgAUEBajYCFCABIANqIAI6AAAgACgCOCgCMEEIdkH/AXEhAiAAKAIsKAIIIQMgACgCLCIEKAIUIQEgBCABQQFqNgIUIAEgA2ogAjoAAEEAQQBBABAaIQEgACgCOCABNgIwCyAAKAIsQfEANgIEIAAoAjgQHCAAKAIsKAIUBEAgACgCLEF/NgIoIABBADYCPAwCCwsCQAJAIAAoAjgoAgQNACAAKAIsKAJ0DQAgACgCNEUNASAAKAIsKAIEQZoFRg0BCyAAAn8gACgCLCgChAFFBEAgACgCLCAAKAI0ELcBDAELAn8gACgCLCgCiAFBAkYEQCAAKAIsIQIgACgCNCEDIwBBIGsiASQAIAEgAjYCGCABIAM2AhQCQANAAkAgASgCGCgCdEUEQCABKAIYEFwgASgCGCgCdEUEQCABKAIURQRAIAFBADYCHAwFCwwCCwsgASgCGEEANgJgIAEgASgCGCICKAI4IAIoAmxqLQAAOgAPIAEoAhgiAigCpC0gAigCoC1BAXRqQQA7AQAgAS0ADyEDIAEoAhgiAigCmC0hBCACIAIoAqAtIgJBAWo2AqAtIAIgBGogAzoAACABKAIYIAEtAA9BAnRqIgIgAi8BlAFBAWo7AZQBIAEgASgCGCgCoC0gASgCGCgCnC1BAWtGNgIQIAEoAhgiAiACKAJ0QQFrNgJ0IAEoAhgiAiACKAJsQQFqNgJsIAEoAhAEQCABKAIYAn8gASgCGCgCXEEATgRAIAEoAhgoAjggASgCGCgCXGoMAQtBAAsgASgCGCgCbCABKAIYKAJca0EAECggASgCGCABKAIYKAJsNgJcIAEoAhgoAgAQHCABKAIYKAIAKAIQRQRAIAFBADYCHAwECwsMAQsLIAEoAhhBADYCtC0gASgCFEEERgRAIAEoAhgCfyABKAIYKAJcQQBOBEAgASgCGCgCOCABKAIYKAJcagwBC0EACyABKAIYKAJsIAEoAhgoAlxrQQEQKCABKAIYIAEoAhgoAmw2AlwgASgCGCgCABAcIAEoAhgoAgAoAhBFBEAgAUECNgIcDAILIAFBAzYCHAwBCyABKAIYKAKgLQRAIAEoAhgCfyABKAIYKAJcQQBOBEAgASgCGCgCOCABKAIYKAJcagwBC0EACyABKAIYKAJsIAEoAhgoAlxrQQAQKCABKAIYIAEoAhgoAmw2AlwgASgCGCgCABAcIAEoAhgoAgAoAhBFBEAgAUEANgIcDAILCyABQQE2AhwLIAEoAhwhAiABQSBqJAAgAgwBCwJ/IAAoAiwoAogBQQNGBEAgACgCLCECIAAoAjQhAyMAQTBrIgEkACABIAI2AiggASADNgIkAkADQAJAIAEoAigoAnRBggJNBEAgASgCKBBcAkAgASgCKCgCdEGCAksNACABKAIkDQAgAUEANgIsDAQLIAEoAigoAnRFDQELIAEoAihBADYCYAJAIAEoAigoAnRBA0kNACABKAIoKAJsRQ0AIAEgASgCKCgCOCABKAIoKAJsakEBazYCGCABIAEoAhgtAAA2AhwgASgCHCECIAEgASgCGCIDQQFqNgIYAkAgAy0AASACRw0AIAEoAhwhAiABIAEoAhgiA0EBajYCGCADLQABIAJHDQAgASgCHCECIAEgASgCGCIDQQFqNgIYIAMtAAEgAkcNACABIAEoAigoAjggASgCKCgCbGpBggJqNgIUA0AgASgCHCECIAEgASgCGCIDQQFqNgIYAn9BACADLQABIAJHDQAaIAEoAhwhAiABIAEoAhgiA0EBajYCGEEAIAMtAAEgAkcNABogASgCHCECIAEgASgCGCIDQQFqNgIYQQAgAy0AASACRw0AGiABKAIcIQIgASABKAIYIgNBAWo2AhhBACADLQABIAJHDQAaIAEoAhwhAiABIAEoAhgiA0EBajYCGEEAIAMtAAEgAkcNABogASgCHCECIAEgASgCGCIDQQFqNgIYQQAgAy0AASACRw0AGiABKAIcIQIgASABKAIYIgNBAWo2AhhBACADLQABIAJHDQAaIAEoAhwhAiABIAEoAhgiA0EBajYCGEEAIAMtAAEgAkcNABogASgCGCABKAIUSQtBAXENAAsgASgCKEGCAiABKAIUIAEoAhhrazYCYCABKAIoKAJgIAEoAigoAnRLBEAgASgCKCABKAIoKAJ0NgJgCwsLAkAgASgCKCgCYEEDTwRAIAEgASgCKCgCYEEDazoAEyABQQE7ARAgASgCKCICKAKkLSACKAKgLUEBdGogAS8BEDsBACABLQATIQMgASgCKCICKAKYLSEEIAIgAigCoC0iAkEBajYCoC0gAiAEaiADOgAAIAEgAS8BEEEBazsBECABKAIoIAEtABNB0N0Aai0AAEECdGpBmAlqIgIgAi8BAEEBajsBACABKAIoQYgTagJ/IAEvARBBgAJJBEAgAS8BEC0A0FkMAQsgAS8BEEEHdkGAAmotANBZC0ECdGoiAiACLwEAQQFqOwEAIAEgASgCKCgCoC0gASgCKCgCnC1BAWtGNgIgIAEoAigiAiACKAJ0IAEoAigoAmBrNgJ0IAEoAigiAiABKAIoKAJgIAIoAmxqNgJsIAEoAihBADYCYAwBCyABIAEoAigiAigCOCACKAJsai0AADoADyABKAIoIgIoAqQtIAIoAqAtQQF0akEAOwEAIAEtAA8hAyABKAIoIgIoApgtIQQgAiACKAKgLSICQQFqNgKgLSACIARqIAM6AAAgASgCKCABLQAPQQJ0aiICIAIvAZQBQQFqOwGUASABIAEoAigoAqAtIAEoAigoApwtQQFrRjYCICABKAIoIgIgAigCdEEBazYCdCABKAIoIgIgAigCbEEBajYCbAsgASgCIARAIAEoAigCfyABKAIoKAJcQQBOBEAgASgCKCgCOCABKAIoKAJcagwBC0EACyABKAIoKAJsIAEoAigoAlxrQQAQKCABKAIoIAEoAigoAmw2AlwgASgCKCgCABAcIAEoAigoAgAoAhBFBEAgAUEANgIsDAQLCwwBCwsgASgCKEEANgK0LSABKAIkQQRGBEAgASgCKAJ/IAEoAigoAlxBAE4EQCABKAIoKAI4IAEoAigoAlxqDAELQQALIAEoAigoAmwgASgCKCgCXGtBARAoIAEoAiggASgCKCgCbDYCXCABKAIoKAIAEBwgASgCKCgCACgCEEUEQCABQQI2AiwMAgsgAUEDNgIsDAELIAEoAigoAqAtBEAgASgCKAJ/IAEoAigoAlxBAE4EQCABKAIoKAI4IAEoAigoAlxqDAELQQALIAEoAigoAmwgASgCKCgCXGtBABAoIAEoAiggASgCKCgCbDYCXCABKAIoKAIAEBwgASgCKCgCACgCEEUEQCABQQA2AiwMAgsLIAFBATYCLAsgASgCLCECIAFBMGokACACDAELIAAoAiwgACgCNCAAKAIsKAKEAUEMbEGA7wBqKAIIEQMACwsLNgIEAkAgACgCBEECRwRAIAAoAgRBA0cNAQsgACgCLEGaBTYCBAsCQCAAKAIEBEAgACgCBEECRw0BCyAAKAI4KAIQRQRAIAAoAixBfzYCKAsgAEEANgI8DAILIAAoAgRBAUYEQAJAIAAoAjRBAUYEQCAAKAIsIQIjAEEgayIBJAAgASACNgIcIAFBAzYCGAJAIAEoAhwoArwtQRAgASgCGGtKBEAgAUECNgIUIAEoAhwiAiACLwG4LSABKAIUQf//A3EgASgCHCgCvC10cjsBuC0gASgCHC8BuC1B/wFxIQMgASgCHCgCCCEEIAEoAhwiBigCFCECIAYgAkEBajYCFCACIARqIAM6AAAgASgCHC8BuC1BCHYhAyABKAIcKAIIIQQgASgCHCIGKAIUIQIgBiACQQFqNgIUIAIgBGogAzoAACABKAIcIAEoAhRB//8DcUEQIAEoAhwoArwta3U7AbgtIAEoAhwiAiACKAK8LSABKAIYQRBrajYCvC0MAQsgASgCHCICIAIvAbgtQQIgASgCHCgCvC10cjsBuC0gASgCHCICIAEoAhggAigCvC1qNgK8LQsgAUGS6AAvAQA2AhACQCABKAIcKAK8LUEQIAEoAhBrSgRAIAFBkOgALwEANgIMIAEoAhwiAiACLwG4LSABKAIMQf//A3EgASgCHCgCvC10cjsBuC0gASgCHC8BuC1B/wFxIQMgASgCHCgCCCEEIAEoAhwiBigCFCECIAYgAkEBajYCFCACIARqIAM6AAAgASgCHC8BuC1BCHYhAyABKAIcKAIIIQQgASgCHCIGKAIUIQIgBiACQQFqNgIUIAIgBGogAzoAACABKAIcIAEoAgxB//8DcUEQIAEoAhwoArwta3U7AbgtIAEoAhwiAiACKAK8LSABKAIQQRBrajYCvC0MAQsgASgCHCICIAIvAbgtQZDoAC8BACABKAIcKAK8LXRyOwG4LSABKAIcIgIgASgCECACKAK8LWo2ArwtCyABKAIcELwBIAFBIGokAAwBCyAAKAI0QQVHBEAgACgCLEEAQQBBABBdIAAoAjRBA0YEQCAAKAIsKAJEIAAoAiwoAkxBAWtBAXRqQQA7AQAgACgCLCgCREEAIAAoAiwoAkxBAWtBAXQQMyAAKAIsKAJ0RQRAIAAoAixBADYCbCAAKAIsQQA2AlwgACgCLEEANgK0LQsLCwsgACgCOBAcIAAoAjgoAhBFBEAgACgCLEF/NgIoIABBADYCPAwDCwsLIAAoAjRBBEcEQCAAQQA2AjwMAQsgACgCLCgCGEEATARAIABBATYCPAwBCwJAIAAoAiwoAhhBAkYEQCAAKAI4KAIwQf8BcSECIAAoAiwoAgghAyAAKAIsIgQoAhQhASAEIAFBAWo2AhQgASADaiACOgAAIAAoAjgoAjBBCHZB/wFxIQIgACgCLCgCCCEDIAAoAiwiBCgCFCEBIAQgAUEBajYCFCABIANqIAI6AAAgACgCOCgCMEEQdkH/AXEhAiAAKAIsKAIIIQMgACgCLCIEKAIUIQEgBCABQQFqNgIUIAEgA2ogAjoAACAAKAI4KAIwQRh2IQIgACgCLCgCCCEDIAAoAiwiBCgCFCEBIAQgAUEBajYCFCABIANqIAI6AAAgACgCOCgCCEH/AXEhAiAAKAIsKAIIIQMgACgCLCIEKAIUIQEgBCABQQFqNgIUIAEgA2ogAjoAACAAKAI4KAIIQQh2Qf8BcSECIAAoAiwoAgghAyAAKAIsIgQoAhQhASAEIAFBAWo2AhQgASADaiACOgAAIAAoAjgoAghBEHZB/wFxIQIgACgCLCgCCCEDIAAoAiwiBCgCFCEBIAQgAUEBajYCFCABIANqIAI6AAAgACgCOCgCCEEYdiECIAAoAiwoAgghAyAAKAIsIgQoAhQhASAEIAFBAWo2AhQgASADaiACOgAADAELIAAoAiwgACgCOCgCMEEQdhBLIAAoAiwgACgCOCgCMEH//wNxEEsLIAAoAjgQHCAAKAIsKAIYQQBKBEAgACgCLEEAIAAoAiwoAhhrNgIYCyAAIAAoAiwoAhRFNgI8CyAAKAI8IQEgAEFAayQAIAUgATYCCAwBCyAFKAIMQRBqIQEjAEHgAGsiACQAIAAgATYCWCAAQQI2AlQCQAJAAkAgACgCWBBKDQAgACgCWCgCDEUNACAAKAJYKAIADQEgACgCWCgCBEUNAQsgAEF+NgJcDAELIAAgACgCWCgCHDYCUCAAKAJQKAIEQb/+AEYEQCAAKAJQQcD+ADYCBAsgACAAKAJYKAIMNgJIIAAgACgCWCgCEDYCQCAAIAAoAlgoAgA2AkwgACAAKAJYKAIENgJEIAAgACgCUCgCPDYCPCAAIAAoAlAoAkA2AjggACAAKAJENgI0IAAgACgCQDYCMCAAQQA2AhADQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAAKAJQKAIEQbT+AGsOHwABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fCyAAKAJQKAIMRQRAIAAoAlBBwP4ANgIEDCELA0AgACgCOEEQSQRAIAAoAkRFDSEgACAAKAJEQQFrNgJEIAAgACgCTCIBQQFqNgJMIAAgACgCPCABLQAAIAAoAjh0ajYCPCAAIAAoAjhBCGo2AjgMAQsLAkAgACgCUCgCDEECcUUNACAAKAI8QZ+WAkcNACAAKAJQKAIoRQRAIAAoAlBBDzYCKAtBAEEAQQAQGiEBIAAoAlAgATYCHCAAIAAoAjw6AAwgACAAKAI8QQh2OgANIAAoAlAoAhwgAEEMakECEBohASAAKAJQIAE2AhwgAEEANgI8IABBADYCOCAAKAJQQbX+ADYCBAwhCyAAKAJQQQA2AhQgACgCUCgCJARAIAAoAlAoAiRBfzYCMAsCQCAAKAJQKAIMQQFxBEAgACgCPEH/AXFBCHQgACgCPEEIdmpBH3BFDQELIAAoAlhBmgw2AhggACgCUEHR/gA2AgQMIQsgACgCPEEPcUEIRwRAIAAoAlhBmw82AhggACgCUEHR/gA2AgQMIQsgACAAKAI8QQR2NgI8IAAgACgCOEEEazYCOCAAIAAoAjxBD3FBCGo2AhQgACgCUCgCKEUEQCAAKAJQIAAoAhQ2AigLAkAgACgCFEEPTQRAIAAoAhQgACgCUCgCKE0NAQsgACgCWEGTDTYCGCAAKAJQQdH+ADYCBAwhCyAAKAJQQQEgACgCFHQ2AhhBAEEAQQAQPSEBIAAoAlAgATYCHCAAKAJYIAE2AjAgACgCUEG9/gBBv/4AIAAoAjxBgARxGzYCBCAAQQA2AjwgAEEANgI4DCALA0AgACgCOEEQSQRAIAAoAkRFDSAgACAAKAJEQQFrNgJEIAAgACgCTCIBQQFqNgJMIAAgACgCPCABLQAAIAAoAjh0ajYCPCAAIAAoAjhBCGo2AjgMAQsLIAAoAlAgACgCPDYCFCAAKAJQKAIUQf8BcUEIRwRAIAAoAlhBmw82AhggACgCUEHR/gA2AgQMIAsgACgCUCgCFEGAwANxBEAgACgCWEGgCTYCGCAAKAJQQdH+ADYCBAwgCyAAKAJQKAIkBEAgACgCUCgCJCAAKAI8QQh2QQFxNgIACwJAIAAoAlAoAhRBgARxRQ0AIAAoAlAoAgxBBHFFDQAgACAAKAI8OgAMIAAgACgCPEEIdjoADSAAKAJQKAIcIABBDGpBAhAaIQEgACgCUCABNgIcCyAAQQA2AjwgAEEANgI4IAAoAlBBtv4ANgIECwNAIAAoAjhBIEkEQCAAKAJERQ0fIAAgACgCREEBazYCRCAAIAAoAkwiAUEBajYCTCAAIAAoAjwgAS0AACAAKAI4dGo2AjwgACAAKAI4QQhqNgI4DAELCyAAKAJQKAIkBEAgACgCUCgCJCAAKAI8NgIECwJAIAAoAlAoAhRBgARxRQ0AIAAoAlAoAgxBBHFFDQAgACAAKAI8OgAMIAAgACgCPEEIdjoADSAAIAAoAjxBEHY6AA4gACAAKAI8QRh2OgAPIAAoAlAoAhwgAEEMakEEEBohASAAKAJQIAE2AhwLIABBADYCPCAAQQA2AjggACgCUEG3/gA2AgQLA0AgACgCOEEQSQRAIAAoAkRFDR4gACAAKAJEQQFrNgJEIAAgACgCTCIBQQFqNgJMIAAgACgCPCABLQAAIAAoAjh0ajYCPCAAIAAoAjhBCGo2AjgMAQsLIAAoAlAoAiQEQCAAKAJQKAIkIAAoAjxB/wFxNgIIIAAoAlAoAiQgACgCPEEIdjYCDAsCQCAAKAJQKAIUQYAEcUUNACAAKAJQKAIMQQRxRQ0AIAAgACgCPDoADCAAIAAoAjxBCHY6AA0gACgCUCgCHCAAQQxqQQIQGiEBIAAoAlAgATYCHAsgAEEANgI8IABBADYCOCAAKAJQQbj+ADYCBAsCQCAAKAJQKAIUQYAIcQRAA0AgACgCOEEQSQRAIAAoAkRFDR8gACAAKAJEQQFrNgJEIAAgACgCTCIBQQFqNgJMIAAgACgCPCABLQAAIAAoAjh0ajYCPCAAIAAoAjhBCGo2AjgMAQsLIAAoAlAgACgCPDYCRCAAKAJQKAIkBEAgACgCUCgCJCAAKAI8NgIUCwJAIAAoAlAoAhRBgARxRQ0AIAAoAlAoAgxBBHFFDQAgACAAKAI8OgAMIAAgACgCPEEIdjoADSAAKAJQKAIcIABBDGpBAhAaIQEgACgCUCABNgIcCyAAQQA2AjwgAEEANgI4DAELIAAoAlAoAiQEQCAAKAJQKAIkQQA2AhALCyAAKAJQQbn+ADYCBAsgACgCUCgCFEGACHEEQCAAIAAoAlAoAkQ2AiwgACgCLCAAKAJESwRAIAAgACgCRDYCLAsgACgCLARAAkAgACgCUCgCJEUNACAAKAJQKAIkKAIQRQ0AIAAgACgCUCgCJCgCFCAAKAJQKAJEazYCFCAAKAJQKAIkKAIQIAAoAhRqIAAoAkwCfyAAKAJQKAIkKAIYIAAoAhQgACgCLGpJBEAgACgCUCgCJCgCGCAAKAIUawwBCyAAKAIsCxAZGgsCQCAAKAJQKAIUQYAEcUUNACAAKAJQKAIMQQRxRQ0AIAAoAlAoAhwgACgCTCAAKAIsEBohASAAKAJQIAE2AhwLIAAgACgCRCAAKAIsazYCRCAAIAAoAiwgACgCTGo2AkwgACgCUCIBIAEoAkQgACgCLGs2AkQLIAAoAlAoAkQNGwsgACgCUEEANgJEIAAoAlBBuv4ANgIECwJAIAAoAlAoAhRBgBBxBEAgACgCREUNGyAAQQA2AiwDQCAAKAJMIQEgACAAKAIsIgJBAWo2AiwgACABIAJqLQAANgIUAkAgACgCUCgCJEUNACAAKAJQKAIkKAIcRQ0AIAAoAlAoAkQgACgCUCgCJCgCIE8NACAAKAIUIQIgACgCUCgCJCgCHCEDIAAoAlAiBCgCRCEBIAQgAUEBajYCRCABIANqIAI6AAALIAAoAhQEfyAAKAIsIAAoAkRJBUEAC0EBcQ0ACwJAIAAoAlAoAhRBgARxRQ0AIAAoAlAoAgxBBHFFDQAgACgCUCgCHCAAKAJMIAAoAiwQGiEBIAAoAlAgATYCHAsgACAAKAJEIAAoAixrNgJEIAAgACgCLCAAKAJMajYCTCAAKAIUDRsMAQsgACgCUCgCJARAIAAoAlAoAiRBADYCHAsLIAAoAlBBADYCRCAAKAJQQbv+ADYCBAsCQCAAKAJQKAIUQYAgcQRAIAAoAkRFDRogAEEANgIsA0AgACgCTCEBIAAgACgCLCICQQFqNgIsIAAgASACai0AADYCFAJAIAAoAlAoAiRFDQAgACgCUCgCJCgCJEUNACAAKAJQKAJEIAAoAlAoAiQoAihPDQAgACgCFCECIAAoAlAoAiQoAiQhAyAAKAJQIgQoAkQhASAEIAFBAWo2AkQgASADaiACOgAACyAAKAIUBH8gACgCLCAAKAJESQVBAAtBAXENAAsCQCAAKAJQKAIUQYAEcUUNACAAKAJQKAIMQQRxRQ0AIAAoAlAoAhwgACgCTCAAKAIsEBohASAAKAJQIAE2AhwLIAAgACgCRCAAKAIsazYCRCAAIAAoAiwgACgCTGo2AkwgACgCFA0aDAELIAAoAlAoAiQEQCAAKAJQKAIkQQA2AiQLCyAAKAJQQbz+ADYCBAsgACgCUCgCFEGABHEEQANAIAAoAjhBEEkEQCAAKAJERQ0aIAAgACgCREEBazYCRCAAIAAoAkwiAUEBajYCTCAAIAAoAjwgAS0AACAAKAI4dGo2AjwgACAAKAI4QQhqNgI4DAELCwJAIAAoAlAoAgxBBHFFDQAgACgCPCAAKAJQKAIcQf//A3FGDQAgACgCWEH7DDYCGCAAKAJQQdH+ADYCBAwaCyAAQQA2AjwgAEEANgI4CyAAKAJQKAIkBEAgACgCUCgCJCAAKAJQKAIUQQl1QQFxNgIsIAAoAlAoAiRBATYCMAtBAEEAQQAQGiEBIAAoAlAgATYCHCAAKAJYIAE2AjAgACgCUEG//gA2AgQMGAsDQCAAKAI4QSBJBEAgACgCREUNGCAAIAAoAkRBAWs2AkQgACAAKAJMIgFBAWo2AkwgACAAKAI8IAEtAAAgACgCOHRqNgI8IAAgACgCOEEIajYCOAwBCwsgACgCUCAAKAI8QQh2QYD+A3EgACgCPEEYdmogACgCPEGA/gNxQQh0aiAAKAI8Qf8BcUEYdGoiATYCHCAAKAJYIAE2AjAgAEEANgI8IABBADYCOCAAKAJQQb7+ADYCBAsgACgCUCgCEEUEQCAAKAJYIAAoAkg2AgwgACgCWCAAKAJANgIQIAAoAlggACgCTDYCACAAKAJYIAAoAkQ2AgQgACgCUCAAKAI8NgI8IAAoAlAgACgCODYCQCAAQQI2AlwMGAtBAEEAQQAQPSEBIAAoAlAgATYCHCAAKAJYIAE2AjAgACgCUEG//gA2AgQLIAAoAlRBBUYNFCAAKAJUQQZGDRQLIAAoAlAoAggEQCAAIAAoAjwgACgCOEEHcXY2AjwgACAAKAI4IAAoAjhBB3FrNgI4IAAoAlBBzv4ANgIEDBULA0AgACgCOEEDSQRAIAAoAkRFDRUgACAAKAJEQQFrNgJEIAAgACgCTCIBQQFqNgJMIAAgACgCPCABLQAAIAAoAjh0ajYCPCAAIAAoAjhBCGo2AjgMAQsLIAAoAlAgACgCPEEBcTYCCCAAIAAoAjxBAXY2AjwgACAAKAI4QQFrNgI4AkACQAJAAkACQCAAKAI8QQNxDgQAAQIDBAsgACgCUEHB/gA2AgQMAwsjAEEQayIBIAAoAlA2AgwgASgCDEGw8gA2AlAgASgCDEEJNgJYIAEoAgxBsIIBNgJUIAEoAgxBBTYCXCAAKAJQQcf+ADYCBCAAKAJUQQZGBEAgACAAKAI8QQJ2NgI8IAAgACgCOEECazYCOAwXCwwCCyAAKAJQQcT+ADYCBAwBCyAAKAJYQfANNgIYIAAoAlBB0f4ANgIECyAAIAAoAjxBAnY2AjwgACAAKAI4QQJrNgI4DBQLIAAgACgCPCAAKAI4QQdxdjYCPCAAIAAoAjggACgCOEEHcWs2AjgDQCAAKAI4QSBJBEAgACgCREUNFCAAIAAoAkRBAWs2AkQgACAAKAJMIgFBAWo2AkwgACAAKAI8IAEtAAAgACgCOHRqNgI8IAAgACgCOEEIajYCOAwBCwsgACgCPEH//wNxIAAoAjxBEHZB//8Dc0cEQCAAKAJYQaEKNgIYIAAoAlBB0f4ANgIEDBQLIAAoAlAgACgCPEH//wNxNgJEIABBADYCPCAAQQA2AjggACgCUEHC/gA2AgQgACgCVEEGRg0SCyAAKAJQQcP+ADYCBAsgACAAKAJQKAJENgIsIAAoAiwEQCAAKAIsIAAoAkRLBEAgACAAKAJENgIsCyAAKAIsIAAoAkBLBEAgACAAKAJANgIsCyAAKAIsRQ0RIAAoAkggACgCTCAAKAIsEBkaIAAgACgCRCAAKAIsazYCRCAAIAAoAiwgACgCTGo2AkwgACAAKAJAIAAoAixrNgJAIAAgACgCLCAAKAJIajYCSCAAKAJQIgEgASgCRCAAKAIsazYCRAwSCyAAKAJQQb/+ADYCBAwRCwNAIAAoAjhBDkkEQCAAKAJERQ0RIAAgACgCREEBazYCRCAAIAAoAkwiAUEBajYCTCAAIAAoAjwgAS0AACAAKAI4dGo2AjwgACAAKAI4QQhqNgI4DAELCyAAKAJQIAAoAjxBH3FBgQJqNgJkIAAgACgCPEEFdjYCPCAAIAAoAjhBBWs2AjggACgCUCAAKAI8QR9xQQFqNgJoIAAgACgCPEEFdjYCPCAAIAAoAjhBBWs2AjggACgCUCAAKAI8QQ9xQQRqNgJgIAAgACgCPEEEdjYCPCAAIAAoAjhBBGs2AjgCQCAAKAJQKAJkQZ4CTQRAIAAoAlAoAmhBHk0NAQsgACgCWEH9CTYCGCAAKAJQQdH+ADYCBAwRCyAAKAJQQQA2AmwgACgCUEHF/gA2AgQLA0AgACgCUCgCbCAAKAJQKAJgSQRAA0AgACgCOEEDSQRAIAAoAkRFDRIgACAAKAJEQQFrNgJEIAAgACgCTCIBQQFqNgJMIAAgACgCPCABLQAAIAAoAjh0ajYCPCAAIAAoAjhBCGo2AjgMAQsLIAAoAjxBB3EhAiAAKAJQQfQAaiEDIAAoAlAiBCgCbCEBIAQgAUEBajYCbCABQQF0QYDyAGovAQBBAXQgA2ogAjsBACAAIAAoAjxBA3Y2AjwgACAAKAI4QQNrNgI4DAELCwNAIAAoAlAoAmxBE0kEQCAAKAJQQfQAaiECIAAoAlAiAygCbCEBIAMgAUEBajYCbCABQQF0QYDyAGovAQBBAXQgAmpBADsBAAwBCwsgACgCUCAAKAJQQbQKajYCcCAAKAJQIAAoAlAoAnA2AlAgACgCUEEHNgJYIABBACAAKAJQQfQAakETIAAoAlBB8ABqIAAoAlBB2ABqIAAoAlBB9AVqEHU2AhAgACgCEARAIAAoAlhBhwk2AhggACgCUEHR/gA2AgQMEAsgACgCUEEANgJsIAAoAlBBxv4ANgIECwNAAkAgACgCUCgCbCAAKAJQKAJkIAAoAlAoAmhqTw0AA0ACQCAAIAAoAlAoAlAgACgCPEEBIAAoAlAoAlh0QQFrcUECdGooAQA2ASAgAC0AISAAKAI4TQ0AIAAoAkRFDREgACAAKAJEQQFrNgJEIAAgACgCTCIBQQFqNgJMIAAgACgCPCABLQAAIAAoAjh0ajYCPCAAIAAoAjhBCGo2AjgMAQsLAkAgAC8BIkEQSQRAIAAgACgCPCAALQAhdjYCPCAAIAAoAjggAC0AIWs2AjggAC8BIiECIAAoAlBB9ABqIQMgACgCUCIEKAJsIQEgBCABQQFqNgJsIAFBAXQgA2ogAjsBAAwBCwJAIAAvASJBEEYEQANAIAAoAjggAC0AIUECakkEQCAAKAJERQ0UIAAgACgCREEBazYCRCAAIAAoAkwiAUEBajYCTCAAIAAoAjwgAS0AACAAKAI4dGo2AjwgACAAKAI4QQhqNgI4DAELCyAAIAAoAjwgAC0AIXY2AjwgACAAKAI4IAAtACFrNgI4IAAoAlAoAmxFBEAgACgCWEHPCTYCGCAAKAJQQdH+ADYCBAwECyAAIAAoAlAgACgCUCgCbEEBdGovAXI2AhQgACAAKAI8QQNxQQNqNgIsIAAgACgCPEECdjYCPCAAIAAoAjhBAms2AjgMAQsCQCAALwEiQRFGBEADQCAAKAI4IAAtACFBA2pJBEAgACgCREUNFSAAIAAoAkRBAWs2AkQgACAAKAJMIgFBAWo2AkwgACAAKAI8IAEtAAAgACgCOHRqNgI8IAAgACgCOEEIajYCOAwBCwsgACAAKAI8IAAtACF2NgI8IAAgACgCOCAALQAhazYCOCAAQQA2AhQgACAAKAI8QQdxQQNqNgIsIAAgACgCPEEDdjYCPCAAIAAoAjhBA2s2AjgMAQsDQCAAKAI4IAAtACFBB2pJBEAgACgCREUNFCAAIAAoAkRBAWs2AkQgACAAKAJMIgFBAWo2AkwgACAAKAI8IAEtAAAgACgCOHRqNgI8IAAgACgCOEEIajYCOAwBCwsgACAAKAI8IAAtACF2NgI8IAAgACgCOCAALQAhazYCOCAAQQA2AhQgACAAKAI8Qf8AcUELajYCLCAAIAAoAjxBB3Y2AjwgACAAKAI4QQdrNgI4CwsgACgCUCgCbCAAKAIsaiAAKAJQKAJkIAAoAlAoAmhqSwRAIAAoAlhBzwk2AhggACgCUEHR/gA2AgQMAgsDQCAAIAAoAiwiAUEBazYCLCABBEAgACgCFCECIAAoAlBB9ABqIQMgACgCUCIEKAJsIQEgBCABQQFqNgJsIAFBAXQgA2ogAjsBAAwBCwsLDAELCyAAKAJQKAIEQdH+AEYNDiAAKAJQLwH0BEUEQCAAKAJYQfULNgIYIAAoAlBB0f4ANgIEDA8LIAAoAlAgACgCUEG0Cmo2AnAgACgCUCAAKAJQKAJwNgJQIAAoAlBBCTYCWCAAQQEgACgCUEH0AGogACgCUCgCZCAAKAJQQfAAaiAAKAJQQdgAaiAAKAJQQfQFahB1NgIQIAAoAhAEQCAAKAJYQesINgIYIAAoAlBB0f4ANgIEDA8LIAAoAlAgACgCUCgCcDYCVCAAKAJQQQY2AlwgAEECIAAoAlBB9ABqIAAoAlAoAmRBAXRqIAAoAlAoAmggACgCUEHwAGogACgCUEHcAGogACgCUEH0BWoQdTYCECAAKAIQBEAgACgCWEG5CTYCGCAAKAJQQdH+ADYCBAwPCyAAKAJQQcf+ADYCBCAAKAJUQQZGDQ0LIAAoAlBByP4ANgIECwJAIAAoAkRBBkkNACAAKAJAQYICSQ0AIAAoAlggACgCSDYCDCAAKAJYIAAoAkA2AhAgACgCWCAAKAJMNgIAIAAoAlggACgCRDYCBCAAKAJQIAAoAjw2AjwgACgCUCAAKAI4NgJAIAAoAjAhAiMAQeAAayIBIAAoAlg2AlwgASACNgJYIAEgASgCXCgCHDYCVCABIAEoAlwoAgA2AlAgASABKAJQIAEoAlwoAgRBBWtqNgJMIAEgASgCXCgCDDYCSCABIAEoAkggASgCWCABKAJcKAIQa2s2AkQgASABKAJIIAEoAlwoAhBBgQJrajYCQCABIAEoAlQoAiw2AjwgASABKAJUKAIwNgI4IAEgASgCVCgCNDYCNCABIAEoAlQoAjg2AjAgASABKAJUKAI8NgIsIAEgASgCVCgCQDYCKCABIAEoAlQoAlA2AiQgASABKAJUKAJUNgIgIAFBASABKAJUKAJYdEEBazYCHCABQQEgASgCVCgCXHRBAWs2AhgDQCABKAIoQQ9JBEAgASABKAJQIgJBAWo2AlAgASABKAIsIAItAAAgASgCKHRqNgIsIAEgASgCKEEIajYCKCABIAEoAlAiAkEBajYCUCABIAEoAiwgAi0AACABKAIodGo2AiwgASABKAIoQQhqNgIoCyABIAEoAiQgASgCLCABKAIccUECdGooAQA2ARACQAJAA0AgASABLQARNgIMIAEgASgCLCABKAIMdjYCLCABIAEoAiggASgCDGs2AiggASABLQAQNgIMIAEoAgxFBEAgAS8BEiECIAEgASgCSCIDQQFqNgJIIAMgAjoAAAwCCyABKAIMQRBxBEAgASABLwESNgIIIAEgASgCDEEPcTYCDCABKAIMBEAgASgCKCABKAIMSQRAIAEgASgCUCICQQFqNgJQIAEgASgCLCACLQAAIAEoAih0ajYCLCABIAEoAihBCGo2AigLIAEgASgCCCABKAIsQQEgASgCDHRBAWtxajYCCCABIAEoAiwgASgCDHY2AiwgASABKAIoIAEoAgxrNgIoCyABKAIoQQ9JBEAgASABKAJQIgJBAWo2AlAgASABKAIsIAItAAAgASgCKHRqNgIsIAEgASgCKEEIajYCKCABIAEoAlAiAkEBajYCUCABIAEoAiwgAi0AACABKAIodGo2AiwgASABKAIoQQhqNgIoCyABIAEoAiAgASgCLCABKAIYcUECdGooAQA2ARACQANAIAEgAS0AETYCDCABIAEoAiwgASgCDHY2AiwgASABKAIoIAEoAgxrNgIoIAEgAS0AEDYCDCABKAIMQRBxBEAgASABLwESNgIEIAEgASgCDEEPcTYCDCABKAIoIAEoAgxJBEAgASABKAJQIgJBAWo2AlAgASABKAIsIAItAAAgASgCKHRqNgIsIAEgASgCKEEIajYCKCABKAIoIAEoAgxJBEAgASABKAJQIgJBAWo2AlAgASABKAIsIAItAAAgASgCKHRqNgIsIAEgASgCKEEIajYCKAsLIAEgASgCBCABKAIsQQEgASgCDHRBAWtxajYCBCABIAEoAiwgASgCDHY2AiwgASABKAIoIAEoAgxrNgIoIAEgASgCSCABKAJEazYCDAJAIAEoAgQgASgCDEsEQCABIAEoAgQgASgCDGs2AgwgASgCDCABKAI4SwRAIAEoAlQoAsQ3BEAgASgCXEHdDDYCGCABKAJUQdH+ADYCBAwKCwsgASABKAIwNgIAAkAgASgCNEUEQCABIAEoAgAgASgCPCABKAIMa2o2AgAgASgCDCABKAIISQRAIAEgASgCCCABKAIMazYCCANAIAEgASgCACICQQFqNgIAIAItAAAhAiABIAEoAkgiA0EBajYCSCADIAI6AAAgASABKAIMQQFrIgI2AgwgAg0ACyABIAEoAkggASgCBGs2AgALDAELAkAgASgCNCABKAIMSQRAIAEgASgCACABKAI8IAEoAjRqIAEoAgxrajYCACABIAEoAgwgASgCNGs2AgwgASgCDCABKAIISQRAIAEgASgCCCABKAIMazYCCANAIAEgASgCACICQQFqNgIAIAItAAAhAiABIAEoAkgiA0EBajYCSCADIAI6AAAgASABKAIMQQFrIgI2AgwgAg0ACyABIAEoAjA2AgAgASgCNCABKAIISQRAIAEgASgCNDYCDCABIAEoAgggASgCDGs2AggDQCABIAEoAgAiAkEBajYCACACLQAAIQIgASABKAJIIgNBAWo2AkggAyACOgAAIAEgASgCDEEBayICNgIMIAINAAsgASABKAJIIAEoAgRrNgIACwsMAQsgASABKAIAIAEoAjQgASgCDGtqNgIAIAEoAgwgASgCCEkEQCABIAEoAgggASgCDGs2AggDQCABIAEoAgAiAkEBajYCACACLQAAIQIgASABKAJIIgNBAWo2AkggAyACOgAAIAEgASgCDEEBayICNgIMIAINAAsgASABKAJIIAEoAgRrNgIACwsLA0AgASgCCEECSwRAIAEgASgCACICQQFqNgIAIAItAAAhAiABIAEoAkgiA0EBajYCSCADIAI6AAAgASABKAIAIgJBAWo2AgAgAi0AACECIAEgASgCSCIDQQFqNgJIIAMgAjoAACABIAEoAgAiAkEBajYCACACLQAAIQIgASABKAJIIgNBAWo2AkggAyACOgAAIAEgASgCCEEDazYCCAwBCwsMAQsgASABKAJIIAEoAgRrNgIAA0AgASABKAIAIgJBAWo2AgAgAi0AACECIAEgASgCSCIDQQFqNgJIIAMgAjoAACABIAEoAgAiAkEBajYCACACLQAAIQIgASABKAJIIgNBAWo2AkggAyACOgAAIAEgASgCACICQQFqNgIAIAItAAAhAiABIAEoAkgiA0EBajYCSCADIAI6AAAgASABKAIIQQNrNgIIIAEoAghBAksNAAsLIAEoAggEQCABIAEoAgAiAkEBajYCACACLQAAIQIgASABKAJIIgNBAWo2AkggAyACOgAAIAEoAghBAUsEQCABIAEoAgAiAkEBajYCACACLQAAIQIgASABKAJIIgNBAWo2AkggAyACOgAACwsMAgsgASgCDEHAAHFFBEAgASABKAIgIAEvARIgASgCLEEBIAEoAgx0QQFrcWpBAnRqKAEANgEQDAELCyABKAJcQYUPNgIYIAEoAlRB0f4ANgIEDAQLDAILIAEoAgxBwABxRQRAIAEgASgCJCABLwESIAEoAixBASABKAIMdEEBa3FqQQJ0aigBADYBEAwBCwsgASgCDEEgcQRAIAEoAlRBv/4ANgIEDAILIAEoAlxB6Q42AhggASgCVEHR/gA2AgQMAQsgASgCUCABKAJMSQR/IAEoAkggASgCQEkFQQALQQFxDQELCyABIAEoAihBA3Y2AgggASABKAJQIAEoAghrNgJQIAEgASgCKCABKAIIQQN0azYCKCABIAEoAixBASABKAIodEEBa3E2AiwgASgCXCABKAJQNgIAIAEoAlwgASgCSDYCDCABKAJcAn8gASgCUCABKAJMSQRAIAEoAkwgASgCUGtBBWoMAQtBBSABKAJQIAEoAkxraws2AgQgASgCXAJ/IAEoAkggASgCQEkEQCABKAJAIAEoAkhrQYECagwBC0GBAiABKAJIIAEoAkBraws2AhAgASgCVCABKAIsNgI8IAEoAlQgASgCKDYCQCAAIAAoAlgoAgw2AkggACAAKAJYKAIQNgJAIAAgACgCWCgCADYCTCAAIAAoAlgoAgQ2AkQgACAAKAJQKAI8NgI8IAAgACgCUCgCQDYCOCAAKAJQKAIEQb/+AEYEQCAAKAJQQX82Asg3CwwNCyAAKAJQQQA2Asg3A0ACQCAAIAAoAlAoAlAgACgCPEEBIAAoAlAoAlh0QQFrcUECdGooAQA2ASAgAC0AISAAKAI4TQ0AIAAoAkRFDQ0gACAAKAJEQQFrNgJEIAAgACgCTCIBQQFqNgJMIAAgACgCPCABLQAAIAAoAjh0ajYCPCAAIAAoAjhBCGo2AjgMAQsLAkAgAC0AIEUNACAALQAgQfABcQ0AIAAgACgBIDYBGANAAkAgACAAKAJQKAJQIAAvARogACgCPEEBIAAtABkgAC0AGGp0QQFrcSAALQAZdmpBAnRqKAEANgEgIAAoAjggAC0AGSAALQAhak8NACAAKAJERQ0OIAAgACgCREEBazYCRCAAIAAoAkwiAUEBajYCTCAAIAAoAjwgAS0AACAAKAI4dGo2AjwgACAAKAI4QQhqNgI4DAELCyAAIAAoAjwgAC0AGXY2AjwgACAAKAI4IAAtABlrNgI4IAAoAlAiASAALQAZIAEoAsg3ajYCyDcLIAAgACgCPCAALQAhdjYCPCAAIAAoAjggAC0AIWs2AjggACgCUCIBIAAtACEgASgCyDdqNgLINyAAKAJQIAAvASI2AkQgAC0AIEUEQCAAKAJQQc3+ADYCBAwNCyAALQAgQSBxBEAgACgCUEF/NgLINyAAKAJQQb/+ADYCBAwNCyAALQAgQcAAcQRAIAAoAlhB6Q42AhggACgCUEHR/gA2AgQMDQsgACgCUCAALQAgQQ9xNgJMIAAoAlBByf4ANgIECyAAKAJQKAJMBEADQCAAKAI4IAAoAlAoAkxJBEAgACgCREUNDSAAIAAoAkRBAWs2AkQgACAAKAJMIgFBAWo2AkwgACAAKAI8IAEtAAAgACgCOHRqNgI8IAAgACgCOEEIajYCOAwBCwsgACgCUCIBIAEoAkQgACgCPEEBIAAoAlAoAkx0QQFrcWo2AkQgACAAKAI8IAAoAlAoAkx2NgI8IAAgACgCOCAAKAJQKAJMazYCOCAAKAJQIgEgACgCUCgCTCABKALIN2o2Asg3CyAAKAJQIAAoAlAoAkQ2Asw3IAAoAlBByv4ANgIECwNAAkAgACAAKAJQKAJUIAAoAjxBASAAKAJQKAJcdEEBa3FBAnRqKAEANgEgIAAtACEgACgCOE0NACAAKAJERQ0LIAAgACgCREEBazYCRCAAIAAoAkwiAUEBajYCTCAAIAAoAjwgAS0AACAAKAI4dGo2AjwgACAAKAI4QQhqNgI4DAELCyAALQAgQfABcUUEQCAAIAAoASA2ARgDQAJAIAAgACgCUCgCVCAALwEaIAAoAjxBASAALQAZIAAtABhqdEEBa3EgAC0AGXZqQQJ0aigBADYBICAAKAI4IAAtABkgAC0AIWpPDQAgACgCREUNDCAAIAAoAkRBAWs2AkQgACAAKAJMIgFBAWo2AkwgACAAKAI8IAEtAAAgACgCOHRqNgI8IAAgACgCOEEIajYCOAwBCwsgACAAKAI8IAAtABl2NgI8IAAgACgCOCAALQAZazYCOCAAKAJQIgEgAC0AGSABKALIN2o2Asg3CyAAIAAoAjwgAC0AIXY2AjwgACAAKAI4IAAtACFrNgI4IAAoAlAiASAALQAhIAEoAsg3ajYCyDcgAC0AIEHAAHEEQCAAKAJYQYUPNgIYIAAoAlBB0f4ANgIEDAsLIAAoAlAgAC8BIjYCSCAAKAJQIAAtACBBD3E2AkwgACgCUEHL/gA2AgQLIAAoAlAoAkwEQANAIAAoAjggACgCUCgCTEkEQCAAKAJERQ0LIAAgACgCREEBazYCRCAAIAAoAkwiAUEBajYCTCAAIAAoAjwgAS0AACAAKAI4dGo2AjwgACAAKAI4QQhqNgI4DAELCyAAKAJQIgEgASgCSCAAKAI8QQEgACgCUCgCTHRBAWtxajYCSCAAIAAoAjwgACgCUCgCTHY2AjwgACAAKAI4IAAoAlAoAkxrNgI4IAAoAlAiASAAKAJQKAJMIAEoAsg3ajYCyDcLIAAoAlBBzP4ANgIECyAAKAJARQ0HIAAgACgCMCAAKAJAazYCLAJAIAAoAlAoAkggACgCLEsEQCAAIAAoAlAoAkggACgCLGs2AiwgACgCLCAAKAJQKAIwSwRAIAAoAlAoAsQ3BEAgACgCWEHdDDYCGCAAKAJQQdH+ADYCBAwMCwsCQCAAKAIsIAAoAlAoAjRLBEAgACAAKAIsIAAoAlAoAjRrNgIsIAAgACgCUCgCOCAAKAJQKAIsIAAoAixrajYCKAwBCyAAIAAoAlAoAjggACgCUCgCNCAAKAIsa2o2AigLIAAoAiwgACgCUCgCREsEQCAAIAAoAlAoAkQ2AiwLDAELIAAgACgCSCAAKAJQKAJIazYCKCAAIAAoAlAoAkQ2AiwLIAAoAiwgACgCQEsEQCAAIAAoAkA2AiwLIAAgACgCQCAAKAIsazYCQCAAKAJQIgEgASgCRCAAKAIsazYCRANAIAAgACgCKCIBQQFqNgIoIAEtAAAhASAAIAAoAkgiAkEBajYCSCACIAE6AAAgACAAKAIsQQFrIgE2AiwgAQ0ACyAAKAJQKAJERQRAIAAoAlBByP4ANgIECwwICyAAKAJARQ0GIAAoAlAoAkQhASAAIAAoAkgiAkEBajYCSCACIAE6AAAgACAAKAJAQQFrNgJAIAAoAlBByP4ANgIEDAcLIAAoAlAoAgwEQANAIAAoAjhBIEkEQCAAKAJERQ0IIAAgACgCREEBazYCRCAAIAAoAkwiAUEBajYCTCAAIAAoAjwgAS0AACAAKAI4dGo2AjwgACAAKAI4QQhqNgI4DAELCyAAIAAoAjAgACgCQGs2AjAgACgCWCIBIAAoAjAgASgCFGo2AhQgACgCUCIBIAAoAjAgASgCIGo2AiACQCAAKAJQKAIMQQRxRQ0AIAAoAjBFDQACfyAAKAJQKAIUBEAgACgCUCgCHCAAKAJIIAAoAjBrIAAoAjAQGgwBCyAAKAJQKAIcIAAoAkggACgCMGsgACgCMBA9CyEBIAAoAlAgATYCHCAAKAJYIAE2AjALIAAgACgCQDYCMAJAIAAoAlAoAgxBBHFFDQACfyAAKAJQKAIUBEAgACgCPAwBCyAAKAI8QQh2QYD+A3EgACgCPEEYdmogACgCPEGA/gNxQQh0aiAAKAI8Qf8BcUEYdGoLIAAoAlAoAhxGDQAgACgCWEHIDDYCGCAAKAJQQdH+ADYCBAwICyAAQQA2AjwgAEEANgI4CyAAKAJQQc/+ADYCBAsCQCAAKAJQKAIMRQ0AIAAoAlAoAhRFDQADQCAAKAI4QSBJBEAgACgCREUNByAAIAAoAkRBAWs2AkQgACAAKAJMIgFBAWo2AkwgACAAKAI8IAEtAAAgACgCOHRqNgI8IAAgACgCOEEIajYCOAwBCwsgACgCPCAAKAJQKAIgRwRAIAAoAlhBsQw2AhggACgCUEHR/gA2AgQMBwsgAEEANgI8IABBADYCOAsgACgCUEHQ/gA2AgQLIABBATYCEAwDCyAAQX02AhAMAgsgAEF8NgJcDAMLIABBfjYCXAwCCwsgACgCWCAAKAJINgIMIAAoAlggACgCQDYCECAAKAJYIAAoAkw2AgAgACgCWCAAKAJENgIEIAAoAlAgACgCPDYCPCAAKAJQIAAoAjg2AkACQAJAIAAoAlAoAiwNACAAKAIwIAAoAlgoAhBGDQEgACgCUCgCBEHR/gBPDQEgACgCUCgCBEHO/gBJDQAgACgCVEEERg0BCwJ/IAAoAlghAiAAKAJYKAIMIQMgACgCMCAAKAJYKAIQayEEIwBBIGsiASQAIAEgAjYCGCABIAM2AhQgASAENgIQIAEgASgCGCgCHDYCDAJAIAEoAgwoAjhFBEAgASgCGCgCKEEBIAEoAgwoAih0QQEgASgCGCgCIBEBACECIAEoAgwgAjYCOCABKAIMKAI4RQRAIAFBATYCHAwCCwsgASgCDCgCLEUEQCABKAIMQQEgASgCDCgCKHQ2AiwgASgCDEEANgI0IAEoAgxBADYCMAsCQCABKAIQIAEoAgwoAixPBEAgASgCDCgCOCABKAIUIAEoAgwoAixrIAEoAgwoAiwQGRogASgCDEEANgI0IAEoAgwgASgCDCgCLDYCMAwBCyABIAEoAgwoAiwgASgCDCgCNGs2AgggASgCCCABKAIQSwRAIAEgASgCEDYCCAsgASgCDCgCOCABKAIMKAI0aiABKAIUIAEoAhBrIAEoAggQGRogASABKAIQIAEoAghrNgIQAkAgASgCEARAIAEoAgwoAjggASgCFCABKAIQayABKAIQEBkaIAEoAgwgASgCEDYCNCABKAIMIAEoAgwoAiw2AjAMAQsgASgCDCICIAEoAgggAigCNGo2AjQgASgCDCgCNCABKAIMKAIsRgRAIAEoAgxBADYCNAsgASgCDCgCMCABKAIMKAIsSQRAIAEoAgwiAiABKAIIIAIoAjBqNgIwCwsLIAFBADYCHAsgASgCHCECIAFBIGokACACCwRAIAAoAlBB0v4ANgIEIABBfDYCXAwCCwsgACAAKAI0IAAoAlgoAgRrNgI0IAAgACgCMCAAKAJYKAIQazYCMCAAKAJYIgEgACgCNCABKAIIajYCCCAAKAJYIgEgACgCMCABKAIUajYCFCAAKAJQIgEgACgCMCABKAIgajYCIAJAIAAoAlAoAgxBBHFFDQAgACgCMEUNAAJ/IAAoAlAoAhQEQCAAKAJQKAIcIAAoAlgoAgwgACgCMGsgACgCMBAaDAELIAAoAlAoAhwgACgCWCgCDCAAKAIwayAAKAIwED0LIQEgACgCUCABNgIcIAAoAlggATYCMAsgACgCWCAAKAJQKAJAQcAAQQAgACgCUCgCCBtqQYABQQAgACgCUCgCBEG//gBGG2pBgAJBACAAKAJQKAIEQcf+AEcEfyAAKAJQKAIEQcL+AEYFQQELQQFxG2o2AiwCQAJAIAAoAjRFBEAgACgCMEUNAQsgACgCVEEERw0BCyAAKAIQDQAgAEF7NgIQCyAAIAAoAhA2AlwLIAAoAlwhASAAQeAAaiQAIAUgATYCCAsgBSgCECIAIAApAwAgBSgCDDUCIH03AwACQAJAAkACQAJAIAUoAghBBWoOBwIDAwMDAAEDCyAFQQA2AhwMAwsgBUEBNgIcDAILIAUoAgwoAhRFBEAgBUEDNgIcDAILCyAFKAIMKAIAQQ0gBSgCCBAUIAVBAjYCHAsgBSgCHCEAIAVBIGokACAACyQBAX8jAEEQayIBIAA2AgwgASABKAIMNgIIIAEoAghBAToADAuXAQEBfyMAQSBrIgMkACADIAA2AhggAyABNgIUIAMgAjcDCCADIAMoAhg2AgQCQAJAIAMpAwhC/////w9YBEAgAygCBCgCFEUNAQsgAygCBCgCAEESQQAQFCADQQA6AB8MAQsgAygCBCADKQMIPgIUIAMoAgQgAygCFDYCECADQQE6AB8LIAMtAB9BAXEhACADQSBqJAAgAAukAgECfyMAQRBrIgEkACABIAA2AgggASABKAIINgIEAkAgASgCBC0ABEEBcQRAIAEgASgCBEEQahC4ATYCAAwBCyABKAIEQRBqIQIjAEEQayIAJAAgACACNgIIAkAgACgCCBBKBEAgAEF+NgIMDAELIAAgACgCCCgCHDYCBCAAKAIEKAI4BEAgACgCCCgCKCAAKAIEKAI4IAAoAggoAiQRBAALIAAoAggoAiggACgCCCgCHCAAKAIIKAIkEQQAIAAoAghBADYCHCAAQQA2AgwLIAAoAgwhAiAAQRBqJAAgASACNgIACwJAIAEoAgAEQCABKAIEKAIAQQ0gASgCABAUIAFBADoADwwBCyABQQE6AA8LIAEtAA9BAXEhACABQRBqJAAgAAuyGAEFfyMAQRBrIgQkACAEIAA2AgggBCAEKAIINgIEIAQoAgRBADYCFCAEKAIEQQA2AhAgBCgCBEEANgIgIAQoAgRBADYCHAJAIAQoAgQtAARBAXEEQCAEKAIEQRBqIQEgBCgCBCgCCCECIwBBMGsiACQAIAAgATYCKCAAIAI2AiQgAEEINgIgIABBcTYCHCAAQQk2AhggAEEANgIUIABBwBI2AhAgAEE4NgIMIABBATYCBAJAAkACQCAAKAIQRQ0AIAAoAhAsAABB+O4ALAAARw0AIAAoAgxBOEYNAQsgAEF6NgIsDAELIAAoAihFBEAgAEF+NgIsDAELIAAoAihBADYCGCAAKAIoKAIgRQRAIAAoAihBBTYCICAAKAIoQQA2AigLIAAoAigoAiRFBEAgACgCKEEGNgIkCyAAKAIkQX9GBEAgAEEGNgIkCwJAIAAoAhxBAEgEQCAAQQA2AgQgAEEAIAAoAhxrNgIcDAELIAAoAhxBD0oEQCAAQQI2AgQgACAAKAIcQRBrNgIcCwsCQAJAIAAoAhhBAUgNACAAKAIYQQlKDQAgACgCIEEIRw0AIAAoAhxBCEgNACAAKAIcQQ9KDQAgACgCJEEASA0AIAAoAiRBCUoNACAAKAIUQQBIDQAgACgCFEEESg0AIAAoAhxBCEcNASAAKAIEQQFGDQELIABBfjYCLAwBCyAAKAIcQQhGBEAgAEEJNgIcCyAAIAAoAigoAihBAUHELSAAKAIoKAIgEQEANgIIIAAoAghFBEAgAEF8NgIsDAELIAAoAiggACgCCDYCHCAAKAIIIAAoAig2AgAgACgCCEEqNgIEIAAoAgggACgCBDYCGCAAKAIIQQA2AhwgACgCCCAAKAIcNgIwIAAoAghBASAAKAIIKAIwdDYCLCAAKAIIIAAoAggoAixBAWs2AjQgACgCCCAAKAIYQQdqNgJQIAAoAghBASAAKAIIKAJQdDYCTCAAKAIIIAAoAggoAkxBAWs2AlQgACgCCCAAKAIIKAJQQQJqQQNuNgJYIAAoAigoAiggACgCCCgCLEECIAAoAigoAiARAQAhASAAKAIIIAE2AjggACgCKCgCKCAAKAIIKAIsQQIgACgCKCgCIBEBACEBIAAoAgggATYCQCAAKAIoKAIoIAAoAggoAkxBAiAAKAIoKAIgEQEAIQEgACgCCCABNgJEIAAoAghBADYCwC0gACgCCEEBIAAoAhhBBmp0NgKcLSAAIAAoAigoAiggACgCCCgCnC1BBCAAKAIoKAIgEQEANgIAIAAoAgggACgCADYCCCAAKAIIIAAoAggoApwtQQJ0NgIMAkACQCAAKAIIKAI4RQ0AIAAoAggoAkBFDQAgACgCCCgCREUNACAAKAIIKAIIDQELIAAoAghBmgU2AgQgACgCKEG42QAoAgA2AhggACgCKBC4ARogAEF8NgIsDAELIAAoAgggACgCACAAKAIIKAKcLUEBdkEBdGo2AqQtIAAoAgggACgCCCgCCCAAKAIIKAKcLUEDbGo2ApgtIAAoAgggACgCJDYChAEgACgCCCAAKAIUNgKIASAAKAIIIAAoAiA6ACQgACgCKCEBIwBBEGsiAyQAIAMgATYCDCADKAIMIQIjAEEQayIBJAAgASACNgIIAkAgASgCCBB4BEAgAUF+NgIMDAELIAEoAghBADYCFCABKAIIQQA2AgggASgCCEEANgIYIAEoAghBAjYCLCABIAEoAggoAhw2AgQgASgCBEEANgIUIAEoAgQgASgCBCgCCDYCECABKAIEKAIYQQBIBEAgASgCBEEAIAEoAgQoAhhrNgIYCyABKAIEIAEoAgQoAhhBAkYEf0E5BUEqQfEAIAEoAgQoAhgbCzYCBAJ/IAEoAgQoAhhBAkYEQEEAQQBBABAaDAELQQBBAEEAED0LIQIgASgCCCACNgIwIAEoAgRBADYCKCABKAIEIQUjAEEQayICJAAgAiAFNgIMIAIoAgwgAigCDEGUAWo2ApgWIAIoAgxB0N8ANgKgFiACKAIMIAIoAgxBiBNqNgKkFiACKAIMQeTfADYCrBYgAigCDCACKAIMQfwUajYCsBYgAigCDEH43wA2ArgWIAIoAgxBADsBuC0gAigCDEEANgK8LSACKAIMEL4BIAJBEGokACABQQA2AgwLIAEoAgwhAiABQRBqJAAgAyACNgIIIAMoAghFBEAgAygCDCgCHCECIwBBEGsiASQAIAEgAjYCDCABKAIMIAEoAgwoAixBAXQ2AjwgASgCDCgCRCABKAIMKAJMQQFrQQF0akEAOwEAIAEoAgwoAkRBACABKAIMKAJMQQFrQQF0EDMgASgCDCABKAIMKAKEAUEMbEGA7wBqLwECNgKAASABKAIMIAEoAgwoAoQBQQxsQYDvAGovAQA2AowBIAEoAgwgASgCDCgChAFBDGxBgO8Aai8BBDYCkAEgASgCDCABKAIMKAKEAUEMbEGA7wBqLwEGNgJ8IAEoAgxBADYCbCABKAIMQQA2AlwgASgCDEEANgJ0IAEoAgxBADYCtC0gASgCDEECNgJ4IAEoAgxBAjYCYCABKAIMQQA2AmggASgCDEEANgJIIAFBEGokAAsgAygCCCEBIANBEGokACAAIAE2AiwLIAAoAiwhASAAQTBqJAAgBCABNgIADAELIAQoAgRBEGohASMAQSBrIgAkACAAIAE2AhggAEFxNgIUIABBwBI2AhAgAEE4NgIMAkACQAJAIAAoAhBFDQAgACgCECwAAEHAEiwAAEcNACAAKAIMQThGDQELIABBejYCHAwBCyAAKAIYRQRAIABBfjYCHAwBCyAAKAIYQQA2AhggACgCGCgCIEUEQCAAKAIYQQU2AiAgACgCGEEANgIoCyAAKAIYKAIkRQRAIAAoAhhBBjYCJAsgACAAKAIYKAIoQQFB0DcgACgCGCgCIBEBADYCBCAAKAIERQRAIABBfDYCHAwBCyAAKAIYIAAoAgQ2AhwgACgCBCAAKAIYNgIAIAAoAgRBADYCOCAAKAIEQbT+ADYCBCAAKAIYIQIgACgCFCEDIwBBIGsiASQAIAEgAjYCGCABIAM2AhQCQCABKAIYEEoEQCABQX42AhwMAQsgASABKAIYKAIcNgIMAkAgASgCFEEASARAIAFBADYCECABQQAgASgCFGs2AhQMAQsgASABKAIUQQR1QQVqNgIQIAEoAhRBMEgEQCABIAEoAhRBD3E2AhQLCwJAIAEoAhRFDQAgASgCFEEITgRAIAEoAhRBD0wNAQsgAUF+NgIcDAELAkAgASgCDCgCOEUNACABKAIMKAIoIAEoAhRGDQAgASgCGCgCKCABKAIMKAI4IAEoAhgoAiQRBAAgASgCDEEANgI4CyABKAIMIAEoAhA2AgwgASgCDCABKAIUNgIoIAEoAhghAiMAQRBrIgMkACADIAI2AggCQCADKAIIEEoEQCADQX42AgwMAQsgAyADKAIIKAIcNgIEIAMoAgRBADYCLCADKAIEQQA2AjAgAygCBEEANgI0IAMoAgghBSMAQRBrIgIkACACIAU2AggCQCACKAIIEEoEQCACQX42AgwMAQsgAiACKAIIKAIcNgIEIAIoAgRBADYCICACKAIIQQA2AhQgAigCCEEANgIIIAIoAghBADYCGCACKAIEKAIMBEAgAigCCCACKAIEKAIMQQFxNgIwCyACKAIEQbT+ADYCBCACKAIEQQA2AgggAigCBEEANgIQIAIoAgRBgIACNgIYIAIoAgRBADYCJCACKAIEQQA2AjwgAigCBEEANgJAIAIoAgQgAigCBEG0CmoiBTYCcCACKAIEIAU2AlQgAigCBCAFNgJQIAIoAgRBATYCxDcgAigCBEF/NgLINyACQQA2AgwLIAIoAgwhBSACQRBqJAAgAyAFNgIMCyADKAIMIQIgA0EQaiQAIAEgAjYCHAsgASgCHCECIAFBIGokACAAIAI2AgggACgCCARAIAAoAhgoAiggACgCBCAAKAIYKAIkEQQAIAAoAhhBADYCHAsgACAAKAIINgIcCyAAKAIcIQEgAEEgaiQAIAQgATYCAAsCQCAEKAIABEAgBCgCBCgCAEENIAQoAgAQFCAEQQA6AA8MAQsgBEEBOgAPCyAELQAPQQFxIQAgBEEQaiQAIAALbwEBfyMAQRBrIgEgADYCCCABIAEoAgg2AgQCQCABKAIELQAEQQFxRQRAIAFBADYCDAwBCyABKAIEKAIIQQNIBEAgAUECNgIMDAELIAEoAgQoAghBB0oEQCABQQE2AgwMAQsgAUEANgIMCyABKAIMCywBAX8jAEEQayIBJAAgASAANgIMIAEgASgCDDYCCCABKAIIEBUgAUEQaiQACzwBAX8jAEEQayIDJAAgAyAAOwEOIAMgATYCCCADIAI2AgRBASADKAIIIAMoAgQQtAEhACADQRBqJAAgAAvBEAECfyMAQSBrIgIkACACIAA2AhggAiABNgIUAkADQAJAIAIoAhgoAnRBhgJJBEAgAigCGBBcAkAgAigCGCgCdEGGAk8NACACKAIUDQAgAkEANgIcDAQLIAIoAhgoAnRFDQELIAJBADYCECACKAIYKAJ0QQNPBEAgAigCGCACKAIYKAJUIAIoAhgoAjggAigCGCgCbEECamotAAAgAigCGCgCSCACKAIYKAJYdHNxNgJIIAIoAhgoAkAgAigCGCgCbCACKAIYKAI0cUEBdGogAigCGCgCRCACKAIYKAJIQQF0ai8BACIAOwEAIAIgAEH//wNxNgIQIAIoAhgoAkQgAigCGCgCSEEBdGogAigCGCgCbDsBAAsgAigCGCACKAIYKAJgNgJ4IAIoAhggAigCGCgCcDYCZCACKAIYQQI2AmACQCACKAIQRQ0AIAIoAhgoAnggAigCGCgCgAFPDQAgAigCGCgCLEGGAmsgAigCGCgCbCACKAIQa0kNACACKAIYIAIoAhAQtgEhACACKAIYIAA2AmACQCACKAIYKAJgQQVLDQAgAigCGCgCiAFBAUcEQCACKAIYKAJgQQNHDQEgAigCGCgCbCACKAIYKAJwa0GAIE0NAQsgAigCGEECNgJgCwsCQAJAIAIoAhgoAnhBA0kNACACKAIYKAJgIAIoAhgoAnhLDQAgAiACKAIYIgAoAmwgACgCdGpBA2s2AgggAiACKAIYKAJ4QQNrOgAHIAIgAigCGCIAKAJsIAAoAmRBf3NqOwEEIAIoAhgiACgCpC0gACgCoC1BAXRqIAIvAQQ7AQAgAi0AByEBIAIoAhgiACgCmC0hAyAAIAAoAqAtIgBBAWo2AqAtIAAgA2ogAToAACACIAIvAQRBAWs7AQQgAigCGCACLQAHQdDdAGotAABBAnRqQZgJaiIAIAAvAQBBAWo7AQAgAigCGEGIE2oCfyACLwEEQYACSQRAIAIvAQQtANBZDAELIAIvAQRBB3ZBgAJqLQDQWQtBAnRqIgAgAC8BAEEBajsBACACIAIoAhgoAqAtIAIoAhgoApwtQQFrRjYCDCACKAIYIgAgACgCdCACKAIYKAJ4QQFrazYCdCACKAIYIgAgACgCeEECazYCeANAIAIoAhgiASgCbEEBaiEAIAEgADYCbCAAIAIoAghNBEAgAigCGCACKAIYKAJUIAIoAhgoAjggAigCGCgCbEECamotAAAgAigCGCgCSCACKAIYKAJYdHNxNgJIIAIoAhgoAkAgAigCGCgCbCACKAIYKAI0cUEBdGogAigCGCgCRCACKAIYKAJIQQF0ai8BACIAOwEAIAIgAEH//wNxNgIQIAIoAhgoAkQgAigCGCgCSEEBdGogAigCGCgCbDsBAAsgAigCGCIBKAJ4QQFrIQAgASAANgJ4IAANAAsgAigCGEEANgJoIAIoAhhBAjYCYCACKAIYIgAgACgCbEEBajYCbCACKAIMBEAgAigCGAJ/IAIoAhgoAlxBAE4EQCACKAIYKAI4IAIoAhgoAlxqDAELQQALIAIoAhgoAmwgAigCGCgCXGtBABAoIAIoAhggAigCGCgCbDYCXCACKAIYKAIAEBwgAigCGCgCACgCEEUEQCACQQA2AhwMBgsLDAELAkAgAigCGCgCaARAIAIgAigCGCIAKAI4IAAoAmxqQQFrLQAAOgADIAIoAhgiACgCpC0gACgCoC1BAXRqQQA7AQAgAi0AAyEBIAIoAhgiACgCmC0hAyAAIAAoAqAtIgBBAWo2AqAtIAAgA2ogAToAACACKAIYIAItAANBAnRqIgAgAC8BlAFBAWo7AZQBIAIgAigCGCgCoC0gAigCGCgCnC1BAWtGNgIMIAIoAgwEQCACKAIYAn8gAigCGCgCXEEATgRAIAIoAhgoAjggAigCGCgCXGoMAQtBAAsgAigCGCgCbCACKAIYKAJca0EAECggAigCGCACKAIYKAJsNgJcIAIoAhgoAgAQHAsgAigCGCIAIAAoAmxBAWo2AmwgAigCGCIAIAAoAnRBAWs2AnQgAigCGCgCACgCEEUEQCACQQA2AhwMBgsMAQsgAigCGEEBNgJoIAIoAhgiACAAKAJsQQFqNgJsIAIoAhgiACAAKAJ0QQFrNgJ0CwsMAQsLIAIoAhgoAmgEQCACIAIoAhgiACgCOCAAKAJsakEBay0AADoAAiACKAIYIgAoAqQtIAAoAqAtQQF0akEAOwEAIAItAAIhASACKAIYIgAoApgtIQMgACAAKAKgLSIAQQFqNgKgLSAAIANqIAE6AAAgAigCGCACLQACQQJ0aiIAIAAvAZQBQQFqOwGUASACIAIoAhgoAqAtIAIoAhgoApwtQQFrRjYCDCACKAIYQQA2AmgLIAIoAhgCfyACKAIYKAJsQQJJBEAgAigCGCgCbAwBC0ECCzYCtC0gAigCFEEERgRAIAIoAhgCfyACKAIYKAJcQQBOBEAgAigCGCgCOCACKAIYKAJcagwBC0EACyACKAIYKAJsIAIoAhgoAlxrQQEQKCACKAIYIAIoAhgoAmw2AlwgAigCGCgCABAcIAIoAhgoAgAoAhBFBEAgAkECNgIcDAILIAJBAzYCHAwBCyACKAIYKAKgLQRAIAIoAhgCfyACKAIYKAJcQQBOBEAgAigCGCgCOCACKAIYKAJcagwBC0EACyACKAIYKAJsIAIoAhgoAlxrQQAQKCACKAIYIAIoAhgoAmw2AlwgAigCGCgCABAcIAIoAhgoAgAoAhBFBEAgAkEANgIcDAILCyACQQE2AhwLIAIoAhwhACACQSBqJAAgAAuVDQECfyMAQSBrIgIkACACIAA2AhggAiABNgIUAkADQAJAIAIoAhgoAnRBhgJJBEAgAigCGBBcAkAgAigCGCgCdEGGAk8NACACKAIUDQAgAkEANgIcDAQLIAIoAhgoAnRFDQELIAJBADYCECACKAIYKAJ0QQNPBEAgAigCGCACKAIYKAJUIAIoAhgoAjggAigCGCgCbEECamotAAAgAigCGCgCSCACKAIYKAJYdHNxNgJIIAIoAhgoAkAgAigCGCgCbCACKAIYKAI0cUEBdGogAigCGCgCRCACKAIYKAJIQQF0ai8BACIAOwEAIAIgAEH//wNxNgIQIAIoAhgoAkQgAigCGCgCSEEBdGogAigCGCgCbDsBAAsCQCACKAIQRQ0AIAIoAhgoAixBhgJrIAIoAhgoAmwgAigCEGtJDQAgAigCGCACKAIQELYBIQAgAigCGCAANgJgCwJAIAIoAhgoAmBBA08EQCACIAIoAhgoAmBBA2s6AAsgAiACKAIYIgAoAmwgACgCcGs7AQggAigCGCIAKAKkLSAAKAKgLUEBdGogAi8BCDsBACACLQALIQEgAigCGCIAKAKYLSEDIAAgACgCoC0iAEEBajYCoC0gACADaiABOgAAIAIgAi8BCEEBazsBCCACKAIYIAItAAtB0N0Aai0AAEECdGpBmAlqIgAgAC8BAEEBajsBACACKAIYQYgTagJ/IAIvAQhBgAJJBEAgAi8BCC0A0FkMAQsgAi8BCEEHdkGAAmotANBZC0ECdGoiACAALwEAQQFqOwEAIAIgAigCGCgCoC0gAigCGCgCnC1BAWtGNgIMIAIoAhgiACAAKAJ0IAIoAhgoAmBrNgJ0AkACQCACKAIYKAJgIAIoAhgoAoABSw0AIAIoAhgoAnRBA0kNACACKAIYIgAgACgCYEEBazYCYANAIAIoAhgiACAAKAJsQQFqNgJsIAIoAhggAigCGCgCVCACKAIYKAI4IAIoAhgoAmxBAmpqLQAAIAIoAhgoAkggAigCGCgCWHRzcTYCSCACKAIYKAJAIAIoAhgoAmwgAigCGCgCNHFBAXRqIAIoAhgoAkQgAigCGCgCSEEBdGovAQAiADsBACACIABB//8DcTYCECACKAIYKAJEIAIoAhgoAkhBAXRqIAIoAhgoAmw7AQAgAigCGCIBKAJgQQFrIQAgASAANgJgIAANAAsgAigCGCIAIAAoAmxBAWo2AmwMAQsgAigCGCIAIAIoAhgoAmAgACgCbGo2AmwgAigCGEEANgJgIAIoAhggAigCGCgCOCACKAIYKAJsai0AADYCSCACKAIYIAIoAhgoAlQgAigCGCgCOCACKAIYKAJsQQFqai0AACACKAIYKAJIIAIoAhgoAlh0c3E2AkgLDAELIAIgAigCGCIAKAI4IAAoAmxqLQAAOgAHIAIoAhgiACgCpC0gACgCoC1BAXRqQQA7AQAgAi0AByEBIAIoAhgiACgCmC0hAyAAIAAoAqAtIgBBAWo2AqAtIAAgA2ogAToAACACKAIYIAItAAdBAnRqIgAgAC8BlAFBAWo7AZQBIAIgAigCGCgCoC0gAigCGCgCnC1BAWtGNgIMIAIoAhgiACAAKAJ0QQFrNgJ0IAIoAhgiACAAKAJsQQFqNgJsCyACKAIMBEAgAigCGAJ/IAIoAhgoAlxBAE4EQCACKAIYKAI4IAIoAhgoAlxqDAELQQALIAIoAhgoAmwgAigCGCgCXGtBABAoIAIoAhggAigCGCgCbDYCXCACKAIYKAIAEBwgAigCGCgCACgCEEUEQCACQQA2AhwMBAsLDAELCyACKAIYAn8gAigCGCgCbEECSQRAIAIoAhgoAmwMAQtBAgs2ArQtIAIoAhRBBEYEQCACKAIYAn8gAigCGCgCXEEATgRAIAIoAhgoAjggAigCGCgCXGoMAQtBAAsgAigCGCgCbCACKAIYKAJca0EBECggAigCGCACKAIYKAJsNgJcIAIoAhgoAgAQHCACKAIYKAIAKAIQRQRAIAJBAjYCHAwCCyACQQM2AhwMAQsgAigCGCgCoC0EQCACKAIYAn8gAigCGCgCXEEATgRAIAIoAhgoAjggAigCGCgCXGoMAQtBAAsgAigCGCgCbCACKAIYKAJca0EAECggAigCGCACKAIYKAJsNgJcIAIoAhgoAgAQHCACKAIYKAIAKAIQRQRAIAJBADYCHAwCCwsgAkEBNgIcCyACKAIcIQAgAkEgaiQAIAALBwAgAC8BMAspAQF/IwBBEGsiAiQAIAIgADYCDCACIAE2AgggAigCCBAVIAJBEGokAAs6AQF/IwBBEGsiAyQAIAMgADYCDCADIAE2AgggAyACNgIEIAMoAgggAygCBGwQGCEAIANBEGokACAAC84FAQF/IwBB0ABrIgUkACAFIAA2AkQgBSABNgJAIAUgAjYCPCAFIAM3AzAgBSAENgIsIAUgBSgCQDYCKAJAAkACQAJAAkACQAJAAkACQCAFKAIsDg8AAQIDBQYHBwcHBwcHBwQHCwJ/IAUoAkQhASAFKAIoIQIjAEHgAGsiACQAIAAgATYCWCAAIAI2AlQgACAAKAJYIABByABqQgwQKyIDNwMIAkAgA0IAUwRAIAAoAlQgACgCWBAXIABBfzYCXAwBCyAAKQMIQgxSBEAgACgCVEERQQAQFCAAQX82AlwMAQsgACgCVCAAQcgAaiAAQcgAakIMQQAQfCAAKAJYIABBEGoQOUEASARAIABBADYCXAwBCyAAKAI4IABBBmogAEEEahCNAQJAIAAtAFMgACgCPEEYdkYNACAALQBTIAAvAQZBCHZGDQAgACgCVEEbQQAQFCAAQX82AlwMAQsgAEEANgJcCyAAKAJcIQEgAEHgAGokACABQQBICwRAIAVCfzcDSAwICyAFQgA3A0gMBwsgBSAFKAJEIAUoAjwgBSkDMBArIgM3AyAgA0IAUwRAIAUoAiggBSgCRBAXIAVCfzcDSAwHCyAFKAJAIAUoAjwgBSgCPCAFKQMgQQAQfCAFIAUpAyA3A0gMBgsgBUIANwNIDAULIAUgBSgCPDYCHCAFKAIcQQA7ATIgBSgCHCIAIAApAwBCgAGENwMAIAUoAhwpAwBCCINCAFIEQCAFKAIcIgAgACkDIEIMfTcDIAsgBUIANwNIDAQLIAVBfzYCFCAFQQU2AhAgBUEENgIMIAVBAzYCCCAFQQI2AgQgBUEBNgIAIAVBACAFEDQ3A0gMAwsgBSAFKAIoIAUoAjwgBSkDMBBDNwNIDAILIAUoAigQvwEgBUIANwNIDAELIAUoAihBEkEAEBQgBUJ/NwNICyAFKQNIIQMgBUHQAGokACADC+4CAQF/IwBBIGsiBSQAIAUgADYCGCAFIAE2AhQgBSACOwESIAUgAzYCDCAFIAQ2AggCQAJAAkAgBSgCCEUNACAFKAIURQ0AIAUvARJBAUYNAQsgBSgCGEEIakESQQAQFCAFQQA2AhwMAQsgBSgCDEEBcQRAIAUoAhhBCGpBGEEAEBQgBUEANgIcDAELIAVBGBAYIgA2AgQgAEUEQCAFKAIYQQhqQQ5BABAUIAVBADYCHAwBCyMAQRBrIgAgBSgCBDYCDCAAKAIMQQA2AgAgACgCDEEANgIEIAAoAgxBADYCCCAFKAIEQfis0ZEBNgIMIAUoAgRBic+VmgI2AhAgBSgCBEGQ8dmiAzYCFCAFKAIEQQAgBSgCCCAFKAIIEC6tQQEQfCAFIAUoAhggBSgCFEEDIAUoAgQQYSIANgIAIABFBEAgBSgCBBC/ASAFQQA2AhwMAQsgBSAFKAIANgIcCyAFKAIcIQAgBUEgaiQAIAALBwAgACgCIAu9GAECfyMAQfAAayIEJAAgBCAANgJkIAQgATYCYCAEIAI3A1ggBCADNgJUIAQgBCgCZDYCUAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkAgBCgCVA4UBgcCDAQFCg8AAwkRCxAOCBIBEg0SC0EAQgBBACAEKAJQEEwhACAEKAJQIAA2AhQgAEUEQCAEQn83A2gMEwsgBCgCUCgCFEIANwM4IAQoAlAoAhRCADcDQCAEQgA3A2gMEgsgBCgCUCgCECEBIAQpA1ghAiAEKAJQIQMjAEFAaiIAJAAgACABNgI4IAAgAjcDMCAAIAM2AiwCQCAAKQMwUARAIABBAEIAQQEgACgCLBBMNgI8DAELIAApAzAgACgCOCkDMFYEQCAAKAIsQRJBABAUIABBADYCPAwBCyAAKAI4KAIoBEAgACgCLEEdQQAQFCAAQQA2AjwMAQsgACAAKAI4IAApAzAQwAE3AyAgACAAKQMwIAAoAjgoAgQgACkDIKdBA3RqKQMAfTcDGCAAKQMYUARAIAAgACkDIEIBfTcDICAAIAAoAjgoAgAgACkDIKdBBHRqKQMINwMYCyAAIAAoAjgoAgAgACkDIKdBBHRqKQMIIAApAxh9NwMQIAApAxAgACkDMFYEQCAAKAIsQRxBABAUIABBADYCPAwBCyAAIAAoAjgoAgAgACkDIEIBfEEAIAAoAiwQTCIBNgIMIAFFBEAgAEEANgI8DAELIAAoAgwoAgAgACgCDCkDCEIBfadBBHRqIAApAxg3AwggACgCDCgCBCAAKAIMKQMIp0EDdGogACkDMDcDACAAKAIMIAApAzA3AzAgACgCDAJ+IAAoAjgpAxggACgCDCkDCEIBfVQEQCAAKAI4KQMYDAELIAAoAgwpAwhCAX0LNwMYIAAoAjggACgCDDYCKCAAKAIMIAAoAjg2AiggACgCOCAAKAIMKQMINwMgIAAoAgwgACkDIEIBfDcDICAAIAAoAgw2AjwLIAAoAjwhASAAQUBrJAAgASEAIAQoAlAgADYCFCAARQRAIARCfzcDaAwSCyAEKAJQKAIUIAQpA1g3AzggBCgCUCgCFCAEKAJQKAIUKQMINwNAIARCADcDaAwRCyAEQgA3A2gMEAsgBCgCUCgCEBAyIAQoAlAgBCgCUCgCFDYCECAEKAJQQQA2AhQgBEIANwNoDA8LIAQgBCgCUCAEKAJgIAQpA1gQQzcDaAwOCyAEKAJQKAIQEDIgBCgCUCgCFBAyIAQoAlAQFSAEQgA3A2gMDQsgBCgCUCgCEEIANwM4IAQoAlAoAhBCADcDQCAEQgA3A2gMDAsgBCkDWEL///////////8AVgRAIAQoAlBBEkEAEBQgBEJ/NwNoDAwLIAQoAlAoAhAhASAEKAJgIQMgBCkDWCECIwBBQGoiACQAIAAgATYCNCAAIAM2AjAgACACNwMoIAACfiAAKQMoIAAoAjQpAzAgACgCNCkDOH1UBEAgACkDKAwBCyAAKAI0KQMwIAAoAjQpAzh9CzcDKAJAIAApAyhQBEAgAEIANwM4DAELIAApAyhC////////////AFYEQCAAQn83AzgMAQsgACAAKAI0KQNANwMYIAAgACgCNCkDOCAAKAI0KAIEIAApAxinQQN0aikDAH03AxAgAEIANwMgA0AgACkDICAAKQMoVARAIAACfiAAKQMoIAApAyB9IAAoAjQoAgAgACkDGKdBBHRqKQMIIAApAxB9VARAIAApAyggACkDIH0MAQsgACgCNCgCACAAKQMYp0EEdGopAwggACkDEH0LNwMIIAAoAjAgACkDIKdqIAAoAjQoAgAgACkDGKdBBHRqKAIAIAApAxCnaiAAKQMIpxAZGiAAKQMIIAAoAjQoAgAgACkDGKdBBHRqKQMIIAApAxB9UQRAIAAgACkDGEIBfDcDGAsgACAAKQMIIAApAyB8NwMgIABCADcDEAwBCwsgACgCNCIBIAApAyAgASkDOHw3AzggACgCNCAAKQMYNwNAIAAgACkDIDcDOAsgACkDOCECIABBQGskACAEIAI3A2gMCwsgBEEAQgBBACAEKAJQEEw2AkwgBCgCTEUEQCAEQn83A2gMCwsgBCgCUCgCEBAyIAQoAlAgBCgCTDYCECAEQgA3A2gMCgsgBCgCUCgCFBAyIAQoAlBBADYCFCAEQgA3A2gMCQsgBCAEKAJQKAIQIAQoAmAgBCkDWCAEKAJQEMEBrDcDaAwICyAEIAQoAlAoAhQgBCgCYCAEKQNYIAQoAlAQwQGsNwNoDAcLIAQpA1hCOFQEQCAEKAJQQRJBABAUIARCfzcDaAwHCyAEIAQoAmA2AkggBCgCSBA7IAQoAkggBCgCUCgCDDYCKCAEKAJIIAQoAlAoAhApAzA3AxggBCgCSCAEKAJIKQMYNwMgIAQoAkhBADsBMCAEKAJIQQA7ATIgBCgCSELcATcDACAEQjg3A2gMBgsgBCgCUCAEKAJgKAIANgIMIARCADcDaAwFCyAEQX82AkAgBEETNgI8IARBCzYCOCAEQQ02AjQgBEEMNgIwIARBCjYCLCAEQQ82AiggBEEJNgIkIARBETYCICAEQQg2AhwgBEEHNgIYIARBBjYCFCAEQQU2AhAgBEEENgIMIARBAzYCCCAEQQI2AgQgBEEBNgIAIARBACAEEDQ3A2gMBAsgBCgCUCgCECkDOEL///////////8AVgRAIAQoAlBBHkE9EBQgBEJ/NwNoDAQLIAQgBCgCUCgCECkDODcDaAwDCyAEKAJQKAIUKQM4Qv///////////wBWBEAgBCgCUEEeQT0QFCAEQn83A2gMAwsgBCAEKAJQKAIUKQM4NwNoDAILIAQpA1hC////////////AFYEQCAEKAJQQRJBABAUIARCfzcDaAwCCyAEKAJQKAIUIQEgBCgCYCEDIAQpA1ghAiAEKAJQIQUjAEHgAGsiACQAIAAgATYCVCAAIAM2AlAgACACNwNIIAAgBTYCRAJAIAApA0ggACgCVCkDOCAAKQNIfEL//wN8VgRAIAAoAkRBEkEAEBQgAEJ/NwNYDAELIAAgACgCVCgCBCAAKAJUKQMIp0EDdGopAwA3AyAgACkDICAAKAJUKQM4IAApA0h8VARAIAAgACgCVCkDCCAAKQNIIAApAyAgACgCVCkDOH19Qv//A3xCEIh8NwMYIAApAxggACgCVCkDEFYEQCAAIAAoAlQpAxA3AxAgACkDEFAEQCAAQhA3AxALA0AgACkDECAAKQMYVARAIAAgACkDEEIBhjcDEAwBCwsgACgCVCAAKQMQIAAoAkQQwgFBAXFFBEAgACgCREEOQQAQFCAAQn83A1gMAwsLA0AgACgCVCkDCCAAKQMYVARAQYCABBAYIQEgACgCVCgCACAAKAJUKQMIp0EEdGogATYCACABBEAgACgCVCgCACAAKAJUKQMIp0EEdGpCgIAENwMIIAAoAlQiASABKQMIQgF8NwMIIAAgACkDIEKAgAR8NwMgIAAoAlQoAgQgACgCVCkDCKdBA3RqIAApAyA3AwAMAgUgACgCREEOQQAQFCAAQn83A1gMBAsACwsLIAAgACgCVCkDQDcDMCAAIAAoAlQpAzggACgCVCgCBCAAKQMwp0EDdGopAwB9NwMoIABCADcDOANAIAApAzggACkDSFQEQCAAAn4gACkDSCAAKQM4fSAAKAJUKAIAIAApAzCnQQR0aikDCCAAKQMofVQEQCAAKQNIIAApAzh9DAELIAAoAlQoAgAgACkDMKdBBHRqKQMIIAApAyh9CzcDCCAAKAJUKAIAIAApAzCnQQR0aigCACAAKQMop2ogACgCUCAAKQM4p2ogACkDCKcQGRogACkDCCAAKAJUKAIAIAApAzCnQQR0aikDCCAAKQMofVEEQCAAIAApAzBCAXw3AzALIAAgACkDCCAAKQM4fDcDOCAAQgA3AygMAQsLIAAoAlQiASAAKQM4IAEpAzh8NwM4IAAoAlQgACkDMDcDQCAAKAJUKQM4IAAoAlQpAzBWBEAgACgCVCAAKAJUKQM4NwMwCyAAIAApAzg3A1gLIAApA1ghAiAAQeAAaiQAIAQgAjcDaAwBCyAEKAJQQRxBABAUIARCfzcDaAsgBCkDaCECIARB8ABqJAAgAgsHACAAKAIACxgAQaibAUIANwIAQbCbAUEANgIAQaibAQuGAQIEfwF+IwBBEGsiASQAAkAgACkDMFAEQAwBCwNAAkAgACAFQQAgAUEPaiABQQhqEIoBIgRBf0YNACABLQAPQQNHDQAgAiABKAIIQYCAgIB/cUGAgICAekZqIQILQX8hAyAEQX9GDQEgAiEDIAVCAXwiBSAAKQMwVA0ACwsgAUEQaiQAIAMLC4GNASMAQYAIC4EMaW5zdWZmaWNpZW50IG1lbW9yeQBuZWVkIGRpY3Rpb25hcnkALSsgICAwWDB4AC0wWCswWCAwWC0weCsweCAweABaaXAgYXJjaGl2ZSBpbmNvbnNpc3RlbnQASW52YWxpZCBhcmd1bWVudABpbnZhbGlkIGxpdGVyYWwvbGVuZ3RocyBzZXQAaW52YWxpZCBjb2RlIGxlbmd0aHMgc2V0AHVua25vd24gaGVhZGVyIGZsYWdzIHNldABpbnZhbGlkIGRpc3RhbmNlcyBzZXQAaW52YWxpZCBiaXQgbGVuZ3RoIHJlcGVhdABGaWxlIGFscmVhZHkgZXhpc3RzAHRvbyBtYW55IGxlbmd0aCBvciBkaXN0YW5jZSBzeW1ib2xzAGludmFsaWQgc3RvcmVkIGJsb2NrIGxlbmd0aHMAJXMlcyVzAGJ1ZmZlciBlcnJvcgBObyBlcnJvcgBzdHJlYW0gZXJyb3IAVGVsbCBlcnJvcgBJbnRlcm5hbCBlcnJvcgBTZWVrIGVycm9yAFdyaXRlIGVycm9yAGZpbGUgZXJyb3IAUmVhZCBlcnJvcgBabGliIGVycm9yAGRhdGEgZXJyb3IAQ1JDIGVycm9yAGluY29tcGF0aWJsZSB2ZXJzaW9uAG5hbgAvZGV2L3VyYW5kb20AaW52YWxpZCBjb2RlIC0tIG1pc3NpbmcgZW5kLW9mLWJsb2NrAGluY29ycmVjdCBoZWFkZXIgY2hlY2sAaW5jb3JyZWN0IGxlbmd0aCBjaGVjawBpbmNvcnJlY3QgZGF0YSBjaGVjawBpbnZhbGlkIGRpc3RhbmNlIHRvbyBmYXIgYmFjawBoZWFkZXIgY3JjIG1pc21hdGNoAGluZgBpbnZhbGlkIHdpbmRvdyBzaXplAFJlYWQtb25seSBhcmNoaXZlAE5vdCBhIHppcCBhcmNoaXZlAFJlc291cmNlIHN0aWxsIGluIHVzZQBNYWxsb2MgZmFpbHVyZQBpbnZhbGlkIGJsb2NrIHR5cGUARmFpbHVyZSB0byBjcmVhdGUgdGVtcG9yYXJ5IGZpbGUAQ2FuJ3Qgb3BlbiBmaWxlAE5vIHN1Y2ggZmlsZQBQcmVtYXR1cmUgZW5kIG9mIGZpbGUAQ2FuJ3QgcmVtb3ZlIGZpbGUAaW52YWxpZCBsaXRlcmFsL2xlbmd0aCBjb2RlAGludmFsaWQgZGlzdGFuY2UgY29kZQB1bmtub3duIGNvbXByZXNzaW9uIG1ldGhvZABzdHJlYW0gZW5kAENvbXByZXNzZWQgZGF0YSBpbnZhbGlkAE11bHRpLWRpc2sgemlwIGFyY2hpdmVzIG5vdCBzdXBwb3J0ZWQAT3BlcmF0aW9uIG5vdCBzdXBwb3J0ZWQARW5jcnlwdGlvbiBtZXRob2Qgbm90IHN1cHBvcnRlZABDb21wcmVzc2lvbiBtZXRob2Qgbm90IHN1cHBvcnRlZABFbnRyeSBoYXMgYmVlbiBkZWxldGVkAENvbnRhaW5pbmcgemlwIGFyY2hpdmUgd2FzIGNsb3NlZABDbG9zaW5nIHppcCBhcmNoaXZlIGZhaWxlZABSZW5hbWluZyB0ZW1wb3JhcnkgZmlsZSBmYWlsZWQARW50cnkgaGFzIGJlZW4gY2hhbmdlZABObyBwYXNzd29yZCBwcm92aWRlZABXcm9uZyBwYXNzd29yZCBwcm92aWRlZABVbmtub3duIGVycm9yICVkAHJiAHIrYgByd2EAJXMuWFhYWFhYAE5BTgBJTkYAQUUAMS4yLjExAC9wcm9jL3NlbGYvZmQvAC4AKG51bGwpADogAFBLBgcAUEsGBgBQSwUGAFBLAwQAUEsBAgAAAAAAAFIFAADZBwAArAgAAJEIAACCBQAApAUAAI0FAADFBQAAbwgAADQHAADpBAAAJAcAAAMHAACvBQAA4QYAAMsIAAA3CAAAQQcAAFoEAAC5BgAAcwUAAEEEAABXBwAAWAgAABcIAACnBgAA4ggAAPcIAAD/BwAAywYAAGgFAADBBwAAIABBmBQLEQEAAAABAAAAAQAAAAEAAAABAEG8FAsJAQAAAAEAAAACAEHoFAsBAQBBiBULAQEAQaIVC6REOiY7JmUmZiZjJmAmIiDYJcsl2SVCJkAmaiZrJjwmuiXEJZUhPCC2AKcArCWoIZEhkyGSIZAhHyKUIbIlvCUgACEAIgAjACQAJQAmACcAKAApACoAKwAsAC0ALgAvADAAMQAyADMANAA1ADYANwA4ADkAOgA7ADwAPQA+AD8AQABBAEIAQwBEAEUARgBHAEgASQBKAEsATABNAE4ATwBQAFEAUgBTAFQAVQBWAFcAWABZAFoAWwBcAF0AXgBfAGAAYQBiAGMAZABlAGYAZwBoAGkAagBrAGwAbQBuAG8AcABxAHIAcwB0AHUAdgB3AHgAeQB6AHsAfAB9AH4AAiPHAPwA6QDiAOQA4ADlAOcA6gDrAOgA7wDuAOwAxADFAMkA5gDGAPQA9gDyAPsA+QD/ANYA3ACiAKMApQCnIJIB4QDtAPMA+gDxANEAqgC6AL8AECOsAL0AvAChAKsAuwCRJZIlkyUCJSQlYSViJVYlVSVjJVElVyVdJVwlWyUQJRQlNCUsJRwlACU8JV4lXyVaJVQlaSVmJWAlUCVsJWclaCVkJWUlWSVYJVIlUyVrJWolGCUMJYglhCWMJZAlgCWxA98AkwPAA6MDwwO1AMQDpgOYA6kDtAMeIsYDtQMpImEisQBlImQiICMhI/cASCKwABkitwAaIn8gsgCgJaAAAAAAAJYwB3csYQ7uulEJmRnEbQeP9GpwNaVj6aOVZJ4yiNsOpLjceR7p1eCI2dKXK0y2Cb18sX4HLbjnkR2/kGQQtx3yILBqSHG5895BvoR91Noa6+TdbVG11PTHhdODVphsE8Coa2R6+WL97Mllik9cARTZbAZjYz0P+vUNCI3IIG47XhBpTORBYNVycWei0eQDPEfUBEv9hQ3Sa7UKpfqotTVsmLJC1sm720D5vKzjbNgydVzfRc8N1txZPdGrrDDZJjoA3lGAUdfIFmHQv7X0tCEjxLNWmZW6zw+lvbieuAIoCIgFX7LZDMYk6Quxh3xvLxFMaFirHWHBPS1mtpBB3HYGcdsBvCDSmCoQ1e+JhbFxH7W2BqXkv58z1LjooskHeDT5AA+OqAmWGJgO4bsNan8tPW0Il2xkkQFcY+b0UWtrYmFsHNgwZYVOAGLy7ZUGbHulARvB9AiCV8QP9cbZsGVQ6bcS6ri+i3yIufzfHd1iSS3aFfN804xlTNT7WGGyTc5RtTp0ALyj4jC71EGl30rXldg9bcTRpPv01tNq6WlD/NluNEaIZ63QuGDacy0EROUdAzNfTAqqyXwN3TxxBVCqQQInEBALvoYgDMkltWhXs4VvIAnUZrmf5GHODvneXpjJ2SkimNCwtKjXxxc9s1mBDbQuO1y9t61susAgg7jttrO/mgzitgOa0rF0OUfV6q930p0VJtsEgxbccxILY+OEO2SUPmptDahaanoLzw7knf8JkyeuAAqxngd9RJMP8NKjCIdo8gEe/sIGaV1XYvfLZ2WAcTZsGecGa252G9T+4CvTiVp62hDMSt1nb9+5+fnvvo5DvrcX1Y6wYOij1tZ+k9GhxMLYOFLy30/xZ7vRZ1e8pt0GtT9LNrJI2isN2EwbCq/2SgM2YHoEQcPvYN9V32eo745uMXm+aUaMs2HLGoNmvKDSbyU24mhSlXcMzANHC7u5FgIiLyYFVb47usUoC72yklq0KwRqs1yn/9fCMc/QtYue2Swdrt5bsMJkmybyY+yco2p1CpNtAqkGCZw/Ng7rhWcHchNXAAWCSr+VFHq44q4rsXs4G7YMm47Skg2+1eW379x8Id/bC9TS04ZC4tTx+LPdaG6D2h/NFr6BWya59uF3sG93R7cY5loIiHBqD//KOwZmXAsBEf+eZY9prmL40/9rYUXPbBZ44gqg7tIN11SDBE7CswM5YSZnp/cWYNBNR2lJ23duPkpq0a7cWtbZZgvfQPA72DdTrrypxZ673n/Pskfp/7UwHPK9vYrCusowk7NTpqO0JAU20LqTBtfNKVfeVL9n2SMuemazuEphxAIbaF2UK28qN74LtKGODMMb3wVaje8CLQAAAABBMRsZgmI2MsNTLSsExWxkRfR3fYanWlbHlkFPCIrZyEm7wtGK6O/6y9n04wxPtaxNfq61ji2Dns8cmIdREsJKECPZU9Nw9HiSQe9hVdeuLhTmtTfXtZgcloSDBVmYG4IYqQCb2/otsJrLNqldXXfmHGxs/98/QdSeDlrNoiSEleMVn4wgRrKnYXepvqbh6PHn0PPoJIPew2Wyxdqqrl1d659GRCjMa29p/XB2rmsxOe9aKiAsCQcLbTgcEvM2Rt+yB13GcVRw7TBla/T38yq7tsIxonWRHIk0oAeQ+7yfF7qNhA553qklOO+yPP9583O+SOhqfRvFQTwq3lgFT3nwRH5i6YctT8LGHFTbAYoVlEC7Do2D6COmwtk4vw3FoDhM9Lshj6eWCs6WjRMJAMxcSDHXRYti+m7KU+F3VF27uhVsoKPWP42Ilw6WkVCY194RqczH0vrh7JPL+vVc12JyHeZ5a961VECfhE9ZWBIOFhkjFQ/acDgkm0EjPadr/WXmWuZ8JQnLV2Q40E6jrpEB4p+KGCHMpzNg/bwqr+Ekre7QP7QtgxKfbLIJhqskSMnqFVPQKUZ++2h3ZeL2eT8vt0gkNnQbCR01KhIE8rxTS7ONSFJw3mV5Me9+YP7z5ue/wv3+fJHQ1T2gy8z6NoqDuweRmnhUvLE5ZaeoS5iDOwqpmCLJ+rUJiMuuEE9d718ObPRGzT/ZbYwOwnRDElrzAiNB6sFwbMGAQXfYR9c2lwbmLY7FtQClhIQbvBqKQXFbu1pomOh3Q9nZbFoeTy0VX342DJwtGyfdHAA+EgCYuVMxg6CQYq6L0VO1khbF9N1X9O/ElKfC79WW2fbpvAeuqI0ct2veMZwq7yqF7XlryqxIcNNvG134LipG4eE23magB8V/Y1ToVCJl803l87ICpMKpG2eRhDAmoJ8puK7F5Pmf3v06zPPWe/3oz7xrqYD9WrKZPgmfsn84hKuwJBws8RUHNTJGKh5zdzEHtOFwSPXQa1E2g0Z6d7JdY07X+ssP5uHSzLXM+Y2E1+BKEpavCyONtshwoJ2JQbuERl0jAwdsOBrEPxUxhQ4OKEKYT2cDqVR+wPp5VYHLYkwfxTiBXvQjmJ2nDrPclhWqGwBU5VoxT/yZYmLX2FN5zhdP4UlWfvpQlS3Xe9QczGITio0tUruWNJHoux/Q2aAG7PN+Xq3CZUdukUhsL6BTdeg2EjqpBwkjalQkCCtlPxHkeaeWpUi8j2YbkaQnKoq94LzL8qGN0Oti3v3AI+/m2b3hvBT80KcNP4OKJn6ykT+5JNBw+BXLaTtG5kJ6d/1btWtl3PRafsU3CVPudjhI97GuCbjwnxKhM8w/inL9JJMAAAAAN2rCAW7UhANZvkYC3KgJB+vCywayfI0EhRZPBbhREw6PO9EP1oWXDeHvVQxk+RoJU5PYCAotngo9R1wLcKMmHEfJ5B0ed6IfKR1gHqwLLxubYe0awt+rGPW1aRnI8jUS/5j3E6YmsRGRTHMQFFo8FSMw/hR6jrgWTeR6F+BGTTjXLI85jpLJO7n4Czo87kQ/C4SGPlI6wDxlUAI9WBdeNm99nDc2w9o1AakYNIS/VzGz1ZUw6mvTMt0BETOQ5Wskp4+pJf4x7yfJWy0mTE1iI3snoCIimeYgFfMkISi0eCof3rorRmD8KXEKPij0HHEtw3azLJrI9S6tojcvwI2acPfnWHGuWR5zmTPcchwlk3crT1F2cvEXdEWb1XV43Il+T7ZLfxYIDX0hYs98pHSAeZMeQnjKoAR6/crGe7AuvGyHRH5t3vo4b+mQ+m5shrVrW+x3agJSMWg1OPNpCH+vYj8VbWNmqythUcHpYNTXpmXjvWRkugMiZo1p4Gcgy9dIF6EVSU4fU0t5dZFK/GPeT8sJHE6St1pMpd2YTZiaxEav8AZH9k5ARcEkgkREMs1Bc1gPQCrmSUIdjItDUGjxVGcCM1U+vHVXCda3VozA+FO7qjpS4hR8UNV+vlHoOeJa31MgW4btZlmxh6RYNJHrXQP7KVxaRW9ebS+tX4AbNeG3cffg7s+x4tmlc+Ncszzma9n+5zJnuOUFDXrkOEom7w8g5O5WnqLsYfRg7eTiL+jTiO3pijar671caerwuBP9x9LR/J5sl/6pBlX/LBAa+ht62PtCxJ75da5c+EjpAPN/g8LyJj2E8BFXRvGUQQn0oyvL9fqVjffN/0/2YF142Vc3utgOifzaOeM+27z1cd6Ln7Pf0iH13eVLN9zYDGvX72ap1rbY79SBsi3VBKRi0DPOoNFqcObTXRok0hD+XsUnlJzEfiraxklAGMfMVlfC+zyVw6KC08GV6BHAqK9Ny5/Fj8rGe8nI8RELyXQHRMxDbYbNGtPAzy25As5Alq+Rd/xtkC5CK5IZKOmTnD6mlqtUZJfy6iKVxYDglPjHvJ/PrX6elhM4nKF5+p0kb7WYEwV3mUq7MZt90fOaMDWJjQdfS4xe4Q2OaYvPj+ydgIrb90KLgkkEibUjxoiIZJqDvw5YguawHoDR2tyBVMyThGOmUYU6GBeHDXLVhqDQ4qmXuiCozgRmqvlupKt8eOuuSxIprxKsb60lxq2sGIHxpy/rM6Z2VXWkQT+3pcQp+KDzQzqhqv18o52XvqLQc8S15xkGtL6nQLaJzYK3DNvNsjuxD7NiD0mxVWWLsGgi17tfSBW6BvZTuDGckbm0it68g+AcvdpeWr/tNJi+AAAAAGVnvLiLyAmq7q+1EleXYo8y8N433F9rJbk4153vKLTFik8IfWTgvW8BhwHXuL/WSt3YavIzd9/gVhBjWJ9XGVD6MKXoFJ8Q+nH4rELIwHvfrafHZ0MIcnUmb87NcH+tlRUYES37t6Q/ntAYhyfozxpCj3OirCDGsMlHegg+rzKgW8iOGLVnOwrQAIeyaThQLwxf7Jfi8FmFh5flPdGHhmW04DrdWk+Pzz8oM3eGEOTq43dYUg3Y7UBov1H4ofgr8MSfl0gqMCJaT1ee4vZvSX+TCPXHfadA1RjA/G1O0J81K7cjjcUYlp+gfyonGUf9unwgQQKSj/QQ9+hIqD1YFJtYP6gjtpAdMdP3oYlqz3YUD6jKrOEHf76EYMMG0nCgXrcXHOZZuKn0PN8VTIXnwtHggH5pDi/Le2tId8OiDw3Lx2ixcynHBGFMoLjZ9ZhvRJD/0/x+UGbuGzfaVk0nuQ4oQAW2xu+wpKOIDBwasNuBf9dnOZF40iv0H26TA/cmO2aQmoOIPy+R7ViTKVRgRLQxB/gM36hNHrrP8abs35L+ibguRmcXm1QCcCfsu0jwcd4vTMkwgPnbVedFY5ygP2v5x4PTF2g2wXIPinnLN13krlDhXED/VE4lmOj2c4iLrhbvNxb4QIIEnSc+vCQf6SFBeFWZr9fgi8qwXDM7tlntXtHlVbB+UEfVGez/bCE7YglGh9rn6TLIgo6OcNSe7Six+VGQX1bkgjoxWDqDCY+n5m4zHwjBhg1tpjq1pOFAvcGG/AUvKUkXSk71r/N2IjKWEZ6KeL4rmB3ZlyBLyfR4Lq5IwMAB/dKlZkFqHF6W93k5Kk+Xlp9d8vEj5QUZa01gftf1jtFi5+u23l9SjgnCN+m1etlGAGi8IbzQ6jHfiI9WYzBh+dYiBJ5qmr2mvQfYwQG/Nm60rVMJCBWaTnId/ynOpRGGe7d04ccPzdkQkqi+rCpGERk4I3algHVmxtgQAXpg/q7PcpvJc8oi8aRXR5YY76k5rf3MXhFFBu5NdmOJ8c6NJkTc6EH4ZFF5L/k0HpNB2rEmU7/WmuvpxvmzjKFFC2IO8BkHaUyhvlGbPNs2J4Q1mZKWUP4uLpm5VCb83uieEnFdjHcW4TTOLjapq0mKEUXmPwMggYO7dpHg4xP2XFv9WelJmD5V8SEGgmxEYT7Uqs6Lxs+pN344QX/WXSbDbrOJdnzW7srEb9YdWQqxoeHkHhTzgXmoS9dpyxOyDnerXKHCuTnGfgGA/qmc5ZkVJAs2oDZuURyOpxZmhsJx2j4s3m8sSbnTlPCBBAmV5rixe0kNox4usRtIPtJDLVlu+8P22+mmkWdRH6mwzHrODHSUYblm8QYF3gAAAAB3BzCW7g5hLJkJUboHbcQZcGr0j+ljpTWeZJWjDtuIMnncuKTg1ekel9LZiAm2TCt+sXy957gtB5C/HZEdtxBkarAg8vO5cUiEvkHeGtrUfW3d5Ov01LVRg9OFxxNsmFZka6jA/WL5eoplyewUAVxPYwZs2foPPWONCA31O24gyExpEF7VYEHkomdxcjwD5NFLBNRH0g2F/aUKtWs1taj6QrKYbNu7ydasvPlAMths40XfXHXc1g3Pq9E9WSbZMKxR3gA6yNdRgL/QYRYhtPS1VrPEI8+6lZm4vaUPKAK4nl8FiAjGDNmysQvpJC9vfIdYaEwRwWEdq7ZmLT123EGQAdtxBpjSILzv1RAqcbGFiQa2tR+fv+Sl6LjUM3gHyaIPAPk0lgmojuEOmBh/ag27CG09LZFkbJfmY1wBa2tR9BxsYWKFZTDY8mIATmwGle0bAaV7ggj0wfUPxFdlsNnGErfpUIu+uOr8uYh8Yt0d3xXaLUmM03zz+9RMZU2yYVg6tVHOo7wAdNS7MOJK36VBPdiV16TRxG3T1vT7Q2npajRu2fytZ4hG2mC40EQELXMzAx3lqgpMX90NfMlQBXE8JwJBqr4LEBDJDCCGV2i1JSBvhbO5ZtQJzmHkn17e+Q4p2cmYsNCYIsfXqLRZsz0XLrQNgbe9XDvAumyt7biDIJq/s7YDtuIMdLHSmurVRzmd0nevBNsmFXPcFoPjYwsSlGQ7hA1taj56alqo5A7PC5MJ/50KAK4nfQeesfAPk0SHCKPSHgHyaGkGwv73YlddgGVnyxlsNnFuawbn/tQbdonTK+AQ2npaZ91KzPm532+Ovu/5F7e+Q2CwjtXW1qPoodGTfjjYwsRP3/JS0btn8aa8V2c/tQbdSLI2S9gNK9qvChtMNgNK9kEEemDfYO/DqGffVTFuju9Gab55y2GzjLxmgxolb9KgUmjiNswMd5W7C0cDIgIWuVUFJi/Fuju+sr0LKCu0WpJcs2oEwtf/p7XQzzEs2Z6LW96uHZtkwrDsY/ImdWqjnAJtkwqcCQap6w42P3IHZ4UFAFcTlb9KguK4ehR7sSuuDLYbOJLSjpvl1b4NfNzvtwvb3yGG09LU8dTiQmjds/gf2oNugb4Wzfa5JltvsHfhGLdHd4gIWub/D2pwZgY7yhEBC1yPZZ7/+GKuaWFr/9MWbM9FoArieNcN0u5OBINUOQOzwqdnJmHQYBb3SWlHTT5ud9uu0WpK2dZa3EDfC2Y32DvwqbyuU967nsVHss9/MLX/6b298hzKusKKU7OTMCS0o6a60DYFzdcGk1TeVykj2We/s2Z6LsRhSrhdaBsCKm8rlLQLvjfDDI6hWgXfGy0C740AAAAAGRsxQTI2YoIrLVPDZGzFBH139EVWWqeGT0GWx8jZigjRwrtJ+u/oiuP02custU8Mta5+TZ6DLY6HmBzPSsISUVPZIxB49HDTYe9Bki6u11U3teYUHJi11wWDhJaCG5hZmwCpGLAt+tupNsua5nddXf9sbBzUQT/fzVoOnpWEJKKMnxXjp7JGIL6pd2Hx6OGm6PPQ58PegyTaxbJlXV2uqkRGn+tva8wodnD9aTkxa64gKlrvCwcJLBIcOG3fRjbzxl0Hsu1wVHH0a2Uwuyrz96IxwraJHJF1kAegNBefvPsOhI26JaneeTyy7zhz83n/auhIvkHFG31Y3io88HlPBelifkTCTy2H21QcxpQVigGNDrtApiPog7842cI4oMUNIbv0TAqWp48TjZbOXMwACUXXMUhu+mKLd+FTyrq7XVSjoGwViI0/1pGWDpfe15hQx8ypEezh+tL1+suTcmLXXGt55h1AVLXeWU+EnxYOElgPFSMZJDhw2j0jQZtl/WunfOZa5lfLCSVO0DhkAZGuoxiKn+Izp8whKrz9YK0k4a+0P9DunxKDLYYJsmzJSCSr0FMV6vt+RiniZXdoLz959jYkSLcdCRt0BBIqNUtTvPJSSI2zeWXecGB+7zHn5vP+/v3Cv9XQkXzMy6A9g4o2+pqRB7uxvFR4qKdlOTuDmEsimKkKCbX6yRCuy4hf711PRvRsDm3ZP810wg6M81oSQ+pBIwLBbHDB2HdBgJc210eOLeYGpQC1xbwbhIRxQYoaaFq7W0N36JhabNnZFS1PHgw2fl8nGy2cPgAc3bmYABKggzFTi65ikJK1U9Hd9MUWxO/0V+/Cp5T22ZbVrge86bccjaicMd5rhSrvKspree3TcEis+F0bb+FGKi5m3jbhf8UHoFToVGNN82UiArLz5RupwqQwhJFnKZ+gJuTFrrj93p/51vPMOs/o/XuAqWu8mbJa/bKfCT6rhDh/LBwksDUHFfEeKkYyBzF3c0hw4bRRa9D1ekaDNmNdsnfL+tdO0uHmD/nMtczg14SNr5YSSraNIwudoHDIhLtBiQMjXUYaOGwHMRU/xCgODoVnT5hCflSpA1V5+sBMYsuBgTjFH5gj9F6zDqedqhWW3OVUABv8TzFa12Jimc55U9hJ4U8XUPp+VnvXLZVizBzULY2KEzSWu1Ifu+iRBqDZ0F5+8+xHZcKtbEiRbnVToC86EjboIwkHqQgkVGoRP2Urlqd55I+8SKWkkRtmvYoqJ/LLvODr0I2hwP3eYtnm7yMUvOG9DafQ/CaKgz8/kbJ+cNAkuWnLFfhC5kY7W/13etxla7XFflr07lMJN/dIOHa4Ca6xoRKf8Io/zDOTJP1yAAAAAAHCajcDhNRuAka+WQcJqNwGy8LrBI18sgVPFoUOE1G4D9E7jw2XhdYMVe/hCRr5ZAjYk1MKni0KC1xHPRwmo3Ad5MlHH6J3Hh5gHSkbLwusGu1hmxir38IZabX1EjXyyBP3mP8RsSamEHNMkRU8WhQU/jAjFriOehd65E04TUbgOY8s1zvJko46C/i5P0TuPD6GhAs8wDpSPQJQZTZeF1g3nH1vNdrDNjQYqQExV7+EMJXVszLTa+ozEQHdJGvlkCWpj6cn7zH+Ji1bySNiTUwioCd7IOaZIiEk8xUqeLQoK7reHyn8YEYoPgpxLXEc9CyzdsMu9ciaLzeirXCajcBxWOf3cx5ZrnLcM5l3kyUcdlFPK3QX8XJ11ZtFfonceH9Ltk99DQgWfM9iIXmAdKR4Qh6TegSgynvGyv1svC6wbX5Eh284+t5u+pDpa7WGbGp37FtoMVICafM4NWKvfwhjbRU/YSurZmDpwVFlptfUZGS942YiA7pn4GmNSNfLIEkVoRdLUx9OSpF1eU/eY/xOHAnLTFq3kk2Y3aVGxJqYRwbwr0VATvZEgiTBQc0yREAPWHNCSeYqQ4uMHVTxaFBVMwJnV3W8Pla31glT+MCMUjqqu1B8FOJRvn7VWuI56FsgU99ZZu2GWKSHsV3rkTRcKfsDXm9FWl+tL23hNRuA4Pdxt+Kxz+7jc6XZ5jyzXOf+2WvluGcy5HoNBe8mSjju5CAP7KKeVu1g9GHoL+Lk6e2I0+urNorqaVy9/RO48PzR0sf+l2ye/1UGqfoaECz72Hob+Z7EQvhcrnXzAOlI8sKDf/CEPSbxRlcR9AlBlPXLK6P3jZX69k//zdl4XWDYujdX2vyJDts+4znecfW837Ofi931IdLcN0vl12sM2NapZu/U79i21S2ygdBipATRoM4z0+ZwatIkGl3FXv4QxJyUJ8baKn7HGEBJwldWzMOVPPvB04KiwBHolctNr6jKj8WfyMl7xskLEfHMRAd0zYZtQ8/A0xrOArktka+WQJBt/HeSK0Iuk+koGZamPpyXZFSrlSLq8pTggMWfvMf4nn6tz5w4E5ad+nmhmLVvJJl3BRObMbtKmvPRfY2JNTCMS18Hjg3hXo/Pi2mKgJ3si0L324kESYKIxiO1g5pkiIJYDr+AHrDmgdza0YSTzFSFUaZjhxcYOobVcg2p4tCgqCC6l6pmBM6rpG75rut4fK8pEkutb6wSrK3GJafxgRimM+svpHVVdqW3P0Gg+CnEoTpD86N8/aqivpedtcRz0LQGGee2QKe+t4LNibLN2wyzD7E7sUkPYrCLZVW71yJouhVIX7hT9ga5kZwxvN6KtL0c4IO/Wl7avpg07QAAAAC4vGdlqgnIixK1r+6PYpdXN97wMiVrX9yd1zi5xbQo730IT4pvveBk1wGHAUrWv7jyatjd4N93M1hjEFZQGVef6KUw+voQnxRCrPhx33vAyGfHp611cghDzc5vJpWtf3AtERgVP6S3+4cY0J4az+gnonOPQrDGIKwIekfJoDKvPhiOyFsKO2e1socA0C9QOGmX7F8MhVnw4j3ll4dlhofR3TrgtM+PT1p3Myg/6uQQhlJYd+NA7dgN+FG/aPAr+KFIl5/EWiIwKuKeV09/SW/2x/UIk9VAp31t/MAYNZ/QTo0jtyuflhjFJyp/oLr9RxkCQSB8EPSPkqhI6PebFFg9I6g/WDEdkLaJoffTFHbPaqzKqA++fwfhBsNghF6gcNLmHBe39Km4WUwV3zzRwueFaX6A4HvLLw7Dd0hryw0PonOxaMdhBMcp2bigTERvmPX80/+Q7mZQflbaNxsOuSdNtgVAKKSw78YcDIijgduwGjln138r0niRk24f9Dsm9wODmpBmkS8/iCmTWO20RGBUDPgHMR5NqN+m8c+6/pLf7EYuuIlUmxdn7CdwAnHwSLvJTC/e2/mAMGNF51VrP6Cc04PH+cE2aBd5ig9y5F03y1zhUK5OVP9A9uiYJa6LiHMWN+8WBIJA+Lw+J50h6R8kmVV4QYvg168zXLDK7Vm2O1Xl0V5HUH6w/+wZ1WI7IWzah0YJyDLp53COjoIo7Z7UkFH5sYLkVl86WDE6p48Jgx8zbuYNhsEItTqmbb1A4aQF/IbBF0kpL6/1TkoyInbzip4Rlpgrvnggl9kdePTJS8BIri7S/QHAakFmpfeWXhxPKjl5XZ+Wl+Uj8fJNaxkF9dd+YOdi0Y5f3rbrwgmOUnq16TdoAEbZ0LwhvIjfMeowY1aPItb5YZpqngQHvaa9vwHB2K20bjYVCAlTHXJOmqXOKf+3e4YRD8fhdJIQ2c0qrL6oOBkRRoCldiPYxmZ1YHoBEHLPrv7Kc8mbV6TxIu8Ylkf9rTmpRRFezHZN7gbO8Ylj3EQmjWT4Qej5L3lRQZMeNFMmsdrrmta/s/nG6QtFoYwZ8A5ioUxpBzybUb6EJzbblpKZNS4u/lAmVLmZnuje/IxdcRI04RZ3qTYuzhGKSasDP+ZFu4OBIOPgkXZbXPYTSelZ/fFVPphsggYh1D5hRMaLzqp+N6nP1n9BOG7DJl18domzxMru1lkd1m/hobEK8xQe5EuoeYETy2nXq3cOsrnCoVwBfsY5nKn+gCQVmeU2oDYLjhxRboZmFqc+2nHCLG/eLJTTuUkJBIHwsbjmlaMNSXsbsS4eQ9I+SPtuWS3p2/bDUWeRpsywqR90DM56ZrlhlN4FBvEUBAAAtgcAAHoJAACZBQAAWwUAALoFAAAABAAARQUAAM8FAAB6CQBB0dkAC7YQAQIDBAQFBQYGBgYHBwcHCAgICAgICAgJCQkJCQkJCQoKCgoKCgoKCgoKCgoKCgoLCwsLCwsLCwsLCwsLCwsLDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwNDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PAAAQERISExMUFBQUFRUVFRYWFhYWFhYWFxcXFxcXFxcYGBgYGBgYGBgYGBgYGBgYGRkZGRkZGRkZGRkZGRkZGRoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxscHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHQABAgMEBQYHCAgJCQoKCwsMDAwMDQ0NDQ4ODg4PDw8PEBAQEBAQEBARERERERERERISEhISEhISExMTExMTExMUFBQUFBQUFBQUFBQUFBQUFRUVFRUVFRUVFRUVFRUVFRYWFhYWFhYWFhYWFhYWFhYXFxcXFxcXFxcXFxcXFxcXGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxwQMAAAEDUAAAEBAAAeAQAADwAAAJA0AACQNQAAAAAAAB4AAAAPAAAAAAAAABA2AAAAAAAAEwAAAAcAAAAAAAAADAAIAIwACABMAAgAzAAIACwACACsAAgAbAAIAOwACAAcAAgAnAAIAFwACADcAAgAPAAIALwACAB8AAgA/AAIAAIACACCAAgAQgAIAMIACAAiAAgAogAIAGIACADiAAgAEgAIAJIACABSAAgA0gAIADIACACyAAgAcgAIAPIACAAKAAgAigAIAEoACADKAAgAKgAIAKoACABqAAgA6gAIABoACACaAAgAWgAIANoACAA6AAgAugAIAHoACAD6AAgABgAIAIYACABGAAgAxgAIACYACACmAAgAZgAIAOYACAAWAAgAlgAIAFYACADWAAgANgAIALYACAB2AAgA9gAIAA4ACACOAAgATgAIAM4ACAAuAAgArgAIAG4ACADuAAgAHgAIAJ4ACABeAAgA3gAIAD4ACAC+AAgAfgAIAP4ACAABAAgAgQAIAEEACADBAAgAIQAIAKEACABhAAgA4QAIABEACACRAAgAUQAIANEACAAxAAgAsQAIAHEACADxAAgACQAIAIkACABJAAgAyQAIACkACACpAAgAaQAIAOkACAAZAAgAmQAIAFkACADZAAgAOQAIALkACAB5AAgA+QAIAAUACACFAAgARQAIAMUACAAlAAgApQAIAGUACADlAAgAFQAIAJUACABVAAgA1QAIADUACAC1AAgAdQAIAPUACAANAAgAjQAIAE0ACADNAAgALQAIAK0ACABtAAgA7QAIAB0ACACdAAgAXQAIAN0ACAA9AAgAvQAIAH0ACAD9AAgAEwAJABMBCQCTAAkAkwEJAFMACQBTAQkA0wAJANMBCQAzAAkAMwEJALMACQCzAQkAcwAJAHMBCQDzAAkA8wEJAAsACQALAQkAiwAJAIsBCQBLAAkASwEJAMsACQDLAQkAKwAJACsBCQCrAAkAqwEJAGsACQBrAQkA6wAJAOsBCQAbAAkAGwEJAJsACQCbAQkAWwAJAFsBCQDbAAkA2wEJADsACQA7AQkAuwAJALsBCQB7AAkAewEJAPsACQD7AQkABwAJAAcBCQCHAAkAhwEJAEcACQBHAQkAxwAJAMcBCQAnAAkAJwEJAKcACQCnAQkAZwAJAGcBCQDnAAkA5wEJABcACQAXAQkAlwAJAJcBCQBXAAkAVwEJANcACQDXAQkANwAJADcBCQC3AAkAtwEJAHcACQB3AQkA9wAJAPcBCQAPAAkADwEJAI8ACQCPAQkATwAJAE8BCQDPAAkAzwEJAC8ACQAvAQkArwAJAK8BCQBvAAkAbwEJAO8ACQDvAQkAHwAJAB8BCQCfAAkAnwEJAF8ACQBfAQkA3wAJAN8BCQA/AAkAPwEJAL8ACQC/AQkAfwAJAH8BCQD/AAkA/wEJAAAABwBAAAcAIAAHAGAABwAQAAcAUAAHADAABwBwAAcACAAHAEgABwAoAAcAaAAHABgABwBYAAcAOAAHAHgABwAEAAcARAAHACQABwBkAAcAFAAHAFQABwA0AAcAdAAHAAMACACDAAgAQwAIAMMACAAjAAgAowAIAGMACADjAAgAAAAFABAABQAIAAUAGAAFAAQABQAUAAUADAAFABwABQACAAUAEgAFAAoABQAaAAUABgAFABYABQAOAAUAHgAFAAEABQARAAUACQAFABkABQAFAAUAFQAFAA0ABQAdAAUAAwAFABMABQALAAUAGwAFAAcABQAXAAUAQbDqAAtNAQAAAAEAAAABAAAAAQAAAAIAAAACAAAAAgAAAAIAAAADAAAAAwAAAAMAAAADAAAABAAAAAQAAAAEAAAABAAAAAUAAAAFAAAABQAAAAUAQaDrAAtlAQAAAAEAAAACAAAAAgAAAAMAAAADAAAABAAAAAQAAAAFAAAABQAAAAYAAAAGAAAABwAAAAcAAAAIAAAACAAAAAkAAAAJAAAACgAAAAoAAAALAAAACwAAAAwAAAAMAAAADQAAAA0AQdDsAAsjAgAAAAMAAAAHAAAAAAAAABAREgAIBwkGCgULBAwDDQIOAQ8AQYTtAAtpAQAAAAIAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAAKAAAADAAAAA4AAAAQAAAAFAAAABgAAAAcAAAAIAAAACgAAAAwAAAAOAAAAEAAAABQAAAAYAAAAHAAAACAAAAAoAAAAMAAAADgAEGE7gALegEAAAACAAAAAwAAAAQAAAAGAAAACAAAAAwAAAAQAAAAGAAAACAAAAAwAAAAQAAAAGAAAACAAAAAwAAAAAABAACAAQAAAAIAAAADAAAABAAAAAYAAAAIAAAADAAAABAAAAAYAAAAIAAAADAAAABAAAAAYAAAMS4yLjExAEGI7wALbQcAAAAEAAQACAAEAAgAAAAEAAUAEAAIAAgAAAAEAAYAIAAgAAgAAAAEAAQAEAAQAAkAAAAIABAAIAAgAAkAAAAIABAAgACAAAkAAAAIACAAgAAAAQkAAAAgAIAAAgEABAkAAAAgAAIBAgEAEAkAQYDwAAulAgMABAAFAAYABwAIAAkACgALAA0ADwARABMAFwAbAB8AIwArADMAOwBDAFMAYwBzAIMAowDDAOMAAgEAAAAAAAAQABAAEAAQABAAEAAQABAAEQARABEAEQASABIAEgASABMAEwATABMAFAAUABQAFAAVABUAFQAVABAATQDKAAAAAQACAAMABAAFAAcACQANABEAGQAhADEAQQBhAIEAwQABAYEBAQIBAwEEAQYBCAEMARABGAEgATABQAFgAAAAABAAEAAQABAAEQARABIAEgATABMAFAAUABUAFQAWABYAFwAXABgAGAAZABkAGgAaABsAGwAcABwAHQAdAEAAQAAQABEAEgAAAAgABwAJAAYACgAFAAsABAAMAAMADQACAA4AAQAPAEGw8gALwRFgBwAAAAhQAAAIEAAUCHMAEgcfAAAIcAAACDAAAAnAABAHCgAACGAAAAggAAAJoAAACAAAAAiAAAAIQAAACeAAEAcGAAAIWAAACBgAAAmQABMHOwAACHgAAAg4AAAJ0AARBxEAAAhoAAAIKAAACbAAAAgIAAAIiAAACEgAAAnwABAHBAAACFQAAAgUABUI4wATBysAAAh0AAAINAAACcgAEQcNAAAIZAAACCQAAAmoAAAIBAAACIQAAAhEAAAJ6AAQBwgAAAhcAAAIHAAACZgAFAdTAAAIfAAACDwAAAnYABIHFwAACGwAAAgsAAAJuAAACAwAAAiMAAAITAAACfgAEAcDAAAIUgAACBIAFQijABMHIwAACHIAAAgyAAAJxAARBwsAAAhiAAAIIgAACaQAAAgCAAAIggAACEIAAAnkABAHBwAACFoAAAgaAAAJlAAUB0MAAAh6AAAIOgAACdQAEgcTAAAIagAACCoAAAm0AAAICgAACIoAAAhKAAAJ9AAQBwUAAAhWAAAIFgBACAAAEwczAAAIdgAACDYAAAnMABEHDwAACGYAAAgmAAAJrAAACAYAAAiGAAAIRgAACewAEAcJAAAIXgAACB4AAAmcABQHYwAACH4AAAg+AAAJ3AASBxsAAAhuAAAILgAACbwAAAgOAAAIjgAACE4AAAn8AGAHAAAACFEAAAgRABUIgwASBx8AAAhxAAAIMQAACcIAEAcKAAAIYQAACCEAAAmiAAAIAQAACIEAAAhBAAAJ4gAQBwYAAAhZAAAIGQAACZIAEwc7AAAIeQAACDkAAAnSABEHEQAACGkAAAgpAAAJsgAACAkAAAiJAAAISQAACfIAEAcEAAAIVQAACBUAEAgCARMHKwAACHUAAAg1AAAJygARBw0AAAhlAAAIJQAACaoAAAgFAAAIhQAACEUAAAnqABAHCAAACF0AAAgdAAAJmgAUB1MAAAh9AAAIPQAACdoAEgcXAAAIbQAACC0AAAm6AAAIDQAACI0AAAhNAAAJ+gAQBwMAAAhTAAAIEwAVCMMAEwcjAAAIcwAACDMAAAnGABEHCwAACGMAAAgjAAAJpgAACAMAAAiDAAAIQwAACeYAEAcHAAAIWwAACBsAAAmWABQHQwAACHsAAAg7AAAJ1gASBxMAAAhrAAAIKwAACbYAAAgLAAAIiwAACEsAAAn2ABAHBQAACFcAAAgXAEAIAAATBzMAAAh3AAAINwAACc4AEQcPAAAIZwAACCcAAAmuAAAIBwAACIcAAAhHAAAJ7gAQBwkAAAhfAAAIHwAACZ4AFAdjAAAIfwAACD8AAAneABIHGwAACG8AAAgvAAAJvgAACA8AAAiPAAAITwAACf4AYAcAAAAIUAAACBAAFAhzABIHHwAACHAAAAgwAAAJwQAQBwoAAAhgAAAIIAAACaEAAAgAAAAIgAAACEAAAAnhABAHBgAACFgAAAgYAAAJkQATBzsAAAh4AAAIOAAACdEAEQcRAAAIaAAACCgAAAmxAAAICAAACIgAAAhIAAAJ8QAQBwQAAAhUAAAIFAAVCOMAEwcrAAAIdAAACDQAAAnJABEHDQAACGQAAAgkAAAJqQAACAQAAAiEAAAIRAAACekAEAcIAAAIXAAACBwAAAmZABQHUwAACHwAAAg8AAAJ2QASBxcAAAhsAAAILAAACbkAAAgMAAAIjAAACEwAAAn5ABAHAwAACFIAAAgSABUIowATByMAAAhyAAAIMgAACcUAEQcLAAAIYgAACCIAAAmlAAAIAgAACIIAAAhCAAAJ5QAQBwcAAAhaAAAIGgAACZUAFAdDAAAIegAACDoAAAnVABIHEwAACGoAAAgqAAAJtQAACAoAAAiKAAAISgAACfUAEAcFAAAIVgAACBYAQAgAABMHMwAACHYAAAg2AAAJzQARBw8AAAhmAAAIJgAACa0AAAgGAAAIhgAACEYAAAntABAHCQAACF4AAAgeAAAJnQAUB2MAAAh+AAAIPgAACd0AEgcbAAAIbgAACC4AAAm9AAAIDgAACI4AAAhOAAAJ/QBgBwAAAAhRAAAIEQAVCIMAEgcfAAAIcQAACDEAAAnDABAHCgAACGEAAAghAAAJowAACAEAAAiBAAAIQQAACeMAEAcGAAAIWQAACBkAAAmTABMHOwAACHkAAAg5AAAJ0wARBxEAAAhpAAAIKQAACbMAAAgJAAAIiQAACEkAAAnzABAHBAAACFUAAAgVABAIAgETBysAAAh1AAAINQAACcsAEQcNAAAIZQAACCUAAAmrAAAIBQAACIUAAAhFAAAJ6wAQBwgAAAhdAAAIHQAACZsAFAdTAAAIfQAACD0AAAnbABIHFwAACG0AAAgtAAAJuwAACA0AAAiNAAAITQAACfsAEAcDAAAIUwAACBMAFQjDABMHIwAACHMAAAgzAAAJxwARBwsAAAhjAAAIIwAACacAAAgDAAAIgwAACEMAAAnnABAHBwAACFsAAAgbAAAJlwAUB0MAAAh7AAAIOwAACdcAEgcTAAAIawAACCsAAAm3AAAICwAACIsAAAhLAAAJ9wAQBwUAAAhXAAAIFwBACAAAEwczAAAIdwAACDcAAAnPABEHDwAACGcAAAgnAAAJrwAACAcAAAiHAAAIRwAACe8AEAcJAAAIXwAACB8AAAmfABQHYwAACH8AAAg/AAAJ3wASBxsAAAhvAAAILwAACb8AAAgPAAAIjwAACE8AAAn/ABAFAQAXBQEBEwURABsFARARBQUAGQUBBBUFQQAdBQFAEAUDABgFAQIUBSEAHAUBIBIFCQAaBQEIFgWBAEAFAAAQBQIAFwWBARMFGQAbBQEYEQUHABkFAQYVBWEAHQUBYBAFBAAYBQEDFAUxABwFATASBQ0AGgUBDBYFwQBABQAAEQAKABEREQAAAAAFAAAAAAAACQAAAAALAAAAAAAAAAARAA8KERERAwoHAAEACQsLAAAJBgsAAAsABhEAAAAREREAQYGEAQshCwAAAAAAAAAAEQAKChEREQAKAAACAAkLAAAACQALAAALAEG7hAELAQwAQceEAQsVDAAAAAAMAAAAAAkMAAAAAAAMAAAMAEH1hAELAQ4AQYGFAQsVDQAAAAQNAAAAAAkOAAAAAAAOAAAOAEGvhQELARAAQbuFAQseDwAAAAAPAAAAAAkQAAAAAAAQAAAQAAASAAAAEhISAEHyhQELDhIAAAASEhIAAAAAAAAJAEGjhgELAQsAQa+GAQsVCgAAAAAKAAAAAAkLAAAAAAALAAALAEHdhgELAQwAQemGAQsnDAAAAAAMAAAAAAkMAAAAAAAMAAAMAAAwMTIzNDU2Nzg5QUJDREVGAEG0hwELARkAQduHAQsF//////8AQaCIAQtXGRJEOwI/LEcUPTMwChsGRktFNw9JDo4XA0AdPGkrNh9KLRwBICUpIQgMFRYiLhA4Pgs0MRhkdHV2L0EJfzkRI0MyQomKiwUEJignDSoeNYwHGkiTE5SVAEGAiQELig5JbGxlZ2FsIGJ5dGUgc2VxdWVuY2UARG9tYWluIGVycm9yAFJlc3VsdCBub3QgcmVwcmVzZW50YWJsZQBOb3QgYSB0dHkAUGVybWlzc2lvbiBkZW5pZWQAT3BlcmF0aW9uIG5vdCBwZXJtaXR0ZWQATm8gc3VjaCBmaWxlIG9yIGRpcmVjdG9yeQBObyBzdWNoIHByb2Nlc3MARmlsZSBleGlzdHMAVmFsdWUgdG9vIGxhcmdlIGZvciBkYXRhIHR5cGUATm8gc3BhY2UgbGVmdCBvbiBkZXZpY2UAT3V0IG9mIG1lbW9yeQBSZXNvdXJjZSBidXN5AEludGVycnVwdGVkIHN5c3RlbSBjYWxsAFJlc291cmNlIHRlbXBvcmFyaWx5IHVuYXZhaWxhYmxlAEludmFsaWQgc2VlawBDcm9zcy1kZXZpY2UgbGluawBSZWFkLW9ubHkgZmlsZSBzeXN0ZW0ARGlyZWN0b3J5IG5vdCBlbXB0eQBDb25uZWN0aW9uIHJlc2V0IGJ5IHBlZXIAT3BlcmF0aW9uIHRpbWVkIG91dABDb25uZWN0aW9uIHJlZnVzZWQASG9zdCBpcyBkb3duAEhvc3QgaXMgdW5yZWFjaGFibGUAQWRkcmVzcyBpbiB1c2UAQnJva2VuIHBpcGUASS9PIGVycm9yAE5vIHN1Y2ggZGV2aWNlIG9yIGFkZHJlc3MAQmxvY2sgZGV2aWNlIHJlcXVpcmVkAE5vIHN1Y2ggZGV2aWNlAE5vdCBhIGRpcmVjdG9yeQBJcyBhIGRpcmVjdG9yeQBUZXh0IGZpbGUgYnVzeQBFeGVjIGZvcm1hdCBlcnJvcgBJbnZhbGlkIGFyZ3VtZW50AEFyZ3VtZW50IGxpc3QgdG9vIGxvbmcAU3ltYm9saWMgbGluayBsb29wAEZpbGVuYW1lIHRvbyBsb25nAFRvbyBtYW55IG9wZW4gZmlsZXMgaW4gc3lzdGVtAE5vIGZpbGUgZGVzY3JpcHRvcnMgYXZhaWxhYmxlAEJhZCBmaWxlIGRlc2NyaXB0b3IATm8gY2hpbGQgcHJvY2VzcwBCYWQgYWRkcmVzcwBGaWxlIHRvbyBsYXJnZQBUb28gbWFueSBsaW5rcwBObyBsb2NrcyBhdmFpbGFibGUAUmVzb3VyY2UgZGVhZGxvY2sgd291bGQgb2NjdXIAU3RhdGUgbm90IHJlY292ZXJhYmxlAFByZXZpb3VzIG93bmVyIGRpZWQAT3BlcmF0aW9uIGNhbmNlbGVkAEZ1bmN0aW9uIG5vdCBpbXBsZW1lbnRlZABObyBtZXNzYWdlIG9mIGRlc2lyZWQgdHlwZQBJZGVudGlmaWVyIHJlbW92ZWQARGV2aWNlIG5vdCBhIHN0cmVhbQBObyBkYXRhIGF2YWlsYWJsZQBEZXZpY2UgdGltZW91dABPdXQgb2Ygc3RyZWFtcyByZXNvdXJjZXMATGluayBoYXMgYmVlbiBzZXZlcmVkAFByb3RvY29sIGVycm9yAEJhZCBtZXNzYWdlAEZpbGUgZGVzY3JpcHRvciBpbiBiYWQgc3RhdGUATm90IGEgc29ja2V0AERlc3RpbmF0aW9uIGFkZHJlc3MgcmVxdWlyZWQATWVzc2FnZSB0b28gbGFyZ2UAUHJvdG9jb2wgd3JvbmcgdHlwZSBmb3Igc29ja2V0AFByb3RvY29sIG5vdCBhdmFpbGFibGUAUHJvdG9jb2wgbm90IHN1cHBvcnRlZABTb2NrZXQgdHlwZSBub3Qgc3VwcG9ydGVkAE5vdCBzdXBwb3J0ZWQAUHJvdG9jb2wgZmFtaWx5IG5vdCBzdXBwb3J0ZWQAQWRkcmVzcyBmYW1pbHkgbm90IHN1cHBvcnRlZCBieSBwcm90b2NvbABBZGRyZXNzIG5vdCBhdmFpbGFibGUATmV0d29yayBpcyBkb3duAE5ldHdvcmsgdW5yZWFjaGFibGUAQ29ubmVjdGlvbiByZXNldCBieSBuZXR3b3JrAENvbm5lY3Rpb24gYWJvcnRlZABObyBidWZmZXIgc3BhY2UgYXZhaWxhYmxlAFNvY2tldCBpcyBjb25uZWN0ZWQAU29ja2V0IG5vdCBjb25uZWN0ZWQAQ2Fubm90IHNlbmQgYWZ0ZXIgc29ja2V0IHNodXRkb3duAE9wZXJhdGlvbiBhbHJlYWR5IGluIHByb2dyZXNzAE9wZXJhdGlvbiBpbiBwcm9ncmVzcwBTdGFsZSBmaWxlIGhhbmRsZQBSZW1vdGUgSS9PIGVycm9yAFF1b3RhIGV4Y2VlZGVkAE5vIG1lZGl1bSBmb3VuZABXcm9uZyBtZWRpdW0gdHlwZQBObyBlcnJvciBpbmZvcm1hdGlvbgBBkJcBC1JQUFAACgAAAAsAAAAMAAAADQAAAA4AAAAPAAAAEAAAABEAAAASAAAACwAAAAwAAAANAAAADgAAAA8AAAAQAAAAEQAAAAEAAAAIAAAAlEsAALRLAEGQmQELAgxQAEHImQELCR8AAADkTAAAAwBB5JkBC4wBLfRRWM+MscBG9rXLKTEDxwRbcDC0Xf0geH+LmthZKVBoSImrp1YDbP+3zYg/1He0K6WjcPG65Kj8QYP92W/hinovLXSWBx8NCV4Ddixw90ClLKdvV0GoqnTfoFhkA0rHxDxTrq9fGAQVseNtKIarDKS/Q/DpUIE5VxZSN/////////////////////8=";Fu(So)||(So=h(So));function Nu(d){try{if(d==So&&W)return new Uint8Array(W);var E=ba(d);if(E)return E;if(m)return m(d);throw"sync fetching of the wasm failed: you can preload it to Module['wasmBinary'] manually, or emcc.py will do that for you when generating HTML (but not JS)"}catch(I){Sr(I)}}function Qh(d,E){var I,D,O;try{O=Nu(d),D=new WebAssembly.Module(O),I=new WebAssembly.Instance(D,E)}catch(ie){var V=ie.toString();throw x("failed to compile wasm module: "+V),(V.includes("imported Memory")||V.includes("memory import"))&&x("Memory size incompatibility issues may be due to changing INITIAL_MEMORY at runtime to something too large. Use ALLOW_MEMORY_GROWTH to allow any size memory (and also make sure not to set INITIAL_MEMORY at runtime to something smaller than it was at compile time)."),ie}return[I,D]}function vh(){var d={a:Qa};function E(O,V){var ie=O.exports;r.asm=ie,A=r.asm.u,mi(A.buffer),Qr=r.asm.pa,SA(r.asm.v),PA("wasm-instantiate")}if(xA("wasm-instantiate"),r.instantiateWasm)try{var I=r.instantiateWasm(d,E);return I}catch(O){return x("Module.instantiateWasm callback failed with error: "+O),!1}var D=Qh(So,d);return E(D[0]),r.asm}var oe,Oi;function ko(d){for(;d.length>0;){var E=d.shift();if(typeof E=="function"){E(r);continue}var I=E.func;typeof I=="number"?E.arg===void 0?Qr.get(I)():Qr.get(I)(E.arg):I(E.arg===void 0?null:E.arg)}}function jn(d,E){var I=new Date(fe[d>>2]*1e3);fe[E>>2]=I.getUTCSeconds(),fe[E+4>>2]=I.getUTCMinutes(),fe[E+8>>2]=I.getUTCHours(),fe[E+12>>2]=I.getUTCDate(),fe[E+16>>2]=I.getUTCMonth(),fe[E+20>>2]=I.getUTCFullYear()-1900,fe[E+24>>2]=I.getUTCDay(),fe[E+36>>2]=0,fe[E+32>>2]=0;var D=Date.UTC(I.getUTCFullYear(),0,1,0,0,0,0),O=(I.getTime()-D)/(1e3*60*60*24)|0;return fe[E+28>>2]=O,jn.GMTString||(jn.GMTString=Fe("GMT")),fe[E+40>>2]=jn.GMTString,E}function Lu(d,E){return jn(d,E)}var vt={splitPath:function(d){var E=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return E.exec(d).slice(1)},normalizeArray:function(d,E){for(var I=0,D=d.length-1;D>=0;D--){var O=d[D];O==="."?d.splice(D,1):O===".."?(d.splice(D,1),I++):I&&(d.splice(D,1),I--)}if(E)for(;I;I--)d.unshift("..");return d},normalize:function(d){var E=d.charAt(0)==="/",I=d.substr(-1)==="/";return d=vt.normalizeArray(d.split("/").filter(function(D){return!!D}),!E).join("/"),!d&&!E&&(d="."),d&&I&&(d+="/"),(E?"/":"")+d},dirname:function(d){var E=vt.splitPath(d),I=E[0],D=E[1];return!I&&!D?".":(D&&(D=D.substr(0,D.length-1)),I+D)},basename:function(d){if(d==="/")return"/";d=vt.normalize(d),d=d.replace(/\/$/,"");var E=d.lastIndexOf("/");return E===-1?d:d.substr(E+1)},extname:function(d){return vt.splitPath(d)[3]},join:function(){var d=Array.prototype.slice.call(arguments,0);return vt.normalize(d.join("/"))},join2:function(d,E){return vt.normalize(d+"/"+E)}};function Gl(){if(typeof crypto=="object"&&typeof crypto.getRandomValues=="function"){var d=new Uint8Array(1);return function(){return crypto.getRandomValues(d),d[0]}}else if(g)try{var E=require("crypto");return function(){return E.randomBytes(1)[0]}}catch(I){}return function(){Sr("randomDevice")}}var Gn={resolve:function(){for(var d="",E=!1,I=arguments.length-1;I>=-1&&!E;I--){var D=I>=0?arguments[I]:v.cwd();if(typeof D!="string")throw new TypeError("Arguments to path.resolve must be strings");if(!D)return"";d=D+"/"+d,E=D.charAt(0)==="/"}return d=vt.normalizeArray(d.split("/").filter(function(O){return!!O}),!E).join("/"),(E?"/":"")+d||"."},relative:function(d,E){d=Gn.resolve(d).substr(1),E=Gn.resolve(E).substr(1);function I(_e){for(var ot=0;ot<_e.length&&_e[ot]==="";ot++);for(var wt=_e.length-1;wt>=0&&_e[wt]==="";wt--);return ot>wt?[]:_e.slice(ot,wt-ot+1)}for(var D=I(d.split("/")),O=I(E.split("/")),V=Math.min(D.length,O.length),ie=V,Be=0;Be<V;Be++)if(D[Be]!==O[Be]){ie=Be;break}for(var Ce=[],Be=ie;Be<D.length;Be++)Ce.push("..");return Ce=Ce.concat(O.slice(ie)),Ce.join("/")}},fs={ttys:[],init:function(){},shutdown:function(){},register:function(d,E){fs.ttys[d]={input:[],output:[],ops:E},v.registerDevice(d,fs.stream_ops)},stream_ops:{open:function(d){var E=fs.ttys[d.node.rdev];if(!E)throw new v.ErrnoError(43);d.tty=E,d.seekable=!1},close:function(d){d.tty.ops.flush(d.tty)},flush:function(d){d.tty.ops.flush(d.tty)},read:function(d,E,I,D,O){if(!d.tty||!d.tty.ops.get_char)throw new v.ErrnoError(60);for(var V=0,ie=0;ie<D;ie++){var Be;try{Be=d.tty.ops.get_char(d.tty)}catch(Ce){throw new v.ErrnoError(29)}if(Be===void 0&&V===0)throw new v.ErrnoError(6);if(Be==null)break;V++,E[I+ie]=Be}return V&&(d.node.timestamp=Date.now()),V},write:function(d,E,I,D,O){if(!d.tty||!d.tty.ops.put_char)throw new v.ErrnoError(60);try{for(var V=0;V<D;V++)d.tty.ops.put_char(d.tty,E[I+V])}catch(ie){throw new v.ErrnoError(29)}return D&&(d.node.timestamp=Date.now()),V}},default_tty_ops:{get_char:function(d){if(!d.input.length){var E=null;if(g){var I=256,D=Buffer.alloc?Buffer.alloc(I):new Buffer(I),O=0;try{O=y.readSync(process.stdin.fd,D,0,I,null)}catch(V){if(V.toString().includes("EOF"))O=0;else throw V}O>0?E=D.slice(0,O).toString("utf-8"):E=null}else typeof window!="undefined"&&typeof window.prompt=="function"?(E=window.prompt("Input: "),E!==null&&(E+=`
+`)):typeof readline=="function"&&(E=readline(),E!==null&&(E+=`
+`));if(!E)return null;d.input=RA(E,!0)}return d.input.shift()},put_char:function(d,E){E===null||E===10?(S(qe(d.output,0)),d.output=[]):E!=0&&d.output.push(E)},flush:function(d){d.output&&d.output.length>0&&(S(qe(d.output,0)),d.output=[])}},default_tty1_ops:{put_char:function(d,E){E===null||E===10?(x(qe(d.output,0)),d.output=[]):E!=0&&d.output.push(E)},flush:function(d){d.output&&d.output.length>0&&(x(qe(d.output,0)),d.output=[])}}};function hs(d){for(var E=Y(d,65536),I=Et(E);d<E;)pe[I+d++]=0;return I}var pt={ops_table:null,mount:function(d){return pt.createNode(null,"/",16384|511,0)},createNode:function(d,E,I,D){if(v.isBlkdev(I)||v.isFIFO(I))throw new v.ErrnoError(63);pt.ops_table||(pt.ops_table={dir:{node:{getattr:pt.node_ops.getattr,setattr:pt.node_ops.setattr,lookup:pt.node_ops.lookup,mknod:pt.node_ops.mknod,rename:pt.node_ops.rename,unlink:pt.node_ops.unlink,rmdir:pt.node_ops.rmdir,readdir:pt.node_ops.readdir,symlink:pt.node_ops.symlink},stream:{llseek:pt.stream_ops.llseek}},file:{node:{getattr:pt.node_ops.getattr,setattr:pt.node_ops.setattr},stream:{llseek:pt.stream_ops.llseek,read:pt.stream_ops.read,write:pt.stream_ops.write,allocate:pt.stream_ops.allocate,mmap:pt.stream_ops.mmap,msync:pt.stream_ops.msync}},link:{node:{getattr:pt.node_ops.getattr,setattr:pt.node_ops.setattr,readlink:pt.node_ops.readlink},stream:{}},chrdev:{node:{getattr:pt.node_ops.getattr,setattr:pt.node_ops.setattr},stream:v.chrdev_stream_ops}});var O=v.createNode(d,E,I,D);return v.isDir(O.mode)?(O.node_ops=pt.ops_table.dir.node,O.stream_ops=pt.ops_table.dir.stream,O.contents={}):v.isFile(O.mode)?(O.node_ops=pt.ops_table.file.node,O.stream_ops=pt.ops_table.file.stream,O.usedBytes=0,O.contents=null):v.isLink(O.mode)?(O.node_ops=pt.ops_table.link.node,O.stream_ops=pt.ops_table.link.stream):v.isChrdev(O.mode)&&(O.node_ops=pt.ops_table.chrdev.node,O.stream_ops=pt.ops_table.chrdev.stream),O.timestamp=Date.now(),d&&(d.contents[E]=O,d.timestamp=O.timestamp),O},getFileDataAsTypedArray:function(d){return d.contents?d.contents.subarray?d.contents.subarray(0,d.usedBytes):new Uint8Array(d.contents):new Uint8Array(0)},expandFileStorage:function(d,E){var I=d.contents?d.contents.length:0;if(!(I>=E)){var D=1024*1024;E=Math.max(E,I*(I<D?2:1.125)>>>0),I!=0&&(E=Math.max(E,256));var O=d.contents;d.contents=new Uint8Array(E),d.usedBytes>0&&d.contents.set(O.subarray(0,d.usedBytes),0)}},resizeFileStorage:function(d,E){if(d.usedBytes!=E)if(E==0)d.contents=null,d.usedBytes=0;else{var I=d.contents;d.contents=new Uint8Array(E),I&&d.contents.set(I.subarray(0,Math.min(E,d.usedBytes))),d.usedBytes=E}},node_ops:{getattr:function(d){var E={};return E.dev=v.isChrdev(d.mode)?d.id:1,E.ino=d.id,E.mode=d.mode,E.nlink=1,E.uid=0,E.gid=0,E.rdev=d.rdev,v.isDir(d.mode)?E.size=4096:v.isFile(d.mode)?E.size=d.usedBytes:v.isLink(d.mode)?E.size=d.link.length:E.size=0,E.atime=new Date(d.timestamp),E.mtime=new Date(d.timestamp),E.ctime=new Date(d.timestamp),E.blksize=4096,E.blocks=Math.ceil(E.size/E.blksize),E},setattr:function(d,E){E.mode!==void 0&&(d.mode=E.mode),E.timestamp!==void 0&&(d.timestamp=E.timestamp),E.size!==void 0&&pt.resizeFileStorage(d,E.size)},lookup:function(d,E){throw v.genericErrors[44]},mknod:function(d,E,I,D){return pt.createNode(d,E,I,D)},rename:function(d,E,I){if(v.isDir(d.mode)){var D;try{D=v.lookupNode(E,I)}catch(V){}if(D)for(var O in D.contents)throw new v.ErrnoError(55)}delete d.parent.contents[d.name],d.parent.timestamp=Date.now(),d.name=I,E.contents[I]=d,E.timestamp=d.parent.timestamp,d.parent=E},unlink:function(d,E){delete d.contents[E],d.timestamp=Date.now()},rmdir:function(d,E){var I=v.lookupNode(d,E);for(var D in I.contents)throw new v.ErrnoError(55);delete d.contents[E],d.timestamp=Date.now()},readdir:function(d){var E=[".",".."];for(var I in d.contents)!d.contents.hasOwnProperty(I)||E.push(I);return E},symlink:function(d,E,I){var D=pt.createNode(d,E,511|40960,0);return D.link=I,D},readlink:function(d){if(!v.isLink(d.mode))throw new v.ErrnoError(28);return d.link}},stream_ops:{read:function(d,E,I,D,O){var V=d.node.contents;if(O>=d.node.usedBytes)return 0;var ie=Math.min(d.node.usedBytes-O,D);if(ie>8&&V.subarray)E.set(V.subarray(O,O+ie),I);else for(var Be=0;Be<ie;Be++)E[I+Be]=V[O+Be];return ie},write:function(d,E,I,D,O,V){if(E.buffer===pe.buffer&&(V=!1),!D)return 0;var ie=d.node;if(ie.timestamp=Date.now(),E.subarray&&(!ie.contents||ie.contents.subarray)){if(V)return ie.contents=E.subarray(I,I+D),ie.usedBytes=D,D;if(ie.usedBytes===0&&O===0)return ie.contents=E.slice(I,I+D),ie.usedBytes=D,D;if(O+D<=ie.usedBytes)return ie.contents.set(E.subarray(I,I+D),O),D}if(pt.expandFileStorage(ie,O+D),ie.contents.subarray&&E.subarray)ie.contents.set(E.subarray(I,I+D),O);else for(var Be=0;Be<D;Be++)ie.contents[O+Be]=E[I+Be];return ie.usedBytes=Math.max(ie.usedBytes,O+D),D},llseek:function(d,E,I){var D=E;if(I===1?D+=d.position:I===2&&v.isFile(d.node.mode)&&(D+=d.node.usedBytes),D<0)throw new v.ErrnoError(28);return D},allocate:function(d,E,I){pt.expandFileStorage(d.node,E+I),d.node.usedBytes=Math.max(d.node.usedBytes,E+I)},mmap:function(d,E,I,D,O,V){if(E!==0)throw new v.ErrnoError(28);if(!v.isFile(d.node.mode))throw new v.ErrnoError(43);var ie,Be,Ce=d.node.contents;if(!(V&2)&&Ce.buffer===ve)Be=!1,ie=Ce.byteOffset;else{if((D>0||D+I<Ce.length)&&(Ce.subarray?Ce=Ce.subarray(D,D+I):Ce=Array.prototype.slice.call(Ce,D,D+I)),Be=!0,ie=hs(I),!ie)throw new v.ErrnoError(48);pe.set(Ce,ie)}return{ptr:ie,allocated:Be}},msync:function(d,E,I,D,O){if(!v.isFile(d.node.mode))throw new v.ErrnoError(43);if(O&2)return 0;var V=pt.stream_ops.write(d,E,0,D,I,!1);return 0}}},xo={EPERM:63,ENOENT:44,ESRCH:71,EINTR:27,EIO:29,ENXIO:60,E2BIG:1,ENOEXEC:45,EBADF:8,ECHILD:12,EAGAIN:6,EWOULDBLOCK:6,ENOMEM:48,EACCES:2,EFAULT:21,ENOTBLK:105,EBUSY:10,EEXIST:20,EXDEV:75,ENODEV:43,ENOTDIR:54,EISDIR:31,EINVAL:28,ENFILE:41,EMFILE:33,ENOTTY:59,ETXTBSY:74,EFBIG:22,ENOSPC:51,ESPIPE:70,EROFS:69,EMLINK:34,EPIPE:64,EDOM:18,ERANGE:68,ENOMSG:49,EIDRM:24,ECHRNG:106,EL2NSYNC:156,EL3HLT:107,EL3RST:108,ELNRNG:109,EUNATCH:110,ENOCSI:111,EL2HLT:112,EDEADLK:16,ENOLCK:46,EBADE:113,EBADR:114,EXFULL:115,ENOANO:104,EBADRQC:103,EBADSLT:102,EDEADLOCK:16,EBFONT:101,ENOSTR:100,ENODATA:116,ETIME:117,ENOSR:118,ENONET:119,ENOPKG:120,EREMOTE:121,ENOLINK:47,EADV:122,ESRMNT:123,ECOMM:124,EPROTO:65,EMULTIHOP:36,EDOTDOT:125,EBADMSG:9,ENOTUNIQ:126,EBADFD:127,EREMCHG:128,ELIBACC:129,ELIBBAD:130,ELIBSCN:131,ELIBMAX:132,ELIBEXEC:133,ENOSYS:52,ENOTEMPTY:55,ENAMETOOLONG:37,ELOOP:32,EOPNOTSUPP:138,EPFNOSUPPORT:139,ECONNRESET:15,ENOBUFS:42,EAFNOSUPPORT:5,EPROTOTYPE:67,ENOTSOCK:57,ENOPROTOOPT:50,ESHUTDOWN:140,ECONNREFUSED:14,EADDRINUSE:3,ECONNABORTED:13,ENETUNREACH:40,ENETDOWN:38,ETIMEDOUT:73,EHOSTDOWN:142,EHOSTUNREACH:23,EINPROGRESS:26,EALREADY:7,EDESTADDRREQ:17,EMSGSIZE:35,EPROTONOSUPPORT:66,ESOCKTNOSUPPORT:137,EADDRNOTAVAIL:4,ENETRESET:39,EISCONN:30,ENOTCONN:53,ETOOMANYREFS:141,EUSERS:136,EDQUOT:19,ESTALE:72,ENOTSUP:138,ENOMEDIUM:148,EILSEQ:25,EOVERFLOW:61,ECANCELED:11,ENOTRECOVERABLE:56,EOWNERDEAD:62,ESTRPIPE:135},lt={isWindows:!1,staticInit:function(){lt.isWindows=!!process.platform.match(/^win/);var d={fs:Oe.constants};d.fs&&(d=d.fs),lt.flagsForNodeMap={1024:d.O_APPEND,64:d.O_CREAT,128:d.O_EXCL,256:d.O_NOCTTY,0:d.O_RDONLY,2:d.O_RDWR,4096:d.O_SYNC,512:d.O_TRUNC,1:d.O_WRONLY}},bufferFrom:function(d){return Buffer.alloc?Buffer.from(d):new Buffer(d)},convertNodeCode:function(d){var E=d.code;return xo[E]},mount:function(d){return lt.createNode(null,"/",lt.getMode(d.opts.root),0)},createNode:function(d,E,I,D){if(!v.isDir(I)&&!v.isFile(I)&&!v.isLink(I))throw new v.ErrnoError(28);var O=v.createNode(d,E,I);return O.node_ops=lt.node_ops,O.stream_ops=lt.stream_ops,O},getMode:function(d){var E;try{E=Oe.lstatSync(d),lt.isWindows&&(E.mode=E.mode|(E.mode&292)>>2)}catch(I){throw I.code?new v.ErrnoError(lt.convertNodeCode(I)):I}return E.mode},realPath:function(d){for(var E=[];d.parent!==d;)E.push(d.name),d=d.parent;return E.push(d.mount.opts.root),E.reverse(),vt.join.apply(null,E)},flagsForNode:function(d){d&=~2097152,d&=~2048,d&=~32768,d&=~524288;var E=0;for(var I in lt.flagsForNodeMap)d&I&&(E|=lt.flagsForNodeMap[I],d^=I);if(d)throw new v.ErrnoError(28);return E},node_ops:{getattr:function(d){var E=lt.realPath(d),I;try{I=Oe.lstatSync(E)}catch(D){throw D.code?new v.ErrnoError(lt.convertNodeCode(D)):D}return lt.isWindows&&!I.blksize&&(I.blksize=4096),lt.isWindows&&!I.blocks&&(I.blocks=(I.size+I.blksize-1)/I.blksize|0),{dev:I.dev,ino:I.ino,mode:I.mode,nlink:I.nlink,uid:I.uid,gid:I.gid,rdev:I.rdev,size:I.size,atime:I.atime,mtime:I.mtime,ctime:I.ctime,blksize:I.blksize,blocks:I.blocks}},setattr:function(d,E){var I=lt.realPath(d);try{if(E.mode!==void 0&&(Oe.chmodSync(I,E.mode),d.mode=E.mode),E.timestamp!==void 0){var D=new Date(E.timestamp);Oe.utimesSync(I,D,D)}E.size!==void 0&&Oe.truncateSync(I,E.size)}catch(O){throw O.code?new v.ErrnoError(lt.convertNodeCode(O)):O}},lookup:function(d,E){var I=vt.join2(lt.realPath(d),E),D=lt.getMode(I);return lt.createNode(d,E,D)},mknod:function(d,E,I,D){var O=lt.createNode(d,E,I,D),V=lt.realPath(O);try{v.isDir(O.mode)?Oe.mkdirSync(V,O.mode):Oe.writeFileSync(V,"",{mode:O.mode})}catch(ie){throw ie.code?new v.ErrnoError(lt.convertNodeCode(ie)):ie}return O},rename:function(d,E,I){var D=lt.realPath(d),O=vt.join2(lt.realPath(E),I);try{Oe.renameSync(D,O)}catch(V){throw V.code?new v.ErrnoError(lt.convertNodeCode(V)):V}d.name=I},unlink:function(d,E){var I=vt.join2(lt.realPath(d),E);try{Oe.unlinkSync(I)}catch(D){throw D.code?new v.ErrnoError(lt.convertNodeCode(D)):D}},rmdir:function(d,E){var I=vt.join2(lt.realPath(d),E);try{Oe.rmdirSync(I)}catch(D){throw D.code?new v.ErrnoError(lt.convertNodeCode(D)):D}},readdir:function(d){var E=lt.realPath(d);try{return Oe.readdirSync(E)}catch(I){throw I.code?new v.ErrnoError(lt.convertNodeCode(I)):I}},symlink:function(d,E,I){var D=vt.join2(lt.realPath(d),E);try{Oe.symlinkSync(I,D)}catch(O){throw O.code?new v.ErrnoError(lt.convertNodeCode(O)):O}},readlink:function(d){var E=lt.realPath(d);try{return E=Oe.readlinkSync(E),E=Mu.relative(Mu.resolve(d.mount.opts.root),E),E}catch(I){throw I.code?new v.ErrnoError(lt.convertNodeCode(I)):I}}},stream_ops:{open:function(d){var E=lt.realPath(d.node);try{v.isFile(d.node.mode)&&(d.nfd=Oe.openSync(E,lt.flagsForNode(d.flags)))}catch(I){throw I.code?new v.ErrnoError(lt.convertNodeCode(I)):I}},close:function(d){try{v.isFile(d.node.mode)&&d.nfd&&Oe.closeSync(d.nfd)}catch(E){throw E.code?new v.ErrnoError(lt.convertNodeCode(E)):E}},read:function(d,E,I,D,O){if(D===0)return 0;try{return Oe.readSync(d.nfd,lt.bufferFrom(E.buffer),I,D,O)}catch(V){throw new v.ErrnoError(lt.convertNodeCode(V))}},write:function(d,E,I,D,O){try{return Oe.writeSync(d.nfd,lt.bufferFrom(E.buffer),I,D,O)}catch(V){throw new v.ErrnoError(lt.convertNodeCode(V))}},llseek:function(d,E,I){var D=E;if(I===1)D+=d.position;else if(I===2&&v.isFile(d.node.mode))try{var O=Oe.fstatSync(d.nfd);D+=O.size}catch(V){throw new v.ErrnoError(lt.convertNodeCode(V))}if(D<0)throw new v.ErrnoError(28);return D},mmap:function(d,E,I,D,O,V){if(E!==0)throw new v.ErrnoError(28);if(!v.isFile(d.node.mode))throw new v.ErrnoError(43);var ie=hs(I);return lt.stream_ops.read(d,pe,ie,I,D),{ptr:ie,allocated:!0}},msync:function(d,E,I,D,O){if(!v.isFile(d.node.mode))throw new v.ErrnoError(43);if(O&2)return 0;var V=lt.stream_ops.write(d,E,0,D,I,!1);return 0}}},mn={lookupPath:function(d){return{path:d,node:{mode:lt.getMode(d)}}},createStandardStreams:function(){v.streams[0]={fd:0,nfd:0,position:0,path:"",flags:0,tty:!0,seekable:!1};for(var d=1;d<3;d++)v.streams[d]={fd:d,nfd:d,position:0,path:"",flags:577,tty:!0,seekable:!1}},cwd:function(){return process.cwd()},chdir:function(){process.chdir.apply(void 0,arguments)},mknod:function(d,E){v.isDir(d)?Oe.mkdirSync(d,E):Oe.writeFileSync(d,"",{mode:E})},mkdir:function(){Oe.mkdirSync.apply(void 0,arguments)},symlink:function(){Oe.symlinkSync.apply(void 0,arguments)},rename:function(){Oe.renameSync.apply(void 0,arguments)},rmdir:function(){Oe.rmdirSync.apply(void 0,arguments)},readdir:function(){Oe.readdirSync.apply(void 0,arguments)},unlink:function(){Oe.unlinkSync.apply(void 0,arguments)},readlink:function(){return Oe.readlinkSync.apply(void 0,arguments)},stat:function(){return Oe.statSync.apply(void 0,arguments)},lstat:function(){return Oe.lstatSync.apply(void 0,arguments)},chmod:function(){Oe.chmodSync.apply(void 0,arguments)},fchmod:function(){Oe.fchmodSync.apply(void 0,arguments)},chown:function(){Oe.chownSync.apply(void 0,arguments)},fchown:function(){Oe.fchownSync.apply(void 0,arguments)},truncate:function(){Oe.truncateSync.apply(void 0,arguments)},ftruncate:function(d,E){if(E<0)throw new v.ErrnoError(28);Oe.ftruncateSync.apply(void 0,arguments)},utime:function(){Oe.utimesSync.apply(void 0,arguments)},open:function(d,E,I,D){typeof E=="string"&&(E=Xs.modeStringToFlags(E));var O=Oe.openSync(d,lt.flagsForNode(E),I),V=D!=null?D:v.nextfd(O),ie={fd:V,nfd:O,position:0,path:d,flags:E,seekable:!0};return v.streams[V]=ie,ie},close:function(d){d.stream_ops||Oe.closeSync(d.nfd),v.closeStream(d.fd)},llseek:function(d,E,I){if(d.stream_ops)return Xs.llseek(d,E,I);var D=E;if(I===1)D+=d.position;else if(I===2)D+=Oe.fstatSync(d.nfd).size;else if(I!==0)throw new v.ErrnoError(xo.EINVAL);if(D<0)throw new v.ErrnoError(xo.EINVAL);return d.position=D,D},read:function(d,E,I,D,O){if(d.stream_ops)return Xs.read(d,E,I,D,O);var V=typeof O!="undefined";!V&&d.seekable&&(O=d.position);var ie=Oe.readSync(d.nfd,lt.bufferFrom(E.buffer),I,D,O);return V||(d.position+=ie),ie},write:function(d,E,I,D,O){if(d.stream_ops)return Xs.write(d,E,I,D,O);d.flags&+"1024"&&v.llseek(d,0,+"2");var V=typeof O!="undefined";!V&&d.seekable&&(O=d.position);var ie=Oe.writeSync(d.nfd,lt.bufferFrom(E.buffer),I,D,O);return V||(d.position+=ie),ie},allocate:function(){throw new v.ErrnoError(xo.EOPNOTSUPP)},mmap:function(d,E,I,D,O,V){if(d.stream_ops)return Xs.mmap(d,E,I,D,O,V);if(E!==0)throw new v.ErrnoError(28);var ie=hs(I);return v.read(d,pe,ie,I,D),{ptr:ie,allocated:!0}},msync:function(d,E,I,D,O){return d.stream_ops?Xs.msync(d,E,I,D,O):(O&2||v.write(d,E,0,D,I),0)},munmap:function(){return 0},ioctl:function(){throw new v.ErrnoError(xo.ENOTTY)}},v={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:!1,ignorePermissions:!0,trackingDelegate:{},tracking:{openFlags:{READ:1,WRITE:2}},ErrnoError:null,genericErrors:{},filesystems:null,syncFSRequests:0,lookupPath:function(d,E){if(d=Gn.resolve(v.cwd(),d),E=E||{},!d)return{path:"",node:null};var I={follow_mount:!0,recurse_count:0};for(var D in I)E[D]===void 0&&(E[D]=I[D]);if(E.recurse_count>8)throw new v.ErrnoError(32);for(var O=vt.normalizeArray(d.split("/").filter(function(ut){return!!ut}),!1),V=v.root,ie="/",Be=0;Be<O.length;Be++){var Ce=Be===O.length-1;if(Ce&&E.parent)break;if(V=v.lookupNode(V,O[Be]),ie=vt.join2(ie,O[Be]),v.isMountpoint(V)&&(!Ce||Ce&&E.follow_mount)&&(V=V.mounted.root),!Ce||E.follow)for(var _e=0;v.isLink(V.mode);){var ot=v.readlink(ie);ie=Gn.resolve(vt.dirname(ie),ot);var wt=v.lookupPath(ie,{recurse_count:E.recurse_count});if(V=wt.node,_e++>40)throw new v.ErrnoError(32)}}return{path:ie,node:V}},getPath:function(d){for(var E;;){if(v.isRoot(d)){var I=d.mount.mountpoint;return E?I[I.length-1]!=="/"?I+"/"+E:I+E:I}E=E?d.name+"/"+E:d.name,d=d.parent}},hashName:function(d,E){for(var I=0,D=0;D<E.length;D++)I=(I<<5)-I+E.charCodeAt(D)|0;return(d+I>>>0)%v.nameTable.length},hashAddNode:function(d){var E=v.hashName(d.parent.id,d.name);d.name_next=v.nameTable[E],v.nameTable[E]=d},hashRemoveNode:function(d){var E=v.hashName(d.parent.id,d.name);if(v.nameTable[E]===d)v.nameTable[E]=d.name_next;else for(var I=v.nameTable[E];I;){if(I.name_next===d){I.name_next=d.name_next;break}I=I.name_next}},lookupNode:function(d,E){var I=v.mayLookup(d);if(I)throw new v.ErrnoError(I,d);for(var D=v.hashName(d.id,E),O=v.nameTable[D];O;O=O.name_next){var V=O.name;if(O.parent.id===d.id&&V===E)return O}return v.lookup(d,E)},createNode:function(d,E,I,D){var O=new v.FSNode(d,E,I,D);return v.hashAddNode(O),O},destroyNode:function(d){v.hashRemoveNode(d)},isRoot:function(d){return d===d.parent},isMountpoint:function(d){return!!d.mounted},isFile:function(d){return(d&61440)==32768},isDir:function(d){return(d&61440)==16384},isLink:function(d){return(d&61440)==40960},isChrdev:function(d){return(d&61440)==8192},isBlkdev:function(d){return(d&61440)==24576},isFIFO:function(d){return(d&61440)==4096},isSocket:function(d){return(d&49152)==49152},flagModes:{r:0,"r+":2,w:577,"w+":578,a:1089,"a+":1090},modeStringToFlags:function(d){var E=v.flagModes[d];if(typeof E=="undefined")throw new Error("Unknown file open mode: "+d);return E},flagsToPermissionString:function(d){var E=["r","w","rw"][d&3];return d&512&&(E+="w"),E},nodePermissions:function(d,E){return v.ignorePermissions?0:E.includes("r")&&!(d.mode&292)||E.includes("w")&&!(d.mode&146)||E.includes("x")&&!(d.mode&73)?2:0},mayLookup:function(d){var E=v.nodePermissions(d,"x");return E||(d.node_ops.lookup?0:2)},mayCreate:function(d,E){try{var I=v.lookupNode(d,E);return 20}catch(D){}return v.nodePermissions(d,"wx")},mayDelete:function(d,E,I){var D;try{D=v.lookupNode(d,E)}catch(V){return V.errno}var O=v.nodePermissions(d,"wx");if(O)return O;if(I){if(!v.isDir(D.mode))return 54;if(v.isRoot(D)||v.getPath(D)===v.cwd())return 10}else if(v.isDir(D.mode))return 31;return 0},mayOpen:function(d,E){return d?v.isLink(d.mode)?32:v.isDir(d.mode)&&(v.flagsToPermissionString(E)!=="r"||E&512)?31:v.nodePermissions(d,v.flagsToPermissionString(E)):44},MAX_OPEN_FDS:4096,nextfd:function(d,E){d=d||0,E=E||v.MAX_OPEN_FDS;for(var I=d;I<=E;I++)if(!v.streams[I])return I;throw new v.ErrnoError(33)},getStream:function(d){return v.streams[d]},createStream:function(d,E,I){v.FSStream||(v.FSStream=function(){},v.FSStream.prototype={object:{get:function(){return this.node},set:function(ie){this.node=ie}},isRead:{get:function(){return(this.flags&2097155)!=1}},isWrite:{get:function(){return(this.flags&2097155)!=0}},isAppend:{get:function(){return this.flags&1024}}});var D=new v.FSStream;for(var O in d)D[O]=d[O];d=D;var V=v.nextfd(E,I);return d.fd=V,v.streams[V]=d,d},closeStream:function(d){v.streams[d]=null},chrdev_stream_ops:{open:function(d){var E=v.getDevice(d.node.rdev);d.stream_ops=E.stream_ops,d.stream_ops.open&&d.stream_ops.open(d)},llseek:function(){throw new v.ErrnoError(70)}},major:function(d){return d>>8},minor:function(d){return d&255},makedev:function(d,E){return d<<8|E},registerDevice:function(d,E){v.devices[d]={stream_ops:E}},getDevice:function(d){return v.devices[d]},getMounts:function(d){for(var E=[],I=[d];I.length;){var D=I.pop();E.push(D),I.push.apply(I,D.mounts)}return E},syncfs:function(d,E){typeof d=="function"&&(E=d,d=!1),v.syncFSRequests++,v.syncFSRequests>1&&x("warning: "+v.syncFSRequests+" FS.syncfs operations in flight at once, probably just doing extra work");var I=v.getMounts(v.root.mount),D=0;function O(ie){return v.syncFSRequests--,E(ie)}function V(ie){if(ie)return V.errored?void 0:(V.errored=!0,O(ie));++D>=I.length&&O(null)}I.forEach(function(ie){if(!ie.type.syncfs)return V(null);ie.type.syncfs(ie,d,V)})},mount:function(d,E,I){var D=I==="/",O=!I,V;if(D&&v.root)throw new v.ErrnoError(10);if(!D&&!O){var ie=v.lookupPath(I,{follow_mount:!1});if(I=ie.path,V=ie.node,v.isMountpoint(V))throw new v.ErrnoError(10);if(!v.isDir(V.mode))throw new v.ErrnoError(54)}var Be={type:d,opts:E,mountpoint:I,mounts:[]},Ce=d.mount(Be);return Ce.mount=Be,Be.root=Ce,D?v.root=Ce:V&&(V.mounted=Be,V.mount&&V.mount.mounts.push(Be)),Ce},unmount:function(d){var E=v.lookupPath(d,{follow_mount:!1});if(!v.isMountpoint(E.node))throw new v.ErrnoError(28);var I=E.node,D=I.mounted,O=v.getMounts(D);Object.keys(v.nameTable).forEach(function(ie){for(var Be=v.nameTable[ie];Be;){var Ce=Be.name_next;O.includes(Be.mount)&&v.destroyNode(Be),Be=Ce}}),I.mounted=null;var V=I.mount.mounts.indexOf(D);I.mount.mounts.splice(V,1)},lookup:function(d,E){return d.node_ops.lookup(d,E)},mknod:function(d,E,I){var D=v.lookupPath(d,{parent:!0}),O=D.node,V=vt.basename(d);if(!V||V==="."||V==="..")throw new v.ErrnoError(28);var ie=v.mayCreate(O,V);if(ie)throw new v.ErrnoError(ie);if(!O.node_ops.mknod)throw new v.ErrnoError(63);return O.node_ops.mknod(O,V,E,I)},create:function(d,E){return E=E!==void 0?E:438,E&=4095,E|=32768,v.mknod(d,E,0)},mkdir:function(d,E){return E=E!==void 0?E:511,E&=511|512,E|=16384,v.mknod(d,E,0)},mkdirTree:function(d,E){for(var I=d.split("/"),D="",O=0;O<I.length;++O)if(!!I[O]){D+="/"+I[O];try{v.mkdir(D,E)}catch(V){if(V.errno!=20)throw V}}},mkdev:function(d,E,I){return typeof I=="undefined"&&(I=E,E=438),E|=8192,v.mknod(d,E,I)},symlink:function(d,E){if(!Gn.resolve(d))throw new v.ErrnoError(44);var I=v.lookupPath(E,{parent:!0}),D=I.node;if(!D)throw new v.ErrnoError(44);var O=vt.basename(E),V=v.mayCreate(D,O);if(V)throw new v.ErrnoError(V);if(!D.node_ops.symlink)throw new v.ErrnoError(63);return D.node_ops.symlink(D,O,d)},rename:function(d,E){var I=vt.dirname(d),D=vt.dirname(E),O=vt.basename(d),V=vt.basename(E),ie,Be,Ce;if(ie=v.lookupPath(d,{parent:!0}),Be=ie.node,ie=v.lookupPath(E,{parent:!0}),Ce=ie.node,!Be||!Ce)throw new v.ErrnoError(44);if(Be.mount!==Ce.mount)throw new v.ErrnoError(75);var _e=v.lookupNode(Be,O),ot=Gn.relative(d,D);if(ot.charAt(0)!==".")throw new v.ErrnoError(28);if(ot=Gn.relative(E,I),ot.charAt(0)!==".")throw new v.ErrnoError(55);var wt;try{wt=v.lookupNode(Ce,V)}catch(It){}if(_e!==wt){var ut=v.isDir(_e.mode),nt=v.mayDelete(Be,O,ut);if(nt)throw new v.ErrnoError(nt);if(nt=wt?v.mayDelete(Ce,V,ut):v.mayCreate(Ce,V),nt)throw new v.ErrnoError(nt);if(!Be.node_ops.rename)throw new v.ErrnoError(63);if(v.isMountpoint(_e)||wt&&v.isMountpoint(wt))throw new v.ErrnoError(10);if(Ce!==Be&&(nt=v.nodePermissions(Be,"w"),nt))throw new v.ErrnoError(nt);try{v.trackingDelegate.willMovePath&&v.trackingDelegate.willMovePath(d,E)}catch(It){x("FS.trackingDelegate['willMovePath']('"+d+"', '"+E+"') threw an exception: "+It.message)}v.hashRemoveNode(_e);try{Be.node_ops.rename(_e,Ce,V)}catch(It){throw It}finally{v.hashAddNode(_e)}try{v.trackingDelegate.onMovePath&&v.trackingDelegate.onMovePath(d,E)}catch(It){x("FS.trackingDelegate['onMovePath']('"+d+"', '"+E+"') threw an exception: "+It.message)}}},rmdir:function(d){var E=v.lookupPath(d,{parent:!0}),I=E.node,D=vt.basename(d),O=v.lookupNode(I,D),V=v.mayDelete(I,D,!0);if(V)throw new v.ErrnoError(V);if(!I.node_ops.rmdir)throw new v.ErrnoError(63);if(v.isMountpoint(O))throw new v.ErrnoError(10);try{v.trackingDelegate.willDeletePath&&v.trackingDelegate.willDeletePath(d)}catch(ie){x("FS.trackingDelegate['willDeletePath']('"+d+"') threw an exception: "+ie.message)}I.node_ops.rmdir(I,D),v.destroyNode(O);try{v.trackingDelegate.onDeletePath&&v.trackingDelegate.onDeletePath(d)}catch(ie){x("FS.trackingDelegate['onDeletePath']('"+d+"') threw an exception: "+ie.message)}},readdir:function(d){var E=v.lookupPath(d,{follow:!0}),I=E.node;if(!I.node_ops.readdir)throw new v.ErrnoError(54);return I.node_ops.readdir(I)},unlink:function(d){var E=v.lookupPath(d,{parent:!0}),I=E.node,D=vt.basename(d),O=v.lookupNode(I,D),V=v.mayDelete(I,D,!1);if(V)throw new v.ErrnoError(V);if(!I.node_ops.unlink)throw new v.ErrnoError(63);if(v.isMountpoint(O))throw new v.ErrnoError(10);try{v.trackingDelegate.willDeletePath&&v.trackingDelegate.willDeletePath(d)}catch(ie){x("FS.trackingDelegate['willDeletePath']('"+d+"') threw an exception: "+ie.message)}I.node_ops.unlink(I,D),v.destroyNode(O);try{v.trackingDelegate.onDeletePath&&v.trackingDelegate.onDeletePath(d)}catch(ie){x("FS.trackingDelegate['onDeletePath']('"+d+"') threw an exception: "+ie.message)}},readlink:function(d){var E=v.lookupPath(d),I=E.node;if(!I)throw new v.ErrnoError(44);if(!I.node_ops.readlink)throw new v.ErrnoError(28);return Gn.resolve(v.getPath(I.parent),I.node_ops.readlink(I))},stat:function(d,E){var I=v.lookupPath(d,{follow:!E}),D=I.node;if(!D)throw new v.ErrnoError(44);if(!D.node_ops.getattr)throw new v.ErrnoError(63);return D.node_ops.getattr(D)},lstat:function(d){return v.stat(d,!0)},chmod:function(d,E,I){var D;if(typeof d=="string"){var O=v.lookupPath(d,{follow:!I});D=O.node}else D=d;if(!D.node_ops.setattr)throw new v.ErrnoError(63);D.node_ops.setattr(D,{mode:E&4095|D.mode&~4095,timestamp:Date.now()})},lchmod:function(d,E){v.chmod(d,E,!0)},fchmod:function(d,E){var I=v.getStream(d);if(!I)throw new v.ErrnoError(8);v.chmod(I.node,E)},chown:function(d,E,I,D){var O;if(typeof d=="string"){var V=v.lookupPath(d,{follow:!D});O=V.node}else O=d;if(!O.node_ops.setattr)throw new v.ErrnoError(63);O.node_ops.setattr(O,{timestamp:Date.now()})},lchown:function(d,E,I){v.chown(d,E,I,!0)},fchown:function(d,E,I){var D=v.getStream(d);if(!D)throw new v.ErrnoError(8);v.chown(D.node,E,I)},truncate:function(d,E){if(E<0)throw new v.ErrnoError(28);var I;if(typeof d=="string"){var D=v.lookupPath(d,{follow:!0});I=D.node}else I=d;if(!I.node_ops.setattr)throw new v.ErrnoError(63);if(v.isDir(I.mode))throw new v.ErrnoError(31);if(!v.isFile(I.mode))throw new v.ErrnoError(28);var O=v.nodePermissions(I,"w");if(O)throw new v.ErrnoError(O);I.node_ops.setattr(I,{size:E,timestamp:Date.now()})},ftruncate:function(d,E){var I=v.getStream(d);if(!I)throw new v.ErrnoError(8);if((I.flags&2097155)==0)throw new v.ErrnoError(28);v.truncate(I.node,E)},utime:function(d,E,I){var D=v.lookupPath(d,{follow:!0}),O=D.node;O.node_ops.setattr(O,{timestamp:Math.max(E,I)})},open:function(d,E,I,D,O){if(d==="")throw new v.ErrnoError(44);E=typeof E=="string"?v.modeStringToFlags(E):E,I=typeof I=="undefined"?438:I,E&64?I=I&4095|32768:I=0;var V;if(typeof d=="object")V=d;else{d=vt.normalize(d);try{var ie=v.lookupPath(d,{follow:!(E&131072)});V=ie.node}catch(wt){}}var Be=!1;if(E&64)if(V){if(E&128)throw new v.ErrnoError(20)}else V=v.mknod(d,I,0),Be=!0;if(!V)throw new v.ErrnoError(44);if(v.isChrdev(V.mode)&&(E&=~512),E&65536&&!v.isDir(V.mode))throw new v.ErrnoError(54);if(!Be){var Ce=v.mayOpen(V,E);if(Ce)throw new v.ErrnoError(Ce)}E&512&&v.truncate(V,0),E&=~(128|512|131072);var _e=v.createStream({node:V,path:v.getPath(V),flags:E,seekable:!0,position:0,stream_ops:V.stream_ops,ungotten:[],error:!1},D,O);_e.stream_ops.open&&_e.stream_ops.open(_e),r.logReadFiles&&!(E&1)&&(v.readFiles||(v.readFiles={}),d in v.readFiles||(v.readFiles[d]=1,x("FS.trackingDelegate error on read file: "+d)));try{if(v.trackingDelegate.onOpenFile){var ot=0;(E&2097155)!=1&&(ot|=v.tracking.openFlags.READ),(E&2097155)!=0&&(ot|=v.tracking.openFlags.WRITE),v.trackingDelegate.onOpenFile(d,ot)}}catch(wt){x("FS.trackingDelegate['onOpenFile']('"+d+"', flags) threw an exception: "+wt.message)}return _e},close:function(d){if(v.isClosed(d))throw new v.ErrnoError(8);d.getdents&&(d.getdents=null);try{d.stream_ops.close&&d.stream_ops.close(d)}catch(E){throw E}finally{v.closeStream(d.fd)}d.fd=null},isClosed:function(d){return d.fd===null},llseek:function(d,E,I){if(v.isClosed(d))throw new v.ErrnoError(8);if(!d.seekable||!d.stream_ops.llseek)throw new v.ErrnoError(70);if(I!=0&&I!=1&&I!=2)throw new v.ErrnoError(28);return d.position=d.stream_ops.llseek(d,E,I),d.ungotten=[],d.position},read:function(d,E,I,D,O){if(D<0||O<0)throw new v.ErrnoError(28);if(v.isClosed(d))throw new v.ErrnoError(8);if((d.flags&2097155)==1)throw new v.ErrnoError(8);if(v.isDir(d.node.mode))throw new v.ErrnoError(31);if(!d.stream_ops.read)throw new v.ErrnoError(28);var V=typeof O!="undefined";if(!V)O=d.position;else if(!d.seekable)throw new v.ErrnoError(70);var ie=d.stream_ops.read(d,E,I,D,O);return V||(d.position+=ie),ie},write:function(d,E,I,D,O,V){if(D<0||O<0)throw new v.ErrnoError(28);if(v.isClosed(d))throw new v.ErrnoError(8);if((d.flags&2097155)==0)throw new v.ErrnoError(8);if(v.isDir(d.node.mode))throw new v.ErrnoError(31);if(!d.stream_ops.write)throw new v.ErrnoError(28);d.seekable&&d.flags&1024&&v.llseek(d,0,2);var ie=typeof O!="undefined";if(!ie)O=d.position;else if(!d.seekable)throw new v.ErrnoError(70);var Be=d.stream_ops.write(d,E,I,D,O,V);ie||(d.position+=Be);try{d.path&&v.trackingDelegate.onWriteToFile&&v.trackingDelegate.onWriteToFile(d.path)}catch(Ce){x("FS.trackingDelegate['onWriteToFile']('"+d.path+"') threw an exception: "+Ce.message)}return Be},allocate:function(d,E,I){if(v.isClosed(d))throw new v.ErrnoError(8);if(E<0||I<=0)throw new v.ErrnoError(28);if((d.flags&2097155)==0)throw new v.ErrnoError(8);if(!v.isFile(d.node.mode)&&!v.isDir(d.node.mode))throw new v.ErrnoError(43);if(!d.stream_ops.allocate)throw new v.ErrnoError(138);d.stream_ops.allocate(d,E,I)},mmap:function(d,E,I,D,O,V){if((O&2)!=0&&(V&2)==0&&(d.flags&2097155)!=2)throw new v.ErrnoError(2);if((d.flags&2097155)==1)throw new v.ErrnoError(2);if(!d.stream_ops.mmap)throw new v.ErrnoError(43);return d.stream_ops.mmap(d,E,I,D,O,V)},msync:function(d,E,I,D,O){return!d||!d.stream_ops.msync?0:d.stream_ops.msync(d,E,I,D,O)},munmap:function(d){return 0},ioctl:function(d,E,I){if(!d.stream_ops.ioctl)throw new v.ErrnoError(59);return d.stream_ops.ioctl(d,E,I)},readFile:function(d,E){if(E=E||{},E.flags=E.flags||0,E.encoding=E.encoding||"binary",E.encoding!=="utf8"&&E.encoding!=="binary")throw new Error('Invalid encoding type "'+E.encoding+'"');var I,D=v.open(d,E.flags),O=v.stat(d),V=O.size,ie=new Uint8Array(V);return v.read(D,ie,0,V,0),E.encoding==="utf8"?I=qe(ie,0):E.encoding==="binary"&&(I=ie),v.close(D),I},writeFile:function(d,E,I){I=I||{},I.flags=I.flags||577;var D=v.open(d,I.flags,I.mode);if(typeof E=="string"){var O=new Uint8Array(he(E)+1),V=se(E,O,0,O.length);v.write(D,O,0,V,void 0,I.canOwn)}else if(ArrayBuffer.isView(E))v.write(D,E,0,E.byteLength,void 0,I.canOwn);else throw new Error("Unsupported data type");v.close(D)},cwd:function(){return v.currentPath},chdir:function(d){var E=v.lookupPath(d,{follow:!0});if(E.node===null)throw new v.ErrnoError(44);if(!v.isDir(E.node.mode))throw new v.ErrnoError(54);var I=v.nodePermissions(E.node,"x");if(I)throw new v.ErrnoError(I);v.currentPath=E.path},createDefaultDirectories:function(){v.mkdir("/tmp"),v.mkdir("/home"),v.mkdir("/home/web_user")},createDefaultDevices:function(){v.mkdir("/dev"),v.registerDevice(v.makedev(1,3),{read:function(){return 0},write:function(E,I,D,O,V){return O}}),v.mkdev("/dev/null",v.makedev(1,3)),fs.register(v.makedev(5,0),fs.default_tty_ops),fs.register(v.makedev(6,0),fs.default_tty1_ops),v.mkdev("/dev/tty",v.makedev(5,0)),v.mkdev("/dev/tty1",v.makedev(6,0));var d=Gl();v.createDevice("/dev","random",d),v.createDevice("/dev","urandom",d),v.mkdir("/dev/shm"),v.mkdir("/dev/shm/tmp")},createSpecialDirectories:function(){v.mkdir("/proc");var d=v.mkdir("/proc/self");v.mkdir("/proc/self/fd"),v.mount({mount:function(){var E=v.createNode(d,"fd",16384|511,73);return E.node_ops={lookup:function(I,D){var O=+D,V=v.getStream(O);if(!V)throw new v.ErrnoError(8);var ie={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:function(){return V.path}}};return ie.parent=ie,ie}},E}},{},"/proc/self/fd")},createStandardStreams:function(){r.stdin?v.createDevice("/dev","stdin",r.stdin):v.symlink("/dev/tty","/dev/stdin"),r.stdout?v.createDevice("/dev","stdout",null,r.stdout):v.symlink("/dev/tty","/dev/stdout"),r.stderr?v.createDevice("/dev","stderr",null,r.stderr):v.symlink("/dev/tty1","/dev/stderr");var d=v.open("/dev/stdin",0),E=v.open("/dev/stdout",1),I=v.open("/dev/stderr",1)},ensureErrnoError:function(){v.ErrnoError||(v.ErrnoError=function(E,I){this.node=I,this.setErrno=function(D){this.errno=D},this.setErrno(E),this.message="FS error"},v.ErrnoError.prototype=new Error,v.ErrnoError.prototype.constructor=v.ErrnoError,[44].forEach(function(d){v.genericErrors[d]=new v.ErrnoError(d),v.genericErrors[d].stack="<generic error, no stack>"}))},staticInit:function(){v.ensureErrnoError(),v.nameTable=new Array(4096),v.mount(pt,{},"/"),v.createDefaultDirectories(),v.createDefaultDevices(),v.createSpecialDirectories(),v.filesystems={MEMFS:pt,NODEFS:lt}},init:function(d,E,I){v.init.initialized=!0,v.ensureErrnoError(),r.stdin=d||r.stdin,r.stdout=E||r.stdout,r.stderr=I||r.stderr,v.createStandardStreams()},quit:function(){v.init.initialized=!1;var d=r._fflush;d&&d(0);for(var E=0;E<v.streams.length;E++){var I=v.streams[E];!I||v.close(I)}},getMode:function(d,E){var I=0;return d&&(I|=292|73),E&&(I|=146),I},findObject:function(d,E){var I=v.analyzePath(d,E);return I.exists?I.object:null},analyzePath:function(d,E){try{var I=v.lookupPath(d,{follow:!E});d=I.path}catch(O){}var D={isRoot:!1,exists:!1,error:0,name:null,path:null,object:null,parentExists:!1,parentPath:null,parentObject:null};try{var I=v.lookupPath(d,{parent:!0});D.parentExists=!0,D.parentPath=I.path,D.parentObject=I.node,D.name=vt.basename(d),I=v.lookupPath(d,{follow:!E}),D.exists=!0,D.path=I.path,D.object=I.node,D.name=I.node.name,D.isRoot=I.path==="/"}catch(O){D.error=O.errno}return D},createPath:function(d,E,I,D){d=typeof d=="string"?d:v.getPath(d);for(var O=E.split("/").reverse();O.length;){var V=O.pop();if(!!V){var ie=vt.join2(d,V);try{v.mkdir(ie)}catch(Be){}d=ie}}return ie},createFile:function(d,E,I,D,O){var V=vt.join2(typeof d=="string"?d:v.getPath(d),E),ie=v.getMode(D,O);return v.create(V,ie)},createDataFile:function(d,E,I,D,O,V){var ie=E?vt.join2(typeof d=="string"?d:v.getPath(d),E):d,Be=v.getMode(D,O),Ce=v.create(ie,Be);if(I){if(typeof I=="string"){for(var _e=new Array(I.length),ot=0,wt=I.length;ot<wt;++ot)_e[ot]=I.charCodeAt(ot);I=_e}v.chmod(Ce,Be|146);var ut=v.open(Ce,577);v.write(ut,I,0,I.length,0,V),v.close(ut),v.chmod(Ce,Be)}return Ce},createDevice:function(d,E,I,D){var O=vt.join2(typeof d=="string"?d:v.getPath(d),E),V=v.getMode(!!I,!!D);v.createDevice.major||(v.createDevice.major=64);var ie=v.makedev(v.createDevice.major++,0);return v.registerDevice(ie,{open:function(Be){Be.seekable=!1},close:function(Be){D&&D.buffer&&D.buffer.length&&D(10)},read:function(Be,Ce,_e,ot,wt){for(var ut=0,nt=0;nt<ot;nt++){var It;try{It=I()}catch(ke){throw new v.ErrnoError(29)}if(It===void 0&&ut===0)throw new v.ErrnoError(6);if(It==null)break;ut++,Ce[_e+nt]=It}return ut&&(Be.node.timestamp=Date.now()),ut},write:function(Be,Ce,_e,ot,wt){for(var ut=0;ut<ot;ut++)try{D(Ce[_e+ut])}catch(nt){throw new v.ErrnoError(29)}return ot&&(Be.node.timestamp=Date.now()),ut}}),v.mkdev(O,V,ie)},forceLoadFile:function(d){if(d.isDevice||d.isFolder||d.link||d.contents)return!0;if(typeof XMLHttpRequest!="undefined")throw new Error("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.");if(p)try{d.contents=RA(p(d.url),!0),d.usedBytes=d.contents.length}catch(E){throw new v.ErrnoError(29)}else throw new Error("Cannot load without read() or XMLHttpRequest.")},createLazyFile:function(d,E,I,D,O){function V(){this.lengthKnown=!1,this.chunks=[]}if(V.prototype.get=function(ut){if(!(ut>this.length-1||ut<0)){var nt=ut%this.chunkSize,It=ut/this.chunkSize|0;return this.getter(It)[nt]}},V.prototype.setDataGetter=function(ut){this.getter=ut},V.prototype.cacheLength=function(){var ut=new XMLHttpRequest;if(ut.open("HEAD",I,!1),ut.send(null),!(ut.status>=200&&ut.status<300||ut.status===304))throw new Error("Couldn't load "+I+". Status: "+ut.status);var nt=Number(ut.getResponseHeader("Content-length")),It,ke=(It=ut.getResponseHeader("Accept-Ranges"))&&It==="bytes",Jn=(It=ut.getResponseHeader("Content-Encoding"))&&It==="gzip",Mi=1024*1024;ke||(Mi=nt);var OA=function(ps,va){if(ps>va)throw new Error("invalid range ("+ps+", "+va+") or no bytes requested!");if(va>nt-1)throw new Error("only "+nt+" bytes available! programmer error!");var Yr=new XMLHttpRequest;if(Yr.open("GET",I,!1),nt!==Mi&&Yr.setRequestHeader("Range","bytes="+ps+"-"+va),typeof Uint8Array!="undefined"&&(Yr.responseType="arraybuffer"),Yr.overrideMimeType&&Yr.overrideMimeType("text/plain; charset=x-user-defined"),Yr.send(null),!(Yr.status>=200&&Yr.status<300||Yr.status===304))throw new Error("Couldn't load "+I+". Status: "+Yr.status);return Yr.response!==void 0?new Uint8Array(Yr.response||[]):RA(Yr.responseText||"",!0)},Gr=this;Gr.setDataGetter(function(ps){var va=ps*Mi,Yr=(ps+1)*Mi-1;if(Yr=Math.min(Yr,nt-1),typeof Gr.chunks[ps]=="undefined"&&(Gr.chunks[ps]=OA(va,Yr)),typeof Gr.chunks[ps]=="undefined")throw new Error("doXHR failed!");return Gr.chunks[ps]}),(Jn||!nt)&&(Mi=nt=1,nt=this.getter(0).length,Mi=nt,S("LazyFiles on gzip forces download of the whole file when length is accessed")),this._length=nt,this._chunkSize=Mi,this.lengthKnown=!0},typeof XMLHttpRequest!="undefined"){if(!u)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";var ie=new V;Object.defineProperties(ie,{length:{get:function(){return this.lengthKnown||this.cacheLength(),this._length}},chunkSize:{get:function(){return this.lengthKnown||this.cacheLength(),this._chunkSize}}});var Be={isDevice:!1,contents:ie}}else var Be={isDevice:!1,url:I};var Ce=v.createFile(d,E,Be,D,O);Be.contents?Ce.contents=Be.contents:Be.url&&(Ce.contents=null,Ce.url=Be.url),Object.defineProperties(Ce,{usedBytes:{get:function(){return this.contents.length}}});var _e={},ot=Object.keys(Ce.stream_ops);return ot.forEach(function(wt){var ut=Ce.stream_ops[wt];_e[wt]=function(){return v.forceLoadFile(Ce),ut.apply(null,arguments)}}),_e.read=function(ut,nt,It,ke,Jn){v.forceLoadFile(Ce);var Mi=ut.node.contents;if(Jn>=Mi.length)return 0;var OA=Math.min(Mi.length-Jn,ke);if(Mi.slice)for(var Gr=0;Gr<OA;Gr++)nt[It+Gr]=Mi[Jn+Gr];else for(var Gr=0;Gr<OA;Gr++)nt[It+Gr]=Mi.get(Jn+Gr);return OA},Ce.stream_ops=_e,Ce},createPreloadedFile:function(d,E,I,D,O,V,ie,Be,Ce,_e){Browser.init();var ot=E?Gn.resolve(vt.join2(d,E)):d,wt=Ru("cp "+ot);function ut(nt){function It(Jn){_e&&_e(),Be||v.createDataFile(d,E,Jn,D,O,Ce),V&&V(),PA(wt)}var ke=!1;r.preloadPlugins.forEach(function(Jn){ke||Jn.canHandle(ot)&&(Jn.handle(nt,ot,It,function(){ie&&ie(),PA(wt)}),ke=!0)}),ke||It(nt)}xA(wt),typeof I=="string"?Browser.asyncLoad(I,function(nt){ut(nt)},ie):ut(I)},indexedDB:function(){return window.indexedDB||window.mozIndexedDB||window.webkitIndexedDB||window.msIndexedDB},DB_NAME:function(){return"EM_FS_"+window.location.pathname},DB_VERSION:20,DB_STORE_NAME:"FILE_DATA",saveFilesToDB:function(d,E,I){E=E||function(){},I=I||function(){};var D=v.indexedDB();try{var O=D.open(v.DB_NAME(),v.DB_VERSION)}catch(V){return I(V)}O.onupgradeneeded=function(){S("creating db");var ie=O.result;ie.createObjectStore(v.DB_STORE_NAME)},O.onsuccess=function(){var ie=O.result,Be=ie.transaction([v.DB_STORE_NAME],"readwrite"),Ce=Be.objectStore(v.DB_STORE_NAME),_e=0,ot=0,wt=d.length;function ut(){ot==0?E():I()}d.forEach(function(nt){var It=Ce.put(v.analyzePath(nt).object.contents,nt);It.onsuccess=function(){_e++,_e+ot==wt&&ut()},It.onerror=function(){ot++,_e+ot==wt&&ut()}}),Be.onerror=I},O.onerror=I},loadFilesFromDB:function(d,E,I){E=E||function(){},I=I||function(){};var D=v.indexedDB();try{var O=D.open(v.DB_NAME(),v.DB_VERSION)}catch(V){return I(V)}O.onupgradeneeded=I,O.onsuccess=function(){var ie=O.result;try{var Be=ie.transaction([v.DB_STORE_NAME],"readonly")}catch(nt){I(nt);return}var Ce=Be.objectStore(v.DB_STORE_NAME),_e=0,ot=0,wt=d.length;function ut(){ot==0?E():I()}d.forEach(function(nt){var It=Ce.get(nt);It.onsuccess=function(){v.analyzePath(nt).exists&&v.unlink(nt),v.createDataFile(vt.dirname(nt),vt.basename(nt),It.result,!0,!0,!0),_e++,_e+ot==wt&&ut()},It.onerror=function(){ot++,_e+ot==wt&&ut()}}),Be.onerror=I},O.onerror=I}},Tt={mappings:{},DEFAULT_POLLMASK:5,umask:511,calculateAt:function(d,E,I){if(E[0]==="/")return E;var D;if(d===-100)D=v.cwd();else{var O=v.getStream(d);if(!O)throw new v.ErrnoError(8);D=O.path}if(E.length==0){if(!I)throw new v.ErrnoError(44);return D}return vt.join2(D,E)},doStat:function(d,E,I){try{var D=d(E)}catch(O){if(O&&O.node&&vt.normalize(E)!==vt.normalize(v.getPath(O.node)))return-54;throw O}return fe[I>>2]=D.dev,fe[I+4>>2]=0,fe[I+8>>2]=D.ino,fe[I+12>>2]=D.mode,fe[I+16>>2]=D.nlink,fe[I+20>>2]=D.uid,fe[I+24>>2]=D.gid,fe[I+28>>2]=D.rdev,fe[I+32>>2]=0,Oi=[D.size>>>0,(oe=D.size,+Math.abs(oe)>=1?oe>0?(Math.min(+Math.floor(oe/4294967296),4294967295)|0)>>>0:~~+Math.ceil((oe-+(~~oe>>>0))/4294967296)>>>0:0)],fe[I+40>>2]=Oi[0],fe[I+44>>2]=Oi[1],fe[I+48>>2]=4096,fe[I+52>>2]=D.blocks,fe[I+56>>2]=D.atime.getTime()/1e3|0,fe[I+60>>2]=0,fe[I+64>>2]=D.mtime.getTime()/1e3|0,fe[I+68>>2]=0,fe[I+72>>2]=D.ctime.getTime()/1e3|0,fe[I+76>>2]=0,Oi=[D.ino>>>0,(oe=D.ino,+Math.abs(oe)>=1?oe>0?(Math.min(+Math.floor(oe/4294967296),4294967295)|0)>>>0:~~+Math.ceil((oe-+(~~oe>>>0))/4294967296)>>>0:0)],fe[I+80>>2]=Oi[0],fe[I+84>>2]=Oi[1],0},doMsync:function(d,E,I,D,O){var V=X.slice(d,d+I);v.msync(E,V,O,I,D)},doMkdir:function(d,E){return d=vt.normalize(d),d[d.length-1]==="/"&&(d=d.substr(0,d.length-1)),v.mkdir(d,E,0),0},doMknod:function(d,E,I){switch(E&61440){case 32768:case 8192:case 24576:case 4096:case 49152:break;default:return-28}return v.mknod(d,E,I),0},doReadlink:function(d,E,I){if(I<=0)return-28;var D=v.readlink(d),O=Math.min(I,he(D)),V=pe[E+O];return Qe(D,E,I+1),pe[E+O]=V,O},doAccess:function(d,E){if(E&~7)return-28;var I,D=v.lookupPath(d,{follow:!0});if(I=D.node,!I)return-44;var O="";return E&4&&(O+="r"),E&2&&(O+="w"),E&1&&(O+="x"),O&&v.nodePermissions(I,O)?-2:0},doDup:function(d,E,I){var D=v.getStream(I);return D&&v.close(D),v.open(d,E,0,I,I).fd},doReadv:function(d,E,I,D){for(var O=0,V=0;V<I;V++){var ie=fe[E+V*8>>2],Be=fe[E+(V*8+4)>>2],Ce=v.read(d,pe,ie,Be,D);if(Ce<0)return-1;if(O+=Ce,Ce<Be)break}return O},doWritev:function(d,E,I,D){for(var O=0,V=0;V<I;V++){var ie=fe[E+V*8>>2],Be=fe[E+(V*8+4)>>2],Ce=v.write(d,pe,ie,Be,D);if(Ce<0)return-1;O+=Ce}return O},varargs:void 0,get:function(){Tt.varargs+=4;var d=fe[Tt.varargs-4>>2];return d},getStr:function(d){var E=re(d);return E},getStreamFromFD:function(d){var E=v.getStream(d);if(!E)throw new v.ErrnoError(8);return E},get64:function(d,E){return d}};function Tu(d,E){try{return d=Tt.getStr(d),v.chmod(d,E),0}catch(I){return(typeof v=="undefined"||!(I instanceof v.ErrnoError))&&Sr(I),-I.errno}}function Yl(d){return fe[Rt()>>2]=d,d}function Sh(d,E,I){Tt.varargs=I;try{var D=Tt.getStreamFromFD(d);switch(E){case 0:{var O=Tt.get();if(O<0)return-28;var V;return V=v.open(D.path,D.flags,0,O),V.fd}case 1:case 2:return 0;case 3:return D.flags;case 4:{var O=Tt.get();return D.flags|=O,0}case 12:{var O=Tt.get(),ie=0;return be[O+ie>>1]=2,0}case 13:case 14:return 0;case 16:case 8:return-28;case 9:return Yl(28),-1;default:return-28}}catch(Be){return(typeof v=="undefined"||!(Be instanceof v.ErrnoError))&&Sr(Be),-Be.errno}}function kh(d,E){try{var I=Tt.getStreamFromFD(d);return Tt.doStat(v.stat,I.path,E)}catch(D){return(typeof v=="undefined"||!(D instanceof v.ErrnoError))&&Sr(D),-D.errno}}function xh(d,E,I){Tt.varargs=I;try{var D=Tt.getStreamFromFD(d);switch(E){case 21509:case 21505:return D.tty?0:-59;case 21510:case 21511:case 21512:case 21506:case 21507:case 21508:return D.tty?0:-59;case 21519:{if(!D.tty)return-59;var O=Tt.get();return fe[O>>2]=0,0}case 21520:return D.tty?-28:-59;case 21531:{var O=Tt.get();return v.ioctl(D,E,O)}case 21523:return D.tty?0:-59;case 21524:return D.tty?0:-59;default:Sr("bad ioctl syscall "+E)}}catch(V){return(typeof v=="undefined"||!(V instanceof v.ErrnoError))&&Sr(V),-V.errno}}function Ph(d,E,I){Tt.varargs=I;try{var D=Tt.getStr(d),O=I?Tt.get():0,V=v.open(D,E,O);return V.fd}catch(ie){return(typeof v=="undefined"||!(ie instanceof v.ErrnoError))&&Sr(ie),-ie.errno}}function Dh(d,E){try{return d=Tt.getStr(d),E=Tt.getStr(E),v.rename(d,E),0}catch(I){return(typeof v=="undefined"||!(I instanceof v.ErrnoError))&&Sr(I),-I.errno}}function G(d){try{return d=Tt.getStr(d),v.rmdir(d),0}catch(E){return(typeof v=="undefined"||!(E instanceof v.ErrnoError))&&Sr(E),-E.errno}}function yt(d,E){try{return d=Tt.getStr(d),Tt.doStat(v.stat,d,E)}catch(I){return(typeof v=="undefined"||!(I instanceof v.ErrnoError))&&Sr(I),-I.errno}}function DA(d){try{return d=Tt.getStr(d),v.unlink(d),0}catch(E){return(typeof v=="undefined"||!(E instanceof v.ErrnoError))&&Sr(E),-E.errno}}function $i(d,E,I){X.copyWithin(d,E,E+I)}function ql(d){try{return A.grow(d-ve.byteLength+65535>>>16),mi(A.buffer),1}catch(E){}}function $e(d){var E=X.length;d=d>>>0;var I=2147483648;if(d>I)return!1;for(var D=1;D<=4;D*=2){var O=E*(1+.2/D);O=Math.min(O,d+100663296);var V=Math.min(I,xe(Math.max(d,O),65536)),ie=ql(V);if(ie)return!0}return!1}function wa(d){try{var E=Tt.getStreamFromFD(d);return v.close(E),0}catch(I){return(typeof v=="undefined"||!(I instanceof v.ErrnoError))&&Sr(I),I.errno}}function Ou(d,E){try{var I=Tt.getStreamFromFD(d),D=I.tty?2:v.isDir(I.mode)?3:v.isLink(I.mode)?7:4;return pe[E>>0]=D,0}catch(O){return(typeof v=="undefined"||!(O instanceof v.ErrnoError))&&Sr(O),O.errno}}function SE(d,E,I,D){try{var O=Tt.getStreamFromFD(d),V=Tt.doReadv(O,E,I);return fe[D>>2]=V,0}catch(ie){return(typeof v=="undefined"||!(ie instanceof v.ErrnoError))&&Sr(ie),ie.errno}}function Rh(d,E,I,D,O){try{var V=Tt.getStreamFromFD(d),ie=4294967296,Be=I*ie+(E>>>0),Ce=9007199254740992;return Be<=-Ce||Be>=Ce?-61:(v.llseek(V,Be,D),Oi=[V.position>>>0,(oe=V.position,+Math.abs(oe)>=1?oe>0?(Math.min(+Math.floor(oe/4294967296),4294967295)|0)>>>0:~~+Math.ceil((oe-+(~~oe>>>0))/4294967296)>>>0:0)],fe[O>>2]=Oi[0],fe[O+4>>2]=Oi[1],V.getdents&&Be===0&&D===0&&(V.getdents=null),0)}catch(_e){return(typeof v=="undefined"||!(_e instanceof v.ErrnoError))&&Sr(_e),_e.errno}}function kE(d,E,I,D){try{var O=Tt.getStreamFromFD(d),V=Tt.doWritev(O,E,I);return fe[D>>2]=V,0}catch(ie){return(typeof v=="undefined"||!(ie instanceof v.ErrnoError))&&Sr(ie),ie.errno}}function gr(d){J(d)}function Yn(d){var E=Date.now()/1e3|0;return d&&(fe[d>>2]=E),E}function Jl(){if(Jl.called)return;Jl.called=!0;var d=new Date().getFullYear(),E=new Date(d,0,1),I=new Date(d,6,1),D=E.getTimezoneOffset(),O=I.getTimezoneOffset(),V=Math.max(D,O);fe[iQ()>>2]=V*60,fe[rQ()>>2]=Number(D!=O);function ie(wt){var ut=wt.toTimeString().match(/\(([A-Za-z ]+)\)$/);return ut?ut[1]:"GMT"}var Be=ie(E),Ce=ie(I),_e=Fe(Be),ot=Fe(Ce);O<D?(fe[Yu()>>2]=_e,fe[Yu()+4>>2]=ot):(fe[Yu()>>2]=ot,fe[Yu()+4>>2]=_e)}function Fh(d){Jl();var E=Date.UTC(fe[d+20>>2]+1900,fe[d+16>>2],fe[d+12>>2],fe[d+8>>2],fe[d+4>>2],fe[d>>2],0),I=new Date(E);fe[d+24>>2]=I.getUTCDay();var D=Date.UTC(I.getUTCFullYear(),0,1,0,0,0,0),O=(I.getTime()-D)/(1e3*60*60*24)|0;return fe[d+28>>2]=O,I.getTime()/1e3|0}var Vs=function(d,E,I,D){d||(d=this),this.parent=d,this.mount=d.mount,this.mounted=null,this.id=v.nextInode++,this.name=E,this.mode=I,this.node_ops={},this.stream_ops={},this.rdev=D},Ba=292|73,En=146;if(Object.defineProperties(Vs.prototype,{read:{get:function(){return(this.mode&Ba)===Ba},set:function(d){d?this.mode|=Ba:this.mode&=~Ba}},write:{get:function(){return(this.mode&En)===En},set:function(d){d?this.mode|=En:this.mode&=~En}},isFolder:{get:function(){return v.isDir(this.mode)}},isDevice:{get:function(){return v.isChrdev(this.mode)}}}),v.FSNode=Vs,v.staticInit(),g){var Oe=W_,Mu=require("path");lt.staticInit()}if(g){var Wl=function(d){return function(){try{return d.apply(this,arguments)}catch(E){throw E.code?new v.ErrnoError(xo[E.code]):E}}},Xs=Object.assign({},v);for(var zl in mn)v[zl]=Wl(mn[zl])}else throw new Error("NODERAWFS is currently only supported on Node.js environment.");function RA(d,E,I){var D=I>0?I:he(d)+1,O=new Array(D),V=se(d,O,0,O.length);return E&&(O.length=V),O}var Uu=typeof atob=="function"?atob:function(d){var E="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",I="",D,O,V,ie,Be,Ce,_e,ot=0;d=d.replace(/[^A-Za-z0-9\+\/\=]/g,"");do ie=E.indexOf(d.charAt(ot++)),Be=E.indexOf(d.charAt(ot++)),Ce=E.indexOf(d.charAt(ot++)),_e=E.indexOf(d.charAt(ot++)),D=ie<<2|Be>>4,O=(Be&15)<<4|Ce>>2,V=(Ce&3)<<6|_e,I=I+String.fromCharCode(D),Ce!==64&&(I=I+String.fromCharCode(O)),_e!==64&&(I=I+String.fromCharCode(V));while(ot<d.length);return I};function Ku(d){if(typeof g=="boolean"&&g){var E;try{E=Buffer.from(d,"base64")}catch(V){E=new Buffer(d,"base64")}return new Uint8Array(E.buffer,E.byteOffset,E.byteLength)}try{for(var I=Uu(d),D=new Uint8Array(I.length),O=0;O<I.length;++O)D[O]=I.charCodeAt(O);return D}catch(V){throw new Error("Converting base64 string to bytes failed.")}}function ba(d){if(!!Fu(d))return Ku(d.slice(jl.length))}var Qa={s:Lu,p:Tu,e:Sh,k:kh,o:xh,q:Ph,i:Dh,r:G,c:yt,h:DA,l:$i,m:$e,f:wa,j:Ou,g:SE,n:Rh,d:kE,a:gr,b:Yn,t:Fh},it=vh(),Po=r.___wasm_call_ctors=it.v,FA=r._zip_ext_count_symlinks=it.w,_l=r._zip_file_get_external_attributes=it.x,Zs=r._zipstruct_stat=it.y,Vl=r._zipstruct_statS=it.z,xE=r._zipstruct_stat_name=it.A,Nh=r._zipstruct_stat_index=it.B,Hu=r._zipstruct_stat_size=it.C,Lh=r._zipstruct_stat_mtime=it.D,PE=r._zipstruct_stat_crc=it.E,Xl=r._zipstruct_error=it.F,DE=r._zipstruct_errorS=it.G,ju=r._zipstruct_error_code_zip=it.H,NA=r._zipstruct_stat_comp_size=it.I,Lr=r._zipstruct_stat_comp_method=it.J,RE=r._zip_close=it.K,$s=r._zip_delete=it.L,eo=r._zip_dir_add=it.M,Gu=r._zip_discard=it.N,LA=r._zip_error_init_with_code=it.O,R=r._zip_get_error=it.P,q=r._zip_file_get_error=it.Q,de=r._zip_error_strerror=it.R,He=r._zip_fclose=it.S,Te=r._zip_file_add=it.T,Xe=r._free=it.U,Et=r._malloc=it.V,Rt=r.___errno_location=it.W,qn=r._zip_source_error=it.X,Jb=r._zip_source_seek=it.Y,xO=r._zip_file_set_external_attributes=it.Z,PO=r._zip_file_set_mtime=it._,Wb=r._zip_fopen=it.$,DO=r._zip_fopen_index=it.aa,RO=r._zip_fread=it.ba,zb=r._zip_get_name=it.ca,FO=r._zip_get_num_entries=it.da,NO=r._zip_source_read=it.ea,_b=r._zip_name_locate=it.fa,LO=r._zip_open=it.ga,TO=r._zip_open_from_source=it.ha,Vb=r._zip_set_file_compression=it.ia,OO=r._zip_source_buffer=it.ja,MO=r._zip_source_buffer_create=it.ka,UO=r._zip_source_close=it.la,KO=r._zip_source_free=it.ma,Xb=r._zip_source_keep=it.na,Zb=r._zip_source_open=it.oa,$b=r._zip_source_set_mtime=it.qa,eQ=r._zip_source_tell=it.ra,tQ=r._zip_stat=it.sa,HO=r._zip_stat_index=it.ta,Yu=r.__get_tzname=it.ua,rQ=r.__get_daylight=it.va,iQ=r.__get_timezone=it.wa,FE=r.stackSave=it.xa,NE=r.stackRestore=it.ya,B=r.stackAlloc=it.za;r.cwrap=Ee,r.getValue=Z;var Ke;ya=function d(){Ke||TA(),Ke||(ya=d)};function TA(d){if(d=d||a,gs>0||(vr(),gs>0))return;function E(){Ke||(Ke=!0,r.calledRun=!0,!ne&&(Hn(),i(r),r.onRuntimeInitialized&&r.onRuntimeInitialized(),us()))}r.setStatus?(r.setStatus("Running..."),setTimeout(function(){setTimeout(function(){r.setStatus("")},1),E()},1)):E()}if(r.run=TA,r.preInit)for(typeof r.preInit=="function"&&(r.preInit=[r.preInit]);r.preInit.length>0;)r.preInit.pop()();return TA(),e}}();typeof Jw=="object"&&typeof XP=="object"?XP.exports=ZP:typeof define=="function"&&define.amd?define([],function(){return ZP}):typeof Jw=="object"&&(Jw.createModule=ZP)});var E5=w((Pat,m5)=>{function GDe(t,e){for(var r=-1,i=t==null?0:t.length,n=Array(i);++r<i;)n[r]=e(t[r],r,t);return n}m5.exports=GDe});var Os=w((Dat,I5)=>{var YDe=Array.isArray;I5.exports=YDe});var v5=w((Rat,y5)=>{var w5=Kc(),qDe=E5(),JDe=Os(),WDe=Id(),zDe=1/0,B5=w5?w5.prototype:void 0,b5=B5?B5.toString:void 0;function Q5(t){if(typeof t=="string")return t;if(JDe(t))return qDe(t,Q5)+"";if(WDe(t))return b5?b5.call(t):"";var e=t+"";return e=="0"&&1/t==-zDe?"-0":e}y5.exports=Q5});var nf=w((Fat,S5)=>{var _De=v5();function VDe(t){return t==null?"":_De(t)}S5.exports=VDe});var sD=w((Nat,k5)=>{function XDe(t,e,r){var i=-1,n=t.length;e<0&&(e=-e>n?0:n+e),r=r>n?n:r,r<0&&(r+=n),n=e>r?0:r-e>>>0,e>>>=0;for(var s=Array(n);++i<n;)s[i]=t[i+e];return s}k5.exports=XDe});var P5=w((Lat,x5)=>{var ZDe=sD();function $De(t,e,r){var i=t.length;return r=r===void 0?i:r,!e&&r>=i?t:ZDe(t,e,r)}x5.exports=$De});var oD=w((Tat,D5)=>{var eRe="\\ud800-\\udfff",tRe="\\u0300-\\u036f",rRe="\\ufe20-\\ufe2f",iRe="\\u20d0-\\u20ff",nRe=tRe+rRe+iRe,sRe="\\ufe0e\\ufe0f",oRe="\\u200d",aRe=RegExp("["+oRe+eRe+nRe+sRe+"]");function ARe(t){return aRe.test(t)}D5.exports=ARe});var F5=w((Oat,R5)=>{function lRe(t){return t.split("")}R5.exports=lRe});var H5=w((Mat,N5)=>{var L5="\\ud800-\\udfff",cRe="\\u0300-\\u036f",uRe="\\ufe20-\\ufe2f",gRe="\\u20d0-\\u20ff",fRe=cRe+uRe+gRe,hRe="\\ufe0e\\ufe0f",pRe="["+L5+"]",aD="["+fRe+"]",AD="\\ud83c[\\udffb-\\udfff]",dRe="(?:"+aD+"|"+AD+")",T5="[^"+L5+"]",O5="(?:\\ud83c[\\udde6-\\uddff]){2}",M5="[\\ud800-\\udbff][\\udc00-\\udfff]",CRe="\\u200d",U5=dRe+"?",K5="["+hRe+"]?",mRe="(?:"+CRe+"(?:"+[T5,O5,M5].join("|")+")"+K5+U5+")*",ERe=K5+U5+mRe,IRe="(?:"+[T5+aD+"?",aD,O5,M5,pRe].join("|")+")",yRe=RegExp(AD+"(?="+AD+")|"+IRe+ERe,"g");function wRe(t){return t.match(yRe)||[]}N5.exports=wRe});var G5=w((Uat,j5)=>{var BRe=F5(),bRe=oD(),QRe=H5();function vRe(t){return bRe(t)?QRe(t):BRe(t)}j5.exports=vRe});var q5=w((Kat,Y5)=>{var SRe=P5(),kRe=oD(),xRe=G5(),PRe=nf();function DRe(t){return function(e){e=PRe(e);var r=kRe(e)?xRe(e):void 0,i=r?r[0]:e.charAt(0),n=r?SRe(r,1).join(""):e.slice(1);return i[t]()+n}}Y5.exports=DRe});var W5=w((Hat,J5)=>{var RRe=q5(),FRe=RRe("toUpperCase");J5.exports=FRe});var tB=w((jat,z5)=>{var NRe=nf(),LRe=W5();function TRe(t){return LRe(NRe(t).toLowerCase())}z5.exports=TRe});var _5=w((Gat,rB)=>{function ORe(){var t=0,e=1,r=2,i=3,n=4,s=5,o=6,a=7,l=8,c=9,u=10,g=11,f=12,h=13,p=14,m=15,y=16,Q=17,S=0,x=1,M=2,Y=3,U=4;function J(A,ne){return 55296<=A.charCodeAt(ne)&&A.charCodeAt(ne)<=56319&&56320<=A.charCodeAt(ne+1)&&A.charCodeAt(ne+1)<=57343}function W(A,ne){ne===void 0&&(ne=0);var le=A.charCodeAt(ne);if(55296<=le&&le<=56319&&ne<A.length-1){var Ae=le,T=A.charCodeAt(ne+1);return 56320<=T&&T<=57343?(Ae-55296)*1024+(T-56320)+65536:Ae}if(56320<=le&&le<=57343&&ne>=1){var Ae=A.charCodeAt(ne-1),T=le;return 55296<=Ae&&Ae<=56319?(Ae-55296)*1024+(T-56320)+65536:T}return le}function ee(A,ne,le){var Ae=[A].concat(ne).concat([le]),T=Ae[Ae.length-2],L=le,Ee=Ae.lastIndexOf(p);if(Ee>1&&Ae.slice(1,Ee).every(function(re){return re==i})&&[i,h,Q].indexOf(A)==-1)return M;var we=Ae.lastIndexOf(n);if(we>0&&Ae.slice(1,we).every(function(re){return re==n})&&[f,n].indexOf(T)==-1)return Ae.filter(function(re){return re==n}).length%2==1?Y:U;if(T==t&&L==e)return S;if(T==r||T==t||T==e)return L==p&&ne.every(function(re){return re==i})?M:x;if(L==r||L==t||L==e)return x;if(T==o&&(L==o||L==a||L==c||L==u))return S;if((T==c||T==a)&&(L==a||L==l))return S;if((T==u||T==l)&&L==l)return S;if(L==i||L==m)return S;if(L==s)return S;if(T==f)return S;var qe=Ae.indexOf(i)!=-1?Ae.lastIndexOf(i)-1:Ae.length-2;return[h,Q].indexOf(Ae[qe])!=-1&&Ae.slice(qe+1,-1).every(function(re){return re==i})&&L==p||T==m&&[y,Q].indexOf(L)!=-1?S:ne.indexOf(n)!=-1?M:T==n&&L==n?S:x}this.nextBreak=function(A,ne){if(ne===void 0&&(ne=0),ne<0)return 0;if(ne>=A.length-1)return A.length;for(var le=Z(W(A,ne)),Ae=[],T=ne+1;T<A.length;T++)if(!J(A,T-1)){var L=Z(W(A,T));if(ee(le,Ae,L))return T;Ae.push(L)}return A.length},this.splitGraphemes=function(A){for(var ne=[],le=0,Ae;(Ae=this.nextBreak(A,le))<A.length;)ne.push(A.slice(le,Ae)),le=Ae;return le<A.length&&ne.push(A.slice(le)),ne},this.iterateGraphemes=function(A){var ne=0,le={next:function(){var Ae,T;return(T=this.nextBreak(A,ne))<A.length?(Ae=A.slice(ne,T),ne=T,{value:Ae,done:!1}):ne<A.length?(Ae=A.slice(ne),ne=A.length,{value:Ae,done:!1}):{value:void 0,done:!0}}.bind(this)};return typeof Symbol!="undefined"&&Symbol.iterator&&(le[Symbol.iterator]=function(){return le}),le},this.countGraphemes=function(A){for(var ne=0,le=0,Ae;(Ae=this.nextBreak(A,le))<A.length;)le=Ae,ne++;return le<A.length&&ne++,ne};function Z(A){return 1536<=A&&A<=1541||A==1757||A==1807||A==2274||A==3406||A==69821||70082<=A&&A<=70083||A==72250||72326<=A&&A<=72329||A==73030?f:A==13?t:A==10?e:0<=A&&A<=9||11<=A&&A<=12||14<=A&&A<=31||127<=A&&A<=159||A==173||A==1564||A==6158||A==8203||8206<=A&&A<=8207||A==8232||A==8233||8234<=A&&A<=8238||8288<=A&&A<=8292||A==8293||8294<=A&&A<=8303||55296<=A&&A<=57343||A==65279||65520<=A&&A<=65528||65529<=A&&A<=65531||113824<=A&&A<=113827||119155<=A&&A<=119162||A==917504||A==917505||917506<=A&&A<=917535||917632<=A&&A<=917759||918e3<=A&&A<=921599?r:768<=A&&A<=879||1155<=A&&A<=1159||1160<=A&&A<=1161||1425<=A&&A<=1469||A==1471||1473<=A&&A<=1474||1476<=A&&A<=1477||A==1479||1552<=A&&A<=1562||1611<=A&&A<=1631||A==1648||1750<=A&&A<=1756||1759<=A&&A<=1764||1767<=A&&A<=1768||1770<=A&&A<=1773||A==1809||1840<=A&&A<=1866||1958<=A&&A<=1968||2027<=A&&A<=2035||2070<=A&&A<=2073||2075<=A&&A<=2083||2085<=A&&A<=2087||2089<=A&&A<=2093||2137<=A&&A<=2139||2260<=A&&A<=2273||2275<=A&&A<=2306||A==2362||A==2364||2369<=A&&A<=2376||A==2381||2385<=A&&A<=2391||2402<=A&&A<=2403||A==2433||A==2492||A==2494||2497<=A&&A<=2500||A==2509||A==2519||2530<=A&&A<=2531||2561<=A&&A<=2562||A==2620||2625<=A&&A<=2626||2631<=A&&A<=2632||2635<=A&&A<=2637||A==2641||2672<=A&&A<=2673||A==2677||2689<=A&&A<=2690||A==2748||2753<=A&&A<=2757||2759<=A&&A<=2760||A==2765||2786<=A&&A<=2787||2810<=A&&A<=2815||A==2817||A==2876||A==2878||A==2879||2881<=A&&A<=2884||A==2893||A==2902||A==2903||2914<=A&&A<=2915||A==2946||A==3006||A==3008||A==3021||A==3031||A==3072||3134<=A&&A<=3136||3142<=A&&A<=3144||3146<=A&&A<=3149||3157<=A&&A<=3158||3170<=A&&A<=3171||A==3201||A==3260||A==3263||A==3266||A==3270||3276<=A&&A<=3277||3285<=A&&A<=3286||3298<=A&&A<=3299||3328<=A&&A<=3329||3387<=A&&A<=3388||A==3390||3393<=A&&A<=3396||A==3405||A==3415||3426<=A&&A<=3427||A==3530||A==3535||3538<=A&&A<=3540||A==3542||A==3551||A==3633||3636<=A&&A<=3642||3655<=A&&A<=3662||A==3761||3764<=A&&A<=3769||3771<=A&&A<=3772||3784<=A&&A<=3789||3864<=A&&A<=3865||A==3893||A==3895||A==3897||3953<=A&&A<=3966||3968<=A&&A<=3972||3974<=A&&A<=3975||3981<=A&&A<=3991||3993<=A&&A<=4028||A==4038||4141<=A&&A<=4144||4146<=A&&A<=4151||4153<=A&&A<=4154||4157<=A&&A<=4158||4184<=A&&A<=4185||4190<=A&&A<=4192||4209<=A&&A<=4212||A==4226||4229<=A&&A<=4230||A==4237||A==4253||4957<=A&&A<=4959||5906<=A&&A<=5908||5938<=A&&A<=5940||5970<=A&&A<=5971||6002<=A&&A<=6003||6068<=A&&A<=6069||6071<=A&&A<=6077||A==6086||6089<=A&&A<=6099||A==6109||6155<=A&&A<=6157||6277<=A&&A<=6278||A==6313||6432<=A&&A<=6434||6439<=A&&A<=6440||A==6450||6457<=A&&A<=6459||6679<=A&&A<=6680||A==6683||A==6742||6744<=A&&A<=6750||A==6752||A==6754||6757<=A&&A<=6764||6771<=A&&A<=6780||A==6783||6832<=A&&A<=6845||A==6846||6912<=A&&A<=6915||A==6964||6966<=A&&A<=6970||A==6972||A==6978||7019<=A&&A<=7027||7040<=A&&A<=7041||7074<=A&&A<=7077||7080<=A&&A<=7081||7083<=A&&A<=7085||A==7142||7144<=A&&A<=7145||A==7149||7151<=A&&A<=7153||7212<=A&&A<=7219||7222<=A&&A<=7223||7376<=A&&A<=7378||7380<=A&&A<=7392||7394<=A&&A<=7400||A==7405||A==7412||7416<=A&&A<=7417||7616<=A&&A<=7673||7675<=A&&A<=7679||A==8204||8400<=A&&A<=8412||8413<=A&&A<=8416||A==8417||8418<=A&&A<=8420||8421<=A&&A<=8432||11503<=A&&A<=11505||A==11647||11744<=A&&A<=11775||12330<=A&&A<=12333||12334<=A&&A<=12335||12441<=A&&A<=12442||A==42607||42608<=A&&A<=42610||42612<=A&&A<=42621||42654<=A&&A<=42655||42736<=A&&A<=42737||A==43010||A==43014||A==43019||43045<=A&&A<=43046||43204<=A&&A<=43205||43232<=A&&A<=43249||43302<=A&&A<=43309||43335<=A&&A<=43345||43392<=A&&A<=43394||A==43443||43446<=A&&A<=43449||A==43452||A==43493||43561<=A&&A<=43566||43569<=A&&A<=43570||43573<=A&&A<=43574||A==43587||A==43596||A==43644||A==43696||43698<=A&&A<=43700||43703<=A&&A<=43704||43710<=A&&A<=43711||A==43713||43756<=A&&A<=43757||A==43766||A==44005||A==44008||A==44013||A==64286||65024<=A&&A<=65039||65056<=A&&A<=65071||65438<=A&&A<=65439||A==66045||A==66272||66422<=A&&A<=66426||68097<=A&&A<=68099||68101<=A&&A<=68102||68108<=A&&A<=68111||68152<=A&&A<=68154||A==68159||68325<=A&&A<=68326||A==69633||69688<=A&&A<=69702||69759<=A&&A<=69761||69811<=A&&A<=69814||69817<=A&&A<=69818||69888<=A&&A<=69890||69927<=A&&A<=69931||69933<=A&&A<=69940||A==70003||70016<=A&&A<=70017||70070<=A&&A<=70078||70090<=A&&A<=70092||70191<=A&&A<=70193||A==70196||70198<=A&&A<=70199||A==70206||A==70367||70371<=A&&A<=70378||70400<=A&&A<=70401||A==70460||A==70462||A==70464||A==70487||70502<=A&&A<=70508||70512<=A&&A<=70516||70712<=A&&A<=70719||70722<=A&&A<=70724||A==70726||A==70832||70835<=A&&A<=70840||A==70842||A==70845||70847<=A&&A<=70848||70850<=A&&A<=70851||A==71087||71090<=A&&A<=71093||71100<=A&&A<=71101||71103<=A&&A<=71104||71132<=A&&A<=71133||71219<=A&&A<=71226||A==71229||71231<=A&&A<=71232||A==71339||A==71341||71344<=A&&A<=71349||A==71351||71453<=A&&A<=71455||71458<=A&&A<=71461||71463<=A&&A<=71467||72193<=A&&A<=72198||72201<=A&&A<=72202||72243<=A&&A<=72248||72251<=A&&A<=72254||A==72263||72273<=A&&A<=72278||72281<=A&&A<=72283||72330<=A&&A<=72342||72344<=A&&A<=72345||72752<=A&&A<=72758||72760<=A&&A<=72765||A==72767||72850<=A&&A<=72871||72874<=A&&A<=72880||72882<=A&&A<=72883||72885<=A&&A<=72886||73009<=A&&A<=73014||A==73018||73020<=A&&A<=73021||73023<=A&&A<=73029||A==73031||92912<=A&&A<=92916||92976<=A&&A<=92982||94095<=A&&A<=94098||113821<=A&&A<=113822||A==119141||119143<=A&&A<=119145||119150<=A&&A<=119154||119163<=A&&A<=119170||119173<=A&&A<=119179||119210<=A&&A<=119213||119362<=A&&A<=119364||121344<=A&&A<=121398||121403<=A&&A<=121452||A==121461||A==121476||121499<=A&&A<=121503||121505<=A&&A<=121519||122880<=A&&A<=122886||122888<=A&&A<=122904||122907<=A&&A<=122913||122915<=A&&A<=122916||122918<=A&&A<=122922||125136<=A&&A<=125142||125252<=A&&A<=125258||917536<=A&&A<=917631||917760<=A&&A<=917999?i:127462<=A&&A<=127487?n:A==2307||A==2363||2366<=A&&A<=2368||2377<=A&&A<=2380||2382<=A&&A<=2383||2434<=A&&A<=2435||2495<=A&&A<=2496||2503<=A&&A<=2504||2507<=A&&A<=2508||A==2563||2622<=A&&A<=2624||A==2691||2750<=A&&A<=2752||A==2761||2763<=A&&A<=2764||2818<=A&&A<=2819||A==2880||2887<=A&&A<=2888||2891<=A&&A<=2892||A==3007||3009<=A&&A<=3010||3014<=A&&A<=3016||3018<=A&&A<=3020||3073<=A&&A<=3075||3137<=A&&A<=3140||3202<=A&&A<=3203||A==3262||3264<=A&&A<=3265||3267<=A&&A<=3268||3271<=A&&A<=3272||3274<=A&&A<=3275||3330<=A&&A<=3331||3391<=A&&A<=3392||3398<=A&&A<=3400||3402<=A&&A<=3404||3458<=A&&A<=3459||3536<=A&&A<=3537||3544<=A&&A<=3550||3570<=A&&A<=3571||A==3635||A==3763||3902<=A&&A<=3903||A==3967||A==4145||4155<=A&&A<=4156||4182<=A&&A<=4183||A==4228||A==6070||6078<=A&&A<=6085||6087<=A&&A<=6088||6435<=A&&A<=6438||6441<=A&&A<=6443||6448<=A&&A<=6449||6451<=A&&A<=6456||6681<=A&&A<=6682||A==6741||A==6743||6765<=A&&A<=6770||A==6916||A==6965||A==6971||6973<=A&&A<=6977||6979<=A&&A<=6980||A==7042||A==7073||7078<=A&&A<=7079||A==7082||A==7143||7146<=A&&A<=7148||A==7150||7154<=A&&A<=7155||7204<=A&&A<=7211||7220<=A&&A<=7221||A==7393||7410<=A&&A<=7411||A==7415||43043<=A&&A<=43044||A==43047||43136<=A&&A<=43137||43188<=A&&A<=43203||43346<=A&&A<=43347||A==43395||43444<=A&&A<=43445||43450<=A&&A<=43451||43453<=A&&A<=43456||43567<=A&&A<=43568||43571<=A&&A<=43572||A==43597||A==43755||43758<=A&&A<=43759||A==43765||44003<=A&&A<=44004||44006<=A&&A<=44007||44009<=A&&A<=44010||A==44012||A==69632||A==69634||A==69762||69808<=A&&A<=69810||69815<=A&&A<=69816||A==69932||A==70018||70067<=A&&A<=70069||70079<=A&&A<=70080||70188<=A&&A<=70190||70194<=A&&A<=70195||A==70197||70368<=A&&A<=70370||70402<=A&&A<=70403||A==70463||70465<=A&&A<=70468||70471<=A&&A<=70472||70475<=A&&A<=70477||70498<=A&&A<=70499||70709<=A&&A<=70711||70720<=A&&A<=70721||A==70725||70833<=A&&A<=70834||A==70841||70843<=A&&A<=70844||A==70846||A==70849||71088<=A&&A<=71089||71096<=A&&A<=71099||A==71102||71216<=A&&A<=71218||71227<=A&&A<=71228||A==71230||A==71340||71342<=A&&A<=71343||A==71350||71456<=A&&A<=71457||A==71462||72199<=A&&A<=72200||A==72249||72279<=A&&A<=72280||A==72343||A==72751||A==72766||A==72873||A==72881||A==72884||94033<=A&&A<=94078||A==119142||A==119149?s:4352<=A&&A<=4447||43360<=A&&A<=43388?o:4448<=A&&A<=4519||55216<=A&&A<=55238?a:4520<=A&&A<=4607||55243<=A&&A<=55291?l:A==44032||A==44060||A==44088||A==44116||A==44144||A==44172||A==44200||A==44228||A==44256||A==44284||A==44312||A==44340||A==44368||A==44396||A==44424||A==44452||A==44480||A==44508||A==44536||A==44564||A==44592||A==44620||A==44648||A==44676||A==44704||A==44732||A==44760||A==44788||A==44816||A==44844||A==44872||A==44900||A==44928||A==44956||A==44984||A==45012||A==45040||A==45068||A==45096||A==45124||A==45152||A==45180||A==45208||A==45236||A==45264||A==45292||A==45320||A==45348||A==45376||A==45404||A==45432||A==45460||A==45488||A==45516||A==45544||A==45572||A==45600||A==45628||A==45656||A==45684||A==45712||A==45740||A==45768||A==45796||A==45824||A==45852||A==45880||A==45908||A==45936||A==45964||A==45992||A==46020||A==46048||A==46076||A==46104||A==46132||A==46160||A==46188||A==46216||A==46244||A==46272||A==46300||A==46328||A==46356||A==46384||A==46412||A==46440||A==46468||A==46496||A==46524||A==46552||A==46580||A==46608||A==46636||A==46664||A==46692||A==46720||A==46748||A==46776||A==46804||A==46832||A==46860||A==46888||A==46916||A==46944||A==46972||A==47e3||A==47028||A==47056||A==47084||A==47112||A==47140||A==47168||A==47196||A==47224||A==47252||A==47280||A==47308||A==47336||A==47364||A==47392||A==47420||A==47448||A==47476||A==47504||A==47532||A==47560||A==47588||A==47616||A==47644||A==47672||A==47700||A==47728||A==47756||A==47784||A==47812||A==47840||A==47868||A==47896||A==47924||A==47952||A==47980||A==48008||A==48036||A==48064||A==48092||A==48120||A==48148||A==48176||A==48204||A==48232||A==48260||A==48288||A==48316||A==48344||A==48372||A==48400||A==48428||A==48456||A==48484||A==48512||A==48540||A==48568||A==48596||A==48624||A==48652||A==48680||A==48708||A==48736||A==48764||A==48792||A==48820||A==48848||A==48876||A==48904||A==48932||A==48960||A==48988||A==49016||A==49044||A==49072||A==49100||A==49128||A==49156||A==49184||A==49212||A==49240||A==49268||A==49296||A==49324||A==49352||A==49380||A==49408||A==49436||A==49464||A==49492||A==49520||A==49548||A==49576||A==49604||A==49632||A==49660||A==49688||A==49716||A==49744||A==49772||A==49800||A==49828||A==49856||A==49884||A==49912||A==49940||A==49968||A==49996||A==50024||A==50052||A==50080||A==50108||A==50136||A==50164||A==50192||A==50220||A==50248||A==50276||A==50304||A==50332||A==50360||A==50388||A==50416||A==50444||A==50472||A==50500||A==50528||A==50556||A==50584||A==50612||A==50640||A==50668||A==50696||A==50724||A==50752||A==50780||A==50808||A==50836||A==50864||A==50892||A==50920||A==50948||A==50976||A==51004||A==51032||A==51060||A==51088||A==51116||A==51144||A==51172||A==51200||A==51228||A==51256||A==51284||A==51312||A==51340||A==51368||A==51396||A==51424||A==51452||A==51480||A==51508||A==51536||A==51564||A==51592||A==51620||A==51648||A==51676||A==51704||A==51732||A==51760||A==51788||A==51816||A==51844||A==51872||A==51900||A==51928||A==51956||A==51984||A==52012||A==52040||A==52068||A==52096||A==52124||A==52152||A==52180||A==52208||A==52236||A==52264||A==52292||A==52320||A==52348||A==52376||A==52404||A==52432||A==52460||A==52488||A==52516||A==52544||A==52572||A==52600||A==52628||A==52656||A==52684||A==52712||A==52740||A==52768||A==52796||A==52824||A==52852||A==52880||A==52908||A==52936||A==52964||A==52992||A==53020||A==53048||A==53076||A==53104||A==53132||A==53160||A==53188||A==53216||A==53244||A==53272||A==53300||A==53328||A==53356||A==53384||A==53412||A==53440||A==53468||A==53496||A==53524||A==53552||A==53580||A==53608||A==53636||A==53664||A==53692||A==53720||A==53748||A==53776||A==53804||A==53832||A==53860||A==53888||A==53916||A==53944||A==53972||A==54e3||A==54028||A==54056||A==54084||A==54112||A==54140||A==54168||A==54196||A==54224||A==54252||A==54280||A==54308||A==54336||A==54364||A==54392||A==54420||A==54448||A==54476||A==54504||A==54532||A==54560||A==54588||A==54616||A==54644||A==54672||A==54700||A==54728||A==54756||A==54784||A==54812||A==54840||A==54868||A==54896||A==54924||A==54952||A==54980||A==55008||A==55036||A==55064||A==55092||A==55120||A==55148||A==55176?c:44033<=A&&A<=44059||44061<=A&&A<=44087||44089<=A&&A<=44115||44117<=A&&A<=44143||44145<=A&&A<=44171||44173<=A&&A<=44199||44201<=A&&A<=44227||44229<=A&&A<=44255||44257<=A&&A<=44283||44285<=A&&A<=44311||44313<=A&&A<=44339||44341<=A&&A<=44367||44369<=A&&A<=44395||44397<=A&&A<=44423||44425<=A&&A<=44451||44453<=A&&A<=44479||44481<=A&&A<=44507||44509<=A&&A<=44535||44537<=A&&A<=44563||44565<=A&&A<=44591||44593<=A&&A<=44619||44621<=A&&A<=44647||44649<=A&&A<=44675||44677<=A&&A<=44703||44705<=A&&A<=44731||44733<=A&&A<=44759||44761<=A&&A<=44787||44789<=A&&A<=44815||44817<=A&&A<=44843||44845<=A&&A<=44871||44873<=A&&A<=44899||44901<=A&&A<=44927||44929<=A&&A<=44955||44957<=A&&A<=44983||44985<=A&&A<=45011||45013<=A&&A<=45039||45041<=A&&A<=45067||45069<=A&&A<=45095||45097<=A&&A<=45123||45125<=A&&A<=45151||45153<=A&&A<=45179||45181<=A&&A<=45207||45209<=A&&A<=45235||45237<=A&&A<=45263||45265<=A&&A<=45291||45293<=A&&A<=45319||45321<=A&&A<=45347||45349<=A&&A<=45375||45377<=A&&A<=45403||45405<=A&&A<=45431||45433<=A&&A<=45459||45461<=A&&A<=45487||45489<=A&&A<=45515||45517<=A&&A<=45543||45545<=A&&A<=45571||45573<=A&&A<=45599||45601<=A&&A<=45627||45629<=A&&A<=45655||45657<=A&&A<=45683||45685<=A&&A<=45711||45713<=A&&A<=45739||45741<=A&&A<=45767||45769<=A&&A<=45795||45797<=A&&A<=45823||45825<=A&&A<=45851||45853<=A&&A<=45879||45881<=A&&A<=45907||45909<=A&&A<=45935||45937<=A&&A<=45963||45965<=A&&A<=45991||45993<=A&&A<=46019||46021<=A&&A<=46047||46049<=A&&A<=46075||46077<=A&&A<=46103||46105<=A&&A<=46131||46133<=A&&A<=46159||46161<=A&&A<=46187||46189<=A&&A<=46215||46217<=A&&A<=46243||46245<=A&&A<=46271||46273<=A&&A<=46299||46301<=A&&A<=46327||46329<=A&&A<=46355||46357<=A&&A<=46383||46385<=A&&A<=46411||46413<=A&&A<=46439||46441<=A&&A<=46467||46469<=A&&A<=46495||46497<=A&&A<=46523||46525<=A&&A<=46551||46553<=A&&A<=46579||46581<=A&&A<=46607||46609<=A&&A<=46635||46637<=A&&A<=46663||46665<=A&&A<=46691||46693<=A&&A<=46719||46721<=A&&A<=46747||46749<=A&&A<=46775||46777<=A&&A<=46803||46805<=A&&A<=46831||46833<=A&&A<=46859||46861<=A&&A<=46887||46889<=A&&A<=46915||46917<=A&&A<=46943||46945<=A&&A<=46971||46973<=A&&A<=46999||47001<=A&&A<=47027||47029<=A&&A<=47055||47057<=A&&A<=47083||47085<=A&&A<=47111||47113<=A&&A<=47139||47141<=A&&A<=47167||47169<=A&&A<=47195||47197<=A&&A<=47223||47225<=A&&A<=47251||47253<=A&&A<=47279||47281<=A&&A<=47307||47309<=A&&A<=47335||47337<=A&&A<=47363||47365<=A&&A<=47391||47393<=A&&A<=47419||47421<=A&&A<=47447||47449<=A&&A<=47475||47477<=A&&A<=47503||47505<=A&&A<=47531||47533<=A&&A<=47559||47561<=A&&A<=47587||47589<=A&&A<=47615||47617<=A&&A<=47643||47645<=A&&A<=47671||47673<=A&&A<=47699||47701<=A&&A<=47727||47729<=A&&A<=47755||47757<=A&&A<=47783||47785<=A&&A<=47811||47813<=A&&A<=47839||47841<=A&&A<=47867||47869<=A&&A<=47895||47897<=A&&A<=47923||47925<=A&&A<=47951||47953<=A&&A<=47979||47981<=A&&A<=48007||48009<=A&&A<=48035||48037<=A&&A<=48063||48065<=A&&A<=48091||48093<=A&&A<=48119||48121<=A&&A<=48147||48149<=A&&A<=48175||48177<=A&&A<=48203||48205<=A&&A<=48231||48233<=A&&A<=48259||48261<=A&&A<=48287||48289<=A&&A<=48315||48317<=A&&A<=48343||48345<=A&&A<=48371||48373<=A&&A<=48399||48401<=A&&A<=48427||48429<=A&&A<=48455||48457<=A&&A<=48483||48485<=A&&A<=48511||48513<=A&&A<=48539||48541<=A&&A<=48567||48569<=A&&A<=48595||48597<=A&&A<=48623||48625<=A&&A<=48651||48653<=A&&A<=48679||48681<=A&&A<=48707||48709<=A&&A<=48735||48737<=A&&A<=48763||48765<=A&&A<=48791||48793<=A&&A<=48819||48821<=A&&A<=48847||48849<=A&&A<=48875||48877<=A&&A<=48903||48905<=A&&A<=48931||48933<=A&&A<=48959||48961<=A&&A<=48987||48989<=A&&A<=49015||49017<=A&&A<=49043||49045<=A&&A<=49071||49073<=A&&A<=49099||49101<=A&&A<=49127||49129<=A&&A<=49155||49157<=A&&A<=49183||49185<=A&&A<=49211||49213<=A&&A<=49239||49241<=A&&A<=49267||49269<=A&&A<=49295||49297<=A&&A<=49323||49325<=A&&A<=49351||49353<=A&&A<=49379||49381<=A&&A<=49407||49409<=A&&A<=49435||49437<=A&&A<=49463||49465<=A&&A<=49491||49493<=A&&A<=49519||49521<=A&&A<=49547||49549<=A&&A<=49575||49577<=A&&A<=49603||49605<=A&&A<=49631||49633<=A&&A<=49659||49661<=A&&A<=49687||49689<=A&&A<=49715||49717<=A&&A<=49743||49745<=A&&A<=49771||49773<=A&&A<=49799||49801<=A&&A<=49827||49829<=A&&A<=49855||49857<=A&&A<=49883||49885<=A&&A<=49911||49913<=A&&A<=49939||49941<=A&&A<=49967||49969<=A&&A<=49995||49997<=A&&A<=50023||50025<=A&&A<=50051||50053<=A&&A<=50079||50081<=A&&A<=50107||50109<=A&&A<=50135||50137<=A&&A<=50163||50165<=A&&A<=50191||50193<=A&&A<=50219||50221<=A&&A<=50247||50249<=A&&A<=50275||50277<=A&&A<=50303||50305<=A&&A<=50331||50333<=A&&A<=50359||50361<=A&&A<=50387||50389<=A&&A<=50415||50417<=A&&A<=50443||50445<=A&&A<=50471||50473<=A&&A<=50499||50501<=A&&A<=50527||50529<=A&&A<=50555||50557<=A&&A<=50583||50585<=A&&A<=50611||50613<=A&&A<=50639||50641<=A&&A<=50667||50669<=A&&A<=50695||50697<=A&&A<=50723||50725<=A&&A<=50751||50753<=A&&A<=50779||50781<=A&&A<=50807||50809<=A&&A<=50835||50837<=A&&A<=50863||50865<=A&&A<=50891||50893<=A&&A<=50919||50921<=A&&A<=50947||50949<=A&&A<=50975||50977<=A&&A<=51003||51005<=A&&A<=51031||51033<=A&&A<=51059||51061<=A&&A<=51087||51089<=A&&A<=51115||51117<=A&&A<=51143||51145<=A&&A<=51171||51173<=A&&A<=51199||51201<=A&&A<=51227||51229<=A&&A<=51255||51257<=A&&A<=51283||51285<=A&&A<=51311||51313<=A&&A<=51339||51341<=A&&A<=51367||51369<=A&&A<=51395||51397<=A&&A<=51423||51425<=A&&A<=51451||51453<=A&&A<=51479||51481<=A&&A<=51507||51509<=A&&A<=51535||51537<=A&&A<=51563||51565<=A&&A<=51591||51593<=A&&A<=51619||51621<=A&&A<=51647||51649<=A&&A<=51675||51677<=A&&A<=51703||51705<=A&&A<=51731||51733<=A&&A<=51759||51761<=A&&A<=51787||51789<=A&&A<=51815||51817<=A&&A<=51843||51845<=A&&A<=51871||51873<=A&&A<=51899||51901<=A&&A<=51927||51929<=A&&A<=51955||51957<=A&&A<=51983||51985<=A&&A<=52011||52013<=A&&A<=52039||52041<=A&&A<=52067||52069<=A&&A<=52095||52097<=A&&A<=52123||52125<=A&&A<=52151||52153<=A&&A<=52179||52181<=A&&A<=52207||52209<=A&&A<=52235||52237<=A&&A<=52263||52265<=A&&A<=52291||52293<=A&&A<=52319||52321<=A&&A<=52347||52349<=A&&A<=52375||52377<=A&&A<=52403||52405<=A&&A<=52431||52433<=A&&A<=52459||52461<=A&&A<=52487||52489<=A&&A<=52515||52517<=A&&A<=52543||52545<=A&&A<=52571||52573<=A&&A<=52599||52601<=A&&A<=52627||52629<=A&&A<=52655||52657<=A&&A<=52683||52685<=A&&A<=52711||52713<=A&&A<=52739||52741<=A&&A<=52767||52769<=A&&A<=52795||52797<=A&&A<=52823||52825<=A&&A<=52851||52853<=A&&A<=52879||52881<=A&&A<=52907||52909<=A&&A<=52935||52937<=A&&A<=52963||52965<=A&&A<=52991||52993<=A&&A<=53019||53021<=A&&A<=53047||53049<=A&&A<=53075||53077<=A&&A<=53103||53105<=A&&A<=53131||53133<=A&&A<=53159||53161<=A&&A<=53187||53189<=A&&A<=53215||53217<=A&&A<=53243||53245<=A&&A<=53271||53273<=A&&A<=53299||53301<=A&&A<=53327||53329<=A&&A<=53355||53357<=A&&A<=53383||53385<=A&&A<=53411||53413<=A&&A<=53439||53441<=A&&A<=53467||53469<=A&&A<=53495||53497<=A&&A<=53523||53525<=A&&A<=53551||53553<=A&&A<=53579||53581<=A&&A<=53607||53609<=A&&A<=53635||53637<=A&&A<=53663||53665<=A&&A<=53691||53693<=A&&A<=53719||53721<=A&&A<=53747||53749<=A&&A<=53775||53777<=A&&A<=53803||53805<=A&&A<=53831||53833<=A&&A<=53859||53861<=A&&A<=53887||53889<=A&&A<=53915||53917<=A&&A<=53943||53945<=A&&A<=53971||53973<=A&&A<=53999||54001<=A&&A<=54027||54029<=A&&A<=54055||54057<=A&&A<=54083||54085<=A&&A<=54111||54113<=A&&A<=54139||54141<=A&&A<=54167||54169<=A&&A<=54195||54197<=A&&A<=54223||54225<=A&&A<=54251||54253<=A&&A<=54279||54281<=A&&A<=54307||54309<=A&&A<=54335||54337<=A&&A<=54363||54365<=A&&A<=54391||54393<=A&&A<=54419||54421<=A&&A<=54447||54449<=A&&A<=54475||54477<=A&&A<=54503||54505<=A&&A<=54531||54533<=A&&A<=54559||54561<=A&&A<=54587||54589<=A&&A<=54615||54617<=A&&A<=54643||54645<=A&&A<=54671||54673<=A&&A<=54699||54701<=A&&A<=54727||54729<=A&&A<=54755||54757<=A&&A<=54783||54785<=A&&A<=54811||54813<=A&&A<=54839||54841<=A&&A<=54867||54869<=A&&A<=54895||54897<=A&&A<=54923||54925<=A&&A<=54951||54953<=A&&A<=54979||54981<=A&&A<=55007||55009<=A&&A<=55035||55037<=A&&A<=55063||55065<=A&&A<=55091||55093<=A&&A<=55119||55121<=A&&A<=55147||55149<=A&&A<=55175||55177<=A&&A<=55203?u:A==9757||A==9977||9994<=A&&A<=9997||A==127877||127938<=A&&A<=127940||A==127943||127946<=A&&A<=127948||128066<=A&&A<=128067||128070<=A&&A<=128080||A==128110||128112<=A&&A<=128120||A==128124||128129<=A&&A<=128131||128133<=A&&A<=128135||A==128170||128372<=A&&A<=128373||A==128378||A==128400||128405<=A&&A<=128406||128581<=A&&A<=128583||128587<=A&&A<=128591||A==128675||128692<=A&&A<=128694||A==128704||A==128716||129304<=A&&A<=129308||129310<=A&&A<=129311||A==129318||129328<=A&&A<=129337||129341<=A&&A<=129342||129489<=A&&A<=129501?h:127995<=A&&A<=127999?p:A==8205?m:A==9792||A==9794||9877<=A&&A<=9878||A==9992||A==10084||A==127752||A==127806||A==127859||A==127891||A==127908||A==127912||A==127979||A==127981||A==128139||128187<=A&&A<=128188||A==128295||A==128300||A==128488||A==128640||A==128658?y:128102<=A&&A<=128105?Q:g}return this}typeof rB!="undefined"&&rB.exports&&(rB.exports=ORe)});var X5=w((Yat,V5)=>{var MRe=/^(.*?)(\x1b\[[^m]+m|\x1b\]8;;.*?(\x1b\\|\u0007))/,iB;function URe(){if(iB)return iB;if(typeof Intl.Segmenter!="undefined"){let t=new Intl.Segmenter("en",{granularity:"grapheme"});return iB=e=>Array.from(t.segment(e),({segment:r})=>r)}else{let t=_5(),e=new t;return iB=r=>e.splitGraphemes(r)}}V5.exports=(t,e=0,r=t.length)=>{if(e<0||r<0)throw new RangeError("Negative indices aren't supported by this implementation");let i=r-e,n="",s=0,o=0;for(;t.length>0;){let a=t.match(MRe)||[t,t,void 0],l=URe()(a[1]),c=Math.min(e-s,l.length);l=l.slice(c);let u=Math.min(i-o,l.length);n+=l.slice(0,u).join(""),s+=c,o+=u,typeof a[2]!="undefined"&&(n+=a[2]),t=t.slice(a[0].length)}return n}});var sf=w((EAt,u6)=>{"use strict";var g6=new Map([["C","cwd"],["f","file"],["z","gzip"],["P","preservePaths"],["U","unlink"],["strip-components","strip"],["stripComponents","strip"],["keep-newer","newer"],["keepNewer","newer"],["keep-newer-files","newer"],["keepNewerFiles","newer"],["k","keep"],["keep-existing","keep"],["keepExisting","keep"],["m","noMtime"],["no-mtime","noMtime"],["p","preserveOwner"],["L","follow"],["h","follow"]]),mAt=u6.exports=t=>t?Object.keys(t).map(e=>[g6.has(e)?g6.get(e):e,t[e]]).reduce((e,r)=>(e[r[0]]=r[1],e),Object.create(null)):{}});var of=w((IAt,f6)=>{"use strict";var ZRe=require("events"),h6=require("stream"),qd=Bp(),p6=require("string_decoder").StringDecoder,sA=Symbol("EOF"),Jd=Symbol("maybeEmitEnd"),hl=Symbol("emittedEnd"),lB=Symbol("emittingEnd"),cB=Symbol("closed"),d6=Symbol("read"),gD=Symbol("flush"),C6=Symbol("flushChunk"),Nn=Symbol("encoding"),oA=Symbol("decoder"),uB=Symbol("flowing"),Wd=Symbol("paused"),zd=Symbol("resume"),pn=Symbol("bufferLength"),m6=Symbol("bufferPush"),fD=Symbol("bufferShift"),_i=Symbol("objectMode"),Vi=Symbol("destroyed"),E6=global._MP_NO_ITERATOR_SYMBOLS_!=="1",$Re=E6&&Symbol.asyncIterator||Symbol("asyncIterator not implemented"),eFe=E6&&Symbol.iterator||Symbol("iterator not implemented"),I6=t=>t==="end"||t==="finish"||t==="prefinish",tFe=t=>t instanceof ArrayBuffer||typeof t=="object"&&t.constructor&&t.constructor.name==="ArrayBuffer"&&t.byteLength>=0,rFe=t=>!Buffer.isBuffer(t)&&ArrayBuffer.isView(t);f6.exports=class y6 extends h6{constructor(e){super();this[uB]=!1,this[Wd]=!1,this.pipes=new qd,this.buffer=new qd,this[_i]=e&&e.objectMode||!1,this[_i]?this[Nn]=null:this[Nn]=e&&e.encoding||null,this[Nn]==="buffer"&&(this[Nn]=null),this[oA]=this[Nn]?new p6(this[Nn]):null,this[sA]=!1,this[hl]=!1,this[lB]=!1,this[cB]=!1,this.writable=!0,this.readable=!0,this[pn]=0,this[Vi]=!1}get bufferLength(){return this[pn]}get encoding(){return this[Nn]}set encoding(e){if(this[_i])throw new Error("cannot set encoding in objectMode");if(this[Nn]&&e!==this[Nn]&&(this[oA]&&this[oA].lastNeed||this[pn]))throw new Error("cannot change encoding");this[Nn]!==e&&(this[oA]=e?new p6(e):null,this.buffer.length&&(this.buffer=this.buffer.map(r=>this[oA].write(r)))),this[Nn]=e}setEncoding(e){this.encoding=e}get objectMode(){return this[_i]}set objectMode(e){this[_i]=this[_i]||!!e}write(e,r,i){if(this[sA])throw new Error("write after end");return this[Vi]?(this.emit("error",Object.assign(new Error("Cannot call write after a stream was destroyed"),{code:"ERR_STREAM_DESTROYED"})),!0):(typeof r=="function"&&(i=r,r="utf8"),r||(r="utf8"),!this[_i]&&!Buffer.isBuffer(e)&&(rFe(e)?e=Buffer.from(e.buffer,e.byteOffset,e.byteLength):tFe(e)?e=Buffer.from(e):typeof e!="string"&&(this.objectMode=!0)),!this.objectMode&&!e.length?(this[pn]!==0&&this.emit("readable"),i&&i(),this.flowing):(typeof e=="string"&&!this[_i]&&!(r===this[Nn]&&!this[oA].lastNeed)&&(e=Buffer.from(e,r)),Buffer.isBuffer(e)&&this[Nn]&&(e=this[oA].write(e)),this.flowing?(this[pn]!==0&&this[gD](!0),this.emit("data",e)):this[m6](e),this[pn]!==0&&this.emit("readable"),i&&i(),this.flowing))}read(e){if(this[Vi])return null;try{return this[pn]===0||e===0||e>this[pn]?null:(this[_i]&&(e=null),this.buffer.length>1&&!this[_i]&&(this.encoding?this.buffer=new qd([Array.from(this.buffer).join("")]):this.buffer=new qd([Buffer.concat(Array.from(this.buffer),this[pn])])),this[d6](e||null,this.buffer.head.value))}finally{this[Jd]()}}[d6](e,r){return e===r.length||e===null?this[fD]():(this.buffer.head.value=r.slice(e),r=r.slice(0,e),this[pn]-=e),this.emit("data",r),!this.buffer.length&&!this[sA]&&this.emit("drain"),r}end(e,r,i){return typeof e=="function"&&(i=e,e=null),typeof r=="function"&&(i=r,r="utf8"),e&&this.write(e,r),i&&this.once("end",i),this[sA]=!0,this.writable=!1,(this.flowing||!this[Wd])&&this[Jd](),this}[zd](){this[Vi]||(this[Wd]=!1,this[uB]=!0,this.emit("resume"),this.buffer.length?this[gD]():this[sA]?this[Jd]():this.emit("drain"))}resume(){return this[zd]()}pause(){this[uB]=!1,this[Wd]=!0}get destroyed(){return this[Vi]}get flowing(){return this[uB]}get paused(){return this[Wd]}[m6](e){return this[_i]?this[pn]+=1:this[pn]+=e.length,this.buffer.push(e)}[fD](){return this.buffer.length&&(this[_i]?this[pn]-=1:this[pn]-=this.buffer.head.value.length),this.buffer.shift()}[gD](e){do;while(this[C6](this[fD]()));!e&&!this.buffer.length&&!this[sA]&&this.emit("drain")}[C6](e){return e?(this.emit("data",e),this.flowing):!1}pipe(e,r){if(this[Vi])return;let i=this[hl];r=r||{},e===process.stdout||e===process.stderr?r.end=!1:r.end=r.end!==!1;let n={dest:e,opts:r,ondrain:s=>this[zd]()};return this.pipes.push(n),e.on("drain",n.ondrain),this[zd](),i&&n.opts.end&&n.dest.end(),e}addListener(e,r){return this.on(e,r)}on(e,r){try{return super.on(e,r)}finally{e==="data"&&!this.pipes.length&&!this.flowing?this[zd]():I6(e)&&this[hl]&&(super.emit(e),this.removeAllListeners(e))}}get emittedEnd(){return this[hl]}[Jd](){!this[lB]&&!this[hl]&&!this[Vi]&&this.buffer.length===0&&this[sA]&&(this[lB]=!0,this.emit("end"),this.emit("prefinish"),this.emit("finish"),this[cB]&&this.emit("close"),this[lB]=!1)}emit(e,r){if(e!=="error"&&e!=="close"&&e!==Vi&&this[Vi])return;if(e==="data"){if(!r)return;this.pipes.length&&this.pipes.forEach(n=>n.dest.write(r)===!1&&this.pause())}else if(e==="end"){if(this[hl]===!0)return;this[hl]=!0,this.readable=!1,this[oA]&&(r=this[oA].end(),r&&(this.pipes.forEach(n=>n.dest.write(r)),super.emit("data",r))),this.pipes.forEach(n=>{n.dest.removeListener("drain",n.ondrain),n.opts.end&&n.dest.end()})}else if(e==="close"&&(this[cB]=!0,!this[hl]&&!this[Vi]))return;let i=new Array(arguments.length);if(i[0]=e,i[1]=r,arguments.length>2)for(let n=2;n<arguments.length;n++)i[n]=arguments[n];try{return super.emit.apply(this,i)}finally{I6(e)?this.removeAllListeners(e):this[Jd]()}}collect(){let e=[];this[_i]||(e.dataLength=0);let r=this.promise();return this.on("data",i=>{e.push(i),this[_i]||(e.dataLength+=i.length)}),r.then(()=>e)}concat(){return this[_i]?Promise.reject(new Error("cannot concat in objectMode")):this.collect().then(e=>this[_i]?Promise.reject(new Error("cannot concat in objectMode")):this[Nn]?e.join(""):Buffer.concat(e,e.dataLength))}promise(){return new Promise((e,r)=>{this.on(Vi,()=>r(new Error("stream destroyed"))),this.on("end",()=>e()),this.on("error",i=>r(i))})}[$Re](){return{next:()=>{let r=this.read();if(r!==null)return Promise.resolve({done:!1,value:r});if(this[sA])return Promise.resolve({done:!0});let i=null,n=null,s=c=>{this.removeListener("data",o),this.removeListener("end",a),n(c)},o=c=>{this.removeListener("error",s),this.removeListener("end",a),this.pause(),i({value:c,done:!!this[sA]})},a=()=>{this.removeListener("error",s),this.removeListener("data",o),i({done:!0})},l=()=>s(new Error("stream destroyed"));return new Promise((c,u)=>{n=u,i=c,this.once(Vi,l),this.once("error",s),this.once("end",a),this.once("data",o)})}}}[eFe](){return{next:()=>{let r=this.read();return{value:r,done:r===null}}}}destroy(e){return this[Vi]?(e?this.emit("error",e):this.emit(Vi),this):(this[Vi]=!0,this.buffer=new qd,this[pn]=0,typeof this.close=="function"&&!this[cB]&&this.close(),e?this.emit("error",e):this.emit(Vi),this)}static isStream(e){return!!e&&(e instanceof y6||e instanceof h6||e instanceof ZRe&&(typeof e.pipe=="function"||typeof e.write=="function"&&typeof e.end=="function"))}}});var B6=w((yAt,w6)=>{var iFe=require("zlib").constants||{ZLIB_VERNUM:4736};w6.exports=Object.freeze(Object.assign(Object.create(null),{Z_NO_FLUSH:0,Z_PARTIAL_FLUSH:1,Z_SYNC_FLUSH:2,Z_FULL_FLUSH:3,Z_FINISH:4,Z_BLOCK:5,Z_OK:0,Z_STREAM_END:1,Z_NEED_DICT:2,Z_ERRNO:-1,Z_STREAM_ERROR:-2,Z_DATA_ERROR:-3,Z_MEM_ERROR:-4,Z_BUF_ERROR:-5,Z_VERSION_ERROR:-6,Z_NO_COMPRESSION:0,Z_BEST_SPEED:1,Z_BEST_COMPRESSION:9,Z_DEFAULT_COMPRESSION:-1,Z_FILTERED:1,Z_HUFFMAN_ONLY:2,Z_RLE:3,Z_FIXED:4,Z_DEFAULT_STRATEGY:0,DEFLATE:1,INFLATE:2,GZIP:3,GUNZIP:4,DEFLATERAW:5,INFLATERAW:6,UNZIP:7,BROTLI_DECODE:8,BROTLI_ENCODE:9,Z_MIN_WINDOWBITS:8,Z_MAX_WINDOWBITS:15,Z_DEFAULT_WINDOWBITS:15,Z_MIN_CHUNK:64,Z_MAX_CHUNK:Infinity,Z_DEFAULT_CHUNK:16384,Z_MIN_MEMLEVEL:1,Z_MAX_MEMLEVEL:9,Z_DEFAULT_MEMLEVEL:8,Z_MIN_LEVEL:-1,Z_MAX_LEVEL:9,Z_DEFAULT_LEVEL:-1,BROTLI_OPERATION_PROCESS:0,BROTLI_OPERATION_FLUSH:1,BROTLI_OPERATION_FINISH:2,BROTLI_OPERATION_EMIT_METADATA:3,BROTLI_MODE_GENERIC:0,BROTLI_MODE_TEXT:1,BROTLI_MODE_FONT:2,BROTLI_DEFAULT_MODE:0,BROTLI_MIN_QUALITY:0,BROTLI_MAX_QUALITY:11,BROTLI_DEFAULT_QUALITY:11,BROTLI_MIN_WINDOW_BITS:10,BROTLI_MAX_WINDOW_BITS:24,BROTLI_LARGE_MAX_WINDOW_BITS:30,BROTLI_DEFAULT_WINDOW:22,BROTLI_MIN_INPUT_BLOCK_BITS:16,BROTLI_MAX_INPUT_BLOCK_BITS:24,BROTLI_PARAM_MODE:0,BROTLI_PARAM_QUALITY:1,BROTLI_PARAM_LGWIN:2,BROTLI_PARAM_LGBLOCK:3,BROTLI_PARAM_DISABLE_LITERAL_CONTEXT_MODELING:4,BROTLI_PARAM_SIZE_HINT:5,BROTLI_PARAM_LARGE_WINDOW:6,BROTLI_PARAM_NPOSTFIX:7,BROTLI_PARAM_NDIRECT:8,BROTLI_DECODER_RESULT_ERROR:0,BROTLI_DECODER_RESULT_SUCCESS:1,BROTLI_DECODER_RESULT_NEEDS_MORE_INPUT:2,BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT:3,BROTLI_DECODER_PARAM_DISABLE_RING_BUFFER_REALLOCATION:0,BROTLI_DECODER_PARAM_LARGE_WINDOW:1,BROTLI_DECODER_NO_ERROR:0,BROTLI_DECODER_SUCCESS:1,BROTLI_DECODER_NEEDS_MORE_INPUT:2,BROTLI_DECODER_NEEDS_MORE_OUTPUT:3,BROTLI_DECODER_ERROR_FORMAT_EXUBERANT_NIBBLE:-1,BROTLI_DECODER_ERROR_FORMAT_RESERVED:-2,BROTLI_DECODER_ERROR_FORMAT_EXUBERANT_META_NIBBLE:-3,BROTLI_DECODER_ERROR_FORMAT_SIMPLE_HUFFMAN_ALPHABET:-4,BROTLI_DECODER_ERROR_FORMAT_SIMPLE_HUFFMAN_SAME:-5,BROTLI_DECODER_ERROR_FORMAT_CL_SPACE:-6,BROTLI_DECODER_ERROR_FORMAT_HUFFMAN_SPACE:-7,BROTLI_DECODER_ERROR_FORMAT_CONTEXT_MAP_REPEAT:-8,BROTLI_DECODER_ERROR_FORMAT_BLOCK_LENGTH_1:-9,BROTLI_DECODER_ERROR_FORMAT_BLOCK_LENGTH_2:-10,BROTLI_DECODER_ERROR_FORMAT_TRANSFORM:-11,BROTLI_DECODER_ERROR_FORMAT_DICTIONARY:-12,BROTLI_DECODER_ERROR_FORMAT_WINDOW_BITS:-13,BROTLI_DECODER_ERROR_FORMAT_PADDING_1:-14,BROTLI_DECODER_ERROR_FORMAT_PADDING_2:-15,BROTLI_DECODER_ERROR_FORMAT_DISTANCE:-16,BROTLI_DECODER_ERROR_DICTIONARY_NOT_SET:-19,BROTLI_DECODER_ERROR_INVALID_ARGUMENTS:-20,BROTLI_DECODER_ERROR_ALLOC_CONTEXT_MODES:-21,BROTLI_DECODER_ERROR_ALLOC_TREE_GROUPS:-22,BROTLI_DECODER_ERROR_ALLOC_CONTEXT_MAP:-25,BROTLI_DECODER_ERROR_ALLOC_RING_BUFFER_1:-26,BROTLI_DECODER_ERROR_ALLOC_RING_BUFFER_2:-27,BROTLI_DECODER_ERROR_ALLOC_BLOCK_TYPE_TREES:-30,BROTLI_DECODER_ERROR_UNREACHABLE:-31},iFe))});var wD=w(ss=>{"use strict";var hD=require("assert"),pl=require("buffer").Buffer,b6=require("zlib"),Wc=ss.constants=B6(),nFe=of(),Q6=pl.concat,zc=Symbol("_superWrite"),_d=class extends Error{constructor(e){super("zlib: "+e.message);this.code=e.code,this.errno=e.errno,this.code||(this.code="ZLIB_ERROR"),this.message="zlib: "+e.message,Error.captureStackTrace(this,this.constructor)}get name(){return"ZlibError"}},sFe=Symbol("opts"),Vd=Symbol("flushFlag"),v6=Symbol("finishFlushFlag"),pD=Symbol("fullFlushFlag"),pr=Symbol("handle"),gB=Symbol("onError"),af=Symbol("sawError"),dD=Symbol("level"),CD=Symbol("strategy"),mD=Symbol("ended"),wAt=Symbol("_defaultFullFlush"),ED=class extends nFe{constructor(e,r){if(!e||typeof e!="object")throw new TypeError("invalid options for ZlibBase constructor");super(e);this[af]=!1,this[mD]=!1,this[sFe]=e,this[Vd]=e.flush,this[v6]=e.finishFlush;try{this[pr]=new b6[r](e)}catch(i){throw new _d(i)}this[gB]=i=>{this[af]||(this[af]=!0,this.close(),this.emit("error",i))},this[pr].on("error",i=>this[gB](new _d(i))),this.once("end",()=>this.close)}close(){this[pr]&&(this[pr].close(),this[pr]=null,this.emit("close"))}reset(){if(!this[af])return hD(this[pr],"zlib binding closed"),this[pr].reset()}flush(e){this.ended||(typeof e!="number"&&(e=this[pD]),this.write(Object.assign(pl.alloc(0),{[Vd]:e})))}end(e,r,i){return e&&this.write(e,r),this.flush(this[v6]),this[mD]=!0,super.end(null,null,i)}get ended(){return this[mD]}write(e,r,i){if(typeof r=="function"&&(i=r,r="utf8"),typeof e=="string"&&(e=pl.from(e,r)),this[af])return;hD(this[pr],"zlib binding closed");let n=this[pr]._handle,s=n.close;n.close=()=>{};let o=this[pr].close;this[pr].close=()=>{},pl.concat=c=>c;let a;try{let c=typeof e[Vd]=="number"?e[Vd]:this[Vd];a=this[pr]._processChunk(e,c),pl.concat=Q6}catch(c){pl.concat=Q6,this[gB](new _d(c))}finally{this[pr]&&(this[pr]._handle=n,n.close=s,this[pr].close=o,this[pr].removeAllListeners("error"))}this[pr]&&this[pr].on("error",c=>this[gB](new _d(c)));let l;if(a)if(Array.isArray(a)&&a.length>0){l=this[zc](pl.from(a[0]));for(let c=1;c<a.length;c++)l=this[zc](a[c])}else l=this[zc](pl.from(a));return i&&i(),l}[zc](e){return super.write(e)}},dl=class extends ED{constructor(e,r){e=e||{},e.flush=e.flush||Wc.Z_NO_FLUSH,e.finishFlush=e.finishFlush||Wc.Z_FINISH,super(e,r),this[pD]=Wc.Z_FULL_FLUSH,this[dD]=e.level,this[CD]=e.strategy}params(e,r){if(!this[af]){if(!this[pr])throw new Error("cannot switch params when binding is closed");if(!this[pr].params)throw new Error("not supported in this implementation");if(this[dD]!==e||this[CD]!==r){this.flush(Wc.Z_SYNC_FLUSH),hD(this[pr],"zlib binding closed");let i=this[pr].flush;this[pr].flush=(n,s)=>{this.flush(n),s()};try{this[pr].params(e,r)}finally{this[pr].flush=i}this[pr]&&(this[dD]=e,this[CD]=r)}}}},S6=class extends dl{constructor(e){super(e,"Deflate")}},k6=class extends dl{constructor(e){super(e,"Inflate")}},ID=Symbol("_portable"),x6=class extends dl{constructor(e){super(e,"Gzip");this[ID]=e&&!!e.portable}[zc](e){return this[ID]?(this[ID]=!1,e[9]=255,super[zc](e)):super[zc](e)}},P6=class extends dl{constructor(e){super(e,"Gunzip")}},D6=class extends dl{constructor(e){super(e,"DeflateRaw")}},R6=class extends dl{constructor(e){super(e,"InflateRaw")}},F6=class extends dl{constructor(e){super(e,"Unzip")}},yD=class extends ED{constructor(e,r){e=e||{},e.flush=e.flush||Wc.BROTLI_OPERATION_PROCESS,e.finishFlush=e.finishFlush||Wc.BROTLI_OPERATION_FINISH,super(e,r),this[pD]=Wc.BROTLI_OPERATION_FLUSH}},N6=class extends yD{constructor(e){super(e,"BrotliCompress")}},L6=class extends yD{constructor(e){super(e,"BrotliDecompress")}};ss.Deflate=S6;ss.Inflate=k6;ss.Gzip=x6;ss.Gunzip=P6;ss.DeflateRaw=D6;ss.InflateRaw=R6;ss.Unzip=F6;typeof b6.BrotliCompress=="function"?(ss.BrotliCompress=N6,ss.BrotliDecompress=L6):ss.BrotliCompress=ss.BrotliDecompress=class{constructor(){throw new Error("Brotli is not supported in this version of Node.js")}}});var Xd=w(fB=>{"use strict";fB.name=new Map([["0","File"],["","OldFile"],["1","Link"],["2","SymbolicLink"],["3","CharacterDevice"],["4","BlockDevice"],["5","Directory"],["6","FIFO"],["7","ContiguousFile"],["g","GlobalExtendedHeader"],["x","ExtendedHeader"],["A","SolarisACL"],["D","GNUDumpDir"],["I","Inode"],["K","NextFileHasLongLinkpath"],["L","NextFileHasLongPath"],["M","ContinuationFile"],["N","OldGnuLongPath"],["S","SparseFile"],["V","TapeVolumeHeader"],["X","OldExtendedHeader"]]);fB.code=new Map(Array.from(fB.name).map(t=>[t[1],t[0]]))});var Zd=w((SAt,T6)=>{"use strict";var QAt=Xd(),oFe=of(),BD=Symbol("slurp");T6.exports=class extends oFe{constructor(e,r,i){super();switch(this.pause(),this.extended=r,this.globalExtended=i,this.header=e,this.startBlockSize=512*Math.ceil(e.size/512),this.blockRemain=this.startBlockSize,this.remain=e.size,this.type=e.type,this.meta=!1,this.ignore=!1,this.type){case"File":case"OldFile":case"Link":case"SymbolicLink":case"CharacterDevice":case"BlockDevice":case"Directory":case"FIFO":case"ContiguousFile":case"GNUDumpDir":break;case"NextFileHasLongLinkpath":case"NextFileHasLongPath":case"OldGnuLongPath":case"GlobalExtendedHeader":case"ExtendedHeader":case"OldExtendedHeader":this.meta=!0;break;default:this.ignore=!0}this.path=e.path,this.mode=e.mode,this.mode&&(this.mode=this.mode&4095),this.uid=e.uid,this.gid=e.gid,this.uname=e.uname,this.gname=e.gname,this.size=e.size,this.mtime=e.mtime,this.atime=e.atime,this.ctime=e.ctime,this.linkpath=e.linkpath,this.uname=e.uname,this.gname=e.gname,r&&this[BD](r),i&&this[BD](i,!0)}write(e){let r=e.length;if(r>this.blockRemain)throw new Error("writing more to entry than is appropriate");let i=this.remain,n=this.blockRemain;return this.remain=Math.max(0,i-r),this.blockRemain=Math.max(0,n-r),this.ignore?!0:i>=r?super.write(e):super.write(e.slice(0,i))}[BD](e,r){for(let i in e)e[i]!==null&&e[i]!==void 0&&!(r&&i==="path")&&(this[i]=e[i])}}});var U6=w(bD=>{"use strict";var kAt=bD.encode=(t,e)=>{if(Number.isSafeInteger(t))t<0?AFe(t,e):aFe(t,e);else throw Error("cannot encode number outside of javascript safe integer range");return e},aFe=(t,e)=>{e[0]=128;for(var r=e.length;r>1;r--)e[r-1]=t&255,t=Math.floor(t/256)},AFe=(t,e)=>{e[0]=255;var r=!1;t=t*-1;for(var i=e.length;i>1;i--){var n=t&255;t=Math.floor(t/256),r?e[i-1]=O6(n):n===0?e[i-1]=0:(r=!0,e[i-1]=M6(n))}},xAt=bD.parse=t=>{var e=t[t.length-1],r=t[0],i;if(r===128)i=cFe(t.slice(1,t.length));else if(r===255)i=lFe(t);else throw Error("invalid base256 encoding");if(!Number.isSafeInteger(i))throw Error("parsed number outside of javascript safe integer range");return i},lFe=t=>{for(var e=t.length,r=0,i=!1,n=e-1;n>-1;n--){var s=t[n],o;i?o=O6(s):s===0?o=s:(i=!0,o=M6(s)),o!==0&&(r-=o*Math.pow(256,e-n-1))}return r},cFe=t=>{for(var e=t.length,r=0,i=e-1;i>-1;i--){var n=t[i];n!==0&&(r+=n*Math.pow(256,e-i-1))}return r},O6=t=>(255^t)&255,M6=t=>(255^t)+1&255});var lf=w((DAt,K6)=>{"use strict";var QD=Xd(),Af=require("path").posix,H6=U6(),vD=Symbol("slurp"),os=Symbol("type"),j6=class{constructor(e,r,i,n){this.cksumValid=!1,this.needPax=!1,this.nullBlock=!1,this.block=null,this.path=null,this.mode=null,this.uid=null,this.gid=null,this.size=null,this.mtime=null,this.cksum=null,this[os]="0",this.linkpath=null,this.uname=null,this.gname=null,this.devmaj=0,this.devmin=0,this.atime=null,this.ctime=null,Buffer.isBuffer(e)?this.decode(e,r||0,i,n):e&&this.set(e)}decode(e,r,i,n){if(r||(r=0),!e||!(e.length>=r+512))throw new Error("need 512 bytes for header");if(this.path=_c(e,r,100),this.mode=Cl(e,r+100,8),this.uid=Cl(e,r+108,8),this.gid=Cl(e,r+116,8),this.size=Cl(e,r+124,12),this.mtime=SD(e,r+136,12),this.cksum=Cl(e,r+148,12),this[vD](i),this[vD](n,!0),this[os]=_c(e,r+156,1),this[os]===""&&(this[os]="0"),this[os]==="0"&&this.path.substr(-1)==="/"&&(this[os]="5"),this[os]==="5"&&(this.size=0),this.linkpath=_c(e,r+157,100),e.slice(r+257,r+265).toString()==="ustar\x0000")if(this.uname=_c(e,r+265,32),this.gname=_c(e,r+297,32),this.devmaj=Cl(e,r+329,8),this.devmin=Cl(e,r+337,8),e[r+475]!==0){let o=_c(e,r+345,155);this.path=o+"/"+this.path}else{let o=_c(e,r+345,130);o&&(this.path=o+"/"+this.path),this.atime=SD(e,r+476,12),this.ctime=SD(e,r+488,12)}let s=8*32;for(let o=r;o<r+148;o++)s+=e[o];for(let o=r+156;o<r+512;o++)s+=e[o];this.cksumValid=s===this.cksum,this.cksum===null&&s===8*32&&(this.nullBlock=!0)}[vD](e,r){for(let i in e)e[i]!==null&&e[i]!==void 0&&!(r&&i==="path")&&(this[i]=e[i])}encode(e,r){if(e||(e=this.block=Buffer.alloc(512),r=0),r||(r=0),!(e.length>=r+512))throw new Error("need 512 bytes for header");let i=this.ctime||this.atime?130:155,n=uFe(this.path||"",i),s=n[0],o=n[1];this.needPax=n[2],this.needPax=Vc(e,r,100,s)||this.needPax,this.needPax=ml(e,r+100,8,this.mode)||this.needPax,this.needPax=ml(e,r+108,8,this.uid)||this.needPax,this.needPax=ml(e,r+116,8,this.gid)||this.needPax,this.needPax=ml(e,r+124,12,this.size)||this.needPax,this.needPax=kD(e,r+136,12,this.mtime)||this.needPax,e[r+156]=this[os].charCodeAt(0),this.needPax=Vc(e,r+157,100,this.linkpath)||this.needPax,e.write("ustar\x0000",r+257,8),this.needPax=Vc(e,r+265,32,this.uname)||this.needPax,this.needPax=Vc(e,r+297,32,this.gname)||this.needPax,this.needPax=ml(e,r+329,8,this.devmaj)||this.needPax,this.needPax=ml(e,r+337,8,this.devmin)||this.needPax,this.needPax=Vc(e,r+345,i,o)||this.needPax,e[r+475]!==0?this.needPax=Vc(e,r+345,155,o)||this.needPax:(this.needPax=Vc(e,r+345,130,o)||this.needPax,this.needPax=kD(e,r+476,12,this.atime)||this.needPax,this.needPax=kD(e,r+488,12,this.ctime)||this.needPax);let a=8*32;for(let l=r;l<r+148;l++)a+=e[l];for(let l=r+156;l<r+512;l++)a+=e[l];return this.cksum=a,ml(e,r+148,8,this.cksum),this.cksumValid=!0,this.needPax}set(e){for(let r in e)e[r]!==null&&e[r]!==void 0&&(this[r]=e[r])}get type(){return QD.name.get(this[os])||this[os]}get typeKey(){return this[os]}set type(e){QD.code.has(e)?this[os]=QD.code.get(e):this[os]=e}},uFe=(t,e)=>{let r=100,i=t,n="",s,o=Af.parse(t).root||".";if(Buffer.byteLength(i)<r)s=[i,n,!1];else{n=Af.dirname(i),i=Af.basename(i);do Buffer.byteLength(i)<=r&&Buffer.byteLength(n)<=e?s=[i,n,!1]:Buffer.byteLength(i)>r&&Buffer.byteLength(n)<=e?s=[i.substr(0,r-1),n,!0]:(i=Af.join(Af.basename(n),i),n=Af.dirname(n));while(n!==o&&!s);s||(s=[t.substr(0,r-1),"",!0])}return s},_c=(t,e,r)=>t.slice(e,e+r).toString("utf8").replace(/\0.*/,""),SD=(t,e,r)=>gFe(Cl(t,e,r)),gFe=t=>t===null?null:new Date(t*1e3),Cl=(t,e,r)=>t[e]&128?H6.parse(t.slice(e,e+r)):fFe(t,e,r),hFe=t=>isNaN(t)?null:t,fFe=(t,e,r)=>hFe(parseInt(t.slice(e,e+r).toString("utf8").replace(/\0.*$/,"").trim(),8)),pFe={12:8589934591,8:2097151},ml=(t,e,r,i)=>i===null?!1:i>pFe[r]||i<0?(H6.encode(i,t.slice(e,e+r)),!0):(dFe(t,e,r,i),!1),dFe=(t,e,r,i)=>t.write(CFe(i,r),e,r,"ascii"),CFe=(t,e)=>mFe(Math.floor(t).toString(8),e),mFe=(t,e)=>(t.length===e-1?t:new Array(e-t.length-1).join("0")+t+" ")+"\0",kD=(t,e,r,i)=>i===null?!1:ml(t,e,r,i.getTime()/1e3),EFe=new Array(156).join("\0"),Vc=(t,e,r,i)=>i===null?!1:(t.write(i+EFe,e,r,"utf8"),i.length!==Buffer.byteLength(i)||i.length>r);K6.exports=j6});var pB=w((RAt,G6)=>{"use strict";var IFe=lf(),yFe=require("path"),hB=class{constructor(e,r){this.atime=e.atime||null,this.charset=e.charset||null,this.comment=e.comment||null,this.ctime=e.ctime||null,this.gid=e.gid||null,this.gname=e.gname||null,this.linkpath=e.linkpath||null,this.mtime=e.mtime||null,this.path=e.path||null,this.size=e.size||null,this.uid=e.uid||null,this.uname=e.uname||null,this.dev=e.dev||null,this.ino=e.ino||null,this.nlink=e.nlink||null,this.global=r||!1}encode(){let e=this.encodeBody();if(e==="")return null;let r=Buffer.byteLength(e),i=512*Math.ceil(1+r/512),n=Buffer.allocUnsafe(i);for(let s=0;s<512;s++)n[s]=0;new IFe({path:("PaxHeader/"+yFe.basename(this.path)).slice(0,99),mode:this.mode||420,uid:this.uid||null,gid:this.gid||null,size:r,mtime:this.mtime||null,type:this.global?"GlobalExtendedHeader":"ExtendedHeader",linkpath:"",uname:this.uname||"",gname:this.gname||"",devmaj:0,devmin:0,atime:this.atime||null,ctime:this.ctime||null}).encode(n),n.write(e,512,r,"utf8");for(let s=r+512;s<n.length;s++)n[s]=0;return n}encodeBody(){return this.encodeField("path")+this.encodeField("ctime")+this.encodeField("atime")+this.encodeField("dev")+this.encodeField("ino")+this.encodeField("nlink")+this.encodeField("charset")+this.encodeField("comment")+this.encodeField("gid")+this.encodeField("gname")+this.encodeField("linkpath")+this.encodeField("mtime")+this.encodeField("size")+this.encodeField("uid")+this.encodeField("uname")}encodeField(e){if(this[e]===null||this[e]===void 0)return"";let r=this[e]instanceof Date?this[e].getTime()/1e3:this[e],i=" "+(e==="dev"||e==="ino"||e==="nlink"?"SCHILY.":"")+e+"="+r+`
+`,n=Buffer.byteLength(i),s=Math.floor(Math.log(n)/Math.log(10))+1;return n+s>=Math.pow(10,s)&&(s+=1),s+n+i}};hB.parse=(t,e,r)=>new hB(wFe(BFe(t),e),r);var wFe=(t,e)=>e?Object.keys(t).reduce((r,i)=>(r[i]=t[i],r),e):t,BFe=t=>t.replace(/\n$/,"").split(`
+`).reduce(bFe,Object.create(null)),bFe=(t,e)=>{let r=parseInt(e,10);if(r!==Buffer.byteLength(e)+1)return t;e=e.substr((r+" ").length);let i=e.split("="),n=i.shift().replace(/^SCHILY\.(dev|ino|nlink)/,"$1");if(!n)return t;let s=i.join("=");return t[n]=/^([A-Z]+\.)?([mac]|birth|creation)time$/.test(n)?new Date(s*1e3):/^[0-9]+$/.test(s)?+s:s,t};G6.exports=hB});var dB=w((FAt,Y6)=>{"use strict";Y6.exports=t=>class extends t{warn(e,r,i={}){this.file&&(i.file=this.file),this.cwd&&(i.cwd=this.cwd),i.code=r instanceof Error&&r.code||e,i.tarCode=e,!this.strict&&i.recoverable!==!1?(r instanceof Error&&(i=Object.assign(r,i),r=r.message),this.emit("warn",i.tarCode,r,i)):r instanceof Error?this.emit("error",Object.assign(r,i)):this.emit("error",Object.assign(new Error(`${e}: ${r}`),i))}}});var PD=w((NAt,q6)=>{"use strict";var CB=["|","<",">","?",":"],xD=CB.map(t=>String.fromCharCode(61440+t.charCodeAt(0))),QFe=new Map(CB.map((t,e)=>[t,xD[e]])),vFe=new Map(xD.map((t,e)=>[t,CB[e]]));q6.exports={encode:t=>CB.reduce((e,r)=>e.split(r).join(QFe.get(r)),t),decode:t=>xD.reduce((e,r)=>e.split(r).join(vFe.get(r)),t)}});var W6=w((LAt,J6)=>{"use strict";J6.exports=(t,e,r)=>(t&=4095,r&&(t=(t|384)&~18),e&&(t&256&&(t|=64),t&32&&(t|=8),t&4&&(t|=1)),t)});var OD=w((KAt,z6)=>{"use strict";var _6=of(),V6=pB(),X6=lf(),TAt=Zd(),ra=require("fs"),cf=require("path"),OAt=Xd(),SFe=16*1024*1024,Z6=Symbol("process"),$6=Symbol("file"),eV=Symbol("directory"),DD=Symbol("symlink"),tV=Symbol("hardlink"),$d=Symbol("header"),mB=Symbol("read"),RD=Symbol("lstat"),EB=Symbol("onlstat"),FD=Symbol("onread"),ND=Symbol("onreadlink"),LD=Symbol("openfile"),TD=Symbol("onopenfile"),Xc=Symbol("close"),IB=Symbol("mode"),rV=dB(),kFe=PD(),iV=W6(),yB=rV(class extends _6{constructor(e,r){if(r=r||{},super(r),typeof e!="string")throw new TypeError("path is required");this.path=e,this.portable=!!r.portable,this.myuid=process.getuid&&process.getuid(),this.myuser=process.env.USER||"",this.maxReadSize=r.maxReadSize||SFe,this.linkCache=r.linkCache||new Map,this.statCache=r.statCache||new Map,this.preservePaths=!!r.preservePaths,this.cwd=r.cwd||process.cwd(),this.strict=!!r.strict,this.noPax=!!r.noPax,this.noMtime=!!r.noMtime,this.mtime=r.mtime||null,typeof r.onwarn=="function"&&this.on("warn",r.onwarn);let i=!1;if(!this.preservePaths&&cf.win32.isAbsolute(e)){let n=cf.win32.parse(e);this.path=e.substr(n.root.length),i=n.root}this.win32=!!r.win32||process.platform==="win32",this.win32&&(this.path=kFe.decode(this.path.replace(/\\/g,"/")),e=e.replace(/\\/g,"/")),this.absolute=r.absolute||cf.resolve(this.cwd,e),this.path===""&&(this.path="./"),i&&this.warn("TAR_ENTRY_INFO",`stripping ${i} from absolute path`,{entry:this,path:i+this.path}),this.statCache.has(this.absolute)?this[EB](this.statCache.get(this.absolute)):this[RD]()}[RD](){ra.lstat(this.absolute,(e,r)=>{if(e)return this.emit("error",e);this[EB](r)})}[EB](e){this.statCache.set(this.absolute,e),this.stat=e,e.isFile()||(e.size=0),this.type=xFe(e),this.emit("stat",e),this[Z6]()}[Z6](){switch(this.type){case"File":return this[$6]();case"Directory":return this[eV]();case"SymbolicLink":return this[DD]();default:return this.end()}}[IB](e){return iV(e,this.type==="Directory",this.portable)}[$d](){this.type==="Directory"&&this.portable&&(this.noMtime=!0),this.header=new X6({path:this.path,linkpath:this.linkpath,mode:this[IB](this.stat.mode),uid:this.portable?null:this.stat.uid,gid:this.portable?null:this.stat.gid,size:this.stat.size,mtime:this.noMtime?null:this.mtime||this.stat.mtime,type:this.type,uname:this.portable?null:this.stat.uid===this.myuid?this.myuser:"",atime:this.portable?null:this.stat.atime,ctime:this.portable?null:this.stat.ctime}),this.header.encode()&&!this.noPax&&this.write(new V6({atime:this.portable?null:this.header.atime,ctime:this.portable?null:this.header.ctime,gid:this.portable?null:this.header.gid,mtime:this.noMtime?null:this.mtime||this.header.mtime,path:this.path,linkpath:this.linkpath,size:this.header.size,uid:this.portable?null:this.header.uid,uname:this.portable?null:this.header.uname,dev:this.portable?null:this.stat.dev,ino:this.portable?null:this.stat.ino,nlink:this.portable?null:this.stat.nlink}).encode()),this.write(this.header.block)}[eV](){this.path.substr(-1)!=="/"&&(this.path+="/"),this.stat.size=0,this[$d](),this.end()}[DD](){ra.readlink(this.absolute,(e,r)=>{if(e)return this.emit("error",e);this[ND](r)})}[ND](e){this.linkpath=e.replace(/\\/g,"/"),this[$d](),this.end()}[tV](e){this.type="Link",this.linkpath=cf.relative(this.cwd,e).replace(/\\/g,"/"),this.stat.size=0,this[$d](),this.end()}[$6](){if(this.stat.nlink>1){let e=this.stat.dev+":"+this.stat.ino;if(this.linkCache.has(e)){let r=this.linkCache.get(e);if(r.indexOf(this.cwd)===0)return this[tV](r)}this.linkCache.set(e,this.absolute)}if(this[$d](),this.stat.size===0)return this.end();this[LD]()}[LD](){ra.open(this.absolute,"r",(e,r)=>{if(e)return this.emit("error",e);this[TD](r)})}[TD](e){let r=512*Math.ceil(this.stat.size/512),i=Math.min(r,this.maxReadSize),n=Buffer.allocUnsafe(i);this[mB](e,n,0,n.length,0,this.stat.size,r)}[mB](e,r,i,n,s,o,a){ra.read(e,r,i,n,s,(l,c)=>{if(l)return this[Xc](e,()=>this.emit("error",l));this[FD](e,r,i,n,s,o,a,c)})}[Xc](e,r){ra.close(e,r)}[FD](e,r,i,n,s,o,a,l){if(l<=0&&o>0){let u=new Error("encountered unexpected EOF");return u.path=this.absolute,u.syscall="read",u.code="EOF",this[Xc](e,()=>this.emit("error",u))}if(l>o){let u=new Error("did not encounter expected EOF");return u.path=this.absolute,u.syscall="read",u.code="EOF",this[Xc](e,()=>this.emit("error",u))}if(l===o)for(let u=l;u<n&&l<a;u++)r[u+i]=0,l++,o++;let c=i===0&&l===r.length?r:r.slice(i,i+l);if(o-=l,a-=l,s+=l,i+=l,this.write(c),!o)return a&&this.write(Buffer.alloc(a)),this[Xc](e,u=>u?this.emit("error",u):this.end());i>=n&&(r=Buffer.allocUnsafe(n),i=0),n=r.length-i,this[mB](e,r,i,n,s,o,a)}}),nV=class extends yB{constructor(e,r){super(e,r)}[RD](){this[EB](ra.lstatSync(this.absolute))}[DD](){this[ND](ra.readlinkSync(this.absolute))}[LD](){this[TD](ra.openSync(this.absolute,"r"))}[mB](e,r,i,n,s,o,a){let l=!0;try{let c=ra.readSync(e,r,i,n,s);this[FD](e,r,i,n,s,o,a,c),l=!1}finally{if(l)try{this[Xc](e,()=>{})}catch(c){}}}[Xc](e,r){ra.closeSync(e),r()}},PFe=rV(class extends _6{constructor(e,r){r=r||{},super(r),this.preservePaths=!!r.preservePaths,this.portable=!!r.portable,this.strict=!!r.strict,this.noPax=!!r.noPax,this.noMtime=!!r.noMtime,this.readEntry=e,this.type=e.type,this.type==="Directory"&&this.portable&&(this.noMtime=!0),this.path=e.path,this.mode=this[IB](e.mode),this.uid=this.portable?null:e.uid,this.gid=this.portable?null:e.gid,this.uname=this.portable?null:e.uname,this.gname=this.portable?null:e.gname,this.size=e.size,this.mtime=this.noMtime?null:r.mtime||e.mtime,this.atime=this.portable?null:e.atime,this.ctime=this.portable?null:e.ctime,this.linkpath=e.linkpath,typeof r.onwarn=="function"&&this.on("warn",r.onwarn);let i=!1;if(cf.isAbsolute(this.path)&&!this.preservePaths){let n=cf.parse(this.path);i=n.root,this.path=this.path.substr(n.root.length)}this.remain=e.size,this.blockRemain=e.startBlockSize,this.header=new X6({path:this.path,linkpath:this.linkpath,mode:this.mode,uid:this.portable?null:this.uid,gid:this.portable?null:this.gid,size:this.size,mtime:this.noMtime?null:this.mtime,type:this.type,uname:this.portable?null:this.uname,atime:this.portable?null:this.atime,ctime:this.portable?null:this.ctime}),i&&this.warn("TAR_ENTRY_INFO",`stripping ${i} from absolute path`,{entry:this,path:i+this.path}),this.header.encode()&&!this.noPax&&super.write(new V6({atime:this.portable?null:this.atime,ctime:this.portable?null:this.ctime,gid:this.portable?null:this.gid,mtime:this.noMtime?null:this.mtime,path:this.path,linkpath:this.linkpath,size:this.size,uid:this.portable?null:this.uid,uname:this.portable?null:this.uname,dev:this.portable?null:this.readEntry.dev,ino:this.portable?null:this.readEntry.ino,nlink:this.portable?null:this.readEntry.nlink}).encode()),super.write(this.header.block),e.pipe(this)}[IB](e){return iV(e,this.type==="Directory",this.portable)}write(e){let r=e.length;if(r>this.blockRemain)throw new Error("writing more to entry than is appropriate");return this.blockRemain-=r,super.write(e)}end(){return this.blockRemain&&this.write(Buffer.alloc(this.blockRemain)),super.end()}});yB.Sync=nV;yB.Tar=PFe;var xFe=t=>t.isFile()?"File":t.isDirectory()?"Directory":t.isSymbolicLink()?"SymbolicLink":"Unsupported";z6.exports=yB});var xB=w((jAt,sV)=>{"use strict";var MD=class{constructor(e,r){this.path=e||"./",this.absolute=r,this.entry=null,this.stat=null,this.readdir=null,this.pending=!1,this.ignore=!1,this.piped=!1}},DFe=of(),RFe=wD(),FFe=Zd(),UD=OD(),NFe=UD.Sync,LFe=UD.Tar,TFe=Bp(),oV=Buffer.alloc(1024),wB=Symbol("onStat"),BB=Symbol("ended"),ia=Symbol("queue"),uf=Symbol("current"),Zc=Symbol("process"),bB=Symbol("processing"),aV=Symbol("processJob"),na=Symbol("jobs"),KD=Symbol("jobDone"),QB=Symbol("addFSEntry"),AV=Symbol("addTarEntry"),HD=Symbol("stat"),jD=Symbol("readdir"),vB=Symbol("onreaddir"),SB=Symbol("pipe"),lV=Symbol("entry"),GD=Symbol("entryOpt"),YD=Symbol("writeEntryClass"),cV=Symbol("write"),qD=Symbol("ondrain"),kB=require("fs"),uV=require("path"),OFe=dB(),JD=OFe(class extends DFe{constructor(e){super(e);e=e||Object.create(null),this.opt=e,this.file=e.file||"",this.cwd=e.cwd||process.cwd(),this.maxReadSize=e.maxReadSize,this.preservePaths=!!e.preservePaths,this.strict=!!e.strict,this.noPax=!!e.noPax,this.prefix=(e.prefix||"").replace(/(\\|\/)+$/,""),this.linkCache=e.linkCache||new Map,this.statCache=e.statCache||new Map,this.readdirCache=e.readdirCache||new Map,this[YD]=UD,typeof e.onwarn=="function"&&this.on("warn",e.onwarn),this.portable=!!e.portable,this.zip=null,e.gzip?(typeof e.gzip!="object"&&(e.gzip={}),this.portable&&(e.gzip.portable=!0),this.zip=new RFe.Gzip(e.gzip),this.zip.on("data",r=>super.write(r)),this.zip.on("end",r=>super.end()),this.zip.on("drain",r=>this[qD]()),this.on("resume",r=>this.zip.resume())):this.on("drain",this[qD]),this.noDirRecurse=!!e.noDirRecurse,this.follow=!!e.follow,this.noMtime=!!e.noMtime,this.mtime=e.mtime||null,this.filter=typeof e.filter=="function"?e.filter:r=>!0,this[ia]=new TFe,this[na]=0,this.jobs=+e.jobs||4,this[bB]=!1,this[BB]=!1}[cV](e){return super.write(e)}add(e){return this.write(e),this}end(e){return e&&this.write(e),this[BB]=!0,this[Zc](),this}write(e){if(this[BB])throw new Error("write after end");return e instanceof FFe?this[AV](e):this[QB](e),this.flowing}[AV](e){let r=uV.resolve(this.cwd,e.path);if(this.prefix&&(e.path=this.prefix+"/"+e.path.replace(/^\.(\/+|$)/,"")),!this.filter(e.path,e))e.resume();else{let i=new MD(e.path,r,!1);i.entry=new LFe(e,this[GD](i)),i.entry.on("end",n=>this[KD](i)),this[na]+=1,this[ia].push(i)}this[Zc]()}[QB](e){let r=uV.resolve(this.cwd,e);this.prefix&&(e=this.prefix+"/"+e.replace(/^\.(\/+|$)/,"")),this[ia].push(new MD(e,r)),this[Zc]()}[HD](e){e.pending=!0,this[na]+=1;let r=this.follow?"stat":"lstat";kB[r](e.absolute,(i,n)=>{e.pending=!1,this[na]-=1,i?this.emit("error",i):this[wB](e,n)})}[wB](e,r){this.statCache.set(e.absolute,r),e.stat=r,this.filter(e.path,r)||(e.ignore=!0),this[Zc]()}[jD](e){e.pending=!0,this[na]+=1,kB.readdir(e.absolute,(r,i)=>{if(e.pending=!1,this[na]-=1,r)return this.emit("error",r);this[vB](e,i)})}[vB](e,r){this.readdirCache.set(e.absolute,r),e.readdir=r,this[Zc]()}[Zc](){if(!this[bB]){this[bB]=!0;for(let e=this[ia].head;e!==null&&this[na]<this.jobs;e=e.next)if(this[aV](e.value),e.value.ignore){let r=e.next;this[ia].removeNode(e),e.next=r}this[bB]=!1,this[BB]&&!this[ia].length&&this[na]===0&&(this.zip?this.zip.end(oV):(super.write(oV),super.end()))}}get[uf](){return this[ia]&&this[ia].head&&this[ia].head.value}[KD](e){this[ia].shift(),this[na]-=1,this[Zc]()}[aV](e){if(!e.pending){if(e.entry){e===this[uf]&&!e.piped&&this[SB](e);return}if(e.stat||(this.statCache.has(e.absolute)?this[wB](e,this.statCache.get(e.absolute)):this[HD](e)),!!e.stat&&!e.ignore&&!(!this.noDirRecurse&&e.stat.isDirectory()&&!e.readdir&&(this.readdirCache.has(e.absolute)?this[vB](e,this.readdirCache.get(e.absolute)):this[jD](e),!e.readdir))){if(e.entry=this[lV](e),!e.entry){e.ignore=!0;return}e===this[uf]&&!e.piped&&this[SB](e)}}}[GD](e){return{onwarn:(r,i,n)=>this.warn(r,i,n),noPax:this.noPax,cwd:this.cwd,absolute:e.absolute,preservePaths:this.preservePaths,maxReadSize:this.maxReadSize,strict:this.strict,portable:this.portable,linkCache:this.linkCache,statCache:this.statCache,noMtime:this.noMtime,mtime:this.mtime}}[lV](e){this[na]+=1;try{return new this[YD](e.path,this[GD](e)).on("end",()=>this[KD](e)).on("error",r=>this.emit("error",r))}catch(r){this.emit("error",r)}}[qD](){this[uf]&&this[uf].entry&&this[uf].entry.resume()}[SB](e){e.piped=!0,e.readdir&&e.readdir.forEach(n=>{let s=this.prefix?e.path.slice(this.prefix.length+1)||"./":e.path,o=s==="./"?"":s.replace(/\/*$/,"/");this[QB](o+n)});let r=e.entry,i=this.zip;i?r.on("data",n=>{i.write(n)||r.pause()}):r.on("data",n=>{super.write(n)||r.pause()})}pause(){return this.zip&&this.zip.pause(),super.pause()}}),gV=class extends JD{constructor(e){super(e);this[YD]=NFe}pause(){}resume(){}[HD](e){let r=this.follow?"statSync":"lstatSync";this[wB](e,kB[r](e.absolute))}[jD](e,r){this[vB](e,kB.readdirSync(e.absolute))}[SB](e){let r=e.entry,i=this.zip;e.readdir&&e.readdir.forEach(n=>{let s=this.prefix?e.path.slice(this.prefix.length+1)||"./":e.path,o=s==="./"?"":s.replace(/\/*$/,"/");this[QB](o+n)}),i?r.on("data",n=>{i.write(n)}):r.on("data",n=>{super[cV](n)})}};JD.Sync=gV;sV.exports=JD});var Cf=w(eC=>{"use strict";var MFe=of(),UFe=require("events").EventEmitter,Ms=require("fs"),PB=process.binding("fs"),GAt=PB.writeBuffers,KFe=PB.FSReqWrap||PB.FSReqCallback,gf=Symbol("_autoClose"),sa=Symbol("_close"),tC=Symbol("_ended"),or=Symbol("_fd"),fV=Symbol("_finished"),$c=Symbol("_flags"),WD=Symbol("_flush"),zD=Symbol("_handleChunk"),_D=Symbol("_makeBuf"),VD=Symbol("_mode"),DB=Symbol("_needDrain"),ff=Symbol("_onerror"),hf=Symbol("_onopen"),XD=Symbol("_onread"),eu=Symbol("_onwrite"),El=Symbol("_open"),Il=Symbol("_path"),tu=Symbol("_pos"),oa=Symbol("_queue"),pf=Symbol("_read"),hV=Symbol("_readSize"),yl=Symbol("_reading"),RB=Symbol("_remain"),pV=Symbol("_size"),FB=Symbol("_write"),df=Symbol("_writing"),NB=Symbol("_defaultFlag"),ZD=class extends MFe{constructor(e,r){if(r=r||{},super(r),this.writable=!1,typeof e!="string")throw new TypeError("path must be a string");this[or]=typeof r.fd=="number"?r.fd:null,this[Il]=e,this[hV]=r.readSize||16*1024*1024,this[yl]=!1,this[pV]=typeof r.size=="number"?r.size:Infinity,this[RB]=this[pV],this[gf]=typeof r.autoClose=="boolean"?r.autoClose:!0,typeof this[or]=="number"?this[pf]():this[El]()}get fd(){return this[or]}get path(){return this[Il]}write(){throw new TypeError("this is a readable stream")}end(){throw new TypeError("this is a readable stream")}[El](){Ms.open(this[Il],"r",(e,r)=>this[hf](e,r))}[hf](e,r){e?this[ff](e):(this[or]=r,this.emit("open",r),this[pf]())}[_D](){return Buffer.allocUnsafe(Math.min(this[hV],this[RB]))}[pf](){if(!this[yl]){this[yl]=!0;let e=this[_D]();if(e.length===0)return process.nextTick(()=>this[XD](null,0,e));Ms.read(this[or],e,0,e.length,null,(r,i,n)=>this[XD](r,i,n))}}[XD](e,r,i){this[yl]=!1,e?this[ff](e):this[zD](r,i)&&this[pf]()}[sa](){this[gf]&&typeof this[or]=="number"&&(Ms.close(this[or],e=>this.emit("close")),this[or]=null)}[ff](e){this[yl]=!0,this[sa](),this.emit("error",e)}[zD](e,r){let i=!1;return this[RB]-=e,e>0&&(i=super.write(e<r.length?r.slice(0,e):r)),(e===0||this[RB]<=0)&&(i=!1,this[sa](),super.end()),i}emit(e,r){switch(e){case"prefinish":case"finish":break;case"drain":typeof this[or]=="number"&&this[pf]();break;default:return super.emit(e,r)}}},dV=class extends ZD{[El](){let e=!0;try{this[hf](null,Ms.openSync(this[Il],"r")),e=!1}finally{e&&this[sa]()}}[pf](){let e=!0;try{if(!this[yl]){this[yl]=!0;do{let r=this[_D](),i=r.length===0?0:Ms.readSync(this[or],r,0,r.length,null);if(!this[zD](i,r))break}while(!0);this[yl]=!1}e=!1}finally{e&&this[sa]()}}[sa](){if(this[gf]&&typeof this[or]=="number"){try{Ms.closeSync(this[or])}catch(e){}this[or]=null,this.emit("close")}}},$D=class extends UFe{constructor(e,r){r=r||{},super(r),this.readable=!1,this[df]=!1,this[tC]=!1,this[DB]=!1,this[oa]=[],this[Il]=e,this[or]=typeof r.fd=="number"?r.fd:null,this[VD]=r.mode===void 0?438:r.mode,this[tu]=typeof r.start=="number"?r.start:null,this[gf]=typeof r.autoClose=="boolean"?r.autoClose:!0;let i=this[tu]!==null?"r+":"w";this[NB]=r.flags===void 0,this[$c]=this[NB]?i:r.flags,this[or]===null&&this[El]()}get fd(){return this[or]}get path(){return this[Il]}[ff](e){this[sa](),this[df]=!0,this.emit("error",e)}[El](){Ms.open(this[Il],this[$c],this[VD],(e,r)=>this[hf](e,r))}[hf](e,r){this[NB]&&this[$c]==="r+"&&e&&e.code==="ENOENT"?(this[$c]="w",this[El]()):e?this[ff](e):(this[or]=r,this.emit("open",r),this[WD]())}end(e,r){e&&this.write(e,r),this[tC]=!0,!this[df]&&!this[oa].length&&typeof this[or]=="number"&&this[eu](null,0)}write(e,r){return typeof e=="string"&&(e=new Buffer(e,r)),this[tC]?(this.emit("error",new Error("write() after end()")),!1):this[or]===null||this[df]||this[oa].length?(this[oa].push(e),this[DB]=!0,!1):(this[df]=!0,this[FB](e),!0)}[FB](e){Ms.write(this[or],e,0,e.length,this[tu],(r,i)=>this[eu](r,i))}[eu](e,r){e?this[ff](e):(this[tu]!==null&&(this[tu]+=r),this[oa].length?this[WD]():(this[df]=!1,this[tC]&&!this[fV]?(this[fV]=!0,this[sa](),this.emit("finish")):this[DB]&&(this[DB]=!1,this.emit("drain"))))}[WD](){if(this[oa].length===0)this[tC]&&this[eu](null,0);else if(this[oa].length===1)this[FB](this[oa].pop());else{let e=this[oa];this[oa]=[],HFe(this[or],e,this[tu],(r,i)=>this[eu](r,i))}}[sa](){this[gf]&&typeof this[or]=="number"&&(Ms.close(this[or],e=>this.emit("close")),this[or]=null)}},CV=class extends $D{[El](){let e;try{e=Ms.openSync(this[Il],this[$c],this[VD])}catch(r){if(this[NB]&&this[$c]==="r+"&&r&&r.code==="ENOENT")return this[$c]="w",this[El]();throw r}this[hf](null,e)}[sa](){if(this[gf]&&typeof this[or]=="number"){try{Ms.closeSync(this[or])}catch(e){}this[or]=null,this.emit("close")}}[FB](e){try{this[eu](null,Ms.writeSync(this[or],e,0,e.length,this[tu]))}catch(r){this[eu](r,0)}}},HFe=(t,e,r,i)=>{let n=(o,a)=>i(o,a,e),s=new KFe;s.oncomplete=n,PB.writeBuffers(t,e,r,s)};eC.ReadStream=ZD;eC.ReadStreamSync=dV;eC.WriteStream=$D;eC.WriteStreamSync=CV});var nC=w((WAt,mV)=>{"use strict";var jFe=dB(),qAt=require("path"),GFe=lf(),YFe=require("events"),qFe=Bp(),JFe=1024*1024,WFe=Zd(),EV=pB(),zFe=wD(),eR=Buffer.from([31,139]),Us=Symbol("state"),ru=Symbol("writeEntry"),aA=Symbol("readEntry"),tR=Symbol("nextEntry"),IV=Symbol("processEntry"),Ks=Symbol("extendedHeader"),rC=Symbol("globalExtendedHeader"),wl=Symbol("meta"),yV=Symbol("emitMeta"),yr=Symbol("buffer"),AA=Symbol("queue"),iu=Symbol("ended"),wV=Symbol("emittedEnd"),nu=Symbol("emit"),Ln=Symbol("unzip"),LB=Symbol("consumeChunk"),TB=Symbol("consumeChunkSub"),rR=Symbol("consumeBody"),BV=Symbol("consumeMeta"),bV=Symbol("consumeHeader"),OB=Symbol("consuming"),iR=Symbol("bufferConcat"),nR=Symbol("maybeEnd"),iC=Symbol("writing"),Bl=Symbol("aborted"),MB=Symbol("onDone"),su=Symbol("sawValidEntry"),UB=Symbol("sawNullBlock"),KB=Symbol("sawEOF"),_Fe=t=>!0;mV.exports=jFe(class extends YFe{constructor(e){e=e||{},super(e),this.file=e.file||"",this[su]=null,this.on(MB,r=>{(this[Us]==="begin"||this[su]===!1)&&this.warn("TAR_BAD_ARCHIVE","Unrecognized archive format")}),e.ondone?this.on(MB,e.ondone):this.on(MB,r=>{this.emit("prefinish"),this.emit("finish"),this.emit("end"),this.emit("close")}),this.strict=!!e.strict,this.maxMetaEntrySize=e.maxMetaEntrySize||JFe,this.filter=typeof e.filter=="function"?e.filter:_Fe,this.writable=!0,this.readable=!1,this[AA]=new qFe,this[yr]=null,this[aA]=null,this[ru]=null,this[Us]="begin",this[wl]="",this[Ks]=null,this[rC]=null,this[iu]=!1,this[Ln]=null,this[Bl]=!1,this[UB]=!1,this[KB]=!1,typeof e.onwarn=="function"&&this.on("warn",e.onwarn),typeof e.onentry=="function"&&this.on("entry",e.onentry)}[bV](e,r){this[su]===null&&(this[su]=!1);let i;try{i=new GFe(e,r,this[Ks],this[rC])}catch(n){return this.warn("TAR_ENTRY_INVALID",n)}if(i.nullBlock)this[UB]?(this[KB]=!0,this[Us]==="begin"&&(this[Us]="header"),this[nu]("eof")):(this[UB]=!0,this[nu]("nullBlock"));else if(this[UB]=!1,!i.cksumValid)this.warn("TAR_ENTRY_INVALID","checksum failure",{header:i});else if(!i.path)this.warn("TAR_ENTRY_INVALID","path is required",{header:i});else{let n=i.type;if(/^(Symbolic)?Link$/.test(n)&&!i.linkpath)this.warn("TAR_ENTRY_INVALID","linkpath required",{header:i});else if(!/^(Symbolic)?Link$/.test(n)&&i.linkpath)this.warn("TAR_ENTRY_INVALID","linkpath forbidden",{header:i});else{let s=this[ru]=new WFe(i,this[Ks],this[rC]);if(!this[su])if(s.remain){let o=()=>{s.invalid||(this[su]=!0)};s.on("end",o)}else this[su]=!0;s.meta?s.size>this.maxMetaEntrySize?(s.ignore=!0,this[nu]("ignoredEntry",s),this[Us]="ignore",s.resume()):s.size>0&&(this[wl]="",s.on("data",o=>this[wl]+=o),this[Us]="meta"):(this[Ks]=null,s.ignore=s.ignore||!this.filter(s.path,s),s.ignore?(this[nu]("ignoredEntry",s),this[Us]=s.remain?"ignore":"header",s.resume()):(s.remain?this[Us]="body":(this[Us]="header",s.end()),this[aA]?this[AA].push(s):(this[AA].push(s),this[tR]())))}}}[IV](e){let r=!0;return e?Array.isArray(e)?this.emit.apply(this,e):(this[aA]=e,this.emit("entry",e),e.emittedEnd||(e.on("end",i=>this[tR]()),r=!1)):(this[aA]=null,r=!1),r}[tR](){do;while(this[IV](this[AA].shift()));if(!this[AA].length){let e=this[aA];!e||e.flowing||e.size===e.remain?this[iC]||this.emit("drain"):e.once("drain",i=>this.emit("drain"))}}[rR](e,r){let i=this[ru],n=i.blockRemain,s=n>=e.length&&r===0?e:e.slice(r,r+n);return i.write(s),i.blockRemain||(this[Us]="header",this[ru]=null,i.end()),s.length}[BV](e,r){let i=this[ru],n=this[rR](e,r);return this[ru]||this[yV](i),n}[nu](e,r,i){!this[AA].length&&!this[aA]?this.emit(e,r,i):this[AA].push([e,r,i])}[yV](e){switch(this[nu]("meta",this[wl]),e.type){case"ExtendedHeader":case"OldExtendedHeader":this[Ks]=EV.parse(this[wl],this[Ks],!1);break;case"GlobalExtendedHeader":this[rC]=EV.parse(this[wl],this[rC],!0);break;case"NextFileHasLongPath":case"OldGnuLongPath":this[Ks]=this[Ks]||Object.create(null),this[Ks].path=this[wl].replace(/\0.*/,"");break;case"NextFileHasLongLinkpath":this[Ks]=this[Ks]||Object.create(null),this[Ks].linkpath=this[wl].replace(/\0.*/,"");break;default:throw new Error("unknown meta: "+e.type)}}abort(e){this[Bl]=!0,this.emit("abort",e),this.warn("TAR_ABORT",e,{recoverable:!1})}write(e){if(this[Bl])return;if(this[Ln]===null&&e){if(this[yr]&&(e=Buffer.concat([this[yr],e]),this[yr]=null),e.length<eR.length)return this[yr]=e,!0;for(let i=0;this[Ln]===null&&i<eR.length;i++)e[i]!==eR[i]&&(this[Ln]=!1);if(this[Ln]===null){let i=this[iu];this[iu]=!1,this[Ln]=new zFe.Unzip,this[Ln].on("data",s=>this[LB](s)),this[Ln].on("error",s=>this.abort(s)),this[Ln].on("end",s=>{this[iu]=!0,this[LB]()}),this[iC]=!0;let n=this[Ln][i?"end":"write"](e);return this[iC]=!1,n}}this[iC]=!0,this[Ln]?this[Ln].write(e):this[LB](e),this[iC]=!1;let r=this[AA].length?!1:this[aA]?this[aA].flowing:!0;return!r&&!this[AA].length&&this[aA].once("drain",i=>this.emit("drain")),r}[iR](e){e&&!this[Bl]&&(this[yr]=this[yr]?Buffer.concat([this[yr],e]):e)}[nR](){if(this[iu]&&!this[wV]&&!this[Bl]&&!this[OB]){this[wV]=!0;let e=this[ru];if(e&&e.blockRemain){let r=this[yr]?this[yr].length:0;this.warn("TAR_BAD_ARCHIVE",`Truncated input (needed ${e.blockRemain} more bytes, only ${r} available)`,{entry:e}),this[yr]&&e.write(this[yr]),e.end()}this[nu](MB)}}[LB](e){if(this[OB])this[iR](e);else if(!e&&!this[yr])this[nR]();else{if(this[OB]=!0,this[yr]){this[iR](e);let r=this[yr];this[yr]=null,this[TB](r)}else this[TB](e);for(;this[yr]&&this[yr].length>=512&&!this[Bl]&&!this[KB];){let r=this[yr];this[yr]=null,this[TB](r)}this[OB]=!1}(!this[yr]||this[iu])&&this[nR]()}[TB](e){let r=0,i=e.length;for(;r+512<=i&&!this[Bl]&&!this[KB];)switch(this[Us]){case"begin":case"header":this[bV](e,r),r+=512;break;case"ignore":case"body":r+=this[rR](e,r);break;case"meta":r+=this[BV](e,r);break;default:throw new Error("invalid state: "+this[Us])}r<i&&(this[yr]?this[yr]=Buffer.concat([e.slice(r),this[yr]]):this[yr]=e.slice(r))}end(e){this[Bl]||(this[Ln]?this[Ln].end(e):(this[iu]=!0,this.write(e)))}})});var HB=w((_At,QV)=>{"use strict";var VFe=sf(),vV=nC(),mf=require("fs"),XFe=Cf(),SV=require("path"),zAt=QV.exports=(t,e,r)=>{typeof t=="function"?(r=t,e=null,t={}):Array.isArray(t)&&(e=t,t={}),typeof e=="function"&&(r=e,e=null),e?e=Array.from(e):e=[];let i=VFe(t);if(i.sync&&typeof r=="function")throw new TypeError("callback not supported for sync tar functions");if(!i.file&&typeof r=="function")throw new TypeError("callback only supported with file option");return e.length&&$Fe(i,e),i.noResume||ZFe(i),i.file&&i.sync?eNe(i):i.file?tNe(i,r):kV(i)},ZFe=t=>{let e=t.onentry;t.onentry=e?r=>{e(r),r.resume()}:r=>r.resume()},$Fe=(t,e)=>{let r=new Map(e.map(s=>[s.replace(/\/+$/,""),!0])),i=t.filter,n=(s,o)=>{let a=o||SV.parse(s).root||".",l=s===a?!1:r.has(s)?r.get(s):n(SV.dirname(s),a);return r.set(s,l),l};t.filter=i?(s,o)=>i(s,o)&&n(s.replace(/\/+$/,"")):s=>n(s.replace(/\/+$/,""))},eNe=t=>{let e=kV(t),r=t.file,i=!0,n;try{let s=mf.statSync(r),o=t.maxReadSize||16*1024*1024;if(s.size<o)e.end(mf.readFileSync(r));else{let a=0,l=Buffer.allocUnsafe(o);for(n=mf.openSync(r,"r");a<s.size;){let c=mf.readSync(n,l,0,o,a);a+=c,e.write(l.slice(0,c))}e.end()}i=!1}finally{if(i&&n)try{mf.closeSync(n)}catch(s){}}},tNe=(t,e)=>{let r=new vV(t),i=t.maxReadSize||16*1024*1024,n=t.file,s=new Promise((o,a)=>{r.on("error",a),r.on("end",o),mf.stat(n,(l,c)=>{if(l)a(l);else{let u=new XFe.ReadStream(n,{readSize:i,size:c.size});u.on("error",a),u.pipe(r)}})});return e?s.then(e,e):s},kV=t=>new vV(t)});var NV=w((ZAt,xV)=>{"use strict";var rNe=sf(),jB=xB(),VAt=require("fs"),PV=Cf(),DV=HB(),RV=require("path"),XAt=xV.exports=(t,e,r)=>{if(typeof e=="function"&&(r=e),Array.isArray(t)&&(e=t,t={}),!e||!Array.isArray(e)||!e.length)throw new TypeError("no files or directories specified");e=Array.from(e);let i=rNe(t);if(i.sync&&typeof r=="function")throw new TypeError("callback not supported for sync tar functions");if(!i.file&&typeof r=="function")throw new TypeError("callback only supported with file option");return i.file&&i.sync?iNe(i,e):i.file?nNe(i,e,r):i.sync?sNe(i,e):oNe(i,e)},iNe=(t,e)=>{let r=new jB.Sync(t),i=new PV.WriteStreamSync(t.file,{mode:t.mode||438});r.pipe(i),FV(r,e)},nNe=(t,e,r)=>{let i=new jB(t),n=new PV.WriteStream(t.file,{mode:t.mode||438});i.pipe(n);let s=new Promise((o,a)=>{n.on("error",a),n.on("close",o),i.on("error",a)});return sR(i,e),r?s.then(r,r):s},FV=(t,e)=>{e.forEach(r=>{r.charAt(0)==="@"?DV({file:RV.resolve(t.cwd,r.substr(1)),sync:!0,noResume:!0,onentry:i=>t.add(i)}):t.add(r)}),t.end()},sR=(t,e)=>{for(;e.length;){let r=e.shift();if(r.charAt(0)==="@")return DV({file:RV.resolve(t.cwd,r.substr(1)),noResume:!0,onentry:i=>t.add(i)}).then(i=>sR(t,e));t.add(r)}t.end()},sNe=(t,e)=>{let r=new jB.Sync(t);return FV(r,e),r},oNe=(t,e)=>{let r=new jB(t);return sR(r,e),r}});var oR=w((tlt,LV)=>{"use strict";var aNe=sf(),TV=xB(),$At=nC(),Hs=require("fs"),OV=Cf(),MV=HB(),UV=require("path"),KV=lf(),elt=LV.exports=(t,e,r)=>{let i=aNe(t);if(!i.file)throw new TypeError("file is required");if(i.gzip)throw new TypeError("cannot append to compressed archives");if(!e||!Array.isArray(e)||!e.length)throw new TypeError("no files or directories specified");return e=Array.from(e),i.sync?ANe(i,e):lNe(i,e,r)},ANe=(t,e)=>{let r=new TV.Sync(t),i=!0,n,s;try{try{n=Hs.openSync(t.file,"r+")}catch(l){if(l.code==="ENOENT")n=Hs.openSync(t.file,"w+");else throw l}let o=Hs.fstatSync(n),a=Buffer.alloc(512);e:for(s=0;s<o.size;s+=512){for(let u=0,g=0;u<512;u+=g){if(g=Hs.readSync(n,a,u,a.length-u,s+u),s===0&&a[0]===31&&a[1]===139)throw new Error("cannot append to compressed archives");if(!g)break e}let l=new KV(a);if(!l.cksumValid)break;let c=512*Math.ceil(l.size/512);if(s+c+512>o.size)break;s+=c,t.mtimeCache&&t.mtimeCache.set(l.path,l.mtime)}i=!1,cNe(t,r,s,n,e)}finally{if(i)try{Hs.closeSync(n)}catch(o){}}},cNe=(t,e,r,i,n)=>{let s=new OV.WriteStreamSync(t.file,{fd:i,start:r});e.pipe(s),uNe(e,n)},lNe=(t,e,r)=>{e=Array.from(e);let i=new TV(t),n=(o,a,l)=>{let c=(p,m)=>{p?Hs.close(o,y=>l(p)):l(null,m)},u=0;if(a===0)return c(null,0);let g=0,f=Buffer.alloc(512),h=(p,m)=>{if(p)return c(p);if(g+=m,g<512&&m)return Hs.read(o,f,g,f.length-g,u+g,h);if(u===0&&f[0]===31&&f[1]===139)return c(new Error("cannot append to compressed archives"));if(g<512)return c(null,u);let y=new KV(f);if(!y.cksumValid)return c(null,u);let Q=512*Math.ceil(y.size/512);if(u+Q+512>a||(u+=Q+512,u>=a))return c(null,u);t.mtimeCache&&t.mtimeCache.set(y.path,y.mtime),g=0,Hs.read(o,f,0,512,u,h)};Hs.read(o,f,0,512,u,h)},s=new Promise((o,a)=>{i.on("error",a);let l="r+",c=(u,g)=>{if(u&&u.code==="ENOENT"&&l==="r+")return l="w+",Hs.open(t.file,l,c);if(u)return a(u);Hs.fstat(g,(f,h)=>{if(f)return a(f);n(g,h.size,(p,m)=>{if(p)return a(p);let y=new OV.WriteStream(t.file,{fd:g,start:m});i.pipe(y),y.on("error",a),y.on("close",o),HV(i,e)})})};Hs.open(t.file,l,c)});return r?s.then(r,r):s},uNe=(t,e)=>{e.forEach(r=>{r.charAt(0)==="@"?MV({file:UV.resolve(t.cwd,r.substr(1)),sync:!0,noResume:!0,onentry:i=>t.add(i)}):t.add(r)}),t.end()},HV=(t,e)=>{for(;e.length;){let r=e.shift();if(r.charAt(0)==="@")return MV({file:UV.resolve(t.cwd,r.substr(1)),noResume:!0,onentry:i=>t.add(i)}).then(i=>HV(t,e));t.add(r)}t.end()}});var GV=w((ilt,jV)=>{"use strict";var gNe=sf(),fNe=oR(),rlt=jV.exports=(t,e,r)=>{let i=gNe(t);if(!i.file)throw new TypeError("file is required");if(i.gzip)throw new TypeError("cannot append to compressed archives");if(!e||!Array.isArray(e)||!e.length)throw new TypeError("no files or directories specified");return e=Array.from(e),hNe(i),fNe(i,e,r)},hNe=t=>{let e=t.filter;t.mtimeCache||(t.mtimeCache=new Map),t.filter=e?(r,i)=>e(r,i)&&!(t.mtimeCache.get(r)>i.mtime):(r,i)=>!(t.mtimeCache.get(r)>i.mtime)}});var JV=w((nlt,YV)=>{var{promisify:qV}=require("util"),bl=require("fs"),pNe=t=>{if(!t)t={mode:511,fs:bl};else if(typeof t=="object")t=N({mode:511,fs:bl},t);else if(typeof t=="number")t={mode:t,fs:bl};else if(typeof t=="string")t={mode:parseInt(t,8),fs:bl};else throw new TypeError("invalid options argument");return t.mkdir=t.mkdir||t.fs.mkdir||bl.mkdir,t.mkdirAsync=qV(t.mkdir),t.stat=t.stat||t.fs.stat||bl.stat,t.statAsync=qV(t.stat),t.statSync=t.statSync||t.fs.statSync||bl.statSync,t.mkdirSync=t.mkdirSync||t.fs.mkdirSync||bl.mkdirSync,t};YV.exports=pNe});var zV=w((slt,WV)=>{var dNe=process.env.__TESTING_MKDIRP_PLATFORM__||process.platform,{resolve:CNe,parse:mNe}=require("path"),ENe=t=>{if(/\0/.test(t))throw Object.assign(new TypeError("path must be a string without null bytes"),{path:t,code:"ERR_INVALID_ARG_VALUE"});if(t=CNe(t),dNe==="win32"){let e=/[*|"<>?:]/,{root:r}=mNe(t);if(e.test(t.substr(r.length)))throw Object.assign(new Error("Illegal characters in path."),{path:t,code:"EINVAL"})}return t};WV.exports=ENe});var $V=w((olt,_V)=>{var{dirname:VV}=require("path"),XV=(t,e,r=void 0)=>r===e?Promise.resolve():t.statAsync(e).then(i=>i.isDirectory()?r:void 0,i=>i.code==="ENOENT"?XV(t,VV(e),e):void 0),ZV=(t,e,r=void 0)=>{if(r!==e)try{return t.statSync(e).isDirectory()?r:void 0}catch(i){return i.code==="ENOENT"?ZV(t,VV(e),e):void 0}};_V.exports={findMade:XV,findMadeSync:ZV}});var lR=w((alt,e9)=>{var{dirname:t9}=require("path"),aR=(t,e,r)=>{e.recursive=!1;let i=t9(t);return i===t?e.mkdirAsync(t,e).catch(n=>{if(n.code!=="EISDIR")throw n}):e.mkdirAsync(t,e).then(()=>r||t,n=>{if(n.code==="ENOENT")return aR(i,e).then(s=>aR(t,e,s));if(n.code!=="EEXIST"&&n.code!=="EROFS")throw n;return e.statAsync(t).then(s=>{if(s.isDirectory())return r;throw n},()=>{throw n})})},AR=(t,e,r)=>{let i=t9(t);if(e.recursive=!1,i===t)try{return e.mkdirSync(t,e)}catch(n){if(n.code!=="EISDIR")throw n;return}try{return e.mkdirSync(t,e),r||t}catch(n){if(n.code==="ENOENT")return AR(t,e,AR(i,e,r));if(n.code!=="EEXIST"&&n.code!=="EROFS")throw n;try{if(!e.statSync(t).isDirectory())throw n}catch(s){throw n}}};e9.exports={mkdirpManual:aR,mkdirpManualSync:AR}});var n9=w((Alt,r9)=>{var{dirname:i9}=require("path"),{findMade:INe,findMadeSync:yNe}=$V(),{mkdirpManual:wNe,mkdirpManualSync:BNe}=lR(),bNe=(t,e)=>(e.recursive=!0,i9(t)===t?e.mkdirAsync(t,e):INe(e,t).then(i=>e.mkdirAsync(t,e).then(()=>i).catch(n=>{if(n.code==="ENOENT")return wNe(t,e);throw n}))),QNe=(t,e)=>{if(e.recursive=!0,i9(t)===t)return e.mkdirSync(t,e);let i=yNe(e,t);try{return e.mkdirSync(t,e),i}catch(n){if(n.code==="ENOENT")return BNe(t,e);throw n}};r9.exports={mkdirpNative:bNe,mkdirpNativeSync:QNe}});var A9=w((llt,s9)=>{var o9=require("fs"),vNe=process.env.__TESTING_MKDIRP_NODE_VERSION__||process.version,cR=vNe.replace(/^v/,"").split("."),a9=+cR[0]>10||+cR[0]==10&&+cR[1]>=12,SNe=a9?t=>t.mkdir===o9.mkdir:()=>!1,kNe=a9?t=>t.mkdirSync===o9.mkdirSync:()=>!1;s9.exports={useNative:SNe,useNativeSync:kNe}});var h9=w((clt,l9)=>{var Ef=JV(),If=zV(),{mkdirpNative:c9,mkdirpNativeSync:u9}=n9(),{mkdirpManual:g9,mkdirpManualSync:f9}=lR(),{useNative:xNe,useNativeSync:PNe}=A9(),yf=(t,e)=>(t=If(t),e=Ef(e),xNe(e)?c9(t,e):g9(t,e)),DNe=(t,e)=>(t=If(t),e=Ef(e),PNe(e)?u9(t,e):f9(t,e));yf.sync=DNe;yf.native=(t,e)=>c9(If(t),Ef(e));yf.manual=(t,e)=>g9(If(t),Ef(e));yf.nativeSync=(t,e)=>u9(If(t),Ef(e));yf.manualSync=(t,e)=>f9(If(t),Ef(e));l9.exports=yf});var y9=w((ult,p9)=>{"use strict";var js=require("fs"),ou=require("path"),RNe=js.lchown?"lchown":"chown",FNe=js.lchownSync?"lchownSync":"chownSync",d9=js.lchown&&!process.version.match(/v1[1-9]+\./)&&!process.version.match(/v10\.[6-9]/),C9=(t,e,r)=>{try{return js[FNe](t,e,r)}catch(i){if(i.code!=="ENOENT")throw i}},NNe=(t,e,r)=>{try{return js.chownSync(t,e,r)}catch(i){if(i.code!=="ENOENT")throw i}},LNe=d9?(t,e,r,i)=>n=>{!n||n.code!=="EISDIR"?i(n):js.chown(t,e,r,i)}:(t,e,r,i)=>i,uR=d9?(t,e,r)=>{try{return C9(t,e,r)}catch(i){if(i.code!=="EISDIR")throw i;NNe(t,e,r)}}:(t,e,r)=>C9(t,e,r),TNe=process.version,m9=(t,e,r)=>js.readdir(t,e,r),ONe=(t,e)=>js.readdirSync(t,e);/^v4\./.test(TNe)&&(m9=(t,e,r)=>js.readdir(t,r));var GB=(t,e,r,i)=>{js[RNe](t,e,r,LNe(t,e,r,n=>{i(n&&n.code!=="ENOENT"?n:null)}))},E9=(t,e,r,i,n)=>{if(typeof e=="string")return js.lstat(ou.resolve(t,e),(s,o)=>{if(s)return n(s.code!=="ENOENT"?s:null);o.name=e,E9(t,o,r,i,n)});if(e.isDirectory())gR(ou.resolve(t,e.name),r,i,s=>{if(s)return n(s);let o=ou.resolve(t,e.name);GB(o,r,i,n)});else{let s=ou.resolve(t,e.name);GB(s,r,i,n)}},gR=(t,e,r,i)=>{m9(t,{withFileTypes:!0},(n,s)=>{if(n){if(n.code==="ENOENT")return i();if(n.code!=="ENOTDIR"&&n.code!=="ENOTSUP")return i(n)}if(n||!s.length)return GB(t,e,r,i);let o=s.length,a=null,l=c=>{if(!a){if(c)return i(a=c);if(--o==0)return GB(t,e,r,i)}};s.forEach(c=>E9(t,c,e,r,l))})},MNe=(t,e,r,i)=>{if(typeof e=="string")try{let n=js.lstatSync(ou.resolve(t,e));n.name=e,e=n}catch(n){if(n.code==="ENOENT")return;throw n}e.isDirectory()&&I9(ou.resolve(t,e.name),r,i),uR(ou.resolve(t,e.name),r,i)},I9=(t,e,r)=>{let i;try{i=ONe(t,{withFileTypes:!0})}catch(n){if(n.code==="ENOENT")return;if(n.code==="ENOTDIR"||n.code==="ENOTSUP")return uR(t,e,r);throw n}return i&&i.length&&i.forEach(n=>MNe(t,n,e,r)),uR(t,e,r)};p9.exports=gR;gR.sync=I9});var Q9=w((hlt,fR)=>{"use strict";var w9=h9(),Gs=require("fs"),YB=require("path"),B9=y9(),hR=class extends Error{constructor(e,r){super("Cannot extract through symbolic link");this.path=r,this.symlink=e}get name(){return"SylinkError"}},sC=class extends Error{constructor(e,r){super(r+": Cannot cd into '"+e+"'");this.path=e,this.code=r}get name(){return"CwdError"}},glt=fR.exports=(t,e,r)=>{let i=e.umask,n=e.mode|448,s=(n&i)!=0,o=e.uid,a=e.gid,l=typeof o=="number"&&typeof a=="number"&&(o!==e.processUid||a!==e.processGid),c=e.preserve,u=e.unlink,g=e.cache,f=e.cwd,h=(y,Q)=>{y?r(y):(g.set(t,!0),Q&&l?B9(Q,o,a,S=>h(S)):s?Gs.chmod(t,n,r):r())};if(g&&g.get(t)===!0)return h();if(t===f)return Gs.stat(t,(y,Q)=>{(y||!Q.isDirectory())&&(y=new sC(t,y&&y.code||"ENOTDIR")),h(y)});if(c)return w9(t,{mode:n}).then(y=>h(null,y),h);let m=YB.relative(f,t).split(/\/|\\/);qB(f,m,n,g,u,f,null,h)},qB=(t,e,r,i,n,s,o,a)=>{if(!e.length)return a(null,o);let l=e.shift(),c=t+"/"+l;if(i.get(c))return qB(c,e,r,i,n,s,o,a);Gs.mkdir(c,r,b9(c,e,r,i,n,s,o,a))},b9=(t,e,r,i,n,s,o,a)=>l=>{if(l){if(l.path&&YB.dirname(l.path)===s&&(l.code==="ENOTDIR"||l.code==="ENOENT"))return a(new sC(s,l.code));Gs.lstat(t,(c,u)=>{if(c)a(c);else if(u.isDirectory())qB(t,e,r,i,n,s,o,a);else if(n)Gs.unlink(t,g=>{if(g)return a(g);Gs.mkdir(t,r,b9(t,e,r,i,n,s,o,a))});else{if(u.isSymbolicLink())return a(new hR(t,t+"/"+e.join("/")));a(l)}})}else o=o||t,qB(t,e,r,i,n,s,o,a)},flt=fR.exports.sync=(t,e)=>{let r=e.umask,i=e.mode|448,n=(i&r)!=0,s=e.uid,o=e.gid,a=typeof s=="number"&&typeof o=="number"&&(s!==e.processUid||o!==e.processGid),l=e.preserve,c=e.unlink,u=e.cache,g=e.cwd,f=y=>{u.set(t,!0),y&&a&&B9.sync(y,s,o),n&&Gs.chmodSync(t,i)};if(u&&u.get(t)===!0)return f();if(t===g){let y=!1,Q="ENOTDIR";try{y=Gs.statSync(t).isDirectory()}catch(S){Q=S.code}finally{if(!y)throw new sC(t,Q)}f();return}if(l)return f(w9.sync(t,i));let p=YB.relative(g,t).split(/\/|\\/),m=null;for(let y=p.shift(),Q=g;y&&(Q+="/"+y);y=p.shift())if(!u.get(Q))try{Gs.mkdirSync(Q,i),m=m||Q,u.set(Q,!0)}catch(S){if(S.path&&YB.dirname(S.path)===g&&(S.code==="ENOTDIR"||S.code==="ENOENT"))return new sC(g,S.code);let x=Gs.lstatSync(Q);if(x.isDirectory()){u.set(Q,!0);continue}else if(c){Gs.unlinkSync(Q),Gs.mkdirSync(Q,i),m=m||Q,u.set(Q,!0);continue}else if(x.isSymbolicLink())return new hR(Q,Q+"/"+p.join("/"))}return f(m)}});var k9=w((plt,v9)=>{var S9=require("assert");v9.exports=()=>{let t=new Map,e=new Map,{join:r}=require("path"),i=u=>r(u).split(/[\\\/]/).slice(0,-1).reduce((g,f)=>g.length?g.concat(r(g[g.length-1],f)):[f],[]),n=new Set,s=u=>{let g=e.get(u);if(!g)throw new Error("function does not have any path reservations");return{paths:g.paths.map(f=>t.get(f)),dirs:[...g.dirs].map(f=>t.get(f))}},o=u=>{let{paths:g,dirs:f}=s(u);return g.every(h=>h[0]===u)&&f.every(h=>h[0]instanceof Set&&h[0].has(u))},a=u=>n.has(u)||!o(u)?!1:(n.add(u),u(()=>l(u)),!0),l=u=>{if(!n.has(u))return!1;let{paths:g,dirs:f}=e.get(u),h=new Set;return g.forEach(p=>{let m=t.get(p);S9.equal(m[0],u),m.length===1?t.delete(p):(m.shift(),typeof m[0]=="function"?h.add(m[0]):m[0].forEach(y=>h.add(y)))}),f.forEach(p=>{let m=t.get(p);S9(m[0]instanceof Set),m[0].size===1&&m.length===1?t.delete(p):m[0].size===1?(m.shift(),h.add(m[0])):m[0].delete(u)}),n.delete(u),h.forEach(p=>a(p)),!0};return{check:o,reserve:(u,g)=>{let f=new Set(u.map(h=>i(h)).reduce((h,p)=>h.concat(p)));return e.set(g,{dirs:f,paths:u}),u.forEach(h=>{let p=t.get(h);p?p.push(g):t.set(h,[g])}),f.forEach(h=>{let p=t.get(h);p?p[p.length-1]instanceof Set?p[p.length-1].add(g):p.push(new Set([g])):t.set(h,[new Set([g])])}),a(g)}}}});var D9=w((dlt,x9)=>{var UNe=process.env.__FAKE_PLATFORM__||process.platform,KNe=UNe==="win32",HNe=global.__FAKE_TESTING_FS__||require("fs"),{O_CREAT:jNe,O_TRUNC:GNe,O_WRONLY:YNe,UV_FS_O_FILEMAP:P9=0}=HNe.constants,qNe=KNe&&!!P9,JNe=512*1024,WNe=P9|GNe|jNe|YNe;x9.exports=qNe?t=>t<JNe?WNe:"w":()=>"w"});var BR=w((Ilt,R9)=>{"use strict";var zNe=require("assert"),Clt=require("events").EventEmitter,_Ne=nC(),$t=require("fs"),VNe=Cf(),lA=require("path"),pR=Q9(),mlt=pR.sync,F9=PD(),XNe=k9(),N9=Symbol("onEntry"),dR=Symbol("checkFs"),L9=Symbol("checkFs2"),CR=Symbol("isReusable"),cA=Symbol("makeFs"),mR=Symbol("file"),ER=Symbol("directory"),JB=Symbol("link"),T9=Symbol("symlink"),O9=Symbol("hardlink"),M9=Symbol("unsupported"),Elt=Symbol("unknown"),U9=Symbol("checkPath"),wf=Symbol("mkdir"),dn=Symbol("onError"),WB=Symbol("pending"),K9=Symbol("pend"),Bf=Symbol("unpend"),IR=Symbol("ended"),yR=Symbol("maybeClose"),wR=Symbol("skip"),oC=Symbol("doChown"),aC=Symbol("uid"),AC=Symbol("gid"),H9=require("crypto"),j9=D9(),zB=()=>{throw new Error("sync function called cb somehow?!?")},ZNe=(t,e)=>{if(process.platform!=="win32")return $t.unlink(t,e);let r=t+".DELETE."+H9.randomBytes(16).toString("hex");$t.rename(t,r,i=>{if(i)return e(i);$t.unlink(r,e)})},$Ne=t=>{if(process.platform!=="win32")return $t.unlinkSync(t);let e=t+".DELETE."+H9.randomBytes(16).toString("hex");$t.renameSync(t,e),$t.unlinkSync(e)},G9=(t,e,r)=>t===t>>>0?t:e===e>>>0?e:r,_B=class extends _Ne{constructor(e){if(e||(e={}),e.ondone=r=>{this[IR]=!0,this[yR]()},super(e),this.reservations=XNe(),this.transform=typeof e.transform=="function"?e.transform:null,this.writable=!0,this.readable=!1,this[WB]=0,this[IR]=!1,this.dirCache=e.dirCache||new Map,typeof e.uid=="number"||typeof e.gid=="number"){if(typeof e.uid!="number"||typeof e.gid!="number")throw new TypeError("cannot set owner without number uid and gid");if(e.preserveOwner)throw new TypeError("cannot preserve owner in archive and also set owner explicitly");this.uid=e.uid,this.gid=e.gid,this.setOwner=!0}else this.uid=null,this.gid=null,this.setOwner=!1;e.preserveOwner===void 0&&typeof e.uid!="number"?this.preserveOwner=process.getuid&&process.getuid()===0:this.preserveOwner=!!e.preserveOwner,this.processUid=(this.preserveOwner||this.setOwner)&&process.getuid?process.getuid():null,this.processGid=(this.preserveOwner||this.setOwner)&&process.getgid?process.getgid():null,this.forceChown=e.forceChown===!0,this.win32=!!e.win32||process.platform==="win32",this.newer=!!e.newer,this.keep=!!e.keep,this.noMtime=!!e.noMtime,this.preservePaths=!!e.preservePaths,this.unlink=!!e.unlink,this.cwd=lA.resolve(e.cwd||process.cwd()),this.strip=+e.strip||0,this.processUmask=process.umask(),this.umask=typeof e.umask=="number"?e.umask:this.processUmask,this.dmode=e.dmode||511&~this.umask,this.fmode=e.fmode||438&~this.umask,this.on("entry",r=>this[N9](r))}warn(e,r,i={}){return(e==="TAR_BAD_ARCHIVE"||e==="TAR_ABORT")&&(i.recoverable=!1),super.warn(e,r,i)}[yR](){this[IR]&&this[WB]===0&&(this.emit("prefinish"),this.emit("finish"),this.emit("end"),this.emit("close"))}[U9](e){if(this.strip){let r=e.path.split(/\/|\\/);if(r.length<this.strip)return!1;if(e.path=r.slice(this.strip).join("/"),e.type==="Link"){let i=e.linkpath.split(/\/|\\/);i.length>=this.strip&&(e.linkpath=i.slice(this.strip).join("/"))}}if(!this.preservePaths){let r=e.path;if(r.match(/(^|\/|\\)\.\.(\\|\/|$)/))return this.warn("TAR_ENTRY_ERROR","path contains '..'",{entry:e,path:r}),!1;if(lA.win32.isAbsolute(r)){let i=lA.win32.parse(r);e.path=r.substr(i.root.length);let n=i.root;this.warn("TAR_ENTRY_INFO",`stripping ${n} from absolute path`,{entry:e,path:r})}}if(this.win32){let r=lA.win32.parse(e.path);e.path=r.root===""?F9.encode(e.path):r.root+F9.encode(e.path.substr(r.root.length))}return lA.isAbsolute(e.path)?e.absolute=e.path:e.absolute=lA.resolve(this.cwd,e.path),!0}[N9](e){if(!this[U9](e))return e.resume();switch(zNe.equal(typeof e.absolute,"string"),e.type){case"Directory":case"GNUDumpDir":e.mode&&(e.mode=e.mode|448);case"File":case"OldFile":case"ContiguousFile":case"Link":case"SymbolicLink":return this[dR](e);case"CharacterDevice":case"BlockDevice":case"FIFO":return this[M9](e)}}[dn](e,r){e.name==="CwdError"?this.emit("error",e):(this.warn("TAR_ENTRY_ERROR",e,{entry:r}),this[Bf](),r.resume())}[wf](e,r,i){pR(e,{uid:this.uid,gid:this.gid,processUid:this.processUid,processGid:this.processGid,umask:this.processUmask,preserve:this.preservePaths,unlink:this.unlink,cache:this.dirCache,cwd:this.cwd,mode:r},i)}[oC](e){return this.forceChown||this.preserveOwner&&(typeof e.uid=="number"&&e.uid!==this.processUid||typeof e.gid=="number"&&e.gid!==this.processGid)||typeof this.uid=="number"&&this.uid!==this.processUid||typeof this.gid=="number"&&this.gid!==this.processGid}[aC](e){return G9(this.uid,e.uid,this.processUid)}[AC](e){return G9(this.gid,e.gid,this.processGid)}[mR](e,r){let i=e.mode&4095||this.fmode,n=new VNe.WriteStream(e.absolute,{flags:j9(e.size),mode:i,autoClose:!1});n.on("error",l=>this[dn](l,e));let s=1,o=l=>{if(l)return this[dn](l,e);--s==0&&$t.close(n.fd,c=>{r(),c?this[dn](c,e):this[Bf]()})};n.on("finish",l=>{let c=e.absolute,u=n.fd;if(e.mtime&&!this.noMtime){s++;let g=e.atime||new Date,f=e.mtime;$t.futimes(u,g,f,h=>h?$t.utimes(c,g,f,p=>o(p&&h)):o())}if(this[oC](e)){s++;let g=this[aC](e),f=this[AC](e);$t.fchown(u,g,f,h=>h?$t.chown(c,g,f,p=>o(p&&h)):o())}o()});let a=this.transform&&this.transform(e)||e;a!==e&&(a.on("error",l=>this[dn](l,e)),e.pipe(a)),a.pipe(n)}[ER](e,r){let i=e.mode&4095||this.dmode;this[wf](e.absolute,i,n=>{if(n)return r(),this[dn](n,e);let s=1,o=a=>{--s==0&&(r(),this[Bf](),e.resume())};e.mtime&&!this.noMtime&&(s++,$t.utimes(e.absolute,e.atime||new Date,e.mtime,o)),this[oC](e)&&(s++,$t.chown(e.absolute,this[aC](e),this[AC](e),o)),o()})}[M9](e){e.unsupported=!0,this.warn("TAR_ENTRY_UNSUPPORTED",`unsupported entry type: ${e.type}`,{entry:e}),e.resume()}[T9](e,r){this[JB](e,e.linkpath,"symlink",r)}[O9](e,r){this[JB](e,lA.resolve(this.cwd,e.linkpath),"link",r)}[K9](){this[WB]++}[Bf](){this[WB]--,this[yR]()}[wR](e){this[Bf](),e.resume()}[CR](e,r){return e.type==="File"&&!this.unlink&&r.isFile()&&r.nlink<=1&&process.platform!=="win32"}[dR](e){this[K9]();let r=[e.path];e.linkpath&&r.push(e.linkpath),this.reservations.reserve(r,i=>this[L9](e,i))}[L9](e,r){this[wf](lA.dirname(e.absolute),this.dmode,i=>{if(i)return r(),this[dn](i,e);$t.lstat(e.absolute,(n,s)=>{s&&(this.keep||this.newer&&s.mtime>e.mtime)?(this[wR](e),r()):n||this[CR](e,s)?this[cA](null,e,r):s.isDirectory()?e.type==="Directory"?!e.mode||(s.mode&4095)===e.mode?this[cA](null,e,r):$t.chmod(e.absolute,e.mode,o=>this[cA](o,e,r)):$t.rmdir(e.absolute,o=>this[cA](o,e,r)):ZNe(e.absolute,o=>this[cA](o,e,r))})})}[cA](e,r,i){if(e)return this[dn](e,r);switch(r.type){case"File":case"OldFile":case"ContiguousFile":return this[mR](r,i);case"Link":return this[O9](r,i);case"SymbolicLink":return this[T9](r,i);case"Directory":case"GNUDumpDir":return this[ER](r,i)}}[JB](e,r,i,n){$t[i](r,e.absolute,s=>{if(s)return this[dn](s,e);n(),this[Bf](),e.resume()})}},Y9=class extends _B{constructor(e){super(e)}[dR](e){let r=this[wf](lA.dirname(e.absolute),this.dmode,zB);if(r)return this[dn](r,e);try{let i=$t.lstatSync(e.absolute);if(this.keep||this.newer&&i.mtime>e.mtime)return this[wR](e);if(this[CR](e,i))return this[cA](null,e,zB);try{return i.isDirectory()?e.type==="Directory"?e.mode&&(i.mode&4095)!==e.mode&&$t.chmodSync(e.absolute,e.mode):$t.rmdirSync(e.absolute):$Ne(e.absolute),this[cA](null,e,zB)}catch(n){return this[dn](n,e)}}catch(i){return this[cA](null,e,zB)}}[mR](e,r){let i=e.mode&4095||this.fmode,n=l=>{let c;try{$t.closeSync(o)}catch(u){c=u}(l||c)&&this[dn](l||c,e)},s,o;try{o=$t.openSync(e.absolute,j9(e.size),i)}catch(l){return n(l)}let a=this.transform&&this.transform(e)||e;a!==e&&(a.on("error",l=>this[dn](l,e)),e.pipe(a)),a.on("data",l=>{try{$t.writeSync(o,l,0,l.length)}catch(c){n(c)}}),a.on("end",l=>{let c=null;if(e.mtime&&!this.noMtime){let u=e.atime||new Date,g=e.mtime;try{$t.futimesSync(o,u,g)}catch(f){try{$t.utimesSync(e.absolute,u,g)}catch(h){c=f}}}if(this[oC](e)){let u=this[aC](e),g=this[AC](e);try{$t.fchownSync(o,u,g)}catch(f){try{$t.chownSync(e.absolute,u,g)}catch(h){c=c||f}}}n(c)})}[ER](e,r){let i=e.mode&4095||this.dmode,n=this[wf](e.absolute,i);if(n)return this[dn](n,e);if(e.mtime&&!this.noMtime)try{$t.utimesSync(e.absolute,e.atime||new Date,e.mtime)}catch(s){}if(this[oC](e))try{$t.chownSync(e.absolute,this[aC](e),this[AC](e))}catch(s){}e.resume()}[wf](e,r){try{return pR.sync(e,{uid:this.uid,gid:this.gid,processUid:this.processUid,processGid:this.processGid,umask:this.processUmask,preserve:this.preservePaths,unlink:this.unlink,cache:this.dirCache,cwd:this.cwd,mode:r})}catch(i){return i}}[JB](e,r,i,n){try{$t[i+"Sync"](r,e.absolute),e.resume()}catch(s){return this[dn](s,e)}}};_B.Sync=Y9;R9.exports=_B});var _9=w((wlt,q9)=>{"use strict";var eLe=sf(),VB=BR(),J9=require("fs"),W9=Cf(),z9=require("path"),ylt=q9.exports=(t,e,r)=>{typeof t=="function"?(r=t,e=null,t={}):Array.isArray(t)&&(e=t,t={}),typeof e=="function"&&(r=e,e=null),e?e=Array.from(e):e=[];let i=eLe(t);if(i.sync&&typeof r=="function")throw new TypeError("callback not supported for sync tar functions");if(!i.file&&typeof r=="function")throw new TypeError("callback only supported with file option");return e.length&&tLe(i,e),i.file&&i.sync?rLe(i):i.file?iLe(i,r):i.sync?nLe(i):sLe(i)},tLe=(t,e)=>{let r=new Map(e.map(s=>[s.replace(/\/+$/,""),!0])),i=t.filter,n=(s,o)=>{let a=o||z9.parse(s).root||".",l=s===a?!1:r.has(s)?r.get(s):n(z9.dirname(s),a);return r.set(s,l),l};t.filter=i?(s,o)=>i(s,o)&&n(s.replace(/\/+$/,"")):s=>n(s.replace(/\/+$/,""))},rLe=t=>{let e=new VB.Sync(t),r=t.file,i=!0,n,s=J9.statSync(r),o=t.maxReadSize||16*1024*1024;new W9.ReadStreamSync(r,{readSize:o,size:s.size}).pipe(e)},iLe=(t,e)=>{let r=new VB(t),i=t.maxReadSize||16*1024*1024,n=t.file,s=new Promise((o,a)=>{r.on("error",a),r.on("close",o),J9.stat(n,(l,c)=>{if(l)a(l);else{let u=new W9.ReadStream(n,{readSize:i,size:c.size});u.on("error",a),u.pipe(r)}})});return e?s.then(e,e):s},nLe=t=>new VB.Sync(t),sLe=t=>new VB(t)});var V9=w(hi=>{"use strict";hi.c=hi.create=NV();hi.r=hi.replace=oR();hi.t=hi.list=HB();hi.u=hi.update=GV();hi.x=hi.extract=_9();hi.Pack=xB();hi.Unpack=BR();hi.Parse=nC();hi.ReadEntry=Zd();hi.WriteEntry=OD();hi.Header=lf();hi.Pax=pB();hi.types=Xd()});var t7=w((Qlt,e7)=>{var QR;e7.exports.getContent=()=>(typeof QR=="undefined"&&(QR=require("zlib").brotliDecompressSync(Buffer.from("W0ISdwE9bQeS320DgM/v/rJIqkB1S3dRtyGA2Swsv6u5umDk4fUhsbltwAy5392lO0dVVZOSExkKxhOa2Kpu//awROkts0ahMEgkCX3MNVCVyD2Wz2U6h7xxFSeLsympT4zG7GnbkEiKnqciN4R102Eu1gNxBrRYkZvmtVC5EmYX6aHkvGlYMFS46pVIPBL6yIiRc1KVyRgb05w8mrL04sZsZPYcKZJiL1x6x3rr10foz/h8qcaG5+lwr9z7UOLQP2oYhjAVrn7vrTTKQiPRRVb4W9p7bNQHy/wVOjPqH/kWwMmtujuIMcEUZklvJjkhNzxah8/7vVp+/e45zX1bVfLk1nPylcobAWI5wwyhuGxWtbKqDZRXiOPc82pDLBiLmtFZH0RpHuwR7NIFV12V8earfX79njg7CBS6AKTksgn3DcyTZ5jufen27qsy4ivWja2rQg7+P81+RTGuSxedEXm3rl4JGPDzWWNVAjZ0JtraFvx/n9rvR9NWFwjt1nKCfE4f3XKZ5DHM/MyP/YyJ6WoHy72/tP6/fgmHx1Q1ZXqZXVDWI1u/3KwnzmyX8fEQIycaY8kPybHdkfxS87VqLRUYrvRC74kzMCdF7fIHudVUIax+jF+qgtj94Xb3FPGH6fSfqISGv8z8Ol56W19VDljCNhWB9AElTnfPyP96S/X/38+X0d6Ax6LVGC6iS4JTB0dt5WqH6X2smBPy8rDt4TSFaVpxUMy9P9Wv33JVnTtxPtnpxk9ReHRvCYIn3R7JRoqFuqwsCUr5UIkDj8pOtzOroJ0jgCf6mPvv8+O9XTFxlft5qrsGehiGVMOo8Q/xpnrCKgkT7UGo1hbrh/zH7oxg/ZClC6If/8P59PmxuytV2xc/z8wdLkMAHZAo/kWswy3VaEkl6cB2czSECfgB9qPG9sdDB6msj8c3ywCDgg1IRTfCHI0KFX8r/b/22i8X74dQ5zyXXFOJtGEqRkB3MXY0CsZTf0E/au88HnqQamrt8c3sLAy/ZCCbBG8R64KoqKjU+mKm9sfD35RlOvnHBxBabCkJzLCpc2s9DQjRS1pnRqb/3lSrjbZ+AxQFUqbINdZXLrPGRrtnbJJY4L33+wv/dwNiG1JoGIpokFoBkFQDgOSOCM11A9IUSM1dgbLkWs3MGZ0xTgTHyIznaA11xrjYRNbm1gXRhBeEF26WXZRekFwSXBp7//veLKteugMRARZLsdhdrfRqdsr2CABMdrUWm334Fe9k/P/dnenuAJgQEWQiIjmMyCSrIpPMEtmsd+973/H9uyPSAUSyEcgkKzPIqqYYkU224AhJjhBitxndYtvLRZCjWCPLZtWL5SxnOf9/lWrf9r2IyMgEQUKkTcnWKVk1DMM6MgFCg1X+wzisi/Huffco472ILyCHMpGZ+EUkgPNFkDwtAMT5Fim/F5kgI5KQnICoqgREVwGU7CLl4VAqVR/ZPVuktLCrqodpXqztqup52Cz/YqmeVfV7nhbL3mx6s+vFbtFWnKYpXSAGV9YSpFKBErAIfbXv4l2Efo7n6WfvbHqu+POuaosC4ahRGRRS57O+sAYUanUIh1s2ITRGGu6n+j7wcUjdVS50GXr6mrGOGZY/wZz1/9gEkx+z+krdFBNjiZFiQxTBggUbYsHSsAIWBEtL9a7P/InX7f97UUGlyNt2ikIPvQcQCPQEemghlFLsZ+7ub4pHZ3YedPP/X1BSClL+6K0LBIQQVlhhzgBKCHMpIMoUJSCyHB1/aQM5f77fFv/H3ZblVhtRgBUYhbqEMQMqYa/3xc+HQbxwjYWooQw+LNyYwWAwxd9p3z/+ndmiyf8QtphiChMMUeFCEENEcECFCCIRQQ9cqHChwvCm2OIU+fz8T9vi/eDSsZgHz6KZIeCnWjTSifdUJ7Zu/T//f2YtYe7nv8WIyQ0C0ZcgEIgKBGLygkAgEBWIir6koqICseU7t+gffHsnZNt+cWIFSREIBII0CAQCQRoEAoEgzQoEYgRixIgRIxZ68eK7PdEPC4XCQiEQCAQCDwKBEsyiTUu4z1sxgHRABaJh/2G3bEl+0Bcmn3ZWhd95FpzddaM8uiaW/P775UPwRWLPl0AwWCrqCDbCT1qg6cp+sUkBDp+FXCK/hYCOax1FpceEDMI8b3HeBRq+nkrYk9B3v0xVBeNmb0aX/gOTMOSATt8tbEjX8ah7Pu5PLlInkUQSESmmst0lxx1IPVRThvB3f/eudkg011Aeg6meqgNHfZx0Rci+mOwTWElLHTLkEnzly+tAPa8AGqSwd73T+GRi0ndwxLMjZiL6LdGArI75wSi09VSyaDFxPylH+KdhSAPVbaQMzQyl/P0BRc+CmusT/QFApUXKIA+vARExxwdl2Rdf3B/eVyfTEKxVRdvVN8fBaRl7UydUkGdwAUmYfSsInXhHxd/cWGxPcnk/dYcY4ZicBKy3d3iKt3khmP0oHL+G2PbzRh3Tt07pXjt+kzyYr6GMr9nnmLyjLNYCw9S/pHscM3LAbtjZsVOxtdPPnL1muyIv2TVvhQi/NbeK31CaAF/Z+biJXfOyze638C1fA+28UpRvkdWzf2WnqjyxtFcnT8fUjUI91f34axGgHGj6fUofZBy766V1XdI9R/zYRmG9g4PLymF9TwIH33obdmB3wLEYEvcDKFJ8JNfRSKjam+2MC9TtzQtCvXlW0Mb+gKJzHkcUmUksOR3ub3X6l7Fsq3Xc77s7/UF/LHd6iS9PHrYFsq1JC+AlYDYE31Q5/e/aGobLXu4Jl9NQkZB8b00YP9bp9A3MiK+Vv5DFAEd6WWbXQKXJbHI40TwwanWFMeUs7kCxC+rPqvtgJGsKhvLMwFgusAW7KbcMl/ZiC3dbuNvi3YZ3Tz9jw1MweHwf0RtMTYqfsaDa6fcP2j1y5ABYfwpzgnqgibGj4KpmjZNZsD5MzSoP04L1UX8glX1oZuUlwpAF7UsGgplF8liBjCNNXIEW1c+CRRWjNWlVBCtuX3ztXHmI3tLLi0BYaXodGUPzDJh3VOYbUSr811yxh2f9oo2XRSKYHwVoWS4qPtYuYd6nRkCdcXwFJtWAN7U2OXmiC/hX/comuEinUUEjybSySG6T8byFtvTxiZtjQicBAUuEYmJlZ4Xmqv5zR+gK1Exui3xSztgiH4gk1EZBv78ZVYjytBRKIFJuDy5cepdcO2gkCa6eqwN1ACSEEtAan8uenDpQSkhYmpx4ME2Oai5HQ4SPIOjQE/+g0j0BHvBBbSwBHuDFZQLpCHuKfAIHhZDIU4wYEKog6CsQfCfSwZeA+zXaKAAeACKnkDjjw1rCBwVGxFPFcSLD0pErGfvmplYSIIKRYw9i4L17jmciqKVYSXUKCWWJYsMlzKp+ShhRO8Ngp9157BeAlYVRImiD3Oe6ihR03hI61SlV54Rdp8LNGJRHzS+/Y/qpEwyZ+3hKBK+hsnYsFv58j6kudzQ/lRVul3uOOap8ObL1eX5vYDfIST91raCUwBFPT613Lxjgq8N7Aol3NTFegT5VlgsF6C8icidvUE4Mff+Aiepeyc97Lp1r/REJ9UFTSJ46WVt6FmQHplZ8/hRGESlb/bLm/c50PwCoHe2W0LJAVezJnCWD2rL/kEKF6Cr2YdXq/qGG0Txe/9NAzLzIgayspUmpqyIoD3IaK5EOH3QgULTdLJ3obj2KOK9fbnGN2OXpdAwn5iQwR6JWbRhRX24zxqij6dsnqFrPhg2XvyJYxzdm+AzgYNWPY4kOQagEGQWJaKDlQa/dqfPpGq8X+pwSla5d+98t4FGDfjr5pez8V+IrTcrwD7dNgZ8cTDAY5HpADrnhuTN8WPfJsPP87J+aZfgXFmsfe8RPc4tSzQ+E81/m+wB/heYcYNQIWHtuu8QRkfIdygrYn8TlnlVpiRmIl0wJ3G6w8gcCfL9RYh+HL8Lxp6u+eunrBvK6B0WAZPOJVe2W0Ytg5Z8SjOk4JBvEhvylrJZYdIPHsHRqK+GuAaJcmUpGlh50pS+M6sviMrdFISQDEZPXa1ElDj5mn6py9iN6DjF3KzncRTT/ETyk5t0ua/wyUZRNpc8G9yNE0751xhbSHJKn+c7d81q5WMQrJT7piRyE6gTTfxHhEbIQVLzQQ8R+w3eKPgfutCr0TV6FhPebIGE1QTwJn1YIltEelxFQ0mj+OKmn9lEzyMbmXiev3WxMTViNUrjh95I84qGiGXMnL78fDb7IugfqYg8HfR8Jt0QKcVSqrFuICtL66Gp5HOAO/EbEezQfuPs7TfEGufXWdpXYIvZvInJKLqNZhoBKKlJDlLDOAaQg8lg5rpZts09dFJ/oP9e6W1L67Kj6GH6mTebW+MtkRDzOQNvb49odEr8vYngDrlNpC2pf/VvUgysFvzTJNO/YA4XX78n/vD+zlyHaCeuxnOp2TRGQM2lzdy/GZpB+n6VRYBX8d5CUTz4Hxa57PW5B48sepeYj+XG83L8P52Q985oTmnnLnbnD89bEgRTxaMykmOfVg1Z5mz7m1NeFAuWJYsqU/ee7sm6AQpkm+m5tSQmgFRj+Ox3LA44sV7pfO4S1ArynaGhLDfICDLpdMEwGydPgG0qUD+bSdlto+52eS92xzVEPP8LwHFVSlVHR25nmSDqjlBhV0kUeFi9u2rc34ROEdzmg0RZaK/1+idlzYYRyLIcmu+qnmfxCQ9TlHVrGVtyF2HGkl5f9tTFh5XRxc+Rl9rtWFRI03iwMvl/XuW2keWh6e2pcZSMKfXz6llflvleKT2z19sU//hAxob5lkk47atU0NvhuRf27w+MIdsEG817ioc3839EU0jIH8XFK7QM0KXIpQqdQUHTNTO0RjR5EyDaZr4cN5w2AO2nkZ+xMzYS7p+RksU9eP4vkQXbcEGH4+EkaptZLJyLp0Kwd13BndUf4GZQfPxf++hZS166TbYPaS/FxkhU+gQTqypZD3mKT7bZQgmcInoLwbY2ok8mOdZTxy7m6paHs1LOqUfjA9cLidjg/KhvjdnUfwWYPhXAZvAdIAR5X9hmllecmLgr9yLN2hMJES2gry0ejcCyWI81WOZVJ8FFXLC/eTIjAAgcHrJBpH8SyxERxZRch9xdpq4KxF0X53jKzEqbICWWeuNnc4VHMRXl/Ziuk1CYezC4qiyzGT93V7/JKv8BrWNFBX1nrn6RTRRLpyUop9kmbAyz1+TltvrsyaoT/QFKfBtcLs3DaPqKw9IH8g67P01Ms42fsnceCIcidHNZBKb8gQ14pz3tKKmfoV0bYH/8Xuitem92ZIaPU4bCSHK6TkCaed9+Cq3rjyQBlNIbXUCKipIoM2SuFq4Jt08edh+DBh3dyQBsk+Kx0yF4AxxYUE5G9VhwoYEKy/Ih6UJJg5nq7gOqdcEjKgrMy7YGoUMHEhL0FXbtOiyAxNHLl20f9XSHERLp/vaVaSPd3fSOLn1bCLRpKx1fr77xVvvNqHeVw/8XBs4FI249eK/gTJhLn0/Urp86OcHhaQr5weO2zZ71OXguvszxVLPqp4MaBGwrqo3bynJpD0j3Adqgu/UV7Ng9DChweZYwESbgT3hdGxZkfrn8yC7nwbXC/tsscNkZ/yontW5yGmDV8Cha/1knfioYuUHtKx9vTFmNZeKK4rIKvgnPT2RDn5zYixAkASp0SYst5pM5nOiy0TR4OcNWEeuFPepiw4pNboa6PYjnC8zJWD8+n09PJd37FeFAG84sGOJACAe7b55BnlLh8BQPhH8HrzJYLJu/kaa8+1jimOogxEqIN8MvY0IdzNu8N2yXwwUPRS2JQITRvg8rQy9PeOEKae8Ewd8lrnEhffBnDxw6qBda//XgGqB5R+HwS+Vynx+CYdvmIpTaWAcjTHmFCddaJiKSmLr4Mr4EBNu9NBKXojBknuDpuOk01PjJbxgVs9GZ87GsWs02hnPRWOS3S9LDTG/F1q9F4ShL8qq4+dv3/dTYN3dq6bfpiCHV1mWts990q7/vQl5jOK17p8pvewtFJ+HwnltbRtM4fBqQP2Q6RURYE4qhKH1IG32GJAYk099nnGuXLRC8Peh7mqYCAxgOT7+8Yz7pL49knzBsJwhE3vMv8/auqf7sElhpb0yqBSznJtycfjn/cpHxndBXpcMeO86ldRa2REt0V/SfcertF24BcYeO/NHTK7m+thj7BGtus+W7kE6pJ0JmRUgCcclZQO/xXp9pHSClxl4TBhnXkhxt1cIzvDNWLDJecB9DZVw28CEwFYTtYJHQKiwGpgXxpoEovTTkRuCMeX8Dt2lijNnFf5SxaRNsm1W5pvPdurxumVgIGSWsfRK1qyGqUPEG7RL22NH6lRVRSBdpgpSeqFU2YB0Umsbyl+mfbkoVlnG/g226nLotUrcwMSNuMJPeAKvPkYcYAivELYGn7jYQYmjNEGHA+WGMmwyH7GwfDyhCh5M+hEqc82wNwGaT2OkgVveQ2dkugvbRRFOycCnccaiYV5q1oYA3W6sSPIDIisop0UeFJSSH3aDUq1HBAobAzvT39ps4/TbjtDtvLeZ8T5UlkcU4d2ZFpH80cCbY7AKXq0IDO8AmdAr3UPOvkGCj8tqxkL+YVdaQvmZB6kaQ5TYzU48nB/JTjuLINMXg+s9GpUM4Sq3UnXC0HK83wiG1TGhlTSCQYTHtxQ5rt8XSMnwF65lfRB/0wpWQYBSp8kiwvW/a/Z1zHeJdJXiOOHSGJ5D431iM6r3KJjmJxNGXD5nQY2QLqWl+mwDg77voZR0Cg5IjDQZ9o6g/9AQC44X8oRL3/JcOD8A3cFF7yID4DiQGEHYgKEGPTSX/Oc9SvL2maK77XejVQB1vpZffAV16HzjbQfycCStcJZFKA8BPh3yjA1GbxkrEnwMvotFtzSG3XKjSmLigH2G7P6kAC2mXKopA30t/jiIu+WukeQ0Ebv2S4Sg7ZO75outRHD/JV3PRR6M9U+h8xhijW1ezYDFF0C0RjiMNfLWRFMWjLdnMNLtI0PTU3u0ORxTLL7eAyPLGwIbX2R8KKtrga0HU1oS9tsFJNx8tYpCa/MIXp+rfeslt7gJ7HLtpLe+ld9j6eE0IOab0QMD5IKE1y50IdFB4zaPWizblwnDoGW5tAajnxjGodK7VymgPr9Dfqhm7loxUhgCZk/VVgi391iE2ENZ85BivqNRGiTIj9fdXIoej1jF3AowJ1SfaIUc/p5SWnMqpGP0SwUqDN8cb496Hh98nL8FkcX3FG203Xf1WeB04RPj01CvcQM1RafAEQwZKaGgNbiM5GJuGN9iqOkge7Vvu2KiffAqlclLmecYcc2oxg/nKcCHlQavdfGosmKPGyPLRQzlLYI6XyVgcsMhHpYFOTvGjfGFE/xJOXEIk3RdJIGouHppfC0T8pgncbwuINySiJDTDPC9xwmb68fycPX91hTGA0LCRHziH1/9b9r0TY+dUvEIwwyrDLkKIgxf2yB4jSFdJ3QpUPI9YI2tXaNYRImfJYEgJgZBhiG/P98vRi3pJOENSP8mR+YeM54fM8+lIJkmv0/XnMUZsOAuITydRp6rWraqiHNzTW1HiVL3zv6TLqcx1nyH+ZCvGWCCM+qRYYcSbke8AoxkXPpmKwJZJ4nyhYQ22B5grEQNT6Z8H1aLuy88I/M/Op2L8C7R66jEmuCQyq+EGXv3ZdUSAuXWPm4N/6n0BmDiUDIm/ee1PqCfOWLgjSleOdnhVTxqEAQMH8p4Y7zTp8uVcdJyGBTjSE9ksR9od0oJ6LK4U2hg56XtOywaZpnJxZctUG9K1OPzclLa8Ec9d891vyyYbowAWNjXJpUj3Ub49Vv/9ZZ2Vau+X8eD7v/lrUVr6bJ0aNBtpHkiwa8Gm9OsJaznVP9/OaqSsCclvQdUcikuKevqi9tShhGFMuJFcvEZkuzbplhcWWQ8Uf7wlpqyOJjr6+7ODdIl188bUEKD18lv49U7Q/NBfBkJefHb/HGgEcN0ZvRp7yizQahvJoWaq18vNpRX51/ac2ejYaQyJfrYtSsTYn/Mf1/CSXyRWY3cacoobZK1yaTHPfOJo3xtBAdJq+Pw54lWcHed7l1aDUOU8GEtSDwekukoDVJIjst8rbUyGCneafY1r0rS4ouW3pSsbQS6pjWa9l1oKR/fgZUv4CxX0FJlNCPP3nPDrd5On6UWv6ypoHkox/TXHl8C7Nbqr0aRWZaz37mL8B3BrfkboE9MVDTm93DyY57D4X4RphluQXbNIeSPoevmyj4IRu9otvrIgcG3apiDoPlh4IJERZ/B7dcBkyDHfEZhr1NGErlZznsSmJRfmJaEMmq8+hf6Zuf+ix/9M4b5ge05pf1HyCmZ1HVZTnwyj8gT07NynqUkRJi2ZNeC5VTtFYCao/mIggKPNh5g6VDARXAk4jj2GpL0ZFUgKFYWg6MN4bV2Ar3yUTIu0sVGO+7oPEEMunJC8UYr77fgAZ7edRgSqt43CiNzHhfoEsTTOirGjCMr1gYUxBKHYtQjFKcMIalKgVDyB6Ss9i1C2DuGeQcQ3EEQQ8H2GXL9/5kJnh0kopgRO6eHzOoJ2dsYfoROKLWkOQxIz+VjhIJ+iUTVN18Gcl1PW1LhQVifQ7McioFaqf1BgJoi92Al9kOeurPpIR6np4WccpX2REktZNUbNMkoZQejGpo9kJYugZoSHb0iqA7xytMZlSSmMxZR9TThbSMQCPhmD4nQBoPPFhdxi84yUYny7QIu4wzj7FgDAOZ0UyDWqH61xO8WjLQ1wgz+tAzvyZfL1g8EaiBlimn/Pwg9N8HDF5RDqk65OkTeSQl3OvJf8QZXnhPM02Ilv5MNXfYpxmCZjwcNqlHofICPDT17CUCwQdGhtTunYKGoJyTJV3fUjZJCHHPn0TWNYDfD9NIQzf2GuwrMCTg1xwkE2Vk74Grb2UO06f+Bu7Degbb5LKG2TAk34HPmQDPiQDoi/8OLgGoziWKzbks0FYq331l/Hy1e4bNQznmPRsqKOHvSno0dF9LNEkUHL1OY8XV+IV2Pn0i2xN3QeIV4j/26r9dKNXEU3dP3XiXeHDaDBM+eN4qBIxhflcbj5l6uowycfn1WvwlLNkSxEOjww1WywYz2ZTFdbj75BIPHbsgXzVThDWbEPtu5T08EPdK+us9hUsP11XgG+usi3hCgMOx9AiEQSZ19nNQnGeW5uFpboAOgOPqWwSJMekF4PRJ9yYUrmUEG56ySbZW0rC5UjEWofUdH7O7bAzavppQzZ9d+1tLhCMki8HW559iy6a1PugU61MYjz2ycNsfyN4Apgu2nqBhe2nI8BTJcbKih4X78AojqVqx4Z8ANZqA5BgPp6aPsM5RgCwpg/wy/lU+YPlN+CMxxDFAJ6WOiGhzb+GpeO6xgTF4ypMFU2W3qqX+hvHPyxnIcXaoXpXp1FgrD4VmCH3oL99akbIak6z9De64P6bAM8z1G5icHaqPkhR16Lv0FBmylxFB0cHUg5S6O0UyQve9cYsFjpqzO5ttDCoL6MWKwL0aX7nXtXiVC0xpE+xpWkGE2imF1QQNCVYJu6Or16oUHK/H2sPtboyVQBO8wFc/o1Wx51Zdl4wEJAcS1EfDPlGWKxn5A1ecvNKsZNf/xFInOByocFeLYinfzcXacCc5RNoItHADLHzsxkKVCCN+WbVfPxrgOF/Tb7wHnYVbHxRNyJLwIhZpG2Z+nITMmv9MlWdHPCOWiZq3BnXQBwMgPjPjj5Nn6j1vGhln5T23/vBEGTlKvKwkt84THnt/qCjPyYE2lZ0wL6y/zkIhiDDkh+igEUvd4/pIz3Ib+9N/hs91v/HU/WkB2ZyInFMKGRDzBok/J6l38SF1d6rGcfVoq58V3DCpRURkYjTIInv5aGEgxzseF+NTknM7dedczgY5fx4FE/MjqpuraRNoinOVyQ+CVOaD0zn1JVjRgrN15UVFWV4z2uhsB/8MFGLodisDLeXocPW/2Y7Y8XBvo7SkaE0SwU6lnGUibj1IsUOGoFEYRzVHbnRnhk1EXqU8u7RtNzOETFLvUCCkPR3Zbygzcs9I4VU5iRBfUuLYIrQJ1Hg2DOh8yyo8TKRlJiZhCQcJ6TWOJ7XrgMQW54rNePPipUExmiOFg40ZMTK4i8XamZbz4BpL0EcP+2V4nhShGbtIYckaE8vUAwPcMxaMHcDb1D7Ly9VqExmVCvovGrY7FmOZfeFMW9B3z/y3JyvzC5qg8TckqiUQCnLS9XJ2O8sI1a2Sru+omKZxY7skQUXENbye144AxwvaYz5v/c58DQxJiHSqdfrBNhcgp/orzvnChJlCrqs57I/3W/a1ceGJYbo+/hYnjWNAw7utKHpi/a7znpo7PPXA3d6EjnM0AzmVVEm2GwLVftDQqPUAp1j4pS/os4C/DbJJ8wFhTlFcQBZna1FHwuPnwmfHE8c7u3iQqWlzQ04NzPmTnLqpE5O2tXUPshFnMw32J85Q5kjbQrf8JwDzlwRNctKC8QcRZFd+adFNKOYy9T0WKEl2PHlzVWMxpraqpwtIvMjwfbgYZ4sZrlqYJHrOh45+UUw+jJTPWJ4Lvl6FF5eEFuceFmG7WBpmOb/+wHeb4z5pU1dwzOgTmVmua9woOztULONCqQzdGlFz88UP4P8YeBM3dOkm55uq1xT9yHyxrxVTuo0W4RVn+KPl1Tqn+AFv35AM7Wz0+7SsG0zBSHsiZeJ1DmpowOZw95HLhwVEmLZlJSn3ZY4bjFSI3+hxmv+zv0df64LR9FqO2aNHesYi7qyoXXShroW6F0C2qTgjBQ86W8nuTvtkL5S7E9enzEeB05/gqeIgzW+/Yv1q3bnZ6G2ysR82xA2rl7rmqxe60TX8nUe0KRhQeE+bPS3sYDQ8HIlP27Gcckld0R1JW6XZiGobdZ/o+kFFF1sjTdvjcSSK97uy/8sJlKc1sq/CZyGcNycLRgtsMkww7tUyGRGwfZRU/Xk6XKmRlqFyd+4DTLDAX2jQJU6tjhXj+RHn++XFZJ0BuC5a7GjGPqJuopNGvcUuWxXuZLFFLkYbWLdMIQq0xbmesexOSntxNbVY51B+ihxZG31F5Wapz0guvP1ddNf6mvrw+xf6e9fzrLhIIsYKgPK6Pn5iT+5yW8+0Z8t9vIGUq1ZDY87mXaIK2IqfK3Oq99wW6OSfiYA0uR5RgeYU9ulXPDSb0PxLPFooJTBHv2iEwotE9G3kp7gIRaCsl5LvsHHJn/yrN4ldDGnt1GnpVvoFyg2gzRM3JJPvPdbGkF/v/9U52msKI6YFHMNYxTi78dMLpSkHeXPaCmp/bmkoroHzzeHd8n+LrKHkJ+iuGBJN874QiXtQkJiUoCwD2LZ6m/WsytUOALWZWfXpIKgiyCgLss/G7OCbcl47RxaWUTmRsOy/umsj6iYNQsp8Brkloh9efBv9F7NrEXUuIsccqKwCJQtpKPOIxEeCFQNkeb1Z9KGYSidk/AoUe39V7scNUOLYoVs83/JjvRiGS/OJo4OXCt+aCBirRXOHw67L/WXkJsQjpbSJURFuvyxVkRbQBdkqwmp0BjYLNBGHJPQZhO3iBQJdVEoa7VWjeEuT0UP0ZSBUgEb4G3ro0MhUlV4F7hR2ina88TQBgDAez0UfiSJi51cJhpMgFVCu5vUS0a9nf+8d9O9HUOD7NMe0OTXz7S9SVYhR2QBPrldnlrYb1Co+4uVM+pK+2p2moY15uCl+wSSUgZYXBmZHcMVW4jSL7HCGpF+YF9nkyA9HFM2Yto6HsA/UgxZoK57Fo6kIauv4emfJSi15pqosf7/A8WVdu1uHp9EyQSA9lvgOYH//zcB5Ph75u11/GWealS5Chax+ECoeshujOSkZuQS3MXHh7m3R+gFEajzfcGIfgtWEBDtwYne8Easb9ZoK5dqK1n3fM/JFdv86zQJ+Us2f1qkQjiNaYI9oqkq0lHCAkKHtG8LhwxeLG0TTaLT21wNBa1a02+i3boftroi79vMb7FzlrKbh10dobfhAC7qcfCLwaomRA77xHZe+IC0unuXvSvtPvlrNovPyb8tPz6D3YP8WNEjAKZwgM+zhoLQXNENqGeH1dbDWAbPaqOqJaMilA0+M3xlGnZGXtX2uyTam6rQpgMsife0VijojnzfAPgaMygcH/dR80bi9EO9SI9N+EMd4cpN7fnT0NM15brz0qEcxqhhgok/PhOCidMtX/uYVH1j4zJjeSuCvYi76BphAxS0a1SKZXnlngjYPqlgSJKpOe0cfjbjbdw52EaTr9XcSP7gemD/wlEYku/PKgbDwFolpHVmTJtKzZSxREDgo7SFK5zL0FJFz6oCBwvGZ+k/x3n9lcjzqdEP8XORBD5QYYgGwhvYcV3seSCGhs30205NTvHOnW3tz7OGKaaF09rQB0ev3h9IJohjrD5onRoOGFZW/QH0QSsnamoepSM5nd/yYJqNDuVYJrPQYpxXFNE8UEuHWX7yKqXWTbuVcgnX05lfYf9VtjtAxA9aLScsrnG7w+bSTybcH6FD07TWb8Dho0XKZKE837z+BG47OZvWQPMeAVrO7HBDSY/K6Uj1K6rl3/54D63ExPBspeWzmOc9ZR/BHoZNk1PCB8tJwg61IKumhjcFhQg9Ck7mWQE1hRNLZpideDx3bmz93cYqlmFySlc8KEFK3/S4V3Svf/Uj9EfwcDF+bokDBxFmRxQS5NB6HFpG9O33x3SvM+mpcOTo/ZdSXs/G0sUBgNhbmSuENn9RqEIi8HBhwNrNR2RdJ1YBFfwkuEZNczOXPp9Wtqoi8SXrRgi3iy6nTldaGXKOxW13/8y5R09La3zTeRC8qNo3L0dwjHaBKKgWpHF6HNMHSNXmH4cvZQtUkZ3kQbw1Jw2OJz2qFZhgALOs96Hp84CXZCAcz6xArBJCo7jqTnnwKS/duFUkJQVVHl9qs5mTuRoD9n+wEbiCZwI8Mipinldb73oL2lAWJsLmJFJAkFCVa6fe57crNryIHFsGnx8FhiBRPX4OAUpEjtbbvStRX1+q4ZoVBd55SqvgVF3O15oAeI6WBldrQ8G7rok3nfpFjT799W2NTuA8DVcuQrTE4oGSszTHtPvJcDkXUi01DGhURWFj7oAM+fQ8/mtAxDV1plMevTNQS3pkXvDA27p6N7Q8AczBP55UsPh1ZLVU4PzXYH7dcwMCTihqRu93Cly/EOKRvmJbsS34lnwD+xOxYVvzrdrsOQAN1299u4HZ/Lv6hT0vB3iMKfX75RvwxN+Ac74BYQGLI8cNZoRMUuB6darFbSSRqTu/3rPKT3kr59EAW2lC1TcMAg+0ORF03h7OKaq3W8vV1hLO4cbdPwotx0v2KHwiiubWF+RKD5CeDMjMS41vBaEs3Ua9AcO1YT8803jh+8xupfJ9k9jcYWPghu0Dl2x+3i2XVbrtSwbaXGnaJJXZWExld4eN2bd85O0ef77wc9OsHyvjmbdBoXoIKSRrKIcT/ko/XyfjI/KSUrNnXinm1c6rqjQm+M+n5V73wkm3mUof6rie2JpJSxTagUld5BWvmzvausaC2NDlf957Q7fq0l73NjxNVvFOe+Tyf/fu6VbNi1ZvfMbluBQv65bRSZ4pxZl77wJm8Vxx3q+aTnJK6amJq5JAq5xgnMfrM5dsoD+ft10eTJonnTNTAmEpM95hJbBnObCrzolu8bjZnQhOko8z4pKGLjwVzfdCDU6FsbAv7spTsVTM7mbabtjl813G5pcswftd412ILMuJXFh+ij3xGCfKDuGA+NHh+aPCahXG4TeetPHLd5/qT9NfqO/1Z74GVp6WskjicBT0L0/uc/YjScE88NcPfGf9iJtfyLMKuAiCSHIQ0ksPeUshVBBXnich4SqgMLZFPRkuNGlAxCvQoPHTZ1GPEVH6XixzR3bQtyMtwvH7OIriFRLtU/BBrnsoE3GFsfI2bByfQUCklEiRLWXQiZe01p1CSeAFDECo6pKY4cyv2DuwpvD1DDMI2lAEEHY7SpZFWJbANBvvTOa1X3TjJkFMuvag3sLwhimdtbmep7zQ56miVjM22pRO3xOa+I/hkUM68W1qlG3iUle/zQ3iR3pS6X9+mGsseLEz9HJtRYx6eBm3vUi08YvDhRJhZoPsJeXSupDiVdrHtpT8N55gkA8bqPjyfLw6FKp4toEvdfOirz07te4+HjtVaDVz4+8sUabr2Nenyaf1hugSeBI8p5pJxxSWUfko8YZVsZq9/EGVIDGeyejrJLGEakmKhtenN2Om0wXIl0F6QHrcqEDBBim4z5K0tlOujthC3LdyXGMY4tU7X/HDz+h/+a2PP9X/L6PPj5PIdWHb1DPJNjoi0s0I648YLbqJxJs9fhKeXMeJLWculcHYruzAkt3goM+UbCrRjgEhlAvJOSHDkCN92VCHKwLrEZgOp8UYgi2Z2ooK6zGRAOi2asdWZ3rc1q+ZwrbvU8v+LKtI0eSDkHbCASlTBXkUfR4lZyRr0w4ek6oY8DcWVDb82t7+nX6pVD5lVTPW6Rhly3TWhRQVKk+vdbhblZitqVS966/9/F5PaXWee/jaL68h4SDfuoGm9Bf/8nptTborYHRMwUQp49RSUFf7JaGA2PQVt/kvOczrBce6+9/b2hSE5dPUngAFwg6Jpt68SB3+z5ZrIhXBAr7Dc8mZibfBgYztRn9yItuAVa4Bxzgcd8/VKj7sloMRZ6D+bFc5C1eL8n7EZHNuKo5nnD1tQBqGTAQvxZ0ww2aXPCd/g/jN+GsDY4lEGv9wwSnS5MTmHTIfXK2YAb4YVl388E/U2svWC8ltvLtHcpWpUYbUToCsq5OrPF/IS/3BaVq5jd2bJnZBgc8W8Vr1CAfItAjWSxY52XBw0hcdnwgUV9EIOnigWp5wp/owl8k5bnSVMxNEBooX5lSRTqqlrthfIrIje69WXz8/E+lHyjvSFh59BXBmgHkI09ezO2tPQRhvX9+fWrZsHe1ANzpocb/dUAys5UsetqJCXmzP823aVSuvDyGsWmYF8TfU4vXTPrPIuoqc2b6nZTmleLm2/FD+4srplf0ck3pu5WswDnVwPuCupJl9YdzZO5dh411NcUV/76sfnmosqUxFqxBPkcRpTLFKSSBx3SF0UZtneImPSRZJstTEpbdRqag8froISgbmuPD2vIKxFmvoNborMe49dJb/lmITIoiZtBxdr7edF3cR0HiZ3u3L4SSzcXT5Njwu3gIDgDI0xZZ8nOEUrRiHPW1gVzzse8T3C8IA+g+lwjH0HW2SmjVCF0Lwfd33D+NB8Vho1k9Bkw0j8wEtyWxS9pNvnLTfRFoquacnXuYrzrjHyJ5O1/9rhy51yGZaO0Pk2i5wBZKifKNdJbZYFUEDoO/ATu8MFdfugtwYQP6eHERTerzjdf9Eh6hjbQnIZro1DV82AwfDupzFle6Q04i7YBY2FeIoDUBkZcNjlSe2XVHPHn94SgWnSDaqvslZAPPqtps71AjT/4SfxeB+6NJ9bLWwWOBi63JlK9URruxeteOt0jxoqMNyaK2hdKH+axLavHYSkC/o2zoYJHlk0P57GtWTkf3dGbZOvVoo6oYFatf+fldLLJ3Jl3FaU5lqzZa1OUfN2RHeZa4YjW5VxzrU0RxR4aBrJqek4J9crCKWO2pgUlI0kpp0DwbICxIJLk5ofSa+VOvhBFaHnzwohrlTi0uLfZfimwsOan3fQFluLUbRI+Tzli9JoGIUZpjxCxZR635iDVZ+3r1bi4TixAW2YnAAIPYLS2Y1LAgn24JzDPhQvwPfoG9AiMaljZGJ5BMJJinAAf0BvQGkdfdej/1WI6L9PzwqlHI2Rr6ESHYzWCW70PDt0l206C+nkNjSzV+kP94DB9qf8T2dXFiom2KIvbBAWSlH/RxOtkzvIKSMJMlNJexwCtd4xhYQfjxfMTBEeGRr6r7r6aTBXaVOSXVCWzkA+6IxEYx7/uFirnCNsGIEVsJzVrGQWN4/v3gDVOgfElP0khj1bSFFWNf93bj2M+URFmZ7EYjy2HOBKkQiY6cHWmnY2hk9qztMFxm/LySJlZCtmUPVSbs4czdmAEqzGoC7YYL4Oz4MVnFhJ9FONcshZHunZ5HRu10rUesLma3/zZl+HkD9Glq29+l8pYWFR1385xqP/HYIlPX8CjX1mtLeHwf3nr6PtQe1PQct33aR+o0g0tXaT4kl9bMoaotbwy89aw/jBNItQ+SqC5K5ufJuYXD1h2PyVdo/tdyG7dx+xym2a6bWWjn+WdzHP6XARVY/k2qw9Xzqwx6WL0N8JTt+62/agVRgK3Vzu/bMpF85y26mPVAeCwAkWcDDyKU7hv3OshxZcGfWyGqo0iIXu/DCvsY6+rKk8OU4UJrbHpP+o80oXS4GXUtbkJFysytsTMnbtJsWtfdyzfmgwTe2SXSM00KBdvrp+xnXVaEmR8DxtNfzc3neeMcTEQ/E5ILc/ASDUFMpgOv311rc0j1TD9lH3lrqdnAuiuK8aOqjIKFfna3vOEuwfBeTIUn8NIKasRMcbz0YFtCFA/GGqoHsjDdaML2MeyK401vRW9zwHqS8veg2WwXiukepA6HzYu22mxSjkqR6mM6ut4YOvL/aAa1n8tJZ9s8tsVlIHO38fCCsrLVr8ETUpdSSOa+1fA3OpgZ1ERi4Ig29QY3zr/vj8A+EcXqor6E5vMNxh/xxPGGdHxXJkP++iQIdR9XWGhwlU8p/lsTyQL0Y99VhLr+BZxBfqSaZBPt5WmvTiPvb7iYUBk1T7c4ojgss4jXznMFwFGpJPu1uF+I+6aQWu+EIaauIpvwCLaRefyb/kK4+KSzrEwwAs1oxoIuEDqCsVkqeDtIjqxbxEzotjm5B7Wi7Thp1Xn6lF3F3oUWaGA94NDn63TsZcVnsvf5khSNsKX/Zy9MvnVJl3lmE1kY7B8N5myzJ6XHqkw9ovxQ+bJ+aYcF5VzaGtl/It2fz+ctkjkyHy8FfRVWfS4OjJvVWsuNQixndgjVor6fYIs8UjC9vuyZcdkhg9q7afh38POyYi/bLlcb+bm1+MWMBbIVKndwl/b6nT0eZ04mkn5qI/RhjpX5PO9qwf/JQN9jnI88Z+3LAan67gNxYXHIxerv8ZzPRAgwwxVtP+iTi0WSmwQ/xEUUTAvIIfiyohIIfjUCSa7Vuc6qxG5UvL9rzbaDCXLQXB+0fEx5kRm/M9Q/5n1R5ej2JL6j+AMHYN3MERPyYhuEPSWosFagg64FDHgtl67xL+SuA036AMHZqaS3Xhdk7PiaYvd6GhceUOjOgKonCmdI7UHnPj40oICQKVnZfnQFen0t8IS8qXADD0LEn5j1TdYTZM470W7jWJvGm4z5sYbE2YAcp5LQYv3mSrKufrihfFe/+t/0DN3QUi4Qofm0/bfMZaS/okEQG4ezDYPkOFLOpsWWSI4nvXpx8l7ZNA5KzPD7Xdqgbp/BurT7x2HLM82tOgZuElDHzdbPAerLYEzUl2+JKNECHxUhlpD2nXw4wprp0wTEz8t0jBt6gNdN7n0ZFeLZ2UJ25b+xuwUN/JD7ct577HFi1d+XX/iWdc0f3dEV/0xtu6bqogi1xGeev1YTLHQVNTCh0eDdDgPFS35XNOomKZi1YelkVhtfJV0Cflim72asY9Hyi8s5jIONE4yJ17hTOXpfCy0UA5ZWgMVP3QFbuXFLtVbjL9GYlXuFf4Fd7rakzO7/YU0uPr9f0kdqVeoLAHeRMmxxIztSAc03DPlLX67RH7+HWcHpfRPv4NqtoFrkINLskNUVYpt+KVPjjoDS8j4Y8C5KZNB1SArLT5ZlXZ6z5JLTl+zZdDoyTbMo3s1q6bXcu1CLJi/vtkWD55Z/eA1pupJkCUU6d/ZYnegDxnO7oLm21HxaHrzfnkTcYuG1tv0rEMMZS4euy/U0wc7kwkmmkisMQIo4OUbivDhpfn+SxX6YV+o8lGOrnry/mto+FSbb/xpYSxRAPE6ZwsWgI/XOivN9rwfEWW01J+3lqmf1LlW3p9aKHK3bw7MzWP7dveg/T5X2YjLRGuYHCuPVDv2GgFEXA8kFBRvrWaWWwBdom8Qed11eBD30NY4hxfleWdDdF8NLHav4Vbw2xHN3dNeyxP40MXhWcA6bBqPVyBWNM7DJQKthUC8+uXXx5r0JFsuhbvEK3WFi+Yj8m0sA880vZEgbidjODUQ0uFz8KpVDCIEeFBpChvmPnw5kd8kWKs9f+mqtWJVPeqY52s88v/ZxnYu50Wcyhn/KrTvHGrnnz+dd0+bEwMPZz3hMaC8aWX7UdkAyE8sq3NhoIYD7aHz5XW++Cmf/0dAz9TetgNkOOk0A5+x4pqyz7idZtQYSTRbbD2mrxJ3krv084kC3jQq9BEkiBOirx7roGtcgE7tlzFjodzd7OBWcaSq293qlP5pXtWiyBavBsDyOslfOSfETKT8CpLvBax49gag34Y8J25VSBOGZ67EZZqVOYd+OvaSec5cHUR6yF4TJ5Xme7W3NBynAH83dmNxPIxwPQh3K0+9BQBnlYQw4hjHOFgweAOiaLiFhbAB6EDrMbHiEu9/Q8b2uIRxk7c2urPt+xO3oXuMdQ9lTON8fkmHuIamNWfTae1abx32FY/PeuDAD0BX1p4GeYheFqMXs6xDOhpzPw7p6i3d3h16DDYSZhoubUULWOjNLFjYoqJMbo9YlrPOU86ButQYi9CAZPH8dW+xsOHrSMFRoXL9FPOvr3M7idumHED5bxwpAhtXboUJsAFR20p9MsSoCDhCXJE5xv/rChhA/IQBXuzeOgpdcHcAh6gEc/8XIl+urwLzx3H6VSo/Ee0EN2cMA+nYr1Tse+HGHfDvSPBBsc6KMKfWIUzsweDaMj7OKA/Xd6/cvQnqGvQmxyoP8YLPj/CCOYbwEfGW6AMcPxSLhm8sqY1krWDDmyLYA6ghaprQbXgFUZBgVGbakWMiAAwvAAawBXIsN7vgph1RckPJNpy5bHODkxlAAUpDyAhggTkLcgLSvO2gFCWGvo2v9A1BQ27mA5SHMGsvd8zYiAyvk6RFiBFX2mOQG+hzKT5gSvAzAW5JXhehAdlbEI67CCAwAAzDnQ56QcsQ6RrI9kA6EVArQkA0UGgoVgZYnal6wAUDPbgWBiAACXgBUCG1rgGqwQ0thjE+7IOsIANgWTzfS65hQsI4IvxZfrAx5rBvI7KAiPHcqq9IHwCY0fcMeExwGNMqlGCosd6pDQ1/jnQiVCDotj1COhH3A2odJzB88VJTP6GfOqVBlv4XmF25nQr7g3oY1Ge2i8RvB078MGFivuesKO8T9Qqj2s0HiDu4GwU1xD6Fmxw65BRP6KuiPsDFcTetFoB41bPM4JuxBXypQh9NBsP1jUQbPD1qJ3aPY5WNRCs36w6A2WfQ0WNbDse7DoFZb9DBadoNmwNddBs+Ng0QssOw0WPcOy82BRDcvGwfCFw7cJNPLzm+Dswu+Xgg6lH4yB93xC1/H4Jsi8cHcp+AHhbRRkJrx9ENx44e2b4MaEt5eCG2X+6ATfvfDHq+C7CX8cBN+VS1WGKVfp6DRjXeBwz59Yr/BnpKRbwWHHk29zHJRt3TIOwoZxhqdrmpn8D7vfDK81hVZ84ec9/dN0xM8z8f9/KdeAFUK7cw0r6axWO0LjIocgkEu4Oyq4MBVFPcBWUYewhNbAnfwSDkN1DkBVbrNY/n8V9l91gBS7tYSC4kjhENF91SAgomQ8wOBI+bzrFHuXFjMMSaizDIqccBXHkbKjIiyhUM//qDW0yEbaQtFpQAI4XocMRtBdekY0hoyiDqS7DW20BOrfpQQHqRZ34i3hqqTPcqV9GfYDtMM/mUKltbCP+IxU9G+rnGBEuwR5uZJLOCasi8NBhekkEMc8sMPKjHnxINY4dQitbG2kkdhqVVfDC11Gji7UsLGyWsvypKZU/n8vIYOiIY6Dw43BIkGJwEwRpEIGh0svsAOS9HtXXkKhbg0YpCWdVMURjYVFgqISvBBC44DFae4fYRkUtaE6F05YNQv0rfrA6+DExpCBzUEUcgmHQQPSD39GKgYyZfy3iGZRLdUoGQsjlPvjJ+CLpb6BL46IWJhuCZUyOtaC5AIzuNKavCowC3F1R6wxGf3SNjrZWM/tL6RJQtdLKH0G+6jPiEBjCkXvSPnNeB0cOQQBER7lEm4ORmgdaEwR/OKoaDc+A5TRSmqAkkf2fgkxnPoiJPCXiMREM3wpRX0HJwqzkWzHHK5jYO+KwuqvF6Hul8XqRQ2ydHxb83HmpZi/L8FmJdjRDe8hm1B9EqEw7RqO4Y2nYOAlfG3mBcI58PA2xQxDxuaQc/o3NknYJhXTItBmGvF6jPEApsPX0wMbC9LO6QHxafY6Nyq2p6ZPDa0EvH8xp844Oo6YYdS1svpMXhDjvp6dsO46Qi9v3iukmSR/F+9+OIh61Ct/VPbKeZmdSB/oJ+FrdBMfI4qGt7bPSCRi56nUgfT1YeBBwnRY/xt6UuASjlFANFuThhgOYSlAlwwZKGdWvdCHFA9jxzc94JtgipjCfs5RX+vBSjwBW6jyJCyA4zqwc2DxGlZHBZS8oV7HZqVluIcjqOEOEwxSzC5AKjPC04hc2mjxUk10c4ccSYo3ciknnIyDY4sVEWLHxxFDHu0QYnj/XqeipOi+MURwlyDj25KQhLYMKHKKX46XpvF1nGW3T8IjwDBsXXMR3kpXv3v+qcqsKIGZ6HEINNXKHXjLWnR9EmOWykaMgj4DI7M2cEiN3+tEEHkSlITHt7OipDRo3l9YWCAHGDK0gEISsTGxFvzWMi6v97XZI4JV40BMnBikWl73SXst6YRny6MY6qwkqfRjFJKJ90GbaCwaMSJZdZylyEHwmT+iYXAI948IelXWVB1S5HcI9P2FhzFB8pmC58YV7I8Mi2daiTiOCVGhEI8Bkn5NDLFLwQVwDNYisHk9wMxiHQtVws5NOvgySqBS70ZZLeAuCi5VcVAoqIOLjTC4MSNE8Numev3kE+WCkjgOBfjrlctUy64zxn+3wPpV5wDbmdMEBXpcriWqIlVbRmwyvZRx0sF1mstL8ZxwxU7kvS7+eHxQivKk1hCu9IAU63e0J+Zsl0i6DwrzJi5vUP2XEaM+v1Ga++5+cC+g0yePiOCEM9GbXAnKYMRa24R6ilRSj7XhCYN6qN/P7u1GAxKIGEqYiuNoCSJQ0HlMIEEZdJ3CFI7ucjKLz2owiNTbWbTrlLdJSfX5TnV/UoPhqIYI3LRMK206c2P3jbyJrbGBFoMoHvn4RnjkhB5wkbdDQsNhywp7qwiRiRRbDKrkmOOu8EovIjdlDHc5td4CpHiuMIbIS0K3L6J7FyjMWOgx0Sam1O6TYPJiofXhCOkLDQdRhy0PHbn3jrv0HnJ1zIL3jVMvJMgD8fzUGnKSYEwwYzQ4O/diLBMiCoEJnIuQDuNmJLchJ3nLBhYOMzMgW+mHj70pls1PBEZs73LRb4sB0+ullXTl2aVXVS9n8EDm+zGvH1iLd5ptR52tirne/sqcYC199OIcxwkVJCZBUUKhB2Q4pBdz4CW0V6c8Uq4EqGYTXPf1jqZPyCDSE7y1aJlQldX1aRwH5wptNHCv6dknPoNU5SaaNaTRDppB55WDgQewEZSr1R3kSAJZoz7LqOEfrTTsG/gTnaGBjtijGCR41Exj6n/tyMATelVcwjE5f6TXswwKot+rm1WkWpLuhufvT6p7qzm/czebBd1XCZqz32+ud24NuNIAsZd3xUQXn1oWGhvQAB3Ql0sqiOzuerYvottZBtFNvj36Fq+abKRHK+40bsCLyN1WI3MnBU37s8J4v91wh8iuPT4RHuPJ2usnn7X1PSKqlTWwQA017EJghmIy74Zd/07S7xtnRgkVFHcNo6MJCzBYTI3OGrx11SDQCVgDT+BujiU6oxc4unrpCDiRfInhWQGn0zKBQV1ixj0tA/vFwZAR+eVYsAvE6T+PwP5OnR85QfoGZ6h0HR+E95a+ymtq3RnM4AloNSA2W0nFol9VzE0rV3AySRDAid+aEJUhLWeO5JfDrBLHIyCb0caIjmY++Rwvd6wPMXREu8YKn514rkzPBWbEgYB5Yce8FIHbx8qkEwaM4HEBh7GFUE6rAK6RWEOmzI/gTNkRdood4ERraVDG3SVU/wADZhgcw74qaUlPxET/nBsnrHZznqI2npMM2+RRjxAUhZnOx0oczFdTZq2YpKycdwVJ67AcPlkJGv+w16rL1h1vipXGDqI4hKzNumzXmexowIlJBGOaLPPUokOsCuK+nvMrVneDs2Uzy8+vwAa0sK57U8eYvD+Bd/9GieMCtmW4Mii6xmL4gMSCSN2WfB37vjkdHz7pMQ4MBVloK85th7IxKpkA12A7AoPaqn3XKQumJuiR3suNOj0TsxRxexlHBbeuJGiKyCMeqHQ+q1yQ2PsqnwARpdyQzoqNM1mDtL6UgSZOKCZo04ZvJZcyJYNBvfa6HCYzd0kXMCTMD0j5rG2yaI7xvgCa1XCPeijyK8mLEbKkTophtxD7Sm5C21M2USV1o/op6uN4ShqBvfGBWYjs6gxNUBD0e65dRdpVit6eDgMUeRop1w6LU4fYVvRdp7nu/pt/k5XP8rJBDJroax+jEUuRwcHyKmmXDQUYahs6KXZFpETXMZrFuEPe3n6Mds6dJPSYJuoARWnCBv9j8SKdIRby/usRYIJ65YCh2J0lzNuAvhKALHvqItgKYLReAsbKtAbkJAFXiFQqbfkVafBHnGCmzHBW3rev/2bg01RB2ZHxL7WYQF8ufLw0dVXdQKg9ziGMU6RtcryqaoR04i9v9KCIgsTmVYXMohfTosk7ZQN/Oxr7xQiDFC3hMMZ84W5fBK2ikMnOX8egTXmJBn0L7I+kPIkDygDdU6NjeL8wv6Ol3b+ImL+joqPKd6JKXi6ygJFnDY5fNjRJPpdsNdJwiTYbkBUh75leURopKm09ItdqnQ19MKhtpOh6gSInlxyJ7vlZjf5AiRaP2kENb1NqcbjI6UixxnVIRT6CiEpN4ACBRUP6gdooG4NQ7qUJ9CTLBBBS6PpR6Ks5XV6I7mMCo1Ddf5ptT/Gs2Ls6vLXjmBXxXikQC+cisK+hM6DQ+GOf1pkuacnKlazTayM0Wo0D2ArSImpUGKuBRZhTrK0GBvM0BmOKma1gZg//xiGjCFq5CagUter+1FpALuDPHxWrz9fTTgxdxCE8Cc87hwU8WZ4h4nssJK9PkQ98u33SlWCy4FNLfi0oz4T/6TWDo8iwL9J37fFTrTdmlK1r11ZhQe+UNWxchN+rGP0aGFL4tzWnUKwmSvM9SAm4roA6Ik8ei4dKsRiHUId3V6oEDitv8NHLRGuJCEhRF8Lj7FVTq21FpA3tdHDUCBzHAEiBoyqRwSfOweAEXAJgMlGgtXzN67O82rhBke7VztFcjoILHuvFFM65uoHrc66NQe9n1VB1YnlQy2pVZY+hqaXqSHZMBG8MnjC0GiB+szHbOHBDMZSMAU8HgyKN6ZFM0Muapw9WEsUO2rwBuyS5GwAh/zAnz/glJifEZAnE8NVYG+cSLAWeh7iS2P7YAaH5TZ0uh0wdWPDir5J8k3hxYTFQHnN0Doc3isRuKUe5ucT7sp6h8M21AjN8lTr9YnCHByiB44GBVYEDflUBrTjslb9+Bvjdr58B9o2hIgLgwYUrjCsSB+tcfLk0KxqvKiBGdhAJxE7DESRohIOJO/xWlCCFskA+MZTbPaicWfDJw1fDUZWw70mLEfliQ+EapaszxbSetoLUR8WnyOH7WYON7sFrOfmVncFdLO8P8PcSx+2RttEpjrB94E8Y4EISeVe0KhaX2j47VqxbnBUqovdFXTsmFPkBP1XSGaZ0pPASOJ4arDUdTA2BO9P6S+VvJw4k5wgh7SQZ7DRDFNIYKcyVNewQkSB4RpxkJtyFox/loDpcPlbcTp0ux0RGZqyMh0RWPyVxsIyqHGOSB878vAC3v5dBirl6uh5gXqkBDeK4QhAEJBAxPs9jZSvNfYyinLbYng/NsRJPRfx+G+RgOUiGEehmIQFF04IXyqgm4ILVelpBV4vAyt4xZt6Wr8XXdjrK1HJGMPqtHVFUO1O+rcjLSxK6H4+6vxsLcqI0Pqmt6i0NYEs2zkygzlltu8XjEE4PRo9rS3oxrhX6/qiKQ5MHAkTqw9zDfIkV4hVM3X/F4vgZXBgzR4+52GEsWQHRwf1gaEKy0/J+Gzgcw7rOUEwD2LUk4pvUK33vnpFkBa5XTMDKDnBxHClYHcTIlcuQ/iIx3CM4dLPqzhovIawc6bRcv9ftiEX6xfYmIxnew4nOYzFMFJU0M4sQaIE9aMPRMhKZydiBhAlJTRfO1WKEEJIhEi/GkOgCPK5jqGmhTTYkDU0Wmk+nhTFptPFuDD+KnfurGP3ky1zQ3cQFj3jXN2G4cr2kikqIxdQhqixNG0yEm27pt60LZZADPNXG1UMM5sXIQ5GDfm8Ee+z6w+GTvtCS6xENxYgPzNV8AEQcfqwr1kVy7/go3hTJzz0p2jjNlgcXRq9y83EKBWHiLkE8jPgwhXIEzbVZzOypO1t1xVG1RDLhjJdT0ZhlXHISJEdLwYL3G1GxKqOgME5uhNul2B0BeFbg78AQhR5nMFM3MC5DxTJi79N7CdRp2f6bwwDPc4YVM6Ukw+ZJnRcJ1Nz14GmTmJsrE5YSLh8VuV+deOme1aLHkgKT33dVY4FFrA+4q+bPj/UxAMKA6AvOdLQI1ZRh9YCWuJB3HO/N47DrVKwDOg1Ab0WSY+/aVj6LC6YzQngPCbmkJX+Mvchrf+LicynbxGR88Le88NOSYyKBz37h2+KSL/7CV3/hjV+UZp/45XzDHlZy0mRMPL62prPabJ+RAQrnNwWs6MwBZP3Ska6gzeIcaGoD8SzqVs1ZhylK5fmFtdVwgPz9joD8AOSjVgpeO7jI/139jOIU97sJzLJ5BAUyoCNErTexMpTyrCOUfh27Lb4oQhKOC2wGQ1KPCeK2GVZ4ESxqBplajnk2MQshTuUIJ9RNDWxtJuzaZZRgY6bND5wBakeolpM+BQXKV9q5Y56yaIK7ihwxKG08zggMijljzMDwZCbaXfloC0H1tNBTGhZVDDpn0PodxWf2iKXI/poC79ODeEOS2r7ISgFF+WevG+p6+y1bfBgC+yoR0Zf31iOGcGUMMcPeDTfhVZ06oXWsIoVEONd4SZQMAQjWVNlL/VM4Nb74sJ1RnmbbubjA19g2+wbOTxARraLT0ttqcVkjof8B1f1lP3c7R/9Gkv/pCIihEg1tHvHNt2qJeMNwmbM12RH8nCGjiKoAUjMrEan87ZEl5VWjpkIrhF0ufTvcj1uK4m/8Z6CvbYmSUVv+BOBqcG9ppP/ZMoJ5mIYIlMhQ9/rrwiSehGY1Zk+mzy9mOCSnjiTsdWzHoOYuEHxig0fjyCtNdIP7o1pYqv52eN8x4sLFyWyf7b2CvQ4M23joQJjvRYZujnZGNgwfd4wJMuamqFQXeDIiS5L26egkuDK4KMPFNQMY3GUUK97+CizsvTszYqycX1YoEdmc6u8DgRAF+09/wHzn2R1frO13Bu2aZtjfsmnFW7vK5AoanlA3bXn0ikF/uNOgrB3S8WiOdYrDgDV9kZIXU60clEmkgtXWapKz9RiDOqcSuXTJ8LcM1T8o4X3zCyLWVqUMyP3rmHcpi9w32KrsW0LXvQpyNDNTL3bZxLhb2TajVvQPs5C0FfdZdT2cc76hDQdWlzlKhbHfGVcop1eKg0M0ghUazBsbszlZdguwFrlouI2rhVt2qLWlD0Cbvi+Eo5qDak0fsBatlRni7XT+UAeEmZJdhCAjoXG+AW1fdstC7XXgEr2HWO90VivnF0JvfyKIwrQ2oYHKNmAr9DsBTfw1/qxDHxcIhhhFBJdETSocFGGRwAb7gjh4AggnnnsuUbxWpooDGofb4sC+7DA05WOeBGKERSicd4vlwB5egTp4m7hXIkeef0I1yo+gMWB5npPxTOgMWsY615GB6Sr2VW3GsmH4y2zgOxAeB/CHpzIrM80Z5ewQROaLckIYKZjrY1v7CCDCbuxL6ks8/AmbrwTyBZ1L75XrgK8P6F6sUlWYjvnavvUHLsiIgYoVOBuW5QqnzA19vRsAskcWHLc1V+cOYW9l4D5IcX0IWmn3gXWK5ZP1HG1eLNtStgfVMJqT8CgBv7YxNY+X3s5RrStdPoMx9LqH+3bGAS1eLa8JDG4P+4c1GU6XUMQ0ubX3nZ3r3fUqXeJgjeiAs4eozDpnIxxo+BX9gjZdQTNP8spLp/3Ttw6qHUJQS4cZGtAcned/za6Q54eIXYuzfZNKu4OFwtfDnV3jeDt3sesHLhOjW2/X/xn2oUd7/g3fUJQcqWCBkBpJOWue+hAgSi4R8xI3O1qAQixOq5QhW20NpaHKxUR8i9AFpmBWDajieH3KFYgAdPIvDp23UBEb8Ohr5allRczofpWBLmtNyyRSVG/HcttaBuw7QMwmIoIfeBNJ/O0kmcLiLWh13kUOKRC7ROiVfMqMOD4adSFa3tE2frdaylcmxttD2n067ipzuEVkxry0xDPNzNLPgbHhqhmF92Mlv3Am8inmmlYJZEms5jMu5akowiYzJR15roceEf1aKxiLFQHhQ+t+A3rn5REMVApYRWuXOH+ZAVLwntej7VohnZaPOLJkqAdu4a6cpfDesbwqipmObhbnOM/2fza0BA4A4VAaZaqhP/zjzUxS5hRyl3YfcA/5m3/oe1+BIb60fGDhd7MyaDNdCaS2VpR2YQzL8hYW1D+wm5XO/3uRYXVW6wsna1qyGpJQf506Gs/yKPd5cV17OyaUiL34zwOuKqHkiqsijx33UsWODINJKczXQo/U1foE0L8FauKLUx+Bt8MMfLU2cTZO7YZSMrfGvnePUuRq3XPpCN6vNh8V9DW8Q1oejCwvoQdfZLVaOF/U3X7QaBYnkfKccGkLqIWirrI2I9AfmFCtt3qzIKiPqLjDiiZgIuYA0r++joZ1X1RVGxVR9GsLCLUII5WCuo0+36LFFvc1vHa+aXQtvjoyTszOkWUHbMmE6UzSOw/MDrSEoOi3WA44KanSUlWPVw9HVBEYbuG8WnyXDRcTza7d7J1sFINpbxPzpGGoLXJtknOGCzS67ZqJfqM9vUXaufJZrYKCIiPh7g9LMIfGHARUdw7LxXCFS9iF9B/SFDdo5eMGzef8CztPgZdgyioIe7iOQfFSSARmFwUt2pvSBFLw7ZUNAS2yd/9IquQWATgHStWpmZZTIh5jcEdgnEsv9EZPJ+mHItwvsb0+tJl+DAge5ZajBby/a7Jt7038O9VLbLt84h/oLoC84USJmtPsWP/LH6Qa6ITjzTn6/O1gPx2wMplYDoZy0HPIrEHjvbu1X6OVRvY8Xxb+gUqJao2H8ZFJUk17odLF7TB25J+qxWhtfJr77RNnI4v0D/QlXaQ0GVISgNO9bNusOtQDOUTY7yPXo9nx06xgCE/XawXnoHgV8xasGJvFdZ7cxVsX0unGB2rxWjwr+zI30nQPGwsKnuutb8B1HfRVPXFMXCwmtLR3ZiJRa6cim2u0tKBTN/5btYCWxIvQUH/SzbITf1iOknXX+ni9qiCDnb+YMffcfrcv/hZ0vxb6y9n1aEj/QfsZLS6zY4ZGdZ2nJz+8ZTHsMkTSkAdsxaXNHghq2ZdoOaqiPbdvV2Cq6pTL3N7xMgAsCrNTFxfUYp8zGp9jf9X/h2c/9v7AFtuwatKEmHO/nCkA22+CAl/VFSK8fTQgroDsey2nW4LtRY28bDn5/iGGFPrns6fIIFEfFfZ/kfxVbDd2YQb5GnG//bpvDu6rKS5uwVBfmLWYYvBqHa4+hkyeYDXRBBepr5hP8AKcdZueMGHIcuxxxkFaO4QQyfOtjzhZS6NzfrnnkHqLceF404kkXj4b325WalEaYx4cViOVERosRn49L4UiedmCfOhVgagJhYqsivgXyoTkSuezQBSqrT2PaTIkumWJnxKV4i+neZyS50dN60Yyc0abMQXfHBfkP523mKmrbyUS3hbLh5f1d05IIGwwFJ2j0oajfKP8CkecQ1V+KH5+RAcTfuvOHMENY4bCrGqhJd4fRyPyQH4sInjMwGaOYMlR4c7qjQdDJaf2rhjiYdDb+pEQVUHbg0huHGsqKCfdRLhrSB1So2BOGSrMeBXDivj6MewIE0xbdEa6i+w4FGqJcitFz+NI6Yx44yhUcdkFi+yKiWns/XL5RpNF/dV7jYpYe0sUTAvtNyGCVtUoC5xoKY6yO5sFhrqjRX3jAPyOy+4tZpYkrh50S6EZw30fUv0qnn9Mez4NuVq/nl3Dsk7+yYa8nNL/uys0GFMvYHDxWdPlog/hKuSzd1yEcTC/sHIBrpPr1KDA3Iu3Z1ezZvnQlZWHjivLijjtk6HCbPXGY1L0Zr80n9rknoY5f8QTjVPoWSaFTiYAUxduYaZLDeCID8QaD9VbhS5d+DvAiEXTJZpXr6/Y/qo4JMe+mmfoCobv0yKXg4/Fozt0ZZxTZQli7gq5fM02C8fRW4j7yN1l19X2QePRLgjnUJoz+a3jWTvMdXyUBPF7Lb8LWULwpnirkNpEPBk7RbalojunR+AmblRZIgg5VKcN6LwpR1/NZXwCAuvEpV6ChC6wq/xehjucTq8Ud7MdIrHSG0weiPWzjrFjSd5f8L2tD1OU39nGRVBdZV+l4MKqhlxantbJuSLDIlnwgnnl6wT87bEGJU+PmL5vDLGCfoefzRXObpTaB/g26XZ1DpGr2U+epLCzrBjG8NgCU1MRlyrNzB0dw5YKy5XhW8qUaUK5ubZJFJz/8OTvUMNqQZ8wAObzxLhFTYh0jVUm1RDZ55/RPsmtltcIpATLtX0qweUj6UaVTVgWAabPKB3ynM11XeEfCDV8ew6iMwQ0AVPacbZeQTvLZLHujiULrR0NVQh35bCZwvN7foIvVZCLLt0AEtV5ooPItzjsOIVZlNgfMJB3PrAIsdAHjldjlIujZzivTTSVoAgQFCspAcjhIXcT7yuJl0AVDja4XozKToGdWuW+UKoZHvNQAzqsUV9CLan+bsj+rUD3A7T0LwIx68QXKwtPAMPmdTgAK9T3HPhRyAWWlfLAWmF42GeS60Fh8rIrtePFxqaG6YmzogePLTTYPXwDxZEKPF9dvAZDVqIkIRETVuvDnr/j8Wz0CNokbo/8Z+w7+LDln30eR4pK0FgfVbzi6MY72Fo9AjJhn7NwUhPeMLtg9DrRmX80tAQ0HNbpyDUiVwbFi5Eb5xwoZLsOhboTg3LiI1aFW9Nq8PZqSg4izIx9U9NQeLstFMILgvgVlMRKWqOkouWyAI89aO+0/W/QP+5G5vk1pRQOjpDAOsvJqf8tubOVOBCw/3uGCu3W/H9oPUAOtKZlR/gZnQGq0VsozVvRGhxD+PPeWJz74XqfLjx21+l9Bd2JA1B7w0t5CtveFp9Ad7HEccc1HsqGEj7LmcWSp1BP1VVFTNzlPtKy9DVay68jHgE8F/KLbW5rUHSQI5PWrtktK1x7hQYQiobrLR+o9DCEjtbF4orxvzFs/4z65V+ntK9Pl22huhazJe0pXytMmQ0xODx2TvDAtfEwBqoYsbGLImHTdrJUfGPCmkZbY5TCcVECq27LsLgMlsM5e1/4gDVUyx3U2k+12pHSsFAdo5z4R+RfVJ3ihV4LSTgcEhKcHh4su3pufOSQO4eS5zlDo+/Patc/JdHRe9O1NRAwMVmsdxW5kFHvJOkRwpayJFAKa5787Vq7P386sFCSCO5q/OnXZPr/a2/vqZs3LeWwxt6hv8LJHRF58nGhON13ge/RiqF9jwYG7BZ5747d1X7JSOS2mLES/2wt7xLwYklR8uAlgo7RJhsEpuVpi8iYgzvUYiwqRyOhCVbScTqYTzDDVOV6xwZaIoVry8v8cYC4nZu7TCrD5IoF1XCPFln2s2NW7Buwe6wZlvkCr/bwvqB7hQFW+QOTekTY5QpwAplh0o2ZjU2Ao4l5bqve4eQW+aTSPDd6cFoopxwuedHInGQwX6W5mI4EnMjYjceIBAUKuTnH6sBZRQmVxUgddA46mOw8dY7GidAhw3LocFzggEcq+94oua65hdRwjrjOGv3wyQGWUyYwT1SlB1nNtB7TtMlMKiy6QBpyB7zgMMg+jrM6mTH2kXrg9nKwO0c5M38/Ef7LqUP4DLdLzda9oEN7iSY5BRxUnzwmSjhzzXm6UexzIpyyGRs3PVPC+enz7kXIPm3Pcly88QD4sJA3w0QHTZBjg0CRdzUvoSBABSCx4r0GhwaYJ/fGlRe599nNC0/VvWeAKE/SUmv1TsnwCRAZ2mzp63E/G9VDAddG0urTscKHgb2bjpalEUlaDnvAP2KO/GojqCLK8qn5FvzphMGfJ/DLuQm+Fv4Js0ZKd5Yw44gggFzvIB6fZVWF/lIErVDbhKbVtQ4k4zRXQxgBsVMdyPNR2QZCw/rZZWeyJUDyEoOweAYRL5WCsqEhFeNeTOxJ/nI2Mc7OtYR5HHS1qRZppB0dhhKboElKrfpbQoOvFhEqLaM6TJuIlHi73p0HR74cFGT7JjikB3ViG9UmpnpySqw5vHN0kjpeFscMOQcG/L6R6l/+iv9wL9Wn9YQrJdqemZZhjXzFdB8RGlNvlbMsPJddWKusTObiK+6R4RlFQLPIsZczxBJv34v6SCNPimrDoKg4jwBbpyegcb0nS9guyt8Y9LxB9FV8WnmbX76ygwFO2xMAU+bcGRjLEyI1XDCxZhFGmdcc02LXlfpxZOGOH5nISzzoyxk0EZ4kxqOHICY1QHNfOdwfFSX/2dlEmRNh9Jsyzs0fUNyWf4CNU1sYK7SfKBLddWh1FK0Kpxm3fUiNJpyKPU+b5RtspEqIJ0FEns0Oz2Inx6wM2XprEfOYlN97IDE/te4z7t1j9zxcu4NTN/XJmXaHpFTfcbjH6z5F0SoBnTjTarStaLJpPawrYVW6ZWJeia7ZrBQA20yEdTe7TXhQ6uIjIhfXCy/+lAy3tHN9eEPR3mF+W90VF7gbkwoo7+dKCrpfTghYzVZTL7rhciIfbSR4uK7S0ss4R912qecpxFWoq40CTMJchluPLsXhyOTUWa0K7bcf7atuIRr5CD5r+o7fPRZg2+UTkyFkfTaPT1RW7AhIQewhEu+Aq1XuE3dN7gIgFUKQk/Qel3CmMUyffeiomsY4feOHjt00ltN4mL5pGuvpyz504TBNd3MtMYZvSrfzqEulJCxN6W7VQ1y6WjLfRtAC2RYR1UYXlM8kMn340KF7Dnmc6as/dOSewxxnGofpm6ZRp2/+0PFhGqvpWz90oZrG22nanGKxQ8WyvLPNapv00D1SPOGHc48QItfw+xri0q1seL+hvhz9V1PCKoiEtujWErZO1ouoO79A9h4MjhACDmRgFibFztoFRFR2Bw01nq/yGq5+MbAJjkwQ+8ZF6rwKQUiRxAsjHSNpUghSDCjUdep44CMySviSlj/AmOi+iDXVGIdFwKWgf7meYNbAxkBCgCYzmdJC6BZq+BID+ifzyv2hkyHIghAS+vtmMOBkj4/NqeFckqSyUnan/Bx9kO2WKC1PW6isbUeU6pRJ8rvePYST+Mic7gBLnFzOC2bYrtVQrLqYdzq5tjTd7mZbh1C/D5BV1TartxapXofrW7eE+wGEwX65ic/C8Qsak+eOqXICXjxv5xY/JW+3GlKOO1nGZN3bcEHCBQmmsJsfYyKxn/xqMGk8GYywbfuY+0Tx7cZVvKW2QkvEX+HC2FGivkEC2UeEh/eg5gByZ2Lm5nmxilO516CVquLk6yI7BGyS+qogLA5PPS/iDLT8ELcpWQN9gIeWrklhS8ryijE5/jTMttn1Qc2vcGIuChe/Kv0NTQkb3l1kC1eG5KpLdOpSME0T62+igQGzTwy+Y/fz/eaYyToybzP2uzosADX9ys4tt2LQGK2j/S7BJlYrCVGuX0gdRdbvSYPqcdnDxKanqn1EMN2ogNxDqichF5fyETDJvjbpRIo8AOQR3+y2VrmF6JZjC7V+cniy+0zVi4hgK1Ie4T3T2nn3SXyOV6ortQ76Yeo3rfuXMrr4tYQfiWUgoKwSicAj+0AdrZkyUv/VbKKX22wMdaX1WTqkBWrapBKhuzWTOJj13HDa1ol2TZU2SVK0xsV+y4SB9ck0UXIH1leVnZNA+GOstQ+B4eYgAYxTrByqHzEDGJ9pq7cnAZL2Kj0LYXntHxBC6utz1oAq1s2/p2fRKv4660A8j4Cfh2zHIpNhd6eoCDAb0j53oqUiRgapDYw0R1L0XjoajqkPOMfgMTx9ah3yNCdxC1iNx5IG2kl/xAjm5j01mnaQ1JG77lsHRluREP5R/hdpO8v72PfAo/wABnP1ZnG1AdQFRzvQAGFsXjNHODY5hInzcd2Fn/CIJftT6eeJnu7jDmriJ+1cixdj0WUCw7isKC04020/wmn1vQ/3ymEX6P+aIm+tbpPDj82dKl+bifBw+eTi4BMrwUx81TpkkmjdC48O0OzA1tj6gmzn0CE0pDcS9ksHJtZBsXRhhi/Y+qqK7Qh1aMZ1EXZCVrqq35HevnwnqbhXnFJFMYV5uqcIAzIvvallX171z+sslHzTpewCbFmo64pH2f2DfZ4XNj822TfqJezFFGNzMCL/83npCPg3VPDKfw2ty/vY2hjXGxXvwwEpi37Og0y7DcyIoFPoKwE1PwQp46VbR5N8HmHAozRrlupJ42k6J9jEnpfA4DlDKFMBCWk+7ktiL3hrLInNHLvkrqR++clW6TmTE+gGPLAFZgv3ZIVCh1jDUgP6DAdTN9s5tGtSsgYrtpwkaKT1GVPD7DVc7q7R6b0UzNi9SLAk7tQKXqubg2AF6+tNBhMEIhRj5sXAlJvLDMyK4sHk7BOWKzl7tc6hTeGrkhdul/D3jeb/7ppOCu7Dv/w+X6/xyhzP0rckKQSEr8Rxi6SsDujO3JNyZI3jDv9cVIaNVg9KlvWM0IXaBcFqjBeTX8dUOBMwf7rKTrGu6btfyYiJHGEQixxyyMn66paEHWAxU+j1MACOHrhwUrpAQhblMOmNCRQ59ELTt3tQe3gTALQtP6w7/os74GAK4MXoIOXqQXUzmEAjom8fSC0Er06Td3Z2OhCIUAuW80SWzp7r8vOwlkIixENd3wuJVsflioFaJsNXRgK8EMWU8ABbsbJQUDHjWnMKLKlarjpCqJo23/GTYwUinpUc8LhokfdZQdU9PAWTamYltdDVl7wE55yp50B1ZgjRRpsGkiT0U4nqhygUIi4YJAKzuHjJMu99gd4OSRXc226t2Yfew9JUYbKGkDNBHGgvfPAKu1wAFrHB4L5RxeKQZgJ6uzvnywtz4UgeBR5FYkFcccmYSFWmSpu5KBaXloovbgqnOQLz3GA6ODH2NYgZJOrl8CXF09kmDItpXVPAj2QuicRse0Y7NhfWT2Qyija0bklZJMLx8pKr4mkyyywj9/dObyOOYB6JRkt/ivlrsdarC/j5zGHEtEjy9u+cOc1Zg4RW8JvwyE7vJeFdb+Fm1Z1zDVVEb/wqpEhr08nTMFWanJPhG3CTDGxsnByjuLaiqzRwtEyU5gwY+r3yZHtTtY0UiJwPMqJBFir1MNzdj4qWcVbtSHdvCcysiolwNGNHZmh1/8XqbwC4hVL5adk1GDuaFoWfU7B19FTfDKduB2XCbFJShUlHmc/BXknZuYSJN06heQSOzNwrCwFzz251/Izem6ZwHudQSb3CtUMw92WcuMC+KpoUc4nl8sRRT79KjdnjYjPBGRvdc1m4B87Y9Q/OKAuHORvns1e4fpjlAWZUwTHoBS8bg1oYksnMRW0u/+BcgvewBLE+tPn6DrGbhd+Z5dETd78viIQ7/zioBn/9+4NgFbDq1L8NW8r8zSvJJcMzTyAmS9T3YiyuJwx3uNJPBkzLc21PKQlX/FxtffR5Gpju/patkivekgMgVDvZJcCKmawwhuZCHAEk2qlu8V0ktrI4VMt46ejcEGVLJuz3L+e2SnATgpv7AIsp4u/Uf2ibG/9i8zYoe2MGAVyqU06Ko5YRqbHuLhEqQwFfA08TquTmrDXh2rgutURR58Cw9dyMBKRR6u6RG6bg1gKFnw1JwZHJU9LjMnBOTcOlQpRBwCKb0e6roydiQqJBCZdK6fV1varsZshHQhQ3GgjDlym3xDYzvP1AW0/alpbxstxyhwjrR0Df21K+oNWpYllS7DoPbFd90rLi5kIh7Wc0ifyvW2S/W0QhaSgY4XC8zWN2RbhztaHvgYow8aQfV44FUlFZXfZ4gkVFYduy6YTWRMCHuGRFbf5wpDaNvNqB8KkhD6cw/NueTGZ6kRmtbDhyER3hQC88ceeEwGf5GnTBjemMPidhyO81fIINu1adeN0cp20bjOstbGG9WZfzTcH8UosFzD9+Ag/3jWJfSeNwmX5+6K2VxFzLBw8fUGBb037OPeNrL60/9Q45oiBnTl3czMR9svNmpAhUg/QVFjwbq4awka9P2LqxL1T4zRR9O99+DnHDHV5derJEXfVES5iGN6ysNAhCykBx82rwrgoBC6ZZ0ijxdYynAsaeaU8Jz/Yroul6ik7awB5QIFHBfKiAenu9ysWPpesOiziX7PGr1LT4FyoKP5Eb67o6bASHiDI199lVb1UcKrUexBiv0oA7Swrp1CFj6XZ2zR5OX/ETvclrFw326HrLdJ9tOQUSOw0e5Zu5MzIiIoQBsHZjVU8vfodXyPnXRQzdUOoJmI7F+eoT2yqc5Dcm+cjs4p9snrE33zh/mMnehjc3Ugr+acj4WFPdmboorLSqXGoSZkJGEg2EZsbqipMb1qDjw+N4yHC3L75mZLVNXfAHM5HUrI1+ICT2EZIeQ0Z4NYL46CyCE/b4SE7E9SoTYvJdbUPLFvO5a6yuqkaz7HLKNdC2+6iy1+54+/goFsxVrDCXBVqEj5xxLbrhlYqulHdWFmA0jOCs8P9gj7p4eEyhFBs2NLlsfkvzYTPuLHcvL2ln4n7VMfnGexGU4joUvm9JiyG+CGaZPv1zFSIcdM5PpF1cmeyizaCTbWz0+YEkqgxG8PUpNbWGfOC+bG39Tj+Plw9aCBfEmKsX2XHdJQCfvxhtXTbtjhLZ6vOmcZ9I7jnCsVzrVKULhqGlGRbhVddPhJW62DMJ2rNw50YP7Tn1zP0dBgp7ioTO/lNvTXhxvYgwU/h+Ml3opFe42IoQUXZP+/J724I8QtpbKRxnmuWEbL19T6zakXjKCrSXwyGzl9n4ALpqRfaUixqwKkm+yVqRHkO55yMwfVKn6hyefRl7zq5a1pc6yv8V4EV/mKlqdU0azczu3biF5NzMqqBhfsPK68ABWZCwVqrKPL/5YSEfosO6sydsnYyHCmmxq94yC52/MARyNcD/qE8IDxHSRK9mFmg2GD0k8hR9m0s7fjFfL+H5bzUn/8QvCWxh1/V+1bPBwxbW4HqnIalwUcPR7Ph6lFlAGNBG3fRxDJk1pHwt00m7v2FjY0ZD3uJnwvzGWNEfEHuTJtv2MmNjj9LDTA6QptIjzygrtfFXs421V+mmtleBrtrGo5v+kJiJ672XyFFCabqhRWAsXGLqB3pD13lB4M823HA2ZGrbyy1UsntFOLve99ccihIegLcUMzFij72QqTQDvcaH8zzXwoTitnHsfIbT0cMObEKl9Cl/+w3RyQ2G8DAq0NnvgfymdYTFluGq8nKJGqO7cpu+QV0coLE2c77JvmCzoF54GGVqhsigr0wc4HxFLsamuCcQJjJqNw1CzDxXzTFsoouhGoRNfuNE3CXOmbqnA9P5I0Bm42t54fG1D+YJEj0cgEI/bhodhkTHqVXREOnBF1eeV/J/HTdgQvTgKOnBNz9F7GBEDGpx6sFB0jZubR5D4qzAQFHCGaosAgSFb9WaMo2EkjAcrYEYsCSzoRz2fymXEZ9EGkBTzio4Yp2YmUMOwfCWkYls7yrENZGhKUxqDXOTzXxnSVEQE4nyXNlUwY1E9lQMyRFtOlN3JSX+rDAyDdloICMQVSnzM16snZAJh2Ptprd0H6YflpAWM5l9NO3iyHkDN5Zj7c0dFu3bSO6LyIRiNTHAzN+vO+BirWDx4GhBXbf45VUpyTD69PIpXEzaKy9lv8hqwnoe/ELKO1Zhgr5zEcGfTlMvcsxNUTxqhgyf5CN4/01cPM/wDrrfb638rwu3aACGGWzCwehN4c4OFwL14mbdzjqqjVdqGC7jY2O8xCmyu0UGry+uzFs1xnQZf5YCQOuCFaDYCLR6rlgTG3HI/6RuLW9QJAv9tLnVjJcldfigjbV7YjTfeIoOFpKf19M2aWnnuCRxry5m29ICz3WzxJv8I8rdg5ZNe1dYqMsa1SERofeQMO27yg8j7uCwo7rw8nJKVNDUiUy3N8tS28hnzxVng08N81k4ZfHtXOFzgrAb6PADtpHaYr21NLVUow9ZVW5r01J2BkYkkIT8WIvY9SMNJ2R+jiKOSEthQNoI5WaeLkhjDY+F65qbxK5j1prKRzum9rFk9823Yqp9pjI03Tha8nszW1GOxj7fKgHKFYTMoB/UFeOORQw/zQzcKu78ykJghqhvMEhJ0vusigMwcen7QPQuBpEgX5oi5YHhLsWeMWfFfv7pYnSHMlHn22Ss5EXh3ftCdAd1IiM0qOVRVQHRva+i+ZC04Rx3mzyafYLpZy11C4vEC7lQwt5yYmS8YjJresnvTnADvY86FIdN4N4JkjlF0ipFNrIm61aT1F5j9MRVbygNyILnuCZxzkQoSdU+NMDMxrML8ePuKULQqKMP74XudQSGX2lsSHziqlTBEHCUoDrmE25toToSwhTau+EU3LM61hmJt9nx4+dvjoVNTsfh9/mp/Huxi9n1vkSSmkM6iwZxA1+LcJq+FwhRa6YTpFqWE/upAL561VAF7R+iUJO5GyobP/ZPeEAbeRWQqNkS1DuZmcFDO1pl/Hj0vqbiyQnjVHZyK5Z5jurUk+UGWQoBPUvryD9rUqo7z4pk4Tj0CWZx1ucYaygKUKtLUM3U8FiLmax2QEjLFNXnOitiJ0YtszTFmiubY7Sf2jnieSU0WSOBTa3A+JJmo0VvGdIMgbNmisZ0XUYG2ChpAUIdB5ZJtwkBUnw/UFoI0jJ3gdXA0fBINgIX61kGHhDLA9Zu0S2awbkvgnam5nSbVqS1XhOAVpwzhvKfdgQGNoruEAX7Ja9wW/9kVuad+4NTNU2cZLF2013sRDB75o/5/Wf2MJtgjZuu1nED9gzrGtfefTzsdzzLbANq86nJ2z8hNO+T0fLoZgzq+GQS3/3Qdc+hyouuwP4kaL1gbij/PgZIdoW8mb0sDiUbxRwkNBnPx4LSIKpnqytjNPslUNUfEOSzcd3L8Y2YMTtfVzvTkfWqKg2YR5qVWdma0+dVbpsZgU/pVOPgiNJEDWJS4EjvNV/apiC2YHhZfvGmK+ndyFxLRofEm7F8gmkuyFTv+cgEVUqGzaRsSsPY3rN0p4aU31PbVl6PIzR+qkmjbyICC7s/iasZ7PMpgu7lKEiWyfcc6qmP8JPpwmQ3liSiRde4CkqQnfcWDkUUEcowlMu1UBbj/e62LVrGPbVE+sADjqM2CyiyCNgsHjl9TMu/AJGpkl1kDDpTp6F3qDwBiqbaZofsQs34yDZFRLtQ6wRNTcTqC5w+IuoaBlpQ4I6820eX5Ax9+NlT6ylSLRfV99HaJZjO+n7DgGaxVZRSLdjidlkisfk1JD/Xs+OpbmzKSPRQwUKJsvpqFZUMEIRG9PzXitu1RlgCP+XJ+M/GhuPGHi59GpG2uPxrGXHDB5j1DsaG9CxhS609lyDpasjb6nad+cxlZMhWJC/x87pfC1Ha872M+Skev3IUi0T4H2qOJCrkYM/K72ioDX2dDDx/jDmoJBUp+6BcKNKR65JhkWB2xObEqJbnBvZmoWFmq9BkOZ+NLZrulilpevpc/iheeJaBa5qyygpl9culQm+VhotLJUfpKUaxMpssG8ocPWRYthYIdMgtrLFXHCFZW8hadQVhtd29DvyK1nHWs5/1m9kVrZeiVOkBDefRHhfZ9LEqgII0osSVfUL8OInq1NQQoJ5sO+7UgSZnBaaHXOAbLBYRfA0I8IcH/U3jw9IfScO9ORWU81kYlV0xynUM1VBwxvKjoeXOaXWmSMcxdIkbWcX121yREMNRv924MSqeX2ePuFx5HWIUV32kn2rvf8QMRWH8gI+9qNACivgCbvdxa63mYaFEIs9WFaMtPBZn4AtrbXWdGyQixD7eUucBQeZ11Z3EU6U5BJWDhMizRPZo5fMnKYPr7Tqt9q8aoj+oVPzfmUTIvRg1jiOQXyggTYUAj+eK/IJ0L1SPnJrIklrELDvsYFyDIqSSN7vYCNlNSTDsW5Ery7ooomLbnz/BSsRASBJSVuMi56nw/VoC9SZfotcR0NrByzXZY75GU6BVJMMdyi71OtgGBBBB+t1Bb/hNHfLHlk7xGaEhZ90UeH8uBtFCqbTlWzcblBMgLwspy4sntHPEpoHINSoVokKauYV0IwiseZh0Ayq5qMt9bFuBIU9fbSkCwsv+F/uLdMxDCXSQV+jVl7iWWhIHF8cZ/EwiY/hN23hvMYMBns16JuFvLkH6j6DREflr7dzhgDQgz3FdK/96z9YcJXNAqbgzi6+r61nQZGesNSxyR6Ie9s4QuH3BIqZNysznhKkjT4YFJXIrhUfg0sLHPKkSfZy0X8zvZ9UcOPyRSOQBFbq1jFV75edTv4fc4BYBwlASCG8cmbgJM4yZp+WC3s4d/ZhqKzx0qeAO5ILecCJ0Vr1kY1njgZa3y+0b/zRa6djA3wmxstGqLl+0/ceT9XycysivPxFydwtZ677GxRMDD0yY6L1rwtywKH/WKZHgZftksK/1WbnpvZDDP8LyllATG5pA8NVOei65/fBqZGvzI0d87zsxZ1MdWl2/dnSXFqTKYMGdma9WETG8c9Fbasz90RjtAmkDsuB0ADuETJAwtD/MeKj7ZbZ03cd+hxWfbJY23ZCcjf6snS0QhB9MR02QUgBup6yN0xHDlFGhGhpXnu8irAVGCiSRGZ4FP3vd/3Op47Tc2tPBqAqIW0q6WwSQ2eOZUlKJxCsxnJAWVucU5MgI00U2i7oB+NkO9nqF2D9Ik+C1DyJKGEHppcVKa3x6BS8TY4GXl3paquTm92CmmK0+zJu9GP0J9rJisj1Z73R7d/MT0j0iXbNMOlSvqR+UQ7shdJMnDKwh61jZyXZmQI0WviDXnPOjfE92p4xynEnvNVBrIBJE7S2vTA/sK4o5fHXzvtCOkWc32dMOhosNLVWndQNLPM+Mtd31qvH15kaRp/jIl1XDB1QBdSRvchYNMP3cyYXiHPzKa5zQOmwP8J1xaZLC3+AJj3Yo/A5wx3FxIzQ6xntnLOk2n5MAyy2yEFzEMsysym9X33DgLFhlhjhY0hL2oCVmyV/esDD4IeBLnz/79qWg+uGy9RlleYE7IA4bXgsdSbddEbMVyTQghGy7zGM7Tt0TnNw2nJNYmdv+7eR0JwZR8H2s5XDPxpE9eloVWGc22mTahszAJ9obNrRm9kon0re9WgFbkTHreEcRIGBVsN6ufT/srdohCIPdvPT7Us8KpjjWY0L40YarlCgm6cMKf4YPGo+qc5hy0Lzc+S5M0NaZc8xMu1d9c2seWlkY9JJRKth0njkPcaSxkCntfwaF5vZ0LZ+Y4QnvcP36L2Q/9R4/S6pjRFnHquoeUsVC09t1PAx5+8/+hoWYwrKAgWscKwBbjAa+9sbFuGcGPiwEYLRrj7Q77DoHx170cqt2A8xHbrH/Gndm0p+zF+bCMqlBLI8DVKT+ACm9risk2rZmogVJ73pgbB1iUDpZpLAHLFRO8KetoaN1rektSUagSCCd70qfft8LNRwrI9e9mg+hZaq5ASYWlZD+xRlyxg7gIv6XPBRpwtEiRsPGRaKneZJBf3h4XwSGdWxQ4flgpfVY9dbjumDlcvezkWhek1jTQFGydQvBUJLbRNzkgnRkXrJXvgxzS/vjWbvl7fevf4bZn2J6pqWFBWfKP8pKWR41jA2FVzszIoMw4BxvbFDY6hZbE/bu7W8LMUkL/bYZNJzKGo31Env4qFYCU9YtFI56xFwnEywp0jK5JZBmsqUJ7an9JG0LJ1w/tMMOEG2RL5gd+DFWx1wGTW15pJaxA0WrkYrJ0PXo+Vl4BIB5AVojd86TylIuDT5itIDdN7BGFQ7Ho9wKLNOHT76tlU/ZZuzJhgGsHhj7cuPKiJwtdz21qKpDyivo78hjFd9WsJK/g0hGCMGhl5T2KJTYu56Y0wlqoP54FgOJ5g9e8Q9/4k6ujIriesFULVli/HX+4f2skoT/K7oKxO+J17AdHm6albrxQo9Z5G6NOENf38PqtjYCUoBezLjFbgxR4u4Qp+7dViL56fa9+v+uZiACA4IwwoCVl1U6JVC6kEu7EIC3FgIOBCzHcEXfFE0M53lPHtKolEhnY1LX/TN8qFlLjLUwrXYhthrbgTHh6np/pcwjTWyaFzAVoi76dgUsTqMlO5p7FVkugdGUSF+MHIuERpReb2Bf6Mn5fAH0/VfeCKR0d2fBy5KZg06ee1OitmFbnxMTaHC2qyYA/fAmsXREl53wTspxCtwGO90SujkNwYH1zCVQtzGJ8NKhlBvxdWTRbiEQHWH6zFifkdR4bXTUCJMXTWkn9gZivqHlvCAPaXDwFv7qMC5RpzAB1/lz5+/JHjVdCmoKYnA5kq33662cw7bCMuNDXfXW2kLtWWGVWcUJffhcpXQVlmEMi7XOBaazW3AeoxcWWc1IJsTKngX7AwWSuF6CErRUJTb124D1eTEjU7zmmtTCQx16DQnydnqiPX29N4AeH6ytPcjmTKa0WB4jRAwIMaW8SowsyqezAgllSXAcZJRwz8CtZP1bc6kB6QGYEjGibVW+h+5B1JifaQho4ASvmwwE9rQpyjqiqVlwxZ810ayoJQeIFOINjL1M0sP67VkNsVAKGhyCvJBl53TdI/5txJL5rkSrn73wzVN1s31Nm7zGNjVVp32wxwl0hRgELRJs15lPywQ/rxUvxNQrj7RNMLW76aPK4UA8DiWu9a/43kkmxudZSorAJ6SMXkVCmmT+JD+3fLJv88LhNKF6S1ndH08as4YTO6TVcxp5CcAnrMpFREPcOFj3IOQD6BBHBjWOcdI9M1aRkb7dbkwwtav3zgZj+IYiDYpA4Sc4zJQ6AU95chHulw2+oZWCtSrTSHMjehBkPd70WZUHjSDV4hRhK/8Cb0zD2kK5HiWD6C7zfzyPYqODJCnFcaQZoWVCQhQGmaU4JVA1AV3YNG040RLanqRjMJYUyp4TYe44UIInnG0WVug7Q5KiTwP7mmLNzAjzZ30am1PPAjULFTQA0DiPMremXsqUo7zYKNMqD6xcGpyZTvkUFeYKAvrp2SSJhxjfXo9kKaCphIzvDWiEyqVi7r/PF0XechTtWkAqyzL8xtUU9RPklC22HK2A4u4SmpP1ULuQorAEyOLKvMVzkUf1jmVH0tHGvrz0z9WoBskUgyaB2uCgMcA+GSyUwtEfN8Q36WS063Uo9HWxiTQmMIr2pmvBIHEL9KMOb7CmA/1BJ113ovFFILGnQzHPuLRqIVhmHc/Tqi+p6YzquCJGdZJj/Uyz44NTvCwUDdfoPqxlUt4GyPm8Bo4X4c9HxsZYK1hd6uBaR24zb7omEUJ2/a6Ia5oApaHjsI/0OCXD3ObTJF9CS/9iZXaeL2g4+eftofdeIWQ50sFeq8YEgudTDBL7YrfXwq8xFrmAZIT0Ituw4IgmbhN/TkGtschMgs606Jfvq1f5/SFROy4AllQ1PpvtW6coBbfEyWOBka7UYCX4I5CNmoMo/rC33B0628Ld2RVJFk6PXnBRn1TxywihXluTXppiLqx3nBKVdMZzO1ReciUi0GigPMHP+P8SGTtOb8xV5qXIpFC7yCtW4jcqZj3cvau9fNKX5bEhWXWmF6809HqvCw1109diRZJMkJDcJWvoDjrHUUWGFP96wSeJyxjZh1uJj5eXjYhD4CGtmLrRDl1cxJM3Osd42kmc9pX1A4uTsg2kK5z6zBPtUgtoU9IR2xKy49EZI8dUsL/ClgFC3Ir3QxcTXd8cai1bjBpEm8OiUVR9Tokhz+JyMud1k4YGRtnm8PVMPgJUHMHbsHEWZkz2Mb+Q4kmYl+9JeysNDH1Rdw6zA0K5b2j2rD53aftoTkKQ4A9g38tEvMI36XUhu27oMNSGi1zXr9vOgFLpotvHfWQEs71qoxYxcAicHqPGNx31MaKb+qXf6Eh+TboQjDpGqPK4ROSf35CtVHNBlqofL/uxPSVrNCjf0d1mWYtEziRRzllgFYmhhQnur4YcdOyuq2gswdqtp66dd+9HVQevhjVZMDaui8ZCTaY1NHtaQTKWh2bwZXkpOS5Hgopl4if2V09KsNJx5ia6d4lFkSuh4KHkOCbQ6+RrTrAAUwzZe1u3ZZs8tAiBi9DJk7LEEOKwhS1rI9YjpFvfK+Qu2Q8BI+diFI1q4pG9MiWTDopNkRHCUsHkbHBoKbXa+o4wrIEDxWLPxgTOspQyN5pYrhjOCUIBP5oHN7L2gZTpappn5uo21ant3wcZG+riuh+NU2bsUrV16d7eXF0SRetk6xTZYWaB3deqGISSoQRGLpAol5eRtOjh5FsjGw9setIx9xNnJxaMdM6nn019GyNvl9pq4xN4oTYTuXwfGBR+eYhEBHwdmX6U8v2mmtvDGXpqEwFZrtmBNSNqoWCQoTUXgyK80xDDyEqA+0w5cdOnaB+dqwV2wJXp6PTci3UeR+0i6PDKdRTBzoyVUY7i4oFdM5aernny0GLb0jjGdBgDoxBED9PlezK1wLh+DBk7wWI6AtsgEqGKW7IE6WcOCUS5QyX4JSA8JyDKN0/CkqpJsPjKVmHP2DMQrtZxik4zKfGVbbBYFYPE+NukEV+gPaSgGp8T6mlnUvhJFFR7Y3220ejKnhc/szq/soPrAjmwLmfb67i+ijznhKeaF9pZVZSR8lqLVXvFO/HtaZeY17dcXJFT2DwFXoFWEuNK0CJynBaNvXZAlobjM0qsGDht9QQN1JZGcxHXo067rshSY+84gzJUS8eid1xAFNL1KUY5b1H8Gu/KwhPCgQechNhBpUFnIlY+iCtPBw7FFkR3wX7UWqPGSNhfUvBxr0l/O4V3+/18jxXFfrrbynHfw3Fgr+nW4Isdd0lOD8ut+bz9PUUEURjyIrk1DLm5m8VnBZ9rUjbk+CmmAiNeWV2MrRlXvnNROcPI/E55w/iAcgkYDkMWzgWhywmG1Yu1ZwpOgSm9Ro39UY7PJYhKHgPikQmkLMp53iIurkZ0bkLNFwuPsFIQ7rnQdo5cNPdJdTXdnrcF0Xs0LI2cRVLufSEMdTNhtCH1jr5fRIp9O9xttbaEhkN2EhO1SGKZdj/oHEj07tV4xCE9Nq058AEXDIy+hjgW6VwSi/aPKNnWpHIERU6IA69pgEtWx4ZKoqJWQH3E6wBV4InyP7NOHvrTo67xqcL+Gjc8XLf96qSmRESHm+thLSVFATgiAHUsoy89+0Z4O1gnjMvBdM/ycEKnjayiClOWuXv8a59DyAE5X6nszfithCHR9WyU5wdmT0yd8LEem/C4QecNOv6hfgJcvI6FsVdr3RI0TytOHVKFyYLsqqDY6VQrmjfiE/YXLNLYqqxjo56WWliABuFhiVVzFSSEosd0oSZBYbJ6G/vFvf2KhEDeWehWRVI8avMYjR2HECYyxrnbUv0TOoB56s+c8JmdKVaK/pz9KeTo8R5OFmXVdie1I5AxyUE312Ck4fXnPm4ExouwUcfMbJhfEefSzEUslZgIuPHfoGkNuIFXh1SVr/G9C8Zac0a7UEVj9vdkOENrOazdcet8H13gGkJF3Y4oGyqQ2QyIg0iMOt/cdYpLDGjAjFEH18PekLKJR/6AqeGCYPfGL+rKjjVlXCW+FA4AaEDApVZLfNkoKoKmMJ55MSKghByxAm0bWvSIZ/KAi6Xe2gR01ft2LoxLgkipLETml+yt7jEesvvKerkBd4K5xDblEcLFN0TeI8u6zbjT5RDOs24UjMVqr7wAvkkrCv5pBL1NaJ7zavJ8ueNthz80NKA/UXR6v5eXEePt5JM9zQ3EZN3/k+UNYcRw2gQ09GHV9LbDfJqEFxFkNAPfwhc0QlefrPqmei1a94j5owd0HgDhWPvDSDmHRzRmWiO+Oo4aV2ENrtIm8oS9uJVq9+NsPtwwj1hrmrcHASj/daoIsHByinthQYTY9OqeF7rF3TmD5Lk3noC2f1P/CC2ZfablMvT4NuMupo8ePfNPNcSVkP1aMo2TUVb32tfUT1YHJuFpKwx0Se7tHtkrl/cozyOBdyyvnx//XnjVpkbx0XtquHXar/KgwmgMOSRxseJGOVrO0l8JxBqRvdeB2g+e1KwR69B9vk0uSEvbKF11egKoVo6/cQ6u8abgkbBOw7APGGSmXAfibU2VzIUPa7vj2XT0Q/s+bhRWQN32WxqOfx/fUQ0Tta6Hu0Af/6Z11Tecu95Bpw0EtSnKnRpAIq3Bq85jKaqv2nmtJLg+rQhOzR3Pfk4GapwhTQjVAVg6HZL3uvJCaBz9vD6jAL2FvWqr3XoiZuKzkerb9ZNh3CqC9U3KsyfTKCKSknqzSJwJ+aijnsosxoWqVdc1GluO7edZY9RsY2sH7Q/OFxyrdvKFJHkW2uaCxAaRquEwfCuRCkP9IYgGjzhQcm1KhzpCt/Rqrdk90AGIu7tkB6tmU4QV23/avQpuNPdQnVZJGUihcI/G6wFLK4/qTrH3y05Swfk+mgjsPFQRMkK81Mc2zph2AW0p6HMA1q0fgRxwHhSrwomPk+pkm7oMH1znhnKvfRn+xKQcPchlz61eFS7tTG5Hmh3NN736/JZNnubWMhTI5vXx3S15bmIkd3ihxmbp6+q7URk39J3/+sOJcjQFJkfyPRaXMyqyaEWA0Uu3Hj9S0UhdKWXBkEEWTZHK6TAp3G8OBXtU+y8cKNLPqCoQh1h3VzFygdQY50psGzL17FHTg1TMzPsIQeHQMoF7Pp3y7Af26AX9PTrybLOT98lOrpM/SZK5jjfUjLANE6nJBQ5etuHu+XJY5lSMopolK7CAMCO7YNwLP0y+VsWqo+Gy3SWWX11sGO72uhqF3jDLIF2BFrTndiL66A+18vXo29Bs1p63wyJ59biV7dwqSd7Dt7O93dcvqqVc0OQk/MHurMW6duiNQF5eTMJ+c4bHVE+xuuGT0yEumpiu43nV8zo2zxefXDv8Yc66Au2D/rC///PcW4aLXociZmjDq2zk7fTi6/Isl2Gv9UjHWazoueCX40+b7sgyUtUSaTK2YzGxk6shdohMPXAlbyd1ke23+6oSeyAvm4jLLXZG33kpnJDma7AU2Cow0rPeLL19Pu/WFnCQ8juec9/FUpXacdzKcZvxFTYDh3PHndt5662TX10Qj1s6y5M6XNEom4wu1nruEI7IImPuHQssz0gHp9mBZ1QyTAudLR3gzy12txBzgqnG8riqHc4mYvDdm+LSShReMDIUNQmqYAWuuWT2flL91BFGFtEUtGAevyTqNCNUHL2sFoXRSc8yZ4YJQQ5O2F6UgmSht102KHXJwhUalyYtNxnngm6+WjVHn+gAllIOFyhABcjhYSoqNF74IPajmN61vzFJgjl1z60NncMEeAwz31PRhGpjt7tE8ZExZ82wys44t0SIz9hIahbhqr4reh41kHfCRpFaZQ6mRA8VFP9KqO71i1bKsjWPj3XD+zeVylSryrOm0mBj64NTAI+kdQJoHFpzaaoC4A+ezNg+9uALL/zNa2HeSwe9EJP9UBI+9TPsG/HU0+Mgt1DHIxY5Aog21q1yQxzLnls/peDk1H19vJyFfb9nYZJkOwTosu/24rh8KepMgKQ/Pi1aEyMGA987OouSGj2vkZN82g5sV+VAIUXaprlEY2SAqG0zuBwQ4m5leZc46wvD6C8Ipg/LG+8hF++Iqvlce+TQlAYk/PRV6qWTe59elBKTwgUW3Q2SRzE/u+nu5BzFi1N+GRd/he5PAXCLjVktEvlzv9dUpQfp8bD55iL6hztSAhQkQH22+ETW6R06e+jrZ603hxAC45qp2hsHOncwwEk22LZDSS7JkqStT7JeQ+lZaQnpZ5CdtbECS/phhSZPCfRm8CMGxVJ4H1gPaPxnCWH1y+7oLwutBaQotFiQkekQi+cKf5Vhz6cbcHYN5nxIwHbStX+rM6jyQQgOuOQ4uW4nZQGKlo87eUWcFxA7m+4H8VwtUSqQWshQCDXgELQZdXo0I6O2xSdfc9tbxyUYD8sNWNlmJVoc9iBSBf3Xq57y4jN5vyrPsWh39QsXhBsVH63GHMrOJOtsk1lvmIvncZR4wPdf+OTwt0laDU7FYeWDO0LYlfpH5UYj9jM0l7SH1Qor17bGz5oz3CAjhpfvoV9O71X0WGYLDJxp7763BppnBdJeiLQn6ZPAqFSdp1YDZW93csd8C32gomtU8aD2K1ewE92I8UT0osItVyFgPELtYp+BnaqeJkd0CtF5Dqs/07gK6mXynHDtPVtZ7cZm6O1gDpCXgJHsIRrb3GJ2woPRObAhJko7pPRfJOszyWVPGsjM9WCsliXjNCT2IY5Y4op1nzywIa7fQjw5gR4tvWTkc3dnLH2Mk/ROsJrOLRApW/meHHxQM4CRkC5QhDZcb8ljmtgxk4TFQh6vr6i4AF4nvnYqFL/6Jy978J5/HuYTIEeCygPM5qyNUsmUGBRxn+PldokubiM9D4JMi7j0Qj7UpHNQQbGgRi3Dm6nGf99V9P55uq8VhA/Qh+BQTPUPjGxCPHMLXrEgr3cHBUuD/vQhMJdmHburfqFWdftqxpb0l5oiu67aNsiI95PKAhDpdzlZ9yLzBO5mHOOMj8wJJccfkj76bH0yjwErWuD9de5AOaXjH89B7MRyn99qBeptR1deMyLNq9I15fXVc4mCvWnjJ//xFMVHwx0f5EdyD2rDno4Bk/CZIhvS13LKeS2OohitOBLsFj+z9tp+Q337Fr2A0gRit+bmfnnszKZsE/OeKTf5XmBM/cbsuoaOPL8y367wQfXXMzSFDuBFEYB6Cq55vXCgtu9x/YDSN+161bquiIogMvcJDpbf/c7uq/SviytWxiTyD8hLStMuUZAOnsnYxp4dQqh+8bsQvH+8MFBw3IkvCenlaUzh2IcXR75lZAly8F01DOiXaFIcN/338J2Yyc6oO1wrzxY6VsiDDC0GG2fvjBIr5G5mQGzEs8upxmoLrHFHnNvKYP8xtX7ctM/l1JM+zkBVkcRM+OkyrokMHQFFd121DTzDGmdkaWPZ0KZ2H1PVCjahFnYes5u3J7+AR/dyeok1oXM5GCBCf1lKRA5nKBKLppifG4JlEhh/m23kVFwxbj5SiQyRr1df5hFbuZjzpEDz8Q88+kG1Pyutv6yOUJ9MIeck+IriO2oKr97j2dGGENkFMOOCK54vMl3g5YeH0fw6vqZNccVK3RDrG6fhwRz0VGp3sMJT2vOmQjelkVt5fAz7cAo6H/Y1tQNfys9fyQjiQhFiRj1PZxdKHo1SoYUXXQ8pgK6wOdjc9cIfXsQFE2+id6/LWGfhSaFZriSSguzpKQzxL8ibBXy7qMym9P5wkJDDhTVXXjmvjqqm6+pfzl9oddHQaJyeiwdNFbm6SoKnSS9me4sOc+fjCpg1ZTtbepVGMzUBRwDjkI2ht6pq7bTIn6Zv2iGhZ6taQ9F5F5kSgXBGmnrExvBSlrpDkRrR2sJOqei7njAHFprzD1zxnx+O3y4G3t049fm9BIWfdEgbZYEiUJU1QEgTWy1UOdH/qZoqUOPW+gzfYU2AKc1G+ljx4DU8nA9iEedvrXpCyKnOZnAS2W9pgTIPfGczRy/46urmkNLU7ZkVRy3XSvgsyDW7rF0GEtFwVNsETGrONxsuJI8ccFu/5UHO+if1N43cv86tI5thcYz+eHbtlXRi7URkgBWPriP6HFXfSTtZltbNFxIui/utWuAEqtSkRon97bcPk/CCjl7seBdFavdch1c5iXk/VV/udev0SYVWWwCH4tagseYSChWsY6FxWrruICj4gj7N3vPVo+QHOXhqJkFzNaX7K+h1L6ReIWljWeSOGQ/euPpl8yBUT9vwCp+AtKbIl+BDAFfS08KWQ6/TiqFxv50n94pmsE6VeMCteY2SS9GOP1r0MbYB64Px9iYsvUf16T36m5HpjImaDpPyyvHEecDiiF1QW7Kw5mnimF85PWhQlZOBemRx+Tq1b5ACR62jvx1/Icz4xPPrrhe+35BjBIH6i49mlK4jfyUcKO3171c51F2qd+PBQEBY9ln3TrB2WGd8+9yaWw6ZOOkN3terVa+IqO4FivARalc3dPqsMtNDQlaoiAAg4OD3X0g0Sw8K22nU3WP7R30DMtYBpTmXbln2PbS0UtzbriD1fDVvUxJ1X+1oCkOCD8dx6dZ90mv2PvCNmSEa5U3kuhhLOF4nd7OKETtkUTu8pFy2A0BBKP5Km1HKww06buHvpNxVeuX5WpZDlCF8aM280E0lyVoaL/U1jZGn3qptAiF2jQmpsN94xZJvz2uDo+b7S69n9ewohqTWjMw/nA5+jmu/iA/i7g8rY9z9m21OzqL82V5pC+cHR0so184VNmnY4hhrl4fCTxwcwMWK/szbtVghBW0+XedOb0mb+nQ6+FTtjXaL+q31k6Imds40JO1RNB09jwAKPhEazRGXLoMCdZKMJTbjfPCJFolotrCKEbxH8yYaPKw4UGrvwtt7NUBjsJ+L3n88ZlA0k/sSpggBPzpoJPVf3SHmQR+GmMNRoCqc6wBKwCReI/wGi5EI+nJsH4WjcBAVPAc/mGjzeQVFg90V6OiYMNQa2kWcPIqyK3QG59YoooOIOLq8Hbomf5g77IroFNQy6NiBn4XpOgj6D/JRD0GWyOftxozQkpt7HSapHvw4AHEYRAZ6zzIuZbevDoWVcVKpW8crFHYfwlgZhetxSl+8kYhFfcQPfhsZ8U6xEzbfGBZrU1fRyFytEwErVNj+aVasTfQ5q4jObjKFpEXdNeENxFiRoa/dyEuD2slxRL97pTQspezPuhocTylyiM4dEaRcROuIsJ9a+mJoUDpgO7qsBzPrCvQmyxWt8iHf4IuCGwi19IboofaaxAF+H/B5zTFsya3KwkrCkOUowFQW6GhuUnOntgBgeVt4XRLSztQOXl1srh43AhvHv93pQ2WzJRKPhBJPVU+pY/ztppvVDwoSdi9gx4OMqJPOgEDFqM10jjDF2lsavCWq/vvG0UFHWprr+tQ5vc54APastNODj+5nAFoUYa/g1jhdUSDZopy5csQtshsJ4d+Ad2L4fjPCR/VvElDqld4guhngoeYR6Y48jLEUfw8Yy/3L2WOBzhaXoy0UsQ+XYAvsy8HokMrU5ypM4b47H19px0LsGpRGF/UKqjMRUc042M8BADfh2jBuiyDx5z62aLwDtbQHkycubPF/S0Ta9ryi0Vsy3ghE31uC4bT9tC82GVGwpBXjcOEQoZkzFB5kQoHY7PLiamo7IeZhKqghhlu4IGtMyiUc2lkmU+C5HWJChlrq/C3qBxRkTtOEwIxuR5B8k4Z0/e4BAz+7FVi3KpziFRSt2b2FhVcq2oMwYaj1VJusq1YIcVtojZn+xlybLkZSh8dJqJ0Xw5rcrVeVTIDGS6QruD6Wo/pgkz63XROj6NQP5HHZDiBHmSYdeKjTX6g6ICRaUzmOIeSMrUzOPpqgTjZg71VA3iUpjTYGYk9ymzGKi4ky7n46T05vcgHx4gVvGik0j3Sm4DJ5kZ8ymm9g3Lja/KyqfKYxUIF32uKwW/ShCrNnyDfQjqEtavZ1JhQAk2lSSUHQLWaspV+LAPBJnN7A/oYLHN5eExOP38Qs5RbAJH42YUt3hOiWRzG1sNfD8619/HcwSnqwaIxw0fgrsgemsPKw8Swyiz9nFpf4YiGYqZFMg3SZXk5v/raWUbpCNo9AwL7VadyL0zpEbm+2r6pypy1jFxF1PlbT+6L8dEm7+MAmiyBjOyfmajPtsomM5y0QPjxucsiFTex783pBF6jiB7qo3BiqeaxtA6zmbM/GjJA1PGrKqlISLmNKTvpoce4xzmVoyPZ2qtUucj3qMtFMw0wG9rIXU9k0ZMuzcVwkoe7EyN1Y3AORO6UpzXKENt219wX5MR1rAtzx+IovrMZF8ZmocPk9RoUnfOGrtUDZjykk8lrC6lJxvIsCTZgruvAE6kV8I+fXvDuW+UwbE+3HXBslgOko1OHV6eCBESyksGfjBVf+fD4KPvsr1pslx5QZy9NyDcNbvscPcqR8kqW8daFT+HMuR7MlorhyJrOPhDSQeBXIJiXW01rMOtBewHJA2B/7+PP2LhRHeLizfY833rr3DEtT/YNcpcyxCZo4VitJxL3ZbPqPnLMvdAhluqKZmvn8aWFFbxSRZjk3sEGTyd6IZzDVUWRTJ0vGPi79e3uCPDSoOcaAvYcNfIt+6EA/KuY+1dpRsIeMTsv55wWyvBJoPANXdwh+HPanHvSBfTOV16RNFNcxp+oIikV9gyaN9P6oHxBzOu2qCfkVp55ddaCjFkOlGZnRxGrZFWJItOP6PUgcd0XISCEW0yqrJSEVG2ZJO4eU+SeS7tZk2Ey89Qd+s28Op+UDj7xyR0YX5HDxi5d2/IEWWos9WIiRcBxtaLHtjDdOUGPNfRC3li26oD63ThrIUTycisds0USKd2qaW1FF9tKm6tD8jr/MiMornm0tmKVt8cFW1+Bcicb+16hW5HQ8PkAEgesqomeJzgUW/IdvD2QSk8Rr3NGZ/gvSbql3GFGXqV53z/DaIeetXvtzonT14n0D1oeYphvuqeGKR86xtLEmpQJMs4IOzpg2+LAJz++DYf8eUKx8PkGpDuFRk+Y8U8YGjsWLpVt5CrOdkEUriLMHaqN344wqYbg+npsbPvYlV7bDOHwgXtEGTqdjkIVPtZUZN+NwihT+MtZ71MPEtyTyRXshnZPR7BpJfckNuz/hkyqqkk+0DkbzuD4dQya4WI9rICC8G/EaCwGAGGkQKDxDrG502qRzL4iF4FmE3Yx6cFMBwADbciafNnjfrlHwzAzIrcEiVH2ennsht0vOaH6Asu8vAc1praKnn+1ROGD36WfrCA46v+BbO103RfrtDwSvrn9PxivKB7jhdEon2QIfvC76PCZmHnqmLPsvMQupHU6OnEuPA2v17PKUcXIPHKfcL7Tppb4KJA6LpLQG21Ndf+qdpvcNNq8xbXc+xTFDzNzO7sS27VfDK+7yoPCDRNgnsIhlCqd+DjWXLGQuJavNjmEx1x7hZAMzzF0fB3EIWJ6OcMF64YVxQalnEAagUSGXdUklGT1LoU2U+XGFhBOLesYU9rKCMNuGizB8fuQbU9pNpHhg5nprMheTNpPO9NppR/uoeKMGcMcGr3Yle/a7GVLFpvXqDWveKymmTDHcb7Q3iTGhsi9Z+4iFwOEetJdyyHWtbqrwughXtSFjTCmOdTS38zXN08HrHDPvr4W64w8tUxf5A8kCPoP4fmwgGDPe1wB/Ip//L1DRHvfbOP/IqaHYH1myBRhtzKG6aFzhQAOtZ50NTjNqUeHN9zRfCHUxSPElFB3MwssWgF3m398Txj/oXjhtcthFsE4fgcXFslmINgogbVFOZ6X3ZDVsWGosuzFslupOBrrlY0VpaBGO5Bd0uBYagqYRbSYgcNqJZ0UwcNtJoI3FZlrqWHrdIT9BEYCMqRo8HCDvSQmfhyyHoPzNdslkkETOMB/nTLKWYqG1PA/GXxajmIDRWFJqS1z36YhARjOBZrweip+1mG2MI3W12NGs8IrU1spWmjmYh/02e2DBjiweUC63hYukcaoSF/g2FcUzKTFtm23wuJQnCFPbS0us/crVvVrsGKP5LLhcSQ8I0JEDGKLkWwUB7OY6w8yMIAzDuP1aNZ2YJI8bUZRwpHUg04owTlcHJTMAjtktRBj8PWj/PARyn+zYnuNtIrwoUuB04wJsYPckEZVO1bNiJYhOrFUXkmbsoLzW22fyWd4dkH0pY+FtDQk2U/ep27SF1gcERmfcuSzP3oUJ2FsWAVQDPKR1m64sczUaVoZmhFzpkqR1JMIrlI+tCdG+TYCVxsEb15Xwkxb3qjdH3c9NVy/Sypkh54AmOZi6KWuPw/GmI1g2aEQN4jOl6yaRpwYrPdty1J3YF0F3cQ+EV3ksVecoPmP1tvk4A9hjDXbprfje6KxSt4yntRxW9mDbWDOLP1anTvySP3zio2nlYWSOjGeaK2LKHD5ac8B9NLwTljuspyQxXOr63BdJ9vBr2GB+4zg7QBpmgtE7TY3EzRMs466a7EG0/Tf9kNZCs7jadH3h6BJ7YtLvDLvvKu2rKWFKIId0RG1MkEIVSkGnG2g0zZth4V2JOnTEvpqbLXO6Ryjt+0PRAA1CQbFoz6o6phuIHLQNUxPotakMYUBPP3Ammv+ADrVyqGm0D+mKWKOxChNtS6Wj6pRM+I1GFC2ZSJt5C1vkcG0OH/kU0+9l0GWVcu1moDrICorqsMQ5RPS/BxIWLTjOxEHzrD2quc/hHarJUP0ioANlJpBWQVd8fyzRu4YRIcZA64XSpDu5SKWYPJjchfjFL9EZkEd2m3gwzerRTMBMQIozqnNiwmj6CSO1xEiQQ25XvUtZg3qkqRoO1FZOgAvVIi2d5OFwsX1uFw964MDpSupt1nHA6aBMoWhr30XqEAmNr3kRRWj4oO9pRVyDtWDym99aEMBK+4KbTjDXynUOErX6CmA/9JclbRJzqZDQ3Lr+yP5To91ayMgoB2zAOIHHcdrSmE08tEuwmIaijies9uWBUJ68l4LOO410UVea4cEw7P4LLMizUIs2LrwdL9AazsJKffx7UJvCWdAXrWeCmaD/VkSwbQrF3SwY9+IgFB16qCV/M3sCD+Tf5nH4Zvn2+cguVLAPIDYXW/+5/kRZZs19NsTNFcgtJfAwLjQ62+qA6KAtT3H7844Z2eMv9nskQvN2OAA3+vNPK+iDxzU5JzndyRyT1B3Yo0ucATRj+usaEjebqOnnqAiFSq0C4LVzLkWRu1TDuniPSH6J/R2PJFGlwazVBwmSj6ziMrN34ntFdFC1vDpoq9dfE7Z2+On6wdLYhvFtNLjalen0nsWekXQ2WD7drhtE3kHsHySp4andX3AsVzKnWnyaXy1MFhmfOn+OpniKUb5KOOii2uro0CAI0gPsnMp2M79t6OZP+ZxGiiB9bkMsS15/WQHb8CAWP0baVlaDWJgScb/x8GtxRMlszlcf82hnrCQdP5rGYZdD3IwyqcpH1S8RxQ/EMNzitIcVbYQ7q+2xuZ4OfcAXX2WecEq2DkHFxb+w3yz3PHr76Dg6sLaDe5vV8tsjz1Zvo1K4YVsnitrB+ZZdhT+cddxO5e43RxuF2yKTcp5Jag1/SxaxZpBylayH2vIP2iWKj7pNOGPa9SMW4lHAoVC9Y6Jz0J1Gg8z77MUbR6aogrJd6vqBfS3w+eYZ6+lwszcn9+Ubpnv85EItVPffwmaujisr/8KCFJ6q9h0GTFffiDtdkOUsbsAihdfwb+fAKsZjedOCGfnfUKW3gsGzx9xameq9EvVQrHSwEPe/Pn8hIr3nwTJx/tc0cB6gh5bOZIr7xzlBRdUYbIuEPSxuj8FkOduKVpXkvk/qaSljwVAzVnCneEKWOApAtf8qhPiciragGLm589qa4CPUMflUBQH8tJ9cZHPwhGWlRsppfTLAUP0bsoxf7dfM+dzIL0fCakP75+WmLQfshFBNtVFnzn6Yqs9Ob888VQ1sTxdX+tULOgIMdRc0IGOnzZQ/dcLgys9t7w72VnV15QKFl6Gho0bdFJ9VbrRuDfOTX5SzH5/XLgtkaHZI4WjpzsXaWcGtCN/9nvgfrcOGgFmrX+P0ljknv1MI26+fScl7rJ2+Puy7eFT5Ab1BT6PlHk45kHSgePpWpND15WAu3xLWHmDMDvn98SP3GcJw2PytLoAoaNvPFfPNPC8UeeozogOCrc4kr3o0AofzviCxkYTwG4bf9rwBYNtw/D4MtCS+XZ+tIf/By6AOZ6K0QOeSNqDlo+M8UMrmfrM6MWPXIeIDEmX/ASLSiPpy7pfLh/33vrgyk/WzgcX4lbOr6ggVw2RedQq/WSOhtr2/uNZxvmfDTcN07vl4RPTqP00J3Cxefevzo8UQcrqkeD8U9+SBjNF1unkKoIPQ1df+6oG6KrIeEVMRk7WKiBpE/Xk4tvhZdS1hm/mE1+EZ4d2ILpYj2BfiY7mzBfAARDtAtbXjXl1owfUYMpieK1QaYLMuw84S+Rlnpucx+/RwVmHZtQ05icNtjlc+xMSIg4vn9q6r8ss7OJBMv17ONa9fg+s5Kx/QthlUoToCLRNYLIgusXzTxQo5jBlY2REx1r4vuwaHWvxzKU2vmRubn2gFMc6YD5rWfOGyW4EZ3qZ1RPi7GqFwzL+/hQrg774zfLst9ow7l8mVb3dK7Bzy4gP7gplZTSSbWFeb8LhIrwDi92IiK+fI4VdwUNt9Yit94ryoll2xTY1whBC9suMrvl80ipWNV1eqv3kHpD3af7bt4P+u5YnSzFW8yzplmcbQXxA74SQHhD4aP+LoK/f2P2+5ys0XVEACk11dakx2DvbNiy539nZ2s3UozGo8B4F5sUdq/ulW6H5W5k+N1OhBq97PsGL99O4ka2tcBNc3TtGKNzspPw17vCV7BC4conGenUaleap4sXkYjSQdMT85Etk36NFdAmdHYiRTHwY59Mx4HNcp1P6YdKudLrhgUOsJI9tID8Mar/vtGcWtYj0+9/fQXHork3z3rGaTD54Yl5R2z4tgWOqCU/FiE4WoMbwXWGgJby0+sBWhymixYyIpImrHxav3figuGMaRYbBSH0ySJiTL7PWwHmPCUi0eFqp/QE8irVhfGo/g3xGxHOPXuE4HAsc6I+O+o27eRW0mHMLZLEzydg7VvIoujr5/2mXZW1AdNV5r6lrNQRIHJQBQizTkbQu1LABgZUXCeQ0QnRRE5HBHquIRq4uJgYXaEw3X+w0gIwvebv4ia3hoPz6E6kPt217VFAVAEXWI3ax24/HR9PplVnsHiM4z16k0Rl/LoWx6CJ0dNlAZQi2ESPe73J04/qlW0uDwY1YDArmhcjvzRbp+W4o0kXWukTFjpmtR+mb7/pmybzJrTUtwvMkZ90ltpXmBmgJJx6ze5cb3lC1v3qNZtTDq67RhIc5apWea8ZCW/tyeBhiMKlpu2c7l7zZZzqlwAVK9cSOalq3Y3toTWcg+rR57P5NK9pvOIQaAczZTEZkRfHHCCdNwnEJDlCEZlrbkVPbElBmoBB+AmpnlHkgatDwfkAiwM910zRTRzlJy4prAASW7sUI715VQflm97tapDgG0EG2xhwrCtj2q1GRNTNrdo/MTz9pKWXNsjA1j0e2RtE/QdcTnncC0kDwlp6VGGPICqWFImqNcCfG5pt4+DnWOaoGtyaiH9nqspUsuB0XybLnvYfu2usOSvowr1BuFODn1WNvuc5OnqkXawZJwbGN9ohR6VTUSmBlv3pA/SjliAeKmaPj9ZoypFdRFlEEyEtfgD92W0j9DLB7hbpUA3UjEOryPET2PVptO57hsJvu2G125DKHuHXsjyCylsvri7V0jfLPGr7To2RnU0l4pyA8QfkwST3joO6JUD+EhRbOWWlUgYeoWze5fyydiEEQMAOFrFZk1CnipPZevW93kKe3MSeIAE9SwVgs25tE+v9IY2HgVh8KqYYZZbAlPXaY4g3C6iQP9lOO0qaymk8JyVCzKdlbJKOrjt7VivtneQIjlPKAiugtWth9HkLh/EOoJo7sn2guIxEUoUogrFggDLgCBqEe8NWbGjSH3IpCqwx6gTgzEIH/UIThIplGthetpofZjtjNJGwDbDg51KJNGWDzj45k1CKnuo3wG7fjfAWyuJ/u2zFcx2FAwv+AFapyH1OymtDX/ZXPQZXjQ0otClWj7xsTyEFHGyQpRZJUWy6PiT+uv483jWnM9xEeOkdmArfs/xn1/9KhcVSlaYqzOxncWH8PkDXCTt2Nuty6AVMJshjeX6BBimk4uThET46+eU15r4xATzTUomswXCXE1iT3i7Vky49dOwVV/+SasIIS+L13enZ5hU9IjnFmZUr7yt1uJ76DsV0ihb/rAuiJ82rKg5d7gQOGMVzTw2Lxe/IulA2BbUG9XZrMsp79of+rdFqMlXwVMFe5joLc1bakpZzScc9ovrdIVFvkWoxr8LscqmSG7Vwif1VxVXyaNYapjKgHbRKco7eVpRr/ryg54mYSlE8XptXnHA9/CQw6OWxkKNXHIwI+Qggp22xcvrxfugW/1636vT0aE5hye/+PewQTNADjOIeOcezz+tps//SSaBapNez9I7vUABFQzA2wAYFTZBGmsB7StbYgz8BY/73j38GDtwykdN3dfTDAKTptA07idJ/dB6jY85AkRMx+ZbPB26QfMFC46Rcj0KZUg85p2lQ7YdajdoZoAJlwJtLAFRXO1sduCjk6PqegmjPu6JR123PofkjU5/XJorbnP8/26Tgo6IqP9xnbESCXXE/8MuUERJhquZHHsUJ3yDmlg9PdXKgLn3+Sh/imYcqxXj3yGAiunP+rQCoAUQqUitakeQYjhl6Vv1grZiC3IBQqunFdMFQ3+J3haXsNI2voVdC8EcDW2S2j2lurjNmIAJNsjfaYJ3YZCr+5UYeSEtIEuPhwn5EypG7mujqtyjsLazeTXJmS0UzLl4tpz5S/I4pQVGNZIhY4++qxvVrHmivu0TgQFfJnJ8fbTkXRzmuTCE7cEf72O8lDPaTns4IgVZ6SAqSzhOwuKcZbrsxW7LHsDX6PLZX8+9ljoRA5pRIyfzyIrSUVuUaElwGQoRNyXJZoWZxZVsK2wVdTJ/YnGrkfrI80Nh/iWudsCqYEzpEwpM0XBSxX+BeryCTnvjR6X2TpHdsmb5bdZK1VfWPr/Bf9ue+Ak5yQwZ5KUHIGdI2rCuVM42KObn36fkyVjjRMpKMmfHtMn4CB4nQW3HtzNK8q6smp6FQjPnW5Q4MCpkwHoHDsOJImfHgdoPtfXA64KuR4dUCU7Imw0TEfPv4J7oSiJ5YaoJeZPXr+LD9XgeqA+4+W7BOnjv0K2EI0Nw/f6PtZC6XoNMG4yn07J/+np64qex6b8KfTVNJELGYux6Uk61yn9ywcrn6txMf59PfRoMqCXnLmo7U5Uj5516/OcbzJsEUYFvE+Ua7u5iGzAEgWdT0cCJK7Axojw5tXBmDoUVg27Jfd5nAHhk+leLm84XgGzEyBS+o8JpjiwsaOFB8dhUwvkNEXFrX7XgqIIAbHJ/zWBL/hQaP1RsXbmut8f8rZ+2ALCWM2TnRLr0GGWdYCT4yni5jPwom9BF3dS50QyKk1NO/eup9lHy9MUuj6jue4tBPr42P1rY7TnyR65ZVOwf3u3L7tPzhiMNCkZN/dysS5OydqvqYJSjxq9nARUNGpftvMRIjKWGq9TZOOOwDQmymAcRueHegc2Qy5ur/eOGrnzS/nwl/cPDbJj+QEXw+rbDxlKD1PAQBYUoQMoOTSz+vnlUR0ps3Kq3yYzxgq/mFI8KG16dxNLW4ThAR1l2bhBEEyjXI6A0sV+lCdbfhpMpSIsdksLj08Ijb/klsZkW7jy+tZc3liKUm8uGYruSrdnQuYFT1h3zatJGwdKaLUWL/ZaGiy9qpy+sczubOdyOpEzmgdWbmOEmn+rjHrZ18XSZ4JBCWug4Ge4dDcV8Rl0jvm+ADy0zqJCs8laP8+DoK76C0S6AiNtJNrYrYfPA0l6sa0ZpkxqVWKPAWxQsEHfsiO7mN4FcUhacw1cd0vSnJM2iF4jKShV6W02mdo3NcwhVAmtBqwFNBCwYmrPMPmBAK/TmwAfR8Jhi4fMQVNad/0FSeH/IYws5eyJtMg7tKbxRGwdJ/30dj9MCGPTD+LgflalmAvQoj/SWNgGxwMEigsuGrWtGVG3I41nenC63ZuRBvOtCJ4qZ3sX2QsXFS6mzYRUEPs2BXWTcOHLOeiEZPrrDMiUhqAQ00zF8UEsLEknn1L00aBKgHvf1E4X/DvtEHskyPTDiFX/kSgtMhJPuL//imt3g3P8NkFR+93TvBp3/EKjBI0GWyuFjxRzPJhGLnnnXrjUP+DSWrqe1eVhoyA2pM/Z/Umr5lDCfjg3pI1v/TKyDRI4ns0082908kuNOQ8kNS2kq+8kCH5HsBfHUwHbNmgXEensd7jXB0SU5663hANN0En7h4bbwEkTAYYrUjnNFj0jEj2jB3zAobWU+IdPzUgBxC5Bxu0uGg+UyPUvh4IYljocUMeC4YAKICmRf2KaCvkZFp7nludgwzeVWH3lRtUwAYi7PDOngCkBkkFIXCX1NkoWAS5sjiQ7b8fxTL/yzbUppWcV928qeCww+VFX9Qt9qKDxb4ITXyOmS6BwSShi6NlNVD4ro6upKSghOtxzMefReESTjBhQVxV/Vlw71R3XrmeSYytOXentxYBxOB++jRavII0TvEGeiPJJl/aJhnWoLBJmgQlUD0K38mo+BxNtrjpnWHrJwd2DK1YFS+Sh3sEm/BKhjDJiEbNS6CcHO01JZK4y6d5lsqLaS0MIz7QjS/FWF80p86WQCbDBBMhe8M9mQRqohozszhV5aLBXnl14ckSItbiYNBkBrslyTBQ8ZIWLmM81xgqoY04JawDOZRWrnIEgJMSDz2/xiSV7IGn2YCn4RyTOb5A7hAe4ek+WSilOcHR5Ka+0WhOXS7357PeOxTUpcOa7x9OFYgOVXsAUsTRZt1He8kx2pNcq3nc2AEqsGOKlZl9J0OAuLSiAU7NG1gA7z0s2snzuUcxJ92a50ZHqB2sA834axL6q97eVEbEkN1fhZiROx2EFsge1k+GK6e08t9GyrblQBFA3BesdQDHiFwLem3K7EpcxgE1I4zTbuWIldp8RAhHOmNXMSSg0O4mSQ9NAheLSAFz5ASyoooaH1P5DMoStsWsD5g2TCtt5CGXh4tSMo7STk2w1vijDBBhoLUUm3mFyTDi7r9qGp0Je9RMrbdQyCyJ1bMp6CKyv42mfGx+PIs7qHUvp3DPh47Mdsgey9getjjccr4pRUkHQesOq6RlBKIKXj5zDRhMCfblzKtA7zJlRIktCQ9HiAYh10eMpSdGj0er6iPYQaWJHf6VwDBEeVh+dbKpYPmctr7/s+4+9Cbo6BmT188Gx6TxY7MJyu7vHy6O3I6NNJPP75uDImLKLH885pAHfNkfccDDzHzA7G3JV1AmF+NyvzCQBC/UB+OeYmIjkWlnOIMK9gyy4o1qy3LX3iGWWr1chEsOUPS7jy4r9rZQaivYLtCk797AY9Z3j4tzrw2pKFrrq+lXFz1+1+bsQDoxubrnDW2FOBooJ0sktwcZrCBEGV2P5eLl/IFTEAUvdaClbM+QoQ92hyv+K4aZ3Qfk7chEGTcQdMSnoNJYfDWJjqPEtzPApagjRtC0xyNVrjo1b5K2b650hLZeP1R60Z7kue+jX81lznPMaPbkrMZPCZctGNXHPHzzaYMn168g3gT50Es3CB9SBBR+9EVUD2AmarHgOP9WKYsr5rOl2f6I9T/T+o3qdgo3AcUJAoOei/lFjbx8Gu0EuM17OlrB8XX8f1kFikBiMgTJ5F+N+Pfg6M+EQsLz0hooYETuhdQiQTAcFLdJ3ynUiwqPCWthNeWRGyOXii+yMI25oRfwQbo6wg6Kor30o4hYpZrsP9lSwTEWJbTVW4ZGO4zaNkLUVZxxEVvblkn2KTt4OuJtK4NUI2fDuJWNgR7sXjrU5Id3q5ueoCsIwwkNSDq/Sfx+ipDv2AfFdh3c3m3vycY3PMnhLXEXWSbXcWHKa4PmN8gx6meHIrSyuzsz26XnJOmGcGwvhhmu7vTuAYinvatHFgkiRWUeTuI/9j7RPBWLrE9kdkwXqT6ZaLrUURRR0jU+59sDkW0yG6qQyh0JHEakKyitQVMHnQA+yMeILcAA5Xpw7g17sOvEDaiMZ8wftpHQQwenOQLq3zsPnSpYYi6u4mJvR7gY6D1spUNhdlR074WHxqOVyru9Bgzaw6OptiAkvqi6om6WTGbxXicqPa3rw0i4CDbCGb+DsFCj12WtnIDoU/b/DLjkIFaplc9ttAKM2lNJGRmonlysfZZzEa6ljApireErzcR9rQyiqxl+UWEfsMofhfH+IC44DbcKlCCjlosGPXQtxWgnNuVPHs+m6XTMdImI01medBZ8TGnyp8vnCVVWoKhxsyRPPWyB5k0CjhHrR98yfUCEImVUjCNNy+EB4SNbxD+G++hyxcHCUAqticM37dtEfWvMGQCTSP8VmoZdRxbE1+wDGAtt1wnDYonxPdpLFHbLqW7f8fhFcgRlQQdwbzjqwe+6thjlrLnbOHEJEJ9Z9klAVkkjxWM9vjWPhRcXzodTw+bmvSD8DPdCe2EW5/TGXHd3p+GWdZyLuoR2EnGsYh7IHJF75TS6Rv0YYbPUJdPxwMxPlXePQxQWDGDM7zz4wadYg/8/W+Tja79sZiMxwWetZXHt3iEYevRs2iZpO5Uyfd0kA1+vzcF8/AjgXYxAYscP+zkMDTTENByvXXzpje21FknFOycayvp+9IRdVMihxTWXEY2s40IfI2daw4J6ynaY6p7GnaqW5rVyPgIZF7O2MEk3ti9p9CXDidWpZWVXlHQQ4mGcFadnLj6xQ6XzBrJEHDmsGsL8aGkMWB2wDZlBNw/O1pozETGva6aR5S3a68/l4XGwqTa6ts3c2oU6u+p6XzJz/oTK51B2yMxrWF74LwlXhCRcYlBq1YUV8KQ7QSVOqCaOrJeM/0ylO6pwDQunntbQdiZMnvZc9NZtFUyiB7lFfOmlPNR4HJ6E4qw+iPZ6QKbsmOXSnzWFW84Y7LHyGgatZjeZyhqEZhvJS3e1UoJpHnMLNCK26HgynGv6KUizW1t3qA9pzo8FY7fFwI1e7FE/367KHnfC2vMsqRW9oKo1XXbKV22YxMya5rNQfr42/+TTLsrSgzKAAQqlaUzHsMJBgMoL5GxDyRmPEjsIy9Kyh3hkeylZHVCXcCAHV6BCjZz4tKFRKjrd1CIQ0i9Eb+85Bh+b3TPkVU9U5l4DLDC1Dr5G9IarpU/+xusfeJS+9B3XYd66u+QOcrG05GjmjRRjcdYFgHNhmPMcNxwJgk9BBBylxjtmchcJ+bm2bwmVWpH/v1Ss6GvZ6oyPVSgPyUyv0354Q+7IQbXNPvSaCVhaCWlysoBvK/tpC43GDp+9j8HXi88bswfdFlczEh1x9jrKJmc4KrxVEpyDdoFk1MDnXEQuw3JDo0+LzsoO7ati+2qSq4MnLthzrq+2F+5rfV7WGgNLXVmfPlefeLmkOoKPNwXlPu0Fwe17X0ChJ/rj4dW6WVyjLUIhQHP/df260E6F75hUgCGhhyRNssGolTnwzr6pcFQqHH9NmkOpUHJ0Ki3l3pAEe7u/307DV+lq75MmFqgYPSvCYiZlkow3zWxJwG7MteXLY3KAc9dI9zuS1VLhEAjModWkZWKGrGpmhwo6REDQffHzkMj4wLoWPbq7+LZhDfxYXUPFJjN6T7OnCu33BRRfaxnZNUciOrhK1cIeafjnD0AeNwj3BBwOmXHA3L4gt/WJV2aHfOC2nX99rOYFDD/6lmLGQDjz/eX3AKcpcdI9ooJX7vEyfT3XgTgmabMlkIz1YZO54bnLHbzZ1cWzhPoKmbhhg/narhztWg3dXNlHrwkjh0qKpqVwMW2Iup2AU7s25fNGPfVmh8eKu4g26E2fUvuPst0T1kVRSuZpSneDedr7GUuZM1m5d/fCB6JDyx3Vr8G5Oyctud/VXuq7auo3vzKrcXD2ripu0NbtBUptXA94m7blTlikZXvzBZ0D5pKoW51pqGGDuJqpthmNt9+C4LU+25lOystF5toZEDNc/eMTkhdfgcyeXDIIsSQxP/+Dd32V/fN4qi5eeEpeL8Z5I2HD5gxvIq4X3TieJtHmlxBnOf3DFONCFNvfC8Q6NYUgkOoIrJDAHV5nnmtUS+6Hs5FYL6kbvpTtoOpqbq+RiZqoFLlqp+mFEY5S6oe3fPZmhMkj8zAlqoSK4AmEr4JihJU6d2d2LtOgckMP50e7oHRl+iKfOiMqAbtRQtu8KaugLNF6bCkoFjMRFI45afBrkhE2N2LnR6Nvk4vIanAP2QPVe9Nz3XBhRcckwkoA5OgsqaxYKFoE7d4Ik6cnlVsexTD1IKJqj6FB5Kirh3taxIH5xOU/0KwfigurOi3Pq+ZyQ66tf+NxGMviXgYxjvudr/j2/jXV2aEfX0X8SclxMPOXpJpK2Lk/LcjGVOT+Wp4IW5OjrnJyX+UyBez7RVn7Fp2wzoNeXeuEXnzCFjLvX7WOQmgi1zRaa0ocnmr9kw3Gu9OgoyYZ1OQz9YePZCuevZIfhao+RKee6HaXWVOQtK+ZlxWRlslJepiefpJTtp6lsYT9MJstPPK9clCTMZu+lz5hoCqi4oAOkuc4FJE2w+rNcbs1kllcRDZaplnQLSvyxOPlVXP+mPU5Xnh4nOaX3Lj+fLJd23wMvmfrdiWEP68/s2ky5G/1x2zXhks4eUVddA32DEoc9CZb3zbli5g3cT1w6z2db3Iey2Q7j3ZrspmmfyT9XNks07OhDUdBlhcaLVxXn3sU4sR2VvGJPieuCg64n8km8LxmrrM0jq4GknyG/tLFk2/MKeL688Uhv7hQNLZXNU1YLPIbBHpmz15QlU+FfxeVv2m0avMfTlisGmYquAQqdxyq41GpxyNL8OfTH8dvDWcYVDdagm3LetGjMVc7/Qzz5addpcDkMucn2ScT7qra3O+S0h1m1zb2UfrVfOYWaPqbW4OEUpGk4/uDQUhvfsU1O0u/r76+IKiybDoNHfUhtl5I9nsvPuRPX3MmEuRou2OHq0tVKNbltjA/2H5oHbEPwDBM76RW3HW8OtiTGCbMJXKa7lJsXcw0FFIo+oCXSAGjrF8321wIJO+LWEmTTnTy/62d0l2ONy1zHE77jZsuc/FGMlm9Q4tUyP4gjPWBBjI/0u2QwFbM9rXnl0KK9zcNJQ4ZYqqNqIPTngVguBcEkHha0NCBBk/9r+VuysUP5/jfbrg4YzUZar6vAVRl+20ve6oBTjNvXTv+Z8Rm+E7XLkK/YK+zwaAfGxOEZEFLiDiKekZ1CCpuTNmogmJwkUYfn+Y7oGmd2w0AH5CyHDJ97a+WHyoqTw0Oneba1NHILZgeXU7gwuzmTdNtXFsZpssazo4S/zW/fC5ocUZUh9JCilSOLwy8rXJ+EyX9xOYkddxDmS1d3QVCxae5BucCTE+xRAiXPF57bJPdoQ/zdvI1h/4wtRUDLrCwwn+KXUEQ8ex9DPmN8XVHIbVwByW0INU89IW4q98CSDhPsu16ESc6/ylxlK757OcBEWU8Q58+tg9u/nmx/wDfHjpWGMeYTTZmRmMgNUcUpEPI+CZJfpeLQO+YJMcTrgwcOsYRveY9UX8oGJAxwdhYwAUBDGrubWi4QNBslnPKTEb7ZXkxurgP7rGNjx1YFicJn3uodkxuD8HfR0LKdtmpPOfP2fB5b9DIuw0ExFWaEXdaKUJsKgC9AfET9noSDd/j2nf/S58uVSJBY0/MS8BxH4AX2RqABv+FBC4jDEfzss7VqUmhNMDrEc+eC9Mx7B7QW/Ya8po8V25okZRtRgAiAoAg2FZCeEW/S9r8Xzqct7G0mQ3FIjOx9mZwwWkS7aOA7wLEAjcnAgbmmNo0SChf1hFE4JIVZXBGTF4TVz27jknnL04dXCpx2VHRHpPa8E7mVK4LVbOF/nJNhUTdYhA5y2HIIxV5qdKkiO45JWe/IBI1bVZA3GgW3caTCjFpG6ijCh9AV+z+J/NGVuKAXb+hRmCE3fSsNye08vMBrnrPKffTgJOY8vdcJHnjL5HiFmTpXk5ixFltxKDPJCZONsZKkRB5QrX/JVLCFqqmge17csKcjAB/kr5Fd4TX5mYgu53ZViAVzuMqLgDYrn6ciLf417kikWnzzA5h8vuWOB8acyWlT//ygQCpfAyS8UFDgH5eVwgzMJXcx8fIKZUJ4Kn1ZnpWsD5RrQqL1nb4wLsETJ3netUfFHPN5XDjIvnxQzAHzD3lgqKyPKqGMT8QH2smwxSUfIwzkzSwRZWcxRAqLvtRMdxh0wLWNJ2GVg32WnpKUebfQqiHa7dUEeZBiK/yIcaF25QjxqEMeci4cUFwMjqoHKV6xnAgw1V7CnmiBryHV/R1Pl65w8bj4tIak4DVyooxvBK+kvyYiPFvVJqaLu5TWZBI5tuDbvxsnNSJYy+fTj7NS9zWnNBkKNlGPs10ul+LF+jxirhoi8eT6i7lC81W9ExiK/RFSofjluERx3a9Vk3DPxC0sePlRRwq1YOUHyamlIKboqYJEuB+KlWNpcH4ic4hJLQhChxBaFJvRIca1Fc5DlsaLTbxP9rIKllTnI6POG4Hie3e6SjHjOzcC6GryC4j7cKlq7H9PiDpd2AJh9YtvxXIGOkIcVs4um/9Yh3nNNoFeb/PQKdYNqHFNQT8M2MPg6ZKlPsGIIn3cQkCXjHCgGVl3qagc4G51xZ6QbEVr0m+35o3bz1ckJtte8aswoFY05nRHYHmR8uvqutMjTHSPiP0JHcgBWi14LGpjxNxxAEZ9AnsATApPAJETZKnkO9IMxeNteVSaHw9iJOwtdhDarPlOQHlCcYAb+/Il34c1VuiF0odgW+CDRmk3kwX/ygiJ2TQ+s3xLEVSd9+fNCltOKTS+aGcqs/HI6C6hDrXbPa5Y8hC7TnPW86TXDXYkRBT5h/6UElBeHuskTFZhWBVGczSdmE7WrODSRD+tKn8oydZC4ILeP8AA0tSiI4rAuhPw13WUEFI+25zZ6yVurY1walUHS5dN/yzA/mOWfuugplaOH518YXWMqVUVtSSn7/GCY61xNnEaAmUljvUBvg5kQQ1sEchq57kNezwI3hwpD56LIRxGnmRVE4as1kBxVFK//08hEHr2P7508Eqo9AGZjyOCZw420BIkOt4SyG5jDuXPerZLH2SV1cD4Loun1MY8WPBbEKrj0TLG9t79UjFQafUjAgR1Hir/cnHeUY0pIprxlGBTvalI6iMdU359iWWNl69z+Aesmq9a1y92zp/GlmH3ZoyPBpLaz9pEd/KCTVCD4kOMicSvCv4vSDrGQIRB5z34PAyxXCxSEcnmHa9KuxGITCNJ47jpDQw61ukzGFCkl4TiYmNI9wFOYHrFNry3E7aa8PwMgu5sRmtwp+8L59d7Fj3rHBiH4VXwkthBTIOHpfUq7aFkXj84IAk+QkLFMIkPHLSHW2/JxGwwB8Rw1U8TBEvP4yzMghK6aNtYucfYB6YDmAEGoqLeI21eEejKh2IXuHAyaCdiEDglsU3VCNX4D6/JonGGEwbSx+JlAOHA4ZA2VcCydy1nqE6oo4sF39SyHiNTPhsuABHUbllMsXY8lZ+G18m2Y1FwFsxg8F3yTzg49MY9Ioy4QlrIfFl3PQlO8yJ8K7VuTOFt9XUGiu1L+LfU2EX5Gyl4T5ZZdPylQJSi695dybd39JaDuZ+KXMgGH15QYsG0UXxDzDi1wsqIKUkzDXEtDWW4+XOOLNgIWozd8/xIoedGVjyAbSiqZMHuOMZyqk52nPxe9VAtl3BVk5t7OM6hQzd7Rj082Hst6flfC+dLlXQU05ycBDw25BYKCmHrZRum7fAA6Y3lzZh7ggOn2JJMmKVSYZ0VHy6xSTobMcMHGlJY1cz1kTPRHe5Ye5bF1WWeksH/b9cTTuCoYffr2oPSQpTUetRljZ1fxt4rxcur3RKSkslx9BwgMia6hz+fs3d7ZgRl+ze4OYPnmU/Weq0cagcdclB7IEz4Gp4CSo9sihRnNcNjTGsYd/bBaaI1AwUZ7EmbWPvVjuinclv0RJ1Pzn3ZY+8y6lriJHqOiJtSsBJ9fFSgSjKZTNoF0wCpqpaoZg30w/dUNKVUe95apanxyNHdmapxJkjm41ZMBjbbJmlKriH2neeXSBHs26TkqJm7cJRxc1kqCJ84XkS9xWLdRlg9WGa7p+SkvnP2oErjkNp1LS+Vekh4mP1BTdbXK/Od3w1RTF39iH7tlUdv3C7ohGBZopbntjgHoVGaIe741J8AB0iP1r77ovKcFAUsVAdHW1eByNcWhHYgghWnjjapro9qD7sC+01LXWg303X64ggy5UAyIaZiCHlD5gfETSz1RJJO/fo+g2yiFlvLYBxDusk6yiIL8aQYum8OtepDcsSTrDrL8kjMu6Z/T7KtNDg60dJqY0B/PypNjlL+XQDGE8+y+MjN02J/RicqkEKZH6yYaqERZNwT+UyysLGx0XhyXWc5z4gtHd5avPj3MeHORj7sf06BaUGWQvhczb5tlGJOcF3wCvqNnK8UWREq/PYPSVXUDfS0KHQ63t6qUGDA/FnJTaLySczBb8PwrLPw/ZJrwOZPCoxlgkUTvME4k1EIEXSz6sCC9Tz8HV88K9TBL1vXgQD//6Fj4BEGzovcn/aKHIhgWYGBHCgJeASfAVVzeVk425RtIlxR2N6Fe+j/8hbZR66Xsznz4TVf1mieVGOmonY50RO5etR7fHAnuW2KILFCLnQaW+0EZ2WSl1484WmwIM9sl1QfQqM/NXOKXsX2nRiLzXsUSaePbETmesuYYDuihTDQ9ZYZCQ662AxYHnc1onkkjMucYPr9SJkS08BL896XYf8b+IfqsA1gLo/PgnTCPHyqE/UE6DQUcG2JLEF0hrQSBxgji/p40I+xawLu4biPBQGsr1M8jNfmctyT+Qy7NBC9KWAiD65A9AlYPRVFF7bD2ZA1UJDAAABBbT76OsH7y5ITJHbEOEQnNIWd0nTUxyr/SugaN4j7x/L8PSfYZ1KJeWljATgJrQ4JlQ30sjKJKCb1dKAvtQIDIJfO7TOCaRpFPDl2E/BqSh/sBX5octTD5SVyJHAa4oC03Vb6xZHR0/RIcFVLmNV0y1B/gp+NQj3g1WSNHXEbsjd8dhDTmiJBBKxsGCBDTrMsXVLKdL1c43Joq61GOy81G/G3CNmqlytxFqqVId64R5nt7UDMh2pYUFZ7BrUPBFVpMdu4pmnx6vnlz0p1mocILQuFZy7cgRM8oBTZY71EiSfQIK6BF8Rdt3VCOEOL7uajtBhNIqdmQuTDvt2zLB2HyeXeqEDEY8DC4X/ov6N8Ebqt8gPf0CFT/4nedEgfG6HvPL1sECNZir6w7dsupAxlSOS6/hRLrNW0W2I+2wXHFRuyLt09Rw3UTbi5f+1Q9Pf4JwJQqCEIPhvMGifZDKR+VrPNKGuEAUZeFUBbFXjCmK3L5Zw9NVFbzeBEdA1yw1GCI5PRjnLCWcvkT4SWIbFBzJnIQhzV0KYj6Hi5mHfJwfVbE4GU4S3IEeHdYm6tD3oHcP7yhWsMXLibf1QoaW0mTi7TcZcSX5+OpM/naKTzWHe3NBZESUNbQqhrVpimnzyk8DgMdkzY/1u3jKwub6+maWs+J9Nr8q7GmNZMivCizJnkgdjlcaeVjuDFg1nARBlQg7HsF+KGiTUxQkqkGyD/qx68kIJ+g6JoK+Xejv1n7iGGznPlAv0/jJmQozB99gZqBernw1o20bOpRZQR6qDFkFJvawT7j/UhXA0LEfCgNA2kHSB7LuoIiGRvNcry0RYIU99bDa4pROO/HxvqI8NjiuNz0r5Dpah2SIHv7aO81X3AafqoGJ93lRTncEJ/u3cu/ME3HhOyp1TDJzeHrNszcxEsUs9Ghe59fT+JvQKO9aiDc8mALXZs7YHIZ8mRl1hVH/gmbHqCgeRw3U6IidDeJCtNPJklRTw0bNTsfRmQCeSTJwEsJhN2D4n8zTTmW9aOUzG5tLO/ThLiGGbwDKQzCKoWWSuFUCn3l9434WkNcJTmSQtv7A8ZnoRqxWAoaA3Qm1Aq5MDZZoaO2cEu99Ks5yc8QQbl0VDbpbBXk0DkXpbLAmE+W4TbDib69BP0y1KYWUSka0fzvIrKkktDmi+KyjhgXOwbNpBFnKR4uOSOQZmvDLT6HPEBlF4VFR62gUc1nq9KgUPj6csq1LKGa5goNNIX0R0+UTc9209oio1FoYCPM6oNmHkbT2SuRkoInLXpiK4XZv1XufnKpjjucp5TxVLOiOr4DR25Z7TMKjPBfzjT3ZH0iAEvnB+KOEcugM6YSZe1XElRmJRvLbyqIMY+ubjdjlVJ2HN9ZysnWM0zW1TdcmlNuJ+l9pEqx8wic4hz1+i1Buse1sxlXPUUFHs5VAXU6W2EHAtwAGiQkuMc6UgwAOYG8y/DsG4oC1VqZtl1NkzZKdk11P5G+vXiJqBdYHZeoQnra56QSH6D3spYPZ9TS51RWP16Wo54YedXOKqRqAXqZBdQGzawHMjN/Lb0hKSIx4AK3C2IKx/SLzWbGSF07gMqR/IqdsTAHFUf0TcidX8LlfgeNRjwqNkPIoItauH7pwx5ju4pwGrzbceCDrpmbgNkQ4PsFiIdDySRvftHOXtbUgBvSEtYstjFBRIMDh4gZXLBSFoRHltx4TZzQ4nwz+bMxpjTlqvPzSzWdqAammKTp5YXxbhgrFv3H1xLW4Td7lbU6+/D//j/FAXd66427ML4cPNQ+K3VGBKChas5h+ZNZVHGhVi5qx8j2RlLFMkRTUtmMStF3bAb0CLro3ZWSZ+eV8pSLabDyFOc3fcH7srkZHoRZCRLtcaESTOCFq3T/kG8Q16JrRRFT6eiFHn6+SUlV2eEB6yk64h76gboxG8X8p/fH2ydrF5eCKVFt+sD5l38fJgV+bEZOkaHQ9wYYk4XS8U4Ic+fzWJBTB1QpF34tzsGj83j+sMOcNTqPNTkodCD31wHVlqIELgAk9hPPHvlXmhUfeqYIgdkqI7EgEDKNoJUDLIzzCF6Ogv7uKUBsUMeZyp/JPRidf4ufRbRU9zQoWMUGVE6FrImnbMaeRp7hNqYJYWxtunPwk0FdGqlqRUhikNv9zNmC9KTirlo7O2WFIIrKaiLQPaBLBEiK2a46SLv6Qy5X2Js/tJfRKQ0EBHZhwvQhA1Z41Cz3LDLsWS1I1Zn8LV+8RWDjIhTfuXJAxplj6yRbNkuRDEetbfwTwdwN6hqf6ojNaDHRKtuN5bKp1azMrIHtHXKzyga1JPkMy8ayipgR5Wz/JAPafY2CsiV/lDqIoFTrh7DzrqIREkeJKiRg4H0w9S5WHHV2WW6NkjLa4+8uRdW8tJ5L4iDVZcdV2iV5IR9t+13xjDRvIzmynk4MEE2zV+VTMORTEAEutCE1B1nbI7AEgwmz7Bz++AXznGhloFVXULMfkmyTY0qSIGwanupHdhcHIzA7YyuZxNrzmAnIQsIayATKvX/e+O68vtZ207QE7hR034H9/CpxHz7h/K1S7r4mhwedCq3xUG1NlM0kQpMK+9hOEyv/x6K+S2cqPOzerCTrMOvJFxa9mdHJSpYLFTM6JemLs7hakmQCBgEQoTeR60jzxFspY7/HFJlLBG+1XbcwCNmdgCMVbEtoiFyM2hE7A1VsptxXY5VmCJjMvcFrqwPb7f6EDvhtNJsm21g25V/EnqJ2S5UX+X8cf8bOhdZQafdeO8i+SjOm//QyBgLv9r631KjsBR7Wjeze4sSyRnUpy3aaqvzVbtHB1TDgF77l+lcoMSmbojYZn6bJJUuh8bwdja2Xrl7PPxYpF1kMVLlntVJd7/EhkXB/0GBgiT2bqMDtUP/zAZBM6kUAA+hR+yRY09d8QjKrujSqhd/81rzcBDo7DrcsWmjVvNP2HBsd4OVoiRBu+0sIPqamtAYCGwP9+zXket6jMzPRKnvc/zuq7Y8cp2EHRzYLCvWmVWiYrQXnYAMea2wXmhq6XZIBwNDLq3zUDpn3OzZNSw/YzukZ5UoNJolj7BBDeLMJkoyc+dbEHIgi3hsOnoEpeQdgD9xn332AtP7Xi20HKBpU3zM87AZoVyfWW/FEQJhQVadJ8gni8kRlrQCZl+GsKYUZ7XmEOOJD3tGdorXx55tpOemi+05HmU038aDFmOiyScbVyEGsKqlufWE8dSsh0n19YjwcEqm59ila9PV8H9YARsxdP8QhAmHbtDOb9Hww3mDFXjlLP994CjVZXUWoA9jQO32lodaYfYbVOasNMMq1Fn8El7RLQMrxmlJMbQO3p/qgFqlyvvjqX5jRVwdGmPA2qlgPMahC4zAPmNHDEcULxqBqY2iNx7HhbbYhqKwkEU6LpfmhEi818jZ8XNcwaic3j5WbhF+9qwRWNsrlYgBcNgk95Caal9C13lPmwvojl9vJMDREfH4GBEceWvNZUWOSRGSzwZlDgbM3XeKn/gkCDerDSKJJj1w424xptLUXm3mq7I5Hzr/NaAnOEGuDBIUmj3Yh2QKl6uIPpjDQULqKnCwAs0iKCaUSmiGIHkyBo1opn3w6mYVFndcGnrHl9Y5gh/FD5zSdv12mfOURqMiNlHDZR3XmWlJVtkAd/etsXRSpAx0cIXH8+pzN0J4DTw21uyQprVPSvdj4m5j52r9Pe2ZfeNtaaxW/v5AbWDp9AcwmLQi2aUvLsIgQ+ZhZIMP5hgCRWm7rUwBnsLZQYv+BG83C4TZEjIICEqm3wuMTWy0OZExmlGzv3UtzpI9hLaj6pMoISUFlsHC7OJbc3PR93PXyP1nhB9/MOm2g1K6C3vJFuh2oFS7tDeX9VOZA7PmKYoFFpdIyioRpeCtFgOGPASvI5/alo2IJ/bFL1SRR5OPEOaqSAefuKLvRZVIRaAnFSHOptcIFZuwXkCPyJGy7CPsW8kVmAIRblaslarhpSNW5HAXsXuo7+4HBSkYhVGgqP3H6iUL4N9oKNRodjDo4TLboWzg0NL1hqtZQs2YkHGwFawKzvOj8V4HU+1sjZviZZaEQEDSo/l2WKpl2W10aa3EU6dPAUqxq+9t5pK6OMq054IUj0AGrZYdU/vLEgCyz9yqOrflbNgrkGIpFRR90nPReqJQ6plWYtARae7BvhpzD9ysoCWLB/aoNJ2bFsRh02V7lBU4dpExu2urAjvXlwD9skUV2DXTfeh2SXzmfaYlK4C7P+3QsO5nFbrRVoAn90DrEFBEh7hCnySGBfgJD0mRpR4GG5ctiPgBMB0W6wrzdplf90llkuGnI/U0vO4tAsbRgW1kWFA8+eHVKDf7V/aB05moi1IDzo0GR+rJEXKIZpouFGPN44SIU9wDDqleqYSEdpXGTRh3K7p/XeDxuvTbjOuSNkaARH+LpqkEoi3Hc+3jbxAx8FYEwg+nPdEGqVpsuxFXMnp6ntQncaEtRk9nYb/0QthVmP6pUTJDzOGmAvQyjpX7LuzZa1uguPKNZk2m8Qq3liVou6G4LIj42XiUx4b/yLdAB6OEPW2SaVkzUV9z8evS4rWz+yNhHeSsQKvt/hdZ5zYjPl/RSULYzgfuQIugMRRY30IEWtc1TxKTWB8T8wmtfxK3juKaibcOIGcoWGcPaX8Z6eXjQoEttSCK8nAa+Eq8hVrBejHFsk1aM8kZ3uTmIsuXmBO/ts38hg33nwe0E0oPofdCTOG/Bug+iiO9lY2RdLZweqkRkGs5N9bEPXFfhwy0TPuIwKQjJUr4TJWS4e/m3GIrntOHoOc6lR7HWfQCmdzHlSjXx4DP2Ti1O26FNNMMIH/H/ng5SqJYlIjB6NyOrqcSnaaKy0VRP050RrvJi2rOdxD6KRBjubuiHHdkTBi1xnB3yIjR0PEvt8H53onHDzKzAsZ4qmVZ+BQi932dQOG3H3TqVKTIfnllNUkvkAmMQnL3qZxy2Agjzso/GYMx9miibL2w1rXkI/FTYPERs/1qd6CT2fPiKexHeXktKmr2DUX8FHu/xFxCnxE3YMVuETqGkiMJceaOeSZQ89J6/1KLofwj4TMqskyQCS/qaxYT7eJEx2uySdQVCG0SqLO3e1zz9Hr6sHcS+xwFbrCYhKrd0q44Oy2VnUB274iaoMDczxrv/iSOsLOOlOvmwlkwBzW2A7jqD1bx95boL0sU2N0Rue8uzNiMCrIf7i54N3E0srtyDWjX3j/qsq/cSFae6Fh5WBiYRm1RWYAu8e0+0k3kiDtVYqbbmxXD3IO8gmhfqRLNsQX3fxfK+8bQxbPE0ifKujjQhJgeP61zLtc/Ee/f02K3sg4FfBXS4QmWZ4zlLqi1YmhBE+ybKx1jY6ULfg7CyishF3tQbVcrlXDgwomuxB+bEH7cmN2qXoY99qB3niLFo+zOuQ21R/UnEqLoHZB1Q0u4uKCPkdsw/davx45m+5Dowk87i4M4ht/45Xzi0zusE51pPuNikOQgkbzm9hkh/JUnRdQ7NkcfMDTynWTyX4zCeUCKBjKIqoZP1hj5wCUFcmowO0NtGppyP+tV4r8ThaYrlDlskGjvr/uO/zd+UDRE0Uh0QNAPq0yJQDQi+bswmAADA1siJWWVIY4iyUrM71956Zw+a3AnXjqT4n6zEAW7SUJyCVwnGA7krdqOiI0qopPE+IvkWOF79QK3k61qwPkk6AiaXw1EZ0VDv6rCaBMl14RhHQis9huYTVAgSax7hQ3asSNOK08TY4FAILVfa116ggOmP2KFMwQRFn+ntvyEXoJqQegQVLcxTghaLpLkTRPGwgHBvTrHqZ3NpJ+N177Wf2YTioDV5WIueOK78w9C6c6v/+0eiiqe7ecBxYPDo08oailcOzAipODrBkfTEWhEvjFCeDW8X8oBM9FUztjEud2R6M8P+7gd34ZrfpbuYQ468cxc6joNgcMBksACvS6aO6PCdvX97G6mPLcDcXDE0WKodwGEUYpTHZG1sp7BaFa7b3senoG46wmPzR7Ly2bAhOD1D+ZGWYxnJ4MG0doOFpC1x6dSdui1VelvDSO23FEPfu+2wroFOFKN3AzqXPoKRojZXGSW/yL8QR+kEbDT/CwEbWnrOGiqZRYoCqKGKfQcdOZ/YNc3bcgfxBSwd05BNCnQsYEeX7MBrPH+RQWbA5qEGt7waxaDB2KXEdpR8ExQheCuh8pHrGB5oVeyBz7LQwEseGzhQ8F8WRy1ayzRLhvmlBvMVa8rdx3HJ+4onV081fzTiYaL6qAynIutJxbvU3BY9NKNtgt0iel+MGCqWwgbRGjVfmBhK4FhXWFboJt2Cu+F4/EGtGKGtW/ZIOpoW1rkrGu7/UFF7BCrnR8W/MoEsec5Ey1iGn/yykXUN9nWMBiZSteRE8jDz8CNnWDABKmBqvLRKcB2R/DvPscWW6DFzqtjLVEsppNriI1rTrnmiMbM7PwGhXdSFNybcaV+9UiCdQXRS2KxwSEDc8KyrLZMLyM6JiRC62G2HOF8MhdjsNh10NFsagdLIEEh4pG2bKyG4hpo0HWEsnLSimONafck7ArZv6mkgRSX8JZ2sYMSc/Vjrpv3cA/3RQATMclKKPWpVM2Gc7rUh70soSjjhordclnKkDLVknKWiUa2Skjkf+jK8gIY5mSVGV2MO9qf3moxX6aRjFH5XOk9nK0O2SSPrrzC0EqNHf6H5Mq4wX2QTB/UL5aH5ANUZ3cEl10fZwmskhAHPQhcqNHy6Hh2dOTnqtxin2HPiPruj+bvfO/yfgaoQiHoHRFQwJGWZzXRpLQUJgUWR8iGMkQRk8PFfFYiRtCOZ3pwd0HiOWFiiWaIRSbozS0MeymKJAhUaNYbnHqRJ86eRj2bSFWPI3CorjaWyJcevVWu5SWzcnHrgnPnxdERQU8tZXi+4EjolwaButEufF0WXj5VDWfztiphmHPgrlLy1zAqYgc1TPTwNPamNYz8RIbja96nkgdqWtmI2+lqeRv+qyO0BXZFQYU/jO1GkehSpVN5yG1b4JGs82KuZhnfgF6SQ9Wn1LBouctRy45DfPNaCE8ZxpWN2aE94i2XIyo02emWgtoaqaZcrjfCdQQoYQX1SDj1HgF16YdzBKfjGbGN1ghYlfkwJA5yZN+cYBrXyzoncaWuM8GJpUJWX+hlQ4dHjYKpZqA/OEgT3wVFBjTq/MoVVm3pn6EJrgw2OL4rZiPKnrMuwF9Yho40S5NXUqKX5wN5V2ZhVGZjeGdJwzhgXCCDHqQ9Zj3UXUoq5Jqx2td9A4yQ2zUahWJldsngtI9eqtSykTqPp5kMgarm5xZddvLkjQ3QJDfg21y1eZN8gn47RQPAjcezwIeJPb4pf2HsTJNz9hyM2putYtLg3rC4h8NRPFQ4+yiRHsFSQpzAmovPT+Ba/pZPvpTYIrS0d53v862kdzliZkPERoWnGqu7TzB5QCj5rfysyUyAmaJ13quSHgTL9/uDAydVTRpNWVuFUsW4FKu7ZZfAaE/bKltZA+P+scHFwchdAUWtnS0JSe5LXUfydsgywBk/RXfstbQPMDYSPdXngOPLj0gpOVRVU+iPwM9A5c99TA71nvMQDske8kz+0baljOsXe8PoqwcIUcpYgZOrketXr2otQVDyJTGQmCo6AsOfqLa2uFlfwz1xf9Z3iLctM2GXsC6jE+/HZ1eM7vsFbL0MiGAsfL+KBpy4b5vGxEUBFdXWec5L3GgwrruVk7xHMahOUpM4ycw7Hr4juWrfauHw0aXk6x6IFcc+EZ4O+RjxbGB/ZkhnWYB7koGcz+cBM9Z7hhk9jAkvAnlZuV6fCUl64XgSOZak+ILSPRIS44YNgvpAmM/KRDJnbVIXpzhxiSiLpSxqijPgn/Opmmucq+p8+9JqCqfp+EfNnTvjugO8/VOqemef3txl6H6WYPm59qxaJ4/7gtXiNC0J3ffc01ltzAILASsPOFlVdxtVKwfzixqiMic3qZgqkp4rzobBDpgk6KZBaUXQcxlzaaiZTFrW/nmaZxsmAGXCA4VcOJ5BwNXWGXw0HQ7E979WdkAZOmKQNqop8aMFInL4NBqrb6pEjil0ixMIVfuKjdMK3pYOMJqAkKawF+p+Jqo4e0e0N2oPYYU61tRnyr6jRgKUOCDbZ1Btg4j8X9VBLoVJZLeHP0BDiIlV26XxObgCJuSFyjO5ZBn1M2LJyhYzLHXzWGxHSygZl7S+hW1HTE+MIr01N/5FOvDFNY5GpPUTbCUOfgY1QXMioOtZzWA82iAHIgFg4YonkOJa4cK2vl2jfmCGTYypkpS0AILvl+YnxKiQZTNOwGPeHOitKI75eIa88w8IUaJ2KK2svDAsMiL3m7bqhVvDJpZ3WksNTPH7d19PpDjnszOV3njheH3W/QH6QqnAK6aP7MuEJt1JKX5wUs3NARf3H5b/NuRAqfpJ8/o33Pn57Olw0g1a0u1tjweBRSFCujF7XZd/0FiQQz6hSg3msijoeONccZSRzeuZXyzdllafa8/0c0272ZHZ80V3RxoLI8krpO5A5HVTYA7iZ9MEcGVO3I1bLIcmU30gMXq35xrg4XSjaMkrpq13F4Ep+OcUZ5VJ8V/651kzixBb0HJl5lmDDh3MJosbDN0bV1AoJn1BODOOCWELB785eMvqdnV2OMQshh1LhZ6oUa/WBfMbgmHg8cst9+0l9x+Ew8xnKDOq/y6QrIhBL5/+f+4oqImT8XsIAEGqws4ymCHDIfbJ+nFW2Ft6D5kbMIKpfx6nLtz22xcAtQN1XGCGQSItPP1Ry8mDcI9vl7Sm059h2FMmr/3ACVFVcUZwZeuioCQPOF7/fyY9CkwisLpSjkduKpSuiY0j3QCY7KgUpPYsvzKRJqdjYglqyGQejT4SkAvrgGKtgC9qWw5YuELvONlENjozAuN/Xbys0k6NXmEu18DRyOaw4apSxbskGfpm5w4BUNeTx9ai3rANjGsS+mCLuAOPfoLBursGzl1Wdm5PyhiYKQnKMI7OibZ1gScW2N+P17fytS7fsWI8t2OQ9w3vPN/ISjRic1abZzBKVSC/fPvpt9NBf24eiYnQcaftvWqbG94Gx49sOjv4y2wNrLMWpjxJiSdmvo8glb6bWvugipbwyEnyMklFLCt+dL6E0j6b2q8FmhunZrOtyktTVC1Oatkbb+TRjxNf3CfdC0nth1b63scxpZy9L0f6tX6j/xt1XFqzqdkfCSd4wdTvrlcQlAsmSVdWlfXTJFVuNrASmEwN6VD3MiuWxhDFyDUnqSUNyjWwOQ1cp15zS3OlDkjGGFdmw0okRxKnUrZqfKL0ZUzMaSPg/OWpiAYXMj1umTgRcybvN2UOG8Pxly/+rHY90dpAMfLr2YrdzWtuVQBPPw3jnta2Io5xuNJiBLIxqt5eD0kNX1d31Wg7OBIhmCPNbhGf7RFZnc8SBONECU8Kz5gekgyKhdzHayiVYbK8lSguTJEY3p847QRTGRQetsE+9gptLZhDFAy3cFgcGouFT5Ex6MD9j4UPzvorvr5n/1MsMSPSjsx8aojhcWouYPmev10hbjthH4bqTRdLlluC1cqsRPMsapceIcuUmpq3CjLohpIrRbFua8MJT3rwLG9tkdV7Ny74TGoUK7TLB5ZL8Q0Z9rkVMP16BsLQ1uuWTvS1OWHofAtrQtYqvd2JLYxpv26ZwcVkc2VMqBjUumAOhaA+lbSkrvAWnaiFYro342OWSUx784kHc9Gd5UpchSfjJMPZ6ZjT+anxlA+qJ3NgKczsQRqDxomxOHcol9BF6e20x0Guyzek+llt9NAz8CyjE7z4mX8r+MaxmOZy6VwRB+fBtaT37ahj3ComQ1H1a6HtMIdcSfVYBiV6AHIGHG/CAwu5UxXWTQe37yyBs95MngWKMYXv0Pv+jGQfO2wOWKtS2j0sCRWd4Ltryezrz8z2z5CGrHZ8d92ofh3VToR3c+5l3c8iY8JKt8atY1QliXdHcqaz6i25gwTMMImoVb+W/Vkn1e02QORU9SCiTDKNhjyobwGAEZ75PyGtEDtu54fACO9xSSFjEnwjSgbnM7pyf8yDEgMAAFxHi9+2m+v9T6CMEsYRvqtQPWuK78SPMU4YBTerZJLvG/yPs4SiEvgZAkT++fasyqwOj5HCpWaAyLFdMyid9roZDG9Dho4eOrSCm7PacgmHIWJ/YlCjARSZoWJsvZ5WacjK+/MVmqezCNU0F5W+rxn4VlGel1QdzoKNLLZlImT5iNn3nbl6dhxVzl6DaudHI3CkzgZBhTd1VTlO5EutWb6wbR54hhqV//RQM9SUyKDTLJjcPUvHTu7UizCLp+/OWOv3E09LooS+OqDX18t27DQkE7rWZa2vC9olhGR6EZfVW3Ipp7BmsNfu9Dm/NvDB37+Ev1INlXLI/aBOBGrXCAbJS7XR+6AxwrkFm9RTVh3ymjVCRdmdy44oElWQ1ef/k4hGcqIN8FJonF4DBETLqjWV2EuVlsmkQVAzRh7Vgb0yu8PHXblvHZ1qUWqcqXtHtS8wC/JDL3e1dhPWgpvJjFJpeTQb6qW201q7AckHJptlFM61vjdhFNdrLO7rhNvBcLc+KsFKJ7FthDZogWwdw6qjGx5InjAKOa7QBoySjMd/FfTccFgjR+tGJ+RRGo0ju5a9OCG2jk17+KiDZR2k+wiuyi/leBS0gaVi7kSn82tQk2ZscCT0n/oj8atMlPnvmDszbrvrkqhjRQbnUDMYBYudghu8lTejRbKREdpRi82nBbPQdFTr5txghIKuynhKZI5cKKjAav4CbxPmfw3h9l4DcMBQa0xS3yABth06VTZJGBTHu6DsPAtVyMg3yPCVsdKSTt2aTkBDF3m/xAdxha3ql3ZX+K3yMk6UOkLkVMdZWOCsU9scZ5OsD8VHscjzTG25obt8NkZwsH9WrZPQVhtHlgbbm1Vq7Dsz+USi/pRgj/XWsBXUe3LKFgI4gwjz2bu3gMJS54zAR4v0DzSGbfgL/iDI6uG0NODIUx6Rvzi9VY1at/KM2Z+FRUCdue6OmhstLVflg16zQ5tz1s9TWy2zbRx76wnPO5jQ81l4NDucsjFkG4DQw0VWOcsMPDcijRccL11/Dp+Wib1ETQ0GY7EWNEyGW+/2VDBFb6B0vM+umQ/DZoiMepBxYA5OPAdufz8gtgOdpkN5yNTIHp38ytlni0GQFTzVRIzeXo/eiXxQ/S7IGsOEleripNqshDLCu+aQOtOodl82fWF3FN0NVSEawSzGjHjVEaLBGYtvrezfEjWP2NF44uYx5ErASbVxKR6Q7Uf5CgXn80G8/MijEnytWeyYZ3na4Ujox/OEgOEgQIdQwyTyjj9YcjtanSKxXMtOZpUR88LEZ8E1mQL1UAOvCzPUSOcisEvtadINFuKF8HbIiMF5n1CcPuuXPextSbitz4/fVnDtKuoy7xgKCh/lBQwNFa0Qev+s2+MfM8rzlx4YgVkCwGR9gYgV9HeI++hppKvMvVLtl4p4Ffy+HZLzA2x7foWOEVLU76uMCaS2nU55OrplerrHD+kk+vRlPc9D1m16AzpqzMgthYVOXiT5IgpxKm1h62TC7tqmxCNROk2oPOemBKlmHYcQcZ2SQl7/4LF2gn0j+9iOjhgALKwVTQp1lIIQLxT7+dkIU7MCQSIdciFCfo4l4WEPVOGZUFPYMgQExe+sSxBxi6Onvc+/2N7obM2xTe+mbkQYxvRg7pNKPX9Zt2okQhRW43if58IzFuaYC02D/jPj1hkGVh0o75g4PNt0JsmRqurIJn7gktfaOsVJw8WrdJijP8GNwo78Vx6RreMz9ZFC2iXWvv35Etm5o7aB84gYeqvWCT3Ot2iLwBdQN51jHkf2qwSrY8E7SaM8iSdb7Q98b7qHapjEyYHx22WDWiZ80N0ZkX9S3Im8EcsU5JB8s9fOgaiBFq9YSMAl07AN1y4OCUVVt2WN8Fz4kL8WdM2+EXa32faE/fwbUkUEVR9diNne1UjIqCjAd7qU7BQINHBrKzbWxxOCsBn2uNVgMtpH/c0VcaP2FW+YGcbGj3K8YUOkrYmJn2JwPm8+DCLd2vxw/tX9YIrkH2AFbNtuYrIzEzXS3Xm7cf4y8z1pEGC0S1ckvgBQfJJifRQODGawq0qupiGegzLMlHyV4EjNZiC1T2aXw5Jzd41+d9WWkqZD6KT2SGTXjrcCn9zueXi2hLX7QErs1qqLDhjQ1auD1WzE990g5chW71k5lYerSXZpAefwNunIqtoNRYFYQwRC+YNNFjwcsZy4Y8GlD4TjRcgMLVk2V5q6rbgBwvF84zUkyeEmzlV59ARzljQ4FCuvnKHkPl3ivcPPoYpgBeezoYKzQtWxcovBCrJ4CeZHEjRdssvCmJo6st9W2RxA/mmpB8FAh7gM4QuOzvjW2VAdivN4gPNwp2472ObqL4npQKYzhrKAISlpLLVUmFHQ9PP/B3Yn9PgbQtyIv6la/bAMWs/peREXE88jS8D7/Na8SPLe6KtpiPXMlxvtFTBxpM3/K1npEyHGZHCXKvhT16CZLv2oZfLAQJiQNwNcSEjEpcisDoO4jMuTzmQq00qBNbFlPPEl4yylfXABYQtxzknGAcMlBGzEObqHO2q3b92JdwRBhYqgURkPcslt2aaMVDFA8Xk+wc/lVFg5AneSxLMK4mr9oAFbH06DA6bwx057qNetXktz04gVKIOCX4iVgEXI8JOAtWEWATuFHMuLYP1wltrbyBtN9jmhXR+yIjvKMAgMYKz5oFkEdPpWw+KPVqMyppywNOOkf5QZDd0A1xNjUQAjePuLUy2Y9bOz4QYZ5YgXx/llMmh2pcvu4R6v8IbRiI4MnMC/8Xr+Ni8Czza3GbQatXYPxuXbDP2KfBL8lZwoS+yynThcYbrpxK+ZnyrblIbJHRGcWI5+sBNS97CSuOeoUNKOVQkLXsCc7k5R7tVHyRbzr2SgoXsTAE02NsuOfZBQBkWgZBNLvIgkJpw0by5sz7T8U6qTtPiYPBbIOKz7TMXPTAvevv6eOJh4uC83sM8nBU7IjGoGgtUZaxRMaJdJbaW/V71OOLydk8A7KiG0hYP+BjF9uEVLRU2hlyGF7yT53jzvqg0IIxpZjOpvJw3Afqrf2EO8XsGWT9i7YDFUtKYdf/hbAPdKC2eJelCmOKBorrjlqEPZdh5WDI/ATy0KdLLxf54lZqK7SMzNVqObTU10s07IcwrFUGXlmBWMjpB9/GHpmK4A/FhT5VBtXNhCcM7fIopcWAUW+20xcWeKp/WAgHQHRGmQ2Od/Og0TbNGs7wt4KOe1D+Ym4aWr1RPIx34Hv7H5AGa3dpbIBaBtFbn08dc1syAR4LH+UITfKnEaNVEAqjY0iYzpWP7xa1KlEVBRLT0ErLSdvRNzg3IBgb+3IdWSoHOql1x9K3OgdddcU+SfLG2J5q3C6jlf2QVdvy4bCuQxf4MbTTSSxB7TTkwT1Fx6M0RIdSChg0AJvtxK6Lnc1k0PlpGpzb6ln2Amv0S0e+ajQw45Y5Ez6Msw8fGXaOgXLrhD78EHXEizRN4aHrYv5ZY5t8QlrCeQ2pqs+OcU3oz2NrRVyfV2Xtr927XaLzyV5GrB9STx/P0YyHMcDb0hxFJ4Z2AAHI7j/Yv0MUhBz1imb2erQt8gbbD3v9/AUsW6sNcxiJUQCYdIcGfVHeVe+znpRThVtkAJnCVX8BEhNgn8mHjK05jAKTtg0ml6AQyABucsUr2waGcp7ivZYyHD0+VsyU43nlnjnKaf/D0wecwd6PifLKKZ9LDkE8UPai27R/XPL5kFLbm1k6+hfAxKyX/eqzV0p7IoEpR5rvJvAkSKVsX0jbyoPAdMZ5r4rCzyJQmusPXOntgB7Q7sE3R+2eR5KnoaTZAiyDGRw3tZuFRMDh2zJrzcxnY3ys7eemGKcjgx4Hl8z1kHIy80hnN4q4pyHA4QEwll+o8+RioMz1cuTMW3BalcjnIFo7iuEd1dKy+dMyXtuwnhciNs0MuekSHLjaOcXWFp+zdKvp77rOFxgpEV0TrnD7FqFl7KQQvTmw6xZBIP/mf4wdxuU9j88WZ0ebWG2KQuOaak/NyXaxZ+JQiyy8urUZQ7W3ybslCQpuUXYLmSKVIMCb3wyruEQ/9Ulvs7j9QeX5GrO35YCFMtkmA0tg/svDH/HhOayf8s7sJ39O+eOfa9BE1G3z3Z4afXs2iqA1C4bMkgyW4Ys8F6NVE8lroEF0sLjvdDSqvVshh/yM4TzgeGC4BXUgMjmi71wUjopoZotFlng9OPeqVo6kMnsSIoPkpsp/xWL2ShdZJRKUmTxUMKUSt3MA5os5SiQ/FdKknfzq45rjlO9FFeOxuPTwzFvRDmweYSMg8KT4VeAG8vZw3vpDU/UTpPUx1Qi05yLn9sXmZ79jInHIyRw+Y1+9w4RRTXfbAgq7vjccyCyMvmspsAqK6yszD7M1k2WZ9LmfQ98sazuxC90EtqbrfluvsPqjJKUVOeUNTdoEMfxQD7t4TeQCEAxh174ssPEWHjNyiuhkFZF6ikTBhaEmHlTcc8P5HC9fyznnkElkKdiJ/ETznAdsLISe2yovFYI612V5d4VlzbclQOia+yPJMqTn4uQ+5PpKpcfXCbL+coGdzwriKvA2wEPIpTGsGOONoZ6LM/pt5DPnN1irJMPwybH93mKTjKwmS/JntwStKZFVxjkUl3mTtypsvAx1b6BMVyxbtELJx7exbqyo5imimQNbmZdDMPppqQAb+1+d3D/tLonIPi4X8+0bITGKPdP43ax7jp7M4RFnGXeZgT6ACEJwDQlsw9S5uX7Nh7JRd4jbB0pGEvZpunwGtJi7kpTNHVOMxAZPaciOQh/UrR1prDTw7gJJLvVQLcpEHPnpMiRWTVLz6TtSUWv9PQKPoElCFOrOvIByJh8MxXfRX/pk+4VfIaIiTJr/qCUzte3oaJvnVYmIR3thU+8pO3JET5PrwsGydmk++IKC6pjWTgnEF3fWcjaMvOLgTCBlKAeo4slwxNVcR7S0uMqRiwqjNFRdGX8Dd5Fmk8joSHmweLop6nua2/2HdchjfyVd1voNtsyw8p0YHVB1mOghDEJKruJGHh4+Lj/s+I2//tCuvM90MGH8Tl33qrnIDXMcw8e968p0hkP9HzGyaVbZi6I3fnC4UgJ63kzo5L5m0dw1JB1BXeLc51DtOE5jA/RfPPzsO2sjLECSvPzaDGqYXk/EWSOeT/Q27Zrbp4PCuNGk0RYieL6xZds9l+6aAjmFgr9jxmpFRI1TQJS5QjAXXy/q0FgvMiJCJ6h4JBQZGgr/pKU7/15/4txTl2ioetn/3uZoGPa6fBYZtqFIRJoXrEiB1A78bzFxPFD1XgvtDqZQkBAlKYAxUrrnplLdPkL2u5TcpiGJpqYLADYZ26orQ7lldVyMt9pf17QQre+U1jmBFOLFkXcT3pwCr9gRgKfw5aKfZi+Zc1CbNcXJxDMnKBgGY658h5BMgqsRPw6PYk4l+03VoqKOgDRL0ALcuxZ/YsBR1NAlcdQBLzX2zIHcDBuOCWelWDFwieNkhbchoTtWqFwrWAUypDB2lX1yk57BHUslRF2AaGSZIlNvKRbpUFiKlmiWQFO4Ql3oT4KBp6pFphqj3NViuc/fC9ABbQky0+AnyB9QcSzzBeo1+8KcWzvApovQoP9Jv5qDiz5zxS6YvAf6jLQVuF3xEJJYuJos5QC1acA8Vkc3Gu7O5cwcuKyk5NgosTMMFBpE6Q49nULBOPo8hnHgygDdEYFkVSEZveyNf6U3IRNPBt1rhg9kWWoxAKsswWBLHClVOI0+G5bVKm987z/ZCRj2x3MCb0uhQDX8ghDIeuY8Germeq97+JhVvnVGzZQWytJUd4A2dbZcRqOvqbTdtTfWMS2SXERhg7/T28h7HMPVwzYAlPAwtveJmSaV3qRDDhYQJKSS5EHQFNghf2TJ10WGv11SanSaCj+O1ECxneLQoGN/cyV3h2R8asK5b0Gslll1u93lWWeSmT+0a41wDCfprNBHm/HZnJVmi8OFXs0L3EIq7rMJNISJsIFab9ui1vkNLqEXwh/XYASZtkciTLOk/9ziD828qU0K/e1d+H3OYTswtvowEOxS2FBAM9KEvxoDD4g8HW6uhF4eEuxirmfGL6nA4UmlWatC1nICF+bjWvQqK2HS9Dn6aMl2hYoF1WzSerXFAXQF4JKh7wgR02DBFmWfPy2OzXKn7K4THugryXuN/bFMsVljaxP64VZibWTBqXaYbm3iv8gKVpzXq+XvF57aE1K+iVfrry7X89mH0dVdN3RgK/x62I0PenSbrt6C210SnV5ymi4IPyRVWWmf3mTpWW5bYyTa95YdyvLXuegbifyDiml6oS4x4zcqG9HuQvY6YVntjRX+aO6z885vyRLLJDtCZ8RNsXjqo2c71J7P2df5htyFW7+OOpRi7E6oGsuMiFF+G9OROZuPjCA98Vnxi8TDx6whsKJHvP5hIhMJJ3F/+z42CH5W7c9VvIf0Sa1GHasK+9s6VxI/uY6mN89W/pfjqtm/lT+LbIVFygVoT8w4bhJXNNJIwRG1bpNpTso4rD46xKkS3undCkyioDuNlZKZaYncX+OVJioxgetggHHVhNmFuHlRt7uzlOPeS/bI6iHmqs544kh/E9LMUrqtscsgZ6a/2oB8oFONbN8INrPNfmUHuubJ94ncMj718ba4kYspBYqEYOz7BAbO7+ilinRODC+PCFqNou1duM6P+azXB2/957C/siz8sA5U6LkpWz7S4TaVzZdYNo/0fTY6xLiqdW3oWlPMzJ+W9oku4w6+53dHZQVYwbjE5htdwcPeFznmW3Sc82JskgYcexTRQnEX+w7VqxZS3VmYqWk2npY8HxSYmhS+niPNHvuybM6cjm+slTQv2BEn+599GhFO9O/FEQrCMdR0JCTi9Lsgwy6sgDBgQ6q4z7MVhgTo77JhHk2SxQyC1Ias60GWe5ZnAZoXvWeIahxcguEg0SmjdD5ap63K19R8Hw/PMC2/VISlZC+/Lnw0Io6aNFymWGUs6l4CntLZ9kCp1Sx/bPbKpDPLfw6m5+rGZnk0elyMrHBSljA2/vjLgow5GIYchsChyV9TbZ82SSdJ2aCiCzdCS977ME/7Uu538Sv5PilcFnL9YMZgetGdMejxzVbBKEUtrVSwlg2UYHQtVFHG+SpMkzeEeRq0jbXCuWpvCee/gSYrn6jPhXRlgzrNxUhn/Fp7JfX4TkQ9IINNwUvf/LrkJA7bY6HjM9jlKMHA55sC4sFDXEVaWI8pyrzMTs3tSFzXRFeaMOik32EisqKHqWxuuU+didHNuUTGnaQ+eKaPCXsvCPt8SKQLWBOQOrntVB8aDOrlPtFiqRFwdetTB2kyEeH3PYyhTadeKTItGHUO38f+8KbQo12k0NhzrB4RuN5jg/EpRet0uiivXZpFS9nvza8bdUbgw4J1lqnA3N8T2VmgqlxNKjhTLWQTaX4nv5WpfqCyPBhPqoMNB5nJ8rR5cW0JAQtunKhX3vvosI7pgy1GKxnNIfFq0ajIfyd8409TfE5K0pzIsBJ2V4Jsn1J3myIxmcJz2cc7WIs1g04NxoqdpG/3iulUqFaYinF0ezaybR638FaqCon5hHcqzZh482+V+HMuhiFeTdefy7Nt8WIdkFoZcp6iSRduLfcfYvRlc9NlHfnY9SbOvX+66cPxeD7fP5cN38eIln3Z4ljWjhd1eU1SBFqTPxITNZ/jhm1BjD8EzFDgIYMdb4O6bnZ/P4VAdj6ttyBXeT7II3VxtD9tufBn/KWEbGpttkCrdrdVRCmNJYmaPs5zxM3PIWCsNTN+LbROeu0bpNU/vQPVVYqO+YiequiG9fm74lUdmCO5uLTmHbg1n3tcu8LNUBum/OA6jwt8tR3dsruF8Bda9UjO7JXIzPIASpmQawpy7KWPgNg8oALA0s49anPbBOYIXg/jSnfllMnjinsHyi7X5sSQOZH8IVNHmdFr0nMyVRMPI2MJtIXO5i/cqgDLTyDm2scl9pdMfjigkSOxw8DfMUU8UXN1uOpKtUSN29nX8kkvHcjH1ZtuZV8W6O/4hak4aqenueAYwBIvkNtrpuOiqFbQd57WuwHsmcZEp3U1raokuk8LBt8danegaMFKt3JWXyqM2HXpYTTRm62YKsG+QN/ePVHxuZ7M1OVgmO6OorGcza3xGs+7YuvNu25pnkka5/V+c/mYunZvcNmeXKKU43YvHbmlGU55QGapeLua6e29nsqFMGY2VxE7ObzeY9lWBWu+4thyJZP2P+RmSL4Gdk0Cazm42nFmq5zJ1r1RTeZ6W/S+UPk1pH9zrg/l9hZziPo6bCXXiEzesDa6KJjtS4ftJn365J0AoVr1hBfS7Qs0NDkqySF58nqCdY6YPEIbvbyJLMWFHX3Y086GOjMGbBy3YRwdPxs74Agz75C97dbsHhpH29acpgyv0ND/XsMg1jfyleX7oHpvxk+6zOvx5WMZUUrdXYxj0q6/n6aZBLxFW32Jm4/xk9aJDDd/SDaCkUmeYxsuEYNDQg+ITSg3FkVI6/QaentBsekY8Q7wCfV2MtIMQzzLN0w4YcbxCE2xp91w5/ZGJ8Fe2HnQ+4G1J5Ms30/1iyJ0fRZORJSgPX32RzUY3yghcPT+eKPI5ngndcVHp1a+LklMH2fQPC5UtPeXdWf2RC95Reh/mSj6+9TBF+0m1lV7xMD1uRflVxfzTcOKolMLuq7srkbsHRqCJkxtG7krL7J9ZW/tffMOIrwtzj1V3RRHOlS77T1D1yIinHonRFFOB2K79OhEOH/Q3vhvEIbmgIvj8I35ftL7QNcKdqtGaRuHVFzFEsB6RDpMlt2zDiscAZ/91+Z74t8lPsk+H3N/1jSorxYRrPrq+whj8NkpgpySivxIwbpRh5//ALdrcJ5GWw+wPWrD0lp/LAHvocaPORBjNjc3qYi6lQHerPU1oEuec0o2WcVa89DzC2DUwKPC8DbzzSWslYCPqwjVzBIsL5yAcYrMydpxq4T0B/16f1S/KMdM8qjdkZ0GkoK5no5dKupgV3frWtOLKygCPU2ruKQcuirMpCZvlaMVEPiUm7wkvXkOD43pUfeJ0rTGOzl8Fvys7cypalPQ8fbSyvmQ17usfN0p6nQ6qW7VNaor84zul2s9miJY6PngAUaYjV1I9tANQpedXFZVwJmw9V35FNTXsBuIoG56OKN7wyp/2DsrU3GPJ918o2byUZxa6km0WtZapV20pGS+4oMa4xurFgKOcC9yKZcR9UbHULxKtdABmCCwrOBctjXiMf5KzyIrvuwPxJFzdLtHsSZvrh1oIo7oH3y4zbgfAaHp3zGqQlE49c2jOXuVp0uqr5JgGMQV9T+A+rbJQxSVer8W0FSPyXJzgZHddfwlMQsnsHxPaOs21iyctUabpIWvIWHrcWYbO2qAB93743k3GOxAjVpu9g1XXOdlWYxRNn28cDp2C9tGE/ecut7xbl1OTqonepCupovHsup2WMWStwapNayeCRphG5sfXzC/EIwJU92JEk/olY7faJKBIA8FKIXlWhcEU35s6c39IImxMEdquuY/4PftDmTOyVa2hHP8paXY9Gj0lShcSoR9pmTuQiYtV15UxlpTcC8Ccy4lk6vhSD/ZpT4F/mVl9iMWJW/LT7D7z3bEfdrLlMVzTRBHXDIlT1ZtrxSodmrFwu66sk0vvz3zwkXq94tFpnPAzULYKJIx9hyF4pj2T7ExcWKxE/i5DFuQzACaYQKm5ttSCtanFS8zSGfF5QlKHW4chppbOu3g0tLsGsDD0ErKR0ZIq8lrsWFhrmsnZE39X3LCBQguYAJsiGxQQ6Tvy+Qze2r18btRWLucO4VmaDVCqhjnOz6t7DeGK/lJk6moa8HHuU7LJy7XmskJnQAYOSQRTQmwUWLg4YsKQ6cvY00jjdsOO49O7BrWtCehF6gXTkyONcma98dVgUbjsiDyzxl9ByqVytO+lfWBFG1RMlduMD+tA7wrgC3l0bXGwQeC1zbj74kSO/MXHzLavPahrt3La63kwIONftF79nX/tHhLi/82bh/7pq8IfOuOBZKBWR+6GxvLtQ1DWUYBqnyUUpjUNuCLjwAfWWrTQ5hoz2trtvtz+LnrPZt55W05kQLUzT76+I3d+k6SPoq+5cUpeQyXDosP2TN8W+i+iU73XxAYv9tt/x/rWPndYToFqLwikMY251CveCPRgr06Z7B4L4GUywm1bB/xUfioW/4WubJ1GOhtWWLWbOMA7R4k5M5PMQTYlffj7evdEPO4/sId7hMouWoFEhNehSP4i6tA5DGyzSjFZKqe8uWkAidhI7v9R7culLE7Uk9ZKXtFfdWcuWBxlCCrlEmndt94P0MDSkvOZ8xCXNv4g2kVGHhSL53gsFEmeb9q+2QgHzb1bOi4i6lwj+t5GJpf9ARP4PSl8Db5lAldk8XOaFiVRTaQAAfl8DhWhypNQjuIyGImkDtK4QRfdKCMT3VlZGnK/rHDGv+XiHM2enGEm7GJnymTEiV7VQIZU4iZ5KnY5UxVMSln9Q3k1oNFijWJLgLvaUEeRIkr7jztLjMZyMvDxNjsy652QgHIwDR46g8i2pOCl+fM5l+iP3vrllQ99RUDktwPOClMlqg67BgpRsH1265eFFG17kgFwnqGbaDtQybVMjNjgxaFjwgafJ62xdWZ5GjVhSTCjUCXWBgZGCdVYsjD1BGP0/9HRkrFQGj/WkbinDNTUouyp6OVLrOSZgOnzEoX9f33qLW+/yNK2wEsH9yDlO1ibxzXnpMe3p6MsaYWW2o9kN1BI79g7EjL6WsH6HNzQxUpEwY7wjq+Cfnz7hT5iY1PwoLF8VnHTvfGRMuZLcr1H1tjxz11ORKacX1e6dPDCg4uMF898fA/lfZ5r+MLLloqz5G15sfT7yApnBvhaUTvvRcZfaa3CeJooyvReyCg05yasFhPZMLmUTN4/dhpdtkRjngSKN5Z82UOYHJ1ili6Ra6zARVfjX6TIaMaIYdqLdna9ZTnyQO9IYBaADuUjTDT4kthrdPxNrbypQVSl6MKdw0YEZR4tdc2nAlMsFC6nfzcvClEtKi2BmjXDMrE/Vkg3g093zYL2+4hExactdVJzq2Az1ttK89UzGvG2bH4660Uc2aOLrj5iVpTtkqj9DXqoZnjQ4x7vucc5m2pwcgG9zFsv25AgB1OJNuy8rmIBaJEqqNYwmJXAv+Fp7+ycWt6CTXujxYAYJFWnwxkAg0mEsqtsjMf+BWi8AVkojW4TMVtqJvcUVlJdVIdC55c3XCinLHPZkhJ1v58PBH1HysmiSntT3PieLW4+CNP8+XjAGKJyos0FO9SoneHEldeI1CjY4qiIozMxq2t4XZfuZLLXhUnZJQ7lCRrBiasfbvxtoCLotKVnbEPvMscdpSDOiCqryBy+7OtA9oj3HLKWIJgdYEea2se4JuSjSnXdJR+4TrTrncA1gtHlPHI3FhTsr4LUlixSnXhnFjpQY4x7eFwGCucABYFE0RM2fd82SBfesgtpR7tHKsCOfqJEYlt1ESykKBLcOkcithlXsu/Duu7BqcOOPAveY2SZu9ZHMOT9JdelH9k4i7x73sxrw+RXGkTL9baM8lxwrMssLy5BIJoN9MN1eFkRajhQOvV9z2WXSF4D9l+Ez9nneCjMlsxhrK6lCi7KLKhf4kyyXsOEthsUHX3y3B2mdLYtKEVQIT/7KJo+JsTO+1Aqp2ULZIwG8yOiWuMHLiEcZUecxd0IpvUmKbGsCeceSOw5HqmwZkYQDJkW8j7T8vW4riHfMlweFIfMKI+NWsRL+boiPhUc505J8km9h9gGawolSUmwWUfWU2iZHZ6w49lyrdja6Q3y4qAQPLP05Ii/qDnwTT31yAO2vEuvSXypzBkQ323tKACBnqPbOVjqA04BHcrih0mTIVpqCvt/U+c1RUC3RpiCjZhjneZc9ogqRD/MlfOPTuiupZNeD5mWJqlkSUUk1uVI9o/5eF1w8kafQsw1d5mkASl2N4/VNnerXZllFNCqWm8xa1hcXLL8XLUqeJ+qZhQkS5ndJa5Z0kMnEianvZ1ZjjfcyGp5nd7JmII3fK2RcVttUec5SimUhlfeeqEGFBkOLWVD9LG7ERvJa2wT0GE3oQVR+8MX1ifXHLKyMConmwUYCOikFvV2YpVodyrXiplCKT2SIlLPPbPF/mQkB7eraV7KwcgEbGAZHGNyg50VSWJd00aLQWGDEAf0ko+XIz16zY1LpbDK3tVKdHR+SJGLK6yn6fvlW3lLfDUYgBD7vTC4POaTIkXcxShiyYHuYnSpE6dhhAYVIpAdqHuAZ6BMFWzmWGWTajHI6PzeF+8+A3JxmyKdnhnW76Lo3OxYTUPvNPrTkZQDrZ9z7+chXj3PsYMPweABTqwCLfIF97+2YhHZ5LzBWqYFdCkY3uvOylEjCSfxUz2B0Qc0nWy2tKYnDXKoAyaund7bEBkW4IBcyocH8gN5U5gTT6R7/tevNHkwt/x7EcQ6cGbJ29BL2pXNS0eVjGZWKayKRFtVLv3uq1QglhZa46yn3foDhuPEXuqFLkz6ktk1saISkcv8o9cmfTuvH4aGxfZbui5/ZXR1D8Xi9EWfz+8ot7y7EQCboqrLYz+VcCIUy1Hd8KRKrUM9+hSQfOBRTsV0Ksz10ww22ozRSi02Ur9ssyDoZSyX30uLbi5BTGpAaWcEd3ACtFzwWXXOdrIt+d3kgazt1dviCAy1FyVXi7oF/JSJOH1ePuLJuW3szynPvjDmzT1PKI61tX0KijgkXN+wx19g84tmz+sKUIhUbF/IYAynif4pREqkbKK0zlqv9yoe7Jn/tcyl4CkblKkFdXwMZKxDJ5GBeN/v+wD8CadJRWKYp1hswJOkrVEmV1YdHkOxBGuWry1AjASFg3TFD77sppiB29vuJCWODfFWIJpdzi2gt0zVYkoESBM80rOMO2PNs/QkTHS4Nycz0r3xwOkxEHvUMoyWK5NxQfSg4I1kgSe1Nt6bPp0Ojdqacuglv8AhtDIYiiXM3OR7VvZG+FtSuDo3i7IYxzYMpr/+pZ+NoGunsaWi8DQ07sKKJHi7QDWShq8PDKo1rMtum8hJvw+9QjdkNBVdVxeuAKGo5n17gwAFPkyavb+gTdZR5ce8a5HL+9Ti4qEwy3GtzJm+sMAitl+REllfMdwSAEotRO6dwo7Zn7lGsXrqm4PY3vOUy48N/rYdLKLvDNk3gsOrOJGLRvzUoKIHs2X3rGkaVljs9Xuri4dQ1KkRneoGJ01DJ+po9NXJhSazD/OWt7PaEUMhUMSSurAhfIutnCF9KhMWAx8sQaTg9MycmHw9NElz9gKS83uhV31+DyehmthQqonrtg7G/liWlkn74KMLK5jsYsZsQS33N08oLCAqLXiVVKnG5zf+Z3cDq0u0qT103cWV+oCV0OIftaRewxP1EvkggtGrENJ1lMfUy27slcz8y9yJLnA1nt3eLt2MayBYjUDwYOx+oFk8OhpLU5ubsjovPIiz0mO6ZzjTXbAt4SaLmhxWQtq4Zp1FSi+1QKRLRtXS0eXWAUYZU7Yu2Q8eWr/U5LAQsoSLrPGABH9yB6jedmEXqb6hKnIgoRsO0ztE+RRqOuLgOXMKus7aZG3zA6jbQi0u+x3AknwfMnlf2rYSWpMjWlYr0ahzv2tLvswiFDZNEZTyQGsD5iqeDNLI2VfSBXtuOE8gDpETXL8eqJrMAWoCwXK3etHC3DOvinVXCz5i0uLQT+GgxwC2FSt4T7q/SgCYhA3vDt67WOFsskv3mXC0G2+lS2DDmBYV3ndVteMGbkAlJ1C/2ZKWEfDDBGzfCawq0vMjLVNdL+BXHU1BR4HiG7t/IgItnIn3Ri0M6G7Y2g4NBEPxZp0sskvFD9BmOOyzhAZN96Z+QpuUPPCI6FpNn1gYN8ZMKbO6/iGnKXxeKjIS1Qw0sowEKwpE7vEHfoCZaOrZKEwHXiGCXaR8tcfYGphUz00JmHox2PdEFF13MK0SX2cQJuy99brDHBqU8rNczp08yeIC65VDMjhm+v6YA+HAtNAwVmWnQK3tuYrJPh6YVPaCTedErs+sVOKBGxjTihNCYKin6uLph75tJLltXNqfMIeYdgDNXW6HzVzpRpYxF7kIGXBLS2aWqR3RmzeM1GbgyTaIeL5bfy0AQDLxxxKpfzRdDJVymWsSJQoYN9UxpSTajlLAQ9FFixvkj56eyvlbmok/GAa6oHlfQMp9Ey0pZGeB3nO+HCvbFuW9ZY//7ytegPbhnnyXOw6lttCzxUvZPNN7EVUNyV+iTQyXMMb2kTp6/6lJaWc8eOTNGLytYXXBOWg7mnuPMqoQNeLHAUOCgHGMEj5gyL7g8N9FWjhdDOXMODEwfpnvmZY75lNyvh4HNN55d+3ijLz6lenT58ydQ5ZO3TpHtjuMM7PqJNGjQPOTxqruukz289g7l1HP7Ir9XRSei997pnXW66gkZka9IEMIDXOR6mSzJDTsN9cRK5nqfTT9aw1SyzoGzq9UxPb+K1zSdwhrRGBV9dTPQa/bgBRfXMhd9sK/+h91vWvqHnh7Sl979p6/c/rcZnzu7B/Rqd1n2fJwrxS/019jf0IcrcAh+p3XhEydSrWN/Vrq/FPcCnsvCUMLvON/Tjn7eonMASYAMQnLxeZ1CyrF8vMljMQJf+5mwe5XE5/KHEE5C9/rjosRbdJ1wHlvuFOA6MttECfcproUXa0FhbDt1sKpNIAM+uATfLKbkx/9oWsGmZ6yY0L+8w6ByrjeuyDiw/FKiCXQyXzka3ahWzEh143srPBDninhiAJE7iWvxW5uZBu4gBW6m9n+qyNLL7vzcynHQWyoecsM6XSGi74NIh7OZG5hosqE2347U52G/XaPNxFR9+ygzm6z+r/7sE5oDJ/1C2S4Mj3Qit0SETd2hEkrBuPeQV4wwCahqcY2qFzhb3L2j9iJbVgibANrGd7L0MDIUkIzOxBlqW6PIbepuTcVzPb5lIy5jCd1N0ulroLWJe92J0Gc0S+y4mgoR7jaFY2KYbU6sFECr6f3swjuH65gccuPkYhUj56BBEkT4KHqoUeTGFKLbb1+r+NCiDMqClWuNorwdLoTk4XzWgru55hee6YtH0c6YJTbqns+7kKLaQT98UpmyF3PUsDCYOOX4/DBBZ0cJo5BNMLvpxe0cPD+HhPvK8zcS26Ule2ISM/vRLR12//M8ucqPvJg4arh9S8gEGJWqaPWLqMKPFzO2nVTmFkuwJv7kZWBUnJVP/bO/celCY8Xf/8en0OCddDJ/9prwb1wS3nkgKQT86WNQH20Ki3/4driuyUYOsNjZBljnlq94YhJizzoXMMyIINCrI+v0o3cBtFgGscsxWekgpa7gNoebBqCjEobDhoArAWzPHXGzfeLSF9qld394DE5Nou/ivrKbopDR/RVRjnPX/sB11ya4LRnn0Bal6s4I6HWZwxEt0ammnTGDHl266iF1myIhj6RUMaUXEgmVpqJWNSGo7lyyxe+nyDlE/hxs2IQOhfbxG2U/+KTEMOnVtiZvxiHvXnbvJNDRaX5Bmuuh3NoxO1J7f+CHmN4DUrH5MlPEzoSxU6OKUcd3idJLcc3MDkxxa+7s/DqkbHqT7PeqK4r4JaKueOmGG0CStYcKxq/dzognG1KLDC6qKpvRdYI0P+JJN77HgdgBL3PF9m6Hu1UsiaD/pLJlnAY8RY3FOILTUumCaBPO7ninLUdOjl6RTogdnA3oQafNU83Mb/B2n3EQa8NtROPq+/Cg6bVRmeJAk9PXe/Fs3xPHIPDOVrKNIfyMmcrttuAQEt2C1/SynEcPA8Z2KdKfFGVoSCAEgg5A4jVP6rEC0DAz3fxNYg0uGrOTmTACqqotVB4llky7BtihViDDph+p7g0VoupwI5WcXGOns3b1NeTDV7QIC+y9MARVweBYMTN0UweDcttmEfRv3SyYU1/9jkMJmrBSF6qwrX26HodCIAtzcKm/Jb9N+x2qrW6dSG/iCTYYXG1KPk8OAmVkoJqQTkrTqvpxQMWgUEgMREIOT/aQPQd63N9EmvMx9oOlgcVeGyS6pTQv4/5Kre8L3TKDPmDFHBG2LK2jRpnFVT+gMOirWzOda8ZRo2SRXWqgUtLNtaLe4lhINorqQMoptEwl0WL7pLD+dgfcYva4E0LvFd7z4C5ZSV6wIfTJCBpCHQWbeHobTSZHSgFDl6sOsogWxD49mhKZVYHELseoXCcHKXNrYCRyv3lvteTgNHNiyqE0vPPaXdinu2LhoWS8dEqJ711hdmhaeeFsS/TJYzl4Jkj8tii+OC0XCGW1Qshmv/XK0gr/dyzFAqJgwW6mLc05VrFe/5RDQ7l2kWIBjlrzk16PfWcpzy1aN87o83DonVevZwv3mdeyDWYavXNqlBCd5Fq8q98zWsirUITPb57t0IYayoPsI2Cm/vPwsiZKYahp0qi5c32/qV3yUGBZT+TtVHG7GGIDc39lUbmR1Jn4TkmccnY5mQptIa4cZErGY3N44jV4JxmTRmWqnVZpHG0zL6HOAFfYZOqS4gudKBy5vZ01DkQYxDjCCuBCL7p+vcTQbvhgV0gLqb1IQ+vdRIxuvrKFY9MMoMdujz6x32MCgCx7bd6HbBAYuhlBtVElUxI7XD0nI5r7jFTJ7TMHBGXROoTnrbqq7QLv1zewaM93XQx1qEGy8qGo1dCfKVOiwxh7daNhK9aqdp1rYudyrzPqH5fnCXBz5yFJwjdGT5vz6whqUFLUzD+caIPBQ0opHHYyVeqgQyYw4ciFaxohOSshJQS1Bh9DgxIUZSLh4GEbVo+1bWcWzZXlUypNWWFf2AfCQnEx+OrQ1p/Z/KxT2Ds/r2RBea36v4OJvIphA/MgZBjFSjC8Wq44Vot3reO951YOpN6+NT2hBsUFy35OWukK2rOxEKO0Up4+VV9rJUJLqgUoRZxtfc3O4dFD/DW/I8Uwkfn5/f+CeuXmSYEuhzVQnvOjzupWwiN05VyLCTpEaRpY6sjXDFZoNWTkZEROxGGncaB3Pr+nQ9dygjfKaC/xvNMxXUuM9hrPu++YrpXumxsazfDemT2vcpjoxMZRTCLScsxoq0Nc6te8iqGTa4K6qDXonjhWHZn9TnnNG6a86X1bM7WCyKc8HDAZPSN8//XQE0ubeXW1a9BtemmFFaKZWh2HW+C31pnqwJTPVLPp5pgqrElKry+ooxPWcPDhJmsd7mWWWzHVBd3sVl5dSjLLoeY8vFemO3qeIyk3HlpMh6HbX9YBbHrA4EagUoc9cjiqDh7EryPFbhqXzK6zVkRucmrI8JlY/IR9R2luLlZeHQkeVA5WD7Aa+aYWft8dfld7xJlb06iF4Z4kxYGpmfHma+f1j7RyYfe+umul1U9GebnIpzaHm5H2NYtqIisOZQgOPOfdyOzn/rmyC/Y+ZQE/rRD6J02Jo0UsQbfCZ+SAK8MP19GaCDu29ANT+JMobzociFHpOx20ZIJJREx097aqDa2OXKuTcct99nKqjxie0MkAk0bDXVfi+fBr70m4hxaoTmPvTPW+K70bjdwayVqwBJX52l6MkWtbziyWG28D1izhvW8tDrpg4unXXbVHF7eZ105u4eFwM3W05Q7LFuJwrnkXu8UzWHX7+bVFDF12mt/jP/yoCEfB3eXeCHQrr1WK3aSqAxmI0t6xxpcWbjzRNS/sQtgNlOF9UqDZ1eaA6gP0qi8frIjB1CIWeTM8TCVcHIaT9Z5kLQlYPsC1AJ4fSlYEJW7fK9fNVQbZvVeLGRnc+6JULNPyBHaTJJVdQsXlrHCaXDK3uBRXEGrmewrMHhuWrIaLaDgakEFQ98tak2aTIjYa8DBC2fNmAhusS9NO0K0rZTsGXV7k+jZofgb1VE6txv3D8/u3s3tK4g1388apI1PookcY2Ek4qnRG9wT8DWRus9nV4O9ivUa7N8xu6wcsMPJJnLV0EUozEdynsGmUFpRO9Xmwy6lDeH/RpP6RvubwNHNpzf0vNDAg1zAVWjfKznXYT4uQ3sUTODkyYiYR5swkOvoCozysJbvq96WilFc67KjtpvINqnQQCDdBCqbn4EzbVbBEi3VjljK30G9dFj9q1xzBwO54NyOOwJFHDLtchBWRSXNgOKpNWhXno7kvedSQ5I9k+m5t4wO/z7O8lqQzVwU+GCq/4No2/DoYtSKgyt9824/+NBH3d3/fEmHJyg3u13ZhD56jSB5u3xN/hBsMnlZkHYIsCRlp5etU+wisWiJEeP8/0osH3Bt54B0RyFkBdqTAKwKgSQy4mBS48wY4TQF3JAA5egBVCvAvKbDRBqhGAXDlAJh2gB8Y4AcyoPc1ICIAvZIHjogAdQLwDxmwMQN2nQBuJhX49g2QWgw4Ig8oiL5JV2wMAAQAMAAQACBIGsZYMjAeyAZG/x4gRAeqSxjDWjm4x5K3Y/Mv+66f3w5vn9k3nmWMmqwbv+68Z5f1eG4f1Tn79N//H4bzt3p4cu4ORYd39zLILQ/9lFRyiyGdlrXeydOvqyOYoRGPYEWueAZ3KIBX8IDCYwEHKA1fcECl+IbLUQM/4RrUHhu4HtuI33AeO8UPTiEMRBCFeWJIQGOUQiJapSVkwR5UQDbYe1J4Roi0gVcclF7gOxxBb/ADjp46+AAnox1SoFM6IM1xC/pC2uDW04C0x9nojNTjTuk/0hA96IpM0HsKkNW4N7pHZvir9IhsxANogWwOjE8pdh7XGh2wNWzgvrDtcW3RGduI3z76j+2Kf3Axqh6/LEqwU/yxaMJuxA+LM0SDK/ABRY5XdT+SC801umCX44cPrqgZb+YeUYYQXi8oA3yB9yhGfHr3O1G5aqqMwxdwqueMiRd2apiMoRf2aiiM4ReWaviM0Rc4quuMJhccqvuMDBdQaoiN8RdmauiMmRdOasSMARcealSMGRcOahSMkRcmaqiMwRfuarSMEhcYatKMLRcZasqMDRe9DE0xw2MzDGHwz2PBjOHwa2MlDDHwJ2NUypHwp2OsyiR0h8OOyuR0Cme0ClgwHqc9DDt058Nkykw1R8M1asYwDMMwjJo8LFociUNe1yiokzsLuVPZtLQ+Zhb2RGaFqYp9nXlYq94XlUuucMUpSKnFGvQhXA5TR4zJet7Pg4uAg+Q454t+is5unizhFI2z9+LSsLlkBOCXeSjBsO3LbWnx4doeAsUtGeISQKTTpDGO3UAXjOunOZP2yQ/ZwB2YnIIqioSRccuuvuWyiRl+RVR1UOXwtlAHrHDTrYN6GRloa70tpx8ek65y8DGRCMTjt/b3udl8ZfjXbkGZ8PmmdintO8+nar8w6CgxQVkr4iQ7NxlDJyf0r25GUxoR/wOMd95VF6MsnNKIMamnvJEMhHO9Lfre+isamdjtciSztgdP58qyqJxIRXmwN8d7E7c4cAWobS6+Mhkh7BYPN079sN4vyzHIFe/jDANYGzNFktpYWH6Y2UyddgQpI/IOgyMWCZBDQXTuySQestk8aRJajKR/ZBpIuXyNjHuzNNNxIeXYdJGnhxQ6RF54xN4BHfopV/3Xcgl3nodmYm1SEPaOvumTkSR9foG7p79CjP71WM+RsCCNKLTHY9nQeeG7q31D8GluRDTyoDleppApKYE6l8RnxQcJrhWUzG/WPrNtX/yYqP++bMhtjqPMAsvo95I0vDGMZ9TGBxbeCjMH6T7VuCRb/D1X8dqriDd0k8k9p9bH71vQBzyxfO5cFvzRJwEf18E0Ddmf526BU/N3Z2Nl0C5CVlCHxEaj0AqF6nkIzZsXxavoCWAJFuIF9rSefNZm2bzo/0MOc02pw1ddDLO9c1PSrmrYlKn1nFIuVCNU4KFIWQjHyV3rKRIxhZ8eUlVIGYIjEu0DGHf7XbgSSMsb3gnPOQ3gQD3f94Lz917EcEI8idX18JkCAy21r6Enlqh/KAS+KXm7KVxR6wlJp0/r5q4qSHeYiPDk3AYiXmMgibwITmHfiUmHKFItJIZfRjwBWZAZByLr0mJOt8Z7+Rcg2NR6Y1wYP2PKHHnhYqN0+peIYY6R1xEsHIl8w7hB2uNuB/zNIF1zRgE6Z3JViPeU+4nnh6EO05d/CfAVC7LC3GqezDZIXPqAZkPubVTIRWvGDBNx3g6Eorxh2IbtR3xgEpkYE0Z6Q4MAcBWhJP1SSgnmHO17EJZy2Om/gG6XmUTwSAua4k6w0Zfh9CWih0SjLlkk+LgoncxuPAhq1pNOMXOwQg1UaUKv+6RjEEjvSRDbOZtiWLM15KmYMOzxrEByx2PgiZfOK1hEicmaLjKqCFpe9rBuUEYW0ZmnaXPXjs2QCF/6+/6tb0qQ6Azax1CvIBk8sq3Rd7Jz17E8A2n662ZBKhMgkL757FmbWlhXoU7PiGdc1ZT9l7C2L6PkmYgEGkJtJonppyZZvGHk6BNmfUVxusMwDMOyhnukzaQgxmH6uncIb/QObu2idZLN0ZjL4yDanLKnnzCRzylphAUSHz1D2PPaZ/aa9UbAfYfw80EVk1K6wqFqAmenAW0+tNx5TkW3YVldeutPp1/HZ9oU4+LxbDglEqYkm8PlCLich7rr5jaWcVxPsvQeIE2BTulV/LibS+cJY2WEM/Y07q7NSVyt95a3hC3PkG0vqmJXDxJ9b7lt+wiXBE5LLtcbGUKvYMwi+3S8VkVnWsccnFPoud8Uwm0g7gl8LIxKReg+pM+OGeij1LeR56G8yxy5sjJAC1M5APnVIrmZLYPj6DaMlgV8tk3jFGoBntVutXuLjv9s6NiIPjo/rox3RFKcPqVLsujnTLKEo8CR/ILwBGdNzzlg94OKC98atH60PVFF3EFEvWuojPEWHi/HbnJ2wERqASCioYiQVxsei2ZHNoYo40NEQ8glMI25W/HL0L23pGrrwB0WMKJ+cpXiGU3AocDVrUCnEatYYjv0ogNmBd8MEyT0dlbuYBRP0ZVSqjPrIeJgkED0+yqw2ApoEicb6282YP5sOyAMe0U6tus/fJazebU61xW13Y7BM+ZNDG1HC1IAQFo0rXSk8qn7hJrBnJ2ccyFYaxWB2l/fIy3TE9UHnULcqGggsckfvyrwuVGgVmUH9pHmo37CXDb+eip3mtvg1gU8zarFwzveerZ14nQV2AO1M65LEOiShCkrt13p98E0Tx7rmV1eGPef6UyWOMQnNyTGievEpQtkfPJJT0jxdLixVZz8ZjfNicX8gtdlslrUoec0F5fK/6AwRjGEV59T+upSZhhAaLjkCo23ITgn2QgdCi14aHORNuGYtkBh1GqOpKMIqfP1ZqprU6DiRO7KSJ+N/DaRjMf9216lMM1bglFM71YJqwQEnbKGpUIwgf0l5NSRgtCRIhVuNbRVQiq35ZlpUan10kgRt6cavSHit+2oAtHlJ999S3g62rXUbqb6mCms1a5HZZ+DcgPZ9FVJa/G//AX5tMTHs2MngwWkgVKiJNUygcgYOA4bv2Tv12MDSBABBlhNWLOb+yzzPNpnURHVyS/BHaGeecrDYnfBfoO3dTBLE7TERwp+C13gsVc8TGQ9GZ769Ts2vp1+UpGZ3+1ibKNNetgAD5fXB780mfQYEU38b0sbC8aovldn9NC4op3ilxS/W6Az2K/IVOXlekEHyRiJtE0g3lAuDcprCNpDJ1i/rBpE6LeVfYY+G52B487RfuWhyqpoDTqqR5J8yJxZWzYa03+d2iQTss4OvVFGAcAJydH2Zn0K0grTxixNnYlv8AFcNdkHYGM9808JH1SiBOH8jM4Rtd2sPgKUC+2r7FuFJqWLy/tnfrXWoB6wm3VaVQ2GYRjGRHaOicigZcz0HhIO1RqVBW7ue7KvViChdVObzVtXeAjbogUbXqKYr+Q7dN4BcBc1wO2EY5ZzbhbahR+90IxJFmoVZ6GqJJRac+FG3h5df688qaDsOYFdsiM4c2eF6gMPxHtUIJLpcCXXlw1HFBdK9IwHbF2i35vIr1xCBFjJ9/tYVyi8X3XVImeM+xeKHByQwzzJHhXi4d8xYBQ6YsvKnGh65gvkoReYyYbEVDww/KUfToOHmG7fOCFW8C0Hj45hxYBVrbGX/Go1+qDJF+BOQY954ZRuqpJGrI1B8Tqo4pYa3ZfdnXhKiwXN7npIloXzQbNfV3eF9ohz6Yv7UZCssOs5kqUrcvF/8u2iaJ9xQIvdh0WkuIW4FKIcjkMMVUbAJ1O9fhOFj6E2OWhqlT2mSo3KmXttYVwCLHapIpnrlj7ZlfETvRcsByYHmPCj3cM9Al40a1TPiBrYss2Vk4E76akwj8xp+842KOQ1Mf14neL+jtzhaowuXeAVk4rmo0tFJITQC7A6gE68TEUNlOzKpw5VFgjvhFdjLZHET7vsFbKbYlKZHnDGLYShGVzBOGC2gBvb4fbJC5SyV04+kPo3lArOgAPglsOYKzM0XH4GEC1y61cPa10qMEPgHuXFqtQMhY0mwvIzHae3JdzEVOU9dmxSC9/Mjegq94AzxpjcrCS0qOuGPIAXO6exAMzi+P8po2cA/9X5sgKRj/fBEpaJfKgGt/Bo3vQjqd2GrPxOB3q/s9C167OdIOEiq/bnYm36pXUXVPFjBImVHWNQZ5kMaE8CsU/uVUm8zPACEPqVsU2wofRP7RGBYsXYvaXAcWZo+W6drrewr2sThEAkEWEMCQv0Ohurq//MqgwAIX+4KAl9Ju0a6mQYrULdpP+El+6YRLIV3vOEpD326NCQ1dwO4m1rMcWtsHI/dpcBankgPoJ5VH+h7vbsHUZGMmojHGsO+L/wITQOoqSQYGVm02jzw0Gxp3apomaFcgX9ix3ZNV4+T3JTOm3m/xCcOXBm3AC3BN9z4HBOw5VUsyZ/5olp3M4gVeuP6LTwTfhmtHv5VkF7j4lqbA1s1kQcWKEhJ6NEeEJ0CjbSSE8aqq3M4CO1d8raw94KcxArS267B76yUfachjS9GQahy4P+XTolVTf+fvCkubJHXngXOT82ND6/lawpSAJCYiuZj/Ww3XgH26b46rjlb9xpu8dr1U6qsq7Le5V1f7UjJ6Dx3ZBDUuR3/cDdIFIkjKPB8QF0LZwV0Pjf+bd2u2FqwZH43nSnoDwYfwfp1InZ0TP1TzSUN2/z6K+XJ82Oh6Y3jCL9kJoOJs/aYyOCvVP/4qViyCeoOwmqAZjiLtcq2sHTVDuAYRiGkcL51jv9Y6aN3ABYGKs4WaiPMjEUFeldzQcMxtFb032xGyiljvzmjii2feT40sHIO9Eku5W/pMEmbNk+UulYCnnC0OJFVCkHmGVsF3Tlke96z2o5hQylmIMpp0izkIFzdINE8FDyJBxwPpmk/erlf1euR61u53jpMEa5ahpg/7UmojpL0sPZenaHUaov81tHf96aVOuOEiexxevRLiW5nSII4/cyGgD7/6nFQCtJsPwNQ8skaNCEDNjlhmVsqZudMrx6hJrN4NTMh6sKu9PbiwGfs2O5zkoGdnsGlGALUilYI4u+L4JkXIGm/pZZCmr6mp/GcTSvBx2HBlpl4+FEVM/EJpr+XJnSKG10ScbtR5OAJ3pttwS5JgkhMQwJmrkm3otYttL00mbINqnQV+L5OeWmkfGV70C9Qk6lpKyOf1D9Rbq3+GaZlURj0fuUTtvbVXkQAl8gkPJL+ussHKD5crDFYSLkvYBn1SyFZ5/sMC+2psvZSkUM6m5IRgifAYvJQjJnWizF/gCXDDtfC93G1Z2XBRsDJVpT3nebyvIDxXWgy5vLnYp4gh2u4OrjgvqnKLHbN4ansec8rEoxwb/GRm78xWxfRBrkiRW7m2fdhNFhH/k2FngwFLBxsnqkwDp9b/PbfHPMURRh9grg5pya8ES2EqN0p+umo7Yn5g3yHOczPuUxzFkgEOpQqn4w5wQyaUappfeZ8HWlWQirML2K9LoXCCUnhvOh6Zc34jeeS4AZn7Bffnq2beBz7Td4tUTmLiQJZIJbC4bYG0h4B01E6bVISAmt0rF9YtF0/Fj9q11M33DlMRgWLLUxHcRNFomIRgdcG4B4XOmSuoUed4I0g+CmPmu4P8UOBy4EHWmvb2bOBCa4OkeJkTpKyX+H6OW6U+go+dkWKJOOVfFt2pHpdurkcSTQnAhoDw2VY6PMn5US/GZrdSWpIksIKwr7Lwdn2Dpg51ZJcAdMQbOxkcPpPRuHidcG3Ei7WG+1oJaOytzYxZOEJAZ8r1EjbkWRvXCznCyVKghTHfyCEGQsFUVcBwdIXk0W2YUpIMFouu71vae/10SRQxSLYgc5C772HqXcNg9ExRx4SrUosLFLXyc85HIKvbpKmMWyl4sI831L77N1gyZNCOLJfoymSSyyXOUGe20kFm86qkZ14vGQ8gSpmV4h8clgOwj9PYDVRDHMicEo9nI+3y21nP+IPHYhp5MycrZ3qeRBbv2/iafFo8v1e62Pn6s91mTu8mepbiQG3KYCT4dQZQ5k4dMrcD9rfdOpPdQX6bEDnajDy2d/tDmWZaYQ37u3ggZzebHrcE/FPlVeD8cLoGwEAaZpiEpXJqbnl3eLl70gyceJchIRbRiGYViU/bmIbPBWVwc1yBc5L5y0kHaoB8Z+9aMqVaOePy/FmOfi0vwGroZqpPW0YybE507giD8aJ5uTxTlQToXyfNkh25mGui28CDrhHuRKOvegjtASt24I1kO3oS3xfc1jEtMhI3oGjHS8Wtbxq56ACV87edIDqRfbjiyTY1DYQrDmE95VEMkwEeRhmcq36B8eN0WdNV0ZahARzALtjA74zHrC2bUkNy/yK7PjtkfGONsDg+I6luMCblB7owU68vLU0F4XbRjiM0u30UEncZP2tD043G1BoQBiTCfszgJbYzRsHimxY3qJN6fvcO5V5ei7VsokEx73iYRyI9NJ92EEFSSHKc+XXXtP0f8bdpmtyCWxczLkdIH6BryR0PvAQBv5yxSbsQdobKalvwRrYlswaxCSkyBaT8UhV+spgR82Q06XZt8jiTQx9fmEmhTGnx5prKCcyUKyaXroqQP7FcvxMGdJ2Os1UC7gsnbqSloqOaESfeNMDyYDQv8foF0jDZAvCRviDGVrIR+mND3SsFQNJULWLrsQJnz3lVAfvn3SQeY1690Lqb8zxVoJmTgMEhxBj42fedFxa1YjpiGzNkaHjQVKfVR22zFdYHmV5zgpRDpY9di7zS08PR31n+RhhskKcLAVKabkMLlEOep40930+KFY3x2zySs65m/mmr6TzDteUTfwIBP+vBSj1GVvuvbQ/acSmqubNY9JjO1CXOw+eQ5vlByyZeAz63EiG7cFZ0ibww0VxCGN0bC5dqrqqBl7gDyZ02xPbuQvkwbpuUk0GcuykcL5e8fDnMUre/IZPJ28XGZfAxgHmdcspWHttyccRQDrvkBcJCvAQbinP+MI3uxlKSW/7vvkOVwbBVUPys+ZyjIqRArwdPLS0xXEoxtTo7eba0/DbEyNHgShmvg6sUB+k+o69MWrv2gpy3H8Q6S/19+peZcxp7UmmPrOPB6jxCOSgwpeaZToCgNR7UDx6i/g2CFd4y4oWtXiQBbhOWtwJWPn/4UDSQk9oolLTs/h2fkjAZWpjLZ+ZtRzeLaCc7KVeDDERhU5xQKREQt/zGmtuTSsal3y8K6L15PcUnWvX+8RzjGzNv/7fO4c/SQJovWAKv3OmfryAJsaWcyrybHorzNcHc/7kOtW0ku5xBolunKQR9YnbRLyawWWgz87TUkQkLQri8QdpI0oN2hxYfauMgsTunCLVczAcqz/2cpZnWQnYXPUi0bZ9/GrpK8LilYNKdMMFfiaQxIU4e9ny78roAlyPkL5OGjQ0GOQyBGaan/tOjQtIC3f4DxoITIC4hPxLwtpgP0q/IYeiSrwQFJCP9zW29syYw9Om6h91B397mueV4g2auJa+iCOxCCGYRhGco/X8RUkWZRdD20+lX6RlWGUFsIo2irJh6Topfq0a3r32U8VuMClu2wIKa7jDCjjzHDATwleTwh5Yn/bOxQvCO1UeVmMlnk/qWh26IgLhgP0rJJAVjsYfiK+VTNOwtt0erhV0pkTOK/VPK4Mn8syrukjZ//A8cH6DxdrXiNB1TRjfuN+JVVJ0Ym1ROSXsKSJsG0Hp+yq+fzszdUxDpFrG1xpBs47o56lnHk684c3I7BGG/ebewpVZ5XCktDa7fP/HSH5uOAnlGUoQ4Ln+vafJpT32TNVVkV4YKrGZKv6tg+rVRBCzeaq8r+P/NufQmcwu1rlHmRRQuEE7RaKkkebrcauHnzO/xAZgZ3iXZ2wayST309m7eylISkMKRRsMQJTyW/IZ7ZoxSi/T3MrNh6GC+LYCNoLmDS9BET/2zcyDr1+kZ6wUyAQXqWIX+sKxlvnpb5VqP4WOHUYOC6/mPQ4lk4cb2Em1OMqCgBhfhNLGBtb9IxU5dsH6aHeSc8RjK65ttIMH2ud80IL4tuBSaVreTttBpR/pZ5W23860aABywDIpvlrfjVeTxL1mH7V47wdEo2QuE/A7eYujctF/2mc7pPB04uSc0+IZZXNDE7/tlXPU5C/iuKqkFiA/OUZv20AvFHupmNniH/jIxi6wRYEWDeXJa/3Fl1s08rR8i9+Eo5u0gaNy2Pic7nrw9qahGHbvd2Dn9DUpmDoaQiDghfPAVZWeXQ2l3kWkUu2UQsEhipLrxJpdTj4zZ+8VdzXSZYsEzcygkyUUNssN+dU9GBbbh6kHqlZ+pDCbpgCzaeEYbWm+UW/I/BuUDU4oOWSNoLotUAoBEBuxX0ESfNwbyqKyIuiz7rCKPG99fZbO9a9qlC/ELoDEKE8B0RJ6/AJ7fO2pgJ5Ef0llEyTQoKO2fUfd6/12ZynVcVZS4FGvXxx33saXUEcvm3DOsAFjC9miy5qytUMzLWa84m95VtQZ5/h4ANZrIVKG5KP/UW0gNoHy103o3m4GjLenMzymGsdOEsLuuSJB2fjLP4GxjxzxxPP2zNTea5ykXml1U+2c4w+DpLMkh7ZcAPw5RqLX8LZx1uuHr2VgUMcOFgjOS628XtRxpueFomf36pya6MItcKAYW/K/8UMRtRuKnA8oFsVC6g4sFcgnO9YT8PuZ/9ATxBN+L3ogpX6AjqX3fgUEnGAAl45LbQzFzo3n0N/4ShC6PNFeQZ5c5ohlBbXRoJ93EocVwDK06JhcM/pTpZNBXX9tvgdUpD2sdEqOeeJ/IzgtiSaz2oH5DKt+SlwyFWgMJnhimZdrFsOHxIyAmG0Ot8xI2OxHneIHA3VBEvFkjOMy4h3cbWaUhbw5y1fteGPYRiGYWQu6wu+Nd2LGTIgcgp1Mx/po/SdVWDqaEJbXAJLgCWMpwWyf5C4LRMjrMCWc3c8ZfpkgA1t7iCNg1/Zdp9pQZkSN0ZZJlRUbvFG9oZcf65DuuB2ersdpZQ0y2NY6OPvY210sin38LJJkE1jcAMqap0A7Jle3DuraksCFyDMD/LGSqgD/m91WDCCAzvchvzL5gmpZQ7q4VfPdlQn9dNKhBt0luHbLHfe7J1kc4Q3JAHBUOPw+HU/A5ZfcORSle5x4KK1J4qZ/GVA7eD888zOD55u4UpSsbJU6YSBeoOt0p1FI/dK/mYqEZLFbtZXj4WC5r6/PIyLLrpM/6tuj7msL9g3smq0ePF0n/XCwEd0+IKiHEVbQ52YilUYTk+xXuaikmn9HGiad5qoNuhOiSem1FzMaQm/arzmm2/pu+Hj5ognj3GhRSgMOPELWPk1IyMpl1aUFM/FCBUuqw3MjLS7fto+dwMw8a49nfj1CcVxPesglTwaQ5KOIdcZkybTM2tdEYZwcUs9eGnLW0W4CsAqAV9LZmbdFzd0GJAEzULpVbfEffJnh0LGv1Oh6XzWKaDPFRg7kk3Oq5LNwS5DDv03E9nQeNQ5RCPKHjMUbkYfMDgj6LftgllKtFT3nskwuhScEI8moOk0vRAp0jz5CNF98N1Cqw8+nmgxGoFTR9DWdC8Gs8v3BLT3B8i+IOIU778/tmoAie7xkooPlxeTrajJ6/sL5xib1LQqvOCGluRFB/SBqBAFAq7dXPYtCz8adC2Msn8XOmpgRL4KhANpq5dM4H4SkZccSEnBudtin+OfsUaFOjS+BjnB9vLXHQuXZkZ2tGNdI2y0T2OCHkDwL0csjvjk6ssYuT+VPw8A++eqqCMKJa26o1FYKfXChO4RRg4DMbM1vcCApDsE9pCvKdluZXx3MbAfAtInO5VBGlExvjHnVb9ApfJMm0lZElj1GcvoN6zQtm8WwrTuYeVlLE5zJ3qtp6L6M0byPQZdqGXDZUroT9LToUAGeneL2YdzyIDIUdkTlCtPg5RrAIZFSrPJXr84Fb199PSNWv2mMaL6Nmpsnonzxx9MwLMSmKd20sNbVUeUda3cyE9tum23ThDV9DnDgym5b9A2XkC7QyatUdGjg3QUwQbBSzTl7Sy4wdehU9SE3wFG3vmpKf8MSjz/Fz0xyNK+fYvEWvKwaJAVO7lm4zhiJNaJNYh9lUh8v06s1qhVGK4zJx5v11BJzeq3cbvJAI57lcOw3CLUhqQG4GZh8VeeIkwKzLbuXqvyLNwf0h1JqirE0SrgxHSzy3UTeb6fJwXyCAY72Tk9DSMS/tTlakMlJlufbjJ8SJ/3Ngnt1KECUywwGsnsXhuIdBiGYTjslspxa2alezV4Bwg7WcvYpMnH6MI9pr8S/jv8zZ2FRCHFx9b7rtgBfiSZCfboJvsvO/sSM2nUgNteEFNHCqua0IIHuBdnfl1ut9sXRW/mk2p5JWC4aFr+L/BbXaOzRplTcJvQJjwUwIoEUrHsB5p5mS3b2XlJ0R6lGmp14bXbbjvrEw/NQir4WAoG4mjHkqaYFCmjDecH69FxxMqxXWoX9ffmNADbh1fU+BqQuBynHdWkBEyGLBsTECUws5kd0ERlxEEh6pRHu7QqhGjQi0DkBQNloENmReETNJjETEHHX6D+/K56bHtR/VZw0XfafQfO8VhKsD172FqFsKtcFBtaIRow4oVy0DN1qWtflHM7235tYwCgfuqWPcymFYmmMPVJZiuU1iYsCp4Q/X/3uSoxiwkGk29df0i+hNr9eyB7ma03M8qWNYoVybvGF9x7smMKAgGHnFKnHvPMfA/r8krzbEJG07yHpJKCV7q+Ka9H6scZRqkdmjt3QLCa5o+Jfix+Wtlp8Ciw4yY/shDIldiAtxvZcHFzM4GJKgACXPWaFJLrUsZgesuZ5MqHmeXH02/V58AKASfGkEwRh8PdJrgCwmwZKAQkY0u8a+gPFyH0g/820HArtGmGvrpEzQi9k43IBKiIVVQG0B9AxaGWcKlhCkPMSe4GJqg/1nmNDCfEguMRCnE5kIkHBJ4UjhRSif6shRL14/UVRzmrX5SFTMlgEGX8SPQhHCki4gVixSi1eak/vxKzPQTl9Lcpz2EGyxwRVHJWhIZVJ0DYH5ZPmfO/WzF3XJv9nptmpIeysn3Vo1hl70gPdGQmG8Rprasobl9b60c8ToFoBIKeqlUzeweRLksgscoOSvAa3fP9aSYrmDeY8MuFEfIU0p/2VvraqJYngTNbOXM8Sddtbhz5zjL6fmQW0RAo75OQY0ZfjlVr2mJLqwG0whKXp+q2VBUHKaBt14nFYgfXXbix7OzknVI4kRdWkHV7v0mtoKY8JPxXuahkHGp696fVdcXql1OTbHKLSsYeFZypj3zMqXpcR0fXSY3KFI3pKPd9OjPwAGeJcE5xSZ6UYvBuCd6V/b88yAlB/fN8XLXU9LALHJkp1Kd0vvoKVN6ycBUIc1N085PrbyXNyw7eBlLxwixWsexmiNM8thPfq8YUrMXQXtujaZIW5vHWNTL931bAJGYbnmnWkkheHWGqXKb/NfYLHocSnsB2yQWNep0KF66lSgT/Q+Z4Qwvr2GduCR+2PhJ81mag4ZxwqgJk4RO9b183X9JJ3AoAmWVOl8BKV33/CgaVVeTnyPtpzAzTi4/88LDKoRXAs88r8tAVAXM+iCp3Pb5nc2mIN3Ai3cd2NDES5Jh+ORCAD+uAbMgAfiMFjr4GyiEGAALQaA1UYQzQo1OiYNuD/zUKE0ewmLRJyJqUfJc6RGFEYcTMaW/62IelM3jk/tb4xIiw5p8YRaTBh9d87czw4WVHPjSUARi/39We519ee9Khsj7gFQaER1+7V+z71ngrZf/2iTG0bP+RcR3f+Q3mHkr411UGW8M8fUx5I85Iy4+zqC8PKDrJBAuMNWwW6ywNlDE2jCsUglAsBKFgS+hKhiFwjBAKt8ZDfwYc7jUHIK52PWAC8BcGOA8gRwKwFgrvMM2xdYcnEy4xLCvSfeLFHaDQFQzKgYj/mv+FuDy6sy16L3ycnbIUuhjQshtmqmI0S+OIvFRJuPKktysK45A5TqEonVyjcuuoLq59yuLQ0K6U+IfL325ti6MnPk6tLN35M7Apr+1Vxad3aZxMXvpwxZVvPNgVNKIOkTiqKO1tULkU1MU/iywOD51KmW9cPjrbFree+ThbZenBj4FQjjWq4smrNI6Xl5795Mq37u2K2uOQJE6pKF38RuWtk7r440sW9++yIgfl5NRtgAmT5xw+ByttYD2SLOriv0pZ96eXJXIzsBSlcehSWmlViRS47uxNWjq7ti3upPK6d0c+kUuu+O1TWfrnr10iNNDXpYYhfSn1T1W8yBV1P26lifxCxcmTvPTqoE6k5urufchK9/7YFTtu6E3dk/2Qm0T+4+LLu6L06842kQzV7bzwpZ0rdXFhp6ybeZAlcrFtdd942tYu+i5DygoTuJLHQ2IxO6NSYysrRHOwMj1frxL3b1G9SM62DebleeTlG9vaOzxKuoQNhQlcyeMhsZidUamxlQ3IhouwZdHz9SpxX6xyZVOvd/ps79FpXXVu9N1rG+nOmlOMs56qxzm/LelZkCDY0eBVG53XX48b+DHntxyzQ9LH+9pvmmqSl82sfLqd2DddsC8zgW2jEVg+BWSzffu//w1HKPk+cVaXhDWKVLawENZsLsDHUaq867kt1ImZog8HbXQcZDol1mRnO0gGIZN1OjN36ss1SMq/DeWHSZus9X0s0hjsGuPevX3uOqLriO8iqfz2KN+16+ScgSM6aqrBmlGoDoTIgqh6S0E5LPbVEYzZRhj0RKjYxJHIVk6PgJImuO7H5uDzCzEHjRLrYGXHMP2z0VACCZxHA8rhPe5AIXTFO1yO7UoRfI7O0x+0wuyjCNsKiAwAAGAgQCLSOCWDtUwikeXDK/Y0x6PEDrDSgSrAXqOZRUOoC8Z9hKw5WZod4G4JXbOHCtYaZXdawGRk5diahhykagFLZGRMcbhaCuTChCAuhB1lIwU7fYiGFLgF9UgNZ9CI1KMHeWSK+xLRxhhrMeM+MtuBt21swQnNKuyR3ORxdnj87Grpy9hW1MCgUsfKDyynOSR2OwDaA+AIAF4AQuYM4AQAogHIh4kdGYfVID96afWJRzsqXo0jz0qHpesV4NQ4InMWFBu6soDPrGEW2N8bmALjwZHxg9BYN7m+0o3HnqaRBOqbnUGsK9weTuncwJnIMTAtj3eMvOlk863/jvsNPDTyS3Nbstg38XNk+38JrHL77kzUEG8IvB4+I8TkWj86DI1tlWs0mp43zBbL151AQe7R9tiqgjbH2cggKJvZMgu1l0tvdD7vOro/9N40S36Ls0jjO2edZDTWDy/Lac4FbDvJN2lD5m6W+1stXf1X9t1b59HmMmTSAniWM+CInkPoelSKKQIsj06BHQojqHPTeGuqUyTwr39Vryqb3rGjmWSzpeyww6Rt7/vo0LAs1W47mcBXczWIO6jgVAeB7fi23Fp0pwavvVOBxzqVKHrjcpubsuLg8txney+XxMl99mgqUc1rpD16o5eo0b/e2pmczxL1vdlX2wDekjuqu/sVAgX6l+eeN88Tr8t/Xi/tP9Nnx5vnkxf6SCL0219zkyf+cs50ASx4h+GPY08B/OFVgGE3AA4AsmdMc/QqgXGfAyZjLwEsVxBJQ6WpvwAHt54B+xlAk1Gq70lZGPQR4L2l5oW9U11G0CcH63xAsZJZYZ/3AWfaJ2Kzxr4CI/kSKdcH1aVls4aof83gOWxw6kXN+xJMnZhdNrBLgJJr3PVPhflmgiRMlws19zF+NHe5YYK8M/MsmI31k5n50TFAyqdGfwsoXkU4e56E6Cqn+tVGn4eTz5HKk1HT6k+9PG+KvmDklq5Gqs6ZColPPlu6Gqlg9S7IhZekoKvRepHf9xnJ83fy+bTqEvGq1Y8x7yeff9ZVxxtzz1uyOvm83svzutWTLj+8Pim/CnnhaifM7OTzywvzxLv5b88A8DCcSJl4rRvQGKNBQ1oj1ZqIaq+Ke9IYguBAFsMkUMXIEEhIvFOcSVm9vRAEHoCRCrx/ITR5nCjuSLljqzNQH8OaZ3phZFpTQ/AEdKTsYoR/IB9jj5Y0QGo1RdTEXgmkgBgKEDdsOCuVUsBssCZSctv+BrhZpl+xwTqcAQHrqI5PNuIYHo9WcU30N1RgTcJ/KmLKm0D2bXJ7rWC8ZVpFy4tI+m8PKWEe1n1wnefowqhuzelnBNUKkt3qkXM7VQxEdIsVG9ZsG8P28envXmMNBt//Ox8nN5o7YIOa9l9aK9FfUoRkk34VFUkjaE2uGt+2l/e5N7ew8RHtTRx0hn7tx1bgydl2b8Mf38mr//PBAWu+kf9FVF8Wf/bg1HlU8iGBpMHe+4n1w9v/51S8lnLGWgf/Nl5ekzLj7SoXnfb1bpN7tx+Tsr/EhV+o0TCPkeD+KenPxX6hrUpPwvWrmOVx2RxWe2yebAyMct9v0+e7VcD57q8vJcknHb6XB19LfrCfqBo+ROPTpD9c+Vf1DEkxKpcHe3QX8nry6iqczBkeNpMiCqtXaLUaLtCwaDFtXwSUDFlfE1FzxLAS3yF1A7gpS4boZfB7ub7lgJrpkM4sUFv6lhzqTZmw3CvtMHxxI3vu3W0ronqPwYFKO/KwECBi4mQ1PaADHKHFMwueWFiRC21gm2Ur1PsQzhd6cOM5dBGDRVvm9XF3jU2piOWkGdnhQG60oAN8jWTKRQzOGVNNnoCjEP4TVHRXzrLYwnCCeYFHGBFSedo0qbxR7ajOz4qQhHo59MSwBTpMxKUNbHKJd/26SjO1moF03kfpEKHggFBkRes7nO59dMgHXuRatJvbzutkwiPHV55h3dOOagRiyGaWRE7WFzxMDWYPff+CfFa4rlhB6geRifrlyMDCpJV85USieitR0KRvC6OCH4GMtiw6IoZJHgaSZkMzbO8ka91c2tChh056Z8mDrIBpOUDAYQ9TJrif+8uO95tjtWahzFSbR1/6wpwxrkl8K0JGJRlw5gL2mttvZZsZFssoOccbDgSlbKUrxu8t3weKW9Odgsf90cb/ByrJ90T1om7mgi44qQCVNYkHUUsjXVR+QGEhDZd52W3t7vRc2XXp7mbXl7qHe5iJoz3FWlWr6QB14nKzcEwFrVa0M0IUMGEurZ8lQiVttYSTOzy/NIcRPuksN42XVt143qG5laUVFt4NoPoibQTA4oOrWdbqzDfD6tAOcamz1kqqnckKzw/BDB+RQXF3/n9Lnaw1h9l1+KO9X8SdkFoG7eQqyuH5/0GcKrEvwFsqWmxwFNgT67A+TIpwJbEn55Flbg1HhJVCvILvxCKhluWI1L2aR7vxKMFPFankyG1woSHd5kQDM3RgOUruOLDAM908hssEjd3HrI2z3VUZ8IqpNZjtb1p5txg14kghlosbVqA4aWQ0I48Eo0k+0tR76yx2rYnteV/NawZZgl3o6dAiD35EvrnQvZwQzgQEbFe2gecteBK5jUVzV1RdhJ2MRop6NCoftQ2lH8T+cel7lNU7Ks5IY3NsYl7RpdfKXQqm8uheA1A7GUEryIBG7gMuEDK+BYt64v29IbwJyjorB0eSmkIRPGdIUOioLRaVV3V5mOyc3xgjwGGJi7Ymp+FTv3LjZdgLp9vQ0MrOdDANubVHJYZHzfHn86D6XGz0oWKStrYNKu7df8RCF7mf6ifll41zs7272cfRymnh5cKxY8M6d4WRpL7gP0A150LLsCtvrWnux5XLB9Eeh0kvMTyJPRUb3KWbvNlfAaXilXhIaEWUy3N4lm4adoUs9AXL5DOMDLT+7D3zCLsjubVlSIHx7lXzH96SUkhrjjMqXptJcod8aEBak/K14DKtYJtH+F7KTYL6osPp6q3EykIDXH4FzJ7VErvVilxvsEDLKTTqP8CrwUe5WyA/u4U9+kPFz4gDnhX+GtJCS3NXoNNKVPCfhdrmsWgdaTtuGvWIIa7rDD2N3lIpP+l7UGdlzT6jkqWnVixVZt3wV9P/cm0g5EMTv+3N55/fUl/+mg4Bi35ePz+u7+61k7VOMTJ/Ttt6/muc1l31oeD5J31eTkF5ql/+ujusPcH5+/Tjl+71xX6FH/1tVn5FWP+XfXxunybX8LoVsXo7NZ+vPUKxbT+arULsuRivf2ZJ903Xz/Q1fP39WUR/JtTUXMXykJ6Xp4/8PwXD+u+SX+qFXPz3b91TlDT/RMvpbf5/ZdPn4kg4CIGHx4Fq/kaAhw853FG6qe4eYYE78bOuFlhI68/pLBk4I5VVrq08Fn24ep5JKXpC7XlcA1S4fOBAoXlutQrb62q/hFPP+e5KTB9vOBIPI2p9s1yW7kgeFe/KJjzDglYRcGQd/YjANRQT9FPlnBcz+wiXafKv64B9YkwFcTSRzx8cYbGu6cL7DGGHXDp6HZBWHzfHvuQGgkakBFFP2qeIdvKZT1wm3W0FzJJwsU//3C7LtFoFQroKlW1KpRH+B6Eyal7Kw8LacUJMdQnQsopT+0rHzEoLYc1VXnU+mVUgh9EW2uZjW8trx+w//pWciubNDCXJNTRBfqW4GzMePlmz5ZBWl7NQ7oFbsJczofw4QthcCSBxD6rrKwu1GnELCFZQzmqJTe8XqZ9UYrfoCUj7QD6exvcwyN/9g34weayyRFudDBHKG5yuEsYX+a0sB8enLrYx/+YYTNW5aP4wyqWRt0zgUNB1/nw0t+Coxjt+btGWt5fyEZFVHC7/23b7mwMb6XAXF02S03iJF2+/QW7n8Mhi1jj2zCPbZ+SDDo+8YWCgew7QfLih23BjrrSQd22eYn3RiCl9iqcgTkeEROE4H9rfS0R/oQRJPzAgB25/U7QT9qAbKa6SSURro9PPZfx+HLVLg2HaBP+SArhGRqSFBUtdlxT/gg9YnP/hxMr7IinBkjwK58mUxNRjVIxZ1vQBj7Her7yguwGpunAc0d37/fEVf1u+sXpa/3b/++18elNP7T8ef1v0PVZ7Pf7LY73MybgaF8kFG3qy2vvH4zpxe/Uby8XyoJYAgQw7ZiAOxLN8cNAupKQ8jV1m8SI2D2sVh13mv5j3HDYzlMDD4FJ7HPD4uKencHh8Psa/vjD7OrhBGARHKzT6wXvCI7V0LMkcmNBmSJM4xALaifSPRJdIQs1TjTaRpJgdRFdfAQ65xc8H4F/LivDxKQ/1teClQRvbZE8qIWWFoah0wAaeVgukLBzORPYG87VyKq7QSjoqeFRxloV0B9vOYuzXsjdYgJ9SuuaQMYvrVqhnsBJosOqlbGHjEeYtSeZmBVEMooMxG+4xoE5Ffh5PKPxi/05aeHUSjXkSsTyPBdEaC8vkEyQpT45L2Y1uqQi8d+4HPxE+qxYzL1+OljFsoDIZyIa9FABEnPZqEj54AILBdPSZ8EyC9qIBFvoS/G1rJnPjyELR5kcvRLwXPwIHzO3CISVxw6DHEzh6GG9wyAkqrbgfjZitsXjVaGg5I3Xag4QE2N0/hJT4mPxe50pdTDWtwCUzCAUix7cc16k8LlVH3e5xyMK2xFFh/9nG+KU0uOc+xA4Z3EN1fYts4RBxCVyIgPeNDcHd5+Xgxo7aVXJIha/zGBY6fmkU5RnBDXqQHKHpKY7vAydIX8RLwC49+kfNPdD9+dyeUO42osG5Z8OGyQhXt9dYZTU4bzCXfBNlVnrNVDiOUteqFL6+r8BGmQ9p8LbM1ZOLDkzAkEAuDxxBvKYlvUuqT+TACx5HpyBzhhbU9LkvSZJnYG//FaHDUv3RifV7stE+6Gse2L/uPJMjw3k6yiNbuNJDX9urtJi1oH4Fui2Und9q3jqhSOprF4KMJCWArjesyYV3CZoUSyzaINnmGCp6BDS0IRdIjjMXs+1IX3vH2CPIPzbKuj6LICJsROOI2zD4i+G+sHRtSVtXVsPqYlYWTeLYywMWJUWshcieVVNySbCqhaJSfnFQHt0CFp2y+AjNKGnBgh0J+kJVHv5OUrhbfVriQeaohrFX7QItbly9ksP6TawjC4qt2SxqEdxVEZUfyLp0AXwQrpDkLsEBl6AONlRFjfAa4kYy2lvodmgWYbgE1apWW3UkVgrHfybGubX4HF9Dcw1ci6XF6I/BrTsouGh9sXgI8UNcD3pY6cVEyT7Okyk1sIpMMVXKw6xJzBFjD+kZummhhyRzgu+1KaseoM9ERWIYsXhhefG4G3w6PXRxF4wES54zRyI3zBZb21+ZLhPMRAcA7qGcxLEv/YUCpJjVvD8Pf2zqApSXF+lPba6xWcvTj07DBT8Y821JI27gu4RyAjpIkURKHa/fKyJ1JvRMOn0eXwe4Xi1QJ8iJkmPoq4XXjc/xG2cW//JvQq/BTRDzLSdQc45ue13FFbJ+Tp8JmR73wBPFJ40TkCaiUD/h3aGq6r4YzJL9S8XbXrzaNbGBpWzdQrHruPcjcmDa3lW6bGA1B0wwY/VfRvjoqM+1BSKZMq1PMZgwEvtPbmfXqzegVuuQe3KK1AmG2Tj7OtJlwTCjlpcrPYrl2ICHe0Pn/Sh4ooDdwSyzT0b74g0BaPyyeLeNUWaGbtcQzsVwLIxpvg+wEcmsSJilNl8iJakC1OZyOp/3j2Ouo9o+mfbHTZRqCp/wV4plOzilfIklo/LtfjSabuIYC6l+cpyXyBFmHXUce7YtqV9t79wEp69Ft5tuaybrEd2Cop492+5HU2LHmGPOjil5oA5y78FcvrJFT0UJH6S+httYmCfRQo3HnFMPRPsKPbqfDF1KBQaC4hPY0OOb4U5zswR23CYvw7Q3dd0mNxaxfD/1dyOpwJR6KVHjy0XgCwips7j3Pj1F5TimzJOyon+BNxrXVFO5JvhSk8Ze7s/PSj+gjzTCs9ZTPgqPAV0ArmUEuBrkdtRKiNaE9zjoy4hHrhcmafGhUeOmrq093wkUO4q6If/APXtM2r4DIlARZ2GsXyBQwpkmADFCMpRB1XtWSJvhuaYR/VyORVxcfCtkK6G67vv2f0QRHyNrz16j5Cv0/rzUuHDOe/GRwCEbhBgzaT9NS3ZdlbgZlSb/NHbDt96Q76ySos/fxKjDmawi4E9g7PfOSRLeh91LhEdyN7EvcbxIkA+HeWkn/tcRs+VZ5eS0lPiLiqYLYHBLzRJ3l4fZP+tHF9OdruBm04960cMQsBT8lzHJsWJ3uyQDAFPJEML07lcVhTdmmIdE5a5C1NEKq2WYaHByPcRZi/sS3y1lIrREsWMcPJ3y0kSP7AW2brfOb5fDOgtkJdreJWLqJC3Dj+57rWGcSGwDi7X2A2dtO4ccqs0y4g54sEdYGEcbgpK/Ee9kn8wpdfayg2ajLGa6qNtOXdfV8surXHvsy8xVglNM05zw0Df95YUEg2kaiAKDOjfrBQRLjvG0yFhP2OteSSK/3cR6UB59unMSd3p5OYHGR4wo+QgF0905jukRQDy+g4kCvydnAYVIRIOID1mR4/QKH7jFowtenLRU5kzT1NoWHNtHwBwV1YfOh5YZAkOcGGzurUOZEvw2nGdkhesQ/eHe9cwuVZWYNHnFuEFnu41Kxo8EKX7lsXgRULw0yvFpJrZYRx9u8AeevBLbXdXIV9ESeXV5kJeAL/KAgqBvt33qEA/3moisBzYS/9R1vz3pL3zqa1L7MME4kozxNrasq6938qIT1D2uD3UkwOtqItjnxvDbq7ZegHHNFEiNqL7isnLe5WE4/+O/rhywcsNEU3KjuWWsM7rdUMinc8CmK62lYH9GeeVMDGpckacGRzUGrg4tkUrufWzhugvHTgL0zfUIFczJ/OZdXpT6nAiLqyWB2QuOGIW7nH3l0F6AhPtKZZ/aDIqiZlzpml+xOAxGy2oKaAEmt2Dg4m84ndS7dPqZ0VVZeoaWyl7DKDlBdeMVK9tTG7AHXQSTzwG8NvKLxmrsobu2FrA+Zski19Rv+DpFxZ/JAnAdOUTl+XBeW+HxOj/tGIr8ksgG86VjCe5WKZSHl2mrNAa7tE3/JA0v8YFbojnztpJzzfxVvBWdHXn+EfgjA0b4VBVrkU4mX79uReGG0GCyNkCln1V13lzzmZIcSKJdYSRfbrwzaVEuXpmz3u/8KNJ7tRclGmZo39/e+IUpGs4XtEp9MH4I5qkfLVrk+PYA4raxDlMHxWiwK0omjNTvWUTYCS8nGnbE0sG2Ix1cO3ZJR7mdL2/kAJrtubgwlnM2qjvGDrYihwAiOSJ9lB19j0SGRFqo8B5lBTZNVAoHaDJyt8FWa3Nrd8DjmRmvp2FSg/VBN7pneOisCtHBEACYWosMOxJhpdHveVBjbK5ruzRGmEqjhhrtzhewvWBeucULHXb2RVlkfH+MgqSPiyEj224qjR1BDVKpGDkCARbHKi5WuwJjx8kORg37uK5xsK2V6gqGYYbcjot42WlfvsfLCpApe79+bImD3oO/QxvD6rC9wDTKcgLwrjMlhEq0hE3B1MXKVM2G5h1q5a7KIUmOKG1pzkJe6I5MMDRhI1kHPKqMvBVB1WV03O9OL8nitncrQidlIh1wShOVkhgrL0V7VV0KwmiJEePiIHZo3A+NxGn1h5HL0aKx6AzVhykZgFKHi1ETpas+weB7vWdnLJHNkjVnH1BZ38vaa5Afgumxx/05Td44GUPue6Tq/ofLeYCZ98tGpYjoDm1UN8mHo1gUw0pXlLi7G/CjoXgFo67s2rikpUfKSJgsdzsgp8o+UUYBGm3iC9jsHjvnlFRPyHKXapsxddKAjZ53m3sg2EnlE67AsWQW1cPLsJPKHSwe6SN5ObcT58zanwaED8Bx4p6QwdwT1cUmByrxDNq6JyzP55zGsskswiPYGkwdvByXnjYTrcLZYxDFb6IhOG9UDBmTErbgGrWuoVg5chRF5zfsMpbG7w0oMLTEwLyTVYZ5zkuTGb2EFawnPUgaYppkQLCmJZHMQr5KuOOlzDP3T8leznsAATsz2AMIJOttxqy1yfmN0fYFu0EMdoht63GuRY204DcEnaD75AUKdgpZ4baSdCrjDHYv09YYBzuiiRL2LQ/F3pQfR1aDsgQ23mRLdv+xCEQbkFvyenn1KX/cm27Zd8pNhVYYnS4Ze15/1EDn168FXq6d2DUiLt4bE0WH+3PpTR/xRi7BRXdJ/jGkfAuOF16OrojiQF80Blt9V2lECwD/HThhAMQYai2AMPzLK1kvma93ztUCgElT9unveCiAQP+XfZdwJj0DYL/Dh/X0UK0+2ALOYT73dE//k7Cjv3SKu3Jtv7kLmfvsTYS91RmJjVtqtAHAcJFSyoJeotqC93YvKOVv30po0hK+zKsnxzWrQQMr8BQyGvnVCou6hTmnywXw8RILmne9+RP8ZbWPLRQMnndbeLg56mfsGlWuYZooAaZGQPSqgKum6c8n47K8LZ62aqUji2CqBadqbwMkZpYEeKrcPmzMhMiTlG8CxPEZJ2nA0U3dhV/yIgJ7CUUIlFuHqei+/iNR2u/4VSHH3LlTEeVGvcum6ztiS4eBW5Ew/Dn29kbaYEeQz27VtbaxicryJRJddx+Pm5D6nJBcf07LCT4mMuonxuLj5BLOjohxhkLv7ZLN4/uOFe40uGHFx6RisILPMwnvKghls/zRZzh0rfjEhGmH3gtELEtxRCiArlA2VoDNdLeIyWQQUMbNFbrXvXi1Sb9rCh42C0HUJATWVRMMFwS7dGsRtRsuo7yaUXEaiMzhwpAHYhaIPFypsuQLVUqCv7u8ay75b4fK560L+4YFM2Bp47qTO50Wo+EXBrr6YPDutVFlDOpTHJ+epgSBcrKspDuf8eUos17iImaKH1NYn/o+ogmEFoo9382nqVV1xf311+Vrc2eFtys5dcK9M/uHFNIbLCy+vv9im8yCzpUkvLKc74XcUcUsd3bx9Y/+FWRUf+CLA9yeGhkgnRL6aaNwgMynnjEO/rSZc4HzZN8S5MJaGjVR8jRhlQ+u1ywvjSIMR09bmZkM1kd49PRUOslXIN4013F4d9MoMyc5kqcoK7WleG2OhLuUfGXxRclqtOqzS+FKVt2ZSB+sfuWgiEpuEM7Glp94VKKQ8g3bPk3f5IldlM8fLUWkuliUMSrKaA4F0UaFEcZ4TzOd2XK9Dk7s8ylHpgPYXy9oAIR84L5Qrnrzzq65SvSoemZFjiXjLAaeBvj/XQzJP2ZtuBt/wx1fctcqsSy+cPtXdEPPz0DfrcLl5WzV67/fnH2RrxMuKZ7SMwUUJi2oNCvV8WpGKDNIafq2ro2rluteROx+VtLDHd2fhHRK2DPG1V8p/rB7oSsww/vZMA/Uo4HVrSiOK1jFFvFWJ6W8Unaa+2EGwIZVqTskcSifeXiFD7LXxa3Q7UPshEozuw3kWlvAsrKHBLO6DmPFaD1sTCyZZtAG1r865KNgY61Z7OqY52yw/QPzG7BV1qQO+SYwr9YkdjVT9YJ7E0rNWKhvzIiBHGuEXnD7fELzzFNTL+tsW8M89zqk2NVK8Y9m94bNdUzJkCetnsPc10ODU52E1WtKMfAkmngIwynExXN4+4B5ff4HuEdnNqzbJt0rqFxdwg3lzM3eE40Gt0MQwp2WfdwQspBUFTzzH+gkWZQ99RXVwa96cCRR0tW6sffx1NQAOqQNiDFF7tS3TCS60oxR5wXjHndItuyzN/cgh63hGjSRBmbw4LIp/QW4d2rsz6JiccSzchlb44H1dal12yZnU/dR8Y8wIwmUPBY4O/8ULPIsRbBbmZ+071VzUCZUJClbx0USOJ1pMtEggvQyRRThp4qUhKLRNPPCoQxC6y/9RNbMpKKqf2EiVnnV+J4CBDSfoQTZJIAV0GxZk+yiIr/W90mTC07TtJ10rEysRmawLSSe5C/2OY6WZj2z60xqr6OyOL0zr4j5nxVxk+3SDwUsmxk+YO2q63Sg+JAVbwnW/18sBsuMnh2oXyyvmN9RDyan8bRHS3V+EjPOtMoVqU2lohdPqorXi1QEV+G4zaAxlO6dS6Nemsfu8mAr8AH35SOxrAXvux6X8H3kVpgjR4JaaV6KKtIPNZXzRIW9fVnokCViqZOMPiY/2o/3VdMDDe4RD1v3vwpImhGMQ/rM8U1iQm/VpPKUykWpEsFyZa0r9f/ZxVlxK6028LyYvdbmqNbbniuiHMk0W8bwaNMPW/H7jhSbho/3IH/BI+M4dbjoeUou6q71Gd+DrncZuCgbCaM9+b8+ryD3VTQWXab6tirFO660571D4MH1kyXPCIpbotlILMnVKD5qLsFijouZ7Q8rV73KrPAWeQ9v9tYMRyulFIwAqHBzhhsdXav3AbiZmsHw17ta30n9qXwicnoCJJa69upuEiVx91d5LvzTu1yPp2wyq9LTuMZl1kxR5VL1ZNM56Rb0HYgXHMC/oS3RMMTx8Q/06d4drMCD1fKpGCPBH19aN5YUgquT/CUSfRJ2pH6Zbt45+cfNfIqGVTWgrv1XOhlZn57QJme9d7+7pvN8jgA5H6r5xJtdX/sfDUtS/tJNL+5Ez1RNIX0DSDMd9pDIJpi/FgvqNsNJkEy4ZzMpxCnGCu443qWObNRPHAbINsbMkWVtZUYnyapvMBBH2ux/Nd/ouRLLJ/vA+hYIS0neH9nuePOup8Rl0nsXCTbl21KRgzaXIJ6Tjn2FV87+phLkak0pNrxIicje9lgOox5U2faEomNyNupsqpDBUgl5pNFXexljUqN5wY5HY27gtwT1E5EKK5dIp0zvilty6kozDHrzxeAcBrpJBBZLxsr6YWl/plaHLJUJBxKsJM1sVJasd6GOSYSNzJI1hZ1VSfTdZ9L0SQ9ap01fvfIo+VF2YmdhYp+CLjNK+9lrEo/8K5uIXiaBHe1e+HYiIoyFEyt8FIR9POwL99ir2MhWPtuMTkfmy3kzx8wYAEICJICz1zU6ddX2VRV6EcdCRlCK4qITA5siWyELzKUn4qAEk/SOlO54c3/53HGfXi9HSWZMdPXgC2EuxkVSbQgqs2FoM6f9R63fZ+HeFQyb63LeGUeryewMZdXG+ljZ/3Gx55XDztqeXtOorzYUJ6OsPQL2AETVzCMJ/O3zFHNJz5eeeQl9WNFE4pDRRVZ8OAtek5jc3DNOiJhbf46XuwuM3MEUzxwKObxWsb/KTJapJKihDNKO3FgwNj6BLE77YBgqOwFB2YyedljDwPgi1K2e95Q/K+qdI6mIMhZF670gJ8renNj9eDtqDx8kQ2RWDmnK6Q5sQBCIzL9pxRalF1R+BP9BFMe+ed1v+tcPVHT/nN0uSAGCvBzw1ZC+TxCr0ExA83WqVx+sgyp+3anZgEFLnNcfP48W7Nj7kIA+yf7Nwv1hpEMQp9nx5CauF4OhxSwf8RTbR3sfJrz8/9GxvGiQdQyCBpc4SxPZzMJ5n7EmQYJucqK60z9SK+E2jhRSVnTZxLdZPQvZaiybq/GehTB4EgXrxCAUkzpBgcxWezF/BnQePi9c7yZcQJYINjSylATWXcKVoMAfB0hVsa1ZhiXDsQ4nXayuSw/WI7biM7Iew4T05oyejCNDpWfnYL0L4DnWhhrKxzvQ2XBJpdme59vyYeT3Q5t7q4gkUYzXimFIU49vpD49uzVyplhCpCT8DFkSi8uQtpOjsJJ42ODsmzmzxZycaIWW1xlwioMdm59ZnN1Cl3KhRyb4u4LfB8Touqa7QNIssGhdjcpJzVDEwu99gN2H1Hws2HFO4W3H08PXhNVQTBG3kQj6m1wyml1YucRoy8n/D0P0b4EJhq6NjLgU688v1jJXZNjxdpVzCBRjH0AFybmcVIXThppeCz5uk8lc1TUgwEplBVUNppo/YEf1N+172FrSXi3R5gSvOfClFc8XVPUj9bxPpSNdVRbDIpXG0ZGWZa7FgtQk5Gy6+VcAEahoSTE8yQBs46cxvoJUbvLkvZ1D4eQtrJZ7VqcDEllb4kN06PCfxrxDTWh1LnRE5m5g8CGWRzelnKInMv3dOXHyvstiKZ89AdTX7gXR7eKmQYHjLZNBil0ow2eVLY+4UCmZz+RMLrv+ZynORg2LoZ5XH284+qQdSzrevYIii0H5MUk5bVFuBa1NwZUqz5rK9hOy5xfJnPE+g7xrDExXTYnBrzAL5CY2z/QQZKfUHZQyacoRmBCx3X11QG548WxWZMhKalIiZJbHIp3oZlnEka5XJAI8Rz39t1vT0rPo6j6rzh4gJZDsQokakzk0CeJVyHbR+Vf9bKA9ZxLsOGP+TZ7zaw/jO+YNpFzKYWZ21Ko+tZUTuvLPE9PScENRgpzzwOJlFOOMdEPMz1JxbBXK5dnvBhR0xdQk9WhlaQESE7Vj/OCJyG/sWY1BgxzT23zyUgBoAOgRdprueznH4TUv8fSoR2r1wvC5FKpI2Eh/CZsDOZkzwZQW7674kaZKkSYw4nSdrd5akH/L8flmeP8sJfGxVcoj8S5g1qBp+VEdIcwpsplSSUOfDG3z3URMQifLkc1YIw+wGloqrsV65mPlD+uy5rM2mghIiZ4LjkSx/0hFg4H0DprVqqOW1HZPU/Kpo1nN3n9CK2g2HAePWhVEEpQB7c5JGuz3PH/vexyqp5K8qf2IrBgs+Z9SuX2sST4fypQ3piOZ8/sk+Jtpw5SrqV03mu2T2MHhYXiZUU6eoZ3znUzI7sif/SvziL01AA7K4pgxjZlfXKn2Ph7nLIP/mFGa0XQlQzVismQrrS3XzIHcsXs8oxjJdTpZZoImdq/fmLvnxXT56KmyoJTMXf0kHYtJKleNSJZxy/fk6bCcr30D4fKrQKfaL5+PvrtOX14h6+e/o08eXX0AH+LrqP4pD2hwMpoRDLL61DgHDWdXcz78argEZmpJUhCRIRXyYPS3NaPLY5nMS9rHND/g7zg+Unss5OWkGo7aOKglz306hXcrtqWzcOsP4OuU0zrV7pDP+NdotJJhoJm+FA0PWxeC4SG3qtapWxhSvB6Nu6Bl63wyL9bTND/gb6of2Og5pelK1OzcpqHR5S7WMLInAu6C5Ee/i7rAM8uS0f9xh6A4vscjUyA9Q/q1WDrbMzSR2lssIjY/FpFAuWUeER+CUPE5SQ9nduSHo1MB8eR/1Xd6NDwCngJKW6dXG6pOkytBqTo9PZfTUtPGCCYv11fzRQFNLepmqd2CbIPtCaDFbycVczFOlSCrIekg4vh0P8o58uTAx+xEFpL1uddeDcQZlHNPovp79gyQgFTHMkl6PL1BQcr2mfMPS9nEO55Xgsn0C0r2YoNSTHgXdHPbckEi0hJ0oX5Mcu1cn1LYzxw/LGSWUq8wL8RZR7JNC2itfC1mUxaONLOcOzaHzaFDOVYgr13hYMDxtxk9txDrfP38lp/3RtoFgV6G/QgZLbBeDacpjukZ5mxo/ja9i0EVl+VfKqoH9VRJ6BFr5YJ/kcZqJ/TK13985G1IdrshjkLYhsZxPmHBt+gRat/rjj6xHPHs/fNgyjlkFtUD2JtwWYbXEouFCioOEL3M8Nypp4j8t9Vvy3quXHafj8tbTgblDkj88p60i1ojpTn/+UqilTM6MuVAjP4+xTuS5rDG8SEmTr4aWs9KVmmHI0jIzWI5ggcy+j3RRA2pFokwStsNOJIi1gAa8zE92mPkq/5V2n4ScyPnJW1FsQcKY/HnTFoCPro3L1YvaOyv37NaRkseTKrn5Cwa3vh2NCPNXYjvP14S+Ve5YJxU1mDoba2VqJWWAiLFPLeybvsIPbjLMjEkuxSEN1CJ7NRQXynJYGKD4fAIUr+naGw8KJQ8Lk5nCj0spdGL8umLY4khRGs8nID0/DUFMMncNAIZKIMrvi5drfTc3HkLEroJOX7ZPUB9Mje7o14ZfyYY/sKrRFL6VnmAEzwnB9tHBQBG6bGZ8ET/UomIgG+yvqpL6pCDnDlS0wy2TNgPGYuXovJXGsnTdx4TFNkMoOR/dUNu5ZWc9pRyV7/dAiUddzlyljtNSuZffSG39XWiqVKq2RfVWxe8FIt7dXEatSB/gnktwM6Akj68lk7b3GjUUsjMk9Lz5ET3N04zv36FUqXRL7/4Fw5uvt5MR6s7na0t0fPDnVaKh7czf6wt0XJQaKi53aIOZ8JD10ZPlGvXVVWBgXXbVqxO4Yp+tLDULg0aZWLe8WaTheD27eY6sKYNi/mrUhiMbTqrQvhxVAMhUzDujZXSpaFVd+WNvXaV3Mz+308jFgNTCoiHvMpi5628S06tvWKGr9F6q/ZER6fIF2COFI91nxNxguN8sGxM9f3JKvO4P3rSEloRploFTREQwtzAShEwYf3k4cAgfwQNvj8K5p5PONBPGVjR8pCU/HAOVi4xT2DwJVljCcqEVihdCHuFxo8Uv9GUgCF4dhm22ceZcqHteDJt+0yq2/S0+5zX+0alSPIj1A/X1Sdl9nPy7VD4xfJ4aEVjnLvOYx81/0MQm0f6rpbOnGvcb0vxnWErSJ/aQdKkbEVbHVrds9Q+W86TG/deJ/kw2pV3mNNvS5PerrQywbdi4Z253yNSeSU2EMyrgphx9jnCaR6G1Cv9pqvOd7OpdV1ZcjfiWxxDMmthFlIVzcMlnOJ0e5AM7QwRbr02aCDhD1mDk3EwqVYpNUbmcdSYYHYDHQ+PWXmgjpIK1JAlMDCE01qtv4OhRN8yfBw321QIE2Vy9Fn3Pv6onhf0zbFFtugA4iwbeqMA3rbHBMjg5m8IehwRW6gY1Je5RAAWfurbzWQi21t8DMezUqy6IOAY44r9E0RFb5PaPD2bnFafWZ5LenbgwLIk6Oxn1sqprkQNv8v0HJFy6RwaEFcCihXIYDpvWjGGnjQJNt0yldB78BcEbRJetEt2OcGchL3IaPj2Uf2PKAVC1cyOZWPHRz/eX3dJNsdMwJuS87SaiZnWXRqk0PMUPvnxroaVJ8nZ/3juXaIDcNuWEbK48tYevRVW/S6r5z22HuRH5WW51Mu8Es7/Q6Xk5trNbPaFbS7jiotAcmlm0pWIcxcwqgx1LNnLXosVfxvZyaNRNMsDAVZ3ka/KN1heSCYMVrKdovI2RlpyyiQhuRf1lVhvz+68waJilBI80FLDfNWl3D8+NY6iAoCIZQCDzyzVtyNKbuOps4kK6oeWnguzeCyxUPW1KHZ4aSKSFmJiJGF3LTaTk3ZEtEaBNcRqlV1KuTOwLsBoFvTbNTi5/Kpl8vHUWB9PpgJmkKWsWTKKmFnmF7RHXcZQcH87u8sxKx31/JtBZcOXl2sOtTTT6NZ9tAlrPH2kSUc5Gwx0kvtYTkE3dV58LZiXLK4NdDNhT2KSQLKKbzExVfBCNo0qVUm8Esi/GC+vrzmwY192guk9S5+VBm85vZekEJF+Vp5AkvZySMo0LVDvkQSpwRAekHSoohsuyNR0lkJr6536sHNdjtP6jPaP+pKddwAvqgbYE1KkPE7DIHFB/dmIp9lyBMIrCjCxAlTfAuv6p3PzIdWrqNz/ujMd5SD0sN97FhDTZ5PJDVl9goGkbDa+jevDWAUlpSV3ZC5kiBCAYz2pWbxfPow2yKJS9h9VB3TdFp2lf9AwbObfjGjKuOaNFDykX37H7qqo8Bhc6FUJdmaspnnvFW5FqjelgXphjfkEjyKqZeBVEz6s6XOvuKzy4I7DyodQBeRhnbVnHlmhInRJ4gJJnDXDCeyKE7IGPKs5RLWWqghXbxTEXPTIz0gf2AyWvjEHsYg2XDgjfXrej1d2ahUWSaxDI6O07cyDZmJ/6uHobnnhWkLTNr0TIkzP9w+Wjv/DkjuDV0auwUzVQu3n3Zdz3DAkD37+VLqDZm65/CVymZCZ0QTmmifMjZkxQXPOD8IlPAbHBZMJoyX+hVRTHY8Df9Av7TSLC8JsnJT0LY6cWZup93wPFcNk3zpwc6aTUprXx/ZEacfJl9UqwfammUkhjLJsVYP6GpZzOSO0rL72pso0VWleCEb/CtWXHgodPvV2cLPe1Fv1cOE3BO3VCL0KM4g3dzCOJdug7HZJv2kiZbWMmOzV8YRwSPDQSaZVpMz1o3Bywo8300k1vHziqZDddNTy4OcTSlNJtifheB6Ow9LQKp2I4zIeLOOEXMUwm+r/E6fcgBkw3Nk3hMdkNhFrBA5GwR6EQYQRQOV4+olta8UpOshi/SCODbT5gA92FahRnNBRrUseaEfiopXcwUQ2fCf+2EABlDQO1CTEbPPt78A+hZALSfcXcwNvx5x4sAQFldSvuh8HwYVwI0TOGlfrUtbd4de6BsuMRPUMMfEEb9v0IilMuPo0hyqXUXqE/7MXWXADBusVNMt+53YGadzOnQbQvI/BafZoUZoQ6VZGG8No1ElT2xfFq7fyBNKytCTP5YhImbZwbheMA5HYnXUbZZvgAIbtgau6WycWfuvml3Ndqrzt75VL54L1tZKq+FhXvegSZr+ZCbJPcKFUfQk0kiAjBoJhfH2EJq4Ma45ISb74/50jq1xsDhURWp6kgRnyH9gDHZGfM21wSB15NRKnTBA74WMzkvzdbvLPND+72NUhQNsJ9NMIm4aXcpdFTDrzdVutxm0ZqleR9yYje2pjzd2Sw2CZUr8x+TUK5ujn/GWXoiYZChlhrh0UPx4H4pQbn9DBfVEwTBKabPElVH/eSzdKdhahavthok+29tvV7TdzUM9r7a80DN3V0cGzp8yAOWHhP7n0k/e6/0pdwcDmGPulijHr18/gyJ5bsQhTcBAxswz0UYM+gSFH7Lu3HOiCeyKfoWnKfVB2zZZL6CyK7pLI9+UHFKxll8rKS72E+khqy5EbEysQFQx66q8e77a0iU4oEZ1BErc2ECjE3fjquU1JaPuc3ghqYtJiqNYjCCjvIvOVBtAZo8U7MM1QJ8YzYW1UJQw+gmmh5p6ofs22uE9VZBf5EElRJvu78AxdjzMVqdAFU+cjCqUzuJzpoSbblxuAzmhZvAXk2fsHvssDJlPcmv6kVOpKJl09VvjZBd0NTVARkhDqb7PXwi3W+z0Nnh4C4QD2d2p9mCCysplwCmGO4hqpsJ1kjC3d0s2bzVemZyCXqUjJTAh+ItLaOBNkdF5PCbaH6O20aqe531cAHlBD//lK4sAPxYXi9yx2iHPdDKUKbKl+hikNTzTljlbgBdWZI+bv/G0z8u5OcL/p3Ho5gYiJObY3+DecWIyPRBYdQhCzS44OmXnk9PC8OFl0Go35Prp8yz3FMcZT6NE3cPSimlNw0A2nv0Kx3bVvxlzZWQQ/Ru0+rbK7VYndg3g/PTP4+quEQxqE4/ql6MGSxLcnrTPXLRyEruRNaRsR9hQ9J14Zef2N4q/Wxupx8GTGjIhezRwDmfvDgQ+S7tlX/ZoKZg+8xdtRWQefpTWpu2CqvnLQ/sEiYzoQ/DvDs4M/cKn++mq6vzDe5bzmAcO5+xf542OjzVsuSlDGw16QDH5H/9+v/sAa/3D7x8SjWnwlu0zSLOoPIl0hI/46/OwYJa761V54hMfmi34LLd1GkflXW9mV0aFUPdUd4tXKJhUaOMQz9ayvg7W9i3FgaZ2DmsMWHBL6ruZstFm/KWKqKW7Lf7lNt/7dmRSOAPJAAB1LRPW1wvmljMpb2USQS+Lcc4ZyT+hGxzhv3Xqgy1ZXPVGnp5eqEWooocuUKkcWZi37Pa0zwhxZ+1QuKYHxpaxb2tJJooQm9Z1Qk7izSom6KJBIiiR65UF6MAmFPOYtyl6LSBD3yJ9g2f9r+Hyo6BcTrV7RCCYaRyPSL1Qlt79sijXLY/af5XCTFHOtak69AYLrNOLsHZc65+A46YkZmALPDFci3f1a3z08a5ieCPUKU5uHHnhf8Hly09MaRP9xZ8nQOi514f4ueRS6RMq8t7bEqNV6EnFEsRWGFls+uuPoNV6PKSI3ke0XDd2ekXlngk6RmSfDOpIrPqXFyylTrk9oFuIPRxgPJH0KHcfl8Wwu3cQO+ov+6sZufHFi+FNGW8hhKUu171GkY1y1EuUEnYQVM5M3j30LWFGTMONzLgcjpNKViC2r2lHq5lF+8mWuzHm6VrLdB4++LQEXcfnjQLCyoCsOTJFNPk38kHYbbsz0c6tgcGdW5nNWNQxhfxv7PfUUXqU0xQWMz1fZ1JlwxsQDOxrRldNbZi16Dr9Zc2KJEs6+l1YY1hNkSB/G/av2tgFY+Ul1yq+RjGtv5C/s60W7l7yd+6cckpCydfjbM/+SUnf+aQXTawv8hGt/TzIJKXbz2IyhZIN5KSPkZqad3UfReiIceA1UF+tvDts1Lv/9qfNL8j7Xr+o3lpFeyPM6lnGCTJnm9lZTA/1X26fRpAwdNKDjTEqI6Cp3FX5CHAuvCWeke/jEDhzLhafYZU1Rd7s+FTAUp3oQBt4KfDnE0D1bjL3fdg/7psd2oYGrXJr6OAUdtGaJlWnGaFd2Zmg3py8Nu4tQejP4hk6EtjPfzXn/yHq2rN4/pP0G+x9jL4EAnhTY42tBd4lapf04E8B8Qyu4bbHE/B5PXSiMqkA74F/tXcOFTUOdKc6rXG/nJoX0IG5zmwp4W3afAaa+O+mB9Rfl1XznwzkN1a5opr7Zcjvn79+gNvhJf9k287ppoP5uKHfsfrTH3aEz5fV32glhBpRUNoZ3I6hXqfAPUjQQueGZfvtZ49Xmf0jLUNaRBeyBESYEuvMAokKHgjpFpVWWcdTeoZghS3zoEzv+9y56QlV38M5rgq/4cl42MC3T3v950cQF79Y0vwUPhdsSoPNTljePr1z6dVakhmmgcIqX2VUy0Kr4wcjBYY/+OWZsd1RQLzNHPcBFKJCpyNWqcyg3b8ee7vuu518GOj3Hax5qr3Dp6ITKBoUTTFDVq6xM6PeVbPmx3Rtr13tfglTNg4QaJDKj4+7YyPKWBL0bkpMZ272yYi1/B7hvvaAfy2HVChGLF+2Dr68+p/Kq4qq9z7v+X1HpkHfYcB3qrSJ/xhCujudER1TxkhaNPuRaOjCEYt3TEXe+f8q/XrOS4Nbv1+L3EF4na/p7cF5X1urvodZw7r9C9EdJ/bBtw+BR6+iU5RXVkAovUaGtZtXLyFEbrmeGfcM/zyTmbac+paVru478vb8gkK81Vnwh2R8dZijU2OIpicZn9oM0OXcYPhjGkDKk8AlK1k+JSx9OziqbEnQ0SJu7Pl1Z5/q5BuLW1c2+f8B7OJeBksgNvn+ACh7GDTRAEH+auIlZZuUdLdwQxmfKknr2pRzGftH/7ubbk/NKvHi17JW2W3xLtGWpSAtVcMPxRH+A+88ejkf9kFY17deVx9i/F1++LxYaH4nr2ySQmTlna5+7nRuQf8ypPXHe60GS3kT69JWTZb8BzH+DvOj/UAjFmIbO5DoaxIc18goQQo47eURXD/GPQovW12Z0GW8jcoVLLZ6zDpK31v3YD8X52ao7++8X480RfgXe2IoXVUHxRiNwrXEpsWrfL9WVG++W8Out1KXkdt6XXjmjzjz4n6Dw18xFWtPw4tyaxpC/qTocDsG6VkXOV9Lbyd00MmJkarUe1inWAXcdiX4Xt1/P+M2yF1fDugaGjkOTHx07C+vqGH9vECni3pCr2KVEv6eeu35Dnczz/t3i5lABpdl7PLmeenxc2qzKW5qu39R/hzozOOnR3qn+fAzxPrqw5H5Nh5uraw2zZyPRqxDBaj4OMv60nVZ8HaA6BMw3vM15h1NSf9W0ES/7KV0dEtrRl7+4gn+DyktwGzZ+SO2OhW6XCdsU+7fid11HvL+9pxfn8q44kufovoLv5ztIAvgpXnde/ZFuYintg2RsabV/A+H49b6OQGES5XMcwR3NsrpPAxrS87ME/4UPBHUVlzi9jIO8P55zGGMHqWCmFWw2yE8NGeO6b7uvftOOyWPcjLPPpi6ximsHmjHyKaZkZCSpsMGodSGBXz3KNJ2ClYNIbpNTUbA2eWMa8keKm+U82IK4m/Wop5QuN+tTNyJfbjbY456zNxhy0NLdZiP2wrwxt2A4H5IisLkTC2+Tda5daHohdMOQrq/OSlaBINFrczmhIXHXFve1ZyA0X1sqnRsi/tWVupcG2BUsEbHTCezaelnJQGjfxAHIt/xr7O0QPTB8rxWuny0MU8eubPcrL5WVH9BH6WbsCBOQIZn7H5eUZ4mXs9ABG0NGitufnswVqjgTuW7lcyxNBSpxZr84ZMEA0+1fwRGXhkyeamIkev3vUWzdBZCmlmESXyuXlEzwLVxwmCyjayF0cZYADwXzrkk2S6DDJq5VwZmzSrRg7XZqJCm59kqeFW/HFPyQrlu1OySbJP4Cv+TvMu6li1p3ypAo2ows4kOr5fzGnZRuI4Q3Wwmw0db6nxIi00d0ec0jx5oNotEjHUeIo0cib59uObJ24lskdlKjKOwLOG0HRfzOvvldm/QFMb9zWPnG8wn3C9hLdY5rXekrK912TmthkKeQpTco5d6NpdfNBxwbfa/rV30s46tmgyft8dp7TCh289KmKsbosiCWnxdsDh2kXgUdP6UXFFaGxzcpmdRoXKl/hBwUevW4dJadap+3zvt5CHJq3y6ALeWgYZfX97fnShZmLS0dLamHot7l30V1H7O///YfP6V/o6dO8r+e9reh6x51VFeiRjh0LxzhCEcuDUfFwdn0+crzCjbT5BNh0lMzUmgbwvegmE6y3oQRURmv5MhZs45StHoxq/RrX8Fg4UDoc8pXvhBP7JGlSPaP78Ukk5P7o0dPBrE0gJINhUHNQp91aJOub6rEjwD1E1XPLLaD5ndxEl2zCI8nGYUPtspCNMQMbLDvR0PjVHhLOCHnNQNo8Mid++dn/Or8gibvCvoJNszSK/dXDmyW6t79yVkrV1ffFiaqi+uZv+WFASiCtfKm807cLhnIch0xOt4QDEopQgyZ07vNW2f8q7j558cKR/jWMpj4lX5n+HVfYJMyaRa8A0y48sBc4QhXLuMXyYCkTB6MWnHZOURKkFD1fgd7nBjeCCg6IBcbggE3WEXQWFH8evEnn8eYNuBXeU1SMnm7oh/bXwbrytwPhixZYVMV+1+XgcIyjf/6lmwIQmQhA87nJvyoj9xYY0hl6iyfjzW4DeEOojl3SsvpxhA5EM7kvOLtlXZoriCR66Aj9NCycZHH4xU7x7ACIJzr9rtfbrBmi1ger7exxy542GiCVJ5OPmDlBLa6IJynQ77yOzvAEQzxLG28ce0E2houIZyn5SB13K7QSmZ6/6NNNroqCGfd6cuh3JhXwojn8MEsnG6MK2klM7k7XQ+YsXtDhFuFzFD+SN+7lYZE3h0fYuTGuDKxaG6E9x7bN3BxmQcERk++dOM1pHK1fqx0C1yBEc/jydeZ51et0AB5gPw19Txb2YqsZGbg3rLiYfN+hlHIB/QAiTeqK2nEc0ObaGH5R561tVaQyuMzdS8PtyvtK+RmNnhxZSBTh8fnrrwxrGwsmplSX+LWbooVEM7SEpOQHJ6GdCO/grRioLN8NPkYzAH0nrm9wPEw5QWVCjELMLHwzn/F9sm6OZl8x7W0aAuku2kCv//dZTqwoRq2CprzkBGZA9wuQRgzkrIBodl8lQYaF2Qjw+ECDifakqnvSAzI5TJBj5MNnBa0nF97wCXKpHKQvYIPqDD70HBAiIS0dEPCEw9dJRUy/hdBSS4rEmlgvH+o0S9P6u6UUqE05/vjRHNqf9Z30wd8ifw/U4YRWGJmgzOWR9nMucGPP+Qgjj6Cg/yKqXS35RIF8MuP7pSjxSFKcg5GB/PDG3carM8nySor0L7+0W/uc4WpXqfsJOrGA+d+Zdc2KeTJOphq4Y6kc6eB160tc/fxQOoRKI+PEL14RAUb+G1wquUPas6HYuHPhLDXBooA9gBQaHQ0ILRQGFhdg3XD4X7hOWB8ZwjyM2UN83EdxmD8TgLcPuGYx+KOIdChBDaHr1K6IpTRERiei8NFy1qORUrfpSyANqTtFEjV8p5lMlPGPL/lybgkaZwQHx1TRLKWdsKrODwSTfPyIx/kjcczEK02aVNvfSVSj0gL5lzDJYvXdyMMSbmNFabDhYnccugYx+JTgGl7kYrbJzizvnoUy6ZnAc+cHyKTq86YkzABrb9TjnJkHN/MekkoTdJ80malicFThiebUKfhgO+/+7+UHNCk7ARxdPoZxaGSP9aGdCvytC2SpyU6LRaaUGWsS3ZZuTwYx/FnZiEHVta0dLcIEHgSCtrdkTRKFU2sNg6S5bKqhVhvzLAt+fVSCUzki96cJMNB1oY0jn7KyITsqmdF2uS6CG++uiPpVGlK3QJpPXSZApDFu4CL5rMrBNCYnYVSh1P6gqSnlg9QR0XaMLrMcQBKDReoXXjgDnHc5WbntkA6ac35fJuopVv0BvErDCLtYw1aLlhopn7OjIVyVVKQxqzyKx9+X3UOlJR0J1nA78vO1M+oN9sM5tQzCPQXKJePvuhcvh/dfs97vkturJluOHtcak/DJ3gJTtCw2IzlJgxD2a/yNKKjP6fAqUySc7Zv7ckQDA2bG960a7fCpjy9xl4OqNW2IsxAuzhTYcFeDFzPeFBVPgg8e2W8+6gbZzqJ4aczZjYvBRTJkpkO6uP3LsoqjuOzNuPLptvSzq1/FVivw9l9wnLfp1dEd0VlAOZ2vxUvIUVA90GpGB2v4FA6me5vMlyVxDjDLW0nFJK3QjwCWeDsipD+0eSq9tkJ/4TcnLsraCgni6Bm6JpzKT9OUREZFCj1pSs97+Kq2JhX2lTD/ENjJ8nEyjW3IXV4f0Dbuzcy+Zcvmq9ij12V0YY+lq+CvznGole7EBmdqS5hGZjzkor8mFJ+L0QHt757lbY08XgAVENub8JYeaK8vobb1wMSNMpyW7E+yuv/7oZBO8+//FzKmuQAcYx24zmAvFmKGUYHtSCLLxCl5Tvuxk4P1EOF6Oe1PoS5TV8vAhv0b2EdkkVwSZJwOmlamAnH90OHJHq8WujwXi7YUcVq59tRE81TrsUzY4PlwrBhvYOXUj8q+6+QAaBHJhCfvpOgkNoXaRJkb27J7KU0bm+tEGi3Tcj+JjeqN7KHgD688I7Cz8x9JJARdGoOLJ+IRMVhBxCm7QQzV1QYwyO2doaLQPVEgJgqN3QhxhEg7hDZ9IUY49DBFoplBsZm3pzKiDhW3zBpDc0FNug35qsYSGJgc6IVRQrKd7AwhNj9dxjIS6gW7SUBEWJgGOG7PKdB0n83SFKH0+AulsfMUyXPOWyCv7ghaVDaWJRYsFC+Ll37O9tTuV7Qt0xLU2EZimTYIB1gWiHW7dPwqpliWMnv+xRnyBDOS0Jgp3D09Vw8GAFHsQBySWaJR0Jv/oX5DAG4RIC8qtXcYH4MLE14aW7znhsn2A2c5PAzDuYdACSgpaquWDg4cVkEkQ5wqTOcQ7zGA0CRqNO+YLSSdQcD6wPQYgENVAXEQwKIBgVCksfD1C7m8ysEvkWEctPfgaoKoLSCDRKsQ2x9fvhPG1DtxyZ4grNbHnMUh5e4AsKCcoLpkIvAOIqiKGlIaaSNoQgbEQgfGSiHDyjFboVhAYCwB8BS/BY0AbGGlEa6HQr3T82tWnzbIUG6fBsZEp81C7bycYqUPgCVPGDf8EguxMQzFWX5NNxdcq18+5XiAdd1BbzZI+auyS76A7jMKcQygGWlHptPB2AQBLDv/gZaY5H74NNAerxuYQPQUDyqa/KsByTyHAEIJAH13HEAVKmtGwG2+ngOLFh27KDjhIiEKybsQ2GYFFbfz3MToDJ9l1lo1QckBPMBrcwnGWONDZAkpvsVBzpZ1FASGjKexbJ8tGw/KStqHErkcHx7Z0lmKXvjSanSUIWBWX308Wo7EWRKh5/bSAyQhj+m+oGRFgAqiIraADQ8JQByeJOE/It0TvIBOzwJofpxZRztWgzpIjJeYCK2/pGQrIbTuFCQMnULshy92zoQSVN2DyIwHwBUcoHLw6DTjgwzNXb3n6ePWvdU7UHoBGQDzMscQHYK7YBOYoAj0FUw4T0dCU1NIUvtFVCL0HUpP0oKp+4yJfK+/Rf2qGBdVs2Krs1C7lN5qC2KMITQQth97ww2I3eaHI4AoU6jYpoAgh9AEGpWjtdmUwXat7GN+gWeVKnDHy1I/tuEn83F57+QBMEthOFFSglbimDjjPo3H6mppl7RcTJ/lZsYkL3M7EndT8XQ1EOowfKhnXtKnSk4EqmAf6UTh2HeOdl9dg4CBy2Rbqg2G6GNgCSw2o5EvPxQZFYRcDnbKge3q7kkm9pgj77D0lyYsOJQs8qo/RVqSv5oQ5MEb3XIb35Z+mj098troz2jtT3oxivdFveaW+iWtNnr2nits+nTJMLJy01jm2p48zPTPh7+mpsKMqtfVspxDEMTIEi7Iy8wtDoOUdSEYPkgf5uU09Ioxe07j+XEAlwqcWk8sADRqfqiZNSycJlO2n1mMUqTz1K4/ZPO/Xy28yy7/P7+a8JdQqei+oKtb9mamAYGNEb9KkDWQvl3oypoqkMHfcs2ON39GAfpqTwzceY9neANvRkC9a+BVK90oUS6VWUFJKgbNh+Cd2GTApvJqr7H4k+tyUR8nqSIuYABkFWe95YknAIyqo8b0FXkvx1C0DRKn5nLtMqAoe78W3fZsmmpGgd1HLXrN2JqrAs11w9yFZhyrl7IgSwx7gSz2F4yMcoypqWFMLFdG7egNuhnZXeBQrvXNvOjR2X6qSsHpzkqPKoi1CofC3rZeINb0m7nfkeKvSlJN0RnZCu7izk4TWyHUcHr2JGuaBWsS6YVnUH765JT5gz0T+q8EXbi1faf199qo7edc0yn0Yag0WiSmzh0Gc1h99t4f0eqYWGJ7eAQiSafdQV2FaUlPqa4jTZujqUCKuJtBzPbgJZNOKTbhrQnQFLNivaclqfMGT4Ht6NF8Y6eqeptRQFtoo8J82edn2uWZ3kVgBFnBK0YHRopoMCYK0gvCtVAptW6DGYRZIRn2hxkW0pK07QYV+wRxwapRL2FCWNYQwhADxz+XfavoIqLdCMrQXRWci1m9NMSeCYnogqe+5jOk3uPznB6hr3iJ7Qe3cnZjAk/LiS3CdNljYqAlrT6iUMaYP1OASaaSADuXlAzmBLiB9B+S2tuWCy20L9bgXEK/ubFFd0gxvcinVscqOYx2ggSfgROfD9VusK98bCS5UIsoxzH+kTosgamhKmEblGJ8+LqUkMq+dYPXftZpDbSkkEeY0m1IdOtlgTmBnIcyYwDUr7jVUO1vjJkP/JcillxQ52FTzZ8J8GSoUgCSmlqVOUj796urdGoqrAU4nW4DrNhUsqUsxWvzh3gNspZKfvQuGWz3NCtdo5Eg845zbPzFJ0uVKXFWXSukmihZOHlZ/Tz4cwRvKFVr1IeZD2R5NVQrgaOZNrsATEtUPGvd0IKzWVT/jYhDIUhAR2XaafXGTm56xcWozVHKTMdkKxNN/aVkeN1R86NyeqD1ah8c440EHkRfxeNSxSgRPRQ23h6eOy+NpstLSYxgNPHmryjQWl4Iu9/pCleFUY4DIgpx7x5lAA9LnJSkzhcE7CEWHOMk2xBJhLW0taACtkLBMa5NI0zjEwAbR3rAI6SKvs/Apm6aK3H6ybSynsQessI+OytNgkwPqvHByfFSJuFoJEYsTPTPJ0zTH8+yJyTtq5Q40n9HLSPFIC8hBM+8egr68IY+SVQmFQrlYkeYzQZhY1BuVQ96YeX1MurS22sZ8z0ZMV65yBma8u/DQpm1OOSwIxEPctd+GeB5ro5ERZj2RK3GIcXCtcvSUmdMnpJ1QIUiIZjA4BVaohaHiwQHtvsmAQr24xIcZeuyls4/pJz6rtMKJlMQGO8jpOaYGMaC6vrjpVNSiYjg6rpNgHg1bz5TkLh8Mq+M4xIFkQEifNkQgj7Upa40025Mzbeno9cIeAUfV5+c+Sf9DZD5nJBVYv8ms0gnxFgF2ZSn7rP942dudFt9ZmCtL3IDfdfeuKPfm/LnnmB2+iUtONivFoJbNmkBGCUqIfiVMEvopAJot6k5pxG2JwDjUqxixxBD2Q7AbyCgSGpdD75PlJi9zwvFcqNZ4CFYkTCoBzy48jZiGSL1YS2CpSPTLCTgG2RsHWo8OKFxhgSLIdyThRqnJFBjDOZ2bVqjtHVnF6HFcdDM8SdLFYrRH7Cnq1Y7WiDm/NyQ8IIA1/jyJkb1K5tGUypk1pGk3xEBkW60JqJ1ARFOwtJExV+FTtSmYdMYRjolh6iOCj6sUGMUnGMWaQ2n4JNkFgqxegNVLYWCQ0x9kcQt4QDzNBEFVzhSqdaHVoYN4L+FYhK0rkpO8cHnvMvSiRiYC3KSphQdZZHd+yQztehiWS9XpoOIn2Uq9VAWBH/z2tSXWsVvW72iF5KvxuZ8RtEpyPHhevLS9TRfMbbRKkkXTAel3TjEzow45FM9T8KULOrQYM5UJyrLNiyjYWisqufBkmQnDDqPEo0D/cQJTr7N+NyHRINpZ8eXdseYJiqWVcVEHlHdWyIGKsZPeJtRO32mKJtHkpuImRWRiLmb1n8671E25AY47ZWaaUNUInI7N5TY43FPQ6lftQWNaAZoEJIAME8v5NirA8ChZAHXJdL0M3cXKmtKnGLCqVc1a4VdoF4M0mjHWfUwsx9SKlv9tIrEJXbivfvIkWC7uLJFtox9d/EqbZdqn15tFxZs3u1kLK6DloSP1VHc0WTOPBmCfgHyiR9QMzNfhRu38oRJQqh/YpsrvlIuEmA2EmMa1D9bQQkRlF8YYrSf05YpnLbWQqfDRVCZOxqOsk5jiyS8W6l/Dy6kBjU+mIYXE6SgysND4EBHtzLCexZ8+neKagNzVBnv7mO6juUq1P4y3MQ1zNo1EGDOF/r/balflHqLkm39Sfe10hMPdZOBSvSSERkOfZM1cVg1Hvb2e0D1lANo6uYw6EthlZCdDlS3MF9fgK8Kxf95waTF8whPceDaxAUCwq6uYj+a+3wMoXpk7Pqhv+qg7OXNy1YTS4D7nxFsMknosnGva+zqYhWzXCBsktu6zm1e3Xaq1OzZPZ+oKuUG2m0Pkd//UWOqWgXlDvqXZf0mrkKrOh5MXl1Q54C+GYO4343T4na+2z1bmS8RM2+lOTcZ3frK4SoWhJ9X/cOeR2bwOtqV1kFW7czd04nw1bGQbxT5K+Yntmxk/7+G0euGMwqZtV1N0UL7cSV8u0LU90/65z7rmRroRr2z6E3hkh1rUcDw8L3QOEW3BY9OYJ9st+9+sc//JEBex+m/RqEuvtOT+StCk3KlL2+7Y33bLKqtzsYifezsT8OVWwtGfoLVBhhxH8CXctIZ1oFNYFr7Aeo0K1iF/D4k06VlBOGs0aKJqYad9uATgibwIVUfy7lZnDNStwATH2LT5JCf32WxM6GyyMO+zxNsO3kSqXyBeUkGNyBwKjM1F9/tWNiHWToi6gg9uCOX6QYFhjmCQf97HX9VIvFUj9K15mAIb5sHNbVij5jnL38EQZ/3hV8NQNp9+LFYyAt5L+EBcnkau36MipcmzvjFGkoQBI+cwTPtWNFQEJKcpgW8ferBAVELVshu93WX9sY2/i1mtgBl9DLCH9knfYfmUiFkPq+pRRvkH9SPXKXC60gXw6yKC1qvbNaf5djmi0t4kr0SzrWW9J8zjFfFUu7cwAjUHLBn+1ItxvJg1jhBAUuvMmcHWp/DQy+a5Oh47v4Uax7Ns+bNX1X119icbX+vsj+5hdfc2Mny2WybY2zjbSe8FfGQCVObbMFPcCCrUu+u2QoKfj70vDRjbP6jMXHgpRrW8+PlNY/P6gkt1wwVzVsvZGJDmisb0rZjdKWRzO+VAQ/sj4XnqHtfHl0OAtfqYZeeOuIPrENPbZlGiTYG7cu/ZKdkefePILX/bL1DJ/ghh5sXrGHXljWc7W9XnrT1QUrb0tdn+6GG8PvB51TvEWVpGHLuJ/OvD4DDnS5VZr40yNbTZsV3fw8PyPV/bLV1gRGL9JNric//n7I9bm2K8rUl4O7NmVzUCIqoXGBZgDsGoEtrSDnZ3xxLValSliYUf5fo1tZmA2IqE7Q5Ir/Dl6Poevf/hoQXb0V28ozvMyAXah4mjH93jrHe81gjGUsuZ2KBgySQ+tRqdDALoJbeSsQnGQyZkyhJX7M0JG9hBup0xZ30VOGRg+HWas5ypdvWUUcJae3xWx9+uLpy5kSzXuOXV1inwNvFYceZXh3kJIkwOJ3vC0CiISIOtvyaF06PixkIUoqhMjffgkKhVj1W+2Gga0y4MkhTqBGxEh6PBg3g6sSxL3k3u1ZIixcph1luGMR1hcovSkABQhg5oQbD3JmCRtTiKb2gRNiphXgZRwaMix1bUhPXsIC5Ppl4oCVsQWPp0omKTpsgyHqhA4eiUQ5nG8sfVzhcfb0sggFaTxCqIjFmeAt4PKI5HBnIZN//VbzCWcLQrwjNeqoyjgc7XOxZzSFWN+tEvApSfbjMg50caKO5W2T4wyO9dRSXUeXhyg7wgkK7ciABFxqZaSV0+0I0xEH28pvm+55X3GoyJ0dy0qyCaXZwhiq7zhJbSgd3vHwxbIfJKbr6cSxNBoA5qTf5pwCT7mtLyHnBb3aPMWS0IgwfI40wGVeMjSFb8S9GTGzXXG9T11aZAcHtBCTBQWySnBbEv3+uMr642mSdMoPw+1OZXydEaXPXIU2cSdq9dcdcrZY1684jDMGLu68yjAqNMjuckqlCBPaKE159D3nAqFjKUHysEy0zzaR1kA/Z2bykY5W4BkiHIC+M5LFfpTt1NZyEkIE6LSrk8r11y02Qv1cp+BsqTLJrj/NpWRbEM/qY83W7ibdV6P0dBzrCNKT1kQr62z3EhFR2eiM+QTPRdrbzNBHv24/k3pHU+b0ih1Ir29dW6IuDB2QeUaJek5Dva0YkbpkF88omkPhmQBAR6kEyYQScDlYdbRxthrPLvmgKRJ9fUp4E2QkcTiZi7aVRhCbRYuUXSj/5V8ihWoLCcJDFZlIL1uXLjwgBTjKGoQvVJBYjJWPmqXfAT2gIt/QaqstYTbdgAx0k06HtgqLocbrGKIT35HURWHPGwwUDfH+827JWjeg2DOvkpzlDtxQmjItfeKsMQkdZxRS0uDzuXIaxAUfDmij7JDox88UXT2zgox6yyEIGoZpqvl82R9G/OjVH1pzCHTPF9VliKLK/Evw/Jve2kCA0Mi7BK34tiKaZ2Dgz4sXPJVnNGRwZvh1GF+vWkVEUhfZuYpz8KwvzvMdOobgiev51BfjEQ5focLwpqgNGDHwkH6wR8tv+/8fMY7kB41jsxo66hPbNJKpx78ZTSqOrcPAy7C9nMtzO0eh64Ff3HPJtYRKcFFTopjS58Mhif7Q9A8FIoAiWsHfo7MSXkv+EZ+LsLDBJ0YfyAAjBsoP2vI/qZvTUWZsKd1sIlEdX7SFz0SoXDdqZf5HwtA0UxtOZA03Gy4p0o0mWql0TOZ3SmK4dfE7nnVeuTubIFG4lUC3H7juVz/7LB9ueEKqWf2z+2lwueJnj9y+/02I3fzMBjeLe978soBhbgWp7vceKchlQfcHpSu5kJz5kbqrMq/7E70CJQt1f46/ezGffgkmUQru6CqZnRzourRQkGAG6R5Tct382xT/GfA65P0k3h28flym/9+6gVbanfJ5U1ikOydmLi1kt+9eWBAqr9JJIFjNJovBRnGwdP0YU1kE/GoF1cfHmPR0i7Jg7J9Q3v1IdI9aKFZ57J/qsymuV3enwvXXcRJm+owJd107tIslLv9JC4EO1FLLeKfuZun3a3zoV3KavbVcvRxM8m7tQBpww6QJTCcfKzs2y8C8t9GfICM7BkqFxKThcrlafmFWRf0Scx2yd6qRQb8YOb4xpah+54no6+Msydu86VmGtPMKZTOfxyYIaOXViAEizqebLfEjtKju+A4fpq+PCTFSqpsTchQLX5OXiAhP7sCDuMtTAXgQBU94wpN7y1M9JhU0grTOZiV81ejyIGviN6TSSVblvQsY5AoQyAmayRWHON3FtPCQf8SHxSkBRMJSEVYQflIM8HYRsu5b4H9EbFVdU2VCnhV+qjIVX1Mcb2zskS1sS/xU8auwDq7ydbxvwmKXcT8Nx4Q5VJqKy/Xy366kvLBKqPUCBxPm6PDaPK3TTJMxKZhqQcTwyoVZtNFfa0hM+YZt8whJ7VIy2xMa7bFSQ33+LudFM7MFW7HlpoKXcbA/tqhFNvNnQwwz8gq2KyKopBIqLRUgx71hTOFLg3O1AwRAjM69zpmnp/ZJdO7ZrITGtPHbp1NKgl4xMz4bKCrox81rkBVFIqPWyuTWcUB9r54tJgiJ4Y6oXnTEZJAP993q7/KBSZv6spJOKYo5gdxZs3OqkAxk1Rq0000fo6ME9dh9tZERYsUL5A11LeVxO3yssvu3lheuNX1lEuBjLYzw5I9DOm1cZC1gnbqKFDGb2E88Tods3zrrWPxx3wTY7HgAhrbmIqIRxVSWewEcIzwHLv1C7C0JwSp2q+6tWip/mcgUSj3KfQ+WSoIN8oNwUL6rtFOLYsdrdOM76+LeTg99DSTVghdXES+b7vXq0FF8HvsSeYWDUo6kJmZeQtpI7PQ3qIejgRIFWc8BoI8lRiZK6wLprMEj1sm4iul/h8d0UCk9aegFi/FxXEk3k3mU9ta6a9iLqkTndFM7+sEl1Xz/b9bZPawiQ5N+wqSz957kzn3sshVzPwodWbXif9ScfKMSPEC2EexRFB79IcLlqrB9eLOLkWjA1XOU0wlx6Na59egqr6CSJsdCxdhcdkGXPqTINhBSJM89nTWVNay5LQXTvbXbbX5PcMBu6FCLtx/gKEG5IWMmKqqoGT1ThiktosSNxXpTzLmmUJrqJXjKhRMjoGh9O5Kq1dgSVJWh8IwlNlLfQqi3TfcvR/aPaNMKTsBFavmDfjOkv1yTfD8Y3faje0rJg13DyrDUuSxLQTTrVE5MqET1vVJRkU3kw/RYbib74tvqEtLLK+VkxYciEg6u1HI9MLbxVM8MU5hAJCER0C2WcFY28rlrdRP1fsl4jC53Xx0VcoQrgdFZM4T3fsvmMXtm/KhXKahvYAvFCqvcmHgB74sxZRz3jF9PCKAgkgKWeqHWTyw5Yh+CNX4Xe54JKz9u+GMDIaOOBKz6gqqE0yjsain3//7pE7OTQr5xI9YNvdupHDSEjDpnothuDBb1OzgK3bkK7P2tmMzghcFB1PaIkUZcsPdboZyzwJ8NeKvEwi7baOX96IH+qQ0RT+WpzCHBfT3ZRrzPuM+QTRujrm2HhPYTmpJQUVbekb9TvhoxzH1VtHbsmDUh8vYQ45eieher4VhG6ijZWsDNVStPICrJPcyW93IOIfJ6jG6WO6Cy6lPFlnIYOoHC6d6Qvdv38sa1a0ZYBnUd302I8Ch8loI9ym8n2bj3ETCfgVsYiXsEB3eCufSF6wo7z8zNupU99FF49dQ/crU4yw1y3IejxVDl6vGzTKDT2totD4i2vmbwAvAMKSDgKIuuo9vNmSPE7WsfYyT85sr2zAG3lWPMT3ActOhbo401e1GRHS54ZgOyv6gaUM/nfq6ooV1Xv48wDUKd2sVPsPIHoo2bO15GqjabNc9vJtjs2ee/KdFqfL9x7XgFNBxn03W14w7AZlxp4WF+zv0hfgJ1aJUAVvLEhJvrpTOAnY1g0NdTFcuBQN3rtHLymzUfAWKSU6+oq/2b8m3/GIfEIoLz5Z2gouQoQaRC0HzhWp5uYzVXfyUXiqmUL/BeUx3EFZ0YpFReagYwQK9ujKpRpw+5/D4zR9YrDGdAcsREfim5TiSN632AnoyYn3GXyrdaEkWLqv3bgOMzdfbRqVJD/YOb1biYRsaewhWoRq1cYlq0adNp24PEZbcuSVo8N/SfZLmKTmO693Ujszo00zpSposnHV3WPxor7dFbFbVWTXlycic6kPs/CTsNzUfYOgopCzXajqHMFTEkSlQ/+Yxgzrvv60H8bh336Wyy+lSrInPu/7DbPFFGRtVRFX9UUG05YDCynJl9W5i3RRgQGU+v2UbJo2jNYiQa6+qRwmu0t7m1/pPsOnP3g3T3D7m7O1jVoWWgyVqp/CEwE+fpQah8YNGXsOq/ZMcZidf9VmaGqdd+FrU182iCs/5MRKW0jVl3jLDriATLTayZwuW8l0qNF4aEVuhOG4KwSuI5bkLkOczz25iEb3cojF8uS98Nnj0yKLBvHTq31DoVNyH5v/BT19/LzECLXBxg5dlJ8oPSoBXlyhEP+JoLl+xrcyxrcoRZSSvUfPvkLNkl28vub3pSfGQEHqAbrpzTSY2Ib/PJEoO1h31ky8gCCSp4NBU+AWJjy8axKo0ZeNeVvbifQ/Dz6gew/vRtdmjvV2i/UYSpYpM4VTA+6ZGzD7gkbQf6Ou+6YxYGXSQ6Ksyqej6l+x7gtwFR8vzcYQUsoKZuyNHdAim3XwPSGZ0jUOF2uphL1Wf12G4b8GyHkJVSd22Y5GPJ9SC+lD9djv4HF2BAyxV+QhodniPWYsmhLuZRAkKRoc8K226Xr1hoF48UPfOUoJ4EwR7Sg/6tN7PWsBv9Zoi+ZwLJ1yRhRl18/DwwZcoUIf5Vx5uceKkNDz8dJQ8+uiIhxnkWGuaVEJIWQfxMm9F5Ro+sUX5bSCwehcNAwJrSJ3XkyBZFE/b8YuZx6C4i+yfbgnBJ3NIPn0GRrUZp2PesdVpTguWPRfz1A9o0Nm5bD41LuXV8NwphE3IQxPFJcCKKyo4fy1gLNt6UpQkflOSAG6+6p9YxaRoPZCZ6zizNTkxzt9a9da/e2H9sbSxnhPSFopg0Xzlp4K1fdK/A4ZXmhyhPCxbb+JZiYJq7yM4ZV3ZNS+l6sDHxZ6lFuw1Eo7fdiZHy+0ktD3Bd5J6grPcSqsz/juFmz84x3deaCEXjTa6qwfEyHpRP9qWcUwj2O4fy1M51HpHLQPTLLi7a+f61xr4AtWP9rJ5JMw++nj66VO72DHsvbLs9xd9X5Jw1Op+SMl1UBqOch6WbxzdpSMpi29cfn0RjwHYkBglYROttJ13sjSvm7kzCg/Uq+mogajZPU4jLcdWVra9gQpCBkaqNqAJsem/dJmB+jQjY5Lboeaw7TAf4mTNTGunSzs0UyvbjlhUgdCuGv+gvbh0LC1tG/qpeYWXv3wUUYpRvcSz6DNzNQsajtFt7ew8rGybU4E5B6s1jOpRMN992Pi2w4fq2W85XWmw4CAyEegUn2ZLuUT/u2Mr5/3IjxqPXY/xlu2ZVnfoqP4pKGPF2dNcdOBYUeM8D9271UZuwcsYjjfA/iEu1e/crPXTq8a8hi4da+2jfg9DPAQsfIBxlZVa9EXzmYQ46/fKmeKuI84oGJ70w6qUv0InQqp+eKxo+PEcadyK5Yjo3k+aE0vrUC5j1z239cDnSdskKxZiHGttLUG2k3pXaG8YG/AvN3Z7XITgK9aU77cqfIcKcj9pwAOPQUuQyTU4SfvotLYcyg+4T9g+NY9xgkqah0QVW0IbXov7XPcKhZ7tA7wsUYHjqhbig2c14PMbz8JPX3Tu7f8xE4c2fssNky22yW5qlagLZXAdgtxpg39wWCjNHUo+8PeEp90tOBvtOc24Jj6rfolcQureKnES+OF+c5RZHKvLiGXHMLns73LF+68Lo3U8W3/2uO1CZbhCaEl/y2mUfGZSs1DK2vBXy1zuGflzeX9znUopKxrjseGChfh9D3lN8+zqRNCXN3oLPkUssD8rFfyTOf3j7cLFAz5LRm570z949i+iCdbg8dcXdgMTXuh+Ry/7dr74aOECh5J4g3t4Ulqy5FqPAEb5w2F2eogJ0xh4KiUjJ/lYZPulPXAzNte6vZK9Ixm4TVG4WI+RckyMaWeiAMkKDk0WAyyHDZ4lJDDzy81bG06hFjmGgc8MUZh3/qSsMY9ZjvcnMBHcx0xyfSbsONcfF7JHbTaVCIj+yxzqdJjZKez3tjBxYu9Cpv+3UyHpBmrfyvbhRJV/B4tg9I5HlYFdRY1VodPFQD72kS49F+14dLl3AHr1APGlEkYeNWOzTwT5gkimSSL/eh7N0E46dZPi3cPRAA8KXB9O8vl7pFdxGZBIIloMPxUeQlnJjn8/MO6fx5ySHXKY86SWzJVXOMSNBNOrQVmxiB97dLROPscz3hBYuo2o2gfwrv7dvY9DMm1SDNNmeZ007GPUB2STVZv7SwMKT2qJ6dpd1PwkGYKnLEOHbhLEMsNM67blQDliTnytE98NT7/qXCFPo99BwxAVM8LNNMFPAVWZqqZYXydX+P7PlC+pDbHk+rCb76VsUk6WzwHpHOw4UZZ4lk54/mlJeOxOUs9v5d9ELJWVyr1YXqOKsfVsWyTn9s+keZZyzkKA6mefCDcMywdcXGDClaF7NFiM8MHKcGlTSp2KwKS4usAEECkf2cyyCofMZw8tNDw6VDqyrLceHW6UoVKHaG4ZuBVrCCk9LdwOmKzGPfMlvzAQ6LTguAHCMxFb1iZN1H6Av+TJ5O0ebJGRwQ3xHlhUgry2lcRvV1YCYG+F5LF4xwkepnxDV0rsP3BQcIp0FUIn325bP6yqKK7E4iaEtCx4z2hXtQpcIx0Qu+g2cVQ1XSsBqydiURR+1eqHlKtMUVvUm6RKcgdkYNaGlS+mRnRZCdtGGUMt/u3wsDoKx4NdULXyEUyY482tHSaxP+fVjGxIADGD+Ce8d10kI63xwBiViZH4k4f3N5V/2fPo25/JVsqqIkNnp4rCN3Sejic5CZ0lVf1M/OWPsIKfvIhk2G3qNHSGSJigqQmXIS0LF+zgFKNdDEIlDgLBPl/zZbo8aMWIgShWZQ9SCFagoPGHovGZ5dk8XiihdCDreYz9jeuipQML66sKJUMASVfjgblQZLFbnqIgpqx9/YBz8GR/A0ZhHFYcYR4ln3kpwKlpYISP2O1nmhE47FnoaB7ReRnk3hBDPqIbmxbEvchjTCxgOIBYfIvC8/h14I54F1+Apu9tVo8nsCsIOWz1kcsLXtGeOszbdJaGVW1qirIHi0zLcw0I5MNYKlrsmc7BekgOh2wV1nbJxQoYWTOrLEfpi3uzSDO3jgk76FvUQE4zTfQDHIxhjCuEd0Wdx3lIZ0yOkGu7EgW2VZ5OvGL+wjRZsI90nx/sdcaJ96zL9zX2BVHvDnb8cEzdSUoo3wegOe7bBRwldxQeQ88+PKIWf1cIzGU39YCHNYce1x6SKD/VZDWPb701SofTfP+DFCuM85x0Zad4hqK1gmETOs0k8vz0LyFlVHSiAYuCEjfINJjNNYemtoGQVkR9esSmZOXbsWRlRKwud9mg3Y9yKwb8k+FimwDSWnKZOVHsi470ml5tP+sWbLTTThlgE6OdcQInQb9HiQso6KP7wa/BHa0SNszUwxVw383MEfp3+AS+cItkOGZy6e7w/iKL8ZqEScYGs16Lj3aKI+gV4Ak8kc27q44DqtYEd/10063sOZkUfU70tdxJv3BRrxUzd1J2dvptW5k7oTfL9mbVpfu1Zv4mmw0A3/pTgtEX+JOG00lrzho+bQKeYZdwSh7BDXc7GyggjFN1KnIu0Hib91rUgmaj6z6Llbp5n5/szgVsxp//4WwUQL7ObaPUa/7buX7PWP/UmPiupUYuvXWNGw/1pbj+sTxjgbDHJPX6P1gu3/Eiafq2/Xn2/ZiO/bvMeKBu1/JrKUdevyU819vG40twR2A5GZN9YbDP8blq8QB3qGvz4JMnrFdYjhcdcPZlVUVc5gpTM5HKiqQQW510rlxlnoepJ2588V0kUy9Y2aR7ZgvbrfiMoe3Sbe6bhkhNkk7ut4Y/N86BXaSc7XDHDLsQY1K/L1/+h8hoVg9FpPRlu/4hIcLSZScO/zncef/BJzK8ceSfPWbyFYqkwaec295ivY1rkSSXofpe+w4yQROLwGDAUrIwXhRylMce5ocjEyT/WAh3V2CYi+mMJqKfY65euZlf8hezpfYG3CnXJ35dP3E3O1My81+n3rRIsMDn/gTryU+zoL7LHBbmAhEZ1+0f9p379sRmVPTsymZhwuUjSV73H3O4NCJ15P/c+cIkEwfMghE6QEzWXD+ysW1vwLBtYVWBTHeK2jx3QRCQd+FAEB/zlTZxr/36nW7CfYTbIXFLbuB2tnRP4vL4MdnFLGMpRTCr6yScod9SwO7/Sd4yFrVsK03iOLcLWzdk22pBp8qABSr00jEBls2o5G3BMj6F5Xb/UODlQT2htaMhYrRI32g+vvd1bgu2mvw4ZDapNBc2mzeU6GbgpTvqb3BrjXZ9F9qzxfs0sbd3BAR8qtgaFQ1UJsYSgrY1fTUEyyUbClnANfsNzI6E1+/REbzeVOFviyvUiLW/agrSctTwYDfnYriUiG8ibki3zWoSqIQLz6Duok2fPSi2jReWg+PXvJE1FiyVrspuVLTN1N3nhwgSRInDFJ8VmWvMGim9H59o/flw5CWAjLKrkDIkEovduM35N8mdH6Luym8sJQRUH8hcNcwvnFdE8ZHZtcnglUJY+kAU0CNjnA/n5eLCm39vudK9FFQlXO97mLtR82X/6fsVWKLKRsjx81tLwrbJ2llyGQwaoDnR/QDCtI3LYA8ag0qCDrqOSaigTKTWPU0m7v3tb1OaNiJm3GSrCHGk7jXIszlxa/4YUr6Ag/HiE8M89LfC3pbeHGonNKNGoOX3qxcHOyVq9CypKvfOcPittdGerPNuAR6KlI63rsS10OEmU1WrFgDlNJbhL5tTHTv3joXXV8CGSYltG9owDvX6oiilxaCaCMj8QfYfagMNS/9adGKfSQGW2hYIAcwTibKKSZZx+DF8sEhvgvTUiXGwYC2+MX8+dzXkQbrwjzb3UfduwnwrCczWqxtj/rJYKN3MVuyqNrcG6f6mQ0fJZHQFjQaGYTEKAUnm2ILPDbk1wDQIDkkagTskUKJgKrJfxgAXZbW4q1jgItkmTh2z8s2fK6iDy9vA5+96ucFTIASLBqDxPsKRHytJf+riQilR/S2WzPcoijtt4slgyaAsRDMMm+9pxqx4I+GaqeoRfnK+/Z7oLNtqShEVnKPTlTAX9thbUIjxYrsFlQzIhiXPQAWlRPLVKw5QMB+aSYY9Qt3Gdk0gpi5RwvB64f7wa7gXH56GaCl1Mok12vXZqAF87bpCZU9xW3ABRhbTN+Q2WY/Sxm/cFY7VO+8ONFUlBtQaxXOfazGZust0c6YqTH598QKErkI0K5CmHuEFhFMui/C7+8KKCWQ/FDAC0v6poSaNjVfA9fygLFErBZ02BTmztRlRbcTNSX1TEwhXVfkRBHvpUFffwkwNqxKgCxCpKLJDGRZ8wU4tf9edy9iO0+Mbm5uaoBJR/phs5Woi41YsO7Ib3LlbRhbs1SrJP2PQUJtUs2w2E2Q4vp0cQkwSVlkKlZegY1e+w1huiBHsDcR+ds6GhGWamglLJkNF+9Vg6nD5uwoR76wsojswi/H523ulSwQFFR8vfPixZ7Vl1HVsesYJyiaacW0Qj5SdVDK2n7XbeHK6mS0STSe2hHFl5Skvf97Z5wI3N8Fs1KPqH7mWrfZLa5kDUELxHFkGjXjObzIPTotPAxFhdXvmNdEsZTh/aEjEnMDpCM4mKh0WRtkJEJnsmIMblRuH3uBi4MPEsN2Gh+SmHERAYEMJ7iB06HfFguWbubVy7HSXPWh4mXUXs59IV8g+DZ2kCL0XU/G3rne4+g3kKKiHkBL6fPPGtEIr7zl38Z9MVfzwnTgCF6OlgrPBJtL3/+4edt2/LBe2LuOAtFWc/KYoYtj8EWy5IoXG+6Ylo98dYg4qKgF9JEgIRw56SuJCLaOdH3vgLSowUqdv7tlsHDtQEYVZcsr2PSIQ5tIBTcY63gXwkLvtKXq+EJ1yZsAWeSogspR8AeikARyC4QZi51sjApmQDHouwTMOGHgkRj1v7AYgYAY57Art/wwrAhagq4fgIjgoLZ2YDNZLQu9m7mf27eZ63wdY+O0dW7dF0YbcDNVJY9zYilaw+sbrujDX/6EpTnMOmXyAx1aqo60v8fu7nW9/dU1n18r/js8jxY8UeToiEHtkt/UQ0TLkUUI2MqDHFgZ22lKQr+YOOLiA69LEtz8sAlQUSuLGCTCI2BhOhYjmYUDtZDkg0mrNx0iaO3T1FTrQUd7BCK8sSdAwfENM2vBuQjhr9+pCFk9HoqdOqtDd32jrF2ToQ+NoWbrI89WZlrisMoHSEtzsCRtGJNOCihDOjeUWkE0fFAjTJVEVvcZ02NXIRA4S9OObAA5uppqMVAglNeZBgnhkiHrsHI7uxALrzDnYZN0DfMcjltg0wsZ9TRhmr4mUGNslF0BkBEDW7JA2zbf0KxqkMFfUub05PR8kIkau4gOnPaZeuiQi64qKReG4GesQRUsb+COhHNiDiomHi7f26JGBE/vRB4+BUGIkeuG8+jRnrQ42dGCWmYWdjyv1NAnghy9N0ZX1YR/aaU5AVQfDst6das4bRK33zHXIwONfgbnbhGY8wa67CbLn4UEllSOhNvA2K9sUsHGFhH6m3NYnto4BbLrVd9MSNCiPXeEcc0lKn415NNqHxHoiFTHORCxFdzFjXPlyOUGs481rr0smZiIaJJP22nnT99jHEV7cl9dQJJ+ekUFloJJ2atqGn+/ZT5Mm2h/FAnpxaypMDYEUCxw9mkDCksnZjRlyISQAs7qhBUHMocPrwTqES5O7THbNRQY0kpbL3pHmt32MVU8VG6FNs0qz/AzwkzO9vS+6DDedaZ6wM1uzLnzD7WCoWbRVDgo7sL9vHD90O+7qzqRpaL3CjQl3BD+fMS9OtlnflWLYJ2cTxYyxJhA1Y9qN+Ll3Uw0KsV02YTq98kgig0QihrZBRO3rr5bIBpfM+LTGw6Z2O2iD8S6TAfKQuyFwqHdjByW3Bbappo9A9tVqKwQHH/L3N4SzVFBppirCxy5J5Tf/0Xt/mWatxB0cmz1Tpdj4vQXd9AN3jn1s7jIQKt9b1rNgIKT2jXSprKcmeOW3CBFd2PeUNpyIJhU/mZBUMSMeJuMl6wYVodqXZzRm0l0iFJnOCCz8kLFzBsznZL7qotP0CURcJ1yl3iUocGCsKbrjYR+v+WQN7HM7G+9JO+a8FnZ8Rg/H6DIv3BqlGAyYvkJcgYd64aM0iiQ2aeYsTU0oE35VlbFu8+9U8KCS5fHMaxujqI2rnedaPDxWjWzs/S8Z7d0hVSf+fACprxQhGTE7CNRAPbKuBfEwvjyug3D0MFpb4a5YB1ngvm3OCwPqJyksi/6Wkg+qkxfknFUpJoq2a+g5HJ4G5UdYfGnw/n8CYaO2IGfAYTpv8kFIFA2MJEMJ5+NdcfuIUGSUajx7VWTgDXmrsTaRrmzjGP19j6ro8upWx61xh3KT+g0oGWKAKo7FpiTNi88jtiE1DBcUt9jCrr1sfCXpra3TwLLFm1Qjd1PBcHUAMFeAHzp/lzfU2iMAhAAZIQ2D4QbCbvgda1Kq1F8VzEOyd6qii4/iASzts6tGtrQKlLIIwTiLtHUM6jBt32BhmvhHj8Nj5V1Z2kuroVpDS3c8LnorukpnpANjh61Gh11bdW/P8enthlspbIhf+HocXzY2UIYhiQAUKYD05y5eQBx8j46FMOBLQvdRjG47QxhEjwim9Ewf03kYBm8b8iDCu/8AEyO50z0yD0o6q37bm6s55v+qZeJyYuO+6/UYWwhW1tKC5HBrHqBzcbVKknMe6u6BCzhy+v7N2Qzx9bws8P3A7W48n99jnp2u3K3aFzxwn3tZ6SQoPjNOlQJA+vzJ810dCGZFTf2/kg2ymDTtrtYlxH9n1vf7/QAvtdG+/wApKuV3lW6FEXYpytsV3WaOkeyBzDoYrp3hAyvThKiEQd2SPc/cRbDvEzwx2NeWxxBQX6h2M2ozodEYaq6ykoPgRD9us2EgBeYMbRXe/YoFIUorvDSpyKn2vp+U5V5Fe1BBg1GWUaSqMACf9RAqaZFh4+kPys7Tq8BJwWYK4Kv84+pKVGeKungc/nRJQK/EUyBv6B2qaSbcFm4xgjQI08frUOZNO2RZu4ZAPwRyym7kYtGc1ZHdIormtUvosJgSplJhjhD4gU1B2VStboI3CWVLfMDlg1HjKevxN7hFUotcI8zcUE52iku5uPXrFyXYjbNdhbeQCU2YbedesGhAcBLXRWpoTFn9fEFyOXryxc8pXhADL58/viX8BL7tplWOQYnTnHrwD3thPhBSgfcwXjwaxVO0YZ91G95GCJeCvIHJimFj4Z5ZlaekhYVMTKW/+JI1GZ+QgZhtrREaL18eBsidV0nuLppBUYRf3rjjZVhuGs0RKnjAnyHitAmrMBmqDU6NBm/OrAf9QrumttpdCuAykzwaR7Kwfh61aJ5cmrGx+ZNht0BHk5gONloegJUtNsVKqGeV0ntEDKnCZD+uJ1Wktr7Yx+8tXlR3ot0vDAhj8K44DA6Ql+MN7RrbnscEOtO7ZUeH/uJCcRbkBEbeEUk3nQcZ2FkYKYiOU8/uWSNwSkMdQ1Ogfptj6NX5UGCbdV6qHLFqikJV4TijywFQDmTxD9zzu5IxhiGdaiU6fxHgcOL2Tnf6VbHnrOK5VtAut/7S527swc8Y3Lc1/sRUGtOMfT4aNm6K3dR3GsD3uiFHnB0shOeWgATNN322GJxht1AJE3PU13OlxzKmCxQam/pksgQ73xONVrdnJulwb0Jyr46uJTVs4i6VU1WpBg3HZ71BgkdXpHVgBSPTWu5+Oi53A3Kjk2mm44zj3qAzI43INLJ+z1miKp3SAk8OvnKm4qkQnicEVqa3eTpKHiZi6zahm5Ntei6rXDwJF3aTwLsSdRPHYrg9T3DAVdbdPjUFRkMXZOOvp2cssGzvwHyjsTRiE+fbsyKhYo96wO4I21Gu4XyhBmVOh5slgqhEUP6lZoyZyeUNsrMYlMEGI/0ixpBPnqIF4IZsLQxpAdLNK6Jc6QdrfEWZmm1OLZ/2Kojc6EoyRT2I/hhgkI/uNtvQof4WPpGoUgW3dlSvkWom6NgqYaDDUDENi/yJpej8m8pNjfUQa5kNdymVzjVub7SE660TKie1gR5MHU/ksfcxuDxn76DkUtOYddddF6vULWMYeYkKZToTUHjdkMo1/SNxG6CQtPRXRmRHa2sCPozpYGLVpzhE3NKvQxnw8o4+BrEZPPz+SkMnbkVYcLxmh6H/jVDZhavvi7+9is+y/UQtV+7VdOP1Zt7SMy+LI9tz0zQqhjWTw7ZoY4tJOZ2AhIXK9xDwjM1AVveE8mIlbwJqiuImgN6HIgBsDUGPVMWOwMmUMUgrUZZiaDOxFEtfkEZp3ZWooppXoE/20PAkyI4KN03uYA19o1TCjsKbu3g78YGqwD0xG8Jw9W2UKDf5YxxxbiHrL7QhiuRIgvI/dF/jrQD+jCgcMVypq0raj2E7z64pUEre1h4oAjsstqI+SkDYyH0UFdhvrFewRqt3yc63yCY7ywZ+Jt95oPPck32qis+vaRoW6gpMiFSuGVqjUsTFuBUgbEMniRHScH23+F6+psWMnpW/yZrBZM59s8j69+8m7PkBt/aeO2bmbaS49+x2brJndkbJezZbXVcUJFkb7KaXfLvfzgYS+iB/8jxZzG9Py53MLSP5LMWgVjN8lfnQOo08LY/1+ozYOmED+c/mNaqePwLwAJxKEr5MqPoc8ZBq9Fugg4eOf5McdI1O3StA/vrlQ+ie+s2FRfo+KAqmgRcVKj/ftGG1muY/2kbizHazdOXxWTW+ZcKMESCHDMGLA3ROFCsjxVsNFiPQ8oZPZM3qG6fgw2rqH1e/eL0TKKk3mxRTLm2HFYrP4xt2vD9uMNXSypiRj1Oattfk530LXx1nk5hU2zlMSdmqz87tUGXsJD7gN+yBvuT5x0Gvej1XTTr0X1jz/FFJjOuLH2aI8/6SPA550fSGo5MifMlFloU58ckufPXLY4d0u5LtSlzfnP8NmmbRck53JQnFZFhg/qpNu6IgNcMiqdhhsFDasFbvm+gLNg/SSWmySSx6QJ6gWDqox7gBIqf5pks8nEP5UTkTEGHOGoG3fyj1tL+sOE7yzgA02NKzYLjqMIE4nvJCDcoRnBdwAfaJvTZUPDN9kA4h3zoyaObchIqiCXRHWKmUc23rPNjCyjgee9KrlEmpCyn/5RkKW/0AEYzuWhzykzPRtI/ozLiexL7XSiAQNxTg3IAi1LhTznDFfs8fLdZFyXXx3zVazxNBCZusrabVI8/OPe/noSOlSNJs/O8qeE+oBwWYg7LGxufKMI7H4t7VyX/gMPhuQzZfszf++iP/yChUJs4i/i64j+cAMwo2+WI9I0APyHsfkdEaV+Dc86fk6kktrHFbVh/SqjHo5bIU9svp+BCWJvGRi0//hZbLkpzCR7gZuCXERvPVGjtiCZzvgig080eTkJhv5qjOKY/6sqTHpPPhr0gRW13Zgxv8goed9QLxFs80n4X/f0SfeFfcDbcxWx+LBSqRtLOVU4qahf3rjSDiC3iluqLDQg/JHQtUDWnCM3N+I+6h+24Zn4E5p6ISaT9SqPqN/1Kc4fx2Br6vhifWs6Xh9qYUOPQfrONoqVXcIryE51I3liFY3oTIn8HPNSplyugksHjGkGIk/4BANjwVDYRDYj5atmxwtIZ/YvN1yq4Mcld2v3JwuMQwyXg3IKjNQIsnaDkfNwgkPm9KG44IpY7iltY7DuQSjAUqKoJ/KfAhh65VEfRCuQiniqrBmzSFyVSYfJsHWiucO1Wv6L6r0/Wi2DtkfDZjGW4l1aVj1qtT3j6TKlJW9OKlKqvB/kUQoRq5/dJ69xJ47yBtir1K1s/Q2ChwMRV9oNjp+QZVcWMoag5V+ohAgx8yvE7wQEQ8P9G8QZQ5DaeMaga092Ff7mnZckSl5kwTMMjke6rMNRVk8uEU08GNxj6DQ0PZE8i6/itwmVP2AiOotvKyti1by4kGnchCRf4yarhUnCvvo35iSfVzA8gcspPhlsPFBvdniOjYK47stLS1HsbpYKNVfuu6FMdHtJukVtdQS/eMgctREHt/lYG+I9cjfLZX9XnFb8FaeKUaVYzwO2qJ7MM3+OD1gtUQTCZ6TWOzshxT/g/+EdLO/XGQPhV6hRpe3IBN5aLf/zDfy/WXZtiPlydfCXkJESi9IhXt5Ji7dCktnMFbfDCM98IUFmLlCyrpJbEhlwM8kc2vMLlxn+r9e7hU5zZQqMXZwA0Haew8QFUrp7r06atGRSW1vVsaDVdMVMIsd5Xl4UGFj3pQa1x+y9LaNaXP9pLr2a7FLn7unMOB+tLVIMu9GndZtnTR7ZboyyYfqAwxFQTyOMH9EmbC4UCq+ZE2ZkEZmgkNMSKdYk1qv5m66Yp0yW1KTSADD7dBWWcZgAr5NphpTBb5I2Vd2N/Cpsa9rf1m0/3oa2K4Pnjogiy1Ypehjd2eKLNYW+OyZc3dVpV6d7N7/vuJ/cXSXClXKEbXNubtYhqlQRMXpTEVETgeLZfDx7x5SzdmRJDDF7bBHZytF+hZzt5dzLNSPAWBTkB1dOWlw0a3gwimA6JsBSBdLqvfn9/n6enWMRV8n4gLI+dmqaEKktn9241XGExVxvWtqbbMnR5fjRycy3G0h1OWdlmG4mktxN15C7gCgKDCb3TlVUuPDFsqzo9QjminRbc5IP7nqmDI75gKmS5LX50Nu0TdwpwQtQJ8OBDfB3ftwDiMMyw9BJCfCJkDhXE1ELLCZMEcnggxnBCGAYDKVi1Njk1gphAKpG+r9nQWwQCNNeCD9DdnOVGCbvdsmkD9H//zWx950G3q7YZ9t9jD78BZoS/Ii8ae3n6126ZZiaRMeqTMWbg7XEOLMKKN2f1hYY03KRUj7g1upeBNNWOyjmbNDqbtqsSwMaRdONpbFPfzhUlFLiu/46Pj/5upv2uP73ezV4Bxylf01bu7LQSHlwXqOZlpTrag0FyWi+Dfc52HCQcxqmJ3XQr2epAogxwAG7+9aVw8fPQv+xavS+q5bEGpSGeWpY6m/QwY0A/uXQYufcscEGuTPnm1BIhuXG1bZkGh7AItSMrAjVD6U6VCjF9egl5yct13St/gDzZ9jJmxWm8pNGw+G5hpRSSoOCm4u1nDaEI6z1mgRmi+yvWzrLIlItd1Re05loQw4LD8Duj1ECRAaxxVnpc34pq/4EBmfautEVRtpQQcJEnQl4XIp1Ijn4PU5p9NEtK4R3HnW7yZ3bEOJfZwrc6Sg/62W/JDm970QqjsiAQTVtEPPasQh9wpgVy/0um+j5NCj7vkJIc63W+9gXzVekvIBtrzjVSW8w4Tjhoi7s2FU+d6idl00qlfgNYpwxevyYD3eT52rzGemuY++pBoaaTdCgI88IPBr5DMmoghohFpEEPJNP0fYx4uQNS5CRHd7gS/FihEWhYU+RCYiYK0ZVAktklB86Ed8dPWIrvRnRHbgo3eCCBEvP2IPRSDaGbm3yJIPNEJ1SOV0ayOxdo6tCLucvUUUzNFq1gQKO3DxvDCUtSG3TtduCJoAFIBQi/BF3x0sjE0mqMpjfpvn16l+18je0Qb/YgFXcRZP1WC17jbes1A8HsnsDyQsUebk4KZzxtzmA6WvauNaDvy2bmPh3RH4nu19r61ece9UCIZG1MEmZN5Hw+BQ4eMnN9seWC6cJS2fzgc1wK/qsM+IWvoNfA1hLA8VxD2/0Nz1kDPcIZ8reTAWtP6+njSeMAw3rvAJgx8TRDZYI5EOHuKL6KLamWBMyvjCAZSjgdKeNNMsJ3GF4bJgXzoQY2pcUmEMgbBvELg7Sodw80+DdR+S6CySf2wdEJsrAhtSXlo9ARjr5gPQ27iDa7dpmMDVBJ6QCcRxza0FTy0jQrFgc/++rTcPvnp2rtcCmlTnWwyYSHf4a9IICkdPvWBgycQBXkegj04L2o9LzkVpNpFRflkOFU4Ky834wg+ffU/a1Zd9Wpga8pOj3xf+tL+wqzFFnBeL439C3fiyh/Ymq/NBGkeu980aA4Yn3FQPZCRLca528LSmHDCIstaUwRJpEaMhjQEF7x2WHB0Immf9NP5M6ply4CJbx7Va7vQ6O9sOpcFCPfS15dWqnv09aYPXc2NNeNwRNGGbq1hQS88gsIxMm2v4jEevP/wQFlUTseF0w1Ut/YZ8ZAVbxfbrFGObYpr1Zj3LhZDCunL4oWNbyhZjwVkm1uVJ47UWVxcz1no8DpM6Ax6JAywdN8i6ChKb5kq9Mu5k2mBJmKgHV9PEKuJ4GXmRfV7GpmCEJWrwiG1tjHmkZOUSvBpgCtvZ8ERV5bW6Y0xq/RHenfzovcvftryYvE6B6q6lLmH6I5K0O413SMgVkkO4V73XLc9G83AFM0g9ReUZJrczl8J3m8F9X5IlFZ7A+LsP510gRP3GUtampI4ZX+dSn1xts9hpG78zuVHr3pCOgJMKqbeUDHlfRDEpIoIXHicjMGFYFrddeDJfCTzBSR7s9p1gCE8yTCE+dW13nVlMgpSdYACEAbjWUROqOzXttU03yvIlZ/i+aqCF/kcRmktJSBTeWvCMXv1xpLm5yNkkuGqmRsFyMQbfgjEzs/UoOgzYEjqmg64r0JToQAOeN12bcnSrV+nvBzzpJ5Mx8z+B7lUw7DzG4rdGX/x3Hlw23xeLe2G2l1U48Qkwuux32xN9AwbWSDOgVg2pb6I3f2O3z295Nla5YbWfTc7HXu9Rm78jx0dqPS8i+WVNTgqDc/JAzvNSH49FWSlkbj+lqX3x22grM4Ng5+s7kJmnsvEiTdb7ei7G69vy+DAhaexTtONWn1P6It9Vs9uONDeCVz61uuA9ura4nyipcPVDp5FRlSEPG4F54MSNUvsB5/64wZP09sfbKjCTTsHxN7Ef7IVZI057G3hgJM2ZsyXjJax0jPiH+HU0VUBc7HQ9eweUyrbM1RL4uBaf8vixj9d+N0l5SE6vbBpx1j+He0IBn+7auGun07LcdXOx/zYbchqZ2JNmw91VWIFqT7Oh0eSmTVOsqphnNyld0heMbpr8S3PbtawmacuGlOm+LKUHR6SYUx92Rz6fNGt1/zrO+I9Oh0w9R2ub9FyFHXQs7l3LwNVXcmRr67XblWDUDkODHR+caeK9Cz/C3FO1bcpJqZtsBKgYOyKXqEEc2BPmms1OuB4bI15YQPs0kwNrUxO3EjL13/iFp4+U6Id+AuOkfUZOE2N8QkEnPuqtqXaciJGus1X/gG35ar2aNimb1W7auG7cXZfXzWvT1XG05044zSkW6mgwZEaG5ZLdfiwJd4CDkN7Kt6vTRtO6fTXehqTv4h0hVldulNXPq2yPlyRcM1No/2iX/NUSKkru42VRJMt03ciWExy1Uwb7hDigfYeLNhvJRtUmOxu1co021e6ueDze3heFuYuihChkdnGyjsgdouBPbE4kXseGN8GUvGhFNou5AOKGNWw0boINYMFfPYK46hrEQbjnYm7pDZGFY0pwwLFIs8cDiuSEWN9XbzFGeh/81SKtvw4dDC7ZoOd2uGVGk3b5GLnkHUv1anbxvmGWAkMdF4v+b0Lwis3Q/k+dfcuZGvJV9j0n7ArvX8kJj9Uyz5P8vxwxruBHt2/ZnUZlf2OOVllOZiZudgqGluUQSzQ0PM7uUizDH0riopzMmcMRPjXrrhvWZu8mDvsjrlau1FPTkzBP5vuCijkIh7+AYl4wcoFOTUP7pCm/UPUArXq63D9/AhqCyk/yS0zMyuSk7ExBM6znpnHnK95JwWV3KL4SHDLrNy7CL1bRUcAiphmvPGP7I4q+2KND8A/lk5/dWzB51WRPbNJg3Abb0clI8Y+dc7hIIfv+mKxpmPHswzxbjEoS+npO8yxtYZvSFrFplalySxpxaGdItrHJ4DcvG+xQyHTrcdspBWsRH3v+QfKbXbDeyvo3fA88/dzxN1h62U3M+PJDJjJGYUf8VYvPpvmO8EpuE3nqtBJPpUzqagPlvXjc1ZNx14HTglV9olWxXCLlT+u4w1imk0MMEL+Dtc/9uJ5q8z7wPG92JMm/yBDVMXgofYtRm3Nijqp+Voe9Tlse+IsAPcKdOmpFbzc3cXMTJQltV6v4oWrjquEoMDdZ+B78eHu54a3jhjeOu7d8uCgt6jMc/yzK2OAiln7RXDpfbn9bgtsRnWayVwnb9k45S+dtnKfM3txsWfl5vEYPuv8wSf9hssHDRJyW7CsZO22z4tgI3Gw4/KLT4B89aNwfbjLs3W26lQ9O6dGsoq12RTbDWWztcuPLE161DGrLctCSo+Z8mJrj1IGTbuqmuXOyRPgSWpcRvncIB/fmjafbDSzOxGVpFIDWX5g3r9j9KXea8+QF9zN0eC9TWA2Jdy5xJ7XpkWHMJu/Pm/RrGiQWQGXp9MVyJwGbBHc/lN2EELz473A62PoJDceZWW0eq8pN8eCw1/RG3b27U08WWm12FW6AOoa/ilsWmxBsMZ5wZZR9D9QB4jED2Igbxugl2lEIkCjJFA0CL82DcOmRm+xZMYbQN1+zCs1qPhdcfZGX0xCF4E22E6lHsprIO6JdLkYoOpTkBP2smEebMlj2fnL5/BNaeO2r3cvqjXtlR3aYhPq0NYQuUZSqTQeTO8iuXMG/g7VW2NB5vQ4zeWL3o1f1GidYTayZrF7/MRP749ESjKFdLlkOwCAa0wacD3jzGypu1ILGRXMo9DLiRRWJVEgtsIaoH79kl1jViyp4zA2vdL16wRNC6XKxwJCl5WFo/BYeZi8CI0yngxa29ouFPdHrg1RQ7tzkgOmQ0ZSneDhd7ZHqNarrzFdgC5XRLMm1SZBq41dgKAnyKYmOsn930osMyq4f8ruO7O+V3yJ20CJbtDlI7vVnmJ0iv/4NwNvN7cpHHKBV891s7ouAP9JDpHtEj6nerPKpvHplNsU2N+9sr+ZTBUFRgz8v956DzsvBBTFVUivbJyuYNHRJC54XKs9qKp3FjFE91TncKjwdzBGtOInhdqk/rI/6rmkj3+BlgxdZ6npOPcbcuGgsOEtT7H1G5ADV3pPkWERoE8aIzUc1prpTf5JHiucYkJOIvCTJn+H6YGqySWYcjTtbPpCjS4KRd1KtJqzKlZyKj4RvDl/cItqfYJeH7QKhEhJ5yjXZK8Mnlbz/q8aAu1oNq349EgDuC2HJGsJg45eJ0jTKJSXy+5iU2aNEo+MSP8XuIH1cY0pMFA2Iz7CO203sL5eUJaxUo/v0PA7lNiWf18MIlPBdMwEGChpOraa2kdAhdyOF9v/Q3cLDmo/EkFE1mz5pIUchCg7GYAqdScMDa2laTBo2GPLyDaV6p4zJN7v2FkfqzVn72EQL7FGQSe0UQaA3PzLR4zs4W6z95QdqYvPpL4vsQX+iMou6dyqJyQ6cEyYAvaYrvD50BA/Xb9cTt1HIuYdSkbi39eewg7hshPuCkTeVsGDq0t3Zy2Y7kD/OZ1YHqVWIblhkzHAa1baqN5wR7E4L9tGAbgpBVZozkySugDJxBR4fbS8mBA0MrIdNbJbCuNb9BfwHM2BiBd6I7Kg3Ty1mcBYvYF+uaq/Km8BciGH6WQnBzZ4esxaLZfDY+k9uaJ7U0GoWF0hlgah9X5jUofjRuC9EgvLsGWSKLtxGwwHC7EAj90pJk+/fDa8ivEzlWwzNH64qzizPZWGfyKJBmRcn2YrOb/S1+zUrlQsEKTljGUmtRVxTbqYRmjaP7UyORt+fLic4WvI1E8vycjKaIfLyhQtgFn0nhy14xdolK+cu8jZWna/zkBmcXPoLrq8blK6ptIFfFS+fX3uXkGLeEAM4znlHB89idYtPV9VGavUr1fY4j5WQ53AMI45FuZa4v7Tr2JDJVC3vAkprGXTi3RV69y6Xx4/KWw2ctf7er3SIZPKI+UCnKobM0qYQDC/iyO1WZba5k4beAa+T+71SLahVPe6wPzNf4WxEPq4SUFNKzkU4cKrY6qNkPeLtpkUGSkZe+Db25UxqG3p6l0D3m7fNKqXk36+o+cHLCbGRPsuiur9C8bA7qAHcULOE3VNFm8vJQc8QGsZ0vJAQdhS93ujpsuVcJrs8cTa0JXXK9YrxEOKGr8PySBs9dNLHmIDRKG+m15U0Y1OVrofFK4JzFsClkS9eyqXFLMIUH5QQuWarzhjXeX45dhiHr8cCEwzheXYse/ZnY74Exij4NclR7OfQDoEWs20IMCAzdIY+bqp3sZelChaVLG5yFiu8aC4U6RQumWRSGtSegWYcrN54Eqa8a8azD0Ix+ayoTWbyepWXlpuXbYKaF5AAqt/N9uWb0Zy7TPr8uZthNVjh63bZCRMhAraN6ufthvZIpgjFDg0vmqreGKLd9YVOWeSj9cAks5pyGPNEMJaC2KuOecileboMfh0E0dlUnZ7yhRGhTBas8ctY8BAUwFyG6kdo94Eg28Qy9XQiKGO+tvuKAGxvBXSRotYCTydW7LMSOuzIzvS4S6SwdATkc4hOCqryfK9vZG5Jc0q4B7cMaJo3bU8E8FBmkjneWi0F4kc+Dji4NzsxAzzGoi8rFuHmDFMxQl3JbwC5GkVV4vVVaG9tSMQvhTiZZLM0lD+aKlXULxqkp9ovkzwRjl88AL4AZO1p9kNOfmnHKjdpk9yJg1hJFxRJvjHyhDDsqgvr15RIDh81LZ2RaTGZyn7z0Hs9mzn9S4svUEie7gVrmpPeDKadvLsU1nGsByPKib4bVDAsvoChgbe3S6sgccEYm9hezQ+ZUXstB4mIA/vai9tcrB6UiZru+ZNgnXhAzTx/mW+4fLWnWZTo67aNVUYZdea0eCfJT62pi0a2DYzLIsSTIgJrVXoYwiCgxlPdEkmTaNALqINQyqifg71an7GTbm2tM9uCS0QzIJj1mQ/EZmaLVgkRHn4FZ+WrKtmFkdoFETUTGMpt6LWjdxxSsR+7OuQgIN+GRxdRahdPBB0YNSwEWdnsPT7sa/W8Zdd0SxJqzCRXWtlnxHorzzO9KOWu9QsDcY+mtCcn3Kd+wcBoHCczsLq3FkdRG5jv9dVSCQLRjlQcWPeC50cHtqRJukmb68e9/ZRGKynJUSIuQyvwmQHDRQMhZoACW7JC6l87C/TbP22Zq6qL1veuR/n8jyAwgPahCupDEQegiisHYJAICuSMeYxPbOjT/7idDwf/cuN/wWkTPPYcuQw5Spvxdwoardw+ZGdrrpWu8X1cRFV8L5crftUkr9EiMyb8D8BTd88aztIpd5qMpxmEywRvSwWUQjkQnBe5Nx1Lbv/Rhya4950pSLL0UKHfcD304FYjj51D3z2I1aTD38oPC7N6DGbVQ08b3FuBBWySIDAU1dBGlgRTU+ZmCX/a3ODaByqwoM7qhE/tIqo5FZPWwO4wreO7T5NDGbXlsKgUtGxwK6qgduN9NcX1zKSwYuUVa42+WB+h9vg3ULekFy+0NjQp6M4/X1Q76KVsB9Vl3xbq4r1L68n69XD5jTi/N44G9GZw4z82Ce62PERKhI6sVy7hURtfVstPAOf3UkigJLgeGLa0SMVzKa0nztJ5fBOnojLKx2q/4wuiF+xH3Z1uvzOc+EIKDczdeS7qRxEl6+VbHFRe6GZQdfa+u8LOBHRYHbSV9USDc4yj49zg7cgwCH8rSYQchsJwkLl3H/Xg9uOPtLbUSG9r9+Tk5OuJBr1x/EUwkmk27G29S2gRo0/8efZgoFT4dcqsNNRZwKXyNkdKJ6wDchJiaivA8K+Wh6VSvQuZ0q/+kXv2YbL02cUvZphZR/p6Nvzcs0hMQXz9DeuuSIZ5ZFpsPlOorpysTql86LnjQREqm5dfrxWnoJRbEMtSOUimaNUTItKTBX+DU+AdkZT6ql5F38SgtGT4bgiQ8w1JGk/UgUxWGs22T/1aTY9gdmzqtiucj+YrOzddRrlQdkE63bcDSdE+jCt+eTRjsRuJcDbicYamDlr0yCQrt+/ht4tr1Hk7mnxyGvCxTRZDY928T+/de0olC8FZNxQIyhpxRXqtsxPQM4c1ay+XCfYenCwPHuirY8P6XYWZ/pbBTgaqwqOKlR+JoVuBKEwP/+6Ml9iBdBo9oRsxLYDHnciqMV4HDmL6NO5Ojv7jjq26zgTlWwixUvyaiZFF4/r1w2SM8kO6+GNyPSmPl98lg+Lwn2LRtwn5HC7j3DYg6P6lfj104RvVw808ri0/q2ONhUCZJ8AUhTN0luWfZ8rYdyZziCQEMAOflfXV8hk7BHR2QfJrIfjG1NAzwFBKS/9lwwW64rNek7pjVdQy7SsKQZ7wX3HwF0o+T3Gw4hgkQh1/pjdxPeDZrqOQeVO1B5gZt2TPDPS0z6DBoDzsq0fRlZioE+v+HzniPvbAPmtha/iKXaJRqxBcXUlDgWX7Hz2enHr/XeV4C+9TDJjYKHRo50Qb6CUW4/iJSawEhKBYKjjEWUIY59klMgecfrb6+6KIEOH47xJ34ZlUXJhxiZn1N/x8jgdMnQivuS51XDK5wg1OeoL7wi6ZrDhmSAfFwyCXvES0FZf/8iyD9T1GMlMiBYjtOdluxflnZZtCUMqgku5YJayUXVqHH1Op3H3TMh3MbiMCZd6p/DTcwnbMygUT3ePcUmsZnpY7Obz59/dmy2ndHW7Qr/UEuPzvx0GaO95DcuooyjyqrW/NdMi29xkR65CaGqFuNvAh8CaYPkq+sh55apbfQ8MswE11C/aQQtA5f2WKJ0/tkzv0IdTNUdvHdA1MP1nRZC8bTrtw+h9OZ9kVsI2zqmAnoKqsRnhTLfWj+fbI8X8sCsJsr50XgJW9wpxd/jXels1IuddE0c4zjYWvtMNtDweZRyHwNXidu0b1CSP5UNdkOum7/1IJp0D3dJnBnDUzzWEW9IQ/U5gAi2K3Wic37JYqRvww2il6tOeguYqdgptHQE9HiOGrncJtXVumjQraZe4T0BqF4PGVTQ/ZGH6GPRNetOvi4TiX0QVsu9ApuI19vp3dAvq4B4rQM3a0ORVM8dVKbKRqG16fnsQcP+n3k2zclj+bCwUvQqXwY1t/LrywT/2DdQHkQTJLvOei8/fTTw4OhAKktp8txPtQpEimeqB5BPljOqYHWYWG6Scq9tCtYHsA5TjCXFXNhdyFDl4UDj8PSOIOxcvUvfG1qBS2nDYE/AQ2CsgRqKoSQhJjECfqgKgKvlrbJqgLScNtFTSPgkD1Ko10jUkQh5syAd5FrPaN2kpTMGiDzdGHDqcHG5PrLk8oV8n3y3yNk22p2AxEOvkvm+xovDancudE+flnluntBcts3ik2bav4QuGWRV6MLRx1wKdIm0H5mjulgAIwAozsqoVksxg3BHe+0ApYOMlKIRR4Zm5Q0uU0k+1zVNu7wSreeWVpouqRn4mIWqEvHe8mbRBlmhLK1Dd0G8sLlCiUnITMciVouw0t1+4U+tiy7i7foZK99ocHbgAfGQDLbWUSzfiM2iCPnViv9juQQfFeqgeBit1NVLpx1ncCuiK17Yelsd67IGoxRWlVcSQ3u8EpLrZU7Q37UPbxQA78azltk0np/ZV8TdP4WmlgRa8dnj/1YAFFfmtdKdrMRh6o6fI/XqlecvqLi/WohFuUWpfEYiEjqvM97l+4xtwX06rEq5wU/eRlxA3lmbJzdrCk1qy5YwTqsO8/SqUOTtbl8mREVJHS0Pt0pZz6qoiuAHiql0BVACzmPBNvkajez0SNO+uP1g+bE9GZPcG9KgfbsdKwZPL3RAm55jrGxImen5QBpBlfS+WIXsV4yd0QrXeipVk3kow7smzYWAvT3zk1IWiFB+qliiODIsdT/+fvs9v3PbDER2e7/Tt5lPjFZCg9UcIURSOOUt3loTw+EM2oJkStVmjEhbt+nRsInsdEnNWfpRBJMcCLvdQC4cj76+w3HGb0nIKy6tEsaz/q6FrE38PdNgVIvEB7aiRDY/8AHmWpZxAvLFM+NuTRx2e8L1K+w9wzSQfafGuh2W/hYJmJorDi17CieUHk40ehgVSIvbxckuyomfYT7T9WieOkzkhKtZyD/5d7viRaUUtsBX8Bv+Sssnast1ecJ1+oR87Qt/63grAvF3IB2cjRm7CqAMzPNN9HqdD/4Ru2FXuu+FkPe2UkjbEa2AJJVYdLoL+Xhb2BKZPFJppxPN9o7c6kuejVLXhry7o8izam0Qi2nMPoJZavLB9JwDtj6b/ZOzBhdqw2b2r8+6O5xD0ZsndsqxsKnxHyStaji+3HBkL/72VOLiSmPFywNa2J5dGqYrF3hy47xrSHZEZIdeiN7oWCUDDdl91ykrQUZUe75Uk33Yx7rqFfK818dToI/ucYCj+xlElEGph1oerzvJT9x8F1uacyxhRZ7z67DmqyaHC4/8AQ3sxMPW7RUxkxROomjOLI6d/sq7DQjljqJ1OtTGGZO7Myzq1FTUKk9Fzo9qcXX4a8IVT7u68xlhD3X7O5QNWf+FTU+4CY3hiBaG0NU2vkoWQYaHx8PJjh8F2nXcPfOBPFJXnGr6J40n3zNYYdoX2aO01dFGn/0M6RhJFLJbxHDgJ1OWlCHhv7UQ8dCoaFTjXYRj+mxKYRjGKvlPCrYShWqWQU0qOfczzFQJ+UYvSlTVF6oSWMIXty5phxMDxTbIOLYqd2T4k+Jtcdc9Ekh7KJQ7Jwd0N1/6T9mT2F4/qpTk6baFQT0tVzcuTgKMfxH+bpSMMERFc8jQfk7Bvqz6/3rWM5Tck61n/KF/Hfj1PQLU9mBen8/rDoiKJiOCoyHV+WhJptZLpJPr+NrH9NkZwRzXkanKnpUFd4PC5oAjwnpqY2Y5LExaTy+rk5Pcq4V1XqG+1hEy8Z/fpFLfzYou8Zu9XRXRP9SOQsg88qMfz3+khGhETRo47p7H+g0IjJ7+d6xXf+dzC5OYa+KqrUMupGVZuQi/Ub5+9L+5eSxF9Dr5583vyP5f02Nb52kMVwZAwoisFeMzcYevW7N+w0bjj6DTgmBeSyoC9c5EZ+tdEaAHDkAMVSQO580GFXiW/tMG+WzcEt2JNJxaaUdHF/w5u2W/nqCpgXUxLcZEBuHNOGwLPs6MlHKqj4VarMECWhk4uUvK6Yf8+CqLRtjLps1kzELCOleVoWUFCNDwU9TShI/O4paEhHfoB+YpSjJ/tPQWUhRhBBvdW7Tl8Hwy+jhMWtgr+HrHUTH7l1wEd4T/dU9tSt2/M+4e/aHr60OxnSOTat0hOqSaaLm/VwTzxF1jgmhXTfhSI01/Z30s/qowytgvuNbtwQSO1FtXRhUF6y4GkGtQLT34wLesnPqF/NMOQBddVL48fCPm90UwZSlaiFaTFB+6k0FzoPU/gSc/9gQ83vS1LnKzf5+Jy7PR7MwR1VZ2jz5RKWCGDShNwRmO3dzPLmLLLnCgRJPZ0y5DCUrRZYiqwbsymZ/S0tsxKzyhpUUbNmV1BygsZs9CpCmTJfs6waHhzxzSv7u/RZP++HM8RouA4snsMVEOiw0oyBIx4WzCmnVI3mhXBI8yXMjpM0kPor6dNqSvfh/uGLovc7Ggj6UCDGlE5jJgAyEKkJlpNMVcdOfdaVN/c9jg//qdPXJ0dwmaWUzVbTMcH1PhrqUQBFxGekGZjgO26k0hFpVLtCYszNL5SYfUdCiBMLnSnow83uJ11Rt7v92Bv6jiqLgxX/VP4f0ubqqnAmI2DlKCibM60ZiqScX0eLXQssNaz+VJkygD4T+R+oNr93bWCVJAAptQCWGgMvcRyv9iXybhLx6mdxD19vBRDsXTyTA3nKR7qB60ytzTq0nX9kXx5s7KLX6e8zNbB8gDlOVvsem2kQsmTmvh0IR3bgEHUk1WRG/roTtwI/qo2632Xx3zMjRWT2GaTPFqycM41sHcCpU9K6Hhglw9i/Y9qC/UgYdbQeL+L2S4jZVFMbkceEjZ1Jx4V5lJODki+DNHq9kXwjNpCcBqAbov8F+Z5WE0neqmY/ugVtessZkfLByrOWBfdJO4Ra7uWbbZ5S+wb4/IRw+PZwNdj3ayNADoDQMiEwYJXhQwAehwPyQt85lYjZd2y3UKM4c/9KMj1DEUbCdCR3r2w2ylPv+VbztUUEZzJ8R+LDJIzrCpeY38YATodmBtIoce76fpYetMx70lM+S3oruau3D7z6Kn/k1Il7HKy0R4GwqTuB73ujC72tu1nO5YOj2Y0hI2Sd4pgUN2M6DT55c6TIf7BHNNIp+8GgP8ja1EfOcrMBaMHQxK2jLXijNNw/K9JNV31xb9EWttRBGeEGPWOhB1qNleHpUCvtjDaJNhbRwJgmavjK1AxjyLpuyVxonoOQPfXLjLwCTjuqsxwXKXNJhSCfce9wLr9jsfbu9gQ3thxi1Ewr8nNnP4PngbJReQDbQZa0qO7EeTcNxnEVTGXJZFwpF/slZbDfNy2KS3GuFsmM1y4C/Ugk8kIwzRDNsyPfYPNtQ7LxfuZG3mefXKnscyyrJY24xJMqma7GjaIimXxmpE5JdcMgXaIGbTk9VfTcUmh8LjQmy4h2PVhySoCurJmmJNWN1yKEBEB+PGejouP0qvs5aytfiqtcqKZFPEOLeSKw3UyIEn6fqlNRWOdd6u7I7M/xL1WEkXQWisyiWotMJ7lywE4MRU900XzF63H4az5/qWcK1r5H+FAP1K0Gn8JMW37Qqs8q2c7hboZuLs5x/u6nu9RW3x8qmCnbN3rp3tS7OZa79lGT7aXsw9XuWNcrH0Mhw4p3JZVNIoMCHAiVG4R2txPuMfj/k15BBBSQIiZqf8S36ZVpvXY1qEYE03taOtBpohyJ8xmLzV7Al6GyDnwdvaUzU5w5ReQlquwyGTgKKaU9wJGwoSlP8ucouXtfQZvw6lKu0Dfw8TcCmAzzvYGZseptgXerFoKJ/eS9ZPPeSD8czrUPbKA6j/Ha7oeOWAZrxse9UQQtZ237+9bm6vZHtPkhbRiwwLUN2zkFXNmwDAb4uEf4wAaHNY7cP+Mi1yAR18OmAnpGCscXeMQhk4DPw/B1G1SiRlgOyBcPEfaGs6Kr01V7/Vr3L9wQ2RSw+gcPEK1jaNLenHG85NralFjSMyKJvam75u7mVtjS+RTEGCPHCGQLRGGmRUYkGBWmUK7dUzgo+wrs7ejvpiQBvgrQpSNfn8BD8RVJ7y30I2WQCz6MmYnhy8R3dY8PvUBdv/oS8lkMKv2V4HjZcwshI2iPvb7tbDcJV9srzytbAjxgfVPuiGoUP697XNeUOyYr8BDORgfOVRYbRM6gmL3M6vS414e0moPAUWB1u/6bsW8TCnIcmZot1oNt9aYkw9UpSb4Yxid86Z212T5m3nlXgxRvh1Hbkp13m1j3fzYC1WdbSwAFSthSyPh+GpCv3dOB5wZ1OByAI98CrBvMAfG2HXO4AOSmDiNSyM5suWvPna23FwHrtyg7ZFfyB4rZRsyuNKF8/nKzBfZJhzG7ys8jPdGSwGrYKHz60qpxGQdn1dbNrvLxOZ93f63R/cVAIqtvBuUy73B9Z/S7rYMUG3suGf/mFw7PFhcD+VXFUbntpSaflpdZoijWNzpaoqTor/z+5e60gcKt0w6nFE+CzXw0iRodnBus4bW+J66tTlAh6B/OXDmib1aeilZ5nyYVFzQv/cD0L+iaVbE9fPXRWs19evJdPT7890wJk/gxYQwfKUcXpIHFyVwMIuNtLC56N6Pe+wAQu0HiQd0wbTN1XPBk9fsvEpYfISa/VDv+LTYFmUI7og6LCPda+6qb4onQT3I1rtopbg8HOxI6OBZ1f1QphfZq26UBd4A0+HJvjYWMln2PPjVFhnrA+LTQ4TzTaF2wePSr3iT/z5NApiLt159kuJHnckVWx3cR9H6Je7hTf3jA/wuLx7khdPuTzIw6R/9/h+Nov2ZsqZ4Zj+B+TZYfWHQc7Mdb1pGua7FTxiRrvbizZrItlKmWffX5fLffpHgX+DsNxY2LIbYp+ov0Q8G5Ur/au6ZUvS9E+3GYIN2LEBJ4nf0nKL6ffJSRns9wrHAMz9pSwhjrKNBhzJuqDUcsruFLDnweQTmhK3oxAF6YETkOtgDA5qP2boGC4TpBwy53/S+81Oi77LMl6gJI7BB9Mxylashhk0UXJxkx4Wu0/PotN1gg8xUEh769RsmvNQK1/gG82bJhuOFBiRrRjbXIrG5oRlSNQ7TkPHIuh14mR/wPL4Ax10JM1SorDS5mzEGF51juHd6xU0fU3C5bO5FuYp0nZXfjTmnDYoVpeJF2lL3aDHvj8uBANVf/F4cm31CI5A0cld5r1QhX1vFyJFTDd86i3O7KSSX+H7//oRod5hftA38BqA6eV2opywn0BXM0oOWAeN9Y9sspymdl8YrJWrx2egQkvhCaBnIx0KpbTqYdksEa5DgVHco46BahnfuOh/n/iPb/d5utnWHV/eHi8Iy69as0wvSnMM72X+LLHrkgzhf3Bcbx1B9W5HS4ua6LsYT+7dz49JUJQzw8t7x4Oos/5imVeqhMnxCyYnrL8uVFhEf4sMWYxM6PTUjhtzGN2DwAB1dqcD+jTGdusETz0JCEDPf1uljsWgQTSnWpCGt2qBgzolrzLhvVq3aYUs5iEa5dn8wgJhzpisKqEaMYjcd1MmStgqYcvJOcrP4mqBd50lkNbeZaflk4TzwYEaCHtXhNMzZyaB+LKQLjWYXpOgzGpfK8GQD87UOSE31KxfxxVToJyGXF6L+cn5RaQ3R5yW7X9CtNmPFML0MMdbsQsZB6x55LhmRj5YG/s2+ZwOSWYOsoqgB/+z8zPV6vR0tFxHjtqCheMLvAxUD/Qte+1o6s7PtbPPKJVgsAdWGlL9oXfhp3JlthHADcqYVoOOOKbqqKZYxIVUTOwC366icZN47pIGIkpoHKOnFoYU/ltU+blZyXYoyWMSn3ksqqYrgydln7BsPnIHjrH1QjGjlSlB9GYzJUIISpeg41R79bCf50g3OTqEcbPYQuqsEtmdtJQ2j1jhFy45WOUOjnMERZgdoRYUxHbuh14fESaGY0IVTJZ4iLh19hyk002xgaq1B/awLHNIRVMhEbcGAymKOx2aFHgBUyoQJRZgLGLkSeFmy6mkIAkxvjOMG/kHqaEbVxcIhtVykxtpwojy6jEJbjbJCpRtShzwYMtWBRP3F60KGzT++dMDQPMvkhSqumbhmeyEv2Q+szDrbjbjziUNBW2/PWx/emsw83R2/MqkZIO4+1hbQu95siHgGu51cUva3BQ96lsZNF9FELXnWDEBd9LV6vMVt4sgBp/FS1Xm6QWSJjDPAwT+p6cWO+aVtjmS1lmezR7HA0QJ8hZcI0tFE2JCAPZ7PrQrUiUpzWtHn21LKbuqPn6QaVGBoSsJjkN2kRcMUsNeIJTshkpH1zwkf6eEPlaXO4uyONSqMG09m+/4aXL67Bk76zXQeZP+wJ3aJXBmqEaeEtYa3un00Y/EPP7gZjNtAk3SIXUoz/3lbHTe1IpBsXUBfFZ9PKldkQGsxxnwI6fHJ/Rte/xWlgmv5gQBvFbkrd6eaPLp/JZ/ejcyXOv0sSHrXjM2+O21dlHoUMmlS3h4m4EbHDolKn9y3DORv1YbjTvet8oVdWrHUEUyQuTF0TsrQjfcv2P1KocYfhmcLdg+eglHAvOQFwax6AKOpfKJZ1uEIRAl5hzAm2L35LSPiEtUKYZQyK4kW/7WiM3poL6qjdqKuXrwaIUCIsFRIHDKqK+EnFCRs2SGuKlUx8ndBhO1OkDr3FQrG4rYoNypQmIb6JcvcU7cuS5VTCT0D141OTdQ7lUBjYCymeSMaAeBjNgabqMlxTXBss/6FcWivhb5YykuPdeDU6tfTLZTwfU8OMEm3VwTgzPFY9geuU9nR1SJs4+jcftVvp7nlQQIQjiKwNsYpBnjRMl+G0g7NOxQHh3yq38xIHNSSjmUkbZmw75Y3a5O5oYuabtKvavtGikoi4k1nIb4MMupnZ0zx6hfcA+KY+icnVByRgqSlhAHmeHbqcODKdKz80Gjzff665k79xO+2Z4NuYcUyId9DtLbiHXkEMTYarvADHXj5x8XGI19SI4M99Avfr3pfcRkhDPqeJDGHEbFBB1luf5Rr9juNpYcK9USt7nlel7VfRzNK+supOtrC1yUsDBNFymXp4KhhndorDtTvZAMPbF2fuIhrHhJ5EeuEYLJ480wVKL+Jwyj+DisspD8HIAZ44izCChRMW9j2R3Z+ezavzXR6c3W4JcDe73eqHWYwarK+5gMw4GcoYNHjcqpuYWJXiZ5umIW23IiB2UVL4CdK2k7oKwWWQYB3PJR8DPDvLz65TrXNIxemK7V081RAAFGQ/8q1+vnSQM+/QW/4l9TqnvpyIx3y9zEphcKnK4GJc4IXkbkzRW23o+wc8bs6rUbtz6pCThPWCk1JR3/skkab0YcLplPwWytIAIAD4mE8FonRgI0NiAIhQz5BkiWvfTahzYl5okBmsK7BxgdkAD8a1oePXDeT26RBRVrkcsy0xvKrjUCWca33LI2QaeUsKQvBkBjopMlQOW9zw3j+JgtS6VLoluiPApJx8y+Kbb+GlOvZHxhq1PghnymcDvj7bt5wbFyNqdMq6gtg2Edc7MCHWz7xgAO9zhAkYcFDCyr1U2j8XeCCWhRs9MumiBOTq4wxQyGLKJUBZw8w52Cmrj5o8s5QfPRmYSyu8Zl+1u/FbBZ8/2mhbT1lmh6lc/zRXH6Ujc4Cj9kqhoRP4K84zIun9XJkMNIdPK9As0TkcJBGDfai7lxx+kK+kCLXpiCVrRbRvdJkjhMWlWYVNaAvqQOVaDklyi+lxbCrx33XMMscHsXbVr5T1PW1RZIAUJOBFoRvB0noHOB1eJRL19AEgcKAY5Jf3eNjUSjbzJC40JdWGlHx20o8JlQ64MXCnitYWu59jTE0FPeyuDA+ypx3WN22d+k6Mo9iYwas2QJDqRwIMGBBUwxsk3AN8YWE+8v+HVYL9cNa3O5Efr7uxxJWxnH302uG5x/3UH5jF9L0YieRH9tHtwuxvH+xiOJb3bA7d4KiakeJEfM0ADR92Z0XAzxiJGjxC3xy2EDRfXVnn5TqOGoYgdLB+UlRYRvgeO0lDgeM9z/x2fwcwCYucmu4bbCCdDvo2cMHBMOh4P4fC1bM/pA1zCZDj1HtzhuA7we2+kUBap9izsNQOsR22w//vm2JPysNFtWKM6IS7ptkFrR5Zo3DYoTX0Ogt7fFw7NNU+KzBunAho77HxMFinvIQDZeWO95gVWqA2C+XokRYbr5++pItgGK5hGaMiETu3rdVFVmxJ6Bh5cvWHEXkQeNYe8W64Q1vEGn2nZEhSLQPNyY0R4rSMWlw2m0uYOoRRc0BYnvppMxSJsycrWuH0pi5DXTgKORzFKfATqNL+e+s2OcGFMZVqkQ+1kSNqjQw56mzGRI5GNSuh7IWSVRUVS008b8Q89TktS30bhl26rFLCgTrtirMJQULdT0ThXUvBGnk6WxHpzYKHI9h4yTvTSOeBBQ1iRh8uGuUIRw1mf9pPsPeG4+9vB5PdBpY12QjTdvN603qTesP6POfYgx3uEZPFqrUUVU2ObyzKL1IINSGS8n/BCFzfyHrPYxS+CMtZrWfAWqmN3puUjaeb11uoN5luNDW/Tc8PnNbTixXeP22oYoxjyDidxt0kJuXyh6d64qyodhnY32y5B4KkAm494rVGg3U5a6dU4Oqm2lfqPUm5LJIxLfcIVQWURcyNgfq6QLSBGGpfXPDUReyqmvbXxliq1jVm3F17KlPYtlEt0QbPv84WN27orVMGFVnMCjndN1qi811dHd52B4oJatc2tr1vvR23qXLv5mZ1yZVVzp3NymtB+1P0vU+FpskGO2MNesB18QQ04465D8TDdJaKlQWINT13xYyFHMdvEK61fwi1+bC2tfc3Cz9RGzASwAZ8paE7ulaZnEKH+ooqCWBPdCmX7HtkMirIcWFaSSATu0UTOygolpbHJeAUW+/Yca6HzIB1A5qaq9frIGYn3Zo1z0ZO0RrFTSAwDYo4zhv03loypH7izteBDnhx3qMGiBijFg5DXV0PPcjQ04s6LpEv8FMucUsVZ6iR/jN897nOQBAxLewRB4QR22An7tpF7SJe7INl3a9wiR4fsPcOv1lu+ZbeKJT0SMRnY7+rkXPMTo2T1ytfVQgeZLncGJB9Sz6r3WDNbSgPYJz6zHBz1L0Ft7vLrD7Gu+A0UrSHVGM3zFO+5bHX8T5AAakrpIKyStykndtqCZUPCGlxiJQb7EyyZIYFONvBUAiGSDI8oyh1HhVO8VWungqHGpAd2UYVVbTeWB91r0D55c5TyClue/FGD9b14erynlDDxsZse/xusjtBJ0Nbd1rVQIuy/gKKxP35kTOrj9FpjMa79hacb1kIl0DSvD4UIV+F3AwnSlCPyKayMHr8UUhDv/R3aOJ+C1M7GHs6vzZSHL0/GkS3MZXXvnoYSjCh2VwjcrFtdjmp2Z18mkrFLgDNLDKs7GczbN6hxkEuDME86n6HYKi8IARxxth4ku+QxoKEVdtU1+LVsWVEFoObbQVcq1e3q1sQiR/OOCqpReqpn6p06cw3q7MraqJBFYsEAIOyQeAL36LwOsORG4N4NLjLNCOMtlvWeccWtfogapsvwNSL4tvd16SuzGl46oYNWAgy6dnC9oGnCyXmD06VUbKo67+SU52Lmjl5DP0CQ2eH81GFycJA+8kWaM4nptFzh8P8z3+Wgsm9NKTpwt5VKT5RaVyF2NFOHfyye5m0PIhxjWcTe8vhQzuSQcnNjm1tymvnmgJONUv5z1icS8YjAA8ySXF4/VjvugjF8KVghBPcZMAQ/4jYJ4NFBA5Ba12HTO6dtsllZ4yqPuLQ64ME6syVeIXWG3s4WHHOyvgUD6mUGPIwHCrHamakF8EJk92gaxuhXfEuifoJKe07S+GOBshgtwwWbiY0j4GyRHjbdrg1bajGk+axOr/liMcF1Fzio1wDgsATGD0smNI5stQji/VxBvIoYyDaawnutok2gUrObapMsGcPZM+7S+Dk6/Z1zNMVJF/hhvV45DFGn70ZrlSZsHBftja7SEjrYTArKrKzk+wmDzOCGjQ9opB7aWp75fpy60P9yRyvpFmrM6hGWwzWpRmDsr6IdIcALnWeFYxr+AIVfuMa6bdizwkhyVoxXBZrgwJ2f+SGfpIlMEbmJpB1nYFS9eCKxV6bLfVb7q/2k2qcNfHcsWFFq3DiWqkhyUY/dJx1Obvh1EbqQzlXJEF1E6xhn2b2ZV/6hnXhLv4l9jmIHZMisBEk0RhzdY8DNMms7A2AazMY+nCEe6ufFPuxR8yUy7CLCWXAv2II6ggn6HdcEa25DZ+IUBrcApRDhOfQpR7ycWrj1Mbi6I6bCVgCMpnoANvL/FK5Ur1jH8CJ1e2NXCfpButxRMvBKTELY5PQgHiUJsgPOKk8frDK+sfva3V3nVt6e0rCt2MjW2NNYkTSR0DUZ5Mg81X8Xn+I6JPlEQhgRQUWTvqOMIZbnVa5nrR2ueRvOfmnOkw+UrRh6yA8/SirY13KbCdLsKFTBKlL3aNs3rkVC4s/6EwlrFaQZaQ5WkDYTcm6pAsWej008rfImtSm9bY2L1vNHYQyeOWHOulLn8DvPH4g1eMLIHrX74leaBaxc6mAD891EtMLTS691AqeU4E+9wAK0zxRZ4Es2mAvWcMCkc0nJdID8SqTLDTo4AmOLZytqSkv+ltLnU52RLdQofETYXLb8vJyXxJ0xCMv+Tfdctq8a3kcCOvpdQl9+4RTrfSYST9bW51+Jd25gvxa1qRDh8uCAl2bRsdv/ZV/lfjZ3oYCxKUDl3uslDgcSF7p7FS2EqQriH9BGmmqMVUWMyb/TLfG3FptCrO5Og5zZ+MZTom0mi0hgneNjNUnt46Vq5jiKXF1htXCmYW77WNLSz5zt3Uuv4WDvV5EV/oYTmdKvB2izCyzKfp04lGums6su9UJUbyTsMPhgfbjDZfju2KhFe/358U8lJOEyFdOCgEqSBOzUpOTp8Sc+K36qIkkjrPE7/Nf0Ar+pNeeFrPb36hio8zWbzjT77eiFpgi89L4AOHlm2aYvGGyMaJEotNtksBjYVwy4xcNNLd8UGt875k55bDnVsY8z6TdRL8f4uOzU8XwbZaf6rgdDuEgX2mr59+T1Z9fZIf109OTpkrHo393Qb2b/SAntdk8e5Sbq4mOZIUYU9uB05SostP0wOmA7zE8//soQ8tr/e3UeH9mvVOQaQTDSlSoRqvUWtl7EC34U9dbgaW5Hin7D9GQnK3SiQi3i8JY1VjEoEb3C86BWpqBGH6vGRTDu89FkNzePkDiTqRUv/FqbS+WOnCKLpsKTVxJeNF81X9/N3g/u758E6RutoRpX9i6SnhRtLyLbJ6F1s8TNZZTcoB9TXBaB8jt9PpsAkK/nI4+GMtsijgTI68nvxK4L/EClrfZonJ305kBX+/idbyOVartZyf2M5WW5i0lpTxaEKf73c46f3sIKB7DMxQq/g8=","base64")).toString()),QR)});var a7=w((vR,o7)=>{(function(t,e){typeof vR=="object"?o7.exports=e():typeof define=="function"&&define.amd?define(e):t.treeify=e()})(vR,function(){function t(n,s){var o=s?"\u2514":"\u251C";return n?o+="\u2500 ":o+="\u2500\u2500\u2510",o}function e(n,s){var o=[];for(var a in n)!n.hasOwnProperty(a)||s&&typeof n[a]=="function"||o.push(a);return o}function r(n,s,o,a,l,c,u){var g="",f=0,h,p,m=a.slice(0);if(m.push([s,o])&&a.length>0&&(a.forEach(function(Q,S){S>0&&(g+=(Q[1]?" ":"\u2502")+"  "),!p&&Q[0]===s&&(p=!0)}),g+=t(n,o)+n,l&&(typeof s!="object"||s instanceof Date)&&(g+=": "+s),p&&(g+=" (circular ref.)"),u(g)),!p&&typeof s=="object"){var y=e(s,c);y.forEach(function(Q){h=++f===y.length,r(Q,s[Q],h,m,l,c,u)})}}var i={};return i.asLines=function(n,s,o,a){var l=typeof o!="function"?o:!1;r(".",n,!1,[],s,l,a||o)},i.asTree=function(n,s,o){var a="";return r(".",n,!1,[],s,o,function(l){a+=l+`
+`}),a},i})});var gA=w(xR=>{"use strict";Object.defineProperty(xR,"__esModule",{value:!0});xR.default=f7;function f7(){}f7.prototype={diff:function(e,r){var i=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{},n=i.callback;typeof i=="function"&&(n=i,i={}),this.options=i;var s=this;function o(m){return n?(setTimeout(function(){n(void 0,m)},0),!0):m}e=this.castInput(e),r=this.castInput(r),e=this.removeEmpty(this.tokenize(e)),r=this.removeEmpty(this.tokenize(r));var a=r.length,l=e.length,c=1,u=a+l,g=[{newPos:-1,components:[]}],f=this.extractCommon(g[0],r,e,0);if(g[0].newPos+1>=a&&f+1>=l)return o([{value:this.join(r),count:r.length}]);function h(){for(var m=-1*c;m<=c;m+=2){var y=void 0,Q=g[m-1],S=g[m+1],x=(S?S.newPos:0)-m;Q&&(g[m-1]=void 0);var M=Q&&Q.newPos+1<a,Y=S&&0<=x&&x<l;if(!M&&!Y){g[m]=void 0;continue}if(!M||Y&&Q.newPos<S.newPos?(y=hLe(S),s.pushComponent(y.components,void 0,!0)):(y=Q,y.newPos++,s.pushComponent(y.components,!0,void 0)),x=s.extractCommon(y,r,e,m),y.newPos+1>=a&&x+1>=l)return o(fLe(s,y.components,r,e,s.useLongestToken));g[m]=y}c++}if(n)(function m(){setTimeout(function(){if(c>u)return n();h()||m()},0)})();else for(;c<=u;){var p=h();if(p)return p}},pushComponent:function(e,r,i){var n=e[e.length-1];n&&n.added===r&&n.removed===i?e[e.length-1]={count:n.count+1,added:r,removed:i}:e.push({count:1,added:r,removed:i})},extractCommon:function(e,r,i,n){for(var s=r.length,o=i.length,a=e.newPos,l=a-n,c=0;a+1<s&&l+1<o&&this.equals(r[a+1],i[l+1]);)a++,l++,c++;return c&&e.components.push({count:c}),e.newPos=a,l},equals:function(e,r){return this.options.comparator?this.options.comparator(e,r):e===r||this.options.ignoreCase&&e.toLowerCase()===r.toLowerCase()},removeEmpty:function(e){for(var r=[],i=0;i<e.length;i++)e[i]&&r.push(e[i]);return r},castInput:function(e){return e},tokenize:function(e){return e.split("")},join:function(e){return e.join("")}};function fLe(t,e,r,i,n){for(var s=0,o=e.length,a=0,l=0;s<o;s++){var c=e[s];if(c.removed){if(c.value=t.join(i.slice(l,l+c.count)),l+=c.count,s&&e[s-1].added){var g=e[s-1];e[s-1]=e[s],e[s]=g}}else{if(!c.added&&n){var u=r.slice(a,a+c.count);u=u.map(function(h,p){var m=i[l+p];return m.length>h.length?m:h}),c.value=t.join(u)}else c.value=t.join(r.slice(a,a+c.count));a+=c.count,c.added||(l+=c.count)}}var f=e[o-1];return o>1&&typeof f.value=="string"&&(f.added||f.removed)&&t.equals("",f.value)&&(e[o-2].value+=f.value,e.pop()),e}function hLe(t){return{newPos:t.newPos,components:t.components.slice(0)}}});var p7=w(lC=>{"use strict";Object.defineProperty(lC,"__esModule",{value:!0});lC.diffChars=pLe;lC.characterDiff=void 0;var CLe=dLe(gA());function dLe(t){return t&&t.__esModule?t:{default:t}}var h7=new CLe.default;lC.characterDiff=h7;function pLe(t,e,r){return h7.diff(t,e,r)}});var DR=w(PR=>{"use strict";Object.defineProperty(PR,"__esModule",{value:!0});PR.generateOptions=mLe;function mLe(t,e){if(typeof t=="function")e.callback=t;else if(t)for(var r in t)t.hasOwnProperty(r)&&(e[r]=t[r]);return e}});var m7=w(bf=>{"use strict";Object.defineProperty(bf,"__esModule",{value:!0});bf.diffWords=ELe;bf.diffWordsWithSpace=ILe;bf.wordDiff=void 0;var wLe=yLe(gA()),BLe=DR();function yLe(t){return t&&t.__esModule?t:{default:t}}var d7=/^[A-Za-z\xC0-\u02C6\u02C8-\u02D7\u02DE-\u02FF\u1E00-\u1EFF]+$/,C7=/\S/,cC=new wLe.default;bf.wordDiff=cC;cC.equals=function(t,e){return this.options.ignoreCase&&(t=t.toLowerCase(),e=e.toLowerCase()),t===e||this.options.ignoreWhitespace&&!C7.test(t)&&!C7.test(e)};cC.tokenize=function(t){for(var e=t.split(/(\s+|[()[\]{}'"]|\b)/),r=0;r<e.length-1;r++)!e[r+1]&&e[r+2]&&d7.test(e[r])&&d7.test(e[r+2])&&(e[r]+=e[r+2],e.splice(r+1,2),r--);return e};function ELe(t,e,r){return r=(0,BLe.generateOptions)(r,{ignoreWhitespace:!0}),cC.diff(t,e,r)}function ILe(t,e,r){return cC.diff(t,e,r)}});var ZB=w(Qf=>{"use strict";Object.defineProperty(Qf,"__esModule",{value:!0});Qf.diffLines=bLe;Qf.diffTrimmedLines=QLe;Qf.lineDiff=void 0;var SLe=vLe(gA()),kLe=DR();function vLe(t){return t&&t.__esModule?t:{default:t}}var XB=new SLe.default;Qf.lineDiff=XB;XB.tokenize=function(t){var e=[],r=t.split(/(\n|\r\n)/);r[r.length-1]||r.pop();for(var i=0;i<r.length;i++){var n=r[i];i%2&&!this.options.newlineIsToken?e[e.length-1]+=n:(this.options.ignoreWhitespace&&(n=n.trim()),e.push(n))}return e};function bLe(t,e,r){return XB.diff(t,e,r)}function QLe(t,e,r){var i=(0,kLe.generateOptions)(r,{ignoreWhitespace:!0});return XB.diff(t,e,i)}});var E7=w(uC=>{"use strict";Object.defineProperty(uC,"__esModule",{value:!0});uC.diffSentences=xLe;uC.sentenceDiff=void 0;var DLe=PLe(gA());function PLe(t){return t&&t.__esModule?t:{default:t}}var RR=new DLe.default;uC.sentenceDiff=RR;RR.tokenize=function(t){return t.split(/(\S.+?[.!?])(?=\s+|$)/)};function xLe(t,e,r){return RR.diff(t,e,r)}});var I7=w(gC=>{"use strict";Object.defineProperty(gC,"__esModule",{value:!0});gC.diffCss=RLe;gC.cssDiff=void 0;var NLe=FLe(gA());function FLe(t){return t&&t.__esModule?t:{default:t}}var FR=new NLe.default;gC.cssDiff=FR;FR.tokenize=function(t){return t.split(/([{}:;,]|\s+)/)};function RLe(t,e,r){return FR.diff(t,e,r)}});var w7=w(vf=>{"use strict";Object.defineProperty(vf,"__esModule",{value:!0});vf.diffJson=LLe;vf.canonicalize=$B;vf.jsonDiff=void 0;var y7=TLe(gA()),OLe=ZB();function TLe(t){return t&&t.__esModule?t:{default:t}}function e0(t){return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?e0=function(r){return typeof r}:e0=function(r){return r&&typeof Symbol=="function"&&r.constructor===Symbol&&r!==Symbol.prototype?"symbol":typeof r},e0(t)}var MLe=Object.prototype.toString,au=new y7.default;vf.jsonDiff=au;au.useLongestToken=!0;au.tokenize=OLe.lineDiff.tokenize;au.castInput=function(t){var e=this.options,r=e.undefinedReplacement,i=e.stringifyReplacer,n=i===void 0?function(s,o){return typeof o=="undefined"?r:o}:i;return typeof t=="string"?t:JSON.stringify($B(t,null,null,n),n,"  ")};au.equals=function(t,e){return y7.default.prototype.equals.call(au,t.replace(/,([\r\n])/g,"$1"),e.replace(/,([\r\n])/g,"$1"))};function LLe(t,e,r){return au.diff(t,e,r)}function $B(t,e,r,i,n){e=e||[],r=r||[],i&&(t=i(n,t));var s;for(s=0;s<e.length;s+=1)if(e[s]===t)return r[s];var o;if(MLe.call(t)==="[object Array]"){for(e.push(t),o=new Array(t.length),r.push(o),s=0;s<t.length;s+=1)o[s]=$B(t[s],e,r,i,n);return e.pop(),r.pop(),o}if(t&&t.toJSON&&(t=t.toJSON()),e0(t)==="object"&&t!==null){e.push(t),o={},r.push(o);var a=[],l;for(l in t)t.hasOwnProperty(l)&&a.push(l);for(a.sort(),s=0;s<a.length;s+=1)l=a[s],o[l]=$B(t[l],e,r,i,l);e.pop(),r.pop()}else o=t;return o}});var B7=w(fC=>{"use strict";Object.defineProperty(fC,"__esModule",{value:!0});fC.diffArrays=ULe;fC.arrayDiff=void 0;var HLe=KLe(gA());function KLe(t){return t&&t.__esModule?t:{default:t}}var hC=new HLe.default;fC.arrayDiff=hC;hC.tokenize=function(t){return t.slice()};hC.join=hC.removeEmpty=function(t){return t};function ULe(t,e,r){return hC.diff(t,e,r)}});var t0=w(NR=>{"use strict";Object.defineProperty(NR,"__esModule",{value:!0});NR.parsePatch=jLe;function jLe(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},r=t.split(/\r\n|[\n\v\f\r\x85]/),i=t.match(/\r\n|[\n\v\f\r\x85]/g)||[],n=[],s=0;function o(){var c={};for(n.push(c);s<r.length;){var u=r[s];if(/^(\-\-\-|\+\+\+|@@)\s/.test(u))break;var g=/^(?:Index:|diff(?: -r \w+)+)\s+(.+?)\s*$/.exec(u);g&&(c.index=g[1]),s++}for(a(c),a(c),c.hunks=[];s<r.length;){var f=r[s];if(/^(Index:|diff|\-\-\-|\+\+\+)\s/.test(f))break;if(/^@@/.test(f))c.hunks.push(l());else{if(f&&e.strict)throw new Error("Unknown line "+(s+1)+" "+JSON.stringify(f));s++}}}function a(c){var u=/^(---|\+\+\+)\s+(.*)$/.exec(r[s]);if(u){var g=u[1]==="---"?"old":"new",f=u[2].split("  ",2),h=f[0].replace(/\\\\/g,"\\");/^".*"$/.test(h)&&(h=h.substr(1,h.length-2)),c[g+"FileName"]=h,c[g+"Header"]=(f[1]||"").trim(),s++}}function l(){for(var c=s,u=r[s++],g=u.split(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/),f={oldStart:+g[1],oldLines:+g[2]||1,newStart:+g[3],newLines:+g[4]||1,lines:[],linedelimiters:[]},h=0,p=0;s<r.length&&!(r[s].indexOf("--- ")===0&&s+2<r.length&&r[s+1].indexOf("+++ ")===0&&r[s+2].indexOf("@@")===0);s++){var m=r[s].length==0&&s!=r.length-1?" ":r[s][0];if(m==="+"||m==="-"||m===" "||m==="\\")f.lines.push(r[s]),f.linedelimiters.push(i[s]||`
+`),m==="+"?h++:m==="-"?p++:m===" "&&(h++,p++);else break}if(!h&&f.newLines===1&&(f.newLines=0),!p&&f.oldLines===1&&(f.oldLines=0),e.strict){if(h!==f.newLines)throw new Error("Added line count did not match for hunk at line "+(c+1));if(p!==f.oldLines)throw new Error("Removed line count did not match for hunk at line "+(c+1))}return f}for(;s<r.length;)o();return n}});var b7=w(LR=>{"use strict";Object.defineProperty(LR,"__esModule",{value:!0});LR.default=GLe;function GLe(t,e,r){var i=!0,n=!1,s=!1,o=1;return function a(){if(i&&!s){if(n?o++:i=!1,t+o<=r)return o;s=!0}if(!n)return s||(i=!0),e<=t-o?-o++:(n=!0,a())}}});var S7=w(r0=>{"use strict";Object.defineProperty(r0,"__esModule",{value:!0});r0.applyPatch=Q7;r0.applyPatches=YLe;var v7=t0(),JLe=qLe(b7());function qLe(t){return t&&t.__esModule?t:{default:t}}function Q7(t,e){var r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{};if(typeof e=="string"&&(e=(0,v7.parsePatch)(e)),Array.isArray(e)){if(e.length>1)throw new Error("applyPatch only works with a single input.");e=e[0]}var i=t.split(/\r\n|[\n\v\f\r\x85]/),n=t.match(/\r\n|[\n\v\f\r\x85]/g)||[],s=e.hunks,o=r.compareLine||function(T,L,Ee,we){return L===we},a=0,l=r.fuzzFactor||0,c=0,u=0,g,f;function h(T,L){for(var Ee=0;Ee<T.lines.length;Ee++){var we=T.lines[Ee],qe=we.length>0?we[0]:" ",re=we.length>0?we.substr(1):we;if(qe===" "||qe==="-"){if(!o(L+1,i[L],qe,re)&&(a++,a>l))return!1;L++}}return!0}for(var p=0;p<s.length;p++){for(var m=s[p],y=i.length-m.oldLines,Q=0,S=u+m.oldStart-1,x=(0,JLe.default)(S,c,y);Q!==void 0;Q=x())if(h(m,S+Q)){m.offset=u+=Q;break}if(Q===void 0)return!1;c=m.offset+m.oldStart+m.oldLines}for(var M=0,Y=0;Y<s.length;Y++){var U=s[Y],J=U.oldStart+U.offset+M-1;M+=U.newLines-U.oldLines,J<0&&(J=0);for(var W=0;W<U.lines.length;W++){var ee=U.lines[W],Z=ee.length>0?ee[0]:" ",A=ee.length>0?ee.substr(1):ee,ne=U.linedelimiters[W];if(Z===" ")J++;else if(Z==="-")i.splice(J,1),n.splice(J,1);else if(Z==="+")i.splice(J,0,A),n.splice(J,0,ne),J++;else if(Z==="\\"){var le=U.lines[W-1]?U.lines[W-1][0]:null;le==="+"?g=!0:le==="-"&&(f=!0)}}}if(g)for(;!i[i.length-1];)i.pop(),n.pop();else f&&(i.push(""),n.push(`
+`));for(var Ae=0;Ae<i.length-1;Ae++)i[Ae]=i[Ae]+n[Ae];return i.join("")}function YLe(t,e){typeof t=="string"&&(t=(0,v7.parsePatch)(t));var r=0;function i(){var n=t[r++];if(!n)return e.complete();e.loadFile(n,function(s,o){if(s)return e.complete(s);var a=Q7(o,n,e);e.patched(n,a,function(l){if(l)return e.complete(l);i()})})}i()}});var OR=w(pC=>{"use strict";Object.defineProperty(pC,"__esModule",{value:!0});pC.structuredPatch=k7;pC.createTwoFilesPatch=x7;pC.createPatch=WLe;var zLe=ZB();function TR(t){return XLe(t)||VLe(t)||_Le()}function _Le(){throw new TypeError("Invalid attempt to spread non-iterable instance")}function VLe(t){if(Symbol.iterator in Object(t)||Object.prototype.toString.call(t)==="[object Arguments]")return Array.from(t)}function XLe(t){if(Array.isArray(t)){for(var e=0,r=new Array(t.length);e<t.length;e++)r[e]=t[e];return r}}function k7(t,e,r,i,n,s,o){o||(o={}),typeof o.context=="undefined"&&(o.context=4);var a=(0,zLe.diffLines)(r,i,o);a.push({value:"",lines:[]});function l(Q){return Q.map(function(S){return" "+S})}for(var c=[],u=0,g=0,f=[],h=1,p=1,m=function(S){var x=a[S],M=x.lines||x.value.replace(/\n$/,"").split(`
+`);if(x.lines=M,x.added||x.removed){var Y;if(!u){var U=a[S-1];u=h,g=p,U&&(f=o.context>0?l(U.lines.slice(-o.context)):[],u-=f.length,g-=f.length)}(Y=f).push.apply(Y,TR(M.map(function(Ae){return(x.added?"+":"-")+Ae}))),x.added?p+=M.length:h+=M.length}else{if(u)if(M.length<=o.context*2&&S<a.length-2){var J;(J=f).push.apply(J,TR(l(M)))}else{var W,ee=Math.min(M.length,o.context);(W=f).push.apply(W,TR(l(M.slice(0,ee))));var Z={oldStart:u,oldLines:h-u+ee,newStart:g,newLines:p-g+ee,lines:f};if(S>=a.length-2&&M.length<=o.context){var A=/\n$/.test(r),ne=/\n$/.test(i),le=M.length==0&&f.length>Z.oldLines;!A&&le&&f.splice(Z.oldLines,0,"\\ No newline at end of file"),(!A&&!le||!ne)&&f.push("\\ No newline at end of file")}c.push(Z),u=0,g=0,f=[]}h+=M.length,p+=M.length}},y=0;y<a.length;y++)m(y);return{oldFileName:t,newFileName:e,oldHeader:n,newHeader:s,hunks:c}}function x7(t,e,r,i,n,s,o){var a=k7(t,e,r,i,n,s,o),l=[];t==e&&l.push("Index: "+t),l.push("==================================================================="),l.push("--- "+a.oldFileName+(typeof a.oldHeader=="undefined"?"":"    "+a.oldHeader)),l.push("+++ "+a.newFileName+(typeof a.newHeader=="undefined"?"":"       "+a.newHeader));for(var c=0;c<a.hunks.length;c++){var u=a.hunks[c];l.push("@@ -"+u.oldStart+","+u.oldLines+" +"+u.newStart+","+u.newLines+" @@"),l.push.apply(l,u.lines)}return l.join(`
+`)+`
+`}function WLe(t,e,r,i,n,s){return x7(t,t,e,r,i,n,s)}});var D7=w(i0=>{"use strict";Object.defineProperty(i0,"__esModule",{value:!0});i0.arrayEqual=ZLe;i0.arrayStartsWith=P7;function ZLe(t,e){return t.length!==e.length?!1:P7(t,e)}function P7(t,e){if(e.length>t.length)return!1;for(var r=0;r<e.length;r++)if(e[r]!==t[r])return!1;return!0}});var j7=w(n0=>{"use strict";Object.defineProperty(n0,"__esModule",{value:!0});n0.calcLineCount=R7;n0.merge=$Le;var eTe=OR(),tTe=t0(),MR=D7();function Sf(t){return nTe(t)||iTe(t)||rTe()}function rTe(){throw new TypeError("Invalid attempt to spread non-iterable instance")}function iTe(t){if(Symbol.iterator in Object(t)||Object.prototype.toString.call(t)==="[object Arguments]")return Array.from(t)}function nTe(t){if(Array.isArray(t)){for(var e=0,r=new Array(t.length);e<t.length;e++)r[e]=t[e];return r}}function R7(t){var e=UR(t.lines),r=e.oldLines,i=e.newLines;r!==void 0?t.oldLines=r:delete t.oldLines,i!==void 0?t.newLines=i:delete t.newLines}function $Le(t,e,r){t=F7(t,r),e=F7(e,r);var i={};(t.index||e.index)&&(i.index=t.index||e.index),(t.newFileName||e.newFileName)&&(N7(t)?N7(e)?(i.oldFileName=s0(i,t.oldFileName,e.oldFileName),i.newFileName=s0(i,t.newFileName,e.newFileName),i.oldHeader=s0(i,t.oldHeader,e.oldHeader),i.newHeader=s0(i,t.newHeader,e.newHeader)):(i.oldFileName=t.oldFileName,i.newFileName=t.newFileName,i.oldHeader=t.oldHeader,i.newHeader=t.newHeader):(i.oldFileName=e.oldFileName||t.oldFileName,i.newFileName=e.newFileName||t.newFileName,i.oldHeader=e.oldHeader||t.oldHeader,i.newHeader=e.newHeader||t.newHeader)),i.hunks=[];for(var n=0,s=0,o=0,a=0;n<t.hunks.length||s<e.hunks.length;){var l=t.hunks[n]||{oldStart:Infinity},c=e.hunks[s]||{oldStart:Infinity};if(L7(l,c))i.hunks.push(T7(l,o)),n++,a+=l.newLines-l.oldLines;else if(L7(c,l))i.hunks.push(T7(c,a)),s++,o+=c.newLines-c.oldLines;else{var u={oldStart:Math.min(l.oldStart,c.oldStart),oldLines:0,newStart:Math.min(l.newStart+o,c.oldStart+a),newLines:0,lines:[]};sTe(u,l.oldStart,l.lines,c.oldStart,c.lines),s++,n++,i.hunks.push(u)}}return i}function F7(t,e){if(typeof t=="string"){if(/^@@/m.test(t)||/^Index:/m.test(t))return(0,tTe.parsePatch)(t)[0];if(!e)throw new Error("Must provide a base reference or pass in a patch");return(0,eTe.structuredPatch)(void 0,void 0,e,t)}return t}function N7(t){return t.newFileName&&t.newFileName!==t.oldFileName}function s0(t,e,r){return e===r?e:(t.conflict=!0,{mine:e,theirs:r})}function L7(t,e){return t.oldStart<e.oldStart&&t.oldStart+t.oldLines<e.oldStart}function T7(t,e){return{oldStart:t.oldStart,oldLines:t.oldLines,newStart:t.newStart+e,newLines:t.newLines,lines:t.lines}}function sTe(t,e,r,i,n){var s={offset:e,lines:r,index:0},o={offset:i,lines:n,index:0};for(M7(t,s,o),M7(t,o,s);s.index<s.lines.length&&o.index<o.lines.length;){var a=s.lines[s.index],l=o.lines[o.index];if((a[0]==="-"||a[0]==="+")&&(l[0]==="-"||l[0]==="+"))oTe(t,s,o);else if(a[0]==="+"&&l[0]===" "){var c;(c=t.lines).push.apply(c,Sf(Au(s)))}else if(l[0]==="+"&&a[0]===" "){var u;(u=t.lines).push.apply(u,Sf(Au(o)))}else a[0]==="-"&&l[0]===" "?O7(t,s,o):l[0]==="-"&&a[0]===" "?O7(t,o,s,!0):a===l?(t.lines.push(a),s.index++,o.index++):KR(t,Au(s),Au(o))}U7(t,s),U7(t,o),R7(t)}function oTe(t,e,r){var i=Au(e),n=Au(r);if(K7(i)&&K7(n)){if((0,MR.arrayStartsWith)(i,n)&&H7(r,i,i.length-n.length)){var s;(s=t.lines).push.apply(s,Sf(i));return}else if((0,MR.arrayStartsWith)(n,i)&&H7(e,n,n.length-i.length)){var o;(o=t.lines).push.apply(o,Sf(n));return}}else if((0,MR.arrayEqual)(i,n)){var a;(a=t.lines).push.apply(a,Sf(i));return}KR(t,i,n)}function O7(t,e,r,i){var n=Au(e),s=aTe(r,n);if(s.merged){var o;(o=t.lines).push.apply(o,Sf(s.merged))}else KR(t,i?s:n,i?n:s)}function KR(t,e,r){t.conflict=!0,t.lines.push({conflict:!0,mine:e,theirs:r})}function M7(t,e,r){for(;e.offset<r.offset&&e.index<e.lines.length;){var i=e.lines[e.index++];t.lines.push(i),e.offset++}}function U7(t,e){for(;e.index<e.lines.length;){var r=e.lines[e.index++];t.lines.push(r)}}function Au(t){for(var e=[],r=t.lines[t.index][0];t.index<t.lines.length;){var i=t.lines[t.index];if(r==="-"&&i[0]==="+"&&(r="+"),r===i[0])e.push(i),t.index++;else break}return e}function aTe(t,e){for(var r=[],i=[],n=0,s=!1,o=!1;n<e.length&&t.index<t.lines.length;){var a=t.lines[t.index],l=e[n];if(l[0]==="+")break;if(s=s||a[0]!==" ",i.push(l),n++,a[0]==="+")for(o=!0;a[0]==="+";)r.push(a),a=t.lines[++t.index];l.substr(1)===a.substr(1)?(r.push(a),t.index++):o=!0}if((e[n]||"")[0]==="+"&&s&&(o=!0),o)return r;for(;n<e.length;)i.push(e[n++]);return{merged:i,changes:r}}function K7(t){return t.reduce(function(e,r){return e&&r[0]==="-"},!0)}function H7(t,e,r){for(var i=0;i<r;i++){var n=e[e.length-r+i].substr(1);if(t.lines[t.index+i]!==" "+n)return!1}return t.index+=r,!0}function UR(t){var e=0,r=0;return t.forEach(function(i){if(typeof i!="string"){var n=UR(i.mine),s=UR(i.theirs);e!==void 0&&(n.oldLines===s.oldLines?e+=n.oldLines:e=void 0),r!==void 0&&(n.newLines===s.newLines?r+=n.newLines:r=void 0)}else r!==void 0&&(i[0]==="+"||i[0]===" ")&&r++,e!==void 0&&(i[0]==="-"||i[0]===" ")&&e++}),{oldLines:e,newLines:r}}});var G7=w(HR=>{"use strict";Object.defineProperty(HR,"__esModule",{value:!0});HR.convertChangesToDMP=ATe;function ATe(t){for(var e=[],r,i,n=0;n<t.length;n++)r=t[n],r.added?i=1:r.removed?i=-1:i=0,e.push([i,r.value]);return e}});var Y7=w(jR=>{"use strict";Object.defineProperty(jR,"__esModule",{value:!0});jR.convertChangesToXML=lTe;function lTe(t){for(var e=[],r=0;r<t.length;r++){var i=t[r];i.added?e.push("<ins>"):i.removed&&e.push("<del>"),e.push(cTe(i.value)),i.added?e.push("</ins>"):i.removed&&e.push("</del>")}return e.join("")}function cTe(t){var e=t;return e=e.replace(/&/g,"&amp;"),e=e.replace(/</g,"&lt;"),e=e.replace(/>/g,"&gt;"),e=e.replace(/"/g,"&quot;"),e}});var _7=w(Kr=>{"use strict";Object.defineProperty(Kr,"__esModule",{value:!0});Object.defineProperty(Kr,"Diff",{enumerable:!0,get:function(){return uTe.default}});Object.defineProperty(Kr,"diffChars",{enumerable:!0,get:function(){return gTe.diffChars}});Object.defineProperty(Kr,"diffWords",{enumerable:!0,get:function(){return q7.diffWords}});Object.defineProperty(Kr,"diffWordsWithSpace",{enumerable:!0,get:function(){return q7.diffWordsWithSpace}});Object.defineProperty(Kr,"diffLines",{enumerable:!0,get:function(){return J7.diffLines}});Object.defineProperty(Kr,"diffTrimmedLines",{enumerable:!0,get:function(){return J7.diffTrimmedLines}});Object.defineProperty(Kr,"diffSentences",{enumerable:!0,get:function(){return fTe.diffSentences}});Object.defineProperty(Kr,"diffCss",{enumerable:!0,get:function(){return hTe.diffCss}});Object.defineProperty(Kr,"diffJson",{enumerable:!0,get:function(){return W7.diffJson}});Object.defineProperty(Kr,"canonicalize",{enumerable:!0,get:function(){return W7.canonicalize}});Object.defineProperty(Kr,"diffArrays",{enumerable:!0,get:function(){return pTe.diffArrays}});Object.defineProperty(Kr,"applyPatch",{enumerable:!0,get:function(){return z7.applyPatch}});Object.defineProperty(Kr,"applyPatches",{enumerable:!0,get:function(){return z7.applyPatches}});Object.defineProperty(Kr,"parsePatch",{enumerable:!0,get:function(){return dTe.parsePatch}});Object.defineProperty(Kr,"merge",{enumerable:!0,get:function(){return CTe.merge}});Object.defineProperty(Kr,"structuredPatch",{enumerable:!0,get:function(){return GR.structuredPatch}});Object.defineProperty(Kr,"createTwoFilesPatch",{enumerable:!0,get:function(){return GR.createTwoFilesPatch}});Object.defineProperty(Kr,"createPatch",{enumerable:!0,get:function(){return GR.createPatch}});Object.defineProperty(Kr,"convertChangesToDMP",{enumerable:!0,get:function(){return mTe.convertChangesToDMP}});Object.defineProperty(Kr,"convertChangesToXML",{enumerable:!0,get:function(){return ETe.convertChangesToXML}});var uTe=ITe(gA()),gTe=p7(),q7=m7(),J7=ZB(),fTe=E7(),hTe=I7(),W7=w7(),pTe=B7(),z7=S7(),dTe=t0(),CTe=j7(),GR=OR(),mTe=G7(),ETe=Y7();function ITe(t){return t&&t.__esModule?t:{default:t}}});var o0=w((Cct,V7)=>{var yTe=Os(),wTe=Id(),BTe=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,bTe=/^\w*$/;function QTe(t,e){if(yTe(t))return!1;var r=typeof t;return r=="number"||r=="symbol"||r=="boolean"||t==null||wTe(t)?!0:bTe.test(t)||!BTe.test(t)||e!=null&&t in Object(e)}V7.exports=QTe});var a0=w((mct,X7)=>{var vTe=Hc(),STe=Rn(),kTe="[object AsyncFunction]",xTe="[object Function]",PTe="[object GeneratorFunction]",DTe="[object Proxy]";function RTe(t){if(!STe(t))return!1;var e=vTe(t);return e==xTe||e==PTe||e==kTe||e==DTe}X7.exports=RTe});var $7=w((Ect,Z7)=>{var FTe=Rs(),NTe=FTe["__core-js_shared__"];Z7.exports=NTe});var rX=w((Ict,eX)=>{var YR=$7(),tX=function(){var t=/[^.]+$/.exec(YR&&YR.keys&&YR.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""}();function LTe(t){return!!tX&&tX in t}eX.exports=LTe});var qR=w((yct,iX)=>{var TTe=Function.prototype,OTe=TTe.toString;function MTe(t){if(t!=null){try{return OTe.call(t)}catch(e){}try{return t+""}catch(e){}}return""}iX.exports=MTe});var sX=w((wct,nX)=>{var UTe=a0(),KTe=rX(),HTe=Rn(),jTe=qR(),GTe=/[\\^$.*+?()[\]{}|]/g,YTe=/^\[object .+?Constructor\]$/,qTe=Function.prototype,JTe=Object.prototype,WTe=qTe.toString,zTe=JTe.hasOwnProperty,_Te=RegExp("^"+WTe.call(zTe).replace(GTe,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");function VTe(t){if(!HTe(t)||KTe(t))return!1;var e=UTe(t)?_Te:YTe;return e.test(jTe(t))}nX.exports=VTe});var aX=w((Bct,oX)=>{function XTe(t,e){return t==null?void 0:t[e]}oX.exports=XTe});var vl=w((bct,AX)=>{var ZTe=sX(),$Te=aX();function eOe(t,e){var r=$Te(t,e);return ZTe(r)?r:void 0}AX.exports=eOe});var dC=w((Qct,lX)=>{var tOe=vl(),rOe=tOe(Object,"create");lX.exports=rOe});var gX=w((vct,cX)=>{var uX=dC();function iOe(){this.__data__=uX?uX(null):{},this.size=0}cX.exports=iOe});var hX=w((Sct,fX)=>{function nOe(t){var e=this.has(t)&&delete this.__data__[t];return this.size-=e?1:0,e}fX.exports=nOe});var dX=w((kct,pX)=>{var sOe=dC(),oOe="__lodash_hash_undefined__",aOe=Object.prototype,AOe=aOe.hasOwnProperty;function lOe(t){var e=this.__data__;if(sOe){var r=e[t];return r===oOe?void 0:r}return AOe.call(e,t)?e[t]:void 0}pX.exports=lOe});var mX=w((xct,CX)=>{var cOe=dC(),uOe=Object.prototype,gOe=uOe.hasOwnProperty;function fOe(t){var e=this.__data__;return cOe?e[t]!==void 0:gOe.call(e,t)}CX.exports=fOe});var IX=w((Pct,EX)=>{var hOe=dC(),pOe="__lodash_hash_undefined__";function dOe(t,e){var r=this.__data__;return this.size+=this.has(t)?0:1,r[t]=hOe&&e===void 0?pOe:e,this}EX.exports=dOe});var wX=w((Dct,yX)=>{var COe=gX(),mOe=hX(),EOe=dX(),IOe=mX(),yOe=IX();function kf(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e<r;){var i=t[e];this.set(i[0],i[1])}}kf.prototype.clear=COe;kf.prototype.delete=mOe;kf.prototype.get=EOe;kf.prototype.has=IOe;kf.prototype.set=yOe;yX.exports=kf});var bX=w((Rct,BX)=>{function wOe(){this.__data__=[],this.size=0}BX.exports=wOe});var xf=w((Fct,QX)=>{function BOe(t,e){return t===e||t!==t&&e!==e}QX.exports=BOe});var CC=w((Nct,vX)=>{var bOe=xf();function QOe(t,e){for(var r=t.length;r--;)if(bOe(t[r][0],e))return r;return-1}vX.exports=QOe});var kX=w((Lct,SX)=>{var vOe=CC(),SOe=Array.prototype,kOe=SOe.splice;function xOe(t){var e=this.__data__,r=vOe(e,t);if(r<0)return!1;var i=e.length-1;return r==i?e.pop():kOe.call(e,r,1),--this.size,!0}SX.exports=xOe});var PX=w((Tct,xX)=>{var POe=CC();function DOe(t){var e=this.__data__,r=POe(e,t);return r<0?void 0:e[r][1]}xX.exports=DOe});var RX=w((Oct,DX)=>{var ROe=CC();function FOe(t){return ROe(this.__data__,t)>-1}DX.exports=FOe});var NX=w((Mct,FX)=>{var NOe=CC();function LOe(t,e){var r=this.__data__,i=NOe(r,t);return i<0?(++this.size,r.push([t,e])):r[i][1]=e,this}FX.exports=LOe});var mC=w((Uct,LX)=>{var TOe=bX(),OOe=kX(),MOe=PX(),UOe=RX(),KOe=NX();function Pf(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e<r;){var i=t[e];this.set(i[0],i[1])}}Pf.prototype.clear=TOe;Pf.prototype.delete=OOe;Pf.prototype.get=MOe;Pf.prototype.has=UOe;Pf.prototype.set=KOe;LX.exports=Pf});var A0=w((Kct,TX)=>{var HOe=vl(),jOe=Rs(),GOe=HOe(jOe,"Map");TX.exports=GOe});var UX=w((Hct,OX)=>{var MX=wX(),YOe=mC(),qOe=A0();function JOe(){this.size=0,this.__data__={hash:new MX,map:new(qOe||YOe),string:new MX}}OX.exports=JOe});var HX=w((jct,KX)=>{function WOe(t){var e=typeof t;return e=="string"||e=="number"||e=="symbol"||e=="boolean"?t!=="__proto__":t===null}KX.exports=WOe});var EC=w((Gct,jX)=>{var zOe=HX();function _Oe(t,e){var r=t.__data__;return zOe(e)?r[typeof e=="string"?"string":"hash"]:r.map}jX.exports=_Oe});var YX=w((Yct,GX)=>{var VOe=EC();function XOe(t){var e=VOe(this,t).delete(t);return this.size-=e?1:0,e}GX.exports=XOe});var JX=w((qct,qX)=>{var ZOe=EC();function $Oe(t){return ZOe(this,t).get(t)}qX.exports=$Oe});var zX=w((Jct,WX)=>{var eMe=EC();function tMe(t){return eMe(this,t).has(t)}WX.exports=tMe});var VX=w((Wct,_X)=>{var rMe=EC();function iMe(t,e){var r=rMe(this,t),i=r.size;return r.set(t,e),this.size+=r.size==i?0:1,this}_X.exports=iMe});var l0=w((zct,XX)=>{var nMe=UX(),sMe=YX(),oMe=JX(),aMe=zX(),AMe=VX();function Df(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e<r;){var i=t[e];this.set(i[0],i[1])}}Df.prototype.clear=nMe;Df.prototype.delete=sMe;Df.prototype.get=oMe;Df.prototype.has=aMe;Df.prototype.set=AMe;XX.exports=Df});var eZ=w((_ct,ZX)=>{var $X=l0(),lMe="Expected a function";function JR(t,e){if(typeof t!="function"||e!=null&&typeof e!="function")throw new TypeError(lMe);var r=function(){var i=arguments,n=e?e.apply(this,i):i[0],s=r.cache;if(s.has(n))return s.get(n);var o=t.apply(this,i);return r.cache=s.set(n,o)||s,o};return r.cache=new(JR.Cache||$X),r}JR.Cache=$X;ZX.exports=JR});var rZ=w((Vct,tZ)=>{var cMe=eZ(),uMe=500;function gMe(t){var e=cMe(t,function(i){return r.size===uMe&&r.clear(),i}),r=e.cache;return e}tZ.exports=gMe});var nZ=w((Xct,iZ)=>{var fMe=rZ(),hMe=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,pMe=/\\(\\)?/g,dMe=fMe(function(t){var e=[];return t.charCodeAt(0)===46&&e.push(""),t.replace(hMe,function(r,i,n,s){e.push(n?s.replace(pMe,"$1"):i||r)}),e});iZ.exports=dMe});var Rf=w((Zct,sZ)=>{var CMe=Os(),mMe=o0(),EMe=nZ(),IMe=nf();function yMe(t,e){return CMe(t)?t:mMe(t,e)?[t]:EMe(IMe(t))}sZ.exports=yMe});var lu=w(($ct,oZ)=>{var wMe=Id(),BMe=1/0;function bMe(t){if(typeof t=="string"||wMe(t))return t;var e=t+"";return e=="0"&&1/t==-BMe?"-0":e}oZ.exports=bMe});var IC=w((eut,aZ)=>{var QMe=Rf(),vMe=lu();function SMe(t,e){e=QMe(e,t);for(var r=0,i=e.length;t!=null&&r<i;)t=t[vMe(e[r++])];return r&&r==i?t:void 0}aZ.exports=SMe});var WR=w((tut,AZ)=>{var kMe=vl(),xMe=function(){try{var t=kMe(Object,"defineProperty");return t({},"",{}),t}catch(e){}}();AZ.exports=xMe});var Ff=w((rut,lZ)=>{var cZ=WR();function PMe(t,e,r){e=="__proto__"&&cZ?cZ(t,e,{configurable:!0,enumerable:!0,value:r,writable:!0}):t[e]=r}lZ.exports=PMe});var c0=w((iut,uZ)=>{var DMe=Ff(),RMe=xf(),FMe=Object.prototype,NMe=FMe.hasOwnProperty;function LMe(t,e,r){var i=t[e];(!(NMe.call(t,e)&&RMe(i,r))||r===void 0&&!(e in t))&&DMe(t,e,r)}uZ.exports=LMe});var yC=w((nut,gZ)=>{var TMe=9007199254740991,OMe=/^(?:0|[1-9]\d*)$/;function MMe(t,e){var r=typeof t;return e=e==null?TMe:e,!!e&&(r=="number"||r!="symbol"&&OMe.test(t))&&t>-1&&t%1==0&&t<e}gZ.exports=MMe});var zR=w((sut,fZ)=>{var UMe=c0(),KMe=Rf(),HMe=yC(),hZ=Rn(),jMe=lu();function GMe(t,e,r,i){if(!hZ(t))return t;e=KMe(e,t);for(var n=-1,s=e.length,o=s-1,a=t;a!=null&&++n<s;){var l=jMe(e[n]),c=r;if(l==="__proto__"||l==="constructor"||l==="prototype")return t;if(n!=o){var u=a[l];c=i?i(u,l,a):void 0,c===void 0&&(c=hZ(u)?u:HMe(e[n+1])?[]:{})}UMe(a,l,c),a=a[l]}return t}fZ.exports=GMe});var dZ=w((out,pZ)=>{var YMe=IC(),qMe=zR(),JMe=Rf();function WMe(t,e,r){for(var i=-1,n=e.length,s={};++i<n;){var o=e[i],a=YMe(t,o);r(a,o)&&qMe(s,JMe(o,t),a)}return s}pZ.exports=WMe});var mZ=w((aut,CZ)=>{function zMe(t,e){return t!=null&&e in Object(t)}CZ.exports=zMe});var IZ=w((Aut,EZ)=>{var _Me=Hc(),VMe=Zo(),XMe="[object Arguments]";function ZMe(t){return VMe(t)&&_Me(t)==XMe}EZ.exports=ZMe});var wC=w((lut,yZ)=>{var wZ=IZ(),$Me=Zo(),BZ=Object.prototype,e1e=BZ.hasOwnProperty,t1e=BZ.propertyIsEnumerable,r1e=wZ(function(){return arguments}())?wZ:function(t){return $Me(t)&&e1e.call(t,"callee")&&!t1e.call(t,"callee")};yZ.exports=r1e});var u0=w((cut,bZ)=>{var i1e=9007199254740991;function n1e(t){return typeof t=="number"&&t>-1&&t%1==0&&t<=i1e}bZ.exports=n1e});var _R=w((uut,QZ)=>{var s1e=Rf(),o1e=wC(),a1e=Os(),A1e=yC(),l1e=u0(),c1e=lu();function u1e(t,e,r){e=s1e(e,t);for(var i=-1,n=e.length,s=!1;++i<n;){var o=c1e(e[i]);if(!(s=t!=null&&r(t,o)))break;t=t[o]}return s||++i!=n?s:(n=t==null?0:t.length,!!n&&l1e(n)&&A1e(o,n)&&(a1e(t)||o1e(t)))}QZ.exports=u1e});var VR=w((gut,vZ)=>{var g1e=mZ(),f1e=_R();function h1e(t,e){return t!=null&&f1e(t,e,g1e)}vZ.exports=h1e});var kZ=w((fut,SZ)=>{var p1e=dZ(),d1e=VR();function C1e(t,e){return p1e(t,e,function(r,i){return d1e(t,i)})}SZ.exports=C1e});var g0=w((hut,xZ)=>{function m1e(t,e){for(var r=-1,i=e.length,n=t.length;++r<i;)t[n+r]=e[r];return t}xZ.exports=m1e});var FZ=w((put,PZ)=>{var DZ=Kc(),E1e=wC(),I1e=Os(),RZ=DZ?DZ.isConcatSpreadable:void 0;function y1e(t){return I1e(t)||E1e(t)||!!(RZ&&t&&t[RZ])}PZ.exports=y1e});var TZ=w((dut,NZ)=>{var w1e=g0(),B1e=FZ();function LZ(t,e,r,i,n){var s=-1,o=t.length;for(r||(r=B1e),n||(n=[]);++s<o;){var a=t[s];e>0&&r(a)?e>1?LZ(a,e-1,r,i,n):w1e(n,a):i||(n[n.length]=a)}return n}NZ.exports=LZ});var MZ=w((Cut,OZ)=>{var b1e=TZ();function Q1e(t){var e=t==null?0:t.length;return e?b1e(t,1):[]}OZ.exports=Q1e});var KZ=w((mut,UZ)=>{function v1e(t,e,r){switch(r.length){case 0:return t.call(e);case 1:return t.call(e,r[0]);case 2:return t.call(e,r[0],r[1]);case 3:return t.call(e,r[0],r[1],r[2])}return t.apply(e,r)}UZ.exports=v1e});var XR=w((Eut,HZ)=>{var S1e=KZ(),jZ=Math.max;function k1e(t,e,r){return e=jZ(e===void 0?t.length-1:e,0),function(){for(var i=arguments,n=-1,s=jZ(i.length-e,0),o=Array(s);++n<s;)o[n]=i[e+n];n=-1;for(var a=Array(e+1);++n<e;)a[n]=i[n];return a[e]=r(o),S1e(t,this,a)}}HZ.exports=k1e});var YZ=w((Iut,GZ)=>{function x1e(t){return function(){return t}}GZ.exports=x1e});var f0=w((yut,qZ)=>{function P1e(t){return t}qZ.exports=P1e});var zZ=w((wut,JZ)=>{var D1e=YZ(),WZ=WR(),R1e=f0(),F1e=WZ?function(t,e){return WZ(t,"toString",{configurable:!0,enumerable:!1,value:D1e(e),writable:!0})}:R1e;JZ.exports=F1e});var VZ=w((But,_Z)=>{var N1e=800,L1e=16,T1e=Date.now;function O1e(t){var e=0,r=0;return function(){var i=T1e(),n=L1e-(i-r);if(r=i,n>0){if(++e>=N1e)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}_Z.exports=O1e});var ZR=w((but,XZ)=>{var M1e=zZ(),U1e=VZ(),K1e=U1e(M1e);XZ.exports=K1e});var $Z=w((Qut,ZZ)=>{var H1e=MZ(),j1e=XR(),G1e=ZR();function Y1e(t){return G1e(j1e(t,void 0,H1e),t+"")}ZZ.exports=Y1e});var t$=w((vut,e$)=>{var q1e=kZ(),J1e=$Z(),W1e=J1e(function(t,e){return t==null?{}:q1e(t,e)});e$.exports=W1e});var h$=w((wft,u$)=>{"use strict";var AF;try{AF=Map}catch(t){}var lF;try{lF=Set}catch(t){}function g$(t,e,r){if(!t||typeof t!="object"||typeof t=="function")return t;if(t.nodeType&&"cloneNode"in t)return t.cloneNode(!0);if(t instanceof Date)return new Date(t.getTime());if(t instanceof RegExp)return new RegExp(t);if(Array.isArray(t))return t.map(f$);if(AF&&t instanceof AF)return new Map(Array.from(t.entries()));if(lF&&t instanceof lF)return new Set(Array.from(t.values()));if(t instanceof Object){e.push(t);var i=Object.create(t);r.push(i);for(var n in t){var s=e.findIndex(function(o){return o===t[n]});i[n]=s>-1?r[s]:g$(t[n],e,r)}return i}return t}function f$(t){return g$(t,[],[])}u$.exports=f$});var vC=w(cF=>{"use strict";Object.defineProperty(cF,"__esModule",{value:!0});cF.default=rUe;var iUe=Object.prototype.toString,nUe=Error.prototype.toString,sUe=RegExp.prototype.toString,oUe=typeof Symbol!="undefined"?Symbol.prototype.toString:()=>"",aUe=/^Symbol\((.*)\)(.*)$/;function AUe(t){return t!=+t?"NaN":t===0&&1/t<0?"-0":""+t}function p$(t,e=!1){if(t==null||t===!0||t===!1)return""+t;let r=typeof t;if(r==="number")return AUe(t);if(r==="string")return e?`"${t}"`:t;if(r==="function")return"[Function "+(t.name||"anonymous")+"]";if(r==="symbol")return oUe.call(t).replace(aUe,"Symbol($1)");let i=iUe.call(t).slice(8,-1);return i==="Date"?isNaN(t.getTime())?""+t:t.toISOString(t):i==="Error"||t instanceof Error?"["+nUe.call(t)+"]":i==="RegExp"?sUe.call(t):null}function rUe(t,e){let r=p$(t,e);return r!==null?r:JSON.stringify(t,function(i,n){let s=p$(this[i],e);return s!==null?s:n},2)}});var fA=w(Bi=>{"use strict";Object.defineProperty(Bi,"__esModule",{value:!0});Bi.default=Bi.array=Bi.object=Bi.boolean=Bi.date=Bi.number=Bi.string=Bi.mixed=void 0;var d$=lUe(vC());function lUe(t){return t&&t.__esModule?t:{default:t}}var C$={default:"${path} is invalid",required:"${path} is a required field",oneOf:"${path} must be one of the following values: ${values}",notOneOf:"${path} must not be one of the following values: ${values}",notType:({path:t,type:e,value:r,originalValue:i})=>{let n=i!=null&&i!==r,s=`${t} must be a \`${e}\` type, but the final value was: \`${(0,d$.default)(r,!0)}\``+(n?` (cast from the value \`${(0,d$.default)(i,!0)}\`).`:".");return r===null&&(s+='\n If "null" is intended as an empty value be sure to mark the schema as `.nullable()`'),s},defined:"${path} must be defined"};Bi.mixed=C$;var m$={length:"${path} must be exactly ${length} characters",min:"${path} must be at least ${min} characters",max:"${path} must be at most ${max} characters",matches:'${path} must match the following: "${regex}"',email:"${path} must be a valid email",url:"${path} must be a valid URL",uuid:"${path} must be a valid UUID",trim:"${path} must be a trimmed string",lowercase:"${path} must be a lowercase string",uppercase:"${path} must be a upper case string"};Bi.string=m$;var E$={min:"${path} must be greater than or equal to ${min}",max:"${path} must be less than or equal to ${max}",lessThan:"${path} must be less than ${less}",moreThan:"${path} must be greater than ${more}",positive:"${path} must be a positive number",negative:"${path} must be a negative number",integer:"${path} must be an integer"};Bi.number=E$;var I$={min:"${path} field must be later than ${min}",max:"${path} field must be at earlier than ${max}"};Bi.date=I$;var y$={isValue:"${path} field must be ${value}"};Bi.boolean=y$;var w$={noUnknown:"${path} field has unspecified keys: ${unknown}"};Bi.object=w$;var B$={min:"${path} field must have at least ${min} items",max:"${path} field must have less than or equal to ${max} items",length:"${path} must be have ${length} items"};Bi.array=B$;var cUe=Object.assign(Object.create(null),{mixed:C$,string:m$,number:E$,date:I$,object:w$,array:B$,boolean:y$});Bi.default=cUe});var Q$=w((Qft,b$)=>{var uUe=Object.prototype,gUe=uUe.hasOwnProperty;function fUe(t,e){return t!=null&&gUe.call(t,e)}b$.exports=fUe});var SC=w((vft,v$)=>{var hUe=Q$(),pUe=_R();function dUe(t,e){return t!=null&&pUe(t,e,hUe)}v$.exports=dUe});var Lf=w(C0=>{"use strict";Object.defineProperty(C0,"__esModule",{value:!0});C0.default=void 0;var CUe=t=>t&&t.__isYupSchema__;C0.default=CUe});var x$=w(m0=>{"use strict";Object.defineProperty(m0,"__esModule",{value:!0});m0.default=void 0;var mUe=S$(SC()),EUe=S$(Lf());function S$(t){return t&&t.__esModule?t:{default:t}}var k$=class{constructor(e,r){if(this.refs=e,this.refs=e,typeof r=="function"){this.fn=r;return}if(!(0,mUe.default)(r,"is"))throw new TypeError("`is:` is required for `when()` conditions");if(!r.then&&!r.otherwise)throw new TypeError("either `then:` or `otherwise:` is required for `when()` conditions");let{is:i,then:n,otherwise:s}=r,o=typeof i=="function"?i:(...a)=>a.every(l=>l===i);this.fn=function(...a){let l=a.pop(),c=a.pop(),u=o(...a)?n:s;if(!!u)return typeof u=="function"?u(c):c.concat(u.resolve(l))}}resolve(e,r){let i=this.refs.map(s=>s.getValue(r==null?void 0:r.value,r==null?void 0:r.parent,r==null?void 0:r.context)),n=this.fn.apply(e,i.concat(e,r));if(n===void 0||n===e)return e;if(!(0,EUe.default)(n))throw new TypeError("conditions must return a schema object");return n.resolve(r)}},IUe=k$;m0.default=IUe});var gF=w(uF=>{"use strict";Object.defineProperty(uF,"__esModule",{value:!0});uF.default=yUe;function yUe(t){return t==null?[]:[].concat(t)}});var cu=w(E0=>{"use strict";Object.defineProperty(E0,"__esModule",{value:!0});E0.default=void 0;var wUe=P$(vC()),BUe=P$(gF());function P$(t){return t&&t.__esModule?t:{default:t}}function fF(){return fF=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},fF.apply(this,arguments)}var bUe=/\$\{\s*(\w+)\s*\}/g,kC=class extends Error{static formatError(e,r){let i=r.label||r.path||"this";return i!==r.path&&(r=fF({},r,{path:i})),typeof e=="string"?e.replace(bUe,(n,s)=>(0,wUe.default)(r[s])):typeof e=="function"?e(r):e}static isError(e){return e&&e.name==="ValidationError"}constructor(e,r,i,n){super();this.name="ValidationError",this.value=r,this.path=i,this.type=n,this.errors=[],this.inner=[],(0,BUe.default)(e).forEach(s=>{kC.isError(s)?(this.errors.push(...s.errors),this.inner=this.inner.concat(s.inner.length?s.inner:s)):this.errors.push(s)}),this.message=this.errors.length>1?`${this.errors.length} errors occurred`:this.errors[0],Error.captureStackTrace&&Error.captureStackTrace(this,kC)}};E0.default=kC});var I0=w(hF=>{"use strict";Object.defineProperty(hF,"__esModule",{value:!0});hF.default=QUe;var pF=vUe(cu());function vUe(t){return t&&t.__esModule?t:{default:t}}var SUe=t=>{let e=!1;return(...r)=>{e||(e=!0,t(...r))}};function QUe(t,e){let{endEarly:r,tests:i,args:n,value:s,errors:o,sort:a,path:l}=t,c=SUe(e),u=i.length,g=[];if(o=o||[],!u)return o.length?c(new pF.default(o,s,l)):c(null,s);for(let f=0;f<i.length;f++)i[f](n,function(m){if(m){if(!pF.default.isError(m))return c(m,s);if(r)return m.value=s,c(m,s);g.push(m)}if(--u<=0){if(g.length&&(a&&g.sort(a),o.length&&g.push(...o),o=g),o.length){c(new pF.default(o,s,l),s);return}c(null,s)}})}});var R$=w((Rft,D$)=>{function kUe(t){return function(e,r,i){for(var n=-1,s=Object(e),o=i(e),a=o.length;a--;){var l=o[t?a:++n];if(r(s[l],l,s)===!1)break}return e}}D$.exports=kUe});var dF=w((Fft,F$)=>{var xUe=R$(),PUe=xUe();F$.exports=PUe});var L$=w((Nft,N$)=>{function DUe(t,e){for(var r=-1,i=Array(t);++r<t;)i[r]=e(r);return i}N$.exports=DUe});var O$=w((Lft,T$)=>{function RUe(){return!1}T$.exports=RUe});var PC=w((xC,Tf)=>{var FUe=Rs(),NUe=O$(),M$=typeof xC=="object"&&xC&&!xC.nodeType&&xC,U$=M$&&typeof Tf=="object"&&Tf&&!Tf.nodeType&&Tf,LUe=U$&&U$.exports===M$,K$=LUe?FUe.Buffer:void 0,TUe=K$?K$.isBuffer:void 0,OUe=TUe||NUe;Tf.exports=OUe});var j$=w((Tft,H$)=>{var MUe=Hc(),UUe=u0(),KUe=Zo(),HUe="[object Arguments]",jUe="[object Array]",GUe="[object Boolean]",YUe="[object Date]",qUe="[object Error]",JUe="[object Function]",WUe="[object Map]",zUe="[object Number]",_Ue="[object Object]",VUe="[object RegExp]",XUe="[object Set]",ZUe="[object String]",$Ue="[object WeakMap]",eKe="[object ArrayBuffer]",tKe="[object DataView]",rKe="[object Float32Array]",iKe="[object Float64Array]",nKe="[object Int8Array]",sKe="[object Int16Array]",oKe="[object Int32Array]",aKe="[object Uint8Array]",AKe="[object Uint8ClampedArray]",lKe="[object Uint16Array]",cKe="[object Uint32Array]",wr={};wr[rKe]=wr[iKe]=wr[nKe]=wr[sKe]=wr[oKe]=wr[aKe]=wr[AKe]=wr[lKe]=wr[cKe]=!0;wr[HUe]=wr[jUe]=wr[eKe]=wr[GUe]=wr[tKe]=wr[YUe]=wr[qUe]=wr[JUe]=wr[WUe]=wr[zUe]=wr[_Ue]=wr[VUe]=wr[XUe]=wr[ZUe]=wr[$Ue]=!1;function uKe(t){return KUe(t)&&UUe(t.length)&&!!wr[MUe(t)]}H$.exports=uKe});var y0=w((Oft,G$)=>{function gKe(t){return function(e){return t(e)}}G$.exports=gKe});var w0=w((DC,Of)=>{var fKe=ux(),Y$=typeof DC=="object"&&DC&&!DC.nodeType&&DC,RC=Y$&&typeof Of=="object"&&Of&&!Of.nodeType&&Of,hKe=RC&&RC.exports===Y$,CF=hKe&&fKe.process,pKe=function(){try{var t=RC&&RC.require&&RC.require("util").types;return t||CF&&CF.binding&&CF.binding("util")}catch(e){}}();Of.exports=pKe});var B0=w((Mft,q$)=>{var dKe=j$(),CKe=y0(),J$=w0(),W$=J$&&J$.isTypedArray,mKe=W$?CKe(W$):dKe;q$.exports=mKe});var mF=w((Uft,z$)=>{var EKe=L$(),IKe=wC(),yKe=Os(),wKe=PC(),BKe=yC(),bKe=B0(),QKe=Object.prototype,vKe=QKe.hasOwnProperty;function SKe(t,e){var r=yKe(t),i=!r&&IKe(t),n=!r&&!i&&wKe(t),s=!r&&!i&&!n&&bKe(t),o=r||i||n||s,a=o?EKe(t.length,String):[],l=a.length;for(var c in t)(e||vKe.call(t,c))&&!(o&&(c=="length"||n&&(c=="offset"||c=="parent")||s&&(c=="buffer"||c=="byteLength"||c=="byteOffset")||BKe(c,l)))&&a.push(c);return a}z$.exports=SKe});var b0=w((Kft,_$)=>{var kKe=Object.prototype;function xKe(t){var e=t&&t.constructor,r=typeof e=="function"&&e.prototype||kKe;return t===r}_$.exports=xKe});var EF=w((Hft,V$)=>{function PKe(t,e){return function(r){return t(e(r))}}V$.exports=PKe});var Z$=w((jft,X$)=>{var DKe=EF(),RKe=DKe(Object.keys,Object);X$.exports=RKe});var eee=w((Gft,$$)=>{var FKe=b0(),NKe=Z$(),LKe=Object.prototype,TKe=LKe.hasOwnProperty;function OKe(t){if(!FKe(t))return NKe(t);var e=[];for(var r in Object(t))TKe.call(t,r)&&r!="constructor"&&e.push(r);return e}$$.exports=OKe});var FC=w((Yft,tee)=>{var MKe=a0(),UKe=u0();function KKe(t){return t!=null&&UKe(t.length)&&!MKe(t)}tee.exports=KKe});var Mf=w((qft,ree)=>{var HKe=mF(),jKe=eee(),GKe=FC();function YKe(t){return GKe(t)?HKe(t):jKe(t)}ree.exports=YKe});var IF=w((Jft,iee)=>{var qKe=dF(),JKe=Mf();function WKe(t,e){return t&&qKe(t,e,JKe)}iee.exports=WKe});var see=w((Wft,nee)=>{var zKe=mC();function _Ke(){this.__data__=new zKe,this.size=0}nee.exports=_Ke});var aee=w((zft,oee)=>{function VKe(t){var e=this.__data__,r=e.delete(t);return this.size=e.size,r}oee.exports=VKe});var lee=w((_ft,Aee)=>{function XKe(t){return this.__data__.get(t)}Aee.exports=XKe});var uee=w((Vft,cee)=>{function ZKe(t){return this.__data__.has(t)}cee.exports=ZKe});var fee=w((Xft,gee)=>{var $Ke=mC(),e2e=A0(),t2e=l0(),r2e=200;function i2e(t,e){var r=this.__data__;if(r instanceof $Ke){var i=r.__data__;if(!e2e||i.length<r2e-1)return i.push([t,e]),this.size=++r.size,this;r=this.__data__=new t2e(i)}return r.set(t,e),this.size=r.size,this}gee.exports=i2e});var NC=w((Zft,hee)=>{var n2e=mC(),s2e=see(),o2e=aee(),a2e=lee(),A2e=uee(),l2e=fee();function Uf(t){var e=this.__data__=new n2e(t);this.size=e.size}Uf.prototype.clear=s2e;Uf.prototype.delete=o2e;Uf.prototype.get=a2e;Uf.prototype.has=A2e;Uf.prototype.set=l2e;hee.exports=Uf});var dee=w(($ft,pee)=>{var c2e="__lodash_hash_undefined__";function u2e(t){return this.__data__.set(t,c2e),this}pee.exports=u2e});var mee=w((eht,Cee)=>{function g2e(t){return this.__data__.has(t)}Cee.exports=g2e});var Iee=w((tht,Eee)=>{var f2e=l0(),h2e=dee(),p2e=mee();function Q0(t){var e=-1,r=t==null?0:t.length;for(this.__data__=new f2e;++e<r;)this.add(t[e])}Q0.prototype.add=Q0.prototype.push=h2e;Q0.prototype.has=p2e;Eee.exports=Q0});var wee=w((rht,yee)=>{function d2e(t,e){for(var r=-1,i=t==null?0:t.length;++r<i;)if(e(t[r],r,t))return!0;return!1}yee.exports=d2e});var bee=w((iht,Bee)=>{function C2e(t,e){return t.has(e)}Bee.exports=C2e});var yF=w((nht,Qee)=>{var m2e=Iee(),E2e=wee(),I2e=bee(),y2e=1,w2e=2;function B2e(t,e,r,i,n,s){var o=r&y2e,a=t.length,l=e.length;if(a!=l&&!(o&&l>a))return!1;var c=s.get(t),u=s.get(e);if(c&&u)return c==e&&u==t;var g=-1,f=!0,h=r&w2e?new m2e:void 0;for(s.set(t,e),s.set(e,t);++g<a;){var p=t[g],m=e[g];if(i)var y=o?i(m,p,g,e,t,s):i(p,m,g,t,e,s);if(y!==void 0){if(y)continue;f=!1;break}if(h){if(!E2e(e,function(Q,S){if(!I2e(h,S)&&(p===Q||n(p,Q,r,i,s)))return h.push(S)})){f=!1;break}}else if(!(p===m||n(p,m,r,i,s))){f=!1;break}}return s.delete(t),s.delete(e),f}Qee.exports=B2e});var wF=w((sht,vee)=>{var b2e=Rs(),Q2e=b2e.Uint8Array;vee.exports=Q2e});var kee=w((oht,See)=>{function v2e(t){var e=-1,r=Array(t.size);return t.forEach(function(i,n){r[++e]=[n,i]}),r}See.exports=v2e});var Pee=w((aht,xee)=>{function S2e(t){var e=-1,r=Array(t.size);return t.forEach(function(i){r[++e]=i}),r}xee.exports=S2e});var Lee=w((Aht,Dee)=>{var Ree=Kc(),Fee=wF(),k2e=xf(),x2e=yF(),P2e=kee(),D2e=Pee(),R2e=1,F2e=2,N2e="[object Boolean]",L2e="[object Date]",T2e="[object Error]",O2e="[object Map]",M2e="[object Number]",U2e="[object RegExp]",K2e="[object Set]",H2e="[object String]",j2e="[object Symbol]",G2e="[object ArrayBuffer]",Y2e="[object DataView]",Nee=Ree?Ree.prototype:void 0,BF=Nee?Nee.valueOf:void 0;function q2e(t,e,r,i,n,s,o){switch(r){case Y2e:if(t.byteLength!=e.byteLength||t.byteOffset!=e.byteOffset)return!1;t=t.buffer,e=e.buffer;case G2e:return!(t.byteLength!=e.byteLength||!s(new Fee(t),new Fee(e)));case N2e:case L2e:case M2e:return k2e(+t,+e);case T2e:return t.name==e.name&&t.message==e.message;case U2e:case H2e:return t==e+"";case O2e:var a=P2e;case K2e:var l=i&R2e;if(a||(a=D2e),t.size!=e.size&&!l)return!1;var c=o.get(t);if(c)return c==e;i|=F2e,o.set(t,e);var u=x2e(a(t),a(e),i,n,s,o);return o.delete(t),u;case j2e:if(BF)return BF.call(t)==BF.call(e)}return!1}Dee.exports=q2e});var bF=w((lht,Tee)=>{var J2e=g0(),W2e=Os();function z2e(t,e,r){var i=e(t);return W2e(t)?i:J2e(i,r(t))}Tee.exports=z2e});var Mee=w((cht,Oee)=>{function _2e(t,e){for(var r=-1,i=t==null?0:t.length,n=0,s=[];++r<i;){var o=t[r];e(o,r,t)&&(s[n++]=o)}return s}Oee.exports=_2e});var QF=w((uht,Uee)=>{function V2e(){return[]}Uee.exports=V2e});var v0=w((ght,Kee)=>{var X2e=Mee(),Z2e=QF(),$2e=Object.prototype,eHe=$2e.propertyIsEnumerable,Hee=Object.getOwnPropertySymbols,tHe=Hee?function(t){return t==null?[]:(t=Object(t),X2e(Hee(t),function(e){return eHe.call(t,e)}))}:Z2e;Kee.exports=tHe});var vF=w((fht,jee)=>{var rHe=bF(),iHe=v0(),nHe=Mf();function sHe(t){return rHe(t,nHe,iHe)}jee.exports=sHe});var qee=w((hht,Gee)=>{var Yee=vF(),oHe=1,aHe=Object.prototype,AHe=aHe.hasOwnProperty;function lHe(t,e,r,i,n,s){var o=r&oHe,a=Yee(t),l=a.length,c=Yee(e),u=c.length;if(l!=u&&!o)return!1;for(var g=l;g--;){var f=a[g];if(!(o?f in e:AHe.call(e,f)))return!1}var h=s.get(t),p=s.get(e);if(h&&p)return h==e&&p==t;var m=!0;s.set(t,e),s.set(e,t);for(var y=o;++g<l;){f=a[g];var Q=t[f],S=e[f];if(i)var x=o?i(S,Q,f,e,t,s):i(Q,S,f,t,e,s);if(!(x===void 0?Q===S||n(Q,S,r,i,s):x)){m=!1;break}y||(y=f=="constructor")}if(m&&!y){var M=t.constructor,Y=e.constructor;M!=Y&&"constructor"in t&&"constructor"in e&&!(typeof M=="function"&&M instanceof M&&typeof Y=="function"&&Y instanceof Y)&&(m=!1)}return s.delete(t),s.delete(e),m}Gee.exports=lHe});var Wee=w((pht,Jee)=>{var cHe=vl(),uHe=Rs(),gHe=cHe(uHe,"DataView");Jee.exports=gHe});var _ee=w((dht,zee)=>{var fHe=vl(),hHe=Rs(),pHe=fHe(hHe,"Promise");zee.exports=pHe});var Xee=w((Cht,Vee)=>{var dHe=vl(),CHe=Rs(),mHe=dHe(CHe,"Set");Vee.exports=mHe});var $ee=w((mht,Zee)=>{var EHe=vl(),IHe=Rs(),yHe=EHe(IHe,"WeakMap");Zee.exports=yHe});var LC=w((Eht,ete)=>{var SF=Wee(),kF=A0(),xF=_ee(),PF=Xee(),DF=$ee(),tte=Hc(),Kf=qR(),rte="[object Map]",wHe="[object Object]",ite="[object Promise]",nte="[object Set]",ste="[object WeakMap]",ote="[object DataView]",BHe=Kf(SF),bHe=Kf(kF),QHe=Kf(xF),vHe=Kf(PF),SHe=Kf(DF),uu=tte;(SF&&uu(new SF(new ArrayBuffer(1)))!=ote||kF&&uu(new kF)!=rte||xF&&uu(xF.resolve())!=ite||PF&&uu(new PF)!=nte||DF&&uu(new DF)!=ste)&&(uu=function(t){var e=tte(t),r=e==wHe?t.constructor:void 0,i=r?Kf(r):"";if(i)switch(i){case BHe:return ote;case bHe:return rte;case QHe:return ite;case vHe:return nte;case SHe:return ste}return e});ete.exports=uu});var hte=w((Iht,ate)=>{var RF=NC(),kHe=yF(),xHe=Lee(),PHe=qee(),Ate=LC(),lte=Os(),cte=PC(),DHe=B0(),RHe=1,ute="[object Arguments]",gte="[object Array]",S0="[object Object]",FHe=Object.prototype,fte=FHe.hasOwnProperty;function NHe(t,e,r,i,n,s){var o=lte(t),a=lte(e),l=o?gte:Ate(t),c=a?gte:Ate(e);l=l==ute?S0:l,c=c==ute?S0:c;var u=l==S0,g=c==S0,f=l==c;if(f&&cte(t)){if(!cte(e))return!1;o=!0,u=!1}if(f&&!u)return s||(s=new RF),o||DHe(t)?kHe(t,e,r,i,n,s):xHe(t,e,l,r,i,n,s);if(!(r&RHe)){var h=u&&fte.call(t,"__wrapped__"),p=g&&fte.call(e,"__wrapped__");if(h||p){var m=h?t.value():t,y=p?e.value():e;return s||(s=new RF),n(m,y,r,i,s)}}return f?(s||(s=new RF),PHe(t,e,r,i,n,s)):!1}ate.exports=NHe});var FF=w((yht,pte)=>{var LHe=hte(),dte=Zo();function Cte(t,e,r,i,n){return t===e?!0:t==null||e==null||!dte(t)&&!dte(e)?t!==t&&e!==e:LHe(t,e,r,i,Cte,n)}pte.exports=Cte});var Ete=w((wht,mte)=>{var THe=NC(),OHe=FF(),MHe=1,UHe=2;function KHe(t,e,r,i){var n=r.length,s=n,o=!i;if(t==null)return!s;for(t=Object(t);n--;){var a=r[n];if(o&&a[2]?a[1]!==t[a[0]]:!(a[0]in t))return!1}for(;++n<s;){a=r[n];var l=a[0],c=t[l],u=a[1];if(o&&a[2]){if(c===void 0&&!(l in t))return!1}else{var g=new THe;if(i)var f=i(c,u,l,t,e,g);if(!(f===void 0?OHe(u,c,MHe|UHe,i,g):f))return!1}}return!0}mte.exports=KHe});var NF=w((Bht,Ite)=>{var HHe=Rn();function jHe(t){return t===t&&!HHe(t)}Ite.exports=jHe});var wte=w((bht,yte)=>{var GHe=NF(),YHe=Mf();function qHe(t){for(var e=YHe(t),r=e.length;r--;){var i=e[r],n=t[i];e[r]=[i,n,GHe(n)]}return e}yte.exports=qHe});var LF=w((Qht,Bte)=>{function JHe(t,e){return function(r){return r==null?!1:r[t]===e&&(e!==void 0||t in Object(r))}}Bte.exports=JHe});var Qte=w((vht,bte)=>{var WHe=Ete(),zHe=wte(),_He=LF();function VHe(t){var e=zHe(t);return e.length==1&&e[0][2]?_He(e[0][0],e[0][1]):function(r){return r===t||WHe(r,t,e)}}bte.exports=VHe});var k0=w((Sht,vte)=>{var XHe=IC();function ZHe(t,e,r){var i=t==null?void 0:XHe(t,e);return i===void 0?r:i}vte.exports=ZHe});var kte=w((kht,Ste)=>{var $He=FF(),eje=k0(),tje=VR(),rje=o0(),ije=NF(),nje=LF(),sje=lu(),oje=1,aje=2;function Aje(t,e){return rje(t)&&ije(e)?nje(sje(t),e):function(r){var i=eje(r,t);return i===void 0&&i===e?tje(r,t):$He(e,i,oje|aje)}}Ste.exports=Aje});var Pte=w((xht,xte)=>{function lje(t){return function(e){return e==null?void 0:e[t]}}xte.exports=lje});var Rte=w((Pht,Dte)=>{var cje=IC();function uje(t){return function(e){return cje(e,t)}}Dte.exports=uje});var Nte=w((Dht,Fte)=>{var gje=Pte(),fje=Rte(),hje=o0(),pje=lu();function dje(t){return hje(t)?gje(pje(t)):fje(t)}Fte.exports=dje});var TF=w((Rht,Lte)=>{var Cje=Qte(),mje=kte(),Eje=f0(),Ije=Os(),yje=Nte();function wje(t){return typeof t=="function"?t:t==null?Eje:typeof t=="object"?Ije(t)?mje(t[0],t[1]):Cje(t):yje(t)}Lte.exports=wje});var OF=w((Fht,Tte)=>{var Bje=Ff(),bje=IF(),Qje=TF();function vje(t,e){var r={};return e=Qje(e,3),bje(t,function(i,n,s){Bje(r,n,e(i,n,s))}),r}Tte.exports=vje});var TC=w((Nht,Ote)=>{"use strict";function gu(t){this._maxSize=t,this.clear()}gu.prototype.clear=function(){this._size=0,this._values=Object.create(null)};gu.prototype.get=function(t){return this._values[t]};gu.prototype.set=function(t,e){return this._size>=this._maxSize&&this.clear(),t in this._values||this._size++,this._values[t]=e};var Sje=/[^.^\]^[]+|(?=\[\]|\.\.)/g,Mte=/^\d+$/,kje=/^\d/,xje=/[~`!#$%\^&*+=\-\[\]\\';,/{}|\\":<>\?]/g,Pje=/^\s*(['"]?)(.*?)(\1)\s*$/,MF=512,Ute=new gu(MF),Kte=new gu(MF),Hte=new gu(MF);Ote.exports={Cache:gu,split:KF,normalizePath:UF,setter:function(t){var e=UF(t);return Kte.get(t)||Kte.set(t,function(i,n){for(var s=0,o=e.length,a=i;s<o-1;){var l=e[s];if(l==="__proto__"||l==="constructor"||l==="prototype")return i;a=a[e[s++]]}a[e[s]]=n})},getter:function(t,e){var r=UF(t);return Hte.get(t)||Hte.set(t,function(n){for(var s=0,o=r.length;s<o;)if(n!=null||!e)n=n[r[s++]];else return;return n})},join:function(t){return t.reduce(function(e,r){return e+(HF(r)||Mte.test(r)?"["+r+"]":(e?".":"")+r)},"")},forEach:function(t,e,r){Dje(Array.isArray(t)?t:KF(t),e,r)}};function UF(t){return Ute.get(t)||Ute.set(t,KF(t).map(function(e){return e.replace(Pje,"$2")}))}function KF(t){return t.match(Sje)}function Dje(t,e,r){var i=t.length,n,s,o,a;for(s=0;s<i;s++)n=t[s],n&&(Rje(n)&&(n='"'+n+'"'),a=HF(n),o=!a&&/^\d+$/.test(n),e.call(r,n,a,o,s,t))}function HF(t){return typeof t=="string"&&t&&["'",'"'].indexOf(t.charAt(0))!==-1}function Fje(t){return t.match(kje)&&!t.match(Mte)}function Nje(t){return xje.test(t)}function Rje(t){return!HF(t)&&(Fje(t)||Nje(t))}});var fu=w(OC=>{"use strict";Object.defineProperty(OC,"__esModule",{value:!0});OC.create=Lje;OC.default=void 0;var Tje=TC(),x0={context:"$",value:"."};function Lje(t,e){return new P0(t,e)}var P0=class{constructor(e,r={}){if(typeof e!="string")throw new TypeError("ref must be a string, got: "+e);if(this.key=e.trim(),e==="")throw new TypeError("ref must be a non-empty string");this.isContext=this.key[0]===x0.context,this.isValue=this.key[0]===x0.value,this.isSibling=!this.isContext&&!this.isValue;let i=this.isContext?x0.context:this.isValue?x0.value:"";this.path=this.key.slice(i.length),this.getter=this.path&&(0,Tje.getter)(this.path,!0),this.map=r.map}getValue(e,r,i){let n=this.isContext?i:this.isValue?e:r;return this.getter&&(n=this.getter(n||{})),this.map&&(n=this.map(n)),n}cast(e,r){return this.getValue(e,r==null?void 0:r.parent,r==null?void 0:r.context)}resolve(){return this}describe(){return{type:"ref",key:this.key}}toString(){return`Ref(${this.key})`}static isRef(e){return e&&e.__isYupRef}};OC.default=P0;P0.prototype.__isYupRef=!0});var jte=w(jF=>{"use strict";Object.defineProperty(jF,"__esModule",{value:!0});jF.default=Oje;var Mje=GF(OF()),D0=GF(cu()),Uje=GF(fu());function GF(t){return t&&t.__esModule?t:{default:t}}function R0(){return R0=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},R0.apply(this,arguments)}function Kje(t,e){if(t==null)return{};var r={},i=Object.keys(t),n,s;for(s=0;s<i.length;s++)n=i[s],!(e.indexOf(n)>=0)&&(r[n]=t[n]);return r}function Oje(t){function e(r,i){let{value:n,path:s="",label:o,options:a,originalValue:l,sync:c}=r,u=Kje(r,["value","path","label","options","originalValue","sync"]),{name:g,test:f,params:h,message:p}=t,{parent:m,context:y}=a;function Q(U){return Uje.default.isRef(U)?U.getValue(n,m,y):U}function S(U={}){let J=(0,Mje.default)(R0({value:n,originalValue:l,label:o,path:U.path||s},h,U.params),Q),W=new D0.default(D0.default.formatError(U.message||p,J),n,J.path,U.type||g);return W.params=J,W}let x=R0({path:s,parent:m,type:g,createError:S,resolve:Q,options:a,originalValue:l},u);if(!c){try{Promise.resolve(f.call(x,n,x)).then(U=>{D0.default.isError(U)?i(U):U?i(null,U):i(S())})}catch(U){i(U)}return}let M;try{var Y;if(M=f.call(x,n,x),typeof((Y=M)==null?void 0:Y.then)=="function")throw new Error(`Validation test of type: "${x.type}" returned a Promise during a synchronous validate. This test will finish after the validate call has returned`)}catch(U){i(U);return}D0.default.isError(M)?i(M):M?i(null,M):i(S())}return e.OPTIONS=t,e}});var YF=w(MC=>{"use strict";Object.defineProperty(MC,"__esModule",{value:!0});MC.getIn=Gte;MC.default=void 0;var Hje=TC(),jje=t=>t.substr(0,t.length-1).substr(1);function Gte(t,e,r,i=r){let n,s,o;return e?((0,Hje.forEach)(e,(a,l,c)=>{let u=l?jje(a):a;if(t=t.resolve({context:i,parent:n,value:r}),t.innerType){let g=c?parseInt(u,10):0;if(r&&g>=r.length)throw new Error(`Yup.reach cannot resolve an array item at index: ${a}, in the path: ${e}. because there is no value at that index. `);n=r,r=r&&r[g],t=t.innerType}if(!c){if(!t.fields||!t.fields[u])throw new Error(`The schema does not contain the path: ${e}. (failed at: ${o} which is a type: "${t._type}")`);n=r,r=r&&r[u],t=t.fields[u]}s=u,o=l?"["+a+"]":"."+a}),{schema:t,parent:n,parentPath:s}):{parent:n,parentPath:e,schema:t}}var Gje=(t,e,r,i)=>Gte(t,e,r,i).schema,Yje=Gje;MC.default=Yje});var qte=w(F0=>{"use strict";Object.defineProperty(F0,"__esModule",{value:!0});F0.default=void 0;var Yte=qje(fu());function qje(t){return t&&t.__esModule?t:{default:t}}var N0=class{constructor(){this.list=new Set,this.refs=new Map}get size(){return this.list.size+this.refs.size}describe(){let e=[];for(let r of this.list)e.push(r);for(let[,r]of this.refs)e.push(r.describe());return e}toArray(){return Array.from(this.list).concat(Array.from(this.refs.values()))}add(e){Yte.default.isRef(e)?this.refs.set(e.key,e):this.list.add(e)}delete(e){Yte.default.isRef(e)?this.refs.delete(e.key):this.list.delete(e)}has(e,r){if(this.list.has(e))return!0;let i,n=this.refs.values();for(;i=n.next(),!i.done;)if(r(i.value)===e)return!0;return!1}clone(){let e=new N0;return e.list=new Set(this.list),e.refs=new Map(this.refs),e}merge(e,r){let i=this.clone();return e.list.forEach(n=>i.add(n)),e.refs.forEach(n=>i.add(n)),r.list.forEach(n=>i.delete(n)),r.refs.forEach(n=>i.delete(n)),i}};F0.default=N0});var pA=w(L0=>{"use strict";Object.defineProperty(L0,"__esModule",{value:!0});L0.default=void 0;var Jte=hA(h$()),Hf=fA(),Jje=hA(x$()),Wte=hA(I0()),T0=hA(jte()),zte=hA(vC()),Wje=hA(fu()),zje=YF(),_je=hA(gF()),_te=hA(cu()),Vte=hA(qte());function hA(t){return t&&t.__esModule?t:{default:t}}function Ys(){return Ys=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},Ys.apply(this,arguments)}var Aa=class{constructor(e){this.deps=[],this.conditions=[],this._whitelist=new Vte.default,this._blacklist=new Vte.default,this.exclusiveTests=Object.create(null),this.tests=[],this.transforms=[],this.withMutation(()=>{this.typeError(Hf.mixed.notType)}),this.type=(e==null?void 0:e.type)||"mixed",this.spec=Ys({strip:!1,strict:!1,abortEarly:!0,recursive:!0,nullable:!1,presence:"optional"},e==null?void 0:e.spec)}get _type(){return this.type}_typeCheck(e){return!0}clone(e){if(this._mutate)return e&&Object.assign(this.spec,e),this;let r=Object.create(Object.getPrototypeOf(this));return r.type=this.type,r._typeError=this._typeError,r._whitelistError=this._whitelistError,r._blacklistError=this._blacklistError,r._whitelist=this._whitelist.clone(),r._blacklist=this._blacklist.clone(),r.exclusiveTests=Ys({},this.exclusiveTests),r.deps=[...this.deps],r.conditions=[...this.conditions],r.tests=[...this.tests],r.transforms=[...this.transforms],r.spec=(0,Jte.default)(Ys({},this.spec,e)),r}label(e){var r=this.clone();return r.spec.label=e,r}meta(...e){if(e.length===0)return this.spec.meta;let r=this.clone();return r.spec.meta=Object.assign(r.spec.meta||{},e[0]),r}withMutation(e){let r=this._mutate;this._mutate=!0;let i=e(this);return this._mutate=r,i}concat(e){if(!e||e===this)return this;if(e.type!==this.type&&this.type!=="mixed")throw new TypeError(`You cannot \`concat()\` schema's of different types: ${this.type} and ${e.type}`);let r=this,i=e.clone(),n=Ys({},r.spec,i.spec);return i.spec=n,i._typeError||(i._typeError=r._typeError),i._whitelistError||(i._whitelistError=r._whitelistError),i._blacklistError||(i._blacklistError=r._blacklistError),i._whitelist=r._whitelist.merge(e._whitelist,e._blacklist),i._blacklist=r._blacklist.merge(e._blacklist,e._whitelist),i.tests=r.tests,i.exclusiveTests=r.exclusiveTests,i.withMutation(s=>{e.tests.forEach(o=>{s.test(o.OPTIONS)})}),i}isType(e){return this.spec.nullable&&e===null?!0:this._typeCheck(e)}resolve(e){let r=this;if(r.conditions.length){let i=r.conditions;r=r.clone(),r.conditions=[],r=i.reduce((n,s)=>s.resolve(n,e),r),r=r.resolve(e)}return r}cast(e,r={}){let i=this.resolve(Ys({value:e},r)),n=i._cast(e,r);if(e!==void 0&&r.assert!==!1&&i.isType(n)!==!0){let s=(0,zte.default)(e),o=(0,zte.default)(n);throw new TypeError(`The value of ${r.path||"field"} could not be cast to a value that satisfies the schema type: "${i._type}". 
+
+attempted value: ${s} 
+`+(o!==s?`result of cast: ${o}`:""))}return n}_cast(e,r){let i=e===void 0?e:this.transforms.reduce((n,s)=>s.call(this,n,e,this),e);return i===void 0&&(i=this.getDefault()),i}_validate(e,r={},i){let{sync:n,path:s,from:o=[],originalValue:a=e,strict:l=this.spec.strict,abortEarly:c=this.spec.abortEarly}=r,u=e;l||(u=this._cast(u,Ys({assert:!1},r)));let g={value:u,path:s,options:r,originalValue:a,schema:this,label:this.spec.label,sync:n,from:o},f=[];this._typeError&&f.push(this._typeError),this._whitelistError&&f.push(this._whitelistError),this._blacklistError&&f.push(this._blacklistError),(0,Wte.default)({args:g,value:u,path:s,sync:n,tests:f,endEarly:c},h=>{if(h)return void i(h,u);(0,Wte.default)({tests:this.tests,args:g,path:s,sync:n,value:u,endEarly:c},i)})}validate(e,r,i){let n=this.resolve(Ys({},r,{value:e}));return typeof i=="function"?n._validate(e,r,i):new Promise((s,o)=>n._validate(e,r,(a,l)=>{a?o(a):s(l)}))}validateSync(e,r){let i=this.resolve(Ys({},r,{value:e})),n;return i._validate(e,Ys({},r,{sync:!0}),(s,o)=>{if(s)throw s;n=o}),n}isValid(e,r){return this.validate(e,r).then(()=>!0,i=>{if(_te.default.isError(i))return!1;throw i})}isValidSync(e,r){try{return this.validateSync(e,r),!0}catch(i){if(_te.default.isError(i))return!1;throw i}}_getDefault(){let e=this.spec.default;return e==null?e:typeof e=="function"?e.call(this):(0,Jte.default)(e)}getDefault(e){return this.resolve(e||{})._getDefault()}default(e){return arguments.length===0?this._getDefault():this.clone({default:e})}strict(e=!0){var r=this.clone();return r.spec.strict=e,r}_isPresent(e){return e!=null}defined(e=Hf.mixed.defined){return this.test({message:e,name:"defined",exclusive:!0,test(r){return r!==void 0}})}required(e=Hf.mixed.required){return this.clone({presence:"required"}).withMutation(r=>r.test({message:e,name:"required",exclusive:!0,test(i){return this.schema._isPresent(i)}}))}notRequired(){var e=this.clone({presence:"optional"});return e.tests=e.tests.filter(r=>r.OPTIONS.name!=="required"),e}nullable(e=!0){var r=this.clone({nullable:e!==!1});return r}transform(e){var r=this.clone();return r.transforms.push(e),r}test(...e){let r;if(e.length===1?typeof e[0]=="function"?r={test:e[0]}:r=e[0]:e.length===2?r={name:e[0],test:e[1]}:r={name:e[0],message:e[1],test:e[2]},r.message===void 0&&(r.message=Hf.mixed.default),typeof r.test!="function")throw new TypeError("`test` is a required parameters");let i=this.clone(),n=(0,T0.default)(r),s=r.exclusive||r.name&&i.exclusiveTests[r.name]===!0;if(r.exclusive&&!r.name)throw new TypeError("Exclusive tests must provide a unique `name` identifying the test");return r.name&&(i.exclusiveTests[r.name]=!!r.exclusive),i.tests=i.tests.filter(o=>!(o.OPTIONS.name===r.name&&(s||o.OPTIONS.test===n.OPTIONS.test))),i.tests.push(n),i}when(e,r){!Array.isArray(e)&&typeof e!="string"&&(r=e,e=".");let i=this.clone(),n=(0,_je.default)(e).map(s=>new Wje.default(s));return n.forEach(s=>{s.isSibling&&i.deps.push(s.key)}),i.conditions.push(new Jje.default(n,r)),i}typeError(e){var r=this.clone();return r._typeError=(0,T0.default)({message:e,name:"typeError",test(i){return i!==void 0&&!this.schema.isType(i)?this.createError({params:{type:this.schema._type}}):!0}}),r}oneOf(e,r=Hf.mixed.oneOf){var i=this.clone();return e.forEach(n=>{i._whitelist.add(n),i._blacklist.delete(n)}),i._whitelistError=(0,T0.default)({message:r,name:"oneOf",test(n){if(n===void 0)return!0;let s=this.schema._whitelist;return s.has(n,this.resolve)?!0:this.createError({params:{values:s.toArray().join(", ")}})}}),i}notOneOf(e,r=Hf.mixed.notOneOf){var i=this.clone();return e.forEach(n=>{i._blacklist.add(n),i._whitelist.delete(n)}),i._blacklistError=(0,T0.default)({message:r,name:"notOneOf",test(n){let s=this.schema._blacklist;return s.has(n,this.resolve)?this.createError({params:{values:s.toArray().join(", ")}}):!0}}),i}strip(e=!0){let r=this.clone();return r.spec.strip=e,r}describe(){let e=this.clone(),{label:r,meta:i}=e.spec;return{meta:i,label:r,type:e.type,oneOf:e._whitelist.describe(),notOneOf:e._blacklist.describe(),tests:e.tests.map(s=>({name:s.OPTIONS.name,params:s.OPTIONS.params})).filter((s,o,a)=>a.findIndex(l=>l.name===s.name)===o)}}};L0.default=Aa;Aa.prototype.__isYupSchema__=!0;for(let t of["validate","validateSync"])Aa.prototype[`${t}At`]=function(e,r,i={}){let{parent:n,parentPath:s,schema:o}=(0,zje.getIn)(this,e,r,i.context);return o[t](n&&n[s],Ys({},i,{parent:n,path:e}))};for(let t of["equals","is"])Aa.prototype[t]=Aa.prototype.oneOf;for(let t of["not","nope"])Aa.prototype[t]=Aa.prototype.notOneOf;Aa.prototype.optional=Aa.prototype.notRequired});var Zte=w(UC=>{"use strict";Object.defineProperty(UC,"__esModule",{value:!0});UC.create=Xte;UC.default=void 0;var Xje=Vje(pA());function Vje(t){return t&&t.__esModule?t:{default:t}}var qF=Xje.default,Zje=qF;UC.default=Zje;function Xte(){return new qF}Xte.prototype=qF.prototype});var jf=w(O0=>{"use strict";Object.defineProperty(O0,"__esModule",{value:!0});O0.default=void 0;var $je=t=>t==null;O0.default=$je});var ire=w(KC=>{"use strict";Object.defineProperty(KC,"__esModule",{value:!0});KC.create=$te;KC.default=void 0;var eGe=ere(pA()),tre=fA(),rre=ere(jf());function ere(t){return t&&t.__esModule?t:{default:t}}function $te(){return new M0}var M0=class extends eGe.default{constructor(){super({type:"boolean"});this.withMutation(()=>{this.transform(function(e){if(!this.isType(e)){if(/^(true|1)$/i.test(String(e)))return!0;if(/^(false|0)$/i.test(String(e)))return!1}return e})})}_typeCheck(e){return e instanceof Boolean&&(e=e.valueOf()),typeof e=="boolean"}isTrue(e=tre.boolean.isValue){return this.test({message:e,name:"is-value",exclusive:!0,params:{value:"true"},test(r){return(0,rre.default)(r)||r===!0}})}isFalse(e=tre.boolean.isValue){return this.test({message:e,name:"is-value",exclusive:!0,params:{value:"false"},test(r){return(0,rre.default)(r)||r===!1}})}};KC.default=M0;$te.prototype=M0.prototype});var ore=w(HC=>{"use strict";Object.defineProperty(HC,"__esModule",{value:!0});HC.create=nre;HC.default=void 0;var la=fA(),dA=sre(jf()),tGe=sre(pA());function sre(t){return t&&t.__esModule?t:{default:t}}var rGe=/^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i,iGe=/^((https?|ftp):)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i,nGe=/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i,sGe=t=>(0,dA.default)(t)||t===t.trim(),oGe={}.toString();function nre(){return new U0}var U0=class extends tGe.default{constructor(){super({type:"string"});this.withMutation(()=>{this.transform(function(e){if(this.isType(e)||Array.isArray(e))return e;let r=e!=null&&e.toString?e.toString():e;return r===oGe?e:r})})}_typeCheck(e){return e instanceof String&&(e=e.valueOf()),typeof e=="string"}_isPresent(e){return super._isPresent(e)&&!!e.length}length(e,r=la.string.length){return this.test({message:r,name:"length",exclusive:!0,params:{length:e},test(i){return(0,dA.default)(i)||i.length===this.resolve(e)}})}min(e,r=la.string.min){return this.test({message:r,name:"min",exclusive:!0,params:{min:e},test(i){return(0,dA.default)(i)||i.length>=this.resolve(e)}})}max(e,r=la.string.max){return this.test({name:"max",exclusive:!0,message:r,params:{max:e},test(i){return(0,dA.default)(i)||i.length<=this.resolve(e)}})}matches(e,r){let i=!1,n,s;return r&&(typeof r=="object"?{excludeEmptyString:i=!1,message:n,name:s}=r:n=r),this.test({name:s||"matches",message:n||la.string.matches,params:{regex:e},test:o=>(0,dA.default)(o)||o===""&&i||o.search(e)!==-1})}email(e=la.string.email){return this.matches(rGe,{name:"email",message:e,excludeEmptyString:!0})}url(e=la.string.url){return this.matches(iGe,{name:"url",message:e,excludeEmptyString:!0})}uuid(e=la.string.uuid){return this.matches(nGe,{name:"uuid",message:e,excludeEmptyString:!1})}ensure(){return this.default("").transform(e=>e===null?"":e)}trim(e=la.string.trim){return this.transform(r=>r!=null?r.trim():r).test({message:e,name:"trim",test:sGe})}lowercase(e=la.string.lowercase){return this.transform(r=>(0,dA.default)(r)?r:r.toLowerCase()).test({message:e,name:"string_case",exclusive:!0,test:r=>(0,dA.default)(r)||r===r.toLowerCase()})}uppercase(e=la.string.uppercase){return this.transform(r=>(0,dA.default)(r)?r:r.toUpperCase()).test({message:e,name:"string_case",exclusive:!0,test:r=>(0,dA.default)(r)||r===r.toUpperCase()})}};HC.default=U0;nre.prototype=U0.prototype});var lre=w(jC=>{"use strict";Object.defineProperty(jC,"__esModule",{value:!0});jC.create=are;jC.default=void 0;var hu=fA(),pu=Are(jf()),aGe=Are(pA());function Are(t){return t&&t.__esModule?t:{default:t}}var AGe=t=>t!=+t;function are(){return new K0}var K0=class extends aGe.default{constructor(){super({type:"number"});this.withMutation(()=>{this.transform(function(e){let r=e;if(typeof r=="string"){if(r=r.replace(/\s/g,""),r==="")return NaN;r=+r}return this.isType(r)?r:parseFloat(r)})})}_typeCheck(e){return e instanceof Number&&(e=e.valueOf()),typeof e=="number"&&!AGe(e)}min(e,r=hu.number.min){return this.test({message:r,name:"min",exclusive:!0,params:{min:e},test(i){return(0,pu.default)(i)||i>=this.resolve(e)}})}max(e,r=hu.number.max){return this.test({message:r,name:"max",exclusive:!0,params:{max:e},test(i){return(0,pu.default)(i)||i<=this.resolve(e)}})}lessThan(e,r=hu.number.lessThan){return this.test({message:r,name:"max",exclusive:!0,params:{less:e},test(i){return(0,pu.default)(i)||i<this.resolve(e)}})}moreThan(e,r=hu.number.moreThan){return this.test({message:r,name:"min",exclusive:!0,params:{more:e},test(i){return(0,pu.default)(i)||i>this.resolve(e)}})}positive(e=hu.number.positive){return this.moreThan(0,e)}negative(e=hu.number.negative){return this.lessThan(0,e)}integer(e=hu.number.integer){return this.test({name:"integer",message:e,test:r=>(0,pu.default)(r)||Number.isInteger(r)})}truncate(){return this.transform(e=>(0,pu.default)(e)?e:e|0)}round(e){var r,i=["ceil","floor","round","trunc"];if(e=((r=e)==null?void 0:r.toLowerCase())||"round",e==="trunc")return this.truncate();if(i.indexOf(e.toLowerCase())===-1)throw new TypeError("Only valid options for round() are: "+i.join(", "));return this.transform(n=>(0,pu.default)(n)?n:Math[e](n))}};jC.default=K0;are.prototype=K0.prototype});var cre=w(JF=>{"use strict";Object.defineProperty(JF,"__esModule",{value:!0});JF.default=lGe;var cGe=/^(\d{4}|[+\-]\d{6})(?:-?(\d{2})(?:-?(\d{2}))?)?(?:[ T]?(\d{2}):?(\d{2})(?::?(\d{2})(?:[,\.](\d{1,}))?)?(?:(Z)|([+\-])(\d{2})(?::?(\d{2}))?)?)?$/;function lGe(t){var e=[1,4,5,6,7,10,11],r=0,i,n;if(n=cGe.exec(t)){for(var s=0,o;o=e[s];++s)n[o]=+n[o]||0;n[2]=(+n[2]||1)-1,n[3]=+n[3]||1,n[7]=n[7]?String(n[7]).substr(0,3):0,(n[8]===void 0||n[8]==="")&&(n[9]===void 0||n[9]==="")?i=+new Date(n[1],n[2],n[3],n[4],n[5],n[6],n[7]):(n[8]!=="Z"&&n[9]!==void 0&&(r=n[10]*60+n[11],n[9]==="+"&&(r=0-r)),i=Date.UTC(n[1],n[2],n[3],n[4],n[5]+r,n[6],n[7]))}else i=Date.parse?Date.parse(t):NaN;return i}});var fre=w(GC=>{"use strict";Object.defineProperty(GC,"__esModule",{value:!0});GC.create=WF;GC.default=void 0;var uGe=H0(cre()),ure=fA(),gre=H0(jf()),gGe=H0(fu()),fGe=H0(pA());function H0(t){return t&&t.__esModule?t:{default:t}}var zF=new Date(""),hGe=t=>Object.prototype.toString.call(t)==="[object Date]";function WF(){return new YC}var YC=class extends fGe.default{constructor(){super({type:"date"});this.withMutation(()=>{this.transform(function(e){return this.isType(e)?e:(e=(0,uGe.default)(e),isNaN(e)?zF:new Date(e))})})}_typeCheck(e){return hGe(e)&&!isNaN(e.getTime())}prepareParam(e,r){let i;if(gGe.default.isRef(e))i=e;else{let n=this.cast(e);if(!this._typeCheck(n))throw new TypeError(`\`${r}\` must be a Date or a value that can be \`cast()\` to a Date`);i=n}return i}min(e,r=ure.date.min){let i=this.prepareParam(e,"min");return this.test({message:r,name:"min",exclusive:!0,params:{min:e},test(n){return(0,gre.default)(n)||n>=this.resolve(i)}})}max(e,r=ure.date.max){var i=this.prepareParam(e,"max");return this.test({message:r,name:"max",exclusive:!0,params:{max:e},test(n){return(0,gre.default)(n)||n<=this.resolve(i)}})}};GC.default=YC;YC.INVALID_DATE=zF;WF.prototype=YC.prototype;WF.INVALID_DATE=zF});var pre=w((Wht,hre)=>{function pGe(t,e,r,i){var n=-1,s=t==null?0:t.length;for(i&&s&&(r=t[++n]);++n<s;)r=e(r,t[n],n,t);return r}hre.exports=pGe});var Cre=w((zht,dre)=>{function dGe(t){return function(e){return t==null?void 0:t[e]}}dre.exports=dGe});var Ere=w((_ht,mre)=>{var CGe=Cre(),mGe={\u00C0:"A",\u00C1:"A",\u00C2:"A",\u00C3:"A",\u00C4:"A",\u00C5:"A",\u00E0:"a",\u00E1:"a",\u00E2:"a",\u00E3:"a",\u00E4:"a",\u00E5:"a",\u00C7:"C",\u00E7:"c",\u00D0:"D",\u00F0:"d",\u00C8:"E",\u00C9:"E",\u00CA:"E",\u00CB:"E",\u00E8:"e",\u00E9:"e",\u00EA:"e",\u00EB:"e",\u00CC:"I",\u00CD:"I",\u00CE:"I",\u00CF:"I",\u00EC:"i",\u00ED:"i",\u00EE:"i",\u00EF:"i",\u00D1:"N",\u00F1:"n",\u00D2:"O",\u00D3:"O",\u00D4:"O",\u00D5:"O",\u00D6:"O",\u00D8:"O",\u00F2:"o",\u00F3:"o",\u00F4:"o",\u00F5:"o",\u00F6:"o",\u00F8:"o",\u00D9:"U",\u00DA:"U",\u00DB:"U",\u00DC:"U",\u00F9:"u",\u00FA:"u",\u00FB:"u",\u00FC:"u",\u00DD:"Y",\u00FD:"y",\u00FF:"y",\u00C6:"Ae",\u00E6:"ae",\u00DE:"Th",\u00FE:"th",\u00DF:"ss",\u0100:"A",\u0102:"A",\u0104:"A",\u0101:"a",\u0103:"a",\u0105:"a",\u0106:"C",\u0108:"C",\u010A:"C",\u010C:"C",\u0107:"c",\u0109:"c",\u010B:"c",\u010D:"c",\u010E:"D",\u0110:"D",\u010F:"d",\u0111:"d",\u0112:"E",\u0114:"E",\u0116:"E",\u0118:"E",\u011A:"E",\u0113:"e",\u0115:"e",\u0117:"e",\u0119:"e",\u011B:"e",\u011C:"G",\u011E:"G",\u0120:"G",\u0122:"G",\u011D:"g",\u011F:"g",\u0121:"g",\u0123:"g",\u0124:"H",\u0126:"H",\u0125:"h",\u0127:"h",\u0128:"I",\u012A:"I",\u012C:"I",\u012E:"I",\u0130:"I",\u0129:"i",\u012B:"i",\u012D:"i",\u012F:"i",\u0131:"i",\u0134:"J",\u0135:"j",\u0136:"K",\u0137:"k",\u0138:"k",\u0139:"L",\u013B:"L",\u013D:"L",\u013F:"L",\u0141:"L",\u013A:"l",\u013C:"l",\u013E:"l",\u0140:"l",\u0142:"l",\u0143:"N",\u0145:"N",\u0147:"N",\u014A:"N",\u0144:"n",\u0146:"n",\u0148:"n",\u014B:"n",\u014C:"O",\u014E:"O",\u0150:"O",\u014D:"o",\u014F:"o",\u0151:"o",\u0154:"R",\u0156:"R",\u0158:"R",\u0155:"r",\u0157:"r",\u0159:"r",\u015A:"S",\u015C:"S",\u015E:"S",\u0160:"S",\u015B:"s",\u015D:"s",\u015F:"s",\u0161:"s",\u0162:"T",\u0164:"T",\u0166:"T",\u0163:"t",\u0165:"t",\u0167:"t",\u0168:"U",\u016A:"U",\u016C:"U",\u016E:"U",\u0170:"U",\u0172:"U",\u0169:"u",\u016B:"u",\u016D:"u",\u016F:"u",\u0171:"u",\u0173:"u",\u0174:"W",\u0175:"w",\u0176:"Y",\u0177:"y",\u0178:"Y",\u0179:"Z",\u017B:"Z",\u017D:"Z",\u017A:"z",\u017C:"z",\u017E:"z",\u0132:"IJ",\u0133:"ij",\u0152:"Oe",\u0153:"oe",\u0149:"'n",\u017F:"s"},EGe=CGe(mGe);mre.exports=EGe});var yre=w((Vht,Ire)=>{var IGe=Ere(),yGe=nf(),wGe=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,BGe="\\u0300-\\u036f",bGe="\\ufe20-\\ufe2f",QGe="\\u20d0-\\u20ff",vGe=BGe+bGe+QGe,SGe="["+vGe+"]",kGe=RegExp(SGe,"g");function xGe(t){return t=yGe(t),t&&t.replace(wGe,IGe).replace(kGe,"")}Ire.exports=xGe});var Bre=w((Xht,wre)=>{var PGe=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g;function DGe(t){return t.match(PGe)||[]}wre.exports=DGe});var Qre=w((Zht,bre)=>{var RGe=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/;function FGe(t){return RGe.test(t)}bre.exports=FGe});var Yre=w(($ht,vre)=>{var Sre="\\ud800-\\udfff",NGe="\\u0300-\\u036f",LGe="\\ufe20-\\ufe2f",TGe="\\u20d0-\\u20ff",OGe=NGe+LGe+TGe,kre="\\u2700-\\u27bf",xre="a-z\\xdf-\\xf6\\xf8-\\xff",MGe="\\xac\\xb1\\xd7\\xf7",UGe="\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf",KGe="\\u2000-\\u206f",HGe=" \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",Pre="A-Z\\xc0-\\xd6\\xd8-\\xde",jGe="\\ufe0e\\ufe0f",Dre=MGe+UGe+KGe+HGe,Rre="['\u2019]",Fre="["+Dre+"]",GGe="["+OGe+"]",Nre="\\d+",YGe="["+kre+"]",Lre="["+xre+"]",Tre="[^"+Sre+Dre+Nre+kre+xre+Pre+"]",qGe="\\ud83c[\\udffb-\\udfff]",JGe="(?:"+GGe+"|"+qGe+")",WGe="[^"+Sre+"]",Ore="(?:\\ud83c[\\udde6-\\uddff]){2}",Mre="[\\ud800-\\udbff][\\udc00-\\udfff]",Gf="["+Pre+"]",zGe="\\u200d",Ure="(?:"+Lre+"|"+Tre+")",_Ge="(?:"+Gf+"|"+Tre+")",Kre="(?:"+Rre+"(?:d|ll|m|re|s|t|ve))?",Hre="(?:"+Rre+"(?:D|LL|M|RE|S|T|VE))?",jre=JGe+"?",Gre="["+jGe+"]?",VGe="(?:"+zGe+"(?:"+[WGe,Ore,Mre].join("|")+")"+Gre+jre+")*",XGe="\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",ZGe="\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])",$Ge=Gre+jre+VGe,eYe="(?:"+[YGe,Ore,Mre].join("|")+")"+$Ge,tYe=RegExp([Gf+"?"+Lre+"+"+Kre+"(?="+[Fre,Gf,"$"].join("|")+")",_Ge+"+"+Hre+"(?="+[Fre,Gf+Ure,"$"].join("|")+")",Gf+"?"+Ure+"+"+Kre,Gf+"+"+Hre,ZGe,XGe,Nre,eYe].join("|"),"g");function rYe(t){return t.match(tYe)||[]}vre.exports=rYe});var Jre=w((ept,qre)=>{var iYe=Bre(),nYe=Qre(),sYe=nf(),oYe=Yre();function aYe(t,e,r){return t=sYe(t),e=r?void 0:e,e===void 0?nYe(t)?oYe(t):iYe(t):t.match(e)||[]}qre.exports=aYe});var _F=w((tpt,Wre)=>{var AYe=pre(),lYe=yre(),cYe=Jre(),uYe="['\u2019]",gYe=RegExp(uYe,"g");function fYe(t){return function(e){return AYe(cYe(lYe(e).replace(gYe,"")),t,"")}}Wre.exports=fYe});var _re=w((rpt,zre)=>{var hYe=_F(),pYe=hYe(function(t,e,r){return t+(r?"_":"")+e.toLowerCase()});zre.exports=pYe});var Xre=w((ipt,Vre)=>{var dYe=tB(),CYe=_F(),mYe=CYe(function(t,e,r){return e=e.toLowerCase(),t+(r?dYe(e):e)});Vre.exports=mYe});var $re=w((npt,Zre)=>{var EYe=Ff(),IYe=IF(),yYe=TF();function wYe(t,e){var r={};return e=yYe(e,3),IYe(t,function(i,n,s){EYe(r,e(i,n,s),i)}),r}Zre.exports=wYe});var tie=w((spt,VF)=>{VF.exports=function(t){return eie(BYe(t),t)};VF.exports.array=eie;function eie(t,e){var r=t.length,i=new Array(r),n={},s=r,o=bYe(e),a=QYe(t);for(e.forEach(function(c){if(!a.has(c[0])||!a.has(c[1]))throw new Error("Unknown node. There is an unknown node in the supplied edges.")});s--;)n[s]||l(t[s],s,new Set);return i;function l(c,u,g){if(g.has(c)){var f;try{f=", node was:"+JSON.stringify(c)}catch(m){f=""}throw new Error("Cyclic dependency"+f)}if(!a.has(c))throw new Error("Found unknown node. Make sure to provided all involved nodes. Unknown node: "+JSON.stringify(c));if(!n[u]){n[u]=!0;var h=o.get(c)||new Set;if(h=Array.from(h),u=h.length){g.add(c);do{var p=h[--u];l(p,a.get(p),g)}while(u);g.delete(c)}i[--r]=c}}}function BYe(t){for(var e=new Set,r=0,i=t.length;r<i;r++){var n=t[r];e.add(n[0]),e.add(n[1])}return Array.from(e)}function bYe(t){for(var e=new Map,r=0,i=t.length;r<i;r++){var n=t[r];e.has(n[0])||e.set(n[0],new Set),e.has(n[1])||e.set(n[1],new Set),e.get(n[0]).add(n[1])}return e}function QYe(t){for(var e=new Map,r=0,i=t.length;r<i;r++)e.set(t[r],r);return e}});var rie=w(XF=>{"use strict";Object.defineProperty(XF,"__esModule",{value:!0});XF.default=vYe;var SYe=j0(SC()),kYe=j0(tie()),xYe=TC(),PYe=j0(fu()),DYe=j0(Lf());function j0(t){return t&&t.__esModule?t:{default:t}}function vYe(t,e=[]){let r=[],i=[];function n(s,o){var a=(0,xYe.split)(s)[0];~i.indexOf(a)||i.push(a),~e.indexOf(`${o}-${a}`)||r.push([o,a])}for(let s in t)if((0,SYe.default)(t,s)){let o=t[s];~i.indexOf(s)||i.push(s),PYe.default.isRef(o)&&o.isSibling?n(o.path,s):(0,DYe.default)(o)&&"deps"in o&&o.deps.forEach(a=>n(a,s))}return kYe.default.array(i,r).reverse()}});var nie=w(ZF=>{"use strict";Object.defineProperty(ZF,"__esModule",{value:!0});ZF.default=RYe;function iie(t,e){let r=Infinity;return t.some((i,n)=>{var s;if(((s=e.path)==null?void 0:s.indexOf(i))!==-1)return r=n,!0}),r}function RYe(t){return(e,r)=>iie(t,e)-iie(t,r)}});var uie=w(qC=>{"use strict";Object.defineProperty(qC,"__esModule",{value:!0});qC.create=sie;qC.default=void 0;var oie=ca(SC()),aie=ca(_re()),FYe=ca(Xre()),NYe=ca($re()),LYe=ca(OF()),TYe=TC(),Aie=fA(),OYe=ca(rie()),lie=ca(nie()),MYe=ca(I0()),UYe=ca(cu()),$F=ca(pA());function ca(t){return t&&t.__esModule?t:{default:t}}function Yf(){return Yf=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},Yf.apply(this,arguments)}var cie=t=>Object.prototype.toString.call(t)==="[object Object]";function KYe(t,e){let r=Object.keys(t.fields);return Object.keys(e).filter(i=>r.indexOf(i)===-1)}var HYe=(0,lie.default)([]),G0=class extends $F.default{constructor(e){super({type:"object"});this.fields=Object.create(null),this._sortErrors=HYe,this._nodes=[],this._excludedEdges=[],this.withMutation(()=>{this.transform(function(i){if(typeof i=="string")try{i=JSON.parse(i)}catch(n){i=null}return this.isType(i)?i:null}),e&&this.shape(e)})}_typeCheck(e){return cie(e)||typeof e=="function"}_cast(e,r={}){var i;let n=super._cast(e,r);if(n===void 0)return this.getDefault();if(!this._typeCheck(n))return n;let s=this.fields,o=(i=r.stripUnknown)!=null?i:this.spec.noUnknown,a=this._nodes.concat(Object.keys(n).filter(g=>this._nodes.indexOf(g)===-1)),l={},c=Yf({},r,{parent:l,__validating:r.__validating||!1}),u=!1;for(let g of a){let f=s[g],h=(0,oie.default)(n,g);if(f){let p,m=n[g];c.path=(r.path?`${r.path}.`:"")+g,f=f.resolve({value:m,context:r.context,parent:l});let y="spec"in f?f.spec:void 0,Q=y==null?void 0:y.strict;if(y==null?void 0:y.strip){u=u||g in n;continue}p=!r.__validating||!Q?f.cast(n[g],c):n[g],p!==void 0&&(l[g]=p)}else h&&!o&&(l[g]=n[g]);l[g]!==n[g]&&(u=!0)}return u?l:n}_validate(e,r={},i){let n=[],{sync:s,from:o=[],originalValue:a=e,abortEarly:l=this.spec.abortEarly,recursive:c=this.spec.recursive}=r;o=[{schema:this,value:a},...o],r.__validating=!0,r.originalValue=a,r.from=o,super._validate(e,r,(u,g)=>{if(u){if(!UYe.default.isError(u)||l)return void i(u,g);n.push(u)}if(!c||!cie(g)){i(n[0]||null,g);return}a=a||g;let f=this._nodes.map(h=>(p,m)=>{let y=h.indexOf(".")===-1?(r.path?`${r.path}.`:"")+h:`${r.path||""}["${h}"]`,Q=this.fields[h];if(Q&&"validate"in Q){Q.validate(g[h],Yf({},r,{path:y,from:o,strict:!0,parent:g,originalValue:a[h]}),m);return}m(null)});(0,MYe.default)({sync:s,tests:f,value:g,errors:n,endEarly:l,sort:this._sortErrors,path:r.path},i)})}clone(e){let r=super.clone(e);return r.fields=Yf({},this.fields),r._nodes=this._nodes,r._excludedEdges=this._excludedEdges,r._sortErrors=this._sortErrors,r}concat(e){let r=super.concat(e),i=r.fields;for(let[n,s]of Object.entries(this.fields)){let o=i[n];o===void 0?i[n]=s:o instanceof $F.default&&s instanceof $F.default&&(i[n]=s.concat(o))}return r.withMutation(()=>r.shape(i))}getDefaultFromShape(){let e={};return this._nodes.forEach(r=>{let i=this.fields[r];e[r]="default"in i?i.getDefault():void 0}),e}_getDefault(){if("default"in this.spec)return super._getDefault();if(!!this._nodes.length)return this.getDefaultFromShape()}shape(e,r=[]){let i=this.clone(),n=Object.assign(i.fields,e);if(i.fields=n,i._sortErrors=(0,lie.default)(Object.keys(n)),r.length){Array.isArray(r[0])||(r=[r]);let s=r.map(([o,a])=>`${o}-${a}`);i._excludedEdges=i._excludedEdges.concat(s)}return i._nodes=(0,OYe.default)(n,i._excludedEdges),i}pick(e){let r={};for(let i of e)this.fields[i]&&(r[i]=this.fields[i]);return this.clone().withMutation(i=>(i.fields={},i.shape(r)))}omit(e){let r=this.clone(),i=r.fields;r.fields={};for(let n of e)delete i[n];return r.withMutation(()=>r.shape(i))}from(e,r,i){let n=(0,TYe.getter)(e,!0);return this.transform(s=>{if(s==null)return s;let o=s;return(0,oie.default)(s,e)&&(o=Yf({},s),i||delete o[e],o[r]=n(s)),o})}noUnknown(e=!0,r=Aie.object.noUnknown){typeof e=="string"&&(r=e,e=!0);let i=this.test({name:"noUnknown",exclusive:!0,message:r,test(n){if(n==null)return!0;let s=KYe(this.schema,n);return!e||s.length===0||this.createError({params:{unknown:s.join(", ")}})}});return i.spec.noUnknown=e,i}unknown(e=!0,r=Aie.object.noUnknown){return this.noUnknown(!e,r)}transformKeys(e){return this.transform(r=>r&&(0,NYe.default)(r,(i,n)=>e(n)))}camelCase(){return this.transformKeys(FYe.default)}snakeCase(){return this.transformKeys(aie.default)}constantCase(){return this.transformKeys(e=>(0,aie.default)(e).toUpperCase())}describe(){let e=super.describe();return e.fields=(0,LYe.default)(this.fields,r=>r.describe()),e}};qC.default=G0;function sie(t){return new G0(t)}sie.prototype=G0.prototype});var fie=w(JC=>{"use strict";Object.defineProperty(JC,"__esModule",{value:!0});JC.create=gie;JC.default=void 0;var eN=qf(jf()),jYe=qf(Lf()),GYe=qf(vC()),tN=fA(),YYe=qf(I0()),qYe=qf(cu()),JYe=qf(pA());function qf(t){return t&&t.__esModule?t:{default:t}}function Y0(){return Y0=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},Y0.apply(this,arguments)}function gie(t){return new q0(t)}var q0=class extends JYe.default{constructor(e){super({type:"array"});this.innerType=e,this.withMutation(()=>{this.transform(function(r){if(typeof r=="string")try{r=JSON.parse(r)}catch(i){r=null}return this.isType(r)?r:null})})}_typeCheck(e){return Array.isArray(e)}get _subType(){return this.innerType}_cast(e,r){let i=super._cast(e,r);if(!this._typeCheck(i)||!this.innerType)return i;let n=!1,s=i.map((o,a)=>{let l=this.innerType.cast(o,Y0({},r,{path:`${r.path||""}[${a}]`}));return l!==o&&(n=!0),l});return n?s:i}_validate(e,r={},i){var n,s;let o=[],a=r.sync,l=r.path,c=this.innerType,u=(n=r.abortEarly)!=null?n:this.spec.abortEarly,g=(s=r.recursive)!=null?s:this.spec.recursive,f=r.originalValue!=null?r.originalValue:e;super._validate(e,r,(h,p)=>{if(h){if(!qYe.default.isError(h)||u)return void i(h,p);o.push(h)}if(!g||!c||!this._typeCheck(p)){i(o[0]||null,p);return}f=f||p;let m=new Array(p.length);for(let y=0;y<p.length;y++){let Q=p[y],S=`${r.path||""}[${y}]`,x=Y0({},r,{path:S,strict:!0,parent:p,index:y,originalValue:f[y]});m[y]=(M,Y)=>c.validate(Q,x,Y)}(0,YYe.default)({sync:a,path:l,value:p,errors:o,endEarly:u,tests:m},i)})}clone(e){let r=super.clone(e);return r.innerType=this.innerType,r}concat(e){let r=super.concat(e);return r.innerType=this.innerType,e.innerType&&(r.innerType=r.innerType?r.innerType.concat(e.innerType):e.innerType),r}of(e){let r=this.clone();if(!(0,jYe.default)(e))throw new TypeError("`array.of()` sub-schema must be a valid yup schema not: "+(0,GYe.default)(e));return r.innerType=e,r}length(e,r=tN.array.length){return this.test({message:r,name:"length",exclusive:!0,params:{length:e},test(i){return(0,eN.default)(i)||i.length===this.resolve(e)}})}min(e,r){return r=r||tN.array.min,this.test({message:r,name:"min",exclusive:!0,params:{min:e},test(i){return(0,eN.default)(i)||i.length>=this.resolve(e)}})}max(e,r){return r=r||tN.array.max,this.test({message:r,name:"max",exclusive:!0,params:{max:e},test(i){return(0,eN.default)(i)||i.length<=this.resolve(e)}})}ensure(){return this.default(()=>[]).transform((e,r)=>this._typeCheck(e)?e:r==null?[]:[].concat(r))}compact(e){let r=e?(i,n,s)=>!e(i,n,s):i=>!!i;return this.transform(i=>i!=null?i.filter(r):i)}describe(){let e=super.describe();return this.innerType&&(e.innerType=this.innerType.describe()),e}nullable(e=!0){return super.nullable(e)}defined(){return super.defined()}required(e){return super.required(e)}};JC.default=q0;gie.prototype=q0.prototype});var hie=w(WC=>{"use strict";Object.defineProperty(WC,"__esModule",{value:!0});WC.create=WYe;WC.default=void 0;var _Ye=zYe(Lf());function zYe(t){return t&&t.__esModule?t:{default:t}}function WYe(t){return new rN(t)}var rN=class{constructor(e){this.type="lazy",this.__isYupSchema__=!0,this._resolve=(r,i={})=>{let n=this.builder(r,i);if(!(0,_Ye.default)(n))throw new TypeError("lazy() functions must return a valid schema");return n.resolve(i)},this.builder=e}resolve(e){return this._resolve(e.value,e)}cast(e,r){return this._resolve(e,r).cast(e,r)}validate(e,r,i){return this._resolve(e,r).validate(e,r,i)}validateSync(e,r){return this._resolve(e,r).validateSync(e,r)}validateAt(e,r,i){return this._resolve(r,i).validateAt(e,r,i)}validateSyncAt(e,r,i){return this._resolve(r,i).validateSyncAt(e,r,i)}describe(){return null}isValid(e,r){return this._resolve(e,r).isValid(e,r)}isValidSync(e,r){return this._resolve(e,r).isValidSync(e,r)}},VYe=rN;WC.default=VYe});var pie=w(iN=>{"use strict";Object.defineProperty(iN,"__esModule",{value:!0});iN.default=XYe;var $Ye=ZYe(fA());function ZYe(t){return t&&t.__esModule?t:{default:t}}function XYe(t){Object.keys(t).forEach(e=>{Object.keys(t[e]).forEach(r=>{$Ye.default[e][r]=t[e][r]})})}});var sN=w(Br=>{"use strict";Object.defineProperty(Br,"__esModule",{value:!0});Br.addMethod=eqe;Object.defineProperty(Br,"MixedSchema",{enumerable:!0,get:function(){return die.default}});Object.defineProperty(Br,"mixed",{enumerable:!0,get:function(){return die.create}});Object.defineProperty(Br,"BooleanSchema",{enumerable:!0,get:function(){return nN.default}});Object.defineProperty(Br,"bool",{enumerable:!0,get:function(){return nN.create}});Object.defineProperty(Br,"boolean",{enumerable:!0,get:function(){return nN.create}});Object.defineProperty(Br,"StringSchema",{enumerable:!0,get:function(){return Cie.default}});Object.defineProperty(Br,"string",{enumerable:!0,get:function(){return Cie.create}});Object.defineProperty(Br,"NumberSchema",{enumerable:!0,get:function(){return mie.default}});Object.defineProperty(Br,"number",{enumerable:!0,get:function(){return mie.create}});Object.defineProperty(Br,"DateSchema",{enumerable:!0,get:function(){return Eie.default}});Object.defineProperty(Br,"date",{enumerable:!0,get:function(){return Eie.create}});Object.defineProperty(Br,"ObjectSchema",{enumerable:!0,get:function(){return Iie.default}});Object.defineProperty(Br,"object",{enumerable:!0,get:function(){return Iie.create}});Object.defineProperty(Br,"ArraySchema",{enumerable:!0,get:function(){return yie.default}});Object.defineProperty(Br,"array",{enumerable:!0,get:function(){return yie.create}});Object.defineProperty(Br,"ref",{enumerable:!0,get:function(){return tqe.create}});Object.defineProperty(Br,"lazy",{enumerable:!0,get:function(){return rqe.create}});Object.defineProperty(Br,"ValidationError",{enumerable:!0,get:function(){return iqe.default}});Object.defineProperty(Br,"reach",{enumerable:!0,get:function(){return nqe.default}});Object.defineProperty(Br,"isSchema",{enumerable:!0,get:function(){return wie.default}});Object.defineProperty(Br,"setLocale",{enumerable:!0,get:function(){return sqe.default}});Object.defineProperty(Br,"BaseSchema",{enumerable:!0,get:function(){return oqe.default}});var die=du(Zte()),nN=du(ire()),Cie=du(ore()),mie=du(lre()),Eie=du(fre()),Iie=du(uie()),yie=du(fie()),tqe=fu(),rqe=hie(),iqe=zC(cu()),nqe=zC(YF()),wie=zC(Lf()),sqe=zC(pie()),oqe=zC(pA());function zC(t){return t&&t.__esModule?t:{default:t}}function Bie(){if(typeof WeakMap!="function")return null;var t=new WeakMap;return Bie=function(){return t},t}function du(t){if(t&&t.__esModule)return t;if(t===null||typeof t!="object"&&typeof t!="function")return{default:t};var e=Bie();if(e&&e.has(t))return e.get(t);var r={},i=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var n in t)if(Object.prototype.hasOwnProperty.call(t,n)){var s=i?Object.getOwnPropertyDescriptor(t,n):null;s&&(s.get||s.set)?Object.defineProperty(r,n,s):r[n]=t[n]}return r.default=t,e&&e.set(t,r),r}function eqe(t,e,r){if(!t||!(0,wie.default)(t.prototype))throw new TypeError("You must provide a yup schema constructor function");if(typeof e!="string")throw new TypeError("A Method name must be provided");if(typeof r!="function")throw new TypeError("Method function must be provided");t.prototype[e]=r}});var kie=w((Qpt,VC)=>{"use strict";var lqe=process.env.TERM_PROGRAM==="Hyper",cqe=process.platform==="win32",Qie=process.platform==="linux",oN={ballotDisabled:"\u2612",ballotOff:"\u2610",ballotOn:"\u2611",bullet:"\u2022",bulletWhite:"\u25E6",fullBlock:"\u2588",heart:"\u2764",identicalTo:"\u2261",line:"\u2500",mark:"\u203B",middot:"\xB7",minus:"\uFF0D",multiplication:"\xD7",obelus:"\xF7",pencilDownRight:"\u270E",pencilRight:"\u270F",pencilUpRight:"\u2710",percent:"%",pilcrow2:"\u2761",pilcrow:"\xB6",plusMinus:"\xB1",section:"\xA7",starsOff:"\u2606",starsOn:"\u2605",upDownArrow:"\u2195"},vie=Object.assign({},oN,{check:"\u221A",cross:"\xD7",ellipsisLarge:"...",ellipsis:"...",info:"i",question:"?",questionSmall:"?",pointer:">",pointerSmall:"\xBB",radioOff:"( )",radioOn:"(*)",warning:"\u203C"}),Sie=Object.assign({},oN,{ballotCross:"\u2718",check:"\u2714",cross:"\u2716",ellipsisLarge:"\u22EF",ellipsis:"\u2026",info:"\u2139",question:"?",questionFull:"\uFF1F",questionSmall:"\uFE56",pointer:Qie?"\u25B8":"\u276F",pointerSmall:Qie?"\u2023":"\u203A",radioOff:"\u25EF",radioOn:"\u25C9",warning:"\u26A0"});VC.exports=cqe&&!lqe?vie:Sie;Reflect.defineProperty(VC.exports,"common",{enumerable:!1,value:oN});Reflect.defineProperty(VC.exports,"windows",{enumerable:!1,value:vie});Reflect.defineProperty(VC.exports,"other",{enumerable:!1,value:Sie})});var Co=w((vpt,aN)=>{"use strict";var uqe=t=>t!==null&&typeof t=="object"&&!Array.isArray(t),gqe=/[\u001b\u009b][[\]#;?()]*(?:(?:(?:[^\W_]*;?[^\W_]*)\u0007)|(?:(?:[0-9]{1,4}(;[0-9]{0,4})*)?[~0-9=<>cf-nqrtyA-PRZ]))/g,xie=()=>{let t={enabled:!0,visible:!0,styles:{},keys:{}};"FORCE_COLOR"in process.env&&(t.enabled=process.env.FORCE_COLOR!=="0");let e=s=>{let o=s.open=`\e[${s.codes[0]}m`,a=s.close=`\e[${s.codes[1]}m`,l=s.regex=new RegExp(`\\u001b\\[${s.codes[1]}m`,"g");return s.wrap=(c,u)=>{c.includes(a)&&(c=c.replace(l,a+o));let g=o+c+a;return u?g.replace(/\r*\n/g,`${a}$&${o}`):g},s},r=(s,o,a)=>typeof s=="function"?s(o):s.wrap(o,a),i=(s,o)=>{if(s===""||s==null)return"";if(t.enabled===!1)return s;if(t.visible===!1)return"";let a=""+s,l=a.includes(`
+`),c=o.length;for(c>0&&o.includes("unstyle")&&(o=[...new Set(["unstyle",...o])].reverse());c-- >0;)a=r(t.styles[o[c]],a,l);return a},n=(s,o,a)=>{t.styles[s]=e({name:s,codes:o}),(t.keys[a]||(t.keys[a]=[])).push(s),Reflect.defineProperty(t,s,{configurable:!0,enumerable:!0,set(c){t.alias(s,c)},get(){let c=u=>i(u,c.stack);return Reflect.setPrototypeOf(c,t),c.stack=this.stack?this.stack.concat(s):[s],c}})};return n("reset",[0,0],"modifier"),n("bold",[1,22],"modifier"),n("dim",[2,22],"modifier"),n("italic",[3,23],"modifier"),n("underline",[4,24],"modifier"),n("inverse",[7,27],"modifier"),n("hidden",[8,28],"modifier"),n("strikethrough",[9,29],"modifier"),n("black",[30,39],"color"),n("red",[31,39],"color"),n("green",[32,39],"color"),n("yellow",[33,39],"color"),n("blue",[34,39],"color"),n("magenta",[35,39],"color"),n("cyan",[36,39],"color"),n("white",[37,39],"color"),n("gray",[90,39],"color"),n("grey",[90,39],"color"),n("bgBlack",[40,49],"bg"),n("bgRed",[41,49],"bg"),n("bgGreen",[42,49],"bg"),n("bgYellow",[43,49],"bg"),n("bgBlue",[44,49],"bg"),n("bgMagenta",[45,49],"bg"),n("bgCyan",[46,49],"bg"),n("bgWhite",[47,49],"bg"),n("blackBright",[90,39],"bright"),n("redBright",[91,39],"bright"),n("greenBright",[92,39],"bright"),n("yellowBright",[93,39],"bright"),n("blueBright",[94,39],"bright"),n("magentaBright",[95,39],"bright"),n("cyanBright",[96,39],"bright"),n("whiteBright",[97,39],"bright"),n("bgBlackBright",[100,49],"bgBright"),n("bgRedBright",[101,49],"bgBright"),n("bgGreenBright",[102,49],"bgBright"),n("bgYellowBright",[103,49],"bgBright"),n("bgBlueBright",[104,49],"bgBright"),n("bgMagentaBright",[105,49],"bgBright"),n("bgCyanBright",[106,49],"bgBright"),n("bgWhiteBright",[107,49],"bgBright"),t.ansiRegex=gqe,t.hasColor=t.hasAnsi=s=>(t.ansiRegex.lastIndex=0,typeof s=="string"&&s!==""&&t.ansiRegex.test(s)),t.alias=(s,o)=>{let a=typeof o=="string"?t[o]:o;if(typeof a!="function")throw new TypeError("Expected alias to be the name of an existing color (string) or a function");a.stack||(Reflect.defineProperty(a,"name",{value:s}),t.styles[s]=a,a.stack=[s]),Reflect.defineProperty(t,s,{configurable:!0,enumerable:!0,set(l){t.alias(s,l)},get(){let l=c=>i(c,l.stack);return Reflect.setPrototypeOf(l,t),l.stack=this.stack?this.stack.concat(a.stack):a.stack,l}})},t.theme=s=>{if(!uqe(s))throw new TypeError("Expected theme to be an object");for(let o of Object.keys(s))t.alias(o,s[o]);return t},t.alias("unstyle",s=>typeof s=="string"&&s!==""?(t.ansiRegex.lastIndex=0,s.replace(t.ansiRegex,"")):""),t.alias("noop",s=>s),t.none=t.clear=t.noop,t.stripColor=t.unstyle,t.symbols=kie(),t.define=n,t};aN.exports=xie();aN.exports.create=xie});var Xi=w(Lt=>{"use strict";var fqe=Object.prototype.toString,qs=Co(),Pie=!1,AN=[],Die={yellow:"blue",cyan:"red",green:"magenta",black:"white",blue:"yellow",red:"cyan",magenta:"green",white:"black"};Lt.longest=(t,e)=>t.reduce((r,i)=>Math.max(r,e?i[e].length:i.length),0);Lt.hasColor=t=>!!t&&qs.hasColor(t);var W0=Lt.isObject=t=>t!==null&&typeof t=="object"&&!Array.isArray(t);Lt.nativeType=t=>fqe.call(t).slice(8,-1).toLowerCase().replace(/\s/g,"");Lt.isAsyncFn=t=>Lt.nativeType(t)==="asyncfunction";Lt.isPrimitive=t=>t!=null&&typeof t!="object"&&typeof t!="function";Lt.resolve=(t,e,...r)=>typeof e=="function"?e.call(t,...r):e;Lt.scrollDown=(t=[])=>[...t.slice(1),t[0]];Lt.scrollUp=(t=[])=>[t.pop(),...t];Lt.reorder=(t=[])=>{let e=t.slice();return e.sort((r,i)=>r.index>i.index?1:r.index<i.index?-1:0),e};Lt.swap=(t,e,r)=>{let i=t.length,n=r===i?0:r<0?i-1:r,s=t[e];t[e]=t[n],t[n]=s};Lt.width=(t,e=80)=>{let r=t&&t.columns?t.columns:e;return t&&typeof t.getWindowSize=="function"&&(r=t.getWindowSize()[0]),process.platform==="win32"?r-1:r};Lt.height=(t,e=20)=>{let r=t&&t.rows?t.rows:e;return t&&typeof t.getWindowSize=="function"&&(r=t.getWindowSize()[1]),r};Lt.wordWrap=(t,e={})=>{if(!t)return t;typeof e=="number"&&(e={width:e});let{indent:r="",newline:i=`
+`+r,width:n=80}=e;n-=((i+r).match(/[^\S\n]/g)||[]).length;let o=`.{1,${n}}([\\s\\u200B]+|$)|[^\\s\\u200B]+?([\\s\\u200B]+|$)`,a=t.trim(),l=new RegExp(o,"g"),c=a.match(l)||[];return c=c.map(u=>u.replace(/\n$/,"")),e.padEnd&&(c=c.map(u=>u.padEnd(n," "))),e.padStart&&(c=c.map(u=>u.padStart(n," "))),r+c.join(i)};Lt.unmute=t=>{let e=t.stack.find(i=>qs.keys.color.includes(i));return e?qs[e]:t.stack.find(i=>i.slice(2)==="bg")?qs[e.slice(2)]:i=>i};Lt.pascal=t=>t?t[0].toUpperCase()+t.slice(1):"";Lt.inverse=t=>{if(!t||!t.stack)return t;let e=t.stack.find(i=>qs.keys.color.includes(i));if(e){let i=qs["bg"+Lt.pascal(e)];return i?i.black:t}let r=t.stack.find(i=>i.slice(0,2)==="bg");return r?qs[r.slice(2).toLowerCase()]||t:qs.none};Lt.complement=t=>{if(!t||!t.stack)return t;let e=t.stack.find(i=>qs.keys.color.includes(i)),r=t.stack.find(i=>i.slice(0,2)==="bg");if(e&&!r)return qs[Die[e]||e];if(r){let i=r.slice(2).toLowerCase(),n=Die[i];return n&&qs["bg"+Lt.pascal(n)]||t}return qs.none};Lt.meridiem=t=>{let e=t.getHours(),r=t.getMinutes(),i=e>=12?"pm":"am";e=e%12;let n=e===0?12:e,s=r<10?"0"+r:r;return n+":"+s+" "+i};Lt.set=(t={},e="",r)=>e.split(".").reduce((i,n,s,o)=>{let a=o.length-1>s?i[n]||{}:r;return!Lt.isObject(a)&&s<o.length-1&&(a={}),i[n]=a},t);Lt.get=(t={},e="",r)=>{let i=t[e]==null?e.split(".").reduce((n,s)=>n&&n[s],t):t[e];return i==null?r:i};Lt.mixin=(t,e)=>{if(!W0(t))return e;if(!W0(e))return t;for(let r of Object.keys(e)){let i=Object.getOwnPropertyDescriptor(e,r);if(i.hasOwnProperty("value"))if(t.hasOwnProperty(r)&&W0(i.value)){let n=Object.getOwnPropertyDescriptor(t,r);W0(n.value)?t[r]=Lt.merge({},t[r],e[r]):Reflect.defineProperty(t,r,i)}else Reflect.defineProperty(t,r,i);else Reflect.defineProperty(t,r,i)}return t};Lt.merge=(...t)=>{let e={};for(let r of t)Lt.mixin(e,r);return e};Lt.mixinEmitter=(t,e)=>{let r=e.constructor.prototype;for(let i of Object.keys(r)){let n=r[i];typeof n=="function"?Lt.define(t,i,n.bind(e)):Lt.define(t,i,n)}};Lt.onExit=t=>{let e=(r,i)=>{Pie||(Pie=!0,AN.forEach(n=>n()),r===!0&&process.exit(128+i))};AN.length===0&&(process.once("SIGTERM",e.bind(null,!0,15)),process.once("SIGINT",e.bind(null,!0,2)),process.once("exit",e)),AN.push(t)};Lt.define=(t,e,r)=>{Reflect.defineProperty(t,e,{value:r})};Lt.defineExport=(t,e,r)=>{let i;Reflect.defineProperty(t,e,{enumerable:!0,configurable:!0,set(n){i=n},get(){return i?i():r()}})}});var Rie=w(Wf=>{"use strict";Wf.ctrl={a:"first",b:"backward",c:"cancel",d:"deleteForward",e:"last",f:"forward",g:"reset",i:"tab",k:"cutForward",l:"reset",n:"newItem",m:"cancel",j:"submit",p:"search",r:"remove",s:"save",u:"undo",w:"cutLeft",x:"toggleCursor",v:"paste"};Wf.shift={up:"shiftUp",down:"shiftDown",left:"shiftLeft",right:"shiftRight",tab:"prev"};Wf.fn={up:"pageUp",down:"pageDown",left:"pageLeft",right:"pageRight",delete:"deleteForward"};Wf.option={b:"backward",f:"forward",d:"cutRight",left:"cutLeft",up:"altUp",down:"altDown"};Wf.keys={pageup:"pageUp",pagedown:"pageDown",home:"home",end:"end",cancel:"cancel",delete:"deleteForward",backspace:"delete",down:"down",enter:"submit",escape:"cancel",left:"left",space:"space",number:"number",return:"submit",right:"right",tab:"next",up:"up"}});var Lie=w((xpt,Fie)=>{"use strict";var Nie=require("readline"),hqe=Rie(),pqe=/^(?:\x1b)([a-zA-Z0-9])$/,dqe=/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/,Cqe={OP:"f1",OQ:"f2",OR:"f3",OS:"f4","[11~":"f1","[12~":"f2","[13~":"f3","[14~":"f4","[[A":"f1","[[B":"f2","[[C":"f3","[[D":"f4","[[E":"f5","[15~":"f5","[17~":"f6","[18~":"f7","[19~":"f8","[20~":"f9","[21~":"f10","[23~":"f11","[24~":"f12","[A":"up","[B":"down","[C":"right","[D":"left","[E":"clear","[F":"end","[H":"home",OA:"up",OB:"down",OC:"right",OD:"left",OE:"clear",OF:"end",OH:"home","[1~":"home","[2~":"insert","[3~":"delete","[4~":"end","[5~":"pageup","[6~":"pagedown","[[5~":"pageup","[[6~":"pagedown","[7~":"home","[8~":"end","[a":"up","[b":"down","[c":"right","[d":"left","[e":"clear","[2$":"insert","[3$":"delete","[5$":"pageup","[6$":"pagedown","[7$":"home","[8$":"end",Oa:"up",Ob:"down",Oc:"right",Od:"left",Oe:"clear","[2^":"insert","[3^":"delete","[5^":"pageup","[6^":"pagedown","[7^":"home","[8^":"end","[Z":"tab"};function mqe(t){return["[a","[b","[c","[d","[e","[2$","[3$","[5$","[6$","[7$","[8$","[Z"].includes(t)}function Eqe(t){return["Oa","Ob","Oc","Od","Oe","[2^","[3^","[5^","[6^","[7^","[8^"].includes(t)}var z0=(t="",e={})=>{let r,i=N({name:e.name,ctrl:!1,meta:!1,shift:!1,option:!1,sequence:t,raw:t},e);if(Buffer.isBuffer(t)?t[0]>127&&t[1]===void 0?(t[0]-=128,t="\e"+String(t)):t=String(t):t!==void 0&&typeof t!="string"?t=String(t):t||(t=i.sequence||""),i.sequence=i.sequence||t||i.name,t==="\r")i.raw=void 0,i.name="return";else if(t===`
+`)i.name="enter";else if(t===" ")i.name="tab";else if(t==="\b"||t==="\x7F"||t==="\e\x7F"||t==="\e\b")i.name="backspace",i.meta=t.charAt(0)==="\e";else if(t==="\e"||t==="\e\e")i.name="escape",i.meta=t.length===2;else if(t===" "||t==="\e ")i.name="space",i.meta=t.length===2;else if(t<="\1a")i.name=String.fromCharCode(t.charCodeAt(0)+"a".charCodeAt(0)-1),i.ctrl=!0;else if(t.length===1&&t>="0"&&t<="9")i.name="number";else if(t.length===1&&t>="a"&&t<="z")i.name=t;else if(t.length===1&&t>="A"&&t<="Z")i.name=t.toLowerCase(),i.shift=!0;else if(r=pqe.exec(t))i.meta=!0,i.shift=/^[A-Z]$/.test(r[1]);else if(r=dqe.exec(t)){let n=[...t];n[0]==="\e"&&n[1]==="\e"&&(i.option=!0);let s=[r[1],r[2],r[4],r[6]].filter(Boolean).join(""),o=(r[3]||r[5]||1)-1;i.ctrl=!!(o&4),i.meta=!!(o&10),i.shift=!!(o&1),i.code=s,i.name=Cqe[s],i.shift=mqe(s)||i.shift,i.ctrl=Eqe(s)||i.ctrl}return i};z0.listen=(t={},e)=>{let{stdin:r}=t;if(!r||r!==process.stdin&&!r.isTTY)throw new Error("Invalid stream passed");let i=Nie.createInterface({terminal:!0,input:r});Nie.emitKeypressEvents(r,i);let n=(a,l)=>e(a,z0(a,l),i),s=r.isRaw;return r.isTTY&&r.setRawMode(!0),r.on("keypress",n),i.resume(),()=>{r.isTTY&&r.setRawMode(s),r.removeListener("keypress",n),i.pause(),i.close()}};z0.action=(t,e,r)=>{let i=N(N({},hqe),r);return e.ctrl?(e.action=i.ctrl[e.name],e):e.option&&i.option?(e.action=i.option[e.name],e):e.shift?(e.action=i.shift[e.name],e):(e.action=i.keys[e.name],e)};Fie.exports=z0});var Oie=w((Ppt,Tie)=>{"use strict";Tie.exports=t=>{t.timers=t.timers||{};let e=t.options.timers;if(!!e)for(let r of Object.keys(e)){let i=e[r];typeof i=="number"&&(i={interval:i}),Iqe(t,r,i)}};function Iqe(t,e,r={}){let i=t.timers[e]={name:e,start:Date.now(),ms:0,tick:0},n=r.interval||120;i.frames=r.frames||[],i.loading=!0;let s=setInterval(()=>{i.ms=Date.now()-i.start,i.tick++,t.render()},n);return i.stop=()=>{i.loading=!1,clearInterval(s)},Reflect.defineProperty(i,"interval",{value:s}),t.once("close",()=>i.stop()),i.stop}});var Kie=w((Dpt,Mie)=>{"use strict";var{define:yqe,width:wqe}=Xi(),Uie=class{constructor(e){let r=e.options;yqe(this,"_prompt",e),this.type=e.type,this.name=e.name,this.message="",this.header="",this.footer="",this.error="",this.hint="",this.input="",this.cursor=0,this.index=0,this.lines=0,this.tick=0,this.prompt="",this.buffer="",this.width=wqe(r.stdout||process.stdout),Object.assign(this,r),this.name=this.name||this.message,this.message=this.message||this.name,this.symbols=e.symbols,this.styles=e.styles,this.required=new Set,this.cancelled=!1,this.submitted=!1}clone(){let e=N({},this);return e.status=this.status,e.buffer=Buffer.from(e.buffer),delete e.clone,e}set color(e){this._color=e}get color(){let e=this.prompt.styles;if(this.cancelled)return e.cancelled;if(this.submitted)return e.submitted;let r=this._color||e[this.status];return typeof r=="function"?r:e.pending}set loading(e){this._loading=e}get loading(){return typeof this._loading=="boolean"?this._loading:this.loadingChoices?"choices":!1}get status(){return this.cancelled?"cancelled":this.submitted?"submitted":"pending"}};Mie.exports=Uie});var jie=w((Rpt,Hie)=>{"use strict";var lN=Xi(),Fi=Co(),cN={default:Fi.noop,noop:Fi.noop,set inverse(t){this._inverse=t},get inverse(){return this._inverse||lN.inverse(this.primary)},set complement(t){this._complement=t},get complement(){return this._complement||lN.complement(this.primary)},primary:Fi.cyan,success:Fi.green,danger:Fi.magenta,strong:Fi.bold,warning:Fi.yellow,muted:Fi.dim,disabled:Fi.gray,dark:Fi.dim.gray,underline:Fi.underline,set info(t){this._info=t},get info(){return this._info||this.primary},set em(t){this._em=t},get em(){return this._em||this.primary.underline},set heading(t){this._heading=t},get heading(){return this._heading||this.muted.underline},set pending(t){this._pending=t},get pending(){return this._pending||this.primary},set submitted(t){this._submitted=t},get submitted(){return this._submitted||this.success},set cancelled(t){this._cancelled=t},get cancelled(){return this._cancelled||this.danger},set typing(t){this._typing=t},get typing(){return this._typing||this.dim},set placeholder(t){this._placeholder=t},get placeholder(){return this._placeholder||this.primary.dim},set highlight(t){this._highlight=t},get highlight(){return this._highlight||this.inverse}};cN.merge=(t={})=>{t.styles&&typeof t.styles.enabled=="boolean"&&(Fi.enabled=t.styles.enabled),t.styles&&typeof t.styles.visible=="boolean"&&(Fi.visible=t.styles.visible);let e=lN.merge({},cN,t.styles);delete e.merge;for(let r of Object.keys(Fi))e.hasOwnProperty(r)||Reflect.defineProperty(e,r,{get:()=>Fi[r]});for(let r of Object.keys(Fi.styles))e.hasOwnProperty(r)||Reflect.defineProperty(e,r,{get:()=>Fi[r]});return e};Hie.exports=cN});var Yie=w((Fpt,Gie)=>{"use strict";var uN=process.platform==="win32",CA=Co(),Bqe=Xi(),gN=te(N({},CA.symbols),{upDownDoubleArrow:"\u21D5",upDownDoubleArrow2:"\u2B0D",upDownArrow:"\u2195",asterisk:"*",asterism:"\u2042",bulletWhite:"\u25E6",electricArrow:"\u2301",ellipsisLarge:"\u22EF",ellipsisSmall:"\u2026",fullBlock:"\u2588",identicalTo:"\u2261",indicator:CA.symbols.check,leftAngle:"\u2039",mark:"\u203B",minus:"\u2212",multiplication:"\xD7",obelus:"\xF7",percent:"%",pilcrow:"\xB6",pilcrow2:"\u2761",pencilUpRight:"\u2710",pencilDownRight:"\u270E",pencilRight:"\u270F",plus:"+",plusMinus:"\xB1",pointRight:"\u261E",rightAngle:"\u203A",section:"\xA7",hexagon:{off:"\u2B21",on:"\u2B22",disabled:"\u2B22"},ballot:{on:"\u2611",off:"\u2610",disabled:"\u2612"},stars:{on:"\u2605",off:"\u2606",disabled:"\u2606"},folder:{on:"\u25BC",off:"\u25B6",disabled:"\u25B6"},prefix:{pending:CA.symbols.question,submitted:CA.symbols.check,cancelled:CA.symbols.cross},separator:{pending:CA.symbols.pointerSmall,submitted:CA.symbols.middot,cancelled:CA.symbols.middot},radio:{off:uN?"( )":"\u25EF",on:uN?"(*)":"\u25C9",disabled:uN?"(|)":"\u24BE"},numbers:["\u24EA","\u2460","\u2461","\u2462","\u2463","\u2464","\u2465","\u2466","\u2467","\u2468","\u2469","\u246A","\u246B","\u246C","\u246D","\u246E","\u246F","\u2470","\u2471","\u2472","\u2473","\u3251","\u3252","\u3253","\u3254","\u3255","\u3256","\u3257","\u3258","\u3259","\u325A","\u325B","\u325C","\u325D","\u325E","\u325F","\u32B1","\u32B2","\u32B3","\u32B4","\u32B5","\u32B6","\u32B7","\u32B8","\u32B9","\u32BA","\u32BB","\u32BC","\u32BD","\u32BE","\u32BF"]});gN.merge=t=>{let e=Bqe.merge({},CA.symbols,gN,t.symbols);return delete e.merge,e};Gie.exports=gN});var Jie=w((Npt,qie)=>{"use strict";var bqe=jie(),Qqe=Yie(),vqe=Xi();qie.exports=t=>{t.options=vqe.merge({},t.options.theme,t.options),t.symbols=Qqe.merge(t.options),t.styles=bqe.merge(t.options)}});var Xie=w((Wie,zie)=>{"use strict";var _ie=process.env.TERM_PROGRAM==="Apple_Terminal",Sqe=Co(),fN=Xi(),mo=zie.exports=Wie,Nr="\e[",Vie="\x07",hN=!1,Sl=mo.code={bell:Vie,beep:Vie,beginning:`${Nr}G`,down:`${Nr}J`,esc:Nr,getPosition:`${Nr}6n`,hide:`${Nr}?25l`,line:`${Nr}2K`,lineEnd:`${Nr}K`,lineStart:`${Nr}1K`,restorePosition:Nr+(_ie?"8":"u"),savePosition:Nr+(_ie?"7":"s"),screen:`${Nr}2J`,show:`${Nr}?25h`,up:`${Nr}1J`},Cu=mo.cursor={get hidden(){return hN},hide(){return hN=!0,Sl.hide},show(){return hN=!1,Sl.show},forward:(t=1)=>`${Nr}${t}C`,backward:(t=1)=>`${Nr}${t}D`,nextLine:(t=1)=>`${Nr}E`.repeat(t),prevLine:(t=1)=>`${Nr}F`.repeat(t),up:(t=1)=>t?`${Nr}${t}A`:"",down:(t=1)=>t?`${Nr}${t}B`:"",right:(t=1)=>t?`${Nr}${t}C`:"",left:(t=1)=>t?`${Nr}${t}D`:"",to(t,e){return e?`${Nr}${e+1};${t+1}H`:`${Nr}${t+1}G`},move(t=0,e=0){let r="";return r+=t<0?Cu.left(-t):t>0?Cu.right(t):"",r+=e<0?Cu.up(-e):e>0?Cu.down(e):"",r},restore(t={}){let{after:e,cursor:r,initial:i,input:n,prompt:s,size:o,value:a}=t;if(i=fN.isPrimitive(i)?String(i):"",n=fN.isPrimitive(n)?String(n):"",a=fN.isPrimitive(a)?String(a):"",o){let l=mo.cursor.up(o)+mo.cursor.to(s.length),c=n.length-r;return c>0&&(l+=mo.cursor.left(c)),l}if(a||e){let l=!n&&!!i?-i.length:-n.length+r;return e&&(l-=e.length),n===""&&i&&!s.includes(i)&&(l+=i.length),mo.cursor.move(l)}}},pN=mo.erase={screen:Sl.screen,up:Sl.up,down:Sl.down,line:Sl.line,lineEnd:Sl.lineEnd,lineStart:Sl.lineStart,lines(t){let e="";for(let r=0;r<t;r++)e+=mo.erase.line+(r<t-1?mo.cursor.up(1):"");return t&&(e+=mo.code.beginning),e}};mo.clear=(t="",e=process.stdout.columns)=>{if(!e)return pN.line+Cu.to(0);let r=s=>[...Sqe.unstyle(s)].length,i=t.split(/\r?\n/),n=0;for(let s of i)n+=1+Math.floor(Math.max(r(s)-1,0)/e);return(pN.line+Cu.prevLine()).repeat(n-1)+pN.line+Cu.to(0)}});var zf=w((Lpt,Zie)=>{"use strict";var kqe=require("events"),$ie=Co(),dN=Lie(),xqe=Oie(),Pqe=Kie(),Dqe=Jie(),Tn=Xi(),mu=Xie(),_0=class extends kqe{constructor(e={}){super();this.name=e.name,this.type=e.type,this.options=e,Dqe(this),xqe(this),this.state=new Pqe(this),this.initial=[e.initial,e.default].find(r=>r!=null),this.stdout=e.stdout||process.stdout,this.stdin=e.stdin||process.stdin,this.scale=e.scale||1,this.term=this.options.term||process.env.TERM_PROGRAM,this.margin=Fqe(this.options.margin),this.setMaxListeners(0),Rqe(this)}async keypress(e,r={}){this.keypressed=!0;let i=dN.action(e,dN(e,r),this.options.actions);this.state.keypress=i,this.emit("keypress",e,i),this.emit("state",this.state.clone());let n=this.options[i.action]||this[i.action]||this.dispatch;if(typeof n=="function")return await n.call(this,e,i);this.alert()}alert(){delete this.state.alert,this.options.show===!1?this.emit("alert"):this.stdout.write(mu.code.beep)}cursorHide(){this.stdout.write(mu.cursor.hide()),Tn.onExit(()=>this.cursorShow())}cursorShow(){this.stdout.write(mu.cursor.show())}write(e){!e||(this.stdout&&this.state.show!==!1&&this.stdout.write(e),this.state.buffer+=e)}clear(e=0){let r=this.state.buffer;this.state.buffer="",!(!r&&!e||this.options.show===!1)&&this.stdout.write(mu.cursor.down(e)+mu.clear(r,this.width))}restore(){if(this.state.closed||this.options.show===!1)return;let{prompt:e,after:r,rest:i}=this.sections(),{cursor:n,initial:s="",input:o="",value:a=""}=this,l=this.state.size=i.length,c={after:r,cursor:n,initial:s,input:o,prompt:e,size:l,value:a},u=mu.cursor.restore(c);u&&this.stdout.write(u)}sections(){let{buffer:e,input:r,prompt:i}=this.state;i=$ie.unstyle(i);let n=$ie.unstyle(e),s=n.indexOf(i),o=n.slice(0,s),l=n.slice(s).split(`
+`),c=l[0],u=l[l.length-1],f=(i+(r?" "+r:"")).length,h=f<c.length?c.slice(f+1):"";return{header:o,prompt:c,after:h,rest:l.slice(1),last:u}}async submit(){this.state.submitted=!0,this.state.validating=!0,this.options.onSubmit&&await this.options.onSubmit.call(this,this.name,this.value,this);let e=this.state.error||await this.validate(this.value,this.state);if(e!==!0){let r=`
+`+this.symbols.pointer+" ";typeof e=="string"?r+=e.trim():r+="Invalid input",this.state.error=`
+`+this.styles.danger(r),this.state.submitted=!1,await this.render(),await this.alert(),this.state.validating=!1,this.state.error=void 0;return}this.state.validating=!1,await this.render(),await this.close(),this.value=await this.result(this.value),this.emit("submit",this.value)}async cancel(e){this.state.cancelled=this.state.submitted=!0,await this.render(),await this.close(),typeof this.options.onCancel=="function"&&await this.options.onCancel.call(this,this.name,this.value,this),this.emit("cancel",await this.error(e))}async close(){this.state.closed=!0;try{let e=this.sections(),r=Math.ceil(e.prompt.length/this.width);e.rest&&this.write(mu.cursor.down(e.rest.length)),this.write(`
+`.repeat(r))}catch(e){}this.emit("close")}start(){!this.stop&&this.options.show!==!1&&(this.stop=dN.listen(this,this.keypress.bind(this)),this.once("close",this.stop))}async skip(){return this.skipped=this.options.skip===!0,typeof this.options.skip=="function"&&(this.skipped=await this.options.skip.call(this,this.name,this.value)),this.skipped}async initialize(){let{format:e,options:r,result:i}=this;if(this.format=()=>e.call(this,this.value),this.result=()=>i.call(this,this.value),typeof r.initial=="function"&&(this.initial=await r.initial.call(this,this)),typeof r.onRun=="function"&&await r.onRun.call(this,this),typeof r.onSubmit=="function"){let n=r.onSubmit.bind(this),s=this.submit.bind(this);delete this.options.onSubmit,this.submit=async()=>(await n(this.name,this.value,this),s())}await this.start(),await this.render()}render(){throw new Error("expected prompt to have a custom render method")}run(){return new Promise(async(e,r)=>{if(this.once("submit",e),this.once("cancel",r),await this.skip())return this.render=()=>{},this.submit();await this.initialize(),this.emit("run")})}async element(e,r,i){let{options:n,state:s,symbols:o,timers:a}=this,l=a&&a[e];s.timer=l;let c=n[e]||s[e]||o[e],u=r&&r[e]!=null?r[e]:await c;if(u==="")return u;let g=await this.resolve(u,s,r,i);return!g&&r&&r[e]?this.resolve(c,s,r,i):g}async prefix(){let e=await this.element("prefix")||this.symbols,r=this.timers&&this.timers.prefix,i=this.state;return i.timer=r,Tn.isObject(e)&&(e=e[i.status]||e.pending),Tn.hasColor(e)?e:(this.styles[i.status]||this.styles.pending)(e)}async message(){let e=await this.element("message");return Tn.hasColor(e)?e:this.styles.strong(e)}async separator(){let e=await this.element("separator")||this.symbols,r=this.timers&&this.timers.separator,i=this.state;i.timer=r;let n=e[i.status]||e.pending||i.separator,s=await this.resolve(n,i);return Tn.isObject(s)&&(s=s[i.status]||s.pending),Tn.hasColor(s)?s:this.styles.muted(s)}async pointer(e,r){let i=await this.element("pointer",e,r);if(typeof i=="string"&&Tn.hasColor(i))return i;if(i){let n=this.styles,s=this.index===r,o=s?n.primary:c=>c,a=await this.resolve(i[s?"on":"off"]||i,this.state),l=Tn.hasColor(a)?a:o(a);return s?l:" ".repeat(a.length)}}async indicator(e,r){let i=await this.element("indicator",e,r);if(typeof i=="string"&&Tn.hasColor(i))return i;if(i){let n=this.styles,s=e.enabled===!0,o=s?n.success:n.dark,a=i[s?"on":"off"]||i;return Tn.hasColor(a)?a:o(a)}return""}body(){return null}footer(){if(this.state.status==="pending")return this.element("footer")}header(){if(this.state.status==="pending")return this.element("header")}async hint(){if(this.state.status==="pending"&&!this.isValue(this.state.input)){let e=await this.element("hint");return Tn.hasColor(e)?e:this.styles.muted(e)}}error(e){return this.state.submitted?"":e||this.state.error}format(e){return e}result(e){return e}validate(e){return this.options.required===!0?this.isValue(e):!0}isValue(e){return e!=null&&e!==""}resolve(e,...r){return Tn.resolve(this,e,...r)}get base(){return _0.prototype}get style(){return this.styles[this.state.status]}get height(){return this.options.rows||Tn.height(this.stdout,25)}get width(){return this.options.columns||Tn.width(this.stdout,80)}get size(){return{width:this.width,height:this.height}}set cursor(e){this.state.cursor=e}get cursor(){return this.state.cursor}set input(e){this.state.input=e}get input(){return this.state.input}set value(e){this.state.value=e}get value(){let{input:e,value:r}=this.state,i=[r,e].find(this.isValue.bind(this));return this.isValue(i)?i:this.initial}static get prompt(){return e=>new this(e).run()}};function Rqe(t){let e=n=>t[n]===void 0||typeof t[n]=="function",r=["actions","choices","initial","margin","roles","styles","symbols","theme","timers","value"],i=["body","footer","error","header","hint","indicator","message","prefix","separator","skip"];for(let n of Object.keys(t.options)){if(r.includes(n)||/^on[A-Z]/.test(n))continue;let s=t.options[n];typeof s=="function"&&e(n)?i.includes(n)||(t[n]=s.bind(t)):typeof t[n]!="function"&&(t[n]=s)}}function Fqe(t){typeof t=="number"&&(t=[t,t,t,t]);let e=[].concat(t||[]),r=n=>n%2==0?`
+`:" ",i=[];for(let n=0;n<4;n++){let s=r(n);e[n]?i.push(s.repeat(e[n])):i.push("")}return i}Zie.exports=_0});var rne=w((Tpt,ene)=>{"use strict";var Nqe=Xi(),tne={default(t,e){return e},checkbox(t,e){throw new Error("checkbox role is not implemented yet")},editable(t,e){throw new Error("editable role is not implemented yet")},expandable(t,e){throw new Error("expandable role is not implemented yet")},heading(t,e){return e.disabled="",e.indicator=[e.indicator," "].find(r=>r!=null),e.message=e.message||"",e},input(t,e){throw new Error("input role is not implemented yet")},option(t,e){return tne.default(t,e)},radio(t,e){throw new Error("radio role is not implemented yet")},separator(t,e){return e.disabled="",e.indicator=[e.indicator," "].find(r=>r!=null),e.message=e.message||t.symbols.line.repeat(5),e},spacer(t,e){return e}};ene.exports=(t,e={})=>{let r=Nqe.merge({},tne,e.roles);return r[t]||r.default}});var XC=w((Opt,ine)=>{"use strict";var Lqe=Co(),Tqe=zf(),Oqe=rne(),V0=Xi(),{reorder:CN,scrollUp:Mqe,scrollDown:Uqe,isObject:nne,swap:Kqe}=V0,sne=class extends Tqe{constructor(e){super(e);this.cursorHide(),this.maxSelected=e.maxSelected||Infinity,this.multiple=e.multiple||!1,this.initial=e.initial||0,this.delay=e.delay||0,this.longest=0,this.num=""}async initialize(){typeof this.options.initial=="function"&&(this.initial=await this.options.initial.call(this)),await this.reset(!0),await super.initialize()}async reset(){let{choices:e,initial:r,autofocus:i,suggest:n}=this.options;if(this.state._choices=[],this.state.choices=[],this.choices=await Promise.all(await this.toChoices(e)),this.choices.forEach(s=>s.enabled=!1),typeof n!="function"&&this.selectable.length===0)throw new Error("At least one choice must be selectable");nne(r)&&(r=Object.keys(r)),Array.isArray(r)?(i!=null&&(this.index=this.findIndex(i)),r.forEach(s=>this.enable(this.find(s))),await this.render()):(i!=null&&(r=i),typeof r=="string"&&(r=this.findIndex(r)),typeof r=="number"&&r>-1&&(this.index=Math.max(0,Math.min(r,this.choices.length)),this.enable(this.find(this.index)))),this.isDisabled(this.focused)&&await this.down()}async toChoices(e,r){this.state.loadingChoices=!0;let i=[],n=0,s=async(o,a)=>{typeof o=="function"&&(o=await o.call(this)),o instanceof Promise&&(o=await o);for(let l=0;l<o.length;l++){let c=o[l]=await this.toChoice(o[l],n++,a);i.push(c),c.choices&&await s(c.choices,c)}return i};return s(e,r).then(o=>(this.state.loadingChoices=!1,o))}async toChoice(e,r,i){if(typeof e=="function"&&(e=await e.call(this,this)),e instanceof Promise&&(e=await e),typeof e=="string"&&(e={name:e}),e.normalized)return e;e.normalized=!0;let n=e.value;if(e=Oqe(e.role,this.options)(this,e),typeof e.disabled=="string"&&!e.hint&&(e.hint=e.disabled,e.disabled=!0),e.disabled===!0&&e.hint==null&&(e.hint="(disabled)"),e.index!=null)return e;e.name=e.name||e.key||e.title||e.value||e.message,e.message=e.message||e.name||"",e.value=[e.value,e.name].find(this.isValue.bind(this)),e.input="",e.index=r,e.cursor=0,V0.define(e,"parent",i),e.level=i?i.level+1:1,e.indent==null&&(e.indent=i?i.indent+"  ":e.indent||""),e.path=i?i.path+"."+e.name:e.name,e.enabled=!!(this.multiple&&!this.isDisabled(e)&&(e.enabled||this.isSelected(e))),this.isDisabled(e)||(this.longest=Math.max(this.longest,Lqe.unstyle(e.message).length));let o=N({},e);return e.reset=(a=o.input,l=o.value)=>{for(let c of Object.keys(o))e[c]=o[c];e.input=a,e.value=l},n==null&&typeof e.initial=="function"&&(e.input=await e.initial.call(this,this.state,e,r)),e}async onChoice(e,r){this.emit("choice",e,r,this),typeof e.onChoice=="function"&&await e.onChoice.call(this,this.state,e,r)}async addChoice(e,r,i){let n=await this.toChoice(e,r,i);return this.choices.push(n),this.index=this.choices.length-1,this.limit=this.choices.length,n}async newItem(e,r,i){let n=N({name:"New choice name?",editable:!0,newChoice:!0},e),s=await this.addChoice(n,r,i);return s.updateChoice=()=>{delete s.newChoice,s.name=s.message=s.input,s.input="",s.cursor=0},this.render()}indent(e){return e.indent==null?e.level>1?"  ".repeat(e.level-1):"":e.indent}dispatch(e,r){if(this.multiple&&this[r.name])return this[r.name]();this.alert()}focus(e,r){return typeof r!="boolean"&&(r=e.enabled),r&&!e.enabled&&this.selected.length>=this.maxSelected?this.alert():(this.index=e.index,e.enabled=r&&!this.isDisabled(e),e)}space(){return this.multiple?(this.toggle(this.focused),this.render()):this.alert()}a(){if(this.maxSelected<this.choices.length)return this.alert();let e=this.selectable.every(r=>r.enabled);return this.choices.forEach(r=>r.enabled=!e),this.render()}i(){return this.choices.length-this.selected.length>this.maxSelected?this.alert():(this.choices.forEach(e=>e.enabled=!e.enabled),this.render())}g(e=this.focused){return this.choices.some(r=>!!r.parent)?(this.toggle(e.parent&&!e.choices?e.parent:e),this.render()):this.a()}toggle(e,r){if(!e.enabled&&this.selected.length>=this.maxSelected)return this.alert();typeof r!="boolean"&&(r=!e.enabled),e.enabled=r,e.choices&&e.choices.forEach(n=>this.toggle(n,r));let i=e.parent;for(;i;){let n=i.choices.filter(s=>this.isDisabled(s));i.enabled=n.every(s=>s.enabled===!0),i=i.parent}return one(this,this.choices),this.emit("toggle",e,this),e}enable(e){return this.selected.length>=this.maxSelected?this.alert():(e.enabled=!this.isDisabled(e),e.choices&&e.choices.forEach(this.enable.bind(this)),e)}disable(e){return e.enabled=!1,e.choices&&e.choices.forEach(this.disable.bind(this)),e}number(e){this.num+=e;let r=i=>{let n=Number(i);if(n>this.choices.length-1)return this.alert();let s=this.focused,o=this.choices.find(a=>n===a.index);if(!o.enabled&&this.selected.length>=this.maxSelected)return this.alert();if(this.visible.indexOf(o)===-1){let a=CN(this.choices),l=a.indexOf(o);if(s.index>l){let c=a.slice(l,l+this.limit),u=a.filter(g=>!c.includes(g));this.choices=c.concat(u)}else{let c=l-this.limit+1;this.choices=a.slice(c).concat(a.slice(0,c))}}return this.index=this.choices.indexOf(o),this.toggle(this.focused),this.render()};return clearTimeout(this.numberTimeout),new Promise(i=>{let n=this.choices.length,s=this.num,o=(a=!1,l)=>{clearTimeout(this.numberTimeout),a&&(l=r(s)),this.num="",i(l)};if(s==="0"||s.length===1&&Number(s+"0")>n)return o(!0);if(Number(s)>n)return o(!1,this.alert());this.numberTimeout=setTimeout(()=>o(!0),this.delay)})}home(){return this.choices=CN(this.choices),this.index=0,this.render()}end(){let e=this.choices.length-this.limit,r=CN(this.choices);return this.choices=r.slice(e).concat(r.slice(0,e)),this.index=this.limit-1,this.render()}first(){return this.index=0,this.render()}last(){return this.index=this.visible.length-1,this.render()}prev(){return this.visible.length<=1?this.alert():this.up()}next(){return this.visible.length<=1?this.alert():this.down()}right(){return this.cursor>=this.input.length?this.alert():(this.cursor++,this.render())}left(){return this.cursor<=0?this.alert():(this.cursor--,this.render())}up(){let e=this.choices.length,r=this.visible.length,i=this.index;return this.options.scroll===!1&&i===0?this.alert():e>r&&i===0?this.scrollUp():(this.index=(i-1%e+e)%e,this.isDisabled()?this.up():this.render())}down(){let e=this.choices.length,r=this.visible.length,i=this.index;return this.options.scroll===!1&&i===r-1?this.alert():e>r&&i===r-1?this.scrollDown():(this.index=(i+1)%e,this.isDisabled()?this.down():this.render())}scrollUp(e=0){return this.choices=Mqe(this.choices),this.index=e,this.isDisabled()?this.up():this.render()}scrollDown(e=this.visible.length-1){return this.choices=Uqe(this.choices),this.index=e,this.isDisabled()?this.down():this.render()}async shiftUp(){if(this.options.sort===!0){this.sorting=!0,this.swap(this.index-1),await this.up(),this.sorting=!1;return}return this.scrollUp(this.index)}async shiftDown(){if(this.options.sort===!0){this.sorting=!0,this.swap(this.index+1),await this.down(),this.sorting=!1;return}return this.scrollDown(this.index)}pageUp(){return this.visible.length<=1?this.alert():(this.limit=Math.max(this.limit-1,0),this.index=Math.min(this.limit-1,this.index),this._limit=this.limit,this.isDisabled()?this.up():this.render())}pageDown(){return this.visible.length>=this.choices.length?this.alert():(this.index=Math.max(0,this.index),this.limit=Math.min(this.limit+1,this.choices.length),this._limit=this.limit,this.isDisabled()?this.down():this.render())}swap(e){Kqe(this.choices,this.index,e)}isDisabled(e=this.focused){return e&&["disabled","collapsed","hidden","completing","readonly"].some(i=>e[i]===!0)?!0:e&&e.role==="heading"}isEnabled(e=this.focused){if(Array.isArray(e))return e.every(r=>this.isEnabled(r));if(e.choices){let r=e.choices.filter(i=>!this.isDisabled(i));return e.enabled&&r.every(i=>this.isEnabled(i))}return e.enabled&&!this.isDisabled(e)}isChoice(e,r){return e.name===r||e.index===Number(r)}isSelected(e){return Array.isArray(this.initial)?this.initial.some(r=>this.isChoice(e,r)):this.isChoice(e,this.initial)}map(e=[],r="value"){return[].concat(e||[]).reduce((i,n)=>(i[n]=this.find(n,r),i),{})}filter(e,r){let i=(a,l)=>[a.name,l].includes(e),n=typeof e=="function"?e:i,o=(this.options.multiple?this.state._choices:this.choices).filter(n);return r?o.map(a=>a[r]):o}find(e,r){if(nne(e))return r?e[r]:e;let i=(o,a)=>[o.name,a].includes(e),n=typeof e=="function"?e:i,s=this.choices.find(n);if(s)return r?s[r]:s}findIndex(e){return this.choices.indexOf(this.find(e))}async submit(){let e=this.focused;if(!e)return this.alert();if(e.newChoice)return e.input?(e.updateChoice(),this.render()):this.alert();if(this.choices.some(o=>o.newChoice))return this.alert();let{reorder:r,sort:i}=this.options,n=this.multiple===!0,s=this.selected;return s===void 0?this.alert():(Array.isArray(s)&&r!==!1&&i!==!0&&(s=V0.reorder(s)),this.value=n?s.map(o=>o.name):s.name,super.submit())}set choices(e=[]){this.state._choices=this.state._choices||[],this.state.choices=e;for(let r of e)this.state._choices.some(i=>i.name===r.name)||this.state._choices.push(r);if(!this._initial&&this.options.initial){this._initial=!0;let r=this.initial;if(typeof r=="string"||typeof r=="number"){let i=this.find(r);i&&(this.initial=i.index,this.focus(i,!0))}}}get choices(){return one(this,this.state.choices||[])}set visible(e){this.state.visible=e}get visible(){return(this.state.visible||this.choices).slice(0,this.limit)}set limit(e){this.state.limit=e}get limit(){let{state:e,options:r,choices:i}=this,n=e.limit||this._limit||r.limit||i.length;return Math.min(n,this.height)}set value(e){super.value=e}get value(){return typeof super.value!="string"&&super.value===this.initial?this.input:super.value}set index(e){this.state.index=e}get index(){return Math.max(0,this.state?this.state.index:0)}get enabled(){return this.filter(this.isEnabled.bind(this))}get focused(){let e=this.choices[this.index];return e&&this.state.submitted&&this.multiple!==!0&&(e.enabled=!0),e}get selectable(){return this.choices.filter(e=>!this.isDisabled(e))}get selected(){return this.multiple?this.enabled:this.focused}};function one(t,e){if(e instanceof Promise)return e;if(typeof e=="function"){if(V0.isAsyncFn(e))return e;e=e.call(t,t)}for(let r of e){if(Array.isArray(r.choices)){let i=r.choices.filter(n=>!t.isDisabled(n));r.enabled=i.every(n=>n.enabled===!0)}t.isDisabled(r)===!0&&delete r.enabled}return e}ine.exports=sne});var kl=w((Mpt,ane)=>{"use strict";var Hqe=XC(),mN=Xi(),Ane=class extends Hqe{constructor(e){super(e);this.emptyError=this.options.emptyError||"No items were selected"}async dispatch(e,r){if(this.multiple)return this[r.name]?await this[r.name](e,r):await super.dispatch(e,r);this.alert()}separator(){if(this.options.separator)return super.separator();let e=this.styles.muted(this.symbols.ellipsis);return this.state.submitted?super.separator():e}pointer(e,r){return!this.multiple||this.options.pointer?super.pointer(e,r):""}indicator(e,r){return this.multiple?super.indicator(e,r):""}choiceMessage(e,r){let i=this.resolve(e.message,this.state,e,r);return e.role==="heading"&&!mN.hasColor(i)&&(i=this.styles.strong(i)),this.resolve(i,this.state,e,r)}choiceSeparator(){return":"}async renderChoice(e,r){await this.onChoice(e,r);let i=this.index===r,n=await this.pointer(e,r),s=await this.indicator(e,r)+(e.pad||""),o=await this.resolve(e.hint,this.state,e,r);o&&!mN.hasColor(o)&&(o=this.styles.muted(o));let a=this.indent(e),l=await this.choiceMessage(e,r),c=()=>[this.margin[3],a+n+s,l,this.margin[1],o].filter(Boolean).join(" ");return e.role==="heading"?c():e.disabled?(mN.hasColor(l)||(l=this.styles.disabled(l)),c()):(i&&(l=this.styles.em(l)),c())}async renderChoices(){if(this.state.loading==="choices")return this.styles.warning("Loading choices");if(this.state.submitted)return"";let e=this.visible.map(async(s,o)=>await this.renderChoice(s,o)),r=await Promise.all(e);r.length||r.push(this.styles.danger("No matching choices"));let i=this.margin[0]+r.join(`
+`),n;return this.options.choicesHeader&&(n=await this.resolve(this.options.choicesHeader,this.state)),[n,i].filter(Boolean).join(`
+`)}format(){return!this.state.submitted||this.state.cancelled?"":Array.isArray(this.selected)?this.selected.map(e=>this.styles.primary(e.name)).join(", "):this.styles.primary(this.selected.name)}async render(){let{submitted:e,size:r}=this.state,i="",n=await this.header(),s=await this.prefix(),o=await this.separator(),a=await this.message();this.options.promptLine!==!1&&(i=[s,a,o,""].join(" "),this.state.prompt=i);let l=await this.format(),c=await this.error()||await this.hint(),u=await this.renderChoices(),g=await this.footer();l&&(i+=l),c&&!i.includes(c)&&(i+=" "+c),e&&!l&&!u.trim()&&this.multiple&&this.emptyError!=null&&(i+=this.styles.danger(this.emptyError)),this.clear(r),this.write([n,i,u,g].filter(Boolean).join(`
+`)),this.write(this.margin[2]),this.restore()}};ane.exports=Ane});var une=w((Upt,lne)=>{"use strict";var jqe=kl(),Gqe=(t,e)=>{let r=t.toLowerCase();return i=>{let s=i.toLowerCase().indexOf(r),o=e(i.slice(s,s+r.length));return s>=0?i.slice(0,s)+o+i.slice(s+r.length):i}},cne=class extends jqe{constructor(e){super(e);this.cursorShow()}moveCursor(e){this.state.cursor+=e}dispatch(e){return this.append(e)}space(e){return this.options.multiple?super.space(e):this.append(e)}append(e){let{cursor:r,input:i}=this.state;return this.input=i.slice(0,r)+e+i.slice(r),this.moveCursor(1),this.complete()}delete(){let{cursor:e,input:r}=this.state;return r?(this.input=r.slice(0,e-1)+r.slice(e),this.moveCursor(-1),this.complete()):this.alert()}deleteForward(){let{cursor:e,input:r}=this.state;return r[e]===void 0?this.alert():(this.input=`${r}`.slice(0,e)+`${r}`.slice(e+1),this.complete())}number(e){return this.append(e)}async complete(){this.completing=!0,this.choices=await this.suggest(this.input,this.state._choices),this.state.limit=void 0,this.index=Math.min(Math.max(this.visible.length-1,0),this.index),await this.render(),this.completing=!1}suggest(e=this.input,r=this.state._choices){if(typeof this.options.suggest=="function")return this.options.suggest.call(this,e,r);let i=e.toLowerCase();return r.filter(n=>n.message.toLowerCase().includes(i))}pointer(){return""}format(){if(!this.focused)return this.input;if(this.options.multiple&&this.state.submitted)return this.selected.map(e=>this.styles.primary(e.message)).join(", ");if(this.state.submitted){let e=this.value=this.input=this.focused.value;return this.styles.primary(e)}return this.input}async render(){if(this.state.status!=="pending")return super.render();let e=this.options.highlight?this.options.highlight.bind(this):this.styles.placeholder,r=Gqe(this.input,e),i=this.choices;this.choices=i.map(n=>te(N({},n),{message:r(n.message)})),await super.render(),this.choices=i}submit(){return this.options.multiple&&(this.value=this.selected.map(e=>e.name)),super.submit()}};lne.exports=cne});var IN=w((Kpt,gne)=>{"use strict";var EN=Xi();gne.exports=(t,e={})=>{t.cursorHide();let{input:r="",initial:i="",pos:n,showCursor:s=!0,color:o}=e,a=o||t.styles.placeholder,l=EN.inverse(t.styles.primary),c=m=>l(t.styles.black(m)),u=r,g=" ",f=c(g);if(t.blink&&t.blink.off===!0&&(c=m=>m,f=""),s&&n===0&&i===""&&r==="")return c(g);if(s&&n===0&&(r===i||r===""))return c(i[0])+a(i.slice(1));i=EN.isPrimitive(i)?`${i}`:"",r=EN.isPrimitive(r)?`${r}`:"";let h=i&&i.startsWith(r)&&i!==r,p=h?c(i[r.length]):f;if(n!==r.length&&s===!0&&(u=r.slice(0,n)+c(r[n])+r.slice(n+1),p=""),s===!1&&(p=""),h){let m=t.styles.unstyle(u+p);return u+p+a(i.slice(m.length))}return u+p}});var X0=w((Hpt,fne)=>{"use strict";var Yqe=Co(),qqe=kl(),Jqe=IN(),hne=class extends qqe{constructor(e){super(te(N({},e),{multiple:!0}));this.type="form",this.initial=this.options.initial,this.align=[this.options.align,"right"].find(r=>r!=null),this.emptyError="",this.values={}}async reset(e){return await super.reset(),e===!0&&(this._index=this.index),this.index=this._index,this.values={},this.choices.forEach(r=>r.reset&&r.reset()),this.render()}dispatch(e){return!!e&&this.append(e)}append(e){let r=this.focused;if(!r)return this.alert();let{cursor:i,input:n}=r;return r.value=r.input=n.slice(0,i)+e+n.slice(i),r.cursor++,this.render()}delete(){let e=this.focused;if(!e||e.cursor<=0)return this.alert();let{cursor:r,input:i}=e;return e.value=e.input=i.slice(0,r-1)+i.slice(r),e.cursor--,this.render()}deleteForward(){let e=this.focused;if(!e)return this.alert();let{cursor:r,input:i}=e;if(i[r]===void 0)return this.alert();let n=`${i}`.slice(0,r)+`${i}`.slice(r+1);return e.value=e.input=n,this.render()}right(){let e=this.focused;return e?e.cursor>=e.input.length?this.alert():(e.cursor++,this.render()):this.alert()}left(){let e=this.focused;return e?e.cursor<=0?this.alert():(e.cursor--,this.render()):this.alert()}space(e,r){return this.dispatch(e,r)}number(e,r){return this.dispatch(e,r)}next(){let e=this.focused;if(!e)return this.alert();let{initial:r,input:i}=e;return r&&r.startsWith(i)&&i!==r?(e.value=e.input=r,e.cursor=e.value.length,this.render()):super.next()}prev(){let e=this.focused;return e?e.cursor===0?super.prev():(e.value=e.input="",e.cursor=0,this.render()):this.alert()}separator(){return""}format(e){return this.state.submitted?"":super.format(e)}pointer(){return""}indicator(e){return e.input?"\u29BF":"\u2299"}async choiceSeparator(e,r){let i=await this.resolve(e.separator,this.state,e,r)||":";return i?" "+this.styles.disabled(i):""}async renderChoice(e,r){await this.onChoice(e,r);let{state:i,styles:n}=this,{cursor:s,initial:o="",name:a,hint:l,input:c=""}=e,{muted:u,submitted:g,primary:f,danger:h}=n,p=l,m=this.index===r,y=e.validate||(()=>!0),Q=await this.choiceSeparator(e,r),S=e.message;this.align==="right"&&(S=S.padStart(this.longest+1," ")),this.align==="left"&&(S=S.padEnd(this.longest+1," "));let x=this.values[a]=c||o,M=c?"success":"dark";await y.call(e,x,this.state)!==!0&&(M="danger");let U=n[M](await this.indicator(e,r))+(e.pad||""),J=this.indent(e),W=()=>[J,U,S+Q,c,p].filter(Boolean).join(" ");if(i.submitted)return S=Yqe.unstyle(S),c=g(c),p="",W();if(e.format)c=await e.format.call(this,c,e,r);else{let ee=this.styles.muted;c=Jqe(this,{input:c,initial:o,pos:s,showCursor:m,color:ee})}return this.isValue(c)||(c=this.styles.muted(this.symbols.ellipsis)),e.result&&(this.values[a]=await e.result.call(this,x,e,r)),m&&(S=f(S)),e.error?c+=(c?" ":"")+h(e.error.trim()):e.hint&&(c+=(c?" ":"")+u(e.hint.trim())),W()}async submit(){return this.value=this.values,super.base.submit.call(this)}};fne.exports=hne});var yN=w((jpt,pne)=>{"use strict";var Wqe=X0(),zqe=()=>{throw new Error("expected prompt to have a custom authenticate method")},dne=(t=zqe)=>{class e extends Wqe{constructor(i){super(i)}async submit(){this.value=await t.call(this,this.values,this.state),super.base.submit.call(this)}static create(i){return dne(i)}}return e};pne.exports=dne()});var Ene=w((Gpt,Cne)=>{"use strict";var _qe=yN();function Vqe(t,e){return t.username===this.options.username&&t.password===this.options.password}var mne=(t=Vqe)=>{let e=[{name:"username",message:"username"},{name:"password",message:"password",format(i){return this.options.showPassword?i:(this.state.submitted?this.styles.primary:this.styles.muted)(this.symbols.asterisk.repeat(i.length))}}];class r extends _qe.create(t){constructor(n){super(te(N({},n),{choices:e}))}static create(n){return mne(n)}}return r};Cne.exports=mne()});var Z0=w((Ypt,Ine)=>{"use strict";var Xqe=zf(),{isPrimitive:Zqe,hasColor:$qe}=Xi(),yne=class extends Xqe{constructor(e){super(e);this.cursorHide()}async initialize(){let e=await this.resolve(this.initial,this.state);this.input=await this.cast(e),await super.initialize()}dispatch(e){return this.isValue(e)?(this.input=e,this.submit()):this.alert()}format(e){let{styles:r,state:i}=this;return i.submitted?r.success(e):r.primary(e)}cast(e){return this.isTrue(e)}isTrue(e){return/^[ty1]/i.test(e)}isFalse(e){return/^[fn0]/i.test(e)}isValue(e){return Zqe(e)&&(this.isTrue(e)||this.isFalse(e))}async hint(){if(this.state.status==="pending"){let e=await this.element("hint");return $qe(e)?e:this.styles.muted(e)}}async render(){let{input:e,size:r}=this.state,i=await this.prefix(),n=await this.separator(),s=await this.message(),o=this.styles.muted(this.default),a=[i,s,o,n].filter(Boolean).join(" ");this.state.prompt=a;let l=await this.header(),c=this.value=this.cast(e),u=await this.format(c),g=await this.error()||await this.hint(),f=await this.footer();g&&!a.includes(g)&&(u+=" "+g),a+=" "+u,this.clear(r),this.write([l,a,f].filter(Boolean).join(`
+`)),this.restore()}set value(e){super.value=e}get value(){return this.cast(super.value)}};Ine.exports=yne});var bne=w((qpt,wne)=>{"use strict";var eJe=Z0(),Bne=class extends eJe{constructor(e){super(e);this.default=this.options.default||(this.initial?"(Y/n)":"(y/N)")}};wne.exports=Bne});var Sne=w((Jpt,Qne)=>{"use strict";var tJe=kl(),rJe=X0(),_f=rJe.prototype,vne=class extends tJe{constructor(e){super(te(N({},e),{multiple:!0}));this.align=[this.options.align,"left"].find(r=>r!=null),this.emptyError="",this.values={}}dispatch(e,r){let i=this.focused,n=i.parent||{};return!i.editable&&!n.editable&&(e==="a"||e==="i")?super[e]():_f.dispatch.call(this,e,r)}append(e,r){return _f.append.call(this,e,r)}delete(e,r){return _f.delete.call(this,e,r)}space(e){return this.focused.editable?this.append(e):super.space()}number(e){return this.focused.editable?this.append(e):super.number(e)}next(){return this.focused.editable?_f.next.call(this):super.next()}prev(){return this.focused.editable?_f.prev.call(this):super.prev()}async indicator(e,r){let i=e.indicator||"",n=e.editable?i:super.indicator(e,r);return await this.resolve(n,this.state,e,r)||""}indent(e){return e.role==="heading"?"":e.editable?" ":"  "}async renderChoice(e,r){return e.indent="",e.editable?_f.renderChoice.call(this,e,r):super.renderChoice(e,r)}error(){return""}footer(){return this.state.error}async validate(){let e=!0;for(let r of this.choices){if(typeof r.validate!="function"||r.role==="heading")continue;let i=r.parent?this.value[r.parent.name]:this.value;if(r.editable?i=r.value===r.name?r.initial||"":r.value:this.isDisabled(r)||(i=r.enabled===!0),e=await r.validate(i,this.state),e!==!0)break}return e!==!0&&(this.state.error=typeof e=="string"?e:"Invalid Input"),e}submit(){if(this.focused.newChoice===!0)return super.submit();if(this.choices.some(e=>e.newChoice))return this.alert();this.value={};for(let e of this.choices){let r=e.parent?this.value[e.parent.name]:this.value;if(e.role==="heading"){this.value[e.name]={};continue}e.editable?r[e.name]=e.value===e.name?e.initial||"":e.value:this.isDisabled(e)||(r[e.name]=e.enabled===!0)}return this.base.submit.call(this)}};Qne.exports=vne});var Eu=w((Wpt,kne)=>{"use strict";var iJe=zf(),nJe=IN(),{isPrimitive:sJe}=Xi(),xne=class extends iJe{constructor(e){super(e);this.initial=sJe(this.initial)?String(this.initial):"",this.initial&&this.cursorHide(),this.state.prevCursor=0,this.state.clipboard=[]}async keypress(e,r={}){let i=this.state.prevKeypress;return this.state.prevKeypress=r,this.options.multiline===!0&&r.name==="return"&&(!i||i.name!=="return")?this.append(`
+`,r):super.keypress(e,r)}moveCursor(e){this.cursor+=e}reset(){return this.input=this.value="",this.cursor=0,this.render()}dispatch(e,r){if(!e||r.ctrl||r.code)return this.alert();this.append(e)}append(e){let{cursor:r,input:i}=this.state;this.input=`${i}`.slice(0,r)+e+`${i}`.slice(r),this.moveCursor(String(e).length),this.render()}insert(e){this.append(e)}delete(){let{cursor:e,input:r}=this.state;if(e<=0)return this.alert();this.input=`${r}`.slice(0,e-1)+`${r}`.slice(e),this.moveCursor(-1),this.render()}deleteForward(){let{cursor:e,input:r}=this.state;if(r[e]===void 0)return this.alert();this.input=`${r}`.slice(0,e)+`${r}`.slice(e+1),this.render()}cutForward(){let e=this.cursor;if(this.input.length<=e)return this.alert();this.state.clipboard.push(this.input.slice(e)),this.input=this.input.slice(0,e),this.render()}cutLeft(){let e=this.cursor;if(e===0)return this.alert();let r=this.input.slice(0,e),i=this.input.slice(e),n=r.split(" ");this.state.clipboard.push(n.pop()),this.input=n.join(" "),this.cursor=this.input.length,this.input+=i,this.render()}paste(){if(!this.state.clipboard.length)return this.alert();this.insert(this.state.clipboard.pop()),this.render()}toggleCursor(){this.state.prevCursor?(this.cursor=this.state.prevCursor,this.state.prevCursor=0):(this.state.prevCursor=this.cursor,this.cursor=0),this.render()}first(){this.cursor=0,this.render()}last(){this.cursor=this.input.length-1,this.render()}next(){let e=this.initial!=null?String(this.initial):"";if(!e||!e.startsWith(this.input))return this.alert();this.input=this.initial,this.cursor=this.initial.length,this.render()}prev(){if(!this.input)return this.alert();this.reset()}backward(){return this.left()}forward(){return this.right()}right(){return this.cursor>=this.input.length?this.alert():(this.moveCursor(1),this.render())}left(){return this.cursor<=0?this.alert():(this.moveCursor(-1),this.render())}isValue(e){return!!e}async format(e=this.value){let r=await this.resolve(this.initial,this.state);return this.state.submitted?this.styles.submitted(e||r):nJe(this,{input:e,initial:r,pos:this.cursor})}async render(){let e=this.state.size,r=await this.prefix(),i=await this.separator(),n=await this.message(),s=[r,n,i].filter(Boolean).join(" ");this.state.prompt=s;let o=await this.header(),a=await this.format(),l=await this.error()||await this.hint(),c=await this.footer();l&&!a.includes(l)&&(a+=" "+l),s+=" "+a,this.clear(e),this.write([o,s,c].filter(Boolean).join(`
+`)),this.restore()}};kne.exports=xne});var Dne=w((zpt,Pne)=>{"use strict";var oJe=t=>t.filter((e,r)=>t.lastIndexOf(e)===r),$0=t=>oJe(t).filter(Boolean);Pne.exports=(t,e={},r="")=>{let{past:i=[],present:n=""}=e,s,o;switch(t){case"prev":case"undo":return s=i.slice(0,i.length-1),o=i[i.length-1]||"",{past:$0([r,...s]),present:o};case"next":case"redo":return s=i.slice(1),o=i[0]||"",{past:$0([...s,r]),present:o};case"save":return{past:$0([...i,r]),present:""};case"remove":return o=$0(i.filter(a=>a!==r)),n="",o.length&&(n=o.pop()),{past:o,present:n};default:throw new Error(`Invalid action: "${t}"`)}}});var wN=w((_pt,Rne)=>{"use strict";var aJe=Eu(),Fne=Dne(),Nne=class extends aJe{constructor(e){super(e);let r=this.options.history;if(r&&r.store){let i=r.values||this.initial;this.autosave=!!r.autosave,this.store=r.store,this.data=this.store.get("values")||{past:[],present:i},this.initial=this.data.present||this.data.past[this.data.past.length-1]}}completion(e){return this.store?(this.data=Fne(e,this.data,this.input),this.data.present?(this.input=this.data.present,this.cursor=this.input.length,this.render()):this.alert()):this.alert()}altUp(){return this.completion("prev")}altDown(){return this.completion("next")}prev(){return this.save(),super.prev()}save(){!this.store||(this.data=Fne("save",this.data,this.input),this.store.set("values",this.data))}submit(){return this.store&&this.autosave===!0&&this.save(),super.submit()}};Rne.exports=Nne});var One=w((Vpt,Lne)=>{"use strict";var AJe=Eu(),Tne=class extends AJe{format(){return""}};Lne.exports=Tne});var Kne=w((Xpt,Mne)=>{"use strict";var lJe=Eu(),Une=class extends lJe{constructor(e={}){super(e);this.sep=this.options.separator||/, */,this.initial=e.initial||""}split(e=this.value){return e?String(e).split(this.sep):[]}format(){let e=this.state.submitted?this.styles.primary:r=>r;return this.list.map(e).join(", ")}async submit(e){let r=this.state.error||await this.validate(this.list,this.state);return r!==!0?(this.state.error=r,super.submit()):(this.value=this.list,super.submit())}get list(){return this.split()}};Mne.exports=Une});var Gne=w((Zpt,Hne)=>{"use strict";var cJe=kl(),jne=class extends cJe{constructor(e){super(te(N({},e),{multiple:!0}))}};Hne.exports=jne});var BN=w(($pt,Yne)=>{"use strict";var uJe=Eu(),qne=class extends uJe{constructor(e={}){super(N({style:"number"},e));this.min=this.isValue(e.min)?this.toNumber(e.min):-Infinity,this.max=this.isValue(e.max)?this.toNumber(e.max):Infinity,this.delay=e.delay!=null?e.delay:1e3,this.float=e.float!==!1,this.round=e.round===!0||e.float===!1,this.major=e.major||10,this.minor=e.minor||1,this.initial=e.initial!=null?e.initial:"",this.input=String(this.initial),this.cursor=this.input.length,this.cursorShow()}append(e){return!/[-+.]/.test(e)||e==="."&&this.input.includes(".")?this.alert("invalid number"):super.append(e)}number(e){return super.append(e)}next(){return this.input&&this.input!==this.initial?this.alert():this.isValue(this.initial)?(this.input=this.initial,this.cursor=String(this.initial).length,this.render()):this.alert()}up(e){let r=e||this.minor,i=this.toNumber(this.input);return i>this.max+r?this.alert():(this.input=`${i+r}`,this.render())}down(e){let r=e||this.minor,i=this.toNumber(this.input);return i<this.min-r?this.alert():(this.input=`${i-r}`,this.render())}shiftDown(){return this.down(this.major)}shiftUp(){return this.up(this.major)}format(e=this.input){return typeof this.options.format=="function"?this.options.format.call(this,e):this.styles.info(e)}toNumber(e=""){return this.float?+e:Math.round(+e)}isValue(e){return/^[-+]?[0-9]+((\.)|(\.[0-9]+))?$/.test(e)}submit(){let e=[this.input,this.initial].find(r=>this.isValue(r));return this.value=this.toNumber(e||0),super.submit()}};Yne.exports=qne});var Wne=w((edt,Jne)=>{Jne.exports=BN()});var Vne=w((tdt,zne)=>{"use strict";var gJe=Eu(),_ne=class extends gJe{constructor(e){super(e);this.cursorShow()}format(e=this.input){return this.keypressed?(this.state.submitted?this.styles.primary:this.styles.muted)(this.symbols.asterisk.repeat(e.length)):""}};zne.exports=_ne});var ese=w((rdt,Xne)=>{"use strict";var fJe=Co(),hJe=XC(),Zne=Xi(),$ne=class extends hJe{constructor(e={}){super(e);this.widths=[].concat(e.messageWidth||50),this.align=[].concat(e.align||"left"),this.linebreak=e.linebreak||!1,this.edgeLength=e.edgeLength||3,this.newline=e.newline||`
+   `;let r=e.startNumber||1;typeof this.scale=="number"&&(this.scaleKey=!1,this.scale=Array(this.scale).fill(0).map((i,n)=>({name:n+r})))}async reset(){return this.tableized=!1,await super.reset(),this.render()}tableize(){if(this.tableized===!0)return;this.tableized=!0;let e=0;for(let r of this.choices){e=Math.max(e,r.message.length),r.scaleIndex=r.initial||2,r.scale=[];for(let i=0;i<this.scale.length;i++)r.scale.push({index:i})}this.widths[0]=Math.min(this.widths[0],e+3)}async dispatch(e,r){if(this.multiple)return this[r.name]?await this[r.name](e,r):await super.dispatch(e,r);this.alert()}heading(e,r,i){return this.styles.strong(e)}separator(){return this.styles.muted(this.symbols.ellipsis)}right(){let e=this.focused;return e.scaleIndex>=this.scale.length-1?this.alert():(e.scaleIndex++,this.render())}left(){let e=this.focused;return e.scaleIndex<=0?this.alert():(e.scaleIndex--,this.render())}indent(){return""}format(){return this.state.submitted?this.choices.map(r=>this.styles.info(r.index)).join(", "):""}pointer(){return""}renderScaleKey(){if(this.scaleKey===!1||this.state.submitted)return"";let e=this.scale.map(i=>`   ${i.name} - ${i.message}`);return["",...e].map(i=>this.styles.muted(i)).join(`
+`)}renderScaleHeading(e){let r=this.scale.map(l=>l.name);typeof this.options.renderScaleHeading=="function"&&(r=this.options.renderScaleHeading.call(this,e));let i=this.scaleLength-r.join("").length,n=Math.round(i/(r.length-1)),o=r.map(l=>this.styles.strong(l)).join(" ".repeat(n)),a=" ".repeat(this.widths[0]);return this.margin[3]+a+this.margin[1]+o}scaleIndicator(e,r,i){if(typeof this.options.scaleIndicator=="function")return this.options.scaleIndicator.call(this,e,r,i);let n=e.scaleIndex===r.index;return r.disabled?this.styles.hint(this.symbols.radio.disabled):n?this.styles.success(this.symbols.radio.on):this.symbols.radio.off}renderScale(e,r){let i=e.scale.map(s=>this.scaleIndicator(e,s,r)),n=this.term==="Hyper"?"":" ";return i.join(n+this.symbols.line.repeat(this.edgeLength))}async renderChoice(e,r){await this.onChoice(e,r);let i=this.index===r,n=await this.pointer(e,r),s=await e.hint;s&&!Zne.hasColor(s)&&(s=this.styles.muted(s));let o=p=>this.margin[3]+p.replace(/\s+$/,"").padEnd(this.widths[0]," "),a=this.newline,l=this.indent(e),c=await this.resolve(e.message,this.state,e,r),u=await this.renderScale(e,r),g=this.margin[1]+this.margin[3];this.scaleLength=fJe.unstyle(u).length,this.widths[0]=Math.min(this.widths[0],this.width-this.scaleLength-g.length);let h=Zne.wordWrap(c,{width:this.widths[0],newline:a}).split(`
+`).map(p=>o(p)+this.margin[1]);return i&&(u=this.styles.info(u),h=h.map(p=>this.styles.info(p))),h[0]+=u,this.linebreak&&h.push(""),[l+n,h.join(`
+`)].filter(Boolean)}async renderChoices(){if(this.state.submitted)return"";this.tableize();let e=this.visible.map(async(n,s)=>await this.renderChoice(n,s)),r=await Promise.all(e),i=await this.renderScaleHeading();return this.margin[0]+[i,...r.map(n=>n.join(" "))].join(`
+`)}async render(){let{submitted:e,size:r}=this.state,i=await this.prefix(),n=await this.separator(),s=await this.message(),o="";this.options.promptLine!==!1&&(o=[i,s,n,""].join(" "),this.state.prompt=o);let a=await this.header(),l=await this.format(),c=await this.renderScaleKey(),u=await this.error()||await this.hint(),g=await this.renderChoices(),f=await this.footer(),h=this.emptyError;l&&(o+=l),u&&!o.includes(u)&&(o+=" "+u),e&&!l&&!g.trim()&&this.multiple&&h!=null&&(o+=this.styles.danger(h)),this.clear(r),this.write([a,o,c,g,f].filter(Boolean).join(`
+`)),this.state.submitted||this.write(this.margin[2]),this.restore()}submit(){this.value={};for(let e of this.choices)this.value[e.name]=e.scaleIndex;return this.base.submit.call(this)}};Xne.exports=$ne});var nse=w((idt,tse)=>{"use strict";var rse=Co(),pJe=(t="")=>typeof t=="string"?t.replace(/^['"]|['"]$/g,""):"",ise=class{constructor(e){this.name=e.key,this.field=e.field||{},this.value=pJe(e.initial||this.field.initial||""),this.message=e.message||this.name,this.cursor=0,this.input="",this.lines=[]}},dJe=async(t={},e={},r=i=>i)=>{let i=new Set,n=t.fields||[],s=t.template,o=[],a=[],l=[],c=1;typeof s=="function"&&(s=await s());let u=-1,g=()=>s[++u],f=()=>s[u+1],h=p=>{p.line=c,o.push(p)};for(h({type:"bos",value:""});u<s.length-1;){let p=g();if(/^[^\S\n ]$/.test(p)){h({type:"text",value:p});continue}if(p===`
+`){h({type:"newline",value:p}),c++;continue}if(p==="\\"){p+=g(),h({type:"text",value:p});continue}if((p==="$"||p==="#"||p==="{")&&f()==="{"){p+=g();let Q={type:"template",open:p,inner:"",close:"",value:p},S;for(;S=g();){if(S==="}"){f()==="}"&&(S+=g()),Q.value+=S,Q.close=S;break}S===":"?(Q.initial="",Q.key=Q.inner):Q.initial!==void 0&&(Q.initial+=S),Q.value+=S,Q.inner+=S}Q.template=Q.open+(Q.initial||Q.inner)+Q.close,Q.key=Q.key||Q.inner,e.hasOwnProperty(Q.key)&&(Q.initial=e[Q.key]),Q=r(Q),h(Q),l.push(Q.key),i.add(Q.key);let x=a.find(M=>M.name===Q.key);Q.field=n.find(M=>M.name===Q.key),x||(x=new ise(Q),a.push(x)),x.lines.push(Q.line-1);continue}let m=o[o.length-1];m.type==="text"&&m.line===c?m.value+=p:h({type:"text",value:p})}return h({type:"eos",value:""}),{input:s,tabstops:o,unique:i,keys:l,items:a}};tse.exports=async t=>{let e=t.options,r=new Set(e.required===!0?[]:e.required||[]),i=N(N({},e.values),e.initial),{tabstops:n,items:s,keys:o}=await dJe(e,i),a=bN("result",t,e),l=bN("format",t,e),c=bN("validate",t,e,!0),u=t.isValue.bind(t);return async(g={},f=!1)=>{let h=0;g.required=r,g.items=s,g.keys=o,g.output="";let p=async(S,x,M,Y)=>{let U=await c(S,x,M,Y);return U===!1?"Invalid field "+M.name:U};for(let S of n){let x=S.value,M=S.key;if(S.type!=="template"){x&&(g.output+=x);continue}if(S.type==="template"){let Y=s.find(Z=>Z.name===M);e.required===!0&&g.required.add(Y.name);let U=[Y.input,g.values[Y.value],Y.value,x].find(u),W=(Y.field||{}).message||S.inner;if(f){let Z=await p(g.values[M],g,Y,h);if(Z&&typeof Z=="string"||Z===!1){g.invalid.set(M,Z);continue}g.invalid.delete(M);let A=await a(g.values[M],g,Y,h);g.output+=rse.unstyle(A);continue}Y.placeholder=!1;let ee=x;x=await l(x,g,Y,h),U!==x?(g.values[M]=U,x=t.styles.typing(U),g.missing.delete(W)):(g.values[M]=void 0,U=`<${W}>`,x=t.styles.primary(U),Y.placeholder=!0,g.required.has(M)&&g.missing.add(W)),g.missing.has(W)&&g.validating&&(x=t.styles.warning(U)),g.invalid.has(M)&&g.validating&&(x=t.styles.danger(U)),h===g.index&&(ee!==x?x=t.styles.underline(x):x=t.styles.heading(rse.unstyle(x))),h++}x&&(g.output+=x)}let m=g.output.split(`
+`).map(S=>" "+S),y=s.length,Q=0;for(let S of s)g.invalid.has(S.name)&&S.lines.forEach(x=>{m[x][0]===" "&&(m[x]=g.styles.danger(g.symbols.bullet)+m[x].slice(1))}),t.isValue(g.values[S.name])&&Q++;return g.completed=(Q/y*100).toFixed(0),g.output=m.join(`
+`),g.output}};function bN(t,e,r,i){return(n,s,o,a)=>typeof o.field[t]=="function"?o.field[t].call(e,n,s,o,a):[i,n].find(l=>e.isValue(l))}});var ase=w((ndt,sse)=>{"use strict";var CJe=Co(),mJe=nse(),EJe=zf(),ose=class extends EJe{constructor(e){super(e);this.cursorHide(),this.reset(!0)}async initialize(){this.interpolate=await mJe(this),await super.initialize()}async reset(e){this.state.keys=[],this.state.invalid=new Map,this.state.missing=new Set,this.state.completed=0,this.state.values={},e!==!0&&(await this.initialize(),await this.render())}moveCursor(e){let r=this.getItem();this.cursor+=e,r.cursor+=e}dispatch(e,r){if(!r.code&&!r.ctrl&&e!=null&&this.getItem()){this.append(e,r);return}this.alert()}append(e,r){let i=this.getItem(),n=i.input.slice(0,this.cursor),s=i.input.slice(this.cursor);this.input=i.input=`${n}${e}${s}`,this.moveCursor(1),this.render()}delete(){let e=this.getItem();if(this.cursor<=0||!e.input)return this.alert();let r=e.input.slice(this.cursor),i=e.input.slice(0,this.cursor-1);this.input=e.input=`${i}${r}`,this.moveCursor(-1),this.render()}increment(e){return e>=this.state.keys.length-1?0:e+1}decrement(e){return e<=0?this.state.keys.length-1:e-1}first(){this.state.index=0,this.render()}last(){this.state.index=this.state.keys.length-1,this.render()}right(){if(this.cursor>=this.input.length)return this.alert();this.moveCursor(1),this.render()}left(){if(this.cursor<=0)return this.alert();this.moveCursor(-1),this.render()}prev(){this.state.index=this.decrement(this.state.index),this.getItem(),this.render()}next(){this.state.index=this.increment(this.state.index),this.getItem(),this.render()}up(){this.prev()}down(){this.next()}format(e){let r=this.state.completed<100?this.styles.warning:this.styles.success;return this.state.submitted===!0&&this.state.completed!==100&&(r=this.styles.danger),r(`${this.state.completed}% completed`)}async render(){let{index:e,keys:r=[],submitted:i,size:n}=this.state,s=[this.options.newline,`
+`].find(S=>S!=null),o=await this.prefix(),a=await this.separator(),l=await this.message(),c=[o,l,a].filter(Boolean).join(" ");this.state.prompt=c;let u=await this.header(),g=await this.error()||"",f=await this.hint()||"",h=i?"":await this.interpolate(this.state),p=this.state.key=r[e]||"",m=await this.format(p),y=await this.footer();m&&(c+=" "+m),f&&!m&&this.state.completed===0&&(c+=" "+f),this.clear(n);let Q=[u,c,h,y,g.trim()];this.write(Q.filter(Boolean).join(s)),this.restore()}getItem(e){let{items:r,keys:i,index:n}=this.state,s=r.find(o=>o.name===i[n]);return s&&s.input!=null&&(this.input=s.input,this.cursor=s.cursor),s}async submit(){typeof this.interpolate!="function"&&await this.initialize(),await this.interpolate(this.state,!0);let{invalid:e,missing:r,output:i,values:n}=this.state;if(e.size){let a="";for(let[l,c]of e)a+=`Invalid ${l}: ${c}
+`;return this.state.error=a,super.submit()}if(r.size)return this.state.error="Required: "+[...r.keys()].join(", "),super.submit();let o=CJe.unstyle(i).split(`
+`).map(a=>a.slice(1)).join(`
+`);return this.value={values:n,result:o},super.submit()}};sse.exports=ose});var cse=w((sdt,Ase)=>{"use strict";var IJe="(Use <shift>+<up/down> to sort)",yJe=kl(),lse=class extends yJe{constructor(e){super(te(N({},e),{reorder:!1,sort:!0,multiple:!0}));this.state.hint=[this.options.hint,IJe].find(this.isValue.bind(this))}indicator(){return""}async renderChoice(e,r){let i=await super.renderChoice(e,r),n=this.symbols.identicalTo+" ",s=this.index===r&&this.sorting?this.styles.muted(n):"  ";return this.options.drag===!1&&(s=""),this.options.numbered===!0?s+`${r+1} - `+i:s+i}get selected(){return this.choices}submit(){return this.value=this.choices.map(e=>e.value),super.submit()}};Ase.exports=lse});var fse=w((odt,use)=>{"use strict";var wJe=XC(),gse=class extends wJe{constructor(e={}){super(e);if(this.emptyError=e.emptyError||"No items were selected",this.term=process.env.TERM_PROGRAM,!this.options.header){let r=["","4 - Strongly Agree","3 - Agree","2 - Neutral","1 - Disagree","0 - Strongly Disagree",""];r=r.map(i=>this.styles.muted(i)),this.state.header=r.join(`
+   `)}}async toChoices(...e){if(this.createdScales)return!1;this.createdScales=!0;let r=await super.toChoices(...e);for(let i of r)i.scale=BJe(5,this.options),i.scaleIdx=2;return r}dispatch(){this.alert()}space(){let e=this.focused,r=e.scale[e.scaleIdx],i=r.selected;return e.scale.forEach(n=>n.selected=!1),r.selected=!i,this.render()}indicator(){return""}pointer(){return""}separator(){return this.styles.muted(this.symbols.ellipsis)}right(){let e=this.focused;return e.scaleIdx>=e.scale.length-1?this.alert():(e.scaleIdx++,this.render())}left(){let e=this.focused;return e.scaleIdx<=0?this.alert():(e.scaleIdx--,this.render())}indent(){return"   "}async renderChoice(e,r){await this.onChoice(e,r);let i=this.index===r,n=this.term==="Hyper",s=n?9:8,o=n?"":" ",a=this.symbols.line.repeat(s),l=" ".repeat(s+(n?0:1)),c=x=>(x?this.styles.success("\u25C9"):"\u25EF")+o,u=r+1+".",g=i?this.styles.heading:this.styles.noop,f=await this.resolve(e.message,this.state,e,r),h=this.indent(e),p=h+e.scale.map((x,M)=>c(M===e.scaleIdx)).join(a),m=x=>x===e.scaleIdx?g(x):x,y=h+e.scale.map((x,M)=>m(M)).join(l),Q=()=>[u,f].filter(Boolean).join(" "),S=()=>[Q(),p,y," "].filter(Boolean).join(`
+`);return i&&(p=this.styles.cyan(p),y=this.styles.cyan(y)),S()}async renderChoices(){if(this.state.submitted)return"";let e=this.visible.map(async(i,n)=>await this.renderChoice(i,n)),r=await Promise.all(e);return r.length||r.push(this.styles.danger("No matching choices")),r.join(`
+`)}format(){return this.state.submitted?this.choices.map(r=>this.styles.info(r.scaleIdx)).join(", "):""}async render(){let{submitted:e,size:r}=this.state,i=await this.prefix(),n=await this.separator(),s=await this.message(),o=[i,s,n].filter(Boolean).join(" ");this.state.prompt=o;let a=await this.header(),l=await this.format(),c=await this.error()||await this.hint(),u=await this.renderChoices(),g=await this.footer();(l||!c)&&(o+=" "+l),c&&!o.includes(c)&&(o+=" "+c),e&&!l&&!u&&this.multiple&&this.type!=="form"&&(o+=this.styles.danger(this.emptyError)),this.clear(r),this.write([o,a,u,g].filter(Boolean).join(`
+`)),this.restore()}submit(){this.value={};for(let e of this.choices)this.value[e.name]=e.scaleIdx;return this.base.submit.call(this)}};function BJe(t,e={}){if(Array.isArray(e.scale))return e.scale.map(i=>N({},i));let r=[];for(let i=1;i<t+1;i++)r.push({i,selected:!1});return r}use.exports=gse});var pse=w((adt,hse)=>{hse.exports=wN()});var mse=w((Adt,dse)=>{"use strict";var bJe=Z0(),Cse=class extends bJe{async initialize(){await super.initialize(),this.value=this.initial=!!this.options.initial,this.disabled=this.options.disabled||"no",this.enabled=this.options.enabled||"yes",await this.render()}reset(){this.value=this.initial,this.render()}delete(){this.alert()}toggle(){this.value=!this.value,this.render()}enable(){if(this.value===!0)return this.alert();this.value=!0,this.render()}disable(){if(this.value===!1)return this.alert();this.value=!1,this.render()}up(){this.toggle()}down(){this.toggle()}right(){this.toggle()}left(){this.toggle()}next(){this.toggle()}prev(){this.toggle()}dispatch(e="",r){switch(e.toLowerCase()){case" ":return this.toggle();case"1":case"y":case"t":return this.enable();case"0":case"n":case"f":return this.disable();default:return this.alert()}}format(){let e=i=>this.styles.primary.underline(i);return[this.value?this.disabled:e(this.disabled),this.value?e(this.enabled):this.enabled].join(this.styles.muted(" / "))}async render(){let{size:e}=this.state,r=await this.header(),i=await this.prefix(),n=await this.separator(),s=await this.message(),o=await this.format(),a=await this.error()||await this.hint(),l=await this.footer(),c=[i,s,n,o].join(" ");this.state.prompt=c,a&&!c.includes(a)&&(c+=" "+a),this.clear(e),this.write([r,c,l].filter(Boolean).join(`
+`)),this.write(this.margin[2]),this.restore()}};dse.exports=Cse});var yse=w((ldt,Ese)=>{"use strict";var QJe=kl(),Ise=class extends QJe{constructor(e){super(e);if(typeof this.options.correctChoice!="number"||this.options.correctChoice<0)throw new Error("Please specify the index of the correct answer from the list of choices")}async toChoices(e,r){let i=await super.toChoices(e,r);if(i.length<2)throw new Error("Please give at least two choices to the user");if(this.options.correctChoice>i.length)throw new Error("Please specify the index of the correct answer from the list of choices");return i}check(e){return e.index===this.options.correctChoice}async result(e){return{selectedAnswer:e,correctAnswer:this.options.choices[this.options.correctChoice].value,correct:await this.check(this.state)}}};Ese.exports=Ise});var Bse=w(QN=>{"use strict";var wse=Xi(),Ci=(t,e)=>{wse.defineExport(QN,t,e),wse.defineExport(QN,t.toLowerCase(),e)};Ci("AutoComplete",()=>une());Ci("BasicAuth",()=>Ene());Ci("Confirm",()=>bne());Ci("Editable",()=>Sne());Ci("Form",()=>X0());Ci("Input",()=>wN());Ci("Invisible",()=>One());Ci("List",()=>Kne());Ci("MultiSelect",()=>Gne());Ci("Numeral",()=>Wne());Ci("Password",()=>Vne());Ci("Scale",()=>ese());Ci("Select",()=>kl());Ci("Snippet",()=>ase());Ci("Sort",()=>cse());Ci("Survey",()=>fse());Ci("Text",()=>pse());Ci("Toggle",()=>mse());Ci("Quiz",()=>yse())});var Qse=w((udt,bse)=>{bse.exports={ArrayPrompt:XC(),AuthPrompt:yN(),BooleanPrompt:Z0(),NumberPrompt:BN(),StringPrompt:Eu()}});var $C=w((gdt,vse)=>{"use strict";var Sse=require("assert"),vN=require("events"),xl=Xi(),ua=class extends vN{constructor(e,r){super();this.options=xl.merge({},e),this.answers=N({},r)}register(e,r){if(xl.isObject(e)){for(let n of Object.keys(e))this.register(n,e[n]);return this}Sse.equal(typeof r,"function","expected a function");let i=e.toLowerCase();return r.prototype instanceof this.Prompt?this.prompts[i]=r:this.prompts[i]=r(this.Prompt,this),this}async prompt(e=[]){for(let r of[].concat(e))try{typeof r=="function"&&(r=await r.call(this)),await this.ask(xl.merge({},this.options,r))}catch(i){return Promise.reject(i)}return this.answers}async ask(e){typeof e=="function"&&(e=await e.call(this));let r=xl.merge({},this.options,e),{type:i,name:n}=e,{set:s,get:o}=xl;if(typeof i=="function"&&(i=await i.call(this,e,this.answers)),!i)return this.answers[n];Sse(this.prompts[i],`Prompt "${i}" is not registered`);let a=new this.prompts[i](r),l=o(this.answers,n);a.state.answers=this.answers,a.enquirer=this,n&&a.on("submit",u=>{this.emit("answer",n,u,a),s(this.answers,n,u)});let c=a.emit.bind(a);return a.emit=(...u)=>(this.emit.call(this,...u),c(...u)),this.emit("prompt",a,this),r.autofill&&l!=null?(a.value=a.input=l,r.autofill==="show"&&await a.submit()):l=a.value=await a.run(),l}use(e){return e.call(this,this),this}set Prompt(e){this._Prompt=e}get Prompt(){return this._Prompt||this.constructor.Prompt}get prompts(){return this.constructor.prompts}static set Prompt(e){this._Prompt=e}static get Prompt(){return this._Prompt||zf()}static get prompts(){return Bse()}static get types(){return Qse()}static get prompt(){let e=(r,...i)=>{let n=new this(...i),s=n.emit.bind(n);return n.emit=(...o)=>(e.emit(...o),s(...o)),n.prompt(r)};return xl.mixinEmitter(e,new vN),e}};xl.mixinEmitter(ua,new vN);var SN=ua.prompts;for(let t of Object.keys(SN)){let e=t.toLowerCase(),r=i=>new SN[t](i).run();ua.prompt[e]=r,ua[e]=r,ua[t]||Reflect.defineProperty(ua,t,{get:()=>SN[t]})}var ZC=t=>{xl.defineExport(ua,t,()=>ua.types[t])};ZC("ArrayPrompt");ZC("AuthPrompt");ZC("BooleanPrompt");ZC("NumberPrompt");ZC("StringPrompt");vse.exports=ua});var Kse=w((tCt,Use)=>{function PJe(t,e){for(var r=-1,i=t==null?0:t.length;++r<i&&e(t[r],r,t)!==!1;);return t}Use.exports=PJe});var Xf=w((rCt,Hse)=>{var DJe=c0(),RJe=Ff();function FJe(t,e,r,i){var n=!r;r||(r={});for(var s=-1,o=e.length;++s<o;){var a=e[s],l=i?i(r[a],t[a],a,r,t):void 0;l===void 0&&(l=t[a]),n?RJe(r,a,l):DJe(r,a,l)}return r}Hse.exports=FJe});var Gse=w((iCt,jse)=>{var NJe=Xf(),LJe=Mf();function TJe(t,e){return t&&NJe(e,LJe(e),t)}jse.exports=TJe});var qse=w((nCt,Yse)=>{function OJe(t){var e=[];if(t!=null)for(var r in Object(t))e.push(r);return e}Yse.exports=OJe});var Wse=w((sCt,Jse)=>{var MJe=Rn(),UJe=b0(),KJe=qse(),HJe=Object.prototype,jJe=HJe.hasOwnProperty;function GJe(t){if(!MJe(t))return KJe(t);var e=UJe(t),r=[];for(var i in t)i=="constructor"&&(e||!jJe.call(t,i))||r.push(i);return r}Jse.exports=GJe});var Zf=w((oCt,zse)=>{var YJe=mF(),qJe=Wse(),JJe=FC();function WJe(t){return JJe(t)?YJe(t,!0):qJe(t)}zse.exports=WJe});var Vse=w((aCt,_se)=>{var zJe=Xf(),_Je=Zf();function VJe(t,e){return t&&zJe(e,_Je(e),t)}_se.exports=VJe});var FN=w((om,$f)=>{var XJe=Rs(),Xse=typeof om=="object"&&om&&!om.nodeType&&om,Zse=Xse&&typeof $f=="object"&&$f&&!$f.nodeType&&$f,ZJe=Zse&&Zse.exports===Xse,$se=ZJe?XJe.Buffer:void 0,eoe=$se?$se.allocUnsafe:void 0;function $Je(t,e){if(e)return t.slice();var r=t.length,i=eoe?eoe(r):new t.constructor(r);return t.copy(i),i}$f.exports=$Je});var NN=w((ACt,toe)=>{function e3e(t,e){var r=-1,i=t.length;for(e||(e=Array(i));++r<i;)e[r]=t[r];return e}toe.exports=e3e});var ioe=w((lCt,roe)=>{var t3e=Xf(),r3e=v0();function i3e(t,e){return t3e(t,r3e(t),e)}roe.exports=i3e});var eb=w((cCt,noe)=>{var n3e=EF(),s3e=n3e(Object.getPrototypeOf,Object);noe.exports=s3e});var LN=w((uCt,soe)=>{var o3e=g0(),a3e=eb(),A3e=v0(),l3e=QF(),c3e=Object.getOwnPropertySymbols,u3e=c3e?function(t){for(var e=[];t;)o3e(e,A3e(t)),t=a3e(t);return e}:l3e;soe.exports=u3e});var aoe=w((gCt,ooe)=>{var g3e=Xf(),f3e=LN();function h3e(t,e){return g3e(t,f3e(t),e)}ooe.exports=h3e});var loe=w((fCt,Aoe)=>{var p3e=bF(),d3e=LN(),C3e=Zf();function m3e(t){return p3e(t,C3e,d3e)}Aoe.exports=m3e});var uoe=w((hCt,coe)=>{var E3e=Object.prototype,I3e=E3e.hasOwnProperty;function y3e(t){var e=t.length,r=new t.constructor(e);return e&&typeof t[0]=="string"&&I3e.call(t,"index")&&(r.index=t.index,r.input=t.input),r}coe.exports=y3e});var tb=w((pCt,goe)=>{var foe=wF();function w3e(t){var e=new t.constructor(t.byteLength);return new foe(e).set(new foe(t)),e}goe.exports=w3e});var poe=w((dCt,hoe)=>{var B3e=tb();function b3e(t,e){var r=e?B3e(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.byteLength)}hoe.exports=b3e});var Coe=w((CCt,doe)=>{var Q3e=/\w*$/;function v3e(t){var e=new t.constructor(t.source,Q3e.exec(t));return e.lastIndex=t.lastIndex,e}doe.exports=v3e});var woe=w((mCt,moe)=>{var Eoe=Kc(),Ioe=Eoe?Eoe.prototype:void 0,yoe=Ioe?Ioe.valueOf:void 0;function S3e(t){return yoe?Object(yoe.call(t)):{}}moe.exports=S3e});var TN=w((ECt,Boe)=>{var k3e=tb();function x3e(t,e){var r=e?k3e(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.length)}Boe.exports=x3e});var Qoe=w((ICt,boe)=>{var P3e=tb(),D3e=poe(),R3e=Coe(),F3e=woe(),N3e=TN(),L3e="[object Boolean]",T3e="[object Date]",O3e="[object Map]",M3e="[object Number]",U3e="[object RegExp]",K3e="[object Set]",H3e="[object String]",j3e="[object Symbol]",G3e="[object ArrayBuffer]",Y3e="[object DataView]",q3e="[object Float32Array]",J3e="[object Float64Array]",W3e="[object Int8Array]",z3e="[object Int16Array]",_3e="[object Int32Array]",V3e="[object Uint8Array]",X3e="[object Uint8ClampedArray]",Z3e="[object Uint16Array]",$3e="[object Uint32Array]";function eWe(t,e,r){var i=t.constructor;switch(e){case G3e:return P3e(t);case L3e:case T3e:return new i(+t);case Y3e:return D3e(t,r);case q3e:case J3e:case W3e:case z3e:case _3e:case V3e:case X3e:case Z3e:case $3e:return N3e(t,r);case O3e:return new i;case M3e:case H3e:return new i(t);case U3e:return R3e(t);case K3e:return new i;case j3e:return F3e(t)}}boe.exports=eWe});var koe=w((yCt,voe)=>{var tWe=Rn(),Soe=Object.create,rWe=function(){function t(){}return function(e){if(!tWe(e))return{};if(Soe)return Soe(e);t.prototype=e;var r=new t;return t.prototype=void 0,r}}();voe.exports=rWe});var ON=w((wCt,xoe)=>{var iWe=koe(),nWe=eb(),sWe=b0();function oWe(t){return typeof t.constructor=="function"&&!sWe(t)?iWe(nWe(t)):{}}xoe.exports=oWe});var Doe=w((BCt,Poe)=>{var aWe=LC(),AWe=Zo(),lWe="[object Map]";function cWe(t){return AWe(t)&&aWe(t)==lWe}Poe.exports=cWe});var Loe=w((bCt,Roe)=>{var uWe=Doe(),gWe=y0(),Foe=w0(),Noe=Foe&&Foe.isMap,fWe=Noe?gWe(Noe):uWe;Roe.exports=fWe});var Ooe=w((QCt,Toe)=>{var hWe=LC(),pWe=Zo(),dWe="[object Set]";function CWe(t){return pWe(t)&&hWe(t)==dWe}Toe.exports=CWe});var Hoe=w((vCt,Moe)=>{var mWe=Ooe(),EWe=y0(),Uoe=w0(),Koe=Uoe&&Uoe.isSet,IWe=Koe?EWe(Koe):mWe;Moe.exports=IWe});var Joe=w((SCt,joe)=>{var yWe=NC(),wWe=Kse(),BWe=c0(),bWe=Gse(),QWe=Vse(),vWe=FN(),SWe=NN(),kWe=ioe(),xWe=aoe(),PWe=vF(),DWe=loe(),RWe=LC(),FWe=uoe(),NWe=Qoe(),LWe=ON(),TWe=Os(),OWe=PC(),MWe=Loe(),UWe=Rn(),KWe=Hoe(),HWe=Mf(),jWe=Zf(),GWe=1,YWe=2,qWe=4,Goe="[object Arguments]",JWe="[object Array]",WWe="[object Boolean]",zWe="[object Date]",_We="[object Error]",Yoe="[object Function]",VWe="[object GeneratorFunction]",XWe="[object Map]",ZWe="[object Number]",qoe="[object Object]",$We="[object RegExp]",e8e="[object Set]",t8e="[object String]",r8e="[object Symbol]",i8e="[object WeakMap]",n8e="[object ArrayBuffer]",s8e="[object DataView]",o8e="[object Float32Array]",a8e="[object Float64Array]",A8e="[object Int8Array]",l8e="[object Int16Array]",c8e="[object Int32Array]",u8e="[object Uint8Array]",g8e="[object Uint8ClampedArray]",f8e="[object Uint16Array]",h8e="[object Uint32Array]",dr={};dr[Goe]=dr[JWe]=dr[n8e]=dr[s8e]=dr[WWe]=dr[zWe]=dr[o8e]=dr[a8e]=dr[A8e]=dr[l8e]=dr[c8e]=dr[XWe]=dr[ZWe]=dr[qoe]=dr[$We]=dr[e8e]=dr[t8e]=dr[r8e]=dr[u8e]=dr[g8e]=dr[f8e]=dr[h8e]=!0;dr[_We]=dr[Yoe]=dr[i8e]=!1;function rb(t,e,r,i,n,s){var o,a=e&GWe,l=e&YWe,c=e&qWe;if(r&&(o=n?r(t,i,n,s):r(t)),o!==void 0)return o;if(!UWe(t))return t;var u=TWe(t);if(u){if(o=FWe(t),!a)return SWe(t,o)}else{var g=RWe(t),f=g==Yoe||g==VWe;if(OWe(t))return vWe(t,a);if(g==qoe||g==Goe||f&&!n){if(o=l||f?{}:LWe(t),!a)return l?xWe(t,QWe(o,t)):kWe(t,bWe(o,t))}else{if(!dr[g])return n?t:{};o=NWe(t,g,a)}}s||(s=new yWe);var h=s.get(t);if(h)return h;s.set(t,o),KWe(t)?t.forEach(function(y){o.add(rb(y,e,r,y,t,s))}):MWe(t)&&t.forEach(function(y,Q){o.set(Q,rb(y,e,r,Q,t,s))});var p=c?l?DWe:PWe:l?jWe:HWe,m=u?void 0:p(t);return wWe(m||t,function(y,Q){m&&(Q=y,y=t[Q]),BWe(o,Q,rb(y,e,r,Q,t,s))}),o}joe.exports=rb});var MN=w((kCt,Woe)=>{var p8e=Joe(),d8e=1,C8e=4;function m8e(t){return p8e(t,d8e|C8e)}Woe.exports=m8e});var _oe=w((xCt,zoe)=>{var E8e=zR();function I8e(t,e,r){return t==null?t:E8e(t,e,r)}zoe.exports=I8e});var tae=w((LCt,eae)=>{function y8e(t){var e=t==null?0:t.length;return e?t[e-1]:void 0}eae.exports=y8e});var iae=w((TCt,rae)=>{var w8e=IC(),B8e=sD();function b8e(t,e){return e.length<2?t:w8e(t,B8e(e,0,-1))}rae.exports=b8e});var sae=w((OCt,nae)=>{var Q8e=Rf(),v8e=tae(),S8e=iae(),k8e=lu();function x8e(t,e){return e=Q8e(e,t),t=S8e(t,e),t==null||delete t[k8e(v8e(e))]}nae.exports=x8e});var aae=w((MCt,oae)=>{var P8e=sae();function D8e(t,e){return t==null?!0:P8e(t,e)}oae.exports=D8e});var dae=w((hmt,pae)=>{pae.exports={name:"@yarnpkg/cli",version:"3.2.0",license:"BSD-2-Clause",main:"./sources/index.ts",dependencies:{"@yarnpkg/core":"workspace:^","@yarnpkg/fslib":"workspace:^","@yarnpkg/libzip":"workspace:^","@yarnpkg/parsers":"workspace:^","@yarnpkg/plugin-compat":"workspace:^","@yarnpkg/plugin-dlx":"workspace:^","@yarnpkg/plugin-essentials":"workspace:^","@yarnpkg/plugin-file":"workspace:^","@yarnpkg/plugin-git":"workspace:^","@yarnpkg/plugin-github":"workspace:^","@yarnpkg/plugin-http":"workspace:^","@yarnpkg/plugin-init":"workspace:^","@yarnpkg/plugin-link":"workspace:^","@yarnpkg/plugin-nm":"workspace:^","@yarnpkg/plugin-npm":"workspace:^","@yarnpkg/plugin-npm-cli":"workspace:^","@yarnpkg/plugin-pack":"workspace:^","@yarnpkg/plugin-patch":"workspace:^","@yarnpkg/plugin-pnp":"workspace:^","@yarnpkg/plugin-pnpm":"workspace:^","@yarnpkg/shell":"workspace:^",chalk:"^3.0.0","ci-info":"^3.2.0",clipanion:"^3.2.0-rc.4",semver:"^7.1.2",tslib:"^1.13.0",typanion:"^3.3.0",yup:"^0.32.9"},devDependencies:{"@types/semver":"^7.1.0","@types/yup":"^0","@yarnpkg/builder":"workspace:^","@yarnpkg/monorepo":"workspace:^","@yarnpkg/pnpify":"workspace:^",micromatch:"^4.0.2"},peerDependencies:{"@yarnpkg/core":"workspace:^"},scripts:{postpack:"rm -rf lib",prepack:'run build:compile "$(pwd)"',"build:cli+hook":"run build:pnp:hook && builder build bundle","build:cli":"builder build bundle","run:cli":"builder run","update-local":"run build:cli --no-git-hash && rsync -a --delete bundles/ bin/"},publishConfig:{main:"./lib/index.js",types:"./lib/index.d.ts",bin:null},files:["/lib/**/*","!/lib/pluginConfiguration.*","!/lib/cli.*"],"@yarnpkg/builder":{bundles:{standard:["@yarnpkg/plugin-essentials","@yarnpkg/plugin-compat","@yarnpkg/plugin-dlx","@yarnpkg/plugin-file","@yarnpkg/plugin-git","@yarnpkg/plugin-github","@yarnpkg/plugin-http","@yarnpkg/plugin-init","@yarnpkg/plugin-link","@yarnpkg/plugin-nm","@yarnpkg/plugin-npm","@yarnpkg/plugin-npm-cli","@yarnpkg/plugin-pack","@yarnpkg/plugin-patch","@yarnpkg/plugin-pnp","@yarnpkg/plugin-pnpm"]}},repository:{type:"git",url:"ssh://git@github.com/yarnpkg/berry.git",directory:"packages/yarnpkg-cli"},engines:{node:">=12 <14 || 14.2 - 14.9 || >14.10.0"}}});var VN=w((Jyt,rAe)=>{"use strict";rAe.exports=function(e,r){r===!0&&(r=0);var i=e.indexOf("://"),n=e.substring(0,i).split("+").filter(Boolean);return typeof r=="number"?n[r]:n}});var XN=w((Wyt,iAe)=>{"use strict";var Z8e=VN();function nAe(t){if(Array.isArray(t))return t.indexOf("ssh")!==-1||t.indexOf("rsync")!==-1;if(typeof t!="string")return!1;var e=Z8e(t);return t=t.substring(t.indexOf("://")+3),nAe(e)?!0:t.indexOf("@")<t.indexOf(":")}iAe.exports=nAe});var oAe=w((zyt,sAe)=>{"use strict";var $8e=VN(),eze=XN(),tze=require("querystring");function rze(t){t=(t||"").trim();var e={protocols:$8e(t),protocol:null,port:null,resource:"",user:"",pathname:"",hash:"",search:"",href:t,query:Object.create(null)},r=t.indexOf("://"),i=-1,n=null,s=null;t.startsWith(".")&&(t.startsWith("./")&&(t=t.substring(2)),e.pathname=t,e.protocol="file");var o=t.charAt(1);return e.protocol||(e.protocol=e.protocols[0],e.protocol||(eze(t)?e.protocol="ssh":((o==="/"||o==="~")&&(t=t.substring(2)),e.protocol="file"))),r!==-1&&(t=t.substring(r+3)),s=t.split("/"),e.protocol!=="file"?e.resource=s.shift():e.resource="",n=e.resource.split("@"),n.length===2&&(e.user=n[0],e.resource=n[1]),n=e.resource.split(":"),n.length===2&&(e.resource=n[0],n[1]?(e.port=Number(n[1]),isNaN(e.port)&&(e.port=null,s.unshift(n[1]))):e.port=null),s=s.filter(Boolean),e.protocol==="file"?e.pathname=e.href:e.pathname=e.pathname||(e.protocol!=="file"||e.href[0]==="/"?"/":"")+s.join("/"),n=e.pathname.split("#"),n.length===2&&(e.pathname=n[0],e.hash=n[1]),n=e.pathname.split("?"),n.length===2&&(e.pathname=n[0],e.search=n[1]),e.query=tze.parse(e.search),e.href=e.href.replace(/\/$/,""),e.pathname=e.pathname.replace(/\/$/,""),e}sAe.exports=rze});var lAe=w((_yt,aAe)=>{"use strict";var ize="text/plain",nze="us-ascii",AAe=(t,e)=>e.some(r=>r instanceof RegExp?r.test(t):r===t),sze=(t,{stripHash:e})=>{let r=/^data:(?<type>[^,]*?),(?<data>[^#]*?)(?:#(?<hash>.*))?$/.exec(t);if(!r)throw new Error(`Invalid URL: ${t}`);let{type:i,data:n,hash:s}=r.groups,o=i.split(";");s=e?"":s;let a=!1;o[o.length-1]==="base64"&&(o.pop(),a=!0);let l=(o.shift()||"").toLowerCase(),u=[...o.map(g=>{let[f,h=""]=g.split("=").map(p=>p.trim());return f==="charset"&&(h=h.toLowerCase(),h===nze)?"":`${f}${h?`=${h}`:""}`}).filter(Boolean)];return a&&u.push("base64"),(u.length!==0||l&&l!==ize)&&u.unshift(l),`data:${u.join(";")},${a?n.trim():n}${s?`#${s}`:""}`},oze=(t,e)=>{if(e=N({defaultProtocol:"http:",normalizeProtocol:!0,forceHttp:!1,forceHttps:!1,stripAuthentication:!0,stripHash:!1,stripTextFragment:!0,stripWWW:!0,removeQueryParameters:[/^utm_\w+/i],removeTrailingSlash:!0,removeSingleSlash:!0,removeDirectoryIndex:!1,sortQueryParameters:!0},e),t=t.trim(),/^data:/i.test(t))return sze(t,e);if(/^view-source:/i.test(t))throw new Error("`view-source:` is not supported as it is a non-standard protocol");let r=t.startsWith("//");!r&&/^\.*\//.test(t)||(t=t.replace(/^(?!(?:\w+:)?\/\/)|^\/\//,e.defaultProtocol));let n=new URL(t);if(e.forceHttp&&e.forceHttps)throw new Error("The `forceHttp` and `forceHttps` options cannot be used together");if(e.forceHttp&&n.protocol==="https:"&&(n.protocol="http:"),e.forceHttps&&n.protocol==="http:"&&(n.protocol="https:"),e.stripAuthentication&&(n.username="",n.password=""),e.stripHash?n.hash="":e.stripTextFragment&&(n.hash=n.hash.replace(/#?:~:text.*?$/i,"")),n.pathname&&(n.pathname=n.pathname.replace(/(?<!\b(?:[a-z][a-z\d+\-.]{1,50}:))\/{2,}/g,"/")),n.pathname)try{n.pathname=decodeURI(n.pathname)}catch(o){}if(e.removeDirectoryIndex===!0&&(e.removeDirectoryIndex=[/^index\.[a-z]+$/]),Array.isArray(e.removeDirectoryIndex)&&e.removeDirectoryIndex.length>0){let o=n.pathname.split("/"),a=o[o.length-1];AAe(a,e.removeDirectoryIndex)&&(o=o.slice(0,o.length-1),n.pathname=o.slice(1).join("/")+"/")}if(n.hostname&&(n.hostname=n.hostname.replace(/\.$/,""),e.stripWWW&&/^www\.(?!www\.)(?:[a-z\-\d]{1,63})\.(?:[a-z.\-\d]{2,63})$/.test(n.hostname)&&(n.hostname=n.hostname.replace(/^www\./,""))),Array.isArray(e.removeQueryParameters))for(let o of[...n.searchParams.keys()])AAe(o,e.removeQueryParameters)&&n.searchParams.delete(o);e.removeQueryParameters===!0&&(n.search=""),e.sortQueryParameters&&n.searchParams.sort(),e.removeTrailingSlash&&(n.pathname=n.pathname.replace(/\/$/,""));let s=t;return t=n.toString(),!e.removeSingleSlash&&n.pathname==="/"&&!s.endsWith("/")&&n.hash===""&&(t=t.replace(/\/$/,"")),(e.removeTrailingSlash||n.pathname==="/")&&n.hash===""&&e.removeSingleSlash&&(t=t.replace(/\/$/,"")),r&&!e.normalizeProtocol&&(t=t.replace(/^http:\/\//,"//")),e.stripProtocol&&(t=t.replace(/^(?:https?:)?\/\//,"")),t};aAe.exports=oze});var uAe=w((Vyt,cAe)=>{"use strict";var aze=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(t){return typeof t}:function(t){return t&&typeof Symbol=="function"&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},Aze=oAe(),lze=lAe();function cze(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1;if(typeof t!="string"||!t.trim())throw new Error("Invalid url.");e&&((typeof e=="undefined"?"undefined":aze(e))!=="object"&&(e={stripHash:!1}),t=lze(t,e));var r=Aze(t);return r}cAe.exports=cze});var hAe=w((Xyt,gAe)=>{"use strict";var uze=uAe(),fAe=XN();function gze(t){var e=uze(t);e.token="";var r=e.user.split(":");return r.length===2&&(r[1]==="x-oauth-basic"?e.token=r[0]:r[0]==="x-token-auth"&&(e.token=r[1])),fAe(e.protocols)||fAe(t)?e.protocol="ssh":e.protocols.length?e.protocol=e.protocols[0]:e.protocol="file",e.href=e.href.replace(/\/$/,""),e}gAe.exports=gze});var dAe=w((Zyt,pAe)=>{"use strict";var fze=hAe();function ZN(t){if(typeof t!="string")throw new Error("The url must be a string.");var e=fze(t),r=e.resource.split("."),i=null;switch(e.toString=function(l){return ZN.stringify(this,l)},e.source=r.length>2?r.slice(1-r.length).join("."):e.source=e.resource,e.git_suffix=/\.git$/.test(e.pathname),e.name=decodeURIComponent(e.pathname.replace(/^\//,"").replace(/\.git$/,"")),e.owner=decodeURIComponent(e.user),e.source){case"git.cloudforge.com":e.owner=e.user,e.organization=r[0],e.source="cloudforge.com";break;case"visualstudio.com":if(e.resource==="vs-ssh.visualstudio.com"){i=e.name.split("/"),i.length===4&&(e.organization=i[1],e.owner=i[2],e.name=i[3],e.full_name=i[2]+"/"+i[3]);break}else{i=e.name.split("/"),i.length===2?(e.owner=i[1],e.name=i[1],e.full_name="_git/"+e.name):i.length===3?(e.name=i[2],i[0]==="DefaultCollection"?(e.owner=i[2],e.organization=i[0],e.full_name=e.organization+"/_git/"+e.name):(e.owner=i[0],e.full_name=e.owner+"/_git/"+e.name)):i.length===4&&(e.organization=i[0],e.owner=i[1],e.name=i[3],e.full_name=e.organization+"/"+e.owner+"/_git/"+e.name);break}case"dev.azure.com":case"azure.com":if(e.resource==="ssh.dev.azure.com"){i=e.name.split("/"),i.length===4&&(e.organization=i[1],e.owner=i[2],e.name=i[3]);break}else{i=e.name.split("/"),i.length===5?(e.organization=i[0],e.owner=i[1],e.name=i[4],e.full_name="_git/"+e.name):i.length===3?(e.name=i[2],i[0]==="DefaultCollection"?(e.owner=i[2],e.organization=i[0],e.full_name=e.organization+"/_git/"+e.name):(e.owner=i[0],e.full_name=e.owner+"/_git/"+e.name)):i.length===4&&(e.organization=i[0],e.owner=i[1],e.name=i[3],e.full_name=e.organization+"/"+e.owner+"/_git/"+e.name);break}default:i=e.name.split("/");var n=i.length-1;if(i.length>=2){var s=i.indexOf("blob",2),o=i.indexOf("tree",2),a=i.indexOf("commit",2);n=s>0?s-1:o>0?o-1:a>0?a-1:n,e.owner=i.slice(0,n).join("/"),e.name=i[n],a&&(e.commit=i[n+2])}e.ref="",e.filepathtype="",e.filepath="",i.length>n+2&&["blob","tree"].indexOf(i[n+1])>=0&&(e.filepathtype=i[n+1],e.ref=i[n+2],i.length>n+3&&(e.filepath=i.slice(n+3).join("/"))),e.organization=e.owner;break}return e.full_name||(e.full_name=e.owner,e.name&&(e.full_name&&(e.full_name+="/"),e.full_name+=e.name)),e}ZN.stringify=function(t,e){e=e||(t.protocols&&t.protocols.length?t.protocols.join("+"):t.protocol);var r=t.port?":"+t.port:"",i=t.user||"git",n=t.git_suffix?".git":"";switch(e){case"ssh":return r?"ssh://"+i+"@"+t.resource+r+"/"+t.full_name+n:i+"@"+t.resource+":"+t.full_name+n;case"git+ssh":case"ssh+git":case"ftp":case"ftps":return e+"://"+i+"@"+t.resource+r+"/"+t.full_name+n;case"http":case"https":var s=t.token?hze(t):t.user&&(t.protocols.includes("http")||t.protocols.includes("https"))?t.user+"@":"";return e+"://"+s+t.resource+r+"/"+t.full_name+n;default:return t.href}};function hze(t){switch(t.source){case"bitbucket.org":return"x-token-auth:"+t.token+"@";default:return t.token+"@"}}pAe.exports=ZN});var kL=w((nbt,UAe)=>{var Fze=Ff(),Nze=xf();function Lze(t,e,r){(r!==void 0&&!Nze(t[e],r)||r===void 0&&!(e in t))&&Fze(t,e,r)}UAe.exports=Lze});var HAe=w((sbt,KAe)=>{var Tze=FC(),Oze=Zo();function Mze(t){return Oze(t)&&Tze(t)}KAe.exports=Mze});var YAe=w((obt,jAe)=>{var Uze=Hc(),Kze=eb(),Hze=Zo(),jze="[object Object]",Gze=Function.prototype,Yze=Object.prototype,GAe=Gze.toString,qze=Yze.hasOwnProperty,Jze=GAe.call(Object);function Wze(t){if(!Hze(t)||Uze(t)!=jze)return!1;var e=Kze(t);if(e===null)return!0;var r=qze.call(e,"constructor")&&e.constructor;return typeof r=="function"&&r instanceof r&&GAe.call(r)==Jze}jAe.exports=Wze});var xL=w((abt,qAe)=>{function zze(t,e){if(!(e==="constructor"&&typeof t[e]=="function")&&e!="__proto__")return t[e]}qAe.exports=zze});var WAe=w((Abt,JAe)=>{var _ze=Xf(),Vze=Zf();function Xze(t){return _ze(t,Vze(t))}JAe.exports=Xze});var $Ae=w((lbt,zAe)=>{var _Ae=kL(),Zze=FN(),$ze=TN(),e4e=NN(),t4e=ON(),VAe=wC(),XAe=Os(),r4e=HAe(),i4e=PC(),n4e=a0(),s4e=Rn(),o4e=YAe(),a4e=B0(),ZAe=xL(),A4e=WAe();function l4e(t,e,r,i,n,s,o){var a=ZAe(t,r),l=ZAe(e,r),c=o.get(l);if(c){_Ae(t,r,c);return}var u=s?s(a,l,r+"",t,e,o):void 0,g=u===void 0;if(g){var f=XAe(l),h=!f&&i4e(l),p=!f&&!h&&a4e(l);u=l,f||h||p?XAe(a)?u=a:r4e(a)?u=e4e(a):h?(g=!1,u=Zze(l,!0)):p?(g=!1,u=$ze(l,!0)):u=[]:o4e(l)||VAe(l)?(u=a,VAe(a)?u=A4e(a):(!s4e(a)||n4e(a))&&(u=t4e(l))):g=!1}g&&(o.set(l,u),n(u,l,i,s,o),o.delete(l)),_Ae(t,r,u)}zAe.exports=l4e});var rle=w((cbt,ele)=>{var c4e=NC(),u4e=kL(),g4e=dF(),f4e=$Ae(),h4e=Rn(),p4e=Zf(),d4e=xL();function tle(t,e,r,i,n){t!==e&&g4e(e,function(s,o){if(n||(n=new c4e),h4e(s))f4e(t,e,o,r,tle,i,n);else{var a=i?i(d4e(t,o),s,o+"",t,e,n):void 0;a===void 0&&(a=s),u4e(t,o,a)}},p4e)}ele.exports=tle});var nle=w((ubt,ile)=>{var C4e=f0(),m4e=XR(),E4e=ZR();function I4e(t,e){return E4e(m4e(t,e,C4e),t+"")}ile.exports=I4e});var ole=w((gbt,sle)=>{var y4e=xf(),w4e=FC(),B4e=yC(),b4e=Rn();function Q4e(t,e,r){if(!b4e(r))return!1;var i=typeof e;return(i=="number"?w4e(r)&&B4e(e,r.length):i=="string"&&e in r)?y4e(r[e],t):!1}sle.exports=Q4e});var Ale=w((fbt,ale)=>{var v4e=nle(),S4e=ole();function k4e(t){return v4e(function(e,r){var i=-1,n=r.length,s=n>1?r[n-1]:void 0,o=n>2?r[2]:void 0;for(s=t.length>3&&typeof s=="function"?(n--,s):void 0,o&&S4e(r[0],r[1],o)&&(s=n<3?void 0:s,n=1),e=Object(e);++i<n;){var a=r[i];a&&t(e,a,i,s)}return e})}ale.exports=k4e});var cle=w((hbt,lle)=>{var x4e=rle(),P4e=Ale(),D4e=P4e(function(t,e,r){x4e(t,e,r)});lle.exports=D4e});var vle=w((EQt,Qle)=>{var GL;Qle.exports=()=>(typeof GL=="undefined"&&(GL=require("zlib").brotliDecompressSync(Buffer.from("W31XWKPorUfgdvBvNq74tjXKGGKKTn67hrdZ+RAVrTgfUG4fKu5WVREkeB0IqqqJSUWGSzqedPTAYEf9VYjMzqCnEw7kFcklPKENO1XiwIa9DI+kNNTSqWg1zmc80tEIriBTqFbPYDcubwfX6V6RtUJ8TAhZmJkY/DpQt3EnnYba76/FdePbgiCS8GO36r24B4230NFRH8pqnqWl16B+8Un+E3a7+Xz8vBb/F0kY0ySR9BJAj81OqiKS0oN4QjZclvdDEPnnL63+5+frjStVkSYvcrfnhrkwDtPO+mCN08dQLBAksNpYYiT5ILVqkC0ZruZrtq9XTTXE9dwJLzJkEWJL0ewJDPyYqv/Q/za17jlct0ksawu0IDPTtXw0pXRIkGiOXgDz2pKM5HmvVm/Vzttq/M/DObFAHmIJtCB57H+xeX2KMpup+nobHaIjDwAcUuWMX8e/OwsSbaX0hFcXcwmQBFEA832dVT9NxduOCQku17t7VQ5gY61vpI+eZA4uNUiPj1fLlp+P6OjQjbS2qqurk9cpIWf2tM4ff4Bsr6rvnmVS0oXPNI+pZXa2fvK7gM8WeWGDow8Ynsdk83jwIFO21eP7SdKmEOCjRetNqwYtDrc6v+rH/3A+fX7s7j6qaWs/z8ydC3MD6JAQg38R64BosCUV24Ht5mgIE/AD7Mf/cPL/nx/33leqnjb9XGv2QIYQ4hDRpn8xPQ40z6Ih7cYu5s/64+H9PND5vKMkK4khoRXG09Jbh53KigNph+6mhvifW43L9+BrnptiNhiMFkE/zN+eXy7eB1T04vLMZJKmX6ZpKbgDlFAREfDeWv/WXul42B9C3FmPPFOJtGErVkBvMbYaBePUX9CPGtsfDx3civp4fLMMMCjYgFR0I8zFqFDxtxI8vr2zXy5+c0pbXvk5QQMpgum7hH8iC6eTM8ZAtbZYDYb0x+6MYP2QpQuiRrqsLAnKdcDeiwOPzqgbafaYeP6/VHW5TWnbi2dQ+KNmqLSdTdkzdA6RdPj9+6bZrueAlCDn4rVJKguQa22sXCdHV916V2g7bACkCEDkGMq58etfVb0GfzeaswA4swtSxtlwfbhhbGwajmaN8/v3/bJet1CD/j3/UypTSMOiN43CdFMI5ZjIOBF3NzLzvVxd8kOvBTRGtRr1hT4RO+KcfCITVVmF/ugCRqG/pJDKp+eTLrVpvsyiAKXy6NLwaDg2/f/vLbV+eu97LyISSAIgVSAlraLUxphxRAC0Uhtrx4V7z7l7Kd6977Uyw3whIyK7gDTrizBrCa4MKd37IhJ8L5BiRSahrsgkVZUgpWqQZRbIUv/F0upRFyl9Y8zo92TszOgbNxn+wZD9Lb8xhsg6/ZmTPfsAQmdaq6jH75umdHV2x8YBOABGu/JPFwR3s5oUKa00fCP1vL1iM8PUSmgAJaUr/7+pfrbvzYDAUD8w/K891IYUelByiKEoM++7b97hvPcGR4MBaCFxPwK1C4LkMQGQZ0VwdwbUXwP82mNIf7UhUhvpLDlu6D5B/kDJQXGDvkNItdY59yGVLl1t5eOidLld4+OmcdO6Kdx2IfLPvydqch+krU1kwv+MRmQz1NHzMtgqux/Yf4FJsxhSgcAmJqKlGhIaQjOafUxngLpqjD7eDeCm319aZA99d9et4DEhQBIggaChiBYrRQsEtc3/9/l+zu8P1kRCst3SBlCpKoIFUKNYUVM0Vb2l/e3v7usDDIzlnAcva8/YLn8QLkx8YchSMLiz/3/+d7s2vNl/81iDIgUzKBKxYJAIGdRLuMZHlCJBi2RQcA324Aze5+h/3Z5/eP+51/7/t+2JIxCILkEgyFKBqCALAoFAIBAVXVJRUYG4ltvz/1Dv3DszE//FFSSDQCDIhmwQCATZIBAIsiEbBIJsEIgWLUq0OL46/Py+BW93GuSkgFR1JQNXMnjAwFIj3jkeLIATdeDd9v8XFTgXRF+cGIOxhBUxQGiRVKqkJUrCQjze+FJZEAO/zf+/7KicKzpfLFTUUSYWoj1nzQrUbSAmVi1+X4D5osxS/i5oGsBPG3m3aFvygV1hwOnlOc3F0W13mzsvIF7pETunC1IwZ1wigrbLd3YrELct1nvqvSunElU/+6XI3ro5WqR2/vDQ1exB6E4PO5X5o36u8tW5PK9M4KF7gB0xm3acz9i+owflgP4hvwvUFQx1ErKcuEdcL9urI5K9Ndn9DI1wPIKLleBv4gbsbQbm1NFFggtmSTl6DMwHLGqYb/Ce7PsfUu+/57WrgxrR2tfF+ok0wW5PMMkEvyINyQp2qCM0+Hoz0YAxuGC02IS3zzhOGGKnsl2Ivlr1CKLbzmrwWj0F/oO1IYutC0rwN/OMOFc8XyTb0G5QQX0YCFRNHDf0Y7F8gKQGy5xrp72K+wOEhsq1z6ufdieegW0v/wzdSP1R1obAQeOmWSRR3fWmat1LvDpSPoF5A/P/fsvurj9+hM+k5HbSt2R3kNAERhY3DxJqBaoh+R4kfI8oScEb67iX3hy5j96E4uPrQZIoMKqH95H7GJxrr/qKaRJOe1eQDV/L0lsvNlpfy1nZLy6yfYQwCfY+yBS7qxbzoyOrJ7z81zBhF6/cj9tjmQCmey260Qq1hGhnJ9DmYpFxTvyTpFLimObu0yl/6tXQztokuUSkfX/9erTX7wqDn2vd1pf1g+ZytBsT6qly8tdjSILt9s8vkIF2N3eKvFB3pwxtR8ry8xBRtf0+8nSJIrWTQwyP5sBD6w1pya70+SPU+zEifUFNt+ydO7t7lo1CU4cEbrSJ8YPpZWOSXsH8ZJKvWbFKr+aDQ/krjNZvSqZwFE6PWRKnsinLHBBuJEMAo0xBD3ggaQmXkB4fqwWCQH6WIbtMWqmRSSy3MqnlwPnL+QafBJsZOf/N+W8uZJO7+e5XAgmVVAaJXcY8Gl3msHjS0tMcOV3O6KCy8Ei++5WGkWgJDlpzYpnIH/+knZ0EZCr/EydtOy3IZMaTlsVtedVCyQLKxlBCp7GRhfdtZOiQtE4GBP0jAI0rU0856mm7mK2sSdXup+9gK86yGRzg6CPCCa10m87aiYeAazPPjIvibvV6SatwZSjrh92qDF+DQ8EHe1xlqg3p67DwTirH+JIhT0uLunuePZY93hvuJSg+rxzS8j3FCDA+/KypZXsviuNH8Fr9C9uGvfZMqXi6DHeymIjMIN34sPO4M/HRnIajg+QKuJhVujPriW9xt97fj7tchwujC5b1wwUHP3MmaXOGNFwWD7vvnjKRSv/SlR/RbhXPAMa1evTxfFz8/hA6phKAohGEadqBaA8hbL9vB8DMiAR+25yYD3zeXOBRVgLILb9rzvkyci5EM/GyDC1EMxHN7QAJ0zUDZjM+QZl2ZmC/GCBDZpkJDPM+2PCP7QywCiCBcDvByZ4UU8xA+dmfYD5TO0MsDsBE/tBe0BvjhQlWi6CQ1Ex1rOvejEXrATfTxWTq5utPnNy+gno2AZW8UkwK4PGznqYVL6DzyxQHYawhFyprhAsMzKoIhw4aYbkZdyRt1bcUMrJWmusXhHOmFCfDWfbKLzu3z/pw4T9aTpK/43Epd10mUCuIlYgOGsFgnQbVnb1mNuxIzMXwEWAteqoqoCZsMSs4xfEWgN/kBJT3kUy2jw74szeH/SQhCFuSu5t7KfDRhLxpg/L5dvldq8jiMYc9r221XDqqvl+Un7wT6fG3X8Eo9lwt9q6xBzt5/mzDqN23D3l4biT2xFX2szm5HB+1o6CdmoNFiHxF07DfqWdp1HdaBC3bvoW1NO9WDbAHhGDMDk2weatSorssnNsO0LJsHSziRIqEi3+5ODYrhOaJd2rt/aCA2WH1pmsGFlnJ64ut8mZuN8GuwR+Pl7KPQ6c3easZ1ZFaA1oocp/wiIrgwSJVJsH2yQo0DMsyvsePTBGdFjv7uKu97Vmsyee9IJ3jgmvuk2+CAEroiTi5uXRSN4e61kry/JzLSyIppv63XmTPbXL1phWKNzqHAgdQ8a/HKXJTHractuIGjbgNRSRu9lfZ+MhE3RYS8q6GwoRsCpwoAwgdFI8nFem8DZrqMBqTyNCoWWwOiLdKVIzWM0JaWxEt7K74+3HemWHcgb809v+tZyRVu/9vd9+K07rwNpnnT+jzJgWMv3fGMkaycMW8S6IvR9rJPrXibYWyt6DBfds7ub9YdKdYmBXwgUqhvKfVl9tXl6ur7/kzhmrM+syeDgbIeskMH3NBk9orUTH2LInEeiekATQZ7w+eQliyWnIH1cKECY+PfGzEeVFmuyOuAe3R3w0ROBN+YyTiUh49vZkjO5VhEPJZ1eX+9oyxfWBiXrbdMEXcwAgDmw7/2azPNBCnDYYINDZ4I9bQdWLwqSAJ6tczQ3DkMHnHEHWUpIZZsEpKGEwfiDQwSDwZTc2pNxoYrGkbjUYQ+jJM0gICUxuKDd9EF3nT7B6VhENE0nQIR1wqUtssQmrhJy02n8DdPuBd9YLQ5Qdi75r2qhRWX3WOS8c4YTS/N88QoUa0sZzXAVwQUtUARRitDVTFKsOKXgdLf8L5NFLv8IJu+VpAWI6KPL2baNT2Lux15hKsBa3nPAYiAmrXi807/3mr2tZOdMbO+jkIbvr/CzprlIKtQjOG/mDRLdWgrUXoZGuuuJXVVQZFsXz0Whpnc1/AbTTubXUBirk7BIQjuEVt+7T4vnsLll3ySAdSUihTvuRe71C6eeiGC9mU5IPTu7ZWqWEuSO5aqnAGH2fEXhYvJADlpWzlzUX7t0BV5/tDfBS9bzYT0iJQVDm5up5zZvU972DrBrNFaS1sIHuOaVYVeZFZmESNEjyNxs8HVU3NeVRJcTZYY9M+qbXS2xnGn85lPWBrM1H1lyUDFEfY1tUJB22tZrm3yz/8Og6wHMrKybUbDeMQOfrST4CsHCps1yD3hyr6pfeh9yWdSb+1XDs19cz02pCw5wF+3mIer1UaRsLDJIgh5t07UXC7XA31pR/63HReOO8CSjYAIuDfI+TEpRngNPG2l5HqtA/a+xXmfCTnY9JAHi2SZ+iVZ5JujfgB2iGDNYrQtF+k6QOvINzLvbN5N5IZBjBm05BXKrvvWcrQk7DuYuh2kT1Ks8l2Lq0+modhtBDTlIVFWe+XfIwXwvt97nyxRZ1JDTmAJj/fPeP63dNXyADhZ7rpEiGU4BKlWQX+UUwwEAOCuQazxgix0rcos4MDFfDBZxeqnEXpToqo6MBJ10KJf4FQd0BwRHtB91cuW9MtkTWrrxpa0AZiramTWclTUTHUTJWyufXflS/xoUyfxCDLN2VcTQUlaE0/e9qFzi6DTl9LKA+SfsM3Uh9GWib2mm7IQNStNgBVI4eGgWTb/QH4Ub0+nZl818CB+LTXIa33TycBNalO/GTHwxxcK0V608vX5LMamSJtsTKu7RS5M/j17cftnt088iYTkBZjPg/JVQXKot8Iwb4Ykbdgl68ApZgPpW6nuXo1EWFuk+E7HPVa2TJSfhqK8+zWLNZQlvrr8MfoALTjVnIwT8TusioUOC6WodOhCu6ERWg1GCLbpffK/k0z5i2jDJ22ByJ64V8MsQiGKFA5oWYfMq8z20nh5nKOvQ42JVgDS+mrR7BIjXAX5A8DZchGnXBAetImmtDz3ZsRg+5UBp6BpfW+i4HkZeP6jQgB2b6xTjyp6nRglVIAnqQxAWM+Mm0J9UMg9ZHbvj0662RjluJz2DKT1SyJAey16+DCJMmetZba8xjfN2f1XyxbpI4yd8mMHpGhDmS3zGgGHDw+oyMH07iMuRdU5NAHvVoaExcrPjZyLCkzSemXPs2KTTLxR2aryYtzqez96YEoZyOipUVF3RSR1xU1mWI6G0GiU6BawOUeCqNmihta7Wir+cpIKh9LwNwhNIUncOCMECgCBiQNbx8lwo4dLwWDF0iYk/UweP0wb2qx+ALaXIJ5GuUkhGW1NcSEjDtyYYZbZnqq7JuJIZHrlvMvLhgfrT1etRJTeat6HzQzyTziu2to/c0PCJWyJe/S+6SdcrDhBdFtG4N8o3Wk8BiZQkR2EB6kfo+75qEjJpmsa7q6NDJdqgg+8vL/C4Bf0X46hoYVIc7TCNjqw2gb3w+BkywiVQ5o+LbyXiMszLrBkQSkpPUY+Ym/EHLVggnBaK9L2dBc42gx/w59GSP6n5llQqztzFwoMqeiQpHVEn0cl2H/S8uJJoqBOd2PwM8T9dOfKqexzZ/sPpqQ6lRpgQEj+HYICeuMCi+YoGQHvqB9R/sSRGEAwQEmGwcu/mRFbL5AF4y8RH1dq/6++eoiT0rWN2ylGdlakJGfqoK3APlwsxX4pAK3XbW0XXnQln5JwQj2oxIH/ggAZNJr9zA4036WhtsHmHSEhGxgr4dfmwQ6oyNEy/dEKBrjsjH37Z0SL65XI03FaNe6htYXjDhcrK3y2umph8tidj0bMpVuHnMMoQdI8XCnjylBycxBaaPh7t3pfu1nmwYCoRBMbi/7B+6Shsapa8C8wZ+6mfWdvnvKXtCv5ut6zOHnFZtnaCCeOk9WjxAlk2Ic4lKbjeDhvTd1hNm71QdITvId2zfJZIdyRQNPAmxpqAYp6rhbcX1yBMwadSeGnQLttpNqgEdKiMH0wZF0IzyJcdprtnFgvHywWdbb9Z9kRzYtvMH4wTtQhJ68uWaHXWPzxvH04rPjRpvNbAA8CaA8s2naMH9Lq/6T6zQ7oO7EJGj6jyqXBKupHWoPEz159mx6uhUzv0MHnbumleD2rpXwb7IZ6VGM/CoZ+O6hmXcPsUC2+A/kufUv71IIs20U0/zur7Dr7AItRoE/6JX1b3JAalNg4/NwPDgzF0nHrODRefqLQz/hF73ih0IMOLqVc9SWFHFpkTYKMf3SLRZOkWT7aA8R345UHoo+Iu5DdfFmfZkdhURyFABs7QbQFU1tDwK09lkj36pMe8sU254jL+kaDC3wHEeuf2laeWnbT//0wApMlyUK7WtT4PQ1abQfdyu2hvDftcSQ2GPTmDIocau8z2HJwv72ntFFzxH7qp/NmeybzWpBhYFSorfHhbbbkUTSdj3bVAPLNzZslQvJrnXBlzd6gQGZOw84DKLZthc/h9LTC2glE15+AtF6FGFMmhDY4AX9XQvnRvq03YcRMz5gfrBCe0DRZYgqFBPC/bTu4KmlSxCD3d7vo6pTCpvnXWYmqrIezKUhPY7FYKRma7PMemK5OOuKizZDED+0KTD1pDIJ72uP7SHAom1emYM/tIF+4ISO52FrLXxpFEhZk6lGnk8Cu+zWQFuKGw3PDLAlw1Plbdr+E7IW7xUlyfSxk/bcpI4FofJ5wJ5qX+vp5PhaIFvG6OQ3EyU9nPdZb5kVbLdujj3SBIqNprZMSR3Wo27C8dhSF4cXF7K+pX9uVGj5207XEr7E+E4wfnvg0LC7KDJTX/HFIU+6aZadafEA36NORHG5mDUKkFkbY7Y8S8NdXffovClpIOd3cBMdC4LJkA9kLyn/2ihMj/Ou7Qdy5BUGCb97g9uO/O76mGMvrtE0iMjnaWpvRpH7ZtmJ1hOY1wBK9C40bBbNnCMMVkWacIXRIn/jXPQUzo2Vwlk6s7JurDD/8uEXRj17sdBKDd8tURiM9hKZg3lKi975aAwR3yOOXW14QFHjwT8sk3S+/05j+OdR4C5s1WPpuI3tstjvoOYykKjqODrFrBOZ86nGxeLHkeG1iqqePtKOLNE/9bh+YkBiZ0QkUuuEOpgmSEdGxmFIg77A0lR7gKm0TqDA8DRroS7rVvcm83EBwZdqFcQHk7Rq+ScfE9Zd+NJTkhVzIDUB7Wjcxj7IQQiPBxGS0bq01B9CCA/JEGxywodXmWJxy7gKhc3ZHMG8RN+JFZmHXATLM0HDvL3fNs1cKgWjabx+VEkeaVznJTyc6xflWXCOuI04bc61FaIFraG4BJpW+UEwQDQ+c9oP+rysYe07kvmUMiqedoCeFj2WTo89KMvNSwTcQ3tR6UelDPBTPFRPMYbmSCZP44tnp6vjERInpd/tWcXAaRJyDPFBW6ccFCzDIL2d2+ICwctKGldFAv9zAkuXOgHj5rf0yRI+HW5xMg7QB5IUEQ+F0CgLZ3J+3EOh6Md88a7I0zwg1vz1CDvKUGchNVx1YhMjAPguUtUh3yV9cR2pSomnV6ns6YbZkehIC0ha+xaghroZxvODV+c1FmAosVndgMlFr3Fbsflk7t+slYtsChGZKQXQjLhYbMV3MI5iXGjmRb8YNMhb9BB0+7zcl89Iy/ffQzj1kaPijYv+CEbMA+WLuKc4DKS3UdgBDC+qSRZpOeehtxVtPM+FNmIELrzHJ7HU4Jd87yedXLNg0LV9vAaVcw5oN7W6cZZIklaIi3Q901e+gZNTVE2QjVORN7faW/sb+L9jMS0X0v0L6l0V/EoXf9NwkN3PaTKL6iAYm851y3FSg+pcfhaGPQY0IuvWjRK5XhsrjSL0LU6z2I0CfUMYrU9WxD2iSNDi0aFUzym8vipWgSy7uyjpPNtFsrQnPamG24qTD4AQZOz0qpmB05eT4DFuTQK/a+1m2zzX7XfDZ4TAvKp33YU1Q5DjMoFkEFD5yAf7WV7b5K0N5025lXj5/RXJPZDyj5sspf3lJxfJqO6zWGnGLDwGwptsGkR3H7vLcn3dfIEX6OKqhUiMfXAIeYQ8ojSUkJkfdIIFwWkWgM1aKCmHmUR9bFPMB5wweU6cjpM0SPOpEOXiaamR8+rmT4Y8PBysfNroGurbzDrHYQYd+q7fKzN5DSNwhjfO/uoDadd4or5qc1/pdGOKlTEY++3S5wZe/TdU3PICctyB+9kWXjUgHGEVzXlBgWgyKJ37sQBlFTHUpss6Vr4ep97RhCp9+ByEPV/7qSNdTa4sE9gHdS+ftRgKcKyz7OF1WL4C633o2jWL1L/TWJ3YE9j3iDsZkqGdMH/8wXk4+mMztx5SFNY/8ty0KoGFoUXvkmrtk6MYs/ieGHkAbEofZ6GUGwh9lDpHpMP3ED0QxpCZRpHv35MLh92QnIxK8eA4JgMrPLcU+GRl/V8JLKv1djK8gikVGuK+XkqZrnRW+ZBNt8dRP52baSHY8Bn8kZOI+wFqjia/z0PcckaWDvC6Xdvi04fq/t90FZ+yl95zatHgtkLHilyW1WvLws2l/gYK6mN59tR6XplE1n7XcPcgeuPf35XtSG7jjSTlHSwi+fYxZGGarrA+ZYDFh5f1pBnIeowrpQqeFodpkX5w71tIBlYvuJCUv+3CWA1HpfDSxGvrDL9O9Qpd3te3XfPrwqyehD0CCjZbjsUtvLJ2o7CGcGux1RuPwwZMSPNstookOWcUywVh24Xnd3mn9OKbUgCecgsrGo7DCnbddXi3puDeGvWorPecgWtdqRL6p5VLrznzwocoaseyuulAKRW+g20nrbzCA3x3/tgdcP7YERC2Ee6PtR7tSE0qAhCSxMJ7RHC+H1yQC/7OEtqdHsye0DBNsIzYuy8uVIBqb0UMK88MXfRDyXVeY7KCEI3ftad7At+mKh4VTwXbtjAyhgzOZmD9I9NsfP2lf0wTmItLlsC8qrHPcHatZXzgvWXtW20I8Gtv3DM/1grC9natSL07G2/s9XAgIq+D7S0IMFSDP4Gyn3gdCqQyz+vG0Y8CyKu3ZZVd1jffsuU15hNR1o7KjLrk0XwI5hDDHO13eurNHEM2EXkWyrJosHZGZSMSAwlww4kJBU/CJFW7YmKIU1eeYjjvX2pPpntYJoxrgOAdFAszFRGqHSThA/rgWGqLHmU/dVJiCS1u6lw5WnlBwKE8kYVT3sN+VKiwzgDlKIsoW5tqALe1JLyDZ9Zz1TsvEiE4IrOnJyq6PRlDBKXjZq2IGOMtaQxZE2ISASVC3keeQkAYQCUToOulqA2Ms4TnY3MDn5BfxmulLWtt8AfxGS+FKSQMxG1hK6d8b5NRfDAZb/n0z+L6TRS6Pqi28jU7fmydnni3j1L2njiMXHQxS4pPhthTqxu+lWROUV9yA0tZOtQgi6tArup30jVL64Eo3wZ4/Mn2bNnhwP9DwDjKVoQuxszCSMfECqsYR9H3NyesehagQfKjQ4OUJLXmB8Ug8wVKNjwFckfGFpXZymZn45BGJlePryM7jLROUWvpSGs+LpujgW6MM8vo7G08asad8dmdrzEOlIg6T3O6NAn+FWA8WM4Gf5UKW2wU/7T0oWFoxtElCHwUB8ZidHAOMLpgkaR8PTtKIucQiMswZTdVRk6x30czroZHRMzAgk9piRht2+S8PlqQkTciVznuiaISdTA5heJKF7zfr3yMOSijCHJqpEv8qOtI9sAH386WhxuyCmh5dreR/zrgabApF+yKIm0yiCFPdCvWqqtV1OE6a+bldAUFw0HVahRf0jyrSqOcphJDQqTCqUhAaPw7mNcWBzXkmpBw6LWTumVe3i24yOsBCRIedgBX4YzPkyei4PnwcYOkAw5eIhoNIDw/4j2ReaG8brUgeZzRw6uHKIfFI+6/Xm3S/a8Ra7+orP3oeu0Xi7UfBWuXMVjI0f4vAnv8R8OWNCj+8QCG/CZ84amC1xJlcf/QJ/S15fL43A/j/yJPzi0NY9YIGsZbGjYzAnLWAu9lCiPBBUPBPtgxT7sCviDpg2LMGBsFR1vGfqNsvEC7fl3El/Sh8z6L8lAu+RSLp6l2lbYjS7vYgQS/0ZAzK01Za6MVbdzDw8JoUvMIHsQBAsj5gYrE5G5tPD2eYGIILVvO0dWhhb6uiXhzaKKpVrHrL+yOxQ1CB9iclmOPJz7NqOgQJaioDorrTMd1IJM/srLAHMT70Z4rLwxyDB8euAH4D9EAQtfd4AM/2QDYow3oXXdQDXuKKmkaX5ApmlprCzwNAsLBRX6xogPhkuLNAp6JCQJDTwbqL0Ii3I8gMwpKMtCC04GUZt6fBtDbg/QIgXvWqwo9CU397ZEfuzJrfVrspDKiHnMBfbzti7ga+zzOCGFDrFPk9Wz9TxbFfK7XlrmOh5jzsMu4D/MhhoHDOUmY4SibL9JpSIcPwoZtamMLD2PMe5HwBaGq3NQRCQrTcF2PxqlGwTUAYDlEoBEaEWLwOhJw+fGy08DDeuXP5s0g4/Dq/SiU8371jAPGTOYjoUezy+ulftzTUN47EN4r0ZxVmXCbK3iDugxfU8x/MCnZ48PEqS/IB0PEFqAcHGuRqMgHFssG2m+IGhR/d81eueGX8ZHnRmgQoRwUi1wWHE0ZvzJxHmS+Q6cnFmdyjUdCt+zjJflyUkrz7989aBtEN7+wAkVjzefMvg6MCrNTLMCUODHP4jGXGWAdK2hfvZ2LdoEeS+ghvuYqrrT5WvZWUSYnAICWB6ICViL3V6mmdtl4G1J2IIJfiCWBRsL4tNcIASA+iSiD+wp1wNg2GCq4sNGshFG95dAoMm9sSayKCV7HYHvyDP3eEcBj4eFcfMiHmUfdhviucMRUVEhgZT4y9DenoKFUbQMYwswUGtuviO6V1e2AQm3Y+GQ8277Bblixm4lBCfTmyrEcege6zemBfU6oU5nbyh5taxPKANbudEbYMSOe2j4q6k/yqbZvR1Q2PZ4GZjvAtNGKB1jhQY34gkaOVLI79oB9zrZRjgAByt0cG2ewf8xHSo+eLxYkeAafxlONzJKlkwCctCmUZw/0I3qSc2BE0diAEHUritQYOLYJt3apIfK1PYl0ThhjuHrBMG8mLVOJn49xGrsgbTlWDcHaOWQHp7S+JMyG6LB8qoc6NpPRORsD7SvJAgtPbipcCoUs4aG2e41hN2ZH/3DNCeyqHv1FvZDSZvTOJHz4MxFLhhB8cODAjWBdDuec1MKTMeSltY6YedS0RxziUGOk1F8JCbOo2UTo1aPC5IYDUoyLaYX2+iDSZR1Ddv0+QTiGz0Mld1pZ0T5pPQSfF0yz2RRL1aiYcCVZesbthjgBAfztQUx2Q54pcz4qk/1sUrsLnWT+U2YCc0GcWtp/+O4TRTljwTzEyWXE5gdPRrMnRkd9aCa+wlyd25YjYbHJLlcLU+8E2UVtekRam2cU3lvZnTG1Pnq6xBBsvc8RW4BQOSu50+fZF8ESd2MyaibBghWZU7lSM58ZwWG1ce6O4O6Ef/EoNsGINjMtJ0iZj/OAHbJ+4hUqWLnaRe4p8lEBsyTNW3QlYaKDhySG1CW/WZj1lEIF0ozWPPbl1L86JRPpCa3B7O/Oubhv5hZ2H82/MqF8jIj7GMxhZEX+AlKsOWrZrHXysf1M3I9S9vs/wDLGGJoygg23ytI7VYw8rA0Qud0E4PfSzPjJGE+dZ8oJ9qlEAnAkZpbg44rIvp1vE9Zbl6dnVcYXUfh6hZ05sPyEz4syoB2SbSbGFL89Fsj3nNoiS70VnWSTIkH5kx8m1vREuyXoQtIT9VeTskkSe/xxbzdMMiJfZKSew1xP25mZmr4n8/Ca8fPpuGkKH/m3BogOmuYgvKP17CNo6WOOYtXn0IWv0Ks2ojjSKKkS+0JlTakl9r0HUt62SV28RoPyjGfsS+5ZdKUq4KrlbufywZKXaKBkkAq9mwLDN0J4v6A9AvH+cKMBiiAgPl+knGC0UmAtVl6AFVrYzRuFw0NSW2m0ruJA0JzItB6Vvl7EMQwvXqrmBRH/ZsT9XyurvXah7gur9hrt4JEXO5daBJjsde2jys3zHtQu37W+Dbay2dX8Y75ZoIWNahstaUkz2472XF5fHPgg7r9b2hH3Or/p6q0AyuEu+YPJc3iSS6pidB2Wun6bzVk/LCGIff3Mlf7UaREuzg6gayZch3QCsx9vj1kgR+gknzG0P50fsrlTChS4yrnfwK/fti9o8iz4vRIRf2NbqBpPruqdgOgJKu1yIvxa0DZjtXxTmYPbT9u/oBi2/XP8u2nO/fS/8d0/etAtFp/+EP4wnWUq2ZNX2ga7x9eZt6Tp1I6K0NTqyqUac/uy6gJQtbk5zIxcQnhhsrV4bcJnSd85w6ZTMuuQV9PGItX2ORxXLzI7qkWylLUeWIGDBZxXOExmwsm8LMmzBuCGyOptV5iNMX9yCEh30gAAr5l6DAacC94gDKCybGOT14qaDd80RBB44p2i5dyYSNIH7wl+zXAlrZR+Yz/xMHB5EDCVi4SSAxKSb+lMsJJ03+gJFDdkiS2u7yiZuROD1UUIL2Mu2xX4DV+NK4U2OyU0+znoBf007VWAOk5uM1kcYG2H2h8/orrpM0ybBEkd4C+wNF3/xC3+nOaLrc23dRuSdDjSg1kNQ6oZ39TwZtuBMM2svFrsg5NdQkfm5khpnmMOGPjxG0TV9NaXYcrCwcwqys9VO/GCs4HDeZIyKOYZ/SCnhDW4hXgT3dmLi5bZU6UnB77QQ1SQEuKefUZoRiLZ7TYG0CeMhVqDvHrAHHazOO1dQYFIiU02bytnpbVZvby7PaL7hrvAP9nChOwPtRAga2zarveuYME2y84FHH4EoQ+rxb7ogAaSeKrGIb6v6TlBrV+yXE/usxD8en0z5Km8QaFT1Xy6zHINXhZNXXN+fyOuaE9DBL7R6S+GebzD4f0DrhXXfFZ2rtIeqJ5Mg0hAuxck24vwRkvIc5+ElGpHwLK5VwY2BGn/ee8cqlm2X5ErDYu9YXG3kDnMvFTh4SogCdhBU97dnMhhFCKwRZzQ+tKf37hYCJ1JP4qHkoQa4iId3/6Pa7zgGV2YiOM4WmxnSDotmVbZcnQXUIcgmss2p7DUMVmMGMWaDIrmP28kiz9wy1LTkmiD+x3XUkXCb96sukQLYDgLsj6a+crNQL7Ij/2pPSwRTu+6+Wt/mO3SkNqBHKzXVSd8q5FbPfFkB2sv9ygFCmKXYEBKuQPP6rDO0rqzSaqIM1+ngommeWZatsJFgJ7ZGbXL1K+tc9uiPZH+bhMraoLTSksiyDF85IxhAkMO+IpvF36kZqqLREmstWkwd7kXgW9lItjlhZ2qaCUumhIB9KNgLvjBzoioZTaQ/JUPUI7gebd8m7meDC8JDoLOjYfsxWVTk/WggDCspTIPzCAHGFTxeGONqr8Vpzi+hfgvzvX/h74m2oIEKUaytjOKMHBygKdsKCuoGytI/6/u1P+hCZvXrprAgoXd1T3oS6+qYhV8PTGkHihK42ioR5lq9yFiW84dAN1cmZ5h1Qtc8PHQ9PpmZ8F9GtOU3gFGd/xM8RvMEeQfZyJnHPcwIUgVdiTMf8inBG18+e5V2rc+Z8FoliuAanlzphKBF1KmJ3rFO6w6ADFA2fpEnH/l4u55A59vQHYvo+jlCUjeaBuBWslh9PROHNu/m3ji7jSxIL+WQ3O5nJEZ5R3I+UErUeFliMP4tTsOsOP3vVm5/cauaoV5Pnf2bQ5R65kRhC+jYXLxirbC0unaCl+n+l8Kb9BmtBxlZksnGnIjZiKv8FFQgkI+cBnoN086pnWIBieyBlXs0FJ5wMfZ8LSQljt81e2LdyiGoNHImC+oyQePMOeY548hpFVGHldV5yOkXHx0vjVwXoAce3lRh3xNe8o6D8pYZkmF+pILkJ8/ojG813jyYdMJIs0mOqXjtnwDLE11vMVIVEces7B3mxybX0x65Ugx8ABjXJqgRi/a0JCxxQDSCwWwBfJNTFfxdF8dyzkfHhBBuqg5WMSHjBfpg1blgOJk3leoa3wKXQTps7a6x8T1U3K1/ADlX43oHNMhJM8IdjWWN3adaZ97G04mKJ5euHwhNoJK2fJXgMp2peQ3hRRuucnPxuljofnBtmbQ48U+lFYZZX6thPGz/E9R7w8otRUa1PYxW7tgtYLOV2zwWypCKv4jGJG3ceoyrwJEd+p2TkqLfmZyMAT6LFeRfNc7sE5D3b2RKpNHwD04VFos40vXKTRLiOZSvmbsSASe2uDzB0D57NnDshB2dSe/ieBG8HiGDR9gwacIpWHoOV4WzsJ5H0yPUSDPR3nYm7A8xPxFCvjCAsBUGvM7c2GCgeCuSxLhOzroD7qo7kprK/ig4+sZOFvXx7/Ao73+SmssrPP8AX4cnSYTHx/KvKy8YLbpiwUHVvxhd6cFWzVU299gLxx2eFP0f5YRJS3H8EchbAuR2wV3BTZX3Ja1grtlnJGfYRQD/sgd6/SZmjMFHpwCS+JdWL8RMdumW0cJOGN/zv8O2uIrZDJSvDMn8oOJ3VeUntcIiI0vWr6sbEQZINsCm5V1Vp36CcKaS7sFvGXQxK10Q7VFOStLxnKhcd77Si8QsAU96gnDV4m9VbdOqKUy92l+kvpNynhWR3uUgFpC+xqv+QckwQkPPsrxRaJ4iioPR6GLvmIvBzJHwCnh7CaNvZyBFnHeQHsDQEdYgI60fhWVyW6ffUAR2J2Ikb8uPWQyPu7A2/sjiCTMt6stLiJSIzldzSslruVQIcAANI4dKUb1IKMsQkNO9+PWu1McpWReXwpHQyRYNyazmszQ19A2Nf31MHnsQVAB1rBFa/wmcjyXnK1Z7aY9Uvij0AMAbXYsXC66GxABC3ydjJTVrvUxB6x3UoldGMAh6XIsSg/RsEIwv4/Xuj9LmsGr/Ch0suxQdkGitgZtv+fdP6A3oVt8UyzXwRjiTzWOC7rm41fYvPyelhw4qsv06wqh6Zu3q4U2h/L++ig1aKKS1GPftUp/n6560fePSbRoNiNGxts2g2skCBABx0cuAKQAWdkmu2jOYdceQUNQhHzv18xwwZXl/ziMPUNrtMNo47obazPsVllm4T2Nf8Ull68/FIfTagHtOCcI3INy0kmRJFsGJ5LuUqpZA6dzljQMNhS7HOTb1J2o7vQiZaqhqCDFep8oyqfyW0ZSi4PJ1t21PSJws+b+DM006McHkZ7SR/y4h+uL2RYcouoos3f0Roc4wiqak68Hyn6Y0JiBifxWHWx2GUNbf+3GGYd7uyMB0lVlGALHPPVfo4ebKB/ic8oaBctQ1M4jbcWeF5YPh0dPa3brb+wM7HHKl3rH+OZ4v9bRH26XQaW7eHpRkY3H9fUEbSYFDEEABfXJgX/fRSA3vvhBlb2fahAoUYvcy6hKgQ165hDbmG7Oywsvi6uMOvtxbXTLubqivDmHsxLpkAOum6UXV2ZyPMKYXtS0I+zxxXyc+t4khW7wdByfnNvYEpzCYqmX+7l4qNJs2PDqJwbi7KlkV0YPu+fhqK1UUph2PrWymgPPRJbARO7xzxDnZ3f/+JRu1+2ehtMMxkvG3xWbxrn/Z2KCylue9Wz40mZSdXu5dA6n438NJL6WUodOxhUlWe5bqLqLorocAizZkNevZ8rKveuvtva+51dmk6LKDdfxlAH7drRAMvkpp/vNoh/+rGyQWsCV0l344d4DtPnp8YOKBZvWcsJ2pdaZKKOg8FGW0XaJMmyT8To+NMs0/a09U3zpNfIeBSiyDzhu95IR3pBctOTVW10FRI9Ipl+DSx14RmPQv5CoOSYMmsi/K94NIHyNoXYW8hfZRpHrbggQ8HXxu69m3HKQ52ZMY9JAuIKZYC40h6CXk7qFqjD3LkAO9bf3F0LBm4iv8Vh/JfM2Qd6wphXEHq6SAAQaSEckV57VwPJRdFc87loKlRCRToAjwbm3K3JFHiptqoQV62wzAha1UrjXbXMo1wOFW0vRwnFWV4Tfzj1pzx/RtQBqAGppwVCG8ruI8ZsmbzV1FnN530DKI2kzAeiWG2kKVIEl37HcxgKOyihQQE7JtixRMzpLUkGPpNvhpAUmzO1yUcFHhlc+vYC6LVyqhJQ2oQFIpF8ytn7molqQRkosKfqC0w1qQUAPrs6rv4Y98C6XCKSHXfKxhsBAElGM0pgGRGHLjj9wA4MWeEdbW2TMxiUjVQWFdN8ZAyqorOMNFns7aDo/VyI4rvHMZ3pyebD6UVKv+ECd7Wt5C1GpoDU9ZBSklfxlp+S0d0pKCPqhSYanFASKxJFcZzImdQV7jPSZc24fkqaz52H/ASyCWR+dtnjvB9oaLD9SbZYOVH14znooYv6CzyPcLy8mUrFJVvi4ygwpmhuGVpFDMDgRnBmkQLIrQHzeRxBXn+FCa+S0EfBMyVt7aBnZ4EOE+fitx/QpAAaW4Jeao/Y60oD0ka6gLXNITysBOPVpK0iyUNShhfD9eghRYfWZWvZhbBBiBN/8kl+et5s2Pfx0DWZkCylF8wg3mcyPg0MZI11dLsYvV+lLz1vD5YL0CEF9FWW/qBWl1QC70F3WgcPHrnHrwi/MJXQn+3r+fP4FtAc6Nh3Tk0HVIMnz9OY+XgKA0Gh49RIBeyvV+FWA7OQOfxIpl6mTSs1Rt7y/ObWOwxBFiydK7nnUIMC89jObp7yfH61Htnyio94nTKSoxXl20FB9WqLS7v545UY1+UFbfNiSa3z+v50ztjFRSbnprXlug5NgbnyzIjCeIxjSt1TxNEDJtntn3vpBvLNgSK44Y1+w/cVWNujAGks+ztwiJRQ33hB4+QOdxJ4V+Zgu+IE6SDOAA2/mznebt4wTXecOSX/9XHoHa3mw6gXmcXNL8JxTIj/MLmSwe47HrLCEOswnCY1KCEprKvfz6iy2znwlbVaNomAKC1ML5q0f0UwrKEF+w5+ZvuILMtP+13ym9WNX+DQWnudE6n05OZtfWyx0E1ackhFn8ZbWMOVjlC0Cv4NYX4wRf8NVoQTHSsPFtX9T7DDvcFMrC3TRvtqkjPyO3XhTQ5mjoMyZ88sBBsUqfO6cA+BkQmYgjrcFX8hPKVhbzzvEgCq7EVouO7GXPiBDZlzRSR/CUP2BV/3Gb5An2nQ6XOV2qjNwXFWDFxgbVDnSI/oCjBj4CPAEqHYhAd+47qL6bHlvf84Cod7JVnfNnN1tqskRd8HQVReOx3+X+fqFTfsp9ffvshXfnioog/g7bNDLTbNlg/1iYcf/j8P9cMNs6Xb9gqn/F8QxKN9ADdvmkOLnWwqq7TeoPOZ48G6CQCbfoQ8Abn8CUN+HT9Qa1rLEpTmjPZVJhV3z+/EC1tJEhtldFHUGI20wwxhV3rQOikh9BCtdJ9trNh2+98kqMNPLr0Kz93EIAljZNKwhjgUiou+w/QBdx8iohMSNJ+xScMA6MD+TSHypFasZ3r9gnHzZUAX94U8JVowlyh+dUztyM1v+6E+kcFwef7I7XZ5s+oNSeTxqHcByWg21XGHLiQQaXGaBJ9bLTjonVjekMPbI8rmyHYowGSR7FlpIVcmUF7JJmEcHuabB4SmhsrOH0f4oHR//YqOWabuLOuop3BvzV3cnPgzA2y5D6jIsc+CDYDGDRI3HQ7ciNXssv98tbCbaodVLRPWtQNQPylFnJggxf0vK0k1W1+0fZFEcMzAFSuMlmM7f044PX7gamDy/q5uOwriZ/zxANDzIYfmgtb1mgH6yt4Nl4gB64eo0OGpeOM4wdcb1rRpXFG51/KHR5wOKKaNHbtMKAYLqjmWAtdbHkbnE5Z71J2JujJ3v/4EBVcgofULWa4y6XSEx+qfLT9zmM4n6MQwC81Nc4LZMmyzO7tauT6/m3XiFpb4JY/YZ+qAAweiK3FkacnnLBWkZzpffAFvE7Rctzx+kyoF3p3imG856sZG5VHR7IZSqqHGrIkupcGcq27YM19JjOixZsyhvgQhVXlOKMGOnykx8GvTxrR5m1SN2Q2wRlVSnvLWnvLSVmNnOIRV38RbaU4kJZrdUckQmDI5ctMUqCewwPhfXvXiR78V+UzpUF1aGg4qwrwTXt4XGzy3H92c2SPIU0ngzrPPNhLaR4cTrlPXqJKdECbse+gAs35i1lbX2mNwwWnVPaQPDXZFOATw4AM5XNqLeIFc3rFYPsouy9gvRa78wrRYdE0N5x73Af/X4pt7KMz+oUMDsm3cbP/oYTg4Wx7ePxHeymGuUlJSU0mjG8rsLHZlPuyvixXE1WI+c16Rzor5DNsW912n+bGdX554/gfdmjw5nbSR7ZEkejxP44xP0nQbnDkrM9T8qbjVC401ARAuXWbNh0RYvxnQ9VRTLmIM3AodjS/IooRABlKJBXWR/yTHV5McdbjMhMaMiHfQAuDbTqyeDI4SHbV0ESHGyUZivVREGpOQNZERpUtI18tMTbp4T3fRurva77vCFteZD6ifHx+iCGuoqWcKDpKuDGZvGU+ypbdjVd2mHoHLn3l5L0RUg0EovSNRQ3n0dpZ/vWK6wobmBSshgilkN8M3YgiMHfn71pIStXkjxQmhcsFxcN7Mo1FHRfm6vEEVaBtZmKp9Teycgyf4hs3X8g2tRTXuJtOs9r6ThMGiSIE0aMJ2JL86YkxUvCd86q6q4bVzM/jrcAfHZUSIEWPPdPTR26Wb3rjM3uBAm+9fDr/Ven93aN63Z7vDXsjlsVX0e67SsGMK9XrVPxEDSzK5YNGoN34Yh5D5V6ofTWSpe/+dyqFdIAvHrB4Cc8QDQD9uX4SY1C1ovR/7A2BJK3sCPu05nsVxVpW2cTtyfcB16ckiS4mYMeQpDd9dqByNUyHq3Jblkkiy03Bh4umXCWWTmqKMMENgjU9rii+ukZhVM3GjKHNVV4odrmLFDPHyanmF+8Yn9wuNWHNHPcGoR4fCbFzwtw3vn1gflgndb+VEv21Yid+GaYoCgTkecNw1Q+I4jX0TCLCeqQ4qQfE8muE7vxJJu6iu3ay+uhyO8YQ5MCF5YVwC9tqWvPfyUKn6jXtBDH6PAHrvSeM55pdWBjCocn2cOCBZ1WvmViWNnNqywvuk8A/1N1vIPaojjGipYSg/XlXYFB0pze+iEL8ar7gLtwEUWoCxutHM+TXhk5oY6uG8JGRn4w25S8HO7pTfHAPx+uV7uOVwSV/xHELbiq2yqRH6HiQ7NgmRxdXcvncLNb06hjs0jmbXyLlA4MKEfLntelJejBQRimXraMV9PQBHc0wKw3M+49h5bQIofzqtOTkAHEI2zzF/blXBlKIr5B8cCSTOBw18plPOxQolfOHQe9PkLic4PFvhg2iGuAPZQAY4+cls8dxWQjAU4MwSGO+5UlDboxqaHHqiKUwEE44319eJ4XsUzHg8zJucCXGKunis6SBTjyLXeQMS7JUa2DFwVfobs+hmlPAYADJhzNimO0h6T1sFrdMEE99RUW74rjychpGnrVQGg3vnSVXXYcD1rbSQgvzTm8GX12guydvScCXiAZx6I7GwKdVTsYeIBfzxOB4NYq6Pk6yhZgCYfvsfjBQzqMzuhTmSvYRh8MB6QAKBsmX4QHs4UEDJeAAez+oPgTD2kDujzwBTL+A9DuZmAs5X7zq9j5eAPIet0AFLZMnrsXLrUOoMDvolvMBnjA1//CAKo4bZSyVFTDYCBARMH+wOnR0Ks40cC5HEz9iYf5MMThedyod1MIwAYUsD4obYA4JkD2X64dVTsYe7ZM7w1mRAAtlYIIfYPogMwBlrGnwMDhwx+NAxvZtxjPMQPe/xym21TQrCyAII8zl2sR2BMGc3bXbNQcLLXEPrpnUbkboEEPfsY6gBlfAUS6JXLHYOg+Q8+DfyjdFS552gkAiO9R691xIOyF1wLeJe3jO9f6XsW6mnH8y14wJMu1ywm36Z3WMZcP8r5bIPAdMt4w/j+bZ74qEJWPQ8xO0PdjP5Gs5oCAbMMqMfBACBxYPyRLxy+CASxKkg6YGIQOhaQccexAAEABPzYY1gAAwCOpd5JzOsQeSLn4qNPhsUBx2EKv9CQBnhKzHXU5Sz3mQoTuqDZhm/Vt8FOb1fbXTYnd/WqJLgoz945Z3YYCnQKE6zBVD+Lid+MOtv4XUC7Ky79Cunug6/X8jZM7U857Tz8WWTFyCXvkZ0upyveQtzvvnuwK37t/v+uX694LHQvXbQPDrzL9HmXCA49h/rpjLeh4fViePHpkqT3QGGMP2oItA48fYAwHX4hp8XMo0LIhxPmUbilkTvBb4T96EkSGS7WJw9DOk9Y7f8DU39qjGxBc3OB9tVQ6SWydx0s885wvYg0+mBEjxUf8e/xnmgkgcqqf2yFhyvAOW/1Ff3LLaDfZLXCiF+oicJUgTtE5bZgewvg2iXulCs1hV7vId4Wu+X6ZbB3YoyuTPbyZmMZ5F6sH+jlcBJ+gIVRMCQPMwhPMgoJFWtlgbhnRy7evI5ihSeo6MaxDYNvAReXJetJ8GXQEFd06A3rh2Iuwp67i7qLPg3iuItp6lzGCuOz3dxe3IT5/fqLWW8uBQx3wDpDHi1w1daFzgj4wSVOXFHnk/LZF+6r3RB6tOq51QeUKWzS2iOkF9nJjNwpvUaf7an8idYxKvhrATo7MPiEXbtX2nAgCWnV1lchiFizVAXByBoJobTjC6TEPpPpwU8uQ3Oq+f2q6sOe+CAn/r54ZhTP1nwDrA5SOREs0I6WUR3WGRmJCiekpIcqAZ0pCSPe5M/nX+2t/zQhFrEdcpA6VPGOr64UyVa5vQV7Wy9mA3xEnHxTFXsCFBd8vO/l70OUox/aDcRDgj/K4x0LImg33URIIS8JR5+oLBqKq7qcyd+MF8CmUnvqMtZF0dj+EndTGP/sZAi8N3wbemvHn9iJt513V1+ZFV1y//PiahxPL+zv3cugygYD3x7H2iyhCwGxidKIgrsrMRYTr0eAMmpllSJdCEg3h4EyC7gvV8iUnIOCUAQXP2zxEDK2lgfOzCEbJcEQMy/Hy7mTykLim3UGnjQ/BSZv0Cmi5kj6Vtx1R4YHtx1322DvrfGFiR9CmdOzXcJt14fidffT73ZL0FdHnKgP6XNXznL17OIiGXmpXNOIvtzz/zUoO2JjvIY6KKCj/bl/UGuo/tXtF5L9H9fF3j9AyNkup2VfxmLXy+4nd9qOdoQgt+9ciFt3hyONEf7SWtEb3uyP+JPm8onb03bZ1oE4pxg6gp1K75VoqIW49UlvN4p492XvjslB4MC7R0zQJ73dOFYENoKiPRvY/dQ+T3Jd/UwiS4QiybSJmqVLD3mvyQRkzgo3W9heTePrhbCI3UGabyA3CNPkTOXcI5O84HQtFM5NUbTlO13Nbo4VwoxTwYLE7/J5lZ6ZRLj1YfOBMPCgbKQVtv2M0M6ENE2E5Jwcbslkv2HRG8jnIHeuNb9bUl6i6qs4UhY6tqTYbkxyq42r0Q6k7cl+Rh+g56JRlHvoKsodkvfwltBmkWx647x7uPAnl+uR+I30lWoTWQYsqwEK898qX9DULo9ScUWCVjLaU4/fkPrHaRd7AcGYZ84TPjEmxYlJAmhGEKv4vf7hzYFGCU58fHRA2BM8sHp0AGk/fsFp1txis5wUL+hifrRgRAZGCXAASCdZ5n+MhNU5ZC0tzbzotRDNH6/EP5RHOnlw+0ZFWtuOE/i4FbsD4zHTB1ARTBuHNT888qO0rn8oKH2DQ0IjAP6D5ZpMOuNhMe4uMuZ/mOb1h6hHxeig5aORPMhS5zpPvBAMNfIRcGMRz//w7/Dlctr4QK8frL/+0RDUHmhUFNiOL3UJrMOcP9lyav4ewT/wF4nbdLrDhF3fTsrZvf9g5JNMJxXKgHr43AP2g+GgE1wnodWJtXyTvdnHHOjL9mJ6nRAvqdumerTyzcvt0bdO0kARjLQCvwIoOM4PHxjEB9NSOvUVMN4rqgWQjkSeG2GAfIBGj25SfYpD59WqUufrgVadCW72ylyPotqhJcjPRW6vAuggaNlND5vtiZ4gzO2Nnngs+plTsxXofgXfRMykHQ8cp/EogrUNXeWn78aLg/kMqbF4MyeTU0mAA9HmRpWXXABv0FgCCUFgiIXPWnDoCiZCPbURDsi9IX/TKPPjgEMxuCDwIIkvinMqHKi4OP6p4QYsHggIZVVUzA9Dgme0GD1c7q/ibBUHKkqGfq7JESiUVsTtYTVdA800C8ByS3mujkm1LQ7/wOtdjY4pLZs+AOo5pYJkNseA+FpZpjX/AiSM9APi65MZfOFYe0ms/eL12o+etZfGWq5a5kDFSLHPBYDGAR5Xcrqb+Q8Ey3AAUFMBqqvWOVBRpiiYLU6gAkDwFab8mS9Wjmm8aAE5Hbi6iqG6eIGgauWIBDMF8hKS8wPqghQHCqvktSifPL6BvZP5xwg8ffzBvuTXfo4fQwpXpfwggPHY0/+MJGLpua12Hbyq/97f/ufe4fuV+fFOm6/cWElCg6T1H1VQKmvXghX+FotnENu2MW0R29nWv3SjY3NukgbihkeadXCzYAiA4uxcPO5c4yRTjS91zlzDxPECkSp8gkCeMtoyXqUbPra040V0FzkY3lH2/XSH8EHSEJ/teD9zsyn1SKEa3YeKf3gamjd2Wvnchx408FlF86N0yEQN5CBx053R8f3ajCt/LHtn9yedZnF3oTXoMGp7tM1/lttYmK4eULxjb2Umf4aSOg90ZYnXOLkU7LEWhW0A4o2/Tm46NZ3Cjm9F5sJeGQNvxltBzVhjPbmnShJ3WtxaJGmBknAj2h4hGYYFiEulsztlt7KDDEqwY+SAho0gYBPkvGBEvvNjspcXAyklAh5avNqeyQwIgQDkpprP+Ck1x+YuL7qFSngmkhs7PyEvErB4M1p84QE/lS1eebmgqL9uAGvdWEymLo0bWxjMzgnyBcBs/sxR9oeX1h+Wgle4XuzTMvb6fMYH3aqfZmR6Iz/HaZAyqIfMtjy/ATJNcHx3ACFQZ98P07+GMQO0HKABGAcbgORADR7nfANw1A352a8PVAYi1HPDHMjzw2XU3aEAQDEFAMg8UJrz+0DFlqdjcjjyi3jmBKvqyOeHcRbfeHEDftm/+VnQDzTUKfaJogaaehd783hYALBMAwC1DvBZ4A8UoNgnAkBTF2PzeFgAsMwD/kqwVJ8Ff9q7wnyYZpmYGLJJQgZvxq0s1buDeCSniA0Pb4Cv+rQ+5/+BptgCE3NDneMjQHUu/PkpOUtssNg5cgqMvzemYZGNNaFD2DDr/OqrWZeOIvVDLfp1nudj+Mi85DGChsRhHnlrTXnJmx0e+eno86EwFfQl+6jv0ZSC20v/tSmCgPgBmT2e+TOMKIYDA0BkHgCkBwZch/q8wz3bDAxFs3o7HDNwzDEDJjXXV0840IhLEJ8qPuCJAI8uvaLD/LBkRMZHpkfKWeMrSMwPkMcwmB1ANG8B0vxiFfMDApNngiDiWyOIoOSBydQTLFmAb6IAUFgqZ6nW5sc7xV/Y9pacvvtrWOfLcmzgGS9o/DScqnqP3JD1Xund72O+S/luXrED/m8ovp0jyPws1ua+whTMLuC6XoCBXLGYi8PslFBwZXeXOBfrwv0Fl5uk6hVdC3F91PBZ4tzCJiLXydlBh+s1F+fBhCUXoQu/xmNqcaFWiazF135ecTc3LfYco8/bYgZHca66GZf1cepjjXGF3ZXT+nzLHVLYVWacRmw1NfDdd9q6IHffOd1C2IQgIP3JdNi8tv5Stp1a7mie7l2EwjdRpuDcr5pFyF+K/N3F6fI0jdcLpsUCv/dQPT6o4i7c6ogLRgcbujjCz4OOf5S+OH4CUbjI6uaGlo3nAIROAtTN90RDBzBjdAgDpNNLr/aDa+DvxIdjwMAuiF4tjqG/Whc5iHtz4SDCwMV57mGAe6AjPVMAi/Za91nCCY7Ra4OFKlMZkE+hazIWzN4UqOn+NWSDm4XNnM3nbgmU+47rPXpj5nv77YkcR3FM3G5Q1dcw6rRIy/V2HNHu4Oy4iwJHqu4Y8DjwqlBTU/9KijWa5APRkxbtkNmAkYNstOe9emBtWZWe8t+0XhL5D2F5fJ35H4we1DzLG8DjVbBGUY1KFxbi7w6hrnHQkR2HNljbNzwZU963dR3X+T3epGej775OKwQcNjIx7jSRAHH/JdyLnKEvXp9XXWV+2mN3rmot8kehcxwkvBW9SCg+e1tQ2lxrcdAPdTVXuGsb/5q+NlrNxDeDYWnmUKdYN0CfGoBp8XcHavMBsf0pWUtzJpzOPh4wP/jDnmdkkQt/EH+GrnHIOfdi7mAAsa+0gmtGvhekS8yDGEnrOXRv1+LeOpjq/26yj/SO8iepukZi/sEA7S4KVAwuP2WD8mrxGp9oDI+XWZD1SXIZBQ7u53hlrp2TM+ZTX2CRfClEsvAB67u54ozP7X0tzMFsIZK/5wtOwktB+msTgBSqFHhxc2mEtuifoh+4Mufz3XevSCPSnOumaPisJdcyg+EJPK+Lw/6WJq1cB93NH68Ry7Opc2ASGxe/DixZYYHZ/1t/7opONPlVZLHo0/IN+9/V7dex/DnsrZXXCbHXauZHVtvdIxhmxLGy19/Dx6Ok+PP5nPP1itrFMJ2r5no6roDz1SBiOAnn1ITZz+xoRqizWL78q+urktDsAPRrh1U7UvC5ZTpmfIVm2WMzzDpcC4+6UIMzeMd4b+PtgD4AJTCYgw4AJvCaYTPBDHy19HMAgPcRsGKYKJ6jbVqNoVCaY3zh3PrBwXBg6B+t4rtAvKT7smQlzdAKR5AS2IEVAKwAnNVb+/eEvZQEHogd39u0l57lAdbYELoK3lcHMSJ2CXYK0PzbTydTcACrN7hAO+kC4KzeqlZvZjwLB85nJUjGsul/HUyVaDQ/xnhB8nDICw0CqgdJ3qBW3MqVEYMh+fVbdi15qzSwuBsMwzNwCC8td09h4Kx+hdwXkxxivyeJGV38UxHEX3+QXNY6F3Tu596xnUxv5xiuEMaqxYbEmOw5U5x18uvNGign3ijb8RcwuxzPE+6IN0N8crhC638AtRFQmuAc/4cy2VFuEGEwLPn88Vrr7LzRH3EOG3+cajwRr/3878kGJrnOXnQCZCL0GLYEMg/0Tv3iHfEVmOXW/KWBQv6jPQm8QX6yKUXtUiDhKIIxARqLc2BvHb6zk3t228ZECZ9MIOORoHFRTNEnVAMunt64N6iTGCJIXa2gDylpcPuSBc6zw6vSKvOcQ4ImChKlQH4UlbwGWOE+i/grmN+bnAko3Ppi+3qsXY0F9z3iTdmrH+ndQdh9INdXPuvCvee9oUfrNiFLoYCkU+sAINWRWx2jngS8hOqqoWuMRTSmojJMfbVwgZj9nZgMS5zjF1Oy5IsryShxAC4pJ5ZzleXAqmMnxGoS91eZtM46Xiq0B58XNRArgNTd+KhxTW99lE5ljTRlnUPdWsxtWbmOqBZKskNZ3HbjaU7VjgI/vA4laN8m/h6g7ObnMfP0Xl9PdBBM+7nU4cbiBOMiweTgbtTATtXGY239jjUSsqsaWgghOq4x1PyZAOASM4Xr/4tftoFT1KeLFQ2tekllw/uWt3j4F7q1ns34qijqObLSOjNXHlYQQ4phRk6ofzaUPhccemMx1yjXiiIDbocXLD42jF8FLp9Wj9fctNlxPgMAfP02Ra1qVckOJFJclkCdu2zBcVcuhNP+gBpnkOWeG+wpLbB6z363qujaB0VLaX+7XVeaePA+OadIeHMqpy70HwToxcco4VW/4ZWJefo82UXZ5vq6fpye6wFSsSgSaXdKwuBxA+zX44jPSLXFvBuUAfFEtxy6004meWzm/ez9JpcL1lg0XzquV0giXLrg7C5I0IeMlfRQKNPhUwg/KSKhSGZ8R0tGooTkNTyaqJRhGGy2Euu+ThKlyptDsz82Zhc0Ij9xdQNpa5w+X4S3Dpk/cNTkdMvOyaggSW2NQote2DXn5tWLelJo8AYKK+3w+le5c5UvTtnl/9Ayz+fj98WfE60lfwI4diOohrfRVP22qYCi6a1ePufXM8j3v0qdL+maQPhTEsB3eUjfcB6d8xy72g+SwCDPf/Q0iRsQTyZbPcT6qZTrKlj1xiJIliN3PIv53tZJC56zrAjf0KdGu3cdWk/CjfN5jlgkYjRqJ7uzjHpbIgaAAMyrOZh7c/VnISXLLXcRexigF7bMM2UvJke9gCpLPfQP5EiPrroluaQ7356+4FhOtPtUhK+CXnlwEt30ooBwA/BC9HZxioZCP4/qIySNsQU7mNefaYd+YpLAYaUlw/DdoNCXkKhc3WoSdaLUjG2IhBS1DC4NH+37gzYxeCkalrHmK8wdtRkjYNDvZyN4Pk5hkgWyknFq5zHfq8/XBBQBc0gHI3U8fzRMUfR9eXZbvA0YdHSSZwwE9nY9GH9fKtPIz3R67eUVI4nKUL10njcB3RYI5H3+z/mR4Bf4nlfglMtBYefyBZReNFCrCDJEYP2z82+7mjDr722OYC7lMNlBJMVzwfsYpCLVl+7QGsQEOYLOkF0O44oX37VI1FHGhWFlbzIz5hGH0KcXf2Byck0iAzlSHHrJueB7YxJmvMHXDkEcB5P8axRY/308f4ccausuk9iUij+fduTuTrNdP8izyL5LpCkU25gKZ4RkGnBU4neGvmGS3M8H6LqT3v+XAMci87A/H3MV98LEj56kBbf+zqe09cJ+Nan7rK5CbTbq7PqOtRlEd3gy85f9VIETrU/HQ0UcZJaR7ZmU+tm1ZeYhpqaH0BsH1oYKm79tKm44KHEl5Vj7DS80m/WPn+OdG5f/so8HSFtA4VV+GQRB3qK/hRH8P6AHB1jGL0chOlOU2aKQ8PwYAL6gSBhfvQlRjS4+C2Xp+gp7SvoJu5VTBti8WxqSugyKJr9b6WmRbjfQOY1oJgoj7ry9+vV3/4d2QWUJ8aAz4dBfU/faOAJK0PohZNhPUeNKRZeWiiB97EKAZ/e+qt1cLvVnbJUhKTm1Z2U4ofsq6IcJkGa5x9V9E5VnVXv42b0NflHUgX8z6IZQ/ebrY7UPH8/RpzvdX/CMoX25aTdw1HGmAoL5KX/4hE6pvfQ2tevQL6jhC95zHf4lvy/85tua9EtnLX7NDG3aF3zSGds4WwYlsKjkld94XJGtnwwAlS0VN2ZHYepDRlBlHu++T7W+TaDQgkKQP8si+R02RCbahzMfmHDHbvCHgnVAmoQuxp/u10iKFwpgPlBXtTLolxjUEzls/hCX1xEeXqybNBTYz8mDqAlOype4+zX5eV3o9zKEKmid0P3rWx7/+yVR+GOEruVoIdo5JE+sm8imqfA+vX1W3BMX/92fVnl4j6DVlY06s+Z6iklc++9w155b/daZ5xEN9Dg/g0Llm4sXxeBOOWCcQTSe/bCE4+igCTQ71h6w19s3+QuOAUJxUwM42QgrWXOzN7j8Qk8VXgfXL6Q1T654Mh86SjBFaT/Csb1XghETmKbLRjrDkmD4jYAPdlCQKwis8APHSnKQuSMITcF7UoSn42/1C1LbCPnrLaR7USvt8IFDQk1Zpi5KjmGC7brmzeVb6y67ivYgkvDWLl37nrun0+oPr1G/PesRJ2824V6CHaeVv6r4e9K7mqevNJECevVwzTYY5hst9zdziH+X/0KWAplXLggFvY0Et0nrZKa7r/LQDMHME3cp9VbUx/tovFkGDhrgqiF2AvACWQOrLft9c8ENEPjY05A9StfyDIDPGOnsMsAXUkIKpEvvtXUkJY/e9D9YkMKIr/i4SfSML/hDsbqW15jgYBeUdTLr4jjrHirvkt46imUkPX3tGUuSVVqwt1bjZn8BsRqyFK3VqZIpf3IKNiXK19hltpJ2RHmVdm5GpNYfHASKflGMluZaZiZXafEOTPG2E7H+vDF5upfe55OylUe3KbXA44JQ97K0m+AH2lTcw9z5oIicEXG9k5wTq0eTbz9Q2y60zfMgIEuu2UCgChYecV+MNVeTsHP2t1j893kCcUB4A8z5hZe79LVrmg3pc9sTnCFM0s6Ly7FGBdPLnfywSS8vYhL5fBtE7O9TUq/JDRGpu3vYi2N97ZS9joMaB0/a1jh9TrxFwZmDZo+p0+x+qGhIJRAN7XnEvsXDsuh8ovun6PwcAfR7AGIwQQ+CxC2ilozezDnH8AiHleZcE/ozz83ML/ABEU2v5FMf4RkFcubJzWeVUXwyDDA+LqfuPh8cn23FWDu8Bwe/bhF2RKCfLOi7sPSbyEhzK1+6Qn09yZg86DPDrUBkbbjtglsfEI6ahBvSw0oykaO4rGmlNLQtWFp1shCNloADxzjLYDoYx5fMto1TsCAYpavOIdJgj3Ic1Ajy8psEsP7xGZ+pGce2KvAwCqL/mFEh6TsZlExaYoSku/heG9/jy+WwfboSWoWBWlbEgug4XTThWmxCgtrG1sJJU1u9UnSNWDrahcDql0q7ChakUkUgVkkqcW9pEv/AGNJKteyGkKFKVbggCS3xCwSXfwQbCoWRroLeN4xfE24Z6wJ8LKIWAQTz432a9NnXyv4xwlejjySMBywEr0A9HNFtw6K/CecsUCq1jE7TuSN5nP47V24glssiNc1NwuUGzZaat4U0I+6bPf7riNnbqEyLZnpQYjz9Mp0Wcd9rqTzRtCcYFSH5dJf6aOpnIF7BEhd7uOqOmHho4mZcuEVvR4OHOdqs/OvyrseL8MR7Xeiy0fHkS30L19RZrg3zVib5fOkn0xVxFaftCMW7iVGhU+e7L0r+ht/wTlBUePdy+seuVhB6OT6+RQ5sPxEgr5lQ23ZZhVRqwSkkV7HQro9/f957TrL2+CdVE8t3nY1fKAlvF1V/QkhyuxGw+kk+Z7rwbadSpCb9zN7yjp9rFHSIAHSrEV9Uxo1pBuNz3xOPFfCL+J7PHV8AuDfRszyAanqeidrh0NL7THR2s/8mWftJPpl0Deu5n2TPHPDCn8TY7ODZfy+Gw/hP0RBv6h5GWVV8e8m3F/rTT6o0f2We3+/NyIZENDl6h0IkN8lPCCUEU006/QZUsRsgQ2xg2w2v8V2Joac+Wk0Mk2+Ejd0KZaGTxfF6H7qxvCmaTvAdkW+RPiQg8H3PttxiCyeUWKdp+VjmmCWcb1BIlzXetUaC7OAO2SvJnlPDSSPM7hN/Bab6laR2uNFss5W1hU6Ge27S7w66NXRhv9lIb7MbaaWRttqN9NBuBOBh3FgRM2HkRh+zLsfF7OPiXngGSeKiEazIgTcxImc3kR+xlSylVlYA7htqwYny+0I1x4TsYAbRrezuJ4N/PiwcwBFS1t3BljY7UheixdV9hjJovxWWBTuvQYycCVhUMM/QmYqDHB+MP3A/Ud9dAE1h2PUwTK55CMatA+7DvxyCqy61y0Q6NrT9mxP1yh63s4qfufEoeqHmXl47mqLUDFd7o/4J/Im1y9p2NSI3ARDbBshQDGLrKNAGZemb5T2WQQLsQUM4PbDO/m0o7ZyhW179TahNV++n8uBpur93snEK7/cPWn8Ko+PsqvUJ5Qg3IGfEsjEsT+zIzHoDLyZXoRNmDMOqXyd+LXfCjtiYC35RLg8bgxPucZ9pMk/RHX/SMRx3oe4fmlRli03325nw5CxJB0NtZYJBlc+fg6mo7zTUc/H7XgVADvg/PeIpq4vCw8Xp024n95UO93ROydPZ+4Pp2Z3adbm9BQBNFCW5OGk71AZldZDgxfipT3rwTj7ZRZPJcci/wjQR7radJm7JZ3sUxwEACnD3JxqtsCdKDBqAcZTcdYlb51jDcYhsEzFe6UP21vjIL942TyZha/WTj0IaEfrg56f0J4U7nrvHrUqvcQ/SO3k2IC9GJqPRk4MRyKuB/lMjRwP91tZ/r4F/eoIMvJP/Rqc/XtqK/ryTLwP9fZHZvxDGl5KBMjCKAg2OQinMg1hi9paLzZjI7AGQPalpiilKMlhilARDstIFKcBDcBQP7aEkksI+DoGw1PDMQkqyQXPksxCTBGhEQ48cL6EJMTVpGxJfyUEQLCNZtyVog047aEY0B5jdx2HGCiydZeMxYCStZoFyBw1ONAslGxgassSUdAo6JWYKrMcWBQDAGEEXRUON9U5KF6T0xDYJpFYK5AuEVjgNodaxZQXIwZUjQZMCUILBEPQYwRynBNFo1NWT6CDocSXBSRlpb5ZmCy+UKflX/JybzkE8IyHcDBzN0yPmBY0/2QTh5shUg6h6RaMjV0KX+S5CW3xGYyRPgq7nS0W25gIletrg6EbezdNlcRkl5qxHQWfMSqAr9Xs0rvEmjfk5GpXNKNgFThDpih/QeMXmKNgpc4LslB27npp4Qx4Eu8Kvilyrf0LjHeVc2FWca5AWP+JxYQAk6w8adZDs8Eujd0j2edCoRbLhoNEbLPs6aNTAsu+DRq+w7Oeg0QmSjb/UdpDseNDoBZadDho9w7LzQaMalk0HxQfOnwfgJNtXhb+h9H6j2CH5gzJwLg9oO9m9KmRZqbtRfIPwrhJkobR7EvzISrtXhR+htLtR/HDmjyvB16z08UPwNZQ+nhW+OidVhimX6RQ8YltgPpZPbNf4fWJvmWBeyyE2FrOxyxvGXMihX+D1jgtN/+HwW+B5S2Gs/+H3Mf95OuH3lvz/L7UNWCG0/9jCSjqbbiN8YyOLIJAreAQqqHDGimpEs4pFWEGr0i9u4FDUEFMcaf6Twv7v1kCKfa6hoDhSOFj0NMwLiCiZJhRGisFp73GK0mCCIgl1PoUiEq7LOFJ2VPgVFOrl1bSlZT0xg2KnhgI4HscKQtD9+IDoBNKrWJDuV9xqCex7NWKganFn3hGuO/qsB27aYaOgY344hUrXwT5WW6RieM06o6T9CD/d6hUcA9Y5YGDhbBCIYzywxSqM3NqJNVYtAgLJ6dTTq1d1UxxIEzK0tIVN7Xoty5OpEfp3ryCDoibOx8BcwCJBicBMEaREBoeVTmAHkrSnUFpBoe4lGKQlnUzEEZ0IiwRFKXgiQmOBJXjsd7AMisqhhiRWWDUz/lZ98DiGsBNIx2YhCrmCQyGG+hEPSMVIaRxdEY8qWqpj7yKMUPbXd9huqa3hcyAiL5wdaVkZBe1AtcQMbueGaOrEAV3v6KUHZ2Edo4lCWqo/fElC13MofQH7GM+IQO0MFK0jxZtuu7ZkEQREOMoV3CMIoY3WNQc/ByqarS/QQaiWaiCJSc6riOFsi5DGRTtiokU20aO6g5PCeqLuRcgmBk6hWKR6GAt1v0LStrTI1tNrLllNSxHcVuGmERzoRnyQFCFVhMLZaGATmTJQiAOvW/PC+RmIQLVYIGXsE1HYi00SdqOyaWG8mU94PFbuAJGiudix8VzyJT3grrDXIJjslt4vG1oKxN8tGJw4yzkmKAMCqWbLhBjVZrbCut/Tet2mo8KEDhD/AR9+MCBb7C/6sn/ffxxn7502rj0Av5B2FuUlFLV4eV9wVkKixQwgfX3gvR7LDaDiyQADdusFRAsfl8RwsFRRZNp+PEgL/Vgxokew40kPPM1NklxsqH+x19XASp4BW6jyrCSA43Zga8EzbMiBCtDzQHUVN42W4QDDq8MaAxRcLBanVWKHBxPZtKFxtya6ebp7oxRvZFMe0Q4ZRDe4tUHkMiYM2WsRLJx/WCQpKbo/OERwN8L96zyShHYCKCLFL8cb1/h6OuvpGIXsDM+J5EsuSjtt63OUFxNmRQIm0iMXZq2td+Cd+KLrkxhLVVUyChbIXFc2Dmnyu50IIg+CRDi+nQ0Spebw/lOEBTKjyJACChmFnRArotS4QppX+9r0iGDV2JiJk0Gq5fUY2mlJJ9Sk7mYiRz05wsKimUwgAjogO2RqcFYdVzmykCEkRp2CA7mLGOjXaFN1SDmfMafvLdxPIxwXAnhsowH7q8DihSwkzqeRqFCI0wDVUkWMmZeNNfANUjN4a55Q11ineo1wsLMazlADpXpPylqBgCb4xIpBodAOaTVCYS6MY2Cx6vVdfvgPyqCrN9UAf2IRdQBk8xkivEIgfa+zgO3db4JzGotaosZSto1pi+mldrMF7ynW9+ZepdSPMq/Lf2IWDdLp2hHXfiDl9RnNSb6kTSZ7GApzKD7loAXuQJVtvBGo702LkwVSERmNYBAgrehNSdAHIdIkVRm4pJV6rC3PCBrCoT+9t1s1FIgYEkzFcfEeFijofBpBQTKnvYcpHO1lcY3PplCwVLuzSrf3vBsVVZ/vXPvFFIqLKSJQoxktkxwFSnbNlCcmSgqdioiuPr4RjjzSwe7hdQqchp142FtFC60k2WgAQd+cDgXHzEREa1pY50x7I6Duuc4IjMPEuXMRPUUBhTkRkkToQAxRSiQYHFkofiRCIr5hLmKxFaOjdODpKJ2TXBmz4LwKtkKCaMzLn07xJ0lqBqve5mTcirEMiCgENmdAwDHT5jh1JS90mAEWPrM0IGv0I9hfl5etDwRG3h6Vs9+JANPrjZd0FTtFr9p+aQLz0ufS1fGm8QGzcdh51DJX3V+bGe3oYyYubhpQgWISFAkKnckwuBWz4BW0VaeNlEsBINkRU3+9w51HMhRVBTcaPRPCTlN5sLgx8ixt1cirOccgI1BbqVEWDiq1g4toUzk6iEcSXr9024G+JPApReyQOnxghhrxLUoDRyihI3YqgjKc0soJjMQWkIFndKoQ+hKsv3L/oUFB9Hv7rYlUUU544uZ+MekN5///0tOWQU9DvfKS52+Vu7cGXKlBPuirwcDJyzstZm5Bo4NFn0YFBZG7n31qB5XdWcPstdT27uCZcoz2GtxRasCT8t1OKStnA5qPw8B+R8IDLIf2Zf7XGM9eX99ltMb9kC4tl8ACNVSwC8EMxWDhgUP04NBfQy6PI4RwxnWngFu1lmk2dRzNOyxItGoArIlnCFj0XsYJMl+XT2MBzci7HLYGcP7MAwg0yjLuqwXYzwGC9PhPwYKDMee/HYHTkbmseITbGn+h1at4I84dfdUztuECZvAMaNRQ0nYTI7FUVcy9W1dQMqsXwMk3ykhoWMqFL/F+hOfi2AHTU02Y6HBLqiTuci/9MEMtbXdvikjZyU6c5SiwG4sFxAtT5rkwnm+lSSfY0MBYwLxoM69UozBUilWTliUSXCz7wwGxH5xorW1KuLeE9i8yYILCCKdhqCU9ERP982+2ZunNJo/KybIkiIumGQgtYanNWMlmyWpZuGKdktR1DUwoZ95VyQW1iyTXZtSNe7kur6j4yGeE+HTRo/vxaEoFP8ZZqHm9dKlRh1kVZvx+rN8yv5sia7bQ948XeAMaWLe/4VhLzvfl/b+Tg3IHbsNwI1BUsfLwolSTSNW2cjj1XXO6Pl24CgXDpg6Tiit+rDpBKWtga30kMKgd1nX7Yatao0d+1nONZlzMWCjwWYwKakNRUBeRU1ys7HzRWFO531k/AESUck26MGILZwulQzkD6lAhiWaStqJLuVcpGR0aTq5z0SzfrtYoJAQjKS5KV1QWmW4LYHpOB9WDIl4pL4JMKnnSDVlB+CvbGc1gpwks7o32Z2iodpcjTMgb502CcahTuKKlji2bdaRd49Ha0wHhgtNIubJYggF5O9B1ex91/3UJVipflE2FMmoMTx4rO5Yjg+PlFdK+OBRgqG3pZHAoIiW6smlVlYDYHr4x3HCkI4OaVCygWC3Y43/0dbleJB4iiasRYIB4I6NQHM7qg+OEthSAMvF1EWwNyFovAX2jCgCKDsK3iFRKbfMeif/ojGpZ5CTdm9/F4tR2jVF2ZPxLJCYQ/kcEzjLB0XLHiymQCwmsisySY0WWTBJJxLyy00VB8uZVA08qd60B5WWKQsT9naKiAp+i95zZdIuAEJdEINLs/3UE2paXgBBZAgmkluF8xBpkg/2+4va4fEZD+3+G6F/GzFedd7pSXilkMSMvPvj+Yoer8h9Ml1GHu9kMQJaFHChTUTqCK2w0anWVLX1kUJu06HqGIpJNjqR7fTTltQPJYeAOKTku0TjSdF7N1tgOqciZiMDUAjIILBrV5yrH6RyHck+tIenwtACIOr16FPrqgvdX0n1JYBSq/bvrbqlGg1Ooz8O4U1LhX6WYj8BpRM84xf5zvXtwN/COXD1QO9WmQ6NY14BrSIuKXZFdGst8MqStEtbnlYz2ZItbwdIR/O5Bxgha2hlQKirVfunU0BoRo71iDUa1GMJQh5siFHO+MhaQz3PbsKorVF8xReR9umPoRjAYcXEpZ4Z0ps3E/lErGk5Nu677fjftVRipDW3boSRondMDGxfha1bl+4EhhX/NJYUzm0Ky4APZA69QQBWUD5zHzvvLY3kPqghvskM90K59zGSgTkUQ5IEXI2Ty+gDwdzqrZd1hctQg7LoYKGKHI8EhSlTivRH4LoDrScTakLtF2k7acEyvqH518DWTZPDCkVZMwZXdlvB+yv6UVD+nDtXWVgRq2bAcdhqxutQ2mI2I4E0DzwiNGoRw0ne2PpxW4GaHAQDPRhrTI6X4GFevDFYSxRaKsQyHJLmbYDAFXJbNfoMhgj5ZIIa7utKfTbDUqQV0m97G2ALReUvn/VQZwIInl7U5TBHvLySPSyNGaxDURDA2y3gYrybRblxHMPBk6gARuJ863wnM7v7sIbeToS6x0WebYygUR9JHTABdf8QEkCfCfhEHDSaYTHi/RIG0Jq6VR4PamwhIlhMUjsVGSxFP2I+FiNl9HPbwQBqgKBGMtyEqZZKiFMGD4KiKZxDRHCMX7QGdw032FWNwuaVKYxiYYXW+H+bNNITH8RQd5AM9JrQA4vgpxLF6YxZlOIf54/6YDZZT5GzQaBnu/jGVmoflxTpYAG/Z19NI0V9dmURnsFJmcI87ZoK11sPVwdC9Nl4q3ozVuuQfAMwnyWiqMhH9bS24XBrYwSJxcFacMtEuw+gYnaIOmyJGg028n0exM2FlYiRkA1QSCsfRoTCTeBAuWg7AHPYSSLFgK3VGo/EGTAzlCnEQ8XCW7riUlW029yEU43mMzfW+c9kfv1ck2h0HLiTHyfEgtFlRQAF1IAaSrGiYIJUeNtF6EVg5BNI2r3OA5bk7XKeVC0E8+riOKKpZLI/N8vRzLLgPR+23Y0EkSuOT6caeEwNzkt4bwICSOo50ey5Gh3Z3K2NGcrfS6Viuij1DxHkM1tdLi0vGVWCkIGr+xoqbR9BA33FxmlaHimQtRA37QNAETl/s8KeBYYStnSCW1mHvsnGL1VrfE4+krPBmxQBYxQda40jBaiF2vsba14rFJjHcKSS4euNdVdlFRFrY+7SF33AgFmn32/uU0r1HFBfDMa0Vm2AISxFrSULuJoYlDs6wd3OinKSrS/8aMkIUUQU+dycJMCTmDQw1LRQqutyRFWHwcNobc6eQD2L4odHG74283tdlLeh+7ZJTfOBdJG5fbZmgnMdmapFrrUghQrSn7urV3LXUzE10tnWsxwXCG1GGhjTboxGcUBxPh53xgF03QXATlHblBI8EVKWe9zy2Rnh4dpnhWVI9JtKwZR4DJ671SRnHZZqaCI6XZpwie5t2WJottlVMEto7c22IoyrKTAuLnlejt9CkVCROLibBnvfrUbFpYRPTFFk0pbfg1YTEEiZXtFpOE1hobAS3oG0Sz+fZ03AS6nPnvhwCONgyrFgIJHLYP2m0RaBmr5NnTmx51iqWGl4vi/QvRbx7J7UYNKPA4Ppl1VjAklWCVdX+JGmOBggjARj9abgo+krglOEuB+JzlluX4WnvxTrATg10J0rEKbSpfBEagv6IER5omaQlhXSjRavoQCXKVvYbB6dd9EiTaGoZN47QKVpoFlvRHu10jS60jBbWGDe+BL0hP2qRNJlyj4fUdDZNthklJLJw08KK8RRIOVoaXBY0WahDZS0jnlTsxpkMWMMqzyfX5RwR/2kuAPkGyPOsFbxxUIv+u+2/WEaxz84gkk0jKJyA+4JsHYokmOic4Uu/lZ0WPuggOccSP4Ig2ceN5Lw5bPIsXviGaeUs84IiDkCjMpQSV48PNjgRciszEiTxOlv4IlAnWn4klTIiImXJW8tidaIXq8oJRaC0/2ZCIFAkHGECgmom7W/dNAevqiq1lIYt5eLOLBr/SCGaHHlD2u9Y8FbsxFuqonuXtQKK9Of7FbvehIdd1Y2xvU5E9Ok3wg0K2BwBGQik7YzqBAOBWbCKdCJxNXhNlIwxCLdVe6D61bnof/nd7oy1wnbn9QRPsXk2jNmegCxgyZ/dN9fyuU0iiVNU+5uh5ibL3xPVvx8BF/QoFCpSCZLniW/UdJ/VPJoSXZ2gQ3FVARcYpOR445/ff0ndptSgTvczvZRN34r59iWK4u/0z0YP6YiSSXsKhT2O8XA+Go3+bmohYoAjchlBIfW6tEpEwLKBBdTRphgeh/zUkAQ5HjkRqHEUxh/Xu2TPvNZEN1gXJzFW6nW69c3xnyt+XPviGG0+cKbbVecxYeSbkKFs0SwoQwTpWxKkL1VRqu7xwo5ktMkXnZXgzeilBK21ABjcFURfh3b3D65NvrJwks3PTzWlyCU1fTBEkZjb7x+Qv1bTP+1UI19EK66PcAFhVu1OQpRUaHvGLmx8GrYcXz3qYQUKZeTbx3CXcCQQK5iITcZUQ68pBtFYuePnZp2TPEKgwqlGOm0ZrgzlPyjhvPoFFmuHYQak/ylMBxVIcflpZcMKuh5XuFDeyIHkZgr9geW4EYCYH2Kho7W1wfPrdGVtSWcODjnMuZRIG62THKu4Vsw+kU5aTzOfbYIYieXAgNuRibQ7ay0Ck8nvUjfAbt3UgXGQr9YMggNkbp+ZWHhXnUGI4yX2EgSKaMUVhd3XXLNQuz9widajbOQ0p6X1M607XwhRmNYmNBI6DjgNfRIgiX8hfzL3mwXCo4wimjOiJm0KIshyGmP+DyzEQiAlaiM2oai5zFwIwYpaJbCvx/QMJdsJcSRoYX0h3JWjhLyCDNDhWHV/ZMjtD4BIW+KdgDenOZmuBF3x3uHjU8h67AoerfKM5czwVhv4DoQjj2hYzKzMFMhK2SAMzi2RECYa5/28O/8NwMUD2Vn1qTqfwYJGIB+QQAGRsgHw8QY5jn1AVg0MI36rj/mOT2TEuIokfEEclmt8Jm/c3csGkGMyEzqvufo3gHdS0JCkuD4ZbeeHxGrlzYlW4vzJ7SGsllKNpF0cIW3Yh3VCqeONDHxq0C7LCELS+Rj3OC4GDc6U8wiBUYAEAOyIOoOiqsxjez/cmVHerNrmDiGmBihHIFPimYxQuO1r+gFpRqNyqrz22hqguHMgYA+CWsaABnSRa+rf4WBI8z3CprnZ0UmNB4RS4bfrKrm58Ymu1ewLn4Ngt+U2/z5sphbNdeFLipIjExYIqaOkReG35wmiZ4tMU37e0QJnM6Os8wzZKgJMSLRbiPgWoUubXQgS6FAcsjNugItww+4OnTZQERvZ+a3nubMm5thsNDiq0bRMIgXkdCy3jSswoA/E3EAieOE1lsfXazKFxajYDwfJHgPcMhFaPWfMUejhqEtyuckshOcayjcnwseveAA1jKp9EqZCXFki9mxzrqlcnLl8ZYQfKeXEGIRbEdO0UXCp2Ibu2VSsmDCzeCWVbgKBU8hf2wUhtinwrp58PIP2X76DgVIBq2j7YtcvMEAKPuXh6gdXzKjtpxydZCt+zIMLjcWrBbdViy127mluRHtB/+dMK3riwLuw8ZJMdccH//LNSyqfQvqSHxKqIOL0/dC7C3I5u7xjkU/X0hwrHwwCaBSlfdqlnfkYZuzP3NxTG/3T4rA5m/ZFmIaG3BaSUz9UHS6TKKPbs0rjah4pFY7j5wBXlVByxc0gxr7LUtM5h2DSgJQNnFJXWxR2XIGa+Bxsc+BtaoCPzizG1tiawRrdrG48vssU36kGz1xB/YZTH/Q1vMHIXsszksTsjqQ6if/JXe0Zetc4iZSzwqU5EACzrfM6I9DfPKbdyHorL6iOqFhjxWRgFnYQj2Uav+JTM1FtVETRrh2AopPZyQpqOn2tycUc93G6Fr658y66GnwdmVylJQjuauf2RqvSQPSUhhB0vUOfmI1UaaWtam6d4J3R6O3gv1rcl4RWokVgFj/3K93wzHcK07oh8JdbhQr/hD2ae27DwA80pxvLPGuXZlCoa7ITPwBiA5fRoEPA1Z3PUgl4wyq2If/DdWuZlr6aA2M+/uIUWfAMmLIWYJDpPC6uhuTAFCrQir0prSEF966dE2jwI/v3T5V9SkDhkZgzYIgdVdB3wd9gZLV6xxNfTtqNLayu8o11beUTT3Qqs3zJAjriTZE74dtDUCkt2648yBN2d8RftrCX8rRAmt/jScpRLixbc+7L02RvAViZT/BTMPKdRxYt+vfuc9+gpUbuecmU/kCkIQDp++mpSaJZ+Uyrle24CBwWazF5HZ+Wf/4kU+YsA0SV2V5iDc6JMVixlW2bWIMqhE/A0CPz1fX47lrIxC8z/ITTUF5hSxcN6ZrR52IbChDMn/jZBwFubs3JqZ3bCZXnpZp6xHTmHlhpQl7VU2ghSKWnoeMzERvbX9YJXbqhPV2MpH9UDctJfpk3gqrump1E5E05GrjRV7OmgvBb+LRgrSjb/ZO74vRr5t88rQyH/I8G0NISCQDvkHWrFw9/aEtiPGiwvKEb4JpNmx44ajmaaCmOKNBs8xWwWnVKZ07vOIwAz4ZoZrKo9vsuI/Nmjkf9dzzGlcADttiGjk0IkLO/MtUBR9kEBT4gNSG8vfVxdAbc8FrOO5r5xZSD3TnxFwCSPe9vzF8iB8710WH/u6aw7MSBAggOPnnoRj2lyYO1hMZjcOyT0w6Z8XfqiPXFyBwE2BfNcTmDmsYAq+OV8sNDJh61DJ9knYobxzxY/vwlrfjxmkzh8vNBSEGAscay5UDJL+9t3MwbNapgMw0KIdJPE/tCsnxEP9ceOdidXBTd1AVBplOWWNlTQH1ifqXzhcDqTmPSEU3GhJ5a3KdcJfZCHUtiZR6SSL13dJE3bJnoFEvqZR87LNb6SnLC22x8uDcvrJBAJLmCAg0DGIUrYbFWqGR5h43F8iMkFCFyFY8MRhEkUBttrQ/N+2KjU6I8EhEiYohGKaeXRJtFQICLQDvDa0MVsXgkd3+lBQgpfBZNndmdDa3iyiB3F6nFc8lwUAyEpYrmrEPs9+CeNsBOAzST3mTLod4dJb2y9EgOehck60gZQrstGeVASUzm8JfLZ7Iiun8ErvMi1h7SJesSyn1E0BqSZrGTjSXRsgilEOgZnfZOCHI9KfccMQFm0F2nXkTn100RP8/EWjz/ALeAvK14DZakTkYXVV1TrmgJnFujBtuk95CX7zXdMXLMT5OvL7gIy3j+YG4NNtV1dXBszhJvsEo64UbVTolLtdL0LnkpaEZBwBYjsmcwOy7HqTxxunnFPU2raMBRpyYRmsB2JzAtdqMEZvp4XuO+U+l0cyeP6KGWv2LScQP7y/a/Jg6OOA13DpeD+XZB5Gpys6z3iA3tA7a3YLMVSue7tt07xreQ2C49JmHImoeHLqXPUq8ZysrxoCBhzj1NohB+vXwqupH4jZBrkd2ksh7HRY+Nhr2iF8CP0NHhSVHUAQhn0HVTDsCOm/wQhLaKF1pLErrEtv27OazxeWGtuFvwFIyt1mj9ULhftTwgk/z9S76PLeIs5be3sRHsrn2kVHD9mppsWp62yrWmYZ+seU2T8msE+DPDGpcSO+Hsw6fk2tjP9bO6xd9AQ0nEJ6UH1XNyXC5+5bMVDq5oqTCeeWwaeISmdpOwD59Shvp2o/iSNZVcNPe3N4PFIYAe/h2v2d5GnHQg5JeJUUtdlIxVVCGdENxHz2ge5qqiEa2P25DPYzTo/l357MomLPuA8M8li5j30uhL/A2h7tobQXeFgKnBVseuVuuO8TJ4uHvXlIU2DscKoWoDvFJ4ka8O8LmJc+nnSAKmdak4P+s3OvQ6RFqadD1wLB+/Y5nHxSFxvCHz1CQmRvTuwNVELQLyzPISgyKaszchv50kCUGoiC6ORtSyNJ1snvrNUi5wzEMlOLNN/cOoG1V+V3T/MmD3Aynq7wCZdZCJT8wTwOB17Q7wNtWpQ34WtE2rdrFpbdM97XM0W0JtSHIs3cuns00Xw0NnS88eh6hxgPipFEcq9Ih185oUiYmVnESvWafPe/6ll4tuhtcmsT3y5dS38EPGP2c2jhSloHYA1vIlRzvdg6/VC0AGoKsjaqricYIh8GtEZ3k60C7Qt9iq01CK2Rws70dxXLOgqB071XtWbCpFTgzRno0N4ZurKVmIMDNOvQNF/ZttoRBeEuIV0pMozTYFXZudgZ584Pi09+9gAj6QrMt+zCoinKDQVispqv5tufeBbAzs/wyI0D6X/yH3OFnQdR1bws8ATR0OBLTekpUNIjGH37dbi3MfNsN4l/nGOvzO0HdjA2oPZCUPYfc3xQdgd/E+u70AZFhiXGfh13gmuRVHUJWzaomIsQQN9Qj9e4IYpaEO1NAkckGKscd8G0rnqZTG7Ws1Y5272AYPrWuqvOENlR0X5wzMrfNOfk3IJBl0+7cqF1vUbdur7kZ9V/ucjww1kkTzpyfPOZ659u+nWJUhPG6gqD+3neO2b0KE67cjTFmU542Q6q42+8tsKdHf5IufsSrNnDftDujDiJSJKY0scuKfkv9DqtNduF/LyHkKUheeH2zbemrd5LiHBMuPdOamOKJ1r5eGHps8u8ogDDs5eCZVG3mRBxonaXFUZ0vSVGqDnnuz4XX/8QHBxZpE0K/F735Lzv7d7aOBYekMhcM2+y7tNT4jEBHzj5vF2XSJq+mK5PyYRjbcKPq6LwebTU9KzMlYsFUXHa7uqp7NRFHy7CWFmtkstwhMy9MpUZCEtKzITLTOdmIrXBo/h8ncQhhnK6+2b6Ao6Nx5albvZ4jnxTFmaI3zK2E6oZou06HIDt2+A9vHnmGZO8gaSlAX9jAVY208M+lFRF6p1MXQMFLGsbBjA6iY58sKRj7Hp2Sl8yLnTe/9lpoFJabEecmTiefXaC6mE43ICYrdI0TCc1q7xoqWcWKxhJq6R8H0+YS22lXqAs6P2CdVU/mcFCr0VOXIGyXXbbeWev6RVYljPfzAAJYlGVgnqjKEsiZbn9S8Kg1abITGm/OAfclhkYCcZrUmaZwi++AT5mzzj1YWtkGE7wD4RJ2p9kLXTSvYobnbYaqqi7SfH3MlWrzBUr1V7HMgPhOaEL04WeIlWueDy5W6dbjQef/mQl3zig5Na52UAd+mOaUx2r+ruRAhAmaC+jYsamBa37euvfLeF7XEfK6eIgGiP0POZsi7JeMngMihyXa/CdXLUd0V8G6mbt99Sz0PnNz7cCodqGJHnND1XQLEq++Abd00D8534a8obH57Ai/RTfO+9k9bNlXSq4RFB5oEjWOM/Pg4q8Ms6OEIYY8INULsKbZdUANQENE73wGdn/Tt4/Rdv8DCZHYJMOJkoK0fQ2RLO1Z+1mFkT1xuD/O3s4sJd6UjTCe+15pqkUY6NiJqiIF6zcRtW8vJ/1oRodIVhOdpG5RyQ3Gn695e7icdhe5JPpnALpKz5vRq732iRbkKFz3xbFWOOVQC3hQfm1j8ta/4jr2Un94SrvUYOmdRkrXwUfXVI0JTkwZzokXXtispRKT51HrTCRmWVDRpGWn2dgZb7l2Phj5TmUhDQzC1LKcSZO7iHFRW/ZykJnXTjeNeEvRwy1eQutUTZTSFwo5mANiaf5dgr4RBbb5gba3CM1rXHCvjwI0tK1N/1/dC5ak62+sHcC18loyjBzIy+ghjqjTuD4vEvx/1ZMpBm12nhSvjZxQr/I2Bn90SO2VCahnSN6DRSQWi/WZZe0UQa87GwRfMlfMBTBi45wQ6X8wSNZCowtLMaa4NiZktUvwEMNMfXE8Vdx5yMFjtPCKqO/rwzIVPItuPnKpZPqpY2ihonGRsSHuKJqF23daiIMYzc3NM9s5BqTA+aRJ4Y/6ySs9KY3wXx8X1ky+uDc8zw3SfbtQKD5k/u+7XE6zmvIGb6pWSzOmXHxLioNHsi2vYUczTjRzP123afQVXaOQB9TKLuAONDrFITlrOVJswqtGdGALj7aDTaYfiaPUG0iTfPZdN3/Fn0AXYbpZjISAbtvMhIHTJvwFS18eIPMbwhvWAjXf0e6w1wFjvQlWSyVqgAQPOwqMKmOP68NgCE3DGzcAKl4UXDqC7ox0x0rTHeqK36oyFilBW0m18Fp0rYtofETRAdkq0muM7TVHQwSE87IbIrHBVeGSHGNGAHW4GKm4Kjw/AEreGFyrgLejmCIuVOJ7O121ZadX5NVKcYXPWkCQKaOzzMf5YTbx0a7ecv3God9aEa8AS2qZey9lA2Suq8fwLZM9J4fAhumgnWIUJsqt2gWKj3UdCpedbvIbNLwKnEPFJct2kGLVXLWhBEuwwb2VGoCRZSoDlunERj6xESfe+vGERGKB7MGVlNYNoHUip6Z/XV9RL2BTOCRsw71RO9rqLW/gMoC3QZDdHxDghiIMU0Sr8QVL4mY6fXLDvUhiC2VKOqPwjDEm2O1JanjZQWd4RW1MrQ/h7tT2Ymfiuke4AS6zcU6aFDy5anxBaJGW6vBy938t2AabhOEEW1si1Pl2Mhp2en7Uj7EeQY9MyFD8Cl7+QeIceOxNmIHFX7dzhp/AdV0/QiYfAnuxaMy4JuSTE1E7rfdXvsj/w5ehKP2xGTN4R5mHc9EP62t+CXF21/hO05fOYaHAlAap/SRs2hSgi/NEgLM7rYhWz8qhD2lWNoS6bbBEQmg3YBnlxfujZkWiNFcjofeGatggKOemdAs6kJSsBLX/76iftjUFd3OKTv6h91qulP9NAwpn3HryFG0GiqhOjeimpo7n1z1BBgLl3BH9k+/Ofb2+Frif+bcV+N4hvfvKEn00PoxGD0ugGbtYj+h0moc3rH0sdRbfvo3rV43wE63PPNkeJ5rtZB3mIlE9yrlnNNYb4G+6evCnPwA7JjXug53ZpeuqEcm09eWjwx5xJEmHPJjncNYFSZ+o9IvlzvFhj7nZQEaPrrB5hBL+ZIHyrnLGGkq4RyYEjB4G+drw5ogu7OmyZe2yKbEjjiyyUXKuCcyqXrbdrAiLLXuwvaB54cGq3zUdda1ZMuEIERJsPc6Uyc7827Z8c8p8g57uReG4JyGCSMmFS65FQAssJp304D3h379DjEFF2/2AvRHdfsQZUcW/1PYINa/ty2YJ4GoR6riTPRCzjHi/QEnBO3v6SAdNKGYc3HBpsiTzpwGJMEmcfeJpRaH54VzxUcVryKXAtjiWNshQAwQtWxmM1HQ6RwsF71bcOpKEuQf1t/R67s76e+g44MrjBeF67XS6+BvXCwzvQDOFt3jbncOwzaSvnp3SqvVEokxKfSz8/6Om+HGNnljLMuPlfV7f5yDZu6iqKrex2n+IM+85HJ2UKy0z/nCXtriIr4k/OA/V38k++7uWdkaNv5vfM3BZuRP4u7Vr5MQK2PIg1s+6A+Zz6BKOaaueHfZh7H2XSiyV+6PTLlPvN2z4t2V7ifihzb00abBzCrf/h4+K8KloF/NOpJGySdWlNM/uVlf+yTUTDOr2QrcGWhbreeJb9PzrqZRGyZGd6QkdxRxbYm1sisUBfl04A/4wyqf1fc+vcpFAhE0e/4qO4SVpiPcpkkh8AcySoFaZLI+UPgC2iElKT4yrCyE6g9QtO3OYxo3OcYy9L4PGiUU2rhhyVH48lsS9551xJyB/HFK/UCeD9bitz8gS6GQ+cgU/koaTQ6YA7XKqBz3xSjXOYyYUypezgRifJQAeN197oIm/jcj/DTgNL5pPY67RA6C6s4+3aAAQruN9k8PggkqIEC2sYoVJjFhDW4AQ+c5+zaCk5qVyeMBB+2llttYU/v2kJHKzppON+6CUe89shW1vkxfgzUwoB4S/HscJorBbo0cqTfuS6iDX+hbRMZ61b61luZIQuFTZGhmN+hPwazrjGdP6GT8uc+WX93jcqMZEldGLx4xYVXmIbKeEA26w0gyqGUtIfjDj0LpRWRlFOfWcCRRat0AzemE0fGkOwkeaHpSdfpBMa4SlF+TkX7XlVz3AaBsVVId4wNLuO+bs6xlbQ5KgiLloXQlD+W129VICCXUKH7CZQG7FAbheANDMlfkoSloUuaYUN2I0Vp7KWedduMLyl6krtESfY4fxd/gyuQ4PKkolCz+v6ejagKqAuzaeGuVkzdOclO5Ikl4pp0JlkYszpDY0Qhr4/Uf7sCzXHa4b3gVX8184Wn/yQ6o53KwqMwmvLD5oSKqsoh4Occ5Lc1Nz8kA3s8gm2iT06D44O9ociK9A3u7O+nmgJR3IqSChVI+Ilm0yjaMtsMpvrklkyVixud1M4jxUwnVsMk5O392FLyA7tAIMVD2ezMK6nJtfCr7SYSLbczkonaqP6aaeUuRmtXFIWSXE0TV65MrtmxpFy6zzZDQIWEtkb+9tYwCqVXkPG7576jASNkrS5Oo88Fi1GGsErcWTnZ0042wruht1sqK9FnlhWTLQNTmiqhL5OZZ4C7gGbpGNj++QgXWNFW6mR7YwhoIRIjvbpFvObqsykuM9FQdjRMJco+H6w3oeKhlCnmpK27wjMrIqBuFiyZ+wObs+mmoAfwS5kJahm16TscETBRxVkKx+qhF2RfuS9OmCyKtnCqmPd7eCwUcm/RHDSn4V7Bi7C4psITRaPcXl6RJcMZzEui2mnSRH7cTR+WhaucV0t58XamOVJOLJEv1WdcMKN+qRMRR+PLeEUJjj2Tyxo8Q/JdFnGpIj9IZHHhVE2981e85a+WWshHyxdILv1XQqeha1hDCItmL15j7w7KzWbRPnA4G9IIiFA3081hfXAviBYGdgZSOShc7HLS2ST8Y5nIJMbpW3FWLxPCPpc+ZYB0/Jc4aKacK2PkNzHzCaj9e7K1skVz8kBEKqdekmwYiEu4Iay5ALAKIfVl/BHR2xoMcivaLc806Js1cDQ38jtUG9neBvkBIs3xU/SdzTNa/7F/m1cXjYm7IEzVcqj7ijnSJqs7TmHSlfAc+J5TlnuxuoLV0h1piGK+gdHbs3ziAFJ90s9QCphFu7ObPKrIXUcpTorE24ByYXJvFrM5kDgtK8RQUbG+wbSXiil18fNqnygMStJED5rKEpfJ0OP84zfv7RuJm1K10Hr2abCRSQwbOqTqVps14WSWT6Ydh6/XfXeyaJbC0kK0HSW+r9mkfvzBS4nzYnuHVEM2cwNEe68bfC9pyBMbOmXlWMBtlVRtrw8waKtzKpyPaEN9vA5LrnRkEVcqE3jb7Uk/Ck+jxbw/HteXWx6hQVS33xk486TaMtIOF5yTLnM34Audmu+oM+ZDPzdxjPYeGDVgd2tcPKpwXX300NLjmMsDAdPdakogFdfPk0/HpuofzmNxZV9uWtdLemVwArPH1BmTxk+V57xsZcGpO/W/tTvaFHd3JyJR6UghV9OukA1CmBQj+hgNUI/XQ/appAvrvPztgMKrd6MhPEQ78HOtFDrPtAQphGGdq1BK6QPZO9IjQ5WACuqZolc+DWcpgxMTuJYgSv/BVZ2LdJBG9gzCr6v8KxcB3XncuXkpjLdw43G5ojwGWpa/hcsjS8Z9rqyhg3RMAjR4FFb9yvToNLsIYDgDA2482k5nfpkzNwuoSVgBbOXvIhsLaO9h64wBZKBEzG90OBFxMmcwUbBCA9zgzfWtrLEDd6B1reYIymHrCd4e0TG71EirMNRvsOEH14Cy5CzZ2/usfBwJgcZWzukHPxTksPbeuP1alGvNapcKhM8SaiijTCU16mEeY0l6BiKsDvm2e4DPzNyuXbW/IM4S53KpYoA6B0h+VESwrsd2EdnKVxU4B53FPac4Qg2+VtzWkui6bnssLlqG4PFD6dcA3N+SF78eml/ewjFMhLK6+xKwnnCRaqySy97R4Ws9XehXMhrBGJJ8v/sZdjyKfReGGKOHKa4bEFO8/E8LlSLl3aG89G4zSnHaz6KoCD1PTK+EwGJO8lmHX2WRYUIm0bi8O5O6kASOg+QQWdJCyNJVB/MU14xxaZiEAWP5vDpb/TTON8pIkwI3ly9iY9NFwH+vPDauqSuLyOO+rxt7Ce4QJZwXzQ3d/ZCL3B1Qgtb2zzDr9XGzkkY7u/00DbrmIcbDEhepkhbPnq/dmdEvFlEmCl8VfOlTlrBd7gv2Eb7Mu0rggBBbdHLVgyreh+W95Y1u2cj1Q7LogrUc1lsBiYVH9zi1ioI5YyJmh5VUt1irciPkvd4i01v0CkHSsSuoeHiqmV9r6MK3gWc9ZerqlaVRKmcZGNZDyTzC61MKf+ytdfAhjJO0i9Za5HY/FzohWpMWfeQrdDhuS0UWRbviZEWLggteRsOeZ5PIJ4TpFe8pjI7OsXuQGeRvgVs4534VJVHwE7icPAXyCXD0c2+2mGP51OsfPQE369PWtzU9t6Z8mtRFoIwYC6G+ycysKwifjfTScG/bmcTDq5viAsmvlG4mBZOBKDMsXtbBtCgpf0CHyjS6olntJTu+IIRIh5W2rn7LaCrNrEAvkvkbuXeEA34iprQhgS0sqldpneAAI1eF7QihrajEFvERoyz2na50UW0mFx92yGV8Ax/Q3EVo/fJF3I2bqZX+nzK0y3OKR4Yx9Z38Ds87+Be0su0KhhdwGp5jTHvRx1GzSFINwgLLLYTd1ScF6hDb69YZXAE0oWUNmLOz9k3bW4QTzFYncylBJWVIo8rBdkY5+LJcFrLrOAAGMjzUtVl3kQdcyVo+yJn2Yw3OTFRmAAvFhGw0sbdqizm2z+4JYg7OcG1iuI0feDKvtPNxJ+rwjcrj8cT4FXcgPaij090h7qrp5R9jMCjNoYJTCRO8uD8mJNgCKZNRS1S6+K0p8ij3W5OCyETnmN4QAwHwtlcgguAEc3AKrESMBS1agHck3B/1ClG/ArmcniwgHGCrUnltZ7FKeS/y6QryGiXSkzL0MYsJvYbGe1IOHfO3iwpyWeLwenLRgmZYl81Yr+gzdIKWXEkrt+0zu5kfgnj7VjI6osrGBfOG3iyPNKOiFiTeCvaL0QmLNcSA8z8/clnmOiwtXh8nGDvWdz+tBRmyCvg1clkUGAlqfgKw4nopfnHUt5jExr0Nz4p+E8XaKCUtW0Sp5o5xye/xD56k5a/VnjfcdRvtfznxQc0SKFJ7CeR3RvR8R3+k6k3N8P2rKa69YIqhylCt8ZpOia271STekvo866ZklvFnwUJxuaMfAugJpngJa9jBxAV+ay/tbzBMlz0dwu8ZpxX7PNZW4P3ydHqKlP2sTAsvZtOykBBJzn347qRQ2MLBW9YGD/4sy9vHLRsSL7EQl2nVJ2jyL3HyTh6rR9JPsTpUI2R5G1HVGyrJzM2etcswwhpEbxk7PFHyTxEUFWPinnyRBwH+/yQeaS7XJ8uAy016SSdW3mxonI8oMfDqcSSDYlDV2JEKf9LFDFFjuUJb68tz+FJsjRV8slwfdXszMTZbrqJfImv/fUhR93q2khdTNYO+sDUsutbSEgrHI764SDQrzBwBfmsrhGPPcL2MyjAsPLhby1ClojeBuOUZwMgqsykgFE/AqJ3SQiy/NIQ2QhXB5Vw0lwW+/m2ByBAxdLOvclbzJvk/edyCJBGURDb1KqorQbdw2+h+ajSMI8HHd8tP835j1hulx5JGGatKAJmbpSsbrJsqslfkBBGOp92WB5ypMC4lcIvEguVOckuh9Mmrx3HEKSF7+sNYMSLwoA4cy6bSUc/N+D8XvAiJE4HKEWIrof3nxWBHhjMX2NvyGPuWqdjaAqcRpQ4wLEpQnVcgS1IvuVobrEG4hXxWnc8JeGfhcy6dOr/nF+G/77aZHStLZQ7i8i4aAHyUA4Jxxl8kxC1pmpBVLRt5Ocy3PqOYuvtv0ehMpNKhGc/d5EqRKasmpi6U8G+7jQreEZhw0zd3T2Xyk/kl1HFHFciSwKqdZPxhrkKnXCZNo5/lqXReF4WKcJx9HNM5NymmYLUwQlBr7CmaRCpIi+HXcApuZRRUOkMieMYtdii4fxiirwmgNQFWw57jfU9jFIaFbfTp1t3mCfQIKVs1mjO4obBIU7KGIFYzcHFis1ChJebSHoLvbusXUCa4MUBpD19GC6tyMgJYnuA1qf0ENf796VQ0HSDntSWFNfXGWAnwR/Dxs8oAmObukdEzf1Vr6C2/+1YmfceEFF2NHfyxeCNAB1GegKtX7rra/W8WGG9t8sGcg/8eb5ubHv4dn/kyaRUhe3m0+XlL0XWbTIZH+NEwF6CQrKbF55T52wePE3TOWHJcAFIKkAB8ZWylxpxnAr3i/tIOLKf+0LsBglvhlfBbPnLoa2/R5AWQ3uY4RtJY3W5LHihC5tVtRo0lRzps8o1sy+ryjE/wdXMRKywBqihHxMRTiWwZudsIQEuGs6rr7NJJMD7+bvJ5JN4P5avMOUFrPVJHJyB9ZKsn+fU9BUnfJkCoKX1FeRdej8GOIS2ScY3F5lFV/RyZwd/PVvTvByJbJXj1Dmf/5Y7uWtDSK8RihQ9VyUAuP/kUTVLHUeeuSTPxaE43R5we5LmPVRLZJm5RPOwLiRdFuCDEvEburT8Oz1kphwxSpqwUqbRd648wRpuA+VKYqqcQ+W6lCaNFU9EbS6GX2L2GdeGQoGZdLjrv/07Q+1CYfXx0/ApUkUXqvxCbRNMZ339QMKWsWsopVpwyh3wKQHrg0xfr2rBU/VEzYKH1DEsUfafU2nJg1JkQU97Q+6GNcJC+EYkF56ISF0eQ+1+DvGhhBwe2nHVM8xuA3vDhFyqs9u2QhCw5qKu8cAVHxm9POWM53XuLvuhHJIVp+Ii9EQDE7FJhH+RwUi6RSaxsf5QY4Xo18jAwSFmopJUN3ry5kVSHbyXYJSgFchPbhuXs7FCy9A8s2HowU25me0tCX2FNQnTN/In5rUnbFygHCfl6vr1RNMLBPR41BnOkmIX85kuoUDflqggzC0UPkF2aZCtHCcbXNhgVSF43/5WDV5pNdctx0V3nlVaNUO43ke0PYVJbrvPIFEHleVBZcbsmfhxpvZUKAlIVbYUjxrBfuaKrA8N4x7cW0f07QMSi/hovCl9NHJJSuDhnApt2FyKfNeKGg1PFb/HW3t5PvR8AcEukp3G3Cumk4rqY10jg8uw385Ml0XwofaIkKuoRpS+8KX/OL0LCn5IyhAiK5vZatGp3lLyQ6oM1lRKOTIGc5W6vVCPLcga+nXxzA+M4P3ePW7Q64jYa6o5saBymdhGh2kyckkN5fLgRdCgerpOq/5dPeQZSsW/nTJigUrUWI5jEaaDvMnj6wSkXxn4CFK/oCozO5krDfazcjjEpDaLFkx+vEkg5D4UBcPRldrioksdm7j9RzewEi6EzJCzGpe6SoVvh0KsOvmH2WkPaG0R5RuK0HqJpswwS8YAFd/qNdgZREC9+8eD27KcTopklgWgFSGuq3YOa2kwFSKN3lZu42xWvgclmSJuHpnY1JE5JUSl0apgI3LMMBTbQWjZ07ybUYlGGzykQAhHEn2rZSAI5yMwsw/UzHOGrTCqSAmQm6La7A8xnCbw5xM5hO/YNQ5cQmmMV7OBT/hnS5D+C2gKsPy0EATIIDc7dJaXy7/Rs9VHJhYoFY+u8bV6WoimhCDno7pAyhri5ErhB2csYtokPv/v1oAo+p5ga0R2sUgnRk096SkU6tcTMu3RUVbN4fPftkveo0K3xrFOr/9q6vpQGT0jSB7Kjfwmkfk5zGFVCjJSh1fuXZm6a4RcruNuyvWWIwkYg6phZrkuhFa3qxOc/9OiUUfjb4dY2TjVmG/q/vvFgL6MKqhefzVk7wpx67HGxicHPD5gwuSDk6ZHsPmXjR6J2jslj727Z6MDDUKRi8RcgY+dONEBKn6ZBegl289XPWdbHZniRz9Bo22nR5CR5NBhAGkzRBuPInaqoKiqUtxxQ5bxYU96E4NBYS4IiIOSA/LQ0XbGIhtPZ3NLP4uHrP6Us8AZt/nV7i/CaoYYvTgLK4OswPhEFSFzg8aJi0J1QlY5v+uoE7DRIVF+Ctbs6mX/d6PjvDzdJ6aQNiBrNlPOBuDqsW2WKJRbPUbUFkXdOZGYMiB7ni70YQRcbWdH1ULmQ1AmJM3H4WwXUHqBsd2aza1IMj0c/CRporHKnH8f7yjmoBurZjJml4LDPJvNT0GzHmzudgb3kM7iWCydq9cMn5DbUUPoqYQCooGBrOUU+jOwUovbKLUSLKk8wvRU8kynUXNviDWQCkjurirfQ0eLJSbf3jSZSFHHIpjU7pVJQKs1CnErkiQKF2Fjs2ps4dpECeqPaa8NH9IG9JA15jIaYHq9mw1pLeLaa5zTrj0h6Lti0jxFPlOQQqOQ3AwK8NDETnRnaHrJUx/wORNY3qmL0AOuYWVVkTf0DQfmQlR+REIkQ7KHbjFLar9nZKi3TXHpz/PU7hQggZ5QpdGSajQGcdRzXBhLvuuNjA1Jvg1BSR6T9JHPTZD83G5pxlbm5v8wR7Efgy77YdZy/obtI4cMaLRg49nonBk58CRUucS40prfW5opPvm6W5iLvIkn14jTHtaB9KPatyM/rC1iNN6Dqwhs7bCBNU7sOE19uhEr0tVtQQs/T/8U3pY47CaKTJia+69ljg43XAF+8qM+Nm9MoZeFTS/vBYMdZ1sLiYPN5XyBX6WSX8TLZ3tmBFv4LpfvP5L85Ft8KWy7uHV9a6uAIszlnsS1zp4cdlLiHQsxhWWGhxtcMoyIZhvfaBO07/nhz5Aghmz4O5pD9gaLwFFUc9fsAswnhnH6SvdnUqGLg7CqCU1I4HTAutRfSkqv2wqZ09MZ20Heex94p4cel3ESKfhxJ4XVLOowdLS0HTUUYgLiTW69K32Wx95QFbuSPI9qPirjdLo+mlsoZFyMS8icBUyK/r/zXKRpD2cx+jatEzdMlRJ0SYIPR2BeJppqz/dWWo+17z6ujhavDH8uMk01iVFHupLde4RGE54TmctIWZh/KV+7M6wt9X+CdgW+fzO7k8V/yuicoRYutjJ801fBCumip5Va91wPD+TBBYjJOLq+hS2L8ACvH2sxBIZpySYlp3V0c9XgEJ9Vh/pAu2ThmuB16ZIVFrq0PO1f0FaGytCJClAoXCTxEsIhO0MURnXhEsSSiTrjYmxtqyJDC0WwtCBR0kS0RWWy/Egw/yHMXoFKpLakShp7xmhN+tjIlrU4EglVBniLPn2KPbV8WrbHXn0eQJyA4Dqr7aaYuZVhoiLVdnhlRV2eWsT6O2ytZPEoXpK94PjZikK1IYveB396ErWpXlr2O5DOP36NT9QHac3iaCux13zVljPCn/Xnz2etJP0/0q2RwKGNVWcqVq70jNd6xlL3cMR4+t4WhrfrIyBbUNu8d2aHkYXuMXHu4V2YDlneOR3VBnBvMxCBAUE7QoCVl8rNBpQuealve8DOJOBAwHLMD+iemLERXYjkgUoVC2nOTWz3F55r+onjLoKGOxigelI4gHm77quU+SWa2Clf0FrI1dGnGwBLwCnKhnOvLNcqGM2G+mLnqCAa0Xu9CfdET9Z3E3g0vuulQEp7dybOK+EPCnlprYnaRq45JSbQ6G/nZDB93xhX+qLMVvgwpciA23ind3tdVkKg4VVlDajniEi0Gya6HB9HFm3XgthKiD6351/g3OGRbtnIky1HO1HfQPw3Ek8L4SSNPt7MZR2HRnY6E/Senz8/mx6UXQqaCsHjUiSODrCgxu6dvExoT1PsrC3YNgWrnBfM0KFrJpc9LEMKLb8LmWluBzgFZGnl4EoyJ2oNC/Z3Cuz9dRWUoJUqcaqPBdbn5cL54oariZN9NXqNsB2uOKM5vd9kQJNj5rXDzlkuFMFjwo4IHkTwpbRK+M5oB401CikzgvtRTIFjA3VJ243PDgb5ATAl4kXzKhwADxEbMr2KURBjx9jdxLjHFk1RgjimskoiyjOmXFFLJpQqWCMCx5m9JaYezpoICBUMDdthhHGnmPfw35tYIjiYxAZADnS7WLe7d9vnALLxi/jto0lOJnGllMH2/fngRPDlMhuFmDrmJt2amf2H7rQOc6kba1ruX/PGSiHGV1rKipYvSaleS4LKZB5EV7e86Y+p4XOeVnugrO5vKE1Zw4wtaPWcRo4C8CGrDClR0ZODtJchF4AOqSeosaejTvzYLHu69sTR4Gu37wN7jHkERmpKQf3HuJ0peQZEypMLeVi1+AtcK1irMo00d4ge0Mp6HPqgyoNGkGpxSrC1i0EmNGwvmPFmGWh7zf/J7MSmAGSmFMeRZoQdEzI4GWSZ4puBrGnMi41q5oSmzToTb8J4stX3igjzgo1S0gSQ7bqWwDK0gTwY7LNZ5UKFRbI+jxRlWDBXVkcDAPVloLlBdTdTdn56L8cqNV6uDs5PuzFR5ekCsCg9giT+YOHp/ZA8RYdr+PjxwOxQuVzMA/gxksivLgrS1pH6sox4drW4fgASZestR2tg+aYMzcl3wDbptnAGy+LaHcZnI3bnzf965/gzs0leTV10JSrGTDFoEqiNDmhssA8AC7lw+yclsTGdjPlqLbb6utxEGhMY23boVjAwboF+LKERl3SgP+BJtx29vxHiLR1KZoxLwxZEMwN5ntZDYapLqmNFjBodDvdLXY/3RtFeaKlAdwSXMintBHJqa+D+Qv48MELCtCB1eY3Q+nLrRD0VAxnH7hH/nVyCuqBgkJv0NCXj9OYzJT9I2AKtsvhvEyZ+AnCSsOlbtEWOfLSvrACB4zEUA2Nb7HotIqup1B1IQcgvrNoFTVAZN/2MglrjEjMJOqdFvz1XX/xzZ9RGAlhy1fjs2tdGUQoeiLNDgZG2VFMJfhCyUbMQxQ8Ol3tEZwNCgFyjcOG36IgU6ssqpTJCW3VSk46aId66DvKHpKdTD6TIvPBWiECjRnkGS1N6JDr2nU3PFXa3gUcFtQu/oEMaIWKuk3v3uaNv97y6OeRU47HtsyW93utaY+rpW7RIEhWF5LG67R3onPrCMlj8s+W9+KqK3N1O/BC4Bd57r9qwa2T3exf3EMk7nVM87djnffvqgSWQii2kK6z6jmc0Ky2gTUlH6Jbw3RZdM/JVZftz3DJA8K1E37fxvltSi1rRZlHDMOegyS4qHxMtpCyuZoteL2lkcGLcw1uYfQFQsafEsdrIM5rdzO9I8Urm5dlkMNRg6Iu6D8g7KJQbgRNo+yAYhqRW2lZal4DkE+fiOUak90Lvur7FkEtG8qq+fuwUoFTa6PbQJ0Zrbu+1UZcwdgzeHKPGX7QbkEqe6ru/0RH53WRJjKWPUGVLIeL/0oZOS7UYZKn6cbSfaigHpT0czd31HIqIXExGWn+BbcQlwARPF1sO8+2uq2gaREm3M1w67z+fVCyiOmiZmPHSdElZiGpcRsuPFaRgCVGHdy+Ghh+8FVQsEz/PYPukBCstF3ZG2xzL6WzmBc8lpzGhBnsHWyFqYIqhRH/rtuzmkIC25VJ4+aQqLq1zLmxZ6bgaId3F3kMeTX8SjMTbid5V6ZS9NSazBsVmYA8imoUpsEdDzd2cfqsUq7BRrPe+bvBOMHLmRhnLdcM5QVumFAViBzd5IH26nKaa3rxnFt76JiFvw3q57mdplLkCRxW2e++XUJOpYmflNihKwOIC01+qYiAlYxVGEojzfgrSoopzrv2kn9u0pFMBVj2UgkO66hNlrQ1S+cSkaxI2h9fb5UOv77mgiKt7JCJc8iPDyxPfrdXiHs7TV5sIKHLNDmx5UQ9bGQloeU+gaH/DwaVnI0B/mBy5GZgsx1tSdwdcuw5Q371YF6kvbQQdYWdgJDiaqTLK9D26hDCKdti9YgoJuN3RNE5ujMzYCjzkOD2Yqgqj24VwJ1jcj4BtURLm9T3hDKRfcDYgyi0qwVeC8GYA54h7YcngviDFx/Djuwzy6AcEwvVSzuhCUyrustdZrIpBaPw3SB2WsDuoV43PBvW8U2auEmPV3l6/5tS92UcSF4bnX9IidEIWPLI+LrEGrxk+aMe1dlkVZaS/dmLYvmStxPbCS8ZL3KyvNOhsPge0Bq0nzYvBidRxQdRbBZE4TTdoHBsyaOa6OaGpDtXdS9yNau2eIFeXo+8Mi1FDmQbV40U4Sd1HpbezR7o2vicLz2A2ITjKsbNKm85JzzLop0p3DukUMgHNgXQbtyHz3v66xOVWmX6jItp+fzbWE29d7Lly2reIgjhsvPt8MeVuyfFhdTpfdf9OCTGWhxKHZ8NcCDwtfkTwZ0bKiZw+JaeR8lOW9TicMf27pE15RhH3yxveh/RLxHN44nCllbuiltk0gv8sGefAeD3UOB55gy4HxLAZBEpNJhVRJbIXWfb0PRgKOz+ifYYONZZRCk50ZqRlzKtnYBZ8IIjekyLq504qlbEuQNJTl0f7Wh/qR0Vke3SrA9dwS6CdSkBSdCyMlZojYXwgUb13vGRtlswU58QK3DTS+ywgLjW+h5eeHld+rMzmOEudBCJ2EylkAqZKWFp0HtYbcTxgLXjuWaBVKyH3x6mu/6fI+9eF8e6pHhYoqElpKx6sD8spz8rQLQGqZhvyMnBwQD3GOiPdbk17rkcS+22UPTaVtazd4pPEiJgJij4KOZzlW6qoGHvRmysEqyfMHvVkTxz5xsH4F/AGiP0JS07Woj327sMHgvLpUlCLXGdyUe4qU/JCrhUtHf1RJDSXdGxV0YneHVebqjNNdF/imrkTGSLpexatKWF5svMQ/nVg9AWOkMhzsRkUUrNQ6Ak6dj8qsM7rcs8l1cP9RNnSU3cplJSSS9Ff1J9HwqAPMVua1bxxVD3CBUMUur8GI4/eeG7jRmDsiOhdzC8O62vijI4ZCeLPKBzHaln/Q4aRR4tclb8hDkGk3XKDeSPT+25A/Vui5awAn7MLvIQz6xLq2ugQ5UzlaxLe/qDT07V77g2CKxgwkSWjB65K/OLNTYeqB2R9Jw4Va4Lqyn1lpVBPXWoHgvVB+FbQEl/R67qIGsErZ6dDesgFK0De3LLFvZKMIFutq6fQO76Sqk6AWqq0HoEFpvygfsz6EnUVgzmDx6I2fUR5hHPzApamlFm9mbXqHKC9HCZiLNl7RxuoMcPIfKMDDVIWuqqOpynA733+gNYm/d6iUxNpeUzTQBMUH2sDYg43/BV5Xxg3XTghJb2CBA53lNWR/KJjgh6BNuoiOlx3y+ptipZh6TPyLxyRQABhwPjNST9HtzRWaqWv9klb7SfKhdc8/HE8zLV7RdvPNMw985Wtu3uBWf+NihRgtT51oH4jnEx9uifHvLPuqkH+3CGfBqf6vPeRiVl9A/Em8qkdZh3AXZx61l+zNFK8k6meKrp2p11NwzzwRCcpXKmuFyTvr+C9EnpKeR0ZyOfojcFxP/Goqb0SBEzu5OFpPzSJ+wzVmFpByKUwsXNsy3QRatmC7H032Kb+vKYO1BOG9ZBc8m3dOteQJ6el3jUk3BoA3xBdHyvFoybxRvWsDBdesu2qYrp5v9Y7vHk3/fJDGzceVsC+FO2MB0Z+dWowATZctlIF367rXNDe8w502kBQO4XouATyoePo22MXSfO3u5n1EwXU500Xmzu8TXWMUX8DduTpjPCl00P+DrIRVvZxb0c0t2Fye++46+CeIj3ZeZzU/BWWg7RIE8BHvjpbp3Je9NNkKkGcbiLKumNUxKzobbsunU6Jj0zL4lBxuRzuCOMPrvW6erToLMk37Q4JQcupVZ2Qk9yZfLVDby+tIy0HN955vxOIE3b1PmCre5QgOAt4+R7c6ayh2uEUGu2Us6dLIdt1LPqDPaT5edxd5VnjqY9/00/WXPHjuUAjWiJ8wL3VxjpOmlEGJZZUOgTeyF+ATHQllGvjuc/z6lw5e5lf2v5VU7/+3H+HSTmAlDGcXpPq/OhO+ULdFVDw8ueloQOepxcOXMbOwnWGCWpKUUC3qb+5dXfnaldBfX1nwf5gopzcE59jWJ+A0VkUWRQjxOsVG5Cfqeg7W9B6TMnIr4G1yQ6p6sg5lzXU6d95rKCisUU4awEbi4qRkQRHm588RcCzz57VHYxuZj1G9hQJxzk8MRgNX3zgkL6kv4Mvzx5fOaDsFzoRFMLMq7hCLAl7MO02FePjFdruXi6Fcc5GCGtaygxuEuFOoXyAGoO02gjnjHptbxDL1172bHeHXZ0iF4sMclfPgx66mwF59ofc/vrw23i5U5PePktz7ImrOMVVnjSl7jf0/pqfNqJfoMzJ+cM92vW8fNAoEpk3p9JvrvH4GiqRb/nA/DUWVBared6yrWN5mtxyw+hnuOiYSKQuCb//+9y7Lkl3PJXyI7d8cca3U9tvyAvvpVd8FKNtVAhejOn+2ay7sIpUuwRlxl4sVnZ4G3KfmAz3PPDtpDEKXXf74kouFM/F65b7Y+rsiOak/JotZXYLpAj2mpXDz9e9cwUCpPwu527qkoV67SwmdFkXbsVOkujq8fh22TrszFq9EJOruawjle2y+ehl7eY+kbB6BNC2mbFIY3ycVgcE05xnutjoysL+3sW3hWbU+pqshDs65rIhgwPJlA2EsvzC8TnXVKpFVHrWTL2JRgM1JiOXpoaNmKev4M7lpbrTtwVDJrQJudZcMSfg48QksyBkyn3TZYNSVyxcofFqcvI2ICns6etEeUTc484KRlyjJqrBjJBQXafX5nuxn2h9d9ZoCIMV9dDtjgufCXIaFnaoRxNZm5oAj72k/poKIs48vXREzkwnKAO6zruu11ED/7jdKN6g2L2O6KGOxt4kuF7FGCSuuJIbw6kLBgWLdarWy06rIabRBXOYLmKNBTQUw4BNbQT/ZSdXto8V+OaJv3ksnAOT5pGCHHW/JHz+Tvr+fFCirl38nOnI2SNuAbKZdbdMS+TlcwPOqo1S9OAU7QsqOGpZeFcKfQJc3n2H4bJqF/qTgMWhHxJtiAGgsZ8MWUW5Ib2sIjPc2u65XZXCdUfbm6NC/ZExAnkruOJJoLiLdIO4GMrTBLhITHcrnANKkPdF7XLuvuDelGmf+gDWqKbzO7deFhTzwguWPQ7yF0m/vOnx/ArhR1J1BdJ/aAQKYNIy5VcbBJfubzFV40R6Oiy/pegukgF6gBqb0B0dRKTTizGGo99rWnUVEYJnOV+ntw/172yk5Ml4hz4llSxX7hqQZx3HArSVQsbVoDwrZDWi4vsKA57T0Dh66XEJdN5b6wMaF9oQZ+ef6dlllggmvCy3BMCF6SFiqGQuK3ttecHhQIQFRdgH6gl6kYtrNNoyBBz3mOQ4e2/neRlAWqf9rOOuClScfQ+EbKkZmxUJLrAVUiU8CiNGnSVd8IG87OhuHjrsxERDEngCWzuvNNFqEHYr6sJJVbUOdYYDrLVMm77NT4wQn1VyCug5np0h7uyRWXVY8Ss5Vpzgpy9/rtePuauJZ+O88N0dLe8aFaQNA5LFSVpJCsROgVjZEVTvHOV5jYQYjr5L7+E3rWG52C4YvtIefnkNURiEi9oEBUrxPPDqtefZ1UfZ4ePUMuuCD9V1l4WdqOtVLNyPcST4pEdD6V2pcAheoDYdNXRY9WzwRUcEVzqCLi1iLNDRcJ6I/VsqWQN6S9RAmgbsKBDkSJAG2UPmID94/UNnYq7bh1EANFCO+czKhxKydL0b62TVRA0kP0aTMciy+lN6EbK6IbijTqq04sKpT9/9sXQzz9N3A6vpXAOOcpofyuZO00B2QjykC+3bwZgnNNebJjlLhtJfXVH2AnyV+NhpUeq9PifZx+/xGSqlIFGStB4ffccS6mtppYJH0YE1Xu1W6eato2NDmG/Rj1t2EDurHWyrW7CjouEfQjX+e1bWp18zZUzZuAD6CN2L6fxdzClkM8OQZBfl/WbSsfTpTyVC02nVsMfqX6hR3b27siXDvatQwnWFG+XlwLlTBkxneY+Tr19k3gIeJ53gUoyvkZIbEHkP+WJtDhkTYjbC19e1A+Wbnby8BLEfy2N+69vQZFO6tcoRY2E13ilfWz3VJNhbN77vH09JgvQ88kmEhIfQCPZ0ExjyZ4F4iG/gl/NWhqIYO/ElIFVKYPANXYemdi/6EoYynIVBt/LVaWfVsVkseUABuF8wwH5HHoeDmry6tuBewhtqtNBTFkagtjKMVRXSV/3DY7V3j+eXKn0ZNqvWGwZ6EcTXPgB3y9/70V8zd+/FLVb0qXARqEriwO+xQA0veGzjXxzkUPfieqHZ/6ywUUi8G18M8svLqP7Yjc93PGXkDOTuuy150H8YZz7Od88yGjG5a+oRd8uzkU4U5i7DkMH22XdHiRVob0aAlxLa7dRkjQRG3wlrdxNMQFD8MfWYzgWXfaIRtUFoRhX8BM/JDNwCZY9dexv+PK+tQ2YAlFuyxkNy2hWioisLp9mdm5S/hMdwdHqdDaHzOpghcn9LykUUF2iQcF/Sr4RYizSNv8WtY26sBQ9eUI+JBB58ky0koX6xEqlN7ssf2blCjf50NOC2+kL9QApZK8S35boFNbL6UHQGZ0JqD4G9h55seWC94Kfunwb3a/hsO2KMrXoi9jZGycOZELRqegiiZ7WXTZv1WY3StcZPopfz0LmEz9kd5NJ6/UuZAF4YWu9QFalcnsujiQVmauuNkC3QFWaHkMG++BcH8cLkt+7gV+WwzSWzmtuuxJHIHOp5DOnvl/cL+LaoL9zsk0CzIvRirZRWV9VX1fde/fP8D1Jd1DdKp5fiTlkFb1ez5zlSjYUOo/PcubkSckc5L5deuVFOnUQAzLtn46muOgq4IPKjHF50T+iLFcWh7r/nmRKBcMJbeMvGc1Q26kOdIaXlxf3SNvLHaf+r13R6/Br/8Cvit5st7z+Y9fWerc7P+KQtskAXaPNKKOKIs9banPTfbC2Uqfl0/bzYwWoA05stDKlgw7t4viIkEk/hbo00JawrJnHuHLgxQvl3vssdU69Z686sGIHqwcyKey2Xi3ocFLuweFiM08l21J0JMhuwzp4XyTNHDDWbaKCtPfGr+h2NVB8aNo59Lw2vf10ObfV0bPlItMGyUNkQfQ5sNISCclya0lhLfGXs0+wtJdSmJmqsvMujzfthe1LHKHlzl8RW8JmBP+OJTf33ahwfdfD7FOXqnYBD2mUYWHYJigx4wUntt/QKt4KyJ/XpJp8HTUwP9vipeRfK1pQesajaXUqDguSNcZEGYtS86eoX+wdSPZ3ENXwASHHKWRMeYvBaeip8Nfo6LRox9KezZF/RjAYqi0d+Ojd4sil2U4Ab9TlgGdZ75ds3M/YO6/1v+TMvEc9RU9eT0soIVwQXnGApnSbiWGXINsB9EWtbQh9VP9lULyynn7P7NXLgXvFY3y2foZI+HfzJt4mXwUwjydT/0JMrpRvHXwYHSgf9uzXH20vnkSWNBUVNt21sibXPxvH5SNAbzh1x1iGy7VXrjtEQt5MpLnzsET8Los81rnpQyE11AUYyDn//dqJreihge426P4R/9DbYycZAaeatF5Z9J22tFU/mK8pwNbt1cyp6sHtxnSGS5uMYwfDJ5Fl9FdaxFCBpd468KsYSzbY5NpKKATju0RnU0C+HYdpEAHqz7cT88Ka+u/i3CveWWzu/tuQEEaCeWwuv1VO5GUyzlcqmPxJ0R81Nk2ShNIEW+53XLGl3Xit8NT9Ser3aZ18x3m39+Prz6cHV8foPxAfa+/vKDIr/FttzS4P/9KC4U/Q/EkOhXEFX2xYhzGLEGzRWvDJzB5fr+lO/LkMtJ4U+niunq0lTpcvHHzW/1m5Rew9/UzTF0QmjMpros7DzESWQW4QDRfpxZUxjz7PhZEqumq/YIZ0tF0FJJE2g8GSJZ2QHZJhUfm/rbdAcm+ZBFyzHTLpmeDBxopbx04NS0r2pDzkbuzFkHE2NtnCtA2AlZLol6mssFiJo+8Q+bW/DEYT8LRAm94+wjobTAUtb6TFhqliZRl8+SrNH+UN065VyXkpcXN32vVM9TB9xja2KKpp134c/AtOlZ3QhZUUnUa7UZ9+IGYGY+wcNEykgVw7CreLILOtcRiWC562hsDJO6o3rbI0l7EYaLq98z+csm3NGLO1zFeS6URIHlTBt5/SI1J2buhYCtX4kxFTn6LdpyRHR58TCVrvDRAiMrqPiHeiwIQdpeb+70TTKjlHDwY3SyCzl+KKr8REaxo/T2eC2r5cjyDvuoyGkTELQpPvarBNLmzrcJteLWutTv6G2kicIiH1NyKF3nEFDrh/0edtRKrLr4rSYOBL4Cgg1ZcaaO8F5XJuBBQ4XyT5vk58rLaJ62dw5poPpk9/x8CtLl0AqXggZz9ZE0Df12x1TNg0WyLs3ceCBPfa8syGgJMjGVcICg+/SEENWdeH3bg9GMtRcl6hLW3YpwnRsFV0gfPa4IOimCHsdt8MFixoZoiruzZF1+F5LCJ9jPooyNg4oQcJOYUutRyPSmzGecwe8+/I4zEYC3jCWp/ZNGg72t7gd5CAe3i9hIsJHQIHLZYaXqp3F0/c/tWr/QkwbSMaDjiGNJoFU0bFp7gDEidS+Mzl6l7/ycY3G+1AbkzCccemU/4dk2bWtoilqcngmtJRPBc9v+7t9YJ8RmdV2sQyqHVJQukrhYzTLBFhdXbxNQz/EKqwFXbDtlh7KnaF+iUd3lckaeGUHbpCn66zLRe+AsiI4nBOcN+4ArDbJvLW/t2DA78UAsK7K/OItdK3l3sgiM0pP2psoMvzIZe5rViB8W+465/47zLvp+eDc4mUmkXoBrMmPnpbzEaZ6Qj4C4l6SVyWd0+luvaGWnZQm1mtkQL4yDn2GdWUroj5I6cFszmKEmOMwX7RP54SomLF3LICDSQHd3lgcSnY7TFkxV5bNL6TJ6To12r9kIZt+DuUjrhXcJkfy8w7uaxhvrWavWYWvNTZV5KC2OewWDdmB3/NEHCJlbgdN+w1KMoDVNK8EIeoZHzDUryJMrJKkd/C3PMH5+p6eB+g+Zil3MCHZsxXbeCSkt2yKr4h9ZQ62JnLyiZjwBNuM9tJId8R/mVYezp5NZolXzbIcZSZ7ZmG85PBu08vp6nIb2KXbZP8WBB1d48a++K1T/MGufVaRAE6Ng0TU/1TT5AdF8BjT+Mwml97J9k+ct82TLjvIe9UJ4eJ0U0FGpnLq8+tLumBj/FiXPPeHbCEbuR4M52Iv+hJ3Nr5tKqr1u7gBWLns4+jf413NcOzce1ENd2no1JiDbZMtZWQyeyWVDS27XuzjOpXUJ36B/hJOiDJYTRwMx4SBuQK/Jt+ufU2iAN9gqYuoWYZhxbXLfB+hwgOleg0LlF1ZsEveTZi9Kk53SYiKtaILW2APEhz8X6vmW1ZnnE04kqX2B0nBbaiH1uC7B0Gs5ujY5oee6vWKFH2OVqw3q06oUJEWZaQEpjchwfXyAyzlbcid2qVzO7qucGWVP1l4K4SsJ8aME1XobCeWHSKZEN0j7I/d3LHx/3DCc0D0Exq9++gc4QpdhGJF/sxkTR6uS8YS3sw208dVHMrGSKkrXcr5r54KLwa7iLHMwe/ZkdXeA5VgrUN8R5dLjl2m6R0+SSSa1dxk4A5kGtUTx5voR9nst5PHag5R0AU+PU9AOn0ZVIK3O8W+DPc33/Who7OQ3cQ5yktZomNo6nWtfs+hBYFnvYOe1kVPV8i1uPD6agTctpgrnZ2ZrayBaSEDoXbZ/50lvrs64iWCywyKW06bgJByFV1FKv2TSg9qMp4nyeZj37Fvlm0hXTg9J0bXO+LmBw7tFKDtMFjvzZkejCjgTLO6FZMoVbzsPoHZ0G+6pn5sndSTpXi8GM8502k33q9p3g3VJZb21RiDGxazJFqvbDa3cK6RxgdzXZ9zPZr4X79a76jL8YHiDD9fEP2OnDj0IOlXXB9gFTP717LhEhUYJb8h32BEY5b2GUrjuTpnveHraEqULmAO3IL1Ptrw5mNVbXrwj8XZmpc6MMc6Orpm2ufDObzemP4In5EtfjlRwTHhRZkWPSBvH/cYGnncpaMJD3zpGhjtacNxd0PNWmF6vpybkG9+8eKmh1BRJ+X2DMcFGebiC81l3tu7n2Lkv5J3IDoZrEvOXzYZ4qqMY+9IdKd0PAID7i/IkIjidDwGx/WHrXAM+9iEZlX1QF2WPYAAUCIgukKscfbbxNUXRoSkMuTdkjpxowwhoZO5lM8c/FpTKHuWAv5gvIBtygt0X8rtijlaH8ESRwdBc2GrZpOu91jcsVNb8e7hrvOXfOsX7M6Sfusj3ttvP5XxpnIB3DA7S2vugu5eN2IdEyYfO6ds+6fYyaljTo7My4Q7a70cIpV3eY98p+IvMusVt4bS50USh6M1G299mJzsJpr955g2+x9jgCdp7uVwaTt0rZGVdHNX+ONE2OcwipUyv34JOxeuZTHl6/NOYDPnFtF8DvYsXl8GcRsOnB3poSUji6CaR+cwmKCLJJt7SzkbnUuJLOT/lMokgUt9zhT2sog45y2fZMT81jcnjupIUdHkTbawGEOhiWu9YfLDQ/L8NjRAfKEn5vLV9WZOBaHTZzLYvs7yAtOz8cBxRpUgYAsfQ8CCk5bJvddeyjHj3dq5zldFuK4QvDEnWsWcuSs2g9XB8ZzzH+0D6qE/t8y9VB8gDtSZJPhHRrw36evaxF/NnydCS3vab+P6gVNbcX0SKZRhnOMP7X/01Q4YazerbXimgajacz1iygB7ozUSjJo9WYa3nQAHzX+wM4p/ID05bXLYhVjnL8AS1GwRwVlBu1uatlrjQNmRM0uX5UCm89I+ikFvuFSUhkbhVH6jIzZlC8wknCckc4bpl0Uw4L2vQCFLlAYaWbR4zs8JDGPe1AYnB8p+7mi3hzJHCMX9fo/JgblcYz7NhZQtMcAcOB8svywXMQGM4USGoYTuQ92gkftwH9QL6WqtJ5zgVL0t9jVX/pLUrpbTtHSwENdD+cCFnSzuSTQP5Yvc+VXph9ybGkzMCE17Rh8/Ka0Zg231OAaMvnDzsNaHxkfTohaOGicIUpNTCkHgXkVh7ukew9LMUI7S0P1qNZ2E5C9nUZoxpT0g3yUyjlcfJT9MvtlDOHj8Q6j/NQrmqBxbjxtuOropssUFR/UXpw9QSKm4WC+GIE33Y7e67GreISpLVwXzR5F3RudO6Zi9paFgCLo1bkakzR2BxRnzL6uyg2EODlc4QbuAk7Srs1jP34k4m8iKkDe9wKR2RESd8iODQdqQRYgxFMl4Q5U77BDoBEbavO67xcAgairhCVN+sHYj94T78b4vWNnQ0YW4TbmaKjhriGEiOQ9HIIul2GM8AVA7r1BQQmUbxElVjZMgfelzCrME3++83nhYdrUZWfEjNLOWFpescZ/xO38c5NDA8/vIEiXPt1ap+hw5BtMgfvEcFLbXWE+pesTst6tfEO7pl9hoivNQR1Ay56MBvECN+PmJVvKGOjZQcAAD3yePkEw9Qb1fO8YkngSnnWSP3RjeyhMmA/JJZ+zeHKkVaiK44IAB9d+ekfeSa+iIg/XPWxFch9OY+OeSwvqEsnrdXha1TG+SOnASgwq0HMWAhJ39yJ9o/QK81yphpwE/risjTvtafMxD5W5jpxxhFaCjYfm0u/lv1WlGbGco4VaMn2Zwea1m+ZagPSgLls64y0WII2ACHpGCpG5uYMHEJeoOuKIZnSm3aXGEd8oA54WwIXE4FgoqCfEBCZYMko1HOWUwAHM3eQMSl7+jMyPFDyptCib24pKAHHCA1BiVmwgM/IyUiAnTzKiHtR9Q0pjrt1UHH+ogMyWRdtGsnvzH/eLw6i7f9eCxc4XgO6h9zHFXppaNtOviPYiQJocqSyMYQep+LIq7cHUKXOd3acJU71RJvh/M3XKdCdLWdGHqiulyFc1jKfUsnF66IdN/u7RfDiUZ6CabEXN4Gcdd9Tl1yJkQy8ks7SQs6ro83panmKSjkzMftVhsnhPDvPlvWMC50FEhp2qnC+w8f2Grbv4TNpzaVW8xzl8LFsIuXCliI1Xo7lbB6DcPpOjeQzf5m9wnYU3pZ3vs/2L58fVwLwXsA5CcG3X9ufeK4sIGU2eLW9siGUso5D9kdw41AulSYV7g+Zd0JTnmnx+3yEhBPd6BW/z51sn65HQN/yRPRjeBg5KbgbyYpEZJJuiK6m91kXnapE1Xp0VE6bUDmKyj61IUxSs7Lo0PiaQX+Fvck0T1BqtW3iXIL7lVCUqM/bPhule1vPpo29dfCrt+fA59w0JvX/m9KKb2ZrrQsNhjkvEWy2ej63oWebiCT5DUFHThMngsKSyqAatWry5OHjFEPXwfUB3UA30Hf9fFhhpxb5BkaV+/QmMaum5TT6vPTu+IGL4bxLLYY3JywKHe7iWOj7SsxrE25eyBk8Cvxg4rE7qa3czThb0San62jtNBx+gFlVKtz6pfGsIfAnB0yPkArhkNLir85OyAex/05tasE445gntQWXExnDbXqZcYdZYhMzMp0ofcVr098ea9OuqF+1b5UXTPwbocqPjqrxOvu2l019GFoxvzKVeZpNboz8wi1ozSqob7sBUL4UK6/tiXtOectsOICGwK+RTad2S4Ck2DqmFH/DNKX6a0QstdhhtGpjzA/ee82EBKuvB8il9+6nSLL7hZC9X+R3ue225xVQ9cSO21o19kIPT6L7SnCV7R6k5ckqqo36uDKAQfS80Q5k6AW4r0lhice/zTVaib9KjHPeNuIR+BU/4mIpp6b5lY/0sNkgNfJD3Wa/FwmBYobSM2EmmPsay+CVOmnJekqiS5P7nX1UxllXKC2H7xjC83LITt6I0E0JqyEtGI8tQahUS3qDNktQWQqsCR8jxdxBKWSpNUkSpNWKDzM2p5v+43SHx15K9FwuqH+vntQxsmOGqVprqTTp6jMFW3ncnOebYa24lervj1BZdgnsaLaDjE1npB7SAnkzveg9vDyS5ZF1jF6Lw8JR3oui8+K98o3voal7+QeRq31w0WyOjsk9rbpfsXJ7WNuSR892fLAjkdOYatWt2SCxVz4rBfCM7fve0mH3L18adI06XOpjt6s14HLD6fD8BkZ4qzb1tS3m/Gc/uFsHaCILGs7m8gxW8Upfvm32gCiEzdfi+YdZapVlSp27AVaM5aZDDnaciM4fZs5ruC+prhb3reALCddN8Ph+0ke1miw0P3jFrAnq9FOSasmTTQ7c5yCi9lj0dRZ87eBbexLpvnY4jIBf2k6RZf4xFx2q+vVKGGBNGLmH0xdx1h0rY9U7Rrnd97OKlzTfvJFY/rA/YMNc3oiaFneHQ+laXRi3jnyu3HiS3kQklxz01GOCEz5AbdvAjoLnTnnYTdb7CWDEjgKzIyWGFfI0g9UOz165rbIFXh074GJzDvR4wZb2EPcVndZSdUBHQBcw0BdFzXGvI+pjyh8wojIPBqcORFgqtxaQ4+op+hFhYXv74hK96vMr35ISYGiLw893ZNrX9pBwdIoV9v59wNX4/xuSwX4I4S1Gnv4YEVLBNcL7B826UCCSycDe2Eigk4UlzFYNd/t1x67Riy9SMaWUwii7IRF6FPSAjn+SaNJcLPqx6MZvwTLihDP3/nu3cHHJo0McObr+x+6cXKFjq5bGnWFBkqe2pzntbpi4HbcHlluUpOv8IB8usk1u7IfKGiA7Gb6YgIXG8e+Bp/25rG+pZrWK/CeR8kP9s/7NmBC7yTIAs0d4he68j1JLbTxbgfznlmexPaNhQfvAP9fcON8yfUAZkprLEQYPqHu+cVV4+ugSHlTsrLmhyfODt3Ste/vGvdn5PgpZQanRhU/cMsWP94j8LgmmWxGxur2Em0PNk4Eyc0VLwTI06QLC+BgRnMHTVTyJCUszDyz06IHPvc5DHBEyTXXdOdx1k5Z9YP7VnU9T7vFjweemhcexkGt28/RjbbdN3nlfCC9f65y+fGocNKyX0voBxueHpoCkhfp2BkeoKL8focJYg3wjc5CYRfRN9TiVB9WO+cDl1E95haPzlk8dBILozftOAnkmwAybV6eViSGSHqZM8L7Sc8kVTrdp/0c8pvi1jus3eQc2PBw12Sod9XN81x2zlHSWLRVvkwk21a44rb7z/kYdWbqRH0O5/hek0g8V4PQBtGEf6Rk1IcoBTrpdTSJqDLyjJ+CvDGkqCoG0lAtN1X0O6mM6DEaz5swSZr+9wEDmnth23I72iWQlA6ovWB1v0SoMX0+jSxkwNB1pnrDBimn0QhtwNFLo9zqIyi417khoPu3Tj+yZ7T6KDQqnoc5kyV7rkPpG/X1Yi57LpKBQsS/nqUvqx+P27uO8zd1TIl22TOhtSRYlCmq4Bn7FofdMsTWlb3T5CsWhu9dT6zGG23onMG8ZhcHNybGiReLcPw5PCqnXZaNKp4DaLqrJFOW9ZiCo5F0ZZfF1B4pBk9+32bHAUpVTdZCZ8Xe2XCEdm8z5E5yvDMMxQ+mz34Mh+b2nxYObPdAhlN20GPTIZdaedKqYCMl6FLv65lg5I9WdcEmP4fyb+8pDyXdcygtRhKPihLJ/eiYpM7N4H3kNbPbOswFc3NMdEf8wgHM7xEty4xN3UV+C011/12yE67jUDubnPlyOpSAUbA1S5qgdMprJ2Oe65og2yOTSnR7aGFHd32EVHxSm5Tn8Ofmp1asOnuAc+16+R7c8I9mb0ZBdmxqp+Bn0mCPp/1cuACMldafcmZYBCCXkIew0vchT30XEEXzbE4hcfdwFwtFDvfJA9yFVJtO7Wj2oxAvteb2xDqmjkZR2kpWPZtH+1LYZ0dftViYn+A2ImJxBKgCk7VQQq57b7UC8FGasq2aq/AIzQujj3AtlfE2BM5THvfqPKsVKDHToy+C+rJ89o6P8kErDfEbh0QCkubIlYBZOa1HFEQzxKzGQivvWYpj/a7yWQLNUO1o9y+n8J28lj07aIyEFbJ76nFgnNvjbTYd38t0Oqi5+3pIEIAILkeSOMG2y/h0g+wFOyvXEbFXILTvp7y6ViKs6HyIpPa8zFgVzzELHvboXk0fKNdy/ds2fCP+cLf3QhOMEVdTcshqoNZd22NIrb7aLGW+r0AJuaHTDfCbQWgM4WLaONZDnQ5aexCqbQ7w5vjUrPCLk4fpRWnT0arHozL3F7JhFdLg/JI2n/jt3TzuZH8KOK7TonZgNvwj49HPrTThdWSe26rnVt/Y4kxHwkC7vPe5e53A0C+yRzIhSpaAf3bk+fncCP8LFfLt9tYuGFwiOLoBiOnhU3uqmzHTuYhT9HTO//Yr7Ahvq9f3p1+GZ+VnuAicFfwNQBX1GYKUaSIPH12GRcpw+1gQOEQAdpSPGuQcXCdSIwNDeBIUM9YfsoCur90YnwPd9JmaUXrKp7K6OjWv04tYNOINkW4Va4IyGRkUb2f6ERl3JSnM+HLmqtqforWTD1MnmimGG9Ll3806g9cWQZEqjt5Nx7kXHaylUdSmfqJq0CtxiKZliQ4o6IJ95Xz67SAkJV7nfLd6h5hEGDZ/+XegmEAU9lij1s8A+7VmAANpQUrB369S540BxK1mXdyQCepphaK0AecL28ADrzL75veQ0K0iiQVk+duRANj4izm0fR+r5L9ILUcKVWkxaxGnA252/ofqKukuJQms46FPJl3tc+swHFQAc/VdGYSaV80D5MpXUMCt+aAC2TOfXjM/aolHzfd7hvXGo/AAZrA19tdL+vpAJno0X59G2JilJtIgkaxlIwYjRez3H1QrGaFTND8/dWKIPgr9/nvAFUK+MohLVBAyvmPGrgGIFuhUunaZE8jRPAr0jdXC7kRLWiNApjznLmiwT+DSKur9KTdX0F78sAtsr1B0xsrgh2iAaTowX26keIGA9g+qMLICmkDXX84oW/GIX9w7NrkKhsUrN1PPi/JoZlNNSf9F56/QZZAqcDHjZhv86VE+ubKVq8BqG0+KBBNJzfcDwHn21MFn7gHTChtN855PafluIlDNE6jgLU84SoxRV/4dLVDzEpu4TB9m7boUy8Dq8iCOtggfY7znFvkNSq0PDMZDiGp84pSi+NFFXQryIo2xz+y2fWpbaS5g4HQZaljQdfAMSldxVMUcKrwPepgBMJv9LksvrbkpuVsBa5rVc0PWNr/NaX19sBlzu2AwElcuQXbIGoC3insklMYPeS+JGdqTRYpmMkf4vYB4KfwNgq3HUKqG6jWpZbU04FwEGjHPCzjghiANmjHXJL4+dBH87m+3nHNoh0gfapsRzCShg4K/XOYDyiKYrkDqzlLonP9Qj6ohpYE9Qkvz2ylmf8q2UKol4w46OtYEOXLRWBynUYzJf+3L98HCj0y549hLKiRY0gV3TMjeaBW/DdvKp+waTb+eQvk6DJALolLoTY+UT66t202BZ5k2CWkBfAn0lbfSjj2YHFS9Nl0gAgMrCaatzvNvQKWpl3V71fdm3AFEEmZ7ersCsM6gDkI4ChNSAMXB852uPbgZGwy0adJ5rj2nQcIlRDQq/zvBtDgw+3OHx26M2r+YEhtv9sAwljdkjsZ6bggLkvgfOIVBG4+gwf9YHTpAHVOpGDUpL6zd3CSQzI/SaHnBc01H0yA8PPHC53Mgce8+simYH/9tDU7r0BvRpxl4/yVE79cTOv3mTWlnjr6pUnASCP77G1G6+DutM2TOfb2iMiTKzvmUAtn2EBYTeTc7qLoqOk7TzN/4/WupXes3uO/fv0ViATlh1nQl9VHMIUeQxlCsyiivzUqI2VWzvXbZUaxyL+QcjzrrdNsyb0i9RA1NmtpBIuYl1NQdYkf6zmXvxuYSkVYjLcW7J5oGv+qWxqT2yKU12v5JONSlCYBUzVqhmifl/liJKwb52tRGwtK2LU2J1st9YHWVrY/rczufB/zBD5nMppZWR8jdP21PHrlUBxbjwQ7KOyCQniOS3uT859R+1jvB5wfBtdRynKm4K/yQNSlfxOlKzCyR7JhWIE8JRjuiz2uHzVRsUDIICQUrtNv2JKbzD+KBGQVeh1C92Fco2dsmMLGMFHa0uVqNldxobfjECvsGrQccKDByqhKVf5Dfr5GVwKXI+9wACNzryltWv9FyRH/pWOyousn6Bg+tKvxWBzcKP1ZbP6jwhs78S6OFJdVtgHPLabjjqWdcDYcQknhdaOzZSNr9qXpWo8uuCdjaYjYDZOqSgo3kpPIUekRKsxExAS4BdSbIIdv5WYDnemvMcJnNTQEuJmgXaWARFLMQXWELOTW1Zpiaoanm/6dduAtHWTxfcQ1/2FnoEeSUxk/eo1vEbfR8WdrFbXfpfGIbfUr1JaUNLwRX9gGIGJauMLvBYxk7xsLtaFu5hNz1a/HGxzpHPxLc9JhuQKJ5jiQH747XQ/CJptvatYvaLcwXeY8slHqwEkW+7IL0c8EJ3E2lwnNg2EpnUX8Ucd9woVs7/lhcKAZeNLugfYQfsYE2HxFWqfV7cCLpL9qIJTEpA2V+CWPg0hh4Jgo796g0oOFcvfLpDCSrQ4HVHPRcEAdlJrzX062IUsj2pdZITjboTyb2HkXyNZgDcvW2VaPhi4FNTFKv4b1rTOJEyO2RlJcuZP4/Lj25ZMrpdcV0z3JQETzn9VV/1gfhdH4K3HkbxBTJyC6JJRywuVZUj7pozupNZCWHgKVmPnov0OZzEhYXZV91t2d+6EbVnWG49DnBsHeweFZe0V8Dy1bYewTsmG2CFmNV/vJhl1bX8r8giBRQHK3jT9i4lwGl36z9PMO9h64xhqiRmWokIHqXRf0msmtLHO7PzvaarZZS29qn9ysANth6kOVZoivIGdPlFgRW20hICQI06f2GkJbeKMPC4lSvFa5r7M3n5082/OhtX6EDBh+7spyYhS+Z6UH+HP9sZ/VZeHecF/y4XpS5XRIAhi+euBJyzRCqA9h2UJI5AE5KA0fCcRyKrOtozEOT+Jk5bwNgC7d9nPUWhYH+XDU/I/gp4niB1R0gprsUu9ZuryDTY540kK+HsIzKL1oyJbCfTNBg8foKA/ggORCEG1dign1eUK6LT1j7zed09FF3AlM/gUQR4jr35d62BKC7sAjiR87Vm2ikDqoxH0zOcfaTMvhlgIc1D01m1zGse8X6d4H/MCMGyQEVGQusJ0zYm6BFSEo3MaCkc6l3WLWjafRoT5l7oAyyAPB0FfzfZoCczwU73IYBhF9kz7bf5DmSIlyKVy2M/ts6l9DM3QDw2Xg3tsna88nbZINDUNJ9b3LgN1QoQng0eyFUPeNPZqj48sp4Bv72FnyH3v1mfMN2j6dPWed7sA624BeNaK8OkSbfFyNaQJUgrePoyOkFCJta4gqsTysmVI8W0Jj1fEdSPBghsodhTSbvjqi/lAaiPQvFpkhH4n1puCcapQvWQ33V77+iItmrRMgxW4Iz+WxB49FHrb4bHX7dmK0k6b1bjAEPaZp5adTbovO33bG33Bvx384tgsPrxpyUqE/H/XMZM4WegOOQI+J6XDuluUaQh0fTN4jcUz1hr0nrLTqegfiMizY5gq+rQTgVhmisGXAvuzKvjjpK+ji2utDNSprTWd47+DtLZu/CX/AunnDDX4NGJaJaQIasyu49RsIDjqx5izK4aMjvgIIlr3RIWUryIEGLeJ+jovS+9DMvwsX8ulE1vGTksbB/AtBbAw1/lVJPkInTwOv+EHuXIM27pJ3b3GMhglHYVMm1Hmqe7I/AHs5bW503qYle0VMp7bctTFOnPqD9PBk8saN40mg806uqfiYWLCG0ltQdWd2mi0rC45QnVCZSr5sutuv2M9zVfw9ep1JjAQ6oQ+h16LhoA53ELNNb1YaNE5OVsfxq2PtiYjEwwYqpVYm7dXg44zDoY/ziusqZVrsHBWEs2TcFQxJ35nXwYeH/AYETlWIjTArxlfYKW65qSL5Y8hzyJhy4762okReZBl5n6yQt1LFjB3UV+0FN3I7psnaTWAHwTR8Ygov0uHyHnuTy5E1H24kdwYbOFL11WVUoxR1Z/PGSs0bBlIka0dW7m0eheaRN6A8jXn/YXgqTdfZjoeo+atgk2fzW3POE7xdmc+ooXn2UlvizijvlO63fC1MdhPQ3Eg/ag+n01gk3y6aTGgpStuQ8nB09B3NU57E+unszvly8A+DbpfrEEpqWscPOrTA5EGPh3ZWCqd8p05XRVnWVKrkeKb7utVhCWwOjzfnhUKrN578IWVfFf/BOTcdhXGhUZD6gyvxtZwl0MY6q0aDpAqPokesLZjs7sj2Y9kXs62UK30LGnVNi+su55hIo3qC4tmcTaFW+Jq2/Ha6GlXiS+jwfZxsm3J3dT0eEO0a/j9hV+wL+RrqUrccIqpVEw3pkcbJ5QKlTOP4pdzibXbgkSqt+z4WiFbZkoTREB6J+wr6q/1DLCQYNlS7ETHLpaOzsCCyvBwG80K5nN2VVVvqaBHTsUh7LCSdVOdble8mDmxBrSJBLFWCvSWuN3NulFM07hX/iVU4JAsLhY61IrpHHsza/nGsrnVOsXaIFP5t1r/WY5uaxHCe4xwJ2s/RhfQyaTnTmp/BhGAHZpSMLU3bsnNunBit74bV9xtxfdLAKia/kexaxOfJSpW2VmIXpbOndIXeZ3hVKZYCt73uUj8BaVQ230vNnzbNxTib6c/tgRgV94+YLKDADcwNtiTi/sAD9LkwIdfwy3D5Ay7pvxYIbPpVdPz8YQTmu/7DOjjqx5zedY7GwLGaIUCH52NY0YxswvMsr7csoT2cQjK3755hrgbnc0cfeZkzrq/N/nb7CMBFIyaZEfv0N276kEiLyLAq/eMS5OsgnJKxzTfmvWlxyUCq0FtxUIjFE6ol2JFO3O5gJi1SXqSMVsdecxDtnuFyCzRPsQrTCrbaV/TXwc8Tz2eXtGSbK0iGg39dUJTC6oSGiisGv1Aefxg6EALW3CSpnFH59Q/e5neXwvLi822w2SlymS80V9N0TKtyM8vr8tZQqeWO78kpjVmYy5PHbemdSX/j1jetcJV/kAOTqkutdx1kgR2104KS4hC7yfSvb5upcs0CHt1mOR2GE6Vs50ot+MKQajmxPrpLO52ozKb+CSpn+823J6cwKA+xzm21RvySvuUGCz6k7zosSCs5ZmiJD3pRfLOKHByRoLD2ArBdI6aLjfoakTbycFt9H/jQbUloRU73dKVlY/URXfYI4lYYQWophlDdbrrjLWWVKpWliHVbcTg+t/7Bh2uWRUfZJhXRlNohIs+Y/WmcEU7irLYSOQkFoQT6iYU0Oc1z2YsJpSIdNKhem1i84sMMHgsSvbeBP2JbTH8heM56aAGJWrto6hj/0kczc0vfcEyDSHsS0ISCFnJ4zksDYg03nztrEoksyyTddDLkYnbXCpp95i7tFa470RJBCe46gmZCTv2BSA2N80gVlqsiTfywQaGtUcP321K3dOf8qIjpq5Qi2BaiXQbQcFLokA9T6XoR3VdyOhKabhwS/TCBXyX8GXgP9dhUKZwEkTsHVJuc7jLDrTBFBTSzZqVHzHkDsQDLL/N9BlAv+5grPYu3mlQUPLvak7DE2pvZr/99S6sJKB3udnTffpxsWWRHGuwPrkba90ze8zUNnZLkD8hvfDO9HrQMQsjFOWnf62/9+sjrXno9EyTKsPz1eLCjDUlyu66qwAq15bdrM0qLColxhcXoYIQ7Pzw67I/9busn+T1eQyvt6MaSlBl4kv26HeLylryxXHMoK4w+5dSXcbeS6onxKomMSNWTHYYVO7ATHiptGkGj+fGXA5fZGbuUPq5+htFudiaYUwcEqp+JsnzSio8p/IOHj1mM/TlPoTEfbLjwQN0MDsOPmaiTCT7mN3igqB8EGD1w4p7WYJ3tr/VazrBLIEwtbUIKS4BnwqsfCYoyIbR46raird7JXiUXBFBJ6vqQzgP2w6y2hd30gndT6PHA4S6MfboJmmNLwcBU1betNF7x2HDpSUS9z8uj4MRAp2TSc3JwHL7WmXxEAPbxgv6GnDA4au5gszPFtmSXDmzKxULb25M1lmxfaYbqP3EsylMEMyXrO3OXPPvtXfyFriugpCAHrLJuKo+oiqdw4fZx1a6qRoxj7wSXVWQtnv3BZ0D+jqpanAsqwsBM56u1PTIyXua4zdL8ml2y7OnT+TWCSIv7P7hj0s1ew5yqPMgQIAdJWtz9wbu/LvkzZ5GdjnOJs2awHQnCLS5/cAPZRYvZqi6I67VTBrEtzn9wxRjQRa9XjW540I0pnrQ7sTDnObzQvOKqFfa9OcqDSnSNNnH7aN6hBysTsjLluhTnGfupU3OormHvP3h0pWYwJJonUWsVjhZAsMDdBqucHrOTxLHunJG91qODHg0nvkK1jVaZKI76SlYbghraGv33sowKz0ayrB4XNT71cxBUM4FuGh58IJe3YSywB7X3fZb7FrcHPhoB6AT8DlpTU7iUtxQA3Tli5ckrJNJOu4caEdWeDoU6DOcW5Lc9LLRfUq4S/XLJX9DZOXJF3V8h2SE7p88DuEB8GTk51nv8zEJiD7DOl204WPxhm+iGSSfTHcQePIm25Ia12Lq3Ws3X5OGvxaLzeqEgPz9ZF/VKTmXXp28seeIdH8cKpaDo+Xc/9WlqBzXUJpp9i52SxJ0b0LnZl7DCbFTTQO8fVx6w7BC2c0HkKrtJ4DwJHVWMRpMrbe7LTMVU/AWTG4qvARWHpqeY8sXn+hJL3GqZPOcMjEcB7WLtKNr7rEFWmgWgsHy1I+dpKXF/MZcCZiq4SPwt7v/gjkizg2Xb9AY2nZvSyHg1nDsadjPjLkvQ+lpTjRvtgtu57q6uXqxm2xrrD6AEI8dBfx+PgI2kSxu7kjykOe49SuRhqxDjHaip5Z9qU2Xrjz/WmXxEAPRQl0x9snMytSiaeW3P8SeFDD2ZOCit4oLDJqv27VoIO4dB8svKUuzOFfA2fWJHb/YiLTKPfagSlbAJg6+fu8nalwXhFpc/uAHsJMM6p3YYTsozhROPmeTU8WJXRwtoQLuA79FdaJ5aveh3gfODGnU7ff4dTwFcAaaBXmdqAjdU4LJNxvtjK67xo6WvtiwuVPszxPoOXBBti+MPDgBRkEVrXUufP/z5CVdGbeLZPopwjJJtnoR0tgqx0c4dlD8Y+LJJ+EisVaSeuprxwRaEk4GdCKYxMqaGcdnyRs2AAu0yP5wcW5XTPjIklMtpKJoBZ4r9oLq3XPtLngiReGZ5aiOHIuf1OWgxL2pwwfPiBEHp9BPza1FafQ9NXYf4B364QxYEele/K4wpH/k0ok17C+SyoUSo6qT0afqrQFtuZMHk/jhoKyCDuvrT/Ddl4x5a97/x9rmnkG6lzVq1XGXjt7zCrzzjFOjeodH+8PAhozG8zNkKYWGfR78zBsgz0aRk60PxkmwWCpBOiHWCDkpzYxCvsoIpNp7b94EO9FkRSnz17RQSVRTneoJx8+yW0wwYLB5iLgBijlMhxRZvTsZxtuHro5y+g7+xHfRBUa3S9EDlFJQloq8oP52I899YncFOW4jwrVsEaVRCtntS3WTJCvZUgQb2ReYh1z0bAuItmXlWL6goAnbMKn0UoA1BJZLYhwnyw4aPC+TwNqvOycNDauliAaBKeGoLYrCf/5NMcnppse218ZrkFmbmehw6/9gaQv9+8uZAck5tq5BorE4YNVMxgDVTi8Mfw580Y60qJqOPPBFg4k0BgwNU+KG3iPj1XA8ZBs7+AhwA1hch3nRzOUQLqcKRpJJAZycxrGNDVmWDZCfWNabtZ13zDc6Nx/u7adixsfOSL7j2TmwVu/QWasG9Yip4hBusFVE31UAOifaRtYcSDj7oO3UWlv68vRhxAp1elkTquIAox284Hag3UhyfckTCnyM+ZI2NbXBMx6TuUpil+XDBzmxaz3d1qZBr8hQ5ogbwAFEuQkYgt9DeFG2Bbw7mLextIUZxjsr5vk1WmDJiVNRDD0hchLrpJWi46c6jkEJlMmZyyAteXANnLnxcv7qNq+ZdTh/6WIDtuOiE6NqrQepWtchWExRXHFoe1nWzWe6gRCiKUG+ndOVAfJzgljaSk3Bx1Sqaz/YBcaTCitkyP4r8IXLAXQBSf1wlMeiVS3qUZyjNPQhEirsEiZe1zEUVvyeITASpbE9PArIWcrqChEY3j5+RoN8+WxKyE7KYMuVKSeNZtfstViGUq2ZbzcviBqEOZ3yUxH52PbKpLqR0RR93hGSwCHc4yGgLkroWaQFzRzGNSTc/dCxfbQDyyKSsotq0EdWkYqgGt4N2Bd8EIVeW/MxMwruEJKlNOUk+m7YllTn1M/k5yRh8n7x2mW3C0h5CjP1Z0cjqtCzczHX+hKiAOYF68Z/2+qxsW/iJxKbmTNjlkpQFBpIXZopTtNk6CkpfUbpVh7nj2sOzvMrZMVWfY8K8LxTrSXd7K0Ey5rX8I8G1OpYTiA8bmKBq8gFVEYjU3gvyyuRSQIiLQvZ0JfxEUu1vpL2wwn+fy3evSRBev5uqljfkJ2+fkw6ZXVNPXZc3kV2XiONSn+B2jyqsaa18Pkn7+wbfU0qTsUJnBnJxqiRBXqKrQtJVXySoSvuIUGu+tQ94gUZ/AqlW6uW0OOPwq1VjGYXERfgkpfa/KT8azY9itEtefOlTNUlBcdyzxNUQ/BxyCVlZIx8Q8YFRZiYRCe5WuBLZ6i9+hD85zGUQSo0vHYo9NaVGnO6ZEsePb4gD1vz3LO4zpWqK/xMP1fFwJSjIP/StWM5AR/B+Y900/+cGzDN28+nrA+7XinVz2njdAPkwEBCTuxes9AlGFGmz+hB0ywkHtpHNe0VFx61wzbYQFkoJ+DmoeWP9j1YZTbcP+DogoFZK5mJ7Qr0o+NnQTae3mOwkRQQU3MkRZS25bWh/xGhsAaL6HFZ0Nqnw+FBOc6bCg9KtHPh3u1cKbgg5HHHJwiFt2TzkYD5BMGQtvv3DvvZbrMAXSh/EbYEPmxjjlcR8c4RcDfsfnYhLfaL3nc4FbDWbL3Jx4bYsDMqYHBDqQo+bT6nWLcTDBS4GPOyN21CvcLWXue/0REEYzk4EFvinjuGlyBHTdD64Jqom/AwrnweT3bXqm1p/D4Opw0Qh7aX1Mzb4ST2KMOE0vNGclI831kY4JmyMpXHp7vyIX3Y1bTRIbfv42cnX1sgJPyE2tZrwg7FqR5GuGlmzuYknclPYvAoUJhKx6PC61yUZh/wgMs+DD7PGIBjPWV5FxpogQRQCn1N/5McAjxjEb3ZF45VTAPfIfB9hbI52AH6Ygp76WYXJrCCIyx7HldeJKkhjtnaVKJJZRUD8jRhE82wJc5Lf+14xoKLRjwmAUfmG1gdEY4qIFiPohOOpyYwkvZ5uKn9KjmWNr1zn/HNetV61rt3srD91L8/4x2NcSkjuXnMZ4rlobpq1WZrwE4rgNdr4De1j9IP/EDnvwec++DBZRAVLETS8Ku5HQGQaSerjfOdg0DHPj2BAkV8Mthfvs+sFQWB6hYWqjgxu1er6YZjm7Mpu8G4vKfD+WURPZRjM2h5Wi4bfQY+EDzEkhYsHZUk7u6UTfAbbtu0ZYtPB+aPrXamkDRFEG267kQLH6VVchwUHhTGMbMoAMuGd6QzGwGBq1FMMVaGhax0aXdOnJI99IgMNp+G36Ritvv0jbQKbM4NEQpuJqxlEBse92lcSoX2sFaV2Yg0e0dbVLksyGFQ7/Q0ggtpvCA4aBtX+niuTXfUjyRZ8n/TZjq842E8kPwJRXC2tJXX59PVUXZCLn0WtG9V/U/++ABS3Z/lZqcPc/NMkfpt2LDq+KMCZdc01lIN736hBzOupwbXOg+IlVUZiG8u/TBxn1+My4JIM0pDc0lPNl89XyRpJMMB2r/KsgqmjLG7IQs3WoLBHxgGdz7OXc/cdH0rmBVzVZIOQGx3cu4WD6vLgAJK8+l4EvpUKpGysW25J2RgwlFQg2Cs3uO00h0wm9BYsLY6CCywgFuZIqbD26gyUlCUkzDk4Yn2IVqNcQ6djo19jbVwBsMs6JaMX9U/fhQ/ZOyD+xvZxBohlwadN6sTmK2i8YtBRDirDMDNFAVMBOJmgUG9rfvVpo1n2/oB11CdRxJlCLXBvu9cigeGDj4Xv4skwe5QdXVxQE5+kq6nrhXen20oMc9VbyN3EPCT5z9M+NCIY9OFiKituYF5xG7LExIC/qYAwsY462lA7OZOcsWtOm8hWBYXC9SgQP1TRXFDyVa+YgfqtY7z3lbgW9IxOazF7BG07ZCt5+di3H2WBH519u5Ts6rVPXMTc3LYyqo4iWH2hKCk+IOzhbIeNpSJFXrG5qggkdcO2HMr1OHme411NORatiOM/l8Q0decLBXZSnr2xB6ATjeUSamsaSnTg5XW5lcSwu16GA0WJ5e8RVwq4UYA+thSfb+gAkY9dCDYh6rtxYposdPCs+nBA2O9bekIbmg2jK24hC+4kc8IV48M3xj8AoFjhpSQu/qa+hhygllwr4N1Exim4zYKZnizmbtt7rV6f7Pkyq8eyQlKhN/CSSHknEA6XWk4NvQPgL3DJkfnfxHk8YMuyI6BngICGSxWKpBEsfCCUGwGTQ5EWwoW9vY3DXRwuSXxJLHB/G8DxH2HCo6GauKmIb1SQMx/+yq7fIH+cGF4avIl8g09PgbgIm3+Ojioq7YYaWyrtTu4U3Sr6zJ/m5EjtT4qK0uetrLPw/Qo4YMeXBeoVLBeA1bhdyCi8CK5ZeHDeJt7/Li9RFgbhr1i3huf///ox8AICTqn0p71HAhywVGAgASUBL4A/GaVze1GcQ+Y27bUhookRHXbwJItpJq+WiwB/+LoPrzVPSrKUqbuimIhZQLqJJDxKcV0gS6yeVMNht53juszA6WUTqYYwPoWsYTgGHtp67hyFldiPYyyGtBhTvsgmjK9HI7VRgB3Co9hbZGQ4aWw4Y3Xj1U/qFkSXFSH2+zY+K0bfK/Ixlf7oJ+CT2mEPgC/P00Exbx5P7GCgIM2nwBblyKsQl9GtrLdoqL1dd9fPETg63+OunwkN2FQXeRx0CHQ8EX9GTH1Y68uYqAIYCJ8m6+WicgPycDlEB2om0tOZoDNHU13idRLOazfEuEdnygRtBFUBxVlpoHR+G6R4OORX8BUhQDOqjFY3wRknKOuYV9mjMGuRFAOzr3f6RqeJACrp6r4kxKZpJJUTN03Rzklgq6tvuLHThsTtsMeJGpOBDMIOYBg1V6Blnqt1Q8Qu1R0v+4Kp3y/UDT8D1giYvKH8BWqHia0RDtNglds+Mhc01cayIFuvlms8Ee12p3bWVdcmEi7QW01yAHAhxunux4FqdrId8Id6WKhWJ/kO7wRtabbqQj3QeAH9lc8F62gN6rLN3EuRgWxpjrJkxUWTZu5IzeQGSasVLhQLyIY2zd2GGmBNMlgzaPm4eU/MzjhAHHNfuIXDphYLyD/TW1LAoLi1se97Gsb1P9lq1iaB62GqwLkjTh0zYqhVUxsr4pSNXJ5X77AE3HEjc3Wsp3yi3yt1UR8WxTM48f5eldfub/EpBVArFZQ+t7g2VopByN6VdIiVpdrA8moD7FZokvErWMoV91JGrTgPjOguOAeVkCh1lEcVBtfyIYrYnOHGQnCigHbUQVdMo5PkRqr+2Q03XY+GVOAbkCXCb49dsLnXWgD8q85SM5DD3f9/BiYVLiTqWvyySQHtM5b8+SoNmoRd81uEQpQ37CWgXgu8DfzspgDmsLkTIn7fWDGKOsuTamekecCpN+B9oQntmuTiFZkL4QOZTeZ+K9pCEs8mGRMtYDUFpPrKDY41sJByaQkMgTUBDqnZNNdBWC4qlnC+Bb54tJ8Xyx+jd31gyNuUDQIVgaY+r2UtfTG1lDJQDwaYlO62ULgJBR+QawS0gPdK00DgASN0oS0wnX1QLwv3rijs3uSFeF0h6v89ravvKo/m48ZkGNPO7Yku8FuGJN/oSXCcvxjGza6c8uxVNR/0G5/4ir+kTIxQacdPZfHcs2d8Ec7VxHqlFv7qvsAbRnlzKGIIvD1k7MyqhOnP8iOcWEee+eZs8AswMuSHVG5mS7tfg6bJl7NQ6XkjpM4+UgCFTD5zGSBiGIAJc5BBIMu/Zb1R5RiLrv/GSQg0zIMNxDUESsYUTK1tlyerQiqsQ2aMqvmMWTJORw8PuVoZMAWDPnofSoXcOYd46JwibnWvy3p6JBUEZXv5WH0hwpo5R55kvs0QhtoCeTsb8Gcq30OFycs60LVBN5+rOLmip0GbycCkhL9cRwF/ksX8Urn3LWx7sOkXxAeg9CoRav/E7fO6557pUfZzl4VoYEPPPFEAzGJECnzSdoQOJ2kEKFyryNFzog0Yv00kSNhClRZcG7d0k4jrlVn1EzIdb3DKqWyRODAeXzRkn7dEUFIGjhRYGgVzH3mp5SGigStkDeiaGZqyJ8xIw0TSncBVUaBTArk9iFVJ2AO/i3UkQQ1tFJUUJNzZ00C1RaocOK9GDNldv/WFOGdBvFWyJVRTbuhUGlSsKyS7ggwAoyvsNavJCOeVUZ/5p8TH3ZQdlGtp2Xj2TRGVwkIafYObtvouFLzGHL8Dx6zOR/0gy4bCVZA9XVUbjZdf935hzqRhVwdQVNNRa+rJDUCt59CKoHKG3HJHOkW+CbQAuAAwH7Mw9cszTahC11nJHyWPeMSj9i2GfqROblbxz6jRVOWNnHHjXCrUtRMN3Kf3TGDdYNu2qI1eF/gIut4B442IlvuSkvCjEzc+4hznN9CBUJZbuaaD4f5D3EwRTDjnTN0ry6xXvqABErQ1d2PgRSvWl2YRsDVshkGA5uLyeSkWgnVr/pG6tIUa+V7bev3z9Tf9npJQfL2njbd+up/fF36rhca8YGpDj2C6h9VQs0Ky3J27cPbHEkreUWqZVJY5t7fsAnYommmYWmiLk4tUdd3uI89zcU+CAJZh67vSnCRpjYcXjdzTuIJGP2AflFwgSyEGXIwi/OIrTMTYQ38kcCndOMzdhoFOkLuUhWp/t3WuJfliyBbNgQsIvvjloKZwFmI6pg/3uBlIOt0sFeOYvH4yDYiJdUhljNXv9ZjcDo7rjzjQbWv00JXnfM++OJhZ6ShMAgKG5E++fpVeq2198egJAOC6AM8IRR7SSKmB0RMnkt1dhn0M6xP7pDhd1SOvlyirj0kQzu6yhhWdQylRNXaS1Oqa1UjWsRegmkVWm3qb3XW4pyKNW9lq1aDGMNnJc85v4MbUyHV979UkH1KhYk0Keh9KFWhZOc2DoMin58j+IgbYWKYKC6o+nJZ9vgBb2JzXdl3L9QcsS2rHYnUa3+4HfmJgTztVrxL+VnU2kBUbiHcB03i6+1U/KsiJx1j7/e2ofhMGu3pYXzaNrn55lJeU60jSATQoRIaa13UV5HGsyql+ykORVUgF8mJ/kAAdx/lQj5Hnhgh2TucSVpv+AO08dZZBcEbw1dAPrHzn6TcPw4rkjH8PkAhrL5suZZh8JsDb86cjMnG8kpYqad64LBv7y80KPmGcI5ixwBgwg8eqn++5xW8aP9X40cEXLrKBYYYWdjnh/UK4TZeqwwJtOopS24lA343Qrzo/pGIjhYzN4QIfN8CAVb3/K5Mb8u9f2nSyntgaBHe0xnf2Rmzvt0Lb5U2Q6xweby9pl8PK7oLcKUY8snyIEDjK6RspJ7l2RPsX5ewgXEfeIF/aNOlIDTVYJozM7Byjl2S1YfJhBEij1fFtY+N42MKycvlLXyhmCSRXe3FzJLFyB9ixKg6lNKR2EE0RAd+Xq8504bx5pIIQnpC1b+Dy+sxxpxcQhYsytGf0vh2W/zm5JfDCfE3O6p/xt9y+YErt9sa7oKSJLnb8CTuHfBzpf4sQ3hFBUUsLG4wV4Rlo1gF7ddD+OuunByhkorC9dKrAZPIt7CWhkXzJk3I3vWN+ezYZvmM9Q3x7RLPERkT5XtZB978lh6Xm/0yJiSTz1koOr03/6HpGMyvnge+hF+wRN5+uggkqV6xpnf9+H7b+/SCx2o25Y4NbtQq+wsabu9dYK0outAcuBNHX6MQmDVPg5lS/Tm3rE+h/Tpb64YC9x6oNj9xmslhhFoYPtSdUMe2lJzAmb+A9aQdaWh/X0SOSK2o/iOo5M322dSuvXBXFqQWTxiDnTjajoZ3ZSWZW1nEKgR3Msqg9zEIFyafzn2yXA/FJI/xhLbgiwMipruBKbENoaopuoioSnGJRaqMXSqqIeXecdAnMsfQUjCrRGgwj3qRP20ZxytaZLzTB1Bljb47ZDENuNuo4MIw/jOCHqRJCpagxHM9dOJ903kuK+Sk/TmUv3JiuxxOsODkS2PhKtGMOfqmqv0HDz+cNVkBUXea/O4kiVibGAcQxQPfjmozugAFwVpm4pQWiYk0AmfCmZmW86KpLCnNr7+PzLVCrVPsHf1tOTkq7Ojclv93TQPSYSStYAPm0nXCcUHjFCRjdEIM3I9dCI6jZiLaMqGJampXpeO+SixvoqOI2t3e7vlkeEr40rgWQVlkhHwORsRkAka5oc2IHh3g09DHj306kFelUAdQiQbK+949X6KZHwrjI2X761jQbYR87pb88Gajzah+RSxNzbrZej0VuQzcz4n18R9w7xB8ziePwyuNCwQAivg0uXKXi+4gMVyMrrwGbCjSQsKRQZNL3BJEyHptoqZl7XbMQy9ZADZODLGofzd+Nbx7VDnwPDnvWh2SdoKgfXMqNzzRcq+JAivjSr4wgKY8i3GKGvrY1PYh3/8fGPqOPFlulYmc++Ta3r+Hbeo/3zb/q9zNjfn5fwXg6/z4UJjsd89a3oGBkmGQs7HHBx9BUSmQJgyZ8DheHUvRX+Pd+gTCbUxqJRsl3fpOyiU2GT2RKZ+rsa6zYSPIQfI+qby68lMY/fKoE42fly1Rez00/t58FfvyJ0x36RtQXj5IsKe5I1Wkz30rWns256azmwZkFUGSQWuZLwW/XhUieQjDYu6M3pzSr6uYHROlH84snf2HkvGNe1E9iTEQuMFmBdra4UJBzEL6QlukjQcU/52vhFRwJeDfb1nbV3NLwKwb0wBqf0d31C6g4FC9PdjfsMUET/yyGpHoLfVIjNylX0yO7oW42wGY50eNwRQRlXObZEbe8QQHdkWsDsBVT8pBMqME9S7NUIbahuRITz5QylHLLG1sJYV0SLTpxTcq3ILPWK4+z+yuQAXJEw/NiD0VthIuQRmlULP1k4rr1icKqeyzGqC3y8IPmmkkj3KmspQAGe5GbztoJ7bBvuTeXRTi3kmO+GKmCQLcuTfTXLEoTd0L3K9mPEjQfc1bKcnb3Jx2c6mljQ0vaDUjlHm8sAkvp0I9yS2AW8EcVw5Q1eFEhLLsQCQT0dMUMNoR5u9LvxaAUUuIJEo2Nn0eLguB0Ro5UdSvjHz/9er6FFR88ouni4Pv08bbBrXp+OTxkI4wYlgjneUzkUe7xCC7frKWeZqEWOQU5qNj9uwEcr6u/nTjY0sbxLLG+Y4OzMWAv56vtR09gtfYuCYQfLnzSPV213nkzuWT6MKX0ZrXELmOyC9W19Uocyiz7KVQ24sDVuYK0JphL+y5tsutzZnL5fjvE1N/Deug9uo3QXNdE8q83m2dKAikzG7b2hFCbZzrRnNzOpMSFgsuw4RTyOuDCgl07/pq69u2HfbWik3rv4y+sTxfZJ1Qqr2NL2oozX0rHhvnET2zzfbgL5NiPfziDLihv4x7cBEy4tw2IgiuLDt1G9HeAlrhWrWD9r88txe6E64x/zf6izI8eFn9SW+WEm/afE81M1QMKXw5MfRtQZvPi35Zdqu1NrTDbn+UGLvp5BFA8skMGdqYjRGDukSXpeVo1wuHfK3KNrXhJH4VCN6rcqaulT4eTR7gSBX5s8jWbhqrn3ZATtgGGeJwOzpH5Yo1QDIFXDnRdaoqaDtyfFw2ngSOafZ6rwe90pX9WjOWuwqLcGZsw7UzUAVFQ0NzxD+DgeuuE+WE8K8LGsw1q4ecQ2e+7Eaq+AmFcpCJI9i8tVbN2AhlAKIR3n4+UgxcY+cD8fdE5ISDNPbIXF7zjpOR+FGwcOeVvdQc6uT6vn9RQJEf99DjEfUYuf5ApbjBlss5MHnDJ7SB0mJILCXH6HnEqVPjG5uhGi7EIFsJHlGWLMA4va7sa8+3yWCcrGObqgHhtcihXVwO5eXFRfSbuhY5EFFjTepJKd1eHHO03N8aa3l3RYVmBld+FvvEmHBFonaiHnXkNjh3srirIgXtDi3+0VFHpbfIGL+D4BUs0BBSmdUbTT9kcRnxXpT7dDbeQLm0ur6UbT1ZWGS52TJOu4YVhy6zaSsYj7HmzCjzdyWgM1zzYM+RtV7VfgF14/bVWP3ZKIZuEln461RzBkTa9+nQwt/N/manoX6NXsh4SeFbU8RYszxjLvVHXiaE5nRBfYhk7UTRdcnYvrLzyconOdq1tKSFChGAj6T72Ice43JrD80HDNnvWGVakcTEtujJn+Oi84TGV3j26bWgVlyUsMmYn5t6a9vGLB44MxfB3O6uBnIff7PWK4od2zgNHts+s6Cd8HRQybx7JCH9ESoGBJwT1IX2/3gsn/yuRWA+QooF00qrpUzYsH3qMTE4HvDPWFVEqh0vWSRA8aaq9WlmBPRK2/1knBEoJ/p4UTaQDXj+84dOTE44kPBxgwrAFN6moAlkulcwK3IRrV+tlO3qSKRqsNJ2LueAwThigwBsUuoPEih1H5PW60wJFmx8fftTzI75Ug/VEcQI+nZgTT3MZROcCETT6ync4lnlNRNZxntRRhwMKl0ni6nYhr53QPPN1bB0LBHJY+xDrxt8wQPTzc+ADfTGzO9jJC1oJqgUBEvrtKjfDa7mskpdNGAwnfuKrc7LaZKY2GW8cNl+KAUUB7bISBQ/QO98NyZ5fvj4+tFW8aPsDigfB0Y8U03GaCRj5YcFDDxHuJE5H7JdGgErAx2oOmEhzfSQo7nq8Ff3GYR+Hp9fDTB+1fbgDnWAzZ7q9D/6BR8KYcdcVjRBq2LK+f4R1UKQbPKsOBZNjqA92DqOUrFrSRmnjIJjU9ls4HravvtGEY9Njed8MHBMi3QNzx6mL6TMB2cMvY3BDr338UAtGa6hKv1NkAf5N1rLRbfXfDQvgwoK+mSuz61dgIXbmUrNiPwEVm6HoBCeanuWgHc4sh30bJIIsL6mDkVc2PMmviS1r8lNRA9i7RMJqFVnZUMvftAFtuGJXhZsRV1HDN/pcVGZOLDNC2xEciDhtr9Oy0vUn80yLc3lTzF0BrHnC8ofi87I4auvgb9lgDyuvIXC9Lt517KJ8U2ns3bnwV5LGnclo0qNjbURC8KdoV/HaThYlulS94lSjVFcSrxOmU5u+xSsK9U2t2wC7kWfgPHC5uQHVzGHrKzceKTSVzRJX9mU/KTmBSKiqh4wHJN+eh01u5rIL2k9XTv1mVzf2R2rcQc/Mz9ckh7MV9Jl4SqMsf24b4zZtIXrP4NdeqsHBteFyolhPJzaHNqYaCY6mEF1X43rL8jsRioyDuFL+hJ6CGxKil8QFSPcMwmrzlm1g8BU4y0RHGMxn2zlcLueqoMRDt+5nc1UEMglqkXTYtYmqZuxRQvcQsiuJ2IqxzJnI26VmcF9No3hywm53y0XALDnRaI8G+kJIZMnxgLPET8DZlIOWE7s2pM3/X36Ydk/JLXNbUsh0DSUt0tHPmriMYVnouY7kIJG5MO3YWQYU3Aq+/wDs1JyTUfmEpYcn8WF8n/ySgwzHaJaL6/8hccNuMJ9s3Af1J5lHiAhwRo9Ey5d6+1D/apTjoEeZCz6tbo+PmAJP8g1Sc2OQnRX1x6s62fp6eQeqUAhaRySVri3Ppkr6pKuBAsoRR6IELDY+rJLRkBgBnjPoxnyMp2E5MRbWhCtiQTfXMJwEKQpZoEy9VGHSisjeOBFbpEjem1Ru56xmtukvI9wpankpvFx+FnP1DCdvfvqUE4Z7/0j8SjyiT7cd2qX09G6in4vtMCQyNwBXKTbEhoBeWexjSoyuLm97qlTXfb7LNpJ/NrFpi8WN/r2Qr25X3yBhZLAnNWX+NHav5YgupXo295VNJTyTVQ0yhSv4NeglOZR+QSEr492Wgo6cglzXQmBlBLdXYccnZEegI+Qbxq5hog46qy+a652wSYAS1tMec1R9SEJVAPMlQsANjd5JO/gdSn6cE3nGUCQj45vcqNucxI3Bjsd+DidEwp1kiPujfsFoMzPOHVBna9+MQWP/vnVFUGDuLQAcLZMhd/G22YKRB9elIxhRsD0GariGKn2ZIuDDhplXnv3xEZUGsReE0WMo2YpTr9RXEBEclKIC3KwOFsju6w0eRV2e0/ik90lqdDBG+0mslA+hOva1S5uiKrmMOn0QckgVVVd1wk/YuUZhDIEWxsASXNDFkUQVPotEnb0EvW7g+VEgO+egeoa9nlck1bFEegLLEuJJbCXX+dPYtPpRDnVeIpeispNNtU26lQYWqZ8SHSDyj0Lr2c+T8iZBCnbzszBL6diUbapempgLtvf95OeTH6p/LJw6Zo5MOSPXhlU3QqjS0LRc0YF5C9nn4m5Ej0c3aue3kST6pScThzulmo4af5D9dy+lVRNxPWFiXwMu7z9iBOWgtM48BGLj1uT3Q2ARJw97fBQpuipzgIx0roP7K32jaMsHCFGKrSAxEj6IunY17QhClW/4gfRoMRYYCUWnMYf6nqNlAkC7Be3t0FUQE9Yq2tmS+uVQ2TQN2n15h4KZQH81jMC4H5jGxEVxMGqg/YaSb/egvC7qSmxUAjYIa9KXmVUL47sQVZvXEOe3VmXe+EAvOo4U5vmeT1AWM7trQ1xnEflJCaI+ZfPtgLD1SubYwdVymbwkNcszYSydfDybPlYI8kVV+UiM2A3LFKqDYEgbJzY+6xRDsOzSRTKVVpamxDH4Mz404WYPrvX5fR7q4DxcL0MOCVh8SLqfUdUqNPGZZ+hpf68135xN6hTg1gC6S4d5pAXPP5xNT6kgPs7HGz5eOYezaigR+cV0kg7JalQxYs59rjglAURgIUS33UEt8z0/VS4ddSaRL027q4x3dCe8NfBj4bl0/ACer+Y+QLfTwIv2HyYbGYeCGASOaop/uIFZIlfg1Ny5Etm3YV28Ed5x79rAVvgvDjV8DOJshWtR73dEJR/AJW2P8utwTz02ZVvmvmEnQWYcFO+zTmUoaPlHIVdowFK8hlOkRHCJ1paNz+n/ztLCYaginTOW0TAoWlbmmGHO+TsqnLuRKgPOsxXbnTBCEZL6O7X379LB311jOMeIr9egzU+AoWBq4LnrnGUQZjdMBIsDUVz5JSR7aly4TdyxiaYQ9J5wXCVb2ALKvp/Z/EAeGkdt5mOwHlmuMBi76wuWxPZ8ifQ8s3b1hCcwxPHXtB6UWicodu4U5464+NHTW4iUB/o3hFEvm1iuNG4cYDF0BH7O9aP6PMCt91rWTzoq9f6Oi3sJi4ALvfTLRs5v7GTTeZl+OqyAg7ase+ORgcXY76Rb5/s8/4PEshzyPVVwMBdHAWuSueosnZsXNX9I2knLz9wT/dxxP0ASfYzo7o27tUoiC7FF6CwyM81BAm2CZ64kEOC4zYqoEqqDSNK7MzcPTz/fDA2HRdN6P4lQQOgUj8aD7k//+WFZRPgtcMWy9MO8jlcw0ixxMKRoXIGhmTQH4YKQpcgqOfjNwS7RbiXvYxfMVWb/1g4nqtW1t+DPDnqDJzCvuW0upZfgUUw3zBm1308Ilsugq+L/PIVATdBkl5PT06rAwRUwh/w4n8pdZWBv6T1EsiwAoscN1cSZbyPUDhL4nqOOc1pmjjLTHvjGJfD7XF9+Oax7Tu3NHzgvqiyuCc6teydI5AGOx/+fo94GToJYiblOSq71oG6FKOd0AziWxQMimxD93ESanK5jlqAOmeop6Q2DlqQFogwazNONECQ0to7ZB0q80gH735Ywq7RdAzYs50UwnN2UJEwpVZX5HAv3U857hLO6vj7YiXrL7sB4KqD3bh3nH31s4EI9Y05gVg52W0oY5CkLUhhM58Deus4DCvb34t1OPvL8H6zoz90uyXkl+yjntpIm7M+m5zMISRX0L630u/ed/nz7fv50GbzT9ti0yR2ex+BXMVwf/FW2BtZ5DVMetFQ/wryPIKW+u7f23BQN4chjyfOgFbGs+Gh9DaU+pfR9nfqXM7QlrUpOS1QtTqbFS2/kz78XaNz774Wk/sNyfe/jNQhc/T+vfM+f+X/pHZ8V2tL0V+ITljf52vqU9kcWAFRIXrWLy9g+ZcyeTspkFxW2MJ2jGkMgQwc5GS+5g3ItbFgD76m33FHg1AbRxOJSN2giOZJ8NCWbxgdKX6Yxg20EXHx1SqIxChqMzYP0iM+UeeXUZy24/MrFn03vBg4tFL3sP0zJXVfzWwPw8FM3qVOuLgSNY4stQjA9grXbzfRN9G+uu221VSPxgqWk3jDC9m6IJ+OMmvuBPfd6pNGc+hIUC8dPpqZ0GpXy1nKJYZKSeC/RWsFQG4WnzAxVq9DGJRzQb9MtHFqtG1clfrxidDrm9tu3nKL4n+L+d/E/LXw1DhDd+1EiutXoXAi2PcRdoco6eR9KtTokk+WWpAXjj4KPz40Tp8NljFBOuwUnqQdjLOdi3e6G8570kaM+t0037/uuwQdTVSzSzm6ilOIb3PWlFjD9mgaBu/e6I2U+ZnpCVPat8bzY09ud2dKeyccdHpyle3axVQ1qXerLC6qLSU+qNq/UiWiKrwY/5pmWiK+dVDKJcvVEKggszLCH8foUkHhSIg9YVDqgCuPkfDk0DYIlJCCtoIvh22mbw4CXn5vqR9NTpBbDEzh/1z9dXLNbw5dNMmsrqAniLbA0qhg41FB0TiRrwbhVDPqi69dM3hf2vJXqsTZK9AAkDoS+DvfE5z4bs3Sa276LBHA9YDUDij6Sb9P7cSDZxxr7A1tVSrun20hFK/jqYOLKXfYY+P/W4bYRL6F71a+r6UKcPUor67Ep9yMb3Tm3Qa9Kkt+9MQqdTW5pD8VjgmpEpfo1H4cNqruNH+OzFpOI4mSqmkMosgBMpWn/vBshdqzuxokR3qvbOE2VBF84XUvkEW3ZX0ujp4rTm7T8ib/J/E8UMprse1ghUwnVx3fm18ot/lb9aJJZX5b4n5YJRSXww7z87DvyowmzWhwjhWstAJFLNxRQOh11PzqexwqZIgU0gh8X0/kGDkXE6TWDGjVQZIGKvo2i2qYmVzfnW9RP1xGyrjEsgEg9+LnBZL/EsHAdnM8irTFC5qOLh4VeTx9Hlbj3Rnngi0qg1BPMyrylrcppYJwLzvNZcj3ChqrSS+JQOFibDKNTJzh3D9d+L3fySZ7FQ/Yf2KonhzT3HaOrDnT6etns9j4kM9o2ylZfl9yP3EmD1yX9bMrjNBSw1+4CGv4SuSP+Cib8rWowo3n7g7dFkdcw9jCWK8fPfqPS1QUZ5ENqfcbCVamskq1NkcgVlpHXxv+uiCq51IauchjSvwnIRME3xJFcgclVssFSFTZyWkaEZXLHZrxq1watdpngOK3bR9nnmXjnXTWwAe20Ffz4cKVUGphmpa4W732l3QH5B4s5yyhclHyvWq73+onJfT+ymhzr3cUQVlrJ26S0RQNkSxkyFusW3wBAoJ7jCtVAOEna91PQcv0RSS8epiB+miaTg4fW/aQntsG+fX4rhpcKkBYkgJWfxNgJmsBqMffJnyts0BQ1Evzim1D9kfwqA9P/bfMQxu3uOo8SWJHBxdQMRsFiF+FKdvM6N5tsjwpVSWP9Y80s1B3N0jnXOVKhyxRUmVwgIRKUIG1+B6sWEn8D4XavBmwUao3T1NYYAdsdgiqbjATF5d4ouxwVK2T0ANOQmzc7OYYhqoAaH/RRfCdX88p8WYsH99mXaWDfFoxTnUxXLf5q5CbR2b5s78XTWER6ultA9JSMJgbMn59N6ljoJtaTNFjlpJR981kqPxT1H3FCFtVwFFQ9bbOF4KhBeIPvZnvrvGP7NMBDM1g9FBqrxmt+J2SN9DO34PBdD6u0BE+lI7cVveQYz0oio05vWyQN1bBzzb5zX0zVztGfZ7ZaZjs58tYXXgaY0PNZqUP70qckaVtfzoYcOxNQ7oTuoDooBIfWvyL/zIPEGoW1x8yTVYCc4Qq8MxUMVWyhdFQsr5gPuh/Dw0akZnxwhB2485NC3iqehunLuTIkZ+gUFY6gLfoBkAVlWHJ6P/xEDkL5mpdL9DM2qst3k2YtlBHOllPfntLSfeWwX3hh76bSEPWgi24j3nYE2XCm8reKLt0SNbNsOj8Begy7IQgD/6pV3dPbp+WBCudLQX75UobGoa35UhBT7T1fD5D8SiGgnwfoMBUySr3jc0vuQOuLJBJpdnuuMWHJQaJ/veFUADMpvDHMcNJdCh4xta/TdvTkLsTblBZFyb/F4PO9nTmcfB5ZFeknbSvYpAyKMx+EvMKneQF9Q0UjhA6wbPV2caVkg4+9JLP4gfX6WDlW4N+CtLlnZ5eZkz8DV/3ckCMFdUjrD7DdxwtkqiBFfc7aJxDcdjLl6faW6Mle3wSUjGupBenDLAfitrFDftug6HSLMF9ORdyWvdA6OSJeC2Y8caPFfoVI56niBZsVDObjykghr36kncIp6WlVs1WWwML9uwbqKGDmJTecn0346KzAkyjuuThNfhcLw+ONUPlpwfewIIhjLoGnaYTFT9ufX2vvdPb6WLu7uelhzJg18H2OkoH+1u/WBKejEeLAZ4y3nkNxIhYYcPnLjb0PbEp8XrVd3mECeS5krGo61qI6qvAzPKWjWkpSuHaY/io3Hnbrf+kdfBugpr6rkHZGOtRvW+TODcMNXUlkYLkazsiNfsBeRA6JwekK8zSyZcVxnWhrM5vgMp4tmNNKbyvnahiQnAT8HHC9mOd81z3u4X+NxBPzC9QUBkm+P2oXOFQjHT8ZucANbtgt34IGGVPVxcDqR7r8gdMhdM2B5/2g6+6S/eoFXEU8VZ0iaG6bGhknFfz8XJ9DZxTm3CCugwBFm800wr73017CCb80ftayuFFOFbRKDjOFpFx2WItoSouv3TOAnw+ee6luI34+//R8b5Dyv0QYsGc7CyxMT+oEvMvm1uBtwYuKL8LaxZOKQzorPme9RFK8yYiMO0zXHCR0IM3Cw6ECxd73Z/cxHbBYEh+tjfMDpbPYcQ+dl27BeR3FwP+5Kb7Ojx63C0fMjD1cddEQBl2vOJiuOjy8F6SccNR7rLiYp2dA7nFyoiObvOOsGjUU+RMleISqB8MyYBw5q7xp4dUPeWXIGDeuWnaaoZ6tAQHphr7/GnLlSEtT8yJkisejqjndSnX6QFZEOXRQ/UdQRWsFp0VfIbh89bACxnAZBXCC1Z4LzZWIWRzYQCYEbpsMA/CC1R75JA2TM6gX3Z7JXbRBMeoreTrqkcG+tLGdFWGeoqJ0lxzVAnvm2JFxoxIG38UkAI/vZuT0BQxA4p9erX+wzFqv6qOiipAkMirgY36rtYjhNybXToBh9Jca9QowCaTN/2tyqTeEB+YBTNXi+W8ABh6309H+yc4PwzPQw7xEkg0WeR4ENG4uO+WUHe52DKh3NkGTRcwCPyRJ3oLLwWRCwWN1l+UUH/HowtZ1P9rzBNUKHEiV/KlLpUJOWajUs+Kr+AB8WVGp9gmEcwZsFQU3/AV3cv1Qbu5RRbx5Ouij7vaUhKTWi9Ac570UMM6LGPMT57UqkaDNQoVFRoh+PkutQvqNAfvEYOPHaGQPE4z800nWutcsDFzAdbE4xYLUBFYhqpkk9yvbrN8wCAM/Fvx4IYyejOrIyD248KwprRLZYr++Skb1Abfq5vdsPXJYTOg4wXECbNcrOI4E7gPAGdIteuhPcuW2hKYFxziChS2nCszLvtfacN/M+4IfO+xU+iYKpnESOTvjdogWEsRy6l4ps2OX6Z1ckVGxE4R62JAkZcK+mYHGxcsAODQMUTuOQNrSN59LDrAEDToxffSifrQVuccK0KrGsONzCjhP3n1dykAkO8z76PpnC2KSwKO5g2n2eUYWsqKahBjaBxOKEGw0UV3u78I3ah/anvMWPionyoUjcQXiPrBY9bjGdTpU0R1z8K3zPhuSPKJBcXrUoi0670cRZ/bQ60389hV7S86JjTbY9Ks/HwFYBmRLaESl7R3FYIW1ozY1wThBEMgz4IsTFQOq2zD38USPk8A6VKY3+5r0ZqlQ5UgabYf145cx21MO+IczKPQSwI85Yk+ujNNKoqv+IVFkKSoyO+o3FydC+cKeaJC+AZw1Mv/8H77iM5Vats2B7+WihiYD87x+tX4CB9lv4zssmMGIa5eJrAHlVoOXvvy+axY6Ar7Zn3NArgJs1KcC6Bh3SI0Zu+4/PwOq1Ppg1EkjIVPn1+/AN2gVkvmr4SlK0ETV61ajEghtvJFaIAJlqyIKKkaUPzVWroDGXTGCTOQN7zXZBEJ7fGM9UqDG3KaXQBnBHBASM/hKK9EQb8jAd8uMa3Pk1h6BJ79FtP8FpQO7iqHkzPqnf9rHnpfSL5gwv8ELHJELZluCy76FxeuFJ9Y6NyHrOQS35isJOoLwQ34A7ZST/l6VXX+9lPzNx5JsLXg+Szw/HGN5hUPCS0JshTcODGDFKvmX6kwI389pR9+BrIJvM13x938/IIlGjVp+ncK4IB/CC+qfY8EiT9oFyC/kVO8wuihcGGDwBSE20CQye6Y7vuhyqWpoHL0DBh0IVyxct6C0i5y3luKllvEFs1rYAaoUsW6c/+lG3nBbObPyd2pEVIMCEUXZs7Kb3FP7V1d4QVvuYXY4lybfSMHXvXpNdyqLypxmXqn8laClGFVMydO5lTkRO2PsC1Ipl7x1QLvv8owIdFDYxxl9DTn0bHQ3NMgTvppFC2JqpIge8Tq83Sb99fTqAcE7U7RCFJuijB9qGrzkoBtX8GIVrYi4CaXwQdN//DFBfmS6NFGVWB1QSnmYK3g5jg2sd8Pu1rhA7PVwUvjO9n4Ptz8tc5qbLHJxPU57zY2SL+rJhbxM8FKjK6hz2JgQhBGFpjKcMbGkk+/+V/BLvoOmMPyjTejy7jWJKZ8SHbwVxE/IrchZM4yr8nYUrSaWup+1wNdAu3dgeZEj5as/vPPKB4QD+P4s+xu3qJK/LFt3/FwoUy0SZ9pXEW2+OQsJg9LkfxePMsbwwVkiOEzQpPWD09n92bUsGvEM5K+SYyQFdePX2aRacn3GmR6bK3F51xdU3GoRr4QKksVDgmcJyM/QP1Pqohnq442FE5yubAx5BHLvslwmawxhBIJEfPt1l6KBmfPjjHVcTQGMFFNX7uPsN0SVwr34AS9Z8y6vie4FbFGipLvixx1euBfjat0WST+ppNNa9fz2thbyflpDiuJKzXbnWrjMuf0BJknoPkmi9miMBEOFjMvSIBT38EyT2oN+jFTo5OwsxTnO1W12HRZ+zpSdqa+kdNYtw+OFrQjv9FKY4D1d2QNmFWlF6EgWS7ubdWim7OdoRej18ETH3GqRPCTI273frLgkZiWtoJ3PBBmA0Lxx0/O9KVzU/YxV+s7y9UTsGexZt7Sj8BLTvc+UPEFn697lVV4Q3J4U5QMJVqF7FJMS7DZk4IlU7atPrMvt7CizS/gquJ5hneBZjKURbouLjYJF+3s0GskPX4OizNavgC7PrstZGKTiTI9neohyaPwy7rOIpbutbbnUpOF7ls0CAb3orSIT8N6J+XpxOzIPFkAn92MfJDPaKBkauwDynraYXvucFY8AlzMtO84BRU3UqwAOHtd3kYjIG82HOKE2AFIBOnApvaLavG7H0asqLdyISBdK9pK2pSO4JSdGA2Gmrv55OUcWD7kIH3JfTG3Dc74DAYdUvrcwCLN6rTjlgyCR697xYVpnWt0ugDpVunmbAE+0nGKpMHzta79K4L4zPCi5Vjj59Kfzida9v7wQE749SJ5EdC5SyUmekluOUD+M86q/YqT8SHSOoewnI3gGFG5rqNnLLieFsJ5VYKBjc+YHjMiQvhXFJ1T3WdsYpIPKXYK4z7JI43ZB9h9uAqZ66cqVU+yPPX0+OexMZ+gR2+oDVXpG+4D5Igi+fL7qcRKWP64/8b8Srf9/t3WNenjKUAeA/oMXy2myPpQ4Lxg6TxWJaSpufMmklq3QeHQB7yz3otKWzMVly0VdBrKVEXX5jzP13zBXzOR7fUrqhUFXYOpJFLaWoFmN7RDZ+ctccwwBBOtypewdG0o5duDrcYVkRm51bBS8d9AJTGKnjj1BuMindbUiEhu4QKM0vY2mcVY1lr5DPpFX5ulLv8QKoklPa66vsgvc7P7k/CALHN09jXZ7VEMBMYXtaSP2Ab0ez3+VGJyhDI+0O72sIkhGirCk5LLrXKQrNUksuwTneU48dcfYTGV0QW421yzhnchCxaV2P8F5H39Ho18Rzi1Fk3JRuRYt/fEYkn8MWiv2YvlXNDmjLm6e83QEgqCasvYGbUwcT8b6SHo0U7zo6rsRCzKtgbQXzc6yfsJUhXjYQJOr06nE+gf2HA9k5z30W/h0zItkT3vcLRUNDBXbJou1yRBR78yqhRoxcqNA5aKUuBo8A6UeqmjDuio8BdZV41rhNhFIOAGUoqdlxhWhzqIdBuEcqNtFsIClbOERSBWtCL5NLGH70geelYLarlrsXGs3+Fl5y2ir566z4svn/1Scs3YquQUXpYi5p55BJbadm7IzzY25vcVzG2vJ2htqhdcnZEB3kW5KHy+mQZm4XURauMG5jcdm2BDDSOy7hMMbV8mFaODBL3SN9wXzBQgZ6FEAtBWxjOGz3PvoGkT5/vPwlFEdpeZujK80WRJ8LfcuaeM+o5x3Vqr335dFnNdUbOVhkK0BCu+RbSsXECfc/q3m7cU+jyOY62FC3We6+dsHk+NFGFDhvb7Fv/z4wmljHEcw72EOZkkpYJCgWuGHG6dLGi64XjVLbN6yUsyOIhxZETAxxHmSxCJz3AsWXaMUF0oleeKa9LGKJMmFfLiHJvQh2QfSHLnbFPDkBQHwI6ltx07Uz/XnVbhKONDlwogZvnHLO6S8egQ+IAB3BgmcFHIi84ZIw07B+7ebIa7/8q7+fqXr8sTs5pdohEJaF3BhsJu+mozRKn/Y543ae3pENGAsZK5mrs9pX8HAMqBdVpsyQLp1rcuJ2nN/6f0YNV6nZoFuWzWfTSumOJjD0htE3etYxXUD3qxgaO6PUVdTjToyZY2vbyHuVV/hikW4AfY3tkAZ9AfTbBhpMPy+yHMsRrZgin3Rh7an1gLfL/YEK6v/fjA7fFhN3x9x/C7XgYIvXhrq9rssrHdE/HIDVSxE+WV5Gdxtx/GBrAfpxpteeIq76yxsIG8pShb30laS3DOmLwyXhPLlmGmbV9HVV5VuCcg3nd+GDY4Iy8Jn1LtaZ2sjqc+JvX+6mc9Ysa1d+s/EBkugPcBUill7gfQtmmAgpS6ylYvGk4AsJ18i5z0Fc75XcqEQkzYmL/6/YTDiYbuddt0u8i9Ck1pMG/a5NW7l3Mg+pvo+vsYP18+ndR28QDaFYo7FNPT3M8xLYQfIsBH7VmluKZ2NKg6CB1aEN2qs5LtfFwq76Fpmlam+bkq9AiRFRTeU4uBatAl/93Dpxt9uXkYfeGmZ06iRYknnTS7LE/LUQZp3OrAhlmu30xLzAt3slmjuOp6UlNR4TRVlm0SeeP/eX4ewmSwSHvsp3NMacbD/J2Ud8dDDQfI7UY1sqr/vKPr0+sbk/tX3NqaBp3fgINtNKcrFjlcKm1y663rWLsAGn2ReIahueecdyViT0z4ACHhYNfebutD3esZtVvn0Ua8qTdxXLMvdNsrQJM/6ieiObaMsCfsT7daWzbupDlZYURYlgfPOkyUHsJQvrxL9fgq8XYjYXz98yqlJIP6v9C5ZZOSDiT/44mJycTiQc3pbYpbg0FN7NqApWk3wn4QTEbC3k56T7edJCUzCmkt12kXuOgCNQFFW2DA2422Pl/arvyZ85cD25FtHjsQwBSatOE2IzZTD6PL3bi2UtyEz87LAUuIl81L0ok8mknHJYxMobUtET138bGx+VmcX/Mu3bYR7tZMAyeX7IzikORVJDKVNntOy6M70cCbOm3aNPJhUncjvp8zEf6/L4M+QeCJo0tLKjtYA0PmBHXLgqHDzVi5lWMKIg7O3OOJLL+Wg8yxamdBritxBqudusdRG8muCQy2Za8qw/4sRFo4od5Tm/8Kn7L+6BEmKulggNMdQ/x2WIlN3+21POLdwl4dP9zxcHAGVGpOrIqwqJ6w04d37GoWBJ031Ois2CFdZaaHSC9RRh9XPweRYR+YL1J47dVqDv4/JD54TlwsUCCeOTCeWCIJiTViszsyUfL0pu3aC7WY8fWrCec8jGZWJjw3SD6nko/8Ty3RAqrf7am7rhIxvMakFf8RBvatXRW37F5MiTno4D6MYernR87AUKLOkdy5PaFe3KQ2hHtbKXAzlVJDv7atgFBmHw3FNLDMUD484Yfm43IzqkrwPQrm2n9x8mcadI6eCbJRn9Vd4UIiQCTi+whXhHJPLgTAcA4/LNC2ZJSjcheHMTmOfT1wy+jqW9Tw8BkpnA8By0nalzbQPz++OhsLJ9fk/gRrI7VdnkR0L9+mjnflfRCW0sjbywTz+3Z2vc5EvfOF9isZJpO34t138S9Edj7U0dftDpCP9+tyaq9eciH0177E77zLZouWLpJ80iIMVZYWIUF3HfyjcFEHmafW6MU2r3EaIKCbogo/oaePfoLrWpPCFuZoXZ1g039wpPE/bpGZ/TVtJumfclArgte1UCmwlYBEs/rkKA+lV98hBoCXJKtfhazFvXYS/8p4qztfP3RHVXRE/uDZ9S6KyEvfXkJ4iqnuL7vs6/SZj99B9ee5Dtb8DgeoG3yb8F6BumJYpPoXN8JkFX11XP+HohbSprxm0fsbSx9J163QI1vlkoXm6wLlftfJX8BSRTtbr1ZuJG8zaX7WJ5q8jY/LiR6bzBpczs7Ek5g2s3/k0g02TB0wz16RG99gtmji+h8yLqiOwtuc4I2F/HDSUB3fLz3hKkaBjT7fdRWl8kOM/MGyit9q3KY2OGSicX2dbtuQdyYIxJXeq/nqisAaX7r68dMU+nvy+VIIGwEXNRClhA8vzFLCuKg2tQnULNjs1XUkV1fWfqeFYz7Dy21k7ROKqr8GYDb9zWAM58f5pz9oNJs80wwe6+zMJ+dzkviTnqX4ax9diibvCsZTsgjoalculuF7as1GkUdkc20uc+C1tNQ2MgmVtvW84D0yGQfPXo1hKvWOMNpPicjy6oPJKWwcdp/Mpy/0DKn8cW4/uZz415E32jlN0nDZbVRTGjcabaEbLbdw76vR3fCbKr+21KNDsmlpx7kuWWriq00nqE6zcWehQ2HJMZlY8A7nuF27spV64r9riOUAEd6ebHgijz/w03z1oicNJu3rfVMKo++s+5HPM6Mn1W+PKW4EJSSoPqV1/fxgkVbKDgvr2FOmwpO+detFEUtyOQJO3QIsPahL5vp6LtkLKNJTBUGLWUIfkmAqATCgxHEWfThN13D4jTRT0jJhcbzmgJTSss3Q9B/av0Qq3hfoB7f9gQE4mNMXxBdQSkaVSOQN8MsVTpGzgJcsIBHdP4rlQqLKQxdPt2paX8YYjjxeUXs3qOLloCDy8BuHqrbF8MKs/GLCTgobHSTMz2GGmCOKMu/ahcVXsvS39l4r9Q4+TcCXuC0q8PcU7svdKifE4JpdTeT9p7ea3COfhbwlzynq8xljzYmd+v7mZdVLJyiZ3ReTnQWu/npLbNvvr3vf9KdxwL/yIke9p7xnVQwBV/VrNcru0yDiUbYN8DDd/qR4BU8/HdoFKut+2lXeSnzznv+EfW7NMEtN/ts1FNPJ1FgeOWNNuMeb9IlLnvahbc/cAw5fRXu9YyzZRns2DuJ+yr61HagbHwXw/HCNfYxrOo1oK0ecY1EqB1hsOBQJ7BowLMm8BWUeEdutyPvApbfCpmeZ84QPoryKi1y64WVCTd6slTP5Mdy8yuu0K6PJUlzqil0vV+mbS1Ks9JeiV9VFCtz0sGzZvyEqmn+vlYq5uchd4xY+rD4OzOwfZkuo5MDg2RgvkTctVastqacLTR3PpxGE+kiJrVvcynVM1763SIs0HptiN2XRZuhwkJgGEqxAHetkJULHCtcse78mx/7C1gnqcJwc+netcjjb+8lOatxDWlt+8/gxbV7l5Mck0tibXrGsDg261K2c2l85C4/qjm9SMRV3wdqQ0boUawdXneN2JU0OyZNm5ZP6I1yiDGVhe4a57wH9y2W8g3T1vB/v5Wn1ZboTfKjPm++NnfnTwNYrOxJ+a6FcuC6RhXVdWmzg4EHeJ5D+qUtJC+w5SokYGEsVtCQEmUZgu7rNgGL8PAnzfWQywJOds6ckiYx4FnnE93mbnoREFf6gYJNMwyWHaTfPh0htf/SE/mysudjOEpOZ6a8N+dlleXy/quJRwEMMtItYM0qxITEWzmbFOk9XmtaqEWc1B+N7ur14AE8CLezKGMP5TnoMqSikcOuOFp0mlYfEo19XASCYahZHDf5xdGfjf/b4HIyJoXzLRNPz4xb3okWJFmWQoSuw0TyRhhBRePCO7eCmA+gATvk7n92II33SKvJgEu0tAXuCWJ7y/632UGwZnx9l6UePNUTzCqbR3UpUvdgCCbUhof2U8Ez//w0Pm+kUP2emgx/7U0YJB+UAk+yK5hcX3XruYKhYgwicGhXIM5mt9d+uEbtWJrV1GMoZ6YWmGTnqkxEudr+u73iWluUuNQoRNjYBkhN9NbSVrtTBTQLr5uiMUCBaiOYN5smk9wfYP/X6CtZjWnzx4sYHKsmtbM2RGFaXmyqLED1GeEJhKc1tzEJkTdpQDOlwan8hMOpzBKJlFfpNZE+JynyFzFFM0obPpwA/FZeUjQZyva/ML6eWwo0Ju18pygdYRrq33RfJvSJjYfGu4+Pqy/2JFGJWJWBovD3DLBqJCC9c+BVK8R5C5JaTVITuJ6tZAet3LOl2jw0vd9WY/oR51PXiJW/G5nSW04v03EP/3Vc0fu+A1zzuVCvc9sFvh3UwfGkowkmN8scrmUBn9XrwAB/Nhnhw9p3uLgw+719xXdADu5dWIpqULYwT+L8Tu74z5M/CrZ/hSciruCuUHbk67ZlDHkm8DF1DOXzim1/c2Nr5OFQ3lqSSWY/J3O7VbJSFHVworvntaiQtjJXGhngX5YDj8dT95kEfLNDYZ6nJJHA9FmN+Rjmo+SmPtV12eD270Fy4lJ+TwobJoy2aHunDchVcaeDEVozjSprgulVLfTQXB4YTr2PUl34ZYPDvBMq9nvXyncF2FhVniNHEglSxDH9DmD0XBlxRseDXiCg2ONkKyy7kie/GIPKlrhP17dXEeq28VXZVcuiFTJB4Zqfq7YvD/hPg18yEdXGphMm7z3ITSaTeQGZM2YJRGWyp8agvPf8HKgVkG1qJ7DYtLIFkHi+JqnuqQfsfP+zx94SojD/ORn0V/mKhUllAXho/cF0aAp7DssQ6r3pfffWjUrC+TSKGnlWA1mxXy8WiZoJpPpuU82kdpnXZKGDsExd3kgb+8pSrMF6PTY5cXq5ioAflFsqGzNhwwyyDcBclwpU33eDg2kokXxyvWvGwFKs1etRTK0ErLnaAPbHK1UPU6SOkvd3avq55SLUUQbWpg86tJHI+grtYoghNhY7zT06mxszfm7CmcVeazpalc+d6pAzLhNlhstRDQZ7mhE3dcH3BrWRyPXgCZZo4Lp+D1IbSyXRv5r2iEdL2djpSgwc7j8JZ4re9E665WfUnuqbCf8bIpCvr3h074IfM6tdrvl++IxfRkp7hPeTEXwF7se/KywD4bOWt7Re0+bQlmVEt5vf6fbg3532ea/zyXFd9u/rpvPrwc+T2Zt4KtKRj+wPHSfTUsNalv65NQVHyXk79egmDalNIySE04bmTZ4zCQPJM3ZLUnrt/85i4Py/e8X9ixPb9Rl1ePKQWyHy0UciNSQCGrT55xxMj09zgAMPN/uejYX007lA9WjfTBihyNzkxALdKRrg1q9RXhUHZsIXtGv4wnJjCzti9tLtbyzdlnvJcSvMHiCTFjIU2aXeyEmHq7cS8+UzKXe3b3t93sqNyq3eUHeGUT7ErZqScy1Q8Cah7m40i4YoOtkztw1gcB7c2Oh4D78PoWioHP3nChMyQLVRAZOanZbjv+Vfkta4wrzBC+L6VLHx6Dz5ESTcsqyd1LU+mWM1ZEIwromouTe83yaV5Z5UBXlpdfq6UiiTiRBba+qh8yf3XmG6lAhmR/imFZ3SfkZbA/PNwVMWXwxBoqkyJqk7idRr4LqRrSEU9haVHY4dao3A9mhXVXsFGay/lwhMumexz/voZ60+xKYRMe2pe6Rucg0Ahl1OHPXg6sQYqUth2r5GhN7sGGMLwd614YSBEK2eQdx09atc9yP6GmO3uENLCp+HDCxjxMfPFVEgJJQzSe4MMRwLEL3QE2hK/jvv6oZinMLVZZ7Sz66NJ4i94YkgTUcxgwixLPbUSQutXXTICnqvWgIADkZ8nsGSMn7vaF07k6C3aZLH6Hy4GuV3+Y+PMTBko54YWjkugjvdeDxxXAT4kQhPH7FUFkB0oR0Uc0l7s4DdTRDm/Dc+xP2w3hkgXY2pZLMWDuQvkSRMl2lRleZFh/+N13J3DXheKoLB0M8eVfy5AwsJ35vVagLGeqQwyAI8Nv7vokAUwZGPREeEIpXibFVDdOvTNJoESEIvEyOpYIQhoQSJf5IYwKEl9zNIpMPusK4+S2sUr9lQHKIqGK6iCi5GdS/wY0tSMis2ykqE0W3GY6aKZk9lI7evW7hyB5XUluOqn6GU8PEeQmGhR4Bm2xcmvVNgP5vsPN5g4lgNcsuLKwku7MacYjR1y/lmkqUg2I5j/Q+eVRWLlE25osmqKrV138iM2kPqwXGUlq48qoysNdM96YzzA0Z6LML4zPrH3UmCkRcxG9WtPjXwiw4NXMn5/eqX5ttr2IhibygFkn+sZSpI9ihyVbnqxn0Z4kEfaA7Jw57jKFRDGaf2nd3WRvQpKpfBI3Q9H8bm3BZbVHBXYWkbaEyPKpqGEV+sMRZvlFJUF6SbWEq4W0HJJyIhRBviuIT68/l9raK5N0198goxORuN3arND0eNK221ppvEKfKrhPt/i/0uQAUWKEzOipbmIPw2BP/Zu0MsszmyVdnKjQHQRJUFMpaVaqs+vSGNsEpMw9tZTo2Ms0loySe56+376ad2nXzIY/Pn/en2wyKhCpRNUGao6RNfJheSgTq91HGHjK2Qgd9/EStLmeXEVa6sXaLHIeXJ2S4kdBNkAzJjVRz2a07ZqXO9Zj0OiXf+vgagZrV8L79cz3yFICYU//qVbCB3nY5BvYQ/CuCFGNKvBa+HUcY3OMYxy0pFzizuOJ3TQEgMnlRx2WlQCZK5Qheef02pZenbqOOgMGrPePBknrq9aI6YJPfj3wy/cGw3gWiZwzF/qy3b6o4bgq1a3eil9qLJVFzGRtdahaTVNSa9L5iexiXuM+6uyJdeiF0zZE7TaxZBMyqo/2Up7qxwv68XXfPzpz/eXP6j53o2x63omL4DZjhd+WwJBpNluZ0fsDgleoRM2nLg2Max5kWUHSI/fGQoKXaCzsveqMHSk6a6NO09ItOFyHyVLpJun4BUZgW3ROzWhw926AnSu+Gb3mOvs6+t3osbjtwgHi360OaChKroy7HzjFOSlq7AB2INo99byjV96xOSuvo0prPb7IVCm5MRMvlcbwGW9R9u9MqSMKySEfIaBAAlAjGj51j6p1RTvItCLi7ipem10BqYJXvlao189DxvdS/zKQg23fJORHU5p3TJZFquAlGHL3FlVGKeqHRpjzwZiYt5dBPREvYC9gRA1wLSYzIfrDmt894LBN8oHOA761md1bNKSbh480PZusO+/5Yu0lE+6X5mTG/Wsdoo7jMbc989jxRUruq84L0QQz5qq979b1+XRvNOooZ3DiX5pF+4NTSfLwDfuHde+g7Zrc2qleotM8pqGw4A1A3Wyy6CIdQD11skHPI1OM9fSQZibWuq6dr4qAk8W2EE58BNXA41BzW5X8MIwpRH0mQYL7PMZNIqLfl4E5L2sdNHMr/1oMaqgTE2zoPJFjtaB1SVIRcV4ZK/gMFCijQWJZg+oXllAmrrrOXvY2cFnJTJ7+kiFrihS0U9NkrK4zIHkDrGZZCbAS++E17ZRY8Ux4r4unM/QrpGcmXkjkkLmpBlCHhARC6ziJVc9+OCbUMsoGEssaUGdnWCbAQeC3vMXwk9dgz8Lq8SfBgEKu38QFbzh6x/c3YfJaWiiICoyvU2D7O1lVWmmKzyLM7/VAnXYHkqnves5wQJRfdm0YUwF0W/0zAlTAx29Xva9ZZjNUhVvMa7pegIr71XxQILdqwVm6znpcZ7LoAfSYuUdY4kQSrSTHbVyiE0zxeFigfDd21bgWT3aeKq+NBmPYb26VWe4xFKVgsNbs1kcmuTd6WgF0Pc3ARqFs1IMk0sRJDrIEiCbncmCrhu8T2DzdHiDMXDxa134UjD+8CXVvGj2Q9N5QmjwWIVAYqP8u5dET/zKQ68zZe9faOzJfnvQDIJfQrMhgD8fbrs9rK3OtuNT2oWapSO/EQV+f/DFV0ONhCFXmO6kZkrRsuEuDja9kR+x65JhPPUhQdO9qLm0Y6XMLkpfr1KUTgEsF0q+toj5mcmx57iu+XWyicw8J39NO3iKBZkW9OPGLVztcLzYYvjzXkMHGrpQ3zMlh+V2XdJde8TJkJIz6lV5cLSSfDOilG+lNBYZe+X2qeyWc6xsqKiuyf47uf5A7X/xspC966ZuAxBa8fp8awj/BBZOITBQUQ0mCVw2+Ywof93bYtP3BDiGKmG/RDfrilxVc4/8Vpi2CnlB4kNdOhTBAraH2UDm/RN+sJmTcXBVJges7IGYGt0vwzsVT+BEaZ3jAExYVP/zwA6X/xCwGSdyCoXuNDXtk9CHdrCzvlPgOddc+p2NXfH9VAfSRWqsF2pLQZmtvlQFnH/emba1Jz2QNa8+xteEm9dLmECwFwBFD5S4LHHHeSYzlbXlLKp0D3we4dg1VuILFpSrSloYl9LwqxPWlHbd0fuHzcyZIFZpHLT8if5SAJhS+sRiK2PFHQDvcpoLkscJ4V6pGdAZpFHFrWR9LyjyEVOxitq7tqWFn4iZqqwm30RY7g7aUltRE7zsPTxXCJ6JpxY393yvfjTbiic0W4MOF1bQt+aVuomxyH5cNicKiWfqtculMxn0Nv/ZKYNnERrmyqxyt5nqCq9JWCEusZFHmrOdHBIEaNfMjhJCgVWN+qlxLd7w8E9obG+DD3J54hDVZ+p3q+TUI2HH9FVYAf/HliWpCq786g5VPQXCRHLadaC32X02DGjUEt3dctp7y+c13kNgkwgfO74h2hFffmZx6PL2RtJi3JFBdTdzgXhnMYc3tBo2xnYvjlLXPsbMSNhWAu/ptExFNKGxcxHoheWV9Z9fjTXugQAsPProZTtUEuQU+Kd1TT0/p0+QmNFWC/8dMxvaegF2HrVZsuXfCDTXZzF/SRyqBBL/fWvETJyiDm3+R4hcrDSHKOT+I+33n4SnA5BfpnIFCIJthgHx+VCFzLG8v8zj5pe8cs+3xpPZSfpHDCYqvPypKvDs7Du9K8/0MnhOTn6wRP2WV/GI6CiZKMjqu2gE94MeXFFebKfnTPwNPkHqOFdfar2xWkau9ZUUpnpenBG4/9JhBjRhOIqSrW5681gG8IpsxICqLxJZ6E+Jp8CZSk2e6+Nc2cXqlvTNFe69VjDyw5mK9RETfk1SHXpWXn78PFzT5jqR3Pmy5u7Qdm9T/SxlLbwkcn/mRaoTG/WUkCpVmdXBcMs2mC9SERjbuKfKWaSaJWi0rqPWa/gYA32n9wEKstWETRk4W4vQ4PJaRzC4ADrWn9fRtdrGsktH1L9rIK1lQvBlWfQfKNq3oW5H7DCmMLNGP+HD3eNjnRtwWxV2t14r8inaqKhFbIcfdJcVwjtY4qK8cYT6Lzl2KrIVSiEf9X1ZHTlTVKltZVYnq7aFCIBLnRyzYm4DfeWYqmKL9MQltdC3UuxakOkbbfQpx2RuR1cLMJrCcOj9NMD5QghWyA7ybSYDn5P4lJPJXGd9JbD9uix9Mkma/sKPD+n+ev8NESH534lzDn68j6WATq0o2/Suowo9XV7a9YOYhS7ABRHkLCGosWofpEXBiusWJx5+Lt3ievRdQVj9mzavd1L/ktSAC+fI9UN9uarO/eEtsenInMbDYuhWwnju+4p5JiL3YW6MwR4JMr7Gsww9eBzBADRJXx2TFXUpPATjH+wZipdL6hxya3MrZ9toRX3ZQkqeivEzuHjqlqIL0u6wv74Ho4ejRnCgnuxu/47YhV99wybuKDphV90egsCvNt7RgVS26ZiYtW26IBxdvgZA8CKoSHu8kovRmnc42nlB8MYUS+AtEHTJER7SeWsNy++Q3qz/0KQjkjNfQBi69e97DvLFC6HChXxOXjVjQlILZo1NyZnDvIdwT7zMFRBNBUcFKDj1bx7tpkG5cK3iB9eBRr7AVcd+lQeDrNET5C74ut7JmVX4jbo8fjsJFYy8omyJklRLGvCHq/ACBfoClW56gBAve50psor3ZsHJmNAsosleyanKXtmbLCFGHDDZjRPjrhBXYdVTk9pVZhcQ5uiEtaZxz13V13tgxXB1sPOsFiSt3TzdaXCDtmRm2nEkAGBNEvHXTwodYYWHe2kommxvvwtIkxoWsUnyYkX3E+xF8BJIKSxBCSDwdRMfrX9ZzGbB+Zfj2+4r1gDT4HU05DJTWLipMSSDWDlIMH9MCcGaUh0OFr1qdpfJPBwciHJP028jH72jgnRC+2BNKYbYrmxU+g0hvw2JQmCbuyjVz4dvfSVihFLbeEmW2u3cHp6HQ9MISpPSek58dOz/eG6s5x3MOwCccgp1nntLJR2UQbEgaI1Cr41mRkn1TPtkf5Byeq0rCCG7a4qSaeOKWKq5jddxmoW4FNZzw5IzbJt/I0xK7CRllmq1AF6mXbf30zsgPO9yomM6FE5mXudQfu2ZUcs1zSby7KZ+i1yXCzFNsXsilJTb52ubnOGOFKbIgh55/s7a4ybQs2WehjWxCalCPkqEdSI9ZyX2F9BtcdhBHdFHsM0JaIheUIRBzvPKNShI0p5dTdr9//2hD9k+BUwUpVdUftauW9dl6hc/oOykX75PlniHvwpG/Ln3GiP5Tl3rTkLpEjRTyGFSEknTaohVpm/9HVmBGoYgU1tSuAtAq0bqfomiQ8BgFZmTvNEkTinLaVFWlUnvGaX0ZDg30zvWAET/LylTDSuurkv0S0pPiyJZF/JwW8qxM4evzR3dvg7pKYJoJGaEJPT3VYBamwobaBi56w6qA4abGpqjg8nxx+43LLawXERUdifYkl1aCNQPMOWSGJf1UIFYy62skkaRhBcRMeuVZWb7Z8WZx9ULqvBOLkFPdMH2xLbzCWgs0uYXQiXGYbSAF77rOttPo0i9sBVSSnaQaBq8n4rX057Zw7GuCtdzy2TE3fY6D6GXvznvPfQKmm+fbKWMyYpm7RWK4Y3hpWeVzWwGNsq4gkHrbULobwPnul8xGwcs+kB5Ub9B8ELobmzTSpXzG2657ddu2sLX13BVby0dd0Hu7uVKg3zhQVEFyzO525jwSVrOwqJV/uNiQQ1DECkB2Sk0CSMM4MIp5bYZOUtFMIi6lOP74ahaiaOJR/FU3l1CwcxeWjuVVZyFbWW1T2zSFtfXzwdeAtPGA9HODgb3LdjFLqrcr/69gIm9j2MfUDwUWsfIYrxuVzGrzrg1efe7SLN67F6dn6iDIqOQzCGYtGC26WpLSpfbjYvJmKykq0y5PU/T1VnpgSLUSRc8/cmN49Pxr9v8LqrUNqgK7HNZAOdAKr9668AhdOddigg5R6gJLHfmSwQqthoycjMiJOOw0DvTO50E6dOjOcKKM9i6eDzqmQxejvcTz4TumQ6Hr5oZGM7x3Zq+rHCY6sXEUk4i0HDMadIhL/VpWMXRyTVB3ag26Jo5VR2YnKa9lw5Q3ve9rplYQ+ZSHAyajGymn/mboiaXNvKa1a9CtubbCCtFErY7DLfCpdaY6MOUz1Wy6OacKu+pIwy+roxN2bf7hJmttvoVZbsVUF3SxW/eapiSznBZdhkFlutlHO5Jy48JiOgwt/6o+w6YHDG4EKnXYI4ej6OBB/DpT7K5xyew6a0XkJqeGDJ+Jxa/nt5Wm5mrl1ZHgQeVg9QCrkW9q4f/t6YErPeLCrWnUwnBPkmLJ1MxYf9O8nksrF3bKlX1VWv1mlJeLfGpzuhlpH1xGNZEV807rH5BNNzI7Gj5KdsHeNmjAz+qfPnZX4mgRS9Ct4zNywJXhh+toTYQdW/qBKfxBlDcdDsQo+90OWjLBJCImuntbtYZWR67V8fjieXs51UcMT+hkgEmj4W5a4nn+Tfck3EMLlKqxd6Z638F+Exq5NZK1YAkqc3VtPCqn95wteu5tFDDIEt6bL8ZBF0w8TXurdvaebZa1k1t4ONxMU9qM3+ZsIQ7nkkMnJ55uKpvyuEUMXXZa3mONjWeFo+Bu9X0i0K17bVLsLlUdyECU+rWp8bWFe5t4UR6ZSQDbcynDe12gHizOAdUn6IGfPVgRg6lFLPJmeJiiuHsoTtYeyVoSsHyA+xXM+J3JiqDE7aBy07nKILvP1GJGBvdZkc3KtJ2EQYWksloof2Z2g7aSzC3OxkGE6vmuArPnhiWr4SIajgZkENSzZa1Js0sRGw14GAHu0TOBDdYlaDHMsidlNAWNQIT7LqC5C+pGTq2Wk/A8+HZ2T0m84W7ZOHVkct3jEQZ2Eo4qndGGgItCFmvo71Ctw16j3Rtm9/UDFhj5JM5aughF/UPu3sCmUVpQOtfXwS6nDuHhPSb1XPqSw9PMtTX3P9fggFzDlHfdKDvXYUltvPoXT+DkyIiZRFgcqqOjrjcihHROB+B8Kkp5pcOO2u4qX6FKB76wGQiiOQdn2m78HaR5N7ZY5Rb6VGhxWLtmFnp217sZcQSOPGLY5SJ8DDlpAtaOSk+o/Lw755NHDUn+SKZXaysf+K2l1bVLOrvxjlB5hK+24l+DUS1aFPnJt/3rnyZa+c7vWyIsWbnB/drSnGjGwqCu9+WhMJhjgZWllAFtmGoqopfWOWUU04T8cyoGY/p/Rn8rBv4RQRsSOCcKb7SQZhF8FIV7i4DXBvybAFoGQG7AfYjCXHvIUwKcO0B1APEYgPspBQzWghACyI0YuKYAGRJwfySCeSnAZkjAn4qBL1uDukTgRQwkRF5Fl0EDgACgAAFACHUVmVohbrA5EvnXwUrkWdolGtC7tHS/tmH93P9pX7vTbX/73p44pd6v22H6uPd2rh053fUv1/bdTn/7lbS33X7D7ozFwNV1x/QS9uPoQrpl34yLNt2njw9uCxxIhXdYaZVXeCCDN/hIZtzgGcvCPwxk5QuTWcEPfM/KmOFHqsIfvLFQTjAVigghoSZC6NgWSQiFoLIgTDTIijDnykSJga5IQ1R2KtfEgQ65JR7pTHbYGTdF9jjoVZ5xmVvkgOu5NTnjRjaFrjhjq/QfVzFAnySJwWhK0/FQ6IGm8Kb0i2ZiD13QzOF5k7AxntQ/Uylz/IFq4Kn4K1Xhj/m/VMYPJpJHHot3bJSL4kcWE/9Rf0rZc457Jsu8Kf9JaZK5+ncWmZNy/qEM/C38xLIiuPVEPuMD3pNN/DWu29t0GMeW20s99GH33C77wy7sXtplOOzG3Wu7tEM3dtpu9bDibmprO4C7qt3NDzfjbtMu9XAXlq9tx6Hj3dR2w2HDy7HtusN12PXtLh26cXdtu+PBcZfbvhwO3OX2lkPh7pPnitEW4Ff2ZtUdWf1bqdb8Gv03xWO69eZdi7d0Cv2kdEgn7RPnfZoMNLiFQePz94BN+qyaKXJsNwCAtjd515rQ4kgc8rpGQZ3cWcidyqal9TGzsCcyK0xV7OvMw1r1vqhccoUrTkFKLdagD+FymDpiTNbzfh5cBBwkxzlf9FN0dvNkCadonL0Xl4bNJSMAv8xDCYZtX25Liw/X9hAobskQlwAinSaNcewGumBcP82ZtE9+yAbuwOQUVFEkjIxbdvUtl03M8CuiqoMqh7eFOmCFm24d1MvIQFvrbTn98Jh0lYOPiUQgHr+1v8/N5ivDv3YLyoTPN7VLad95PlX7hUFHiQnKWhEn2bnJGDo5oX91M5rSiPgfYLzzrroYZeGURoxJPeWNZCCc623R99Zf0cjEbpcjmbU9eDpXlkXlRCrKg7053pu4xYErQG1z8ZXJCGG3eLhx6of1flmOQa54H2cYwNqYKZLUxsLyw8xm6rQjSBmRdxgcsUiAHAqic08m8ZDN5kmT0GIk/SPTQMrla2Tcm6WZjgspx6aLPD2k0CHywiP2DujQT7nqv5ZLuPM8NBNrk4Kwd/RNn4wk6fML3D39FWL0r8d6joQFaUShPR7Lhs4L313tG4JPcyOikQfN8TKFTEkJ1LkkPis+SHCtoGR+s/aZbfvix0T992VDbnMcZRZYRr+XpOGNYTyjNj6w8FaYOUj3qcYl2eLvuYrXXkW8oZtM7jm1Pn7fgj7gieVz57Lgjz4J+LgOpmnI/jx3C5yavzsbK4N2EbKCOiQ2GoVWKFTPQ2jevCheRU8AS7AQL7Cn9eSzNsvmRf8fcphrSh2+6mKY7Z2bknZVw6ZMreeUcqEaoQIPRcpCOE7uWk+RiCn89JCqQsoQHJFoH8C42+/ClUBa3vBOeM5pAAfq+b4XnL/3IoYT4kmsrofPFBhoqX0NPbFE/UMh8E3J203hilpPSDp9Wjd3VUG6w0SEJ+c2EPEaA0nkRXAK+05MOkSRaiEx/DLiCciCzDgQWZcWc7o13su/AMGm1hvjwvgZU+bICxcbpdO/RAxzjLyOYOFI5BvGDdIedzvgbwbpmjMK0DmTq0K8p9xPPD8MdZi+/EuAr1iQFeZW82S2QeLSBzQbcm+jQi5aM2aYiPN2IBTlDcM2bD/iA5PIxJgw0hsaBICrCCXpl1JKMOdo34OwlMNO/wV0u8wkgkda0BR3go2+DKcvET0kGnXJIsHHRelkduNB8N160ilmDlaogSpN6HWfdAwC6T0JYjtnUwxrtoY8FROGPZ4VSO54DDzx0nkFiygxWdNFRhVBy8se1g3KyCI68zRt7tqxGRLhS3/fv/VNCRKdQfsY6hUkg0e2NfpOdu46lmcgTX/dLEhlAgTSN589a1ML6yrU6RnxjKuasv8S1vZllDwTkUBDqM0kMf3UJIs3jBx9wqyvKE53GIZhWNZwj7SZFMQ4TF/3DuGN3sGtXbROsjkac3kcRJtT9vQTJvI5JY2wQOKjZwh7XvvMXrPeCLjvEH4+qGJSSlc4VE3g7DSgzYeWO8+p6DYsq0tv/en06/hMm2JcPJ4Np0TClGRzuBwBl/NQd93cxjKO60mW3gOkKdApvYofd3PpPGGsjHDGnsbdtTmJq/Xe8paw5Rmy7UVV7OpBou8tt20f4ZLAacnleiND6BWMWWSfjteq6EzrmINzCj33m0K4DcQ9gY+FUakI3Yf02TEDfZT6NvI8lHeZI1dWBmhhKgcgv1okN7NlcBzdhtGygM+2aZxCLcCz2q12b9Hxnw0dG9FH58eV8Y5IitOndEkW/ZxJlnAUOJJfEJ7grOk5B+x+UHHhW4PWj7Ynqog7iKh3DZUx3sLj5dhNzg6YSC0ARDQUEfJqw2PR7MjGEGV8iGgIuQSmMXcrfhm695ZUbR24wwJG1E+uUjyjCTgUuLoV6DRiFUtsh150wKzgm2GChN7Oyh2M4im6Ukp1Zj1EHAwSiH5fBRZbAU3iZGP9zQbMn20HhGGvSMcmnbN5tTrXFbXdjsEz5k0MbUcLUgDALZpWOlL51H1CzWDOTs65EKy1ikDtr++RlumJ6oNOIW5UNJDY5I9fFfjcKFCrsgP7SPNRP2EuG389lTvNbXDrAp5m1eLhHW892zpxugrsgdoZ1yUIdEnClJXbrvT7YJonj/XMLi+M+890Jksc4pMbEuPEdeLSBTI++aQnpHg63NgqTn6zm+bEYn7B6zJZLerQc5qLS+V/UBijGMKrzyl9dSkzDCA0XHKFxtsQnJNshA6FFjy0uUibcExboDBqNUfSUYTU+Xoz1bUpUHEid2Wkz0Z+m0jG4/5tr1KY5i3BKKZ3q4RVAoJOWcNSIZjA/hJy6khB6EiRCrca2iohldvyzLSo1HpppIjbU43eEPHbdlSB6PKT774lPB3tWmo3U33MFNZq16Oyz0G5gWz6qqS1+F/+gnxa4uPZsZPBAtJAKVGSaplAZAwch41fsvfrsQEkiAADrCas2c19lnke7bOoiOrkl+COUM885WGxu2C/wds6mKUJWuIjBb+FLvDYKx4msp4MT/36HRvfTj+pyMzvdjG20SY9bICHy+uDX5pMeoyIJv63pY0FY1TfqzN6aFzRTvFLit8t0BnsV2Sq8nK9oINkjETaJhBvKJcG5TUE7aETrF9WDSL028o+Q5+NzsBx52i/8lBlVbQGHdUjST5kzqwtG43pv05tkglZZ4feKKMAoENytL1Zn4K0wrQxS1Nn4ht8AFdN9gHYWM/8U8IHlShBOD+jc0RtN6uPAOVC+yr7VqFJ6eLy/plfrTWoB+xmnVZVg2EYhjGRnWMiMmgZM72HhEO1RmWBm/ue7KsVSGjd1Gbz1hUewrZowYaXKOYr+Q6ddwDcRQ1wO+GY5ZybhXbhRy80Y5KFWsVZqCoJpdZcuJG3R9ffK08qKHtOYJfsCM7cWaH6wAPxHhWIZDpcyfVlwxHFhRI94wFbl+j3JvIrlxABVvL9PtYVCu9XXbXIGeP+hSIHB+QwT7JHhXj4dwwYhY7YsjInmp75AnnoBWayITEVDwx/6YfT4CGm2zdOiBV8y8GjY1gxYFVr7CW/Wo0+aPIFuFPQY144pZuqpBFrY1C8Dqq4pUb3ZXcnntJiQbO7HpJl4XzQ7NfVXaE94lz64n4UJCvseo5k6Ypc/J98uyjaZxzQYvdhESluIS6FKIfjEEOVEfDJVK/fROFjqE0Omlplj6lSo3LmXlsYlwCLXapI5rqlT3Zl/ETvBcuByQEm/Gj3cI+AF80a1TOiBrZsc+Vk4E56Kswjc9q+sw0KeU1MP16nuL8jd7gao0sXeMWkovnoUhEJIfQCrA6gEy9TUQMlu/KpQ5UFwjvh1VhLJPHTLnuF7KaYVKYHnHELYWgGVzAOmC3gxna4ffICpeyVkw+k/g2lgjPgALjlMObKDA2XnwFEi9z61cNalwrMELhHebEqNUNho4mw/EzH6W0JNzFVeY8dm9TCN3Mjuso94IwxJjcrCS3quiEP4MXOaSwAszj+f8roGcB/db6sQOTjfbCEZSIfqsEtPJo3/UhqtyErv9OB3u8sdO36bCdIuMiq/blYm35p3QVV/BhBYmXHGNRZJgPak0Dsk3tVEi8zvACEfmVsE2wo/VN7RKBYMXZvKXCcGVq+W6frLezr2gQhEElEGEPCAr3OxurqP7MqA0DIHy5KQp9Ju4Y6GUarUDfpP+GlOyaRbIX3PCFpjz06NGQ1t4N421pMcSus3I/dZYBaHoiPYB7VX6i7PXuHkZGM2gjHmgP+L3wIjYMoKSRYmdk02vxwUOypXaqoWaFcQf9iR3aNl8+T3JROm/k/BGcOnBk3wC3B9xw4nNNwJdWsyZ95Yhq3M0jV+iM6LXwTvhntXr5V0N5johpbA5s1EQdWaMjJKBGeEJ2CjTTSk4ZqKzP4SO2dsvawt8IcxMqS2+6Br2yUPachTW+GQejyoH+XTknVjb8fPGmu7JEX3kXOjw2Nz28lawqSgJDYSuZjPWw33sG2Kb46bvkbd9ru8Vq1k6qs6/JeZd1f7cgJaHw35JAU+V0/cDeIFAnjaHB8AF0LZwU0/nf+rd1umFpwJL433SkoD8bfQTp1Ynb0TP0TDeXN2zz66+VJs+Oh6Q2jSD+kpoPJs/bYiGDv1L94qRjyCepOgmoAprjLtYp28DTVDmAYhmGkcL71Tv+YaSM3ABbGKk4W6qNMDEVFelfzAYNx9NZ0X+wGSqkjv7kjim0fOb50MPJONMlu5S9psAlbto9UOpZCnjC0eBFVygFmGdsFXXnku96zWk4hQynmYMop0ixk4BzdIBE8lDwJB5xPJmm/evnfletRq9s5XjqMUa6aBth/rYmozpL0cLae3WGU6sv81tGftybVuqPESWzxerRLSW6nCML4vYwGwP5/ajHQShIsf8PQMgkaNCEDdrlhGVvqZqcMrx6hZjM4NfPhqsLu9PZiwOfsWK6zkoHdngEl2IJUCtbIou+LIBlXoKm/ZZaCmr7mp3EczetBx6GBVtl4OBHVM7GJpj9XpjRKG12ScfvRJOCJXtstQa5JQkgMQ4Jmron3IpatNL20GbJNKvSVeH5OuWlkfOU7UK+QUykpq+MfVH+R7i2+WWYl0Vj0PqXT9nZVHoTAFwik/JL+OgsHaL4cbHGYCHkv4Fk1S+HZJzvMi63pcrZSEYO6G5IRwmfAYrKQzJkWS7E/wCXDztdCt3F152XBxkCJ1pT33aay/EBxHejy5nKnIp5ghyu4+rig/ilK7PaN4WnsOQ+rUkzwr7GRG38x2xeRBnlixe7mWTdhdNhHvo0FHgwFbJysHimwTt/b/DbfHHMURZi9Arg5pyY8ka3EKN3puumo7Yl5gzzH+YxPeQxzFgiEOpSqH8w5gUyaUWrpfSZ8XWkWwipMryK97gVCyYnhfGj65Y34jecSYMYn7Jefnm0b+Fz7DV4tkbkLSQKZ4NaCIfYGEt5BE1F6LRJSQqt0bJ9YNB0/Vv9qF9M3XHkMhgVLbUwHcZNFIqLRAdcGIB5XuqRuocedIM0guKnPGu5PscOBC0FH2uubmTOBCa7OUWKkjlLy3yF6ue4UOkp+tgXKpGNVfJt2ZLqdOnkcCTQnAtpDQ+XYKPNnpQS/2VpdSarIEsKKwv7LwRm2Dti5VRLcAVPQbGzkcHrPxmHitQE30i7WWy2opaMyN3bxJCGJAd9r1IhbUWQv3CwnS6UKwlQHvyAEGUtFEdfBAZJXk0V2YQpIMJque33v6e81UeQQxaLYQc6Cr71HKbfNA1ExB55SLQps7NLXCQ+5nEKvrhJmsezlIsJ839L7bN2gSROCeLIfo2kSiyxXucFeG4nFm46qUZ14PKQ8QWqmV0h8MtgOQn8PYDVRDHNiMIq9nM93Sy3nPyKPXcjppIyc7V0qeZBb/2/iafHocv1e6+Pnao81mbv8WaobiQG3qcDTIVSZA1n49Arcz1rfdGoP9UV67EAn6vDy2R9tjmWZKcT37q2gwVxe7DrcU7FPldfD8QIoG0GAaRqi0pWJ6fnl3eJlL0jycaKcREQbhmEYFmV/LiIbvNXVQQ3yRc4LJy2kHeqBsV/9qErVqOfPSzHmubg0v4GroRppPe2YCfG5Ezjij8bJ5mRxDpRToTxfdsh2pqFuCy+CTrgHuZLOPagjtMStG4L10G1oS3xf85jEdMiIngEjHa+WdfyqJ2DC106e9EDqxbYjy+QYFLYQrPmEdxVEMkwEeVim8i36h8dNUWdNV4YaRASzQDujAz6znnB2LcnNi/zK7LjtkTHO9sCguI7luIAb1N5ogY68PDW010UbhvjM0m100EncpD1tDw53W1AogBjTCbuzwNYYDZtHSuyYXuLN6Tuce1U5+q6VMsmEx30iodzIdNJ9GEEFyWHK82XX3lP0/4ZdZitySeycDDldoL4BbyT0PjDQRv4yxWbsARqbaekvwZrYFswahOQkiNZTccjVekrgh82Q06XZ90giTUx9PqEmhfGnRxorKGeykGyaHnrqwH7FcjzMWRL2eg2UC7isnbqSlkpOqETfONODyYDQ/wdo10gD5EvChjhD2VrIhylNjzQsVUOJkLXLLoQJ330l1Idvn3SQec1690Lq70yxVkImDoMER9Bj42dedNya1YhpyKyN0WFjgVIfld12TBdYXuU5TgqRDlY99m5zC09PR/0neZhhsgIcbEWKKTlMLlGOOt50Nz1+KNZ3x2zyio75m7mm7yTzjlfUDTzIhD8vxSh12ZuuPXT/qYTm6mbNYxJjuxAXu0+ewxslh2wZ+Mx6nMjGbcEZ0uZwQwVxSGM0bK6dqjpqxh4gT+Y025Mb+cukQXpuEk3GsmykcP7e8TBn8cqefAZPJy+X2dcAxkHmNUtpWPvtCUcRwLovEBfJCnAQ7unPOII3e1lKya/7PnkO10ZB1YPyc6ayjAqRAjydvPR0BfHoxtTo7eba0zAbU6MHQagmvk4skN+kug598eovWspyHP8Q6e/1d2reZcxprQmmvjOPxyjxiOSgglcaJbrCQFQ7ULz6Czh2SNe4C4pWtTiQRXjOGlzJ2Pl/4UBSQo9o4pLTc3h2/khAZSqjrZ8Z9RyereCcbCUeDLFRRU6xQGTEwh9zWmsuDatalzy86+L1JLdU3evXe4RzzKzN/z6fO0c/SYJoPaBKv3OmvjzApkYW82pyLPrrDFfH8z7kupX0Ui6xRomuHOSR9UmbhPxageXgz05TEgSYdmWRuIO0EeUGLS7M3lVmYUIXbrGKGViO9T9bOauT7CRsjnrRKPs+fpX0dUHRqiFlmqECX3NIgiL8/Wz5dwU0Qc5HKB8HDRp6DBI5QlPtr12HpgWk5RucBy1ERkB8Iv5lIQ2wX4Xf0CNRBR5ISuiH23p7W2bswWkTtY+6o999zfMK0UZNXEsfxJEYxDAMw0ju8Tq+giSLsuuhzafSL7IyjNJCGEVbJfmQFL1Un3ZN7z77qQIXuHSXDSHFdZwBZZwZDvgpwesJIU/sb3uH4gWhnSovi9Ey7ycVzQ4dccFwgJ5VEshqB8NPxLdqxkl4m04Pt0o6cwLntZrHleFzWcY1feTsHzg+WP/hYs1rJKiaZsxv3K+kKik6sZaI/BKWNBG27eCUXTWfn725OsYhcm2DK83AeWfUs5QzT2f+8GYE1mjjfnNPoeqsUlgSWrt9/r8jJB8X/ISyDGVI8Fzf/tOE8j57psqqCA9M1ZhsVd/2YbUKQqjZXFX+95F/+1PoDGZXq9yDLEoonKDdQlHyaLPV2NWDz/kfIiOwU7yrE3aNZPL7yaydvTQkhSGFgi1GYCr5DfnMFq0Y5fdpbsXGw3BBHBtBewGTppeA6H/7Rsah1y/SE3YKBMKrFPFrXcF467zUtwrV3wKnDgPH5ReTHsfSieMtzIR6XEUBIMxvYgljY4uekap8+yA91DvpOYLRNddWmuFjrXNeaEF8OzCpdC1vp82A8q/U02r7TycaNGAZANk0f82vxutJoh7Tr3qct0OiERL3Cbjd3KVxueg/jdN9Mnh6UXLuCbGsspnB6d+26nkK8ldRXBUSC5C/POO3DYAbyt107Azxb3wEQzfYggDr5rLk9d6ii21aOVr+xU/C0U3aoHF5THwud31YW5MwbLu3e/ATmtoUDD0NYVDw4jnAyiqPzuYyzyJyyTZqgcBQZelVIq0OB7/5k7eK+zrJkmXiRkaQiRJqm+XmnIoebMvNg9QjNUsfUtgNU6D5lDCs1jS/6HcE3g2qBge0XNJGEL0WCIUAGK+4jyBpHu5NRRF5UfRZVxglvrfefmvHulcV6hdCdwAilOeAKGkdPqF93tZUIC+iv4SSaVJI0DG7/uPutT6b87SqOGsp0KiXL+57T6MriMO3bVgHuIDxxWzRRU25moG5VnM+sbd8C+rsMxx8IIu1UGlD8rG/iBZQ+2C562Y0D1dDxpuTWR5zrQNnaUGXPPHgbJzF38CYZ+544nl7ZirPVS4yr7T6yXaO0cdBklnSIxtuAL5cY/FLOPt4y9WjtzJwiAMHayTHxTZ+L8p409Mi8fNbVW5tFKFWGDDsTfm/mMGI2k0Fjgd0q2IBFQf2CoTzHetp2P3sH+gJogm/F12wUl9A57Ibn0IiDlDAK6eFduZC5+Zz6C8cRQh9vijPIG9OM4TS4tpIsI9bieMKQHlaNAzuOd3Jsqmgrt8Wv0MK0j42WiXnPJGfEdyWRPNZ7YBcpjU/BQ65ChQmM1zRrIt1y+FDQkYgjFbnO2ZkLNbjDpGjoZpgqVhyhnEZ8S6uVlPKAv685as2/DEMwzCMzGV9wbemezFDBkROoW7mI32UvrMKTB1NaItLYAmwhPG0QPYPErdlYoQV2HLujqdMnwywoc0dpHHwK9vuMy0oU+LGKMuEisot3sjekOvPdUgX3E5vt6OUkmZ5DAt9/H2sjU425R5eNgmyaQxuQEWtE4A904t7Z1VtSeAChPlB3lgJdcD/rQ4LRnBgh9uQf9k8IbXMQT386tmO6qR+Wolwg84yfJvlzpu9k2yO8IYkIBhqHB6/7mfA8guOXKrSPQ5ctPZEMZO/DKgdnH+e2fnB0y1cSSpWliqdMFBvsFW6s2jkXsnfTCVCstjN+uqxUNDc95eHcdFFl+l/1e0xl/UF+0ZWjRYvnu6zXhj4iA5fUJSjaGuoE1OxCsPpKdbLXFQyrZ8DTfNOE9UG3SnxxJSaizkt4VeN13zzLX03fNwc8eQxLrQIhQEnfgErv2ZkJOXSipLiuRihwmW1gZmRdtdP2+duACbetacTvz6hOK5nHaSSR2NI0jHkOmPSZHpmrSvCEC5uqQcvbXmrCFcBWCXga8nMrPvihg4DkqBZKL3qlrhP/uxQyPh3KjSdzzoF9LkCY0eyyXlVsjnYZcih/2YiGxqPOodoRNljhsLN6AMGZwT9tl0wS4mW6t4zGUaXghPi0QQ0naYXIkWaJx8hug++W2j1wccTLUYjcOoI2pruxWB2+Z6A9v4A2RdEnOL998dWDSDRPV5S8eHyYrIVNXl9f+EcY5OaVoUX3NCSvOiAPhAVokDAtZvLvmXhR4OuhVH270JHDYzIV4FwIG31kgncTyLykgMpKTh3W+xz/DPWqFCHxtcgJ9he/rpj4dLMyI52rGuEjfZpTNADCB7liMURn1x9GSP3p/LnAWD/XBV1RKGkVXc0CiulXpjQPcLIYSBmtqYXGJB0h8Ae8jUl262M7y4G9kNA+mSnMkgjKsY35rzqF6hUnmkzKUsCqz5jGf2GFdr2zUKY1j2svIzFae5Er/VUVH/GSL7HoAu1bLhMCf1JejoUyEDvbjH7cA4ZEDkqe4Jy5WmQcg3AsEhpNtnrF6eit4+evlGr3zRGVN9Gjc0zcf74gwl4VgLz1E56eKvqiLKulRv5qU237dYJopo+Z3gwJfcN2sYLaHfIpDUqenSQjiLYIHiJprydBTf4OnSKmvA7wMg7PzXln0GJ5/+iJwZZ2rdvkVhLHhYNsmIn12wcR4zEOrEGsa8Sie/XidUatQrDdebE4+0aKqlZ/TZuNxnAca9yGJZbhNqQ1ADcLCz+ylOESYHZ1t1rVZ6F+0O6I0lVhThaBZyYbna5biLP9/OkQB7BYCc7p6dhRMKfulxtqMRk69NNhg/p894moZ06VGCKBUYjmd1rA5EOwzAMh91SOW7NrHSvBu8AYSdrGZs0+RhduMf0V8J/h7+5s5AopPjYet8VO8CPJDPBHt1k/2VnX2ImjRpw2wti6khhVRNa8AD34syvy+12+6LozXxSLa8EDBdNy/8FfqtrdNYocwpuE9qEhwJYkUAqlv1AMy+zZTs7Lynao1RDrS68dtttZ33ioVlIBR9LwUAc7VjSFJMiZbTh/GA9Oo5YObZL7aL+3pwGYPvwihpfAxKX47SjmpSAyZBlYwKiBGY2swOaqIw4KESd8miXVoUQDXoRiLxgoAx0yKwofIIGk5gp6PgL1J/fVY9tL6rfCi76TrvvwDkeSwm2Zw9bqxB2lYtiQytEA0a8UA56pi517Ytybmfbr20MAPw8dcseZtOKRFOY+iSzFUprExYFT4j+v/tclZjFBIPJt64/JF9C7f49kL3M1psZZcsaxYrkXeML7j3ZMQWBQEZOqVOPeWa+h3V5pXk2IaNp3kNSScErXd+U1yP14wyj1A7NnTsgWE3zx0Q/Fj+t7DR4FNhxkx9ZCORKbMDbjWy4uLmZwEQVAAGuek0KyXUpYzC95Uxy5cPM8uPpt+pzYIWAE2NIpojD4W4TXAFhtgwUApKxJd419IeLEPrBfxtouBXaNENfXaJmhN7JRmQCVMQqKgPoD6DiUEu41DCFIeYkdwMT1B/rvEaGE2LB8QiFuBzIxAMCewpHCqlEf9ZCifrx+oqjnNUvykKmZDCIMn4k+hCOFBHxArFilNq81J9fidkegnL625TnMINljggqOStCw6oTIOwPy6fM+d+tmDuuzX7PTTPSQ1nZvupRrLJ3pAc6MpMN4rTWVRS3r631Ix6nQDQCQU/Vqpm9g0iXJZBYZQcleI3u+f40kxXMG0z45cIIeQrpT3srfW1Uy5PAma2cOZ6k6zY3jnxnGX0/MotoCJT3Scgxoy/HqjVtsaXVAFphictTdVuqioMU0LbrxGKxg+su3Fh2dvJOKZzICyvIur3fpFZQUx4S/qtcVDIONb370+q6YvXLqUk2uUUlY48KztRHPuZUPa6jo+ukRmWKxnSU+z6dGXiAs0Q4p7gkT0oxeLcE78r+Xx7khKD+eT6uWmp62AWOzBTqUzpffQUqb1m4CoS5Kbr5yfW3kuZlB28DqXhhFqtYdjPEaR7bie9VYwrWYmiv7dE0SQvzeOsamf5vK2ASsw3PNGtJJK+OMFUu0/8a+wWPQwlPYLvkgka9ToUL11Ilgv8hc7yhhXXsM7eED1sfCT5rM9BwTjhVAbLwid63r5sv6SRuBYDMMqdLYKWrvn8Fg8oq8nPk/TRmhunFR354WOXQCuDZ5xV56IqAOR9Elbse37O5NMQbOJHuYzuaWFQZ5SO0AryXHdAcW+BuReHaOrA8RgACbLWDXEWQX05FZg8j+HevaPKCRtG+ltInYkPClMSUxJSWdbzxCR8XXD7hnG36lDBY/pRYGZ29Z83Xvhzes2xv1aEKaPifoni0z9xG0eFtt4MvG3DZ+zms4HlYQz6S7Xj7KXG8c4ePiZ9py79t7t1J/nHVs4dDl/5keVVwejaBiuRA3koQ9MbUNpwdGr+4NMl02hxbLifI5wQ5Q1myeoEUBbmJHDyG/zb/x7QAgu+AIMAjCt4ALTNwulLOzzLntOFNOOMcjNPALz0kksFIhsIvSLgHc5OtdQw+xfEWlkllsqScWy1yPDurxxNtcmnK577sbGKh33AcL7NI/noiuTudVfzyVzMebeqT2g+Xe/ViHdc+xPFKy+TM8zLPrTVy/HdVj5dok0cPfO7E3iZko24I43mLZG9GclutVXy6acY/WI+M2kY1emujRred1HgVlWXy038edCJWq86hL9VdhdUmO3d87odXm1h53zAbL7dI3v0huQcrq7jwTzP+72KkSBan7gpGjiYpMeNUrtCRqW4V3wrL3LedZrz5MsVSv2lKloI8Xsbl/rmok3+erOOe0+budeJ493z89tcy+e3NZjxahlzjsGFIGj9y/JFa5P54UY/3SOLGhza5sbQar+Rz5741yblnm9gwm6rcRrOhGu+Xiw9Xi+TD1nq8hOSefIrJkwerOLWwzJ3aa8abfAHEpLsH/9lQH4Vq+WTAG0qcbUiyVmlC1A+nR45sUU29Psg18KtWzveXHjP5cnx56htJ9NBdJFTHJ8ZtKHF0R5LeWhPBdp0moyjGzMNiSbZUx+DmVe/95eV9hQPffs1Rujs5lvHsmG93eb7rRxdjuLN+vvPX4+G29vP8d3PvvUx9292nJ23U5oqj5W3az9mz0Md/iTP0wd/A9GJTbnb+X/s2FLzY6Lh0uWDeaFqjFdpfvYP3t2ihx6zOOirRIvaz5qPq21nFd1GJLFrPChwpNRwoHVn8Sy1IHX4X8sci5Mgi/Z6obbE/hZsc8Jeuf8CE4sVtI3X6beT3umOZxYlKgPLc6T51JED1gACk/p3LQC4hrkOPDyowhiMwVRZy50qAAIwiCQ1/N9TeG/h/QMwQiNAjhocRLuQwwxs1konGFqpIxhWTWZsYYqY3+cVnjmYM6wwlAABQBMK/P4ayRQI/NpVTAJJHjAbjuylkB6keHprgSv0CC3E286ywcFbISf035x4TUJU9PmAKoQ+CJsGCoL04QmaL1IRCgyixAGCWMymY40yRa6cXClldZcHBLTLiChtowhk7yEiUB/DwFwkMoy6FZbbHZXNkYtGbZBY4Z+7e37AX7VpuW1tSV3r3l8BDf1Azj2kycx9u1BHiwYaJxRWkBr2gHaO506tW/n9/xOxYo54hUuCsptysd6Qp0OdoM+7kM2AcUZeQe9AT6gQJHTYTFoFEhd6ZuvppZ8rXhDFD3UCGotZhhXqDOIXzETVB0gojoP1iFSsYA2qB3BYW3q9QXyHeYOtNaYmG1AZ9QvsPYYdTRj1Anopy9KjHjkhy0OcXyU3akSahf6F94k5swnhGVUfuFN2heiGhsI3CIobEI/Q7tAMep9k1jF+o2448qLkPE/WpEHeE8wyVgqQ/YCzQLljFOoxr1KGIwLwPP+q9EN/Dlk1h8YbUPfRXtH8QXuFUo+4K8mjKsUW9FETmOA+iTW9IM0c/QTvnXj4njP+oq4LcG/oa9a2Q8BO2ygULSAzoW1NX3Qz5rDA+UdeKDMnsuEC9KuICzs8oK5JmDEGbd7pYj3GLulDkNpn3oaG+KPEjbJemsIQVqUfob2hTIfwOpxZ1r8hTMjsG1JMi0pl15y43C0OaDv0H2lexic0wDqimyN0RPaKGkvCI7a+wOEicoT+gvRb79OMaxgXqoyIPR9WHE/VZiZvh/Av1QZH0HcYK7cR1sYxxhZp3Dpcz8z48qKMRn2GrTWFxhtQZ+gvaWyFcw8mjVkMee7Njg3o2RCY4X5u0UUOaCfpvtB+dIZ8Z4x/q0pD7Hn2JOhkJ/8B2ZmIRReIA/cas8CDfGeMcdWPIMDevwwb1Zogb4PwfNRmSjjAatN+dLjZiPKEWQ27nDry/oL4a8RW2R0lLbUhdoX+gfRTCbzhtUA+GPM29cgT1iAgOjG9JS2NIA7qhmVrFjhgFVchdQAfVIQG2vbBIQWKB3qON6kG+J4w56hZ5COZ12KI+QVyB8ycqkNRhJLRqZRVTjA51wJNbXzPvw4t6h3iFrTWFxStSK/QR7a8SVjgF1B3kcVSOCfUCEYPzrUmbbkcag16hna3cy+eAMaGuIPcjekZ9g4Qdtp8mFnYkJvRLU1d/mClfGeMLdV2QoTOvwxL1WhCXcD6gdCQVhkP7ubKKDRh3qIuC3HYOvHvUl0L8EbadKS1hR+oj9L9o70r4A04L1H1BnjqzY0Q9FUR6Vjqm3GRDmh76Gdq3uhOrMF5RrSB3M/QaNQoJr7C9C4tTJM7RH9H+qMfp7BrGCepjQR5m5j5cqM+FuDnOF6gPBUk/YazRTo1FPjC2qDATlJ1LlQEqDC+0Z0JCR6bGcWXs57HeqFxLjeNbt3ZvEiX0g46J6vpHdcVjPdAxUbNxb9Y7M2tFx8R2/nrxct/HemGGPJPHH1P3x/rNxsBMnTNTy2PdfDJmGBtDf5ihho2KqcaTqtNjfTBVG6ay+X+2TxER5UY0SLCuRqaILVvRjlo7EemiKg+ikUDnkRKhqZPLQEMnSVJ0yp1oSLFcE4EIqGgi2jWxzbFW7kVDH7RrkDFC+51cBxrtpBeiQC8afKT7hCziikvRGXXpyEsfTQlRCMoCCdug1FFBp9zz6PJHEdhCtQjbsMWOCQg4nZaAVsoEMEH7AGAbtgHt64BOex7gIeHr3J4uiLthXJrLewj17z2yRA0dHo3+zjyo/Jceq33ToJYJzY8xmd/U19HQG3Q1sCZri/W++mOv1pY99/9HH81wA5sB1XqDXjq8dVIkNF9T0r845bgMN/EliGqoX/bRikyjdyfrHi7MO8qN8IDmYR9HHl/de/3POwaWm/S9Mt3Z6rs8Ol3rt2FXE+rZ3vzIOPLwP3Ejq/zPqd45j77fuG0b1pYmyzB0i6ts7jC5eny3pV2o/jiPmfGwceO/8nChLbcm3nVvSUu22/55eeCy7vnZbtBVdpPmovAHekt/zoNMp1f/6zl7fxyKpI/BeM6fRZR7w4bR19tjrqg42v96yx+DkNf1a1rhZM7wsKmLKKxeodVquEDNosW0JcoY3ddC1BwxrGTwGPEG/KZT0oiZDP7XntNyQE0zpL0UqC0BNzm0Y5mYuafZYfjiRvZt3GMronqPwXGVycjzhcAjToRTVi/qBEdo8cyCJxZW5Eprvs2zhdf7nrhc6dK9zaGLGCzaMq/h3TU2pdr0gMGM7BhBvu5Bx/E1kikXMdg/hqkmw4WvEAMoiNHzzCsjtjC8Rw6PMCKk8rFrom01qj2YGrIiJEMzOfSkYRv2cIJ2N7DJJd716y5N1GqGpzv9sHRHS0NrngMr6zuc77PokA8Mwfqg3ZGTs43LWh2tvPB1Dz6KEYigJLNkZKW+4WF0sMig7188nxeuKkviaArOE+2rkYCFSZOVlBNJ1dcSBU2a8jCC+CHIaGDRETFM8jCQNDuaw/5OstbNtQ0deuikd5Y8yAqolgMPOOJhuvzgfl1Ax97mWK1ZmMQKhDz6hBimGpuYwrdOIJlNBlw4hmeqO24cN4PmGYXotoY5QVS2g2bxe1tlrngx3SX3uHW08f9RMki+a9XjOsUFXVClgqtclHgQtdTSQ+UBIGIhEkd61n3t7nQ/2G3pnmbX19RHepiJoz3FYE6tpgO2E7RTwjGVa61FO/QJwy3m2np4IsRkUi0RuIcF9nNihE+6yE3jtQU4nXaob2tphYV3A6i+SWv5fFvd8nKyVhe+GVaHdohLnbWWVe+0LDd+J8zwERkU2+v/WupkLTnMrsNf7SUzxhOiO0F4ciDV9vT/IKMqsXmuA8tbbHAUmEwMK/owKdoHyXwiJVnl1nBEsqJXpuCL8UiIy9mI1DPVkjU6JAWFKklMHnwJItSnHScamKEDy1FSj4MLMqYbUS4Y1HaeWRsXe6gy4BVTazA7u2nlLS1qsEjUeDm+EQXyO3YN25Qhwewk76r7Vj2J7LbSFWY+m58gTsntQlkLizz4LpXNlTaIQozm7AFT2RoeveBJ5JaO5sI4AOF5ezRo5KNW55FeKcMeNGPuLeo5e8cKUaktTk0MLQrNtHL7wqJlaKM50GQycq2ABGo5D1ygTfgWLOqJ90FDeB0YeFkOjiRdDTnxsCFModk1mVHnC2AeJlvim0ZJjkWeGl2DPA0tlCr3tvR7fTcK9a3qTAdTn9sbzcbwKDkD+miovhYbS1AxSbiu6Vds3H9Ehx7nvrGflUcbl2Z/d/MYjlbqnmeFFd+GwcoVRpL6hv9yqjkXWhp79tSa5qYcvPMgtadh0n0MN6O5YjO8TJuv+13AaDwZ9wqrwObyMBlL15BkhRZxArTKM4z09Z73srlTsiN568mQCMbtl81ReIRgIbIe21T8aupkG3nfgGwtyhnZfnrB1pLw1lSbBPVNkc3qVGJlAQP2fxbMXtUoe9XKuq5aoOUUGrUgSFmDmXYbAaY7twBD717FAc8K+2bSere3vgIfYTGFXV0vvFVmlkbbMV8M+oIPQ+INz/DH61vO5Qf9F1En+aJjRiVJk7xYssma4W8F/+VaQciHKj7t1cfPb6mvfwyTgEW/Px/vX2/mdZC1zjEyP5dtufzRd9uuelfw/JOe1lNQnuvvP9aHtRd2+b58/1f3+mT/hu/9Y1Z+Rlj/k2N8bh8m1/B6FLE6nZvRa49QbNv3Zq8Qe67Gr59F0k3o8du8Xl5/f1fRT01VzUUs7+lmfX7P/xHmh3+XPKoXcvHPT16qNyafAFZSCM98awOpt4AHG7Z3tLeQN222yXp0LjyJBRCbg9b2UtRwQCJqK0DvZpNmTbfRbAsJvUD+9tCTEy64xzIfnctqIhvB6zEWomDGdpUKIOGNmUcBFsgshLhVCImpHKQEaKZrDlJ1SKIODSDcRJIHAGruKnNLQBeY5d5yHYWy/PtATPR76EfcGdTSNEw35KHOVILjTABKYGhJZp/7MuDXpDUJntQbX8RpXZ61oKW6/szBJTHdUTtjHQ+I6Vo6IS73GUWvIaHVkPY7/jc00kd57/XcmGVAx5HuCB0JzRfVXEpgsJtTVS9GAxoQk5HL8dVm5Vg1t3tD7q6dV3Hrwi06AUW6M0DN6f7oezJ9aVzV9SmFZaTzR5YOR3VO4Twt1eMtu2H6RFDAcKpI856fWA9IpazU0wCf1MDbaA1C2d0RA2BHncCCzNWBg/N9+6IKjE5NJ/kCPEi/J33C5bz1ZRYn+56xOI3jN1CKt4A//UBBMKvHipzcBI9b7bSU0q2lxO8HnUAOaay7Q8REii6dIACfQiuMSejdKYcBXKfo5m7dZfc10prr834u/ER6yL1W4zfMNx7ThnfSz4+sg9mkIaE6umcsSKiT5kWk736/4AgGsyyd7HDZzbt+ctA7gCBP706XR6sv/Nr562eFnGk3B+xYEtfyBAT+SGWPD4j2kNuLCn39Ah17Dg29fjgD6ygPdQ+93sqdfOJMkRGOtcFLsxzL42ICW7F9mftVgj714W5sUKsA21ik8xY9PcG/zKexyZjjReFQguWQnERgygzngmjpEYyfsVEr3Yc5tigE8h0eeRSeBHJwDaD3m2GW7UvPG+Qu4reOv1tCgK4f0hJUaaUQ3ENh4EsrhdwfALyX/WVQ+Eu1gBvqwo+IebhaguExS6a8Wc7eIyghEfIC9JOddfj0gpxRL9YAfitcdlKsjSvS8TwMwqBOIVOSdy45Sjj5GF/YvJnvk/68U9oDHD08n2AmCbjtefS3Mqh1SnEl4XNalIVlb7SNPLkCFbW88/Im/V/cIAHwqRQ5EMOS/FF8+datBdnPG2O3JEalxo2hdVPPybim0M/LEPw7v/8LOxNcPT8hGoDeEuJ7ud+RV7NLYYG1SRhBEhbHH/9tT1cZ1HFyB/Rzu5jboLtXVs+kxoqoXTcclyf+u27ToMcrF4HX5BN7RipZbeHk3R6Z5nz90W3ttuiExrLUJwC4oOImrMs96CZbMmUcDekaPjy85Tp5/BHt8dCergkW54imBDlb+cbARDFi3Nx+2iptc6tTFr20I056/BnCemVeTT9e/wRgocd7h9mO/XvTmxlK5g3Lxuf1IW4EXmw99rvS4OcRTKV1ebL4OiIaseivpWLfSEaHRF0UuXunHk9vWCpPo36Zc0eNrn47i5px6l6cQtNBSXfztPWTwm+RdjvajEsQyzY6dM/NytwkMyaUReFcMoKyRR1ir3Yn+LQtzQ8h6Kgo7M5nedhw+T4vCv6xH3eYq0o7wzf+t/5z1pA5FwkeXNgaIC8AdEtpJU3HG82V7qAnna/bWJBEw6BVeh93R7zFHMTK8t9Fqi5ba3soZi5v/R4ZRyRksKA5JAebb6DHjS/DgY+jhjX1bZ0d392FD8pqq6E88xlk5wayWGBuFfqRu6uaV3H73CgPUjejTT3/WRh6lTBqgyZou50nDX8sijYoXG5Qp3F1nWle3Zq/wkrbGh4jjBYVTJx/BFbSx1pJl+IqurQnxRtxlKLtklZcyEArlp8vhnOC4ji4nibVpzU5pgBOVfBG6AqJljRD3U5rVu2+Wk6cSyiHNYkSOQ+miPHS9Z/+UN1ghovCLDA6eSdsZqlM3T3Lp1E0l3xmsTglggM3refPe9e19ML+OjTV1YBJG80KdJ5sq5OdQtMWR5kc83WktHnO2b6PqJfu/Cz67joYaLaS07XjxB4dtx8CcWG8Ca5o8B8Fa+qLwKP/b1LWcXDa3zr3GKN9CilhGgUXB/UUONij8ImR8q355rxM8cR8B72fTPJrBf7OpyIUNn5VBFPNuUlD0uXYe5H9EsE2I2tI8D0eNlw/cjBDN3dhI4LaN9C10MaRgdaGvLCExLLHRgClJY+PVlJbAX0HzfsYIJ7BhKDHkYPI6H7EZtJ3PtXBxRMfQsRRnlQ6BJ6B/xaxc1SF2lKYBzOG9KTjgHRJYTsISwwUl3VXyKK2oqCWH6Uk7QdXYLodsbDPLiVAD/xkZnFfmyesnSsxRi0n0RAugaHOqAJQ53npPUvnt4JrOtq76fNpTvmfelIMvMihCEm9QhX3s1fjuXq1rI/FyFFULQtcD9X1BeF2kp9LDZrAVFx1vHG43Gfr0852YFJKIeml7Xe4lWPJjuihV2CnqjhOSgQr/4wfw26XSmTRGU+ZHWNkeaDHRE3X66T5T1P/NqttJyn6ARDZHE/Oyv2MJ8XgDcXwHW7gbDfSPV1yFP5gwknE1yvaZhh5R1WlMFWFpiHQ/Tx1liT0j1HbMmFWiZdi03qVK7LbWxztGdYn5EGr51XI69lXZr49Dfd1Yczz5tkYqiFZ45hhRTxup2OXYwgLx5Y3ppVPfjqcE/Sbro8+bfncaWA2erp00zWs3Ps2nt0JU5TOBz8P1RNvznmBuGofOQb+jl+J9HpOHxdituOtSHi0WlrS42pXNTIOGjdbngknAdcz9+u4rDQen7us5wORfkCrqJvmikdNUdzRDOm1kBypIA3vqYn3oB6J3AJAqnQ03ld6EfCZnO+Rybg2YUQiV8wIwVo7e1UWvqDZol5ITmvNOb/JRvXHSWLWXardtKSW7+AI2V4RJ+AKntzLkZkfUe3KbRhlEgn2PtGVi5chTffHBNDpDLAPxuw62mL6u/2LhZininWBTE251vn9QVkhfwxPFEcbLDw675pFQ3k0qqq3wCjugMx5LFJtMXAOBA42Etegg6e3yPeAovR1XSVtU6CEUH43kgDPxb1HfZNKPIWbeBDBfWcPmvyrXIfX88H9B3HOr78VdmK7clPLD5R6jBuH1uewrrdqmwMEXCn3c8vSxzlwGS56s8HsFU5q7bwZMVsoabmYt2huwA0K/9wHKTt/I3wORxJwp9HIaa2fBPHHFWFoiLVoWZEOHNKWujTKbgADc/NgeNszkIT6N/HOpWLO99xe8hlfJy+UA9FqZoYWcDvD+g9r/T08aX6gVVMiJyt3P1O+f4noHB/V3LYHOiGJSjr8L+msIWRsTjH7+iOOwdgrbmigU5knTnEUNeBuBZ9liVuhbWc5cU8oBJrjJp+WB6gN5/stGGNLCB3Kj/yklyquGZK+ekT5GXBJPk8dhoePHQSmryKi2tzyNDZqoy417p3SHQce1xgqt5y1PiRJ9XwJZTuTD1coS15jOK7YpnPQmqmPxav30QPtdOz2etEjoJkDJvg7g+91qR4iaK363UEKU9W3Bc9cdTX2OmJzO1oX+qsP3fTslnwIbsDC4XaMPJABDihsfmXraQ8bL3aVisMn+T/4mmvsm1pt6QsRvX95ageYU9ekLZm8h1HmhtIccgoRNtD2vlnxmVwR8ghvhpzZdHT6Gz15P+Q114JeUc+d8/JW8N4TD9jdrx+dxyRX2wvdUn0z+Zo1BhaFBe2eDlllrS5UFpQQpYaG+1ensBYevspBOthyb9TOgZYjhWS0WbFs2xHg1dhX5f+Ie+njeYhoJpL4xumUiBMtXZwwnzkRhzVTTJovTYgJDYeE+YqDONHS+QQ2Gyhl2Zhin76dhLMQCOgzfWD5PYLcrlYIpppolKaEoVGmsm3omnadMTWOS6poINFaQWEkggvcAbP8LVtMYmsbu7GM7EZGegYgmVhu+YIPJSVbROQ7X3XwWhKJdgp7BahxZF4Nd5u4wXrD6f5Rk4p+rmz25AZD6k3QaR/QnvxsmZvtdnhE8Jq4rSmwyU2yobZXAsxNgu0wLfPWkAVkzFCWI6SWY4IFTpzqF5eog3wGYA4pVnEgCSYZUg7albalAvYSldPdmXLq75qV4yiDaSABJo7gvJ7EZjuFMqMfSgGIUPhdeSNMamU9JKiFZkF7AY7skNoXc5VGUiR3poF1PzQAEcbkE6d3I9/OsZXv0RUm4cp0E+vXxejnYLehSjwzOMowZctzyiZNoP/glhIokCFUi55MBXJpJ2FEpJBjZL/4v/94x9i7VVyyLk2EnrdFJC8nFdUVqCIP+nhyzp1pyb/lXg7ly3+nQB1LYVmjwnGBYDV/4v8SAI6Lw+O+PtMWlHGbL+k+Mfd/I4F4cXp4YiCpXLLKeAwfJaasu0Yv+H/iwDxW/CsAbshQGBiKCwcyROOrVDFFKMpEQ7i86dJ7RT2hDio6uZk9mnO+H0I5mRF5ICsEx4ytTB31ecevvwxtunocmwgu1JeHqo1tNWwzsXLjTiQoY5xTkDFMvOIAA2yuBfYgHYu9ciVXtEg5gVSGMOqJeXOxTHdzvG1DRuaD1C1PnN7Qigr04xzAI/YwLRChPbx6NXRqcmF/AiLFhaNnK4PLULAdDIeu5jzjqN1RSmExVSdEmTZVkEqyYLvQQJGLBLr1YOmwg7b6UICvavi0fj12EjIz/17X1X6TMaS7j4Xk7yd22752ggBk0xZ9gCG1mx8BrJ+guG8dWgoaqlKWt/kCWmmO0FyyMlvlxrOPKQg0a2vg2GzjzzXp2uSDJ/EkmWuxv2ebO12th7W5QWum+c3aHBgXZJv5coZMHmflbCweCq6/QY25pQKd2BpgRBdQyVPbJoO8Os5rMzRZCY/1D8jwF45LqSDploZHGvxhikWous/gN/iovwj1SerX9tHI4d0TUkqj/NgiZFDdquBZPwJQtotYS3Q8SQq7hwMgA3DiJEXN1uchAGNizFh3rvZRqDhs2FihdS2CLzzJLiUwKKihewYolegn0c3BevkF9xrChn3N1nNoF/WBwwC/PLq7UMjv+wXH2SW/d6dOEr45i/IfY+C91yTXV6bCtrSTwACIwvCKgpvyPV4tudwa+HQINSk+EXiaGjjNZBWCT7D7OdgWtFYyMpM1Fd7ZqA8l0TqpxWJ9n9aFWuWoVU+VdxKP37F6LPOS9unv5yzM7v8YbOg3kuLuIQwXBPrQsdco45GOM34EQT2WqsUPlhagD47r9/VkzSpqOVNXe8iMc6OmXeMteKi2aJd9Car6YUJhqFeIpfd7YmiDUJKyYFjKcfoeTe+1/j9EbWfVS5ZG7fzUi777txRRMxhTt2Mc8bD/qURFg8Ko4rsk8pTWe3ntnANhh6OyvMrPPBGlYzpTJN88CQVZk46BjrBewJ9ndA+mRJUuNb0jDsHNf5VBNzTNmOrZh/Ku33Xfh+DV+VGZlXq1kWJMJ/Xj7DAZ6C2Bez9sdEeErysfGb1s3rxDGaGUA4yhmeZtt4aUfHyEDvrRtKh43gXxNDtIm28G3uGwxn43AiNNve2HMXc0C82+N1rBsPkVqA3UV6K+Ghgvlwykw0L6r8TRRpxRZHGUFyfQcr3VfA9HMNOcvwxbyqFtiA9sZCl8OL3Wm732CsMjcqa6Ks/BOXg58YESIqMd7RLUHpAfUXBBaM23k7GRTazZnYSld73Ljf94G/Y5RosKtP7vRR4uk88wAoY8wXtp6uymxHIJAtGbFezN7Jh/sDmxNYCa9fWDyVCsgU2YHSdm5gDOLrVx6TRYJkgX5te8zPz3fUg+TYzjg1gp2myyC58m+j25pET8SBCeZpeN+0eax72jWqP+IlhpOHzdZM6hjNv9+IF9ZcPuI0QZG2uN4vBx8D0dFaowGIz8xyj22UUy2/mICtaoPhoDKJRFS3cx/vHo9fMuPDYTjaRI259Xv2lkdU5vHu6Psg7PUppnuqUbDJWNsXR7P6xJVbHx5CCGEmPvXvLaZRwPHZQwFmMLYE40wY4lq4mOBEPPghVYeCIj7EC3YmeGFU/HOR2qVEEwfrQ3kBK5iGCwkZwgQjyO9aVsvYZoZviZRoR5ICBJMMR9HklvBVsWOk5e1jCydYR2k+yhLicP7VBkrJ8lmQuJIVmkdjt5sJ8iNsV+9EWOoZLMbdFLRg95SzUxe7Y3bgQxjJlm78nhZ19h2WLCxFDqC1ArDFkXDWTNE6zjPq2+xsZj1MdeyghGTNYOdO4XLu8++CmJio/i+/elF+usq+u+eFxroR+b4hDoxv2SZpAyk5zDncYHcguaYwkJ0GRCpjdS0taVRu0Qky5PY1hM1SOPUqIhM8qAKO/dYuM9pixh1tLF3B9OoCleWrJKsZDBZ7xbJKSFIkKHaCyznatMXQXEiFCnJoWU1TmhkbatwYGC5yQsZAnxhd1qSJUB3IvEmHWrdRmiF5PWSgaNMWPaR5xYsLMU6/Q3bbNnF812VPDw0MPMMiRPtQ/hx3i4ZWUej7qqiqPptgZNrsoLpJRl3IwsgO/PMepJxtlohWMccaTstp4/4qeLQgKZxiaouMhApaMiy9Fe0Ol9RaqaYhQoIwaVxrrSsuipCgspO6czZkWMXA9CeuwtY9QYEruA5jrResl+DiGxmqlEItXluciiRafa2FWN+UC/jY56jx8XxLTji1ZS1ib2ADSW4dbT5/5JWNR6rekHUh9iBMOEcbJM4AHa4kC5gjrWTrG7dlMXdZ7pWElt2JdYjrisnms17XCNPT3XIJ96l21qpKtVtmn1Pz1dahppu26JTpYlDT7f23m+tPb3Ee2wA5dZmrDWr7vElD3ec8yhSUG+IFy64++qIKdR9gZatfuGk2Vw/XXrcURYqqigX6amVUJ3LcIsOANkpcSK2uXiqqv7Pk9FxSNp3eIdIInKhV8O2FlWO4WFp3rq6cn7yJAG99Ek732bECW0MGwvWRH4j5jtjE36wfbskdzU/KfQUsvvpRkB7ckrdlOA7/6KfLt88cPHm2fbgumHIYkiKR9t2hYEShQT3/tTlDvl0Dp69gQA6xzJ0pO8a77dRPa+DUGByLGW01GtxP6uouuA1kjwIbRAN2PwvZ8+IZa4Dn8MOlIJ/UpGfg2iqfcz3gFh2PsZEK/J43eL6cvC+kZWKQFxLAzwjt0G0bXY9t2kqRQc7gO1+wP5YnZtMnrHO2Hv9/NAj7lFJeXwAPZRk8CNf2jzrPwPDPi0sVTmQPTS1PPxM3a9FZNZX+yQAd9Z7EFvX1ct6X/XUz464ZsDB11aDOTQov0eUSoTKs5TJp1BpQ/jw+qOuca8PqaSoRhRL3Uw+NiL+mmdsTWzhw3wxV0ijHLQERhErdWpVQN1rU+B+UtlxF8JH6nUxvBH4/Zmr9iFdoowDYWlPWLj0+sfvWeRkUsB//XR0td7SXJEMmaEJoJblI1JUQ1580yU+D4zWdRjCsMjoRJHFjHjRPCMy1hwHPpgu85GUhlLWBnOhqHh1HDcScfCbY+aeIzchgl4mxu181U9M8SuDBbn/mHkKxqSCiAPC9SeMY2n2sGj/ptcw5RFTAn1PlUNZyfwAIG+d5Niy8ELqI1NfFh7PcMKEq9gEj3N7CvRKlGA6LNYQP4wX4UP74pbZ/kxCfFshBRFaIEU0ktgePI4vxM9xsQFdZa4pIbhAJ56ebAO5lMWDnJwMggwn6q2vEysBW5r5nrCzj3YOuP4pkqwg1vG3SzepHZR0SiwOYXBLCLZJmtvtfTOVZQy8KLX+ydJ6KOHB+w4wEbSHGhXPkyFWA//d86Z/zp6BKovd69I54zNt5tc7MIg+SmA2iAAg9qjyG29/jvIj23uECwYTG33A7X6rSn1rvOzmRz79Rebv8R1fn+AG+0xc3Oiv4EIRMv9Azw4HuTTMkXYYgeetdI0BU5qGPWvk++G6gMLQNSJAxGogYsob6i0cvxABx89UU65anYfUQ3fisrD7MfCUlTJQfbCKkq1CsL8rhaw8Vf09d4W85/heh8wKCF+VAB0pmmHL4LSHwUtQPsIHMnwCQL/zegjOq/P1bvCrDey1UK2bSNDX8/daWAbTmhcgfhiaRxSMtZUXoo2m63ouDESVXPjFdLU59ALxWiQ7fVx+BkV93Ul56Yr//KeKteUjs0xN6wy351lfpqPaVsgwOqmpVq+R8uv27ycjs4cDfswwBx4w0J8oM2hxkZDZUAytdsAK0//OXz+O4kr1GvDzCa84asXc7F8sqybLKgmzkxQ90ewerGQnMpdvJi4/5dHAaG8K/yxljOYow1gN2ZBsD6NwgzfxgeQzot5Gb2R0YDO4M1L0/m95rhUrUh3NqznoJFvI1J3bxujblf7UtLL6dF8as/vawao7n3NwhsWeuHrG8JDGq7sm+e/nGgSxF2GVtXqsPfzAxnW3QQ9WV5Qu32U2fMt9KPRXs2Oy1EqIIhpZlLjsDcR7PbCxmcPql8/SCj5SYN7VqHBWDKuxXlpz5nGyVXQsMibfp8lswPLchae2NXAk2EvA63kk4UeddS6qrUje2Sr0oEIGuoTK0uZaMhGhqcwBj051SiW2b2MT667yUxgd0oDOjdsf07i2i8nD95OEXg7lUAOApTWrJAzjxdBgLw29URzLmGS6BRd4QkxtlVsqWFqoBIY453Qth0uPhhuK39r0FISVMJZK70Xt7+8eR4YU2wCdYb8hsdGPEF8htqvrCSNPNwKMzBM7VveuVLhRyr3T7T4K9WSbwbkgBkYBvjGbA4o/GC7U3N4zWmwZZJcrFkk+gZacvFefsksb+3YSYo3/QOVF2ktUPih9v7rq69KVGMBWMCB0dDyoTupXoyMJJ0Reqw3UR/jGQeoAwxnpwI1JthcmhRkb8ByUFdRRqGl+E7KddtlLPMsyYfjvMDY2HPzkxv8B742XppdZdIfoZVEjRMwxYI7qpXQUCOfc7dNBk/1khwXMDoN+28J3lzLSY0VCLXwE5fqot8jfseFOVpoU7VTsrLY3d7kej/m04Q+hIE6I9JP29iX952RctPiee8Ee7KZWtSQgdShTBXYPi8DfSQwKsGMJd459RabvKKuqCs86iSTxbWgcGtQ9y8q+q4Ep68Gz3pqlVpG5hMDBREYxDsJIwWKyS3G46Oc0DDI2lQMtJO6BseEBFCEt9HaOwyPnEfTOu+k0YvHUj7d5QEzu71znN0jxxKx0ZqbzxYKKK5R2GgOmAd55cJrfrd07vSK8lyS1n0Dj+j/0s3uYhsOtlQ5Vypefwg2JRqiHRo64pALuXoGiR7ELsec3Sw8KXwLEyOJk6hImqInFq3rDGJBPbJBuK6AYr5ifAgJC6PMJj5abBiGgpfzxrQAEzrv2PnoFUPFdNgtjeYfLDk2rk94KuQjucgF3+p8ywAf5mat9OtesIuf/aTMo+GNSc5juGyRnhDMx8RJ7KfGpMhpH6aNpBMmr0U8Q9Tz2S7rqOqyl5m3raXlJGh5x4IsupSLDidszyN5MPElYjXpPS+061W2/JYZ/0NR0Tlwhx9u3llYiTn/BRaF+O9Lyf56oGFFqrW53x9qF6xvZYsasSW2JOw/tUTsj6R/Sb0ClqydiE3W1rYgtrJfFpFs6l5MYYbLNWRQRh7zH5MyhvuiDNpGiK0106GmgZt8NBKLHOeG9r1Wz+ZUwK1ayt3NlH3KOV5G6B2Qkv9lW6Jf5MGu7glvnrwjLHHpoF65xLn+dbmmp9ssK/DyArqcj2b81NegJVh2tUauKIjVNI4uRj9hWX3VKXBzKNjd4Ky3ZnRkZoAShPfW6WTIfJvE8TqwvD60/Dq3m/ISqaslGEaedwkHqJhb7Z5d1KQI2uobfU+OswedLEKfAfQW9V143YQzIC/WALONcW5FFPnR+zS8mAQQT+XkV0jRNI/4RC3mCxjQ6GFRBUltEOSE2yJMvUgaEfJW4BcJSHVEOMozgqph/4d82KBruJStnPaM5PDAPZ7cBxQS6gGz0/fCL3cFB38pOaJzaMKSxZ8pYhpWv9T1nVzP5HmEpam8zfuA4AEGovM0N69Lw2wxPN9kDtwYwQqPWtRdc22Eo5Ysm8wuXQp0bw8xQ+vQbu/wr7+HA+xh0+6nOJ6UfPABFILApwA8KxBUSaXhPDb/L5+Yim/6jbG2U4rKi7MKN4iWRVGQfx8oTjsYVY/fhIilHAnbUXrci2Ifl3SYhVcIkYWGboSCE0Y/NgwBYI7LTK4ihD2Pc8xduYPBmt9yT+jVo3R4fyKuUDvSsRj4r8g7eBs7LY3kvQ2pBfLUWAa9HSnUFQaQJ7lTeIHDgwwkxEbwrndFldqgIBxkI2exQJYPBOMKoSkjf7Yn68XWBboaMV/IhzvENnMDyUAs+aKh3/TqX8d+lBgEKiw4k1xoPEhsEQT3Jc0FnKLV0ZwPVKznnyhY+JCcNQLQtoO8p6kF5d0bx5pE5lOfPj3UlKQ8tA4OZtpkWgTrkG4RDdDr650gpg6zRA2/ZchR7E5bgGqSaJZyc4xcuQ57rK9i0+Duw+Y0NNElmWHolYjAL/vCTaYLeIgBLW8Ph3S2IQLY7Jzj1T+stqHsb5L5evx7ZkUloAuLmEOFUIZLcmQQ8Ge6saA+4MP7LJFdKzZ+4QHJ+0wYr65uhOUf1r0JgqzwE2nZjh47OR5DHSYMUA8s5hNoFMkiHHoPKSxJkplU8ZWasyz49xxN+YQtn87OQB0pvlKeCWE4WSZzlj7C8px2p+xejXRwfzQmUlyB6KUxIdmK6a//+c7xPIlZulGehCwFTN8J3pp1Da/MLvJ8V5jK1oA5xxsXw2FCgCZjT4ybJWdvaiZKtaReus62uTXMm9rDjSWWW7LwWsEsv133+ttGynVvT7JyWV6rTeZWAnKazz53U2qKAlMP2nahOlF6IirTCdMQJ4nhmj52UvfWnXkSFxUCwBUJ0f+kPoUh0OHWO/AysIpFYEhSdbXKQ89JSXsC+CGYPABRIOFOLzqbe44F7N39kAbunx4ajfZb5bCeq/kzH1EGUa023ggIPvWmSqy8SQULdjHGjcFIImHtBSmif8kIpYPMY5Br/pLXbWTSsLll5WvmG8BXi8D97Kk3FFL1ebThLJJM1qlihjK5pLH3KdPp1nj/2boVed00+rLobr5ZsyGJ///qazhCG0lk9LkotuhU9kLfwXRVLubw6HG0OXctsI8BPNiwLUSGQ3TrHY3czjGsKvp4YwSOw5/XMUJRPfVh8zJ6fsMM7f9yN5Zn2t5yxkxVdkFPPVZSDUjNVDmBOWyfz/oJfOL+jWMLbnut9HBfAc8mR4FZbeIV+9hJ4x//+Swd2dO5ZFdCdpJfa4q3IFwhlN0Au4qf6EsL1xhzIjmxZnnshMiXRK/jgJ5GhnCZZLd2WIEcIPTVxJNIeuzMgtwNlpPhHTB6pTCPeI3caGIxfl3K0GoiRZqOWQDE93PtmGyPqE3AVg0kjGsbXNkk/eyMo0NxuzmgjtLZpIlbKE0ByA2WczNpeA470q9WUIix9UmGq4Vk0855odKGlQA5FiRlvw/DKDcG5wweLD+glfbZK/Q5ex6+6nVhsjzEa88mg02fqUNsvwSuJEq4RQR0JxxFC9DnA24cnHaoEbpSL+vuChnjDZagyde56WOjBnC31M0+0gk0b6+EVXnnh2qKerXmgOIjyPsPlRjz2mltZ9WMnDQjZEjspoonr4MlR1Vq5JyK5+qeM8tGuPZh1BAIoPWTaefl0/3NIxzpxh01Ash1XNpovQVXMpMXaf60cWoxqJrK8vwfATzdawDD1unpYk3AX2XLkQR5sQEcljDBLg/syqLRprAYiVTfLro+0Vq4Qqj0/yoS8mDvy7XrJBKFpl3kQO3MwxEpo+VChYcvIYwIuSLcPOqvAc8PLmMc9wpH8H0IlWIf5xVa4mks3NyvnjnC6ZxESbhSCfPz9WqUUgWVHjWvu8QoP043m9AQ5HMq8DY/jyZd5X6MUq9/mJy/kHzqSdimckgE454hfzPJCKdomv54b9MOC3LZWfjkVRkLFm6n5SZGrSQ8wudekPrAvxjn2RDsi2Q4B9dGmI7aUst7A1j4sjf184Ymzlzfr6bTBEa29wy8ZVHm9hINyI9vEant+xWeq8OgKXU66sjfhlySmrYFnYhgvFeGHCZiccJjlOykRlhsyhQBOLLxKr8YijuLp4ZRVbJFru6xxjLuldDl2ZfpIENYfboLl+2ZaFpU2dsepho8InAErVjkhnESRsNUmGPgc9wHqFyb54KNvynnXIaepQrFClCh3sIEF7Bu92HGQDOD+fEIkTR8ARBfZk+LQ+MgD8P+LXT1Fd90xae6jR7R0I6XYgd9TUiN/BeOdrxB/Xn/G+sLgkKd1b8f8lyrCAmfAHrWA5A0gBUny3sDlOHzggosXGuHvV4cL3D060UaYhR7XngQbyBeiGFDuuP5Uv3hxyaPZtDQKRCqXn/xjB9WLYa0nfSoFDYdJ4TihaN62WVAMytMtM3gfWsR2EYnosev4dxn2Lf14P2b993Z7FTJZU2GwdsCbtZiKRFrOKHsqPi8+bg21o7NDxDNWHF/CR0yHbG1NMDwyYYnY1voWlsWGlYNN5eAHMygRgTsTHX8X4c30+W5PNcbPQA2nWnAZ99X4euIuEdZuU5EvUGiQTZyl0ZFJni5SFnxbdqtW0NNWr18yCcKHMqGIz88qFLqKHqfzG9HDfdsbbbSZ8XHZQhZ3d8LY3pWcUkdObNCtOZZZ9RAwwqyEAWDZ2ObD1vgpMorYeOyMChdK44K1b49hB93wi4UhmBEiegv4VR+nRhZszQ+BZ3qaNFPcyYjG5p6rmHIr5HGYsCBqWFRbqVDJEzy1WkBaPfCzE0gtMsO/MUeAhluEIQUhWkARFaSQM+ryNNZlS3YdfHYdlwEVSp+CIIKZFZkbrMvXYmQkZx/7sw2bVni9vRl0cQHxCazurFcgxW96Rjx5uMVw/s57xC0BBCiRBSQy/D4ri+Z6YF/IXhZQl8M9/uwuD2YWEJNOxJBrR48wJu/TB9IKJQ3a8qC0Op8Z4hURsjCVIqLJ+WR+9qfgcQ62NIZPzjYwggG1UG+BuCojs4bwo5ECeBxNLkWMXjIavO6cOXB0254y6WQBTcbaKbNut4gItDwhl7vIPPalsDM3d6KAt8gItRjsGo4ODBQeAmYJ1h4bTjuwd0cHmDx/loimgpzsQOsYmAlAGLf4s7pqPIrivfvo4xZkMVyqXV7c4t0mE0MOKbcvCUeKVzyTbbPOpMQ2yxc834siHjJjDDzHPBV+tWTRV7P4SL1yXft4Y4fH0Ye11DUFN40V82KDrDqYFX5UWuFzI3CNKsvU6+e13kUWG79NanZNOofVn4xKwcYnpyqzw5UeyRfrJKGwjyo5yaXPChZBKQN+sigxqZMcNoQtafz7MzosCjrR2Wcl/HgPRiA+hsqzxEfr8x8RPVhbzEZOCGECzr69otYEqxtl9cHUvr/53CQhuQLPTQx84nivSDPJ7uiZ14TNtCS9cBPaoBCxs2GiUP3sGYLzPGZcFAzdDE6RYHN9P6BlEuysAnZ3rkBwvGvv3mhArTxEuinN8xTQqkcdFfv3YlOzKK36UdJn07MwNvI0oEH3+abcrszzA/4PUFBByLCtBg3IoS/1s7N0N51sAQLeLC3z+1D+RF3g6WPX3ScPwBcR7H9Yi/TLJh79n4e8dM3g73+VATa2sof9YafTxcgxNC3DYfLUjjG/gD685QNrnH9QozE2hwfmyrN7/7zieeAh2Uee/OhFcfgK/XVRDIWwywAUF3PmAxXeQBkFqd/gY8d4iwth5/+tyC8QTP9fE/3uhV/adborIdMPZkFLtn3+/+SsN7S5NMLXb6JHun5+ZSjPlvR9YvfwPoyIYNmb19NC1TRt1DBZR6qrM2TtF0lldsBtzwNfVVgGaDewZec0zUVB7h3SdlH5tQRIIApNwmUteVmJny6jwZD0NkQ+O1+ePgRSRMz6V80TqWXCPhEkDNBxJc7wYbpM1iK5aPCXALSTCmfcXNyyvuG9Q0d42sdoOFvQm4oRr56rsRbOvLdWKyjLZZBWg9MMtCD4fjlIvYm8+4MHiqOKiVuV9eZGAPwKlSaX1OvbcSErYHqmYyIzZDuGAoLKBuf4//l9DK6OcQs1zYBN25om0PnJ4BFRNgMyZqMVPZRzQfALXLa7NUPlo1/UJBcW6llmjA5IXMBhyMajZpcrjpbnetbHi1RzZl6/4xOj4fjprjAVZgRzzbMXlvwnfqIBGWFU1CxDfWg24nSp5U9Jn26FGaPPSzO2hDEDcXQiV1geFzLEfnlTYIoxXuco1gX+7coMpJP4KxmFYy2naelMflAOs881BD1YF6FaL1AbD1lKDEmhLvEaTpwY9VkvR1b0Detf8/sFQGm8XUSvjbY5kBJQzXvtFzFW38Y7eId4fidgmWqw98szJ9YEq8vMbjmRqbWQkz2uUTH978T6fW93/iLwFOj5tIw+mRM8RFRZvNA1afS9BB0p4apWUIlH4KIAUB+Fjh+8s5DDyPb8NR0/T4zyR7BZg1FswmTa9rC2ruHr0AfEVeI+WSY3In16U0N1ZKNY5QWGgEOe2pNLFXarWB6GjNvflQb3+TxkZJ5KH3j5kAa2kj0ERedRvPp80hmprmtzZbLE/2UF5vPdpqgH4n1N/PcptgEwgvLC/rfoPhHC9q53h7mhpir1QsRrE4h+9+KOB3w09BmgmlwIps882y96Go/kiBVCxHM74i9anFOUUcBc+eyOzlrFgjE5rE9jZ+aN7/2/4NOxtQGS5qL3UyMVh5++lhflQwYiNMyz7/SnFsQj9A8A7k8aU0qzBzwrMzHRKe4ZS9dkt0WBNZX2jIhr1iG1D8Ot+x7aRy4wtVZkQapQsKsof50mHM8gxaZemqzNj9XRt0bBs8PXZuR2ZAPn5gZZL0VE1+QBpAmY0dTDYD+SvIDiwiJsauWJtrkxMPFiYQhcY7NgN0VQ9tQFh3TxXgssKfw3WiiIpY3JbGAiX4tiS5iWOAsTH+zLg7pfuBqw5d/nrOF0orfSFFhS5WzxDVyHz20+d5oCxoBs1tFlu/cmfgMAWzRWNjxNdm4XmmGeEHjJzF/Ztz+8GWg0m6LbBdYzSNDK1KzEIRULWynF9xhniscm1u+Wsmjvoo/6fnPgIwLNTKK6CMtUfAP8MIs7ZhVjRReijkJ3gbFS+S0yx/y3c2QbZsQkeUkyBCL5P/JNy8gE+bj6YDgCgU2GSVQt2DRp9kGRzq2vOwadRd8hJNhAXyKd5zoIqvbpTR1Fo5PZw0qcbePP2GsHbocJ0HZz45PVF+02OsMsbHvKAEm+dz2fRSoYdnkN/H1TnFmuXHHfSEv/4iCkIzrSkZoM1HmD4n09VmksJEEgrf+EJjOaXCT1ynSLCwr/5npzNdSOB7uT8eZQPWqw45GAPwHIwisjiX/PUIHe/j48ob34kLN4ITpu/bneyHi77piH74vLt5xPA0qz50odxsC+1oL8CbnO7f8LWz6o03nV5HdOZy9l3PSjyJQdg7KP73lvfzl4OOifteOdTmjA/nsH946jXaqVy2V+/4pzqOHR15Gteci55H3N049hvO68bLRxGJn2ti4l1dmRxYO9Sc6jkvRVrYVTB5Pz93TFP4502R/2mjGLqo3HcyxtWXfQ/X3iNDYZsMfVS7n68CHuWNXuBxPmLKtnAZ4IDcw5QqGYmDDUVQVYM5GV08sogP6Qj96PHcT3P6jhQZHMUnTWII6IV5ejhgVku9aKIlrcVODQQHZE8TeEYPqEi+w018gNnLSZj1FRkh8mbTCPc9i1X5WAbiEKkmUGb9KoWNFT48eY/a5tFsfKUonSzn6l0efndxML6cZS4B2Hvl2W4b+xhHGmGkAHu1w4Oy93HBSAHU5FzKo8TIjoPM+BMFyKkxaz4q/Ll3KxlaffqX34OsNOsk45kq58tMZTrHPZ00LEyQOlOOGtyek6NFo629Xw5NqwsfxnEWKHzeAt7co7czZOnXWQTcDcIYibs66vrp3saFpnDOozVe9sQ6ZPGwqGcV9a/TqeJOquokNc6C3SpFdzGXreqR4PYBYt9JI3PkEYJ/+x/rEHtVdpIAMF2YZHSGIfgtcq4Jy70haemlbmFrS86iAk6ovRTZg9RhuwpfkOOp+8TYS5yuptjonN/EU/+Ej7UQhXL6UQSw12thBReXY66vUDrOYo63ZQsqiIr0xDqTz+HEmQAPtMYTkVmTqQih7WO8ZnueyxUQ++e+gZoly6GvhS4ztUyg4yR2mOsyByDRVV59kGiq3V3A+0jbmw6usgQZlUyDASNq8a1pdbHSlG5YMLO6O5O0IdVDBHd0gHbPYevA3kYILFkD9UNcnLO61rqgcASxj0PNJ2WjLthiix8WfzovBBU6c24kA822rUTNQSpfT5UzAPHkbdySJjoi5MDsE3viHKgHXblC80FE/aPvg7p7ccRBYJ8b3kWJmBZrdAwOrPsYrryVhVRDkgnlQylOtWgTaQBiJY1DD8vYnujTEoI4qarufHAH96kcJTOe3hFh5A341ebQrVKzgBOQaxARtdWRPwUl8AByMlgVofTEg9uEFzDm55tZLGZ3pjQhIbEpjbmA55ifLJwp4zArbbCJ6krpk6qpit6zVRGmdz9jVJDqw4bHvxrro8j2tVEXGW9vlJqsgW6XBBEOCgs+OB/z4YlaY3TIc4Ww/N2EFn/nIr+IJtBOsIyaxMHUZQKB67zeEyZCHERr6eEfJ14vSnVmvPCYU9hAwJWywUfBQaujXEUzsyGa/wOkRBij3EeIzo9TUsfd8sT1Ip19Pn5YafFGEL0FStc+gF2+I5TRDXHHnVc24NmOAf5Iy07Xxkg+6pFi8iDnk/mnsfFVnnoECB6jZ0WwoiATzwo25Q+BpKdiPBqTteFq4lBOHmr91VxRyex4mq23H6BzhWF6rhXJu90ulP0bBMcByL4SvcV7vJOHBAJP32s3UZJ4+5852dcC6CEQNiNd9MogUm5enfyvFET142DDp8t87CFSsTlZe/1lAHqyDoFj3fU70nq33MdaS2t2oGQtn8QToSZ3/dYD4T6GZslFe/tV+OMjBdoaL1z4XzfAv9ANwOlgHPTgZ9GAVrIMc5OAMEGAZBDgfHB+TEWF5PGX0Pfzwy/1M3XWIt/tpNgo+IdrfMC4u7fr2u3olltXn7fqv70UE5G2WPcOXK0Uf63WntQBSFWSP0j8SA7VS/+MaL1Ju+8Cbw396jmJtdn/uZL3UfwdSt31iT2QpjB3ejxaqc8LZkccmr9JxOfP/9AJ6eg52do5Qk7a1uOTz+77Cn7H8/0s+W18L6k6OMegkoFRSLRFGm4OU4JivSyK0j6uuBeGqexIBXPgOFgwB7zDZPSWBbPC3tRA0i+XAyrwpdV6PseFQEaXBxVBfE4hXkERQXeogF0KzcKj0AW8rzxQwNwlzNCJMj17A4497+hMC4hh43OSfAsxsLZohrHtHi6RwiTO7yPAqLrWEgHC6lBm3bz2yUm40a1JvjboLO5I7vbfLkYPl606bv83/KFpN2N9/hLS6tX9g0K09XxrdaD8MT1XzIcEO1m7tpyM6+71SPe47AKQ3Dzlw25BoaV2OSLDRudApnrr7du3j6Zubdfy6QLNU+6Qleq5wOnratYCM42jMf2HGu33PCphXfCWcnulpVXbaOGXuxF4OpJKtlda3Rzvf7jOg5oTOFN8+SefUKJpAK1zqmAIB3fgc8QFDMPKjzpZpZmCpwqLBGbpEdeG2+Jkr+b497DKUJIyCWESH2X/5gRi/yInqpX5hliWWtxkHgCH+eTk0UB9QSbdni3F8x/Nmen34DT84w6h6itdkP83PvX0ssVC+S9ML2taDIL/sUviFK8jT9Ie8faDeHCcKUqrNf99M31ArKS2m8pmDHvbsx6sAeZTuqJ16hveBeO7DKzIsyiLHUQs16SK8Fl0pwScu+OLd4BW7WiNpXApfBvPxRG9LfV/vzNejdEsEkyaL0/etBV6F0jPY7kHO4xrZD3K56DwflIKo+PKUkP33oMK25m26IILkzRYDkAKg70To8GAEx1kuDr3FYmyKZkJ4u81Jetub3gcAd4Qjzpk0dzS0gsPuFSCkWgglrejgoYvN9L22FJtaRe+oCr9b07uUzFrwyPKT9b7RdMzK3ZBU8GZ0R8632IjzcCHt6Rm06sqoztQrlR2V8D2r1nCO+D2/WiYl0F9bLQBkEKbYA77kYlqnaCdVo1GnizzOc7iJILtu9lJugwHnrLqjYcpdpJN3AczSa7mS3HrgrZ/qGMZrDWzEp04bwZCOyMdmo48gep1e3jTJionqvXZu79mHZ1QBs6UWmmcq+iMs5/j4xF88u0ov6dbXC3k/PKjfG4aLR0rFY1ZKyPiVVtRLUcOh2zYD/kw4AjxTendvuTCCkY+/cwEaQqcp4hwdoRG9enNkI+3iKtPNVMLU4f7y5QMftePUthqnOQq/QBAbCVUqB//zlVeG4bp+6THy1+lJtEgl0gHrrXBeWFr20gt5Zv+XvuePEllK/pe/I+A54H2FZyecjxV1bcw1SL8bQ61m7lfoYIvvZp0FN9TD7huvub+GY65HzvSXtyN0v8gAfOH//9Afj56glNX/2ymc6Zazj/OTQkRn9q+u4NtG/L91ltoe19uy6OguhMwrorI40PstL+DYjzZtZx8FdRQm8pBnybImmDTfwCYF8ktr8blyZcNWhvMpwCp7Ym6c6VwzSPHOp0qSlDUAHsflbqeeVWDcg3GBbEDDaIoQsEZcoD6cQ77kFk7PWzt1xZe6qNLEYkOew5WNfyjfqf5Wq1BoBI/+dATNgIlKvegHtGUm0HuSEMVwYx7Qj6cJZEvRRY8eHszbNB8Wm8B8FbAysIutASwfG/qwUJ9/bbXL4Nkzaz5mdKrXr0ytf/jz3OAaqTeaFvULGHwe6abTNXSetEYvaRy7paXc1rFX+omuXYrDLWHu6a6VSYeshq8blWkPy/qi011vIfq0DuojaQmEZJDMT2hibi91S541WBv6NzDkOJosTm79fhCzZ7eXXPUVFn/5RdcrySG8J0AloQlgVju+aRTxukzVSYX6NV7I4ngn3xBmbT0huvwuLyKX7pxBvdCzfpbrJv1r5KGdhB4RTrbge2FAm4HImz6uYuKF9c9R8acy/MlU6ecZI8yiIty/W1QXWq9fz9hX4J241n0rVm+kj8YV1mC2KmQbktGqzeN2vVbtik+7nNYpG1b5b30NJP23+uOysL4gYYH63Kvq03ghbSf/6Z6oAEm9xBMS9K87NkLFhkUh962qulJ5+WR+UwP0Cw+HbnyY/3TsNTuDtrYIqFClSyAPjyV8jI91KeI45u068XH/45mRH73WRFGbiVSemzt2o44g6db+c5d9WWAGhs0jmVHOu3NiSvLMAImupYaR3I4f7bqjKRy06Kdr8DhutuI/etULoj6UYyg8wxlYOBhOL8b6EFckQy6LhmlOopx/fLyzffrBtZpKenVba90fYHq/yf3EjZ6YHdT+Zd4QFoCOBY8R6z/d7WWe7zMafhX8atZXQa90qVp1RHJ2v1XMEj3jUu6kGvw+w2LouJjkYeXgqAYYFlY7+MCMptpFe7tAZxig0dnG03ge/3TsfpbaW6yy69Jm+600Xv7zszTKJZJCVe2ZNQwjGjPRhyu1eP+PS+Pl3VT5+SYQ8mGPhz4xcknivrxZN0UxsfJETvSFLOGRJ9q0FDyW3xZ1YBWfKygyaCXFkE5U6j1UT/mnMO/Fxg3RVey6qOvuAsp/ojad/Qz+GViyfS1K+1avUGGjzAsuFrgXw78TIHdeZlfDIBeOPb0b7xF/HPsG9OeLME7q0/7iyYePtJ/wfYiifs+4v3tH95PeX+uKEueStVY58tLiWr+O0587bbZczAF/cfDIWMMnmFM0REGKOiZAz1jRGm3xdPWTOKmx9AP/BHm/YWIG/VZBjlURZjStgi71+VVx16Gibm2UZ7WAZgvgX38UDee65Av+FiV0gqbFseB07PYu86+TDsr1P+m+tyHWLfiAFhIK1wATvGx3HniBLPL9CMi+oPJpZeJ5lKfvHvUE3taZRlttChSmRCoVMjqu8UV3e70Acx12raJbfaV9jxQGRq6PqQJmpDfAWsCZHVGeDFq43wfcHa97hmMMl3/DFUDCpucUFJwLF9hZ/FJJD6DIJhxpR/bpPBSkK6pPQtJtunRmebu7z/cjkvwpUea5S1lhqDbvQiICwZgz6Tgc+W44N9cQ5X/j+cgU4ZxU/pkMt2FmR1ygDvq1CmhFJj9AJNAd/TemO6NOuKGfh99rnDA87aAnv/aaBYIDtG2E/2arVgu7C2u5Lm/CAlDwqYZH2u5sd+RfNvLO9V+rtu5HP0eIxPcf5EjuNEjUBUywyl/lhA8jSwz8BrfRjoAyCT/VjQZHwXYxav6vlY+37y++KODFw1hv7A9ewYGDH8C02c+VYpt7HRzvRk28SGXU/Ih46R1QKkz5vlEWctuEV5aaqNK4MEaebXbPfJLqmzOhxLwJ2fxRuYsskMQ12BJPgZ5BG2hU+M2xdyNnhcH5daoZm1tBAMw3O5csfYtU5hyHqmdob+LkLFqIzvZLwH1jq8/qDSryX60UnpphLHvvvmK5/9R4rA1ux2hOVksvXz9QFUO6PaT85z2g8LxiqTf6nvG+nKdl9T3WPte5DvmdGGTr32DrUxN/mTPvA91ICU1ML3yk+/0x4CGBUp5ad2Bd/0cTdPIPBiJ0K229de0IM8EVILSDiBqOA5XpC34Fr2RAov2b4PaB3aDffSyxu4AYYXQ4yl97TV/D8QvL4Raa1Tw3lu+lJvycU9/y4/cI8U99+CXM31VfFfXGGXkZor97tCjCh+fFkHYexTGhqjwGPo8ctJrtnwEyvQ1NPIfiEH3PqTPcJAqxyB9PaAeyuaav6Tcg3PNezX3hUvEr8QlFjw4yPrAPf1nv3oO5l363sVKQWc0vJeHDCt44UWjlBPZ1N1m8+KAkyaHyxdDyJGAQs/sV2rZ1P40mZcYrB11bnTLycKh+qdrqGnOstAnubRTTb2rPAR/O8jW1m3otm+9OT/YD3mr56a3i7bQhozC3FGUI4pva+f8MpzhqiflPlqzrL1INuCxUo+VATra6DunCfPtZWG+x26U6D40iPRRK/R2rT4CPTKrOO3PpjTUwWXfQdj8ThSvL338HlJDZ/OlQCdfd53TwPwBjPOJeBnorcSeJhjRPvWXy2nPOF5vG+lkKFBvIesMvIO1k4WOJmup7Voqay/JlzEhrKvjMzWaXor8WlzlzU0cd2tbkusXU1qDnRUr9bzTR7SnDjuXdk4BRQ9L8Hi1XnMIPh+mb5cPaHst2+PxXYkw80E1OGYUPry1vrwRhBdKdlUOKnw55fegfJx49/94Xtoal4k21+601HArls8xef49T38mGh//bO43T6XYg5FbbMMJ+XrpCiBjoVNAdhCiQ2M812RWdg7t1SXRMbSI54ETrEnn556uCumtbwsjJe+bg6N6KGJ8ZCTJstOKOLZlfD9XA6xifsz3+R/W0Oab4aVeqvcOPu0XMLmtjDrEq9h88KfP1y2//7v5Mp0tv5W2yK8+wPhTooxH+Fpo32AF/HnuuPb2rY1cBibtSWD2CLCDz3LSb9+nRAXNyHEd35N9Ki/drm7F/hX3H3uPXXKv6pPDUXyBE4m+o3gL8My2ZpwY4x8tgCS/LRaDP6U7kdztBLOY8RCDdux1J/U4lJ+XvUbLX8duXEMBXw33qkzs704FUL1/zqrZowcFvyuQGfsclXjpN8E5pYdG/dbhuU/doqm93N7/7C9Vh4saVS+9WGIHwQ8DqxIJQ6kNiANvD8b9WeVt22LL+kw8b+H6cRg/XrcTQA35S5M2jmgmnDc9Jza53a3Rcfp6uzcdUbNDeCpoGYdq1lOBe9UMnEUOx9X/sbLDEacxPEU0j2bacifv+K8JUN2qozoSIpl+mk4OXnOdwMhc9Zi1fBcQsl5qXA8LD+RDwMwg6s9Ay5vlIDZrTYIf0WIAQoj2UKFZxyDX8OkAUNEhn9A7gMFUpsh4vwTU3m1T7hFb3/ZRKEuxTDHLC273lP5OvtR6YxQS7TqgBa08A/JCfB4SDF7NRk4/kfPhfsMj9YD3FvqWwE/vkfC40Pv1L40ep6KP3ESs+qKzyo9cTn8HGHeSOx9+1yzUsCIk1MWwG+Hs4OkS/9v07Hcf1fSuK/MReDWdNwCAT02lMHru7nQ9CNHNikSH55vLwFzt+UiZ06+3q8hvztTvRpXnAFUaglR7sUT/BE6JE71x4bwT0wV8JQEuURDDDNU7/eeZw8ZNgn6Sjc67vSQGOhPLFt44BChPQD5ZT3MpjfO4VBGZUUVBlH83I+znUQ31zEupnrFtoB4Ia2Nd4/i/3lN34Q6NU0g6th8QqvISBugqQ2t3k/rPRuHid+K9+49+SknsDOLErz94rDngKTPVZpxUDFongDgqjEcewGTJksUYrW3Ma/Yt2YEbVkAXrH58bTfjq4vNGVcBC41CwLvJ30z9rHWD0RKfJ1d1GLVrcTjQ6oq9BHZoBTLrgFb1bBRK84nfLZeGred9bPy75MmDyNVhJOed+netOKc0RpOsfB3h7TtDJ8ZWv5Pnq6pflTP1yDG39qjwKFv1dmCL9D/3pQzks7nUZjMqp92sU61/F32petQjd0k4pkpiJa90b7suu7lxBXsjYyeQX8qaP+FEZvqBI+8qt1Bc0l0BUHV9QLMGxPr6gxalQG8X5PmEcfZ5dUFI76E39re6XSQgBqnDc3Aez3EmngVs3vzyC9Jenct1nhNJK3/r/wetZrtTnsfpMN2UXaVh/bV9a3kqz7sY4KGMGvKOc3R0cssKx/vZkomcul151KfSy/2XsTt6NbP3douTAS2Y0eftbe3+6qQLvXS8Mv+yNDtT3fjctFfxrfMxt3B3QoQp1djh3f7KJbp7MoVPu/RlWIvNOtecXB7qQh4d7/4MrjiX2KKElQe30vcq7MFp7hrqDlTisg7r9Sv1rGKnu2+h9Z5xK/mAP2TNzcD2g6s6iUaQu37P1KtrA0fbs9B2XaVZjqkNw54O4m3peL6WfodxfWtkyptBc9Cv0aXmD75C8+mMUmvfu9Uchdv+T6m92oRhetYpcYl8t46Fg/38RiDCGxtGdTZS02G318YT/CYewyJW8uxgIYM4aO86YcNso1ACw09zhghqrUIbjTvYVV3yFQh0q6OiQvOINn3FQfw6w/vwO47YonDcgf779zW3aJyC06Wk/BMDLZkbazpfvOya02vTBbhSs+Eeb0m8cqG8OFWPYaj5Jl0CMrLbAoj6A60vTAMKuTNiz5Tc2Uf6lq5TyilouEp8nzI/QEfvHvxUCYdsAwg02IDX0+Xpsd4EBIWo2YWNbidW0if6rgokz+nFYdW8IjG6OlYb23HKmGsEsJAuQjhp8VjCdE4cUPLDk61TBgmkY+GuL7V/o/Puz4RKkFZhLEHkKoqHJwzdAZX4EakDw6k4Yzm6ClivjI1aHKzn8qGaK00NNFYDKqdk5jgGEYyhiPewvrMGN07kRcEdA+AIvHnxVWVeneIkUmJPvILaLiNsESDnqPout1jqhPF81/Gi1VscFMSHxRstK0zpuNPbd7S/bq/gUtmoxrRd8WNBiMvt+fPv/EfCOoyNN001yiNY6lnUM+pJu95Ed1YoloM0IB/aSa/VsldTVfnY9FfWTFfdg7ap9J2L/nKhtJsa3ABUr4Y7aT2Vp15CtJsHabbtlSXGuCNR+2jsrR0D7SiegzQh1tYgV2cp4sHZE2FO+IFBUfoqA7bhkfQFwumIOrP30VK/tzxWGWtudbr/wcrTiDqz5zN7FHTSrImCrhvwZCeqVCLW2qy9pXbchoFzJVHu7y21r/nauqK39TFfRCeqV9rpm8WM735h9qnncLisHlxdRrtKBzoLvelJxxaoRsP0UexAY4Folj9plOITBDFG+4qHWds+6TZK5+gspap9NZi/DFghXg4CzuOC7GrjfzbSSEehMMeBtKivGlcq0zxJJeHtZ6UTnDIFhZ7ibrozX1a460+ELV51grXhqgWaik9UB4znLISA4S4FVTu2n6JR8B2tYC6ZXS17w6wYsBS50rwk3gfhaSpcMvqRfugq99JB3hhwHqx+zB9FSxB3StRlnL0rDVu1FJLhCsQs9LBRIMLQXE1yJXifDoY516khFhtetqxDPaPPOZW2UQzGrMpiHAq601twvTJCvSeyjunlqHe+GyiuwR43ELVf36pR6sShhK1JcAd2310iwa+jh0ME4fBWNHbErHu94cPbYComlq9+sa9T2tzgUxRQHoupiap7XHIpJ0S3pL284Mv+07ppINd3dc6KzPweSmoSxJ29o+ysZui1ScRdZYMauEbsL6dEaeV8HQYuebOVgVz7swrd2j4/VJqYgWrFXQX32gRNdK+XiU2+n+DQT+CnjOTSU3y7mklH7zwPnKn8elKv6eQidbXwfqkSrlPlVp/KjnJaSFWo1AD2X0jwCYnNOHY9byk6PVJqF2EG6sSYXzAHhh5OgNBDGT8QYUiQbGImfcjIpAuWEBJCiByqr2aEaA11V85ULUS2ChoKqUU+TZsTEwxOTodz9aIJjl53Fi7WnkBArpyfMUmO0Lo1iV61ltHr/AnoqulKnxYAG3qvCTUJFb4agfKZql7xxkuRebfBogCWbcPTcCjBhrBqkpESMmyKAgy6zmEwuyF30gxytG59WR2pxG7bE2YfBFIXUxBRIoLpykk9LXvIgVw/1omR80yJLhNcmDvfR3JKXZ+n7gv/941PWXUaK5BbpDAZA2AuKF2Y6r+abQgX87rticSde6EURimjjm1Qg82nrhZwT51JMxVsnkPiO2aU1I0iSMAh3lXHm5Rq3qLQ+l5Dwtb3Fy5kQbSkKaKjEkWFU8NrXGbEn2BhhfMrUegLgknucpIDHrg5kUsWblQl4Ukavk1GVcCGsdIkMRVm60dgT42SPBJyoqvYsVc5ZmYAXKHUJM5ONuznjzF2VTE3jp3IY7NGWNDwYaksuk6XSfqqPJQGVVcVzgUy+XybnyWt/ns5gA3E2cwk1T2VytQx7kzxGM8KiSm5kVeSiKhvheflLTYOWeUUND2isIWSZ4o9VG1qvqA3W+tWDU6ryqC6SyTPe04mAqgpKtK5MJ/zCv9Hfkm8UiD3oAecmwT1Ro5MEv7HEhnYbbI7Ohs2XB/qNZNdL302BZiJh9ftiiC1n2L0HXXIX/5gngEMqbzjxFEtWsGOKPeriNWJx5wBazvHrL7gBjWMfrKA8BA2qoyXQ27bXCv8ULCGKFDtnhdPYqtwougJMbIIdeG5S6AFeQ2zboZt/rUBqJEiSTpREVw7T5zciw8eAOdLfUQcUzkzY6TBluxJUbIqvj1z//DE29KCTXQDOdjQTB2UIbT3nF9p7ABUlaYBu5/gxqDXSQ1Tit0K0x2FsQX1W+HVJBroLJDeY/9l8AUvcluvGGddXcpEAmnMTH5PkgI94/qFE8xDLbfaKjWNOum7KD93rmdSfT/n/7gXOKdcbJafD2m2VcCJyCE23kzCsdm17959oenWxhEA1SAx6HbiJYIVJvZTLTC4OCzMIM4fB4t/F2luDmC8Us6W3m7cM8J1CnKTOZ52Ib6vZx3xihtufN4+dfyyI7cxE1GcgIAub4JJemeTWMmgCpLVAA+4HREBzNNP6cLP5aXk1YndlKuzimwgbjY7/QvHIof9jheVdN8SmAHkDBxM4bjJBKYHb28zunf5OItt9S53qn14UI2Bc2bQwcMPXsrlJd+b7UjnWORvL9GLa97QCb2EGhWE38y+bCY/tdM5WIqiIH6sYuf2A9jnORwCDx7brtP3iNuXUE1lDubt7xgfstBBtKFxsIr7JBHB5k6bTunI33WtbVtpkzVA+yKi11OhcpKMKILGKIWdbyTIAkVXmkYi2h1w58AR+vHNPTuIcQHkU+MvVwZhQpKoAthcZXI9cY9ym56W1FyIPw3lrONq7e3rH7Ix8Dygi09PUcIR/BVf8bCFejdIYoJtzLkxiX6Dq5ZKMnHczuYhwnNCKu9NvLD4nPBNWzSM2+jec6eC3Ie+x7URCGftyCbvP3vFCbBcVTASUXygy7HjeP6+BCFIkvPDTtoc5vzooqx/cXz/LXaHIOckyC40j3sOZtCUgNC6GYZr261HR3xklN98F0/cHONHuuvxm17oUloVZMne+7yR4nF1imIeuwfB1/+rokHhf326m07byImM2EDWgD9iK9xR3YgvZhO74Q9cw+zkeGgTae0E28betBwXKkyHvPXZSflzuJaIC8fzD5/2li+XOfljcvrq/fWrgtE3pBsFpKorAGwi6fi21JLDkCu5f5fbVb1/V9uah/9rrRfNxEBaVJ8v6uCB/LAZNHCjYSEAkx6y4Mnnll5vCWH5UrgIjjERBAbv7kxodWiDFJp+uPy8xRcDzxcQY7iKj8tWrRiIqWOeoVxacqzDrmwVrSp/rNtiW90Z41IZZaZIGW5Lan1IPUnJ9WnBXvlpgbmgiq0W8g0rQuV/zQeNxIctLJmPh5+0/oXmAzxGumbxmO/2jT3jnEYeFsLOnBv1dBocOX+MN60ay/buleYhUAjzIiTaniT9Ezwps0a2DZJ856adcxKdNOjJug+P0ifY7SqWx4tR2CBdpjB8ZSy2eI8z18Tcw+dJJn0hFRqt0C47UHxJdBGom9I9PDuzAtmYcaSxb/LxINXqwbTcIggGS1sDSC37BMeqyx1lHN5/MR5Kv14OHF+wlSphJDytkBQzgLAQ0maXYYX+9KbriO2YICdxDa1E9maMTeY0XsQuLMJjWa9rpailjJEn+je+tVVBxtV9a4pKZWdICsgIav3aNrCSGUBwKgwDCmNQIJT5/FmR6HxijggC+ZYbu5TEEUrEHyDR/osp8eMV5BoARsyBKg5SFXMmpNYOYPTZFKhpHwEncjVxINRQ+yMZBDR7VkzZSNZ9xQeIDDELzyhp2b6vIgBLYBSJxc0rm9wk/KErTtuREm3DVfjDf7ih0XHYcBhCNvZRSlQGHlewQMyEwEGpA1gzsz6P3ja2gc3BU8a8nvuoeFnA8CkmM1sbr6Il9fK03g5v5ec+K6rbrh1J2wkDO5dT3Ur2kg0ZMEOnSXbMfPi3dkrHpwe9m95P2wFrq3bMz3eGI/BUNhswn8fkCCje7kozeGCCNjZcIGpLZgU5y6Dng3Jo85mnVhsa90v1aHDL0UIQhH6AtNxIHy57xJRT2ZbIGsEy0b+ZTDJKeM78Xel81lPFP0K9Q5OJxd/NIwBIuVChOqO6dgx9bbVzvokbgcgh5oAJ4znVBzrsd3DxtKuARcIG8P7dWwzQSje04y4OMgjthSZIMpaxSQtuW4yZGnNq5GrIvArZJ3BP5yW/e2UvmNh8bKhYnba020d76xwkAX0jMs+79NBXTNvgYHUss1AegDVzhS7QuUnl5NZ2nZoNZYbldIZ0WpCB71Op07K5n/JVv7dchDunIyBsLD8vlIt04McDFx+yI2AJF23DuwJE7uzVIwv9pvDC8CeK9sDg9kppOg1JahtpkvbMaTpt80GwPs6NXPaHcvcxrziYk+00xnnf80fSuTPk5/60e8uPWqqUFeugpgk5fFfd6cjETBEf0IZsHcO46gU+Uc4fiUeIZu5QTGJKeoPrQRHqwExfBGg45oj4L1uPHKVxWeZ28vk3tRwJLY99/uTnL2OlblmWZ3V7aNUFRcd+60qVifSWKKk4uv/u6h9/2Sbiwb1WbZ++27JKv9RSu4C5CVXN/rUwGq6hYypoF/snALjnMP3JO1PNo3CodTaZFwlD7SXbWB8eqbqzLOIZhjoZMhRfTJYfiyhyPizhIfZROUNtIpEBoEs78bcG3SKM5L4sRT9Lat62A+K/r8Pz87nxHji03vSAculjoJgr2gxvILceeq0AE+oCh21S6TySc2L8yAFc1Q2ATBvHFBiervtu1RHBNGLDvljqWvPR68Ze6fU7oaiaE+0/c/xOX0z//nUqnKzqeEtRJEDTHoL37ZWmNuOkJTVyWCCNlA2/urg6LRfcTKTYPJwU/ova3zkn+3u590sWoWhtEr/WK0X0UnM2YJK96QhrAxYtBRiHUi9INx8jCF6G7pSVu7YW8Gm5Fr8UJ6IIVXGE6qC+M9CIIeubMLqNTkjYiKYqYstvgnCU0Rm5lFJ27z48JJxxQtWwiZmGxtC9z5rbdgICMt0sO3S6NS1/seuj4MMALBRaakn3l91898/vGdjiLPzzVmfTUD+CQrOY/8t+d8Ov2/tMQaPHeZamymgu2RXr8750A5DMeA7YlZwr95t0X13apDM++SG9MAXnHT5K25PwPUeZI6yKGSIuMhWfCvz1HQd/dWWdvT3TRJzXrxITUvIAU8fO0clyRrEf5De6693+PA3wvJBts9hNM3scunbmpeyNJByh2c+G3TVJVLckxUR4bibl+AEnsq6rhVrLFHFdy5SY2AfOWiL/C4Q7Y5a1NAfYDaau5OnEGHU3xraAdaRXVobYE+zWfqHd1SdoKY6ztGxc3QAphEnu6kflD97VuBFedgBSLwqsdPs8hwrY8WWm+arQLnqyaFypaZoFUI5WL+dUNBxciYLnKpQp6eaU/bvbpNEBHbQdsVCopuYX4oJLK4G2Gt4ZwvugmL36XqAC1U2yVhIQLt6Q+7n7V0NvSp3foMvNgAgtffEolWzMMtOTGlYusL6LiIGkDCGcl8Q0lMdpv6WtUGBa+j4yyx0XUxEfxWAAfJ/V3nvc5JCeihE54WX87l/BL8+FlOBdOrDZOiBQ4xfGIUlhzhgWmb6Mo+GOJkfXHDqeOtN2EiS49LbCS0W3FbIJKthILKV4Z1DYSZo2u1CznKlZ8O6tk6zkT/bC+JDcl7WG9BfKRPiSTGOcfuT5ApC+8+nGdzih2qUzgqW7g2VzhfMXpH5DlvnT4EEmbohMksY5HpBve85WwBTJZKOrnTJcTsECfDIIa8+mjgL4Om7aqfTKz232fzJL9ObiGTSuctcNs040vIvpDH7kYn5ZsL/FsTWzn2K1eGdVVp4kxvoba2gi6NlFbBpGYzMXnx4JIaI8Ktw6OCKQpADtc9mq3jS+g20MleaDXF8Zl9sUpdCeImNDf5XphS19z/q6db/wL2/N/jBjeRGD/2ajPaIjARaN1SqtUq76RE/u+LalZYHYUvHHYIwBRh4gQA7+tNy29tMUOyfq6JFP2LCAbJByHVhgJcATyMSJMScQw+3pEDuFbblX5Dw4mJeMNdFtV0WmjLUklkK4SLaozEpzYzhCEty/8NZx7F2kJqINSeLeCpaALXNvtJZ5sNgDaEqK5hXo0WIMDKJpJLg7Jkvzq+DeR6RFuCwjsQBfZxw/vjLpy5oEprRLWxkDREu6YC4MGKM068UVItj3w4v0qcBw/hSiCLgmOxF1c/+obQfCmF4Xh6KtETf1hysFg3vuj50K/4cQ7EZYDF0jq8ln0AANxnQ70yY6XTm5SyJwjS8fClE4vIkJo4oKor3T10AzzWYjZX/nB6uUj6Rdw/NRwWTI2n7tyd7E/15fVwNtc2Krh1h5VlnURZe6MYejsMWAsgg/GfRV/bwdre8NloMBjNS1v7aUCsn2VEFswJbTLub/rZkk5HHHntqKTUqpnCVJ6zD0FKjiHavHpBFUno0/odjf57bP5+e9KmXz/BN+he9LpW5mFsrv+30kiBFrORPktHen9kd3zATjBMmdwaD3iWCTqhhDVOVTccGCUhNuIWt7D6MGh+RnsrAkQFdeCUOQbboTRh+Y0zOwb1vMks/+/MIxyORQJTCzl33eOrz401zML4rPA6RcA2qDBuDw+lLfZZp4ZFWYkb902mi4OuWf8UdNwGbTlnHGCVDpxiIFCNe2jiQohDDlMF/tKLiScd+gzKpJsMS+gAJleu7GL2rp2mMTKmdb0cu4cCgzPcc0j3Wsa7OGzOuyOEGjBn4HjnQVkT/IwZhQh1S7CjNeGZTLo6X3ecYnkGy1Uw6v968Ge4xojdvy6uaYaOoR0bzRneHglw3AqRYt7hcM8qUn9pOjc/qmZX22Wa8Jw2kkpcLTprPDTacS+LIBQ2jbwyWp2t+FoVgDajNMFqEgstwqY/54IdQHjncX/tkAxn6FvzdiW6vmusDlqkFIB1y2PD8yTcmKQyocFQzB672BwDK6kRo7SHcyHA7avcnWltoPNyyxQtk37SOg9mt+rGcBmomM6YVt5ELgis4I7p072lt+3bGxJMfz9c0yvEdjQFOIZGQ6PZSuHhRGewZutR7jfjAFLdknByGPp6WfVXNALOnbTSgNZiUEBI97pHJ5QLOo7doD+KsbMHqZTbnjpTYmhy2MHg1L2RmkWSOF7N9wC+Yy39F/mPE8RwYEkJQZU+rGXNw7KVWM/veEeHOEfl7zHYy4gMvtJZdIASk7BsETOeiOSCkwcEAQglcFVDz+223FCUPRPAVDW7qBhvS1rVlTG/QLfbNd6jVhweQM2M0ppbsJ9Gb9GkbReGJcglpsSIOYL1pQJgZuNARrV6lggL3bVu7gFVwsu0qjOMGCi8xvG1T3XhYwlrjxJ43bpC4hWUPsRJxVIXgL1hj1f0Vdglzu1schD6s/TZ49z+SdvoEVOTwztD19M4KNf1Om9A37su2HePgs0olAmMRJLeCd1zaXM1IOzM5P3OLfVFJ/Md1sEE8TUaoc+hcM0YYN3zaTX4Ef+Yn1zvav4ToHNW9Ck7bI/5g3AoClrGLGQYbQocgUUvq3yZ0YqyaHcPeltDIk6LvUBl7fIYnN+MKzv+ItwYS82LWB1hykW7o9MYviiB5ayQNqmSMvl9/dez13WuRG17ok8IOwDhTs3ttmxjoCysR5E++Nj2g/0KY70Rk4IrURQePhTC7dvGypAXkSUfU8/QxjLG6bWO5rLE6qHYQWvFUWjeBfvOSEsczFE26xYhj6tRY6CVeu4lBlChvMWmaITCPAy9dohC3onb2QlkBN/5vdsApDEd2hwHFX1i0Ckf5hWO8aE08cYuw0l/Qv3cPZ1rDIyrIJ8ld3z05szKjVppduklrRHCGIBk5AK9Q4+lMZ9IAIUpJHnsOqKq6WtppBWsnZ+0erNwHIXbH6u925bPOKKgLy1Vn6FsixchFLllhxFnrg17F7x9krUWkJtgbFHSf85v6bNgWUYiEG0jorelAQe39TdCRUrTuc6aGGGVOEfXV4qX6peqjeaFjUqttWlXuhW6cUg8BIW6hOrp27Jf3AHG+MsSUhgX5N0q3TkRAwW/+HZ999ORoW2EzXv3Rav/Pbyt+5Mf/l+J6/9dX//5LvvlWHlJKpL0ODOCdysPoQ+1L3I4fv58YFct9Mu53/f2XIuy+UakleoM/axqpqZ9R/w81+Q9Y/WbPr29eQmlxCQY+XLe5+Av9pRRQBdJ8p/JdC41DYnQT6q6kctctbhTCD6GUHoztD48tEbzxUMd22Eo+2RtfuYnduHa1ym1n0bma3/gwmKdJsFv9hHaB33T1FAbVGn8+235yJ/NfTEq+xu94IbWILESfPf0VoHnFTdy3Kg/iNBRAxH1HsubM32iQaYZT4ZWzhIvNScpd2mGW8Iztub92wT5PANJxAlj37H4/PA275+vlDDblyUStlQwEgcPz8QRX9XN1kMH7cCXhMg8ei9EzalO83fKC9D7JJnQca2B3wSkuVBwhS+EaS7AV1WXW/2VFhe/w3UDC+8X9vM08EIUJJWZjzeBXKEbYjS8p3FVxAfLPjyC41Bgsw4++OLyk403k7Gu9Oe4IB1btUlqWG6Dd/O3B15e1rPKoeDob8InAPJxRDDMwBgDrH27XDJqRHNd4BadhdgHc8mM0nQ3iK9T0jV1X98co6i3lGEvL1ES9hheVhnDl0QUi9K3kOhgX/auwE6Q3lHJmaLHf352he1n8TO4VSBVlueFB8KBd3fXGzL6lol8vUZemJA412jiYB3dDF6pViPYUDKmOIBsLFBbMgan4b9d9ngWBDv/L6i/GWpE0u0iW3QI+C0qiQIhgt7fZMxAl0q5x1yxs7K4UxK81pHPMVWEIS69ipsFBc0hWCkcRd/a7X6ZbWaFtu1IAS9FazF4tZFyw6Fiu9QMFbh5UZellJXahj1n+bm/cOs7CUnVYmR7Vqd4rfdAUrkv5oEf1fewE4v37HBFPmqqJWIFIySMZ/ZxrXch1T/JgJMJm5OhR48Cw4X7fWNNV1/nM60pOgLQPPk0+fhUPvBOR0ZDMrpNOHAnNYMqpfQMYGY8ia2jFyzPwXOCQEoImSSxN4OUwERfSN9BtwjLEFSYSNgtsGFsF/bMCzvTK5R2Z01vu2YBn0yEEug+0j01aHnn/mBGkZDBkuuXVkX1aAdltVyIjhDSTIs1YuSG3B/ywshT3slupJ3BX0+OwNqtXXbjhCOYNam2GAnvTPc9zqCqO1SnnQYdxkFXS1yFaGHox42dpGcRB/IX9kerZ0RSVtNExI0js9quq0fxjnTzaHKz4mByoqfSHD1743TRRf4bZUIjrKQ2xVs+OtqMuFJDiWo8GJLhFMSE2MnvkOSBS5+NjDMzN58adSOKLXOn24d7xDY8kLMFZeaGYQT9lMbJgopI4KPtK8n5vGYnensXBihYFKuHBEiHVZcJ6d8kAAt9WABG5R7lXJlvoM1+tvcpj939GAILeDoQ8E7jsBCEngkESFpn3ObFpXXxGE3Fha0PW7vjom0RISAFYi9HlY4rAfF+XaYU8J7a9LsHslTwsY+XOtwVnJiNZ04M3DkL7GuHK+sKrkXzF9H0INbu7zR4Y0ATGVA4v9+NqDCcF5Yq8NI96gBjcim5t/jjW0pxenasxaRQteL13r2u7yvaonECqMpVKdPjht8lFXy9BZHKkrdiwIdcreoe+XgzXr2u/xIMgp9vuA1prOKAcFlObn44/PvbwX5YgBWOPbC95hn8H0FwNI6+xbG6fvbacF0zjAy19vTv/eYVseAYOVz++MlBfUyqLa+IYBjPjeCSODjyNwKTEIuLjVkR44dWUt8JcAt44uFpWSktq+kWqpT8/WnKB3j6GhOsVQ0l8HcmIB5CqzQo4FlYK7oY6kI+1jOOulZBRn2k626z1I5ul9Qq4tkNAhdISih93iDq4B3puYs5XvlQUbYjZdTTIwgDDIl6fHR42PHtAtS5D1xRElIqgwumaI07eQoO2S2H0QK6JZa+Pg1ogS+pgN37UAIS+T5+Fni3tPDQ4DulBnkzixn773jrvF3jrLgEgSJkwKbgcY9VU25iI0N29kyhI1SEP6pSHg+rtXLXI3NKPbBro8P0QISB0eSwzdCtp21/QjY054cwJ4TBp0Rmr9GF7mpaVjBo5rQwuxSxpZtoDPCXIQa3wenfJEiEESXFUXs794Tp+u9S5QGhYsygrypysOefuoka7ju/Jdd+UAduGtUvAKk6Y34fiuQe2XfRObgbz0TgBA/0eProof0X9DQO1oU0rb6iXX2w7WGtZkSoT8VWNOaJ62DJ0Cbgjds4ggR48bn54HJdJsiREYwvN3J+IQW6PAbVCFAeOPkqg+SAmLLZ2Zhor46ueA8LdAZ8MrE4y6hC2AqIwsa+hRNiNyaQARtEeEKIfRSDhzqWgrw3ESeh+EmMcVqkmO8tpE9yUHAOU1ZCKFI4+Bs5+y9sP9ALMVtHIP0jeCvRtXu7j8n9skg0Gp+dW09yyrD3MDb+d7hCoMJ3EJjdZTsi8piXwvkj+9VsRXmcm/hwj/0bUDbTSt4HgYOW8WH4tByDIVHftsFjZAi8eBiH7s8r2GGUH4vvjQu0wfyHpU5jldh8kqGUXX25ur0+6hn0RvONDo1qJb4fRJDApzUZRGRAFwu3ohSAAuusHlurqqmPlGu1bvalJP6mJZdOSgks/+jFbsoAjtUH+ABPpa+BE8IH4doDSoe30t6/L875AHThtkXDqgdNKLd7gzjQ9GPcHBOUqOl6u0BlX7swMhxcIs49mevLQqnCEUEUIaB6a6X/vuANP62uMzVAVQsJZEPchnRYop4bOtSxk+zZZINWfjIemqQWhefMVRBgM+dOO+2z/n2AaoggYaP5gH61EmH+GNMJm4+loadiFbxb/mjBwaiE7XHILBT7NcH5PyIEv96XgWFMzPy0EF4EzpK78kSR3wLhkYABmQu3t/N4FyrruxLPQO2usF8SW5gjomeRwlFFGxQeUmyTfOA9AKjgMJKiLZhZNBug9t9YmhYQQfj3XfVYghekXDfIF6s4zW0QbGtKYB4Nyc9/L/zvTb1uCUECJspPaT1Rqfe78Y9Yz5ySPGH94pecE7wokkYV1QFIceZhDtw3GnYtzlnBucsHHctGvPDja7r0WeW1r++oMoHqREzMlNhTl9V+uGLiwRp+wvNB+QlZRuBaIWbM46Un8D8EUFnZn/b9+X5+Z8AOjxB/dK87RN/0/RqYBriv0KTn5KfVtAmKnfpKZ7jz1IdzVglPM+Kemy0qyc5pwEalISm8H9GnM5iHOfIN142pveic/t66JPsHLM8v+S4izM1BnuqeDBXbmBa5nnSrwyA/03mfJsh9AarR2spOWtoHoXZ3glAhbuel+ZY4dsW5MGgoT4cC+ieQ8KEleIGBBfo7+BB6mqgMKBrnzradOpN2LZPXfVOSUCF6oO2Ld4RbKEY6l0OyHf6bAWoquKFG2I/2KKkdFY3YTvonDU/YdcD2PWAVYD8cyKQROTidYK/omO2H2jCH6Y1i6KsDhXfq8hhf6kWoKYKaVl+vYcXjEyyLyX+yrij9oLk3hQQ5kWnKo3rPr79CHwgxTb+QOT9QVhg29VVoD660b5h/WlLdv//K0DJq+zO+mxBgcMCq8/Kf3q723qlrbsFoUYyb/25lE0TGN4TDArDbZ1hF5hlf37Q9jUrwqLHAQ10Mc1D6RMTHalZQcw7w/bgqRXCS4Jk9yKNhVCvvCH9/Hu62HD4v9XZsxdaeV5qi6YzMjb1blj8OKz+wpDNSg5xJZ0PztSj5VD5YMGrWavb8RUEu7oIFbu4ag+nGA+hLGOl9ygRuoXa+XvopR4fylNs7/PQdlAJyvfIzYjvWlAmiu1tZ0sU28CiChywCOJDrMI9DQb9krY/Ogw7XmAyaVDODog8pnvR8pLcWf5X4mgD6PyEgIf98N83ZGy8RdM/+Yjtv6sK/8i7bUvJ1lUvQOc3TDSCzOKECvtkkSLGKHStpumIGaJ3wESO9xVQiAzSli8c05GbZyu24/baWL9INTEzItOP7IELsCZb0EbTZQR91Gn7UoOUFnquz0D2ysBDdH9KF3lHmI/HBEERwVyft1XvqjyD4ekZl9+lEKP3Me3taRVxiSQMfI8iwXF631vC9SzezTSycTAcIKrH5QTCaS3Kdep8wWyI05D3raB7WATUmx8pRt3pNTo4MH7IAewA+zdO3fIdTIq9McZIbTUXDFl1cDf4Z1jxAv83GKoOZ5MMYj6nlcR+HM8LR4wcWpvHysSvpdooeMglANf9ouF3HcWq7Y60KIPQgZYRmVZI3AiCNdlBI2zzQnue88adUCyd/7tGEpOGgDFO+qbI1g9FpiFmuo3Z1oVhc64Jg9f0bdAoIVOX7dqjSffs4za9oL9AxFp2dzWb8UD+/pEmAqjoPSLZncpElG5GPvUQBn/oXQaKsotE0Nn5YUSMFZr18hAE3N0Ww8HiQN5miSNdLB2fwuITBvBhBMx0q2slrxyS9wvnuoU73hwUMJbgta1MIXI2wiJmSLabClNzv16WZOYNzs3pLXTq76l5/do/o9wc6SO1lTlmGN10edDpddxVDKQkba9WMxsplFOqYop2epYOJP33n/4FudsRMLi7fRcC2xW2Z3Bmn8PjiZCpDbNLTfK4PYMC3Nj4paDjXN+36LJJcFyGJfZnJsu01rKx5CoKZvZYERJJ6Av92/tL6aPvNF7Rx1DMMvvvNJCsHpIflHayznVErNb3P/23sgVDf+46wafdg3AXAtI57KAyJQczqv0O4xatAq03sb4JAllvF0jsteOxAtJSnne2QEylUMbSPdACtwUgtpakWwdQYjs+i/fQ2hG3QTvtf9XzFq50hguKofyIpltlqFWW6uMCD/WERhKvlQONg9wwLB/Fv/dA7IViuI9RskOhiJj/KiicYKJ1Ww7QDQ9Cx1zj6v9/8CGBClPcaMcbv2cFLYutwtuVI+yvhH3/TrAbTbDJ4PHvYbitRATlIqd9tpKFtKlBcbY267/Fy6ZJVljdJH/Fbcn9N7oMq1eJ4Beghw5qG2e6A0/X+cnTq92lnDEu6eoycFQUJJIsxPjeWrBasZNWPoXLktlkcA3a9co1yNWG+OiuqaLpwizi7oXMu0jRaW4hSKWMOnb7tbUzzam1d2BA6R9EMoQoTAnrzElhXzx34Czmz9J94407XBqsFguGsMUydB08t0CuXPW2Cd9dzj/ZUQ5lAOUecbtT/DLyyy8aCAXknw4jpVaS3YM4j8RKn3REEhIFVH1vR8uZ0Orm0BdOHbpsh/baj0t/LZgtPypItpDe0ldIK3KFyeSXDpKMnGVlhg5/WN7oQK+iGKQ6QyCFMBysGbYlVWtPtwHfa2ndzUsD+z5ZuGm+j5ytZGSzjXbiEzr6ST9ZHu9rpTTVh2Ja7vERKXxLfAkurg2vyb3iwv0UHHA8GPZ2xAuZZ8HQuHSwjKNcbsTrXetyX0UwJkEIM+E5ntfUCMWlxwFuXeis8Z4Wf8+jcEa7P2JiwlOo5sgjp+3JOA2rGHimAD8kpkUJSQn9tUmyNN4/Fwzyv1N0f0qlyBRIKYATZD7hbAhwNyC5HnPWkSgzT/ZDJ1KauWN01xNPJczj/Z4zsewF85IVrEe/uMYm5QWFV8RpJC+r00dM4c4XEl5TObUR0qzeNl7rTeOPwOoN6oWQifDc91pF5qIP1qkxwl2LQnyvRn6nV96GhojUYGS5dt7P+plInXw7kHzklqJqfdl082nhFrbFpfJoZcnBWB+RTR6SWsRuMO8vmNMQsOmM+feqVK5Zal67etiqk9uTLTkWDHhtc3fMLQcQA9pjxo/mfF7kpYRmXLT/q/WwbsdkhD1sc4HC9yz09+RDAGMB/C6tah6pXNLQzx0mSls7SZ2c8EUO1yX50hVhlB/Z0y/d0AyYLD8zQndyOnKHz4tmpYACS7dDAwIRhi4WuhbtZwG6rcwDVFiXsyWRZDGsgQk/wGXW73rOg95ekHk+BonpPZjZPNO+4YGkP5wU93+3DDf4skVzzX6P4PDvxoIGqOqdSqfGexEauQ/BeO9+yv5ayqNrfsgttCyyzLK3UYi1g+DeEKgzWcrUcnnMF22DnuBP9JyHrYsORBWS9wMTAQsXVB+LfmPDhdtlL50Xx+ye6ZecorwSQHituNWRc5FTlusvqQu3uFcmCKqxmB8DgM5qNxgrpZ97kc1t2I8o6EUSwr29DHwQge9M6P8R/5aJhl01t2qJC2kGli1DwLjFTvqoeyELI9gaNeEoWc1g8FehjK3cXFrgYI5FY0b5kS/a0eK87k1ZNrNeUaOcw4yIY1o+t0lRuavnJwtat9+ZUHskI5xu1l7Uct3OJ2dy8B8bYFnDnlXfMMmw8n5QAdUz1fFG2e1R+tFsZOe5wWq5FYrZ7OrAtfWJQJcJpNzSRs7Dvg/lE1vX2OEiMfFw7t0Y1IQuHxes6YJMyKJczF7bXzN0I5koImue5OnKtjCL9BoTVsZiP0bJ4RFMYYzAAmturXsP0k7tJ+MsGxDf0xDcmBLOZZbIhAiCwxYxSppXPDRavJk9AwAerfuBx7TTZ0MoXbFuv6HGZ3USiGlQwVYrQyOWtUh1675ILLwsPh+0w86yArzewZAh0XzTslXNr+t+ggaF2Glb6J6LQ6+/bwIRNNrKtbQofhVMl/yE3FLxOUGbxjmfxcskmVcaN5gTz/K48HL+zSqUWpdPAbh+fYl8RGOGu/ebGXm5fByu8aFiDJvzPDpa3xY03lMkmABFd5kK5TEpnAmt1ohnfDwUeGDNYEt6vN6murXouKzYwhd/PjEydWsDdr75Gfo5GRsiZEANcgB1yJ3yQgSfM7ZLPqpqN0wwEJV/hFkItgzIfSEnES7AMlv5Q42K0nYh+nyxELcZj38IJS5dnabZeWCuPo27kp4G1lUdfUFQkJNn+hnL+4rHKoYr5ikGKq6VYr6irhinGKTIK0Yoxij8UxkcrOJaKwYpZioOToHcq/EI9eASyArXEmyTI2KBdWbIAWM43HLnvgXAdXd4PyexzR9kS6IL8W1Lh1hu1C8JQgR7L2k7Fg5jExUg713zCc+ncZLIzLRCadZ8AhMCNXrrP3psKzXgJBHNOhARxrEENsN0kd/xlKTAQLN9zlNd7M6MEJJrkb0kxYcabm7jeF6a2b3ZwstgvrevQ3/0La1jCdzLFqgfNOJZluvBfM95lAAx2wJKxOHD/rf3CvzQ298kcRvx46BNNmIlVbaW2q+BwrkZcz0J8NrnFgqfBiB/CgCdzwQg19AqTOwo5pv8mnqpMBLrCXUPJ6pF6epOQyZOJoBLpI16oujytGTeYhoRWMbFq/cM0X0qBQ8HfLJpcf+Ise0YJnnuc6eGnSo3bUbnYrAdII+OEe1c3T11xpGRBLzzp1cgCmC26GJbIHdjN0f+Yifj52k8asWzohmRJwFqJx21yauiIZTEJZ49ugeQuaffGfTy74i3w/dVY7Yn4+Z0yecPH5sZy4gPEK4KZ2jmoMuwX6d0dbJ6sKRAx4odzcM+dRc+WhqGvVg+wjvgn3ZrsJ76vNa5gpfAb3OqbD/KsQHV96PpsfmvACNnwJD7yCXuMxis+Pa5nn74Lj6XUo++0IrTuUwvuo7jKYu7tpkLfm5Wo0rO3EXlLY23pbRvKe4x25PeAc4Yfhtl6bfl+UZaxQnxy3qyuPPxIr5NUCTbLTqCG7BYeX1D4wPYnyyMefDZ9hZwKD83x0iQl2gVc+TtPxDLn/eHdMdrlS4r92RhT7m136WNc9obLS/1xQNs2EKDx/14T4EDeoWUo/dLALPkBtvjUfhox4NrLOY5+DGkkoq2EE+fK2DDnNtLQ1uUMzKJdWoIkalHZW9OIp1y5eMAM/IAEZt5lRPxi84Sod72LZikJQp5J2TRHvjm/i0c0GZoibLJc83rRdCn8AA=","base64")).toString()),GL)});var Rle=w(qL=>{function nh(t,e){if(typeof t=="string")return t;if(t){let r,i;if(Array.isArray(t)){for(r=0;r<t.length;r++)if(i=nh(t[r],e))return i}else for(r in t)if(e.has(r))return nh(t[r],e)}}function bu(t,e,r){throw new Error(r?`No known conditions for "${e}" entry in "${t}" package`:`Missing "${e}" export in "${t}" package`)}function Dle(t,e){return e===t?".":e[0]==="."?e:e.replace(new RegExp("^"+t+"/"),"./")}function s_e(t,e=".",r={}){let{name:i,exports:n}=t;if(n){let{browser:s,require:o,unsafe:a,conditions:l=[]}=r,c=Dle(i,e);if(c[0]!=="."&&(c="./"+c),typeof n=="string")return c==="."?n:bu(i,c);let u=new Set(["default",...l]);a||u.add(o?"require":"import"),a||u.add(s?"browser":"node");let g,f,h=!1;for(g in n){h=g[0]!==".";break}if(h)return c==="."?nh(n,u)||bu(i,c,1):bu(i,c);if(f=n[c])return nh(f,u)||bu(i,c,1);for(g in n){if(f=g[g.length-1],f==="/"&&c.startsWith(g))return(f=nh(n[g],u))?f+c.substring(g.length):bu(i,c,1);if(f==="*"&&c.startsWith(g.slice(0,-1))&&c.substring(g.length-1).length>0)return(f=nh(n[g],u))?f.replace("*",c.substring(g.length-1)):bu(i,c,1)}return bu(i,c)}}function o_e(t,e={}){let r=0,i,n=e.browser,s=e.fields||["module","main"];for(n&&!s.includes("browser")&&s.unshift("browser");r<s.length;r++)if(i=t[s[r]]){if(typeof i!="string")if(typeof i=="object"&&s[r]=="browser"){if(typeof n=="string"&&(i=i[n=Dle(t.name,n)],i==null))return n}else continue;return typeof i=="string"?"./"+i.replace(/^\.?\//,""):i}}qL.legacy=o_e;qL.resolve=s_e});var Ule=w((XQt,Mle)=>{var zL;Mle.exports=()=>(typeof zL=="undefined"&&(zL=require("zlib").brotliDecompressSync(Buffer.from("G10hAKwOjG0Yab+syByiPMj3Q6L/91P/Pz9ftxtaCkm69dCxllpxlwzlCWleq7QCiMzOBOnxaaciKKlEbkt1vTwowg4cBnvOTQ6v///TzIWNIQO4m1IpiO5rsaSx3DTeVsr7/0vTt7VxWoc5ATBBQZ3xpWGBzAAG0Cxj6rO6dzCPECCAPbqxE3V+Ay+/JsUie1t8rnY3FEx3PjtdsQjWtbh0aVKPltwOcp3P60quHwhX3vGre2dp5M9BWjbXTqzkGSb7JAOCIFDkCHdzdg2/so+h6QAEDXi/5bNCnzcbANf9gR8nchF08zZC2tiz4IaIsit+PG/sa6DsrJy+fAbaFgJ+jWssxm4nUWoDpZqWuaTl/9sMrDCw96fOoarS8j9wUYb8YjJNgUMY+JDKCJ9FQO+uukYMId0wwrGTFFJA3EUpzXCFpgHFI/PpCi0etu/WLRL4oSv1pnAGi6KVk016fl10lGn4hAQwg26BalH9YoQJ0OmyatUSJBz7wnzei5EjTW45x7IhK0L2AAdT0ky0X/5laDXRfxID6pCpsKstprVYxIO47BZUgOUzJ9ysBxzcO0f0oM4URmRK/OPDKwClDztMRXM7T4COaj2DoqqJACT1mukaVYHpkK0NqIEJTQUuWMEfZar5scbsU6VCSQBb05UFgnXpY3baEvvdlFqCHI7mPLxJ1WX4b3bydzj2hbxo9e9g9TNw6DKeZyhf6cVRiPbKPvMZ8qnP8B5a9EZzp56asmqKuWNSJuxeSivzrKYcT4s9SOH8qhldXcFXneERU1mu9YVZ5mr+7igGFDXaj90vnJTcr1Hri6MgZ4j/k4yX2PUqqjg3XCNDCTYJ+o53+tNU15DNgvC/PG6+IQaG87AHtJ7+NAIcQljQw/3ACsmiSrPfXa1+3GesILwL4epZQv0HjBkxC1hu8nM2cRqKxxWZxEOMT4aQeDHbT78cEmy+IRB8PmzFEnJPv7ThBPsv2IPRir0cSivDliSsvXhF1lbeI79qWZbOSEc7cwefMoNCR1GYbttWyvBqLe2kWVEj0SvwgMD1/UvSIR0UWuKc0SS/B+V3LnbBqxBtgIgMR+h3pCn4IgBuoVzaJR27QvIhoQS2M5YGDvCNT1FRHZmqHkpa4aDdGUm5eN6jM2VTwxoRa+pUjTGhpukbiTG6AwAZYEkN9aguoSx4sCgHb1XmHjHfcMit9srQlay1sKTIaAGFi5+FjpUhrkLHZQ+sAG0vBeFJ5WqLBS0C7FwXU6/ICtAacCIr60pLubC1MDfqRiGdVrsJF6okGq3x1hzUWqIfkE/0P1g6fSHOLgLi5tNiekBgh6XUhzjj9NMvE0TvMRg7EBaEBh0dcZqMAqSEY240hOeFE5NvC4wzKPXYuNJz1/19xCQXiZsAWBVxXPKoZ6fO5yB0CENDNU4QULqzC9RUYqqIpwTiIAnmvDC+4MRdnk+piN4AYFZdTQFT3KYh4tOZbXCr8fdrU5PttppEAeVPCBvKmnbRQn7bi5j48v+DH1p0lUp+tKquDCMCYewaw67sxomdpb+iNekQAcn9x7NoURWhq+nHCjoeaOFkhFJkvgFh2obz3gk3Qh870MuhaaTHJ1MzKYkObGNHbNYOoW2ooJhFLmb7ULrzTYxWswKzgf31/h7IPbm32Qqe+hRzWGorLZklw2UIanlp1YUrj6MwAs4DN/AHN2qogzfhWNgDiEkcvUtvQFTcRWlDg4q3LfdHdfFT8yJnqNDhxFj2GVrsmtYEwEMFQFQzzOZymRLff/x+4mFbbFl+5Ly4a76Hytg6JYdtRrQ+dwYZlNHfm2wEvPAwIlYSFwjOpEMH+fYuOOh6+49K7Eli1/Q2jwvQXsjKnXi8aRwvc6wHCY8DuCFs8UzVmwzACi1+6quw6RThnsB1kI57SVOvCtri3qisoVeToL2Y9Baqs9DrJnVqwlQ6WbWIKak+9E620Nz/71qUKg3M2wnnbbfaXRIJXbQ3cWA4b4iB+ydcmIZM856R9IRQiVSUYjZbz2cjpD6Olb2Z5TzP91nQNo5/Q+vTpktm0Z7js3YeY/TbzLJKMwfvnbfvfbFgpduBXj7ED99dRnS57bzoSuelPfSYIuMnhL/QZ8w1KcnF2af2vctO9H5JLrNaV+UjwlDtxrjE+geaXJbpfasDJS3wHmZLKAc3Fdq2QZaQI/Rlo5+9E9saUAo/HTgak7oZx5BHE0XzMI0B7cawJ4vf8QDsxabkLDNamLr3dIPslpjWhh4GYG6W+QZsuloc3IWUXc55gQXOzamOhkNA3HAfBXuMbO00DE4vuZGaoVpFSrIf5QynRExGY2cKaTa0B2wvYAVoM0NmsEaMyyS3y+VaTH4HzbR/xYSRfhZXFphd0pAYX1uFkNbznojgKfJsP8r/UR55Pk5Av1jpqwbApXPNaIFlF60jSNui1XwNPGW0iXro2Ut51e/bZZY2/SOMHras0doh5hF6k0rf+Noim8SUvNQNW52hB+YICpq9RD1c3/Q1+AF3SicEsOVw6WgV6fC61s86YzbVTZ+MzCwDuc5CVTcdIrrbF1Dr/3LhJa6R3M6XJh99Hb7oBZK5epJfcZ9qZNWP2Saae5bmGymX7FKXNsJJXATykPE0gd0402Yw4WBlidYmSz7Dk+0uc56VJWCUC7brO9Iz/RrxTpkoAIYctGl1llfEssHho7n+wj25Fh2FWgZsz7VxPxS1oGEK+O9+Xiw8PQau31vfWt9e21zfdkEHo1uLlDbZhrRzdr2XpwQmskOGMw2BOv8CgJpvdihPYZEb+WA8uGeIu4BXWmSrQASBYgLMLujbUOaQL9encmSl7p8qz1RBH8iFp6MqMpvrB+K18syfqPwQTbMfEoxh+OgpLwfho6epE5k+vEcxrmazPR4NB9ujzx/M71olQWlkvqOXIoh2tF9nhLWv0CDo8GHhcoApUXN3VK7TeaVLLKabeuAtTa9yEf6cbKz0M4IKTMeRQmiz0hX++RAp+DMEet3ea91xlD+g1NVIppHJ0nPVUAPfRnLdqd4mtG3Idl7L4uKiNIDIgub6tGxM2TmAutpayo8HzjIoXgw+JMEbeL5Bu7d7at40w5bGj7lSO12dwvkBaMQZIGIUBGmOxBMlAQ==","base64")).toString()),zL)});var Wle=w((tT,rT)=>{(function(t){tT&&typeof tT=="object"&&typeof rT!="undefined"?rT.exports=t():typeof define=="function"&&define.amd?define([],t):typeof window!="undefined"?window.isWindows=t():typeof global!="undefined"?global.isWindows=t():typeof self!="undefined"?self.isWindows=t():this.isWindows=t()})(function(){"use strict";return function(){return process&&(process.platform==="win32"||/^(msys|cygwin)$/.test(process.env.OSTYPE))}})});var Xle=w((iSt,zle)=>{"use strict";iT.ifExists=E_e;var oh=require("util"),Js=require("path"),_le=Wle(),I_e=/^#!\s*(?:\/usr\/bin\/env)?\s*([^ \t]+)(.*)$/,y_e={createPwshFile:!0,createCmdFile:_le(),fs:require("fs")},w_e=new Map([[".js","node"],[".cjs","node"],[".mjs","node"],[".cmd","cmd"],[".bat","cmd"],[".ps1","pwsh"],[".sh","sh"]]);function Vle(t){let e=N(N({},y_e),t),r=e.fs;return e.fs_={chmod:r.chmod?oh.promisify(r.chmod):async()=>{},mkdir:oh.promisify(r.mkdir),readFile:oh.promisify(r.readFile),stat:oh.promisify(r.stat),unlink:oh.promisify(r.unlink),writeFile:oh.promisify(r.writeFile)},e}async function iT(t,e,r){let i=Vle(r);await i.fs_.stat(t),await B_e(t,e,i)}function E_e(t,e,r){return iT(t,e,r).catch(()=>{})}function b_e(t,e){return e.fs_.unlink(t).catch(()=>{})}async function B_e(t,e,r){let i=await S_e(t,r);return await Q_e(e,r),v_e(t,e,i,r)}function Q_e(t,e){return e.fs_.mkdir(Js.dirname(t),{recursive:!0})}function v_e(t,e,r,i){let n=Vle(i),s=[{generator:P_e,extension:""}];return n.createCmdFile&&s.push({generator:x_e,extension:".cmd"}),n.createPwshFile&&s.push({generator:D_e,extension:".ps1"}),Promise.all(s.map(o=>k_e(t,e+o.extension,r,o.generator,n)))}function R_e(t,e){return b_e(t,e)}function N_e(t,e){return F_e(t,e)}async function S_e(t,e){let n=(await e.fs_.readFile(t,"utf8")).trim().split(/\r*\n/)[0].match(I_e);if(!n){let s=Js.extname(t).toLowerCase();return{program:w_e.get(s)||null,additionalArgs:""}}return{program:n[1],additionalArgs:n[2]}}async function k_e(t,e,r,i,n){let s=n.preserveSymlinks?"--preserve-symlinks":"",o=[r.additionalArgs,s].filter(a=>a).join(" ");return n=Object.assign({},n,{prog:r.program,args:o}),await R_e(e,n),await n.fs_.writeFile(e,i(t,e,n),"utf8"),N_e(e,n)}function x_e(t,e,r){let n=Js.relative(Js.dirname(e),t).split("/").join("\\"),s=Js.isAbsolute(n)?`"${n}"`:`"%~dp0\\${n}"`,o,a=r.prog,l=r.args||"",c=nT(r.nodePath).win32;a?(o=`"%~dp0\\${a}.exe"`,n=s):(a=s,l="",n="");let u=r.progArgs?`${r.progArgs.join(" ")} `:"",g=c?`@SET NODE_PATH=${c}\r
+`:"";return o?g+=`@IF EXIST ${o} (\r
+  ${o} ${l} ${n} ${u}%*\r
+) ELSE (\r
+  @SETLOCAL\r
+  @SET PATHEXT=%PATHEXT:;.JS;=;%\r
+  ${a} ${l} ${n} ${u}%*\r
+)\r
+`:g+=`@${a} ${l} ${n} ${u}%*\r
+`,g}function P_e(t,e,r){let i=Js.relative(Js.dirname(e),t),n=r.prog&&r.prog.split("\\").join("/"),s;i=i.split("\\").join("/");let o=Js.isAbsolute(i)?`"${i}"`:`"$basedir/${i}"`,a=r.args||"",l=nT(r.nodePath).posix;n?(s=`"$basedir/${r.prog}"`,i=o):(n=o,a="",i="");let c=r.progArgs?`${r.progArgs.join(" ")} `:"",u=`#!/bin/sh
+basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")
+
+case \`uname\` in
+    *CYGWIN*) basedir=\`cygpath -w "$basedir"\`;;
+esac
+
+`,g=r.nodePath?`export NODE_PATH="${l}"
+`:"";return s?u+=`${g}if [ -x ${s} ]; then
+  exec ${s} ${a} ${i} ${c}"$@"
+else
+  exec ${n} ${a} ${i} ${c}"$@"
+fi
+`:u+=`${g}${n} ${a} ${i} ${c}"$@"
+exit $?
+`,u}function D_e(t,e,r){let i=Js.relative(Js.dirname(e),t),n=r.prog&&r.prog.split("\\").join("/"),s=n&&`"${n}$exe"`,o;i=i.split("\\").join("/");let a=Js.isAbsolute(i)?`"${i}"`:`"$basedir/${i}"`,l=r.args||"",c=nT(r.nodePath),u=c.win32,g=c.posix;s?(o=`"$basedir/${r.prog}$exe"`,i=a):(s=a,l="",i="");let f=r.progArgs?`${r.progArgs.join(" ")} `:"",h=`#!/usr/bin/env pwsh
+$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
+
+$exe=""
+${r.nodePath?`$env_node_path=$env:NODE_PATH
+$env:NODE_PATH="${u}"
+`:""}if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
+  # Fix case when both the Windows and Linux builds of Node
+  # are installed in the same directory
+  $exe=".exe"
+}`;return r.nodePath&&(h+=` else {
+  $env:NODE_PATH="${g}"
+}`),o?h+=`
+$ret=0
+if (Test-Path ${o}) {
+  # Support pipeline input
+  if ($MyInvocation.ExpectingInput) {
+    $input | & ${o} ${l} ${i} ${f}$args
+  } else {
+    & ${o} ${l} ${i} ${f}$args
+  }
+  $ret=$LASTEXITCODE
+} else {
+  # Support pipeline input
+  if ($MyInvocation.ExpectingInput) {
+    $input | & ${s} ${l} ${i} ${f}$args
+  } else {
+    & ${s} ${l} ${i} ${f}$args
+  }
+  $ret=$LASTEXITCODE
+}
+${r.nodePath?`$env:NODE_PATH=$env_node_path
+`:""}exit $ret
+`:h+=`
+# Support pipeline input
+if ($MyInvocation.ExpectingInput) {
+  $input | & ${s} ${l} ${i} ${f}$args
+} else {
+  & ${s} ${l} ${i} ${f}$args
+}
+${r.nodePath?`$env:NODE_PATH=$env_node_path
+`:""}exit $LASTEXITCODE
+`,h}function F_e(t,e){return e.fs_.chmod(t,493)}function nT(t){if(!t)return{win32:"",posix:""};let e=typeof t=="string"?t.split(Js.delimiter):Array.from(t),r={};for(let i=0;i<e.length;i++){let n=e[i].split("/").join("\\"),s=_le()?e[i].split("\\").join("/").replace(/^([^:\\/]*):/,(o,a)=>`/mnt/${a.toLowerCase()}`):e[i];r.win32=r.win32?`${r.win32};${n}`:n,r.posix=r.posix?`${r.posix}:${s}`:s,r[i]={win32:n,posix:s}}return r}zle.exports=iT});var IT=w((Ukt,Cce)=>{Cce.exports=require("stream")});var yce=w((Kkt,mce)=>{"use strict";function Ece(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter(function(n){return Object.getOwnPropertyDescriptor(t,n).enumerable})),r.push.apply(r,i)}return r}function e5e(t){for(var e=1;e<arguments.length;e++){var r=arguments[e]!=null?arguments[e]:{};e%2?Ece(Object(r),!0).forEach(function(i){$_e(t,i,r[i])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(r)):Ece(Object(r)).forEach(function(i){Object.defineProperty(t,i,Object.getOwnPropertyDescriptor(r,i))})}return t}function $_e(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}function t5e(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function Ice(t,e){for(var r=0;r<e.length;r++){var i=e[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,i.key,i)}}function r5e(t,e,r){return e&&Ice(t.prototype,e),r&&Ice(t,r),t}var i5e=require("buffer"),mb=i5e.Buffer,n5e=require("util"),yT=n5e.inspect,s5e=yT&&yT.custom||"inspect";function o5e(t,e,r){mb.prototype.copy.call(t,e,r)}mce.exports=function(){function t(){t5e(this,t),this.head=null,this.tail=null,this.length=0}return r5e(t,[{key:"push",value:function(r){var i={data:r,next:null};this.length>0?this.tail.next=i:this.head=i,this.tail=i,++this.length}},{key:"unshift",value:function(r){var i={data:r,next:this.head};this.length===0&&(this.tail=i),this.head=i,++this.length}},{key:"shift",value:function(){if(this.length!==0){var r=this.head.data;return this.length===1?this.head=this.tail=null:this.head=this.head.next,--this.length,r}}},{key:"clear",value:function(){this.head=this.tail=null,this.length=0}},{key:"join",value:function(r){if(this.length===0)return"";for(var i=this.head,n=""+i.data;i=i.next;)n+=r+i.data;return n}},{key:"concat",value:function(r){if(this.length===0)return mb.alloc(0);for(var i=mb.allocUnsafe(r>>>0),n=this.head,s=0;n;)o5e(n.data,i,s),s+=n.data.length,n=n.next;return i}},{key:"consume",value:function(r,i){var n;return r<this.head.data.length?(n=this.head.data.slice(0,r),this.head.data=this.head.data.slice(r)):r===this.head.data.length?n=this.shift():n=i?this._getString(r):this._getBuffer(r),n}},{key:"first",value:function(){return this.head.data}},{key:"_getString",value:function(r){var i=this.head,n=1,s=i.data;for(r-=s.length;i=i.next;){var o=i.data,a=r>o.length?o.length:r;if(a===o.length?s+=o:s+=o.slice(0,r),r-=a,r===0){a===o.length?(++n,i.next?this.head=i.next:this.head=this.tail=null):(this.head=i,i.data=o.slice(a));break}++n}return this.length-=n,s}},{key:"_getBuffer",value:function(r){var i=mb.allocUnsafe(r),n=this.head,s=1;for(n.data.copy(i),r-=n.data.length;n=n.next;){var o=n.data,a=r>o.length?o.length:r;if(o.copy(i,i.length-r,0,a),r-=a,r===0){a===o.length?(++s,n.next?this.head=n.next:this.head=this.tail=null):(this.head=n,n.data=o.slice(a));break}++s}return this.length-=s,i}},{key:s5e,value:function(r,i){return yT(this,e5e({},i,{depth:0,customInspect:!1}))}}]),t}()});var BT=w((Hkt,wce)=>{"use strict";function a5e(t,e){var r=this,i=this._readableState&&this._readableState.destroyed,n=this._writableState&&this._writableState.destroyed;return i||n?(e?e(t):t&&(this._writableState?this._writableState.errorEmitted||(this._writableState.errorEmitted=!0,process.nextTick(wT,this,t)):process.nextTick(wT,this,t)),this):(this._readableState&&(this._readableState.destroyed=!0),this._writableState&&(this._writableState.destroyed=!0),this._destroy(t||null,function(s){!e&&s?r._writableState?r._writableState.errorEmitted?process.nextTick(Eb,r):(r._writableState.errorEmitted=!0,process.nextTick(Bce,r,s)):process.nextTick(Bce,r,s):e?(process.nextTick(Eb,r),e(s)):process.nextTick(Eb,r)}),this)}function Bce(t,e){wT(t,e),Eb(t)}function Eb(t){t._writableState&&!t._writableState.emitClose||t._readableState&&!t._readableState.emitClose||t.emit("close")}function A5e(){this._readableState&&(this._readableState.destroyed=!1,this._readableState.reading=!1,this._readableState.ended=!1,this._readableState.endEmitted=!1),this._writableState&&(this._writableState.destroyed=!1,this._writableState.ended=!1,this._writableState.ending=!1,this._writableState.finalCalled=!1,this._writableState.prefinished=!1,this._writableState.finished=!1,this._writableState.errorEmitted=!1)}function wT(t,e){t.emit("error",e)}function l5e(t,e){var r=t._readableState,i=t._writableState;r&&r.autoDestroy||i&&i.autoDestroy?t.destroy(e):t.emit("error",e)}wce.exports={destroy:a5e,undestroy:A5e,errorOrDestroy:l5e}});var Nl=w((jkt,bce)=>{"use strict";var Qce={};function Ws(t,e,r){r||(r=Error);function i(s,o,a){return typeof e=="string"?e:e(s,o,a)}class n extends r{constructor(o,a,l){super(i(o,a,l))}}n.prototype.name=r.name,n.prototype.code=t,Qce[t]=n}function vce(t,e){if(Array.isArray(t)){let r=t.length;return t=t.map(i=>String(i)),r>2?`one of ${e} ${t.slice(0,r-1).join(", ")}, or `+t[r-1]:r===2?`one of ${e} ${t[0]} or ${t[1]}`:`of ${e} ${t[0]}`}else return`of ${e} ${String(t)}`}function c5e(t,e,r){return t.substr(!r||r<0?0:+r,e.length)===e}function u5e(t,e,r){return(r===void 0||r>t.length)&&(r=t.length),t.substring(r-e.length,r)===e}function g5e(t,e,r){return typeof r!="number"&&(r=0),r+e.length>t.length?!1:t.indexOf(e,r)!==-1}Ws("ERR_INVALID_OPT_VALUE",function(t,e){return'The value "'+e+'" is invalid for option "'+t+'"'},TypeError);Ws("ERR_INVALID_ARG_TYPE",function(t,e,r){let i;typeof e=="string"&&c5e(e,"not ")?(i="must not be",e=e.replace(/^not /,"")):i="must be";let n;if(u5e(t," argument"))n=`The ${t} ${i} ${vce(e,"type")}`;else{let s=g5e(t,".")?"property":"argument";n=`The "${t}" ${s} ${i} ${vce(e,"type")}`}return n+=`. Received type ${typeof r}`,n},TypeError);Ws("ERR_STREAM_PUSH_AFTER_EOF","stream.push() after EOF");Ws("ERR_METHOD_NOT_IMPLEMENTED",function(t){return"The "+t+" method is not implemented"});Ws("ERR_STREAM_PREMATURE_CLOSE","Premature close");Ws("ERR_STREAM_DESTROYED",function(t){return"Cannot call "+t+" after a stream was destroyed"});Ws("ERR_MULTIPLE_CALLBACK","Callback called multiple times");Ws("ERR_STREAM_CANNOT_PIPE","Cannot pipe, not readable");Ws("ERR_STREAM_WRITE_AFTER_END","write after end");Ws("ERR_STREAM_NULL_VALUES","May not write null values to stream",TypeError);Ws("ERR_UNKNOWN_ENCODING",function(t){return"Unknown encoding: "+t},TypeError);Ws("ERR_STREAM_UNSHIFT_AFTER_END_EVENT","stream.unshift() after end event");bce.exports.codes=Qce});var bT=w((Gkt,Sce)=>{"use strict";var f5e=Nl().codes.ERR_INVALID_OPT_VALUE;function h5e(t,e,r){return t.highWaterMark!=null?t.highWaterMark:e?t[r]:null}function p5e(t,e,r,i){var n=h5e(e,i,r);if(n!=null){if(!(isFinite(n)&&Math.floor(n)===n)||n<0){var s=i?r:"highWaterMark";throw new f5e(s,n)}return Math.floor(n)}return t.objectMode?16:16*1024}Sce.exports={getHighWaterMark:p5e}});var kce=w((Ykt,QT)=>{typeof Object.create=="function"?QT.exports=function(e,r){r&&(e.super_=r,e.prototype=Object.create(r.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}))}:QT.exports=function(e,r){if(r){e.super_=r;var i=function(){};i.prototype=r.prototype,e.prototype=new i,e.prototype.constructor=e}}});var Ll=w((qkt,vT)=>{try{if(ST=require("util"),typeof ST.inherits!="function")throw"";vT.exports=ST.inherits}catch(t){vT.exports=kce()}var ST});var Pce=w((Jkt,xce)=>{xce.exports=require("util").deprecate});var PT=w((Wkt,Dce)=>{"use strict";Dce.exports=jr;function Rce(t){var e=this;this.next=null,this.entry=null,this.finish=function(){d5e(e,t)}}var lh;jr.WritableState=_m;var C5e={deprecate:Pce()},Fce=IT(),Ib=require("buffer").Buffer,m5e=global.Uint8Array||function(){};function E5e(t){return Ib.from(t)}function I5e(t){return Ib.isBuffer(t)||t instanceof m5e}var kT=BT(),y5e=bT(),w5e=y5e.getHighWaterMark,Tl=Nl().codes,B5e=Tl.ERR_INVALID_ARG_TYPE,b5e=Tl.ERR_METHOD_NOT_IMPLEMENTED,Q5e=Tl.ERR_MULTIPLE_CALLBACK,v5e=Tl.ERR_STREAM_CANNOT_PIPE,S5e=Tl.ERR_STREAM_DESTROYED,k5e=Tl.ERR_STREAM_NULL_VALUES,x5e=Tl.ERR_STREAM_WRITE_AFTER_END,P5e=Tl.ERR_UNKNOWN_ENCODING,ch=kT.errorOrDestroy;Ll()(jr,Fce);function D5e(){}function _m(t,e,r){lh=lh||vu(),t=t||{},typeof r!="boolean"&&(r=e instanceof lh),this.objectMode=!!t.objectMode,r&&(this.objectMode=this.objectMode||!!t.writableObjectMode),this.highWaterMark=w5e(this,t,"writableHighWaterMark",r),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var i=t.decodeStrings===!1;this.decodeStrings=!i,this.defaultEncoding=t.defaultEncoding||"utf8",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(n){R5e(e,n)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.emitClose=t.emitClose!==!1,this.autoDestroy=!!t.autoDestroy,this.bufferedRequestCount=0,this.corkedRequestsFree=new Rce(this)}_m.prototype.getBuffer=function(){for(var e=this.bufferedRequest,r=[];e;)r.push(e),e=e.next;return r};(function(){try{Object.defineProperty(_m.prototype,"buffer",{get:C5e.deprecate(function(){return this.getBuffer()},"_writableState.buffer is deprecated. Use _writableState.getBuffer instead.","DEP0003")})}catch(t){}})();var yb;typeof Symbol=="function"&&Symbol.hasInstance&&typeof Function.prototype[Symbol.hasInstance]=="function"?(yb=Function.prototype[Symbol.hasInstance],Object.defineProperty(jr,Symbol.hasInstance,{value:function(e){return yb.call(this,e)?!0:this!==jr?!1:e&&e._writableState instanceof _m}})):yb=function(e){return e instanceof this};function jr(t){lh=lh||vu();var e=this instanceof lh;if(!e&&!yb.call(jr,this))return new jr(t);this._writableState=new _m(t,this,e),this.writable=!0,t&&(typeof t.write=="function"&&(this._write=t.write),typeof t.writev=="function"&&(this._writev=t.writev),typeof t.destroy=="function"&&(this._destroy=t.destroy),typeof t.final=="function"&&(this._final=t.final)),Fce.call(this)}jr.prototype.pipe=function(){ch(this,new v5e)};function F5e(t,e){var r=new x5e;ch(t,r),process.nextTick(e,r)}function N5e(t,e,r,i){var n;return r===null?n=new k5e:typeof r!="string"&&!e.objectMode&&(n=new B5e("chunk",["string","Buffer"],r)),n?(ch(t,n),process.nextTick(i,n),!1):!0}jr.prototype.write=function(t,e,r){var i=this._writableState,n=!1,s=!i.objectMode&&I5e(t);return s&&!Ib.isBuffer(t)&&(t=E5e(t)),typeof e=="function"&&(r=e,e=null),s?e="buffer":e||(e=i.defaultEncoding),typeof r!="function"&&(r=D5e),i.ending?F5e(this,r):(s||N5e(this,i,t,r))&&(i.pendingcb++,n=L5e(this,i,s,t,e,r)),n};jr.prototype.cork=function(){this._writableState.corked++};jr.prototype.uncork=function(){var t=this._writableState;t.corked&&(t.corked--,!t.writing&&!t.corked&&!t.bufferProcessing&&t.bufferedRequest&&Nce(this,t))};jr.prototype.setDefaultEncoding=function(e){if(typeof e=="string"&&(e=e.toLowerCase()),!(["hex","utf8","utf-8","ascii","binary","base64","ucs2","ucs-2","utf16le","utf-16le","raw"].indexOf((e+"").toLowerCase())>-1))throw new P5e(e);return this._writableState.defaultEncoding=e,this};Object.defineProperty(jr.prototype,"writableBuffer",{enumerable:!1,get:function(){return this._writableState&&this._writableState.getBuffer()}});function T5e(t,e,r){return!t.objectMode&&t.decodeStrings!==!1&&typeof e=="string"&&(e=Ib.from(e,r)),e}Object.defineProperty(jr.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}});function L5e(t,e,r,i,n,s){if(!r){var o=T5e(e,i,n);i!==o&&(r=!0,n="buffer",i=o)}var a=e.objectMode?1:i.length;e.length+=a;var l=e.length<e.highWaterMark;if(l||(e.needDrain=!0),e.writing||e.corked){var c=e.lastBufferedRequest;e.lastBufferedRequest={chunk:i,encoding:n,isBuf:r,callback:s,next:null},c?c.next=e.lastBufferedRequest:e.bufferedRequest=e.lastBufferedRequest,e.bufferedRequestCount+=1}else xT(t,e,!1,a,i,n,s);return l}function xT(t,e,r,i,n,s,o){e.writelen=i,e.writecb=o,e.writing=!0,e.sync=!0,e.destroyed?e.onwrite(new S5e("write")):r?t._writev(n,e.onwrite):t._write(n,s,e.onwrite),e.sync=!1}function O5e(t,e,r,i,n){--e.pendingcb,r?(process.nextTick(n,i),process.nextTick(Vm,t,e),t._writableState.errorEmitted=!0,ch(t,i)):(n(i),t._writableState.errorEmitted=!0,ch(t,i),Vm(t,e))}function M5e(t){t.writing=!1,t.writecb=null,t.length-=t.writelen,t.writelen=0}function R5e(t,e){var r=t._writableState,i=r.sync,n=r.writecb;if(typeof n!="function")throw new Q5e;if(M5e(r),e)O5e(t,r,i,e,n);else{var s=Tce(r)||t.destroyed;!s&&!r.corked&&!r.bufferProcessing&&r.bufferedRequest&&Nce(t,r),i?process.nextTick(Lce,t,r,s,n):Lce(t,r,s,n)}}function Lce(t,e,r,i){r||U5e(t,e),e.pendingcb--,i(),Vm(t,e)}function U5e(t,e){e.length===0&&e.needDrain&&(e.needDrain=!1,t.emit("drain"))}function Nce(t,e){e.bufferProcessing=!0;var r=e.bufferedRequest;if(t._writev&&r&&r.next){var i=e.bufferedRequestCount,n=new Array(i),s=e.corkedRequestsFree;s.entry=r;for(var o=0,a=!0;r;)n[o]=r,r.isBuf||(a=!1),r=r.next,o+=1;n.allBuffers=a,xT(t,e,!0,e.length,n,"",s.finish),e.pendingcb++,e.lastBufferedRequest=null,s.next?(e.corkedRequestsFree=s.next,s.next=null):e.corkedRequestsFree=new Rce(e),e.bufferedRequestCount=0}else{for(;r;){var l=r.chunk,c=r.encoding,u=r.callback,g=e.objectMode?1:l.length;if(xT(t,e,!1,g,l,c,u),r=r.next,e.bufferedRequestCount--,e.writing)break}r===null&&(e.lastBufferedRequest=null)}e.bufferedRequest=r,e.bufferProcessing=!1}jr.prototype._write=function(t,e,r){r(new b5e("_write()"))};jr.prototype._writev=null;jr.prototype.end=function(t,e,r){var i=this._writableState;return typeof t=="function"?(r=t,t=null,e=null):typeof e=="function"&&(r=e,e=null),t!=null&&this.write(t,e),i.corked&&(i.corked=1,this.uncork()),i.ending||K5e(this,i,r),this};Object.defineProperty(jr.prototype,"writableLength",{enumerable:!1,get:function(){return this._writableState.length}});function Tce(t){return t.ending&&t.length===0&&t.bufferedRequest===null&&!t.finished&&!t.writing}function H5e(t,e){t._final(function(r){e.pendingcb--,r&&ch(t,r),e.prefinished=!0,t.emit("prefinish"),Vm(t,e)})}function j5e(t,e){!e.prefinished&&!e.finalCalled&&(typeof t._final=="function"&&!e.destroyed?(e.pendingcb++,e.finalCalled=!0,process.nextTick(H5e,t,e)):(e.prefinished=!0,t.emit("prefinish")))}function Vm(t,e){var r=Tce(e);if(r&&(j5e(t,e),e.pendingcb===0&&(e.finished=!0,t.emit("finish"),e.autoDestroy))){var i=t._readableState;(!i||i.autoDestroy&&i.endEmitted)&&t.destroy()}return r}function K5e(t,e,r){e.ending=!0,Vm(t,e),r&&(e.finished?process.nextTick(r):t.once("finish",r)),e.ended=!0,t.writable=!1}function d5e(t,e,r){var i=t.entry;for(t.entry=null;i;){var n=i.callback;e.pendingcb--,n(r),i=i.next}e.corkedRequestsFree.next=t}Object.defineProperty(jr.prototype,"destroyed",{enumerable:!1,get:function(){return this._writableState===void 0?!1:this._writableState.destroyed},set:function(e){!this._writableState||(this._writableState.destroyed=e)}});jr.prototype.destroy=kT.destroy;jr.prototype._undestroy=kT.undestroy;jr.prototype._destroy=function(t,e){e(t)}});var vu=w((zkt,Oce)=>{"use strict";var G5e=Object.keys||function(t){var e=[];for(var r in t)e.push(r);return e};Oce.exports=pa;var Mce=DT(),RT=PT();Ll()(pa,Mce);for(FT=G5e(RT.prototype),wb=0;wb<FT.length;wb++)Bb=FT[wb],pa.prototype[Bb]||(pa.prototype[Bb]=RT.prototype[Bb]);var FT,Bb,wb;function pa(t){if(!(this instanceof pa))return new pa(t);Mce.call(this,t),RT.call(this,t),this.allowHalfOpen=!0,t&&(t.readable===!1&&(this.readable=!1),t.writable===!1&&(this.writable=!1),t.allowHalfOpen===!1&&(this.allowHalfOpen=!1,this.once("end",Y5e)))}Object.defineProperty(pa.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}});Object.defineProperty(pa.prototype,"writableBuffer",{enumerable:!1,get:function(){return this._writableState&&this._writableState.getBuffer()}});Object.defineProperty(pa.prototype,"writableLength",{enumerable:!1,get:function(){return this._writableState.length}});function Y5e(){this._writableState.ended||process.nextTick(q5e,this)}function q5e(t){t.end()}Object.defineProperty(pa.prototype,"destroyed",{enumerable:!1,get:function(){return this._readableState===void 0||this._writableState===void 0?!1:this._readableState.destroyed&&this._writableState.destroyed},set:function(e){this._readableState===void 0||this._writableState===void 0||(this._readableState.destroyed=e,this._writableState.destroyed=e)}})});var Hce=w((NT,Uce)=>{var bb=require("buffer"),BA=bb.Buffer;function Kce(t,e){for(var r in t)e[r]=t[r]}BA.from&&BA.alloc&&BA.allocUnsafe&&BA.allocUnsafeSlow?Uce.exports=bb:(Kce(bb,NT),NT.Buffer=uh);function uh(t,e,r){return BA(t,e,r)}Kce(BA,uh);uh.from=function(t,e,r){if(typeof t=="number")throw new TypeError("Argument must not be a number");return BA(t,e,r)};uh.alloc=function(t,e,r){if(typeof t!="number")throw new TypeError("Argument must be a number");var i=BA(t);return e!==void 0?typeof r=="string"?i.fill(e,r):i.fill(e):i.fill(0),i};uh.allocUnsafe=function(t){if(typeof t!="number")throw new TypeError("Argument must be a number");return BA(t)};uh.allocUnsafeSlow=function(t){if(typeof t!="number")throw new TypeError("Argument must be a number");return bb.SlowBuffer(t)}});var OT=w(jce=>{"use strict";var LT=Hce().Buffer,Gce=LT.isEncoding||function(t){switch(t=""+t,t&&t.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function J5e(t){if(!t)return"utf8";for(var e;;)switch(t){case"utf8":case"utf-8":return"utf8";case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return"utf16le";case"latin1":case"binary":return"latin1";case"base64":case"ascii":case"hex":return t;default:if(e)return;t=(""+t).toLowerCase(),e=!0}}function W5e(t){var e=J5e(t);if(typeof e!="string"&&(LT.isEncoding===Gce||!Gce(t)))throw new Error("Unknown encoding: "+t);return e||t}jce.StringDecoder=Xm;function Xm(t){this.encoding=W5e(t);var e;switch(this.encoding){case"utf16le":this.text=_5e,this.end=V5e,e=4;break;case"utf8":this.fillLast=z5e,e=4;break;case"base64":this.text=X5e,this.end=Z5e,e=3;break;default:this.write=$5e,this.end=e6e;return}this.lastNeed=0,this.lastTotal=0,this.lastChar=LT.allocUnsafe(e)}Xm.prototype.write=function(t){if(t.length===0)return"";var e,r;if(this.lastNeed){if(e=this.fillLast(t),e===void 0)return"";r=this.lastNeed,this.lastNeed=0}else r=0;return r<t.length?e?e+this.text(t,r):this.text(t,r):e||""};Xm.prototype.end=t6e;Xm.prototype.text=r6e;Xm.prototype.fillLast=function(t){if(this.lastNeed<=t.length)return t.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);t.copy(this.lastChar,this.lastTotal-this.lastNeed,0,t.length),this.lastNeed-=t.length};function TT(t){return t<=127?0:t>>5==6?2:t>>4==14?3:t>>3==30?4:t>>6==2?-1:-2}function i6e(t,e,r){var i=e.length-1;if(i<r)return 0;var n=TT(e[i]);return n>=0?(n>0&&(t.lastNeed=n-1),n):--i<r||n===-2?0:(n=TT(e[i]),n>=0?(n>0&&(t.lastNeed=n-2),n):--i<r||n===-2?0:(n=TT(e[i]),n>=0?(n>0&&(n===2?n=0:t.lastNeed=n-3),n):0))}function n6e(t,e,r){if((e[0]&192)!=128)return t.lastNeed=0,"\uFFFD";if(t.lastNeed>1&&e.length>1){if((e[1]&192)!=128)return t.lastNeed=1,"\uFFFD";if(t.lastNeed>2&&e.length>2&&(e[2]&192)!=128)return t.lastNeed=2,"\uFFFD"}}function z5e(t){var e=this.lastTotal-this.lastNeed,r=n6e(this,t,e);if(r!==void 0)return r;if(this.lastNeed<=t.length)return t.copy(this.lastChar,e,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);t.copy(this.lastChar,e,0,t.length),this.lastNeed-=t.length}function r6e(t,e){var r=i6e(this,t,e);if(!this.lastNeed)return t.toString("utf8",e);this.lastTotal=r;var i=t.length-(r-this.lastNeed);return t.copy(this.lastChar,0,i),t.toString("utf8",e,i)}function t6e(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+"\uFFFD":e}function _5e(t,e){if((t.length-e)%2==0){var r=t.toString("utf16le",e);if(r){var i=r.charCodeAt(r.length-1);if(i>=55296&&i<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1],r.slice(0,-1)}return r}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=t[t.length-1],t.toString("utf16le",e,t.length-1)}function V5e(t){var e=t&&t.length?this.write(t):"";if(this.lastNeed){var r=this.lastTotal-this.lastNeed;return e+this.lastChar.toString("utf16le",0,r)}return e}function X5e(t,e){var r=(t.length-e)%3;return r===0?t.toString("base64",e):(this.lastNeed=3-r,this.lastTotal=3,r===1?this.lastChar[0]=t[t.length-1]:(this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1]),t.toString("base64",e,t.length-r))}function Z5e(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+this.lastChar.toString("base64",0,3-this.lastNeed):e}function $5e(t){return t.toString(this.encoding)}function e6e(t){return t&&t.length?this.write(t):""}});var Qb=w((Vkt,Yce)=>{"use strict";var qce=Nl().codes.ERR_STREAM_PREMATURE_CLOSE;function s6e(t){var e=!1;return function(){if(!e){e=!0;for(var r=arguments.length,i=new Array(r),n=0;n<r;n++)i[n]=arguments[n];t.apply(this,i)}}}function o6e(){}function a6e(t){return t.setHeader&&typeof t.abort=="function"}function Jce(t,e,r){if(typeof e=="function")return Jce(t,null,e);e||(e={}),r=s6e(r||o6e);var i=e.readable||e.readable!==!1&&t.readable,n=e.writable||e.writable!==!1&&t.writable,s=function(){t.writable||a()},o=t._writableState&&t._writableState.finished,a=function(){n=!1,o=!0,i||r.call(t)},l=t._readableState&&t._readableState.endEmitted,c=function(){i=!1,l=!0,n||r.call(t)},u=function(p){r.call(t,p)},g=function(){var p;if(i&&!l)return(!t._readableState||!t._readableState.ended)&&(p=new qce),r.call(t,p);if(n&&!o)return(!t._writableState||!t._writableState.ended)&&(p=new qce),r.call(t,p)},f=function(){t.req.on("finish",a)};return a6e(t)?(t.on("complete",a),t.on("abort",g),t.req?f():t.on("request",f)):n&&!t._writableState&&(t.on("end",s),t.on("close",s)),t.on("end",c),t.on("finish",a),e.error!==!1&&t.on("error",u),t.on("close",g),function(){t.removeListener("complete",a),t.removeListener("abort",g),t.removeListener("request",f),t.req&&t.req.removeListener("finish",a),t.removeListener("end",s),t.removeListener("close",s),t.removeListener("finish",a),t.removeListener("end",c),t.removeListener("error",u),t.removeListener("close",g)}}Yce.exports=Jce});var zce=w((Xkt,Wce)=>{"use strict";var vb;function Ol(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}var A6e=Qb(),Ml=Symbol("lastResolve"),Su=Symbol("lastReject"),Zm=Symbol("error"),Sb=Symbol("ended"),ku=Symbol("lastPromise"),MT=Symbol("handlePromise"),xu=Symbol("stream");function Ul(t,e){return{value:t,done:e}}function l6e(t){var e=t[Ml];if(e!==null){var r=t[xu].read();r!==null&&(t[ku]=null,t[Ml]=null,t[Su]=null,e(Ul(r,!1)))}}function c6e(t){process.nextTick(l6e,t)}function u6e(t,e){return function(r,i){t.then(function(){if(e[Sb]){r(Ul(void 0,!0));return}e[MT](r,i)},i)}}var g6e=Object.getPrototypeOf(function(){}),f6e=Object.setPrototypeOf((vb={get stream(){return this[xu]},next:function(){var e=this,r=this[Zm];if(r!==null)return Promise.reject(r);if(this[Sb])return Promise.resolve(Ul(void 0,!0));if(this[xu].destroyed)return new Promise(function(o,a){process.nextTick(function(){e[Zm]?a(e[Zm]):o(Ul(void 0,!0))})});var i=this[ku],n;if(i)n=new Promise(u6e(i,this));else{var s=this[xu].read();if(s!==null)return Promise.resolve(Ul(s,!1));n=new Promise(this[MT])}return this[ku]=n,n}},Ol(vb,Symbol.asyncIterator,function(){return this}),Ol(vb,"return",function(){var e=this;return new Promise(function(r,i){e[xu].destroy(null,function(n){if(n){i(n);return}r(Ul(void 0,!0))})})}),vb),g6e),h6e=function(e){var r,i=Object.create(f6e,(r={},Ol(r,xu,{value:e,writable:!0}),Ol(r,Ml,{value:null,writable:!0}),Ol(r,Su,{value:null,writable:!0}),Ol(r,Zm,{value:null,writable:!0}),Ol(r,Sb,{value:e._readableState.endEmitted,writable:!0}),Ol(r,MT,{value:function(s,o){var a=i[xu].read();a?(i[ku]=null,i[Ml]=null,i[Su]=null,s(Ul(a,!1))):(i[Ml]=s,i[Su]=o)},writable:!0}),r));return i[ku]=null,A6e(e,function(n){if(n&&n.code!=="ERR_STREAM_PREMATURE_CLOSE"){var s=i[Su];s!==null&&(i[ku]=null,i[Ml]=null,i[Su]=null,s(n)),i[Zm]=n;return}var o=i[Ml];o!==null&&(i[ku]=null,i[Ml]=null,i[Su]=null,o(Ul(void 0,!0))),i[Sb]=!0}),e.on("readable",c6e.bind(null,i)),i};Wce.exports=h6e});var Zce=w((Zkt,_ce)=>{"use strict";function Vce(t,e,r,i,n,s,o){try{var a=t[s](o),l=a.value}catch(c){r(c);return}a.done?e(l):Promise.resolve(l).then(i,n)}function p6e(t){return function(){var e=this,r=arguments;return new Promise(function(i,n){var s=t.apply(e,r);function o(l){Vce(s,i,n,o,a,"next",l)}function a(l){Vce(s,i,n,o,a,"throw",l)}o(void 0)})}}function Xce(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter(function(n){return Object.getOwnPropertyDescriptor(t,n).enumerable})),r.push.apply(r,i)}return r}function C6e(t){for(var e=1;e<arguments.length;e++){var r=arguments[e]!=null?arguments[e]:{};e%2?Xce(Object(r),!0).forEach(function(i){d6e(t,i,r[i])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(r)):Xce(Object(r)).forEach(function(i){Object.defineProperty(t,i,Object.getOwnPropertyDescriptor(r,i))})}return t}function d6e(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}var m6e=Nl().codes.ERR_INVALID_ARG_TYPE;function E6e(t,e,r){var i;if(e&&typeof e.next=="function")i=e;else if(e&&e[Symbol.asyncIterator])i=e[Symbol.asyncIterator]();else if(e&&e[Symbol.iterator])i=e[Symbol.iterator]();else throw new m6e("iterable",["Iterable"],e);var n=new t(C6e({objectMode:!0},r)),s=!1;n._read=function(){s||(s=!0,o())};function o(){return a.apply(this,arguments)}function a(){return a=p6e(function*(){try{var l=yield i.next(),c=l.value,u=l.done;u?n.push(null):n.push(yield c)?o():s=!1}catch(g){n.destroy(g)}}),a.apply(this,arguments)}return n}_ce.exports=E6e});var DT=w((ext,$ce)=>{"use strict";$ce.exports=Kt;var gh;Kt.ReadableState=eue;var $kt=require("events").EventEmitter,tue=function(e,r){return e.listeners(r).length},$m=IT(),kb=require("buffer").Buffer,I6e=global.Uint8Array||function(){};function y6e(t){return kb.from(t)}function w6e(t){return kb.isBuffer(t)||t instanceof I6e}var UT=require("util"),xt;UT&&UT.debuglog?xt=UT.debuglog("stream"):xt=function(){};var B6e=yce(),KT=BT(),b6e=bT(),Q6e=b6e.getHighWaterMark,xb=Nl().codes,v6e=xb.ERR_INVALID_ARG_TYPE,S6e=xb.ERR_STREAM_PUSH_AFTER_EOF,k6e=xb.ERR_METHOD_NOT_IMPLEMENTED,x6e=xb.ERR_STREAM_UNSHIFT_AFTER_END_EVENT,fh,HT,jT;Ll()(Kt,$m);var eE=KT.errorOrDestroy,GT=["error","close","destroy","pause","resume"];function P6e(t,e,r){if(typeof t.prependListener=="function")return t.prependListener(e,r);!t._events||!t._events[e]?t.on(e,r):Array.isArray(t._events[e])?t._events[e].unshift(r):t._events[e]=[r,t._events[e]]}function eue(t,e,r){gh=gh||vu(),t=t||{},typeof r!="boolean"&&(r=e instanceof gh),this.objectMode=!!t.objectMode,r&&(this.objectMode=this.objectMode||!!t.readableObjectMode),this.highWaterMark=Q6e(this,t,"readableHighWaterMark",r),this.buffer=new B6e,this.length=0,this.pipes=null,this.pipesCount=0,this.flowing=null,this.ended=!1,this.endEmitted=!1,this.reading=!1,this.sync=!0,this.needReadable=!1,this.emittedReadable=!1,this.readableListening=!1,this.resumeScheduled=!1,this.paused=!0,this.emitClose=t.emitClose!==!1,this.autoDestroy=!!t.autoDestroy,this.destroyed=!1,this.defaultEncoding=t.defaultEncoding||"utf8",this.awaitDrain=0,this.readingMore=!1,this.decoder=null,this.encoding=null,t.encoding&&(fh||(fh=OT().StringDecoder),this.decoder=new fh(t.encoding),this.encoding=t.encoding)}function Kt(t){if(gh=gh||vu(),!(this instanceof Kt))return new Kt(t);var e=this instanceof gh;this._readableState=new eue(t,this,e),this.readable=!0,t&&(typeof t.read=="function"&&(this._read=t.read),typeof t.destroy=="function"&&(this._destroy=t.destroy)),$m.call(this)}Object.defineProperty(Kt.prototype,"destroyed",{enumerable:!1,get:function(){return this._readableState===void 0?!1:this._readableState.destroyed},set:function(e){!this._readableState||(this._readableState.destroyed=e)}});Kt.prototype.destroy=KT.destroy;Kt.prototype._undestroy=KT.undestroy;Kt.prototype._destroy=function(t,e){e(t)};Kt.prototype.push=function(t,e){var r=this._readableState,i;return r.objectMode?i=!0:typeof t=="string"&&(e=e||r.defaultEncoding,e!==r.encoding&&(t=kb.from(t,e),e=""),i=!0),rue(this,t,e,!1,i)};Kt.prototype.unshift=function(t){return rue(this,t,null,!0,!1)};function rue(t,e,r,i,n){xt("readableAddChunk",e);var s=t._readableState;if(e===null)s.reading=!1,R6e(t,s);else{var o;if(n||(o=D6e(s,e)),o)eE(t,o);else if(s.objectMode||e&&e.length>0)if(typeof e!="string"&&!s.objectMode&&Object.getPrototypeOf(e)!==kb.prototype&&(e=y6e(e)),i)s.endEmitted?eE(t,new x6e):YT(t,s,e,!0);else if(s.ended)eE(t,new S6e);else{if(s.destroyed)return!1;s.reading=!1,s.decoder&&!r?(e=s.decoder.write(e),s.objectMode||e.length!==0?YT(t,s,e,!1):qT(t,s)):YT(t,s,e,!1)}else i||(s.reading=!1,qT(t,s))}return!s.ended&&(s.length<s.highWaterMark||s.length===0)}function YT(t,e,r,i){e.flowing&&e.length===0&&!e.sync?(e.awaitDrain=0,t.emit("data",r)):(e.length+=e.objectMode?1:r.length,i?e.buffer.unshift(r):e.buffer.push(r),e.needReadable&&Pb(t)),qT(t,e)}function D6e(t,e){var r;return!w6e(e)&&typeof e!="string"&&e!==void 0&&!t.objectMode&&(r=new v6e("chunk",["string","Buffer","Uint8Array"],e)),r}Kt.prototype.isPaused=function(){return this._readableState.flowing===!1};Kt.prototype.setEncoding=function(t){fh||(fh=OT().StringDecoder);var e=new fh(t);this._readableState.decoder=e,this._readableState.encoding=this._readableState.decoder.encoding;for(var r=this._readableState.buffer.head,i="";r!==null;)i+=e.write(r.data),r=r.next;return this._readableState.buffer.clear(),i!==""&&this._readableState.buffer.push(i),this._readableState.length=i.length,this};var iue=1073741824;function F6e(t){return t>=iue?t=iue:(t--,t|=t>>>1,t|=t>>>2,t|=t>>>4,t|=t>>>8,t|=t>>>16,t++),t}function nue(t,e){return t<=0||e.length===0&&e.ended?0:e.objectMode?1:t!==t?e.flowing&&e.length?e.buffer.head.data.length:e.length:(t>e.highWaterMark&&(e.highWaterMark=F6e(t)),t<=e.length?t:e.ended?e.length:(e.needReadable=!0,0))}Kt.prototype.read=function(t){xt("read",t),t=parseInt(t,10);var e=this._readableState,r=t;if(t!==0&&(e.emittedReadable=!1),t===0&&e.needReadable&&((e.highWaterMark!==0?e.length>=e.highWaterMark:e.length>0)||e.ended))return xt("read: emitReadable",e.length,e.ended),e.length===0&&e.ended?JT(this):Pb(this),null;if(t=nue(t,e),t===0&&e.ended)return e.length===0&&JT(this),null;var i=e.needReadable;xt("need readable",i),(e.length===0||e.length-t<e.highWaterMark)&&(i=!0,xt("length less than watermark",i)),e.ended||e.reading?(i=!1,xt("reading or ended",i)):i&&(xt("do read"),e.reading=!0,e.sync=!0,e.length===0&&(e.needReadable=!0),this._read(e.highWaterMark),e.sync=!1,e.reading||(t=nue(r,e)));var n;return t>0?n=sue(t,e):n=null,n===null?(e.needReadable=e.length<=e.highWaterMark,t=0):(e.length-=t,e.awaitDrain=0),e.length===0&&(e.ended||(e.needReadable=!0),r!==t&&e.ended&&JT(this)),n!==null&&this.emit("data",n),n};function R6e(t,e){if(xt("onEofChunk"),!e.ended){if(e.decoder){var r=e.decoder.end();r&&r.length&&(e.buffer.push(r),e.length+=e.objectMode?1:r.length)}e.ended=!0,e.sync?Pb(t):(e.needReadable=!1,e.emittedReadable||(e.emittedReadable=!0,oue(t)))}}function Pb(t){var e=t._readableState;xt("emitReadable",e.needReadable,e.emittedReadable),e.needReadable=!1,e.emittedReadable||(xt("emitReadable",e.flowing),e.emittedReadable=!0,process.nextTick(oue,t))}function oue(t){var e=t._readableState;xt("emitReadable_",e.destroyed,e.length,e.ended),!e.destroyed&&(e.length||e.ended)&&(t.emit("readable"),e.emittedReadable=!1),e.needReadable=!e.flowing&&!e.ended&&e.length<=e.highWaterMark,WT(t)}function qT(t,e){e.readingMore||(e.readingMore=!0,process.nextTick(N6e,t,e))}function N6e(t,e){for(;!e.reading&&!e.ended&&(e.length<e.highWaterMark||e.flowing&&e.length===0);){var r=e.length;if(xt("maybeReadMore read 0"),t.read(0),r===e.length)break}e.readingMore=!1}Kt.prototype._read=function(t){eE(this,new k6e("_read()"))};Kt.prototype.pipe=function(t,e){var r=this,i=this._readableState;switch(i.pipesCount){case 0:i.pipes=t;break;case 1:i.pipes=[i.pipes,t];break;default:i.pipes.push(t);break}i.pipesCount+=1,xt("pipe count=%d opts=%j",i.pipesCount,e);var n=(!e||e.end!==!1)&&t!==process.stdout&&t!==process.stderr,s=n?a:m;i.endEmitted?process.nextTick(s):r.once("end",s),t.on("unpipe",o);function o(y,Q){xt("onunpipe"),y===r&&Q&&Q.hasUnpiped===!1&&(Q.hasUnpiped=!0,u())}function a(){xt("onend"),t.end()}var l=L6e(r);t.on("drain",l);var c=!1;function u(){xt("cleanup"),t.removeListener("close",h),t.removeListener("finish",p),t.removeListener("drain",l),t.removeListener("error",f),t.removeListener("unpipe",o),r.removeListener("end",a),r.removeListener("end",m),r.removeListener("data",g),c=!0,i.awaitDrain&&(!t._writableState||t._writableState.needDrain)&&l()}r.on("data",g);function g(y){xt("ondata");var Q=t.write(y);xt("dest.write",Q),Q===!1&&((i.pipesCount===1&&i.pipes===t||i.pipesCount>1&&aue(i.pipes,t)!==-1)&&!c&&(xt("false write response, pause",i.awaitDrain),i.awaitDrain++),r.pause())}function f(y){xt("onerror",y),m(),t.removeListener("error",f),tue(t,"error")===0&&eE(t,y)}P6e(t,"error",f);function h(){t.removeListener("finish",p),m()}t.once("close",h);function p(){xt("onfinish"),t.removeListener("close",h),m()}t.once("finish",p);function m(){xt("unpipe"),r.unpipe(t)}return t.emit("pipe",r),i.flowing||(xt("pipe resume"),r.resume()),t};function L6e(t){return function(){var r=t._readableState;xt("pipeOnDrain",r.awaitDrain),r.awaitDrain&&r.awaitDrain--,r.awaitDrain===0&&tue(t,"data")&&(r.flowing=!0,WT(t))}}Kt.prototype.unpipe=function(t){var e=this._readableState,r={hasUnpiped:!1};if(e.pipesCount===0)return this;if(e.pipesCount===1)return t&&t!==e.pipes?this:(t||(t=e.pipes),e.pipes=null,e.pipesCount=0,e.flowing=!1,t&&t.emit("unpipe",this,r),this);if(!t){var i=e.pipes,n=e.pipesCount;e.pipes=null,e.pipesCount=0,e.flowing=!1;for(var s=0;s<n;s++)i[s].emit("unpipe",this,{hasUnpiped:!1});return this}var o=aue(e.pipes,t);return o===-1?this:(e.pipes.splice(o,1),e.pipesCount-=1,e.pipesCount===1&&(e.pipes=e.pipes[0]),t.emit("unpipe",this,r),this)};Kt.prototype.on=function(t,e){var r=$m.prototype.on.call(this,t,e),i=this._readableState;return t==="data"?(i.readableListening=this.listenerCount("readable")>0,i.flowing!==!1&&this.resume()):t==="readable"&&!i.endEmitted&&!i.readableListening&&(i.readableListening=i.needReadable=!0,i.flowing=!1,i.emittedReadable=!1,xt("on readable",i.length,i.reading),i.length?Pb(this):i.reading||process.nextTick(T6e,this)),r};Kt.prototype.addListener=Kt.prototype.on;Kt.prototype.removeListener=function(t,e){var r=$m.prototype.removeListener.call(this,t,e);return t==="readable"&&process.nextTick(Aue,this),r};Kt.prototype.removeAllListeners=function(t){var e=$m.prototype.removeAllListeners.apply(this,arguments);return(t==="readable"||t===void 0)&&process.nextTick(Aue,this),e};function Aue(t){var e=t._readableState;e.readableListening=t.listenerCount("readable")>0,e.resumeScheduled&&!e.paused?e.flowing=!0:t.listenerCount("data")>0&&t.resume()}function T6e(t){xt("readable nexttick read 0"),t.read(0)}Kt.prototype.resume=function(){var t=this._readableState;return t.flowing||(xt("resume"),t.flowing=!t.readableListening,O6e(this,t)),t.paused=!1,this};function O6e(t,e){e.resumeScheduled||(e.resumeScheduled=!0,process.nextTick(M6e,t,e))}function M6e(t,e){xt("resume",e.reading),e.reading||t.read(0),e.resumeScheduled=!1,t.emit("resume"),WT(t),e.flowing&&!e.reading&&t.read(0)}Kt.prototype.pause=function(){return xt("call pause flowing=%j",this._readableState.flowing),this._readableState.flowing!==!1&&(xt("pause"),this._readableState.flowing=!1,this.emit("pause")),this._readableState.paused=!0,this};function WT(t){var e=t._readableState;for(xt("flow",e.flowing);e.flowing&&t.read()!==null;);}Kt.prototype.wrap=function(t){var e=this,r=this._readableState,i=!1;t.on("end",function(){if(xt("wrapped end"),r.decoder&&!r.ended){var o=r.decoder.end();o&&o.length&&e.push(o)}e.push(null)}),t.on("data",function(o){if(xt("wrapped data"),r.decoder&&(o=r.decoder.write(o)),!(r.objectMode&&o==null)&&!(!r.objectMode&&(!o||!o.length))){var a=e.push(o);a||(i=!0,t.pause())}});for(var n in t)this[n]===void 0&&typeof t[n]=="function"&&(this[n]=function(a){return function(){return t[a].apply(t,arguments)}}(n));for(var s=0;s<GT.length;s++)t.on(GT[s],this.emit.bind(this,GT[s]));return this._read=function(o){xt("wrapped _read",o),i&&(i=!1,t.resume())},this};typeof Symbol=="function"&&(Kt.prototype[Symbol.asyncIterator]=function(){return HT===void 0&&(HT=zce()),HT(this)});Object.defineProperty(Kt.prototype,"readableHighWaterMark",{enumerable:!1,get:function(){return this._readableState.highWaterMark}});Object.defineProperty(Kt.prototype,"readableBuffer",{enumerable:!1,get:function(){return this._readableState&&this._readableState.buffer}});Object.defineProperty(Kt.prototype,"readableFlowing",{enumerable:!1,get:function(){return this._readableState.flowing},set:function(e){this._readableState&&(this._readableState.flowing=e)}});Kt._fromList=sue;Object.defineProperty(Kt.prototype,"readableLength",{enumerable:!1,get:function(){return this._readableState.length}});function sue(t,e){if(e.length===0)return null;var r;return e.objectMode?r=e.buffer.shift():!t||t>=e.length?(e.decoder?r=e.buffer.join(""):e.buffer.length===1?r=e.buffer.first():r=e.buffer.concat(e.length),e.buffer.clear()):r=e.buffer.consume(t,e.decoder),r}function JT(t){var e=t._readableState;xt("endReadable",e.endEmitted),e.endEmitted||(e.ended=!0,process.nextTick(U6e,e,t))}function U6e(t,e){if(xt("endReadableNT",t.endEmitted,t.length),!t.endEmitted&&t.length===0&&(t.endEmitted=!0,e.readable=!1,e.emit("end"),t.autoDestroy)){var r=e._writableState;(!r||r.autoDestroy&&r.finished)&&e.destroy()}}typeof Symbol=="function"&&(Kt.from=function(t,e){return jT===void 0&&(jT=Zce()),jT(Kt,t,e)});function aue(t,e){for(var r=0,i=t.length;r<i;r++)if(t[r]===e)return r;return-1}});var zT=w((txt,lue)=>{"use strict";lue.exports=bA;var Db=Nl().codes,K6e=Db.ERR_METHOD_NOT_IMPLEMENTED,H6e=Db.ERR_MULTIPLE_CALLBACK,j6e=Db.ERR_TRANSFORM_ALREADY_TRANSFORMING,G6e=Db.ERR_TRANSFORM_WITH_LENGTH_0,Rb=vu();Ll()(bA,Rb);function Y6e(t,e){var r=this._transformState;r.transforming=!1;var i=r.writecb;if(i===null)return this.emit("error",new H6e);r.writechunk=null,r.writecb=null,e!=null&&this.push(e),i(t);var n=this._readableState;n.reading=!1,(n.needReadable||n.length<n.highWaterMark)&&this._read(n.highWaterMark)}function bA(t){if(!(this instanceof bA))return new bA(t);Rb.call(this,t),this._transformState={afterTransform:Y6e.bind(this),needTransform:!1,transforming:!1,writecb:null,writechunk:null,writeencoding:null},this._readableState.needReadable=!0,this._readableState.sync=!1,t&&(typeof t.transform=="function"&&(this._transform=t.transform),typeof t.flush=="function"&&(this._flush=t.flush)),this.on("prefinish",q6e)}function q6e(){var t=this;typeof this._flush=="function"&&!this._readableState.destroyed?this._flush(function(e,r){cue(t,e,r)}):cue(this,null,null)}bA.prototype.push=function(t,e){return this._transformState.needTransform=!1,Rb.prototype.push.call(this,t,e)};bA.prototype._transform=function(t,e,r){r(new K6e("_transform()"))};bA.prototype._write=function(t,e,r){var i=this._transformState;if(i.writecb=r,i.writechunk=t,i.writeencoding=e,!i.transforming){var n=this._readableState;(i.needTransform||n.needReadable||n.length<n.highWaterMark)&&this._read(n.highWaterMark)}};bA.prototype._read=function(t){var e=this._transformState;e.writechunk!==null&&!e.transforming?(e.transforming=!0,this._transform(e.writechunk,e.writeencoding,e.afterTransform)):e.needTransform=!0};bA.prototype._destroy=function(t,e){Rb.prototype._destroy.call(this,t,function(r){e(r)})};function cue(t,e,r){if(e)return t.emit("error",e);if(r!=null&&t.push(r),t._writableState.length)throw new G6e;if(t._transformState.transforming)throw new j6e;return t.push(null)}});var fue=w((rxt,uue)=>{"use strict";uue.exports=tE;var gue=zT();Ll()(tE,gue);function tE(t){if(!(this instanceof tE))return new tE(t);gue.call(this,t)}tE.prototype._transform=function(t,e,r){r(null,t)}});var mue=w((ixt,hue)=>{"use strict";var _T;function J6e(t){var e=!1;return function(){e||(e=!0,t.apply(void 0,arguments))}}var pue=Nl().codes,W6e=pue.ERR_MISSING_ARGS,z6e=pue.ERR_STREAM_DESTROYED;function due(t){if(t)throw t}function _6e(t){return t.setHeader&&typeof t.abort=="function"}function V6e(t,e,r,i){i=J6e(i);var n=!1;t.on("close",function(){n=!0}),_T===void 0&&(_T=Qb()),_T(t,{readable:e,writable:r},function(o){if(o)return i(o);n=!0,i()});var s=!1;return function(o){if(!n&&!s){if(s=!0,_6e(t))return t.abort();if(typeof t.destroy=="function")return t.destroy();i(o||new z6e("pipe"))}}}function Cue(t){t()}function X6e(t,e){return t.pipe(e)}function Z6e(t){return!t.length||typeof t[t.length-1]!="function"?due:t.pop()}function $6e(){for(var t=arguments.length,e=new Array(t),r=0;r<t;r++)e[r]=arguments[r];var i=Z6e(e);if(Array.isArray(e[0])&&(e=e[0]),e.length<2)throw new W6e("streams");var n,s=e.map(function(o,a){var l=a<e.length-1,c=a>0;return V6e(o,l,c,function(u){n||(n=u),u&&s.forEach(Cue),!l&&(s.forEach(Cue),i(n))})});return e.reduce(X6e)}hue.exports=$6e});var hh=w((zs,rE)=>{var iE=require("stream");process.env.READABLE_STREAM==="disable"&&iE?(rE.exports=iE.Readable,Object.assign(rE.exports,iE),rE.exports.Stream=iE):(zs=rE.exports=DT(),zs.Stream=iE||zs,zs.Readable=zs,zs.Writable=PT(),zs.Duplex=vu(),zs.Transform=zT(),zs.PassThrough=fue(),zs.finished=Qb(),zs.pipeline=mue())});var yue=w((nxt,Eue)=>{"use strict";var{Buffer:Qo}=require("buffer"),Iue=Symbol.for("BufferList");function mr(t){if(!(this instanceof mr))return new mr(t);mr._init.call(this,t)}mr._init=function(e){Object.defineProperty(this,Iue,{value:!0}),this._bufs=[],this.length=0,e&&this.append(e)};mr.prototype._new=function(e){return new mr(e)};mr.prototype._offset=function(e){if(e===0)return[0,0];let r=0;for(let i=0;i<this._bufs.length;i++){let n=r+this._bufs[i].length;if(e<n||i===this._bufs.length-1)return[i,e-r];r=n}};mr.prototype._reverseOffset=function(t){let e=t[0],r=t[1];for(let i=0;i<e;i++)r+=this._bufs[i].length;return r};mr.prototype.get=function(e){if(e>this.length||e<0)return;let r=this._offset(e);return this._bufs[r[0]][r[1]]};mr.prototype.slice=function(e,r){return typeof e=="number"&&e<0&&(e+=this.length),typeof r=="number"&&r<0&&(r+=this.length),this.copy(null,0,e,r)};mr.prototype.copy=function(e,r,i,n){if((typeof i!="number"||i<0)&&(i=0),(typeof n!="number"||n>this.length)&&(n=this.length),i>=this.length||n<=0)return e||Qo.alloc(0);let s=!!e,o=this._offset(i),a=n-i,l=a,c=s&&r||0,u=o[1];if(i===0&&n===this.length){if(!s)return this._bufs.length===1?this._bufs[0]:Qo.concat(this._bufs,this.length);for(let g=0;g<this._bufs.length;g++)this._bufs[g].copy(e,c),c+=this._bufs[g].length;return e}if(l<=this._bufs[o[0]].length-u)return s?this._bufs[o[0]].copy(e,r,u,u+l):this._bufs[o[0]].slice(u,u+l);s||(e=Qo.allocUnsafe(a));for(let g=o[0];g<this._bufs.length;g++){let f=this._bufs[g].length-u;if(l>f)this._bufs[g].copy(e,c,u),c+=f;else{this._bufs[g].copy(e,c,u,u+l),c+=f;break}l-=f,u&&(u=0)}return e.length>c?e.slice(0,c):e};mr.prototype.shallowSlice=function(e,r){if(e=e||0,r=typeof r!="number"?this.length:r,e<0&&(e+=this.length),r<0&&(r+=this.length),e===r)return this._new();let i=this._offset(e),n=this._offset(r),s=this._bufs.slice(i[0],n[0]+1);return n[1]===0?s.pop():s[s.length-1]=s[s.length-1].slice(0,n[1]),i[1]!==0&&(s[0]=s[0].slice(i[1])),this._new(s)};mr.prototype.toString=function(e,r,i){return this.slice(r,i).toString(e)};mr.prototype.consume=function(e){if(e=Math.trunc(e),Number.isNaN(e)||e<=0)return this;for(;this._bufs.length;)if(e>=this._bufs[0].length)e-=this._bufs[0].length,this.length-=this._bufs[0].length,this._bufs.shift();else{this._bufs[0]=this._bufs[0].slice(e),this.length-=e;break}return this};mr.prototype.duplicate=function(){let e=this._new();for(let r=0;r<this._bufs.length;r++)e.append(this._bufs[r]);return e};mr.prototype.append=function(e){if(e==null)return this;if(e.buffer)this._appendBuffer(Qo.from(e.buffer,e.byteOffset,e.byteLength));else if(Array.isArray(e))for(let r=0;r<e.length;r++)this.append(e[r]);else if(this._isBufferList(e))for(let r=0;r<e._bufs.length;r++)this.append(e._bufs[r]);else typeof e=="number"&&(e=e.toString()),this._appendBuffer(Qo.from(e));return this};mr.prototype._appendBuffer=function(e){this._bufs.push(e),this.length+=e.length};mr.prototype.indexOf=function(t,e,r){if(r===void 0&&typeof e=="string"&&(r=e,e=void 0),typeof t=="function"||Array.isArray(t))throw new TypeError('The "value" argument must be one of type string, Buffer, BufferList, or Uint8Array.');if(typeof t=="number"?t=Qo.from([t]):typeof t=="string"?t=Qo.from(t,r):this._isBufferList(t)?t=t.slice():Array.isArray(t.buffer)?t=Qo.from(t.buffer,t.byteOffset,t.byteLength):Qo.isBuffer(t)||(t=Qo.from(t)),e=Number(e||0),isNaN(e)&&(e=0),e<0&&(e=this.length+e),e<0&&(e=0),t.length===0)return e>this.length?this.length:e;let i=this._offset(e),n=i[0],s=i[1];for(;n<this._bufs.length;n++){let o=this._bufs[n];for(;s<o.length;)if(o.length-s>=t.length){let l=o.indexOf(t,s);if(l!==-1)return this._reverseOffset([n,l]);s=o.length-t.length+1}else{let l=this._reverseOffset([n,s]);if(this._match(l,t))return l;s++}s=0}return-1};mr.prototype._match=function(t,e){if(this.length-t<e.length)return!1;for(let r=0;r<e.length;r++)if(this.get(t+r)!==e[r])return!1;return!0};(function(){let t={readDoubleBE:8,readDoubleLE:8,readFloatBE:4,readFloatLE:4,readInt32BE:4,readInt32LE:4,readUInt32BE:4,readUInt32LE:4,readInt16BE:2,readInt16LE:2,readUInt16BE:2,readUInt16LE:2,readInt8:1,readUInt8:1,readIntBE:null,readIntLE:null,readUIntBE:null,readUIntLE:null};for(let e in t)(function(r){t[r]===null?mr.prototype[r]=function(i,n){return this.slice(i,i+n)[r](0,n)}:mr.prototype[r]=function(i=0){return this.slice(i,i+t[r])[r](0)}})(e)})();mr.prototype._isBufferList=function(e){return e instanceof mr||mr.isBufferList(e)};mr.isBufferList=function(e){return e!=null&&e[Iue]};Eue.exports=mr});var wue=w((sxt,Fb)=>{"use strict";var VT=hh().Duplex,eVe=Ll(),nE=yue();function Zi(t){if(!(this instanceof Zi))return new Zi(t);if(typeof t=="function"){this._callback=t;let e=function(i){this._callback&&(this._callback(i),this._callback=null)}.bind(this);this.on("pipe",function(i){i.on("error",e)}),this.on("unpipe",function(i){i.removeListener("error",e)}),t=null}nE._init.call(this,t),VT.call(this)}eVe(Zi,VT);Object.assign(Zi.prototype,nE.prototype);Zi.prototype._new=function(e){return new Zi(e)};Zi.prototype._write=function(e,r,i){this._appendBuffer(e),typeof i=="function"&&i()};Zi.prototype._read=function(e){if(!this.length)return this.push(null);e=Math.min(e,this.length),this.push(this.slice(0,e)),this.consume(e)};Zi.prototype.end=function(e){VT.prototype.end.call(this,e),this._callback&&(this._callback(null,this.slice()),this._callback=null)};Zi.prototype._destroy=function(e,r){this._bufs.length=0,this.length=0,r(e)};Zi.prototype._isBufferList=function(e){return e instanceof Zi||e instanceof nE||Zi.isBufferList(e)};Zi.isBufferList=nE.isBufferList;Fb.exports=Zi;Fb.exports.BufferListStream=Zi;Fb.exports.BufferList=nE});var $T=w(ph=>{var tVe=Buffer.alloc,rVe="0000000000000000000",iVe="7777777777777777777",Bue="0".charCodeAt(0),bue=Buffer.from("ustar\0","binary"),nVe=Buffer.from("00","binary"),sVe=Buffer.from("ustar ","binary"),oVe=Buffer.from(" \0","binary"),aVe=parseInt("7777",8),sE=257,XT=263,AVe=function(t,e,r){return typeof t!="number"?r:(t=~~t,t>=e?e:t>=0||(t+=e,t>=0)?t:0)},lVe=function(t){switch(t){case 0:return"file";case 1:return"link";case 2:return"symlink";case 3:return"character-device";case 4:return"block-device";case 5:return"directory";case 6:return"fifo";case 7:return"contiguous-file";case 72:return"pax-header";case 55:return"pax-global-header";case 27:return"gnu-long-link-path";case 28:case 30:return"gnu-long-path"}return null},cVe=function(t){switch(t){case"file":return 0;case"link":return 1;case"symlink":return 2;case"character-device":return 3;case"block-device":return 4;case"directory":return 5;case"fifo":return 6;case"contiguous-file":return 7;case"pax-header":return 72}return 0},Que=function(t,e,r,i){for(;r<i;r++)if(t[r]===e)return r;return i},vue=function(t){for(var e=8*32,r=0;r<148;r++)e+=t[r];for(var i=156;i<512;i++)e+=t[i];return e},Kl=function(t,e){return t=t.toString(8),t.length>e?iVe.slice(0,e)+" ":rVe.slice(0,e-t.length)+t+" "};function uVe(t){var e;if(t[0]===128)e=!0;else if(t[0]===255)e=!1;else return null;for(var r=[],i=t.length-1;i>0;i--){var n=t[i];e?r.push(n):r.push(255-n)}var s=0,o=r.length;for(i=0;i<o;i++)s+=r[i]*Math.pow(256,i);return e?s:-1*s}var Hl=function(t,e,r){if(t=t.slice(e,e+r),e=0,t[e]&128)return uVe(t);for(;e<t.length&&t[e]===32;)e++;for(var i=AVe(Que(t,32,e,t.length),t.length,t.length);e<i&&t[e]===0;)e++;return i===e?0:parseInt(t.slice(e,i).toString(),8)},dh=function(t,e,r,i){return t.slice(e,Que(t,0,e,e+r)).toString(i)},ZT=function(t){var e=Buffer.byteLength(t),r=Math.floor(Math.log(e)/Math.log(10))+1;return e+r>=Math.pow(10,r)&&r++,e+r+t};ph.decodeLongPath=function(t,e){return dh(t,0,t.length,e)};ph.encodePax=function(t){var e="";t.name&&(e+=ZT(" path="+t.name+`
+`)),t.linkname&&(e+=ZT(" linkpath="+t.linkname+`
+`));var r=t.pax;if(r)for(var i in r)e+=ZT(" "+i+"="+r[i]+`
+`);return Buffer.from(e)};ph.decodePax=function(t){for(var e={};t.length;){for(var r=0;r<t.length&&t[r]!==32;)r++;var i=parseInt(t.slice(0,r).toString(),10);if(!i)return e;var n=t.slice(r+1,i-1).toString(),s=n.indexOf("=");if(s===-1)return e;e[n.slice(0,s)]=n.slice(s+1),t=t.slice(i)}return e};ph.encode=function(t){var e=tVe(512),r=t.name,i="";if(t.typeflag===5&&r[r.length-1]!=="/"&&(r+="/"),Buffer.byteLength(r)!==r.length)return null;for(;Buffer.byteLength(r)>100;){var n=r.indexOf("/");if(n===-1)return null;i+=i?"/"+r.slice(0,n):r.slice(0,n),r=r.slice(n+1)}return Buffer.byteLength(r)>100||Buffer.byteLength(i)>155||t.linkname&&Buffer.byteLength(t.linkname)>100?null:(e.write(r),e.write(Kl(t.mode&aVe,6),100),e.write(Kl(t.uid,6),108),e.write(Kl(t.gid,6),116),e.write(Kl(t.size,11),124),e.write(Kl(t.mtime.getTime()/1e3|0,11),136),e[156]=Bue+cVe(t.type),t.linkname&&e.write(t.linkname,157),bue.copy(e,sE),nVe.copy(e,XT),t.uname&&e.write(t.uname,265),t.gname&&e.write(t.gname,297),e.write(Kl(t.devmajor||0,6),329),e.write(Kl(t.devminor||0,6),337),i&&e.write(i,345),e.write(Kl(vue(e),6),148),e)};ph.decode=function(t,e,r){var i=t[156]===0?0:t[156]-Bue,n=dh(t,0,100,e),s=Hl(t,100,8),o=Hl(t,108,8),a=Hl(t,116,8),l=Hl(t,124,12),c=Hl(t,136,12),u=lVe(i),g=t[157]===0?null:dh(t,157,100,e),f=dh(t,265,32),h=dh(t,297,32),p=Hl(t,329,8),m=Hl(t,337,8),y=vue(t);if(y===8*32)return null;if(y!==Hl(t,148,8))throw new Error("Invalid tar header. Maybe the tar is corrupted or it needs to be gunzipped?");if(bue.compare(t,sE,sE+6)===0)t[345]&&(n=dh(t,345,155,e)+"/"+n);else if(!(sVe.compare(t,sE,sE+6)===0&&oVe.compare(t,XT,XT+2)===0)){if(!r)throw new Error("Invalid tar header: unknown format.")}return i===0&&n&&n[n.length-1]==="/"&&(i=5),{name:n,mode:s,uid:o,gid:a,size:l,mtime:new Date(1e3*c),type:u,linkname:g,uname:f,gname:h,devmajor:p,devminor:m}}});var Fue=w((axt,Sue)=>{var kue=require("util"),gVe=wue(),oE=$T(),xue=hh().Writable,Pue=hh().PassThrough,Due=function(){},Rue=function(t){return t&=511,t&&512-t},fVe=function(t,e){var r=new Nb(t,e);return r.end(),r},hVe=function(t,e){return e.path&&(t.name=e.path),e.linkpath&&(t.linkname=e.linkpath),e.size&&(t.size=parseInt(e.size,10)),t.pax=e,t},Nb=function(t,e){this._parent=t,this.offset=e,Pue.call(this,{autoDestroy:!1})};kue.inherits(Nb,Pue);Nb.prototype.destroy=function(t){this._parent.destroy(t)};var QA=function(t){if(!(this instanceof QA))return new QA(t);xue.call(this,t),t=t||{},this._offset=0,this._buffer=gVe(),this._missing=0,this._partial=!1,this._onparse=Due,this._header=null,this._stream=null,this._overflow=null,this._cb=null,this._locked=!1,this._destroyed=!1,this._pax=null,this._paxGlobal=null,this._gnuLongPath=null,this._gnuLongLinkPath=null;var e=this,r=e._buffer,i=function(){e._continue()},n=function(f){if(e._locked=!1,f)return e.destroy(f);e._stream||i()},s=function(){e._stream=null;var f=Rue(e._header.size);f?e._parse(f,o):e._parse(512,g),e._locked||i()},o=function(){e._buffer.consume(Rue(e._header.size)),e._parse(512,g),i()},a=function(){var f=e._header.size;e._paxGlobal=oE.decodePax(r.slice(0,f)),r.consume(f),s()},l=function(){var f=e._header.size;e._pax=oE.decodePax(r.slice(0,f)),e._paxGlobal&&(e._pax=Object.assign({},e._paxGlobal,e._pax)),r.consume(f),s()},c=function(){var f=e._header.size;this._gnuLongPath=oE.decodeLongPath(r.slice(0,f),t.filenameEncoding),r.consume(f),s()},u=function(){var f=e._header.size;this._gnuLongLinkPath=oE.decodeLongPath(r.slice(0,f),t.filenameEncoding),r.consume(f),s()},g=function(){var f=e._offset,h;try{h=e._header=oE.decode(r.slice(0,512),t.filenameEncoding,t.allowUnknownFormat)}catch(p){e.emit("error",p)}if(r.consume(512),!h){e._parse(512,g),i();return}if(h.type==="gnu-long-path"){e._parse(h.size,c),i();return}if(h.type==="gnu-long-link-path"){e._parse(h.size,u),i();return}if(h.type==="pax-global-header"){e._parse(h.size,a),i();return}if(h.type==="pax-header"){e._parse(h.size,l),i();return}if(e._gnuLongPath&&(h.name=e._gnuLongPath,e._gnuLongPath=null),e._gnuLongLinkPath&&(h.linkname=e._gnuLongLinkPath,e._gnuLongLinkPath=null),e._pax&&(e._header=h=hVe(h,e._pax),e._pax=null),e._locked=!0,!h.size||h.type==="directory"){e._parse(512,g),e.emit("entry",h,fVe(e,f),n);return}e._stream=new Nb(e,f),e.emit("entry",h,e._stream,n),e._parse(h.size,s),i()};this._onheader=g,this._parse(512,g)};kue.inherits(QA,xue);QA.prototype.destroy=function(t){this._destroyed||(this._destroyed=!0,t&&this.emit("error",t),this.emit("close"),this._stream&&this._stream.emit("close"))};QA.prototype._parse=function(t,e){this._destroyed||(this._offset+=t,this._missing=t,e===this._onheader&&(this._partial=!1),this._onparse=e)};QA.prototype._continue=function(){if(!this._destroyed){var t=this._cb;this._cb=Due,this._overflow?this._write(this._overflow,void 0,t):t()}};QA.prototype._write=function(t,e,r){if(!this._destroyed){var i=this._stream,n=this._buffer,s=this._missing;if(t.length&&(this._partial=!0),t.length<s)return this._missing-=t.length,this._overflow=null,i?i.write(t,r):(n.append(t),r());this._cb=r,this._missing=0;var o=null;t.length>s&&(o=t.slice(s),t=t.slice(0,s)),i?i.end(t):n.append(t),this._overflow=o,this._onparse()}};QA.prototype._final=function(t){if(this._partial)return this.destroy(new Error("Unexpected end of data"));t()};Sue.exports=QA});var Lue=w((Axt,Nue)=>{Nue.exports=require("fs").constants||require("constants")});var Kue=w((lxt,Tue)=>{var Ch=Lue(),Oue=Wx(),Lb=Ll(),pVe=Buffer.alloc,Mue=hh().Readable,mh=hh().Writable,dVe=require("string_decoder").StringDecoder,Tb=$T(),CVe=parseInt("755",8),mVe=parseInt("644",8),Uue=pVe(1024),eO=function(){},tO=function(t,e){e&=511,e&&t.push(Uue.slice(0,512-e))};function EVe(t){switch(t&Ch.S_IFMT){case Ch.S_IFBLK:return"block-device";case Ch.S_IFCHR:return"character-device";case Ch.S_IFDIR:return"directory";case Ch.S_IFIFO:return"fifo";case Ch.S_IFLNK:return"symlink"}return"file"}var Ob=function(t){mh.call(this),this.written=0,this._to=t,this._destroyed=!1};Lb(Ob,mh);Ob.prototype._write=function(t,e,r){if(this.written+=t.length,this._to.push(t))return r();this._to._drain=r};Ob.prototype.destroy=function(){this._destroyed||(this._destroyed=!0,this.emit("close"))};var Mb=function(){mh.call(this),this.linkname="",this._decoder=new dVe("utf-8"),this._destroyed=!1};Lb(Mb,mh);Mb.prototype._write=function(t,e,r){this.linkname+=this._decoder.write(t),r()};Mb.prototype.destroy=function(){this._destroyed||(this._destroyed=!0,this.emit("close"))};var aE=function(){mh.call(this),this._destroyed=!1};Lb(aE,mh);aE.prototype._write=function(t,e,r){r(new Error("No body allowed for this entry"))};aE.prototype.destroy=function(){this._destroyed||(this._destroyed=!0,this.emit("close"))};var da=function(t){if(!(this instanceof da))return new da(t);Mue.call(this,t),this._drain=eO,this._finalized=!1,this._finalizing=!1,this._destroyed=!1,this._stream=null};Lb(da,Mue);da.prototype.entry=function(t,e,r){if(this._stream)throw new Error("already piping an entry");if(!(this._finalized||this._destroyed)){typeof e=="function"&&(r=e,e=null),r||(r=eO);var i=this;if((!t.size||t.type==="symlink")&&(t.size=0),t.type||(t.type=EVe(t.mode)),t.mode||(t.mode=t.type==="directory"?CVe:mVe),t.uid||(t.uid=0),t.gid||(t.gid=0),t.mtime||(t.mtime=new Date),typeof e=="string"&&(e=Buffer.from(e)),Buffer.isBuffer(e)){t.size=e.length,this._encode(t);var n=this.push(e);return tO(i,t.size),n?process.nextTick(r):this._drain=r,new aE}if(t.type==="symlink"&&!t.linkname){var s=new Mb;return Oue(s,function(a){if(a)return i.destroy(),r(a);t.linkname=s.linkname,i._encode(t),r()}),s}if(this._encode(t),t.type!=="file"&&t.type!=="contiguous-file")return process.nextTick(r),new aE;var o=new Ob(this);return this._stream=o,Oue(o,function(a){if(i._stream=null,a)return i.destroy(),r(a);if(o.written!==t.size)return i.destroy(),r(new Error("size mismatch"));tO(i,t.size),i._finalizing&&i.finalize(),r()}),o}};da.prototype.finalize=function(){if(this._stream){this._finalizing=!0;return}this._finalized||(this._finalized=!0,this.push(Uue),this.push(null))};da.prototype.destroy=function(t){this._destroyed||(this._destroyed=!0,t&&this.emit("error",t),this.emit("close"),this._stream&&this._stream.destroy&&this._stream.destroy())};da.prototype._encode=function(t){if(!t.pax){var e=Tb.encode(t);if(e){this.push(e);return}}this._encodePax(t)};da.prototype._encodePax=function(t){var e=Tb.encodePax({name:t.name,linkname:t.linkname,pax:t.pax}),r={name:"PaxHeader",mode:t.mode,uid:t.uid,gid:t.gid,size:e.length,mtime:t.mtime,type:"pax-header",linkname:t.linkname&&"PaxHeader",uname:t.uname,gname:t.gname,devmajor:t.devmajor,devminor:t.devminor};this.push(Tb.encode(r)),this.push(e),tO(this,e.length),r.size=t.size,r.type=t.type,this.push(Tb.encode(r))};da.prototype._read=function(t){var e=this._drain;this._drain=eO,e()};Tue.exports=da});var Hue=w(rO=>{rO.extract=Fue();rO.pack=Kue()});var ege=w((Rxt,Vue)=>{"use strict";var Eh=class{constructor(e,r,i){this.__specs=e||{},Object.keys(this.__specs).forEach(n=>{if(typeof this.__specs[n]=="string"){let s=this.__specs[n],o=this.__specs[s];if(o){let a=o.aliases||[];a.push(n,s),o.aliases=[...new Set(a)],this.__specs[n]=o}else throw new Error(`Alias refers to invalid key: ${s} -> ${n}`)}}),this.__opts=r||{},this.__providers=Zue(i.filter(n=>n!=null&&typeof n=="object")),this.__isFiggyPudding=!0}get(e){return AO(this,e,!0)}get[Symbol.toStringTag](){return"FiggyPudding"}forEach(e,r=this){for(let[i,n]of this.entries())e.call(r,n,i,this)}toJSON(){let e={};return this.forEach((r,i)=>{e[i]=r}),e}*entries(e){for(let i of Object.keys(this.__specs))yield[i,this.get(i)];let r=e||this.__opts.other;if(r){let i=new Set;for(let n of this.__providers){let s=n.entries?n.entries(r):RVe(n);for(let[o,a]of s)r(o)&&!i.has(o)&&(i.add(o),yield[o,a])}}}*[Symbol.iterator](){for(let[e,r]of this.entries())yield[e,r]}*keys(){for(let[e]of this.entries())yield e}*values(){for(let[,e]of this.entries())yield e}concat(...e){return new Proxy(new Eh(this.__specs,this.__opts,Zue(this.__providers).concat(e)),Xue)}};try{let t=require("util");Eh.prototype[t.inspect.custom]=function(e,r){return this[Symbol.toStringTag]+" "+t.inspect(this.toJSON(),r)}}catch(t){}function FVe(t){throw Object.assign(new Error(`invalid config key requested: ${t}`),{code:"EBADKEY"})}function AO(t,e,r){let i=t.__specs[e];if(r&&!i&&(!t.__opts.other||!t.__opts.other(e)))FVe(e);else{i||(i={});let n;for(let s of t.__providers){if(n=$ue(e,s),n===void 0&&i.aliases&&i.aliases.length){for(let o of i.aliases)if(o!==e&&(n=$ue(o,s),n!==void 0))break}if(n!==void 0)break}return n===void 0&&i.default!==void 0?typeof i.default=="function"?i.default(t):i.default:n}}function $ue(t,e){let r;return e.__isFiggyPudding?r=AO(e,t,!1):typeof e.get=="function"?r=e.get(t):r=e[t],r}var Xue={has(t,e){return e in t.__specs&&AO(t,e,!1)!==void 0},ownKeys(t){return Object.keys(t.__specs)},get(t,e){return typeof e=="symbol"||e.slice(0,2)==="__"||e in Eh.prototype?t[e]:t.get(e)},set(t,e,r){if(typeof e=="symbol"||e.slice(0,2)==="__")return t[e]=r,!0;throw new Error("figgyPudding options cannot be modified. Use .concat() instead.")},deleteProperty(){throw new Error("figgyPudding options cannot be deleted. Use .concat() and shadow them instead.")}};Vue.exports=NVe;function NVe(t,e){function r(...i){return new Proxy(new Eh(t,e,i),Xue)}return r}function Zue(t){let e=[];return t.forEach(r=>e.unshift(r)),e}function RVe(t){return Object.keys(t).map(e=>[e,t[e]])}});var ige=w((Fxt,Ca)=>{"use strict";var lE=require("crypto"),LVe=ege(),TVe=require("stream").Transform,tge=["sha256","sha384","sha512"],OVe=/^[a-z0-9+/]+(?:=?=?)$/i,MVe=/^([^-]+)-([^?]+)([?\S*]*)$/,UVe=/^([^-]+)-([A-Za-z0-9+/=]{44,88})(\?[\x21-\x7E]*)*$/,KVe=/^[\x21-\x7E]+$/,Cn=LVe({algorithms:{default:["sha512"]},error:{default:!1},integrity:{},options:{default:[]},pickAlgorithm:{default:()=>HVe},Promise:{default:()=>Promise},sep:{default:" "},single:{default:!1},size:{},strict:{default:!1}}),Pu=class{get isHash(){return!0}constructor(e,r){r=Cn(r);let i=!!r.strict;this.source=e.trim();let n=this.source.match(i?UVe:MVe);if(!n||i&&!tge.some(o=>o===n[1]))return;this.algorithm=n[1],this.digest=n[2];let s=n[3];this.options=s?s.slice(1).split("?"):[]}hexDigest(){return this.digest&&Buffer.from(this.digest,"base64").toString("hex")}toJSON(){return this.toString()}toString(e){if(e=Cn(e),e.strict&&!(tge.some(i=>i===this.algorithm)&&this.digest.match(OVe)&&(this.options||[]).every(i=>i.match(KVe))))return"";let r=this.options&&this.options.length?`?${this.options.join("?")}`:"";return`${this.algorithm}-${this.digest}${r}`}},Ih=class{get isIntegrity(){return!0}toJSON(){return this.toString()}toString(e){e=Cn(e);let r=e.sep||" ";return e.strict&&(r=r.replace(/\S+/g," ")),Object.keys(this).map(i=>this[i].map(n=>Pu.prototype.toString.call(n,e)).filter(n=>n.length).join(r)).filter(i=>i.length).join(r)}concat(e,r){r=Cn(r);let i=typeof e=="string"?e:cE(e,r);return ma(`${this.toString(r)} ${i}`,r)}hexDigest(){return ma(this,{single:!0}).hexDigest()}match(e,r){r=Cn(r);let i=ma(e,r),n=i.pickAlgorithm(r);return this[n]&&i[n]&&this[n].find(s=>i[n].find(o=>s.digest===o.digest))||!1}pickAlgorithm(e){e=Cn(e);let r=e.pickAlgorithm,i=Object.keys(this);if(!i.length)throw new Error(`No algorithms available for ${JSON.stringify(this.toString())}`);return i.reduce((n,s)=>r(n,s)||n)}};Ca.exports.parse=ma;function ma(t,e){if(e=Cn(e),typeof t=="string")return lO(t,e);if(t.algorithm&&t.digest){let r=new Ih;return r[t.algorithm]=[t],lO(cE(r,e),e)}else return lO(cE(t,e),e)}function lO(t,e){return e.single?new Pu(t,e):t.trim().split(/\s+/).reduce((r,i)=>{let n=new Pu(i,e);if(n.algorithm&&n.digest){let s=n.algorithm;r[s]||(r[s]=[]),r[s].push(n)}return r},new Ih)}Ca.exports.stringify=cE;function cE(t,e){return e=Cn(e),t.algorithm&&t.digest?Pu.prototype.toString.call(t,e):typeof t=="string"?cE(ma(t,e),e):Ih.prototype.toString.call(t,e)}Ca.exports.fromHex=jVe;function jVe(t,e,r){r=Cn(r);let i=r.options&&r.options.length?`?${r.options.join("?")}`:"";return ma(`${e}-${Buffer.from(t,"hex").toString("base64")}${i}`,r)}Ca.exports.fromData=GVe;function GVe(t,e){e=Cn(e);let r=e.algorithms,i=e.options&&e.options.length?`?${e.options.join("?")}`:"";return r.reduce((n,s)=>{let o=lE.createHash(s).update(t).digest("base64"),a=new Pu(`${s}-${o}${i}`,e);if(a.algorithm&&a.digest){let l=a.algorithm;n[l]||(n[l]=[]),n[l].push(a)}return n},new Ih)}Ca.exports.fromStream=YVe;function YVe(t,e){e=Cn(e);let r=e.Promise||Promise,i=cO(e);return new r((n,s)=>{t.pipe(i),t.on("error",s),i.on("error",s);let o;i.on("integrity",a=>{o=a}),i.on("end",()=>n(o)),i.on("data",()=>{})})}Ca.exports.checkData=qVe;function qVe(t,e,r){if(r=Cn(r),e=ma(e,r),!Object.keys(e).length){if(r.error)throw Object.assign(new Error("No valid integrity hashes to check against"),{code:"EINTEGRITY"});return!1}let i=e.pickAlgorithm(r),n=lE.createHash(i).update(t).digest("base64"),s=ma({algorithm:i,digest:n}),o=s.match(e,r);if(o||!r.error)return o;if(typeof r.size=="number"&&t.length!==r.size){let a=new Error(`data size mismatch when checking ${e}.
+  Wanted: ${r.size}
+  Found: ${t.length}`);throw a.code="EBADSIZE",a.found=t.length,a.expected=r.size,a.sri=e,a}else{let a=new Error(`Integrity checksum failed when using ${i}: Wanted ${e}, but got ${s}. (${t.length} bytes)`);throw a.code="EINTEGRITY",a.found=s,a.expected=e,a.algorithm=i,a.sri=e,a}}Ca.exports.checkStream=JVe;function JVe(t,e,r){r=Cn(r);let i=r.Promise||Promise,n=cO(r.concat({integrity:e}));return new i((s,o)=>{t.pipe(n),t.on("error",o),n.on("error",o);let a;n.on("verified",l=>{a=l}),n.on("end",()=>s(a)),n.on("data",()=>{})})}Ca.exports.integrityStream=cO;function cO(t){t=Cn(t);let e=t.integrity&&ma(t.integrity,t),r=e&&Object.keys(e).length,i=r&&e.pickAlgorithm(t),n=r&&e[i],s=Array.from(new Set(t.algorithms.concat(i?[i]:[]))),o=s.map(lE.createHash),a=0,l=new TVe({transform(c,u,g){a+=c.length,o.forEach(f=>f.update(c,u)),g(null,c,u)}}).on("end",()=>{let c=t.options&&t.options.length?`?${t.options.join("?")}`:"",u=ma(o.map((f,h)=>`${s[h]}-${f.digest("base64")}${c}`).join(" "),t),g=r&&u.match(e,t);if(typeof t.size=="number"&&a!==t.size){let f=new Error(`stream size mismatch when checking ${e}.
+  Wanted: ${t.size}
+  Found: ${a}`);f.code="EBADSIZE",f.found=a,f.expected=t.size,f.sri=e,l.emit("error",f)}else if(t.integrity&&!g){let f=new Error(`${e} integrity checksum failed when using ${i}: wanted ${n} but got ${u}. (${a} bytes)`);f.code="EINTEGRITY",f.found=u,f.expected=n,f.algorithm=i,f.sri=e,l.emit("error",f)}else l.emit("size",a),l.emit("integrity",u),g&&l.emit("verified",g)});return l}Ca.exports.create=WVe;function WVe(t){t=Cn(t);let e=t.algorithms,r=t.options.length?`?${t.options.join("?")}`:"",i=e.map(lE.createHash);return{update:function(n,s){return i.forEach(o=>o.update(n,s)),this},digest:function(n){return e.reduce((o,a)=>{let l=i.shift().digest("base64"),c=new Pu(`${a}-${l}${r}`,t);if(c.algorithm&&c.digest){let u=c.algorithm;o[u]||(o[u]=[]),o[u].push(c)}return o},new Ih)}}}var zVe=new Set(lE.getHashes()),rge=["md5","whirlpool","sha1","sha224","sha256","sha384","sha512","sha3","sha3-256","sha3-384","sha3-512","sha3_256","sha3_384","sha3_512"].filter(t=>zVe.has(t));function HVe(t,e){return rge.indexOf(t.toLowerCase())>=rge.indexOf(e.toLowerCase())?t:e}});var QC={};ft(QC,{BuildType:()=>As,Cache:()=>Nt,Configuration:()=>ye,DEFAULT_LOCK_FILENAME:()=>wx,DEFAULT_RC_FILENAME:()=>yx,FormatType:()=>Di,InstallMode:()=>di,LightReport:()=>uA,LinkType:()=>Qt,Manifest:()=>At,MessageName:()=>$,MultiFetcher:()=>yd,PackageExtensionStatus:()=>qi,PackageExtensionType:()=>yi,Project:()=>ze,ProjectLookup:()=>ol,Report:()=>Ji,ReportError:()=>ct,SettingsType:()=>Ie,StreamReport:()=>Je,TAG_REGEXP:()=>Gg,TelemetryManager:()=>bC,ThrowReport:()=>pi,VirtualFetcher:()=>Bd,Workspace:()=>BC,WorkspaceFetcher:()=>bd,WorkspaceResolver:()=>si,YarnVersion:()=>Ur,execUtils:()=>Fr,folderUtils:()=>hx,formatUtils:()=>ae,hashUtils:()=>Dn,httpUtils:()=>ir,miscUtils:()=>Se,nodeUtils:()=>qg,parseMessageName:()=>BI,scriptUtils:()=>Zt,semverUtils:()=>Wt,stringifyMessageName:()=>YA,structUtils:()=>P,tgzUtils:()=>wi,treeUtils:()=>as});var Fr={};ft(Fr,{EndStrategy:()=>is,ExecError:()=>xx,PipeError:()=>Qw,execvp:()=>Eke,pipevp:()=>$o});var Zh={};ft(Zh,{AliasFS:()=>Pa,CwdFS:()=>_t,DEFAULT_COMPRESSION_LEVEL:()=>ic,FakeFS:()=>KA,Filename:()=>Pt,JailFS:()=>Da,LazyFS:()=>zh,LinkStrategy:()=>jh,NoFS:()=>zE,NodeFS:()=>ar,PortablePath:()=>Me,PosixFS:()=>_h,ProxiedFS:()=>bi,VirtualFS:()=>Jr,ZipFS:()=>Ai,ZipOpenFS:()=>ms,constants:()=>Dr,extendFs:()=>VE,normalizeLineEndings:()=>$l,npath:()=>j,opendir:()=>qE,patchFs:()=>bQ,ppath:()=>k,statUtils:()=>uQ,toFilename:()=>qr,xfs:()=>K});var Dr={};ft(Dr,{SAFE_TIME:()=>cQ,S_IFDIR:()=>Sa,S_IFLNK:()=>xa,S_IFMT:()=>zn,S_IFREG:()=>ka});var zn=61440,Sa=16384,ka=32768,xa=40960,cQ=456789e3;var uQ={};ft(uQ,{BigIntStatsEntry:()=>Uh,DEFAULT_MODE:()=>Mh,DirEntry:()=>sM,StatEntry:()=>MA,areStatsEqual:()=>fQ,clearStats:()=>ME,convertToBigIntStats:()=>UE,makeDefaultStats:()=>Kh,makeEmptyStats:()=>dfe});var gQ=ge(require("util"));var Mh=ka|420,sM=class{constructor(){this.name="";this.mode=0}isBlockDevice(){return!1}isCharacterDevice(){return!1}isDirectory(){return(this.mode&zn)===Sa}isFIFO(){return!1}isFile(){return(this.mode&zn)===ka}isSocket(){return!1}isSymbolicLink(){return(this.mode&zn)===xa}},MA=class{constructor(){this.uid=0;this.gid=0;this.size=0;this.blksize=0;this.atimeMs=0;this.mtimeMs=0;this.ctimeMs=0;this.birthtimeMs=0;this.atime=new Date(0);this.mtime=new Date(0);this.ctime=new Date(0);this.birthtime=new Date(0);this.dev=0;this.ino=0;this.mode=Mh;this.nlink=1;this.rdev=0;this.blocks=1}isBlockDevice(){return!1}isCharacterDevice(){return!1}isDirectory(){return(this.mode&zn)===Sa}isFIFO(){return!1}isFile(){return(this.mode&zn)===ka}isSocket(){return!1}isSymbolicLink(){return(this.mode&zn)===xa}},Uh=class{constructor(){this.uid=BigInt(0);this.gid=BigInt(0);this.size=BigInt(0);this.blksize=BigInt(0);this.atimeMs=BigInt(0);this.mtimeMs=BigInt(0);this.ctimeMs=BigInt(0);this.birthtimeMs=BigInt(0);this.atimeNs=BigInt(0);this.mtimeNs=BigInt(0);this.ctimeNs=BigInt(0);this.birthtimeNs=BigInt(0);this.atime=new Date(0);this.mtime=new Date(0);this.ctime=new Date(0);this.birthtime=new Date(0);this.dev=BigInt(0);this.ino=BigInt(0);this.mode=BigInt(Mh);this.nlink=BigInt(1);this.rdev=BigInt(0);this.blocks=BigInt(1)}isBlockDevice(){return!1}isCharacterDevice(){return!1}isDirectory(){return(this.mode&BigInt(zn))===BigInt(Sa)}isFIFO(){return!1}isFile(){return(this.mode&BigInt(zn))===BigInt(ka)}isSocket(){return!1}isSymbolicLink(){return(this.mode&BigInt(zn))===BigInt(xa)}};function Kh(){return new MA}function dfe(){return ME(Kh())}function ME(t){for(let e in t)if(Object.prototype.hasOwnProperty.call(t,e)){let r=t[e];typeof r=="number"?t[e]=0:typeof r=="bigint"?t[e]=BigInt(0):gQ.types.isDate(r)&&(t[e]=new Date(0))}return t}function UE(t){let e=new Uh;for(let r in t)if(Object.prototype.hasOwnProperty.call(t,r)){let i=t[r];typeof i=="number"?e[r]=BigInt(i):gQ.types.isDate(i)&&(e[r]=new Date(i))}return e.atimeNs=e.atimeMs*BigInt(1e6),e.mtimeNs=e.mtimeMs*BigInt(1e6),e.ctimeNs=e.ctimeMs*BigInt(1e6),e.birthtimeNs=e.birthtimeMs*BigInt(1e6),e}function fQ(t,e){if(t.atimeMs!==e.atimeMs||t.birthtimeMs!==e.birthtimeMs||t.blksize!==e.blksize||t.blocks!==e.blocks||t.ctimeMs!==e.ctimeMs||t.dev!==e.dev||t.gid!==e.gid||t.ino!==e.ino||t.isBlockDevice()!==e.isBlockDevice()||t.isCharacterDevice()!==e.isCharacterDevice()||t.isDirectory()!==e.isDirectory()||t.isFIFO()!==e.isFIFO()||t.isFile()!==e.isFile()||t.isSocket()!==e.isSocket()||t.isSymbolicLink()!==e.isSymbolicLink()||t.mode!==e.mode||t.mtimeMs!==e.mtimeMs||t.nlink!==e.nlink||t.rdev!==e.rdev||t.size!==e.size||t.uid!==e.uid)return!1;let r=t,i=e;return!(r.atimeNs!==i.atimeNs||r.mtimeNs!==i.mtimeNs||r.ctimeNs!==i.ctimeNs||r.birthtimeNs!==i.birthtimeNs)}var HE=ge(require("fs"));var Hh=ge(require("path")),oM;(function(i){i[i.File=0]="File",i[i.Portable=1]="Portable",i[i.Native=2]="Native"})(oM||(oM={}));var Me={root:"/",dot:"."},Pt={nodeModules:"node_modules",manifest:"package.json",lockfile:"yarn.lock",virtual:"__virtual__",pnpJs:".pnp.js",pnpCjs:".pnp.cjs",rc:".yarnrc.yml"},j=Object.create(Hh.default),k=Object.create(Hh.default.posix);j.cwd=()=>process.cwd();k.cwd=()=>hQ(process.cwd());k.resolve=(...t)=>t.length>0&&k.isAbsolute(t[0])?Hh.default.posix.resolve(...t):Hh.default.posix.resolve(k.cwd(),...t);var aM=function(t,e,r){return e=t.normalize(e),r=t.normalize(r),e===r?".":(e.endsWith(t.sep)||(e=e+t.sep),r.startsWith(e)?r.slice(e.length):null)};j.fromPortablePath=AM;j.toPortablePath=hQ;j.contains=(t,e)=>aM(j,t,e);k.contains=(t,e)=>aM(k,t,e);var Cfe=/^([a-zA-Z]:.*)$/,mfe=/^\/\/(\.\/)?(.*)$/,Efe=/^\/([a-zA-Z]:.*)$/,Ife=/^\/unc\/(\.dot\/)?(.*)$/;function AM(t){if(process.platform!=="win32")return t;let e,r;if(e=t.match(Efe))t=e[1];else if(r=t.match(Ife))t=`\\\\${r[1]?".\\":""}${r[2]}`;else return t;return t.replace(/\//g,"\\")}function hQ(t){if(process.platform!=="win32")return t;t=t.replace(/\\/g,"/");let e,r;return(e=t.match(Cfe))?t=`/${e[1]}`:(r=t.match(mfe))&&(t=`/unc/${r[1]?".dot/":""}${r[2]}`),t}function KE(t,e){return t===j?AM(e):hQ(e)}function qr(t){if(j.parse(t).dir!==""||k.parse(t).dir!=="")throw new Error(`Invalid filename: "${t}"`);return t}var jE=new Date(cQ*1e3),jh;(function(r){r.Allow="allow",r.ReadOnly="readOnly"})(jh||(jh={}));async function lM(t,e,r,i,n){let s=t.pathUtils.normalize(e),o=r.pathUtils.normalize(i),a=[],l=[],{atime:c,mtime:u}=n.stableTime?{atime:jE,mtime:jE}:await r.lstatPromise(o);await t.mkdirpPromise(t.pathUtils.dirname(e),{utimes:[c,u]});let g=typeof t.lutimesPromise=="function"?t.lutimesPromise.bind(t):t.utimesPromise.bind(t);await pQ(a,l,g,t,s,r,o,te(N({},n),{didParentExist:!0}));for(let f of a)await f();await Promise.all(l.map(f=>f()))}async function pQ(t,e,r,i,n,s,o,a){var h,p;let l=a.didParentExist?await yfe(i,n):null,c=await s.lstatPromise(o),{atime:u,mtime:g}=a.stableTime?{atime:jE,mtime:jE}:c,f;switch(!0){case c.isDirectory():f=await wfe(t,e,r,i,n,l,s,o,c,a);break;case c.isFile():f=await Bfe(t,e,r,i,n,l,s,o,c,a);break;case c.isSymbolicLink():f=await bfe(t,e,r,i,n,l,s,o,c,a);break;default:throw new Error(`Unsupported file type (${c.mode})`)}return(f||((h=l==null?void 0:l.mtime)==null?void 0:h.getTime())!==g.getTime()||((p=l==null?void 0:l.atime)==null?void 0:p.getTime())!==u.getTime())&&(e.push(()=>r(n,u,g)),f=!0),(l===null||(l.mode&511)!=(c.mode&511))&&(e.push(()=>i.chmodPromise(n,c.mode&511)),f=!0),f}async function yfe(t,e){try{return await t.lstatPromise(e)}catch(r){return null}}async function wfe(t,e,r,i,n,s,o,a,l,c){if(s!==null&&!s.isDirectory())if(c.overwrite)t.push(async()=>i.removePromise(n)),s=null;else return!1;let u=!1;s===null&&(t.push(async()=>{try{await i.mkdirPromise(n,{mode:l.mode})}catch(h){if(h.code!=="EEXIST")throw h}}),u=!0);let g=await o.readdirPromise(a),f=c.didParentExist&&!s?te(N({},c),{didParentExist:!1}):c;if(c.stableSort)for(let h of g.sort())await pQ(t,e,r,i,i.pathUtils.join(n,h),o,o.pathUtils.join(a,h),f)&&(u=!0);else(await Promise.all(g.map(async p=>{await pQ(t,e,r,i,i.pathUtils.join(n,p),o,o.pathUtils.join(a,p),f)}))).some(p=>p)&&(u=!0);return u}var dQ=new WeakMap;function CQ(t,e,r,i,n){return async()=>{await t.linkPromise(r,e),n===jh.ReadOnly&&(i.mode&=~146,await t.chmodPromise(e,i.mode))}}function Qfe(t,e,r,i,n){let s=dQ.get(t);return typeof s=="undefined"?async()=>{try{await t.copyFilePromise(r,e,HE.default.constants.COPYFILE_FICLONE_FORCE),dQ.set(t,!0)}catch(o){if(o.code==="ENOSYS"||o.code==="ENOTSUP")dQ.set(t,!1),await CQ(t,e,r,i,n)();else throw o}}:s?async()=>t.copyFilePromise(r,e,HE.default.constants.COPYFILE_FICLONE_FORCE):CQ(t,e,r,i,n)}async function Bfe(t,e,r,i,n,s,o,a,l,c){var f;if(s!==null)if(c.overwrite)t.push(async()=>i.removePromise(n)),s=null;else return!1;let u=(f=c.linkStrategy)!=null?f:null,g=i===o?u!==null?Qfe(i,n,a,l,u):async()=>i.copyFilePromise(a,n,HE.default.constants.COPYFILE_FICLONE):u!==null?CQ(i,n,a,l,u):async()=>i.writeFilePromise(n,await o.readFilePromise(a));return t.push(async()=>g()),!0}async function bfe(t,e,r,i,n,s,o,a,l,c){if(s!==null)if(c.overwrite)t.push(async()=>i.removePromise(n)),s=null;else return!1;return t.push(async()=>{await i.symlinkPromise(KE(i.pathUtils,await o.readlinkPromise(a)),n)}),!0}function Cs(t,e){return Object.assign(new Error(`${t}: ${e}`),{code:t})}function GE(t){return Cs("EBUSY",t)}function Gh(t,e){return Cs("ENOSYS",`${t}, ${e}`)}function UA(t){return Cs("EINVAL",`invalid argument, ${t}`)}function en(t){return Cs("EBADF",`bad file descriptor, ${t}`)}function to(t){return Cs("ENOENT",`no such file or directory, ${t}`)}function Do(t){return Cs("ENOTDIR",`not a directory, ${t}`)}function Yh(t){return Cs("EISDIR",`illegal operation on a directory, ${t}`)}function YE(t){return Cs("EEXIST",`file already exists, ${t}`)}function In(t){return Cs("EROFS",`read-only filesystem, ${t}`)}function cM(t){return Cs("ENOTEMPTY",`directory not empty, ${t}`)}function uM(t){return Cs("EOPNOTSUPP",`operation not supported, ${t}`)}function gM(){return Cs("ERR_DIR_CLOSED","Directory handle was closed")}var mQ=class extends Error{constructor(e,r){super(e);this.name="Libzip Error",this.code=r}};var fM=class{constructor(e,r,i={}){this.path=e;this.nextDirent=r;this.opts=i;this.closed=!1}throwIfClosed(){if(this.closed)throw gM()}async*[Symbol.asyncIterator](){try{let e;for(;(e=await this.read())!==null;)yield e}finally{await this.close()}}read(e){let r=this.readSync();return typeof e!="undefined"?e(null,r):Promise.resolve(r)}readSync(){return this.throwIfClosed(),this.nextDirent()}close(e){return this.closeSync(),typeof e!="undefined"?e(null):Promise.resolve()}closeSync(){var e,r;this.throwIfClosed(),(r=(e=this.opts).onClose)==null||r.call(e),this.closed=!0}};function qE(t,e,r,i){let n=()=>{let s=r.shift();return typeof s=="undefined"?null:Object.assign(t.statSync(t.pathUtils.join(e,s)),{name:s})};return new fM(e,n,i)}var hM=ge(require("os"));var KA=class{constructor(e){this.pathUtils=e}async*genTraversePromise(e,{stableSort:r=!1}={}){let i=[e];for(;i.length>0;){let n=i.shift();if((await this.lstatPromise(n)).isDirectory()){let o=await this.readdirPromise(n);if(r)for(let a of o.sort())i.push(this.pathUtils.join(n,a));else throw new Error("Not supported")}else yield n}}async removePromise(e,{recursive:r=!0,maxRetries:i=5}={}){let n;try{n=await this.lstatPromise(e)}catch(s){if(s.code==="ENOENT")return;throw s}if(n.isDirectory()){if(r){let s=await this.readdirPromise(e);await Promise.all(s.map(o=>this.removePromise(this.pathUtils.resolve(e,o))))}for(let s=0;s<=i;s++)try{await this.rmdirPromise(e);break}catch(o){if(o.code!=="EBUSY"&&o.code!=="ENOTEMPTY")throw o;s<i&&await new Promise(a=>setTimeout(a,s*100))}}else await this.unlinkPromise(e)}removeSync(e,{recursive:r=!0}={}){let i;try{i=this.lstatSync(e)}catch(n){if(n.code==="ENOENT")return;throw n}if(i.isDirectory()){if(r)for(let n of this.readdirSync(e))this.removeSync(this.pathUtils.resolve(e,n));this.rmdirSync(e)}else this.unlinkSync(e)}async mkdirpPromise(e,{chmod:r,utimes:i}={}){if(e=this.resolve(e),e===this.pathUtils.dirname(e))return;let n=e.split(this.pathUtils.sep);for(let s=2;s<=n.length;++s){let o=n.slice(0,s).join(this.pathUtils.sep);if(!this.existsSync(o)){try{await this.mkdirPromise(o)}catch(a){if(a.code==="EEXIST")continue;throw a}if(r!=null&&await this.chmodPromise(o,r),i!=null)await this.utimesPromise(o,i[0],i[1]);else{let a=await this.statPromise(this.pathUtils.dirname(o));await this.utimesPromise(o,a.atime,a.mtime)}}}}mkdirpSync(e,{chmod:r,utimes:i}={}){if(e=this.resolve(e),e===this.pathUtils.dirname(e))return;let n=e.split(this.pathUtils.sep);for(let s=2;s<=n.length;++s){let o=n.slice(0,s).join(this.pathUtils.sep);if(!this.existsSync(o)){try{this.mkdirSync(o)}catch(a){if(a.code==="EEXIST")continue;throw a}if(r!=null&&this.chmodSync(o,r),i!=null)this.utimesSync(o,i[0],i[1]);else{let a=this.statSync(this.pathUtils.dirname(o));this.utimesSync(o,a.atime,a.mtime)}}}}async copyPromise(e,r,{baseFs:i=this,overwrite:n=!0,stableSort:s=!1,stableTime:o=!1,linkStrategy:a=null}={}){return await lM(this,e,i,r,{overwrite:n,stableSort:s,stableTime:o,linkStrategy:a})}copySync(e,r,{baseFs:i=this,overwrite:n=!0}={}){let s=i.lstatSync(r),o=this.existsSync(e);if(s.isDirectory()){this.mkdirpSync(e);let l=i.readdirSync(r);for(let c of l)this.copySync(this.pathUtils.join(e,c),i.pathUtils.join(r,c),{baseFs:i,overwrite:n})}else if(s.isFile()){if(!o||n){o&&this.removeSync(e);let l=i.readFileSync(r);this.writeFileSync(e,l)}}else if(s.isSymbolicLink()){if(!o||n){o&&this.removeSync(e);let l=i.readlinkSync(r);this.symlinkSync(KE(this.pathUtils,l),e)}}else throw new Error(`Unsupported file type (file: ${r}, mode: 0o${s.mode.toString(8).padStart(6,"0")})`);let a=s.mode&511;this.chmodSync(e,a)}async changeFilePromise(e,r,i={}){return Buffer.isBuffer(r)?this.changeFileBufferPromise(e,r,i):this.changeFileTextPromise(e,r,i)}async changeFileBufferPromise(e,r,{mode:i}={}){let n=Buffer.alloc(0);try{n=await this.readFilePromise(e)}catch(s){}Buffer.compare(n,r)!==0&&await this.writeFilePromise(e,r,{mode:i})}async changeFileTextPromise(e,r,{automaticNewlines:i,mode:n}={}){let s="";try{s=await this.readFilePromise(e,"utf8")}catch(a){}let o=i?$l(s,r):r;s!==o&&await this.writeFilePromise(e,o,{mode:n})}changeFileSync(e,r,i={}){return Buffer.isBuffer(r)?this.changeFileBufferSync(e,r,i):this.changeFileTextSync(e,r,i)}changeFileBufferSync(e,r,{mode:i}={}){let n=Buffer.alloc(0);try{n=this.readFileSync(e)}catch(s){}Buffer.compare(n,r)!==0&&this.writeFileSync(e,r,{mode:i})}changeFileTextSync(e,r,{automaticNewlines:i=!1,mode:n}={}){let s="";try{s=this.readFileSync(e,"utf8")}catch(a){}let o=i?$l(s,r):r;s!==o&&this.writeFileSync(e,o,{mode:n})}async movePromise(e,r){try{await this.renamePromise(e,r)}catch(i){if(i.code==="EXDEV")await this.copyPromise(r,e),await this.removePromise(e);else throw i}}moveSync(e,r){try{this.renameSync(e,r)}catch(i){if(i.code==="EXDEV")this.copySync(r,e),this.removeSync(e);else throw i}}async lockPromise(e,r){let i=`${e}.flock`,n=1e3/60,s=Date.now(),o=null,a=async()=>{let l;try{[l]=await this.readJsonPromise(i)}catch(c){return Date.now()-s<500}try{return process.kill(l,0),!0}catch(c){return!1}};for(;o===null;)try{o=await this.openPromise(i,"wx")}catch(l){if(l.code==="EEXIST"){if(!await a())try{await this.unlinkPromise(i);continue}catch(c){}if(Date.now()-s<60*1e3)await new Promise(c=>setTimeout(c,n));else throw new Error(`Couldn't acquire a lock in a reasonable time (via ${i})`)}else throw l}await this.writePromise(o,JSON.stringify([process.pid]));try{return await r()}finally{try{await this.closePromise(o),await this.unlinkPromise(i)}catch(l){}}}async readJsonPromise(e){let r=await this.readFilePromise(e,"utf8");try{return JSON.parse(r)}catch(i){throw i.message+=` (in ${e})`,i}}readJsonSync(e){let r=this.readFileSync(e,"utf8");try{return JSON.parse(r)}catch(i){throw i.message+=` (in ${e})`,i}}async writeJsonPromise(e,r){return await this.writeFilePromise(e,`${JSON.stringify(r,null,2)}
+`)}writeJsonSync(e,r){return this.writeFileSync(e,`${JSON.stringify(r,null,2)}
+`)}async preserveTimePromise(e,r){let i=await this.lstatPromise(e),n=await r();typeof n!="undefined"&&(e=n),this.lutimesPromise?await this.lutimesPromise(e,i.atime,i.mtime):i.isSymbolicLink()||await this.utimesPromise(e,i.atime,i.mtime)}async preserveTimeSync(e,r){let i=this.lstatSync(e),n=r();typeof n!="undefined"&&(e=n),this.lutimesSync?this.lutimesSync(e,i.atime,i.mtime):i.isSymbolicLink()||this.utimesSync(e,i.atime,i.mtime)}},ec=class extends KA{constructor(){super(k)}};function vfe(t){let e=t.match(/\r?\n/g);if(e===null)return hM.EOL;let r=e.filter(n=>n===`\r
+`).length,i=e.length-r;return r>i?`\r
+`:`
+`}function $l(t,e){return e.replace(/\r?\n/g,vfe(t))}var qu=ge(require("fs")),EQ=ge(require("stream")),mM=ge(require("util")),IQ=ge(require("zlib"));var pM=ge(require("fs"));var ar=class extends ec{constructor(e=pM.default){super();this.realFs=e,typeof this.realFs.lutimes!="undefined"&&(this.lutimesPromise=this.lutimesPromiseImpl,this.lutimesSync=this.lutimesSyncImpl)}getExtractHint(){return!1}getRealPath(){return Me.root}resolve(e){return k.resolve(e)}async openPromise(e,r,i){return await new Promise((n,s)=>{this.realFs.open(j.fromPortablePath(e),r,i,this.makeCallback(n,s))})}openSync(e,r,i){return this.realFs.openSync(j.fromPortablePath(e),r,i)}async opendirPromise(e,r){return await new Promise((i,n)=>{typeof r!="undefined"?this.realFs.opendir(j.fromPortablePath(e),r,this.makeCallback(i,n)):this.realFs.opendir(j.fromPortablePath(e),this.makeCallback(i,n))}).then(i=>Object.defineProperty(i,"path",{value:e,configurable:!0,writable:!0}))}opendirSync(e,r){let i=typeof r!="undefined"?this.realFs.opendirSync(j.fromPortablePath(e),r):this.realFs.opendirSync(j.fromPortablePath(e));return Object.defineProperty(i,"path",{value:e,configurable:!0,writable:!0})}async readPromise(e,r,i=0,n=0,s=-1){return await new Promise((o,a)=>{this.realFs.read(e,r,i,n,s,(l,c)=>{l?a(l):o(c)})})}readSync(e,r,i,n,s){return this.realFs.readSync(e,r,i,n,s)}async writePromise(e,r,i,n,s){return await new Promise((o,a)=>typeof r=="string"?this.realFs.write(e,r,i,this.makeCallback(o,a)):this.realFs.write(e,r,i,n,s,this.makeCallback(o,a)))}writeSync(e,r,i,n,s){return typeof r=="string"?this.realFs.writeSync(e,r,i):this.realFs.writeSync(e,r,i,n,s)}async closePromise(e){await new Promise((r,i)=>{this.realFs.close(e,this.makeCallback(r,i))})}closeSync(e){this.realFs.closeSync(e)}createReadStream(e,r){let i=e!==null?j.fromPortablePath(e):e;return this.realFs.createReadStream(i,r)}createWriteStream(e,r){let i=e!==null?j.fromPortablePath(e):e;return this.realFs.createWriteStream(i,r)}async realpathPromise(e){return await new Promise((r,i)=>{this.realFs.realpath(j.fromPortablePath(e),{},this.makeCallback(r,i))}).then(r=>j.toPortablePath(r))}realpathSync(e){return j.toPortablePath(this.realFs.realpathSync(j.fromPortablePath(e),{}))}async existsPromise(e){return await new Promise(r=>{this.realFs.exists(j.fromPortablePath(e),r)})}accessSync(e,r){return this.realFs.accessSync(j.fromPortablePath(e),r)}async accessPromise(e,r){return await new Promise((i,n)=>{this.realFs.access(j.fromPortablePath(e),r,this.makeCallback(i,n))})}existsSync(e){return this.realFs.existsSync(j.fromPortablePath(e))}async statPromise(e,r){return await new Promise((i,n)=>{r?this.realFs.stat(j.fromPortablePath(e),r,this.makeCallback(i,n)):this.realFs.stat(j.fromPortablePath(e),this.makeCallback(i,n))})}statSync(e,r){return r?this.realFs.statSync(j.fromPortablePath(e),r):this.realFs.statSync(j.fromPortablePath(e))}async fstatPromise(e,r){return await new Promise((i,n)=>{r?this.realFs.fstat(e,r,this.makeCallback(i,n)):this.realFs.fstat(e,this.makeCallback(i,n))})}fstatSync(e,r){return r?this.realFs.fstatSync(e,r):this.realFs.fstatSync(e)}async lstatPromise(e,r){return await new Promise((i,n)=>{r?this.realFs.lstat(j.fromPortablePath(e),r,this.makeCallback(i,n)):this.realFs.lstat(j.fromPortablePath(e),this.makeCallback(i,n))})}lstatSync(e,r){return r?this.realFs.lstatSync(j.fromPortablePath(e),r):this.realFs.lstatSync(j.fromPortablePath(e))}async chmodPromise(e,r){return await new Promise((i,n)=>{this.realFs.chmod(j.fromPortablePath(e),r,this.makeCallback(i,n))})}chmodSync(e,r){return this.realFs.chmodSync(j.fromPortablePath(e),r)}async chownPromise(e,r,i){return await new Promise((n,s)=>{this.realFs.chown(j.fromPortablePath(e),r,i,this.makeCallback(n,s))})}chownSync(e,r,i){return this.realFs.chownSync(j.fromPortablePath(e),r,i)}async renamePromise(e,r){return await new Promise((i,n)=>{this.realFs.rename(j.fromPortablePath(e),j.fromPortablePath(r),this.makeCallback(i,n))})}renameSync(e,r){return this.realFs.renameSync(j.fromPortablePath(e),j.fromPortablePath(r))}async copyFilePromise(e,r,i=0){return await new Promise((n,s)=>{this.realFs.copyFile(j.fromPortablePath(e),j.fromPortablePath(r),i,this.makeCallback(n,s))})}copyFileSync(e,r,i=0){return this.realFs.copyFileSync(j.fromPortablePath(e),j.fromPortablePath(r),i)}async appendFilePromise(e,r,i){return await new Promise((n,s)=>{let o=typeof e=="string"?j.fromPortablePath(e):e;i?this.realFs.appendFile(o,r,i,this.makeCallback(n,s)):this.realFs.appendFile(o,r,this.makeCallback(n,s))})}appendFileSync(e,r,i){let n=typeof e=="string"?j.fromPortablePath(e):e;i?this.realFs.appendFileSync(n,r,i):this.realFs.appendFileSync(n,r)}async writeFilePromise(e,r,i){return await new Promise((n,s)=>{let o=typeof e=="string"?j.fromPortablePath(e):e;i?this.realFs.writeFile(o,r,i,this.makeCallback(n,s)):this.realFs.writeFile(o,r,this.makeCallback(n,s))})}writeFileSync(e,r,i){let n=typeof e=="string"?j.fromPortablePath(e):e;i?this.realFs.writeFileSync(n,r,i):this.realFs.writeFileSync(n,r)}async unlinkPromise(e){return await new Promise((r,i)=>{this.realFs.unlink(j.fromPortablePath(e),this.makeCallback(r,i))})}unlinkSync(e){return this.realFs.unlinkSync(j.fromPortablePath(e))}async utimesPromise(e,r,i){return await new Promise((n,s)=>{this.realFs.utimes(j.fromPortablePath(e),r,i,this.makeCallback(n,s))})}utimesSync(e,r,i){this.realFs.utimesSync(j.fromPortablePath(e),r,i)}async lutimesPromiseImpl(e,r,i){let n=this.realFs.lutimes;if(typeof n=="undefined")throw Gh("unavailable Node binding",`lutimes '${e}'`);return await new Promise((s,o)=>{n.call(this.realFs,j.fromPortablePath(e),r,i,this.makeCallback(s,o))})}lutimesSyncImpl(e,r,i){let n=this.realFs.lutimesSync;if(typeof n=="undefined")throw Gh("unavailable Node binding",`lutimes '${e}'`);n.call(this.realFs,j.fromPortablePath(e),r,i)}async mkdirPromise(e,r){return await new Promise((i,n)=>{this.realFs.mkdir(j.fromPortablePath(e),r,this.makeCallback(i,n))})}mkdirSync(e,r){return this.realFs.mkdirSync(j.fromPortablePath(e),r)}async rmdirPromise(e,r){return await new Promise((i,n)=>{r?this.realFs.rmdir(j.fromPortablePath(e),r,this.makeCallback(i,n)):this.realFs.rmdir(j.fromPortablePath(e),this.makeCallback(i,n))})}rmdirSync(e,r){return this.realFs.rmdirSync(j.fromPortablePath(e),r)}async linkPromise(e,r){return await new Promise((i,n)=>{this.realFs.link(j.fromPortablePath(e),j.fromPortablePath(r),this.makeCallback(i,n))})}linkSync(e,r){return this.realFs.linkSync(j.fromPortablePath(e),j.fromPortablePath(r))}async symlinkPromise(e,r,i){return await new Promise((n,s)=>{this.realFs.symlink(j.fromPortablePath(e.replace(/\/+$/,"")),j.fromPortablePath(r),i,this.makeCallback(n,s))})}symlinkSync(e,r,i){return this.realFs.symlinkSync(j.fromPortablePath(e.replace(/\/+$/,"")),j.fromPortablePath(r),i)}async readFilePromise(e,r){return await new Promise((i,n)=>{let s=typeof e=="string"?j.fromPortablePath(e):e;this.realFs.readFile(s,r,this.makeCallback(i,n))})}readFileSync(e,r){let i=typeof e=="string"?j.fromPortablePath(e):e;return this.realFs.readFileSync(i,r)}async readdirPromise(e,r){return await new Promise((i,n)=>{(r==null?void 0:r.withFileTypes)?this.realFs.readdir(j.fromPortablePath(e),{withFileTypes:!0},this.makeCallback(i,n)):this.realFs.readdir(j.fromPortablePath(e),this.makeCallback(s=>i(s),n))})}readdirSync(e,r){return(r==null?void 0:r.withFileTypes)?this.realFs.readdirSync(j.fromPortablePath(e),{withFileTypes:!0}):this.realFs.readdirSync(j.fromPortablePath(e))}async readlinkPromise(e){return await new Promise((r,i)=>{this.realFs.readlink(j.fromPortablePath(e),this.makeCallback(r,i))}).then(r=>j.toPortablePath(r))}readlinkSync(e){return j.toPortablePath(this.realFs.readlinkSync(j.fromPortablePath(e)))}async truncatePromise(e,r){return await new Promise((i,n)=>{this.realFs.truncate(j.fromPortablePath(e),r,this.makeCallback(i,n))})}truncateSync(e,r){return this.realFs.truncateSync(j.fromPortablePath(e),r)}watch(e,r,i){return this.realFs.watch(j.fromPortablePath(e),r,i)}watchFile(e,r,i){return this.realFs.watchFile(j.fromPortablePath(e),r,i)}unwatchFile(e,r){return this.realFs.unwatchFile(j.fromPortablePath(e),r)}makeCallback(e,r){return(i,n)=>{i?r(i):e(n)}}};var dM=ge(require("events"));var tc;(function(r){r.Change="change",r.Stop="stop"})(tc||(tc={}));var rc;(function(i){i.Ready="ready",i.Running="running",i.Stopped="stopped"})(rc||(rc={}));function CM(t,e){if(t!==e)throw new Error(`Invalid StatWatcher status: expected '${e}', got '${t}'`)}var qh=class extends dM.EventEmitter{constructor(e,r,{bigint:i=!1}={}){super();this.status=rc.Ready;this.changeListeners=new Map;this.startTimeout=null;this.fakeFs=e,this.path=r,this.bigint=i,this.lastStats=this.stat()}static create(e,r,i){let n=new qh(e,r,i);return n.start(),n}start(){CM(this.status,rc.Ready),this.status=rc.Running,this.startTimeout=setTimeout(()=>{this.startTimeout=null,this.fakeFs.existsSync(this.path)||this.emit(tc.Change,this.lastStats,this.lastStats)},3)}stop(){CM(this.status,rc.Running),this.status=rc.Stopped,this.startTimeout!==null&&(clearTimeout(this.startTimeout),this.startTimeout=null),this.emit(tc.Stop)}stat(){try{return this.fakeFs.statSync(this.path,{bigint:this.bigint})}catch(e){let r=this.bigint?new Uh:new MA;return ME(r)}}makeInterval(e){let r=setInterval(()=>{let i=this.stat(),n=this.lastStats;fQ(i,n)||(this.lastStats=i,this.emit(tc.Change,i,n))},e.interval);return e.persistent?r:r.unref()}registerChangeListener(e,r){this.addListener(tc.Change,e),this.changeListeners.set(e,this.makeInterval(r))}unregisterChangeListener(e){this.removeListener(tc.Change,e);let r=this.changeListeners.get(e);typeof r!="undefined"&&clearInterval(r),this.changeListeners.delete(e)}unregisterAllChangeListeners(){for(let e of this.changeListeners.keys())this.unregisterChangeListener(e)}hasChangeListeners(){return this.changeListeners.size>0}ref(){for(let e of this.changeListeners.values())e.ref();return this}unref(){for(let e of this.changeListeners.values())e.unref();return this}};var JE=new WeakMap;function WE(t,e,r,i){let n,s,o,a;switch(typeof r){case"function":n=!1,s=!0,o=5007,a=r;break;default:({bigint:n=!1,persistent:s=!0,interval:o=5007}=r),a=i;break}let l=JE.get(t);typeof l=="undefined"&&JE.set(t,l=new Map);let c=l.get(e);return typeof c=="undefined"&&(c=qh.create(t,e,{bigint:n}),l.set(e,c)),c.registerChangeListener(a,{persistent:s,interval:o}),c}function Jh(t,e,r){let i=JE.get(t);if(typeof i=="undefined")return;let n=i.get(e);typeof n!="undefined"&&(typeof r=="undefined"?n.unregisterAllChangeListeners():n.unregisterChangeListener(r),n.hasChangeListeners()||(n.stop(),i.delete(e)))}function Wh(t){let e=JE.get(t);if(typeof e!="undefined")for(let r of e.keys())Jh(t,r)}var ic="mixed";function Sfe(t){if(typeof t=="string"&&String(+t)===t)return+t;if(Number.isFinite(t))return t<0?Date.now()/1e3:t;if(mM.types.isDate(t))return t.getTime()/1e3;throw new Error("Invalid time")}function EM(){return Buffer.from([80,75,5,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0])}var Ai=class extends ec{constructor(e,r){super();this.lzSource=null;this.listings=new Map;this.entries=new Map;this.fileSources=new Map;this.fds=new Map;this.nextFd=0;this.ready=!1;this.readOnly=!1;this.libzip=r.libzip;let i=r;if(this.level=typeof i.level!="undefined"?i.level:ic,e!=null||(e=EM()),typeof e=="string"){let{baseFs:o=new ar}=i;this.baseFs=o,this.path=e}else this.path=null,this.baseFs=null;if(r.stats)this.stats=r.stats;else if(typeof e=="string")try{this.stats=this.baseFs.statSync(e)}catch(o){if(o.code==="ENOENT"&&i.create)this.stats=Kh();else throw o}else this.stats=Kh();let n=this.libzip.malloc(4);try{let o=0;if(typeof e=="string"&&i.create&&(o|=this.libzip.ZIP_CREATE|this.libzip.ZIP_TRUNCATE),r.readOnly&&(o|=this.libzip.ZIP_RDONLY,this.readOnly=!0),typeof e=="string")this.zip=this.libzip.open(j.fromPortablePath(e),o,n);else{let a=this.allocateUnattachedSource(e);try{this.zip=this.libzip.openFromSource(a,o,n),this.lzSource=a}catch(l){throw this.libzip.source.free(a),l}}if(this.zip===0){let a=this.libzip.struct.errorS();throw this.libzip.error.initWithCode(a,this.libzip.getValue(n,"i32")),this.makeLibzipError(a)}}finally{this.libzip.free(n)}this.listings.set(Me.root,new Set);let s=this.libzip.getNumEntries(this.zip,0);for(let o=0;o<s;++o){let a=this.libzip.getName(this.zip,o,0);if(k.isAbsolute(a))continue;let l=k.resolve(Me.root,a);this.registerEntry(l,o),a.endsWith("/")&&this.registerListing(l)}if(this.symlinkCount=this.libzip.ext.countSymlinks(this.zip),this.symlinkCount===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));this.ready=!0}makeLibzipError(e){let r=this.libzip.struct.errorCodeZip(e),i=this.libzip.error.strerror(e),n=new mQ(i,this.libzip.errors[r]);if(r===this.libzip.errors.ZIP_ER_CHANGED)throw new Error(`Assertion failed: Unexpected libzip error: ${n.message}`);return n}getExtractHint(e){for(let r of this.entries.keys()){let i=this.pathUtils.extname(r);if(e.relevantExtensions.has(i))return!0}return!1}getAllFiles(){return Array.from(this.entries.keys())}getRealPath(){if(!this.path)throw new Error("ZipFS don't have real paths when loaded from a buffer");return this.path}getBufferAndClose(){if(this.prepareClose(),!this.lzSource)throw new Error("ZipFS was not created from a Buffer");try{if(this.libzip.source.keep(this.lzSource),this.libzip.close(this.zip)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));if(this.libzip.source.open(this.lzSource)===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));if(this.libzip.source.seek(this.lzSource,0,0,this.libzip.SEEK_END)===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));let e=this.libzip.source.tell(this.lzSource);if(e===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));if(this.libzip.source.seek(this.lzSource,0,0,this.libzip.SEEK_SET)===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));let r=this.libzip.malloc(e);if(!r)throw new Error("Couldn't allocate enough memory");try{let i=this.libzip.source.read(this.lzSource,r,e);if(i===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));if(i<e)throw new Error("Incomplete read");if(i>e)throw new Error("Overread");let n=this.libzip.HEAPU8.subarray(r,r+e);return Buffer.from(n)}finally{this.libzip.free(r)}}finally{this.libzip.source.close(this.lzSource),this.libzip.source.free(this.lzSource),this.ready=!1}}prepareClose(){if(!this.ready)throw GE("archive closed, close");Wh(this)}saveAndClose(){if(!this.path||!this.baseFs)throw new Error("ZipFS cannot be saved and must be discarded when loaded from a buffer");if(this.prepareClose(),this.readOnly){this.discardAndClose();return}let e=this.baseFs.existsSync(this.path)||this.stats.mode===Mh?void 0:this.stats.mode;if(this.entries.size===0)this.discardAndClose(),this.baseFs.writeFileSync(this.path,EM(),{mode:e});else{if(this.libzip.close(this.zip)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));typeof e!="undefined"&&this.baseFs.chmodSync(this.path,e)}this.ready=!1}discardAndClose(){this.prepareClose(),this.libzip.discard(this.zip),this.ready=!1}resolve(e){return k.resolve(Me.root,e)}async openPromise(e,r,i){return this.openSync(e,r,i)}openSync(e,r,i){let n=this.nextFd++;return this.fds.set(n,{cursor:0,p:e}),n}hasOpenFileHandles(){return!!this.fds.size}async opendirPromise(e,r){return this.opendirSync(e,r)}opendirSync(e,r={}){let i=this.resolveFilename(`opendir '${e}'`,e);if(!this.entries.has(i)&&!this.listings.has(i))throw to(`opendir '${e}'`);let n=this.listings.get(i);if(!n)throw Do(`opendir '${e}'`);let s=[...n],o=this.openSync(i,"r");return qE(this,i,s,{onClose:()=>{this.closeSync(o)}})}async readPromise(e,r,i,n,s){return this.readSync(e,r,i,n,s)}readSync(e,r,i=0,n=r.byteLength,s=-1){let o=this.fds.get(e);if(typeof o=="undefined")throw en("read");let a=s===-1||s===null?o.cursor:s,l=this.readFileSync(o.p);l.copy(r,i,a,a+n);let c=Math.max(0,Math.min(l.length-a,n));return(s===-1||s===null)&&(o.cursor+=c),c}async writePromise(e,r,i,n,s){return typeof r=="string"?this.writeSync(e,r,s):this.writeSync(e,r,i,n,s)}writeSync(e,r,i,n,s){throw typeof this.fds.get(e)=="undefined"?en("read"):new Error("Unimplemented")}async closePromise(e){return this.closeSync(e)}closeSync(e){if(typeof this.fds.get(e)=="undefined")throw en("read");this.fds.delete(e)}createReadStream(e,{encoding:r}={}){if(e===null)throw new Error("Unimplemented");let i=this.openSync(e,"r"),n=Object.assign(new EQ.PassThrough({emitClose:!0,autoDestroy:!0,destroy:(o,a)=>{clearImmediate(s),this.closeSync(i),a(o)}}),{close(){n.destroy()},bytesRead:0,path:e}),s=setImmediate(async()=>{try{let o=await this.readFilePromise(e,r);n.bytesRead=o.length,n.end(o)}catch(o){n.destroy(o)}});return n}createWriteStream(e,{encoding:r}={}){if(this.readOnly)throw In(`open '${e}'`);if(e===null)throw new Error("Unimplemented");let i=[],n=this.openSync(e,"w"),s=Object.assign(new EQ.PassThrough({autoDestroy:!0,emitClose:!0,destroy:(o,a)=>{try{o?a(o):(this.writeFileSync(e,Buffer.concat(i),r),a(null))}catch(l){a(l)}finally{this.closeSync(n)}}}),{bytesWritten:0,path:e,close(){s.destroy()}});return s.on("data",o=>{let a=Buffer.from(o);s.bytesWritten+=a.length,i.push(a)}),s}async realpathPromise(e){return this.realpathSync(e)}realpathSync(e){let r=this.resolveFilename(`lstat '${e}'`,e);if(!this.entries.has(r)&&!this.listings.has(r))throw to(`lstat '${e}'`);return r}async existsPromise(e){return this.existsSync(e)}existsSync(e){if(!this.ready)throw GE(`archive closed, existsSync '${e}'`);if(this.symlinkCount===0){let i=k.resolve(Me.root,e);return this.entries.has(i)||this.listings.has(i)}let r;try{r=this.resolveFilename(`stat '${e}'`,e)}catch(i){return!1}return this.entries.has(r)||this.listings.has(r)}async accessPromise(e,r){return this.accessSync(e,r)}accessSync(e,r=qu.constants.F_OK){let i=this.resolveFilename(`access '${e}'`,e);if(!this.entries.has(i)&&!this.listings.has(i))throw to(`access '${e}'`);if(this.readOnly&&r&qu.constants.W_OK)throw In(`access '${e}'`)}async statPromise(e,r){return this.statSync(e,r)}statSync(e,r){let i=this.resolveFilename(`stat '${e}'`,e);if(!this.entries.has(i)&&!this.listings.has(i))throw to(`stat '${e}'`);if(e[e.length-1]==="/"&&!this.listings.has(i))throw Do(`stat '${e}'`);return this.statImpl(`stat '${e}'`,i,r)}async fstatPromise(e,r){return this.fstatSync(e,r)}fstatSync(e,r){let i=this.fds.get(e);if(typeof i=="undefined")throw en("fstatSync");let{p:n}=i,s=this.resolveFilename(`stat '${n}'`,n);if(!this.entries.has(s)&&!this.listings.has(s))throw to(`stat '${n}'`);if(n[n.length-1]==="/"&&!this.listings.has(s))throw Do(`stat '${n}'`);return this.statImpl(`fstat '${n}'`,s,r)}async lstatPromise(e,r){return this.lstatSync(e,r)}lstatSync(e,r){let i=this.resolveFilename(`lstat '${e}'`,e,!1);if(!this.entries.has(i)&&!this.listings.has(i))throw to(`lstat '${e}'`);if(e[e.length-1]==="/"&&!this.listings.has(i))throw Do(`lstat '${e}'`);return this.statImpl(`lstat '${e}'`,i,r)}statImpl(e,r,i={}){let n=this.entries.get(r);if(typeof n!="undefined"){let s=this.libzip.struct.statS();if(this.libzip.statIndex(this.zip,n,0,0,s)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));let a=this.stats.uid,l=this.stats.gid,c=this.libzip.struct.statSize(s)>>>0,u=512,g=Math.ceil(c/u),f=(this.libzip.struct.statMtime(s)>>>0)*1e3,h=f,p=f,m=f,y=new Date(h),Q=new Date(p),S=new Date(m),x=new Date(f),M=this.listings.has(r)?Sa:this.isSymbolicLink(n)?xa:ka,Y=M===Sa?493:420,U=M|this.getUnixMode(n,Y)&511,J=this.libzip.struct.statCrc(s),W=Object.assign(new MA,{uid:a,gid:l,size:c,blksize:u,blocks:g,atime:y,birthtime:Q,ctime:S,mtime:x,atimeMs:h,birthtimeMs:p,ctimeMs:m,mtimeMs:f,mode:U,crc:J});return i.bigint===!0?UE(W):W}if(this.listings.has(r)){let s=this.stats.uid,o=this.stats.gid,a=0,l=512,c=0,u=this.stats.mtimeMs,g=this.stats.mtimeMs,f=this.stats.mtimeMs,h=this.stats.mtimeMs,p=new Date(u),m=new Date(g),y=new Date(f),Q=new Date(h),S=Sa|493,x=0,M=Object.assign(new MA,{uid:s,gid:o,size:a,blksize:l,blocks:c,atime:p,birthtime:m,ctime:y,mtime:Q,atimeMs:u,birthtimeMs:g,ctimeMs:f,mtimeMs:h,mode:S,crc:x});return i.bigint===!0?UE(M):M}throw new Error("Unreachable")}getUnixMode(e,r){if(this.libzip.file.getExternalAttributes(this.zip,e,0,0,this.libzip.uint08S,this.libzip.uint32S)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));return this.libzip.getValue(this.libzip.uint08S,"i8")>>>0!==this.libzip.ZIP_OPSYS_UNIX?r:this.libzip.getValue(this.libzip.uint32S,"i32")>>>16}registerListing(e){let r=this.listings.get(e);if(r)return r;this.registerListing(k.dirname(e)).add(k.basename(e));let n=new Set;return this.listings.set(e,n),n}registerEntry(e,r){this.registerListing(k.dirname(e)).add(k.basename(e)),this.entries.set(e,r)}unregisterListing(e){this.listings.delete(e);let r=this.listings.get(k.dirname(e));r==null||r.delete(k.basename(e))}unregisterEntry(e){this.unregisterListing(e);let r=this.entries.get(e);this.entries.delete(e),typeof r!="undefined"&&(this.fileSources.delete(r),this.isSymbolicLink(r)&&this.symlinkCount--)}deleteEntry(e,r){if(this.unregisterEntry(e),this.libzip.delete(this.zip,r)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}resolveFilename(e,r,i=!0){if(!this.ready)throw GE(`archive closed, ${e}`);let n=k.resolve(Me.root,r);if(n==="/")return Me.root;let s=this.entries.get(n);if(i&&s!==void 0)if(this.symlinkCount!==0&&this.isSymbolicLink(s)){let o=this.getFileSource(s).toString();return this.resolveFilename(e,k.resolve(k.dirname(n),o),!0)}else return n;for(;;){let o=this.resolveFilename(e,k.dirname(n),!0),a=this.listings.has(o),l=this.entries.has(o);if(!a&&!l)throw to(e);if(!a)throw Do(e);if(n=k.resolve(o,k.basename(n)),!i||this.symlinkCount===0)break;let c=this.libzip.name.locate(this.zip,n.slice(1));if(c===-1)break;if(this.isSymbolicLink(c)){let u=this.getFileSource(c).toString();n=k.resolve(k.dirname(n),u)}else break}return n}allocateBuffer(e){Buffer.isBuffer(e)||(e=Buffer.from(e));let r=this.libzip.malloc(e.byteLength);if(!r)throw new Error("Couldn't allocate enough memory");return new Uint8Array(this.libzip.HEAPU8.buffer,r,e.byteLength).set(e),{buffer:r,byteLength:e.byteLength}}allocateUnattachedSource(e){let r=this.libzip.struct.errorS(),{buffer:i,byteLength:n}=this.allocateBuffer(e),s=this.libzip.source.fromUnattachedBuffer(i,n,0,!0,r);if(s===0)throw this.libzip.free(r),this.makeLibzipError(r);return s}allocateSource(e){let{buffer:r,byteLength:i}=this.allocateBuffer(e),n=this.libzip.source.fromBuffer(this.zip,r,i,0,!0);if(n===0)throw this.libzip.free(r),this.makeLibzipError(this.libzip.getError(this.zip));return n}setFileSource(e,r){let i=Buffer.isBuffer(r)?r:Buffer.from(r),n=k.relative(Me.root,e),s=this.allocateSource(r);try{let o=this.libzip.file.add(this.zip,n,s,this.libzip.ZIP_FL_OVERWRITE);if(o===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));if(this.level!=="mixed"){let a=this.level===0?this.libzip.ZIP_CM_STORE:this.libzip.ZIP_CM_DEFLATE;if(this.libzip.file.setCompression(this.zip,o,0,a,this.level)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}return this.fileSources.set(o,i),o}catch(o){throw this.libzip.source.free(s),o}}isSymbolicLink(e){if(this.symlinkCount===0)return!1;if(this.libzip.file.getExternalAttributes(this.zip,e,0,0,this.libzip.uint08S,this.libzip.uint32S)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));return this.libzip.getValue(this.libzip.uint08S,"i8")>>>0!==this.libzip.ZIP_OPSYS_UNIX?!1:(this.libzip.getValue(this.libzip.uint32S,"i32")>>>16&zn)===xa}getFileSource(e,r={asyncDecompress:!1}){let i=this.fileSources.get(e);if(typeof i!="undefined")return i;let n=this.libzip.struct.statS();if(this.libzip.statIndex(this.zip,e,0,0,n)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));let o=this.libzip.struct.statCompSize(n),a=this.libzip.struct.statCompMethod(n),l=this.libzip.malloc(o);try{let c=this.libzip.fopenIndex(this.zip,e,0,this.libzip.ZIP_FL_COMPRESSED);if(c===0)throw this.makeLibzipError(this.libzip.getError(this.zip));try{let u=this.libzip.fread(c,l,o,0);if(u===-1)throw this.makeLibzipError(this.libzip.file.getError(c));if(u<o)throw new Error("Incomplete read");if(u>o)throw new Error("Overread");let g=this.libzip.HEAPU8.subarray(l,l+o),f=Buffer.from(g);if(a===0)return this.fileSources.set(e,f),f;if(r.asyncDecompress)return new Promise((h,p)=>{IQ.default.inflateRaw(f,(m,y)=>{m?p(m):(this.fileSources.set(e,y),h(y))})});{let h=IQ.default.inflateRawSync(f);return this.fileSources.set(e,h),h}}finally{this.libzip.fclose(c)}}finally{this.libzip.free(l)}}async chmodPromise(e,r){return this.chmodSync(e,r)}chmodSync(e,r){if(this.readOnly)throw In(`chmod '${e}'`);r&=493;let i=this.resolveFilename(`chmod '${e}'`,e,!1),n=this.entries.get(i);if(typeof n=="undefined")throw new Error(`Assertion failed: The entry should have been registered (${i})`);let o=this.getUnixMode(n,ka|0)&~511|r;if(this.libzip.file.setExternalAttributes(this.zip,n,0,0,this.libzip.ZIP_OPSYS_UNIX,o<<16)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}async chownPromise(e,r,i){return this.chownSync(e,r,i)}chownSync(e,r,i){throw new Error("Unimplemented")}async renamePromise(e,r){return this.renameSync(e,r)}renameSync(e,r){throw new Error("Unimplemented")}async copyFilePromise(e,r,i){let{indexSource:n,indexDest:s,resolvedDestP:o}=this.prepareCopyFile(e,r,i),a=await this.getFileSource(n,{asyncDecompress:!0}),l=this.setFileSource(o,a);l!==s&&this.registerEntry(o,l)}copyFileSync(e,r,i=0){let{indexSource:n,indexDest:s,resolvedDestP:o}=this.prepareCopyFile(e,r,i),a=this.getFileSource(n),l=this.setFileSource(o,a);l!==s&&this.registerEntry(o,l)}prepareCopyFile(e,r,i=0){if(this.readOnly)throw In(`copyfile '${e} -> '${r}'`);if((i&qu.constants.COPYFILE_FICLONE_FORCE)!=0)throw Gh("unsupported clone operation",`copyfile '${e}' -> ${r}'`);let n=this.resolveFilename(`copyfile '${e} -> ${r}'`,e),s=this.entries.get(n);if(typeof s=="undefined")throw UA(`copyfile '${e}' -> '${r}'`);let o=this.resolveFilename(`copyfile '${e}' -> ${r}'`,r),a=this.entries.get(o);if((i&(qu.constants.COPYFILE_EXCL|qu.constants.COPYFILE_FICLONE_FORCE))!=0&&typeof a!="undefined")throw YE(`copyfile '${e}' -> '${r}'`);return{indexSource:s,resolvedDestP:o,indexDest:a}}async appendFilePromise(e,r,i){if(this.readOnly)throw In(`open '${e}'`);return typeof i=="undefined"?i={flag:"a"}:typeof i=="string"?i={flag:"a",encoding:i}:typeof i.flag=="undefined"&&(i=N({flag:"a"},i)),this.writeFilePromise(e,r,i)}appendFileSync(e,r,i={}){if(this.readOnly)throw In(`open '${e}'`);return typeof i=="undefined"?i={flag:"a"}:typeof i=="string"?i={flag:"a",encoding:i}:typeof i.flag=="undefined"&&(i=N({flag:"a"},i)),this.writeFileSync(e,r,i)}fdToPath(e,r){var n;let i=(n=this.fds.get(e))==null?void 0:n.p;if(typeof i=="undefined")throw en(r);return i}async writeFilePromise(e,r,i){let{encoding:n,mode:s,index:o,resolvedP:a}=this.prepareWriteFile(e,i);o!==void 0&&typeof i=="object"&&i.flag&&i.flag.includes("a")&&(r=Buffer.concat([await this.getFileSource(o,{asyncDecompress:!0}),Buffer.from(r)])),n!==null&&(r=r.toString(n));let l=this.setFileSource(a,r);l!==o&&this.registerEntry(a,l),s!==null&&await this.chmodPromise(a,s)}writeFileSync(e,r,i){let{encoding:n,mode:s,index:o,resolvedP:a}=this.prepareWriteFile(e,i);o!==void 0&&typeof i=="object"&&i.flag&&i.flag.includes("a")&&(r=Buffer.concat([this.getFileSource(o),Buffer.from(r)])),n!==null&&(r=r.toString(n));let l=this.setFileSource(a,r);l!==o&&this.registerEntry(a,l),s!==null&&this.chmodSync(a,s)}prepareWriteFile(e,r){if(typeof e=="number"&&(e=this.fdToPath(e,"read")),this.readOnly)throw In(`open '${e}'`);let i=this.resolveFilename(`open '${e}'`,e);if(this.listings.has(i))throw Yh(`open '${e}'`);let n=null,s=null;typeof r=="string"?n=r:typeof r=="object"&&({encoding:n=null,mode:s=null}=r);let o=this.entries.get(i);return{encoding:n,mode:s,resolvedP:i,index:o}}async unlinkPromise(e){return this.unlinkSync(e)}unlinkSync(e){if(this.readOnly)throw In(`unlink '${e}'`);let r=this.resolveFilename(`unlink '${e}'`,e);if(this.listings.has(r))throw Yh(`unlink '${e}'`);let i=this.entries.get(r);if(typeof i=="undefined")throw UA(`unlink '${e}'`);this.deleteEntry(r,i)}async utimesPromise(e,r,i){return this.utimesSync(e,r,i)}utimesSync(e,r,i){if(this.readOnly)throw In(`utimes '${e}'`);let n=this.resolveFilename(`utimes '${e}'`,e);this.utimesImpl(n,i)}async lutimesPromise(e,r,i){return this.lutimesSync(e,r,i)}lutimesSync(e,r,i){if(this.readOnly)throw In(`lutimes '${e}'`);let n=this.resolveFilename(`utimes '${e}'`,e,!1);this.utimesImpl(n,i)}utimesImpl(e,r){this.listings.has(e)&&(this.entries.has(e)||this.hydrateDirectory(e));let i=this.entries.get(e);if(i===void 0)throw new Error("Unreachable");if(this.libzip.file.setMtime(this.zip,i,0,Sfe(r),0)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}async mkdirPromise(e,r){return this.mkdirSync(e,r)}mkdirSync(e,{mode:r=493,recursive:i=!1}={}){if(i){this.mkdirpSync(e,{chmod:r});return}if(this.readOnly)throw In(`mkdir '${e}'`);let n=this.resolveFilename(`mkdir '${e}'`,e);if(this.entries.has(n)||this.listings.has(n))throw YE(`mkdir '${e}'`);this.hydrateDirectory(n),this.chmodSync(n,r)}async rmdirPromise(e,r){return this.rmdirSync(e,r)}rmdirSync(e,{recursive:r=!1}={}){if(this.readOnly)throw In(`rmdir '${e}'`);if(r){this.removeSync(e);return}let i=this.resolveFilename(`rmdir '${e}'`,e),n=this.listings.get(i);if(!n)throw Do(`rmdir '${e}'`);if(n.size>0)throw cM(`rmdir '${e}'`);let s=this.entries.get(i);if(typeof s=="undefined")throw UA(`rmdir '${e}'`);this.deleteEntry(e,s)}hydrateDirectory(e){let r=this.libzip.dir.add(this.zip,k.relative(Me.root,e));if(r===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));return this.registerListing(e),this.registerEntry(e,r),r}async linkPromise(e,r){return this.linkSync(e,r)}linkSync(e,r){throw uM(`link '${e}' -> '${r}'`)}async symlinkPromise(e,r){return this.symlinkSync(e,r)}symlinkSync(e,r){if(this.readOnly)throw In(`symlink '${e}' -> '${r}'`);let i=this.resolveFilename(`symlink '${e}' -> '${r}'`,r);if(this.listings.has(i))throw Yh(`symlink '${e}' -> '${r}'`);if(this.entries.has(i))throw YE(`symlink '${e}' -> '${r}'`);let n=this.setFileSource(i,e);if(this.registerEntry(i,n),this.libzip.file.setExternalAttributes(this.zip,n,0,0,this.libzip.ZIP_OPSYS_UNIX,(xa|511)<<16)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));this.symlinkCount+=1}async readFilePromise(e,r){typeof r=="object"&&(r=r?r.encoding:void 0);let i=await this.readFileBuffer(e,{asyncDecompress:!0});return r?i.toString(r):i}readFileSync(e,r){typeof r=="object"&&(r=r?r.encoding:void 0);let i=this.readFileBuffer(e);return r?i.toString(r):i}readFileBuffer(e,r={asyncDecompress:!1}){typeof e=="number"&&(e=this.fdToPath(e,"read"));let i=this.resolveFilename(`open '${e}'`,e);if(!this.entries.has(i)&&!this.listings.has(i))throw to(`open '${e}'`);if(e[e.length-1]==="/"&&!this.listings.has(i))throw Do(`open '${e}'`);if(this.listings.has(i))throw Yh("read");let n=this.entries.get(i);if(n===void 0)throw new Error("Unreachable");return this.getFileSource(n,r)}async readdirPromise(e,r){return this.readdirSync(e,r)}readdirSync(e,r){let i=this.resolveFilename(`scandir '${e}'`,e);if(!this.entries.has(i)&&!this.listings.has(i))throw to(`scandir '${e}'`);let n=this.listings.get(i);if(!n)throw Do(`scandir '${e}'`);let s=[...n];return(r==null?void 0:r.withFileTypes)?s.map(o=>Object.assign(this.statImpl("lstat",k.join(e,o)),{name:o})):s}async readlinkPromise(e){let r=this.prepareReadlink(e);return(await this.getFileSource(r,{asyncDecompress:!0})).toString()}readlinkSync(e){let r=this.prepareReadlink(e);return this.getFileSource(r).toString()}prepareReadlink(e){let r=this.resolveFilename(`readlink '${e}'`,e,!1);if(!this.entries.has(r)&&!this.listings.has(r))throw to(`readlink '${e}'`);if(e[e.length-1]==="/"&&!this.listings.has(r))throw Do(`open '${e}'`);if(this.listings.has(r))throw UA(`readlink '${e}'`);let i=this.entries.get(r);if(i===void 0)throw new Error("Unreachable");if(!this.isSymbolicLink(i))throw UA(`readlink '${e}'`);return i}async truncatePromise(e,r=0){let i=this.resolveFilename(`open '${e}'`,e),n=this.entries.get(i);if(typeof n=="undefined")throw UA(`open '${e}'`);let s=await this.getFileSource(n,{asyncDecompress:!0}),o=Buffer.alloc(r,0);return s.copy(o),await this.writeFilePromise(e,o)}truncateSync(e,r=0){let i=this.resolveFilename(`open '${e}'`,e),n=this.entries.get(i);if(typeof n=="undefined")throw UA(`open '${e}'`);let s=this.getFileSource(n),o=Buffer.alloc(r,0);return s.copy(o),this.writeFileSync(e,o)}watch(e,r,i){let n;switch(typeof r){case"function":case"string":case"undefined":n=!0;break;default:({persistent:n=!0}=r);break}if(!n)return{on:()=>{},close:()=>{}};let s=setInterval(()=>{},24*60*60*1e3);return{on:()=>{},close:()=>{clearInterval(s)}}}watchFile(e,r,i){let n=k.resolve(Me.root,e);return WE(this,n,r,i)}unwatchFile(e,r){let i=k.resolve(Me.root,e);return Jh(this,i,r)}};var bi=class extends KA{getExtractHint(e){return this.baseFs.getExtractHint(e)}resolve(e){return this.mapFromBase(this.baseFs.resolve(this.mapToBase(e)))}getRealPath(){return this.mapFromBase(this.baseFs.getRealPath())}async openPromise(e,r,i){return this.baseFs.openPromise(this.mapToBase(e),r,i)}openSync(e,r,i){return this.baseFs.openSync(this.mapToBase(e),r,i)}async opendirPromise(e,r){return Object.assign(await this.baseFs.opendirPromise(this.mapToBase(e),r),{path:e})}opendirSync(e,r){return Object.assign(this.baseFs.opendirSync(this.mapToBase(e),r),{path:e})}async readPromise(e,r,i,n,s){return await this.baseFs.readPromise(e,r,i,n,s)}readSync(e,r,i,n,s){return this.baseFs.readSync(e,r,i,n,s)}async writePromise(e,r,i,n,s){return typeof r=="string"?await this.baseFs.writePromise(e,r,i):await this.baseFs.writePromise(e,r,i,n,s)}writeSync(e,r,i,n,s){return typeof r=="string"?this.baseFs.writeSync(e,r,i):this.baseFs.writeSync(e,r,i,n,s)}async closePromise(e){return this.baseFs.closePromise(e)}closeSync(e){this.baseFs.closeSync(e)}createReadStream(e,r){return this.baseFs.createReadStream(e!==null?this.mapToBase(e):e,r)}createWriteStream(e,r){return this.baseFs.createWriteStream(e!==null?this.mapToBase(e):e,r)}async realpathPromise(e){return this.mapFromBase(await this.baseFs.realpathPromise(this.mapToBase(e)))}realpathSync(e){return this.mapFromBase(this.baseFs.realpathSync(this.mapToBase(e)))}async existsPromise(e){return this.baseFs.existsPromise(this.mapToBase(e))}existsSync(e){return this.baseFs.existsSync(this.mapToBase(e))}accessSync(e,r){return this.baseFs.accessSync(this.mapToBase(e),r)}async accessPromise(e,r){return this.baseFs.accessPromise(this.mapToBase(e),r)}async statPromise(e,r){return this.baseFs.statPromise(this.mapToBase(e),r)}statSync(e,r){return this.baseFs.statSync(this.mapToBase(e),r)}async fstatPromise(e,r){return this.baseFs.fstatPromise(e,r)}fstatSync(e,r){return this.baseFs.fstatSync(e,r)}async lstatPromise(e,r){return this.baseFs.lstatPromise(this.mapToBase(e),r)}lstatSync(e,r){return this.baseFs.lstatSync(this.mapToBase(e),r)}async chmodPromise(e,r){return this.baseFs.chmodPromise(this.mapToBase(e),r)}chmodSync(e,r){return this.baseFs.chmodSync(this.mapToBase(e),r)}async chownPromise(e,r,i){return this.baseFs.chownPromise(this.mapToBase(e),r,i)}chownSync(e,r,i){return this.baseFs.chownSync(this.mapToBase(e),r,i)}async renamePromise(e,r){return this.baseFs.renamePromise(this.mapToBase(e),this.mapToBase(r))}renameSync(e,r){return this.baseFs.renameSync(this.mapToBase(e),this.mapToBase(r))}async copyFilePromise(e,r,i=0){return this.baseFs.copyFilePromise(this.mapToBase(e),this.mapToBase(r),i)}copyFileSync(e,r,i=0){return this.baseFs.copyFileSync(this.mapToBase(e),this.mapToBase(r),i)}async appendFilePromise(e,r,i){return this.baseFs.appendFilePromise(this.fsMapToBase(e),r,i)}appendFileSync(e,r,i){return this.baseFs.appendFileSync(this.fsMapToBase(e),r,i)}async writeFilePromise(e,r,i){return this.baseFs.writeFilePromise(this.fsMapToBase(e),r,i)}writeFileSync(e,r,i){return this.baseFs.writeFileSync(this.fsMapToBase(e),r,i)}async unlinkPromise(e){return this.baseFs.unlinkPromise(this.mapToBase(e))}unlinkSync(e){return this.baseFs.unlinkSync(this.mapToBase(e))}async utimesPromise(e,r,i){return this.baseFs.utimesPromise(this.mapToBase(e),r,i)}utimesSync(e,r,i){return this.baseFs.utimesSync(this.mapToBase(e),r,i)}async mkdirPromise(e,r){return this.baseFs.mkdirPromise(this.mapToBase(e),r)}mkdirSync(e,r){return this.baseFs.mkdirSync(this.mapToBase(e),r)}async rmdirPromise(e,r){return this.baseFs.rmdirPromise(this.mapToBase(e),r)}rmdirSync(e,r){return this.baseFs.rmdirSync(this.mapToBase(e),r)}async linkPromise(e,r){return this.baseFs.linkPromise(this.mapToBase(e),this.mapToBase(r))}linkSync(e,r){return this.baseFs.linkSync(this.mapToBase(e),this.mapToBase(r))}async symlinkPromise(e,r,i){let n=this.mapToBase(r);if(this.pathUtils.isAbsolute(e))return this.baseFs.symlinkPromise(this.mapToBase(e),n,i);let s=this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(r),e)),o=this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(n),s);return this.baseFs.symlinkPromise(o,n,i)}symlinkSync(e,r,i){let n=this.mapToBase(r);if(this.pathUtils.isAbsolute(e))return this.baseFs.symlinkSync(this.mapToBase(e),n,i);let s=this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(r),e)),o=this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(n),s);return this.baseFs.symlinkSync(o,n,i)}async readFilePromise(e,r){return r==="utf8"?this.baseFs.readFilePromise(this.fsMapToBase(e),r):this.baseFs.readFilePromise(this.fsMapToBase(e),r)}readFileSync(e,r){return r==="utf8"?this.baseFs.readFileSync(this.fsMapToBase(e),r):this.baseFs.readFileSync(this.fsMapToBase(e),r)}async readdirPromise(e,r){return this.baseFs.readdirPromise(this.mapToBase(e),r)}readdirSync(e,r){return this.baseFs.readdirSync(this.mapToBase(e),r)}async readlinkPromise(e){return this.mapFromBase(await this.baseFs.readlinkPromise(this.mapToBase(e)))}readlinkSync(e){return this.mapFromBase(this.baseFs.readlinkSync(this.mapToBase(e)))}async truncatePromise(e,r){return this.baseFs.truncatePromise(this.mapToBase(e),r)}truncateSync(e,r){return this.baseFs.truncateSync(this.mapToBase(e),r)}watch(e,r,i){return this.baseFs.watch(this.mapToBase(e),r,i)}watchFile(e,r,i){return this.baseFs.watchFile(this.mapToBase(e),r,i)}unwatchFile(e,r){return this.baseFs.unwatchFile(this.mapToBase(e),r)}fsMapToBase(e){return typeof e=="number"?e:this.mapToBase(e)}};var Pa=class extends bi{constructor(e,{baseFs:r,pathUtils:i}){super(i);this.target=e,this.baseFs=r}getRealPath(){return this.target}getBaseFs(){return this.baseFs}mapFromBase(e){return e}mapToBase(e){return e}};var _t=class extends bi{constructor(e,{baseFs:r=new ar}={}){super(k);this.target=this.pathUtils.normalize(e),this.baseFs=r}getRealPath(){return this.pathUtils.resolve(this.baseFs.getRealPath(),this.target)}resolve(e){return this.pathUtils.isAbsolute(e)?k.normalize(e):this.baseFs.resolve(k.join(this.target,e))}mapFromBase(e){return e}mapToBase(e){return this.pathUtils.isAbsolute(e)?e:this.pathUtils.join(this.target,e)}};var IM=Me.root,Da=class extends bi{constructor(e,{baseFs:r=new ar}={}){super(k);this.target=this.pathUtils.resolve(Me.root,e),this.baseFs=r}getRealPath(){return this.pathUtils.resolve(this.baseFs.getRealPath(),this.pathUtils.relative(Me.root,this.target))}getTarget(){return this.target}getBaseFs(){return this.baseFs}mapToBase(e){let r=this.pathUtils.normalize(e);if(this.pathUtils.isAbsolute(e))return this.pathUtils.resolve(this.target,this.pathUtils.relative(IM,e));if(r.match(/^\.\.\/?/))throw new Error(`Resolving this path (${e}) would escape the jail`);return this.pathUtils.resolve(this.target,e)}mapFromBase(e){return this.pathUtils.resolve(IM,this.pathUtils.relative(this.target,e))}};var zh=class extends bi{constructor(e,r){super(r);this.instance=null;this.factory=e}get baseFs(){return this.instance||(this.instance=this.factory()),this.instance}set baseFs(e){this.instance=e}mapFromBase(e){return e}mapToBase(e){return e}};var st=()=>Object.assign(new Error("ENOSYS: unsupported filesystem access"),{code:"ENOSYS"}),yQ=class extends KA{constructor(){super(k)}getExtractHint(){throw st()}getRealPath(){throw st()}resolve(){throw st()}async openPromise(){throw st()}openSync(){throw st()}async opendirPromise(){throw st()}opendirSync(){throw st()}async readPromise(){throw st()}readSync(){throw st()}async writePromise(){throw st()}writeSync(){throw st()}async closePromise(){throw st()}closeSync(){throw st()}createWriteStream(){throw st()}createReadStream(){throw st()}async realpathPromise(){throw st()}realpathSync(){throw st()}async readdirPromise(){throw st()}readdirSync(){throw st()}async existsPromise(e){throw st()}existsSync(e){throw st()}async accessPromise(){throw st()}accessSync(){throw st()}async statPromise(){throw st()}statSync(){throw st()}async fstatPromise(e){throw st()}fstatSync(e){throw st()}async lstatPromise(e){throw st()}lstatSync(e){throw st()}async chmodPromise(){throw st()}chmodSync(){throw st()}async chownPromise(){throw st()}chownSync(){throw st()}async mkdirPromise(){throw st()}mkdirSync(){throw st()}async rmdirPromise(){throw st()}rmdirSync(){throw st()}async linkPromise(){throw st()}linkSync(){throw st()}async symlinkPromise(){throw st()}symlinkSync(){throw st()}async renamePromise(){throw st()}renameSync(){throw st()}async copyFilePromise(){throw st()}copyFileSync(){throw st()}async appendFilePromise(){throw st()}appendFileSync(){throw st()}async writeFilePromise(){throw st()}writeFileSync(){throw st()}async unlinkPromise(){throw st()}unlinkSync(){throw st()}async utimesPromise(){throw st()}utimesSync(){throw st()}async readFilePromise(){throw st()}readFileSync(){throw st()}async readlinkPromise(){throw st()}readlinkSync(){throw st()}async truncatePromise(){throw st()}truncateSync(){throw st()}watch(){throw st()}watchFile(){throw st()}unwatchFile(){throw st()}},zE=yQ;zE.instance=new yQ;var _h=class extends bi{constructor(e){super(j);this.baseFs=e}mapFromBase(e){return j.fromPortablePath(e)}mapToBase(e){return j.toPortablePath(e)}};var kfe=/^[0-9]+$/,wQ=/^(\/(?:[^/]+\/)*?(?:\$\$virtual|__virtual__))((?:\/((?:[^/]+-)?[a-f0-9]+)(?:\/([^/]+))?)?((?:\/.*)?))$/,xfe=/^([^/]+-)?[a-f0-9]+$/,Jr=class extends bi{static makeVirtualPath(e,r,i){if(k.basename(e)!=="__virtual__")throw new Error('Assertion failed: Virtual folders must be named "__virtual__"');if(!k.basename(r).match(xfe))throw new Error("Assertion failed: Virtual components must be ended by an hexadecimal hash");let s=k.relative(k.dirname(e),i).split("/"),o=0;for(;o<s.length&&s[o]==="..";)o+=1;let a=s.slice(o);return k.join(e,r,String(o),...a)}static resolveVirtual(e){let r=e.match(wQ);if(!r||!r[3]&&r[5])return e;let i=k.dirname(r[1]);if(!r[3]||!r[4])return i;if(!kfe.test(r[4]))return e;let s=Number(r[4]),o="../".repeat(s),a=r[5]||".";return Jr.resolveVirtual(k.join(i,o,a))}constructor({baseFs:e=new ar}={}){super(k);this.baseFs=e}getExtractHint(e){return this.baseFs.getExtractHint(e)}getRealPath(){return this.baseFs.getRealPath()}realpathSync(e){let r=e.match(wQ);if(!r)return this.baseFs.realpathSync(e);if(!r[5])return e;let i=this.baseFs.realpathSync(this.mapToBase(e));return Jr.makeVirtualPath(r[1],r[3],i)}async realpathPromise(e){let r=e.match(wQ);if(!r)return await this.baseFs.realpathPromise(e);if(!r[5])return e;let i=await this.baseFs.realpathPromise(this.mapToBase(e));return Jr.makeVirtualPath(r[1],r[3],i)}mapToBase(e){if(e==="")return e;if(this.pathUtils.isAbsolute(e))return Jr.resolveVirtual(e);let r=Jr.resolveVirtual(this.baseFs.resolve(Me.dot)),i=Jr.resolveVirtual(this.baseFs.resolve(e));return k.relative(r,i)||Me.dot}mapFromBase(e){return e}};var Vh=ge(require("fs"));var Ra=2147483648,yM=(t,e)=>{let r=t.indexOf(e);if(r<=0)return null;let i=r;for(;r>=0&&(i=r+e.length,t[i]!==k.sep);){if(t[r-1]===k.sep)return null;r=t.indexOf(e,i)}return t.length>i&&t[i]!==k.sep?null:t.slice(0,i)},ms=class extends ec{constructor({libzip:e,baseFs:r=new ar,filter:i=null,maxOpenFiles:n=Infinity,readOnlyArchives:s=!1,useCache:o=!0,maxAge:a=5e3,fileExtensions:l=null}){super();this.fdMap=new Map;this.nextFd=3;this.isZip=new Set;this.notZip=new Set;this.realPaths=new Map;this.limitOpenFilesTimeout=null;this.libzipFactory=typeof e!="function"?()=>e:e,this.baseFs=r,this.zipInstances=o?new Map:null,this.filter=i,this.maxOpenFiles=n,this.readOnlyArchives=s,this.maxAge=a,this.fileExtensions=l}static async openPromise(e,r){let i=new ms(r);try{return await e(i)}finally{i.saveAndClose()}}get libzip(){return typeof this.libzipInstance=="undefined"&&(this.libzipInstance=this.libzipFactory()),this.libzipInstance}getExtractHint(e){return this.baseFs.getExtractHint(e)}getRealPath(){return this.baseFs.getRealPath()}saveAndClose(){if(Wh(this),this.zipInstances)for(let[e,{zipFs:r}]of this.zipInstances.entries())r.saveAndClose(),this.zipInstances.delete(e)}discardAndClose(){if(Wh(this),this.zipInstances)for(let[e,{zipFs:r}]of this.zipInstances.entries())r.discardAndClose(),this.zipInstances.delete(e)}resolve(e){return this.baseFs.resolve(e)}remapFd(e,r){let i=this.nextFd++|Ra;return this.fdMap.set(i,[e,r]),i}async openPromise(e,r,i){return await this.makeCallPromise(e,async()=>await this.baseFs.openPromise(e,r,i),async(n,{subPath:s})=>this.remapFd(n,await n.openPromise(s,r,i)))}openSync(e,r,i){return this.makeCallSync(e,()=>this.baseFs.openSync(e,r,i),(n,{subPath:s})=>this.remapFd(n,n.openSync(s,r,i)))}async opendirPromise(e,r){return await this.makeCallPromise(e,async()=>await this.baseFs.opendirPromise(e,r),async(i,{subPath:n})=>await i.opendirPromise(n,r),{requireSubpath:!1})}opendirSync(e,r){return this.makeCallSync(e,()=>this.baseFs.opendirSync(e,r),(i,{subPath:n})=>i.opendirSync(n,r),{requireSubpath:!1})}async readPromise(e,r,i,n,s){if((e&Ra)==0)return await this.baseFs.readPromise(e,r,i,n,s);let o=this.fdMap.get(e);if(typeof o=="undefined")throw en("read");let[a,l]=o;return await a.readPromise(l,r,i,n,s)}readSync(e,r,i,n,s){if((e&Ra)==0)return this.baseFs.readSync(e,r,i,n,s);let o=this.fdMap.get(e);if(typeof o=="undefined")throw en("readSync");let[a,l]=o;return a.readSync(l,r,i,n,s)}async writePromise(e,r,i,n,s){if((e&Ra)==0)return typeof r=="string"?await this.baseFs.writePromise(e,r,i):await this.baseFs.writePromise(e,r,i,n,s);let o=this.fdMap.get(e);if(typeof o=="undefined")throw en("write");let[a,l]=o;return typeof r=="string"?await a.writePromise(l,r,i):await a.writePromise(l,r,i,n,s)}writeSync(e,r,i,n,s){if((e&Ra)==0)return typeof r=="string"?this.baseFs.writeSync(e,r,i):this.baseFs.writeSync(e,r,i,n,s);let o=this.fdMap.get(e);if(typeof o=="undefined")throw en("writeSync");let[a,l]=o;return typeof r=="string"?a.writeSync(l,r,i):a.writeSync(l,r,i,n,s)}async closePromise(e){if((e&Ra)==0)return await this.baseFs.closePromise(e);let r=this.fdMap.get(e);if(typeof r=="undefined")throw en("close");this.fdMap.delete(e);let[i,n]=r;return await i.closePromise(n)}closeSync(e){if((e&Ra)==0)return this.baseFs.closeSync(e);let r=this.fdMap.get(e);if(typeof r=="undefined")throw en("closeSync");this.fdMap.delete(e);let[i,n]=r;return i.closeSync(n)}createReadStream(e,r){return e===null?this.baseFs.createReadStream(e,r):this.makeCallSync(e,()=>this.baseFs.createReadStream(e,r),(i,{archivePath:n,subPath:s})=>{let o=i.createReadStream(s,r);return o.path=j.fromPortablePath(this.pathUtils.join(n,s)),o})}createWriteStream(e,r){return e===null?this.baseFs.createWriteStream(e,r):this.makeCallSync(e,()=>this.baseFs.createWriteStream(e,r),(i,{subPath:n})=>i.createWriteStream(n,r))}async realpathPromise(e){return await this.makeCallPromise(e,async()=>await this.baseFs.realpathPromise(e),async(r,{archivePath:i,subPath:n})=>{let s=this.realPaths.get(i);return typeof s=="undefined"&&(s=await this.baseFs.realpathPromise(i),this.realPaths.set(i,s)),this.pathUtils.join(s,this.pathUtils.relative(Me.root,await r.realpathPromise(n)))})}realpathSync(e){return this.makeCallSync(e,()=>this.baseFs.realpathSync(e),(r,{archivePath:i,subPath:n})=>{let s=this.realPaths.get(i);return typeof s=="undefined"&&(s=this.baseFs.realpathSync(i),this.realPaths.set(i,s)),this.pathUtils.join(s,this.pathUtils.relative(Me.root,r.realpathSync(n)))})}async existsPromise(e){return await this.makeCallPromise(e,async()=>await this.baseFs.existsPromise(e),async(r,{subPath:i})=>await r.existsPromise(i))}existsSync(e){return this.makeCallSync(e,()=>this.baseFs.existsSync(e),(r,{subPath:i})=>r.existsSync(i))}async accessPromise(e,r){return await this.makeCallPromise(e,async()=>await this.baseFs.accessPromise(e,r),async(i,{subPath:n})=>await i.accessPromise(n,r))}accessSync(e,r){return this.makeCallSync(e,()=>this.baseFs.accessSync(e,r),(i,{subPath:n})=>i.accessSync(n,r))}async statPromise(e,r){return await this.makeCallPromise(e,async()=>await this.baseFs.statPromise(e,r),async(i,{subPath:n})=>await i.statPromise(n,r))}statSync(e,r){return this.makeCallSync(e,()=>this.baseFs.statSync(e,r),(i,{subPath:n})=>i.statSync(n,r))}async fstatPromise(e,r){if((e&Ra)==0)return this.baseFs.fstatPromise(e,r);let i=this.fdMap.get(e);if(typeof i=="undefined")throw en("fstat");let[n,s]=i;return n.fstatPromise(s,r)}fstatSync(e,r){if((e&Ra)==0)return this.baseFs.fstatSync(e,r);let i=this.fdMap.get(e);if(typeof i=="undefined")throw en("fstatSync");let[n,s]=i;return n.fstatSync(s,r)}async lstatPromise(e,r){return await this.makeCallPromise(e,async()=>await this.baseFs.lstatPromise(e,r),async(i,{subPath:n})=>await i.lstatPromise(n,r))}lstatSync(e,r){return this.makeCallSync(e,()=>this.baseFs.lstatSync(e,r),(i,{subPath:n})=>i.lstatSync(n,r))}async chmodPromise(e,r){return await this.makeCallPromise(e,async()=>await this.baseFs.chmodPromise(e,r),async(i,{subPath:n})=>await i.chmodPromise(n,r))}chmodSync(e,r){return this.makeCallSync(e,()=>this.baseFs.chmodSync(e,r),(i,{subPath:n})=>i.chmodSync(n,r))}async chownPromise(e,r,i){return await this.makeCallPromise(e,async()=>await this.baseFs.chownPromise(e,r,i),async(n,{subPath:s})=>await n.chownPromise(s,r,i))}chownSync(e,r,i){return this.makeCallSync(e,()=>this.baseFs.chownSync(e,r,i),(n,{subPath:s})=>n.chownSync(s,r,i))}async renamePromise(e,r){return await this.makeCallPromise(e,async()=>await this.makeCallPromise(r,async()=>await this.baseFs.renamePromise(e,r),async()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})}),async(i,{subPath:n})=>await this.makeCallPromise(r,async()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})},async(s,{subPath:o})=>{if(i!==s)throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"});return await i.renamePromise(n,o)}))}renameSync(e,r){return this.makeCallSync(e,()=>this.makeCallSync(r,()=>this.baseFs.renameSync(e,r),()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})}),(i,{subPath:n})=>this.makeCallSync(r,()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})},(s,{subPath:o})=>{if(i!==s)throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"});return i.renameSync(n,o)}))}async copyFilePromise(e,r,i=0){let n=async(s,o,a,l)=>{if((i&Vh.constants.COPYFILE_FICLONE_FORCE)!=0)throw Object.assign(new Error(`EXDEV: cross-device clone not permitted, copyfile '${o}' -> ${l}'`),{code:"EXDEV"});if(i&Vh.constants.COPYFILE_EXCL&&await this.existsPromise(o))throw Object.assign(new Error(`EEXIST: file already exists, copyfile '${o}' -> '${l}'`),{code:"EEXIST"});let c;try{c=await s.readFilePromise(o)}catch(u){throw Object.assign(new Error(`EINVAL: invalid argument, copyfile '${o}' -> '${l}'`),{code:"EINVAL"})}await a.writeFilePromise(l,c)};return await this.makeCallPromise(e,async()=>await this.makeCallPromise(r,async()=>await this.baseFs.copyFilePromise(e,r,i),async(s,{subPath:o})=>await n(this.baseFs,e,s,o)),async(s,{subPath:o})=>await this.makeCallPromise(r,async()=>await n(s,o,this.baseFs,r),async(a,{subPath:l})=>s!==a?await n(s,o,a,l):await s.copyFilePromise(o,l,i)))}copyFileSync(e,r,i=0){let n=(s,o,a,l)=>{if((i&Vh.constants.COPYFILE_FICLONE_FORCE)!=0)throw Object.assign(new Error(`EXDEV: cross-device clone not permitted, copyfile '${o}' -> ${l}'`),{code:"EXDEV"});if(i&Vh.constants.COPYFILE_EXCL&&this.existsSync(o))throw Object.assign(new Error(`EEXIST: file already exists, copyfile '${o}' -> '${l}'`),{code:"EEXIST"});let c;try{c=s.readFileSync(o)}catch(u){throw Object.assign(new Error(`EINVAL: invalid argument, copyfile '${o}' -> '${l}'`),{code:"EINVAL"})}a.writeFileSync(l,c)};return this.makeCallSync(e,()=>this.makeCallSync(r,()=>this.baseFs.copyFileSync(e,r,i),(s,{subPath:o})=>n(this.baseFs,e,s,o)),(s,{subPath:o})=>this.makeCallSync(r,()=>n(s,o,this.baseFs,r),(a,{subPath:l})=>s!==a?n(s,o,a,l):s.copyFileSync(o,l,i)))}async appendFilePromise(e,r,i){return await this.makeCallPromise(e,async()=>await this.baseFs.appendFilePromise(e,r,i),async(n,{subPath:s})=>await n.appendFilePromise(s,r,i))}appendFileSync(e,r,i){return this.makeCallSync(e,()=>this.baseFs.appendFileSync(e,r,i),(n,{subPath:s})=>n.appendFileSync(s,r,i))}async writeFilePromise(e,r,i){return await this.makeCallPromise(e,async()=>await this.baseFs.writeFilePromise(e,r,i),async(n,{subPath:s})=>await n.writeFilePromise(s,r,i))}writeFileSync(e,r,i){return this.makeCallSync(e,()=>this.baseFs.writeFileSync(e,r,i),(n,{subPath:s})=>n.writeFileSync(s,r,i))}async unlinkPromise(e){return await this.makeCallPromise(e,async()=>await this.baseFs.unlinkPromise(e),async(r,{subPath:i})=>await r.unlinkPromise(i))}unlinkSync(e){return this.makeCallSync(e,()=>this.baseFs.unlinkSync(e),(r,{subPath:i})=>r.unlinkSync(i))}async utimesPromise(e,r,i){return await this.makeCallPromise(e,async()=>await this.baseFs.utimesPromise(e,r,i),async(n,{subPath:s})=>await n.utimesPromise(s,r,i))}utimesSync(e,r,i){return this.makeCallSync(e,()=>this.baseFs.utimesSync(e,r,i),(n,{subPath:s})=>n.utimesSync(s,r,i))}async mkdirPromise(e,r){return await this.makeCallPromise(e,async()=>await this.baseFs.mkdirPromise(e,r),async(i,{subPath:n})=>await i.mkdirPromise(n,r))}mkdirSync(e,r){return this.makeCallSync(e,()=>this.baseFs.mkdirSync(e,r),(i,{subPath:n})=>i.mkdirSync(n,r))}async rmdirPromise(e,r){return await this.makeCallPromise(e,async()=>await this.baseFs.rmdirPromise(e,r),async(i,{subPath:n})=>await i.rmdirPromise(n,r))}rmdirSync(e,r){return this.makeCallSync(e,()=>this.baseFs.rmdirSync(e,r),(i,{subPath:n})=>i.rmdirSync(n,r))}async linkPromise(e,r){return await this.makeCallPromise(r,async()=>await this.baseFs.linkPromise(e,r),async(i,{subPath:n})=>await i.linkPromise(e,n))}linkSync(e,r){return this.makeCallSync(r,()=>this.baseFs.linkSync(e,r),(i,{subPath:n})=>i.linkSync(e,n))}async symlinkPromise(e,r,i){return await this.makeCallPromise(r,async()=>await this.baseFs.symlinkPromise(e,r,i),async(n,{subPath:s})=>await n.symlinkPromise(e,s))}symlinkSync(e,r,i){return this.makeCallSync(r,()=>this.baseFs.symlinkSync(e,r,i),(n,{subPath:s})=>n.symlinkSync(e,s))}async readFilePromise(e,r){return this.makeCallPromise(e,async()=>{switch(r){case"utf8":return await this.baseFs.readFilePromise(e,r);default:return await this.baseFs.readFilePromise(e,r)}},async(i,{subPath:n})=>await i.readFilePromise(n,r))}readFileSync(e,r){return this.makeCallSync(e,()=>{switch(r){case"utf8":return this.baseFs.readFileSync(e,r);default:return this.baseFs.readFileSync(e,r)}},(i,{subPath:n})=>i.readFileSync(n,r))}async readdirPromise(e,r){return await this.makeCallPromise(e,async()=>await this.baseFs.readdirPromise(e,r),async(i,{subPath:n})=>await i.readdirPromise(n,r),{requireSubpath:!1})}readdirSync(e,r){return this.makeCallSync(e,()=>this.baseFs.readdirSync(e,r),(i,{subPath:n})=>i.readdirSync(n,r),{requireSubpath:!1})}async readlinkPromise(e){return await this.makeCallPromise(e,async()=>await this.baseFs.readlinkPromise(e),async(r,{subPath:i})=>await r.readlinkPromise(i))}readlinkSync(e){return this.makeCallSync(e,()=>this.baseFs.readlinkSync(e),(r,{subPath:i})=>r.readlinkSync(i))}async truncatePromise(e,r){return await this.makeCallPromise(e,async()=>await this.baseFs.truncatePromise(e,r),async(i,{subPath:n})=>await i.truncatePromise(n,r))}truncateSync(e,r){return this.makeCallSync(e,()=>this.baseFs.truncateSync(e,r),(i,{subPath:n})=>i.truncateSync(n,r))}watch(e,r,i){return this.makeCallSync(e,()=>this.baseFs.watch(e,r,i),(n,{subPath:s})=>n.watch(s,r,i))}watchFile(e,r,i){return this.makeCallSync(e,()=>this.baseFs.watchFile(e,r,i),()=>WE(this,e,r,i))}unwatchFile(e,r){return this.makeCallSync(e,()=>this.baseFs.unwatchFile(e,r),()=>Jh(this,e,r))}async makeCallPromise(e,r,i,{requireSubpath:n=!0}={}){if(typeof e!="string")return await r();let s=this.resolve(e),o=this.findZip(s);return o?n&&o.subPath==="/"?await r():await this.getZipPromise(o.archivePath,async a=>await i(a,o)):await r()}makeCallSync(e,r,i,{requireSubpath:n=!0}={}){if(typeof e!="string")return r();let s=this.resolve(e),o=this.findZip(s);return!o||n&&o.subPath==="/"?r():this.getZipSync(o.archivePath,a=>i(a,o))}findZip(e){if(this.filter&&!this.filter.test(e))return null;let r="";for(;;){let i=e.substring(r.length),n;if(!this.fileExtensions)n=yM(i,".zip");else for(let s of this.fileExtensions)if(n=yM(i,s),n)break;if(!n)return null;if(r=this.pathUtils.join(r,n),this.isZip.has(r)===!1){if(this.notZip.has(r))continue;try{if(!this.baseFs.lstatSync(r).isFile()){this.notZip.add(r);continue}}catch{return null}this.isZip.add(r)}return{archivePath:r,subPath:this.pathUtils.join(Me.root,e.substring(r.length))}}}limitOpenFiles(e){if(this.zipInstances===null)return;let r=Date.now(),i=r+this.maxAge,n=e===null?0:this.zipInstances.size-e;for(let[s,{zipFs:o,expiresAt:a,refCount:l}]of this.zipInstances.entries())if(!(l!==0||o.hasOpenFileHandles())){if(r>=a){o.saveAndClose(),this.zipInstances.delete(s),n-=1;continue}else if(e===null||n<=0){i=a;break}o.saveAndClose(),this.zipInstances.delete(s),n-=1}this.limitOpenFilesTimeout===null&&(e===null&&this.zipInstances.size>0||e!==null)&&(this.limitOpenFilesTimeout=setTimeout(()=>{this.limitOpenFilesTimeout=null,this.limitOpenFiles(null)},i-r).unref())}async getZipPromise(e,r){let i=async()=>({baseFs:this.baseFs,libzip:this.libzip,readOnly:this.readOnlyArchives,stats:await this.baseFs.statPromise(e)});if(this.zipInstances){let n=this.zipInstances.get(e);if(!n){let s=await i();n=this.zipInstances.get(e),n||(n={zipFs:new Ai(e,s),expiresAt:0,refCount:0})}this.zipInstances.delete(e),this.limitOpenFiles(this.maxOpenFiles-1),this.zipInstances.set(e,n),n.expiresAt=Date.now()+this.maxAge,n.refCount+=1;try{return await r(n.zipFs)}finally{n.refCount-=1}}else{let n=new Ai(e,await i());try{return await r(n)}finally{n.saveAndClose()}}}getZipSync(e,r){let i=()=>({baseFs:this.baseFs,libzip:this.libzip,readOnly:this.readOnlyArchives,stats:this.baseFs.statSync(e)});if(this.zipInstances){let n=this.zipInstances.get(e);return n||(n={zipFs:new Ai(e,i()),expiresAt:0,refCount:0}),this.zipInstances.delete(e),this.limitOpenFiles(this.maxOpenFiles-1),this.zipInstances.set(e,n),n.expiresAt=Date.now()+this.maxAge,r(n.zipFs)}else{let n=new Ai(e,i());try{return r(n)}finally{n.saveAndClose()}}}};var Xh=ge(require("util"));var _E=ge(require("url"));var BQ=class extends bi{constructor(e){super(j);this.baseFs=e}mapFromBase(e){return e}mapToBase(e){return e instanceof _E.URL?(0,_E.fileURLToPath)(e):e}};var Pfe=new Set(["accessSync","appendFileSync","createReadStream","createWriteStream","chmodSync","chownSync","closeSync","copyFileSync","linkSync","lstatSync","fstatSync","lutimesSync","mkdirSync","openSync","opendirSync","readlinkSync","readFileSync","readdirSync","readlinkSync","realpathSync","renameSync","rmdirSync","statSync","symlinkSync","truncateSync","unlinkSync","unwatchFile","utimesSync","watch","watchFile","writeFileSync","writeSync"]),wM=new Set(["accessPromise","appendFilePromise","chmodPromise","chownPromise","closePromise","copyFilePromise","linkPromise","fstatPromise","lstatPromise","lutimesPromise","mkdirPromise","openPromise","opendirPromise","readdirPromise","realpathPromise","readFilePromise","readdirPromise","readlinkPromise","renamePromise","rmdirPromise","statPromise","symlinkPromise","truncatePromise","unlinkPromise","utimesPromise","writeFilePromise","writeSync"]),Dfe=new Set(["appendFilePromise","chmodPromise","chownPromise","closePromise","readPromise","readFilePromise","statPromise","truncatePromise","utimesPromise","writePromise","writeFilePromise"]);function bQ(t,e){e=new BQ(e);let r=(i,n,s)=>{let o=i[n];i[n]=s,typeof(o==null?void 0:o[Xh.promisify.custom])!="undefined"&&(s[Xh.promisify.custom]=o[Xh.promisify.custom])};{r(t,"exists",(i,...n)=>{let o=typeof n[n.length-1]=="function"?n.pop():()=>{};process.nextTick(()=>{e.existsPromise(i).then(a=>{o(a)},()=>{o(!1)})})}),r(t,"read",(...i)=>{let[n,s,o,a,l,c]=i;if(i.length<=3){let u={};i.length<3?c=i[1]:(u=i[1],c=i[2]),{buffer:s=Buffer.alloc(16384),offset:o=0,length:a=s.byteLength,position:l}=u}if(o==null&&(o=0),a|=0,a===0){process.nextTick(()=>{c(null,0,s)});return}l==null&&(l=-1),process.nextTick(()=>{e.readPromise(n,s,o,a,l).then(u=>{c(null,u,s)},u=>{c(u,0,s)})})});for(let i of wM){let n=i.replace(/Promise$/,"");if(typeof t[n]=="undefined")continue;let s=e[i];if(typeof s=="undefined")continue;r(t,n,(...a)=>{let c=typeof a[a.length-1]=="function"?a.pop():()=>{};process.nextTick(()=>{s.apply(e,a).then(u=>{c(null,u)},u=>{c(u)})})})}t.realpath.native=t.realpath}{r(t,"existsSync",i=>{try{return e.existsSync(i)}catch(n){return!1}}),r(t,"readSync",(...i)=>{let[n,s,o,a,l]=i;return i.length<=3&&({offset:o=0,length:a=s.byteLength,position:l}=i[2]||{}),o==null&&(o=0),a|=0,a===0?0:(l==null&&(l=-1),e.readSync(n,s,o,a,l))});for(let i of Pfe){let n=i;if(typeof t[n]=="undefined")continue;let s=e[i];typeof s!="undefined"&&r(t,n,s.bind(e))}t.realpathSync.native=t.realpathSync}{let i=process.emitWarning;process.emitWarning=()=>{};let n;try{n=t.promises}finally{process.emitWarning=i}if(typeof n!="undefined"){for(let o of wM){let a=o.replace(/Promise$/,"");if(typeof n[a]=="undefined")continue;let l=e[o];typeof l!="undefined"&&o!=="open"&&r(n,a,l.bind(e))}class s{constructor(a){this.fd=a}}for(let o of Dfe){let a=o.replace(/Promise$/,""),l=e[o];typeof l!="undefined"&&r(s.prototype,a,function(...c){return l.call(e,this.fd,...c)})}r(n,"open",async(...o)=>{let a=await e.openPromise(...o);return new s(a)})}}t.read[Xh.promisify.custom]=async(i,n,...s)=>({bytesRead:await e.readPromise(i,n,...s),buffer:n})}function VE(t,e){let r=Object.create(t);return bQ(r,e),r}var BM=ge(require("os"));function bM(t){let e=Math.ceil(Math.random()*4294967296).toString(16).padStart(8,"0");return`${t}${e}`}var ro=new Set,QQ=null;function QM(){if(QQ)return QQ;let t=j.toPortablePath(BM.default.tmpdir()),e=K.realpathSync(t);return process.once("exit",()=>{K.rmtempSync()}),QQ={tmpdir:t,realTmpdir:e}}var K=Object.assign(new ar,{detachTemp(t){ro.delete(t)},mktempSync(t){let{tmpdir:e,realTmpdir:r}=QM();for(;;){let i=bM("xfs-");try{this.mkdirSync(k.join(e,i))}catch(s){if(s.code==="EEXIST")continue;throw s}let n=k.join(r,i);if(ro.add(n),typeof t=="undefined")return n;try{return t(n)}finally{if(ro.has(n)){ro.delete(n);try{this.removeSync(n)}catch{}}}}},async mktempPromise(t){let{tmpdir:e,realTmpdir:r}=QM();for(;;){let i=bM("xfs-");try{await this.mkdirPromise(k.join(e,i))}catch(s){if(s.code==="EEXIST")continue;throw s}let n=k.join(r,i);if(ro.add(n),typeof t=="undefined")return n;try{return await t(n)}finally{if(ro.has(n)){ro.delete(n);try{await this.removePromise(n)}catch{}}}}},async rmtempPromise(){await Promise.all(Array.from(ro.values()).map(async t=>{try{await K.removePromise(t,{maxRetries:0}),ro.delete(t)}catch{}}))},rmtempSync(){for(let t of ro)try{K.removeSync(t),ro.delete(t)}catch{}}});var Sx=ge(LQ());var op={};ft(op,{parseResolution:()=>rI,parseShell:()=>ZE,parseSyml:()=>Qi,stringifyArgument:()=>UQ,stringifyArgumentSegment:()=>KQ,stringifyArithmeticExpression:()=>tI,stringifyCommand:()=>MQ,stringifyCommandChain:()=>_u,stringifyCommandChainThen:()=>OQ,stringifyCommandLine:()=>$E,stringifyCommandLineThen:()=>TQ,stringifyEnvSegment:()=>eI,stringifyRedirectArgument:()=>$h,stringifyResolution:()=>iI,stringifyShell:()=>zu,stringifyShellLine:()=>zu,stringifySyml:()=>Na,stringifyValueArgument:()=>sc});var p1=ge(h1());function ZE(t,e={isGlobPattern:()=>!1}){try{return(0,p1.parse)(t,e)}catch(r){throw r.location&&(r.message=r.message.replace(/(\.)?$/,` (line ${r.location.start.line}, column ${r.location.start.column})$1`)),r}}function zu(t,{endSemicolon:e=!1}={}){return t.map(({command:r,type:i},n)=>`${$E(r)}${i===";"?n!==t.length-1||e?";":"":" &"}`).join(" ")}function $E(t){return`${_u(t.chain)}${t.then?` ${TQ(t.then)}`:""}`}function TQ(t){return`${t.type} ${$E(t.line)}`}function _u(t){return`${MQ(t)}${t.then?` ${OQ(t.then)}`:""}`}function OQ(t){return`${t.type} ${_u(t.chain)}`}function MQ(t){switch(t.type){case"command":return`${t.envs.length>0?`${t.envs.map(e=>eI(e)).join(" ")} `:""}${t.args.map(e=>UQ(e)).join(" ")}`;case"subshell":return`(${zu(t.subshell)})${t.args.length>0?` ${t.args.map(e=>$h(e)).join(" ")}`:""}`;case"group":return`{ ${zu(t.group,{endSemicolon:!0})} }${t.args.length>0?` ${t.args.map(e=>$h(e)).join(" ")}`:""}`;case"envs":return t.envs.map(e=>eI(e)).join(" ");default:throw new Error(`Unsupported command type:  "${t.type}"`)}}function eI(t){return`${t.name}=${t.args[0]?sc(t.args[0]):""}`}function UQ(t){switch(t.type){case"redirection":return $h(t);case"argument":return sc(t);default:throw new Error(`Unsupported argument type: "${t.type}"`)}}function $h(t){return`${t.subtype} ${t.args.map(e=>sc(e)).join(" ")}`}function sc(t){return t.segments.map(e=>KQ(e)).join("")}function KQ(t){let e=(i,n)=>n?`"${i}"`:i,r=i=>i===""?'""':i.match(/[(){}<>$|&; \t"']/)?`$'${i.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/\f/g,"\\f").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/\t/g,"\\t").replace(/\v/g,"\\v").replace(/\0/g,"\\0")}'`:i;switch(t.type){case"text":return r(t.text);case"glob":return t.pattern;case"shell":return e(`\${${zu(t.shell)}}`,t.quoted);case"variable":return e(typeof t.defaultValue=="undefined"?typeof t.alternativeValue=="undefined"?`\${${t.name}}`:t.alternativeValue.length===0?`\${${t.name}:+}`:`\${${t.name}:+${t.alternativeValue.map(i=>sc(i)).join(" ")}}`:t.defaultValue.length===0?`\${${t.name}:-}`:`\${${t.name}:-${t.defaultValue.map(i=>sc(i)).join(" ")}}`,t.quoted);case"arithmetic":return`$(( ${tI(t.arithmetic)} ))`;default:throw new Error(`Unsupported argument segment type: "${t.type}"`)}}function tI(t){let e=n=>{switch(n){case"addition":return"+";case"subtraction":return"-";case"multiplication":return"*";case"division":return"/";default:throw new Error(`Can't extract operator from arithmetic expression of type "${n}"`)}},r=(n,s)=>s?`( ${n} )`:n,i=n=>r(tI(n),!["number","variable"].includes(n.type));switch(t.type){case"number":return String(t.value);case"variable":return t.name;default:return`${i(t.left)} ${e(t.type)} ${i(t.right)}`}}var m1=ge(C1());function rI(t){let e=t.match(/^\*{1,2}\/(.*)/);if(e)throw new Error(`The override for '${t}' includes a glob pattern. Glob patterns have been removed since their behaviours don't match what you'd expect. Set the override to '${e[1]}' instead.`);try{return(0,m1.parse)(t)}catch(r){throw r.location&&(r.message=r.message.replace(/(\.)?$/,` (line ${r.location.start.line}, column ${r.location.start.column})$1`)),r}}function iI(t){let e="";return t.from&&(e+=t.from.fullName,t.from.description&&(e+=`@${t.from.description}`),e+="/"),e+=t.descriptor.fullName,t.descriptor.description&&(e+=`@${t.descriptor.description}`),e}var hI=ge(AK()),uK=ge(cK()),Kde=/^(?![-?:,\][{}#&*!|>'"%@` \t\r\n]).([ \t]*(?![,\][{}:# \t\r\n]).)*$/,gK=["__metadata","version","resolution","dependencies","peerDependencies","dependenciesMeta","peerDependenciesMeta","binaries"],$Q=class{constructor(e){this.data=e}};function fK(t){return t.match(Kde)?t:JSON.stringify(t)}function hK(t){return typeof t=="undefined"?!0:typeof t=="object"&&t!==null?Object.keys(t).every(e=>hK(t[e])):!1}function ev(t,e,r){if(t===null)return`null
+`;if(typeof t=="number"||typeof t=="boolean")return`${t.toString()}
+`;if(typeof t=="string")return`${fK(t)}
+`;if(Array.isArray(t)){if(t.length===0)return`[]
+`;let i="  ".repeat(e);return`
+${t.map(s=>`${i}- ${ev(s,e+1,!1)}`).join("")}`}if(typeof t=="object"&&t){let i,n;t instanceof $Q?(i=t.data,n=!1):(i=t,n=!0);let s="  ".repeat(e),o=Object.keys(i);n&&o.sort((l,c)=>{let u=gK.indexOf(l),g=gK.indexOf(c);return u===-1&&g===-1?l<c?-1:l>c?1:0:u!==-1&&g===-1?-1:u===-1&&g!==-1?1:u-g});let a=o.filter(l=>!hK(i[l])).map((l,c)=>{let u=i[l],g=fK(l),f=ev(u,e+1,!0),h=c>0||r?s:"";return f.startsWith(`
+`)?`${h}${g}:${f}`:`${h}${g}: ${f}`}).join(e===0?`
+`:"")||`
+`;return r?`
+${a}`:`${a}`}throw new Error(`Unsupported value type (${t})`)}function Na(t){try{let e=ev(t,0,!1);return e!==`
+`?e:""}catch(e){throw e.location&&(e.message=e.message.replace(/(\.)?$/,` (line ${e.location.start.line}, column ${e.location.start.column})$1`)),e}}Na.PreserveOrdering=$Q;function Hde(t){return t.endsWith(`
+`)||(t+=`
+`),(0,uK.parse)(t)}var jde=/^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i;function Gde(t){if(jde.test(t))return Hde(t);let e=(0,hI.safeLoad)(t,{schema:hI.FAILSAFE_SCHEMA,json:!0});if(e==null)return{};if(typeof e!="object")throw new Error(`Expected an indexed object, got a ${typeof e} instead. Does your file follow Yaml's rules?`);if(Array.isArray(e))throw new Error("Expected an indexed object, got an array instead. Does your file follow Yaml's rules?");return e}function Qi(t){return Gde(t)}var rz=ge(dK()),yw=ge(hc());var Cp={};ft(Cp,{Builtins:()=>pv,Cli:()=>Is,Command:()=>Re,Option:()=>z,UsageError:()=>Pe,formatMarkdownish:()=>Ki});var pc=0,ap=1,tn=2,rv="\ 1",vi="\0",ng=-1,iv=/^(-h|--help)(?:=([0-9]+))?$/,pI=/^(--[a-z]+(?:-[a-z]+)*|-[a-zA-Z]+)$/,yK=/^-[a-zA-Z]{2,}$/,nv=/^([^=]+)=([\s\S]*)$/,sv=process.env.DEBUG_CLI==="1";var Pe=class extends Error{constructor(e){super(e);this.clipanion={type:"usage"},this.name="UsageError"}},Ap=class extends Error{constructor(e,r){super();if(this.input=e,this.candidates=r,this.clipanion={type:"none"},this.name="UnknownSyntaxError",this.candidates.length===0)this.message="Command not found, but we're not sure what's the alternative.";else if(this.candidates.every(i=>i.reason!==null&&i.reason===r[0].reason)){let[{reason:i}]=this.candidates;this.message=`${i}
+
+${this.candidates.map(({usage:n})=>`$ ${n}`).join(`
+`)}`}else if(this.candidates.length===1){let[{usage:i}]=this.candidates;this.message=`Command not found; did you mean:
+
+$ ${i}
+${ov(e)}`}else this.message=`Command not found; did you mean one of:
+
+${this.candidates.map(({usage:i},n)=>`${`${n}.`.padStart(4)} ${i}`).join(`
+`)}
+
+${ov(e)}`}},av=class extends Error{constructor(e,r){super();this.input=e,this.usages=r,this.clipanion={type:"none"},this.name="AmbiguousSyntaxError",this.message=`Cannot find which to pick amongst the following alternatives:
+
+${this.usages.map((i,n)=>`${`${n}.`.padStart(4)} ${i}`).join(`
+`)}
+
+${ov(e)}`}},ov=t=>`While running ${t.filter(e=>e!==vi).map(e=>{let r=JSON.stringify(e);return e.match(/\s/)||e.length===0||r!==`"${e}"`?r:e}).join(" ")}`;var lp=Symbol("clipanion/isOption");function rn(t){return te(N({},t),{[lp]:!0})}function No(t,e){return typeof t=="undefined"?[t,e]:typeof t=="object"&&t!==null&&!Array.isArray(t)?[void 0,t]:[t,e]}function dI(t,e=!1){let r=t.replace(/^\.: /,"");return e&&(r=r[0].toLowerCase()+r.slice(1)),r}function cp(t,e){return e.length===1?new Pe(`${t}: ${dI(e[0],!0)}`):new Pe(`${t}:
+${e.map(r=>`
+- ${dI(r)}`).join("")}`)}function up(t,e,r){if(typeof r=="undefined")return e;let i=[],n=[],s=a=>{let l=e;return e=a,s.bind(null,l)};if(!r(e,{errors:i,coercions:n,coercion:s}))throw cp(`Invalid value for ${t}`,i);for(let[,a]of n)a();return e}var Re=class{constructor(){this.help=!1}static Usage(e){return e}async catch(e){throw e}async validateAndExecute(){let r=this.constructor.schema;if(Array.isArray(r)){let{isDict:n,isUnknown:s,applyCascade:o}=await Promise.resolve().then(()=>(Es(),sg)),a=o(n(s()),r),l=[],c=[];if(!a(this,{errors:l,coercions:c}))throw cp("Invalid option schema",l);for(let[,g]of c)g()}else if(r!=null)throw new Error("Invalid command schema");let i=await this.execute();return typeof i!="undefined"?i:0}};Re.isOption=lp;Re.Default=[];var DK=80,cv=Array(DK).fill("\u2501");for(let t=0;t<=24;++t)cv[cv.length-t]=`\e[38;5;${232+t}m\u2501`;var uv={header:t=>`\e[1m\u2501\u2501\u2501 ${t}${t.length<DK-5?` ${cv.slice(t.length+5).join("")}`:":"}\e[0m`,bold:t=>`\e[1m${t}\e[22m`,error:t=>`\e[31m\e[1m${t}\e[22m\e[39m`,code:t=>`\e[36m${t}\e[39m`},RK={header:t=>t,bold:t=>t,error:t=>t,code:t=>t};function QCe(t){let e=t.split(`
+`),r=e.filter(n=>n.match(/\S/)),i=r.length>0?r.reduce((n,s)=>Math.min(n,s.length-s.trimStart().length),Number.MAX_VALUE):0;return e.map(n=>n.slice(i).trimRight()).join(`
+`)}function Ki(t,{format:e,paragraphs:r}){return t=t.replace(/\r\n?/g,`
+`),t=QCe(t),t=t.replace(/^\n+|\n+$/g,""),t=t.replace(/^(\s*)-([^\n]*?)\n+/gm,`$1-$2
+
+`),t=t.replace(/\n(\n)?\n*/g,"$1"),r&&(t=t.split(/\n/).map(i=>{let n=i.match(/^\s*[*-][\t ]+(.*)/);if(!n)return i.match(/(.{1,80})(?: |$)/g).join(`
+`);let s=i.length-i.trimStart().length;return n[1].match(new RegExp(`(.{1,${78-s}})(?: |$)`,"g")).map((o,a)=>" ".repeat(s)+(a===0?"- ":"  ")+o).join(`
+`)}).join(`
+
+`)),t=t.replace(/(`+)((?:.|[\n])*?)\1/g,(i,n,s)=>e.code(n+s+n)),t=t.replace(/(\*\*)((?:.|[\n])*?)\1/g,(i,n,s)=>e.bold(n+s+n)),t?`${t}
+`:""}var hv=ge(require("tty"));function wn(t){sv&&console.log(t)}var FK={candidateUsage:null,requiredOptions:[],errorMessage:null,ignoreOptions:!1,path:[],positionals:[],options:[],remainder:null,selectedIndex:ng};function NK(){return{nodes:[sn(),sn(),sn()]}}function SCe(t){let e=NK(),r=[],i=e.nodes.length;for(let n of t){r.push(i);for(let s=0;s<n.nodes.length;++s)LK(s)||e.nodes.push(vCe(n.nodes[s],i));i+=n.nodes.length-2}for(let n of r)og(e,pc,n);return e}function io(t,e){return t.nodes.push(e),t.nodes.length-1}function kCe(t){let e=new Set,r=i=>{if(e.has(i))return;e.add(i);let n=t.nodes[i];for(let o of Object.values(n.statics))for(let{to:a}of o)r(a);for(let[,{to:o}]of n.dynamics)r(o);for(let{to:o}of n.shortcuts)r(o);let s=new Set(n.shortcuts.map(({to:o})=>o));for(;n.shortcuts.length>0;){let{to:o}=n.shortcuts.shift(),a=t.nodes[o];for(let[l,c]of Object.entries(a.statics)){let u=Object.prototype.hasOwnProperty.call(n.statics,l)?n.statics[l]:n.statics[l]=[];for(let g of c)u.some(({to:f})=>g.to===f)||u.push(g)}for(let[l,c]of a.dynamics)n.dynamics.some(([u,{to:g}])=>l===u&&c.to===g)||n.dynamics.push([l,c]);for(let l of a.shortcuts)s.has(l.to)||(n.shortcuts.push(l),s.add(l.to))}};r(pc)}function xCe(t,{prefix:e=""}={}){if(sv){wn(`${e}Nodes are:`);for(let r=0;r<t.nodes.length;++r)wn(`${e}  ${r}: ${JSON.stringify(t.nodes[r])}`)}}function TK(t,e,r=!1){wn(`Running a vm on ${JSON.stringify(e)}`);let i=[{node:pc,state:{candidateUsage:null,requiredOptions:[],errorMessage:null,ignoreOptions:!1,options:[],path:[],positionals:[],remainder:null,selectedIndex:null}}];xCe(t,{prefix:"  "});let n=[rv,...e];for(let s=0;s<n.length;++s){let o=n[s];wn(`  Processing ${JSON.stringify(o)}`);let a=[];for(let{node:l,state:c}of i){wn(`    Current node is ${l}`);let u=t.nodes[l];if(l===tn){a.push({node:l,state:c});continue}console.assert(u.shortcuts.length===0,"Shortcuts should have been eliminated by now");let g=Object.prototype.hasOwnProperty.call(u.statics,o);if(!r||s<n.length-1||g)if(g){let f=u.statics[o];for(let{to:h,reducer:p}of f)a.push({node:h,state:typeof p!="undefined"?mI(gv,p,c,o):c}),wn(`      Static transition to ${h} found`)}else wn("      No static transition found");else{let f=!1;for(let h of Object.keys(u.statics))if(!!h.startsWith(o)){if(o===h)for(let{to:p,reducer:m}of u.statics[h])a.push({node:p,state:typeof m!="undefined"?mI(gv,m,c,o):c}),wn(`      Static transition to ${p} found`);else for(let{to:p}of u.statics[h])a.push({node:p,state:te(N({},c),{remainder:h.slice(o.length)})}),wn(`      Static transition to ${p} found (partial match)`);f=!0}f||wn("      No partial static transition found")}if(o!==vi)for(let[f,{to:h,reducer:p}]of u.dynamics)mI(EI,f,c,o)&&(a.push({node:h,state:typeof p!="undefined"?mI(gv,p,c,o):c}),wn(`      Dynamic transition to ${h} found (via ${f})`))}if(a.length===0&&o===vi&&e.length===1)return[{node:pc,state:FK}];if(a.length===0)throw new Ap(e,i.filter(({node:l})=>l!==tn).map(({state:l})=>({usage:l.candidateUsage,reason:null})));if(a.every(({node:l})=>l===tn))throw new Ap(e,a.map(({state:l})=>({usage:l.candidateUsage,reason:l.errorMessage})));i=PCe(a)}if(i.length>0){wn("  Results:");for(let s of i)wn(`    - ${s.node} -> ${JSON.stringify(s.state)}`)}else wn("  No results");return i}function DCe(t,e){if(e.selectedIndex!==null)return!0;if(Object.prototype.hasOwnProperty.call(t.statics,vi)){for(let{to:r}of t.statics[vi])if(r===ap)return!0}return!1}function FCe(t,e,r){let i=r&&e.length>0?[""]:[],n=TK(t,e,r),s=[],o=new Set,a=(l,c,u=!0)=>{let g=[c];for(;g.length>0;){let h=g;g=[];for(let p of h){let m=t.nodes[p],y=Object.keys(m.statics);for(let Q of Object.keys(m.statics)){let S=y[0];for(let{to:x,reducer:M}of m.statics[S])M==="pushPath"&&(u||l.push(S),g.push(x))}}u=!1}let f=JSON.stringify(l);o.has(f)||(s.push(l),o.add(f))};for(let{node:l,state:c}of n){if(c.remainder!==null){a([c.remainder],l);continue}let u=t.nodes[l],g=DCe(u,c);for(let[f,h]of Object.entries(u.statics))(g&&f!==vi||!f.startsWith("-")&&h.some(({reducer:p})=>p==="pushPath"))&&a([...i,f],l);if(!!g)for(let[f,{to:h}]of u.dynamics){if(h===tn)continue;let p=RCe(f,c);if(p!==null)for(let m of p)a([...i,m],l)}}return[...s].sort()}function LCe(t,e){let r=TK(t,[...e,vi]);return NCe(e,r.map(({state:i})=>i))}function PCe(t){let e=0;for(let{state:r}of t)r.path.length>e&&(e=r.path.length);return t.filter(({state:r})=>r.path.length===e)}function NCe(t,e){let r=e.filter(g=>g.selectedIndex!==null);if(r.length===0)throw new Error;let i=r.filter(g=>g.requiredOptions.every(f=>f.some(h=>g.options.find(p=>p.name===h))));if(i.length===0)throw new Ap(t,r.map(g=>({usage:g.candidateUsage,reason:null})));let n=0;for(let g of i)g.path.length>n&&(n=g.path.length);let s=i.filter(g=>g.path.length===n),o=g=>g.positionals.filter(({extra:f})=>!f).length+g.options.length,a=s.map(g=>({state:g,positionalCount:o(g)})),l=0;for(let{positionalCount:g}of a)g>l&&(l=g);let c=a.filter(({positionalCount:g})=>g===l).map(({state:g})=>g),u=TCe(c);if(u.length>1)throw new av(t,u.map(g=>g.candidateUsage));return u[0]}function TCe(t){let e=[],r=[];for(let i of t)i.selectedIndex===ng?r.push(i):e.push(i);return r.length>0&&e.push(te(N({},FK),{path:OK(...r.map(i=>i.path)),options:r.reduce((i,n)=>i.concat(n.options),[])})),e}function OK(t,e,...r){return e===void 0?Array.from(t):OK(t.filter((i,n)=>i===e[n]),...r)}function sn(){return{dynamics:[],shortcuts:[],statics:{}}}function LK(t){return t===ap||t===tn}function fv(t,e=0){return{to:LK(t.to)?t.to:t.to>2?t.to+e-2:t.to+e,reducer:t.reducer}}function vCe(t,e=0){let r=sn();for(let[i,n]of t.dynamics)r.dynamics.push([i,fv(n,e)]);for(let i of t.shortcuts)r.shortcuts.push(fv(i,e));for(let[i,n]of Object.entries(t.statics))r.statics[i]=n.map(s=>fv(s,e));return r}function Si(t,e,r,i,n){t.nodes[e].dynamics.push([r,{to:i,reducer:n}])}function og(t,e,r,i){t.nodes[e].shortcuts.push({to:r,reducer:i})}function La(t,e,r,i,n){(Object.prototype.hasOwnProperty.call(t.nodes[e].statics,r)?t.nodes[e].statics[r]:t.nodes[e].statics[r]=[]).push({to:i,reducer:n})}function mI(t,e,r,i){if(Array.isArray(e)){let[n,...s]=e;return t[n](r,i,...s)}else return t[e](r,i)}function RCe(t,e){let r=Array.isArray(t)?EI[t[0]]:EI[t];if(typeof r.suggest=="undefined")return null;let i=Array.isArray(t)?t.slice(1):[];return r.suggest(e,...i)}var EI={always:()=>!0,isOptionLike:(t,e)=>!t.ignoreOptions&&e!=="-"&&e.startsWith("-"),isNotOptionLike:(t,e)=>t.ignoreOptions||e==="-"||!e.startsWith("-"),isOption:(t,e,r,i)=>!t.ignoreOptions&&e===r,isBatchOption:(t,e,r)=>!t.ignoreOptions&&yK.test(e)&&[...e.slice(1)].every(i=>r.includes(`-${i}`)),isBoundOption:(t,e,r,i)=>{let n=e.match(nv);return!t.ignoreOptions&&!!n&&pI.test(n[1])&&r.includes(n[1])&&i.filter(s=>s.names.includes(n[1])).every(s=>s.allowBinding)},isNegatedOption:(t,e,r)=>!t.ignoreOptions&&e===`--no-${r.slice(2)}`,isHelp:(t,e)=>!t.ignoreOptions&&iv.test(e),isUnsupportedOption:(t,e,r)=>!t.ignoreOptions&&e.startsWith("-")&&pI.test(e)&&!r.includes(e),isInvalidOption:(t,e)=>!t.ignoreOptions&&e.startsWith("-")&&!pI.test(e)};EI.isOption.suggest=(t,e,r=!0)=>r?null:[e];var gv={setCandidateState:(t,e,r)=>N(N({},t),r),setSelectedIndex:(t,e,r)=>te(N({},t),{selectedIndex:r}),pushBatch:(t,e)=>te(N({},t),{options:t.options.concat([...e.slice(1)].map(r=>({name:`-${r}`,value:!0})))}),pushBound:(t,e)=>{let[,r,i]=e.match(nv);return te(N({},t),{options:t.options.concat({name:r,value:i})})},pushPath:(t,e)=>te(N({},t),{path:t.path.concat(e)}),pushPositional:(t,e)=>te(N({},t),{positionals:t.positionals.concat({value:e,extra:!1})}),pushExtra:(t,e)=>te(N({},t),{positionals:t.positionals.concat({value:e,extra:!0})}),pushExtraNoLimits:(t,e)=>te(N({},t),{positionals:t.positionals.concat({value:e,extra:Vn})}),pushTrue:(t,e,r=e)=>te(N({},t),{options:t.options.concat({name:e,value:!0})}),pushFalse:(t,e,r=e)=>te(N({},t),{options:t.options.concat({name:r,value:!1})}),pushUndefined:(t,e)=>te(N({},t),{options:t.options.concat({name:e,value:void 0})}),pushStringValue:(t,e)=>{var r;let i=te(N({},t),{options:[...t.options]}),n=t.options[t.options.length-1];return n.value=((r=n.value)!==null&&r!==void 0?r:[]).concat([e]),i},setStringValue:(t,e)=>{let r=te(N({},t),{options:[...t.options]}),i=t.options[t.options.length-1];return i.value=e,r},inhibateOptions:t=>te(N({},t),{ignoreOptions:!0}),useHelp:(t,e,r)=>{let[,,i]=e.match(iv);return typeof i!="undefined"?te(N({},t),{options:[{name:"-c",value:String(r)},{name:"-i",value:i}]}):te(N({},t),{options:[{name:"-c",value:String(r)}]})},setError:(t,e,r)=>e===vi?te(N({},t),{errorMessage:`${r}.`}):te(N({},t),{errorMessage:`${r} ("${e}").`}),setOptionArityError:(t,e)=>{let r=t.options[t.options.length-1];return te(N({},t),{errorMessage:`Not enough arguments to option ${r.name}.`})}},Vn=Symbol(),MK=class{constructor(e,r){this.allOptionNames=[],this.arity={leading:[],trailing:[],extra:[],proxy:!1},this.options=[],this.paths=[],this.cliIndex=e,this.cliOpts=r}addPath(e){this.paths.push(e)}setArity({leading:e=this.arity.leading,trailing:r=this.arity.trailing,extra:i=this.arity.extra,proxy:n=this.arity.proxy}){Object.assign(this.arity,{leading:e,trailing:r,extra:i,proxy:n})}addPositional({name:e="arg",required:r=!0}={}){if(!r&&this.arity.extra===Vn)throw new Error("Optional parameters cannot be declared when using .rest() or .proxy()");if(!r&&this.arity.trailing.length>0)throw new Error("Optional parameters cannot be declared after the required trailing positional arguments");!r&&this.arity.extra!==Vn?this.arity.extra.push(e):this.arity.extra!==Vn&&this.arity.extra.length===0?this.arity.leading.push(e):this.arity.trailing.push(e)}addRest({name:e="arg",required:r=0}={}){if(this.arity.extra===Vn)throw new Error("Infinite lists cannot be declared multiple times in the same command");if(this.arity.trailing.length>0)throw new Error("Infinite lists cannot be declared after the required trailing positional arguments");for(let i=0;i<r;++i)this.addPositional({name:e});this.arity.extra=Vn}addProxy({required:e=0}={}){this.addRest({required:e}),this.arity.proxy=!0}addOption({names:e,description:r,arity:i=0,hidden:n=!1,required:s=!1,allowBinding:o=!0}){if(!o&&i>1)throw new Error("The arity cannot be higher than 1 when the option only supports the --arg=value syntax");if(!Number.isInteger(i))throw new Error(`The arity must be an integer, got ${i}`);if(i<0)throw new Error(`The arity must be positive, got ${i}`);this.allOptionNames.push(...e),this.options.push({names:e,description:r,arity:i,hidden:n,required:s,allowBinding:o})}setContext(e){this.context=e}usage({detailed:e=!0,inlineOptions:r=!0}={}){let i=[this.cliOpts.binaryName],n=[];if(this.paths.length>0&&i.push(...this.paths[0]),e){for(let{names:o,arity:a,hidden:l,description:c,required:u}of this.options){if(l)continue;let g=[];for(let h=0;h<a;++h)g.push(` #${h}`);let f=`${o.join(",")}${g.join("")}`;!r&&c?n.push({definition:f,description:c,required:u}):i.push(u?`<${f}>`:`[${f}]`)}i.push(...this.arity.leading.map(o=>`<${o}>`)),this.arity.extra===Vn?i.push("..."):i.push(...this.arity.extra.map(o=>`[${o}]`)),i.push(...this.arity.trailing.map(o=>`<${o}>`))}return{usage:i.join(" "),options:n}}compile(){if(typeof this.context=="undefined")throw new Error("Assertion failed: No context attached");let e=NK(),r=pc,i=this.usage().usage,n=this.options.filter(a=>a.required).map(a=>a.names);r=io(e,sn()),La(e,pc,rv,r,["setCandidateState",{candidateUsage:i,requiredOptions:n}]);let s=this.arity.proxy?"always":"isNotOptionLike",o=this.paths.length>0?this.paths:[[]];for(let a of o){let l=r;if(a.length>0){let f=io(e,sn());og(e,l,f),this.registerOptions(e,f),l=f}for(let f=0;f<a.length;++f){let h=io(e,sn());La(e,l,a[f],h,"pushPath"),l=h}if(this.arity.leading.length>0||!this.arity.proxy){let f=io(e,sn());Si(e,l,"isHelp",f,["useHelp",this.cliIndex]),La(e,f,vi,ap,["setSelectedIndex",ng]),this.registerOptions(e,l)}this.arity.leading.length>0&&La(e,l,vi,tn,["setError","Not enough positional arguments"]);let c=l;for(let f=0;f<this.arity.leading.length;++f){let h=io(e,sn());this.arity.proxy||this.registerOptions(e,h),(this.arity.trailing.length>0||f+1!==this.arity.leading.length)&&La(e,h,vi,tn,["setError","Not enough positional arguments"]),Si(e,c,"isNotOptionLike",h,"pushPositional"),c=h}let u=c;if(this.arity.extra===Vn||this.arity.extra.length>0){let f=io(e,sn());if(og(e,c,f),this.arity.extra===Vn){let h=io(e,sn());this.arity.proxy||this.registerOptions(e,h),Si(e,c,s,h,"pushExtraNoLimits"),Si(e,h,s,h,"pushExtraNoLimits"),og(e,h,f)}else for(let h=0;h<this.arity.extra.length;++h){let p=io(e,sn());this.arity.proxy||this.registerOptions(e,p),Si(e,u,s,p,"pushExtra"),og(e,p,f),u=p}u=f}this.arity.trailing.length>0&&La(e,u,vi,tn,["setError","Not enough positional arguments"]);let g=u;for(let f=0;f<this.arity.trailing.length;++f){let h=io(e,sn());this.arity.proxy||this.registerOptions(e,h),f+1<this.arity.trailing.length&&La(e,h,vi,tn,["setError","Not enough positional arguments"]),Si(e,g,"isNotOptionLike",h,"pushPositional"),g=h}Si(e,g,s,tn,["setError","Extraneous positional argument"]),La(e,g,vi,ap,["setSelectedIndex",this.cliIndex])}return{machine:e,context:this.context}}registerOptions(e,r){Si(e,r,["isOption","--"],r,"inhibateOptions"),Si(e,r,["isBatchOption",this.allOptionNames],r,"pushBatch"),Si(e,r,["isBoundOption",this.allOptionNames,this.options],r,"pushBound"),Si(e,r,["isUnsupportedOption",this.allOptionNames],tn,["setError","Unsupported option name"]),Si(e,r,["isInvalidOption"],tn,["setError","Invalid option name"]);for(let i of this.options){let n=i.names.reduce((s,o)=>o.length>s.length?o:s,"");if(i.arity===0)for(let s of i.names)Si(e,r,["isOption",s,i.hidden||s!==n],r,"pushTrue"),s.startsWith("--")&&!s.startsWith("--no-")&&Si(e,r,["isNegatedOption",s],r,["pushFalse",s]);else{let s=io(e,sn());for(let o of i.names)Si(e,r,["isOption",o,i.hidden||o!==n],s,"pushUndefined");for(let o=0;o<i.arity;++o){let a=io(e,sn());La(e,s,vi,tn,"setOptionArityError"),Si(e,s,"isOptionLike",tn,"setOptionArityError");let l=i.arity===1?"setStringValue":"pushStringValue";Si(e,s,"isNotOptionLike",a,l),s=a}og(e,s,r)}}}},pp=class{constructor({binaryName:e="..."}={}){this.builders=[],this.opts={binaryName:e}}static build(e,r={}){return new pp(r).commands(e).compile()}getBuilderByIndex(e){if(!(e>=0&&e<this.builders.length))throw new Error(`Assertion failed: Out-of-bound command index (${e})`);return this.builders[e]}commands(e){for(let r of e)r(this.command());return this}command(){let e=new MK(this.builders.length,this.opts);return this.builders.push(e),e}compile(){let e=[],r=[];for(let n of this.builders){let{machine:s,context:o}=n.compile();e.push(s),r.push(o)}let i=SCe(e);return kCe(i),{machine:i,contexts:r,process:n=>LCe(i,n),suggest:(n,s)=>FCe(i,n,s)}}};var dp=class extends Re{constructor(e){super();this.contexts=e,this.commands=[]}static from(e,r){let i=new dp(r);i.path=e.path;for(let n of e.options)switch(n.name){case"-c":i.commands.push(Number(n.value));break;case"-i":i.index=Number(n.value);break}return i}async execute(){let e=this.commands;if(typeof this.index!="undefined"&&this.index>=0&&this.index<e.length&&(e=[e[this.index]]),e.length===0)this.context.stdout.write(this.cli.usage());else if(e.length===1)this.context.stdout.write(this.cli.usage(this.contexts[e[0]].commandClass,{detailed:!0}));else if(e.length>1){this.context.stdout.write(`Multiple commands match your selection:
+`),this.context.stdout.write(`
+`);let r=0;for(let i of this.commands)this.context.stdout.write(this.cli.usage(this.contexts[i].commandClass,{prefix:`${r++}. `.padStart(5)}));this.context.stdout.write(`
+`),this.context.stdout.write(`Run again with -h=<index> to see the longer details of any of those commands.
+`)}}};var UK=Symbol("clipanion/errorCommand");function OCe(){return process.env.FORCE_COLOR==="0"?1:process.env.FORCE_COLOR==="1"||typeof process.stdout!="undefined"&&process.stdout.isTTY?8:1}var Is=class{constructor({binaryLabel:e,binaryName:r="...",binaryVersion:i,enableCapture:n=!1,enableColors:s}={}){this.registrations=new Map,this.builder=new pp({binaryName:r}),this.binaryLabel=e,this.binaryName=r,this.binaryVersion=i,this.enableCapture=n,this.enableColors=s}static from(e,r={}){let i=new Is(r);for(let n of e)i.register(n);return i}register(e){var r;let i=new Map,n=new e;for(let l in n){let c=n[l];typeof c=="object"&&c!==null&&c[Re.isOption]&&i.set(l,c)}let s=this.builder.command(),o=s.cliIndex,a=(r=e.paths)!==null&&r!==void 0?r:n.paths;if(typeof a!="undefined")for(let l of a)s.addPath(l);this.registrations.set(e,{specs:i,builder:s,index:o});for(let[l,{definition:c}]of i.entries())c(s,l);s.setContext({commandClass:e})}process(e){let{contexts:r,process:i}=this.builder.compile(),n=i(e);switch(n.selectedIndex){case ng:return dp.from(n,r);default:{let{commandClass:s}=r[n.selectedIndex],o=this.registrations.get(s);if(typeof o=="undefined")throw new Error("Assertion failed: Expected the command class to have been registered.");let a=new s;a.path=n.path;try{for(let[l,{transformer:c}]of o.specs.entries())a[l]=c(o.builder,l,n);return a}catch(l){throw l[UK]=a,l}}break}}async run(e,r){var i;let n,s=N(N({},Is.defaultContext),r),o=(i=this.enableColors)!==null&&i!==void 0?i:s.colorDepth>1;if(!Array.isArray(e))n=e;else try{n=this.process(e)}catch(c){return s.stdout.write(this.error(c,{colored:o})),1}if(n.help)return s.stdout.write(this.usage(n,{colored:o,detailed:!0})),0;n.context=s,n.cli={binaryLabel:this.binaryLabel,binaryName:this.binaryName,binaryVersion:this.binaryVersion,enableCapture:this.enableCapture,enableColors:this.enableColors,definitions:()=>this.definitions(),error:(c,u)=>this.error(c,u),format:c=>this.format(c),process:c=>this.process(c),run:(c,u)=>this.run(c,N(N({},s),u)),usage:(c,u)=>this.usage(c,u)};let a=this.enableCapture?MCe(s):KK,l;try{l=await a(()=>n.validateAndExecute().catch(c=>n.catch(c).then(()=>0)))}catch(c){return s.stdout.write(this.error(c,{colored:o,command:n})),1}return l}async runExit(e,r){process.exitCode=await this.run(e,r)}suggest(e,r){let{suggest:i}=this.builder.compile();return i(e,r)}definitions({colored:e=!1}={}){let r=[];for(let[i,{index:n}]of this.registrations){if(typeof i.usage=="undefined")continue;let{usage:s}=this.getUsageByIndex(n,{detailed:!1}),{usage:o,options:a}=this.getUsageByIndex(n,{detailed:!0,inlineOptions:!1}),l=typeof i.usage.category!="undefined"?Ki(i.usage.category,{format:this.format(e),paragraphs:!1}):void 0,c=typeof i.usage.description!="undefined"?Ki(i.usage.description,{format:this.format(e),paragraphs:!1}):void 0,u=typeof i.usage.details!="undefined"?Ki(i.usage.details,{format:this.format(e),paragraphs:!0}):void 0,g=typeof i.usage.examples!="undefined"?i.usage.examples.map(([f,h])=>[Ki(f,{format:this.format(e),paragraphs:!1}),h.replace(/\$0/g,this.binaryName)]):void 0;r.push({path:s,usage:o,category:l,description:c,details:u,examples:g,options:a})}return r}usage(e=null,{colored:r,detailed:i=!1,prefix:n="$ "}={}){var s;if(e===null){for(let l of this.registrations.keys()){let c=l.paths,u=typeof l.usage!="undefined";if(!c||c.length===0||c.length===1&&c[0].length===0||((s=c==null?void 0:c.some(h=>h.length===0))!==null&&s!==void 0?s:!1))if(e){e=null;break}else e=l;else if(u){e=null;continue}}e&&(i=!0)}let o=e!==null&&e instanceof Re?e.constructor:e,a="";if(o)if(i){let{description:l="",details:c="",examples:u=[]}=o.usage||{};l!==""&&(a+=Ki(l,{format:this.format(r),paragraphs:!1}).replace(/^./,h=>h.toUpperCase()),a+=`
+`),(c!==""||u.length>0)&&(a+=`${this.format(r).header("Usage")}
+`,a+=`
+`);let{usage:g,options:f}=this.getUsageByRegistration(o,{inlineOptions:!1});if(a+=`${this.format(r).bold(n)}${g}
+`,f.length>0){a+=`
+`,a+=`${uv.header("Options")}
+`;let h=f.reduce((p,m)=>Math.max(p,m.definition.length),0);a+=`
+`;for(let{definition:p,description:m}of f)a+=`  ${this.format(r).bold(p.padEnd(h))}    ${Ki(m,{format:this.format(r),paragraphs:!1})}`}if(c!==""&&(a+=`
+`,a+=`${this.format(r).header("Details")}
+`,a+=`
+`,a+=Ki(c,{format:this.format(r),paragraphs:!0})),u.length>0){a+=`
+`,a+=`${this.format(r).header("Examples")}
+`;for(let[h,p]of u)a+=`
+`,a+=Ki(h,{format:this.format(r),paragraphs:!1}),a+=`${p.replace(/^/m,`  ${this.format(r).bold(n)}`).replace(/\$0/g,this.binaryName)}
+`}}else{let{usage:l}=this.getUsageByRegistration(o);a+=`${this.format(r).bold(n)}${l}
+`}else{let l=new Map;for(let[f,{index:h}]of this.registrations.entries()){if(typeof f.usage=="undefined")continue;let p=typeof f.usage.category!="undefined"?Ki(f.usage.category,{format:this.format(r),paragraphs:!1}):null,m=l.get(p);typeof m=="undefined"&&l.set(p,m=[]);let{usage:y}=this.getUsageByIndex(h);m.push({commandClass:f,usage:y})}let c=Array.from(l.keys()).sort((f,h)=>f===null?-1:h===null?1:f.localeCompare(h,"en",{usage:"sort",caseFirst:"upper"})),u=typeof this.binaryLabel!="undefined",g=typeof this.binaryVersion!="undefined";u||g?(u&&g?a+=`${this.format(r).header(`${this.binaryLabel} - ${this.binaryVersion}`)}
+
+`:u?a+=`${this.format(r).header(`${this.binaryLabel}`)}
+`:a+=`${this.format(r).header(`${this.binaryVersion}`)}
+`,a+=`  ${this.format(r).bold(n)}${this.binaryName} <command>
+`):a+=`${this.format(r).bold(n)}${this.binaryName} <command>
+`;for(let f of c){let h=l.get(f).slice().sort((m,y)=>m.usage.localeCompare(y.usage,"en",{usage:"sort",caseFirst:"upper"})),p=f!==null?f.trim():"General commands";a+=`
+`,a+=`${this.format(r).header(`${p}`)}
+`;for(let{commandClass:m,usage:y}of h){let Q=m.usage.description||"undocumented";a+=`
+`,a+=`  ${this.format(r).bold(y)}
+`,a+=`    ${Ki(Q,{format:this.format(r),paragraphs:!1})}`}}a+=`
+`,a+=Ki("You can also print more details about any of these commands by calling them with the `-h,--help` flag right after the command name.",{format:this.format(r),paragraphs:!0})}return a}error(e,r){var i,{colored:n,command:s=(i=e[UK])!==null&&i!==void 0?i:null}=r===void 0?{}:r;e instanceof Error||(e=new Error(`Execution failed with a non-error rejection (rejected value: ${JSON.stringify(e)})`));let o="",a=e.name.replace(/([a-z])([A-Z])/g,"$1 $2");a==="Error"&&(a="Internal Error"),o+=`${this.format(n).error(a)}: ${e.message}
+`;let l=e.clipanion;return typeof l!="undefined"?l.type==="usage"&&(o+=`
+`,o+=this.usage(s)):e.stack&&(o+=`${e.stack.replace(/^.*\n/,"")}
+`),o}format(e){var r;return((r=e!=null?e:this.enableColors)!==null&&r!==void 0?r:Is.defaultContext.colorDepth>1)?uv:RK}getUsageByRegistration(e,r){let i=this.registrations.get(e);if(typeof i=="undefined")throw new Error("Assertion failed: Unregistered command");return this.getUsageByIndex(i.index,r)}getUsageByIndex(e,r){return this.builder.getBuilderByIndex(e).usage(r)}};Is.defaultContext={stdin:process.stdin,stdout:process.stdout,stderr:process.stderr,colorDepth:"getColorDepth"in hv.default.WriteStream.prototype?hv.default.WriteStream.prototype.getColorDepth():OCe()};var HK;function MCe(t){let e=HK;if(typeof e=="undefined"){if(t.stdout===process.stdout&&t.stderr===process.stderr)return KK;let{AsyncLocalStorage:r}=require("async_hooks");e=HK=new r;let i=process.stdout._write;process.stdout._write=function(s,o,a){let l=e.getStore();return typeof l=="undefined"?i.call(this,s,o,a):l.stdout.write(s,o,a)};let n=process.stderr._write;process.stderr._write=function(s,o,a){let l=e.getStore();return typeof l=="undefined"?n.call(this,s,o,a):l.stderr.write(s,o,a)}}return r=>e.run(t,r)}function KK(t){return t()}var pv={};ft(pv,{DefinitionsCommand:()=>II,HelpCommand:()=>yI,VersionCommand:()=>wI});var II=class extends Re{async execute(){this.context.stdout.write(`${JSON.stringify(this.cli.definitions(),null,2)}
+`)}};II.paths=[["--clipanion=definitions"]];var yI=class extends Re{async execute(){this.context.stdout.write(this.cli.usage())}};yI.paths=[["-h"],["--help"]];var wI=class extends Re{async execute(){var e;this.context.stdout.write(`${(e=this.cli.binaryVersion)!==null&&e!==void 0?e:"<unknown>"}
+`)}};wI.paths=[["-v"],["--version"]];var z={};ft(z,{Array:()=>jK,Boolean:()=>GK,Counter:()=>YK,Proxy:()=>qK,Rest:()=>JK,String:()=>WK,applyValidator:()=>up,cleanValidationError:()=>dI,formatError:()=>cp,isOptionSymbol:()=>lp,makeCommandOption:()=>rn,rerouteArguments:()=>No});function jK(t,e,r){let[i,n]=No(e,r!=null?r:{}),{arity:s=1}=n,o=t.split(","),a=new Set(o);return rn({definition(l){l.addOption({names:o,arity:s,hidden:n==null?void 0:n.hidden,description:n==null?void 0:n.description,required:n.required})},transformer(l,c,u){let g=typeof i!="undefined"?[...i]:void 0;for(let{name:f,value:h}of u.options)!a.has(f)||(g=g!=null?g:[],g.push(h));return g}})}function GK(t,e,r){let[i,n]=No(e,r!=null?r:{}),s=t.split(","),o=new Set(s);return rn({definition(a){a.addOption({names:s,allowBinding:!1,arity:0,hidden:n.hidden,description:n.description,required:n.required})},transformer(a,l,c){let u=i;for(let{name:g,value:f}of c.options)!o.has(g)||(u=f);return u}})}function YK(t,e,r){let[i,n]=No(e,r!=null?r:{}),s=t.split(","),o=new Set(s);return rn({definition(a){a.addOption({names:s,allowBinding:!1,arity:0,hidden:n.hidden,description:n.description,required:n.required})},transformer(a,l,c){let u=i;for(let{name:g,value:f}of c.options)!o.has(g)||(u!=null||(u=0),f?u+=1:u=0);return u}})}function qK(t={}){return rn({definition(e,r){var i;e.addProxy({name:(i=t.name)!==null&&i!==void 0?i:r,required:t.required})},transformer(e,r,i){return i.positionals.map(({value:n})=>n)}})}function JK(t={}){return rn({definition(e,r){var i;e.addRest({name:(i=t.name)!==null&&i!==void 0?i:r,required:t.required})},transformer(e,r,i){let n=o=>{let a=i.positionals[o];return a.extra===Vn||a.extra===!1&&o<e.arity.leading.length},s=0;for(;s<i.positionals.length&&n(s);)s+=1;return i.positionals.splice(0,s).map(({value:o})=>o)}})}function UCe(t,e,r){let[i,n]=No(e,r!=null?r:{}),{arity:s=1}=n,o=t.split(","),a=new Set(o);return rn({definition(l){l.addOption({names:o,arity:n.tolerateBoolean?0:s,hidden:n.hidden,description:n.description,required:n.required})},transformer(l,c,u){let g,f=i;for(let{name:h,value:p}of u.options)!a.has(h)||(g=h,f=p);return typeof f=="string"?up(g!=null?g:c,f,n.validator):f}})}function KCe(t={}){let{required:e=!0}=t;return rn({definition(r,i){var n;r.addPositional({name:(n=t.name)!==null&&n!==void 0?n:i,required:t.required})},transformer(r,i,n){var s;for(let o=0;o<n.positionals.length;++o){if(n.positionals[o].extra===Vn||e&&n.positionals[o].extra===!0||!e&&n.positionals[o].extra===!1)continue;let[a]=n.positionals.splice(o,1);return up((s=t.name)!==null&&s!==void 0?s:i,a.value,t.validator)}}})}function WK(t,...e){return typeof t=="string"?UCe(t,...e):KCe(t)}var iz=ge(ag()),Ix=ge(require("stream"));var $;(function(oe){oe[oe.UNNAMED=0]="UNNAMED",oe[oe.EXCEPTION=1]="EXCEPTION",oe[oe.MISSING_PEER_DEPENDENCY=2]="MISSING_PEER_DEPENDENCY",oe[oe.CYCLIC_DEPENDENCIES=3]="CYCLIC_DEPENDENCIES",oe[oe.DISABLED_BUILD_SCRIPTS=4]="DISABLED_BUILD_SCRIPTS",oe[oe.BUILD_DISABLED=5]="BUILD_DISABLED",oe[oe.SOFT_LINK_BUILD=6]="SOFT_LINK_BUILD",oe[oe.MUST_BUILD=7]="MUST_BUILD",oe[oe.MUST_REBUILD=8]="MUST_REBUILD",oe[oe.BUILD_FAILED=9]="BUILD_FAILED",oe[oe.RESOLVER_NOT_FOUND=10]="RESOLVER_NOT_FOUND",oe[oe.FETCHER_NOT_FOUND=11]="FETCHER_NOT_FOUND",oe[oe.LINKER_NOT_FOUND=12]="LINKER_NOT_FOUND",oe[oe.FETCH_NOT_CACHED=13]="FETCH_NOT_CACHED",oe[oe.YARN_IMPORT_FAILED=14]="YARN_IMPORT_FAILED",oe[oe.REMOTE_INVALID=15]="REMOTE_INVALID",oe[oe.REMOTE_NOT_FOUND=16]="REMOTE_NOT_FOUND",oe[oe.RESOLUTION_PACK=17]="RESOLUTION_PACK",oe[oe.CACHE_CHECKSUM_MISMATCH=18]="CACHE_CHECKSUM_MISMATCH",oe[oe.UNUSED_CACHE_ENTRY=19]="UNUSED_CACHE_ENTRY",oe[oe.MISSING_LOCKFILE_ENTRY=20]="MISSING_LOCKFILE_ENTRY",oe[oe.WORKSPACE_NOT_FOUND=21]="WORKSPACE_NOT_FOUND",oe[oe.TOO_MANY_MATCHING_WORKSPACES=22]="TOO_MANY_MATCHING_WORKSPACES",oe[oe.CONSTRAINTS_MISSING_DEPENDENCY=23]="CONSTRAINTS_MISSING_DEPENDENCY",oe[oe.CONSTRAINTS_INCOMPATIBLE_DEPENDENCY=24]="CONSTRAINTS_INCOMPATIBLE_DEPENDENCY",oe[oe.CONSTRAINTS_EXTRANEOUS_DEPENDENCY=25]="CONSTRAINTS_EXTRANEOUS_DEPENDENCY",oe[oe.CONSTRAINTS_INVALID_DEPENDENCY=26]="CONSTRAINTS_INVALID_DEPENDENCY",oe[oe.CANT_SUGGEST_RESOLUTIONS=27]="CANT_SUGGEST_RESOLUTIONS",oe[oe.FROZEN_LOCKFILE_EXCEPTION=28]="FROZEN_LOCKFILE_EXCEPTION",oe[oe.CROSS_DRIVE_VIRTUAL_LOCAL=29]="CROSS_DRIVE_VIRTUAL_LOCAL",oe[oe.FETCH_FAILED=30]="FETCH_FAILED",oe[oe.DANGEROUS_NODE_MODULES=31]="DANGEROUS_NODE_MODULES",oe[oe.NODE_GYP_INJECTED=32]="NODE_GYP_INJECTED",oe[oe.AUTHENTICATION_NOT_FOUND=33]="AUTHENTICATION_NOT_FOUND",oe[oe.INVALID_CONFIGURATION_KEY=34]="INVALID_CONFIGURATION_KEY",oe[oe.NETWORK_ERROR=35]="NETWORK_ERROR",oe[oe.LIFECYCLE_SCRIPT=36]="LIFECYCLE_SCRIPT",oe[oe.CONSTRAINTS_MISSING_FIELD=37]="CONSTRAINTS_MISSING_FIELD",oe[oe.CONSTRAINTS_INCOMPATIBLE_FIELD=38]="CONSTRAINTS_INCOMPATIBLE_FIELD",oe[oe.CONSTRAINTS_EXTRANEOUS_FIELD=39]="CONSTRAINTS_EXTRANEOUS_FIELD",oe[oe.CONSTRAINTS_INVALID_FIELD=40]="CONSTRAINTS_INVALID_FIELD",oe[oe.AUTHENTICATION_INVALID=41]="AUTHENTICATION_INVALID",oe[oe.PROLOG_UNKNOWN_ERROR=42]="PROLOG_UNKNOWN_ERROR",oe[oe.PROLOG_SYNTAX_ERROR=43]="PROLOG_SYNTAX_ERROR",oe[oe.PROLOG_EXISTENCE_ERROR=44]="PROLOG_EXISTENCE_ERROR",oe[oe.STACK_OVERFLOW_RESOLUTION=45]="STACK_OVERFLOW_RESOLUTION",oe[oe.AUTOMERGE_FAILED_TO_PARSE=46]="AUTOMERGE_FAILED_TO_PARSE",oe[oe.AUTOMERGE_IMMUTABLE=47]="AUTOMERGE_IMMUTABLE",oe[oe.AUTOMERGE_SUCCESS=48]="AUTOMERGE_SUCCESS",oe[oe.AUTOMERGE_REQUIRED=49]="AUTOMERGE_REQUIRED",oe[oe.DEPRECATED_CLI_SETTINGS=50]="DEPRECATED_CLI_SETTINGS",oe[oe.PLUGIN_NAME_NOT_FOUND=51]="PLUGIN_NAME_NOT_FOUND",oe[oe.INVALID_PLUGIN_REFERENCE=52]="INVALID_PLUGIN_REFERENCE",oe[oe.CONSTRAINTS_AMBIGUITY=53]="CONSTRAINTS_AMBIGUITY",oe[oe.CACHE_OUTSIDE_PROJECT=54]="CACHE_OUTSIDE_PROJECT",oe[oe.IMMUTABLE_INSTALL=55]="IMMUTABLE_INSTALL",oe[oe.IMMUTABLE_CACHE=56]="IMMUTABLE_CACHE",oe[oe.INVALID_MANIFEST=57]="INVALID_MANIFEST",oe[oe.PACKAGE_PREPARATION_FAILED=58]="PACKAGE_PREPARATION_FAILED",oe[oe.INVALID_RANGE_PEER_DEPENDENCY=59]="INVALID_RANGE_PEER_DEPENDENCY",oe[oe.INCOMPATIBLE_PEER_DEPENDENCY=60]="INCOMPATIBLE_PEER_DEPENDENCY",oe[oe.DEPRECATED_PACKAGE=61]="DEPRECATED_PACKAGE",oe[oe.INCOMPATIBLE_OS=62]="INCOMPATIBLE_OS",oe[oe.INCOMPATIBLE_CPU=63]="INCOMPATIBLE_CPU",oe[oe.FROZEN_ARTIFACT_EXCEPTION=64]="FROZEN_ARTIFACT_EXCEPTION",oe[oe.TELEMETRY_NOTICE=65]="TELEMETRY_NOTICE",oe[oe.PATCH_HUNK_FAILED=66]="PATCH_HUNK_FAILED",oe[oe.INVALID_CONFIGURATION_VALUE=67]="INVALID_CONFIGURATION_VALUE",oe[oe.UNUSED_PACKAGE_EXTENSION=68]="UNUSED_PACKAGE_EXTENSION",oe[oe.REDUNDANT_PACKAGE_EXTENSION=69]="REDUNDANT_PACKAGE_EXTENSION",oe[oe.AUTO_NM_SUCCESS=70]="AUTO_NM_SUCCESS",oe[oe.NM_CANT_INSTALL_EXTERNAL_SOFT_LINK=71]="NM_CANT_INSTALL_EXTERNAL_SOFT_LINK",oe[oe.NM_PRESERVE_SYMLINKS_REQUIRED=72]="NM_PRESERVE_SYMLINKS_REQUIRED",oe[oe.UPDATE_LOCKFILE_ONLY_SKIP_LINK=73]="UPDATE_LOCKFILE_ONLY_SKIP_LINK",oe[oe.NM_HARDLINKS_MODE_DOWNGRADED=74]="NM_HARDLINKS_MODE_DOWNGRADED",oe[oe.PROLOG_INSTANTIATION_ERROR=75]="PROLOG_INSTANTIATION_ERROR",oe[oe.INCOMPATIBLE_ARCHITECTURE=76]="INCOMPATIBLE_ARCHITECTURE",oe[oe.GHOST_ARCHITECTURE=77]="GHOST_ARCHITECTURE"})($||($={}));function YA(t){return`YN${t.toString(10).padStart(4,"0")}`}function BI(t){let e=Number(t.slice(2));if(typeof $[e]=="undefined")throw new Error(`Unknown message name: "${t}"`);return e}var P={};ft(P,{areDescriptorsEqual:()=>c8,areIdentsEqual:()=>fd,areLocatorsEqual:()=>hd,areVirtualPackagesEquivalent:()=>uSe,bindDescriptor:()=>lSe,bindLocator:()=>cSe,convertDescriptorToLocator:()=>uw,convertLocatorToDescriptor:()=>nx,convertPackageToLocator:()=>ASe,convertToIdent:()=>aSe,convertToManifestRange:()=>hSe,copyPackage:()=>cd,devirtualizeDescriptor:()=>ud,devirtualizeLocator:()=>gd,getIdentVendorPath:()=>lx,isPackageCompatible:()=>pw,isVirtualDescriptor:()=>il,isVirtualLocator:()=>Xo,makeDescriptor:()=>rr,makeIdent:()=>Vo,makeLocator:()=>cn,makeRange:()=>fw,parseDescriptor:()=>nl,parseFileStyleRange:()=>gSe,parseIdent:()=>An,parseLocator:()=>Mc,parseRange:()=>Kg,prettyDependent:()=>YS,prettyDescriptor:()=>sr,prettyIdent:()=>gi,prettyLocator:()=>Bt,prettyLocatorNoColors:()=>Ax,prettyRange:()=>cw,prettyReference:()=>dd,prettyResolution:()=>qS,prettyWorkspace:()=>Cd,renamePackage:()=>ld,slugifyIdent:()=>ax,slugifyLocator:()=>Hg,sortDescriptors:()=>jg,stringifyDescriptor:()=>Pn,stringifyIdent:()=>Ot,stringifyLocator:()=>Ps,tryParseDescriptor:()=>pd,tryParseIdent:()=>u8,tryParseLocator:()=>gw,virtualizeDescriptor:()=>sx,virtualizePackage:()=>ox});var Ug=ge(require("querystring")),a8=ge(ti()),A8=ge(bY());var ae={};ft(ae,{LogLevel:()=>go,Style:()=>Pc,Type:()=>Ge,addLogFilterSupport:()=>nd,applyColor:()=>rs,applyHyperlink:()=>Fg,applyStyle:()=>Ly,json:()=>Dc,jsonOrPretty:()=>G0e,mark:()=>VS,pretty:()=>et,prettyField:()=>Jo,prettyList:()=>_S,supportsColor:()=>Fy,supportsHyperlinks:()=>WS,tuple:()=>uo});var rd=ge(IS()),id=ge(hc());var QJ=ge(ts()),vJ=ge(gJ());var Se={};ft(Se,{AsyncActions:()=>EJ,BufferStream:()=>mJ,CachingStrategy:()=>xc,DefaultStream:()=>IJ,allSettledSafe:()=>co,assertNever:()=>US,bufferStream:()=>Dg,buildIgnorePattern:()=>U0e,convertMapsToIndexableObjects:()=>Ry,dynamicRequire:()=>Rg,escapeRegExp:()=>N0e,getArrayWithDefault:()=>kg,getFactoryWithDefault:()=>qa,getMapWithDefault:()=>xg,getSetWithDefault:()=>kc,isIndexableObject:()=>KS,isPathLike:()=>K0e,isTaggedYarnVersion:()=>F0e,mapAndFilter:()=>qo,mapAndFind:()=>$p,overrideType:()=>MS,parseBoolean:()=>td,parseOptionalBoolean:()=>bJ,prettifyAsyncErrors:()=>Pg,prettifySyncErrors:()=>HS,releaseAfterUseAsync:()=>T0e,replaceEnvVariables:()=>jS,sortMap:()=>xn,tryParseOptionalBoolean:()=>GS,validateEnum:()=>L0e});var fJ=ge(ts()),hJ=ge(ag()),pJ=ge(ti()),OS=ge(require("stream"));function F0e(t){return!!(pJ.default.valid(t)&&t.match(/^[^-]+(-rc\.[0-9]+)?$/))}function N0e(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function MS(t){}function US(t){throw new Error(`Assertion failed: Unexpected object '${t}'`)}function L0e(t,e){let r=Object.values(t);if(!r.includes(e))throw new Pe(`Invalid value for enumeration: ${JSON.stringify(e)} (expected one of ${r.map(i=>JSON.stringify(i)).join(", ")})`);return e}function qo(t,e){let r=[];for(let i of t){let n=e(i);n!==dJ&&r.push(n)}return r}var dJ=Symbol();qo.skip=dJ;function $p(t,e){for(let r of t){let i=e(r);if(i!==CJ)return i}}var CJ=Symbol();$p.skip=CJ;function KS(t){return typeof t=="object"&&t!==null}async function co(t){let e=await Promise.allSettled(t),r=[];for(let i of e){if(i.status==="rejected")throw i.reason;r.push(i.value)}return r}function Ry(t){if(t instanceof Map&&(t=Object.fromEntries(t)),KS(t))for(let e of Object.keys(t)){let r=t[e];KS(r)&&(t[e]=Ry(r))}return t}function qa(t,e,r){let i=t.get(e);return typeof i=="undefined"&&t.set(e,i=r()),i}function kg(t,e){let r=t.get(e);return typeof r=="undefined"&&t.set(e,r=[]),r}function kc(t,e){let r=t.get(e);return typeof r=="undefined"&&t.set(e,r=new Set),r}function xg(t,e){let r=t.get(e);return typeof r=="undefined"&&t.set(e,r=new Map),r}async function T0e(t,e){if(e==null)return await t();try{return await t()}finally{await e()}}async function Pg(t,e){try{return await t()}catch(r){throw r.message=e(r.message),r}}function HS(t,e){try{return t()}catch(r){throw r.message=e(r.message),r}}async function Dg(t){return await new Promise((e,r)=>{let i=[];t.on("error",n=>{r(n)}),t.on("data",n=>{i.push(n)}),t.on("end",()=>{e(Buffer.concat(i))})})}var mJ=class extends OS.Transform{constructor(){super(...arguments);this.chunks=[]}_transform(e,r,i){if(r!=="buffer"||!Buffer.isBuffer(e))throw new Error("Assertion failed: BufferStream only accept buffers");this.chunks.push(e),i(null,null)}_flush(e){e(null,Buffer.concat(this.chunks))}};function O0e(){let t,e;return{promise:new Promise((i,n)=>{t=i,e=n}),resolve:t,reject:e}}var EJ=class{constructor(e){this.deferred=new Map;this.promises=new Map;this.limit=(0,hJ.default)(e)}set(e,r){let i=this.deferred.get(e);typeof i=="undefined"&&this.deferred.set(e,i=O0e());let n=this.limit(()=>r());return this.promises.set(e,n),n.then(()=>{this.promises.get(e)===n&&i.resolve()},s=>{this.promises.get(e)===n&&i.reject(s)}),i.promise}reduce(e,r){var n;let i=(n=this.promises.get(e))!=null?n:Promise.resolve();this.set(e,()=>r(i))}async wait(){await Promise.all(this.promises.values())}},IJ=class extends OS.Transform{constructor(e=Buffer.alloc(0)){super();this.active=!0;this.ifEmpty=e}_transform(e,r,i){if(r!=="buffer"||!Buffer.isBuffer(e))throw new Error("Assertion failed: DefaultStream only accept buffers");this.active=!1,i(null,e)}_flush(e){this.active&&this.ifEmpty.length>0?e(null,this.ifEmpty):e(null)}},ed=eval("require");function yJ(t){return ed(j.fromPortablePath(t))}function wJ(path){let physicalPath=j.fromPortablePath(path),currentCacheEntry=ed.cache[physicalPath];delete ed.cache[physicalPath];let result;try{result=yJ(physicalPath);let freshCacheEntry=ed.cache[physicalPath],dynamicModule=eval("module"),freshCacheIndex=dynamicModule.children.indexOf(freshCacheEntry);freshCacheIndex!==-1&&dynamicModule.children.splice(freshCacheIndex,1)}finally{ed.cache[physicalPath]=currentCacheEntry}return result}var BJ=new Map;function M0e(t){let e=BJ.get(t),r=K.statSync(t);if((e==null?void 0:e.mtime)===r.mtimeMs)return e.instance;let i=wJ(t);return BJ.set(t,{mtime:r.mtimeMs,instance:i}),i}var xc;(function(i){i[i.NoCache=0]="NoCache",i[i.FsTime=1]="FsTime",i[i.Node=2]="Node"})(xc||(xc={}));function Rg(t,{cachingStrategy:e=2}={}){switch(e){case 0:return wJ(t);case 1:return M0e(t);case 2:return yJ(t);default:throw new Error("Unsupported caching strategy")}}function xn(t,e){let r=Array.from(t);Array.isArray(e)||(e=[e]);let i=[];for(let s of e)i.push(r.map(o=>s(o)));let n=r.map((s,o)=>o);return n.sort((s,o)=>{for(let a of i){let l=a[s]<a[o]?-1:a[s]>a[o]?1:0;if(l!==0)return l}return 0}),n.map(s=>r[s])}function U0e(t){return t.length===0?null:t.map(e=>`(${fJ.default.makeRe(e,{windows:!1,dot:!0}).source})`).join("|")}function jS(t,{env:e}){let r=/\${(?<variableName>[\d\w_]+)(?<colon>:)?(?:-(?<fallback>[^}]*))?}/g;return t.replace(r,(...i)=>{let{variableName:n,colon:s,fallback:o}=i[i.length-1],a=Object.prototype.hasOwnProperty.call(e,n),l=e[n];if(l||a&&!s)return l;if(o!=null)return o;throw new Pe(`Environment variable not found (${n})`)})}function td(t){switch(t){case"true":case"1":case 1:case!0:return!0;case"false":case"0":case 0:case!1:return!1;default:throw new Error(`Couldn't parse "${t}" as a boolean`)}}function bJ(t){return typeof t=="undefined"?t:td(t)}function GS(t){try{return bJ(t)}catch{return null}}function K0e(t){return!!(j.isAbsolute(t)||t.match(/^(\.{1,2}|~)\//))}var Qt;(function(r){r.HARD="HARD",r.SOFT="SOFT"})(Qt||(Qt={}));var yi;(function(i){i.Dependency="Dependency",i.PeerDependency="PeerDependency",i.PeerDependencyMeta="PeerDependencyMeta"})(yi||(yi={}));var qi;(function(i){i.Inactive="inactive",i.Redundant="redundant",i.Active="active"})(qi||(qi={}));var Ge={NO_HINT:"NO_HINT",NULL:"NULL",SCOPE:"SCOPE",NAME:"NAME",RANGE:"RANGE",REFERENCE:"REFERENCE",NUMBER:"NUMBER",PATH:"PATH",URL:"URL",ADDED:"ADDED",REMOVED:"REMOVED",CODE:"CODE",DURATION:"DURATION",SIZE:"SIZE",IDENT:"IDENT",DESCRIPTOR:"DESCRIPTOR",LOCATOR:"LOCATOR",RESOLUTION:"RESOLUTION",DEPENDENT:"DEPENDENT",PACKAGE_EXTENSION:"PACKAGE_EXTENSION",SETTING:"SETTING",MARKDOWN:"MARKDOWN"},Pc;(function(e){e[e.BOLD=2]="BOLD"})(Pc||(Pc={}));var JS=id.default.GITHUB_ACTIONS?{level:2}:rd.default.supportsColor?{level:rd.default.supportsColor.level}:{level:0},Fy=JS.level!==0,WS=Fy&&!id.default.GITHUB_ACTIONS&&!id.default.CIRCLE&&!id.default.GITLAB,zS=new rd.default.Instance(JS),H0e=new Map([[Ge.NO_HINT,null],[Ge.NULL,["#a853b5",129]],[Ge.SCOPE,["#d75f00",166]],[Ge.NAME,["#d7875f",173]],[Ge.RANGE,["#00afaf",37]],[Ge.REFERENCE,["#87afff",111]],[Ge.NUMBER,["#ffd700",220]],[Ge.PATH,["#d75fd7",170]],[Ge.URL,["#d75fd7",170]],[Ge.ADDED,["#5faf00",70]],[Ge.REMOVED,["#d70000",160]],[Ge.CODE,["#87afff",111]],[Ge.SIZE,["#ffd700",220]]]),Ds=t=>t,Ny={[Ge.NUMBER]:Ds({pretty:(t,e)=>`${e}`,json:t=>t}),[Ge.IDENT]:Ds({pretty:(t,e)=>gi(t,e),json:t=>Ot(t)}),[Ge.LOCATOR]:Ds({pretty:(t,e)=>Bt(t,e),json:t=>Ps(t)}),[Ge.DESCRIPTOR]:Ds({pretty:(t,e)=>sr(t,e),json:t=>Pn(t)}),[Ge.RESOLUTION]:Ds({pretty:(t,{descriptor:e,locator:r})=>qS(t,e,r),json:({descriptor:t,locator:e})=>({descriptor:Pn(t),locator:e!==null?Ps(e):null})}),[Ge.DEPENDENT]:Ds({pretty:(t,{locator:e,descriptor:r})=>YS(t,e,r),json:({locator:t,descriptor:e})=>({locator:Ps(t),descriptor:Pn(e)})}),[Ge.PACKAGE_EXTENSION]:Ds({pretty:(t,e)=>{switch(e.type){case yi.Dependency:return`${gi(t,e.parentDescriptor)} \u27A4 ${rs(t,"dependencies",Ge.CODE)} \u27A4 ${gi(t,e.descriptor)}`;case yi.PeerDependency:return`${gi(t,e.parentDescriptor)} \u27A4 ${rs(t,"peerDependencies",Ge.CODE)} \u27A4 ${gi(t,e.descriptor)}`;case yi.PeerDependencyMeta:return`${gi(t,e.parentDescriptor)} \u27A4 ${rs(t,"peerDependenciesMeta",Ge.CODE)} \u27A4 ${gi(t,An(e.selector))} \u27A4 ${rs(t,e.key,Ge.CODE)}`;default:throw new Error(`Assertion failed: Unsupported package extension type: ${e.type}`)}},json:t=>{switch(t.type){case yi.Dependency:return`${Ot(t.parentDescriptor)} > ${Ot(t.descriptor)}`;case yi.PeerDependency:return`${Ot(t.parentDescriptor)} >> ${Ot(t.descriptor)}`;case yi.PeerDependencyMeta:return`${Ot(t.parentDescriptor)} >> ${t.selector} / ${t.key}`;default:throw new Error(`Assertion failed: Unsupported package extension type: ${t.type}`)}}}),[Ge.SETTING]:Ds({pretty:(t,e)=>(t.get(e),Fg(t,rs(t,e,Ge.CODE),`https://yarnpkg.com/configuration/yarnrc#${e}`)),json:t=>t}),[Ge.DURATION]:Ds({pretty:(t,e)=>{if(e>1e3*60){let r=Math.floor(e/1e3/60),i=Math.ceil((e-r*60*1e3)/1e3);return i===0?`${r}m`:`${r}m ${i}s`}else{let r=Math.floor(e/1e3),i=e-r*1e3;return i===0?`${r}s`:`${r}s ${i}ms`}},json:t=>t}),[Ge.SIZE]:Ds({pretty:(t,e)=>{let r=["KB","MB","GB","TB"],i=r.length;for(;i>1&&e<1024**i;)i-=1;let n=1024**i,s=Math.floor(e*100/n)/100;return rs(t,`${s} ${r[i-1]}`,Ge.NUMBER)},json:t=>t}),[Ge.PATH]:Ds({pretty:(t,e)=>rs(t,j.fromPortablePath(e),Ge.PATH),json:t=>j.fromPortablePath(t)}),[Ge.MARKDOWN]:Ds({pretty:(t,{text:e,format:r,paragraphs:i})=>Ki(e,{format:r,paragraphs:i}),json:({text:t})=>t})};function uo(t,e){return[e,t]}function Ly(t,e,r){return t.get("enableColors")&&r&2&&(e=rd.default.bold(e)),e}function rs(t,e,r){if(!t.get("enableColors"))return e;let i=H0e.get(r);if(i===null)return e;let n=typeof i=="undefined"?r:JS.level>=3?i[0]:i[1],s=typeof n=="number"?zS.ansi256(n):n.startsWith("#")?zS.hex(n):zS[n];if(typeof s!="function")throw new Error(`Invalid format type ${n}`);return s(e)}var j0e=!!process.env.KONSOLE_VERSION;function Fg(t,e,r){return t.get("enableHyperlinks")?j0e?`\e]8;;${r}\e\\${e}\e]8;;\e\\`:`\e]8;;${r}\x07${e}\e]8;;\x07`:e}function et(t,e,r){if(e===null)return rs(t,"null",Ge.NULL);if(Object.prototype.hasOwnProperty.call(Ny,r))return Ny[r].pretty(t,e);if(typeof e!="string")throw new Error(`Assertion failed: Expected the value to be a string, got ${typeof e}`);return rs(t,e,r)}function _S(t,e,r,{separator:i=", "}={}){return[...e].map(n=>et(t,n,r)).join(i)}function Dc(t,e){if(t===null)return null;if(Object.prototype.hasOwnProperty.call(Ny,e))return MS(e),Ny[e].json(t);if(typeof t!="string")throw new Error(`Assertion failed: Expected the value to be a string, got ${typeof t}`);return t}function G0e(t,e,[r,i]){return t?Dc(r,i):et(e,r,i)}function VS(t){return{Check:rs(t,"\u2713","green"),Cross:rs(t,"\u2718","red"),Question:rs(t,"?","cyan")}}function Jo(t,{label:e,value:[r,i]}){return`${et(t,e,Ge.CODE)}: ${et(t,r,i)}`}var go;(function(n){n.Error="error",n.Warning="warning",n.Info="info",n.Discard="discard"})(go||(go={}));function nd(t,{configuration:e}){let r=e.get("logFilters"),i=new Map,n=new Map,s=[];for(let g of r){let f=g.get("level");if(typeof f=="undefined")continue;let h=g.get("code");typeof h!="undefined"&&i.set(h,f);let p=g.get("text");typeof p!="undefined"&&n.set(p,f);let m=g.get("pattern");typeof m!="undefined"&&s.push([QJ.default.matcher(m,{contains:!0}),f])}s.reverse();let o=(g,f,h)=>{if(g===null||g===$.UNNAMED)return h;let p=n.size>0||s.length>0?(0,vJ.default)(f):f;if(n.size>0){let m=n.get(p);if(typeof m!="undefined")return m!=null?m:h}if(s.length>0){for(let[m,y]of s)if(m(p))return y!=null?y:h}if(i.size>0){let m=i.get(YA(g));if(typeof m!="undefined")return m!=null?m:h}return h},a=t.reportInfo,l=t.reportWarning,c=t.reportError,u=function(g,f,h,p){switch(o(f,h,p)){case go.Info:a.call(g,f,h);break;case go.Warning:l.call(g,f!=null?f:$.UNNAMED,h);break;case go.Error:c.call(g,f!=null?f:$.UNNAMED,h);break}};t.reportInfo=function(...g){return u(this,...g,go.Info)},t.reportWarning=function(...g){return u(this,...g,go.Warning)},t.reportError=function(...g){return u(this,...g,go.Error)}}var Dn={};ft(Dn,{checksumFile:()=>Aw,checksumPattern:()=>lw,makeHash:()=>ln});var aw=ge(require("crypto")),ix=ge(rx());function ln(...t){let e=(0,aw.createHash)("sha512"),r="";for(let i of t)typeof i=="string"?r+=i:i&&(r&&(e.update(r),r=""),e.update(i));return r&&e.update(r),e.digest("hex")}async function Aw(t,{baseFs:e,algorithm:r}={baseFs:K,algorithm:"sha512"}){let i=await e.openPromise(t,"r");try{let n=65536,s=Buffer.allocUnsafeSlow(n),o=(0,aw.createHash)(r),a=0;for(;(a=await e.readPromise(i,s,0,n))!==0;)o.update(a===n?s:s.slice(0,a));return o.digest("hex")}finally{await e.closePromise(i)}}async function lw(t,{cwd:e}){let i=(await(0,ix.default)(t,{cwd:j.fromPortablePath(e),expandDirectories:!1,onlyDirectories:!0,unique:!0})).map(a=>`${a}/**/*`),n=await(0,ix.default)([t,...i],{cwd:j.fromPortablePath(e),expandDirectories:!1,onlyFiles:!1,unique:!0});n.sort();let s=await Promise.all(n.map(async a=>{let l=[Buffer.from(a)],c=j.toPortablePath(a),u=await K.lstatPromise(c);return u.isSymbolicLink()?l.push(Buffer.from(await K.readlinkPromise(c))):u.isFile()&&l.push(await K.readFilePromise(c)),l.join("\0")})),o=(0,aw.createHash)("sha512");for(let a of s)o.update(a);return o.digest("hex")}var Ad="virtual:",sSe=5,l8=/(os|cpu|libc)=([a-z0-9_-]+)/,oSe=(0,A8.makeParser)(l8);function Vo(t,e){if(t==null?void 0:t.startsWith("@"))throw new Error("Invalid scope: don't prefix it with '@'");return{identHash:ln(t,e),scope:t,name:e}}function rr(t,e){return{identHash:t.identHash,scope:t.scope,name:t.name,descriptorHash:ln(t.identHash,e),range:e}}function cn(t,e){return{identHash:t.identHash,scope:t.scope,name:t.name,locatorHash:ln(t.identHash,e),reference:e}}function aSe(t){return{identHash:t.identHash,scope:t.scope,name:t.name}}function uw(t){return{identHash:t.identHash,scope:t.scope,name:t.name,locatorHash:t.descriptorHash,reference:t.range}}function nx(t){return{identHash:t.identHash,scope:t.scope,name:t.name,descriptorHash:t.locatorHash,range:t.reference}}function ASe(t){return{identHash:t.identHash,scope:t.scope,name:t.name,locatorHash:t.locatorHash,reference:t.reference}}function ld(t,e){return{identHash:e.identHash,scope:e.scope,name:e.name,locatorHash:e.locatorHash,reference:e.reference,version:t.version,languageName:t.languageName,linkType:t.linkType,conditions:t.conditions,dependencies:new Map(t.dependencies),peerDependencies:new Map(t.peerDependencies),dependenciesMeta:new Map(t.dependenciesMeta),peerDependenciesMeta:new Map(t.peerDependenciesMeta),bin:new Map(t.bin)}}function cd(t){return ld(t,t)}function sx(t,e){if(e.includes("#"))throw new Error("Invalid entropy");return rr(t,`virtual:${e}#${t.range}`)}function ox(t,e){if(e.includes("#"))throw new Error("Invalid entropy");return ld(t,cn(t,`virtual:${e}#${t.reference}`))}function il(t){return t.range.startsWith(Ad)}function Xo(t){return t.reference.startsWith(Ad)}function ud(t){if(!il(t))throw new Error("Not a virtual descriptor");return rr(t,t.range.replace(/^[^#]*#/,""))}function gd(t){if(!Xo(t))throw new Error("Not a virtual descriptor");return cn(t,t.reference.replace(/^[^#]*#/,""))}function lSe(t,e){return t.range.includes("::")?t:rr(t,`${t.range}::${Ug.default.stringify(e)}`)}function cSe(t,e){return t.reference.includes("::")?t:cn(t,`${t.reference}::${Ug.default.stringify(e)}`)}function fd(t,e){return t.identHash===e.identHash}function c8(t,e){return t.descriptorHash===e.descriptorHash}function hd(t,e){return t.locatorHash===e.locatorHash}function uSe(t,e){if(!Xo(t))throw new Error("Invalid package type");if(!Xo(e))throw new Error("Invalid package type");if(!fd(t,e)||t.dependencies.size!==e.dependencies.size)return!1;for(let r of t.dependencies.values()){let i=e.dependencies.get(r.identHash);if(!i||!c8(r,i))return!1}return!0}function An(t){let e=u8(t);if(!e)throw new Error(`Invalid ident (${t})`);return e}function u8(t){let e=t.match(/^(?:@([^/]+?)\/)?([^/]+)$/);if(!e)return null;let[,r,i]=e,n=typeof r!="undefined"?r:null;return Vo(n,i)}function nl(t,e=!1){let r=pd(t,e);if(!r)throw new Error(`Invalid descriptor (${t})`);return r}function pd(t,e=!1){let r=e?t.match(/^(?:@([^/]+?)\/)?([^/]+?)(?:@(.+))$/):t.match(/^(?:@([^/]+?)\/)?([^/]+?)(?:@(.+))?$/);if(!r)return null;let[,i,n,s]=r;if(s==="unknown")throw new Error(`Invalid range (${t})`);let o=typeof i!="undefined"?i:null,a=typeof s!="undefined"?s:"unknown";return rr(Vo(o,n),a)}function Mc(t,e=!1){let r=gw(t,e);if(!r)throw new Error(`Invalid locator (${t})`);return r}function gw(t,e=!1){let r=e?t.match(/^(?:@([^/]+?)\/)?([^/]+?)(?:@(.+))$/):t.match(/^(?:@([^/]+?)\/)?([^/]+?)(?:@(.+))?$/);if(!r)return null;let[,i,n,s]=r;if(s==="unknown")throw new Error(`Invalid reference (${t})`);let o=typeof i!="undefined"?i:null,a=typeof s!="undefined"?s:"unknown";return cn(Vo(o,n),a)}function Kg(t,e){let r=t.match(/^([^#:]*:)?((?:(?!::)[^#])*)(?:#((?:(?!::).)*))?(?:::(.*))?$/);if(r===null)throw new Error(`Invalid range (${t})`);let i=typeof r[1]!="undefined"?r[1]:null;if(typeof(e==null?void 0:e.requireProtocol)=="string"&&i!==e.requireProtocol)throw new Error(`Invalid protocol (${i})`);if((e==null?void 0:e.requireProtocol)&&i===null)throw new Error(`Missing protocol (${i})`);let n=typeof r[3]!="undefined"?decodeURIComponent(r[2]):null;if((e==null?void 0:e.requireSource)&&n===null)throw new Error(`Missing source (${t})`);let s=typeof r[3]!="undefined"?decodeURIComponent(r[3]):decodeURIComponent(r[2]),o=(e==null?void 0:e.parseSelector)?Ug.default.parse(s):s,a=typeof r[4]!="undefined"?Ug.default.parse(r[4]):null;return{protocol:i,source:n,selector:o,params:a}}function gSe(t,{protocol:e}){let{selector:r,params:i}=Kg(t,{requireProtocol:e,requireBindings:!0});if(typeof i.locator!="string")throw new Error(`Assertion failed: Invalid bindings for ${t}`);return{parentLocator:Mc(i.locator,!0),path:r}}function g8(t){return t=t.replace(/%/g,"%25"),t=t.replace(/:/g,"%3A"),t=t.replace(/#/g,"%23"),t}function fSe(t){return t===null?!1:Object.entries(t).length>0}function fw({protocol:t,source:e,selector:r,params:i}){let n="";return t!==null&&(n+=`${t}`),e!==null&&(n+=`${g8(e)}#`),n+=g8(r),fSe(i)&&(n+=`::${Ug.default.stringify(i)}`),n}function hSe(t){let{params:e,protocol:r,source:i,selector:n}=Kg(t);for(let s in e)s.startsWith("__")&&delete e[s];return fw({protocol:r,source:i,params:e,selector:n})}function Ot(t){return t.scope?`@${t.scope}/${t.name}`:`${t.name}`}function Pn(t){return t.scope?`@${t.scope}/${t.name}@${t.range}`:`${t.name}@${t.range}`}function Ps(t){return t.scope?`@${t.scope}/${t.name}@${t.reference}`:`${t.name}@${t.reference}`}function ax(t){return t.scope!==null?`@${t.scope}-${t.name}`:t.name}function Hg(t){let{protocol:e,selector:r}=Kg(t.reference),i=e!==null?e.replace(/:$/,""):"exotic",n=a8.default.valid(r),s=n!==null?`${i}-${n}`:`${i}`,o=10,a=t.scope?`${ax(t)}-${s}-${t.locatorHash.slice(0,o)}`:`${ax(t)}-${s}-${t.locatorHash.slice(0,o)}`;return qr(a)}function gi(t,e){return e.scope?`${et(t,`@${e.scope}/`,Ge.SCOPE)}${et(t,e.name,Ge.NAME)}`:`${et(t,e.name,Ge.NAME)}`}function hw(t){if(t.startsWith(Ad)){let e=hw(t.substring(t.indexOf("#")+1)),r=t.substring(Ad.length,Ad.length+sSe);return`${e} [${r}]`}else return t.replace(/\?.*/,"?[...]")}function cw(t,e){return`${et(t,hw(e),Ge.RANGE)}`}function sr(t,e){return`${gi(t,e)}${et(t,"@",Ge.RANGE)}${cw(t,e.range)}`}function dd(t,e){return`${et(t,hw(e),Ge.REFERENCE)}`}function Bt(t,e){return`${gi(t,e)}${et(t,"@",Ge.REFERENCE)}${dd(t,e.reference)}`}function Ax(t){return`${Ot(t)}@${hw(t.reference)}`}function jg(t){return xn(t,[e=>Ot(e),e=>e.range])}function Cd(t,e){return gi(t,e.locator)}function qS(t,e,r){let i=il(e)?ud(e):e;return r===null?`${sr(t,i)} \u2192 ${VS(t).Cross}`:i.identHash===r.identHash?`${sr(t,i)} \u2192 ${dd(t,r.reference)}`:`${sr(t,i)} \u2192 ${Bt(t,r)}`}function YS(t,e,r){return r===null?`${Bt(t,e)}`:`${Bt(t,e)} (via ${cw(t,r.range)})`}function lx(t){return`node_modules/${Ot(t)}`}function pw(t,e){return t.conditions?oSe(t.conditions,r=>{let[,i,n]=r.match(l8),s=e[i];return s?s.includes(n):!0}):!0}var f8={hooks:{reduceDependency:(t,e,r,i,{resolver:n,resolveOptions:s})=>{for(let{pattern:o,reference:a}of e.topLevelWorkspace.manifest.resolutions){if(o.from&&o.from.fullName!==Ot(r)||o.from&&o.from.description&&o.from.description!==r.reference||o.descriptor.fullName!==Ot(t)||o.descriptor.description&&o.descriptor.description!==t.range)continue;return n.bindDescriptor(rr(t,a),e.topLevelWorkspace.anchoredLocator,s)}return t},validateProject:async(t,e)=>{for(let r of t.workspaces){let i=Cd(t.configuration,r);await t.configuration.triggerHook(n=>n.validateWorkspace,r,{reportWarning:(n,s)=>e.reportWarning(n,`${i}: ${s}`),reportError:(n,s)=>e.reportError(n,`${i}: ${s}`)})}},validateWorkspace:async(t,e)=>{let{manifest:r}=t;r.resolutions.length&&t.cwd!==t.project.cwd&&r.errors.push(new Error("Resolutions field will be ignored"));for(let i of r.errors)e.reportWarning($.INVALID_MANIFEST,i.message)}}};var C8=ge(ti());var md=class{supportsDescriptor(e,r){return!!(e.range.startsWith(md.protocol)||r.project.tryWorkspaceByDescriptor(e)!==null)}supportsLocator(e,r){return!!e.reference.startsWith(md.protocol)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,i){return e}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){return[i.project.getWorkspaceByDescriptor(e).anchoredLocator]}async getSatisfying(e,r,i){return null}async resolve(e,r){let i=r.project.getWorkspaceByCwd(e.reference.slice(md.protocol.length));return te(N({},e),{version:i.manifest.version||"0.0.0",languageName:"unknown",linkType:Qt.SOFT,conditions:null,dependencies:new Map([...i.manifest.dependencies,...i.manifest.devDependencies]),peerDependencies:new Map([...i.manifest.peerDependencies]),dependenciesMeta:i.manifest.dependenciesMeta,peerDependenciesMeta:i.manifest.peerDependenciesMeta,bin:i.manifest.bin})}},si=md;si.protocol="workspace:";var Wt={};ft(Wt,{SemVer:()=>h8.SemVer,clean:()=>dSe,satisfiesWithPrereleases:()=>Uc,validRange:()=>fo});var dw=ge(ti()),h8=ge(ti()),p8=new Map;function Uc(t,e,r=!1){if(!t)return!1;let i=`${e}${r}`,n=p8.get(i);if(typeof n=="undefined")try{n=new dw.default.Range(e,{includePrerelease:!0,loose:r})}catch{return!1}finally{p8.set(i,n||null)}else if(n===null)return!1;let s;try{s=new dw.default.SemVer(t,n)}catch(o){return!1}return n.test(s)?!0:(s.prerelease&&(s.prerelease=[]),n.set.some(o=>{for(let a of o)a.semver.prerelease&&(a.semver.prerelease=[]);return o.every(a=>a.test(s))}))}var d8=new Map;function fo(t){if(t.indexOf(":")!==-1)return null;let e=d8.get(t);if(typeof e!="undefined")return e;try{e=new dw.default.Range(t)}catch{e=null}return d8.set(t,e),e}var pSe=/^(?:[\sv=]*?)((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\s*)$/;function dSe(t){let e=pSe.exec(t);return e?e[1]:null}var sl=class{constructor(){this.indent="  ";this.name=null;this.version=null;this.os=null;this.cpu=null;this.libc=null;this.type=null;this.packageManager=null;this.private=!1;this.license=null;this.main=null;this.module=null;this.browser=null;this.languageName=null;this.bin=new Map;this.scripts=new Map;this.dependencies=new Map;this.devDependencies=new Map;this.peerDependencies=new Map;this.workspaceDefinitions=[];this.dependenciesMeta=new Map;this.peerDependenciesMeta=new Map;this.resolutions=[];this.files=null;this.publishConfig=null;this.installConfig=null;this.preferUnplugged=null;this.raw={};this.errors=[]}static async tryFind(e,{baseFs:r=new ar}={}){let i=k.join(e,"package.json");try{return await sl.fromFile(i,{baseFs:r})}catch(n){if(n.code==="ENOENT")return null;throw n}}static async find(e,{baseFs:r}={}){let i=await sl.tryFind(e,{baseFs:r});if(i===null)throw new Error("Manifest not found");return i}static async fromFile(e,{baseFs:r=new ar}={}){let i=new sl;return await i.loadFile(e,{baseFs:r}),i}static fromText(e){let r=new sl;return r.loadFromText(e),r}static isManifestFieldCompatible(e,r){if(e===null)return!0;let i=!0,n=!1;for(let s of e)if(s[0]==="!"){if(n=!0,r===s.slice(1))return!1}else if(i=!1,s===r)return!0;return n&&i}loadFromText(e){let r;try{r=JSON.parse(E8(e)||"{}")}catch(i){throw i.message+=` (when parsing ${e})`,i}this.load(r),this.indent=m8(e)}async loadFile(e,{baseFs:r=new ar}){let i=await r.readFilePromise(e,"utf8"),n;try{n=JSON.parse(E8(i)||"{}")}catch(s){throw s.message+=` (when parsing ${e})`,s}this.load(n),this.indent=m8(i)}load(e,{yamlCompatibilityMode:r=!1}={}){if(typeof e!="object"||e===null)throw new Error(`Utterly invalid manifest data (${e})`);this.raw=e;let i=[];if(this.name=null,typeof e.name=="string")try{this.name=An(e.name)}catch(s){i.push(new Error("Parsing failed for the 'name' field"))}if(typeof e.version=="string"?this.version=e.version:this.version=null,Array.isArray(e.os)){let s=[];this.os=s;for(let o of e.os)typeof o!="string"?i.push(new Error("Parsing failed for the 'os' field")):s.push(o)}else this.os=null;if(Array.isArray(e.cpu)){let s=[];this.cpu=s;for(let o of e.cpu)typeof o!="string"?i.push(new Error("Parsing failed for the 'cpu' field")):s.push(o)}else this.cpu=null;if(Array.isArray(e.libc)){let s=[];this.libc=s;for(let o of e.libc)typeof o!="string"?i.push(new Error("Parsing failed for the 'libc' field")):s.push(o)}else this.libc=null;if(typeof e.type=="string"?this.type=e.type:this.type=null,typeof e.packageManager=="string"?this.packageManager=e.packageManager:this.packageManager=null,typeof e.private=="boolean"?this.private=e.private:this.private=!1,typeof e.license=="string"?this.license=e.license:this.license=null,typeof e.languageName=="string"?this.languageName=e.languageName:this.languageName=null,typeof e.main=="string"?this.main=un(e.main):this.main=null,typeof e.module=="string"?this.module=un(e.module):this.module=null,e.browser!=null)if(typeof e.browser=="string")this.browser=un(e.browser);else{this.browser=new Map;for(let[s,o]of Object.entries(e.browser))this.browser.set(un(s),typeof o=="string"?un(o):o)}else this.browser=null;if(this.bin=new Map,typeof e.bin=="string")this.name!==null?this.bin.set(this.name.name,un(e.bin)):i.push(new Error("String bin field, but no attached package name"));else if(typeof e.bin=="object"&&e.bin!==null)for(let[s,o]of Object.entries(e.bin)){if(typeof o!="string"){i.push(new Error(`Invalid bin definition for '${s}'`));continue}let a=An(s);this.bin.set(a.name,un(o))}if(this.scripts=new Map,typeof e.scripts=="object"&&e.scripts!==null)for(let[s,o]of Object.entries(e.scripts)){if(typeof o!="string"){i.push(new Error(`Invalid script definition for '${s}'`));continue}this.scripts.set(s,o)}if(this.dependencies=new Map,typeof e.dependencies=="object"&&e.dependencies!==null)for(let[s,o]of Object.entries(e.dependencies)){if(typeof o!="string"){i.push(new Error(`Invalid dependency range for '${s}'`));continue}let a;try{a=An(s)}catch(c){i.push(new Error(`Parsing failed for the dependency name '${s}'`));continue}let l=rr(a,o);this.dependencies.set(l.identHash,l)}if(this.devDependencies=new Map,typeof e.devDependencies=="object"&&e.devDependencies!==null)for(let[s,o]of Object.entries(e.devDependencies)){if(typeof o!="string"){i.push(new Error(`Invalid dependency range for '${s}'`));continue}let a;try{a=An(s)}catch(c){i.push(new Error(`Parsing failed for the dependency name '${s}'`));continue}let l=rr(a,o);this.devDependencies.set(l.identHash,l)}if(this.peerDependencies=new Map,typeof e.peerDependencies=="object"&&e.peerDependencies!==null)for(let[s,o]of Object.entries(e.peerDependencies)){let a;try{a=An(s)}catch(c){i.push(new Error(`Parsing failed for the dependency name '${s}'`));continue}(typeof o!="string"||!o.startsWith(si.protocol)&&!fo(o))&&(i.push(new Error(`Invalid dependency range for '${s}'`)),o="*");let l=rr(a,o);this.peerDependencies.set(l.identHash,l)}typeof e.workspaces=="object"&&e.workspaces!==null&&e.workspaces.nohoist&&i.push(new Error("'nohoist' is deprecated, please use 'installConfig.hoistingLimits' instead"));let n=Array.isArray(e.workspaces)?e.workspaces:typeof e.workspaces=="object"&&e.workspaces!==null&&Array.isArray(e.workspaces.packages)?e.workspaces.packages:[];this.workspaceDefinitions=[];for(let s of n){if(typeof s!="string"){i.push(new Error(`Invalid workspace definition for '${s}'`));continue}this.workspaceDefinitions.push({pattern:s})}if(this.dependenciesMeta=new Map,typeof e.dependenciesMeta=="object"&&e.dependenciesMeta!==null)for(let[s,o]of Object.entries(e.dependenciesMeta)){if(typeof o!="object"||o===null){i.push(new Error(`Invalid meta field for '${s}`));continue}let a=nl(s),l=this.ensureDependencyMeta(a),c=Cw(o.built,{yamlCompatibilityMode:r});if(c===null){i.push(new Error(`Invalid built meta field for '${s}'`));continue}let u=Cw(o.optional,{yamlCompatibilityMode:r});if(u===null){i.push(new Error(`Invalid optional meta field for '${s}'`));continue}let g=Cw(o.unplugged,{yamlCompatibilityMode:r});if(g===null){i.push(new Error(`Invalid unplugged meta field for '${s}'`));continue}Object.assign(l,{built:c,optional:u,unplugged:g})}if(this.peerDependenciesMeta=new Map,typeof e.peerDependenciesMeta=="object"&&e.peerDependenciesMeta!==null)for(let[s,o]of Object.entries(e.peerDependenciesMeta)){if(typeof o!="object"||o===null){i.push(new Error(`Invalid meta field for '${s}'`));continue}let a=nl(s),l=this.ensurePeerDependencyMeta(a),c=Cw(o.optional,{yamlCompatibilityMode:r});if(c===null){i.push(new Error(`Invalid optional meta field for '${s}'`));continue}Object.assign(l,{optional:c})}if(this.resolutions=[],typeof e.resolutions=="object"&&e.resolutions!==null)for(let[s,o]of Object.entries(e.resolutions)){if(typeof o!="string"){i.push(new Error(`Invalid resolution entry for '${s}'`));continue}try{this.resolutions.push({pattern:rI(s),reference:o})}catch(a){i.push(a);continue}}if(Array.isArray(e.files)){this.files=new Set;for(let s of e.files){if(typeof s!="string"){i.push(new Error(`Invalid files entry for '${s}'`));continue}this.files.add(s)}}else this.files=null;if(typeof e.publishConfig=="object"&&e.publishConfig!==null){if(this.publishConfig={},typeof e.publishConfig.access=="string"&&(this.publishConfig.access=e.publishConfig.access),typeof e.publishConfig.main=="string"&&(this.publishConfig.main=un(e.publishConfig.main)),typeof e.publishConfig.module=="string"&&(this.publishConfig.module=un(e.publishConfig.module)),e.publishConfig.browser!=null)if(typeof e.publishConfig.browser=="string")this.publishConfig.browser=un(e.publishConfig.browser);else{this.publishConfig.browser=new Map;for(let[s,o]of Object.entries(e.publishConfig.browser))this.publishConfig.browser.set(un(s),typeof o=="string"?un(o):o)}if(typeof e.publishConfig.registry=="string"&&(this.publishConfig.registry=e.publishConfig.registry),typeof e.publishConfig.bin=="string")this.name!==null?this.publishConfig.bin=new Map([[this.name.name,un(e.publishConfig.bin)]]):i.push(new Error("String bin field, but no attached package name"));else if(typeof e.publishConfig.bin=="object"&&e.publishConfig.bin!==null){this.publishConfig.bin=new Map;for(let[s,o]of Object.entries(e.publishConfig.bin)){if(typeof o!="string"){i.push(new Error(`Invalid bin definition for '${s}'`));continue}this.publishConfig.bin.set(s,un(o))}}if(Array.isArray(e.publishConfig.executableFiles)){this.publishConfig.executableFiles=new Set;for(let s of e.publishConfig.executableFiles){if(typeof s!="string"){i.push(new Error("Invalid executable file definition"));continue}this.publishConfig.executableFiles.add(un(s))}}}else this.publishConfig=null;if(typeof e.installConfig=="object"&&e.installConfig!==null){this.installConfig={};for(let s of Object.keys(e.installConfig))s==="hoistingLimits"?typeof e.installConfig.hoistingLimits=="string"?this.installConfig.hoistingLimits=e.installConfig.hoistingLimits:i.push(new Error("Invalid hoisting limits definition")):s=="selfReferences"?typeof e.installConfig.selfReferences=="boolean"?this.installConfig.selfReferences=e.installConfig.selfReferences:i.push(new Error("Invalid selfReferences definition, must be a boolean value")):i.push(new Error(`Unrecognized installConfig key: ${s}`))}else this.installConfig=null;if(typeof e.optionalDependencies=="object"&&e.optionalDependencies!==null)for(let[s,o]of Object.entries(e.optionalDependencies)){if(typeof o!="string"){i.push(new Error(`Invalid dependency range for '${s}'`));continue}let a;try{a=An(s)}catch(g){i.push(new Error(`Parsing failed for the dependency name '${s}'`));continue}let l=rr(a,o);this.dependencies.set(l.identHash,l);let c=rr(a,"unknown"),u=this.ensureDependencyMeta(c);Object.assign(u,{optional:!0})}typeof e.preferUnplugged=="boolean"?this.preferUnplugged=e.preferUnplugged:this.preferUnplugged=null,this.errors=i}getForScope(e){switch(e){case"dependencies":return this.dependencies;case"devDependencies":return this.devDependencies;case"peerDependencies":return this.peerDependencies;default:throw new Error(`Unsupported value ("${e}")`)}}hasConsumerDependency(e){return!!(this.dependencies.has(e.identHash)||this.peerDependencies.has(e.identHash))}hasHardDependency(e){return!!(this.dependencies.has(e.identHash)||this.devDependencies.has(e.identHash))}hasSoftDependency(e){return!!this.peerDependencies.has(e.identHash)}hasDependency(e){return!!(this.hasHardDependency(e)||this.hasSoftDependency(e))}getConditions(){let e=[];return this.os&&this.os.length>0&&e.push(cx("os",this.os)),this.cpu&&this.cpu.length>0&&e.push(cx("cpu",this.cpu)),this.libc&&this.libc.length>0&&e.push(cx("libc",this.libc)),e.length>0?e.join(" & "):null}isCompatibleWithOS(e){return sl.isManifestFieldCompatible(this.os,e)}isCompatibleWithCPU(e){return sl.isManifestFieldCompatible(this.cpu,e)}ensureDependencyMeta(e){if(e.range!=="unknown"&&!C8.default.valid(e.range))throw new Error(`Invalid meta field range for '${Pn(e)}'`);let r=Ot(e),i=e.range!=="unknown"?e.range:null,n=this.dependenciesMeta.get(r);n||this.dependenciesMeta.set(r,n=new Map);let s=n.get(i);return s||n.set(i,s={}),s}ensurePeerDependencyMeta(e){if(e.range!=="unknown")throw new Error(`Invalid meta field range for '${Pn(e)}'`);let r=Ot(e),i=this.peerDependenciesMeta.get(r);return i||this.peerDependenciesMeta.set(r,i={}),i}setRawField(e,r,{after:i=[]}={}){let n=new Set(i.filter(s=>Object.prototype.hasOwnProperty.call(this.raw,s)));if(n.size===0||Object.prototype.hasOwnProperty.call(this.raw,e))this.raw[e]=r;else{let s=this.raw,o=this.raw={},a=!1;for(let l of Object.keys(s))o[l]=s[l],a||(n.delete(l),n.size===0&&(o[e]=r,a=!0))}}exportTo(e,{compatibilityMode:r=!0}={}){var s;if(Object.assign(e,this.raw),this.name!==null?e.name=Ot(this.name):delete e.name,this.version!==null?e.version=this.version:delete e.version,this.os!==null?e.os=this.os:delete e.os,this.cpu!==null?e.cpu=this.cpu:delete e.cpu,this.type!==null?e.type=this.type:delete e.type,this.packageManager!==null?e.packageManager=this.packageManager:delete e.packageManager,this.private?e.private=!0:delete e.private,this.license!==null?e.license=this.license:delete e.license,this.languageName!==null?e.languageName=this.languageName:delete e.languageName,this.main!==null?e.main=this.main:delete e.main,this.module!==null?e.module=this.module:delete e.module,this.browser!==null){let o=this.browser;typeof o=="string"?e.browser=o:o instanceof Map&&(e.browser=Object.assign({},...Array.from(o.keys()).sort().map(a=>({[a]:o.get(a)}))))}else delete e.browser;this.bin.size===1&&this.name!==null&&this.bin.has(this.name.name)?e.bin=this.bin.get(this.name.name):this.bin.size>0?e.bin=Object.assign({},...Array.from(this.bin.keys()).sort().map(o=>({[o]:this.bin.get(o)}))):delete e.bin,this.workspaceDefinitions.length>0?this.raw.workspaces&&!Array.isArray(this.raw.workspaces)?e.workspaces=te(N({},this.raw.workspaces),{packages:this.workspaceDefinitions.map(({pattern:o})=>o)}):e.workspaces=this.workspaceDefinitions.map(({pattern:o})=>o):this.raw.workspaces&&!Array.isArray(this.raw.workspaces)&&Object.keys(this.raw.workspaces).length>0?e.workspaces=this.raw.workspaces:delete e.workspaces;let i=[],n=[];for(let o of this.dependencies.values()){let a=this.dependenciesMeta.get(Ot(o)),l=!1;if(r&&a){let c=a.get(null);c&&c.optional&&(l=!0)}l?n.push(o):i.push(o)}i.length>0?e.dependencies=Object.assign({},...jg(i).map(o=>({[Ot(o)]:o.range}))):delete e.dependencies,n.length>0?e.optionalDependencies=Object.assign({},...jg(n).map(o=>({[Ot(o)]:o.range}))):delete e.optionalDependencies,this.devDependencies.size>0?e.devDependencies=Object.assign({},...jg(this.devDependencies.values()).map(o=>({[Ot(o)]:o.range}))):delete e.devDependencies,this.peerDependencies.size>0?e.peerDependencies=Object.assign({},...jg(this.peerDependencies.values()).map(o=>({[Ot(o)]:o.range}))):delete e.peerDependencies,e.dependenciesMeta={};for(let[o,a]of xn(this.dependenciesMeta.entries(),([l,c])=>l))for(let[l,c]of xn(a.entries(),([u,g])=>u!==null?`0${u}`:"1")){let u=l!==null?Pn(rr(An(o),l)):o,g=N({},c);r&&l===null&&delete g.optional,Object.keys(g).length!==0&&(e.dependenciesMeta[u]=g)}if(Object.keys(e.dependenciesMeta).length===0&&delete e.dependenciesMeta,this.peerDependenciesMeta.size>0?e.peerDependenciesMeta=Object.assign({},...xn(this.peerDependenciesMeta.entries(),([o,a])=>o).map(([o,a])=>({[o]:a}))):delete e.peerDependenciesMeta,this.resolutions.length>0?e.resolutions=Object.assign({},...this.resolutions.map(({pattern:o,reference:a})=>({[iI(o)]:a}))):delete e.resolutions,this.files!==null?e.files=Array.from(this.files):delete e.files,this.preferUnplugged!==null?e.preferUnplugged=this.preferUnplugged:delete e.preferUnplugged,this.scripts!==null&&this.scripts.size>0){(s=e.scripts)!=null||(e.scripts={});for(let o of Object.keys(e.scripts))this.scripts.has(o)||delete e.scripts[o];for(let[o,a]of this.scripts.entries())e.scripts[o]=a}else delete e.scripts;return e}},At=sl;At.fileName="package.json",At.allDependencies=["dependencies","devDependencies","peerDependencies"],At.hardDependencies=["dependencies","devDependencies"];function m8(t){let e=t.match(/^[ \t]+/m);return e?e[0]:"  "}function E8(t){return t.charCodeAt(0)===65279?t.slice(1):t}function un(t){return t.replace(/\\/g,"/")}function Cw(t,{yamlCompatibilityMode:e}){return e?GS(t):typeof t=="undefined"||typeof t=="boolean"?t:null}function I8(t,e){let r=e.search(/[^!]/);if(r===-1)return"invalid";let i=r%2==0?"":"!",n=e.slice(r);return`${i}${t}=${n}`}function cx(t,e){return e.length===1?I8(t,e[0]):`(${e.map(r=>I8(t,r)).join(" | ")})`}var X8=ge(V8()),Z8=ge(require("stream")),$8=ge(require("string_decoder"));var lke=15,ct=class extends Error{constructor(e,r,i){super(r);this.reportExtra=i;this.reportCode=e}};function cke(t){return typeof t.reportCode!="undefined"}var Ji=class{constructor(){this.reportedInfos=new Set;this.reportedWarnings=new Set;this.reportedErrors=new Set}static progressViaCounter(e){let r=0,i,n=new Promise(l=>{i=l}),s=l=>{let c=i;n=new Promise(u=>{i=u}),r=l,c()},o=(l=0)=>{s(r+1)},a=async function*(){for(;r<e;)await n,yield{progress:r/e}}();return{[Symbol.asyncIterator](){return a},hasProgress:!0,hasTitle:!1,set:s,tick:o}}static progressViaTitle(){let e,r,i=new Promise(o=>{r=o}),n=(0,X8.default)(o=>{let a=r;i=new Promise(l=>{r=l}),e=o,a()},1e3/lke),s=async function*(){for(;;)await i,yield{title:e}}();return{[Symbol.asyncIterator](){return s},hasProgress:!1,hasTitle:!0,setTitle:n}}async startProgressPromise(e,r){let i=this.reportProgress(e);try{return await r(e)}finally{i.stop()}}startProgressSync(e,r){let i=this.reportProgress(e);try{return r(e)}finally{i.stop()}}reportInfoOnce(e,r,i){var s;let n=i&&i.key?i.key:r;this.reportedInfos.has(n)||(this.reportedInfos.add(n),this.reportInfo(e,r),(s=i==null?void 0:i.reportExtra)==null||s.call(i,this))}reportWarningOnce(e,r,i){var s;let n=i&&i.key?i.key:r;this.reportedWarnings.has(n)||(this.reportedWarnings.add(n),this.reportWarning(e,r),(s=i==null?void 0:i.reportExtra)==null||s.call(i,this))}reportErrorOnce(e,r,i){var s;let n=i&&i.key?i.key:r;this.reportedErrors.has(n)||(this.reportedErrors.add(n),this.reportError(e,r),(s=i==null?void 0:i.reportExtra)==null||s.call(i,this))}reportExceptionOnce(e){cke(e)?this.reportErrorOnce(e.reportCode,e.message,{key:e,reportExtra:e.reportExtra}):this.reportErrorOnce($.EXCEPTION,e.stack||e.message,{key:e})}createStreamReporter(e=null){let r=new Z8.PassThrough,i=new $8.StringDecoder,n="";return r.on("data",s=>{let o=i.write(s),a;do if(a=o.indexOf(`
+`),a!==-1){let l=n+o.substring(0,a);o=o.substring(a+1),n="",e!==null?this.reportInfo(null,`${e} ${l}`):this.reportInfo(null,l)}while(a!==-1);n+=o}),r.on("end",()=>{let s=i.end();s!==""&&(e!==null?this.reportInfo(null,`${e} ${s}`):this.reportInfo(null,s))}),r}};var yd=class{constructor(e){this.fetchers=e}supports(e,r){return!!this.tryFetcher(e,r)}getLocalPath(e,r){return this.getFetcher(e,r).getLocalPath(e,r)}async fetch(e,r){return await this.getFetcher(e,r).fetch(e,r)}tryFetcher(e,r){let i=this.fetchers.find(n=>n.supports(e,r));return i||null}getFetcher(e,r){let i=this.fetchers.find(n=>n.supports(e,r));if(!i)throw new ct($.FETCHER_NOT_FOUND,`${Bt(r.project.configuration,e)} isn't supported by any available fetcher`);return i}};var wd=class{constructor(e){this.resolvers=e.filter(r=>r)}supportsDescriptor(e,r){return!!this.tryResolverByDescriptor(e,r)}supportsLocator(e,r){return!!this.tryResolverByLocator(e,r)}shouldPersistResolution(e,r){return this.getResolverByLocator(e,r).shouldPersistResolution(e,r)}bindDescriptor(e,r,i){return this.getResolverByDescriptor(e,i).bindDescriptor(e,r,i)}getResolutionDependencies(e,r){return this.getResolverByDescriptor(e,r).getResolutionDependencies(e,r)}async getCandidates(e,r,i){return await this.getResolverByDescriptor(e,i).getCandidates(e,r,i)}async getSatisfying(e,r,i){return this.getResolverByDescriptor(e,i).getSatisfying(e,r,i)}async resolve(e,r){return await this.getResolverByLocator(e,r).resolve(e,r)}tryResolverByDescriptor(e,r){let i=this.resolvers.find(n=>n.supportsDescriptor(e,r));return i||null}getResolverByDescriptor(e,r){let i=this.resolvers.find(n=>n.supportsDescriptor(e,r));if(!i)throw new Error(`${sr(r.project.configuration,e)} isn't supported by any available resolver`);return i}tryResolverByLocator(e,r){let i=this.resolvers.find(n=>n.supportsLocator(e,r));return i||null}getResolverByLocator(e,r){let i=this.resolvers.find(n=>n.supportsLocator(e,r));if(!i)throw new Error(`${Bt(r.project.configuration,e)} isn't supported by any available resolver`);return i}};var ez=ge(ti());var Gg=/^(?!v)[a-z0-9._-]+$/i,fx=class{supportsDescriptor(e,r){return!!(fo(e.range)||Gg.test(e.range))}supportsLocator(e,r){return!!(ez.default.valid(e.reference)||Gg.test(e.reference))}shouldPersistResolution(e,r){return r.resolver.shouldPersistResolution(this.forwardLocator(e,r),r)}bindDescriptor(e,r,i){return i.resolver.bindDescriptor(this.forwardDescriptor(e,i),r,i)}getResolutionDependencies(e,r){return r.resolver.getResolutionDependencies(this.forwardDescriptor(e,r),r)}async getCandidates(e,r,i){return await i.resolver.getCandidates(this.forwardDescriptor(e,i),r,i)}async getSatisfying(e,r,i){return await i.resolver.getSatisfying(this.forwardDescriptor(e,i),r,i)}async resolve(e,r){let i=await r.resolver.resolve(this.forwardLocator(e,r),r);return ld(i,e)}forwardDescriptor(e,r){return rr(e,`${r.project.configuration.get("defaultProtocol")}${e.range}`)}forwardLocator(e,r){return cn(e,`${r.project.configuration.get("defaultProtocol")}${e.reference}`)}};var Bd=class{supports(e){return!!e.reference.startsWith("virtual:")}getLocalPath(e,r){let i=e.reference.indexOf("#");if(i===-1)throw new Error("Invalid virtual package reference");let n=e.reference.slice(i+1),s=cn(e,n);return r.fetcher.getLocalPath(s,r)}async fetch(e,r){let i=e.reference.indexOf("#");if(i===-1)throw new Error("Invalid virtual package reference");let n=e.reference.slice(i+1),s=cn(e,n),o=await r.fetcher.fetch(s,r);return await this.ensureVirtualLink(e,o,r)}getLocatorFilename(e){return Hg(e)}async ensureVirtualLink(e,r,i){let n=r.packageFs.getRealPath(),s=i.project.configuration.get("virtualFolder"),o=this.getLocatorFilename(e),a=Jr.makeVirtualPath(s,o,n),l=new Pa(a,{baseFs:r.packageFs,pathUtils:k});return te(N({},r),{packageFs:l})}};var Yg=class{static isVirtualDescriptor(e){return!!e.range.startsWith(Yg.protocol)}static isVirtualLocator(e){return!!e.reference.startsWith(Yg.protocol)}supportsDescriptor(e,r){return Yg.isVirtualDescriptor(e)}supportsLocator(e,r){return Yg.isVirtualLocator(e)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,i){throw new Error('Assertion failed: calling "bindDescriptor" on a virtual descriptor is unsupported')}getResolutionDependencies(e,r){throw new Error('Assertion failed: calling "getResolutionDependencies" on a virtual descriptor is unsupported')}async getCandidates(e,r,i){throw new Error('Assertion failed: calling "getCandidates" on a virtual descriptor is unsupported')}async getSatisfying(e,r,i){throw new Error('Assertion failed: calling "getSatisfying" on a virtual descriptor is unsupported')}async resolve(e,r){throw new Error('Assertion failed: calling "resolve" on a virtual locator is unsupported')}},mw=Yg;mw.protocol="virtual:";var bd=class{supports(e){return!!e.reference.startsWith(si.protocol)}getLocalPath(e,r){return this.getWorkspace(e,r).cwd}async fetch(e,r){let i=this.getWorkspace(e,r).cwd;return{packageFs:new _t(i),prefixPath:Me.dot,localPath:i}}getWorkspace(e,r){return r.project.getWorkspaceByCwd(e.reference.slice(si.protocol.length))}};var hx={};ft(hx,{getDefaultGlobalFolder:()=>dx,getHomeFolder:()=>Qd,isFolderInside:()=>Cx});var px=ge(require("os"));function dx(){if(process.platform==="win32"){let t=j.toPortablePath(process.env.LOCALAPPDATA||j.join((0,px.homedir)(),"AppData","Local"));return k.resolve(t,"Yarn/Berry")}if(process.env.XDG_DATA_HOME){let t=j.toPortablePath(process.env.XDG_DATA_HOME);return k.resolve(t,"yarn/berry")}return k.resolve(Qd(),".yarn/berry")}function Qd(){return j.toPortablePath((0,px.homedir)()||"/usr/local/share")}function Cx(t,e){let r=k.relative(e,t);return r&&!r.startsWith("..")&&!k.isAbsolute(r)}var qg={};ft(qg,{builtinModules:()=>mx,getArchitecture:()=>vd,getArchitectureName:()=>gke,getArchitectureSet:()=>Ex});var tz=ge(require("module"));function mx(){return new Set(tz.default.builtinModules||Object.keys(process.binding("natives")))}function uke(){var i,n,s,o;if(process.platform==="win32")return null;let e=(s=((n=(i=process.report)==null?void 0:i.getReport())!=null?n:{}).sharedObjects)!=null?s:[],r=/\/(?:(ld-linux-|[^/]+-linux-gnu\/)|(libc.musl-|ld-musl-))/;return(o=$p(e,a=>{let l=a.match(r);if(!l)return $p.skip;if(l[1])return"glibc";if(l[2])return"musl";throw new Error("Assertion failed: Expected the libc variant to have been detected")}))!=null?o:null}var Ew,Iw;function vd(){return Ew=Ew!=null?Ew:{os:process.platform,cpu:process.arch,libc:uke()}}function gke(t=vd()){return t.libc?`${t.os}-${t.cpu}-${t.libc}`:`${t.os}-${t.cpu}`}function Ex(){let t=vd();return Iw=Iw!=null?Iw:{os:[t.os],cpu:[t.cpu],libc:t.libc?[t.libc]:[]}}var fke=new Set(["binFolder","version","flags","profile","gpg","ignoreNode","wrapOutput","home","confDir"]),ww="yarn_",yx=".yarnrc.yml",wx="yarn.lock",hke="********",Ie;(function(u){u.ANY="ANY",u.BOOLEAN="BOOLEAN",u.ABSOLUTE_PATH="ABSOLUTE_PATH",u.LOCATOR="LOCATOR",u.LOCATOR_LOOSE="LOCATOR_LOOSE",u.NUMBER="NUMBER",u.STRING="STRING",u.SECRET="SECRET",u.SHAPE="SHAPE",u.MAP="MAP"})(Ie||(Ie={}));var Di=Ge,Bx={lastUpdateCheck:{description:"Last timestamp we checked whether new Yarn versions were available",type:Ie.STRING,default:null},yarnPath:{description:"Path to the local executable that must be used over the global one",type:Ie.ABSOLUTE_PATH,default:null},ignorePath:{description:"If true, the local executable will be ignored when using the global one",type:Ie.BOOLEAN,default:!1},ignoreCwd:{description:"If true, the `--cwd` flag will be ignored",type:Ie.BOOLEAN,default:!1},cacheKeyOverride:{description:"A global cache key override; used only for test purposes",type:Ie.STRING,default:null},globalFolder:{description:"Folder where all system-global files are stored",type:Ie.ABSOLUTE_PATH,default:dx()},cacheFolder:{description:"Folder where the cache files must be written",type:Ie.ABSOLUTE_PATH,default:"./.yarn/cache"},compressionLevel:{description:"Zip files compression level, from 0 to 9 or mixed (a variant of 9, which stores some files uncompressed, when compression doesn't yield good results)",type:Ie.NUMBER,values:["mixed",0,1,2,3,4,5,6,7,8,9],default:ic},virtualFolder:{description:"Folder where the virtual packages (cf doc) will be mapped on the disk (must be named __virtual__)",type:Ie.ABSOLUTE_PATH,default:"./.yarn/__virtual__"},lockfileFilename:{description:"Name of the files where the Yarn dependency tree entries must be stored",type:Ie.STRING,default:wx},installStatePath:{description:"Path of the file where the install state will be persisted",type:Ie.ABSOLUTE_PATH,default:"./.yarn/install-state.gz"},immutablePatterns:{description:"Array of glob patterns; files matching them won't be allowed to change during immutable installs",type:Ie.STRING,default:[],isArray:!0},rcFilename:{description:"Name of the files where the configuration can be found",type:Ie.STRING,default:Bw()},enableGlobalCache:{description:"If true, the system-wide cache folder will be used regardless of `cache-folder`",type:Ie.BOOLEAN,default:!1},enableColors:{description:"If true, the CLI is allowed to use colors in its output",type:Ie.BOOLEAN,default:Fy,defaultText:"<dynamic>"},enableHyperlinks:{description:"If true, the CLI is allowed to use hyperlinks in its output",type:Ie.BOOLEAN,default:WS,defaultText:"<dynamic>"},enableInlineBuilds:{description:"If true, the CLI will print the build output on the command line",type:Ie.BOOLEAN,default:yw.isCI,defaultText:"<dynamic>"},enableMessageNames:{description:"If true, the CLI will prefix most messages with codes suitable for search engines",type:Ie.BOOLEAN,default:!0},enableProgressBars:{description:"If true, the CLI is allowed to show a progress bar for long-running events",type:Ie.BOOLEAN,default:!yw.isCI,defaultText:"<dynamic>"},enableTimers:{description:"If true, the CLI is allowed to print the time spent executing commands",type:Ie.BOOLEAN,default:!0},preferAggregateCacheInfo:{description:"If true, the CLI will only print a one-line report of any cache changes",type:Ie.BOOLEAN,default:yw.isCI},preferInteractive:{description:"If true, the CLI will automatically use the interactive mode when called from a TTY",type:Ie.BOOLEAN,default:!1},preferTruncatedLines:{description:"If true, the CLI will truncate lines that would go beyond the size of the terminal",type:Ie.BOOLEAN,default:!1},progressBarStyle:{description:"Which style of progress bar should be used (only when progress bars are enabled)",type:Ie.STRING,default:void 0,defaultText:"<dynamic>"},defaultLanguageName:{description:"Default language mode that should be used when a package doesn't offer any insight",type:Ie.STRING,default:"node"},defaultProtocol:{description:"Default resolution protocol used when resolving pure semver and tag ranges",type:Ie.STRING,default:"npm:"},enableTransparentWorkspaces:{description:"If false, Yarn won't automatically resolve workspace dependencies unless they use the `workspace:` protocol",type:Ie.BOOLEAN,default:!0},supportedArchitectures:{description:"Architectures that Yarn will fetch and inject into the resolver",type:Ie.SHAPE,properties:{os:{description:"Array of supported process.platform strings, or null to target them all",type:Ie.STRING,isArray:!0,isNullable:!0,default:["current"]},cpu:{description:"Array of supported process.arch strings, or null to target them all",type:Ie.STRING,isArray:!0,isNullable:!0,default:["current"]},libc:{description:"Array of supported libc libraries, or null to target them all",type:Ie.STRING,isArray:!0,isNullable:!0,default:["current"]}}},enableMirror:{description:"If true, the downloaded packages will be retrieved and stored in both the local and global folders",type:Ie.BOOLEAN,default:!0},enableNetwork:{description:"If false, the package manager will refuse to use the network if required to",type:Ie.BOOLEAN,default:!0},httpProxy:{description:"URL of the http proxy that must be used for outgoing http requests",type:Ie.STRING,default:null},httpsProxy:{description:"URL of the http proxy that must be used for outgoing https requests",type:Ie.STRING,default:null},unsafeHttpWhitelist:{description:"List of the hostnames for which http queries are allowed (glob patterns are supported)",type:Ie.STRING,default:[],isArray:!0},httpTimeout:{description:"Timeout of each http request in milliseconds",type:Ie.NUMBER,default:6e4},httpRetry:{description:"Retry times on http failure",type:Ie.NUMBER,default:3},networkConcurrency:{description:"Maximal number of concurrent requests",type:Ie.NUMBER,default:50},networkSettings:{description:"Network settings per hostname (glob patterns are supported)",type:Ie.MAP,valueDefinition:{description:"",type:Ie.SHAPE,properties:{caFilePath:{description:"Path to file containing one or multiple Certificate Authority signing certificates",type:Ie.ABSOLUTE_PATH,default:null},enableNetwork:{description:"If false, the package manager will refuse to use the network if required to",type:Ie.BOOLEAN,default:null},httpProxy:{description:"URL of the http proxy that must be used for outgoing http requests",type:Ie.STRING,default:null},httpsProxy:{description:"URL of the http proxy that must be used for outgoing https requests",type:Ie.STRING,default:null},httpsKeyFilePath:{description:"Path to file containing private key in PEM format",type:Ie.ABSOLUTE_PATH,default:null},httpsCertFilePath:{description:"Path to file containing certificate chain in PEM format",type:Ie.ABSOLUTE_PATH,default:null}}}},caFilePath:{description:"A path to a file containing one or multiple Certificate Authority signing certificates",type:Ie.ABSOLUTE_PATH,default:null},httpsKeyFilePath:{description:"Path to file containing private key in PEM format",type:Ie.ABSOLUTE_PATH,default:null},httpsCertFilePath:{description:"Path to file containing certificate chain in PEM format",type:Ie.ABSOLUTE_PATH,default:null},enableStrictSsl:{description:"If false, SSL certificate errors will be ignored",type:Ie.BOOLEAN,default:!0},logFilters:{description:"Overrides for log levels",type:Ie.SHAPE,isArray:!0,concatenateValues:!0,properties:{code:{description:"Code of the messages covered by this override",type:Ie.STRING,default:void 0},text:{description:"Code of the texts covered by this override",type:Ie.STRING,default:void 0},pattern:{description:"Code of the patterns covered by this override",type:Ie.STRING,default:void 0},level:{description:"Log level override, set to null to remove override",type:Ie.STRING,values:Object.values(go),isNullable:!0,default:void 0}}},enableTelemetry:{description:"If true, telemetry will be periodically sent, following the rules in https://yarnpkg.com/advanced/telemetry",type:Ie.BOOLEAN,default:!0},telemetryInterval:{description:"Minimal amount of time between two telemetry uploads, in days",type:Ie.NUMBER,default:7},telemetryUserId:{description:"If you desire to tell us which project you are, you can set this field. Completely optional and opt-in.",type:Ie.STRING,default:null},enableScripts:{description:"If true, packages are allowed to have install scripts by default",type:Ie.BOOLEAN,default:!0},enableStrictSettings:{description:"If true, unknown settings will cause Yarn to abort",type:Ie.BOOLEAN,default:!0},enableImmutableCache:{description:"If true, the cache is reputed immutable and actions that would modify it will throw",type:Ie.BOOLEAN,default:!1},checksumBehavior:{description:"Enumeration defining what to do when a checksum doesn't match expectations",type:Ie.STRING,default:"throw"},packageExtensions:{description:"Map of package corrections to apply on the dependency tree",type:Ie.MAP,valueDefinition:{description:"The extension that will be applied to any package whose version matches the specified range",type:Ie.SHAPE,properties:{dependencies:{description:"The set of dependencies that must be made available to the current package in order for it to work properly",type:Ie.MAP,valueDefinition:{description:"A range",type:Ie.STRING}},peerDependencies:{description:"Inherited dependencies - the consumer of the package will be tasked to provide them",type:Ie.MAP,valueDefinition:{description:"A semver range",type:Ie.STRING}},peerDependenciesMeta:{description:"Extra information related to the dependencies listed in the peerDependencies field",type:Ie.MAP,valueDefinition:{description:"The peerDependency meta",type:Ie.SHAPE,properties:{optional:{description:"If true, the selected peer dependency will be marked as optional by the package manager and the consumer omitting it won't be reported as an error",type:Ie.BOOLEAN,default:!1}}}}}}}};function Qx(t,e,r,i,n){if(i.isArray||i.type===Ie.ANY&&Array.isArray(r))return Array.isArray(r)?r.map((s,o)=>bx(t,`${e}[${o}]`,s,i,n)):String(r).split(/,/).map(s=>bx(t,e,s,i,n));if(Array.isArray(r))throw new Error(`Non-array configuration settings "${e}" cannot be an array`);return bx(t,e,r,i,n)}function bx(t,e,r,i,n){var a;switch(i.type){case Ie.ANY:return r;case Ie.SHAPE:return pke(t,e,r,i,n);case Ie.MAP:return dke(t,e,r,i,n)}if(r===null&&!i.isNullable&&i.default!==null)throw new Error(`Non-nullable configuration settings "${e}" cannot be set to null`);if((a=i.values)==null?void 0:a.includes(r))return r;let o=(()=>{if(i.type===Ie.BOOLEAN&&typeof r!="string")return td(r);if(typeof r!="string")throw new Error(`Expected value (${r}) to be a string`);let l=jS(r,{env:process.env});switch(i.type){case Ie.ABSOLUTE_PATH:return k.resolve(n,j.toPortablePath(l));case Ie.LOCATOR_LOOSE:return Mc(l,!1);case Ie.NUMBER:return parseInt(l);case Ie.LOCATOR:return Mc(l);case Ie.BOOLEAN:return td(l);default:return l}})();if(i.values&&!i.values.includes(o))throw new Error(`Invalid value, expected one of ${i.values.join(", ")}`);return o}function pke(t,e,r,i,n){if(typeof r!="object"||Array.isArray(r))throw new Pe(`Object configuration settings "${e}" must be an object`);let s=vx(t,i,{ignoreArrays:!0});if(r===null)return s;for(let[o,a]of Object.entries(r)){let l=`${e}.${o}`;if(!i.properties[o])throw new Pe(`Unrecognized configuration settings found: ${e}.${o} - run "yarn config -v" to see the list of settings supported in Yarn`);s.set(o,Qx(t,l,a,i.properties[o],n))}return s}function dke(t,e,r,i,n){let s=new Map;if(typeof r!="object"||Array.isArray(r))throw new Pe(`Map configuration settings "${e}" must be an object`);if(r===null)return s;for(let[o,a]of Object.entries(r)){let l=i.normalizeKeys?i.normalizeKeys(o):o,c=`${e}['${l}']`,u=i.valueDefinition;s.set(l,Qx(t,c,a,u,n))}return s}function vx(t,e,{ignoreArrays:r=!1}={}){switch(e.type){case Ie.SHAPE:{if(e.isArray&&!r)return[];let i=new Map;for(let[n,s]of Object.entries(e.properties))i.set(n,vx(t,s));return i}break;case Ie.MAP:return e.isArray&&!r?[]:new Map;case Ie.ABSOLUTE_PATH:return e.default===null?null:t.projectCwd===null?k.isAbsolute(e.default)?k.normalize(e.default):e.isNullable?null:void 0:Array.isArray(e.default)?e.default.map(i=>k.resolve(t.projectCwd,i)):k.resolve(t.projectCwd,e.default);default:return e.default}}function bw(t,e,r){if(e.type===Ie.SECRET&&typeof t=="string"&&r.hideSecrets)return hke;if(e.type===Ie.ABSOLUTE_PATH&&typeof t=="string"&&r.getNativePaths)return j.fromPortablePath(t);if(e.isArray&&Array.isArray(t)){let i=[];for(let n of t)i.push(bw(n,e,r));return i}if(e.type===Ie.MAP&&t instanceof Map){let i=new Map;for(let[n,s]of t.entries())i.set(n,bw(s,e.valueDefinition,r));return i}if(e.type===Ie.SHAPE&&t instanceof Map){let i=new Map;for(let[n,s]of t.entries()){let o=e.properties[n];i.set(n,bw(s,o,r))}return i}return t}function Cke(){let t={};for(let[e,r]of Object.entries(process.env))e=e.toLowerCase(),!!e.startsWith(ww)&&(e=(0,rz.default)(e.slice(ww.length)),t[e]=r);return t}function Bw(){let t=`${ww}rc_filename`;for(let[e,r]of Object.entries(process.env))if(e.toLowerCase()===t&&typeof r=="string")return r;return yx}var ol;(function(i){i[i.LOCKFILE=0]="LOCKFILE",i[i.MANIFEST=1]="MANIFEST",i[i.NONE=2]="NONE"})(ol||(ol={}));var Xa=class{constructor(e){this.projectCwd=null;this.plugins=new Map;this.settings=new Map;this.values=new Map;this.sources=new Map;this.invalid=new Map;this.packageExtensions=new Map;this.limits=new Map;this.startingCwd=e}static create(e,r,i){let n=new Xa(e);typeof r!="undefined"&&!(r instanceof Map)&&(n.projectCwd=r),n.importSettings(Bx);let s=typeof i!="undefined"?i:r instanceof Map?r:new Map;for(let[o,a]of s)n.activatePlugin(o,a);return n}static async find(e,r,{lookup:i=0,strict:n=!0,usePath:s=!1,useRc:o=!0}={}){let a=Cke();delete a.rcFilename;let l=await Xa.findRcFiles(e),c=await Xa.findHomeRcFile();if(c){let Q=l.find(S=>S.path===c.path);Q?Q.strict=!1:l.push(te(N({},c),{strict:!1}))}let u=({ignoreCwd:Q,yarnPath:S,ignorePath:x,lockfileFilename:M})=>({ignoreCwd:Q,yarnPath:S,ignorePath:x,lockfileFilename:M}),g=U=>{var J=U,{ignoreCwd:Q,yarnPath:S,ignorePath:x,lockfileFilename:M}=J,Y=Tr(J,["ignoreCwd","yarnPath","ignorePath","lockfileFilename"]);return Y},f=new Xa(e);f.importSettings(u(Bx)),f.useWithSource("<environment>",u(a),e,{strict:!1});for(let{path:Q,cwd:S,data:x}of l)f.useWithSource(Q,u(x),S,{strict:!1});if(s){let Q=f.get("yarnPath"),S=f.get("ignorePath");if(Q!==null&&!S)return f}let h=f.get("lockfileFilename"),p;switch(i){case 0:p=await Xa.findProjectCwd(e,h);break;case 1:p=await Xa.findProjectCwd(e,null);break;case 2:K.existsSync(k.join(e,"package.json"))?p=k.resolve(e):p=null;break}f.startingCwd=e,f.projectCwd=p,f.importSettings(g(Bx));let m=new Map([["@@core",f8]]),y=Q=>"default"in Q?Q.default:Q;if(r!==null){for(let M of r.plugins.keys())m.set(M,y(r.modules.get(M)));let Q=new Map;for(let M of mx())Q.set(M,()=>Rg(M));for(let[M,Y]of r.modules)Q.set(M,()=>Y);let S=new Set,x=async(M,Y)=>{let{factory:U,name:J}=Rg(M);if(S.has(J))return;let W=new Map(Q),ee=A=>{if(W.has(A))return W.get(A)();throw new Pe(`This plugin cannot access the package referenced via ${A} which is neither a builtin, nor an exposed entry`)},Z=await Pg(async()=>y(await U(ee)),A=>`${A} (when initializing ${J}, defined in ${Y})`);Q.set(J,()=>Z),S.add(J),m.set(J,Z)};if(a.plugins)for(let M of a.plugins.split(";")){let Y=k.resolve(e,j.toPortablePath(M));await x(Y,"<environment>")}for(let{path:M,cwd:Y,data:U}of l)if(!!o&&!!Array.isArray(U.plugins))for(let J of U.plugins){let W=typeof J!="string"?J.path:J,ee=k.resolve(Y,j.toPortablePath(W));await x(ee,M)}}for(let[Q,S]of m)f.activatePlugin(Q,S);f.useWithSource("<environment>",g(a),e,{strict:n});for(let{path:Q,cwd:S,data:x,strict:M}of l)f.useWithSource(Q,g(x),S,{strict:M!=null?M:n});return f.get("enableGlobalCache")&&(f.values.set("cacheFolder",`${f.get("globalFolder")}/cache`),f.sources.set("cacheFolder","<internal>")),await f.refreshPackageExtensions(),f}static async findRcFiles(e){let r=Bw(),i=[],n=e,s=null;for(;n!==s;){s=n;let o=k.join(s,r);if(K.existsSync(o)){let a=await K.readFilePromise(o,"utf8"),l;try{l=Qi(a)}catch(c){let u="";throw a.match(/^\s+(?!-)[^:]+\s+\S+/m)&&(u=" (in particular, make sure you list the colons after each key name)"),new Pe(`Parse error when loading ${o}; please check it's proper Yaml${u}`)}i.push({path:o,cwd:s,data:l})}n=k.dirname(s)}return i}static async findHomeRcFile(){let e=Bw(),r=Qd(),i=k.join(r,e);if(K.existsSync(i)){let n=await K.readFilePromise(i,"utf8"),s=Qi(n);return{path:i,cwd:r,data:s}}return null}static async findProjectCwd(e,r){let i=null,n=e,s=null;for(;n!==s;){if(s=n,K.existsSync(k.join(s,"package.json"))&&(i=s),r!==null){if(K.existsSync(k.join(s,r))){i=s;break}}else if(i!==null)break;n=k.dirname(s)}return i}static async updateConfiguration(e,r){let i=Bw(),n=k.join(e,i),s=K.existsSync(n)?Qi(await K.readFilePromise(n,"utf8")):{},o=!1,a;if(typeof r=="function"){try{a=r(s)}catch{a=r({})}if(a===s)return}else{a=s;for(let l of Object.keys(r)){let c=s[l],u=r[l],g;if(typeof u=="function")try{g=u(c)}catch{g=u(void 0)}else g=u;c!==g&&(a[l]=g,o=!0)}if(!o)return}await K.changeFilePromise(n,Na(a),{automaticNewlines:!0})}static async updateHomeConfiguration(e){let r=Qd();return await Xa.updateConfiguration(r,e)}activatePlugin(e,r){this.plugins.set(e,r),typeof r.configuration!="undefined"&&this.importSettings(r.configuration)}importSettings(e){for(let[r,i]of Object.entries(e))if(i!=null){if(this.settings.has(r))throw new Error(`Cannot redefine settings "${r}"`);this.settings.set(r,i),this.values.set(r,vx(this,i))}}useWithSource(e,r,i,n){try{this.use(e,r,i,n)}catch(s){throw s.message+=` (in ${et(this,e,Ge.PATH)})`,s}}use(e,r,i,{strict:n=!0,overwrite:s=!1}={}){n=n&&this.get("enableStrictSettings");for(let o of["enableStrictSettings",...Object.keys(r)]){if(typeof r[o]=="undefined"||o==="plugins"||e==="<environment>"&&fke.has(o))continue;if(o==="rcFilename")throw new Pe(`The rcFilename settings can only be set via ${`${ww}RC_FILENAME`.toUpperCase()}, not via a rc file`);let l=this.settings.get(o);if(!l){if(n)throw new Pe(`Unrecognized or legacy configuration settings found: ${o} - run "yarn config -v" to see the list of settings supported in Yarn`);this.invalid.set(o,e);continue}if(this.sources.has(o)&&!(s||l.type===Ie.MAP||l.isArray&&l.concatenateValues))continue;let c;try{c=Qx(this,o,r[o],l,i)}catch(u){throw u.message+=` in ${et(this,e,Ge.PATH)}`,u}if(o==="enableStrictSettings"&&e!=="<environment>"){n=c;continue}if(l.type===Ie.MAP){let u=this.values.get(o);this.values.set(o,new Map(s?[...u,...c]:[...c,...u])),this.sources.set(o,`${this.sources.get(o)}, ${e}`)}else if(l.isArray&&l.concatenateValues){let u=this.values.get(o);this.values.set(o,s?[...u,...c]:[...c,...u]),this.sources.set(o,`${this.sources.get(o)}, ${e}`)}else this.values.set(o,c),this.sources.set(o,e)}}get(e){if(!this.values.has(e))throw new Error(`Invalid configuration key "${e}"`);return this.values.get(e)}getSpecial(e,{hideSecrets:r=!1,getNativePaths:i=!1}){let n=this.get(e),s=this.settings.get(e);if(typeof s=="undefined")throw new Pe(`Couldn't find a configuration settings named "${e}"`);return bw(n,s,{hideSecrets:r,getNativePaths:i})}getSubprocessStreams(e,{header:r,prefix:i,report:n}){let s,o,a=K.createWriteStream(e);if(this.get("enableInlineBuilds")){let l=n.createStreamReporter(`${i} ${et(this,"STDOUT","green")}`),c=n.createStreamReporter(`${i} ${et(this,"STDERR","red")}`);s=new Ix.PassThrough,s.pipe(l),s.pipe(a),o=new Ix.PassThrough,o.pipe(c),o.pipe(a)}else s=a,o=a,typeof r!="undefined"&&s.write(`${r}
+`);return{stdout:s,stderr:o}}makeResolver(){let e=[];for(let r of this.plugins.values())for(let i of r.resolvers||[])e.push(new i);return new wd([new mw,new si,new fx,...e])}makeFetcher(){let e=[];for(let r of this.plugins.values())for(let i of r.fetchers||[])e.push(new i);return new yd([new Bd,new bd,...e])}getLinkers(){let e=[];for(let r of this.plugins.values())for(let i of r.linkers||[])e.push(new i);return e}getSupportedArchitectures(){let e=vd(),r=this.get("supportedArchitectures"),i=r.get("os");i!==null&&(i=i.map(o=>o==="current"?e.os:o));let n=r.get("cpu");n!==null&&(n=n.map(o=>o==="current"?e.cpu:o));let s=r.get("libc");return s!==null&&(s=qo(s,o=>{var a;return o==="current"?(a=e.libc)!=null?a:qo.skip:o})),{os:i,cpu:n,libc:s}}async refreshPackageExtensions(){this.packageExtensions=new Map;let e=this.packageExtensions,r=(i,n,{userProvided:s=!1}={})=>{if(!fo(i.range))throw new Error("Only semver ranges are allowed as keys for the packageExtensions setting");let o=new At;o.load(n,{yamlCompatibilityMode:!0});let a=kg(e,i.identHash),l=[];a.push([i.range,l]);let c={status:qi.Inactive,userProvided:s,parentDescriptor:i};for(let u of o.dependencies.values())l.push(te(N({},c),{type:yi.Dependency,descriptor:u}));for(let u of o.peerDependencies.values())l.push(te(N({},c),{type:yi.PeerDependency,descriptor:u}));for(let[u,g]of o.peerDependenciesMeta)for(let[f,h]of Object.entries(g))l.push(te(N({},c),{type:yi.PeerDependencyMeta,selector:u,key:f,value:h}))};await this.triggerHook(i=>i.registerPackageExtensions,this,r);for(let[i,n]of this.get("packageExtensions"))r(nl(i,!0),Ry(n),{userProvided:!0})}normalizePackage(e){let r=cd(e);if(this.packageExtensions==null)throw new Error("refreshPackageExtensions has to be called before normalizing packages");let i=this.packageExtensions.get(e.identHash);if(typeof i!="undefined"){let s=e.version;if(s!==null){for(let[o,a]of i)if(!!Uc(s,o))for(let l of a)switch(l.status===qi.Inactive&&(l.status=qi.Redundant),l.type){case yi.Dependency:typeof r.dependencies.get(l.descriptor.identHash)=="undefined"&&(l.status=qi.Active,r.dependencies.set(l.descriptor.identHash,l.descriptor));break;case yi.PeerDependency:typeof r.peerDependencies.get(l.descriptor.identHash)=="undefined"&&(l.status=qi.Active,r.peerDependencies.set(l.descriptor.identHash,l.descriptor));break;case yi.PeerDependencyMeta:{let c=r.peerDependenciesMeta.get(l.selector);(typeof c=="undefined"||!Object.prototype.hasOwnProperty.call(c,l.key)||c[l.key]!==l.value)&&(l.status=qi.Active,qa(r.peerDependenciesMeta,l.selector,()=>({}))[l.key]=l.value)}break;default:US(l);break}}}let n=s=>s.scope?`${s.scope}__${s.name}`:`${s.name}`;for(let s of r.peerDependenciesMeta.keys()){let o=An(s);r.peerDependencies.has(o.identHash)||r.peerDependencies.set(o.identHash,rr(o,"*"))}for(let s of r.peerDependencies.values()){if(s.scope==="types")continue;let o=n(s),a=Vo("types",o),l=Ot(a);r.peerDependencies.has(a.identHash)||r.peerDependenciesMeta.has(l)||(r.peerDependencies.set(a.identHash,rr(a,"*")),r.peerDependenciesMeta.set(l,{optional:!0}))}return r.dependencies=new Map(xn(r.dependencies,([,s])=>Pn(s))),r.peerDependencies=new Map(xn(r.peerDependencies,([,s])=>Pn(s))),r}getLimit(e){return qa(this.limits,e,()=>(0,iz.default)(this.get(e)))}async triggerHook(e,...r){for(let i of this.plugins.values()){let n=i.hooks;if(!n)continue;let s=e(n);!s||await s(...r)}}async triggerMultipleHooks(e,r){for(let i of r)await this.triggerHook(e,...i)}async reduceHook(e,r,...i){let n=r;for(let s of this.plugins.values()){let o=s.hooks;if(!o)continue;let a=e(o);!a||(n=await a(n,...i))}return n}async firstHook(e,...r){for(let i of this.plugins.values()){let n=i.hooks;if(!n)continue;let s=e(n);if(!s)continue;let o=await s(...r);if(typeof o!="undefined")return o}return null}},ye=Xa;ye.telemetry=null;var is;(function(i){i[i.Never=0]="Never",i[i.ErrorCode=1]="ErrorCode",i[i.Always=2]="Always"})(is||(is={}));var Qw=class extends ct{constructor({fileName:e,code:r,signal:i}){let n=ye.create(k.cwd()),s=et(n,e,Ge.PATH);super($.EXCEPTION,`Child ${s} reported an error`,o=>{mke(r,i,{configuration:n,report:o})});this.code=kx(r,i)}},xx=class extends Qw{constructor({fileName:e,code:r,signal:i,stdout:n,stderr:s}){super({fileName:e,code:r,signal:i});this.stdout=n,this.stderr=s}};function jc(t){return t!==null&&typeof t.fd=="number"}var Gc=new Set;function Px(){}function Dx(){for(let t of Gc)t.kill()}async function $o(t,e,{cwd:r,env:i=process.env,strict:n=!1,stdin:s=null,stdout:o,stderr:a,end:l=2}){let c=["pipe","pipe","pipe"];s===null?c[0]="ignore":jc(s)&&(c[0]=s),jc(o)&&(c[1]=o),jc(a)&&(c[2]=a);let u=(0,Sx.default)(t,e,{cwd:j.fromPortablePath(r),env:te(N({},i),{PWD:j.fromPortablePath(r)}),stdio:c});Gc.add(u),Gc.size===1&&(process.on("SIGINT",Px),process.on("SIGTERM",Dx)),!jc(s)&&s!==null&&s.pipe(u.stdin),jc(o)||u.stdout.pipe(o,{end:!1}),jc(a)||u.stderr.pipe(a,{end:!1});let g=()=>{for(let f of new Set([o,a]))jc(f)||f.end()};return new Promise((f,h)=>{u.on("error",p=>{Gc.delete(u),Gc.size===0&&(process.off("SIGINT",Px),process.off("SIGTERM",Dx)),(l===2||l===1)&&g(),h(p)}),u.on("close",(p,m)=>{Gc.delete(u),Gc.size===0&&(process.off("SIGINT",Px),process.off("SIGTERM",Dx)),(l===2||l===1&&p>0)&&g(),p===0||!n?f({code:kx(p,m)}):h(new Qw({fileName:t,code:p,signal:m}))})})}async function Eke(t,e,{cwd:r,env:i=process.env,encoding:n="utf8",strict:s=!1}){let o=["ignore","pipe","pipe"],a=[],l=[],c=j.fromPortablePath(r);typeof i.PWD!="undefined"&&(i=te(N({},i),{PWD:c}));let u=(0,Sx.default)(t,e,{cwd:c,env:i,stdio:o});return u.stdout.on("data",g=>{a.push(g)}),u.stderr.on("data",g=>{l.push(g)}),await new Promise((g,f)=>{u.on("error",h=>{let p=ye.create(r),m=et(p,t,Ge.PATH);f(new ct($.EXCEPTION,`Process ${m} failed to spawn`,y=>{y.reportError($.EXCEPTION,`  ${Jo(p,{label:"Thrown Error",value:uo(Ge.NO_HINT,h.message)})}`)}))}),u.on("close",(h,p)=>{let m=n==="buffer"?Buffer.concat(a):Buffer.concat(a).toString(n),y=n==="buffer"?Buffer.concat(l):Buffer.concat(l).toString(n);h===0||!s?g({code:kx(h,p),stdout:m,stderr:y}):f(new xx({fileName:t,code:h,signal:p,stdout:m,stderr:y}))})})}var Ike=new Map([["SIGINT",2],["SIGQUIT",3],["SIGKILL",9],["SIGTERM",15]]);function kx(t,e){let r=Ike.get(e);return typeof r!="undefined"?128+r:t!=null?t:1}function mke(t,e,{configuration:r,report:i}){i.reportError($.EXCEPTION,`  ${Jo(r,t!==null?{label:"Exit Code",value:uo(Ge.NUMBER,t)}:{label:"Exit Signal",value:uo(Ge.CODE,e)})}`)}var ir={};ft(ir,{Method:()=>gl,RequestError:()=>j_.RequestError,del:()=>RDe,get:()=>PDe,getNetworkSettings:()=>J_,post:()=>VP,put:()=>DDe,request:()=>Od});var U_=ge(Gw()),K_=ge(require("https")),H_=ge(require("http")),WP=ge(ts()),zP=ge(M_()),Yw=ge(require("url"));var j_=ge(Gw()),G_=new Map,Y_=new Map,vDe=new H_.Agent({keepAlive:!0}),SDe=new K_.Agent({keepAlive:!0});function q_(t){let e=new Yw.URL(t),r={host:e.hostname,headers:{}};return e.port&&(r.port=Number(e.port)),{proxy:r}}async function _P(t){return qa(Y_,t,()=>K.readFilePromise(t).then(e=>(Y_.set(t,e),e)))}function kDe({statusCode:t,statusMessage:e},r){let i=et(r,t,Ge.NUMBER),n=`https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/${t}`;return Fg(r,`${i}${e?` (${e})`:""}`,n)}async function qw(t,{configuration:e,customErrorMessage:r}){var i,n;try{return await t}catch(s){if(s.name!=="HTTPError")throw s;let o=(n=r==null?void 0:r(s))!=null?n:(i=s.response.body)==null?void 0:i.error;o==null&&(s.message.startsWith("Response code")?o="The remote server failed to provide the requested resource":o=s.message),s instanceof U_.TimeoutError&&s.event==="socket"&&(o+=`(can be increased via ${et(e,"httpTimeout",Ge.SETTING)})`);let a=new ct($.NETWORK_ERROR,o,l=>{s.response&&l.reportError($.NETWORK_ERROR,`  ${Jo(e,{label:"Response Code",value:uo(Ge.NO_HINT,kDe(s.response,e))})}`),s.request&&(l.reportError($.NETWORK_ERROR,`  ${Jo(e,{label:"Request Method",value:uo(Ge.NO_HINT,s.request.options.method)})}`),l.reportError($.NETWORK_ERROR,`  ${Jo(e,{label:"Request URL",value:uo(Ge.URL,s.request.requestUrl)})}`)),s.request.redirects.length>0&&l.reportError($.NETWORK_ERROR,`  ${Jo(e,{label:"Request Redirects",value:uo(Ge.NO_HINT,_S(e,s.request.redirects,Ge.URL))})}`),s.request.retryCount===s.request.options.retry.limit&&l.reportError($.NETWORK_ERROR,`  ${Jo(e,{label:"Request Retry Count",value:uo(Ge.NO_HINT,`${et(e,s.request.retryCount,Ge.NUMBER)} (can be increased via ${et(e,"httpRetry",Ge.SETTING)})`)})}`)});throw a.originalError=s,a}}function J_(t,e){let r=[...e.configuration.get("networkSettings")].sort(([o],[a])=>a.length-o.length),i={enableNetwork:void 0,caFilePath:void 0,httpProxy:void 0,httpsProxy:void 0,httpsKeyFilePath:void 0,httpsCertFilePath:void 0},n=Object.keys(i),s=typeof t=="string"?new Yw.URL(t):t;for(let[o,a]of r)if(WP.default.isMatch(s.hostname,o))for(let l of n){let c=a.get(l);c!==null&&typeof i[l]=="undefined"&&(i[l]=c)}for(let o of n)typeof i[o]=="undefined"&&(i[o]=e.configuration.get(o));return i}var gl;(function(n){n.GET="GET",n.PUT="PUT",n.POST="POST",n.DELETE="DELETE"})(gl||(gl={}));async function Od(t,e,{configuration:r,headers:i,jsonRequest:n,jsonResponse:s,method:o=gl.GET}){let a=async()=>await xDe(t,e,{configuration:r,headers:i,jsonRequest:n,jsonResponse:s,method:o});return await(await r.reduceHook(c=>c.wrapNetworkRequest,a,{target:t,body:e,configuration:r,headers:i,jsonRequest:n,jsonResponse:s,method:o}))()}async function PDe(t,n){var s=n,{configuration:e,jsonResponse:r}=s,i=Tr(s,["configuration","jsonResponse"]);let o=qa(G_,t,()=>qw(Od(t,null,N({configuration:e},i)),{configuration:e}).then(a=>(G_.set(t,a.body),a.body)));return Buffer.isBuffer(o)===!1&&(o=await o),r?JSON.parse(o.toString()):o}async function DDe(t,e,n){var s=n,{customErrorMessage:r}=s,i=Tr(s,["customErrorMessage"]);return(await qw(Od(t,e,te(N({},i),{method:gl.PUT})),i)).body}async function VP(t,e,n){var s=n,{customErrorMessage:r}=s,i=Tr(s,["customErrorMessage"]);return(await qw(Od(t,e,te(N({},i),{method:gl.POST})),i)).body}async function RDe(t,i){var n=i,{customErrorMessage:e}=n,r=Tr(n,["customErrorMessage"]);return(await qw(Od(t,null,te(N({},r),{method:gl.DELETE})),r)).body}async function xDe(t,e,{configuration:r,headers:i,jsonRequest:n,jsonResponse:s,method:o=gl.GET}){let a=typeof t=="string"?new Yw.URL(t):t,l=J_(a,{configuration:r});if(l.enableNetwork===!1)throw new Error(`Request to '${a.href}' has been blocked because of your configuration settings`);if(a.protocol==="http:"&&!WP.default.isMatch(a.hostname,r.get("unsafeHttpWhitelist")))throw new Error(`Unsafe http requests must be explicitly whitelisted in your configuration (${a.hostname})`);let u={agent:{http:l.httpProxy?zP.default.httpOverHttp(q_(l.httpProxy)):vDe,https:l.httpsProxy?zP.default.httpsOverHttp(q_(l.httpsProxy)):SDe},headers:i,method:o};u.responseType=s?"json":"buffer",e!==null&&(Buffer.isBuffer(e)||!n&&typeof e=="string"?u.body=e:u.json=e);let g=r.get("httpTimeout"),f=r.get("httpRetry"),h=r.get("enableStrictSsl"),p=l.caFilePath,m=l.httpsCertFilePath,y=l.httpsKeyFilePath,{default:Q}=await Promise.resolve().then(()=>ge(Gw())),S=p?await _P(p):void 0,x=m?await _P(m):void 0,M=y?await _P(y):void 0,Y=Q.extend(N({timeout:{socket:g},retry:f,https:{rejectUnauthorized:h,certificateAuthority:S,certificate:x,key:M}},u));return r.getLimit("networkConcurrency")(()=>Y(a))}var Zt={};ft(Zt,{PackageManager:()=>hn,detectPackageManager:()=>s6,executePackageAccessibleBinary:()=>c6,executePackageScript:()=>aB,executePackageShellcode:()=>uD,executeWorkspaceAccessibleBinary:()=>XRe,executeWorkspaceLifecycleScript:()=>l6,executeWorkspaceScript:()=>A6,getPackageAccessibleBinaries:()=>AB,getWorkspaceAccessibleBinaries:()=>a6,hasPackageScript:()=>zRe,hasWorkspaceScript:()=>cD,makeScriptEnv:()=>Yd,maybeExecuteWorkspaceLifecycleScript:()=>VRe,prepareExternalProject:()=>WRe});var Md={};ft(Md,{getLibzipPromise:()=>fn,getLibzipSync:()=>X_});var V_=ge(z_());var fl=["number","number"],$P;(function(L){L[L.ZIP_ER_OK=0]="ZIP_ER_OK",L[L.ZIP_ER_MULTIDISK=1]="ZIP_ER_MULTIDISK",L[L.ZIP_ER_RENAME=2]="ZIP_ER_RENAME",L[L.ZIP_ER_CLOSE=3]="ZIP_ER_CLOSE",L[L.ZIP_ER_SEEK=4]="ZIP_ER_SEEK",L[L.ZIP_ER_READ=5]="ZIP_ER_READ",L[L.ZIP_ER_WRITE=6]="ZIP_ER_WRITE",L[L.ZIP_ER_CRC=7]="ZIP_ER_CRC",L[L.ZIP_ER_ZIPCLOSED=8]="ZIP_ER_ZIPCLOSED",L[L.ZIP_ER_NOENT=9]="ZIP_ER_NOENT",L[L.ZIP_ER_EXISTS=10]="ZIP_ER_EXISTS",L[L.ZIP_ER_OPEN=11]="ZIP_ER_OPEN",L[L.ZIP_ER_TMPOPEN=12]="ZIP_ER_TMPOPEN",L[L.ZIP_ER_ZLIB=13]="ZIP_ER_ZLIB",L[L.ZIP_ER_MEMORY=14]="ZIP_ER_MEMORY",L[L.ZIP_ER_CHANGED=15]="ZIP_ER_CHANGED",L[L.ZIP_ER_COMPNOTSUPP=16]="ZIP_ER_COMPNOTSUPP",L[L.ZIP_ER_EOF=17]="ZIP_ER_EOF",L[L.ZIP_ER_INVAL=18]="ZIP_ER_INVAL",L[L.ZIP_ER_NOZIP=19]="ZIP_ER_NOZIP",L[L.ZIP_ER_INTERNAL=20]="ZIP_ER_INTERNAL",L[L.ZIP_ER_INCONS=21]="ZIP_ER_INCONS",L[L.ZIP_ER_REMOVE=22]="ZIP_ER_REMOVE",L[L.ZIP_ER_DELETED=23]="ZIP_ER_DELETED",L[L.ZIP_ER_ENCRNOTSUPP=24]="ZIP_ER_ENCRNOTSUPP",L[L.ZIP_ER_RDONLY=25]="ZIP_ER_RDONLY",L[L.ZIP_ER_NOPASSWD=26]="ZIP_ER_NOPASSWD",L[L.ZIP_ER_WRONGPASSWD=27]="ZIP_ER_WRONGPASSWD",L[L.ZIP_ER_OPNOTSUPP=28]="ZIP_ER_OPNOTSUPP",L[L.ZIP_ER_INUSE=29]="ZIP_ER_INUSE",L[L.ZIP_ER_TELL=30]="ZIP_ER_TELL",L[L.ZIP_ER_COMPRESSED_DATA=31]="ZIP_ER_COMPRESSED_DATA"})($P||($P={}));var __=t=>({get HEAP8(){return t.HEAP8},get HEAPU8(){return t.HEAPU8},errors:$P,SEEK_SET:0,SEEK_CUR:1,SEEK_END:2,ZIP_CHECKCONS:4,ZIP_CREATE:1,ZIP_EXCL:2,ZIP_TRUNCATE:8,ZIP_RDONLY:16,ZIP_FL_OVERWRITE:8192,ZIP_FL_COMPRESSED:4,ZIP_OPSYS_DOS:0,ZIP_OPSYS_AMIGA:1,ZIP_OPSYS_OPENVMS:2,ZIP_OPSYS_UNIX:3,ZIP_OPSYS_VM_CMS:4,ZIP_OPSYS_ATARI_ST:5,ZIP_OPSYS_OS_2:6,ZIP_OPSYS_MACINTOSH:7,ZIP_OPSYS_Z_SYSTEM:8,ZIP_OPSYS_CPM:9,ZIP_OPSYS_WINDOWS_NTFS:10,ZIP_OPSYS_MVS:11,ZIP_OPSYS_VSE:12,ZIP_OPSYS_ACORN_RISC:13,ZIP_OPSYS_VFAT:14,ZIP_OPSYS_ALTERNATE_MVS:15,ZIP_OPSYS_BEOS:16,ZIP_OPSYS_TANDEM:17,ZIP_OPSYS_OS_400:18,ZIP_OPSYS_OS_X:19,ZIP_CM_DEFAULT:-1,ZIP_CM_STORE:0,ZIP_CM_DEFLATE:8,uint08S:t._malloc(1),uint16S:t._malloc(2),uint32S:t._malloc(4),uint64S:t._malloc(8),malloc:t._malloc,free:t._free,getValue:t.getValue,open:t.cwrap("zip_open","number",["string","number","number"]),openFromSource:t.cwrap("zip_open_from_source","number",["number","number","number"]),close:t.cwrap("zip_close","number",["number"]),discard:t.cwrap("zip_discard",null,["number"]),getError:t.cwrap("zip_get_error","number",["number"]),getName:t.cwrap("zip_get_name","string",["number","number","number"]),getNumEntries:t.cwrap("zip_get_num_entries","number",["number","number"]),delete:t.cwrap("zip_delete","number",["number","number"]),stat:t.cwrap("zip_stat","number",["number","string","number","number"]),statIndex:t.cwrap("zip_stat_index","number",["number",...fl,"number","number"]),fopen:t.cwrap("zip_fopen","number",["number","string","number"]),fopenIndex:t.cwrap("zip_fopen_index","number",["number",...fl,"number"]),fread:t.cwrap("zip_fread","number",["number","number","number","number"]),fclose:t.cwrap("zip_fclose","number",["number"]),dir:{add:t.cwrap("zip_dir_add","number",["number","string"])},file:{add:t.cwrap("zip_file_add","number",["number","string","number","number"]),getError:t.cwrap("zip_file_get_error","number",["number"]),getExternalAttributes:t.cwrap("zip_file_get_external_attributes","number",["number",...fl,"number","number","number"]),setExternalAttributes:t.cwrap("zip_file_set_external_attributes","number",["number",...fl,"number","number","number"]),setMtime:t.cwrap("zip_file_set_mtime","number",["number",...fl,"number","number"]),setCompression:t.cwrap("zip_set_file_compression","number",["number",...fl,"number","number"])},ext:{countSymlinks:t.cwrap("zip_ext_count_symlinks","number",["number"])},error:{initWithCode:t.cwrap("zip_error_init_with_code",null,["number","number"]),strerror:t.cwrap("zip_error_strerror","string",["number"])},name:{locate:t.cwrap("zip_name_locate","number",["number","string","number"])},source:{fromUnattachedBuffer:t.cwrap("zip_source_buffer_create","number",["number","number","number","number"]),fromBuffer:t.cwrap("zip_source_buffer","number",["number","number",...fl,"number"]),free:t.cwrap("zip_source_free",null,["number"]),keep:t.cwrap("zip_source_keep",null,["number"]),open:t.cwrap("zip_source_open","number",["number"]),close:t.cwrap("zip_source_close","number",["number"]),seek:t.cwrap("zip_source_seek","number",["number",...fl,"number"]),tell:t.cwrap("zip_source_tell","number",["number"]),read:t.cwrap("zip_source_read","number",["number","number","number"]),error:t.cwrap("zip_source_error","number",["number"]),setMtime:t.cwrap("zip_source_set_mtime","number",["number","number"])},struct:{stat:t.cwrap("zipstruct_stat","number",[]),statS:t.cwrap("zipstruct_statS","number",[]),statName:t.cwrap("zipstruct_stat_name","string",["number"]),statIndex:t.cwrap("zipstruct_stat_index","number",["number"]),statSize:t.cwrap("zipstruct_stat_size","number",["number"]),statCompSize:t.cwrap("zipstruct_stat_comp_size","number",["number"]),statCompMethod:t.cwrap("zipstruct_stat_comp_method","number",["number"]),statMtime:t.cwrap("zipstruct_stat_mtime","number",["number"]),statCrc:t.cwrap("zipstruct_stat_crc","number",["number"]),error:t.cwrap("zipstruct_error","number",[]),errorS:t.cwrap("zipstruct_errorS","number",[]),errorCodeZip:t.cwrap("zipstruct_error_code_zip","number",["number"])}});var eD=null;function X_(){return eD===null&&(eD=__((0,V_.default)())),eD}async function fn(){return X_()}var Kd={};ft(Kd,{ShellError:()=>Ts,execute:()=>eB,globUtils:()=>Ww});var l5=ge(IS()),c5=ge(require("os")),ns=ge(require("stream")),u5=ge(require("util"));var Ts=class extends Error{constructor(e){super(e);this.name="ShellError"}};var Ww={};ft(Ww,{fastGlobOptions:()=>e5,isBraceExpansion:()=>t5,isGlobPattern:()=>FDe,match:()=>NDe,micromatchOptions:()=>_w});var Z_=ge(tw()),$_=ge(require("fs")),zw=ge(ts()),_w={strictBrackets:!0},e5={onlyDirectories:!1,onlyFiles:!1};function FDe(t){if(!zw.default.scan(t,_w).isGlob)return!1;try{zw.default.parse(t,_w)}catch{return!1}return!0}function NDe(t,{cwd:e,baseFs:r}){return(0,Z_.default)(t,te(N({},e5),{cwd:j.fromPortablePath(e),fs:VE($_.default,new _h(r))}))}function t5(t){return zw.default.scan(t,_w).isBrace}var r5=ge(LQ()),ta=ge(require("stream")),i5=ge(require("string_decoder")),Fn;(function(i){i[i.STDIN=0]="STDIN",i[i.STDOUT=1]="STDOUT",i[i.STDERR=2]="STDERR"})(Fn||(Fn={}));var qc=new Set;function tD(){}function rD(){for(let t of qc)t.kill()}function n5(t,e,r,i){return n=>{let s=n[0]instanceof ta.Transform?"pipe":n[0],o=n[1]instanceof ta.Transform?"pipe":n[1],a=n[2]instanceof ta.Transform?"pipe":n[2],l=(0,r5.default)(t,e,te(N({},i),{stdio:[s,o,a]}));return qc.add(l),qc.size===1&&(process.on("SIGINT",tD),process.on("SIGTERM",rD)),n[0]instanceof ta.Transform&&n[0].pipe(l.stdin),n[1]instanceof ta.Transform&&l.stdout.pipe(n[1],{end:!1}),n[2]instanceof ta.Transform&&l.stderr.pipe(n[2],{end:!1}),{stdin:l.stdin,promise:new Promise(c=>{l.on("error",u=>{switch(qc.delete(l),qc.size===0&&(process.off("SIGINT",tD),process.off("SIGTERM",rD)),u.code){case"ENOENT":n[2].write(`command not found: ${t}
+`),c(127);break;case"EACCES":n[2].write(`permission denied: ${t}
+`),c(128);break;default:n[2].write(`uncaught error: ${u.message}
+`),c(1);break}}),l.on("exit",u=>{qc.delete(l),qc.size===0&&(process.off("SIGINT",tD),process.off("SIGTERM",rD)),c(u!==null?u:129)})})}}}function s5(t){return e=>{let r=e[0]==="pipe"?new ta.PassThrough:e[0];return{stdin:r,promise:Promise.resolve().then(()=>t({stdin:r,stdout:e[1],stderr:e[2]}))}}}var po=class{constructor(e){this.stream=e}close(){}get(){return this.stream}},o5=class{constructor(){this.stream=null}close(){if(this.stream===null)throw new Error("Assertion failed: No stream attached");this.stream.end()}attach(e){this.stream=e}get(){if(this.stream===null)throw new Error("Assertion failed: No stream attached");return this.stream}},Ud=class{constructor(e,r){this.stdin=null;this.stdout=null;this.stderr=null;this.pipe=null;this.ancestor=e,this.implementation=r}static start(e,{stdin:r,stdout:i,stderr:n}){let s=new Ud(null,e);return s.stdin=r,s.stdout=i,s.stderr=n,s}pipeTo(e,r=1){let i=new Ud(this,e),n=new o5;return i.pipe=n,i.stdout=this.stdout,i.stderr=this.stderr,(r&1)==1?this.stdout=n:this.ancestor!==null&&(this.stderr=this.ancestor.stdout),(r&2)==2?this.stderr=n:this.ancestor!==null&&(this.stderr=this.ancestor.stderr),i}async exec(){let e=["ignore","ignore","ignore"];if(this.pipe)e[0]="pipe";else{if(this.stdin===null)throw new Error("Assertion failed: No input stream registered");e[0]=this.stdin.get()}let r;if(this.stdout===null)throw new Error("Assertion failed: No output stream registered");r=this.stdout,e[1]=r.get();let i;if(this.stderr===null)throw new Error("Assertion failed: No error stream registered");i=this.stderr,e[2]=i.get();let n=this.implementation(e);return this.pipe&&this.pipe.attach(n.stdin),await n.promise.then(s=>(r.close(),i.close(),s))}async run(){let e=[];for(let i=this;i;i=i.ancestor)e.push(i.exec());return(await Promise.all(e))[0]}};function Vw(t,e){return Ud.start(t,e)}function a5(t,e=null){let r=new ta.PassThrough,i=new i5.StringDecoder,n="";return r.on("data",s=>{let o=i.write(s),a;do if(a=o.indexOf(`
+`),a!==-1){let l=n+o.substring(0,a);o=o.substring(a+1),n="",t(e!==null?`${e} ${l}`:l)}while(a!==-1);n+=o}),r.on("end",()=>{let s=i.end();s!==""&&t(e!==null?`${e} ${s}`:s)}),r}function A5(t,{prefix:e}){return{stdout:a5(r=>t.stdout.write(`${r}
+`),t.stdout.isTTY?e:null),stderr:a5(r=>t.stderr.write(`${r}
+`),t.stderr.isTTY?e:null)}}var LDe=(0,u5.promisify)(setTimeout);var zi;(function(r){r[r.Readable=1]="Readable",r[r.Writable=2]="Writable"})(zi||(zi={}));function g5(t,e,r){let i=new ns.PassThrough({autoDestroy:!0});switch(t){case Fn.STDIN:(e&1)==1&&r.stdin.pipe(i,{end:!1}),(e&2)==2&&r.stdin instanceof ns.Writable&&i.pipe(r.stdin,{end:!1});break;case Fn.STDOUT:(e&1)==1&&r.stdout.pipe(i,{end:!1}),(e&2)==2&&i.pipe(r.stdout,{end:!1});break;case Fn.STDERR:(e&1)==1&&r.stderr.pipe(i,{end:!1}),(e&2)==2&&i.pipe(r.stderr,{end:!1});break;default:throw new Ts(`Bad file descriptor: "${t}"`)}return i}function Xw(t,e={}){let r=N(N({},t),e);return r.environment=N(N({},t.environment),e.environment),r.variables=N(N({},t.variables),e.variables),r}var TDe=new Map([["cd",async([t=(0,c5.homedir)(),...e],r,i)=>{let n=k.resolve(i.cwd,j.toPortablePath(t));if(!(await r.baseFs.statPromise(n).catch(o=>{throw o.code==="ENOENT"?new Ts(`cd: no such file or directory: ${t}`):o})).isDirectory())throw new Ts(`cd: not a directory: ${t}`);return i.cwd=n,0}],["pwd",async(t,e,r)=>(r.stdout.write(`${j.fromPortablePath(r.cwd)}
+`),0)],[":",async(t,e,r)=>0],["true",async(t,e,r)=>0],["false",async(t,e,r)=>1],["exit",async([t,...e],r,i)=>i.exitCode=parseInt(t!=null?t:i.variables["?"],10)],["echo",async(t,e,r)=>(r.stdout.write(`${t.join(" ")}
+`),0)],["sleep",async([t],e,r)=>{if(typeof t=="undefined")throw new Ts("sleep: missing operand");let i=Number(t);if(Number.isNaN(i))throw new Ts(`sleep: invalid time interval '${t}'`);return await LDe(1e3*i,0)}],["__ysh_run_procedure",async(t,e,r)=>{let i=r.procedures[t[0]];return await Vw(i,{stdin:new po(r.stdin),stdout:new po(r.stdout),stderr:new po(r.stderr)}).run()}],["__ysh_set_redirects",async(t,e,r)=>{let i=r.stdin,n=r.stdout,s=r.stderr,o=[],a=[],l=[],c=0;for(;t[c]!=="--";){let g=t[c++],{type:f,fd:h}=JSON.parse(g),p=S=>{switch(h){case null:case 0:o.push(S);break;default:throw new Error(`Unsupported file descriptor: "${h}"`)}},m=S=>{switch(h){case null:case 1:a.push(S);break;case 2:l.push(S);break;default:throw new Error(`Unsupported file descriptor: "${h}"`)}},y=Number(t[c++]),Q=c+y;for(let S=c;S<Q;++c,++S)switch(f){case"<":p(()=>e.baseFs.createReadStream(k.resolve(r.cwd,j.toPortablePath(t[S]))));break;case"<<<":p(()=>{let x=new ns.PassThrough;return process.nextTick(()=>{x.write(`${t[S]}
+`),x.end()}),x});break;case"<&":p(()=>g5(Number(t[S]),1,r));break;case">":case">>":{let x=k.resolve(r.cwd,j.toPortablePath(t[S]));m(x==="/dev/null"?new ns.Writable({autoDestroy:!0,emitClose:!0,write(M,Y,U){setImmediate(U)}}):e.baseFs.createWriteStream(x,f===">>"?{flags:"a"}:void 0))}break;case">&":m(g5(Number(t[S]),2,r));break;default:throw new Error(`Assertion failed: Unsupported redirection type: "${f}"`)}}if(o.length>0){let g=new ns.PassThrough;i=g;let f=h=>{if(h===o.length)g.end();else{let p=o[h]();p.pipe(g,{end:!1}),p.on("end",()=>{f(h+1)})}};f(0)}if(a.length>0){let g=new ns.PassThrough;n=g;for(let f of a)g.pipe(f)}if(l.length>0){let g=new ns.PassThrough;s=g;for(let f of l)g.pipe(f)}let u=await Vw(Hd(t.slice(c+1),e,r),{stdin:new po(i),stdout:new po(n),stderr:new po(s)}).run();return await Promise.all(a.map(g=>new Promise((f,h)=>{g.on("error",p=>{h(p)}),g.on("close",()=>{f()}),g.end()}))),await Promise.all(l.map(g=>new Promise((f,h)=>{g.on("error",p=>{h(p)}),g.on("close",()=>{f()}),g.end()}))),u}]]);async function ODe(t,e,r){let i=[],n=new ns.PassThrough;return n.on("data",s=>i.push(s)),await Zw(t,e,Xw(r,{stdout:n})),Buffer.concat(i).toString().replace(/[\r\n]+$/,"")}async function f5(t,e,r){let i=t.map(async s=>{let o=await iA(s.args,e,r);return{name:s.name,value:o.join(" ")}});return(await Promise.all(i)).reduce((s,o)=>(s[o.name]=o.value,s),{})}function $w(t){return t.match(/[^ \r\n\t]+/g)||[]}async function h5(t,e,r,i,n=i){switch(t.name){case"$":i(String(process.pid));break;case"#":i(String(e.args.length));break;case"@":if(t.quoted)for(let s of e.args)n(s);else for(let s of e.args){let o=$w(s);for(let a=0;a<o.length-1;++a)n(o[a]);i(o[o.length-1])}break;case"*":{let s=e.args.join(" ");if(t.quoted)i(s);else for(let o of $w(s))n(o)}break;case"PPID":i(String(process.ppid));break;case"RANDOM":i(String(Math.floor(Math.random()*32768)));break;default:{let s=parseInt(t.name,10),o;if(Number.isFinite(s))if(s>=0&&s<e.args.length)o=e.args[s];else if(t.defaultValue)o=(await iA(t.defaultValue,e,r)).join(" ");else if(t.alternativeValue)o=(await iA(t.alternativeValue,e,r)).join(" ");else throw new Ts(`Unbound argument #${s}`);else if(Object.prototype.hasOwnProperty.call(r.variables,t.name))o=r.variables[t.name];else if(Object.prototype.hasOwnProperty.call(r.environment,t.name))o=r.environment[t.name];else if(t.defaultValue)o=(await iA(t.defaultValue,e,r)).join(" ");else throw new Ts(`Unbound variable "${t.name}"`);if(typeof o!="undefined"&&t.alternativeValue&&(o=(await iA(t.alternativeValue,e,r)).join(" ")),t.quoted)i(o);else{let a=$w(o);for(let c=0;c<a.length-1;++c)n(a[c]);let l=a[a.length-1];typeof l!="undefined"&&i(l)}}break}}var MDe={addition:(t,e)=>t+e,subtraction:(t,e)=>t-e,multiplication:(t,e)=>t*e,division:(t,e)=>Math.trunc(t/e)};async function jd(t,e,r){if(t.type==="number"){if(Number.isInteger(t.value))return t.value;throw new Error(`Invalid number: "${t.value}", only integers are allowed`)}else if(t.type==="variable"){let i=[];await h5(te(N({},t),{quoted:!0}),e,r,s=>i.push(s));let n=Number(i.join(" "));return Number.isNaN(n)?jd({type:"variable",name:i.join(" ")},e,r):jd({type:"number",value:n},e,r)}else return MDe[t.type](await jd(t.left,e,r),await jd(t.right,e,r))}async function iA(t,e,r){let i=new Map,n=[],s=[],o=u=>{s.push(u)},a=()=>{s.length>0&&n.push(s.join("")),s=[]},l=u=>{o(u),a()},c=(u,g,f)=>{let h=JSON.stringify({type:u,fd:g}),p=i.get(h);typeof p=="undefined"&&i.set(h,p=[]),p.push(f)};for(let u of t){let g=!1;switch(u.type){case"redirection":{let f=await iA(u.args,e,r);for(let h of f)c(u.subtype,u.fd,h)}break;case"argument":for(let f of u.segments)switch(f.type){case"text":o(f.text);break;case"glob":o(f.pattern),g=!0;break;case"shell":{let h=await ODe(f.shell,e,r);if(f.quoted)o(h);else{let p=$w(h);for(let m=0;m<p.length-1;++m)l(p[m]);o(p[p.length-1])}}break;case"variable":await h5(f,e,r,o,l);break;case"arithmetic":o(String(await jd(f.arithmetic,e,r)));break}break}if(a(),g){let f=n.pop();if(typeof f=="undefined")throw new Error("Assertion failed: Expected a glob pattern to have been set");let h=await e.glob.match(f,{cwd:r.cwd,baseFs:e.baseFs});if(h.length===0){let p=t5(f)?". Note: Brace expansion of arbitrary strings isn't currently supported. For more details, please read this issue: https://github.com/yarnpkg/berry/issues/22":"";throw new Ts(`No matches found: "${f}"${p}`)}for(let p of h.sort())l(p)}}if(i.size>0){let u=[];for(let[g,f]of i.entries())u.splice(u.length,0,g,String(f.length),...f);n.splice(0,0,"__ysh_set_redirects",...u,"--")}return n}function Hd(t,e,r){e.builtins.has(t[0])||(t=["command",...t]);let i=j.fromPortablePath(r.cwd),n=r.environment;typeof n.PWD!="undefined"&&(n=te(N({},n),{PWD:i}));let[s,...o]=t;if(s==="command")return n5(o[0],o.slice(1),e,{cwd:i,env:n});let a=e.builtins.get(s);if(typeof a=="undefined")throw new Error(`Assertion failed: A builtin should exist for "${s}"`);return s5(async({stdin:l,stdout:c,stderr:u})=>{let{stdin:g,stdout:f,stderr:h}=r;r.stdin=l,r.stdout=c,r.stderr=u;try{return await a(o,e,r)}finally{r.stdin=g,r.stdout=f,r.stderr=h}})}function UDe(t,e,r){return i=>{let n=new ns.PassThrough,s=Zw(t,e,Xw(r,{stdin:n}));return{stdin:n,promise:s}}}function KDe(t,e,r){return i=>{let n=new ns.PassThrough,s=Zw(t,e,r);return{stdin:n,promise:s}}}function p5(t,e,r,i){if(e.length===0)return t;{let n;do n=String(Math.random());while(Object.prototype.hasOwnProperty.call(i.procedures,n));return i.procedures=N({},i.procedures),i.procedures[n]=t,Hd([...e,"__ysh_run_procedure",n],r,i)}}async function d5(t,e,r){let i=t,n=null,s=null;for(;i;){let o=i.then?N({},r):r,a;switch(i.type){case"command":{let l=await iA(i.args,e,r),c=await f5(i.envs,e,r);a=i.envs.length?Hd(l,e,Xw(o,{environment:c})):Hd(l,e,o)}break;case"subshell":{let l=await iA(i.args,e,r),c=UDe(i.subshell,e,o);a=p5(c,l,e,o)}break;case"group":{let l=await iA(i.args,e,r),c=KDe(i.group,e,o);a=p5(c,l,e,o)}break;case"envs":{let l=await f5(i.envs,e,r);o.environment=N(N({},o.environment),l),a=Hd(["true"],e,o)}break}if(typeof a=="undefined")throw new Error("Assertion failed: An action should have been generated");if(n===null)s=Vw(a,{stdin:new po(o.stdin),stdout:new po(o.stdout),stderr:new po(o.stderr)});else{if(s===null)throw new Error("Assertion failed: The execution pipeline should have been setup");switch(n){case"|":s=s.pipeTo(a,Fn.STDOUT);break;case"|&":s=s.pipeTo(a,Fn.STDOUT|Fn.STDERR);break}}i.then?(n=i.then.type,i=i.then.chain):i=null}if(s===null)throw new Error("Assertion failed: The execution pipeline should have been setup");return await s.run()}async function HDe(t,e,r,{background:i=!1}={}){function n(s){let o=["#2E86AB","#A23B72","#F18F01","#C73E1D","#CCE2A3"],a=o[s%o.length];return l5.default.hex(a)}if(i){let s=r.nextBackgroundJobIndex++,o=n(s),a=`[${s}]`,l=o(a),{stdout:c,stderr:u}=A5(r,{prefix:l});return r.backgroundJobs.push(d5(t,e,Xw(r,{stdout:c,stderr:u})).catch(g=>u.write(`${g.message}
+`)).finally(()=>{r.stdout.isTTY&&r.stdout.write(`Job ${l}, '${o(_u(t))}' has ended
+`)})),0}return await d5(t,e,r)}async function jDe(t,e,r,{background:i=!1}={}){let n,s=a=>{n=a,r.variables["?"]=String(a)},o=async a=>{try{return await HDe(a.chain,e,r,{background:i&&typeof a.then=="undefined"})}catch(l){if(!(l instanceof Ts))throw l;return r.stderr.write(`${l.message}
+`),1}};for(s(await o(t));t.then;){if(r.exitCode!==null)return r.exitCode;switch(t.then.type){case"&&":n===0&&s(await o(t.then.line));break;case"||":n!==0&&s(await o(t.then.line));break;default:throw new Error(`Assertion failed: Unsupported command type: "${t.then.type}"`)}t=t.then.line}return n}async function Zw(t,e,r){let i=r.backgroundJobs;r.backgroundJobs=[];let n=0;for(let{command:s,type:o}of t){if(n=await jDe(s,e,r,{background:o==="&"}),r.exitCode!==null)return r.exitCode;r.variables["?"]=String(n)}return await Promise.all(r.backgroundJobs),r.backgroundJobs=i,n}function C5(t){switch(t.type){case"variable":return t.name==="@"||t.name==="#"||t.name==="*"||Number.isFinite(parseInt(t.name,10))||"defaultValue"in t&&!!t.defaultValue&&t.defaultValue.some(e=>Gd(e))||"alternativeValue"in t&&!!t.alternativeValue&&t.alternativeValue.some(e=>Gd(e));case"arithmetic":return iD(t.arithmetic);case"shell":return nD(t.shell);default:return!1}}function Gd(t){switch(t.type){case"redirection":return t.args.some(e=>Gd(e));case"argument":return t.segments.some(e=>C5(e));default:throw new Error(`Assertion failed: Unsupported argument type: "${t.type}"`)}}function iD(t){switch(t.type){case"variable":return C5(t);case"number":return!1;default:return iD(t.left)||iD(t.right)}}function nD(t){return t.some(({command:e})=>{for(;e;){let r=e.chain;for(;r;){let i;switch(r.type){case"subshell":i=nD(r.subshell);break;case"command":i=r.envs.some(n=>n.args.some(s=>Gd(s)))||r.args.some(n=>Gd(n));break}if(i)return!0;if(!r.then)break;r=r.then.chain}if(!e.then)break;e=e.then.line}return!1})}async function eB(t,e=[],{baseFs:r=new ar,builtins:i={},cwd:n=j.toPortablePath(process.cwd()),env:s=process.env,stdin:o=process.stdin,stdout:a=process.stdout,stderr:l=process.stderr,variables:c={},glob:u=Ww}={}){let g={};for(let[p,m]of Object.entries(s))typeof m!="undefined"&&(g[p]=m);let f=new Map(TDe);for(let[p,m]of Object.entries(i))f.set(p,m);o===null&&(o=new ns.PassThrough,o.end());let h=ZE(t,u);if(!nD(h)&&h.length>0&&e.length>0){let{command:p}=h[h.length-1];for(;p.then;)p=p.then.line;let m=p.chain;for(;m.then;)m=m.then.chain;m.type==="command"&&(m.args=m.args.concat(e.map(y=>({type:"argument",segments:[{type:"text",text:y}]}))))}return await Zw(h,{args:e,baseFs:r,builtins:f,initialStdin:o,initialStdout:a,initialStderr:l,glob:u},{cwd:n,environment:g,exitCode:null,procedures:{},stdin:o,stdout:a,stderr:l,variables:Object.assign({},c,{["?"]:0}),nextBackgroundJobIndex:1,backgroundJobs:[]})}var i6=ge(tB()),n6=ge(ag()),Jc=ge(require("stream"));var Z5=ge(X5()),nB=ge(hc());var $5=["\u280B","\u2819","\u2839","\u2838","\u283C","\u2834","\u2826","\u2827","\u2807","\u280F"],e6=80,KRe=new Set([$.FETCH_NOT_CACHED,$.UNUSED_CACHE_ENTRY]),HRe=5,sB=nB.default.GITHUB_ACTIONS?{start:t=>`::group::${t}
+`,end:t=>`::endgroup::
+`}:nB.default.TRAVIS?{start:t=>`travis_fold:start:${t}
+`,end:t=>`travis_fold:end:${t}
+`}:nB.default.GITLAB?{start:t=>`section_start:${Math.floor(Date.now()/1e3)}:${t.toLowerCase().replace(/\W+/g,"_")}[collapsed=true]\r\e[0K${t}
+`,end:t=>`section_end:${Math.floor(Date.now()/1e3)}:${t.toLowerCase().replace(/\W+/g,"_")}\r\e[0K`}:null,t6=new Date,jRe=["iTerm.app","Apple_Terminal"].includes(process.env.TERM_PROGRAM)||!!process.env.WT_SESSION,GRe=t=>t,oB=GRe({patrick:{date:[17,3],chars:["\u{1F340}","\u{1F331}"],size:40},simba:{date:[19,7],chars:["\u{1F981}","\u{1F334}"],size:40},jack:{date:[31,10],chars:["\u{1F383}","\u{1F987}"],size:40},hogsfather:{date:[31,12],chars:["\u{1F389}","\u{1F384}"],size:40},default:{chars:["=","-"],size:80}}),YRe=jRe&&Object.keys(oB).find(t=>{let e=oB[t];return!(e.date&&(e.date[0]!==t6.getDate()||e.date[1]!==t6.getMonth()+1))})||"default";function r6(t,{configuration:e,json:r}){if(!e.get("enableMessageNames"))return"";let n=YA(t===null?0:t);return!r&&t===null?et(e,n,"grey"):n}function lD(t,{configuration:e,json:r}){let i=r6(t,{configuration:e,json:r});if(!i||t===null||t===$.UNNAMED)return i;let n=$[t],s=`https://yarnpkg.com/advanced/error-codes#${i}---${n}`.toLowerCase();return Fg(e,i,s)}var Je=class extends Ji{constructor({configuration:e,stdout:r,json:i=!1,includeFooter:n=!0,includeLogs:s=!i,includeInfos:o=s,includeWarnings:a=s,forgettableBufferSize:l=HRe,forgettableNames:c=new Set}){super();this.uncommitted=new Set;this.cacheHitCount=0;this.cacheMissCount=0;this.lastCacheMiss=null;this.warningCount=0;this.errorCount=0;this.startTime=Date.now();this.indent=0;this.progress=new Map;this.progressTime=0;this.progressFrame=0;this.progressTimeout=null;this.progressStyle=null;this.progressMaxScaledSize=null;this.forgettableLines=[];if(nd(this,{configuration:e}),this.configuration=e,this.forgettableBufferSize=l,this.forgettableNames=new Set([...c,...KRe]),this.includeFooter=n,this.includeInfos=o,this.includeWarnings=a,this.json=i,this.stdout=r,e.get("enableProgressBars")&&!i&&r.isTTY&&r.columns>22){let u=e.get("progressBarStyle")||YRe;if(!Object.prototype.hasOwnProperty.call(oB,u))throw new Error("Assertion failed: Invalid progress bar style");this.progressStyle=oB[u];let g="\u27A4 YN0000: \u250C ".length,f=Math.max(0,Math.min(r.columns-g,80));this.progressMaxScaledSize=Math.floor(this.progressStyle.size*f/80)}}static async start(e,r){let i=new this(e),n=process.emitWarning;process.emitWarning=(s,o)=>{if(typeof s!="string"){let l=s;s=l.message,o=o!=null?o:l.name}let a=typeof o!="undefined"?`${o}: ${s}`:s;i.reportWarning($.UNNAMED,a)};try{await r(i)}catch(s){i.reportExceptionOnce(s)}finally{await i.finalize(),process.emitWarning=n}return i}hasErrors(){return this.errorCount>0}exitCode(){return this.hasErrors()?1:0}reportCacheHit(e){this.cacheHitCount+=1}reportCacheMiss(e,r){this.lastCacheMiss=e,this.cacheMissCount+=1,typeof r!="undefined"&&!this.configuration.get("preferAggregateCacheInfo")&&this.reportInfo($.FETCH_NOT_CACHED,r)}startSectionSync({reportHeader:e,reportFooter:r,skipIfEmpty:i},n){let s={committed:!1,action:()=>{e==null||e()}};i?this.uncommitted.add(s):(s.action(),s.committed=!0);let o=Date.now();try{return n()}catch(a){throw this.reportExceptionOnce(a),a}finally{let a=Date.now();this.uncommitted.delete(s),s.committed&&(r==null||r(a-o))}}async startSectionPromise({reportHeader:e,reportFooter:r,skipIfEmpty:i},n){let s={committed:!1,action:()=>{e==null||e()}};i?this.uncommitted.add(s):(s.action(),s.committed=!0);let o=Date.now();try{return await n()}catch(a){throw this.reportExceptionOnce(a),a}finally{let a=Date.now();this.uncommitted.delete(s),s.committed&&(r==null||r(a-o))}}startTimerImpl(e,r,i){let n=typeof r=="function"?{}:r;return{cb:typeof r=="function"?r:i,reportHeader:()=>{this.reportInfo(null,`\u250C ${e}`),this.indent+=1,sB!==null&&!this.json&&this.includeInfos&&this.stdout.write(sB.start(e))},reportFooter:o=>{this.indent-=1,sB!==null&&!this.json&&this.includeInfos&&this.stdout.write(sB.end(e)),this.configuration.get("enableTimers")&&o>200?this.reportInfo(null,`\u2514 Completed in ${et(this.configuration,o,Ge.DURATION)}`):this.reportInfo(null,"\u2514 Completed")},skipIfEmpty:n.skipIfEmpty}}startTimerSync(e,r,i){let o=this.startTimerImpl(e,r,i),{cb:n}=o,s=Tr(o,["cb"]);return this.startSectionSync(s,n)}async startTimerPromise(e,r,i){let o=this.startTimerImpl(e,r,i),{cb:n}=o,s=Tr(o,["cb"]);return this.startSectionPromise(s,n)}async startCacheReport(e){let r=this.configuration.get("preferAggregateCacheInfo")?{cacheHitCount:this.cacheHitCount,cacheMissCount:this.cacheMissCount}:null;try{return await e()}catch(i){throw this.reportExceptionOnce(i),i}finally{r!==null&&this.reportCacheChanges(r)}}reportSeparator(){this.indent===0?this.writeLineWithForgettableReset(""):this.reportInfo(null,"")}reportInfo(e,r){if(!this.includeInfos)return;this.commit();let i=this.formatNameWithHyperlink(e),n=i?`${i}: `:"",s=`${et(this.configuration,"\u27A4","blueBright")} ${n}${this.formatIndent()}${r}`;if(this.json)this.reportJson({type:"info",name:e,displayName:this.formatName(e),indent:this.formatIndent(),data:r});else if(this.forgettableNames.has(e))if(this.forgettableLines.push(s),this.forgettableLines.length>this.forgettableBufferSize){for(;this.forgettableLines.length>this.forgettableBufferSize;)this.forgettableLines.shift();this.writeLines(this.forgettableLines,{truncate:!0})}else this.writeLine(s,{truncate:!0});else this.writeLineWithForgettableReset(s)}reportWarning(e,r){if(this.warningCount+=1,!this.includeWarnings)return;this.commit();let i=this.formatNameWithHyperlink(e),n=i?`${i}: `:"";this.json?this.reportJson({type:"warning",name:e,displayName:this.formatName(e),indent:this.formatIndent(),data:r}):this.writeLineWithForgettableReset(`${et(this.configuration,"\u27A4","yellowBright")} ${n}${this.formatIndent()}${r}`)}reportError(e,r){this.errorCount+=1,this.commit();let i=this.formatNameWithHyperlink(e),n=i?`${i}: `:"";this.json?this.reportJson({type:"error",name:e,displayName:this.formatName(e),indent:this.formatIndent(),data:r}):this.writeLineWithForgettableReset(`${et(this.configuration,"\u27A4","redBright")} ${n}${this.formatIndent()}${r}`,{truncate:!1})}reportProgress(e){if(this.progressStyle===null)return te(N({},Promise.resolve()),{stop:()=>{}});if(e.hasProgress&&e.hasTitle)throw new Error("Unimplemented: Progress bars can't have both progress and titles.");let r=!1,i=Promise.resolve().then(async()=>{let s={progress:e.hasProgress?0:void 0,title:e.hasTitle?"":void 0};this.progress.set(e,{definition:s,lastScaledSize:e.hasProgress?-1:void 0,lastTitle:void 0}),this.refreshProgress({delta:-1});for await(let{progress:o,title:a}of e)r||s.progress===o&&s.title===a||(s.progress=o,s.title=a,this.refreshProgress());n()}),n=()=>{r||(r=!0,this.progress.delete(e),this.refreshProgress({delta:1}))};return te(N({},i),{stop:n})}reportJson(e){this.json&&this.writeLineWithForgettableReset(`${JSON.stringify(e)}`)}async finalize(){if(!this.includeFooter)return;let e="";this.errorCount>0?e="Failed with errors":this.warningCount>0?e="Done with warnings":e="Done";let r=et(this.configuration,Date.now()-this.startTime,Ge.DURATION),i=this.configuration.get("enableTimers")?`${e} in ${r}`:e;this.errorCount>0?this.reportError($.UNNAMED,i):this.warningCount>0?this.reportWarning($.UNNAMED,i):this.reportInfo($.UNNAMED,i)}writeLine(e,{truncate:r}={}){this.clearProgress({clear:!0}),this.stdout.write(`${this.truncate(e,{truncate:r})}
+`),this.writeProgress()}writeLineWithForgettableReset(e,{truncate:r}={}){this.forgettableLines=[],this.writeLine(e,{truncate:r})}writeLines(e,{truncate:r}={}){this.clearProgress({delta:e.length});for(let i of e)this.stdout.write(`${this.truncate(i,{truncate:r})}
+`);this.writeProgress()}reportCacheChanges({cacheHitCount:e,cacheMissCount:r}){let i=this.cacheHitCount-e,n=this.cacheMissCount-r;if(i===0&&n===0)return;let s="";this.cacheHitCount>1?s+=`${this.cacheHitCount} packages were already cached`:this.cacheHitCount===1?s+=" - one package was already cached":s+="No packages were cached",this.cacheHitCount>0?this.cacheMissCount>1?s+=`, ${this.cacheMissCount} had to be fetched`:this.cacheMissCount===1&&(s+=`, one had to be fetched (${Bt(this.configuration,this.lastCacheMiss)})`):this.cacheMissCount>1?s+=` - ${this.cacheMissCount} packages had to be fetched`:this.cacheMissCount===1&&(s+=` - one package had to be fetched (${Bt(this.configuration,this.lastCacheMiss)})`),this.reportInfo($.FETCH_NOT_CACHED,s)}commit(){let e=this.uncommitted;this.uncommitted=new Set;for(let r of e)r.committed=!0,r.action()}clearProgress({delta:e=0,clear:r=!1}){this.progressStyle!==null&&this.progress.size+e>0&&(this.stdout.write(`\e[${this.progress.size+e}A`),(e>0||r)&&this.stdout.write("\e[0J"))}writeProgress(){if(this.progressStyle===null||(this.progressTimeout!==null&&clearTimeout(this.progressTimeout),this.progressTimeout=null,this.progress.size===0))return;let e=Date.now();e-this.progressTime>e6&&(this.progressFrame=(this.progressFrame+1)%$5.length,this.progressTime=e);let r=$5[this.progressFrame];for(let i of this.progress.values()){let n="";if(typeof i.lastScaledSize!="undefined"){let l=this.progressStyle.chars[0].repeat(i.lastScaledSize),c=this.progressStyle.chars[1].repeat(this.progressMaxScaledSize-i.lastScaledSize);n=` ${l}${c}`}let s=this.formatName(null),o=s?`${s}: `:"",a=i.definition.title?` ${i.definition.title}`:"";this.stdout.write(`${et(this.configuration,"\u27A4","blueBright")} ${o}${r}${n}${a}
+`)}this.progressTimeout=setTimeout(()=>{this.refreshProgress({force:!0})},e6)}refreshProgress({delta:e=0,force:r=!1}={}){let i=!1,n=!1;if(r||this.progress.size===0)i=!0;else for(let s of this.progress.values()){let o=typeof s.definition.progress!="undefined"?Math.trunc(this.progressMaxScaledSize*s.definition.progress):void 0,a=s.lastScaledSize;s.lastScaledSize=o;let l=s.lastTitle;if(s.lastTitle=s.definition.title,o!==a||(n=l!==s.definition.title)){i=!0;break}}i&&(this.clearProgress({delta:e,clear:n}),this.writeProgress())}truncate(e,{truncate:r}={}){return this.progressStyle===null&&(r=!1),typeof r=="undefined"&&(r=this.configuration.get("preferTruncatedLines")),r&&(e=(0,Z5.default)(e,0,this.stdout.columns-1)),e}formatName(e){return r6(e,{configuration:this.configuration,json:this.json})}formatNameWithHyperlink(e){return lD(e,{configuration:this.configuration,json:this.json})}formatIndent(){return"\u2502 ".repeat(this.indent)}};var Ur="3.2.0";var hn;(function(n){n.Yarn1="Yarn Classic",n.Yarn2="Yarn",n.Npm="npm",n.Pnpm="pnpm"})(hn||(hn={}));async function nA(t,e,r,i=[]){if(process.platform==="win32"){let n=`@goto #_undefined_# 2>NUL || @title %COMSPEC% & @setlocal & @"${r}" ${i.map(s=>`"${s.replace('"','""')}"`).join(" ")} %*`;await K.writeFilePromise(k.format({dir:t,name:e,ext:".cmd"}),n)}await K.writeFilePromise(k.join(t,e),`#!/bin/sh
+exec "${r}" ${i.map(n=>`'${n.replace(/'/g,`'"'"'`)}'`).join(" ")} "$@"
+`,{mode:493})}async function s6(t){let e=await At.tryFind(t);if(e==null?void 0:e.packageManager){let i=gw(e.packageManager);if(i==null?void 0:i.name){let n=`found ${JSON.stringify({packageManager:e.packageManager})} in manifest`,[s]=i.reference.split(".");switch(i.name){case"yarn":return{packageManager:Number(s)===1?hn.Yarn1:hn.Yarn2,reason:n};case"npm":return{packageManager:hn.Npm,reason:n};case"pnpm":return{packageManager:hn.Pnpm,reason:n}}}}let r;try{r=await K.readFilePromise(k.join(t,Pt.lockfile),"utf8")}catch{}return r!==void 0?r.match(/^__metadata:$/m)?{packageManager:hn.Yarn2,reason:'"__metadata" key found in yarn.lock'}:{packageManager:hn.Yarn1,reason:'"__metadata" key not found in yarn.lock, must be a Yarn classic lockfile'}:K.existsSync(k.join(t,"package-lock.json"))?{packageManager:hn.Npm,reason:`found npm's "package-lock.json" lockfile`}:K.existsSync(k.join(t,"pnpm-lock.yaml"))?{packageManager:hn.Pnpm,reason:`found pnpm's "pnpm-lock.yaml" lockfile`}:null}async function Yd({project:t,locator:e,binFolder:r,lifecycleScript:i}){var l,c;let n={};for(let[u,g]of Object.entries(process.env))typeof g!="undefined"&&(n[u.toLowerCase()!=="path"?u:"PATH"]=g);let s=j.fromPortablePath(r);n.BERRY_BIN_FOLDER=j.fromPortablePath(s);let o=process.env.COREPACK_ROOT?j.join(process.env.COREPACK_ROOT,"dist/yarn.js"):process.argv[1];if(await Promise.all([nA(r,"node",process.execPath),...Ur!==null?[nA(r,"run",process.execPath,[o,"run"]),nA(r,"yarn",process.execPath,[o]),nA(r,"yarnpkg",process.execPath,[o]),nA(r,"node-gyp",process.execPath,[o,"run","--top-level","node-gyp"])]:[]]),t&&(n.INIT_CWD=j.fromPortablePath(t.configuration.startingCwd),n.PROJECT_CWD=j.fromPortablePath(t.cwd)),n.PATH=n.PATH?`${s}${j.delimiter}${n.PATH}`:`${s}`,n.npm_execpath=`${s}${j.sep}yarn`,n.npm_node_execpath=`${s}${j.sep}node`,e){if(!t)throw new Error("Assertion failed: Missing project");let u=t.tryWorkspaceByLocator(e),g=u?(l=u.manifest.version)!=null?l:"":(c=t.storedPackages.get(e.locatorHash).version)!=null?c:"";n.npm_package_name=Ot(e),n.npm_package_version=g}let a=Ur!==null?`yarn/${Ur}`:`yarn/${Rg("@yarnpkg/core").version}-core`;return n.npm_config_user_agent=`${a} npm/? node/${process.version} ${process.platform} ${process.arch}`,i&&(n.npm_lifecycle_event=i),t&&await t.configuration.triggerHook(u=>u.setupScriptEnvironment,t,n,async(u,g,f)=>await nA(r,qr(u),g,f)),n}var qRe=2,JRe=(0,n6.default)(qRe);async function WRe(t,e,{configuration:r,report:i,workspace:n=null,locator:s=null}){await JRe(async()=>{await K.mktempPromise(async o=>{let a=k.join(o,"pack.log"),l=null,{stdout:c,stderr:u}=r.getSubprocessStreams(a,{prefix:j.fromPortablePath(t),report:i}),g=s&&Xo(s)?gd(s):s,f=g?Ps(g):"an external project";c.write(`Packing ${f} from sources
+`);let h=await s6(t),p;h!==null?(c.write(`Using ${h.packageManager} for bootstrap. Reason: ${h.reason}
+
+`),p=h.packageManager):(c.write(`No package manager configuration detected; defaulting to Yarn
+
+`),p=hn.Yarn2),await K.mktempPromise(async m=>{let y=await Yd({binFolder:m}),S=new Map([[hn.Yarn1,async()=>{let M=n!==null?["workspace",n]:[],Y=await $o("yarn",["set","version","classic","--only-if-needed"],{cwd:t,env:y,stdin:l,stdout:c,stderr:u,end:is.ErrorCode});if(Y.code!==0)return Y.code;await K.appendFilePromise(k.join(t,".npmignore"),`/.yarn
+`),c.write(`
+`);let U=await $o("yarn",["install"],{cwd:t,env:y,stdin:l,stdout:c,stderr:u,end:is.ErrorCode});if(U.code!==0)return U.code;c.write(`
+`);let J=await $o("yarn",[...M,"pack","--filename",j.fromPortablePath(e)],{cwd:t,env:y,stdin:l,stdout:c,stderr:u});return J.code!==0?J.code:0}],[hn.Yarn2,async()=>{let M=n!==null?["workspace",n]:[];y.YARN_ENABLE_INLINE_BUILDS="1";let Y=k.join(t,Pt.lockfile);await K.existsPromise(Y)||await K.writeFilePromise(Y,"");let U=await $o("yarn",[...M,"pack","--install-if-needed","--filename",j.fromPortablePath(e)],{cwd:t,env:y,stdin:l,stdout:c,stderr:u});return U.code!==0?U.code:0}],[hn.Npm,async()=>{if(n!==null){let A=new Jc.PassThrough,ne=Dg(A);A.pipe(c,{end:!1});let le=await $o("npm",["--version"],{cwd:t,env:y,stdin:l,stdout:A,stderr:u,end:is.Never});if(A.end(),le.code!==0)return c.end(),u.end(),le.code;let Ae=(await ne).toString().trim();if(!Uc(Ae,">=7.x")){let T=Vo(null,"npm"),L=rr(T,Ae),Ee=rr(T,">=7.x");throw new Error(`Workspaces aren't supported by ${sr(r,L)}; please upgrade to ${sr(r,Ee)} (npm has been detected as the primary package manager for ${et(r,t,Ge.PATH)})`)}}let M=n!==null?["--workspace",n]:[];delete y.npm_config_user_agent;let Y=await $o("npm",["install"],{cwd:t,env:y,stdin:l,stdout:c,stderr:u,end:is.ErrorCode});if(Y.code!==0)return Y.code;let U=new Jc.PassThrough,J=Dg(U);U.pipe(c);let W=await $o("npm",["pack","--silent",...M],{cwd:t,env:y,stdin:l,stdout:U,stderr:u});if(W.code!==0)return W.code;let ee=(await J).toString().trim().replace(/^.*\n/s,""),Z=k.resolve(t,j.toPortablePath(ee));return await K.renamePromise(Z,e),0}]]).get(p);if(typeof S=="undefined")throw new Error("Assertion failed: Unsupported workflow");let x=await S();if(!(x===0||typeof x=="undefined"))throw K.detachTemp(o),new ct($.PACKAGE_PREPARATION_FAILED,`Packing the package failed (exit code ${x}, logs can be found here: ${et(r,a,Ge.PATH)})`)})})})}async function zRe(t,e,{project:r}){let i=r.tryWorkspaceByLocator(t);if(i!==null)return cD(i,e);let n=r.storedPackages.get(t.locatorHash);if(!n)throw new Error(`Package for ${Bt(r.configuration,t)} not found in the project`);return await ms.openPromise(async s=>{let o=r.configuration,a=r.configuration.getLinkers(),l={project:r,report:new Je({stdout:new Jc.PassThrough,configuration:o})},c=a.find(h=>h.supportsPackage(n,l));if(!c)throw new Error(`The package ${Bt(r.configuration,n)} isn't supported by any of the available linkers`);let u=await c.findPackageLocation(n,l),g=new _t(u,{baseFs:s});return(await At.find(Me.dot,{baseFs:g})).scripts.has(e)},{libzip:await fn()})}async function aB(t,e,r,{cwd:i,project:n,stdin:s,stdout:o,stderr:a}){return await K.mktempPromise(async l=>{let{manifest:c,env:u,cwd:g}=await o6(t,{project:n,binFolder:l,cwd:i,lifecycleScript:e}),f=c.scripts.get(e);if(typeof f=="undefined")return 1;let h=async()=>await eB(f,r,{cwd:g,env:u,stdin:s,stdout:o,stderr:a});return await(await n.configuration.reduceHook(m=>m.wrapScriptExecution,h,n,t,e,{script:f,args:r,cwd:g,env:u,stdin:s,stdout:o,stderr:a}))()})}async function uD(t,e,r,{cwd:i,project:n,stdin:s,stdout:o,stderr:a}){return await K.mktempPromise(async l=>{let{env:c,cwd:u}=await o6(t,{project:n,binFolder:l,cwd:i});return await eB(e,r,{cwd:u,env:c,stdin:s,stdout:o,stderr:a})})}async function _Re(t,{binFolder:e,cwd:r,lifecycleScript:i}){let n=await Yd({project:t.project,locator:t.anchoredLocator,binFolder:e,lifecycleScript:i});return await Promise.all(Array.from(await a6(t),([s,[,o]])=>nA(e,qr(s),process.execPath,[o]))),typeof r=="undefined"&&(r=k.dirname(await K.realpathPromise(k.join(t.cwd,"package.json")))),{manifest:t.manifest,binFolder:e,env:n,cwd:r}}async function o6(t,{project:e,binFolder:r,cwd:i,lifecycleScript:n}){let s=e.tryWorkspaceByLocator(t);if(s!==null)return _Re(s,{binFolder:r,cwd:i,lifecycleScript:n});let o=e.storedPackages.get(t.locatorHash);if(!o)throw new Error(`Package for ${Bt(e.configuration,t)} not found in the project`);return await ms.openPromise(async a=>{let l=e.configuration,c=e.configuration.getLinkers(),u={project:e,report:new Je({stdout:new Jc.PassThrough,configuration:l})},g=c.find(y=>y.supportsPackage(o,u));if(!g)throw new Error(`The package ${Bt(e.configuration,o)} isn't supported by any of the available linkers`);let f=await Yd({project:e,locator:t,binFolder:r,lifecycleScript:n});await Promise.all(Array.from(await AB(t,{project:e}),([y,[,Q]])=>nA(r,qr(y),process.execPath,[Q])));let h=await g.findPackageLocation(o,u),p=new _t(h,{baseFs:a}),m=await At.find(Me.dot,{baseFs:p});return typeof i=="undefined"&&(i=h),{manifest:m,binFolder:r,env:f,cwd:i}},{libzip:await fn()})}async function A6(t,e,r,{cwd:i,stdin:n,stdout:s,stderr:o}){return await aB(t.anchoredLocator,e,r,{cwd:i,project:t.project,stdin:n,stdout:s,stderr:o})}function cD(t,e){return t.manifest.scripts.has(e)}async function l6(t,e,{cwd:r,report:i}){let{configuration:n}=t.project,s=null;await K.mktempPromise(async o=>{let a=k.join(o,`${e}.log`),l=`# This file contains the result of Yarn calling the "${e}" lifecycle script inside a workspace ("${j.fromPortablePath(t.cwd)}")
+`,{stdout:c,stderr:u}=n.getSubprocessStreams(a,{report:i,prefix:Bt(n,t.anchoredLocator),header:l});i.reportInfo($.LIFECYCLE_SCRIPT,`Calling the "${e}" lifecycle script`);let g=await A6(t,e,[],{cwd:r,stdin:s,stdout:c,stderr:u});if(c.end(),u.end(),g!==0)throw K.detachTemp(o),new ct($.LIFECYCLE_SCRIPT,`${(0,i6.default)(e)} script failed (exit code ${et(n,g,Ge.NUMBER)}, logs can be found here: ${et(n,a,Ge.PATH)}); run ${et(n,`yarn ${e}`,Ge.CODE)} to investigate`)})}async function VRe(t,e,r){cD(t,e)&&await l6(t,e,r)}async function AB(t,{project:e}){let r=e.configuration,i=new Map,n=e.storedPackages.get(t.locatorHash);if(!n)throw new Error(`Package for ${Bt(r,t)} not found in the project`);let s=new Jc.Writable,o=r.getLinkers(),a={project:e,report:new Je({configuration:r,stdout:s})},l=new Set([t.locatorHash]);for(let u of n.dependencies.values()){let g=e.storedResolutions.get(u.descriptorHash);if(!g)throw new Error(`Assertion failed: The resolution (${sr(r,u)}) should have been registered`);l.add(g)}let c=await Promise.all(Array.from(l,async u=>{let g=e.storedPackages.get(u);if(!g)throw new Error(`Assertion failed: The package (${u}) should have been registered`);if(g.bin.size===0)return qo.skip;let f=o.find(p=>p.supportsPackage(g,a));if(!f)return qo.skip;let h=null;try{h=await f.findPackageLocation(g,a)}catch(p){if(p.code==="LOCATOR_NOT_INSTALLED")return qo.skip;throw p}return{dependency:g,packageLocation:h}}));for(let u of c){if(u===qo.skip)continue;let{dependency:g,packageLocation:f}=u;for(let[h,p]of g.bin)i.set(h,[g,j.fromPortablePath(k.resolve(f,p))])}return i}async function a6(t){return await AB(t.anchoredLocator,{project:t.project})}async function c6(t,e,r,{cwd:i,project:n,stdin:s,stdout:o,stderr:a,nodeArgs:l=[],packageAccessibleBinaries:c}){c!=null||(c=await AB(t,{project:n}));let u=c.get(e);if(!u)throw new Error(`Binary not found (${e}) for ${Bt(n.configuration,t)}`);return await K.mktempPromise(async g=>{let[,f]=u,h=await Yd({project:n,locator:t,binFolder:g});await Promise.all(Array.from(c,([m,[,y]])=>nA(h.BERRY_BIN_FOLDER,qr(m),process.execPath,[y])));let p;try{p=await $o(process.execPath,[...l,f,...r],{cwd:i,env:h,stdin:s,stdout:o,stderr:a})}finally{await K.removePromise(h.BERRY_BIN_FOLDER)}return p.code})}async function XRe(t,e,r,{cwd:i,stdin:n,stdout:s,stderr:o,packageAccessibleBinaries:a}){return await c6(t.anchoredLocator,e,r,{project:t.project,cwd:i,stdin:n,stdout:s,stderr:o,packageAccessibleBinaries:a})}var wi={};ft(wi,{convertToZip:()=>aLe,extractArchiveTo:()=>lLe,makeArchiveFromDirectory:()=>oLe});var r7=ge(require("stream")),i7=ge(V9());var X9=ge(require("os")),Z9=ge(ag()),$9=ge(require("worker_threads")),Ql=Symbol("kTaskInfo"),bR=class{constructor(e){this.source=e;this.workers=[];this.limit=(0,Z9.default)(Math.max(1,(0,X9.cpus)().length));this.cleanupInterval=setInterval(()=>{if(this.limit.pendingCount===0&&this.limit.activeCount===0){let r=this.workers.pop();r?r.terminate():clearInterval(this.cleanupInterval)}},5e3).unref()}createWorker(){this.cleanupInterval.refresh();let e=new $9.Worker(this.source,{eval:!0,execArgv:[...process.execArgv,"--unhandled-rejections=strict"]});return e.on("message",r=>{if(!e[Ql])throw new Error("Assertion failed: Worker sent a result without having a task assigned");e[Ql].resolve(r),e[Ql]=null,e.unref(),this.workers.push(e)}),e.on("error",r=>{var i;(i=e[Ql])==null||i.reject(r),e[Ql]=null}),e.on("exit",r=>{var i;r!==0&&((i=e[Ql])==null||i.reject(new Error(`Worker exited with code ${r}`))),e[Ql]=null}),e}run(e){return this.limit(()=>{var i;let r=(i=this.workers.pop())!=null?i:this.createWorker();return r.ref(),new Promise((n,s)=>{r[Ql]={resolve:n,reject:s},r.postMessage(e)})})}};var n7=ge(t7());async function oLe(t,{baseFs:e=new ar,prefixPath:r=Me.root,compressionLevel:i,inMemory:n=!1}={}){let s=await fn(),o;if(n)o=new Ai(null,{libzip:s,level:i});else{let l=await K.mktempPromise(),c=k.join(l,"archive.zip");o=new Ai(c,{create:!0,libzip:s,level:i})}let a=k.resolve(Me.root,r);return await o.copyPromise(a,t,{baseFs:e,stableTime:!0,stableSort:!0}),o}var s7;async function aLe(t,e){let r=await K.mktempPromise(),i=k.join(r,"archive.zip");return s7||(s7=new bR((0,n7.getContent)())),await s7.run({tmpFile:i,tgz:t,opts:e}),new Ai(i,{libzip:await fn(),level:e.compressionLevel})}async function*ALe(t){let e=new i7.default.Parse,r=new r7.PassThrough({objectMode:!0,autoDestroy:!0,emitClose:!0});e.on("entry",i=>{r.write(i)}),e.on("error",i=>{r.destroy(i)}),e.on("close",()=>{r.destroyed||r.end()}),e.end(t);for await(let i of r){let n=i;yield n,n.resume()}}async function lLe(t,e,{stripComponents:r=0,prefixPath:i=Me.dot}={}){var s,o;function n(a){if(a.path[0]==="/")return!0;let l=a.path.split(/\//g);return!!(l.some(c=>c==="..")||l.length<=r)}for await(let a of ALe(t)){if(n(a))continue;let l=k.normalize(j.toPortablePath(a.path)).replace(/\/$/,"").split(/\//g);if(l.length<=r)continue;let c=l.slice(r).join("/"),u=k.join(i,c),g=420;switch((a.type==="Directory"||(((s=a.mode)!=null?s:0)&73)!=0)&&(g|=73),a.type){case"Directory":e.mkdirpSync(k.dirname(u),{chmod:493,utimes:[Dr.SAFE_TIME,Dr.SAFE_TIME]}),e.mkdirSync(u,{mode:g}),e.utimesSync(u,Dr.SAFE_TIME,Dr.SAFE_TIME);break;case"OldFile":case"File":e.mkdirpSync(k.dirname(u),{chmod:493,utimes:[Dr.SAFE_TIME,Dr.SAFE_TIME]}),e.writeFileSync(u,await Dg(a),{mode:g}),e.utimesSync(u,Dr.SAFE_TIME,Dr.SAFE_TIME);break;case"SymbolicLink":e.mkdirpSync(k.dirname(u),{chmod:493,utimes:[Dr.SAFE_TIME,Dr.SAFE_TIME]}),e.symlinkSync(a.linkpath,u),(o=e.lutimesSync)==null||o.call(e,u,Dr.SAFE_TIME,Dr.SAFE_TIME);break}}return e}var as={};ft(as,{emitList:()=>cLe,emitTree:()=>u7,treeNodeToJson:()=>c7,treeNodeToTreeify:()=>l7});var A7=ge(a7());function l7(t,{configuration:e}){let r={},i=(n,s)=>{let o=Array.isArray(n)?n.entries():Object.entries(n);for(let[a,{label:l,value:c,children:u}]of o){let g=[];typeof l!="undefined"&&g.push(Ly(e,l,Pc.BOLD)),typeof c!="undefined"&&g.push(et(e,c[0],c[1])),g.length===0&&g.push(Ly(e,`${a}`,Pc.BOLD));let f=g.join(": "),h=s[f]={};typeof u!="undefined"&&i(u,h)}};if(typeof t.children=="undefined")throw new Error("The root node must only contain children");return i(t.children,r),r}function c7(t){let e=r=>{var s;if(typeof r.children=="undefined"){if(typeof r.value=="undefined")throw new Error("Assertion failed: Expected a value to be set if the children are missing");return Dc(r.value[0],r.value[1])}let i=Array.isArray(r.children)?r.children.entries():Object.entries((s=r.children)!=null?s:{}),n=Array.isArray(r.children)?[]:{};for(let[o,a]of i)n[o]=e(a);return typeof r.value=="undefined"?n:{value:Dc(r.value[0],r.value[1]),children:n}};return e(t)}function cLe(t,{configuration:e,stdout:r,json:i}){let n=t.map(s=>({value:s}));u7({children:n},{configuration:e,stdout:r,json:i})}function u7(t,{configuration:e,stdout:r,json:i,separators:n=0}){var o;if(i){let a=Array.isArray(t.children)?t.children.values():Object.values((o=t.children)!=null?o:{});for(let l of a)r.write(`${JSON.stringify(c7(l))}
+`);return}let s=(0,A7.asTree)(l7(t,{configuration:e}),!1,!1);if(n>=1&&(s=s.replace(/^([├└]─)/gm,`\u2502
+$1`).replace(/^│\n/,"")),n>=2)for(let a=0;a<2;++a)s=s.replace(/^([│ ].{2}[├│ ].{2}[^\n]+\n)(([│ ]).{2}[├└].{2}[^\n]*\n[│ ].{2}[│ ].{2}[├└]─)/gm,`$1$3  \u2502
+$2`).replace(/^│\n/,"");if(n>=3)throw new Error("Only the first two levels are accepted by treeUtils.emitTree");r.write(s)}var g7=ge(require("crypto")),SR=ge(require("fs"));var uLe=8,Nt=class{constructor(e,{configuration:r,immutable:i=r.get("enableImmutableCache"),check:n=!1}){this.markedFiles=new Set;this.mutexes=new Map;this.cacheId=`-${(0,g7.randomBytes)(8).toString("hex")}.tmp`;this.configuration=r,this.cwd=e,this.immutable=i,this.check=n;let s=r.get("cacheKeyOverride");if(s!==null)this.cacheKey=`${s}`;else{let o=r.get("compressionLevel"),a=o!==ic?`c${o}`:"";this.cacheKey=[uLe,a].join("")}}static async find(e,{immutable:r,check:i}={}){let n=new Nt(e.get("cacheFolder"),{configuration:e,immutable:r,check:i});return await n.setup(),n}get mirrorCwd(){if(!this.configuration.get("enableMirror"))return null;let e=`${this.configuration.get("globalFolder")}/cache`;return e!==this.cwd?e:null}getVersionFilename(e){return`${Hg(e)}-${this.cacheKey}.zip`}getChecksumFilename(e,r){let n=gLe(r).slice(0,10);return`${Hg(e)}-${n}.zip`}getLocatorPath(e,r,i={}){var s;return this.mirrorCwd===null||((s=i.unstablePackages)==null?void 0:s.has(e.locatorHash))?k.resolve(this.cwd,this.getVersionFilename(e)):r===null||kR(r)!==this.cacheKey?null:k.resolve(this.cwd,this.getChecksumFilename(e,r))}getLocatorMirrorPath(e){let r=this.mirrorCwd;return r!==null?k.resolve(r,this.getVersionFilename(e)):null}async setup(){if(!this.configuration.get("enableGlobalCache"))if(this.immutable){if(!await K.existsPromise(this.cwd))throw new ct($.IMMUTABLE_CACHE,"Cache path does not exist.")}else{await K.mkdirPromise(this.cwd,{recursive:!0});let e=k.resolve(this.cwd,".gitignore");await K.changeFilePromise(e,`/.gitignore
+*.flock
+*.tmp
+`)}(this.mirrorCwd||!this.immutable)&&await K.mkdirPromise(this.mirrorCwd||this.cwd,{recursive:!0})}async fetchPackageFromCache(e,r,a){var l=a,{onHit:i,onMiss:n,loader:s}=l,o=Tr(l,["onHit","onMiss","loader"]);var A;let c=this.getLocatorMirrorPath(e),u=new ar,g=()=>{let ne=new Ai(null,{libzip:Y}),le=k.join(Me.root,lx(e));return ne.mkdirSync(le,{recursive:!0}),ne.writeJsonSync(k.join(le,Pt.manifest),{name:Ot(e),mocked:!0}),ne},f=async(ne,le=null)=>{var T;if(le===null&&((T=o.unstablePackages)==null?void 0:T.has(e.locatorHash)))return null;let Ae=!o.skipIntegrityCheck||!r?`${this.cacheKey}/${await Aw(ne)}`:r;if(le!==null){let L=!o.skipIntegrityCheck||!r?`${this.cacheKey}/${await Aw(le)}`:r;if(Ae!==L)throw new ct($.CACHE_CHECKSUM_MISMATCH,"The remote archive doesn't match the local checksum - has the local cache been corrupted?")}if(r!==null&&Ae!==r){let L;switch(this.check?L="throw":kR(r)!==kR(Ae)?L="update":L=this.configuration.get("checksumBehavior"),L){case"ignore":return r;case"update":return Ae;default:case"throw":throw new ct($.CACHE_CHECKSUM_MISMATCH,"The remote archive doesn't match the expected checksum")}}return Ae},h=async ne=>{if(!s)throw new Error(`Cache check required but no loader configured for ${Bt(this.configuration,e)}`);let le=await s(),Ae=le.getRealPath();return le.saveAndClose(),await K.chmodPromise(Ae,420),await f(ne,Ae)},p=async()=>{if(c===null||!await K.existsPromise(c)){let ne=await s(),le=ne.getRealPath();return ne.saveAndClose(),{source:"loader",path:le}}return{source:"mirror",path:c}},m=async()=>{if(!s)throw new Error(`Cache entry required but missing for ${Bt(this.configuration,e)}`);if(this.immutable)throw new ct($.IMMUTABLE_CACHE,`Cache entry required but missing for ${Bt(this.configuration,e)}`);let{path:ne,source:le}=await p(),Ae=await f(ne),T=this.getLocatorPath(e,Ae,o);if(!T)throw new Error("Assertion failed: Expected the cache path to be available");let L=[];le!=="mirror"&&c!==null&&L.push(async()=>{let we=`${c}${this.cacheId}`;await K.copyFilePromise(ne,we,SR.default.constants.COPYFILE_FICLONE),await K.chmodPromise(we,420),await K.renamePromise(we,c)}),(!o.mirrorWriteOnly||c===null)&&L.push(async()=>{let we=`${T}${this.cacheId}`;await K.copyFilePromise(ne,we,SR.default.constants.COPYFILE_FICLONE),await K.chmodPromise(we,420),await K.renamePromise(we,T)});let Ee=o.mirrorWriteOnly&&c!=null?c:T;return await Promise.all(L.map(we=>we())),[!1,Ee,Ae]},y=async()=>{let le=(async()=>{var qe;let Ae=this.getLocatorPath(e,r,o),T=Ae!==null?await u.existsPromise(Ae):!1,L=!!((qe=o.mockedPackages)==null?void 0:qe.has(e.locatorHash))&&(!this.check||!T),Ee=L||T,we=Ee?i:n;if(we&&we(),Ee){let re=null,se=Ae;return L||(re=this.check?await h(se):await f(se)),[L,se,re]}else return m()})();this.mutexes.set(e.locatorHash,le);try{return await le}finally{this.mutexes.delete(e.locatorHash)}};for(let ne;ne=this.mutexes.get(e.locatorHash);)await ne;let[Q,S,x]=await y();this.markedFiles.add(S);let M,Y=await fn(),U=Q?()=>g():()=>new Ai(S,{baseFs:u,libzip:Y,readOnly:!0}),J=new zh(()=>HS(()=>M=U(),ne=>`Failed to open the cache entry for ${Bt(this.configuration,e)}: ${ne}`),k),W=new Pa(S,{baseFs:J,pathUtils:k}),ee=()=>{M==null||M.discardAndClose()},Z=((A=o.unstablePackages)==null?void 0:A.has(e.locatorHash))?null:x;return[W,ee,Z]}};function kR(t){let e=t.indexOf("/");return e!==-1?t.slice(0,e):null}function gLe(t){let e=t.indexOf("/");return e!==-1?t.slice(e+1):t}var As;(function(r){r[r.SCRIPT=0]="SCRIPT",r[r.SHELLCODE=1]="SHELLCODE"})(As||(As={}));var uA=class extends Ji{constructor({configuration:e,stdout:r,suggestInstall:i=!0}){super();this.errorCount=0;nd(this,{configuration:e}),this.configuration=e,this.stdout=r,this.suggestInstall=i}static async start(e,r){let i=new this(e);try{await r(i)}catch(n){i.reportExceptionOnce(n)}finally{await i.finalize()}return i}hasErrors(){return this.errorCount>0}exitCode(){return this.hasErrors()?1:0}reportCacheHit(e){}reportCacheMiss(e){}startSectionSync(e,r){return r()}async startSectionPromise(e,r){return await r()}startTimerSync(e,r,i){return(typeof r=="function"?r:i)()}async startTimerPromise(e,r,i){return await(typeof r=="function"?r:i)()}async startCacheReport(e){return await e()}reportSeparator(){}reportInfo(e,r){}reportWarning(e,r){}reportError(e,r){this.errorCount+=1,this.stdout.write(`${et(this.configuration,"\u27A4","redBright")} ${this.formatNameWithHyperlink(e)}: ${r}
+`)}reportProgress(e){let r=Promise.resolve().then(async()=>{for await(let{}of e);}),i=()=>{};return te(N({},r),{stop:i})}reportJson(e){}async finalize(){this.errorCount>0&&(this.stdout.write(`
+`),this.stdout.write(`${et(this.configuration,"\u27A4","redBright")} Errors happened when preparing the environment required to run this command.
+`),this.suggestInstall&&this.stdout.write(`${et(this.configuration,"\u27A4","redBright")} This might be caused by packages being missing from the lockfile, in which case running "yarn install" might help.
+`))}formatNameWithHyperlink(e){return lD(e,{configuration:this.configuration,json:!1})}};var h0=ge(require("crypto")),i$=ge(_7()),p0=ge(t$()),n$=ge(ag()),s$=ge(ti()),rF=ge(require("util")),iF=ge(require("v8")),nF=ge(require("zlib"));var z1e=[[/^(git(?:\+(?:https|ssh))?:\/\/.*(?:\.git)?)#(.*)$/,(t,e,r,i)=>`${r}#commit=${i}`],[/^https:\/\/((?:[^/]+?)@)?codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/([0-9a-f]+)$/,(t,e,r="",i,n)=>`https://${r}github.com/${i}.git#commit=${n}`],[/^https:\/\/((?:[^/]+?)@)?github\.com\/([^/]+\/[^/]+?)(?:\.git)?#([0-9a-f]+)$/,(t,e,r="",i,n)=>`https://${r}github.com/${i}.git#commit=${n}`],[/^https?:\/\/[^/]+\/(?:[^/]+\/)*(?:@.+(?:\/|(?:%2f)))?([^/]+)\/(?:-|download)\/\1-[^/]+\.tgz(?:#|$)/,t=>`npm:${t}`],[/^https:\/\/npm\.pkg\.github\.com\/download\/(?:@[^/]+)\/(?:[^/]+)\/(?:[^/]+)\/(?:[0-9a-f]+)(?:#|$)/,t=>`npm:${t}`],[/^https:\/\/npm\.fontawesome\.com\/(?:@[^/]+)\/([^/]+)\/-\/([^/]+)\/\1-\2.tgz(?:#|$)/,t=>`npm:${t}`],[/^https?:\/\/(?:[^\\.]+)\.jfrog\.io\/.*\/(@[^/]+)\/([^/]+)\/-\/\1\/\2-(?:[.\d\w-]+)\.tgz(?:#|$)/,(t,e)=>fw({protocol:"npm:",source:null,selector:t,params:{__archiveUrl:e}})],[/^[^/]+\.tgz#[0-9a-f]+$/,t=>`npm:${t}`]],$R=class{constructor(e){this.resolver=e;this.resolutions=null}async setup(e,{report:r}){let i=k.join(e.cwd,e.configuration.get("lockfileFilename"));if(!K.existsSync(i))return;let n=await K.readFilePromise(i,"utf8"),s=Qi(n);if(Object.prototype.hasOwnProperty.call(s,"__metadata"))return;let o=this.resolutions=new Map;for(let a of Object.keys(s)){let l=pd(a);if(!l){r.reportWarning($.YARN_IMPORT_FAILED,`Failed to parse the string "${a}" into a proper descriptor`);continue}fo(l.range)&&(l=rr(l,`npm:${l.range}`));let{version:c,resolved:u}=s[a];if(!u)continue;let g;for(let[h,p]of z1e){let m=u.match(h);if(m){g=p(c,...m);break}}if(!g){r.reportWarning($.YARN_IMPORT_FAILED,`${sr(e.configuration,l)}: Only some patterns can be imported from legacy lockfiles (not "${u}")`);continue}let f=l;try{let h=Kg(l.range),p=pd(h.selector,!0);p&&(f=p)}catch{}o.set(l.descriptorHash,cn(f,g))}}supportsDescriptor(e,r){return this.resolutions?this.resolutions.has(e.descriptorHash):!1}supportsLocator(e,r){return!1}shouldPersistResolution(e,r){throw new Error("Assertion failed: This resolver doesn't support resolving locators to packages")}bindDescriptor(e,r,i){return e}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){if(!this.resolutions)throw new Error("Assertion failed: The resolution store should have been setup");let n=this.resolutions.get(e.descriptorHash);if(!n)throw new Error("Assertion failed: The resolution should have been registered");return await this.resolver.getCandidates(nx(n),r,i)}async getSatisfying(e,r,i){return null}async resolve(e,r){throw new Error("Assertion failed: This resolver doesn't support resolving locators to packages")}};var eF=class{constructor(e){this.resolver=e}supportsDescriptor(e,r){return!!(r.project.storedResolutions.get(e.descriptorHash)||r.project.originalPackages.has(uw(e).locatorHash))}supportsLocator(e,r){return!!(r.project.originalPackages.has(e.locatorHash)&&!r.project.lockfileNeedsRefresh)}shouldPersistResolution(e,r){throw new Error("The shouldPersistResolution method shouldn't be called on the lockfile resolver, which would always answer yes")}bindDescriptor(e,r,i){return e}getResolutionDependencies(e,r){return this.resolver.getResolutionDependencies(e,r)}async getCandidates(e,r,i){let n=i.project.originalPackages.get(uw(e).locatorHash);if(n)return[n];let s=i.project.storedResolutions.get(e.descriptorHash);if(!s)throw new Error("Expected the resolution to have been successful - resolution not found");if(n=i.project.originalPackages.get(s),!n)throw new Error("Expected the resolution to have been successful - package not found");return[n]}async getSatisfying(e,r,i){return null}async resolve(e,r){let i=r.project.originalPackages.get(e.locatorHash);if(!i)throw new Error("The lockfile resolver isn't meant to resolve packages - they should already have been stored into a cache");return i}};var tF=class{constructor(e){this.resolver=e}supportsDescriptor(e,r){return this.resolver.supportsDescriptor(e,r)}supportsLocator(e,r){return this.resolver.supportsLocator(e,r)}shouldPersistResolution(e,r){return this.resolver.shouldPersistResolution(e,r)}bindDescriptor(e,r,i){return this.resolver.bindDescriptor(e,r,i)}getResolutionDependencies(e,r){return this.resolver.getResolutionDependencies(e,r)}async getCandidates(e,r,i){throw new ct($.MISSING_LOCKFILE_ENTRY,`This package doesn't seem to be present in your lockfile; run "yarn install" to update the lockfile`)}async getSatisfying(e,r,i){throw new ct($.MISSING_LOCKFILE_ENTRY,`This package doesn't seem to be present in your lockfile; run "yarn install" to update the lockfile`)}async resolve(e,r){throw new ct($.MISSING_LOCKFILE_ENTRY,`This package doesn't seem to be present in your lockfile; run "yarn install" to update the lockfile`)}};var pi=class extends Ji{reportCacheHit(e){}reportCacheMiss(e){}startSectionSync(e,r){return r()}async startSectionPromise(e,r){return await r()}startTimerSync(e,r,i){return(typeof r=="function"?r:i)()}async startTimerPromise(e,r,i){return await(typeof r=="function"?r:i)()}async startCacheReport(e){return await e()}reportSeparator(){}reportInfo(e,r){}reportWarning(e,r){}reportError(e,r){}reportProgress(e){let r=Promise.resolve().then(async()=>{for await(let{}of e);}),i=()=>{};return te(N({},r),{stop:i})}reportJson(e){}async finalize(){}};var r$=ge(rx());var BC=class{constructor(e,{project:r}){this.workspacesCwds=new Set;this.dependencies=new Map;this.project=r,this.cwd=e}async setup(){var s;this.manifest=(s=await At.tryFind(this.cwd))!=null?s:new At,this.relativeCwd=k.relative(this.project.cwd,this.cwd)||Me.dot;let e=this.manifest.name?this.manifest.name:Vo(null,`${this.computeCandidateName()}-${ln(this.relativeCwd).substring(0,6)}`),r=this.manifest.version?this.manifest.version:"0.0.0";this.locator=cn(e,r),this.anchoredDescriptor=rr(this.locator,`${si.protocol}${this.relativeCwd}`),this.anchoredLocator=cn(this.locator,`${si.protocol}${this.relativeCwd}`);let i=this.manifest.workspaceDefinitions.map(({pattern:o})=>o),n=await(0,r$.default)(i,{cwd:j.fromPortablePath(this.cwd),expandDirectories:!1,onlyDirectories:!0,onlyFiles:!1,ignore:["**/node_modules","**/.git","**/.yarn"]});n.sort();for(let o of n){let a=k.resolve(this.cwd,j.toPortablePath(o));K.existsSync(k.join(a,"package.json"))&&this.workspacesCwds.add(a)}}accepts(e){var o;let r=e.indexOf(":"),i=r!==-1?e.slice(0,r+1):null,n=r!==-1?e.slice(r+1):e;if(i===si.protocol&&k.normalize(n)===this.relativeCwd||i===si.protocol&&(n==="*"||n==="^"||n==="~"))return!0;let s=fo(n);return s?i===si.protocol?s.test((o=this.manifest.version)!=null?o:"0.0.0"):this.project.configuration.get("enableTransparentWorkspaces")&&this.manifest.version!==null?s.test(this.manifest.version):!1:!1}computeCandidateName(){return this.cwd===this.project.cwd?"root-workspace":`${k.basename(this.cwd)}`||"unnamed-workspace"}getRecursiveWorkspaceDependencies({dependencies:e=At.hardDependencies}={}){let r=new Set,i=n=>{for(let s of e)for(let o of n.manifest[s].values()){let a=this.project.tryWorkspaceByDescriptor(o);a===null||r.has(a)||(r.add(a),i(a))}};return i(this),r}getRecursiveWorkspaceDependents({dependencies:e=At.hardDependencies}={}){let r=new Set,i=n=>{for(let s of this.project.workspaces)e.some(a=>[...s.manifest[a].values()].some(l=>{let c=this.project.tryWorkspaceByDescriptor(l);return c!==null&&hd(c.anchoredLocator,n.anchoredLocator)}))&&!r.has(s)&&(r.add(s),i(s))};return i(this),r}getRecursiveWorkspaceChildren(){let e=[];for(let r of this.workspacesCwds){let i=this.project.workspacesByCwd.get(r);i&&e.push(i,...i.getRecursiveWorkspaceChildren())}return e}async persistManifest(){let e={};this.manifest.exportTo(e);let r=k.join(this.cwd,At.fileName),i=`${JSON.stringify(e,null,this.manifest.indent)}
+`;await K.changeFilePromise(r,i,{automaticNewlines:!0}),this.manifest.raw=e}};var o$=6,_1e=1,V1e=/ *, */g,a$=/\/$/,X1e=32,Z1e=(0,rF.promisify)(nF.default.gzip),$1e=(0,rF.promisify)(nF.default.gunzip),di;(function(r){r.UpdateLockfile="update-lockfile",r.SkipBuild="skip-build"})(di||(di={}));var sF={restoreInstallersCustomData:["installersCustomData"],restoreResolutions:["accessibleLocators","conditionalLocators","disabledLocators","optionalBuilds","storedDescriptors","storedResolutions","storedPackages","lockFileChecksum"],restoreBuildState:["storedBuildState"]},A$=t=>ln(`${_1e}`,t),ze=class{constructor(e,{configuration:r}){this.resolutionAliases=new Map;this.workspaces=[];this.workspacesByCwd=new Map;this.workspacesByIdent=new Map;this.storedResolutions=new Map;this.storedDescriptors=new Map;this.storedPackages=new Map;this.storedChecksums=new Map;this.storedBuildState=new Map;this.accessibleLocators=new Set;this.conditionalLocators=new Set;this.disabledLocators=new Set;this.originalPackages=new Map;this.optionalBuilds=new Set;this.lockfileNeedsRefresh=!1;this.peerRequirements=new Map;this.installersCustomData=new Map;this.lockFileChecksum=null;this.installStateChecksum=null;this.configuration=r,this.cwd=e}static async find(e,r){var p,m,y;if(!e.projectCwd)throw new Pe(`No project found in ${r}`);let i=e.projectCwd,n=r,s=null;for(;s!==e.projectCwd;){if(s=n,K.existsSync(k.join(s,Pt.manifest))){i=s;break}n=k.dirname(s)}let o=new ze(e.projectCwd,{configuration:e});(p=ye.telemetry)==null||p.reportProject(o.cwd),await o.setupResolutions(),await o.setupWorkspaces(),(m=ye.telemetry)==null||m.reportWorkspaceCount(o.workspaces.length),(y=ye.telemetry)==null||y.reportDependencyCount(o.workspaces.reduce((Q,S)=>Q+S.manifest.dependencies.size+S.manifest.devDependencies.size,0));let a=o.tryWorkspaceByCwd(i);if(a)return{project:o,workspace:a,locator:a.anchoredLocator};let l=await o.findLocatorForLocation(`${i}/`,{strict:!0});if(l)return{project:o,locator:l,workspace:null};let c=et(e,o.cwd,Ge.PATH),u=et(e,k.relative(o.cwd,i),Ge.PATH),g=`- If ${c} isn't intended to be a project, remove any yarn.lock and/or package.json file there.`,f=`- If ${c} is intended to be a project, it might be that you forgot to list ${u} in its workspace configuration.`,h=`- Finally, if ${c} is fine and you intend ${u} to be treated as a completely separate project (not even a workspace), create an empty yarn.lock file in it.`;throw new Pe(`The nearest package directory (${et(e,i,Ge.PATH)}) doesn't seem to be part of the project declared in ${et(e,o.cwd,Ge.PATH)}.
+
+${[g,f,h].join(`
+`)}`)}async setupResolutions(){var i;this.storedResolutions=new Map,this.storedDescriptors=new Map,this.storedPackages=new Map,this.lockFileChecksum=null;let e=k.join(this.cwd,this.configuration.get("lockfileFilename")),r=this.configuration.get("defaultLanguageName");if(K.existsSync(e)){let n=await K.readFilePromise(e,"utf8");this.lockFileChecksum=A$(n);let s=Qi(n);if(s.__metadata){let o=s.__metadata.version,a=s.__metadata.cacheKey;this.lockfileNeedsRefresh=o<o$;for(let l of Object.keys(s)){if(l==="__metadata")continue;let c=s[l];if(typeof c.resolution=="undefined")throw new Error(`Assertion failed: Expected the lockfile entry to have a resolution field (${l})`);let u=Mc(c.resolution,!0),g=new At;g.load(c,{yamlCompatibilityMode:!0});let f=g.version,h=g.languageName||r,p=c.linkType.toUpperCase(),m=(i=c.conditions)!=null?i:null,y=g.dependencies,Q=g.peerDependencies,S=g.dependenciesMeta,x=g.peerDependenciesMeta,M=g.bin;if(c.checksum!=null){let U=typeof a!="undefined"&&!c.checksum.includes("/")?`${a}/${c.checksum}`:c.checksum;this.storedChecksums.set(u.locatorHash,U)}let Y=te(N({},u),{version:f,languageName:h,linkType:p,conditions:m,dependencies:y,peerDependencies:Q,dependenciesMeta:S,peerDependenciesMeta:x,bin:M});this.originalPackages.set(Y.locatorHash,Y);for(let U of l.split(V1e)){let J=nl(U);this.storedDescriptors.set(J.descriptorHash,J),this.storedResolutions.set(J.descriptorHash,u.locatorHash)}}}}}async setupWorkspaces(){this.workspaces=[],this.workspacesByCwd=new Map,this.workspacesByIdent=new Map;let e=[this.cwd];for(;e.length>0;){let r=e;e=[];for(let i of r){if(this.workspacesByCwd.has(i))continue;let n=await this.addWorkspace(i),s=this.storedPackages.get(n.anchoredLocator.locatorHash);s&&(n.dependencies=s.dependencies);for(let o of n.workspacesCwds)e.push(o)}}}async addWorkspace(e){let r=new BC(e,{project:this});await r.setup();let i=this.workspacesByIdent.get(r.locator.identHash);if(typeof i!="undefined")throw new Error(`Duplicate workspace name ${gi(this.configuration,r.locator)}: ${j.fromPortablePath(e)} conflicts with ${j.fromPortablePath(i.cwd)}`);return this.workspaces.push(r),this.workspacesByCwd.set(e,r),this.workspacesByIdent.set(r.locator.identHash,r),r}get topLevelWorkspace(){return this.getWorkspaceByCwd(this.cwd)}tryWorkspaceByCwd(e){k.isAbsolute(e)||(e=k.resolve(this.cwd,e)),e=k.normalize(e).replace(/\/+$/,"");let r=this.workspacesByCwd.get(e);return r||null}getWorkspaceByCwd(e){let r=this.tryWorkspaceByCwd(e);if(!r)throw new Error(`Workspace not found (${e})`);return r}tryWorkspaceByFilePath(e){let r=null;for(let i of this.workspaces)k.relative(i.cwd,e).startsWith("../")||r&&r.cwd.length>=i.cwd.length||(r=i);return r||null}getWorkspaceByFilePath(e){let r=this.tryWorkspaceByFilePath(e);if(!r)throw new Error(`Workspace not found (${e})`);return r}tryWorkspaceByIdent(e){let r=this.workspacesByIdent.get(e.identHash);return typeof r=="undefined"?null:r}getWorkspaceByIdent(e){let r=this.tryWorkspaceByIdent(e);if(!r)throw new Error(`Workspace not found (${gi(this.configuration,e)})`);return r}tryWorkspaceByDescriptor(e){let r=this.tryWorkspaceByIdent(e);return r===null||(il(e)&&(e=ud(e)),!r.accepts(e.range))?null:r}getWorkspaceByDescriptor(e){let r=this.tryWorkspaceByDescriptor(e);if(r===null)throw new Error(`Workspace not found (${sr(this.configuration,e)})`);return r}tryWorkspaceByLocator(e){let r=this.tryWorkspaceByIdent(e);return r===null||(Xo(e)&&(e=gd(e)),r.locator.locatorHash!==e.locatorHash&&r.anchoredLocator.locatorHash!==e.locatorHash)?null:r}getWorkspaceByLocator(e){let r=this.tryWorkspaceByLocator(e);if(!r)throw new Error(`Workspace not found (${Bt(this.configuration,e)})`);return r}refreshWorkspaceDependencies(){for(let e of this.workspaces){let r=this.storedPackages.get(e.anchoredLocator.locatorHash);if(!r)throw new Error(`Assertion failed: Expected workspace ${Cd(this.configuration,e)} (${et(this.configuration,k.join(e.cwd,Pt.manifest),Ge.PATH)}) to have been resolved. Run "yarn install" to update the lockfile`);e.dependencies=new Map(r.dependencies)}}forgetResolution(e){let r=n=>{this.storedResolutions.delete(n),this.storedDescriptors.delete(n)},i=n=>{this.originalPackages.delete(n),this.storedPackages.delete(n),this.accessibleLocators.delete(n)};if("descriptorHash"in e){let n=this.storedResolutions.get(e.descriptorHash);r(e.descriptorHash);let s=new Set(this.storedResolutions.values());typeof n!="undefined"&&!s.has(n)&&i(n)}if("locatorHash"in e){i(e.locatorHash);for(let[n,s]of this.storedResolutions)s===e.locatorHash&&r(n)}}forgetTransientResolutions(){let e=this.configuration.makeResolver();for(let r of this.originalPackages.values()){let i;try{i=e.shouldPersistResolution(r,{project:this,resolver:e})}catch{i=!1}i||this.forgetResolution(r)}}forgetVirtualResolutions(){for(let e of this.storedPackages.values())for(let[r,i]of e.dependencies)il(i)&&e.dependencies.set(r,ud(i))}getDependencyMeta(e,r){let i={},s=this.topLevelWorkspace.manifest.dependenciesMeta.get(Ot(e));if(!s)return i;let o=s.get(null);if(o&&Object.assign(i,o),r===null||!s$.default.valid(r))return i;for(let[a,l]of s)a!==null&&a===r&&Object.assign(i,l);return i}async findLocatorForLocation(e,{strict:r=!1}={}){let i=new pi,n=this.configuration.getLinkers(),s={project:this,report:i};for(let o of n){let a=await o.findPackageLocator(e,s);if(a){if(r&&(await o.findPackageLocation(a,s)).replace(a$,"")!==e.replace(a$,""))continue;return a}}return null}async resolveEverything(e){if(!this.workspacesByCwd||!this.workspacesByIdent)throw new Error("Workspaces must have been setup before calling this function");this.forgetVirtualResolutions(),e.lockfileOnly||this.forgetTransientResolutions();let r=e.resolver||this.configuration.makeResolver(),i=new $R(r);await i.setup(this,{report:e.report});let n=e.lockfileOnly?[new tF(r)]:[i,r],s=new wd([new eF(r),...n]),o=this.configuration.makeFetcher(),a=e.lockfileOnly?{project:this,report:e.report,resolver:s}:{project:this,report:e.report,resolver:s,fetchOptions:{project:this,cache:e.cache,checksums:this.storedChecksums,report:e.report,fetcher:o,cacheOptions:{mirrorWriteOnly:!0}}},l=new Map,c=new Map,u=new Map,g=new Map,f=new Map,h=new Map,p=this.topLevelWorkspace.anchoredLocator,m=new Set,y=[],Q=Ex(),S=this.configuration.getSupportedArchitectures();await e.report.startProgressPromise(Ji.progressViaTitle(),async ee=>{let Z=async T=>{let L=await Pg(async()=>await s.resolve(T,a),qe=>`${Bt(this.configuration,T)}: ${qe}`);if(!hd(T,L))throw new Error(`Assertion failed: The locator cannot be changed by the resolver (went from ${Bt(this.configuration,T)} to ${Bt(this.configuration,L)})`);g.set(L.locatorHash,L);let Ee=this.configuration.normalizePackage(L);for(let[qe,re]of Ee.dependencies){let se=await this.configuration.reduceHook(he=>he.reduceDependency,re,this,Ee,re,{resolver:s,resolveOptions:a});if(!fd(re,se))throw new Error("Assertion failed: The descriptor ident cannot be changed through aliases");let Qe=s.bindDescriptor(se,T,a);Ee.dependencies.set(qe,Qe)}let we=co([...Ee.dependencies.values()].map(qe=>Ae(qe)));return y.push(we),we.catch(()=>{}),c.set(Ee.locatorHash,Ee),Ee},A=async T=>{let L=f.get(T.locatorHash);if(typeof L!="undefined")return L;let Ee=Promise.resolve().then(()=>Z(T));return f.set(T.locatorHash,Ee),Ee},ne=async(T,L)=>{let Ee=await Ae(L);return l.set(T.descriptorHash,T),u.set(T.descriptorHash,Ee.locatorHash),Ee},le=async T=>{ee.setTitle(sr(this.configuration,T));let L=this.resolutionAliases.get(T.descriptorHash);if(typeof L!="undefined")return ne(T,this.storedDescriptors.get(L));let Ee=s.getResolutionDependencies(T,a),we=new Map(await co(Ee.map(async se=>{let Qe=s.bindDescriptor(se,p,a),he=await Ae(Qe);return m.add(he.locatorHash),[se.descriptorHash,he]}))),re=(await Pg(async()=>await s.getCandidates(T,we,a),se=>`${sr(this.configuration,T)}: ${se}`))[0];if(typeof re=="undefined")throw new Error(`${sr(this.configuration,T)}: No candidates found`);return l.set(T.descriptorHash,T),u.set(T.descriptorHash,re.locatorHash),A(re)},Ae=T=>{let L=h.get(T.descriptorHash);if(typeof L!="undefined")return L;l.set(T.descriptorHash,T);let Ee=Promise.resolve().then(()=>le(T));return h.set(T.descriptorHash,Ee),Ee};for(let T of this.workspaces){let L=T.anchoredDescriptor;y.push(Ae(L))}for(;y.length>0;){let T=[...y];y.length=0,await co(T)}});let x=new Set(this.resolutionAliases.values()),M=new Set(c.keys()),Y=new Set,U=new Map;eUe({project:this,report:e.report,accessibleLocators:Y,volatileDescriptors:x,optionalBuilds:M,peerRequirements:U,allDescriptors:l,allResolutions:u,allPackages:c});for(let ee of m)M.delete(ee);for(let ee of x)l.delete(ee),u.delete(ee);let J=new Set,W=new Set;for(let ee of c.values())ee.conditions!=null&&(!M.has(ee.locatorHash)||(pw(ee,S)||(pw(ee,Q)&&e.report.reportWarningOnce($.GHOST_ARCHITECTURE,`${Bt(this.configuration,ee)}: Your current architecture (${process.platform}-${process.arch}) is supported by this package, but is missing from the ${et(this.configuration,"supportedArchitectures",Di.SETTING)} setting`),W.add(ee.locatorHash)),J.add(ee.locatorHash)));this.storedResolutions=u,this.storedDescriptors=l,this.storedPackages=c,this.accessibleLocators=Y,this.conditionalLocators=J,this.disabledLocators=W,this.originalPackages=g,this.optionalBuilds=M,this.peerRequirements=U,this.refreshWorkspaceDependencies()}async fetchEverything({cache:e,report:r,fetcher:i,mode:n}){let s={mockedPackages:this.disabledLocators,unstablePackages:this.conditionalLocators},o=i||this.configuration.makeFetcher(),a={checksums:this.storedChecksums,project:this,cache:e,fetcher:o,report:r,cacheOptions:s},l=Array.from(new Set(xn(this.storedResolutions.values(),[f=>{let h=this.storedPackages.get(f);if(!h)throw new Error("Assertion failed: The locator should have been registered");return Ps(h)}])));n===di.UpdateLockfile&&(l=l.filter(f=>!this.storedChecksums.has(f)));let c=!1,u=Ji.progressViaCounter(l.length);r.reportProgress(u);let g=(0,n$.default)(X1e);if(await r.startCacheReport(async()=>{await co(l.map(f=>g(async()=>{let h=this.storedPackages.get(f);if(!h)throw new Error("Assertion failed: The locator should have been registered");if(Xo(h))return;let p;try{p=await o.fetch(h,a)}catch(m){m.message=`${Bt(this.configuration,h)}: ${m.message}`,r.reportExceptionOnce(m),c=m;return}p.checksum!=null?this.storedChecksums.set(h.locatorHash,p.checksum):this.storedChecksums.delete(h.locatorHash),p.releaseFs&&p.releaseFs()}).finally(()=>{u.tick()})))}),c)throw c}async linkEverything({cache:e,report:r,fetcher:i,mode:n}){var A,ne,le;let s={mockedPackages:this.disabledLocators,unstablePackages:this.conditionalLocators,skipIntegrityCheck:!0},o=i||this.configuration.makeFetcher(),a={checksums:this.storedChecksums,project:this,cache:e,fetcher:o,report:r,skipIntegrityCheck:!0,cacheOptions:s},l=this.configuration.getLinkers(),c={project:this,report:r},u=new Map(l.map(Ae=>{let T=Ae.makeInstaller(c),L=T.getCustomDataKey(),Ee=this.installersCustomData.get(L);return typeof Ee!="undefined"&&T.attachCustomData(Ee),[Ae,T]})),g=new Map,f=new Map,h=new Map,p=new Map(await co([...this.accessibleLocators].map(async Ae=>{let T=this.storedPackages.get(Ae);if(!T)throw new Error("Assertion failed: The locator should have been registered");return[Ae,await o.fetch(T,a)]}))),m=[];for(let Ae of this.accessibleLocators){let T=this.storedPackages.get(Ae);if(typeof T=="undefined")throw new Error("Assertion failed: The locator should have been registered");let L=p.get(T.locatorHash);if(typeof L=="undefined")throw new Error("Assertion failed: The fetch result should have been registered");let Ee=[],we=re=>{Ee.push(re)},qe=this.tryWorkspaceByLocator(T);if(qe!==null){let re=[],{scripts:se}=qe.manifest;for(let he of["preinstall","install","postinstall"])se.has(he)&&re.push([As.SCRIPT,he]);try{for(let[he,Fe]of u)if(he.supportsPackage(T,c)&&(await Fe.installPackage(T,L,{holdFetchResult:we})).buildDirective!==null)throw new Error("Assertion failed: Linkers can't return build directives for workspaces; this responsibility befalls to the Yarn core")}finally{Ee.length===0?(A=L.releaseFs)==null||A.call(L):m.push(co(Ee).catch(()=>{}).then(()=>{var he;(he=L.releaseFs)==null||he.call(L)}))}let Qe=k.join(L.packageFs.getRealPath(),L.prefixPath);f.set(T.locatorHash,Qe),!Xo(T)&&re.length>0&&h.set(T.locatorHash,{directives:re,buildLocations:[Qe]})}else{let re=l.find(he=>he.supportsPackage(T,c));if(!re)throw new ct($.LINKER_NOT_FOUND,`${Bt(this.configuration,T)} isn't supported by any available linker`);let se=u.get(re);if(!se)throw new Error("Assertion failed: The installer should have been registered");let Qe;try{Qe=await se.installPackage(T,L,{holdFetchResult:we})}finally{Ee.length===0?(ne=L.releaseFs)==null||ne.call(L):m.push(co(Ee).then(()=>{}).then(()=>{var he;(he=L.releaseFs)==null||he.call(L)}))}g.set(T.locatorHash,re),f.set(T.locatorHash,Qe.packageLocation),Qe.buildDirective&&Qe.buildDirective.length>0&&Qe.packageLocation&&h.set(T.locatorHash,{directives:Qe.buildDirective,buildLocations:[Qe.packageLocation]})}}let y=new Map;for(let Ae of this.accessibleLocators){let T=this.storedPackages.get(Ae);if(!T)throw new Error("Assertion failed: The locator should have been registered");let L=this.tryWorkspaceByLocator(T)!==null,Ee=async(we,qe)=>{let re=f.get(T.locatorHash);if(typeof re=="undefined")throw new Error(`Assertion failed: The package (${Bt(this.configuration,T)}) should have been registered`);let se=[];for(let Qe of T.dependencies.values()){let he=this.storedResolutions.get(Qe.descriptorHash);if(typeof he=="undefined")throw new Error(`Assertion failed: The resolution (${sr(this.configuration,Qe)}, from ${Bt(this.configuration,T)})should have been registered`);let Fe=this.storedPackages.get(he);if(typeof Fe=="undefined")throw new Error(`Assertion failed: The package (${he}, resolved from ${sr(this.configuration,Qe)}) should have been registered`);let Ue=this.tryWorkspaceByLocator(Fe)===null?g.get(he):null;if(typeof Ue=="undefined")throw new Error(`Assertion failed: The package (${he}, resolved from ${sr(this.configuration,Qe)}) should have been registered`);Ue===we||Ue===null?f.get(Fe.locatorHash)!==null&&se.push([Qe,Fe]):!L&&re!==null&&kg(y,he).push(re)}re!==null&&await qe.attachInternalDependencies(T,se)};if(L)for(let[we,qe]of u)we.supportsPackage(T,c)&&await Ee(we,qe);else{let we=g.get(T.locatorHash);if(!we)throw new Error("Assertion failed: The linker should have been found");let qe=u.get(we);if(!qe)throw new Error("Assertion failed: The installer should have been registered");await Ee(we,qe)}}for(let[Ae,T]of y){let L=this.storedPackages.get(Ae);if(!L)throw new Error("Assertion failed: The package should have been registered");let Ee=g.get(L.locatorHash);if(!Ee)throw new Error("Assertion failed: The linker should have been found");let we=u.get(Ee);if(!we)throw new Error("Assertion failed: The installer should have been registered");await we.attachExternalDependents(L,T)}let Q=new Map;for(let Ae of u.values()){let T=await Ae.finalizeInstall();for(let L of(le=T==null?void 0:T.records)!=null?le:[])h.set(L.locatorHash,{directives:L.buildDirective,buildLocations:L.buildLocations});typeof(T==null?void 0:T.customData)!="undefined"&&Q.set(Ae.getCustomDataKey(),T.customData)}if(this.installersCustomData=Q,await co(m),n===di.SkipBuild)return;let S=new Set(this.storedPackages.keys()),x=new Set(h.keys());for(let Ae of x)S.delete(Ae);let M=(0,h0.createHash)("sha512");M.update(process.versions.node),await this.configuration.triggerHook(Ae=>Ae.globalHashGeneration,this,Ae=>{M.update("\0"),M.update(Ae)});let Y=M.digest("hex"),U=new Map,J=Ae=>{let T=U.get(Ae.locatorHash);if(typeof T!="undefined")return T;let L=this.storedPackages.get(Ae.locatorHash);if(typeof L=="undefined")throw new Error("Assertion failed: The package should have been registered");let Ee=(0,h0.createHash)("sha512");Ee.update(Ae.locatorHash),U.set(Ae.locatorHash,"<recursive>");for(let we of L.dependencies.values()){let qe=this.storedResolutions.get(we.descriptorHash);if(typeof qe=="undefined")throw new Error(`Assertion failed: The resolution (${sr(this.configuration,we)}) should have been registered`);let re=this.storedPackages.get(qe);if(typeof re=="undefined")throw new Error("Assertion failed: The package should have been registered");Ee.update(J(re))}return T=Ee.digest("hex"),U.set(Ae.locatorHash,T),T},W=(Ae,T)=>{let L=(0,h0.createHash)("sha512");L.update(Y),L.update(J(Ae));for(let Ee of T)L.update(Ee);return L.digest("hex")},ee=new Map,Z=!1;for(;x.size>0;){let Ae=x.size,T=[];for(let L of x){let Ee=this.storedPackages.get(L);if(!Ee)throw new Error("Assertion failed: The package should have been registered");let we=!0;for(let se of Ee.dependencies.values()){let Qe=this.storedResolutions.get(se.descriptorHash);if(!Qe)throw new Error(`Assertion failed: The resolution (${sr(this.configuration,se)}) should have been registered`);if(x.has(Qe)){we=!1;break}}if(!we)continue;x.delete(L);let qe=h.get(Ee.locatorHash);if(!qe)throw new Error("Assertion failed: The build directive should have been registered");let re=W(Ee,qe.buildLocations);if(this.storedBuildState.get(Ee.locatorHash)===re){ee.set(Ee.locatorHash,re);continue}Z||(await this.persistInstallStateFile(),Z=!0),this.storedBuildState.has(Ee.locatorHash)?r.reportInfo($.MUST_REBUILD,`${Bt(this.configuration,Ee)} must be rebuilt because its dependency tree changed`):r.reportInfo($.MUST_BUILD,`${Bt(this.configuration,Ee)} must be built because it never has been before or the last one failed`);for(let se of qe.buildLocations){if(!k.isAbsolute(se))throw new Error(`Assertion failed: Expected the build location to be absolute (not ${se})`);T.push((async()=>{for(let[Qe,he]of qe.directives){let Fe=`# This file contains the result of Yarn building a package (${Ps(Ee)})
+`;switch(Qe){case As.SCRIPT:Fe+=`# Script name: ${he}
+`;break;case As.SHELLCODE:Fe+=`# Script code: ${he}
+`;break}let Ue=null;if(!await K.mktempPromise(async ve=>{let pe=k.join(ve,"build.log"),{stdout:X,stderr:be}=this.configuration.getSubprocessStreams(pe,{header:Fe,prefix:Bt(this.configuration,Ee),report:r}),ce;try{switch(Qe){case As.SCRIPT:ce=await aB(Ee,he,[],{cwd:se,project:this,stdin:Ue,stdout:X,stderr:be});break;case As.SHELLCODE:ce=await uD(Ee,he,[],{cwd:se,project:this,stdin:Ue,stdout:X,stderr:be});break}}catch(gt){be.write(gt.stack),ce=1}if(X.end(),be.end(),ce===0)return ee.set(Ee.locatorHash,re),!0;K.detachTemp(ve);let fe=`${Bt(this.configuration,Ee)} couldn't be built successfully (exit code ${et(this.configuration,ce,Ge.NUMBER)}, logs can be found here: ${et(this.configuration,pe,Ge.PATH)})`;return this.optionalBuilds.has(Ee.locatorHash)?(r.reportInfo($.BUILD_FAILED,fe),ee.set(Ee.locatorHash,re),!0):(r.reportError($.BUILD_FAILED,fe),!1)}))return}})())}}if(await co(T),Ae===x.size){let L=Array.from(x).map(Ee=>{let we=this.storedPackages.get(Ee);if(!we)throw new Error("Assertion failed: The package should have been registered");return Bt(this.configuration,we)}).join(", ");r.reportError($.CYCLIC_DEPENDENCIES,`Some packages have circular dependencies that make their build order unsatisfiable - as a result they won't be built (affected packages are: ${L})`);break}}this.storedBuildState=ee}async install(e){var a,l;let r=this.configuration.get("nodeLinker");(a=ye.telemetry)==null||a.reportInstall(r),await e.report.startTimerPromise("Project validation",{skipIfEmpty:!0},async()=>{await this.configuration.triggerHook(c=>c.validateProject,this,{reportWarning:e.report.reportWarning.bind(e.report),reportError:e.report.reportError.bind(e.report)})});for(let c of this.configuration.packageExtensions.values())for(let[,u]of c)for(let g of u)g.status=qi.Inactive;let i=k.join(this.cwd,this.configuration.get("lockfileFilename")),n=null;if(e.immutable)try{n=await K.readFilePromise(i,"utf8")}catch(c){throw c.code==="ENOENT"?new ct($.FROZEN_LOCKFILE_EXCEPTION,"The lockfile would have been created by this install, which is explicitly forbidden."):c}await e.report.startTimerPromise("Resolution step",async()=>{await this.resolveEverything(e)}),await e.report.startTimerPromise("Post-resolution validation",{skipIfEmpty:!0},async()=>{for(let[,c]of this.configuration.packageExtensions)for(let[,u]of c)for(let g of u)if(g.userProvided){let f=et(this.configuration,g,Ge.PACKAGE_EXTENSION);switch(g.status){case qi.Inactive:e.report.reportWarning($.UNUSED_PACKAGE_EXTENSION,`${f}: No matching package in the dependency tree; you may not need this rule anymore.`);break;case qi.Redundant:e.report.reportWarning($.REDUNDANT_PACKAGE_EXTENSION,`${f}: This rule seems redundant when applied on the original package; the extension may have been applied upstream.`);break}}if(n!==null){let c=$l(n,this.generateLockfile());if(c!==n){let u=(0,i$.structuredPatch)(i,i,n,c);e.report.reportSeparator();for(let g of u.hunks){e.report.reportInfo(null,`@@ -${g.oldStart},${g.oldLines} +${g.newStart},${g.newLines} @@`);for(let f of g.lines)f.startsWith("+")?e.report.reportError($.FROZEN_LOCKFILE_EXCEPTION,et(this.configuration,f,Ge.ADDED)):f.startsWith("-")?e.report.reportError($.FROZEN_LOCKFILE_EXCEPTION,et(this.configuration,f,Ge.REMOVED)):e.report.reportInfo(null,et(this.configuration,f,"grey"))}throw e.report.reportSeparator(),new ct($.FROZEN_LOCKFILE_EXCEPTION,"The lockfile would have been modified by this install, which is explicitly forbidden.")}}});for(let c of this.configuration.packageExtensions.values())for(let[,u]of c)for(let g of u)g.userProvided&&g.status===qi.Active&&((l=ye.telemetry)==null||l.reportPackageExtension(Dc(g,Ge.PACKAGE_EXTENSION)));await e.report.startTimerPromise("Fetch step",async()=>{await this.fetchEverything(e),(typeof e.persistProject=="undefined"||e.persistProject)&&e.mode!==di.UpdateLockfile&&await this.cacheCleanup(e)});let s=e.immutable?[...new Set(this.configuration.get("immutablePatterns"))].sort():[],o=await Promise.all(s.map(async c=>lw(c,{cwd:this.cwd})));(typeof e.persistProject=="undefined"||e.persistProject)&&await this.persist(),await e.report.startTimerPromise("Link step",async()=>{if(e.mode===di.UpdateLockfile){e.report.reportWarning($.UPDATE_LOCKFILE_ONLY_SKIP_LINK,`Skipped due to ${et(this.configuration,"mode=update-lockfile",Ge.CODE)}`);return}await this.linkEverything(e);let c=await Promise.all(s.map(async u=>lw(u,{cwd:this.cwd})));for(let u=0;u<s.length;++u)o[u]!==c[u]&&e.report.reportError($.FROZEN_ARTIFACT_EXCEPTION,`The checksum for ${s[u]} has been modified by this install, which is explicitly forbidden.`)}),await this.persistInstallStateFile(),await this.configuration.triggerHook(c=>c.afterAllInstalled,this,e)}generateLockfile(){let e=new Map;for(let[n,s]of this.storedResolutions.entries()){let o=e.get(s);o||e.set(s,o=new Set),o.add(n)}let r={};r.__metadata={version:o$,cacheKey:void 0};for(let[n,s]of e.entries()){let o=this.originalPackages.get(n);if(!o)continue;let a=[];for(let f of s){let h=this.storedDescriptors.get(f);if(!h)throw new Error("Assertion failed: The descriptor should have been registered");a.push(h)}let l=a.map(f=>Pn(f)).sort().join(", "),c=new At;c.version=o.linkType===Qt.HARD?o.version:"0.0.0-use.local",c.languageName=o.languageName,c.dependencies=new Map(o.dependencies),c.peerDependencies=new Map(o.peerDependencies),c.dependenciesMeta=new Map(o.dependenciesMeta),c.peerDependenciesMeta=new Map(o.peerDependenciesMeta),c.bin=new Map(o.bin);let u,g=this.storedChecksums.get(o.locatorHash);if(typeof g!="undefined"){let f=g.indexOf("/");if(f===-1)throw new Error("Assertion failed: Expected the checksum to reference its cache key");let h=g.slice(0,f),p=g.slice(f+1);typeof r.__metadata.cacheKey=="undefined"&&(r.__metadata.cacheKey=h),h===r.__metadata.cacheKey?u=p:u=g}r[l]=te(N({},c.exportTo({},{compatibilityMode:!1})),{linkType:o.linkType.toLowerCase(),resolution:Ps(o),checksum:u,conditions:o.conditions||void 0})}return`${[`# This file is generated by running "yarn install" inside your project.
+`,`# Manual changes might be lost - proceed with caution!
+`].join("")}
+`+Na(r)}async persistLockfile(){let e=k.join(this.cwd,this.configuration.get("lockfileFilename")),r="";try{r=await K.readFilePromise(e,"utf8")}catch(s){}let i=this.generateLockfile(),n=$l(r,i);n!==r&&(await K.writeFilePromise(e,n),this.lockFileChecksum=A$(n),this.lockfileNeedsRefresh=!1)}async persistInstallStateFile(){let e=[];for(let o of Object.values(sF))e.push(...o);let r=(0,p0.default)(this,e),i=iF.default.serialize(r),n=ln(i);if(this.installStateChecksum===n)return;let s=this.configuration.get("installStatePath");await K.mkdirPromise(k.dirname(s),{recursive:!0}),await K.writeFilePromise(s,await Z1e(i)),this.installStateChecksum=n}async restoreInstallState({restoreInstallersCustomData:e=!0,restoreResolutions:r=!0,restoreBuildState:i=!0}={}){let n=this.configuration.get("installStatePath"),s;try{let o=await $1e(await K.readFilePromise(n));s=iF.default.deserialize(o),this.installStateChecksum=ln(o)}catch{r&&await this.applyLightResolution();return}e&&typeof s.installersCustomData!="undefined"&&(this.installersCustomData=s.installersCustomData),i&&Object.assign(this,(0,p0.default)(s,sF.restoreBuildState)),r&&(s.lockFileChecksum===this.lockFileChecksum?(Object.assign(this,(0,p0.default)(s,sF.restoreResolutions)),this.refreshWorkspaceDependencies()):await this.applyLightResolution())}async applyLightResolution(){await this.resolveEverything({lockfileOnly:!0,report:new pi}),await this.persistInstallStateFile()}async persist(){await this.persistLockfile();for(let e of this.workspacesByCwd.values())await e.persistManifest()}async cacheCleanup({cache:e,report:r}){let i=new Set([".gitignore"]);if(!Cx(e.cwd,this.cwd)||!await K.existsPromise(e.cwd))return;let n=this.configuration.get("preferAggregateCacheInfo"),s=0,o=null;for(let a of await K.readdirPromise(e.cwd)){if(i.has(a))continue;let l=k.resolve(e.cwd,a);e.markedFiles.has(l)||(o=a,e.immutable?r.reportError($.IMMUTABLE_CACHE,`${et(this.configuration,k.basename(l),"magenta")} appears to be unused and would be marked for deletion, but the cache is immutable`):(n?s+=1:r.reportInfo($.UNUSED_CACHE_ENTRY,`${et(this.configuration,k.basename(l),"magenta")} appears to be unused - removing`),await K.removePromise(l)))}n&&s!==0&&r.reportInfo($.UNUSED_CACHE_ENTRY,s>1?`${s} packages appeared to be unused and were removed`:`${o} appeared to be unused and was removed`),e.markedFiles.clear()}};function eUe({project:t,allDescriptors:e,allResolutions:r,allPackages:i,accessibleLocators:n=new Set,optionalBuilds:s=new Set,peerRequirements:o=new Map,volatileDescriptors:a=new Set,report:l,tolerateMissingPackages:c=!1}){var ee;let u=new Map,g=[],f=new Map,h=new Map,p=new Map,m=new Map,y=new Map,Q=new Map(t.workspaces.map(Z=>{let A=Z.anchoredLocator.locatorHash,ne=i.get(A);if(typeof ne=="undefined"){if(c)return[A,null];throw new Error("Assertion failed: The workspace should have an associated package")}return[A,cd(ne)]})),S=()=>{let Z=K.mktempSync(),A=k.join(Z,"stacktrace.log"),ne=String(g.length+1).length,le=g.map((Ae,T)=>`${`${T+1}.`.padStart(ne," ")} ${Ps(Ae)}
+`).join("");throw K.writeFileSync(A,le),K.detachTemp(Z),new ct($.STACK_OVERFLOW_RESOLUTION,`Encountered a stack overflow when resolving peer dependencies; cf ${j.fromPortablePath(A)}`)},x=Z=>{let A=r.get(Z.descriptorHash);if(typeof A=="undefined")throw new Error("Assertion failed: The resolution should have been registered");let ne=i.get(A);if(!ne)throw new Error("Assertion failed: The package could not be found");return ne},M=(Z,A,ne,{top:le,optional:Ae})=>{g.length>1e3&&S(),g.push(A);let T=Y(Z,A,ne,{top:le,optional:Ae});return g.pop(),T},Y=(Z,A,ne,{top:le,optional:Ae})=>{if(n.has(A.locatorHash))return;n.add(A.locatorHash),Ae||s.delete(A.locatorHash);let T=i.get(A.locatorHash);if(!T){if(c)return;throw new Error(`Assertion failed: The package (${Bt(t.configuration,A)}) should have been registered`)}let L=[],Ee=[],we=[],qe=[],re=[];for(let Qe of Array.from(T.dependencies.values())){if(T.peerDependencies.has(Qe.identHash)&&T.locatorHash!==le)continue;if(il(Qe))throw new Error("Assertion failed: Virtual packages shouldn't be encountered when virtualizing a branch");a.delete(Qe.descriptorHash);let he=Ae;if(!he){let be=T.dependenciesMeta.get(Ot(Qe));if(typeof be!="undefined"){let ce=be.get(null);typeof ce!="undefined"&&ce.optional&&(he=!0)}}let Fe=r.get(Qe.descriptorHash);if(!Fe){if(c)continue;throw new Error(`Assertion failed: The resolution (${sr(t.configuration,Qe)}) should have been registered`)}let Ue=Q.get(Fe)||i.get(Fe);if(!Ue)throw new Error(`Assertion failed: The package (${Fe}, resolved from ${sr(t.configuration,Qe)}) should have been registered`);if(Ue.peerDependencies.size===0){M(Qe,Ue,new Map,{top:le,optional:he});continue}let xe,ve,pe=new Set,X;Ee.push(()=>{xe=sx(Qe,A.locatorHash),ve=ox(Ue,A.locatorHash),T.dependencies.delete(Qe.identHash),T.dependencies.set(xe.identHash,xe),r.set(xe.descriptorHash,ve.locatorHash),e.set(xe.descriptorHash,xe),i.set(ve.locatorHash,ve),L.push([Ue,xe,ve])}),we.push(()=>{var be;X=new Map;for(let ce of ve.peerDependencies.values()){let fe=T.dependencies.get(ce.identHash);if(!fe&&fd(A,ce)&&(Z.identHash===A.identHash?fe=Z:(fe=rr(A,Z.range),e.set(fe.descriptorHash,fe),r.set(fe.descriptorHash,A.locatorHash),a.delete(fe.descriptorHash))),(!fe||fe.range==="missing:")&&ve.dependencies.has(ce.identHash)){ve.peerDependencies.delete(ce.identHash);continue}fe||(fe=rr(ce,"missing:")),ve.dependencies.set(fe.identHash,fe),il(fe)&&kc(p,fe.descriptorHash).add(ve.locatorHash),f.set(fe.identHash,fe),fe.range==="missing:"&&pe.add(fe.identHash),X.set(ce.identHash,(be=ne.get(ce.identHash))!=null?be:ve.locatorHash)}ve.dependencies=new Map(xn(ve.dependencies,([ce,fe])=>Ot(fe)))}),qe.push(()=>{if(!i.has(ve.locatorHash))return;let be=u.get(Ue.locatorHash);typeof be=="number"&&be>=2&&S();let ce=u.get(Ue.locatorHash),fe=typeof ce!="undefined"?ce+1:1;u.set(Ue.locatorHash,fe),M(xe,ve,X,{top:le,optional:he}),u.set(Ue.locatorHash,fe-1)}),re.push(()=>{let be=T.dependencies.get(Qe.identHash);if(typeof be=="undefined")throw new Error("Assertion failed: Expected the peer dependency to have been turned into a dependency");let ce=r.get(be.descriptorHash);if(typeof ce=="undefined")throw new Error("Assertion failed: Expected the descriptor to be registered");if(kc(y,ce).add(A.locatorHash),!!i.has(ve.locatorHash)){for(let fe of ve.peerDependencies.values()){let gt=X.get(fe.identHash);if(typeof gt=="undefined")throw new Error("Assertion failed: Expected the peer dependency ident to be registered");kg(xg(m,gt),Ot(fe)).push(ve.locatorHash)}for(let fe of pe)ve.dependencies.delete(fe)}})}for(let Qe of[...Ee,...we])Qe();let se;do{se=!0;for(let[Qe,he,Fe]of L){let Ue=xg(h,Qe.locatorHash),xe=ln(...[...Fe.dependencies.values()].map(be=>{let ce=be.range!=="missing:"?r.get(be.descriptorHash):"missing:";if(typeof ce=="undefined")throw new Error(`Assertion failed: Expected the resolution for ${sr(t.configuration,be)} to have been registered`);return ce===le?`${ce} (top)`:ce}),he.identHash),ve=Ue.get(xe);if(typeof ve=="undefined"){Ue.set(xe,he);continue}if(ve===he)continue;i.delete(Fe.locatorHash),e.delete(he.descriptorHash),r.delete(he.descriptorHash),n.delete(Fe.locatorHash);let pe=p.get(he.descriptorHash)||[],X=[T.locatorHash,...pe];p.delete(he.descriptorHash);for(let be of X){let ce=i.get(be);typeof ce!="undefined"&&(ce.dependencies.get(he.identHash).descriptorHash!==ve.descriptorHash&&(se=!1),ce.dependencies.set(he.identHash,ve))}}}while(!se);for(let Qe of[...qe,...re])Qe()};for(let Z of t.workspaces){let A=Z.anchoredLocator;a.delete(Z.anchoredDescriptor.descriptorHash),M(Z.anchoredDescriptor,A,new Map,{top:A.locatorHash,optional:!1})}var U;(function(ne){ne[ne.NotProvided=0]="NotProvided",ne[ne.NotCompatible=1]="NotCompatible"})(U||(U={}));let J=[];for(let[Z,A]of y){let ne=i.get(Z);if(typeof ne=="undefined")throw new Error("Assertion failed: Expected the root to be registered");let le=m.get(Z);if(typeof le!="undefined")for(let Ae of A){let T=i.get(Ae);if(typeof T!="undefined")for(let[L,Ee]of le){let we=An(L);if(T.peerDependencies.has(we.identHash))continue;let qe=`p${ln(Ae,L,Z).slice(0,5)}`;o.set(qe,{subject:Ae,requested:we,rootRequester:Z,allRequesters:Ee});let re=ne.dependencies.get(we.identHash);if(typeof re!="undefined"){let se=x(re),Qe=(ee=se.version)!=null?ee:"0.0.0",he=new Set;for(let Ue of Ee){let xe=i.get(Ue);if(typeof xe=="undefined")throw new Error("Assertion failed: Expected the link to be registered");let ve=xe.peerDependencies.get(we.identHash);if(typeof ve=="undefined")throw new Error("Assertion failed: Expected the ident to be registered");he.add(ve.range)}[...he].every(Ue=>{if(Ue.startsWith(si.protocol)){if(!t.tryWorkspaceByLocator(se))return!1;Ue=Ue.slice(si.protocol.length),(Ue==="^"||Ue==="~")&&(Ue="*")}return Uc(Qe,Ue)})||J.push({type:1,subject:T,requested:we,requester:ne,version:Qe,hash:qe,requirementCount:Ee.length})}else{let se=ne.peerDependenciesMeta.get(L);(se==null?void 0:se.optional)||J.push({type:0,subject:T,requested:we,requester:ne,hash:qe})}}}}let W=[Z=>Ax(Z.subject),Z=>Ot(Z.requested),Z=>`${Z.type}`];l==null||l.startSectionSync({reportFooter:()=>{l.reportWarning($.UNNAMED,`Some peer dependencies are incorrectly met; run ${et(t.configuration,"yarn explain peer-requirements <hash>",Ge.CODE)} for details, where ${et(t.configuration,"<hash>",Ge.CODE)} is the six-letter p-prefixed code`)},skipIfEmpty:!0},()=>{for(let Z of xn(J,W))switch(Z.type){case 0:l.reportWarning($.MISSING_PEER_DEPENDENCY,`${Bt(t.configuration,Z.subject)} doesn't provide ${gi(t.configuration,Z.requested)} (${et(t.configuration,Z.hash,Ge.CODE)}), requested by ${gi(t.configuration,Z.requester)}`);break;case 1:{let A=Z.requirementCount>1?"and some of its descendants request":"requests";l.reportWarning($.INCOMPATIBLE_PEER_DEPENDENCY,`${Bt(t.configuration,Z.subject)} provides ${gi(t.configuration,Z.requested)} (${et(t.configuration,Z.hash,Ge.CODE)}) with version ${dd(t.configuration,Z.version)}, which doesn't satisfy what ${gi(t.configuration,Z.requester)} ${A}`)}break}})}var aa;(function(l){l.VERSION="version",l.COMMAND_NAME="commandName",l.PLUGIN_NAME="pluginName",l.INSTALL_COUNT="installCount",l.PROJECT_COUNT="projectCount",l.WORKSPACE_COUNT="workspaceCount",l.DEPENDENCY_COUNT="dependencyCount",l.EXTENSION="packageExtension"})(aa||(aa={}));var bC=class{constructor(e,r){this.values=new Map;this.hits=new Map;this.enumerators=new Map;this.configuration=e;let i=this.getRegistryPath();this.isNew=!K.existsSync(i),this.sendReport(r),this.startBuffer()}reportVersion(e){this.reportValue(aa.VERSION,e.replace(/-git\..*/,"-git"))}reportCommandName(e){this.reportValue(aa.COMMAND_NAME,e||"<none>")}reportPluginName(e){this.reportValue(aa.PLUGIN_NAME,e)}reportProject(e){this.reportEnumerator(aa.PROJECT_COUNT,e)}reportInstall(e){this.reportHit(aa.INSTALL_COUNT,e)}reportPackageExtension(e){this.reportValue(aa.EXTENSION,e)}reportWorkspaceCount(e){this.reportValue(aa.WORKSPACE_COUNT,String(e))}reportDependencyCount(e){this.reportValue(aa.DEPENDENCY_COUNT,String(e))}reportValue(e,r){kc(this.values,e).add(r)}reportEnumerator(e,r){kc(this.enumerators,e).add(ln(r))}reportHit(e,r="*"){let i=xg(this.hits,e),n=qa(i,r,()=>0);i.set(r,n+1)}getRegistryPath(){let e=this.configuration.get("globalFolder");return k.join(e,"telemetry.json")}sendReport(e){var u,g,f;let r=this.getRegistryPath(),i;try{i=K.readJsonSync(r)}catch{i={}}let n=Date.now(),s=this.configuration.get("telemetryInterval")*24*60*60*1e3,a=((u=i.lastUpdate)!=null?u:n+s+Math.floor(s*Math.random()))+s;if(a>n&&i.lastUpdate!=null)return;try{K.mkdirSync(k.dirname(r),{recursive:!0}),K.writeJsonSync(r,{lastUpdate:n})}catch{return}if(a>n||!i.blocks)return;let l=`https://browser-http-intake.logs.datadoghq.eu/v1/input/${e}?ddsource=yarn`,c=h=>VP(l,h,{configuration:this.configuration}).catch(()=>{});for(let[h,p]of Object.entries((g=i.blocks)!=null?g:{})){if(Object.keys(p).length===0)continue;let m=p;m.userId=h,m.reportType="primary";for(let S of Object.keys((f=m.enumerators)!=null?f:{}))m.enumerators[S]=m.enumerators[S].length;c(m);let y=new Map,Q=20;for(let[S,x]of Object.entries(m.values))x.length>0&&y.set(S,x.slice(0,Q));for(;y.size>0;){let S={};S.userId=h,S.reportType="secondary",S.metrics={};for(let[x,M]of y)S.metrics[x]=M.shift(),M.length===0&&y.delete(x);c(S)}}}applyChanges(){var o,a,l,c,u,g,f,h,p;let e=this.getRegistryPath(),r;try{r=K.readJsonSync(e)}catch{r={}}let i=(o=this.configuration.get("telemetryUserId"))!=null?o:"*",n=r.blocks=(a=r.blocks)!=null?a:{},s=n[i]=(l=n[i])!=null?l:{};for(let m of this.hits.keys()){let y=s.hits=(c=s.hits)!=null?c:{},Q=y[m]=(u=y[m])!=null?u:{};for(let[S,x]of this.hits.get(m))Q[S]=((g=Q[S])!=null?g:0)+x}for(let m of["values","enumerators"])for(let y of this[m].keys()){let Q=s[m]=(f=s[m])!=null?f:{};Q[y]=[...new Set([...(h=Q[y])!=null?h:[],...(p=this[m].get(y))!=null?p:[]])]}K.mkdirSync(k.dirname(e),{recursive:!0}),K.writeJsonSync(e,r)}startBuffer(){process.on("exit",()=>{try{this.applyChanges()}catch{}})}};var oF=ge(require("child_process")),l$=ge(hc());var aF=ge(require("fs"));var Nf=new Map([["constraints",[["constraints","query"],["constraints","source"],["constraints"]]],["exec",[]],["interactive-tools",[["search"],["upgrade-interactive"]]],["stage",[["stage"]]],["typescript",[]],["version",[["version","apply"],["version","check"],["version"]]],["workspace-tools",[["workspaces","focus"],["workspaces","foreach"]]]]);function tUe(t){let e=j.fromPortablePath(t);process.on("SIGINT",()=>{}),e?(0,oF.execFileSync)(process.execPath,[e,...process.argv.slice(2)],{stdio:"inherit",env:te(N({},process.env),{YARN_IGNORE_PATH:"1",YARN_IGNORE_CWD:"1"})}):(0,oF.execFileSync)(e,process.argv.slice(2),{stdio:"inherit",env:te(N({},process.env),{YARN_IGNORE_PATH:"1",YARN_IGNORE_CWD:"1"})})}async function d0({binaryVersion:t,pluginConfiguration:e}){async function r(){let n=new Is({binaryLabel:"Yarn Package Manager",binaryName:"yarn",binaryVersion:t});try{await i(n)}catch(s){process.stdout.write(n.error(s)),process.exitCode=1}}async function i(n){var m,y,Q,S,x;let s=process.versions.node,o=">=12 <14 || 14.2 - 14.9 || >14.10.0";if(!Se.parseOptionalBoolean(process.env.YARN_IGNORE_NODE)&&!Wt.satisfiesWithPrereleases(s,o))throw new Pe(`This tool requires a Node version compatible with ${o} (got ${s}). Upgrade Node, or set \`YARN_IGNORE_NODE=1\` in your environment.`);let l=await ye.find(j.toPortablePath(process.cwd()),e,{usePath:!0,strict:!1}),c=l.get("yarnPath"),u=l.get("ignorePath"),g=l.get("ignoreCwd"),f=j.toPortablePath(j.resolve(process.argv[1])),h=M=>K.readFilePromise(M).catch(()=>Buffer.of());if(!u&&!g&&await(async()=>c===f||Buffer.compare(...await Promise.all([h(c),h(f)]))===0)()){process.env.YARN_IGNORE_PATH="1",process.env.YARN_IGNORE_CWD="1",await i(n);return}else if(c!==null&&!u)if(!K.existsSync(c))process.stdout.write(n.error(new Error(`The "yarn-path" option has been set (in ${l.sources.get("yarnPath")}), but the specified location doesn't exist (${c}).`))),process.exitCode=1;else try{tUe(c)}catch(M){process.exitCode=M.code||1}else{u&&delete process.env.YARN_IGNORE_PATH,l.get("enableTelemetry")&&!l$.isCI&&process.stdout.isTTY&&(ye.telemetry=new bC(l,"puba9cdc10ec5790a2cf4969dd413a47270")),(m=ye.telemetry)==null||m.reportVersion(t);for(let[J,W]of l.plugins.entries()){Nf.has((Q=(y=J.match(/^@yarnpkg\/plugin-(.*)$/))==null?void 0:y[1])!=null?Q:"")&&((S=ye.telemetry)==null||S.reportPluginName(J));for(let ee of W.commands||[])n.register(ee)}let Y=n.process(process.argv.slice(2));Y.help||(x=ye.telemetry)==null||x.reportCommandName(Y.path.join(" "));let U=Y.cwd;if(typeof U!="undefined"&&!g){let J=(0,aF.realpathSync)(process.cwd()),W=(0,aF.realpathSync)(U);if(J!==W){process.chdir(U),await r();return}}await n.runExit(Y,{cwd:j.toPortablePath(process.cwd()),plugins:e,quiet:!1,stdin:process.stdin,stdout:process.stdout,stderr:process.stderr})}}return r().catch(n=>{process.stdout.write(n.stack||n.message),process.exitCode=1}).finally(()=>K.rmtempPromise())}function c$(t){t.Command.Path=(...e)=>r=>{r.paths=r.paths||[],r.paths.push(e)};for(let e of["Array","Boolean","String","Proxy","Rest","Counter"])t.Command[e]=(...r)=>(i,n)=>{let s=t.Option[e](...r);Object.defineProperty(i,`__${n}`,{configurable:!1,enumerable:!0,get(){return s},set(o){this[n]=o}})};return t}var _C={};ft(_C,{BaseCommand:()=>Le,WorkspaceRequiredError:()=>ht,getDynamicLibs:()=>bie,getPluginConfiguration:()=>J0,main:()=>d0,openWorkspace:()=>Jf,pluginCommands:()=>Nf});var Le=class extends Re{constructor(){super(...arguments);this.cwd=z.String("--cwd",{hidden:!0})}};var ht=class extends Pe{constructor(e,r){let i=k.relative(e,r),n=k.join(e,At.fileName);super(`This command can only be run from within a workspace of your project (${i} isn't a workspace of ${n}).`)}};var aqe=ge(ti());Es();var Aqe=ge(sN()),bie=()=>new Map([["@yarnpkg/cli",_C],["@yarnpkg/core",QC],["@yarnpkg/fslib",Zh],["@yarnpkg/libzip",Md],["@yarnpkg/parsers",op],["@yarnpkg/shell",Kd],["clipanion",Cp],["semver",aqe],["typanion",sg],["yup",Aqe]]);async function Jf(t,e){let{project:r,workspace:i}=await ze.find(t,e);if(!i)throw new ht(r.cwd,e);return i}var S9e=ge(ti());Es();var k9e=ge(sN());var AL={};ft(AL,{dedupeUtils:()=>HN,default:()=>Ize,suggestUtils:()=>kN});var vAe=ge(hc());var Fse=ge($C());Es();var kN={};ft(kN,{Modifier:()=>ga,Strategy:()=>_r,Target:()=>Hr,WorkspaceModifier:()=>Vf,applyModifier:()=>xse,extractDescriptorFromPath:()=>DN,extractRangeModifier:()=>kse,fetchDescriptorFrom:()=>PN,findProjectDescriptors:()=>Rse,getModifier:()=>em,getSuggestedDescriptors:()=>tm,makeWorkspaceDescriptor:()=>Dse,toWorkspaceModifier:()=>Pse});var xN=ge(ti()),vJe="workspace:",Hr;(function(i){i.REGULAR="dependencies",i.DEVELOPMENT="devDependencies",i.PEER="peerDependencies"})(Hr||(Hr={}));var ga;(function(i){i.CARET="^",i.TILDE="~",i.EXACT=""})(ga||(ga={}));var Vf;(function(i){i.CARET="^",i.TILDE="~",i.EXACT="*"})(Vf||(Vf={}));var _r;(function(s){s.KEEP="keep",s.REUSE="reuse",s.PROJECT="project",s.LATEST="latest",s.CACHE="cache"})(_r||(_r={}));function em(t,e){return t.exact?ga.EXACT:t.caret?ga.CARET:t.tilde?ga.TILDE:e.configuration.get("defaultSemverRangePrefix")}var SJe=/^([\^~]?)[0-9]+(?:\.[0-9]+){0,2}(?:-\S+)?$/;function kse(t,{project:e}){let r=t.match(SJe);return r?r[1]:e.configuration.get("defaultSemverRangePrefix")}function xse(t,e){let{protocol:r,source:i,params:n,selector:s}=P.parseRange(t.range);return xN.default.valid(s)&&(s=`${e}${t.range}`),P.makeDescriptor(t,P.makeRange({protocol:r,source:i,params:n,selector:s}))}function Pse(t){switch(t){case ga.CARET:return Vf.CARET;case ga.TILDE:return Vf.TILDE;case ga.EXACT:return Vf.EXACT;default:throw new Error(`Assertion failed: Unknown modifier: "${t}"`)}}function Dse(t,e){return P.makeDescriptor(t.anchoredDescriptor,`${vJe}${Pse(e)}`)}async function Rse(t,{project:e,target:r}){let i=new Map,n=s=>{let o=i.get(s.descriptorHash);return o||i.set(s.descriptorHash,o={descriptor:s,locators:[]}),o};for(let s of e.workspaces)if(r===Hr.PEER){let o=s.manifest.peerDependencies.get(t.identHash);o!==void 0&&n(o).locators.push(s.locator)}else{let o=s.manifest.dependencies.get(t.identHash),a=s.manifest.devDependencies.get(t.identHash);r===Hr.DEVELOPMENT?a!==void 0?n(a).locators.push(s.locator):o!==void 0&&n(o).locators.push(s.locator):o!==void 0?n(o).locators.push(s.locator):a!==void 0&&n(a).locators.push(s.locator)}return i}async function DN(t,{cwd:e,workspace:r}){return await kJe(async i=>{k.isAbsolute(t)||(t=k.relative(r.cwd,k.resolve(e,t)),t.match(/^\.{0,2}\//)||(t=`./${t}`));let{project:n}=r,s=await PN(P.makeIdent(null,"archive"),t,{project:r.project,cache:i,workspace:r});if(!s)throw new Error("Assertion failed: The descriptor should have been found");let o=new pi,a=n.configuration.makeResolver(),l=n.configuration.makeFetcher(),c={checksums:n.storedChecksums,project:n,cache:i,fetcher:l,report:o,resolver:a},u=a.bindDescriptor(s,r.anchoredLocator,c),g=P.convertDescriptorToLocator(u),f=await l.fetch(g,c),h=await At.find(f.prefixPath,{baseFs:f.packageFs});if(!h.name)throw new Error("Target path doesn't have a name");return P.makeDescriptor(h.name,t)})}async function tm(t,{project:e,workspace:r,cache:i,target:n,modifier:s,strategies:o,maxResults:a=Infinity}){if(!(a>=0))throw new Error(`Invalid maxResults (${a})`);if(t.range!=="unknown")return{suggestions:[{descriptor:t,name:`Use ${P.prettyDescriptor(e.configuration,t)}`,reason:"(unambiguous explicit request)"}],rejections:[]};let l=typeof r!="undefined"&&r!==null&&r.manifest[n].get(t.identHash)||null,c=[],u=[],g=async f=>{try{await f()}catch(h){u.push(h)}};for(let f of o){if(c.length>=a)break;switch(f){case _r.KEEP:await g(async()=>{l&&c.push({descriptor:l,name:`Keep ${P.prettyDescriptor(e.configuration,l)}`,reason:"(no changes)"})});break;case _r.REUSE:await g(async()=>{for(let{descriptor:h,locators:p}of(await Rse(t,{project:e,target:n})).values()){if(p.length===1&&p[0].locatorHash===r.anchoredLocator.locatorHash&&o.includes(_r.KEEP))continue;let m=`(originally used by ${P.prettyLocator(e.configuration,p[0])}`;m+=p.length>1?` and ${p.length-1} other${p.length>2?"s":""})`:")",c.push({descriptor:h,name:`Reuse ${P.prettyDescriptor(e.configuration,h)}`,reason:m})}});break;case _r.CACHE:await g(async()=>{for(let h of e.storedDescriptors.values())h.identHash===t.identHash&&c.push({descriptor:h,name:`Reuse ${P.prettyDescriptor(e.configuration,h)}`,reason:"(already used somewhere in the lockfile)"})});break;case _r.PROJECT:await g(async()=>{if(r.manifest.name!==null&&t.identHash===r.manifest.name.identHash)return;let h=e.tryWorkspaceByIdent(t);if(h===null)return;let p=Dse(h,s);c.push({descriptor:p,name:`Attach ${P.prettyDescriptor(e.configuration,p)}`,reason:`(local workspace at ${ae.pretty(e.configuration,h.relativeCwd,ae.Type.PATH)})`})});break;case _r.LATEST:await g(async()=>{if(t.range!=="unknown")c.push({descriptor:t,name:`Use ${P.prettyRange(e.configuration,t.range)}`,reason:"(explicit range requested)"});else if(n===Hr.PEER)c.push({descriptor:P.makeDescriptor(t,"*"),name:"Use *",reason:"(catch-all peer dependency pattern)"});else if(!e.configuration.get("enableNetwork"))c.push({descriptor:null,name:"Resolve from latest",reason:ae.pretty(e.configuration,"(unavailable because enableNetwork is toggled off)","grey")});else{let h=await PN(t,"latest",{project:e,cache:i,workspace:r,preserveModifier:!1});h&&(h=xse(h,s),c.push({descriptor:h,name:`Use ${P.prettyDescriptor(e.configuration,h)}`,reason:"(resolved from latest)"}))}});break}}return{suggestions:c.slice(0,a),rejections:u.slice(0,a)}}async function PN(t,e,{project:r,cache:i,workspace:n,preserveModifier:s=!0}){let o=P.makeDescriptor(t,e),a=new pi,l=r.configuration.makeFetcher(),c=r.configuration.makeResolver(),u={project:r,fetcher:l,cache:i,checksums:r.storedChecksums,report:a,cacheOptions:{skipIntegrityCheck:!0},skipIntegrityCheck:!0},g=te(N({},u),{resolver:c,fetchOptions:u}),f=c.bindDescriptor(o,n.anchoredLocator,g),h=await c.getCandidates(f,new Map,g);if(h.length===0)return null;let p=h[0],{protocol:m,source:y,params:Q,selector:S}=P.parseRange(P.convertToManifestRange(p.reference));if(m===r.configuration.get("defaultProtocol")&&(m=null),xN.default.valid(S)&&s!==!1){let x=typeof s=="string"?s:o.range;S=kse(x,{project:r})+S}return P.makeDescriptor(p,P.makeRange({protocol:m,source:y,params:Q,selector:S}))}async function kJe(t){return await K.mktempPromise(async e=>{let r=ye.create(e);return r.useWithSource(e,{enableMirror:!1,compressionLevel:0},e,{overwrite:!0}),await t(new Nt(e,{configuration:r,check:!1,immutable:!1}))})}var rm=class extends Le{constructor(){super(...arguments);this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.exact=z.Boolean("-E,--exact",!1,{description:"Don't use any semver modifier on the resolved range"});this.tilde=z.Boolean("-T,--tilde",!1,{description:"Use the `~` semver modifier on the resolved range"});this.caret=z.Boolean("-C,--caret",!1,{description:"Use the `^` semver modifier on the resolved range"});this.dev=z.Boolean("-D,--dev",!1,{description:"Add a package as a dev dependency"});this.peer=z.Boolean("-P,--peer",!1,{description:"Add a package as a peer dependency"});this.optional=z.Boolean("-O,--optional",!1,{description:"Add / upgrade a package to an optional regular / peer dependency"});this.preferDev=z.Boolean("--prefer-dev",!1,{description:"Add / upgrade a package to a dev dependency"});this.interactive=z.Boolean("-i,--interactive",{description:"Reuse the specified package from other workspaces in the project"});this.cached=z.Boolean("--cached",!1,{description:"Reuse the highest version already used somewhere within the project"});this.mode=z.String("--mode",{description:"Change what artifacts installs generate",validator:nn(di)});this.silent=z.Boolean("--silent",{hidden:!0});this.packages=z.Rest()}async execute(){var m;let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState({restoreResolutions:!1});let s=(m=this.interactive)!=null?m:e.get("preferInteractive"),o=em(this,r),a=[...s?[_r.REUSE]:[],_r.PROJECT,...this.cached?[_r.CACHE]:[],_r.LATEST],l=s?Infinity:1,c=await Promise.all(this.packages.map(async y=>{let Q=y.match(/^\.{0,2}\//)?await DN(y,{cwd:this.context.cwd,workspace:i}):P.tryParseDescriptor(y),S=y.match(/^(https?:|git@github)/);if(S)throw new Pe(`It seems you are trying to add a package using a ${ae.pretty(e,`${S[0]}...`,Di.RANGE)} url; we now require package names to be explicitly specified.
+Try running the command again with the package name prefixed: ${ae.pretty(e,"yarn add",Di.CODE)} ${ae.pretty(e,P.makeDescriptor(P.makeIdent(null,"my-package"),`${S[0]}...`),Di.DESCRIPTOR)}`);if(!Q)throw new Pe(`The ${ae.pretty(e,y,Di.CODE)} string didn't match the required format (package-name@range). Did you perhaps forget to explicitly reference the package name?`);let x=xJe(i,Q,{dev:this.dev,peer:this.peer,preferDev:this.preferDev,optional:this.optional}),M=await tm(Q,{project:r,workspace:i,cache:n,target:x,modifier:o,strategies:a,maxResults:l});return[Q,M,x]})),u=await uA.start({configuration:e,stdout:this.context.stdout,suggestInstall:!1},async y=>{for(let[Q,{suggestions:S,rejections:x}]of c)if(S.filter(Y=>Y.descriptor!==null).length===0){let[Y]=x;if(typeof Y=="undefined")throw new Error("Assertion failed: Expected an error to have been set");r.configuration.get("enableNetwork")?y.reportError($.CANT_SUGGEST_RESOLUTIONS,`${P.prettyDescriptor(e,Q)} can't be resolved to a satisfying range`):y.reportError($.CANT_SUGGEST_RESOLUTIONS,`${P.prettyDescriptor(e,Q)} can't be resolved to a satisfying range (note: network resolution has been disabled)`),y.reportSeparator(),y.reportExceptionOnce(Y)}});if(u.hasErrors())return u.exitCode();let g=!1,f=[],h=[];for(let[,{suggestions:y},Q]of c){let S,x=y.filter(J=>J.descriptor!==null),M=x[0].descriptor,Y=x.every(J=>P.areDescriptorsEqual(J.descriptor,M));x.length===1||Y?S=M:(g=!0,{answer:S}=await(0,Fse.prompt)({type:"select",name:"answer",message:"Which range do you want to use?",choices:y.map(({descriptor:J,name:W,reason:ee})=>J?{name:W,hint:ee,descriptor:J}:{name:W,hint:ee,disabled:!0}),onCancel:()=>process.exit(130),result(J){return this.find(J,"descriptor")},stdin:this.context.stdin,stdout:this.context.stdout}));let U=i.manifest[Q].get(S.identHash);(typeof U=="undefined"||U.descriptorHash!==S.descriptorHash)&&(i.manifest[Q].set(S.identHash,S),this.optional&&(Q==="dependencies"?i.manifest.ensureDependencyMeta(te(N({},S),{range:"unknown"})).optional=!0:Q==="peerDependencies"&&(i.manifest.ensurePeerDependencyMeta(te(N({},S),{range:"unknown"})).optional=!0)),typeof U=="undefined"?f.push([i,Q,S,a]):h.push([i,Q,U,S]))}return await e.triggerMultipleHooks(y=>y.afterWorkspaceDependencyAddition,f),await e.triggerMultipleHooks(y=>y.afterWorkspaceDependencyReplacement,h),g&&this.context.stdout.write(`
+`),(await Je.start({configuration:e,json:this.json,stdout:this.context.stdout,includeLogs:!this.context.quiet},async y=>{await r.install({cache:n,report:y,mode:this.mode})})).exitCode()}};rm.paths=[["add"]],rm.usage=Re.Usage({description:"add dependencies to the project",details:"\n      This command adds a package to the package.json for the nearest workspace.\n\n      - If it didn't exist before, the package will by default be added to the regular `dependencies` field, but this behavior can be overriden thanks to the `-D,--dev` flag (which will cause the dependency to be added to the `devDependencies` field instead) and the `-P,--peer` flag (which will do the same but for `peerDependencies`).\n\n      - If the package was already listed in your dependencies, it will by default be upgraded whether it's part of your `dependencies` or `devDependencies` (it won't ever update `peerDependencies`, though).\n\n      - If set, the `--prefer-dev` flag will operate as a more flexible `-D,--dev` in that it will add the package to your `devDependencies` if it isn't already listed in either `dependencies` or `devDependencies`, but it will also happily upgrade your `dependencies` if that's what you already use (whereas `-D,--dev` would throw an exception).\n\n      - If set, the `-O,--optional` flag will add the package to the `optionalDependencies` field and, in combination with the `-P,--peer` flag, it will add the package as an optional peer dependency. If the package was already listed in your `dependencies`, it will be upgraded to `optionalDependencies`. If the package was already listed in your `peerDependencies`, in combination with the `-P,--peer` flag, it will be upgraded to an optional peer dependency: `\"peerDependenciesMeta\": { \"<package>\": { \"optional\": true } }`\n\n      - If the added package doesn't specify a range at all its `latest` tag will be resolved and the returned version will be used to generate a new semver range (using the `^` modifier by default unless otherwise configured via the `defaultSemverRangePrefix` configuration, or the `~` modifier if `-T,--tilde` is specified, or no modifier at all if `-E,--exact` is specified). Two exceptions to this rule: the first one is that if the package is a workspace then its local version will be used, and the second one is that if you use `-P,--peer` the default range will be `*` and won't be resolved at all.\n\n      - If the added package specifies a range (such as `^1.0.0`, `latest`, or `rc`), Yarn will add this range as-is in the resulting package.json entry (in particular, tags such as `rc` will be encoded as-is rather than being converted into a semver range).\n\n      If the `--cached` option is used, Yarn will preferably reuse the highest version already used somewhere within the project, even if through a transitive dependency.\n\n      If the `-i,--interactive` option is used (or if the `preferInteractive` settings is toggled on) the command will first try to check whether other workspaces in the project use the specified package and, if so, will offer to reuse them.\n\n      If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n      - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the later will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n      - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n      For a compilation of all the supported protocols, please consult the dedicated page from our website: https://yarnpkg.com/features/protocols.\n    ",examples:[["Add a regular package to the current workspace","$0 add lodash"],["Add a specific version for a package to the current workspace","$0 add lodash@1.2.3"],["Add a package from a GitHub repository (the master branch) to the current workspace using a URL","$0 add lodash@https://github.com/lodash/lodash"],["Add a package from a GitHub repository (the master branch) to the current workspace using the GitHub protocol","$0 add lodash@github:lodash/lodash"],["Add a package from a GitHub repository (the master branch) to the current workspace using the GitHub protocol (shorthand)","$0 add lodash@lodash/lodash"],["Add a package from a specific branch of a GitHub repository to the current workspace using the GitHub protocol (shorthand)","$0 add lodash-es@lodash/lodash#es"]]});var Nse=rm;function xJe(t,e,{dev:r,peer:i,preferDev:n,optional:s}){let o=t.manifest[Hr.REGULAR].has(e.identHash),a=t.manifest[Hr.DEVELOPMENT].has(e.identHash),l=t.manifest[Hr.PEER].has(e.identHash);if((r||i)&&o)throw new Pe(`Package "${P.prettyIdent(t.project.configuration,e)}" is already listed as a regular dependency - remove the -D,-P flags or remove it from your dependencies first`);if(!r&&!i&&l)throw new Pe(`Package "${P.prettyIdent(t.project.configuration,e)}" is already listed as a peer dependency - use either of -D or -P, or remove it from your peer dependencies first`);if(s&&a)throw new Pe(`Package "${P.prettyIdent(t.project.configuration,e)}" is already listed as a dev dependency - remove the -O flag or remove it from your dev dependencies first`);if(s&&!i&&l)throw new Pe(`Package "${P.prettyIdent(t.project.configuration,e)}" is already listed as a peer dependency - remove the -O flag or add the -P flag or remove it from your peer dependencies first`);if((r||n)&&s)throw new Pe(`Package "${P.prettyIdent(t.project.configuration,e)}" cannot simultaneously be a dev dependency and an optional dependency`);return i?Hr.PEER:r||n?Hr.DEVELOPMENT:o?Hr.REGULAR:a?Hr.DEVELOPMENT:Hr.REGULAR}var im=class extends Le{constructor(){super(...arguments);this.verbose=z.Boolean("-v,--verbose",!1,{description:"Print both the binary name and the locator of the package that provides the binary"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.name=z.String({required:!1})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,locator:i}=await ze.find(e,this.context.cwd);if(await r.restoreInstallState(),this.name){let o=(await Zt.getPackageAccessibleBinaries(i,{project:r})).get(this.name);if(!o)throw new Pe(`Couldn't find a binary named "${this.name}" for package "${P.prettyLocator(e,i)}"`);let[,a]=o;return this.context.stdout.write(`${a}
+`),0}return(await Je.start({configuration:e,json:this.json,stdout:this.context.stdout},async s=>{let o=await Zt.getPackageAccessibleBinaries(i,{project:r}),l=Array.from(o.keys()).reduce((c,u)=>Math.max(c,u.length),0);for(let[c,[u,g]]of o)s.reportJson({name:c,source:P.stringifyIdent(u),path:g});if(this.verbose)for(let[c,[u]]of o)s.reportInfo(null,`${c.padEnd(l," ")}   ${P.prettyLocator(e,u)}`);else for(let c of o.keys())s.reportInfo(null,c)})).exitCode()}};im.paths=[["bin"]],im.usage=Re.Usage({description:"get the path to a binary script",details:`
+      When used without arguments, this command will print the list of all the binaries available in the current workspace. Adding the \`-v,--verbose\` flag will cause the output to contain both the binary name and the locator of the package that provides the binary.
+
+      When an argument is specified, this command will just print the path to the binary on the standard output and exit. Note that the reported path may be stored within a zip archive.
+    `,examples:[["List all the available binaries","$0 bin"],["Print the path to a specific binary","$0 bin eslint"]]});var Lse=im;var nm=class extends Le{constructor(){super(...arguments);this.mirror=z.Boolean("--mirror",!1,{description:"Remove the global cache files instead of the local cache files"});this.all=z.Boolean("--all",!1,{description:"Remove both the global cache files and the local cache files of the current project"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),r=await Nt.find(e);return(await Je.start({configuration:e,stdout:this.context.stdout},async()=>{let n=(this.all||this.mirror)&&r.mirrorCwd!==null,s=!this.mirror;n&&(await K.removePromise(r.mirrorCwd),await e.triggerHook(o=>o.cleanGlobalArtifacts,e)),s&&await K.removePromise(r.cwd)})).exitCode()}};nm.paths=[["cache","clean"],["cache","clear"]],nm.usage=Re.Usage({description:"remove the shared cache files",details:`
+      This command will remove all the files from the cache.
+    `,examples:[["Remove all the local archives","$0 cache clean"],["Remove all the archives stored in the ~/.yarn directory","$0 cache clean --mirror"]]});var Tse=nm;var Ose=ge(k0()),RN=ge(require("util")),sm=class extends Le{constructor(){super(...arguments);this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.unsafe=z.Boolean("--no-redacted",!1,{description:"Don't redact secrets (such as tokens) from the output"});this.name=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),r=this.name.replace(/[.[].*$/,""),i=this.name.replace(/^[^.[]*/,"");if(typeof e.settings.get(r)=="undefined")throw new Pe(`Couldn't find a configuration settings named "${r}"`);let s=e.getSpecial(r,{hideSecrets:!this.unsafe,getNativePaths:!0}),o=Se.convertMapsToIndexableObjects(s),a=i?(0,Ose.default)(o,i):o,l=await Je.start({configuration:e,includeFooter:!1,json:this.json,stdout:this.context.stdout},async c=>{c.reportJson(a)});if(!this.json){if(typeof a=="string")return this.context.stdout.write(`${a}
+`),l.exitCode();RN.inspect.styles.name="cyan",this.context.stdout.write(`${(0,RN.inspect)(a,{depth:Infinity,colors:e.get("enableColors"),compact:!1})}
+`)}return l.exitCode()}};sm.paths=[["config","get"]],sm.usage=Re.Usage({description:"read a configuration settings",details:`
+      This command will print a configuration setting.
+
+      Secrets (such as tokens) will be redacted from the output by default. If this behavior isn't desired, set the \`--no-redacted\` to get the untransformed value.
+    `,examples:[["Print a simple configuration setting","yarn config get yarnPath"],["Print a complex configuration setting","yarn config get packageExtensions"],["Print a nested field from the configuration",`yarn config get 'npmScopes["my-company"].npmRegistryServer'`],["Print a token from the configuration","yarn config get npmAuthToken --no-redacted"],["Print a configuration setting as JSON","yarn config get packageExtensions --json"]]});var Mse=sm;var Voe=ge(MN()),Xoe=ge(k0()),Zoe=ge(_oe()),UN=ge(require("util")),am=class extends Le{constructor(){super(...arguments);this.json=z.Boolean("--json",!1,{description:"Set complex configuration settings to JSON values"});this.home=z.Boolean("-H,--home",!1,{description:"Update the home configuration instead of the project configuration"});this.name=z.String();this.value=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),r=()=>{if(!e.projectCwd)throw new Pe("This command must be run from within a project folder");return e.projectCwd},i=this.name.replace(/[.[].*$/,""),n=this.name.replace(/^[^.[]*\.?/,"");if(typeof e.settings.get(i)=="undefined")throw new Pe(`Couldn't find a configuration settings named "${i}"`);if(i==="enableStrictSettings")throw new Pe("This setting only affects the file it's in, and thus cannot be set from the CLI");let o=this.json?JSON.parse(this.value):this.value;await(this.home?h=>ye.updateHomeConfiguration(h):h=>ye.updateConfiguration(r(),h))(h=>{if(n){let p=(0,Voe.default)(h);return(0,Zoe.default)(p,this.name,o),p}else return te(N({},h),{[i]:o})});let c=(await ye.find(this.context.cwd,this.context.plugins)).getSpecial(i,{hideSecrets:!0,getNativePaths:!0}),u=Se.convertMapsToIndexableObjects(c),g=n?(0,Xoe.default)(u,n):u;return(await Je.start({configuration:e,includeFooter:!1,stdout:this.context.stdout},async h=>{UN.inspect.styles.name="cyan",h.reportInfo($.UNNAMED,`Successfully set ${this.name} to ${(0,UN.inspect)(g,{depth:Infinity,colors:e.get("enableColors"),compact:!1})}`)})).exitCode()}};am.paths=[["config","set"]],am.usage=Re.Usage({description:"change a configuration settings",details:`
+      This command will set a configuration setting.
+
+      When used without the \`--json\` flag, it can only set a simple configuration setting (a string, a number, or a boolean).
+
+      When used with the \`--json\` flag, it can set both simple and complex configuration settings, including Arrays and Objects.
+    `,examples:[["Set a simple configuration setting (a string, a number, or a boolean)","yarn config set initScope myScope"],["Set a simple configuration setting (a string, a number, or a boolean) using the `--json` flag",'yarn config set initScope --json \\"myScope\\"'],["Set a complex configuration setting (an Array) using the `--json` flag",`yarn config set unsafeHttpWhitelist --json '["*.example.com", "example.com"]'`],["Set a complex configuration setting (an Object) using the `--json` flag",`yarn config set packageExtensions --json '{ "@babel/parser@*": { "dependencies": { "@babel/types": "*" } } }'`],["Set a nested configuration setting",'yarn config set npmScopes.company.npmRegistryServer "https://npm.example.com"'],["Set a nested configuration setting using indexed access for non-simple keys",`yarn config set 'npmRegistries["//npm.example.com"].npmAuthToken' "ffffffff-ffff-ffff-ffff-ffffffffffff"`]]});var $oe=am;var Aae=ge(MN()),lae=ge(SC()),cae=ge(aae()),Am=class extends Le{constructor(){super(...arguments);this.home=z.Boolean("-H,--home",!1,{description:"Update the home configuration instead of the project configuration"});this.name=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),r=()=>{if(!e.projectCwd)throw new Pe("This command must be run from within a project folder");return e.projectCwd},i=this.name.replace(/[.[].*$/,""),n=this.name.replace(/^[^.[]*\.?/,"");if(typeof e.settings.get(i)=="undefined")throw new Pe(`Couldn't find a configuration settings named "${i}"`);let o=this.home?l=>ye.updateHomeConfiguration(l):l=>ye.updateConfiguration(r(),l);return(await Je.start({configuration:e,includeFooter:!1,stdout:this.context.stdout},async l=>{let c=!1;await o(u=>{if(!(0,lae.default)(u,this.name))return l.reportWarning($.UNNAMED,`Configuration doesn't contain setting ${this.name}; there is nothing to unset`),c=!0,u;let g=n?(0,Aae.default)(u):N({},u);return(0,cae.default)(g,this.name),g}),c||l.reportInfo($.UNNAMED,`Successfully unset ${this.name}`)})).exitCode()}};Am.paths=[["config","unset"]],Am.usage=Re.Usage({description:"unset a configuration setting",details:`
+      This command will unset a configuration setting.
+    `,examples:[["Unset a simple configuration setting","yarn config unset initScope"],["Unset a complex configuration setting","yarn config unset packageExtensions"],["Unset a nested configuration setting","yarn config unset npmScopes.company.npmRegistryServer"]]});var uae=Am;var KN=ge(require("util")),lm=class extends Le{constructor(){super(...arguments);this.verbose=z.Boolean("-v,--verbose",!1,{description:"Print the setting description on top of the regular key/value information"});this.why=z.Boolean("--why",!1,{description:"Print the reason why a setting is set a particular way"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins,{strict:!1});return(await Je.start({configuration:e,json:this.json,stdout:this.context.stdout},async i=>{if(e.invalid.size>0&&!this.json){for(let[n,s]of e.invalid)i.reportError($.INVALID_CONFIGURATION_KEY,`Invalid configuration key "${n}" in ${s}`);i.reportSeparator()}if(this.json){let n=Se.sortMap(e.settings.keys(),s=>s);for(let s of n){let o=e.settings.get(s),a=e.getSpecial(s,{hideSecrets:!0,getNativePaths:!0}),l=e.sources.get(s);this.verbose?i.reportJson({key:s,effective:a,source:l}):i.reportJson(N({key:s,effective:a,source:l},o))}}else{let n=Se.sortMap(e.settings.keys(),a=>a),s=n.reduce((a,l)=>Math.max(a,l.length),0),o={breakLength:Infinity,colors:e.get("enableColors"),maxArrayLength:2};if(this.why||this.verbose){let a=n.map(c=>{let u=e.settings.get(c);if(!u)throw new Error(`Assertion failed: This settings ("${c}") should have been registered`);let g=this.why?e.sources.get(c)||"<default>":u.description;return[c,g]}),l=a.reduce((c,[,u])=>Math.max(c,u.length),0);for(let[c,u]of a)i.reportInfo(null,`${c.padEnd(s," ")}   ${u.padEnd(l," ")}   ${(0,KN.inspect)(e.getSpecial(c,{hideSecrets:!0,getNativePaths:!0}),o)}`)}else for(let a of n)i.reportInfo(null,`${a.padEnd(s," ")}   ${(0,KN.inspect)(e.getSpecial(a,{hideSecrets:!0,getNativePaths:!0}),o)}`)}})).exitCode()}};lm.paths=[["config"]],lm.usage=Re.Usage({description:"display the current configuration",details:`
+      This command prints the current active configuration settings.
+    `,examples:[["Print the active configuration settings","$0 config"]]});var gae=lm;Es();var HN={};ft(HN,{Strategy:()=>Iu,acceptedStrategies:()=>R8e,dedupe:()=>jN});var fae=ge(ts()),Iu;(function(e){e.HIGHEST="highest"})(Iu||(Iu={}));var R8e=new Set(Object.values(Iu)),F8e={highest:async(t,e,{resolver:r,fetcher:i,resolveOptions:n,fetchOptions:s})=>{let o=new Map;for(let[a,l]of t.storedResolutions){let c=t.storedDescriptors.get(a);if(typeof c=="undefined")throw new Error(`Assertion failed: The descriptor (${a}) should have been registered`);Se.getSetWithDefault(o,c.identHash).add(l)}return Array.from(t.storedDescriptors.values(),async a=>{if(e.length&&!fae.default.isMatch(P.stringifyIdent(a),e))return null;let l=t.storedResolutions.get(a.descriptorHash);if(typeof l=="undefined")throw new Error(`Assertion failed: The resolution (${a.descriptorHash}) should have been registered`);let c=t.originalPackages.get(l);if(typeof c=="undefined"||!r.shouldPersistResolution(c,n))return null;let u=o.get(a.identHash);if(typeof u=="undefined")throw new Error(`Assertion failed: The resolutions (${a.identHash}) should have been registered`);if(u.size===1)return null;let g=[...u].map(y=>{let Q=t.originalPackages.get(y);if(typeof Q=="undefined")throw new Error(`Assertion failed: The package (${y}) should have been registered`);return Q.reference}),f=await r.getSatisfying(a,g,n),h=f==null?void 0:f[0];if(typeof h=="undefined")return null;let p=h.locatorHash,m=t.originalPackages.get(p);if(typeof m=="undefined")throw new Error(`Assertion failed: The package (${p}) should have been registered`);return p===l?null:{descriptor:a,currentPackage:c,updatedPackage:m}})}};async function jN(t,{strategy:e,patterns:r,cache:i,report:n}){let{configuration:s}=t,o=new pi,a=s.makeResolver(),l=s.makeFetcher(),c={cache:i,checksums:t.storedChecksums,fetcher:l,project:t,report:o,skipIntegrityCheck:!0,cacheOptions:{skipIntegrityCheck:!0}},u={project:t,resolver:a,report:o,fetchOptions:c};return await n.startTimerPromise("Deduplication step",async()=>{let f=await F8e[e](t,r,{resolver:a,resolveOptions:u,fetcher:l,fetchOptions:c}),h=Ji.progressViaCounter(f.length);n.reportProgress(h);let p=0;await Promise.all(f.map(Q=>Q.then(S=>{if(S===null)return;p++;let{descriptor:x,currentPackage:M,updatedPackage:Y}=S;n.reportInfo($.UNNAMED,`${P.prettyDescriptor(s,x)} can be deduped from ${P.prettyLocator(s,M)} to ${P.prettyLocator(s,Y)}`),n.reportJson({descriptor:P.stringifyDescriptor(x),currentResolution:P.stringifyLocator(M),updatedResolution:P.stringifyLocator(Y)}),t.storedResolutions.set(x.descriptorHash,Y.locatorHash)}).finally(()=>h.tick())));let m;switch(p){case 0:m="No packages";break;case 1:m="One package";break;default:m=`${p} packages`}let y=ae.pretty(s,e,ae.Type.CODE);return n.reportInfo($.UNNAMED,`${m} can be deduped using the ${y} strategy`),p})}var cm=class extends Le{constructor(){super(...arguments);this.strategy=z.String("-s,--strategy",Iu.HIGHEST,{description:"The strategy to use when deduping dependencies",validator:nn(Iu)});this.check=z.Boolean("-c,--check",!1,{description:"Exit with exit code 1 when duplicates are found, without persisting the dependency tree"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.mode=z.String("--mode",{description:"Change what artifacts installs generate",validator:nn(di)});this.patterns=z.Rest()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r}=await ze.find(e,this.context.cwd),i=await Nt.find(e);await r.restoreInstallState({restoreResolutions:!1});let n=0,s=await Je.start({configuration:e,includeFooter:!1,stdout:this.context.stdout,json:this.json},async o=>{n=await jN(r,{strategy:this.strategy,patterns:this.patterns,cache:i,report:o})});return s.hasErrors()?s.exitCode():this.check?n?1:0:(await Je.start({configuration:e,stdout:this.context.stdout,json:this.json},async a=>{await r.install({cache:i,report:a,mode:this.mode})})).exitCode()}};cm.paths=[["dedupe"]],cm.usage=Re.Usage({description:"deduplicate dependencies with overlapping ranges",details:"\n      Duplicates are defined as descriptors with overlapping ranges being resolved and locked to different locators. They are a natural consequence of Yarn's deterministic installs, but they can sometimes pile up and unnecessarily increase the size of your project.\n\n      This command dedupes dependencies in the current project using different strategies (only one is implemented at the moment):\n\n      - `highest`: Reuses (where possible) the locators with the highest versions. This means that dependencies can only be upgraded, never downgraded. It's also guaranteed that it never takes more than a single pass to dedupe the entire dependency tree.\n\n      **Note:** Even though it never produces a wrong dependency tree, this command should be used with caution, as it modifies the dependency tree, which can sometimes cause problems when packages don't strictly follow semver recommendations. Because of this, it is recommended to also review the changes manually.\n\n      If set, the `-c,--check` flag will only report the found duplicates, without persisting the modified dependency tree. If changes are found, the command will exit with a non-zero exit code, making it suitable for CI purposes.\n\n      If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n      - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the later will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n      - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n      This command accepts glob patterns as arguments (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n      ### In-depth explanation:\n\n      Yarn doesn't deduplicate dependencies by default, otherwise installs wouldn't be deterministic and the lockfile would be useless. What it actually does is that it tries to not duplicate dependencies in the first place.\n\n      **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@*`will cause Yarn to reuse `foo@2.3.4`, even if the latest `foo` is actually `foo@2.10.14`, thus preventing unnecessary duplication.\n\n      Duplication happens when Yarn can't unlock dependencies that have already been locked inside the lockfile.\n\n      **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@2.10.14` will cause Yarn to install `foo@2.10.14` because the existing resolution doesn't satisfy the range `2.10.14`. This behavior can lead to (sometimes) unwanted duplication, since now the lockfile contains 2 separate resolutions for the 2 `foo` descriptors, even though they have overlapping ranges, which means that the lockfile can be simplified so that both descriptors resolve to `foo@2.10.14`.\n    ",examples:[["Dedupe all packages","$0 dedupe"],["Dedupe all packages using a specific strategy","$0 dedupe --strategy highest"],["Dedupe a specific package","$0 dedupe lodash"],["Dedupe all packages with the `@babel/*` scope","$0 dedupe '@babel/*'"],["Check for duplicates (can be used as a CI step)","$0 dedupe --check"]]});var hae=cm;var ib=class extends Le{async execute(){let{plugins:e}=await ye.find(this.context.cwd,this.context.plugins),r=[];for(let o of e){let{commands:a}=o[1];if(a){let c=Is.from(a).definitions();r.push([o[0],c])}}let i=this.cli.definitions(),n=(o,a)=>o.split(" ").slice(1).join()===a.split(" ").slice(1).join(),s=dae()["@yarnpkg/builder"].bundles.standard;for(let o of r){let a=o[1];for(let l of a)i.find(c=>n(c.path,l.path)).plugin={name:o[0],useAlts:s.includes(o[0])}}this.context.stdout.write(`${JSON.stringify(i,null,2)}
+`)}};ib.paths=[["--clipanion=definitions"]];var Cae=ib;var nb=class extends Le{async execute(){this.context.stdout.write(this.cli.usage(null))}};nb.paths=[["help"],["--help"],["-h"]];var mae=nb;var GN=class extends Le{constructor(){super(...arguments);this.leadingArgument=z.String();this.args=z.Proxy()}async execute(){if(this.leadingArgument.match(/[\\/]/)&&!P.tryParseIdent(this.leadingArgument)){let e=k.resolve(this.context.cwd,j.toPortablePath(this.leadingArgument));return await this.cli.run(this.args,{cwd:e})}else return await this.cli.run(["run",this.leadingArgument,...this.args])}},Eae=GN;var sb=class extends Le{async execute(){this.context.stdout.write(`${Ur||"<unknown>"}
+`)}};sb.paths=[["-v"],["--version"]];var Iae=sb;var um=class extends Le{constructor(){super(...arguments);this.commandName=z.String();this.args=z.Proxy()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,locator:i}=await ze.find(e,this.context.cwd);return await r.restoreInstallState(),await Zt.executePackageShellcode(i,this.commandName,this.args,{cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,project:r})}};um.paths=[["exec"]],um.usage=Re.Usage({description:"execute a shell script",details:`
+      This command simply executes a shell script within the context of the root directory of the active workspace using the portable shell.
+
+      It also makes sure to call it in a way that's compatible with the current project (for example, on PnP projects the environment will be setup in such a way that PnP will be correctly injected into the environment).
+    `,examples:[["Execute a single shell command","$0 exec echo Hello World"],["Execute a shell script",'$0 exec "tsc & babel src --out-dir lib"']]});var yae=um;Es();var gm=class extends Le{constructor(){super(...arguments);this.hash=z.String({required:!1,validator:fp(gp(),[hp(/^p[0-9a-f]{5}$/)])})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r}=await ze.find(e,this.context.cwd);return await r.restoreInstallState({restoreResolutions:!1}),await r.applyLightResolution(),typeof this.hash!="undefined"?await N8e(this.hash,r,{stdout:this.context.stdout}):(await Je.start({configuration:e,stdout:this.context.stdout,includeFooter:!1},async n=>{var o;let s=[([,a])=>P.stringifyLocator(r.storedPackages.get(a.subject)),([,a])=>P.stringifyIdent(a.requested)];for(let[a,l]of Se.sortMap(r.peerRequirements,s)){let c=r.storedPackages.get(l.subject);if(typeof c=="undefined")throw new Error("Assertion failed: Expected the subject package to have been registered");let u=r.storedPackages.get(l.rootRequester);if(typeof u=="undefined")throw new Error("Assertion failed: Expected the root package to have been registered");let g=(o=c.dependencies.get(l.requested.identHash))!=null?o:null,f=ae.pretty(e,a,ae.Type.CODE),h=P.prettyLocator(e,c),p=P.prettyIdent(e,l.requested),m=P.prettyIdent(e,u),y=l.allRequesters.length-1,Q=`descendant${y===1?"":"s"}`,S=y>0?` and ${y} ${Q}`:"",x=g!==null?"provides":"doesn't provide";n.reportInfo(null,`${f} \u2192 ${h} ${x} ${p} to ${m}${S}`)}})).exitCode()}};gm.paths=[["explain","peer-requirements"]],gm.usage=Re.Usage({description:"explain a set of peer requirements",details:`
+      A set of peer requirements represents all peer requirements that a dependent must satisfy when providing a given peer request to a requester and its descendants.
+
+      When the hash argument is specified, this command prints a detailed explanation of all requirements of the set corresponding to the hash and whether they're satisfied or not.
+
+      When used without arguments, this command lists all sets of peer requirements and the corresponding hash that can be used to get detailed information about a given set.
+
+      **Note:** A hash is a six-letter p-prefixed code that can be obtained from peer dependency warnings or from the list of all peer requirements (\`yarn explain peer-requirements\`).
+    `,examples:[["Explain the corresponding set of peer requirements for a hash","$0 explain peer-requirements p1a4ed"],["List all sets of peer requirements","$0 explain peer-requirements"]]});var wae=gm;async function N8e(t,e,r){let{configuration:i}=e,n=e.peerRequirements.get(t);if(typeof n=="undefined")throw new Error(`No peerDependency requirements found for hash: "${t}"`);return(await Je.start({configuration:i,stdout:r.stdout,includeFooter:!1},async o=>{var Q,S;let a=e.storedPackages.get(n.subject);if(typeof a=="undefined")throw new Error("Assertion failed: Expected the subject package to have been registered");let l=e.storedPackages.get(n.rootRequester);if(typeof l=="undefined")throw new Error("Assertion failed: Expected the root package to have been registered");let c=(Q=a.dependencies.get(n.requested.identHash))!=null?Q:null,u=c!==null?e.storedResolutions.get(c.descriptorHash):null;if(typeof u=="undefined")throw new Error("Assertion failed: Expected the resolution to have been registered");let g=u!==null?e.storedPackages.get(u):null;if(typeof g=="undefined")throw new Error("Assertion failed: Expected the provided package to have been registered");let f=[...n.allRequesters.values()].map(x=>{let M=e.storedPackages.get(x);if(typeof M=="undefined")throw new Error("Assertion failed: Expected the package to be registered");let Y=P.devirtualizeLocator(M),U=e.storedPackages.get(Y.locatorHash);if(typeof U=="undefined")throw new Error("Assertion failed: Expected the package to be registered");let J=U.peerDependencies.get(n.requested.identHash);if(typeof J=="undefined")throw new Error("Assertion failed: Expected the peer dependency to be registered");return{pkg:M,peerDependency:J}});if(g!==null){let x=f.every(({peerDependency:M})=>Wt.satisfiesWithPrereleases(g.version,M.range));o.reportInfo($.UNNAMED,`${P.prettyLocator(i,a)} provides ${P.prettyLocator(i,g)} with version ${P.prettyReference(i,(S=g.version)!=null?S:"<missing>")}, which ${x?"satisfies":"doesn't satisfy"} the following requirements:`)}else o.reportInfo($.UNNAMED,`${P.prettyLocator(i,a)} doesn't provide ${P.prettyIdent(i,n.requested)}, breaking the following requirements:`);o.reportSeparator();let h=ae.mark(i),p=[];for(let{pkg:x,peerDependency:M}of Se.sortMap(f,Y=>P.stringifyLocator(Y.pkg))){let U=(g!==null?Wt.satisfiesWithPrereleases(g.version,M.range):!1)?h.Check:h.Cross;p.push({stringifiedLocator:P.stringifyLocator(x),prettyLocator:P.prettyLocator(i,x),prettyRange:P.prettyRange(i,M.range),mark:U})}let m=Math.max(...p.map(({stringifiedLocator:x})=>x.length)),y=Math.max(...p.map(({prettyRange:x})=>x.length));for(let{stringifiedLocator:x,prettyLocator:M,prettyRange:Y,mark:U}of Se.sortMap(p,({stringifiedLocator:J})=>J))o.reportInfo(null,`${M.padEnd(m+(M.length-x.length)," ")} \u2192 ${Y.padEnd(y," ")} ${U}`);p.length>1&&(o.reportSeparator(),o.reportInfo($.UNNAMED,`Note: these requirements start with ${P.prettyLocator(e.configuration,l)}`))})).exitCode()}Es();var Bae=ge(ti()),fm=class extends Le{constructor(){super(...arguments);this.onlyIfNeeded=z.Boolean("--only-if-needed",!1,{description:"Only lock the Yarn version if it isn't already locked"});this.version=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins);if(e.get("yarnPath")&&this.onlyIfNeeded)return 0;let r=()=>{if(typeof Ur=="undefined")throw new Pe("The --install flag can only be used without explicit version specifier from the Yarn CLI");return`file://${process.argv[1]}`},i;if(this.version==="self")i=r();else if(this.version==="latest"||this.version==="berry"||this.version==="stable")i=`https://repo.yarnpkg.com/${await hm(e,"stable")}/packages/yarnpkg-cli/bin/yarn.js`;else if(this.version==="canary")i=`https://repo.yarnpkg.com/${await hm(e,"canary")}/packages/yarnpkg-cli/bin/yarn.js`;else if(this.version==="classic")i="https://nightly.yarnpkg.com/latest.js";else if(this.version.match(/^https?:/))i=this.version;else if(this.version.match(/^\.{0,2}[\\/]/)||j.isAbsolute(this.version))i=`file://${j.resolve(this.version)}`;else if(Wt.satisfiesWithPrereleases(this.version,">=2.0.0"))i=`https://repo.yarnpkg.com/${this.version}/packages/yarnpkg-cli/bin/yarn.js`;else if(Wt.satisfiesWithPrereleases(this.version,"^0.x || ^1.x"))i=`https://github.com/yarnpkg/yarn/releases/download/v${this.version}/yarn-${this.version}.js`;else if(Wt.validRange(this.version))i=`https://repo.yarnpkg.com/${await L8e(e,this.version)}/packages/yarnpkg-cli/bin/yarn.js`;else throw new Pe(`Invalid version descriptor "${this.version}"`);return(await Je.start({configuration:e,stdout:this.context.stdout,includeLogs:!this.context.quiet},async s=>{let o="file://",a;i.startsWith(o)?(s.reportInfo($.UNNAMED,`Downloading ${ae.pretty(e,i,Di.URL)}`),a=await K.readFilePromise(j.toPortablePath(i.slice(o.length)))):(s.reportInfo($.UNNAMED,`Retrieving ${ae.pretty(e,i,Di.PATH)}`),a=await ir.get(i,{configuration:e})),await YN(e,null,a,{report:s})})).exitCode()}};fm.paths=[["set","version"]],fm.usage=Re.Usage({description:"lock the Yarn version used by the project",details:"\n      This command will download a specific release of Yarn directly from the Yarn GitHub repository, will store it inside your project, and will change the `yarnPath` settings from your project `.yarnrc.yml` file to point to the new file.\n\n      A very good use case for this command is to enforce the version of Yarn used by the any single member of your team inside a same project - by doing this you ensure that you have control on Yarn upgrades and downgrades (including on your deployment servers), and get rid of most of the headaches related to someone using a slightly different version and getting a different behavior than you.\n\n      The version specifier can be:\n\n      - a tag:\n        - `latest` / `berry` / `stable` -> the most recent stable berry (`>=2.0.0`) release\n        - `canary` -> the most recent canary (release candidate) berry (`>=2.0.0`) release\n        - `classic` -> the most recent classic (`^0.x || ^1.x`) release\n\n      - a semver range (e.g. `2.x`) -> the most recent version satisfying the range (limited to berry releases)\n\n      - a semver version (e.g. `2.4.1`, `1.22.1`)\n\n      - a local file referenced through either a relative or absolute path\n\n      - `self` -> the version used to invoke the command\n    ",examples:[["Download the latest release from the Yarn repository","$0 set version latest"],["Download the latest canary release from the Yarn repository","$0 set version canary"],["Download the latest classic release from the Yarn repository","$0 set version classic"],["Download the most recent Yarn 3 build","$0 set version 3.x"],["Download a specific Yarn 2 build","$0 set version 2.0.0-rc.30"],["Switch back to a specific Yarn 1 release","$0 set version 1.22.1"],["Use a release from the local filesystem","$0 set version ./yarn.cjs"],["Use a release from a URL","$0 set version https://repo.yarnpkg.com/3.1.0/packages/yarnpkg-cli/bin/yarn.js"],["Download the version used to invoke the command","$0 set version self"]]});var bae=fm;async function L8e(t,e){let i=(await ir.get("https://repo.yarnpkg.com/tags",{configuration:t,jsonResponse:!0})).tags.filter(n=>Wt.satisfiesWithPrereleases(n,e));if(i.length===0)throw new Pe(`No matching release found for range ${ae.pretty(t,e,ae.Type.RANGE)}.`);return i[0]}async function hm(t,e){let r=await ir.get("https://repo.yarnpkg.com/tags",{configuration:t,jsonResponse:!0});if(!r.latest[e])throw new Pe(`Tag ${ae.pretty(t,e,ae.Type.RANGE)} not found`);return r.latest[e]}async function YN(t,e,r,{report:i}){var g;e===null&&await K.mktempPromise(async f=>{let h=k.join(f,"yarn.cjs");await K.writeFilePromise(h,r);let{stdout:p}=await Fr.execvp(process.execPath,[j.fromPortablePath(h),"--version"],{cwd:f,env:te(N({},process.env),{YARN_IGNORE_PATH:"1"})});if(e=p.trim(),!Bae.default.valid(e))throw new Error(`Invalid semver version. ${ae.pretty(t,"yarn --version",ae.Type.CODE)} returned:
+${e}`)});let n=(g=t.projectCwd)!=null?g:t.startingCwd,s=k.resolve(n,".yarn/releases"),o=k.resolve(s,`yarn-${e}.cjs`),a=k.relative(t.startingCwd,o),l=k.relative(n,o),c=t.get("yarnPath"),u=c===null||c.startsWith(`${s}/`);if(i.reportInfo($.UNNAMED,`Saving the new release in ${ae.pretty(t,a,"magenta")}`),await K.removePromise(k.dirname(o)),await K.mkdirPromise(k.dirname(o),{recursive:!0}),await K.writeFilePromise(o,r,{mode:493}),u){await ye.updateConfiguration(n,{yarnPath:l});let f=await At.tryFind(n)||new At;f.packageManager=`yarn@${e&&Se.isTaggedYarnVersion(e)?e:await hm(t,"stable")}`;let h={};f.exportTo(h);let p=k.join(n,At.fileName),m=`${JSON.stringify(h,null,f.indent)}
+`;await K.changeFilePromise(p,m,{automaticNewlines:!0})}}function Qae(t){return $[BI(t)]}var T8e=/## (?<code>YN[0-9]{4}) - `(?<name>[A-Z_]+)`\n\n(?<details>(?:.(?!##))+)/gs;async function O8e(t){let r=`https://repo.yarnpkg.com/${Se.isTaggedYarnVersion(Ur)?Ur:await hm(t,"canary")}/packages/gatsby/content/advanced/error-codes.md`,i=await ir.get(r,{configuration:t});return new Map(Array.from(i.toString().matchAll(T8e),({groups:n})=>{if(!n)throw new Error("Assertion failed: Expected the match to have been successful");let s=Qae(n.code);if(n.name!==s)throw new Error(`Assertion failed: Invalid error code data: Expected "${n.name}" to be named "${s}"`);return[n.code,n.details]}))}var pm=class extends Le{constructor(){super(...arguments);this.code=z.String({required:!1,validator:fp(gp(),[hp(/^YN[0-9]{4}$/)])});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins);if(typeof this.code!="undefined"){let r=Qae(this.code),i=ae.pretty(e,r,ae.Type.CODE),n=this.cli.format().header(`${this.code} - ${i}`),o=(await O8e(e)).get(this.code),a=typeof o!="undefined"?ae.jsonOrPretty(this.json,e,ae.tuple(ae.Type.MARKDOWN,{text:o,format:this.cli.format(),paragraphs:!0})):`This error code does not have a description.
+
+You can help us by editing this page on GitHub \u{1F642}:
+${ae.jsonOrPretty(this.json,e,ae.tuple(ae.Type.URL,"https://github.com/yarnpkg/berry/blob/master/packages/gatsby/content/advanced/error-codes.md"))}
+`;this.json?this.context.stdout.write(`${JSON.stringify({code:this.code,name:r,details:a})}
+`):this.context.stdout.write(`${n}
+
+${a}
+`)}else{let r={children:Se.mapAndFilter(Object.entries($),([i,n])=>Number.isNaN(Number(i))?Se.mapAndFilter.skip:{label:YA(Number(i)),value:ae.tuple(ae.Type.CODE,n)})};as.emitTree(r,{configuration:e,stdout:this.context.stdout,json:this.json})}}};pm.paths=[["explain"]],pm.usage=Re.Usage({description:"explain an error code",details:`
+      When the code argument is specified, this command prints its name and its details.
+
+      When used without arguments, this command lists all error codes and their names.
+    `,examples:[["Explain an error code","$0 explain YN0006"],["List all error codes","$0 explain"]]});var vae=pm;var Sae=ge(ts()),dm=class extends Le{constructor(){super(...arguments);this.all=z.Boolean("-A,--all",!1,{description:"Print versions of a package from the whole project"});this.recursive=z.Boolean("-R,--recursive",!1,{description:"Print information for all packages, including transitive dependencies"});this.extra=z.Array("-X,--extra",[],{description:"An array of requests of extra data provided by plugins"});this.cache=z.Boolean("--cache",!1,{description:"Print information about the cache entry of a package (path, size, checksum)"});this.dependents=z.Boolean("--dependents",!1,{description:"Print all dependents for each matching package"});this.manifest=z.Boolean("--manifest",!1,{description:"Print data obtained by looking at the package archive (license, homepage, ...)"});this.nameOnly=z.Boolean("--name-only",!1,{description:"Only print the name for the matching packages"});this.virtuals=z.Boolean("--virtuals",!1,{description:"Print each instance of the virtual packages"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.patterns=z.Rest()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i&&!this.all)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState();let s=new Set(this.extra);this.cache&&s.add("cache"),this.dependents&&s.add("dependents"),this.manifest&&s.add("manifest");let o=(x,{recursive:M})=>{let Y=x.anchoredLocator.locatorHash,U=new Map,J=[Y];for(;J.length>0;){let W=J.shift();if(U.has(W))continue;let ee=r.storedPackages.get(W);if(typeof ee=="undefined")throw new Error("Assertion failed: Expected the package to be registered");if(U.set(W,ee),P.isVirtualLocator(ee)&&J.push(P.devirtualizeLocator(ee).locatorHash),!(!M&&W!==Y))for(let Z of ee.dependencies.values()){let A=r.storedResolutions.get(Z.descriptorHash);if(typeof A=="undefined")throw new Error("Assertion failed: Expected the resolution to be registered");J.push(A)}}return U.values()},a=({recursive:x})=>{let M=new Map;for(let Y of r.workspaces)for(let U of o(Y,{recursive:x}))M.set(U.locatorHash,U);return M.values()},l=({all:x,recursive:M})=>x&&M?r.storedPackages.values():x?a({recursive:M}):o(i,{recursive:M}),c=({all:x,recursive:M})=>{let Y=l({all:x,recursive:M}),U=this.patterns.map(ee=>{let Z=P.parseLocator(ee),A=Sae.default.makeRe(P.stringifyIdent(Z)),ne=P.isVirtualLocator(Z),le=ne?P.devirtualizeLocator(Z):Z;return Ae=>{let T=P.stringifyIdent(Ae);if(!A.test(T))return!1;if(Z.reference==="unknown")return!0;let L=P.isVirtualLocator(Ae),Ee=L?P.devirtualizeLocator(Ae):Ae;return!(ne&&L&&Z.reference!==Ae.reference||le.reference!==Ee.reference)}}),J=Se.sortMap([...Y],ee=>P.stringifyLocator(ee));return{selection:J.filter(ee=>U.length===0||U.some(Z=>Z(ee))),sortedLookup:J}},{selection:u,sortedLookup:g}=c({all:this.all,recursive:this.recursive});if(u.length===0)throw new Pe("No package matched your request");let f=new Map;if(this.dependents)for(let x of g)for(let M of x.dependencies.values()){let Y=r.storedResolutions.get(M.descriptorHash);if(typeof Y=="undefined")throw new Error("Assertion failed: Expected the resolution to be registered");Se.getArrayWithDefault(f,Y).push(x)}let h=new Map;for(let x of g){if(!P.isVirtualLocator(x))continue;let M=P.devirtualizeLocator(x);Se.getArrayWithDefault(h,M.locatorHash).push(x)}let p={},m={children:p},y=e.makeFetcher(),Q={project:r,fetcher:y,cache:n,checksums:r.storedChecksums,report:new pi,cacheOptions:{skipIntegrityCheck:!0},skipIntegrityCheck:!0},S=[async(x,M,Y)=>{var W,ee;if(!M.has("manifest"))return;let U=await y.fetch(x,Q),J;try{J=await At.find(U.prefixPath,{baseFs:U.packageFs})}finally{(W=U.releaseFs)==null||W.call(U)}Y("Manifest",{License:ae.tuple(ae.Type.NO_HINT,J.license),Homepage:ae.tuple(ae.Type.URL,(ee=J.raw.homepage)!=null?ee:null)})},async(x,M,Y)=>{var A;if(!M.has("cache"))return;let U={mockedPackages:r.disabledLocators,unstablePackages:r.conditionalLocators},J=(A=r.storedChecksums.get(x.locatorHash))!=null?A:null,W=n.getLocatorPath(x,J,U),ee;if(W!==null)try{ee=K.statSync(W)}catch{}let Z=typeof ee!="undefined"?[ee.size,ae.Type.SIZE]:void 0;Y("Cache",{Checksum:ae.tuple(ae.Type.NO_HINT,J),Path:ae.tuple(ae.Type.PATH,W),Size:Z})}];for(let x of u){let M=P.isVirtualLocator(x);if(!this.virtuals&&M)continue;let Y={},U={value:[x,ae.Type.LOCATOR],children:Y};if(p[P.stringifyLocator(x)]=U,this.nameOnly){delete U.children;continue}let J=h.get(x.locatorHash);typeof J!="undefined"&&(Y.Instances={label:"Instances",value:ae.tuple(ae.Type.NUMBER,J.length)}),Y.Version={label:"Version",value:ae.tuple(ae.Type.NO_HINT,x.version)};let W=(Z,A)=>{let ne={};if(Y[Z]=ne,Array.isArray(A))ne.children=A.map(le=>({value:le}));else{let le={};ne.children=le;for(let[Ae,T]of Object.entries(A))typeof T!="undefined"&&(le[Ae]={label:Ae,value:T})}};if(!M){for(let Z of S)await Z(x,s,W);await e.triggerHook(Z=>Z.fetchPackageInfo,x,s,W)}x.bin.size>0&&!M&&W("Exported Binaries",[...x.bin.keys()].map(Z=>ae.tuple(ae.Type.PATH,Z)));let ee=f.get(x.locatorHash);typeof ee!="undefined"&&ee.length>0&&W("Dependents",ee.map(Z=>ae.tuple(ae.Type.LOCATOR,Z))),x.dependencies.size>0&&!M&&W("Dependencies",[...x.dependencies.values()].map(Z=>{var le;let A=r.storedResolutions.get(Z.descriptorHash),ne=typeof A!="undefined"&&(le=r.storedPackages.get(A))!=null?le:null;return ae.tuple(ae.Type.RESOLUTION,{descriptor:Z,locator:ne})})),x.peerDependencies.size>0&&M&&W("Peer dependencies",[...x.peerDependencies.values()].map(Z=>{var Ae,T;let A=x.dependencies.get(Z.identHash),ne=typeof A!="undefined"&&(Ae=r.storedResolutions.get(A.descriptorHash))!=null?Ae:null,le=ne!==null&&(T=r.storedPackages.get(ne))!=null?T:null;return ae.tuple(ae.Type.RESOLUTION,{descriptor:Z,locator:le})}))}as.emitTree(m,{configuration:e,json:this.json,stdout:this.context.stdout,separators:this.nameOnly?0:2})}};dm.paths=[["info"]],dm.usage=Re.Usage({description:"see information related to packages",details:"\n      This command prints various information related to the specified packages, accepting glob patterns.\n\n      By default, if the locator reference is missing, Yarn will default to print the information about all the matching direct dependencies of the package for the active workspace. To instead print all versions of the package that are direct dependencies of any of your workspaces, use the `-A,--all` flag. Adding the `-R,--recursive` flag will also report transitive dependencies.\n\n      Some fields will be hidden by default in order to keep the output readable, but can be selectively displayed by using additional options (`--dependents`, `--manifest`, `--virtuals`, ...) described in the option descriptions.\n\n      Note that this command will only print the information directly related to the selected packages - if you wish to know why the package is there in the first place, use `yarn why` which will do just that (it also provides a `-R,--recursive` flag that may be of some help).\n    ",examples:[["Show information about Lodash","$0 info lodash"]]});var kae=dm;var ob=ge(hc());Es();var Cm=class extends Le{constructor(){super(...arguments);this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.immutable=z.Boolean("--immutable",{description:"Abort with an error exit code if the lockfile was to be modified"});this.immutableCache=z.Boolean("--immutable-cache",{description:"Abort with an error exit code if the cache folder was to be modified"});this.checkCache=z.Boolean("--check-cache",!1,{description:"Always refetch the packages and ensure that their checksums are consistent"});this.inlineBuilds=z.Boolean("--inline-builds",{description:"Verbosely print the output of the build steps of dependencies"});this.mode=z.String("--mode",{description:"Change what artifacts installs generate",validator:nn(di)});this.cacheFolder=z.String("--cache-folder",{hidden:!0});this.frozenLockfile=z.Boolean("--frozen-lockfile",{hidden:!0});this.ignoreEngines=z.Boolean("--ignore-engines",{hidden:!0});this.nonInteractive=z.Boolean("--non-interactive",{hidden:!0});this.preferOffline=z.Boolean("--prefer-offline",{hidden:!0});this.production=z.Boolean("--production",{hidden:!0});this.registry=z.String("--registry",{hidden:!0});this.silent=z.Boolean("--silent",{hidden:!0});this.networkTimeout=z.String("--network-timeout",{hidden:!0})}async execute(){var g;let e=await ye.find(this.context.cwd,this.context.plugins);typeof this.inlineBuilds!="undefined"&&e.useWithSource("<cli>",{enableInlineBuilds:this.inlineBuilds},e.startingCwd,{overwrite:!0});let r=!!process.env.FUNCTION_TARGET||!!process.env.GOOGLE_RUNTIME,i=async(f,{error:h})=>{let p=await Je.start({configuration:e,stdout:this.context.stdout,includeFooter:!1},async m=>{h?m.reportError($.DEPRECATED_CLI_SETTINGS,f):m.reportWarning($.DEPRECATED_CLI_SETTINGS,f)});return p.hasErrors()?p.exitCode():null};if(typeof this.ignoreEngines!="undefined"){let f=await i("The --ignore-engines option is deprecated; engine checking isn't a core feature anymore",{error:!ob.default.VERCEL});if(f!==null)return f}if(typeof this.registry!="undefined"){let f=await i("The --registry option is deprecated; prefer setting npmRegistryServer in your .yarnrc.yml file",{error:!1});if(f!==null)return f}if(typeof this.preferOffline!="undefined"){let f=await i("The --prefer-offline flag is deprecated; use the --cached flag with 'yarn add' instead",{error:!ob.default.VERCEL});if(f!==null)return f}if(typeof this.production!="undefined"){let f=await i("The --production option is deprecated on 'install'; use 'yarn workspaces focus' instead",{error:!0});if(f!==null)return f}if(typeof this.nonInteractive!="undefined"){let f=await i("The --non-interactive option is deprecated",{error:!r});if(f!==null)return f}if(typeof this.frozenLockfile!="undefined"&&(await i("The --frozen-lockfile option is deprecated; use --immutable and/or --immutable-cache instead",{error:!1}),this.immutable=this.frozenLockfile),typeof this.cacheFolder!="undefined"){let f=await i("The cache-folder option has been deprecated; use rc settings instead",{error:!ob.default.NETLIFY});if(f!==null)return f}let n=this.mode===di.UpdateLockfile;if(n&&(this.immutable||this.immutableCache))throw new Pe(`${ae.pretty(e,"--immutable",ae.Type.CODE)} and ${ae.pretty(e,"--immutable-cache",ae.Type.CODE)} cannot be used with ${ae.pretty(e,"--mode=update-lockfile",ae.Type.CODE)}`);let s=((g=this.immutable)!=null?g:e.get("enableImmutableInstalls"))&&!n,o=this.immutableCache&&!n;if(e.projectCwd!==null){let f=await Je.start({configuration:e,json:this.json,stdout:this.context.stdout,includeFooter:!1},async h=>{await M8e(e,s)&&(h.reportInfo($.AUTOMERGE_SUCCESS,"Automatically fixed merge conflicts \u{1F44D}"),h.reportSeparator())});if(f.hasErrors())return f.exitCode()}if(e.projectCwd!==null&&typeof e.sources.get("nodeLinker")=="undefined"){let f=e.projectCwd,h;try{h=await K.readFilePromise(k.join(f,Pt.lockfile),"utf8")}catch{}if(h==null?void 0:h.includes("yarn lockfile v1")){let p=await Je.start({configuration:e,json:this.json,stdout:this.context.stdout,includeFooter:!1},async m=>{m.reportInfo($.AUTO_NM_SUCCESS,"Migrating from Yarn 1; automatically enabling the compatibility node-modules linker \u{1F44D}"),m.reportSeparator(),e.use("<compat>",{nodeLinker:"node-modules"},f,{overwrite:!0}),await ye.updateConfiguration(f,{nodeLinker:"node-modules"})});if(p.hasErrors())return p.exitCode()}}if(e.projectCwd!==null){let f=await Je.start({configuration:e,json:this.json,stdout:this.context.stdout,includeFooter:!1},async h=>{var p;((p=ye.telemetry)==null?void 0:p.isNew)&&(h.reportInfo($.TELEMETRY_NOTICE,"Yarn will periodically gather anonymous telemetry: https://yarnpkg.com/advanced/telemetry"),h.reportInfo($.TELEMETRY_NOTICE,`Run ${ae.pretty(e,"yarn config set --home enableTelemetry 0",ae.Type.CODE)} to disable`),h.reportSeparator())});if(f.hasErrors())return f.exitCode()}let{project:a,workspace:l}=await ze.find(e,this.context.cwd),c=await Nt.find(e,{immutable:o,check:this.checkCache});if(!l)throw new ht(a.cwd,this.context.cwd);return await a.restoreInstallState({restoreResolutions:!1}),(await Je.start({configuration:e,json:this.json,stdout:this.context.stdout,includeLogs:!0},async f=>{await a.install({cache:c,report:f,immutable:s,mode:this.mode})})).exitCode()}};Cm.paths=[["install"],Re.Default],Cm.usage=Re.Usage({description:"install the project dependencies",details:`
+      This command sets up your project if needed. The installation is split into four different steps that each have their own characteristics:
+
+      - **Resolution:** First the package manager will resolve your dependencies. The exact way a dependency version is privileged over another isn't standardized outside of the regular semver guarantees. If a package doesn't resolve to what you would expect, check that all dependencies are correctly declared (also check our website for more information: ).
+
+      - **Fetch:** Then we download all the dependencies if needed, and make sure that they're all stored within our cache (check the value of \`cacheFolder\` in \`yarn config\` to see where the cache files are stored).
+
+      - **Link:** Then we send the dependency tree information to internal plugins tasked with writing them on the disk in some form (for example by generating the .pnp.cjs file you might know).
+
+      - **Build:** Once the dependency tree has been written on the disk, the package manager will now be free to run the build scripts for all packages that might need it, in a topological order compatible with the way they depend on one another. See https://yarnpkg.com/advanced/lifecycle-scripts for detail.
+
+      Note that running this command is not part of the recommended workflow. Yarn supports zero-installs, which means that as long as you store your cache and your .pnp.cjs file inside your repository, everything will work without requiring any install right after cloning your repository or switching branches.
+
+      If the \`--immutable\` option is set (defaults to true on CI), Yarn will abort with an error exit code if the lockfile was to be modified (other paths can be added using the \`immutablePatterns\` configuration setting). For backward compatibility we offer an alias under the name of \`--frozen-lockfile\`, but it will be removed in a later release.
+
+      If the \`--immutable-cache\` option is set, Yarn will abort with an error exit code if the cache folder was to be modified (either because files would be added, or because they'd be removed).
+
+      If the \`--check-cache\` option is set, Yarn will always refetch the packages and will ensure that their checksum matches what's 1/ described in the lockfile 2/ inside the existing cache files (if present). This is recommended as part of your CI workflow if you're both following the Zero-Installs model and accepting PRs from third-parties, as they'd otherwise have the ability to alter the checked-in packages before submitting them.
+
+      If the \`--inline-builds\` option is set, Yarn will verbosely print the output of the build steps of your dependencies (instead of writing them into individual files). This is likely useful mostly for debug purposes only when using Docker-like environments.
+
+      If the \`--mode=<mode>\` option is set, Yarn will change which artifacts are generated. The modes currently supported are:
+
+      - \`skip-build\` will not run the build scripts at all. Note that this is different from setting \`enableScripts\` to false because the later will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.
+
+      - \`update-lockfile\` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.
+    `,examples:[["Install the project","$0 install"],["Validate a project when using Zero-Installs","$0 install --immutable --immutable-cache"],["Validate a project when using Zero-Installs (slightly safer if you accept external PRs)","$0 install --immutable --immutable-cache --check-cache"]]});var xae=Cm,U8e="|||||||",K8e=">>>>>>>",H8e="=======",Pae="<<<<<<<";async function M8e(t,e){if(!t.projectCwd)return!1;let r=k.join(t.projectCwd,t.get("lockfileFilename"));if(!await K.existsPromise(r))return!1;let i=await K.readFilePromise(r,"utf8");if(!i.includes(Pae))return!1;if(e)throw new ct($.AUTOMERGE_IMMUTABLE,"Cannot autofix a lockfile when running an immutable install");let[n,s]=j8e(i),o,a;try{o=Qi(n),a=Qi(s)}catch(c){throw new ct($.AUTOMERGE_FAILED_TO_PARSE,"The individual variants of the lockfile failed to parse")}let l=N(N({},o),a);for(let[c,u]of Object.entries(l))typeof u=="string"&&delete l[c];return await K.changeFilePromise(r,Na(l),{automaticNewlines:!0}),!0}function j8e(t){let e=[[],[]],r=t.split(/\r?\n/g),i=!1;for(;r.length>0;){let n=r.shift();if(typeof n=="undefined")throw new Error("Assertion failed: Some lines should remain");if(n.startsWith(Pae)){for(;r.length>0;){let s=r.shift();if(typeof s=="undefined")throw new Error("Assertion failed: Some lines should remain");if(s===H8e){i=!1;break}else if(i||s.startsWith(U8e)){i=!0;continue}else e[0].push(s)}for(;r.length>0;){let s=r.shift();if(typeof s=="undefined")throw new Error("Assertion failed: Some lines should remain");if(s.startsWith(K8e))break;e[1].push(s)}}else e[0].push(n),e[1].push(n)}return[e[0].join(`
+`),e[1].join(`
+`)]}var mm=class extends Le{constructor(){super(...arguments);this.all=z.Boolean("-A,--all",!1,{description:"Link all workspaces belonging to the target project to the current one"});this.private=z.Boolean("-p,--private",!1,{description:"Also link private workspaces belonging to the target project to the current one"});this.relative=z.Boolean("-r,--relative",!1,{description:"Link workspaces using relative paths instead of absolute paths"});this.destination=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState({restoreResolutions:!1});let s=k.resolve(this.context.cwd,j.toPortablePath(this.destination)),o=await ye.find(s,this.context.plugins,{useRc:!1,strict:!1}),{project:a,workspace:l}=await ze.find(o,s);if(r.cwd===a.cwd)throw new Pe("Invalid destination; Can't link the project to itself");if(!l)throw new ht(a.cwd,s);let c=r.topLevelWorkspace,u=[];if(this.all){for(let f of a.workspaces)f.manifest.name&&(!f.manifest.private||this.private)&&u.push(f);if(u.length===0)throw new Pe("No workspace found to be linked in the target project")}else{if(!l.manifest.name)throw new Pe("The target workspace doesn't have a name and thus cannot be linked");if(l.manifest.private&&!this.private)throw new Pe("The target workspace is marked private - use the --private flag to link it anyway");u.push(l)}for(let f of u){let h=P.stringifyIdent(f.locator),p=this.relative?k.relative(r.cwd,f.cwd):f.cwd;c.manifest.resolutions.push({pattern:{descriptor:{fullName:h}},reference:`portal:${p}`})}return(await Je.start({configuration:e,stdout:this.context.stdout},async f=>{await r.install({cache:n,report:f})})).exitCode()}};mm.paths=[["link"]],mm.usage=Re.Usage({description:"connect the local project to another one",details:"\n      This command will set a new `resolutions` field in the project-level manifest and point it to the workspace at the specified location (even if part of another project).\n    ",examples:[["Register a remote workspace for use in the current project","$0 link ~/ts-loader"],["Register all workspaces from a remote project for use in the current project","$0 link ~/jest --all"]]});var Dae=mm;var Em=class extends Le{constructor(){super(...arguments);this.args=z.Proxy()}async execute(){return this.cli.run(["exec","node",...this.args])}};Em.paths=[["node"]],Em.usage=Re.Usage({description:"run node with the hook already setup",details:`
+      This command simply runs Node. It also makes sure to call it in a way that's compatible with the current project (for example, on PnP projects the environment will be setup in such a way that PnP will be correctly injected into the environment).
+
+      The Node process will use the exact same version of Node as the one used to run Yarn itself, which might be a good way to ensure that your commands always use a consistent Node version.
+    `,examples:[["Run a Node script","$0 node ./my-script.js"]]});var Rae=Em;var Hae=ge(require("os"));var Nae=ge(require("os"));var G8e="https://raw.githubusercontent.com/yarnpkg/berry/master/plugins.yml";async function yu(t){let e=await ir.get(G8e,{configuration:t});return Qi(e.toString())}var Im=class extends Le{constructor(){super(...arguments);this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins);return(await Je.start({configuration:e,json:this.json,stdout:this.context.stdout},async i=>{let n=await yu(e);for(let s of Object.entries(n)){let[l,o]=s,a=o,{experimental:c}=a,u=Tr(a,["experimental"]);let g=l;c&&(g+=" [experimental]"),i.reportJson(N({name:l,experimental:c},u)),i.reportInfo(null,g)}})).exitCode()}};Im.paths=[["plugin","list"]],Im.usage=Re.Usage({category:"Plugin-related commands",description:"list the available official plugins",details:"\n      This command prints the plugins available directly from the Yarn repository. Only those plugins can be referenced by name in `yarn plugin import`.\n    ",examples:[["List the official plugins","$0 plugin list"]]});var Fae=Im;var Y8e=/^[0-9]+$/;function Lae(t){return Y8e.test(t)?`pull/${t}/head`:t}var q8e=({repository:t,branch:e},r)=>[["git","init",j.fromPortablePath(r)],["git","remote","add","origin",t],["git","fetch","origin","--depth=1",Lae(e)],["git","reset","--hard","FETCH_HEAD"]],J8e=({branch:t})=>[["git","fetch","origin","--depth=1",Lae(t),"--force"],["git","reset","--hard","FETCH_HEAD"],["git","clean","-dfx"]],W8e=({plugins:t,noMinify:e},r)=>[["yarn","build:cli",...new Array().concat(...t.map(i=>["--plugin",k.resolve(r,i)])),...e?["--no-minify"]:[],"|"]],ym=class extends Le{constructor(){super(...arguments);this.installPath=z.String("--path",{description:"The path where the repository should be cloned to"});this.repository=z.String("--repository","https://github.com/yarnpkg/berry.git",{description:"The repository that should be cloned"});this.branch=z.String("--branch","master",{description:"The branch of the repository that should be cloned"});this.plugins=z.Array("--plugin",[],{description:"An array of additional plugins that should be included in the bundle"});this.noMinify=z.Boolean("--no-minify",!1,{description:"Build a bundle for development (debugging) - non-minified and non-mangled"});this.force=z.Boolean("-f,--force",!1,{description:"Always clone the repository instead of trying to fetch the latest commits"});this.skipPlugins=z.Boolean("--skip-plugins",!1,{description:"Skip updating the contrib plugins"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r}=await ze.find(e,this.context.cwd),i=typeof this.installPath!="undefined"?k.resolve(this.context.cwd,j.toPortablePath(this.installPath)):k.resolve(j.toPortablePath((0,Nae.tmpdir)()),"yarnpkg-sources",Dn.makeHash(this.repository).slice(0,6));return(await Je.start({configuration:e,stdout:this.context.stdout},async s=>{await JN(this,{configuration:e,report:s,target:i}),s.reportSeparator(),s.reportInfo($.UNNAMED,"Building a fresh bundle"),s.reportSeparator(),await wm(W8e(this,i),{configuration:e,context:this.context,target:i}),s.reportSeparator();let o=k.resolve(i,"packages/yarnpkg-cli/bundles/yarn.js"),a=await K.readFilePromise(o);await YN(e,"sources",a,{report:s}),this.skipPlugins||await z8e(this,{project:r,report:s,target:i})})).exitCode()}};ym.paths=[["set","version","from","sources"]],ym.usage=Re.Usage({description:"build Yarn from master",details:`
+      This command will clone the Yarn repository into a temporary folder, then build it. The resulting bundle will then be copied into the local project.
+
+      By default, it also updates all contrib plugins to the same commit the bundle is built from. This behavior can be disabled by using the \`--skip-plugins\` flag.
+    `,examples:[["Build Yarn from master","$0 set version from sources"]]});var Tae=ym;async function wm(t,{configuration:e,context:r,target:i}){for(let[n,...s]of t){let o=s[s.length-1]==="|";if(o&&s.pop(),o)await Fr.pipevp(n,s,{cwd:i,stdin:r.stdin,stdout:r.stdout,stderr:r.stderr,strict:!0});else{r.stdout.write(`${ae.pretty(e,`  $ ${[n,...s].join(" ")}`,"grey")}
+`);try{await Fr.execvp(n,s,{cwd:i,strict:!0})}catch(a){throw r.stdout.write(a.stdout||a.stack),a}}}}async function JN(t,{configuration:e,report:r,target:i}){let n=!1;if(!t.force&&K.existsSync(k.join(i,".git"))){r.reportInfo($.UNNAMED,"Fetching the latest commits"),r.reportSeparator();try{await wm(J8e(t),{configuration:e,context:t.context,target:i}),n=!0}catch(s){r.reportSeparator(),r.reportWarning($.UNNAMED,"Repository update failed; we'll try to regenerate it")}}n||(r.reportInfo($.UNNAMED,"Cloning the remote repository"),r.reportSeparator(),await K.removePromise(i),await K.mkdirPromise(i,{recursive:!0}),await wm(q8e(t,i),{configuration:e,context:t.context,target:i}))}async function z8e(t,{project:e,report:r,target:i}){let n=await yu(e.configuration),s=new Set(Object.keys(n));for(let o of e.configuration.plugins.keys())!s.has(o)||await qN(o,t,{project:e,report:r,target:i})}var Oae=ge(ti()),Mae=ge(require("url")),Uae=ge(require("vm"));var Bm=class extends Le{constructor(){super(...arguments);this.name=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins);return(await Je.start({configuration:e,stdout:this.context.stdout},async i=>{let{project:n}=await ze.find(e,this.context.cwd),s,o;if(this.name.match(/^\.{0,2}[\\/]/)||j.isAbsolute(this.name)){let a=k.resolve(this.context.cwd,j.toPortablePath(this.name));i.reportInfo($.UNNAMED,`Reading ${ae.pretty(e,a,ae.Type.PATH)}`),s=k.relative(n.cwd,a),o=await K.readFilePromise(a)}else{let a;if(this.name.match(/^https?:/)){try{new Mae.URL(this.name)}catch{throw new ct($.INVALID_PLUGIN_REFERENCE,`Plugin specifier "${this.name}" is neither a plugin name nor a valid url`)}s=this.name,a=this.name}else{let l=P.parseLocator(this.name.replace(/^((@yarnpkg\/)?plugin-)?/,"@yarnpkg/plugin-"));if(l.reference!=="unknown"&&!Oae.default.valid(l.reference))throw new ct($.UNNAMED,"Official plugins only accept strict version references. Use an explicit URL if you wish to download them from another location.");let c=P.stringifyIdent(l),u=await yu(e);if(!Object.prototype.hasOwnProperty.call(u,c))throw new ct($.PLUGIN_NAME_NOT_FOUND,`Couldn't find a plugin named "${c}" on the remote registry. Note that only the plugins referenced on our website (https://github.com/yarnpkg/berry/blob/master/plugins.yml) can be referenced by their name; any other plugin will have to be referenced through its public url (for example https://github.com/yarnpkg/berry/raw/master/packages/plugin-typescript/bin/%40yarnpkg/plugin-typescript.js).`);s=c,a=u[c].url,l.reference!=="unknown"?a=a.replace(/\/master\//,`/${c}/${l.reference}/`):Ur!==null&&(a=a.replace(/\/master\//,`/@yarnpkg/cli/${Ur}/`))}i.reportInfo($.UNNAMED,`Downloading ${ae.pretty(e,a,"green")}`),o=await ir.get(a,{configuration:e})}await WN(s,o,{project:n,report:i})})).exitCode()}};Bm.paths=[["plugin","import"]],Bm.usage=Re.Usage({category:"Plugin-related commands",description:"download a plugin",details:`
+      This command downloads the specified plugin from its remote location and updates the configuration to reference it in further CLI invocations.
+
+      Three types of plugin references are accepted:
+
+      - If the plugin is stored within the Yarn repository, it can be referenced by name.
+      - Third-party plugins can be referenced directly through their public urls.
+      - Local plugins can be referenced by their path on the disk.
+
+      Plugins cannot be downloaded from the npm registry, and aren't allowed to have dependencies (they need to be bundled into a single file, possibly thanks to the \`@yarnpkg/builder\` package).
+    `,examples:[['Download and activate the "@yarnpkg/plugin-exec" plugin',"$0 plugin import @yarnpkg/plugin-exec"],['Download and activate the "@yarnpkg/plugin-exec" plugin (shorthand)',"$0 plugin import exec"],["Download and activate a community plugin","$0 plugin import https://example.org/path/to/plugin.js"],["Activate a local plugin","$0 plugin import ./path/to/plugin.js"]]});var Kae=Bm;async function WN(t,e,{project:r,report:i}){let{configuration:n}=r,s={},o={exports:s};(0,Uae.runInNewContext)(e.toString(),{module:o,exports:s});let a=o.exports.name,l=`.yarn/plugins/${a}.cjs`,c=k.resolve(r.cwd,l);i.reportInfo($.UNNAMED,`Saving the new plugin in ${ae.pretty(n,l,"magenta")}`),await K.mkdirPromise(k.dirname(c),{recursive:!0}),await K.writeFilePromise(c,e);let u={path:l,spec:t};await ye.updateConfiguration(r.cwd,g=>{let f=[],h=!1;for(let p of g.plugins||[]){let m=typeof p!="string"?p.path:p,y=k.resolve(r.cwd,j.toPortablePath(m)),{name:Q}=Se.dynamicRequire(y);Q!==a?f.push(p):(f.push(u),h=!0)}return h||f.push(u),te(N({},g),{plugins:f})})}var _8e=({pluginName:t,noMinify:e},r)=>[["yarn",`build:${t}`,...e?["--no-minify"]:[],"|"]],bm=class extends Le{constructor(){super(...arguments);this.installPath=z.String("--path",{description:"The path where the repository should be cloned to"});this.repository=z.String("--repository","https://github.com/yarnpkg/berry.git",{description:"The repository that should be cloned"});this.branch=z.String("--branch","master",{description:"The branch of the repository that should be cloned"});this.noMinify=z.Boolean("--no-minify",!1,{description:"Build a plugin for development (debugging) - non-minified and non-mangled"});this.force=z.Boolean("-f,--force",!1,{description:"Always clone the repository instead of trying to fetch the latest commits"});this.name=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),r=typeof this.installPath!="undefined"?k.resolve(this.context.cwd,j.toPortablePath(this.installPath)):k.resolve(j.toPortablePath((0,Hae.tmpdir)()),"yarnpkg-sources",Dn.makeHash(this.repository).slice(0,6));return(await Je.start({configuration:e,stdout:this.context.stdout},async n=>{let{project:s}=await ze.find(e,this.context.cwd),o=P.parseIdent(this.name.replace(/^((@yarnpkg\/)?plugin-)?/,"@yarnpkg/plugin-")),a=P.stringifyIdent(o),l=await yu(e);if(!Object.prototype.hasOwnProperty.call(l,a))throw new ct($.PLUGIN_NAME_NOT_FOUND,`Couldn't find a plugin named "${a}" on the remote registry. Note that only the plugins referenced on our website (https://github.com/yarnpkg/berry/blob/master/plugins.yml) can be built and imported from sources.`);let c=a;await JN(this,{configuration:e,report:n,target:r}),await qN(c,this,{project:s,report:n,target:r})})).exitCode()}};bm.paths=[["plugin","import","from","sources"]],bm.usage=Re.Usage({category:"Plugin-related commands",description:"build a plugin from sources",details:`
+      This command clones the Yarn repository into a temporary folder, builds the specified contrib plugin and updates the configuration to reference it in further CLI invocations.
+
+      The plugins can be referenced by their short name if sourced from the official Yarn repository.
+    `,examples:[['Build and activate the "@yarnpkg/plugin-exec" plugin',"$0 plugin import from sources @yarnpkg/plugin-exec"],['Build and activate the "@yarnpkg/plugin-exec" plugin (shorthand)',"$0 plugin import from sources exec"]]});var jae=bm;async function qN(t,{context:e,noMinify:r},{project:i,report:n,target:s}){let o=t.replace(/@yarnpkg\//,""),{configuration:a}=i;n.reportSeparator(),n.reportInfo($.UNNAMED,`Building a fresh ${o}`),n.reportSeparator(),await wm(_8e({pluginName:o,noMinify:r},s),{configuration:a,context:e,target:s}),n.reportSeparator();let l=k.resolve(s,`packages/${o}/bundles/${t}.js`),c=await K.readFilePromise(l);await WN(t,c,{project:i,report:n})}var Qm=class extends Le{constructor(){super(...arguments);this.name=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r}=await ze.find(e,this.context.cwd);return(await Je.start({configuration:e,stdout:this.context.stdout},async n=>{let s=this.name,o=P.parseIdent(s);if(!e.plugins.has(s))throw new Pe(`${P.prettyIdent(e,o)} isn't referenced by the current configuration`);let a=`.yarn/plugins/${s}.cjs`,l=k.resolve(r.cwd,a);K.existsSync(l)&&(n.reportInfo($.UNNAMED,`Removing ${ae.pretty(e,a,ae.Type.PATH)}...`),await K.removePromise(l)),n.reportInfo($.UNNAMED,"Updating the configuration..."),await ye.updateConfiguration(r.cwd,c=>{if(!Array.isArray(c.plugins))return c;let u=c.plugins.filter(g=>g.path!==a);return c.plugins.length===u.length?c:te(N({},c),{plugins:u})})})).exitCode()}};Qm.paths=[["plugin","remove"]],Qm.usage=Re.Usage({category:"Plugin-related commands",description:"remove a plugin",details:`
+      This command deletes the specified plugin from the .yarn/plugins folder and removes it from the configuration.
+
+      **Note:** The plugins have to be referenced by their name property, which can be obtained using the \`yarn plugin runtime\` command. Shorthands are not allowed.
+   `,examples:[["Remove a plugin imported from the Yarn repository","$0 plugin remove @yarnpkg/plugin-typescript"],["Remove a plugin imported from a local file","$0 plugin remove my-local-plugin"]]});var Gae=Qm;var vm=class extends Le{constructor(){super(...arguments);this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins);return(await Je.start({configuration:e,json:this.json,stdout:this.context.stdout},async i=>{for(let n of e.plugins.keys()){let s=this.context.plugins.plugins.has(n),o=n;s&&(o+=" [builtin]"),i.reportJson({name:n,builtin:s}),i.reportInfo(null,`${o}`)}})).exitCode()}};vm.paths=[["plugin","runtime"]],vm.usage=Re.Usage({category:"Plugin-related commands",description:"list the active plugins",details:`
+      This command prints the currently active plugins. Will be displayed both builtin plugins and external plugins.
+    `,examples:[["List the currently active plugins","$0 plugin runtime"]]});var Yae=vm;var Sm=class extends Le{constructor(){super(...arguments);this.idents=z.Rest()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i)throw new ht(r.cwd,this.context.cwd);let s=new Set;for(let a of this.idents)s.add(P.parseIdent(a).identHash);if(await r.restoreInstallState({restoreResolutions:!1}),await r.resolveEverything({cache:n,report:new pi}),s.size>0)for(let a of r.storedPackages.values())s.has(a.identHash)&&r.storedBuildState.delete(a.locatorHash);else r.storedBuildState.clear();return(await Je.start({configuration:e,stdout:this.context.stdout,includeLogs:!this.context.quiet},async a=>{await r.install({cache:n,report:a})})).exitCode()}};Sm.paths=[["rebuild"]],Sm.usage=Re.Usage({description:"rebuild the project's native packages",details:`
+      This command will automatically cause Yarn to forget about previous compilations of the given packages and to run them again.
+
+      Note that while Yarn forgets the compilation, the previous artifacts aren't erased from the filesystem and may affect the next builds (in good or bad). To avoid this, you may remove the .yarn/unplugged folder, or any other relevant location where packages might have been stored (Yarn may offer a way to do that automatically in the future).
+
+      By default all packages will be rebuilt, but you can filter the list by specifying the names of the packages you want to clear from memory.
+    `,examples:[["Rebuild all packages","$0 rebuild"],["Rebuild fsevents only","$0 rebuild fsevents"]]});var qae=Sm;var zN=ge(ts());Es();var km=class extends Le{constructor(){super(...arguments);this.all=z.Boolean("-A,--all",!1,{description:"Apply the operation to all workspaces from the current project"});this.mode=z.String("--mode",{description:"Change what artifacts installs generate",validator:nn(di)});this.patterns=z.Rest()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState({restoreResolutions:!1});let s=this.all?r.workspaces:[i],o=[Hr.REGULAR,Hr.DEVELOPMENT,Hr.PEER],a=[],l=!1,c=[];for(let h of this.patterns){let p=!1,m=P.parseIdent(h);for(let y of s){let Q=[...y.manifest.peerDependenciesMeta.keys()];for(let S of(0,zN.default)(Q,h))y.manifest.peerDependenciesMeta.delete(S),l=!0,p=!0;for(let S of o){let x=y.manifest.getForScope(S),M=[...x.values()].map(Y=>P.stringifyIdent(Y));for(let Y of(0,zN.default)(M,P.stringifyIdent(m))){let{identHash:U}=P.parseIdent(Y),J=x.get(U);if(typeof J=="undefined")throw new Error("Assertion failed: Expected the descriptor to be registered");y.manifest[S].delete(U),c.push([y,S,J]),l=!0,p=!0}}}p||a.push(h)}let u=a.length>1?"Patterns":"Pattern",g=a.length>1?"don't":"doesn't",f=this.all?"any":"this";if(a.length>0)throw new Pe(`${u} ${ae.prettyList(e,a,Di.CODE)} ${g} match any packages referenced by ${f} workspace`);return l?(await e.triggerMultipleHooks(p=>p.afterWorkspaceDependencyRemoval,c),(await Je.start({configuration:e,stdout:this.context.stdout},async p=>{await r.install({cache:n,report:p,mode:this.mode})})).exitCode()):0}};km.paths=[["remove"]],km.usage=Re.Usage({description:"remove dependencies from the project",details:`
+      This command will remove the packages matching the specified patterns from the current workspace.
+
+      If the \`--mode=<mode>\` option is set, Yarn will change which artifacts are generated. The modes currently supported are:
+
+      - \`skip-build\` will not run the build scripts at all. Note that this is different from setting \`enableScripts\` to false because the later will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.
+
+      - \`update-lockfile\` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.
+
+      This command accepts glob patterns as arguments (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.
+    `,examples:[["Remove a dependency from the current project","$0 remove lodash"],["Remove a dependency from all workspaces at once","$0 remove lodash --all"],["Remove all dependencies starting with `eslint-`","$0 remove 'eslint-*'"],["Remove all dependencies with the `@babel` scope","$0 remove '@babel/*'"],["Remove all dependencies matching `react-dom` or `react-helmet`","$0 remove 'react-{dom,helmet}'"]]});var Jae=km;var Wae=ge(require("util")),ab=class extends Le{async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd);if(!i)throw new ht(r.cwd,this.context.cwd);return(await Je.start({configuration:e,stdout:this.context.stdout},async s=>{let o=i.manifest.scripts,a=Se.sortMap(o.keys(),u=>u),l={breakLength:Infinity,colors:e.get("enableColors"),maxArrayLength:2},c=a.reduce((u,g)=>Math.max(u,g.length),0);for(let[u,g]of o.entries())s.reportInfo(null,`${u.padEnd(c," ")}   ${(0,Wae.inspect)(g,l)}`)})).exitCode()}};ab.paths=[["run"]];var zae=ab;var xm=class extends Le{constructor(){super(...arguments);this.inspect=z.String("--inspect",!1,{tolerateBoolean:!0,description:"Forwarded to the underlying Node process when executing a binary"});this.inspectBrk=z.String("--inspect-brk",!1,{tolerateBoolean:!0,description:"Forwarded to the underlying Node process when executing a binary"});this.topLevel=z.Boolean("-T,--top-level",!1,{description:"Check the root workspace for scripts and/or binaries instead of the current one"});this.binariesOnly=z.Boolean("-B,--binaries-only",!1,{description:"Ignore any user defined scripts and only check for binaries"});this.silent=z.Boolean("--silent",{hidden:!0});this.scriptName=z.String();this.args=z.Proxy()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i,locator:n}=await ze.find(e,this.context.cwd);await r.restoreInstallState();let s=this.topLevel?r.topLevelWorkspace.anchoredLocator:n;if(!this.binariesOnly&&await Zt.hasPackageScript(s,this.scriptName,{project:r}))return await Zt.executePackageScript(s,this.scriptName,this.args,{project:r,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});let o=await Zt.getPackageAccessibleBinaries(s,{project:r});if(o.get(this.scriptName)){let l=[];return this.inspect&&(typeof this.inspect=="string"?l.push(`--inspect=${this.inspect}`):l.push("--inspect")),this.inspectBrk&&(typeof this.inspectBrk=="string"?l.push(`--inspect-brk=${this.inspectBrk}`):l.push("--inspect-brk")),await Zt.executePackageAccessibleBinary(s,this.scriptName,this.args,{cwd:this.context.cwd,project:r,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,nodeArgs:l,packageAccessibleBinaries:o})}if(!this.topLevel&&!this.binariesOnly&&i&&this.scriptName.includes(":")){let c=(await Promise.all(r.workspaces.map(async u=>u.manifest.scripts.has(this.scriptName)?u:null))).filter(u=>u!==null);if(c.length===1)return await Zt.executeWorkspaceScript(c[0],this.scriptName,this.args,{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr})}if(this.topLevel)throw this.scriptName==="node-gyp"?new Pe(`Couldn't find a script name "${this.scriptName}" in the top-level (used by ${P.prettyLocator(e,n)}). This typically happens because some package depends on "node-gyp" to build itself, but didn't list it in their dependencies. To fix that, please run "yarn add node-gyp" into your top-level workspace. You also can open an issue on the repository of the specified package to suggest them to use an optional peer dependency.`):new Pe(`Couldn't find a script name "${this.scriptName}" in the top-level (used by ${P.prettyLocator(e,n)}).`);{if(this.scriptName==="global")throw new Pe("The 'yarn global' commands have been removed in 2.x - consider using 'yarn dlx' or a third-party plugin instead");let l=[this.scriptName].concat(this.args);for(let[c,u]of Nf)for(let g of u)if(l.length>=g.length&&JSON.stringify(l.slice(0,g.length))===JSON.stringify(g))throw new Pe(`Couldn't find a script named "${this.scriptName}", but a matching command can be found in the ${c} plugin. You can install it with "yarn plugin import ${c}".`);throw new Pe(`Couldn't find a script named "${this.scriptName}".`)}}};xm.paths=[["run"]],xm.usage=Re.Usage({description:"run a script defined in the package.json",details:`
+      This command will run a tool. The exact tool that will be executed will depend on the current state of your workspace:
+
+      - If the \`scripts\` field from your local package.json contains a matching script name, its definition will get executed.
+
+      - Otherwise, if one of the local workspace's dependencies exposes a binary with a matching name, this binary will get executed.
+
+      - Otherwise, if the specified name contains a colon character and if one of the workspaces in the project contains exactly one script with a matching name, then this script will get executed.
+
+      Whatever happens, the cwd of the spawned process will be the workspace that declares the script (which makes it possible to call commands cross-workspaces using the third syntax).
+    `,examples:[["Run the tests from the local workspace","$0 run test"],['Same thing, but without the "run" keyword',"$0 test"],["Inspect Webpack while running","$0 run --inspect-brk webpack"]]});var _ae=xm;var Pm=class extends Le{constructor(){super(...arguments);this.save=z.Boolean("-s,--save",!1,{description:"Persist the resolution inside the top-level manifest"});this.descriptor=z.String();this.resolution=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(await r.restoreInstallState({restoreResolutions:!1}),!i)throw new ht(r.cwd,this.context.cwd);let s=P.parseDescriptor(this.descriptor,!0),o=P.makeDescriptor(s,this.resolution);return r.storedDescriptors.set(s.descriptorHash,s),r.storedDescriptors.set(o.descriptorHash,o),r.resolutionAliases.set(s.descriptorHash,o.descriptorHash),(await Je.start({configuration:e,stdout:this.context.stdout},async l=>{await r.install({cache:n,report:l})})).exitCode()}};Pm.paths=[["set","resolution"]],Pm.usage=Re.Usage({description:"enforce a package resolution",details:'\n      This command updates the resolution table so that `descriptor` is resolved by `resolution`.\n\n      Note that by default this command only affect the current resolution table - meaning that this "manual override" will disappear if you remove the lockfile, or if the package disappear from the table. If you wish to make the enforced resolution persist whatever happens, add the `-s,--save` flag which will also edit the `resolutions` field from your top-level manifest.\n\n      Note that no attempt is made at validating that `resolution` is a valid resolution entry for `descriptor`.\n    ',examples:[["Force all instances of lodash@npm:^1.2.3 to resolve to 1.5.0","$0 set resolution lodash@npm:^1.2.3 1.5.0"]]});var Vae=Pm;var Xae=ge(ts()),Dm=class extends Le{constructor(){super(...arguments);this.all=z.Boolean("-A,--all",!1,{description:"Unlink all workspaces belonging to the target project from the current one"});this.leadingArguments=z.Rest()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i)throw new ht(r.cwd,this.context.cwd);let s=r.topLevelWorkspace,o=new Set;if(this.leadingArguments.length===0&&this.all)for(let{pattern:l,reference:c}of s.manifest.resolutions)c.startsWith("portal:")&&o.add(l.descriptor.fullName);if(this.leadingArguments.length>0)for(let l of this.leadingArguments){let c=k.resolve(this.context.cwd,j.toPortablePath(l));if(Se.isPathLike(l)){let u=await ye.find(c,this.context.plugins,{useRc:!1,strict:!1}),{project:g,workspace:f}=await ze.find(u,c);if(!f)throw new ht(g.cwd,c);if(this.all){for(let h of g.workspaces)h.manifest.name&&o.add(P.stringifyIdent(h.locator));if(o.size===0)throw new Pe("No workspace found to be unlinked in the target project")}else{if(!f.manifest.name)throw new Pe("The target workspace doesn't have a name and thus cannot be unlinked");o.add(P.stringifyIdent(f.locator))}}else{let u=[...s.manifest.resolutions.map(({pattern:g})=>g.descriptor.fullName)];for(let g of(0,Xae.default)(u,l))o.add(g)}}return s.manifest.resolutions=s.manifest.resolutions.filter(({pattern:l})=>!o.has(l.descriptor.fullName)),(await Je.start({configuration:e,stdout:this.context.stdout},async l=>{await r.install({cache:n,report:l})})).exitCode()}};Dm.paths=[["unlink"]],Dm.usage=Re.Usage({description:"disconnect the local project from another one",details:`
+      This command will remove any resolutions in the project-level manifest that would have been added via a yarn link with similar arguments.
+    `,examples:[["Unregister a remote workspace in the current project","$0 unlink ~/ts-loader"],["Unregister all workspaces from a remote project in the current project","$0 unlink ~/jest --all"],["Unregister all previously linked workspaces","$0 unlink --all"],["Unregister all workspaces matching a glob","$0 unlink '@babel/*' 'pkg-{a,b}'"]]});var Zae=Dm;var $ae=ge($C()),_N=ge(ts());Es();var eh=class extends Le{constructor(){super(...arguments);this.interactive=z.Boolean("-i,--interactive",{description:"Offer various choices, depending on the detected upgrade paths"});this.exact=z.Boolean("-E,--exact",!1,{description:"Don't use any semver modifier on the resolved range"});this.tilde=z.Boolean("-T,--tilde",!1,{description:"Use the `~` semver modifier on the resolved range"});this.caret=z.Boolean("-C,--caret",!1,{description:"Use the `^` semver modifier on the resolved range"});this.recursive=z.Boolean("-R,--recursive",!1,{description:"Resolve again ALL resolutions for those packages"});this.mode=z.String("--mode",{description:"Change what artifacts installs generate",validator:nn(di)});this.patterns=z.Rest()}async execute(){return this.recursive?await this.executeUpRecursive():await this.executeUpClassic()}async executeUpRecursive(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState({restoreResolutions:!1});let s=[...r.storedDescriptors.values()],o=s.map(u=>P.stringifyIdent(u)),a=new Set;for(let u of this.patterns){if(P.parseDescriptor(u).range!=="unknown")throw new Pe("Ranges aren't allowed when using --recursive");for(let g of(0,_N.default)(o,u)){let f=P.parseIdent(g);a.add(f.identHash)}}let l=s.filter(u=>a.has(u.identHash));for(let u of l)r.storedDescriptors.delete(u.descriptorHash),r.storedResolutions.delete(u.descriptorHash);return(await Je.start({configuration:e,stdout:this.context.stdout},async u=>{await r.install({cache:n,report:u})})).exitCode()}async executeUpClassic(){var m;let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState({restoreResolutions:!1});let s=(m=this.interactive)!=null?m:e.get("preferInteractive"),o=em(this,r),a=s?[_r.KEEP,_r.REUSE,_r.PROJECT,_r.LATEST]:[_r.PROJECT,_r.LATEST],l=[],c=[];for(let y of this.patterns){let Q=!1,S=P.parseDescriptor(y);for(let x of r.workspaces)for(let M of[Hr.REGULAR,Hr.DEVELOPMENT]){let U=[...x.manifest.getForScope(M).values()].map(J=>P.stringifyIdent(J));for(let J of(0,_N.default)(U,P.stringifyIdent(S))){let W=P.parseIdent(J),ee=x.manifest[M].get(W.identHash);if(typeof ee=="undefined")throw new Error("Assertion failed: Expected the descriptor to be registered");let Z=P.makeDescriptor(W,S.range);l.push(Promise.resolve().then(async()=>[x,M,ee,await tm(Z,{project:r,workspace:x,cache:n,target:M,modifier:o,strategies:a})])),Q=!0}}Q||c.push(y)}if(c.length>1)throw new Pe(`Patterns ${ae.prettyList(e,c,Di.CODE)} don't match any packages referenced by any workspace`);if(c.length>0)throw new Pe(`Pattern ${ae.prettyList(e,c,Di.CODE)} doesn't match any packages referenced by any workspace`);let u=await Promise.all(l),g=await uA.start({configuration:e,stdout:this.context.stdout,suggestInstall:!1},async y=>{for(let[,,Q,{suggestions:S,rejections:x}]of u){let M=S.filter(Y=>Y.descriptor!==null);if(M.length===0){let[Y]=x;if(typeof Y=="undefined")throw new Error("Assertion failed: Expected an error to have been set");let U=this.cli.error(Y);r.configuration.get("enableNetwork")?y.reportError($.CANT_SUGGEST_RESOLUTIONS,`${P.prettyDescriptor(e,Q)} can't be resolved to a satisfying range
+
+${U}`):y.reportError($.CANT_SUGGEST_RESOLUTIONS,`${P.prettyDescriptor(e,Q)} can't be resolved to a satisfying range (note: network resolution has been disabled)
+
+${U}`)}else M.length>1&&!s&&y.reportError($.CANT_SUGGEST_RESOLUTIONS,`${P.prettyDescriptor(e,Q)} has multiple possible upgrade strategies; use -i to disambiguate manually`)}});if(g.hasErrors())return g.exitCode();let f=!1,h=[];for(let[y,Q,,{suggestions:S}]of u){let x,M=S.filter(W=>W.descriptor!==null),Y=M[0].descriptor,U=M.every(W=>P.areDescriptorsEqual(W.descriptor,Y));M.length===1||U?x=Y:(f=!0,{answer:x}=await(0,$ae.prompt)({type:"select",name:"answer",message:`Which range to you want to use in ${P.prettyWorkspace(e,y)} \u276F ${Q}?`,choices:S.map(({descriptor:W,name:ee,reason:Z})=>W?{name:ee,hint:Z,descriptor:W}:{name:ee,hint:Z,disabled:!0}),onCancel:()=>process.exit(130),result(W){return this.find(W,"descriptor")},stdin:this.context.stdin,stdout:this.context.stdout}));let J=y.manifest[Q].get(x.identHash);if(typeof J=="undefined")throw new Error("Assertion failed: This descriptor should have a matching entry");if(J.descriptorHash!==x.descriptorHash)y.manifest[Q].set(x.identHash,x),h.push([y,Q,J,x]);else{let W=e.makeResolver(),ee={project:r,resolver:W},Z=W.bindDescriptor(J,y.anchoredLocator,ee);r.forgetResolution(Z)}}return await e.triggerMultipleHooks(y=>y.afterWorkspaceDependencyReplacement,h),f&&this.context.stdout.write(`
+`),(await Je.start({configuration:e,stdout:this.context.stdout},async y=>{await r.install({cache:n,report:y,mode:this.mode})})).exitCode()}};eh.paths=[["up"]],eh.usage=Re.Usage({description:"upgrade dependencies across the project",details:"\n      This command upgrades the packages matching the list of specified patterns to their latest available version across the whole project (regardless of whether they're part of `dependencies` or `devDependencies` - `peerDependencies` won't be affected). This is a project-wide command: all workspaces will be upgraded in the process.\n\n      If `-R,--recursive` is set the command will change behavior and no other switch will be allowed. When operating under this mode `yarn up` will force all ranges matching the selected packages to be resolved again (often to the highest available versions) before being stored in the lockfile. It however won't touch your manifests anymore, so depending on your needs you might want to run both `yarn up` and `yarn up -R` to cover all bases.\n\n      If `-i,--interactive` is set (or if the `preferInteractive` settings is toggled on) the command will offer various choices, depending on the detected upgrade paths. Some upgrades require this flag in order to resolve ambiguities.\n\n      The, `-C,--caret`, `-E,--exact` and  `-T,--tilde` options have the same meaning as in the `add` command (they change the modifier used when the range is missing or a tag, and are ignored when the range is explicitly set).\n\n      If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n      - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the later will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n      - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n      Generally you can see `yarn up` as a counterpart to what was `yarn upgrade --latest` in Yarn 1 (ie it ignores the ranges previously listed in your manifests), but unlike `yarn upgrade` which only upgraded dependencies in the current workspace, `yarn up` will upgrade all workspaces at the same time.\n\n      This command accepts glob patterns as arguments (if valid Descriptors and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n      **Note:** The ranges have to be static, only the package scopes and names can contain glob patterns.\n    ",examples:[["Upgrade all instances of lodash to the latest release","$0 up lodash"],["Upgrade all instances of lodash to the latest release, but ask confirmation for each","$0 up lodash -i"],["Upgrade all instances of lodash to 1.2.3","$0 up lodash@1.2.3"],["Upgrade all instances of packages with the `@babel` scope to the latest release","$0 up '@babel/*'"],["Upgrade all instances of packages containing the word `jest` to the latest release","$0 up '*jest*'"],["Upgrade all instances of packages with the `@babel` scope to 7.0.0","$0 up '@babel/*@7.0.0'"]]}),eh.schema=[lv("recursive",Cc.Forbids,["interactive","exact","tilde","caret"],{ignore:[void 0,!1]})];var eAe=eh;var Rm=class extends Le{constructor(){super(...arguments);this.recursive=z.Boolean("-R,--recursive",!1,{description:"List, for each workspace, what are all the paths that lead to the dependency"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.peers=z.Boolean("--peers",!1,{description:"Also print the peer dependencies that match the specified name"});this.package=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd);if(!i)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState();let n=P.parseIdent(this.package).identHash,s=this.recursive?X8e(r,n,{configuration:e,peers:this.peers}):V8e(r,n,{configuration:e,peers:this.peers});as.emitTree(s,{configuration:e,stdout:this.context.stdout,json:this.json,separators:1})}};Rm.paths=[["why"]],Rm.usage=Re.Usage({description:"display the reason why a package is needed",details:`
+      This command prints the exact reasons why a package appears in the dependency tree.
+
+      If \`-R,--recursive\` is set, the listing will go in depth and will list, for each workspaces, what are all the paths that lead to the dependency. Note that the display is somewhat optimized in that it will not print the package listing twice for a single package, so if you see a leaf named "Foo" when looking for "Bar", it means that "Foo" already got printed higher in the tree.
+    `,examples:[["Explain why lodash is used in your project","$0 why lodash"]]});var tAe=Rm;function V8e(t,e,{configuration:r,peers:i}){let n=Se.sortMap(t.storedPackages.values(),a=>P.stringifyLocator(a)),s={},o={children:s};for(let a of n){let l={},c=null;for(let u of a.dependencies.values()){if(!i&&a.peerDependencies.has(u.identHash))continue;let g=t.storedResolutions.get(u.descriptorHash);if(!g)throw new Error("Assertion failed: The resolution should have been registered");let f=t.storedPackages.get(g);if(!f)throw new Error("Assertion failed: The package should have been registered");if(f.identHash!==e)continue;if(c===null){let p=P.stringifyLocator(a);s[p]={value:[a,ae.Type.LOCATOR],children:l}}let h=P.stringifyLocator(f);l[h]={value:[{descriptor:u,locator:f},ae.Type.DEPENDENT]}}}return o}function X8e(t,e,{configuration:r,peers:i}){let n=Se.sortMap(t.workspaces,f=>P.stringifyLocator(f.anchoredLocator)),s=new Set,o=new Set,a=f=>{if(s.has(f.locatorHash))return o.has(f.locatorHash);if(s.add(f.locatorHash),f.identHash===e)return o.add(f.locatorHash),!0;let h=!1;f.identHash===e&&(h=!0);for(let p of f.dependencies.values()){if(!i&&f.peerDependencies.has(p.identHash))continue;let m=t.storedResolutions.get(p.descriptorHash);if(!m)throw new Error("Assertion failed: The resolution should have been registered");let y=t.storedPackages.get(m);if(!y)throw new Error("Assertion failed: The package should have been registered");a(y)&&(h=!0)}return h&&o.add(f.locatorHash),h};for(let f of n){let h=t.storedPackages.get(f.anchoredLocator.locatorHash);if(!h)throw new Error("Assertion failed: The package should have been registered");a(h)}let l=new Set,c={},u={children:c},g=(f,h,p)=>{if(!o.has(f.locatorHash))return;let m=p!==null?ae.tuple(ae.Type.DEPENDENT,{locator:f,descriptor:p}):ae.tuple(ae.Type.LOCATOR,f),y={},Q={value:m,children:y},S=P.stringifyLocator(f);if(h[S]=Q,!l.has(f.locatorHash)&&(l.add(f.locatorHash),!(p!==null&&t.tryWorkspaceByLocator(f))))for(let x of f.dependencies.values()){if(!i&&f.peerDependencies.has(x.identHash))continue;let M=t.storedResolutions.get(x.descriptorHash);if(!M)throw new Error("Assertion failed: The resolution should have been registered");let Y=t.storedPackages.get(M);if(!Y)throw new Error("Assertion failed: The package should have been registered");g(Y,y,x)}};for(let f of n){let h=t.storedPackages.get(f.anchoredLocator.locatorHash);if(!h)throw new Error("Assertion failed: The package should have been registered");g(h,c,null)}return u}var aL={};ft(aL,{default:()=>mze,gitUtils:()=>wu});var wu={};ft(wu,{TreeishProtocols:()=>On,clone:()=>nL,fetchBase:()=>wAe,fetchChangedFiles:()=>BAe,fetchChangedWorkspaces:()=>dze,fetchRoot:()=>yAe,isGitUrl:()=>rh,lsRemote:()=>IAe,normalizeLocator:()=>tL,normalizeRepoUrl:()=>Fm,resolveUrl:()=>iL,splitRepoUrl:()=>Nm});var $N=ge(dAe()),CAe=ge(tB()),th=ge(require("querystring")),eL=ge(ti()),mAe=ge(require("url"));function EAe(){return te(N({},process.env),{GIT_SSH_COMMAND:"ssh -o BatchMode=yes"})}var pze=[/^ssh:/,/^git(?:\+[^:]+)?:/,/^(?:git\+)?https?:[^#]+\/[^#]+(?:\.git)(?:#.*)?$/,/^git@[^#]+\/[^#]+\.git(?:#.*)?$/,/^(?:github:|https:\/\/github\.com\/)?(?!\.{1,2}\/)([a-zA-Z._0-9-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z._0-9-]+?)(?:\.git)?(?:#.*)?$/,/^https:\/\/github\.com\/(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z0-9._-]+?)\/tarball\/(.+)?$/],On;(function(n){n.Commit="commit",n.Head="head",n.Tag="tag",n.Semver="semver"})(On||(On={}));function rh(t){return t?pze.some(e=>!!t.match(e)):!1}function Nm(t){t=Fm(t);let e=t.indexOf("#");if(e===-1)return{repo:t,treeish:{protocol:On.Head,request:"HEAD"},extra:{}};let r=t.slice(0,e),i=t.slice(e+1);if(i.match(/^[a-z]+=/)){let n=th.default.parse(i);for(let[l,c]of Object.entries(n))if(typeof c!="string")throw new Error(`Assertion failed: The ${l} parameter must be a literal string`);let s=Object.values(On).find(l=>Object.prototype.hasOwnProperty.call(n,l)),o,a;typeof s!="undefined"?(o=s,a=n[s]):(o=On.Head,a="HEAD");for(let l of Object.values(On))delete n[l];return{repo:r,treeish:{protocol:o,request:a},extra:n}}else{let n=i.indexOf(":"),s,o;return n===-1?(s=null,o=i):(s=i.slice(0,n),o=i.slice(n+1)),{repo:r,treeish:{protocol:s,request:o},extra:{}}}}function Fm(t,{git:e=!1}={}){var r;if(t=t.replace(/^git\+https:/,"https:"),t=t.replace(/^(?:github:|https:\/\/github\.com\/)?(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z0-9._-]+?)(?:\.git)?(#.*)?$/,"https://github.com/$1/$2.git$3"),t=t.replace(/^https:\/\/github\.com\/(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z0-9._-]+?)\/tarball\/(.+)?$/,"https://github.com/$1/$2.git#$3"),e){t=t.replace(/^git\+([^:]+):/,"$1:");let i;try{i=mAe.default.parse(t)}catch{i=null}i&&i.protocol==="ssh:"&&((r=i.path)==null?void 0:r.startsWith("/:"))&&(t=t.replace(/^ssh:\/\//,""))}return t}function tL(t){return P.makeLocator(t,Fm(t.reference))}async function IAe(t,e){let r=Fm(t,{git:!0});if(!ir.getNetworkSettings(`https://${(0,$N.default)(r).resource}`,{configuration:e}).enableNetwork)throw new Error(`Request to '${r}' has been blocked because of your configuration settings`);let n=await rL("listing refs",["ls-remote",r],{cwd:e.startingCwd,env:EAe()},{configuration:e,normalizedRepoUrl:r}),s=new Map,o=/^([a-f0-9]{40})\t([^\n]+)/gm,a;for(;(a=o.exec(n.stdout))!==null;)s.set(a[2],a[1]);return s}async function iL(t,e){let{repo:r,treeish:{protocol:i,request:n},extra:s}=Nm(t),o=await IAe(r,e),a=(c,u)=>{switch(c){case On.Commit:{if(!u.match(/^[a-f0-9]{40}$/))throw new Error("Invalid commit hash");return th.default.stringify(te(N({},s),{commit:u}))}case On.Head:{let g=o.get(u==="HEAD"?u:`refs/heads/${u}`);if(typeof g=="undefined")throw new Error(`Unknown head ("${u}")`);return th.default.stringify(te(N({},s),{commit:g}))}case On.Tag:{let g=o.get(`refs/tags/${u}`);if(typeof g=="undefined")throw new Error(`Unknown tag ("${u}")`);return th.default.stringify(te(N({},s),{commit:g}))}case On.Semver:{let g=Wt.validRange(u);if(!g)throw new Error(`Invalid range ("${u}")`);let f=new Map([...o.entries()].filter(([p])=>p.startsWith("refs/tags/")).map(([p,m])=>[eL.default.parse(p.slice(10)),m]).filter(p=>p[0]!==null)),h=eL.default.maxSatisfying([...f.keys()],g);if(h===null)throw new Error(`No matching range ("${u}")`);return th.default.stringify(te(N({},s),{commit:f.get(h)}))}case null:{let g;if((g=l(On.Commit,u))!==null||(g=l(On.Tag,u))!==null||(g=l(On.Head,u))!==null)return g;throw u.match(/^[a-f0-9]+$/)?new Error(`Couldn't resolve "${u}" as either a commit, a tag, or a head - if a commit, use the 40-characters commit hash`):new Error(`Couldn't resolve "${u}" as either a commit, a tag, or a head`)}default:throw new Error(`Invalid Git resolution protocol ("${c}")`)}},l=(c,u)=>{try{return a(c,u)}catch(g){return null}};return`${r}#${a(i,n)}`}async function nL(t,e){return await e.getLimit("cloneConcurrency")(async()=>{let{repo:r,treeish:{protocol:i,request:n}}=Nm(t);if(i!=="commit")throw new Error("Invalid treeish protocol when cloning");let s=Fm(r,{git:!0});if(ir.getNetworkSettings(`https://${(0,$N.default)(s).resource}`,{configuration:e}).enableNetwork===!1)throw new Error(`Request to '${s}' has been blocked because of your configuration settings`);let o=await K.mktempPromise(),a={cwd:o,env:EAe()};return await rL("cloning the repository",["clone","-c core.autocrlf=false",s,j.fromPortablePath(o)],a,{configuration:e,normalizedRepoUrl:s}),await rL("switching branch",["checkout",`${n}`],a,{configuration:e,normalizedRepoUrl:s}),o})}async function yAe(t){let e=null,r,i=t;do r=i,await K.existsPromise(k.join(r,".git"))&&(e=r),i=k.dirname(r);while(e===null&&i!==r);return e}async function wAe(t,{baseRefs:e}){if(e.length===0)throw new Pe("Can't run this command with zero base refs specified.");let r=[];for(let a of e){let{code:l}=await Fr.execvp("git",["merge-base",a,"HEAD"],{cwd:t});l===0&&r.push(a)}if(r.length===0)throw new Pe(`No ancestor could be found between any of HEAD and ${e.join(", ")}`);let{stdout:i}=await Fr.execvp("git",["merge-base","HEAD",...r],{cwd:t,strict:!0}),n=i.trim(),{stdout:s}=await Fr.execvp("git",["show","--quiet","--pretty=format:%s",n],{cwd:t,strict:!0}),o=s.trim();return{hash:n,title:o}}async function BAe(t,{base:e,project:r}){let i=Se.buildIgnorePattern(r.configuration.get("changesetIgnorePatterns")),{stdout:n}=await Fr.execvp("git",["diff","--name-only",`${e}`],{cwd:t,strict:!0}),s=n.split(/\r\n|\r|\n/).filter(c=>c.length>0).map(c=>k.resolve(t,j.toPortablePath(c))),{stdout:o}=await Fr.execvp("git",["ls-files","--others","--exclude-standard"],{cwd:t,strict:!0}),a=o.split(/\r\n|\r|\n/).filter(c=>c.length>0).map(c=>k.resolve(t,j.toPortablePath(c))),l=[...new Set([...s,...a].sort())];return i?l.filter(c=>!k.relative(r.cwd,c).match(i)):l}async function dze({ref:t,project:e}){if(e.configuration.projectCwd===null)throw new Pe("This command can only be run from within a Yarn project");let r=[k.resolve(e.cwd,e.configuration.get("cacheFolder")),k.resolve(e.cwd,e.configuration.get("installStatePath")),k.resolve(e.cwd,e.configuration.get("lockfileFilename")),k.resolve(e.cwd,e.configuration.get("virtualFolder"))];await e.configuration.triggerHook(o=>o.populateYarnPaths,e,o=>{o!=null&&r.push(o)});let i=await yAe(e.configuration.projectCwd);if(i==null)throw new Pe("This command can only be run on Git repositories");let n=await wAe(i,{baseRefs:typeof t=="string"?[t]:e.configuration.get("changesetBaseRefs")}),s=await BAe(i,{base:n.hash,project:e});return new Set(Se.mapAndFilter(s,o=>{let a=e.tryWorkspaceByFilePath(o);return a===null?Se.mapAndFilter.skip:r.some(l=>o.startsWith(l))?Se.mapAndFilter.skip:a}))}async function rL(t,e,r,{configuration:i,normalizedRepoUrl:n}){try{return await Fr.execvp("git",e,te(N({},r),{strict:!0}))}catch(s){if(!(s instanceof Fr.ExecError))throw s;let o=s.reportExtra,a=s.stderr.toString();throw new ct($.EXCEPTION,`Failed ${t}`,l=>{l.reportError($.EXCEPTION,`  ${ae.prettyField(i,{label:"Repository URL",value:ae.tuple(ae.Type.URL,n)})}`);for(let c of a.matchAll(/^(.+?): (.*)$/gm)){let[,u,g]=c;u=u.toLowerCase();let f=u==="error"?"Error":`${(0,CAe.default)(u)} Error`;l.reportError($.EXCEPTION,`  ${ae.prettyField(i,{label:f,value:ae.tuple(ae.Type.NO_HINT,g)})}`)}o==null||o(l)})}}var sL=class{supports(e,r){return rh(e.reference)}getLocalPath(e,r){return null}async fetch(e,r){let i=r.checksums.get(e.locatorHash)||null,n=tL(e),s=new Map(r.checksums);s.set(n.locatorHash,i);let o=te(N({},r),{checksums:s}),a=await this.downloadHosted(n,o);if(a!==null)return a;let[l,c,u]=await r.cache.fetchPackageFromCache(e,i,N({onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${P.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote repository`),loader:()=>this.cloneFromRemote(n,o),skipIntegrityCheck:r.skipIntegrityCheck},r.cacheOptions));return{packageFs:l,releaseFs:c,prefixPath:P.getIdentVendorPath(e),checksum:u}}async downloadHosted(e,r){return r.project.configuration.reduceHook(i=>i.fetchHostedRepository,null,e,r)}async cloneFromRemote(e,r){let i=await nL(e.reference,r.project.configuration),n=Nm(e.reference),s=k.join(i,"package.tgz");await Zt.prepareExternalProject(i,s,{configuration:r.project.configuration,report:r.report,workspace:n.extra.workspace,locator:e});let o=await K.readFilePromise(s);return await Se.releaseAfterUseAsync(async()=>await wi.convertToZip(o,{compressionLevel:r.project.configuration.get("compressionLevel"),prefixPath:P.getIdentVendorPath(e),stripComponents:1}))}};var oL=class{supportsDescriptor(e,r){return rh(e.range)}supportsLocator(e,r){return rh(e.reference)}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,i){return e}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){let n=await iL(e.range,i.project.configuration);return[P.makeLocator(e,n)]}async getSatisfying(e,r,i){return null}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let i=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),n=await Se.releaseAfterUseAsync(async()=>await At.find(i.prefixPath,{baseFs:i.packageFs}),i.releaseFs);return te(N({},e),{version:n.version||"0.0.0",languageName:n.languageName||r.project.configuration.get("defaultLanguageName"),linkType:Qt.HARD,conditions:n.getConditions(),dependencies:n.dependencies,peerDependencies:n.peerDependencies,dependenciesMeta:n.dependenciesMeta,peerDependenciesMeta:n.peerDependenciesMeta,bin:n.bin})}};var Cze={configuration:{changesetBaseRefs:{description:"The base git refs that the current HEAD is compared against when detecting changes. Supports git branches, tags, and commits.",type:Ie.STRING,isArray:!0,isNullable:!1,default:["master","origin/master","upstream/master","main","origin/main","upstream/main"]},changesetIgnorePatterns:{description:"Array of glob patterns; files matching them will be ignored when fetching the changed files",type:Ie.STRING,default:[],isArray:!0},cloneConcurrency:{description:"Maximal number of concurrent clones",type:Ie.NUMBER,default:2}},fetchers:[sL],resolvers:[oL]};var mze=Cze;var Lm=class extends Le{constructor(){super(...arguments);this.since=z.String("--since",{description:"Only include workspaces that have been changed since the specified ref.",tolerateBoolean:!0});this.recursive=z.Boolean("-R,--recursive",!1,{description:"Find packages via dependencies/devDependencies instead of using the workspaces field"});this.verbose=z.Boolean("-v,--verbose",!1,{description:"Also return the cross-dependencies between workspaces"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r}=await ze.find(e,this.context.cwd);return(await Je.start({configuration:e,json:this.json,stdout:this.context.stdout},async n=>{let s=this.since?await wu.fetchChangedWorkspaces({ref:this.since,project:r}):r.workspaces,o=new Set(s);if(this.recursive)for(let a of[...s].map(l=>l.getRecursiveWorkspaceDependents()))for(let l of a)o.add(l);for(let a of o){let{manifest:l}=a,c;if(this.verbose){let u=new Set,g=new Set;for(let f of At.hardDependencies)for(let[h,p]of l.getForScope(f)){let m=r.tryWorkspaceByDescriptor(p);m===null?r.workspacesByIdent.has(h)&&g.add(p):u.add(m)}c={workspaceDependencies:Array.from(u).map(f=>f.relativeCwd),mismatchedWorkspaceDependencies:Array.from(g).map(f=>P.stringifyDescriptor(f))}}n.reportInfo(null,`${a.relativeCwd}`),n.reportJson(N({location:a.relativeCwd,name:l.name?P.stringifyIdent(l.name):null},c))}})).exitCode()}};Lm.paths=[["workspaces","list"]],Lm.usage=Re.Usage({category:"Workspace-related commands",description:"list all available workspaces",details:"\n      This command will print the list of all workspaces in the project.\n\n      - If `--since` is set, Yarn will only list workspaces that have been modified since the specified ref. By default Yarn will use the refs specified by the `changesetBaseRefs` configuration option.\n\n      - If `-R,--recursive` is set, Yarn will find workspaces to run the command on by recursively evaluating `dependencies` and `devDependencies` fields, instead of looking at the `workspaces` fields.\n\n      - If both the `-v,--verbose` and `--json` options are set, Yarn will also return the cross-dependencies between each workspaces (useful when you wish to automatically generate Buck / Bazel rules).\n    "});var bAe=Lm;var Tm=class extends Le{constructor(){super(...arguments);this.workspaceName=z.String();this.commandName=z.String();this.args=z.Proxy()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd);if(!i)throw new ht(r.cwd,this.context.cwd);let n=r.workspaces,s=new Map(n.map(a=>{let l=P.convertToIdent(a.locator);return[P.stringifyIdent(l),a]})),o=s.get(this.workspaceName);if(o===void 0){let a=Array.from(s.keys()).sort();throw new Pe(`Workspace '${this.workspaceName}' not found. Did you mean any of the following:
+  - ${a.join(`
+  - `)}?`)}return this.cli.run([this.commandName,...this.args],{cwd:o.cwd})}};Tm.paths=[["workspace"]],Tm.usage=Re.Usage({category:"Workspace-related commands",description:"run a command within the specified workspace",details:`
+      This command will run a given sub-command on a single workspace.
+    `,examples:[["Add a package to a single workspace","yarn workspace components add -D react"],["Run build script on a single workspace","yarn workspace components run build"]]});var QAe=Tm;var Eze={configuration:{enableImmutableInstalls:{description:"If true (the default on CI), prevents the install command from modifying the lockfile",type:Ie.BOOLEAN,default:vAe.isCI},defaultSemverRangePrefix:{description:"The default save prefix: '^', '~' or ''",type:Ie.STRING,values:["^","~",""],default:ga.CARET}},commands:[Tse,Mse,$oe,uae,Vae,Tae,bae,bAe,Cae,mae,Eae,Iae,Nse,Lse,gae,hae,yae,wae,vae,kae,xae,Dae,Zae,Rae,jae,Kae,Gae,Fae,Yae,qae,Jae,zae,_ae,eAe,tAe,QAe]},Ize=Eze;var gL={};ft(gL,{default:()=>wze});var Ye={optional:!0},SAe=[["@tailwindcss/aspect-ratio@<0.2.1",{peerDependencies:{tailwindcss:"^2.0.2"}}],["@tailwindcss/line-clamp@<0.2.1",{peerDependencies:{tailwindcss:"^2.0.2"}}],["@fullhuman/postcss-purgecss@3.1.3 || 3.1.3-alpha.0",{peerDependencies:{postcss:"^8.0.0"}}],["@samverschueren/stream-to-observable@<0.3.1",{peerDependenciesMeta:{rxjs:Ye,zenObservable:Ye}}],["any-observable@<0.5.1",{peerDependenciesMeta:{rxjs:Ye,zenObservable:Ye}}],["@pm2/agent@<1.0.4",{dependencies:{debug:"*"}}],["debug@<4.2.0",{peerDependenciesMeta:{["supports-color"]:Ye}}],["got@<11",{dependencies:{["@types/responselike"]:"^1.0.0",["@types/keyv"]:"^3.1.1"}}],["cacheable-lookup@<4.1.2",{dependencies:{["@types/keyv"]:"^3.1.1"}}],["http-link-dataloader@*",{peerDependencies:{graphql:"^0.13.1 || ^14.0.0"}}],["typescript-language-server@*",{dependencies:{["vscode-jsonrpc"]:"^5.0.1",["vscode-languageserver-protocol"]:"^3.15.0"}}],["postcss-syntax@*",{peerDependenciesMeta:{["postcss-html"]:Ye,["postcss-jsx"]:Ye,["postcss-less"]:Ye,["postcss-markdown"]:Ye,["postcss-scss"]:Ye}}],["jss-plugin-rule-value-function@<=10.1.1",{dependencies:{["tiny-warning"]:"^1.0.2"}}],["ink-select-input@<4.1.0",{peerDependencies:{react:"^16.8.2"}}],["license-webpack-plugin@<2.3.18",{peerDependenciesMeta:{webpack:Ye}}],["snowpack@>=3.3.0",{dependencies:{["node-gyp"]:"^7.1.0"}}],["promise-inflight@*",{peerDependenciesMeta:{bluebird:Ye}}],["reactcss@*",{peerDependencies:{react:"*"}}],["react-color@<=2.19.0",{peerDependencies:{react:"*"}}],["gatsby-plugin-i18n@*",{dependencies:{ramda:"^0.24.1"}}],["useragent@^2.0.0",{dependencies:{request:"^2.88.0",yamlparser:"0.0.x",semver:"5.5.x"}}],["@apollographql/apollo-tools@*",{peerDependencies:{graphql:"^14.2.1 || ^15.0.0"}}],["material-table@^2.0.0",{dependencies:{"@babel/runtime":"^7.11.2"}}],["@babel/parser@*",{dependencies:{"@babel/types":"^7.8.3"}}],["fork-ts-checker-webpack-plugin@<=6.3.4",{peerDependencies:{eslint:">= 6",typescript:">= 2.7",webpack:">= 4","vue-template-compiler":"*"},peerDependenciesMeta:{eslint:Ye,"vue-template-compiler":Ye}}],["rc-animate@<=3.1.1",{peerDependencies:{react:">=16.9.0","react-dom":">=16.9.0"}}],["react-bootstrap-table2-paginator@*",{dependencies:{classnames:"^2.2.6"}}],["react-draggable@<=4.4.3",{peerDependencies:{react:">= 16.3.0","react-dom":">= 16.3.0"}}],["apollo-upload-client@<14",{peerDependencies:{graphql:"14 - 15"}}],["react-instantsearch-core@<=6.7.0",{peerDependencies:{algoliasearch:">= 3.1 < 5"}}],["react-instantsearch-dom@<=6.7.0",{dependencies:{"react-fast-compare":"^3.0.0"}}],["ws@<7.2.1",{peerDependencies:{bufferutil:"^4.0.1","utf-8-validate":"^5.0.2"},peerDependenciesMeta:{bufferutil:Ye,"utf-8-validate":Ye}}],["react-portal@*",{peerDependencies:{"react-dom":"^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0"}}],["react-scripts@<=4.0.1",{peerDependencies:{react:"*"}}],["testcafe@<=1.10.1",{dependencies:{"@babel/plugin-transform-for-of":"^7.12.1","@babel/runtime":"^7.12.5"}}],["testcafe-legacy-api@<=4.2.0",{dependencies:{"testcafe-hammerhead":"^17.0.1","read-file-relative":"^1.2.0"}}],["@google-cloud/firestore@<=4.9.3",{dependencies:{protobufjs:"^6.8.6"}}],["gatsby-source-apiserver@*",{dependencies:{["babel-polyfill"]:"^6.26.0"}}],["@webpack-cli/package-utils@<=1.0.1-alpha.4",{dependencies:{["cross-spawn"]:"^7.0.3"}}],["gatsby-remark-prismjs@<3.3.28",{dependencies:{lodash:"^4"}}],["gatsby-plugin-favicon@*",{peerDependencies:{webpack:"*"}}],["gatsby-plugin-sharp@<=4.6.0-next.3",{dependencies:{debug:"^4.3.1"}}],["gatsby-react-router-scroll@<=5.6.0-next.0",{dependencies:{["prop-types"]:"^15.7.2"}}],["@rebass/forms@*",{dependencies:{["@styled-system/should-forward-prop"]:"^5.0.0"},peerDependencies:{react:"^16.8.6"}}],["rebass@*",{peerDependencies:{react:"^16.8.6"}}],["@ant-design/react-slick@<=0.28.3",{peerDependencies:{react:">=16.0.0"}}],["mqtt@<4.2.7",{dependencies:{duplexify:"^4.1.1"}}],["vue-cli-plugin-vuetify@<=2.0.3",{dependencies:{semver:"^6.3.0"},peerDependenciesMeta:{"sass-loader":Ye,"vuetify-loader":Ye}}],["vue-cli-plugin-vuetify@<=2.0.4",{dependencies:{"null-loader":"^3.0.0"}}],["@vuetify/cli-plugin-utils@<=0.0.4",{dependencies:{semver:"^6.3.0"},peerDependenciesMeta:{"sass-loader":Ye}}],["@vue/cli-plugin-typescript@<=5.0.0-alpha.0",{dependencies:{"babel-loader":"^8.1.0"}}],["@vue/cli-plugin-typescript@<=5.0.0-beta.0",{dependencies:{"@babel/core":"^7.12.16"},peerDependencies:{"vue-template-compiler":"^2.0.0"},peerDependenciesMeta:{"vue-template-compiler":Ye}}],["cordova-ios@<=6.3.0",{dependencies:{underscore:"^1.9.2"}}],["cordova-lib@<=10.0.1",{dependencies:{underscore:"^1.9.2"}}],["git-node-fs@*",{peerDependencies:{"js-git":"^0.7.8"},peerDependenciesMeta:{"js-git":Ye}}],["consolidate@*",{peerDependencies:{velocityjs:"^2.0.1",tinyliquid:"^0.2.34","liquid-node":"^3.0.1",jade:"^1.11.0","then-jade":"*",dust:"^0.3.0","dustjs-helpers":"^1.7.4","dustjs-linkedin":"^2.7.5",swig:"^1.4.2","swig-templates":"^2.0.3","razor-tmpl":"^1.3.1",atpl:">=0.7.6",liquor:"^0.0.5",twig:"^1.15.2",ejs:"^3.1.5",eco:"^1.1.0-rc-3",jazz:"^0.0.18",jqtpl:"~1.1.0",hamljs:"^0.6.2",hamlet:"^0.3.3",whiskers:"^0.4.0","haml-coffee":"^1.14.1","hogan.js":"^3.0.2",templayed:">=0.2.3",handlebars:"^4.7.6",underscore:"^1.11.0",lodash:"^4.17.20",pug:"^3.0.0","then-pug":"*",qejs:"^3.0.5",walrus:"^0.10.1",mustache:"^4.0.1",just:"^0.1.8",ect:"^0.5.9",mote:"^0.2.0",toffee:"^0.3.6",dot:"^1.1.3","bracket-template":"^1.1.5",ractive:"^1.3.12",nunjucks:"^3.2.2",htmling:"^0.0.8","babel-core":"^6.26.3",plates:"~0.4.11","react-dom":"^16.13.1",react:"^16.13.1","arc-templates":"^0.5.3",vash:"^0.13.0",slm:"^2.0.0",marko:"^3.14.4",teacup:"^2.0.0","coffee-script":"^1.12.7",squirrelly:"^5.1.0",twing:"^5.0.2"},peerDependenciesMeta:{velocityjs:Ye,tinyliquid:Ye,"liquid-node":Ye,jade:Ye,"then-jade":Ye,dust:Ye,"dustjs-helpers":Ye,"dustjs-linkedin":Ye,swig:Ye,"swig-templates":Ye,"razor-tmpl":Ye,atpl:Ye,liquor:Ye,twig:Ye,ejs:Ye,eco:Ye,jazz:Ye,jqtpl:Ye,hamljs:Ye,hamlet:Ye,whiskers:Ye,"haml-coffee":Ye,"hogan.js":Ye,templayed:Ye,handlebars:Ye,underscore:Ye,lodash:Ye,pug:Ye,"then-pug":Ye,qejs:Ye,walrus:Ye,mustache:Ye,just:Ye,ect:Ye,mote:Ye,toffee:Ye,dot:Ye,"bracket-template":Ye,ractive:Ye,nunjucks:Ye,htmling:Ye,"babel-core":Ye,plates:Ye,"react-dom":Ye,react:Ye,"arc-templates":Ye,vash:Ye,slm:Ye,marko:Ye,teacup:Ye,"coffee-script":Ye,squirrelly:Ye,twing:Ye}}],["vue-loader@<=16.3.1",{peerDependencies:{"@vue/compiler-sfc":"^3.0.8",webpack:"^4.1.0 || ^5.0.0-0"}}],["scss-parser@*",{dependencies:{lodash:"^4.17.21"}}],["query-ast@*",{dependencies:{lodash:"^4.17.21"}}],["redux-thunk@<=2.3.0",{peerDependencies:{redux:"^4.0.0"}}],["skypack@<=0.3.2",{dependencies:{tar:"^6.1.0"}}],["@npmcli/metavuln-calculator@<2.0.0",{dependencies:{"json-parse-even-better-errors":"^2.3.1"}}],["bin-links@<2.3.0",{dependencies:{"mkdirp-infer-owner":"^1.0.2"}}],["rollup-plugin-polyfill-node@<=0.8.0",{peerDependencies:{rollup:"^1.20.0 || ^2.0.0"}}],["snowpack@<3.8.6",{dependencies:{"magic-string":"^0.25.7"}}],["elm-webpack-loader@*",{dependencies:{temp:"^0.9.4"}}],["winston-transport@<=4.4.0",{dependencies:{logform:"^2.2.0"}}],["jest-vue-preprocessor@*",{dependencies:{"@babel/core":"7.8.7","@babel/template":"7.8.6"},peerDependencies:{pug:"^2.0.4"},peerDependenciesMeta:{pug:Ye}}],["redux-persist@*",{peerDependencies:{react:">=16"},peerDependenciesMeta:{react:Ye}}],["sodium@>=3",{dependencies:{"node-gyp":"^3.8.0"}}],["babel-plugin-graphql-tag@<=3.1.0",{peerDependencies:{graphql:"^14.0.0 || ^15.0.0"}}],["@playwright/test@<=1.14.1",{dependencies:{"jest-matcher-utils":"^26.4.2"}}],...["babel-plugin-remove-graphql-queries@<3.14.0-next.1","babel-preset-gatsby-package@<1.14.0-next.1","create-gatsby@<1.14.0-next.1","gatsby-admin@<0.24.0-next.1","gatsby-cli@<3.14.0-next.1","gatsby-core-utils@<2.14.0-next.1","gatsby-design-tokens@<3.14.0-next.1","gatsby-legacy-polyfills@<1.14.0-next.1","gatsby-plugin-benchmark-reporting@<1.14.0-next.1","gatsby-plugin-graphql-config@<0.23.0-next.1","gatsby-plugin-image@<1.14.0-next.1","gatsby-plugin-mdx@<2.14.0-next.1","gatsby-plugin-netlify-cms@<5.14.0-next.1","gatsby-plugin-no-sourcemaps@<3.14.0-next.1","gatsby-plugin-page-creator@<3.14.0-next.1","gatsby-plugin-preact@<5.14.0-next.1","gatsby-plugin-preload-fonts@<2.14.0-next.1","gatsby-plugin-schema-snapshot@<2.14.0-next.1","gatsby-plugin-styletron@<6.14.0-next.1","gatsby-plugin-subfont@<3.14.0-next.1","gatsby-plugin-utils@<1.14.0-next.1","gatsby-recipes@<0.25.0-next.1","gatsby-source-shopify@<5.6.0-next.1","gatsby-source-wikipedia@<3.14.0-next.1","gatsby-transformer-screenshot@<3.14.0-next.1","gatsby-worker@<0.5.0-next.1"].map(t=>[t,{dependencies:{"@babel/runtime":"^7.14.8"}}]),["gatsby-core-utils@<2.14.0-next.1",{dependencies:{got:"8.3.2"}}],["gatsby-plugin-gatsby-cloud@<=3.1.0-next.0",{dependencies:{"gatsby-core-utils":"^2.13.0-next.0"}}],["gatsby-plugin-gatsby-cloud@<=3.2.0-next.1",{peerDependencies:{webpack:"*"}}],["babel-plugin-remove-graphql-queries@<=3.14.0-next.1",{dependencies:{"gatsby-core-utils":"^2.8.0-next.1"}}],["gatsby-plugin-netlify@3.13.0-next.1",{dependencies:{"gatsby-core-utils":"^2.13.0-next.0"}}],["clipanion-v3-codemod@<=0.2.0",{peerDependencies:{jscodeshift:"^0.11.0"}}],["react-live@*",{peerDependencies:{"react-dom":"*",react:"*"}}],["webpack@<4.44.1",{peerDependenciesMeta:{"webpack-cli":Ye,"webpack-command":Ye}}],["webpack@<5.0.0-beta.23",{peerDependenciesMeta:{"webpack-cli":Ye}}],["webpack-dev-server@<3.10.2",{peerDependenciesMeta:{"webpack-cli":Ye}}],["@docusaurus/responsive-loader@<1.5.0",{peerDependenciesMeta:{sharp:Ye,jimp:Ye}}],["eslint-module-utils@*",{peerDependenciesMeta:{"eslint-import-resolver-node":Ye,"eslint-import-resolver-typescript":Ye,"eslint-import-resolver-webpack":Ye,"@typescript-eslint/parser":Ye}}],["eslint-plugin-import@*",{peerDependenciesMeta:{"@typescript-eslint/parser":Ye}}],["critters-webpack-plugin@<3.0.2",{peerDependenciesMeta:{"html-webpack-plugin":Ye}}],["terser@<=5.10.0",{dependencies:{acorn:"^8.5.0"}}],["babel-preset-react-app@10.0.x",{dependencies:{"@babel/plugin-proposal-private-property-in-object":"^7.16.0"}}],["eslint-config-react-app@*",{peerDependenciesMeta:{typescript:Ye}}],["@vue/eslint-config-typescript@*",{peerDependenciesMeta:{typescript:Ye}}],["unplugin-vue2-script-setup@<0.9.1",{peerDependencies:{"@vue/composition-api":"^1.4.3","@vue/runtime-dom":"^3.2.26"}}]];var lL;function kAe(){return typeof lL=="undefined"&&(lL=require("zlib").brotliDecompressSync(Buffer.from("G7weAByFTVk3Vs7UfHhq4yykgEM7pbW7TI43SG2S5tvGrwHBAzdz+s/npQ6tgEvobvxisrPIadkXeUAJotBn5bDZ5kAhcRqsIHe3F75Walet5hNalwgFDtxb0BiDUjiUQkjG0yW2hto9HPgiCkm316d6bC0kST72YN7D7rfkhCE9x4J0XwB0yavalxpUu2t9xszHrmtwalOxT7VslsxWcB1qpqZwERUra4psWhTV8BgwWeizurec82Caf1ABL11YMfbf8FJ9JBceZOkgmvrQPbC9DUldX/yMbmX06UQluCEjSwUoyO+EZPIjofr+/oAZUck2enraRD+oWLlnlYnj8xB+gwSo9lmmks4fXv574qSqcWA6z21uYkzMu3EWj+K23RxeQlLqiE35/rC8GcS4CGkKHKKq+zAIQwD9iRDNfiAqueLLpicFFrNsAI4zeTD/eO9MHcnRa5m8UT+M2+V+AkFST4BlKneiAQRSdST8KEAIyFlULt6wa9EBd0Ds28VmpaxquJdVt+nwdEs5xUskI13OVtFyY0UrQIRAlCuvvWivvlSKQfTO+2Q8OyUR1W5RvetaPz4jD27hdtwHFFA1Ptx6Ee/t2cY2rg2G46M1pNDRf2pWhvpy8pqMnuI3++4OF3+7OFIWXGjh+o7Nr2jNvbiYcQdQS1h903/jVFgOpA0yJ78z+x759bFA0rq+6aY5qPB4FzS3oYoLupDUhD9nDz6F6H7hpnlMf18KNKDu4IKjTWwrAnY6MFQw1W6ymOALHlFyCZmQhldg1MQHaMVVQTVgDC60TfaBqG++Y8PEoFhN/PBTZT175KNP/BlHDYGOOBmnBdzqJKplZ/ljiVG0ZBzfqeBRrrUkn6rA54462SgiliKoYVnbeptMdXNfAuaupIEi0bApF10TlgHfmEJAPUVidRVFyDupSem5po5vErPqWKhKbUIp0LozpYsIKK57dM/HKr+nguF+7924IIWMICkQ8JUigs9D+W+c4LnNoRtPPKNRUiCYmP+Jfo2lfKCKw8qpraEeWU3uiNRO6zcyKQoXPR5htmzzLznke7b4YbXW3I1lIRzmgG02Udb58U+7TpwyN7XymCgH+wuPDthZVQvRZuEP+SnLtMicz9m5zASWOBiAcLmkuFlTKuHspSIhCBD0yUPKcxu81A+4YD78rA2vtwsUEday9WNyrShyrl60rWmA+SmbYZkQOwFJWArxRYYc5jGhA5ikxYw1rx3ei4NmeX/lKiwpZ9Ln1tV2Ae7sArvxuVLbJjqJRjW1vFXAyHpvLG+8MJ6T2Ubx5M2KDa2SN6vuIGxJ9WQM9Mk3Q7aCNiZONXllhqq24DmoLbQfW2rYWsOgHWjtOmIQMyMKdiHZDjoyIq5+U700nZ6odJAoYXPQBvFNiQ78d5jaXliBqLTJEqUCwi+LiH2mx92EmNKDsJL74Z613+3lf20pxkV1+erOrjj8pW00vsPaahKUM+05ssd5uwM7K482KWEf3TCwlg/o3e5ngto7qSMz7YteIgCsF1UOcsLk7F7MxWbvrPMY473ew0G+noVL8EPbkmEMftMSeL6HFub/zy+2JQ==","base64")).toString()),lL}var cL;function xAe(){return typeof cL=="undefined"&&(cL=require("zlib").brotliDecompressSync(Buffer.from("G8MSIIzURnVBnObTcvb3XE6v2S9Qgc2K801Oa5otNKEtK8BINZNcaQHy+9/vf/WXBimwutXC33P2DPc64pps5rz7NGGWaOKNSPL4Y2KRE8twut2lFOIN+OXPtRmPMRhMTILib2bEQx43az2I5d3YS8Roa5UZpF/ujHb3Djd3GDvYUfvFYSUQ39vb2cmifp/rgB4J/65JK3wRBTvMBoNBmn3mbXC63/gbBkW/2IRPri0O8bcsRBsmarF328pAln04nyJFkwUAvNu934supAqLtyerZZpJ8I8suJHhf/ocMV+scKwa8NOiDKIPXw6Ex/EEZD6TEGaW8N5zvNHYF10l6Lfooj7D5W2k3dgvQSbp2Wv8TGOayS978gxlOLVjTGXs66ozewbrjwElLtyrYNnWTfzzdEutgROUFPVMhnMoy8EjJLLlWwIEoySxliim9kYW30JUHiPVyjt0iAw/ZpPmCbUCltYPnq6ZNblIKhTNhqS/oqC9iya5sGKZTOVsTEg34n92uZTf2iPpcZih8rPW8CzA+adIGmyCPcKdLMsBLShd+zuEbTrqpwuh+DLmracZcjPC5Sdf5odDAhKpFuOsQS67RT+1VgWWygSv3YwxDnylc04/PYuaMeIzhBkLrvs7e/OUzRTF56MmfY6rI63QtEjEQzq637zQqJ39nNhu3NmoRRhW/086bHGBUtx0PE0j3aEGvkdh9WJC8y8j8mqqke9/dQ5la+Q3ba4RlhvTbnfQhPDDab3tUifkjKuOsp13mXEmO00Mu88F/M67R7LXfoFDFLNtgCSWjWX+3Jn1371pJTK9xPBiMJafvDjtFyAzu8rxeQ0TKMQXNPs5xxiBOd+BRJP8KP88XPtJIbZKh/cdW8KvBUkpqKpGoiIaA32c3/JnQr4efXt85mXvidOvn/eU3Pase1typLYBalJ14mCso9h79nuMOuCa/kZAOkJHmTjP5RM2WNoPasZUAnT1TAE/NH25hUxcQv6hQWR/m1PKk4ooXMcM4SR1iYU3fUohvqk4RY2hbmTVVIXv6TvqO+0doOjgeVFAcom+RlwJQmOVH7pr1Q9LoJT6n1DeQEB+NHygsATbIwTcOKZlJsY8G4+suX1uQLjUWwLjjs0mvSvZcLTpIGAekeR7GCgl8eo3ndAqEe2XCav4huliHjdbIPBsGJuPX7lrO9HX1UbXRH5opOe1x6JsOSgHZR+EaxuXVhpLLxm6jk1LJtZfHSc6BKPun3CpYYVMJGwEUyk8MTGG0XL5MfEwaXpnc9TKnBmlGn6nHiGREc3ysn47XIBDzA+YvFdjZzVIEDcKGpS6PbUJehFRjEne8D0lVU1XuRtlgszq6pTNlQ/3MzNOEgCWPyTct22V2mEi2krizn5VDo9B19/X2DB3hCGRMM7ONbtnAcIx/OWB1u5uPbW1gsH8irXxT/IzG0PoXWYjhbMsH3KTuoOl5o17PulcgvsfTSnKFM354GWI8luqZnrswWjiXy3G+Vbyo1KMopFmmvBwNELgaS8z8dNZchx/Cl/xjddxhMcyqtzFyONb2Zdu90NkI8pAeufe7YlXrp53v8Dj/l8vWeVspRKBGXScBBPI/HinSTGmLDOGGOCIyH0JFdOZx0gWsacNlQLJMIrBhqRxXxHF/5pseWwejlAAvZ3klZSDSYY8mkToaWejXhgNomeGtx1DTLEUFMRkgF5yFB22WYdJnaWN14r1YJj81hGi45+jrADS5nYRhCiSlCJJ1nL8pYX+HDSMhdTEWyRcgHVp/IsUIZYMfT+YYncUQPgcxNGCHfZ88vDdrcUuaGIl6zhAsiaq7R5dfqrqXH/JcBhfjT8D0azayIyEz75Nxp6YkcyDxlJq3EXnJUpqDohJJOysL1t1uNiHESlvsxPb5cpbW0+ICZqJmUZus1BMW0F5IVBODLIo2zHHjA0=","base64")).toString()),cL}var uL;function PAe(){return typeof uL=="undefined"&&(uL=require("zlib").brotliDecompressSync(Buffer.from("mwO6FaORsdsGcONiBdm+GYlI5y201PzTeQMV083BKbeT8BMrpQ2odQF3mN44LvRLbJSkDh9Bd6X8rqsk+kHglydMzGrLobOUPr9if9TuCkpvD49xOQ2jFKLUMYtdBtgubYZIkGwockj/2RiwVsFP7EeybqGwU0xzBaTFL19N//Xz9dx3z0vIJZVM47XKseSURhWAFBwK4uxCl5En+fOn2X9+voTShFj2IeNtTOSS1q4V21GeEHAa6oB83BxWg/TMKLT89c7XqJcqxOSyO7PfoCwNoeQwDvh+TlS2rsxUDaYndGWZuMLBDrI8yF9EbGCtChNvD6gPslDb0ep9X3tTEN8Yg/ctKBttEKK4NDLOZMrk8fqcOYN9DivgAagFCK7hcr+hkXVnzplz73MAvUTwG2MiGWOTSEnqc/ksvs9xHSnLlSVlwZXzkfSDIBGEeQol0hLEZe11gycawZoK+2hXyoX2v6qV7ZgXkogNwtcGQPds8lKJaRqp6BxyUxxtAKPZdS5jmHOcdShdTSLoXKXYu3cnuWg6d7U0IkUHceUAkA4A6SBynEJr4tl+yKlCs9rCl/W3ZSyretvOPn+tCCGEEDFQ5dL9dJia6N12XttOtxMBpgNipuhdvKXysHTwUFV63gZI/GdMTS9NeyvdKiBLQPgiFHwmQ5f6c7XspBDKqKJ/hXpE1ztLSYWkQxf8O8qzZNay7F/9Sdchjqtk4LTJaupq7T4V9NfqJNSfE5jX3cn+3fJ15c9Mwt6rJsKsIh7m7WNQND34NYz5gM33v3hepFc+ijgO4ZVMoigK5SfY7Ui8YnaBfqK5m2LOEtLjmKtq/XCSn9z9Sq5c76wFBbxDzetVGaRl8Gxyh044pMzLb3XCKDc/RXq9UcbyyPrQlbNdLG3Y/OCIgH4rwRI++AXbb5R0uURINen6wdWIIUAVLBpQSwdPtY7RVuO/cFd/zuLVw0p3yw5BOvjhsL3aocplq3XKufm8hn8DrVsSW4yfQQxfG9x+bZ5FmyMsgKgh/OeF9K53E1++eliOXpcI1wEAD9790MJQ8EouF8IQ0mTmGc4Xxp438Yega3nt7pOZWY9IOF6d3YrUYx/t9zBq4qisUJfNtr0dfiqE7joopDhJGn8xmz7QNucKpuovsPYSDy5ePXQVji6I68Fa/GwwFBuDdVs2bA4KAguR8dcb+rgcI2QkXmk9WnJm2kiVz/JuV8DRUlB3cbXb5eGg3mlEhAIN0K748zr1EJIPaR2D+Qwg7Cf/NFEJgNOxW1+fI9mBGSFsuKqoyH7pBEch5JPXYwRIYv6TaYKshlyte8OLjQWSV67s6yjJTCATurzd9oeyfzU3Ld+6SV9J89pdNm2zoFWAyZSB9ezAPGDs+gzFEWpsD3xGfBIergYyTQQGKghYw0StfkgxiXd63IQuIRUJGEtIV8wnJWIQAk5O9iWRPdjCiwnMEHfIowZPMrfwrJI1PNCDRbBeRln/UVOaW7HIPKj+YzxZlo+xXMyeJTGiDtWVwHBi6+oGCTe3nspFKv7UVEr2M/mpYX7OizddmH0RF2YsEsPCN95asJ/fdHwM5Bah1wtPxMUJJZMoZzEFcnYyNnvC5SgnOERN0hF9fcna/ME6ecqol7lVEQQJaC+sNvT1Tdc29hov4xg6uwOqImvzuiQr24LkmCsXTUvdEhEQ5DT2tTiMl+sMgpWZGx8AgdBtQ4DKPNobnBSMbj0Jxxt5yNXT/3T6zOqEJFneZeWbzxn18aHzv38/SqtcT0ffnKMziwH+LNNcYrSCRTB/CP+wuiUOIIcF03ah/L4HVH9EXAvoPWaWTtErU71MLBR44jFzXDbpWQm10ZQr35MshC2HN78J52fkLsEdkFuuN+OwRiznJ3PlToN92lmOa3N9z5L4uNom38JGWytRLrblRnKt3ea8qS3zKFJiqRUopRTwUqzlj6f168546DrxE5d96flW5OaHFAxTiPX6hZPpN8ds+SvYLzYoo1zMgxAUro9Ces5Nz7VZR1AT3N02w+oekcSr35LqgqB2O2pB4wXI2TMOmD/GHEtBjzW6Pla6rM1WW12DzC79x4Ptwc0dfhJkQI5eXEwtP2Tgs+47kFdw4QQFmdXrFb94bkyjPsPpi0iWX851zPjTA8gHjnFtFkJY5z1m+7dk+VQvnTzLI8ObrFLkDGYBhLcrdOzK7mlE1sc+JnHQR97si3pG9rpdo9/P5Cl/Qx4o7yQ2+/mMpHQdnYuAkWnjcqaEtOEExnV+S89r+xLE4MPi7dEDQhYlbtM+cD0m21t6es+b2WYf7lamENzePS4gzOaFD468FrnNynUWLc1/IpDufNMkYeG4ZWhrx25ym0p0ATV7iE9+FMxWdLtDGriqXYQ5RcWCB3b5yIkuFQGgjagMJBxwgHyr2xomD7dLx0sqN6ELvIZrncK+WxQ0WeiVCwdUXofKJclDuuLcoW3AeAyD23vsAUc9tJIi/9QKFQg/wq8PWQRQELLdg4BNT5VbFlTjtS5dXZb75oTMkb1XgYUpIg6I8CadMiKxjlAIf8uWSvSUwmTBgahJI04eupXT62YQvPNQUeEQLnF5lXg9wwHPVDe9WOjtC5VEXlEeyInvIMslxPgdSxJ5JE8WY2F67PrGr/zO/TctJ9k3vvH9IWfoHqByiq2YfN3uNTBONHRSMuXB8Ey2HzgfOEw4+dn2EnCib2h5sfFSm+4Qm0cswqDr60h5eDOcz00ugS+COOcF5XogBbc698qAfrLny0Eg+3QSLdmsUW8cnv4PNI59v3F01QKAL+/lX0+zQdj7OtUi3OTANngO4TLPl76LlZ73+iEASGFDTLlwxFgptDMqhR7WakfBbdWn/eNahJmop1V3yrC1RU275VKl6vQ/K7aKmERaRh9w3sCqlw2zu/9Uwq2qX0Xzc7X5LVl7h3S5tWJ1ONeIyiy7Beg9skKO54SLZ9cWh//NEs+DwG6B+9iTHczdyfwLcZ6wCme14SRcvXmt6B6puG2J3AwWge+mPoHDPybEdlpfpuJA1mV2TvYznTe1yF7LvqzF1G782L36fa4A1bkH4aj80bqssbn58j/2HzcHXo+iVqxCOVO7c2yHneecn30Fm8i4YtEjGD5kvjrOZMLSxh6ssCx7weZPq3jPWfo6/iKP71N0ZGA6oE+PrarJrhpo7RpoQ35ldn7HDOlVXjXfWBLb+F9iHXd7Q5CNqyZ+ETIDDMSLyR/WSoBOKz62X4/ZDrNfadj+voq/W338GysDzhBlitnK34YVDhNPV8oKuwUbO0MGN3azgKf98gBk56qIKmnUQt/S+UC+J/8p4YCB3G2Fi2n5DzqaY3wXgN9O28bJqX30rXYENB9mdGoNdLGB8+vLz0dAEOd2oRo2+eTwjS1XLnV0MtkflgWrPJ8IuWzvDIrLm8jTQx8vKDEUAMfszwDVrMcMFwA4omxxhK9wRDShXUK5mmHXCABIzl1al2YgLPNjPASzVdlnN5+1++bBp7checMtz6dBK+tcigsAzEGAKOyGdGI7A2lljwAAF0Ro5tHLTIdmtg/ox+5BP/CDqRdPUcYK3cw8r2AFAO18//LnT/89GD3aYHanQjlbd79pIV+rfYkbwpuXQOfPQadJhgsAJKjZ/QvnGgKHdhzKrkfYNQIAJ2shHNurIt01j4nfyQb8vWB2JGZfFCfSEvaGchkAALkVBPL/5vK//uz8/4PQjUmbaX+2Q5Cpdpt9igIZurrrhzdg49l3f5qzHTB0HwiGJ34iVmlvKH8LAJCTiLJ0ImRjYtfZCRuPnCZbISS112ZP94FEm4syAYB4CiwvPOTNNQtC4GctRyonmCTFjIqVtgBAEfdvYdi4fzPtoRoL93nTJQDQwpTmyLv3TBA639r2Q5tAIuZhbyjHFgCgk0EXU3gEPym0VyOmnB9tagQASpII0ZgF/AFFP5oFF+6MBr2cXMrzKgDoZrWhGe/wh9FM2lp+BejvPHp0E0pHaeYHmGcLADjHRrrC8OD+ZNufKivu/uQ3AgByppCMWTo6ZuL7dHfC6PglbzvmbSQhU7i9oVwGAECW0ifUGDfgfrYL7xhVZMZmuwD/VGJ/C3ad3kz10dmde0u6XyB64nckld4byt8CACRleT6NcUdu8BTwjlgBGhvtXvS3JcLGTADghF3RnGe+0/Y/nceN45aTfTqNUaUKFwCoAKfjCGcK/ViiFtpX+tsSNlwCACu70d9Sck1T/nBh/LD55ST+UakKFwAooZ+2qAXhk35ModAe6W9T2LAJAERYT9gMm9/Uq7UmtZs/Te4/F6wAYJ3Vyi7TT0LTfXyu0+z7iP2d9HNp3C+XHMrHBQA8Zt9nY7gP97PgHtzPTY0AQLBkyQIyhYrPnUOrK/sms1du8XJcErivqgSTSXpIrs7/wqKp14KS0Tc5+71IsD0JoWyHgyS1poEBU9LhF8KTrNgVCRYdLLToCjIRX7KxD6dCx6E4w9XAkV9/STonvadnooaXvURfyVOvF/o+8aqLmPFZ3E/BIv1CgZHsm9v+ahf9gK/ZfMlxl3cg4AtpHIDzE6vtIkUf+tfofPKt2WK8fRexPz2UeVGmLqSRwJX0fTLajpHcImqCvoVZQ9sEI/gu8GYMFxAennjv8xrd+9Yg59S2RhLG7YXX6mRMr+rrN0z5PP/RmL74XpPXaU7IKEC6sOeVrLyy37HbjcOQniJCkera9L4x0jvCCIqRssw458LO7k5WamuJ6Y7JHKBb5r2sUFbj6hifC7uMvcvWK2f7vVlcDh5y0RBdWhPrFWNJwToPLbBIi+3oqnaIblojGfs49MUQhEcdD11/QH/CdtBYYBFRn5244nkVMt7nwZYgawsyVaQrD5rwh4Zg5jKI4S4cvDXdsdq8xbGxRyy/mRJihy89Xjg822fPbl0pdMjlh+f8ymBDWH7j3NNDJmknwpRv3TSLCV4kUV2yjyaXITt3PkHLh+ijNEQCPQqUFU773ivEBLyPx0npSc8cfsiwLFQ7rADIwI5lWm9ayj2RsX2IHG9iDkp6Cnzwh3vUTLcgIqLmawgd34X1vf5izXvR80if1m6szyfB3P8Dx13l4x0CkF+/xfnk1azRxKGB13IxeRLyIKhB22/qJADC6I9Uv7RF7jiooVEaQ85qJXcL00aJ2cDHREH9Dka6jObUby5gcCDwnlF9XJzA6XiNbUz3kW9fHtq5JHIvA1KL7AKT41kC4fW5Dyfu/4o8nNmvLrW2sFw0tGu8gXKmdl555bnXTT7aj9B/ZwesRYWNq5/KNjMmlM8xsR+9vhxHXffD5ZfgQdjvU9huqpYvrYu6rcalR5u9dl53ewebrWjSLn+hiQ77GPNtH/eKhc1Of+0wAPnQbeytW4MREvrpSdZv+cLZr4aWBWDRZL6lKjszqSNrdoQ3nQ+h+cbk+aURs6N6mD2zpgsYbxrlIE+TZYhVwOOEtS8S03nYOqg9s2UyNmQypTAA4jig2aNruMtvywS1HXA/kWBYCxxToeruiwl+qsdiGRlMwepFCvW0j/hYmFuxnv4RH7O/xepKeMndD2QEO5tYh5IeprKKxad2JvGZVV6nN9mhHBtr/RbEd35bXxi0JFZzBiZmTFwPhqBfRwZFhXO7C29+X6STk3DJ7hnv9KqWt+A96xEn2PVdvfISX0ydLCJgRcum435cACJ4DROEU8a8PgQwpLxCibLENZtfiSNhnC/oeUVe9lGN8GGGa9g3HcJ6XXJwVZv0WExGOdmhU68/khAXP6+NSx3dHWAsxGf1Rm8+1iTA+i6xcRtQLDuALXEin90Q1WUpEFu7Juwgl1C5F+4WJl76ybWx2eMKk4NTqOtj3bjbhWI4saBlMjktS0y6s9fnY0FANRmV6rymMly5ZZRK7udv+nDljBLSiNvNmp3CP2lDX93r3qg43Kg/Z+14I3gLo9zlBy50tvS611GlpjBW0NeV902hh+VjT4EYthK3eelxtKcshOFFB/Z8rqqm9v1FrL0VG7osUKYVnzdzkgRLafWXdNrWJhjv8NeHkn9BoYcmXrNgmau1/tjFOVblePjKFym3YGkjF3Wx3zbNCaMGW79EPNRUvhgetV/jaGqzMlhLZA0OXTKhIhl6jMJoXgOex/suHS9jO9mUpjejWWe2w7Een8llBgA82jQ0ReH1iIV2e9QNmgCA9X26PIcflAns8Ci5qenTmiF5V9PkFoob2Sgaaj+ueNLLqE82m+nVadY60Fw1MwAAcqQpkgrHR6FdyeQ7aoUmACAf/kaXb+FPlwr0ciku7YtAu2VPRZJp1K1oJmeYHzov47tcTAVgWU1ijQgXTZ7hb/3My3ju9nKaXkmzlkBJJTMAkOVYU8tlhb2t0c7yiBWaAMCVvqDLDXylHMBGVW5ZdrP1n0h+EQn4F+8hdu65wX91E6yR99MQCS4nUqUZALAjYQV2gl11Z07P9UmznYRw1535vCFvM+vxWP4f2JpLICjOpBNjAgTUFSvCLTdNNztYGBk1ll0R+p7swo+LCQPdZiZ3+SO7o4qKivGVNtqcH6Q+Lgwx+aY+TEYOms4FN7lHLOtaM85ealgmZCAoD6iMN7+YEHTR8l+xU/yWMB6dioBCWsCtzdlHKlZiWtTGUTWiL37ZsUHxwVdcTp9knFCDTmv6+AoOjwUNw4TWS2RuH4oAhfq/7rmAWnZDuvsBSukq04dzZQ4chMqjhyom46GpDzApIVH35nr3fDRqLh582cw5E8jSYxih6owoy+Odsm8wBLOG1VBnlt7jpupMP9hJDFAQ5ngoG4vGiibIKhL6pQPQ5hfYI3pp1i5d2S8P4SnqjptfeeVHJzQlKyT0Zx6d1Nf59CGGAw3TPCpw+3KodeLcOmsWoAIxGIY82+E3dra1+W6H4c036XbmVph3wX3UYcUlQPVaEJZAl9/S4atlmxAnIVs1QQqvnx8HRu4Yubu4MPA5YIa9kEJIoGkc+4Y85Y/BcWfi2ywehXHNji24QdlN8pVIcaD3GeDYKazFFwp7XdBHK+4lW2qayBgEscBUdsB6epapfy9HcPSB48P2rRHzVFVyrJFzm5gBgEhOZUf4xKQX37zbtNlRNbE2AxSXcv6tTAAApRGHurgdJn/4P9xd3Ds4xNsASxqxBNDG6v02zRcgzMkthtdlxnNNzI11+PytDgzXdIdg0EARfCmwvzSmljlZvn0wGLsWi1t3BW54YN9cdfVLgzYJR+b70AZU+o8mQPncULt/lbQeuelV2k5pkpoXFqm9it14ZjfWwIbW9y73Z1Ore70Eh1OowKHw/+5q+JxIWGwOuI8CEoe+e8TDKCeZjc0If33uIBSQ28rwJkaq3lJFs58dTaXYGbSlJTUgkxwV0dqdjPrD6h2dpZ4qwNlKDAkpsvojrVNYKdbfMra3Tq+BC4uSuWITbjK+HUtoYMviymTjRs4y+NKZjc7Sfxmfj8IbGqKmn9WRocB7zZ5eWktv+Iw6QfyM8x/fkJ5cOqzlcFwVuFeAmjm5iSrLyprLGvyFMG+cPr+9+hIgUkSi9rdGs27FjUBSNTMAIOFbBWEXSukA1dBs7+AbVZoAgAM61d2rCLptRgkA1MUgrJrJzQDR1DRdNSqyy8c+mJoHW2cPtj9e2h0tIr65VQ5AjAiFqdRkx+NXbyenj0NevhMxI5Izf8oBiCGhNm+47LO8c2Rv+u+R7SRS/IiEvWq2vJ+u5jMCcN3IDADgDPBUEEarKHJ+bLY38YMKTQCgybWcyu53uTSuNgkAGFRls5rdofscu59+bevmF3OV/2okfdVseT8FMUTJlF83mQEAMUBZK0ZK0WTcqfkrQNesKAa7YYBmmqvNV51vw1TRX0T5Eha98Dc5dygZLStu6JYQUstia5+pwda7hn8oqFDY7n7hX5CSX+ykAphOoABoXh4gaGoRvgCn3ThNrjAJn/uax8lVs2QyFXPIFyCq+tUW66pVEUPoPzxuEZKbhQw5AmU/oxsLpAEgewIiyZZWCe3Ai7bkI2imueF/tKMOrelgWvpEhaug3XWSdZ97P1x76v2o3m/mQGqeEWj2QGBcFxV+TUWHFoq+W+EEarxocK7/FoKYiGYDR+5HzgG6vnHivLIPn6M69dhDOToeL+qLOR/0J1rYfTOC0mE0y30chzHyjg1tQ47UHz2PkbUVj/bPeHbuxdrRFSCKVM+idJgT+sICGSnhfOiaQuJkGyYTsGkFjGkIMLjJLLHp/eWpDc3Q6HdTAMDXamMmjcW9UWqsQxzl56/DFx5WZtd8R4ZqteSVHyTDtb6n/KHrD9wrHaGAkLFeMb5mkJ9uWPMJDyxhiF1l0HoyD7E/0QaTyC8StsUokg+Okv7LVbxsaIGRkA4mCfqgheYR5ws7mBjD50JtuFZHOC22JXhya7A6DXdMcc69Z1NOhvTgSa63wnH9Ezbm413L0LO2AXB0ckzSYqDNnITFygAjxSVHUhZ0TpJi+ZmW4zIcNluBEHZ3rJfqE8UziCEXBg6aaK+u3mnQDla1bvbddtqHLiiVBgX8QgSdKqHnOnSfV1HtjtCFjLi7F9pghoEIuKWgSiF7BKaCuejPlQfdcQHI+Cd2rV6uTyuhNeVJn4MrNkyxgR2yxXrYqN4xq+1Dgk2yrn67UFotEJ0IC76wIKsVU/uEEKcYIIb7SrdURGduRdfZBI2wQcoaHvEM99ezNh86QEq0lbYYvO0UN4dKIkirpGX7MWWglTjjwf3xT6Al5dlVpn2+ZWq8Z70i4KR/FI6i/C5oFcW8vVxqOkpnCwoJiv4sWuT7x16sxdbyYGxYQg2agBFpbvs4ut0eMiC2DFYEGeScWNDZzuLLiojGCb87vcEO7U18b+YSzicy+OEhlZQDVbRsguJmHt6YxW7ebeQvz1R/bf6CxtXkFqYVVRVnglFKU6KEC4lQnUT6U6aAIWOgIuQMb4iB4j6jpeO9rceiXmyW1WjbtLGUo+LKZgKGBAU8u/fzTLlo1zqJiOLKeERUPcF9ZI/f6VYDn7yYHyc+9yMzMislAMJiWbTEh/Sh5bRJKy1eLIaTK2DI3H6dYz69KqUOi2l/xlSPXTrReceDZxfbnNEPtsa5onKjhdG8RovdlW9/G1JeDi3Kn3n89aiKsxoXrkh8+Gqjtbul6q0N9LZJT1Wp2M9Swga/TGEi9POI1gspNsg/FzriiI0uZEb3yTJ7n4nEU9C81w9MTN+i4sGr5cY+aCEP7zLaCdG4uZaT94d0+ChKBE3SPFvGW89fCfD0K39jab57mati+4fSqofuNqNpPHIujxkAkKTJiJFSzGtcpuIVpDslUQrtXFRNNjVcVSYAQByfJKpTdaMY5jqdXGqnFr/xPQmsFoBrPWgtXh83Rq8+hxuKFon7gKjDQDxHUz3Jr+cJXxe6jb/0hAeqyxVzxFDTFYBYdWzr3zQmTGmmJvxENNp5JxDqAxJcsErFBsMQocRRdBYBlCSxcCk6/2cxvn6hm/mH5PSC9gCgt2xsc3OJKwD4oMu4axx6XoM1nIFGFGmGhLE+I1mxgW3Pps12Drn9LSDRWJreRONTE/YlYDoM6oGJl9JOsTrEGJsbzfh7BR9GGD1xjP2M4PyR2tGsh/qvr0Xu1d0/iZS/RnndlqwDEPRhF32uOL5/EkZX8qj0UAKSllivXBaOP8Abj1afevcjy8zF1n1risc8mOQ2PWMgiUS/HksYHoTi0X8HJl8K+Nj3rkeV4KA9k6BPs4ratcntolYP5XQVgddv4LBHmVe8U/kwvHMU4GA8Ge9fEkodSiB0v0jQ3cHEv9M8Hl8jnrMaUK6B5jifzrQgWHeLsvf0ZUi9SF27HtHn41rvbADtxpWNdWu3VDuvgrkmGda1SFlrVKOeafwsfyx+Y/0fLnCzU42htaaoayN0o1knvJMkGA1nnM21Vw/IiZAPXcuiv3BZL+lmHTVxWLCXK5o1G8PpjUs4p9N97U2nq5N45K/NqB5WzXmNmESGTenNVw+1tyths6JcTQEdpggp646XNdoabjKYwoZ4fWP6AqvYHB444HnnjPfYKmiCmHrRPEVmnm8ZUT/uJJzgGgrsnbRGf0BXYQasDX2XwsgmUYgWRXmcrDlN+PG9ca6YGg46Dyqmu1jFwDARXBh23BBbmLUsIArdKVkKnmsqVqPAQXMjTbcbLM5Gy5LI7/jX8yf6xiYoODUmqpUReyl9eZfTh7uOPuyRVt2neg52NBT+K8/THy/p4kX7+VkIU6ncQ0yYTciYyzKd6IyMgloXWfgCZ+1zozNnYgOoalwvnrw5rifCJobrXZhWWHNMMCWhPnX4YvQLqV8tA2+ZUliYgTdCA8vWX+bE7MouekLMb9P1JfR3hE3Zq5REu+MRWxHnNY3dgVxb4W12EugrVzRy5Vr8y0d7INrT0yr6mqZjncVdMk2fLXTBb0yrNqjibUosn+0fN2SL2m2U0EknHGTfTMAl0iV6+ktEKWdLUgJdBviUVHwF+No0aoKYlsBLH8qEnVZ4JSbqGY9jxmYHDyktRsLVlaguqG7zRuprEpcpTwKnnQCDPS5vE7s2JGGHI458lpyIJi2kl+IcnRHKYWgg3QH556yNLj2EXqxyXW9uoNLjnBAsEe8Wt6pSbghJWGE62BGU0JM4bi0w7bpS1zA943xYlkmeonBvXnWa6DOul0ojlLQ0QIhtwY2XzF49Z8TQVHiyu7mou6+j9+LdMqfE2W5Ni2KG3bSX8kWRcAwBAmYh9l9fFMevqMXhAftuszsNrz0xBYStmBOufOMlpZDRbeTF8kd+wmz6uO59BN8PfK+/u9wO452/JfbhL62YLfQQV2I+1h4y893md7wTzokiz24Gbwy+tiacjKUIwYB4aQcTXQgYQ90VyUXwq+yw4Phby3pAiTowBmFHJ3Ss7tLwMtudyNW5UuuDRqlB4kzC8t65Hz+7Rqm0UeHuGbxMV3dKO1aAEF/GZJdQbrlZkkHmOGBezKGQ2LDcgHfyR0GS636vwN7o1oPvnvlIf1MGfJLu0XvNPdcI7us7TX2UpRDZpvy6gRkAOAPkesVYT+hzUyhjsesDxp3JTszYHof3XJkJABz6GVmAPTo2twFyVaO3KwbbauoHAA4CSLtQCgD1WT6rAYG22ClAlPPnHSCESbB7O5sCRKu/r8594a66JdkIlFXTpcS2Ae9as80AWFoL+QBWu9naAJzYbzcAUHVFAlBR+Da2ltcG02ew6rCPYJ+H+OYaSACR++djPCY+Jdv+7g2OzGbk5KcjfpecuQsSQIhEtWVLZdH72R3uGWgpV0kZPU7Y/vVp1etRh6GIS6RNzABAS5C0GMVgGmD7yJnsg69HjkOsrzN8XKEJAGAYeM+02QGtyvS2uNRV2Q8AoEBsI8oLauo+GjSnjS0Qy/xLQFjDIMIXrLfPqKrKMdsejL+I0Vwi/2AnvB7F3CR3xDwNmS8nih6Kc6MZACAJGhSj+BkDdNqCwa42wLROKM2/+HY6ZQ5uuTDcqhyTi/mHdUsgJfHloxbHMnIZiNF4UrhuXNLmOVYLoo/u5bruxi/ydu+EPaFe/8P/4SVPkC+CixOWkiTwy7x+V+pX6XjvT6zFvXWYD1/BdeXBeaKnNsfBFSei6TzA4IoczgpL4zNX1JVhjBkGV57lwSEIzsMrE9eXmeCS9fPuTuZng5+OL/Kz2DW+N1O6NWuGFDbEskfO5TEDAI1qSbBqMQrCklxVcUV2jO1igOMt15wJAAh3vBFI7m+7BlTv3k9vEnrILNZi/+Vxz36JlmzZ+HXNYx2Ol9eoC3e6DjW0G9ePk59UnbnFAECtjhc8RslB192+rLy7T2Xt4R92VTfvWq8Zp0QmKR2/sh72KWkDIYZfuId/2DUQ7w4BMkoKQyrtfXDv2jmENK7XQLekHEAlbEmS8nHctGeoXvnvZZpiQSFlW4FwoFDYykZcULhq642sodRMVxHThfoKHbMa7fzxeTTJC9rpr9djn6tW5P3UStaf9qe0+eFkRctpXyfxvZkR5LtUVbou87OKGQDolmy67MSGt8OXrZBcHwvu99SGO3fP7FyOJgDQk0UU0rRsYGrvB2C6Vk42A1U3FwQAnubxrsYYUKPOLOJgSnoPkIOZ0sSQHAza9IPlgHNGgSwHJHW51oHraiY9BNxVo+Qxh5pSac0GBKK2FgcD8brZJwI52u/bAtKuK7oiKup0zM7dom5+EUkw3770enSTgyABxO+fj2ods5ovGn+a0Zif0A+fej2+yWmQAAIlKv5ah2DeW/XdxTN8Wp1MyRGC+p/3p7iZP9asXFD1JmYAoMYMV/BLxWB6iL09C+3Tn+Wcv6nSBABmQYxpwwLZpXJurs250iAAaIMmRpQIqtJ9GFBLG6tBY/PPAA0NAzcIp713UFW5491+Yk+/iUiGNtAbb0aCnkePeIAcLudcmB3O8l0jEasuvq9DCVyA2uayRJIOlqNiynP9kvgGrpwVcVoZI/7MX2Us6rPQd955OMl6/mrHUvnHeKb1xJLmRWLjcedAPGGkENQuzgVYfhGkZJkIa7vtM/HReqY6loZ37OCquPnclJn2mabMWXjEHDyhjF85tFuS3T0a7ygyxQu8sXok4vxhmNtxaimoJBjPJ8qFEJqlZSe0TWbr+1fqajsM61yXlUxxLkCZdpcyC2AU0ckXF1K6+zK+byiYL5A8x+XFXsdGorIIQUTWWyWP/a/8esFbNYo3dShnOXCyLNq8eKfVjwmtxM/GAmEFPRA+D2Q4IxlVthWgKeXTTpBylqlQyO56egX27MqrQhuCIOWjG78Bm5qcUVAxRQoq0CmSkGG45PxvieILICDdoV+EyLQYwyNk2jcoLXMfBXnSlZd+26LLb66TKRBOmT9gL/txfUITA7eQ190uKnvyycDzXYmJzkmEFMSOYIVSOokx4Ytwz+RtiBQYjZLvCNtKicC7QghmZol2QZDjDK0Eucbp+lxVnZWG8XbnUmFPzv42/fuH3SkEAJmIk1CCcQxbzSZJcrj6hazuntXiZgmZG7Gsog+sTHHixoMdiWUnYllV9v1P1inG4kC5MFd4KOrjXRgc4QlckefPr/YomTnjJWPi34jtsu+pVvr4XzCepA2G5brPnqJnitvLFjVYWDTjsJVKx69PmIoKGMQmI7Memlc+nygWQXF2uHYKrWFQ9M3CHfLFUdWWTvVyX/I+liX74K/27Bwd1wrqSEF3cSZ/ZWPgom/HGwRXrrWsPXb2NH290Y3XfncdACDj1Oj8J3TSg7JpMQ4p1as9ciinxlwqVntqbP5tc7IcFOFTSY0dWrNovKFDR58VTMSqUntqvNgenq0MB8anMzjNsd6RzPraBHPHgcwu2wmo1sCmD6QnejQZ+j4Df70ZMXpDvToc6BX6+hV8NwQPSdJyWaDcl6TDRGHSuithTSpGTWYZz5cJvoauuwYMG+kOJZufktBdEZJzn747g4mNjAWtG3LHaQnfLQffyyL8jyZ8AGqQvjUvEJ0tB4+4jysCfRrJc4P/1qYlu6mWTuf+/bq4JqRDttVTs7H/nL42s6ZuQxUYWKFTDj6FNTQRVZnxd9Caifdrk4PcQH27YYomjszkwX4ERSIvL0SvoPBmiC+uLgS2qGzFKB7qyMBBOsB1PgQMz74Q01x5gTbjWMoBkpKwhDPvRzzB5F00BNBh/oAzR7QH1tNT46+re9JXQ3QqG0wjdRJLYwnQpGQskSpTSv6VSmL5agKpa639y7Q5oO/A7o1LmkYpMGJn+tiKz/FF6lOZfwAi1wi2lvoOSxfSgTyf1M5/R+ysXyhCTgXpThLH4c8Wcmd36p7dvaxLqTlcOLxNavd7Xbfv7FHVtS1jhFhcYpaWSqNoe23SpgsHRSwdX7ksjHtgG7Vc0hv39VNnYqk0W7iaYI4/mTvQYY/uvXiSxxkCL8G6P5OPgHSpD1ZrlW7Exx5b6xKY5w8QOXBYaOigseEwAGhlCXTXI5MQeUcork3jiHi4sAeM+JA+Ich/O3xTRnj2UHERIXon09SY691F3s2FtcwTgERqihKVsj758BYTniR7qEADSnnOMGgp/785KJQOVECcljf6URtIXupuqg3L7WYzmWkbqc6K5UwOp7lYGolJIsIWmtKJOPa1RKkK8zKn0PtxONE5ReDQHyu8jueTWLVcbnPTyvcfTN1ab3VsVsU9beGrfUQQUq019W6qP0uYGnIMXS1aaHGiwO9S+bDrfG+YzPXkCWxmk0AJaa1O5hgUPjM3C1ohDNaMGzQGhaiKnYtC67kVHK5WWaNKGgm4J+qnuLFHdFPaa2bzDRkx3MLkRyQ2bHbqIgG+6Tg0hN/fIi1U93TptPJW4L+pMhs/Cr5SL8/U9QILXZ2Yw+4ogrFPzTnxn626+MoAvbsr3ZM2cPGLe5ivhXfoDRL3g8y+KibNjOKBZi+aKHYY9YLfClENu7FdpRPQvm6ub5fS1WAzbSOSKKQ+lUyjlSBVeb1pmY7vOYuFvDvmOsn1UCm6eYhVAtL58jik77MqK3hWxH1It3h86XBbMra9JT9RK8YKBqbYOKXEf7Z/tHpbkkdcY0p/1hUP7inuoQb5vFu6okFs4EkKDYFaSJB0O2rNRPwQ98ocohsI0HXAgOtFSFkJ4CIIkz1UzbTca3QrrKNt9Pz+YmALDs14R6KrzCyKRXdHP1sJvrFtcMDi/3fx96c8HI9w+u94mZ38qNdLAv7TNjvC5UnIlL6f3eEbnF4fIfzxi7nQt5Oza34Jn4M86s+GDMbfIJShebntIsT644lRqzxSmWJMs5plLLTTQor1tj/ES1L4Qtto5noprV4Vvy3nbAx4p/EWz+RAgDuhfdMzNpAsib6AIpKXux1CY2fJNYrkvmiWijoWb3y2lFl250Q5d0kVd5Ra4Wgny5KTK+DGbVIxN3Az5b+RmzRIN3Tpqs26Kmfn0cZ8i68m38CyNtjkQ2etjpVz0Xt8lwTMm3SHkQMsNBr0eHhkxBBhXWfr0LQkaDfDrhplWXiQjmvvMco2XAR61i4f6FM6ZKiG5spQZtCBFp3w6TzgSJTz7bk1U4RAvLLJIVWJBqvplwkj3O9vfg98745VELQoY2qyZ9gK37RObfwMCFeI0VR01cM73WYHmDZBcDYDcyxHDL2iCFIMyczLtxi+1QEtD8KatsLgmPTJa7SaXWnNO/xJkzhDPxAnT4l/Aw/ShN6BukWwtoiQF7NUtT8vguChjS1bFzeMPBS5qJsTkYoUXT+4ztlQKpXzQfkDTZAWe8CvO5nPMDPyJDNxVJlL5W68PI2D5hjq6qe4K6CwJihJ6h9UbFZpiiaLGWV7oQLpJmmibBQK0T+m6UHyN1H5b7jthNgGM1lY8tgSdpuxU1Pad8miGQrsrsI3RhUo1/xhZJ/5ogS6q0L/pmbEaHzuWl01ran0bQDNZFfQ6ZYda7jMI0R03cjtzVjFB6PU5IrL6WG5+tFSHXCJPX3w6sTQcbIx3VDb6orQp0pwfry2u2Om5Og297S8+NcRZwtndjtnj8YahZjHBOWJRdZyvV+cLbd3Dp6TY24jow5X3ADoMve7ZpB4Ic6CunffNd0STlE6JKFnvDdaq3gYDx3Un2aLPUmrznGKR4Kwvkla8bIn7NEh0h8vtVZ8eo/5FgxZ5jryKsWaXnU61c8uaaqFKRhHOqmp04drwcp/s/F2PeZks0d/2ujOCn01gwObQHJVwCSRC2kHaYC8v7Ee4z4C/yG94uCg03XMHkUNoHjuCIcQ3PK0JGC6y5GGM8RxCZXl0wI9MbPeT7uNhyDgzL5vCyWNDLBlwoNzYgyEyaRjd9kBvN8YCjWpllLKI/htwX6vsQNw8RZBbyWewcYRhYyq2JQyDew9FWFoYRjAPlK7llgdu07Cfemszn5Is9MdGJ7Jh85JpOHnsKZUbItFzhBrYeX7qKOrC5B/hhCS5q+nYC2XNzb2CdJlHxi9wC0CwzIzVpbFX4FiSVhbQ/g8iFieLDFu/PJ1KcVaJRLdOoaRWYOlYri3ACNeben3+t1lvSBSEvD2ETSsim3ROzpS0M/arAOPNfMWEiZowiHZxApYwfcmrIMoOffCbQIDT9W2hMmxgbId6QPBvCN/Nay866IBw8V2YisaRH6nk8OXttFdGTADdlIf8Yd1Q1rRxNuN+3ESW0f6j6SYZWBNIjvW2ZRKkS2d7ZTTgN+92hdK2inAx/mkKEfRYHXHPHtJ6hK64GKrY16Z3dJOJAYbb5yAfQVnDzbFlUDrHI80QyJuFf3qpDfLqBd6ItNxk+fV2SCAspKBiGuB/tuiKnrxV3pHhgOl4rDqkK2MV6QXvxjcG4uQn8p9dfab8Qcmv77baiRXi+8o7oE1Af3D40PIsgLtpeHd/6GHka15B3L6ZZVO4yijlemhWobhKFqXZmElUTXneHzGLyXMny1Z14qdaDT2w2m3BfDYTCbzZ0s0w+6sjpUyYPs0AKjEo3fvkQk68GtLl+5d7uGu83DR7aEUNP+0RgYJuOyy6dkAsmn3/3LRYaI/BNgNnR7Rqy/H0s7hE7VS/QFGwXMnCJA/mc/UWrYrZpY7nesskO/OqoLsR2aHg/PaEQjwrT1PIrH4SjRaHv0qwDhpZwpgbOfpkpm9yhKbtmCa9R8P4NujsIGfPSLltXJEKRMNyapS6+PrX0xU7z7V0x+2qJRkXvkSC8StOFZvbDSum8V9giqGb4PnxbiLXUz3zJXKSRc5gNY2siAL59t0QRiQvzd/tbPArj03SteUo0IDhxMrByI31pvQVhswB/molLMR7+y6AnHNgykH7xZMTX5OevpJ4n6UlBvTVck8PfRdglYstBaEAvyVrbV61H4kU3GXJWEx+aqEzVMESdff7A79h2yDWIns1J/2WQcI23QrN19+BXdksmiOul0iiofnRESDGa21oi3WcCRUAVxvg0x7as8kIck5R0cV3BtkTjW7BMBuB3REVYgfnpHr3tmdUv2u+3NH/Jn3ucLVJPe1nr6uKHyuHGGXnJmfTJaRjOOFyu1Op+dFll4R2nmRpXdLmhbI4fqRgZP+OGXpJNPsBDmWfCbvlVdfmllIyX10+HwdP5pR5E/M313l7nvi8yT7dC+QwO+RepWE3owu6gYAblCZF8OY03SwnUjSc+FtaLvVwGGQrVDAaliE0oqsHmO1WqtPFcsv0yCMbiMCriLkVroLXGUSpGzXstiCDWr/bgu4woX/AMAUCPjo/CwaiM+6XReQXT18qLG3JG2gO+OPWuC7tArQBBTV0xJAm8AOLO/lgIXcsbsBr/pftgMAU1VL/4y/9001lv6lZaKW2zEa3tR2Xx5uoDdTHU3HKfDRbZW0zVB1acwDANABMrZxtwXQtDPpA7jjZgcBVOs2xAC4qYEKyZn9RvVNzGfu52OZZPqRVvtdn5v+/VvoTsD+cPzQz2CsgUxB1phUWOqKDcX94v1m9nNlKtMkiZ9duWobQDaYYpub71s0n7mfj3WFmeVUfOekvTLZ1CG1f8eP5ewW58smdJVZob//WCuIzy1xNFp9f0L4eDM+5L5tAF1tVjwyd2Eu4RnaWYbn+a/5rnO16p1W2hvo1QHkakrObeoGAPT5GuAeruu6yaeZngu7dn3n5qBmtkIB7tAilFYECnBHC2USZ+EuGxGtGjFhpbsAJ5NAZWrXxRbYW/uXCPDthf8AQO8cwD3nx2NApnVLCdDCFmcW0NH5Cwucm1bBHBY6W9n2z+lQZXxXNvinEu07ouKst6UeRsP7bi1ykNFazm3qAgBOExexL4b/XfGknncTml8/cvHz+CN/JmjanUfvJ+sZANSJUBQ+LTdLAgYtZ2gKAvdcUAAAwB7IFbDFvx6y/8TbfP5OqGHXCt2GUuqLdQAA2oBothwX4aKXJCIiqP+EFNMYhx6bMt+LCCtrxHcTf4gjtmuNwVijUl549nnhhMfd5v9rQSDYT4SFLpZ88uoLWlti7+HFg3//22nlUMs/tm8vVSMHoIOT8oOmSeAbWus/etI6fenv9Ea9e15ZjZo94hvNrNaw/ZGHEbMP7HoVGWUKWVWQc5u5AYAVYTVEWDMkyk0K3yMn5jJSTsDyamvHyoxL8bIVCtiFEeHuFTHOK2EXhbtJnHBPcZsQAQCLLmpdgmWPgokWWpQ+df1xaxgyo4v+AQCuDJWGzo9s/1lQuWZ7qRBB5WSCU/CVdXFM9goyLb1yxIg5t+TlsolqKbdAd8OOENW67ImrWVocbHEw3fAd5/XpWbGus+UHV071cxY1s+MX2TBqtTuiwNfB/3zJzRe9Jc57dVXfFDI4rwIGy2h4hS24amELFdw3BQoefIUJ7rvmFVSpm/x4Gu2iffL5fVeAYLCF3AoOnFxo4NwLDJxrYYEzfa1eciGBEwoIDPTm/mnmF+yTzM+4QMCIT4HvU5MLAZxPAYDj74RIOOT879O1scC+zP/k8d+f0/RUT+oSsr1TcmU629rqoiquCwlmdiJJwhSb0uMzS7QgUDCbpgAA8NjMSds9tM3jM2d02qEj3qeiuiYgkqviyDoV9Fq6KA8AOESQi2cL56r2fGvo1vrvMe5lc8ft4jVhYusUuI2SnMBkWCn0uNIF5Vlbp2IiGigtnxiExp12VfYW1vD0Nbt3NPBkHdech7Gzny55PzFN+rvUIlq/dXICUjoHuDK3x7MvJpqJ6zcu9DBW9oXRqkIzeCJk9eVfdQMArKnJgBfGrNsMC7y59d21q4CzFsIzrrEYL3jYZRJz5m/FzWpCBACagq5qXIJ1jQ2/FnsBu1f6dzfUGg143fgPAKgEVXpbP1pJkLPVPATY2TXxjdZlBVicpr81iutSK+rCOQXurW69D9slSp9MaYnRINX1c6sAadQ97IohsLS7ihkCZ2dMezwK9jS1AwAuMYSGli8Qj0KYeruQ0K5ha6WHBMBrzAMA5ANO1nDTvmXlcLaszBQGuCIO59bJnpI4vEOnYSri8FZ3QKG0JOdbVrbC3pz0Lpr0h3yH1sMomWHI3pEKF7OyhnoP+Y+jwadb1P3xMDrmYGwAWTmaH++t7qYR+Rr0O5LfSp/x28M4mYXosrDKWraSeT8fwI9G458xJH/pYXzM6dgAujysuJY+ZaJrXZ8XzXY3lQg9jJckmFhVTCB3N3JuUzcAkL7zj3vgNrcL7FDbpVvjJVkL4ZouryhbR5XDBZdJ1Ap3XxMiANBD6M7FJVjWuMtzsRfgZe1froE0L/wHALgUoIbzYxWga6sZDOCBLo5tpgoyLL2yLLWiWtAs0DV6Hw5by9MapbR8SGHbDp0R0nuwdEoy7i/5U+8pevxiSDcZrbyfgigEcCW/brYBAGA+1Um9bXrbgqq3/uPeSVrOjKhKoG7LAPGTwswMCJ/ozt8VhON0ZRBlCGe75UzMQshpHqoJAIC0guuqEdbM1xJUOj8WxPbWrjiugFnBvoYdAMBbkBDlKEUUjspmxgOOYIljgSPi2EsCcgfHJOLAjf++7O6UcQrUwp+kVCZT6otiX1WSTFyCrEYwgR+5hCP7nc1GvVhb2WX4eDSe68wn2OOI1aM0KwWcU2UIAFx6gsRZgtqkC3l2Il23lqEbcXKyJCJtwZ1vn+HjP8KpKnVXTHEgU60OiSkLPJ0GRABgZgi71Me7Q+ShaGqv/C/tpolL6ep/ACADzQ3Nny0rq6ItvbkN2tLG26CtX9BtsiVR686BnD7RHY1bsLhBzAd2bP2XzX4Z5EPvvvLyS76StbIbBIXP/7vAapxAYlnpGnVW08WonHxvQj0cIzAxItmcc+5QFcvt37OazTlnC9VjpZuGeV7Ib0aRUNzp+PG10mi3u2VeQb/ijyxi/H/FQ7UVm/gs0NtB7MBCLB4Zbr//Lx8etjIXrMVNTgRh0LSG5rz/0QUTMIpUdlfearIsHKm3NTZK8fhIoAuhNPlTAIBpU6lMWz4t9fjIYr2dVnxUWLXaKSwP0norL2ny8gCASno1NMbLk4VTY0nVU1sZwYaqsa1sYQlVm60GsQvq1nZFmWXCdM1xhs/ckqZnObdKbrEa/XX4OEbz8MHQH4THsetqdPJLNsO0qaxeYg+9iuiN8Nidx7GqZ6WsbV3nkN1uOZdnCADUNfjpSLaz8Ny4zZgDL2PO745aNzjcR1N4ULy7pUN46uokRgdGyojgjHOFV7oL0nCJtheYRekfrhIsGQTd+A8AhIRu95o/OwZORdt6cwHa0cYL0M6Di0zpAkibBLpCVApAtz5xb5cXUU6q1UWOoZxsZyAJQl27211PGuquao15lgzd7YvaAQCjCuqZ3fKZKIYeuNvOxDEkgcKKrYrK4aWb8gAAlKpXVQ7osXGpAGjaGbkAYNxsZQFo3QYXAWjKoFz1wn1yC1ZezVf0PdGc4vy9jfY4SmUYMjWkwqqorAg9F/yime13mrgeR6fcag0gA0PzY9BTtmV1jfhEg78T/BsZj+NUFqJ7ugrFErztWukvGvNI/fDzOD7lamsA3dtVa0tTNm0dkzdG03zirbXH8QqH1KokIetkkarAAQB3/8fHtXGbewg2R9t52/plGQvRvr0ai0KDq1gnUSK4LSNieoz/v9JdoNpCkWtuL5rK1v6lt2ZDHaSr8R8AyACaa5g/WxY1RTm9uQ3a0sbboK0HtzOlSxi2W4ANMLLtZy2qjG3KluvUXzCjySjku3veqn/zfiTGibVIjDPfU7hB9quFuR0cAxPdPg66Xl59+sYLwYNbzxamfxW5BWDOlsezmRnS7ha9Qro3jsAILBL28xpQKNtZrJoOsw+kPnlcjuVf7e0T0XPvpr0Fh2aro68OiVEqhk8vni5ROiE8Hgv7CJ2bkcxHUTmGF9Vt+HHl6RPXvsxw5RD6oU7pF0rGLgMbEr16ukSaavUTQTKJfw4qy56meJthEBTZIJ6Yc46eln5DLtQZiq76O8Ja4o0kHtQug8HHyd6q3otq+PKfSSp76yvx4hqGyjaQfnn0ZbkHkq+pgV0mguRLf5AGN0tPUoPOT6f1wP6ibubUQ/VZpgq9JOzmWtm4IfvB3WckhThbT1XjqPFPhGo7NU4YRAc/OWinJtmGulIIOMs3zWHFJ76tZBskLnhtuzSqSQKZBJOj2VIvcJ1lAvTT16PoT9DxtbnGnxsp+vl0Sr28EqBBWwrAezAfuck+gD/oY2HxWYseiD2nRW9fAVw+oBTt12sjg/BX2ot65DtYnKUlkeHYN6hTk7prWCZ3TK3S2+VrD97X4nZ/oa/Dx20chFpal/8CsE2zm5LmVMuMNNepyWAk8LswObgdpWMgR4zNPAly9Nrh07VLIw4ejkkZbqJrFuRsiZ4lGNNny7+y491b+mR7cy5/woXqPYTKGjDjs7pz1jHflT/CLt5M/G1fLauqFk9tXL965dyOi73RfjUR5Tf+huu8ONF7eHktn4VJ8n3mHA6jAJFYHbSOH+/nHM85VtocOGgVhn8lsMaOh2wUZsoq2UBIS2/es2651+G+ZP5CJQXrWUkE3XgnXE3g0UiC9/HhcDdwNfwGUzoB+H168JKNBvlJ3J27fmefHr5jMg36jGgBfJqZ+YIOf8DTveHc1c1Uh7HBefDPNxlVtkNUAc5dzc2smRB3udrZNh72G7FG06Vud5//dO8rVyhvKvTTOgBZUdKBBQDaGYoqbl4lBv6FLQMN9ZbBJFP3nbmpxIqWRc9SWn5uB0bz7P7MzLWNirmZb1PYo7buqgj+NLZhN9UpFO6qNtRV3F4jowhkowY/UFxYI2hZSj6/SsPL1UTjsrVq6VB9dcSxPUaREiNt/LelX7KzD7iJ/mP2AIBMjr/6I0jlSCyQ4rqYyuX3/9giplDcpuT5C0T95CEuY641PTW5beIpTkvObGKWr9LGfUixi+kpVV1TMGVaXHV6Sq1rBKZsSq7JNV3uFSUXtL3lPhB9i8SFLcyuWsqKV3i3QClrXz22JZ6R3Xncli91OC41WlNwS1EMDAer5u5e1cydq9qHrlVN7ysYrqfUu73na4xWTP3f3zHcY0QZWDk1k3OHmlh8gKTkbgF+fGf83l6/KXbysu9jGoPeyau5+6VizMQDFaXccZtvp5pnbNXcv91OF6V06VqVYJqSDrrfPbcaxQvsiuKGipRjGaZ8UdEE3g4UwHP4/l7j0HQG8C5xr173spf7aF47bam34bzVlJaFbZm1SGk5fU32OkNpyfzteH2L++I265Y0wCZiNVxo2kf2s30+XowJWWc0IWfLVTyQ1ug7WObs/lXF4xG0yAGmQzSfHWr3okeTw116sntYJ+gEzfg+vkbWc+iDs+5XdV/fBSuA1Q6yaoS7TWn1A6SvkXEe3YZUnrTCMKj807fz7WpR0vYopiERkGjRVJqxbPPEDt0sB/wroo/k40v9tp+69WomSU+5d2iVqy9HWFdgVxpMAAB3gzNV+MhAS51GCWPX/SMKguJ1naEOEJSnXYBuQlBhGwk9Cx7AaJvto14rtQW+NolyrmGlx1OH4LmogYCqPQDArnntU2cBdM4PbwEiVlNbAANN7Jl+KuCAS6fUGMBHo2DpIFYOUmcIrEXtunaX4wjcqSFMxHAJa16Pl1jpkuuOrcEzS1PNDwGAVQxXse3LgoGWNXe5AbxxVa7A0D1XvgfWfPbZd0EoG3cnQBc7s1zALTd7bcBsB2JvAEM1YrtLQpOuDgyEXR+l2YRnbxVP2TCKv80P3QAyXzQfBlRlqxbrFfuM4uWP2vFDc1a/zTfdALrIq9azrawifxJvsVmXL2bqxHWNV/22r6JWVU2Gid1hY8tiGKbwCr+mygQAKOO3ih6qkcFSKfkLE3CET5af7eIrpkEdvs9IGuDPFtq2arFYrS1/XSjQtHffYgvmqk4Qd1yarQIIANz4ovszBxBGb0Yg75q4J/BJBJQzSicMuFcogKVgBVQsUki7WzZmtDsSCv5sfX5A/K//0/6pPzfrkWmgARzginLJrgK+QQ35jSEslGVg2c/9GQdhXRxAA+A5aUfgc/XM9gmj7BRAA0bHlsMCU2r4AABUhIBbLRYoBT7y1/QEZG1Y+vPnZC238m//91MrhLpsmdtg4ZgysFNDkTWbpP36GW9ewtzcqvqyaY3HcSmkHY+ZgGdgJa0DAJ1JVcnOYRV8W+Iq3hSshbzeShsukQrOgZU+TPLcLtebHNDrvwnm8XXfpq9tBHv03jJU8LJ29UWGiZde9ueJw91ONP6uPkkHe1merPvKpvyjWdWzS8sP1VHGdYm/uemttaveqvpX39FaucKtxXdVmQAALnA6bnoUMLvcP2ogaE/XGaYMQUdtAswESWH+xkRiC4NuEAUAWA2A4xBBIJwC/ZwpSuliOLHk/4wfiywrn+2ucApLEZu0AQB1NJ8BC8PpAR+czvxknQsabTWbAhq0xc0DGnKlWoXyoIXhtOKt63SmLQusUJJo0PX6hA1U4OVl+Nml6bPbFahuZ6WZouUKzOoNq1zA1Vjemwau4I7dDIMrNUAEADcErm37DmFwnZq7w0J3dcbKVQMhIHS20b3nrRlH2WbeF3Q5t31b5whTs/33mJQw3EnN3TVhYqsUpjDiyXlQrDCNPq5GTBlN7uWp+9yGM1Pmd6Wm3OZzINi7wXe/nlmlj/Cc8ssJl/pNv2rWqzU/RYFDiSmcxc9VmQAAW+DrNj1OTViTNErYRQPQHJiN3g/Iqj1jFRg+XckZhiEXUhjbLhIhsFhGhG6O6UIeAgAggMchQobvmRIH1ZS7JV6+uAbKVmoDAOle0TWvAs4aLnIhPuMKVLaa5gHiaGJvm+KEAwmmVKoyxk9AkJR2vR56jNQKsjIEDgOEEWTL7coZyHYdYS4cnBFZ3p0BzHPHFmJgqwEiAFg1kMT23ZJBVvV2d2B4TJkq3rYG63Vv1UR34ZtGknT2XeCBhttU2PgcMNIyGXHAg5EdigP1ORBJAWrUiMUmGfu4suD7HoevTr/oJDuDMzKkbpMvtwFkX2hO5uPlMfbF97Wb6Te//qN16IZtcr/l320A3bRVPLOQkMEft2e86euv5TKhu2eb7G+vdz+It6vOVTmt72zFwKymsIKvqzIBAHTglA31sH4Uw7hzAcm2WkVPXX/WJ3zTNGjjx0YSih5ktOFalIartYXaBaIxMKuAIH5ZmahLwUZtAKCqWou5FRoUXDwRYBRbqFpvkiBEGy/xuW4bzyBr7dqTSgyKhbbW/pj6YQA=","base64")).toString()),uL}var DAe=new Map([[P.makeIdent(null,"fsevents").identHash,kAe],[P.makeIdent(null,"resolve").identHash,xAe],[P.makeIdent(null,"typescript").identHash,PAe]]),yze={hooks:{registerPackageExtensions:async(t,e)=>{for(let[r,i]of SAe)e(P.parseDescriptor(r,!0),i)},getBuiltinPatch:async(t,e)=>{var s;let r="compat/";if(!e.startsWith(r))return;let i=P.parseIdent(e.slice(r.length)),n=(s=DAe.get(i.identHash))==null?void 0:s();return typeof n!="undefined"?n:null},reduceDependency:async(t,e,r,i)=>typeof DAe.get(t.identHash)=="undefined"?t:P.makeDescriptor(t,P.makeRange({protocol:"patch:",source:P.stringifyDescriptor(t),selector:`~builtin<compat/${P.stringifyIdent(t)}>`,params:null}))}},wze=yze;var fL={};ft(fL,{default:()=>bze});var Ab=class extends Le{constructor(){super(...arguments);this.pkg=z.String("-p,--package",{description:"The package to run the provided command from"});this.quiet=z.Boolean("-q,--quiet",!1,{description:"Only report critical errors instead of printing the full install logs"});this.command=z.String();this.args=z.Proxy()}async execute(){let e=[];this.pkg&&e.push("--package",this.pkg),this.quiet&&e.push("--quiet");let r=P.parseIdent(this.command),i=P.makeIdent(r.scope,`create-${r.name}`);return this.cli.run(["dlx",...e,P.stringifyIdent(i),...this.args])}};Ab.paths=[["create"]];var RAe=Ab;var Om=class extends Le{constructor(){super(...arguments);this.packages=z.Array("-p,--package",{description:"The package(s) to install before running the command"});this.quiet=z.Boolean("-q,--quiet",!1,{description:"Only report critical errors instead of printing the full install logs"});this.command=z.String();this.args=z.Proxy()}async execute(){return ye.telemetry=null,await K.mktempPromise(async e=>{var p;let r=k.join(e,`dlx-${process.pid}`);await K.mkdirPromise(r),await K.writeFilePromise(k.join(r,"package.json"),`{}
+`),await K.writeFilePromise(k.join(r,"yarn.lock"),"");let i=k.join(r,".yarnrc.yml"),n=await ye.findProjectCwd(this.context.cwd,Pt.lockfile),s=!(await ye.find(this.context.cwd,null,{strict:!1})).get("enableGlobalCache"),o=n!==null?k.join(n,".yarnrc.yml"):null;o!==null&&K.existsSync(o)?(await K.copyFilePromise(o,i),await ye.updateConfiguration(r,m=>{let y=te(N({},m),{enableGlobalCache:s,enableTelemetry:!1});return Array.isArray(m.plugins)&&(y.plugins=m.plugins.map(Q=>{let S=typeof Q=="string"?Q:Q.path,x=j.isAbsolute(S)?S:j.resolve(j.fromPortablePath(n),S);return typeof Q=="string"?x:{path:x,spec:Q.spec}})),y})):await K.writeFilePromise(i,`enableGlobalCache: ${s}
+enableTelemetry: false
+`);let a=(p=this.packages)!=null?p:[this.command],l=P.parseDescriptor(this.command).name,c=await this.cli.run(["add","--",...a],{cwd:r,quiet:this.quiet});if(c!==0)return c;this.quiet||this.context.stdout.write(`
+`);let u=await ye.find(r,this.context.plugins),{project:g,workspace:f}=await ze.find(u,r);if(f===null)throw new ht(g.cwd,r);await g.restoreInstallState();let h=await Zt.getWorkspaceAccessibleBinaries(f);return h.has(l)===!1&&h.size===1&&typeof this.packages=="undefined"&&(l=Array.from(h)[0][0]),await Zt.executeWorkspaceAccessibleBinary(f,l,this.args,{packageAccessibleBinaries:h,cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr})})}};Om.paths=[["dlx"]],Om.usage=Re.Usage({description:"run a package in a temporary environment",details:"\n      This command will install a package within a temporary environment, and run its binary script if it contains any. The binary will run within the current cwd.\n\n      By default Yarn will download the package named `command`, but this can be changed through the use of the `-p,--package` flag which will instruct Yarn to still run the same command but from a different package.\n\n      Using `yarn dlx` as a replacement of `yarn add` isn't recommended, as it makes your project non-deterministic (Yarn doesn't keep track of the packages installed through `dlx` - neither their name, nor their version).\n    ",examples:[["Use create-react-app to create a new React app","yarn dlx create-react-app ./my-app"],["Install multiple packages for a single command",`yarn dlx -p typescript -p ts-node ts-node --transpile-only -e "console.log('hello!')"`]]});var FAe=Om;var Bze={commands:[RAe,FAe]},bze=Bze;var wL={};ft(wL,{default:()=>Sze,fileUtils:()=>hL});var ih=/^(?:[a-zA-Z]:[\\/]|\.{0,2}\/)/,Mm=/^[^?]*\.(?:tar\.gz|tgz)(?:::.*)?$/,Vr="file:";var hL={};ft(hL,{makeArchiveFromLocator:()=>lb,makeBufferFromLocator:()=>CL,makeLocator:()=>dL,makeSpec:()=>NAe,parseSpec:()=>pL});function pL(t){let{params:e,selector:r}=P.parseRange(t),i=j.toPortablePath(r);return{parentLocator:e&&typeof e.locator=="string"?P.parseLocator(e.locator):null,path:i}}function NAe({parentLocator:t,path:e,folderHash:r,protocol:i}){let n=t!==null?{locator:P.stringifyLocator(t)}:{},s=typeof r!="undefined"?{hash:r}:{};return P.makeRange({protocol:i,source:e,selector:e,params:N(N({},s),n)})}function dL(t,{parentLocator:e,path:r,folderHash:i,protocol:n}){return P.makeLocator(t,NAe({parentLocator:e,path:r,folderHash:i,protocol:n}))}async function lb(t,{protocol:e,fetchOptions:r,inMemory:i=!1}){let{parentLocator:n,path:s}=P.parseFileStyleRange(t.reference,{protocol:e}),o=k.isAbsolute(s)?{packageFs:new _t(Me.root),prefixPath:Me.dot,localPath:Me.root}:await r.fetcher.fetch(n,r),a=o.localPath?{packageFs:new _t(Me.root),prefixPath:k.relative(Me.root,o.localPath)}:o;o!==a&&o.releaseFs&&o.releaseFs();let l=a.packageFs,c=k.join(a.prefixPath,s);return await Se.releaseAfterUseAsync(async()=>await wi.makeArchiveFromDirectory(c,{baseFs:l,prefixPath:P.getIdentVendorPath(t),compressionLevel:r.project.configuration.get("compressionLevel"),inMemory:i}),a.releaseFs)}async function CL(t,{protocol:e,fetchOptions:r}){return(await lb(t,{protocol:e,fetchOptions:r,inMemory:!0})).getBufferAndClose()}var mL=class{supports(e,r){return!!e.reference.startsWith(Vr)}getLocalPath(e,r){let{parentLocator:i,path:n}=P.parseFileStyleRange(e.reference,{protocol:Vr});if(k.isAbsolute(n))return n;let s=r.fetcher.getLocalPath(i,r);return s===null?null:k.resolve(s,n)}async fetch(e,r){let i=r.checksums.get(e.locatorHash)||null,[n,s,o]=await r.cache.fetchPackageFromCache(e,i,N({onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${P.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the disk`),loader:()=>this.fetchFromDisk(e,r),skipIntegrityCheck:r.skipIntegrityCheck},r.cacheOptions));return{packageFs:n,releaseFs:s,prefixPath:P.getIdentVendorPath(e),localPath:this.getLocalPath(e,r),checksum:o}}async fetchFromDisk(e,r){return lb(e,{protocol:Vr,fetchOptions:r})}};var Qze=2,EL=class{supportsDescriptor(e,r){return e.range.match(ih)?!0:!!e.range.startsWith(Vr)}supportsLocator(e,r){return!!e.reference.startsWith(Vr)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,i){return ih.test(e.range)&&(e=P.makeDescriptor(e,`${Vr}${e.range}`)),P.bindDescriptor(e,{locator:P.stringifyLocator(r)})}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){if(!i.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{path:n,parentLocator:s}=pL(e.range);if(s===null)throw new Error("Assertion failed: The descriptor should have been bound");let o=await CL(P.makeLocator(e,P.makeRange({protocol:Vr,source:n,selector:n,params:{locator:P.stringifyLocator(s)}})),{protocol:Vr,fetchOptions:i.fetchOptions}),a=Dn.makeHash(`${Qze}`,o).slice(0,6);return[dL(e,{parentLocator:s,path:n,folderHash:a,protocol:Vr})]}async getSatisfying(e,r,i){return null}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let i=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),n=await Se.releaseAfterUseAsync(async()=>await At.find(i.prefixPath,{baseFs:i.packageFs}),i.releaseFs);return te(N({},e),{version:n.version||"0.0.0",languageName:n.languageName||r.project.configuration.get("defaultLanguageName"),linkType:Qt.HARD,conditions:n.getConditions(),dependencies:n.dependencies,peerDependencies:n.peerDependencies,dependenciesMeta:n.dependenciesMeta,peerDependenciesMeta:n.peerDependenciesMeta,bin:n.bin})}};var IL=class{supports(e,r){return Mm.test(e.reference)?!!e.reference.startsWith(Vr):!1}getLocalPath(e,r){return null}async fetch(e,r){let i=r.checksums.get(e.locatorHash)||null,[n,s,o]=await r.cache.fetchPackageFromCache(e,i,N({onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${P.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the disk`),loader:()=>this.fetchFromDisk(e,r),skipIntegrityCheck:r.skipIntegrityCheck},r.cacheOptions));return{packageFs:n,releaseFs:s,prefixPath:P.getIdentVendorPath(e),checksum:o}}async fetchFromDisk(e,r){let{parentLocator:i,path:n}=P.parseFileStyleRange(e.reference,{protocol:Vr}),s=k.isAbsolute(n)?{packageFs:new _t(Me.root),prefixPath:Me.dot,localPath:Me.root}:await r.fetcher.fetch(i,r),o=s.localPath?{packageFs:new _t(Me.root),prefixPath:k.relative(Me.root,s.localPath)}:s;s!==o&&s.releaseFs&&s.releaseFs();let a=o.packageFs,l=k.join(o.prefixPath,n),c=await a.readFilePromise(l);return await Se.releaseAfterUseAsync(async()=>await wi.convertToZip(c,{compressionLevel:r.project.configuration.get("compressionLevel"),prefixPath:P.getIdentVendorPath(e),stripComponents:1}),o.releaseFs)}};var yL=class{supportsDescriptor(e,r){return Mm.test(e.range)?!!(e.range.startsWith(Vr)||ih.test(e.range)):!1}supportsLocator(e,r){return Mm.test(e.reference)?!!e.reference.startsWith(Vr):!1}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,i){return ih.test(e.range)&&(e=P.makeDescriptor(e,`${Vr}${e.range}`)),P.bindDescriptor(e,{locator:P.stringifyLocator(r)})}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){let n=e.range;return n.startsWith(Vr)&&(n=n.slice(Vr.length)),[P.makeLocator(e,`${Vr}${j.toPortablePath(n)}`)]}async getSatisfying(e,r,i){return null}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let i=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),n=await Se.releaseAfterUseAsync(async()=>await At.find(i.prefixPath,{baseFs:i.packageFs}),i.releaseFs);return te(N({},e),{version:n.version||"0.0.0",languageName:n.languageName||r.project.configuration.get("defaultLanguageName"),linkType:Qt.HARD,conditions:n.getConditions(),dependencies:n.dependencies,peerDependencies:n.peerDependencies,dependenciesMeta:n.dependenciesMeta,peerDependenciesMeta:n.peerDependenciesMeta,bin:n.bin})}};var vze={fetchers:[IL,mL],resolvers:[yL,EL]},Sze=vze;var bL={};ft(bL,{default:()=>Pze});var LAe=ge(require("querystring")),TAe=[/^https?:\/\/(?:([^/]+?)@)?github.com\/([^/#]+)\/([^/#]+)\/tarball\/([^/#]+)(?:#(.*))?$/,/^https?:\/\/(?:([^/]+?)@)?github.com\/([^/#]+)\/([^/#]+?)(?:\.git)?(?:#(.*))?$/];function OAe(t){return t?TAe.some(e=>!!t.match(e)):!1}function MAe(t){let e;for(let a of TAe)if(e=t.match(a),e)break;if(!e)throw new Error(kze(t));let[,r,i,n,s="master"]=e,{commit:o}=LAe.default.parse(s);return s=o||s.replace(/[^:]*:/,""),{auth:r,username:i,reponame:n,treeish:s}}function kze(t){return`Input cannot be parsed as a valid GitHub URL ('${t}').`}var BL=class{supports(e,r){return!!OAe(e.reference)}getLocalPath(e,r){return null}async fetch(e,r){let i=r.checksums.get(e.locatorHash)||null,[n,s,o]=await r.cache.fetchPackageFromCache(e,i,N({onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${P.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from GitHub`),loader:()=>this.fetchFromNetwork(e,r),skipIntegrityCheck:r.skipIntegrityCheck},r.cacheOptions));return{packageFs:n,releaseFs:s,prefixPath:P.getIdentVendorPath(e),checksum:o}}async fetchFromNetwork(e,r){let i=await ir.get(this.getLocatorUrl(e,r),{configuration:r.project.configuration});return await K.mktempPromise(async n=>{let s=new _t(n);await wi.extractArchiveTo(i,s,{stripComponents:1});let o=wu.splitRepoUrl(e.reference),a=k.join(n,"package.tgz");await Zt.prepareExternalProject(n,a,{configuration:r.project.configuration,report:r.report,workspace:o.extra.workspace,locator:e});let l=await K.readFilePromise(a);return await wi.convertToZip(l,{compressionLevel:r.project.configuration.get("compressionLevel"),prefixPath:P.getIdentVendorPath(e),stripComponents:1})})}getLocatorUrl(e,r){let{auth:i,username:n,reponame:s,treeish:o}=MAe(e.reference);return`https://${i?`${i}@`:""}github.com/${n}/${s}/archive/${o}.tar.gz`}};var xze={hooks:{async fetchHostedRepository(t,e,r){if(t!==null)return t;let i=new BL;if(!i.supports(e,r))return null;try{return await i.fetch(e,r)}catch(n){return null}}}},Pze=xze;var SL={};ft(SL,{default:()=>Rze});var Um=/^[^?]*\.(?:tar\.gz|tgz)(?:\?.*)?$/,Km=/^https?:/;var QL=class{supports(e,r){return Um.test(e.reference)?!!Km.test(e.reference):!1}getLocalPath(e,r){return null}async fetch(e,r){let i=r.checksums.get(e.locatorHash)||null,[n,s,o]=await r.cache.fetchPackageFromCache(e,i,N({onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${P.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote server`),loader:()=>this.fetchFromNetwork(e,r),skipIntegrityCheck:r.skipIntegrityCheck},r.cacheOptions));return{packageFs:n,releaseFs:s,prefixPath:P.getIdentVendorPath(e),checksum:o}}async fetchFromNetwork(e,r){let i=await ir.get(e.reference,{configuration:r.project.configuration});return await wi.convertToZip(i,{compressionLevel:r.project.configuration.get("compressionLevel"),prefixPath:P.getIdentVendorPath(e),stripComponents:1})}};var vL=class{supportsDescriptor(e,r){return Um.test(e.range)?!!Km.test(e.range):!1}supportsLocator(e,r){return Um.test(e.reference)?!!Km.test(e.reference):!1}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,i){return e}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){return[P.convertDescriptorToLocator(e)]}async getSatisfying(e,r,i){return null}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let i=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),n=await Se.releaseAfterUseAsync(async()=>await At.find(i.prefixPath,{baseFs:i.packageFs}),i.releaseFs);return te(N({},e),{version:n.version||"0.0.0",languageName:n.languageName||r.project.configuration.get("defaultLanguageName"),linkType:Qt.HARD,conditions:n.getConditions(),dependencies:n.dependencies,peerDependencies:n.peerDependencies,dependenciesMeta:n.dependenciesMeta,peerDependenciesMeta:n.peerDependenciesMeta,bin:n.bin})}};var Dze={fetchers:[QL],resolvers:[vL]},Rze=Dze;var DL={};ft(DL,{default:()=>F4e});var ule=ge(cle()),PL=ge(require("util")),Hm=class extends Le{constructor(){super(...arguments);this.private=z.Boolean("-p,--private",!1,{description:"Initialize a private package"});this.workspace=z.Boolean("-w,--workspace",!1,{description:"Initialize a workspace root with a `packages/` directory"});this.install=z.String("-i,--install",!1,{tolerateBoolean:!0,description:"Initialize a package with a specific bundle that will be locked in the project"});this.usev2=z.Boolean("-2",!1,{hidden:!0});this.yes=z.Boolean("-y,--yes",{hidden:!0});this.assumeFreshProject=z.Boolean("--assume-fresh-project",!1,{hidden:!0})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),r=typeof this.install=="string"?this.install:this.usev2||this.install===!0?"latest":null;return r!==null?await this.executeProxy(e,r):await this.executeRegular(e)}async executeProxy(e,r){if(e.projectCwd!==null&&e.projectCwd!==this.context.cwd)throw new Pe("Cannot use the --install flag from within a project subdirectory");K.existsSync(this.context.cwd)||await K.mkdirPromise(this.context.cwd,{recursive:!0});let i=k.join(this.context.cwd,e.get("lockfileFilename"));K.existsSync(i)||await K.writeFilePromise(i,"");let n=await this.cli.run(["set","version",r],{quiet:!0});if(n!==0)return n;let s=[];return this.private&&s.push("-p"),this.workspace&&s.push("-w"),this.yes&&s.push("-y"),await K.mktempPromise(async o=>{let{code:a}=await Fr.pipevp("yarn",["init",...s],{cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,env:await Zt.makeScriptEnv({binFolder:o})});return a})}async executeRegular(e){var l;let r=null;try{r=(await ze.find(e,this.context.cwd)).project}catch{r=null}K.existsSync(this.context.cwd)||await K.mkdirPromise(this.context.cwd,{recursive:!0});let i=await At.tryFind(this.context.cwd)||new At,n=Object.fromEntries(e.get("initFields").entries());i.load(n),i.name=(l=i.name)!=null?l:P.makeIdent(e.get("initScope"),k.basename(this.context.cwd)),i.packageManager=Ur&&Se.isTaggedYarnVersion(Ur)?`yarn@${Ur}`:null,typeof i.raw.private=="undefined"&&(this.private||this.workspace&&i.workspaceDefinitions.length===0)&&(i.private=!0),this.workspace&&i.workspaceDefinitions.length===0&&(await K.mkdirPromise(k.join(this.context.cwd,"packages"),{recursive:!0}),i.workspaceDefinitions=[{pattern:"packages/*"}]);let s={};i.exportTo(s),PL.inspect.styles.name="cyan",this.context.stdout.write(`${(0,PL.inspect)(s,{depth:Infinity,colors:!0,compact:!1})}
+`);let o=k.join(this.context.cwd,At.fileName);await K.changeFilePromise(o,`${JSON.stringify(s,null,2)}
+`,{automaticNewlines:!0});let a=k.join(this.context.cwd,"README.md");if(K.existsSync(a)||await K.writeFilePromise(a,`# ${P.stringifyIdent(i.name)}
+`),!r||r.cwd===this.context.cwd){let c=k.join(this.context.cwd,Pt.lockfile);K.existsSync(c)||await K.writeFilePromise(c,"");let g=[".yarn/*","!.yarn/patches","!.yarn/plugins","!.yarn/releases","!.yarn/sdks","!.yarn/versions","","# Swap the comments on the following lines if you don't wish to use zero-installs","# Documentation here: https://yarnpkg.com/features/zero-installs","!.yarn/cache","#.pnp.*"].map(y=>`${y}
+`).join(""),f=k.join(this.context.cwd,".gitignore");K.existsSync(f)||await K.writeFilePromise(f,g);let h={["*"]:{endOfLine:"lf",insertFinalNewline:!0},["*.{js,json,yml}"]:{charset:"utf-8",indentStyle:"space",indentSize:2}};(0,ule.default)(h,e.get("initEditorConfig"));let p=`root = true
+`;for(let[y,Q]of Object.entries(h)){p+=`
+[${y}]
+`;for(let[S,x]of Object.entries(Q))p+=`${S.replace(/[A-Z]/g,Y=>`_${Y.toLowerCase()}`)} = ${x}
+`}let m=k.join(this.context.cwd,".editorconfig");K.existsSync(m)||await K.writeFilePromise(m,p),K.existsSync(k.join(this.context.cwd,".git"))||await Fr.execvp("git",["init"],{cwd:this.context.cwd})}}};Hm.paths=[["init"]],Hm.usage=Re.Usage({description:"create a new package",details:"\n      This command will setup a new package in your local directory.\n\n      If the `-p,--private` or `-w,--workspace` options are set, the package will be private by default.\n\n      If the `-w,--workspace` option is set, the package will be configured to accept a set of workspaces in the `packages/` directory.\n\n      If the `-i,--install` option is given a value, Yarn will first download it using `yarn set version` and only then forward the init call to the newly downloaded bundle. Without arguments, the downloaded bundle will be `latest`.\n\n      The initial settings of the manifest can be changed by using the `initScope` and `initFields` configuration values. Additionally, Yarn will generate an EditorConfig file whose rules can be altered via `initEditorConfig`, and will initialize a Git repository in the current directory.\n    ",examples:[["Create a new package in the local directory","yarn init"],["Create a new private package in the local directory","yarn init -p"],["Create a new package and store the Yarn release inside","yarn init -i=latest"],["Create a new private package and defines it as a workspace root","yarn init -w"]]});var gle=Hm;var R4e={configuration:{initScope:{description:"Scope used when creating packages via the init command",type:Ie.STRING,default:null},initFields:{description:"Additional fields to set when creating packages via the init command",type:Ie.MAP,valueDefinition:{description:"",type:Ie.ANY}},initEditorConfig:{description:"Extra rules to define in the generator editorconfig",type:Ie.MAP,valueDefinition:{description:"",type:Ie.ANY}}},commands:[gle]},F4e=R4e;var TL={};ft(TL,{default:()=>L4e});var mA="portal:",EA="link:";var RL=class{supports(e,r){return!!e.reference.startsWith(mA)}getLocalPath(e,r){let{parentLocator:i,path:n}=P.parseFileStyleRange(e.reference,{protocol:mA});if(k.isAbsolute(n))return n;let s=r.fetcher.getLocalPath(i,r);return s===null?null:k.resolve(s,n)}async fetch(e,r){var c;let{parentLocator:i,path:n}=P.parseFileStyleRange(e.reference,{protocol:mA}),s=k.isAbsolute(n)?{packageFs:new _t(Me.root),prefixPath:Me.dot,localPath:Me.root}:await r.fetcher.fetch(i,r),o=s.localPath?{packageFs:new _t(Me.root),prefixPath:k.relative(Me.root,s.localPath),localPath:Me.root}:s;s!==o&&s.releaseFs&&s.releaseFs();let a=o.packageFs,l=k.resolve((c=o.localPath)!=null?c:o.packageFs.getRealPath(),o.prefixPath,n);return s.localPath?{packageFs:new _t(l,{baseFs:a}),releaseFs:o.releaseFs,prefixPath:Me.dot,localPath:l}:{packageFs:new Da(l,{baseFs:a}),releaseFs:o.releaseFs,prefixPath:Me.dot}}};var FL=class{supportsDescriptor(e,r){return!!e.range.startsWith(mA)}supportsLocator(e,r){return!!e.reference.startsWith(mA)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,i){return P.bindDescriptor(e,{locator:P.stringifyLocator(r)})}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){let n=e.range.slice(mA.length);return[P.makeLocator(e,`${mA}${j.toPortablePath(n)}`)]}async getSatisfying(e,r,i){return null}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let i=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),n=await Se.releaseAfterUseAsync(async()=>await At.find(i.prefixPath,{baseFs:i.packageFs}),i.releaseFs);return te(N({},e),{version:n.version||"0.0.0",languageName:n.languageName||r.project.configuration.get("defaultLanguageName"),linkType:Qt.SOFT,conditions:n.getConditions(),dependencies:new Map([...n.dependencies]),peerDependencies:n.peerDependencies,dependenciesMeta:n.dependenciesMeta,peerDependenciesMeta:n.peerDependenciesMeta,bin:n.bin})}};var NL=class{supports(e,r){return!!e.reference.startsWith(EA)}getLocalPath(e,r){let{parentLocator:i,path:n}=P.parseFileStyleRange(e.reference,{protocol:EA});if(k.isAbsolute(n))return n;let s=r.fetcher.getLocalPath(i,r);return s===null?null:k.resolve(s,n)}async fetch(e,r){var c;let{parentLocator:i,path:n}=P.parseFileStyleRange(e.reference,{protocol:EA}),s=k.isAbsolute(n)?{packageFs:new _t(Me.root),prefixPath:Me.dot,localPath:Me.root}:await r.fetcher.fetch(i,r),o=s.localPath?{packageFs:new _t(Me.root),prefixPath:k.relative(Me.root,s.localPath),localPath:Me.root}:s;s!==o&&s.releaseFs&&s.releaseFs();let a=o.packageFs,l=k.resolve((c=o.localPath)!=null?c:o.packageFs.getRealPath(),o.prefixPath,n);return s.localPath?{packageFs:new _t(l,{baseFs:a}),releaseFs:o.releaseFs,prefixPath:Me.dot,discardFromLookup:!0,localPath:l}:{packageFs:new Da(l,{baseFs:a}),releaseFs:o.releaseFs,prefixPath:Me.dot,discardFromLookup:!0}}};var LL=class{supportsDescriptor(e,r){return!!e.range.startsWith(EA)}supportsLocator(e,r){return!!e.reference.startsWith(EA)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,i){return P.bindDescriptor(e,{locator:P.stringifyLocator(r)})}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){let n=e.range.slice(EA.length);return[P.makeLocator(e,`${EA}${j.toPortablePath(n)}`)]}async getSatisfying(e,r,i){return null}async resolve(e,r){return te(N({},e),{version:"0.0.0",languageName:r.project.configuration.get("defaultLanguageName"),linkType:Qt.SOFT,conditions:null,dependencies:new Map,peerDependencies:new Map,dependenciesMeta:new Map,peerDependenciesMeta:new Map,bin:new Map})}};var N4e={fetchers:[NL,RL],resolvers:[LL,FL]},L4e=N4e;var gT={};ft(gT,{default:()=>Y_e});var ls;(function(i){i[i.REGULAR=0]="REGULAR",i[i.WORKSPACE=1]="WORKSPACE",i[i.EXTERNAL_SOFT_LINK=2]="EXTERNAL_SOFT_LINK"})(ls||(ls={}));var IA;(function(i){i[i.YES=0]="YES",i[i.NO=1]="NO",i[i.DEPENDS=2]="DEPENDS"})(IA||(IA={}));var OL=(t,e)=>`${t}@${e}`,fle=(t,e)=>{let r=e.indexOf("#"),i=r>=0?e.substring(r+1):e;return OL(t,i)},Eo;(function(s){s[s.NONE=-1]="NONE",s[s.PERF=0]="PERF",s[s.CHECK=1]="CHECK",s[s.REASONS=2]="REASONS",s[s.INTENSIVE_CHECK=9]="INTENSIVE_CHECK"})(Eo||(Eo={}));var ple=(t,e={})=>{let r=e.debugLevel||Number(process.env.NM_DEBUG_LEVEL||-1),i=e.check||r>=9,n=e.hoistingLimits||new Map,s={check:i,debugLevel:r,hoistingLimits:n,fastLookupPossible:!0},o;s.debugLevel>=0&&(o=Date.now());let a=T4e(t,s),l=!1,c=0;do l=ML(a,[a],new Set([a.locator]),new Map,s).anotherRoundNeeded,s.fastLookupPossible=!1,c++;while(l);if(s.debugLevel>=0&&console.log(`hoist time: ${Date.now()-o}ms, rounds: ${c}`),s.debugLevel>=1){let u=jm(a);if(ML(a,[a],new Set([a.locator]),new Map,s).isGraphChanged)throw new Error(`The hoisting result is not terminal, prev tree:
+${u}, next tree:
+${jm(a)}`);let f=hle(a);if(f)throw new Error(`${f}, after hoisting finished:
+${jm(a)}`)}return s.debugLevel>=2&&console.log(jm(a)),O4e(a)},M4e=t=>{let e=t[t.length-1],r=new Map,i=new Set,n=s=>{if(!i.has(s)){i.add(s);for(let o of s.hoistedDependencies.values())r.set(o.name,o);for(let o of s.dependencies.values())s.peerNames.has(o.name)||n(o)}};return n(e),r},U4e=t=>{let e=t[t.length-1],r=new Map,i=new Set,n=new Set,s=(o,a)=>{if(i.has(o))return;i.add(o);for(let c of o.hoistedDependencies.values())if(!a.has(c.name)){let u;for(let g of t)u=g.dependencies.get(c.name),u&&r.set(u.name,u)}let l=new Set;for(let c of o.dependencies.values())l.add(c.name);for(let c of o.dependencies.values())o.peerNames.has(c.name)||s(c,l)};return s(e,n),r},dle=(t,e)=>{if(e.decoupled)return e;let{name:r,references:i,ident:n,locator:s,dependencies:o,originalDependencies:a,hoistedDependencies:l,peerNames:c,reasons:u,isHoistBorder:g,hoistPriority:f,dependencyKind:h,hoistedFrom:p,hoistedTo:m}=e,y={name:r,references:new Set(i),ident:n,locator:s,dependencies:new Map(o),originalDependencies:new Map(a),hoistedDependencies:new Map(l),peerNames:new Set(c),reasons:new Map(u),decoupled:!0,isHoistBorder:g,hoistPriority:f,dependencyKind:h,hoistedFrom:new Map(p),hoistedTo:new Map(m)},Q=y.dependencies.get(r);return Q&&Q.ident==y.ident&&y.dependencies.set(r,y),t.dependencies.set(y.name,y),y},K4e=(t,e)=>{let r=new Map([[t.name,[t.ident]]]);for(let n of t.dependencies.values())t.peerNames.has(n.name)||r.set(n.name,[n.ident]);let i=Array.from(e.keys());i.sort((n,s)=>{let o=e.get(n),a=e.get(s);return a.hoistPriority!==o.hoistPriority?a.hoistPriority-o.hoistPriority:a.peerDependents.size!==o.peerDependents.size?a.peerDependents.size-o.peerDependents.size:a.dependents.size-o.dependents.size});for(let n of i){let s=n.substring(0,n.indexOf("@",1)),o=n.substring(s.length+1);if(!t.peerNames.has(s)){let a=r.get(s);a||(a=[],r.set(s,a)),a.indexOf(o)<0&&a.push(o)}}return r},UL=t=>{let e=new Set,r=(i,n=new Set)=>{if(!n.has(i)){n.add(i);for(let s of i.peerNames)if(!t.peerNames.has(s)){let o=t.dependencies.get(s);o&&!e.has(o)&&r(o,n)}e.add(i)}};for(let i of t.dependencies.values())t.peerNames.has(i.name)||r(i);return e},ML=(t,e,r,i,n,s=new Set)=>{let o=e[e.length-1];if(s.has(o))return{anotherRoundNeeded:!1,isGraphChanged:!1};s.add(o);let a=j4e(o),l=K4e(o,a),c=t==o?new Map:n.fastLookupPossible?M4e(e):U4e(e),u,g=!1,f=!1,h=new Map(Array.from(l.entries()).map(([m,y])=>[m,y[0]])),p=new Map;do{let m=H4e(t,e,r,c,h,l,i,p,n);m.isGraphChanged&&(f=!0),m.anotherRoundNeeded&&(g=!0),u=!1;for(let[y,Q]of l)Q.length>1&&!o.dependencies.has(y)&&(h.delete(y),Q.shift(),h.set(y,Q[0]),u=!0)}while(u);for(let m of o.dependencies.values())if(!o.peerNames.has(m.name)&&!r.has(m.locator)){r.add(m.locator);let y=ML(t,[...e,m],r,p,n);y.isGraphChanged&&(f=!0),y.anotherRoundNeeded&&(g=!0),r.delete(m.locator)}return{anotherRoundNeeded:g,isGraphChanged:f}},G4e=(t,e,r,i,n,s,o,a,{outputReason:l,fastLookupPossible:c})=>{let u,g=null,f=new Set;l&&(u=`${Array.from(e).map(y=>Ni(y)).join("\u2192")}`);let h=r[r.length-1],m=!(i.ident===h.ident);if(l&&!m&&(g="- self-reference"),m&&(m=i.dependencyKind!==1,l&&!m&&(g="- workspace")),m&&(m=i.dependencyKind!==2||i.dependencies.size===0,l&&!m&&(g="- external soft link with unhoisted dependencies")),m&&(m=h.dependencyKind!==1||h.hoistedFrom.has(i.name)||e.size===1,l&&!m&&(g=h.reasons.get(i.name))),m&&(m=!t.peerNames.has(i.name),l&&!m&&(g=`- cannot shadow peer: ${Ni(t.originalDependencies.get(i.name).locator)} at ${u}`)),m){let y=!1,Q=n.get(i.name);if(y=!Q||Q.ident===i.ident,l&&!y&&(g=`- filled by: ${Ni(Q.locator)} at ${u}`),y)for(let S=r.length-1;S>=1;S--){let M=r[S].dependencies.get(i.name);if(M&&M.ident!==i.ident){y=!1;let Y=a.get(h);Y||(Y=new Set,a.set(h,Y)),Y.add(i.name),l&&(g=`- filled by ${Ni(M.locator)} at ${r.slice(0,S).map(U=>Ni(U.locator)).join("\u2192")}`);break}}m=y}if(m&&(m=s.get(i.name)===i.ident,l&&!m&&(g=`- filled by: ${Ni(o.get(i.name)[0])} at ${u}`)),m){let y=!0,Q=new Set(i.peerNames);for(let S=r.length-1;S>=1;S--){let x=r[S];for(let M of Q){if(x.peerNames.has(M)&&x.originalDependencies.has(M))continue;let Y=x.dependencies.get(M);Y&&t.dependencies.get(M)!==Y&&(S===r.length-1?f.add(Y):(f=null,y=!1,l&&(g=`- peer dependency ${Ni(Y.locator)} from parent ${Ni(x.locator)} was not hoisted to ${u}`))),Q.delete(M)}if(!y)break}m=y}if(m&&!c)for(let y of i.hoistedDependencies.values()){let Q=n.get(y.name);if(!Q||y.ident!==Q.ident){m=!1,l&&(g=`- previously hoisted dependency mismatch, needed: ${Ni(y.locator)}, available: ${Ni(Q==null?void 0:Q.locator)}`);break}}return f!==null&&f.size>0?{isHoistable:2,dependsOn:f,reason:g}:{isHoistable:m?0:1,reason:g}},H4e=(t,e,r,i,n,s,o,a,l)=>{let c=e[e.length-1],u=new Set,g=!1,f=!1,h=(y,Q,S,x)=>{if(u.has(S))return;let M=[...Q,S.locator],Y=new Map,U=new Map;for(let Z of UL(S)){let A=G4e(c,r,[c,...y,S],Z,i,n,s,a,{outputReason:l.debugLevel>=2,fastLookupPossible:l.fastLookupPossible});if(U.set(Z,A),A.isHoistable===2)for(let ne of A.dependsOn){let le=Y.get(ne.name)||new Set;le.add(Z.name),Y.set(ne.name,le)}}let J=new Set,W=(Z,A,ne)=>{if(!J.has(Z)){J.add(Z),U.set(Z,{isHoistable:1,reason:ne});for(let le of Y.get(Z.name)||[])W(S.dependencies.get(le),A,l.debugLevel>=2?`- peer dependency ${Ni(Z.locator)} from parent ${Ni(S.locator)} was not hoisted`:"")}};for(let[Z,A]of U)A.isHoistable===1&&W(Z,A,A.reason);for(let Z of U.keys())if(!J.has(Z)){f=!0;let A=o.get(S);A&&A.has(Z.name)&&(g=!0),S.dependencies.delete(Z.name),S.hoistedDependencies.set(Z.name,Z),S.reasons.delete(Z.name);let ne=c.dependencies.get(Z.name);if(l.debugLevel>=2){let le=Array.from(Q).concat([S.locator]).map(T=>Ni(T)).join("\u2192"),Ae=c.hoistedFrom.get(Z.name);Ae||(Ae=[],c.hoistedFrom.set(Z.name,Ae)),Ae.push(le),S.hoistedTo.set(Z.name,Array.from(e).map(T=>Ni(T.locator)).join("\u2192"))}if(!ne)c.ident!==Z.ident&&(c.dependencies.set(Z.name,Z),x.add(Z));else for(let le of Z.references)ne.references.add(le)}if(l.check){let Z=hle(t);if(Z)throw new Error(`${Z}, after hoisting dependencies of ${[c,...y,S].map(A=>Ni(A.locator)).join("\u2192")}:
+${jm(t)}`)}let ee=UL(S);for(let Z of ee)if(J.has(Z)){let A=U.get(Z);if((n.get(Z.name)===Z.ident||!S.reasons.has(Z.name))&&A.isHoistable!==0&&S.reasons.set(Z.name,A.reason),!Z.isHoistBorder&&M.indexOf(Z.locator)<0){u.add(S);let le=dle(S,Z);h([...y,S],[...Q,S.locator],le,m),u.delete(S)}}},p,m=new Set(UL(c));do{p=m,m=new Set;for(let y of p){if(y.locator===c.locator||y.isHoistBorder)continue;let Q=dle(c,y);h([],Array.from(r),Q,m)}}while(m.size>0);return{anotherRoundNeeded:g,isGraphChanged:f}},hle=t=>{let e=[],r=new Set,i=new Set,n=(s,o,a)=>{if(r.has(s)||(r.add(s),i.has(s)))return;let l=new Map(o);for(let c of s.dependencies.values())s.peerNames.has(c.name)||l.set(c.name,c);for(let c of s.originalDependencies.values()){let u=l.get(c.name),g=()=>`${Array.from(i).concat([s]).map(f=>Ni(f.locator)).join("\u2192")}`;if(s.peerNames.has(c.name)){let f=o.get(c.name);(f!==u||!f||f.ident!==c.ident)&&e.push(`${g()} - broken peer promise: expected ${c.ident} but found ${f&&f.ident}`)}else{let f=a.hoistedFrom.get(s.name),h=s.hoistedTo.get(c.name),p=`${f?` hoisted from ${f.join(", ")}`:""}`,m=`${h?` hoisted to ${h}`:""}`,y=`${g()}${p}`;u?u.ident!==c.ident&&e.push(`${y} - broken require promise for ${c.name}${m}: expected ${c.ident}, but found: ${u.ident}`):e.push(`${y} - broken require promise: no required dependency ${c.name}${m} found`)}}i.add(s);for(let c of s.dependencies.values())s.peerNames.has(c.name)||n(c,l,s);i.delete(s)};return n(t,t.dependencies,t),e.join(`
+`)},T4e=(t,e)=>{let{identName:r,name:i,reference:n,peerNames:s}=t,o={name:i,references:new Set([n]),locator:OL(r,n),ident:fle(r,n),dependencies:new Map,originalDependencies:new Map,hoistedDependencies:new Map,peerNames:new Set(s),reasons:new Map,decoupled:!0,isHoistBorder:!0,hoistPriority:0,dependencyKind:1,hoistedFrom:new Map,hoistedTo:new Map},a=new Map([[t,o]]),l=(c,u)=>{let g=a.get(c),f=!!g;if(!g){let{name:h,identName:p,reference:m,peerNames:y,hoistPriority:Q,dependencyKind:S}=c,x=e.hoistingLimits.get(u.locator);g={name:h,references:new Set([m]),locator:OL(p,m),ident:fle(p,m),dependencies:new Map,originalDependencies:new Map,hoistedDependencies:new Map,peerNames:new Set(y),reasons:new Map,decoupled:!0,isHoistBorder:x?x.has(h):!1,hoistPriority:Q||0,dependencyKind:S||0,hoistedFrom:new Map,hoistedTo:new Map},a.set(c,g)}if(u.dependencies.set(c.name,g),u.originalDependencies.set(c.name,g),f){let h=new Set,p=m=>{if(!h.has(m)){h.add(m),m.decoupled=!1;for(let y of m.dependencies.values())m.peerNames.has(y.name)||p(y)}};p(g)}else for(let h of c.dependencies)l(h,g)};for(let c of t.dependencies)l(c,o);return o},KL=t=>t.substring(0,t.indexOf("@",1)),O4e=t=>{let e={name:t.name,identName:KL(t.locator),references:new Set(t.references),dependencies:new Set},r=new Set([t]),i=(n,s,o)=>{let a=r.has(n),l;if(s===n)l=o;else{let{name:c,references:u,locator:g}=n;l={name:c,identName:KL(g),references:u,dependencies:new Set}}if(o.dependencies.add(l),!a){r.add(n);for(let c of n.dependencies.values())n.peerNames.has(c.name)||i(c,n,l);r.delete(n)}};for(let n of t.dependencies.values())i(n,t,e);return e},j4e=t=>{let e=new Map,r=new Set([t]),i=o=>`${o.name}@${o.ident}`,n=o=>{let a=i(o),l=e.get(a);return l||(l={dependents:new Set,peerDependents:new Set,hoistPriority:0},e.set(a,l)),l},s=(o,a)=>{let l=!!r.has(a);if(n(a).dependents.add(o.ident),!l){r.add(a);for(let u of a.dependencies.values()){let g=n(u);g.hoistPriority=Math.max(g.hoistPriority,u.hoistPriority),a.peerNames.has(u.name)?g.peerDependents.add(a.ident):s(a,u)}}};for(let o of t.dependencies.values())t.peerNames.has(o.name)||s(t,o);return e},Ni=t=>{if(!t)return"none";let e=t.indexOf("@",1),r=t.substring(0,e);r.endsWith("$wsroot$")&&(r=`wh:${r.replace("$wsroot$","")}`);let i=t.substring(e+1);if(i==="workspace:.")return".";if(i){let n=(i.indexOf("#")>0?i.split("#")[1]:i).replace("npm:","");return i.startsWith("virtual")&&(r=`v:${r}`),n.startsWith("workspace")&&(r=`w:${r}`,n=""),`${r}${n?`@${n}`:""}`}else return`${r}`},Cle=5e4,jm=t=>{let e=0,r=(n,s,o="")=>{if(e>Cle||s.has(n))return"";e++;let a=Array.from(n.dependencies.values()).sort((c,u)=>c.name===u.name?0:c.name>u.name?1:-1),l="";s.add(n);for(let c=0;c<a.length;c++){let u=a[c];if(!n.peerNames.has(u.name)&&u!==n){let g=n.reasons.get(u.name),f=KL(u.locator),h=n.hoistedFrom.get(u.name)||[];l+=`${o}${c<a.length-1?"\u251C\u2500":"\u2514\u2500"}${(s.has(u)?">":"")+(f!==u.name?`a:${u.name}:`:"")+Ni(u.locator)+(g?` ${g}`:"")+(u!==n&&h.length>0?`, hoisted from: ${h.join(", ")}`:"")}
+`,l+=r(u,s,`${o}${c<a.length-1?"\u2502 ":"  "}`)}}return s.delete(n),l};return r(t,new Set)+(e>Cle?`
+Tree is too large, part of the tree has been dunped
+`:"")};var Io;(function(r){r.HARD="HARD",r.SOFT="SOFT"})(Io||(Io={}));var Mn;(function(i){i.WORKSPACES="workspaces",i.DEPENDENCIES="dependencies",i.NONE="none"})(Mn||(Mn={}));var mle="node_modules",Bu="$wsroot$";var Gm=(t,e)=>{let{packageTree:r,hoistingLimits:i,errors:n,preserveSymlinksRequired:s}=Y4e(t,e),o=null;if(n.length===0){let a=ple(r,{hoistingLimits:i});o=q4e(t,a,e)}return{tree:o,errors:n,preserveSymlinksRequired:s}},fa=t=>`${t.name}@${t.reference}`,HL=t=>{let e=new Map;for(let[r,i]of t.entries())if(!i.dirList){let n=e.get(i.locator);n||(n={target:i.target,linkType:i.linkType,locations:[],aliases:i.aliases},e.set(i.locator,n)),n.locations.push(r)}for(let r of e.values())r.locations=r.locations.sort((i,n)=>{let s=i.split(k.delimiter).length,o=n.split(k.delimiter).length;return n===i?0:s!==o?o-s:n>i?1:-1});return e},Ele=(t,e)=>{let r=P.isVirtualLocator(t)?P.devirtualizeLocator(t):t,i=P.isVirtualLocator(e)?P.devirtualizeLocator(e):e;return P.areLocatorsEqual(r,i)},jL=(t,e,r,i)=>{if(t.linkType!==Io.SOFT)return!1;let n=j.toPortablePath(r.resolveVirtual&&e.reference&&e.reference.startsWith("virtual:")?r.resolveVirtual(t.packageLocation):t.packageLocation);return k.contains(i,n)===null},J4e=t=>{let e=t.getPackageInformation(t.topLevel);if(e===null)throw new Error("Assertion failed: Expected the top-level package to have been registered");if(t.findPackageLocator(e.packageLocation)===null)throw new Error("Assertion failed: Expected the top-level package to have a physical locator");let i=j.toPortablePath(e.packageLocation.slice(0,-1)),n=new Map,s={children:new Map},o=t.getDependencyTreeRoots(),a=new Map,l=new Set,c=(f,h)=>{let p=fa(f);if(l.has(p))return;l.add(p);let m=t.getPackageInformation(f);if(m){let y=h?fa(h):"";if(fa(f)!==y&&m.linkType===Io.SOFT&&!jL(m,f,t,i)){let Q=Ile(m,f,t);(!a.get(Q)||f.reference.startsWith("workspace:"))&&a.set(Q,f)}for(let[Q,S]of m.packageDependencies)S!==null&&(m.packagePeers.has(Q)||c(t.getLocator(Q,S),f))}};for(let f of o)c(f,null);let u=i.split(k.sep);for(let f of a.values()){let h=t.getPackageInformation(f),m=j.toPortablePath(h.packageLocation.slice(0,-1)).split(k.sep).slice(u.length),y=s;for(let Q of m){let S=y.children.get(Q);S||(S={children:new Map},y.children.set(Q,S)),y=S}y.workspaceLocator=f}let g=(f,h)=>{if(f.workspaceLocator){let p=fa(h),m=n.get(p);m||(m=new Set,n.set(p,m)),m.add(f.workspaceLocator)}for(let p of f.children.values())g(p,f.workspaceLocator||h)};for(let f of s.children.values())g(f,s.workspaceLocator);return n},Y4e=(t,e)=>{let r=[],i=!1,n=new Map,s=J4e(t),o=t.getPackageInformation(t.topLevel);if(o===null)throw new Error("Assertion failed: Expected the top-level package to have been registered");let a=t.findPackageLocator(o.packageLocation);if(a===null)throw new Error("Assertion failed: Expected the top-level package to have a physical locator");let l=j.toPortablePath(o.packageLocation.slice(0,-1)),c={name:a.name,identName:a.name,reference:a.reference,peerNames:o.packagePeers,dependencies:new Set,dependencyKind:ls.WORKSPACE},u=new Map,g=(h,p)=>`${fa(p)}:${h}`,f=(h,p,m,y,Q,S,x,M)=>{var Ae,T;let Y=g(h,m),U=u.get(Y),J=!!U;!J&&m.name===a.name&&m.reference===a.reference&&(U=c,u.set(Y,c));let W=jL(p,m,t,l);if(!U){let L=ls.REGULAR;W?L=ls.EXTERNAL_SOFT_LINK:p.linkType===Io.SOFT&&m.name.endsWith(Bu)&&(L=ls.WORKSPACE),U={name:h,identName:m.name,reference:m.reference,dependencies:new Set,peerNames:L===ls.WORKSPACE?new Set:p.packagePeers,dependencyKind:L},u.set(Y,U)}let ee;if(W?ee=2:Q.linkType===Io.SOFT?ee=1:ee=0,U.hoistPriority=Math.max(U.hoistPriority||0,ee),M&&!W){let L=fa({name:y.identName,reference:y.reference}),Ee=n.get(L)||new Set;n.set(L,Ee),Ee.add(U.name)}let Z=new Map(p.packageDependencies);if(e.project){let L=e.project.workspacesByCwd.get(j.toPortablePath(p.packageLocation.slice(0,-1)));if(L){let Ee=new Set([...Array.from(L.manifest.peerDependencies.values(),we=>P.stringifyIdent(we)),...Array.from(L.manifest.peerDependenciesMeta.keys())]);for(let we of Ee)Z.has(we)||(Z.set(we,S.get(we)||null),U.peerNames.add(we))}}let A=fa({name:m.name.replace(Bu,""),reference:m.reference}),ne=s.get(A);if(ne)for(let L of ne)Z.set(`${L.name}${Bu}`,L.reference);(p!==Q||p.linkType!==Io.SOFT||!e.selfReferencesByCwd||e.selfReferencesByCwd.get(x))&&y.dependencies.add(U);let le=m!==a&&p.linkType===Io.SOFT&&!m.name.endsWith(Bu)&&!W;if(!J&&!le){let L=new Map;for(let[Ee,we]of Z)if(we!==null){let qe=t.getLocator(Ee,we),re=t.getLocator(Ee.replace(Bu,""),we),se=t.getPackageInformation(re);if(se===null)throw new Error("Assertion failed: Expected the package to have been registered");let Qe=jL(se,qe,t,l);if(e.validateExternalSoftLinks&&e.project&&Qe){se.packageDependencies.size>0&&(i=!0);for(let[ve,pe]of se.packageDependencies)if(pe!==null){let X=P.parseLocator(Array.isArray(pe)?`${pe[0]}@${pe[1]}`:`${ve}@${pe}`);if(fa(X)!==fa(qe)){let be=Z.get(ve);if(be){let ce=P.parseLocator(Array.isArray(be)?`${be[0]}@${be[1]}`:`${ve}@${be}`);Ele(ce,X)||r.push({messageName:$.NM_CANT_INSTALL_EXTERNAL_SOFT_LINK,text:`Cannot link ${P.prettyIdent(e.project.configuration,P.parseIdent(qe.name))} into ${P.prettyLocator(e.project.configuration,P.parseLocator(`${m.name}@${m.reference}`))} dependency ${P.prettyLocator(e.project.configuration,X)} conflicts with parent dependency ${P.prettyLocator(e.project.configuration,ce)}`})}else{let ce=L.get(ve);if(ce){let fe=ce.target,gt=P.parseLocator(Array.isArray(fe)?`${fe[0]}@${fe[1]}`:`${ve}@${fe}`);Ele(gt,X)||r.push({messageName:$.NM_CANT_INSTALL_EXTERNAL_SOFT_LINK,text:`Cannot link ${P.prettyIdent(e.project.configuration,P.parseIdent(qe.name))} into ${P.prettyLocator(e.project.configuration,P.parseLocator(`${m.name}@${m.reference}`))} dependency ${P.prettyLocator(e.project.configuration,X)} conflicts with dependency ${P.prettyLocator(e.project.configuration,gt)} from sibling portal ${P.prettyIdent(e.project.configuration,P.parseIdent(ce.portal.name))}`})}else L.set(ve,{target:X.reference,portal:qe})}}}}let he=(Ae=e.hoistingLimitsByCwd)==null?void 0:Ae.get(x),Fe=Qe?x:k.relative(l,j.toPortablePath(se.packageLocation))||Me.dot,Ue=(T=e.hoistingLimitsByCwd)==null?void 0:T.get(Fe),xe=he===Mn.DEPENDENCIES||Ue===Mn.DEPENDENCIES||Ue===Mn.WORKSPACES;f(Ee,se,qe,U,p,Z,Fe,xe)}}};return f(a.name,o,a,c,o,o.packageDependencies,Me.dot,!1),{packageTree:c,hoistingLimits:n,errors:r,preserveSymlinksRequired:i}};function Ile(t,e,r){let i=r.resolveVirtual&&e.reference&&e.reference.startsWith("virtual:")?r.resolveVirtual(t.packageLocation):t.packageLocation;return j.toPortablePath(i||t.packageLocation)}function W4e(t,e,r){let i=e.getLocator(t.name.replace(Bu,""),t.reference),n=e.getPackageInformation(i);if(n===null)throw new Error("Assertion failed: Expected the package to be registered");let s,o;return r.pnpifyFs?(o=j.toPortablePath(n.packageLocation),s=Io.SOFT):(o=Ile(n,t,e),s=n.linkType),{linkType:s,target:o}}var q4e=(t,e,r)=>{let i=new Map,n=(u,g,f)=>{let{linkType:h,target:p}=W4e(u,t,r);return{locator:fa(u),nodePath:g,target:p,linkType:h,aliases:f}},s=u=>{let[g,f]=u.split("/");return f?{scope:qr(g),name:qr(f)}:{scope:null,name:qr(g)}},o=new Set,a=(u,g,f)=>{if(!o.has(u)){o.add(u);for(let h of u.dependencies){if(h===u)continue;let p=Array.from(h.references).sort(),m={name:h.identName,reference:p[0]},{name:y,scope:Q}=s(h.name),S=Q?[Q,y]:[y],x=k.join(g,mle),M=k.join(x,...S),Y=`${f}/${m.name}`,U=n(m,f,p.slice(1)),J=!1;if(U.linkType===Io.SOFT&&r.project){let W=r.project.workspacesByCwd.get(U.target.slice(0,-1));J=!!(W&&!W.manifest.name)}if(!h.name.endsWith(Bu)&&!J){let W=i.get(M);if(W){if(W.dirList)throw new Error(`Assertion failed: ${M} cannot merge dir node with leaf node`);{let ne=P.parseLocator(W.locator),le=P.parseLocator(U.locator);if(W.linkType!==U.linkType)throw new Error(`Assertion failed: ${M} cannot merge nodes with different link types ${W.nodePath}/${P.stringifyLocator(ne)} and ${f}/${P.stringifyLocator(le)}`);if(ne.identHash!==le.identHash)throw new Error(`Assertion failed: ${M} cannot merge nodes with different idents ${W.nodePath}/${P.stringifyLocator(ne)} and ${f}/s${P.stringifyLocator(le)}`);U.aliases=[...U.aliases,...W.aliases,P.parseLocator(W.locator).reference]}}i.set(M,U);let ee=M.split("/"),Z=ee.indexOf(mle),A=ee.length-1;for(;Z>=0&&A>Z;){let ne=j.toPortablePath(ee.slice(0,A).join(k.sep)),le=qr(ee[A]),Ae=i.get(ne);if(!Ae)i.set(ne,{dirList:new Set([le])});else if(Ae.dirList){if(Ae.dirList.has(le))break;Ae.dirList.add(le)}A--}}a(h,U.linkType===Io.SOFT?U.target:M,Y)}}},l=n({name:e.name,reference:Array.from(e.references)[0]},"",[]),c=l.target;return i.set(c,l),a(e,c,""),i};var eT={};ft(eT,{PnpInstaller:()=>sh,PnpLinker:()=>Qu,default:()=>m_e,getPnpPath:()=>Pl,jsInstallUtils:()=>wo,pnpUtils:()=>ZL,quotePathIfNeeded:()=>Jle});var Yle=ge(ti()),qle=ge(require("url"));var yle;(function(r){r.HARD="HARD",r.SOFT="SOFT"})(yle||(yle={}));var er;(function(f){f.DEFAULT="DEFAULT",f.TOP_LEVEL="TOP_LEVEL",f.FALLBACK_EXCLUSION_LIST="FALLBACK_EXCLUSION_LIST",f.FALLBACK_EXCLUSION_ENTRIES="FALLBACK_EXCLUSION_ENTRIES",f.FALLBACK_EXCLUSION_DATA="FALLBACK_EXCLUSION_DATA",f.PACKAGE_REGISTRY_DATA="PACKAGE_REGISTRY_DATA",f.PACKAGE_REGISTRY_ENTRIES="PACKAGE_REGISTRY_ENTRIES",f.PACKAGE_STORE_DATA="PACKAGE_STORE_DATA",f.PACKAGE_STORE_ENTRIES="PACKAGE_STORE_ENTRIES",f.PACKAGE_INFORMATION_DATA="PACKAGE_INFORMATION_DATA",f.PACKAGE_DEPENDENCIES="PACKAGE_DEPENDENCIES",f.PACKAGE_DEPENDENCY="PACKAGE_DEPENDENCY"})(er||(er={}));var wle={[er.DEFAULT]:{collapsed:!1,next:{["*"]:er.DEFAULT}},[er.TOP_LEVEL]:{collapsed:!1,next:{fallbackExclusionList:er.FALLBACK_EXCLUSION_LIST,packageRegistryData:er.PACKAGE_REGISTRY_DATA,["*"]:er.DEFAULT}},[er.FALLBACK_EXCLUSION_LIST]:{collapsed:!1,next:{["*"]:er.FALLBACK_EXCLUSION_ENTRIES}},[er.FALLBACK_EXCLUSION_ENTRIES]:{collapsed:!0,next:{["*"]:er.FALLBACK_EXCLUSION_DATA}},[er.FALLBACK_EXCLUSION_DATA]:{collapsed:!0,next:{["*"]:er.DEFAULT}},[er.PACKAGE_REGISTRY_DATA]:{collapsed:!1,next:{["*"]:er.PACKAGE_REGISTRY_ENTRIES}},[er.PACKAGE_REGISTRY_ENTRIES]:{collapsed:!0,next:{["*"]:er.PACKAGE_STORE_DATA}},[er.PACKAGE_STORE_DATA]:{collapsed:!1,next:{["*"]:er.PACKAGE_STORE_ENTRIES}},[er.PACKAGE_STORE_ENTRIES]:{collapsed:!0,next:{["*"]:er.PACKAGE_INFORMATION_DATA}},[er.PACKAGE_INFORMATION_DATA]:{collapsed:!1,next:{packageDependencies:er.PACKAGE_DEPENDENCIES,["*"]:er.DEFAULT}},[er.PACKAGE_DEPENDENCIES]:{collapsed:!1,next:{["*"]:er.PACKAGE_DEPENDENCY}},[er.PACKAGE_DEPENDENCY]:{collapsed:!0,next:{["*"]:er.DEFAULT}}};function z4e(t,e,r){let i="";i+="[";for(let n=0,s=t.length;n<s;++n)i+=cb(String(n),t[n],e,r).replace(/^ +/g,""),n+1<s&&(i+=", ");return i+="]",i}function _4e(t,e,r){let i=`${r}  `,n="";n+=r,n+=`[
+`;for(let s=0,o=t.length;s<o;++s)n+=i+cb(String(s),t[s],e,i).replace(/^ +/,""),s+1<o&&(n+=","),n+=`
+`;return n+=r,n+="]",n}function V4e(t,e,r){let i=Object.keys(t),n="";n+="{";for(let s=0,o=i.length,a=0;s<o;++s){let l=i[s],c=t[l];typeof c!="undefined"&&(a!==0&&(n+=", "),n+=JSON.stringify(l),n+=": ",n+=cb(l,c,e,r).replace(/^ +/g,""),a+=1)}return n+="}",n}function X4e(t,e,r){let i=Object.keys(t),n=`${r}  `,s="";s+=r,s+=`{
+`;let o=0;for(let a=0,l=i.length;a<l;++a){let c=i[a],u=t[c];typeof u!="undefined"&&(o!==0&&(s+=",",s+=`
+`),s+=n,s+=JSON.stringify(c),s+=": ",s+=cb(c,u,e,n).replace(/^ +/g,""),o+=1)}return o!==0&&(s+=`
+`),s+=r,s+="}",s}function cb(t,e,r,i){let{next:n}=wle[r],s=n[t]||n["*"];return Ble(e,s,i)}function Ble(t,e,r){let{collapsed:i}=wle[e];return Array.isArray(t)?i?z4e(t,e,r):_4e(t,e,r):typeof t=="object"&&t!==null?i?V4e(t,e,r):X4e(t,e,r):JSON.stringify(t)}function ble(t){return Ble(t,er.TOP_LEVEL,"")}function Ym(t,e){let r=Array.from(t);Array.isArray(e)||(e=[e]);let i=[];for(let s of e)i.push(r.map(o=>s(o)));let n=r.map((s,o)=>o);return n.sort((s,o)=>{for(let a of i){let l=a[s]<a[o]?-1:a[s]>a[o]?1:0;if(l!==0)return l}return 0}),n.map(s=>r[s])}function Z4e(t){let e=new Map,r=Ym(t.fallbackExclusionList||[],[({name:i,reference:n})=>i,({name:i,reference:n})=>n]);for(let{name:i,reference:n}of r){let s=e.get(i);typeof s=="undefined"&&e.set(i,s=new Set),s.add(n)}return Array.from(e).map(([i,n])=>[i,Array.from(n)])}function $4e(t){return Ym(t.fallbackPool||[],([e])=>e)}function e_e(t){let e=[];for(let[r,i]of Ym(t.packageRegistry,([n])=>n===null?"0":`1${n}`)){let n=[];e.push([r,n]);for(let[s,{packageLocation:o,packageDependencies:a,packagePeers:l,linkType:c,discardFromLookup:u}]of Ym(i,([g])=>g===null?"0":`1${g}`)){let g=[];r!==null&&s!==null&&!a.has(r)&&g.push([r,s]);for(let[p,m]of Ym(a.entries(),([y])=>y))g.push([p,m]);let f=l&&l.size>0?Array.from(l):void 0,h=u||void 0;n.push([s,{packageLocation:o,packageDependencies:g,packagePeers:f,linkType:c,discardFromLookup:h}])}}return e}function qm(t){return{__info:["This file is automatically generated. Do not touch it, or risk","your modifications being lost. We also recommend you not to read","it either without using the @yarnpkg/pnp package, as the data layout","is entirely unspecified and WILL change from a version to another."],dependencyTreeRoots:t.dependencyTreeRoots,enableTopLevelFallback:t.enableTopLevelFallback||!1,ignorePatternData:t.ignorePattern||null,fallbackExclusionList:Z4e(t),fallbackPool:$4e(t),packageRegistryData:e_e(t)}}var Sle=ge(vle());function kle(t,e){return[t?`${t}
+`:"",`/* eslint-disable */
+
+`,`try {
+`,`  Object.freeze({}).detectStrictMode = true;
+`,`} catch (error) {
+`,"  throw new Error(`The whole PnP file got strict-mode-ified, which is known to break (Emscripten libraries aren't strict mode). This usually happens when the file goes through Babel.`);\n",`}
+`,`
+`,`function $$SETUP_STATE(hydrateRuntimeState, basePath) {
+`,e.replace(/^/gm,"  "),`}
+`,`
+`,(0,Sle.default)()].join("")}function t_e(t){return JSON.stringify(t,null,2)}function r_e(t){return`'${t.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/\n/g,`\\
+`)}'`}function i_e(t){return[`return hydrateRuntimeState(JSON.parse(${r_e(ble(t))}), {basePath: basePath || __dirname});
+`].join("")}function n_e(t){return[`var path = require('path');
+`,`var dataLocation = path.resolve(__dirname, ${JSON.stringify(t)});
+`,`return hydrateRuntimeState(require(dataLocation), {basePath: basePath || path.dirname(dataLocation)});
+`].join("")}function xle(t){let e=qm(t),r=i_e(e);return kle(t.shebang,r)}function Ple(t){let e=qm(t),r=n_e(t.dataLocation),i=kle(t.shebang,r);return{dataFile:t_e(e),loaderFile:i}}var Lle=ge(require("fs")),u_e=ge(require("path")),Tle=ge(require("util"));function YL(t,{basePath:e}){let r=j.toPortablePath(e),i=k.resolve(r),n=t.ignorePatternData!==null?new RegExp(t.ignorePatternData):null,s=new Map,o=new Map(t.packageRegistryData.map(([g,f])=>[g,new Map(f.map(([h,p])=>{var x;if(g===null!=(h===null))throw new Error("Assertion failed: The name and reference should be null, or neither should");let m=(x=p.discardFromLookup)!=null?x:!1,y={name:g,reference:h},Q=s.get(p.packageLocation);Q?(Q.discardFromLookup=Q.discardFromLookup&&m,m||(Q.locator=y)):s.set(p.packageLocation,{locator:y,discardFromLookup:m});let S=null;return[h,{packageDependencies:new Map(p.packageDependencies),packagePeers:new Set(p.packagePeers),linkType:p.linkType,discardFromLookup:m,get packageLocation(){return S||(S=k.join(i,p.packageLocation))}}]}))])),a=new Map(t.fallbackExclusionList.map(([g,f])=>[g,new Set(f)])),l=new Map(t.fallbackPool),c=t.dependencyTreeRoots,u=t.enableTopLevelFallback;return{basePath:r,dependencyTreeRoots:c,enableTopLevelFallback:u,fallbackExclusionList:a,fallbackPool:l,ignorePattern:n,packageLocatorsByLocations:s,packageRegistry:o}}var Jm=ge(require("module")),Nle=ge(Rle()),JL=ge(require("util"));var ur;(function(c){c.API_ERROR="API_ERROR",c.BUILTIN_NODE_RESOLUTION_FAILED="BUILTIN_NODE_RESOLUTION_FAILED",c.EXPORTS_RESOLUTION_FAILED="EXPORTS_RESOLUTION_FAILED",c.MISSING_DEPENDENCY="MISSING_DEPENDENCY",c.MISSING_PEER_DEPENDENCY="MISSING_PEER_DEPENDENCY",c.QUALIFIED_PATH_RESOLUTION_FAILED="QUALIFIED_PATH_RESOLUTION_FAILED",c.INTERNAL="INTERNAL",c.UNDECLARED_DEPENDENCY="UNDECLARED_DEPENDENCY",c.UNSUPPORTED="UNSUPPORTED"})(ur||(ur={}));var a_e=new Set([ur.BUILTIN_NODE_RESOLUTION_FAILED,ur.MISSING_DEPENDENCY,ur.MISSING_PEER_DEPENDENCY,ur.QUALIFIED_PATH_RESOLUTION_FAILED,ur.UNDECLARED_DEPENDENCY]);function oi(t,e,r={},i){i!=null||(i=a_e.has(t)?"MODULE_NOT_FOUND":t);let n={configurable:!0,writable:!0,enumerable:!1};return Object.defineProperties(new Error(e),{code:te(N({},n),{value:i}),pnpCode:te(N({},n),{value:t}),data:te(N({},n),{value:r})})}function yo(t){return j.normalize(j.fromPortablePath(t))}var A_e=ge(require("fs")),Fle=ge(require("module")),l_e=ge(require("path")),c_e=new Set(Fle.Module.builtinModules||Object.keys(process.binding("natives"))),ub=t=>t.startsWith("node:")||c_e.has(t);function WL(t,e){let r=Number(process.env.PNP_ALWAYS_WARN_ON_FALLBACK)>0,i=Number(process.env.PNP_DEBUG_LEVEL),n=/^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:node:)?(?:@[^/]+\/)?[^/]+)\/*(.*|)$/,s=/^(\/|\.{1,2}(\/|$))/,o=/\/$/,a=/^\.{0,2}\//,l={name:null,reference:null},c=[],u=new Set;if(t.enableTopLevelFallback===!0&&c.push(l),e.compatibilityMode!==!1)for(let re of["react-scripts","gatsby"]){let se=t.packageRegistry.get(re);if(se)for(let Qe of se.keys()){if(Qe===null)throw new Error("Assertion failed: This reference shouldn't be null");c.push({name:re,reference:Qe})}}let{ignorePattern:g,packageRegistry:f,packageLocatorsByLocations:h}=t;function p(re,se){return{fn:re,args:se,error:null,result:null}}function m(re){var Ue,xe,ve,pe,X,be;let se=(ve=(xe=(Ue=process.stderr)==null?void 0:Ue.hasColors)==null?void 0:xe.call(Ue))!=null?ve:process.stdout.isTTY,Qe=(ce,fe)=>`\e[${ce}m${fe}\e[0m`,he=re.error;console.error(he?Qe("31;1",`\u2716 ${(pe=re.error)==null?void 0:pe.message.replace(/\n.*/s,"")}`):Qe("33;1","\u203C Resolution")),re.args.length>0&&console.error();for(let ce of re.args)console.error(`  ${Qe("37;1","In \u2190")} ${(0,JL.inspect)(ce,{colors:se,compact:!0})}`);re.result&&(console.error(),console.error(`  ${Qe("37;1","Out \u2192")} ${(0,JL.inspect)(re.result,{colors:se,compact:!0})}`));let Fe=(be=(X=new Error().stack.match(/(?<=^ +)at.*/gm))==null?void 0:X.slice(2))!=null?be:[];if(Fe.length>0){console.error();for(let ce of Fe)console.error(`  ${Qe("38;5;244",ce)}`)}console.error()}function y(re,se){if(e.allowDebug===!1)return se;if(Number.isFinite(i)){if(i>=2)return(...Qe)=>{let he=p(re,Qe);try{return he.result=se(...Qe)}catch(Fe){throw he.error=Fe}finally{m(he)}};if(i>=1)return(...Qe)=>{try{return se(...Qe)}catch(he){let Fe=p(re,Qe);throw Fe.error=he,m(Fe),he}}}return se}function Q(re){let se=A(re);if(!se)throw oi(ur.INTERNAL,"Couldn't find a matching entry in the dependency tree for the specified parent (this is probably an internal error)");return se}function S(re){if(re.name===null)return!0;for(let se of t.dependencyTreeRoots)if(se.name===re.name&&se.reference===re.reference)return!0;return!1}let x=new Set(["default","node","require"]);function M(re,se=x){let Qe=Ae(k.join(re,"internal.js"),{resolveIgnored:!0,includeDiscardFromLookup:!0});if(Qe===null)throw oi(ur.INTERNAL,`The locator that owns the "${re}" path can't be found inside the dependency tree (this is probably an internal error)`);let{packageLocation:he}=Q(Qe),Fe=k.join(he,Pt.manifest);if(!e.fakeFs.existsSync(Fe))return null;let Ue=JSON.parse(e.fakeFs.readFileSync(Fe,"utf8")),xe=k.contains(he,re);if(xe===null)throw oi(ur.INTERNAL,"unqualifiedPath doesn't contain the packageLocation (this is probably an internal error)");a.test(xe)||(xe=`./${xe}`);let ve;try{ve=(0,Nle.resolve)(Ue,k.normalize(xe),{conditions:se,unsafe:!0})}catch(pe){throw oi(ur.EXPORTS_RESOLUTION_FAILED,pe.message,{unqualifiedPath:yo(re),locator:Qe,pkgJson:Ue,subpath:yo(xe),conditions:se},"ERR_PACKAGE_PATH_NOT_EXPORTED")}return typeof ve=="string"?k.join(he,ve):null}function Y(re,se,{extensions:Qe}){let he;try{se.push(re),he=e.fakeFs.statSync(re)}catch(Fe){}if(he&&!he.isDirectory())return e.fakeFs.realpathSync(re);if(he&&he.isDirectory()){let Fe;try{Fe=JSON.parse(e.fakeFs.readFileSync(k.join(re,Pt.manifest),"utf8"))}catch(xe){}let Ue;if(Fe&&Fe.main&&(Ue=k.resolve(re,Fe.main)),Ue&&Ue!==re){let xe=Y(Ue,se,{extensions:Qe});if(xe!==null)return xe}}for(let Fe=0,Ue=Qe.length;Fe<Ue;Fe++){let xe=`${re}${Qe[Fe]}`;if(se.push(xe),e.fakeFs.existsSync(xe))return xe}if(he&&he.isDirectory())for(let Fe=0,Ue=Qe.length;Fe<Ue;Fe++){let xe=k.format({dir:re,name:"index",ext:Qe[Fe]});if(se.push(xe),e.fakeFs.existsSync(xe))return xe}return null}function U(re){let se=new Jm.Module(re,null);return se.filename=re,se.paths=Jm.Module._nodeModulePaths(re),se}function J(re,se){return se.endsWith("/")&&(se=k.join(se,"internal.js")),Jm.Module._resolveFilename(j.fromPortablePath(re),U(j.fromPortablePath(se)),!1,{plugnplay:!1})}function W(re){if(g===null)return!1;let se=k.contains(t.basePath,re);return se===null?!1:!!g.test(se.replace(/\/$/,""))}let ee={std:3,resolveVirtual:1,getAllLocators:1},Z=l;function A({name:re,reference:se}){let Qe=f.get(re);if(!Qe)return null;let he=Qe.get(se);return he||null}function ne({name:re,reference:se}){let Qe=[];for(let[he,Fe]of f)if(he!==null)for(let[Ue,xe]of Fe)Ue===null||xe.packageDependencies.get(re)!==se||he===re&&Ue===se||Qe.push({name:he,reference:Ue});return Qe}function le(re,se){let Qe=new Map,he=new Set,Fe=xe=>{let ve=JSON.stringify(xe.name);if(he.has(ve))return;he.add(ve);let pe=ne(xe);for(let X of pe)if(Q(X).packagePeers.has(re))Fe(X);else{let ce=Qe.get(X.name);typeof ce=="undefined"&&Qe.set(X.name,ce=new Set),ce.add(X.reference)}};Fe(se);let Ue=[];for(let xe of[...Qe.keys()].sort())for(let ve of[...Qe.get(xe)].sort())Ue.push({name:xe,reference:ve});return Ue}function Ae(re,{resolveIgnored:se=!1,includeDiscardFromLookup:Qe=!1}={}){if(W(re)&&!se)return null;let he=k.relative(t.basePath,re);he.match(s)||(he=`./${he}`),he.endsWith("/")||(he=`${he}/`);do{let Fe=h.get(he);if(typeof Fe=="undefined"||Fe.discardFromLookup&&!Qe){he=he.substring(0,he.lastIndexOf("/",he.length-2)+1);continue}return Fe.locator}while(he!=="");return null}function T(re,se,{considerBuiltins:Qe=!0}={}){if(re==="pnpapi")return j.toPortablePath(e.pnpapiResolution);if(Qe&&ub(re))return null;let he=yo(re),Fe=se&&yo(se);if(se&&W(se)&&(!k.isAbsolute(re)||Ae(re)===null)){let ve=J(re,se);if(ve===!1)throw oi(ur.BUILTIN_NODE_RESOLUTION_FAILED,`The builtin node resolution algorithm was unable to resolve the requested module (it didn't go through the pnp resolver because the issuer was explicitely ignored by the regexp)
+
+Require request: "${he}"
+Required by: ${Fe}
+`,{request:he,issuer:Fe});return j.toPortablePath(ve)}let Ue,xe=re.match(n);if(xe){if(!se)throw oi(ur.API_ERROR,"The resolveToUnqualified function must be called with a valid issuer when the path isn't a builtin nor absolute",{request:he,issuer:Fe});let[,ve,pe]=xe,X=Ae(se);if(!X){let jt=J(re,se);if(jt===!1)throw oi(ur.BUILTIN_NODE_RESOLUTION_FAILED,`The builtin node resolution algorithm was unable to resolve the requested module (it didn't go through the pnp resolver because the issuer doesn't seem to be part of the Yarn-managed dependency tree).
+
+Require path: "${he}"
+Required by: ${Fe}
+`,{request:he,issuer:Fe});return j.toPortablePath(jt)}let ce=Q(X).packageDependencies.get(ve),fe=null;if(ce==null&&X.name!==null){let jt=t.fallbackExclusionList.get(X.name);if(!jt||!jt.has(X.reference)){for(let Ti=0,_s=c.length;Ti<_s;++Ti){let Kn=Q(c[Ti]).packageDependencies.get(ve);if(Kn!=null){r?fe=Kn:ce=Kn;break}}if(t.enableTopLevelFallback&&ce==null&&fe===null){let Ti=t.fallbackPool.get(ve);Ti!=null&&(fe=Ti)}}}let gt=null;if(ce===null)if(S(X))gt=oi(ur.MISSING_PEER_DEPENDENCY,`Your application tried to access ${ve} (a peer dependency); this isn't allowed as there is no ancestor to satisfy the requirement. Use a devDependency if needed.
+
+Required package: ${ve}${ve!==he?` (via "${he}")`:""}
+Required by: ${Fe}
+`,{request:he,issuer:Fe,dependencyName:ve});else{let jt=le(ve,X);jt.every(Qr=>S(Qr))?gt=oi(ur.MISSING_PEER_DEPENDENCY,`${X.name} tried to access ${ve} (a peer dependency) but it isn't provided by your application; this makes the require call ambiguous and unsound.
+
+Required package: ${ve}${ve!==he?` (via "${he}")`:""}
+Required by: ${X.name}@${X.reference} (via ${Fe})
+${jt.map(Qr=>`Ancestor breaking the chain: ${Qr.name}@${Qr.reference}
+`).join("")}
+`,{request:he,issuer:Fe,issuerLocator:Object.assign({},X),dependencyName:ve,brokenAncestors:jt}):gt=oi(ur.MISSING_PEER_DEPENDENCY,`${X.name} tried to access ${ve} (a peer dependency) but it isn't provided by its ancestors; this makes the require call ambiguous and unsound.
+
+Required package: ${ve}${ve!==he?` (via "${he}")`:""}
+Required by: ${X.name}@${X.reference} (via ${Fe})
+
+${jt.map(Qr=>`Ancestor breaking the chain: ${Qr.name}@${Qr.reference}
+`).join("")}
+`,{request:he,issuer:Fe,issuerLocator:Object.assign({},X),dependencyName:ve,brokenAncestors:jt})}else ce===void 0&&(!Qe&&ub(re)?S(X)?gt=oi(ur.UNDECLARED_DEPENDENCY,`Your application tried to access ${ve}. While this module is usually interpreted as a Node builtin, your resolver is running inside a non-Node resolution context where such builtins are ignored. Since ${ve} isn't otherwise declared in your dependencies, this makes the require call ambiguous and unsound.
+
+Required package: ${ve}${ve!==he?` (via "${he}")`:""}
+Required by: ${Fe}
+`,{request:he,issuer:Fe,dependencyName:ve}):gt=oi(ur.UNDECLARED_DEPENDENCY,`${X.name} tried to access ${ve}. While this module is usually interpreted as a Node builtin, your resolver is running inside a non-Node resolution context where such builtins are ignored. Since ${ve} isn't otherwise declared in ${X.name}'s dependencies, this makes the require call ambiguous and unsound.
+
+Required package: ${ve}${ve!==he?` (via "${he}")`:""}
+Required by: ${Fe}
+`,{request:he,issuer:Fe,issuerLocator:Object.assign({},X),dependencyName:ve}):S(X)?gt=oi(ur.UNDECLARED_DEPENDENCY,`Your application tried to access ${ve}, but it isn't declared in your dependencies; this makes the require call ambiguous and unsound.
+
+Required package: ${ve}${ve!==he?` (via "${he}")`:""}
+Required by: ${Fe}
+`,{request:he,issuer:Fe,dependencyName:ve}):gt=oi(ur.UNDECLARED_DEPENDENCY,`${X.name} tried to access ${ve}, but it isn't declared in its dependencies; this makes the require call ambiguous and unsound.
+
+Required package: ${ve}${ve!==he?` (via "${he}")`:""}
+Required by: ${X.name}@${X.reference} (via ${Fe})
+`,{request:he,issuer:Fe,issuerLocator:Object.assign({},X),dependencyName:ve}));if(ce==null){if(fe===null||gt===null)throw gt||new Error("Assertion failed: Expected an error to have been set");ce=fe;let jt=gt.message.replace(/\n.*/g,"");gt.message=jt,!u.has(jt)&&i!==0&&(u.add(jt),process.emitWarning(gt))}let Ht=Array.isArray(ce)?{name:ce[0],reference:ce[1]}:{name:ve,reference:ce},Mt=Q(Ht);if(!Mt.packageLocation)throw oi(ur.MISSING_DEPENDENCY,`A dependency seems valid but didn't get installed for some reason. This might be caused by a partial install, such as dev vs prod.
+
+Required package: ${Ht.name}@${Ht.reference}${Ht.name!==he?` (via "${he}")`:""}
+Required by: ${X.name}@${X.reference} (via ${Fe})
+`,{request:he,issuer:Fe,dependencyLocator:Object.assign({},Ht)});let mi=Mt.packageLocation;pe?Ue=k.join(mi,pe):Ue=mi}else if(k.isAbsolute(re))Ue=k.normalize(re);else{if(!se)throw oi(ur.API_ERROR,"The resolveToUnqualified function must be called with a valid issuer when the path isn't a builtin nor absolute",{request:he,issuer:Fe});let ve=k.resolve(se);se.match(o)?Ue=k.normalize(k.join(ve,re)):Ue=k.normalize(k.join(k.dirname(ve),re))}return k.normalize(Ue)}function L(re,se,Qe=x){if(s.test(re))return se;let he=M(se,Qe);return he?k.normalize(he):se}function Ee(re,{extensions:se=Object.keys(Jm.Module._extensions)}={}){var Fe,Ue;let Qe=[],he=Y(re,Qe,{extensions:se});if(he)return k.normalize(he);{let xe=yo(re),ve=Ae(re);if(ve){let{packageLocation:pe}=Q(ve),X=!0;try{e.fakeFs.accessSync(pe)}catch(be){if((be==null?void 0:be.code)==="ENOENT")X=!1;else{let ce=((Ue=(Fe=be==null?void 0:be.message)!=null?Fe:be)!=null?Ue:"empty exception thrown").replace(/^[A-Z]/,fe=>fe.toLowerCase());throw oi(ur.QUALIFIED_PATH_RESOLUTION_FAILED,`Required package exists but could not be accessed (${ce}).
+
+Missing package: ${ve.name}@${ve.reference}
+Expected package location: ${yo(pe)}
+`,{unqualifiedPath:xe,extensions:se})}}if(!X){let be=pe.includes("/unplugged/")?"Required unplugged package missing from disk. This may happen when switching branches without running installs (unplugged packages must be fully materialized on disk to work).":"Required package missing from disk. If you keep your packages inside your repository then restarting the Node process may be enough. Otherwise, try to run an install first.";throw oi(ur.QUALIFIED_PATH_RESOLUTION_FAILED,`${be}
+
+Missing package: ${ve.name}@${ve.reference}
+Expected package location: ${yo(pe)}
+`,{unqualifiedPath:xe,extensions:se})}}throw oi(ur.QUALIFIED_PATH_RESOLUTION_FAILED,`Qualified path resolution failed: we looked for the following paths, but none could be accessed.
+
+Source path: ${xe}
+${Qe.map(pe=>`Not found: ${yo(pe)}
+`).join("")}`,{unqualifiedPath:xe,extensions:se})}}function we(re,se,{considerBuiltins:Qe,extensions:he,conditions:Fe}={}){try{let Ue=T(re,se,{considerBuiltins:Qe});if(re==="pnpapi")return Ue;if(Ue===null)return null;let xe=()=>se!==null?W(se):!1,ve=(!Qe||!ub(re))&&!xe()?L(re,Ue,Fe):Ue;return Ee(ve,{extensions:he})}catch(Ue){throw Object.prototype.hasOwnProperty.call(Ue,"pnpCode")&&Object.assign(Ue.data,{request:yo(re),issuer:se&&yo(se)}),Ue}}function qe(re){let se=k.normalize(re),Qe=Jr.resolveVirtual(se);return Qe!==se?Qe:null}return{VERSIONS:ee,topLevel:Z,getLocator:(re,se)=>Array.isArray(se)?{name:se[0],reference:se[1]}:{name:re,reference:se},getDependencyTreeRoots:()=>[...t.dependencyTreeRoots],getAllLocators(){let re=[];for(let[se,Qe]of f)for(let he of Qe.keys())se!==null&&he!==null&&re.push({name:se,reference:he});return re},getPackageInformation:re=>{let se=A(re);if(se===null)return null;let Qe=j.fromPortablePath(se.packageLocation);return te(N({},se),{packageLocation:Qe})},findPackageLocator:re=>Ae(j.toPortablePath(re)),resolveToUnqualified:y("resolveToUnqualified",(re,se,Qe)=>{let he=se!==null?j.toPortablePath(se):null,Fe=T(j.toPortablePath(re),he,Qe);return Fe===null?null:j.fromPortablePath(Fe)}),resolveUnqualified:y("resolveUnqualified",(re,se)=>j.fromPortablePath(Ee(j.toPortablePath(re),se))),resolveRequest:y("resolveRequest",(re,se,Qe)=>{let he=se!==null?j.toPortablePath(se):null,Fe=we(j.toPortablePath(re),he,Qe);return Fe===null?null:j.fromPortablePath(Fe)}),resolveVirtual:y("resolveVirtual",re=>{let se=qe(j.toPortablePath(re));return se!==null?j.fromPortablePath(se):null})}}var YQt=(0,Tle.promisify)(Lle.readFile);var Ole=(t,e,r)=>{let i=qm(t),n=YL(i,{basePath:e}),s=j.join(e,Pt.pnpCjs);return WL(n,{fakeFs:r,pnpapiResolution:s})};var _L=ge(Ule());var wo={};ft(wo,{checkAndReportManifestCompatibility:()=>Hle,checkManifestCompatibility:()=>Kle,extractBuildScripts:()=>gb,getExtractHint:()=>VL,hasBindingGyp:()=>XL});function Kle(t){return P.isPackageCompatible(t,qg.getArchitectureSet())}function Hle(t,e,{configuration:r,report:i}){return Kle(t)?!0:(i==null||i.reportWarningOnce($.INCOMPATIBLE_ARCHITECTURE,`${P.prettyLocator(r,t)} The ${qg.getArchitectureName()} architecture is incompatible with this package, ${e} skipped.`),!1)}function gb(t,e,r,{configuration:i,report:n}){let s=[];for(let a of["preinstall","install","postinstall"])e.manifest.scripts.has(a)&&s.push([As.SCRIPT,a]);return!e.manifest.scripts.has("install")&&e.misc.hasBindingGyp&&s.push([As.SHELLCODE,"node-gyp rebuild"]),s.length===0?[]:t.linkType!==Qt.HARD?(n==null||n.reportWarningOnce($.SOFT_LINK_BUILD,`${P.prettyLocator(i,t)} lists build scripts, but is referenced through a soft link. Soft links don't support build scripts, so they'll be ignored.`),[]):r&&r.built===!1?(n==null||n.reportInfoOnce($.BUILD_DISABLED,`${P.prettyLocator(i,t)} lists build scripts, but its build has been explicitly disabled through configuration.`),[]):!i.get("enableScripts")&&!r.built?(n==null||n.reportWarningOnce($.DISABLED_BUILD_SCRIPTS,`${P.prettyLocator(i,t)} lists build scripts, but all build scripts have been disabled.`),[]):Hle(t,"build",{configuration:i,report:n})?s:[]}var g_e=new Set([".exe",".h",".hh",".hpp",".c",".cc",".cpp",".java",".jar",".node"]);function VL(t){return t.packageFs.getExtractHint({relevantExtensions:g_e})}function XL(t){let e=k.join(t.prefixPath,"binding.gyp");return t.packageFs.existsSync(e)}var ZL={};ft(ZL,{getUnpluggedPath:()=>Wm});function Wm(t,{configuration:e}){return k.resolve(e.get("pnpUnpluggedFolder"),P.slugifyLocator(t))}var f_e=new Set([P.makeIdent(null,"nan").identHash,P.makeIdent(null,"node-gyp").identHash,P.makeIdent(null,"node-pre-gyp").identHash,P.makeIdent(null,"node-addon-api").identHash,P.makeIdent(null,"fsevents").identHash]),Qu=class{constructor(){this.mode="strict";this.pnpCache=new Map}supportsPackage(e,r){return this.isEnabled(r)}async findPackageLocation(e,r){if(!this.isEnabled(r))throw new Error("Assertion failed: Expected the PnP linker to be enabled");let i=Pl(r.project).cjs;if(!K.existsSync(i))throw new Pe(`The project in ${ae.pretty(r.project.configuration,`${r.project.cwd}/package.json`,ae.Type.PATH)} doesn't seem to have been installed - running an install there might help`);let n=Se.getFactoryWithDefault(this.pnpCache,i,()=>Se.dynamicRequire(i,{cachingStrategy:Se.CachingStrategy.FsTime})),s={name:P.stringifyIdent(e),reference:e.reference},o=n.getPackageInformation(s);if(!o)throw new Pe(`Couldn't find ${P.prettyLocator(r.project.configuration,e)} in the currently installed PnP map - running an install might help`);return j.toPortablePath(o.packageLocation)}async findPackageLocator(e,r){if(!this.isEnabled(r))return null;let i=Pl(r.project).cjs;if(!K.existsSync(i))return null;let s=Se.getFactoryWithDefault(this.pnpCache,i,()=>Se.dynamicRequire(i,{cachingStrategy:Se.CachingStrategy.FsTime})).findPackageLocator(j.fromPortablePath(e));return s?P.makeLocator(P.parseIdent(s.name),s.reference):null}makeInstaller(e){return new sh(e)}isEnabled(e){return!(e.project.configuration.get("nodeLinker")!=="pnp"||e.project.configuration.get("pnpMode")!==this.mode)}},sh=class{constructor(e){this.opts=e;this.mode="strict";this.asyncActions=new Se.AsyncActions(10);this.packageRegistry=new Map;this.virtualTemplates=new Map;this.isESMLoaderRequired=!1;this.customData={store:new Map};this.unpluggedPaths=new Set;this.opts=e}getCustomDataKey(){return JSON.stringify({name:"PnpInstaller",version:2})}attachCustomData(e){this.customData=e}async installPackage(e,r,i){let n=P.stringifyIdent(e),s=e.reference,o=!!this.opts.project.tryWorkspaceByLocator(e),a=P.isVirtualLocator(e),l=e.peerDependencies.size>0&&!a,c=!l&&!o,u=!l&&e.linkType!==Qt.SOFT,g,f;if(c||u){let x=a?P.devirtualizeLocator(e):e;g=this.customData.store.get(x.locatorHash),typeof g=="undefined"&&(g=await h_e(r),e.linkType===Qt.HARD&&this.customData.store.set(x.locatorHash,g)),g.manifest.type==="module"&&(this.isESMLoaderRequired=!0),f=this.opts.project.getDependencyMeta(x,e.version)}let h=c?gb(e,g,f,{configuration:this.opts.project.configuration,report:this.opts.report}):[],p=u?await this.unplugPackageIfNeeded(e,g,r,f,i):r.packageFs;if(k.isAbsolute(r.prefixPath))throw new Error(`Assertion failed: Expected the prefix path (${r.prefixPath}) to be relative to the parent`);let m=k.resolve(p.getRealPath(),r.prefixPath),y=$L(this.opts.project.cwd,m),Q=new Map,S=new Set;if(a){for(let x of e.peerDependencies.values())Q.set(P.stringifyIdent(x),null),S.add(P.stringifyIdent(x));if(!o){let x=P.devirtualizeLocator(e);this.virtualTemplates.set(x.locatorHash,{location:$L(this.opts.project.cwd,Jr.resolveVirtual(m)),locator:x})}}return Se.getMapWithDefault(this.packageRegistry,n).set(s,{packageLocation:y,packageDependencies:Q,packagePeers:S,linkType:e.linkType,discardFromLookup:r.discardFromLookup||!1}),{packageLocation:m,buildDirective:h.length>0?h:null}}async attachInternalDependencies(e,r){let i=this.getPackageInformation(e);for(let[n,s]of r){let o=P.areIdentsEqual(n,s)?s.reference:[P.stringifyIdent(s),s.reference];i.packageDependencies.set(P.stringifyIdent(n),o)}}async attachExternalDependents(e,r){for(let i of r)this.getDiskInformation(i).packageDependencies.set(P.stringifyIdent(e),e.reference)}async finalizeInstall(){if(this.opts.project.configuration.get("pnpMode")!==this.mode)return;let e=Pl(this.opts.project);if(K.existsSync(e.cjsLegacy)&&(this.opts.report.reportWarning($.UNNAMED,`Removing the old ${ae.pretty(this.opts.project.configuration,Pt.pnpJs,ae.Type.PATH)} file. You might need to manually update existing references to reference the new ${ae.pretty(this.opts.project.configuration,Pt.pnpCjs,ae.Type.PATH)} file. If you use Editor SDKs, you'll have to rerun ${ae.pretty(this.opts.project.configuration,"yarn sdks",ae.Type.CODE)}.`),await K.removePromise(e.cjsLegacy)),this.isEsmEnabled()||await K.removePromise(e.esmLoader),this.opts.project.configuration.get("nodeLinker")!=="pnp"){await K.removePromise(e.cjs),await K.removePromise(this.opts.project.configuration.get("pnpDataPath")),await K.removePromise(e.esmLoader);return}for(let{locator:u,location:g}of this.virtualTemplates.values())Se.getMapWithDefault(this.packageRegistry,P.stringifyIdent(u)).set(u.reference,{packageLocation:g,packageDependencies:new Map,packagePeers:new Set,linkType:Qt.SOFT,discardFromLookup:!1});this.packageRegistry.set(null,new Map([[null,this.getPackageInformation(this.opts.project.topLevelWorkspace.anchoredLocator)]]));let r=this.opts.project.configuration.get("pnpFallbackMode"),i=this.opts.project.workspaces.map(({anchoredLocator:u})=>({name:P.stringifyIdent(u),reference:u.reference})),n=r!=="none",s=[],o=new Map,a=Se.buildIgnorePattern([".yarn/sdks/**",...this.opts.project.configuration.get("pnpIgnorePatterns")]),l=this.packageRegistry,c=this.opts.project.configuration.get("pnpShebang");if(r==="dependencies-only")for(let u of this.opts.project.storedPackages.values())this.opts.project.tryWorkspaceByLocator(u)&&s.push({name:P.stringifyIdent(u),reference:u.reference});return await this.finalizeInstallWithPnp({dependencyTreeRoots:i,enableTopLevelFallback:n,fallbackExclusionList:s,fallbackPool:o,ignorePattern:a,packageRegistry:l,shebang:c}),await this.asyncActions.wait(),{customData:this.customData}}async transformPnpSettings(e){}isEsmEnabled(){if(this.opts.project.configuration.sources.has("pnpEnableEsmLoader"))return this.opts.project.configuration.get("pnpEnableEsmLoader");if(this.isESMLoaderRequired)return!0;for(let e of this.opts.project.workspaces)if(e.manifest.type==="module")return!0;return!1}async finalizeInstallWithPnp(e){let r=Pl(this.opts.project),i=this.opts.project.configuration.get("pnpDataPath"),n=await this.locateNodeModules(e.ignorePattern);if(n.length>0){this.opts.report.reportWarning($.DANGEROUS_NODE_MODULES,"One or more node_modules have been detected and will be removed. This operation may take some time.");for(let o of n)await K.removePromise(o)}if(await this.transformPnpSettings(e),this.opts.project.configuration.get("pnpEnableInlining")){let o=xle(e);await K.changeFilePromise(r.cjs,o,{automaticNewlines:!0,mode:493}),await K.removePromise(i)}else{let o=k.relative(k.dirname(r.cjs),i),{dataFile:a,loaderFile:l}=Ple(te(N({},e),{dataLocation:o}));await K.changeFilePromise(r.cjs,l,{automaticNewlines:!0,mode:493}),await K.changeFilePromise(i,a,{automaticNewlines:!0,mode:420})}this.isEsmEnabled()&&(this.opts.report.reportWarning($.UNNAMED,"ESM support for PnP uses the experimental loader API and is therefore experimental"),await K.changeFilePromise(r.esmLoader,(0,_L.default)(),{automaticNewlines:!0,mode:420}));let s=this.opts.project.configuration.get("pnpUnpluggedFolder");if(this.unpluggedPaths.size===0)await K.removePromise(s);else for(let o of await K.readdirPromise(s)){let a=k.resolve(s,o);this.unpluggedPaths.has(a)||await K.removePromise(a)}}async locateNodeModules(e){let r=[],i=e?new RegExp(e):null;for(let n of this.opts.project.workspaces){let s=k.join(n.cwd,"node_modules");if(i&&i.test(k.relative(this.opts.project.cwd,n.cwd))||!K.existsSync(s))continue;let o=await K.readdirPromise(s,{withFileTypes:!0}),a=o.filter(l=>!l.isDirectory()||l.name===".bin"||!l.name.startsWith("."));if(a.length===o.length)r.push(s);else for(let l of a)r.push(k.join(s,l.name))}return r}async unplugPackageIfNeeded(e,r,i,n,s){return this.shouldBeUnplugged(e,r,n)?this.unplugPackage(e,i,s):i.packageFs}shouldBeUnplugged(e,r,i){return typeof i.unplugged!="undefined"?i.unplugged:f_e.has(e.identHash)||e.conditions!=null?!0:r.manifest.preferUnplugged!==null?r.manifest.preferUnplugged:!!(gb(e,r,i,{configuration:this.opts.project.configuration}).length>0||r.misc.extractHint)}async unplugPackage(e,r,i){let n=Wm(e,{configuration:this.opts.project.configuration});return this.opts.project.disabledLocators.has(e.locatorHash)?new Pa(n,{baseFs:r.packageFs,pathUtils:k}):(this.unpluggedPaths.add(n),i.holdFetchResult(this.asyncActions.set(e.locatorHash,async()=>{let s=k.join(n,r.prefixPath,".ready");await K.existsPromise(s)||(this.opts.project.storedBuildState.delete(e.locatorHash),await K.mkdirPromise(n,{recursive:!0}),await K.copyPromise(n,Me.dot,{baseFs:r.packageFs,overwrite:!1}),await K.writeFilePromise(s,""))})),new _t(n))}getPackageInformation(e){let r=P.stringifyIdent(e),i=e.reference,n=this.packageRegistry.get(r);if(!n)throw new Error(`Assertion failed: The package information store should have been available (for ${P.prettyIdent(this.opts.project.configuration,e)})`);let s=n.get(i);if(!s)throw new Error(`Assertion failed: The package information should have been available (for ${P.prettyLocator(this.opts.project.configuration,e)})`);return s}getDiskInformation(e){let r=Se.getMapWithDefault(this.packageRegistry,"@@disk"),i=$L(this.opts.project.cwd,e);return Se.getFactoryWithDefault(r,i,()=>({packageLocation:i,packageDependencies:new Map,packagePeers:new Set,linkType:Qt.SOFT,discardFromLookup:!1}))}};function $L(t,e){let r=k.relative(t,e);return r.match(/^\.{0,2}\//)||(r=`./${r}`),r.replace(/\/?$/,"/")}async function h_e(t){var i;let e=(i=await At.tryFind(t.prefixPath,{baseFs:t.packageFs}))!=null?i:new At,r=new Set(["preinstall","install","postinstall"]);for(let n of e.scripts.keys())r.has(n)||e.scripts.delete(n);return{manifest:{scripts:e.scripts,preferUnplugged:e.preferUnplugged,type:e.type},misc:{extractHint:VL(t),hasBindingGyp:XL(t)}}}var jle=ge(ts());var zm=class extends Le{constructor(){super(...arguments);this.all=z.Boolean("-A,--all",!1,{description:"Unplug direct dependencies from the entire project"});this.recursive=z.Boolean("-R,--recursive",!1,{description:"Unplug both direct and transitive dependencies"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.patterns=z.Rest()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i)throw new ht(r.cwd,this.context.cwd);if(e.get("nodeLinker")!=="pnp")throw new Pe("This command can only be used if the `nodeLinker` option is set to `pnp`");await r.restoreInstallState();let s=new Set(this.patterns),o=this.patterns.map(f=>{let h=P.parseDescriptor(f),p=h.range!=="unknown"?h:P.makeDescriptor(h,"*");if(!Wt.validRange(p.range))throw new Pe(`The range of the descriptor patterns must be a valid semver range (${P.prettyDescriptor(e,p)})`);return m=>{let y=P.stringifyIdent(m);return!jle.default.isMatch(y,P.stringifyIdent(p))||m.version&&!Wt.satisfiesWithPrereleases(m.version,p.range)?!1:(s.delete(f),!0)}}),a=()=>{let f=[];for(let h of r.storedPackages.values())!r.tryWorkspaceByLocator(h)&&!P.isVirtualLocator(h)&&o.some(p=>p(h))&&f.push(h);return f},l=f=>{let h=new Set,p=[],m=(y,Q)=>{if(!h.has(y.locatorHash)&&(h.add(y.locatorHash),!r.tryWorkspaceByLocator(y)&&o.some(S=>S(y))&&p.push(y),!(Q>0&&!this.recursive)))for(let S of y.dependencies.values()){let x=r.storedResolutions.get(S.descriptorHash);if(!x)throw new Error("Assertion failed: The resolution should have been registered");let M=r.storedPackages.get(x);if(!M)throw new Error("Assertion failed: The package should have been registered");m(M,Q+1)}};for(let y of f){let Q=r.storedPackages.get(y.anchoredLocator.locatorHash);if(!Q)throw new Error("Assertion failed: The package should have been registered");m(Q,0)}return p},c,u;if(this.all&&this.recursive?(c=a(),u="the project"):this.all?(c=l(r.workspaces),u="any workspace"):(c=l([i]),u="this workspace"),s.size>1)throw new Pe(`Patterns ${ae.prettyList(e,s,ae.Type.CODE)} don't match any packages referenced by ${u}`);if(s.size>0)throw new Pe(`Pattern ${ae.prettyList(e,s,ae.Type.CODE)} doesn't match any packages referenced by ${u}`);return c=Se.sortMap(c,f=>P.stringifyLocator(f)),(await Je.start({configuration:e,stdout:this.context.stdout,json:this.json},async f=>{var h;for(let p of c){let m=(h=p.version)!=null?h:"unknown",y=r.topLevelWorkspace.manifest.ensureDependencyMeta(P.makeDescriptor(p,m));y.unplugged=!0,f.reportInfo($.UNNAMED,`Will unpack ${P.prettyLocator(e,p)} to ${ae.pretty(e,Wm(p,{configuration:e}),ae.Type.PATH)}`),f.reportJson({locator:P.stringifyLocator(p),version:m})}await r.topLevelWorkspace.persistManifest(),f.reportSeparator(),await r.install({cache:n,report:f})})).exitCode()}};zm.paths=[["unplug"]],zm.usage=Re.Usage({description:"force the unpacking of a list of packages",details:"\n      This command will add the selectors matching the specified patterns to the list of packages that must be unplugged when installed.\n\n      A package being unplugged means that instead of being referenced directly through its archive, it will be unpacked at install time in the directory configured via `pnpUnpluggedFolder`. Note that unpacking packages this way is generally not recommended because it'll make it harder to store your packages within the repository. However, it's a good approach to quickly and safely debug some packages, and can even sometimes be required depending on the context (for example when the package contains shellscripts).\n\n      Running the command will set a persistent flag inside your top-level `package.json`, in the `dependenciesMeta` field. As such, to undo its effects, you'll need to revert the changes made to the manifest and run `yarn install` to apply the modification.\n\n      By default, only direct dependencies from the current workspace are affected. If `-A,--all` is set, direct dependencies from the entire project are affected. Using the `-R,--recursive` flag will affect transitive dependencies as well as direct ones.\n\n      This command accepts glob patterns inside the scope and name components (not the range). Make sure to escape the patterns to prevent your own shell from trying to expand them.\n    ",examples:[["Unplug the lodash dependency from the active workspace","yarn unplug lodash"],["Unplug all instances of lodash referenced by any workspace","yarn unplug lodash -A"],["Unplug all instances of lodash referenced by the active workspace and its dependencies","yarn unplug lodash -R"],["Unplug all instances of lodash, anywhere","yarn unplug lodash -AR"],["Unplug one specific version of lodash","yarn unplug lodash@1.2.3"],["Unplug all packages with the `@babel` scope","yarn unplug '@babel/*'"],["Unplug all packages (only for testing, not recommended)","yarn unplug -R '*'"]]});var Gle=zm;var Pl=t=>({cjs:k.join(t.cwd,Pt.pnpCjs),cjsLegacy:k.join(t.cwd,Pt.pnpJs),esmLoader:k.join(t.cwd,".pnp.loader.mjs")}),Jle=t=>/\s/.test(t)?JSON.stringify(t):t;async function p_e(t,e,r){let i=Pl(t),n=`--require ${Jle(j.fromPortablePath(i.cjs))}`;if(K.existsSync(i.esmLoader)&&(n=`${n} --experimental-loader ${(0,qle.pathToFileURL)(j.fromPortablePath(i.esmLoader)).href}`),i.cjs.includes(" ")&&Yle.default.lt(process.versions.node,"12.0.0"))throw new Error(`Expected the build location to not include spaces when using Node < 12.0.0 (${process.versions.node})`);if(K.existsSync(i.cjs)){let s=e.NODE_OPTIONS||"",o=/\s*--require\s+\S*\.pnp\.c?js\s*/g,a=/\s*--experimental-loader\s+\S*\.pnp\.loader\.mjs\s*/;s=s.replace(o," ").replace(a," ").trim(),s=s?`${n} ${s}`:n,e.NODE_OPTIONS=s}}async function d_e(t,e){let r=Pl(t);e(r.cjs),e(r.esmLoader),e(t.configuration.get("pnpDataPath")),e(t.configuration.get("pnpUnpluggedFolder"))}var C_e={hooks:{populateYarnPaths:d_e,setupScriptEnvironment:p_e},configuration:{nodeLinker:{description:'The linker used for installing Node packages, one of: "pnp", "node-modules"',type:Ie.STRING,default:"pnp"},pnpMode:{description:"If 'strict', generates standard PnP maps. If 'loose', merges them with the n_m resolution.",type:Ie.STRING,default:"strict"},pnpShebang:{description:"String to prepend to the generated PnP script",type:Ie.STRING,default:"#!/usr/bin/env node"},pnpIgnorePatterns:{description:"Array of glob patterns; files matching them will use the classic resolution",type:Ie.STRING,default:[],isArray:!0},pnpEnableEsmLoader:{description:"If true, Yarn will generate an ESM loader (`.pnp.loader.mjs`). If this is not explicitly set Yarn tries to automatically detect whether ESM support is required.",type:Ie.BOOLEAN,default:!1},pnpEnableInlining:{description:"If true, the PnP data will be inlined along with the generated loader",type:Ie.BOOLEAN,default:!0},pnpFallbackMode:{description:"If true, the generated PnP loader will follow the top-level fallback rule",type:Ie.STRING,default:"dependencies-only"},pnpUnpluggedFolder:{description:"Folder where the unplugged packages must be stored",type:Ie.ABSOLUTE_PATH,default:"./.yarn/unplugged"},pnpDataPath:{description:"Path of the file where the PnP data (used by the loader) must be written",type:Ie.ABSOLUTE_PATH,default:"./.pnp.data.json"}},linkers:[Qu],commands:[Gle]},m_e=C_e;var Zle=ge(Xle());var sT=ge(require("crypto")),$le=ge(require("fs")),ece=1,ai="node_modules",oT=".bin",tce=".yarn-state.yml",Li;(function(i){i.CLASSIC="classic",i.HARDLINKS_LOCAL="hardlinks-local",i.HARDLINKS_GLOBAL="hardlinks-global"})(Li||(Li={}));var aT=class{constructor(){this.installStateCache=new Map}supportsPackage(e,r){return this.isEnabled(r)}async findPackageLocation(e,r){if(!this.isEnabled(r))throw new Error("Assertion failed: Expected the node-modules linker to be enabled");let i=r.project.tryWorkspaceByLocator(e);if(i)return i.cwd;let n=await Se.getFactoryWithDefault(this.installStateCache,r.project.cwd,async()=>await AT(r.project,{unrollAliases:!0}));if(n===null)throw new Pe("Couldn't find the node_modules state file - running an install might help (findPackageLocation)");let s=n.locatorMap.get(P.stringifyLocator(e));if(!s){let a=new Pe(`Couldn't find ${P.prettyLocator(r.project.configuration,e)} in the currently installed node_modules map - running an install might help`);throw a.code="LOCATOR_NOT_INSTALLED",a}let o=r.project.configuration.startingCwd;return s.locations.find(a=>k.contains(o,a))||s.locations[0]}async findPackageLocator(e,r){if(!this.isEnabled(r))return null;let i=await Se.getFactoryWithDefault(this.installStateCache,r.project.cwd,async()=>await AT(r.project,{unrollAliases:!0}));if(i===null)return null;let{locationRoot:n,segments:s}=fb(k.resolve(e),{skipPrefix:r.project.cwd}),o=i.locationTree.get(n);if(!o)return null;let a=o.locator;for(let l of s){if(o=o.children.get(l),!o)break;a=o.locator||a}return P.parseLocator(a)}makeInstaller(e){return new rce(e)}isEnabled(e){return e.project.configuration.get("nodeLinker")==="node-modules"}},rce=class{constructor(e){this.opts=e;this.localStore=new Map;this.realLocatorChecksums=new Map;this.customData={store:new Map}}getCustomDataKey(){return JSON.stringify({name:"NodeModulesInstaller",version:2})}attachCustomData(e){this.customData=e}async installPackage(e,r){var u;let i=k.resolve(r.packageFs.getRealPath(),r.prefixPath),n=this.customData.store.get(e.locatorHash);if(typeof n=="undefined"&&(n=await L_e(e,r),e.linkType===Qt.HARD&&this.customData.store.set(e.locatorHash,n)),!wo.checkManifestCompatibility(e))return{packageLocation:null,buildDirective:null};let s=new Map,o=new Set;s.has(P.stringifyIdent(e))||s.set(P.stringifyIdent(e),e.reference);let a=e;if(P.isVirtualLocator(e)){a=P.devirtualizeLocator(e);for(let g of e.peerDependencies.values())s.set(P.stringifyIdent(g),null),o.add(P.stringifyIdent(g))}let l={packageLocation:`${j.fromPortablePath(i)}/`,packageDependencies:s,packagePeers:o,linkType:e.linkType,discardFromLookup:(u=r.discardFromLookup)!=null?u:!1};this.localStore.set(e.locatorHash,{pkg:e,customPackageData:n,dependencyMeta:this.opts.project.getDependencyMeta(e,e.version),pnpNode:l});let c=r.checksum?r.checksum.substring(r.checksum.indexOf("/")+1):null;return this.realLocatorChecksums.set(a.locatorHash,c),{packageLocation:i,buildDirective:null}}async attachInternalDependencies(e,r){let i=this.localStore.get(e.locatorHash);if(typeof i=="undefined")throw new Error("Assertion failed: Expected information object to have been registered");for(let[n,s]of r){let o=P.areIdentsEqual(n,s)?s.reference:[P.stringifyIdent(s),s.reference];i.pnpNode.packageDependencies.set(P.stringifyIdent(n),o)}}async attachExternalDependents(e,r){throw new Error("External dependencies haven't been implemented for the node-modules linker")}async finalizeInstall(){if(this.opts.project.configuration.get("nodeLinker")!=="node-modules")return;let e=new Jr({baseFs:new ms({libzip:await fn(),maxOpenFiles:80,readOnlyArchives:!0})}),r=await AT(this.opts.project),i=this.opts.project.configuration.get("nmMode");(r===null||i!==r.nmMode)&&(this.opts.project.storedBuildState.clear(),r={locatorMap:new Map,binSymlinks:new Map,locationTree:new Map,nmMode:i});let n=new Map(this.opts.project.workspaces.map(f=>{var p,m;let h=this.opts.project.configuration.get("nmHoistingLimits");try{h=Se.validateEnum(Mn,(m=(p=f.manifest.installConfig)==null?void 0:p.hoistingLimits)!=null?m:h)}catch(y){let Q=P.prettyWorkspace(this.opts.project.configuration,f);this.opts.report.reportWarning($.INVALID_MANIFEST,`${Q}: Invalid 'installConfig.hoistingLimits' value. Expected one of ${Object.values(Mn).join(", ")}, using default: "${h}"`)}return[f.relativeCwd,h]})),s=new Map(this.opts.project.workspaces.map(f=>{var p,m;let h=this.opts.project.configuration.get("nmSelfReferences");return h=(m=(p=f.manifest.installConfig)==null?void 0:p.selfReferences)!=null?m:h,[f.relativeCwd,h]})),o={VERSIONS:{std:1},topLevel:{name:null,reference:null},getLocator:(f,h)=>Array.isArray(h)?{name:h[0],reference:h[1]}:{name:f,reference:h},getDependencyTreeRoots:()=>this.opts.project.workspaces.map(f=>{let h=f.anchoredLocator;return{name:P.stringifyIdent(f.locator),reference:h.reference}}),getPackageInformation:f=>{let h=f.reference===null?this.opts.project.topLevelWorkspace.anchoredLocator:P.makeLocator(P.parseIdent(f.name),f.reference),p=this.localStore.get(h.locatorHash);if(typeof p=="undefined")throw new Error("Assertion failed: Expected the package reference to have been registered");return p.pnpNode},findPackageLocator:f=>{let h=this.opts.project.tryWorkspaceByCwd(j.toPortablePath(f));if(h!==null){let p=h.anchoredLocator;return{name:P.stringifyIdent(p),reference:p.reference}}throw new Error("Assertion failed: Unimplemented")},resolveToUnqualified:()=>{throw new Error("Assertion failed: Unimplemented")},resolveUnqualified:()=>{throw new Error("Assertion failed: Unimplemented")},resolveRequest:()=>{throw new Error("Assertion failed: Unimplemented")},resolveVirtual:f=>j.fromPortablePath(Jr.resolveVirtual(j.toPortablePath(f)))},{tree:a,errors:l,preserveSymlinksRequired:c}=Gm(o,{pnpifyFs:!1,validateExternalSoftLinks:!0,hoistingLimitsByCwd:n,project:this.opts.project,selfReferencesByCwd:s});if(!a){for(let{messageName:f,text:h}of l)this.opts.report.reportError(f,h);return}let u=HL(a);await T_e(r,u,{baseFs:e,project:this.opts.project,report:this.opts.report,realLocatorChecksums:this.realLocatorChecksums,loadManifest:async f=>{let h=P.parseLocator(f),p=this.localStore.get(h.locatorHash);if(typeof p=="undefined")throw new Error("Assertion failed: Expected the slot to exist");return p.customPackageData.manifest}});let g=[];for(let[f,h]of u.entries()){if(ice(f))continue;let p=P.parseLocator(f),m=this.localStore.get(p.locatorHash);if(typeof m=="undefined")throw new Error("Assertion failed: Expected the slot to exist");if(this.opts.project.tryWorkspaceByLocator(m.pkg))continue;let y=wo.extractBuildScripts(m.pkg,m.customPackageData,m.dependencyMeta,{configuration:this.opts.project.configuration,report:this.opts.report});y.length!==0&&g.push({buildLocations:h.locations,locatorHash:p.locatorHash,buildDirective:y})}return c&&this.opts.report.reportWarning($.NM_PRESERVE_SYMLINKS_REQUIRED,`The application uses portals and that's why ${ae.pretty(this.opts.project.configuration,"--preserve-symlinks",ae.Type.CODE)} Node option is required for launching it`),{customData:this.customData,records:g}}};async function L_e(t,e){var n;let r=(n=await At.tryFind(e.prefixPath,{baseFs:e.packageFs}))!=null?n:new At,i=new Set(["preinstall","install","postinstall"]);for(let s of r.scripts.keys())i.has(s)||r.scripts.delete(s);return{manifest:{bin:r.bin,scripts:r.scripts},misc:{extractHint:wo.getExtractHint(e),hasBindingGyp:wo.hasBindingGyp(e)}}}async function O_e(t,e,r,i){let n="";n+=`# Warning: This file is automatically generated. Removing it is fine, but will
+`,n+=`# cause your node_modules installation to become invalidated.
+`,n+=`
+`,n+=`__metadata:
+`,n+=`  version: ${ece}
+`,n+=`  nmMode: ${i.value}
+`;let s=Array.from(e.keys()).sort(),o=P.stringifyLocator(t.topLevelWorkspace.anchoredLocator);for(let c of s){let u=e.get(c);n+=`
+`,n+=`${JSON.stringify(c)}:
+`,n+=`  locations:
+`;for(let g of u.locations){let f=k.contains(t.cwd,g);if(f===null)throw new Error(`Assertion failed: Expected the path to be within the project (${g})`);n+=`    - ${JSON.stringify(f)}
+`}if(u.aliases.length>0){n+=`  aliases:
+`;for(let g of u.aliases)n+=`    - ${JSON.stringify(g)}
+`}if(c===o&&r.size>0){n+=`  bin:
+`;for(let[g,f]of r){let h=k.contains(t.cwd,g);if(h===null)throw new Error(`Assertion failed: Expected the path to be within the project (${g})`);n+=`    ${JSON.stringify(h)}:
+`;for(let[p,m]of f){let y=k.relative(k.join(g,ai),m);n+=`      ${JSON.stringify(p)}: ${JSON.stringify(y)}
+`}}}}let a=t.cwd,l=k.join(a,ai,tce);await K.changeFilePromise(l,n,{automaticNewlines:!0})}async function AT(t,{unrollAliases:e=!1}={}){let r=t.cwd,i=k.join(r,ai,tce);if(!K.existsSync(i))return null;let n=Qi(await K.readFilePromise(i,"utf8"));if(n.__metadata.version>ece)return null;let s=n.__metadata.nmMode||Li.CLASSIC,o=new Map,a=new Map;delete n.__metadata;for(let[l,c]of Object.entries(n)){let u=c.locations.map(f=>k.join(r,f)),g=c.bin;if(g)for(let[f,h]of Object.entries(g)){let p=k.join(r,j.toPortablePath(f)),m=Se.getMapWithDefault(a,p);for(let[y,Q]of Object.entries(h))m.set(qr(y),j.toPortablePath([p,ai,Q].join(k.delimiter)))}if(o.set(l,{target:Me.dot,linkType:Qt.HARD,locations:u,aliases:c.aliases||[]}),e&&c.aliases)for(let f of c.aliases){let{scope:h,name:p}=P.parseLocator(l),m=P.makeLocator(P.makeIdent(h,p),f),y=P.stringifyLocator(m);o.set(y,{target:Me.dot,linkType:Qt.HARD,locations:u,aliases:[]})}}return{locatorMap:o,binSymlinks:a,locationTree:nce(o,{skipPrefix:t.cwd}),nmMode:s}}var ah=async(t,e)=>{if(t.split(k.sep).indexOf(ai)<0)throw new Error(`Assertion failed: trying to remove dir that doesn't contain node_modules: ${t}`);try{if(!e.innerLoop){let i=e.allowSymlink?await K.statPromise(t):await K.lstatPromise(t);if(e.allowSymlink&&!i.isDirectory()||!e.allowSymlink&&i.isSymbolicLink()){await K.unlinkPromise(t);return}}let r=await K.readdirPromise(t,{withFileTypes:!0});for(let i of r){let n=k.join(t,qr(i.name));i.isDirectory()?(i.name!==ai||e&&e.innerLoop)&&await ah(n,{innerLoop:!0,contentsOnly:!1}):await K.unlinkPromise(n)}e.contentsOnly||await K.rmdirPromise(t)}catch(r){if(r.code!=="ENOENT"&&r.code!=="ENOTEMPTY")throw r}},sce=4,fb=(t,{skipPrefix:e})=>{let r=k.contains(e,t);if(r===null)throw new Error(`Assertion failed: Writing attempt prevented to ${t} which is outside project root: ${e}`);let i=r.split(k.sep).filter(l=>l!==""),n=i.indexOf(ai),s=i.slice(0,n).join(k.sep),o=k.join(e,s),a=i.slice(n);return{locationRoot:o,segments:a}},nce=(t,{skipPrefix:e})=>{let r=new Map;if(t===null)return r;let i=()=>({children:new Map,linkType:Qt.HARD});for(let[n,s]of t.entries()){if(s.linkType===Qt.SOFT&&k.contains(e,s.target)!==null){let a=Se.getFactoryWithDefault(r,s.target,i);a.locator=n,a.linkType=s.linkType}for(let o of s.locations){let{locationRoot:a,segments:l}=fb(o,{skipPrefix:e}),c=Se.getFactoryWithDefault(r,a,i);for(let u=0;u<l.length;++u){let g=l[u];if(g!=="."){let f=Se.getFactoryWithDefault(c.children,g,i);c.children.set(g,f),c=f}u===l.length-1&&(c.locator=n,c.linkType=s.linkType)}}}return r},lT=async(t,e)=>{let r;try{process.platform==="win32"&&(r=await K.lstatPromise(t))}catch(i){}process.platform=="win32"&&(!r||r.isDirectory())?await K.symlinkPromise(t,e,"junction"):await K.symlinkPromise(k.relative(k.dirname(e),t),e)};async function oce(t,e,r){let i=k.join(t,qr(`${sT.default.randomBytes(16).toString("hex")}.tmp`));try{await K.writeFilePromise(i,r);try{await K.linkPromise(i,e)}catch(n){}}finally{await K.unlinkPromise(i)}}async function M_e({srcPath:t,dstPath:e,srcMode:r,globalHardlinksStore:i,baseFs:n,nmMode:s,digest:o}){if(s.value===Li.HARDLINKS_GLOBAL&&i&&o){let l=k.join(i,o.substring(0,2),`${o.substring(2)}.dat`),c;try{if(await Dn.checksumFile(l,{baseFs:K,algorithm:"sha1"})!==o){let g=k.join(i,qr(`${sT.default.randomBytes(16).toString("hex")}.tmp`));await K.renamePromise(l,g);let f=await n.readFilePromise(t);await K.writeFilePromise(g,f);try{await K.linkPromise(g,l),await K.unlinkPromise(g)}catch(h){}}await K.linkPromise(l,e),c=!0}catch(u){c=!1}if(!c){let u=await n.readFilePromise(t);await oce(i,l,u);try{await K.linkPromise(l,e)}catch(g){g&&g.code&&g.code=="EXDEV"&&(s.value=Li.HARDLINKS_LOCAL,await n.copyFilePromise(t,e))}}}else await n.copyFilePromise(t,e);let a=r&511;a!==420&&await K.chmodPromise(e,a)}var Dl;(function(i){i.FILE="file",i.DIRECTORY="directory",i.SYMLINK="symlink"})(Dl||(Dl={}));var U_e=async(t,e,{baseFs:r,globalHardlinksStore:i,nmMode:n,packageChecksum:s})=>{await K.mkdirPromise(t,{recursive:!0});let o=async(l=Me.dot)=>{let c=k.join(e,l),u=await r.readdirPromise(c,{withFileTypes:!0}),g=new Map;for(let f of u){let h=k.join(l,f.name),p,m=k.join(c,f.name);if(f.isFile()){if(p={kind:Dl.FILE,mode:(await r.lstatPromise(m)).mode},n.value===Li.HARDLINKS_GLOBAL){let y=await Dn.checksumFile(m,{baseFs:r,algorithm:"sha1"});p.digest=y}}else if(f.isDirectory())p={kind:Dl.DIRECTORY};else if(f.isSymbolicLink())p={kind:Dl.SYMLINK,symlinkTo:await r.readlinkPromise(m)};else throw new Error(`Unsupported file type (file: ${m}, mode: 0o${await r.statSync(m).mode.toString(8).padStart(6,"0")})`);if(g.set(h,p),f.isDirectory()&&h!==ai){let y=await o(h);for(let[Q,S]of y)g.set(Q,S)}}return g},a;if(n.value===Li.HARDLINKS_GLOBAL&&i&&s){let l=k.join(i,s.substring(0,2),`${s.substring(2)}.json`);try{a=new Map(Object.entries(JSON.parse(await K.readFilePromise(l,"utf8"))))}catch(c){a=await o(),await oce(i,l,Buffer.from(JSON.stringify(Object.fromEntries(a))))}}else a=await o();for(let[l,c]of a){let u=k.join(e,l),g=k.join(t,l);c.kind===Dl.DIRECTORY?await K.mkdirPromise(g,{recursive:!0}):c.kind===Dl.FILE?await M_e({srcPath:u,dstPath:g,srcMode:c.mode,digest:c.digest,nmMode:n,baseFs:r,globalHardlinksStore:i}):c.kind===Dl.SYMLINK&&await lT(k.resolve(k.dirname(g),c.symlinkTo),g)}};function K_e(t,e){let r=new Map([...t]),i=new Map([...e]);for(let[n,s]of t){let o=k.join(n,ai);if(!K.existsSync(o)){s.children.delete(ai);for(let a of i.keys())k.contains(o,a)!==null&&i.delete(a)}}return{locationTree:r,binSymlinks:i}}function ice(t){let e=P.parseDescriptor(t);return P.isVirtualDescriptor(e)&&(e=P.devirtualizeDescriptor(e)),e.range.startsWith("link:")}async function H_e(t,e,r,{loadManifest:i}){let n=new Map;for(let[a,{locations:l}]of t){let c=ice(a)?null:await i(a,l[0]),u=new Map;if(c)for(let[g,f]of c.bin){let h=k.join(l[0],f);f!==""&&K.existsSync(h)&&u.set(g,f)}n.set(a,u)}let s=new Map,o=(a,l,c)=>{let u=new Map,g=k.contains(r,a);if(c.locator&&g!==null){let f=n.get(c.locator);for(let[h,p]of f){let m=k.join(a,j.toPortablePath(p));u.set(qr(h),m)}for(let[h,p]of c.children){let m=k.join(a,h),y=o(m,m,p);y.size>0&&s.set(a,new Map([...s.get(a)||new Map,...y]))}}else for(let[f,h]of c.children){let p=o(k.join(a,f),l,h);for(let[m,y]of p)u.set(m,y)}return u};for(let[a,l]of e){let c=o(a,a,l);c.size>0&&s.set(a,new Map([...s.get(a)||new Map,...c]))}return s}var ace=(t,e)=>{if(!t||!e)return t===e;let r=P.parseLocator(t);P.isVirtualLocator(r)&&(r=P.devirtualizeLocator(r));let i=P.parseLocator(e);return P.isVirtualLocator(i)&&(i=P.devirtualizeLocator(i)),P.areLocatorsEqual(r,i)};function cT(t){return k.join(t.get("globalFolder"),"store")}async function T_e(t,e,{baseFs:r,project:i,report:n,loadManifest:s,realLocatorChecksums:o}){let a=k.join(i.cwd,ai),{locationTree:l,binSymlinks:c}=K_e(t.locationTree,t.binSymlinks),u=nce(e,{skipPrefix:i.cwd}),g=[],f=async({srcDir:U,dstDir:J,linkType:W,globalHardlinksStore:ee,nmMode:Z,packageChecksum:A})=>{let ne=(async()=>{try{W===Qt.SOFT?(await K.mkdirPromise(k.dirname(J),{recursive:!0}),await lT(k.resolve(U),J)):await U_e(J,U,{baseFs:r,globalHardlinksStore:ee,nmMode:Z,packageChecksum:A})}catch(le){throw le.message=`While persisting ${U} -> ${J} ${le.message}`,le}finally{S.tick()}})().then(()=>g.splice(g.indexOf(ne),1));g.push(ne),g.length>sce&&await Promise.race(g)},h=async(U,J,W)=>{let ee=(async()=>{let Z=async(A,ne,le)=>{try{le.innerLoop||await K.mkdirPromise(ne,{recursive:!0});let Ae=await K.readdirPromise(A,{withFileTypes:!0});for(let T of Ae){if(!le.innerLoop&&T.name===oT)continue;let L=k.join(A,T.name),Ee=k.join(ne,T.name);T.isDirectory()?(T.name!==ai||le&&le.innerLoop)&&(await K.mkdirPromise(Ee,{recursive:!0}),await Z(L,Ee,te(N({},le),{innerLoop:!0}))):Y.value===Li.HARDLINKS_LOCAL||Y.value===Li.HARDLINKS_GLOBAL?await K.linkPromise(L,Ee):await K.copyFilePromise(L,Ee,$le.default.constants.COPYFILE_FICLONE)}}catch(Ae){throw le.innerLoop||(Ae.message=`While cloning ${A} -> ${ne} ${Ae.message}`),Ae}finally{le.innerLoop||S.tick()}};await Z(U,J,W)})().then(()=>g.splice(g.indexOf(ee),1));g.push(ee),g.length>sce&&await Promise.race(g)},p=async(U,J,W)=>{if(W)for(let[ee,Z]of J.children){let A=W.children.get(ee);await p(k.join(U,ee),Z,A)}else{J.children.has(ai)&&await ah(k.join(U,ai),{contentsOnly:!1});let ee=k.basename(U)===ai&&u.has(k.join(k.dirname(U),k.sep));await ah(U,{contentsOnly:U===a,allowSymlink:ee})}};for(let[U,J]of l){let W=u.get(U);for(let[ee,Z]of J.children){if(ee===".")continue;let A=W&&W.children.get(ee),ne=k.join(U,ee);await p(ne,Z,A)}}let m=async(U,J,W)=>{if(W){ace(J.locator,W.locator)||await ah(U,{contentsOnly:J.linkType===Qt.HARD});for(let[ee,Z]of J.children){let A=W.children.get(ee);await m(k.join(U,ee),Z,A)}}else{J.children.has(ai)&&await ah(k.join(U,ai),{contentsOnly:!0});let ee=k.basename(U)===ai&&u.has(k.join(k.dirname(U),k.sep));await ah(U,{contentsOnly:J.linkType===Qt.HARD,allowSymlink:ee})}};for(let[U,J]of u){let W=l.get(U);for(let[ee,Z]of J.children){if(ee===".")continue;let A=W&&W.children.get(ee);await m(k.join(U,ee),Z,A)}}let y=new Map,Q=[];for(let[U,{locations:J}]of t.locatorMap.entries())for(let W of J){let{locationRoot:ee,segments:Z}=fb(W,{skipPrefix:i.cwd}),A=u.get(ee),ne=ee;if(A){for(let le of Z)if(ne=k.join(ne,le),A=A.children.get(le),!A)break;if(A){let le=ace(A.locator,U),Ae=e.get(A.locator),T=Ae.target,L=ne,Ee=Ae.linkType;if(le)y.has(T)||y.set(T,L);else if(T!==L){let we=P.parseLocator(A.locator);P.isVirtualLocator(we)&&(we=P.devirtualizeLocator(we)),Q.push({srcDir:T,dstDir:L,linkType:Ee,realLocatorHash:we.locatorHash})}}}}for(let[U,{locations:J}]of e.entries())for(let W of J){let{locationRoot:ee,segments:Z}=fb(W,{skipPrefix:i.cwd}),A=l.get(ee),ne=u.get(ee),le=ee,Ae=e.get(U),T=P.parseLocator(U);P.isVirtualLocator(T)&&(T=P.devirtualizeLocator(T));let L=T.locatorHash,Ee=Ae.target,we=W;if(Ee===we)continue;let qe=Ae.linkType;for(let re of Z)ne=ne.children.get(re);if(!A)Q.push({srcDir:Ee,dstDir:we,linkType:qe,realLocatorHash:L});else for(let re of Z)if(le=k.join(le,re),A=A.children.get(re),!A){Q.push({srcDir:Ee,dstDir:we,linkType:qe,realLocatorHash:L});break}}let S=Ji.progressViaCounter(Q.length),x=n.reportProgress(S),M=i.configuration.get("nmMode"),Y={value:M};try{let U=Y.value===Li.HARDLINKS_GLOBAL?`${cT(i.configuration)}/v1`:null;if(U&&!await K.existsPromise(U)){await K.mkdirpPromise(U);for(let W=0;W<256;W++)await K.mkdirPromise(k.join(U,W.toString(16).padStart(2,"0")))}for(let W of Q)(W.linkType===Qt.SOFT||!y.has(W.srcDir))&&(y.set(W.srcDir,W.dstDir),await f(te(N({},W),{globalHardlinksStore:U,nmMode:Y,packageChecksum:o.get(W.realLocatorHash)||null})));await Promise.all(g),g.length=0;for(let W of Q){let ee=y.get(W.srcDir);W.linkType!==Qt.SOFT&&W.dstDir!==ee&&await h(ee,W.dstDir,{nmMode:Y})}await Promise.all(g),await K.mkdirPromise(a,{recursive:!0});let J=await H_e(e,u,i.cwd,{loadManifest:s});await j_e(c,J,i.cwd),await O_e(i,e,J,Y),M==Li.HARDLINKS_GLOBAL&&Y.value==Li.HARDLINKS_LOCAL&&n.reportWarningOnce($.NM_HARDLINKS_MODE_DOWNGRADED,"'nmMode' has been downgraded to 'hardlinks-local' due to global cache and install folder being on different devices")}finally{x.stop()}}async function j_e(t,e,r){for(let i of t.keys()){if(k.contains(r,i)===null)throw new Error(`Assertion failed. Excepted bin symlink location to be inside project dir, instead it was at ${i}`);if(!e.has(i)){let n=k.join(i,ai,oT);await K.removePromise(n)}}for(let[i,n]of e){if(k.contains(r,i)===null)throw new Error(`Assertion failed. Excepted bin symlink location to be inside project dir, instead it was at ${i}`);let s=k.join(i,ai,oT),o=t.get(i)||new Map;await K.mkdirPromise(s,{recursive:!0});for(let a of o.keys())n.has(a)||(await K.removePromise(k.join(s,a)),process.platform==="win32"&&await K.removePromise(k.join(s,qr(`${a}.cmd`))));for(let[a,l]of n){let c=o.get(a),u=k.join(s,a);c!==l&&(process.platform==="win32"?await(0,Zle.default)(j.fromPortablePath(l),j.fromPortablePath(u),{createPwshFile:!1}):(await K.removePromise(u),await lT(l,u),k.contains(r,await K.realpathPromise(l))!==null&&await K.chmodPromise(l,493)))}}}var uT=class extends Qu{constructor(){super(...arguments);this.mode="loose"}makeInstaller(e){return new Ace(e)}},Ace=class extends sh{constructor(){super(...arguments);this.mode="loose"}async transformPnpSettings(e){let r=new Jr({baseFs:new ms({libzip:await fn(),maxOpenFiles:80,readOnlyArchives:!0})}),i=Ole(e,this.opts.project.cwd,r),{tree:n,errors:s}=Gm(i,{pnpifyFs:!1,project:this.opts.project});if(!n){for(let{messageName:u,text:g}of s)this.opts.report.reportError(u,g);return}let o=new Map;e.fallbackPool=o;let a=(u,g)=>{let f=P.parseLocator(g.locator),h=P.stringifyIdent(f);h===u?o.set(u,f.reference):o.set(u,[h,f.reference])},l=k.join(this.opts.project.cwd,Pt.nodeModules),c=n.get(l);if(typeof c!="undefined"){if("target"in c)throw new Error("Assertion failed: Expected the root junction point to be a directory");for(let u of c.dirList){let g=k.join(l,u),f=n.get(g);if(typeof f=="undefined")throw new Error("Assertion failed: Expected the child to have been registered");if("target"in f)a(u,f);else for(let h of f.dirList){let p=k.join(g,h),m=n.get(p);if(typeof m=="undefined")throw new Error("Assertion failed: Expected the subchild to have been registered");if("target"in m)a(`${u}/${h}`,m);else throw new Error("Assertion failed: Expected the leaf junction to be a package")}}}}};var G_e={hooks:{cleanGlobalArtifacts:async t=>{let e=cT(t);await K.removePromise(e)}},configuration:{nmHoistingLimits:{description:"Prevent packages to be hoisted past specific levels",type:Ie.STRING,values:[Mn.WORKSPACES,Mn.DEPENDENCIES,Mn.NONE],default:Mn.NONE},nmMode:{description:'If set to "hardlinks-local" Yarn will utilize hardlinks to reduce disk space consumption inside "node_modules" directories. With "hardlinks-global" Yarn will use global content addressable storage to reduce "node_modules" size across all the projects using this option.',type:Ie.STRING,values:[Li.CLASSIC,Li.HARDLINKS_LOCAL,Li.HARDLINKS_GLOBAL],default:Li.CLASSIC},nmSelfReferences:{description:"If set to 'false' the workspace will not be allowed to require itself and corresponding self-referencing symlink will not be created",type:Ie.BOOLEAN,default:!0}},linkers:[aT,uT]},Y_e=G_e;var uO={};ft(uO,{default:()=>ZVe,npmConfigUtils:()=>br,npmHttpUtils:()=>zt,npmPublishUtils:()=>yh});var fce=ge(ti());var Cr="npm:";var zt={};ft(zt,{AuthType:()=>cs,customPackageError:()=>W_e,del:()=>V_e,get:()=>Bo,getIdentUrl:()=>Fl,handleInvalidAuthenticationError:()=>Rl,post:()=>z_e,put:()=>__e});var uce=ge($C()),gce=ge(require("url"));var br={};ft(br,{RegistryType:()=>yA,getAuditRegistry:()=>q_e,getAuthConfiguration:()=>hT,getDefaultRegistry:()=>hb,getPublishRegistry:()=>lce,getRegistryConfiguration:()=>cce,getScopeConfiguration:()=>fT,getScopeRegistry:()=>wA,normalizeRegistry:()=>ha});var yA;(function(i){i.AUDIT_REGISTRY="npmAuditRegistry",i.FETCH_REGISTRY="npmRegistryServer",i.PUBLISH_REGISTRY="npmPublishRegistry"})(yA||(yA={}));function ha(t){return t.replace(/\/$/,"")}function q_e(t,{configuration:e}){let r=e.get(yA.AUDIT_REGISTRY);return r!==null?ha(r):lce(t,{configuration:e})}function lce(t,{configuration:e}){var r;return((r=t.publishConfig)==null?void 0:r.registry)?ha(t.publishConfig.registry):t.name?wA(t.name.scope,{configuration:e,type:yA.PUBLISH_REGISTRY}):hb({configuration:e,type:yA.PUBLISH_REGISTRY})}function wA(t,{configuration:e,type:r=yA.FETCH_REGISTRY}){let i=fT(t,{configuration:e});if(i===null)return hb({configuration:e,type:r});let n=i.get(r);return n===null?hb({configuration:e,type:r}):ha(n)}function hb({configuration:t,type:e=yA.FETCH_REGISTRY}){let r=t.get(e);return ha(r!==null?r:t.get(yA.FETCH_REGISTRY))}function cce(t,{configuration:e}){let r=e.get("npmRegistries"),i=ha(t),n=r.get(i);if(typeof n!="undefined")return n;let s=r.get(i.replace(/^[a-z]+:/,""));return typeof s!="undefined"?s:null}function fT(t,{configuration:e}){if(t===null)return null;let i=e.get("npmScopes").get(t);return i||null}function hT(t,{configuration:e,ident:r}){let i=r&&fT(r.scope,{configuration:e});return(i==null?void 0:i.get("npmAuthIdent"))||(i==null?void 0:i.get("npmAuthToken"))?i:cce(t,{configuration:e})||e}var cs;(function(n){n[n.NO_AUTH=0]="NO_AUTH",n[n.BEST_EFFORT=1]="BEST_EFFORT",n[n.CONFIGURATION=2]="CONFIGURATION",n[n.ALWAYS_AUTH=3]="ALWAYS_AUTH"})(cs||(cs={}));async function Rl(t,{attemptedAs:e,registry:r,headers:i,configuration:n}){var s,o;if(pb(t))throw new ct($.AUTHENTICATION_INVALID,"Invalid OTP token");if(((s=t.originalError)==null?void 0:s.name)==="HTTPError"&&((o=t.originalError)==null?void 0:o.response.statusCode)===401)throw new ct($.AUTHENTICATION_INVALID,`Invalid authentication (${typeof e!="string"?`as ${await J_e(r,i,{configuration:n})}`:`attempted as ${e}`})`)}function W_e(t){var e;return((e=t.response)==null?void 0:e.statusCode)===404?"Package not found":null}function Fl(t){return t.scope?`/@${t.scope}%2f${t.name}`:`/${t.name}`}async function Bo(t,a){var l=a,{configuration:e,headers:r,ident:i,authType:n,registry:s}=l,o=Tr(l,["configuration","headers","ident","authType","registry"]);if(i&&typeof s=="undefined"&&(s=wA(i.scope,{configuration:e})),i&&i.scope&&typeof n=="undefined"&&(n=1),typeof s!="string")throw new Error("Assertion failed: The registry should be a string");let c=await db(s,{authType:n,configuration:e,ident:i});c&&(r=te(N({},r),{authorization:c}));try{return await ir.get(t.charAt(0)==="/"?`${s}${t}`:t,N({configuration:e,headers:r},o))}catch(u){throw await Rl(u,{registry:s,configuration:e,headers:r}),u}}async function z_e(t,e,u){var g=u,{attemptedAs:r,configuration:i,headers:n,ident:s,authType:o=3,registry:a,otp:l}=g,c=Tr(g,["attemptedAs","configuration","headers","ident","authType","registry","otp"]);if(s&&typeof a=="undefined"&&(a=wA(s.scope,{configuration:i})),typeof a!="string")throw new Error("Assertion failed: The registry should be a string");let f=await db(a,{authType:o,configuration:i,ident:s});f&&(n=te(N({},n),{authorization:f})),l&&(n=N(N({},n),Ah(l)));try{return await ir.post(a+t,e,N({configuration:i,headers:n},c))}catch(h){if(!pb(h)||l)throw await Rl(h,{attemptedAs:r,registry:a,configuration:i,headers:n}),h;l=await pT();let p=N(N({},n),Ah(l));try{return await ir.post(`${a}${t}`,e,N({configuration:i,headers:p},c))}catch(m){throw await Rl(m,{attemptedAs:r,registry:a,configuration:i,headers:n}),m}}}async function __e(t,e,u){var g=u,{attemptedAs:r,configuration:i,headers:n,ident:s,authType:o=3,registry:a,otp:l}=g,c=Tr(g,["attemptedAs","configuration","headers","ident","authType","registry","otp"]);if(s&&typeof a=="undefined"&&(a=wA(s.scope,{configuration:i})),typeof a!="string")throw new Error("Assertion failed: The registry should be a string");let f=await db(a,{authType:o,configuration:i,ident:s});f&&(n=te(N({},n),{authorization:f})),l&&(n=N(N({},n),Ah(l)));try{return await ir.put(a+t,e,N({configuration:i,headers:n},c))}catch(h){if(!pb(h))throw await Rl(h,{attemptedAs:r,registry:a,configuration:i,headers:n}),h;l=await pT();let p=N(N({},n),Ah(l));try{return await ir.put(`${a}${t}`,e,N({configuration:i,headers:p},c))}catch(m){throw await Rl(m,{attemptedAs:r,registry:a,configuration:i,headers:n}),m}}}async function V_e(t,c){var u=c,{attemptedAs:e,configuration:r,headers:i,ident:n,authType:s=3,registry:o,otp:a}=u,l=Tr(u,["attemptedAs","configuration","headers","ident","authType","registry","otp"]);if(n&&typeof o=="undefined"&&(o=wA(n.scope,{configuration:r})),typeof o!="string")throw new Error("Assertion failed: The registry should be a string");let g=await db(o,{authType:s,configuration:r,ident:n});g&&(i=te(N({},i),{authorization:g})),a&&(i=N(N({},i),Ah(a)));try{return await ir.del(o+t,N({configuration:r,headers:i},l))}catch(f){if(!pb(f)||a)throw await Rl(f,{attemptedAs:e,registry:o,configuration:r,headers:i}),f;a=await pT();let h=N(N({},i),Ah(a));try{return await ir.del(`${o}${t}`,N({configuration:r,headers:h},l))}catch(p){throw await Rl(p,{attemptedAs:e,registry:o,configuration:r,headers:i}),p}}}async function db(t,{authType:e=2,configuration:r,ident:i}){let n=hT(t,{configuration:r,ident:i}),s=X_e(n,e);if(!s)return null;let o=await r.reduceHook(a=>a.getNpmAuthenticationHeader,void 0,t,{configuration:r,ident:i});if(o)return o;if(n.get("npmAuthToken"))return`Bearer ${n.get("npmAuthToken")}`;if(n.get("npmAuthIdent")){let a=n.get("npmAuthIdent");return a.includes(":")?`Basic ${Buffer.from(a).toString("base64")}`:`Basic ${a}`}if(s&&e!==1)throw new ct($.AUTHENTICATION_NOT_FOUND,"No authentication configured for request");return null}function X_e(t,e){switch(e){case 2:return t.get("npmAlwaysAuth");case 1:case 3:return!0;case 0:return!1;default:throw new Error("Unreachable")}}async function J_e(t,e,{configuration:r}){var i;if(typeof e=="undefined"||typeof e.authorization=="undefined")return"an anonymous user";try{return(i=(await ir.get(new gce.URL(`${t}/-/whoami`).href,{configuration:r,headers:e,jsonResponse:!0})).username)!=null?i:"an unknown user"}catch{return"an unknown user"}}async function pT(){if(process.env.TEST_ENV)return process.env.TEST_NPM_2FA_TOKEN||"";let{otp:t}=await(0,uce.prompt)({type:"password",name:"otp",message:"One-time password:",required:!0,onCancel:()=>process.exit(130)});return t}function pb(t){var e,r;if(((e=t.originalError)==null?void 0:e.name)!=="HTTPError")return!1;try{return((r=t.originalError)==null?void 0:r.response.headers["www-authenticate"].split(/,\s*/).map(n=>n.toLowerCase())).includes("otp")}catch(i){return!1}}function Ah(t){return{["npm-otp"]:t}}var dT=class{supports(e,r){if(!e.reference.startsWith(Cr))return!1;let{selector:i,params:n}=P.parseRange(e.reference);return!(!fce.default.valid(i)||n===null||typeof n.__archiveUrl!="string")}getLocalPath(e,r){return null}async fetch(e,r){let i=r.checksums.get(e.locatorHash)||null,[n,s,o]=await r.cache.fetchPackageFromCache(e,i,N({onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${P.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote server`),loader:()=>this.fetchFromNetwork(e,r),skipIntegrityCheck:r.skipIntegrityCheck},r.cacheOptions));return{packageFs:n,releaseFs:s,prefixPath:P.getIdentVendorPath(e),checksum:o}}async fetchFromNetwork(e,r){let{params:i}=P.parseRange(e.reference);if(i===null||typeof i.__archiveUrl!="string")throw new Error("Assertion failed: The archiveUrl querystring parameter should have been available");let n=await Bo(i.__archiveUrl,{configuration:r.project.configuration,ident:e});return await wi.convertToZip(n,{compressionLevel:r.project.configuration.get("compressionLevel"),prefixPath:P.getIdentVendorPath(e),stripComponents:1})}};var CT=class{supportsDescriptor(e,r){return!(!e.range.startsWith(Cr)||!P.tryParseDescriptor(e.range.slice(Cr.length),!0))}supportsLocator(e,r){return!1}shouldPersistResolution(e,r){throw new Error("Unreachable")}bindDescriptor(e,r,i){return e}getResolutionDependencies(e,r){let i=P.parseDescriptor(e.range.slice(Cr.length),!0);return r.resolver.getResolutionDependencies(i,r)}async getCandidates(e,r,i){let n=P.parseDescriptor(e.range.slice(Cr.length),!0);return await i.resolver.getCandidates(n,r,i)}async getSatisfying(e,r,i){let n=P.parseDescriptor(e.range.slice(Cr.length),!0);return i.resolver.getSatisfying(n,r,i)}resolve(e,r){throw new Error("Unreachable")}};var hce=ge(ti()),pce=ge(require("url"));var bo=class{supports(e,r){if(!e.reference.startsWith(Cr))return!1;let i=new pce.URL(e.reference);return!(!hce.default.valid(i.pathname)||i.searchParams.has("__archiveUrl"))}getLocalPath(e,r){return null}async fetch(e,r){let i=r.checksums.get(e.locatorHash)||null,[n,s,o]=await r.cache.fetchPackageFromCache(e,i,N({onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${P.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote registry`),loader:()=>this.fetchFromNetwork(e,r),skipIntegrityCheck:r.skipIntegrityCheck},r.cacheOptions));return{packageFs:n,releaseFs:s,prefixPath:P.getIdentVendorPath(e),checksum:o}}async fetchFromNetwork(e,r){let i;try{i=await Bo(bo.getLocatorUrl(e),{configuration:r.project.configuration,ident:e})}catch(n){i=await Bo(bo.getLocatorUrl(e).replace(/%2f/g,"/"),{configuration:r.project.configuration,ident:e})}return await wi.convertToZip(i,{compressionLevel:r.project.configuration.get("compressionLevel"),prefixPath:P.getIdentVendorPath(e),stripComponents:1})}static isConventionalTarballUrl(e,r,{configuration:i}){let n=wA(e.scope,{configuration:i}),s=bo.getLocatorUrl(e);return r=r.replace(/^https?:(\/\/(?:[^/]+\.)?npmjs.org(?:$|\/))/,"https:$1"),n=n.replace(/^https:\/\/registry\.npmjs\.org($|\/)/,"https://registry.yarnpkg.com$1"),r=r.replace(/^https:\/\/registry\.npmjs\.org($|\/)/,"https://registry.yarnpkg.com$1"),r===n+s||r===n+s.replace(/%2f/g,"/")}static getLocatorUrl(e){let r=Wt.clean(e.reference.slice(Cr.length));if(r===null)throw new ct($.RESOLVER_NOT_FOUND,"The npm semver resolver got selected, but the version isn't semver");return`${Fl(e)}/-/${e.name}-${r}.tgz`}};var dce=ge(ti());var Cb=P.makeIdent(null,"node-gyp"),Z_e=/\b(node-gyp|prebuild-install)\b/,mT=class{supportsDescriptor(e,r){return e.range.startsWith(Cr)?!!Wt.validRange(e.range.slice(Cr.length)):!1}supportsLocator(e,r){if(!e.reference.startsWith(Cr))return!1;let{selector:i}=P.parseRange(e.reference);return!!dce.default.valid(i)}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,i){return e}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){let n=Wt.validRange(e.range.slice(Cr.length));if(n===null)throw new Error(`Expected a valid range, got ${e.range.slice(Cr.length)}`);let s=await Bo(Fl(e),{configuration:i.project.configuration,ident:e,jsonResponse:!0}),o=Se.mapAndFilter(Object.keys(s.versions),c=>{try{let u=new Wt.SemVer(c);if(n.test(u))return u}catch{}return Se.mapAndFilter.skip}),a=o.filter(c=>!s.versions[c.raw].deprecated),l=a.length>0?a:o;return l.sort((c,u)=>-c.compare(u)),l.map(c=>{let u=P.makeLocator(e,`${Cr}${c.raw}`),g=s.versions[c.raw].dist.tarball;return bo.isConventionalTarballUrl(u,g,{configuration:i.project.configuration})?u:P.bindLocator(u,{__archiveUrl:g})})}async getSatisfying(e,r,i){let n=Wt.validRange(e.range.slice(Cr.length));if(n===null)throw new Error(`Expected a valid range, got ${e.range.slice(Cr.length)}`);return Se.mapAndFilter(r,s=>{try{let{selector:o}=P.parseRange(s,{requireProtocol:Cr}),a=new Wt.SemVer(o);if(n.test(a))return{reference:s,version:a}}catch{}return Se.mapAndFilter.skip}).sort((s,o)=>-s.version.compare(o.version)).map(({reference:s})=>P.makeLocator(e,s))}async resolve(e,r){let{selector:i}=P.parseRange(e.reference),n=Wt.clean(i);if(n===null)throw new ct($.RESOLVER_NOT_FOUND,"The npm semver resolver got selected, but the version isn't semver");let s=await Bo(Fl(e),{configuration:r.project.configuration,ident:e,jsonResponse:!0});if(!Object.prototype.hasOwnProperty.call(s,"versions"))throw new ct($.REMOTE_INVALID,'Registry returned invalid data for - missing "versions" field');if(!Object.prototype.hasOwnProperty.call(s.versions,n))throw new ct($.REMOTE_NOT_FOUND,`Registry failed to return reference "${n}"`);let o=new At;if(o.load(s.versions[n]),!o.dependencies.has(Cb.identHash)&&!o.peerDependencies.has(Cb.identHash)){for(let a of o.scripts.values())if(a.match(Z_e)){o.dependencies.set(Cb.identHash,P.makeDescriptor(Cb,"latest")),r.report.reportWarningOnce($.NODE_GYP_INJECTED,`${P.prettyLocator(r.project.configuration,e)}: Implicit dependencies on node-gyp are discouraged`);break}}if(typeof o.raw.deprecated=="string"&&o.raw.deprecated!==""){let a=P.prettyLocator(r.project.configuration,e),l=o.raw.deprecated.match(/\S/)?`${a} is deprecated: ${o.raw.deprecated}`:`${a} is deprecated`;r.report.reportWarningOnce($.DEPRECATED_PACKAGE,l)}return te(N({},e),{version:n,languageName:"node",linkType:Qt.HARD,conditions:o.getConditions(),dependencies:o.dependencies,peerDependencies:o.peerDependencies,dependenciesMeta:o.dependenciesMeta,peerDependenciesMeta:o.peerDependenciesMeta,bin:o.bin})}};var ET=class{supportsDescriptor(e,r){return!(!e.range.startsWith(Cr)||!Gg.test(e.range.slice(Cr.length)))}supportsLocator(e,r){return!1}shouldPersistResolution(e,r){throw new Error("Unreachable")}bindDescriptor(e,r,i){return e}getResolutionDependencies(e,r){return[]}async getCandidates(e,r,i){let n=e.range.slice(Cr.length),s=await Bo(Fl(e),{configuration:i.project.configuration,ident:e,jsonResponse:!0});if(!Object.prototype.hasOwnProperty.call(s,"dist-tags"))throw new ct($.REMOTE_INVALID,'Registry returned invalid data - missing "dist-tags" field');let o=s["dist-tags"];if(!Object.prototype.hasOwnProperty.call(o,n))throw new ct($.REMOTE_NOT_FOUND,`Registry failed to return tag "${n}"`);let a=o[n],l=P.makeLocator(e,`${Cr}${a}`),c=s.versions[a].dist.tarball;return bo.isConventionalTarballUrl(l,c,{configuration:i.project.configuration})?[l]:[P.bindLocator(l,{__archiveUrl:c})]}async getSatisfying(e,r,i){return null}async resolve(e,r){throw new Error("Unreachable")}};var yh={};ft(yh,{getGitHead:()=>VVe,makePublishBody:()=>_Ve});var aO={};ft(aO,{default:()=>DVe,packUtils:()=>vA});var vA={};ft(vA,{genPackList:()=>Ub,genPackStream:()=>oO,genPackageManifest:()=>Yue,hasPackScripts:()=>nO,prepareForPack:()=>sO});var iO=ge(ts()),jue=ge(Hue()),Gue=ge(require("zlib")),IVe=["/package.json","/readme","/readme.*","/license","/license.*","/licence","/licence.*","/changelog","/changelog.*"],yVe=["/package.tgz",".github",".git",".hg","node_modules",".npmignore",".gitignore",".#*",".DS_Store"];async function nO(t){return!!(Zt.hasWorkspaceScript(t,"prepack")||Zt.hasWorkspaceScript(t,"postpack"))}async function sO(t,{report:e},r){await Zt.maybeExecuteWorkspaceLifecycleScript(t,"prepack",{report:e});try{let i=k.join(t.cwd,At.fileName);await K.existsPromise(i)&&await t.manifest.loadFile(i,{baseFs:K}),await r()}finally{await Zt.maybeExecuteWorkspaceLifecycleScript(t,"postpack",{report:e})}}async function oO(t,e){var s,o;typeof e=="undefined"&&(e=await Ub(t));let r=new Set;for(let a of(o=(s=t.manifest.publishConfig)==null?void 0:s.executableFiles)!=null?o:new Set)r.add(k.normalize(a));for(let a of t.manifest.bin.values())r.add(k.normalize(a));let i=jue.default.pack();process.nextTick(async()=>{for(let a of e){let l=k.normalize(a),c=k.resolve(t.cwd,l),u=k.join("package",l),g=await K.lstatPromise(c),f={name:u,mtime:new Date(Dr.SAFE_TIME*1e3)},h=r.has(l)?493:420,p,m,y=new Promise((S,x)=>{p=S,m=x}),Q=S=>{S?m(S):p()};if(g.isFile()){let S;l==="package.json"?S=Buffer.from(JSON.stringify(await Yue(t),null,2)):S=await K.readFilePromise(c),i.entry(te(N({},f),{mode:h,type:"file"}),S,Q)}else g.isSymbolicLink()?i.entry(te(N({},f),{mode:h,type:"symlink",linkname:await K.readlinkPromise(c)}),Q):Q(new Error(`Unsupported file type ${g.mode} for ${j.fromPortablePath(l)}`));await y}i.finalize()});let n=(0,Gue.createGzip)();return i.pipe(n),n}async function Yue(t){let e=JSON.parse(JSON.stringify(t.manifest.raw));return await t.project.configuration.triggerHook(r=>r.beforeWorkspacePacking,t,e),e}async function Ub(t){var g,f,h,p,m,y,Q,S;let e=t.project,r=e.configuration,i={accept:[],reject:[]};for(let x of yVe)i.reject.push(x);for(let x of IVe)i.accept.push(x);i.reject.push(r.get("rcFilename"));let n=x=>{if(x===null||!x.startsWith(`${t.cwd}/`))return;let M=k.relative(t.cwd,x),Y=k.resolve(Me.root,M);i.reject.push(Y)};n(k.resolve(e.cwd,r.get("lockfileFilename"))),n(r.get("cacheFolder")),n(r.get("globalFolder")),n(r.get("installStatePath")),n(r.get("virtualFolder")),n(r.get("yarnPath")),await r.triggerHook(x=>x.populateYarnPaths,e,x=>{n(x)});for(let x of e.workspaces){let M=k.relative(t.cwd,x.cwd);M!==""&&!M.match(/^(\.\.)?\//)&&i.reject.push(`/${M}`)}let s={accept:[],reject:[]},o=(f=(g=t.manifest.publishConfig)==null?void 0:g.main)!=null?f:t.manifest.main,a=(p=(h=t.manifest.publishConfig)==null?void 0:h.module)!=null?p:t.manifest.module,l=(y=(m=t.manifest.publishConfig)==null?void 0:m.browser)!=null?y:t.manifest.browser,c=(S=(Q=t.manifest.publishConfig)==null?void 0:Q.bin)!=null?S:t.manifest.bin;o!=null&&s.accept.push(k.resolve(Me.root,o)),a!=null&&s.accept.push(k.resolve(Me.root,a)),typeof l=="string"&&s.accept.push(k.resolve(Me.root,l));for(let x of c.values())s.accept.push(k.resolve(Me.root,x));if(l instanceof Map)for(let[x,M]of l.entries())s.accept.push(k.resolve(Me.root,x)),typeof M=="string"&&s.accept.push(k.resolve(Me.root,M));let u=t.manifest.files!==null;if(u){s.reject.push("/*");for(let x of t.manifest.files)que(s.accept,x,{cwd:Me.root})}return await wVe(t.cwd,{hasExplicitFileList:u,globalList:i,ignoreList:s})}async function wVe(t,{hasExplicitFileList:e,globalList:r,ignoreList:i}){let n=[],s=new Da(t),o=[[Me.root,[i]]];for(;o.length>0;){let[a,l]=o.pop(),c=await s.lstatPromise(a);if(!Wue(a,{globalList:r,ignoreLists:c.isDirectory()?null:l}))if(c.isDirectory()){let u=await s.readdirPromise(a),g=!1,f=!1;if(!e||a!==Me.root)for(let m of u)g=g||m===".gitignore",f=f||m===".npmignore";let h=f?await Jue(s,a,".npmignore"):g?await Jue(s,a,".gitignore"):null,p=h!==null?[h].concat(l):l;Wue(a,{globalList:r,ignoreLists:l})&&(p=[...l,{accept:[],reject:["**/*"]}]);for(let m of u)o.push([k.resolve(a,m),p])}else(c.isFile()||c.isSymbolicLink())&&n.push(k.relative(Me.root,a))}return n.sort()}async function Jue(t,e,r){let i={accept:[],reject:[]},n=await t.readFilePromise(k.join(e,r),"utf8");for(let s of n.split(/\n/g))que(i.reject,s,{cwd:e});return i}function BVe(t,{cwd:e}){let r=t[0]==="!";return r&&(t=t.slice(1)),t.match(/\.{0,1}\//)&&(t=k.resolve(e,t)),r&&(t=`!${t}`),t}function que(t,e,{cwd:r}){let i=e.trim();i===""||i[0]==="#"||t.push(BVe(i,{cwd:r}))}function Wue(t,{globalList:e,ignoreLists:r}){if(Kb(t,e.accept))return!1;if(Kb(t,e.reject))return!0;if(r!==null)for(let i of r){if(Kb(t,i.accept))return!1;if(Kb(t,i.reject))return!0}return!1}function Kb(t,e){let r=e,i=[];for(let n=0;n<e.length;++n)e[n][0]!=="!"?r!==e&&r.push(e[n]):(r===e&&(r=e.slice(0,n)),i.push(e[n].slice(1)));return zue(t,i)?!1:!!zue(t,r)}function zue(t,e){let r=e,i=[];for(let n=0;n<e.length;++n)e[n].includes("/")?r!==e&&r.push(e[n]):(r===e&&(r=e.slice(0,n)),i.push(e[n]));return!!(iO.default.isMatch(t,r,{dot:!0,nocase:!0})||iO.default.isMatch(t,i,{dot:!0,basename:!0,nocase:!0}))}var AE=class extends Le{constructor(){super(...arguments);this.installIfNeeded=z.Boolean("--install-if-needed",!1,{description:"Run a preliminary `yarn install` if the package contains build scripts"});this.dryRun=z.Boolean("-n,--dry-run",!1,{description:"Print the file paths without actually generating the package archive"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.out=z.String("-o,--out",{description:"Create the archive at the specified path"});this.filename=z.String("--filename",{hidden:!0})}async execute(){var a;let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd);if(!i)throw new ht(r.cwd,this.context.cwd);await nO(i)&&(this.installIfNeeded?await r.install({cache:await Nt.find(e),report:new pi}):await r.restoreInstallState());let n=(a=this.out)!=null?a:this.filename,s=typeof n!="undefined"?k.resolve(this.context.cwd,bVe(n,{workspace:i})):k.resolve(i.cwd,"package.tgz");return(await Je.start({configuration:e,stdout:this.context.stdout,json:this.json},async l=>{await sO(i,{report:l},async()=>{l.reportJson({base:j.fromPortablePath(i.cwd)});let c=await Ub(i);for(let u of c)l.reportInfo(null,j.fromPortablePath(u)),l.reportJson({location:j.fromPortablePath(u)});if(!this.dryRun){let u=await oO(i,c),g=K.createWriteStream(s);u.pipe(g),await new Promise(f=>{g.on("finish",f)})}}),this.dryRun||(l.reportInfo($.UNNAMED,`Package archive generated in ${ae.pretty(e,s,ae.Type.PATH)}`),l.reportJson({output:j.fromPortablePath(s)}))})).exitCode()}};AE.paths=[["pack"]],AE.usage=Re.Usage({description:"generate a tarball from the active workspace",details:"\n      This command will turn the active workspace into a compressed archive suitable for publishing. The archive will by default be stored at the root of the workspace (`package.tgz`).\n\n      If the `-o,---out` is set the archive will be created at the specified path. The `%s` and `%v` variables can be used within the path and will be respectively replaced by the package name and version.\n    ",examples:[["Create an archive from the active workspace","yarn pack"],["List the files that would be made part of the workspace's archive","yarn pack --dry-run"],["Name and output the archive in a dedicated folder","yarn pack --out /artifacts/%s-%v.tgz"]]});var _ue=AE;function bVe(t,{workspace:e}){let r=t.replace("%s",QVe(e)).replace("%v",vVe(e));return j.toPortablePath(r)}function QVe(t){return t.manifest.name!==null?P.slugifyIdent(t.manifest.name):"package"}function vVe(t){return t.manifest.version!==null?t.manifest.version:"unknown"}var SVe=["dependencies","devDependencies","peerDependencies"],kVe="workspace:",xVe=(t,e)=>{var i,n;e.publishConfig&&(e.publishConfig.main&&(e.main=e.publishConfig.main),e.publishConfig.browser&&(e.browser=e.publishConfig.browser),e.publishConfig.module&&(e.module=e.publishConfig.module),e.publishConfig.browser&&(e.browser=e.publishConfig.browser),e.publishConfig.exports&&(e.exports=e.publishConfig.exports),e.publishConfig.bin&&(e.bin=e.publishConfig.bin));let r=t.project;for(let s of SVe)for(let o of t.manifest.getForScope(s).values()){let a=r.tryWorkspaceByDescriptor(o),l=P.parseRange(o.range);if(l.protocol===kVe)if(a===null){if(r.tryWorkspaceByIdent(o)===null)throw new ct($.WORKSPACE_NOT_FOUND,`${P.prettyDescriptor(r.configuration,o)}: No local workspace found for this range`)}else{let c;P.areDescriptorsEqual(o,a.anchoredDescriptor)||l.selector==="*"?c=(i=a.manifest.version)!=null?i:"0.0.0":l.selector==="~"||l.selector==="^"?c=`${l.selector}${(n=a.manifest.version)!=null?n:"0.0.0"}`:c=l.selector;let u=s==="dependencies"?P.makeDescriptor(o,"unknown"):null,g=u!==null&&t.manifest.ensureDependencyMeta(u).optional?"optionalDependencies":s;e[g][P.stringifyIdent(o)]=c}}},PVe={hooks:{beforeWorkspacePacking:xVe},commands:[_ue]},DVe=PVe;var nge=ge(require("crypto")),sge=ge(ige()),oge=ge(require("url"));async function _Ve(t,e,{access:r,tag:i,registry:n,gitHead:s}){let o=t.project.configuration,a=t.manifest.name,l=t.manifest.version,c=P.stringifyIdent(a),u=(0,nge.createHash)("sha1").update(e).digest("hex"),g=sge.default.fromData(e).toString();typeof r=="undefined"&&(t.manifest.publishConfig&&typeof t.manifest.publishConfig.access=="string"?r=t.manifest.publishConfig.access:o.get("npmPublishAccess")!==null?r=o.get("npmPublishAccess"):a.scope?r="restricted":r="public");let f=await vA.genPackageManifest(t),h=`${c}-${l}.tgz`,p=new oge.URL(`${ha(n)}/${c}/-/${h}`);return{_id:c,_attachments:{[h]:{content_type:"application/octet-stream",data:e.toString("base64"),length:e.length}},name:c,access:r,["dist-tags"]:{[i]:l},versions:{[l]:te(N({},f),{_id:`${c}@${l}`,name:c,version:l,gitHead:s,dist:{shasum:u,integrity:g,tarball:p.toString()}})}}}async function VVe(t){try{let{stdout:e}=await Fr.execvp("git",["rev-parse","--revs-only","HEAD"],{cwd:t});return e.trim()===""?void 0:e.trim()}catch{return}}var gO={npmAlwaysAuth:{description:"URL of the selected npm registry (note: npm enterprise isn't supported)",type:Ie.BOOLEAN,default:!1},npmAuthIdent:{description:"Authentication identity for the npm registry (_auth in npm and yarn v1)",type:Ie.SECRET,default:null},npmAuthToken:{description:"Authentication token for the npm registry (_authToken in npm and yarn v1)",type:Ie.SECRET,default:null}},age={npmAuditRegistry:{description:"Registry to query for audit reports",type:Ie.STRING,default:null},npmPublishRegistry:{description:"Registry to push packages to",type:Ie.STRING,default:null},npmRegistryServer:{description:"URL of the selected npm registry (note: npm enterprise isn't supported)",type:Ie.STRING,default:"https://registry.yarnpkg.com"}},XVe={configuration:te(N(N({},gO),age),{npmScopes:{description:"Settings per package scope",type:Ie.MAP,valueDefinition:{description:"",type:Ie.SHAPE,properties:N(N({},gO),age)}},npmRegistries:{description:"Settings per registry",type:Ie.MAP,normalizeKeys:ha,valueDefinition:{description:"",type:Ie.SHAPE,properties:N({},gO)}}}),fetchers:[dT,bo],resolvers:[CT,mT,ET]},ZVe=XVe;var dO={};ft(dO,{default:()=>a9e});Es();var Ea;(function(i){i.All="all",i.Production="production",i.Development="development"})(Ea||(Ea={}));var vo;(function(s){s.Info="info",s.Low="low",s.Moderate="moderate",s.High="high",s.Critical="critical"})(vo||(vo={}));var Hb=[vo.Info,vo.Low,vo.Moderate,vo.High,vo.Critical];function Age(t,e){let r=[],i=new Set,n=o=>{i.has(o)||(i.add(o),r.push(o))};for(let o of e)n(o);let s=new Set;for(;r.length>0;){let o=r.shift(),a=t.storedResolutions.get(o);if(typeof a=="undefined")throw new Error("Assertion failed: Expected the resolution to have been registered");let l=t.storedPackages.get(a);if(!!l){s.add(o);for(let c of l.dependencies.values())n(c.descriptorHash)}}return s}function $Ve(t,e){return new Set([...t].filter(r=>!e.has(r)))}function e9e(t,e,{all:r}){let i=r?t.workspaces:[e],n=i.map(f=>f.manifest),s=new Set(n.map(f=>[...f.dependencies].map(([h,p])=>h)).flat()),o=new Set(n.map(f=>[...f.devDependencies].map(([h,p])=>h)).flat()),a=i.map(f=>[...f.dependencies.values()]).flat(),l=a.filter(f=>s.has(f.identHash)).map(f=>f.descriptorHash),c=a.filter(f=>o.has(f.identHash)).map(f=>f.descriptorHash),u=Age(t,l),g=Age(t,c);return $Ve(g,u)}function lge(t){let e={};for(let r of t)e[P.stringifyIdent(r)]=P.parseRange(r.range).selector;return e}function cge(t){if(typeof t=="undefined")return new Set;let e=Hb.indexOf(t),r=Hb.slice(e);return new Set(r)}function t9e(t,e){let r=cge(e),i={};for(let n of r)i[n]=t[n];return i}function uge(t,e){var i;let r=t9e(t,e);for(let n of Object.keys(r))if((i=r[n])!=null?i:0>0)return!0;return!1}function gge(t,e){var s;let r={},i={children:r},n=Object.values(t.advisories);if(e!=null){let o=cge(e);n=n.filter(a=>o.has(a.severity))}for(let o of Se.sortMap(n,a=>a.module_name))r[o.module_name]={label:o.module_name,value:ae.tuple(ae.Type.RANGE,o.findings.map(a=>a.version).join(", ")),children:{Issue:{label:"Issue",value:ae.tuple(ae.Type.NO_HINT,o.title)},URL:{label:"URL",value:ae.tuple(ae.Type.URL,o.url)},Severity:{label:"Severity",value:ae.tuple(ae.Type.NO_HINT,o.severity)},["Vulnerable Versions"]:{label:"Vulnerable Versions",value:ae.tuple(ae.Type.RANGE,o.vulnerable_versions)},["Patched Versions"]:{label:"Patched Versions",value:ae.tuple(ae.Type.RANGE,o.patched_versions)},Via:{label:"Via",value:ae.tuple(ae.Type.NO_HINT,Array.from(new Set(o.findings.map(a=>a.paths).flat().map(a=>a.split(">")[0]))).join(", "))},Recommendation:{label:"Recommendation",value:ae.tuple(ae.Type.NO_HINT,(s=o.recommendation)==null?void 0:s.replace(/\n/g," "))}}};return i}function fge(t,e,{all:r,environment:i}){let n=r?t.workspaces:[e],s=[Ea.All,Ea.Production].includes(i),o=[];if(s)for(let c of n)for(let u of c.manifest.dependencies.values())o.push(u);let a=[Ea.All,Ea.Development].includes(i),l=[];if(a)for(let c of n)for(let u of c.manifest.devDependencies.values())l.push(u);return lge([...o,...l].filter(c=>P.parseRange(c.range).protocol===null))}function hge(t,e,{all:r}){var s;let i=e9e(t,e,{all:r}),n={};for(let o of t.storedPackages.values())n[P.stringifyIdent(o)]={version:(s=o.version)!=null?s:"0.0.0",integrity:o.identHash,requires:lge(o.dependencies.values()),dev:i.has(P.convertLocatorToDescriptor(o).descriptorHash)};return n}var uE=class extends Le{constructor(){super(...arguments);this.all=z.Boolean("-A,--all",!1,{description:"Audit dependencies from all workspaces"});this.recursive=z.Boolean("-R,--recursive",!1,{description:"Audit transitive dependencies as well"});this.environment=z.String("--environment",Ea.All,{description:"Which environments to cover",validator:nn(Ea)});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.severity=z.String("--severity",vo.Info,{description:"Minimal severity requested for packages to be displayed",validator:nn(vo)})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd);if(!i)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState();let n=fge(r,i,{all:this.all,environment:this.environment}),s=hge(r,i,{all:this.all});if(!this.recursive)for(let f of Object.keys(s))Object.prototype.hasOwnProperty.call(n,f)?s[f].requires={}:delete s[f];let o={requires:n,dependencies:s},a=br.getAuditRegistry(i.manifest,{configuration:e}),l,c=await uA.start({configuration:e,stdout:this.context.stdout},async()=>{l=await zt.post("/-/npm/v1/security/audits/quick",o,{authType:zt.AuthType.BEST_EFFORT,configuration:e,jsonResponse:!0,registry:a})});if(c.hasErrors())return c.exitCode();let u=uge(l.metadata.vulnerabilities,this.severity);return!this.json&&u?(as.emitTree(gge(l,this.severity),{configuration:e,json:this.json,stdout:this.context.stdout,separators:2}),1):(await Je.start({configuration:e,includeFooter:!1,json:this.json,stdout:this.context.stdout},async f=>{f.reportJson(l),u||f.reportInfo($.EXCEPTION,"No audit suggestions")})).exitCode()}};uE.paths=[["npm","audit"]],uE.usage=Re.Usage({description:"perform a vulnerability audit against the installed packages",details:`
+      This command checks for known security reports on the packages you use. The reports are by default extracted from the npm registry, and may or may not be relevant to your actual program (not all vulnerabilities affect all code paths).
+
+      For consistency with our other commands the default is to only check the direct dependencies for the active workspace. To extend this search to all workspaces, use \`-A,--all\`. To extend this search to both direct and transitive dependencies, use \`-R,--recursive\`.
+
+      Applying the \`--severity\` flag will limit the audit table to vulnerabilities of the corresponding severity and above. Valid values are ${Hb.map(e=>`\`${e}\``).join(", ")}.
+
+      If the \`--json\` flag is set, Yarn will print the output exactly as received from the registry. Regardless of this flag, the process will exit with a non-zero exit code if a report is found for the selected packages.
+
+      To understand the dependency tree requiring vulnerable packages, check the raw report with the \`--json\` flag or use \`yarn why <package>\` to get more information as to who depends on them.
+    `,examples:[["Checks for known security issues with the installed packages. The output is a list of known issues.","yarn npm audit"],["Audit dependencies in all workspaces","yarn npm audit --all"],["Limit auditing to `dependencies` (excludes `devDependencies`)","yarn npm audit --environment production"],["Show audit report as valid JSON","yarn npm audit --json"],["Audit all direct and transitive dependencies","yarn npm audit --recursive"],["Output moderate (or more severe) vulnerabilities","yarn npm audit --severity moderate"]]});var pge=uE;var fO=ge(ti()),hO=ge(require("util")),gE=class extends Le{constructor(){super(...arguments);this.fields=z.String("-f,--fields",{description:"A comma-separated list of manifest fields that should be displayed"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.packages=z.Rest()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r}=await ze.find(e,this.context.cwd),i=typeof this.fields!="undefined"?new Set(["name",...this.fields.split(/\s*,\s*/)]):null,n=[],s=!1,o=await Je.start({configuration:e,includeFooter:!1,json:this.json,stdout:this.context.stdout},async a=>{for(let l of this.packages){let c;if(l==="."){let x=r.topLevelWorkspace;if(!x.manifest.name)throw new Pe(`Missing ${ae.pretty(e,"name",ae.Type.CODE)} field in ${j.fromPortablePath(k.join(x.cwd,Pt.manifest))}`);c=P.makeDescriptor(x.manifest.name,"unknown")}else c=P.parseDescriptor(l);let u=zt.getIdentUrl(c),g=pO(await zt.get(u,{configuration:e,ident:c,jsonResponse:!0,customErrorMessage:zt.customPackageError})),f=Object.keys(g.versions).sort(fO.default.compareLoose),p=g["dist-tags"].latest||f[f.length-1],m=Wt.validRange(c.range);if(m){let x=fO.default.maxSatisfying(f,m);x!==null?p=x:(a.reportWarning($.UNNAMED,`Unmet range ${P.prettyRange(e,c.range)}; falling back to the latest version`),s=!0)}else Object.prototype.hasOwnProperty.call(g["dist-tags"],c.range)?p=g["dist-tags"][c.range]:c.range!=="unknown"&&(a.reportWarning($.UNNAMED,`Unknown tag ${P.prettyRange(e,c.range)}; falling back to the latest version`),s=!0);let y=g.versions[p],Q=te(N(N({},g),y),{version:p,versions:f}),S;if(i!==null){S={};for(let x of i){let M=Q[x];if(typeof M!="undefined")S[x]=M;else{a.reportWarning($.EXCEPTION,`The ${ae.pretty(e,x,ae.Type.CODE)} field doesn't exist inside ${P.prettyIdent(e,c)}'s information`),s=!0;continue}}}else this.json||(delete Q.dist,delete Q.readme,delete Q.users),S=Q;a.reportJson(S),this.json||n.push(S)}});hO.inspect.styles.name="cyan";for(let a of n)(a!==n[0]||s)&&this.context.stdout.write(`
+`),this.context.stdout.write(`${(0,hO.inspect)(a,{depth:Infinity,colors:!0,compact:!1})}
+`);return o.exitCode()}};gE.paths=[["npm","info"]],gE.usage=Re.Usage({category:"Npm-related commands",description:"show information about a package",details:"\n      This command fetches information about a package from the npm registry and prints it in a tree format.\n\n      The package does not have to be installed locally, but needs to have been published (in particular, local changes will be ignored even for workspaces).\n\n      Append `@<range>` to the package argument to provide information specific to the latest version that satisfies the range or to the corresponding tagged version. If the range is invalid or if there is no version satisfying the range, the command will print a warning and fall back to the latest version.\n\n      If the `-f,--fields` option is set, it's a comma-separated list of fields which will be used to only display part of the package information.\n\n      By default, this command won't return the `dist`, `readme`, and `users` fields, since they are often very long. To explicitly request those fields, explicitly list them with the `--fields` flag or request the output in JSON mode.\n    ",examples:[["Show all available information about react (except the `dist`, `readme`, and `users` fields)","yarn npm info react"],["Show all available information about react as valid JSON (including the `dist`, `readme`, and `users` fields)","yarn npm info react --json"],["Show all available information about react@16.12.0","yarn npm info react@16.12.0"],["Show all available information about react@next","yarn npm info react@next"],["Show the description of react","yarn npm info react --fields description"],["Show all available versions of react","yarn npm info react --fields versions"],["Show the readme of react","yarn npm info react --fields readme"],["Show a few fields of react","yarn npm info react --fields homepage,repository"]]});var dge=gE;function pO(t){if(Array.isArray(t)){let e=[];for(let r of t)r=pO(r),r&&e.push(r);return e}else if(typeof t=="object"&&t!==null){let e={};for(let r of Object.keys(t)){if(r.startsWith("_"))continue;let i=pO(t[r]);i&&(e[r]=i)}return e}else return t||null}var Cge=ge($C()),fE=class extends Le{constructor(){super(...arguments);this.scope=z.String("-s,--scope",{description:"Login to the registry configured for a given scope"});this.publish=z.Boolean("--publish",!1,{description:"Login to the publish registry"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),r=await jb({configuration:e,cwd:this.context.cwd,publish:this.publish,scope:this.scope});return(await Je.start({configuration:e,stdout:this.context.stdout},async n=>{let s=await i9e({registry:r,report:n,stdin:this.context.stdin,stdout:this.context.stdout}),o=`/-/user/org.couchdb.user:${encodeURIComponent(s.name)}`,a=await zt.put(o,s,{attemptedAs:s.name,configuration:e,registry:r,jsonResponse:!0,authType:zt.AuthType.NO_AUTH});return await r9e(r,a.token,{configuration:e,scope:this.scope}),n.reportInfo($.UNNAMED,"Successfully logged in")})).exitCode()}};fE.paths=[["npm","login"]],fE.usage=Re.Usage({category:"Npm-related commands",description:"store new login info to access the npm registry",details:"\n      This command will ask you for your username, password, and 2FA One-Time-Password (when it applies). It will then modify your local configuration (in your home folder, never in the project itself) to reference the new tokens thus generated.\n\n      Adding the `-s,--scope` flag will cause the authentication to be done against whatever registry is configured for the associated scope (see also `npmScopes`).\n\n      Adding the `--publish` flag will cause the authentication to be done against the registry used when publishing the package (see also `publishConfig.registry` and `npmPublishRegistry`).\n    ",examples:[["Login to the default registry","yarn npm login"],["Login to the registry linked to the @my-scope registry","yarn npm login --scope my-scope"],["Login to the publish registry for the current package","yarn npm login --publish"]]});var mge=fE;async function jb({scope:t,publish:e,configuration:r,cwd:i}){return t&&e?br.getScopeRegistry(t,{configuration:r,type:br.RegistryType.PUBLISH_REGISTRY}):t?br.getScopeRegistry(t,{configuration:r}):e?br.getPublishRegistry((await Jf(r,i)).manifest,{configuration:r}):br.getDefaultRegistry({configuration:r})}async function r9e(t,e,{configuration:r,scope:i}){let n=o=>a=>{let l=Se.isIndexableObject(a)?a:{},c=l[o],u=Se.isIndexableObject(c)?c:{};return te(N({},l),{[o]:te(N({},u),{npmAuthToken:e})})},s=i?{npmScopes:n(i)}:{npmRegistries:n(t)};return await ye.updateHomeConfiguration(s)}async function i9e({registry:t,report:e,stdin:r,stdout:i}){if(process.env.TEST_ENV)return{name:process.env.TEST_NPM_USER||"",password:process.env.TEST_NPM_PASSWORD||""};e.reportInfo($.UNNAMED,`Logging in to ${t}`);let n=!1;t.match(/^https:\/\/npm\.pkg\.github\.com(\/|$)/)&&(e.reportInfo($.UNNAMED,"You seem to be using the GitHub Package Registry. Tokens must be generated with the 'repo', 'write:packages', and 'read:packages' permissions."),n=!0),e.reportSeparator();let{username:s,password:o}=await(0,Cge.prompt)([{type:"input",name:"username",message:"Username:",required:!0,onCancel:()=>process.exit(130),stdin:r,stdout:i},{type:"password",name:"password",message:n?"Token:":"Password:",required:!0,onCancel:()=>process.exit(130),stdin:r,stdout:i}]);return e.reportSeparator(),{name:s,password:o}}var wh=new Set(["npmAuthIdent","npmAuthToken"]),hE=class extends Le{constructor(){super(...arguments);this.scope=z.String("-s,--scope",{description:"Logout of the registry configured for a given scope"});this.publish=z.Boolean("--publish",!1,{description:"Logout of the publish registry"});this.all=z.Boolean("-A,--all",!1,{description:"Logout of all registries"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),r=async()=>{var l;let n=await jb({configuration:e,cwd:this.context.cwd,publish:this.publish,scope:this.scope}),s=await ye.find(this.context.cwd,this.context.plugins),o=P.makeIdent((l=this.scope)!=null?l:null,"pkg");return!br.getAuthConfiguration(n,{configuration:s,ident:o}).get("npmAuthToken")};return(await Je.start({configuration:e,stdout:this.context.stdout},async n=>{if(this.all&&(await n9e(),n.reportInfo($.UNNAMED,"Successfully logged out from everything")),this.scope){await Ege("npmScopes",this.scope),await r()?n.reportInfo($.UNNAMED,`Successfully logged out from ${this.scope}`):n.reportWarning($.UNNAMED,"Scope authentication settings removed, but some other ones settings still apply to it");return}let s=await jb({configuration:e,cwd:this.context.cwd,publish:this.publish});await Ege("npmRegistries",s),await r()?n.reportInfo($.UNNAMED,`Successfully logged out from ${s}`):n.reportWarning($.UNNAMED,"Registry authentication settings removed, but some other ones settings still apply to it")})).exitCode()}};hE.paths=[["npm","logout"]],hE.usage=Re.Usage({category:"Npm-related commands",description:"logout of the npm registry",details:"\n      This command will log you out by modifying your local configuration (in your home folder, never in the project itself) to delete all credentials linked to a registry.\n\n      Adding the `-s,--scope` flag will cause the deletion to be done against whatever registry is configured for the associated scope (see also `npmScopes`).\n\n      Adding the `--publish` flag will cause the deletion to be done against the registry used when publishing the package (see also `publishConfig.registry` and `npmPublishRegistry`).\n\n      Adding the `-A,--all` flag will cause the deletion to be done against all registries and scopes.\n    ",examples:[["Logout of the default registry","yarn npm logout"],["Logout of the @my-scope scope","yarn npm logout --scope my-scope"],["Logout of the publish registry for the current package","yarn npm logout --publish"],["Logout of all registries","yarn npm logout --all"]]});var Ige=hE;function s9e(t,e){let r=t[e];if(!Se.isIndexableObject(r))return!1;let i=new Set(Object.keys(r));if([...wh].every(s=>!i.has(s)))return!1;for(let s of wh)i.delete(s);if(i.size===0)return t[e]=void 0,!0;let n=N({},r);for(let s of wh)delete n[s];return t[e]=n,!0}async function n9e(){let t=e=>{let r=!1,i=Se.isIndexableObject(e)?N({},e):{};i.npmAuthToken&&(delete i.npmAuthToken,r=!0);for(let n of Object.keys(i))s9e(i,n)&&(r=!0);if(Object.keys(i).length!==0)return r?i:e};return await ye.updateHomeConfiguration({npmRegistries:t,npmScopes:t})}async function Ege(t,e){return await ye.updateHomeConfiguration({[t]:r=>{let i=Se.isIndexableObject(r)?r:{};if(!Object.prototype.hasOwnProperty.call(i,e))return r;let n=i[e],s=Se.isIndexableObject(n)?n:{},o=new Set(Object.keys(s));if([...wh].every(l=>!o.has(l)))return r;for(let l of wh)o.delete(l);if(o.size===0)return Object.keys(i).length===1?void 0:te(N({},i),{[e]:void 0});let a={};for(let l of wh)a[l]=void 0;return te(N({},i),{[e]:N(N({},s),a)})}})}var pE=class extends Le{constructor(){super(...arguments);this.access=z.String("--access",{description:"The access for the published package (public or restricted)"});this.tag=z.String("--tag","latest",{description:"The tag on the registry that the package should be attached to"});this.tolerateRepublish=z.Boolean("--tolerate-republish",!1,{description:"Warn and exit when republishing an already existing version of a package"});this.otp=z.String("--otp",{description:"The OTP token to use with the command"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd);if(!i)throw new ht(r.cwd,this.context.cwd);if(i.manifest.private)throw new Pe("Private workspaces cannot be published");if(i.manifest.name===null||i.manifest.version===null)throw new Pe("Workspaces must have valid names and versions to be published on an external registry");await r.restoreInstallState();let n=i.manifest.name,s=i.manifest.version,o=br.getPublishRegistry(i.manifest,{configuration:e});return(await Je.start({configuration:e,stdout:this.context.stdout},async l=>{var c,u;if(this.tolerateRepublish)try{let g=await zt.get(zt.getIdentUrl(n),{configuration:e,registry:o,ident:n,jsonResponse:!0});if(!Object.prototype.hasOwnProperty.call(g,"versions"))throw new ct($.REMOTE_INVALID,'Registry returned invalid data for - missing "versions" field');if(Object.prototype.hasOwnProperty.call(g.versions,s)){l.reportWarning($.UNNAMED,`Registry already knows about version ${s}; skipping.`);return}}catch(g){if(((u=(c=g.originalError)==null?void 0:c.response)==null?void 0:u.statusCode)!==404)throw g}await Zt.maybeExecuteWorkspaceLifecycleScript(i,"prepublish",{report:l}),await vA.prepareForPack(i,{report:l},async()=>{let g=await vA.genPackList(i);for(let y of g)l.reportInfo(null,y);let f=await vA.genPackStream(i,g),h=await Se.bufferStream(f),p=await yh.getGitHead(i.cwd),m=await yh.makePublishBody(i,h,{access:this.access,tag:this.tag,registry:o,gitHead:p});await zt.put(zt.getIdentUrl(n),m,{configuration:e,registry:o,ident:n,otp:this.otp,jsonResponse:!0})}),l.reportInfo($.UNNAMED,"Package archive published")})).exitCode()}};pE.paths=[["npm","publish"]],pE.usage=Re.Usage({category:"Npm-related commands",description:"publish the active workspace to the npm registry",details:'\n      This command will pack the active workspace into a fresh archive and upload it to the npm registry.\n\n      The package will by default be attached to the `latest` tag on the registry, but this behavior can be overriden by using the `--tag` option.\n\n      Note that for legacy reasons scoped packages are by default published with an access set to `restricted` (aka "private packages"). This requires you to register for a paid npm plan. In case you simply wish to publish a public scoped package to the registry (for free), just add the `--access public` flag. This behavior can be enabled by default through the `npmPublishAccess` settings.\n    ',examples:[["Publish the active workspace","yarn npm publish"]]});var yge=pE;var Bge=ge(ti());var dE=class extends Le{constructor(){super(...arguments);this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.package=z.String({required:!1})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n;if(typeof this.package!="undefined")n=P.parseIdent(this.package);else{if(!i)throw new ht(r.cwd,this.context.cwd);if(!i.manifest.name)throw new Pe(`Missing 'name' field in ${j.fromPortablePath(k.join(i.cwd,Pt.manifest))}`);n=i.manifest.name}let s=await CE(n,e),a={children:Se.sortMap(Object.entries(s),([l])=>l).map(([l,c])=>({value:ae.tuple(ae.Type.RESOLUTION,{descriptor:P.makeDescriptor(n,l),locator:P.makeLocator(n,c)})}))};return as.emitTree(a,{configuration:e,json:this.json,stdout:this.context.stdout})}};dE.paths=[["npm","tag","list"]],dE.usage=Re.Usage({category:"Npm-related commands",description:"list all dist-tags of a package",details:`
+      This command will list all tags of a package from the npm registry.
+
+      If the package is not specified, Yarn will default to the current workspace.
+    `,examples:[["List all tags of package `my-pkg`","yarn npm tag list my-pkg"]]});var wge=dE;async function CE(t,e){let r=`/-/package${zt.getIdentUrl(t)}/dist-tags`;return zt.get(r,{configuration:e,ident:t,jsonResponse:!0,customErrorMessage:zt.customPackageError})}var mE=class extends Le{constructor(){super(...arguments);this.package=z.String();this.tag=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd);if(!i)throw new ht(r.cwd,this.context.cwd);let n=P.parseDescriptor(this.package,!0),s=n.range;if(!Bge.default.valid(s))throw new Pe(`The range ${ae.pretty(e,n.range,ae.Type.RANGE)} must be a valid semver version`);let o=br.getPublishRegistry(i.manifest,{configuration:e}),a=ae.pretty(e,n,ae.Type.IDENT),l=ae.pretty(e,s,ae.Type.RANGE),c=ae.pretty(e,this.tag,ae.Type.CODE);return(await Je.start({configuration:e,stdout:this.context.stdout},async g=>{let f=await CE(n,e);Object.prototype.hasOwnProperty.call(f,this.tag)&&f[this.tag]===s&&g.reportWarning($.UNNAMED,`Tag ${c} is already set to version ${l}`);let h=`/-/package${zt.getIdentUrl(n)}/dist-tags/${encodeURIComponent(this.tag)}`;await zt.put(h,s,{configuration:e,registry:o,ident:n,jsonRequest:!0,jsonResponse:!0}),g.reportInfo($.UNNAMED,`Tag ${c} added to version ${l} of package ${a}`)})).exitCode()}};mE.paths=[["npm","tag","add"]],mE.usage=Re.Usage({category:"Npm-related commands",description:"add a tag for a specific version of a package",details:`
+      This command will add a tag to the npm registry for a specific version of a package. If the tag already exists, it will be overwritten.
+    `,examples:[["Add a `beta` tag for version `2.3.4-beta.4` of package `my-pkg`","yarn npm tag add my-pkg@2.3.4-beta.4 beta"]]});var bge=mE;var EE=class extends Le{constructor(){super(...arguments);this.package=z.String();this.tag=z.String()}async execute(){if(this.tag==="latest")throw new Pe("The 'latest' tag cannot be removed.");let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd);if(!i)throw new ht(r.cwd,this.context.cwd);let n=P.parseIdent(this.package),s=br.getPublishRegistry(i.manifest,{configuration:e}),o=ae.pretty(e,this.tag,ae.Type.CODE),a=ae.pretty(e,n,ae.Type.IDENT),l=await CE(n,e);if(!Object.prototype.hasOwnProperty.call(l,this.tag))throw new Pe(`${o} is not a tag of package ${a}`);return(await Je.start({configuration:e,stdout:this.context.stdout},async u=>{let g=`/-/package${zt.getIdentUrl(n)}/dist-tags/${encodeURIComponent(this.tag)}`;await zt.del(g,{configuration:e,registry:s,ident:n,jsonResponse:!0}),u.reportInfo($.UNNAMED,`Tag ${o} removed from package ${a}`)})).exitCode()}};EE.paths=[["npm","tag","remove"]],EE.usage=Re.Usage({category:"Npm-related commands",description:"remove a tag from a package",details:`
+      This command will remove a tag from a package from the npm registry.
+    `,examples:[["Remove the `beta` tag from package `my-pkg`","yarn npm tag remove my-pkg beta"]]});var Qge=EE;var IE=class extends Le{constructor(){super(...arguments);this.scope=z.String("-s,--scope",{description:"Print username for the registry configured for a given scope"});this.publish=z.Boolean("--publish",!1,{description:"Print username for the publish registry"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),r;return this.scope&&this.publish?r=br.getScopeRegistry(this.scope,{configuration:e,type:br.RegistryType.PUBLISH_REGISTRY}):this.scope?r=br.getScopeRegistry(this.scope,{configuration:e}):this.publish?r=br.getPublishRegistry((await Jf(e,this.context.cwd)).manifest,{configuration:e}):r=br.getDefaultRegistry({configuration:e}),(await Je.start({configuration:e,stdout:this.context.stdout},async n=>{var o,a;let s;try{s=await zt.get("/-/whoami",{configuration:e,registry:r,authType:zt.AuthType.ALWAYS_AUTH,jsonResponse:!0,ident:this.scope?P.makeIdent(this.scope,""):void 0})}catch(l){if(((o=l.response)==null?void 0:o.statusCode)===401||((a=l.response)==null?void 0:a.statusCode)===403){n.reportError($.AUTHENTICATION_INVALID,"Authentication failed - your credentials may have expired");return}else throw l}n.reportInfo($.UNNAMED,s.username)})).exitCode()}};IE.paths=[["npm","whoami"]],IE.usage=Re.Usage({category:"Npm-related commands",description:"display the name of the authenticated user",details:"\n      Print the username associated with the current authentication settings to the standard output.\n\n      When using `-s,--scope`, the username printed will be the one that matches the authentication settings of the registry associated with the given scope (those settings can be overriden using the `npmRegistries` map, and the registry associated with the scope is configured via the `npmScopes` map).\n\n      When using `--publish`, the registry we'll select will by default be the one used when publishing packages (`publishConfig.registry` or `npmPublishRegistry` if available, otherwise we'll fallback to the regular `npmRegistryServer`).\n    ",examples:[["Print username for the default registry","yarn npm whoami"],["Print username for the registry on a given scope","yarn npm whoami --scope company"]]});var vge=IE;var o9e={configuration:{npmPublishAccess:{description:"Default access of the published packages",type:Ie.STRING,default:null}},commands:[pge,dge,mge,Ige,yge,bge,wge,Qge,vge]},a9e=o9e;var bO={};ft(bO,{default:()=>B9e,patchUtils:()=>CO});var CO={};ft(CO,{applyPatchFile:()=>qb,diffFolders:()=>yO,extractPackageToDisk:()=>IO,extractPatchFlags:()=>Nge,isParentRequired:()=>EO,loadPatchFiles:()=>bE,makeDescriptor:()=>I9e,makeLocator:()=>mO,parseDescriptor:()=>wE,parseLocator:()=>BE,parsePatchFile:()=>Yb});var yE=class extends Error{constructor(e,r){super(`Cannot apply hunk #${e+1}`);this.hunk=r}};var A9e=/^@@ -(\d+)(,(\d+))? \+(\d+)(,(\d+))? @@.*/;function Bh(t){return k.relative(Me.root,k.resolve(Me.root,j.toPortablePath(t)))}function l9e(t){let e=t.trim().match(A9e);if(!e)throw new Error(`Bad header line: '${t}'`);return{original:{start:Math.max(Number(e[1]),1),length:Number(e[3]||1)},patched:{start:Math.max(Number(e[4]),1),length:Number(e[6]||1)}}}var c9e=420,u9e=493,Xr;(function(i){i.Context="context",i.Insertion="insertion",i.Deletion="deletion"})(Xr||(Xr={}));var Sge=()=>({semverExclusivity:null,diffLineFromPath:null,diffLineToPath:null,oldMode:null,newMode:null,deletedFileMode:null,newFileMode:null,renameFrom:null,renameTo:null,beforeHash:null,afterHash:null,fromPath:null,toPath:null,hunks:null}),g9e=t=>({header:l9e(t),parts:[]}),f9e={["@"]:"header",["-"]:Xr.Deletion,["+"]:Xr.Insertion,[" "]:Xr.Context,["\\"]:"pragma",undefined:Xr.Context};function p9e(t){let e=[],r=Sge(),i="parsing header",n=null,s=null;function o(){n&&(s&&(n.parts.push(s),s=null),r.hunks.push(n),n=null)}function a(){o(),e.push(r),r=Sge()}for(let l=0;l<t.length;l++){let c=t[l];if(i==="parsing header")if(c.startsWith("@@"))i="parsing hunks",r.hunks=[],l-=1;else if(c.startsWith("diff --git ")){r&&r.diffLineFromPath&&a();let u=c.match(/^diff --git a\/(.*?) b\/(.*?)\s*$/);if(!u)throw new Error(`Bad diff line: ${c}`);r.diffLineFromPath=u[1],r.diffLineToPath=u[2]}else if(c.startsWith("old mode "))r.oldMode=c.slice("old mode ".length).trim();else if(c.startsWith("new mode "))r.newMode=c.slice("new mode ".length).trim();else if(c.startsWith("deleted file mode "))r.deletedFileMode=c.slice("deleted file mode ".length).trim();else if(c.startsWith("new file mode "))r.newFileMode=c.slice("new file mode ".length).trim();else if(c.startsWith("rename from "))r.renameFrom=c.slice("rename from ".length).trim();else if(c.startsWith("rename to "))r.renameTo=c.slice("rename to ".length).trim();else if(c.startsWith("index ")){let u=c.match(/(\w+)\.\.(\w+)/);if(!u)continue;r.beforeHash=u[1],r.afterHash=u[2]}else c.startsWith("semver exclusivity ")?r.semverExclusivity=c.slice("semver exclusivity ".length).trim():c.startsWith("--- ")?r.fromPath=c.slice("--- a/".length).trim():c.startsWith("+++ ")&&(r.toPath=c.slice("+++ b/".length).trim());else{let u=f9e[c[0]]||null;switch(u){case"header":o(),n=g9e(c);break;case null:i="parsing header",a(),l-=1;break;case"pragma":{if(!c.startsWith("\\ No newline at end of file"))throw new Error(`Unrecognized pragma in patch file: ${c}`);if(!s)throw new Error("Bad parser state: No newline at EOF pragma encountered without context");s.noNewlineAtEndOfFile=!0}break;case Xr.Context:case Xr.Deletion:case Xr.Insertion:{if(!n)throw new Error("Bad parser state: Hunk lines encountered before hunk header");s&&s.type!==u&&(n.parts.push(s),s=null),s||(s={type:u,lines:[],noNewlineAtEndOfFile:!1}),s.lines.push(c.slice(1))}break;default:Se.assertNever(u);break}}}a();for(let{hunks:l}of e)if(l)for(let c of l)h9e(c);return e}function d9e(t){let e=[];for(let r of t){let{semverExclusivity:i,diffLineFromPath:n,diffLineToPath:s,oldMode:o,newMode:a,deletedFileMode:l,newFileMode:c,renameFrom:u,renameTo:g,beforeHash:f,afterHash:h,fromPath:p,toPath:m,hunks:y}=r,Q=u?"rename":l?"file deletion":c?"file creation":y&&y.length>0?"patch":"mode change",S=null;switch(Q){case"rename":{if(!u||!g)throw new Error("Bad parser state: rename from & to not given");e.push({type:"rename",semverExclusivity:i,fromPath:Bh(u),toPath:Bh(g)}),S=g}break;case"file deletion":{let x=n||p;if(!x)throw new Error("Bad parse state: no path given for file deletion");e.push({type:"file deletion",semverExclusivity:i,hunk:y&&y[0]||null,path:Bh(x),mode:Gb(l),hash:f})}break;case"file creation":{let x=s||m;if(!x)throw new Error("Bad parse state: no path given for file creation");e.push({type:"file creation",semverExclusivity:i,hunk:y&&y[0]||null,path:Bh(x),mode:Gb(c),hash:h})}break;case"patch":case"mode change":S=m||s;break;default:Se.assertNever(Q);break}S&&o&&a&&o!==a&&e.push({type:"mode change",semverExclusivity:i,path:Bh(S),oldMode:Gb(o),newMode:Gb(a)}),S&&y&&y.length&&e.push({type:"patch",semverExclusivity:i,path:Bh(S),hunks:y,beforeHash:f,afterHash:h})}if(e.length===0)throw new Error("Unable to parse patch file: No changes found. Make sure the patch is a valid UTF8 encoded string");return e}function Gb(t){let e=parseInt(t,8)&511;if(e!==c9e&&e!==u9e)throw new Error(`Unexpected file mode string: ${t}`);return e}function Yb(t){let e=t.split(/\n/g);return e[e.length-1]===""&&e.pop(),d9e(p9e(e))}function h9e(t){let e=0,r=0;for(let{type:i,lines:n}of t.parts)switch(i){case Xr.Context:r+=n.length,e+=n.length;break;case Xr.Deletion:e+=n.length;break;case Xr.Insertion:r+=n.length;break;default:Se.assertNever(i);break}if(e!==t.header.original.length||r!==t.header.patched.length){let i=n=>n<0?n:`+${n}`;throw new Error(`hunk header integrity check failed (expected @@ ${i(t.header.original.length)} ${i(t.header.patched.length)} @@, got @@ ${i(e)} ${i(r)} @@)`)}}async function bh(t,e,r){let i=await t.lstatPromise(e),n=await r();if(typeof n!="undefined"&&(e=n),t.lutimesPromise)await t.lutimesPromise(e,i.atime,i.mtime);else if(!i.isSymbolicLink())await t.utimesPromise(e,i.atime,i.mtime);else throw new Error("Cannot preserve the time values of a symlink")}async function qb(t,{baseFs:e=new ar,dryRun:r=!1,version:i=null}={}){for(let n of t)if(!(n.semverExclusivity!==null&&i!==null&&!Wt.satisfiesWithPrereleases(i,n.semverExclusivity)))switch(n.type){case"file deletion":if(r){if(!e.existsSync(n.path))throw new Error(`Trying to delete a file that doesn't exist: ${n.path}`)}else await bh(e,k.dirname(n.path),async()=>{await e.unlinkPromise(n.path)});break;case"rename":if(r){if(!e.existsSync(n.fromPath))throw new Error(`Trying to move a file that doesn't exist: ${n.fromPath}`)}else await bh(e,k.dirname(n.fromPath),async()=>{await bh(e,k.dirname(n.toPath),async()=>{await bh(e,n.fromPath,async()=>(await e.movePromise(n.fromPath,n.toPath),n.toPath))})});break;case"file creation":if(r){if(e.existsSync(n.path))throw new Error(`Trying to create a file that already exists: ${n.path}`)}else{let s=n.hunk?n.hunk.parts[0].lines.join(`
+`)+(n.hunk.parts[0].noNewlineAtEndOfFile?"":`
+`):"";await e.mkdirpPromise(k.dirname(n.path),{chmod:493,utimes:[Dr.SAFE_TIME,Dr.SAFE_TIME]}),await e.writeFilePromise(n.path,s,{mode:n.mode}),await e.utimesPromise(n.path,Dr.SAFE_TIME,Dr.SAFE_TIME)}break;case"patch":await bh(e,n.path,async()=>{await C9e(n,{baseFs:e,dryRun:r})});break;case"mode change":{let o=(await e.statPromise(n.path)).mode;if(kge(n.newMode)!==kge(o))continue;await bh(e,n.path,async()=>{await e.chmodPromise(n.path,n.newMode)})}break;default:Se.assertNever(n);break}}function kge(t){return(t&64)>0}function xge(t){return t.replace(/\s+$/,"")}function m9e(t,e){return xge(t)===xge(e)}async function C9e({hunks:t,path:e},{baseFs:r,dryRun:i=!1}){let n=await r.statSync(e).mode,o=(await r.readFileSync(e,"utf8")).split(/\n/),a=[],l=0,c=0;for(let g of t){let f=Math.max(c,g.header.patched.start+l),h=Math.max(0,f-c),p=Math.max(0,o.length-f-g.header.original.length),m=Math.max(h,p),y=0,Q=0,S=null;for(;y<=m;){if(y<=h&&(Q=f-y,S=Pge(g,o,Q),S!==null)){y=-y;break}if(y<=p&&(Q=f+y,S=Pge(g,o,Q),S!==null))break;y+=1}if(S===null)throw new yE(t.indexOf(g),g);a.push(S),l+=y,c=Q+g.header.original.length}if(i)return;let u=0;for(let g of a)for(let f of g)switch(f.type){case"splice":{let h=f.index+u;o.splice(h,f.numToDelete,...f.linesToInsert),u+=f.linesToInsert.length-f.numToDelete}break;case"pop":o.pop();break;case"push":o.push(f.line);break;default:Se.assertNever(f);break}await r.writeFilePromise(e,o.join(`
+`),{mode:n})}function Pge(t,e,r){let i=[];for(let n of t.parts)switch(n.type){case Xr.Context:case Xr.Deletion:{for(let s of n.lines){let o=e[r];if(o==null||!m9e(o,s))return null;r+=1}n.type===Xr.Deletion&&(i.push({type:"splice",index:r-n.lines.length,numToDelete:n.lines.length,linesToInsert:[]}),n.noNewlineAtEndOfFile&&i.push({type:"push",line:""}))}break;case Xr.Insertion:i.push({type:"splice",index:r,numToDelete:0,linesToInsert:n.lines}),n.noNewlineAtEndOfFile&&i.push({type:"pop"});break;default:Se.assertNever(n.type);break}return i}var E9e=/^builtin<([^>]+)>$/;function Dge(t,e){let{source:r,selector:i,params:n}=P.parseRange(t);if(r===null)throw new Error("Patch locators must explicitly define their source");let s=i?i.split(/&/).map(c=>j.toPortablePath(c)):[],o=n&&typeof n.locator=="string"?P.parseLocator(n.locator):null,a=n&&typeof n.version=="string"?n.version:null,l=e(r);return{parentLocator:o,sourceItem:l,patchPaths:s,sourceVersion:a}}function wE(t){let i=Dge(t.range,P.parseDescriptor),{sourceItem:e}=i,r=Tr(i,["sourceItem"]);return te(N({},r),{sourceDescriptor:e})}function BE(t){let i=Dge(t.reference,P.parseLocator),{sourceItem:e}=i,r=Tr(i,["sourceItem"]);return te(N({},r),{sourceLocator:e})}function Rge({parentLocator:t,sourceItem:e,patchPaths:r,sourceVersion:i,patchHash:n},s){let o=t!==null?{locator:P.stringifyLocator(t)}:{},a=typeof i!="undefined"?{version:i}:{},l=typeof n!="undefined"?{hash:n}:{};return P.makeRange({protocol:"patch:",source:s(e),selector:r.join("&"),params:N(N(N({},a),l),o)})}function I9e(t,{parentLocator:e,sourceDescriptor:r,patchPaths:i}){return P.makeLocator(t,Rge({parentLocator:e,sourceItem:r,patchPaths:i},P.stringifyDescriptor))}function mO(t,{parentLocator:e,sourcePackage:r,patchPaths:i,patchHash:n}){return P.makeLocator(t,Rge({parentLocator:e,sourceItem:r,sourceVersion:r.version,patchPaths:i,patchHash:n},P.stringifyLocator))}function Fge({onAbsolute:t,onRelative:e,onBuiltin:r},i){i.startsWith("~")&&(i=i.slice(1));let s=i.match(E9e);return s!==null?r(s[1]):k.isAbsolute(i)?t(i):e(i)}function Nge(t){let e=t.startsWith("~");return e&&(t=t.slice(1)),{optional:e}}function EO(t){return Fge({onAbsolute:()=>!1,onRelative:()=>!0,onBuiltin:()=>!1},t)}async function bE(t,e,r){let i=t!==null?await r.fetcher.fetch(t,r):null,n=i&&i.localPath?{packageFs:new _t(Me.root),prefixPath:k.relative(Me.root,i.localPath)}:i;i&&i!==n&&i.releaseFs&&i.releaseFs();let s=await Se.releaseAfterUseAsync(async()=>await Promise.all(e.map(async o=>{let a=Nge(o),l=await Fge({onAbsolute:async()=>await K.readFilePromise(o,"utf8"),onRelative:async()=>{if(n===null)throw new Error("Assertion failed: The parent locator should have been fetched");return await n.packageFs.readFilePromise(k.join(n.prefixPath,o),"utf8")},onBuiltin:async c=>await r.project.configuration.firstHook(u=>u.getBuiltinPatch,r.project,c)},o);return te(N({},a),{source:l})})));for(let o of s)typeof o.source=="string"&&(o.source=o.source.replace(/\r\n?/g,`
+`));return s}async function IO(t,{cache:e,project:r}){let i=r.storedPackages.get(t.locatorHash);if(typeof i=="undefined")throw new Error("Assertion failed: Expected the package to be registered");let n=r.storedChecksums,s=new pi,o=r.configuration.makeFetcher(),a=await o.fetch(t,{cache:e,project:r,fetcher:o,checksums:n,report:s}),l=await K.mktempPromise(),c=k.join(l,"source"),u=k.join(l,"user"),g=k.join(l,".yarn-patch.json");return await Promise.all([K.copyPromise(c,a.prefixPath,{baseFs:a.packageFs}),K.copyPromise(u,a.prefixPath,{baseFs:a.packageFs}),K.writeJsonPromise(g,{locator:P.stringifyLocator(t),version:i.version})]),K.detachTemp(l),u}async function yO(t,e){let r=j.fromPortablePath(t).replace(/\\/g,"/"),i=j.fromPortablePath(e).replace(/\\/g,"/"),{stdout:n,stderr:s}=await Fr.execvp("git",["-c","core.safecrlf=false","diff","--src-prefix=a/","--dst-prefix=b/","--ignore-cr-at-eol","--full-index","--no-index","--text",r,i],{cwd:j.toPortablePath(process.cwd()),env:te(N({},process.env),{GIT_CONFIG_NOSYSTEM:"1",HOME:"",XDG_CONFIG_HOME:"",USERPROFILE:""})});if(s.length>0)throw new Error(`Unable to diff directories. Make sure you have a recent version of 'git' available in PATH.
+The following error was reported by 'git':
+${s}`);let o=r.startsWith("/")?a=>a.slice(1):a=>a;return n.replace(new RegExp(`(a|b)(${Se.escapeRegExp(`/${o(r)}/`)})`,"g"),"$1/").replace(new RegExp(`(a|b)${Se.escapeRegExp(`/${o(i)}/`)}`,"g"),"$1/").replace(new RegExp(Se.escapeRegExp(`${r}/`),"g"),"").replace(new RegExp(Se.escapeRegExp(`${i}/`),"g"),"")}function Lge(t,{configuration:e,report:r}){for(let i of t.parts)for(let n of i.lines)switch(i.type){case Xr.Context:r.reportInfo(null,`  ${ae.pretty(e,n,"grey")}`);break;case Xr.Deletion:r.reportError($.FROZEN_LOCKFILE_EXCEPTION,`- ${ae.pretty(e,n,ae.Type.REMOVED)}`);break;case Xr.Insertion:r.reportError($.FROZEN_LOCKFILE_EXCEPTION,`+ ${ae.pretty(e,n,ae.Type.ADDED)}`);break;default:Se.assertNever(i.type)}}var wO=class{supports(e,r){return!!e.reference.startsWith("patch:")}getLocalPath(e,r){return null}async fetch(e,r){let i=r.checksums.get(e.locatorHash)||null,[n,s,o]=await r.cache.fetchPackageFromCache(e,i,N({onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${P.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the disk`),loader:()=>this.patchPackage(e,r),skipIntegrityCheck:r.skipIntegrityCheck},r.cacheOptions));return{packageFs:n,releaseFs:s,prefixPath:P.getIdentVendorPath(e),localPath:this.getLocalPath(e,r),checksum:o}}async patchPackage(e,r){let{parentLocator:i,sourceLocator:n,sourceVersion:s,patchPaths:o}=BE(e),a=await bE(i,o,r),l=await K.mktempPromise(),c=k.join(l,"current.zip"),u=await r.fetcher.fetch(n,r),g=P.getIdentVendorPath(e),f=await fn(),h=new Ai(c,{libzip:f,create:!0,level:r.project.configuration.get("compressionLevel")});await Se.releaseAfterUseAsync(async()=>{await h.copyPromise(g,u.prefixPath,{baseFs:u.packageFs,stableSort:!0})},u.releaseFs),h.saveAndClose();for(let{source:p,optional:m}of a){if(p===null)continue;let y=new Ai(c,{libzip:f,level:r.project.configuration.get("compressionLevel")}),Q=new _t(k.resolve(Me.root,g),{baseFs:y});try{await qb(Yb(p),{baseFs:Q,version:s})}catch(S){if(!(S instanceof yE))throw S;let x=r.project.configuration.get("enableInlineHunks"),M=!x&&!m?" (set enableInlineHunks for details)":"",Y=`${P.prettyLocator(r.project.configuration,e)}: ${S.message}${M}`,U=J=>{!x||Lge(S.hunk,{configuration:r.project.configuration,report:J})};if(y.discardAndClose(),m){r.report.reportWarningOnce($.PATCH_HUNK_FAILED,Y,{reportExtra:U});continue}else throw new ct($.PATCH_HUNK_FAILED,Y,U)}y.saveAndClose()}return new Ai(c,{libzip:f,level:r.project.configuration.get("compressionLevel")})}};var y9e=3,BO=class{supportsDescriptor(e,r){return!!e.range.startsWith("patch:")}supportsLocator(e,r){return!!e.reference.startsWith("patch:")}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,i){let{patchPaths:n}=wE(e);return n.every(s=>!EO(s))?e:P.bindDescriptor(e,{locator:P.stringifyLocator(r)})}getResolutionDependencies(e,r){let{sourceDescriptor:i}=wE(e);return[i]}async getCandidates(e,r,i){if(!i.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{parentLocator:n,sourceDescriptor:s,patchPaths:o}=wE(e),a=await bE(n,o,i.fetchOptions),l=r.get(s.descriptorHash);if(typeof l=="undefined")throw new Error("Assertion failed: The dependency should have been resolved");let c=Dn.makeHash(`${y9e}`,...a.map(u=>JSON.stringify(u))).slice(0,6);return[mO(e,{parentLocator:n,sourcePackage:l,patchPaths:o,patchHash:c})]}async getSatisfying(e,r,i){return null}async resolve(e,r){let{sourceLocator:i}=BE(e),n=await r.resolver.resolve(i,r);return N(N({},n),e)}};var QE=class extends Le{constructor(){super(...arguments);this.save=z.Boolean("-s,--save",!1,{description:"Add the patch to your resolution entries"});this.patchFolder=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd);if(!i)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState();let n=k.resolve(this.context.cwd,j.toPortablePath(this.patchFolder)),s=k.join(n,"../source"),o=k.join(n,"../.yarn-patch.json");if(!K.existsSync(s))throw new Pe("The argument folder didn't get created by 'yarn patch'");let a=await yO(s,n),l=await K.readJsonPromise(o),c=P.parseLocator(l.locator,!0);if(!r.storedPackages.has(c.locatorHash))throw new Pe("No package found in the project for the given locator");if(!this.save){this.context.stdout.write(a);return}let u=e.get("patchFolder"),g=k.join(u,`${P.slugifyLocator(c)}.patch`);await K.mkdirPromise(u,{recursive:!0}),await K.writeFilePromise(g,a);let f=k.relative(r.cwd,g);r.topLevelWorkspace.manifest.resolutions.push({pattern:{descriptor:{fullName:P.stringifyIdent(c),description:l.version}},reference:`patch:${P.stringifyLocator(c)}#${f}`}),await r.persist()}};QE.paths=[["patch-commit"]],QE.usage=Re.Usage({description:"generate a patch out of a directory",details:"\n      By default, this will print a patchfile on stdout based on the diff between the folder passed in and the original version of the package. Such file is suitable for consumption with the `patch:` protocol.\n\n      With the `-s,--save` option set, the patchfile won't be printed on stdout anymore and will instead be stored within a local file (by default kept within `.yarn/patches`, but configurable via the `patchFolder` setting). A `resolutions` entry will also be added to your top-level manifest, referencing the patched package via the `patch:` protocol.\n\n      Note that only folders generated by `yarn patch` are accepted as valid input for `yarn patch-commit`.\n    "});var Tge=QE;var vE=class extends Le{constructor(){super(...arguments);this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.package=z.String()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,workspace:i}=await ze.find(e,this.context.cwd),n=await Nt.find(e);if(!i)throw new ht(r.cwd,this.context.cwd);await r.restoreInstallState();let s=P.parseLocator(this.package);if(s.reference==="unknown"){let o=Se.mapAndFilter([...r.storedPackages.values()],a=>a.identHash!==s.identHash?Se.mapAndFilter.skip:P.isVirtualLocator(a)?Se.mapAndFilter.skip:a);if(o.length===0)throw new Pe("No package found in the project for the given locator");if(o.length>1)throw new Pe(`Multiple candidate packages found; explicitly choose one of them (use \`yarn why <package>\` to get more information as to who depends on them):
+${o.map(a=>`
+- ${P.prettyLocator(e,a)}`).join("")}`);s=o[0]}if(!r.storedPackages.has(s.locatorHash))throw new Pe("No package found in the project for the given locator");await Je.start({configuration:e,json:this.json,stdout:this.context.stdout},async o=>{let a=await IO(s,{cache:n,project:r});o.reportJson({locator:P.stringifyLocator(s),path:j.fromPortablePath(a)}),o.reportInfo($.UNNAMED,`Package ${P.prettyLocator(e,s)} got extracted with success!`),o.reportInfo($.UNNAMED,`You can now edit the following folder: ${ae.pretty(e,j.fromPortablePath(a),"magenta")}`),o.reportInfo($.UNNAMED,`Once you are done run ${ae.pretty(e,`yarn patch-commit -s ${process.platform==="win32"?'"':""}${j.fromPortablePath(a)}${process.platform==="win32"?'"':""}`,"cyan")} and Yarn will store a patchfile based on your changes.`)})}};vE.paths=[["patch"]],vE.usage=Re.Usage({description:"prepare a package for patching",details:"\n      This command will cause a package to be extracted in a temporary directory intended to be editable at will.\n      \n      Once you're done with your changes, run `yarn patch-commit -s <path>` (with `<path>` being the temporary directory you received) to generate a patchfile and register it into your top-level manifest via the `patch:` protocol. Run `yarn patch-commit -h` for more details.\n    "});var Oge=vE;var w9e={configuration:{enableInlineHunks:{description:"If true, the installs will print unmatched patch hunks",type:Ie.BOOLEAN,default:!1},patchFolder:{description:"Folder where the patch files must be written",type:Ie.ABSOLUTE_PATH,default:"./.yarn/patches"}},commands:[Tge,Oge],fetchers:[wO],resolvers:[BO]},B9e=w9e;var kO={};ft(kO,{default:()=>v9e});var QO=class{supportsPackage(e,r){return this.isEnabled(r)}async findPackageLocation(e,r){if(!this.isEnabled(r))throw new Error("Assertion failed: Expected the pnpm linker to be enabled");let i=vO(),n=r.project.installersCustomData.get(i);if(!n)throw new Pe(`The project in ${ae.pretty(r.project.configuration,`${r.project.cwd}/package.json`,ae.Type.PATH)} doesn't seem to have been installed - running an install there might help`);let s=n.pathByLocator.get(e.locatorHash);if(typeof s=="undefined")throw new Pe(`Couldn't find ${P.prettyLocator(r.project.configuration,e)} in the currently installed pnpm map - running an install might help`);return s}async findPackageLocator(e,r){if(!this.isEnabled(r))return null;let i=vO(),n=r.project.installersCustomData.get(i);if(!n)throw new Pe(`The project in ${ae.pretty(r.project.configuration,`${r.project.cwd}/package.json`,ae.Type.PATH)} doesn't seem to have been installed - running an install there might help`);let s=e.match(/(^.*\/node_modules\/(@[^/]*\/)?[^/]+)(\/.*$)/);if(s){let l=n.locatorByPath.get(s[1]);if(l)return l}let o=e,a=e;do{a=o,o=k.dirname(a);let l=n.locatorByPath.get(a);if(l)return l}while(o!==a);return null}makeInstaller(e){return new Mge(e)}isEnabled(e){return e.project.configuration.get("nodeLinker")==="pnpm"}},Mge=class{constructor(e){this.opts=e;this.asyncActions=new Se.AsyncActions(10);this.customData={pathByLocator:new Map,locatorByPath:new Map}}getCustomDataKey(){return vO()}attachCustomData(e){}async installPackage(e,r,i){switch(e.linkType){case Qt.SOFT:return this.installPackageSoft(e,r,i);case Qt.HARD:return this.installPackageHard(e,r,i)}throw new Error("Assertion failed: Unsupported package link type")}async installPackageSoft(e,r,i){let n=k.resolve(r.packageFs.getRealPath(),r.prefixPath);return this.customData.pathByLocator.set(e.locatorHash,n),{packageLocation:n,buildDirective:null}}async installPackageHard(e,r,i){var u;let n=b9e(e,{project:this.opts.project});this.customData.locatorByPath.set(n,P.stringifyLocator(e)),this.customData.pathByLocator.set(e.locatorHash,n),i.holdFetchResult(this.asyncActions.set(e.locatorHash,async()=>{await K.mkdirPromise(n,{recursive:!0}),await K.copyPromise(n,r.prefixPath,{baseFs:r.packageFs,overwrite:!1})}));let o=P.isVirtualLocator(e)?P.devirtualizeLocator(e):e,a={manifest:(u=await At.tryFind(r.prefixPath,{baseFs:r.packageFs}))!=null?u:new At,misc:{hasBindingGyp:wo.hasBindingGyp(r)}},l=this.opts.project.getDependencyMeta(o,e.version),c=wo.extractBuildScripts(e,a,l,{configuration:this.opts.project.configuration,report:this.opts.report});return{packageLocation:n,buildDirective:c}}async attachInternalDependencies(e,r){this.opts.project.configuration.get("nodeLinker")==="pnpm"&&(!Hge(e,{project:this.opts.project})||this.asyncActions.reduce(e.locatorHash,async i=>{await i;let n=this.customData.pathByLocator.get(e.locatorHash);if(typeof n=="undefined")throw new Error(`Assertion failed: Expected the package to have been registered (${P.stringifyLocator(e)})`);let s=k.join(n,Pt.nodeModules),o=[],a=await jge(s);for(let[l,c]of r){let u=c;Hge(c,{project:this.opts.project})||(this.opts.report.reportWarning($.UNNAMED,"The pnpm linker doesn't support providing different versions to workspaces' peer dependencies"),u=P.devirtualizeLocator(c));let g=this.customData.pathByLocator.get(u.locatorHash);if(typeof g=="undefined")throw new Error(`Assertion failed: Expected the package to have been registered (${P.stringifyLocator(c)})`);let f=P.stringifyIdent(l),h=k.join(s,f),p=k.relative(k.dirname(h),g),m=a.get(f);a.delete(f),o.push(Promise.resolve().then(async()=>{if(m){if(m.isSymbolicLink()&&await K.readlinkPromise(h)===p)return;await K.removePromise(h)}await K.mkdirpPromise(k.dirname(h)),process.platform=="win32"?await K.symlinkPromise(g,h,"junction"):await K.symlinkPromise(p,h)}))}o.push(Gge(s,a)),await Promise.all(o)}))}async attachExternalDependents(e,r){throw new Error("External dependencies haven't been implemented for the pnpm linker")}async finalizeInstall(){let e=Kge(this.opts.project);if(this.opts.project.configuration.get("nodeLinker")!=="pnpm")await K.removePromise(e);else{let r=[],i=new Set;for(let s of this.customData.pathByLocator.values()){let o=k.contains(e,s);if(o!==null){let[a,,...l]=o.split(k.sep);i.add(a);let c=k.join(e,a);r.push(K.readdirPromise(c).then(u=>Promise.all(u.map(async g=>{let f=k.join(c,g);if(g===Pt.nodeModules){let h=await jge(f);return h.delete(l.join(k.sep)),Gge(f,h)}else return K.removePromise(f)}))).catch(u=>{if(u.code!=="ENOENT")throw u}))}}let n;try{n=await K.readdirPromise(e)}catch{n=[]}for(let s of n)i.has(s)||r.push(K.removePromise(k.join(e,s)));await Promise.all(r)}return await this.asyncActions.wait(),await SO(e),await SO(Uge(this.opts.project)),{customData:this.customData}}};function vO(){return JSON.stringify({name:"PnpmInstaller",version:2})}function Uge(t){return k.join(t.cwd,Pt.nodeModules)}function Kge(t){return k.join(Uge(t),".store")}function b9e(t,{project:e}){let r=P.slugifyLocator(t),i=P.getIdentVendorPath(t);return k.join(Kge(e),r,i)}function Hge(t,{project:e}){return!P.isVirtualLocator(t)||!e.tryWorkspaceByLocator(t)}async function jge(t){let e=new Map,r=[];try{r=await K.readdirPromise(t,{withFileTypes:!0})}catch(i){if(i.code!=="ENOENT")throw i}try{for(let i of r)if(!i.name.startsWith("."))if(i.name.startsWith("@")){let n=await K.readdirPromise(k.join(t,i.name),{withFileTypes:!0});if(n.length===0)e.set(i.name,i);else for(let s of n)e.set(`${i.name}/${s.name}`,s)}else e.set(i.name,i)}catch(i){if(i.code!=="ENOENT")throw i}return e}async function Gge(t,e){var n;let r=[],i=new Set;for(let s of e.keys()){r.push(K.removePromise(k.join(t,s)));let o=(n=P.tryParseIdent(s))==null?void 0:n.scope;o&&i.add(`@${o}`)}return Promise.all(r).then(()=>Promise.all([...i].map(s=>SO(k.join(t,s)))))}async function SO(t){try{await K.rmdirPromise(t)}catch(e){if(e.code!=="ENOENT"&&e.code!=="ENOTEMPTY")throw e}}var Q9e={linkers:[QO]},v9e=Q9e;var J0=()=>({modules:new Map([["@yarnpkg/cli",_C],["@yarnpkg/core",QC],["@yarnpkg/fslib",Zh],["@yarnpkg/libzip",Md],["@yarnpkg/parsers",op],["@yarnpkg/shell",Kd],["clipanion",c$(Cp)],["semver",S9e],["typanion",sg],["yup",k9e],["@yarnpkg/plugin-essentials",AL],["@yarnpkg/plugin-compat",gL],["@yarnpkg/plugin-dlx",fL],["@yarnpkg/plugin-file",wL],["@yarnpkg/plugin-git",aL],["@yarnpkg/plugin-github",bL],["@yarnpkg/plugin-http",SL],["@yarnpkg/plugin-init",DL],["@yarnpkg/plugin-link",TL],["@yarnpkg/plugin-nm",gT],["@yarnpkg/plugin-npm",uO],["@yarnpkg/plugin-npm-cli",dO],["@yarnpkg/plugin-pack",aO],["@yarnpkg/plugin-patch",bO],["@yarnpkg/plugin-pnp",eT],["@yarnpkg/plugin-pnpm",kO]]),plugins:new Set(["@yarnpkg/plugin-essentials","@yarnpkg/plugin-compat","@yarnpkg/plugin-dlx","@yarnpkg/plugin-file","@yarnpkg/plugin-git","@yarnpkg/plugin-github","@yarnpkg/plugin-http","@yarnpkg/plugin-init","@yarnpkg/plugin-link","@yarnpkg/plugin-nm","@yarnpkg/plugin-npm","@yarnpkg/plugin-npm-cli","@yarnpkg/plugin-pack","@yarnpkg/plugin-patch","@yarnpkg/plugin-pnp","@yarnpkg/plugin-pnpm"])});d0({binaryVersion:Ur||"<unknown>",pluginConfiguration:J0()});})();
+/*!
+ * buildToken
+ * Builds OAuth token prefix (helper function)
+ *
+ * @name buildToken
+ * @function
+ * @param {GitUrl} obj The parsed Git url object.
+ * @return {String} token prefix
+ */
+/*!
+ * fill-range <https://github.com/jonschlinkert/fill-range>
+ *
+ * Copyright (c) 2014-present, Jon Schlinkert.
+ * Licensed under the MIT License.
+ */
+/*!
+ * is-extglob <https://github.com/jonschlinkert/is-extglob>
+ *
+ * Copyright (c) 2014-2016, Jon Schlinkert.
+ * Licensed under the MIT License.
+ */
+/*!
+ * is-glob <https://github.com/jonschlinkert/is-glob>
+ *
+ * Copyright (c) 2014-2017, Jon Schlinkert.
+ * Released under the MIT License.
+ */
+/*!
+ * is-number <https://github.com/jonschlinkert/is-number>
+ *
+ * Copyright (c) 2014-present, Jon Schlinkert.
+ * Released under the MIT License.
+ */
+/*!
+ * is-windows <https://github.com/jonschlinkert/is-windows>
+ *
+ * Copyright © 2015-2018, Jon Schlinkert.
+ * Released under the MIT License.
+ */
+/*!
+ * to-regex-range <https://github.com/micromatch/to-regex-range>
+ *
+ * Copyright (c) 2015-present, Jon Schlinkert.
+ * Released under the MIT License.
+ */
diff --git a/services/workbench2/.yarnrc b/services/workbench2/.yarnrc
new file mode 100644 (file)
index 0000000..95b8581
--- /dev/null
@@ -0,0 +1 @@
+save-prefix false
diff --git a/services/workbench2/.yarnrc.yml b/services/workbench2/.yarnrc.yml
new file mode 100644 (file)
index 0000000..f4367f4
--- /dev/null
@@ -0,0 +1,6 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+yarnPath: .yarn/releases/yarn-3.2.0.cjs
+nodeLinker: node-modules
diff --git a/services/workbench2/AUTHORS b/services/workbench2/AUTHORS
new file mode 100644 (file)
index 0000000..9a861a6
--- /dev/null
@@ -0,0 +1,20 @@
+# Names should be added to this file with this pattern:
+#
+# For individuals:
+#   Name <email address>
+#
+# For organizations:
+#   Organization <fnmatch pattern>
+#
+# See python fnmatch module documentation for more information.
+
+Curoverse, Inc. <*@curoverse.com>
+Adam Savitzky <adam.savitzky@gmail.com>
+Colin Nolan <colin.nolan@sanger.ac.uk>
+David <davide.fiorentino.loregio@gmail.com>
+Guillermo Carrasco <guille.ch.88@gmail.com>
+Joshua Randall <joshua.randall@sanger.ac.uk>
+President and Fellows of Harvard College <*@harvard.edu>
+Thomas Mooney <tmooney@genome.wustl.edu>
+Chen Chen <aflyhorse@gmail.com>
+Veritas Genetics, Inc. <*@veritasgenetics.com>
diff --git a/services/workbench2/COPYING b/services/workbench2/COPYING
new file mode 100644 (file)
index 0000000..61c3139
--- /dev/null
@@ -0,0 +1,19 @@
+Unless indicated otherwise in the header of the file, the files in this
+repository are distributed under one of three different licenses: AGPL-3.0,
+Apache-2.0 or CC-BY-SA-3.0.
+
+Individual files contain an SPDX tag that indicates the license for the file.
+These are the three tags in use:
+
+    SPDX-License-Identifier: AGPL-3.0
+    SPDX-License-Identifier: Apache-2.0
+    SPDX-License-Identifier: CC-BY-SA-3.0
+
+This enables machine processing of license information based on the SPDX
+License Identifiers that are available here: http://spdx.org/licenses/
+
+The full license text for each license is available in this directory:
+
+  AGPL-3.0:     agpl-3.0.txt
+  Apache-2.0:   apache-2.0.txt
+  CC-BY-SA-3.0: cc-by-sa-3.0.txt
diff --git a/services/workbench2/Makefile b/services/workbench2/Makefile
new file mode 100644 (file)
index 0000000..72235b9
--- /dev/null
@@ -0,0 +1,204 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# Use bash, and run all lines in each recipe as one shell command
+SHELL := /bin/bash
+.ONESHELL:
+
+GOPATH?=~/go
+APP_NAME?=arvados-workbench2
+
+# Cypress test file that can be passed to the integration-test target
+SPECFILE?=ALL
+
+# VERSION uses all the above to produce X.Y.Z.timestamp
+# something in the lines of 1.2.0.20180612145021, this will be the package version
+# it can be overwritten when invoking make as in make packages VERSION=1.2.0
+VERSION?=$(shell ./version-at-commit.sh HEAD)
+# We don't use BUILD_NUMBER at the moment, but it needs to be defined
+BUILD_NUMBER?=0
+GIT_COMMIT?=$(shell git rev-parse --short HEAD)
+
+# ITERATION is the package iteration, intended for manual change if anything non-code related
+# changes in the package. (i.e. example config files externally added
+ITERATION?=1
+
+TARGETS?=rocky8 debian11 debian12 ubuntu2004 ubuntu2204
+
+DESCRIPTION=Arvados Workbench2 - Arvados is a free and open source platform for big data science.
+MAINTAINER=Arvados Package Maintainers <packaging@arvados.org>
+
+# DEST_DIR will have the build package copied.
+DEST_DIR=/var/www/$(APP_NAME)/workbench2/
+
+# Debian package file
+DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb
+
+# redHat package file
+RPM_FILE=$(APP_NAME)-$(VERSION)-$(ITERATION).x86_64.rpm
+
+GOPATH=$(shell go env GOPATH)
+export WORKSPACE?=$(shell pwd)
+
+ARVADOS_DIRECTORY?=$(shell env -C $(WORKSPACE) git rev-parse --show-toplevel)
+
+ifndef ci
+       TI=-ti
+else
+       TI=
+endif
+
+.PHONY: help clean* yarn-install test build packages packages-with-version integration-tests-in-docker
+
+help:
+       @echo >&2
+       @echo >&2 "There is no default make target here.  Did you mean 'make test'?"
+       @echo >&2
+       @echo >&2 "More info:"
+       @echo >&2 "  Installing              --> http://doc.arvados.org/install"
+       @echo >&2 "  Developing/contributing --> https://dev.arvados.org"
+       @echo >&2 "  Project home            --> https://arvados.org"
+       @echo >&2
+       @false
+
+clean-deb:
+       rm -f $(WORKSPACE)/*.deb
+
+clean-rpm:
+       rm -f $(WORKSPACE)/*.rpm
+
+clean-node-modules:
+       rm -rf $(WORKSPACE)/node_modules
+
+clean: clean-rpm clean-deb clean-node-modules
+
+arvados-server-install: check-arvados-directory
+       cd $(ARVADOS_DIRECTORY)
+       go mod download
+       cd cmd/arvados-server
+       echo GOPATH is $(GOPATH)
+       GOFLAGS=-buildvcs=false go install
+       cd -
+       ls -l $(GOPATH)/bin/arvados-server
+       $(GOPATH)/bin/arvados-server install -type test
+
+yarn-install:
+       yarn install
+
+unit-tests: yarn-install
+       yarn test --no-watchAll --bail --ci
+
+integration-tests: yarn-install check-arvados-directory
+       yarn run cypress install
+ifeq ($(SPECFILE), ALL)
+       $(WORKSPACE)/tools/run-integration-tests.sh -a $(ARVADOS_DIRECTORY)
+else
+       $(WORKSPACE)/tools/run-integration-tests.sh -a $(ARVADOS_DIRECTORY) -- --spec $(SPECFILE)
+endif
+
+integration-tests-in-docker: workbench2-build-image check-arvados-directory
+       docker run $(TI) --rm \
+               --env ARVADOS_DIRECTORY=/usr/src/arvados \
+               --env GIT_DISCOVERY_ACROSS_FILESYSTEM=1 \
+               -v $(WORKSPACE):/usr/src/arvados/services/workbench2 \
+               -v $(ARVADOS_DIRECTORY):/usr/src/arvados \
+               -w /usr/src/arvados/services/workbench2 \
+               workbench2-build \
+               make arvados-server-install integration-tests SPECFILE=$(SPECFILE)
+
+unit-tests-in-docker: workbench2-build-image check-arvados-directory
+       docker run $(TI) --rm \
+               --env ARVADOS_DIRECTORY=/usr/src/arvados \
+               --env GIT_DISCOVERY_ACROSS_FILESYSTEM=1 \
+               -v $(WORKSPACE):/usr/src/arvados/services/workbench2 \
+               -v $(ARVADOS_DIRECTORY):/usr/src/arvados \
+               -w /usr/src/arvados/services/workbench2 \
+               workbench2-build \
+               make arvados-server-install unit-tests
+
+tests-in-docker: workbench2-build-image check-arvados-directory
+       docker run $(TI) --rm \
+               --env ARVADOS_DIRECTORY=/usr/src/arvados \
+               --env GIT_DISCOVERY_ACROSS_FILESYSTEM=1 \
+               --env ci="${ci}" \
+               -v $(WORKSPACE):/usr/src/arvados/services/workbench2 \
+               -v$(ARVADOS_DIRECTORY):/usr/src/arvados \
+               -w /usr/src/arvados/services/workbench2 \
+               workbench2-build \
+               make test
+
+test: unit-tests integration-tests
+
+build: yarn-install
+       VERSION=$(VERSION) BUILD_NUMBER=$(BUILD_NUMBER) GIT_COMMIT=$(GIT_COMMIT) yarn build
+
+$(DEB_FILE): build
+       fpm \
+        -s dir \
+        -t deb \
+        -n "$(APP_NAME)" \
+        -v "$(VERSION)" \
+        --iteration "$(ITERATION)" \
+        --vendor="The Arvados Authors" \
+        --maintainer="$(MAINTAINER)" \
+        --url="https://arvados.org" \
+        --license="GNU Affero General Public License, version 3.0" \
+        --description="$(DESCRIPTION)" \
+        --config-files="etc/arvados/$(APP_NAME)/workbench2.example.json" \
+       $(WORKSPACE)/build/=$(DEST_DIR) \
+       etc/arvados/workbench2/workbench2.example.json=/etc/arvados/$(APP_NAME)/workbench2.example.json
+
+$(RPM_FILE): build
+       fpm \
+        -s dir \
+        -t rpm \
+        -n "$(APP_NAME)" \
+        -v "$(VERSION)" \
+        --iteration "$(ITERATION)" \
+        --vendor="The Arvados Authors" \
+        --maintainer="$(MAINTAINER)" \
+        --url="https://arvados.org" \
+        --license="GNU Affero General Public License, version 3.0" \
+        --description="$(DESCRIPTION)" \
+        --config-files="etc/arvados/$(APP_NAME)/workbench2.example.json" \
+        $(WORKSPACE)/build/=$(DEST_DIR) \
+       etc/arvados/workbench2/workbench2.example.json=/etc/arvados/$(APP_NAME)/workbench2.example.json
+
+copy: $(DEB_FILE) $(RPM_FILE)
+       for target in $(TARGETS); do \
+               mkdir -p "packages/$$target" && \
+               case "$$target" in \
+                       centos*|rocky*) cp -p "$(RPM_FILE)" "packages/$$target" ;; \
+                       debian*|ubuntu*) cp -p "$(DEB_FILE)" "packages/$$target" ;; \
+                       *) echo "Unknown copy target $$target"; exit 1 ;; \
+               esac ; \
+       done ; \
+       rm -f "$(DEB_FILE)" "$(RPM_FILE)"
+
+# use FPM to create DEB and RPM
+packages: copy
+
+check-arvados-directory:
+       @if test "${ARVADOS_DIRECTORY}" == "unset"; then echo "the environment variable ARVADOS_DIRECTORY must be set to the path of an arvados git checkout"; exit 1; fi
+       @if ! test -d "${ARVADOS_DIRECTORY}"; then echo "the environment variable ARVADOS_DIRECTORY does not point at a directory"; exit 1; fi
+
+packages-in-docker: check-arvados-directory workbench2-build-image
+       docker run -t --rm --env ci="true" \
+               --env ARVADOS_DIRECTORY=/tmp/arvados \
+               --env APP_NAME=${APP_NAME} \
+               --env VERSION="${VERSION}" \
+               --env ITERATION=${ITERATION} \
+               --env TARGETS="${TARGETS}" \
+               --env MAINTAINER="${MAINTAINER}" \
+               --env DESCRIPTION="${DESCRIPTION}" \
+               --env GIT_DISCOVERY_ACROSS_FILESYSTEM=1 \
+               -w "/tmp/workbench2" \
+               -v ${WORKSPACE}:/tmp/workbench2 \
+               -v ${ARVADOS_DIRECTORY}:/tmp/arvados \
+               workbench2-build:latest \
+               sh -c 'git config --global --add safe.directory /tmp/workbench2 && make packages'
+
+workbench2-build-image:
+       docker inspect workbench2-build &> /dev/null || \
+               docker build -t workbench2-build -f docker/Dockerfile ${ARVADOS_DIRECTORY}
diff --git a/services/workbench2/README.md b/services/workbench2/README.md
new file mode 100644 (file)
index 0000000..9aa788a
--- /dev/null
@@ -0,0 +1,113 @@
+[comment]: # (Copyright © The Arvados Authors. All rights reserved.)
+[comment]: # ()
+[comment]: # (SPDX-License-Identifier: CC-BY-SA-3.0)
+
+# Arvados Workbench 2
+
+## Setup
+```
+npm install yarn
+yarn install
+```
+
+Install [redux-devtools-extension](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd)
+
+## Start project for development
+```
+yarn start
+```
+
+## Start project for development inside Docker container
+
+```
+make workbench2-build-image
+# (create public/config.json, see "Run time configuration" below)
+docker run -ti -v$PWD:$PWD -p 3000:3000 -w$PWD workbench2-build /bin/bash
+# (inside docker container)
+yarn install
+yarn start
+```
+
+## Run unit tests
+```
+make unit-tests
+```
+
+## Run end-to-end tests
+
+```
+make integration-tests
+```
+
+## Run end-to-end tests in a Docker container
+
+```
+make integration-tests-in-docker
+```
+
+## Run tests interactively in container
+
+```
+xhost +local:root
+docker run -ti -v$PWD:$PWD -v$(realpath ../..):/usr/src/arvados -w$PWD --env="DISPLAY" --volume="/tmp/.X11-unix:/tmp/.X11-unix:rw" workbench2-build /bin/bash
+(inside container)
+yarn run cypress install
+tools/run-integration-tests.sh -i -a /usr/src/arvados
+```
+
+## Production build
+```
+yarn build
+```
+
+## Package build
+```
+make packages
+```
+
+## Build time configuration
+You can customize project global variables using env variables. Default values are placed in the `.env` file.
+
+Example:
+```
+REACT_APP_ARVADOS_CONFIG_URL=config.json yarn build
+```
+
+## Run time configuration
+The app will fetch runtime configuration when starting. By default it will try to fetch `/config.json`.  In development mode, this can be found in the `public` directory.
+You can customize this url using build time configuration.
+
+Currently this configuration schema is supported:
+```
+{
+    "API_HOST": "string",
+    "FILE_VIEWERS_CONFIG_URL": "string",
+}
+```
+
+### API_HOST
+
+The Arvados base URL.
+
+The `REACT_APP_ARVADOS_API_HOST` environment variable can be used to set the default URL if the run time configuration is unreachable.
+
+## FILE_VIEWERS_CONFIG_URL
+Local path, or any URL that allows cross-origin requests. See:
+
+[File viewers config file example](public/file-viewers-example.json)
+
+[File viewers config scheme](src/models/file-viewers-config.ts)
+
+To use the URL defined in the Arvados cluster configuration, remove the entire `FILE_VIEWERS_CONFIG_URL` entry from the runtime configuration. Found in `/config.json` by default.
+
+## Plugin support
+
+Workbench supports plugins to add new functionality to the user
+interface.  For information about installing plugins, the provided
+example plugins, see [src/plugins/README.md](src/plugins/README.md).
+
+
+## Licensing
+
+Arvados is Free Software. See COPYING for information about Arvados Free
+Software licenses.
diff --git a/services/workbench2/__mocks__/popper.js.js b/services/workbench2/__mocks__/popper.js.js
new file mode 100644 (file)
index 0000000..07c7856
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export default class Popper {
+    static placements = [
+        'auto',
+        'auto-end',
+        'auto-start',
+        'bottom',
+        'bottom-end',
+        'bottom-start',
+        'left',
+        'left-end',
+        'left-start',
+        'right',
+        'right-end',
+        'right-start',
+        'top',
+        'top-end',
+        'top-start'
+    ];
+
+    constructor() {
+        return {
+            destroy: jest.fn(),
+            scheduleUpdate: jest.fn()
+        };
+    }
+}
\ No newline at end of file
diff --git a/services/workbench2/agpl-3.0.txt b/services/workbench2/agpl-3.0.txt
new file mode 100644 (file)
index 0000000..dba13ed
--- /dev/null
@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<http://www.gnu.org/licenses/>.
diff --git a/services/workbench2/apache-2.0.txt b/services/workbench2/apache-2.0.txt
new file mode 100644 (file)
index 0000000..d645695
--- /dev/null
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/services/workbench2/cc-by-sa-3.0.txt b/services/workbench2/cc-by-sa-3.0.txt
new file mode 100644 (file)
index 0000000..281c9b6
--- /dev/null
@@ -0,0 +1,297 @@
+Creative Commons Legal Code
+
+Attribution-ShareAlike 3.0 United States
+
+License
+
+THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE
+COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY
+COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS
+AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
+
+BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE TO BE
+BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY BE
+CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE
+IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND CONDITIONS.
+
+1. Definitions
+
+ a. "Collective Work" means a work, such as a periodical issue, anthology or
+    encyclopedia, in which the Work in its entirety in unmodified form, along
+    with one or more other contributions, constituting separate and independent
+    works in themselves, are assembled into a collective whole. A work that
+    constitutes a Collective Work will not be considered a Derivative Work (as
+    defined below) for the purposes of this License.
+
+ b. "Creative Commons Compatible License" means a license that is listed at
+    http://creativecommons.org/compatiblelicenses that has been approved by
+    Creative Commons as being essentially equivalent to this License,
+    including, at a minimum, because that license: (i) contains terms that have
+    the same purpose, meaning and effect as the License Elements of this
+    License; and, (ii) explicitly permits the relicensing of derivatives of
+    works made available under that license under this License or either a
+    Creative Commons unported license or a Creative Commons jurisdiction
+    license with the same License Elements as this License.
+
+ c. "Derivative Work" means a work based upon the Work or upon the Work and
+    other pre-existing works, such as a translation, musical arrangement,
+    dramatization, fictionalization, motion picture version, sound recording,
+    art reproduction, abridgment, condensation, or any other form in which the
+    Work may be recast, transformed, or adapted, except that a work that
+    constitutes a Collective Work will not be considered a Derivative Work for
+    the purpose of this License. For the avoidance of doubt, where the Work is
+    a musical composition or sound recording, the synchronization of the Work
+    in timed-relation with a moving image ("synching") will be considered a
+    Derivative Work for the purpose of this License.
+
+ d. "License Elements" means the following high-level license attributes as
+    selected by Licensor and indicated in the title of this License:
+    Attribution, ShareAlike.
+
+ e. "Licensor" means the individual, individuals, entity or entities that
+    offers the Work under the terms of this License.
+
+ f. "Original Author" means the individual, individuals, entity or entities who
+    created the Work.
+
+ g. "Work" means the copyrightable work of authorship offered under the terms
+    of this License.
+
+    h. "You" means an individual or entity exercising rights under this License
+    who has not previously violated the terms of this License with respect to
+    the Work, or who has received express permission from the Licensor to
+    exercise rights under this License despite a previous violation.
+
+2. Fair Use Rights. Nothing in this license is intended to reduce, limit, or
+restrict any rights arising from fair use, first sale or other limitations on
+the exclusive rights of the copyright owner under copyright law or other
+applicable laws.
+
+3. License Grant. Subject to the terms and conditions of this License, Licensor
+hereby grants You a worldwide, royalty-free, non-exclusive, perpetual (for the
+duration of the applicable copyright) license to exercise the rights in the
+Work as stated below:
+
+ a. to reproduce the Work, to incorporate the Work into one or more Collective
+    Works, and to reproduce the Work as incorporated in the Collective Works;
+
+ b. to create and reproduce Derivative Works provided that any such
+    Derivative Work, including any translation in any medium, takes reasonable
+    steps to clearly label, demarcate or otherwise identify that changes were
+    made to the original Work. For example, a translation could be marked "The
+    original work was translated from English to Spanish," or a modification
+    could indicate "The original work has been modified.";
+
+ c. to distribute copies or phonorecords of, display publicly, perform
+    publicly, and perform publicly by means of a digital audio transmission the
+    Work including as incorporated in Collective Works;
+
+ d. to distribute copies or phonorecords of, display publicly, perform
+    publicly, and perform publicly by means of a digital audio transmission
+    Derivative Works.
+
+ e. For the avoidance of doubt, where the Work is a musical composition:
+
+     i. Performance Royalties Under Blanket Licenses. Licensor waives the
+        exclusive right to collect, whether individually or, in the event that
+        Licensor is a member of a performance rights society (e.g. ASCAP, BMI,
+        SESAC), via that society, royalties for the public performance or
+        public digital performance (e.g. webcast) of the Work.
+
+    ii. Mechanical Rights and Statutory Royalties. Licensor waives the
+        exclusive right to collect, whether individually or via a music rights
+        agency or designated agent (e.g. Harry Fox Agency), royalties for any
+        phonorecord You create from the Work ("cover version") and distribute,
+        subject to the compulsory license created by 17 USC Section 115 of the
+        US Copyright Act (or the equivalent in other jurisdictions).
+
+ f. Webcasting Rights and Statutory Royalties. For the avoidance of doubt,
+    where the Work is a sound recording, Licensor waives the exclusive right to
+    collect, whether individually or via a performance-rights society
+    (e.g. SoundExchange), royalties for the public digital performance
+    (e.g. webcast) of the Work, subject to the compulsory license created by 17
+    USC Section 114 of the US Copyright Act (or the equivalent in other
+    jurisdictions).
+
+The above rights may be exercised in all media and formats whether now known or
+hereafter devised. The above rights include the right to make such
+modifications as are technically necessary to exercise the rights in other
+media and formats. All rights not expressly granted by Licensor are hereby
+reserved.
+
+4. Restrictions. The license granted in Section 3 above is expressly made subject to and limited by the following restrictions:
+
+ a. You may distribute, publicly display, publicly perform, or publicly
+    digitally perform the Work only under the terms of this License, and You
+    must include a copy of, or the Uniform Resource Identifier for, this
+    License with every copy or phonorecord of the Work You distribute, publicly
+    display, publicly perform, or publicly digitally perform. You may not offer
+    or impose any terms on the Work that restrict the terms of this License or
+    the ability of a recipient of the Work to exercise of the rights granted to
+    that recipient under the terms of the License. You may not sublicense the
+    Work. You must keep intact all notices that refer to this License and to
+    the disclaimer of warranties. When You distribute, publicly display,
+    publicly perform, or publicly digitally perform the Work, You may not
+    impose any technological measures on the Work that restrict the ability of
+    a recipient of the Work from You to exercise of the rights granted to that
+    recipient under the terms of the License. This Section 4(a) applies to the
+    Work as incorporated in a Collective Work, but this does not require the
+    Collective Work apart from the Work itself to be made subject to the terms
+    of this License. If You create a Collective Work, upon notice from any
+    Licensor You must, to the extent practicable, remove from the Collective
+    Work any credit as required by Section 4(c), as requested. If You create a
+    Derivative Work, upon notice from any Licensor You must, to the extent
+    practicable, remove from the Derivative Work any credit as required by
+    Section 4(c), as requested.
+
+ b. You may distribute, publicly display, publicly perform, or publicly
+    digitally perform a Derivative Work only under: (i) the terms of this
+    License; (ii) a later version of this License with the same License
+    Elements as this License; (iii) either the Creative Commons (Unported)
+    license or a Creative Commons jurisdiction license (either this or a later
+    license version) that contains the same License Elements as this License
+    (e.g. Attribution-ShareAlike 3.0 (Unported)); (iv) a Creative Commons
+    Compatible License. If you license the Derivative Work under one of the
+    licenses mentioned in (iv), you must comply with the terms of that
+    license. If you license the Derivative Work under the terms of any of the
+    licenses mentioned in (i), (ii) or (iii) (the "Applicable License"), you
+    must comply with the terms of the Applicable License generally and with the
+    following provisions: (I) You must include a copy of, or the Uniform
+    Resource Identifier for, the Applicable License with every copy or
+    phonorecord of each Derivative Work You distribute, publicly display,
+    publicly perform, or publicly digitally perform; (II) You may not offer or
+    impose any terms on the Derivative Works that restrict the terms of the
+    Applicable License or the ability of a recipient of the Work to exercise
+    the rights granted to that recipient under the terms of the Applicable
+    License; (III) You must keep intact all notices that refer to the
+    Applicable License and to the disclaimer of warranties; and, (IV) when You
+    distribute, publicly display, publicly perform, or publicly digitally
+    perform the Work, You may not impose any technological measures on the
+    Derivative Work that restrict the ability of a recipient of the Derivative
+    Work from You to exercise the rights granted to that recipient under the
+    terms of the Applicable License. This Section 4(b) applies to the
+    Derivative Work as incorporated in a Collective Work, but this does not
+    require the Collective Work apart from the Derivative Work itself to be
+    made subject to the terms of the Applicable License.
+
+ c. If You distribute, publicly display, publicly perform, or publicly
+    digitally perform the Work (as defined in Section 1 above) or any
+    Derivative Works (as defined in Section 1 above) or Collective Works (as
+    defined in Section 1 above), You must, unless a request has been made
+    pursuant to Section 4(a), keep intact all copyright notices for the Work
+    and provide, reasonable to the medium or means You are utilizing: (i) the
+    name of the Original Author (or pseudonym, if applicable) if supplied,
+    and/or (ii) if the Original Author and/or Licensor designate another party
+    or parties (e.g. a sponsor institute, publishing entity, journal) for
+    attribution ("Attribution Parties") in Licensor's copyright notice, terms
+    of service or by other reasonable means, the name of such party or parties;
+    the title of the Work if supplied; to the extent reasonably practicable,
+    the Uniform Resource Identifier, if any, that Licensor specifies to be
+    associated with the Work, unless such URI does not refer to the copyright
+    notice or licensing information for the Work; and, consistent with Section
+    3(b) in the case of a Derivative Work, a credit identifying the use of the
+    Work in the Derivative Work (e.g., "French translation of the Work by
+    Original Author," or "Screenplay based on original Work by Original
+    Author"). The credit required by this Section 4(c) may be implemented in
+    any reasonable manner; provided, however, that in the case of a Derivative
+    Work or Collective Work, at a minimum such credit will appear, if a credit
+    for all contributing authors of the Derivative Work or Collective Work
+    appears, then as part of these credits and in a manner at least as
+    prominent as the credits for the other contributing authors. For the
+    avoidance of doubt, You may only use the credit required by this Section
+    for the purpose of attribution in the manner set out above and, by
+    exercising Your rights under this License, You may not implicitly or
+    explicitly assert or imply any connection with, sponsorship or endorsement
+    by the Original Author, Licensor and/or Attribution Parties, as
+    appropriate, of You or Your use of the Work, without the separate, express
+    prior written permission of the Original Author, Licensor and/or
+    Attribution Parties.
+
+
+5. Representations, Warranties and Disclaimer
+
+UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR OFFERS
+THE WORK AS-IS AND ONLY TO THE EXTENT OF ANY RIGHTS HELD IN THE LICENSED WORK
+BY THE LICENSOR. THE LICENSOR MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
+KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING,
+WITHOUT LIMITATION, WARRANTIES OF TITLE, MARKETABILITY, MERCHANTIBILITY,
+FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR
+OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT
+DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED
+WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
+
+6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN
+NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL,
+INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THIS
+LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+7. Termination
+
+ a. This License and the rights granted hereunder will terminate automatically
+    upon any breach by You of the terms of this License. Individuals or
+    entities who have received Derivative Works or Collective Works from You
+    under this License, however, will not have their licenses terminated
+    provided such individuals or entities remain in full compliance with those
+    licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any termination of
+    this License.
+
+ b. Subject to the above terms and conditions, the license granted here is
+    perpetual (for the duration of the applicable copyright in the
+    Work). Notwithstanding the above, Licensor reserves the right to release
+    the Work under different license terms or to stop distributing the Work at
+    any time; provided, however that any such election will not serve to
+    withdraw this License (or any other license that has been, or is required
+    to be, granted under the terms of this License), and this License will
+    continue in full force and effect unless terminated as stated above.
+
+8. Miscellaneous
+
+ a. Each time You distribute or publicly digitally perform the Work (as defined
+    in Section 1 above) or a Collective Work (as defined in Section 1 above),
+    the Licensor offers to the recipient a license to the Work on the same
+    terms and conditions as the license granted to You under this License.
+
+ b. Each time You distribute or publicly digitally perform a Derivative Work,
+    Licensor offers to the recipient a license to the original Work on the same
+    terms and conditions as the license granted to You under this License.
+
+ c. If any provision of this License is invalid or unenforceable under
+    applicable law, it shall not affect the validity or enforceability of the
+    remainder of the terms of this License, and without further action by the
+    parties to this agreement, such provision shall be reformed to the minimum
+    extent necessary to make such provision valid and enforceable.
+
+ d. No term or provision of this License shall be deemed waived and no breach
+    consented to unless such waiver or consent shall be in writing and signed
+    by the party to be charged with such waiver or consent.
+
+ e. This License constitutes the entire agreement between the parties with
+    respect to the Work licensed here. There are no understandings, agreements
+    or representations with respect to the Work not specified here. Licensor
+    shall not be bound by any additional provisions that may appear in any
+    communication from You. This License may not be modified without the mutual
+    written agreement of the Licensor and You.
+
+Creative Commons Notice
+
+    Creative Commons is not a party to this License, and makes no warranty
+    whatsoever in connection with the Work. Creative Commons will not be liable
+    to You or any party on any legal theory for any damages whatsoever,
+    including without limitation any general, special, incidental or
+    consequential damages arising in connection to this
+    license. Notwithstanding the foregoing two (2) sentences, if Creative
+    Commons has expressly identified itself as the Licensor hereunder, it shall
+    have all rights and obligations of Licensor.
+
+    Except for the limited purpose of indicating to the public that the Work is
+    licensed under the CCPL, Creative Commons does not authorize the use by
+    either party of the trademark "Creative Commons" or any related trademark
+    or logo of Creative Commons without the prior written consent of Creative
+    Commons. Any permitted use will be in compliance with Creative Commons'
+    then-current trademark usage guidelines, as may be published on its website
+    or otherwise made available upon request from time to time. For the
+    avoidance of doubt, this trademark restriction does not form part of this
+    License.
+
+    Creative Commons may be contacted at http://creativecommons.org/.
diff --git a/services/workbench2/cypress.config.ts b/services/workbench2/cypress.config.ts
new file mode 100644 (file)
index 0000000..d5698b0
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { defineConfig } from 'cypress'
+
+export default defineConfig({
+  chromeWebSecurity: false,
+  viewportWidth: 1920,
+  viewportHeight: 1080,
+  downloadsFolder: 'cypress/downloads',
+  videoCompression: false,
+  e2e: {
+    // We've imported your old cypress plugins here.
+    // You may want to clean this up later by importing these.
+    setupNodeEvents(on, config) {
+      return require('./cypress/plugins/index.js')(on, config)
+    },
+    baseUrl: 'https://localhost:3000/',
+    experimentalRunAllSpecs: true,
+    // The 2 options below make Electron crash a lot less and Firefox behave better
+    experimentalMemoryManagement: true,
+    numTestsKeptInMemory: 0,
+  },
+})
diff --git a/services/workbench2/cypress/e2e/banner-tooltip.cy.js b/services/workbench2/cypress/e2e/banner-tooltip.cy.js
new file mode 100644 (file)
index 0000000..f810096
--- /dev/null
@@ -0,0 +1,112 @@
+
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Banner / tooltip tests', function () {
+    let activeUser;
+    let adminUser;
+    let collectionUUID;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
+            .as('activeUser').then(function () {
+                activeUser = this.activeUser;
+            });
+
+        cy.getAll('@adminUser').then(([adminUser]) => {
+            // This collection will not be deleted after each test, we'll
+            // clean it up manually.
+            cy.createCollection(adminUser.token, {
+                name: `BannerTooltipTest${Math.floor(Math.random() * 999999)}`,
+                owner_uuid: adminUser.user.uuid,
+            }, true).as('bannerCollection');
+        });
+
+        cy.getAll('@bannerCollection').then(function ([bannerCollection]) {
+            collectionUUID = bannerCollection.uuid;
+
+            cy.loginAs(adminUser);
+
+            cy.goToPath(`/collections/${bannerCollection.uuid}`);
+
+            cy.get('[data-cy=upload-button]').click();
+
+            cy.fixture('files/banner.html').as('banner');
+            cy.fixture('files/tooltips.txt').as('tooltips');
+
+            cy.getAll('@banner', '@tooltips').then(([banner, tooltips]) => {
+                cy.get('[data-cy=drag-and-drop]').upload(banner, 'banner.html', false);
+                cy.get('[data-cy=drag-and-drop]').upload(tooltips, 'tooltips.json', false);
+            });
+
+            cy.get('[data-cy=form-submit-btn]').click();
+            cy.get('[data-cy=form-submit-btn]').should('not.exist');
+            cy.get('[data-cy=collection-files-right-panel]')
+                .should('contain', 'banner.html');
+            cy.get('[data-cy=collection-files-right-panel]')
+                .should('contain', 'tooltips.json');
+        });
+    });
+
+    beforeEach(function () {
+        cy.on('uncaught:exception', (err, runnable, promise) => {
+            Cypress.log({ message: `Application Error: ${err}`});
+            if (promise) {
+                return false;
+            }
+        });
+        cy.intercept({ method: 'GET', url: '**/arvados/v1/config?nocache=*' }, (req) => {
+            req.on('response', (res) => {
+                res.body.Workbench.BannerUUID = collectionUUID;
+            });
+        });
+    });
+
+    after(function () {
+        // Delete banner collection after all test used it.
+        cy.deleteResource(adminUser.token, "collections", collectionUUID);
+    });
+
+    it('should re-show the banner', () => {
+        cy.loginAs(adminUser);
+        cy.waitForDom();
+
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+        cy.waitForDom();
+        cy.get('[data-cy=confirmation-dialog]').should('not.exist');
+
+        cy.get('[title=Notifications]').click();
+        cy.get('li').contains('Restore Banner').click();
+
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').should('be.visible');
+    });
+
+
+    it('should show tooltips and remove tooltips as localStorage key is present', () => {
+        cy.loginAs(adminUser);
+
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        cy.contains('This allows you to navigate through the app').should('not.exist'); // This content comes from tooltips.txt
+        cy.get('[data-cy=side-panel-tree]').trigger('mouseover');
+        cy.get('[data-cy=side-panel-tree]').trigger('mouseenter');
+        cy.contains('This allows you to navigate through the app').should('be.visible');
+
+        cy.get('[title=Notifications]').click();
+        cy.get('li').contains('Disable tooltips').click();
+
+        cy.contains('This allows you to navigate through the app').should('not.exist');
+        cy.get('[data-cy=side-panel-tree]').trigger('mouseover');
+        cy.get('[data-cy=side-panel-tree]').trigger('mouseenter');
+        cy.contains('This allows you to navigate through the app').should('not.exist');
+    });
+});
diff --git a/services/workbench2/cypress/e2e/collection.cy.js b/services/workbench2/cypress/e2e/collection.cy.js
new file mode 100644 (file)
index 0000000..20ecf11
--- /dev/null
@@ -0,0 +1,1335 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+const path = require("path");
+
+describe("Collection panel tests", function () {
+    let activeUser;
+    let adminUser;
+    let downloadsFolder;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser("collectionuser1", "Collection", "User", false, true)
+            .as("activeUser")
+            .then(function () {
+                activeUser = this.activeUser;
+            });
+        downloadsFolder = Cypress.config("downloadsFolder");
+    });
+
+    it('shows the appropriate buttons in the toolbar', () => {
+
+        const msButtonTooltips = [
+            'View details',
+            'Open in new tab',
+            'Copy link to clipboard',
+            'Open with 3rd party client',
+            'API Details',
+            'Share',
+            'Edit collection',
+            'Move to',
+            'Make a copy',
+            'Move to trash',
+            'Add to favorites',
+        ];
+
+        cy.loginAs(activeUser);
+        const name = `Test collection ${Math.floor(Math.random() * 999999)}`;
+        cy.get("[data-cy=side-panel-button]").click({force: true});
+        cy.get("[data-cy=side-panel-new-collection]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New collection")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
+                });
+                cy.get("[data-cy=form-submit-btn]").click();
+            });
+            cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
+            cy.waitForDom()
+            cy.get('[data-cy=data-table-row]').contains(name).should('exist').parent().parent().parent().parent().click()
+            cy.waitForDom()
+            cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length)
+            for (let i = 0; i < msButtonTooltips.length; i++) {
+                cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover');
+                cy.get('body').contains(msButtonTooltips[i]).should('exist')
+                cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout');
+            }
+    })
+
+    it("allows to download mountain duck config for a collection", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection")
+            .then(function (testCollection) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testCollection.uuid}`);
+
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Open with 3rd party client").click();
+                cy.get("[data-cy=download-button").click();
+
+                const filename = path.join(downloadsFolder, `${testCollection.name}.duck`);
+
+                cy.readFile(filename, { timeout: 15000 })
+                    .then(body => {
+                        const childrenCollection = Array.prototype.slice.call(Cypress.$(body).find("dict")[0].children);
+                        const map = {};
+                        let i,
+                            j = 2;
+
+                        for (i = 0; i < childrenCollection.length; i += j) {
+                            map[childrenCollection[i].outerText] = childrenCollection[i + 1].outerText;
+                        }
+
+                        cy.get("#simple-tabpanel-0")
+                            .find("a")
+                            .then(a => {
+                                const [host, port] = a.text().split("@")[1].split("/")[0].split(":");
+                                expect(map["Protocol"]).to.equal("davs");
+                                expect(map["UUID"]).to.equal(testCollection.uuid);
+                                expect(map["Username"]).to.equal(activeUser.user.username);
+                                expect(map["Port"]).to.equal(port);
+                                expect(map["Hostname"]).to.equal(host);
+                                if (map["Path"]) {
+                                    expect(map["Path"]).to.equal(`/c=${testCollection.uuid}`);
+                                }
+                            });
+                    })
+                    .then(() => cy.task("clearDownload", { filename }));
+            });
+    });
+
+    it("attempts to use a preexisting name creating or updating a collection", function () {
+        const name = `Test collection ${Math.floor(Math.random() * 999999)}`;
+        cy.createCollection(adminUser.token, {
+            name: name,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        });
+        cy.loginAs(activeUser);
+        cy.goToPath(`/projects/${activeUser.user.uuid}`);
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("not.exist");
+        // Attempt to create new collection with a duplicate name
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-collection]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New collection")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
+                });
+                cy.get("[data-cy=form-submit-btn]").click();
+            });
+        // Error message should display, allowing editing the name
+        cy.get("[data-cy=form-dialog]")
+            .should("exist")
+            .and("contain", "Collection with the same name already exists")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(" renamed");
+                });
+                cy.get("[data-cy=form-submit-btn]").click({timeout: 10000});
+            });
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        // Attempt to rename the collection with the duplicate name
+        cy.get("[data-cy=collection-panel-options-btn]").click();
+        cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Edit Collection")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type("{selectall}{backspace}").type(name);
+                });
+                cy.get("[data-cy=form-submit-btn]").click();
+            });
+        cy.get("[data-cy=form-dialog]").should("exist").and("contain", "Collection with the same name already exists");
+    });
+
+    it("uses the property editor (from edit dialog) with vocabulary terms", function () {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection")
+            .then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                cy.get("[data-cy=collection-info-panel").should("contain", this.testCollection.name).and("not.contain", "Color: Magenta");
+
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]").should("contain", "Properties");
+
+                // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
+                cy.get("[data-cy=resource-properties-form]").within(() => {
+                    cy.get("[data-cy=property-field-key]").within(() => {
+                        cy.get("input").type("Color");
+                    });
+                    cy.get("[data-cy=property-field-value]").within(() => {
+                        cy.get("input").type("Magenta");
+                    });
+                    cy.root().submit();
+                });
+                // Confirm proper vocabulary labels are displayed on the UI.
+                cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
+                cy.get("[data-cy=form-dialog]").contains("Save").click();
+                cy.get("[data-cy=form-dialog]").should("not.exist");
+                // Confirm proper vocabulary IDs were saved on the backend.
+                cy.doRequest("GET", `/arvados/v1/collections/${this.testCollection.uuid}`)
+                    .its("body")
+                    .as("collection")
+                    .then(function () {
+                        expect(this.collection.properties.IDTAGCOLORS).to.equal("IDVALCOLORS3");
+                    });
+                // Confirm the property is displayed on the UI.
+                cy.get("[data-cy=collection-info-panel").should("contain", this.testCollection.name).and("contain", "Color: Magenta");
+            });
+    });
+
+    it("uses the editor (from details panel) with vocabulary terms", function () {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection")
+            .then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                cy.get("[data-cy=collection-info-panel")
+                    .should("contain", this.testCollection.name)
+                    .and("not.contain", "Color: Magenta")
+                    .and("not.contain", "Size: S");
+                cy.get("[data-cy=additional-info-icon]").click();
+
+                cy.get("[data-cy=details-panel]").within(() => {
+                    cy.get("[data-cy=details-panel-edit-btn]").click();
+                });
+                cy.get("[data-cy=form-dialog").contains("Edit Collection");
+
+                // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
+                cy.get("[data-cy=resource-properties-form]").within(() => {
+                    cy.get("[data-cy=property-field-key]").within(() => {
+                        cy.get("input").type("Color");
+                    });
+                    cy.get("[data-cy=property-field-value]").within(() => {
+                        cy.get("input").type("Magenta");
+                    });
+                    cy.root().submit();
+                });
+                // Confirm proper vocabulary labels are displayed on the UI.
+                cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
+
+                // Case-insensitive on-blur auto-selection test
+                // Key: Size (IDTAGSIZES) - Value: Small (IDVALSIZES2)
+                cy.get("[data-cy=resource-properties-form]").within(() => {
+                    cy.get("[data-cy=property-field-key]").within(() => {
+                        cy.get("input").type("sIzE");
+                    });
+                    cy.get("[data-cy=property-field-value]").within(() => {
+                        cy.get("input").type("sMaLL");
+                    });
+                    // Cannot "type()" TAB on Cypress so let's click another field
+                    // to trigger the onBlur event.
+                    cy.get("[data-cy=property-field-key]").click();
+                    cy.root().submit();
+                });
+                // Confirm proper vocabulary labels are displayed on the UI.
+                cy.get("[data-cy=form-dialog]").should("contain", "Size: S");
+
+                cy.get("[data-cy=form-dialog]").contains("Save").click();
+                cy.get("[data-cy=form-dialog]").should("not.exist");
+
+                // Confirm proper vocabulary IDs were saved on the backend.
+                cy.doRequest("GET", `/arvados/v1/collections/${this.testCollection.uuid}`)
+                    .its("body")
+                    .as("collection")
+                    .then(function () {
+                        expect(this.collection.properties.IDTAGCOLORS).to.equal("IDVALCOLORS3");
+                        expect(this.collection.properties.IDTAGSIZES).to.equal("IDVALSIZES2");
+                    });
+
+                // Confirm properties display on the UI.
+                cy.get("[data-cy=collection-info-panel")
+                    .should("contain", this.testCollection.name)
+                    .and("contain", "Color: Magenta")
+                    .and("contain", "Size: S");
+            });
+    });
+
+    it("shows collection by URL", function () {
+        cy.loginAs(activeUser);
+        [true, false].map(function (isWritable) {
+            // Using different file names to avoid test flakyness: the second iteration
+            // on this loop may pass an assertion from the first iteration by looking
+            // for the same file name.
+            const fileName = isWritable ? "bar" : "foo";
+            const subDirName = "subdir";
+            cy.createGroup(adminUser.token, {
+                name: "Shared project",
+                group_class: "project",
+            })
+                .as("sharedGroup")
+                .then(function () {
+                    // Creates the collection using the admin token so we can set up
+                    // a bogus manifest text without block signatures.
+                    cy.doRequest("GET", "/arvados/v1/config", null, null)
+                        .its("body")
+                        .should(clusterConfig => {
+                            expect(clusterConfig.Collections, "clusterConfig").to.have.property("TrustAllContent", true);
+                            expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAV").have.property("ExternalURL");
+                            expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAVDownload").have.property("ExternalURL");
+                            const inlineUrl =
+                                clusterConfig.Services.WebDAV.ExternalURL !== ""
+                                    ? clusterConfig.Services.WebDAV.ExternalURL
+                                    : clusterConfig.Services.WebDAVDownload.ExternalURL;
+                            expect(inlineUrl).to.not.contain("*");
+                        })
+                        .createCollection(adminUser.token, {
+                            name: "Test collection",
+                            owner_uuid: this.sharedGroup.uuid,
+                            properties: { someKey: "someValue" },
+                            manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n./${subDirName} 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`,
+                        })
+                        .as("testCollection")
+                        .then(function () {
+                            // Share the group with active user.
+                            cy.createLink(adminUser.token, {
+                                name: isWritable ? "can_write" : "can_read",
+                                link_class: "permission",
+                                head_uuid: this.sharedGroup.uuid,
+                                tail_uuid: activeUser.user.uuid,
+                            });
+                            cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                            // Check that name & uuid are correct.
+                            cy.get("[data-cy=collection-info-panel]")
+                                .should("contain", this.testCollection.name)
+                                .and("contain", this.testCollection.uuid)
+                                .and("not.contain", "This is an old version");
+                            // Check for the read-only icon
+                            cy.get("[data-cy=read-only-icon]").should(`${isWritable ? "not." : ""}exist`);
+                            // Check that both read and write operations are available on
+                            // the 'More options' menu.
+                            cy.get("[data-cy=collection-panel-options-btn]").click();
+                            cy.get("[data-cy=context-menu]")
+                                .should("contain", "Add to favorites")
+                                .and(`${isWritable ? "" : "not."}contain`, "Edit collection");
+                            cy.get("body").click(); // Collapse the menu avoiding details panel expansion
+                            cy.get("[data-cy=collection-info-panel]")
+                                .should("contain", "someKey: someValue")
+                                .and("not.contain", "anotherKey: anotherValue");
+                            // Check that the file listing show both read & write operations
+                            cy.waitForDom()
+                                .get("[data-cy=collection-files-panel]")
+                                .within(() => {
+                                    cy.get("[data-cy=collection-files-right-panel]", { timeout: 5000 }).should("contain", fileName);
+                                    if (isWritable) {
+                                        cy.get("[data-cy=upload-button]").should(`${isWritable ? "" : "not."}contain`, "Upload data");
+                                    }
+                                });
+                            // Test context menus
+                            cy.get("[data-cy=collection-files-panel]").contains(fileName).rightclick();
+                            cy.get("[data-cy=context-menu]")
+                                .should("contain", "Download")
+                                .and("contain", "Open in new tab")
+                                .and("contain", "Copy link to clipboard")
+                                .and(`${isWritable ? "" : "not."}contain`, "Rename")
+                                .and(`${isWritable ? "" : "not."}contain`, "Remove");
+                            cy.get("body").click(); // Collapse the menu
+                            cy.get("[data-cy=collection-files-panel]").contains(subDirName).rightclick();
+                            cy.get("[data-cy=context-menu]")
+                                .should("not.contain", "Download")
+                                .and("contain", "Open in new tab")
+                                .and("contain", "Copy link to clipboard")
+                                .and(`${isWritable ? "" : "not."}contain`, "Rename")
+                                .and(`${isWritable ? "" : "not."}contain`, "Remove");
+                            cy.get("body").click(); // Collapse the menu
+                            // File/dir item 'more options' button
+                            cy.get("[data-cy=file-item-options-btn").first().click();
+                            cy.get("[data-cy=context-menu]").should(`${isWritable ? "" : "not."}contain`, "Remove");
+                            cy.get("body").click(); // Collapse the menu
+                            // Hamburger 'more options' menu button
+                            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                            cy.get("[data-cy=context-menu]").should("contain", "Select all").click();
+                            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                            cy.get("[data-cy=context-menu]").should(`${isWritable ? "" : "not."}contain`, "Remove selected");
+                            cy.get("body").click(); // Collapse the menu
+                        });
+                });
+        });
+    });
+
+    it("renames a file using valid names", function () {
+        function eachPair(lst, func) {
+            for (var i = 0; i < lst.length - 1; i++) {
+                func(lst[i], lst[i + 1]);
+            }
+        }
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection")
+            .then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                const names = [
+                    "bar", // initial name already set
+                    "&",
+                    "foo",
+                    "&amp;",
+                    "I ❤️ ⛵️",
+                    "...",
+                    "#..",
+                    "some name with whitespaces",
+                    "some name with #2",
+                    "is this name legal? I hope it is",
+                    "some_file.pdf#",
+                    "some_file.pdf?",
+                    "?some_file.pdf",
+                    "some%file.pdf",
+                    "some%2Ffile.pdf",
+                    "some%22file.pdf",
+                    "some%20file.pdf",
+                    "G%C3%BCnter's%20file.pdf",
+                    "table%&?*2",
+                    "bar", // make sure we can go back to the original name as a last step
+                ];
+                cy.intercept({ method: "PUT", url: "**/arvados/v1/collections/*" }).as("renameRequest");
+                eachPair(names, (from, to) => {
+                    cy.waitForDom().get("[data-cy=collection-files-panel]").contains(`${from}`).rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
+                        .within(() => {
+                            cy.get("input").type("{selectall}{backspace}").type(to, { parseSpecialCharSequences: false });
+                        });
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.wait("@renameRequest");
+                    cy.get("[data-cy=collection-files-panel]").should("not.contain", `${from}`).and("contain", `${to}`);
+                });
+            });
+    });
+
+    it("renames a file to a different directory", function () {
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection")
+            .then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                ["subdir", "G%C3%BCnter's%20file", "table%&?*2"].forEach(subdir => {
+                    cy.waitForDom().get("[data-cy=collection-files-panel]").contains("bar").rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
+                        .within(() => {
+                            cy.get("input").type(`{selectall}{backspace}${subdir}/foo`);
+                        });
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.get("[data-cy=collection-files-panel]").should("not.contain", "bar").and("contain", subdir);
+                    cy.get("[data-cy=collection-files-panel]").contains(subdir).click();
+
+                    // Rename 'subdir/foo' to 'bar'
+                    cy.wait(1000);
+                    cy.get("[data-cy=collection-files-panel]").contains("foo").rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
+                        .within(() => {
+                            cy.get("input").should("have.value", `${subdir}/foo`).type(`{selectall}{backspace}bar`);
+                        });
+                    cy.get("[data-cy=form-submit-btn]").click();
+
+                    // need to wait for dialog to dismiss
+                    cy.get("[data-cy=form-dialog]").should("not.exist");
+
+                    cy.waitForDom().get("[data-cy=collection-files-panel]").contains("Home").click();
+
+                    cy.wait(2000);
+                    cy.get("[data-cy=collection-files-panel]")
+                        .should("contain", subdir) // empty dir kept
+                        .and("contain", "bar");
+
+                    cy.get("[data-cy=collection-files-panel]").contains(subdir).rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Remove").click();
+                    cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+                    cy.get("[data-cy=form-dialog]").should("not.exist");
+                });
+            });
+    });
+
+    it("shows collection owner", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection")
+            .then(testCollection => {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testCollection.uuid}`);
+                cy.wait(5000);
+                cy.get("[data-cy=collection-info-panel]").contains(`Collection User`);
+            });
+    });
+
+    it("tries to rename a file with illegal names", function () {
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection")
+            .then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                const illegalNamesFromUI = [
+                    [".", "Name cannot be '.' or '..'"],
+                    ["..", "Name cannot be '.' or '..'"],
+                    ["", "This field is required"],
+                    [" ", "Leading/trailing whitespaces not allowed"],
+                    [" foo", "Leading/trailing whitespaces not allowed"],
+                    ["foo ", "Leading/trailing whitespaces not allowed"],
+                    ["//foo", "Empty dir name not allowed"],
+                ];
+                illegalNamesFromUI.forEach(([name, errMsg]) => {
+                    cy.get("[data-cy=collection-files-panel]").contains("bar").rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
+                        .within(() => {
+                            cy.get("input").type(`{selectall}{backspace}${name}`);
+                        });
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
+                        .within(() => {
+                            cy.contains(`${errMsg}`);
+                        });
+                    cy.get("[data-cy=form-cancel-btn]").click();
+                });
+            });
+    });
+
+    it("can correctly display old versions", function () {
+        const colName = `Versioned Collection ${Math.floor(Math.random() * 999999)}`;
+        let colUuid = "";
+        let oldVersionUuid = "";
+        // Make sure no other collections with this name exist
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
+            filters: `[["name", "=", "${colName}"]]`,
+            include_old_versions: true,
+        })
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.be.empty;
+            });
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("originalVersion")
+            .then(function () {
+                // Change the file name to create a new version.
+                cy.updateCollection(adminUser.token, this.originalVersion.uuid, {
+                    manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n",
+                });
+                colUuid = this.originalVersion.uuid;
+            });
+        // Confirm that there are 2 versions of the collection
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
+            filters: `[["name", "=", "${colName}"]]`,
+            include_old_versions: true,
+        })
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.have.lengthOf(2);
+                this.collections.map(function (aCollection) {
+                    expect(aCollection.current_version_uuid).to.equal(colUuid);
+                    if (aCollection.uuid !== aCollection.current_version_uuid) {
+                        oldVersionUuid = aCollection.uuid;
+                    }
+                });
+                // Check the old version displays as what it is.
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${oldVersionUuid}`);
+
+                cy.get("[data-cy=collection-info-panel]").should("contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("exist");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "bar");
+            });
+    });
+
+    it("views & edits storage classes data", function () {
+        const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n",
+        })
+            .as("collection")
+            .then(function () {
+                expect(this.collection.storage_classes_desired).to.deep.equal(["default"]);
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+
+                // Initial check: it should show the 'default' storage class
+                cy.get("[data-cy=collection-info-panel]")
+                    .should("contain", "Storage classes")
+                    .and("contain", "default")
+                    .and("not.contain", "foo")
+                    .and("not.contain", "bar");
+                // Edit collection: add storage class 'foo'
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Edit Collection")
+                    .and("contain", "Storage classes")
+                    .and("contain", "default")
+                    .and("contain", "foo")
+                    .and("contain", "bar")
+                    .within(() => {
+                        cy.get("[data-cy=checkbox-foo]").click();
+                    });
+                cy.get("[data-cy=form-submit-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("contain", "default").and("contain", "foo").and("not.contain", "bar");
+                cy.doRequest("GET", `/arvados/v1/collections/${this.collection.uuid}`)
+                    .its("body")
+                    .as("updatedCollection")
+                    .then(function () {
+                        expect(this.updatedCollection.storage_classes_desired).to.deep.equal(["default", "foo"]);
+                    });
+                // Edit collection: remove storage class 'default'
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Edit Collection")
+                    .and("contain", "Storage classes")
+                    .and("contain", "default")
+                    .and("contain", "foo")
+                    .and("contain", "bar")
+                    .within(() => {
+                        cy.get("[data-cy=checkbox-default]").click();
+                    });
+                cy.get("[data-cy=form-submit-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "default").and("contain", "foo").and("not.contain", "bar");
+                cy.doRequest("GET", `/arvados/v1/collections/${this.collection.uuid}`)
+                    .its("body")
+                    .as("updatedCollection")
+                    .then(function () {
+                        expect(this.updatedCollection.storage_classes_desired).to.deep.equal(["foo"]);
+                    });
+            });
+    });
+
+    it("moves a collection to a different project", function () {
+        const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
+        const projName = `Test Project ${Math.floor(Math.random() * 999999)}`;
+        const fileName = `Test_File_${Math.floor(Math.random() * 999999)}`;
+
+        cy.createCollection(adminUser.token, {
+            name: collName,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`,
+        }).as("testCollection");
+        cy.createGroup(adminUser.token, {
+            name: projName,
+            group_class: "project",
+            owner_uuid: activeUser.user.uuid,
+        }).as("testProject");
+
+        cy.getAll("@testCollection", "@testProject").then(function ([testCollection, testProject]) {
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${testCollection.uuid}`);
+            cy.get("[data-cy=collection-files-panel]").should("contain", fileName);
+            cy.get("[data-cy=collection-info-panel]").should("not.contain", projName).and("not.contain", testProject.uuid);
+            cy.get("[data-cy=collection-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Move to").click();
+            cy.get("[data-cy=form-dialog]")
+                .should("contain", "Move to")
+                .within(() => {
+                    // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                    cy.get("[data-cy=projects-tree-home-tree-picker]")
+                        .find("i")
+                        .then(el => el.click());
+                    cy.get("[data-cy=projects-tree-home-tree-picker]").contains(projName).click();
+                });
+            cy.get("[data-cy=form-submit-btn]").click();
+            cy.get("[data-cy=snackbar]").contains("Collection has been moved");
+            cy.get("[data-cy=collection-info-panel]").contains(projName).and("contain", testProject.uuid);
+            // Double check that the collection is in the project
+            cy.goToPath(`/projects/${testProject.uuid}`);
+            cy.waitForDom().get("[data-cy=project-panel]").should("contain", collName);
+        });
+    });
+
+    it("automatically updates the collection UI contents without using the Refresh button", function () {
+        const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
+
+        cy.createCollection(adminUser.token, {
+            name: collName,
+            owner_uuid: activeUser.user.uuid,
+        }).as("testCollection");
+
+        cy.getAll("@testCollection").then(function ([testCollection]) {
+            cy.loginAs(activeUser);
+
+            const files = ["foobar", "anotherFile", "", "finalName"];
+
+            cy.goToPath(`/collections/${testCollection.uuid}`);
+            cy.get("[data-cy=collection-files-panel]").should("contain", "This collection is empty");
+            cy.get("[data-cy=collection-files-panel]").should("not.contain", files[0]);
+            cy.get("[data-cy=collection-info-panel]").should("contain", collName);
+
+            files.map((fileName, i, files) => {
+                cy.updateCollection(adminUser.token, testCollection.uuid, {
+                    name: `${collName + " updated"}`,
+                    manifest_text: fileName ? `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n` : "",
+                }).as("updatedCollection");
+                cy.getAll("@updatedCollection").then(function ([updatedCollection]) {
+                    expect(updatedCollection.name).to.equal(`${collName + " updated"}`);
+                    cy.get("[data-cy=collection-info-panel]").should("contain", updatedCollection.name);
+                    fileName
+                        ? cy.get("[data-cy=collection-files-panel]").should("contain", fileName)
+                        : cy.get("[data-cy=collection-files-panel]").should("not.contain", files[i - 1]);
+                });
+            });
+        });
+    });
+
+    it("makes a copy of an existing collection", function () {
+        const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
+        const copyName = `Copy of: ${collName}`;
+
+        cy.createCollection(adminUser.token, {
+            name: collName,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n",
+        })
+            .as("collection")
+            .then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "some-file");
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Make a copy").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Make a copy")
+                    .within(() => {
+                        cy.get("[data-cy=projects-tree-home-tree-picker]").contains("Projects").click();
+                        cy.get("[data-cy=form-submit-btn]").click();
+                    });
+                cy.get("[data-cy=snackbar]").contains("Collection has been copied.");
+                cy.get("[data-cy=snackbar-goto-action]").click();
+                cy.get("[data-cy=project-panel]").contains(copyName).click();
+                cy.get("[data-cy=collection-files-panel]").should("contain", "some-file");
+            });
+    });
+
+    it("uses the collection version browser to view a previous version", function () {
+        const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
+
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        })
+            .as("collection")
+            .then(function () {
+                // Visit collection, check basic information
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "1");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar");
+
+                // Modify collection, expect version number change
+                cy.get("[data-cy=collection-files-panel]").contains("foo").rightclick();
+                cy.get("[data-cy=context-menu]").contains("Remove").click();
+                cy.get("[data-cy=confirmation-dialog]").should("contain", "Removing file");
+                cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+                cy.get("[data-cy=collection-version-number]").should("contain", "2");
+                cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar");
+
+                // Click on version number, check version browser. Click on past version.
+                cy.get("[data-cy=collection-version-browser]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").contains("2").click();
+                cy.get("[data-cy=collection-version-browser]")
+                    .should("contain", "Nr")
+                    .and("contain", "Size")
+                    .and("contain", "Date")
+                    .within(() => {
+                        // Version 1: 6 bytes in size
+                        cy.get("[data-cy=collection-version-browser-select-1]")
+                            .should("contain", "1")
+                            .and("contain", "6 B")
+                            .and("contain", adminUser.user.full_name);
+                        // Version 2: 3 bytes in size (one file removed)
+                        cy.get("[data-cy=collection-version-browser-select-2]")
+                            .should("contain", "2")
+                            .and("contain", "3 B")
+                            .and("contain", activeUser.user.full_name);
+                        cy.get("[data-cy=collection-version-browser-select-3]").should("not.exist");
+                        cy.get("[data-cy=collection-version-browser-select-1]").click();
+                    });
+                cy.get("[data-cy=collection-info-panel]").should("contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "1");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar");
+
+                // Check that only old collection action are available on context menu
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").should("contain", "Restore version").and("not.contain", "Add to favorites");
+                cy.get("body").click(); // Collapse the menu avoiding details panel expansion
+
+                // Click on "head version" link, confirm that it's the latest version.
+                cy.get("[data-cy=collection-info-panel]").contains("head version").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "2");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar");
+
+                // Check that old collection action isn't available on context menu
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").should("not.contain", "Restore version");
+                cy.get("body").click(); // Collapse the menu avoiding details panel expansion
+
+                // Make another change, confirm new version.
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Edit Collection")
+                    .within(() => {
+                        // appends some text
+                        cy.get("input").first().type(" renamed");
+                    });
+                cy.get("[data-cy=form-submit-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "3");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName + " renamed");
+                cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar");
+                cy.get("[data-cy=collection-version-browser-select-3]").should("contain", "3").and("contain", "3 B");
+
+                // Check context menus on version browser
+                cy.waitForDom();
+                cy.get("[data-cy=collection-version-browser-select-3]").rightclick();
+                cy.get("[data-cy=context-menu]")
+                    .should("contain", "Add to favorites")
+                    .and("contain", "Make a copy")
+                    .and("contain", "Edit collection");
+                cy.get("body").click();
+                // (and now an old version...)
+                cy.get("[data-cy=collection-version-browser-select-1]").rightclick();
+                cy.get("[data-cy=context-menu]")
+                    .should("not.contain", "Add to favorites")
+                    .and("contain", "Make a copy")
+                    .and("not.contain", "Edit collection");
+                cy.get("body").click();
+
+                // Restore first version
+                cy.get("[data-cy=collection-version-browser]").within(() => {
+                    cy.get("[data-cy=collection-version-browser-select-1]").click();
+                });
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Restore version").click();
+                cy.get("[data-cy=confirmation-dialog]").should("contain", "Restore version");
+                cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=collection-version-number]").should("contain", "4");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar");
+            });
+    });
+
+    it("copies selected files into new collection", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        })
+            .as("collection")
+            .then(function () {
+                // Visit collection, check basic information
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+
+                cy.get("[data-cy=collection-files-panel]").within(() => {
+                    cy.get("input[type=checkbox]").first().click();
+                });
+
+                cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Copy selected into new collection").click();
+
+                cy.get("[data-cy=form-dialog]").contains("Projects").click();
+
+                cy.get("[data-cy=form-submit-btn]").click();
+
+                cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
+
+                cy.waitForDom().get("main").contains(`Files extracted from: ${this.collection.name}`).click();
+                cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+            });
+    });
+
+    it("copies selected files into existing collection", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
+
+        cy.createCollection(adminUser.token, {
+            name: `Destination Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: "",
+        }).as("destinationCollection");
+
+        cy.getAll("@sourceCollection", "@destinationCollection").then(function ([sourceCollection, destinationCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
+
+            cy.get("[data-cy=collection-files-panel]").within(() => {
+                cy.get("input[type=checkbox]").first().click();
+            });
+
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Copy selected into existing collection").click();
+
+            cy.get("[data-cy=form-dialog]").contains(destinationCollection.name).click();
+
+            cy.get("[data-cy=form-submit-btn]").click();
+            cy.wait(2000);
+
+            cy.goToPath(`/collections/${destinationCollection.uuid}`);
+
+            cy.get("main").contains(destinationCollection.name).should("exist");
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+        });
+    });
+
+    it("copies selected files into separate collections", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
+
+        cy.getAll("@sourceCollection").then(function ([sourceCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
+
+            // Select both files
+            cy.waitForDom()
+                .get("[data-cy=collection-files-panel]")
+                .within(() => {
+                    cy.get("input[type=checkbox]").first().click();
+                    cy.get("input[type=checkbox]").last().click();
+                });
+
+            // Copy to separate collections
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Copy selected into separate collections").click();
+            cy.get("[data-cy=form-dialog]").contains("Projects").click();
+            cy.get("[data-cy=form-submit-btn]").click();
+
+            // Verify created collections
+            cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
+            cy.get("main").contains(`File copied from collection ${sourceCollection.name}/foo`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "foo");
+            cy.get(".layout-pane-primary").contains("Projects").click();
+            cy.get("main").contains(`File copied from collection ${sourceCollection.name}/bar`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+
+            // Verify separate collection menu items not present when single file selected
+            // Wait for dom for collection to re-render
+            cy.waitForDom()
+                .get("[data-cy=collection-files-panel]")
+                .within(() => {
+                    cy.get("input[type=checkbox]").first().click();
+                });
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").should("not.contain", "Copy selected into separate collections");
+            cy.get("[data-cy=context-menu]").should("not.contain", "Move selected into separate collections");
+        });
+    });
+
+    it("moves selected files into new collection", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        })
+            .as("collection")
+            .then(function () {
+                // Visit collection, check basic information
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+
+                cy.get("[data-cy=collection-files-panel]").within(() => {
+                    cy.get("input[type=checkbox]").first().click();
+                });
+
+                cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Move selected into new collection").click();
+
+                cy.get("[data-cy=form-dialog]").contains("Projects").click();
+
+                cy.get("[data-cy=form-submit-btn]").click();
+
+                cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
+
+                cy.get("main").contains(`Files moved from: ${this.collection.name}`).click();
+                cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+            });
+    });
+
+    it("moves selected files into existing collection", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
+
+        cy.createCollection(adminUser.token, {
+            name: `Destination Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: "",
+        }).as("destinationCollection");
+
+        cy.getAll("@sourceCollection", "@destinationCollection").then(function ([sourceCollection, destinationCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
+
+            cy.get("[data-cy=collection-files-panel]").within(() => {
+                cy.get("input[type=checkbox]").first().click();
+            });
+
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Move selected into existing collection").click();
+
+            cy.get("[data-cy=form-dialog]").contains(destinationCollection.name).click();
+
+            cy.get("[data-cy=form-submit-btn]").click();
+            cy.wait(2000);
+
+            cy.goToPath(`/collections/${destinationCollection.uuid}`);
+
+            cy.get("main").contains(destinationCollection.name).should("exist");
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+        });
+    });
+
+    it("moves selected files into separate collections", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
+
+        cy.getAll("@sourceCollection").then(function ([sourceCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
+
+            // Select both files
+            cy.get("[data-cy=collection-files-panel]").within(() => {
+                cy.get("input[type=checkbox]").first().click();
+                cy.get("input[type=checkbox]").last().click();
+            });
+
+            // Copy to separate collections
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Move selected into separate collections").click();
+            cy.get("[data-cy=form-dialog]").contains("Projects").click();
+            cy.get("[data-cy=form-submit-btn]").click();
+
+            // Verify created collections
+            cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
+            cy.get("main").contains(`File moved from collection ${sourceCollection.name}/foo`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "foo");
+            cy.get(".layout-pane-primary").contains("Projects").click();
+            cy.get("main").contains(`File moved from collection ${sourceCollection.name}/bar`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+        });
+    });
+
+    it("creates new collection with properties on home project", function () {
+        cy.loginAs(activeUser);
+        cy.goToPath(`/projects/${activeUser.user.uuid}`);
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("not.exist");
+        // Create new collection
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-collection]").click();
+        // Name between brackets tests bugfix #17582
+        const collName = `[Test collection (${Math.floor(999999 * Math.random())})]`;
+
+        // Select a storage class.
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New collection")
+            .and("contain", "Storage classes")
+            .and("contain", "default")
+            .and("contain", "foo")
+            .and("contain", "bar")
+            .within(() => {
+                cy.get("[data-cy=parent-field]").within(() => {
+                    cy.get("input").should("have.value", "Home project");
+                });
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(collName);
+                });
+                cy.get("[data-cy=checkbox-foo]").click();
+            });
+
+        // Add a property.
+        // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
+        cy.get("[data-cy=form-dialog]").should("not.contain", "Color: Magenta");
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").type("Color");
+            });
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Magenta");
+            });
+            cy.root().submit();
+        });
+        // Confirm proper vocabulary labels are displayed on the UI.
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
+
+        // Value field should not complain about being required just after
+        // adding a new property. See #19732
+        cy.get("[data-cy=form-dialog]").should("not.contain", "This field is required");
+
+        cy.get("[data-cy=form-submit-btn]").click();
+        // Confirm that the user was taken to the newly created collection
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("contain", collName);
+        cy.get("[data-cy=collection-info-panel]")
+            .should("contain", "default")
+            .and("contain", "foo")
+            .and("contain", "Color: Magenta")
+            .and("not.contain", "bar");
+        // Confirm that the collection's properties has the real values.
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
+            filters: `[["name", "=", "${collName}"]]`,
+        })
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.have.lengthOf(1);
+                expect(this.collections[0].properties).to.have.property("IDTAGCOLORS", "IDVALCOLORS3");
+            });
+    });
+
+    it("shows responsible person for collection if available", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).as("testCollection1");
+
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: adminUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection2")
+            .then(function (testCollection2) {
+                cy.shareWith(adminUser.token, activeUser.user.uuid, testCollection2.uuid, "can_write");
+            });
+
+        cy.getAll("@testCollection1", "@testCollection2").then(function ([testCollection1, testCollection2]) {
+            cy.loginAs(activeUser);
+
+            cy.goToPath(`/collections/${testCollection1.uuid}`);
+            cy.get("[data-cy=responsible-person-wrapper]").contains(activeUser.user.uuid);
+
+            cy.goToPath(`/collections/${testCollection2.uuid}`);
+            cy.get("[data-cy=responsible-person-wrapper]").contains(adminUser.user.uuid);
+        });
+    });
+
+    describe("file upload", () => {
+        beforeEach(() => {
+            cy.createCollection(adminUser.token, {
+                name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+                owner_uuid: activeUser.user.uuid,
+                manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+            }).as("testCollection1");
+        });
+
+        it("uploads a file and checks the collection UI to be fresh", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
+                cy.get("[data-cy=upload-button]").click();
+                cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("not.exist");
+                cy.get("[data-cy=collection-file-count]").should("contain", "2");
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
+                    cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("exist");
+                    cy.get("[data-cy=collection-file-count]").should("contain", "3");
+
+                    cy.get("[data-cy=collection-files-panel]").contains("subdir").click();
+                    cy.get("[data-cy=upload-button]").click();
+                    cy.fixture("files/5mb.bin", "base64").then(content => {
+                        cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
+                        cy.get("[data-cy=form-submit-btn]").click();
+                        cy.waitForDom().get("[data-cy=form-submit-btn]").should("not.exist");
+                        // subdir gets unselected, I think this is a bug but
+                        // for the time being let's just make sure the test works.
+                        cy.get("[data-cy=collection-files-panel]").contains("subdir").click();
+                        cy.waitForDom().get("[data-cy=collection-files-right-panel]").contains("5mb_b.bin").should("exist");
+                    });
+                });
+            });
+        });
+
+        it("allows to cancel running upload", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
+
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
+
+                cy.get("[data-cy=upload-button]").click();
+
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
+
+                    cy.get("[data-cy=form-submit-btn]").click();
+
+                    cy.get("button").contains("Cancel").click();
+
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
+                });
+            });
+        });
+
+        it("allows to cancel single file from the running upload", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
+
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
+
+                cy.get("[data-cy=upload-button]").click();
+
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
+
+                    cy.get("[data-cy=form-submit-btn]").click();
+
+                    cy.get("button[aria-label=Remove]").eq(1).click();
+
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
+
+                    cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("exist");
+                });
+            });
+        });
+
+        it("allows to cancel all files from the running upload", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
+
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
+
+                // Confirm initial collection state.
+                cy.get("[data-cy=collection-files-panel]").contains("bar").should("exist");
+                cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("not.exist");
+                cy.get("[data-cy=collection-files-panel]").contains("5mb_b.bin").should("not.exist");
+
+                cy.get("[data-cy=upload-button]").click();
+
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
+
+                    cy.get("[data-cy=form-submit-btn]").click();
+
+                    cy.get("button[aria-label=Remove]").should("exist").click({ multiple: true});
+
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
+
+                    // Confirm final collection state.
+                    cy.get("[data-cy=collection-files-panel]").contains("bar").should("exist");
+                    // The following fails, but doesn't seem to happen
+                    // in the real world. Maybe there's a race between
+                    // the PUT request finishing and the 'Remove' button
+                    // dissapearing, because sometimes just one of the 2
+                    // files gets uploaded.
+                    // Maybe this will be needed to simulate a slow network:
+                    // https://docs.cypress.io/api/commands/intercept#Convenience-functions-1
+                    // cy.get('[data-cy=collection-files-panel]')
+                    //     .contains('5mb_a.bin').should('not.exist');
+                    // cy.get('[data-cy=collection-files-panel]')
+                    //     .contains('5mb_b.bin').should('not.exist');
+                });
+            });
+        });
+    });
+});
diff --git a/services/workbench2/cypress/e2e/create-workflow.cy.js b/services/workbench2/cypress/e2e/create-workflow.cy.js
new file mode 100644 (file)
index 0000000..c8acc67
--- /dev/null
@@ -0,0 +1,279 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Create workflow tests', function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function () {
+                adminUser = this.adminUser;
+            }
+            );
+        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
+            .as('activeUser').then(function () {
+                activeUser = this.activeUser;
+            }
+            );
+    });
+
+    it('can create project with nested data', function () {
+        cy.createGroup(adminUser.token, {
+            group_class: "project",
+            name: `Test project (${Math.floor(Math.random() * 999999)})`,
+        }).as('project1');
+
+        cy.get('@project1').then(() => {
+            cy.createGroup(adminUser.token, {
+                group_class: "project",
+                name: `Test project (${Math.floor(Math.random() * 999999)})`,
+                owner_uuid: this.project1.uuid,
+            }).as('project2');
+        })
+
+        cy.get('@project2').then(() => {
+            cy.createGroup(adminUser.token, {
+                group_class: "project",
+                name: `Test project (${Math.floor(Math.random() * 999999)})`,
+                owner_uuid: this.project2.uuid,
+            }).as('project3');
+        });
+
+        cy.get('@project3').then(() => {
+            cy.createWorkflow(adminUser.token, {
+                name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`,
+                definition: "{\n    \"$graph\": [\n        {\n            \"class\": \"Workflow\",\n            \"doc\": \"Reverse the lines in a document, then sort those lines.\",\n            \"hints\": [\n                {\n                    \"acrContainerImage\": \"99b0201f4cade456b4c9d343769a3b70+261\",\n                    \"class\": \"http://arvados.org/cwl#WorkflowRunnerResources\"\n                }\n            ],\n            \"id\": \"#main\",\n            \"inputs\": [\n                {\n                    \"default\": null,\n                    \"doc\": \"The input file to be processed.\",\n                    \"id\": \"#main/input\",\n                    \"type\": \"File\"\n                },\n                {\n                    \"default\": true,\n                    \"doc\": \"If true, reverse (decending) sort\",\n                    \"id\": \"#main/reverse_sort\",\n                    \"type\": \"boolean\"\n                }\n            ],\n            \"outputs\": [\n                {\n                    \"doc\": \"The output with the lines reversed and sorted.\",\n                    \"id\": \"#main/output\",\n                    \"outputSource\": \"#main/sorted/output\",\n                    \"type\": \"File\"\n                }\n            ],\n            \"steps\": [\n                {\n                    \"id\": \"#main/rev\",\n                    \"in\": [\n                        {\n                            \"id\": \"#main/rev/input\",\n                            \"source\": \"#main/input\"\n                        }\n                    ],\n                    \"out\": [\n                        \"#main/rev/output\"\n                    ],\n                    \"run\": \"#revtool.cwl\"\n                },\n                {\n                    \"id\": \"#main/sorted\",\n                    \"in\": [\n                        {\n                            \"id\": \"#main/sorted/input\",\n                            \"source\": \"#main/rev/output\"\n                        },\n                        {\n                            \"id\": \"#main/sorted/reverse\",\n                            \"source\": \"#main/reverse_sort\"\n                        }\n                    ],\n                    \"out\": [\n                        \"#main/sorted/output\"\n                    ],\n                    \"run\": \"#sorttool.cwl\"\n                }\n            ]\n        },\n        {\n            \"baseCommand\": \"rev\",\n            \"class\": \"CommandLineTool\",\n            \"doc\": \"Reverse each line using the `rev` command\",\n            \"hints\": [\n                {\n                    \"class\": \"ResourceRequirement\",\n                    \"ramMin\": 8\n                }\n            ],\n            \"id\": \"#revtool.cwl\",\n            \"inputs\": [\n                {\n                    \"id\": \"#revtool.cwl/input\",\n                    \"inputBinding\": {},\n                    \"type\": \"File\"\n                }\n            ],\n            \"outputs\": [\n                {\n                    \"id\": \"#revtool.cwl/output\",\n                    \"outputBinding\": {\n                        \"glob\": \"output.txt\"\n                    },\n                    \"type\": \"File\"\n                }\n            ],\n            \"stdout\": \"output.txt\"\n        },\n        {\n            \"baseCommand\": \"sort\",\n            \"class\": \"CommandLineTool\",\n            \"doc\": \"Sort lines using the `sort` command\",\n            \"hints\": [\n                {\n                    \"class\": \"ResourceRequirement\",\n                    \"ramMin\": 8\n                }\n            ],\n            \"id\": \"#sorttool.cwl\",\n            \"inputs\": [\n                {\n                    \"id\": \"#sorttool.cwl/reverse\",\n                    \"inputBinding\": {\n                        \"position\": 1,\n                        \"prefix\": \"-r\"\n                    },\n                    \"type\": \"boolean\"\n                },\n                {\n                    \"id\": \"#sorttool.cwl/input\",\n                    \"inputBinding\": {\n                        \"position\": 2\n                    },\n                    \"type\": \"File\"\n                }\n            ],\n            \"outputs\": [\n                {\n                    \"id\": \"#sorttool.cwl/output\",\n                    \"outputBinding\": {\n                        \"glob\": \"output.txt\"\n                    },\n                    \"type\": \"File\"\n                }\n            ],\n            \"stdout\": \"output.txt\"\n        }\n    ],\n    \"cwlVersion\": \"v1.0\"\n}",
+            })
+                .as('testWorkflow');
+
+            cy.createCollection(adminUser.token, {
+                name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+                owner_uuid: this.project3.uuid,
+                manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:baz\n"
+            })
+                .as('testCollection');
+        });
+
+        cy.get('@testWorkflow').then(() => {
+            cy.loginAs(adminUser);
+
+            cy.get('[data-cy=side-panel-button]').click();
+            cy.get('[data-cy=side-panel-run-process]').click();
+
+            cy.get('.layout-pane')
+                .contains(this.testWorkflow.name)
+                .click();
+
+            cy.get('[data-cy=run-process-next-button]').click();
+
+            cy.get('[data-cy=new-process-panel]').contains('Run workflow').should('be.disabled');
+
+            cy.get('[data-cy=new-process-panel]')
+                .within(() => {
+                    cy.get('[name=name]').type(`Workflow name (${Math.floor(Math.random() * 999999)})`);
+                    cy.contains('input').next().click();
+                });
+
+            cy.get('[data-cy=choose-a-file-dialog]').as('chooseFileDialog');
+            cy.get('@chooseFileDialog').contains('Home Projects').closest('ul').find('i').click();
+
+            cy.get('@project1').then((project1) => {
+                cy.get('@chooseFileDialog').find(`[data-id=${project1.uuid}]`).find('i').click();
+            });
+
+            cy.get('@project2').then((project2) => {
+                cy.get('@chooseFileDialog').find(`[data-id=${project2.uuid}]`).find('i').click();
+            });
+
+            cy.get('@project3').then((project3) => {
+                cy.get('@chooseFileDialog').find(`[data-id=${project3.uuid}]`).find('i').click();
+            });
+
+            cy.get('@testCollection').then((testCollection) => {
+                cy.get('@chooseFileDialog').find(`[data-id=${testCollection.uuid}]`).find('i').click();
+            });
+
+            cy.get('@chooseFileDialog').contains('baz').click();
+
+            cy.get('@chooseFileDialog').find('button').contains('Ok').click();
+
+            cy.get('[data-cy=new-process-panel]')
+                .find('button').contains('Run workflow').should('not.be.disabled');
+        });
+    });
+
+    ['workflow_with_array_fields.yaml', 'workflow_with_default_array_fields.yaml'].forEach((yamlfile) =>
+    it('can select multi files when creating workflow '+yamlfile, () => {
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: 'myProject1',
+            addToFavorites: true
+        });
+
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:baz\n"
+        })
+            .as('testCollection');
+
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:buz\n`
+        })
+            .as('testCollection2');
+
+        cy.getAll('@myProject1', '@testCollection', '@testCollection2')
+            .then(function ([myProject1, testCollection, testCollection2]) {
+                cy.readFile('cypress/fixtures/'+yamlfile).then(workflow => {
+                    cy.createWorkflow(adminUser.token, {
+                        name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`,
+                        definition: workflow,
+                        owner_uuid: myProject1.uuid,
+                    })
+                        .as('testWorkflow');
+                });
+
+                cy.loginAs(activeUser);
+
+                cy.get('main').contains(myProject1.name).click();
+
+                cy.get('[data-cy=side-panel-button]').click();
+
+                cy.get('#aside-menu-list').contains('Run a workflow').click();
+
+                cy.get('@testWorkflow')
+                    .then((testWorkflow) => {
+                        cy.get('main').contains(testWorkflow.name).click();
+                        cy.get('[data-cy=run-process-next-button]').click();
+
+                        cy.get('label').contains('foo').parent('div').find('input').click();
+                        cy.get('div[role=dialog]')
+                            .within(() => {
+                                // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                                cy.get('p').contains('Home Projects').closest('ul')
+                                    .find('i')
+                                    .then(el => el.click());
+
+                                cy.get(`[data-id=${testCollection.uuid}]`)
+                                    .find('i').click();
+
+                                cy.wait(1000);
+                                cy.contains('bar').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
+                                cy.contains('baz').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
+
+                                cy.get('[data-cy=ok-button]').click();
+                            });
+
+                        cy.get('label').contains('bar').parent('div').find('input').click();
+                        cy.get('div[role=dialog]')
+                            .within(() => {
+                                // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                                cy.get('p').contains('Home Projects').closest('ul')
+                                    .find('i')
+                                    .then(el => el.click());
+
+                                cy.get(`[data-id=${testCollection.uuid}]`)
+                                    .find('input[type=checkbox]').click();
+
+                                cy.get(`[data-id=${testCollection2.uuid}]`)
+                                    .find('input[type=checkbox]').click();
+
+                                cy.get('[data-cy=ok-button]').click();
+                            });
+                    });
+
+                cy.get('label').contains('foo').parent('div')
+                    .within(() => {
+                        cy.contains('baz');
+                        cy.contains('bar');
+                    });
+
+                cy.get('label').contains('bar').parent('div')
+                    .within(() => {
+                        cy.contains(testCollection.name);
+                        cy.contains(testCollection2.name);
+                    });
+            });
+    }));
+
+    it('allows selecting collection subdirectories and reselects existing selections', () => {
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: 'myProject1',
+            addToFavorites: true
+        });
+
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: "./subdir/dir1 d41d8cd98f00b204e9800998ecf8427e+0 0:0:\\056\n./subdir/dir2 d41d8cd98f00b204e9800998ecf8427e+0 0:0:\\056\n"
+        })
+            .as('testCollection');
+
+        cy.getAll('@myProject1', '@testCollection')
+            .then(function ([myProject1, testCollection]) {
+                cy.readFile('cypress/fixtures/workflow_directory_array.yaml').then(workflow => {
+                    cy.createWorkflow(adminUser.token, {
+                        name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`,
+                        definition: workflow,
+                        owner_uuid: myProject1.uuid,
+                    })
+                        .as('testWorkflow');
+                });
+
+                cy.loginAs(activeUser);
+
+                cy.get('main').contains(myProject1.name).click();
+
+                cy.get('[data-cy=side-panel-button]').click();
+
+                cy.get('#aside-menu-list').contains('Run a workflow').click();
+
+                cy.get('@testWorkflow')
+                    .then((testWorkflow) => {
+                        cy.get('main').contains(testWorkflow.name).click();
+                        cy.get('[data-cy=run-process-next-button]').click();
+
+                        cy.get('label').contains('directoryInputName').parent('div').find('input').click();
+                        cy.get('div[role=dialog]')
+                            .within(() => {
+                                // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                                cy.get('p').contains('Home Projects').closest('ul')
+                                    .find('i')
+                                    .then(el => el.click());
+
+                                cy.get(`[data-id=${testCollection.uuid}]`)
+                                    .find('i').click();
+
+                                cy.get(`[data-id="${testCollection.uuid}/subdir"]`)
+                                    .find('i').click();
+
+                                cy.contains('dir1').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
+                                cy.contains('dir2').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
+
+                                cy.get('[data-cy=ok-button]').click();
+                            });
+
+                        // Verify subdirectories were selected
+                        cy.get('label').contains('directoryInputName').parent('div')
+                            .within(() => {
+                                cy.contains('dir1');
+                                cy.contains('dir2');
+                            });
+
+                        // Reopen tree picker and verify subdirectories are preselected
+                        cy.get('label').contains('directoryInputName').parent('div').find('input').click();
+                        cy.waitForDom().get('div[role=dialog]')
+                            .within(() => {
+                                cy.contains('dir1').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').should('be.checked');
+                                cy.contains('dir2').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').should('be.checked');
+                            });
+                    });
+
+            });
+    })
+})
diff --git a/services/workbench2/cypress/e2e/delete-multiple-files.cy.js b/services/workbench2/cypress/e2e/delete-multiple-files.cy.js
new file mode 100644 (file)
index 0000000..8086dd1
--- /dev/null
@@ -0,0 +1,100 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Multi-file deletion tests', function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function () {
+                adminUser = this.adminUser;
+            }
+            );
+        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
+            .as('activeUser').then(function () {
+                activeUser = this.activeUser;
+            }
+            );
+    });
+
+    it('deletes all files from root dir', function () {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:baz\n"
+        })
+            .as('testCollection').then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                cy.get('[data-cy=collection-files-panel]').within(() => {
+                    cy.get('[type="checkbox"]').first().check();
+                    cy.get('[type="checkbox"]').last().check();
+                });
+                cy.get('[data-cy=collection-files-panel-options-btn]').click();
+                cy.get('[data-cy=context-menu] div').contains('Remove selected').click();
+                cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+                cy.wait(1000);
+                cy.get('[data-cy=collection-files-panel]')
+                    .should('not.contain', 'baz')
+                    .and('not.contain', 'bar');
+            });
+    });
+
+    it.skip('deletes all files from non root dir', function () {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:baz\n"
+        })
+            .as('testCollection').then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                cy.get('[data-cy=virtual-file-tree] > div > i').first().click();
+                cy.get('[data-cy=collection-files-panel]')
+                    .should('contain', 'foo');
+
+                cy.get('[data-cy=collection-files-panel]')
+                    .contains('foo').closest('[data-cy=virtual-file-tree]').find('[type="checkbox"]').click();
+
+                cy.get('[data-cy=collection-files-panel-options-btn]').click();
+                cy.get('[data-cy=context-menu] div').contains('Remove selected').click();
+                cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+                cy.get('[data-cy=collection-files-panel]')
+                    .should('not.contain', 'subdir')
+                    .and('contain', 'baz');
+            });
+    });
+
+    it('deletes all files from non root dir', function () {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:baz\n"
+        })
+            .as('testCollection').then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                cy.get('[data-cy=collection-files-panel]').contains('subdir').click();
+                cy.wait(1000);
+                cy.get('[data-cy=collection-files-panel]')
+                    .should('contain', 'foo');
+
+                cy.get('[data-cy=collection-files-panel]')
+                    .contains('foo').parent().find('[type="checkbox"]').click();
+
+                cy.get('[data-cy=collection-files-panel-options-btn]').click();
+                cy.get('[data-cy=context-menu] div').contains('Remove selected').click();
+                cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+                cy.get('[data-cy=collection-files-panel]')
+                    .should('not.contain', 'foo')
+                    .and('contain', 'subdir');
+            });
+    });
+})
diff --git a/services/workbench2/cypress/e2e/favorites.cy.js b/services/workbench2/cypress/e2e/favorites.cy.js
new file mode 100644 (file)
index 0000000..2898c22
--- /dev/null
@@ -0,0 +1,255 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Favorites tests', function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
+            .as('activeUser').then(function () {
+                activeUser = this.activeUser;
+            });
+    });
+
+    it('creates and removes a public favorite', function () {
+        cy.loginAs(adminUser);
+
+        cy.createGroup(adminUser.token, {
+            name: `my-favorite-project`,
+            group_class: 'project',
+        }).as('myFavoriteProject').then(function () {
+            cy.contains('Refresh').click();
+            cy.get('main').contains('my-favorite-project').rightclick();
+            cy.contains('Add to public favorites').click();
+            cy.contains('Public Favorites').click();
+            cy.get('main').contains('my-favorite-project').rightclick();
+            cy.contains('Remove from public favorites').click();
+            cy.get('main').contains('my-favorite-project').should('not.exist');
+            cy.trashGroup(adminUser.token, this.myFavoriteProject.uuid);
+        });
+    });
+
+    // Disabled while addressing #18587
+    it.skip('can copy selected into the collection', () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test source collection ${Math.floor(Math.random() * 999999)}`,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        }).as('testSourceCollection').then(function (testSourceCollection) {
+            cy.shareWith(adminUser.token, activeUser.user.uuid, testSourceCollection.uuid, 'can_read');
+        });
+        cy.createCollection(adminUser.token, {
+            name: `Test target collection ${Math.floor(Math.random() * 999999)}`,
+        }).as('testTargetCollection').then(function (testTargetCollection) {
+            cy.shareWith(adminUser.token, activeUser.user.uuid, testTargetCollection.uuid, 'can_write');
+            cy.addToFavorites(activeUser.token, activeUser.user.uuid, testTargetCollection.uuid);
+        });
+
+        cy.getAll('@testSourceCollection', '@testTargetCollection')
+            .then(function ([testSourceCollection, testTargetCollection]) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testSourceCollection.uuid}`);
+                cy.get('[data-cy=collection-files-panel]').contains('bar');
+                cy.get('[data-cy=collection-files-panel]').find('input[type=checkbox]').click();
+                cy.get('[data-cy=collection-files-panel-options-btn]').click();
+                cy.get('[data-cy=context-menu]')
+                    .contains('Copy selected into the collection').click();
+                cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+                    .find('i')
+                    .click();
+                cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+                    .contains(testTargetCollection.name)
+                    .click();
+                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get('.layout-pane-primary').contains('Projects').click();
+                cy.goToPath(`/collections/${testTargetCollection.uuid}`);
+                cy.get('[data-cy=collection-files-panel]').contains('bar');
+            });
+    });
+
+    it('can copy collection to favorites', () => {
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedWritableProject',
+            canWrite: true,
+            addToFavorites: true
+        });
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedReadonlyProject',
+            canWrite: false,
+            addToFavorites: true
+        });
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: 'myProject1',
+            addToFavorites: true
+        });
+
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        })
+            .as('testCollection');
+
+        cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject', '@myProject1', '@testCollection')
+            .then(function ([mySharedWritableProject, mySharedReadonlyProject, myProject1, testCollection]) {
+                cy.loginAs(activeUser);
+
+                cy.contains(testCollection.name).rightclick();
+                cy.get('[data-cy=context-menu]').within(() => {
+                    cy.contains('Move to').click();
+                });
+
+                cy.get('[data-cy=form-dialog]').within(function () {
+                    // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                    cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+                        .find('i')
+                        .then(el => el.click());
+                    cy.contains(myProject1.name);
+                    cy.contains(mySharedWritableProject.name);
+                    cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+                        .should('not.contain', mySharedReadonlyProject.name);
+                    cy.contains(mySharedWritableProject.name).click();
+                    cy.get('[data-cy=form-submit-btn]').click();
+                });
+
+                cy.goToPath(`/projects/${mySharedWritableProject.uuid}`);
+                cy.get('main').contains(testCollection.name);
+            });
+    });
+
+    it('can edit project and collections in favorites', () => {
+        cy.createProject({
+            owningUser: adminUser,
+            projectName: 'mySharedWritableProject',
+            canWrite: true,
+            addToFavorites: true
+        });
+
+        cy.createCollection(adminUser.token, {
+            owner_uuid: adminUser.user.uuid,
+            name: `Test target collection ${Math.floor(Math.random() * 999999)}`,
+        }).as('testTargetCollection').then(function (testTargetCollection) {
+            cy.addToFavorites(adminUser.token, adminUser.user.uuid, testTargetCollection.uuid);
+        });
+
+        cy.getAll('@mySharedWritableProject', '@testTargetCollection')
+            .then(function ([mySharedWritableProject, testTargetCollection]) {
+                cy.loginAs(adminUser);
+
+                cy.get('[data-cy=side-panel-tree]').contains('My Favorites').click();
+
+                const newProjectName = `New project name ${mySharedWritableProject.name}`;
+                const newProjectDescription = `New project description ${mySharedWritableProject.name}`;
+                const newCollectionName = `New collection name ${testTargetCollection.name}`;
+                const newCollectionDescription = `New collection description ${testTargetCollection.name}`;
+
+                cy.testEditProjectOrCollection('main', mySharedWritableProject.name, newProjectName, newProjectDescription);
+                cy.testEditProjectOrCollection('main', testTargetCollection.name, newCollectionName, newCollectionDescription, false);
+
+                cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+
+                cy.get('main').contains(newProjectName).rightclick();
+                cy.contains('Add to public favorites').click();
+                cy.get('main').contains(newCollectionName).rightclick();
+                cy.contains('Add to public favorites').click();
+
+                cy.get('[data-cy=side-panel-tree]').contains('Public Favorites').click();
+
+                cy.testEditProjectOrCollection('main', newProjectName, mySharedWritableProject.name, 'newProjectDescription');
+                cy.testEditProjectOrCollection('main', newCollectionName, testTargetCollection.name, 'newCollectionDescription', false);
+            });
+    });
+
+    it('can view favorites in workflow', () => {
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedWritableProject',
+            canWrite: true,
+            addToFavorites: true
+        });
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedReadonlyProject',
+            canWrite: false,
+            addToFavorites: true
+        });
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: 'myProject1',
+            addToFavorites: true
+        });
+
+        cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject', '@myProject1')
+            .then(function ([mySharedWritableProject, mySharedReadonlyProject, myProject1]) {
+                cy.createWorkflow(adminUser.token, {
+                    name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`,
+                    definition: "{\n    \"$graph\": [\n        {\n            \"class\": \"Workflow\",\n            \"doc\": \"Reverse the lines in a document, then sort those lines.\",\n            \"hints\": [\n                {\n                    \"acrContainerImage\": \"99b0201f4cade456b4c9d343769a3b70+261\",\n                    \"class\": \"http://arvados.org/cwl#WorkflowRunnerResources\"\n                }\n            ],\n            \"id\": \"#main\",\n            \"inputs\": [\n                {\n                    \"default\": null,\n                    \"doc\": \"The input file to be processed.\",\n                    \"id\": \"#main/input\",\n                    \"type\": \"File\"\n                },\n                {\n                    \"default\": true,\n                    \"doc\": \"If true, reverse (decending) sort\",\n                    \"id\": \"#main/reverse_sort\",\n                    \"type\": \"boolean\"\n                }\n            ],\n            \"outputs\": [\n                {\n                    \"doc\": \"The output with the lines reversed and sorted.\",\n                    \"id\": \"#main/output\",\n                    \"outputSource\": \"#main/sorted/output\",\n                    \"type\": \"File\"\n                }\n            ],\n            \"steps\": [\n                {\n                    \"id\": \"#main/rev\",\n                    \"in\": [\n                        {\n                            \"id\": \"#main/rev/input\",\n                            \"source\": \"#main/input\"\n                        }\n                    ],\n                    \"out\": [\n                        \"#main/rev/output\"\n                    ],\n                    \"run\": \"#revtool.cwl\"\n                },\n                {\n                    \"id\": \"#main/sorted\",\n                    \"in\": [\n                        {\n                            \"id\": \"#main/sorted/input\",\n                            \"source\": \"#main/rev/output\"\n                        },\n                        {\n                            \"id\": \"#main/sorted/reverse\",\n                            \"source\": \"#main/reverse_sort\"\n                        }\n                    ],\n                    \"out\": [\n                        \"#main/sorted/output\"\n                    ],\n                    \"run\": \"#sorttool.cwl\"\n                }\n            ]\n        },\n        {\n            \"baseCommand\": \"rev\",\n            \"class\": \"CommandLineTool\",\n            \"doc\": \"Reverse each line using the `rev` command\",\n            \"hints\": [\n                {\n                    \"class\": \"ResourceRequirement\",\n                    \"ramMin\": 8\n                }\n            ],\n            \"id\": \"#revtool.cwl\",\n            \"inputs\": [\n                {\n                    \"id\": \"#revtool.cwl/input\",\n                    \"inputBinding\": {},\n                    \"type\": \"File\"\n                }\n            ],\n            \"outputs\": [\n                {\n                    \"id\": \"#revtool.cwl/output\",\n                    \"outputBinding\": {\n                        \"glob\": \"output.txt\"\n                    },\n                    \"type\": \"File\"\n                }\n            ],\n            \"stdout\": \"output.txt\"\n        },\n        {\n            \"baseCommand\": \"sort\",\n            \"class\": \"CommandLineTool\",\n            \"doc\": \"Sort lines using the `sort` command\",\n            \"hints\": [\n                {\n                    \"class\": \"ResourceRequirement\",\n                    \"ramMin\": 8\n                }\n            ],\n            \"id\": \"#sorttool.cwl\",\n            \"inputs\": [\n                {\n                    \"id\": \"#sorttool.cwl/reverse\",\n                    \"inputBinding\": {\n                        \"position\": 1,\n                        \"prefix\": \"-r\"\n                    },\n                    \"type\": \"boolean\"\n                },\n                {\n                    \"id\": \"#sorttool.cwl/input\",\n                    \"inputBinding\": {\n                        \"position\": 2\n                    },\n                    \"type\": \"File\"\n                }\n            ],\n            \"outputs\": [\n                {\n                    \"id\": \"#sorttool.cwl/output\",\n                    \"outputBinding\": {\n                        \"glob\": \"output.txt\"\n                    },\n                    \"type\": \"File\"\n                }\n            ],\n            \"stdout\": \"output.txt\"\n        }\n    ],\n    \"cwlVersion\": \"v1.0\"\n}",
+                    owner_uuid: myProject1.uuid,
+                })
+                    .as('testWorkflow');
+
+                cy.createWorkflow(adminUser.token, {
+                    name: `TestWorkflow2-${Math.floor(Math.random() * 999999)}.cwl`,
+                    definition: "{     \"$graph\": [         {             \"$namespaces\": {                 \"arv\": \"http://arvados.org/cwl#\"             },             \"class\": \"Workflow\",             \"doc\": \"Detect blurriness of WSI data.\",             \"id\": \"#main\",             \"inputs\": [                 {                     \"default\": {                         \"basename\": \"3d3cb547725e72ddb442bc620adbc342+2463\",                         \"class\": \"Directory\",                         \"location\": \"keep:3d3cb547725e72ddb442bc620adbc342+2463\"                     },                     \"doc\": \"Collection containing all pipeline input images\",                     \"id\": \"#main/image_collection\",                     \"type\": \"Directory\"                 }             ],             \"outputs\": [                 {                     \"id\": \"#main/blur_report\",                     \"outputSource\": \"#main/blurdetection/report\",                     \"type\": \"Any\"                 }             ],             \"steps\": [                 {                     \"id\": \"#main/blurdetection\",                     \"in\": [                         {                             \"id\": \"#main/blurdetection/image_collection\",                             \"source\": \"#main/image_collection\"                         }                     ],                     \"out\": [                         \"#main/blurdetection/report\"                     ],                     \"run\": \"#blurdetection.cwl\"                 }             ]         },         {             \"arguments\": [                 \"--num_workers\",                 \"0\",                 \"--wsi_dir\",                 \"$(inputs.image_collection)\",                 \"--tile_out_dir\",                 \"$(runtime.outdir)\"             ],             \"baseCommand\": [                 \"python3\",                 \"/updated_blur_on_folder.py\"             ],             \"class\": \"CommandLineTool\",             \"hints\": [                 {                     \"class\": \"DockerRequirement\",                     \"dockerPull\": \"updated_score_aws:cpu2\",                     \"http://arvados.org/cwl#dockerCollectionPDH\": \"0d6702518d1408ce2c471ffec40695cf+4924\"                 },                 {                     \"class\": \"ResourceRequirement\",                     \"coresMin\": 8,                     \"ramMin\": 20000                 },                 {                     \"class\": \"http://arvados.org/cwl#RuntimeConstraints\",                     \"keep_cache\": 2000                 }             ],             \"id\": \"#blurdetection.cwl\",             \"inputs\": [                 {                     \"doc\": \"Collection containing all pipeline input images\",                     \"id\": \"#blurdetection.cwl/image_collection\",                     \"type\": \"Directory\"                 }             ],             \"outputs\": [                 {                     \"id\": \"#blurdetection.cwl/report\",                     \"outputBinding\": {                         \"glob\": \"*.csv\"                     },                     \"type\": \"Any\"                 }             ]         }     ],     \"cwlVersion\": \"v1.0\" }",
+                    owner_uuid: myProject1.uuid,
+                })
+                    .as('testWorkflow2');
+
+                cy.loginAs(activeUser);
+
+                cy.get('main').contains(myProject1.name).click();
+
+                cy.get('[data-cy=side-panel-button]').click();
+
+                cy.get('#aside-menu-list').contains('Run a workflow').click();
+
+                cy.get('@testWorkflow')
+                    .then((testWorkflow) => {
+                        cy.get('main').contains(testWorkflow.name).click();
+                        cy.get('[data-cy=run-process-next-button]').click();
+                        cy.get('[data-cy=new-process-panel]')
+                            .within(() => {
+                                cy.contains('input').next().click();
+                            });
+                        cy.get('[data-cy=choose-a-file-dialog]').as('chooseFileDialog');
+                        cy.get('[data-cy=projects-tree-favourites-tree-picker]').contains('Favorites').closest('ul').find('i').click();
+                        cy.get('@chooseFileDialog').find(`[data-id=${mySharedWritableProject.uuid}]`);
+                        cy.get('@chooseFileDialog').find(`[data-id=${mySharedReadonlyProject.uuid}]`);
+                        cy.get('button').contains('Cancel').click();
+                    });
+
+                cy.get('button').contains('Back').click();
+
+                cy.get('@testWorkflow2')
+                    .then((testWorkflow2) => {
+                        cy.get('main').contains(testWorkflow2.name).click();
+                        cy.get('button').contains('Change Workflow').click();
+                        cy.get('[data-cy=run-process-next-button]').click();
+                        cy.get('[data-cy=new-process-panel]')
+                            .within(() => {
+                                cy.contains('image_collection').next().click();
+                            });
+                        cy.get('[data-cy=choose-a-directory-dialog]').as('chooseDirectoryDialog');
+                        cy.get('[data-cy=projects-tree-favourites-tree-picker]').contains('Favorites').closest('ul').find('i').click();
+                        cy.get('@chooseDirectoryDialog').find(`[data-id=${mySharedWritableProject.uuid}]`);
+                        cy.get('@chooseDirectoryDialog').find(`[data-id=${mySharedReadonlyProject.uuid}]`);
+                    });
+            });
+    });
+});
diff --git a/services/workbench2/cypress/e2e/group-manage.cy.js b/services/workbench2/cypress/e2e/group-manage.cy.js
new file mode 100644 (file)
index 0000000..4af9b40
--- /dev/null
@@ -0,0 +1,311 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Group manage tests', function() {
+    let activeUser;
+    let adminUser;
+    let otherUser;
+    let userThree;
+    const groupName = `Test group (${Math.floor(999999 * Math.random())})`;
+
+    before(function() {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function() {
+                adminUser = this.adminUser;
+            }
+        );
+        cy.getUser('user', 'Active', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+        cy.getUser('otheruser', 'Other', 'User', false, true)
+            .as('otherUser').then(function() {
+                otherUser = this.otherUser;
+            }
+        );
+        cy.getUser('userThree', 'User', 'Three', false, true)
+            .as('userThree').then(function() {
+                userThree = this.userThree;
+            }
+        );
+    });
+
+    it('creates a new group, add users to it and changes permission level', function() {
+        cy.loginAs(activeUser);
+
+        // Navigate to Groups
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+
+        // Create new group
+        cy.get('[data-cy=groups-panel-new-group]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Group')
+            .within(() => {
+                cy.get('input[name=name]').type(groupName);
+                cy.get('[data-cy=users-field] input').type("three");
+            });
+        cy.get('[role=tooltip]').click();
+        cy.get('[data-cy=form-dialog]').within(() => {
+            cy.get('[data-cy=form-submit-btn]').click();
+        })
+
+        // Check that the group was created
+        cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click();
+        cy.get('[data-cy=group-members-data-explorer]').contains(activeUser.user.full_name);
+        cy.get('[data-cy=group-members-data-explorer]').contains(userThree.user.full_name);
+
+        // Add other user to the group
+        cy.get('[data-cy=group-member-add]').click();
+        cy.get('.sharing-dialog')
+            .should('contain', 'Sharing settings')
+            .within(() => {
+                cy.get('[data-cy=invite-people-field] input').type("other");
+            });
+        cy.get('[role=tooltip]').click();
+        // Add admin to the group
+        cy.get('.sharing-dialog')
+            .should('contain', 'Sharing settings')
+            .within(() => {
+                cy.get('[data-cy=invite-people-field] input').type("admin");
+            });
+        cy.get('[role=tooltip]').click();
+        cy.get('.sharing-dialog').get('[data-cy=add-invited-people]').click();
+        cy.get('.sharing-dialog').contains('Close').click();
+
+        // Check that both users are present with appropriate permissions
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read');
+            });
+        cy.get('[data-cy=group-members-data-explorer] tr')
+            .contains(activeUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Manage');
+            });
+
+        // Test change permission level
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read')
+                    .parents('td')
+                    .within(() => {
+                        cy.get('button').click();
+                    });
+            });
+        cy.get('[data-cy=context-menu]')
+            .contains('Write')
+            .click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Write');
+            });
+
+        // Change admin to manage
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(adminUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read')
+                    .parents('td')
+                    .within(() => {
+                        cy.get('button').click();
+                    });
+            });
+        cy.get('[data-cy=context-menu]')
+            .contains('Manage')
+            .click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(adminUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Manage');
+            });
+    });
+
+    it('can unhide and re-hide users', function() {
+        // Must use admin user to have manage permission on user
+        cy.loginAs(adminUser);
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+        cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click();
+
+        // Check that other user is hidden
+        cy.get('[data-cy=group-details-permissions-tab]').click();
+        cy.get('[data-cy=group-permissions-data-explorer]')
+            .should('not.contain', otherUser.user.full_name)
+        cy.get('[data-cy=group-details-members-tab]').click();
+
+        // Test unhide
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.get('[data-cy=user-visible-checkbox]').click();
+            });
+        // Check that other user is visible
+        cy.get('[data-cy=group-details-permissions-tab]').click();
+        cy.get('[data-cy=group-permissions-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read');
+            });
+        // Test re-hide
+        cy.get('[data-cy=group-details-members-tab]').click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.get('[data-cy=user-visible-checkbox]').click();
+            });
+        // Check that other user is hidden
+        cy.get('[data-cy=group-details-permissions-tab]').click();
+        cy.get('[data-cy=group-permissions-data-explorer]')
+            .should('not.contain', otherUser.user.full_name)
+    });
+
+    it('displays resources shared with the group', function() {
+        // Switch to activeUser
+        cy.loginAs(activeUser);
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+
+        // Get groupUuid and create shared project
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .contains(groupName)
+            .parents('tr')
+            .find('[data-cy=uuid]')
+            .invoke('text')
+            .as('groupUuid')
+            .then((groupUuid) => {
+                cy.createProject({
+                    owningUser: activeUser,
+                    projectName: 'test-project',
+                }).as('testProject').then((testProject) => {
+                    cy.shareWith(activeUser.token, groupUuid, testProject.uuid, 'can_read');
+                });
+            });
+
+        // Check that the project is listed in permissions
+        cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click();
+        cy.get('[data-cy=group-details-permissions-tab]').click();
+        cy.get('[data-cy=group-permissions-data-explorer]')
+            .contains('test-project')
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read');
+            });
+    });
+
+    it('removes users from the group', function() {
+        cy.loginAs(activeUser);
+
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+        cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click();
+
+        // Remove other user
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.get('[data-cy=resource-delete-button]').click();
+            });
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .should('not.contain', otherUser.user.full_name);
+
+        // Remove user three
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(userThree.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.get('[data-cy=resource-delete-button]').click();
+            });
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .should('not.contain', userThree.user.full_name);
+    });
+
+    it('renames the group', function() {
+        cy.loginAs(adminUser);
+        // Navigate to Groups
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+
+        // Open rename dialog
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .contains(groupName)
+            .rightclick();
+        cy.get('[data-cy=context-menu]')
+            .contains('Rename')
+            .click();
+
+        // Rename the group
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'Edit Group')
+            .within(() => {
+                cy.get('input[name=name]').clear().type(groupName + ' (renamed)');
+                cy.get('button').contains('Save').click();
+            });
+
+        // Check that the group was renamed
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .contains(groupName + ' (renamed)');
+    });
+
+    it('deletes the group', function() {
+        cy.loginAs(adminUser);
+
+        // Navigate to Groups
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+
+        // Delete the group
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .contains(groupName + ' (renamed)')
+            .rightclick();
+        cy.get('[data-cy=context-menu]')
+            .contains('Remove')
+            .click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that the group was deleted
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .should('not.contain', groupName + ' (renamed)');
+    });
+
+    it('disables group-related controls for built-in groups', function() {
+        cy.loginAs(adminUser);
+
+        ['All users', 'Anonymous users', 'System group'].forEach((builtInGroup) => {
+            cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+            cy.get('[data-cy=groups-panel-data-explorer]').contains(builtInGroup).click();
+
+            // Check group member actions
+            cy.get('[data-cy=group-members-data-explorer]')
+                .within(() => {
+                    cy.get('[data-cy=group-member-add]').should('not.exist');
+                    cy.get('[data-cy=user-visible-checkbox] input').should('be.disabled');
+                    cy.get('[data-cy=resource-delete-button]').should('be.disabled');
+                    cy.get('[data-cy=edit-permission-button]').should('not.exist');
+                });
+
+            // Check permissions actions
+            cy.get('[data-cy=group-details-permissions-tab]').click();
+            cy.get('[data-cy=group-permissions-data-explorer]').within(() => {
+                cy.get('[data-cy=resource-delete-button]').should('be.disabled');
+                cy.get('[data-cy=edit-permission-button]').should('not.exist');
+            });
+        });
+    });
+
+});
diff --git a/services/workbench2/cypress/e2e/login.cy.js b/services/workbench2/cypress/e2e/login.cy.js
new file mode 100644 (file)
index 0000000..6f2c91c
--- /dev/null
@@ -0,0 +1,134 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Login tests', function() {
+    let activeUser;
+    let inactiveUser;
+    let adminUser;
+    let randomUser = {};
+
+    before(function() {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function() {
+                adminUser = this.adminUser;
+            }
+        );
+        cy.getUser('active', 'Active', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+        cy.getUser('inactive', 'Inactive', 'User', false, false)
+            .as('inactiveUser').then(function() {
+                inactiveUser = this.inactiveUser;
+            }
+                                    );
+        // Username/password match Login.Test section of arvados_config.yml
+        randomUser.username = 'randomuser1234';
+        randomUser.password = 'topsecret';
+    })
+
+    it('shows login page on first visit', function() {
+        cy.visit('/')
+        cy.get('div#root').should('contain', 'Please log in')
+        cy.url().should('not.contain', '/projects/')
+    })
+
+    it('shows login page with no token', function() {
+        cy.visit('/token/?api_token=')
+        cy.get('div#root').should('contain', 'Please log in')
+        cy.url().should('not.contain', '/projects/')
+    })
+
+    it('shows inactive page to inactive user', function() {
+        cy.visit(`/token/?api_token=${inactiveUser.token}`)
+        cy.get('div#root').should('contain', 'Your account is inactive');
+    })
+
+    it('shows login page with invalid token', function() {
+        cy.visit('/token/?api_token=nope')
+        cy.get('div#root').should('contain', 'Please log in')
+        cy.url().should('not.contain', '/projects/')
+    })
+
+    it('logs in successfully with valid user token', function() {
+        cy.visit(`/token/?api_token=${activeUser.token}`);
+        cy.url().should('contain', '/projects/');
+        cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
+        cy.get('div#root').should('not.contain', 'Your account is inactive');
+        cy.get('button[title="Account Management"]').click();
+        cy.get('ul[role=menu] > li[role=menuitem]').contains(
+            `${activeUser.user.first_name} ${activeUser.user.last_name}`);
+    })
+
+    it('logs out when token no longer valid', function() {
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: `Test Project ${Math.floor(Math.random() * 999999)}`,
+            addToFavorites: false
+        }).as('testProject1');
+        // Log in
+        cy.visit(`/token/?api_token=${activeUser.token}`);
+        cy.url().should('contain', '/projects/');
+        cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
+        cy.get('div#root').should('not.contain', 'Your account is inactive');
+        cy.waitForDom();
+
+        // Invalidate own token.
+        const tokenUuid = activeUser.token.split('/')[1];
+        cy.doRequest('PUT', `/arvados/v1/api_client_authorizations/${tokenUuid}`, {
+            id: tokenUuid,
+            api_client_authorization: JSON.stringify({
+                api_token: `randomToken${Math.floor(Math.random() * 999999)}`
+            })
+        }, null, activeUser.token, true);
+        // Should log the user out.
+
+        cy.getAll('@testProject1').then(([testProject1]) => {
+            cy.get('main').contains(testProject1.name).click();
+            cy.get('div#root').should('contain', 'Please log in');
+            // Should retain last visited url when auth is invalidated
+            cy.url().should('contain', `/projects/${testProject1.uuid}`);
+        })
+    })
+
+    it('logs in successfully with valid admin token', function() {
+        cy.visit(`/token/?api_token=${adminUser.token}`);
+        cy.url().should('contain', '/projects/');
+        cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
+        cy.get('div#root').should('not.contain', 'Your account is inactive');
+        cy.get('button[title="Admin Panel"]').click();
+        cy.get('ul[role=menu] > li[role=menuitem]')
+            .contains('Repositories')
+            .type('{esc}');
+        cy.get('button[title="Account Management"]').click();
+        cy.get('ul[role=menu] > li[role=menuitem]').contains(
+            `${adminUser.user.first_name} ${adminUser.user.last_name}`);
+    })
+
+    it('fails to authenticate using the login form with wrong password', function() {
+        cy.visit('/');
+        cy.get('#username').type(randomUser.username);
+        cy.get('#password').type('wrong password');
+        cy.get("button span:contains('Log in')").click();
+        cy.get('p#password-helper-text').should('contain', 'authentication failed');
+        cy.url().should('not.contain', '/projects/');
+    })
+
+    it('successfully authenticates using the login form', function() {
+        cy.visit('/');
+        cy.get('#username').type(randomUser.username);
+        cy.get('#password').type(randomUser.password);
+        cy.get("button span:contains('Log in')").click();
+        cy.url().should('contain', '/projects/');
+        cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
+        cy.get('div#root').should('contain', 'Your account is inactive');
+        cy.get('button[title="Account Management"]').click();
+        cy.get('ul[role=menu] > li[role=menuitem]').contains(randomUser.username);
+    })
+})
diff --git a/services/workbench2/cypress/e2e/multiselect-toolbar.cy.js b/services/workbench2/cypress/e2e/multiselect-toolbar.cy.js
new file mode 100644 (file)
index 0000000..ce3551b
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Multiselect Toolbar Tests', () => {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser')
+            .then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser('user', 'Active', 'User', false, true)
+            .as('activeUser')
+            .then(function () {
+                activeUser = this.activeUser;
+            });
+    });
+
+    it('exists in DOM in neutral state', () => {
+        cy.loginAs(activeUser);
+        cy.get('[data-cy=multiselect-toolbar]').should('exist');
+        cy.get('[data-cy=multiselect-button]').should('not.exist');
+    });
+});
diff --git a/services/workbench2/cypress/e2e/page-not-found.cy.js b/services/workbench2/cypress/e2e/page-not-found.cy.js
new file mode 100644 (file)
index 0000000..7cffd10
--- /dev/null
@@ -0,0 +1,55 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Page not found tests', function() {
+    let adminUser;
+
+    before(function() {
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function() {
+                adminUser = this.adminUser;
+            }
+        );
+    });
+
+    it('shows not found page', function() {
+        // when
+        cy.loginAs(adminUser);
+        cy.goToPath(`/this/is/an/invalid/route`);
+
+        // then
+        cy.get('[data-cy=not-found-page]').should('exist');
+        cy.get('[data-cy=not-found-content]').should('exist');
+    });
+
+
+    it('shows not found popup', function() {
+        // given
+        [
+            '/projects/zzzzz-j7d0g-nonexistingproj',
+            '/projects/zzzzz-tpzed-nonexistinguser',
+        ].forEach(function(path) {
+            // Using de slower loginAs() method to avoid bumping into dialog
+            // dismissal issues that are not related to this test.
+            cy.loginAs(adminUser);
+
+            // when
+            cy.goToPath(path);
+
+            // then
+            cy.get('[data-cy=default-view]').should('exist');
+        });
+
+        [
+            '/processes/zzzzz-xvhdp-nonexistingproc',
+            '/collections/zzzzz-4zz18-nonexistingcoll'
+        ].forEach(function(path) {
+            cy.loginAs(adminUser);
+
+            cy.goToPath(path);
+
+            cy.get('[data-cy=not-found-view]').should('exist');
+        });
+    });
+})
diff --git a/services/workbench2/cypress/e2e/process.cy.js b/services/workbench2/cypress/e2e/process.cy.js
new file mode 100644 (file)
index 0000000..6a3a894
--- /dev/null
@@ -0,0 +1,1567 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContainerState } from "models/container";
+
+describe("Process tests", function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser("user", "Active", "User", false, true)
+            .as("activeUser")
+            .then(function () {
+                activeUser = this.activeUser;
+            });
+    });
+
+    function setupDockerImage(image_name) {
+        // Create a collection that will be used as a docker image for the tests.
+        cy.createCollection(adminUser.token, {
+            name: "docker_image",
+            manifest_text:
+                ". d21353cfe035e3e384563ee55eadbb2f+67108864 5c77a43e329b9838cbec18ff42790e57+55605760 0:122714624:sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678.tar\n",
+        })
+            .as("dockerImage")
+            .then(function (dockerImage) {
+                // Give read permissions to the active user on the docker image.
+                cy.createLink(adminUser.token, {
+                    link_class: "permission",
+                    name: "can_read",
+                    tail_uuid: activeUser.user.uuid,
+                    head_uuid: dockerImage.uuid,
+                })
+                    .as("dockerImagePermission")
+                    .then(function () {
+                        // Set-up docker image collection tags
+                        cy.createLink(activeUser.token, {
+                            link_class: "docker_image_repo+tag",
+                            name: image_name,
+                            head_uuid: dockerImage.uuid,
+                        }).as("dockerImageRepoTag");
+                        cy.createLink(activeUser.token, {
+                            link_class: "docker_image_hash",
+                            name: "sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678",
+                            head_uuid: dockerImage.uuid,
+                        }).as("dockerImageHash");
+                    });
+            });
+        return cy.getAll("@dockerImage", "@dockerImageRepoTag", "@dockerImageHash", "@dockerImagePermission").then(function ([dockerImage]) {
+            return dockerImage;
+        });
+    }
+
+    function createContainerRequest(user, name, docker_image, command, reuse = false, state = "Uncommitted") {
+        return setupDockerImage(docker_image).then(function (dockerImage) {
+            return cy.createContainerRequest(user.token, {
+                name: name,
+                command: command,
+                container_image: dockerImage.portable_data_hash, // for some reason, docker_image doesn't work here
+                output_path: "stdout.txt",
+                priority: 1,
+                runtime_constraints: {
+                    vcpus: 1,
+                    ram: 1,
+                },
+                use_existing: reuse,
+                state: state,
+                mounts: {
+                    foo: {
+                        kind: "tmp",
+                        path: "/tmp/foo",
+                    },
+                },
+            });
+        });
+    }
+
+    describe('Multiselect Toolbar', () => {
+        it('shows the appropriate buttons in the toolbar', () => {
+
+            const msButtonTooltips = [
+                'View details',
+                'Open in new tab',
+                'Outputs',
+                'API Details',
+                'Edit process',
+                'Copy and re-run process',
+                'CANCEL',
+                'Remove',
+                'Add to favorites',
+            ];
+
+            createContainerRequest(
+                activeUser,
+                `test_container_request ${Math.floor(Math.random() * 999999)}`,
+                "arvados/jobs",
+                ["echo", "hello world"],
+                false,
+                "Committed"
+            ).then(function (containerRequest) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", containerRequest.name);
+                cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`Active User (${activeUser.user.uuid})`);
+                cy.get("[data-cy=process-details-attributes-runtime-user]").should("not.exist");
+                cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
+                cy.waitForDom()
+                cy.get('[data-cy=data-table-row]').contains(containerRequest.name).should('exist').parent().parent().parent().parent().click()
+                cy.waitForDom()
+                cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length)
+                for (let i = 0; i < msButtonTooltips.length; i++) {
+                    cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover');
+                    cy.get('body').contains(msButtonTooltips[i]).should('exist')
+                    cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout');
+                }
+            });
+        })
+    })
+
+    describe("Details panel", function () {
+        it("shows process details", function () {
+            createContainerRequest(
+                activeUser,
+                `test_container_request ${Math.floor(Math.random() * 999999)}`,
+                "arvados/jobs",
+                ["echo", "hello world"],
+                false,
+                "Committed"
+            ).then(function (containerRequest) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", containerRequest.name);
+                cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`Active User (${activeUser.user.uuid})`);
+                cy.get("[data-cy=process-details-attributes-runtime-user]").should("not.exist");
+            });
+
+            // Fake submitted by another user
+            cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                req.on('response', res => {
+                    res.body.modified_by_user_uuid = "zzzzz-tpzed-000000000000000";
+                });
+            });
+
+            createContainerRequest(
+                activeUser,
+                `test_container_request ${Math.floor(Math.random() * 999999)}`,
+                "arvados/jobs",
+                ["echo", "hello world"],
+                false,
+                "Committed"
+            ).then(function (containerRequest) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", containerRequest.name);
+                cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`zzzzz-tpzed-000000000000000`);
+                cy.get("[data-cy=process-details-attributes-runtime-user]").contains(`Active User (${activeUser.user.uuid})`);
+            });
+        });
+
+        it("should show runtime status indicators", function () {
+            // Setup running container with runtime_status error & warning messages
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed")
+                .as("containerRequest")
+                .then(function (containerRequest) {
+                    expect(containerRequest.state).to.equal("Committed");
+                    expect(containerRequest.container_uuid).not.to.be.equal("");
+
+                    cy.getContainer(activeUser.token, containerRequest.container_uuid).then(function (queuedContainer) {
+                        expect(queuedContainer.state).to.be.equal("Queued");
+                    });
+                    cy.updateContainer(adminUser.token, containerRequest.container_uuid, {
+                        state: "Locked",
+                    }).then(function (lockedContainer) {
+                        expect(lockedContainer.state).to.be.equal("Locked");
+
+                        cy.updateContainer(adminUser.token, lockedContainer.uuid, {
+                            state: "Running",
+                            runtime_status: {
+                                error: "Something went wrong",
+                                errorDetail: "Process exited with status 1",
+                                warning: "Free disk space is low",
+                            },
+                        })
+                            .as("runningContainer")
+                            .then(function (runningContainer) {
+                                expect(runningContainer.state).to.be.equal("Running");
+                                expect(runningContainer.runtime_status).to.be.deep.equal({
+                                    error: "Something went wrong",
+                                    errorDetail: "Process exited with status 1",
+                                    warning: "Free disk space is low",
+                                });
+                            });
+                    });
+                });
+            // Test that the UI shows the error and warning messages
+            cy.getAll("@containerRequest", "@runningContainer").then(function ([containerRequest]) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-runtime-status-error]")
+                    .should("contain", "Something went wrong")
+                    .and("contain", "Process exited with status 1");
+                cy.get("[data-cy=process-runtime-status-warning]")
+                    .should("contain", "Free disk space is low")
+                    .and("contain", "No additional warning details available");
+            });
+
+            // Force container_count for testing
+            let containerCount = 2;
+            cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                req.on('response', res => {
+                    res.body.container_count = containerCount;
+                });
+            });
+
+            cy.getAll("@containerRequest").then(function ([containerRequest]) {
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-runtime-status-retry-warning]", { timeout: 7000 }).should("contain", "Process retried 1 time");
+            });
+
+            cy.getAll("@containerRequest").then(function ([containerRequest]) {
+                containerCount = 3;
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-runtime-status-retry-warning]", { timeout: 7000 }).should("contain", "Process retried 2 times");
+            });
+        });
+
+        it("allows copying processes", function () {
+            const crName = "first_container_request";
+            const copiedCrName = "copied_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+
+                cy.get("[data-cy=process-details]").find('button[title="More options"]').click();
+                cy.get("ul[data-cy=context-menu]").contains("Copy and re-run process").click();
+            });
+
+            cy.get("[data-cy=form-dialog]").within(() => {
+                cy.get("input[name=name]").clear().type(copiedCrName);
+                cy.get("[data-cy=projects-tree-home-tree-picker]").click();
+                cy.get("[data-cy=form-submit-btn]").click();
+            });
+
+            cy.get("[data-cy=process-details]").should("contain", copiedCrName);
+            cy.get("[data-cy=process-details]").find("button").contains("Run");
+        });
+
+        const getFakeContainer = fakeContainerUuid => ({
+            href: `/containers/${fakeContainerUuid}`,
+            kind: "arvados#container",
+            etag: "ecfosljpnxfari9a8m7e4yv06",
+            uuid: fakeContainerUuid,
+            owner_uuid: "zzzzz-tpzed-000000000000000",
+            created_at: "2023-02-13T15:55:47.308915000Z",
+            modified_by_client_uuid: "zzzzz-ozdt8-q6dzdi1lcc03155",
+            modified_by_user_uuid: "zzzzz-tpzed-000000000000000",
+            modified_at: "2023-02-15T19:12:45.987086000Z",
+            command: [
+                "arvados-cwl-runner",
+                "--api=containers",
+                "--local",
+                "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza",
+                "/var/lib/cwl/workflow.json#main",
+                "/var/lib/cwl/cwl.input.json",
+            ],
+            container_image: "4ad7d11381df349e464694762db14e04+303",
+            cwd: "/var/spool/cwl",
+            environment: {},
+            exit_code: null,
+            finished_at: null,
+            locked_by_uuid: null,
+            log: null,
+            output: null,
+            output_path: "/var/spool/cwl",
+            progress: null,
+            runtime_constraints: {
+                API: true,
+                cuda: {
+                    device_count: 0,
+                    driver_version: "",
+                    hardware_capability: "",
+                },
+                keep_cache_disk: 2147483648,
+                keep_cache_ram: 0,
+                ram: 1342177280,
+                vcpus: 1,
+            },
+            runtime_status: {},
+            started_at: null,
+            auth_uuid: null,
+            scheduling_parameters: {
+                max_run_time: 0,
+                partitions: [],
+                preemptible: false,
+            },
+            runtime_user_uuid: "zzzzz-tpzed-vllbpebicy84rd5",
+            runtime_auth_scopes: ["all"],
+            lock_count: 2,
+            gateway_address: null,
+            interactive_session_started: false,
+            output_storage_classes: ["default"],
+            output_properties: {},
+            cost: 0.0,
+            subrequests_cost: 0.0,
+        });
+
+        it("shows cancel button when appropriate", function () {
+            // Ignore collection requests
+            cy.intercept(
+                { method: "GET", url: `**/arvados/v1/collections/*` },
+                {
+                    statusCode: 200,
+                    body: {},
+                }
+            );
+
+            // Uncommitted container
+            const crUncommitted = `Test process ${Math.floor(Math.random() * 999999)}`;
+            createContainerRequest(activeUser, crUncommitted, "arvados/jobs", ["echo", "hello world"], false, "Uncommitted").then(function (
+                containerRequest
+            ) {
+                cy.loginAs(activeUser);
+                // Navigate to process and verify run / cancel button
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.waitForDom();
+                cy.get("[data-cy=process-details]").should("contain", crUncommitted);
+                cy.get("[data-cy=process-run-button]").should("exist");
+                cy.get("[data-cy=process-cancel-button]").should("not.exist");
+            });
+
+            // Queued container
+            const crQueued = `Test process ${Math.floor(Math.random() * 999999)}`;
+            const fakeCrUuid = "zzzzz-dz642-000000000000001";
+            createContainerRequest(activeUser, crQueued, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                // Fake container uuid
+                cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => {
+                    req.on('response', res => {
+                        res.body.output_uuid = fakeCrUuid;
+                        res.body.priority = 500;
+                        res.body.state = "Committed";
+                    });
+                });
+
+                // Fake container
+                const container = getFakeContainer(fakeCrUuid);
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Queued", priority: 500 },
+                    }
+                );
+
+                // Navigate to process and verify cancel button
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.waitForDom();
+                cy.get("[data-cy=process-details]").should("contain", crQueued);
+                cy.get("[data-cy=process-cancel-button]").contains("Cancel");
+            });
+
+            // Locked container
+            const crLocked = `Test process ${Math.floor(Math.random() * 999999)}`;
+            const fakeCrLockedUuid = "zzzzz-dz642-000000000000002";
+            createContainerRequest(activeUser, crLocked, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                // Fake container uuid
+                cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => {
+                    req.on('response', res => {
+                        res.body.output_uuid = fakeCrLockedUuid;
+                        res.body.priority = 500;
+                        res.body.state = "Committed";
+                    });
+                });
+
+                // Fake container
+                const container = getFakeContainer(fakeCrLockedUuid);
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrLockedUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Locked", priority: 500 },
+                    }
+                );
+
+                // Navigate to process and verify cancel button
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.waitForDom();
+                cy.get("[data-cy=process-details]").should("contain", crLocked);
+                cy.get("[data-cy=process-cancel-button]").contains("Cancel");
+            });
+
+            // On Hold container
+            const crOnHold = `Test process ${Math.floor(Math.random() * 999999)}`;
+            const fakeCrOnHoldUuid = "zzzzz-dz642-000000000000003";
+            createContainerRequest(activeUser, crOnHold, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                // Fake container uuid
+                cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => {
+                    req.on('response', res => {
+                        res.body.output_uuid = fakeCrOnHoldUuid;
+                        res.body.priority = 0;
+                        res.body.state = "Committed";
+                    });
+                });
+
+                // Fake container
+                const container = getFakeContainer(fakeCrOnHoldUuid);
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrOnHoldUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Queued", priority: 0 },
+                    }
+                );
+
+                // Navigate to process and verify cancel button
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.waitForDom();
+                cy.get("[data-cy=process-details]").should("contain", crOnHold);
+                cy.get("[data-cy=process-run-button]").should("exist");
+                cy.get("[data-cy=process-cancel-button]").should("not.exist");
+            });
+        });
+    });
+
+    describe("Logs panel", function () {
+        it("shows live process logs", function () {
+            cy.intercept({ method: "GET", url: "**/arvados/v1/containers/*" }, req => {
+                req.on('response', res => {
+                    res.body.state = ContainerState.RUNNING;
+                });
+            });
+
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                // Create empty log file before loading process page
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [""]);
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet").and("not.contain", "hello world");
+
+                // Append a log line
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", ["2023-07-18T20:14:48.128642814Z hello world"]).then(() => {
+                    cy.get("[data-cy=process-logs]", { timeout: 7000 }).should("not.contain", "No logs yet").and("contain", "hello world");
+                });
+
+                // Append new log line to different file
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", ["2023-07-18T20:14:49.128642814Z hello new line"]).then(() => {
+                    cy.get("[data-cy=process-logs]", { timeout: 7000 }).should("not.contain", "No logs yet").and("contain", "hello new line");
+                });
+            });
+        });
+
+        it("filters process logs by event type", function () {
+            const nodeInfoLogs = [
+                "Host Information",
+                "Linux compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p 5.4.0-1059-azure #62~18.04.1-Ubuntu SMP Tue Sep 14 17:53:18 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux",
+                "CPU Information",
+                "processor  : 0",
+                "vendor_id  : GenuineIntel",
+                "cpu family : 6",
+                "model      : 79",
+                "model name : Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz",
+            ];
+            const crunchRunLogs = [
+                "2022-03-22T13:56:22.542417997Z using local keepstore process (pid 3733) at http://localhost:46837, writing logs to keepstore.txt in log collection",
+                "2022-03-22T13:56:26.237571754Z crunch-run 2.4.0~dev20220321141729 (go1.17.1) started",
+                "2022-03-22T13:56:26.244704134Z crunch-run process has uid=0(root) gid=0(root) groups=0(root)",
+                "2022-03-22T13:56:26.244862836Z Executing container 'zzzzz-dz642-1wokwvcct9s9du3' using docker runtime",
+                "2022-03-22T13:56:26.245037738Z Executing on host 'compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p'",
+            ];
+            const stdoutLogs = [
+                "2022-03-22T13:56:22.542417987Z Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec dui nisi, hendrerit porta sapien a, pretium dignissim purus.",
+                "2022-03-22T13:56:22.542417997Z Integer viverra, mauris finibus aliquet ultricies, dui mauris cursus justo, ut venenatis nibh ex eget neque.",
+                "2022-03-22T13:56:22.542418007Z In hac habitasse platea dictumst.",
+                "2022-03-22T13:56:22.542418027Z Fusce fringilla turpis id accumsan faucibus. Donec congue congue ex non posuere. In semper mi quis tristique rhoncus.",
+                "2022-03-22T13:56:22.542418037Z Interdum et malesuada fames ac ante ipsum primis in faucibus.",
+                "2022-03-22T13:56:22.542418047Z Quisque fermentum tortor ex, ut suscipit velit feugiat faucibus.",
+                "2022-03-22T13:56:22.542418057Z Donec vitae porta risus, at luctus nulla. Mauris gravida iaculis ipsum, id sagittis tortor egestas ac.",
+                "2022-03-22T13:56:22.542418067Z Maecenas condimentum volutpat nulla. Integer lacinia maximus risus eu posuere.",
+                "2022-03-22T13:56:22.542418077Z Donec vitae leo id augue gravida bibendum.",
+                "2022-03-22T13:56:22.542418087Z Nam libero libero, pretium ac faucibus elementum, mattis nec ex.",
+                "2022-03-22T13:56:22.542418097Z Nullam id laoreet nibh. Vivamus tellus metus, pretium quis justo ut, bibendum varius metus. Pellentesque vitae accumsan lorem, quis tincidunt augue.",
+                "2022-03-22T13:56:22.542418107Z Aliquam viverra nisi nulla, et efficitur dolor mattis in.",
+                "2022-03-22T13:56:22.542418117Z Sed at enim sit amet nulla tincidunt mattis. Aenean eget aliquet ex, non ultrices ex. Nulla ex tortor, vestibulum aliquam tempor ac, aliquam vel est.",
+                "2022-03-22T13:56:22.542418127Z Fusce auctor faucibus libero id venenatis. Etiam sodales, odio eu cursus efficitur, quam sem blandit ex, quis porttitor enim dui quis lectus. In id tincidunt felis.",
+                "2022-03-22T13:56:22.542418137Z Phasellus non ex quis arcu tempus faucibus molestie in sapien.",
+                "2022-03-22T13:56:22.542418147Z Duis tristique semper dolor, vitae pulvinar risus.",
+                "2022-03-22T13:56:22.542418157Z Aliquam tortor elit, luctus nec tortor eget, porta tristique nulla.",
+                "2022-03-22T13:56:22.542418167Z Nulla eget mollis ipsum.",
+            ];
+
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "node-info.txt", nodeInfoLogs).as("nodeInfoLogs");
+                cy.appendLog(adminUser.token, containerRequest.uuid, "crunch-run.txt", crunchRunLogs).as("crunchRunLogs");
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", stdoutLogs).as("stdoutLogs");
+
+                cy.getAll("@stdoutLogs", "@nodeInfoLogs", "@crunchRunLogs").then(function () {
+                    cy.loginAs(activeUser);
+                    cy.goToPath(`/processes/${containerRequest.uuid}`);
+                    // Should show main logs by default
+                    cy.get("[data-cy=process-logs-filter]", { timeout: 7000 }).should("contain", "Main logs");
+                    cy.get("[data-cy=process-logs]")
+                        .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("not.contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    // Select 'All logs'
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "All logs").click();
+                    cy.get("[data-cy=process-logs]")
+                        .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    // Select 'node-info' logs
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "node-info").click();
+                    cy.get("[data-cy=process-logs]")
+                        .should("not.contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("not.contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    // Select 'stdout' logs
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "stdout").click();
+                    cy.get("[data-cy=process-logs]")
+                        .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("not.contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("not.contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                });
+            });
+        });
+
+        it("sorts combined logs", function () {
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "node-info.txt", [
+                    "3: nodeinfo 1",
+                    "2: nodeinfo 2",
+                    "1: nodeinfo 3",
+                    "2: nodeinfo 4",
+                    "3: nodeinfo 5",
+                ]).as("node-info");
+
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
+                    "2023-07-18T20:14:48.128642814Z first",
+                    "2023-07-18T20:14:49.128642814Z third",
+                ]).as("stdout");
+
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", ["2023-07-18T20:14:48.528642814Z second"]).as("stderr");
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet");
+
+                cy.getAll("@node-info", "@stdout", "@stderr").then(() => {
+                    // Verify sorted main logs
+                    cy.get("[data-cy=process-logs] span > p", { timeout: 7000 }).eq(0).should("contain", "2023-07-18T20:14:48.128642814Z first");
+                    cy.get("[data-cy=process-logs] span > p").eq(1).should("contain", "2023-07-18T20:14:48.528642814Z second");
+                    cy.get("[data-cy=process-logs] span > p").eq(2).should("contain", "2023-07-18T20:14:49.128642814Z third");
+
+                    // Switch to All logs
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "All logs").click();
+                    // Verify non-sorted lines were preserved
+                    cy.get("[data-cy=process-logs] span > p").eq(0).should("contain", "3: nodeinfo 1");
+                    cy.get("[data-cy=process-logs] span > p").eq(1).should("contain", "2: nodeinfo 2");
+                    cy.get("[data-cy=process-logs] span > p").eq(2).should("contain", "1: nodeinfo 3");
+                    cy.get("[data-cy=process-logs] span > p").eq(3).should("contain", "2: nodeinfo 4");
+                    cy.get("[data-cy=process-logs] span > p").eq(4).should("contain", "3: nodeinfo 5");
+                    // Verify sorted logs
+                    cy.get("[data-cy=process-logs] span > p").eq(5).should("contain", "2023-07-18T20:14:48.128642814Z first");
+                    cy.get("[data-cy=process-logs] span > p").eq(6).should("contain", "2023-07-18T20:14:48.528642814Z second");
+                    cy.get("[data-cy=process-logs] span > p").eq(7).should("contain", "2023-07-18T20:14:49.128642814Z third");
+                });
+            });
+        });
+
+        it("preserves original ordering of lines within the same log type", function () {
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
+                    // Should come first
+                    "2023-07-18T20:14:46.000000000Z A out 1",
+                    // Comes fourth in a contiguous block
+                    "2023-07-18T20:14:48.128642814Z A out 2",
+                    "2023-07-18T20:14:48.128642814Z X out 3",
+                    "2023-07-18T20:14:48.128642814Z A out 4",
+                ]).as("stdout");
+
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", [
+                    // Comes second
+                    "2023-07-18T20:14:47.000000000Z Z err 1",
+                    // Comes third in a contiguous block
+                    "2023-07-18T20:14:48.128642814Z B err 2",
+                    "2023-07-18T20:14:48.128642814Z C err 3",
+                    "2023-07-18T20:14:48.128642814Z Y err 4",
+                    "2023-07-18T20:14:48.128642814Z Z err 5",
+                    "2023-07-18T20:14:48.128642814Z A err 6",
+                ]).as("stderr");
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet");
+
+                cy.getAll("@stdout", "@stderr").then(() => {
+                    // Switch to All logs
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "All logs").click();
+                    // Verify sorted logs
+                    cy.get("[data-cy=process-logs] span > p").eq(0).should("contain", "2023-07-18T20:14:46.000000000Z A out 1");
+                    cy.get("[data-cy=process-logs] span > p").eq(1).should("contain", "2023-07-18T20:14:47.000000000Z Z err 1");
+                    cy.get("[data-cy=process-logs] span > p").eq(2).should("contain", "2023-07-18T20:14:48.128642814Z B err 2");
+                    cy.get("[data-cy=process-logs] span > p").eq(3).should("contain", "2023-07-18T20:14:48.128642814Z C err 3");
+                    cy.get("[data-cy=process-logs] span > p").eq(4).should("contain", "2023-07-18T20:14:48.128642814Z Y err 4");
+                    cy.get("[data-cy=process-logs] span > p").eq(5).should("contain", "2023-07-18T20:14:48.128642814Z Z err 5");
+                    cy.get("[data-cy=process-logs] span > p").eq(6).should("contain", "2023-07-18T20:14:48.128642814Z A err 6");
+                    cy.get("[data-cy=process-logs] span > p").eq(7).should("contain", "2023-07-18T20:14:48.128642814Z A out 2");
+                    cy.get("[data-cy=process-logs] span > p").eq(8).should("contain", "2023-07-18T20:14:48.128642814Z X out 3");
+                    cy.get("[data-cy=process-logs] span > p").eq(9).should("contain", "2023-07-18T20:14:48.128642814Z A out 4");
+                });
+            });
+        });
+
+        it("correctly generates sniplines", function () {
+            const SNIPLINE = `================ ✀ ================ ✀ ========= Some log(s) were skipped ========= ✀ ================ ✀ ================`;
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
+                    "X".repeat(63999) + "_" + "O".repeat(100) + "_" + "X".repeat(63999),
+                ]).as("stdout");
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet");
+
+                // Switch to stdout since lines are unsortable (no timestamp)
+                cy.get("[data-cy=process-logs-filter]").click();
+                cy.get("body").contains("li", "stdout").click();
+
+                cy.getAll("@stdout").then(() => {
+                    // Verify first 64KB and snipline
+                    cy.get("[data-cy=process-logs] span > p", { timeout: 7000 })
+                        .eq(0)
+                        .should("contain", "X".repeat(63999) + "_\n" + SNIPLINE);
+                    // Verify last 64KB
+                    cy.get("[data-cy=process-logs] span > p")
+                        .eq(1)
+                        .should("contain", "_" + "X".repeat(63999));
+                    // Verify none of the Os got through
+                    cy.get("[data-cy=process-logs] span > p").should("not.contain", "O");
+                });
+            });
+        });
+
+        it("correctly break long lines when no obvious line separation exists", function () {
+            function randomString(length) {
+                const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+                let res = '';
+                for (let i = 0; i < length; i++) {
+                    res += chars.charAt(Math.floor(Math.random() * chars.length));
+                }
+                return res;
+            }
+
+            const logLinesQty = 10;
+            const logLines = [];
+            for (let i = 0; i < logLinesQty; i++) {
+                const length = Math.floor(Math.random() * 500) + 500;
+                logLines.push(randomString(length));
+            }
+
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", logLines).as("stdoutLogs");
+
+                cy.getAll("@stdoutLogs").then(function () {
+                    cy.loginAs(activeUser);
+                    cy.goToPath(`/processes/${containerRequest.uuid}`);
+                    // Select 'stdout' log filter
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "stdout").click();
+                    cy.get("[data-cy=process-logs] span > p")
+                        .should('have.length', logLinesQty)
+                        .each($p => {
+                            expect($p.text().length).to.be.greaterThan(499);
+
+                            // This looks like an ugly hack, but I was not able
+                            // to get [client|scroll]Width attributes through
+                            // the usual Cypress methods.
+                            const parentClientWidth = $p[0].parentElement.clientWidth;
+                            const parentScrollWidth = $p[0].parentElement.scrollWidth
+                            // Scrollbar should not be visible
+                            expect(parentClientWidth).to.be.eq(parentScrollWidth);
+                        });
+                });
+            });
+        });
+    });
+
+    describe("I/O panel", function () {
+        const testInputs = [
+            {
+                definition: {
+                    id: "#main/input_file",
+                    label: "Label Description",
+                    type: "File",
+                },
+                input: {
+                    input_file: {
+                        basename: "input1.tar",
+                        class: "File",
+                        location: "keep:00000000000000000000000000000000+01/input1.tar",
+                        secondaryFiles: [
+                            {
+                                basename: "input1-2.txt",
+                                class: "File",
+                                location: "keep:00000000000000000000000000000000+01/input1-2.txt",
+                            },
+                            {
+                                basename: "input1-3.txt",
+                                class: "File",
+                                location: "keep:00000000000000000000000000000000+01/input1-3.txt",
+                            },
+                            {
+                                basename: "input1-4.txt",
+                                class: "File",
+                                location: "keep:00000000000000000000000000000000+01/input1-4.txt",
+                            },
+                        ],
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_dir",
+                    doc: "Doc Description",
+                    type: "Directory",
+                },
+                input: {
+                    input_dir: {
+                        basename: "11111111111111111111111111111111+01",
+                        class: "Directory",
+                        location: "keep:11111111111111111111111111111111+01",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_bool",
+                    doc: ["Doc desc 1", "Doc desc 2"],
+                    type: "boolean",
+                },
+                input: {
+                    input_bool: true,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_int",
+                    type: "int",
+                },
+                input: {
+                    input_int: 1,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_long",
+                    type: "long",
+                },
+                input: {
+                    input_long: 1,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_float",
+                    type: "float",
+                },
+                input: {
+                    input_float: 1.5,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_double",
+                    type: "double",
+                },
+                input: {
+                    input_double: 1.3,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_string",
+                    type: "string",
+                },
+                input: {
+                    input_string: "Hello World",
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_file_array",
+                    type: {
+                        items: "File",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_file_array: [
+                        {
+                            basename: "input2.tar",
+                            class: "File",
+                            location: "keep:00000000000000000000000000000000+02/input2.tar",
+                        },
+                        {
+                            basename: "input3.tar",
+                            class: "File",
+                            location: "keep:00000000000000000000000000000000+03/input3.tar",
+                            secondaryFiles: [
+                                {
+                                    basename: "input3-2.txt",
+                                    class: "File",
+                                    location: "keep:00000000000000000000000000000000+03/input3-2.txt",
+                                },
+                            ],
+                        },
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_dir_array",
+                    type: {
+                        items: "Directory",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_dir_array: [
+                        {
+                            basename: "11111111111111111111111111111111+02",
+                            class: "Directory",
+                            location: "keep:11111111111111111111111111111111+02",
+                        },
+                        {
+                            basename: "11111111111111111111111111111111+03",
+                            class: "Directory",
+                            location: "keep:11111111111111111111111111111111+03",
+                        },
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_int_array",
+                    type: {
+                        items: "int",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_int_array: [
+                        1,
+                        3,
+                        5,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_long_array",
+                    type: {
+                        items: "long",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_long_array: [
+                        10,
+                        20,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_float_array",
+                    type: {
+                        items: "float",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_float_array: [
+                        10.2,
+                        10.4,
+                        10.6,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_double_array",
+                    type: {
+                        items: "double",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_double_array: [
+                        20.1,
+                        20.2,
+                        20.3,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_string_array",
+                    type: {
+                        items: "string",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_string_array: [
+                        "Hello",
+                        "World",
+                        "!",
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_bool_include",
+                    type: "boolean",
+                },
+                input: {
+                    input_bool_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_int_include",
+                    type: "int",
+                },
+                input: {
+                    input_int_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_float_include",
+                    type: "float",
+                },
+                input: {
+                    input_float_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_string_include",
+                    type: "string",
+                },
+                input: {
+                    input_string_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_file_include",
+                    type: "File",
+                },
+                input: {
+                    input_file_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_directory_include",
+                    type: "Directory",
+                },
+                input: {
+                    input_directory_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_file_url",
+                    type: "File",
+                },
+                input: {
+                    input_file_url: {
+                        basename: "index.html",
+                        class: "File",
+                        location: "http://example.com/index.html",
+                    },
+                },
+            },
+        ];
+
+        const testOutputs = [
+            {
+                definition: {
+                    id: "#main/output_file",
+                    label: "Label Description",
+                    type: "File",
+                },
+                output: {
+                    output_file: {
+                        basename: "cat.png",
+                        class: "File",
+                        location: "cat.png",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_file_with_secondary",
+                    doc: "Doc Description",
+                    type: "File",
+                },
+                output: {
+                    output_file_with_secondary: {
+                        basename: "main.dat",
+                        class: "File",
+                        location: "main.dat",
+                        secondaryFiles: [
+                            {
+                                basename: "secondary.dat",
+                                class: "File",
+                                location: "secondary.dat",
+                            },
+                            {
+                                basename: "secondary2.dat",
+                                class: "File",
+                                location: "secondary2.dat",
+                            },
+                        ],
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_dir",
+                    doc: ["Doc desc 1", "Doc desc 2"],
+                    type: "Directory",
+                },
+                output: {
+                    output_dir: {
+                        basename: "outdir1",
+                        class: "Directory",
+                        location: "outdir1",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_bool",
+                    type: "boolean",
+                },
+                output: {
+                    output_bool: true,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_int",
+                    type: "int",
+                },
+                output: {
+                    output_int: 1,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_long",
+                    type: "long",
+                },
+                output: {
+                    output_long: 1,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_float",
+                    type: "float",
+                },
+                output: {
+                    output_float: 100.5,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_double",
+                    type: "double",
+                },
+                output: {
+                    output_double: 100.3,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_string",
+                    type: "string",
+                },
+                output: {
+                    output_string: "Hello output",
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_file_array",
+                    type: {
+                        items: "File",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_file_array: [
+                        {
+                            basename: "output2.tar",
+                            class: "File",
+                            location: "output2.tar",
+                        },
+                        {
+                            basename: "output3.tar",
+                            class: "File",
+                            location: "output3.tar",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_dir_array",
+                    type: {
+                        items: "Directory",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_dir_array: [
+                        {
+                            basename: "outdir2",
+                            class: "Directory",
+                            location: "outdir2",
+                        },
+                        {
+                            basename: "outdir3",
+                            class: "Directory",
+                            location: "outdir3",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_int_array",
+                    type: {
+                        items: "int",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_int_array: [10, 11, 12],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_long_array",
+                    type: {
+                        items: "long",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_long_array: [51, 52],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_float_array",
+                    type: {
+                        items: "float",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_float_array: [100.2, 100.4, 100.6],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_double_array",
+                    type: {
+                        items: "double",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_double_array: [100.1, 100.2, 100.3],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_string_array",
+                    type: {
+                        items: "string",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_string_array: ["Hello", "Output", "!"],
+                },
+            },
+        ];
+
+        const verifyIOParameter = (name, label, doc, val, collection, multipleRows) => {
+            cy.get("table tr")
+                .contains(name)
+                .parents("tr")
+                .within($mainRow => {
+                    cy.get($mainRow).scrollIntoView();
+                    label && cy.contains(label);
+
+                    if (multipleRows) {
+                        cy.get($mainRow).nextUntil('[data-cy="process-io-param"]').as("secondaryRows");
+                        if (val) {
+                            if (Array.isArray(val)) {
+                                val.forEach(v => cy.get("@secondaryRows").contains(v));
+                            } else {
+                                cy.get("@secondaryRows").contains(val);
+                            }
+                        }
+                        if (collection) {
+                            cy.get("@secondaryRows").contains(collection);
+                        }
+                    } else {
+                        if (val) {
+                            if (Array.isArray(val)) {
+                                val.forEach(v => cy.contains(v));
+                            } else {
+                                cy.contains(val);
+                            }
+                        }
+                        if (collection) {
+                            cy.contains(collection);
+                        }
+                    }
+                });
+        };
+
+        const verifyIOParameterImage = (name, url) => {
+            cy.get("table tr")
+                .contains(name)
+                .parents("tr")
+                .within(() => {
+                    cy.get('[alt="Inline Preview"]')
+                        .should("be.visible")
+                        .and($img => {
+                            expect($img[0].naturalWidth).to.be.greaterThan(0);
+                            expect($img[0].src).contains(url);
+                        });
+                });
+        };
+
+        it("displays IO parameters with keep links and previews", function () {
+            // Create output collection for real files
+            cy.createCollection(adminUser.token, {
+                name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+                owner_uuid: activeUser.user.uuid,
+            }).then(testOutputCollection => {
+                cy.loginAs(activeUser);
+
+                cy.goToPath(`/collections/${testOutputCollection.uuid}`);
+
+                cy.get("[data-cy=upload-button]").click();
+
+                cy.fixture("files/cat.png", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "cat.png");
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.waitForDom().get("[data-cy=form-submit-btn]").should("not.exist");
+                    // Confirm final collection state.
+                    cy.get("[data-cy=collection-files-panel]").contains("cat.png").should("exist");
+                });
+
+                cy.getCollection(activeUser.token, testOutputCollection.uuid).as("testOutputCollection");
+            });
+
+            // Get updated collection pdh
+            cy.getAll("@testOutputCollection").then(([testOutputCollection]) => {
+                // Add output uuid and inputs to container request
+                cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                    req.on('response', res => {
+                        res.body.output_uuid = testOutputCollection.uuid;
+                        res.body.mounts["/var/lib/cwl/cwl.input.json"] = {
+                            content: testInputs.map(param => param.input).reduce((acc, val) => Object.assign(acc, val), {}),
+                        };
+                        res.body.mounts["/var/lib/cwl/workflow.json"] = {
+                            content: {
+                                $graph: [
+                                    {
+                                        id: "#main",
+                                        inputs: testInputs.map(input => input.definition),
+                                        outputs: testOutputs.map(output => output.definition),
+                                    },
+                                ],
+                            },
+                        };
+                    });
+                });
+
+                // Stub fake output collection
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/collections/${testOutputCollection.uuid}*` },
+                    {
+                        statusCode: 200,
+                        body: {
+                            uuid: testOutputCollection.uuid,
+                            portable_data_hash: testOutputCollection.portable_data_hash,
+                        },
+                    }
+                );
+
+                // Stub fake output json
+                cy.intercept(
+                    { method: "GET", url: "**/c%3Dzzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json" },
+                    {
+                        statusCode: 200,
+                        body: testOutputs.map(param => param.output).reduce((acc, val) => Object.assign(acc, val), {}),
+                    }
+                );
+
+                // Stub webdav response, points to output json
+                cy.intercept(
+                    { method: "PROPFIND", url: "*" },
+                    {
+                        fixture: "webdav-propfind-outputs.xml",
+                    }
+                );
+            });
+
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").as(
+                "containerRequest"
+            );
+
+            cy.getAll("@containerRequest", "@testOutputCollection").then(function ([containerRequest, testOutputCollection]) {
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Input Parameters")
+                    .parents("[data-cy=process-io-card]")
+                    .within((ctx) => {
+                        cy.get(ctx).scrollIntoView();
+                        verifyIOParameter("input_file", null, "Label Description", "input1.tar", "00000000000000000000000000000000+01");
+                        verifyIOParameter("input_file", null, "Label Description", "input1-2.txt", undefined, true);
+                        verifyIOParameter("input_file", null, "Label Description", "input1-3.txt", undefined, true);
+                        verifyIOParameter("input_file", null, "Label Description", "input1-4.txt", undefined, true);
+                        verifyIOParameter("input_dir", null, "Doc Description", "/", "11111111111111111111111111111111+01");
+                        verifyIOParameter("input_bool", null, "Doc desc 1, Doc desc 2", "true");
+                        verifyIOParameter("input_int", null, null, "1");
+                        verifyIOParameter("input_long", null, null, "1");
+                        verifyIOParameter("input_float", null, null, "1.5");
+                        verifyIOParameter("input_double", null, null, "1.3");
+                        verifyIOParameter("input_string", null, null, "Hello World");
+                        verifyIOParameter("input_file_array", null, null, "input2.tar", "00000000000000000000000000000000+02");
+                        verifyIOParameter("input_file_array", null, null, "input3.tar", undefined, true);
+                        verifyIOParameter("input_file_array", null, null, "input3-2.txt", undefined, true);
+                        verifyIOParameter("input_file_array", null, null, "Cannot display value", undefined, true);
+                        verifyIOParameter("input_dir_array", null, null, "/", "11111111111111111111111111111111+02");
+                        verifyIOParameter("input_dir_array", null, null, "/", "11111111111111111111111111111111+03", true);
+                        verifyIOParameter("input_dir_array", null, null, "Cannot display value", undefined, true);
+                        verifyIOParameter("input_int_array", null, null, ["1", "3", "5", "Cannot display value"]);
+                        verifyIOParameter("input_long_array", null, null, ["10", "20", "Cannot display value"]);
+                        verifyIOParameter("input_float_array", null, null, ["10.2", "10.4", "10.6", "Cannot display value"]);
+                        verifyIOParameter("input_double_array", null, null, ["20.1", "20.2", "20.3", "Cannot display value"]);
+                        verifyIOParameter("input_string_array", null, null, ["Hello", "World", "!", "Cannot display value"]);
+                        verifyIOParameter("input_bool_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_int_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_float_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_string_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_file_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_directory_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_file_url", null, null, "http://example.com/index.html");
+                    });
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Output Parameters")
+                    .parents("[data-cy=process-io-card]")
+                    .within(ctx => {
+                        cy.get(ctx).scrollIntoView();
+                        const outPdh = testOutputCollection.portable_data_hash;
+
+                        verifyIOParameter("output_file", null, "Label Description", "cat.png", `${outPdh}`);
+                        // Disabled until image preview returns
+                        // verifyIOParameterImage("output_file", `/c=${outPdh}/cat.png`);
+                        verifyIOParameter("output_file_with_secondary", null, "Doc Description", "main.dat", `${outPdh}`);
+                        verifyIOParameter("output_file_with_secondary", null, "Doc Description", "secondary.dat", undefined, true);
+                        verifyIOParameter("output_file_with_secondary", null, "Doc Description", "secondary2.dat", undefined, true);
+                        verifyIOParameter("output_dir", null, "Doc desc 1, Doc desc 2", "outdir1", `${outPdh}`);
+                        verifyIOParameter("output_bool", null, null, "true");
+                        verifyIOParameter("output_int", null, null, "1");
+                        verifyIOParameter("output_long", null, null, "1");
+                        verifyIOParameter("output_float", null, null, "100.5");
+                        verifyIOParameter("output_double", null, null, "100.3");
+                        verifyIOParameter("output_string", null, null, "Hello output");
+                        verifyIOParameter("output_file_array", null, null, "output2.tar", `${outPdh}`);
+                        verifyIOParameter("output_file_array", null, null, "output3.tar", undefined, true);
+                        verifyIOParameter("output_dir_array", null, null, "outdir2", `${outPdh}`);
+                        verifyIOParameter("output_dir_array", null, null, "outdir3", undefined, true);
+                        verifyIOParameter("output_int_array", null, null, ["10", "11", "12"]);
+                        verifyIOParameter("output_long_array", null, null, ["51", "52"]);
+                        verifyIOParameter("output_float_array", null, null, ["100.2", "100.4", "100.6"]);
+                        verifyIOParameter("output_double_array", null, null, ["100.1", "100.2", "100.3"]);
+                        verifyIOParameter("output_string_array", null, null, ["Hello", "Output", "!"]);
+                    });
+            });
+        });
+
+        it("displays IO parameters with no value", function () {
+            const fakeOutputUUID = "zzzzz-4zz18-abcdefghijklmno";
+            const fakeOutputPDH = "11111111111111111111111111111111+99/";
+
+            cy.loginAs(activeUser);
+
+            // Add output uuid and inputs to container request
+            cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                req.on('response', res => {
+                    res.body.output_uuid = fakeOutputUUID;
+                    res.body.mounts["/var/lib/cwl/cwl.input.json"] = {
+                        content: {},
+                    };
+                    res.body.mounts["/var/lib/cwl/workflow.json"] = {
+                        content: {
+                            $graph: [
+                                {
+                                    id: "#main",
+                                    inputs: testInputs.map(input => input.definition),
+                                    outputs: testOutputs.map(output => output.definition),
+                                },
+                            ],
+                        },
+                    };
+                });
+            });
+
+            // Stub fake output collection
+            cy.intercept(
+                { method: "GET", url: `**/arvados/v1/collections/${fakeOutputUUID}*` },
+                {
+                    statusCode: 200,
+                    body: {
+                        uuid: fakeOutputUUID,
+                        portable_data_hash: fakeOutputPDH,
+                    },
+                }
+            );
+
+            // Stub fake output json
+            cy.intercept(
+                { method: "GET", url: `**/c%3D${fakeOutputUUID}/cwl.output.json` },
+                {
+                    statusCode: 200,
+                    body: {},
+                }
+            );
+
+            cy.readFile("cypress/fixtures/webdav-propfind-outputs.xml").then(data => {
+                // Stub webdav response, points to output json
+                cy.intercept(
+                    { method: "PROPFIND", url: "*" },
+                    {
+                        statusCode: 200,
+                        body: data.replace(/zzzzz-4zz18-zzzzzzzzzzzzzzz/g, fakeOutputUUID),
+                    }
+                );
+            });
+
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").as(
+                "containerRequest"
+            );
+
+            cy.getAll("@containerRequest").then(function ([containerRequest]) {
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.waitForDom();
+
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Input Parameters")
+                    .parents("[data-cy=process-io-card]")
+                    .within((ctx) => {
+                        cy.get(ctx).scrollIntoView();
+                        cy.wait(2000);
+                        cy.waitForDom();
+
+                        testInputs.map((input) => {
+                            verifyIOParameter(input.definition.id.split('/').slice(-1)[0], null, null, "No value");
+                        });
+                    });
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Output Parameters")
+                    .parents("[data-cy=process-io-card]")
+                    .within((ctx) => {
+                        cy.get(ctx).scrollIntoView();
+
+                        testOutputs.map((output) => {
+                            verifyIOParameter(output.definition.id.split('/').slice(-1)[0], null, null, "No value");
+                        });
+                    });
+            });
+        });
+    });
+});
diff --git a/services/workbench2/cypress/e2e/project.cy.js b/services/workbench2/cypress/e2e/project.cy.js
new file mode 100644 (file)
index 0000000..4321574
--- /dev/null
@@ -0,0 +1,696 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe("Project tests", function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser("user", "Active", "User", false, true)
+            .as("activeUser")
+            .then(function () {
+                activeUser = this.activeUser;
+            });
+    });
+
+    it("creates a new project with multiple properties", function () {
+        const projName = `Test project (${Math.floor(999999 * Math.random())})`;
+        cy.loginAs(activeUser);
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-project]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(projName);
+                });
+            });
+        // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
+        cy.get("[data-cy=form-dialog]").should("not.contain", "Color: Magenta");
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").type("Color").blur();
+            });
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Magenta").blur();
+            });
+            cy.get("[data-cy=property-add-btn]").click();
+
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Pink").blur();
+            });
+            cy.get("[data-cy=property-add-btn]").click();
+
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Yellow").blur();
+            });
+            cy.get("[data-cy=property-add-btn]").click();
+        });
+        // Confirm proper vocabulary labels are displayed on the UI.
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Pink");
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Yellow");
+
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").focus();
+            });
+            cy.get("[data-cy=property-field-key]").should("not.contain", "Color");
+        });
+
+        // Create project and confirm the properties' real values.
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.get("[data-cy=breadcrumb-last]").should("contain", projName);
+        cy.doRequest("GET", "/arvados/v1/groups", null, {
+            filters: `[["name", "=", "${projName}"], ["group_class", "=", "project"]]`,
+        })
+            .its("body.items")
+            .as("projects")
+            .then(function () {
+                expect(this.projects).to.have.lengthOf(1);
+                expect(this.projects[0].properties).to.deep.equal(
+                    // Pink is not in the test vocab
+                    { IDTAGCOLORS: ["IDVALCOLORS3", "Pink", "IDVALCOLORS1"] }
+                );
+            });
+
+        // Open project edit via breadcrumbs
+        cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick();
+        cy.get("[data-cy=context-menu]").contains("Edit").click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=resource-properties-list]").within(() => {
+                cy.get("div[role=button]").contains("Color: Magenta");
+                cy.get("div[role=button]").contains("Color: Pink");
+                cy.get("div[role=button]").contains("Color: Yellow");
+            });
+        });
+        // Add another property
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").type("Animal").blur();
+            });
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Dog").blur();
+            });
+            cy.get("[data-cy=property-add-btn]").click();
+        });
+        cy.get("[data-cy=form-submit-btn]").click();
+        // Reopen edit via breadcrumbs and verify properties
+        cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick();
+        cy.get("[data-cy=context-menu]").contains("Edit").click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=resource-properties-list]").within(() => {
+                cy.get("div[role=button]").contains("Color: Magenta");
+                cy.get("div[role=button]").contains("Color: Pink");
+                cy.get("div[role=button]").contains("Color: Yellow");
+                cy.get("div[role=button]").contains("Animal: Dog");
+            });
+        });
+    });
+
+    it("creates a project without and with description", function () {
+        const projName = `Test project (${Math.floor(999999 * Math.random())})`;
+        cy.loginAs(activeUser);
+
+        // Create project
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-project]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(projName);
+                });
+            });
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+
+        const editProjectDescription = (name, type) => {
+            cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
+            cy.get("[data-cy=project-panel] tbody tr").contains(name).rightclick({ force: true });
+            cy.get("[data-cy=context-menu]").contains("Edit").click();
+            cy.get("[data-cy=form-dialog]").within(() => {
+                cy.get("div[contenteditable=true]").click().type(type);
+                cy.get("[data-cy=form-submit-btn]").click();
+            });
+        };
+
+        const verifyProjectDescription = (name, description) => {
+            cy.doRequest("GET", "/arvados/v1/groups", null, {
+                filters: `[["name", "=", "${name}"], ["group_class", "=", "project"]]`,
+            })
+                .its("body.items")
+                .as("projects")
+                .then(function () {
+                    expect(this.projects).to.have.lengthOf(1);
+                    expect(this.projects[0].description).to.equal(description);
+                });
+        };
+
+        // Edit description
+        editProjectDescription(projName, "Test description");
+
+        // Check description is set
+        verifyProjectDescription(projName, "<p>Test description</p>");
+
+        // Clear description
+        editProjectDescription(projName, "{selectall}{backspace}");
+
+        // Check description is null
+        verifyProjectDescription(projName, null);
+
+        // Set description to contain whitespace
+        editProjectDescription(projName, "{selectall}{backspace}    x");
+        editProjectDescription(projName, "{backspace}");
+
+        // Check description is null
+        verifyProjectDescription(projName, null);
+    });
+
+    it("creates a project from the context menu in the correct subfolder", function () {
+        const parentProjName = `Test project (${Math.floor(999999 * Math.random())})`;
+        const childProjName = `Test project (${Math.floor(999999 * Math.random())})`;
+        cy.loginAs(activeUser);
+
+        // Create project
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-project]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(parentProjName);
+                });
+            });
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.go('back')
+
+        // Create subproject from context menu
+        cy.get("[data-cy=project-panel] tbody tr").contains(parentProjName).rightclick({ force: true });
+        cy.get("[data-cy=context-menu]").contains("New project").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(childProjName);
+                });
+            });
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+
+        // open details panel and check 'owner' field
+        cy.get("[data-cy=additional-info-icon]").click();
+        cy.waitForDom();
+        cy.get("[data-cy=details-panel-owner]").contains(parentProjName).should("be.visible")
+        cy.get("[data-cy=additional-info-icon]").click();
+    });
+
+    it('shows the appropriate buttons in the multiselect toolbar', () => {
+
+        const msButtonTooltips = [
+            'View details',
+            'Open in new tab',
+            'Copy link to clipboard',
+            'Open with 3rd party client',
+            'API Details',
+            'Share',
+            'New project',
+            'Edit project',
+            'Move to',
+            'Move to trash',
+            'Freeze project',
+            'Add to favorites',
+        ];
+
+        cy.loginAs(activeUser);
+        const projName = `Test project (${Math.floor(999999 * Math.random())})`;
+        cy.get('[data-cy=side-panel-button]').click();
+        cy.get('[data-cy=side-panel-new-project]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Project')
+            .within(() => {
+                cy.get('[data-cy=name-field]').within(() => {
+                    cy.get('input').type(projName);
+                });
+            })
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.waitForDom()
+        cy.go('back')
+
+        cy.get('[data-cy=data-table-row]').contains(projName).should('exist').parent().parent().parent().click()
+        cy.waitForDom()
+        cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length)
+        for (let i = 0; i < msButtonTooltips.length; i++) {
+            cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover');
+            cy.get('body').contains(msButtonTooltips[i]).should('exist')
+            cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout');
+        }
+    })
+
+    it("creates new project on home project and then a subproject inside it", function () {
+        const createProject = function (name, parentName) {
+            cy.get("[data-cy=side-panel-button]").click();
+            cy.get("[data-cy=side-panel-new-project]").click();
+            cy.get("[data-cy=form-dialog]")
+                .should("contain", "New Project")
+                .within(() => {
+                    cy.get("[data-cy=parent-field]").within(() => {
+                        cy.get("input")
+                            .invoke("val")
+                            .then(val => {
+                                expect(val).to.include(parentName);
+                            });
+                    });
+                    cy.get("[data-cy=name-field]").within(() => {
+                        cy.get("input").type(name);
+                    });
+                });
+            cy.get("[data-cy=form-submit-btn]").click();
+        };
+
+        cy.loginAs(activeUser);
+        cy.goToPath(`/projects/${activeUser.user.uuid}`);
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("not.exist");
+        // Create new project
+        const projName = `Test project (${Math.floor(999999 * Math.random())})`;
+        createProject(projName, "Home project");
+        // Confirm that the user was taken to the newly created thing
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("contain", projName);
+        // Create a subproject
+        const subProjName = `Test project (${Math.floor(999999 * Math.random())})`;
+        createProject(subProjName, projName);
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("contain", subProjName);
+    });
+
+    it("attempts to use a preexisting name creating a project", function () {
+        const name = `Test project ${Math.floor(Math.random() * 999999)}`;
+        cy.createGroup(activeUser.token, {
+            name: name,
+            group_class: "project",
+        });
+        cy.loginAs(activeUser);
+        cy.goToPath(`/projects/${activeUser.user.uuid}`);
+
+        // Attempt to create new collection with a duplicate name
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-project]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
+                });
+                cy.get("[data-cy=form-submit-btn]").click();
+            });
+        // Error message should display, allowing editing the name
+        cy.get("[data-cy=form-dialog]")
+            .should("exist")
+            .and("contain", "Project with the same name already exists")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(" renamed");
+                });
+                cy.get("[data-cy=form-submit-btn]").click();
+            });
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+    });
+
+    it("navigates to the parent project after trashing the one being displayed", function () {
+        cy.createGroup(activeUser.token, {
+            name: `Test root project ${Math.floor(Math.random() * 999999)}`,
+            group_class: "project",
+        })
+            .as("testRootProject")
+            .then(function () {
+                cy.createGroup(activeUser.token, {
+                    name: `Test subproject ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
+                    owner_uuid: this.testRootProject.uuid,
+                }).as("testSubProject");
+            });
+        cy.getAll("@testRootProject", "@testSubProject").then(function ([testRootProject, testSubProject]) {
+            cy.loginAs(activeUser);
+
+            // Go to subproject and trash it.
+            cy.goToPath(`/projects/${testSubProject.uuid}`);
+            cy.get("[data-cy=side-panel-tree]").should("contain", testSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testSubProject.name).rightclick();
+            cy.get("[data-cy=context-menu]").contains("Move to trash").click();
+
+            // Confirm that the parent project should be displayed.
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name);
+            cy.url().should("contain", `/projects/${testRootProject.uuid}`);
+            cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name);
+
+            // Checks for bugfix #17637.
+            cy.get("[data-cy=not-found-content]").should("not.exist");
+            cy.get("[data-cy=not-found-page]").should("not.exist");
+        });
+    });
+
+    it("resets the search box only when navigating out of the current project", function () {
+        const fooProjectNameA = `Test foo project ${Math.floor(Math.random() * 999999)}`;
+        const fooProjectNameB = `Test foo project ${Math.floor(Math.random() * 999999)}`;
+        const barProjectNameA = `Test bar project ${Math.floor(Math.random() * 999999)}`;
+
+        [fooProjectNameA, fooProjectNameB, barProjectNameA].forEach(projName => {
+            cy.createGroup(activeUser.token, {
+                name: projName,
+                group_class: "project",
+            });
+        });
+
+        cy.loginAs(activeUser);
+        cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("contain", barProjectNameA);
+
+        cy.get("[data-cy=search-input]").type("foo");
+        cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("not.contain", barProjectNameA);
+
+        // Click on the table row to select it, search should remain the same.
+        cy.get(`p:contains(${fooProjectNameA})`).parent().parent().parent().parent().click();
+        cy.get("[data-cy=search-input] input").should("have.value", "foo");
+
+        // Click to navigate to the project, search should be reset
+        cy.get(`p:contains(${fooProjectNameA})`).click();
+        cy.get("[data-cy=search-input] input").should("not.have.value", "foo");
+    });
+
+    it("navigates to the root project after trashing the parent of the one being displayed", function () {
+        cy.createGroup(activeUser.token, {
+            name: `Test root project ${Math.floor(Math.random() * 999999)}`,
+            group_class: "project",
+        })
+            .as("testRootProject")
+            .then(function () {
+                cy.createGroup(activeUser.token, {
+                    name: `Test subproject ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
+                    owner_uuid: this.testRootProject.uuid,
+                })
+                    .as("testSubProject")
+                    .then(function () {
+                        cy.createGroup(activeUser.token, {
+                            name: `Test sub subproject ${Math.floor(Math.random() * 999999)}`,
+                            group_class: "project",
+                            owner_uuid: this.testSubProject.uuid,
+                        }).as("testSubSubProject");
+                    });
+            });
+        cy.getAll("@testRootProject", "@testSubProject", "@testSubSubProject").then(function ([testRootProject, testSubProject, testSubSubProject]) {
+            cy.loginAs(activeUser);
+
+            // Go to innermost project and trash its parent.
+            cy.goToPath(`/projects/${testSubSubProject.uuid}`);
+            cy.get("[data-cy=side-panel-tree]").should("contain", testSubSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testSubSubProject.name);
+            cy.get("[data-cy=side-panel-tree]").contains(testSubProject.name).rightclick();
+            cy.get("[data-cy=context-menu]").contains("Move to trash").click();
+
+            // Confirm that the trashed project's parent should be displayed.
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name);
+            cy.url().should("contain", `/projects/${testRootProject.uuid}`);
+            cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name);
+            cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubSubProject.name);
+
+            // Checks for bugfix #17637.
+            cy.get("[data-cy=not-found-content]").should("not.exist");
+            cy.get("[data-cy=not-found-page]").should("not.exist");
+        });
+    });
+
+    it("shows details panel when clicking on the info icon", () => {
+        cy.createGroup(activeUser.token, {
+            name: `Test root project ${Math.floor(Math.random() * 999999)}`,
+            group_class: "project",
+        })
+            .as("testRootProject")
+            .then(function (testRootProject) {
+                cy.loginAs(activeUser);
+
+                cy.get("[data-cy=side-panel-tree]").contains(testRootProject.name).click();
+
+                cy.get("[data-cy=additional-info-icon]").click();
+
+                cy.contains(testRootProject.uuid).should("exist");
+            });
+    });
+
+    it("clears search input when changing project", () => {
+        cy.createGroup(activeUser.token, {
+            name: `Test root project ${Math.floor(Math.random() * 999999)}`,
+            group_class: "project",
+        })
+            .as("testProject1")
+            .then(testProject1 => {
+                cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, "can_write");
+            });
+
+        cy.getAll("@testProject1").then(function ([testProject1]) {
+            cy.loginAs(activeUser);
+
+            cy.get("[data-cy=side-panel-tree]").contains(testProject1.name).click();
+
+            cy.get("[data-cy=search-input] input").type("test123");
+
+            cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
+
+            cy.get("[data-cy=search-input] input").should("not.have.value", "test123");
+        });
+    });
+
+    it("opens advanced popup for project with username", () => {
+        const projectName = `Test project ${Math.floor(Math.random() * 999999)}`;
+
+        cy.createGroup(adminUser.token, {
+            name: projectName,
+            group_class: "project",
+        }).as("mainProject");
+
+        cy.getAll("@mainProject").then(function ([mainProject]) {
+            cy.loginAs(adminUser);
+
+            cy.get("[data-cy=side-panel-tree]").contains("Groups").click();
+
+            cy.get("[data-cy=uuid]")
+                .eq(0)
+                .invoke("text")
+                .then(uuid => {
+                    cy.createLink(adminUser.token, {
+                        name: "can_write",
+                        link_class: "permission",
+                        head_uuid: mainProject.uuid,
+                        tail_uuid: uuid,
+                    });
+
+                    cy.createLink(adminUser.token, {
+                        name: "can_write",
+                        link_class: "permission",
+                        head_uuid: mainProject.uuid,
+                        tail_uuid: activeUser.user.uuid,
+                    });
+
+                    cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
+
+                    cy.get("main").contains(projectName).rightclick();
+
+                    cy.get("[data-cy=context-menu]").contains("API Details").click();
+
+                    cy.get("[role=tablist]").contains("METADATA").click();
+
+                    cy.get("td").contains(uuid).should("exist");
+
+                    cy.get("td").contains(activeUser.user.uuid).should("exist");
+                });
+        });
+    });
+
+    describe("Frozen projects", () => {
+        beforeEach(() => {
+            cy.createGroup(activeUser.token, {
+                name: `Main project ${Math.floor(Math.random() * 999999)}`,
+                group_class: "project",
+            }).as("mainProject");
+
+            cy.createGroup(adminUser.token, {
+                name: `Admin project ${Math.floor(Math.random() * 999999)}`,
+                group_class: "project",
+            })
+                .as("adminProject")
+                .then(mainProject => {
+                    cy.shareWith(adminUser.token, activeUser.user.uuid, mainProject.uuid, "can_write");
+                });
+
+            cy.get("@mainProject").then(mainProject => {
+                cy.createGroup(adminUser.token, {
+                    name: `Sub project ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
+                    owner_uuid: mainProject.uuid,
+                }).as("subProject");
+
+                cy.createCollection(adminUser.token, {
+                    name: `Main collection ${Math.floor(Math.random() * 999999)}`,
+                    owner_uuid: mainProject.uuid,
+                    manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+                }).as("mainCollection");
+            });
+        });
+
+        it("should be able to freeze own project", () => {
+            cy.getAll("@mainProject").then(([mainProject]) => {
+                cy.loginAs(activeUser);
+
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Freeze").click();
+
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist");
+            });
+        });
+
+        it("should not be able to modify items within the frozen project", () => {
+            cy.getAll("@mainProject", "@mainCollection").then(([mainProject, mainCollection]) => {
+                cy.loginAs(activeUser);
+
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Freeze").click();
+
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).click();
+
+                cy.get("[data-cy=project-panel]").contains(mainCollection.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Move to trash").should("not.exist");
+            });
+        });
+
+        it("should be able to freeze not owned project", () => {
+            cy.getAll("@adminProject").then(([adminProject]) => {
+                cy.loginAs(activeUser);
+
+                cy.get("[data-cy=side-panel-tree]").contains("Shared with me").click();
+
+                cy.get("main").contains(adminProject.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist");
+            });
+        });
+
+        it("should be able to unfreeze project if user is an admin", () => {
+            cy.getAll("@adminProject").then(([adminProject]) => {
+                cy.loginAs(adminUser);
+
+                cy.get("main").contains(adminProject.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Freeze").click();
+
+                cy.wait(1000);
+
+                cy.get("main").contains(adminProject.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Unfreeze").click();
+
+                cy.get("main").contains(adminProject.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Freeze").should("exist");
+            });
+        });
+    });
+
+    // The following test is enabled on Electron only, as Chromium and Firefox
+    // require permissions to access the clipboard.
+    it("copies project URL to clipboard", { browser: 'electron' }, () => {
+        const projectName = `Test project (${Math.floor(999999 * Math.random())})`;
+
+        cy.loginAs(activeUser);
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-project]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(projectName);
+                });
+                cy.get("[data-cy=form-submit-btn]").click();
+            });
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=snackbar]").contains("created");
+        cy.get("[data-cy=snackbar]").should("not.exist");
+        cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
+        cy.waitForDom();
+        cy.get("[data-cy=project-panel]").contains(projectName).should("be.visible").rightclick();
+        cy.get("[data-cy=context-menu]").contains("Copy link to clipboard").click();
+        cy.window().then(win =>
+            win.navigator.clipboard.readText().then(text => {
+                expect(text).to.match(/https\:\/\/127\.0\.0\.1\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/);
+            })
+        );
+    });
+
+    it("sorts displayed items correctly", () => {
+        cy.loginAs(activeUser);
+
+        cy.get('[data-cy=project-panel] button[title="Select columns"]').click();
+        cy.get("div[role=presentation] ul > div[role=button]").contains("Date Created").click();
+        cy.get("div[role=presentation] ul > div[role=button]").contains("Trash at").click();
+        cy.get("div[role=presentation] ul > div[role=button]").contains("Delete at").click();
+        cy.get("div[role=presentation] > div[aria-hidden=true]").click();
+
+        cy.intercept({ method: "GET", url: "**/arvados/v1/groups/*/contents*" }).as("filteredQuery");
+        [
+            {
+                name: "Name",
+                asc: "collections.name asc,container_requests.name asc,groups.name asc,container_requests.created_at desc",
+                desc: "collections.name desc,container_requests.name desc,groups.name desc,container_requests.created_at desc",
+            },
+            {
+                name: "Last Modified",
+                asc: "collections.modified_at asc,container_requests.modified_at asc,groups.modified_at asc,container_requests.created_at desc",
+                desc: "collections.modified_at desc,container_requests.modified_at desc,groups.modified_at desc,container_requests.created_at desc",
+            },
+            {
+                name: "Date Created",
+                asc: "collections.created_at asc,container_requests.created_at asc,groups.created_at asc,container_requests.created_at desc",
+                desc: "collections.created_at desc,container_requests.created_at desc,groups.created_at desc,container_requests.created_at desc",
+            },
+            {
+                name: "Trash at",
+                asc: "collections.trash_at asc,container_requests.trash_at asc,groups.trash_at asc,container_requests.created_at desc",
+                desc: "collections.trash_at desc,container_requests.trash_at desc,groups.trash_at desc,container_requests.created_at desc",
+            },
+            {
+                name: "Delete at",
+                asc: "collections.delete_at asc,container_requests.delete_at asc,groups.delete_at asc,container_requests.created_at desc",
+                desc: "collections.delete_at desc,container_requests.delete_at desc,groups.delete_at desc,container_requests.created_at desc",
+            },
+        ].forEach(test => {
+            cy.get("[data-cy=project-panel] table thead th").contains(test.name).click();
+            cy.wait("@filteredQuery").then(interception => {
+                const searchParams = new URLSearchParams(new URL(interception.request.url).search);
+                expect(searchParams.get("order")).to.eq(test.asc);
+            });
+            cy.get("[data-cy=project-panel] table thead th").contains(test.name).click();
+            cy.wait("@filteredQuery").then(interception => {
+                const searchParams = new URLSearchParams(new URL(interception.request.url).search);
+                expect(searchParams.get("order")).to.eq(test.desc);
+            });
+        });
+    });
+});
diff --git a/services/workbench2/cypress/e2e/search.cy.js b/services/workbench2/cypress/e2e/search.cy.js
new file mode 100644 (file)
index 0000000..094a3f6
--- /dev/null
@@ -0,0 +1,321 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe("Search tests", function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser("collectionuser1", "Collection", "User", false, true)
+            .as("activeUser")
+            .then(function () {
+                activeUser = this.activeUser;
+            });
+    });
+
+    it("can search for old collection versions", function () {
+        const colName = `Versioned Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+        let colUuid = "";
+        let oldVersionUuid = "";
+        // Make sure no other collections with this name exist
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
+            filters: `[["name", "=", "${colName}"]]`,
+            include_old_versions: true,
+        })
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.be.empty;
+            });
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("originalVersion")
+            .then(function () {
+                // Change the file name to create a new version.
+                cy.updateCollection(adminUser.token, this.originalVersion.uuid, {
+                    manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n",
+                });
+                colUuid = this.originalVersion.uuid;
+            });
+        // Confirm that there are 2 versions of the collection
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
+            filters: `[["name", "=", "${colName}"]]`,
+            include_old_versions: true,
+        })
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.have.lengthOf(2);
+                this.collections.map(function (aCollection) {
+                    expect(aCollection.current_version_uuid).to.equal(colUuid);
+                    if (aCollection.uuid !== aCollection.current_version_uuid) {
+                        oldVersionUuid = aCollection.uuid;
+                    }
+                });
+                cy.loginAs(activeUser);
+                const searchQuery = `${colName} type:arvados#collection`;
+                // Search for only collection's current version
+                cy.doSearch(`${searchQuery}`);
+                cy.get("[data-cy=search-results]").should("contain", "head version");
+                cy.get("[data-cy=search-results]").should("not.contain", "version 1");
+                // ...and then, include old versions.
+                cy.doSearch(`${searchQuery} is:pastVersion`);
+                cy.get("[data-cy=search-results]").should("contain", "head version");
+                cy.get("[data-cy=search-results]").should("contain", "version 1");
+            });
+    });
+
+    it("can display path of the selected item", function () {
+        const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).then(function () {
+            cy.loginAs(activeUser);
+
+            cy.doSearch(colName);
+
+            cy.get("[data-cy=search-results]").should("contain", colName);
+
+            cy.get("[data-cy=search-results]").contains(colName).closest("tr").click();
+
+            cy.get("[data-cy=element-path]").should("contain", `/ Projects / ${colName}`);
+        });
+    });
+
+    it("can search items using quotes", function () {
+        const random = Math.floor(Math.random() * Math.floor(999999));
+        const colName = `Collection ${random}`;
+        const colName2 = `Collection test ${random}`;
+
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).as("collection1");
+
+        cy.createCollection(adminUser.token, {
+            name: colName2,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).as("collection2");
+
+        cy.getAll("@collection1", "@collection2").then(function () {
+            cy.loginAs(activeUser);
+
+            cy.doSearch(colName);
+            cy.get("[data-cy=search-results] table tbody tr").should("have.length", 2);
+
+            cy.doSearch(`"${colName}"`);
+            cy.get("[data-cy=search-results] table tbody tr").should("have.length", 1);
+        });
+    });
+
+    it("can display owner of the item", function () {
+        const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).then(function () {
+            cy.loginAs(activeUser);
+
+            cy.doSearch(colName);
+
+            cy.get("[data-cy=search-results]").should("contain", colName);
+
+            cy.get("[data-cy=search-results]")
+                .contains(colName)
+                .closest("tr")
+                .within(() => {
+                    cy.get("p").contains(activeUser.user.uuid).should("contain", activeUser.user.full_name);
+                });
+        });
+    });
+
+    // The following test is enabled on Electron only, as Chromium and Firefox
+    // require permissions to access the clipboard.
+    it("shows search context menu", { browser: 'electron' } , function () {
+        const colName = `Home Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+        const federatedColName = `Federated Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+        const federatedColUuid = "xxxxx-4zz18-000000000000000";
+
+        // Intercept config to insert remote cluster
+        cy.intercept({ method: "GET", hostname: "127.0.0.1", url: "**/arvados/v1/config?nocache=*" }, req => {
+            req.on('response', res => {
+                res.body.RemoteClusters = {
+                    "*": res.body.RemoteClusters["*"],
+                    xxxxx: {
+                        ActivateUsers: true,
+                        Host: "xxxxx.fakecluster.tld",
+                        Insecure: false,
+                        Proxy: true,
+                        Scheme: "",
+                    },
+                };
+            });
+        });
+
+        // Fake remote cluster config
+        cy.intercept(
+            {
+                method: "GET",
+                hostname: "xxxxx.fakecluster.tld",
+                url: "**/arvados/v1/config",
+            },
+            {
+                statusCode: 200,
+                body: {
+                    API: {},
+                    ClusterID: "xxxxx",
+                    Collections: {},
+                    Containers: {},
+                    InstanceTypes: {},
+                    Login: {},
+                    Mail: { SupportEmailAddress: "arvados@example.com" },
+                    RemoteClusters: {
+                        "*": {
+                            ActivateUsers: false,
+                            Host: "",
+                            Insecure: false,
+                            Proxy: false,
+                            Scheme: "https",
+                        },
+                    },
+                    Services: {
+                        Composer: { ExternalURL: "" },
+                        Controller: { ExternalURL: "https://xxxxx.fakecluster.tld:34763/" },
+                        DispatchCloud: { ExternalURL: "" },
+                        DispatchLSF: { ExternalURL: "" },
+                        DispatchSLURM: { ExternalURL: "" },
+                        GitHTTP: { ExternalURL: "https://xxxxx.fakecluster.tld:39105/" },
+                        GitSSH: { ExternalURL: "" },
+                        Health: { ExternalURL: "https://xxxxx.fakecluster.tld:42915/" },
+                        Keepbalance: { ExternalURL: "" },
+                        Keepproxy: { ExternalURL: "https://xxxxx.fakecluster.tld:46773/" },
+                        Keepstore: { ExternalURL: "" },
+                        RailsAPI: { ExternalURL: "" },
+                        WebDAV: { ExternalURL: "https://xxxxx.fakecluster.tld:36041/" },
+                        WebDAVDownload: { ExternalURL: "https://xxxxx.fakecluster.tld:42957/" },
+                        WebShell: { ExternalURL: "" },
+                        Websocket: { ExternalURL: "wss://xxxxx.fakecluster.tld:37121/websocket" },
+                        Workbench1: { ExternalURL: "https://wb1.xxxxx.fakecluster.tld/" },
+                        Workbench2: { ExternalURL: "https://wb2.xxxxx.fakecluster.tld/" },
+                    },
+                    StorageClasses: {
+                        default: { Default: true, Priority: 0 },
+                    },
+                    Users: {},
+                    Volumes: {},
+                    Workbench: {},
+                },
+            }
+        );
+
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).then(function (testCollection) {
+            cy.loginAs(activeUser);
+
+            // Intercept search results to add federated result
+            cy.intercept({ method: "GET", url: "**/arvados/v1/groups/contents?*" }, req => {
+                req.on('response', res => {
+                    res.body.items = [
+                        res.body.items[0],
+                        {
+                            ...res.body.items[0],
+                            uuid: federatedColUuid,
+                            portable_data_hash: "00000000000000000000000000000000+0",
+                            name: federatedColName,
+                            href: res.body.items[0].href.replace(testCollection.uuid, federatedColUuid),
+                        },
+                    ];
+                    res.body.items_available += 1;
+                });
+            });
+
+            cy.doSearch(colName);
+
+            // Stub new window
+            cy.window().then(win => {
+                cy.stub(win, "open").as("Open");
+            });
+
+            // Check Copy link to clipboard
+            cy.get("[data-cy=search-results]").contains(colName).rightclick();
+            cy.get("[data-cy=context-menu]").within(ctx => {
+                // Check that there are 4 items in the menu
+                cy.get(ctx).children().should("have.length", 4);
+                cy.contains("API Details");
+                cy.contains("Copy link to clipboard");
+                cy.contains("Open in new tab");
+                cy.contains("View details");
+
+                cy.contains("Copy link to clipboard").click();
+                cy.waitForDom();
+                cy.window().then(win =>
+                    win.navigator.clipboard.readText().then(text => {
+                        expect(text).to.match(new RegExp(`/collections/${testCollection.uuid}$`));
+                    })
+                );
+            });
+
+            // Check open in new tab
+            cy.get("[data-cy=search-results]").contains(colName).rightclick();
+            cy.get("[data-cy=context-menu]").within(() => {
+                cy.contains("Open in new tab").click();
+                cy.waitForDom();
+                cy.get("@Open").should("have.been.calledOnceWith", `${window.location.origin}/collections/${testCollection.uuid}`);
+            });
+
+            // Check federated result Copy link to clipboard
+            cy.get("[data-cy=search-results]").contains(federatedColName).rightclick();
+            cy.get("[data-cy=context-menu]").within(() => {
+                cy.contains("Copy link to clipboard").click();
+                cy.waitForDom();
+                cy.window().then(win =>
+                    win.navigator.clipboard.readText().then(text => {
+                        expect(text).to.equal(`https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`);
+                    })
+                );
+            });
+            // Check open in new tab
+            cy.get("[data-cy=search-results]").contains(federatedColName).rightclick();
+            cy.get("[data-cy=context-menu]").within(() => {
+                cy.contains("Open in new tab").click();
+                cy.waitForDom();
+                cy.get("@Open").should("have.been.calledWith", `https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`);
+            });
+        });
+    });
+});
diff --git a/services/workbench2/cypress/e2e/sharing.cy.js b/services/workbench2/cypress/e2e/sharing.cy.js
new file mode 100644 (file)
index 0000000..4cb7e48
--- /dev/null
@@ -0,0 +1,173 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Sharing tests', function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
+            .as('activeUser').then(function () {
+                activeUser = this.activeUser;
+            });
+    })
+
+    it('can create and delete sharing URLs on collections', () => {
+        const collName = 'shared-collection ' + new Date().getTime();
+        cy.createCollection(adminUser.token, {
+            name: collName,
+            owner_uuid: adminUser.uuid,
+        }).as('sharedCollection').then(function (sharedCollection) {
+            cy.loginAs(adminUser);
+
+            cy.get('main').contains(sharedCollection.name).rightclick();
+            cy.get('[data-cy=context-menu]').within(() => {
+                cy.contains('Share').click({ waitForAnimations: false });
+            });
+            cy.get('.sharing-dialog').within(() => {
+                cy.contains('Sharing URLs').click();
+                cy.contains('Create sharing URL');
+                cy.contains('No sharing URLs');
+                cy.should('not.contain', 'Token');
+                cy.should('not.contain', 'expiring at:');
+
+                cy.contains('Create sharing URL').click();
+                cy.should('not.contain', 'No sharing URLs');
+                cy.contains('Token');
+                cy.contains('expiring at:');
+
+                cy.get('[data-cy=remove-url-btn]').find('button').click();
+                cy.contains('No sharing URLs');
+                cy.should('not.contain', 'Token');
+                cy.should('not.contain', 'expiring at:');
+            })
+        })
+    });
+
+    it('can share projects to other users', () => {
+        cy.loginAs(adminUser);
+
+        cy.createGroup(adminUser.token, {
+            name: `my-shared-writable-project ${Math.floor(Math.random() * 999999)}`,
+            group_class: 'project',
+        }).as('mySharedWritableProject').then(function (mySharedWritableProject) {
+            cy.contains('Refresh').click();
+            cy.get('main').contains(mySharedWritableProject.name).rightclick();
+            cy.get('[data-cy=context-menu]').within(() => {
+                cy.contains('Share').click({ waitForAnimations: false });
+            });
+            cy.get('[id="select-permissions"]').as('selectPermissions');
+            cy.get('@selectPermissions').click();
+            cy.contains('Write').click();
+            cy.get('.sharing-dialog').as('sharingDialog');
+            cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
+            cy.get('[role=tooltip]').click();
+            cy.get('@sharingDialog').within(() => {
+                cy.get('[data-cy=add-invited-people]').click();
+                cy.contains('Close').click({ waitForAnimations: false });
+            });
+        });
+
+        cy.createGroup(adminUser.token, {
+            name: `my-shared-readonly-project ${Math.floor(Math.random() * 999999)}`,
+            group_class: 'project',
+        }).as('mySharedReadonlyProject').then(function (mySharedReadonlyProject) {
+            cy.contains('Refresh').click();
+            cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
+            cy.get('[data-cy=context-menu]').within(() => {
+                cy.contains('Share').click({ waitForAnimations: false });
+            });
+            cy.get('.sharing-dialog').as('sharingDialog');
+            cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
+            cy.get('[role=tooltip]').click();
+            cy.get('@sharingDialog').within(() => {
+                cy.get('[data-cy=add-invited-people]').click();
+                cy.contains('Close').click({ waitForAnimations: false });
+            });
+        });
+
+        cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject')
+            .then(function ([mySharedWritableProject, mySharedReadonlyProject]) {
+                cy.loginAs(activeUser);
+
+                cy.contains('Shared with me').click();
+
+                // Test search
+                cy.get('[data-cy=search-input] input').type('readonly');
+                cy.get('main').should('not.contain', mySharedWritableProject.name);
+                cy.get('main').should('contain', mySharedReadonlyProject.name);
+                cy.get('[data-cy=search-input] input').clear();
+
+                // Test filter
+                cy.waitForDom().get('th').contains('Type').click();
+                cy.get('div[role=presentation]').contains('Project').click();
+                cy.waitForDom().get('main table tr td').contains('Project').should('not.exist');
+                cy.get('div[role=presentation]').contains('Project').click();
+                cy.waitForDom().get('div[role=presentation] button').contains('Close').click();
+
+                // Test move to trash
+                cy.get('main').contains(mySharedWritableProject.name).rightclick();
+                cy.get('[data-cy=context-menu]').should('contain', 'Move to trash');
+                cy.get('[data-cy=context-menu]').contains('Move to trash').click({ waitForAnimations: false });
+
+                // GUARD: Let's wait for the above removed project to disappear
+                // before continuing, to avoid intermittent failures.
+                cy.get('main').should('not.contain', mySharedWritableProject.name);
+
+                cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
+                cy.get('[data-cy=context-menu]').should('not.contain', 'Move to trash');
+            });
+    });
+
+    it('can edit project in shared with me', () => {
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedWritableProject',
+            canWrite: true,
+            addToFavorites: true
+        });
+
+        cy.getAll('@mySharedWritableProject')
+            .then(function ([mySharedWritableProject]) {
+                cy.loginAs(activeUser);
+
+                cy.get('[data-cy=side-panel-tree]').contains('Shared with me').click();
+
+                const newProjectName = `New project name ${mySharedWritableProject.name}`;
+                const newProjectDescription = `New project description ${mySharedWritableProject.name}`;
+
+                cy.testEditProjectOrCollection('main', mySharedWritableProject.name, newProjectName, newProjectDescription);
+            });
+    });
+
+    it('can share only when target users are present', () => {
+        const collName = `mySharedCollectionForUsers-${new Date().getTime()}`;
+        cy.createCollection(adminUser.token, {
+            name: collName,
+            owner_uuid: adminUser.uuid,
+        }).as('mySharedCollectionForUsers')
+
+        cy.getAll('@mySharedCollectionForUsers')
+            .then(function ([]) {
+                cy.loginAs(adminUser);
+                cy.get('[data-cy=project-panel]').contains(collName).rightclick();
+                cy.get('[data-cy=context-menu]').contains('Share').click({ waitForAnimations: false });
+                cy.get('button').get('[data-cy=add-invited-people]').should('be.disabled');
+                cy.get('[data-cy=invite-people-field] input').type('Anonymous');
+                cy.get('div[role=tooltip]').contains('anonymous').click();
+                cy.get('button').get('[data-cy=add-invited-people]').should('not.be.disabled');
+                cy.get('[data-cy=invite-people-field] div[role=button]').contains('anonymous').parent().find('svg').click();
+                cy.get('button').get('[data-cy=add-invited-people]').should('be.disabled');
+            });
+    });
+});
diff --git a/services/workbench2/cypress/e2e/side-panel.cy.js b/services/workbench2/cypress/e2e/side-panel.cy.js
new file mode 100644 (file)
index 0000000..6d6b19b
--- /dev/null
@@ -0,0 +1,170 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Side panel tests', function() {
+    let activeUser;
+    let adminUser;
+
+    before(function() {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function() {
+                adminUser = this.adminUser;
+            }
+        );
+        cy.getUser('user', 'Active', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+    })
+
+    it('enables the +NEW side panel button on users home project', function() {
+        cy.loginAs(activeUser);
+        cy.get('[data-cy=side-panel-button]')
+            .should('exist')
+            .and('not.be.disabled');
+    })
+
+    it('disables or enables the +NEW side panel button depending on project permissions', function() {
+        cy.loginAs(activeUser);
+        [true, false].map(function(isWritable) {
+            cy.createGroup(adminUser.token, {
+                name: `Test ${isWritable ? 'writable' : 'read-only'} project`,
+                group_class: 'project',
+            }).as('sharedGroup').then(function() {
+                cy.createLink(adminUser.token, {
+                    name: isWritable ? 'can_write' : 'can_read',
+                    link_class: 'permission',
+                    head_uuid: this.sharedGroup.uuid,
+                    tail_uuid: activeUser.user.uuid
+                })
+                cy.goToPath(`/projects/${this.sharedGroup.uuid}`);
+                cy.get('[data-cy=side-panel-button]')
+                    .should('exist')
+                    .and(`${isWritable ? 'not.' : ''}be.disabled`);
+            })
+        })
+    })
+
+    it('disables the +NEW side panel button on appropriate sections', function() {
+        cy.loginAs(activeUser);
+        [
+            {url: '/shared-with-me', label: 'Shared with me'},
+            {url: '/public-favorites', label: 'Public Favorites'},
+            {url: '/favorites', label: 'My Favorites'},
+            {url: '/all_processes', label: 'All Processes'},
+            {url: '/trash', label: 'Trash'},
+        ].map(function(section) {
+            cy.waitForDom().goToPath(section.url);
+            cy.get('[data-cy=breadcrumb-first]')
+                .should('contain', section.label);
+            cy.get('[data-cy=side-panel-button]')
+                .should('exist')
+                .and('be.disabled');
+        })
+    })
+
+    it('disables the +NEW side panel button when viewing filter group', function() {
+        cy.loginAs(adminUser);
+        cy.createGroup(adminUser.token, {
+            name: `my-favorite-filter-group`,
+            group_class: 'filter',
+            properties: {filters: []},
+        }).as('myFavoriteFilterGroup').then(function (myFavoriteFilterGroup) {
+            cy.goToPath(`/projects/${myFavoriteFilterGroup.uuid}`);
+            cy.get('[data-cy=breadcrumb-last]').should('contain', 'my-favorite-filter-group');
+
+            cy.get('[data-cy=side-panel-button]')
+                    .should('exist')
+                    .and(`be.disabled`);
+        })
+    })
+
+    it('can edit project in side panel', () => {
+        cy.createProject({
+            owningUser: activeUser,
+            targetUser: activeUser,
+            projectName: 'mySharedWritableProject',
+            canWrite: true,
+            addToFavorites: false
+        });
+
+        cy.getAll('@mySharedWritableProject')
+            .then(function ([mySharedWritableProject]) {
+                cy.loginAs(activeUser);
+
+                cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+
+                const newProjectName = `New project name ${mySharedWritableProject.name}`;
+                const newProjectDescription = `New project description ${mySharedWritableProject.name}`;
+
+                cy.testEditProjectOrCollection('[data-cy=side-panel-tree]', mySharedWritableProject.name, newProjectName, newProjectDescription);
+            });
+    });
+
+    it('side panel react to refresh when project data changes', () => {
+        const project = 'writableProject';
+
+        cy.createProject({
+            owningUser: activeUser,
+            targetUser: activeUser,
+            projectName: project,
+            canWrite: true,
+            addToFavorites: false
+        });
+
+        cy.getAll('@writableProject').then(function ([writableProject]) {
+            cy.loginAs(activeUser);
+            cy.get('[data-cy=side-panel-tree]')
+                .contains('Projects').click();
+            cy.get('[data-cy=side-panel-tree]')
+                .contains(writableProject.name).should('exist');
+            cy.trashGroup(activeUser.token, writableProject.uuid).then(() => {
+                cy.contains('Refresh').click();
+                cy.contains(writableProject.name).should('not.exist');
+            });
+        });
+    });
+
+    it('collapses and un-collapses', () => {
+
+        cy.loginAs(activeUser)
+        cy.get('[data-cy=side-panel-tree]').should('exist')
+        cy.get('[data-cy=side-panel-toggle]').click()
+        cy.get('[data-cy=side-panel-tree]').should('not.exist')
+        cy.get('[data-cy=side-panel-collapsed]').should('exist')
+        cy.get('[data-cy=side-panel-toggle]').click()
+        cy.get('[data-cy=side-panel-tree]').should('exist')
+        cy.get('[data-cy=side-panel-collapsed]').should('not.exist')
+    })
+
+    it('can navigate from collapsed panel', () => {
+
+        const collapsedCategories = {
+            'shared-with-me': '/shared-with-me',
+            'public-favorites': '/public-favorites',
+            'my-favorites': '/favorites',
+            'groups': '/groups',
+            'all-processes': '/all_processes',
+            'trash': '/trash',
+            'shell-access': '/virtual-machines-user',
+            'home-projects': `/projects/${activeUser.user.uuid}`,
+        }
+
+        cy.loginAs(activeUser)
+        cy.get('[data-cy=side-panel-tree]').should('exist')
+        cy.get('[data-cy=side-panel-toggle]').click()
+        cy.get('[data-cy=side-panel-collapsed]').should('exist')
+
+        for (const cat in collapsedCategories) {
+            cy.get(`[data-cy=collapsed-${cat}]`).should('exist').click()
+            cy.url().should('include', collapsedCategories[cat])
+        }
+    })
+})
+
diff --git a/services/workbench2/cypress/e2e/user-profile.cy.js b/services/workbench2/cypress/e2e/user-profile.cy.js
new file mode 100644 (file)
index 0000000..3e4cb7f
--- /dev/null
@@ -0,0 +1,454 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('User profile tests', function() {
+    let activeUser;
+    let adminUser;
+    const roleGroupName = `Test role group (${Math.floor(999999 * Math.random())})`;
+    const projectGroupName = `Test project group (${Math.floor(999999 * Math.random())})`;
+
+    before(function() {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function() {
+                adminUser = this.adminUser;
+            }
+        );
+        cy.getUser('user', 'Active', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+    });
+
+    function assertProfileValues({
+        firstName,
+        lastName,
+        email,
+        username,
+        org,
+        org_email,
+        role,
+        website,
+    }) {
+        cy.get('[data-cy=profile-form] input[name="firstName"]').invoke('val').should('equal', firstName);
+        cy.get('[data-cy=profile-form] input[name="lastName"]').invoke('val').should('equal', lastName);
+        cy.get('[data-cy=profile-form] [data-cy=email] [data-cy=value]').contains(email);
+        cy.get('[data-cy=profile-form] [data-cy=username] [data-cy=value]').contains(username);
+
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization"]').invoke('val').should('equal', org);
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization_email"]').invoke('val').should('equal', org_email);
+        cy.get('[data-cy=profile-form] select[name="prefs.profile.role"]').invoke('val').should('equal', role);
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.website_url"]').invoke('val').should('equal', website);
+    }
+
+    function enterProfileValues({
+        org,
+        org_email,
+        role,
+        website,
+    }) {
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization"]').clear();
+        if (org) {
+            cy.get('[data-cy=profile-form] input[name="prefs.profile.organization"]').type(org);
+        }
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization_email"]').clear();
+        if (org_email) {
+            cy.get('[data-cy=profile-form] input[name="prefs.profile.organization_email"]').type(org_email);
+        }
+        cy.get('[data-cy=profile-form] select[name="prefs.profile.role"]').select(role);
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.website_url"]').clear();
+        if (website) {
+            cy.get('[data-cy=profile-form] input[name="prefs.profile.website_url"]').type(website);
+        }
+    }
+
+    function assertContextMenuItems({
+        account,
+        activate,
+        deactivate,
+        login,
+        setup
+    }) {
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').within(() => {
+            cy.get('[role=button]').contains('API Details');
+
+            cy.get('[role=button]').should(account ? 'contain' : 'not.contain', 'Account Settings');
+            cy.get('[role=button]').should(activate ? 'contain' : 'not.contain', 'Activate user');
+            cy.get('[role=button]').should(deactivate ? 'contain' : 'not.contain', 'Deactivate user');
+            cy.get('[role=button]').should(login ? 'contain' : 'not.contain', 'Login as user');
+            cy.get('[role=button]').should(setup ? 'contain' : 'not.contain', 'Setup user');
+        });
+        cy.get('div[role=presentation]').click();
+    }
+
+    beforeEach(function() {
+        cy.updateResource(adminUser.token, 'users', adminUser.user.uuid, {
+            prefs: {
+                profile: {
+                    organization: '',
+                    organization_email: '',
+                    role: '',
+                    website_url: '',
+                },
+            },
+        });
+        cy.updateResource(adminUser.token, 'users', activeUser.user.uuid, {
+            prefs: {
+                profile: {
+                    organization: '',
+                    organization_email: '',
+                    role: '',
+                    website_url: '',
+                },
+            },
+        });
+    });
+
+    it('non-admin can edit own profile', function() {
+        cy.loginAs(activeUser);
+
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('My account').click();
+
+        // Admin actions should be hidden, no account menu
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: false,
+            login: false,
+            setup: false,
+        });
+
+        // Check initial values
+        assertProfileValues({
+            firstName: 'Active',
+            lastName: 'User',
+            email: 'user@example.local',
+            username: 'user',
+            org: '',
+            org_email: '',
+            role: '',
+            website: '',
+        });
+
+        // Change values
+        enterProfileValues({
+            org: 'Org name',
+            org_email: 'email@example.com',
+            role: 'Data Scientist',
+            website: 'example.com',
+        });
+
+        cy.get('[data-cy=profile-form] button[type="submit"]').should('not.be.disabled');
+
+        // Submit
+        cy.get('[data-cy=profile-form] button[type="submit"]').click();
+
+        // Check new values
+        assertProfileValues({
+            firstName: 'Active',
+            lastName: 'User',
+            email: 'user@example.local',
+            username: 'user',
+            org: 'Org name',
+            org_email: 'email@example.com',
+            role: 'Data Scientist',
+            website: 'example.com',
+        });
+
+        // if it worked, the save button should be disabled.
+        cy.get('[data-cy=profile-form] button[type="submit"]').should('be.disabled');
+    });
+
+    it('non-admin cannot edit other profile', function() {
+        cy.loginAs(activeUser);
+        cy.goToPath('/user/' + adminUser.user.uuid);
+
+        assertProfileValues({
+            firstName: 'Admin',
+            lastName: 'User',
+            email: 'admin@example.local',
+            username: 'admin',
+            org: '',
+            org_email: '',
+            role: '',
+            website: '',
+        });
+
+        // Inputs should be disabled
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization"]').should('be.disabled');
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization_email"]').should('be.disabled');
+        cy.get('[data-cy=profile-form] select[name="prefs.profile.role"]').should('be.disabled');
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.website_url"]').should('be.disabled');
+
+        // Submit should be disabled
+        cy.get('[data-cy=profile-form] button[type="submit"]').should('be.disabled');
+
+        // Admin actions should be hidden, no account menu
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: false,
+            login: false,
+            setup: false,
+        });
+    });
+
+    it('admin can edit own profile', function() {
+        cy.loginAs(adminUser);
+
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('My account').click();
+
+        // Admin actions should be visible, no account menu
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: false,
+            setup: false,
+        });
+
+        // Check initial values
+        assertProfileValues({
+            firstName: 'Admin',
+            lastName: 'User',
+            email: 'admin@example.local',
+            username: 'admin',
+            org: '',
+            org_email: '',
+            role: '',
+            website: '',
+        });
+
+        // Change values
+        enterProfileValues({
+            org: 'Admin org name',
+            org_email: 'admin@example.com',
+            role: 'Researcher',
+            website: 'admin.local',
+        });
+        cy.get('[data-cy=profile-form] button[type="submit"]').click();
+
+        // Check new values
+        assertProfileValues({
+            firstName: 'Admin',
+            lastName: 'User',
+            email: 'admin@example.local',
+            username: 'admin',
+            org: 'Admin org name',
+            org_email: 'admin@example.com',
+            role: 'Researcher',
+            website: 'admin.local',
+        });
+    });
+
+    it('admin can edit other profile', function() {
+        cy.loginAs(adminUser);
+        cy.goToPath('/user/' + activeUser.user.uuid);
+
+        // Check new values
+        assertProfileValues({
+            firstName: 'Active',
+            lastName: 'User',
+            email: 'user@example.local',
+            username: 'user',
+            org: '',
+            org_email: '',
+            role: '',
+            website: '',
+        });
+
+        enterProfileValues({
+            org: 'Changed org name',
+            org_email: 'changed@example.com',
+            role: 'Researcher',
+            website: 'changed.local',
+        });
+        cy.get('[data-cy=profile-form] button[type="submit"]').click();
+
+        // Check new values
+        assertProfileValues({
+            firstName: 'Active',
+            lastName: 'User',
+            email: 'user@example.local',
+            username: 'user',
+            org: 'Changed org name',
+            org_email: 'changed@example.com',
+            role: 'Researcher',
+            website: 'changed.local',
+        });
+
+        // Admin actions should be visible, no account menu
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+    });
+
+    it('displays role groups on user profile', function() {
+        cy.loginAs(adminUser);
+
+        cy.createGroup(adminUser.token, {
+            name: roleGroupName,
+            group_class: 'role',
+        }).as('roleGroup').then(function() {
+            cy.createLink(adminUser.token, {
+                name: 'can_write',
+                link_class: 'permission',
+                head_uuid: this.roleGroup.uuid,
+                tail_uuid: adminUser.user.uuid
+            });
+            cy.createLink(adminUser.token, {
+                name: 'can_write',
+                link_class: 'permission',
+                head_uuid: this.roleGroup.uuid,
+                tail_uuid: activeUser.user.uuid
+            });
+        });
+
+        cy.createGroup(adminUser.token, {
+            name: projectGroupName,
+            group_class: 'project',
+        }).as('projectGroup').then(function() {
+            cy.createLink(adminUser.token, {
+                name: 'can_write',
+                link_class: 'permission',
+                head_uuid: this.projectGroup.uuid,
+                tail_uuid: adminUser.user.uuid
+            });
+            cy.createLink(adminUser.token, {
+                name: 'can_write',
+                link_class: 'permission',
+                head_uuid: this.projectGroup.uuid,
+                tail_uuid: activeUser.user.uuid
+            });
+        });
+
+        cy.goToPath('/user/' + activeUser.user.uuid);
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').contains(roleGroupName);
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('not.contain', projectGroupName);
+
+        cy.goToPath('/user/' + adminUser.user.uuid);
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').contains(roleGroupName);
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('not.contain', projectGroupName);
+    });
+
+    it('allows performing admin functions', function() {
+        cy.loginAs(adminUser);
+        cy.goToPath('/user/' + activeUser.user.uuid);
+
+        // Check that user is active
+        cy.get('[data-cy=account-status]').contains('Active');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+
+        // Deactivate user
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Deactivate user').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that user is deactivated
+        cy.get('[data-cy=account-status]').contains('Inactive');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('not.contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: true,
+            deactivate: false,
+            login: true,
+            setup: true,
+        });
+
+        // Setup user
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Setup user').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that user is setup
+        cy.get('[data-cy=account-status]').contains('Setup');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: true,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+
+        // Activate user
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Activate user').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that user is active
+        cy.get('[data-cy=account-status]').contains('Active');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+
+        // Deactivate and activate user skipping setup
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Deactivate user').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+        // Check
+        cy.get('[data-cy=account-status]').contains('Inactive');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('not.contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: true,
+            deactivate: false,
+            login: true,
+            setup: true,
+        });
+        // reactivate
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Activate user').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that user is active
+        cy.get('[data-cy=account-status]').contains('Active');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+    });
+
+});
diff --git a/services/workbench2/cypress/e2e/virtual-machine-admin.cy.js b/services/workbench2/cypress/e2e/virtual-machine-admin.cy.js
new file mode 100644 (file)
index 0000000..f0f5f6b
--- /dev/null
@@ -0,0 +1,272 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe("Virtual machine login manage tests", function () {
+    let activeUser;
+    let adminUser;
+
+    const vmHost = `vm-${Math.floor(999999 * Math.random())}.host`;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser("admin", "VMAdmin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser("user", "VMActive", "User", false, true)
+            .as("activeUser")
+            .then(function () {
+                activeUser = this.activeUser;
+            });
+    });
+
+    it("adds and removes vm logins", function () {
+        cy.loginAs(adminUser);
+        cy.createVirtualMachine(adminUser.token, { hostname: vmHost });
+
+        // Navigate to VM admin
+        cy.get('header button[title="Admin Panel"]').click();
+        cy.get("#admin-menu").contains("Shell Access").click();
+
+        // Add login permission to admin
+        cy.get("[data-cy=vm-admin-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get('button[title="Add Login Permission"]').click();
+            });
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Add login permission")
+            .within(() => {
+                cy.get("label")
+                    .contains("Search for user")
+                    .parent()
+                    .within(() => {
+                        cy.get("input").type("VMAdmin");
+                    });
+            });
+        cy.waitForDom().get("[role=tooltip]").click();
+        cy.get("[data-cy=form-dialog]")
+            .as("add-login-dialog")
+            .should("contain", "Add login permission")
+            .within(() => {
+                cy.get("label")
+                    .contains("Add groups")
+                    .parent()
+                    .within(() => {
+                        cy.get("input").type("docker ");
+                        // Veryfy submit enabled (form has changed)
+                        cy.get("@add-login-dialog").within(() => {
+                            cy.get("[data-cy=form-submit-btn]").should("be.enabled");
+                        });
+                        cy.get("input").type("sudo");
+                        // Veryfy submit disabled (partial input in chips)
+                        cy.get("@add-login-dialog").within(() => {
+                            cy.get("[data-cy=form-submit-btn]").should("be.disabled");
+                        });
+                        cy.get("input").type("{enter}");
+                    });
+            });
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
+        });
+
+        cy.get("[data-cy=vm-admin-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get("td").contains("admin");
+            });
+
+        // Add login permission to activeUser
+        cy.get("[data-cy=vm-admin-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get('button[title="Add Login Permission"]').click();
+            });
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Add login permission")
+            .within(() => {
+                cy.get("label")
+                    .contains("Search for user")
+                    .parent()
+                    .within(() => {
+                        cy.get("input").type("VMActive user");
+                    });
+            });
+        cy.get("[role=tooltip]").click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
+        });
+
+        cy.get("[data-cy=vm-admin-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get("td").contains("user");
+            });
+
+        // Check admin's vm page for login
+        cy.get('header button[title="Account Management"]').click();
+        cy.get("#account-menu").contains("Shell Access").click();
+
+        cy.get("[data-cy=vm-user-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get("td").contains("admin");
+                cy.get("td").contains("docker");
+                cy.get("td").contains("sudo");
+                cy.get("td").contains("ssh admin@" + vmHost);
+            });
+
+        // Check activeUser's vm page for login
+        cy.loginAs(activeUser);
+        cy.get('header button[title="Account Management"]').click();
+        cy.get("#account-menu").contains("Shell Access").click();
+
+        cy.get("[data-cy=vm-user-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get("td").contains("user");
+                cy.get("td").should("not.contain", "docker");
+                cy.get("td").should("not.contain", "sudo");
+                cy.get("td").contains("ssh user@" + vmHost);
+            });
+
+        // Edit login permissions
+        cy.loginAs(adminUser);
+        cy.get('header button[title="Admin Panel"]').click();
+        cy.get("#admin-menu").contains("Shell Access").click();
+
+        cy.get("[data-cy=vm-admin-table]").contains("admin"); // Wait for page to finish
+
+        cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("admin").click();
+
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Update login permission")
+            .within(() => {
+                cy.get("label").contains("Add groups").parent().as("groupInput");
+            });
+
+        cy.get("@groupInput").within(() => {
+            cy.get("div[role=button]").contains("sudo").parent().find("svg").click();
+            cy.get("div[role=button]").contains("docker").parent().find("svg").click();
+        });
+
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
+        });
+
+        // Wait for page to finish loading
+        cy.get("[data-cy=vm-admin-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get("div[role=button]").parent().first().contains("admin");
+            });
+
+        cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("user").click();
+
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Update login permission")
+            .within(() => {
+                cy.get("label")
+                    .contains("Add groups")
+                    .parent()
+                    .within(() => {
+                        cy.get("input").type("docker{enter}");
+                    });
+            });
+
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
+        });
+
+        // Verify new login permissions
+        // Check admin's vm page for login
+        cy.get('header button[title="Account Management"]').click();
+        cy.get("#account-menu").contains("Shell Access").click();
+
+        cy.get("[data-cy=vm-user-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get("td").contains("admin");
+                cy.get("td").should("not.contain", "docker");
+                cy.get("td").should("not.contain", "sudo");
+                cy.get("td").contains("ssh admin@" + vmHost);
+            });
+
+        // Verify new login permissions
+        // Check activeUser's vm page for login
+        cy.loginAs(activeUser);
+        cy.get('header button[title="Account Management"]').click();
+        cy.get("#account-menu").contains("Shell Access").click();
+
+        cy.get("[data-cy=vm-user-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get("td").contains("user");
+                cy.get("td").contains("docker");
+                cy.get("td").should("not.contain", "sudo");
+                cy.get("td").contains("ssh user@" + vmHost);
+            });
+
+        // Remove login permissions
+        cy.loginAs(adminUser);
+        cy.get('header button[title="Admin Panel"]').click();
+        cy.get("#admin-menu").contains("Shell Access").click();
+
+        cy.get("[data-cy=vm-admin-table]").contains("user"); // Wait for page to finish
+
+        cy.get("[data-cy=vm-admin-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .as("vmRow")
+            .contains("user")
+            .parents("[role=button]")
+            .find("svg")
+            .as("removeButton");
+        cy.get("@removeButton").click();
+        cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+
+        cy.get("@vmRow").within(() => {
+            cy.get("div[role=button]").should("not.contain", "user");
+            cy.get("div[role=button]").should("have.length", 1);
+        });
+
+        cy.get("@vmRow").find("div[role=button]").contains("admin").parents("[role=button]").find("svg").as("removeButton");
+        cy.get("@removeButton").click();
+        cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+
+        cy.waitForDom()
+            .get("[data-cy=vm-admin-table]")
+            .contains(vmHost)
+            .parents("tr")
+            .within(() => {
+                cy.get("div[role=button]").should("not.exist");
+            });
+
+        // Check admin's vm page for login
+        cy.get('header button[title="Account Management"]').click();
+        cy.get("#account-menu").contains("Shell Access").click();
+
+        cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost);
+
+        // Check activeUser's vm page for login
+        cy.loginAs(activeUser);
+        cy.get('header button[title="Account Management"]').click();
+        cy.get("#account-menu").contains("Shell Access").click();
+
+        cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost);
+    });
+});
diff --git a/services/workbench2/cypress/e2e/workflow.cy.js b/services/workbench2/cypress/e2e/workflow.cy.js
new file mode 100644 (file)
index 0000000..b9cf86c
--- /dev/null
@@ -0,0 +1,295 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Registered workflow panel tests', function() {
+    let activeUser;
+    let adminUser;
+
+    before(function() {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function() {
+                adminUser = this.adminUser;
+            }
+        );
+        cy.getUser('user', 'Active', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+    });
+
+    it('should handle null definition', function() {
+        cy.createResource(activeUser.token, "workflows", {workflow: {name: "Test wf"}})
+            .then(function(workflowResource) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/workflows/${workflowResource.uuid}`);
+                cy.get('[data-cy=registered-workflow-info-panel]').should('contain', workflowResource.name);
+                cy.get('[data-cy=workflow-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
+            });
+    });
+
+    it('should handle malformed definition', function() {
+        cy.createResource(activeUser.token, "workflows", {workflow: {name: "Test wf", definition: "zap:"}})
+            .then(function(workflowResource) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/workflows/${workflowResource.uuid}`);
+                cy.get('[data-cy=registered-workflow-info-panel]').should('contain', workflowResource.name);
+                cy.get('[data-cy=workflow-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
+            });
+    });
+
+    it('should handle malformed run', function() {
+        cy.createResource(activeUser.token, "workflows", {workflow: {
+            name: "Test wf",
+            definition: JSON.stringify({
+                cwlVersion: "v1.2",
+                $graph: [
+                    {
+                        "class": "Workflow",
+                        "id": "#main",
+                        "inputs": [],
+                        "outputs": [],
+                        "requirements": [
+                            {
+                                "class": "SubworkflowFeatureRequirement"
+                            }
+                        ],
+                        "steps": [
+                            {
+                                "id": "#main/cat1-testcli.cwl (v1.2.0-109-g9b091ed)",
+                                "in": [],
+                                "label": "cat1-testcli.cwl (v1.2.0-109-g9b091ed)",
+                                "out": [
+                                    {
+                                        "id": "#main/step/args"
+                                    }
+                                ],
+                                "run": `keep:undefined/bar`
+                            }
+                        ]
+                    }
+                ],
+                "cwlVersion": "v1.2",
+                "http://arvados.org/cwl#gitBranch": "1.2.1_proposed",
+                "http://arvados.org/cwl#gitCommit": "9b091ed7e0bef98b3312e9478c52b89ba25792de",
+                "http://arvados.org/cwl#gitCommitter": "GitHub <noreply@github.com>",
+                "http://arvados.org/cwl#gitDate": "Sun, 11 Sep 2022 21:24:42 +0200",
+                "http://arvados.org/cwl#gitDescribe": "v1.2.0-109-g9b091ed",
+                "http://arvados.org/cwl#gitOrigin": "git@github.com:common-workflow-language/cwl-v1.2",
+                "http://arvados.org/cwl#gitPath": "tests/cat1-testcli.cwl",
+                "http://arvados.org/cwl#gitStatus": ""
+            })
+        }}).then(function(workflowResource) {
+            cy.loginAs(activeUser);
+            cy.goToPath(`/workflows/${workflowResource.uuid}`);
+            cy.get('[data-cy=registered-workflow-info-panel]').should('contain', workflowResource.name);
+            cy.get('[data-cy=workflow-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
+        });
+    });
+
+    const verifyIOParameter = (name, label, doc, val, collection) => {
+        cy.get('table tr').contains(name).parents('tr').within(($mainRow) => {
+            label && cy.contains(label);
+
+            if (val) {
+                if (Array.isArray(val)) {
+                    val.forEach(v => cy.contains(v));
+                } else {
+                    cy.contains(val);
+                }
+            }
+            if (collection) {
+                cy.contains(collection);
+            }
+        });
+    };
+
+    it('shows workflow details', function() {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        })
+            .then(function(collectionResource) {
+                cy.createResource(activeUser.token, "workflows", {workflow: {
+                    name: "Test wf",
+                    definition: JSON.stringify({
+                        cwlVersion: "v1.2",
+                        $graph: [
+                            {
+                                "class": "Workflow",
+                                "hints": [
+                                    {
+                                        "class": "DockerRequirement",
+                                        "dockerPull": "python:2-slim"
+                                    }
+                                ],
+                                "id": "#main",
+                                "inputs": [
+                                    {
+                                        "id": "#main/file1",
+                                        "type": "File"
+                                    },
+                                    {
+                                        "id": "#main/numbering",
+                                        "type": [
+                                            "null",
+                                            "boolean"
+                                        ]
+                                    },
+                                    {
+                                        "default": {
+                                            "basename": "args.py",
+                                            "class": "File",
+                                            "location": "keep:de738550734533c5027997c87dc5488e+53/args.py",
+                                            "nameext": ".py",
+                                            "nameroot": "args",
+                                            "size": 179
+                                        },
+                                        "id": "#main/args.py",
+                                        "type": "File"
+                                    }
+                                ],
+                                "outputs": [
+                                    {
+                                        "id": "#main/args",
+                                        "outputSource": "#main/step/args",
+                                        "type": {
+                                            "items": "string",
+                                            "name": "_:b0adccc1-502d-476f-8a5b-c8ef7119e2dc",
+                                            "type": "array"
+                                        }
+                                    }
+                                ],
+                                "requirements": [
+                                    {
+                                        "class": "SubworkflowFeatureRequirement"
+                                    }
+                                ],
+                                "steps": [
+                                    {
+                                        "id": "#main/cat1-testcli.cwl (v1.2.0-109-g9b091ed)",
+                                        "in": [
+                                            {
+                                                "id": "#main/step/file1",
+                                                "source": "#main/file1"
+                                            },
+                                            {
+                                                "id": "#main/step/numbering",
+                                                "source": "#main/numbering"
+                                            },
+                                            {
+                                                "id": "#main/step/args.py",
+                                                "source": "#main/args.py"
+                                            }
+                                        ],
+                                        "label": "cat1-testcli.cwl (v1.2.0-109-g9b091ed)",
+                                        "out": [
+                                            {
+                                                "id": "#main/step/args"
+                                            }
+                                        ],
+                                        "run": `keep:${collectionResource.portable_data_hash}/bar`
+                                    }
+                                ]
+                            }
+                        ],
+                        "cwlVersion": "v1.2",
+                        "http://arvados.org/cwl#gitBranch": "1.2.1_proposed",
+                        "http://arvados.org/cwl#gitCommit": "9b091ed7e0bef98b3312e9478c52b89ba25792de",
+                        "http://arvados.org/cwl#gitCommitter": "GitHub <noreply@github.com>",
+                        "http://arvados.org/cwl#gitDate": "Sun, 11 Sep 2022 21:24:42 +0200",
+                        "http://arvados.org/cwl#gitDescribe": "v1.2.0-109-g9b091ed",
+                        "http://arvados.org/cwl#gitOrigin": "git@github.com:common-workflow-language/cwl-v1.2",
+                        "http://arvados.org/cwl#gitPath": "tests/cat1-testcli.cwl",
+                        "http://arvados.org/cwl#gitStatus": ""
+                    })
+                }}).then(function(workflowResource) {
+                    cy.loginAs(activeUser);
+                    cy.goToPath(`/workflows/${workflowResource.uuid}`);
+                    cy.get('[data-cy=registered-workflow-info-panel]').should('contain', workflowResource.name);
+                    cy.get('[data-cy=workflow-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
+                    cy.get('[data-cy=registered-workflow-info-panel')
+                        .should('contain', 'gitCommit: 9b091ed7e0bef98b3312e9478c52b89ba25792de')
+
+                    cy.get('[data-cy=process-io-card] h6').contains('Input Parameters')
+                        .parents('[data-cy=process-io-card]').within(() => {
+                            verifyIOParameter('file1', null, '', '', '');
+                            verifyIOParameter('numbering', null, '', '', '');
+                            verifyIOParameter('args.py', null, '', 'args.py', 'de738550734533c5027997c87dc5488e+53');
+                        });
+                    cy.get('[data-cy=process-io-card] h6').contains('Output Parameters')
+                        .parents('[data-cy=process-io-card]').within(() => {
+                            verifyIOParameter('args', null, '', '', '');
+                        });
+                    cy.get('[data-cy=collection-files-panel]').within(() => {
+                        cy.get('[data-cy=collection-files-right-panel]', { timeout: 5000 })
+                            .should('contain', 'bar');
+                    });
+                });
+            });
+    });
+
+    it('can delete a workflow', function() {
+        cy.createResource(activeUser.token, "workflows", {workflow: {name: "Test wf"}})
+            .then(function(workflowResource) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/projects/${activeUser.user.uuid}`);
+                cy.get('[data-cy=project-panel] table tbody').contains(workflowResource.name).rightclick();
+                cy.get('[data-cy=context-menu]').contains('Delete Workflow').click();
+                cy.get('[data-cy=project-panel] table tbody').should('not.contain', workflowResource.name);
+            });
+    });
+
+    it('cannot delete readonly workflow', function() {
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedReadonlyProject',
+            canWrite: false,
+        });
+        cy.getAll('@mySharedReadonlyProject')
+            .then(function ([mySharedReadonlyProject]) {
+                cy.createResource(adminUser.token, "workflows", {workflow: {name: "Test wf", owner_uuid: mySharedReadonlyProject.uuid}})
+                    .then(function(workflowResource) {
+                        cy.loginAs(activeUser);
+                        cy.goToPath(`/shared-with-me`);
+                        cy.contains("mySharedReadonlyProject").click();
+                        cy.get('[data-cy=project-panel] table tbody').contains(workflowResource.name).rightclick();
+                        cy.get('[data-cy=context-menu]').should("not.contain", 'Delete Workflow');
+                    });
+            });
+    });
+
+    it('shows the appropriate buttons in the multiselect toolbar', () => {
+
+        const msButtonTooltips = [
+            'View details',
+            'Open in new tab',
+            'Copy link to clipboard',
+            'API Details',
+            'Run Workflow',
+            'Delete Workflow',
+        ];
+
+        cy.createResource(activeUser.token, "workflows", {workflow: {name: "Test wf"}})
+            .then(function(workflowResource) {
+                cy.loginAs(activeUser);
+                cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
+                cy.waitForDom()
+                cy.get('[data-cy=data-table-row]').contains(workflowResource.name).should('exist').parent().parent().parent().click()
+                cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length)
+                for (let i = 0; i < msButtonTooltips.length; i++) {
+                        cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover');
+                        cy.get('body').contains(msButtonTooltips[i]).should('exist')
+                        cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout');
+                    }
+                });
+    })
+
+});
diff --git a/services/workbench2/cypress/fixtures/files/5mb.bin b/services/workbench2/cypress/fixtures/files/5mb.bin
new file mode 100644 (file)
index 0000000..d52f252
Binary files /dev/null and b/services/workbench2/cypress/fixtures/files/5mb.bin differ
diff --git a/services/workbench2/cypress/fixtures/files/banner.html b/services/workbench2/cypress/fixtures/files/banner.html
new file mode 100644 (file)
index 0000000..34966bd
--- /dev/null
@@ -0,0 +1,5 @@
+<div>
+    <h1>Hi there</h1>
+    <h3>This is my amazing</h3>
+    <h5 style="color: red">Banner</h5>
+</div>
\ No newline at end of file
diff --git a/services/workbench2/cypress/fixtures/files/cat.png b/services/workbench2/cypress/fixtures/files/cat.png
new file mode 100644 (file)
index 0000000..6ebc4ba
Binary files /dev/null and b/services/workbench2/cypress/fixtures/files/cat.png differ
diff --git a/services/workbench2/cypress/fixtures/files/tooltips.txt b/services/workbench2/cypress/fixtures/files/tooltips.txt
new file mode 100644 (file)
index 0000000..c3c2162
--- /dev/null
@@ -0,0 +1,3 @@
+{
+    "[data-cy=side-panel-tree]": "This allows you to navigate through the app"
+}
\ No newline at end of file
diff --git a/services/workbench2/cypress/fixtures/webdav-propfind-outputs.xml b/services/workbench2/cypress/fixtures/webdav-propfind-outputs.xml
new file mode 100644 (file)
index 0000000..4bd1659
--- /dev/null
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<D:multistatus xmlns:D="DAV:">
+  <D:response>
+    <D:href>/c=zzzzz-4zz18-zzzzzzzzzzzzzzz/</D:href>
+    <D:propstat>
+      <D:prop>
+        <D:resourcetype>
+          <D:collection xmlns:D="DAV:" />
+        </D:resourcetype>
+        <D:getlastmodified>Mon, 11 Jul 2022 21:54:20 GMT</D:getlastmodified>
+        <D:supportedlock>
+          <D:lockentry xmlns:D="DAV:">
+            <D:lockscope>
+              <D:exclusive />
+            </D:lockscope>
+            <D:locktype>
+              <D:write />
+            </D:locktype>
+          </D:lockentry>
+        </D:supportedlock>
+        <D:displayname></D:displayname>
+      </D:prop>
+      <D:status>HTTP/1.1 200 OK</D:status>
+    </D:propstat>
+  </D:response>
+  <D:response>
+    <D:href>/c=zzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json</D:href>
+    <D:propstat>
+      <D:prop>
+        <D:displayname>cwl.output.json</D:displayname>
+        <D:getcontentlength>141</D:getcontentlength>
+        <D:getlastmodified>Mon, 11 Jul 2022 21:54:20 GMT</D:getlastmodified>
+        <D:supportedlock>
+          <D:lockentry xmlns:D="DAV:">
+            <D:lockscope>
+              <D:exclusive />
+            </D:lockscope>
+            <D:locktype>
+              <D:write />
+            </D:locktype>
+          </D:lockentry>
+        </D:supportedlock>
+        <D:resourcetype></D:resourcetype>
+        <D:getcontenttype>application/json</D:getcontenttype>
+        <D:getetag>"000000000000000000"</D:getetag>
+      </D:prop>
+      <D:status>HTTP/1.1 200 OK</D:status>
+    </D:propstat>
+  </D:response>
+</D:multistatus>
diff --git a/services/workbench2/cypress/fixtures/workflow_directory_array.yaml b/services/workbench2/cypress/fixtures/workflow_directory_array.yaml
new file mode 100644 (file)
index 0000000..fbdbd32
--- /dev/null
@@ -0,0 +1,20 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+---
+"$graph":
+- class: Workflow
+  cwlVersion: v1.2
+  hints:
+  - acrContainerImage: 7009415fdc959d0c2819ee2e9db96561+261
+    class: http://arvados.org/cwl#WorkflowRunnerResources
+  id: "#main"
+  inputs:
+  - id: "#main/directoryInputName"
+    type:
+      items: Directory
+      type: array
+  outputs: []
+  steps: []
+cwlVersion: v1.2
diff --git a/services/workbench2/cypress/fixtures/workflow_with_array_fields.yaml b/services/workbench2/cypress/fixtures/workflow_with_array_fields.yaml
new file mode 100644 (file)
index 0000000..33f03f6
--- /dev/null
@@ -0,0 +1,24 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+---
+"$graph":
+- class: Workflow
+  cwlVersion: v1.2
+  hints:
+  - acrContainerImage: 7009415fdc959d0c2819ee2e9db96561+261
+    class: http://arvados.org/cwl#WorkflowRunnerResources
+  id: "#main"
+  inputs:
+  - id: "#main/bar"
+    type:
+      items: Directory
+      type: array
+  - id: "#main/foo"
+    type:
+      items: File
+      type: array
+  outputs: []
+  steps: []
+cwlVersion: v1.2
diff --git a/services/workbench2/cypress/fixtures/workflow_with_default_array_fields.yaml b/services/workbench2/cypress/fixtures/workflow_with_default_array_fields.yaml
new file mode 100644 (file)
index 0000000..fc71cf8
--- /dev/null
@@ -0,0 +1,26 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+---
+"$graph":
+- class: Workflow
+  cwlVersion: v1.2
+  hints:
+  - acrContainerImage: 7009415fdc959d0c2819ee2e9db96561+261
+    class: http://arvados.org/cwl#WorkflowRunnerResources
+  id: "#main"
+  inputs:
+  - default: []
+    id: "#main/bar"
+    type:
+      items: Directory
+      type: array
+  - default: []
+    id: "#main/foo"
+    type:
+      items: File
+      type: array
+  outputs: []
+  steps: []
+cwlVersion: v1.2
diff --git a/services/workbench2/cypress/plugins/index.js b/services/workbench2/cypress/plugins/index.js
new file mode 100644 (file)
index 0000000..132f9b0
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+/// <reference types="cypress" />
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * @type {Cypress.PluginConfig}
+ */
+module.exports = (on, config) => {
+  // `on` is used to hook into various events Cypress emits
+  // `config` is the resolved Cypress config
+  on("before:browser:launch", (browser = {}, launchOptions) => {
+    const downloadDirectory = path.join(__dirname, "..", "downloads");
+    if (browser.family === 'chromium' && browser.name !== 'electron') {
+     launchOptions.preferences.default["download"] = {
+      default_directory: downloadDirectory
+     };
+    }
+    return launchOptions;
+  });
+
+  on('task', {
+    clearDownload({ filename }) {
+      fs.unlinkSync(filename);
+      return null;
+    }
+  });
+}
diff --git a/services/workbench2/cypress/support/commands.js b/services/workbench2/cypress/support/commands.js
new file mode 100644 (file)
index 0000000..529d776
--- /dev/null
@@ -0,0 +1,575 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+
+import 'cypress-wait-until';
+import { extractFilesData } from "services/collection-service/collection-service-files-response";
+
+const controllerURL = Cypress.env("controller_url");
+const systemToken = Cypress.env("system_token");
+let createdResources = [];
+
+const containerLogFolderPrefix = "log for container ";
+
+// Clean up anything that was created.  You can temporarily add
+// 'return' to the top if you need the resources to hang around to
+// debug a specific test.
+afterEach(function () {
+    if (createdResources.length === 0) {
+        return;
+    }
+    cy.log(`Cleaning ${createdResources.length} previously created resource(s).`);
+    createdResources.forEach(function ({ suffix, uuid }) {
+        // Don't fail when a resource isn't already there, some objects may have
+        // been removed, directly or indirectly, from the test that created them.
+        cy.deleteResource(systemToken, suffix, uuid, false);
+    });
+    createdResources = [];
+});
+
+Cypress.Commands.add(
+    "doRequest",
+    (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
+        return cy.request({
+            method: method,
+            url: `${controllerURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
+            body: data,
+            qs: auth ? qs : Object.assign({ api_token: token }, qs),
+            auth: auth ? { bearer: `${token}` } : undefined,
+            followRedirect: followRedirect,
+            failOnStatusCode: failOnStatusCode,
+        });
+    }
+);
+
+Cypress.Commands.add(
+    "doWebDAVRequest",
+    (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
+        return cy.doRequest("GET", "/arvados/v1/config", null, null).then(({ body: config }) => {
+            return cy.request({
+                method: method,
+                url: `${config.Services.WebDAVDownload.ExternalURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
+                body: data,
+                qs: auth ? qs : Object.assign({ api_token: token }, qs),
+                auth: auth ? { bearer: `${token}` } : undefined,
+                followRedirect: followRedirect,
+                failOnStatusCode: failOnStatusCode,
+            });
+        });
+    }
+);
+
+Cypress.Commands.add("getUser", (username, first_name = "", last_name = "", is_admin = false, is_active = true) => {
+    // Create user if not already created
+    return (
+        cy
+            .doRequest(
+                "POST",
+                "/auth/controller/callback",
+                {
+                    auth_info: JSON.stringify({
+                        email: `${username}@example.local`,
+                        username: username,
+                        first_name: first_name,
+                        last_name: last_name,
+                        alternate_emails: [],
+                    }),
+                    return_to: ",https://controller.api.client.invalid",
+                },
+                null,
+                systemToken,
+                true,
+                false
+            ) // Don't follow redirects so we can catch the token
+            .its("headers.location")
+            .as("location")
+            // Get its token and set the account up as admin and/or active
+            .then(function () {
+                this.userToken = this.location.split("=")[1];
+                assert.isString(this.userToken);
+                return cy
+                    .doRequest("GET", "/arvados/v1/users", null, {
+                        filters: `[["username", "=", "${username}"]]`,
+                    })
+                    .its("body.items.0")
+                    .as("aUser")
+                    .then(function () {
+                        cy.doRequest("PUT", `/arvados/v1/users/${this.aUser.uuid}`, {
+                            user: {
+                                is_admin: is_admin,
+                                is_active: is_active,
+                            },
+                        })
+                            .its("body")
+                            .as("theUser")
+                            .then(function () {
+                                cy.doRequest("GET", "/arvados/v1/api_clients", null, {
+                                    filters: `[["is_trusted", "=", false]]`,
+                                    order: `["created_at desc"]`,
+                                })
+                                    .its("body.items")
+                                    .as("apiClients")
+                                    .then(function () {
+                                        if (this.apiClients.length > 0) {
+                                            cy.doRequest("PUT", `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, {
+                                                api_client: {
+                                                    is_trusted: true,
+                                                },
+                                            })
+                                                .its("body")
+                                                .as("updatedApiClient")
+                                                .then(function () {
+                                                    assert(this.updatedApiClient.is_trusted);
+                                                });
+                                        }
+                                    })
+                                    .then(function () {
+                                        return { user: this.theUser, token: this.userToken };
+                                    });
+                            });
+                    });
+            })
+    );
+});
+
+Cypress.Commands.add("createLink", (token, data) => {
+    return cy.createResource(token, "links", {
+        link: JSON.stringify(data),
+    });
+});
+
+Cypress.Commands.add("createGroup", (token, data) => {
+    return cy.createResource(token, "groups", {
+        group: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
+
+Cypress.Commands.add("trashGroup", (token, uuid) => {
+    return cy.deleteResource(token, "groups", uuid);
+});
+
+Cypress.Commands.add("createWorkflow", (token, data) => {
+    return cy.createResource(token, "workflows", {
+        workflow: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
+
+Cypress.Commands.add("createCollection", (token, data, keep = false) => {
+    return cy.createResource(token, "collections", {
+        collection: JSON.stringify(data),
+        ensure_unique_name: true,
+    }, keep);
+});
+
+Cypress.Commands.add("getCollection", (token, uuid) => {
+    return cy.getResource(token, "collections", uuid);
+});
+
+Cypress.Commands.add("updateCollection", (token, uuid, data) => {
+    return cy.updateResource(token, "collections", uuid, {
+        collection: JSON.stringify(data),
+    });
+});
+
+Cypress.Commands.add("collectionReplaceFiles", (token, uuid, data) => {
+    return cy.updateResource(token, "collections", uuid, {
+        collection: {
+            preserve_version: true,
+        },
+        replace_files: JSON.stringify(data),
+    });
+});
+
+Cypress.Commands.add("getContainer", (token, uuid) => {
+    return cy.getResource(token, "containers", uuid);
+});
+
+Cypress.Commands.add("updateContainer", (token, uuid, data) => {
+    return cy.updateResource(token, "containers", uuid, {
+        container: JSON.stringify(data),
+    });
+});
+
+Cypress.Commands.add("getContainerRequest", (token, uuid) => {
+    return cy.getResource(token, "container_requests", uuid);
+});
+
+Cypress.Commands.add("createContainerRequest", (token, data) => {
+    return cy.createResource(token, "container_requests", {
+        container_request: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
+
+Cypress.Commands.add("updateContainerRequest", (token, uuid, data) => {
+    return cy.updateResource(token, "container_requests", uuid, {
+        container_request: JSON.stringify(data),
+    });
+});
+
+/**
+ * Requires an admin token for log_uuid modification to succeed
+ */
+Cypress.Commands.add("appendLog", (token, crUuid, fileName, lines = []) =>
+    cy.getContainerRequest(token, crUuid).then(containerRequest => {
+        if (containerRequest.log_uuid) {
+            cy.listContainerRequestLogs(token, crUuid).then(logFiles => {
+                const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`;
+                if (logFiles.find(file => file.name === fileName)) {
+                    // File exists, fetch and append
+                    return cy
+                        .doWebDAVRequest("GET", `c=${filePath}`, null, null, token)
+                        .then(({ body: contents }) =>
+                            cy.doWebDAVRequest("PUT", `c=${filePath}`, contents.split("\n").concat(lines).join("\n"), null, token)
+                        );
+                } else {
+                    // File not exists, put new file
+                    cy.doWebDAVRequest("PUT", `c=${filePath}`, lines.join("\n"), null, token);
+                }
+            });
+        } else {
+            // Create log collection
+            return cy
+                .createCollection(token, {
+                    name: `Test log collection ${Math.floor(Math.random() * 999999)}`,
+                    owner_uuid: containerRequest.owner_uuid,
+                    manifest_text: "",
+                })
+                .then(collection => {
+                    // Update CR log_uuid to fake log collection
+                    cy.updateContainerRequest(token, containerRequest.uuid, {
+                        log_uuid: collection.uuid,
+                    }).then(() =>
+                        // Create empty directory for container uuid
+                        cy
+                            .collectionReplaceFiles(token, collection.uuid, {
+                                [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0",
+                            })
+                            .then(() =>
+                                // Put new log file with contents into fake log collection
+                                cy.doWebDAVRequest(
+                                    "PUT",
+                                    `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`,
+                                    lines.join("\n"),
+                                    null,
+                                    token
+                                )
+                            )
+                    );
+                });
+        }
+    })
+);
+
+Cypress.Commands.add("listContainerRequestLogs", (token, crUuid) =>
+    cy.getContainerRequest(token, crUuid).then(containerRequest =>
+        cy
+            .doWebDAVRequest(
+                "PROPFIND",
+                `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`,
+                null,
+                null,
+                token
+            )
+            .then(({ body: data }) => {
+                return extractFilesData(new DOMParser().parseFromString(data, "text/xml"));
+            })
+    )
+);
+
+Cypress.Commands.add("createVirtualMachine", (token, data) => {
+    return cy.createResource(token, "virtual_machines", {
+        virtual_machine: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
+
+Cypress.Commands.add("getResource", (token, suffix, uuid) => {
+    return cy
+        .doRequest("GET", `/arvados/v1/${suffix}/${uuid}`, null, {}, token)
+        .its("body")
+        .then(function (resource) {
+            return resource;
+        });
+});
+
+Cypress.Commands.add("createResource", (token, suffix, data, keep = false) => {
+    return cy
+        .doRequest("POST", "/arvados/v1/" + suffix, data, null, token, true)
+        .its("body")
+        .then(function (resource) {
+            if (! keep) {
+                createdResources.push({ suffix, uuid: resource.uuid });
+            };
+            return resource;
+        });
+});
+
+
+Cypress.Commands.add("deleteResource", (token, suffix, uuid, failOnStatusCode = true) => {
+    return cy
+        .doRequest("DELETE", "/arvados/v1/" + suffix + "/" + uuid, null, null, token, false, true, failOnStatusCode)
+        .its("body")
+        .then(function (resource) {
+            return resource;
+        });
+});
+
+Cypress.Commands.add("updateResource", (token, suffix, uuid, data) => {
+    return cy
+        .doRequest("PATCH", "/arvados/v1/" + suffix + "/" + uuid, data, null, token, true)
+        .its("body")
+        .then(function (resource) {
+            return resource;
+        });
+});
+
+Cypress.Commands.add("loginAs", user => {
+    // This shouldn't be necessary unless we need to call loginAs multiple times
+    // in the same test.
+    cy.clearCookies();
+    cy.clearAllLocalStorage();
+    cy.clearAllSessionStorage();
+    cy.visit(`/token/?api_token=${user.token}`);
+    // Use waitUntil to avoid permafail race conditions with window.location being undefined
+    cy.waitUntil(() => cy.window().then(win =>
+        win?.location?.href &&
+        win.location.href.includes("/projects/")
+    ), { timeout: 15000 });
+    // Wait for page to settle before getting elements
+    cy.waitForDom();
+    cy.get("div#root").should("contain", "Arvados Workbench (zzzzz)");
+    cy.get("div#root").should("not.contain", "Your account is inactive");
+});
+
+Cypress.Commands.add("testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => {
+    cy.get(container).contains(oldName).rightclick();
+    cy.get("[data-cy=context-menu]")
+        .contains(isProject ? "Edit project" : "Edit collection")
+        .click();
+    cy.get("[data-cy=form-dialog]").within(() => {
+        cy.get("input[name=name]").clear().type(newName);
+        cy.get(isProject ? "div[contenteditable=true]" : "input[name=description]")
+            .clear()
+            .type(newDescription);
+        cy.get("[data-cy=form-submit-btn]").click();
+    });
+
+    cy.get(container).contains(newName).rightclick();
+    cy.get("[data-cy=context-menu]")
+        .contains(isProject ? "Edit project" : "Edit collection")
+        .click();
+    cy.get("[data-cy=form-dialog]").within(() => {
+        cy.get("input[name=name]").should("have.value", newName);
+
+        if (isProject) {
+            cy.get("span[data-text=true]").contains(newDescription);
+        } else {
+            cy.get("input[name=description]").should("have.value", newDescription);
+        }
+
+        cy.get("[data-cy=form-cancel-btn]").click();
+    });
+});
+
+Cypress.Commands.add("doSearch", searchTerm => {
+    cy.get("[data-cy=searchbar-input-field]").type(`{selectall}${searchTerm}{enter}`);
+});
+
+Cypress.Commands.add("goToPath", path => {
+    return cy.window().its("appHistory").invoke("push", path);
+});
+
+Cypress.Commands.add("getAll", (...elements) => {
+    const promise = cy.wrap([], { log: false });
+
+    for (let element of elements) {
+        promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got])));
+    }
+
+    return promise;
+});
+
+Cypress.Commands.add("shareWith", (srcUserToken, targetUserUUID, itemUUID, permission = "can_write") => {
+    cy.createLink(srcUserToken, {
+        name: permission,
+        link_class: "permission",
+        head_uuid: itemUUID,
+        tail_uuid: targetUserUUID,
+    });
+});
+
+Cypress.Commands.add("addToFavorites", (userToken, userUUID, itemUUID) => {
+    cy.createLink(userToken, {
+        head_uuid: itemUUID,
+        link_class: "star",
+        name: "",
+        owner_uuid: userUUID,
+        tail_uuid: userUUID,
+    });
+});
+
+Cypress.Commands.add("createProject", ({ owningUser, targetUser, projectName, canWrite, addToFavorites }) => {
+    const writePermission = canWrite ? "can_write" : "can_read";
+
+    cy.createGroup(owningUser.token, {
+        name: `${projectName} ${Math.floor(Math.random() * 999999)}`,
+        group_class: "project",
+    })
+        .as(`${projectName}`)
+        .then(project => {
+            if (targetUser && targetUser !== owningUser) {
+                cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
+            }
+            if (addToFavorites) {
+                const user = targetUser ? targetUser : owningUser;
+                cy.addToFavorites(user.token, user.user.uuid, project.uuid);
+            }
+        });
+});
+
+Cypress.Commands.add(
+    "upload",
+    {
+        prevSubject: "element",
+    },
+    (subject, file, fileName, binaryMode = true) => {
+        cy.window().then(window => {
+            const blob = binaryMode ? b64toBlob(file, "", 512) : new Blob([file], { type: "text/plain" });
+            const testFile = new window.File([blob], fileName);
+
+            cy.wrap(subject).trigger("drop", {
+                dataTransfer: { files: [testFile] },
+            });
+        });
+    }
+);
+
+function b64toBlob(b64Data, contentType = "", sliceSize = 512) {
+    const byteCharacters = atob(b64Data);
+    const byteArrays = [];
+
+    for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
+        const slice = byteCharacters.slice(offset, offset + sliceSize);
+
+        const byteNumbers = new Array(slice.length);
+        for (let i = 0; i < slice.length; i++) {
+            byteNumbers[i] = slice.charCodeAt(i);
+        }
+
+        const byteArray = new Uint8Array(byteNumbers);
+
+        byteArrays.push(byteArray);
+    }
+
+    const blob = new Blob(byteArrays, { type: contentType });
+    return blob;
+}
+
+// From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070=
+// This command requires the async package (https://www.npmjs.com/package/async)
+Cypress.Commands.add("waitForDom", () => {
+    cy.window().then(
+        {
+            // Don't timeout before waitForDom finishes
+            timeout: 10000,
+        },
+        win => {
+            let timeElapsed = 0;
+
+            cy.log("Waiting for DOM mutations to complete");
+
+            return new Cypress.Promise(resolve => {
+                // set the required variables
+                let async = require("async");
+                let observerConfig = { attributes: true, childList: true, subtree: true };
+                let items = Array.apply(null, { length: 50 }).map(Number.call, Number);
+                win.mutationCount = 0;
+                win.previousMutationCount = null;
+
+                // create an observer instance
+                let observer = new win.MutationObserver(mutations => {
+                    mutations.forEach(mutation => {
+                        // Only record "attributes" type mutations that are not a "class" mutation.
+                        // If the mutation is not an "attributes" type, then we always record it.
+                        if (mutation.type === "attributes" && mutation.attributeName !== "class") {
+                            win.mutationCount += 1;
+                        } else if (mutation.type !== "attributes") {
+                            win.mutationCount += 1;
+                        }
+                    });
+
+                    // initialize the previousMutationCount
+                    if (win.previousMutationCount == null) win.previousMutationCount = 0;
+                });
+
+                // watch the document body for the specified mutations
+                observer.observe(win.document.body, observerConfig);
+
+                // check the DOM for mutations up to 50 times for a maximum time of 5 seconds
+                async.eachSeries(
+                    items,
+                    function iteratee(item, callback) {
+                        // keep track of the elapsed time so we can log it at the end of the command
+                        timeElapsed = timeElapsed + 100;
+
+                        // make each iteration of the loop 100ms apart
+                        setTimeout(() => {
+                            if (win.mutationCount === win.previousMutationCount) {
+                                // pass an argument to the async callback to exit the loop
+                                return callback("Resolved - DOM changes complete.");
+                            } else if (win.previousMutationCount != null) {
+                                // only set the previous count if the observer has checked the DOM at least once
+                                win.previousMutationCount = win.mutationCount;
+                                return callback();
+                            } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) {
+                                // this is an early exit in case nothing is changing in the DOM. That way we only
+                                // wait 500ms instead of the full 5 seconds when no DOM changes are occurring.
+                                return callback("Resolved - Exiting early since no DOM changes were detected.");
+                            } else {
+                                // proceed to the next iteration
+                                return callback();
+                            }
+                        }, 100);
+                    },
+                    function done() {
+                        // Log the total wait time so users can see it
+                        cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`);
+
+                        // disconnect the observer and resolve the promise
+                        observer.disconnect();
+                        resolve();
+                    }
+                );
+            });
+        }
+    );
+});
diff --git a/services/workbench2/cypress/support/e2e.js b/services/workbench2/cypress/support/e2e.js
new file mode 100644 (file)
index 0000000..0a89cb5
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// ***********************************************************
+// This example support/index.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
diff --git a/services/workbench2/cypress/support/index.d.ts b/services/workbench2/cypress/support/index.d.ts
new file mode 100644 (file)
index 0000000..d74d5b3
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+  * This command tries to ensure that the elements in the DOM are actually visible
+  * and done (re)rendering. This is due to how React re-renders components.
+  *
+  * IMPORTANT NOTES:
+  *    => You should only use this command in instances where a test is failing due
+  *       to detached elements. Cypress will probably give you a warning along the lines
+  *       of, "Element has an effective width/height of 0". This warning is not very useful
+  *       in pointing out it is due to the element being detached from the DOM AFTER the
+  *       cy.get command had already retrieved it. This command can save you from that
+  *       by explicitly waiting for the DOM to stop changing.
+  *    => This command can take anywhere from 100ms to 5 seconds to complete
+  *    => This command will exit early (500ms) when no changes are occurring in the DOM.
+  *       We wait a minimum of 500ms because sometimes it can take up to around that time
+  *       for mutations to start occurring.
+  *
+  * GitHub Issues:
+  *    * https://github.com/cypress-io/cypress/issues/695 (Closed - no activity)
+  *    * https://github.com/cypress-io/cypress/issues/7306 (Open - re-get detached elements)
+  *
+  * @example Wait for the DOM to stop changing before retrieving an element
+  * cy.waitForDom().get('#an-elements-id')
+  */
+ waitForDom(): Chainable<any>
diff --git a/services/workbench2/docker/Dockerfile b/services/workbench2/docker/Dockerfile
new file mode 100644 (file)
index 0000000..fa42661
--- /dev/null
@@ -0,0 +1,40 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+FROM node:12.22.12-bullseye
+LABEL maintainer="Arvados Package Maintainers <packaging@arvados.org>"
+
+RUN echo deb http://deb.debian.org/debian bullseye-backports main >> /etc/apt/sources.list.d/backports.list
+RUN apt-get update && \
+    apt-get -yq --no-install-recommends -o Acquire::Retries=6 install \
+    libsecret-1-0 libsecret-1-dev rpm ruby ruby-dev rubygems build-essential \
+    libpam0g-dev libgbm1 git && \
+    apt-get clean
+
+RUN /usr/bin/gem install --no-document fpm
+WORKDIR /usr/src/arvados
+COPY . .
+RUN cd /usr/src/arvados && \
+    test -d cmd/arvados-server || \
+      (echo "ERROR: build context must be an Arvados repository" && false) && \
+    GO_VERSION=$(grep 'goversion =' lib/install/deps.go |awk -F'"' '{print $2}') && \
+    ARCH=$(dpkg --print-architecture) && \
+    echo $GO_VERSION && \
+    cd /usr/src && \
+    wget https://golang.org/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz && \
+    tar xzf go${GO_VERSION}.linux-${ARCH}.tar.gz && \
+    ln -s /usr/src/go/bin/go /usr/local/bin/go-${GO_VERSION} && \
+    ln -s /usr/src/go/bin/gofmt /usr/local/bin/gofmt-${GO_VERSION} && \
+    ln -s /usr/local/bin/go-${GO_VERSION} /usr/local/bin/go && \
+    ln -s /usr/local/bin/gofmt-${GO_VERSION} /usr/local/bin/gofmt
+
+# preseed arvados build dependencies
+RUN cd /usr/src/arvados && \
+    apt-get update && \
+    go mod download && \
+    go run ./cmd/arvados-server install -type test && cd .. && \
+    rm -rf arvados && \
+    apt-get clean
+
+RUN git config --global --add safe.directory /usr/src/arvados
diff --git a/services/workbench2/etc/arvados/workbench2/workbench2.example.json b/services/workbench2/etc/arvados/workbench2/workbench2.example.json
new file mode 100644 (file)
index 0000000..d790112
--- /dev/null
@@ -0,0 +1,3 @@
+{
+  "API_HOST": "CHANGE.TO.YOUR.ARVADOS.API.HOST" 
+}
\ No newline at end of file
diff --git a/services/workbench2/package.json b/services/workbench2/package.json
new file mode 100644 (file)
index 0000000..71dc4d7
--- /dev/null
@@ -0,0 +1,146 @@
+{
+  "name": "arvados-workbench-2",
+  "version": "0.1.0",
+  "private": true,
+  "dependencies": {
+    "@babel/core": "^7.0.0",
+    "@babel/runtime-corejs2": "^7.0.0",
+    "@coreui/coreui": "^4.3.2",
+    "@coreui/react": "^4.11.0",
+    "@date-io/date-fns": "1",
+    "@fortawesome/fontawesome-svg-core": "1.2.28",
+    "@fortawesome/free-solid-svg-icons": "5.13.0",
+    "@fortawesome/react-fontawesome": "0.1.9",
+    "@material-ui/core": "3.9.3",
+    "@material-ui/icons": "3.0.1",
+    "@types/debounce": "3.0.0",
+    "@types/dompurify": "^3.0.3",
+    "@types/file-saver": "2.0.0",
+    "@types/js-yaml": "3.11.2",
+    "@types/jssha": "0.0.29",
+    "@types/jszip": "3.1.5",
+    "@types/lodash": "4.14.116",
+    "@types/react": "17.0.11",
+    "@types/react-copy-to-clipboard": "5.0.0",
+    "@types/react-dropzone": "4.2.2",
+    "@types/react-highlight-words": "0.12.0",
+    "@types/react-virtualized-auto-sizer": "1.0.0",
+    "@types/react-window": "1.8.2",
+    "@types/redux-form": "7.4.12",
+    "@types/shell-escape": "^0.2.0",
+    "axios": "^0.28.1",
+    "bootstrap": "^5.3.2",
+    "caniuse-lite": "1.0.30001606",
+    "classnames": "2.2.6",
+    "cwlts": "1.15.29",
+    "date-fns": "^2.28.0",
+    "debounce": "1.2.0",
+    "dompurify": "^3.0.6",
+    "elliptic": "6.5.4",
+    "file-saver": "2.0.1",
+    "fstream": "1.0.12",
+    "is-image": "3.0.0",
+    "js-yaml": "3.13.1",
+    "jssha": "2.3.1",
+    "jszip": "^3.10.1",
+    "lodash": "^4.17.21",
+    "lodash-es": "^4.17.21",
+    "lodash.mergewith": "4.6.2",
+    "lodash.template": "4.5.0",
+    "material-ui-pickers": "^2.2.4",
+    "mem": "4.0.0",
+    "mime": "^3.0.0",
+    "moment": "^2.29.4",
+    "parse-duration": "0.4.4",
+    "prop-types": "15.7.2",
+    "query-string": "6.9.0",
+    "react": "16.14.0",
+    "react-copy-to-clipboard": "5.0.3",
+    "react-dnd": "5.0.0",
+    "react-dnd-html5-backend": "5.0.1",
+    "react-dom": "16.14.0",
+    "react-dropzone": "5.1.1",
+    "react-highlight-words": "0.14.0",
+    "react-idle-timer": "4.3.6",
+    "react-loader-spinner": "^6.1.6",
+    "react-redux": "5.0.7",
+    "react-router": "4.3.1",
+    "react-router-dom": "4.3.1",
+    "react-router-redux": "5.0.0-alpha.9",
+    "react-rte": "^0.16.5",
+    "react-scripts": "3.4.4",
+    "react-splitter-layout": "3.0.1",
+    "react-transition-group": "2.5.0",
+    "react-virtualized-auto-sizer": "1.0.2",
+    "react-window": "1.8.5",
+    "redux": "4.0.3",
+    "redux-devtools-extension": "^2.13.9",
+    "redux-form": "7.4.2",
+    "redux-thunk": "2.3.0",
+    "reselect": "4.0.0",
+    "set-value": "2.0.1",
+    "shell-escape": "^0.2.0",
+    "sinon": "7.3",
+    "tippy.js": "^6.3.7",
+    "unionize": "2.1.2",
+    "uuid": "3.3.2"
+  },
+  "scripts": {
+    "start": "BROWSER=none react-scripts start",
+    "build": "REACT_APP_VERSION=$VERSION REACT_APP_BUILD_NUMBER=$BUILD_NUMBER REACT_APP_GIT_COMMIT=$GIT_COMMIT react-scripts build",
+    "build-local": "react-scripts build",
+    "test": "CI=true react-scripts test",
+    "test-local": "react-scripts test",
+    "eject": "react-scripts eject",
+    "lint": "tslint src/** -t verbose",
+    "build-css": "node-sass-chokidar src/ -o src/",
+    "watch-css": "npm run build-css && node-sass-chokidar src/ -o src/ --watch --recursive"
+  },
+  "devDependencies": {
+    "@sinonjs/fake-timers": "^10.3.0",
+    "@types/classnames": "2.2.6",
+    "@types/enzyme": "3.1.14",
+    "@types/enzyme-adapter-react-16": "1.0.3",
+    "@types/is-image": "3.0.0",
+    "@types/jest": "26.0.23",
+    "@types/node": "15.12.4",
+    "@types/react-dom": "17.0.8",
+    "@types/react-redux": "6.0.9",
+    "@types/react-router": "4.0.31",
+    "@types/react-router-dom": "4.3.1",
+    "@types/react-router-redux": "5.0.16",
+    "@types/redux-devtools": "3.0.44",
+    "@types/redux-mock-store": "1.0.2",
+    "@types/sinon": "7.5",
+    "@types/uuid": "3.4.4",
+    "axios-mock-adapter": "1.17.0",
+    "cypress": "^13.6.6",
+    "cypress-wait-until": "^3.0.1",
+    "enzyme": "3.11.0",
+    "enzyme-adapter-react-16": "1.15.6",
+    "jest-localstorage-mock": "2.2.0",
+    "node-sass": "^9.0.0",
+    "node-sass-chokidar": "^2.0.0",
+    "redux-devtools": "3.4.1",
+    "redux-mock-store": "1.5.4",
+    "ts-mock-imports": "1.3.7",
+    "tslint": "5.20.0",
+    "tslint-etc": "1.6.0",
+    "typescript": "4.3.4",
+    "wait-on": "4.0.2",
+    "yamljs": "0.3.0"
+  },
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not op_mini all"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  },
+  "packageManager": "yarn@3.2.0"
+}
diff --git a/services/workbench2/public/arvados_logo.png b/services/workbench2/public/arvados_logo.png
new file mode 100644 (file)
index 0000000..cdb8160
Binary files /dev/null and b/services/workbench2/public/arvados_logo.png differ
diff --git a/services/workbench2/public/file-viewers-example.json b/services/workbench2/public/file-viewers-example.json
new file mode 100644 (file)
index 0000000..27adb70
--- /dev/null
@@ -0,0 +1,25 @@
+[
+    {
+        "name": "File browser",
+        "extensions": [
+            ".txt",
+            ".zip"
+        ],
+        "url": "https://doc.arvados.org",
+        "filePathParam": "filePath",
+        "iconUrl": "https://material.io/tools/icons/static/icons/baseline-next_week-24px.svg"
+    },
+    {
+        "name": "Collection browser",
+        "extensions": [],
+        "collections": true,
+        "url": "https://doc.arvados.org",
+        "filePathParam": "collectionPath"
+    },
+    {
+        "name": "Universal browser",
+        "collections": true,
+        "url": "https://doc.arvados.org",
+        "filePathParam": "filePath"
+    }
+]
\ No newline at end of file
diff --git a/services/workbench2/public/index.html b/services/workbench2/public/index.html
new file mode 100644 (file)
index 0000000..84b9282
--- /dev/null
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="theme-color" content="#000000">
+    <!--
+      manifest.json provides metadata used when your web app is added to the
+      homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
+    -->
+    <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
+    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico?v=1">
+    <link href="//netdna.bootstrapcdn.com/font-awesome/3.2.1/css/font-awesome.css" rel="stylesheet">
+    <!--
+      Notice the use of %PUBLIC_URL% in the tags above.
+      It will be replaced with the URL of the `public` folder during the build.
+      Only files inside the `public` folder can be referenced from the HTML.
+
+      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+      work correctly both with client-side routing and a non-root public URL.
+      Learn how to configure a non-root public URL by running `npm run build`.
+    -->
+    <title>Arvados Workbench 2</title>
+    <script>FontAwesomeConfig = { autoReplaceSvg: 'nest' }</script>
+    <script defer src="https://use.fontawesome.com/releases/v5.0.13/js/all.js" integrity="sha384-xymdQtn1n3lH2wcu0qhcdaOpQwyoarkgLVxC/wZ5q7h9gHtxICrpcaSUfygqZGOe" crossorigin="anonymous"></script>
+  </head>
+  <body>
+    <noscript>
+      You need to enable JavaScript to run this app.
+    </noscript>
+    <div id="root"></div>
+    <!--
+      This HTML file is a template.
+      If you open it directly in the browser, you will see an empty page.
+
+      You can add webfonts, meta tags, or analytics to this file.
+      The build step will place the bundled scripts into the <body> tag.
+
+      To begin the development, run `npm start` or `yarn start`.
+      To create a production bundle, use `npm run build` or `yarn build`.
+    -->
+  </body>
+</html>
diff --git a/services/workbench2/public/manifest.json b/services/workbench2/public/manifest.json
new file mode 100644 (file)
index 0000000..9f9dcf7
--- /dev/null
@@ -0,0 +1,15 @@
+{
+  "short_name": "Arvados Workbench 2",
+  "name": "Arvados Workbench 2",
+  "icons": [
+    {
+      "src": "favicon.ico",
+      "sizes": "64x64 32x32 24x24 16x16",
+      "type": "image/x-icon"
+    }
+  ],
+  "start_url": "./index.html",
+  "display": "standalone",
+  "theme_color": "#000000",
+  "background_color": "#ffffff"
+}
diff --git a/services/workbench2/public/mui-start-icon.svg b/services/workbench2/public/mui-start-icon.svg
new file mode 100644 (file)
index 0000000..3140cc3
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M80-240v-480h80v480H80Zm560 0-57-56 144-144H240v-80h487L584-664l56-56 240 240-240 240Z"/></svg>
\ No newline at end of file
diff --git a/services/workbench2/public/webshell/index.html b/services/workbench2/public/webshell/index.html
new file mode 100644 (file)
index 0000000..aae70a9
--- /dev/null
@@ -0,0 +1,141 @@
+<!DOCTYPE html>
+    <head>
+    <title></title>
+    <link rel="stylesheet" href="styles.css" type="text/css">
+    <style type="text/css">
+      body {
+        margin: 0px;
+      }
+      #notoken {
+        position: absolute;
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        text-align: center;
+        vertical-align: middle;
+        line-height: 100vh;
+        z-index: 100;
+        font-family: sans;
+      }
+    </style>
+    <script type="text/javascript"><!--
+      (function() {
+        // We would like to hide overflowing lines as this can lead to
+        // visually jarring results if the browser substitutes oversized
+        // Unicode characters from different fonts. Unfortunately, a bug
+        // in Firefox prevents it from allowing multi-line text
+        // selections whenever we change the "overflow" style. So, only
+        // do so for non-Netscape browsers.
+        if (typeof navigator.appName == 'undefined' ||
+            navigator.appName != 'Netscape') {
+          document.write('<style type="text/css">' +
+                         '#vt100 #console div, #vt100 #alt_console div {' +
+                         '  overflow: hidden;' +
+                         '}' +
+                         '</style>');
+        }
+      })();
+      var sh;
+      var urlParams = new URLSearchParams(window.location.search);
+      var token = urlParams.get('token');
+      var user = urlParams.get('login');
+      var host = urlParams.get('host');
+      var timeout = urlParams.get('timeout');
+      urlParams = null;
+
+      var idleTimeoutMs = timeout * 1000;
+
+      function updateIdleTimer() {
+        var currentTime = Date.now();
+        var lastTime = localStorage.getItem('lastActiveTimestamp');
+        if (currentTime - lastTime > 1000) {
+          localStorage.setItem('lastActiveTimestamp', currentTime);
+        }
+      }
+
+      function checkIdleTimer() {
+        var currentTime = Date.now();
+        var lastTime = localStorage.getItem('lastActiveTimestamp');
+        if (currentTime - lastTime > idleTimeoutMs) {
+          //logout
+          sh.reset();
+          sh.sessionClosed("Session timed out after " + timeout + " seconds.");
+          document.body.onmousemove = undefined;
+          document.body.onkeydown = undefined;
+        } else {
+          setTimeout(checkIdleTimer, 1000);
+        }
+      }
+
+      function login() {
+        sh = new ShellInABox(host);
+
+        var findText = function(txt) {
+          var a = document.querySelectorAll("span.ansi0");
+          for (var i = 0; i < a.length; i++) {
+            if (a[i].textContent.indexOf(txt) > -1) {
+              return true;
+            }
+          }
+          return false;
+        }
+
+        var trySendToken = function() {
+          // change this text when PAM is reconfigured to present a
+          // password prompt that we can wait for.
+          if (findText("assword:")) {
+             sh.keysPressed(token + "\n");
+             sh.vt100('(sent authentication token)\n');
+             token = null;
+             if (timeout > 0) {
+               updateIdleTimer();
+               document.body.onmousemove = updateIdleTimer;
+               document.body.onkeydown = updateIdleTimer;
+               setTimeout(checkIdleTimer, 1000);
+             }
+          } else {
+            setTimeout(trySendToken, 200);
+          }
+        };
+
+        var trySendLogin = function() {
+          if (findText("login:")) {
+            sh.keysPressed(user + "\n");
+            // Make this wait shorter when PAM is reconfigured to
+            // present a password prompt that we can wait for.
+            setTimeout(trySendToken, 200);
+          } else {
+            setTimeout(trySendLogin, 200);
+          }
+        };
+
+        trySendLogin();
+      }
+
+      function init() {
+        if (token) {
+          history.replaceState(null, "", `/webshell/?host=${encodeURIComponent(host)}&timeout=${timeout}&login=${encodeURIComponent(user)}`);
+        } else if (localStorage.getItem('apiToken')) {
+          token = localStorage.getItem('apiToken');
+        } else {
+          document.getElementById("notoken").style.display = "block";
+          return;
+        }
+        login();
+      }
+    // -->
+</script>
+    <script type="text/javascript" src="shell_in_a_box.js"></script>
+  </head>
+  <!-- Load ShellInABox from a timer as Konqueror sometimes fails to
+       correctly deal with the enclosing frameset (if any), if we do not
+       do this
+   -->
+<body onload="setTimeout(init, 1000)"
+    scroll="no"><noscript>JavaScript must be enabled for ShellInABox</noscript>
+    <div id="notoken" style="display: none;">
+      Error: No token found. Please return to <a href="/virtual-machines-user">Virtual Machines</a> and try again.
+    </div>
+</body>
+</html>
diff --git a/services/workbench2/public/webshell/keyboard.html b/services/workbench2/public/webshell/keyboard.html
new file mode 100644 (file)
index 0000000..6a95f3b
--- /dev/null
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xml:lang="en" lang="en">
+<head>
+</head>
+<body><pre class="box"><div
+  ><i id="27">Esc</i><i id="112">F1</i><i id="113">F2</i><i id="114">F3</i
+  ><i id="115">F4</i><i id="116">F5</i><i id="117">F6</i><i id="118">F7</i
+  ><i id="119">F8</i><i id="120">F9</i><i id="121">F10</i><i id="122">F11</i
+  ><i id="123">F12</i><br
+  /><b><span class="unshifted">`</span><span class="shifted">~</span></b
+    ><b><span class="unshifted">1</span><span class="shifted">!</span></b
+    ><b><span class="unshifted">2</span><span class="shifted">@</span></b
+    ><b><span class="unshifted">3</span><span class="shifted">#</span></b
+    ><b><span class="unshifted">4</span><span class="shifted">&#36;</span></b
+    ><b><span class="unshifted">5</span><span class="shifted">&#37;</span></b
+    ><b><span class="unshifted">6</span><span class="shifted">^</span></b
+    ><b><span class="unshifted">7</span><span class="shifted">&amp;</span></b
+    ><b><span class="unshifted">8</span><span class="shifted">*</span></b
+    ><b><span class="unshifted">9</span><span class="shifted">(</span></b
+    ><b><span class="unshifted">0</span><span class="shifted">)</span></b
+    ><b><span class="unshifted">-</span><span class="shifted">_</span></b
+    ><b><span class="unshifted">=</span><span class="shifted">+</span></b
+    ><i id="8">&nbsp;&larr;&nbsp;</i
+    ><br
+  /><i id="9">Tab</i
+    ><b>Q</b><b>W</b><b>E</b><b>R</b><b>T</b><b>Y</b><b>U</b><b>I</b><b>O</b
+    ><b>P</b
+    ><b><span class="unshifted">[</span><span class="shifted">{</span></b
+    ><b><span class="unshifted">]</span><span class="shifted">}</span></b
+    ><b><span class="unshifted">&#92;</span><span class="shifted">|</span></b
+    ><br
+  /><u>Tab&nbsp;&nbsp;</u
+    ><b>A</b><b>S</b><b>D</b><b>F</b><b>G</b><b>H</b><b>J</b><b>K</b><b>L</b
+    ><b><span class="unshifted">;</span><span class="shifted">:</span></b
+    ><b><span class="unshifted">&#39;</span><span class="shifted">"</span></b
+    ><i id="13">Enter</i
+    ><br
+  /><u>&nbsp;&nbsp;</u
+    ><i id="16">Shift</i
+    ><b>Z</b><b>X</b><b>C</b><b>V</b><b>B</b><b>N</b><b>M</b
+    ><b><span class="unshifted">,</span><span class="shifted">&lt;</span></b
+    ><b><span class="unshifted">.</span><span class="shifted">&gt;</span></b
+    ><b><span class="unshifted">/</span><span class="shifted">?</span></b
+    ><i id="16">Shift</i
+    ><br
+  /><u>XXX</u
+    ><i id="17">Ctrl</i
+    ><i id="18">Alt</i
+    ><i style="width: 25ex">&nbsp</i
+  ></div
+  >&nbsp;&nbsp;&nbsp;<div
+    ><i id="45">Ins</i><i id="46">Del</i><i id="36">Home</i><i id="35">End</i
+    ><br
+    /><u>&nbsp;</u><br
+    /><u>&nbsp;</u><br
+    /><u>Ins</u><s>&nbsp;</s><b id="38">&uarr;</b><s>&nbsp;</s><u>&nbsp;</u
+      ><b id="33">&uArr;</b><br
+    /><u>Ins</u><b id="37">&larr;</b><b id="40">&darr;</b
+      ><b id="39">&rarr;</b><u>&nbsp;</u><b id="34">&dArr;</b
+  ></div
+></pre></body></html>
diff --git a/services/workbench2/public/webshell/shell_in_a_box.js b/services/workbench2/public/webshell/shell_in_a_box.js
new file mode 100644 (file)
index 0000000..6b0a5b6
--- /dev/null
@@ -0,0 +1,4839 @@
+// Copyright (C) 2008-2010 Markus Gutschke <markus@shellinabox.com> All rights reserved.
+//
+// SPDX-License-Identifier: GPL-2.0
+
+// This file contains code from shell_in_a_box.js and vt100.js
+
+
+// ShellInABox.js -- Use XMLHttpRequest to provide an AJAX terminal emulator.
+// Copyright (C) 2008-2010 Markus Gutschke <markus@shellinabox.com>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program; if not, write to the Free Software Foundation, Inc.,
+// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+//
+// In addition to these license terms, the author grants the following
+// additional rights:
+//
+// If you modify this program, or any covered work, by linking or
+// combining it with the OpenSSL project's OpenSSL library (or a
+// modified version of that library), containing parts covered by the
+// terms of the OpenSSL or SSLeay licenses, the author
+// grants you additional permission to convey the resulting work.
+// Corresponding Source for a non-source form of such a combination
+// shall include the source code for the parts of OpenSSL used as well
+// as that of the covered work.
+//
+// You may at your option choose to remove this additional permission from
+// the work, or from any part of it.
+//
+// It is possible to build this program in a way that it loads OpenSSL
+// libraries at run-time. If doing so, the following notices are required
+// by the OpenSSL and SSLeay licenses:
+//
+// This product includes software developed by the OpenSSL Project
+// for use in the OpenSSL Toolkit. (http://www.openssl.org/)
+//
+// This product includes cryptographic software written by Eric Young
+// (eay@cryptsoft.com)
+//
+//
+// The most up-to-date version of this program is always available from
+// http://shellinabox.com
+//
+//
+// Notes:
+//
+// The author believes that for the purposes of this license, you meet the
+// requirements for publishing the source code, if your web server publishes
+// the source in unmodified form (i.e. with licensing information, comments,
+// formatting, and identifier names intact). If there are technical reasons
+// that require you to make changes to the source code when serving the
+// JavaScript (e.g to remove pre-processor directives from the source), these
+// changes should be done in a reversible fashion.
+//
+// The author does not consider websites that reference this script in
+// unmodified form, and web servers that serve this script in unmodified form
+// to be derived works. As such, they are believed to be outside of the
+// scope of this license and not subject to the rights or restrictions of the
+// GNU General Public License.
+//
+// If in doubt, consult a legal professional familiar with the laws that
+// apply in your country.
+
+// #define XHR_UNITIALIZED 0
+// #define XHR_OPEN        1
+// #define XHR_SENT        2
+// #define XHR_RECEIVING   3
+// #define XHR_LOADED      4
+
+// IE does not define XMLHttpRequest by default, so we provide a suitable
+// wrapper.
+if (typeof XMLHttpRequest == 'undefined') {
+  XMLHttpRequest = function() {
+    try { return new ActiveXObject('Msxml2.XMLHTTP.6.0');} catch (e) { }
+    try { return new ActiveXObject('Msxml2.XMLHTTP.3.0');} catch (e) { }
+    try { return new ActiveXObject('Msxml2.XMLHTTP');    } catch (e) { }
+    try { return new ActiveXObject('Microsoft.XMLHTTP'); } catch (e) { }
+    throw new Error('');
+  };
+}
+
+function extend(subClass, baseClass) {
+  function inheritance() { }
+  inheritance.prototype          = baseClass.prototype;
+  subClass.prototype             = new inheritance();
+  subClass.prototype.constructor = subClass;
+  subClass.prototype.superClass  = baseClass.prototype;
+};
+
+function ShellInABox(url, container) {
+  if (url == undefined) {
+    this.rooturl    = document.location.href;
+    this.url        = document.location.href.replace(/[?#].*/, '');
+  } else {
+    this.rooturl    = url;
+    this.url        = url;
+  }
+  if (document.location.hash != '') {
+    var hash        = decodeURIComponent(document.location.hash).
+                      replace(/^#/, '');
+    this.nextUrl    = hash.replace(/,.*/, '');
+    this.session    = hash.replace(/[^,]*,/, '');
+  } else {
+    this.nextUrl    = this.url;
+    this.session    = null;
+  }
+  this.pendingKeys  = '';
+  this.keysInFlight = false;
+  this.connected    = false;
+  this.superClass.constructor.call(this, container);
+
+  // We have to initiate the first XMLHttpRequest from a timer. Otherwise,
+  // Chrome never realizes that the page has loaded.
+  setTimeout(function(shellInABox) {
+               return function() {
+                 shellInABox.sendRequest(true);
+               };
+             }(this), 1);
+};
+extend(ShellInABox, VT100);
+
+ShellInABox.prototype.sessionClosed = function(msg) {
+  try {
+    this.connected    = false;
+    if (this.session) {
+      this.session    = undefined;
+      if (this.cursorX > 0) {
+        this.vt100('\r\n');
+      }
+      this.vt100(msg || 'Session closed.');
+      this.currentRequest.abort();
+    }
+    // Revealing the "reconnect" button is commented out until we hook
+    // up the username+token auto-login mechanism to the new session:
+    //this.showReconnect(true);
+  } catch (e) {
+  }
+};
+
+ShellInABox.prototype.reconnect = function() {
+  this.showReconnect(false);
+  if (!this.session) {
+    if (document.location.hash != '') {
+      // A shellinaboxd daemon launched from a CGI only allows a single
+      // session. In order to reconnect, we must reload the frame definition
+      // and obtain a new port number. As this is a different origin, we
+      // need to get enclosing page to help us.
+      parent.location        = this.nextUrl;
+    } else {
+      if (this.url != this.nextUrl) {
+        document.location.replace(this.nextUrl);
+      } else {
+        this.pendingKeys     = '';
+        this.keysInFlight    = false;
+        this.reset(true);
+        this.sendRequest(true);
+      }
+    }
+  }
+  return false;
+};
+
+ShellInABox.prototype.sendRequest = function(init = false, request) {
+  if (request == undefined) {
+    request                  = new XMLHttpRequest();
+  }
+  request.open('POST', this.url + '?', true);
+  request.setRequestHeader('Cache-Control', 'no-cache');
+  request.setRequestHeader('Content-Type',
+                           'application/x-www-form-urlencoded; charset=utf-8');
+  var content                = 'width=' + this.terminalWidth +
+                               '&height=' + this.terminalHeight +
+                               (this.session ? '&session=' +
+                                encodeURIComponent(this.session) : '&rooturl='+
+                                encodeURIComponent(this.rooturl));
+
+  request.onreadystatechange = function(shellInABox) {
+    return function() {
+             try {
+               return shellInABox.onReadyStateChange(request, init);
+             } catch (e) {
+               shellInABox.sessionClosed();
+             }
+           }
+    }(this);
+  ShellInABox.lastRequestSent = Date.now();
+  request.send(content);
+  this.currentRequest = request;
+};
+
+ShellInABox.prototype.onReadyStateChange = function(request, init) {
+  if (request.readyState == 4 /* XHR_LOADED */ && (this.connected || init)) {
+    if (request.status == 200) {
+      this.connected = true;
+      var response   = eval('(' + request.responseText + ')');
+      if (response.data) {
+        this.vt100(response.data);
+      }
+
+      if (!response.session ||
+          this.session && this.session != response.session) {
+        this.sessionClosed();
+      } else {
+        this.session = response.session;
+        this.sendRequest(false, request);
+      }
+    } else if (request.status == 0) {
+        if (ShellInABox.lastRequestSent + 2000 < Date.now()) {
+            // Timeout, try again
+            this.sendRequest(false, request);
+        } else {
+            this.vt100('\r\n\r\nRequest failed.');
+            this.sessionClosed();
+        }
+    } else {
+      this.sessionClosed();
+    }
+  }
+};
+
+ShellInABox.prototype.sendKeys = function(keys) {
+  if (!this.connected) {
+    return;
+  }
+  if (this.keysInFlight || this.session == undefined) {
+    this.pendingKeys          += keys;
+  } else {
+    this.keysInFlight          = true;
+    keys                       = this.pendingKeys + keys;
+    this.pendingKeys           = '';
+    var request                = new XMLHttpRequest();
+    request.open('POST', this.url + '?', true);
+    request.setRequestHeader('Cache-Control', 'no-cache');
+    request.setRequestHeader('Content-Type',
+                           'application/x-www-form-urlencoded; charset=utf-8');
+    var content                = 'width=' + this.terminalWidth +
+                                 '&height=' + this.terminalHeight +
+                                 '&session=' +encodeURIComponent(this.session)+
+                                 '&keys=' + encodeURIComponent(keys);
+    request.onreadystatechange = function(shellInABox) {
+      return function() {
+               try {
+                 return shellInABox.keyPressReadyStateChange(request);
+               } catch (e) {
+               }
+             }
+      }(this);
+    request.send(content);
+  }
+};
+
+ShellInABox.prototype.keyPressReadyStateChange = function(request) {
+  if (request.readyState == 4 /* XHR_LOADED */) {
+    this.keysInFlight = false;
+    if (this.pendingKeys) {
+      this.sendKeys('');
+    }
+  }
+};
+
+ShellInABox.prototype.keysPressed = function(ch) {
+  var hex = '0123456789ABCDEF';
+  var s   = '';
+  for (var i = 0; i < ch.length; i++) {
+    var c = ch.charCodeAt(i);
+    if (c < 128) {
+      s += hex.charAt(c >> 4) + hex.charAt(c & 0xF);
+    } else if (c < 0x800) {
+      s += hex.charAt(0xC +  (c >> 10)       ) +
+           hex.charAt(       (c >>  6) & 0xF ) +
+           hex.charAt(0x8 + ((c >>  4) & 0x3)) +
+           hex.charAt(        c        & 0xF );
+    } else if (c < 0x10000) {
+      s += 'E'                                 +
+           hex.charAt(       (c >> 12)       ) +
+           hex.charAt(0x8 + ((c >> 10) & 0x3)) +
+           hex.charAt(       (c >>  6) & 0xF ) +
+           hex.charAt(0x8 + ((c >>  4) & 0x3)) +
+           hex.charAt(        c        & 0xF );
+    } else if (c < 0x110000) {
+      s += 'F'                                 +
+           hex.charAt(       (c >> 18)       ) +
+           hex.charAt(0x8 + ((c >> 16) & 0x3)) +
+           hex.charAt(       (c >> 12) & 0xF ) +
+           hex.charAt(0x8 + ((c >> 10) & 0x3)) +
+           hex.charAt(       (c >>  6) & 0xF ) +
+           hex.charAt(0x8 + ((c >>  4) & 0x3)) +
+           hex.charAt(        c        & 0xF );
+    }
+  }
+  this.sendKeys(s);
+};
+
+ShellInABox.prototype.resized = function(w, h) {
+  // Do not send a resize request until we are fully initialized.
+  if (this.session) {
+    // sendKeys() always transmits the current terminal size. So, flush all
+    // pending keys.
+    this.sendKeys('');
+  }
+};
+
+ShellInABox.prototype.toggleSSL = function() {
+  if (document.location.hash != '') {
+    if (this.nextUrl.match(/\?plain$/)) {
+      this.nextUrl    = this.nextUrl.replace(/\?plain$/, '');
+    } else {
+      this.nextUrl    = this.nextUrl.replace(/[?#].*/, '') + '?plain';
+    }
+    if (!this.session) {
+      parent.location = this.nextUrl;
+    }
+  } else {
+    this.nextUrl      = this.nextUrl.match(/^https:/)
+           ? this.nextUrl.replace(/^https:/, 'http:').replace(/\/*$/, '/plain')
+           : this.nextUrl.replace(/^http/, 'https').replace(/\/*plain$/, '');
+  }
+  if (this.nextUrl.match(/^[:]*:\/\/[^/]*$/)) {
+    this.nextUrl     += '/';
+  }
+  if (this.session && this.nextUrl != this.url) {
+    alert('This change will take effect the next time you login.');
+  }
+};
+
+ShellInABox.prototype.extendContextMenu = function(entries, actions) {
+  // Modify the entries and actions in place, adding any locally defined
+  // menu entries.
+  var oldActions            = [ ];
+  for (var i = 0; i < actions.length; i++) {
+    oldActions[i]           = actions[i];
+  }
+  for (var node = entries.firstChild, i = 0, j = 0; node;
+       node = node.nextSibling) {
+    if (node.tagName == 'LI') {
+      actions[i++]          = oldActions[j++];
+      if (node.id == "endconfig") {
+        node.id             = '';
+        if (typeof serverSupportsSSL != 'undefined' && serverSupportsSSL &&
+            !(typeof disableSSLMenu != 'undefined' && disableSSLMenu)) {
+          // If the server supports both SSL and plain text connections,
+          // provide a menu entry to switch between the two.
+          var newNode       = document.createElement('li');
+          var isSecure;
+          if (document.location.hash != '') {
+            isSecure        = !this.nextUrl.match(/\?plain$/);
+          } else {
+            isSecure        =  this.nextUrl.match(/^https:/);
+          }
+          newNode.innerHTML = (isSecure ? '&#10004; ' : '') + 'Secure';
+          if (node.nextSibling) {
+            entries.insertBefore(newNode, node.nextSibling);
+          } else {
+            entries.appendChild(newNode);
+          }
+          actions[i++]      = this.toggleSSL;
+          node              = newNode;
+        }
+        node.id             = 'endconfig';
+      }
+    }
+  }
+
+};
+
+ShellInABox.prototype.about = function() {
+  alert("Shell In A Box version " + "2.10 (revision 239)" +
+        "\nCopyright 2008-2010 by Markus Gutschke\n" +
+        "For more information check http://shellinabox.com" +
+        (typeof serverSupportsSSL != 'undefined' && serverSupportsSSL ?
+         "\n\n" +
+         "This product includes software developed by the OpenSSL Project\n" +
+         "for use in the OpenSSL Toolkit. (http://www.openssl.org/)\n" +
+         "\n" +
+         "This product includes cryptographic software written by " +
+         "Eric Young\n(eay@cryptsoft.com)" :
+         ""));
+};
+
+
+// VT100.js -- JavaScript based terminal emulator
+// Copyright (C) 2008-2010 Markus Gutschke <markus@shellinabox.com>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program; if not, write to the Free Software Foundation, Inc.,
+// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+//
+// In addition to these license terms, the author grants the following
+// additional rights:
+//
+// If you modify this program, or any covered work, by linking or
+// combining it with the OpenSSL project's OpenSSL library (or a
+// modified version of that library), containing parts covered by the
+// terms of the OpenSSL or SSLeay licenses, the author
+// grants you additional permission to convey the resulting work.
+// Corresponding Source for a non-source form of such a combination
+// shall include the source code for the parts of OpenSSL used as well
+// as that of the covered work.
+//
+// You may at your option choose to remove this additional permission from
+// the work, or from any part of it.
+//
+// It is possible to build this program in a way that it loads OpenSSL
+// libraries at run-time. If doing so, the following notices are required
+// by the OpenSSL and SSLeay licenses:
+//
+// This product includes software developed by the OpenSSL Project
+// for use in the OpenSSL Toolkit. (http://www.openssl.org/)
+//
+// This product includes cryptographic software written by Eric Young
+// (eay@cryptsoft.com)
+//
+//
+// The most up-to-date version of this program is always available from
+// http://shellinabox.com
+//
+//
+// Notes:
+//
+// The author believes that for the purposes of this license, you meet the
+// requirements for publishing the source code, if your web server publishes
+// the source in unmodified form (i.e. with licensing information, comments,
+// formatting, and identifier names intact). If there are technical reasons
+// that require you to make changes to the source code when serving the
+// JavaScript (e.g to remove pre-processor directives from the source), these
+// changes should be done in a reversible fashion.
+//
+// The author does not consider websites that reference this script in
+// unmodified form, and web servers that serve this script in unmodified form
+// to be derived works. As such, they are believed to be outside of the
+// scope of this license and not subject to the rights or restrictions of the
+// GNU General Public License.
+//
+// If in doubt, consult a legal professional familiar with the laws that
+// apply in your country.
+
+// #define ESnormal        0
+// #define ESesc           1
+// #define ESsquare        2
+// #define ESgetpars       3
+// #define ESgotpars       4
+// #define ESdeviceattr    5
+// #define ESfunckey       6
+// #define EShash          7
+// #define ESsetG0         8
+// #define ESsetG1         9
+// #define ESsetG2        10
+// #define ESsetG3        11
+// #define ESbang         12
+// #define ESpercent      13
+// #define ESignore       14
+// #define ESnonstd       15
+// #define ESpalette      16
+// #define EStitle        17
+// #define ESss2          18
+// #define ESss3          19
+
+// #define ATTR_DEFAULT   0x00F0
+// #define ATTR_REVERSE   0x0100
+// #define ATTR_UNDERLINE 0x0200
+// #define ATTR_DIM       0x0400
+// #define ATTR_BRIGHT    0x0800
+// #define ATTR_BLINK     0x1000
+
+// #define MOUSE_DOWN     0
+// #define MOUSE_UP       1
+// #define MOUSE_CLICK    2
+
+function VT100(container) {
+  if (typeof linkifyURLs == 'undefined' || linkifyURLs <= 0) {
+    this.urlRE            = null;
+  } else {
+    this.urlRE            = new RegExp(
+    // Known URL protocol are "http", "https", and "ftp".
+    '(?:http|https|ftp)://' +
+
+    // Optionally allow username and passwords.
+    '(?:[^:@/ \u00A0]*(?::[^@/ \u00A0]*)?@)?' +
+
+    // Hostname.
+    '(?:[1-9][0-9]{0,2}(?:[.][1-9][0-9]{0,2}){3}|' +
+    '[0-9a-fA-F]{0,4}(?::{1,2}[0-9a-fA-F]{1,4})+|' +
+    '(?!-)[^[!"#$%&\'()*+,/:;<=>?@\\^_`{|}~\u0000- \u007F-\u00A0]+)' +
+
+    // Port
+    '(?::[1-9][0-9]*)?' +
+
+    // Path.
+    '(?:/(?:(?![/ \u00A0]|[,.)}"\u0027!]+[ \u00A0]|[,.)}"\u0027!]+$).)*)*|' +
+
+    (linkifyURLs <= 1 ? '' :
+    // Also support URLs without a protocol (assume "http").
+    // Optional username and password.
+    '(?:[^:@/ \u00A0]*(?::[^@/ \u00A0]*)?@)?' +
+
+    // Hostnames must end with a well-known top-level domain or must be
+    // numeric.
+    '(?:[1-9][0-9]{0,2}(?:[.][1-9][0-9]{0,2}){3}|' +
+    'localhost|' +
+    '(?:(?!-)' +
+        '[^.[!"#$%&\'()*+,/:;<=>?@\\^_`{|}~\u0000- \u007F-\u00A0]+[.]){2,}' +
+    '(?:(?:com|net|org|edu|gov|aero|asia|biz|cat|coop|info|int|jobs|mil|mobi|'+
+    'museum|name|pro|tel|travel|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|' +
+    'au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|' +
+    'ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|' +
+    'dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|' +
+    'gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|' +
+    'ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|' +
+    'lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|' +
+    'mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|' +
+    'pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|' +
+    'sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|' +
+    'tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|' +
+    'yu|za|zm|zw|arpa)(?![a-zA-Z0-9])|[Xx][Nn]--[-a-zA-Z0-9]+))' +
+
+    // Port
+    '(?::[1-9][0-9]{0,4})?' +
+
+    // Path.
+    '(?:/(?:(?![/ \u00A0]|[,.)}"\u0027!]+[ \u00A0]|[,.)}"\u0027!]+$).)*)*|') +
+
+    // In addition, support e-mail address. Optionally, recognize "mailto:"
+    '(?:mailto:)' + (linkifyURLs <= 1 ? '' : '?') +
+
+    // Username:
+    '[-_.+a-zA-Z0-9]+@' +
+
+    // Hostname.
+    '(?!-)[-a-zA-Z0-9]+(?:[.](?!-)[-a-zA-Z0-9]+)?[.]' +
+    '(?:(?:com|net|org|edu|gov|aero|asia|biz|cat|coop|info|int|jobs|mil|mobi|'+
+    'museum|name|pro|tel|travel|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|' +
+    'au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|' +
+    'ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|' +
+    'dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|' +
+    'gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|' +
+    'ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|' +
+    'lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|' +
+    'mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|' +
+    'pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|' +
+    'sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|' +
+    'tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|' +
+    'yu|za|zm|zw|arpa)(?![a-zA-Z0-9])|[Xx][Nn]--[-a-zA-Z0-9]+)' +
+
+    // Optional arguments
+    '(?:[?](?:(?![ \u00A0]|[,.)}"\u0027!]+[ \u00A0]|[,.)}"\u0027!]+$).)*)?');
+  }
+  this.getUserSettings();
+  this.initializeElements(container);
+  this.maxScrollbackLines = 500;
+  this.npar               = 0;
+  this.par                = [ ];
+  this.isQuestionMark     = false;
+  this.savedX             = [ ];
+  this.savedY             = [ ];
+  this.savedAttr          = [ ];
+  this.savedUseGMap       = 0;
+  this.savedGMap          = [ this.Latin1Map, this.VT100GraphicsMap,
+                              this.CodePage437Map, this.DirectToFontMap ];
+  this.savedValid         = [ ];
+  this.respondString      = '';
+  this.titleString        = '';
+  this.internalClipboard  = undefined;
+  this.reset(true);
+}
+
+VT100.prototype.reset = function(clearHistory) {
+  this.isEsc                                         = 0 /* ESnormal */;
+  this.needWrap                                      = false;
+  this.autoWrapMode                                  = true;
+  this.dispCtrl                                      = false;
+  this.toggleMeta                                    = false;
+  this.insertMode                                    = false;
+  this.applKeyMode                                   = false;
+  this.cursorKeyMode                                 = false;
+  this.crLfMode                                      = false;
+  this.offsetMode                                    = false;
+  this.mouseReporting                                = false;
+  this.printing                                      = false;
+  if (typeof this.printWin != 'undefined' &&
+      this.printWin && !this.printWin.closed) {
+    this.printWin.close();
+  }
+  this.printWin                                      = null;
+  this.utfEnabled                                    = this.utfPreferred;
+  this.utfCount                                      = 0;
+  this.utfChar                                       = 0;
+  this.color                                         = 'ansi0 bgAnsi15';
+  this.style                                         = '';
+  this.attr                                          = 0x00F0 /* ATTR_DEFAULT */;
+  this.useGMap                                       = 0;
+  this.GMap                                          = [ this.Latin1Map,
+                                                         this.VT100GraphicsMap,
+                                                         this.CodePage437Map,
+                                                         this.DirectToFontMap];
+  this.translate                                     = this.GMap[this.useGMap];
+  this.top                                           = 0;
+  this.bottom                                        = this.terminalHeight;
+  this.lastCharacter                                 = ' ';
+  this.userTabStop                                   = [ ];
+
+  if (clearHistory) {
+    for (var i = 0; i < 2; i++) {
+      while (this.console[i].firstChild) {
+        this.console[i].removeChild(this.console[i].firstChild);
+      }
+    }
+  }
+
+  this.enableAlternateScreen(false);
+
+  var wasCompressed                                  = false;
+  var transform                                      = this.getTransformName();
+  if (transform) {
+    for (var i = 0; i < 2; ++i) {
+      wasCompressed                  |= this.console[i].style[transform] != '';
+      this.console[i].style[transform]               = '';
+    }
+    this.cursor.style[transform]                     = '';
+    this.space.style[transform]                      = '';
+    if (transform == 'filter') {
+      this.console[this.currentScreen].style.width   = '';
+    }
+  }
+  this.scale                                         = 1.0;
+  if (wasCompressed) {
+    this.resizer();
+  }
+
+  this.gotoXY(0, 0);
+  this.showCursor();
+  this.isInverted                                    = false;
+  this.refreshInvertedState();
+  this.clearRegion(0, 0, this.terminalWidth, this.terminalHeight,
+                   this.color, this.style);
+};
+
+VT100.prototype.addListener = function(elem, event, listener) {
+  try {
+    if (elem.addEventListener) {
+      elem.addEventListener(event, listener, false);
+    } else {
+      elem.attachEvent('on' + event, listener);
+    }
+  } catch (e) {
+  }
+};
+
+VT100.prototype.getUserSettings = function() {
+  // Compute hash signature to identify the entries in the userCSS menu.
+  // If the menu is unchanged from last time, default values can be
+  // looked up in a cookie associated with this page.
+  this.signature            = 3;
+  this.utfPreferred         = true;
+  this.visualBell           = typeof suppressAllAudio != 'undefined' &&
+                              suppressAllAudio;
+  this.autoprint            = true;
+  this.softKeyboard         = false;
+  this.blinkingCursor       = true;
+  if (this.visualBell) {
+    this.signature          = Math.floor(16807*this.signature + 1) %
+                                         ((1 << 31) - 1);
+  }
+  if (typeof userCSSList != 'undefined') {
+    for (var i = 0; i < userCSSList.length; ++i) {
+      var label             = userCSSList[i][0];
+      for (var j = 0; j < label.length; ++j) {
+        this.signature      = Math.floor(16807*this.signature+
+                                         label.charCodeAt(j)) %
+                                         ((1 << 31) - 1);
+      }
+      if (userCSSList[i][1]) {
+        this.signature      = Math.floor(16807*this.signature + 1) %
+                                         ((1 << 31) - 1);
+      }
+    }
+  }
+
+  var key                   = 'shellInABox=' + this.signature + ':';
+  var settings              = document.cookie.indexOf(key);
+  if (settings >= 0) {
+    settings                = document.cookie.substr(settings + key.length).
+                                                   replace(/([0-1]*).*/, "$1");
+    if (settings.length == 5 + (typeof userCSSList == 'undefined' ?
+                                0 : userCSSList.length)) {
+      this.utfPreferred     = settings.charAt(0) != '0';
+      this.visualBell       = settings.charAt(1) != '0';
+      this.autoprint        = settings.charAt(2) != '0';
+      this.softKeyboard     = settings.charAt(3) != '0';
+      this.blinkingCursor   = settings.charAt(4) != '0';
+      if (typeof userCSSList != 'undefined') {
+        for (var i = 0; i < userCSSList.length; ++i) {
+          userCSSList[i][2] = settings.charAt(i + 5) != '0';
+        }
+      }
+    }
+  }
+  this.utfEnabled           = this.utfPreferred;
+};
+
+VT100.prototype.storeUserSettings = function() {
+  var settings  = 'shellInABox=' + this.signature + ':' +
+                  (this.utfEnabled     ? '1' : '0') +
+                  (this.visualBell     ? '1' : '0') +
+                  (this.autoprint      ? '1' : '0') +
+                  (this.softKeyboard   ? '1' : '0') +
+                  (this.blinkingCursor ? '1' : '0');
+  if (typeof userCSSList != 'undefined') {
+    for (var i = 0; i < userCSSList.length; ++i) {
+      settings += userCSSList[i][2] ? '1' : '0';
+    }
+  }
+  var d         = new Date();
+  d.setDate(d.getDate() + 3653);
+  document.cookie = settings + ';expires=' + d.toGMTString();
+};
+
+VT100.prototype.initializeUserCSSStyles = function() {
+  this.usercssActions                    = [];
+  if (typeof userCSSList != 'undefined') {
+    var menu                             = '';
+    var group                            = '';
+    var wasSingleSel                     = 1;
+    var beginOfGroup                     = 0;
+    for (var i = 0; i <= userCSSList.length; ++i) {
+      if (i < userCSSList.length) {
+        var label                        = userCSSList[i][0];
+        var newGroup                     = userCSSList[i][1];
+        var enabled                      = userCSSList[i][2];
+
+        // Add user style sheet to document
+        var style                        = document.createElement('link');
+        var id                           = document.createAttribute('id');
+        id.nodeValue                     = 'usercss-' + i;
+        style.setAttributeNode(id);
+        var rel                          = document.createAttribute('rel');
+        rel.nodeValue                    = 'stylesheet';
+        style.setAttributeNode(rel);
+        var href                         = document.createAttribute('href');
+        href.nodeValue                   = 'usercss-' + i + '.css';
+        style.setAttributeNode(href);
+        var type                         = document.createAttribute('type');
+        type.nodeValue                   = 'text/css';
+        style.setAttributeNode(type);
+        document.getElementsByTagName('head')[0].appendChild(style);
+        style.disabled                   = !enabled;
+      }
+
+      // Add entry to menu
+      if (newGroup || i == userCSSList.length) {
+        if (beginOfGroup != 0 && (i - beginOfGroup > 1 || !wasSingleSel)) {
+          // The last group had multiple entries that are mutually exclusive;
+          // or the previous to last group did. In either case, we need to
+          // append a "<hr />" before we can add the last group to the menu.
+          menu                          += '<hr />';
+        }
+        wasSingleSel                     = i - beginOfGroup < 1;
+        menu                            += group;
+        group                            = '';
+
+        for (var j = beginOfGroup; j < i; ++j) {
+          this.usercssActions[this.usercssActions.length] =
+            function(vt100, current, begin, count) {
+
+              // Deselect all other entries in the group, then either select
+              // (for multiple entries in group) or toggle (for on/off entry)
+              // the current entry.
+              return function() {
+                var entry                = vt100.getChildById(vt100.menu,
+                                                              'beginusercss');
+                var i                    = -1;
+                var j                    = -1;
+                for (var c = count; c > 0; ++j) {
+                  if (entry.tagName == 'LI') {
+                    if (++i >= begin) {
+                      --c;
+                      var label          = vt100.usercss.childNodes[j];
+
+                      // Restore label to just the text content
+                      if (typeof label.textContent == 'undefined') {
+                        var s            = label.innerText;
+                        label.innerHTML  = '';
+                        label.appendChild(document.createTextNode(s));
+                      } else {
+                        label.textContent= label.textContent;
+                      }
+
+                      // User style sheets are numbered sequentially
+                      var sheet          = document.getElementById(
+                                                               'usercss-' + i);
+                      if (i == current) {
+                        if (count == 1) {
+                          sheet.disabled = !sheet.disabled;
+                        } else {
+                          sheet.disabled = false;
+                        }
+                        if (!sheet.disabled) {
+                          label.innerHTML= '<img src="/webshell/enabled.gif" />' +
+                                           label.innerHTML;
+                        }
+                      } else {
+                        sheet.disabled   = true;
+                      }
+                      userCSSList[i][2]  = !sheet.disabled;
+                    }
+                  }
+                  entry                  = entry.nextSibling;
+                }
+
+                // If the font size changed, adjust cursor and line dimensions
+                this.cursor.style.cssText= '';
+                this.cursorWidth         = this.cursor.clientWidth;
+                this.cursorHeight        = this.lineheight.clientHeight;
+                for (i = 0; i < this.console.length; ++i) {
+                  for (var line = this.console[i].firstChild; line;
+                       line = line.nextSibling) {
+                    line.style.height    = this.cursorHeight + 'px';
+                  }
+                }
+                vt100.resizer();
+              };
+            }(this, j, beginOfGroup, i - beginOfGroup);
+        }
+
+        if (i == userCSSList.length) {
+          break;
+        }
+
+        beginOfGroup                     = i;
+      }
+      // Collect all entries in a group, before attaching them to the menu.
+      // This is necessary as we don't know whether this is a group of
+      // mutually exclusive options (which should be separated by "<hr />" on
+      // both ends), or whether this is a on/off toggle, which can be grouped
+      // together with other on/off options.
+      group                             +=
+        '<li>' + (enabled ? '<img src="/webshell/enabled.gif" />' : '') +
+                 label +
+        '</li>';
+    }
+    this.usercss.innerHTML               = menu;
+  }
+};
+
+VT100.prototype.resetLastSelectedKey = function(e) {
+  var key                          = this.lastSelectedKey;
+  if (!key) {
+    return false;
+  }
+
+  var position                     = this.mousePosition(e);
+
+  // We don't get all the necessary events to reliably reselect a key
+  // if we moved away from it and then back onto it. We approximate the
+  // behavior by remembering the key until either we release the mouse
+  // button (we might never get this event if the mouse has since left
+  // the window), or until we move away too far.
+  var box                          = this.keyboard.firstChild;
+  if (position[0] <  box.offsetLeft + key.offsetWidth ||
+      position[1] <  box.offsetTop + key.offsetHeight ||
+      position[0] >= box.offsetLeft + box.offsetWidth - key.offsetWidth ||
+      position[1] >= box.offsetTop + box.offsetHeight - key.offsetHeight ||
+      position[0] <  box.offsetLeft + key.offsetLeft - key.offsetWidth ||
+      position[1] <  box.offsetTop + key.offsetTop - key.offsetHeight ||
+      position[0] >= box.offsetLeft + key.offsetLeft + 2*key.offsetWidth ||
+      position[1] >= box.offsetTop + key.offsetTop + 2*key.offsetHeight) {
+    if (this.lastSelectedKey.className) log.console('reset: deselecting');
+    this.lastSelectedKey.className = '';
+    this.lastSelectedKey           = undefined;
+  }
+  return false;
+};
+
+VT100.prototype.showShiftState = function(state) {
+  var style              = document.getElementById('shift_state');
+  if (state) {
+    this.setTextContentRaw(style,
+                           '#vt100 #keyboard .shifted {' +
+                             'display: inline }' +
+                           '#vt100 #keyboard .unshifted {' +
+                             'display: none }');
+  } else {
+    this.setTextContentRaw(style, '');
+  }
+  var elems              = this.keyboard.getElementsByTagName('I');
+  for (var i = 0; i < elems.length; ++i) {
+    if (elems[i].id == '16') {
+      elems[i].className = state ? 'selected' : '';
+    }
+  }
+};
+
+VT100.prototype.showCtrlState = function(state) {
+  var ctrl         = this.getChildById(this.keyboard, '17' /* Ctrl */);
+  if (ctrl) {
+    ctrl.className = state ? 'selected' : '';
+  }
+};
+
+VT100.prototype.showAltState = function(state) {
+  var alt         = this.getChildById(this.keyboard, '18' /* Alt */);
+  if (alt) {
+    alt.className = state ? 'selected' : '';
+  }
+};
+
+VT100.prototype.clickedKeyboard = function(e, elem, ch, key, shift, ctrl, alt){
+  var fake      = [ ];
+  fake.charCode = ch;
+  fake.keyCode  = key;
+  fake.ctrlKey  = ctrl;
+  fake.shiftKey = shift;
+  fake.altKey   = alt;
+  fake.metaKey  = alt;
+  return this.handleKey(fake);
+};
+
+VT100.prototype.addKeyBinding = function(elem, ch, key, CH, KEY) {
+  if (elem == undefined) {
+    return;
+  }
+  if (ch == '\u00A0') {
+    // &nbsp; should be treated as a regular space character.
+    ch                                  = ' ';
+  }
+  if (ch != undefined && CH == undefined) {
+    // For letter keys, we automatically compute the uppercase character code
+    // from the lowercase one.
+    CH                                  = ch.toUpperCase();
+  }
+  if (KEY == undefined && key != undefined) {
+    // Most keys have identically key codes for both lowercase and uppercase
+    // keypresses. Normally, only function keys would have distinct key codes,
+    // whereas regular keys have character codes.
+    KEY                                 = key;
+  } else if (KEY == undefined && CH != undefined) {
+    // For regular keys, copy the character code to the key code.
+    KEY                                 = CH.charCodeAt(0);
+  }
+  if (key == undefined && ch != undefined) {
+    // For regular keys, copy the character code to the key code.
+    key                                 = ch.charCodeAt(0);
+  }
+  // Convert characters to numeric character codes. If the character code
+  // is undefined (i.e. this is a function key), set it to zero.
+  ch                                    = ch ? ch.charCodeAt(0) : 0;
+  CH                                    = CH ? CH.charCodeAt(0) : 0;
+
+  // Mouse down events high light the key. We also set lastSelectedKey. This
+  // is needed to that mouseout/mouseover can keep track of the key that
+  // is currently being clicked.
+  this.addListener(elem, 'mousedown',
+    function(vt100, elem, key) { return function(e) {
+      if ((e.which || e.button) == 1) {
+        if (vt100.lastSelectedKey) {
+          vt100.lastSelectedKey.className= '';
+        }
+        // Highlight the key while the mouse button is held down.
+        if (key == 16 /* Shift */) {
+          if (!elem.className != vt100.isShift) {
+            vt100.showShiftState(!vt100.isShift);
+          }
+        } else if (key == 17 /* Ctrl */) {
+          if (!elem.className != vt100.isCtrl) {
+            vt100.showCtrlState(!vt100.isCtrl);
+          }
+        } else if (key == 18 /* Alt */) {
+          if (!elem.className != vt100.isAlt) {
+            vt100.showAltState(!vt100.isAlt);
+          }
+        } else {
+          elem.className                  = 'selected';
+        }
+        vt100.lastSelectedKey             = elem;
+      }
+      return false; }; }(this, elem, key));
+  var clicked                           =
+    // Modifier keys update the state of the keyboard, but do not generate
+    // any key clicks that get forwarded to the application.
+    key >= 16 /* Shift */ && key <= 18 /* Alt */ ?
+    function(vt100, elem) { return function(e) {
+      if (elem == vt100.lastSelectedKey) {
+        if (key == 16 /* Shift */) {
+          // The user clicked the Shift key
+          vt100.isShift                 = !vt100.isShift;
+          vt100.showShiftState(vt100.isShift);
+        } else if (key == 17 /* Ctrl */) {
+          vt100.isCtrl                  = !vt100.isCtrl;
+          vt100.showCtrlState(vt100.isCtrl);
+        } else if (key == 18 /* Alt */) {
+          vt100.isAlt                   = !vt100.isAlt;
+          vt100.showAltState(vt100.isAlt);
+        }
+        vt100.lastSelectedKey           = undefined;
+      }
+      if (vt100.lastSelectedKey) {
+        vt100.lastSelectedKey.className = '';
+        vt100.lastSelectedKey           = undefined;
+      }
+      return false; }; }(this, elem) :
+    // Regular keys generate key clicks, when the mouse button is released or
+    // when a mouse click event is received.
+    function(vt100, elem, ch, key, CH, KEY) { return function(e) {
+      if (vt100.lastSelectedKey) {
+        if (elem == vt100.lastSelectedKey) {
+          // The user clicked a key.
+          if (vt100.isShift) {
+            vt100.clickedKeyboard(e, elem, CH, KEY,
+                                  true, vt100.isCtrl, vt100.isAlt);
+          } else {
+            vt100.clickedKeyboard(e, elem, ch, key,
+                                  false, vt100.isCtrl, vt100.isAlt);
+          }
+          vt100.isShift                 = false;
+          vt100.showShiftState(false);
+          vt100.isCtrl                  = false;
+          vt100.showCtrlState(false);
+          vt100.isAlt                   = false;
+          vt100.showAltState(false);
+        }
+        vt100.lastSelectedKey.className = '';
+        vt100.lastSelectedKey           = undefined;
+      }
+      elem.className                    = '';
+      return false; }; }(this, elem, ch, key, CH, KEY);
+  this.addListener(elem, 'mouseup', clicked);
+  this.addListener(elem, 'click', clicked);
+
+  // When moving the mouse away from a key, check if any keys need to be
+  // deselected.
+  this.addListener(elem, 'mouseout',
+    function(vt100, elem, key) { return function(e) {
+      if (key == 16 /* Shift */) {
+        if (!elem.className == vt100.isShift) {
+          vt100.showShiftState(vt100.isShift);
+        }
+      } else if (key == 17 /* Ctrl */) {
+        if (!elem.className == vt100.isCtrl) {
+          vt100.showCtrlState(vt100.isCtrl);
+        }
+      } else if (key == 18 /* Alt */) {
+        if (!elem.className == vt100.isAlt) {
+          vt100.showAltState(vt100.isAlt);
+        }
+      } else if (elem.className) {
+        elem.className                  = '';
+        vt100.lastSelectedKey           = elem;
+      } else if (vt100.lastSelectedKey) {
+        vt100.resetLastSelectedKey(e);
+      }
+      return false; }; }(this, elem, key));
+
+  // When moving the mouse over a key, select it if the user is still holding
+  // the mouse button down (i.e. elem == lastSelectedKey)
+  this.addListener(elem, 'mouseover',
+    function(vt100, elem, key) { return function(e) {
+      if (elem == vt100.lastSelectedKey) {
+        if (key == 16 /* Shift */) {
+          if (!elem.className != vt100.isShift) {
+            vt100.showShiftState(!vt100.isShift);
+          }
+        } else if (key == 17 /* Ctrl */) {
+          if (!elem.className != vt100.isCtrl) {
+            vt100.showCtrlState(!vt100.isCtrl);
+          }
+        } else if (key == 18 /* Alt */) {
+          if (!elem.className != vt100.isAlt) {
+            vt100.showAltState(!vt100.isAlt);
+          }
+        } else if (!elem.className) {
+          elem.className                = 'selected';
+        }
+      } else {
+        vt100.resetLastSelectedKey(e);
+      }
+      return false; }; }(this, elem, key));
+};
+
+VT100.prototype.initializeKeyBindings = function(elem) {
+  if (elem) {
+    if (elem.nodeName == "I" || elem.nodeName == "B") {
+      if (elem.id) {
+        // Function keys. The Javascript keycode is part of the "id"
+        var i     = parseInt(elem.id);
+        if (i) {
+          // If the id does not parse as a number, it is not a keycode.
+          this.addKeyBinding(elem, undefined, i);
+        }
+      } else {
+        var child = elem.firstChild;
+        if (child) {
+          if (child.nodeName == "#text") {
+            // If the key only has a text node as a child, then it is a letter.
+            // Automatically compute the lower and upper case version of the
+            // key.
+            var text = this.getTextContent(child) ||
+                       this.getTextContent(elem);
+            this.addKeyBinding(elem, text.toLowerCase());
+          } else if (child.nextSibling) {
+            // If the key has two children, they are the lower and upper case
+            // character code, respectively.
+            this.addKeyBinding(elem, this.getTextContent(child), undefined,
+                               this.getTextContent(child.nextSibling));
+          }
+        }
+      }
+    }
+  }
+  // Recursively parse all other child nodes.
+  for (elem = elem.firstChild; elem; elem = elem.nextSibling) {
+    this.initializeKeyBindings(elem);
+  }
+};
+
+VT100.prototype.initializeKeyboardButton = function() {
+  // Configure mouse event handlers for button that displays/hides keyboard
+  this.addListener(this.keyboardImage, 'click',
+    function(vt100) { return function(e) {
+      if (vt100.keyboard.style.display != '') {
+        if (vt100.reconnectBtn.style.visibility != '') {
+          vt100.initializeKeyboard();
+          vt100.showSoftKeyboard();
+        }
+      } else {
+        vt100.hideSoftKeyboard();
+        vt100.input.focus();
+      }
+      return false; }; }(this));
+
+  // Enable button that displays keyboard
+  if (this.softKeyboard) {
+    this.keyboardImage.style.visibility = 'visible';
+  }
+};
+
+VT100.prototype.initializeKeyboard = function() {
+  // Only need to initialize the keyboard the very first time. When doing so,
+  // copy the keyboard layout from the iframe.
+  if (this.keyboard.firstChild) {
+    return;
+  }
+  this.keyboard.innerHTML               =
+                                    this.layout.contentDocument.body.innerHTML;
+  var box                               = this.keyboard.firstChild;
+  this.hideSoftKeyboard();
+
+  // Configure mouse event handlers for on-screen keyboard
+  this.addListener(this.keyboard, 'click',
+    function(vt100) { return function(e) {
+      vt100.hideSoftKeyboard();
+      vt100.input.focus();
+      return false; }; }(this));
+  this.addListener(this.keyboard, 'selectstart', this.cancelEvent);
+  this.addListener(box, 'click', this.cancelEvent);
+  this.addListener(box, 'mouseup',
+    function(vt100) { return function(e) {
+      if (vt100.lastSelectedKey) {
+        vt100.lastSelectedKey.className = '';
+        vt100.lastSelectedKey           = undefined;
+      }
+      return false; }; }(this));
+  this.addListener(box, 'mouseout',
+    function(vt100) { return function(e) {
+      return vt100.resetLastSelectedKey(e); }; }(this));
+  this.addListener(box, 'mouseover',
+    function(vt100) { return function(e) {
+      return vt100.resetLastSelectedKey(e); }; }(this));
+
+  // Configure SHIFT key behavior
+  var style                             = document.createElement('style');
+  var id                                = document.createAttribute('id');
+  id.nodeValue                          = 'shift_state';
+  style.setAttributeNode(id);
+  var type                              = document.createAttribute('type');
+  type.nodeValue                        = 'text/css';
+  style.setAttributeNode(type);
+  document.getElementsByTagName('head')[0].appendChild(style);
+
+  // Set up key bindings
+  this.initializeKeyBindings(box);
+};
+
+VT100.prototype.initializeElements = function(container) {
+  // If the necessary objects have not already been defined in the HTML
+  // page, create them now.
+  if (container) {
+    this.container             = container;
+  } else if (!(this.container  = document.getElementById('vt100'))) {
+    this.container             = document.createElement('div');
+    this.container.id          = 'vt100';
+    document.body.appendChild(this.container);
+  }
+
+  if (!this.getChildById(this.container, 'reconnect')   ||
+      !this.getChildById(this.container, 'menu')        ||
+      !this.getChildById(this.container, 'keyboard')    ||
+      !this.getChildById(this.container, 'kbd_button')  ||
+      !this.getChildById(this.container, 'kbd_img')     ||
+      !this.getChildById(this.container, 'layout')      ||
+      !this.getChildById(this.container, 'scrollable')  ||
+      !this.getChildById(this.container, 'console')     ||
+      !this.getChildById(this.container, 'alt_console') ||
+      !this.getChildById(this.container, 'ieprobe')     ||
+      !this.getChildById(this.container, 'padding')     ||
+      !this.getChildById(this.container, 'cursor')      ||
+      !this.getChildById(this.container, 'lineheight')  ||
+      !this.getChildById(this.container, 'usercss')     ||
+      !this.getChildById(this.container, 'space')       ||
+      !this.getChildById(this.container, 'input')       ||
+      !this.getChildById(this.container, 'cliphelper')) {
+    // Only enable the "embed" object, if we have a suitable plugin. Otherwise,
+    // we might get a pointless warning that a suitable plugin is not yet
+    // installed. If in doubt, we'd rather just stay silent.
+    var embed                  = '';
+    try {
+      if (typeof navigator.mimeTypes["audio/x-wav"].enabledPlugin.name !=
+          'undefined') {
+        embed                  = typeof suppressAllAudio != 'undefined' &&
+                                 suppressAllAudio ? "" :
+        '<embed classid="clsid:02BF25D5-8C17-4B23-BC80-D3488ABDDC6B" ' +
+                       'id="beep_embed" ' +
+                       'src="beep.wav" ' +
+                       'autostart="false" ' +
+                       'volume="100" ' +
+                       'enablejavascript="true" ' +
+                       'type="audio/x-wav" ' +
+                       'height="16" ' +
+                       'width="200" ' +
+                       'style="position:absolute;left:-1000px;top:-1000px" />';
+      }
+    } catch (e) {
+    }
+
+    this.container.innerHTML   =
+                       '<div id="reconnect" style="visibility: hidden">' +
+                         '<input type="button" value="Connect" ' +
+                                'onsubmit="return false" />' +
+                       '</div>' +
+                       '<div id="cursize" style="visibility: hidden">' +
+                       '</div>' +
+                       '<div id="menu"></div>' +
+                       '<div id="keyboard" unselectable="on">' +
+                       '</div>' +
+                       '<div id="scrollable">' +
+                         '<table id="kbd_button">' +
+                           '<tr><td width="100%">&nbsp;</td>' +
+                           '<td><img id="kbd_img" src="/webshell/keyboard.png" /></td>' +
+                           '<td>&nbsp;&nbsp;&nbsp;&nbsp;</td></tr>' +
+                         '</table>' +
+                         '<pre id="lineheight">&nbsp;</pre>' +
+                         '<pre id="console">' +
+                           '<pre></pre>' +
+                           '<div id="ieprobe"><span>&nbsp;</span></div>' +
+                         '</pre>' +
+                         '<pre id="alt_console" style="display: none"></pre>' +
+                         '<div id="padding"></div>' +
+                         '<pre id="cursor">&nbsp;</pre>' +
+                       '</div>' +
+                       '<div class="hidden">' +
+                         '<div id="usercss"></div>' +
+                         '<pre><div><span id="space"></span></div></pre>' +
+                         '<input type="textfield" id="input" autocorrect="off" autocapitalize="off" />' +
+                         '<input type="textfield" id="cliphelper" />' +
+                         (typeof suppressAllAudio != 'undefined' &&
+                          suppressAllAudio ? "" :
+                         embed + '<bgsound id="beep_bgsound" loop=1 />') +
+                          '<iframe id="layout" src="/webshell/keyboard.html" />' +
+                        '</div>';
+  }
+
+  // Find the object used for playing the "beep" sound, if any.
+  if (typeof suppressAllAudio != 'undefined' && suppressAllAudio) {
+    this.beeper                = undefined;
+  } else {
+    this.beeper                = this.getChildById(this.container,
+                                                   'beep_embed');
+    if (!this.beeper || !this.beeper.Play) {
+      this.beeper              = this.getChildById(this.container,
+                                                   'beep_bgsound');
+      if (!this.beeper || typeof this.beeper.src == 'undefined') {
+        this.beeper            = undefined;
+      }
+    }
+  }
+
+  // Initialize the variables for finding the text console and the
+  // cursor.
+  this.reconnectBtn            = this.getChildById(this.container,'reconnect');
+  this.curSizeBox              = this.getChildById(this.container, 'cursize');
+  this.menu                    = this.getChildById(this.container, 'menu');
+  this.keyboard                = this.getChildById(this.container, 'keyboard');
+  this.keyboardImage           = this.getChildById(this.container, 'kbd_img');
+  this.layout                  = this.getChildById(this.container, 'layout');
+  this.scrollable              = this.getChildById(this.container,
+                                                                 'scrollable');
+  this.lineheight              = this.getChildById(this.container,
+                                                                 'lineheight');
+  this.console                 =
+                          [ this.getChildById(this.container, 'console'),
+                            this.getChildById(this.container, 'alt_console') ];
+  var ieProbe                  = this.getChildById(this.container, 'ieprobe');
+  this.padding                 = this.getChildById(this.container, 'padding');
+  this.cursor                  = this.getChildById(this.container, 'cursor');
+  this.usercss                 = this.getChildById(this.container, 'usercss');
+  this.space                   = this.getChildById(this.container, 'space');
+  this.input                   = this.getChildById(this.container, 'input');
+  this.cliphelper              = this.getChildById(this.container,
+                                                                 'cliphelper');
+
+  // Add any user selectable style sheets to the menu
+  this.initializeUserCSSStyles();
+
+  // Remember the dimensions of a standard character glyph. We would
+  // expect that we could just check cursor.clientWidth/Height at any time,
+  // but it turns out that browsers sometimes invalidate these values
+  // (e.g. while displaying a print preview screen).
+  this.cursorWidth             = this.cursor.clientWidth;
+  this.cursorHeight            = this.lineheight.clientHeight;
+
+  // IE has a slightly different boxing model, that we need to compensate for
+  this.isIE                    = ieProbe.offsetTop > 1;
+  ieProbe                      = undefined;
+  this.console.innerHTML       = '';
+
+  // Determine if the terminal window is positioned at the beginning of the
+  // page, or if it is embedded somewhere else in the page. For full-screen
+  // terminals, automatically resize whenever the browser window changes.
+  var marginTop                = parseInt(this.getCurrentComputedStyle(
+                                          document.body, 'marginTop'));
+  var marginLeft               = parseInt(this.getCurrentComputedStyle(
+                                          document.body, 'marginLeft'));
+  var marginRight              = parseInt(this.getCurrentComputedStyle(
+                                          document.body, 'marginRight'));
+  var x                        = this.container.offsetLeft;
+  var y                        = this.container.offsetTop;
+  for (var parent = this.container; parent = parent.offsetParent; ) {
+    x                         += parent.offsetLeft;
+    y                         += parent.offsetTop;
+  }
+  this.isEmbedded              = marginTop != y ||
+                                 marginLeft != x ||
+                                 (window.innerWidth ||
+                                  document.documentElement.clientWidth ||
+                                  document.body.clientWidth) -
+                                 marginRight != x + this.container.offsetWidth;
+  if (!this.isEmbedded) {
+    // Some browsers generate resize events when the terminal is first
+    // shown. Disable showing the size indicator until a little bit after
+    // the terminal has been rendered the first time.
+    this.indicateSize          = false;
+    setTimeout(function(vt100) {
+      return function() {
+        vt100.indicateSize     = true;
+      };
+    }(this), 100);
+    this.addListener(window, 'resize',
+                     function(vt100) {
+                       return function() {
+                         vt100.hideContextMenu();
+                         vt100.resizer();
+                         vt100.showCurrentSize();
+                        }
+                      }(this));
+
+    // Hide extra scrollbars attached to window
+    document.body.style.margin = '0px';
+    try { document.body.style.overflow ='hidden'; } catch (e) { }
+    try { document.body.oncontextmenu = function() {return false;};} catch(e){}
+  }
+
+  // Set up onscreen soft keyboard
+  this.initializeKeyboardButton();
+
+  // Hide context menu
+  this.hideContextMenu();
+
+  // Add listener to reconnect button
+  this.addListener(this.reconnectBtn.firstChild, 'click',
+                   function(vt100) {
+                     return function() {
+                       var rc = vt100.reconnect();
+                       vt100.input.focus();
+                       return rc;
+                     }
+                   }(this));
+
+  // Add input listeners
+  this.addListener(this.input, 'blur',
+                   function(vt100) {
+                     return function() { vt100.blurCursor(); } }(this));
+  this.addListener(this.input, 'focus',
+                   function(vt100) {
+                     return function() { vt100.focusCursor(); } }(this));
+  this.addListener(this.input, 'keydown',
+                   function(vt100) {
+                     return function(e) {
+                       if (!e) e = window.event;
+                       return vt100.keyDown(e); } }(this));
+  this.addListener(this.input, 'keypress',
+                   function(vt100) {
+                     return function(e) {
+                       if (!e) e = window.event;
+                       return vt100.keyPressed(e); } }(this));
+  this.addListener(this.input, 'keyup',
+                   function(vt100) {
+                     return function(e) {
+                       if (!e) e = window.event;
+                       return vt100.keyUp(e); } }(this));
+
+  // Attach listeners that move the focus to the <input> field. This way we
+  // can make sure that we can receive keyboard input.
+  var mouseEvent               = function(vt100, type) {
+    return function(e) {
+      if (!e) e = window.event;
+      return vt100.mouseEvent(e, type);
+    };
+  };
+  this.addListener(this.scrollable,'mousedown',mouseEvent(this, 0 /* MOUSE_DOWN */));
+  this.addListener(this.scrollable,'mouseup',  mouseEvent(this, 1 /* MOUSE_UP */));
+  this.addListener(this.scrollable,'click',    mouseEvent(this, 2 /* MOUSE_CLICK */));
+
+  // Check that browser supports drag and drop
+  if ('draggable' in document.createElement('span')) {
+      var dropEvent            = function (vt100) {
+          return function(e) {
+              if (!e) e = window.event;
+              if (e.preventDefault) e.preventDefault();
+              vt100.keysPressed(e.dataTransfer.getData('Text'));
+              return false;
+          };
+      };
+      // Tell the browser that we *can* drop on this target
+      this.addListener(this.scrollable, 'dragover', cancel);
+      this.addListener(this.scrollable, 'dragenter', cancel);
+
+      // Add a listener for the drop event
+      this.addListener(this.scrollable, 'drop', dropEvent(this));
+  }
+
+  // Initialize the blank terminal window.
+  this.currentScreen           = 0;
+  this.cursorX                 = 0;
+  this.cursorY                 = 0;
+  this.numScrollbackLines      = 0;
+  this.top                     = 0;
+  this.bottom                  = 0x7FFFFFFF;
+  this.scale                   = 1.0;
+  this.resizer();
+  this.focusCursor();
+  this.input.focus();
+};
+
+function cancel(event) {
+  if (event.preventDefault) {
+    event.preventDefault();
+  }
+  return false;
+}
+
+VT100.prototype.getChildById = function(parent, id) {
+  var nodeList = parent.all || parent.getElementsByTagName('*');
+  if (typeof nodeList.namedItem == 'undefined') {
+    for (var i = 0; i < nodeList.length; i++) {
+      if (nodeList[i].id == id) {
+        return nodeList[i];
+      }
+    }
+    return null;
+  } else {
+    var elem = (parent.all || parent.getElementsByTagName('*')).namedItem(id);
+    return elem ? elem[0] || elem : null;
+  }
+};
+
+VT100.prototype.getCurrentComputedStyle = function(elem, style) {
+  if (typeof elem.currentStyle != 'undefined') {
+    return elem.currentStyle[style];
+  } else {
+    return document.defaultView.getComputedStyle(elem, null)[style];
+  }
+};
+
+VT100.prototype.reconnect = function() {
+  return false;
+};
+
+VT100.prototype.showReconnect = function(state) {
+  if (state) {
+    this.hideSoftKeyboard();
+    this.reconnectBtn.style.visibility = '';
+  } else {
+    this.reconnectBtn.style.visibility = 'hidden';
+  }
+};
+
+VT100.prototype.repairElements = function(console) {
+  for (var line = console.firstChild; line; line = line.nextSibling) {
+    if (!line.clientHeight) {
+      var newLine = document.createElement(line.tagName);
+      newLine.style.cssText       = line.style.cssText;
+      newLine.className           = line.className;
+      if (line.tagName == 'DIV') {
+        for (var span = line.firstChild; span; span = span.nextSibling) {
+          var newSpan             = document.createElement(span.tagName);
+          newSpan.style.cssText   = span.style.cssText;
+          newSpan.className       = span.className;
+          this.setTextContent(newSpan, this.getTextContent(span));
+          newLine.appendChild(newSpan);
+        }
+      } else {
+        this.setTextContent(newLine, this.getTextContent(line));
+      }
+      line.parentNode.replaceChild(newLine, line);
+      line                        = newLine;
+    }
+  }
+};
+
+VT100.prototype.resized = function(w, h) {
+};
+
+VT100.prototype.resizer = function() {
+  // Hide onscreen soft keyboard
+  this.hideSoftKeyboard();
+
+  // The cursor can get corrupted if the print-preview is displayed in Firefox.
+  // Recreating it, will repair it.
+  var newCursor                = document.createElement('pre');
+  this.setTextContent(newCursor, ' ');
+  newCursor.id                 = 'cursor';
+  newCursor.style.cssText      = this.cursor.style.cssText;
+  this.cursor.parentNode.insertBefore(newCursor, this.cursor);
+  if (!newCursor.clientHeight) {
+    // Things are broken right now. This is probably because we are
+    // displaying the print-preview. Just don't change any of our settings
+    // until the print dialog is closed again.
+    newCursor.parentNode.removeChild(newCursor);
+    return;
+  } else {
+    // Swap the old broken cursor for the newly created one.
+    this.cursor.parentNode.removeChild(this.cursor);
+    this.cursor                = newCursor;
+  }
+
+  // Really horrible things happen if the contents of the terminal changes
+  // while the print-preview is showing. We get HTML elements that show up
+  // in the DOM, but that do not take up any space. Find these elements and
+  // try to fix them.
+  this.repairElements(this.console[0]);
+  this.repairElements(this.console[1]);
+
+  // Lock the cursor size to the size of a normal character. This helps with
+  // characters that are taller/shorter than normal. Unfortunately, we will
+  // still get confused if somebody enters a character that is wider/narrower
+  // than normal. This can happen if the browser tries to substitute a
+  // characters from a different font.
+  this.cursor.style.width      = this.cursorWidth  + 'px';
+  this.cursor.style.height     = this.cursorHeight + 'px';
+
+  // Adjust height for one pixel padding of the #vt100 element.
+  // The latter is necessary to properly display the inactive cursor.
+  var console                  = this.console[this.currentScreen];
+  var height                   = (this.isEmbedded ? this.container.clientHeight
+                                  : (window.innerHeight ||
+                                     document.documentElement.clientHeight ||
+                                     document.body.clientHeight))-1;
+  var partial                  = height % this.cursorHeight;
+  this.scrollable.style.height = (height > 0 ? height : 0) + 'px';
+  this.padding.style.height    = (partial > 0 ? partial : 0) + 'px';
+  var oldTerminalHeight        = this.terminalHeight;
+  this.updateWidth();
+  this.updateHeight();
+
+  // Clip the cursor to the visible screen.
+  var cx                       = this.cursorX;
+  var cy                       = this.cursorY + this.numScrollbackLines;
+
+  // The alternate screen never keeps a scroll back buffer.
+  this.updateNumScrollbackLines();
+  while (this.currentScreen && this.numScrollbackLines > 0) {
+    console.removeChild(console.firstChild);
+    this.numScrollbackLines--;
+  }
+  cy                          -= this.numScrollbackLines;
+  if (cx < 0) {
+    cx                         = 0;
+  } else if (cx > this.terminalWidth) {
+    cx                         = this.terminalWidth - 1;
+    if (cx < 0) {
+      cx                       = 0;
+    }
+  }
+  if (cy < 0) {
+    cy                         = 0;
+  } else if (cy > this.terminalHeight) {
+    cy                         = this.terminalHeight - 1;
+    if (cy < 0) {
+      cy                       = 0;
+    }
+  }
+
+  // Clip the scroll region to the visible screen.
+  if (this.bottom > this.terminalHeight ||
+      this.bottom == oldTerminalHeight) {
+    this.bottom                = this.terminalHeight;
+  }
+  if (this.top >= this.bottom) {
+    this.top                   = this.bottom-1;
+    if (this.top < 0) {
+      this.top                 = 0;
+    }
+  }
+
+  // Truncate lines, if necessary. Explicitly reposition cursor (this is
+  // particularly important after changing the screen number), and reset
+  // the scroll region to the default.
+  this.truncateLines(this.terminalWidth);
+  this.putString(cx, cy, '', undefined);
+  this.scrollable.scrollTop    = this.numScrollbackLines *
+                                 this.cursorHeight + 1;
+
+  // Update classNames for lines in the scrollback buffer
+  var line                     = console.firstChild;
+  for (var i = 0; i < this.numScrollbackLines; i++) {
+    line.className             = 'scrollback';
+    line                       = line.nextSibling;
+  }
+  while (line) {
+    line.className             = '';
+    line                       = line.nextSibling;
+  }
+
+  // Reposition the reconnect button
+  this.reconnectBtn.style.left = (this.terminalWidth*this.cursorWidth/
+                                  this.scale -
+                                  this.reconnectBtn.clientWidth)/2 + 'px';
+  this.reconnectBtn.style.top  = (this.terminalHeight*this.cursorHeight-
+                                  this.reconnectBtn.clientHeight)/2 + 'px';
+
+  // Send notification that the window size has been changed
+  this.resized(this.terminalWidth, this.terminalHeight);
+};
+
+VT100.prototype.showCurrentSize = function() {
+  if (!this.indicateSize) {
+    return;
+  }
+  this.curSizeBox.innerHTML             = '' + this.terminalWidth + 'x' +
+                                               this.terminalHeight;
+  this.curSizeBox.style.left            =
+                                      (this.terminalWidth*this.cursorWidth/
+                                       this.scale -
+                                       this.curSizeBox.clientWidth)/2 + 'px';
+  this.curSizeBox.style.top             =
+                                      (this.terminalHeight*this.cursorHeight -
+                                       this.curSizeBox.clientHeight)/2 + 'px';
+  this.curSizeBox.style.visibility      = '';
+  if (this.curSizeTimeout) {
+    clearTimeout(this.curSizeTimeout);
+  }
+
+  // Only show the terminal size for a short amount of time after resizing.
+  // Then hide this information, again. Some browsers generate resize events
+  // throughout the entire resize operation. This is nice, and we will show
+  // the terminal size while the user is dragging the window borders.
+  // Other browsers only generate a single event when the user releases the
+  // mouse. In those cases, we can only show the terminal size once at the
+  // end of the resize operation.
+  this.curSizeTimeout                   = setTimeout(function(vt100) {
+    return function() {
+      vt100.curSizeTimeout              = null;
+      vt100.curSizeBox.style.visibility = 'hidden';
+    };
+  }(this), 1000);
+};
+
+VT100.prototype.selection = function() {
+  try {
+    return '' + (window.getSelection && window.getSelection() ||
+                 document.selection && document.selection.type == 'Text' &&
+                 document.selection.createRange().text || '');
+  } catch (e) {
+  }
+  return '';
+};
+
+VT100.prototype.cancelEvent = function(event) {
+  try {
+    // For non-IE browsers
+    event.stopPropagation();
+    event.preventDefault();
+  } catch (e) {
+  }
+  try {
+    // For IE
+    event.cancelBubble = true;
+    event.returnValue  = false;
+    event.button       = 0;
+    event.keyCode      = 0;
+  } catch (e) {
+  }
+  return false;
+};
+
+VT100.prototype.mousePosition = function(event) {
+  var offsetX      = this.container.offsetLeft;
+  var offsetY      = this.container.offsetTop;
+  for (var e = this.container; e = e.offsetParent; ) {
+    offsetX       += e.offsetLeft;
+    offsetY       += e.offsetTop;
+  }
+  return [ event.clientX - offsetX,
+           event.clientY - offsetY ];
+};
+
+VT100.prototype.mouseEvent = function(event, type) {
+  // If any text is currently selected, do not move the focus as that would
+  // invalidate the selection.
+  var selection    = this.selection();
+  if ((type == 1 /* MOUSE_UP */ || type == 2 /* MOUSE_CLICK */) && !selection.length) {
+    this.input.focus();
+  }
+
+  // Compute mouse position in characters.
+  var position     = this.mousePosition(event);
+  var x            = Math.floor(position[0] / this.cursorWidth);
+  var y            = Math.floor((position[1] + this.scrollable.scrollTop) /
+                                this.cursorHeight) - this.numScrollbackLines;
+  var inside       = true;
+  if (x >= this.terminalWidth) {
+    x              = this.terminalWidth - 1;
+    inside         = false;
+  }
+  if (x < 0) {
+    x              = 0;
+    inside         = false;
+  }
+  if (y >= this.terminalHeight) {
+    y              = this.terminalHeight - 1;
+    inside         = false;
+  }
+  if (y < 0) {
+    y              = 0;
+    inside         = false;
+  }
+
+  // Compute button number and modifier keys.
+  var button       = type != 0 /* MOUSE_DOWN */ ? 3 :
+                     typeof event.pageX != 'undefined' ? event.button :
+                     [ undefined, 0, 2, 0, 1, 0, 1, 0  ][event.button];
+  if (button != undefined) {
+    if (event.shiftKey) {
+      button      |= 0x04;
+    }
+    if (event.altKey || event.metaKey) {
+      button      |= 0x08;
+    }
+    if (event.ctrlKey) {
+      button      |= 0x10;
+    }
+  }
+
+  // Report mouse events if they happen inside of the current screen and
+  // with the SHIFT key unpressed. Both of these restrictions do not apply
+  // for button releases, as we always want to report those.
+  if (this.mouseReporting && !selection.length &&
+      (type != 0 /* MOUSE_DOWN */ || !event.shiftKey)) {
+    if (inside || type != 0 /* MOUSE_DOWN */) {
+      if (button != undefined) {
+        var report = '\u001B[M' + String.fromCharCode(button + 32) +
+                                  String.fromCharCode(x      + 33) +
+                                  String.fromCharCode(y      + 33);
+        if (type != 2 /* MOUSE_CLICK */) {
+          this.keysPressed(report);
+        }
+
+        // If we reported the event, stop propagating it (not sure, if this
+        // actually works on most browsers; blocking the global "oncontextmenu"
+        // even is still necessary).
+        return this.cancelEvent(event);
+      }
+    }
+  }
+
+  // Bring up context menu.
+  if (button == 2 && !event.shiftKey) {
+    if (type == 0 /* MOUSE_DOWN */) {
+      this.showContextMenu(position[0], position[1]);
+    }
+    return this.cancelEvent(event);
+  }
+
+  if (this.mouseReporting) {
+    try {
+      event.shiftKey         = false;
+    } catch (e) {
+    }
+  }
+
+  return true;
+};
+
+VT100.prototype.replaceChar = function(s, ch, repl) {
+  for (var i = -1;;) {
+    i = s.indexOf(ch, i + 1);
+    if (i < 0) {
+      break;
+    }
+    s = s.substr(0, i) + repl + s.substr(i + 1);
+  }
+  return s;
+};
+
+VT100.prototype.htmlEscape = function(s) {
+  return this.replaceChar(this.replaceChar(this.replaceChar(this.replaceChar(
+                s, '&', '&amp;'), '<', '&lt;'), '"', '&quot;'), ' ', '\u00A0');
+};
+
+VT100.prototype.getTextContent = function(elem) {
+  return elem.textContent ||
+         (typeof elem.textContent == 'undefined' ? elem.innerText : '');
+};
+
+VT100.prototype.setTextContentRaw = function(elem, s) {
+  // Updating the content of an element is an expensive operation. It actually
+  // pays off to first check whether the element is still unchanged.
+  if (typeof elem.textContent == 'undefined') {
+    if (elem.innerText != s) {
+      try {
+        elem.innerText = s;
+      } catch (e) {
+        // Very old versions of IE do not allow setting innerText. Instead,
+        // remove all children, by setting innerHTML and then set the text
+        // using DOM methods.
+        elem.innerHTML = '';
+        elem.appendChild(document.createTextNode(
+                                          this.replaceChar(s, ' ', '\u00A0')));
+      }
+    }
+  } else {
+    if (elem.textContent != s) {
+      elem.textContent = s;
+    }
+  }
+};
+
+VT100.prototype.setTextContent = function(elem, s) {
+  // Check if we find any URLs in the text. If so, automatically convert them
+  // to links.
+  if (this.urlRE && this.urlRE.test(s)) {
+    var inner          = '';
+    for (;;) {
+      var consumed = 0;
+      if (RegExp.leftContext != null) {
+        inner         += this.htmlEscape(RegExp.leftContext);
+        consumed      += RegExp.leftContext.length;
+      }
+      var url          = this.htmlEscape(RegExp.lastMatch);
+      var fullUrl      = url;
+
+      // If no protocol was specified, try to guess a reasonable one.
+      if (url.indexOf('http://') < 0 && url.indexOf('https://') < 0 &&
+          url.indexOf('ftp://')  < 0 && url.indexOf('mailto:')  < 0) {
+        var slash      = url.indexOf('/');
+        var at         = url.indexOf('@');
+        var question   = url.indexOf('?');
+        if (at > 0 &&
+            (at < question || question < 0) &&
+            (slash < 0 || (question > 0 && slash > question))) {
+          fullUrl      = 'mailto:' + url;
+        } else {
+          fullUrl      = (url.indexOf('ftp.') == 0 ? 'ftp://' : 'http://') +
+                          url;
+        }
+      }
+
+      inner           += '<a target="vt100Link" href="' + fullUrl +
+                         '">' + url + '</a>';
+      consumed        += RegExp.lastMatch.length;
+      s                = s.substr(consumed);
+      if (!this.urlRE.test(s)) {
+        if (RegExp.rightContext != null) {
+          inner       += this.htmlEscape(RegExp.rightContext);
+        }
+        break;
+      }
+    }
+    elem.innerHTML     = inner;
+    return;
+  }
+
+  this.setTextContentRaw(elem, s);
+};
+
+VT100.prototype.insertBlankLine = function(y, color, style) {
+  // Insert a blank line a position y. This method ignores the scrollback
+  // buffer. The caller has to add the length of the scrollback buffer to
+  // the position, if necessary.
+  // If the position is larger than the number of current lines, this
+  // method just adds a new line right after the last existing one. It does
+  // not add any missing lines in between. It is the caller's responsibility
+  // to do so.
+  if (!color) {
+    color                = 'ansi0 bgAnsi15';
+  }
+  if (!style) {
+    style                = '';
+  }
+  var line;
+  if (color != 'ansi0 bgAnsi15' && !style) {
+    line                 = document.createElement('pre');
+    this.setTextContent(line, '\n');
+  } else {
+    line                 = document.createElement('div');
+    var span             = document.createElement('span');
+    span.style.cssText   = style;
+    span.className       = color;
+    this.setTextContent(span, this.spaces(this.terminalWidth));
+    line.appendChild(span);
+  }
+  line.style.height      = this.cursorHeight + 'px';
+  var console            = this.console[this.currentScreen];
+  if (console.childNodes.length > y) {
+    console.insertBefore(line, console.childNodes[y]);
+  } else {
+    console.appendChild(line);
+  }
+};
+
+VT100.prototype.updateWidth = function() {
+  this.terminalWidth = Math.floor(this.console[this.currentScreen].offsetWidth/
+                                  this.cursorWidth*this.scale);
+  return this.terminalWidth;
+};
+
+VT100.prototype.updateHeight = function() {
+  // We want to be able to display either a terminal window that fills the
+  // entire browser window, or a terminal window that is contained in a
+  // <div> which is embededded somewhere in the web page.
+  if (this.isEmbedded) {
+    // Embedded terminal. Use size of the containing <div> (id="vt100").
+    this.terminalHeight = Math.floor((this.container.clientHeight-1) /
+                                     this.cursorHeight);
+  } else {
+    // Use the full browser window.
+    this.terminalHeight = Math.floor(((window.innerHeight ||
+                                       document.documentElement.clientHeight ||
+                                       document.body.clientHeight)-1)/
+                                     this.cursorHeight);
+  }
+  return this.terminalHeight;
+};
+
+VT100.prototype.updateNumScrollbackLines = function() {
+  var scrollback          = Math.floor(
+                                this.console[this.currentScreen].offsetHeight /
+                                this.cursorHeight) -
+                            this.terminalHeight;
+  this.numScrollbackLines = scrollback < 0 ? 0 : scrollback;
+  return this.numScrollbackLines;
+};
+
+VT100.prototype.truncateLines = function(width) {
+  if (width < 0) {
+    width             = 0;
+  }
+  for (var line = this.console[this.currentScreen].firstChild; line;
+       line = line.nextSibling) {
+    if (line.tagName == 'DIV') {
+      var x           = 0;
+
+      // Traverse current line and truncate it once we saw "width" characters
+      for (var span = line.firstChild; span;
+           span = span.nextSibling) {
+        var s         = this.getTextContent(span);
+        var l         = s.length;
+        if (x + l > width) {
+          this.setTextContent(span, s.substr(0, width - x));
+          while (span.nextSibling) {
+            line.removeChild(line.lastChild);
+          }
+          break;
+        }
+        x            += l;
+      }
+      // Prune white space from the end of the current line
+      var span       = line.lastChild;
+      while (span &&
+             span.className == 'ansi0 bgAnsi15' &&
+             !span.style.cssText.length) {
+        // Scan backwards looking for first non-space character
+        var s         = this.getTextContent(span);
+        for (var i = s.length; i--; ) {
+          if (s.charAt(i) != ' ' && s.charAt(i) != '\u00A0') {
+            if (i+1 != s.length) {
+              this.setTextContent(s.substr(0, i+1));
+            }
+            span      = null;
+            break;
+          }
+        }
+        if (span) {
+          var sibling = span;
+          span        = span.previousSibling;
+          if (span) {
+            // Remove blank <span>'s from end of line
+            line.removeChild(sibling);
+          } else {
+            // Remove entire line (i.e. <div>), if empty
+            var blank = document.createElement('pre');
+            blank.style.height = this.cursorHeight + 'px';
+            this.setTextContent(blank, '\n');
+            line.parentNode.replaceChild(blank, line);
+          }
+        }
+      }
+    }
+  }
+};
+
+VT100.prototype.putString = function(x, y, text, color, style) {
+  if (!color) {
+    color                           = 'ansi0 bgAnsi15';
+  }
+  if (!style) {
+    style                           = '';
+  }
+  var yIdx                          = y + this.numScrollbackLines;
+  var line;
+  var sibling;
+  var s;
+  var span;
+  var xPos                          = 0;
+  var console                       = this.console[this.currentScreen];
+  if (!text.length && (yIdx >= console.childNodes.length ||
+                       console.childNodes[yIdx].tagName != 'DIV')) {
+    // Positioning cursor to a blank location
+    span                            = null;
+  } else {
+    // Create missing blank lines at end of page
+    while (console.childNodes.length <= yIdx) {
+      // In order to simplify lookups, we want to make sure that each line
+      // is represented by exactly one element (and possibly a whole bunch of
+      // children).
+      // For non-blank lines, we can create a <div> containing one or more
+      // <span>s. For blank lines, this fails as browsers tend to optimize them
+      // away. But fortunately, a <pre> tag containing a newline character
+      // appears to work for all browsers (a &nbsp; would also work, but then
+      // copying from the browser window would insert superfluous spaces into
+      // the clipboard).
+      this.insertBlankLine(yIdx);
+    }
+    line                            = console.childNodes[yIdx];
+
+    // If necessary, promote blank '\n' line to a <div> tag
+    if (line.tagName != 'DIV') {
+      var div                       = document.createElement('div');
+      div.style.height              = this.cursorHeight + 'px';
+      div.innerHTML                 = '<span></span>';
+      console.replaceChild(div, line);
+      line                          = div;
+    }
+
+    // Scan through list of <span>'s until we find the one where our text
+    // starts
+    span                            = line.firstChild;
+    var len;
+    while (span.nextSibling && xPos < x) {
+      len                           = this.getTextContent(span).length;
+      if (xPos + len > x) {
+        break;
+      }
+      xPos                         += len;
+      span                          = span.nextSibling;
+    }
+
+    if (text.length) {
+      // If current <span> is not long enough, pad with spaces or add new
+      // span
+      s                             = this.getTextContent(span);
+      var oldColor                  = span.className;
+      var oldStyle                  = span.style.cssText;
+      if (xPos + s.length < x) {
+        if (oldColor != 'ansi0 bgAnsi15' || oldStyle != '') {
+          span                      = document.createElement('span');
+          line.appendChild(span);
+          span.className            = 'ansi0 bgAnsi15';
+          span.style.cssText        = '';
+          oldColor                  = 'ansi0 bgAnsi15';
+          oldStyle                  = '';
+          xPos                     += s.length;
+          s                         = '';
+        }
+        do {
+          s                        += ' ';
+        } while (xPos + s.length < x);
+      }
+
+      // If styles do not match, create a new <span>
+      var del                       = text.length - s.length + x - xPos;
+      if (oldColor != color ||
+          (oldStyle != style && (oldStyle || style))) {
+        if (xPos == x) {
+          // Replacing text at beginning of existing <span>
+          if (text.length >= s.length) {
+            // New text is equal or longer than existing text
+            s                       = text;
+          } else {
+            // Insert new <span> before the current one, then remove leading
+            // part of existing <span>, adjust style of new <span>, and finally
+            // set its contents
+            sibling                 = document.createElement('span');
+            line.insertBefore(sibling, span);
+            this.setTextContent(span, s.substr(text.length));
+            span                    = sibling;
+            s                       = text;
+          }
+        } else {
+          // Replacing text some way into the existing <span>
+          var remainder             = s.substr(x + text.length - xPos);
+          this.setTextContent(span, s.substr(0, x - xPos));
+          xPos                      = x;
+          sibling                   = document.createElement('span');
+          if (span.nextSibling) {
+            line.insertBefore(sibling, span.nextSibling);
+            span                    = sibling;
+            if (remainder.length) {
+              sibling               = document.createElement('span');
+              sibling.className     = oldColor;
+              sibling.style.cssText = oldStyle;
+              this.setTextContent(sibling, remainder);
+              line.insertBefore(sibling, span.nextSibling);
+            }
+          } else {
+            line.appendChild(sibling);
+            span                    = sibling;
+            if (remainder.length) {
+              sibling               = document.createElement('span');
+              sibling.className     = oldColor;
+              sibling.style.cssText = oldStyle;
+              this.setTextContent(sibling, remainder);
+              line.appendChild(sibling);
+            }
+          }
+          s                         = text;
+        }
+        span.className              = color;
+        span.style.cssText          = style;
+      } else {
+        // Overwrite (partial) <span> with new text
+        s                           = s.substr(0, x - xPos) +
+          text +
+          s.substr(x + text.length - xPos);
+      }
+      this.setTextContent(span, s);
+
+
+      // Delete all subsequent <span>'s that have just been overwritten
+      sibling                       = span.nextSibling;
+      while (del > 0 && sibling) {
+        s                           = this.getTextContent(sibling);
+        len                         = s.length;
+        if (len <= del) {
+          line.removeChild(sibling);
+          del                      -= len;
+          sibling                   = span.nextSibling;
+        } else {
+          this.setTextContent(sibling, s.substr(del));
+          break;
+        }
+      }
+
+      // Merge <span> with next sibling, if styles are identical
+      if (sibling && span.className == sibling.className &&
+          span.style.cssText == sibling.style.cssText) {
+        this.setTextContent(span,
+                            this.getTextContent(span) +
+                            this.getTextContent(sibling));
+        line.removeChild(sibling);
+      }
+    }
+  }
+
+  // Position cursor
+  this.cursorX                      = x + text.length;
+  if (this.cursorX >= this.terminalWidth) {
+    this.cursorX                    = this.terminalWidth - 1;
+    if (this.cursorX < 0) {
+      this.cursorX                  = 0;
+    }
+  }
+  var pixelX                        = -1;
+  var pixelY                        = -1;
+  if (!this.cursor.style.visibility) {
+    var idx                         = this.cursorX - xPos;
+    if (span) {
+      // If we are in a non-empty line, take the cursor Y position from the
+      // other elements in this line. If dealing with broken, non-proportional
+      // fonts, this is likely to yield better results.
+      pixelY                        = span.offsetTop +
+                                      span.offsetParent.offsetTop;
+      s                             = this.getTextContent(span);
+      var nxtIdx                    = idx - s.length;
+      if (nxtIdx < 0) {
+        this.setTextContent(this.cursor, s.charAt(idx));
+        pixelX                      = span.offsetLeft +
+                                      idx*span.offsetWidth / s.length;
+      } else {
+        if (nxtIdx == 0) {
+          pixelX                    = span.offsetLeft + span.offsetWidth;
+        }
+        if (span.nextSibling) {
+          s                         = this.getTextContent(span.nextSibling);
+          this.setTextContent(this.cursor, s.charAt(nxtIdx));
+          if (pixelX < 0) {
+            pixelX                  = span.nextSibling.offsetLeft +
+                                      nxtIdx*span.offsetWidth / s.length;
+          }
+        } else {
+          this.setTextContent(this.cursor, ' ');
+        }
+      }
+    } else {
+      this.setTextContent(this.cursor, ' ');
+    }
+  }
+  if (pixelX >= 0) {
+    this.cursor.style.left          = (pixelX + (this.isIE ? 1 : 0))/
+                                      this.scale + 'px';
+  } else {
+    this.setTextContent(this.space, this.spaces(this.cursorX));
+    this.cursor.style.left          = (this.space.offsetWidth +
+                                       console.offsetLeft)/this.scale + 'px';
+  }
+  this.cursorY                      = yIdx - this.numScrollbackLines;
+  if (pixelY >= 0) {
+    this.cursor.style.top           = pixelY + 'px';
+  } else {
+    this.cursor.style.top           = yIdx*this.cursorHeight +
+                                      console.offsetTop + 'px';
+  }
+
+  if (text.length) {
+    // Merge <span> with previous sibling, if styles are identical
+    if ((sibling = span.previousSibling) &&
+        span.className == sibling.className &&
+        span.style.cssText == sibling.style.cssText) {
+      this.setTextContent(span,
+                          this.getTextContent(sibling) +
+                          this.getTextContent(span));
+      line.removeChild(sibling);
+    }
+
+    // Prune white space from the end of the current line
+    span                            = line.lastChild;
+    while (span &&
+           span.className == 'ansi0 bgAnsi15' &&
+           !span.style.cssText.length) {
+      // Scan backwards looking for first non-space character
+      s                             = this.getTextContent(span);
+      for (var i = s.length; i--; ) {
+        if (s.charAt(i) != ' ' && s.charAt(i) != '\u00A0') {
+          if (i+1 != s.length) {
+            this.setTextContent(s.substr(0, i+1));
+          }
+          span                      = null;
+          break;
+        }
+      }
+      if (span) {
+        sibling                     = span;
+        span                        = span.previousSibling;
+        if (span) {
+          // Remove blank <span>'s from end of line
+          line.removeChild(sibling);
+        } else {
+          // Remove entire line (i.e. <div>), if empty
+          var blank                 = document.createElement('pre');
+          blank.style.height        = this.cursorHeight + 'px';
+          this.setTextContent(blank, '\n');
+          line.parentNode.replaceChild(blank, line);
+        }
+      }
+    }
+  }
+};
+
+VT100.prototype.gotoXY = function(x, y) {
+  if (x >= this.terminalWidth) {
+    x           = this.terminalWidth - 1;
+  }
+  if (x < 0) {
+    x           = 0;
+  }
+  var minY, maxY;
+  if (this.offsetMode) {
+    minY        = this.top;
+    maxY        = this.bottom;
+  } else {
+    minY        = 0;
+    maxY        = this.terminalHeight;
+  }
+  if (y >= maxY) {
+    y           = maxY - 1;
+  }
+  if (y < minY) {
+    y           = minY;
+  }
+  this.putString(x, y, '', undefined);
+  this.needWrap = false;
+};
+
+VT100.prototype.gotoXaY = function(x, y) {
+  this.gotoXY(x, this.offsetMode ? (this.top + y) : y);
+};
+
+VT100.prototype.refreshInvertedState = function() {
+  if (this.isInverted) {
+    this.scrollable.className += ' inverted';
+  } else {
+    this.scrollable.className = this.scrollable.className.
+                                                     replace(/ *inverted/, '');
+  }
+};
+
+VT100.prototype.enableAlternateScreen = function(state) {
+  // Don't do anything, if we are already on the desired screen
+  if ((state ? 1 : 0) == this.currentScreen) {
+    // Calling the resizer is not actually necessary. But it is a good way
+    // of resetting state that might have gotten corrupted.
+    this.resizer();
+    return;
+  }
+
+  // We save the full state of the normal screen, when we switch away from it.
+  // But for the alternate screen, no saving is necessary. We always reset
+  // it when we switch to it.
+  if (state) {
+    this.saveCursor();
+  }
+
+  // Display new screen, and initialize state (the resizer does that for us).
+  this.currentScreen                                 = state ? 1 : 0;
+  this.console[1-this.currentScreen].style.display   = 'none';
+  this.console[this.currentScreen].style.display     = '';
+
+  // Select appropriate character pitch.
+  var transform                                      = this.getTransformName();
+  if (transform) {
+    if (state) {
+      // Upon enabling the alternate screen, we switch to 80 column mode. But
+      // upon returning to the regular screen, we restore the mode that was
+      // in effect previously.
+      this.console[1].style[transform]               = '';
+    }
+    var style                                        =
+                             this.console[this.currentScreen].style[transform];
+    this.cursor.style[transform]                     = style;
+    this.space.style[transform]                      = style;
+    this.scale                                       = style == '' ? 1.0:1.65;
+    if (transform == 'filter') {
+       this.console[this.currentScreen].style.width  = style == '' ? '165%':'';
+    }
+  }
+  this.resizer();
+
+  // If we switched to the alternate screen, reset it completely. Otherwise,
+  // restore the saved state.
+  if (state) {
+    this.gotoXY(0, 0);
+    this.clearRegion(0, 0, this.terminalWidth, this.terminalHeight);
+  } else {
+    this.restoreCursor();
+  }
+};
+
+VT100.prototype.hideCursor = function() {
+  var hidden = this.cursor.style.visibility == 'hidden';
+  if (!hidden) {
+    this.cursor.style.visibility = 'hidden';
+    return true;
+  }
+  return false;
+};
+
+VT100.prototype.showCursor = function(x, y) {
+  if (this.cursor.style.visibility) {
+    this.cursor.style.visibility = '';
+    this.putString(x == undefined ? this.cursorX : x,
+                   y == undefined ? this.cursorY : y,
+                   '', undefined);
+    return true;
+  }
+  return false;
+};
+
+VT100.prototype.scrollBack = function() {
+  var i                     = this.scrollable.scrollTop -
+                              this.scrollable.clientHeight;
+  this.scrollable.scrollTop = i < 0 ? 0 : i;
+};
+
+VT100.prototype.scrollFore = function() {
+  var i                     = this.scrollable.scrollTop +
+                              this.scrollable.clientHeight;
+  this.scrollable.scrollTop = i > this.numScrollbackLines *
+                                  this.cursorHeight + 1
+                              ? this.numScrollbackLines *
+                                this.cursorHeight + 1
+                              : i;
+};
+
+VT100.prototype.spaces = function(i) {
+  var s = '';
+  while (i-- > 0) {
+    s += ' ';
+  }
+  return s;
+};
+
+VT100.prototype.clearRegion = function(x, y, w, h, color, style) {
+  w         += x;
+  if (x < 0) {
+    x        = 0;
+  }
+  if (w > this.terminalWidth) {
+    w        = this.terminalWidth;
+  }
+  if ((w    -= x) <= 0) {
+    return;
+  }
+  h         += y;
+  if (y < 0) {
+    y        = 0;
+  }
+  if (h > this.terminalHeight) {
+    h        = this.terminalHeight;
+  }
+  if ((h    -= y) <= 0) {
+    return;
+  }
+
+  // Special case the situation where we clear the entire screen, and we do
+  // not have a scrollback buffer. In that case, we should just remove all
+  // child nodes.
+  if (!this.numScrollbackLines &&
+      w == this.terminalWidth && h == this.terminalHeight &&
+      (color == undefined || color == 'ansi0 bgAnsi15') && !style) {
+    var console = this.console[this.currentScreen];
+    while (console.lastChild) {
+      console.removeChild(console.lastChild);
+    }
+    this.putString(this.cursorX, this.cursorY, '', undefined);
+  } else {
+    var hidden = this.hideCursor();
+    var cx     = this.cursorX;
+    var cy     = this.cursorY;
+    var s      = this.spaces(w);
+    for (var i = y+h; i-- > y; ) {
+      this.putString(x, i, s, color, style);
+    }
+    hidden ? this.showCursor(cx, cy) : this.putString(cx, cy, '', undefined);
+  }
+};
+
+VT100.prototype.copyLineSegment = function(dX, dY, sX, sY, w) {
+  var text                            = [ ];
+  var className                       = [ ];
+  var style                           = [ ];
+  var console                         = this.console[this.currentScreen];
+  if (sY >= console.childNodes.length) {
+    text[0]                           = this.spaces(w);
+    className[0]                      = undefined;
+    style[0]                          = undefined;
+  } else {
+    var line = console.childNodes[sY];
+    if (line.tagName != 'DIV' || !line.childNodes.length) {
+      text[0]                         = this.spaces(w);
+      className[0]                    = undefined;
+      style[0]                        = undefined;
+    } else {
+      var x                           = 0;
+      for (var span = line.firstChild; span && w > 0; span = span.nextSibling){
+        var s                         = this.getTextContent(span);
+        var len                       = s.length;
+        if (x + len > sX) {
+          var o                       = sX > x ? sX - x : 0;
+          text[text.length]           = s.substr(o, w);
+          className[className.length] = span.className;
+          style[style.length]         = span.style.cssText;
+          w                          -= len - o;
+        }
+        x                            += len;
+      }
+      if (w > 0) {
+        text[text.length]             = this.spaces(w);
+        className[className.length]   = undefined;
+        style[style.length]           = undefined;
+      }
+    }
+  }
+  var hidden                          = this.hideCursor();
+  var cx                              = this.cursorX;
+  var cy                              = this.cursorY;
+  for (var i = 0; i < text.length; i++) {
+    var color;
+    if (className[i]) {
+      color                           = className[i];
+    } else {
+      color                           = 'ansi0 bgAnsi15';
+    }
+    this.putString(dX, dY - this.numScrollbackLines, text[i], color, style[i]);
+    dX                               += text[i].length;
+  }
+  hidden ? this.showCursor(cx, cy) : this.putString(cx, cy, '', undefined);
+};
+
+VT100.prototype.scrollRegion = function(x, y, w, h, incX, incY,
+                                        color, style) {
+  var left             = incX < 0 ? -incX : 0;
+  var right            = incX > 0 ?  incX : 0;
+  var up               = incY < 0 ? -incY : 0;
+  var down             = incY > 0 ?  incY : 0;
+
+  // Clip region against terminal size
+  var dontScroll       = null;
+  w                   += x;
+  if (x < left) {
+    x                  = left;
+  }
+  if (w > this.terminalWidth - right) {
+    w                  = this.terminalWidth - right;
+  }
+  if ((w              -= x) <= 0) {
+    dontScroll         = 1;
+  }
+  h                   += y;
+  if (y < up) {
+    y                  = up;
+  }
+  if (h > this.terminalHeight - down) {
+    h                  = this.terminalHeight - down;
+  }
+  if ((h              -= y) < 0) {
+    dontScroll         = 1;
+  }
+  if (!dontScroll) {
+    if (style && style.indexOf('underline')) {
+      // Different terminal emulators disagree on the attributes that
+      // are used for scrolling. The consensus seems to be, never to
+      // fill with underlined spaces. N.B. this is different from the
+      // cases when the user blanks a region. User-initiated blanking
+      // always fills with all of the current attributes.
+      style            = style.replace(/text-decoration:underline;/, '');
+    }
+
+    // Compute current scroll position
+    var scrollPos      = this.numScrollbackLines -
+                      (this.scrollable.scrollTop-1) / this.cursorHeight;
+
+    // Determine original cursor position. Hide cursor temporarily to avoid
+    // visual artifacts.
+    var hidden         = this.hideCursor();
+    var cx             = this.cursorX;
+    var cy             = this.cursorY;
+    var console        = this.console[this.currentScreen];
+
+    if (!incX && !x && w == this.terminalWidth) {
+      // Scrolling entire lines
+      if (incY < 0) {
+        // Scrolling up
+        if (!this.currentScreen && y == -incY &&
+            h == this.terminalHeight + incY) {
+          // Scrolling up with adding to the scrollback buffer. This is only
+          // possible if there are at least as many lines in the console,
+          // as the terminal is high
+          while (console.childNodes.length < this.terminalHeight) {
+            this.insertBlankLine(this.terminalHeight);
+          }
+
+          // Add new lines at bottom in order to force scrolling
+          for (var i = 0; i < y; i++) {
+            this.insertBlankLine(console.childNodes.length, color, style);
+          }
+
+          // Adjust the number of lines in the scrollback buffer by
+          // removing excess entries.
+          this.updateNumScrollbackLines();
+          while (this.numScrollbackLines >
+                 (this.currentScreen ? 0 : this.maxScrollbackLines)) {
+            console.removeChild(console.firstChild);
+            this.numScrollbackLines--;
+          }
+
+          // Mark lines in the scrollback buffer, so that they do not get
+          // printed.
+          for (var i = this.numScrollbackLines, j = -incY;
+               i-- > 0 && j-- > 0; ) {
+            console.childNodes[i].className = 'scrollback';
+          }
+        } else {
+          // Scrolling up without adding to the scrollback buffer.
+          for (var i = -incY;
+               i-- > 0 &&
+               console.childNodes.length >
+               this.numScrollbackLines + y + incY; ) {
+            console.removeChild(console.childNodes[
+                                          this.numScrollbackLines + y + incY]);
+          }
+
+          // If we used to have a scrollback buffer, then we must make sure
+          // that we add back blank lines at the bottom of the terminal.
+          // Similarly, if we are scrolling in the middle of the screen,
+          // we must add blank lines to ensure that the bottom of the screen
+          // does not move up.
+          if (this.numScrollbackLines > 0 ||
+              console.childNodes.length > this.numScrollbackLines+y+h+incY) {
+            for (var i = -incY; i-- > 0; ) {
+              this.insertBlankLine(this.numScrollbackLines + y + h + incY,
+                                   color, style);
+            }
+          }
+        }
+      } else {
+        // Scrolling down
+        for (var i = incY;
+             i-- > 0 &&
+             console.childNodes.length > this.numScrollbackLines + y + h; ) {
+          console.removeChild(console.childNodes[this.numScrollbackLines+y+h]);
+        }
+        for (var i = incY; i--; ) {
+          this.insertBlankLine(this.numScrollbackLines + y, color, style);
+        }
+      }
+    } else {
+      // Scrolling partial lines
+      if (incY <= 0) {
+        // Scrolling up or horizontally within a line
+        for (var i = y + this.numScrollbackLines;
+             i < y + this.numScrollbackLines + h;
+             i++) {
+          this.copyLineSegment(x + incX, i + incY, x, i, w);
+        }
+      } else {
+        // Scrolling down
+        for (var i = y + this.numScrollbackLines + h;
+             i-- > y + this.numScrollbackLines; ) {
+          this.copyLineSegment(x + incX, i + incY, x, i, w);
+        }
+      }
+
+      // Clear blank regions
+      if (incX > 0) {
+        this.clearRegion(x, y, incX, h, color, style);
+      } else if (incX < 0) {
+        this.clearRegion(x + w + incX, y, -incX, h, color, style);
+      }
+      if (incY > 0) {
+        this.clearRegion(x, y, w, incY, color, style);
+      } else if (incY < 0) {
+        this.clearRegion(x, y + h + incY, w, -incY, color, style);
+      }
+    }
+
+    // Reset scroll position
+    this.scrollable.scrollTop = (this.numScrollbackLines-scrollPos) *
+                                this.cursorHeight + 1;
+
+    // Move cursor back to its original position
+    hidden ? this.showCursor(cx, cy) : this.putString(cx, cy, '', undefined);
+  }
+};
+
+VT100.prototype.copy = function(selection) {
+  if (selection == undefined) {
+    selection                = this.selection();
+  }
+  this.internalClipboard     = undefined;
+  if (selection.length) {
+    try {
+      // IE
+      this.cliphelper.value  = selection;
+      this.cliphelper.select();
+      this.cliphelper.createTextRange().execCommand('copy');
+    } catch (e) {
+      this.internalClipboard = selection;
+    }
+    this.cliphelper.value    = '';
+  }
+};
+
+VT100.prototype.copyLast = function() {
+  // Opening the context menu can remove the selection. We try to prevent this
+  // from happening, but that is not possible for all browsers. So, instead,
+  // we compute the selection before showing the menu.
+  this.copy(this.lastSelection);
+};
+
+VT100.prototype.pasteFnc = function() {
+  var clipboard     = undefined;
+  if (this.internalClipboard != undefined) {
+    clipboard       = this.internalClipboard;
+  } else {
+    try {
+      this.cliphelper.value = '';
+      this.cliphelper.createTextRange().execCommand('paste');
+      clipboard     = this.cliphelper.value;
+    } catch (e) {
+    }
+  }
+  this.cliphelper.value = '';
+  if (clipboard && this.menu.style.visibility == 'hidden') {
+    return function() {
+      this.keysPressed('' + clipboard);
+    };
+  } else {
+    return undefined;
+  }
+};
+
+VT100.prototype.pasteBrowserFnc = function() {
+  var clipboard     = prompt("Paste into this box:","");
+  if (clipboard != undefined) {
+     return this.keysPressed('' + clipboard);
+  }
+};
+
+VT100.prototype.toggleUTF = function() {
+  this.utfEnabled   = !this.utfEnabled;
+
+  // We always persist the last value that the user selected. Not necessarily
+  // the last value that a random program requested.
+  this.utfPreferred = this.utfEnabled;
+};
+
+VT100.prototype.toggleBell = function() {
+  this.visualBell = !this.visualBell;
+};
+
+VT100.prototype.toggleSoftKeyboard = function() {
+  this.softKeyboard = !this.softKeyboard;
+  this.keyboardImage.style.visibility = this.softKeyboard ? 'visible' : '';
+};
+
+VT100.prototype.deselectKeys = function(elem) {
+  if (elem && elem.className == 'selected') {
+    elem.className = '';
+  }
+  for (elem = elem.firstChild; elem; elem = elem.nextSibling) {
+    this.deselectKeys(elem);
+  }
+};
+
+VT100.prototype.showSoftKeyboard = function() {
+  // Make sure no key is currently selected
+  this.lastSelectedKey           = undefined;
+  this.deselectKeys(this.keyboard);
+  this.isShift                   = false;
+  this.showShiftState(false);
+  this.isCtrl                    = false;
+  this.showCtrlState(false);
+  this.isAlt                     = false;
+  this.showAltState(false);
+
+  this.keyboard.style.left       = '0px';
+  this.keyboard.style.top        = '0px';
+  this.keyboard.style.width      = this.container.offsetWidth  + 'px';
+  this.keyboard.style.height     = this.container.offsetHeight + 'px';
+  this.keyboard.style.visibility = 'hidden';
+  this.keyboard.style.display    = '';
+
+  var kbd                        = this.keyboard.firstChild;
+  var scale                      = 1.0;
+  var transform                  = this.getTransformName();
+  if (transform) {
+    kbd.style[transform]         = '';
+    if (kbd.offsetWidth > 0.9 * this.container.offsetWidth) {
+      scale                      = (kbd.offsetWidth/
+                                    this.container.offsetWidth)/0.9;
+    }
+    if (kbd.offsetHeight > 0.9 * this.container.offsetHeight) {
+      scale                      = Math.max((kbd.offsetHeight/
+                                             this.container.offsetHeight)/0.9);
+    }
+    var style                    = this.getTransformStyle(transform,
+                                              scale > 1.0 ? scale : undefined);
+    kbd.style[transform]         = style;
+  }
+  if (transform == 'filter') {
+    scale                        = 1.0;
+  }
+  kbd.style.left                 = ((this.container.offsetWidth -
+                                     kbd.offsetWidth/scale)/2) + 'px';
+  kbd.style.top                  = ((this.container.offsetHeight -
+                                     kbd.offsetHeight/scale)/2) + 'px';
+
+  this.keyboard.style.visibility = 'visible';
+};
+
+VT100.prototype.hideSoftKeyboard = function() {
+  this.keyboard.style.display    = 'none';
+};
+
+VT100.prototype.toggleCursorBlinking = function() {
+  this.blinkingCursor = !this.blinkingCursor;
+};
+
+VT100.prototype.about = function() {
+  alert("VT100 Terminal Emulator " + "2.10 (revision 239)" +
+        "\nCopyright 2008-2010 by Markus Gutschke\n" +
+        "For more information check http://shellinabox.com");
+};
+
+VT100.prototype.hideContextMenu = function() {
+  this.menu.style.visibility = 'hidden';
+  this.menu.style.top        = '-100px';
+  this.menu.style.left       = '-100px';
+  this.menu.style.width      = '0px';
+  this.menu.style.height     = '0px';
+};
+
+VT100.prototype.extendContextMenu = function(entries, actions) {
+};
+
+VT100.prototype.showContextMenu = function(x, y) {
+  this.menu.innerHTML         =
+    '<table class="popup" ' +
+           'cellpadding="0" cellspacing="0">' +
+      '<tr><td>' +
+        '<ul id="menuentries">' +
+          '<li id="beginclipboard">Copy</li>' +
+          '<li id="endclipboard">Paste</li>' +
+          '<li id="browserclipboard">Paste from browser</li>' +
+          '<hr />' +
+          '<li id="reset">Reset</li>' +
+          '<hr />' +
+          '<li id="beginconfig">' +
+             (this.utfEnabled ? '<img src="/webshell/enabled.gif" />' : '') +
+             'Unicode</li>' +
+          '<li>' +
+             (this.visualBell ? '<img src="/webshell/enabled.gif" />' : '') +
+             'Visual Bell</li>'+
+          '<li>' +
+             (this.softKeyboard ? '<img src="/webshell/enabled.gif" />' : '') +
+             'Onscreen Keyboard</li>' +
+          '<li id="endconfig">' +
+             (this.blinkingCursor ? '<img src="/webshell/enabled.gif" />' : '') +
+             'Blinking Cursor</li>'+
+          (this.usercss.firstChild ?
+           '<hr id="beginusercss" />' +
+           this.usercss.innerHTML +
+           '<hr id="endusercss" />' :
+           '<hr />') +
+          '<li id="about">About...</li>' +
+        '</ul>' +
+      '</td></tr>' +
+    '</table>';
+
+  var popup                   = this.menu.firstChild;
+  var menuentries             = this.getChildById(popup, 'menuentries');
+
+  // Determine menu entries that should be disabled
+  this.lastSelection          = this.selection();
+  if (!this.lastSelection.length) {
+    menuentries.firstChild.className
+                              = 'disabled';
+  }
+  var p                       = this.pasteFnc();
+  if (!p) {
+    menuentries.childNodes[1].className
+                              = 'disabled';
+  }
+
+  // Actions for default items
+  var actions                 = [ this.copyLast, p, this.pasteBrowserFnc, this.reset,
+                                  this.toggleUTF, this.toggleBell,
+                                  this.toggleSoftKeyboard,
+                                  this.toggleCursorBlinking ];
+
+  // Actions for user CSS styles (if any)
+  for (var i = 0; i < this.usercssActions.length; ++i) {
+    actions[actions.length]   = this.usercssActions[i];
+  }
+  actions[actions.length]     = this.about;
+
+  // Allow subclasses to dynamically add entries to the context menu
+  this.extendContextMenu(menuentries, actions);
+
+  // Hook up event listeners
+  for (var node = menuentries.firstChild, i = 0; node;
+       node = node.nextSibling) {
+    if (node.tagName == 'LI') {
+      if (node.className != 'disabled') {
+        this.addListener(node, 'mouseover',
+                         function(vt100, node) {
+                           return function() {
+                             node.className = 'hover';
+                           }
+                         }(this, node));
+        this.addListener(node, 'mouseout',
+                         function(vt100, node) {
+                           return function() {
+                             node.className = '';
+                           }
+                         }(this, node));
+        this.addListener(node, 'mousedown',
+                         function(vt100, action) {
+                           return function(event) {
+                             vt100.hideContextMenu();
+                             action.call(vt100);
+                             vt100.storeUserSettings();
+                             return vt100.cancelEvent(event || window.event);
+                           }
+                         }(this, actions[i]));
+        this.addListener(node, 'mouseup',
+                         function(vt100) {
+                           return function(event) {
+                             return vt100.cancelEvent(event || window.event);
+                           }
+                         }(this));
+        this.addListener(node, 'mouseclick',
+                         function(vt100) {
+                           return function(event) {
+                             return vt100.cancelEvent(event || window.event);
+                           }
+                         }());
+      }
+      i++;
+    }
+  }
+
+  // Position menu next to the mouse pointer
+  this.menu.style.left        = '0px';
+  this.menu.style.top         = '0px';
+  this.menu.style.width       =  this.container.offsetWidth  + 'px';
+  this.menu.style.height      =  this.container.offsetHeight + 'px';
+  popup.style.left            = '0px';
+  popup.style.top             = '0px';
+
+  var margin                  = 2;
+  if (x + popup.clientWidth >= this.container.offsetWidth - margin) {
+    x              = this.container.offsetWidth-popup.clientWidth - margin - 1;
+  }
+  if (x < margin) {
+    x                         = margin;
+  }
+  if (y + popup.clientHeight >= this.container.offsetHeight - margin) {
+    y            = this.container.offsetHeight-popup.clientHeight - margin - 1;
+  }
+  if (y < margin) {
+    y                         = margin;
+  }
+  popup.style.left            = x + 'px';
+  popup.style.top             = y + 'px';
+
+  // Block all other interactions with the terminal emulator
+  this.addListener(this.menu, 'click', function(vt100) {
+                                         return function() {
+                                           vt100.hideContextMenu();
+                                         }
+                                       }(this));
+
+  // Show the menu
+  this.menu.style.visibility  = '';
+};
+
+VT100.prototype.keysPressed = function(ch) {
+  for (var i = 0; i < ch.length; i++) {
+    var c = ch.charCodeAt(i);
+    this.vt100(c >= 7 && c <= 15 ||
+               c == 24 || c == 26 || c == 27 || c >= 32
+               ? String.fromCharCode(c) : '<' + c + '>');
+  }
+};
+
+VT100.prototype.applyModifiers = function(ch, event) {
+  if (ch) {
+    if (event.ctrlKey) {
+      if (ch >= 32 && ch <= 127) {
+        // For historic reasons, some control characters are treated specially
+        switch (ch) {
+        case /* 3 */ 51: ch  =  27; break;
+        case /* 4 */ 52: ch  =  28; break;
+        case /* 5 */ 53: ch  =  29; break;
+        case /* 6 */ 54: ch  =  30; break;
+        case /* 7 */ 55: ch  =  31; break;
+        case /* 8 */ 56: ch  = 127; break;
+        case /* ? */ 63: ch  = 127; break;
+        default:         ch &=  31; break;
+        }
+      }
+    }
+    return String.fromCharCode(ch);
+  } else {
+    return undefined;
+  }
+};
+
+VT100.prototype.handleKey = function(event) {
+  // this.vt100('H: c=' + event.charCode + ', k=' + event.keyCode +
+  //            (event.shiftKey || event.ctrlKey || event.altKey ||
+  //             event.metaKey ? ', ' +
+  //             (event.shiftKey ? 'S' : '') + (event.ctrlKey ? 'C' : '') +
+  //             (event.altKey ? 'A' : '') + (event.metaKey ? 'M' : '') : '') +
+  //            '\r\n');
+  var ch, key;
+  if (typeof event.charCode != 'undefined') {
+    // non-IE keypress events have a translated charCode value. Also, our
+    // fake events generated when receiving keydown events include this data
+    // on all browsers.
+    ch                                = event.charCode;
+    key                               = event.keyCode;
+  } else {
+    // When sending a keypress event, IE includes the translated character
+    // code in the keyCode field.
+    ch                                = event.keyCode;
+    key                               = undefined;
+  }
+
+  // Apply modifier keys (ctrl and shift)
+  if (ch) {
+    key                               = undefined;
+  }
+  ch                                  = this.applyModifiers(ch, event);
+
+  // By this point, "ch" is either defined and contains the character code, or
+  // it is undefined and "key" defines the code of a function key
+  if (ch != undefined) {
+    this.scrollable.scrollTop         = this.numScrollbackLines *
+                                        this.cursorHeight + 1;
+  } else {
+    if ((event.altKey || event.metaKey) && !event.shiftKey && !event.ctrlKey) {
+      // Many programs have difficulties dealing with parametrized escape
+      // sequences for function keys. Thus, if ALT is the only modifier
+      // key, return Emacs-style keycodes for commonly used keys.
+      switch (key) {
+      case  33: /* Page Up      */ ch = '\u001B<';                      break;
+      case  34: /* Page Down    */ ch = '\u001B>';                      break;
+      case  37: /* Left         */ ch = '\u001Bb';                      break;
+      case  38: /* Up           */ ch = '\u001Bp';                      break;
+      case  39: /* Right        */ ch = '\u001Bf';                      break;
+      case  40: /* Down         */ ch = '\u001Bn';                      break;
+      case  46: /* Delete       */ ch = '\u001Bd';                      break;
+      default:                                                          break;
+      }
+    } else if (event.shiftKey && !event.ctrlKey &&
+               !event.altKey && !event.metaKey) {
+      switch (key) {
+      case  33: /* Page Up      */ this.scrollBack();                   return;
+      case  34: /* Page Down    */ this.scrollFore();                   return;
+      default:                                                          break;
+      }
+    }
+    if (ch == undefined) {
+      switch (key) {
+      case   8: /* Backspace    */ ch = '\u007f';                       break;
+      case   9: /* Tab          */ ch = '\u0009';                       break;
+      case  10: /* Return       */ ch = '\u000A';                       break;
+      case  13: /* Enter        */ ch = this.crLfMode ?
+                                        '\r\n' : '\r';                  break;
+      case  16: /* Shift        */                                      return;
+      case  17: /* Ctrl         */                                      return;
+      case  18: /* Alt          */                                      return;
+      case  19: /* Break        */                                      return;
+      case  20: /* Caps Lock    */                                      return;
+      case  27: /* Escape       */ ch = '\u001B';                       break;
+      case  33: /* Page Up      */ ch = '\u001B[5~';                    break;
+      case  34: /* Page Down    */ ch = '\u001B[6~';                    break;
+      case  35: /* End          */ ch = '\u001BOF';                     break;
+      case  36: /* Home         */ ch = '\u001BOH';                     break;
+      case  37: /* Left         */ ch = this.cursorKeyMode ?
+                             '\u001BOD' : '\u001B[D';                   break;
+      case  38: /* Up           */ ch = this.cursorKeyMode ?
+                             '\u001BOA' : '\u001B[A';                   break;
+      case  39: /* Right        */ ch = this.cursorKeyMode ?
+                             '\u001BOC' : '\u001B[C';                   break;
+      case  40: /* Down         */ ch = this.cursorKeyMode ?
+                             '\u001BOB' : '\u001B[B';                   break;
+      case  45: /* Insert       */ ch = '\u001B[2~';                    break;
+      case  46: /* Delete       */ ch = '\u001B[3~';                    break;
+      case  91: /* Left Window  */                                      return;
+      case  92: /* Right Window */                                      return;
+      case  93: /* Select       */                                      return;
+      case  96: /* 0            */ ch = this.applyModifiers(48, event); break;
+      case  97: /* 1            */ ch = this.applyModifiers(49, event); break;
+      case  98: /* 2            */ ch = this.applyModifiers(50, event); break;
+      case  99: /* 3            */ ch = this.applyModifiers(51, event); break;
+      case 100: /* 4            */ ch = this.applyModifiers(52, event); break;
+      case 101: /* 5            */ ch = this.applyModifiers(53, event); break;
+      case 102: /* 6            */ ch = this.applyModifiers(54, event); break;
+      case 103: /* 7            */ ch = this.applyModifiers(55, event); break;
+      case 104: /* 8            */ ch = this.applyModifiers(56, event); break;
+      case 105: /* 9            */ ch = this.applyModifiers(58, event); break;
+      case 106: /* *            */ ch = this.applyModifiers(42, event); break;
+      case 107: /* +            */ ch = this.applyModifiers(43, event); break;
+      case 109: /* -            */ ch = this.applyModifiers(45, event); break;
+      case 110: /* .            */ ch = this.applyModifiers(46, event); break;
+      case 111: /* /            */ ch = this.applyModifiers(47, event); break;
+      case 112: /* F1           */ ch = '\u001BOP';                     break;
+      case 113: /* F2           */ ch = '\u001BOQ';                     break;
+      case 114: /* F3           */ ch = '\u001BOR';                     break;
+      case 115: /* F4           */ ch = '\u001BOS';                     break;
+      case 116: /* F5           */ ch = '\u001B[15~';                   break;
+      case 117: /* F6           */ ch = '\u001B[17~';                   break;
+      case 118: /* F7           */ ch = '\u001B[18~';                   break;
+      case 119: /* F8           */ ch = '\u001B[19~';                   break;
+      case 120: /* F9           */ ch = '\u001B[20~';                   break;
+      case 121: /* F10          */ ch = '\u001B[21~';                   break;
+      case 122: /* F11          */ ch = '\u001B[23~';                   break;
+      case 123: /* F12          */ ch = '\u001B[24~';                   break;
+      case 144: /* Num Lock     */                                      return;
+      case 145: /* Scroll Lock  */                                      return;
+      case 186: /* ;            */ ch = this.applyModifiers(59, event); break;
+      case 187: /* =            */ ch = this.applyModifiers(61, event); break;
+      case 188: /* ,            */ ch = this.applyModifiers(44, event); break;
+      case 189: /* -            */ ch = this.applyModifiers(45, event); break;
+      case 173: /* -            */ ch = this.applyModifiers(45, event); break; // FF15 Patch
+      case 190: /* .            */ ch = this.applyModifiers(46, event); break;
+      case 191: /* /            */ ch = this.applyModifiers(47, event); break;
+      // Conflicts with dead key " on Swiss keyboards
+      //case 192: /* `            */ ch = this.applyModifiers(96, event); break;
+      // Conflicts with dead key " on Swiss keyboards
+      //case 219: /* [            */ ch = this.applyModifiers(91, event); break;
+      case 220: /* \            */ ch = this.applyModifiers(92, event); break;
+      // Conflicts with dead key ^ and ` on Swiss keaboards
+      //                         ^ and " on French keyboards
+      //case 221: /* ]            */ ch = this.applyModifiers(93, event); break;
+      case 222: /* '            */ ch = this.applyModifiers(39, event); break;
+      default:                                                          return;
+      }
+      this.scrollable.scrollTop       = this.numScrollbackLines *
+                                        this.cursorHeight + 1;
+    }
+  }
+
+  // "ch" now contains the sequence of keycodes to send. But we might still
+  // have to apply the effects of modifier keys.
+  if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
+    var start, digit, part1, part2;
+    if ((start = ch.substr(0, 2)) == '\u001B[') {
+      for (part1 = start;
+           part1.length < ch.length &&
+             (digit = ch.charCodeAt(part1.length)) >= 48 && digit <= 57; ) {
+        part1                         = ch.substr(0, part1.length + 1);
+      }
+      part2                           = ch.substr(part1.length);
+      if (part1.length > 2) {
+        part1                        += ';';
+      }
+    } else if (start == '\u001BO') {
+      part1                           = start;
+      part2                           = ch.substr(2);
+    }
+    if (part1 != undefined) {
+      ch                              = part1                                 +
+                                       ((event.shiftKey             ? 1 : 0)  +
+                                        (event.altKey|event.metaKey ? 2 : 0)  +
+                                        (event.ctrlKey              ? 4 : 0)) +
+                                        part2;
+    } else if (ch.length == 1 && (event.altKey || event.metaKey)) {
+      ch                              = '\u001B' + ch;
+    }
+  }
+
+  if (this.menu.style.visibility == 'hidden') {
+    // this.vt100('R: c=');
+    // for (var i = 0; i < ch.length; i++)
+    //   this.vt100((i != 0 ? ', ' : '') + ch.charCodeAt(i));
+    // this.vt100('\r\n');
+    this.keysPressed(ch);
+  }
+};
+
+VT100.prototype.inspect = function(o, d) {
+  if (d == undefined) {
+    d       = 0;
+  }
+  var rc    = '';
+  if (typeof o == 'object' && ++d < 2) {
+    rc      = '[\r\n';
+    for (i in o) {
+      rc   += this.spaces(d * 2) + i + ' -> ';
+      try {
+        rc += this.inspect(o[i], d);
+      } catch (e) {
+        rc += '?' + '?' + '?\r\n';
+      }
+    }
+    rc     += ']\r\n';
+  } else {
+    rc     += ('' + o).replace(/\n/g, ' ').replace(/ +/g,' ') + '\r\n';
+  }
+  return rc;
+};
+
+VT100.prototype.checkComposedKeys = function(event) {
+  // Composed keys (at least on Linux) do not generate normal events.
+  // Instead, they get entered into the text field. We normally catch
+  // this on the next keyup event.
+  var s              = this.input.value;
+  if (s.length) {
+    this.input.value = '';
+    if (this.menu.style.visibility == 'hidden') {
+      this.keysPressed(s);
+    }
+  }
+};
+
+VT100.prototype.fixEvent = function(event) {
+  // Some browsers report AltGR as a combination of ALT and CTRL. As AltGr
+  // is used as a second-level selector, clear the modifier bits before
+  // handling the event.
+  if (event.ctrlKey && event.altKey) {
+    var fake                = [ ];
+    fake.charCode           = event.charCode;
+    fake.keyCode            = event.keyCode;
+    fake.ctrlKey            = false;
+    fake.shiftKey           = event.shiftKey;
+    fake.altKey             = false;
+    fake.metaKey            = event.metaKey;
+    return fake;
+  }
+
+  // Some browsers fail to translate keys, if both shift and alt/meta is
+  // pressed at the same time. We try to translate those cases, but that
+  // only works for US keyboard layouts.
+  if (event.shiftKey) {
+    var u                   = undefined;
+    var s                   = undefined;
+    switch (this.lastNormalKeyDownEvent.keyCode) {
+    case  39: /* ' -> " */ u = 39; s =  34; break;
+    case  44: /* , -> < */ u = 44; s =  60; break;
+    case  45: /* - -> _ */ u = 45; s =  95; break;
+    case  46: /* . -> > */ u = 46; s =  62; break;
+    case  47: /* / -> ? */ u = 47; s =  63; break;
+
+    case  48: /* 0 -> ) */ u = 48; s =  41; break;
+    case  49: /* 1 -> ! */ u = 49; s =  33; break;
+    case  50: /* 2 -> @ */ u = 50; s =  64; break;
+    case  51: /* 3 -> # */ u = 51; s =  35; break;
+    case  52: /* 4 -> $ */ u = 52; s =  36; break;
+    case  53: /* 5 -> % */ u = 53; s =  37; break;
+    case  54: /* 6 -> ^ */ u = 54; s =  94; break;
+    case  55: /* 7 -> & */ u = 55; s =  38; break;
+    case  56: /* 8 -> * */ u = 56; s =  42; break;
+    case  57: /* 9 -> ( */ u = 57; s =  40; break;
+
+    case  59: /* ; -> : */ u = 59; s =  58; break;
+    case  61: /* = -> + */ u = 61; s =  43; break;
+    case  91: /* [ -> { */ u = 91; s = 123; break;
+    case  92: /* \ -> | */ u = 92; s = 124; break;
+    case  93: /* ] -> } */ u = 93; s = 125; break;
+    case  96: /* ` -> ~ */ u = 96; s = 126; break;
+
+    case 109: /* - -> _ */ u = 45; s =  95; break;
+    case 111: /* / -> ? */ u = 47; s =  63; break;
+
+    case 186: /* ; -> : */ u = 59; s =  58; break;
+    case 187: /* = -> + */ u = 61; s =  43; break;
+    case 188: /* , -> < */ u = 44; s =  60; break;
+    case 189: /* - -> _ */ u = 45; s =  95; break;
+    case 173: /* - -> _ */ u = 45; s =  95; break; // FF15 Patch
+    case 190: /* . -> > */ u = 46; s =  62; break;
+    case 191: /* / -> ? */ u = 47; s =  63; break;
+    case 192: /* ` -> ~ */ u = 96; s = 126; break;
+    case 219: /* [ -> { */ u = 91; s = 123; break;
+    case 220: /* \ -> | */ u = 92; s = 124; break;
+    case 221: /* ] -> } */ u = 93; s = 125; break;
+    case 222: /* ' -> " */ u = 39; s =  34; break;
+    default:                                break;
+    }
+    if (s && (event.charCode == u || event.charCode == 0)) {
+      var fake              = [ ];
+      fake.charCode         = s;
+      fake.keyCode          = event.keyCode;
+      fake.ctrlKey          = event.ctrlKey;
+      fake.shiftKey         = event.shiftKey;
+      fake.altKey           = event.altKey;
+      fake.metaKey          = event.metaKey;
+      return fake;
+    }
+  }
+  return event;
+};
+
+VT100.prototype.keyDown = function(event) {
+  // this.vt100('D: c=' + event.charCode + ', k=' + event.keyCode +
+  //            (event.shiftKey || event.ctrlKey || event.altKey ||
+  //             event.metaKey ? ', ' +
+  //             (event.shiftKey ? 'S' : '') + (event.ctrlKey ? 'C' : '') +
+  //             (event.altKey ? 'A' : '') + (event.metaKey ? 'M' : '') : '') +
+  //            '\r\n');
+  this.checkComposedKeys(event);
+  this.lastKeyPressedEvent      = undefined;
+  this.lastKeyDownEvent         = undefined;
+  this.lastNormalKeyDownEvent   = event;
+
+  // Swiss keyboard conflicts:
+  // [ 59
+  // ] 192
+  // ' 219 (dead key)
+  // { 220
+  // ~ 221 (dead key)
+  // } 223
+  // French keyoard conflicts:
+  // ~ 50 (dead key)
+  // } 107
+  var asciiKey                  =
+    event.keyCode ==  32                         ||
+    event.keyCode >=  48 && event.keyCode <=  57 ||
+    event.keyCode >=  65 && event.keyCode <=  90;
+  var alphNumKey                =
+    asciiKey                                     ||
+    event.keyCode ==  59 ||
+    event.keyCode >=  96 && event.keyCode <= 105 ||
+    event.keyCode == 107 ||
+    event.keyCode == 192 ||
+    event.keyCode >= 219 && event.keyCode <= 221 ||
+    event.keyCode == 223 ||
+    event.keyCode == 226;
+  var normalKey                 =
+    alphNumKey                                   ||
+    event.keyCode ==  61 ||
+    event.keyCode == 106 ||
+    event.keyCode >= 109 && event.keyCode <= 111 ||
+    event.keyCode >= 186 && event.keyCode <= 191 ||
+    event.keyCode == 222 ||
+    event.keyCode == 252;
+  try {
+    if (navigator.appName == 'Konqueror') {
+      normalKey                |= event.keyCode < 128;
+    }
+  } catch (e) {
+  }
+
+  // We normally prefer to look at keypress events, as they perform the
+  // translation from keyCode to charCode. This is important, as the
+  // translation is locale-dependent.
+  // But for some keys, we must intercept them during the keydown event,
+  // as they would otherwise get interpreted by the browser.
+  // Even, when doing all of this, there are some keys that we can never
+  // intercept. This applies to some of the menu navigation keys in IE.
+  // In fact, we see them, but we cannot stop IE from seeing them, too.
+  if ((event.charCode || event.keyCode) &&
+      ((alphNumKey && (event.ctrlKey || event.altKey || event.metaKey) &&
+        !event.shiftKey &&
+        // Some browsers signal AltGR as both CTRL and ALT. Do not try to
+        // interpret this sequence ourselves, as some keyboard layouts use
+        // it for second-level layouts.
+        !(event.ctrlKey && event.altKey)) ||
+       this.catchModifiersEarly && normalKey && !alphNumKey &&
+       (event.ctrlKey || event.altKey || event.metaKey) ||
+       !normalKey)) {
+    this.lastKeyDownEvent       = event;
+    var fake                    = [ ];
+    fake.ctrlKey                = event.ctrlKey;
+    fake.shiftKey               = event.shiftKey;
+    fake.altKey                 = event.altKey;
+    fake.metaKey                = event.metaKey;
+    if (asciiKey) {
+      fake.charCode             = event.keyCode;
+      fake.keyCode              = 0;
+    } else {
+      fake.charCode             = 0;
+      fake.keyCode              = event.keyCode;
+      if (!alphNumKey && event.shiftKey) {
+        fake                    = this.fixEvent(fake);
+      }
+    }
+
+    this.handleKey(fake);
+    this.lastNormalKeyDownEvent = undefined;
+
+    try {
+      // For non-IE browsers
+      event.stopPropagation();
+      event.preventDefault();
+    } catch (e) {
+    }
+    try {
+      // For IE
+      event.cancelBubble = true;
+      event.returnValue  = false;
+      event.keyCode      = 0;
+    } catch (e) {
+    }
+
+    return false;
+  }
+  return true;
+};
+
+VT100.prototype.keyPressed = function(event) {
+  // this.vt100('P: c=' + event.charCode + ', k=' + event.keyCode +
+  //            (event.shiftKey || event.ctrlKey || event.altKey ||
+  //             event.metaKey ? ', ' +
+  //             (event.shiftKey ? 'S' : '') + (event.ctrlKey ? 'C' : '') +
+  //             (event.altKey ? 'A' : '') + (event.metaKey ? 'M' : '') : '') +
+  //            '\r\n');
+  if (this.lastKeyDownEvent) {
+    // If we already processed the key on keydown, do not process it
+    // again here. Ideally, the browser should not even have generated a
+    // keypress event in this case. But that does not appear to always work.
+    this.lastKeyDownEvent     = undefined;
+  } else {
+    this.handleKey(event.altKey || event.metaKey
+                   ? this.fixEvent(event) : event);
+  }
+
+  try {
+    // For non-IE browsers
+    event.preventDefault();
+  } catch (e) {
+  }
+
+  try {
+    // For IE
+    event.cancelBubble = true;
+    event.returnValue  = false;
+    event.keyCode      = 0;
+  } catch (e) {
+  }
+
+  this.lastNormalKeyDownEvent = undefined;
+  this.lastKeyPressedEvent    = event;
+  return false;
+};
+
+VT100.prototype.keyUp = function(event) {
+  // this.vt100('U: c=' + event.charCode + ', k=' + event.keyCode +
+  //            (event.shiftKey || event.ctrlKey || event.altKey ||
+  //             event.metaKey ? ', ' +
+  //             (event.shiftKey ? 'S' : '') + (event.ctrlKey ? 'C' : '') +
+  //             (event.altKey ? 'A' : '') + (event.metaKey ? 'M' : '') : '') +
+  //            '\r\n');
+  if (this.lastKeyPressedEvent) {
+    // The compose key on Linux occasionally confuses the browser and keeps
+    // inserting bogus characters into the input field, even if just a regular
+    // key has been pressed. Detect this case and drop the bogus characters.
+    (event.target ||
+     event.srcElement).value      = '';
+  } else {
+    // This is usually were we notice that a key has been composed and
+    // thus failed to generate normal events.
+    this.checkComposedKeys(event);
+
+    // Some browsers don't report keypress events if ctrl or alt is pressed
+    // for non-alphanumerical keys. Patch things up for now, but in the
+    // future we will catch these keys earlier (in the keydown handler).
+    if (this.lastNormalKeyDownEvent) {
+      // this.vt100('ENABLING EARLY CATCHING OF MODIFIER KEYS\r\n');
+      this.catchModifiersEarly    = true;
+      var asciiKey                =
+        event.keyCode ==  32                         ||
+        // Conflicts with dead key ~ (code 50) on French keyboards
+        //event.keyCode >=  48 && event.keyCode <=  57 ||
+        event.keyCode >=  48 && event.keyCode <=  49 ||
+        event.keyCode >=  51 && event.keyCode <=  57 ||
+        event.keyCode >=  65 && event.keyCode <=  90;
+      var alphNumKey              =
+        asciiKey                                     ||
+        event.keyCode ==  50                         ||
+        event.keyCode >=  96 && event.keyCode <= 105;
+      var normalKey               =
+        alphNumKey                                   ||
+        event.keyCode ==  59 || event.keyCode ==  61 ||
+        event.keyCode == 106 || event.keyCode == 107 ||
+        event.keyCode >= 109 && event.keyCode <= 111 ||
+        event.keyCode >= 186 && event.keyCode <= 192 ||
+        event.keyCode >= 219 && event.keyCode <= 223 ||
+        event.keyCode == 252;
+      var fake                    = [ ];
+      fake.ctrlKey                = event.ctrlKey;
+      fake.shiftKey               = event.shiftKey;
+      fake.altKey                 = event.altKey;
+      fake.metaKey                = event.metaKey;
+      if (asciiKey) {
+        fake.charCode             = event.keyCode;
+        fake.keyCode              = 0;
+      } else {
+        fake.charCode             = 0;
+        fake.keyCode              = event.keyCode;
+        if (!alphNumKey && (event.ctrlKey || event.altKey || event.metaKey)) {
+          fake                    = this.fixEvent(fake);
+        }
+      }
+      this.lastNormalKeyDownEvent = undefined;
+      this.handleKey(fake);
+    }
+  }
+
+  try {
+    // For IE
+    event.cancelBubble            = true;
+    event.returnValue             = false;
+    event.keyCode                 = 0;
+  } catch (e) {
+  }
+
+  this.lastKeyDownEvent           = undefined;
+  this.lastKeyPressedEvent        = undefined;
+  return false;
+};
+
+VT100.prototype.animateCursor = function(inactive) {
+  if (!this.cursorInterval) {
+    this.cursorInterval       = setInterval(
+      function(vt100) {
+        return function() {
+          vt100.animateCursor();
+
+          // Use this opportunity to check whether the user entered a composed
+          // key, or whether somebody pasted text into the textfield.
+          vt100.checkComposedKeys();
+        }
+      }(this), 500);
+  }
+  if (inactive != undefined || this.cursor.className != 'inactive') {
+    if (inactive) {
+      this.cursor.className   = 'inactive';
+    } else {
+      if (this.blinkingCursor) {
+        this.cursor.className = this.cursor.className == 'bright'
+                                ? 'dim' : 'bright';
+      } else {
+        this.cursor.className = 'bright';
+      }
+    }
+  }
+};
+
+VT100.prototype.blurCursor = function() {
+  this.animateCursor(true);
+};
+
+VT100.prototype.focusCursor = function() {
+  this.animateCursor(false);
+};
+
+VT100.prototype.flashScreen = function() {
+  this.isInverted       = !this.isInverted;
+  this.refreshInvertedState();
+  this.isInverted       = !this.isInverted;
+  setTimeout(function(vt100) {
+               return function() {
+                 vt100.refreshInvertedState();
+               };
+             }(this), 100);
+};
+
+VT100.prototype.beep = function() {
+  if (this.visualBell) {
+    this.flashScreen();
+  } else {
+    try {
+      this.beeper.Play();
+    } catch (e) {
+      try {
+        this.beeper.src = 'beep.wav';
+      } catch (e) {
+      }
+    }
+  }
+};
+
+VT100.prototype.bs = function() {
+  if (this.cursorX > 0) {
+    this.gotoXY(this.cursorX - 1, this.cursorY);
+    this.needWrap = false;
+  }
+};
+
+VT100.prototype.ht = function(count) {
+  if (count == undefined) {
+    count        = 1;
+  }
+  var cx         = this.cursorX;
+  while (count-- > 0) {
+    while (cx++ < this.terminalWidth) {
+      var tabState = this.userTabStop[cx];
+      if (tabState == false) {
+        // Explicitly cleared tab stop
+        continue;
+      } else if (tabState) {
+        // Explicitly set tab stop
+        break;
+      } else {
+        // Default tab stop at each eighth column
+        if (cx % 8 == 0) {
+          break;
+        }
+      }
+    }
+  }
+  if (cx > this.terminalWidth - 1) {
+    cx           = this.terminalWidth - 1;
+  }
+  if (cx != this.cursorX) {
+    this.gotoXY(cx, this.cursorY);
+  }
+};
+
+VT100.prototype.rt = function(count) {
+  if (count == undefined) {
+    count          = 1 ;
+  }
+  var cx           = this.cursorX;
+  while (count-- > 0) {
+    while (cx-- > 0) {
+      var tabState = this.userTabStop[cx];
+      if (tabState == false) {
+        // Explicitly cleared tab stop
+        continue;
+      } else if (tabState) {
+        // Explicitly set tab stop
+        break;
+      } else {
+        // Default tab stop at each eighth column
+        if (cx % 8 == 0) {
+          break;
+        }
+      }
+    }
+  }
+  if (cx < 0) {
+    cx             = 0;
+  }
+  if (cx != this.cursorX) {
+    this.gotoXY(cx, this.cursorY);
+  }
+};
+
+VT100.prototype.cr = function() {
+  this.gotoXY(0, this.cursorY);
+  this.needWrap = false;
+};
+
+VT100.prototype.lf = function(count) {
+  if (count == undefined) {
+    count    = 1;
+  } else {
+    if (count > this.terminalHeight) {
+      count  = this.terminalHeight;
+    }
+    if (count < 1) {
+      count  = 1;
+    }
+  }
+  while (count-- > 0) {
+    if (this.cursorY == this.bottom - 1) {
+      this.scrollRegion(0, this.top + 1,
+                        this.terminalWidth, this.bottom - this.top - 1,
+                        0, -1, this.color, this.style);
+      offset = undefined;
+    } else if (this.cursorY < this.terminalHeight - 1) {
+      this.gotoXY(this.cursorX, this.cursorY + 1);
+    }
+  }
+};
+
+VT100.prototype.ri = function(count) {
+  if (count == undefined) {
+    count   = 1;
+  } else {
+    if (count > this.terminalHeight) {
+      count = this.terminalHeight;
+    }
+    if (count < 1) {
+      count = 1;
+    }
+  }
+  while (count-- > 0) {
+    if (this.cursorY == this.top) {
+      this.scrollRegion(0, this.top,
+                        this.terminalWidth, this.bottom - this.top - 1,
+                        0, 1, this.color, this.style);
+    } else if (this.cursorY > 0) {
+      this.gotoXY(this.cursorX, this.cursorY - 1);
+    }
+  }
+  this.needWrap = false;
+};
+
+VT100.prototype.respondID = function() {
+  this.respondString += '\u001B[?6c';
+};
+
+VT100.prototype.respondSecondaryDA = function() {
+  this.respondString += '\u001B[>0;0;0c';
+};
+
+
+VT100.prototype.updateStyle = function() {
+  this.style   = '';
+  if (this.attr & 0x0200 /* ATTR_UNDERLINE */) {
+    this.style = 'text-decoration: underline;';
+  }
+  var bg       = (this.attr >> 4) & 0xF;
+  var fg       =  this.attr       & 0xF;
+  if (this.attr & 0x0100 /* ATTR_REVERSE */) {
+    var tmp    = bg;
+    bg         = fg;
+    fg         = tmp;
+  }
+  if ((this.attr & (0x0100 /* ATTR_REVERSE */ | 0x0400 /* ATTR_DIM */)) == 0x0400 /* ATTR_DIM */) {
+    fg         = 8; // Dark grey
+  } else if (this.attr & 0x0800 /* ATTR_BRIGHT */) {
+    fg        |= 8;
+    this.style = 'font-weight: bold;';
+  }
+  if (this.attr & 0x1000 /* ATTR_BLINK */) {
+    this.style = 'text-decoration: blink;';
+  }
+  this.color   = 'ansi' + fg + ' bgAnsi' + bg;
+};
+
+VT100.prototype.setAttrColors = function(attr) {
+  if (attr != this.attr) {
+    this.attr = attr;
+    this.updateStyle();
+  }
+};
+
+VT100.prototype.saveCursor = function() {
+  this.savedX[this.currentScreen]     = this.cursorX;
+  this.savedY[this.currentScreen]     = this.cursorY;
+  this.savedAttr[this.currentScreen]  = this.attr;
+  this.savedUseGMap                   = this.useGMap;
+  for (var i = 0; i < 4; i++) {
+    this.savedGMap[i]                 = this.GMap[i];
+  }
+  this.savedValid[this.currentScreen] = true;
+};
+
+VT100.prototype.restoreCursor = function() {
+  if (!this.savedValid[this.currentScreen]) {
+    return;
+  }
+  this.attr      = this.savedAttr[this.currentScreen];
+  this.updateStyle();
+  this.useGMap   = this.savedUseGMap;
+  for (var i = 0; i < 4; i++) {
+    this.GMap[i] = this.savedGMap[i];
+  }
+  this.translate = this.GMap[this.useGMap];
+  this.needWrap  = false;
+  this.gotoXY(this.savedX[this.currentScreen],
+              this.savedY[this.currentScreen]);
+};
+
+VT100.prototype.getTransformName = function() {
+  var styles = [ 'transform', 'WebkitTransform', 'MozTransform', 'filter' ];
+  for (var i = 0; i < styles.length; ++i) {
+    if (typeof this.console[0].style[styles[i]] != 'undefined') {
+      return styles[i];
+    }
+  }
+  return undefined;
+};
+
+VT100.prototype.getTransformStyle = function(transform, scale) {
+  return scale && scale != 1.0
+    ? transform == 'filter'
+      ? 'progid:DXImageTransform.Microsoft.Matrix(' +
+                                 'M11=' + (1.0/scale) + ',M12=0,M21=0,M22=1,' +
+                                 "sizingMethod='auto expand')"
+      : 'translateX(-50%) ' +
+        'scaleX(' + (1.0/scale) + ') ' +
+        'translateX(50%)'
+    : '';
+};
+
+VT100.prototype.set80_132Mode = function(state) {
+  var transform                  = this.getTransformName();
+  if (transform) {
+    if ((this.console[this.currentScreen].style[transform] != '') == state) {
+      return;
+    }
+    var style                    = state ?
+                                   this.getTransformStyle(transform, 1.65):'';
+    this.console[this.currentScreen].style[transform] = style;
+    this.cursor.style[transform] = style;
+    this.space.style[transform]  = style;
+    this.scale                   = state ? 1.65 : 1.0;
+    if (transform == 'filter') {
+      this.console[this.currentScreen].style.width = state ? '165%' : '';
+    }
+    this.resizer();
+  }
+};
+
+VT100.prototype.setMode = function(state) {
+  for (var i = 0; i <= this.npar; i++) {
+    if (this.isQuestionMark) {
+      switch (this.par[i]) {
+      case  1: this.cursorKeyMode      = state;                      break;
+      case  3: this.set80_132Mode(state);                            break;
+      case  5: this.isInverted = state; this.refreshInvertedState(); break;
+      case  6: this.offsetMode         = state;                      break;
+      case  7: this.autoWrapMode       = state;                      break;
+      case 1000:
+      case  9: this.mouseReporting     = state;                      break;
+      case 25: this.cursorNeedsShowing = state;
+               if (state) { this.showCursor(); }
+               else       { this.hideCursor(); }                     break;
+      case 1047:
+      case 1049:
+      case 47: this.enableAlternateScreen(state);                    break;
+      default:                                                       break;
+      }
+    } else {
+      switch (this.par[i]) {
+      case  3: this.dispCtrl           = state;                      break;
+      case  4: this.insertMode         = state;                      break;
+      case  20:this.crLfMode           = state;                      break;
+      default:                                                       break;
+      }
+    }
+  }
+};
+
+VT100.prototype.statusReport = function() {
+  // Ready and operational.
+  this.respondString += '\u001B[0n';
+};
+
+VT100.prototype.cursorReport = function() {
+  this.respondString += '\u001B[' +
+                        (this.cursorY + (this.offsetMode ? this.top + 1 : 1)) +
+                        ';' +
+                        (this.cursorX + 1) +
+                        'R';
+};
+
+VT100.prototype.setCursorAttr = function(setAttr, xorAttr) {
+  // Changing of cursor color is not implemented.
+};
+
+VT100.prototype.openPrinterWindow = function() {
+  var rc            = true;
+  try {
+    if (!this.printWin || this.printWin.closed) {
+      this.printWin = window.open('', 'print-output',
+        'width=800,height=600,directories=no,location=no,menubar=yes,' +
+        'status=no,toolbar=no,titlebar=yes,scrollbars=yes,resizable=yes');
+      this.printWin.document.body.innerHTML =
+        '<link rel="stylesheet" href="' +
+          document.location.protocol + '//' + document.location.host +
+          document.location.pathname.replace(/[^/]*$/, '') +
+          'print-styles.css" type="text/css">\n' +
+        '<div id="options"><input id="autoprint" type="checkbox"' +
+          (this.autoprint ? ' checked' : '') + '>' +
+          'Automatically, print page(s) when job is ready' +
+        '</input></div>\n' +
+        '<div id="spacer"><input type="checkbox">&nbsp;</input></div>' +
+        '<pre id="print"></pre>\n';
+      var autoprint = this.printWin.document.getElementById('autoprint');
+      this.addListener(autoprint, 'click',
+                       (function(vt100, autoprint) {
+                         return function() {
+                           vt100.autoprint = autoprint.checked;
+                           vt100.storeUserSettings();
+                           return false;
+                         };
+                       })(this, autoprint));
+      this.printWin.document.title = 'ShellInABox Printer Output';
+    }
+  } catch (e) {
+    // Maybe, a popup blocker prevented us from working. Better catch the
+    // exception, so that we won't break the entire terminal session. The
+    // user probably needs to disable the blocker first before retrying the
+    // operation.
+    rc              = false;
+  }
+  rc               &= this.printWin && !this.printWin.closed &&
+                      (this.printWin.innerWidth ||
+                       this.printWin.document.documentElement.clientWidth ||
+                       this.printWin.document.body.clientWidth) > 1;
+
+  if (!rc && this.printing == 100) {
+    // Different popup blockers work differently. We try to detect a couple
+    // of common methods. And then we retry again a brief amount later, as
+    // false positives are otherwise possible. If we are sure that there is
+    // a popup blocker in effect, we alert the user to it. This is helpful
+    // as some popup blockers have minimal or no UI, and the user might not
+    // notice that they are missing the popup. In any case, we only show at
+    // most one message per print job.
+    this.printing   = true;
+    setTimeout((function(win) {
+                  return function() {
+                    if (!win || win.closed ||
+                        (win.innerWidth ||
+                         win.document.documentElement.clientWidth ||
+                         win.document.body.clientWidth) <= 1) {
+                      alert('Attempted to print, but a popup blocker ' +
+                            'prevented the printer window from opening');
+                    }
+                  };
+                })(this.printWin), 2000);
+  }
+  return rc;
+};
+
+VT100.prototype.sendToPrinter = function(s) {
+  this.openPrinterWindow();
+  try {
+    var doc   = this.printWin.document;
+    var print = doc.getElementById('print');
+    if (print.lastChild && print.lastChild.nodeName == '#text') {
+      print.lastChild.textContent += this.replaceChar(s, ' ', '\u00A0');
+    } else {
+      print.appendChild(doc.createTextNode(this.replaceChar(s, ' ','\u00A0')));
+    }
+  } catch (e) {
+    // There probably was a more aggressive popup blocker that prevented us
+    // from accessing the printer windows.
+  }
+};
+
+VT100.prototype.sendControlToPrinter = function(ch) {
+  // We get called whenever doControl() is active. But for the printer, we
+  // only implement a basic line printer that doesn't understand most of
+  // the escape sequences of the VT100 terminal. In fact, the only escape
+  // sequence that we really need to recognize is '^[[5i' for turning the
+  // printer off.
+  try {
+    switch (ch) {
+    case  9:
+      // HT
+      this.openPrinterWindow();
+      var doc                 = this.printWin.document;
+      var print               = doc.getElementById('print');
+      var chars               = print.lastChild &&
+                                print.lastChild.nodeName == '#text' ?
+                                print.lastChild.textContent.length : 0;
+      this.sendToPrinter(this.spaces(8 - (chars % 8)));
+      break;
+    case 10:
+      // CR
+      break;
+    case 12:
+      // FF
+      this.openPrinterWindow();
+      var pageBreak           = this.printWin.document.createElement('div');
+      pageBreak.className     = 'pagebreak';
+      pageBreak.innerHTML     = '<hr />';
+      this.printWin.document.getElementById('print').appendChild(pageBreak);
+      break;
+    case 13:
+      // LF
+      this.openPrinterWindow();
+      var lineBreak           = this.printWin.document.createElement('br');
+      this.printWin.document.getElementById('print').appendChild(lineBreak);
+      break;
+    case 27:
+      // ESC
+      this.isEsc              = 1 /* ESesc */;
+      break;
+    default:
+      switch (this.isEsc) {
+      case 1 /* ESesc */:
+        this.isEsc            = 0 /* ESnormal */;
+        switch (ch) {
+        case 0x5B /*[*/:
+          this.isEsc          = 2 /* ESsquare */;
+          break;
+        default:
+          break;
+        }
+        break;
+      case 2 /* ESsquare */:
+        this.npar             = 0;
+        this.par              = [ 0, 0, 0, 0, 0, 0, 0, 0,
+                                  0, 0, 0, 0, 0, 0, 0, 0 ];
+        this.isEsc            = 3 /* ESgetpars */;
+        this.isQuestionMark   = ch == 0x3F /*?*/;
+        if (this.isQuestionMark) {
+          break;
+        }
+        // Fall through
+      case 3 /* ESgetpars */:
+        if (ch == 0x3B /*;*/) {
+          this.npar++;
+          break;
+        } else if (ch >= 0x30 /*0*/ && ch <= 0x39 /*9*/) {
+          var par             = this.par[this.npar];
+          if (par == undefined) {
+            par               = 0;
+          }
+          this.par[this.npar] = 10*par + (ch & 0xF);
+          break;
+        } else {
+          this.isEsc          = 4 /* ESgotpars */;
+        }
+        // Fall through
+      case 4 /* ESgotpars */:
+        this.isEsc            = 0 /* ESnormal */;
+        if (this.isQuestionMark) {
+          break;
+        }
+        switch (ch) {
+        case 0x69 /*i*/:
+          this.csii(this.par[0]);
+          break;
+        default:
+          break;
+        }
+        break;
+      default:
+        this.isEsc            = 0 /* ESnormal */;
+        break;
+      }
+      break;
+    }
+  } catch (e) {
+    // There probably was a more aggressive popup blocker that prevented us
+    // from accessing the printer windows.
+  }
+};
+
+VT100.prototype.csiAt = function(number) {
+  // Insert spaces
+  if (number == 0) {
+    number      = 1;
+  }
+  if (number > this.terminalWidth - this.cursorX) {
+    number      = this.terminalWidth - this.cursorX;
+  }
+  this.scrollRegion(this.cursorX, this.cursorY,
+                    this.terminalWidth - this.cursorX - number, 1,
+                    number, 0, this.color, this.style);
+  this.needWrap = false;
+};
+
+VT100.prototype.csii = function(number) {
+  // Printer control
+  switch (number) {
+  case 0: // Print Screen
+    window.print();
+    break;
+  case 4: // Stop printing
+    try {
+      if (this.printing && this.printWin && !this.printWin.closed) {
+        var print = this.printWin.document.getElementById('print');
+        while (print.lastChild &&
+               print.lastChild.tagName == 'DIV' &&
+               print.lastChild.className == 'pagebreak') {
+          // Remove trailing blank pages
+          print.removeChild(print.lastChild);
+        }
+        if (this.autoprint) {
+          this.printWin.print();
+        }
+      }
+    } catch (e) {
+    }
+    this.printing = false;
+    break;
+  case 5: // Start printing
+    if (!this.printing && this.printWin && !this.printWin.closed) {
+      this.printWin.document.getElementById('print').innerHTML = '';
+    }
+    this.printing = 100;
+    break;
+  default:
+    break;
+  }
+};
+
+VT100.prototype.csiJ = function(number) {
+  switch (number) {
+  case 0: // Erase from cursor to end of display
+    this.clearRegion(this.cursorX, this.cursorY,
+                     this.terminalWidth - this.cursorX, 1,
+                     this.color, this.style);
+    if (this.cursorY < this.terminalHeight-2) {
+      this.clearRegion(0, this.cursorY+1,
+                       this.terminalWidth, this.terminalHeight-this.cursorY-1,
+                       this.color, this.style);
+    }
+    break;
+  case 1: // Erase from start to cursor
+    if (this.cursorY > 0) {
+      this.clearRegion(0, 0,
+                       this.terminalWidth, this.cursorY,
+                       this.color, this.style);
+    }
+    this.clearRegion(0, this.cursorY, this.cursorX + 1, 1,
+                     this.color, this.style);
+    break;
+  case 2: // Erase whole display
+    this.clearRegion(0, 0, this.terminalWidth, this.terminalHeight,
+                     this.color, this.style);
+    break;
+  default:
+    return;
+  }
+  needWrap = false;
+};
+
+VT100.prototype.csiK = function(number) {
+  switch (number) {
+  case 0: // Erase from cursor to end of line
+    this.clearRegion(this.cursorX, this.cursorY,
+                     this.terminalWidth - this.cursorX, 1,
+                     this.color, this.style);
+    break;
+  case 1: // Erase from start of line to cursor
+    this.clearRegion(0, this.cursorY, this.cursorX + 1, 1,
+                     this.color, this.style);
+    break;
+  case 2: // Erase whole line
+    this.clearRegion(0, this.cursorY, this.terminalWidth, 1,
+                     this.color, this.style);
+    break;
+  default:
+    return;
+  }
+  needWrap = false;
+};
+
+VT100.prototype.csiL = function(number) {
+  // Open line by inserting blank line(s)
+  if (this.cursorY >= this.bottom) {
+    return;
+  }
+  if (number == 0) {
+    number = 1;
+  }
+  if (number > this.bottom - this.cursorY) {
+    number = this.bottom - this.cursorY;
+  }
+  this.scrollRegion(0, this.cursorY,
+                    this.terminalWidth, this.bottom - this.cursorY - number,
+                    0, number, this.color, this.style);
+  needWrap = false;
+};
+
+VT100.prototype.csiM = function(number) {
+  // Delete line(s), scrolling up the bottom of the screen.
+  if (this.cursorY >= this.bottom) {
+    return;
+  }
+  if (number == 0) {
+    number = 1;
+  }
+  if (number > this.bottom - this.cursorY) {
+    number = bottom - cursorY;
+  }
+  this.scrollRegion(0, this.cursorY + number,
+                    this.terminalWidth, this.bottom - this.cursorY - number,
+                    0, -number, this.color, this.style);
+  needWrap = false;
+};
+
+VT100.prototype.csim = function() {
+  for (var i = 0; i <= this.npar; i++) {
+    switch (this.par[i]) {
+    case 0:  this.attr  = 0x00F0 /* ATTR_DEFAULT */;                                break;
+    case 1:  this.attr  = (this.attr & ~0x0400 /* ATTR_DIM */)|0x0800 /* ATTR_BRIGHT */;         break;
+    case 2:  this.attr  = (this.attr & ~0x0800 /* ATTR_BRIGHT */)|0x0400 /* ATTR_DIM */;         break;
+    case 4:  this.attr |= 0x0200 /* ATTR_UNDERLINE */;                              break;
+    case 5:  this.attr |= 0x1000 /* ATTR_BLINK */;                                  break;
+    case 7:  this.attr |= 0x0100 /* ATTR_REVERSE */;                                break;
+    case 10:
+      this.translate    = this.GMap[this.useGMap];
+      this.dispCtrl     = false;
+      this.toggleMeta   = false;
+      break;
+    case 11:
+      this.translate    = this.CodePage437Map;
+      this.dispCtrl     = true;
+      this.toggleMeta   = false;
+      break;
+    case 12:
+      this.translate    = this.CodePage437Map;
+      this.dispCtrl     = true;
+      this.toggleMeta   = true;
+      break;
+    case 21:
+    case 22: this.attr &= ~(0x0800 /* ATTR_BRIGHT */|0x0400 /* ATTR_DIM */);                     break;
+    case 24: this.attr &= ~ 0x0200 /* ATTR_UNDERLINE */;                            break;
+    case 25: this.attr &= ~ 0x1000 /* ATTR_BLINK */;                                break;
+    case 27: this.attr &= ~ 0x0100 /* ATTR_REVERSE */;                              break;
+    case 38: this.attr  = (this.attr & ~(0x0400 /* ATTR_DIM */|0x0800 /* ATTR_BRIGHT */|0x0F))|
+                          0x0200 /* ATTR_UNDERLINE */;                              break;
+    case 39: this.attr &= ~(0x0400 /* ATTR_DIM */|0x0800 /* ATTR_BRIGHT */|0x0200 /* ATTR_UNDERLINE */|0x0F); break;
+    case 49: this.attr |= 0xF0;                                        break;
+    default:
+      if (this.par[i] >= 30 && this.par[i] <= 37) {
+          var fg        = this.par[i] - 30;
+          this.attr     = (this.attr & ~0x0F) | fg;
+      } else if (this.par[i] >= 40 && this.par[i] <= 47) {
+          var bg        = this.par[i] - 40;
+          this.attr     = (this.attr & ~0xF0) | (bg << 4);
+      }
+      break;
+    }
+  }
+  this.updateStyle();
+};
+
+VT100.prototype.csiP = function(number) {
+  // Delete character(s) following cursor
+  if (number == 0) {
+    number = 1;
+  }
+  if (number > this.terminalWidth - this.cursorX) {
+    number = this.terminalWidth - this.cursorX;
+  }
+  this.scrollRegion(this.cursorX + number, this.cursorY,
+                    this.terminalWidth - this.cursorX - number, 1,
+                    -number, 0, this.color, this.style);
+  needWrap = false;
+};
+
+VT100.prototype.csiX = function(number) {
+  // Clear characters following cursor
+  if (number == 0) {
+    number++;
+  }
+  if (number > this.terminalWidth - this.cursorX) {
+    number = this.terminalWidth - this.cursorX;
+  }
+  this.clearRegion(this.cursorX, this.cursorY, number, 1,
+                   this.color, this.style);
+  needWrap = false;
+};
+
+VT100.prototype.settermCommand = function() {
+  // Setterm commands are not implemented
+};
+
+VT100.prototype.doControl = function(ch) {
+  if (this.printing) {
+    this.sendControlToPrinter(ch);
+    return '';
+  }
+  var lineBuf                = '';
+  switch (ch) {
+  case 0x00: /* ignored */                                              break;
+  case 0x08: this.bs();                                                 break;
+  case 0x09: this.ht();                                                 break;
+  case 0x0A:
+  case 0x0B:
+  case 0x0C:
+  case 0x84: this.lf(); if (!this.crLfMode)                             break;
+  case 0x0D: this.cr();                                                 break;
+  case 0x85: this.cr(); this.lf();                                      break;
+  case 0x0E: this.useGMap     = 1;
+             this.translate   = this.GMap[1];
+             this.dispCtrl    = true;                                   break;
+  case 0x0F: this.useGMap     = 0;
+             this.translate   = this.GMap[0];
+             this.dispCtrl    = false;                                  break;
+  case 0x18:
+  case 0x1A: this.isEsc       = 0 /* ESnormal */;                               break;
+  case 0x1B: this.isEsc       = 1 /* ESesc */;                                  break;
+  case 0x7F: /* ignored */                                              break;
+  case 0x88: this.userTabStop[this.cursorX] = true;                     break;
+  case 0x8D: this.ri();                                                 break;
+  case 0x8E: this.isEsc       = 18 /* ESss2 */;                                  break;
+  case 0x8F: this.isEsc       = 19 /* ESss3 */;                                  break;
+  case 0x9A: this.respondID();                                          break;
+  case 0x9B: this.isEsc       = 2 /* ESsquare */;                               break;
+  case 0x07: if (this.isEsc != 17 /* EStitle */) {
+               this.beep();                                             break;
+             }
+             /* fall thru */
+  default:   switch (this.isEsc) {
+    case 1 /* ESesc */:
+      this.isEsc              = 0 /* ESnormal */;
+      switch (ch) {
+/*%*/ case 0x25: this.isEsc   = 13 /* ESpercent */;                              break;
+/*(*/ case 0x28: this.isEsc   = 8 /* ESsetG0 */;                                break;
+/*-*/ case 0x2D:
+/*)*/ case 0x29: this.isEsc   = 9 /* ESsetG1 */;                                break;
+/*.*/ case 0x2E:
+/***/ case 0x2A: this.isEsc   = 10 /* ESsetG2 */;                                break;
+/*/*/ case 0x2F:
+/*+*/ case 0x2B: this.isEsc   = 11 /* ESsetG3 */;                                break;
+/*#*/ case 0x23: this.isEsc   = 7 /* EShash */;                                 break;
+/*7*/ case 0x37: this.saveCursor();                                     break;
+/*8*/ case 0x38: this.restoreCursor();                                  break;
+/*>*/ case 0x3E: this.applKeyMode = false;                              break;
+/*=*/ case 0x3D: this.applKeyMode = true;                               break;
+/*D*/ case 0x44: this.lf();                                             break;
+/*E*/ case 0x45: this.cr(); this.lf();                                  break;
+/*M*/ case 0x4D: this.ri();                                             break;
+/*N*/ case 0x4E: this.isEsc   = 18 /* ESss2 */;                                  break;
+/*O*/ case 0x4F: this.isEsc   = 19 /* ESss3 */;                                  break;
+/*H*/ case 0x48: this.userTabStop[this.cursorX] = true;                 break;
+/*Z*/ case 0x5A: this.respondID();                                      break;
+/*[*/ case 0x5B: this.isEsc   = 2 /* ESsquare */;                               break;
+/*]*/ case 0x5D: this.isEsc   = 15 /* ESnonstd */;                               break;
+/*c*/ case 0x63: this.reset();                                          break;
+/*g*/ case 0x67: this.flashScreen();                                    break;
+      default:                                                          break;
+      }
+      break;
+    case 15 /* ESnonstd */:
+      switch (ch) {
+/*0*/ case 0x30:
+/*1*/ case 0x31:
+/*2*/ case 0x32: this.isEsc   = 17 /* EStitle */; this.titleString = '';         break;
+/*P*/ case 0x50: this.npar    = 0; this.par = [ 0, 0, 0, 0, 0, 0, 0 ];
+                 this.isEsc   = 16 /* ESpalette */;                              break;
+/*R*/ case 0x52: // Palette support is not implemented
+                 this.isEsc   = 0 /* ESnormal */;                               break;
+      default:   this.isEsc   = 0 /* ESnormal */;                               break;
+      }
+      break;
+    case 16 /* ESpalette */:
+      if ((ch >= 0x30 /*0*/ && ch <= 0x39 /*9*/) ||
+          (ch >= 0x41 /*A*/ && ch <= 0x46 /*F*/) ||
+          (ch >= 0x61 /*a*/ && ch <= 0x66 /*f*/)) {
+        this.par[this.npar++] = ch > 0x39  /*9*/ ? (ch & 0xDF) - 55
+                                                : (ch & 0xF);
+        if (this.npar == 7) {
+          // Palette support is not implemented
+          this.isEsc          = 0 /* ESnormal */;
+        }
+      } else {
+        this.isEsc            = 0 /* ESnormal */;
+      }
+      break;
+    case 2 /* ESsquare */:
+      this.npar               = 0;
+      this.par                = [ 0, 0, 0, 0, 0, 0, 0, 0,
+                                  0, 0, 0, 0, 0, 0, 0, 0 ];
+      this.isEsc              = 3 /* ESgetpars */;
+/*[*/ if (ch == 0x5B) { // Function key
+        this.isEsc            = 6 /* ESfunckey */;
+        break;
+      } else {
+/*?*/   this.isQuestionMark   = ch == 0x3F;
+        if (this.isQuestionMark) {
+          break;
+        }
+      }
+      // Fall through
+    case 5 /* ESdeviceattr */:
+    case 3 /* ESgetpars */:
+/*;*/ if (ch == 0x3B) {
+        this.npar++;
+        break;
+      } else if (ch >= 0x30 /*0*/ && ch <= 0x39 /*9*/) {
+        var par               = this.par[this.npar];
+        if (par == undefined) {
+          par                 = 0;
+        }
+        this.par[this.npar]   = 10*par + (ch & 0xF);
+        break;
+      } else if (this.isEsc == 5 /* ESdeviceattr */) {
+        switch (ch) {
+/*c*/   case 0x63: if (this.par[0] == 0) this.respondSecondaryDA();     break;
+/*m*/   case 0x6D: /* (re)set key modifier resource values */           break;
+/*n*/   case 0x6E: /* disable key modifier resource values */           break;
+/*p*/   case 0x70: /* set pointer mode resource value */                break;
+        default:                                                        break;
+        }
+        this.isEsc            = 0 /* ESnormal */;
+        break;
+      } else {
+        this.isEsc            = 4 /* ESgotpars */;
+      }
+      // Fall through
+    case 4 /* ESgotpars */:
+      this.isEsc              = 0 /* ESnormal */;
+      if (this.isQuestionMark) {
+        switch (ch) {
+/*h*/   case 0x68: this.setMode(true);                                  break;
+/*l*/   case 0x6C: this.setMode(false);                                 break;
+/*c*/   case 0x63: this.setCursorAttr(this.par[2], this.par[1]);        break;
+        default:                                                        break;
+        }
+        this.isQuestionMark   = false;
+        break;
+      }
+      switch (ch) {
+/*!*/ case 0x21: this.isEsc   = 12 /* ESbang */;                                 break;
+/*>*/ case 0x3E: if (!this.npar) this.isEsc  = 5 /* ESdeviceattr */;            break;
+/*G*/ case 0x47:
+/*`*/ case 0x60: this.gotoXY(this.par[0] - 1, this.cursorY);            break;
+/*A*/ case 0x41: this.gotoXY(this.cursorX,
+                             this.cursorY - (this.par[0] ? this.par[0] : 1));
+                                                                        break;
+/*B*/ case 0x42:
+/*e*/ case 0x65: this.gotoXY(this.cursorX,
+                             this.cursorY + (this.par[0] ? this.par[0] : 1));
+                                                                        break;
+/*C*/ case 0x43:
+/*a*/ case 0x61: this.gotoXY(this.cursorX + (this.par[0] ? this.par[0] : 1),
+                             this.cursorY);                             break;
+/*D*/ case 0x44: this.gotoXY(this.cursorX - (this.par[0] ? this.par[0] : 1),
+                             this.cursorY);                             break;
+/*E*/ case 0x45: this.gotoXY(0, this.cursorY + (this.par[0] ? this.par[0] :1));
+                                                                        break;
+/*F*/ case 0x46: this.gotoXY(0, this.cursorY - (this.par[0] ? this.par[0] :1));
+                                                                        break;
+/*d*/ case 0x64: this.gotoXaY(this.cursorX, this.par[0] - 1);           break;
+/*H*/ case 0x48:
+/*f*/ case 0x66: this.gotoXaY(this.par[1] - 1, this.par[0] - 1);        break;
+/*I*/ case 0x49: this.ht(this.par[0] ? this.par[0] : 1);                break;
+/*@*/ case 0x40: this.csiAt(this.par[0]);                               break;
+/*i*/ case 0x69: this.csii(this.par[0]);                                break;
+/*J*/ case 0x4A: this.csiJ(this.par[0]);                                break;
+/*K*/ case 0x4B: this.csiK(this.par[0]);                                break;
+/*L*/ case 0x4C: this.csiL(this.par[0]);                                break;
+/*M*/ case 0x4D: this.csiM(this.par[0]);                                break;
+/*m*/ case 0x6D: this.csim();                                           break;
+/*P*/ case 0x50: this.csiP(this.par[0]);                                break;
+/*X*/ case 0x58: this.csiX(this.par[0]);                                break;
+/*S*/ case 0x53: this.lf(this.par[0] ? this.par[0] : 1);                break;
+/*T*/ case 0x54: this.ri(this.par[0] ? this.par[0] : 1);                break;
+/*c*/ case 0x63: if (!this.par[0]) this.respondID();                    break;
+/*g*/ case 0x67: if (this.par[0] == 0) {
+                   this.userTabStop[this.cursorX] = false;
+                 } else if (this.par[0] == 2 || this.par[0] == 3) {
+                   this.userTabStop               = [ ];
+                   for (var i = 0; i < this.terminalWidth; i++) {
+                     this.userTabStop[i]          = false;
+                   }
+                 }
+                 break;
+/*h*/ case 0x68: this.setMode(true);                                    break;
+/*l*/ case 0x6C: this.setMode(false);                                   break;
+/*n*/ case 0x6E: switch (this.par[0]) {
+                 case 5: this.statusReport();                           break;
+                 case 6: this.cursorReport();                           break;
+                 default:                                               break;
+                 }
+                 break;
+/*q*/ case 0x71: // LED control not implemented
+                                                                        break;
+/*r*/ case 0x72: var t        = this.par[0] ? this.par[0] : 1;
+                 var b        = this.par[1] ? this.par[1]
+                                            : this.terminalHeight;
+                 if (t < b && b <= this.terminalHeight) {
+                   this.top   = t - 1;
+                   this.bottom= b;
+                   this.gotoXaY(0, 0);
+                 }
+                 break;
+/*b*/ case 0x62: var c        = this.par[0] ? this.par[0] : 1;
+                 if (c > this.terminalWidth * this.terminalHeight) {
+                   c          = this.terminalWidth * this.terminalHeight;
+                 }
+                 while (c-- > 0) {
+                   lineBuf   += this.lastCharacter;
+                 }
+                 break;
+/*s*/ case 0x73: this.saveCursor();                                     break;
+/*u*/ case 0x75: this.restoreCursor();                                  break;
+/*Z*/ case 0x5A: this.rt(this.par[0] ? this.par[0] : 1);                break;
+/*]*/ case 0x5D: this.settermCommand();                                 break;
+      default:                                                          break;
+      }
+      break;
+    case 12 /* ESbang */:
+      if (ch == 'p') {
+        this.reset();
+      }
+      this.isEsc              = 0 /* ESnormal */;
+      break;
+    case 13 /* ESpercent */:
+      this.isEsc              = 0 /* ESnormal */;
+      switch (ch) {
+/*@*/ case 0x40: this.utfEnabled = false;                               break;
+/*G*/ case 0x47:
+/*8*/ case 0x38: this.utfEnabled = true;                                break;
+      default:                                                          break;
+      }
+      break;
+    case 6 /* ESfunckey */:
+      this.isEsc              = 0 /* ESnormal */;                               break;
+    case 7 /* EShash */:
+      this.isEsc              = 0 /* ESnormal */;
+/*8*/ if (ch == 0x38) {
+        // Screen alignment test not implemented
+      }
+      break;
+    case 8 /* ESsetG0 */:
+    case 9 /* ESsetG1 */:
+    case 10 /* ESsetG2 */:
+    case 11 /* ESsetG3 */:
+      var g                   = this.isEsc - 8 /* ESsetG0 */;
+      this.isEsc              = 0 /* ESnormal */;
+      switch (ch) {
+/*0*/ case 0x30: this.GMap[g] = this.VT100GraphicsMap;                  break;
+/*A*/ case 0x42:
+/*B*/ case 0x42: this.GMap[g] = this.Latin1Map;                         break;
+/*U*/ case 0x55: this.GMap[g] = this.CodePage437Map;                    break;
+/*K*/ case 0x4B: this.GMap[g] = this.DirectToFontMap;                   break;
+      default:                                                          break;
+      }
+      if (this.useGMap == g) {
+        this.translate        = this.GMap[g];
+      }
+      break;
+    case 17 /* EStitle */:
+      if (ch == 0x07) {
+        if (this.titleString && this.titleString.charAt(0) == ';') {
+          this.titleString    = this.titleString.substr(1);
+          if (this.titleString != '') {
+            this.titleString += ' - ';
+          }
+          this.titleString += 'Shell In A Box'
+        }
+        try {
+          window.document.title = this.titleString;
+        } catch (e) {
+        }
+        this.isEsc            = 0 /* ESnormal */;
+      } else {
+        this.titleString     += String.fromCharCode(ch);
+      }
+      break;
+    case 18 /* ESss2 */:
+    case 19 /* ESss3 */:
+      if (ch < 256) {
+          ch                  = this.GMap[this.isEsc - 18 /* ESss2 */ + 2]
+                                         [this.toggleMeta ? (ch | 0x80) : ch];
+        if ((ch & 0xFF00) == 0xF000) {
+          ch                  = ch & 0xFF;
+        } else if (ch == 0xFEFF || (ch >= 0x200A && ch <= 0x200F)) {
+          this.isEsc         = 0 /* ESnormal */;                                break;
+        }
+      }
+      this.lastCharacter      = String.fromCharCode(ch);
+      lineBuf                += this.lastCharacter;
+      this.isEsc              = 0 /* ESnormal */;                               break;
+    default:
+      this.isEsc              = 0 /* ESnormal */;                               break;
+    }
+    break;
+  }
+  return lineBuf;
+};
+
+VT100.prototype.renderString = function(s, showCursor) {
+  if (this.printing) {
+    this.sendToPrinter(s);
+    if (showCursor) {
+      this.showCursor();
+    }
+    return;
+  }
+
+  // We try to minimize the number of DOM operations by coalescing individual
+  // characters into strings. This is a significant performance improvement.
+  var incX = s.length;
+  if (incX > this.terminalWidth - this.cursorX) {
+    incX   = this.terminalWidth - this.cursorX;
+    if (incX <= 0) {
+      return;
+    }
+    s      = s.substr(0, incX - 1) + s.charAt(s.length - 1);
+  }
+  if (showCursor) {
+    // Minimize the number of calls to putString(), by avoiding a direct
+    // call to this.showCursor()
+    this.cursor.style.visibility = '';
+  }
+  this.putString(this.cursorX, this.cursorY, s, this.color, this.style);
+};
+
+VT100.prototype.vt100 = function(s) {
+  this.cursorNeedsShowing = this.hideCursor();
+  this.respondString      = '';
+  var lineBuf             = '';
+  for (var i = 0; i < s.length; i++) {
+    var ch = s.charCodeAt(i);
+    if (this.utfEnabled) {
+      // Decode UTF8 encoded character
+      if (ch > 0x7F) {
+        if (this.utfCount > 0 && (ch & 0xC0) == 0x80) {
+          this.utfChar    = (this.utfChar << 6) | (ch & 0x3F);
+          if (--this.utfCount <= 0) {
+            if (this.utfChar > 0xFFFF || this.utfChar < 0) {
+              ch = 0xFFFD;
+            } else {
+              ch          = this.utfChar;
+            }
+          } else {
+            continue;
+          }
+        } else {
+          if ((ch & 0xE0) == 0xC0) {
+            this.utfCount = 1;
+            this.utfChar  = ch & 0x1F;
+          } else if ((ch & 0xF0) == 0xE0) {
+            this.utfCount = 2;
+            this.utfChar  = ch & 0x0F;
+          } else if ((ch & 0xF8) == 0xF0) {
+            this.utfCount = 3;
+            this.utfChar  = ch & 0x07;
+          } else if ((ch & 0xFC) == 0xF8) {
+            this.utfCount = 4;
+            this.utfChar  = ch & 0x03;
+          } else if ((ch & 0xFE) == 0xFC) {
+            this.utfCount = 5;
+            this.utfChar  = ch & 0x01;
+          } else {
+            this.utfCount = 0;
+          }
+          continue;
+        }
+      } else {
+        this.utfCount     = 0;
+      }
+    }
+    var isNormalCharacter =
+      (ch >= 32 && ch <= 127 || ch >= 160 ||
+       this.utfEnabled && ch >= 128 ||
+       !(this.dispCtrl ? this.ctrlAlways : this.ctrlAction)[ch & 0x1F]) &&
+      (ch != 0x7F || this.dispCtrl);
+
+    if (isNormalCharacter && this.isEsc == 0 /* ESnormal */) {
+      if (ch < 256) {
+        ch                = this.translate[this.toggleMeta ? (ch | 0x80) : ch];
+      }
+      if ((ch & 0xFF00) == 0xF000) {
+        ch                = ch & 0xFF;
+      } else if (ch == 0xFEFF || (ch >= 0x200A && ch <= 0x200F)) {
+        continue;
+      }
+      if (!this.printing) {
+        if (this.needWrap || this.insertMode) {
+          if (lineBuf) {
+            this.renderString(lineBuf);
+            lineBuf       = '';
+          }
+        }
+        if (this.needWrap) {
+          this.cr(); this.lf();
+        }
+        if (this.insertMode) {
+          this.scrollRegion(this.cursorX, this.cursorY,
+                            this.terminalWidth - this.cursorX - 1, 1,
+                            1, 0, this.color, this.style);
+        }
+      }
+      this.lastCharacter  = String.fromCharCode(ch);
+      lineBuf            += this.lastCharacter;
+      if (!this.printing &&
+          this.cursorX + lineBuf.length >= this.terminalWidth) {
+        this.needWrap     = this.autoWrapMode;
+      }
+    } else {
+      if (lineBuf) {
+        this.renderString(lineBuf);
+        lineBuf           = '';
+      }
+      var expand          = this.doControl(ch);
+      if (expand.length) {
+        var r             = this.respondString;
+        this.respondString= r + this.vt100(expand);
+      }
+    }
+  }
+  if (lineBuf) {
+    this.renderString(lineBuf, this.cursorNeedsShowing);
+  } else if (this.cursorNeedsShowing) {
+    this.showCursor();
+  }
+  return this.respondString;
+};
+
+VT100.prototype.Latin1Map = [
+0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
+0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F,
+0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
+0x0088, 0x0089, 0x008A, 0x008B, 0x008C, 0x008D, 0x008E, 0x008F,
+0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
+0x0098, 0x0099, 0x009A, 0x009B, 0x009C, 0x009D, 0x009E, 0x009F,
+0x00A0, 0x00A1, 0x00A2, 0x00A3, 0x00A4, 0x00A5, 0x00A6, 0x00A7,
+0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF,
+0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5, 0x00B6, 0x00B7,
+0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF,
+0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
+0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF,
+0x00D0, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7,
+0x00D8, 0x00D9, 0x00DA, 0x00DB, 0x00DC, 0x00DD, 0x00DE, 0x00DF,
+0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7,
+0x00E8, 0x00E9, 0x00EA, 0x00EB, 0x00EC, 0x00ED, 0x00EE, 0x00EF,
+0x00F0, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7,
+0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x00FD, 0x00FE, 0x00FF
+];
+
+VT100.prototype.VT100GraphicsMap = [
+0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+0x0028, 0x0029, 0x002A, 0x2192, 0x2190, 0x2191, 0x2193, 0x002F,
+0x2588, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x00A0,
+0x25C6, 0x2592, 0x2409, 0x240C, 0x240D, 0x240A, 0x00B0, 0x00B1,
+0x2591, 0x240B, 0x2518, 0x2510, 0x250C, 0x2514, 0x253C, 0xF800,
+0xF801, 0x2500, 0xF803, 0xF804, 0x251C, 0x2524, 0x2534, 0x252C,
+0x2502, 0x2264, 0x2265, 0x03C0, 0x2260, 0x00A3, 0x00B7, 0x007F,
+0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
+0x0088, 0x0089, 0x008A, 0x008B, 0x008C, 0x008D, 0x008E, 0x008F,
+0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
+0x0098, 0x0099, 0x009A, 0x009B, 0x009C, 0x009D, 0x009E, 0x009F,
+0x00A0, 0x00A1, 0x00A2, 0x00A3, 0x00A4, 0x00A5, 0x00A6, 0x00A7,
+0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF,
+0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5, 0x00B6, 0x00B7,
+0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF,
+0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
+0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF,
+0x00D0, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7,
+0x00D8, 0x00D9, 0x00DA, 0x00DB, 0x00DC, 0x00DD, 0x00DE, 0x00DF,
+0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7,
+0x00E8, 0x00E9, 0x00EA, 0x00EB, 0x00EC, 0x00ED, 0x00EE, 0x00EF,
+0x00F0, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7,
+0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x00FD, 0x00FE, 0x00FF
+];
+
+VT100.prototype.CodePage437Map = [
+0x0000, 0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
+0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C,
+0x25B6, 0x25C0, 0x2195, 0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8,
+0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
+0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
+0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x2302,
+0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7,
+0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5,
+0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9,
+0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192,
+0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA,
+0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB,
+0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
+0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510,
+0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
+0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567,
+0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B,
+0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580,
+0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4,
+0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229,
+0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248,
+0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0
+];
+
+VT100.prototype.DirectToFontMap = [
+0xF000, 0xF001, 0xF002, 0xF003, 0xF004, 0xF005, 0xF006, 0xF007,
+0xF008, 0xF009, 0xF00A, 0xF00B, 0xF00C, 0xF00D, 0xF00E, 0xF00F,
+0xF010, 0xF011, 0xF012, 0xF013, 0xF014, 0xF015, 0xF016, 0xF017,
+0xF018, 0xF019, 0xF01A, 0xF01B, 0xF01C, 0xF01D, 0xF01E, 0xF01F,
+0xF020, 0xF021, 0xF022, 0xF023, 0xF024, 0xF025, 0xF026, 0xF027,
+0xF028, 0xF029, 0xF02A, 0xF02B, 0xF02C, 0xF02D, 0xF02E, 0xF02F,
+0xF030, 0xF031, 0xF032, 0xF033, 0xF034, 0xF035, 0xF036, 0xF037,
+0xF038, 0xF039, 0xF03A, 0xF03B, 0xF03C, 0xF03D, 0xF03E, 0xF03F,
+0xF040, 0xF041, 0xF042, 0xF043, 0xF044, 0xF045, 0xF046, 0xF047,
+0xF048, 0xF049, 0xF04A, 0xF04B, 0xF04C, 0xF04D, 0xF04E, 0xF04F,
+0xF050, 0xF051, 0xF052, 0xF053, 0xF054, 0xF055, 0xF056, 0xF057,
+0xF058, 0xF059, 0xF05A, 0xF05B, 0xF05C, 0xF05D, 0xF05E, 0xF05F,
+0xF060, 0xF061, 0xF062, 0xF063, 0xF064, 0xF065, 0xF066, 0xF067,
+0xF068, 0xF069, 0xF06A, 0xF06B, 0xF06C, 0xF06D, 0xF06E, 0xF06F,
+0xF070, 0xF071, 0xF072, 0xF073, 0xF074, 0xF075, 0xF076, 0xF077,
+0xF078, 0xF079, 0xF07A, 0xF07B, 0xF07C, 0xF07D, 0xF07E, 0xF07F,
+0xF080, 0xF081, 0xF082, 0xF083, 0xF084, 0xF085, 0xF086, 0xF087,
+0xF088, 0xF089, 0xF08A, 0xF08B, 0xF08C, 0xF08D, 0xF08E, 0xF08F,
+0xF090, 0xF091, 0xF092, 0xF093, 0xF094, 0xF095, 0xF096, 0xF097,
+0xF098, 0xF099, 0xF09A, 0xF09B, 0xF09C, 0xF09D, 0xF09E, 0xF09F,
+0xF0A0, 0xF0A1, 0xF0A2, 0xF0A3, 0xF0A4, 0xF0A5, 0xF0A6, 0xF0A7,
+0xF0A8, 0xF0A9, 0xF0AA, 0xF0AB, 0xF0AC, 0xF0AD, 0xF0AE, 0xF0AF,
+0xF0B0, 0xF0B1, 0xF0B2, 0xF0B3, 0xF0B4, 0xF0B5, 0xF0B6, 0xF0B7,
+0xF0B8, 0xF0B9, 0xF0BA, 0xF0BB, 0xF0BC, 0xF0BD, 0xF0BE, 0xF0BF,
+0xF0C0, 0xF0C1, 0xF0C2, 0xF0C3, 0xF0C4, 0xF0C5, 0xF0C6, 0xF0C7,
+0xF0C8, 0xF0C9, 0xF0CA, 0xF0CB, 0xF0CC, 0xF0CD, 0xF0CE, 0xF0CF,
+0xF0D0, 0xF0D1, 0xF0D2, 0xF0D3, 0xF0D4, 0xF0D5, 0xF0D6, 0xF0D7,
+0xF0D8, 0xF0D9, 0xF0DA, 0xF0DB, 0xF0DC, 0xF0DD, 0xF0DE, 0xF0DF,
+0xF0E0, 0xF0E1, 0xF0E2, 0xF0E3, 0xF0E4, 0xF0E5, 0xF0E6, 0xF0E7,
+0xF0E8, 0xF0E9, 0xF0EA, 0xF0EB, 0xF0EC, 0xF0ED, 0xF0EE, 0xF0EF,
+0xF0F0, 0xF0F1, 0xF0F2, 0xF0F3, 0xF0F4, 0xF0F5, 0xF0F6, 0xF0F7,
+0xF0F8, 0xF0F9, 0xF0FA, 0xF0FB, 0xF0FC, 0xF0FD, 0xF0FE, 0xF0FF
+];
+
+VT100.prototype.ctrlAction = [
+  true,  false, false, false, false, false, false, true,
+  true,  true,  true,  true,  true,  true,  true,  true,
+  false, false, false, false, false, false, false, false,
+  true,  false, true,  true,  false, false, false, false
+];
+
+VT100.prototype.ctrlAlways = [
+  true,  false, false, false, false, false, false, false,
+  true,  false, true,  false, true,  true,  true,  true,
+  false, false, false, false, false, false, false, false,
+  false, false, false, true,  false, false, false, false
+];
diff --git a/services/workbench2/src/common/app-info.ts b/services/workbench2/src/common/app-info.ts
new file mode 100644 (file)
index 0000000..a6e3af7
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const getBuildInfo = (): string => {
+    if (process.env.REACT_APP_VERSION) {
+      return "v" + process.env.REACT_APP_VERSION;
+    } else {
+      const getBuildNumber = "BN-" + (process.env.REACT_APP_BUILD_NUMBER || "dev");
+      const getGitCommit = "GIT-" + (process.env.REACT_APP_GIT_COMMIT || "latest").substring(0, 7);
+      return getBuildNumber + " / " + getGitCommit;
+    }
+};
diff --git a/services/workbench2/src/common/array-utils.ts b/services/workbench2/src/common/array-utils.ts
new file mode 100644 (file)
index 0000000..a92461c
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const sortByProperty = (propName: string) => (obj1: any, obj2: any) => {
+    const prop1 = obj1[propName];
+    const prop2 = obj2[propName];
+    
+    if (prop1 > prop2) {
+        return 1;
+    }
+
+    if (prop1 < prop2) {
+        return -1;
+    }
+
+    return 0;
+};
diff --git a/services/workbench2/src/common/codes.ts b/services/workbench2/src/common/codes.ts
new file mode 100644 (file)
index 0000000..6342a29
--- /dev/null
@@ -0,0 +1,8 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const KEY_CODE_UP = 38;
+export const KEY_CODE_DOWN = 40;
+export const KEY_CODE_ESC = 27;
+export const KEY_ENTER = 13;
diff --git a/services/workbench2/src/common/config.ts b/services/workbench2/src/common/config.ts
new file mode 100644 (file)
index 0000000..ed99e7d
--- /dev/null
@@ -0,0 +1,411 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import Axios from 'axios';
+
+export const WORKBENCH_CONFIG_URL =
+    process.env.REACT_APP_ARVADOS_CONFIG_URL || '/config.json';
+
+interface WorkbenchConfig {
+    API_HOST: string;
+    VOCABULARY_URL?: string;
+    FILE_VIEWERS_CONFIG_URL?: string;
+}
+
+export interface ClusterConfigJSON {
+    API: {
+        UnfreezeProjectRequiresAdmin: boolean
+        MaxItemsPerResponse: number
+    },
+    ClusterID: string;
+    Containers: {
+        ReserveExtraRAM: number;
+    },
+    InstanceTypes?: {
+        [key: string]: {
+            AddedScratch: number;
+            CUDA?: {
+                DeviceCount: number;
+                DriverVersion: string;
+                HardwareCapability: string;
+            };
+            IncludedScratch: number;
+            Preemptible: boolean;
+            Price: number;
+            ProviderType: string;
+            RAM: number;
+            VCPUs: number;
+        };
+    };
+    RemoteClusters: {
+        [key: string]: {
+            ActivateUsers: boolean
+            Host: string
+            Insecure: boolean
+            Proxy: boolean
+            Scheme: string
+        }
+    };
+    Mail?: {
+        SupportEmailAddress: string;
+    };
+    Services: {
+        Controller: {
+            ExternalURL: string;
+        };
+        Workbench1: {
+            ExternalURL: string;
+        };
+        Workbench2: {
+            ExternalURL: string;
+        };
+        Workbench: {
+            DisableSharingURLsUI: boolean;
+            ArvadosDocsite: string;
+            FileViewersConfigURL: string;
+            WelcomePageHTML: string;
+            InactivePageHTML: string;
+            SSHHelpPageHTML: string;
+            SSHHelpHostSuffix: string;
+            SiteName: string;
+            IdleTimeout: string;
+        };
+        Websocket: {
+            ExternalURL: string;
+        };
+        WebDAV: {
+            ExternalURL: string;
+        };
+        WebDAVDownload: {
+            ExternalURL: string;
+        };
+        WebShell: {
+            ExternalURL: string;
+        };
+    };
+    Workbench: {
+        DisableSharingURLsUI: boolean;
+        ArvadosDocsite: string;
+        FileViewersConfigURL: string;
+        WelcomePageHTML: string;
+        InactivePageHTML: string;
+        SSHHelpPageHTML: string;
+        SSHHelpHostSuffix: string;
+        SiteName: string;
+        IdleTimeout: string;
+        BannerUUID: string;
+        UserProfileFormFields: {};
+        UserProfileFormMessage: string;
+    };
+    Login: {
+        LoginCluster: string;
+        Google: {
+            Enable: boolean;
+        };
+        LDAP: {
+            Enable: boolean;
+        };
+        OpenIDConnect: {
+            Enable: boolean;
+        };
+        PAM: {
+            Enable: boolean;
+        };
+        SSO: {
+            Enable: boolean;
+        };
+        Test: {
+            Enable: boolean;
+        };
+    };
+    Collections: {
+        ForwardSlashNameSubstitution: string;
+        ManagedProperties?: {
+            [key: string]: {
+                Function: string;
+                Value: string;
+                Protected?: boolean;
+            };
+        };
+        TrustAllContent: boolean;
+    };
+    Volumes: {
+        [key: string]: {
+            StorageClasses: {
+                [key: string]: boolean;
+            };
+        };
+    };
+    Users: {
+        AnonymousUserToken: string;
+    };
+}
+
+export class Config {
+    baseUrl!: string;
+    keepWebServiceUrl!: string;
+    keepWebInlineServiceUrl!: string;
+    remoteHosts!: {
+        [key: string]: string;
+    };
+    rootUrl!: string;
+    uuidPrefix!: string;
+    websocketUrl!: string;
+    workbenchUrl!: string;
+    workbench2Url!: string;
+    vocabularyUrl!: string;
+    fileViewersConfigUrl!: string;
+    loginCluster!: string;
+    clusterConfig!: ClusterConfigJSON;
+    apiRevision!: number;
+}
+
+export const buildConfig = (clusterConfig: ClusterConfigJSON): Config => {
+    const clusterConfigJSON = removeTrailingSlashes(clusterConfig);
+    const config = new Config();
+    config.rootUrl = clusterConfigJSON.Services.Controller.ExternalURL;
+    config.baseUrl = `${config.rootUrl}/${ARVADOS_API_PATH}`;
+    config.uuidPrefix = clusterConfigJSON.ClusterID;
+    config.websocketUrl = clusterConfigJSON.Services.Websocket.ExternalURL;
+    config.workbench2Url = clusterConfigJSON.Services.Workbench2.ExternalURL;
+    config.workbenchUrl = clusterConfigJSON.Services.Workbench1.ExternalURL;
+    config.keepWebServiceUrl =
+        clusterConfigJSON.Services.WebDAVDownload.ExternalURL;
+    config.keepWebInlineServiceUrl =
+        clusterConfigJSON.Services.WebDAV.ExternalURL;
+    config.loginCluster = clusterConfigJSON.Login.LoginCluster;
+    config.clusterConfig = clusterConfigJSON;
+    config.apiRevision = 0;
+    mapRemoteHosts(clusterConfigJSON, config);
+    return config;
+};
+
+export const getStorageClasses = (config: Config): string[] => {
+    const classes: Set<string> = new Set(['default']);
+    const volumes = config.clusterConfig.Volumes;
+    Object.keys(volumes).forEach((v) => {
+        Object.keys(volumes[v].StorageClasses || {}).forEach((sc) => {
+            if (volumes[v].StorageClasses[sc]) {
+                classes.add(sc);
+            }
+        });
+    });
+    return Array.from(classes);
+};
+
+const getApiRevision = async (apiUrl: string) => {
+    try {
+        const dd = (await Axios.get<any>(`${apiUrl}/${DISCOVERY_DOC_PATH}`)).data;
+        return parseInt(dd.revision, 10) || 0;
+    } catch {
+        console.warn(
+            'Unable to get API Revision number, defaulting to zero. Some features may not work properly.'
+        );
+        return 0;
+    }
+};
+
+const removeTrailingSlashes = (
+    config: ClusterConfigJSON
+): ClusterConfigJSON => {
+    const svcs: any = {};
+    Object.keys(config.Services).forEach((s) => {
+        svcs[s] = config.Services[s];
+        if (svcs[s].hasOwnProperty('ExternalURL')) {
+            svcs[s].ExternalURL = svcs[s].ExternalURL.replace(/\/+$/, '');
+        }
+    });
+    return { ...config, Services: svcs };
+};
+
+export const fetchConfig = () => {
+    return Axios.get<WorkbenchConfig>(
+        WORKBENCH_CONFIG_URL + '?nocache=' + new Date().getTime()
+    )
+        .then((response) => response.data)
+        .catch(() => {
+            console.warn(
+                `There was an exception getting the Workbench config file at ${WORKBENCH_CONFIG_URL}. Using defaults instead.`
+            );
+            return Promise.resolve(getDefaultConfig());
+        })
+        .then((workbenchConfig) => {
+            if (workbenchConfig.API_HOST === undefined) {
+                throw new Error(
+                    `Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.`
+                );
+            }
+            return Axios.get<ClusterConfigJSON>(
+                getClusterConfigURL(workbenchConfig.API_HOST)
+            ).then(async (response) => {
+                const apiRevision = await getApiRevision(
+                    response.data.Services.Controller.ExternalURL.replace(/\/+$/, '')
+                );
+                const config = { ...buildConfig(response.data), apiRevision };
+                const warnLocalConfig = (varName: string) =>
+                    console.warn(
+                        `A value for ${varName} was found in ${WORKBENCH_CONFIG_URL}. To use the Arvados centralized configuration instead, \
+remove the entire ${varName} entry from ${WORKBENCH_CONFIG_URL}`
+                    );
+
+                // Check if the workbench config has an entry for vocabulary and file viewer URLs
+                // If so, use these values (even if it is an empty string), but print a console warning.
+                // Otherwise, use the cluster config.
+                let fileViewerConfigUrl;
+                if (workbenchConfig.FILE_VIEWERS_CONFIG_URL !== undefined) {
+                    warnLocalConfig('FILE_VIEWERS_CONFIG_URL');
+                    fileViewerConfigUrl = workbenchConfig.FILE_VIEWERS_CONFIG_URL;
+                } else {
+                    fileViewerConfigUrl =
+                        config.clusterConfig.Workbench.FileViewersConfigURL ||
+                        '/file-viewers-example.json';
+                }
+                config.fileViewersConfigUrl = fileViewerConfigUrl;
+
+                if (workbenchConfig.VOCABULARY_URL !== undefined) {
+                    console.warn(
+                        `A value for VOCABULARY_URL was found in ${WORKBENCH_CONFIG_URL}. It will be ignored as the cluster already provides its own endpoint, you can safely remove it.`
+                    );
+                }
+                config.vocabularyUrl = getVocabularyURL(workbenchConfig.API_HOST);
+
+                return { config, apiHost: workbenchConfig.API_HOST };
+            });
+        });
+};
+
+// Maps remote cluster hosts and removes the default RemoteCluster entry
+export const mapRemoteHosts = (
+    clusterConfigJSON: ClusterConfigJSON,
+    config: Config
+) => {
+    config.remoteHosts = {};
+    Object.keys(clusterConfigJSON.RemoteClusters).forEach((k) => {
+        config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host;
+    });
+    delete config.remoteHosts['*'];
+};
+
+export const mockClusterConfigJSON = (
+    config: Partial<ClusterConfigJSON>
+): ClusterConfigJSON => ({
+    API: {
+        UnfreezeProjectRequiresAdmin: false,
+        MaxItemsPerResponse: 1000,
+    },
+    ClusterID: '',
+    Containers: {
+        ReserveExtraRAM: 576716800,
+    },
+    RemoteClusters: {},
+    Services: {
+        Controller: { ExternalURL: '' },
+        Workbench1: { ExternalURL: '' },
+        Workbench2: { ExternalURL: '' },
+        Websocket: { ExternalURL: '' },
+        WebDAV: { ExternalURL: '' },
+        WebDAVDownload: { ExternalURL: '' },
+        WebShell: { ExternalURL: '' },
+        Workbench: {
+            DisableSharingURLsUI: false,
+            ArvadosDocsite: "",
+            FileViewersConfigURL: "",
+            WelcomePageHTML: "",
+            InactivePageHTML: "",
+            SSHHelpPageHTML: "",
+            SSHHelpHostSuffix: "",
+            SiteName: "",
+            IdleTimeout: "0s"
+        },
+    },
+    Workbench: {
+        DisableSharingURLsUI: false,
+        ArvadosDocsite: '',
+        FileViewersConfigURL: '',
+        WelcomePageHTML: '',
+        InactivePageHTML: '',
+        SSHHelpPageHTML: '',
+        SSHHelpHostSuffix: '',
+        SiteName: '',
+        IdleTimeout: '0s',
+        BannerUUID: "",
+        UserProfileFormFields: {},
+        UserProfileFormMessage: '',
+    },
+    Login: {
+        LoginCluster: '',
+        Google: {
+            Enable: false,
+        },
+        LDAP: {
+            Enable: false,
+        },
+        OpenIDConnect: {
+            Enable: false,
+        },
+        PAM: {
+            Enable: false,
+        },
+        SSO: {
+            Enable: false,
+        },
+        Test: {
+            Enable: false,
+        },
+    },
+    Collections: {
+        ForwardSlashNameSubstitution: '',
+        TrustAllContent: false,
+    },
+    Volumes: {},
+    Users: {
+        AnonymousUserToken: ""
+    },
+    ...config,
+});
+
+export const mockConfig = (config: Partial<Config>): Config => ({
+    baseUrl: '',
+    keepWebServiceUrl: '',
+    keepWebInlineServiceUrl: '',
+    remoteHosts: {},
+    rootUrl: '',
+    uuidPrefix: '',
+    websocketUrl: '',
+    workbenchUrl: '',
+    workbench2Url: '',
+    vocabularyUrl: '',
+    fileViewersConfigUrl: '',
+    loginCluster: '',
+    clusterConfig: mockClusterConfigJSON({}),
+    apiRevision: 0,
+    ...config,
+});
+
+const getDefaultConfig = (): WorkbenchConfig => {
+    let apiHost = '';
+    const envHost = process.env.REACT_APP_ARVADOS_API_HOST;
+    if (envHost !== undefined) {
+        console.warn(`Using default API host ${envHost}.`);
+        apiHost = envHost;
+    } else {
+        console.warn(
+            `No API host was found in the environment. Workbench may not be able to communicate with Arvados components.`
+        );
+    }
+    return {
+        API_HOST: apiHost,
+        VOCABULARY_URL: undefined,
+        FILE_VIEWERS_CONFIG_URL: undefined,
+    };
+};
+
+export const ARVADOS_API_PATH = 'arvados/v1';
+export const CLUSTER_CONFIG_PATH = 'arvados/v1/config';
+export const VOCABULARY_PATH = 'arvados/v1/vocabulary';
+export const DISCOVERY_DOC_PATH = 'discovery/v1/apis/arvados/v1/rest';
+export const getClusterConfigURL = (apiHost: string) =>
+    `https://${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${new Date().getTime()}`;
+export const getVocabularyURL = (apiHost: string) =>
+    `https://${apiHost}/${VOCABULARY_PATH}?nocache=${new Date().getTime()}`;
diff --git a/services/workbench2/src/common/custom-theme.ts b/services/workbench2/src/common/custom-theme.ts
new file mode 100644 (file)
index 0000000..135204a
--- /dev/null
@@ -0,0 +1,210 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { createMuiTheme } from '@material-ui/core/styles';
+import { ThemeOptions, Theme } from '@material-ui/core/styles/createMuiTheme';
+import blue from '@material-ui/core/colors/blue';
+import grey from '@material-ui/core/colors/grey';
+import green from '@material-ui/core/colors/green';
+import yellow from '@material-ui/core/colors/yellow';
+import red from '@material-ui/core/colors/red';
+
+export interface ArvadosThemeOptions extends ThemeOptions {
+    customs: any;
+}
+
+export interface ArvadosTheme extends Theme {
+    customs: {
+        colors: Colors
+    };
+}
+
+interface Colors {
+    green700: string;
+    green800: string;
+    yellow100: string;
+    yellow700: string;
+    yellow900: string;
+    red100: string;
+    red900: string;
+    blue500: string;
+    blue700: string;
+    grey500: string;
+    grey600: string;
+    grey700: string;
+    grey900: string;
+    purple: string;
+    orange: string; 
+    greyL: string;
+    greyD: string;
+    darkblue: string;
+}
+
+/**
+* arvadosGreyLight is the hex equivalent of rgba(0,0,0,0.87) on #fafafa background and arvadosGreyDark is the hex equivalent of rgab(0,0,0,0.54) on #fafafa background  
+*/
+
+const arvadosDarkBlue = '#052a3c';
+const arvadosGreyLight = '#737373'; 
+const arvadosGreyDark = '#212121'; 
+const grey500 = grey["500"];
+const grey600 = grey["600"];
+const grey700 = grey["700"];
+const grey800 = grey["800"];
+const grey900 = grey["900"];
+
+export const themeOptions: ArvadosThemeOptions = {
+    typography: {
+        useNextVariants: true,
+    },
+    customs: {
+        colors: {
+            green700: green["700"],
+            green800: green["800"],
+            yellow100: yellow["100"],
+            yellow700: yellow["700"],
+            yellow900: yellow["900"],
+            red100: red["100"],
+            red900: red['900'],
+            blue500: blue['500'],
+            blue700: blue['700'],
+            grey500: grey500,
+            grey600: grey600,
+            grey700: grey700,
+            grey800: grey800,
+            grey900: grey900,
+            darkblue: arvadosDarkBlue,
+            orange: '#f0ad4e',
+            greyL: arvadosGreyLight,
+            greyD: arvadosGreyDark,
+        }
+    },
+    overrides: {
+        MuiTypography: {
+            body1: {
+                fontSize: '0.8125rem'
+            }
+        },
+        MuiAppBar: {
+            colorPrimary: {
+                backgroundColor: arvadosDarkBlue
+            }
+        },
+        MuiTabs: {
+            root: {
+                color: grey600
+            },
+            indicator: {
+                backgroundColor: arvadosDarkBlue
+            }
+        },
+        MuiTab: {
+            root: {
+                '&$selected': {
+                    fontWeight: 700,
+                }
+            }
+        },
+        MuiList: {
+            root: {
+                color: grey900
+            }
+        },
+        MuiListItemText: {
+            root: {
+                padding: 0
+            }
+        },
+        MuiListItemIcon: {
+            root: {
+                fontSize: '1.25rem',
+            }
+        },
+        MuiCardHeader: {
+            avatar: {
+                display: 'flex',
+                alignItems: 'center'
+            },
+            title: {
+                color: arvadosGreyDark, 
+                fontSize: '1.25rem'
+            }
+        },
+        MuiExpansionPanel: {
+            expanded: {
+                marginTop: '8px',
+            }
+        },
+        MuiExpansionPanelDetails: {
+            root: {
+                marginBottom: 0,
+                paddingBottom: '4px',
+            }
+        },
+        MuiExpansionPanelSummary: {
+            content: {
+                '&$expanded': {
+                    margin: 0,
+                },
+                color: grey700,
+                fontSize: '1.25rem',
+                margin: 0,
+            },
+            expanded: {},
+        },
+        MuiMenuItem: {
+            root: {
+                padding: '8px 16px'
+            }
+        },
+        MuiInput: {
+            root: {
+                fontSize: '0.875rem'
+            },
+            underline: {
+                '&:after': {
+                    borderBottomColor: arvadosDarkBlue
+                },
+                '&:hover:not($disabled):not($focused):not($error):before': {
+                    borderBottom: '1px solid inherit'
+                }
+            }
+        },
+        MuiFormLabel: {
+            root: {
+                fontSize: '0.875rem',
+                "&$focused": {
+                    "&$focused:not($error)": {
+                        color: arvadosDarkBlue
+                    }
+                }
+            }
+        },
+        MuiStepIcon: {
+            root: {
+                '&$active': {
+                    color: arvadosDarkBlue
+                },
+                '&$completed': {
+                    color: 'inherited'
+                },
+            }
+        }
+    },
+    mixins: {
+        toolbar: {
+            minHeight: '48px'
+        }
+    },
+    palette: {
+        primary: {
+            main: '#017ead',
+            dark: '#015272',
+            light: '#82cffd',
+            contrastText: '#fff'
+        }
+    },
+};
+
+export const CustomTheme = createMuiTheme(themeOptions);
diff --git a/services/workbench2/src/common/file.ts b/services/workbench2/src/common/file.ts
new file mode 100644 (file)
index 0000000..2311399
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const fileToArrayBuffer = (file: File) =>
+    new Promise<ArrayBuffer>((resolve, reject) => {
+        const reader = new FileReader();
+        reader.onload = () => {
+            resolve(reader.result as ArrayBuffer);
+        };
+        reader.onerror = () => {
+            reject();
+        };
+        reader.readAsArrayBuffer(file);
+    });
diff --git a/services/workbench2/src/common/formatters.test.ts b/services/workbench2/src/common/formatters.test.ts
new file mode 100644 (file)
index 0000000..cde1a4f
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { formatFileSize, formatUploadSpeed, formatCost, formatCWLResourceSize } from "./formatters";
+
+describe('formatFileSize', () => {
+    it('should pick the largest unit', () => {
+        const base = 1024;
+        const testCases = [
+            {input: 0, output: '0 B'},
+            {input: 1, output: '1 B'},
+            {input: 1023, output: '1023 B'},
+            {input: base, output: '1.0 KiB'},
+            {input: 1.1 * base, output: '1.1 KiB'},
+            {input: 1.5 * base, output: '1.5 KiB'},
+            {input: base ** 2, output: '1.0 MiB'},
+            {input: 1.5 * (base ** 2), output: '1.5 MiB'},
+            {input: base ** 3, output: '1.0 GiB'},
+            {input: base ** 4, output: '1.0 TiB'},
+        ];
+
+        for (const { input, output } of testCases) {
+            expect(formatFileSize(input)).toBe(output);
+        }
+    });
+
+    it('should handle accidental empty string or undefined input', () => {
+        expect(formatFileSize('')).toBe('-');
+        expect(formatFileSize(undefined)).toBe('-');
+    });
+
+    it('should handle accidental non-empty string input', () => {
+        expect(formatFileSize('foo')).toBe('0 B');
+    });
+});
+
+describe('formatCWLResourceSize', () => {
+    it('should format bytes as MiB', () => {
+        const base = 1024 ** 2;
+
+        const testCases = [
+            {input: 0, output: '0 MiB'},
+            {input: 1, output: '0 MiB'},
+            {input: base - 1, output: '1 MiB'},
+            {input: 2 * base, output: '2 MiB'},
+            {input: 1024 * base, output: '1024 MiB'},
+            {input: 10000 * base, output: '10000 MiB'},
+        ];
+
+        for (const { input, output } of testCases) {
+            expect(formatCWLResourceSize(input)).toBe(output);
+        }
+    });
+});
+
+describe('formatUploadSpeed', () => {
+    it('should show speed less than 1MB/s', () => {
+        // given
+        const speed = 900;
+
+        // when
+        const result = formatUploadSpeed(0, speed, 0, 1);
+
+        // then
+        expect(result).toBe('0.90 MB/s');
+    });
+
+    it('should show 5MB/s', () => {
+        // given
+        const speed = 5230;
+
+        // when
+        const result = formatUploadSpeed(0, speed, 0, 1);
+
+        // then
+        expect(result).toBe('5.23 MB/s');
+    });
+});
+
+describe('formatContainerCost', () => {
+    it('should correctly round to tenth of a cent', () => {
+        expect(formatCost(0.0)).toBe('$0');
+        expect(formatCost(0.125)).toBe('$0.125');
+        expect(formatCost(0.1254)).toBe('$0.125');
+        expect(formatCost(0.1255)).toBe('$0.126');
+    });
+
+    it('should round up any smaller value to 0.001', () => {
+        expect(formatCost(0.0)).toBe('$0');
+        expect(formatCost(0.001)).toBe('$0.001');
+        expect(formatCost(0.0001)).toBe('$0.001');
+        expect(formatCost(0.00001)).toBe('$0.001');
+    });
+});
diff --git a/services/workbench2/src/common/formatters.ts b/services/workbench2/src/common/formatters.ts
new file mode 100644 (file)
index 0000000..e44a21e
--- /dev/null
@@ -0,0 +1,140 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PropertyValue } from 'models/search-bar';
+import {
+    Vocabulary,
+    getTagKeyLabel,
+    getTagValueLabel,
+} from 'models/vocabulary';
+
+export const formatDate = (isoDate?: string | null, utc: boolean = false) => {
+    if (isoDate) {
+        const date = new Date(isoDate);
+        let text: string;
+        if (utc) {
+            text = date.toUTCString();
+        } else {
+            text = date.toLocaleString();
+        }
+        return text === 'Invalid Date' ? '(none)' : text;
+    }
+    return '-';
+};
+
+export const formatFileSize = (size?: number | string) => {
+    if (typeof size === 'number') {
+        if (size === 0) {
+            return '0 B';
+        }
+
+        for (const { base, unit } of FILE_SIZES) {
+            if (size >= base) {
+                return `${(size / base).toFixed(base === 1 ? 0 : 1)} ${unit}`;
+            }
+        }
+    }
+    if ((typeof size === 'string' && size === '') || size === undefined) {
+        return '-';
+    }
+    return '0 B';
+};
+
+export const formatCWLResourceSize = (size: number) => {
+    return `${(size / CWL_SIZE.base).toFixed(0)} ${CWL_SIZE.unit}`;
+};
+
+export const formatTime = (time: number, seconds?: boolean) => {
+    const minutes = Math.floor((time / (1000 * 60)) % 60).toFixed(0);
+    const hours = Math.floor(time / (1000 * 60 * 60)).toFixed(0);
+
+    if (seconds) {
+        const seconds = Math.floor((time / 1000) % 60).toFixed(0);
+        return hours + 'h ' + minutes + 'm ' + seconds + 's';
+    }
+
+    return hours + 'h ' + minutes + 'm';
+};
+
+export const getTimeDiff = (endTime: string, startTime: string) => {
+    return new Date(endTime).getTime() - new Date(startTime).getTime();
+};
+
+export const formatProgress = (loaded: number, total: number) => {
+    const progress = loaded >= 0 && total > 0 ? (loaded * 100) / total : 0;
+    return `${progress.toFixed(2)}%`;
+};
+
+export function formatUploadSpeed(
+    prevLoaded: number,
+    loaded: number,
+    prevTime: number,
+    currentTime: number
+) {
+    const speed =
+        loaded > prevLoaded && currentTime > prevTime
+            ? (loaded - prevLoaded) / (currentTime - prevTime)
+            : 0;
+
+    return `${(speed / 1000).toFixed(2)} MB/s`;
+}
+
+const FILE_SIZES = [
+    {
+        base: 1024 ** 4,
+        unit: 'TiB',
+    },
+    {
+        base: 1024 ** 3,
+        unit: 'GiB',
+    },
+    {
+        base: 1024 ** 2,
+        unit: 'MiB',
+    },
+    {
+        base: 1024,
+        unit: 'KiB',
+    },
+    {
+        base: 1,
+        unit: 'B',
+    },
+];
+
+const CWL_SIZE = {
+    base: 1024 ** 2,
+    unit: 'MiB',
+};
+
+export const formatPropertyValue = (
+    pv: PropertyValue,
+    vocabulary?: Vocabulary
+) => {
+    if (vocabulary && pv.keyID && pv.valueID) {
+        return `${getTagKeyLabel(pv.keyID, vocabulary)}: ${getTagValueLabel(
+            pv.keyID,
+            pv.valueID!,
+            vocabulary
+        )}`;
+    }
+    if (pv.key) {
+        return pv.value ? `${pv.key}: ${pv.value}` : pv.key;
+    }
+    return '';
+};
+
+export const formatCost = (cost: number): string => {
+    const decimalPlaces = 3;
+
+    const factor = Math.pow(10, decimalPlaces);
+    const rounded = Math.round(cost * factor) / factor;
+    if (cost > 0 && rounded === 0) {
+        // Display min value of 0.001
+        return `$${1 / factor}`;
+    } else {
+        // Otherwise use rounded value to proper decimal places
+        return `$${rounded}`;
+    }
+};
diff --git a/services/workbench2/src/common/frozen-resources.ts b/services/workbench2/src/common/frozen-resources.ts
new file mode 100644 (file)
index 0000000..8d22791
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ProjectResource } from "models/project";
+import { getResource } from "store/resources/resources";
+
+export const resourceIsFrozen = (resource: any, resources): boolean => {
+    let isFrozen: boolean = !!resource.frozenByUuid;
+    let ownerUuid: string | undefined = resource?.ownerUuid;
+
+    while(!isFrozen && !!ownerUuid && ownerUuid.indexOf('000000000000000') === -1) {
+        const parentResource: ProjectResource | undefined = getResource<ProjectResource>(ownerUuid)(resources);
+        isFrozen = !!parentResource?.frozenByUuid;
+        ownerUuid = parentResource?.ownerUuid;
+    }
+
+    return isFrozen;
+}
\ No newline at end of file
diff --git a/services/workbench2/src/common/getuser.ts b/services/workbench2/src/common/getuser.ts
new file mode 100644 (file)
index 0000000..b3370bc
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+
+export const getUserUuid = (state: RootState) => {
+    const user = state.auth.user;
+    if (user) {
+        return user.uuid;
+    } else {
+        return undefined;
+    }
+};
diff --git a/services/workbench2/src/common/html-sanitize.ts b/services/workbench2/src/common/html-sanitize.ts
new file mode 100644 (file)
index 0000000..e7c66f1
--- /dev/null
@@ -0,0 +1,51 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import DOMPurify from 'dompurify';
+
+type TDomPurifyConfig = {
+    ALLOWED_TAGS: string[];
+    ALLOWED_ATTR: string[];
+};
+
+const domPurifyConfig: TDomPurifyConfig = {
+    ALLOWED_TAGS: [
+        'a',
+        'b',
+        'blockquote',
+        'br',
+        'code',
+        'del',
+        'dd',
+        'dl',
+        'dt',
+        'em',
+        'h1',
+        'h2',
+        'h3',
+        'h4',
+        'h5',
+        'h6',
+        'hr',
+        'i',
+        'img',
+        'kbd',
+        'li',
+        'ol',
+        'p',
+        'pre',
+        's',
+        'del',
+        'section',
+        'span',
+        'strong',
+        'sub',
+        'sup',
+        'ul',
+    ],
+    ALLOWED_ATTR: ['src', 'width', 'height', 'href', 'alt', 'title', 'style' ],
+};
+
+export const sanitizeHTML = (dirtyString: string): string => DOMPurify.sanitize(dirtyString, domPurifyConfig);
+
diff --git a/services/workbench2/src/common/labels.ts b/services/workbench2/src/common/labels.ts
new file mode 100644 (file)
index 0000000..e784cec
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ResourceKind } from "models/resource";
+
+export const resourceLabel = (type: string, subtype = '') => {
+    switch (type) {
+        case ResourceKind.COLLECTION:
+            return "Data collection";
+        case ResourceKind.PROJECT:
+            if (subtype === "filter") {
+                return "Filter group";
+            } else if (subtype === "role") {
+                return "Group";
+            }
+            return "Project";
+        case ResourceKind.PROCESS:
+            return "Process";
+        case ResourceKind.USER:
+            return "User";
+        case ResourceKind.GROUP:
+            return "Group";
+        case ResourceKind.VIRTUAL_MACHINE:
+            return "Virtual Machine";
+        case ResourceKind.WORKFLOW:
+            return "Workflow";
+        default:
+            return "Unknown";
+    }
+};
diff --git a/services/workbench2/src/common/link-update-name.ts b/services/workbench2/src/common/link-update-name.ts
new file mode 100644 (file)
index 0000000..d9a04df
--- /dev/null
@@ -0,0 +1,74 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkResource } from 'models/link';
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository, getResourceService } from 'services/services';
+import { Resource, extractUuidKind } from 'models/resource';
+
+type NameableResource = Resource & { name?: string };
+
+export const verifyAndUpdateLink = async (link: LinkResource, dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<LinkResource> => {
+    //head resource should already be in the store
+    let headResource: Resource | undefined = getState().resources[link.headUuid];
+    //if not, fetch it
+    if (!headResource) {
+        headResource = await fetchResource(link.headUuid)(dispatch, getState, services);
+        if (!headResource) {
+            if (!link.name) console.error('Could not validate link', link, 'because link head', link.headUuid, 'is not available');
+            return link;
+        }
+    }
+
+    if (validateLinkNameProp(link, headResource) === true) return link;
+
+    const updatedLink = updateLinkNameProp(link, headResource);
+    updateRemoteLinkName(updatedLink)(dispatch, getState, services);
+
+    return updatedLink;
+};
+
+export const verifyAndUpdateLinks = async (links: LinkResource[], dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    
+    const updatedLinks = links.map((link) => verifyAndUpdateLink(link, dispatch, getState, services));
+        return Promise.all(updatedLinks);
+};
+
+const fetchResource = (uuid: string, showErrors?: boolean) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const kind = extractUuidKind(uuid);
+        const service = getResourceService(kind)(services);
+        if (service) {
+            const resource = await service.get(uuid, showErrors);
+            return resource;
+        }
+    } catch (e) {
+        console.error(`Could not fetch resource ${uuid}`, e);
+    }
+    return undefined;
+};
+
+const validateLinkNameProp = (link: LinkResource, head: NameableResource) => {
+    if (!link.name || link.name !== head.name) return false;
+    return true;
+};
+
+const updateLinkNameProp = (link: LinkResource, head: NameableResource) => {
+    const updatedLink = { ...link };
+    if (head.name) updatedLink.name = head.name;
+    return updatedLink;
+};
+
+const updateRemoteLinkName = (link: LinkResource) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const kind = extractUuidKind(link.uuid);
+        const service = getResourceService(kind)(services);
+        if (service) {
+            service.update(link.uuid, { name: link.name });
+        }
+    } catch (error) {
+        console.error('Could not update link name', link, error);
+    }
+};
diff --git a/services/workbench2/src/common/objects.ts b/services/workbench2/src/common/objects.ts
new file mode 100644 (file)
index 0000000..12dd004
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { union, keys as keys_1, filter } from "lodash";
+
+export function getModifiedKeys(a: any, b: any) {
+    const keys = union(keys_1(a), keys_1(b));
+    return filter(keys, key => a[key] !== b[key]);
+}
+
+export function getModifiedKeysValues(a: any, b: any) {
+    const keys = getModifiedKeys(a, b);
+    const obj = {};
+    keys.forEach(k => {
+        obj[k] = a[k];
+    });
+    return obj;
+}
diff --git a/services/workbench2/src/common/plugintypes.ts b/services/workbench2/src/common/plugintypes.ts
new file mode 100644 (file)
index 0000000..da6e805
--- /dev/null
@@ -0,0 +1,178 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Dispatch, Middleware } from 'redux';
+import { RootStore, RootState } from 'store/store';
+import { ResourcesState } from 'store/resources/resources';
+import { Location } from 'history';
+import { ServiceRepository } from "services/services";
+
+export type ElementListReducer = (startingList: React.ReactElement[], itemClass?: string) => React.ReactElement[];
+export type CategoriesListReducer = (startingList: string[]) => string[];
+export type NavigateMatcher = (dispatch: Dispatch, getState: () => RootState, uuid: string) => boolean;
+export type LocationChangeMatcher = (store: RootStore, pathname: string) => boolean;
+export type EnableNew = (location: Location, currentItemId: string, currentUserUUID: string | undefined, resources: ResourcesState) => boolean;
+export type MiddlewareListReducer = (startingList: Middleware[], services: ServiceRepository) => Middleware[];
+
+/* Workbench Plugin API
+
+   Code to your plugin should go into a subdirectory of 'plugins/'.
+
+   Your plugin should implement a "register" function, which will be
+   called with an object with the PluginConfig interface described
+   below.  The register function may make in-place modifications to
+   the pluginConfig object, but to preserve composability, it is
+   strongly advised this should be limited to push()ing new values
+   onto the various lists of hooks.
+
+   To enable a plugin, edit 'plugins.tsx', import the register
+   function exported by the plugin, and add a call to the register
+   function following the examples in the comments.  Then, build a new
+   Workbench package that includes the plugin.
+
+   Be aware that because plugins heavily leverage workbench, and in
+   fact must be compiled together, they are considered "derived works"
+   and so _must_ be license-compatible with AGPL-3.0.
+
+ */
+
+export interface PluginConfig {
+
+    /* During initialization, each
+     * function in the callback list will be called with the list of
+     * react - router "Route" components that will be used select what should
+     * be displayed in the central panel based on the navigation bar.
+     *
+     * The callback function may add, edit, or remove items from this list,
+     * and return a new list of components, which will be passed to the next
+     * function in `centerPanelList`.
+     *
+     * The hooks are applied in `views/workbench/workbench.tsx`.
+     *  */
+    centerPanelList: ElementListReducer[];
+
+    /* During initialization, each
+     * function in the callback list will be called with the list of strings
+     * that are the top-level categories in the left hand navigation tree.
+     *
+     * The callback function may add, edit, or remove items from this list,
+     * and return a new list of strings, which will be passed to the next
+     * function in `sidePanelCategories`.
+     *
+     * The hooks are applied in `store/side-panel-tree/side-panel-tree-actions.ts`.
+     *  */
+    sidePanelCategories: CategoriesListReducer[];
+
+    /* This is a list of additional dialog box components.
+     * Dialogs are components that are wrapped using the "withDialog()" method.
+     *
+     * These are added to the list in `views/workbench/workbench.tsx`.
+     *  */
+    dialogs: React.ReactElement[];
+
+    /* This is a list of additional navigation matchers.
+     * These are callbacks that are called by the navigateTo(uuid) method to
+     * set the path in the navigation bar to display the desired resource.
+     * Each handler should return "true" if the uuid was handled and "false or "undefined" if not.
+     *
+     * These are used in `store/navigation/navigation-action.tsx`.
+     *  */
+    navigateToHandlers: NavigateMatcher[];
+
+    /* This is a list of additional location change matchers.
+     * These are callbacks called when the URL in the navigation bar changes
+     * (this could be in response to "navigateTo()" or due to the user
+     * entering/changing the URL directly).
+     *
+     * The Route components in centerPanelList should
+     * automatically change in response to navigation.  The
+     * purpose of these handlers is trigger additional loading,
+     * such as fetching the object contents that will be
+     * displayed.
+     *
+     * Each handler should return "true" if the path was handled and "false or "undefined" if not.
+     *
+     * These are used in `routes/route-change-handlers.ts`.
+     */
+    locationChangeHandlers: LocationChangeMatcher[];
+
+    /* Replace the left side of the app bar.  Normally, this displays
+     * the site banner.
+     *
+     * Note: unlike most of the other hooks, this is not composable.
+     * This completely replaces that section of the app bar.  Multiple
+     * plugins setting this value will conflict.
+     *
+     * Used in 'views-components/main-app-bar/main-app-bar.tsx'
+     */
+    appBarLeft?: React.ReactElement;
+
+    /* Replace the middle part of the app bar.  Normally, this displays
+     * the search bar.
+     *
+     * Note: unlike most of the other hooks, this is not composable.
+     * This completely replaces that section of the app bar.  Multiple
+     * plugins setting this value will conflict.
+     *
+     * Used in 'views-components/main-app-bar/main-app-bar.tsx'
+     */
+    appBarMiddle?: React.ReactElement;
+
+    /* Replace the right part of the app bar.  Normally, this displays
+     * the admin menu and help menu.
+     * (Note: the user menu can be customized separately using accountMenuList)
+     *
+     * Note: unlike most of the other hooks, this is not composable.
+     * This completely replaces that section of the app bar.  Multiple
+     * plugins setting this value will conflict.
+     *
+     * Used in 'views-components/main-app-bar/main-app-bar.tsx'
+     */
+    appBarRight?: React.ReactElement;
+
+    /* During initialization, each
+     * function in the callback list will be called with the menu items that
+     * will appear in the "user account" menu.
+     *
+     * The callback function may add, edit, or remove items from this list,
+     * and return a new list of menu items, which will be passed to the next
+     * function in `accountMenuList`.
+     *
+     * The hooks are applied in 'views-components/main-app-bar/account-menu.tsx'.
+     *  */
+    accountMenuList: ElementListReducer[];
+
+    /* Each function in this list is called to determine if the the "NEW" button
+     * should be enabled or disabled.  If any function returns "true", the button
+     * (and corresponding drop-down menu) will be enabled.
+     *
+     * The hooks are applied in 'views-components/side-panel-button/side-panel-button.tsx'.
+     *  */
+    enableNewButtonMatchers: EnableNew[];
+
+    /* During initialization, each
+     * function in the callback list will be called with the menu items that
+     * will appear in the "NEW" dropdown menu.
+     *
+     * The callback function may add, edit, or remove items from this list,
+     * and return a new list of menu items, which will be passed to the next
+     * function in `newButtonMenuList`.
+     *
+     * The hooks are applied in 'views-components/side-panel-button/side-panel-button.tsx'.
+     *  */
+    newButtonMenuList: ElementListReducer[];
+
+    /* Add Middlewares to the Redux store.
+     *
+     * Middlewares intercept redux actions before they get to the reducer, and
+     * may produce side effects.  For example, the REQUEST_ITEMS action is intercepted by a middleware to
+     * trigger a load of data table contents.
+     *
+     * https://redux.js.org/tutorials/fundamentals/part-4-store#middleware
+     *
+     * Used in 'store/store.ts'
+     *  */
+    middlewares: MiddlewareListReducer[];
+}
diff --git a/services/workbench2/src/common/redirect-to.test.ts b/services/workbench2/src/common/redirect-to.test.ts
new file mode 100644 (file)
index 0000000..adb52f4
--- /dev/null
@@ -0,0 +1,82 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { storeRedirects, handleRedirects } from './redirect-to';
+
+describe('redirect-to', () => {
+    const { location } = window;
+    const config: any = {
+        keepWebServiceUrl: 'http://localhost',
+        keepWebServiceInlineUrl: 'http://localhost-inline'
+    };
+    const redirectTo = 'c=acbd18db4cc2f85cedef654fccc4a4d8%2B3/foo';
+    const locationTemplate = {
+        hash: '',
+        hostname: '',
+        origin: '',
+        host: '',
+        pathname: '',
+        port: '80',
+        protocol: 'http',
+        search: '',
+        reload: () => { },
+        replace: () => { },
+        assign: () => { },
+        ancestorOrigins: [],
+        href: '',
+    };
+
+    afterAll((): void => {
+        window.location = location;
+    });
+
+    describe('storeRedirects', () => {
+        beforeEach(() => {
+            delete window.location;
+            window.location = {
+                ...locationTemplate,
+                href: `${location.href}?redirectToDownload=${redirectTo}`,
+            } as any;
+            Object.defineProperty(window, 'localStorage', {
+                value: {
+                    setItem: jest.fn(),
+                },
+                writable: true
+            });
+        });
+
+        it('should store redirectTo in the session storage', () => {
+            // when
+            storeRedirects();
+
+            // then
+            expect(window.localStorage.setItem).toHaveBeenCalledWith('redirectToDownload', decodeURIComponent(redirectTo));
+        });
+    });
+
+    describe('handleRedirects', () => {
+        beforeEach(() => {
+            delete window.location;
+            window.location = {
+                ...locationTemplate,
+                href: `${location.href}?redirectToDownload=${redirectTo}`,
+            } as any;;
+            Object.defineProperty(window, 'localStorage', {
+                value: {
+                    getItem: () => redirectTo,
+                    removeItem: jest.fn(),
+                },
+                writable: true
+            });
+        });
+
+        it('should redirect to page when it is present in session storage', () => {
+            // when
+            handleRedirects("abcxyz", config);
+
+            // then
+            expect(window.location.href).toBe(`${config.keepWebServiceUrl}${redirectTo}?api_token=abcxyz`);
+        });
+    });
+});
diff --git a/services/workbench2/src/common/redirect-to.ts b/services/workbench2/src/common/redirect-to.ts
new file mode 100644 (file)
index 0000000..e71ebde
--- /dev/null
@@ -0,0 +1,64 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { getInlineFileUrl } from 'views-components/context-menu/actions/helpers';
+import { Config } from './config';
+
+export const REDIRECT_TO_DOWNLOAD_KEY = 'redirectToDownload';
+export const REDIRECT_TO_PREVIEW_KEY = 'redirectToPreview';
+export const REDIRECT_TO_KEY = 'redirectTo';
+
+const getRedirectKeyFromUrl = (href: string): string | null => {
+    switch (true) {
+        case href.indexOf(REDIRECT_TO_DOWNLOAD_KEY) > -1:
+            return REDIRECT_TO_DOWNLOAD_KEY;
+        case href.indexOf(REDIRECT_TO_PREVIEW_KEY) > -1:
+            return REDIRECT_TO_PREVIEW_KEY;
+        case href.indexOf(`${REDIRECT_TO_KEY}=`) > -1:
+            return REDIRECT_TO_KEY;
+        default:
+            return null;
+    }
+}
+
+const getRedirectKeyFromStorage = (localStorage: Storage): string | null => {
+    if (localStorage.getItem(REDIRECT_TO_DOWNLOAD_KEY)) {
+        return REDIRECT_TO_DOWNLOAD_KEY;
+    } else if (localStorage.getItem(REDIRECT_TO_PREVIEW_KEY)) {
+        return REDIRECT_TO_PREVIEW_KEY;
+    }
+    return null;
+}
+
+export const storeRedirects = () => {
+    const { location: { href }, localStorage } = window;
+    const redirectKey = getRedirectKeyFromUrl(href);
+
+    // Change old redirectTo -> redirectToPreview when storing redirect
+    const redirectStoreKey = redirectKey === REDIRECT_TO_KEY ? REDIRECT_TO_PREVIEW_KEY : redirectKey;
+
+    if (localStorage && redirectKey && redirectStoreKey) {
+        localStorage.setItem(redirectStoreKey, decodeURIComponent(href.split(`${redirectKey}=`)[1]));
+    }
+};
+
+export const handleRedirects = (token: string, config: Config) => {
+    const { localStorage } = window;
+    const { keepWebServiceUrl, keepWebInlineServiceUrl } = config;
+
+    if (localStorage) {
+        const redirectKey = getRedirectKeyFromStorage(localStorage);
+        const redirectPath = redirectKey ? localStorage.getItem(redirectKey) : '';
+        redirectKey && localStorage.removeItem(redirectKey);
+
+        if (redirectKey && redirectPath) {
+            const sep = redirectPath.indexOf("?") > -1 ? "&" : "?";
+            let redirectUrl = `${keepWebServiceUrl}${redirectPath}${sep}api_token=${token}`;
+            if (redirectKey === REDIRECT_TO_PREVIEW_KEY) {
+                redirectUrl = getInlineFileUrl(redirectUrl, keepWebServiceUrl, keepWebInlineServiceUrl);
+            }
+            window.location.href = redirectUrl;
+        }
+    }
+};
diff --git a/services/workbench2/src/common/regexp.ts b/services/workbench2/src/common/regexp.ts
new file mode 100644 (file)
index 0000000..eca24c7
--- /dev/null
@@ -0,0 +1,6 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const escapeRegExp = (st: string) =>
+    st.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
diff --git a/services/workbench2/src/common/service-provider.ts b/services/workbench2/src/common/service-provider.ts
new file mode 100644 (file)
index 0000000..e0504eb
--- /dev/null
@@ -0,0 +1,50 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+class ServicesProvider {
+
+    private static instance: ServicesProvider;
+
+    private store;
+    private services;
+
+    private constructor() {}
+
+    public static getInstance(): ServicesProvider {
+        if (!ServicesProvider.instance) {
+            ServicesProvider.instance = new ServicesProvider();
+        }
+
+        return ServicesProvider.instance;
+    }
+
+    public setServices(newServices): void {
+        if (!this.services) {
+            this.services = newServices;
+        }
+    }
+
+    public getServices() {
+        if (!this.services) {
+            throw "Please check if services have been set in the index.ts before the app is initiated"; // eslint-disable-line no-throw-literal
+        }
+        return this.services;
+    }
+
+    public setStore(newStore): void {
+        if (!this.store) {
+            this.store = newStore;
+        }
+    }
+
+    public getStore() {
+        if (!this.store) {
+            throw "Please check if store has been set in the index.ts before the app is initiated"; // eslint-disable-line no-throw-literal
+        }
+
+        return this.store;
+    }
+}
+
+export default ServicesProvider.getInstance();
diff --git a/services/workbench2/src/common/unionize.ts b/services/workbench2/src/common/unionize.ts
new file mode 100644 (file)
index 0000000..794b5f6
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize as originalUnionize, SingleValueRec } from 'unionize';
+
+export * from 'unionize';
+
+export function unionize<Record extends SingleValueRec>(record: Record) {
+    return originalUnionize(record, {
+        tag: 'type',
+        value: 'payload'
+    });
+}
+
diff --git a/services/workbench2/src/common/url.test.ts b/services/workbench2/src/common/url.test.ts
new file mode 100644 (file)
index 0000000..21bc518
--- /dev/null
@@ -0,0 +1,57 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { customDecodeURI, customEncodeURI } from './url';
+
+describe('url', () => {
+    describe('customDecodeURI', () => {
+        it('should decode encoded URI', () => {
+            // given
+            const path = 'test%23test%2Ftest';
+            const expectedResult = 'test#test%2Ftest';
+
+            // when
+            const result = customDecodeURI(path);
+
+            // then
+            expect(result).toEqual(expectedResult);
+        });
+
+        it('ignores non parsable URI and return its original form', () => {
+            // given
+            const path = 'test/path/with%wrong/sign';
+
+            // when
+            const result = customDecodeURI(path);
+
+            // then
+            expect(result).toEqual(path);
+        });
+    });
+
+    describe('customEncodeURI', () => {
+        it('should encode URI', () => {
+            // given
+            const path = 'test#test/test';
+            const expectedResult = 'test%23test/test';
+
+            // when
+            const result = customEncodeURI(path);
+
+            // then
+            expect(result).toEqual(expectedResult);
+        });
+
+        it('ignores non encodable URI and return its original form', () => {
+            // given
+            const path = 22;
+
+            // when
+            const result = customEncodeURI(path as any);
+
+            // then
+            expect(result).toEqual(path);
+        });
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/common/url.ts b/services/workbench2/src/common/url.ts
new file mode 100644 (file)
index 0000000..db12cb8
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export function getUrlParameter(search: string, name: string) {
+    const safeName = name.replace(/[[]/, '\\[').replace(/[\]]/, '\\]');
+    const regex = new RegExp('[\\?&]' + safeName + '=([^&#]*)');
+    const results = regex.exec(search);
+    return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
+}
+
+export function normalizeURLPath(url: string) {
+    const u = new URL(url);
+    u.pathname = u.pathname.replace(/\/\//, '/');
+    if (u.pathname[u.pathname.length - 1] === '/') {
+        u.pathname = u.pathname.substring(0, u.pathname.length - 1);
+    }
+    return u.toString();
+}
+
+export const customEncodeURI = (path: string) => {
+    try {
+        return path.split('/').map(encodeURIComponent).join('/');
+    } catch(e) {}
+
+    return path;
+};
+
+export const customDecodeURI = (path: string) => {
+    try {
+        return path.split('%2F').map(decodeURIComponent).join('%2F');
+    } catch(e) {}
+
+    return path;
+};
diff --git a/services/workbench2/src/common/use-async-interval.test.tsx b/services/workbench2/src/common/use-async-interval.test.tsx
new file mode 100644 (file)
index 0000000..188f184
--- /dev/null
@@ -0,0 +1,96 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { useAsyncInterval } from './use-async-interval';
+import { configure, mount } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import FakeTimers from "@sinonjs/fake-timers";
+
+configure({ adapter: new Adapter() });
+const clock = FakeTimers.install();
+
+jest.mock('react', () => {
+    const originalReact = jest.requireActual('react');
+    const mUseRef = jest.fn();
+    return {
+        ...originalReact,
+        useRef: mUseRef,
+    };
+});
+
+const TestComponent = (props): JSX.Element => {
+    useAsyncInterval(props.callback, 2000);
+    return <span />;
+};
+
+describe('useAsyncInterval', () => {
+    it('should fire repeatedly after the interval', async () => {
+        const mockedReact = React as jest.Mocked<typeof React>;
+        const ref = { current: {} };
+        mockedReact.useRef.mockReturnValue(ref);
+
+        const syncCallback = jest.fn();
+        const testComponent = mount(<TestComponent
+            callback={syncCallback}
+        />);
+
+        // cb queued with interval but not called
+        expect(syncCallback).not.toHaveBeenCalled();
+
+        // wait for first tick
+        await clock.tickAsync(2000);
+        expect(syncCallback).toHaveBeenCalledTimes(1);
+
+        // wait for second tick
+        await clock.tickAsync(2000);
+        expect(syncCallback).toHaveBeenCalledTimes(2);
+
+        // wait for third tick
+        await clock.tickAsync(2000);
+        expect(syncCallback).toHaveBeenCalledTimes(3);
+    });
+
+    it('should wait for async callbacks to complete in between polling', async () => {
+        const mockedReact = React as jest.Mocked<typeof React>;
+        const ref = { current: {} };
+        mockedReact.useRef.mockReturnValue(ref);
+
+        const delayedCallback = jest.fn(() => (
+            new Promise<void>((resolve) => {
+                setTimeout(() => {
+                    resolve();
+                }, 2000);
+            })
+        ));
+        const testComponent = mount(<TestComponent
+            callback={delayedCallback}
+        />);
+
+        // cb queued with setInterval but not called
+        expect(delayedCallback).not.toHaveBeenCalled();
+
+        // Wait 2 seconds for first tick
+        await clock.tickAsync(2000);
+        // First cb called after 2 seconds
+        expect(delayedCallback).toHaveBeenCalledTimes(1);
+        // Wait for cb to resolve for 2 seconds
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(1);
+
+        // Wait 2 seconds for second tick
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(2);
+        // Wait for cb to resolve for 2 seconds
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(2);
+
+        // Wait 2 seconds for third tick
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(3);
+        // Wait for cb to resolve for 2 seconds
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(3);
+    });
+});
diff --git a/services/workbench2/src/common/use-async-interval.ts b/services/workbench2/src/common/use-async-interval.ts
new file mode 100644 (file)
index 0000000..3be7309
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+
+export const useAsyncInterval = function (callback, delay) {
+    const ref = React.useRef<{cb: () => Promise<any>, active: boolean}>({
+        cb: async () => {},
+        active: false}
+    );
+
+    // Remember the latest callback.
+    React.useEffect(() => {
+        ref.current.cb = callback;
+    }, [callback]);
+    // Set up the interval.
+    React.useEffect(() => {
+        function tick() {
+            if (ref.current.active) {
+                // Wrap execution chain with promise so that execution errors or
+                //   non-async callbacks still fall through to .finally, avoids breaking polling
+                new Promise((resolve) => {
+                    return resolve(ref.current.cb());
+                }).then(() => {
+                    // Promise succeeded
+                    // Possibly implement back-off reset
+                }).catch(() => {
+                    // Promise rejected
+                    // Possibly implement back-off in the future
+                }).finally(() => {
+                    setTimeout(tick, delay);
+                });
+            }
+        }
+        if (delay !== null) {
+            ref.current.active = true;
+            setTimeout(tick, delay);
+        }
+        // Suppress warning about cleanup function - can be ignored when variables are unrelated to dom elements
+        //   https://github.com/facebook/react/issues/15841#issuecomment-500133759
+        // eslint-disable-next-line
+        return () => {ref.current.active = false;};
+    }, [delay]);
+};
diff --git a/services/workbench2/src/common/webdav.test.ts b/services/workbench2/src/common/webdav.test.ts
new file mode 100644 (file)
index 0000000..1149c45
--- /dev/null
@@ -0,0 +1,159 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { WebDAV } from "./webdav";
+
+describe('WebDAV', () => {
+    it('makes use of provided config', async () => {
+        const { open, load, setRequestHeader, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV({ baseURL: 'http://foo.com/', headers: { Authorization: 'Basic' } }, createRequest);
+        const promise = webdav.propfind('foo');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('PROPFIND', 'http://foo.com/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Authorization', 'Basic');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('allows to modify defaults after instantiation', async () => {
+        const { open, load, setRequestHeader, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV({ baseURL: 'http://foo.com/' }, createRequest);
+        webdav.setAuthorization('Basic');
+        const promise = webdav.propfind('foo');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('PROPFIND', 'http://foo.com/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Authorization', 'Basic');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('PROPFIND', async () => {
+        const { open, load, setRequestHeader, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV(undefined, createRequest);
+        const promise = webdav.propfind('foo');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('PROPFIND', 'foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('PUT', async () => {
+        const { open, send, load, progress, setRequestHeader, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV(undefined, createRequest);
+        const promise = webdav.put('foo', 'Test data');
+        progress();
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('PUT', 'foo');
+        expect(send).toHaveBeenCalledWith('Test data');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('COPY', async () => {
+        const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
+        const promise = webdav.copy('foo', 'foo-copy');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('COPY - adds baseURL with trailing slash to Destination header', async () => {
+        const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
+        const promise = webdav.copy('foo', 'foo-copy');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('COPY - adds baseURL without trailing slash to Destination header', async () => {
+        const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
+        const promise = webdav.copy('foo', 'foo-copy');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('MOVE', async () => {
+        const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
+        const promise = webdav.move('foo', 'foo-moved');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('MOVE - adds baseURL with trailing slash to Destination header', async () => {
+        const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
+        const promise = webdav.move('foo', 'foo-moved');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('MOVE - adds baseURL without trailing slash to Destination header', async () => {
+        const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
+        const promise = webdav.move('foo', 'foo-moved');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+
+    it('DELETE', async () => {
+        const { open, load, setRequestHeader, createRequest } = mockCreateRequest();
+        const webdav = new WebDAV(undefined, createRequest);
+        const promise = webdav.delete('foo');
+        load();
+        const request = await promise;
+        expect(open).toHaveBeenCalledWith('DELETE', 'foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
+        expect(request).toBeInstanceOf(XMLHttpRequest);
+    });
+});
+
+const mockCreateRequest = () => {
+    const send = jest.fn();
+    const open = jest.fn();
+    const setRequestHeader = jest.fn();
+    const request = new XMLHttpRequest();
+    request.send = send;
+    request.open = open;
+    request.setRequestHeader = setRequestHeader;
+    const load = () => request.dispatchEvent(new Event('load'));
+    const progress = () => request.dispatchEvent(new Event('progress'));
+    return {
+        send,
+        open,
+        load,
+        progress,
+        setRequestHeader,
+        createRequest: () => request
+    };
+};
diff --git a/services/workbench2/src/common/webdav.ts b/services/workbench2/src/common/webdav.ts
new file mode 100644 (file)
index 0000000..1f3da0d
--- /dev/null
@@ -0,0 +1,159 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { customEncodeURI } from "./url";
+
+export class WebDAV {
+
+    private defaults: WebDAVDefaults = {
+        baseURL: '',
+        headers: {
+            'Cache-Control': 'no-cache'
+        },
+    };
+
+    constructor(config?: Partial<WebDAVDefaults>, private createRequest = () => new XMLHttpRequest()) {
+        if (config) {
+            this.defaults = {
+                ...this.defaults,
+                ...config,
+                headers: {
+                    ...this.defaults.headers,
+                    ...config.headers
+                },
+            };
+        }
+    }
+
+    getBaseUrl = (): string => this.defaults.baseURL;
+    setAuthorization = (token?) => this.defaults.headers.Authorization = token;
+
+    propfind = (url: string, config: WebDAVRequestConfig = {}) =>
+        this.request({
+            ...config, url,
+            method: 'PROPFIND'
+        })
+
+    put = (url: string, data?: any, config: WebDAVRequestConfig = {}) =>
+        this.request({
+            ...config, url,
+            method: 'PUT',
+            data
+        })
+
+    get = (url: string, config: WebDAVRequestConfig = {}) =>
+        this.request({
+            ...config, url,
+            method: 'GET'
+        })
+
+    upload = (url: string, files: File[], config: WebDAVRequestConfig = {}) => {
+        return Promise.all(
+            files.map(file => this.request({
+                ...config, url,
+                method: 'PUT',
+                data: file
+            }))
+        );
+    }
+
+    copy = (url: string, destination: string, config: WebDAVRequestConfig = {}) =>
+        this.request({
+            ...config, url,
+            method: 'COPY',
+            headers: {
+                ...config.headers,
+                Destination: this.defaults.baseURL
+                    ? this.defaults.baseURL.replace(/\/+$/, '') + '/' + destination.replace(/^\/+/, '')
+                    : destination
+            }
+        })
+
+    move = (url: string, destination: string, config: WebDAVRequestConfig = {}) =>
+        this.request({
+            ...config, url,
+            method: 'MOVE',
+            headers: {
+                ...config.headers,
+                Destination: this.defaults.baseURL
+                    ? this.defaults.baseURL.replace(/\/+$/, '') + '/' + destination.replace(/^\/+/, '')
+                    : destination
+            }
+        })
+
+    delete = (url: string, config: WebDAVRequestConfig = {}) =>
+        this.request({
+            ...config, url,
+            method: 'DELETE'
+        })
+
+    private request = (config: RequestConfig) => {
+        return new Promise<XMLHttpRequest>((resolve, reject) => {
+            const r = this.createRequest();
+            this.defaults.baseURL = this.defaults.baseURL.replace(/\/+$/, '');
+            r.open(config.method,
+                `${this.defaults.baseURL
+                    ? this.defaults.baseURL + '/'
+                    : ''}${customEncodeURI(config.url)}`);
+
+            const headers = { ...this.defaults.headers, ...config.headers };
+            Object
+                .keys(headers)
+                .forEach(key => r.setRequestHeader(key, headers[key]));
+
+            if (!(window as any).cancelTokens) {
+                Object.assign(window, { cancelTokens: {} });
+            }
+
+            (window as any).cancelTokens[config.url] = () => {
+                resolve(r);
+                r.abort();
+            }
+
+            if (config.onUploadProgress) {
+                r.upload.addEventListener('progress', config.onUploadProgress);
+            }
+
+            // This event gets triggered on *any* server response
+            r.addEventListener('load', () => {
+                if (r.status >= 400) {
+                    return reject(r);
+                } else {
+                    return resolve(r);
+                }
+            });
+
+            // This event gets triggered on network errors
+            r.addEventListener('error', () => {
+                return reject(r);
+            });
+
+            r.upload.addEventListener('error', () => {
+                return reject(r);
+            });
+
+            r.send(config.data);
+        });
+    }
+}
+
+export interface WebDAVRequestConfig {
+    headers?: {
+        [key: string]: string;
+    };
+    onUploadProgress?: (event: ProgressEvent) => void;
+}
+
+interface WebDAVDefaults {
+    baseURL: string;
+    headers: { [key: string]: string };
+}
+
+interface RequestConfig {
+    method: string;
+    url: string;
+    headers?: { [key: string]: string };
+    data?: any;
+    onUploadProgress?: (event: ProgressEvent) => void;
+}
diff --git a/services/workbench2/src/common/xml.ts b/services/workbench2/src/common/xml.ts
new file mode 100644 (file)
index 0000000..e7db3ac
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { customDecodeURI } from "./url";
+
+export const getTagValue = (document: Document | Element, tagName: string, defaultValue: string, skipDecoding: boolean = false) => {
+    const [el] = Array.from(document.getElementsByTagName(tagName));
+    const URI = el ? htmlDecode(el.innerHTML) : defaultValue;
+
+    if (!skipDecoding) {
+        try {
+            return customDecodeURI(URI);
+        } catch(e) {}
+    }
+
+    return URI;
+};
+
+const htmlDecode = (input: string) => {
+    const out = input.split(' ').map((i) => {
+        const doc = new DOMParser().parseFromString(i, "text/html");
+        if (doc.documentElement !== null) {
+            return doc.documentElement.textContent || '';
+        }
+        return '';
+    });
+    return out.join(' ');
+};
diff --git a/services/workbench2/src/components/autocomplete/autocomplete.tsx b/services/workbench2/src/components/autocomplete/autocomplete.tsx
new file mode 100644 (file)
index 0000000..17d85e8
--- /dev/null
@@ -0,0 +1,276 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    Input as MuiInput,
+    Chip as MuiChip,
+    Popper as MuiPopper,
+    Paper as MuiPaper,
+    FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText, Tooltip
+} from '@material-ui/core';
+import { PopperProps } from '@material-ui/core/Popper';
+import { WithStyles } from '@material-ui/core/styles';
+import { noop } from 'lodash';
+
+export interface AutocompleteProps<Item, Suggestion> {
+    label?: string;
+    value: string;
+    items: Item[];
+    disabled?: boolean;
+    suggestions?: Suggestion[];
+    error?: boolean;
+    helperText?: string;
+    autofocus?: boolean;
+    onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+    onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
+    onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
+    onCreate?: () => void;
+    onDelete?: (item: Item, index: number) => void;
+    onSelect?: (suggestion: Suggestion) => void;
+    renderChipValue?: (item: Item) => string;
+    renderChipTooltip?: (item: Item) => string;
+    renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
+}
+
+export interface AutocompleteState {
+    suggestionsOpen: boolean;
+    selectedSuggestionIndex: number;
+}
+
+export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
+
+    state = {
+        suggestionsOpen: false,
+        selectedSuggestionIndex: 0,
+    };
+
+    containerRef = React.createRef<HTMLDivElement>();
+    inputRef = React.createRef<HTMLInputElement>();
+
+    render() {
+        return (
+            <RootRef rootRef={this.containerRef}>
+                <FormControl fullWidth error={this.props.error}>
+                    {this.renderLabel()}
+                    {this.renderInput()}
+                    {this.renderHelperText()}
+                    {this.renderSuggestions()}
+                </FormControl>
+            </RootRef>
+        );
+    }
+
+    renderLabel() {
+        const { label } = this.props;
+        return label && <InputLabel>{label}</InputLabel>;
+    }
+
+    renderInput() {
+        return <Input
+            disabled={this.props.disabled}
+            autoFocus={this.props.autofocus}
+            inputRef={this.inputRef}
+            value={this.props.value}
+            startAdornment={this.renderChips()}
+            onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
+            onChange={this.props.onChange}
+            onKeyPress={this.handleKeyPress}
+            onKeyDown={this.handleNavigationKeyPress}
+        />;
+    }
+
+    renderHelperText() {
+        return <FormHelperText>{this.props.helperText}</FormHelperText>;
+    }
+
+    renderSuggestions() {
+        const { suggestions = [] } = this.props;
+        return (
+            <Popper
+                open={this.isSuggestionBoxOpen()}
+                anchorEl={this.inputRef.current}
+                key={suggestions.length}>
+                <Paper onMouseDown={this.preventBlur}>
+                    <List dense style={{ width: this.getSuggestionsWidth() }}>
+                        {suggestions.map(
+                            (suggestion, index) =>
+                                <ListItem
+                                    button
+                                    key={index}
+                                    onClick={this.handleSelect(suggestion)}
+                                    selected={index === this.state.selectedSuggestionIndex}>
+                                    {this.renderSuggestion(suggestion)}
+                                </ListItem>
+                        )}
+                    </List>
+                </Paper>
+            </Popper>
+        );
+    }
+
+    isSuggestionBoxOpen() {
+        const { suggestions = [] } = this.props;
+        return this.state.suggestionsOpen && suggestions.length > 0;
+    }
+
+    handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
+        const { onFocus = noop } = this.props;
+        this.setState({ suggestionsOpen: true });
+        onFocus(event);
+    }
+
+    handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
+        setTimeout(() => {
+            const { onBlur = noop } = this.props;
+            this.setState({ suggestionsOpen: false });
+            onBlur(event);
+        });
+    }
+
+    handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
+        const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
+        const { selectedSuggestionIndex } = this.state;
+        if (event.key === 'Enter') {
+            if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
+                // prevent form submissions when selecting a suggestion
+                event.preventDefault();
+                onSelect(suggestions[selectedSuggestionIndex]);
+            } else if (this.props.value.length > 0) {
+                onCreate();
+            }
+        }
+    }
+
+    handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
+        if (key === 'ArrowUp') {
+            this.updateSelectedSuggestionIndex(-1);
+        } else if (key === 'ArrowDown') {
+            this.updateSelectedSuggestionIndex(1);
+        }
+    }
+
+    updateSelectedSuggestionIndex(value: -1 | 1) {
+        const { suggestions = [] } = this.props;
+        this.setState(({ selectedSuggestionIndex }) => ({
+            selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
+        }));
+    }
+
+    renderChips() {
+        const { items, onDelete } = this.props;
+
+        /**
+         * If input startAdornment prop is not undefined, input's label will stay above the input.
+         * If there is not items, we want the label to go back to placeholder position.
+         * That why we return without a value instead of returning a result of a _map_ which is an empty array.
+         */
+        if (items.length === 0) {
+            return;
+        }
+
+        return items.map(
+            (item, index) => {
+                const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
+                if (tooltip && tooltip.length) {
+                    return <span key={index}>
+                        <Tooltip title={tooltip}>
+                        <Chip
+                            label={this.renderChipValue(item)}
+                            key={index}
+                            onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} />
+                    </Tooltip></span>
+                } else {
+                    return <span key={index}><Chip
+                        label={this.renderChipValue(item)}
+                        onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} /></span>
+                }
+            }
+        );
+    }
+
+    renderChipValue(value: Value) {
+        const { renderChipValue } = this.props;
+        return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
+    }
+
+    preventBlur = (event: React.MouseEvent<HTMLElement>) => {
+        event.preventDefault();
+    }
+
+    handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
+        if (event.target !== this.inputRef.current) {
+            this.setState({ suggestionsOpen: false });
+        }
+    }
+
+    handleSelect(suggestion: Suggestion) {
+        return () => {
+            const { onSelect = noop } = this.props;
+            const { current } = this.inputRef;
+            if (current) {
+                current.focus();
+            }
+            onSelect(suggestion);
+        };
+    }
+
+    renderSuggestion(suggestion: Suggestion) {
+        const { renderSuggestion } = this.props;
+        return renderSuggestion
+            ? renderSuggestion(suggestion)
+            : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
+    }
+
+    getSuggestionsWidth() {
+        return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
+    }
+}
+
+type ChipClasses = 'root';
+
+const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
+    root: {
+        marginRight: theme.spacing.unit / 4,
+        height: theme.spacing.unit * 3,
+    }
+});
+
+const Chip = withStyles(chipStyles)(MuiChip);
+
+type PopperClasses = 'root';
+
+const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
+    root: {
+        zIndex: theme.zIndex.modal,
+    }
+});
+
+const Popper = withStyles(popperStyles)(
+    ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
+        <MuiPopper {...props} className={classes.root} />
+);
+
+type InputClasses = 'root';
+
+const inputStyles: StyleRulesCallback<InputClasses> = () => ({
+    root: {
+        display: 'flex',
+        flexWrap: 'wrap',
+    },
+    input: {
+        minWidth: '20%',
+        flex: 1,
+    },
+});
+
+const Input = withStyles(inputStyles)(MuiInput);
+
+const Paper = withStyles({
+    root: {
+        maxHeight: '80vh',
+        overflowY: 'auto',
+    }
+})(MuiPaper);
diff --git a/services/workbench2/src/components/breadcrumbs/breadcrumbs.test.tsx b/services/workbench2/src/components/breadcrumbs/breadcrumbs.test.tsx
new file mode 100644 (file)
index 0000000..dfc5286
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { configure, mount } from "enzyme";
+
+import Adapter from "enzyme-adapter-react-16";
+import { Breadcrumbs } from "./breadcrumbs";
+import { Button, MuiThemeProvider } from "@material-ui/core";
+import ChevronRightIcon from '@material-ui/icons/ChevronRight';
+import { CustomTheme } from 'common/custom-theme';
+import { Provider } from "react-redux";
+import { combineReducers, createStore } from "redux";
+
+configure({ adapter: new Adapter() });
+
+describe("<Breadcrumbs />", () => {
+
+    let onClick: () => void;
+    let resources = {};
+    let store;
+    beforeEach(() => {
+        onClick = jest.fn();
+        const initialAuthState = {
+            config: {
+                clusterConfig: {
+                    Collections: {
+                        ForwardSlashNameSubstitution: "/"
+                    }
+                }
+            }
+        }
+        store = createStore(combineReducers({
+            auth: (state: any = initialAuthState, action: any) => state,
+        }));
+    });
+
+    it("renders one item", () => {
+        const items = [
+            { label: 'breadcrumb 1', uuid: '1' }
+        ];
+        const breadcrumbs = mount(
+            <Provider store={store}>
+                <MuiThemeProvider theme={CustomTheme}>
+                    <Breadcrumbs items={items} resources={resources} onClick={onClick} onContextMenu={jest.fn()} />
+                </MuiThemeProvider>
+            </Provider>);
+        expect(breadcrumbs.find(Button)).toHaveLength(1);
+        expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(0);
+    });
+
+    it("renders multiple items", () => {
+        const items = [
+            { label: 'breadcrumb 1', uuid: '1' },
+            { label: 'breadcrumb 2', uuid: '2' }
+        ];
+        const breadcrumbs = mount(
+            <Provider store={store}>
+                <MuiThemeProvider theme={CustomTheme}>
+                    <Breadcrumbs items={items} resources={resources} onClick={onClick} onContextMenu={jest.fn()} />
+                </MuiThemeProvider>
+            </Provider>);
+        expect(breadcrumbs.find(Button)).toHaveLength(2);
+        expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(1);
+    });
+
+    it("calls onClick with clicked item", () => {
+        const items = [
+            { label: 'breadcrumb 1', uuid: '1' },
+            { label: 'breadcrumb 2', uuid: '2' }
+        ];
+        const breadcrumbs = mount(
+            <Provider store={store}>
+                <MuiThemeProvider theme={CustomTheme}>
+                    <Breadcrumbs items={items} resources={resources} onClick={onClick} onContextMenu={jest.fn()} />
+                </MuiThemeProvider>
+            </Provider>);
+        breadcrumbs.find(Button).at(1).simulate('click');
+        expect(onClick).toHaveBeenCalledWith(expect.any(Function), items[1]);
+    });
+
+});
diff --git a/services/workbench2/src/components/breadcrumbs/breadcrumbs.tsx b/services/workbench2/src/components/breadcrumbs/breadcrumbs.tsx
new file mode 100644 (file)
index 0000000..0eed36f
--- /dev/null
@@ -0,0 +1,117 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } from '@material-ui/core';
+import ChevronRightIcon from '@material-ui/icons/ChevronRight';
+import { withStyles } from '@material-ui/core';
+import { IllegalNamingWarning } from '../warning/warning';
+import { IconType, FreezeIcon } from 'components/icon/icon';
+import grey from '@material-ui/core/colors/grey';
+import { getResource, ResourcesState } from 'store/resources/resources';
+import classNames from 'classnames';
+import { ArvadosTheme } from 'common/custom-theme';
+import { GroupClass } from "models/group";
+import { navigateTo, navigateToGroupDetails } from 'store/navigation/navigation-action';
+export interface Breadcrumb {
+    label: string;
+    icon?: IconType;
+    uuid: string;
+}
+
+type CssRules = "item" | "chevron" | "label" | "buttonLabel" | "icon" | "frozenIcon";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    item: {
+        borderRadius: '16px',
+        height: '32px',
+        minWidth: '36px',
+        color: theme.customs.colors.grey700,
+        '&.parentItem': {
+            color: `${theme.palette.primary.main}`,
+        },
+    },
+    chevron: {
+        color: grey["600"],
+    },
+    label: {
+        textTransform: "none",
+        paddingRight: '3px',
+        paddingLeft: '3px',
+        lineHeight: '1.4',
+    },
+    buttonLabel: {
+        overflow: 'hidden',
+        justifyContent: 'flex-start',
+    },
+    icon: {
+        fontSize: 20,
+        color: grey["600"],
+        marginRight: '5px',
+    },
+    frozenIcon: {
+        fontSize: 20,
+        color: grey["600"],
+        marginLeft: '3px',
+    },
+});
+
+export interface BreadcrumbsProps {
+    items: Breadcrumb[];
+    resources: ResourcesState;
+    onClick: (navFunc: (uuid: string) => void, breadcrumb: Breadcrumb) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: Breadcrumb) => void;
+}
+
+export const Breadcrumbs = withStyles(styles)(
+    ({ classes, onClick, onContextMenu, items, resources }: BreadcrumbsProps & WithStyles<CssRules>) =>
+    <Grid container data-cy='breadcrumbs' alignItems="center" wrap="nowrap">
+    {
+        items.map((item, index) => {
+            const isLastItem = index === items.length - 1;
+            const isFirstItem = index === 0;
+            const Icon = item.icon || (() => (null));
+            const resource = getResource(item.uuid)(resources) as any;
+            const navFunc = resource && 'groupClass' in resource && resource.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo;
+
+            return (
+                <React.Fragment key={index}>
+                    {isFirstItem ? null : <IllegalNamingWarning name={item.label} />}
+                    <Tooltip title={item.label} disableFocusListener>
+                        <Button
+                            data-cy={
+                                isFirstItem
+                                ? 'breadcrumb-first'
+                                : isLastItem
+                                    ? 'breadcrumb-last'
+                                    : false}
+                            className={classNames(
+                                isLastItem ? null : 'parentItem',
+                                classes.item
+                            )}
+                            classes={{
+                                label: classes.buttonLabel
+                            }}
+                            color="inherit"
+                            onClick={() => onClick(navFunc, item)}
+                            onContextMenu={event => onContextMenu(event, item)}>
+                            <Icon className={classes.icon} />
+                            <Typography
+                                noWrap
+                                color="inherit"
+                                className={classes.label}>
+                                {item.label}
+                            </Typography>
+                            {
+                                (resources[item.uuid] as any)?.frozenByUuid ? <FreezeIcon className={classes.frozenIcon} /> : null
+                            }
+                        </Button>
+                    </Tooltip>
+                    {!isLastItem && <ChevronRightIcon color="inherit" className={classNames('parentItem', classes.chevron)} />}
+                </React.Fragment>
+            );
+        })
+    }
+    </Grid>
+);
diff --git a/services/workbench2/src/components/checkbox-field/checkbox-field.tsx b/services/workbench2/src/components/checkbox-field/checkbox-field.tsx
new file mode 100644 (file)
index 0000000..accd1e6
--- /dev/null
@@ -0,0 +1,77 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WrappedFieldProps } from 'redux-form';
+import {
+    FormControlLabel,
+    Checkbox,
+    FormControl,
+    FormGroup,
+    FormLabel,
+    FormHelperText
+} from '@material-ui/core';
+
+export const CheckboxField = (props: WrappedFieldProps & { label?: string }) =>
+    <FormControlLabel
+        control={
+            <Checkbox
+                checked={props.input.value}
+                onChange={props.input.onChange}
+                disabled={props.meta.submitting}
+                color="primary" />
+        }
+        label={props.label}
+    />;
+
+type MultiCheckboxFieldProps = {
+    items: string[];
+    defaultValues?: string[];
+    label?: string;
+    minSelection?: number;
+    maxSelection?: number;
+    helperText?: string;
+    rowLayout?: boolean;
+}
+
+export const MultiCheckboxField = (props: WrappedFieldProps & MultiCheckboxFieldProps) => {
+    const isValid = (items: string[]) => (items.length >= (props.minSelection || 0)) &&
+        (items.length <= (props.maxSelection || items.length));
+    if (props.input.value.length === 0 && (props.defaultValues || []).length !== 0) {
+        props.input.value = props.defaultValues ? [...props.defaultValues] : [];
+    }
+    return <FormControl error={!isValid(props.input.value)}>
+        <FormLabel component='label'>{props.label}</FormLabel>
+        <FormGroup row={props.rowLayout}>
+        { props.items.map((item, idx) =>
+            <FormControlLabel
+                key={`label-${idx}`}
+                control={
+                    <Checkbox
+                        data-cy={`checkbox-${item}`}
+                        key={`control-${idx}`}
+                        name={`${props.input.name}[${idx}]`}
+                        value={item}
+                        checked={
+                            props.input.value.indexOf(item) !== -1 ||
+                            (props.input.value.length === 0 &&
+                                (props.defaultValues || []).indexOf(item) !== -1)
+                        }
+                        onChange={e => {
+                            const newValue = [...props.input.value];
+                            if (e.target.checked) {
+                                newValue.push(item);
+                            } else {
+                                newValue.splice(newValue.indexOf(item), 1);
+                            }
+                            if (!isValid(newValue)) { return; }
+                            return props.input.onChange(newValue);
+                        }}
+                        disabled={props.meta.submitting}
+                        color="primary" />
+                }
+                label={item} />) }
+        </FormGroup>
+        <FormHelperText>{props.helperText}</FormHelperText>
+    </FormControl> };
\ No newline at end of file
diff --git a/services/workbench2/src/components/chips-input/chips-input.tsx b/services/workbench2/src/components/chips-input/chips-input.tsx
new file mode 100644 (file)
index 0000000..7b9ff4a
--- /dev/null
@@ -0,0 +1,175 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Chips } from 'components/chips/chips';
+import { Input as MuiInput, withStyles, WithStyles } from '@material-ui/core';
+import { StyleRulesCallback } from '@material-ui/core/styles';
+import { InputProps } from '@material-ui/core/Input';
+
+interface ChipsInputProps<Value> {
+    values: Value[];
+    getLabel?: (value: Value) => string;
+    onChange: (value: Value[]) => void;
+    onPartialInput?: (value: boolean) => void;
+    handleFocus?: (e: any) => void;
+    handleBlur?: (e: any) => void;
+    chipsClassName?: string;
+    createNewValue: (value: string) => Value;
+    inputComponent?: React.ComponentType<InputProps>;
+    inputProps?: InputProps;
+    deletable?: boolean;
+    orderable?: boolean;
+    disabled?: boolean;
+    pattern?: RegExp;
+}
+
+type CssRules = 'chips' | 'input' | 'inputContainer';
+
+const styles: StyleRulesCallback = ({ spacing }) => ({
+    chips: {
+        minHeight: spacing.unit * 5,
+        zIndex: 1,
+        position: 'relative',
+    },
+    input: {
+        zIndex: 1,
+        marginBottom: 8,
+        position: 'relative',
+    },
+    inputContainer: {
+        marginTop: -34
+    },
+});
+
+export const ChipsInput = withStyles(styles)(
+    class ChipsInput<Value> extends React.Component<ChipsInputProps<Value> & WithStyles<CssRules>> {
+
+        state = {
+            text: '',
+        };
+
+        filler = React.createRef<HTMLDivElement>();
+        timeout = -1;
+
+        setText = (event: React.ChangeEvent<HTMLInputElement>) => {
+            this.setState({ text: event.target.value }, () => {
+                // Update partial input status
+                this.props.onPartialInput && this.props.onPartialInput(this.state.text !== '');
+
+                // If pattern is provided, check for delimiter
+                if (this.props.pattern) {
+                    const matches = this.state.text.match(this.props.pattern);
+                    // Only create values if 1 match and the last character is a delimiter
+                    //   (user pressed an invalid character at the end of a token)
+                    //   or if multiple matches (user pasted text)
+                    if (matches &&
+                            (
+                                matches.length > 1 ||
+                                (matches.length === 1 && !this.state.text.endsWith(matches[0]))
+                            )) {
+                        this.createNewValue(matches.map((i) => i));
+                    }
+                }
+            });
+        }
+
+        handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
+            // Handle special keypresses
+            if (e.key === 'Enter') {
+                this.createNewValue();
+                e.preventDefault();
+            } else if (e.key === 'Backspace') {
+                this.deleteLastValue();
+            }
+        }
+
+        createNewValue = (matches?: string[]) => {
+            if (this.state.text) {
+                if (matches && matches.length > 0) {
+                    const newValues = matches.map((v) => this.props.createNewValue(v));
+                    this.setState({ text: '' });
+                    this.props.onChange([...this.props.values, ...newValues]);
+                } else {
+                    const newValue = this.props.createNewValue(this.state.text);
+                    this.setState({ text: '' });
+                    this.props.onChange([...this.props.values, newValue]);
+                }
+                this.props.onPartialInput && this.props.onPartialInput(false);
+            }
+        }
+
+        deleteLastValue = () => {
+            if (this.state.text.length === 0 && this.props.values.length > 0) {
+                this.props.onChange(this.props.values.slice(0, -1));
+            }
+        }
+
+        updateCursorPosition = () => {
+            if (this.timeout) {
+                clearTimeout(this.timeout);
+            }
+            this.timeout = window.setTimeout(() => this.setState({ ...this.state }));
+        }
+
+        getInputStyles = (): React.CSSProperties => ({
+            width: this.filler.current
+                ? this.filler.current.offsetWidth
+                : '100%',
+            right: this.filler.current
+                ? `calc(${this.filler.current.offsetWidth}px - 100%)`
+                : 0,
+
+        })
+
+        componentDidMount() {
+            this.updateCursorPosition();
+        }
+
+        render() {
+            return <>
+                {this.renderChips()}
+                {this.renderInput()}
+            </>;
+        }
+
+        renderChips() {
+            const { classes, ...props } = this.props;
+            return <div className={[classes.chips, this.props.chipsClassName].join(' ')}>
+                <Chips
+                    {...props}
+                    clickable={!props.disabled}
+                    filler={<div ref={this.filler} />}
+                />
+            </div>;
+        }
+
+        renderInput() {
+            const { inputProps: InputProps, inputComponent: Input = MuiInput, classes } = this.props;
+            return <Input
+                {...InputProps}
+                value={this.state.text}
+                onChange={this.setText}
+                disabled={this.props.disabled}
+                onKeyDown={this.handleKeyPress}
+                onFocus={this.props.handleFocus}
+                onBlur={this.props.handleBlur}
+                inputProps={{
+                    ...(InputProps && InputProps.inputProps),
+                    className: classes.input,
+                    style: this.getInputStyles(),
+                }}
+                fullWidth
+                className={classes.inputContainer} />;
+        }
+
+        componentDidUpdate(prevProps: ChipsInputProps<Value>) {
+            if (prevProps.values !== this.props.values) {
+                this.updateCursorPosition();
+            }
+        }
+        componentWillUnmount() {
+            clearTimeout(this.timeout);
+        }
+    });
diff --git a/services/workbench2/src/components/chips/chips.tsx b/services/workbench2/src/components/chips/chips.tsx
new file mode 100644 (file)
index 0000000..c4724d1
--- /dev/null
@@ -0,0 +1,138 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Chip, Grid, StyleRulesCallback, withStyles } from '@material-ui/core';
+import {
+    DragSource,
+    DragSourceSpec,
+    DragSourceCollector,
+    ConnectDragSource,
+    DropTarget,
+    DropTargetSpec,
+    DropTargetCollector,
+    ConnectDropTarget
+} from 'react-dnd';
+import { compose } from 'lodash/fp';
+import { WithStyles } from '@material-ui/core/styles';
+interface ChipsProps<Value> {
+    values: Value[];
+    getLabel?: (value: Value) => string;
+    filler?: React.ReactNode;
+    deletable?: boolean;
+    orderable?: boolean;
+    onChange: (value: Value[]) => void;
+    clickable?: boolean;
+}
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = ({ spacing }) => ({
+    root: {
+        margin: `0px -${spacing.unit / 2}px`,
+    },
+});
+export const Chips = withStyles(styles)(
+    class Chips<Value> extends React.Component<ChipsProps<Value> & WithStyles<CssRules>> {
+        render() {
+            const { values, filler } = this.props;
+            return <Grid container spacing={8} className={this.props.classes.root}>
+                {values && values.map(this.renderChip)}
+                {filler && <Grid item xs>{filler}</Grid>}
+            </Grid>;
+        }
+
+        renderChip = (value: Value, index: number) => {
+            const { deletable, getLabel } = this.props;
+            return <Grid item key={index}>
+                <Chip onDelete={deletable ? this.deleteValue(value) : undefined}
+                    label={getLabel !== undefined ? getLabel(value) : value} />
+            </Grid>
+        }
+
+        type = 'chip';
+
+        dragSpec: DragSourceSpec<DraggableChipProps<Value>, { value: Value }> = {
+            beginDrag: ({ value }) => ({ value }),
+            endDrag: ({ value: dragValue }, monitor) => {
+                const result = monitor.getDropResult();
+                if (result) {
+                    const { value: dropValue } = monitor.getDropResult();
+                    const dragIndex = this.props.values.indexOf(dragValue);
+                    const dropIndex = this.props.values.indexOf(dropValue);
+                    const newValues = this.props.values.slice(0);
+                    if (dragIndex < dropIndex) {
+                        newValues.splice(dragIndex, 1);
+                        newValues.splice(dropIndex - 1 || 0, 0, dragValue);
+                    } else if (dragIndex > dropIndex) {
+                        newValues.splice(dragIndex, 1);
+                        newValues.splice(dropIndex, 0, dragValue);
+                    }
+                    this.props.onChange(newValues);
+                }
+            }
+        };
+
+        dragCollector: DragSourceCollector<{}> = connect => ({
+            connectDragSource: connect.dragSource(),
+        })
+
+        dropSpec: DropTargetSpec<DraggableChipProps<Value>> = {
+            drop: ({ value }) => ({ value }),
+        };
+
+        dropCollector: DropTargetCollector<{}> = (connect, monitor) => ({
+            connectDropTarget: connect.dropTarget(),
+            isOver: monitor.isOver(),
+        })
+        chip = compose(
+            DragSource(this.type, this.dragSpec, this.dragCollector),
+            DropTarget(this.type, this.dropSpec, this.dropCollector),
+        )(
+            ({ connectDragSource, connectDropTarget, isOver, value }: DraggableChipProps<Value> & CollectedProps) => {
+                const connect = compose(
+                    connectDragSource,
+                    connectDropTarget,
+                );
+
+                const chip =
+                    <span>
+                        <Chip
+                            color={isOver ? 'primary' : 'default'}
+                            onDelete={this.props.deletable
+                                ? this.deleteValue(value)
+                                : undefined}
+                            clickable={this.props.clickable}
+                            label={this.props.getLabel ?
+                                this.props.getLabel(value)
+                                : typeof value === 'object'
+                                    ? JSON.stringify(value)
+                                    : value} />
+                    </span>;
+
+                return this.props.orderable
+                    ? connect(chip)
+                    : chip;
+            }
+        );
+
+        deleteValue = (value: Value) => () => {
+            const { values } = this.props;
+            const index = values.indexOf(value);
+            const newValues = values.slice(0);
+            newValues.splice(index, 1);
+            this.props.onChange(newValues);
+        }
+    });
+
+interface CollectedProps {
+    connectDragSource: ConnectDragSource;
+    connectDropTarget: ConnectDropTarget;
+
+    isOver: boolean;
+}
+
+interface DraggableChipProps<Value> {
+    value: Value;
+}
diff --git a/services/workbench2/src/components/code-snippet/code-snippet.tsx b/services/workbench2/src/components/code-snippet/code-snippet.tsx
new file mode 100644 (file)
index 0000000..3be1e4f
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, Typography, withStyles, Link } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import classNames from 'classnames';
+import { connect, DispatchProp } from 'react-redux';
+import { RootState } from 'store/store';
+import { FederationConfig, getNavUrl } from 'routes/routes';
+import { Dispatch } from 'redux';
+import { navigationNotAvailable } from 'store/navigation/navigation-action';
+
+type CssRules = 'root' | 'inlineRoot' | 'space' | 'inline';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        boxSizing: 'border-box',
+        overflow: 'auto',
+        padding: theme.spacing.unit,
+    },
+    inlineRoot: {
+        padding: "3px",
+        display: "inline",
+    },
+    space: {
+        marginLeft: '15px',
+    },
+    inline: {
+        display: 'inline',
+    },
+});
+
+export interface CodeSnippetDataProps {
+    lines: string[];
+    className?: string;
+    apiResponse?: boolean;
+    linked?: boolean;
+    children?: JSX.Element;
+    inline?: boolean;
+}
+
+interface CodeSnippetAuthProps {
+    auth: FederationConfig;
+}
+
+type CodeSnippetProps = CodeSnippetDataProps & WithStyles<CssRules>;
+
+const mapStateToProps = (state: RootState): CodeSnippetAuthProps => ({
+    auth: state.auth,
+});
+
+export const CodeSnippet = withStyles(styles)(connect(mapStateToProps)(
+    ({ classes, lines, linked, className, apiResponse, dispatch, auth, children, inline }: CodeSnippetProps & CodeSnippetAuthProps & DispatchProp) =>
+        <Typography
+            component="div"
+            className={classNames([classes.root, className, inline ? classes.inlineRoot : undefined])}>
+            <Typography className={apiResponse ? classes.space : classNames([className, inline ? classes.inline : undefined])} component="pre">
+                {children}
+                {linked ?
+                    lines.map((line, index) => <React.Fragment key={index}>{renderLinks(auth, dispatch)(line)}{`\n`}</React.Fragment>) :
+                    lines.join('\n')
+                }
+            </Typography>
+        </Typography>
+));
+
+export const renderLinks = (auth: FederationConfig, dispatch: Dispatch) => (text: string): JSX.Element => {
+    // Matches UUIDs & PDHs
+    const REGEX = /[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}|[0-9a-f]{32}\+\d+/g;
+    const links = text.match(REGEX);
+    if (!links) {
+        return <>{text}</>;
+    }
+    return <>
+        {text.split(REGEX).map((part, index) =>
+            <React.Fragment key={index}>
+                {part}
+                {links[index] &&
+                    <Link onClick={() => {
+                        const url = getNavUrl(links[index], auth)
+                        if (url) {
+                            window.open(`${window.location.origin}${url}`, '_blank', "noopener");
+                        } else {
+                            dispatch(navigationNotAvailable(links[index]));
+                        }
+                    }}
+                        style={{ cursor: 'pointer' }}>
+                        {links[index]}
+                    </Link>}
+            </React.Fragment>
+        )}
+    </>;
+};
diff --git a/services/workbench2/src/components/code-snippet/virtual-code-snippet.tsx b/services/workbench2/src/components/code-snippet/virtual-code-snippet.tsx
new file mode 100644 (file)
index 0000000..09db2c0
--- /dev/null
@@ -0,0 +1,76 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, Typography, withStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import classNames from 'classnames';
+import { connect, DispatchProp } from 'react-redux';
+import { RootState } from 'store/store';
+import { FederationConfig } from 'routes/routes';
+import { renderLinks } from './code-snippet';
+import { FixedSizeList } from 'react-window';
+import AutoSizer from "react-virtualized-auto-sizer";
+
+type CssRules = 'root' | 'space' | 'content' ;
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        boxSizing: 'border-box',
+        height: '100%',
+        padding: theme.spacing.unit,
+    },
+    space: {
+        marginLeft: '15px',
+    },
+    content: {
+        maxHeight: '100%',
+        height: '100vh',
+    },
+});
+
+export interface CodeSnippetDataProps {
+    lines: string[];
+    lineFormatter?: (lines: string[], index: number) => string;
+    className?: string;
+    apiResponse?: boolean;
+    linked?: boolean;
+}
+
+interface CodeSnippetAuthProps {
+    auth: FederationConfig;
+}
+
+type CodeSnippetProps = CodeSnippetDataProps & WithStyles<CssRules>;
+
+const mapStateToProps = (state: RootState): CodeSnippetAuthProps => ({
+    auth: state.auth,
+});
+
+export const VirtualCodeSnippet = withStyles(styles)(connect(mapStateToProps)(
+    ({ classes, lines, lineFormatter, linked, className, apiResponse, dispatch, auth }: CodeSnippetProps & CodeSnippetAuthProps & DispatchProp) => {
+        const RenderRow = ({index, style}) => {
+            const lineContents = lineFormatter ? lineFormatter(lines, index) : lines[index];
+            return <span style={style}>{linked ? renderLinks(auth, dispatch)(lineContents) : lineContents}</span>
+        };
+
+        return <Typography
+            component="div"
+            className={classNames([classes.root, className])}>
+            <Typography className={classNames(classes.content, apiResponse ? classes.space : className)} component="pre">
+                <AutoSizer>
+                    {({ height, width }) =>
+                        <FixedSizeList
+                            height={height}
+                            width={width}
+                            itemSize={21}
+                            itemCount={lines.length}
+                        >
+                            {RenderRow}
+                        </FixedSizeList>
+                    }
+                </AutoSizer>
+            </Typography>
+        </Typography>;
+}));
diff --git a/services/workbench2/src/components/collection-panel-files/collection-panel-files.tsx b/services/workbench2/src/components/collection-panel-files/collection-panel-files.tsx
new file mode 100644 (file)
index 0000000..e58eb89
--- /dev/null
@@ -0,0 +1,708 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import classNames from "classnames";
+import { connect } from "react-redux";
+import { FixedSizeList } from "react-window";
+import AutoSizer from "react-virtualized-auto-sizer";
+import servicesProvider from "common/service-provider";
+import { DownloadIcon, MoreHorizontalIcon, MoreVerticalIcon } from "components/icon/icon";
+import { SearchInput } from "components/search-input/search-input";
+import {
+    ListItemIcon,
+    StyleRulesCallback,
+    Theme,
+    WithStyles,
+    withStyles,
+    Tooltip,
+    IconButton,
+    Checkbox,
+    CircularProgress,
+    Button,
+} from "@material-ui/core";
+import { FileTreeData } from "../file-tree/file-tree-data";
+import { TreeItem, TreeItemStatus } from "../tree/tree";
+import { RootState } from "store/store";
+import { WebDAV, WebDAVRequestConfig } from "common/webdav";
+import { AuthState } from "store/auth/auth-reducer";
+import { extractFilesData } from "services/collection-service/collection-service-files-response";
+import { DefaultIcon, DirectoryIcon, FileIcon, BackIcon, SidePanelRightArrowIcon } from "components/icon/icon";
+import { setCollectionFiles } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { sortBy } from "lodash";
+import { formatFileSize } from "common/formatters";
+import { getInlineFileUrl, sanitizeToken } from "views-components/context-menu/actions/helpers";
+import { extractUuidKind, ResourceKind } from "models/resource";
+
+export interface CollectionPanelFilesProps {
+    isWritable: boolean;
+    onUploadDataClick: (targetLocation?: string) => void;
+    onSearchChange: (searchValue: string) => void;
+    onItemMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>, isWritable: boolean) => void;
+    onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, isWritable: boolean) => void;
+    onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
+    onCollapseToggle: (id: string, status: TreeItemStatus) => void;
+    onFileClick: (id: string) => void;
+    currentItemUuid: any;
+    dispatch: Function;
+    collectionPanelFiles: any;
+    collectionPanel: any;
+}
+
+type CssRules =
+    | "backButton"
+    | "backButtonHidden"
+    | "pathPanelPathWrapper"
+    | "uploadButton"
+    | "uploadIcon"
+    | "moreOptionsButton"
+    | "moreOptions"
+    | "loader"
+    | "wrapper"
+    | "dataWrapper"
+    | "row"
+    | "rowEmpty"
+    | "leftPanel"
+    | "rightPanel"
+    | "pathPanel"
+    | "pathPanelItem"
+    | "rowName"
+    | "listItemIcon"
+    | "rowActive"
+    | "pathPanelMenu"
+    | "rowSelection"
+    | "leftPanelHidden"
+    | "leftPanelVisible"
+    | "searchWrapper"
+    | "searchWrapperHidden";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    wrapper: {
+        display: "flex",
+        minHeight: "600px",
+        color: "rgba(0,0,0,0.87)",
+        fontSize: "0.875rem",
+        fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
+        fontWeight: 400,
+        lineHeight: "1.5",
+        letterSpacing: "0.01071em",
+    },
+    backButton: {
+        color: "#00bfa5",
+        cursor: "pointer",
+        float: "left",
+    },
+    backButtonHidden: {
+        display: "none",
+    },
+    dataWrapper: {
+        minHeight: "500px",
+    },
+    row: {
+        display: "flex",
+        marginTop: "0.5rem",
+        marginBottom: "0.5rem",
+        cursor: "pointer",
+        "&:hover": {
+            backgroundColor: "rgba(0, 0, 0, 0.08)",
+        },
+    },
+    rowEmpty: {
+        top: "40%",
+        width: "100%",
+        textAlign: "center",
+        position: "absolute",
+    },
+    loader: {
+        top: "50%",
+        left: "50%",
+        marginTop: "-15px",
+        marginLeft: "-15px",
+        position: "absolute",
+    },
+    rowName: {
+        display: "inline-flex",
+        flexDirection: "column",
+        justifyContent: "center",
+    },
+    searchWrapper: {
+        display: "inline-block",
+        marginBottom: "1rem",
+        marginLeft: "1rem",
+    },
+    searchWrapperHidden: {
+        width: "0px",
+    },
+    rowSelection: {
+        padding: "0px",
+    },
+    rowActive: {
+        color: `${theme.palette.primary.main} !important`,
+    },
+    listItemIcon: {
+        display: "inline-flex",
+        flexDirection: "column",
+        justifyContent: "center",
+    },
+    pathPanelMenu: {
+        float: "right",
+        marginTop: "-15px",
+    },
+    pathPanel: {
+        padding: "0.5rem",
+        marginBottom: "0.5rem",
+        backgroundColor: "#fff",
+        boxShadow: "0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)",
+    },
+    pathPanelPathWrapper: {
+        display: "inline-block",
+    },
+    leftPanel: {
+        flex: 0,
+        padding: "0 1rem 1rem",
+        marginRight: "1rem",
+        whiteSpace: "nowrap",
+        position: "relative",
+        backgroundColor: "#fff",
+        boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)",
+    },
+    leftPanelVisible: {
+        opacity: 1,
+        flex: "50%",
+        animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`,
+    },
+    leftPanelHidden: {
+        opacity: 0,
+        flex: "initial",
+        padding: "0",
+        marginRight: "0",
+    },
+    "@keyframes animateVisible": {
+        "0%": {
+            opacity: 0,
+            flex: "initial",
+        },
+        "100%": {
+            opacity: 1,
+            flex: "50%",
+        },
+    },
+    rightPanel: {
+        flex: "50%",
+        padding: "1rem",
+        paddingTop: "0.5rem",
+        marginTop: "-0.5rem",
+        position: "relative",
+        backgroundColor: "#fff",
+        boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)",
+    },
+    pathPanelItem: {
+        cursor: "pointer",
+    },
+    uploadIcon: {
+        transform: "rotate(180deg)",
+    },
+    uploadButton: {
+        float: "right",
+    },
+    moreOptionsButton: {
+        width: theme.spacing.unit * 3,
+        height: theme.spacing.unit * 3,
+        marginRight: theme.spacing.unit,
+        marginTop: "auto",
+        marginBottom: "auto",
+        justifyContent: "center",
+    },
+    moreOptions: {
+        position: "absolute",
+    },
+});
+
+const pathPromise = {};
+
+export const CollectionPanelFiles = withStyles(styles)(
+    connect((state: RootState) => ({
+        auth: state.auth,
+        collectionPanel: state.collectionPanel,
+        collectionPanelFiles: state.collectionPanelFiles,
+    }))((props: CollectionPanelFilesProps & WithStyles<CssRules> & { auth: AuthState }) => {
+        const { classes, onItemMenuOpen, onUploadDataClick, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props;
+        const { apiToken, config } = props.auth;
+
+        const webdavClient = new WebDAV({
+            baseURL: config.keepWebServiceUrl,
+            headers: {
+                Authorization: `Bearer ${apiToken}`,
+            },
+        });
+
+        const webDAVRequestConfig: WebDAVRequestConfig = {
+            headers: {
+                Depth: "1",
+            },
+        };
+
+        const parentRef = React.useRef(null);
+        const [path, setPath] = React.useState<string[]>([]);
+        const [pathData, setPathData] = React.useState({});
+        const [isLoading, setIsLoading] = React.useState(false);
+        const [leftSearch, setLeftSearch] = React.useState("");
+        const [rightSearch, setRightSearch] = React.useState("");
+
+        const leftKey = (path.length > 1 ? path.slice(0, path.length - 1) : path).join("/");
+        const rightKey = path.join("/");
+
+        const leftData = pathData[leftKey] || [];
+        const rightData = pathData[rightKey];
+
+        React.useEffect(() => {
+            if (props.currentItemUuid && extractUuidKind(props.currentItemUuid) === ResourceKind.COLLECTION) {
+                setPathData({});
+                setPath([props.currentItemUuid]);
+            }
+        }, [props.currentItemUuid]);
+
+        const fetchData = (keys, ignoreCache = false) => {
+            const keyArray = Array.isArray(keys) ? keys : [keys];
+
+            Promise.all(
+                keyArray
+                    .filter(key => !!key)
+                    .map(key => {
+                        const dataExists = !!pathData[key];
+                        const runningRequest = pathPromise[key];
+
+                        if (ignoreCache || (!dataExists && !runningRequest)) {
+                            if (!isLoading) {
+                                setIsLoading(true);
+                            }
+
+                            pathPromise[key] = true;
+
+                            return webdavClient.propfind(`c=${key}`, webDAVRequestConfig);
+                        }
+
+                        return Promise.resolve(null);
+                    })
+                    .filter(promise => !!promise)
+            )
+                .then(requests => {
+                    const newState = requests
+                        .map((request, index) => {
+                            if (request && request.responseXML != null) {
+                                const key = keyArray[index];
+                                const result: any = extractFilesData(request.responseXML);
+                                const sortedResult = sortBy(result, n => n.name).sort((n1, n2) => {
+                                    if (n1.type === "directory" && n2.type !== "directory") {
+                                        return -1;
+                                    }
+                                    if (n1.type !== "directory" && n2.type === "directory") {
+                                        return 1;
+                                    }
+                                    return 0;
+                                });
+
+                                return { [key]: sortedResult };
+                            }
+                            return {};
+                        })
+                        .reduce((prev, next) => {
+                            return { ...next, ...prev };
+                        }, {});
+                    setPathData(state => ({ ...state, ...newState }));
+                }, () => {
+                    // Nothing to do
+                })
+                .finally(() => {
+                    setIsLoading(false);
+                    keyArray.forEach(key => delete pathPromise[key]);
+                });
+        };
+
+        React.useEffect(() => {
+            if (rightKey) {
+                fetchData(rightKey);
+                setLeftSearch("");
+                setRightSearch("");
+            }
+        }, [rightKey, rightData]); // eslint-disable-line react-hooks/exhaustive-deps
+
+        const currentPDH = (collectionPanel.item || {}).portableDataHash;
+        React.useEffect(() => {
+            if (currentPDH) {
+                fetchData([leftKey, rightKey], true);
+            }
+        }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps
+
+        React.useEffect(() => {
+            if (rightData) {
+                const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
+                setCollectionFiles(filtered, false)(dispatch);
+            }
+        }, [rightData, dispatch, rightSearch]);
+
+        const handleRightClick = React.useCallback(
+            event => {
+                event.preventDefault();
+                let elem = event.target;
+
+                while (elem && elem.dataset && !elem.dataset.item) {
+                    elem = elem.parentNode;
+                }
+
+                if (!elem || !elem.dataset) {
+                    return;
+                }
+
+                const { id } = elem.dataset;
+
+                const item: any = {
+                    id,
+                    data: rightData.find(elem => elem.id === id),
+                };
+
+                if (id) {
+                    onItemMenuOpen(event, item, isWritable);
+                }
+            },
+            [onItemMenuOpen, isWritable, rightData]
+        );
+
+        React.useEffect(() => {
+            let node = null;
+
+            if (parentRef?.current) {
+                node = parentRef.current;
+                (node as any).addEventListener("contextmenu", handleRightClick);
+            }
+
+            return () => {
+                if (node) {
+                    (node as any).removeEventListener("contextmenu", handleRightClick);
+                }
+            };
+        }, [parentRef, handleRightClick]);
+
+        const handleClick = React.useCallback(
+            (event: any) => {
+                let isCheckbox = false;
+                let isMoreButton = false;
+                let elem = event.target;
+
+                if (elem.type === "checkbox") {
+                    isCheckbox = true;
+                }
+                // The "More options" button click event could be triggered on its
+                // internal graphic element.
+                else if (
+                    (elem.dataset && elem.dataset.id === "moreOptions") ||
+                    (elem.parentNode && elem.parentNode.dataset && elem.parentNode.dataset.id === "moreOptions")
+                ) {
+                    isMoreButton = true;
+                }
+
+                while (elem && elem.dataset && !elem.dataset.item) {
+                    elem = elem.parentNode;
+                }
+
+                if (elem && elem.dataset && !isCheckbox && !isMoreButton) {
+                    const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset;
+
+                    if (breadcrumbPath) {
+                        const index = path.indexOf(breadcrumbPath);
+                        setPath(state => [...state.slice(0, index + 1)]);
+                    }
+
+                    if (parentPath && type === "directory") {
+                        if (path.length > 1) {
+                            path.pop();
+                        }
+
+                        setPath(state => [...state, parentPath]);
+                    }
+
+                    if (subfolderPath && type === "directory") {
+                        setPath(state => [...state, subfolderPath]);
+                    }
+
+                    if (elem.dataset.id && type === "file") {
+                        const item = rightData.find(({ id }) => id === elem.dataset.id) || leftData.find(({ id }) => id === elem.dataset.id);
+                        const enhancedItem = servicesProvider.getServices().collectionService.extendFileURL(item);
+                        const fileUrl = sanitizeToken(
+                            getInlineFileUrl(enhancedItem.url, config.keepWebServiceUrl, config.keepWebInlineServiceUrl),
+                            true
+                        );
+                        window.open(fileUrl, "_blank", "noopener");
+                    }
+                }
+
+                if (isCheckbox) {
+                    const { id } = elem.dataset;
+                    const item = collectionPanelFiles[id];
+                    props.onSelectionToggle(event, item);
+                }
+                if (isMoreButton) {
+                    const { id } = elem.dataset;
+                    const item: any = {
+                        id,
+                        data: rightData.find(elem => elem.id === id),
+                    };
+                    onItemMenuOpen(event, item, isWritable);
+                }
+            },
+            [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps
+        );
+
+        const getItemIcon = React.useCallback(
+            (type: string, activeClass: string | null) => {
+                let Icon = DefaultIcon;
+
+                switch (type) {
+                    case "directory":
+                        Icon = DirectoryIcon;
+                        break;
+                    case "file":
+                        Icon = FileIcon;
+                        break;
+                }
+
+                return (
+                    <ListItemIcon className={classNames(classes.listItemIcon, activeClass)}>
+                        <Icon />
+                    </ListItemIcon>
+                );
+            },
+            [classes]
+        );
+
+        const getActiveClass = React.useCallback(
+            name => {
+                return path[path.length - 1] === name ? classes.rowActive : null;
+            },
+            [path, classes]
+        );
+
+        const onOptionsMenuOpen = React.useCallback(
+            (ev, isWritable) => {
+                props.onOptionsMenuOpen(ev, isWritable);
+            },
+            [props.onOptionsMenuOpen] // eslint-disable-line react-hooks/exhaustive-deps
+        );
+
+        return (
+            <div
+                data-cy="collection-files-panel"
+                onClick={handleClick}
+                ref={parentRef}
+            >
+                <div className={classes.pathPanel}>
+                    <div className={classes.pathPanelPathWrapper}>
+                        {path.map((p: string, index: number) => (
+                            <span
+                                key={`${index}-${p}`}
+                                data-item="true"
+                                className={classes.pathPanelItem}
+                                data-breadcrumb-path={p}
+                            >
+                                <span className={classes.rowActive}>{index === 0 ? "Home" : p}</span> <b>/</b>&nbsp;
+                            </span>
+                        ))}
+                    </div>
+                    <Tooltip
+                        className={classes.pathPanelMenu}
+                        title="More options"
+                        disableFocusListener
+                    >
+                        <IconButton
+                            data-cy="collection-files-panel-options-btn"
+                            onClick={ev => {
+                                onOptionsMenuOpen(ev, isWritable);
+                            }}
+                        >
+                            <MoreVerticalIcon />
+                        </IconButton>
+                    </Tooltip>
+                </div>
+                <div className={classes.wrapper}>
+                    <div
+                        className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)}
+                        data-cy="collection-files-left-panel"
+                    >
+                        <Tooltip
+                            title="Go back"
+                            className={path.length > 1 ? classes.backButton : classes.backButtonHidden}
+                        >
+                            <IconButton onClick={() => setPath(state => [...state.slice(0, state.length - 1)])}>
+                                <BackIcon />
+                            </IconButton>
+                        </Tooltip>
+                        <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
+                            <SearchInput
+                                selfClearProp={leftKey}
+                                label="Search"
+                                value={leftSearch}
+                                onSearch={setLeftSearch}
+                            />
+                        </div>
+                        <div className={classes.dataWrapper}>
+                            {leftData ? (
+                                <AutoSizer defaultWidth={0}>
+                                    {({ height, width }) => {
+                                        const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1);
+                                        return !!filtered.length ? (
+                                            <FixedSizeList
+                                                height={height}
+                                                itemCount={filtered.length}
+                                                itemSize={35}
+                                                width={width}
+                                            >
+                                                {({ index, style }) => {
+                                                    const { id, type, name } = filtered[index];
+                                                    return (
+                                                        <div
+                                                            data-id={id}
+                                                            style={style}
+                                                            data-item="true"
+                                                            data-type={type}
+                                                            data-parent-path={name}
+                                                            className={classNames(classes.row, getActiveClass(name))}
+                                                            key={id}
+                                                        >
+                                                            {getItemIcon(type, getActiveClass(name))}
+                                                            <div className={classes.rowName}>{name}</div>
+                                                            {getActiveClass(name) ? (
+                                                                <SidePanelRightArrowIcon
+                                                                    style={{ display: "inline", marginTop: "5px", marginLeft: "5px" }}
+                                                                />
+                                                            ) : null}
+                                                        </div>
+                                                    );
+                                                }}
+                                            </FixedSizeList>
+                                        ) : (
+                                            <div className={classes.rowEmpty}>No directories available</div>
+                                        );
+                                    }}
+                                </AutoSizer>
+                            ) : (
+                                <div
+                                    data-cy="collection-loader"
+                                    className={classes.row}
+                                >
+                                    <CircularProgress
+                                        className={classes.loader}
+                                        size={30}
+                                    />
+                                </div>
+                            )}
+                        </div>
+                    </div>
+                    <div
+                        className={classes.rightPanel}
+                        data-cy="collection-files-right-panel"
+                    >
+                        <div className={classes.searchWrapper}>
+                            <SearchInput
+                                selfClearProp={rightKey}
+                                label="Search"
+                                value={rightSearch}
+                                onSearch={setRightSearch}
+                            />
+                        </div>
+                        {isWritable && (
+                            <Button
+                                className={classes.uploadButton}
+                                data-cy="upload-button"
+                                onClick={() => {
+                                    onUploadDataClick(rightKey === leftKey ? undefined : rightKey);
+                                }}
+                                variant="contained"
+                                color="primary"
+                                size="small"
+                            >
+                                <DownloadIcon className={classes.uploadIcon} />
+                                Upload data
+                            </Button>
+                        )}
+                        <div className={classes.dataWrapper}>
+                            {rightData && !isLoading ? (
+                                <AutoSizer defaultHeight={500}>
+                                    {({ height, width }) => {
+                                        const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
+                                        return !!filtered.length ? (
+                                            <FixedSizeList
+                                                height={height}
+                                                itemCount={filtered.length}
+                                                itemSize={35}
+                                                width={width}
+                                            >
+                                                {({ index, style }) => {
+                                                    const { id, type, name, size } = filtered[index];
+
+                                                    return (
+                                                        <div
+                                                            style={style}
+                                                            data-id={id}
+                                                            data-item="true"
+                                                            data-type={type}
+                                                            data-subfolder-path={name}
+                                                            className={classes.row}
+                                                            key={id}
+                                                        >
+                                                            <Checkbox
+                                                                color="primary"
+                                                                className={classes.rowSelection}
+                                                                checked={collectionPanelFiles[id] ? collectionPanelFiles[id].value.selected : false}
+                                                            />
+                                                            &nbsp;
+                                                            {getItemIcon(type, null)}
+                                                            <div className={classes.rowName}>{name}</div>
+                                                            <span
+                                                                className={classes.rowName}
+                                                                style={{
+                                                                    marginLeft: "auto",
+                                                                    marginRight: "1rem",
+                                                                }}
+                                                            >
+                                                                {formatFileSize(size)}
+                                                            </span>
+                                                            <Tooltip
+                                                                title="More options"
+                                                                disableFocusListener
+                                                            >
+                                                                <IconButton
+                                                                    data-id="moreOptions"
+                                                                    data-cy="file-item-options-btn"
+                                                                    className={classes.moreOptionsButton}
+                                                                >
+                                                                    <MoreHorizontalIcon
+                                                                        data-id="moreOptions"
+                                                                        className={classes.moreOptions}
+                                                                    />
+                                                                </IconButton>
+                                                            </Tooltip>
+                                                        </div>
+                                                    );
+                                                }}
+                                            </FixedSizeList>
+                                        ) : (
+                                            <div className={classes.rowEmpty}>This collection is empty</div>
+                                        );
+                                    }}
+                                </AutoSizer>
+                            ) : (
+                                <div className={classes.row}>
+                                    <CircularProgress
+                                        className={classes.loader}
+                                        size={30}
+                                    />
+                                </div>
+                            )}
+                        </div>
+                    </div>
+                </div>
+            </div>
+        );
+    })
+);
diff --git a/services/workbench2/src/components/column-selector/column-selector.test.tsx b/services/workbench2/src/components/column-selector/column-selector.test.tsx
new file mode 100644 (file)
index 0000000..87fa2ca
--- /dev/null
@@ -0,0 +1,84 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { mount, configure } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+import { ColumnSelector, ColumnSelectorTrigger } from "./column-selector";
+import { ListItem, Checkbox } from "@material-ui/core";
+import { DataColumns } from "../data-table/data-table";
+
+configure({ adapter: new Adapter() });
+
+describe("<ColumnSelector />", () => {
+    it("shows only configurable columns", () => {
+        const columns: DataColumns<void> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true,
+                configurable: true
+            },
+            {
+                name: "Column 2",
+                render: () => <span />,
+                selected: true,
+                configurable: true,
+            },
+            {
+                name: "Column 3",
+                render: () => <span />,
+                selected: true,
+                configurable: false
+            }
+        ];
+        const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={jest.fn()} />);
+        columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+        expect(columnsConfigurator.find(ListItem)).toHaveLength(2);
+    });
+
+    it("renders checked checkboxes next to selected columns", () => {
+        const columns: DataColumns<void> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true,
+                configurable: true
+            },
+            {
+                name: "Column 2",
+                render: () => <span />,
+                selected: false,
+                configurable: true
+            },
+            {
+                name: "Column 3",
+                render: () => <span />,
+                selected: true,
+                configurable: true
+            }
+        ];
+        const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={jest.fn()} />);
+        columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+        expect(columnsConfigurator.find(Checkbox).at(0).prop("checked")).toBe(true);
+        expect(columnsConfigurator.find(Checkbox).at(1).prop("checked")).toBe(false);
+        expect(columnsConfigurator.find(Checkbox).at(2).prop("checked")).toBe(true);
+    });
+
+    it("calls onColumnToggle with clicked column", () => {
+        const columns: DataColumns<void> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true,
+                configurable: true
+            }
+        ];
+        const onColumnToggle = jest.fn();
+        const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={onColumnToggle} />);
+        columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+        columnsConfigurator.find(ListItem).simulate("click");
+        expect(onColumnToggle).toHaveBeenCalledWith(columns[0]);
+    });
+});
diff --git a/services/workbench2/src/components/column-selector/column-selector.tsx b/services/workbench2/src/components/column-selector/column-selector.tsx
new file mode 100644 (file)
index 0000000..0eb1323
--- /dev/null
@@ -0,0 +1,71 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WithStyles, StyleRulesCallback, withStyles, IconButton, Paper, List, Checkbox, ListItemText, ListItem, Tooltip } from '@material-ui/core';
+import MenuIcon from "@material-ui/icons/Menu";
+import { DataColumn } from '../data-table/data-column';
+import { Popover } from "../popover/popover";
+import { IconButtonProps } from '@material-ui/core/IconButton';
+import { DataColumns } from '../data-table/data-table';
+import { ArvadosTheme } from "common/custom-theme";
+
+interface ColumnSelectorDataProps {
+    columns: DataColumns<any, any>;
+    onColumnToggle: (column: DataColumn<any, any>) => void;
+    className?: string;
+}
+
+type CssRules = "checkbox" | "listItem" | "listItemText";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    checkbox: {
+        width: 24,
+        height: 24
+    },
+    listItem: {
+        padding: 0
+    },
+    listItemText: {
+        paddingTop: '0.2rem'
+    }
+});
+
+export type ColumnSelectorProps = ColumnSelectorDataProps & WithStyles<CssRules>;
+
+export const ColumnSelector = withStyles(styles)(
+    ({ columns, onColumnToggle, classes }: ColumnSelectorProps) =>
+        <Popover triggerComponent={ColumnSelectorTrigger}>
+            <Paper>
+                <List dense>
+                    {columns
+                        .filter(column => column.configurable)
+                        .map((column, index) =>
+                            <ListItem
+                                button
+                                key={index}
+                                className={classes.listItem}
+                                onClick={() => onColumnToggle(column)}>
+                                <Checkbox
+                                    disableRipple
+                                    color="primary"
+                                    checked={column.selected}
+                                    className={classes.checkbox} />
+                                <ListItemText
+                                    className={classes.listItemText}>
+                                    {column.name}
+                                </ListItemText>
+                            </ListItem>
+                        )}
+                </List>
+            </Paper>
+        </Popover>
+);
+
+export const ColumnSelectorTrigger = (props: IconButtonProps) =>
+    <Tooltip disableFocusListener title="Select columns">
+        <IconButton {...props}>
+            <MenuIcon aria-label="Select columns" />
+        </IconButton>
+    </Tooltip>;
diff --git a/services/workbench2/src/components/confirmation-dialog/confirmation-dialog.tsx b/services/workbench2/src/components/confirmation-dialog/confirmation-dialog.tsx
new file mode 100644 (file)
index 0000000..fa09ffc
--- /dev/null
@@ -0,0 +1,51 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, DialogContentText } from "@material-ui/core";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { WarningIcon } from 'components/icon/icon';
+
+export interface ConfirmationDialogDataProps {
+    title: string;
+    text: string;
+    info?: string;
+    cancelButtonLabel?: string;
+    confirmButtonLabel?: string;
+}
+
+export interface ConfirmationDialogProps {
+    onConfirm: () => void;
+}
+
+export const ConfirmationDialog = (props: ConfirmationDialogProps & WithDialogProps<ConfirmationDialogDataProps>) =>
+    <Dialog open={props.open}>
+        <div data-cy='confirmation-dialog'>
+            <DialogTitle>{props.data.title}</DialogTitle>
+            <DialogContent style={{ display: 'flex', alignItems: 'center' }}>
+                <WarningIcon />
+                <DialogContentText style={{ paddingLeft: '8px' }}>
+                    <span style={{display: 'block'}}>{props.data.text}</span>
+                    <span style={{display: 'block'}}>{props.data.info}</span>
+                </DialogContentText>
+            </DialogContent>
+            <DialogActions style={{ margin: '0px 24px 24px' }}>
+                <Button
+                    data-cy='confirmation-dialog-cancel-btn'
+                    variant='text'
+                    color='primary'
+                    onClick={props.closeDialog}>
+                    {props.data.cancelButtonLabel || 'Cancel'}
+                </Button>
+                <Button
+                    data-cy='confirmation-dialog-ok-btn'
+                    variant='contained'
+                    color='primary'
+                    type='submit'
+                    onClick={props.onConfirm}>
+                    {props.data.confirmButtonLabel || 'Ok'}
+                </Button>
+            </DialogActions>
+        </div>
+    </Dialog>;
diff --git a/services/workbench2/src/components/context-menu/context-menu.test.tsx b/services/workbench2/src/components/context-menu/context-menu.test.tsx
new file mode 100644 (file)
index 0000000..31e7720
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { mount, configure } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+import { ContextMenu } from "./context-menu";
+import { ListItem } from "@material-ui/core";
+import { ShareIcon } from "../icon/icon";
+
+configure({ adapter: new Adapter() });
+
+describe("<ContextMenu />", () => {
+    const items = [[{
+        icon: ShareIcon,
+        name: "Action 1.1"
+    }, {
+        icon: ShareIcon,
+        name: "Action 1.2"
+    },], [{
+        icon: ShareIcon,
+        name: "Action 2.1"
+    }]];
+
+    it("calls onItemClick with clicked action", () => {
+        const onItemClick = jest.fn();
+        const contextMenu = mount(<ContextMenu
+            anchorEl={document.createElement("div")}
+            open={true}
+            onClose={jest.fn()}
+            onItemClick={onItemClick}
+            items={items} />);
+        contextMenu.find(ListItem).at(2).simulate("click");
+        expect(onItemClick).toHaveBeenCalledWith(items[1][0]);
+    });
+});
diff --git a/services/workbench2/src/components/context-menu/context-menu.tsx b/services/workbench2/src/components/context-menu/context-menu.tsx
new file mode 100644 (file)
index 0000000..a44e8b7
--- /dev/null
@@ -0,0 +1,74 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import React from "react";
+import { Popover, List, ListItem, ListItemIcon, ListItemText, Divider } from "@material-ui/core";
+import { DefaultTransformOrigin } from "../popover/helpers";
+import { IconType } from "../icon/icon";
+import { RootState } from "store/store";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+
+export interface ContextMenuItem {
+    name?: string | React.ComponentType;
+    icon?: IconType;
+    component?: React.ComponentType<any>;
+    adminOnly?: boolean;
+    filters?: ((state: RootState, resource: ContextMenuResource) => boolean)[]
+}
+
+export type ContextMenuItemGroup = ContextMenuItem[];
+
+export interface ContextMenuProps {
+    anchorEl?: HTMLElement;
+    items: ContextMenuItemGroup[];
+    open: boolean;
+    onItemClick: (action: ContextMenuItem) => void;
+    onClose: () => void;
+}
+
+export class ContextMenu extends React.PureComponent<ContextMenuProps> {
+    render() {
+        const { anchorEl, items, open, onClose, onItemClick } = this.props;
+        return <Popover
+            anchorEl={anchorEl}
+            open={open}
+            onClose={onClose}
+            transformOrigin={DefaultTransformOrigin}
+            anchorOrigin={DefaultTransformOrigin}
+            onContextMenu={this.handleContextMenu}>
+            <List data-cy='context-menu' dense>
+                {items.map((group, groupIndex) =>
+                    <React.Fragment key={groupIndex}>
+                        {group.map((item, actionIndex) =>
+                            item.component
+                                ? <item.component
+                                    key={actionIndex}
+                                    onClick={() => onItemClick(item)} />
+                                : <ListItem
+                                    button
+                                    key={actionIndex}
+                                    onClick={() => onItemClick(item)}>
+                                    {item.icon &&
+                                        <ListItemIcon>
+                                            <item.icon />
+                                        </ListItemIcon>}
+                                    {item.name &&
+                                        <ListItemText>
+                                            {item.name}
+                                        </ListItemText>}
+                                </ListItem>)}
+                        {
+                            items[groupIndex + 1] &&
+                            items[groupIndex + 1].length > 0 &&
+                            <Divider />
+                        }
+                    </React.Fragment>)}
+            </List>
+        </Popover>;
+    }
+
+    handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
+        event.preventDefault();
+        this.props.onClose();
+    }
+}
diff --git a/services/workbench2/src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx b/services/workbench2/src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx
new file mode 100644 (file)
index 0000000..13e6531
--- /dev/null
@@ -0,0 +1,62 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import { StyleRulesCallback, Tooltip, WithStyles, withStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { CopyIcon } from 'components/icon/icon';
+
+type CssRules = 'copyIcon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    copyIcon: {
+        marginLeft: theme.spacing.unit,
+        color: theme.palette.grey['500'],
+        cursor: 'pointer',
+        display: 'inline',
+        '& svg': {
+            fontSize: '1rem',
+            verticalAlign: 'middle',
+        },
+    },
+});
+
+interface CopyToClipboardDataProps {
+    children?: React.ReactNode;
+    value: string;
+}
+
+type CopyToClipboardProps = CopyToClipboardDataProps & WithStyles<CssRules> & DispatchProp;
+
+export const CopyToClipboardSnackbar = connect()(
+    withStyles(styles)(
+        class CopyToClipboardSnackbar extends React.Component<CopyToClipboardProps> {
+            onCopy = () => {
+                this.props.dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: 'Copied',
+                        hideDuration: 2000,
+                        kind: SnackbarKind.SUCCESS,
+                    })
+                );
+            };
+
+            render() {
+                const { children, value, classes } = this.props;
+                return (
+                    <Tooltip title='Copy link to clipboard' onClick={(ev) => ev.stopPropagation()}>
+                        <span className={classes.copyIcon}>
+                            <CopyToClipboard text={value} onCopy={this.onCopy}>
+                                {children || <CopyIcon />}
+                            </CopyToClipboard>
+                        </span>
+                    </Tooltip>
+                );
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/components/copy-to-clipboard/copy-result-to-clipboard.ts b/services/workbench2/src/components/copy-to-clipboard/copy-result-to-clipboard.ts
new file mode 100644 (file)
index 0000000..129002b
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { ReactElementLike } from 'prop-types';
+import copy from 'copy-to-clipboard';
+
+interface CopyToClipboardProps {
+  getText: (() => string);
+  children: ReactElementLike;
+  onCopy?(text: string, result: boolean): void;
+  options?: {
+    debug?: boolean;
+    message?: string;
+    format?: string; // MIME type
+  };
+}
+
+export default class CopyResultToClipboard extends React.PureComponent<CopyToClipboardProps> {
+  static defaultProps = {
+    onCopy: undefined,
+    options: undefined
+  };
+
+  onClick = event => {
+    const {
+      getText,
+      onCopy,
+      children,
+      options
+    } = this.props;
+
+    const elem = React.Children.only(children);
+
+    const text = getText();
+
+    const result = copy(text, options);
+
+    if (onCopy) {
+      onCopy(text, result);
+    }
+
+    // Bypass onClick if it was present
+    if (elem && elem.props && typeof elem.props.onClick === 'function') {
+      elem.props.onClick(event);
+    }
+  };
+
+
+  render() {
+    const {
+      getText: _getText,
+      onCopy: _onCopy,
+      options: _options,
+      children,
+      ...props
+    } = this.props;
+    const elem = React.Children.only(children);
+
+    return React.cloneElement(elem, {...props, onClick: this.onClick});
+  }
+}
diff --git a/services/workbench2/src/components/data-explorer/data-explorer.test.tsx b/services/workbench2/src/components/data-explorer/data-explorer.test.tsx
new file mode 100644 (file)
index 0000000..b86567a
--- /dev/null
@@ -0,0 +1,163 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { configure, mount } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+
+import { DataExplorer } from "./data-explorer";
+import { ColumnSelector } from "../column-selector/column-selector";
+import { DataTable, DataTableFetchMode } from "../data-table/data-table";
+import { SearchInput } from "../search-input/search-input";
+import { TablePagination } from "@material-ui/core";
+import { ProjectIcon } from "../icon/icon";
+import { SortDirection } from "../data-table/data-column";
+import { combineReducers, createStore } from "redux";
+import { Provider } from "react-redux";
+
+configure({ adapter: new Adapter() });
+
+describe("<DataExplorer />", () => {
+    let store;
+    beforeEach(() => {
+        const initialMSState = {
+            multiselect: {
+                checkedList: {},
+                isVisible: false,
+            },
+            resources: {},
+        };
+        store = createStore(
+            combineReducers({
+                multiselect: (state: any = initialMSState.multiselect, action: any) => state,
+                resources: (state: any = initialMSState.resources, action: any) => state,
+            })
+        );
+    });
+
+    it("communicates with <SearchInput/>", () => {
+        const onSearch = jest.fn();
+        const onSetColumns = jest.fn();
+
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    items={[{ name: "item 1" }]}
+                    searchValue="search value"
+                    onSearch={onSearch}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
+        expect(dataExplorer.find(SearchInput).prop("value")).toEqual("search value");
+        dataExplorer.find(SearchInput).prop("onSearch")("new value");
+        expect(onSearch).toHaveBeenCalledWith("new value");
+    });
+
+    it("communicates with <ColumnSelector/>", () => {
+        const onColumnToggle = jest.fn();
+        const onSetColumns = jest.fn();
+        const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: {} }];
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    columns={columns}
+                    onColumnToggle={onColumnToggle}
+                    items={[{ name: "item 1" }]}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
+        expect(dataExplorer.find(ColumnSelector).prop("columns")).toBe(columns);
+        dataExplorer.find(ColumnSelector).prop("onColumnToggle")("columns");
+        expect(onColumnToggle).toHaveBeenCalledWith("columns");
+    });
+
+    it("communicates with <DataTable/>", () => {
+        const onFiltersChange = jest.fn();
+        const onSortToggle = jest.fn();
+        const onRowClick = jest.fn();
+        const onSetColumns = jest.fn();
+        const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: {} }];
+        const items = [{ name: "item 1" }];
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    columns={columns}
+                    items={items}
+                    onFiltersChange={onFiltersChange}
+                    onSortToggle={onSortToggle}
+                    onRowClick={onRowClick}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
+        expect(dataExplorer.find(DataTable).prop("columns").slice(1, 2)).toEqual(columns);
+        expect(dataExplorer.find(DataTable).prop("items")).toBe(items);
+        dataExplorer.find(DataTable).prop("onRowClick")("event", "rowClick");
+        dataExplorer.find(DataTable).prop("onFiltersChange")("filtersChange");
+        dataExplorer.find(DataTable).prop("onSortToggle")("sortToggle");
+        expect(onFiltersChange).toHaveBeenCalledWith("filtersChange");
+        expect(onSortToggle).toHaveBeenCalledWith("sortToggle");
+        expect(onRowClick).toHaveBeenCalledWith("rowClick");
+    });
+
+    it("communicates with <TablePagination/>", () => {
+        const onChangePage = jest.fn();
+        const onChangeRowsPerPage = jest.fn();
+        const onSetColumns = jest.fn();
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    items={[{ name: "item 1" }]}
+                    page={10}
+                    rowsPerPage={50}
+                    onChangePage={onChangePage}
+                    onChangeRowsPerPage={onChangeRowsPerPage}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
+        expect(dataExplorer.find(TablePagination).prop("page")).toEqual(10);
+        expect(dataExplorer.find(TablePagination).prop("rowsPerPage")).toEqual(50);
+        dataExplorer.find(TablePagination).prop("onChangePage")(undefined, 6);
+        dataExplorer.find(TablePagination).prop("onChangeRowsPerPage")({ target: { value: 10 } });
+        expect(onChangePage).toHaveBeenCalledWith(6);
+        expect(onChangeRowsPerPage).toHaveBeenCalledWith(10);
+    });
+});
+
+const mockDataExplorerProps = () => ({
+    fetchMode: DataTableFetchMode.PAGINATED,
+    columns: [],
+    items: [],
+    itemsAvailable: 0,
+    contextActions: [],
+    searchValue: "",
+    page: 0,
+    rowsPerPage: 0,
+    rowsPerPageOptions: [0],
+    onSearch: jest.fn(),
+    onFiltersChange: jest.fn(),
+    onSortToggle: jest.fn(),
+    onRowClick: jest.fn(),
+    onRowDoubleClick: jest.fn(),
+    onColumnToggle: jest.fn(),
+    onChangePage: jest.fn(),
+    onChangeRowsPerPage: jest.fn(),
+    onContextMenu: jest.fn(),
+    defaultIcon: ProjectIcon,
+    onSetColumns: jest.fn(),
+    onLoadMore: jest.fn(),
+    defaultMessages: ["testing"],
+    contextMenuColumn: true,
+    setCheckedListOnStore: jest.fn(),
+    toggleMSToolbar: jest.fn(),
+    isMSToolbarVisible: false,
+    checkedList: {},
+});
diff --git a/services/workbench2/src/components/data-explorer/data-explorer.tsx b/services/workbench2/src/components/data-explorer/data-explorer.tsx
new file mode 100644 (file)
index 0000000..ba710bc
--- /dev/null
@@ -0,0 +1,376 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from "@material-ui/core";
+import { ColumnSelector } from "components/column-selector/column-selector";
+import { DataTable, DataColumns, DataTableFetchMode } from "components/data-table/data-table";
+import { DataColumn } from "components/data-table/data-column";
+import { SearchInput } from "components/search-input/search-input";
+import { ArvadosTheme } from "common/custom-theme";
+import { MultiselectToolbar } from "components/multiselect-toolbar/MultiselectToolbar";
+import { TCheckedList } from "components/data-table/data-table";
+import { createTree } from "models/tree";
+import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
+import { CloseIcon, IconType, MaximizeIcon, UnMaximizeIcon, MoreVerticalIcon } from "components/icon/icon";
+import { PaperProps } from "@material-ui/core/Paper";
+import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
+
+type CssRules = "titleWrapper" | "searchBox" | "headerMenu" | "toolbar" | "footer" | "root" | "moreOptionsButton" | "title" | 'subProcessTitle' | "dataTable" | "container";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    titleWrapper: {
+        display: "flex",
+        justifyContent: "space-between",
+    },
+    searchBox: {
+        paddingBottom: 0,
+    },
+    toolbar: {
+        paddingTop: 0,
+        paddingRight: theme.spacing.unit,
+        paddingLeft: "10px",
+    },
+    footer: {
+        overflow: "auto",
+    },
+    root: {
+        height: "100%",
+    },
+    moreOptionsButton: {
+        padding: 0,
+    },
+    title: {
+        display: "inline-block",
+        paddingLeft: theme.spacing.unit * 2,
+        paddingTop: theme.spacing.unit * 2,
+        fontSize: "18px",
+        paddingRight: "10px",
+    },
+    subProcessTitle: {
+        display: "inline-block",
+        paddingLeft: theme.spacing.unit * 2,
+        paddingTop: theme.spacing.unit * 2,
+        fontSize: "18px",
+        flexGrow: 0,
+        paddingRight: "10px",
+    },
+    dataTable: {
+        height: "100%",
+        overflow: "auto",
+    },
+    container: {
+        height: "100%",
+    },
+    headerMenu: {
+        marginLeft: "auto",
+        flexBasis: "initial",
+        flexGrow: 0,
+    },
+});
+
+interface DataExplorerDataProps<T> {
+    fetchMode: DataTableFetchMode;
+    items: T[];
+    itemsAvailable: number;
+    columns: DataColumns<T, any>;
+    searchLabel?: string;
+    searchValue: string;
+    rowsPerPage: number;
+    rowsPerPageOptions: number[];
+    page: number;
+    contextMenuColumn: boolean;
+    defaultViewIcon?: IconType;
+    defaultViewMessages?: string[];
+    working?: boolean;
+    currentRoute?: string;
+    hideColumnSelector?: boolean;
+    paperProps?: PaperProps;
+    actions?: React.ReactNode;
+    hideSearchInput?: boolean;
+    title?: React.ReactNode;
+    progressBar?: React.ReactNode;
+    paperKey?: string;
+    currentItemUuid: string;
+    elementPath?: string;
+    isMSToolbarVisible: boolean;
+    checkedList: TCheckedList;
+    isNotFound: boolean;
+}
+
+interface DataExplorerActionProps<T> {
+    onSetColumns: (columns: DataColumns<T, any>) => void;
+    onSearch: (value: string) => void;
+    onRowClick: (item: T) => void;
+    onRowDoubleClick: (item: T) => void;
+    onColumnToggle: (column: DataColumn<T, any>) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
+    onSortToggle: (column: DataColumn<T, any>) => void;
+    onFiltersChange: (filters: DataTableFilters, column: DataColumn<T, any>) => void;
+    onChangePage: (page: number) => void;
+    onChangeRowsPerPage: (rowsPerPage: number) => void;
+    onLoadMore: (page: number) => void;
+    extractKey?: (item: T) => React.Key;
+    toggleMSToolbar: (isVisible: boolean) => void;
+    setCheckedListOnStore: (checkedList: TCheckedList) => void;
+}
+
+type DataExplorerProps<T> = DataExplorerDataProps<T> & DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
+
+export const DataExplorer = withStyles(styles)(
+    class DataExplorerGeneric<T> extends React.Component<DataExplorerProps<T>> {
+
+        multiSelectToolbarInTitle = !this.props.title && !this.props.progressBar;
+
+        componentDidMount() {
+            if (this.props.onSetColumns) {
+                this.props.onSetColumns(this.props.columns);
+            }
+        }
+
+        render() {
+            const {
+                columns,
+                onContextMenu,
+                onFiltersChange,
+                onSortToggle,
+                extractKey,
+                rowsPerPage,
+                rowsPerPageOptions,
+                onColumnToggle,
+                searchLabel,
+                searchValue,
+                onSearch,
+                items,
+                itemsAvailable,
+                onRowClick,
+                onRowDoubleClick,
+                classes,
+                defaultViewIcon,
+                defaultViewMessages,
+                hideColumnSelector,
+                actions,
+                paperProps,
+                hideSearchInput,
+                paperKey,
+                fetchMode,
+                currentItemUuid,
+                currentRoute,
+                title,
+                progressBar,
+                doHidePanel,
+                doMaximizePanel,
+                doUnMaximizePanel,
+                panelName,
+                panelMaximized,
+                elementPath,
+                toggleMSToolbar,
+                setCheckedListOnStore,
+                checkedList,
+                working,
+            } = this.props;
+            return (
+                <Paper
+                    className={classes.root}
+                    {...paperProps}
+                    key={paperKey}
+                    data-cy={this.props["data-cy"]}
+                >
+                    <Grid
+                        container
+                        direction="column"
+                        wrap="nowrap"
+                        className={classes.container}
+                    >
+                        <div className={classes.titleWrapper} style={currentRoute?.includes('search-results') || !!progressBar ? {marginBottom: '-20px'} : {}}>
+                            {title && (
+                                <Grid
+                                    item
+                                    xs
+                                    className={!!progressBar ? classes.subProcessTitle : classes.title}
+                                >
+                                    {title}
+                                </Grid>
+                            )}
+                            {!!progressBar && progressBar}
+                            {this.multiSelectToolbarInTitle && <MultiselectToolbar />}
+                            {(!hideColumnSelector || !hideSearchInput || !!actions) && (
+                                <Grid
+                                    className={classes.headerMenu}
+                                    item
+                                    xs
+                                >
+                                    <Toolbar className={classes.toolbar}>
+                                        <Grid container justify="space-between" wrap="nowrap" alignItems="center">
+                                            {!hideSearchInput && (
+                                                <div className={classes.searchBox}>
+                                                    {!hideSearchInput && (
+                                                        <SearchInput
+                                                            label={searchLabel}
+                                                            value={searchValue}
+                                                            selfClearProp={""}
+                                                            onSearch={onSearch}
+                                                        />
+                                                    )}
+                                                </div>
+                                            )}
+                                            {actions}
+                                            {!hideColumnSelector && (
+                                                <ColumnSelector
+                                                    columns={columns}
+                                                    onColumnToggle={onColumnToggle}
+                                                />
+                                            )}
+                                        </Grid>
+                                        {doUnMaximizePanel && panelMaximized && (
+                                            <Tooltip
+                                                title={`Unmaximize ${panelName || "panel"}`}
+                                                disableFocusListener
+                                            >
+                                                <IconButton onClick={doUnMaximizePanel}>
+                                                    <UnMaximizeIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        )}
+                                        {doMaximizePanel && !panelMaximized && (
+                                            <Tooltip
+                                                title={`Maximize ${panelName || "panel"}`}
+                                                disableFocusListener
+                                            >
+                                                <IconButton onClick={doMaximizePanel}>
+                                                    <MaximizeIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        )}
+                                        {doHidePanel && (
+                                            <Tooltip
+                                                title={`Close ${panelName || "panel"}`}
+                                                disableFocusListener
+                                            >
+                                                <IconButton
+                                                    disabled={panelMaximized}
+                                                    onClick={doHidePanel}
+                                                >
+                                                    <CloseIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        )}
+                                    </Toolbar>
+                                </Grid>
+                            )}
+                        </div>
+                        {!this.multiSelectToolbarInTitle && <MultiselectToolbar />}
+                        <Grid
+                            item
+                            xs="auto"
+                            className={classes.dataTable}
+                            style={currentRoute?.includes('search-results')  || !!progressBar ? {marginTop: '-10px'} : {}}
+                        >
+                            <DataTable
+                                columns={this.props.contextMenuColumn ? [...columns, this.contextMenuColumn] : columns}
+                                items={items}
+                                onRowClick={(_, item: T) => onRowClick(item)}
+                                onContextMenu={onContextMenu}
+                                onRowDoubleClick={(_, item: T) => onRowDoubleClick(item)}
+                                onFiltersChange={onFiltersChange}
+                                onSortToggle={onSortToggle}
+                                extractKey={extractKey}
+                                defaultViewIcon={defaultViewIcon}
+                                defaultViewMessages={defaultViewMessages}
+                                currentItemUuid={currentItemUuid}
+                                currentRoute={paperKey}
+                                toggleMSToolbar={toggleMSToolbar}
+                                setCheckedListOnStore={setCheckedListOnStore}
+                                checkedList={checkedList}
+                                working={working}
+                                isNotFound={this.props.isNotFound}
+                            />
+                        </Grid>
+                        <Grid
+                            item
+                            xs
+                        >
+                            <Toolbar className={classes.footer}>
+                                {elementPath && (
+                                    <Grid container>
+                                        <span data-cy="element-path">{elementPath}</span>
+                                    </Grid>
+                                )}
+                                <Grid
+                                    container={!elementPath}
+                                    justify="flex-end"
+                                >
+                                    {fetchMode === DataTableFetchMode.PAGINATED ? (
+                                        <TablePagination
+                                            count={itemsAvailable}
+                                            rowsPerPage={rowsPerPage}
+                                            rowsPerPageOptions={rowsPerPageOptions}
+                                            page={this.props.page}
+                                            onChangePage={this.changePage}
+                                            onChangeRowsPerPage={this.changeRowsPerPage}
+                                            // Disable next button on empty lists since that's not default behavior
+                                            nextIconButtonProps={itemsAvailable > 0 ? {} : { disabled: true }}
+                                            component="div"
+                                        />
+                                    ) : (
+                                        <Button
+                                            variant="text"
+                                            size="medium"
+                                            onClick={this.loadMore}
+                                        >
+                                            Load more
+                                        </Button>
+                                    )}
+                                </Grid>
+                            </Toolbar>
+                        </Grid>
+                    </Grid>
+                </Paper>
+            );
+        }
+
+        changePage = (event: React.MouseEvent<HTMLButtonElement>, page: number) => {
+            this.props.onChangePage(page);
+        };
+
+        changeRowsPerPage: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = event => {
+            this.props.onChangeRowsPerPage(parseInt(event.target.value, 10));
+        };
+
+        loadMore = () => {
+            this.props.onLoadMore(this.props.page + 1);
+        };
+
+        renderContextMenuTrigger = (item: T) => (
+            <Grid
+                container
+                justify="center"
+            >
+                <Tooltip
+                    title="More options"
+                    disableFocusListener
+                >
+                    <IconButton
+                        className={this.props.classes.moreOptionsButton}
+                        onClick={event => {
+                            event.stopPropagation()
+                            this.props.onContextMenu(event, item)
+                        }}
+                    >
+                        <MoreVerticalIcon />
+                    </IconButton>
+                </Tooltip>
+            </Grid>
+        );
+
+        contextMenuColumn: DataColumn<any, any> = {
+            name: "Actions",
+            selected: true,
+            configurable: false,
+            filters: createTree(),
+            key: "context-actions",
+            render: this.renderContextMenuTrigger,
+        };
+    }
+);
diff --git a/services/workbench2/src/components/data-table-default-view/data-table-default-view.tsx b/services/workbench2/src/components/data-table-default-view/data-table-default-view.tsx
new file mode 100644 (file)
index 0000000..b245c19
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { DefaultViewDataProps, DefaultView } from 'components/default-view/default-view';
+import { ArvadosTheme } from 'common/custom-theme';
+import { DetailsIcon } from 'components/icon/icon';
+
+type CssRules = 'classRoot';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    classRoot: {
+        marginTop: theme.spacing.unit * 4,
+        marginBottom: theme.spacing.unit * 4,
+    },
+});
+type DataTableDefaultViewDataProps = Partial<Pick<DefaultViewDataProps, 'icon' | 'messages' | 'filtersApplied'>>;
+type DataTableDefaultViewProps = DataTableDefaultViewDataProps & WithStyles<CssRules>;
+
+export const DataTableDefaultView = withStyles(styles)(
+    ({ classes, ...props }: DataTableDefaultViewProps) => {
+        const icon = props.icon || DetailsIcon;
+        const filterWarning: string[] = props.filtersApplied ? ['Filters are applied to the data.'] : [];
+        const messages = filterWarning.concat(props.messages || ['No items found']);
+        return <DefaultView {...classes} {...{ icon, messages }} />;
+    });
diff --git a/services/workbench2/src/components/data-table-filters/data-table-filters-popover.test.tsx b/services/workbench2/src/components/data-table-filters/data-table-filters-popover.test.tsx
new file mode 100644 (file)
index 0000000..d6d52c8
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { mount, configure } from "enzyme";
+import { DataTableFiltersPopover } from "./data-table-filters-popover";
+import Adapter from 'enzyme-adapter-react-16';
+import { Checkbox, IconButton } from "@material-ui/core";
+import { getInitialProcessStatusFilters } from "store/resource-type-filters/resource-type-filters"
+
+configure({ adapter: new Adapter() });
+
+describe("<DataTableFiltersPopover />", () => {
+    it("renders filters according to their state", () => {
+        // 1st filter (All) is selected, the rest aren't.
+        const filters = getInitialProcessStatusFilters()
+
+        const dataTableFilter = mount(<DataTableFiltersPopover name="" filters={filters} />);
+        dataTableFilter.find(IconButton).simulate("click");
+        expect(dataTableFilter.find(Checkbox).at(0).prop("checked")).toBeTruthy();
+        expect(dataTableFilter.find(Checkbox).at(1).prop("checked")).toBeFalsy();
+    });
+});
diff --git a/services/workbench2/src/components/data-table-filters/data-table-filters-popover.tsx b/services/workbench2/src/components/data-table-filters/data-table-filters-popover.tsx
new file mode 100644 (file)
index 0000000..557abd8
--- /dev/null
@@ -0,0 +1,191 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useEffect } from 'react';
+import {
+    WithStyles,
+    withStyles,
+    ButtonBase,
+    StyleRulesCallback,
+    Theme,
+    Popover,
+    Button,
+    Card,
+    CardActions,
+    Typography,
+    CardContent,
+    Tooltip,
+    IconButton,
+} from '@material-ui/core';
+import classnames from 'classnames';
+import { DefaultTransformOrigin } from 'components/popover/helpers';
+import { createTree } from 'models/tree';
+import { DataTableFilters, DataTableFiltersTree } from './data-table-filters-tree';
+import { getNodeDescendants } from 'models/tree';
+import debounce from 'lodash/debounce';
+
+export type CssRules = 'root' | 'icon' | 'iconButton' | 'active' | 'checkbox';
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    root: {
+        cursor: 'pointer',
+        display: 'inline-flex',
+        justifyContent: 'flex-start',
+        flexDirection: 'inherit',
+        alignItems: 'center',
+        '&:hover': {
+            color: theme.palette.text.primary,
+        },
+        '&:focus': {
+            color: theme.palette.text.primary,
+        },
+    },
+    active: {
+        color: theme.palette.text.primary,
+        '& $iconButton': {
+            opacity: 1,
+        },
+    },
+    icon: {
+        fontSize: 12,
+        userSelect: 'none',
+        width: 16,
+        height: 15,
+        marginTop: 1,
+    },
+    iconButton: {
+        color: theme.palette.text.primary,
+        opacity: 0.7,
+    },
+    checkbox: {
+        width: 24,
+        height: 24,
+    },
+});
+
+enum SelectionMode {
+    ALL = 'all',
+    NONE = 'none',
+}
+
+export interface DataTableFilterProps {
+    name: string;
+    filters: DataTableFilters;
+    onChange?: (filters: DataTableFilters) => void;
+
+    /**
+     * When set to true, only one filter can be selected at a time.
+     */
+    mutuallyExclusive?: boolean;
+
+    /**
+     * By default `all` filters selection means that label should be grayed out.
+     * Use `none` when label is supposed to be grayed out when no filter is selected.
+     */
+    defaultSelection?: SelectionMode;
+}
+
+interface DataTableFilterState {
+    anchorEl?: HTMLElement;
+    filters: DataTableFilters;
+    prevFilters: DataTableFilters;
+}
+
+export const DataTableFiltersPopover = withStyles(styles)(
+    class extends React.Component<DataTableFilterProps & WithStyles<CssRules>, DataTableFilterState> {
+        state: DataTableFilterState = {
+            anchorEl: undefined,
+            filters: createTree(),
+            prevFilters: createTree(),
+        };
+        icon = React.createRef<HTMLElement>();
+
+        render() {
+            const { name, classes, defaultSelection = SelectionMode.ALL, children } = this.props;
+            const isActive = getNodeDescendants('')(this.state.filters).some((f) => (defaultSelection === SelectionMode.ALL ? !f.selected : f.selected));
+            return (
+                <>
+                    <Tooltip disableFocusListener title='Filters'>
+                        <ButtonBase className={classnames([classes.root, { [classes.active]: isActive }])} component='span' onClick={this.open} disableRipple>
+                            {children}
+                            <IconButton component='span' classes={{ root: classes.iconButton }} tabIndex={-1}>
+                                <i className={classnames(['fas fa-filter', classes.icon])} data-fa-transform='shrink-3' ref={this.icon} />
+                            </IconButton>
+                        </ButtonBase>
+                    </Tooltip>
+                    <Popover
+                        anchorEl={this.state.anchorEl}
+                        open={!!this.state.anchorEl}
+                        anchorOrigin={DefaultTransformOrigin}
+                        transformOrigin={DefaultTransformOrigin}
+                        onClose={this.close}
+                    >
+                        <Card>
+                            <CardContent>
+                                <Typography variant='caption'>{name}</Typography>
+                            </CardContent>
+                            <DataTableFiltersTree filters={this.state.filters} mutuallyExclusive={this.props.mutuallyExclusive} onChange={this.onChange} />
+                            <>
+                                {this.props.mutuallyExclusive || (
+                                    <CardActions>
+                                        <Button color='primary' variant='outlined' size='small' onClick={this.close}>
+                                            Close
+                                        </Button>
+                                    </CardActions>
+                                )}
+                            </>
+                        </Card>
+                    </Popover>
+                    <this.MountHandler />
+                </>
+            );
+        }
+
+        static getDerivedStateFromProps(props: DataTableFilterProps, state: DataTableFilterState): DataTableFilterState {
+            return props.filters !== state.prevFilters ? { ...state, filters: props.filters, prevFilters: props.filters } : state;
+        }
+
+        open = () => {
+            this.setState({ anchorEl: this.icon.current || undefined });
+        };
+
+        onChange = (filters) => {
+            this.setState({ filters });
+            if (this.props.mutuallyExclusive) {
+                // Mutually exclusive filters apply immediately
+                const { onChange } = this.props;
+                if (onChange) {
+                    onChange(filters);
+                }
+                this.close();
+            } else {
+                // Non-mutually exclusive filters are debounced
+                this.submit();
+            }
+        };
+
+        submit = debounce(() => {
+            const { onChange } = this.props;
+            if (onChange) {
+                onChange(this.state.filters);
+            }
+        }, 1000);
+
+        MountHandler = () => {
+            useEffect(() => {
+                return () => {
+                    this.submit.cancel();
+                };
+            }, []);
+            return null;
+        };
+
+        close = () => {
+            this.setState((prev) => ({
+                ...prev,
+                anchorEl: undefined,
+            }));
+        };
+    }
+);
diff --git a/services/workbench2/src/components/data-table-filters/data-table-filters-tree.tsx b/services/workbench2/src/components/data-table-filters/data-table-filters-tree.tsx
new file mode 100644 (file)
index 0000000..d52b58f
--- /dev/null
@@ -0,0 +1,112 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Tree, toggleNodeSelection, getNode, initTreeNode, getNodeChildrenIds, selectNode, deselectNodes } from 'models/tree';
+import { Tree as TreeComponent, TreeItem, TreeItemStatus } from 'components/tree/tree';
+import { noop, map } from "lodash/fp";
+import { toggleNodeCollapse } from 'models/tree';
+import { countNodes, countChildren } from 'models/tree';
+
+export interface DataTableFilterItem {
+    name: string;
+}
+
+export type DataTableFilters = Tree<DataTableFilterItem>;
+
+export interface DataTableFilterProps {
+    filters: DataTableFilters;
+    onChange?: (filters: DataTableFilters) => void;
+
+    /**
+     * When set to true, only one filter can be selected at a time.
+     */
+    mutuallyExclusive?: boolean;
+}
+
+export class DataTableFiltersTree extends React.Component<DataTableFilterProps> {
+
+    render() {
+        const { filters } = this.props;
+        const hasSubfilters = countNodes(filters) !== countChildren('')(filters);
+        return <TreeComponent
+            levelIndentation={hasSubfilters ? 20 : 0}
+            itemRightPadding={20}
+            items={filtersToTree(filters)}
+            render={this.props.mutuallyExclusive ? renderRadioItem : renderItem}
+            showSelection
+            useRadioButtons={this.props.mutuallyExclusive}
+            disableRipple
+            onContextMenu={noop}
+            toggleItemActive={
+                this.props.mutuallyExclusive
+                    ? this.toggleRadioButtonFilter
+                    : this.toggleFilter
+            }
+            toggleItemOpen={this.toggleOpen}
+        />;
+    }
+
+    /**
+     * Handler for when a tree item is toggled via a radio button.
+     * Ensures mutual exclusivity among filter tree items.
+     */
+    toggleRadioButtonFilter = (_: any, item: TreeItem<DataTableFilterItem>) => {
+        const { onChange = noop } = this.props;
+
+        // If the filter is already selected, do nothing.
+        if (item.selected) { return; }
+
+        // Otherwise select this node and deselect the others
+        const filters = selectNode(item.id, true)(this.props.filters);
+        const toDeselect = Object.keys(this.props.filters).filter((id) => (id !== item.id));
+        onChange(deselectNodes(toDeselect, true)(filters));
+    }
+
+    toggleFilter = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
+        const { onChange = noop } = this.props;
+        onChange(toggleNodeSelection(item.id, true)(this.props.filters));
+    }
+
+    toggleOpen = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
+        const { onChange = noop } = this.props;
+        onChange(toggleNodeCollapse(item.id)(this.props.filters));
+    }
+}
+
+const renderItem = (item: TreeItem<DataTableFilterItem>) =>
+    <span>
+        {item.data.name}
+        {item.initialState !== item.selected ? <>
+            *
+        </> : null}
+    </span>;
+
+const renderRadioItem = (item: TreeItem<DataTableFilterItem>) =>
+    <span>
+        {item.data.name}
+    </span>;
+
+const filterToTreeItem = (filters: DataTableFilters) =>
+    (id: string): TreeItem<any> => {
+        const node = getNode(id)(filters) || initTreeNode({ id: '', value: 'InvalidNode' });
+        const items = getNodeChildrenIds(node.id)(filters)
+            .map(filterToTreeItem(filters));
+        const isIndeterminate = !node.selected && items.some(i => i.selected || i.indeterminate);
+
+        return {
+            active: node.active,
+            data: node.value,
+            id: node.id,
+            items: items.length > 0 ? items : undefined,
+            open: node.expanded,
+            selected: node.selected,
+            initialState: node.initialState,
+            indeterminate: isIndeterminate,
+            status: TreeItemStatus.LOADED,
+        };
+    };
+
+const filtersToTree = (filters: DataTableFilters): TreeItem<DataTableFilterItem>[] =>
+    map(filterToTreeItem(filters), getNodeChildrenIds('')(filters));
diff --git a/services/workbench2/src/components/data-table-filters/data-table-filters.tsx b/services/workbench2/src/components/data-table-filters/data-table-filters.tsx
new file mode 100644 (file)
index 0000000..ed7b30e
--- /dev/null
@@ -0,0 +1,8 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface DataTableFilterItem {
+    name: string;
+    selected: boolean;
+}
diff --git a/services/workbench2/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx b/services/workbench2/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx
new file mode 100644 (file)
index 0000000..0248c82
--- /dev/null
@@ -0,0 +1,149 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { WithStyles, withStyles, ButtonBase, StyleRulesCallback, Theme, Popover, Card, Tooltip, IconButton } from "@material-ui/core";
+import classnames from "classnames";
+import { DefaultTransformOrigin } from "components/popover/helpers";
+import { grey } from "@material-ui/core/colors";
+import { TCheckedList } from "components/data-table/data-table";
+
+export type CssRules = "root" | "icon" | "iconButton" | "disabled" | "optionsContainer" | "option";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    root: {
+        borderRadius: "7px",
+        "&:hover": {
+            backgroundColor: grey[200],
+        },
+        "&:focus": {
+            color: theme.palette.text.primary,
+        },
+    },
+    icon: {
+        cursor: "pointer",
+        fontSize: 20,
+        userSelect: "none",
+        "&:hover": {
+            color: theme.palette.text.primary,
+        },
+        paddingBottom: "5px",
+    },
+    iconButton: {
+        color: theme.palette.text.primary,
+        opacity: 0.6,
+        padding: 1,
+        paddingBottom: 5,
+    },
+    disabled: {
+        color: grey[500],
+    },
+    optionsContainer: {
+        padding: "1rem 0",
+        flex: 1,
+    },
+    option: {
+        cursor: "pointer",
+        display: "flex",
+        padding: "3px 2rem",
+        fontSize: "0.9rem",
+        alignItems: "center",
+        "&:hover": {
+            backgroundColor: "rgba(0, 0, 0, 0.08)",
+        },
+    },
+});
+
+export type DataTableMultiselectOption = {
+    name: string;
+    fn: (checkedList) => void;
+};
+
+export interface DataTableMultiselectProps {
+    name: string;
+    disabled: boolean;
+    options: DataTableMultiselectOption[];
+    checkedList: TCheckedList;
+}
+
+interface DataTableFMultiselectPopState {
+    anchorEl?: HTMLElement;
+}
+
+export const DataTableMultiselectPopover = withStyles(styles)(
+    class extends React.Component<DataTableMultiselectProps & WithStyles<CssRules>, DataTableFMultiselectPopState> {
+        state: DataTableFMultiselectPopState = {
+            anchorEl: undefined,
+        };
+        icon = React.createRef<HTMLElement>();
+
+        render() {
+            const { classes, children, options, checkedList, disabled } = this.props;
+            return (
+                <>
+                    <Tooltip
+                        disableFocusListener
+                        title="Select Options"
+                    >
+                        <ButtonBase
+                            className={classnames(classes.root)}
+                            component="span"
+                            onClick={disabled ? () => {} : this.open}
+                            disableRipple
+                        >
+                            {children}
+                            <IconButton
+                                component="span"
+                                classes={{ root: classes.iconButton }}
+                                tabIndex={-1}
+                            >
+                                <i
+                                    className={`${classnames(["fas fa-sort-down", classes.icon])}${disabled ? ` ${classes.disabled}` : ""}`}
+                                    data-fa-transform="shrink-3"
+                                    ref={this.icon}
+                                />
+                            </IconButton>
+                        </ButtonBase>
+                    </Tooltip>
+                    <Popover
+                        anchorEl={this.state.anchorEl}
+                        open={!!this.state.anchorEl}
+                        anchorOrigin={DefaultTransformOrigin}
+                        transformOrigin={DefaultTransformOrigin}
+                        onClose={this.close}
+                    >
+                        <Card>
+                            <div className={classes.optionsContainer}>
+                                {options.length &&
+                                    options.map((option, i) => (
+                                        <div
+                                            key={i}
+                                            className={classes.option}
+                                            onClick={() => {
+                                                option.fn(checkedList);
+                                                this.close();
+                                            }}
+                                        >
+                                            {option.name}
+                                        </div>
+                                    ))}
+                            </div>
+                        </Card>
+                    </Popover>
+                </>
+            );
+        }
+
+        open = () => {
+            this.setState({ anchorEl: this.icon.current || undefined });
+        };
+
+        close = () => {
+            this.setState(prev => ({
+                ...prev,
+                anchorEl: undefined,
+            }));
+        };
+    }
+);
diff --git a/services/workbench2/src/components/data-table/data-column.ts b/services/workbench2/src/components/data-table/data-column.ts
new file mode 100644 (file)
index 0000000..35655fb
--- /dev/null
@@ -0,0 +1,57 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { DataTableFilters } from "../data-table-filters/data-table-filters-tree";
+import { createTree } from 'models/tree';
+
+/**
+ *
+ * @template I Type of dataexplorer item reference
+ * @template R Type of resource to use to restrict values of column sort.field
+ */
+export interface DataColumn<I, R> {
+    key?: React.Key;
+    name: string;
+    selected: boolean;
+    configurable: boolean;
+
+    /**
+     * If set to true, filters on this column will be displayed in a
+     * radio group and only one filter can be selected at a time.
+     */
+    mutuallyExclusiveFilters?: boolean;
+    sort?: {direction: SortDirection, field: keyof R};
+    filters: DataTableFilters;
+    render: (item: I) => React.ReactElement<any>;
+    renderHeader?: () => React.ReactElement<any>;
+}
+
+export enum SortDirection {
+    ASC = "asc",
+    DESC = "desc",
+    NONE = "none"
+}
+
+export const toggleSortDirection = <I, R>(column: DataColumn<I, R>): DataColumn<I, R> => {
+    return column.sort
+        ? column.sort.direction === SortDirection.ASC
+            ? { ...column, sort: {...column.sort, direction: SortDirection.DESC} }
+            : { ...column, sort: {...column.sort, direction: SortDirection.ASC} }
+        : column;
+};
+
+export const resetSortDirection = <I, R>(column: DataColumn<I, R>): DataColumn<I, R> => {
+    return column.sort ? { ...column, sort: {...column.sort, direction: SortDirection.NONE} } : column;
+};
+
+export const createDataColumn = <I, R>(dataColumn: Partial<DataColumn<I, R>>): DataColumn<I, R> => ({
+    key: '',
+    name: '',
+    selected: true,
+    configurable: true,
+    filters: createTree(),
+    render: () => React.createElement('span'),
+    ...dataColumn,
+});
diff --git a/services/workbench2/src/components/data-table/data-table.test.tsx b/services/workbench2/src/components/data-table/data-table.test.tsx
new file mode 100644 (file)
index 0000000..87d3efc
--- /dev/null
@@ -0,0 +1,248 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { mount, configure } from "enzyme";
+import { pipe } from "lodash/fp";
+import { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } from "@material-ui/core";
+import Adapter from "enzyme-adapter-react-16";
+import { DataTable, DataColumns } from "./data-table";
+import { SortDirection, createDataColumn } from "./data-column";
+import { DataTableFiltersPopover } from "components/data-table-filters/data-table-filters-popover";
+import { createTree, setNode, initTreeNode } from "models/tree";
+import { DataTableFilterItem } from "components/data-table-filters/data-table-filters-tree";
+
+configure({ adapter: new Adapter() });
+
+describe("<DataTable />", () => {
+    it("shows only selected columns", () => {
+        const columns: DataColumns<string, string> = [
+            createDataColumn({
+                name: "Column 1",
+                render: () => <span />,
+                selected: true,
+                configurable: true,
+            }),
+            createDataColumn({
+                name: "Column 2",
+                render: () => <span />,
+                selected: true,
+                configurable: true,
+            }),
+            createDataColumn({
+                name: "Column 3",
+                render: () => <span />,
+                selected: false,
+                configurable: true,
+            }),
+        ];
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[{ key: "1", name: "item 1" }]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(3);
+    });
+
+    it("renders column name", () => {
+        const columns: DataColumns<string, string> = [
+            createDataColumn({
+                name: "Column 1",
+                render: () => <span />,
+                selected: true,
+                configurable: true,
+            }),
+        ];
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableHead).find(TableCell).last().text()).toBe("Column 1");
+    });
+
+    it("uses renderHeader instead of name prop", () => {
+        const columns: DataColumns<string, string> = [
+            createDataColumn({
+                name: "Column 1",
+                renderHeader: () => <span>Column Header</span>,
+                render: () => <span />,
+                selected: true,
+                configurable: true,
+            }),
+        ];
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableHead).find(TableCell).last().text()).toBe("Column Header");
+    });
+
+    it("passes column key prop to corresponding cells", () => {
+        const columns: DataColumns<string, string> = [
+            createDataColumn({
+                name: "Column 1",
+                key: "column-1-key",
+                render: () => <span />,
+                selected: true,
+                configurable: true,
+            }),
+        ];
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                working={false}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        setTimeout(() => {
+            expect(dataTable.find(TableBody).find(TableCell).last().key()).toBe("column-1-key");
+        }, 1000);
+    });
+
+    it("renders items", () => {
+        const columns: DataColumns<string, string> = [
+            createDataColumn({
+                name: "Column 1",
+                render: item => <Typography>{item}</Typography>,
+                selected: true,
+                configurable: true,
+            }),
+            createDataColumn({
+                name: "Column 2",
+                render: item => <Button>{item}</Button>,
+                selected: true,
+                configurable: true,
+            }),
+        ];
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                working={false}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        setTimeout(() => {
+            expect(dataTable.find(TableBody).find(Typography).last().text()).toBe("item 1");
+            expect(dataTable.find(TableBody).find(Button).last().text()).toBe("item 1");
+        }, 1000);
+    });
+
+    it("passes sorting props to <TableSortLabel />", () => {
+        const columns: DataColumns<string, string> = [
+            createDataColumn({
+                name: "Column 1",
+                sort: { direction: SortDirection.ASC, field: "length" },
+                selected: true,
+                configurable: true,
+                render: item => <Typography>{item}</Typography>,
+            }),
+        ];
+        const onSortToggle = jest.fn();
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={onSortToggle}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableSortLabel).prop("active")).toBeTruthy();
+        dataTable.find(TableSortLabel).at(0).simulate("click");
+        expect(onSortToggle).toHaveBeenCalledWith(columns[1]);
+    });
+
+    it("does not display <DataTableFiltersPopover /> if there is no filters provided", () => {
+        const columns: DataColumns<string, string> = [
+            {
+                name: "Column 1",
+                selected: true,
+                configurable: true,
+                filters: [],
+                render: item => <Typography>{item}</Typography>,
+            },
+        ];
+        const onFiltersChange = jest.fn();
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[]}
+                onFiltersChange={onFiltersChange}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onSortToggle={jest.fn()}
+                onContextMenu={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(DataTableFiltersPopover)).toHaveLength(0);
+    });
+
+    it("passes filter props to <DataTableFiltersPopover />", () => {
+        const filters = pipe(() => createTree<DataTableFilterItem>(), setNode(initTreeNode({ id: "filter", value: { name: "filter" } })));
+        const columns: DataColumns<string, string> = [
+            {
+                name: "Column 1",
+                selected: true,
+                configurable: true,
+                filters: filters(),
+                render: item => <Typography>{item}</Typography>,
+            },
+        ];
+        const onFiltersChange = jest.fn();
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[]}
+                onFiltersChange={onFiltersChange}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onSortToggle={jest.fn()}
+                onContextMenu={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(DataTableFiltersPopover).prop("filters")).toBe(columns[1].filters);
+        dataTable.find(DataTableFiltersPopover).prop("onChange")([]);
+        expect(onFiltersChange).toHaveBeenCalledWith([], columns[1]);
+    });
+});
diff --git a/services/workbench2/src/components/data-table/data-table.tsx b/services/workbench2/src/components/data-table/data-table.tsx
new file mode 100644 (file)
index 0000000..7b78799
--- /dev/null
@@ -0,0 +1,448 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import {
+    Table,
+    TableBody,
+    TableRow,
+    TableCell,
+    TableHead,
+    TableSortLabel,
+    StyleRulesCallback,
+    Theme,
+    WithStyles,
+    withStyles,
+    IconButton,
+    Tooltip,
+} from "@material-ui/core";
+import classnames from "classnames";
+import { DataColumn, SortDirection } from "./data-column";
+import { DataTableDefaultView } from "../data-table-default-view/data-table-default-view";
+import { DataTableFilters } from "../data-table-filters/data-table-filters-tree";
+import { DataTableMultiselectPopover } from "../data-table-multiselect-popover/data-table-multiselect-popover";
+import { DataTableFiltersPopover } from "../data-table-filters/data-table-filters-popover";
+import { countNodes, getTreeDirty } from "models/tree";
+import { IconType } from "components/icon/icon";
+import { SvgIconProps } from "@material-ui/core/SvgIcon";
+import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
+import { createTree } from "models/tree";
+import { DataTableMultiselectOption } from "../data-table-multiselect-popover/data-table-multiselect-popover";
+import { PendingIcon } from "components/icon/icon";
+
+export type DataColumns<I, R> = Array<DataColumn<I, R>>;
+
+export enum DataTableFetchMode {
+    PAGINATED,
+    INFINITE,
+}
+
+export interface DataTableDataProps<I> {
+    items: I[];
+    columns: DataColumns<I, any>;
+    onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: I) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: I) => void;
+    onRowDoubleClick: (event: React.MouseEvent<HTMLTableRowElement>, item: I) => void;
+    onSortToggle: (column: DataColumn<I, any>) => void;
+    onFiltersChange: (filters: DataTableFilters, column: DataColumn<I, any>) => void;
+    extractKey?: (item: I) => React.Key;
+    working?: boolean;
+    defaultViewIcon?: IconType;
+    defaultViewMessages?: string[];
+    currentItemUuid?: string;
+    currentRoute?: string;
+    toggleMSToolbar: (isVisible: boolean) => void;
+    setCheckedListOnStore: (checkedList: TCheckedList) => void;
+    checkedList: TCheckedList;
+    isNotFound?: boolean;
+}
+
+type CssRules =
+    | "tableBody"
+    | "root"
+    | "content"
+    | "noItemsInfo"
+    | "checkBoxHead"
+    | "checkBoxCell"
+    | "clickBox"
+    | "checkBox"
+    | "firstTableCell"
+    | "tableCell"
+    | "arrow"
+    | "arrowButton"
+    | "tableCellWorkflows";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    root: {
+        width: "100%",
+    },
+    content: {
+        display: "inline-block",
+        width: "100%",
+    },
+    tableBody: {
+        background: theme.palette.background.paper,
+    },
+    noItemsInfo: {
+        textAlign: "center",
+        padding: theme.spacing.unit,
+    },
+    checkBoxHead: {
+        padding: "0",
+        display: "flex",
+        width: '2rem',
+        height: "1.5rem",
+        paddingLeft: '0.9rem',
+        marginRight: '0.5rem'
+    },
+    checkBoxCell: {
+        padding: "0",
+    },
+    clickBox: {
+        display: 'flex',
+        width: '1.6rem',
+        height: "1.5rem",
+        paddingLeft: '0.35rem',
+        paddingTop: '0.1rem',
+        marginLeft: '0.5rem',
+        cursor: "pointer",
+    },
+    checkBox: {
+        cursor: "pointer",
+    },
+    tableCell: {
+        wordWrap: "break-word",
+        paddingRight: "24px",
+        color: "#737373",
+    },
+    firstTableCell: {
+        paddingLeft: "5px",
+    },
+    tableCellWorkflows: {
+        "&:nth-last-child(2)": {
+            padding: "0px",
+            maxWidth: "48px",
+        },
+        "&:last-child": {
+            padding: "0px",
+            paddingRight: "24px",
+            width: "48px",
+        },
+    },
+    arrow: {
+        margin: 0,
+    },
+    arrowButton: {
+        color: theme.palette.text.primary,
+    },
+});
+
+export type TCheckedList = Record<string, boolean>;
+
+type DataTableState = {
+    isSelected: boolean;
+    isLoaded: boolean;
+};
+
+type DataTableProps<T> = DataTableDataProps<T> & WithStyles<CssRules>;
+
+export const DataTable = withStyles(styles)(
+    class Component<T> extends React.Component<DataTableProps<T>> {
+        state: DataTableState = {
+            isSelected: false,
+            isLoaded: false,
+        };
+
+        componentDidMount(): void {
+            this.initializeCheckedList([]);
+        }
+
+        componentDidUpdate(prevProps: Readonly<DataTableProps<T>>, prevState: DataTableState) {
+            const { items, setCheckedListOnStore } = this.props;
+            const { isSelected } = this.state;
+            if (prevProps.items !== items) {
+                if (isSelected === true) this.setState({ isSelected: false });
+                if (items.length) this.initializeCheckedList(items);
+                else setCheckedListOnStore({});
+            }
+            if (prevProps.currentRoute !== this.props.currentRoute) {
+                this.initializeCheckedList([])
+            }
+            if(prevProps.working === true && this.props.working === false) {
+                this.setState({ isLoaded: true });
+            }
+            if((this.props.items.length > 0) && !this.state.isLoaded) {
+                this.setState({ isLoaded: true });
+            }
+        }
+
+        componentWillUnmount(): void {
+            this.initializeCheckedList([])
+        }
+
+        checkBoxColumn: DataColumn<any, any> = {
+            name: "checkBoxColumn",
+            selected: true,
+            configurable: false,
+            filters: createTree(),
+            render: uuid => {
+                const { classes, checkedList } = this.props;
+                return (
+                    <div
+                        className={classes.clickBox}
+                        onClick={(ev) => {
+                            ev.stopPropagation()
+                            this.handleSelectOne(uuid)
+                        }}
+                        onDoubleClick={(ev) => ev.stopPropagation()}
+                    >
+                        <input
+                            data-cy={`multiselect-checkbox-${uuid}`}
+                            type='checkbox'
+                            name={uuid}
+                            className={classes.checkBox}
+                            checked={checkedList && checkedList[uuid] ? checkedList[uuid] : false}
+                            onChange={() => this.handleSelectOne(uuid)}
+                            onDoubleClick={(ev) => ev.stopPropagation()}
+                        ></input>
+                    </div>
+                );
+            },
+        };
+
+        multiselectOptions: DataTableMultiselectOption[] = [
+            { name: "All", fn: list => this.handleSelectAll(list) },
+            { name: "None", fn: list => this.handleSelectNone(list) },
+            { name: "Invert", fn: list => this.handleInvertSelect(list) },
+        ];
+
+        initializeCheckedList = (uuids: any[]): void => {
+            const newCheckedList = { ...this.props.checkedList };
+
+            uuids.forEach(uuid => {
+                if (!newCheckedList.hasOwnProperty(uuid)) {
+                    newCheckedList[uuid] = false;
+                }
+            });
+            for (const key in newCheckedList) {
+                if (!uuids.includes(key)) {
+                    delete newCheckedList[key];
+                }
+            }
+            this.props.setCheckedListOnStore(newCheckedList);
+        };
+
+        isAllSelected = (list: TCheckedList): boolean => {
+            for (const key in list) {
+                if (list[key] === false) return false;
+            }
+            return true;
+        };
+
+        isAnySelected = (): boolean => {
+            const { checkedList } = this.props;
+            if (!Object.keys(checkedList).length) return false;
+            for (const key in checkedList) {
+                if (checkedList[key] === true) return true;
+            }
+            return false;
+        };
+
+        handleSelectOne = (uuid: string): void => {
+            const { checkedList } = this.props;
+            const newCheckedList = { ...checkedList };
+            newCheckedList[uuid] = !checkedList[uuid];
+            this.setState({ isSelected: this.isAllSelected(newCheckedList) });
+            this.props.setCheckedListOnStore(newCheckedList);
+        };
+
+        handleSelectorSelect = (): void => {
+            const { checkedList } = this.props;
+            const { isSelected } = this.state;
+            isSelected ? this.handleSelectNone(checkedList) : this.handleSelectAll(checkedList);
+        };
+
+        handleSelectAll = (list: TCheckedList): void => {
+            if (Object.keys(list).length) {
+                const newCheckedList = { ...list };
+                for (const key in newCheckedList) {
+                    newCheckedList[key] = true;
+                }
+                this.setState({ isSelected: true });
+                this.props.setCheckedListOnStore(newCheckedList);
+            }
+        };
+
+        handleSelectNone = (list: TCheckedList): void => {
+            const newCheckedList = { ...list };
+            for (const key in newCheckedList) {
+                newCheckedList[key] = false;
+            }
+            this.setState({ isSelected: false });
+            this.props.setCheckedListOnStore(newCheckedList);
+        };
+
+        handleInvertSelect = (list: TCheckedList): void => {
+            if (Object.keys(list).length) {
+                const newCheckedList = { ...list };
+                for (const key in newCheckedList) {
+                    newCheckedList[key] = !list[key];
+                }
+                this.setState({ isSelected: this.isAllSelected(newCheckedList) });
+                this.props.setCheckedListOnStore(newCheckedList);
+            }
+        };
+
+        render() {
+            const { items, classes, columns, isNotFound } = this.props;
+            const { isLoaded } = this.state;
+            if (columns[0].name === this.checkBoxColumn.name) columns.shift();
+            columns.unshift(this.checkBoxColumn);
+            return (
+                <div className={classes.root}>
+                    <div className={classes.content}>
+                        <Table>
+                            <TableHead>
+                                <TableRow>{this.mapVisibleColumns(this.renderHeadCell)}</TableRow>
+                            </TableHead>
+                            <TableBody className={classes.tableBody}>{(isLoaded && !isNotFound) && items.map(this.renderBodyRow)}</TableBody>
+                        </Table>
+                        {(!isLoaded || isNotFound || items.length === 0) && this.renderNoItemsPlaceholder(this.props.columns)}
+                    </div>
+                </div>
+            );
+        }
+
+        renderNoItemsPlaceholder = (columns: DataColumns<T, any>) => {
+            const { isLoaded } = this.state;
+            const { working, isNotFound } = this.props;
+            const dirty = columns.some(column => getTreeDirty("")(column.filters));
+            if (isNotFound && isLoaded) {
+                return (
+                    <DataTableDefaultView 
+                        icon={this.props.defaultViewIcon} 
+                        messages={["No items found"]} 
+                    />
+                );
+            } else 
+            if (isLoaded === false || working === true) {
+                return (
+                    <DataTableDefaultView 
+                        icon={PendingIcon} 
+                        messages={["Loading data, please wait"]} 
+                    />
+                );
+            } else {
+                // isLoaded && !working && !isNotFound
+                return (
+                    <DataTableDefaultView
+                        icon={this.props.defaultViewIcon}
+                        messages={this.props.defaultViewMessages}
+                        filtersApplied={dirty}
+                    />
+                );
+            }
+        };
+
+        renderHeadCell = (column: DataColumn<T, any>, index: number) => {
+            const { name, key, renderHeader, filters, sort } = column;
+            const { onSortToggle, onFiltersChange, classes, checkedList } = this.props;
+            const { isSelected } = this.state;
+            return column.name === "checkBoxColumn" ? (
+                <TableCell
+                    key={key || index}
+                    className={classes.checkBoxCell}>
+                    <div className={classes.checkBoxHead}>
+                        <Tooltip title={this.state.isSelected ? "Deselect All" : "Select All"}>
+                            <input
+                                type="checkbox"
+                                className={classes.checkBox}
+                                checked={isSelected}
+                                disabled={!this.props.items.length}
+                                onChange={this.handleSelectorSelect}></input>
+                        </Tooltip>
+                        <DataTableMultiselectPopover
+                            name={`Options`}
+                            disabled={!this.props.items.length}
+                            options={this.multiselectOptions}
+                            checkedList={checkedList}></DataTableMultiselectPopover>
+                    </div>
+                </TableCell>
+            ) : (
+                <TableCell
+                    className={index === 1 ? classes.firstTableCell : classes.tableCell}
+                    key={key || index}>
+                    {renderHeader ? (
+                        renderHeader()
+                    ) : countNodes(filters) > 0 ? (
+                        <DataTableFiltersPopover
+                            name={`${name} filters`}
+                            mutuallyExclusive={column.mutuallyExclusiveFilters}
+                            onChange={filters => onFiltersChange && onFiltersChange(filters, column)}
+                            filters={filters}>
+                            {name}
+                        </DataTableFiltersPopover>
+                    ) : sort ? (
+                        <TableSortLabel
+                            active={sort.direction !== SortDirection.NONE}
+                            direction={sort.direction !== SortDirection.NONE ? sort.direction : undefined}
+                            IconComponent={this.ArrowIcon}
+                            hideSortIcon
+                            onClick={() => onSortToggle && onSortToggle(column)}>
+                            {name}
+                        </TableSortLabel>
+                    ) : (
+                        <span>{name}</span>
+                    )}
+                </TableCell>
+            );
+        };
+
+        ArrowIcon = ({ className, ...props }: SvgIconProps) => (
+            <IconButton
+                component="span"
+                className={this.props.classes.arrowButton}
+                tabIndex={-1}>
+                <ArrowDownwardIcon
+                    {...props}
+                    className={classnames(className, this.props.classes.arrow)}
+                />
+            </IconButton>
+        );
+
+        renderBodyRow = (item: any, index: number) => {
+            const { onRowClick, onRowDoubleClick, extractKey, classes, currentItemUuid, currentRoute } = this.props;
+            return (
+                <TableRow
+                    data-cy={'data-table-row'}
+                    hover
+                    key={extractKey ? extractKey(item) : index}
+                    onClick={event => onRowClick && onRowClick(event, item)}
+                    onContextMenu={this.handleRowContextMenu(item)}
+                    onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)}
+                    selected={item === currentItemUuid}>
+                    {this.mapVisibleColumns((column, index) => (
+                        <TableCell
+                            key={column.key || index}
+                            className={
+                                currentRoute === "/workflows"
+                                    ? classes.tableCellWorkflows
+                                    : index === 0
+                                    ? classes.checkBoxCell
+                                    : `${classes.tableCell} ${index === 1 ? classes.firstTableCell : ""}`
+                            }>
+                            {column.render(item)}
+                        </TableCell>
+                    ))}
+                </TableRow>
+            );
+        };
+
+        mapVisibleColumns = (fn: (column: DataColumn<T, any>, index: number) => React.ReactElement<any>) => {
+            return this.props.columns.filter(column => column.selected).map(fn);
+        };
+
+        handleRowContextMenu = (item: T) => (event: React.MouseEvent<HTMLElement>) => this.props.onContextMenu(event, item);
+    }
+);
diff --git a/services/workbench2/src/components/default-code-snippet/default-code-snippet.tsx b/services/workbench2/src/components/default-code-snippet/default-code-snippet.tsx
new file mode 100644 (file)
index 0000000..bdcfc10
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
+import { CodeSnippet, CodeSnippetDataProps } from 'components/code-snippet/code-snippet';
+import grey from '@material-ui/core/colors/grey';
+import { themeOptions } from 'common/custom-theme';
+
+const theme = createMuiTheme(Object.assign({}, themeOptions, {
+    overrides: {
+        MuiTypography: {
+            body1: {
+                color: grey["900"]
+            },
+            root: {
+                backgroundColor: grey["200"]
+            }
+        }
+    },
+    typography: {
+        fontFamily: 'monospace',
+        useNextVariants: true,
+    }
+}));
+
+export const DefaultCodeSnippet = (props: CodeSnippetDataProps) =>
+    <MuiThemeProvider theme={theme}>
+        <CodeSnippet {...props} />
+    </MuiThemeProvider>;
diff --git a/services/workbench2/src/components/default-code-snippet/default-virtual-code-snippet.tsx b/services/workbench2/src/components/default-code-snippet/default-virtual-code-snippet.tsx
new file mode 100644 (file)
index 0000000..581f0f4
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
+import { VirtualCodeSnippet, CodeSnippetDataProps } from 'components/code-snippet/virtual-code-snippet';
+import grey from '@material-ui/core/colors/grey';
+import { themeOptions } from 'common/custom-theme';
+
+const theme = createMuiTheme(Object.assign({}, themeOptions, {
+    overrides: {
+        MuiTypography: {
+            body1: {
+                color: grey["900"]
+            },
+            root: {
+                backgroundColor: grey["200"]
+            }
+        }
+    },
+    typography: {
+        fontFamily: 'monospace',
+        useNextVariants: true,
+    }
+}));
+
+export const DefaultVirtualCodeSnippet = (props: CodeSnippetDataProps) =>
+    <MuiThemeProvider theme={theme}>
+        <VirtualCodeSnippet {...props} />
+    </MuiThemeProvider>;
diff --git a/services/workbench2/src/components/default-view/default-view.tsx b/services/workbench2/src/components/default-view/default-view.tsx
new file mode 100644 (file)
index 0000000..588dcfa
--- /dev/null
@@ -0,0 +1,48 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from '../../common/custom-theme';
+import { Typography } from '@material-ui/core';
+import { IconType } from '../icon/icon';
+import classnames from "classnames";
+
+type CssRules = 'root' | 'icon' | 'message';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        textAlign: 'center'
+    },
+    icon: {
+        color: theme.palette.grey["500"],
+        fontSize: '4.5rem'
+    },
+    message: {
+        color: theme.palette.grey["500"]
+    }
+});
+
+export interface DefaultViewDataProps {
+    classRoot?: string;
+    messages: string[];
+    filtersApplied?: boolean;
+    classMessage?: string;
+    icon?: IconType;
+    classIcon?: string;
+}
+
+type DefaultViewProps = DefaultViewDataProps & WithStyles<CssRules>;
+
+export const DefaultView = withStyles(styles)(
+    ({ classes, classRoot, messages, classMessage, icon: Icon, classIcon }: DefaultViewProps) =>
+        <Typography className={classnames([classes.root, classRoot])} component="div">
+            {Icon && <Icon className={classnames([classes.icon, classIcon])} />}
+            {messages.map((msg: string, index: number) => {
+                return <Typography key={index}
+                    data-cy='default-view'
+                    className={classnames([classes.message, classMessage])}>{msg}</Typography>;
+            })}
+        </Typography>
+);
diff --git a/services/workbench2/src/components/details-attribute/details-attribute.tsx b/services/workbench2/src/components/details-attribute/details-attribute.tsx
new file mode 100644 (file)
index 0000000..019470c
--- /dev/null
@@ -0,0 +1,135 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import Typography from '@material-ui/core/Typography';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { Tooltip } from '@material-ui/core';
+import { CopyIcon } from 'components/icon/icon';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { ArvadosTheme } from 'common/custom-theme';
+import classnames from "classnames";
+import { Link } from 'react-router-dom';
+import { RootState } from "store/store";
+import { FederationConfig, getNavUrl } from "routes/routes";
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+
+type CssRules = 'attribute' | 'label' | 'value' | 'lowercaseValue' | 'link' | 'copyIcon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    attribute: {
+        marginBottom: ".6 rem"
+    },
+    label: {
+        boxSizing: 'border-box',
+        color: theme.palette.grey["600"],
+        width: '100%',
+        marginTop: "0.4em",
+    },
+    value: {
+        boxSizing: 'border-box',
+        alignItems: 'flex-start'
+    },
+    lowercaseValue: {
+        textTransform: 'lowercase'
+    },
+    link: {
+        color: theme.palette.primary.main,
+        textDecoration: 'none',
+        overflowWrap: 'break-word',
+        cursor: 'pointer'
+    },
+    copyIcon: {
+        marginLeft: theme.spacing.unit,
+        color: theme.palette.grey["600"],
+        cursor: 'pointer',
+        display: 'inline',
+        '& svg': {
+            fontSize: '1rem'
+        }
+    }
+});
+
+interface DetailsAttributeDataProps {
+    label: string;
+    classLabel?: string;
+    value?: React.ReactNode;
+    classValue?: string;
+    lowercaseValue?: boolean;
+    link?: string;
+    children?: React.ReactNode;
+    onValueClick?: () => void;
+    linkToUuid?: string;
+    copyValue?: string;
+    uuidEnhancer?: Function;
+}
+
+type DetailsAttributeProps = DetailsAttributeDataProps & WithStyles<CssRules> & FederationConfig & DispatchProp;
+
+const mapStateToProps = ({ auth }: RootState): FederationConfig => ({
+    localCluster: auth.localCluster,
+    remoteHostsConfig: auth.remoteHostsConfig,
+    sessions: auth.sessions
+});
+
+export const DetailsAttribute = connect(mapStateToProps)(withStyles(styles)(
+    class extends React.Component<DetailsAttributeProps> {
+
+        onCopy = (message: string) => {
+            this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                message,
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS
+            }));
+        }
+
+        render() {
+            const { uuidEnhancer, link, value, classes, linkToUuid,
+                localCluster, remoteHostsConfig, sessions } = this.props;
+            let valueNode: React.ReactNode;
+
+            if (linkToUuid) {
+                const uuid = uuidEnhancer ? uuidEnhancer(linkToUuid) : linkToUuid;
+                const linkUrl = getNavUrl(linkToUuid || "", { localCluster, remoteHostsConfig, sessions });
+                if (linkUrl[0] === '/') {
+                    valueNode = <Link to={linkUrl} className={classes.link}>{uuid}</Link>;
+                } else {
+                    valueNode = <a href={linkUrl} className={classes.link} target='_blank' rel="noopener noreferrer">{uuid}</a>;
+                }
+            } else if (link) {
+                valueNode = <a href={link} className={classes.link} target='_blank' rel="noopener noreferrer">{value}</a>;
+            } else {
+                valueNode = value;
+            }
+
+            return <DetailsAttributeComponent {...this.props} value={valueNode} onCopy={this.onCopy} />;
+        }
+    }
+));
+
+interface DetailsAttributeComponentProps {
+    value: React.ReactNode;
+    onCopy?: (msg: string) => void;
+}
+
+export const DetailsAttributeComponent = withStyles(styles)(
+    (props: DetailsAttributeDataProps & WithStyles<CssRules> & DetailsAttributeComponentProps) =>
+        <Typography component="div" className={props.classes.attribute} data-cy={`details-panel-${props.label.toLowerCase()}`}>
+            <Typography component="div" className={classnames([props.classes.label, props.classLabel])}>{props.label}</Typography>
+            <Typography
+                onClick={props.onValueClick}
+                component="div"
+                className={classnames([props.classes.value, props.classValue, { [props.classes.lowercaseValue]: props.lowercaseValue }])}>
+                {props.value}
+                {props.children}
+                {(props.linkToUuid || props.copyValue) && props.onCopy && <Tooltip title="Copy link to clipboard">
+                    <span className={props.classes.copyIcon}>
+                        <CopyToClipboard text={props.linkToUuid || props.copyValue || ""} onCopy={() => props.onCopy!("Copied")}>
+                            <CopyIcon />
+                        </CopyToClipboard>
+                    </span>
+                </Tooltip>}
+            </Typography>
+        </Typography>);
diff --git a/services/workbench2/src/components/dialog-actions/dialog-actions.tsx b/services/workbench2/src/components/dialog-actions/dialog-actions.tsx
new file mode 100644 (file)
index 0000000..6987a10
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DialogActions as MuiDialogActions } from '@material-ui/core/';
+import { StyleRulesCallback, withStyles } from '@material-ui/core';
+
+const styles: StyleRulesCallback<'root'> = theme => {
+    const margin = theme.spacing.unit * 3;
+    return {
+        root: {
+            marginRight: margin,
+            marginBottom: margin,
+            marginLeft: margin,
+        },
+    };
+};
+export const DialogActions = withStyles(styles)(MuiDialogActions);
diff --git a/services/workbench2/src/components/dropdown-menu/dropdown-menu.test.tsx b/services/workbench2/src/components/dropdown-menu/dropdown-menu.test.tsx
new file mode 100644 (file)
index 0000000..e5bd1d1
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { shallow, configure } from "enzyme";
+import { DropdownMenu } from "./dropdown-menu";
+import Adapter from 'enzyme-adapter-react-16';
+import { MenuItem, IconButton, Menu } from "@material-ui/core";
+import { PaginationRightArrowIcon } from "../icon/icon";
+
+configure({ adapter: new Adapter() });
+
+describe("<DropdownMenu />", () => {
+    it("renders menu icon", () => {
+        const dropdownMenu = shallow(<DropdownMenu id="test-menu" icon={<PaginationRightArrowIcon />} />);
+        expect(dropdownMenu.find(PaginationRightArrowIcon)).toHaveLength(1);
+    });
+
+    it("render menu items", () => {
+        const dropdownMenu = shallow(
+            <DropdownMenu id="test-menu" icon={<PaginationRightArrowIcon />}>
+                <MenuItem>Item 1</MenuItem>
+                <MenuItem>Item 2</MenuItem>
+            </DropdownMenu>
+        );
+        expect(dropdownMenu.find(MenuItem)).toHaveLength(2);
+    });
+
+    it("opens on menu icon click", () => {
+        const dropdownMenu = shallow(<DropdownMenu id="test-menu" icon={<PaginationRightArrowIcon />} />);
+        dropdownMenu.find(IconButton).simulate("click", {currentTarget: {}});
+        expect((dropdownMenu.state() as any).anchorEl).toBeDefined();
+    });
+
+    it("closes on menu click", () => {
+        const dropdownMenu = shallow(<DropdownMenu id="test-menu" icon={<PaginationRightArrowIcon />} />);
+        dropdownMenu.find(Menu).simulate("click", {currentTarget: {}});
+        expect((dropdownMenu.state() as any).anchorEl).toBeUndefined();
+    });
+
+});
diff --git a/services/workbench2/src/components/dropdown-menu/dropdown-menu.tsx b/services/workbench2/src/components/dropdown-menu/dropdown-menu.tsx
new file mode 100644 (file)
index 0000000..39cce04
--- /dev/null
@@ -0,0 +1,67 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import Menu from "@material-ui/core/Menu";
+import IconButton from "@material-ui/core/IconButton";
+import { PopoverOrigin } from "@material-ui/core/Popover";
+import { Tooltip } from "@material-ui/core";
+
+interface DropdownMenuProps {
+    id: string;
+    icon: React.ReactElement<any>;
+    title: string;
+}
+
+interface DropdownMenuState {
+    anchorEl: any;
+}
+
+export class DropdownMenu extends React.Component<DropdownMenuProps, DropdownMenuState> {
+    state = {
+        anchorEl: undefined,
+    };
+
+    transformOrigin: PopoverOrigin = {
+        vertical: -50,
+        horizontal: 0,
+    };
+
+    render() {
+        const { icon, id, children, title } = this.props;
+        const { anchorEl } = this.state;
+        return (
+            <div>
+                <Tooltip
+                    title={title}
+                    disableFocusListener>
+                    <IconButton
+                        aria-owns={anchorEl ? id : undefined}
+                        aria-haspopup="true"
+                        color="inherit"
+                        onClick={this.handleOpen}>
+                        {icon}
+                    </IconButton>
+                </Tooltip>
+                <Menu
+                    id={id}
+                    anchorEl={anchorEl}
+                    open={Boolean(anchorEl)}
+                    onClose={this.handleClose}
+                    onClick={this.handleClose}
+                    transformOrigin={this.transformOrigin}>
+                    {children}
+                </Menu>
+            </div>
+        );
+    }
+
+    handleClose = () => {
+        this.setState({ anchorEl: undefined });
+    };
+
+    handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
+        this.setState({ anchorEl: event.currentTarget });
+    };
+}
diff --git a/services/workbench2/src/components/file-tree/file-thumbnail.test.tsx b/services/workbench2/src/components/file-tree/file-thumbnail.test.tsx
new file mode 100644 (file)
index 0000000..3d93c89
--- /dev/null
@@ -0,0 +1,43 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { configure, mount } from "enzyme";
+import { FileThumbnail } from "./file-thumbnail";
+import { CollectionFileType } from '../../models/collection-file';
+import Adapter from 'enzyme-adapter-react-16';
+import { Provider } from "react-redux";
+import { combineReducers, createStore } from "redux";
+
+configure({ adapter: new Adapter() });
+
+let store;
+
+describe("<FileThumbnail />", () => {
+    let file;
+
+    beforeEach(() => {
+        const initialAuthState = {
+            config: {
+                keepWebServiceUrl: 'http://example.com/',
+                keepWebInlineServiceUrl: 'http://*.collections.example.com/',
+            }
+        }
+        store = createStore(combineReducers({
+            auth: (state: any = initialAuthState, action: any) => state,
+        }));
+
+        file = {
+            name: 'test-image.jpg',
+            type: CollectionFileType.FILE,
+            url: 'http://example.com/c=zzzzz-4zz18-0123456789abcde/t=v2/zzzzz-gj3su-0123456789abcde/xxxxxxtokenxxxxx/test-image.jpg',
+            size: 300
+        };
+    });
+
+    it("renders file thumbnail with proper src", () => {
+        const fileThumbnail = mount(<Provider store={store}><FileThumbnail file={file} /></Provider>);
+        expect(fileThumbnail.html()).toBe('<img class="Component-thumbnail-1" alt="test-image.jpg" src="http://zzzzz-4zz18-0123456789abcde.collections.example.com/test-image.jpg?api_token=v2/zzzzz-gj3su-0123456789abcde/xxxxxxtokenxxxxx">');
+    });
+});
diff --git a/services/workbench2/src/components/file-tree/file-thumbnail.tsx b/services/workbench2/src/components/file-tree/file-thumbnail.tsx
new file mode 100644 (file)
index 0000000..aeb8d68
--- /dev/null
@@ -0,0 +1,49 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import isImage from 'is-image';
+import { withStyles, WithStyles } from '@material-ui/core';
+import { FileTreeData } from 'components/file-tree/file-tree-data';
+import { CollectionFileType } from 'models/collection-file';
+import { getInlineFileUrl, sanitizeToken } from "views-components/context-menu/actions/helpers";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+
+interface FileThumbnailProps {
+    file: FileTreeData;
+}
+
+export const FileThumbnail =
+    ({ file }: FileThumbnailProps) =>
+        file.type === CollectionFileType.FILE && isImage(file.name)
+            ? <ImageFileThumbnail file={file} />
+            : null;
+
+type ImageFileThumbnailCssRules = 'thumbnail';
+
+const imageFileThumbnailStyle = withStyles<ImageFileThumbnailCssRules>(theme => ({
+    thumbnail: {
+        maxWidth: 250,
+        margin: `${theme.spacing.unit}px 0`,
+    }
+}));
+
+interface ImageFileThumbnailProps {
+    keepWebServiceUrl: string;
+    keepWebInlineServiceUrl: string;
+}
+
+const mapStateToProps = ({ auth }: RootState): ImageFileThumbnailProps => ({
+    keepWebServiceUrl: auth.config.keepWebServiceUrl,
+    keepWebInlineServiceUrl: auth.config.keepWebInlineServiceUrl,
+});
+
+const ImageFileThumbnail = connect(mapStateToProps)(imageFileThumbnailStyle(
+    ({ classes, file, keepWebServiceUrl, keepWebInlineServiceUrl }: WithStyles<ImageFileThumbnailCssRules> & FileThumbnailProps & ImageFileThumbnailProps) =>
+        <img
+            className={classes.thumbnail}
+            alt={file.name}
+            src={sanitizeToken(getInlineFileUrl(file.url, keepWebServiceUrl, keepWebInlineServiceUrl))} />
+));
diff --git a/services/workbench2/src/components/file-tree/file-tree-data.ts b/services/workbench2/src/components/file-tree/file-tree-data.ts
new file mode 100644 (file)
index 0000000..4154611
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface FileTreeData {
+    name: string;
+    type: string;
+    url: string;
+    size?: number;
+}
diff --git a/services/workbench2/src/components/file-tree/file-tree-item.tsx b/services/workbench2/src/components/file-tree/file-tree-item.tsx
new file mode 100644 (file)
index 0000000..d94c729
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DirectoryIcon, DefaultIcon, FileIcon } from "../icon/icon";
+
+export const getIcon = (type: string) => {
+    switch (type) {
+        case 'directory':
+            return DirectoryIcon;
+        case 'file':
+            return FileIcon;
+        default:
+            return DefaultIcon;
+    }
+};
+
diff --git a/services/workbench2/src/components/file-upload/file-upload.tsx b/services/workbench2/src/components/file-upload/file-upload.tsx
new file mode 100644 (file)
index 0000000..e6c1514
--- /dev/null
@@ -0,0 +1,213 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import classnames from 'classnames';
+import {
+    Grid,
+    StyleRulesCallback,
+    Table, TableBody, TableCell, TableHead, TableRow,
+    Typography,
+    WithStyles,
+    IconButton
+} from '@material-ui/core';
+import { withStyles } from '@material-ui/core';
+import Dropzone from 'react-dropzone';
+import { CloudUploadIcon, RemoveIcon } from "../icon/icon";
+import { formatFileSize, formatProgress, formatUploadSpeed } from "common/formatters";
+import { UploadFile } from 'store/file-uploader/file-uploader-actions';
+
+type CssRules = "root" | "dropzone" | "dropzoneWrapper" | "container" | "uploadIcon"
+    | "dropzoneBorder" | "dropzoneBorderLeft" | "dropzoneBorderRight" | "dropzoneBorderTop" | "dropzoneBorderBottom"
+    | "dropzoneBorderHorzActive" | "dropzoneBorderVertActive" | "deleteButton" | "deleteButtonDisabled" | "deleteIcon";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    root: {
+    },
+    dropzone: {
+        width: "100%",
+        height: "100%",
+        overflow: "auto"
+    },
+    dropzoneWrapper: {
+        width: "100%",
+        height: "200px",
+        position: "relative",
+        border: "1px solid rgba(0, 0, 0, 0.42)",
+        boxSizing: 'border-box',
+    },
+    dropzoneBorder: {
+        content: "",
+        position: "absolute",
+        transition: "transform 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms",
+        pointerEvents: "none",
+        backgroundColor: "#6a1b9a"
+    },
+    dropzoneBorderLeft: {
+        left: -1,
+        top: -1,
+        bottom: -1,
+        width: 2,
+        transform: "scaleY(0)",
+    },
+    dropzoneBorderRight: {
+        right: -1,
+        top: -1,
+        bottom: -1,
+        width: 2,
+        transform: "scaleY(0)",
+    },
+    dropzoneBorderTop: {
+        left: 0,
+        right: 0,
+        top: -1,
+        height: 2,
+        transform: "scaleX(0)",
+    },
+    dropzoneBorderBottom: {
+        left: 0,
+        right: 0,
+        bottom: -1,
+        height: 2,
+        transform: "scaleX(0)",
+    },
+    dropzoneBorderHorzActive: {
+        transform: "scaleY(1)"
+    },
+    dropzoneBorderVertActive: {
+        transform: "scaleX(1)"
+    },
+    container: {
+        height: "100%"
+    },
+    uploadIcon: {
+        verticalAlign: "middle"
+    },
+    deleteButton: {
+        cursor: "pointer"
+    },
+    deleteButtonDisabled: {
+        cursor: "not-allowed"
+    },
+    deleteIcon: {
+        marginLeft: "-6px"
+    }
+});
+
+interface FileUploadPropsData {
+    files: UploadFile[];
+    disabled: boolean;
+    onDrop: (files: File[]) => void;
+    onDelete: (file: UploadFile) => void;
+}
+
+interface FileUploadState {
+    focused: boolean;
+}
+
+export type FileUploadProps = FileUploadPropsData & WithStyles<CssRules>;
+
+export const FileUpload = withStyles(styles)(
+    class extends React.Component<FileUploadProps, FileUploadState> {
+        constructor(props: FileUploadProps) {
+            super(props);
+            this.state = {
+                focused: false
+            };
+        }
+        onDelete = (event: React.MouseEvent<HTMLTableCellElement>, file: UploadFile): void => {
+            const { onDelete, disabled } = this.props;
+
+            event.stopPropagation();
+
+            if (!disabled) {
+                onDelete(file);
+            }
+
+            let interval = setInterval(() => {
+                const key = Object.keys((window as any).cancelTokens).find(key => key.indexOf(file.file.name) > -1);
+
+                if (key) {
+                    clearInterval(interval);
+                    (window as any).cancelTokens[key]();
+                    delete (window as any).cancelTokens[key];
+                }
+            }, 100);
+
+        }
+        render() {
+            const { classes, onDrop, disabled, files } = this.props;
+            return <div className={"file-upload-dropzone " + classes.dropzoneWrapper}>
+                <div className={classnames(classes.dropzoneBorder, classes.dropzoneBorderLeft, { [classes.dropzoneBorderHorzActive]: this.state.focused })} />
+                <div className={classnames(classes.dropzoneBorder, classes.dropzoneBorderRight, { [classes.dropzoneBorderHorzActive]: this.state.focused })} />
+                <div className={classnames(classes.dropzoneBorder, classes.dropzoneBorderTop, { [classes.dropzoneBorderVertActive]: this.state.focused })} />
+                <div className={classnames(classes.dropzoneBorder, classes.dropzoneBorderBottom, { [classes.dropzoneBorderVertActive]: this.state.focused })} />
+                <Dropzone className={classes.dropzone}
+                    onDrop={files => onDrop(files)}
+                    onClick={() => {
+                        const el = document.getElementsByClassName("file-upload-dropzone")[0];
+                        const inputs = el.getElementsByTagName("input");
+                        if (inputs.length > 0) {
+                            inputs[0].focus();
+                        }
+                    }}
+                    data-cy="drag-and-drop"
+                    disabled={disabled}
+                    inputProps={{
+                        onFocus: () => {
+                            this.setState({
+                                focused: true
+                            });
+                        },
+                        onBlur: () => {
+                            this.setState({
+                                focused: false
+                            });
+                        }
+                    }}>
+                    {files.length === 0 &&
+                        <Grid container justify="center" alignItems="center" className={classes.container}>
+                            <Grid item component={"span"}>
+                                <Typography variant='subtitle1'>
+                                    <CloudUploadIcon className={classes.uploadIcon} /> Drag and drop data or click to browse
+                            </Typography>
+                            </Grid>
+                        </Grid>}
+                    {files.length > 0 &&
+                        <Table style={{ width: "100%" }}>
+                            <TableHead>
+                                <TableRow>
+                                    <TableCell>File name</TableCell>
+                                    <TableCell>File size</TableCell>
+                                    <TableCell>Upload speed</TableCell>
+                                    <TableCell>Upload progress</TableCell>
+                                    <TableCell>Delete</TableCell>
+                                </TableRow>
+                            </TableHead>
+                            <TableBody>
+                                {files.map(f =>
+                                    <TableRow key={f.id}>
+                                        <TableCell>{f.file.name}</TableCell>
+                                        <TableCell>{formatFileSize(f.file.size)}</TableCell>
+                                        <TableCell>{formatUploadSpeed(f.prevLoaded, f.loaded, f.prevTime, f.currentTime)}</TableCell>
+                                        <TableCell>{formatProgress(f.loaded, f.total)}</TableCell>
+                                        <TableCell>
+                                            <IconButton
+                                                aria-label="Remove"
+                                                onClick={(event: React.MouseEvent<HTMLTableCellElement>) => this.onDelete(event, f)}
+                                                className={disabled ? classnames(classes.deleteButtonDisabled, classes.deleteIcon) : classnames(classes.deleteButton, classes.deleteIcon)}
+                                            >
+                                                <RemoveIcon />
+                                            </IconButton>
+                                        </TableCell>
+                                    </TableRow>
+                                )}
+                            </TableBody>
+                        </Table>
+                    }
+                </Dropzone>
+            </div>;
+        }
+    }
+);
diff --git a/services/workbench2/src/components/float-input/float-input.tsx b/services/workbench2/src/components/float-input/float-input.tsx
new file mode 100644 (file)
index 0000000..1b90930
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Input } from '@material-ui/core';
+import { InputProps } from '@material-ui/core/Input';
+
+export class FloatInput extends React.Component<InputProps> {
+    state = {
+        endsWithDecimalSeparator: false,
+    };
+
+    handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+        const { onChange = () => { return; } } = this.props;
+        const [, fraction] = event.target.value.split('.');
+        this.setState({ endsWithDecimalSeparator: fraction === '' });
+        const parsedValue = parseFloat(event.target.value).toString();
+        event.target.value = parsedValue;
+        onChange(event);
+    }
+
+    render() {
+        const parsedValue = parseFloat(typeof this.props.value === 'string' ? this.props.value : '');
+        const value = isNaN(parsedValue) ? '' : parsedValue.toString();
+        const props = {
+            ...this.props,
+            value: value + (this.state.endsWithDecimalSeparator ? '.' : ''),
+            onChange: this.handleChange,
+        };
+        return <Input {...props} />;
+    }
+}
diff --git a/services/workbench2/src/components/form-dialog/form-dialog.tsx b/services/workbench2/src/components/form-dialog/form-dialog.tsx
new file mode 100644 (file)
index 0000000..b50504a
--- /dev/null
@@ -0,0 +1,104 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core/';
+import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+
+type CssRules = "button" | "lastButton" | "form" | "formContainer" | "dialogTitle" | "progressIndicator" | "dialogActions";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    button: {
+        marginLeft: theme.spacing.unit
+    },
+    lastButton: {
+        marginLeft: theme.spacing.unit,
+        marginRight: "0",
+    },
+    form: {
+        display: 'flex',
+        overflowY: 'auto',
+        flexDirection: 'column',
+        flex: '0 1 auto',
+    },
+    formContainer: {
+        display: "flex",
+        flexDirection: "column",
+        paddingBottom: "0",
+    },
+    dialogTitle: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    progressIndicator: {
+        position: "absolute",
+        minWidth: "20px",
+    },
+    dialogActions: {
+        marginBottom: theme.spacing.unit,
+        marginRight: theme.spacing.unit * 3,
+    }
+});
+
+interface DialogProjectDataProps {
+    cancelLabel?: string;
+    dialogTitle: string;
+    formFields: React.ComponentType<InjectedFormProps<any> & WithDialogProps<any>>;
+    submitLabel?: string;
+    cancelCallback?: Function;
+    enableWhenPristine?: boolean;
+    doNotDisableCancel?: boolean;
+}
+
+type DialogProjectProps = DialogProjectDataProps & WithDialogProps<{}> & InjectedFormProps<any> & WithStyles<CssRules>;
+
+export const FormDialog = withStyles(styles)((props: DialogProjectProps) =>
+    <Dialog
+        open={props.open}
+        onClose={props.closeDialog}
+        disableBackdropClick
+        disableEscapeKeyDown={props.submitting}
+        fullWidth
+        maxWidth='md'>
+        <form data-cy='form-dialog' className={props.classes.form}>
+            <DialogTitle className={props.classes.dialogTitle}>
+                {props.dialogTitle}
+            </DialogTitle>
+            <DialogContent className={props.classes.formContainer}>
+                <props.formFields {...props} />
+            </DialogContent>
+            <DialogActions className={props.classes.dialogActions}>
+                <Button
+                    data-cy='form-cancel-btn'
+                    onClick={() => {
+                        props.closeDialog();
+
+                        if (props.cancelCallback) {
+                            props.cancelCallback();
+                            props.reset();
+                            props.initialize({});
+                        }
+                    }}
+                    className={props.classes.button}
+                    color="primary"
+                    disabled={props.doNotDisableCancel ? false : props.submitting}>
+                    {props.cancelLabel || 'Cancel'}
+                </Button>
+                <Button
+                    data-cy='form-submit-btn'
+                    type="submit"
+                    onClick={props.handleSubmit}
+                    className={props.classes.lastButton}
+                    color="primary"
+                    disabled={props.invalid || props.submitting || (props.pristine && !props.enableWhenPristine)}
+                    variant="contained">
+                    {props.submitLabel || 'Submit'}
+                    {props.submitting && <CircularProgress size={20} className={props.classes.progressIndicator} />}
+                </Button>
+            </DialogActions>
+        </form>
+    </Dialog>
+);
diff --git a/services/workbench2/src/components/form-field/form-field.tsx b/services/workbench2/src/components/form-field/form-field.tsx
new file mode 100644 (file)
index 0000000..81e0881
--- /dev/null
@@ -0,0 +1,41 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WrappedFieldProps, WrappedFieldInputProps } from 'redux-form';
+import { FormGroup, FormLabel, FormHelperText } from '@material-ui/core';
+
+interface FormFieldCustomProps {
+    children: <P>(props: WrappedFieldInputProps) => React.ReactElement<P>;
+    label?: string;
+    helperText?: string;
+    required?: boolean;
+}
+
+export type FormFieldProps = FormFieldCustomProps & WrappedFieldProps;
+
+export const FormField = ({ children, ...props }: FormFieldProps & WrappedFieldProps) => {
+    return (
+        <FormGroup>
+
+            <FormLabel
+                focused={props.meta.active}
+                required={props.required}
+                error={props.meta.touched && !!props.meta.error}>
+                {props.label}
+            </FormLabel>
+
+            { children(props.input) }
+
+            <FormHelperText error={props.meta.touched && !!props.meta.error}>
+                {
+                    props.meta.touched && props.meta.error
+                        ? props.meta.error
+                        : props.helperText
+                }
+            </FormHelperText>
+
+        </FormGroup>
+    );
+};
diff --git a/services/workbench2/src/components/icon/icon.tsx b/services/workbench2/src/components/icon/icon.tsx
new file mode 100644 (file)
index 0000000..08c2e8f
--- /dev/null
@@ -0,0 +1,285 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Badge, SvgIcon, Tooltip } from "@material-ui/core";
+import Add from "@material-ui/icons/Add";
+import ArrowBack from "@material-ui/icons/ArrowBack";
+import ArrowDropDown from "@material-ui/icons/ArrowDropDown";
+import Build from "@material-ui/icons/Build";
+import Cached from "@material-ui/icons/Cached";
+import DescriptionIcon from "@material-ui/icons/Description";
+import ChevronLeft from "@material-ui/icons/ChevronLeft";
+import CloudUpload from "@material-ui/icons/CloudUpload";
+import Code from "@material-ui/icons/Code";
+import Create from "@material-ui/icons/Create";
+import ImportContacts from "@material-ui/icons/ImportContacts";
+import ChevronRight from "@material-ui/icons/ChevronRight";
+import Close from "@material-ui/icons/Close";
+import ContentCopy from "@material-ui/icons/FileCopyOutlined";
+import CreateNewFolder from "@material-ui/icons/CreateNewFolder";
+import Delete from "@material-ui/icons/Delete";
+import DeviceHub from "@material-ui/icons/DeviceHub";
+import Edit from "@material-ui/icons/Edit";
+import ErrorRoundedIcon from "@material-ui/icons/ErrorRounded";
+import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
+import FlipToFront from "@material-ui/icons/FlipToFront";
+import Folder from "@material-ui/icons/Folder";
+import FolderShared from "@material-ui/icons/FolderShared";
+import Pageview from "@material-ui/icons/Pageview";
+import GetApp from "@material-ui/icons/GetApp";
+import Help from "@material-ui/icons/Help";
+import HelpOutline from "@material-ui/icons/HelpOutline";
+import History from "@material-ui/icons/History";
+import Inbox from "@material-ui/icons/Inbox";
+import Memory from "@material-ui/icons/Memory";
+import MoveToInbox from "@material-ui/icons/MoveToInbox";
+import Info from "@material-ui/icons/Info";
+import Input from "@material-ui/icons/Input";
+import InsertDriveFile from "@material-ui/icons/InsertDriveFile";
+import LastPage from "@material-ui/icons/LastPage";
+import LibraryBooks from "@material-ui/icons/LibraryBooks";
+import ListAlt from "@material-ui/icons/ListAlt";
+import Menu from "@material-ui/icons/Menu";
+import MoreVert from "@material-ui/icons/MoreVert";
+import MoreHoriz from "@material-ui/icons/MoreHoriz";
+import Mail from "@material-ui/icons/Mail";
+import Notifications from "@material-ui/icons/Notifications";
+import OpenInNew from "@material-ui/icons/OpenInNew";
+import People from "@material-ui/icons/People";
+import Person from "@material-ui/icons/Person";
+import PersonAdd from "@material-ui/icons/PersonAdd";
+import PlayArrow from "@material-ui/icons/PlayArrow";
+import Public from "@material-ui/icons/Public";
+import RateReview from "@material-ui/icons/RateReview";
+import RestoreFromTrash from "@material-ui/icons/History";
+import Search from "@material-ui/icons/Search";
+import SettingsApplications from "@material-ui/icons/SettingsApplications";
+import SettingsEthernet from "@material-ui/icons/SettingsEthernet";
+import Settings from "@material-ui/icons/Settings";
+import Star from "@material-ui/icons/Star";
+import StarBorder from "@material-ui/icons/StarBorder";
+import Warning from "@material-ui/icons/Warning";
+import VpnKey from "@material-ui/icons/VpnKey";
+import LinkOutlined from "@material-ui/icons/LinkOutlined";
+import RemoveRedEye from "@material-ui/icons/RemoveRedEye";
+import Computer from "@material-ui/icons/Computer";
+import WrapText from "@material-ui/icons/WrapText";
+import TextIncrease from "@material-ui/icons/ZoomIn";
+import TextDecrease from "@material-ui/icons/ZoomOut";
+import FullscreenSharp from "@material-ui/icons/FullscreenSharp";
+import FullscreenExitSharp from "@material-ui/icons/FullscreenExitSharp";
+import ExitToApp from "@material-ui/icons/ExitToApp";
+import CheckCircleOutline from "@material-ui/icons/CheckCircleOutline";
+import RemoveCircleOutline from "@material-ui/icons/RemoveCircleOutline";
+import NotInterested from "@material-ui/icons/NotInterested";
+import Image from "@material-ui/icons/Image";
+import Stop from "@material-ui/icons/Stop";
+import FileCopy from "@material-ui/icons/FileCopy";
+import ShowChart from "@material-ui/icons/ShowChart";
+
+// Import FontAwesome icons
+import { library } from "@fortawesome/fontawesome-svg-core";
+import { faPencilAlt, faSlash, faUsers, faEllipsisH } from "@fortawesome/free-solid-svg-icons";
+import { FormatAlignLeft } from "@material-ui/icons";
+library.add(faPencilAlt, faSlash, faUsers, faEllipsisH);
+
+export const FreezeIcon: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M20.79,13.95L18.46,14.57L16.46,13.44V10.56L18.46,9.43L20.79,10.05L21.31,8.12L19.54,7.65L20,5.88L18.07,5.36L17.45,7.69L15.45,8.82L13,7.38V5.12L14.71,3.41L13.29,2L12,3.29L10.71,2L9.29,3.41L11,5.12V7.38L8.5,8.82L6.5,7.69L5.92,5.36L4,5.88L4.47,7.65L2.7,8.12L3.22,10.05L5.55,9.43L7.55,10.56V13.45L5.55,14.58L3.22,13.96L2.7,15.89L4.47,16.36L4,18.12L5.93,18.64L6.55,16.31L8.55,15.18L11,16.62V18.88L9.29,20.59L10.71,22L12,20.71L13.29,22L14.7,20.59L13,18.88V16.62L15.5,15.17L17.5,16.3L18.12,18.63L20,18.12L19.53,16.35L21.3,15.88L20.79,13.95M9.5,10.56L12,9.11L14.5,10.56V13.44L12,14.89L9.5,13.44V10.56Z" />
+    </SvgIcon>
+);
+
+export const UnfreezeIcon: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M11 5.12L9.29 3.41L10.71 2L12 3.29L13.29 2L14.71 3.41L13 5.12V7.38L15.45 8.82L17.45 7.69L18.07 5.36L20 5.88L19.54 7.65L21.31 8.12L20.79 10.05L18.46 9.43L16.46 10.56V13.26L14.5 11.3V10.56L12.74 9.54L10.73 7.53L11 7.38V5.12M18.46 14.57L16.87 13.67L19.55 16.35L21.3 15.88L20.79 13.95L18.46 14.57M13 16.62V18.88L14.7 20.59L13.29 22L12 20.71L10.71 22L9.29 20.59L11 18.88V16.62L8.55 15.18L6.55 16.31L5.93 18.64L4 18.12L4.47 16.36L2.7 15.89L3.22 13.96L5.55 14.58L7.55 13.45V10.56L5.55 9.43L3.22 10.05L2.7 8.12L4.47 7.65L4 5.89L1.11 3L2.39 1.73L22.11 21.46L20.84 22.73L14.1 16L13 16.62M12 14.89L12.63 14.5L9.5 11.39V13.44L12 14.89Z" />
+    </SvgIcon>
+);
+
+export const PendingIcon = (props: any) => (
+    <span {...props}>
+        <span className="fas fa-ellipsis-h" />
+    </span>
+);
+
+export const ReadOnlyIcon = (props: any) => (
+    <span {...props}>
+        <div className="fa-layers fa-1x fa-fw">
+            <span
+                className="fas fa-slash"
+                data-fa-mask="fas fa-pencil-alt"
+                data-fa-transform="down-1.5"
+            />
+            <span className="fas fa-slash" />
+        </div>
+    </span>
+);
+
+export const GroupsIcon = (props: any) => (
+    <span {...props}>
+        <span className="fas fa-users" />
+    </span>
+);
+
+export const CollectionOldVersionIcon = (props: any) => (
+    <Tooltip title="Old version">
+        <Badge badgeContent={<History fontSize="small" />}>
+            <CollectionIcon {...props} />
+        </Badge>
+    </Tooltip>
+);
+
+// https://materialdesignicons.com/icon/image-off
+export const ImageOffIcon = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M21 17.2L6.8 3H19C20.1 3 21 3.9 21 5V17.2M20.7 22L19.7 21H5C3.9 21 3 20.1 3 19V4.3L2 3.3L3.3 2L22 20.7L20.7 22M16.8 18L12.9 14.1L11 16.5L8.5 13.5L5 18H16.8Z" />
+    </SvgIcon>
+);
+
+// https://materialdesignicons.com/icon/inbox-arrow-up
+export const OutputIcon: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M14,14H10V11H8L12,7L16,11H14V14M16,11M5,15V5H19V15H15A3,3 0 0,1 12,18A3,3 0 0,1 9,15H5M19,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3" />
+    </SvgIcon>
+);
+
+// https://pictogrammers.com/library/mdi/icon/file-move/
+export const FileMoveIcon: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M14,17H18V14L23,18.5L18,23V20H14V17M13,9H18.5L13,3.5V9M6,2H14L20,8V12.34C19.37,12.12 18.7,12 18,12A6,6 0 0,0 12,18C12,19.54 12.58,20.94 13.53,22H6C4.89,22 4,21.1 4,20V4A2,2 0 0,1 6,2Z" />
+    </SvgIcon>
+);
+
+// https://pictogrammers.com/library/mdi/icon/checkbox-multiple-outline/
+export const CheckboxMultipleOutline: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M20,16H8V4H20V16M16,20V22H4A2,2 0 0,1 2,20V7H4V20H16M18.53,8.06L17.47,7L12.59,11.88L10.47,9.76L9.41,10.82L12.59,14L18.53,8.06Z" />
+    </SvgIcon>
+);
+
+// https://pictogrammers.com/library/mdi/icon/checkbox-multiple-blank-outline/
+export const CheckboxMultipleBlankOutline: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M20,16V4H8V16H20M22,16A2,2 0 0,1 20,18H8C6.89,18 6,17.1 6,16V4C6,2.89 6.89,2 8,2H20A2,2 0 0,1 22,4V16M16,20V22H4A2,2 0 0,1 2,20V7H4V20H16Z" />
+    </SvgIcon>
+);
+
+//https://pictogrammers.com/library/mdi/icon/console/
+export const TerminalIcon: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z" />
+    </SvgIcon>
+)
+
+//https://pictogrammers.com/library/mdi/icon/chevron-double-right/
+export const DoubleRightArrows: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M5.59,7.41L7,6L13,12L7,18L5.59,16.59L10.17,12L5.59,7.41M11.59,7.41L13,6L19,12L13,18L11.59,16.59L16.17,12L11.59,7.41Z" />
+    </SvgIcon>
+)
+
+//https://pictogrammers.com/library/memory/icon/box-light-vertical/
+export const VerticalLineDivider: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M12 0V22H10V0H12Z" />
+    </SvgIcon>
+)
+
+export type IconType = React.SFC<{ className?: string; style?: object }>;
+
+export const AddIcon: IconType = props => <Add {...props} />;
+export const AddFavoriteIcon: IconType = props => <StarBorder {...props} />;
+export const AdminMenuIcon: IconType = props => <Build {...props} />;
+export const AdvancedIcon: IconType = props => <SettingsApplications {...props} />;
+export const AttributesIcon: IconType = props => <ListAlt {...props} />;
+export const BackIcon: IconType = props => <ArrowBack {...props} />;
+export const CustomizeTableIcon: IconType = props => <Menu {...props} />;
+export const CommandIcon: IconType = props => <LastPage {...props} />;
+export const CopyIcon: IconType = props => <ContentCopy {...props} />;
+export const FileCopyIcon: IconType = props => <FileCopy {...props} />;
+export const CollectionIcon: IconType = props => <LibraryBooks {...props} />;
+export const CloseIcon: IconType = props => <Close {...props} />;
+export const CloudUploadIcon: IconType = props => <CloudUpload {...props} />;
+export const DefaultIcon: IconType = props => <RateReview {...props} />;
+export const DetailsIcon: IconType = props => <Info {...props} />;
+export const DirectoryIcon: IconType = props => <Folder {...props} />;
+export const DownloadIcon: IconType = props => <GetApp {...props} />;
+export const EditSavedQueryIcon: IconType = props => <Create {...props} />;
+export const ExpandIcon: IconType = props => <ExpandMoreIcon {...props} />;
+export const ErrorIcon: IconType = props => (
+    <ErrorRoundedIcon
+        style={{ color: "#ff0000" }}
+        {...props}
+    />
+);
+export const FavoriteIcon: IconType = props => <Star {...props} />;
+export const FileIcon: IconType = props => <DescriptionIcon {...props} />;
+export const HelpIcon: IconType = props => <Help {...props} />;
+export const HelpOutlineIcon: IconType = props => <HelpOutline {...props} />;
+export const ImportContactsIcon: IconType = props => <ImportContacts {...props} />;
+export const InfoIcon: IconType = props => <Info {...props} />;
+export const FileInputIcon: IconType = props => <InsertDriveFile {...props} />;
+export const KeyIcon: IconType = props => <VpnKey {...props} />;
+export const LogIcon: IconType = props => <SettingsEthernet {...props} />;
+export const MailIcon: IconType = props => <Mail {...props} />;
+export const MaximizeIcon: IconType = props => <FullscreenSharp {...props} />;
+export const ResourceIcon: IconType = props => <Memory {...props} />;
+export const UnMaximizeIcon: IconType = props => <FullscreenExitSharp {...props} />;
+export const MoreVerticalIcon: IconType = props => <MoreVert {...props} />;
+export const MoreHorizontalIcon: IconType = props => <MoreHoriz {...props} />;
+export const MoveToIcon: IconType = props => <Input {...props} />;
+export const NewProjectIcon: IconType = props => <CreateNewFolder {...props} />;
+export const NotificationIcon: IconType = props => <Notifications {...props} />;
+export const OpenIcon: IconType = props => <OpenInNew {...props} />;
+export const InputIcon: IconType = props => <MoveToInbox {...props} />;
+export const PaginationDownIcon: IconType = props => <ArrowDropDown {...props} />;
+export const PaginationLeftArrowIcon: IconType = props => <ChevronLeft {...props} />;
+export const PaginationRightArrowIcon: IconType = props => <ChevronRight {...props} />;
+export const ProcessIcon: IconType = props => <Settings {...props} />;
+export const ProjectIcon: IconType = props => <Folder {...props} />;
+export const FilterGroupIcon: IconType = props => <Pageview {...props} />;
+export const ProjectsIcon: IconType = props => <Inbox {...props} />;
+export const ProvenanceGraphIcon: IconType = props => <DeviceHub {...props} />;
+export const RemoveIcon: IconType = props => <Delete {...props} />;
+export const RemoveFavoriteIcon: IconType = props => <Star {...props} />;
+export const PublicFavoriteIcon: IconType = props => <Public {...props} />;
+export const RenameIcon: IconType = props => <Edit {...props} />;
+export const RestoreVersionIcon: IconType = props => <FlipToFront {...props} />;
+export const RestoreFromTrashIcon: IconType = props => <RestoreFromTrash {...props} />;
+export const ReRunProcessIcon: IconType = props => <Cached {...props} />;
+export const SearchIcon: IconType = props => <Search {...props} />;
+export const ShareIcon: IconType = props => <PersonAdd {...props} />;
+export const ShareMeIcon: IconType = props => <People {...props} />;
+export const SidePanelRightArrowIcon: IconType = props => <PlayArrow {...props} />;
+export const TrashIcon: IconType = props => <Delete {...props} />;
+export const UserPanelIcon: IconType = props => <Person {...props} />;
+export const UsedByIcon: IconType = props => <Folder {...props} />;
+export const WorkflowIcon: IconType = props => <Code {...props} />;
+export const WarningIcon: IconType = props => (
+    <Warning
+        style={{ color: "#fbc02d", height: "30px", width: "30px" }}
+        {...props}
+    />
+);
+export const Link: IconType = props => <LinkOutlined {...props} />;
+export const FolderSharedIcon: IconType = props => <FolderShared {...props} />;
+export const CanReadIcon: IconType = props => <RemoveRedEye {...props} />;
+export const CanWriteIcon: IconType = props => <Edit {...props} />;
+export const CanManageIcon: IconType = props => <Computer {...props} />;
+export const AddUserIcon: IconType = props => <PersonAdd {...props} />;
+export const WordWrapOnIcon: IconType = props => <WrapText {...props} />;
+export const WordWrapOffIcon: IconType = props => <FormatAlignLeft {...props} />;
+export const TextIncreaseIcon: IconType = props => <TextIncrease {...props} />;
+export const TextDecreaseIcon: IconType = props => <TextDecrease {...props} />;
+export const DeactivateUserIcon: IconType = props => <NotInterested {...props} />;
+export const LoginAsIcon: IconType = props => <ExitToApp {...props} />;
+export const ActiveIcon: IconType = props => <CheckCircleOutline {...props} />;
+export const SetupIcon: IconType = props => <RemoveCircleOutline {...props} />;
+export const InactiveIcon: IconType = props => <NotInterested {...props} />;
+export const ImageIcon: IconType = props => <Image {...props} />;
+export const StartIcon: IconType = props => <PlayArrow {...props} />;
+export const StopIcon: IconType = props => <Stop {...props} />;
+export const SelectAllIcon: IconType = props => <CheckboxMultipleOutline {...props} />;
+export const SelectNoneIcon: IconType = props => <CheckboxMultipleBlankOutline {...props} />;
+export const ShowChartIcon: IconType = props => <ShowChart {...props} />;
diff --git a/services/workbench2/src/components/int-input/int-input.tsx b/services/workbench2/src/components/int-input/int-input.tsx
new file mode 100644 (file)
index 0000000..26277b4
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Input } from '@material-ui/core';
+import { InputProps } from '@material-ui/core/Input';
+
+export class IntInput extends React.Component<InputProps> {
+    handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+        const { onChange = () => { return; } } = this.props;
+        const parsedValue = parseInt(event.target.value, 10);
+        event.target.value = parsedValue.toString();
+        onChange(event);
+    }
+
+    render() {
+        const parsedValue = parseInt(typeof this.props.value === 'string' ? this.props.value : '', 10);
+        const value = isNaN(parsedValue) ? '' : parsedValue.toString();
+        const props = {
+            ...this.props,
+            value,
+            onChange: this.handleChange,
+        };
+        return <Input {...props} />;
+    }
+}
diff --git a/services/workbench2/src/components/list-item-text-icon/list-item-text-icon.tsx b/services/workbench2/src/components/list-item-text-icon/list-item-text-icon.tsx
new file mode 100644 (file)
index 0000000..226556a
--- /dev/null
@@ -0,0 +1,66 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ListItemIcon, ListItemText, Typography } from '@material-ui/core';
+import { IconType } from '../icon/icon';
+import classnames from "classnames";
+
+type CssRules = 'root' | 'listItemText' | 'hasMargin' | 'active';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        display: 'flex',
+        alignItems: 'center'
+    },
+    listItemText: {
+        fontWeight: 400
+    },
+    active: {
+        color: theme.palette.primary.main,
+    },
+    hasMargin: {
+        marginLeft: `${theme.spacing.unit}px`,
+    }
+});
+
+export interface ListItemTextIconDataProps {
+    icon: IconType;
+    name: string;
+    isActive?: boolean;
+    hasMargin?: boolean;
+    iconSize?: number;
+    nameDecorator?: JSX.Element;
+}
+
+type ListItemTextIconProps = ListItemTextIconDataProps & WithStyles<CssRules>;
+
+export const ListItemTextIcon = withStyles(styles)(
+    class extends React.Component<ListItemTextIconProps, {}> {
+        render() {
+            const { classes, isActive, hasMargin, name, icon: Icon, iconSize, nameDecorator } = this.props;
+            return (
+                <Typography component='span' className={classes.root}>
+                    <ListItemIcon className={classnames({
+                            [classes.hasMargin]: hasMargin,
+                            [classes.active]: isActive
+                        })}>
+
+                        <Icon style={{ fontSize: `${iconSize}rem` }} />
+                    </ListItemIcon>
+                    {nameDecorator || null}
+                    <ListItemText primary={
+                        <Typography className={classnames(classes.listItemText, {
+                                [classes.active]: isActive
+                            })}>
+                            {name}
+                        </Typography>
+                    } />
+                </Typography>
+            );
+        }
+    }
+);
diff --git a/services/workbench2/src/components/loading/inline-pulser.tsx b/services/workbench2/src/components/loading/inline-pulser.tsx
new file mode 100644 (file)
index 0000000..def6b5e
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { ThreeDots } from 'react-loader-spinner'
+import { withTheme } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+
+type ThemeProps = {
+    theme: ArvadosTheme;
+};
+
+type Props = {
+    color?: string;
+    height?: number;
+    width?: number;
+    radius?: number;
+};
+
+export const InlinePulser = withTheme()((props: Props & ThemeProps) => (
+    <ThreeDots
+        visible={true}
+        height={props.height || "30"}
+        width={props.width || "30"}
+        color={props.color || props.theme.customs.colors.greyL}
+        radius={props.radius || "10"}
+        ariaLabel="three-dots-loading"
+    />
+));
diff --git a/services/workbench2/src/components/multi-panel-view/multi-panel-view.test.tsx b/services/workbench2/src/components/multi-panel-view/multi-panel-view.test.tsx
new file mode 100644 (file)
index 0000000..3f4911c
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { configure, mount } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+import { MPVContainer } from './multi-panel-view';
+import { Button } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+const PanelMock = ({panelName, panelMaximized, doHidePanel, doMaximizePanel, doUnMaximizePanel, panelIlluminated, panelRef, children, ...rest}) =>
+    <div {...rest}>{children}</div>;
+
+describe('<MPVContainer />', () => {
+    let props;
+
+    beforeEach(() => {
+        props = {
+            classes: {},
+        };
+    });
+
+    it('should show default panel buttons for every child', () => {
+        const childs = [
+            <PanelMock key={1}>This is one panel</PanelMock>,
+            <PanelMock key={2}>This is another panel</PanelMock>,
+        ];
+        const wrapper = mount(<MPVContainer {...props}>{[...childs]}</MPVContainer>);
+        expect(wrapper.find(Button).first().html()).toContain('Panel 1');
+        expect(wrapper.html()).toContain('This is one panel');
+        expect(wrapper.find(Button).last().html()).toContain('Panel 2');
+        expect(wrapper.html()).toContain('This is another panel');
+    });
+
+    it('should show panel when clicking on its button', () => {
+        const childs = [
+            <PanelMock key={1}>This is one panel</PanelMock>,
+        ];
+        props.panelStates = [
+            {name: 'Initially invisible Panel', visible: false},
+        ]
+
+        const wrapper = mount(<MPVContainer {...props}>{[...childs]}</MPVContainer>);
+
+        // Initial state: panel not visible
+        expect(wrapper.html()).not.toContain('This is one panel');
+        expect(wrapper.html()).toContain('All panels are hidden');
+
+        // Panel visible when clicking on its button
+        wrapper.find(Button).simulate('click');
+        expect(wrapper.html()).toContain('This is one panel');
+        expect(wrapper.html()).not.toContain('All panels are hidden');
+    });
+
+    it('should show custom panel buttons when config provided', () => {
+        const childs = [
+            <PanelMock key={1}>This is one panel</PanelMock>,
+            <PanelMock key={2}>This is another panel</PanelMock>,
+        ];
+        props.panelStates = [
+            {name: 'First Panel'},
+        ]
+        const wrapper = mount(<MPVContainer {...props}>{[...childs]}</MPVContainer>);
+        expect(wrapper.find(Button).first().html()).toContain('First Panel');
+        expect(wrapper.html()).toContain('This is one panel');
+        // Second panel received the default button naming and hidden status by default
+        expect(wrapper.find(Button).last().html()).toContain('Panel 2');
+        expect(wrapper.html()).not.toContain('This is another panel');
+        wrapper.find(Button).last().simulate('click');
+        expect(wrapper.html()).toContain('This is another panel');
+    });
+
+    it('should set panel hidden when requested', () => {
+        const childs = [
+            <PanelMock key={1}>This is one panel</PanelMock>,
+        ];
+        props.panelStates = [
+            {name: 'First Panel', visible: false},
+        ]
+        const wrapper = mount(<MPVContainer {...props}>{[...childs]}</MPVContainer>);
+        expect(wrapper.find(Button).html()).toContain('First Panel');
+        expect(wrapper.html()).not.toContain('This is one panel');
+        expect(wrapper.html()).toContain('All panels are hidden');
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/components/multi-panel-view/multi-panel-view.tsx b/services/workbench2/src/components/multi-panel-view/multi-panel-view.tsx
new file mode 100644 (file)
index 0000000..7e0ca8f
--- /dev/null
@@ -0,0 +1,227 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { MutableRefObject, ReactElement, ReactNode, useEffect, useRef, useState } from 'react';
+import {
+    Button,
+    Grid,
+    Paper,
+    StyleRulesCallback,
+    Tooltip,
+    withStyles,
+    WithStyles
+} from "@material-ui/core";
+import { GridProps } from '@material-ui/core/Grid';
+import { isArray } from 'lodash';
+import { DefaultView } from 'components/default-view/default-view';
+import { InfoIcon } from 'components/icon/icon';
+import { ReactNodeArray } from 'prop-types';
+import classNames from 'classnames';
+
+type CssRules = 'root' | 'button' | 'buttonIcon' | 'content';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    root: {
+        marginTop: '10px',
+    },
+    button: {
+        padding: '2px 5px',
+        marginRight: '5px',
+    },
+    buttonIcon: {
+        boxShadow: 'none',
+        padding: '2px 0px 2px 5px',
+        fontSize: '1rem'
+    },
+    content: {
+        overflow: 'auto',
+    },
+});
+
+interface MPVHideablePanelDataProps {
+    name: string;
+    visible: boolean;
+    maximized: boolean;
+    illuminated: boolean;
+    children: ReactNode;
+    panelRef?: MutableRefObject<any>;
+}
+
+interface MPVHideablePanelActionProps {
+    doHidePanel: () => void;
+    doMaximizePanel: () => void;
+    doUnMaximizePanel: () => void;
+}
+
+type MPVHideablePanelProps = MPVHideablePanelDataProps & MPVHideablePanelActionProps;
+
+const MPVHideablePanel = ({ doHidePanel, doMaximizePanel, doUnMaximizePanel, name, visible, maximized, illuminated, ...props }: MPVHideablePanelProps) =>
+    visible
+        ? <>
+            {React.cloneElement((props.children as ReactElement), { doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName: name, panelMaximized: maximized, panelIlluminated: illuminated, panelRef: props.panelRef })}
+        </>
+        : null;
+
+interface MPVPanelDataProps {
+    panelName?: string;
+    panelMaximized?: boolean;
+    panelIlluminated?: boolean;
+    panelRef?: MutableRefObject<any>;
+    forwardProps?: boolean;
+    maxHeight?: string;
+    minHeight?: string;
+}
+
+interface MPVPanelActionProps {
+    doHidePanel?: () => void;
+    doMaximizePanel?: () => void;
+    doUnMaximizePanel?: () => void;
+}
+
+// Props received by panel implementors
+export type MPVPanelProps = MPVPanelDataProps & MPVPanelActionProps;
+
+type MPVPanelContentProps = { children: ReactElement } & MPVPanelProps & GridProps;
+
+// Grid item compatible component for layout and MPV props passing
+export const MPVPanelContent = ({ doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName,
+    panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight, minHeight,
+    ...props }: MPVPanelContentProps) => {
+    useEffect(() => {
+        if (panelRef && panelRef.current) {
+            panelRef.current.scrollIntoView({ alignToTop: true });
+        }
+    }, [panelRef]);
+
+    const maxH = panelMaximized
+        ? '100%'
+        : maxHeight;
+
+    return <Grid item style={{ maxHeight: maxH, minHeight }} {...props}>
+        <span ref={panelRef} /> {/* Element to scroll to when the panel is selected */}
+        <Paper style={{ height: '100%' }} elevation={panelIlluminated ? 8 : 0}>
+            {forwardProps
+                ? React.cloneElement(props.children, { doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized })
+                : props.children}
+        </Paper>
+    </Grid>;
+}
+
+export interface MPVPanelState {
+    name: string;
+    visible?: boolean;
+}
+interface MPVContainerDataProps {
+    panelStates?: MPVPanelState[];
+}
+type MPVContainerProps = MPVContainerDataProps & GridProps;
+
+// Grid container compatible component that also handles panel toggling.
+const MPVContainerComponent = ({ children, panelStates, classes, ...props }: MPVContainerProps & WithStyles<CssRules>) => {
+    if (children === undefined || children === null || children === {}) {
+        children = [];
+    } else if (!isArray(children)) {
+        children = [children];
+    }
+    const initialVisibility = (children as ReactNodeArray).map((_, idx) =>
+        !panelStates || // if panelStates wasn't passed, default to all visible panels
+        (panelStates[idx] &&
+            (panelStates[idx].visible || panelStates[idx].visible === undefined)));
+    const [panelVisibility, setPanelVisibility] = useState<boolean[]>(initialVisibility);
+    const [previousPanelVisibility, setPreviousPanelVisibility] = useState<boolean[]>(initialVisibility);
+    const [highlightedPanel, setHighlightedPanel] = useState<number>(-1);
+    const [selectedPanel, setSelectedPanel] = useState<number>(-1);
+    const panelRef = useRef<any>(null);
+
+    let panels: JSX.Element[] = [];
+    let buttons: JSX.Element[] = [];
+
+    if (isArray(children)) {
+        for (let idx = 0; idx < children.length; idx++) {
+            const showFn = (idx: number) => () => {
+                setPreviousPanelVisibility(initialVisibility);
+                setPanelVisibility([
+                    ...panelVisibility.slice(0, idx),
+                    true,
+                    ...panelVisibility.slice(idx + 1)
+                ]);
+                setSelectedPanel(idx);
+            };
+            const hideFn = (idx: number) => () => {
+                setPreviousPanelVisibility(initialVisibility);
+                setPanelVisibility([
+                    ...panelVisibility.slice(0, idx),
+                    false,
+                    ...panelVisibility.slice(idx + 1)
+                ])
+            };
+            const maximizeFn = (idx: number) => () => {
+                setPreviousPanelVisibility(panelVisibility);
+                // Maximize X == hide all but X
+                setPanelVisibility([
+                    ...panelVisibility.slice(0, idx).map(() => false),
+                    true,
+                    ...panelVisibility.slice(idx + 1).map(() => false),
+                ]);
+            };
+            const unMaximizeFn = (idx: number) => () => {
+                setPanelVisibility(previousPanelVisibility);
+                setSelectedPanel(idx);
+            }
+            const panelName = panelStates === undefined
+                ? `Panel ${idx + 1}`
+                : (panelStates[idx] && panelStates[idx].name) || `Panel ${idx + 1}`;
+            const btnVariant = panelVisibility[idx]
+                ? "contained"
+                : "outlined";
+            const btnTooltip = panelVisibility[idx]
+                ? ``
+                : `Open ${panelName} panel`;
+            const panelIsMaximized = panelVisibility[idx] &&
+                panelVisibility.filter(e => e).length === 1;
+
+            buttons = [
+                ...buttons,
+                <Tooltip title={btnTooltip} disableFocusListener>
+                    <Button variant={btnVariant} size="small" color="primary"
+                        className={classNames(classes.button)}
+                        onMouseEnter={() => {
+                            setHighlightedPanel(idx);
+                        }}
+                        onMouseLeave={() => {
+                            setHighlightedPanel(-1);
+                        }}
+                        onClick={showFn(idx)}>
+                        {panelName}
+                    </Button>
+                </Tooltip>
+            ];
+
+            const aPanel =
+                <MPVHideablePanel key={idx} visible={panelVisibility[idx]} name={panelName}
+                    panelRef={(idx === selectedPanel) ? panelRef : undefined}
+                    maximized={panelIsMaximized} illuminated={idx === highlightedPanel}
+                    doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)} doUnMaximizePanel={panelIsMaximized ? unMaximizeFn(idx) : () => null}>
+                    {children[idx]}
+                </MPVHideablePanel>;
+            panels = [...panels, aPanel];
+        };
+    };
+
+    return <Grid container {...props} className={classes.root}>
+        <Grid container item direction="row">
+            {buttons.map((tgl, idx) => <Grid item key={idx}>{tgl}</Grid>)}
+        </Grid>
+        <Grid container item {...props} xs className={classes.content}
+            onScroll={() => setSelectedPanel(-1)}>
+            {panelVisibility.includes(true)
+                ? panels
+                : <Grid container item alignItems='center' justify='center'>
+                    <DefaultView messages={["All panels are hidden.", "Click on the buttons above to show them."]} icon={InfoIcon} />
+                </Grid>}
+        </Grid>
+    </Grid>;
+};
+
+export const MPVContainer = withStyles(styles)(MPVContainerComponent);
diff --git a/services/workbench2/src/components/multiselect-toolbar/MultiselectToolbar.tsx b/services/workbench2/src/components/multiselect-toolbar/MultiselectToolbar.tsx
new file mode 100644 (file)
index 0000000..194950b
--- /dev/null
@@ -0,0 +1,346 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { connect } from "react-redux";
+import { StyleRulesCallback, withStyles, WithStyles, Toolbar, Tooltip, IconButton } from "@material-ui/core";
+import { ArvadosTheme } from "common/custom-theme";
+import { RootState } from "store/store";
+import { Dispatch } from "redux";
+import { TCheckedList } from "components/data-table/data-table";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { Resource, ResourceKind, extractUuidKind } from "models/resource";
+import { getResource } from "store/resources/resources";
+import { ResourcesState } from "store/resources/resources";
+import { MultiSelectMenuAction, MultiSelectMenuActionSet } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { ContextMenuAction, ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { multiselectActionsFilters, TMultiselectActionsFilters } from "./ms-toolbar-action-filters";
+import { kindToActionSet, findActionByName } from "./ms-kind-action-differentiator";
+import { msToggleTrashAction } from "views-components/multiselect-toolbar/ms-project-action-set";
+import { copyToClipboardAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { ContainerRequestResource } from "models/container-request";
+import { FavoritesState } from "store/favorites/favorites-reducer";
+import { resourceIsFrozen } from "common/frozen-resources";
+import { getResourceWithEditableStatus } from "store/resources/resources";
+import { GroupResource } from "models/group";
+import { EditableResource } from "models/resource";
+import { User } from "models/user";
+import { GroupClass } from "models/group";
+import { isProcessCancelable } from "store/processes/process";
+import { CollectionResource } from "models/collection";
+import { getProcess } from "store/processes/process";
+import { Process } from "store/processes/process";
+import { PublicFavoritesState } from "store/public-favorites/public-favorites-reducer";
+import { isExactlyOneSelected } from "store/multiselect/multiselect-actions";
+import { IntersectionObserverWrapper } from "./ms-toolbar-overflow-wrapper";
+import { ContextMenuKind, sortMenuItems, menuDirection } from 'views-components/context-menu/menu-item-sort';
+
+type CssRules = "root" | "button" | "iconContainer" | "icon" | "divider";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        display: "flex",
+        flexDirection: "row",
+        width: 0,
+        height: '2.7rem',
+        padding: 0,
+        margin: "1rem auto auto 0.3rem",
+        overflow: 'hidden',
+    },
+    button: {
+        width: "2.5rem",
+        height: "2.5rem ",
+        paddingLeft: 0,
+        border: "1px solid transparent",
+    },
+    iconContainer: {
+        height: '100%',
+    },
+    icon: {
+        marginLeft: '-0.5rem',
+    },
+    divider: {
+        display: "flex",
+        alignItems: "center",
+    },
+});
+
+export type MultiselectToolbarProps = {
+    checkedList: TCheckedList;
+    singleSelectedUuid: string | null
+    iconProps: IconProps
+    user: User | null
+    disabledButtons: Set<string>
+    executeMulti: (action: ContextMenuAction, checkedList: TCheckedList, resources: ResourcesState) => void;
+};
+
+type IconProps = {
+    resources: ResourcesState;
+    favorites: FavoritesState;
+    publicFavorites: PublicFavoritesState;
+}
+
+export const MultiselectToolbar = connect(
+    mapStateToProps,
+    mapDispatchToProps
+)(
+    withStyles(styles)((props: MultiselectToolbarProps & WithStyles<CssRules>) => {
+        const { classes, checkedList, singleSelectedUuid, iconProps, user, disabledButtons } = props;
+        const singleResourceKind = singleSelectedUuid ? [resourceToMsResourceKind(singleSelectedUuid, iconProps.resources, user)] : null
+        const currentResourceKinds = singleResourceKind ? singleResourceKind : Array.from(selectedToKindSet(checkedList));
+        const currentPathIsTrash = window.location.pathname === "/trash";
+
+        const rawActions =
+            currentPathIsTrash && selectedToKindSet(checkedList).size
+                ? [msToggleTrashAction]
+                : selectActionsByKind(currentResourceKinds as string[], multiselectActionsFilters).filter((action) =>
+                        singleSelectedUuid === null ? action.isForMulti : true
+                    );
+                    
+        const actions: ContextMenuAction[] | MultiSelectMenuAction[] = sortMenuItems(
+            singleResourceKind && singleResourceKind.length ? (singleResourceKind[0] as ContextMenuKind) : ContextMenuKind.MULTI,
+            rawActions,
+            menuDirection.HORIZONTAL
+        ); 
+
+        return (
+            <React.Fragment>
+                <Toolbar
+                    className={classes.root}
+                    style={{ width: `${(actions.length * 2.5) + 6}rem`}}
+                    data-cy='multiselect-toolbar'
+                    >
+                    {actions.length ? (
+                        <IntersectionObserverWrapper menuLength={actions.length}>
+                            {actions.map((action, i) =>{
+                                const { hasAlts, useAlts, name, altName, icon, altIcon } = action;
+                            return action.name === ContextMenuActionNames.DIVIDER ? (
+                                action.component && (
+                                    <div
+                                        className={classes.divider}
+                                        data-targetid={`${name}${i}`}
+                                        key={i}
+                                    >
+                                        <action.component />
+                                    </div>
+                                )
+                            ) : hasAlts ? (
+                                <Tooltip
+                                    className={classes.button}
+                                    data-targetid={name}
+                                    title={currentPathIsTrash || (useAlts && useAlts(singleSelectedUuid, iconProps)) ? altName : name}
+                                    key={i}
+                                    disableFocusListener
+                                >
+                                    <span className={classes.iconContainer}>
+                                        <IconButton
+                                            data-cy='multiselect-button'
+                                            disabled={disabledButtons.has(name)}
+                                            onClick={() => props.executeMulti(action, checkedList, iconProps.resources)}
+                                            className={classes.icon}
+                                        >
+                                            {currentPathIsTrash || (useAlts && useAlts(singleSelectedUuid, iconProps)) ? altIcon && altIcon({}) : icon({})}
+                                        </IconButton>
+                                    </span>
+                                </Tooltip>
+                            ) : (
+                                <Tooltip
+                                    className={classes.button}
+                                    data-targetid={name}
+                                    title={action.name}
+                                    key={i}
+                                    disableFocusListener
+                                >
+                                    <span className={classes.iconContainer}>
+                                        <IconButton
+                                            data-cy='multiselect-button'
+                                            onClick={() => props.executeMulti(action, checkedList, iconProps.resources)}
+                                            className={classes.icon}
+                                        >
+                                            {action.icon({})}
+                                        </IconButton>
+                                    </span>
+                                </Tooltip>
+                            );
+                            })}
+                        </IntersectionObserverWrapper>
+                    ) : (
+                        <></>
+                    )}
+                </Toolbar>
+            </React.Fragment>
+        )
+    })
+);
+
+export function selectedToArray(checkedList: TCheckedList): Array<string> {
+    const arrayifiedSelectedList: Array<string> = [];
+    for (const [key, value] of Object.entries(checkedList)) {
+        if (value === true) {
+            arrayifiedSelectedList.push(key);
+        }
+    }
+    return arrayifiedSelectedList;
+}
+
+export function selectedToKindSet(checkedList: TCheckedList): Set<string> {
+    const setifiedList = new Set<string>();
+    for (const [key, value] of Object.entries(checkedList)) {
+        if (value === true) {
+            setifiedList.add(extractUuidKind(key) as string);
+        }
+    }
+    return setifiedList;
+}
+
+function groupByKind(checkedList: TCheckedList, resources: ResourcesState): Record<string, ContextMenuResource[]> {
+    const result = {};
+    selectedToArray(checkedList).forEach(uuid => {
+        const resource = getResource(uuid)(resources) as ContainerRequestResource | Resource;
+        if (!result[resource.kind]) result[resource.kind] = [];
+        result[resource.kind].push(resource);
+    });
+    return result;
+}
+
+function filterActions(actionArray: MultiSelectMenuActionSet, filters: Set<string>): Array<MultiSelectMenuAction> {
+    return actionArray[0].filter(action => filters.has(action.name as string));
+}
+
+const resourceToMsResourceKind = (uuid: string, resources: ResourcesState, user: User | null, readonly = false): (ContextMenuKind | ResourceKind) | undefined => {
+    if (!user) return;
+    const resource = getResourceWithEditableStatus<GroupResource & EditableResource>(uuid, user.uuid)(resources);
+    const { isAdmin } = user;
+    const kind = extractUuidKind(uuid);
+
+    const isFrozen = resourceIsFrozen(resource, resources);
+    const isEditable = (user.isAdmin || (resource || ({} as EditableResource)).isEditable) && !readonly && !isFrozen;
+
+    switch (kind) {
+        case ResourceKind.PROJECT:
+            if (isFrozen) {
+                return isAdmin ? ContextMenuKind.FROZEN_PROJECT_ADMIN : ContextMenuKind.FROZEN_PROJECT;
+            }
+
+            return isAdmin && !readonly
+                ? resource && resource.groupClass !== GroupClass.FILTER
+                    ? ContextMenuKind.PROJECT_ADMIN
+                    : ContextMenuKind.FILTER_GROUP_ADMIN
+                : isEditable
+                ? resource && resource.groupClass !== GroupClass.FILTER
+                    ? ContextMenuKind.PROJECT
+                    : ContextMenuKind.FILTER_GROUP
+                : ContextMenuKind.READONLY_PROJECT;
+        case ResourceKind.COLLECTION:
+            const c = getResource<CollectionResource>(uuid)(resources);
+            if (c === undefined) {
+                return;
+            }
+            const isOldVersion = c.uuid !== c.currentVersionUuid;
+            const isTrashed = c.isTrashed;
+            return isOldVersion
+                ? ContextMenuKind.OLD_VERSION_COLLECTION
+                : isTrashed && isEditable
+                ? ContextMenuKind.TRASHED_COLLECTION
+                : isAdmin && isEditable
+                ? ContextMenuKind.COLLECTION_ADMIN
+                : isEditable
+                ? ContextMenuKind.COLLECTION
+                : ContextMenuKind.READONLY_COLLECTION;
+        case ResourceKind.PROCESS:
+            return isAdmin && isEditable
+                ? resource && isProcessCancelable(getProcess(resource.uuid)(resources) as Process)
+                    ? ContextMenuKind.RUNNING_PROCESS_ADMIN
+                    : ContextMenuKind.PROCESS_ADMIN
+                : readonly
+                ? ContextMenuKind.READONLY_PROCESS_RESOURCE
+                : resource && isProcessCancelable(getProcess(resource.uuid)(resources) as Process)
+                ? ContextMenuKind.RUNNING_PROCESS_RESOURCE
+                : ContextMenuKind.PROCESS_RESOURCE;
+        case ResourceKind.USER:
+            return ContextMenuKind.ROOT_PROJECT;
+        case ResourceKind.LINK:
+            return ContextMenuKind.LINK;
+        case ResourceKind.WORKFLOW:
+            return isEditable ? ContextMenuKind.WORKFLOW : ContextMenuKind.READONLY_WORKFLOW;
+        default:
+            return;
+    }
+}; 
+
+function selectActionsByKind(currentResourceKinds: Array<string>, filterSet: TMultiselectActionsFilters): MultiSelectMenuAction[] {
+    const rawResult: Set<MultiSelectMenuAction> = new Set();
+    const resultNames = new Set();
+    const allFiltersArray: MultiSelectMenuAction[][] = []
+    currentResourceKinds.forEach(kind => {
+        if (filterSet[kind]) {
+            const actions = filterActions(...filterSet[kind]);
+            allFiltersArray.push(actions);
+            actions.forEach(action => {
+                if (!resultNames.has(action.name)) {
+                    rawResult.add(action);
+                    resultNames.add(action.name);
+                }
+            });
+        }
+    });
+
+    const filteredNameSet = allFiltersArray.map(filterArray => {
+        const resultSet = new Set<string>();
+        filterArray.forEach(action => resultSet.add(action.name as string || ""));
+        return resultSet;
+    });
+
+    const filteredResult = Array.from(rawResult).filter(action => {
+        for (let i = 0; i < filteredNameSet.length; i++) {
+            if (!filteredNameSet[i].has(action.name as string)) return false;
+        }
+        return true;
+    });
+
+    return filteredResult;
+}
+
+
+//--------------------------------------------------//
+
+function mapStateToProps({auth, multiselect, resources, favorites, publicFavorites}: RootState) {
+    return {
+        checkedList: multiselect.checkedList as TCheckedList,
+        singleSelectedUuid: isExactlyOneSelected(multiselect.checkedList),
+        user: auth && auth.user ? auth.user : null,
+        disabledButtons: new Set<string>(multiselect.disabledButtons),
+        iconProps: {
+            resources,
+            favorites,
+            publicFavorites
+        }
+    }
+}
+
+function mapDispatchToProps(dispatch: Dispatch) {
+    return {
+        executeMulti: (selectedAction: ContextMenuAction, checkedList: TCheckedList, resources: ResourcesState): void => {
+            const kindGroups = groupByKind(checkedList, resources);
+            switch (selectedAction.name) {
+                case ContextMenuActionNames.MOVE_TO:
+                case ContextMenuActionNames.REMOVE:
+                    const firstResource = getResource(selectedToArray(checkedList)[0])(resources) as ContainerRequestResource | Resource;
+                    const action = findActionByName(selectedAction.name as string, kindToActionSet[firstResource.kind]);
+                    if (action) action.execute(dispatch, kindGroups[firstResource.kind]);
+                    break;
+                case ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD:
+                    const selectedResources = selectedToArray(checkedList).map(uuid => getResource(uuid)(resources));
+                    dispatch<any>(copyToClipboardAction(selectedResources));
+                    break;
+                default:
+                    for (const kind in kindGroups) {
+                        const action = findActionByName(selectedAction.name as string, kindToActionSet[kind]);
+                        if (action) action.execute(dispatch, kindGroups[kind]);
+                    }
+                    break;
+            }
+        },
+    };
+}
diff --git a/services/workbench2/src/components/multiselect-toolbar/ms-kind-action-differentiator.ts b/services/workbench2/src/components/multiselect-toolbar/ms-kind-action-differentiator.ts
new file mode 100644 (file)
index 0000000..5a84d4c
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ResourceKind } from "models/resource";
+import { MultiSelectMenuActionSet} from "views-components/multiselect-toolbar/ms-menu-actions";
+import { msCollectionActionSet } from "views-components/multiselect-toolbar/ms-collection-action-set";
+import { msProjectActionSet } from "views-components/multiselect-toolbar/ms-project-action-set";
+import { msProcessActionSet } from "views-components/multiselect-toolbar/ms-process-action-set";
+import { msWorkflowActionSet } from "views-components/multiselect-toolbar/ms-workflow-action-set";
+
+export function findActionByName(name: string, actionSet: MultiSelectMenuActionSet) {
+    return actionSet[0].find(action => action.name === name);
+}
+
+const { COLLECTION, PROCESS, PROJECT, WORKFLOW } = ResourceKind;
+
+export const kindToActionSet: Record<string, MultiSelectMenuActionSet> = {
+    [COLLECTION]: msCollectionActionSet,
+    [PROCESS]: msProcessActionSet,
+    [PROJECT]: msProjectActionSet,
+    [WORKFLOW]: msWorkflowActionSet,
+};
diff --git a/services/workbench2/src/components/multiselect-toolbar/ms-toolbar-action-filters.ts b/services/workbench2/src/components/multiselect-toolbar/ms-toolbar-action-filters.ts
new file mode 100644 (file)
index 0000000..2b30525
--- /dev/null
@@ -0,0 +1,68 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { MultiSelectMenuActionSet } from 'views-components/multiselect-toolbar/ms-menu-actions';
+import { msCollectionActionSet, msCommonCollectionActionFilter, msReadOnlyCollectionActionFilter } from 'views-components/multiselect-toolbar/ms-collection-action-set';
+import {
+    msProjectActionSet,
+    msCommonProjectActionFilter,
+    msReadOnlyProjectActionFilter,
+    msFilterGroupActionFilter,
+    msAdminFilterGroupActionFilter,
+    msFrozenProjectActionFilter,
+    msAdminFrozenProjectActionFilter
+} from 'views-components/multiselect-toolbar/ms-project-action-set';
+import { msProcessActionSet, msCommonProcessActionFilter, msAdminProcessActionFilter, msRunningProcessActionFilter } from 'views-components/multiselect-toolbar/ms-process-action-set';
+import { msWorkflowActionSet, msWorkflowActionFilter, msReadOnlyWorkflowActionFilter } from 'views-components/multiselect-toolbar/ms-workflow-action-set';
+import { ResourceKind } from 'models/resource';
+import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
+
+const {
+    COLLECTION,
+    COLLECTION_ADMIN,
+    READONLY_COLLECTION,
+    PROCESS_RESOURCE,
+    RUNNING_PROCESS_RESOURCE,
+    RUNNING_PROCESS_ADMIN,
+    PROCESS_ADMIN,
+    PROJECT,
+    PROJECT_ADMIN,
+    FROZEN_PROJECT,
+    FROZEN_PROJECT_ADMIN,
+    READONLY_PROJECT,
+    FILTER_GROUP,
+    FILTER_GROUP_ADMIN,
+    WORKFLOW,
+    READONLY_WORKFLOW,
+} = ContextMenuKind;
+
+export type TMultiselectActionsFilters = Record<string, [MultiSelectMenuActionSet, Set<string>]>;
+
+const allActionNames = (actionSet: MultiSelectMenuActionSet): Set<string> => new Set(actionSet[0].map((action) => action.name));
+
+export const multiselectActionsFilters: TMultiselectActionsFilters = {
+    [COLLECTION]: [msCollectionActionSet, msCommonCollectionActionFilter],
+    [COLLECTION_ADMIN]: [msCollectionActionSet, allActionNames(msCollectionActionSet)],
+    [READONLY_COLLECTION]: [msCollectionActionSet, msReadOnlyCollectionActionFilter],
+    [ResourceKind.COLLECTION]: [msCollectionActionSet, msCommonCollectionActionFilter],
+
+    [PROCESS_RESOURCE]: [msProcessActionSet, msCommonProcessActionFilter],
+    [PROCESS_ADMIN]: [msProcessActionSet, msAdminProcessActionFilter],
+    [RUNNING_PROCESS_RESOURCE]: [msProcessActionSet, msRunningProcessActionFilter],
+    [RUNNING_PROCESS_ADMIN]: [msProcessActionSet, allActionNames(msProcessActionSet)],
+    [ResourceKind.PROCESS]: [msProcessActionSet, msCommonProcessActionFilter],
+    
+    [PROJECT]: [msProjectActionSet, msCommonProjectActionFilter],
+    [PROJECT_ADMIN]: [msProjectActionSet, allActionNames(msProjectActionSet)],
+    [FROZEN_PROJECT]: [msProjectActionSet, msFrozenProjectActionFilter],
+    [FROZEN_PROJECT_ADMIN]: [msProjectActionSet, msAdminFrozenProjectActionFilter], 
+    [READONLY_PROJECT]: [msProjectActionSet, msReadOnlyProjectActionFilter],
+    [ResourceKind.PROJECT]: [msProjectActionSet, msCommonProjectActionFilter],
+    
+    [FILTER_GROUP]: [msProjectActionSet, msFilterGroupActionFilter],
+    [FILTER_GROUP_ADMIN]: [msProjectActionSet, msAdminFilterGroupActionFilter],
+    
+    [WORKFLOW]: [msWorkflowActionSet, msWorkflowActionFilter],
+    [READONLY_WORKFLOW]: [msWorkflowActionSet, msReadOnlyWorkflowActionFilter],
+};
diff --git a/services/workbench2/src/components/multiselect-toolbar/ms-toolbar-overflow-menu.tsx b/services/workbench2/src/components/multiselect-toolbar/ms-toolbar-overflow-menu.tsx
new file mode 100644 (file)
index 0000000..28b8227
--- /dev/null
@@ -0,0 +1,102 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useState, useMemo, ReactElement, JSXElementConstructor } from 'react';
+import { DoubleRightArrows } from 'components/icon/icon';
+import classnames from 'classnames';
+import { IconButton, Menu, MenuItem, StyleRulesCallback, Tooltip, WithStyles, withStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+
+type CssRules = 'inOverflowMenu' | 'openMenuButton' | 'menu' | 'menuItem' | 'menuElement';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    inOverflowMenu: {
+        '&:hover': {
+            backgroundColor: 'transparent',
+        },
+    },
+    openMenuButton: {
+        right: '10px',
+    },
+    menu: {
+        marginLeft: 0,
+    },
+    menuItem: {
+        '&:hover': {
+            backgroundColor: 'white',
+        },
+        marginTop: 0,
+        paddingTop: 0,
+        paddingLeft: '1rem',
+        height: '2.5rem',
+    },
+    menuElement: {
+        width: '2rem',
+    }
+});
+
+export type OverflowChild = ReactElement<{ className: string; }, string | JSXElementConstructor<any>>
+
+type OverflowMenuProps = {
+    children: OverflowChild[]
+    className: string
+    visibilityMap: {}
+}
+
+export const OverflowMenu = withStyles(styles)((props: OverflowMenuProps & WithStyles<CssRules>) => {
+    const { children, className, visibilityMap, classes } = props;
+    const [anchorEl, setAnchorEl] = useState(null);
+    const open = Boolean(anchorEl);
+    const handleClick = (event) => {
+        setAnchorEl(event.currentTarget);
+    };
+
+    const handleClose = () => {
+        setAnchorEl(null);
+    };
+
+    const shouldShowMenu = useMemo(() => Object.values(visibilityMap).some((v) => v === false), [visibilityMap]);
+    if (!shouldShowMenu) {
+        return null;
+    }
+    return (
+        <div className={className}>
+            <Tooltip title="More Options" disableFocusListener>
+                <IconButton
+                    aria-label='more'
+                    aria-controls='long-menu'
+                    aria-haspopup='true'
+                    onClick={handleClick}
+                    className={classes.openMenuButton}
+                >
+                        <DoubleRightArrows />
+                </IconButton>
+            </Tooltip>
+            <Menu
+                id='long-menu'
+                anchorEl={anchorEl}
+                keepMounted
+                open={open}
+                onClose={handleClose}
+                disableAutoFocusItem
+                className={classes.menu}
+            >
+                {React.Children.map(children, (child: any) => {
+                    if (!visibilityMap[child.props['data-targetid']]) {
+                        return <MenuItem
+                                key={child}
+                                onClick={handleClose}
+                                className={classes.menuItem}
+                            >
+                                {React.cloneElement(child, {
+                                    className: classnames(classes.menuElement),
+                                })}
+                            </MenuItem>
+                    }
+                    return null;
+                })}
+            </Menu>
+        </div>
+    );
+});
diff --git a/services/workbench2/src/components/multiselect-toolbar/ms-toolbar-overflow-wrapper.tsx b/services/workbench2/src/components/multiselect-toolbar/ms-toolbar-overflow-wrapper.tsx
new file mode 100644 (file)
index 0000000..e0f32f1
--- /dev/null
@@ -0,0 +1,135 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useState, useRef, useEffect } from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import classnames from 'classnames';
+import { ArvadosTheme } from 'common/custom-theme';
+import { OverflowMenu, OverflowChild } from './ms-toolbar-overflow-menu';
+
+type CssRules = 'visible' | 'inVisible' | 'toolbarWrapper' | 'overflowStyle';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    visible: {
+        order: 0,
+        visibility: 'visible',
+        opacity: 1,
+    },
+    inVisible: {
+        order: 100,
+        visibility: 'hidden',
+        pointerEvents: 'none',
+    },
+    toolbarWrapper: {
+        display: 'flex',
+        overflow: 'hidden',
+        padding: '0 0px 0 20px',
+        width: '100%',
+    },
+    overflowStyle: {
+        order: 99,
+        position: 'sticky',
+        right: '-2rem',
+        width: 0,
+    },
+});
+
+type WrapperProps = {
+    children: OverflowChild[];
+    menuLength: number;
+};
+
+export const IntersectionObserverWrapper = withStyles(styles)((props: WrapperProps & WithStyles<CssRules>) => {
+    const { classes, children, menuLength } = props;
+    const lastEntryId = (children[menuLength - 1] as any).props['data-targetid'];
+    const navRef = useRef<any>(null);
+    const [visibilityMap, setVisibilityMap] = useState<Record<string, boolean>>({});
+    const [numHidden, setNumHidden] = useState(() => findNumHidden(visibilityMap));
+
+    const prevNumHidden = useRef(numHidden);
+
+    const handleIntersection = (entries) => {
+        const updatedEntries: Record<string, boolean> = {};
+        entries.forEach((entry) => {
+            const targetid = entry.target.dataset.targetid as string;
+            //if true, the element is visible
+            if (entry.isIntersecting) {
+                updatedEntries[targetid] = true;
+            } else {
+                updatedEntries[targetid] = false;
+            }
+        });
+
+        setVisibilityMap((prev) => ({
+            ...prev,
+            ...updatedEntries,
+            [lastEntryId]: Object.keys(updatedEntries)[0] === lastEntryId,
+        }));
+    };
+
+    //ensures that the last element is always visible if the second to last is visible
+    useEffect(() => {
+        if ((prevNumHidden.current > 1 || prevNumHidden.current === 0) && numHidden === 1) {
+            setVisibilityMap((prev) => ({
+                ...prev,
+                [lastEntryId]: true,
+            }));
+        }
+        prevNumHidden.current = numHidden;
+    }, [numHidden, lastEntryId]);
+
+    useEffect(() => {
+        setNumHidden(findNumHidden(visibilityMap));
+    }, [visibilityMap]);
+
+    useEffect((): any => {
+        setVisibilityMap({});
+        const observer = new IntersectionObserver(handleIntersection, {
+            root: navRef.current,
+            rootMargin: '0px -30px 0px 0px',
+            threshold: 1,
+        });
+        // We are adding observers to child elements of the container div
+        // with ref as navRef. Notice that we are adding observers
+        // only if we have the data attribute targetid on the child element
+        if (navRef.current)
+            Array.from(navRef.current.children).forEach((item: any) => {
+                if (item.dataset.targetid) {
+                    observer.observe(item);
+                }
+            });
+        return () => {
+            observer.disconnect();
+        };
+        // eslint-disable-next-line
+    }, [menuLength]);
+
+    function findNumHidden(visMap: {}) {
+        return Object.values(visMap).filter((x) => x === false).length;
+    }
+
+    return (
+        <div
+            className={classes.toolbarWrapper}
+            ref={navRef}
+        >
+            {React.Children.map(children, (child) => {
+                return React.cloneElement(child, {
+                    className: classnames(child.props.className, {
+                        [classes.visible]: !!visibilityMap[child.props['data-targetid']],
+                        [classes.inVisible]: !visibilityMap[child.props['data-targetid']],
+                    }),
+                });
+            })}
+            {numHidden >= 2 && (
+                <OverflowMenu
+                    visibilityMap={visibilityMap}
+                    className={classes.overflowStyle}
+                >
+                    {children.filter((child) => !child.props['data-targetid'].includes("Divider"))}
+                </OverflowMenu>
+            )}
+        </div>
+    );
+});
diff --git a/services/workbench2/src/components/popover/helpers.ts b/services/workbench2/src/components/popover/helpers.ts
new file mode 100644 (file)
index 0000000..ac860ac
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PopoverOrigin } from "@material-ui/core/Popover";
+
+export const createAnchorAt = (position: {x: number, y: number}) => {
+    const el = document.createElement('div');
+    const clientRect: DOMRect = {
+        x: position.x,
+        y: position.y,
+        toJSON: () => '',
+        left: position.x,
+        right: position.x,
+        top: position.y,
+        bottom: position.y,
+        width: 0,
+        height: 0
+    };
+    el.getBoundingClientRect = () => clientRect;
+    return el;
+};
+
+export const DefaultTransformOrigin: PopoverOrigin = {
+    vertical: "top",
+    horizontal: "right",
+};
\ No newline at end of file
diff --git a/services/workbench2/src/components/popover/popover.test.tsx b/services/workbench2/src/components/popover/popover.test.tsx
new file mode 100644 (file)
index 0000000..c728b49
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { mount, configure } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+
+import { Popover, DefaultTrigger } from "./popover";
+import Button, { ButtonProps } from "@material-ui/core/Button";
+
+configure({ adapter: new Adapter() });
+
+describe("<Popover />", () => {
+    it("opens on default trigger click", () => {
+        const popover = mount(<Popover />);
+        popover.find(DefaultTrigger).simulate("click");
+        expect((popover.state() as any).anchorEl).toBeDefined();
+    });
+
+    it("renders custom trigger", () => {
+        const popover = mount(<Popover triggerComponent={CustomTrigger} />);
+        expect(popover.find(Button).text()).toBe("Open popover");
+    });
+
+    it("opens on custom trigger click", () => {
+        const popover = mount(<Popover triggerComponent={CustomTrigger} />);
+        popover.find(CustomTrigger).simulate("click");
+        expect((popover.state() as any).anchorEl).toBeDefined();
+    });
+
+    it("renders children when opened", () => {
+        const popover = mount(
+            <Popover>
+                <CustomTrigger />
+            </Popover>
+        );
+        popover.find(DefaultTrigger).simulate("click");
+        expect(popover.find(CustomTrigger)).toHaveLength(1);
+    });
+
+    it("does not close if closeOnContentClick is not set", () => {
+        const popover = mount(
+            <Popover>
+                <CustomTrigger />
+            </Popover>
+        );
+        popover.find(DefaultTrigger).simulate("click");
+        popover.find(CustomTrigger).simulate("click");
+        expect((popover.state() as any).anchorEl).toBeDefined();
+    });
+    it("closes on content click if closeOnContentClick is set", () => {
+        const popover = mount(
+            <Popover closeOnContentClick>
+                <CustomTrigger />
+            </Popover>
+        );
+        popover.find(DefaultTrigger).simulate("click");
+        popover.find(CustomTrigger).simulate("click");
+        expect((popover.state() as any).anchorEl).toBeUndefined();
+    });
+
+});
+
+const CustomTrigger: React.SFC<ButtonProps> = (props) => (
+    <Button {...props}>
+        Open popover
+    </Button>
+);
diff --git a/services/workbench2/src/components/popover/popover.tsx b/services/workbench2/src/components/popover/popover.tsx
new file mode 100644 (file)
index 0000000..ce9f8ce
--- /dev/null
@@ -0,0 +1,64 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Popover as MaterialPopover } from '@material-ui/core';
+
+import { PopoverOrigin } from '@material-ui/core/Popover';
+import IconButton, { IconButtonProps } from '@material-ui/core/IconButton';
+
+export interface PopoverProps {
+    triggerComponent?: React.ComponentType<{ onClick: (event: React.MouseEvent<any>) => void }>;
+    closeOnContentClick?: boolean;
+}
+
+export class Popover extends React.Component<PopoverProps> {
+    state = {
+        anchorEl: undefined
+    };
+
+    transformOrigin: PopoverOrigin = {
+        vertical: "top",
+        horizontal: "right",
+    };
+
+    render() {
+        const Trigger = this.props.triggerComponent || DefaultTrigger;
+        return (
+            <>
+                <Trigger onClick={this.handleTriggerClick} />
+                <MaterialPopover
+                    anchorEl={this.state.anchorEl}
+                    open={Boolean(this.state.anchorEl)}
+                    onClose={this.handleClose}
+                    onClick={this.handleSelfClick}
+                    transformOrigin={this.transformOrigin}
+                    anchorOrigin={this.transformOrigin}
+                >
+                    {this.props.children}
+                </MaterialPopover>
+            </>
+        );
+    }
+
+    handleClose = () => {
+        this.setState({ anchorEl: undefined });
+    }
+
+    handleTriggerClick = (event: React.MouseEvent<any>) => {
+        this.setState({ anchorEl: event.currentTarget });
+    }
+
+    handleSelfClick = () => {
+        if (this.props.closeOnContentClick) {
+            this.handleClose();
+        }
+    }
+}
+
+export const DefaultTrigger: React.SFC<IconButtonProps> = (props) => (
+    <IconButton {...props}>
+        <i className="fas" />
+    </IconButton>
+);
diff --git a/services/workbench2/src/components/progress-button/progress-button.tsx b/services/workbench2/src/components/progress-button/progress-button.tsx
new file mode 100644 (file)
index 0000000..751373c
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import Button, { ButtonProps } from '@material-ui/core/Button';
+import { CircularProgress, withStyles } from '@material-ui/core';
+import { CircularProgressProps } from '@material-ui/core/CircularProgress';
+
+interface ProgressButtonProps extends ButtonProps {
+    loading?: boolean;
+    progressProps?: CircularProgressProps;
+}
+
+export const ProgressButton = ({ loading, progressProps, children, disabled, ...props }: ProgressButtonProps) =>
+    <Button {...props} disabled={disabled || loading}>
+        {children}
+        {loading && <Progress {...progressProps} size={getProgressSize(props.size)} />}
+    </Button>;
+
+const Progress = withStyles({
+    root: {
+        position: 'absolute',
+    },
+})(CircularProgress);
+
+const getProgressSize = (size?: 'small' | 'medium' | 'large') => {
+    switch (size) {
+        case 'small':
+            return 16;
+        case 'large':
+            return 24;
+        default:
+            return 20;
+    }
+};
diff --git a/services/workbench2/src/components/refresh-button/refresh-button.test.tsx b/services/workbench2/src/components/refresh-button/refresh-button.test.tsx
new file mode 100644 (file)
index 0000000..3a9292e
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Button } from "@material-ui/core";
+import { shallow, configure } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+import { LAST_REFRESH_TIMESTAMP, RefreshButton } from './refresh-button';
+
+configure({ adapter: new Adapter() });
+
+describe('<RefreshButton />', () => {
+    let props;
+
+    beforeEach(() => {
+        props = {
+            history: {
+                replace: jest.fn(),
+            },
+            classes: {},
+        };
+    });
+
+    it('should render without issues', () => {
+        // when
+        const wrapper = shallow(<RefreshButton {...props} />);
+
+        // then
+        expect(wrapper.html()).toContain('button');
+    });
+
+    it('should pass window location to router', () => {
+        expect(localStorage.getItem(LAST_REFRESH_TIMESTAMP)).toBeFalsy();
+        // setup
+        const wrapper = shallow(<RefreshButton {...props} />);
+
+        // when
+        wrapper.find(Button).simulate('click');
+
+        // then
+        expect(props.history.replace).toHaveBeenCalledWith('/');
+        expect(localStorage.getItem(LAST_REFRESH_TIMESTAMP)).not.toBeFalsy();
+    });
+});
diff --git a/services/workbench2/src/components/refresh-button/refresh-button.tsx b/services/workbench2/src/components/refresh-button/refresh-button.tsx
new file mode 100644 (file)
index 0000000..e2fe548
--- /dev/null
@@ -0,0 +1,50 @@
+
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import classNames from 'classnames';
+import { withRouter, RouteComponentProps } from 'react-router';
+import { StyleRulesCallback, Button, WithStyles, withStyles } from "@material-ui/core";
+import { ReRunProcessIcon } from 'components/icon/icon';
+
+type CssRules = 'button' | 'buttonRight';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    button: {
+        boxShadow: 'none',
+        padding: '2px 10px 2px 5px',
+        fontSize: '0.75rem'
+    },
+    buttonRight: {
+        marginLeft: 'auto',
+    },
+});
+
+interface RefreshButtonProps {
+    onClick?: () => void;
+}
+
+export const LAST_REFRESH_TIMESTAMP = 'lastRefreshTimestamp';
+
+export const RefreshButton = ({ history, classes, onClick }: RouteComponentProps & WithStyles<CssRules> & RefreshButtonProps) =>
+    <Button
+        color="primary"
+        size="small"
+        variant="contained"
+        onClick={() => {
+            // Notify interested parties that the refresh button was clicked.
+            const now = (new Date()).getTime();
+            localStorage.setItem(LAST_REFRESH_TIMESTAMP, now.toString());
+            history.replace(window.location.pathname);
+            if (onClick) {
+                onClick();
+            }
+        }}
+        className={classNames(classes.buttonRight, classes.button)}>
+        <ReRunProcessIcon />
+        Refresh
+    </Button>;
+
+export default withStyles(styles)(withRouter(RefreshButton));
\ No newline at end of file
diff --git a/services/workbench2/src/components/rich-text-editor-link/rich-text-editor-link.tsx b/services/workbench2/src/components/rich-text-editor-link/rich-text-editor-link.tsx
new file mode 100644 (file)
index 0000000..68a8c03
--- /dev/null
@@ -0,0 +1,43 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { withStyles, StyleRulesCallback, WithStyles, Typography } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { openRichTextEditorDialog } from 'store/rich-text-editor-dialog/rich-text-editor-dialog-actions';
+
+type CssRules = "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        color: theme.palette.primary.main,
+        cursor: 'pointer'
+    }
+});
+
+interface RichTextEditorLinkData {
+    title: string;
+    label: string;
+    content: string;
+}
+
+interface RichTextEditorLinkActions {
+    onClick: (title: string, content: string) => void;
+}
+
+type RichTextEditorLinkProps = RichTextEditorLinkData & RichTextEditorLinkActions & WithStyles<CssRules>;
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onClick: (title: string, content: string) => dispatch<any>(openRichTextEditorDialog(title, content))
+});
+
+export const RichTextEditorLink = connect(undefined, mapDispatchToProps)(
+    withStyles(styles)(({ classes, title, content, label, onClick }: RichTextEditorLinkProps) =>
+        <Typography component='span' className={classes.root}
+            onClick={() => onClick(title, content) }>
+            {label}
+        </Typography>
+    ));
\ No newline at end of file
diff --git a/services/workbench2/src/components/search-input/search-input.test.tsx b/services/workbench2/src/components/search-input/search-input.test.tsx
new file mode 100644 (file)
index 0000000..ba70f75
--- /dev/null
@@ -0,0 +1,119 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { mount, configure } from "enzyme";
+import { SearchInput, DEFAULT_SEARCH_DEBOUNCE } from "./search-input";
+import Adapter from 'enzyme-adapter-react-16';
+
+configure({ adapter: new Adapter() });
+
+describe("<SearchInput />", () => {
+
+    jest.useFakeTimers();
+
+    let onSearch: () => void;
+
+    beforeEach(() => {
+        onSearch = jest.fn();
+    });
+
+    describe("on submit", () => {
+        it("calls onSearch with initial value passed via props", () => {
+            const searchInput = mount(<SearchInput selfClearProp="" value="initial value" onSearch={onSearch} />);
+            searchInput.find("form").simulate("submit");
+            expect(onSearch).toBeCalledWith("initial value");
+        });
+
+        it("calls onSearch with current value", () => {
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            searchInput.find("form").simulate("submit");
+            expect(onSearch).toBeCalledWith("current value");
+        });
+
+        it("calls onSearch with new value passed via props", () => {
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            searchInput.setProps({value: "new value"});
+            searchInput.find("form").simulate("submit");
+            expect(onSearch).toBeCalledWith("new value");
+        });
+
+        it("cancels timeout set on input value change", () => {
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} debounce={1000} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            searchInput.find("form").simulate("submit");
+            jest.runTimersToTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+            expect(onSearch).toBeCalledWith("current value");
+        });
+
+    });
+
+    describe("on input value change", () => {
+        it("calls onSearch after default timeout", () => {
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            expect(onSearch).not.toBeCalled();
+            jest.runTimersToTime(DEFAULT_SEARCH_DEBOUNCE);
+            expect(onSearch).toBeCalledWith("current value");
+        });
+
+        it("calls onSearch after the time specified in props has passed", () => {
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} debounce={2000}/>);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            jest.runTimersToTime(1000);
+            expect(onSearch).not.toBeCalled();
+            jest.runTimersToTime(1000);
+            expect(onSearch).toBeCalledWith("current value");
+        });
+
+        it("calls onSearch only once after no change happened during the specified time", () => {
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} debounce={1000}/>);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            jest.runTimersToTime(500);
+            searchInput.find("input").simulate("change", { target: { value: "changed value" } });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+        });
+
+        it("calls onSearch again after the specified time has passed since previous call", () => {
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} debounce={1000}/>);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            jest.runTimersToTime(500);
+            searchInput.find("input").simulate("change", { target: { value: "intermediate value" } });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toBeCalledWith("intermediate value");
+            searchInput.find("input").simulate("change", { target: { value: "latest value" } });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toBeCalledWith("latest value");
+            expect(onSearch).toHaveBeenCalledTimes(2);
+
+        });
+
+    });
+
+    describe("on input target change", () => {
+        it("clears the input value on selfClearProp change", () => {
+            const searchInput = mount(<SearchInput selfClearProp="abc" value="123" onSearch={onSearch} debounce={1000}/>);
+
+            // component should clear value upon creation
+            jest.runTimersToTime(1000);
+            expect(onSearch).toBeCalledWith("");
+            expect(onSearch).toHaveBeenCalledTimes(1);
+
+            // component should not clear on same selfClearProp
+            searchInput.setProps({ selfClearProp: 'abc' });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+
+            // component should clear on selfClearProp change
+            searchInput.setProps({ selfClearProp: '111' });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toBeCalledWith("");
+            expect(onSearch).toHaveBeenCalledTimes(2);
+        });
+    });
+});
diff --git a/services/workbench2/src/components/search-input/search-input.tsx b/services/workbench2/src/components/search-input/search-input.tsx
new file mode 100644 (file)
index 0000000..6d98aed
--- /dev/null
@@ -0,0 +1,98 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, {useState, useEffect} from 'react';
+import {
+    IconButton,
+    FormControl,
+    InputLabel,
+    Input,
+    InputAdornment,
+    Tooltip,
+} from '@material-ui/core';
+import SearchIcon from '@material-ui/icons/Search';
+
+interface SearchInputDataProps {
+    value: string;
+    label?: string;
+    selfClearProp: string;
+}
+
+interface SearchInputActionProps {
+    onSearch: (value: string) => any;
+    debounce?: number;
+}
+
+type SearchInputProps = SearchInputDataProps & SearchInputActionProps;
+
+export const DEFAULT_SEARCH_DEBOUNCE = 1000;
+
+export const SearchInput = (props: SearchInputProps) => {
+    const [timeout, setTimeout] = useState(0);
+    const [value, setValue] = useState("");
+    const [label, setLabel] = useState("Search");
+    const [selfClearProp, setSelfClearProp] = useState("");
+
+    useEffect(() => {
+        if (props.value) {
+            setValue(props.value);
+        }
+        if (props.label) {
+            setLabel(props.label);
+        }
+
+        return () => {
+            setValue("");
+            clearTimeout(timeout);
+        };
+    }, [props.value, props.label]); // eslint-disable-line react-hooks/exhaustive-deps
+
+    useEffect(() => {
+        if (selfClearProp !== props.selfClearProp) {
+            setValue("");
+            setSelfClearProp(props.selfClearProp);
+            handleChange({ target: { value: "" } } as any);
+        }
+    }, [props.selfClearProp]); // eslint-disable-line react-hooks/exhaustive-deps
+
+    const handleSubmit = (event: React.FormEvent<HTMLElement>) => {
+        event.preventDefault();
+        clearTimeout(timeout);
+        props.onSearch(value);
+    };
+
+    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+        const { target: { value: eventValue } } = event;
+        clearTimeout(timeout);
+        setValue(eventValue);
+
+        setTimeout(window.setTimeout(
+            () => {
+                props.onSearch(eventValue);
+            },
+            props.debounce || DEFAULT_SEARCH_DEBOUNCE
+        ));
+    };
+
+    return <form onSubmit={handleSubmit}>
+        <FormControl style={{ width: '14rem'}}>
+            <InputLabel>{label}</InputLabel>
+            <Input
+                type="text"
+                data-cy="search-input"
+                value={value}
+                onChange={handleChange}
+                endAdornment={
+                    <InputAdornment position="end">
+                        <Tooltip title='Search'>
+                            <IconButton
+                                onClick={handleSubmit}>
+                                <SearchIcon />
+                            </IconButton>
+                        </Tooltip>
+                    </InputAdornment>
+                } />
+        </FormControl>
+    </form>;
+};
diff --git a/services/workbench2/src/components/select-field/select-field.tsx b/services/workbench2/src/components/select-field/select-field.tsx
new file mode 100644 (file)
index 0000000..bea0649
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WrappedFieldProps } from 'redux-form';
+import { ArvadosTheme } from 'common/custom-theme';
+import { StyleRulesCallback, WithStyles, withStyles, FormControl, InputLabel, Select, FormHelperText } from '@material-ui/core';
+
+type CssRules = 'formControl' | 'selectWrapper' | 'select' | 'option';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    formControl: {
+        width: '100%',
+    },
+    selectWrapper: {
+        backgroundColor: theme.palette.common.white,
+        '&:before': {
+            borderBottomColor: 'rgba(0, 0, 0, 0.42)',
+        },
+        '&:focus': {
+            outline: 'none',
+        },
+    },
+    select: {
+        fontSize: '0.875rem',
+        '&:focus': {
+            backgroundColor: 'rgba(0, 0, 0, 0.0)',
+        },
+    },
+    option: {
+        fontSize: '0.875rem',
+        backgroundColor: theme.palette.common.white,
+        height: '30px',
+    },
+});
+
+interface NativeSelectFieldProps {
+    disabled?: boolean;
+}
+
+export const NativeSelectField = withStyles(styles)((props: WrappedFieldProps & NativeSelectFieldProps & WithStyles<CssRules> & { items: any[] }) => (
+    <FormControl className={props.classes.formControl}>
+        <Select
+            className={props.classes.selectWrapper}
+            native
+            value={props.input.value}
+            onChange={props.input.onChange}
+            disabled={props.meta.submitting || props.disabled}
+            name={props.input.name}
+            inputProps={{
+                id: `id-${props.input.name}`,
+                className: props.classes.select,
+            }}>
+            {props.items.map(item => (
+                <option
+                    key={item.key}
+                    value={item.key}
+                    className={props.classes.option}>
+                    {item.value}
+                </option>
+            ))}
+        </Select>
+    </FormControl>
+));
+
+interface SelectFieldProps {
+    children: React.ReactNode;
+    label: string;
+}
+
+type SelectFieldCssRules = 'formControl';
+
+const selectFieldStyles: StyleRulesCallback<SelectFieldCssRules> = (theme: ArvadosTheme) => ({
+    formControl: {
+        marginBottom: theme.spacing.unit * 3,
+    },
+});
+export const SelectField = withStyles(selectFieldStyles)((props: WrappedFieldProps & SelectFieldProps & WithStyles<SelectFieldCssRules>) => (
+    <FormControl
+        error={props.meta.invalid}
+        className={props.classes.formControl}>
+        <InputLabel>{props.label}</InputLabel>
+        <Select {...props.input}>{props.children}</Select>
+        <FormHelperText>{props.meta.error}</FormHelperText>
+    </FormControl>
+));
diff --git a/services/workbench2/src/components/subprocess-filter/subprocess-filter.tsx b/services/workbench2/src/components/subprocess-filter/subprocess-filter.tsx
new file mode 100644 (file)
index 0000000..1722de8
--- /dev/null
@@ -0,0 +1,51 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { Typography, Switch } from '@material-ui/core';
+
+type CssRules = 'container' | 'label' | 'value';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    container: {
+        display: 'flex',
+        alignItems: 'center',
+        height: '20px'
+    },
+    label: {
+        width: '86px',
+        color: theme.palette.grey["500"],
+        textAlign: 'right',
+    },
+    value: {
+        width: '24px',
+        paddingLeft: theme.spacing.unit,
+    }
+});
+
+export interface SubprocessFilterDataProps {
+    label: string;
+    value: number;
+    checked?: boolean;
+    key?: string;
+    onToggle?: () => void;
+}
+
+type SubprocessFilterProps = SubprocessFilterDataProps & WithStyles<CssRules>;
+
+export const SubprocessFilter = withStyles(styles)(
+    ({ classes, label, value, key, checked, onToggle }: SubprocessFilterProps) =>
+        <div className={classes.container} >
+            <Typography component="span" className={classes.label}>{label}:</Typography>
+            <Typography component="span" className={classes.value}>{value}</Typography>
+            {onToggle && <Switch
+                checked={checked}
+                onChange={onToggle}
+                value={key}
+                color="primary" />
+            }
+        </div>
+);
\ No newline at end of file
diff --git a/services/workbench2/src/components/subprocess-progress-bar/subprocess-progress-bar.test.tsx b/services/workbench2/src/components/subprocess-progress-bar/subprocess-progress-bar.test.tsx
new file mode 100644 (file)
index 0000000..bd8603f
--- /dev/null
@@ -0,0 +1,165 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { configure, mount } from "enzyme";
+import { ServiceRepository, createServices } from "services/services";
+import { configureStore } from "store/store";
+import { createBrowserHistory } from "history";
+import { mockConfig } from 'common/config';
+import { ApiActions } from "services/api/api-actions";
+import Axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { Process } from "store/processes/process";
+import { ContainerState } from "models/container";
+import Adapter from "enzyme-adapter-react-16";
+import { SubprocessProgressBar } from "./subprocess-progress-bar";
+import { Provider } from "react-redux";
+import { FilterBuilder } from 'services/api/filter-builder';
+import { ProcessStatusFilter, buildProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
+import {act} from "react-dom/test-utils";
+
+configure({ adapter: new Adapter() });
+
+describe("<SubprocessProgressBar />", () => {
+    const axiosInst = Axios.create({ headers: {} });
+    const axiosMock = new MockAdapter(axiosInst);
+
+    let store;
+    let services: ServiceRepository;
+    const config: any = {};
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+    let statusResponse = {
+        [ProcessStatusFilter.COMPLETED]: 0,
+        [ProcessStatusFilter.RUNNING]: 0,
+        [ProcessStatusFilter.FAILED]: 0,
+        [ProcessStatusFilter.QUEUED]: 0,
+    };
+
+    const createMockListFunc = (uuid: string) => jest.fn(async (args) => {
+        const baseFilter = new FilterBuilder().addEqual('requesting_container_uuid', uuid).getFilters();
+
+        const filterResponses = Object.keys(statusResponse)
+            .map(status => ({filters: buildProcessStatusFilters(new FilterBuilder(baseFilter), status).getFilters(), value: statusResponse[status]}));
+
+        const matchedFilter = filterResponses.find(response => response.filters === args.filters);
+        if (matchedFilter) {
+            return { itemsAvailable: matchedFilter.value };
+        } else {
+            return { itemsAvailable: 0 };
+        }
+    });
+
+    beforeEach(() => {
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+    });
+
+    it("requests subprocess progress stats for stopped processes and displays progress", async () => {
+        // when
+        const process = {
+            container: {
+                state: ContainerState.COMPLETE,
+            },
+            containerRequest: {
+                containerUuid: 'zzzzz-dz642-000000000000000',
+            },
+        } as Process;
+
+        statusResponse = {
+            [ProcessStatusFilter.COMPLETED]: 100,
+            [ProcessStatusFilter.RUNNING]: 200,
+
+            // Combined into failed segment
+            [ProcessStatusFilter.FAILED]: 200,
+            [ProcessStatusFilter.CANCELLED]: 100,
+
+            // Combined into queued segment
+            [ProcessStatusFilter.QUEUED]: 300,
+            [ProcessStatusFilter.ONHOLD]: 100,
+        };
+
+        services.containerRequestService.list = createMockListFunc(process.containerRequest.containerUuid);
+
+        let progressBar;
+        await act(async () => {
+            progressBar = mount(
+                <Provider store={store}>
+                    <SubprocessProgressBar process={process} />
+                </Provider>);
+        });
+        await progressBar.update();
+
+        // expects 6 subprocess status list requests
+        const expectedFilters = [
+            ProcessStatusFilter.COMPLETED,
+            ProcessStatusFilter.RUNNING,
+            ProcessStatusFilter.FAILED,
+            ProcessStatusFilter.CANCELLED,
+            ProcessStatusFilter.QUEUED,
+            ProcessStatusFilter.ONHOLD,
+        ].map((state) =>
+            buildProcessStatusFilters(
+                new FilterBuilder().addEqual(
+                    "requesting_container_uuid",
+                    process.containerRequest.containerUuid
+                ),
+                state
+            ).getFilters()
+        );
+
+        expectedFilters.forEach((filter) => {
+            expect(services.containerRequestService.list).toHaveBeenCalledWith({limit: 0, offset: 0, filters: filter});
+        });
+
+        // Verify progress bar with correct degment widths
+        ['10%', '20%', '30%', '40%'].forEach((value, i) => {
+            const styles = progressBar.find('.progress').at(i).props().style;
+            expect(styles).toHaveProperty('width', value);
+        });
+    });
+
+    it("dislays correct progress bar widths with different values", async () => {
+        const process = {
+            container: {
+                state: ContainerState.COMPLETE,
+            },
+            containerRequest: {
+                containerUuid: 'zzzzz-dz642-000000000000001',
+            },
+        } as Process;
+
+        statusResponse = {
+            [ProcessStatusFilter.COMPLETED]: 50,
+            [ProcessStatusFilter.RUNNING]: 55,
+
+            [ProcessStatusFilter.FAILED]: 30,
+            [ProcessStatusFilter.CANCELLED]: 30,
+
+            [ProcessStatusFilter.QUEUED]: 235,
+            [ProcessStatusFilter.ONHOLD]: 100,
+        };
+
+        services.containerRequestService.list = createMockListFunc(process.containerRequest.containerUuid);
+
+        let progressBar;
+        await act(async () => {
+            progressBar = mount(
+                <Provider store={store}>
+                    <SubprocessProgressBar process={process} />
+                </Provider>);
+        });
+        await progressBar.update();
+
+        // Verify progress bar with correct degment widths
+        ['10%', '11%', '12%', '67%'].forEach((value, i) => {
+            const styles = progressBar.find('.progress').at(i).props().style;
+            expect(styles).toHaveProperty('width', value);
+        });
+    });
+
+});
diff --git a/services/workbench2/src/components/subprocess-progress-bar/subprocess-progress-bar.tsx b/services/workbench2/src/components/subprocess-progress-bar/subprocess-progress-bar.tsx
new file mode 100644 (file)
index 0000000..bf932cd
--- /dev/null
@@ -0,0 +1,118 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useEffect, useState } from "react";
+import { StyleRulesCallback, Tooltip, WithStyles, withStyles } from "@material-ui/core";
+import { CProgressStacked, CProgress } from '@coreui/react';
+import { useAsyncInterval } from "common/use-async-interval";
+import { Process, isProcessRunning } from "store/processes/process";
+import { connect } from "react-redux";
+import { Dispatch } from "redux";
+import { fetchSubprocessProgress } from "store/subprocess-panel/subprocess-panel-actions";
+import { ProcessStatusFilter } from "store/resource-type-filters/resource-type-filters";
+
+type CssRules = 'progressWrapper' | 'progressStacked';
+
+const styles: StyleRulesCallback<CssRules> = (theme) => ({
+    progressWrapper: {
+        margin: "28px 0 0",
+        flexGrow: 1,
+        flexBasis: "100px",
+    },
+    progressStacked: {
+        border: "1px solid gray",
+        height: "10px",
+        // Override stripe color to be close to white
+        "& .progress-bar-striped": {
+            backgroundImage:
+                "linear-gradient(45deg,rgba(255,255,255,.80) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.80) 50%,rgba(255,255,255,.80) 75%,transparent 75%,transparent)",
+        },
+    },
+});
+
+export interface ProgressBarDataProps {
+    process: Process;
+}
+
+export interface ProgressBarActionProps {
+    fetchSubprocessProgress: (requestingContainerUuid: string) => Promise<ProgressBarData | undefined>;
+}
+
+type ProgressBarProps = ProgressBarDataProps & ProgressBarActionProps & WithStyles<CssRules>;
+
+export type ProgressBarData = {
+    [ProcessStatusFilter.COMPLETED]: number;
+    [ProcessStatusFilter.RUNNING]: number;
+    [ProcessStatusFilter.FAILED]: number;
+    [ProcessStatusFilter.QUEUED]: number;
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): ProgressBarActionProps => ({
+    fetchSubprocessProgress: (requestingContainerUuid: string) => {
+        return dispatch<any>(fetchSubprocessProgress(requestingContainerUuid));
+    },
+});
+
+export const SubprocessProgressBar = connect(null, mapDispatchToProps)(withStyles(styles)(
+    ({ process, classes, fetchSubprocessProgress }: ProgressBarProps) => {
+
+        const [progressData, setProgressData] = useState<ProgressBarData | undefined>(undefined);
+        const requestingContainerUuid = process.containerRequest.containerUuid;
+        const isRunning = isProcessRunning(process);
+
+        useAsyncInterval(async () => (
+            requestingContainerUuid && setProgressData(await fetchSubprocessProgress(requestingContainerUuid))
+        ), isRunning ? 5000 : null);
+
+        useEffect(() => {
+            if (!isRunning && requestingContainerUuid) {
+                fetchSubprocessProgress(requestingContainerUuid)
+                    .then(result => setProgressData(result));
+            }
+        }, [fetchSubprocessProgress, isRunning, requestingContainerUuid]);
+
+        let tooltip = "";
+        if (progressData) {
+            let total = 0;
+            [ProcessStatusFilter.COMPLETED,
+            ProcessStatusFilter.RUNNING,
+            ProcessStatusFilter.FAILED,
+            ProcessStatusFilter.QUEUED].forEach(psf => {
+                if (progressData[psf] > 0) {
+                    if (tooltip.length > 0) { tooltip += ", "; }
+                    tooltip += `${progressData[psf]} ${psf}`;
+                    total += progressData[psf];
+                }
+            });
+            if (total > 0) {
+                if (tooltip.length > 0) { tooltip += ", "; }
+                tooltip += `${total} Total`;
+            }
+        }
+
+        return progressData !== undefined && getStatusTotal(progressData) > 0 ? <div className={classes.progressWrapper}>
+            <Tooltip title={tooltip}>
+                <CProgressStacked className={classes.progressStacked}>
+                    <CProgress height={10} color="success"
+                        value={getStatusPercent(progressData, ProcessStatusFilter.COMPLETED)} />
+                    <CProgress height={10} color="success" variant="striped"
+                        value={getStatusPercent(progressData, ProcessStatusFilter.RUNNING)} />
+                    <CProgress height={10} color="danger"
+                        value={getStatusPercent(progressData, ProcessStatusFilter.FAILED)} />
+                    <CProgress height={10} color="secondary" variant="striped"
+                        value={getStatusPercent(progressData, ProcessStatusFilter.QUEUED)} />
+                </CProgressStacked>
+            </Tooltip>
+        </div> : <></>;
+    }
+));
+
+const getStatusTotal = (progressData: ProgressBarData) =>
+    (Object.keys(progressData).reduce((accumulator, key) => (accumulator += progressData[key]), 0));
+
+/**
+ * Gets the integer percent value for process status
+ */
+const getStatusPercent = (progressData: ProgressBarData, status: keyof ProgressBarData) =>
+    (progressData[status] / getStatusTotal(progressData) * 100);
diff --git a/services/workbench2/src/components/switch-field/switch-field.tsx b/services/workbench2/src/components/switch-field/switch-field.tsx
new file mode 100644 (file)
index 0000000..0c63a36
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { FormFieldProps, FormField } from 'components/form-field/form-field';
+import { Switch } from '@material-ui/core';
+import { SwitchProps } from '@material-ui/core/Switch';
+
+export const SwitchField = ({ switchProps, ...props }: FormFieldProps & { switchProps: SwitchProps }) =>
+    <FormField {...props}>
+        {input => <Switch {...switchProps} checked={input.value} onChange={input.onChange} />}
+    </FormField>;
+
diff --git a/services/workbench2/src/components/text-field/text-field.tsx b/services/workbench2/src/components/text-field/text-field.tsx
new file mode 100644 (file)
index 0000000..b2a8dd4
--- /dev/null
@@ -0,0 +1,107 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WrappedFieldProps } from 'redux-form';
+import { ArvadosTheme } from 'common/custom-theme';
+import {
+    TextField as MaterialTextField,
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    PropTypes
+} from '@material-ui/core';
+import RichTextEditor from 'react-rte';
+
+type CssRules = 'textField' | 'rte';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    textField: {
+        marginBottom: theme.spacing.unit
+    },
+    rte: {
+        fontFamily: 'Arial',
+        '& a': {
+            textDecoration: 'none',
+            color: theme.palette.primary.main,
+            '&:hover': {
+                cursor: 'pointer',
+                textDecoration: 'underline'
+            }
+        }
+    }
+});
+
+type TextFieldProps = WrappedFieldProps & WithStyles<CssRules>;
+
+export const TextField = withStyles(styles)((props: TextFieldProps & {
+    label?: string, autoFocus?: boolean, required?: boolean, select?: boolean, disabled?: boolean, children: React.ReactNode, margin?: PropTypes.Margin, placeholder?: string,
+    helperText?: string, type?: string,
+}) =>
+    <MaterialTextField
+        helperText={(props.meta.touched && props.meta.error) || props.helperText}
+        className={props.classes.textField}
+        label={props.label}
+        disabled={props.disabled || props.meta.submitting}
+        error={props.meta.touched && !!props.meta.error}
+        autoComplete='off'
+        autoFocus={props.autoFocus}
+        fullWidth={true}
+        required={props.required}
+        select={props.select}
+        children={props.children}
+        margin={props.margin}
+        placeholder={props.placeholder}
+        type={props.type}
+        {...props.input}
+    />);
+
+
+interface RichEditorTextFieldData {
+    label?: string;
+}
+
+type RichEditorTextFieldProps = RichEditorTextFieldData & TextFieldProps;
+
+export const RichEditorTextField = withStyles(styles)(
+    class RichEditorTextField extends React.Component<RichEditorTextFieldProps> {
+        state = {
+            value: RichTextEditor.createValueFromString(this.props.input.value, 'html')
+        };
+
+        onChange = (value: any) => {
+            this.setState({ value });
+            this.props.input.onChange(
+                !!value.getEditorState().getCurrentContent().getPlainText().trim()
+                ? value.toString('html')
+                : null
+            );
+        }
+
+        render() {
+            return <RichTextEditor
+                className={this.props.classes.rte}
+                value={this.state.value}
+                onChange={this.onChange}
+                placeholder={this.props.label} />;
+        }
+    }
+);
+
+export const DateTextField = withStyles(styles)
+    ((props: TextFieldProps) =>
+        <MaterialTextField
+            type="date"
+            disabled={props.meta.submitting}
+            helperText={props.meta.error}
+            error={!!props.meta.error}
+            fullWidth={true}
+            InputLabelProps={{
+                shrink: true
+            }}
+            name={props.input.name}
+            onChange={props.input.onChange}
+            value={props.input.value}
+        />
+    );
diff --git a/services/workbench2/src/components/tree/tree.test.tsx b/services/workbench2/src/components/tree/tree.test.tsx
new file mode 100644 (file)
index 0000000..8a4854b
--- /dev/null
@@ -0,0 +1,99 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import React from 'react';
+import { mount } from 'enzyme';
+import * as Enzyme from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import ListItem from "@material-ui/core/ListItem/ListItem";
+
+import { Tree, TreeItem, TreeItemStatus } from './tree';
+import { ProjectResource } from '../../models/project';
+import { mockProjectResource } from '../../models/test-utils';
+import { Checkbox } from '@material-ui/core';
+
+Enzyme.configure({ adapter: new Adapter() });
+
+describe("Tree component", () => {
+
+    it("should render ListItem", () => {
+        const project: TreeItem<ProjectResource> = {
+            data: mockProjectResource(),
+            id: "3",
+            open: true,
+            active: true,
+            status: TreeItemStatus.LOADED
+        };
+        const wrapper = mount(<Tree
+            render={project => <div />}
+            toggleItemOpen={jest.fn()}
+            toggleItemActive={jest.fn()}
+            onContextMenu={jest.fn()}
+            items={[project]} />);
+        expect(wrapper.find(ListItem)).toHaveLength(1);
+    });
+
+    it("should render arrow", () => {
+        const project: TreeItem<ProjectResource> = {
+            data: mockProjectResource(),
+            id: "3",
+            open: true,
+            active: true,
+            status: TreeItemStatus.LOADED,
+        };
+        const wrapper = mount(<Tree
+            render={project => <div />}
+            toggleItemOpen={jest.fn()}
+            toggleItemActive={jest.fn()}
+            onContextMenu={jest.fn()}
+            items={[project]} />);
+        expect(wrapper.find('i')).toHaveLength(1);
+    });
+
+    it("should render checkbox", () => {
+        const project: TreeItem<ProjectResource> = {
+            data: mockProjectResource(),
+            id: "3",
+            open: true,
+            active: true,
+            status: TreeItemStatus.LOADED
+        };
+        const wrapper = mount(<Tree
+            showSelection={true}
+            render={() => <div />}
+            toggleItemOpen={jest.fn()}
+            toggleItemActive={jest.fn()}
+            onContextMenu={jest.fn()}
+            items={[project]} />);
+        expect(wrapper.find(Checkbox)).toHaveLength(1);
+    });
+
+    it("call onSelectionChanged with associated item", () => {
+        const project: TreeItem<ProjectResource> = {
+            data: mockProjectResource(),
+            id: "3",
+            open: true,
+            active: true,
+            status: TreeItemStatus.LOADED,
+        };
+        const spy = jest.fn();
+        const onSelectionChanged = (event: any, item: TreeItem<any>) => spy(item);
+        const wrapper = mount(<Tree
+            showSelection={true}
+            render={() => <div />}
+            toggleItemOpen={jest.fn()}
+            toggleItemActive={jest.fn()}
+            onContextMenu={jest.fn()}
+            toggleItemSelection={onSelectionChanged}
+            items={[project]} />);
+        wrapper.find(Checkbox).simulate('click');
+        expect(spy).toHaveBeenLastCalledWith({
+            data: mockProjectResource(),
+            id: "3",
+            open: true,
+            active: true,
+            status: TreeItemStatus.LOADED,
+        });
+    });
+
+});
diff --git a/services/workbench2/src/components/tree/tree.tsx b/services/workbench2/src/components/tree/tree.tsx
new file mode 100644 (file)
index 0000000..1f7aa83
--- /dev/null
@@ -0,0 +1,421 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useCallback, useState } from 'react';
+import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core";
+import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
+import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, ProcessIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon';
+import { ReactElement } from "react";
+import CircularProgress from '@material-ui/core/CircularProgress';
+import classnames from "classnames";
+
+import { ArvadosTheme } from 'common/custom-theme';
+import { SidePanelRightArrowIcon } from '../icon/icon';
+import { ResourceKind } from 'models/resource';
+import { GroupClass } from 'models/group';
+import { SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
+
+type CssRules = 'list'
+    | 'listItem'
+    | 'active'
+    | 'loader'
+    | 'toggableIconContainer'
+    | 'iconClose'
+    | 'renderContainer'
+    | 'iconOpen'
+    | 'toggableIcon'
+    | 'checkbox'
+    | 'childItem'
+    | 'childItemIcon'
+    | 'frozenIcon'
+    | 'indentSpacer';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    list: {
+        padding: '3px 0px'
+    },
+    listItem: {
+        padding: '3px 0px',
+    },
+    loader: {
+        position: 'absolute',
+        transform: 'translate(0px)',
+        top: '3px'
+    },
+    toggableIconContainer: {
+        color: theme.palette.grey["700"],
+        height: '14px',
+        width: '14px',
+        marginBottom: '0.4rem',
+    },
+    toggableIcon: {
+        fontSize: '14px',
+    },
+    renderContainer: {
+        flex: 1
+    },
+    iconClose: {
+        transition: 'all 0.1s ease',
+    },
+    iconOpen: {
+        transition: 'all 0.1s ease',
+        transform: 'rotate(90deg)',
+    },
+    checkbox: {
+        width: theme.spacing.unit * 3,
+        height: theme.spacing.unit * 3,
+        margin: `0 ${theme.spacing.unit}px`,
+        padding: 0,
+        color: theme.palette.grey["500"],
+    },
+    childItem: {
+        cursor: 'pointer',
+        display: 'flex',
+        padding: '3px 20px',
+        fontSize: '0.875rem',
+        alignItems: 'center',
+        '&:hover': {
+            backgroundColor: 'rgba(0, 0, 0, 0.08)',
+        }
+    },
+    childItemIcon: {
+        marginLeft: '8px',
+        marginRight: '16px',
+        color: 'rgba(0, 0, 0, 0.54)',
+    },
+    active: {
+        color: theme.palette.primary.main,
+    },
+    frozenIcon: {
+        fontSize: 20,
+        color: theme.palette.grey["600"],
+        marginLeft: '10px',
+    },
+    indentSpacer: {
+        width: '0.25rem'
+    }
+});
+
+export enum TreeItemStatus {
+    INITIAL = 'initial',
+    PENDING = 'pending',
+    LOADED = 'loaded'
+}
+
+export interface TreeItem<T> {
+    data: T;
+    depth?: number;
+    id: string;
+    open: boolean;
+    active: boolean;
+    selected?: boolean;
+    initialState?: boolean;
+    indeterminate?: boolean;
+    flatTree?: boolean;
+    status: TreeItemStatus;
+    items?: Array<TreeItem<T>>;
+    isFrozen?: boolean;
+}
+
+export interface TreeProps<T> {
+    disableRipple?: boolean;
+    currentItemUuid?: string;
+    items?: Array<TreeItem<T>>;
+    level?: number;
+    itemsMap?: Map<string, TreeItem<T>>;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
+    render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
+    showSelection?: boolean | ((item: TreeItem<T>) => boolean);
+    levelIndentation?: number;
+    itemRightPadding?: number;
+    toggleItemActive: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
+    toggleItemOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
+    toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
+    selectedRef?: (node: HTMLDivElement | null) => void;
+
+    /**
+     * When set to true use radio buttons instead of checkboxes for item selection.
+     * This does not guarantee radio group behavior (i.e item mutual exclusivity).
+     * Any item selection logic must be done in the toggleItemActive callback prop.
+     */
+    useRadioButtons?: boolean;
+}
+
+const getActionAndId = (event: any, initAction: string | undefined = undefined) => {
+    const { nativeEvent: { target } } = event;
+    let currentTarget: HTMLElement = target as HTMLElement;
+    let action: string | undefined = initAction || currentTarget.dataset.action;
+    let id: string | undefined = currentTarget.dataset.id;
+
+    while (action === undefined || id === undefined) {
+        currentTarget = currentTarget.parentElement as HTMLElement;
+
+        if (!currentTarget) {
+            break;
+        }
+
+        action = action || currentTarget.dataset.action;
+        id = id || currentTarget.dataset.id;
+    }
+
+    return [action, id];
+};
+
+const isInFavoritesTree = (item: TreeItem<any>): boolean => {
+    return item.id === SidePanelTreeCategory.FAVORITES || item.id === SidePanelTreeCategory.PUBLIC_FAVORITES;
+}
+
+interface FlatTreeProps {
+    it: TreeItem<any>;
+    levelIndentation: number;
+    onContextMenu: Function;
+    handleToggleItemOpen: Function;
+    toggleItemActive: Function;
+    getToggableIconClassNames: Function;
+    getProperArrowAnimation: Function;
+    itemsMap?: Map<string, TreeItem<any>>;
+    classes: any;
+    showSelection: any;
+    useRadioButtons?: boolean;
+    handleCheckboxChange: Function;
+    selectedRef?: (node: HTMLDivElement | null) => void;
+}
+
+const FLAT_TREE_ACTIONS = {
+    toggleOpen: 'TOGGLE_OPEN',
+    contextMenu: 'CONTEXT_MENU',
+    toggleActive: 'TOGGLE_ACTIVE',
+};
+
+const ItemIcon = React.memo(({ type, kind, headKind, active, groupClass, classes }: any) => {
+    let Icon = ProjectIcon;
+
+    if (groupClass === GroupClass.FILTER) {
+        Icon = FilterGroupIcon;
+    }
+
+    if (type) {
+        switch (type) {
+            case 'directory':
+                Icon = DirectoryIcon;
+                break;
+            case 'file':
+                Icon = FileIcon;
+                break;
+            default:
+                Icon = DefaultIcon;
+        }
+    }
+
+    if (kind) {
+        if(kind === ResourceKind.LINK && headKind) kind = headKind;
+        switch (kind) {
+            case ResourceKind.COLLECTION:
+                Icon = CollectionIcon;
+                break;
+            case ResourceKind.CONTAINER_REQUEST:
+                Icon = ProcessIcon;
+                break;
+            default:
+                break;
+        }
+    }
+
+    return <Icon className={classnames({ [classes.active]: active }, classes.childItemIcon)} />;
+});
+
+const FlatTree = (props: FlatTreeProps) =>
+    <div
+        onContextMenu={(event) => {
+            const id = getActionAndId(event, FLAT_TREE_ACTIONS.contextMenu)[1];
+            props.onContextMenu(event, { id } as any);
+        }}
+        onClick={(event) => {
+            const [action, id] = getActionAndId(event);
+
+            if (action && id) {
+                const item = props.itemsMap ? props.itemsMap[id] : { id };
+
+                switch (action) {
+                    case FLAT_TREE_ACTIONS.toggleOpen:
+                        props.handleToggleItemOpen(item as any, event);
+                        break;
+                    case FLAT_TREE_ACTIONS.toggleActive:
+                        props.toggleItemActive(event, item as any);
+                        break;
+                    default:
+                        break;
+                }
+            }
+        }}
+    >
+        {
+            (props.it.items || [])
+                .map((item: any, index: number) => <div key={item.id || index} data-id={item.id}
+                    className={classnames(props.classes.childItem, { [props.classes.active]: item.active })}
+                    style={{ paddingLeft: `${item.depth * props.levelIndentation}px` }}>
+                    {isInFavoritesTree(props.it) ? 
+                        <div className={props.classes.indentSpacer} />
+                        :
+                        <i data-action={FLAT_TREE_ACTIONS.toggleOpen} className={props.classes.toggableIconContainer}>
+                            <ListItemIcon className={props.getToggableIconClassNames(item.open, item.active)}>
+                                {props.getProperArrowAnimation(item.status, item.items!)}
+                            </ListItemIcon> 
+                        </i>}
+                    {props.showSelection(item) && !props.useRadioButtons &&
+                        <Checkbox
+                            checked={item.selected}
+                            className={props.classes.checkbox}
+                            color="primary"
+                            onClick={props.handleCheckboxChange(item)} />}
+                    {props.showSelection(item) && props.useRadioButtons &&
+                        <Radio
+                            checked={item.selected}
+                            className={props.classes.checkbox}
+                            color="primary" />}
+                    <div data-action={FLAT_TREE_ACTIONS.toggleActive} className={props.classes.renderContainer} ref={item.active ? props.selectedRef : undefined}>
+                        <span style={{ display: 'flex', alignItems: 'center' }}>
+                            <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} headKind={item.data.headKind || null} groupClass={item.data.kind === ResourceKind.GROUP ? item.data.groupClass : ''} classes={props.classes} />
+                            <span style={{ fontSize: '0.875rem' }}>
+                                {item.data.name}
+                            </span>
+                            {
+                                !!item.data.frozenByUuid ? <FreezeIcon className={props.classes.frozenIcon} /> : null
+                            }
+                        </span>
+                    </div>
+                </div>)
+        }
+    </div>;
+
+export const Tree = withStyles(styles)(
+    function<T>(props: TreeProps<T> & WithStyles<CssRules>) {
+        const level = props.level ? props.level : 0;
+        const { classes, render, items, toggleItemActive, toggleItemOpen, disableRipple, currentItemUuid, useRadioButtons, itemsMap } = props;
+        const { list, listItem, loader, toggableIconContainer, renderContainer } = classes;
+        const showSelection = typeof props.showSelection === 'function'
+            ? props.showSelection
+            : () => props.showSelection ? true : false;
+
+        const getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
+            return isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon style={{ fontSize: '14px' }} />;
+        }
+
+        const isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
+            return status === TreeItemStatus.PENDING ||
+                (status === TreeItemStatus.LOADED && !items) ||
+                (status === TreeItemStatus.LOADED && items && items.length === 0);
+        }
+
+        const getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
+            const { iconOpen, iconClose, active, toggableIcon } = props.classes;
+            return classnames(toggableIcon, {
+                [iconOpen]: isOpen,
+                [iconClose]: !isOpen,
+                [active]: isActive
+            });
+        }
+
+        const handleCheckboxChange = (item: TreeItem<T>) => {
+            const { toggleItemSelection } = props;
+            return toggleItemSelection
+                ? (event: React.MouseEvent<HTMLElement>) => {
+                    event.stopPropagation();
+                    toggleItemSelection(event, item);
+                }
+                : undefined;
+        }
+
+        const handleToggleItemOpen = (item: TreeItem<T>, event: React.MouseEvent<HTMLElement>) => {
+            event.stopPropagation();
+            props.toggleItemOpen(event, item);
+        }
+
+        // Scroll to selected item whenever it changes, accepts selectedRef from props for recursive trees
+        const [cachedSelectedRef, setCachedRef] = useState<HTMLDivElement | null>(null)
+        const selectedRef = props.selectedRef || useCallback((node: HTMLDivElement | null) => {
+            if (node && node.scrollIntoView && node !== cachedSelectedRef) {
+                node.scrollIntoView({ behavior: "smooth", block: "center" });
+            }
+            setCachedRef(node);
+        }, [cachedSelectedRef]);
+
+        const { levelIndentation = 20, itemRightPadding = 20 } = props;
+        return <List className={list}>
+            {items && items.map((it: TreeItem<T>, idx: number) => {
+                if (isInFavoritesTree(it) && it.open === true && it.items && it.items.length) {
+                    it = { ...it, items: it.items.filter(item => item.depth && item.depth < 3) }
+                }
+                return <div key={`item/${level}/${it.id}`}>
+                    <ListItem button className={listItem}
+                        style={{
+                            paddingLeft: (level + 1) * levelIndentation,
+                            paddingRight: itemRightPadding,
+                        }}
+                        disableRipple={disableRipple}
+                        onClick={event => toggleItemActive(event, it)}
+                        selected={showSelection(it) && it.id === currentItemUuid}
+                        onContextMenu={(event) => props.onContextMenu(event, it)}>
+                        {it.status === TreeItemStatus.PENDING ?
+                            <CircularProgress size={10} className={loader} /> : null}
+                        <i onClick={(e) => handleToggleItemOpen(it, e)}
+                            className={toggableIconContainer}>
+                            <ListItemIcon className={getToggableIconClassNames(it.open, it.active)}>
+                                {getProperArrowAnimation(it.status, it.items!)}
+                            </ListItemIcon>
+                        </i>
+                        {showSelection(it) && !useRadioButtons &&
+                            <Checkbox
+                                checked={it.selected}
+                                indeterminate={!it.selected && it.indeterminate}
+                                className={classes.checkbox}
+                                color="primary"
+                                onClick={handleCheckboxChange(it)} />}
+                        {showSelection(it) && useRadioButtons &&
+                            <Radio
+                                checked={it.selected}
+                                className={classes.checkbox}
+                                color="primary" />}
+                        <div className={renderContainer} ref={!!it.active ? selectedRef : undefined}>
+                            {render(it, level)}
+                        </div>
+                    </ListItem>
+                    {
+                        it.open && it.items && it.items.length > 0 &&
+                            it.flatTree ?
+                            <FlatTree
+                                it={it}
+                                itemsMap={itemsMap}
+                                showSelection={showSelection}
+                                classes={props.classes}
+                                useRadioButtons={useRadioButtons}
+                                levelIndentation={levelIndentation}
+                                handleCheckboxChange={handleCheckboxChange}
+                                onContextMenu={props.onContextMenu}
+                                handleToggleItemOpen={handleToggleItemOpen}
+                                toggleItemActive={props.toggleItemActive}
+                                getToggableIconClassNames={getToggableIconClassNames}
+                                getProperArrowAnimation={getProperArrowAnimation}
+                                selectedRef={selectedRef}
+                            /> :
+                            <Collapse in={it.open} timeout="auto" unmountOnExit>
+                                <Tree
+                                    showSelection={props.showSelection}
+                                    items={it.items}
+                                    render={render}
+                                    disableRipple={disableRipple}
+                                    toggleItemOpen={toggleItemOpen}
+                                    toggleItemActive={toggleItemActive}
+                                    level={level + 1}
+                                    onContextMenu={props.onContextMenu}
+                                    toggleItemSelection={props.toggleItemSelection}
+                                    selectedRef={selectedRef}
+                                />
+                            </Collapse>
+                    }
+                </div>;
+            })}
+        </List>;
+    }
+);
diff --git a/services/workbench2/src/components/tree/virtual-tree.tsx b/services/workbench2/src/components/tree/virtual-tree.tsx
new file mode 100644 (file)
index 0000000..ca7cd40
--- /dev/null
@@ -0,0 +1,200 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import classnames from "classnames";
+import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
+import { ReactElement } from "react";
+import { FixedSizeList, ListChildComponentProps } from "react-window";
+import AutoSizer from "react-virtualized-auto-sizer";
+
+import { ArvadosTheme } from 'common/custom-theme';
+import { TreeItem, TreeProps, TreeItemStatus } from './tree';
+import { ListItem, Radio, Checkbox, CircularProgress, ListItemIcon } from '@material-ui/core';
+import { SidePanelRightArrowIcon } from '../icon/icon';
+
+type CssRules = 'list'
+    | 'listItem'
+    | 'active'
+    | 'loader'
+    | 'toggableIconContainer'
+    | 'iconClose'
+    | 'renderContainer'
+    | 'iconOpen'
+    | 'toggableIcon'
+    | 'checkbox'
+    | 'virtualFileTree'
+    | 'virtualizedList';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    list: {
+        padding: '3px 0px',
+    },
+    virtualFileTree: {
+        "&:last-child": {
+            paddingBottom: 20
+          }
+    },
+    virtualizedList: {
+        height: '200px',
+    },
+    listItem: {
+        padding: '3px 0px',
+    },
+    loader: {
+        position: 'absolute',
+        transform: 'translate(0px)',
+        top: '3px'
+    },
+    toggableIconContainer: {
+        color: theme.palette.grey["700"],
+        height: '14px',
+        width: '14px',
+    },
+    toggableIcon: {
+        fontSize: '14px'
+    },
+    renderContainer: {
+        flex: 1
+    },
+    active: {
+        color: theme.palette.primary.main,
+    },
+    iconClose: {
+        transition: 'all 0.1s ease',
+    },
+    iconOpen: {
+        transition: 'all 0.1s ease',
+        transform: 'rotate(90deg)',
+    },
+    checkbox: {
+        width: theme.spacing.unit * 3,
+        height: theme.spacing.unit * 3,
+        margin: `0 ${theme.spacing.unit}px`,
+        padding: 0,
+        color: theme.palette.grey["500"],
+    }
+});
+
+export interface VirtualTreeItem<T> extends TreeItem<T> {
+    itemCount?: number;
+    level?: number;
+}
+
+// For some reason, on TSX files it isn't accepted just one generic param, so
+// I'm using <T, _> as a workaround.
+// eslint-disable-next-line
+export const Row =  <T, _>(itemList: VirtualTreeItem<T>[], render: any, treeProps: TreeProps<T>) => withStyles(styles)(
+    (props: React.PropsWithChildren<ListChildComponentProps> & WithStyles<CssRules>) => {
+        const { index, style, classes } = props;
+        const it = itemList[index];
+        const level = it.level || 0;
+        const { toggleItemActive, disableRipple, currentItemUuid, useRadioButtons } = treeProps;
+        const { listItem, loader, toggableIconContainer, renderContainer, virtualFileTree } = classes;
+        const { levelIndentation = 20, itemRightPadding = 20 } = treeProps;
+
+        const showSelection = typeof treeProps.showSelection === 'function'
+            ? treeProps.showSelection
+            : () => treeProps.showSelection ? true : false;
+
+        const handleRowContextMenu = (item: VirtualTreeItem<T>) =>
+            (event: React.MouseEvent<HTMLElement>) => {
+                treeProps.onContextMenu(event, item);
+            };
+
+        const handleToggleItemOpen = (item: VirtualTreeItem<T>) =>
+            (event: React.MouseEvent<HTMLElement>) => {
+                event.stopPropagation();
+                treeProps.toggleItemOpen(event, item);
+            };
+
+        const getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
+            const { iconOpen, iconClose, active, toggableIcon } = props.classes;
+            return classnames(toggableIcon, {
+                [iconOpen]: isOpen,
+                [iconClose]: !isOpen,
+                [active]: isActive
+            });
+        };
+
+        const isSidePanelIconNotNeeded = (status: string, itemCount: number) => {
+            return status === TreeItemStatus.PENDING ||
+                (status === TreeItemStatus.LOADED && itemCount === 0);
+        };
+
+        const getProperArrowAnimation = (status: string, itemCount: number) => {
+            return isSidePanelIconNotNeeded(status, itemCount) ? <span /> : <SidePanelRightArrowIcon style={{ fontSize: '14px' }} />;
+        };
+
+        const handleCheckboxChange = (item: VirtualTreeItem<T>) => {
+            const { toggleItemSelection } = treeProps;
+            return toggleItemSelection
+                ? (event: React.MouseEvent<HTMLElement>) => {
+                    event.stopPropagation();
+                    toggleItemSelection(event, item);
+                }
+                : undefined;
+        };
+
+        return <div className={virtualFileTree} data-cy='virtual-file-tree' style={style}>
+            <ListItem button className={listItem}
+                style={{
+                    paddingLeft: (level + 1) * levelIndentation,
+                    paddingRight: itemRightPadding,
+                }}
+                disableRipple={disableRipple}
+                onClick={event => toggleItemActive(event, it)}
+                selected={showSelection(it) && it.id === currentItemUuid}
+                onContextMenu={handleRowContextMenu(it)}>
+                {it.status === TreeItemStatus.PENDING ?
+                    <CircularProgress size={10} className={loader} /> : null}
+                <i onClick={handleToggleItemOpen(it)}
+                    className={toggableIconContainer}>
+                    <ListItemIcon className={getToggableIconClassNames(it.open, it.active)}>
+                        {getProperArrowAnimation(it.status, it.itemCount!)}
+                    </ListItemIcon>
+                </i>
+                {showSelection(it) && !useRadioButtons &&
+                    <Checkbox
+                        checked={it.selected}
+                        className={classes.checkbox}
+                        color="primary"
+                        onClick={handleCheckboxChange(it)} />}
+                {showSelection(it) && useRadioButtons &&
+                    <Radio
+                        checked={it.selected}
+                        className={classes.checkbox}
+                        color="primary" />}
+                <div className={renderContainer}>
+                    {render(it, level)}
+                </div>
+            </ListItem>
+        </div>;
+    });
+
+const itemSize = 30;
+
+// eslint-disable-next-line
+export const VirtualList = <T, _>(height: number, width: number, items: VirtualTreeItem<T>[], render: any, treeProps: TreeProps<T>) =>
+    <FixedSizeList
+        height={height}
+        itemCount={items.length}
+        itemSize={itemSize}
+        width={width}
+    >
+        {Row(items, render, treeProps)}
+    </FixedSizeList>;
+
+export const VirtualTree = withStyles(styles)(
+    class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
+        render(): ReactElement<any> {
+            const { items, render } = this.props;
+            return <AutoSizer>
+                {({ height, width }) => {
+                    return VirtualList(height, width, items || [], render, this.props);
+                }}
+            </AutoSizer>;
+        }
+    }
+);
diff --git a/services/workbench2/src/components/warning-collection/warning-collection.tsx b/services/workbench2/src/components/warning-collection/warning-collection.tsx
new file mode 100644 (file)
index 0000000..deb6710
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { WarningIcon } from "components/icon/icon";
+import { StyleRulesCallback, DialogContentText, WithStyles, withStyles } from "@material-ui/core";
+import { ArvadosTheme } from 'common/custom-theme';
+
+type CssRules = 'container' | 'text';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    container: {
+        display: 'flex',
+        alignItems: 'center',
+    },
+    text: {
+        paddingLeft: '8px'
+    }
+});
+
+interface WarningCollectionProps {
+    text: string;
+}
+
+export const WarningCollection = withStyles(styles)(({ classes, text }: WarningCollectionProps & WithStyles<CssRules>) =>
+    <span className={classes.container}>
+        <WarningIcon />
+        <DialogContentText className={classes.text}>{text}</DialogContentText>
+    </span>);
\ No newline at end of file
diff --git a/services/workbench2/src/components/warning/warning.tsx b/services/workbench2/src/components/warning/warning.tsx
new file mode 100644 (file)
index 0000000..d459a37
--- /dev/null
@@ -0,0 +1,41 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { ErrorIcon } from "components/icon/icon";
+import { Tooltip } from "@material-ui/core";
+import { disallowSlash } from "validators/valid-name";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+
+interface WarningComponentProps {
+    text: string;
+    rules: RegExp[];
+    message: string;
+}
+
+export const WarningComponent = ({ text, rules, message }: WarningComponentProps) =>
+    !text ? <Tooltip title={"No name"}><ErrorIcon /></Tooltip>
+        : (rules.find(aRule => text.match(aRule) !== null)
+            ? message
+                ? <Tooltip title={message}><ErrorIcon /></Tooltip>
+                : <ErrorIcon />
+            : null);
+
+interface IllegalNamingWarningProps {
+    name: string;
+    validate: RegExp[];
+}
+
+
+export const IllegalNamingWarning = connect(
+    (state: RootState) => {
+        return {
+            validate: (state.auth.config.clusterConfig.Collections.ForwardSlashNameSubstitution === "" ?
+                [disallowSlash] : [])
+        };
+    })(({ name, validate }: IllegalNamingWarningProps) =>
+        <WarningComponent
+            text={name} rules={validate}
+            message="Names embedding '/' will be renamed or invisible to file system access (arv-mount or WebDAV)" />);
diff --git a/services/workbench2/src/components/workflow-inputs-form/validators.ts b/services/workbench2/src/components/workflow-inputs-form/validators.ts
new file mode 100644 (file)
index 0000000..1de21d9
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommandInputParameter } from 'models/workflow';
+import { require } from 'validators/require';
+import { CWLType } from '../../models/workflow';
+
+
+const alwaysValid = () => undefined;
+
+export const required = ({ type }: CommandInputParameter) => {
+    if (type instanceof Array) {
+        for (const t of type) {
+            if (t === CWLType.NULL) {
+                return alwaysValid;
+            }
+        }
+    }
+    return require;
+};
diff --git a/services/workbench2/src/components/workflow-inputs-form/workflow-input.tsx b/services/workbench2/src/components/workflow-inputs-form/workflow-input.tsx
new file mode 100644 (file)
index 0000000..d555d3c
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { CommandInputParameter } from 'models/workflow';
+import { TextField } from '@material-ui/core';
+import { required } from 'components/workflow-inputs-form/validators';
+
+export interface WorkflowInputProps {
+    input: CommandInputParameter;
+}
+export const WorkflowInput = ({ input }: WorkflowInputProps) =>
+    <TextField
+        label={`${input.label || input.id}${required(input)() ? '*' : ''}`}
+        name={input.id}
+        helperText={input.doc}
+        fullWidth />;
\ No newline at end of file
diff --git a/services/workbench2/src/index.css b/services/workbench2/src/index.css
new file mode 100644 (file)
index 0000000..51f0776
--- /dev/null
@@ -0,0 +1,29 @@
+body {
+    margin: 0;
+    padding: 0;
+    font-family: 'Roboto', "Helvetica", "Arial", sans-serif;
+    width: 100vw;
+    height: 100vh;
+}
+
+.app-banner {
+    width: calc(100% - 2rem);
+    height: 150px;
+    z-index: 11111;
+    position: fixed;
+    top: 0px;
+    background-color: #00bfa5;
+    border: 1px solid #01685a;
+    color: #ffffff;
+    margin: 1rem;
+    box-sizing: border-box;
+    cursor: pointer;
+}
+
+.app-banner span {
+    font-size: 2rem;
+    text-align: center;
+    display: block;
+    margin: auto;
+    padding: 2rem;
+}
\ No newline at end of file
diff --git a/services/workbench2/src/index.tsx b/services/workbench2/src/index.tsx
new file mode 100644 (file)
index 0000000..400b975
--- /dev/null
@@ -0,0 +1,247 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import ReactDOM from "react-dom";
+import { Provider } from "react-redux";
+import { MainPanel } from "views/main-panel/main-panel";
+import "index.css";
+import { Route, Switch } from "react-router";
+import { createBrowserHistory } from "history";
+import { History } from "history";
+import { configureStore, RootStore } from "store/store";
+import { ConnectedRouter } from "react-router-redux";
+import { ApiToken } from "views-components/api-token/api-token";
+import { AddSession } from "views-components/add-session/add-session";
+import { initAuth, logout } from "store/auth/auth-action";
+import { createServices } from "services/services";
+import { MuiThemeProvider } from "@material-ui/core/styles";
+import { CustomTheme } from "common/custom-theme";
+import { fetchConfig } from "common/config";
+import servicesProvider from "common/service-provider";
+import { addMenuActionSet } from "views-components/context-menu/context-menu";
+import { ContextMenuKind } from "views-components/context-menu/menu-item-sort";
+import { rootProjectActionSet } from "views-components/context-menu/action-sets/root-project-action-set";
+import {
+    filterGroupActionSet,
+    frozenActionSet,
+    projectActionSet,
+    readOnlyProjectActionSet,
+} from "views-components/context-menu/action-sets/project-action-set";
+import { resourceActionSet } from "views-components/context-menu/action-sets/resource-action-set";
+import { favoriteActionSet } from "views-components/context-menu/action-sets/favorite-action-set";
+import {
+    collectionFilesActionSet,
+    collectionFilesMultipleActionSet,
+    readOnlyCollectionFilesActionSet,
+    readOnlyCollectionFilesMultipleActionSet,
+} from "views-components/context-menu/action-sets/collection-files-action-set";
+import {
+    collectionDirectoryItemActionSet,
+    collectionFileItemActionSet,
+    readOnlyCollectionDirectoryItemActionSet,
+    readOnlyCollectionFileItemActionSet,
+} from "views-components/context-menu/action-sets/collection-files-item-action-set";
+import { collectionFilesNotSelectedActionSet } from "views-components/context-menu/action-sets/collection-files-not-selected-action-set";
+import {
+    collectionActionSet,
+    collectionAdminActionSet,
+    oldCollectionVersionActionSet,
+    readOnlyCollectionActionSet,
+} from "views-components/context-menu/action-sets/collection-action-set";
+import { loadWorkbench } from "store/workbench/workbench-actions";
+import { Routes } from "routes/routes";
+import { trashActionSet } from "views-components/context-menu/action-sets/trash-action-set";
+import { ServiceRepository } from "services/services";
+import { initWebSocket } from "websocket/websocket";
+import { Config } from "common/config";
+import { addRouteChangeHandlers } from "./routes/route-change-handlers";
+import { setTokenDialogApiHost } from "store/token-dialog/token-dialog-actions";
+import {
+    processResourceActionSet,
+    runningProcessResourceActionSet,
+    processResourceAdminActionSet,
+    runningProcessResourceAdminActionSet,
+    readOnlyProcessResourceActionSet,
+} from "views-components/context-menu/action-sets/process-resource-action-set";
+import { trashedCollectionActionSet } from "views-components/context-menu/action-sets/trashed-collection-action-set";
+import { setBuildInfo } from "store/app-info/app-info-actions";
+import { getBuildInfo } from "common/app-info";
+import { DragDropContextProvider } from "react-dnd";
+import HTML5Backend from "react-dnd-html5-backend";
+import { initAdvancedFormProjectsTree } from "store/search-bar/search-bar-actions";
+import { repositoryActionSet } from "views-components/context-menu/action-sets/repository-action-set";
+import { sshKeyActionSet } from "views-components/context-menu/action-sets/ssh-key-action-set";
+import { keepServiceActionSet } from "views-components/context-menu/action-sets/keep-service-action-set";
+import { loadVocabulary } from "store/vocabulary/vocabulary-actions";
+import { virtualMachineActionSet } from "views-components/context-menu/action-sets/virtual-machine-action-set";
+import { userActionSet } from "views-components/context-menu/action-sets/user-action-set";
+import { apiClientAuthorizationActionSet } from "views-components/context-menu/action-sets/api-client-authorization-action-set";
+import { groupActionSet } from "views-components/context-menu/action-sets/group-action-set";
+import { groupMemberActionSet } from "views-components/context-menu/action-sets/group-member-action-set";
+import { linkActionSet } from "views-components/context-menu/action-sets/link-action-set";
+import { loadFileViewersConfig } from "store/file-viewers/file-viewers-actions";
+import {
+    filterGroupAdminActionSet,
+    frozenAdminActionSet,
+    projectAdminActionSet,
+} from "views-components/context-menu/action-sets/project-admin-action-set";
+import { permissionEditActionSet } from "views-components/context-menu/action-sets/permission-edit-action-set";
+import { workflowActionSet, readOnlyWorkflowActionSet } from "views-components/context-menu/action-sets/workflow-action-set";
+import { storeRedirects } from "./common/redirect-to";
+import { searchResultsActionSet } from "views-components/context-menu/action-sets/search-results-action-set";
+
+import 'bootstrap/dist/css/bootstrap.min.css';
+import '@coreui/coreui/dist/css/coreui.min.css';
+
+console.log(`Starting arvados [${getBuildInfo()}]`);
+
+addMenuActionSet(ContextMenuKind.ROOT_PROJECT, rootProjectActionSet);
+addMenuActionSet(ContextMenuKind.PROJECT, projectActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_PROJECT, readOnlyProjectActionSet);
+addMenuActionSet(ContextMenuKind.FILTER_GROUP, filterGroupActionSet);
+addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet);
+addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILES_MULTIPLE, collectionFilesMultipleActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES, readOnlyCollectionFilesActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES_MULTIPLE, readOnlyCollectionFilesMultipleActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILES_NOT_SELECTED, collectionFilesNotSelectedActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_DIRECTORY_ITEM, collectionDirectoryItemActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM, readOnlyCollectionDirectoryItemActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILE_ITEM, collectionFileItemActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILE_ITEM, readOnlyCollectionFileItemActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION, collectionActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_COLLECTION, readOnlyCollectionActionSet);
+addMenuActionSet(ContextMenuKind.OLD_VERSION_COLLECTION, oldCollectionVersionActionSet);
+addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet);
+addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet);
+addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_RESOURCE, runningProcessResourceActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_PROCESS_RESOURCE, readOnlyProcessResourceActionSet);
+addMenuActionSet(ContextMenuKind.TRASH, trashActionSet);
+addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet);
+addMenuActionSet(ContextMenuKind.SSH_KEY, sshKeyActionSet);
+addMenuActionSet(ContextMenuKind.VIRTUAL_MACHINE, virtualMachineActionSet);
+addMenuActionSet(ContextMenuKind.KEEP_SERVICE, keepServiceActionSet);
+addMenuActionSet(ContextMenuKind.USER, userActionSet);
+addMenuActionSet(ContextMenuKind.LINK, linkActionSet);
+addMenuActionSet(ContextMenuKind.API_CLIENT_AUTHORIZATION, apiClientAuthorizationActionSet);
+addMenuActionSet(ContextMenuKind.GROUPS, groupActionSet);
+addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
+addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
+addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_ADMIN, runningProcessResourceAdminActionSet);
+addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
+addMenuActionSet(ContextMenuKind.FROZEN_PROJECT, frozenActionSet);
+addMenuActionSet(ContextMenuKind.FROZEN_PROJECT_ADMIN, frozenAdminActionSet);
+addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet);
+addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_WORKFLOW, readOnlyWorkflowActionSet);
+addMenuActionSet(ContextMenuKind.WORKFLOW, workflowActionSet);
+addMenuActionSet(ContextMenuKind.SEARCH_RESULTS, searchResultsActionSet);
+
+storeRedirects();
+
+fetchConfig().then(({ config, apiHost }) => {
+    const history = createBrowserHistory();
+
+    // Provide browser's history access to Cypress to allow programmatic
+    // navigation.
+    if ((window as any).Cypress) {
+        (window as any).appHistory = history;
+    }
+
+    const services = createServices(config, {
+        progressFn: (id, working) => {
+        },
+        errorFn: (id, error, showSnackBar: boolean) => {
+            if (showSnackBar) {
+                console.error("Backend error:", error);
+                if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) {
+                    // Catch auth errors when navigating and redirect to login preserving url location
+                    store.dispatch(logout(false, true));
+                }
+            }
+        },
+    });
+
+    // be sure this is initiated before the app starts
+    servicesProvider.setServices(services);
+
+    const store = configureStore(history, services, config);
+
+    servicesProvider.setStore(store);
+
+    store.subscribe(initListener(history, store, services, config));
+    store.dispatch(initAuth(config));
+    store.dispatch(setBuildInfo());
+    store.dispatch(setTokenDialogApiHost(apiHost));
+    store.dispatch(loadVocabulary);
+    store.dispatch(loadFileViewersConfig);
+
+    const TokenComponent = (props: any) => (
+        <ApiToken
+            authService={services.authService}
+            config={config}
+            loadMainApp={true}
+            {...props}
+        />
+    );
+    const AddSessionComponent = (props: any) => <AddSession {...props} />;
+    const FedTokenComponent = (props: any) => (
+        <ApiToken
+            authService={services.authService}
+            config={config}
+            loadMainApp={false}
+            {...props}
+        />
+    );
+    const MainPanelComponent = (props: any) => <MainPanel {...props} />;
+
+    const App = () => (
+        <MuiThemeProvider theme={CustomTheme}>
+            <DragDropContextProvider backend={HTML5Backend}>
+                <Provider store={store}>
+                    <ConnectedRouter history={history}>
+                        <Switch>
+                            <Route
+                                path={Routes.TOKEN}
+                                component={TokenComponent}
+                            />
+                            <Route
+                                path={Routes.FED_LOGIN}
+                                component={FedTokenComponent}
+                            />
+                            <Route
+                                path={Routes.ADD_SESSION}
+                                component={AddSessionComponent}
+                            />
+                            <Route
+                                path={Routes.ROOT}
+                                component={MainPanelComponent}
+                            />
+                        </Switch>
+                    </ConnectedRouter>
+                </Provider>
+            </DragDropContextProvider>
+        </MuiThemeProvider>
+    );
+
+    ReactDOM.render(<App />, document.getElementById("root") as HTMLElement);
+});
+
+const initListener = (history: History, store: RootStore, services: ServiceRepository, config: Config) => {
+    let initialized = false;
+    return async () => {
+        const { router, auth } = store.getState();
+        if (router.location && auth.user && services.authService.getApiToken() && !initialized) {
+            initialized = true;
+            initWebSocket(config, services.authService, store);
+            await store.dispatch(loadWorkbench());
+            addRouteChangeHandlers(history, store);
+            // ToDo: move to searchBar component
+            store.dispatch(initAdvancedFormProjectsTree());
+        }
+    };
+};
diff --git a/services/workbench2/src/lib/cwl-svg/assets/cmd.png b/services/workbench2/src/lib/cwl-svg/assets/cmd.png
new file mode 100644 (file)
index 0000000..04fc982
Binary files /dev/null and b/services/workbench2/src/lib/cwl-svg/assets/cmd.png differ
diff --git a/services/workbench2/src/lib/cwl-svg/assets/images/file_input.svg b/services/workbench2/src/lib/cwl-svg/assets/images/file_input.svg
new file mode 100644 (file)
index 0000000..8e712c9
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 499 462.86"><title>file_input</title><g id="Layer_16" data-name="Layer 16"><polygon points="386.06 0 386.06 0 175 0 175 58.29 225 108.29 225 50 365.35 50 449 133.65 449 412.86 225 412.86 225 353.71 175 403.71 175 462.86 499 462.86 499 112.94 386.06 0"/></g><g id="Layer_7_copy" data-name="Layer 7 copy"><polyline points="498.78 138.76 362.93 138.38 362.81 138.38 362.81 1.06" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:50px"/></g><g id="Layer_11_copy" data-name="Layer 11 copy"><polyline points="159 327 255 231 160 136" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:50px"/><g id="Layer_9_copy_2" data-name="Layer 9 copy 2"><line y1="231" x2="255" y2="231" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:50px"/></g></g></svg>
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/assets/images/file_output.svg b/services/workbench2/src/lib/cwl-svg/assets/images/file_output.svg
new file mode 100644 (file)
index 0000000..30b963d
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 507.36 462.86"><title>file_output</title><g id="Layer_10" data-name="Layer 10"><g id="Layer_9_copy" data-name="Layer 9 copy"><polygon points="274 298.5 274 412.86 50 412.86 50 50 190.35 50 274 133.65 274 163.5 324 163.5 324 112.94 211.06 0 211.06 0 0 0 0 462.86 324 462.86 324 298.5 274 298.5"/></g></g><g id="Layer_7" data-name="Layer 7"><polyline points="323.78 138.76 187.93 138.38 187.81 138.38 187.81 1.06" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:50px"/></g><g id="Layer_11" data-name="Layer 11"><polyline points="376 327 472 231 377 136" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:50px"/><g id="Layer_9" data-name="Layer 9"><line x1="217" y1="231" x2="472" y2="231" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:50px"/></g></g></svg>
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/assets/images/tool.svg b/services/workbench2/src/lib/cwl-svg/assets/images/tool.svg
new file mode 100644 (file)
index 0000000..2a0243c
--- /dev/null
@@ -0,0 +1 @@
+<svg id="tool" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500.07 500.24"><title>tool_new</title><rect x="284.07" y="450.07" width="216" height="50"/><rect x="-34.14" y="117.56" width="353.4" height="50" transform="translate(142.62 -58.98) rotate(45)"/><rect x="-34.15" y="332.53" width="353.47" height="50" transform="translate(496.28 509.58) rotate(135)"/></svg>
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/assets/images/type_input.svg b/services/workbench2/src/lib/cwl-svg/assets/images/type_input.svg
new file mode 100644 (file)
index 0000000..f406df8
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 499 365"><title>type_input</title><g id="input"><path d="M316.5,68a181.72,181.72,0,0,0-114.12,40.09L238,143.72a132.5,132.5,0,1,1,1.16,214.39L203.48,393.8A182.5,182.5,0,1,0,316.5,68Z" transform="translate(0 -68)"/><g id="Layer_22" data-name="Layer 22"><g id="Layer_9_copy_4" data-name="Layer 9 copy 4"><polygon points="290.36 182 176.68 295.68 141.32 260.32 194.64 207 0 207 0 157 194.64 157 142.32 104.68 177.68 69.32 290.36 182"/></g></g></g></svg>
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/assets/images/type_output.svg b/services/workbench2/src/lib/cwl-svg/assets/images/type_output.svg
new file mode 100644 (file)
index 0000000..3b14838
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500.36 365"><title>type_output</title><g id="output"><path d="M291.95,325.23a134,134,0,0,1-15.76,19,132.5,132.5,0,1,1,0-187.38,133.9,133.9,0,0,1,16.16,19.55l35.81-35.81A182.5,182.5,0,1,0,327.73,361Z" transform="translate(0 -68)"/><g id="circle_source_copy" data-name="circle source copy"><g id="Layer_22_copy" data-name="Layer 22 copy"><g id="Layer_9_copy_5" data-name="Layer 9 copy 5"><polygon points="500.36 182 386.68 295.68 351.32 260.32 404.64 207 210 207 210 157 404.64 157 352.32 104.68 387.68 69.32 500.36 182"/></g></g></g></g></svg>
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/assets/images/workflow.svg b/services/workbench2/src/lib/cwl-svg/assets/images/workflow.svg
new file mode 100644 (file)
index 0000000..c6197bb
--- /dev/null
@@ -0,0 +1 @@
+<svg id="workflow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><title>workflow_new</title><circle cx="400.5" cy="249.5" r="99.5"/><circle cx="99.5" cy="99.5" r="99.5"/><circle cx="99.5" cy="400.5" r="99.5"/><g id="Layer_4" data-name="Layer 4"><line x1="99" y1="99" x2="400" y2="249" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:40px"/><line x1="99" y1="400" x2="400" y2="249" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:40px"/></g></svg>
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/assets/styles/_variables.scss b/services/workbench2/src/lib/cwl-svg/assets/styles/_variables.scss
new file mode 100644 (file)
index 0000000..5a13da8
--- /dev/null
@@ -0,0 +1,50 @@
+
+// Colors
+$color-primary: #11a7a7 !default;
+$color-neutral: rgb(154, 154, 154) !default;
+$background-color: white !default;
+
+// Workflow
+$background: $background-color !default;
+
+// Fonts
+$font-color: #333 !default;
+$text-stroke: #fff !default;
+$font-family: sans-serif !default;
+
+// Labels
+$label-stroke-color: $background-color !default;
+$label-stroke-width: 4px !default;
+
+// Edges
+$edge-inner-color: #222 !default;
+$edge-outer-color: #fff !default;
+$edge-inner-hover-color: $color-primary !default;
+$edge-inner-stroke-width: 2px !default;
+$edge-inner-stroke-color: $color-neutral !default;
+$edge-outer-stroke-width: 5px !default;
+$edge-outer-stroke-color: $background-color !default;
+
+// Nodes
+$node-outer-fill-color: $background-color !default;
+$node-outer-stroke-color: $color-neutral !default;
+$node-outer-stroke-width: 2px !default;
+
+$node-input-fill-color: #c3c3c3 !default;
+$node-output-fill-color: #c3c3c3 !default;
+$node-step-fill-color: $color-primary !default;
+
+$node-hover-port-transition: all .1s !default;
+
+// Node Icons
+$node-icon-fill-color: $font-color !default;
+$node-icon-stroke-color: $font-color !default;
+$node-icon-stroke-width: 3px !default;
+
+// Ports
+$port-fill-color: $color-neutral !default;
+$port-hover-stroke-color: darken($port-fill-color, 20%) !default;
+$port-hover-stroke-width: 2px !default;
+$port-label-color: $font-color !default;
+$port-label-size: .9em !default;
+
diff --git a/services/workbench2/src/lib/cwl-svg/assets/styles/style.css b/services/workbench2/src/lib/cwl-svg/assets/styles/style.css
new file mode 100644 (file)
index 0000000..2cc9693
--- /dev/null
@@ -0,0 +1,80 @@
+svg.cwl-workflow {
+  background: white;
+  color: #333;
+  font-family: sans-serif;
+  padding: 0;
+  width: 100%;
+  display: block;
+  transform: translateZ(0); }
+  svg.cwl-workflow [tabindex]:active, svg.cwl-workflow [tabindex]:focus {
+    outline: none; }
+  svg.cwl-workflow .hidden {
+    display: none; }
+  svg.cwl-workflow .workflow {
+    user-select: none; }
+  svg.cwl-workflow .pan-handle {
+    fill: transparent; }
+  svg.cwl-workflow .label {
+    fill: #333;
+    stroke: white;
+    stroke-width: 4px;
+    text-anchor: middle;
+    paint-order: stroke;
+    stroke-linecap: butt;
+    stroke-linejoin: miter; }
+  svg.cwl-workflow .node-icon {
+    fill: #333;
+    stroke: #333;
+    stroke-width: 3px;
+    stroke-linecap: round; }
+  svg.cwl-workflow .node .outer {
+    fill: white;
+    stroke: #9a9a9a;
+    stroke-width: 2px; }
+  svg.cwl-workflow .node .inner {
+    stroke: 0; }
+  svg.cwl-workflow .node.input .inner {
+    fill: #c3c3c3; }
+  svg.cwl-workflow .node.output .inner {
+    fill: #c3c3c3; }
+  svg.cwl-workflow .node.step .inner {
+    fill: #11a7a7; }
+  svg.cwl-workflow .node .core .inner,
+  svg.cwl-workflow .node .core .node-icon {
+    pointer-events: none; }
+  svg.cwl-workflow .node:hover .port .label {
+    transition: all 0.1s;
+    opacity: 1; }
+  svg.cwl-workflow .node .port {
+    fill: #9a9a9a; }
+    svg.cwl-workflow .node .port:hover {
+      stroke: #676767;
+      stroke-width: 2px; }
+    svg.cwl-workflow .node .port.output-port .label {
+      text-anchor: start;
+      transform: translate(10px, 0); }
+    svg.cwl-workflow .node .port.input-port .label {
+      text-anchor: end;
+      transform: translate(-10px, 0); }
+    svg.cwl-workflow .node .port .label {
+      fill: #333;
+      opacity: 0;
+      font-size: .9em;
+      user-select: none;
+      transition: all .1s;
+      pointer-events: none;
+      alignment-baseline: middle; }
+  svg.cwl-workflow .edge:hover .inner {
+    stroke: #11a7a7; }
+  svg.cwl-workflow .edge .inner, svg.cwl-workflow .edge .outer {
+    fill: none;
+    stroke-linecap: round; }
+  svg.cwl-workflow .edge .inner {
+    stroke-width: 2px;
+    stroke: #9a9a9a; }
+  svg.cwl-workflow .edge .outer {
+    stroke-width: 5px;
+    stroke: white; }
+  svg.cwl-workflow .unselectable {
+    user-select: none;
+    cursor: pointer; }
diff --git a/services/workbench2/src/lib/cwl-svg/assets/styles/style.scss b/services/workbench2/src/lib/cwl-svg/assets/styles/style.scss
new file mode 100644 (file)
index 0000000..ff8d4cf
--- /dev/null
@@ -0,0 +1,149 @@
+@import "variables";
+svg.cwl-workflow {
+
+  background: $background;
+  color: $font-color;
+  font-family: $font-family;
+
+  padding: 0;
+  width: 100%;
+  display: block;
+  transform: translateZ(0);
+
+  // We will add tabindex to some elements because they should be focusable, but style should not change
+  [tabindex]:active, [tabindex]:focus {
+    outline: none;
+  }
+
+  .hidden {
+    display: none;
+  }
+
+  .workflow {
+    user-select: none;
+  }
+
+  .pan-handle {
+    // Cannot be “none” because it wouldn't have a clickable zone
+    fill: transparent;
+  }
+
+  .label {
+    fill: $font-color;
+    stroke: $label-stroke-color;
+    stroke-width: $label-stroke-width;
+
+    text-anchor: middle;
+    paint-order: stroke;
+    stroke-linecap: butt;
+    stroke-linejoin: miter;
+
+  }
+
+  .node-icon {
+    fill: $node-icon-fill-color;
+    stroke: $node-icon-stroke-color;
+    stroke-width: $node-icon-stroke-width;
+
+    stroke-linecap: round;
+  }
+
+  .node {
+
+    .outer {
+      fill: $node-outer-fill-color;
+      stroke: $node-outer-stroke-color;
+      stroke-width: $node-outer-stroke-width;
+    }
+
+    .inner {
+      stroke: 0;
+    }
+
+    &.input .inner {
+      fill: $node-input-fill-color;
+    }
+
+    &.output .inner {
+      fill: $node-output-fill-color;
+    }
+
+    &.step .inner {
+      fill: $node-step-fill-color;
+    }
+
+    // Prevent mouseenter/leave events when hovering over nested elements
+    // otherwise, cursor would change while moving the mouse through the node
+    .core {
+      .inner,
+      .node-icon {
+        pointer-events: none;
+      }
+    }
+
+    &:hover {
+      .port .label {
+        transition: $node-hover-port-transition;
+        opacity: 1;
+      }
+    }
+
+    .port {
+      fill: $port-fill-color;
+
+      &:hover {
+        stroke: $port-hover-stroke-color;
+        stroke-width: $port-hover-stroke-width;
+      }
+
+      &.output-port .label {
+        text-anchor: start;
+        transform: translate(10px, 0);
+      }
+
+      &.input-port .label {
+        text-anchor: end;
+        transform: translate(-10px, 0);
+      }
+
+      .label {
+        fill: $port-label-color;
+
+        opacity: 0;
+        font-size: .9em;
+        user-select: none;
+        transition: all .1s;
+        pointer-events: none;
+        alignment-baseline: middle;
+      }
+    }
+  }
+
+  .edge {
+
+    &:hover .inner {
+      stroke: $edge-inner-hover-color;
+    }
+
+    .inner, .outer {
+      fill: none;
+      stroke-linecap: round;
+    }
+
+    .inner {
+      stroke-width: $edge-inner-stroke-width;
+      stroke: $edge-inner-stroke-color;
+    }
+    .outer {
+      stroke-width: $edge-outer-stroke-width;
+      stroke: $edge-outer-stroke-color;
+    }
+
+  }
+
+  .unselectable {
+    user-select: none;
+    cursor: pointer;
+  }
+
+}
diff --git a/services/workbench2/src/lib/cwl-svg/assets/styles/theme.css b/services/workbench2/src/lib/cwl-svg/assets/styles/theme.css
new file mode 100644 (file)
index 0000000..2cc9693
--- /dev/null
@@ -0,0 +1,80 @@
+svg.cwl-workflow {
+  background: white;
+  color: #333;
+  font-family: sans-serif;
+  padding: 0;
+  width: 100%;
+  display: block;
+  transform: translateZ(0); }
+  svg.cwl-workflow [tabindex]:active, svg.cwl-workflow [tabindex]:focus {
+    outline: none; }
+  svg.cwl-workflow .hidden {
+    display: none; }
+  svg.cwl-workflow .workflow {
+    user-select: none; }
+  svg.cwl-workflow .pan-handle {
+    fill: transparent; }
+  svg.cwl-workflow .label {
+    fill: #333;
+    stroke: white;
+    stroke-width: 4px;
+    text-anchor: middle;
+    paint-order: stroke;
+    stroke-linecap: butt;
+    stroke-linejoin: miter; }
+  svg.cwl-workflow .node-icon {
+    fill: #333;
+    stroke: #333;
+    stroke-width: 3px;
+    stroke-linecap: round; }
+  svg.cwl-workflow .node .outer {
+    fill: white;
+    stroke: #9a9a9a;
+    stroke-width: 2px; }
+  svg.cwl-workflow .node .inner {
+    stroke: 0; }
+  svg.cwl-workflow .node.input .inner {
+    fill: #c3c3c3; }
+  svg.cwl-workflow .node.output .inner {
+    fill: #c3c3c3; }
+  svg.cwl-workflow .node.step .inner {
+    fill: #11a7a7; }
+  svg.cwl-workflow .node .core .inner,
+  svg.cwl-workflow .node .core .node-icon {
+    pointer-events: none; }
+  svg.cwl-workflow .node:hover .port .label {
+    transition: all 0.1s;
+    opacity: 1; }
+  svg.cwl-workflow .node .port {
+    fill: #9a9a9a; }
+    svg.cwl-workflow .node .port:hover {
+      stroke: #676767;
+      stroke-width: 2px; }
+    svg.cwl-workflow .node .port.output-port .label {
+      text-anchor: start;
+      transform: translate(10px, 0); }
+    svg.cwl-workflow .node .port.input-port .label {
+      text-anchor: end;
+      transform: translate(-10px, 0); }
+    svg.cwl-workflow .node .port .label {
+      fill: #333;
+      opacity: 0;
+      font-size: .9em;
+      user-select: none;
+      transition: all .1s;
+      pointer-events: none;
+      alignment-baseline: middle; }
+  svg.cwl-workflow .edge:hover .inner {
+    stroke: #11a7a7; }
+  svg.cwl-workflow .edge .inner, svg.cwl-workflow .edge .outer {
+    fill: none;
+    stroke-linecap: round; }
+  svg.cwl-workflow .edge .inner {
+    stroke-width: 2px;
+    stroke: #9a9a9a; }
+  svg.cwl-workflow .edge .outer {
+    stroke-width: 5px;
+    stroke: white; }
+  svg.cwl-workflow .unselectable {
+    user-select: none;
+    cursor: pointer; }
diff --git a/services/workbench2/src/lib/cwl-svg/assets/styles/theme.scss b/services/workbench2/src/lib/cwl-svg/assets/styles/theme.scss
new file mode 100644 (file)
index 0000000..56e6580
--- /dev/null
@@ -0,0 +1,2 @@
+@import "./variables.scss";
+@import "./style.scss";
diff --git a/services/workbench2/src/lib/cwl-svg/assets/styles/themes/rabix-dark/_variables.scss b/services/workbench2/src/lib/cwl-svg/assets/styles/themes/rabix-dark/_variables.scss
new file mode 100644 (file)
index 0000000..32cda65
--- /dev/null
@@ -0,0 +1,20 @@
+
+
+// Colors
+$background-color: rgb(48, 48, 48) !default;
+
+// Fonts
+$font-color: white !default;
+
+// Edges
+$edge-outer-stroke-width: 7px !default;
+
+// Node Icons
+$node-icon-fill-color: $background-color !default;
+$node-icon-stroke-color: $background-color !default;
+
+// Ports
+$port-hover-stroke-color: white !default;
+$port-fill-color: rgb(195, 195, 195) !default;
+
+@import "../../variables";
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/assets/styles/themes/rabix-dark/theme.css b/services/workbench2/src/lib/cwl-svg/assets/styles/themes/rabix-dark/theme.css
new file mode 100644 (file)
index 0000000..97e6a75
--- /dev/null
@@ -0,0 +1,80 @@
+svg.cwl-workflow {
+  background: #303030;
+  color: white;
+  font-family: sans-serif;
+  padding: 0;
+  width: 100%;
+  display: block;
+  transform: translateZ(0); }
+  svg.cwl-workflow [tabindex]:active, svg.cwl-workflow [tabindex]:focus {
+    outline: none; }
+  svg.cwl-workflow .hidden {
+    display: none; }
+  svg.cwl-workflow .workflow {
+    user-select: none; }
+  svg.cwl-workflow .pan-handle {
+    fill: transparent; }
+  svg.cwl-workflow .label {
+    fill: white;
+    stroke: #303030;
+    stroke-width: 4px;
+    text-anchor: middle;
+    paint-order: stroke;
+    stroke-linecap: butt;
+    stroke-linejoin: miter; }
+  svg.cwl-workflow .node-icon {
+    fill: #303030;
+    stroke: #303030;
+    stroke-width: 3px;
+    stroke-linecap: round; }
+  svg.cwl-workflow .node .outer {
+    fill: #303030;
+    stroke: #9a9a9a;
+    stroke-width: 2px; }
+  svg.cwl-workflow .node .inner {
+    stroke: 0; }
+  svg.cwl-workflow .node.input .inner {
+    fill: #c3c3c3; }
+  svg.cwl-workflow .node.output .inner {
+    fill: #c3c3c3; }
+  svg.cwl-workflow .node.step .inner {
+    fill: #11a7a7; }
+  svg.cwl-workflow .node .core .inner,
+  svg.cwl-workflow .node .core .node-icon {
+    pointer-events: none; }
+  svg.cwl-workflow .node:hover .port .label {
+    transition: all 0.1s;
+    opacity: 1; }
+  svg.cwl-workflow .node .port {
+    fill: #c3c3c3; }
+    svg.cwl-workflow .node .port:hover {
+      stroke: white;
+      stroke-width: 2px; }
+    svg.cwl-workflow .node .port.output-port .label {
+      text-anchor: start;
+      transform: translate(10px, 0); }
+    svg.cwl-workflow .node .port.input-port .label {
+      text-anchor: end;
+      transform: translate(-10px, 0); }
+    svg.cwl-workflow .node .port .label {
+      fill: white;
+      opacity: 0;
+      font-size: .9em;
+      user-select: none;
+      transition: all .1s;
+      pointer-events: none;
+      alignment-baseline: middle; }
+  svg.cwl-workflow .edge:hover .inner {
+    stroke: #11a7a7; }
+  svg.cwl-workflow .edge .inner, svg.cwl-workflow .edge .outer {
+    fill: none;
+    stroke-linecap: round; }
+  svg.cwl-workflow .edge .inner {
+    stroke-width: 2px;
+    stroke: #9a9a9a; }
+  svg.cwl-workflow .edge .outer {
+    stroke-width: 7px;
+    stroke: #303030; }
+  svg.cwl-workflow .unselectable {
+    user-select: none;
+    cursor: pointer; }
diff --git a/services/workbench2/src/lib/cwl-svg/assets/styles/themes/rabix-dark/theme.scss b/services/workbench2/src/lib/cwl-svg/assets/styles/themes/rabix-dark/theme.scss
new file mode 100644 (file)
index 0000000..f4b5400
--- /dev/null
@@ -0,0 +1,2 @@
+@import "variables";
+@import "../../style.scss";
diff --git a/services/workbench2/src/lib/cwl-svg/behaviors/edge-panning.ts b/services/workbench2/src/lib/cwl-svg/behaviors/edge-panning.ts
new file mode 100644 (file)
index 0000000..7bd983c
--- /dev/null
@@ -0,0 +1,133 @@
+import {Workflow} from "..";
+
+export class EdgePanner {
+
+
+    /** ID of the requested animation frame for panning */
+    private panAnimationFrame: any;
+
+    private workflow: Workflow;
+
+    private movementSpeed = 10;
+    private scrollMargin  = 100;
+
+    /**
+     * Current state of collision on both axes, each negative if beyond top/left border,
+     * positive if beyond right/bottom, zero if inside the viewport
+     */
+    private collision = {x: 0, y: 0};
+
+    private viewportClientRect: ClientRect;
+    private panningCallback = (sdx: number, sdy: number) => {};
+
+    constructor(workflow: Workflow, config = {
+        scrollMargin: 100,
+        movementSpeed: 10
+    }) {
+        const options = Object.assign({
+            scrollMargin: 100,
+            movementSpeed: 10
+        }, config);
+
+        this.workflow      = workflow;
+        this.scrollMargin  = options.scrollMargin;
+        this.movementSpeed = options.movementSpeed;
+
+        this.viewportClientRect = this.workflow.svgRoot.getBoundingClientRect();
+    }
+
+    /**
+     * Calculates if dragged node is at or beyond the point beyond which workflow panning should be triggered.
+     * If collision state has changed, {@link onBoundaryCollisionChange} will be triggered.
+     */
+    triggerCollisionDetection(x: number, y: number, callback: (sdx: number, sdy: number) => void) {
+        const collision      = {x: 0, y: 0};
+        this.panningCallback = callback;
+
+        let {left, right, top, bottom} = this.viewportClientRect;
+
+        left   = left + this.scrollMargin;
+        right  = right - this.scrollMargin;
+        top    = top + this.scrollMargin;
+        bottom = bottom - this.scrollMargin;
+
+        if (x < left) {
+            collision.x = x - left;
+        } else if (x > right) {
+            collision.x = x - right;
+        }
+
+        if (y < top) {
+            collision.y = y - top;
+        } else if (y > bottom) {
+            collision.y = y - bottom;
+        }
+
+        if (
+            Math.sign(collision.x) !== Math.sign(this.collision.x)
+            || Math.sign(collision.y) !== Math.sign(this.collision.y)
+        ) {
+            const previous = this.collision;
+            this.collision = collision;
+            this.onBoundaryCollisionChange(collision, previous);
+        }
+    }
+
+    /**
+     * Triggered when {@link triggerCollisionDetection} determines that collision properties have changed.
+     */
+    private onBoundaryCollisionChange(current: { x: number, y: number }, previous: { x: number, y: number }): void {
+
+        this.stop();
+
+        if (current.x === 0 && current.y === 0) {
+            return;
+        }
+
+        this.start(this.collision);
+    }
+
+    private start(direction: { x: number, y: number }) {
+
+        let startTimestamp: number | undefined;
+
+        const scale    = this.workflow.scale;
+        const matrix   = this.workflow.workflow.transform.baseVal.getItem(0).matrix;
+        const sixtyFPS = 16.6666;
+
+        const onFrame = (timestamp: number) => {
+
+            const frameDeltaTime = timestamp - (startTimestamp || timestamp);
+            startTimestamp       = timestamp;
+
+            // We need to stop the animation at some point
+            // It should be stopped when there is no animation frame ID anymore,
+            // which means that stopScroll() was called
+            // However, don't do that if we haven't made the first move yet, which is a situation when ∆t is 0
+            if (frameDeltaTime !== 0 && !this.panAnimationFrame) {
+                startTimestamp = undefined;
+                return;
+            }
+
+            const moveX = Math.sign(direction.x) * this.movementSpeed * frameDeltaTime / sixtyFPS;
+            const moveY = Math.sign(direction.y) * this.movementSpeed * frameDeltaTime / sixtyFPS;
+
+            matrix.e -= moveX;
+            matrix.f -= moveY;
+
+            const frameDiffX = moveX / scale;
+            const frameDiffY = moveY / scale;
+
+            this.panningCallback(frameDiffX, frameDiffY);
+            this.panAnimationFrame = window.requestAnimationFrame(onFrame);
+        };
+
+        this.panAnimationFrame = window.requestAnimationFrame(onFrame);
+    }
+
+    stop() {
+        window.cancelAnimationFrame(this.panAnimationFrame);
+        this.panAnimationFrame = undefined;
+    }
+
+}
diff --git a/services/workbench2/src/lib/cwl-svg/graph/connectable.ts b/services/workbench2/src/lib/cwl-svg/graph/connectable.ts
new file mode 100644 (file)
index 0000000..6084b0d
--- /dev/null
@@ -0,0 +1,4 @@
+export interface Connectable {
+    connectionId: string;
+    isVisible: boolean;
+}
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/graph/edge.ts b/services/workbench2/src/lib/cwl-svg/graph/edge.ts
new file mode 100644 (file)
index 0000000..334a514
--- /dev/null
@@ -0,0 +1,135 @@
+import {Edge as ModelEdge} from "cwlts/models";
+import {Geometry} from "../utils/geometry";
+import {IOPort} from "./io-port";
+import {Workflow} from "./workflow";
+
+export class Edge {
+
+    static makeTemplate(edge: ModelEdge, containerNode: SVGGElement, connectionStates?: string): string | undefined {
+        if (!edge.isVisible || edge.source.type === "Step" || edge.destination.type === "Step") {
+            return "";
+        }
+
+        const [, sourceStepId, sourcePort] = edge.source.id.split("/");
+        const [, destStepId, destPort]       = edge.destination.id.split("/");
+
+        const sourceVertex = containerNode.querySelector(`.node[data-id="${sourceStepId}"] .output-port[data-port-id="${sourcePort}"] .io-port`) as SVGGElement;
+        const destVertex   = containerNode.querySelector(`.node[data-id="${destStepId}"] .input-port[data-port-id="${destPort}"] .io-port`) as SVGGElement;
+
+        if (edge.source.type === edge.destination.type) {
+            console.error("Can't update edge between nodes of the same type.", edge);
+            return;
+        }
+
+        if (!sourceVertex) {
+            console.error("Source vertex not found for edge " + edge.source.id, edge);
+            return;
+        }
+
+        if (!destVertex) {
+            console.error("Destination vertex not found for edge " + edge.destination.id, edge);
+            return;
+        }
+
+        const sourceCTM = sourceVertex.getCTM() as SVGMatrix;
+        const destCTM   = destVertex.getCTM() as SVGMatrix;
+
+        const wfMatrix = containerNode.transform.baseVal.getItem(0).matrix;
+
+        const pathStr = Workflow.makeConnectionPath(
+            (sourceCTM.e - wfMatrix.e) / sourceCTM.a,
+            (sourceCTM.f - wfMatrix.f) / sourceCTM.a,
+            (destCTM.e - wfMatrix.e) / sourceCTM.a,
+            (destCTM.f - wfMatrix.f) / sourceCTM.a
+        );
+
+        return `
+            <g tabindex="-1" class="edge ${connectionStates}"
+               data-source-port="${sourcePort}"
+               data-destination-port="${destPort}"
+               data-source-node="${sourceStepId}"
+               data-source-connection="${edge.source.id}"
+               data-destination-connection="${edge.destination.id}"
+               data-destination-node="${destStepId}">
+                <path class="sub-edge outer" d="${pathStr}"></path>
+                <path class="sub-edge inner" d="${pathStr}"></path>
+            </g>
+        `;
+    }
+
+    static spawn(pathStr = "", connectionIDs: {
+        source?: string,
+        destination?: string,
+    }                    = {}) {
+
+        const ns   = "http://www.w3.org/2000/svg";
+        const edge = document.createElementNS(ns, "g");
+
+        const [, sourceStepId, sourcePort] = (connectionIDs.source || "//").split("/");
+        const [, destStepId, destPort]       = (connectionIDs.destination || "//").split("/");
+
+        edge.classList.add("edge");
+        if (sourceStepId) {
+            edge.classList.add(sourceStepId);
+        }
+        if (destStepId) {
+            edge.classList.add(destStepId);
+        }
+        edge.setAttribute("tabindex", "-1");
+        edge.setAttribute("data-destination-node", destStepId);
+        edge.setAttribute("data-destination-port", destPort);
+        edge.setAttribute("data-source-port", sourcePort);
+        edge.setAttribute("data-source-node", sourceStepId);
+        edge.setAttribute("data-source-connection", connectionIDs.source!);
+        edge.setAttribute("data-destination-connection", connectionIDs.destination!);
+
+        edge.innerHTML = `
+            <path class="sub-edge outer" d="${pathStr}"></path>
+            <path class="sub-edge inner" d="${pathStr}"></path>
+        `;
+
+        return edge;
+    }
+
+    static spawnBetweenConnectionIDs(root: SVGElement, source: string, destination: string) {
+
+        if (source.startsWith("in")) {
+            const tmp   = source;
+            source      = destination;
+            destination = tmp;
+        }
+
+        const sourceNode      = root.querySelector(`.port[data-connection-id="${source}"]`) as SVGGElement;
+        const destinationNode = root.querySelector(`.port[data-connection-id="${destination}"]`) as SVGAElement;
+
+        const sourceCTM = Geometry.getTransformToElement(sourceNode, root);
+        const destCTM   = Geometry.getTransformToElement(destinationNode, root);
+        const path      = IOPort.makeConnectionPath(sourceCTM.e, sourceCTM.f, destCTM.e, destCTM.f);
+
+        // If there is already a connection between these ports, update that one instead
+        const existingEdge = root.querySelector(`.edge[data-source-connection="${source}"][data-destination-connection="${destination}"]`);
+        if (existingEdge) {
+            existingEdge.querySelectorAll(".sub-edge").forEach(sub => sub.setAttribute("d", path!));
+            return existingEdge;
+        }
+
+        const edge = Edge.spawn(path, {
+            source,
+            destination
+        });
+
+        const firstNode = root.querySelector(".node");
+        root.insertBefore(edge, firstNode);
+
+        return edge;
+    }
+
+    static findEdge(root: any, sourceConnectionID: string, destinationConnectionID: string) {
+        return root.querySelector(`[data-source-connection="${sourceConnectionID}"][data-destination-connection="${destinationConnectionID}"]`);
+    }
+
+    static parseConnectionID(cid: string) {
+        const [side, stepID, portID] = (cid || "//").split("/");
+        return {side, stepID, portID};
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/graph/graph-node.ts b/services/workbench2/src/lib/cwl-svg/graph/graph-node.ts
new file mode 100644 (file)
index 0000000..d6736b2
--- /dev/null
@@ -0,0 +1,215 @@
+import {ParameterTypeModel, StepModel, WorkflowInputParameterModel, WorkflowOutputParameterModel} from "cwlts/models";
+import {HtmlUtils} from "../utils/html-utils";
+import {SVGUtils} from "../utils/svg-utils";
+import {IOPort} from "./io-port";
+
+export type NodePosition = { x: number, y: number };
+export type NodeDataModel = WorkflowInputParameterModel | WorkflowOutputParameterModel | StepModel;
+
+export class GraphNode {
+
+    public position: NodePosition = {x: 0, y: 0};
+
+    static radius = 30;
+
+    constructor(position: Partial<NodePosition>,
+                private dataModel: NodeDataModel) {
+
+        this.dataModel = dataModel;
+
+        Object.assign(this.position, position);
+    }
+
+    /**
+     * @FIXME Making icons increases the rendering time by 50-100%. Try embedding the SVG directly.
+     */
+
+    private static workflowIconSvg: string   = "<svg class=\"node-icon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 400.01 399.88\" x=\"-9\" y=\"-10\" width=\"20\" height=\"20\"><title>workflow</title><path d=\"M400,200a80,80,0,0,1-140.33,52.53L158.23,303.24a80,80,0,1,1-17.9-35.77l101.44-50.71a80.23,80.23,0,0,1,0-33.52L140.33,132.53a79.87,79.87,0,1,1,17.9-35.77l101.44,50.71A80,80,0,0,1,400,200Z\" transform=\"translate(0.01 -0.16)\"/></svg>";
+    private static toolIconSvg: string       = "<svg class=\"node-icon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 398.39 397.78\" x=\"-10\" y=\"-8\" width=\"20\" height=\"15\"><title>tool2</title><polygon points=\"38.77 397.57 0 366 136.15 198.78 0 31.57 38.77 0 200.63 198.78 38.77 397.57\"/><rect x=\"198.39\" y=\"347.78\" width=\"200\" height=\"50\"/></svg>";
+    private static fileInputIconSvg: string  = "<svg class=\"node-icon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 499 462.86\" y=\"-10\" x=\"-11\" width=\"20\" height=\"20\"><title>file_input</title><path d=\"M386.06,0H175V58.29l50,50V50H337.81V163.38h25l86.19.24V412.86H225V353.71l-50,50v59.15H499V112.94Zm1.75,113.45v-41l41.1,41.1Z\"/><polygon points=\"387.81 1.06 387.81 1.75 387.12 1.06 387.81 1.06\"/><polygon points=\"290.36 231 176.68 344.68 141.32 309.32 194.64 256 0 256 0 206 194.64 206 142.32 153.68 177.68 118.32 290.36 231\"/></svg>";
+    private static fileOutputIconSvg: string = "<svg class=\"node-icon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 499 462.86\" x=\"-7\" y=\"-11\" width=\"20\" height=\"20\"><title>file_output</title><polygon points=\"387.81 1.06 387.81 1.75 387.12 1.06 387.81 1.06\"/><polygon points=\"499 231 385.32 344.68 349.96 309.32 403.28 256 208.64 256 208.64 206 403.28 206 350.96 153.68 386.32 118.32 499 231\"/><path d=\"M187.81,163.38l77.69.22H324V112.94L211.06,0H0V462.86H324V298.5H274V412.86H50V50H162.81V163.38Zm25-90.92,41.1,41.1-41.1-.11Z\"/></svg>";
+    private static inputIconSvg: string      = "<svg class=\"node-icon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 499 365\" x=\"-11\" y=\"-10\" width=\"20\" height=\"20\"><title>type_input</title><g id=\"input\"><path d=\"M316.5,68a181.72,181.72,0,0,0-114.12,40.09L238,143.72a132.5,132.5,0,1,1,1.16,214.39L203.48,393.8A182.5,182.5,0,1,0,316.5,68Z\" transform=\"translate(0 -68)\"/><g id=\"Layer_22\" data-name=\"Layer 22\"><g id=\"Layer_9_copy_4\" data-name=\"Layer 9 copy 4\"><polygon points=\"290.36 182 176.68 295.68 141.32 260.32 194.64 207 0 207 0 157 194.64 157 142.32 104.68 177.68 69.32 290.36 182\"/></g></g></g></svg>";
+    private static outputIconSvg: string     = "<svg class=\"node-icon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 500.36 365\" x=\"-9\" y=\"-10\" width=\"20\" height=\"20\"><title>type_output</title><g id=\"output\"><path d=\"M291.95,325.23a134,134,0,0,1-15.76,19,132.5,132.5,0,1,1,0-187.38,133.9,133.9,0,0,1,16.16,19.55l35.81-35.81A182.5,182.5,0,1,0,327.73,361Z\" transform=\"translate(0 -68)\"/><g id=\"circle_source_copy\" data-name=\"circle source copy\"><g id=\"Layer_22_copy\" data-name=\"Layer 22 copy\"><g id=\"Layer_9_copy_5\" data-name=\"Layer 9 copy 5\"><polygon points=\"500.36 182 386.68 295.68 351.32 260.32 404.64 207 210 207 210 157 404.64 157 352.32 104.68 387.68 69.32 500.36 182\"/></g></g></g></g></svg>";
+
+    private static makeIconFragment(model: any) {
+
+        let iconStr = "";
+
+        if (model instanceof StepModel && model.run) {
+
+            if (model.run.class === "Workflow") {
+                iconStr = this.workflowIconSvg;
+            } else if (model.run.class === "CommandLineTool") {
+                iconStr = this.toolIconSvg;
+            }
+
+        } else if (model instanceof WorkflowInputParameterModel && model.type) {
+            if (model.type.type === "File" || (model.type.type === "array" && model.type.items === "File")) {
+                iconStr = this.fileInputIconSvg;
+            } else {
+                iconStr = this.inputIconSvg;
+            }
+        } else if (model instanceof WorkflowOutputParameterModel && model.type) {
+            if (model.type.type === "File" || (model.type.type === "array" && model.type.items === "File")) {
+                iconStr = this.fileOutputIconSvg;
+            } else {
+                iconStr = this.outputIconSvg;
+            }
+        }
+
+        return iconStr;
+    }
+
+    static makeTemplate(dataModel: {
+        id: string,
+        connectionId: string,
+        label?: string,
+        in?: any[],
+        type?: ParameterTypeModel
+        out?: any[],
+        customProps?: {
+            "sbg:x"?: number
+            "sbg:y"?: number
+        }
+    }, labelScale = 1): string {
+
+        const x = ~~(dataModel.customProps && dataModel.customProps["sbg:x"])!;
+        const y = ~~(dataModel.customProps && dataModel.customProps["sbg:y"])!;
+
+        let nodeTypeClass = "step";
+        if (dataModel instanceof WorkflowInputParameterModel) {
+            nodeTypeClass = "input";
+        } else if (dataModel instanceof WorkflowOutputParameterModel) {
+            nodeTypeClass = "output";
+        }
+
+        const inputs   = (dataModel.in || []).filter(p => p.isVisible);
+        const outputs  = (dataModel.out || []).filter(p => p.isVisible);
+        const maxPorts = Math.max(inputs.length, outputs.length);
+        const radius   = GraphNode.radius + maxPorts * IOPort.radius;
+
+        let typeClass = "";
+        let itemsClass = "";
+
+        if (dataModel.type) {
+            typeClass = "type-" + dataModel.type.type;
+
+            if(dataModel.type.items){
+                itemsClass = "items-" + dataModel.type.items;
+            }
+        }
+
+        const inputPortTemplates = inputs
+            .sort((a, b) => -a.id.localeCompare(b.id))
+            .map((p, i, arr) => GraphNode.makePortTemplate(
+                p,
+                "input",
+                SVGUtils.matrixToTransformAttr(
+                    GraphNode.createPortMatrix(arr.length, i, radius, "input")
+                )
+            ))
+            .reduce((acc, tpl) => acc + tpl, "");
+
+        const outputPortTemplates = outputs
+            .sort((a, b) => -a.id.localeCompare(b.id))
+            .map((p, i, arr) => GraphNode.makePortTemplate(
+                p,
+                "output",
+                SVGUtils.matrixToTransformAttr(
+                    GraphNode.createPortMatrix(arr.length, i, radius, "output")
+                )
+            ))
+            .reduce((acc, tpl) => acc + tpl, "");
+
+        return `
+            <g tabindex="-1" class="node ${nodeTypeClass} ${typeClass} ${itemsClass}"
+               data-connection-id="${dataModel.connectionId}"
+               transform="matrix(1, 0, 0, 1, ${x}, ${y})"
+               data-id="${dataModel.id}">
+               
+                <g class="core" transform="matrix(1, 0, 0, 1, 0, 0)">
+                    <circle cx="0" cy="0" r="${radius}" class="outer"></circle>
+                    <circle cx="0" cy="0" r="${radius * .75}" class="inner"></circle>
+                    
+                    ${GraphNode.makeIconFragment(dataModel)}
+                </g>
+                
+                <text transform="matrix(${labelScale},0,0,${labelScale},0,${radius + 30})" class="title label">${HtmlUtils.escapeHTML(dataModel.label || dataModel.id)}</text>
+                
+                ${inputPortTemplates}
+                ${outputPortTemplates}
+            </g>
+        `;
+    }
+
+    private static makePortTemplate(port: {
+                                        label?: string,
+                                        id: string,
+                                        connectionId: string
+                                    },
+                                    type: "input" | "output",
+                                    transform = "matrix(1, 0, 0, 1, 0, 0)"): string {
+
+        const portClass = type === "input" ? "input-port" : "output-port";
+        const label     = port.label || port.id;
+
+        return `
+            <g class="port ${portClass}" transform="${transform || "matrix(1, 0, 0, 1, 0, 0)"}"
+               data-connection-id="${port.connectionId}"
+               data-port-id="${port.id}"
+            >
+                <g class="io-port">
+                    <circle cx="0" cy="0" r="7" class="port-handle"></circle>
+                </g>
+                <text x="0" y="0" transform="matrix(1,0,0,1,0,0)" class="label unselectable">${label}</text>
+            </g>
+            
+        `;
+    }
+
+    public static createPortMatrix(totalPortLength: number,
+                                   portIndex: number,
+                                   radius: number,
+                                   type: "input" | "output"): SVGMatrix {
+        const availableAngle = 140;
+
+        let rotationAngle =
+                // Starting rotation angle
+                (-availableAngle / 2) +
+                (
+                    // Angular offset by element index
+                    (portIndex + 1)
+                    // Angle between elements
+                    * availableAngle / (totalPortLength + 1)
+                );
+
+        if (type === "input") {
+            rotationAngle =
+                // Determines the starting rotation angle
+                180 - (availableAngle / -2)
+                // Determines the angular offset modifier for the current index
+                - (portIndex + 1)
+                // Determines the angular offset
+                * availableAngle / (totalPortLength + 1);
+        }
+
+        const matrix = SVGUtils.createMatrix();
+        return matrix.rotate(rotationAngle).translate(radius, 0).rotate(-rotationAngle);
+    }
+
+    static patchModelPorts<T>(model: T & { connectionId: string, id: string }): T {
+        const patch = [{connectionId: model.connectionId, isVisible: true, id: model.id}];
+        if (model instanceof WorkflowInputParameterModel) {
+            const copy = Object.create(model);
+            return Object.assign(copy, {out: patch});
+
+
+        } else if (model instanceof WorkflowOutputParameterModel) {
+            const copy = Object.create(model);
+            return Object.assign(copy, {in: patch});
+        }
+
+        return model;
+    }
+
+}
diff --git a/services/workbench2/src/lib/cwl-svg/graph/io-port.ts b/services/workbench2/src/lib/cwl-svg/graph/io-port.ts
new file mode 100644 (file)
index 0000000..fdd2e92
--- /dev/null
@@ -0,0 +1,31 @@
+export class IOPort {
+
+    static radius = 7;
+
+    /**
+     * @param x1
+     * @param y1
+     * @param x2
+     * @param y2
+     * @param {"right" | "left" | string} forceDirection
+     * @returns {string}
+     */
+    public static makeConnectionPath(x1: any, y1: any, x2: any, y2: any, forceDirection: "right" | "left" | string = "right"): string | undefined {
+
+        if (!forceDirection) {
+            return `M ${x1} ${y1} C ${(x1 + x2) / 2} ${y1} ${(x1 + x2) / 2} ${y2} ${x2} ${y2}`;
+        } else if (forceDirection === "right") {
+            const outDir = x1 + Math.abs(x1 - x2) / 2;
+            const inDir  = x2 - Math.abs(x1 - x2) / 2;
+
+            return `M ${x1} ${y1} C ${outDir} ${y1} ${inDir} ${y2} ${x2} ${y2}`;
+        } else if (forceDirection === "left") {
+            const outDir = x1 - Math.abs(x1 - x2) / 2;
+            const inDir  = x2 + Math.abs(x1 - x2) / 2;
+
+            return `M ${x1} ${y1} C ${outDir} ${y1} ${inDir} ${y2} ${x2} ${y2}`;
+        }
+
+        return undefined;
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/graph/step-node.ts b/services/workbench2/src/lib/cwl-svg/graph/step-node.ts
new file mode 100644 (file)
index 0000000..6924146
--- /dev/null
@@ -0,0 +1,41 @@
+import {StepModel} from "cwlts/models";
+import {Edge} from "./edge";
+import {GraphNode} from "./graph-node";
+import {TemplateParser} from "./template-parser";
+
+export class StepNode {
+
+    private svg: SVGSVGElement;
+    private stepEl: SVGElement;
+    private model: StepModel;
+
+    constructor(element: SVGElement, stepModel: StepModel) {
+
+        this.stepEl = element;
+        this.svg    = element.ownerSVGElement!;
+        this.model  = stepModel;
+
+    }
+
+    update() {
+        const tpl = GraphNode.makeTemplate(this.model);
+        const el  = TemplateParser.parse(tpl)!;
+
+        this.stepEl.innerHTML = el.innerHTML;
+
+        // Reposition all edges
+        const incomingEdges = this.svg.querySelectorAll(`.edge[data-destination-node="${this.model.connectionId}"]`);
+        const outgoingEdges = this.svg.querySelectorAll(`.edge[data-source-node="${this.model.connectionId}"`);
+
+        for (const edge of [...Array.from(incomingEdges), ...Array.from(outgoingEdges)]) {
+            Edge.spawnBetweenConnectionIDs(
+                this.svg.querySelector(".workflow") as SVGGElement,
+                edge.getAttribute("data-source-connection")!,
+                edge.getAttribute("data-destination-connection")!
+            );
+        }
+
+        console.log("Should redraw input port", incomingEdges);
+
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/graph/template-parser.ts b/services/workbench2/src/lib/cwl-svg/graph/template-parser.ts
new file mode 100644 (file)
index 0000000..77f4183
--- /dev/null
@@ -0,0 +1,9 @@
+export class TemplateParser {
+
+    static parse(tpl: any) {
+        const ns = "http://www.w3.org/2000/svg";
+        const node = document.createElementNS(ns, "g");
+        node.innerHTML = tpl;
+        return node.firstElementChild;
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/graph/workflow.ts b/services/workbench2/src/lib/cwl-svg/graph/workflow.ts
new file mode 100644 (file)
index 0000000..f0cd1c4
--- /dev/null
@@ -0,0 +1,611 @@
+import {WorkflowStepInputModel}       from "cwlts/models/generic";
+import {StepModel}                    from "cwlts/models/generic/StepModel";
+import {WorkflowInputParameterModel}  from "cwlts/models/generic/WorkflowInputParameterModel";
+import {WorkflowModel}                from "cwlts/models/generic/WorkflowModel";
+import {WorkflowOutputParameterModel} from "cwlts/models/generic/WorkflowOutputParameterModel";
+import {SVGPlugin}                    from "../plugins/plugin";
+import {DomEvents}                    from "../utils/dom-events";
+import {EventHub}                     from "../utils/event-hub";
+import {Connectable}                  from "./connectable";
+import {Edge as GraphEdge}            from "./edge";
+import {GraphNode}                    from "./graph-node";
+import {StepNode}                     from "./step-node";
+import {TemplateParser}               from "./template-parser";
+import {WorkflowStepOutputModel}      from "cwlts/models";
+
+/**
+ * @FIXME validation states of old and newly created edges
+ */
+export class Workflow {
+
+    readonly eventHub: EventHub;
+    readonly svgID = this.makeID();
+
+    minScale = 0.2;
+    maxScale = 2;
+
+    domEvents: DomEvents;
+    svgRoot: SVGSVGElement;
+    workflow: SVGGElement;
+    model: WorkflowModel;
+    editingEnabled = true;
+
+    /** Scale of labels, they are different than scale of other elements in the workflow */
+    labelScale = 1;
+
+    private workflowBoundingClientRect: any;
+    private plugins: SVGPlugin[]  = [];
+    private disposers: Function[] = [];
+
+    private pendingFirstDraw = true;
+
+    /** Stored in order to ensure that once destroyed graph cannot be reused again */
+    private isDestroyed = false;
+
+    constructor(parameters: {
+        svgRoot: SVGSVGElement,
+        model: WorkflowModel,
+        plugins?: SVGPlugin[],
+        editingEnabled?: boolean
+    }) {
+        this.svgRoot        = parameters.svgRoot;
+        this.plugins        = parameters.plugins || [];
+        this.domEvents      = new DomEvents(this.svgRoot as any);
+        this.model          = parameters.model;
+        this.editingEnabled = parameters.editingEnabled !== false; // default to true if undefined
+
+        this.svgRoot.classList.add(this.svgID);
+
+        this.svgRoot.innerHTML = `
+            <rect x="0" y="0" width="100%" height="100%" class="pan-handle" transform="matrix(1,0,0,1,0,0)"></rect>
+            <g class="workflow" transform="matrix(1,0,0,1,0,0)"></g>
+        `;
+
+        this.workflow = this.svgRoot.querySelector(".workflow") as any;
+
+        this.invokePlugins("registerWorkflow", this);
+
+        this.eventHub = new EventHub([
+            "connection.create",
+            "app.create.step",
+            "app.create.input",
+            "app.create.output",
+            "beforeChange",
+            "afterChange",
+            "afterRender",
+            "selectionChange"
+        ]);
+
+        this.hookPlugins();
+        this.draw(parameters.model);
+
+
+        this.eventHub.on("afterRender", () => this.invokePlugins("afterRender"));
+    }
+
+    /** Current scale of the document */
+    private docScale = 1;
+
+    get scale() {
+        return this.docScale;
+    }
+
+    // noinspection JSUnusedGlobalSymbols
+    set scale(scale: number) {
+        this.workflowBoundingClientRect = this.svgRoot.getBoundingClientRect();
+
+        const x = (this.workflowBoundingClientRect.right + this.workflowBoundingClientRect.left) / 2;
+        const y = (this.workflowBoundingClientRect.top + this.workflowBoundingClientRect.bottom) / 2;
+
+        this.scaleAtPoint(scale, x, y);
+    }
+
+    static canDrawIn(element: SVGElement): boolean {
+        return element.getBoundingClientRect().width !== 0;
+    }
+
+    static makeConnectionPath(x1: any, y1: any, x2: any, y2: any, forceDirection: "right" | "left" | string = "right"): string | undefined {
+
+        if (!forceDirection) {
+            return `M ${x1} ${y1} C ${(x1 + x2) / 2} ${y1} ${(x1 + x2) / 2} ${y2} ${x2} ${y2}`;
+        } else if (forceDirection === "right") {
+            const outDir = x1 + Math.abs(x1 - x2) / 2;
+            const inDir  = x2 - Math.abs(x1 - x2) / 2;
+
+            return `M ${x1} ${y1} C ${outDir} ${y1} ${inDir} ${y2} ${x2} ${y2}`;
+        } else if (forceDirection === "left") {
+            const outDir = x1 - Math.abs(x1 - x2) / 2;
+            const inDir  = x2 + Math.abs(x1 - x2) / 2;
+
+            return `M ${x1} ${y1} C ${outDir} ${y1} ${inDir} ${y2} ${x2} ${y2}`;
+        }
+        return undefined;
+    }
+
+    draw(model: WorkflowModel = this.model) {
+
+        this.assertNotDestroyed("draw");
+
+        // We will need to restore the transformations when we redraw the model, so save the current state
+        const oldTransform = this.workflow.getAttribute("transform");
+
+        const modelChanged = this.model !== model;
+
+        if (modelChanged || this.pendingFirstDraw) {
+            this.pendingFirstDraw = false;
+
+            this.model = model;
+
+            const stepChangeDisposer        = this.model.on("step.change", this.onStepChange.bind(this));
+            const stepCreateDisposer        = this.model.on("step.create", this.onStepCreate.bind(this));
+            const stepRemoveDisposer        = this.model.on("step.remove", this.onStepRemove.bind(this));
+            const inputCreateDisposer       = this.model.on("input.create", this.onInputCreate.bind(this));
+            const inputRemoveDisposer       = this.model.on("input.remove", this.onInputRemove.bind(this));
+            const outputCreateDisposer      = this.model.on("output.create", this.onOutputCreate.bind(this));
+            const outputRemoveDisposer      = this.model.on("output.remove", this.onOutputRemove.bind(this));
+            const stepInPortShowDisposer    = this.model.on("step.inPort.show", this.onInputPortShow.bind(this));
+            const stepInPortHideDisposer    = this.model.on("step.inPort.hide", this.onInputPortHide.bind(this));
+            const connectionCreateDisposer  = this.model.on("connection.create", this.onConnectionCreate.bind(this));
+            const connectionRemoveDisposer  = this.model.on("connection.remove", this.onConnectionRemove.bind(this));
+            const stepOutPortCreateDisposer = this.model.on("step.outPort.create", this.onOutputPortCreate.bind(this));
+            const stepOutPortRemoveDisposer = this.model.on("step.outPort.remove", this.onOutputPortRemove.bind(this));
+
+            this.disposers.push(() => {
+                stepChangeDisposer.dispose();
+                stepCreateDisposer.dispose();
+                stepRemoveDisposer.dispose();
+                inputCreateDisposer.dispose();
+                inputRemoveDisposer.dispose();
+                outputCreateDisposer.dispose();
+                outputRemoveDisposer.dispose();
+                stepInPortShowDisposer.dispose();
+                stepInPortHideDisposer.dispose();
+                connectionCreateDisposer.dispose();
+                connectionRemoveDisposer.dispose();
+                stepOutPortCreateDisposer.dispose();
+                stepOutPortRemoveDisposer.dispose();
+            });
+
+            this.invokePlugins("afterModelChange");
+        }
+
+        this.clearCanvas();
+
+        const nodes = [
+            ...this.model.steps,
+            ...this.model.inputs,
+            ...this.model.outputs
+        ].filter(n => n.isVisible);
+
+        /**
+         * If there is a missing sbg:x or sbg:y property on any node model,
+         * graph should be arranged to avoid random placement.
+         */
+        let nodeTemplate = "";
+
+        for (const node of nodes) {
+            const patched  = GraphNode.patchModelPorts(node);
+            nodeTemplate += GraphNode.makeTemplate(patched);
+        }
+
+        this.workflow.innerHTML += nodeTemplate;
+
+        this.redrawEdges();
+
+        Array.from(this.workflow.querySelectorAll(".node")).forEach(e => {
+            this.workflow.appendChild(e);
+        });
+
+        this.addEventListeners();
+
+        this.workflow.setAttribute("transform", oldTransform!);
+
+        this.scaleAtPoint(this.scale);
+
+
+        this.invokePlugins("afterRender");
+    }
+
+    findParent(el: Element, parentClass = "node"): SVGGElement | undefined {
+        let parentNode: Element | null = el;
+        while (parentNode) {
+            if (parentNode.classList.contains(parentClass)) {
+                return parentNode as SVGGElement;
+            }
+            parentNode = parentNode.parentElement;
+        }
+        return undefined;
+    }
+
+    /**
+     * Retrieves a plugin instance
+     * @param {{new(...args: any[]) => T}} plugin
+     * @returns {T}
+     */
+    getPlugin<T extends SVGPlugin>(plugin: { new(...args: any[]): T }): T {
+        return this.plugins.find(p => p instanceof plugin) as T;
+    }
+
+    on(event: string, handler: any) {
+        this.eventHub.on(event, handler);
+    }
+
+    off(event: string, handler: any) {
+        this.eventHub.off(event, handler);
+    }
+
+    /**
+     * Scales the workflow to fit the available viewport
+     */
+    fitToViewport(ignoreScaleLimits = false): void {
+
+        this.scaleAtPoint(1);
+
+        Object.assign(this.workflow.transform.baseVal.getItem(0).matrix, {
+            e: 0,
+            f: 0
+        });
+
+        const clientBounds = this.svgRoot.getBoundingClientRect();
+        const wfBounds     = this.workflow.getBoundingClientRect();
+        const padding    = 100;
+
+        if (clientBounds.width === 0 || clientBounds.height === 0) {
+            throw new Error("Cannot fit workflow to the area that has no visible viewport.");
+        }
+
+        const verticalScale   = (wfBounds.height) / (clientBounds.height - padding);
+        const horizontalScale = (wfBounds.width) / (clientBounds.width - padding);
+
+        const scaleFactor = Math.max(verticalScale, horizontalScale);
+
+        // Cap the upscaling to 1, we don't want to zoom in workflows that would fit anyway
+        let newScale = Math.min(this.scale / scaleFactor, 1);
+
+        if (!ignoreScaleLimits) {
+            newScale = Math.max(newScale, this.minScale);
+        }
+
+        this.scaleAtPoint(newScale);
+
+        const scaledWFBounds = this.workflow.getBoundingClientRect();
+
+        const moveY = clientBounds.top - scaledWFBounds.top + Math.abs(clientBounds.height - scaledWFBounds.height) / 2;
+        const moveX = clientBounds.left - scaledWFBounds.left + Math.abs(clientBounds.width - scaledWFBounds.width) / 2;
+
+        const matrix = this.workflow.transform.baseVal.getItem(0).matrix;
+        matrix.e += moveX;
+        matrix.f += moveY;
+    }
+
+    redrawEdges() {
+
+        const highlightedEdges = new Set();
+
+        Array.from(this.workflow.querySelectorAll(".edge")).forEach((el) => {
+            if (el.classList.contains("highlighted")) {
+                const edgeID = el.attributes["data-source-connection"].value + el.attributes["data-destination-connection"].value;
+                highlightedEdges.add(edgeID);
+            }
+            el.remove();
+        });
+
+
+        const edgesTpl = this.model.connections
+            .map(c => {
+                const edgeId     = c.source.id + c.destination.id;
+                const edgeStates = highlightedEdges.has(edgeId) ? "highlighted" : "";
+                return GraphEdge.makeTemplate(c, this.workflow, edgeStates);
+            })
+            .reduce((acc, tpl) => acc! + tpl, "");
+
+        this.workflow.innerHTML = edgesTpl + this.workflow.innerHTML;
+    }
+
+    /**
+     * Scale the workflow by the scaleCoefficient (not compounded) over given coordinates
+     */
+    scaleAtPoint(scale = 1, x = 0, y = 0): void {
+
+        this.docScale     = scale;
+        this.labelScale = 1 + (1 - this.docScale) / (this.docScale * 2);
+
+        const transform         = this.workflow.transform.baseVal;
+        const matrix: SVGMatrix = transform.getItem(0).matrix;
+
+        const coords = this.transformScreenCTMtoCanvas(x, y);
+
+        matrix.e += matrix.a * coords.x;
+        matrix.f += matrix.a * coords.y;
+        matrix.a = matrix.d = scale;
+        matrix.e -= scale * coords.x;
+        matrix.f -= scale * coords.y;
+
+        const nodeLabels: any = this.workflow.querySelectorAll(".node .label") as  NodeListOf<SVGPathElement>;
+
+        for (const el of nodeLabels) {
+            const matrix = el.transform.baseVal.getItem(0).matrix;
+
+            Object.assign(matrix, {
+                a: this.labelScale,
+                d: this.labelScale
+            });
+        }
+
+    }
+
+    transformScreenCTMtoCanvas(x: any, y: any) {
+        const svg   = this.svgRoot;
+        const ctm   = this.workflow.getScreenCTM()!;
+        const point = svg.createSVGPoint();
+        point.x     = x;
+        point.y     = y;
+
+        const t = point.matrixTransform(ctm.inverse());
+        return {
+            x: t.x,
+            y: t.y
+        };
+    }
+
+    enableEditing(enabled: boolean): void {
+        this.invokePlugins("onEditableStateChange", enabled);
+        this.editingEnabled = enabled;
+    }
+
+    // noinspection JSUnusedGlobalSymbols
+    destroy() {
+
+        this.svgRoot.classList.remove(this.svgID);
+
+        this.clearCanvas();
+        this.eventHub.empty();
+
+        this.invokePlugins("destroy");
+
+        for (const dispose of this.disposers) {
+            dispose();
+        }
+
+        this.isDestroyed = true;
+    }
+
+    resetTransform() {
+        this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
+        this.scaleAtPoint();
+    }
+
+    private assertNotDestroyed(method: string) {
+        if (this.isDestroyed) {
+            throw new Error("Cannot call the " + method + " method on a destroyed graph. " +
+                "Destroying this object removes DOM listeners, " +
+                "and reusing it would result in unexpected things not working. " +
+                "Instead, you can just call the “draw” method with a different model, " +
+                "or create a new Workflow object.");
+
+        }
+    }
+
+    private addEventListeners(): void {
+
+
+        /**
+         * Attach canvas panning
+         */
+        {
+            let pane: SVGGElement | undefined;
+            let x = 0;
+            let y = 0;
+            let matrix: SVGMatrix | undefined;
+            this.domEvents.drag(".pan-handle", (dx, dy) => {
+
+                matrix!.e = x + dx;
+                matrix!.f = y + dy;
+
+            }, (ev, el, root) => {
+                pane   = root!.querySelector(".workflow") as SVGGElement;
+                matrix = pane.transform.baseVal.getItem(0).matrix;
+                x      = matrix.e;
+                y      = matrix.f;
+            }, () => {
+                pane   = undefined;
+                matrix = undefined;
+            });
+        }
+
+        /**
+         * On mouse over node, bring it to the front
+         */
+        this.domEvents.on("mouseover", ".node", (ev, target, root) => {
+            if (this.workflow.querySelector(".edge.dragged")) {
+                return;
+            }
+            target!.parentElement!.appendChild(target!);
+        });
+
+    }
+
+    private clearCanvas() {
+        this.domEvents.detachAll();
+        this.workflow.innerHTML = "";
+        this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
+        this.workflow.setAttribute("class", "workflow");
+    }
+
+    private hookPlugins() {
+
+        this.plugins.forEach(plugin => {
+
+            plugin.registerOnBeforeChange!(event => {
+                this.eventHub.emit("beforeChange", event);
+            });
+
+            plugin.registerOnAfterChange!(event => {
+                this.eventHub.emit("afterChange", event);
+            });
+
+            plugin.registerOnAfterRender!(event => {
+                this.eventHub.emit("afterRender", event);
+            });
+        });
+    }
+
+    private invokePlugins(methodName: keyof SVGPlugin, ...args: any[]) {
+        this.plugins.forEach(plugin => {
+            if (typeof plugin[methodName] === "function") {
+                (plugin[methodName] as Function)(...args);
+            }
+        });
+    }
+
+    /**
+     * Listener for “connection.create” event on model that renders new edges on canvas
+     */
+    private onConnectionCreate(source: Connectable, destination: Connectable): void {
+
+        if (!source.isVisible || !destination.isVisible) {
+            return;
+        }
+
+        const sourceID      = source.connectionId;
+        const destinationID = destination.connectionId;
+
+        GraphEdge.spawnBetweenConnectionIDs(this.workflow, sourceID, destinationID);
+    }
+
+    /**
+     * Listener for "connection.remove" event on the model that disconnects nodes
+     */
+    private onConnectionRemove(source: Connectable, destination: Connectable): void {
+        if (!source.isVisible || !destination.isVisible) {
+            return;
+        }
+
+        const sourceID      = source.connectionId;
+        const destinationID = destination.connectionId;
+
+        const edge = this.svgRoot.querySelector(`.edge[data-source-connection="${sourceID}"][data-destination-connection="${destinationID}"`);
+        edge!.remove();
+    }
+
+    /**
+     * Listener for “input.create” event on model that renders workflow inputs
+     */
+    private onInputCreate(input: WorkflowInputParameterModel): void {
+        if (!input.isVisible) {
+            return;
+        }
+
+        const patched       = GraphNode.patchModelPorts(input);
+        const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
+
+        const el = TemplateParser.parse(graphTemplate)!;
+        this.workflow.appendChild(el);
+
+    }
+
+    /**
+     * Listener for “output.create” event on model that renders workflow outputs
+     */
+    private onOutputCreate(output: WorkflowOutputParameterModel): void {
+
+        if (!output.isVisible) {
+            return;
+        }
+
+        const patched       = GraphNode.patchModelPorts(output);
+        const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
+
+        const el = TemplateParser.parse(graphTemplate)!;
+        this.workflow.appendChild(el);
+    }
+
+    private onStepCreate(step: StepModel) {
+        // if the step doesn't have x & y coordinates, check if they are in the run property
+        if (!step.customProps["sbg:x"] && step.run.customProps && step.run.customProps["sbg:x"]) {
+
+            Object.assign(step.customProps, {
+                "sbg:x": step.run.customProps["sbg:x"],
+                "sbg:y": step.run.customProps["sbg:y"]
+            });
+
+            // remove them from the run property once finished
+            delete step.run.customProps["sbg:x"];
+            delete step.run.customProps["sbg:y"];
+        }
+
+        const template = GraphNode.makeTemplate(step, this.labelScale);
+        const element  = TemplateParser.parse(template)!;
+        this.workflow.appendChild(element);
+    }
+
+
+    private onStepChange(change: StepModel) {
+        const title = this.workflow.querySelector(`.step[data-id="${change.connectionId}"] .title`) as SVGTextElement;
+        if (title) {
+            title.textContent = change.label;
+        }
+    }
+
+    private onInputPortShow(input: WorkflowStepInputModel) {
+
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
+        new StepNode(stepEl, input.parentStep).update();
+    }
+
+    private onInputPortHide(input: WorkflowStepInputModel) {
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
+        new StepNode(stepEl, input.parentStep).update();
+    }
+
+    private onOutputPortCreate(output: WorkflowStepOutputModel) {
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${output.parentStep.connectionId}"]`) as SVGElement;
+        new StepNode(stepEl, output.parentStep).update();
+    }
+
+    private onOutputPortRemove(output: WorkflowStepOutputModel) {
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${output.parentStep.connectionId}"]`) as SVGElement;
+        new StepNode(stepEl, output.parentStep).update();
+    }
+
+    /**
+     * Listener for "step.remove" event on model which removes steps
+     */
+    private onStepRemove(step: StepModel) {
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${step.connectionId}"]`) as SVGElement;
+        stepEl.remove();
+    }
+
+    /**
+     * Listener for "input.remove" event on model which removes inputs
+     */
+    private onInputRemove(input: WorkflowInputParameterModel) {
+        if (!input.isVisible) {
+            return;
+        }
+        const inputEl = this.svgRoot.querySelector(`.node.input[data-connection-id="${input.connectionId}"]`);
+        inputEl!.remove();
+    }
+
+    /**
+     * Listener for "output.remove" event on model which removes outputs
+     */
+    private onOutputRemove(output: WorkflowOutputParameterModel) {
+        if (!output.isVisible) {
+            return;
+        }
+        const outputEl = this.svgRoot.querySelector(`.node.output[data-connection-id="${output.connectionId}"]`);
+        outputEl!.remove();
+    }
+
+    private makeID(length = 6) {
+        let output    = "";
+        const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
+
+        for (let i = 0; i < length; i++) {
+            output += charset.charAt(Math.floor(Math.random() * charset.length));
+        }
+
+        return output;
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/index.ts b/services/workbench2/src/lib/cwl-svg/index.ts
new file mode 100644 (file)
index 0000000..d53918a
--- /dev/null
@@ -0,0 +1,10 @@
+export * from "./graph/workflow";
+export * from "./plugins/zoom/zoom";
+export * from "./plugins/arrange/arrange";
+export * from "./plugins/validate/validate";
+export * from "./plugins/node-move/node-move";
+export * from "./plugins/port-drag/port-drag";
+export * from "./plugins/selection/selection";
+export * from "./plugins/edge-hover/edge-hover";
+export * from "./plugins/deletion/deletion";
+export * from "./utils/svg-dumper";
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/arrange/arrange.ts b/services/workbench2/src/lib/cwl-svg/plugins/arrange/arrange.ts
new file mode 100644 (file)
index 0000000..34efd6f
--- /dev/null
@@ -0,0 +1,470 @@
+import {GraphNode}                                                  from '../../graph/graph-node';
+import {Workflow}                                                   from '../../graph/workflow';
+import {SVGUtils}                                                   from '../../utils/svg-utils';
+import {GraphChange, SVGPlugin}                                     from '../plugin';
+import {
+    StepModel,
+    WorkflowInputParameterModel,
+    WorkflowOutputParameterModel
+} from "cwlts/models";
+
+export class SVGArrangePlugin implements SVGPlugin {
+    private workflow: Workflow;
+    private svgRoot: SVGSVGElement;
+    private onBeforeChange: () => void;
+    private onAfterChange: (updates: NodePositionUpdates) => void;
+    private triggerAfterRender: () => void;
+
+    registerWorkflow(workflow: Workflow): void {
+        this.workflow = workflow;
+        this.svgRoot  = workflow.svgRoot;
+    }
+
+
+    registerOnBeforeChange(fn: (change: GraphChange) => void): void {
+        this.onBeforeChange = () => fn({type: "arrange"});
+    }
+
+    registerOnAfterChange(fn: (change: GraphChange) => void): void {
+        this.onAfterChange = () => fn({type: "arrange"});
+    }
+
+    registerOnAfterRender(fn: (change: GraphChange) => void): void {
+        this.triggerAfterRender = () => fn({type: "arrange"});
+    }
+
+    afterRender(): void {
+        const model     = this.workflow.model;
+        const arr = [] as Array<WorkflowInputParameterModel | WorkflowOutputParameterModel | StepModel>;
+        const drawables = arr.concat(
+            model.steps || [],
+            model.inputs || [],
+            model.outputs || []
+        );
+
+        for (const node of drawables) {
+            if (node.isVisible) {
+                const missingCoordinate = isNaN(parseInt(node.customProps["sbg:x"], 10));
+                if (missingCoordinate) {
+                    this.arrange();
+                    return;
+                }
+            }
+        }
+    }
+
+    arrange() {
+
+        this.onBeforeChange();
+
+        // We need to reset all transformations on the workflow for now.
+        // @TODO Make arranging work without this
+        this.workflow.resetTransform();
+
+        // We need main graph and dangling nodes separately, they will be distributed differently
+        const {mainGraph, danglingNodes} = this.makeNodeGraphs();
+
+        // Create an array of columns, each containing a list of NodeIOs
+        const columns = this.distributeNodesIntoColumns(mainGraph);
+
+        // Get total area in which we will fit the graph, and per-column dimensions
+        const {distributionArea, columnDimensions} = this.calculateColumnSizes(columns);
+
+        // This will be the vertical middle around which the graph should be centered
+        const verticalBaseline = distributionArea.height / 2;
+
+        let xOffset    = 0;
+        let maxYOffset = 0;
+
+        // Here we will store positions for each node that is to be updated.
+        // This should then be emitted as an afterChange event.
+        const nodePositionUpdates = {} as NodePositionUpdates;
+
+        columns.forEach((column, index) => {
+            const colSize = columnDimensions[index];
+            let yOffset   = verticalBaseline - (colSize.height / 2) - column[0].rect.height / 2;
+
+            column.forEach(node => {
+                yOffset += node.rect.height / 2;
+
+                const matrix = SVGUtils.createMatrix().translate(xOffset, yOffset);
+
+                yOffset += node.rect.height / 2;
+
+                if (yOffset > maxYOffset) {
+                    maxYOffset = yOffset;
+                }
+
+                node.el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
+
+                nodePositionUpdates[node.connectionID] = {
+                    x: matrix.e,
+                    y: matrix.f
+                };
+
+            });
+
+            xOffset += colSize.width;
+        });
+
+        const danglingNodeKeys = Object.keys(danglingNodes).sort((a, b) => {
+
+            const aIsInput  = a.startsWith("out/");
+            const aIsOutput = a.startsWith("in/");
+            const bIsInput  = b.startsWith("out/");
+            const bIsOutput = b.startsWith("in/");
+
+            const lowerA = a.toLowerCase();
+            const lowerB = b.toLowerCase();
+
+            if (aIsOutput) {
+
+                if (bIsOutput) {
+                    return lowerB.localeCompare(lowerA);
+                }
+                else {
+                    return 1;
+                }
+            } else if (aIsInput) {
+                if (bIsOutput) {
+                    return -1;
+                }
+                if (bIsInput) {
+                    return lowerB.localeCompare(lowerA);
+                }
+                else {
+                    return 1;
+                }
+            } else {
+                if (!bIsOutput && !bIsInput) {
+                    return lowerB.localeCompare(lowerA);
+                }
+                else {
+                    return -1;
+                }
+            }
+        });
+
+        const danglingNodeMarginOffset = 30;
+        const danglingNodeSideLength   = GraphNode.radius * 5;
+
+        let maxNodeHeightInRow = 0;
+        let row                = 0;
+        const indexWidthMap      = new Map<number, number>();
+        const rowMaxHeightMap    = new Map<number, number>();
+
+        xOffset = 0;
+
+        const danglingRowAreaWidth = Math.max(distributionArea.width, danglingNodeSideLength * 3);
+        danglingNodeKeys.forEach((connectionID, index) => {
+            const el   = danglingNodes[connectionID] as SVGGElement;
+            const rect = el.firstElementChild!.getBoundingClientRect();
+            indexWidthMap.set(index, rect.width);
+
+            if (xOffset === 0) {
+                xOffset -= rect.width / 2;
+            }
+            if (rect.height > maxNodeHeightInRow) {
+                maxNodeHeightInRow = rect.height;
+            }
+            xOffset += rect.width + danglingNodeMarginOffset + Math.max(150 - rect.width, 0);
+
+            if (xOffset >= danglingRowAreaWidth && index < danglingNodeKeys.length - 1) {
+                rowMaxHeightMap.set(row++, maxNodeHeightInRow);
+                maxNodeHeightInRow = 0;
+                xOffset            = 0;
+            }
+        });
+
+        rowMaxHeightMap.set(row, maxNodeHeightInRow);
+        let colYOffset = maxYOffset;
+        xOffset        = 0;
+        row            = 0;
+
+        danglingNodeKeys.forEach((connectionID, index) => {
+            const el        = danglingNodes[connectionID] as SVGGElement;
+            const width     = indexWidthMap.get(index)!;
+            const rowHeight = rowMaxHeightMap.get(row)!;
+            let left        = xOffset + width / 2;
+            const top       = colYOffset
+                + danglingNodeMarginOffset
+                + Math.ceil(rowHeight / 2)
+                + ((xOffset === 0 ? 0 : left) / danglingRowAreaWidth) * danglingNodeSideLength;
+
+            if (xOffset === 0) {
+                left -= width / 2;
+                xOffset -= width / 2;
+            }
+            xOffset += width + danglingNodeMarginOffset + Math.max(150 - width, 0);
+
+            const matrix = SVGUtils.createMatrix().translate(left, top);
+            el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
+
+            nodePositionUpdates[connectionID] = {x: matrix.e, y: matrix.f};
+
+            if (xOffset >= danglingRowAreaWidth) {
+                colYOffset += Math.ceil(rowHeight) + danglingNodeMarginOffset;
+                xOffset            = 0;
+                maxNodeHeightInRow = 0;
+                row++;
+            }
+        });
+
+        this.workflow.redrawEdges();
+        this.workflow.fitToViewport();
+
+        this.onAfterChange(nodePositionUpdates);
+        this.triggerAfterRender();
+
+        for (const id in nodePositionUpdates) {
+            const pos       = nodePositionUpdates[id];
+            const nodeModel = this.workflow.model.findById(id);
+            if (!nodeModel.customProps) {
+                nodeModel.customProps = {};
+            }
+
+            Object.assign(nodeModel.customProps, {
+                "sbg:x": pos.x,
+                "sbg:y": pos.y
+            });
+        }
+
+        return nodePositionUpdates;
+    }
+
+    /**
+     * Calculates column dimensions and total graph area
+     * @param {NodeIO[][]} columns
+     */
+    private calculateColumnSizes(columns: NodeIO[][]): {
+        columnDimensions: {
+            width: number,
+            height: number
+        }[],
+        distributionArea: {
+            width: number,
+            height: number
+        }
+    } {
+        const distributionArea = {width: 0, height: 0};
+        const columnDimensions: any[] = [];
+
+        for (let i = 1; i < columns.length; i++) {
+
+            let width  = 0;
+            let height = 0;
+
+            for (let j = 0; j < columns[i].length; j++) {
+                const entry = columns[i][j];
+
+                height += entry.rect.height;
+
+                if (width < entry.rect.width) {
+                    width = entry.rect.width;
+                }
+            }
+
+            columnDimensions[i] = {height, width};
+
+            distributionArea.width += width;
+            if (height > distributionArea.height) {
+                distributionArea.height = height;
+            }
+        }
+
+        return {
+            columnDimensions,
+            distributionArea
+        };
+
+    }
+
+    /**
+     * Maps node's connectionID to a 1-indexed column number
+     */
+    private distributeNodesIntoColumns(graph: NodeMap): Array<NodeIO[]> {
+        const idToZoneMap   = {};
+        const sortedNodeIDs = Object.keys(graph).sort((a, b) => b.localeCompare(a));
+        const zones         = [] as any[];
+
+        for (let i = 0; i < sortedNodeIDs.length; i++) {
+            const nodeID = sortedNodeIDs[i];
+            const node   = graph[nodeID];
+
+            // For outputs and steps, we calculate the zone as a longest path you can take to them
+            if (node.type !== "input") {
+                idToZoneMap[nodeID] = this.traceLongestNodePathLength(node, graph);
+            } else {
+                //
+                // Longest trace methods would put all inputs in the first column,
+                // but we want it just behind the leftmost step that it is connected to
+                // So instead of:
+                //
+                // (input)<----------------->(step)---
+                // (input)<---------->(step)----------
+                //
+                // It should be:
+                //
+                // ---------------(input)<--->(step)---
+                // --------(input)<-->(step)-----------
+                //
+
+                let closestNodeZone = Infinity;
+                for (let i = 0; i < node.outputs.length; i++) {
+                    const successorNodeZone = idToZoneMap[node.outputs[i]];
+
+                    if (successorNodeZone < closestNodeZone) {
+                        closestNodeZone = successorNodeZone;
+                    }
+                }
+                if (closestNodeZone === Infinity) {
+                    idToZoneMap[nodeID] = 1;
+                } else {
+                    idToZoneMap[nodeID] = closestNodeZone - 1;
+                }
+
+            }
+
+            const zone = idToZoneMap[nodeID];
+            zones[zone] || (zones[zone] = []);
+
+            zones[zone].push(graph[nodeID]);
+        }
+
+        return zones;
+
+    }
+
+    /**
+     * Finds all nodes in the graph, and indexes them by their "data-connection-id" attribute
+     */
+    private indexNodesByID(): { [dataConnectionID: string]: SVGGElement } {
+        const indexed = {};
+        const nodes   = this.svgRoot.querySelectorAll(".node");
+
+        for (let i = 0; i < nodes.length; i++) {
+            indexed[nodes[i].getAttribute("data-connection-id")!] = nodes[i];
+        }
+
+        return indexed;
+    }
+
+    /**
+     * Finds length of the longest possible path from the graph root to a node.
+     * Lengths are 1-indexed. When a node has no predecessors, it will have length of 1.
+     */
+    private traceLongestNodePathLength(node: NodeIO, nodeGraph: any, visited = new Set<NodeIO>()): number {
+
+        visited.add(node);
+
+        if (node.inputs.length === 0) {
+            return 1;
+        }
+
+        const inputPathLengths: any[] = [];
+
+        for (let i = 0; i < node.inputs.length; i++) {
+            const el = nodeGraph[node.inputs[i]];
+
+            if (visited.has(el)) {
+                continue;
+            }
+
+            inputPathLengths.push(this.traceLongestNodePathLength(el, nodeGraph, visited));
+        }
+
+        return Math.max(...inputPathLengths) + 1;
+    }
+
+    private makeNodeGraphs(): {
+        mainGraph: NodeMap,
+        danglingNodes: { [nodeID: string]: SVGGElement }
+    } {
+
+        // We need all nodes in order to find the dangling ones, those will be sorted separately
+        const allNodes = this.indexNodesByID();
+
+        // Make a graph representation where you can trace inputs and outputs from/to connection ids
+        const nodeGraph = {} as NodeMap;
+
+        // Edges are the main source of information from which we will distribute nodes
+        const edges = this.svgRoot.querySelectorAll(".edge");
+
+        for (let i = 0; i < edges.length; i++) {
+
+            const edge = edges[i];
+
+            const sourceConnectionID      = edge.getAttribute("data-source-connection")!;
+            const destinationConnectionID = edge.getAttribute("data-destination-connection")!;
+
+            const [sourceSide, sourceNodeID, sourcePortID]                = sourceConnectionID.split("/");
+            const [destinationSide, destinationNodeID, destinationPortID] = destinationConnectionID.split("/");
+
+            // Both source and destination are considered to be steps by default
+            let sourceType      = "step";
+            let destinationType = "step";
+
+            // Ports have the same node and port ids
+            if (sourceNodeID === sourcePortID) {
+                sourceType = sourceSide === "in" ? "output" : "input";
+            }
+
+            if (destinationNodeID === destinationPortID) {
+                destinationType = destinationSide === "in" ? "output" : "input";
+            }
+
+            // Initialize keys on graph if they don't exist
+            const sourceNode      = this.svgRoot.querySelector(`.node[data-id="${sourceNodeID}"]`) as SVGGElement;
+            const destinationNode = this.svgRoot.querySelector(`.node[data-id="${destinationNodeID}"]`) as SVGGElement;
+
+            const sourceNodeConnectionID      = sourceNode.getAttribute("data-connection-id")!;
+            const destinationNodeConnectionID = destinationNode.getAttribute("data-connection-id")!;
+
+            // Source and destination of this edge are obviously not dangling, so we can remove them
+            // from the set of potentially dangling nodes
+            delete allNodes[sourceNodeConnectionID];
+            delete allNodes[destinationNodeConnectionID];
+
+            // Ensure that the source node has its entry in the node graph
+            (nodeGraph[sourceNodeID] || (nodeGraph[sourceNodeID] = {
+                inputs: [],
+                outputs: [],
+                type: sourceType,
+                connectionID: sourceNodeConnectionID,
+                el: sourceNode,
+                rect: sourceNode.getBoundingClientRect()
+            }));
+
+            // Ensure that the source node has its entry in the node graph
+            (nodeGraph[destinationNodeID] || (nodeGraph[destinationNodeID] = {
+                inputs: [],
+                outputs: [],
+                type: destinationType,
+                connectionID: destinationNodeConnectionID,
+                el: destinationNode,
+                rect: destinationNode.getBoundingClientRect()
+            }));
+
+            nodeGraph[sourceNodeID].outputs.push(destinationNodeID);
+            nodeGraph[destinationNodeID].inputs.push(sourceNodeID);
+        }
+
+        return {
+            mainGraph: nodeGraph,
+            danglingNodes: allNodes
+        };
+    }
+}
+
+
+export type NodeIO = {
+    inputs: string[],
+    outputs: string[],
+    connectionID: string,
+    el: SVGGElement,
+    rect: ClientRect,
+    type: "step" | "input" | "output" | string
+};
+export type NodeMap = { [connectionID: string]: NodeIO }
+
+export type NodePositionUpdates = { [connectionID: string]: { x: number, y: number } };
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/deletion/deletion.ts b/services/workbench2/src/lib/cwl-svg/plugins/deletion/deletion.ts
new file mode 100644 (file)
index 0000000..3af7543
--- /dev/null
@@ -0,0 +1,75 @@
+import {PluginBase} from "../plugin-base";
+import {SelectionPlugin} from "../selection/selection";
+import {StepModel, WorkflowInputParameterModel, WorkflowOutputParameterModel} from "cwlts/models";
+
+export class DeletionPlugin extends PluginBase {
+
+    private boundDeleteFunction = this.onDelete.bind(this);
+
+    afterRender(): void {
+        this.attachDeleteBehavior();
+    }
+
+    onEditableStateChange(enable: boolean) {
+        if (enable) {
+            this.attachDeleteBehavior();
+        } else {
+            this.detachDeleteBehavior();
+        }
+    }
+
+    private attachDeleteBehavior() {
+
+        this.detachDeleteBehavior();
+        window.addEventListener("keyup", this.boundDeleteFunction, true);
+    }
+
+    private detachDeleteBehavior() {
+        window.removeEventListener("keyup", this.boundDeleteFunction, true);
+    }
+
+    private onDelete(ev: KeyboardEvent) {
+        if ((ev.which !== 8 && ev.which !== 46) || !(ev.target instanceof SVGElement)) {
+            return;
+        }
+
+        this.deleteSelection();
+    }
+
+    public deleteSelection() {
+        const selection = this.workflow.getPlugin(SelectionPlugin);
+
+        if (!selection || !this.workflow.editingEnabled) {
+            return;
+        }
+
+        const selected = selection.getSelection();
+        selected.forEach((type, id) => {
+            if (type === "node") {
+                const model = this.workflow.model.findById(id);
+
+                if (model instanceof StepModel) {
+                    this.workflow.model.removeStep(model);
+                    selection.clearSelection();
+
+                } else if (model instanceof WorkflowInputParameterModel) {
+                    this.workflow.model.removeInput(model);
+                    selection.clearSelection();
+
+                } else if (model instanceof WorkflowOutputParameterModel) {
+
+                    this.workflow.model.removeOutput(model);
+                    selection.clearSelection();
+                }
+            } else {
+                const [source, destination] = id.split(SelectionPlugin.edgePortsDelimiter);
+                this.workflow.model.disconnect(source, destination);
+                selection.clearSelection();
+            }
+        });
+    }
+
+    destroy() {
+        this.detachDeleteBehavior();
+    }
+}
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/edge-hover/edge-hover.ts b/services/workbench2/src/lib/cwl-svg/plugins/edge-hover/edge-hover.ts
new file mode 100644 (file)
index 0000000..9d3b423
--- /dev/null
@@ -0,0 +1,87 @@
+import {PluginBase} from "../plugin-base";
+
+export class SVGEdgeHoverPlugin extends PluginBase {
+
+    private boundEdgeEnterFunction = this.onEdgeEnter.bind(this);
+
+    private modelListener: { dispose: Function } = {
+        dispose: () => void 0
+    };
+
+    afterRender(): void {
+        this.attachEdgeHoverBehavior();
+    }
+
+    destroy(): void {
+        this.detachEdgeHoverBehavior();
+        this.modelListener.dispose();
+    }
+
+    private attachEdgeHoverBehavior() {
+
+        this.detachEdgeHoverBehavior();
+        this.workflow.workflow.addEventListener("mouseenter", this.boundEdgeEnterFunction, true);
+    }
+
+    private detachEdgeHoverBehavior() {
+        this.workflow.workflow.removeEventListener("mouseenter", this.boundEdgeEnterFunction, true);
+    }
+
+    private onEdgeEnter(ev: MouseEvent) {
+
+
+        // Ignore if we did not enter an edge
+        if (!(ev.target! as Element).classList.contains("edge")) return;
+
+        const target = ev.target as SVGGElement;
+        let tipEl: SVGGElement;
+
+        const onMouseMove = (ev: MouseEvent) => {
+            const coords = this.workflow.transformScreenCTMtoCanvas(ev.clientX, ev.clientY);
+            tipEl.setAttribute("x", String(coords.x));
+            tipEl.setAttribute("y", String(coords.y - 16));
+        };
+
+        const onMouseLeave = (ev: MouseEvent) => {
+            tipEl.remove();
+            target.removeEventListener("mousemove", onMouseMove);
+            target.removeEventListener("mouseleave", onMouseLeave)
+        };
+
+        this.modelListener = this.workflow.model.on("connection.remove", (source, destination) => {
+            if (!tipEl) return;
+            const [tipS, tipD] = tipEl.getAttribute("data-source-destination")!.split("$!$");
+            if (tipS === source.connectionId && tipD === destination.connectionId) {
+                tipEl.remove();
+            }
+        });
+
+        const sourceNode    = target.getAttribute("data-source-node");
+        const destNode      = target.getAttribute("data-destination-node");
+        const sourcePort    = target.getAttribute("data-source-port");
+        const destPort      = target.getAttribute("data-destination-port");
+        const sourceConnect = target.getAttribute("data-source-connection");
+        const destConnect   = target.getAttribute("data-destination-connection");
+
+        const sourceLabel = sourceNode === sourcePort ? sourceNode : `${sourceNode} (${sourcePort})`;
+        const destLabel   = destNode === destPort ? destNode : `${destNode} (${destPort})`;
+
+        const coords = this.workflow.transformScreenCTMtoCanvas(ev.clientX, ev.clientY);
+
+        const ns = "http://www.w3.org/2000/svg";
+        tipEl    = document.createElementNS(ns, "text");
+        tipEl.classList.add("label");
+        tipEl.classList.add("label-edge");
+        tipEl.setAttribute("x", String(coords.x));
+        tipEl.setAttribute("y", String(coords.y));
+        tipEl.setAttribute("data-source-destination", sourceConnect + "$!$" + destConnect);
+        tipEl.innerHTML = sourceLabel + " → " + destLabel;
+
+        this.workflow.workflow.appendChild(tipEl);
+
+        target.addEventListener("mousemove", onMouseMove);
+        target.addEventListener("mouseleave", onMouseLeave);
+
+    }
+
+}
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/node-move/node-move.ts b/services/workbench2/src/lib/cwl-svg/plugins/node-move/node-move.ts
new file mode 100644 (file)
index 0000000..0f3d4a4
--- /dev/null
@@ -0,0 +1,277 @@
+import {Workflow}   from "../..";
+import {PluginBase} from "../plugin-base";
+import {EdgePanner} from "../../behaviors/edge-panning";
+
+export interface ConstructorParams {
+    movementSpeed?: number,
+    scrollMargin?: number
+}
+
+/**
+ * This plugin makes node dragging and movement possible.
+ *
+ * @FIXME: attach events for before and after change
+ */
+export class SVGNodeMovePlugin extends PluginBase {
+
+    /** Difference in movement on the X axis since drag start, adapted for scale and possibly panned distance */
+    private sdx: number;
+
+    /** Difference in movement on the Y axis since drag start, adapted for scale and possibly panned distance */
+    private sdy: number;
+
+    /** Stored onDragStart so we can put node to a fixed position determined by startX + ∆x */
+    private startX?: number;
+
+    /** Stored onDragStart so we can put node to a fixed position determined by startY + ∆y */
+    private startY?: number;
+
+    /** How far from the edge of the viewport does mouse need to be before panning is triggered */
+    private scrollMargin = 50;
+
+    /** How fast does workflow move while panning */
+    private movementSpeed = 10;
+
+    /** Holds an element that is currently being dragged. Stored onDragStart and translated afterwards. */
+    private movingNode?: SVGGElement;
+
+    /** Stored onDragStart to detect collision with viewport edges */
+    private boundingClientRect?: ClientRect;
+
+    /** Cache input edges and their parsed bezier curve parameters so we don't query for them on each mouse move */
+    private inputEdges?: Map<SVGPathElement, number[]>;
+
+    /** Cache output edges and their parsed bezier curve parameters so we don't query for them on each mouse move */
+    private outputEdges?: Map<SVGPathElement, number[]>;
+
+    /** Workflow panning at the time of onDragStart, used to adjust ∆x and ∆y while panning */
+    private startWorkflowTranslation?: { x: number, y: number };
+
+    private wheelPrevent = (ev: any) => ev.stopPropagation();
+
+    private boundMoveHandler      = this.onMove.bind(this);
+    private boundMoveStartHandler = this.onMoveStart.bind(this);
+    private boundMoveEndHandler   = this.onMoveEnd.bind(this);
+
+    private detachDragListenerFn: any = undefined;
+
+    private edgePanner: EdgePanner;
+
+    constructor(parameters: ConstructorParams = {}) {
+        super();
+        Object.assign(this, parameters);
+    }
+
+
+    onEditableStateChange(enabled: boolean): void {
+
+        if (enabled) {
+            this.attachDrag();
+        } else {
+            this.detachDrag();
+        }
+    }
+
+    afterRender() {
+
+        if (this.workflow.editingEnabled) {
+            this.attachDrag();
+        }
+
+    }
+
+    destroy(): void {
+        this.detachDrag();
+    }
+
+    registerWorkflow(workflow: Workflow): void {
+        super.registerWorkflow(workflow);
+
+        this.edgePanner = new EdgePanner(this.workflow, {
+            scrollMargin: this.scrollMargin,
+            movementSpeed: this.movementSpeed
+        });
+    }
+
+    private detachDrag() {
+        if (typeof this.detachDragListenerFn === "function") {
+            this.detachDragListenerFn();
+        }
+
+        this.detachDragListenerFn = undefined;
+    }
+
+    private attachDrag() {
+
+        this.detachDrag();
+
+        this.detachDragListenerFn = this.workflow.domEvents.drag(
+            ".node .core",
+            this.boundMoveHandler,
+            this.boundMoveStartHandler,
+            this.boundMoveEndHandler
+        );
+    }
+
+    private getWorkflowMatrix(): SVGMatrix {
+        return this.workflow.workflow.transform.baseVal.getItem(0).matrix;
+    }
+
+    private onMove(dx: number, dy: number, ev: MouseEvent): void {
+
+        /** We will use workflow scale to determine how our mouse movement translate to svg proportions */
+        const scale = this.workflow.scale;
+
+        /** Need to know how far did the workflow itself move since when we started dragging */
+        const matrixMovement = {
+            x: this.getWorkflowMatrix().e - this.startWorkflowTranslation!.x,
+            y: this.getWorkflowMatrix().f - this.startWorkflowTranslation!.y
+        };
+
+        /** We might have hit the boundary and need to start panning */
+        this.edgePanner.triggerCollisionDetection(ev.clientX, ev.clientY, (sdx, sdy) => {
+            this.sdx += sdx;
+            this.sdy += sdy;
+
+            this.translateNodeBy(this.movingNode!, sdx, sdy);
+            this.redrawEdges(this.sdx, this.sdy);
+        });
+
+        /**
+         * We need to store scaled ∆x and ∆y because this is not the only place from which node is being moved.
+         * If mouse is outside the viewport, and the workflow is panning, startScroll will continue moving
+         * this node, so it needs to know where to start from and update it, so this method can take
+         * over when mouse gets back to the viewport.
+         *
+         * If there was no handoff, node would jump back and forth to
+         * last positions for each movement initiator separately.
+         */
+        this.sdx = (dx - matrixMovement.x) / scale;
+        this.sdy = (dy - matrixMovement.y) / scale;
+
+        const moveX = this.sdx + this.startX!;
+        const moveY = this.sdy + this.startY!;
+
+        this.translateNodeTo(this.movingNode!, moveX, moveY);
+        this.redrawEdges(this.sdx, this.sdy);
+    }
+
+    /**
+     * Triggered from {@link attachDrag} when drag starts.
+     * This method initializes properties that are needed for calculations during movement.
+     */
+    private onMoveStart(event: MouseEvent, handle: SVGGElement): void {
+
+        /** We will query the SVG dom for edges that we need to move, so store svg element for easy access */
+        const svg = this.workflow.svgRoot;
+
+        document.addEventListener("mousewheel", this.wheelPrevent, true);
+
+        /** Our drag handle is not the whole node because that would include ports and labels, but a child of it*/
+        const node = handle.parentNode as SVGGElement;
+
+        /** Store initial transform values so we know how much we've moved relative from the starting position */
+        const nodeMatrix = node.transform.baseVal.getItem(0).matrix;
+        this.startX      = nodeMatrix.e;
+        this.startY      = nodeMatrix.f;
+
+        /** We have to query for edges that are attached to this node because we will move them as well */
+        const nodeID = node.getAttribute("data-id");
+
+        /**
+         * When user drags the node to the edge and waits while workflow pans to the side,
+         * mouse movement stops, but workflow movement starts.
+         * We then utilize this to get movement ∆ of the workflow, and use that for translation instead.
+         */
+        this.startWorkflowTranslation = {
+            x: this.getWorkflowMatrix().e,
+            y: this.getWorkflowMatrix().f
+        };
+
+        /** Used to determine whether dragged node is hitting the edge, so we can pan the Workflow*/
+        this.boundingClientRect = svg.getBoundingClientRect();
+
+        /** Node movement can be initiated from both mouse events and animationFrame, so make it accessible */
+        this.movingNode = handle.parentNode as SVGGElement;
+
+        /**
+         * While node is being moved, incoming and outgoing edges also need to be moved in order to stay attached.
+         * We don't want to query them all the time, so we cache them in maps that point from their dom elements
+         * to an array of numbers that represent their bezier curves, since we will update those curves.
+         */
+        this.inputEdges = new Map();
+        this.outputEdges = new Map();
+
+        const outputsSelector = `.edge[data-source-node='${nodeID}'] .sub-edge`;
+        const inputsSelector  = `.edge[data-destination-node='${nodeID}'] .sub-edge`;
+
+        const query: any = svg.querySelectorAll([inputsSelector, outputsSelector].join(", ")) as NodeListOf<SVGPathElement>;
+
+        for (let subEdge of query) {
+            const isInput = subEdge.parentElement.getAttribute("data-destination-node") === nodeID;
+            const path    = subEdge.getAttribute("d").split(" ").map(Number).filter((e: any) => !isNaN(e));
+            isInput ? this.inputEdges.set(subEdge, path) : this.outputEdges.set(subEdge, path);
+        }
+    }
+
+    private translateNodeBy(node: SVGGElement, x?: number, y?: number): void {
+        const matrix = node.transform.baseVal.getItem(0).matrix;
+        this.translateNodeTo(node, matrix.e + x!, matrix.f + y!);
+    }
+
+    private translateNodeTo(node: SVGGElement, x?: number, y?: number): void {
+        node.transform.baseVal.getItem(0).setTranslate(x!, y!);
+    }
+
+    /**
+     * Redraws stored input and output edges so as to transform them with respect to
+     * scaled transformation differences, sdx and sdy.
+     */
+    private redrawEdges(sdx: number, sdy: number): void {
+        this.inputEdges!.forEach((p, el) => {
+            const path = Workflow.makeConnectionPath(p[0], p[1], p[6] + sdx, p[7] + sdy);
+            el.setAttribute("d", path!);
+        });
+
+        this.outputEdges!.forEach((p, el) => {
+            const path = Workflow.makeConnectionPath(p[0] + sdx, p[1] + sdy, p[6], p[7]);
+            el.setAttribute("d", path!);
+        });
+    }
+
+    /**
+     * Triggered from {@link attachDrag} after move event ends
+     */
+    private onMoveEnd(): void {
+
+        this.edgePanner.stop();
+
+        const id        = this.movingNode!.getAttribute("data-connection-id")!;
+        const nodeModel = this.workflow.model.findById(id);
+
+        if (!nodeModel.customProps) {
+            nodeModel.customProps = {};
+        }
+
+        const matrix = this.movingNode!.transform.baseVal.getItem(0).matrix;
+
+        Object.assign(nodeModel.customProps, {
+            "sbg:x": matrix.e,
+            "sbg:y": matrix.f,
+        });
+
+        this.onAfterChange({type: "node-move"});
+
+        document.removeEventListener("mousewheel", this.wheelPrevent, true);
+
+        delete this.startX;
+        delete this.startY;
+        delete this.movingNode;
+        delete this.inputEdges;
+        delete this.outputEdges;
+        delete this.boundingClientRect;
+        delete this.startWorkflowTranslation;
+    }
+
+
+}
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/plugin-base.ts b/services/workbench2/src/lib/cwl-svg/plugins/plugin-base.ts
new file mode 100644 (file)
index 0000000..1b0c650
--- /dev/null
@@ -0,0 +1,32 @@
+import {GraphChange, SVGPlugin} from "./plugin";
+import {Workflow}               from "../graph/workflow";
+
+export abstract class PluginBase implements SVGPlugin {
+
+    protected workflow: Workflow;
+
+    /** plugin should trigger before a change is about to occur on the model */
+    protected onBeforeChange: (change: GraphChange) => void;
+
+    /** plugin should trigger after a change has occurred on the model */
+    protected onAfterChange: (change: GraphChange) => void;
+
+    /** plugin should trigger when internal svg elements have been deleted and new ones created */
+    protected onAfterRender: (change: GraphChange) => void;
+
+    registerWorkflow(workflow: Workflow): void {
+        this.workflow = workflow;
+    }
+
+    registerOnBeforeChange(fn: (change: GraphChange) => void): void {
+        this.onBeforeChange = fn;
+    }
+
+    registerOnAfterChange(fn: (change: GraphChange) => void): void {
+        this.onAfterChange = fn;
+    }
+
+    registerOnAfterRender(fn: (change: GraphChange) => void): void {
+        this.onAfterRender = fn;
+    }
+}
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/plugin.ts b/services/workbench2/src/lib/cwl-svg/plugins/plugin.ts
new file mode 100644 (file)
index 0000000..23c7d62
--- /dev/null
@@ -0,0 +1,34 @@
+import {Workflow} from '../graph/workflow';
+
+export interface GraphChange {
+    type: string;
+
+}
+
+export interface SVGPlugin {
+
+    registerWorkflow?(workflow: Workflow): void;
+
+    registerOnBeforeChange?(fn: (change: GraphChange) => void): void;
+
+    registerOnAfterChange?(fn: (change: GraphChange) => void): void;
+
+    registerOnAfterRender?(fn: (change: GraphChange) => void): void;
+
+    afterRender?(): void;
+
+    /**
+     * Invoked when the underlying model instance changes.
+     * Implementation should dispose listeners from the old model and attach listeners to the new one.
+     */
+    afterModelChange?(): void;
+
+    onEditableStateChange?(enabled: boolean): void;
+
+    /**
+     * Invoked when a graph should be destroyed.
+     * Implementations should remove attached DOM and model event listeners, as well as other stuff that
+     * might be left in memory.
+     */
+    destroy?(): void;
+}
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/port-drag/_variables.scss b/services/workbench2/src/lib/cwl-svg/plugins/port-drag/_variables.scss
new file mode 100644 (file)
index 0000000..f79aacd
--- /dev/null
@@ -0,0 +1,9 @@
+@import "../../assets/styles/variables";
+
+$port-suggested-fill: $color-primary !default;
+$port-snap-highlight-stroke: $port-hover-stroke-color !default;
+$port-snap-stroke-width: 2px !default;
+$edge-dragged-stroke: $edge-inner-stroke-color !default;
+$edge-dragged-dasharray: 5 !default;
+$ghost-node-stroke-color: $node-input-fill-color !default;
+$ghost-node-fill-color: $background-color !default;
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/port-drag/port-drag.ts b/services/workbench2/src/lib/cwl-svg/plugins/port-drag/port-drag.ts
new file mode 100644 (file)
index 0000000..e5d11e3
--- /dev/null
@@ -0,0 +1,442 @@
+import {PluginBase} from "../plugin-base";
+import {Workflow}   from "../..";
+import {GraphNode}  from "../../graph/graph-node";
+import {Geometry}   from "../../utils/geometry";
+import {Edge}       from "../../graph/edge";
+import {EdgePanner} from "../../behaviors/edge-panning";
+
+export class SVGPortDragPlugin extends PluginBase {
+
+    /** Stored on drag start to detect collision with viewport edges */
+    private boundingClientRect: ClientRect | undefined;
+
+    private portOrigins: Map<SVGGElement, SVGMatrix> | undefined;
+
+    /** Group of edges (compound element) leading from origin port to ghost node */
+    private edgeGroup: SVGGElement | undefined;
+
+    /** Coordinates of the node from which dragged port originates, stored so we can measure the distance from it */
+    private nodeCoords: { x: number; y: number } | undefined;
+
+    /** Reference to a node that marks a new input/output creation */
+    private ghostNode: SVGGElement | undefined;
+
+    /** How far away from the port you need to drag in order to create a new input/output instead of snapping */
+    private snapRadius = 120;
+
+    /** Tells if the port is on the left or on the right side of a node */
+    private portType: "input" | "output";
+
+    /** Stores a port to which a connection would snap if user stops the drag */
+    private snapPort: SVGGElement | undefined;
+
+    /** Map of CSS classes attached by this plugin */
+    private css = {
+
+        /** Added to svgRoot as a sign that this plugin is active */
+        plugin: "__plugin-port-drag",
+
+        /** Suggests that an element that contains it will be the one to snap to */
+        snap: "__port-drag-snap",
+
+        /** Added to svgRoot while dragging is in progress */
+        dragging: "__port-drag-dragging",
+
+        /** Will be added to suggested ports and their parent nodes */
+        suggestion: "__port-drag-suggestion",
+    };
+
+    /** Port from which we initiated the drag */
+    private originPort: SVGGElement | undefined;
+    private detachDragListenerFn: Function | undefined = undefined;
+
+    private wheelPrevent = (ev: any) => ev.stopPropagation();
+    private panner: EdgePanner;
+
+    private ghostX = 0;
+    private ghostY = 0;
+    private portOnCanvas: { x: number; y: number };
+    private lastMouseMove: { x: number; y: number };
+
+    registerWorkflow(workflow: Workflow): void {
+        super.registerWorkflow(workflow);
+        this.panner = new EdgePanner(this.workflow);
+
+        this.workflow.svgRoot.classList.add(this.css.plugin);
+    }
+
+    afterRender(): void {
+        if(this.workflow.editingEnabled){
+            this.attachPortDrag();
+        }
+
+    }
+
+    onEditableStateChange(enabled: boolean): void {
+
+        if (enabled) {
+            this.attachPortDrag();
+        } else {
+            this.detachPortDrag();
+        }
+    }
+
+
+    destroy(): void {
+        this.detachPortDrag();
+    }
+
+    detachPortDrag() {
+        if (typeof this.detachDragListenerFn === "function") {
+            this.detachDragListenerFn();
+        }
+
+        this.detachDragListenerFn = undefined;
+    }
+
+    attachPortDrag() {
+
+        this.detachPortDrag();
+
+        this.detachDragListenerFn = this.workflow.domEvents.drag(
+            ".port",
+            this.onMove.bind(this),
+            this.onMoveStart.bind(this),
+            this.onMoveEnd.bind(this)
+        );
+
+    }
+
+    onMove(dx: number, dy: number, ev: MouseEvent, portElement: SVGGElement): void {
+
+
+        document.addEventListener("mousewheel", this.wheelPrevent, true);
+        const mouseOnSVG = this.workflow.transformScreenCTMtoCanvas(ev.clientX, ev.clientY);
+        const scale      = this.workflow.scale;
+
+        const sdx = (dx - this.lastMouseMove.x) / scale;
+        const sdy = (dy - this.lastMouseMove.y) / scale;
+
+        /** We might have hit the boundary and need to start panning */
+        this.panner.triggerCollisionDetection(ev.clientX, ev.clientY, (sdx, sdy) => {
+            this.ghostX += sdx;
+            this.ghostY += sdy;
+            this.translateGhostNode(this.ghostX, this.ghostY);
+            this.updateEdge(this.portOnCanvas.x, this.portOnCanvas.y, this.ghostX, this.ghostY);
+        });
+
+        const nodeToMouseDistance = Geometry.distance(
+            this.nodeCoords!.x, this.nodeCoords!.y,
+            mouseOnSVG.x, mouseOnSVG.y
+        );
+
+        const closestPort = this.findClosestPort(mouseOnSVG.x, mouseOnSVG.y);
+        this.updateSnapPort(closestPort.portEl!, closestPort.distance);
+
+        this.ghostX += sdx;
+        this.ghostY += sdy;
+
+        this.translateGhostNode(this.ghostX, this.ghostY);
+        this.updateGhostNodeVisibility(nodeToMouseDistance, closestPort.distance);
+        this.updateEdge(this.portOnCanvas.x, this.portOnCanvas.y, this.ghostX, this.ghostY);
+
+        this.lastMouseMove = {x: dx, y: dy};
+    }
+
+    /**
+     * @FIXME: Add panning
+     * @param {MouseEvent} ev
+     * @param {SVGGElement} portEl
+     */
+    onMoveStart(ev: MouseEvent, portEl: SVGGElement): void {
+
+        this.lastMouseMove = {x: 0, y: 0};
+
+        this.originPort   = portEl;
+        const portCTM     = portEl.getScreenCTM()!;
+        this.portOnCanvas = this.workflow.transformScreenCTMtoCanvas(portCTM.e, portCTM.f);
+        this.ghostX       = this.portOnCanvas.x;
+        this.ghostY       = this.portOnCanvas.y;
+
+        // Needed for collision detection
+        this.boundingClientRect = this.workflow.svgRoot.getBoundingClientRect();
+
+        const nodeMatrix = this.workflow.findParent(portEl)!.transform.baseVal.getItem(0).matrix;
+        this.nodeCoords  = {
+            x: nodeMatrix.e,
+            y: nodeMatrix.f
+        };
+
+        const workflowGroup = this.workflow.workflow;
+
+        this.portType = portEl.classList.contains("input-port") ? "input" : "output";
+
+        this.ghostNode = this.createGhostNode(this.portType);
+
+        workflowGroup.appendChild(this.ghostNode);
+
+        /** @FIXME: this should come from workflow */
+        this.edgeGroup = Edge.spawn();
+        this.edgeGroup.classList.add(this.css.dragging);
+
+        workflowGroup.appendChild(this.edgeGroup);
+
+        this.workflow.svgRoot.classList.add(this.css.dragging);
+
+
+        this.portOrigins = this.getPortCandidateTransformations(portEl);
+
+        this.highlightSuggestedPorts(portEl.getAttribute("data-connection-id")!);
+
+
+    }
+
+    onMoveEnd(ev: MouseEvent): void {
+
+        document.removeEventListener("mousewheel", this.wheelPrevent, true);
+
+        this.panner.stop();
+
+        const ghostType      = this.ghostNode!.getAttribute("data-type");
+        const ghostIsVisible = !this.ghostNode!.classList.contains("hidden");
+
+        const shouldSnap         = this.snapPort !== undefined;
+        const shouldCreateInput  = ghostIsVisible && ghostType === "input";
+        const shouldCreateOutput = ghostIsVisible && ghostType === "output";
+        const portID             = this.originPort!.getAttribute("data-connection-id")!;
+
+        if (shouldSnap) {
+            this.createEdgeBetweenPorts(this.originPort!, this.snapPort!);
+        } else if (shouldCreateInput || shouldCreateOutput) {
+
+            const svgCoordsUnderMouse = this.workflow.transformScreenCTMtoCanvas(ev.clientX, ev.clientY);
+            const customProps         = {
+                "sbg:x": svgCoordsUnderMouse.x,
+                "sbg:y": svgCoordsUnderMouse.y
+            };
+
+            if (shouldCreateInput) {
+                this.workflow.model.createInputFromPort(portID, {customProps});
+            } else {
+                this.workflow.model.createOutputFromPort(portID, {customProps});
+            }
+        }
+
+        this.cleanMemory();
+        this.cleanStyles();
+    }
+
+    private updateSnapPort(closestPort: SVGGElement, closestPortDistance: number) {
+
+        const closestPortChanged      = closestPort !== this.snapPort;
+        const closestPortIsOutOfRange = closestPortDistance > this.snapRadius;
+
+        // We might need to remove old class for snapping if we are closer to some other port now
+        if (this.snapPort && (closestPortChanged || closestPortIsOutOfRange)) {
+            const node = this.workflow.findParent(this.snapPort)!;
+            this.snapPort.classList.remove(this.css.snap);
+            node.classList.remove(this.css.snap);
+            delete this.snapPort;
+        }
+
+        // If closest port is further away than our snapRadius, no highlighting should be done
+        if (closestPortDistance > this.snapRadius) {
+            return;
+        }
+
+        const originID = this.originPort!.getAttribute("data-connection-id")!;
+        const targetID = closestPort.getAttribute("data-connection-id")!;
+
+        if (this.findEdge(originID, targetID)) {
+            delete this.snapPort;
+            return;
+        }
+
+        this.snapPort = closestPort;
+
+        const node             = this.workflow.findParent(closestPort)!;
+        const oppositePortType = this.portType === "input" ? "output" : "input";
+
+        closestPort.classList.add(this.css.snap);
+        node.classList.add(this.css.snap);
+        node.classList.add(`${this.css.snap}-${oppositePortType}`);
+    }
+
+    private updateEdge(fromX: number, fromY: number, toX: number, toY: number): void {
+        const subEdges = this.edgeGroup!.children as HTMLCollectionOf<SVGPathElement>;
+
+        for (let subEdge of subEdges as any) {
+
+            const path = Workflow.makeConnectionPath(
+                fromX,
+                fromY,
+                toX,
+                toY,
+                this.portType === "input" ? "left" : "right"
+            );
+
+            subEdge.setAttribute("d", path);
+        }
+    }
+
+    private updateGhostNodeVisibility(distanceToMouse: number, distanceToClosestPort: any) {
+
+        const isHidden        = this.ghostNode!.classList.contains("hidden");
+        const shouldBeVisible = distanceToMouse > this.snapRadius && distanceToClosestPort > this.snapRadius;
+
+        if (shouldBeVisible && isHidden) {
+            this.ghostNode!.classList.remove("hidden");
+        } else if (!shouldBeVisible && !isHidden) {
+            this.ghostNode!.classList.add("hidden");
+        }
+    }
+
+    private translateGhostNode(x: number, y: number) {
+        this.ghostNode!.transform.baseVal.getItem(0).setTranslate(x, y);
+    }
+
+    private getPortCandidateTransformations(portEl: SVGGElement): Map<SVGGElement, SVGMatrix> {
+        const nodeEl           = this.workflow.findParent(portEl)!;
+        const nodeConnectionID = nodeEl.getAttribute("data-connection-id");
+
+        const otherPortType = this.portType === "input" ? "output" : "input";
+        const portQuery     = `.node:not([data-connection-id="${nodeConnectionID}"]) .port.${otherPortType}-port`;
+
+        const candidates: any = this.workflow.workflow.querySelectorAll(portQuery) as NodeListOf<SVGGElement>;
+        const matrices   = new Map<SVGGElement, SVGMatrix>();
+
+        for (let port of candidates) {
+            matrices.set(port, Geometry.getTransformToElement(port, this.workflow.workflow));
+        }
+
+        return matrices;
+    }
+
+    /**
+     * Highlights ports that are model says are suggested.
+     * Also marks their parent nodes as highlighted.
+     *
+     * @param {string} targetConnectionID ConnectionID of the origin port
+     */
+    private highlightSuggestedPorts(targetConnectionID: string): void {
+
+        // Find all ports that we can validly connect to
+        // Note that we can connect to any port, but some of them are suggested based on hypothetical validity.
+        const portModels = this.workflow.model.gatherValidConnectionPoints(targetConnectionID);
+
+        for (let i = 0; i < portModels.length; i++) {
+
+            const portModel = portModels[i];
+
+            if (!portModel.isVisible) continue;
+
+            // Find port element by this connectionID and it's parent node element
+            const portQuery   = `.port[data-connection-id="${portModel.connectionId}"]`;
+            const portElement = this.workflow.workflow.querySelector(portQuery)!;
+            const parentNode  = this.workflow.findParent(portElement)!;
+
+            // Add highlighting classes to port and it's parent node
+            parentNode.classList.add(this.css.suggestion);
+            portElement.classList.add(this.css.suggestion);
+        }
+    }
+
+    /**
+     * @FIXME: GraphNode.radius should somehow come through Workflow,
+     */
+    private createGhostNode(type: "input" | "output"): SVGGElement {
+        const namespace = "http://www.w3.org/2000/svg";
+        const node      = document.createElementNS(namespace, "g");
+
+        node.setAttribute("transform", "matrix(1,0,0,1,0,0)");
+        node.setAttribute("data-type", type);
+        node.classList.add("ghost");
+        node.classList.add("node");
+        node.innerHTML = `<circle class="ghost-circle" cx="0" cy="0" r="${GraphNode.radius / 1.5}"></circle>`;
+
+        return node;
+    }
+
+    /**
+     * Finds a port closest to given SVG coordinates.
+     */
+    private findClosestPort(x: number, y: number): { portEl: SVGGElement | undefined, distance: number } {
+        let closestPort: any     = undefined;
+        let closestDistance: any = Infinity;
+
+        this.portOrigins!.forEach((matrix, port) => {
+
+            const distance = Geometry.distance(x, y, matrix.e, matrix.f);
+
+            if (distance < closestDistance) {
+                closestPort     = port;
+                closestDistance = distance;
+            }
+        });
+
+
+        return {
+            portEl: closestPort,
+            distance: closestDistance
+        };
+    }
+
+    /**
+     * Removes all dom elements and objects cached in-memory during dragging that are no longer needed.
+     */
+    private cleanMemory() {
+        this.edgeGroup!.remove();
+        this.ghostNode!.remove();
+
+        this.snapPort           = undefined;
+        this.edgeGroup          = undefined;
+        this.nodeCoords         = undefined;
+        this.originPort         = undefined;
+        this.portOrigins        = undefined;
+        this.boundingClientRect = undefined;
+
+    }
+
+    /**
+     * Removes all css classes attached by this plugin
+     */
+    private cleanStyles(): void {
+        this.workflow.svgRoot.classList.remove(this.css.dragging);
+
+        for (let cls in this.css) {
+            const query: any = this.workflow.svgRoot.querySelectorAll("." + this.css[cls]);
+
+            for (let el of query) {
+                el.classList.remove(this.css[cls]);
+            }
+        }
+    }
+
+
+    /**
+     * Creates an edge (connection) between two elements determined by their connection IDs
+     * This edge is created on the model, and not rendered directly on graph, as main workflow
+     * is supposed to catch the creation event and draw it.
+     */
+    private createEdgeBetweenPorts(source: SVGGElement, destination: SVGGElement): void {
+
+        // Find the connection ids of origin port and the highlighted port
+        let sourceID      = source.getAttribute("data-connection-id")!;
+        let destinationID = destination.getAttribute("data-connection-id")!;
+
+        // Swap their places in case you dragged out from input to output, since they have to be ordered output->input
+        if (sourceID.startsWith("in")) {
+            const tmp     = sourceID;
+            sourceID      = destinationID;
+            destinationID = tmp;
+        }
+
+        this.workflow.model.connect(sourceID, destinationID);
+    }
+
+    private findEdge(sourceID: string, destinationID: string): SVGGElement | undefined {
+        const ltrQuery = `[data-source-connection="${sourceID}"][data-destination-connection="${destinationID}"]`;
+        const rtlQuery = `[data-source-connection="${destinationID}"][data-destination-connection="${sourceID}"]`;
+        return this.workflow.workflow.querySelector(`${ltrQuery},${rtlQuery}`) as SVGGElement;
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/port-drag/style.css b/services/workbench2/src/lib/cwl-svg/plugins/port-drag/style.css
new file mode 100644 (file)
index 0000000..c2c3c0c
--- /dev/null
@@ -0,0 +1,25 @@
+.cwl-workflow.__plugin-port-drag .port.__port-drag-suggestion {
+  fill: #11a7a7; }
+  .cwl-workflow.__plugin-port-drag .port.__port-drag-suggestion .label {
+    opacity: 1; }
+
+.cwl-workflow.__plugin-port-drag .port.__port-drag-snap {
+  stroke: #676767;
+  stroke-width: 2px; }
+
+.cwl-workflow.__plugin-port-drag .node.__port-drag-snap.__port-drag-snap-input .input-port .label,
+.cwl-workflow.__plugin-port-drag .node.__port-drag-snap.__port-drag-snap-output .output-port .label {
+  opacity: 1; }
+
+.cwl-workflow.__plugin-port-drag.__port-drag-dragging {
+  pointer-events: none; }
+
+.cwl-workflow.__plugin-port-drag .edge.__port-drag-dragging .inner {
+  stroke: #9a9a9a !important;
+  stroke-dasharray: 5; }
+
+.cwl-workflow.__plugin-port-drag .ghost {
+  stroke: #c3c3c3;
+  stroke-width: 2px;
+  stroke-dasharray: 5 3;
+  fill: white; }
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/port-drag/style.scss b/services/workbench2/src/lib/cwl-svg/plugins/port-drag/style.scss
new file mode 100644 (file)
index 0000000..5670125
--- /dev/null
@@ -0,0 +1,56 @@
+@import "variables";
+
+// Expectations:
+//
+.cwl-workflow.__plugin-port-drag {
+
+  // Ports that are marked as suggested should be coloured differently and have visible labels
+  .port.__port-drag-suggestion {
+    fill: $port-suggested-fill;
+
+    .label {
+      opacity: 1;
+    }
+
+  }
+
+  // Port that is marked as a snap choice should have a stroke around it
+  .port.__port-drag-snap {
+    stroke: $port-snap-highlight-stroke;
+    stroke-width: $port-snap-stroke-width;
+  }
+
+  .node.__port-drag-snap {
+
+    // Nodes that are parents of snap choice ports should make all port labels on that side visible
+    &.__port-drag-snap-input .input-port,
+    &.__port-drag-snap-output .output-port {
+
+      .label {
+        opacity: 1;
+      }
+
+    }
+  }
+
+  // While dragging from a port, hover effects over other elements should not be triggered
+  // This also prevents zooming in and out using mouse scroll while dragging
+  &.__port-drag-dragging {
+    pointer-events: none;
+  }
+
+  .edge.__port-drag-dragging .inner {
+    stroke: $edge-dragged-stroke !important;
+    stroke-dasharray: $edge-dragged-dasharray;
+
+  }
+
+  .ghost {
+    stroke: $ghost-node-stroke-color;
+    stroke-width: 2px;
+
+    stroke-dasharray: 5 3;
+    fill: $ghost-node-fill-color;
+  }
+
+}
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.css b/services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.css
new file mode 100644 (file)
index 0000000..c2c3c0c
--- /dev/null
@@ -0,0 +1,25 @@
+.cwl-workflow.__plugin-port-drag .port.__port-drag-suggestion {
+  fill: #11a7a7; }
+  .cwl-workflow.__plugin-port-drag .port.__port-drag-suggestion .label {
+    opacity: 1; }
+
+.cwl-workflow.__plugin-port-drag .port.__port-drag-snap {
+  stroke: #676767;
+  stroke-width: 2px; }
+
+.cwl-workflow.__plugin-port-drag .node.__port-drag-snap.__port-drag-snap-input .input-port .label,
+.cwl-workflow.__plugin-port-drag .node.__port-drag-snap.__port-drag-snap-output .output-port .label {
+  opacity: 1; }
+
+.cwl-workflow.__plugin-port-drag.__port-drag-dragging {
+  pointer-events: none; }
+
+.cwl-workflow.__plugin-port-drag .edge.__port-drag-dragging .inner {
+  stroke: #9a9a9a !important;
+  stroke-dasharray: 5; }
+
+.cwl-workflow.__plugin-port-drag .ghost {
+  stroke: #c3c3c3;
+  stroke-width: 2px;
+  stroke-dasharray: 5 3;
+  fill: white; }
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.dark.css b/services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.dark.css
new file mode 100644 (file)
index 0000000..3213a08
--- /dev/null
@@ -0,0 +1,25 @@
+.cwl-workflow.__plugin-port-drag .port.__port-drag-suggestion {
+  fill: #00fff0; }
+  .cwl-workflow.__plugin-port-drag .port.__port-drag-suggestion .label {
+    opacity: 1; }
+
+.cwl-workflow.__plugin-port-drag .port.__port-drag-snap {
+  stroke: white;
+  stroke-width: 2px; }
+
+.cwl-workflow.__plugin-port-drag .node.__port-drag-snap.__port-drag-snap-input .input-port .label,
+.cwl-workflow.__plugin-port-drag .node.__port-drag-snap.__port-drag-snap-output .output-port .label {
+  opacity: 1; }
+
+.cwl-workflow.__plugin-port-drag.__port-drag-dragging {
+  pointer-events: none; }
+
+.cwl-workflow.__plugin-port-drag .edge.__port-drag-dragging .inner {
+  stroke: #9a9a9a !important;
+  stroke-dasharray: 5; }
+
+.cwl-workflow.__plugin-port-drag .ghost {
+  stroke: #c3c3c3;
+  stroke-width: 2px;
+  stroke-dasharray: 5 3;
+  fill: #303030; }
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.dark.scss b/services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.dark.scss
new file mode 100644 (file)
index 0000000..14fdfa2
--- /dev/null
@@ -0,0 +1,6 @@
+@import "../../assets/styles/themes/rabix-dark/variables";
+
+$port-suggested-fill: #00fff0 !default;
+
+@import "variables";
+@import "./style.scss";
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.scss b/services/workbench2/src/lib/cwl-svg/plugins/port-drag/theme.scss
new file mode 100644 (file)
index 0000000..3671785
--- /dev/null
@@ -0,0 +1,2 @@
+@import "variables";
+@import "./style.scss";
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/selection/_variables.scss b/services/workbench2/src/lib/cwl-svg/plugins/selection/_variables.scss
new file mode 100644 (file)
index 0000000..5b3ed61
--- /dev/null
@@ -0,0 +1,16 @@
+@import "../../assets/styles/variables";
+
+$color-neutral-faded: #e6e6e6 !default;
+$io-faded-fill: #f7f7f7 !default;
+
+$node-selected-outer-stroke: $color-primary !default;
+$edge-selected-inner-stroke: $color-primary !default;
+
+$node-faded-outer-stroke-color: $color-neutral-faded !default;
+$node-faded-step-fill-color: #c1d4d3 !default;
+$node-faded-input-fill-color: $io-faded-fill !default;
+$node-faded-output-fill-color: $io-faded-fill !default;
+
+$label-faded-color: #7e7d7d !default;
+$port-faded-fill-color: $color-neutral-faded !default;
+$edge-faded-inner-stroke-color: $color-neutral-faded !default;
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/selection/selection.ts b/services/workbench2/src/lib/cwl-svg/plugins/selection/selection.ts
new file mode 100644 (file)
index 0000000..443e60a
--- /dev/null
@@ -0,0 +1,234 @@
+import {Workflow} from "../..";
+import {PluginBase} from "../plugin-base";
+
+export class SelectionPlugin extends PluginBase {
+
+    static edgePortsDelimiter = "$!$";
+    private svg: SVGSVGElement;
+    private selection = new Map<string, "edge" | "node">();
+    private cleanups: Function[] = [];
+    private detachModelEvents: Function | undefined;
+
+    private selectionChangeCallbacks: Function[] = [];
+
+    private css = {
+        selected: "__selection-plugin-selected",
+        highlight: "__selection-plugin-highlight",
+        fade: "__selection-plugin-fade",
+        plugin: "__plugin-selection"
+    };
+
+    registerWorkflow(workflow: Workflow): void {
+        super.registerWorkflow(workflow);
+
+        this.svg = this.workflow.svgRoot;
+
+        this.svg.classList.add(this.css.plugin);
+
+        const clickListener = this.onClick.bind(this);
+        this.svg.addEventListener("click", clickListener);
+        this.cleanups.push(() => this.svg.removeEventListener("click", clickListener));
+    }
+
+    afterRender() {
+        this.restoreSelection();
+    }
+
+    afterModelChange(): void {
+        if (typeof this.detachModelEvents === "function") {
+            this.detachModelEvents();
+        }
+
+        this.detachModelEvents = this.bindModelEvents();
+    }
+
+    destroy() {
+
+        if (this.detachModelEvents) {
+            this.detachModelEvents();
+        }
+        this.detachModelEvents = undefined;
+
+        this.svg.classList.remove(this.css.plugin);
+
+        for (const fn of this.cleanups) {
+            fn();
+        }
+    }
+
+    clearSelection(): void {
+
+        const selection: any  = this.svg.querySelectorAll(`.${this.css.selected}`);
+        const highlights: any = this.svg.querySelectorAll(`.${this.css.highlight}`);
+
+        for (const el of selection) {
+            el.classList.remove(this.css.selected);
+        }
+
+        for (const el of highlights) {
+            el.classList.remove(this.css.highlight);
+        }
+
+        this.svg.classList.remove(this.css.fade);
+
+        this.selection.clear();
+
+        this.emitChange(null);
+    }
+
+    getSelection() {
+        return this.selection;
+    }
+
+    registerOnSelectionChange(fn: (node: any) => any) {
+        this.selectionChangeCallbacks.push(fn);
+    }
+
+    selectStep(stepID: string) {
+        const query = `[data-connection-id="${stepID}"]`;
+        const el = this.svg.querySelector(query) as SVGElement;
+
+        if (el) {
+            this.materializeClickOnElement(el);
+        }
+
+    }
+
+    private bindModelEvents() {
+
+        const handler = () => this.restoreSelection();
+        const cleanup: any[] = [];
+        const events  = ["connection.create", "connection.remove"];
+
+        for (const ev of events) {
+            const dispose = this.workflow.model.on(ev as any, handler);
+            cleanup.push(() => dispose.dispose());
+        }
+
+        return () => cleanup.forEach(fn => fn());
+    }
+
+    private restoreSelection() {
+        this.selection.forEach((type, connectionID) => {
+
+            if (type === "node") {
+
+                const el = this.svg.querySelector(`[data-connection-id="${connectionID}"]`) as SVGElement;
+
+                if (el) {
+                    this.selectNode(el);
+                }
+
+            } else if (type === "edge") {
+
+                const [sID, dID]   = connectionID.split(SelectionPlugin.edgePortsDelimiter);
+                const edgeSelector = `[data-source-connection="${sID}"][data-destination-connection="${dID}"]`;
+
+                const edge = this.svg.querySelector(edgeSelector) as SVGElement;
+
+                if (edge) {
+                    this.selectEdge(edge);
+                }
+
+            }
+        });
+    }
+
+    private onClick(click: MouseEvent): void {
+        const target = click.target as SVGElement;
+
+        this.clearSelection();
+
+        this.materializeClickOnElement(target);
+    }
+
+    private materializeClickOnElement(target: SVGElement) {
+
+        let element: SVGElement | undefined;
+
+        if ((element = this.workflow.findParent(target, "node"))) {
+            this.selectNode(element);
+            this.selection.set(element.getAttribute("data-connection-id")!, "node");
+            this.emitChange(element);
+
+        } else if ((element = this.workflow.findParent(target, "edge"))) {
+            this.selectEdge(element);
+            const cid = [
+                element.getAttribute("data-source-connection"),
+                SelectionPlugin.edgePortsDelimiter,
+                element.getAttribute("data-destination-connection")
+            ].join("");
+
+            this.selection.set(cid, "edge");
+            this.emitChange(cid);
+        }
+    }
+
+    private selectNode(element: SVGElement): void {
+        // Fade everything on canvas so we can highlight only selected stuff
+        this.svg.classList.add(this.css.fade);
+
+        // Mark this node as selected
+        element.classList.add(this.css.selected);
+        // Highlight it in case there are no edges on the graph
+        element.classList.add(this.css.highlight);
+
+        // Take all adjacent edges since we should highlight them and move them above the other edges
+        const nodeID        = element.getAttribute("data-id");
+        const adjacentEdges: any = this.svg.querySelectorAll(
+            `.edge[data-source-node="${nodeID}"],` +
+            `.edge[data-destination-node="${nodeID}"]`
+        );
+
+        // Find the first node to be an anchor, so we can put all those edges just before that one.
+        const firstNode = this.svg.getElementsByClassName("node")[0];
+
+        for (const edge of adjacentEdges) {
+
+            // Highlight each adjacent edge
+            edge.classList.add(this.css.highlight);
+
+            // Move it above other edges
+            this.workflow.workflow.insertBefore(edge, firstNode);
+
+            // Find all adjacent nodes so we can highlight them
+            const sourceNodeID      = edge.getAttribute("data-source-node");
+            const destinationNodeID = edge.getAttribute("data-destination-node");
+            const connectedNodes: any    = this.svg.querySelectorAll(
+                `.node[data-id="${sourceNodeID}"],` +
+                `.node[data-id="${destinationNodeID}"]`
+            );
+
+            // Highlight each adjacent node
+            for (const n of connectedNodes) {
+                n.classList.add(this.css.highlight);
+            }
+        }
+    }
+
+    private selectEdge(element: SVGElement) {
+
+        element.classList.add(this.css.highlight);
+        element.classList.add(this.css.selected);
+
+        const sourceNode = element.getAttribute("data-source-node");
+        const destNode   = element.getAttribute("data-destination-node");
+        const sourcePort = element.getAttribute("data-source-port");
+        const destPort   = element.getAttribute("data-destination-port");
+
+        const inputPortSelector  = `.node[data-id="${destNode}"] .input-port[data-port-id="${destPort}"]`;
+        const outputPortSelector = `.node[data-id="${sourceNode}"] .output-port[data-port-id="${sourcePort}"]`;
+
+        const connectedPorts: any = this.svg.querySelectorAll(`${inputPortSelector}, ${outputPortSelector}`);
+
+        for (const port of connectedPorts) {
+            port.classList.add(this.css.highlight);
+        }
+    }
+
+    private emitChange(change: any) {
+        for (const fn of this.selectionChangeCallbacks) {
+            fn(change);
+        }
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/selection/style.css b/services/workbench2/src/lib/cwl-svg/plugins/selection/style.css
new file mode 100644 (file)
index 0000000..9ea27ff
--- /dev/null
@@ -0,0 +1,33 @@
+.cwl-workflow.__plugin-selection .node,
+.cwl-workflow.__plugin-selection .edge {
+  cursor: pointer; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .outer {
+  stroke: #e6e6e6; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .inner {
+  fill: #c1d4d3; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight).input .inner {
+  fill: #f7f7f7; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight).output .inner {
+  fill: #f7f7f7; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .label {
+  fill: #7e7d7d; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .port {
+  fill: #e6e6e6; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .edge:not(.__selection-plugin-highlight) .inner {
+  stroke: #e6e6e6; }
+
+.cwl-workflow.__plugin-selection .port.__selection-plugin-highlight .label {
+  opacity: 1; }
+
+.cwl-workflow.__plugin-selection .__selection-plugin-selected.edge .inner {
+  stroke: #11a7a7; }
+
+.cwl-workflow.__plugin-selection .__selection-plugin-selected.node .outer {
+  stroke: #11a7a7; }
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/selection/style.scss b/services/workbench2/src/lib/cwl-svg/plugins/selection/style.scss
new file mode 100644 (file)
index 0000000..f569f47
--- /dev/null
@@ -0,0 +1,67 @@
+@import "variables";
+
+.cwl-workflow.__plugin-selection {
+
+  .node,
+  .edge {
+    cursor: pointer;
+  }
+
+  // When something is selected on canvas, everything should fade.
+  // Then, selected and highlighted elements should override that.
+  &.__selection-plugin-fade {
+
+    // This is how nodes fade out
+    .node:not(.__selection-plugin-highlight) {
+
+      .outer {
+        stroke: $node-faded-outer-stroke-color;
+      }
+
+      .inner {
+        fill: $node-faded-step-fill-color;
+      }
+
+      &.input .inner {
+        fill: $node-faded-input-fill-color;
+      }
+
+      &.output .inner {
+        fill: $node-faded-output-fill-color;
+      }
+
+      // Their labels fade away a bit less
+      .label {
+        fill: $label-faded-color;
+      }
+      // Ports are darker
+      .port {
+        fill: $port-faded-fill-color;;
+      }
+
+    }
+
+    .edge:not(.__selection-plugin-highlight) {
+      .inner {
+        stroke: $edge-faded-inner-stroke-color;
+      }
+    }
+
+  }
+
+  .port.__selection-plugin-highlight .label {
+    opacity: 1;
+  }
+
+  .__selection-plugin-selected {
+
+    &.edge .inner {
+      stroke: $edge-selected-inner-stroke;
+    }
+
+    &.node .outer {
+      stroke: $node-selected-outer-stroke;
+    }
+
+  }
+}
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/selection/theme.css b/services/workbench2/src/lib/cwl-svg/plugins/selection/theme.css
new file mode 100644 (file)
index 0000000..9ea27ff
--- /dev/null
@@ -0,0 +1,33 @@
+.cwl-workflow.__plugin-selection .node,
+.cwl-workflow.__plugin-selection .edge {
+  cursor: pointer; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .outer {
+  stroke: #e6e6e6; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .inner {
+  fill: #c1d4d3; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight).input .inner {
+  fill: #f7f7f7; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight).output .inner {
+  fill: #f7f7f7; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .label {
+  fill: #7e7d7d; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .port {
+  fill: #e6e6e6; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .edge:not(.__selection-plugin-highlight) .inner {
+  stroke: #e6e6e6; }
+
+.cwl-workflow.__plugin-selection .port.__selection-plugin-highlight .label {
+  opacity: 1; }
+
+.cwl-workflow.__plugin-selection .__selection-plugin-selected.edge .inner {
+  stroke: #11a7a7; }
+
+.cwl-workflow.__plugin-selection .__selection-plugin-selected.node .outer {
+  stroke: #11a7a7; }
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/selection/theme.dark.css b/services/workbench2/src/lib/cwl-svg/plugins/selection/theme.dark.css
new file mode 100644 (file)
index 0000000..c211e72
--- /dev/null
@@ -0,0 +1,33 @@
+.cwl-workflow.__plugin-selection .node,
+.cwl-workflow.__plugin-selection .edge {
+  cursor: pointer; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .outer {
+  stroke: #444343; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .inner {
+  fill: #216b6b; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight).input .inner {
+  fill: #838383; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight).output .inner {
+  fill: #838383; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .label {
+  fill: #7e7d7d; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .node:not(.__selection-plugin-highlight) .port {
+  fill: #444343; }
+
+.cwl-workflow.__plugin-selection.__selection-plugin-fade .edge:not(.__selection-plugin-highlight) .inner {
+  stroke: #444343; }
+
+.cwl-workflow.__plugin-selection .port.__selection-plugin-highlight .label {
+  opacity: 1; }
+
+.cwl-workflow.__plugin-selection .__selection-plugin-selected.edge .inner {
+  stroke: #11a7a7; }
+
+.cwl-workflow.__plugin-selection .__selection-plugin-selected.node .outer {
+  stroke: #11a7a7; }
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/selection/theme.dark.scss b/services/workbench2/src/lib/cwl-svg/plugins/selection/theme.dark.scss
new file mode 100644 (file)
index 0000000..a82537b
--- /dev/null
@@ -0,0 +1,19 @@
+@import "../../assets/styles/themes/rabix-dark/variables";
+
+$color-neutral-faded: #444343 !default;
+$io-faded-fill: #838383 !default;
+
+$node-selected-outer-stroke: $color-primary !default;
+$edge-selected-inner-stroke: $color-primary !default;
+
+$node-faded-outer-stroke-color: $color-neutral-faded !default;
+$node-faded-step-fill-color: #216b6b !default;
+$node-faded-input-fill-color: $io-faded-fill !default;
+$node-faded-output-fill-color: $io-faded-fill !default;
+
+$label-faded-color: #7e7d7d !default;
+$port-faded-fill-color: $color-neutral-faded !default;
+$edge-faded-inner-stroke-color: $color-neutral-faded !default;
+
+@import "variables";
+@import "./style.scss";
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/selection/theme.scss b/services/workbench2/src/lib/cwl-svg/plugins/selection/theme.scss
new file mode 100644 (file)
index 0000000..3671785
--- /dev/null
@@ -0,0 +1,2 @@
+@import "variables";
+@import "./style.scss";
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/validate/validate.css b/services/workbench2/src/lib/cwl-svg/plugins/validate/validate.css
new file mode 100644 (file)
index 0000000..b2fd314
--- /dev/null
@@ -0,0 +1,5 @@
+svg.cwl-workflow.__plugin-validate .workflow .__validate-invalid .inner {
+  stroke: #f89406; }
+
+svg.cwl-workflow.__plugin-validate .workflow.has-selection .__validate-invalid:not(.highlighted) .inner {
+  stroke: #7c4a03; }
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/validate/validate.scss b/services/workbench2/src/lib/cwl-svg/plugins/validate/validate.scss
new file mode 100644 (file)
index 0000000..2de2c2f
--- /dev/null
@@ -0,0 +1,14 @@
+$edge-invalid-fill: #f89406;
+
+svg.cwl-workflow.__plugin-validate .workflow {
+
+    .__validate-invalid .inner {
+        stroke: $edge-invalid-fill;
+    }
+
+    &.has-selection {
+        .__validate-invalid:not(.highlighted) .inner {
+            stroke: darken($edge-invalid-fill, 25%);
+        }
+    }
+}
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/validate/validate.ts b/services/workbench2/src/lib/cwl-svg/plugins/validate/validate.ts
new file mode 100644 (file)
index 0000000..fd7bd0a
--- /dev/null
@@ -0,0 +1,91 @@
+import {Edge}          from "cwlts/models";
+import {PluginBase}    from "../plugin-base";
+import { Workflow } from "lib/cwl-svg";
+
+export class SVGValidatePlugin extends PluginBase {
+
+    private modelDisposers: any[] = [];
+
+    /** Map of CSS classes attached by this plugin */
+    private css = {
+        plugin: "__plugin-validate",
+        invalid: "__validate-invalid"
+    };
+
+    registerWorkflow(workflow: Workflow): void {
+        super.registerWorkflow(workflow);
+
+        // add plugin specific class to the svgRoot for scoping
+        this.workflow.svgRoot.classList.add(this.css.plugin);
+    }
+
+    afterModelChange(): void {
+
+        this.disposeModelListeners();
+
+        // add listener for all subsequent edge validation
+        const update = this.workflow.model.on("connections.updated", this.renderEdgeValidation.bind(this));
+        const create = this.workflow.model.on("connection.create", this.renderEdgeValidation.bind(this));
+
+        this.modelDisposers.concat([update.dispose, create.dispose]);
+    }
+
+    destroy(): void {
+        this.disposeModelListeners();
+    }
+
+    afterRender(): void {
+        // do initial validation rendering for edges
+        this.renderEdgeValidation();
+    }
+
+    onEditableStateChange(enabled: boolean): void {
+
+        if (enabled) {
+            // only show validation if workflow is editable
+            this.renderEdgeValidation();
+        } else {
+            this.removeClasses(this.workflow.workflow.querySelectorAll(".edge"))
+        }
+    }
+
+    private disposeModelListeners(): void {
+        for (let disposeListener of this.modelDisposers) {
+            disposeListener();
+        }
+        this.modelDisposers = [];
+    }
+
+    private removeClasses(edges: NodeListOf<Element>): void {
+        // remove validity class on all edges
+        for (const e of (edges as any)) {
+            e.classList.remove(this.css.invalid);
+        }
+    }
+
+    private renderEdgeValidation(): void {
+        const graphEdges: any = this.workflow.workflow.querySelectorAll(".edge") as NodeListOf<Element>;
+
+        this.removeClasses(graphEdges);
+
+        // iterate through all modal connections
+        this.workflow.model.connections.forEach((e: Edge) => {
+            // if the connection isn't valid (should be colored on graph)
+            if (!e.isValid) {
+
+                // iterate through edges on the svg
+                for (const ge of graphEdges) {
+                    const sourceNodeID      = ge.getAttribute("data-source-connection");
+                    const destinationNodeID = ge.getAttribute("data-destination-connection");
+
+                    // compare invalid edge source/destination with svg edge
+                    if (e.source.id === sourceNodeID && e.destination.id === destinationNodeID) {
+                        // if its a match, tag it with the appropriate class and break from the loop
+                        ge.classList.add(this.css.invalid);
+                        break;
+                    }
+                }
+            }
+        });
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/zoom/index.ts b/services/workbench2/src/lib/cwl-svg/plugins/zoom/index.ts
new file mode 100644 (file)
index 0000000..d227a5b
--- /dev/null
@@ -0,0 +1 @@
+export * from "./zoom";
\ No newline at end of file
diff --git a/services/workbench2/src/lib/cwl-svg/plugins/zoom/zoom.ts b/services/workbench2/src/lib/cwl-svg/plugins/zoom/zoom.ts
new file mode 100644 (file)
index 0000000..1fc7f02
--- /dev/null
@@ -0,0 +1,48 @@
+import {Workflow}   from "../..";
+import {PluginBase} from "../plugin-base";
+
+export class ZoomPlugin extends PluginBase {
+    private svg: SVGSVGElement;
+    private dispose: Function | undefined;
+
+    registerWorkflow(workflow: Workflow): void {
+        super.registerWorkflow(workflow);
+        this.svg = workflow.svgRoot;
+
+        this.dispose = this.attachWheelListener();
+    }
+
+    attachWheelListener(): () => void {
+        const handler = this.onMouseWheel.bind(this);
+        this.svg.addEventListener("mousewheel", handler, true);
+        return () => this.svg.removeEventListener("mousewheel", handler, true);
+    }
+
+    onMouseWheel(event: MouseWheelEvent) {
+
+        const scale       = this.workflow.scale;
+        const scaleUpdate = scale - event.deltaY / 500;
+
+        const zoominOut = scaleUpdate < scale;
+        const zoomingIn = scaleUpdate > scale;
+
+        if (zoomingIn && this.workflow.maxScale < scaleUpdate) {
+            return;
+        }
+
+        if (zoominOut && this.workflow.minScale > scaleUpdate) {
+            return;
+        }
+
+        this.workflow.scaleAtPoint(scaleUpdate, event.clientX, event.clientY);
+        event.stopPropagation();
+    }
+
+    destroy(): void {
+        if (typeof this.dispose === "function") {
+            this.dispose();
+        }
+
+        this.dispose = undefined;
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/utils/dom-events.ts b/services/workbench2/src/lib/cwl-svg/utils/dom-events.ts
new file mode 100644 (file)
index 0000000..d7ae43f
--- /dev/null
@@ -0,0 +1,301 @@
+export class DomEvents {
+
+    private handlers = new Map<{ removeEventListener: Function }, { [key: string]: Function[] }>();
+
+    constructor(private root: HTMLElement) {
+
+    }
+
+    public on(event: string, selector: string, handler: (event: UIEvent, target?: Element, root?: Element) => any, root?: Element): Function;
+    public on(event: string, handler: (event: UIEvent, target?: Element, root?: Element) => any, root?: Element): Function;
+    public on(...args: any[]) {
+
+        const event    = args.shift();
+        const selector = typeof args[0] === "string" ? args.shift() : undefined;
+        const handler  = typeof args[0] === "function" ? args.shift() : () => {
+        };
+        const root     = args.shift();
+
+        const eventHolder = root || this.root;
+
+        if (!this.handlers.has(eventHolder)) {
+            this.handlers.set(eventHolder, {});
+        }
+        if (!this.handlers.get(eventHolder)![event]) {
+            this.handlers.get(eventHolder)![event] = [];
+        }
+
+        const evListener = (ev: UIEvent) => {
+            let target: any;
+            if (selector) {
+                const selected = Array.from(this.root.querySelectorAll(selector));
+                target         = ev.target as HTMLElement;
+                while (target) {
+                    // eslint-disable-next-line
+                    if (selected.find(el => el === target)) {
+                        break;
+                    }
+                    target = target.parentNode;
+                }
+
+                if (!target) {
+                    return;
+                }
+            }
+
+            const handlerOutput = handler(ev, target || ev.target, this.root);
+            if (handlerOutput === false) {
+                return false;
+            }
+
+            return false;
+        };
+
+        eventHolder.addEventListener(event, evListener);
+
+        this.handlers.get(eventHolder)![event].push(evListener);
+
+        return function off() {
+            eventHolder.removeEventListener(event, evListener);
+        }
+    }
+
+    public keyup() {
+
+    }
+
+    public adaptedDrag(selector: string,
+                       move?: (dx: number, dy: number, event: UIEvent, target?: Element, root?: Element) => any,
+                       start?: (event: UIEvent, target?: Element, root?: Element) => any,
+                       end?: (event: UIEvent, target?: Element, root?: Element) => any) {
+
+        let dragging       = false;
+        let lastMove: MouseEvent | undefined;
+        let draggedEl: Element | undefined;
+        let moveEventCount = 0;
+        let mouseDownEv: MouseEvent;
+        let threshold      = 3;
+        let mouseOverListeners: EventListener[];
+
+        const onMouseDown = (ev: MouseEvent, el: Element) => {
+            dragging    = true;
+            lastMove    = ev;
+            draggedEl   = el;
+            mouseDownEv = ev;
+
+            ev.preventDefault();
+
+            mouseOverListeners = this.detachHandlers("mouseover");
+
+            document.addEventListener("mousemove", moveHandler);
+            document.addEventListener("mouseup", upHandler);
+
+            return false;
+        };
+
+        const off = this.on("mousedown", selector, onMouseDown);
+
+        const moveHandler = (ev: MouseEvent) => {
+            if (!dragging) {
+                return;
+            }
+
+            const dx = ev.screenX - lastMove!.screenX;
+            const dy = ev.screenY - lastMove!.screenY;
+            moveEventCount++;
+
+            if (moveEventCount === threshold && typeof start === "function") {
+                start(mouseDownEv, draggedEl, this.root);
+            }
+
+            if (moveEventCount >= threshold && typeof move === "function") {
+                move(dx, dy, ev, draggedEl, this.root);
+            }
+        };
+        const upHandler   = (ev: MouseEvent) => {
+            if (moveEventCount >= threshold) {
+                if (dragging) {
+                    if (typeof end === "function") {
+                        end(ev, draggedEl, this.root)
+                    }
+                }
+
+                const parentNode        = draggedEl!.parentNode;
+                const clickCancellation = (ev: MouseEvent) => {
+                    ev.stopPropagation();
+                    parentNode!.removeEventListener("click", clickCancellation, true);
+                };
+                parentNode!.addEventListener("click", clickCancellation, true);
+            }
+
+            dragging       = false;
+            draggedEl      = undefined;
+            lastMove       = undefined;
+            moveEventCount = 0;
+            document.removeEventListener("mouseup", upHandler);
+            document.removeEventListener("mousemove", moveHandler);
+
+            for (let i in mouseOverListeners) {
+                this.root.addEventListener("mouseover", mouseOverListeners[i]);
+                this.handlers.get(this.root)!["mouseover"] = [];
+                this.handlers.get(this.root)!["mouseover"].push(mouseOverListeners[i]);
+            }
+        };
+
+        return off;
+    }
+
+
+    public drag(selector: string,
+                move?: (dx: number, dy: number, event: UIEvent, target?: Element, root?: Element) => any,
+                start?: (event: UIEvent, target?: Element, root?: Element) => any,
+                end?: (event: UIEvent, target?: Element, root?: Element) => any) {
+
+        let dragging       = false;
+        let lastMove: MouseEvent | undefined;
+        let draggedEl: Element | undefined;
+        let moveEventCount = 0;
+        let mouseDownEv: MouseEvent;
+        let threshold      = 3;
+        let mouseOverListeners: EventListener[];
+
+        const onMouseDown = (ev: MouseEvent, el: Element, root: Element) => {
+            dragging    = true;
+            lastMove    = ev;
+            draggedEl   = el;
+            mouseDownEv = ev;
+
+            ev.preventDefault();
+
+            mouseOverListeners = this.detachHandlers("mouseover");
+
+            document.addEventListener("mousemove", moveHandler);
+            document.addEventListener("mouseup", upHandler);
+
+            return false;
+        };
+
+        const off = this.on("mousedown", selector, onMouseDown);
+
+        const moveHandler = (ev: MouseEvent) => {
+            if (!dragging) {
+                return;
+            }
+
+            const dx = ev.screenX - lastMove!.screenX;
+            const dy = ev.screenY - lastMove!.screenY;
+            moveEventCount++;
+
+            if (moveEventCount === threshold && typeof start === "function") {
+                start(mouseDownEv, draggedEl, this.root);
+            }
+
+            if (moveEventCount >= threshold && typeof move === "function") {
+                move(dx, dy, ev, draggedEl, this.root);
+            }
+        };
+
+        const upHandler = (ev: MouseEvent) => {
+
+            if (moveEventCount >= threshold) {
+                if (dragging) {
+                    if (typeof end === "function") {
+                        end(ev, draggedEl, this.root)
+                    }
+                }
+
+                // When releasing the mouse button, if it happens over the same element that we initially had
+                // the mouseDown event, it will trigger a click event. We want to stop that, so we intercept
+                // it by capturing click top-down and stopping its propagation.
+                // However, if the mouseUp didn't happen above the starting element, it wouldn't trigger a click,
+                // but it would intercept the next (unrelated) click event unless we prevent interception in the
+                // first place by checking if we released above the starting element.
+                if (draggedEl!.contains(ev.target as Node)) {
+                    const parentNode = draggedEl!.parentNode;
+
+                    const clickCancellation = (ev: MouseEvent) => {
+                        ev.stopPropagation();
+                        parentNode!.removeEventListener("click", clickCancellation, true);
+                    };
+                    parentNode!.addEventListener("click", clickCancellation, true);
+                }
+
+            }
+
+            dragging       = false;
+            draggedEl      = undefined;
+            lastMove       = undefined;
+            moveEventCount = 0;
+            document.removeEventListener("mouseup", upHandler);
+            document.removeEventListener("mousemove", moveHandler);
+
+
+            for (let i in mouseOverListeners) {
+                this.root.addEventListener("mouseover", mouseOverListeners[i]);
+                this.handlers.get(this.root)!["mouseover"] = [];
+                this.handlers.get(this.root)!["mouseover"].push(mouseOverListeners[i]);
+            }
+        };
+
+        return off;
+    }
+
+    public hover(element: HTMLElement,
+                 hover: (event: UIEvent, target?: HTMLElement, root?: HTMLElement) => any = () => {},
+                 enter: (event: UIEvent, target?: HTMLElement, root?: HTMLElement) => any = () => {},
+                 leave: (event: UIEvent, target?: HTMLElement, root?: HTMLElement) => any = () => {}) {
+
+        let hovering = false;
+
+        element.addEventListener("mouseenter", (ev: MouseEvent) => {
+            hovering = true;
+            enter(ev, element, this.root);
+
+        });
+
+        element.addEventListener("mouseleave", (ev) => {
+            hovering = false;
+            leave(ev, element, this.root);
+        });
+
+        element.addEventListener("mousemove", (ev) => {
+            if (!hovering) {
+                return;
+            }
+            hover(ev, element, this.root);
+        });
+    }
+
+    public detachHandlers(evName: string, root?: HTMLElement): EventListener[] {
+        root                                = root || this.root;
+        let eventListeners: EventListener[] = [];
+        this.handlers.forEach((handlers: { [event: string]: EventListener[] }, listenerRoot: Element) => {
+            if (listenerRoot.id !== root!.id || listenerRoot !== root) {
+                return;
+            }
+            for (let eventName in handlers) {
+                if (eventName !== evName) {
+                    continue;
+                }
+                handlers[eventName].forEach((handler) => {
+                    eventListeners.push(handler);
+                    listenerRoot.removeEventListener(eventName, handler);
+                });
+            }
+        });
+
+        delete this.handlers.get(this.root)![evName];
+
+        return eventListeners;
+    }
+
+    public detachAll() {
+        this.handlers.forEach((handlers: { [event: string]: EventListener[] }, listenerRoot: Element) => {
+            for (let eventName in handlers) {
+                handlers[eventName].forEach(handler => listenerRoot.removeEventListener(eventName, handler));
+            }
+        });
+
+        this.handlers.clear();
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/utils/dynamic-stylesheet.ts b/services/workbench2/src/lib/cwl-svg/utils/dynamic-stylesheet.ts
new file mode 100644 (file)
index 0000000..0ccf98d
--- /dev/null
@@ -0,0 +1,35 @@
+import {Workflow} from "..";
+
+export class DynamicStylesheet {
+    private styleElement: HTMLStyleElement;
+    private scopedSelector: string;
+    private innerStyle = "";
+
+    constructor(workflow: Workflow) {
+
+        this.styleElement      = document.createElement("style");
+        this.styleElement.type = "text/css";
+
+        this.scopedSelector = `svg.${workflow.svgID}`;
+
+        document.getElementsByTagName("head")[0].appendChild(this.styleElement);
+    }
+
+    remove() {
+        this.styleElement.remove();
+    }
+
+    set(style: string) {
+        this.innerStyle = style;
+
+        this.styleElement.innerHTML = `
+            ${this.scopedSelector} {
+                ${this.innerStyle}
+            }
+        `
+    }
+
+
+
+
+}
diff --git a/services/workbench2/src/lib/cwl-svg/utils/event-hub.ts b/services/workbench2/src/lib/cwl-svg/utils/event-hub.ts
new file mode 100644 (file)
index 0000000..2c26c4f
--- /dev/null
@@ -0,0 +1,39 @@
+export class EventHub {
+    public readonly handlers: { [event: string]: Function[] };
+
+    constructor(validEventList: string[]) {
+        this.handlers = validEventList.reduce((acc, ev) => Object.assign(acc, {[ev]: []}), {});
+    }
+
+    on(event: string, handler: Function) {
+        this.guard(event, "subscribe to");
+        this.handlers[event].push(handler);
+
+        return () => this.off(event, handler);
+    }
+
+    off(event: string, handler: Function) {
+        this.guard(event, "unsubscribe from");
+        return this.handlers[event].splice(this.handlers[event].findIndex(h => handler === h), 1);
+    }
+
+    emit(event: string, ...data: any[]) {
+        this.guard(event, "emit");
+        for (let i = 0; i < this.handlers[event].length; i++) {
+            this.handlers[event][i](...data);
+        }
+    }
+
+    empty() {
+        for (let event in this.handlers) {
+            this.handlers[event] = [];
+        }
+    }
+
+    private guard(event: string, verb: string) {
+        if (!this.handlers[event]) {
+            console.warn(`Trying to ${verb} a non-supported event “${event}”. 
+            Supported events are: ${Object.keys(this.handlers).join(", ")}”`);
+        }
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/utils/geometry.ts b/services/workbench2/src/lib/cwl-svg/utils/geometry.ts
new file mode 100644 (file)
index 0000000..348a7e2
--- /dev/null
@@ -0,0 +1,32 @@
+export class Geometry {
+
+    static distance(x1: number, y1: number, x2: number, y2: number) {
+        return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
+    }
+
+    static getTransformToElement(from: SVGElement, to: SVGElement) {
+        const getPosition = (node: SVGGElement, addE = 0, addF = 0): SVGMatrix => {
+
+            if (!node.ownerSVGElement) {
+                // node is the root svg element
+                const matrix = (node as SVGSVGElement).createSVGMatrix();
+                matrix.e = addE;
+                matrix.f = addF;
+                return matrix;
+            } else {
+                // node still has parent elements
+                const {e, f} = node.transform.baseVal.getItem(0).matrix;
+                return getPosition(node.parentNode as SVGGElement, e + addE, f + addF);
+            }
+        };
+
+        const toPosition = getPosition(to as SVGAElement);
+        const fromPosition = getPosition(from as SVGAElement);
+
+        const result = from.ownerSVGElement!.createSVGMatrix();
+        result.e = toPosition.e - fromPosition.e;
+        result.f = toPosition.f - fromPosition.f;
+
+        return result.inverse();
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/utils/html-utils.ts b/services/workbench2/src/lib/cwl-svg/utils/html-utils.ts
new file mode 100644 (file)
index 0000000..b7bad2b
--- /dev/null
@@ -0,0 +1,15 @@
+export class HtmlUtils {
+
+    private static entityMap = {
+        "&": "&amp;",
+        "<": "&lt;",
+        ">": "&gt;",
+        "\"\"": "&quot;",
+        "'": "&#39;",
+        "/": "&#x2F;"
+    };
+
+    public static escapeHTML(source: string): string {
+        return String(source).replace(/[&<>"'/]/g, s => HtmlUtils.entityMap[s]);
+    }
+}
diff --git a/services/workbench2/src/lib/cwl-svg/utils/perf.ts b/services/workbench2/src/lib/cwl-svg/utils/perf.ts
new file mode 100644 (file)
index 0000000..eba3599
--- /dev/null
@@ -0,0 +1,27 @@
+export class Perf {
+
+    static DEFAULT_THROTTLE = 1;
+
+    public static throttle(fn: Function, threshold = Perf.DEFAULT_THROTTLE, context?: any): Function {
+        let last: any, deferTimer: any;
+
+        return function () {
+            // @ts-ignore
+            const scope = context || this;
+
+            let now  = +new Date,
+                args = arguments;
+            if (last && now < last + threshold) {
+                clearTimeout(deferTimer);
+                deferTimer = setTimeout(function () {
+                    last = now;
+                    fn.apply(scope, args);
+                }, threshold);
+            } else {
+                last = now;
+                fn.apply(scope, args);
+            }
+        };
+    }
+
+}
diff --git a/services/workbench2/src/lib/cwl-svg/utils/svg-dumper.ts b/services/workbench2/src/lib/cwl-svg/utils/svg-dumper.ts
new file mode 100644 (file)
index 0000000..22893e8
--- /dev/null
@@ -0,0 +1,95 @@
+export class SvgDumper {
+
+    private containerElements = ["svg", "g"];
+    private embeddableStyles  = {
+        "rect": ["fill", "stroke", "stroke-width"],
+        "path": ["fill", "stroke", "stroke-width"],
+        "circle": ["fill", "stroke", "stroke-width"],
+        "line": ["stroke", "stroke-width"],
+        "text": ["fill", "font-size", "text-anchor", "font-family"],
+        "polygon": ["stroke", "fill"]
+    };
+
+    constructor(private svg: SVGSVGElement) {
+        this.svg = svg
+    }
+
+    dump({padding} = {padding: 50}): string {
+        this.adaptViewbox(this.svg, padding);
+        const clone = this.svg.cloneNode(true) as SVGSVGElement;
+
+        const portLabels: any = clone.querySelectorAll(".port .label");
+
+
+        for (const label of portLabels) {
+            label.parentNode.removeChild(label);
+        }
+
+        this.treeShakeStyles(clone, this.svg);
+
+        // Remove panning handle so we don't have to align it
+        const panHandle = clone.querySelector(".pan-handle");
+        if (panHandle) {
+            clone.removeChild(panHandle);
+        }
+
+        return new XMLSerializer().serializeToString(clone);
+
+    }
+
+    private adaptViewbox(svg: SVGSVGElement, padding = 50) {
+        const workflow = svg.querySelector(".workflow");
+        const rect     = workflow!.getBoundingClientRect();
+
+        const origin = this.getPointOnSVG(rect.left, rect.top);
+
+        const viewBox  = this.svg.viewBox.baseVal;
+        viewBox.x      = origin.x - padding / 2;
+        viewBox.y      = origin.y - padding / 2;
+        viewBox.height = rect.height + padding;
+        viewBox.width  = rect.width + padding;
+
+    }
+
+    private getPointOnSVG(x: number, y: number): SVGPoint {
+        const svgCTM = this.svg.getScreenCTM();
+        const point  = this.svg.createSVGPoint();
+        point.x      = x;
+        point.y      = y;
+
+        return point.matrixTransform(svgCTM!.inverse());
+
+    }
+
+    private treeShakeStyles(clone: SVGElement, original: SVGElement) {
+
+        const children             = clone.childNodes;
+        const originalChildrenData = original.childNodes as NodeListOf<SVGElement>;
+
+
+        for (let childIndex = 0; childIndex < children.length; childIndex++) {
+
+            const child   = children[childIndex] as SVGElement;
+            const tagName = child.tagName;
+
+            if (this.containerElements.indexOf(tagName) !== -1) {
+                this.treeShakeStyles(child, originalChildrenData[childIndex]);
+            } else if (tagName in this.embeddableStyles) {
+
+                const styleDefinition = window.getComputedStyle(originalChildrenData[childIndex]);
+
+                let styleString = "";
+                for (let st = 0; st < this.embeddableStyles[tagName].length; st++) {
+                    styleString +=
+                        this.embeddableStyles[tagName][st]
+                        + ":"
+                        + styleDefinition.getPropertyValue(this.embeddableStyles[tagName][st])
+                        + "; ";
+                }
+
+                child.setAttribute("style", styleString);
+            }
+        }
+    }
+}
+
diff --git a/services/workbench2/src/lib/cwl-svg/utils/svg-utils.ts b/services/workbench2/src/lib/cwl-svg/utils/svg-utils.ts
new file mode 100644 (file)
index 0000000..bb5aaea
--- /dev/null
@@ -0,0 +1,11 @@
+export class SVGUtils {
+    static matrixToTransformAttr(matrix: SVGMatrix): string {
+        const {a, b, c, d, e, f} = matrix;
+        return `matrix(${a}, ${b}, ${c}, ${d}, ${e}, ${f})`;
+    }
+
+    static createMatrix(): SVGMatrix {
+        return document.createElementNS("http://www.w3.org/2000/svg", "svg").createSVGMatrix();
+
+    }
+}
\ No newline at end of file
diff --git a/services/workbench2/src/lib/resource-properties.test.ts b/services/workbench2/src/lib/resource-properties.test.ts
new file mode 100644 (file)
index 0000000..c70b231
--- /dev/null
@@ -0,0 +1,59 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as _ from "./resource-properties";
+import { omit } from "lodash";
+
+describe("Resource properties lib", () => {
+
+    let properties: any;
+
+    beforeEach(() => {
+        properties = {
+            animal: 'dog',
+            color: ['brown', 'black'],
+            name: ['Toby']
+        }
+    })
+
+    it("should convert a single string value into a list when adding values", () => {
+        expect(
+            _.addProperty(properties, 'animal', 'cat')
+        ).toEqual({
+            ...properties, animal: ['dog', 'cat']
+        });
+    });
+
+    it("should convert a 2 value list into a string when removing values", () => {
+        expect(
+            _.deleteProperty(properties, 'color', 'brown')
+        ).toEqual({
+            ...properties, color: 'black'
+        });
+    });
+
+    it("shouldn't add duplicated key:value items", () => {
+        expect(
+            _.addProperty(properties, 'animal', 'dog')
+        ).toEqual(properties);
+    });
+
+    it("should remove the key when deleting from a one value list", () => {
+        expect(
+            _.deleteProperty(properties, 'name', 'Toby')
+        ).toEqual(omit(properties, 'name'));
+    });
+
+    it("should return the same when deleting non-existant value", () => {
+        expect(
+            _.deleteProperty(properties, 'animal', 'dolphin')
+        ).toEqual(properties);
+    });
+
+    it("should return the same when deleting non-existant key", () => {
+        expect(
+            _.deleteProperty(properties, 'doesntexist', 'something')
+        ).toEqual(properties);
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/lib/resource-properties.ts b/services/workbench2/src/lib/resource-properties.ts
new file mode 100644 (file)
index 0000000..02f13b6
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const deleteProperty = (properties: any, key: string, value: string) => {
+    if (Array.isArray(properties[key])) {
+        properties[key] = properties[key].filter((v: string) => v !== value);
+        if (properties[key].length === 1) {
+            properties[key] = properties[key][0];
+        } else if (properties[key].length === 0) {
+            delete properties[key];
+        }
+    } else if (properties[key] === value) {
+        delete properties[key];
+    }
+    return properties;
+}
+
+export const addProperty = (properties: any, key: string, value: string) => {
+    if (properties[key]) {
+        if (Array.isArray(properties[key])) {
+            properties[key] = [...properties[key], value];
+        } else {
+            properties[key] = [properties[key], value];
+        }
+        // Remove potential duplicate and save as single value if needed
+        properties[key] = Array.from(new Set(properties[key]));
+        if (properties[key].length === 1) {
+            properties[key] = properties[key][0];
+        }
+    } else {
+        properties[key] = value;
+    }
+    return properties;
+}
\ No newline at end of file
diff --git a/services/workbench2/src/models/api-client-authorization.ts b/services/workbench2/src/models/api-client-authorization.ts
new file mode 100644 (file)
index 0000000..c1f948a
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from 'models/resource';
+
+export interface ApiClientAuthorization extends Resource {
+    uuid: string;
+    apiToken: string;
+    apiClientId: number;
+    userId: number;
+    createdByIpAddress: string;
+    lastUsedByIpAddress: string;
+    lastUsedAt: string;
+    expiresAt: string;
+    createdAt: string;
+    updatedAt: string;
+    ownerUuid: string;
+    defaultOwnerUuid: string;
+    scopes: string[];
+}
+
+export const getTokenV2 = (aca: ApiClientAuthorization): string =>
+    `v2/${aca.uuid}/${aca.apiToken}`;
\ No newline at end of file
diff --git a/services/workbench2/src/models/client-authorization.ts b/services/workbench2/src/models/client-authorization.ts
new file mode 100644 (file)
index 0000000..767916e
--- /dev/null
@@ -0,0 +1,12 @@
+export interface ClientAuthorizationResource {
+    uuid: string;
+    apiToken: string;
+    apiClientId: number;
+    userId: number;
+    createdByIpAddress: string;
+    lastUsedByIpAddress: string;
+    lastUsedAt: string;
+    expiresAt: string;
+    ownerUuid: string;
+    scopes: string[];
+}
diff --git a/services/workbench2/src/models/collection-file.ts b/services/workbench2/src/models/collection-file.ts
new file mode 100644 (file)
index 0000000..3688557
--- /dev/null
@@ -0,0 +1,80 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Tree, createTree, setNode, TreeNodeStatus } from './tree';
+import { head, split, pipe, join } from 'lodash/fp';
+
+export type CollectionFilesTree = Tree<CollectionDirectory | CollectionFile>;
+
+export enum CollectionFileType {
+    DIRECTORY = 'directory',
+    FILE = 'file'
+}
+
+export interface CollectionDirectory {
+    path: string;
+    url: string;
+    id: string;
+    name: string;
+    type: CollectionFileType.DIRECTORY;
+}
+
+export interface CollectionFile {
+    path: string;
+    url: string;
+    id: string;
+    name: string;
+    size: number;
+    type: CollectionFileType.FILE;
+}
+
+export interface CollectionUploadFile {
+    name: string;
+}
+
+export const createCollectionDirectory = (data: Partial<CollectionDirectory>): CollectionDirectory => ({
+    id: '',
+    name: '',
+    path: '',
+    url: '',
+    type: CollectionFileType.DIRECTORY,
+    ...data
+});
+
+export const createCollectionFile = (data: Partial<CollectionFile>): CollectionFile => ({
+    id: '',
+    name: '',
+    path: '',
+    url: '',
+    size: 0,
+    type: CollectionFileType.FILE,
+    ...data
+});
+
+export const createCollectionFilesTree = (data: Array<CollectionDirectory | CollectionFile>, joinParents: Boolean = true) => {
+    const directories = data.filter(item => item.type === CollectionFileType.DIRECTORY);
+    directories.sort((a, b) => a.path.localeCompare(b.path));
+    const files = data.filter(item => item.type === CollectionFileType.FILE);
+    return [...directories, ...files]
+        .reduce((tree, item) => setNode({
+            children: [],
+            id: item.id,
+            parent: joinParents ? getParentId(item) : '',
+            value: item,
+            active: false,
+            selected: false,
+            expanded: false,
+            status: TreeNodeStatus.INITIAL
+        })(tree), createTree<CollectionDirectory | CollectionFile>());
+};
+
+const getParentId = (item: CollectionDirectory | CollectionFile) =>
+    item.path
+        ? join('', [getCollectionResourceCollectionUuid(item.id), item.path])
+        : item.path;
+
+export const getCollectionResourceCollectionUuid = pipe(
+    split('/'),
+    head,
+);
diff --git a/services/workbench2/src/models/collection.ts b/services/workbench2/src/models/collection.ts
new file mode 100644 (file)
index 0000000..defaca7
--- /dev/null
@@ -0,0 +1,74 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import {
+    ResourceKind,
+    TrashableResource,
+    ResourceWithProperties
+} from "./resource";
+
+export interface CollectionResource extends TrashableResource, ResourceWithProperties {
+    kind: ResourceKind.COLLECTION;
+    name: string;
+    description: string;
+    portableDataHash: string;
+    manifestText: string;
+    replicationDesired: number;
+    replicationConfirmed: number;
+    replicationConfirmedAt: string;
+    storageClassesDesired: string[];
+    storageClassesConfirmed: string[];
+    storageClassesConfirmedAt: string;
+    currentVersionUuid: string;
+    version: number;
+    preserveVersion: boolean;
+    unsignedManifestText?: string;
+    fileCount: number;
+    fileSizeTotal: number;
+}
+
+// We exclude 'manifestText' and 'unsignedManifestText' from the default
+export const defaultCollectionSelectedFields = [
+    'name',
+    'description',
+    'portableDataHash',
+    'replicationDesired',
+    'replicationConfirmed',
+    'replicationConfirmedAt',
+    'storageClassesDesired',
+    'storageClassesConfirmed',
+    'storageClassesConfirmedAt',
+    'currentVersionUuid',
+    'version',
+    'preserveVersion',
+    'fileCount',
+    'fileSizeTotal',
+    // ResourceWithProperties field
+    'properties',
+    // TrashableResource fields
+    'trashAt',
+    'deleteAt',
+    'isTrashed',
+    // Resource fields
+    'uuid',
+    'ownerUuid',
+    'createdAt',
+    'modifiedByClientUuid',
+    'modifiedByUserUuid',
+    'modifiedAt',
+    'href',
+    'kind',
+    'etag',
+];
+
+export const getCollectionUrl = (uuid: string) => {
+    return `/collections/${uuid}`;
+};
+
+export enum CollectionType {
+    GENERAL = 'nil',
+    OUTPUT = 'output',
+    LOG = 'log',
+    INTERMEDIATE = 'intermediate',
+}
diff --git a/services/workbench2/src/models/container-request.ts b/services/workbench2/src/models/container-request.ts
new file mode 100644 (file)
index 0000000..d3adb03
--- /dev/null
@@ -0,0 +1,85 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource, ResourceKind, ResourceWithProperties } from './resource';
+import { MountType } from 'models/mount-types';
+import { RuntimeConstraints } from './runtime-constraints';
+import { SchedulingParameters } from './scheduling-parameters';
+
+export enum ContainerRequestState {
+    UNCOMMITTED = 'Uncommitted',
+    COMMITTED = 'Committed',
+    FINAL = 'Final',
+}
+
+export interface ContainerRequestResource
+    extends Resource,
+    ResourceWithProperties {
+    command: string[];
+    containerCountMax: number;
+    containerCount: number;
+    containerImage: string;
+    containerUuid: string | null;
+    cumulativeCost: number;
+    cwd: string;
+    description: string;
+    environment: any;
+    expiresAt: string;
+    filters: string;
+    kind: ResourceKind.CONTAINER_REQUEST;
+    logUuid: string | null;
+    mounts: { [path: string]: MountType };
+    name: string;
+    outputName: string;
+    outputPath: string;
+    outputProperties: any;
+    outputStorageClasses: string[];
+    outputTtl: number;
+    outputUuid: string | null;
+    priority: number | null;
+    requestingContainerUuid: string | null;
+    runtimeConstraints: RuntimeConstraints;
+    schedulingParameters: SchedulingParameters;
+    state: ContainerRequestState;
+    useExisting: boolean;
+}
+
+// Until the api supports unselecting fields, we need a list of all other fields to omit mounts
+export const containerRequestFieldsNoMounts = [
+    "command",
+    "container_count_max",
+    "container_count",
+    "container_image",
+    "container_uuid",
+    "created_at",
+    "cumulative_cost",
+    "cwd",
+    "description",
+    "environment",
+    "etag",
+    "expires_at",
+    "filters",
+    "href",
+    "kind",
+    "log_uuid",
+    "modified_at",
+    "modified_by_client_uuid",
+    "modified_by_user_uuid",
+    "name",
+    "output_name",
+    "output_path",
+    "output_properties",
+    "output_storage_classes",
+    "output_ttl",
+    "output_uuid",
+    "owner_uuid",
+    "priority",
+    "properties",
+    "requesting_container_uuid",
+    "runtime_constraints",
+    "scheduling_parameters",
+    "state",
+    "use_existing",
+    "uuid",
+];
diff --git a/services/workbench2/src/models/container.ts b/services/workbench2/src/models/container.ts
new file mode 100644 (file)
index 0000000..c86f11c
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource, ResourceKind } from "./resource";
+import { MountType } from 'models/mount-types';
+import { RuntimeConstraints } from "models/runtime-constraints";
+import { SchedulingParameters } from './scheduling-parameters';
+import { RuntimeStatus } from "./runtime-status";
+
+export enum ContainerState {
+    QUEUED = 'Queued',
+    LOCKED = 'Locked',
+    RUNNING = 'Running',
+    COMPLETE = 'Complete',
+    CANCELLED = 'Cancelled',
+}
+
+export interface ContainerResource extends Resource {
+    kind: ResourceKind.CONTAINER;
+    state: string;
+    startedAt: string | null;
+    finishedAt: string | null;
+    log: string | null;
+    environment: {};
+    cwd: string;
+    command: string[];
+    cost: number;
+    outputPath: string;
+    mounts: MountType[];
+    runtimeConstraints: RuntimeConstraints;
+    runtimeStatus: RuntimeStatus;
+    runtimeUserUuid: string;
+    schedulingParameters: SchedulingParameters;
+    output: string | null;
+    containerImage: string;
+    progress: number;
+    priority: number;
+    exitCode: number | null;
+    authUuid: string | null;
+    lockedByUuid: string | null;
+}
diff --git a/services/workbench2/src/models/details.ts b/services/workbench2/src/models/details.ts
new file mode 100644 (file)
index 0000000..b6eabd7
--- /dev/null
@@ -0,0 +1,12 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ProjectResource } from "./project";
+import { CollectionResource } from "./collection";
+import { ProcessResource } from "./process";
+import { EmptyResource } from "./empty";
+import { CollectionFile, CollectionDirectory } from 'models/collection-file';
+import { WorkflowResource } from 'models/workflow';
+
+export type DetailsResource = ProjectResource | CollectionResource | ProcessResource | EmptyResource | CollectionFile | CollectionDirectory | WorkflowResource;
diff --git a/services/workbench2/src/models/empty.ts b/services/workbench2/src/models/empty.ts
new file mode 100644 (file)
index 0000000..539f9f5
--- /dev/null
@@ -0,0 +1,8 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface EmptyResource {
+    name: string;
+    kind: undefined;
+}
diff --git a/services/workbench2/src/models/file-viewers-config.ts b/services/workbench2/src/models/file-viewers-config.ts
new file mode 100644 (file)
index 0000000..e95116b
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type FileViewerList = FileViewer[];
+
+export interface FileViewer {
+    /**
+     * Name is used as a label in file's context menu
+     */
+    name: string;
+
+    /**
+     * Limits files for which viewer is enabled
+     * If not given, viewer will be enabled for all files
+     * Viewer is enabled if file name ends with an extension.
+     * 
+     * Example: `['.zip', '.tar.gz', 'bam']`
+     */
+    extensions?: string[];
+
+    /**
+     * Determines whether a viewer is enabled for collections.
+     */
+    collections?: boolean;
+
+    /**
+     * URL that redirects to a viewer 
+     * Example: `https://bam-viewer.com`
+     */
+    url: string;
+
+    /**
+     * Name of a search param that will be used to send file's path to a viewer
+     * Example: 
+     * 
+     * `{ filePathParam: 'filePath' }`
+     * 
+     * `https://bam-viewer.com?filePath=/path/to/file`
+     */
+    filePathParam: string;
+
+    /**
+     * Icon that will display next to a label
+     */
+    iconUrl?: string;
+}
diff --git a/services/workbench2/src/models/group.ts b/services/workbench2/src/models/group.ts
new file mode 100644 (file)
index 0000000..078e2a2
--- /dev/null
@@ -0,0 +1,59 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import {
+    ResourceKind,
+    ResourceWithProperties,
+    RESOURCE_UUID_REGEX,
+    ResourceObjectType,
+    TrashableResource
+} from "./resource";
+
+export interface GroupResource extends TrashableResource, ResourceWithProperties {
+    kind: ResourceKind.GROUP;
+    name: string;
+    groupClass: GroupClass | null;
+    description: string;
+    ensure_unique_name: boolean;
+    canWrite: boolean;
+    canManage: boolean;
+    // Optional local-only field, undefined for not loaded, null for failed to load
+    memberCount?: number | null;
+}
+
+export enum GroupClass {
+    PROJECT = 'project',
+    FILTER = 'filter',
+    ROLE = 'role',
+}
+
+export enum BuiltinGroups {
+    ALL = 'fffffffffffffff',
+    ANON = 'anonymouspublic',
+    SYSTEM = '000000000000000',
+}
+
+export const getBuiltinGroupUuid = (cluster: string, groupName: BuiltinGroups): string => {
+    return cluster ? `${cluster}-${ResourceObjectType.GROUP}-${groupName}` : "";
+};
+
+export const isBuiltinGroup = (uuid: string) => {
+    const match = RESOURCE_UUID_REGEX.exec(uuid);
+    const parts = match ? match[0].split('-') : [];
+    return parts.length === 3 && parts[1] === ResourceObjectType.GROUP && Object.values<string>(BuiltinGroups).includes(parts[2]);
+};
+
+export const selectedFieldsOfGroup = [
+    "uuid",
+    "name",
+    "group_class",
+    "description",
+    "properties",
+    "can_write",
+    "can_manage",
+    "trash_at",
+    "delete_at",
+    "is_trashed",
+    "frozen_by_uuid"
+];
diff --git a/services/workbench2/src/models/keep-manifest.ts b/services/workbench2/src/models/keep-manifest.ts
new file mode 100644 (file)
index 0000000..6dc6445
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type KeepManifest = KeepManifestStream[];
+
+export interface KeepManifestStream {
+    name: string;
+    locators: string[];
+    files: Array<KeepManifestStreamFile>;
+}
+
+export interface KeepManifestStreamFile {
+    name: string;
+    position: string;
+    size: number;
+}
diff --git a/services/workbench2/src/models/keep-services.ts b/services/workbench2/src/models/keep-services.ts
new file mode 100644 (file)
index 0000000..2c5fa4f
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from 'models/resource';
+
+export interface KeepServiceResource extends Resource {
+    serviceHost: string;
+    servicePort: number;
+    serviceSslFlag: boolean;
+    serviceType: string;
+    readOnly: boolean;
+}
\ No newline at end of file
diff --git a/services/workbench2/src/models/link-account.ts b/services/workbench2/src/models/link-account.ts
new file mode 100644 (file)
index 0000000..f5b6040
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export enum LinkAccountStatus {
+    SUCCESS,
+    CANCELLED,
+    FAILED
+}
+
+export enum LinkAccountType {
+    ADD_OTHER_LOGIN,
+    ADD_LOCAL_TO_REMOTE,
+    ACCESS_OTHER_ACCOUNT,
+    ACCESS_OTHER_REMOTE_ACCOUNT
+}
+
+export interface AccountToLink {
+    type: LinkAccountType;
+    userUuid: string;
+    token: string;
+}
diff --git a/services/workbench2/src/models/link.ts b/services/workbench2/src/models/link.ts
new file mode 100644 (file)
index 0000000..f55c5cc
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource, ResourceKind, ResourceWithProperties } from 'models/resource';
+
+export interface LinkResource extends Resource, ResourceWithProperties {
+    headUuid: string;
+    headKind: ResourceKind;
+    tailUuid: string;
+    tailKind: string;
+    linkClass: string;
+    name: string;
+    kind: ResourceKind.LINK;
+}
+
+export enum LinkClass {
+    STAR = 'star',
+    TAG = 'tag',
+    PERMISSION = 'permission',
+    PRESET = 'preset',
+}
diff --git a/services/workbench2/src/models/log.ts b/services/workbench2/src/models/log.ts
new file mode 100644 (file)
index 0000000..f5d351a
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource, ResourceWithProperties } from "./resource";
+import { ResourceKind } from 'models/resource';
+
+export enum LogEventType {
+    CREATE = 'create',
+    UPDATE = 'update',
+    DISPATCH = 'dispatch',
+    CRUNCH_RUN = 'crunch-run',
+    CRUNCHSTAT = 'crunchstat',
+    HOSTSTAT = 'hoststat',
+    NODE_INFO = 'node-info',
+    ARV_MOUNT = 'arv-mount',
+    STDOUT = 'stdout',
+    STDERR = 'stderr',
+    CONTAINER = 'container',
+    KEEPSTORE = 'keepstore',
+}
+
+export interface LogResource extends Resource, ResourceWithProperties {
+    kind: ResourceKind.LOG;
+    objectUuid: string;
+    eventAt: string;
+    eventType: LogEventType;
+    summary: string;
+}
diff --git a/services/workbench2/src/models/mount-types.ts b/services/workbench2/src/models/mount-types.ts
new file mode 100644 (file)
index 0000000..db87db1
--- /dev/null
@@ -0,0 +1,62 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export enum MountKind {
+    COLLECTION = 'collection',
+    GIT_TREE = 'git_tree',
+    TEMPORARY_DIRECTORY = 'tmp',
+    KEEP = 'keep',
+    MOUNTED_FILE = 'file',
+    JSON = 'json'
+}
+
+export type MountType =
+    CollectionMount |
+    GitTreeMount |
+    TemporaryDirectoryMount |
+    KeepMount |
+    JSONMount |
+    FileMount;
+
+export interface CollectionMount {
+    kind: MountKind.COLLECTION;
+    uuid?: string;
+    portable_data_hash?: string;
+    path?: string;
+    writable?: boolean;
+}
+
+export interface GitTreeMount {
+    kind: MountKind.GIT_TREE;
+    uuid: string;
+    commit: string;
+    path?: string;
+}
+
+export enum TemporaryDirectoryDeviceType {
+    RAM = 'ram',
+    SSD = 'ssd',
+    DISK = 'disk',
+    NETWORK = 'network',
+}
+
+export interface TemporaryDirectoryMount {
+    kind: MountKind.TEMPORARY_DIRECTORY;
+    capacity: number;
+    deviceType: TemporaryDirectoryDeviceType;
+}
+
+export interface KeepMount {
+    kind: MountKind.KEEP;
+}
+
+export interface JSONMount {
+    kind: MountKind.JSON;
+    content: any;
+}
+
+export interface FileMount {
+    kind: MountKind.MOUNTED_FILE;
+    path: string;
+}
diff --git a/services/workbench2/src/models/node.ts b/services/workbench2/src/models/node.ts
new file mode 100644 (file)
index 0000000..c4200a6
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from 'models/resource';
+
+export interface NodeResource extends Resource {
+    slotNumber: number;
+    hostname: string;
+    domain: string;
+    ipAddress: string;
+    jobUuid: string;
+    firstPingAt: string;
+    lastPingAt: string;
+    status: string;
+    info: NodeInfo;
+    properties: NodeProperties;
+}
+
+export interface NodeInfo {
+    last_action: string;
+    ping_secret: string;
+    ec2_instance_id: string;
+    slurm_state?: string;
+}
+
+export interface NodeProperties {
+    cloud_node: CloudNode;
+    total_ram_mb: number;
+    total_cpu_cores: number;
+    total_scratch_mb: number;
+}
+
+interface CloudNode {
+    size: string;
+    price: number;
+}
\ No newline at end of file
diff --git a/services/workbench2/src/models/object-types.ts b/services/workbench2/src/models/object-types.ts
new file mode 100644 (file)
index 0000000..f0f17e0
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+const USER_UUID_REGEX = /.*tpzed.*/;
+const GROUP_UUID_REGEX = /.*-j7d0g-.*/;
+
+export enum ObjectTypes {
+    USER = "User",
+    GROUP = "Group",
+    UNKNOWN = "Unknown"
+}
+
+export const getUuidObjectType = (uuid: string) => {
+    switch (true) {
+        case USER_UUID_REGEX.test(uuid):
+            return ObjectTypes.USER;
+        case GROUP_UUID_REGEX.test(uuid):
+            return ObjectTypes.GROUP;
+        default:
+            return ObjectTypes.UNKNOWN;
+    }
+};
\ No newline at end of file
diff --git a/services/workbench2/src/models/permission.ts b/services/workbench2/src/models/permission.ts
new file mode 100644 (file)
index 0000000..1d60380
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkResource, LinkClass } from './link';
+
+export interface PermissionResource extends LinkResource {
+    linkClass: LinkClass.PERMISSION;
+}
+
+export enum PermissionLevel {
+    NONE = 'none',
+    CAN_READ = 'can_read',
+    CAN_WRITE = 'can_write',
+    CAN_MANAGE = 'can_manage',
+    CAN_LOGIN = 'can_login',
+}
diff --git a/services/workbench2/src/models/process.ts b/services/workbench2/src/models/process.ts
new file mode 100644 (file)
index 0000000..cb1bfe8
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContainerRequestResource } from "./container-request";
+import { MountType, MountKind } from 'models/mount-types';
+import { WorkflowResource, parseWorkflowDefinition } from 'models/workflow';
+import { WorkflowInputsData } from './workflow';
+
+export type ProcessResource = ContainerRequestResource;
+
+export const MOUNT_PATH_CWL_WORKFLOW = '/var/lib/cwl/workflow.json';
+export const MOUNT_PATH_CWL_INPUT = '/var/lib/cwl/cwl.input.json';
+
+export const createWorkflowMounts = (workflow: WorkflowResource, inputs: WorkflowInputsData): { [path: string]: MountType } => {
+    return {
+        '/var/spool/cwl': {
+            kind: MountKind.COLLECTION,
+            writable: true,
+        },
+        'stdout': {
+            kind: MountKind.MOUNTED_FILE,
+            path: '/var/spool/cwl/cwl.output.json',
+        },
+        '/var/lib/cwl/workflow.json': {
+            kind: MountKind.JSON,
+            content: parseWorkflowDefinition(workflow)
+        },
+        '/var/lib/cwl/cwl.input.json': {
+            kind: MountKind.JSON,
+            content: inputs,
+        }
+    };
+};
diff --git a/services/workbench2/src/models/project.ts b/services/workbench2/src/models/project.ts
new file mode 100644 (file)
index 0000000..8dd2e71
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { GroupClass, GroupResource } from "./group";
+
+export interface ProjectResource extends GroupResource {
+    frozenByUuid: null | string;
+    groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE;
+}
+
+export const getProjectUrl = (uuid: string) => {
+    return `/projects/${uuid}`;
+};
diff --git a/services/workbench2/src/models/repositories.ts b/services/workbench2/src/models/repositories.ts
new file mode 100644 (file)
index 0000000..96903ad
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "models/resource";
+
+export interface RepositoryResource extends Resource {
+    name: string;
+    cloneUrls: string[];
+}
diff --git a/services/workbench2/src/models/resource.ts b/services/workbench2/src/models/resource.ts
new file mode 100644 (file)
index 0000000..2d2b9f2
--- /dev/null
@@ -0,0 +1,115 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface Resource {
+    uuid: string;
+    ownerUuid: string;
+    createdAt: string;
+    modifiedByClientUuid: string;
+    modifiedByUserUuid: string;
+    modifiedAt: string;
+    href: string;
+    kind: ResourceKind;
+    etag: string;
+}
+
+export interface ResourceWithProperties extends Resource {
+    properties: any;
+}
+
+export interface EditableResource extends Resource {
+    isEditable: boolean;
+}
+
+export interface TrashableResource extends Resource {
+    trashAt: string;
+    deleteAt: string;
+    isTrashed: boolean;
+}
+
+export enum ResourceKind {
+    API_CLIENT_AUTHORIZATION = "arvados#apiClientAuthorization",
+    COLLECTION = "arvados#collection",
+    CONTAINER = "arvados#container",
+    CONTAINER_REQUEST = "arvados#containerRequest",
+    GROUP = "arvados#group",
+    LINK = "arvados#link",
+    LOG = "arvados#log",
+    PROCESS = "arvados#containerRequest",
+    PROJECT = "arvados#group",
+    REPOSITORY = "arvados#repository",
+    SSH_KEY = "arvados#authorizedKeys",
+    KEEP_SERVICE = "arvados#keepService",
+    USER = "arvados#user",
+    VIRTUAL_MACHINE = "arvados#virtualMachine",
+    WORKFLOW = "arvados#workflow",
+    NONE = "arvados#none"
+}
+
+export enum ResourceObjectType {
+    API_CLIENT_AUTHORIZATION = 'gj3su',
+    COLLECTION = '4zz18',
+    CONTAINER = 'dz642',
+    CONTAINER_REQUEST = 'xvhdp',
+    GROUP = 'j7d0g',
+    LINK = 'o0j2j',
+    LOG = '57u5n',
+    REPOSITORY = 's0uqq',
+    USER = 'tpzed',
+    VIRTUAL_MACHINE = '2x53u',
+    WORKFLOW = '7fd4e',
+    SSH_KEY = 'fngyi',
+    KEEP_SERVICE = 'bi6l4'
+}
+
+export const RESOURCE_UUID_PATTERN = '[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}';
+export const PORTABLE_DATA_HASH_PATTERN = '[a-f0-9]{32}\\+\\d+';
+export const RESOURCE_UUID_REGEX = new RegExp("^" + RESOURCE_UUID_PATTERN + "$");
+export const COLLECTION_PDH_REGEX = new RegExp("^" + PORTABLE_DATA_HASH_PATTERN + "$");
+export const KEEP_URL_REGEX = new RegExp("^(keep:)?" + PORTABLE_DATA_HASH_PATTERN);
+
+export const isResourceUuid = (uuid: string) =>
+    RESOURCE_UUID_REGEX.test(uuid);
+
+export const extractUuidObjectType = (uuid: string) => {
+    const match = RESOURCE_UUID_REGEX.exec(uuid);
+    return match
+        ? match[0].split('-')[1]
+        : undefined;
+};
+
+export const extractUuidKind = (uuid: string = '') => {
+    const objectType = extractUuidObjectType(uuid);
+    switch (objectType) {
+        case ResourceObjectType.USER:
+            return ResourceKind.USER;
+        case ResourceObjectType.GROUP:
+            return ResourceKind.GROUP;
+        case ResourceObjectType.COLLECTION:
+            return ResourceKind.COLLECTION;
+        case ResourceObjectType.CONTAINER_REQUEST:
+            return ResourceKind.CONTAINER_REQUEST;
+        case ResourceObjectType.CONTAINER:
+            return ResourceKind.CONTAINER;
+        case ResourceObjectType.LOG:
+            return ResourceKind.LOG;
+        case ResourceObjectType.WORKFLOW:
+            return ResourceKind.WORKFLOW;
+        case ResourceObjectType.VIRTUAL_MACHINE:
+            return ResourceKind.VIRTUAL_MACHINE;
+        case ResourceObjectType.REPOSITORY:
+            return ResourceKind.REPOSITORY;
+        case ResourceObjectType.SSH_KEY:
+            return ResourceKind.SSH_KEY;
+        case ResourceObjectType.KEEP_SERVICE:
+            return ResourceKind.KEEP_SERVICE;
+        case ResourceObjectType.API_CLIENT_AUTHORIZATION:
+            return ResourceKind.API_CLIENT_AUTHORIZATION;
+        case ResourceObjectType.LINK:
+            return ResourceKind.LINK;
+        default:
+            const match = COLLECTION_PDH_REGEX.exec(uuid);
+            return match ? ResourceKind.COLLECTION : undefined;
+    }
+};
diff --git a/services/workbench2/src/models/runtime-constraints.ts b/services/workbench2/src/models/runtime-constraints.ts
new file mode 100644 (file)
index 0000000..6398252
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface CUDAParameters {
+    device_count: number;
+    driver_version: string;
+    hardware_capability: string;
+}
+
+export interface RuntimeConstraints {
+    ram: number;
+    vcpus: number;
+    keep_cache_ram?: number;
+    keep_cache_disk?: number;
+    API: boolean;
+    cuda?: CUDAParameters;
+}
diff --git a/services/workbench2/src/models/runtime-status.ts b/services/workbench2/src/models/runtime-status.ts
new file mode 100644 (file)
index 0000000..c659930
--- /dev/null
@@ -0,0 +1,11 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface RuntimeStatus {
+    error?: string;
+    warning?: string;
+    activity?: string;
+    errorDetail?: string;
+    warningDetail?: string;
+}
diff --git a/services/workbench2/src/models/scheduling-parameters.ts b/services/workbench2/src/models/scheduling-parameters.ts
new file mode 100644 (file)
index 0000000..f2167c9
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface SchedulingParameters {
+    partitions?: string[];
+    preemptible?: boolean;
+    max_run_time?: number;
+}
diff --git a/services/workbench2/src/models/search-bar.ts b/services/workbench2/src/models/search-bar.ts
new file mode 100644 (file)
index 0000000..f9320a2
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ResourceKind } from 'models/resource';
+import { GroupResource } from './group';
+
+export type SearchBarAdvancedFormData = {
+    type?: ResourceKind;
+    cluster?: string;
+    projectUuid?: string;
+    projectObject?: GroupResource;
+    inTrash: boolean;
+    pastVersions: boolean;
+    dateFrom: string;
+    dateTo: string;
+    saveQuery: boolean;
+    queryName: string;
+    searchValue: string;
+    properties: PropertyValue[];
+};
+
+export interface PropertyValue {
+    key: string;
+    keyID?: string;
+    value: string;
+    valueID?: string;
+}
diff --git a/services/workbench2/src/models/session.ts b/services/workbench2/src/models/session.ts
new file mode 100644 (file)
index 0000000..630b63d
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export enum SessionStatus {
+    INVALIDATED,
+    BEING_VALIDATED,
+    VALIDATED
+}
+
+export interface Session {
+    clusterId: string;
+    remoteHost: string;
+    baseUrl: string;
+    name: string;
+    email: string;
+    token: string;
+    uuid: string;
+    loggedIn: boolean;
+    status: SessionStatus;
+    active: boolean;
+    userIsActive: boolean;
+    apiRevision: number;
+}
diff --git a/services/workbench2/src/models/ssh-key.ts b/services/workbench2/src/models/ssh-key.ts
new file mode 100644 (file)
index 0000000..d4362d7
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from 'models/resource';
+
+export enum KeyType {
+    SSH = 'SSH'
+}
+
+export interface SshKeyResource extends Resource {
+    name: string;
+    keyType: KeyType;
+    authorizedUserUuid: string;
+    publicKey: string;
+    expiresAt: string;
+}
\ No newline at end of file
diff --git a/services/workbench2/src/models/tag.ts b/services/workbench2/src/models/tag.ts
new file mode 100644 (file)
index 0000000..fa36486
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkResource } from "./link";
+
+export interface TagResource extends LinkResource {
+    tailUuid: TagTailType;
+    properties: TagProperty;
+}
+
+export interface TagProperty {
+    uuid: string;
+    key: string;
+    keyID?: string;
+    value: string;
+    valueID?: string;
+}
+
+export enum TagTailType {
+    COLLECTION = 'Collection',
+    JOB = 'Job'
+}
\ No newline at end of file
diff --git a/services/workbench2/src/models/test-utils.ts b/services/workbench2/src/models/test-utils.ts
new file mode 100644 (file)
index 0000000..74667a9
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { GroupClass, GroupResource } from "./group";
+import { Resource, ResourceKind } from "./resource";
+import { ProjectResource } from "./project";
+
+export const mockGroupResource = (data: Partial<GroupResource> = {}): GroupResource => ({
+    createdAt: "",
+    deleteAt: "",
+    description: "",
+    etag: "",
+    groupClass: null,
+    href: "",
+    isTrashed: false,
+    kind: ResourceKind.GROUP,
+    modifiedAt: "",
+    modifiedByClientUuid: "",
+    modifiedByUserUuid: "",
+    name: "",
+    ownerUuid: "",
+    properties: "",
+    trashAt: "",
+    uuid: "",
+    ensure_unique_name: true,
+    canWrite: false,
+    canManage: false,
+    ...data
+});
+
+export const mockProjectResource = (data: Partial<ProjectResource> = {}): ProjectResource =>
+    mockGroupResource({ ...data, groupClass: GroupClass.PROJECT }) as ProjectResource;
+
+export const mockCommonResource = (data: Partial<Resource>): Resource => ({
+    createdAt: "",
+    etag: "",
+    href: "",
+    kind: ResourceKind.NONE,
+    modifiedAt: "",
+    modifiedByClientUuid: "",
+    modifiedByUserUuid: "",
+    ownerUuid: "",
+    uuid: "",
+    ...data
+});
diff --git a/services/workbench2/src/models/tree.test.ts b/services/workbench2/src/models/tree.test.ts
new file mode 100644 (file)
index 0000000..0e8063b
--- /dev/null
@@ -0,0 +1,133 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as Tree from './tree';
+import { initTreeNode } from './tree';
+import { pipe } from 'lodash/fp';
+
+describe('Tree', () => {
+    let tree: Tree.Tree<string>;
+
+    beforeEach(() => {
+        tree = Tree.createTree();
+    });
+
+    it('sets new node', () => {
+        const newTree = Tree.setNode(initTreeNode({ id: 'Node 1', value: 'Value 1' }))(tree);
+        expect(Tree.getNode('Node 1')(newTree)).toEqual(initTreeNode({ id: 'Node 1', value: 'Value 1' }));
+    });
+
+    it('appends a subtree', () => {
+        const newTree = Tree.setNode(initTreeNode({ id: 'Node 1', value: 'Value 1' }))(tree);
+        const subtree = Tree.setNode(initTreeNode({ id: 'Node 2', value: 'Value 2' }))(Tree.createTree());
+        const mergedTree = Tree.appendSubtree('Node 1', subtree)(newTree);
+        expect(Tree.getNode('Node 1')(mergedTree)).toBeDefined();
+        expect(Tree.getNode('Node 2')(mergedTree)).toBeDefined();
+    });
+
+    it('adds new node reference to parent children', () => {
+        const newTree = pipe(
+            Tree.setNode(initTreeNode({ id: 'Node 1', parent: '', value: 'Value 1' })),
+            Tree.setNode(initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 'Value 2' })),
+        )(tree);
+
+        expect(Tree.getNode('Node 1')(newTree)).toEqual({
+            ...initTreeNode({ id: 'Node 1', parent: '', value: 'Value 1' }),
+            children: ['Node 2']
+        });
+    });
+
+    it('gets node ancestors', () => {
+        const newTree = [
+            initTreeNode({ id: 'Node 1', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3', parent: 'Node 2', value: 'Value 1' }),
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+        expect(Tree.getNodeAncestorsIds('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
+    });
+
+    it('gets node descendants', () => {
+        const newTree = [
+            initTreeNode({ id: 'Node 1', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3', parent: 'Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }),
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+        expect(Tree.getNodeDescendantsIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+    });
+
+    it('gets root descendants', () => {
+        const newTree = [
+            initTreeNode({ id: 'Node 1', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3', parent: 'Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }),
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+        expect(Tree.getNodeDescendantsIds('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+    });
+
+    it('gets node children', () => {
+        const newTree = [
+            initTreeNode({ id: 'Node 1', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3', parent: 'Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }),
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+        expect(Tree.getNodeChildrenIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
+    });
+
+    it('gets root children', () => {
+        const newTree = [
+            initTreeNode({ id: 'Node 1', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }),
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+        expect(Tree.getNodeChildrenIds('')(newTree)).toEqual(['Node 1', 'Node 3']);
+    });
+
+    it('maps tree', () => {
+        const newTree = [
+            initTreeNode({ id: 'Node 1', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 'Value 2' }),
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+        const mappedTree = Tree.mapTreeValues<string, number>(value => parseInt(value.split(' ')[1], 10))(newTree);
+        expect(Tree.getNode('Node 2')(mappedTree)).toEqual(initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 2 }));
+    });
+
+    it('expands node ancestor chains', () => {
+        const newTree = [
+            initTreeNode({ id: 'Root Node 1', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 1.1', parent: 'Root Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 1.1.1', parent: 'Node 1.1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 1.2', parent: 'Root Node 1', value: 'Value 1' }),
+
+            initTreeNode({ id: 'Root Node 2', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1', parent: 'Root Node 2', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1.1', parent: 'Node 2.1', value: 'Value 1' }),
+
+            initTreeNode({ id: 'Root Node 3', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3.1', parent: 'Root Node 3', value: 'Value 1' }),
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+
+        const expandedTree = Tree.expandNodeAncestors(
+            'Node 1.1.1', // Expands 1.1 and 1
+            'Node 2.1', // Expands 2
+        )(newTree);
+
+        expect(Tree.getNode('Root Node 1')(expandedTree)?.expanded).toEqual(true);
+        expect(Tree.getNode('Node 1.1')(expandedTree)?.expanded).toEqual(true);
+        expect(Tree.getNode('Node 1.1.1')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Node 1.2')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Root Node 2')(expandedTree)?.expanded).toEqual(true);
+        expect(Tree.getNode('Node 2.1')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Node 2.1.1')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Root Node 3')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Node 3.1')(expandedTree)?.expanded).toEqual(false);
+    });
+});
diff --git a/services/workbench2/src/models/tree.ts b/services/workbench2/src/models/tree.ts
new file mode 100644 (file)
index 0000000..aeb4154
--- /dev/null
@@ -0,0 +1,259 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { pipe, map, reduce } from 'lodash/fp';
+export type Tree<T> = Record<string, TreeNode<T>>;
+
+export const TREE_ROOT_ID = '';
+
+export interface TreeNode<T = any> {
+    children: string[];
+    value: T;
+    id: string;
+    parent: string;
+    active: boolean;
+    selected: boolean;
+    initialState?: boolean;
+    expanded: boolean;
+    status: TreeNodeStatus;
+}
+
+export enum TreeNodeStatus {
+    INITIAL = 'INITIAL',
+    PENDING = 'PENDING',
+    LOADED = 'LOADED',
+}
+
+export enum TreePickerId {
+    PROJECTS = 'Projects',
+    SHARED_WITH_ME = 'Shared with me',
+    FAVORITES = 'Favorites',
+    PUBLIC_FAVORITES = 'Public Favorites'
+}
+
+export const createTree = <T>(): Tree<T> => ({});
+
+export const getNode = (id: string) => <T>(tree: Tree<T>): TreeNode<T> | undefined => tree[id];
+
+export const appendSubtree = <T>(id: string, subtree: Tree<T>) => (tree: Tree<T>) =>
+    pipe(
+        getNodeDescendants(''),
+        map(node => node.parent === '' ? { ...node, parent: id } : node),
+        reduce((newTree, node) => setNode(node)(newTree), tree)
+    )(subtree) as Tree<T>;
+
+export const setNode = <T>(node: TreeNode<T>) => (tree: Tree<T>): Tree<T> => {
+    if (tree[node.id] && tree[node.id] === node) { return tree; }
+
+    tree[node.id] = node;
+    if (tree[node.parent]) {
+        tree[node.parent].children = Array.from(new Set([...tree[node.parent].children, node.id]));
+    }
+    return tree;
+};
+
+export const getNodeValue = (id: string) => <T>(tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    return node ? node.value : undefined;
+};
+
+export const setNodeValue = (id: string) => <T>(value: T) => (tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    return node
+        ? setNode(mapNodeValue(() => value)(node))(tree)
+        : tree;
+};
+
+export const setNodeValueWith = <T>(mapFn: (value: T) => T) => (id: string) => (tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    return node
+        ? setNode(mapNodeValue(mapFn)(node))(tree)
+        : tree;
+};
+
+export const mapTreeValues = <T, R>(mapFn: (value: T) => R) => (tree: Tree<T>): Tree<R> =>
+    getNodeDescendantsIds('')(tree)
+        .map(id => getNode(id)(tree))
+        .filter(node => !!node)
+        .map(mapNodeValue(mapFn))
+        .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
+
+export const mapTree = <T, R = T>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
+    getNodeDescendantsIds('')(tree)
+        .map(id => getNode(id)(tree))
+        .map(mapFn)
+        .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
+
+export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeAncestorsIds(id)(tree))(tree);
+
+
+export const getNodeAncestorsIds = (id: string) => <T>(tree: Tree<T>): string[] => {
+    const node = getNode(id)(tree);
+    return node && node.parent
+        ? [...getNodeAncestorsIds(node.parent)(tree), node.parent]
+        : [];
+};
+
+export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeDescendantsIds(id, limit)(tree))(tree);
+
+export const countNodes = <T>(tree: Tree<T>) =>
+    getNodeDescendantsIds('')(tree).length;
+
+export const countChildren = (id: string) => <T>(tree: Tree<T>) =>
+    getNodeChildren('')(tree).length;
+
+export const getNodeDescendantsIds = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
+    const node = getNode(id)(tree);
+    const children = node ? node.children :
+        id === TREE_ROOT_ID
+            ? getRootNodeChildrenIds(tree)
+            : [];
+
+    return children
+        .concat(limit < 1
+            ? []
+            : children
+                .map(id => getNodeDescendantsIds(id, limit - 1)(tree))
+                .reduce((nodes, nodeChildren) => [...nodes, ...nodeChildren], []));
+};
+
+export const getNodeChildren = (id: string) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeChildrenIds(id)(tree))(tree);
+
+export const getNodeChildrenIds = (id: string) => <T>(tree: Tree<T>): string[] =>
+    getNodeDescendantsIds(id, 0)(tree);
+
+export const mapIdsToNodes = (ids: string[]) => <T>(tree: Tree<T>) =>
+    ids.map(id => getNode(id)(tree)).filter((node): node is TreeNode<T> => node !== undefined);
+
+export const activateNode = (id: string) => <T>(tree: Tree<T>) =>
+    mapTree((node: TreeNode<T>) => node.id === id ? { ...node, active: true } : { ...node, active: false })(tree);
+
+export const deactivateNode = <T>(tree: Tree<T>) =>
+    mapTree((node: TreeNode<T>) => node.active ? { ...node, active: false } : node)(tree);
+
+export const expandNode = (...ids: string[]) => <T>(tree: Tree<T>) =>
+    mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree);
+
+export const expandNodeAncestors = (...ids: string[]) => <T>(tree: Tree<T>) => {
+    const ancestors = ids.reduce((acc, id): string[] => ([...acc, ...getNodeAncestorsIds(id)(tree)]), [] as string[]);
+    return mapTree((node: TreeNode<T>) => ancestors.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree);
+}
+
+export const collapseNode = (...ids: string[]) => <T>(tree: Tree<T>) =>
+    mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: false } : node)(tree);
+
+export const toggleNodeCollapse = (...ids: string[]) => <T>(tree: Tree<T>) =>
+    mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: !node.expanded } : node)(tree);
+
+export const setNodeStatus = (id: string) => (status: TreeNodeStatus) => <T>(tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    return node
+        ? setNode({ ...node, status })(tree)
+        : tree;
+};
+
+export const toggleNodeSelection = (id: string, cascade: boolean) => <T>(tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+
+    return node
+        ? cascade
+            ? pipe(
+                setNode({ ...node, selected: !node.selected }),
+                toggleAncestorsSelection(id),
+                toggleDescendantsSelection(id))(tree)
+            : setNode({ ...node, selected: !node.selected })(tree)
+        : tree;
+};
+
+export const selectNode = (id: string, cascade: boolean) => <T>(tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    return node && node.selected
+        ? tree
+        : toggleNodeSelection(id, cascade)(tree);
+};
+
+export const selectNodes = (id: string | string[], cascade: boolean) => <T>(tree: Tree<T>) => {
+    const ids = typeof id === 'string' ? [id] : id;
+    return ids.reduce((tree, id) => selectNode(id, cascade)(tree), tree);
+};
+export const deselectNode = (id: string, cascade: boolean) => <T>(tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    return node && node.selected
+        ? toggleNodeSelection(id, cascade)(tree)
+        : tree;
+};
+
+export const deselectNodes = (id: string | string[], cascade: boolean) => <T>(tree: Tree<T>) => {
+    const ids = typeof id === 'string' ? [id] : id;
+    return ids.reduce((tree, id) => deselectNode(id, cascade)(tree), tree);
+};
+
+export const getSelectedNodes = <T>(tree: Tree<T>) =>
+    getNodeDescendants('')(tree)
+        .filter(node => node.selected);
+
+export const initTreeNode = <T>(data: Pick<TreeNode<T>, 'id' | 'value'> & { parent?: string }): TreeNode<T> => ({
+    children: [],
+    active: false,
+    selected: false,
+    expanded: false,
+    status: TreeNodeStatus.INITIAL,
+    parent: '',
+    ...data,
+});
+
+export const getTreeDirty = (id: string) => <T>(tree: Tree<T>): boolean => {
+    const node = getNode(id)(tree);
+    const children = getNodeDescendants(id)(tree);
+    return (node
+            && node.initialState !== undefined
+            && node.selected !== node.initialState
+            )
+            || children.some(child =>
+                child.initialState !== undefined
+                && child.selected !== child.initialState
+            );
+}
+
+const toggleDescendantsSelection = (id: string) => <T>(tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    if (node) {
+        return getNodeDescendants(id)(tree)
+            .reduce((newTree, subNode) =>
+                setNode({ ...subNode, selected: node.selected })(newTree),
+                tree);
+    }
+    return tree;
+};
+
+const toggleAncestorsSelection = (id: string) => <T>(tree: Tree<T>) => {
+    const ancestors = getNodeAncestorsIds(id)(tree).reverse();
+    return ancestors.reduce((newTree, parent) => parent ? toggleParentNodeSelection(parent)(newTree) : newTree, tree);
+};
+
+const toggleParentNodeSelection = (id: string) => <T>(tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    if (node) {
+        const parentNode = getNode(node.id)(tree);
+        if (parentNode) {
+            const selected = parentNode.children
+                .map(id => getNode(id)(tree))
+                .every(node => node !== undefined && node.selected);
+            return setNode({ ...parentNode, selected })(tree);
+        }
+        return setNode(node)(tree);
+    }
+    return tree;
+};
+
+const mapNodeValue = <T, R>(mapFn: (value: T) => R) => (node: TreeNode<T>): TreeNode<R> =>
+    ({ ...node, value: mapFn(node.value) });
+
+const getRootNodeChildrenIds = <T>(tree: Tree<T>) =>
+    Object
+        .keys(tree)
+        .filter(id => getNode(id)(tree)!.parent === TREE_ROOT_ID);
diff --git a/services/workbench2/src/models/user.test.ts b/services/workbench2/src/models/user.test.ts
new file mode 100644 (file)
index 0000000..30f82cf
--- /dev/null
@@ -0,0 +1,124 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { User, getUserDisplayName } from './user';
+
+describe('User', () => {
+    it('gets the user display name', () => {
+        type UserCase = {
+            caseName: string;
+            withEmail?: boolean;
+            user: User;
+            expect: string;
+        };
+        const testCases: UserCase[] = [
+            {
+                caseName: 'Full data available',
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: 'Some', lastName: 'User',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'Some User'
+            },
+            {
+                caseName: 'Full data available (with email)',
+                withEmail: true,
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: 'Some', lastName: 'User',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'Some User <someuser@example.com>'
+            },
+            {
+                caseName: 'Missing first name',
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: '', lastName: 'User',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser@example.com'
+            },
+            {
+                caseName: 'Missing last name',
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: 'Some', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser@example.com'
+            },
+            {
+                caseName: 'Missing first & last names',
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser@example.com'
+            },
+            {
+                caseName: 'Missing first & last names (with email)',
+                withEmail: true,
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser@example.com'
+            },
+            {
+                caseName: 'Missing first & last names, and email address',
+                user: {
+                    email: '', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser'
+            },
+            {
+                caseName: 'Missing first & last names, and email address (with email)',
+                withEmail: true,
+                user: {
+                    email: '', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser'
+            },
+            {
+                caseName: 'Missing all data (should not happen)',
+                user: {
+                    email: '', username: '',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'zzzzz-tpzed-someusersuuid'
+            },
+        ];
+        testCases.forEach(c => {
+            const dispName = getUserDisplayName(c.user, c.withEmail);
+            expect(dispName).toEqual(c.expect);
+        })
+    });
+});
diff --git a/services/workbench2/src/models/user.ts b/services/workbench2/src/models/user.ts
new file mode 100644 (file)
index 0000000..0df6eac
--- /dev/null
@@ -0,0 +1,67 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource, ResourceKind, RESOURCE_UUID_REGEX } from 'models/resource';
+
+export type UserPrefs = {
+    profile?: {
+        organization?: string,
+        organization_email?: string,
+        lab?: string,
+        website_url?: string,
+        role?: string
+    }
+};
+
+export interface User {
+    email: string;
+    firstName: string;
+    lastName: string;
+    uuid: string;
+    ownerUuid: string;
+    username: string;
+    prefs: UserPrefs;
+    isAdmin: boolean;
+    isActive: boolean;
+    canWrite: boolean;
+    canManage: boolean;
+}
+
+export const getUserFullname = (user: User) => {
+    return user.firstName && user.lastName
+        ? `${user.firstName} ${user.lastName}`
+        : "";
+};
+
+export const getUserDisplayName = (user: User, withEmail = false, withUuid = false) => {
+    const displayName = getUserFullname(user) || user.email || user.username || user.uuid;
+    let parts: string[] = [displayName];
+    if (withEmail && user.email && displayName !== user.email) {
+        parts.push(`<${user.email}>`);
+    }
+    if (withUuid) {
+        parts.push(`(${user.uuid})`);
+    }
+    return parts.join(' ');
+};
+
+export const getUserDetailsString = (user: User) => {
+    let parts: string[] = [];
+    const userCluster = getUserClusterID(user);
+    user.username.length && parts.push(user.username);
+    user.email.length && parts.push(`<${user.email}>`);
+    userCluster && userCluster.length && parts.push(`(${userCluster})`);
+    return parts.join(' ');
+};
+
+export const getUserClusterID = (user: User): string | undefined => {
+    const match = RESOURCE_UUID_REGEX.exec(user.uuid);
+    const parts = match ? match[0].split('-') : [];
+    return parts.length === 3 ? parts[0] : undefined;
+};
+
+export interface UserResource extends Resource, User {
+    kind: ResourceKind.USER;
+    defaultOwnerUuid: string;
+}
diff --git a/services/workbench2/src/models/virtual-machines.ts b/services/workbench2/src/models/virtual-machines.ts
new file mode 100644 (file)
index 0000000..9ee4940
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "models/resource";
+
+export interface VirtualMachinesResource extends Resource {
+    hostname: string;
+}
+
+export interface VirtualMachinesLoginsItems {
+    hostname: string;
+    username: string;
+    public_key: string;
+    userUuid: string;
+    virtualMachineUuid: string;
+    authorizedKeyUuid: string;
+}
+
+export interface VirtualMachineLogins {
+    kind: string;
+    items: VirtualMachinesLoginsItems[];
+}
\ No newline at end of file
diff --git a/services/workbench2/src/models/vocabulary.test.ts b/services/workbench2/src/models/vocabulary.test.ts
new file mode 100644 (file)
index 0000000..f4ba64e
--- /dev/null
@@ -0,0 +1,222 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as Vocabulary from './vocabulary';
+
+describe('Vocabulary', () => {
+    let vocabulary: Vocabulary.Vocabulary;
+
+    beforeEach(() => {
+        vocabulary = {
+            strict_tags: false,
+            tags: {
+                IDKEYCOMMENT: {
+                    labels: []
+                },
+                IDKEYANIMALS: {
+                    strict: false,
+                    labels: [
+                        {label: "Animal" },
+                        {label: "Creature"},
+                        {label: "Beast"},
+                    ],
+                    values: {
+                        IDVALANIMALS1: {
+                            labels: [
+                                {label: "Human"},
+                                {label: "Homo sapiens"}
+                            ]
+                        },
+                        IDVALANIMALS2: {
+                            labels: [
+                                {label: "Dog"},
+                                {label: "Canis lupus familiaris"}
+                            ]
+                        },
+                    }
+                },
+                IDKEYSIZES: {
+                    labels: [{label: "Sizes"}],
+                    values: {
+                        IDVALSIZES1: {
+                            labels: [{label: "Small"}, {label: "S"}, {label: "Little"}]
+                        },
+                        IDVALSIZES2: {
+                            labels: [{label: "Medium"}, {label: "M"}]
+                        },
+                        IDVALSIZES3: {
+                            labels: [{label: "Large"}, {label: "L"}]
+                        },
+                        IDVALSIZES4: {
+                            labels: []
+                        }
+                    }
+                },
+                automation: {
+                    strict: true,
+                    labels: [],
+                    values: {
+                        upload: { labels: [] },
+                        results: { labels: [] },
+                    }
+                }
+            }
+        }
+    });
+
+    it('returns the list of tag keys', () => {
+        const tagKeys = Vocabulary.getTags(vocabulary);
+        // Alphabetically ordered by label
+        expect(tagKeys).toEqual([
+            {id: "IDKEYANIMALS", label: "Animal"},
+            {id: "IDKEYANIMALS", label: "Beast"},
+            {id: "IDKEYANIMALS", label: "Creature"},
+            {id: "IDKEYCOMMENT", label: "IDKEYCOMMENT"},
+            {id: "IDKEYSIZES", label: "Sizes"},
+            {id: "automation", label: "automation"},
+        ]);
+    });
+
+    it('returns the list of preferred tag keys', () => {
+        const preferredTagKeys = Vocabulary.getPreferredTags(vocabulary);
+        // Alphabetically ordered by label
+        expect(preferredTagKeys).toEqual([
+            {id: "IDKEYANIMALS", label: "Animal", synonyms: []},
+            {id: "IDKEYCOMMENT", label: "IDKEYCOMMENT", synonyms: []},
+            {id: "IDKEYSIZES", label: "Sizes", synonyms: []},
+            {id: "automation", label: "automation", synonyms: []},
+        ]);
+    });
+
+    it('returns the list of preferred tag keys with matching synonyms', () => {
+        const preferredTagKeys = Vocabulary.getPreferredTags(vocabulary, 'creat');
+        // Alphabetically ordered by label
+        expect(preferredTagKeys).toEqual([
+            {id: "IDKEYANIMALS", label: "Animal", synonyms: ["Creature"]},
+            {id: "IDKEYCOMMENT", label: "IDKEYCOMMENT", synonyms: []},
+            {id: "IDKEYSIZES", label: "Sizes", synonyms: []},
+            {id: "automation", label: "automation", synonyms: []},
+        ]);
+    });
+
+    it('returns the tag values for a given key', () => {
+        const tagValues = Vocabulary.getTagValues('IDKEYSIZES', vocabulary);
+        // Alphabetically ordered by label
+        expect(tagValues).toEqual([
+            {id: "IDVALSIZES4", label: "IDVALSIZES4"},
+            {id: "IDVALSIZES3", label: "L"},
+            {id: "IDVALSIZES3", label: "Large"},
+            {id: "IDVALSIZES1", label: "Little"},
+            {id: "IDVALSIZES2", label: "M"},
+            {id: "IDVALSIZES2", label: "Medium"},
+            {id: "IDVALSIZES1", label: "S"},
+            {id: "IDVALSIZES1", label: "Small"},
+        ]);
+        // Let's try a key that doesn't have any labels
+        const tagValues2 = Vocabulary.getTagValues('automation', vocabulary);
+        expect(tagValues2).toEqual([
+            {id: "results", label: "results"},
+            {id: "upload", label: "upload"},
+        ]);
+    });
+
+    it('returns the preferred tag values for a given key', () => {
+        const preferredTagValues = Vocabulary.getPreferredTagValues('IDKEYSIZES', vocabulary);
+        // Alphabetically ordered by label
+        expect(preferredTagValues).toEqual([
+            {id: "IDVALSIZES4", label: "IDVALSIZES4", synonyms: []},
+            {id: "IDVALSIZES3", label: "Large", synonyms: []},
+            {id: "IDVALSIZES2", label: "Medium", synonyms: []},
+            {id: "IDVALSIZES1", label: "Small", synonyms: []},
+        ]);
+        // Let's try a key that doesn't have any labels
+        const preferredTagValues2 = Vocabulary.getPreferredTagValues('automation', vocabulary);
+        expect(preferredTagValues2).toEqual([
+            {id: "results", label: "results", synonyms: []},
+            {id: "upload", label: "upload", synonyms: []},
+        ]);
+    });
+
+    it('returns the preferred tag values with matching synonyms for a given key', () => {
+        const preferredTagValues = Vocabulary.getPreferredTagValues('IDKEYSIZES', vocabulary, 'litt');
+        // Alphabetically ordered by label
+        expect(preferredTagValues).toEqual([
+            {id: "IDVALSIZES4", label: "IDVALSIZES4", synonyms: []},
+            {id: "IDVALSIZES3", label: "Large", synonyms: []},
+            {id: "IDVALSIZES2", label: "Medium", synonyms: []},
+            {id: "IDVALSIZES1", label: "Small", synonyms: ["Little"]},
+        ])
+    });
+
+    it('returns an empty list of values for an non-existent key', () => {
+        const tagValues = Vocabulary.getTagValues('IDNONSENSE', vocabulary);
+        expect(tagValues).toEqual([]);
+    });
+
+    it('returns a key id for a given key label', () => {
+        const testCases = [
+            // Two labels belonging to the same ID
+            {keyLabel: 'Animal', expected: 'IDKEYANIMALS'},
+            {keyLabel: 'Creature', expected: 'IDKEYANIMALS'},
+            // Non-existent label returns empty string
+            {keyLabel: 'ThisKeyLabelDoesntExist', expected: ''},
+            // Key with no labels still returns the key ID
+            {keyLabel: 'automation', expected: 'automation'},
+        ]
+        testCases.forEach(tc => {
+            const tagValueID = Vocabulary.getTagKeyID(tc.keyLabel, vocabulary);
+            expect(tagValueID).toEqual(tc.expected);
+        });
+    });
+
+    it('returns an key label for a given key id', () => {
+        const testCases = [
+            // ID with many labels return the first one
+            {keyID: 'IDKEYANIMALS', expected: 'Animal'},
+            // Key IDs without any labels or unknown keys should return the literal
+            // key from the API's response (that is, the key 'id')
+            {keyID: 'IDKEYCOMMENT', expected: 'IDKEYCOMMENT'},
+            {keyID: 'FOO', expected: 'FOO'},
+        ]
+        testCases.forEach(tc => {
+            const tagValueID = Vocabulary.getTagKeyLabel(tc.keyID, vocabulary);
+            expect(tagValueID).toEqual(tc.expected);
+        });
+    });
+
+    it('returns a value id for a given key id and value label', () => {
+        const testCases = [
+            // Key ID and value label known
+            {keyID: 'IDKEYANIMALS', valueLabel: 'Human', expected: 'IDVALANIMALS1'},
+            {keyID: 'IDKEYANIMALS', valueLabel: 'Homo sapiens', expected: 'IDVALANIMALS1'},
+            // Key ID known, value label unknown
+            {keyID: 'IDKEYANIMALS', valueLabel: 'Dinosaur', expected: ''},
+            // Key ID unknown
+            {keyID: 'IDNONSENSE', valueLabel: 'Does not matter', expected: ''},
+            // Value with no labels still returns the value ID
+            {keyID: 'automation', valueLabel: 'results', expected: 'results'},
+        ]
+        testCases.forEach(tc => {
+            const tagValueID = Vocabulary.getTagValueID(tc.keyID, tc.valueLabel, vocabulary);
+            expect(tagValueID).toEqual(tc.expected);
+        });
+    });
+
+    it('returns a value label for a given key & value id pair', () => {
+        const testCases = [
+            // Known key & value ids with multiple value labels: returns the first label
+            {keyId: 'IDKEYANIMALS', valueId: 'IDVALANIMALS1', expected: 'Human'},
+            // Values without label or unknown values should return the literal value from
+            // the API's response (that is, the value 'id')
+            {keyId: 'IDKEYSIZES', valueId: 'IDVALSIZES4', expected: 'IDVALSIZES4'},
+            {keyId: 'IDKEYCOMMENT', valueId: 'FOO', expected: 'FOO'},
+            {keyId: 'IDKEYANIMALS', valueId: 'BAR', expected: 'BAR'},
+            {keyId: 'IDKEYNONSENSE', valueId: 'FOOBAR', expected: 'FOOBAR'},
+        ]
+        testCases.forEach(tc => {
+            const tagValueLabel = Vocabulary.getTagValueLabel(tc.keyId, tc.valueId, vocabulary);
+            expect(tagValueLabel).toEqual(tc.expected);
+        });
+    });
+});
diff --git a/services/workbench2/src/models/vocabulary.ts b/services/workbench2/src/models/vocabulary.ts
new file mode 100644 (file)
index 0000000..c913bd6
--- /dev/null
@@ -0,0 +1,147 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { escapeRegExp } from 'common/regexp';
+import { isObject, has, every } from 'lodash/fp';
+
+export interface Vocabulary {
+    strict_tags: boolean;
+    tags: Record<string, Tag>;
+}
+
+export interface Label {
+    lang?: string;
+    label: string;
+}
+
+export interface TagValue {
+    labels: Label[];
+}
+
+export interface Tag {
+    strict?: boolean;
+    labels: Label[];
+    values?: Record<string, TagValue>;
+}
+
+export interface PropFieldSuggestion {
+    id: string;
+    label: string;
+    synonyms?: string[];
+}
+
+const VOCABULARY_VALIDATORS = [
+    isObject,
+    has('strict_tags'),
+    has('tags'),
+];
+
+export const isVocabulary = (value: any) =>
+    every(validator => validator(value), VOCABULARY_VALIDATORS);
+
+export const isStrictTag = (tagKeyID: string, vocabulary: Vocabulary) => {
+    const tag = vocabulary.tags[tagKeyID];
+    return tag ? tag.strict : false;
+};
+
+export const getTagValueID = (tagKeyID:string, tagValueLabel:string, vocabulary: Vocabulary) => {
+    if (tagKeyID && vocabulary.tags[tagKeyID] && vocabulary.tags[tagKeyID].values) {
+        const values = vocabulary.tags[tagKeyID].values!;
+        return Object.keys(values).find(k =>
+            (k.toLowerCase() === tagValueLabel.toLowerCase())
+            || values[k].labels.find(
+                l => l.label.toLowerCase() === tagValueLabel.toLowerCase()) !== undefined)
+            || '';
+    };
+    return '';
+};
+
+export const getTagValueLabel = (tagKeyID:string, tagValueID:string, vocabulary: Vocabulary) =>
+    vocabulary.tags[tagKeyID] &&
+    vocabulary.tags[tagKeyID].values &&
+    vocabulary.tags[tagKeyID].values![tagValueID] &&
+    vocabulary.tags[tagKeyID].values![tagValueID].labels.length > 0
+        ? vocabulary.tags[tagKeyID].values![tagValueID].labels[0].label
+        : tagValueID;
+
+const compare = (a: PropFieldSuggestion, b: PropFieldSuggestion) => {
+    if (a.label < b.label) {return -1;}
+    if (a.label > b.label) {return 1;}
+    return 0;
+};
+
+export const getTagValues = (tagKeyID: string, vocabulary: Vocabulary): PropFieldSuggestion[] => {
+    const tag = vocabulary.tags[tagKeyID];
+    return tag && tag.values
+        ? Object.keys(tag.values).map(
+            tagValueID => tag.values![tagValueID].labels && tag.values![tagValueID].labels.length > 0
+                ? tag.values![tagValueID].labels.map(
+                    lbl => Object.assign({}, {"id": tagValueID, "label": lbl.label}))
+                : [{"id": tagValueID, "label": tagValueID}])
+            .reduce((prev, curr) => [...prev, ...curr], [])
+            .sort(compare)
+        : [];
+};
+
+export const getPreferredTagValues = (tagKeyID: string, vocabulary: Vocabulary, withMatch?: string): PropFieldSuggestion[] => {
+    const tag = vocabulary.tags[tagKeyID];
+    const regex = !!withMatch ? new RegExp(escapeRegExp(withMatch), 'i') : undefined;
+    return tag && tag.values
+        ? Object.keys(tag.values).map(
+            tagValueID => tag.values![tagValueID].labels && tag.values![tagValueID].labels.length > 0
+                ? {
+                    "id": tagValueID,
+                    "label": tag.values![tagValueID].labels[0].label,
+                    "synonyms": !!withMatch && tag.values![tagValueID].labels.length > 1
+                        ? tag.values![tagValueID].labels.slice(1)
+                            .filter(l => !!regex ? regex.test(l.label) : true)
+                            .map(l => l.label)
+                        : []
+                }
+                : {"id": tagValueID, "label": tagValueID, "synonyms": []})
+            .sort(compare)
+        : [];
+};
+
+export const getTags = ({ tags }: Vocabulary): PropFieldSuggestion[] => {
+    return tags && Object.keys(tags)
+        ? Object.keys(tags).map(
+            tagID => tags[tagID].labels && tags[tagID].labels.length > 0
+                ? tags[tagID].labels.map(
+                    lbl => Object.assign({}, {"id": tagID, "label": lbl.label}))
+                : [{"id": tagID, "label": tagID}])
+            .reduce((prev, curr) => [...prev, ...curr], [])
+            .sort(compare)
+        : [];
+};
+
+export const getPreferredTags = ({ tags }: Vocabulary, withMatch?: string): PropFieldSuggestion[] => {
+    const regex = !!withMatch ? new RegExp(escapeRegExp(withMatch), 'i') : undefined;
+    return tags && Object.keys(tags)
+        ? Object.keys(tags).map(
+            tagID => tags[tagID].labels && tags[tagID].labels.length > 0
+                ? {
+                    "id": tagID,
+                    "label": tags[tagID].labels[0].label,
+                    "synonyms": !!withMatch && tags[tagID].labels.length > 1
+                        ? tags[tagID].labels.slice(1)
+                                .filter(l => !!regex ? regex.test(l.label) : true)
+                                .map(lbl => lbl.label)
+                        : []
+                }
+                : {"id": tagID, "label": tagID, "synonyms": []})
+            .sort(compare)
+        : [];
+};
+
+export const getTagKeyID = (tagKeyLabel: string, vocabulary: Vocabulary) =>
+    Object.keys(vocabulary.tags).find(k => (k.toLowerCase() === tagKeyLabel.toLowerCase())
+        || vocabulary.tags[k].labels.find(
+            l => l.label.toLowerCase() === tagKeyLabel.toLowerCase()) !== undefined)
+        || '';
+
+export const getTagKeyLabel = (tagKeyID:string, vocabulary: Vocabulary) =>
+    vocabulary.tags[tagKeyID] && vocabulary.tags[tagKeyID].labels.length > 0
+    ? vocabulary.tags[tagKeyID].labels[0].label
+    : tagKeyID;
diff --git a/services/workbench2/src/models/workflow.ts b/services/workbench2/src/models/workflow.ts
new file mode 100644 (file)
index 0000000..369db4c
--- /dev/null
@@ -0,0 +1,230 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource, ResourceKind } from "./resource";
+import { safeLoad } from 'js-yaml';
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
+
+export interface WorkflowResource extends Resource {
+    kind: ResourceKind.WORKFLOW;
+    name: string;
+    description: string;
+    definition: string;
+}
+export interface WorkflowResourceDefinition {
+    cwlVersion: string;
+    $graph?: Array<Workflow | CommandLineTool>;
+}
+export interface Workflow {
+    class: 'Workflow';
+    doc?: string;
+    id?: string;
+    inputs: CommandInputParameter[];
+    outputs: any[];
+    steps: any[];
+    hints?: ProcessRequirement[];
+}
+
+export interface CommandLineTool {
+    class: 'CommandLineTool';
+    id: string;
+    inputs: CommandInputParameter[];
+    outputs: any[];
+    hints?: ProcessRequirement[];
+}
+
+export type ProcessRequirement = GenericProcessRequirement | WorkflowRunnerResources;
+
+export interface GenericProcessRequirement {
+    class: string;
+}
+
+export interface WorkflowRunnerResources {
+    class: 'http://arvados.org/cwl#WorkflowRunnerResources';
+    ramMin?: number;
+    coresMin?: number;
+    keep_cache?: number;
+    acrContainerImage?: string;
+}
+
+export type CommandInputParameter =
+    BooleanCommandInputParameter |
+    IntCommandInputParameter |
+    LongCommandInputParameter |
+    FloatCommandInputParameter |
+    DoubleCommandInputParameter |
+    StringCommandInputParameter |
+    FileCommandInputParameter |
+    DirectoryCommandInputParameter |
+    StringArrayCommandInputParameter |
+    IntArrayCommandInputParameter |
+    FloatArrayCommandInputParameter |
+    FileArrayCommandInputParameter |
+    DirectoryArrayCommandInputParameter |
+    EnumCommandInputParameter;
+
+export enum CWLType {
+    NULL = 'null',
+    BOOLEAN = 'boolean',
+    INT = 'int',
+    LONG = 'long',
+    FLOAT = 'float',
+    DOUBLE = 'double',
+    STRING = 'string',
+    FILE = 'File',
+    DIRECTORY = 'Directory',
+}
+
+export interface CommandInputEnumSchema {
+    symbols: string[];
+    type: 'enum';
+    label?: string;
+    name?: string;
+}
+
+export interface CommandInputArraySchema<ItemType> {
+    items: ItemType;
+    type: 'array';
+    label?: string;
+}
+
+export interface File {
+    class: CWLType.FILE;
+    location?: string;
+    path?: string;
+    basename?: string;
+}
+
+export interface Directory {
+    class: CWLType.DIRECTORY;
+    location?: string;
+    path?: string;
+    basename?: string;
+}
+
+export interface GenericCommandInputParameter<Type, Value> {
+    id: string;
+    label?: string;
+    doc?: string | string[];
+    default?: Value;
+    type?: Type | Array<Type | CWLType.NULL>;
+    value?: Value;
+    disabled?: boolean;
+}
+export type GenericArrayCommandInputParameter<Type, Value> = GenericCommandInputParameter<CommandInputArraySchema<Type>, Value[]>;
+
+export type BooleanCommandInputParameter = GenericCommandInputParameter<CWLType.BOOLEAN, boolean>;
+export type IntCommandInputParameter = GenericCommandInputParameter<CWLType.INT, number>;
+export type LongCommandInputParameter = GenericCommandInputParameter<CWLType.LONG, number>;
+export type FloatCommandInputParameter = GenericCommandInputParameter<CWLType.FLOAT, number>;
+export type DoubleCommandInputParameter = GenericCommandInputParameter<CWLType.DOUBLE, number>;
+export type StringCommandInputParameter = GenericCommandInputParameter<CWLType.STRING, string>;
+export type FileCommandInputParameter = GenericCommandInputParameter<CWLType.FILE, File>;
+export type DirectoryCommandInputParameter = GenericCommandInputParameter<CWLType.DIRECTORY, Directory>;
+export type EnumCommandInputParameter = GenericCommandInputParameter<CommandInputEnumSchema, string>;
+
+export type StringArrayCommandInputParameter = GenericArrayCommandInputParameter<CWLType.STRING, string>;
+export type IntArrayCommandInputParameter = GenericArrayCommandInputParameter<CWLType.INT, string>;
+export type FloatArrayCommandInputParameter = GenericArrayCommandInputParameter<CWLType.FLOAT, string>;
+export type FileArrayCommandInputParameter = GenericArrayCommandInputParameter<CWLType.FILE, File>;
+export type DirectoryArrayCommandInputParameter = GenericArrayCommandInputParameter<CWLType.DIRECTORY, Directory>;
+
+export type WorkflowInputsData = {
+    [key: string]: boolean | number | string | File | Directory;
+};
+export const parseWorkflowDefinition = (workflow: WorkflowResource): WorkflowResourceDefinition => {
+    const definition = safeLoad(workflow.definition);
+    return definition;
+};
+
+export const getWorkflow = (workflowDefinition: WorkflowResourceDefinition) => {
+    if (!workflowDefinition.$graph) { return undefined; }
+    const mainWorkflow = workflowDefinition.$graph.find(item => item.id === '#main');
+    return mainWorkflow
+        ? mainWorkflow
+        : undefined;
+};
+
+export const getWorkflowInputs = (workflowDefinition: WorkflowResourceDefinition) => {
+    if (!workflowDefinition) { return undefined; }
+    return getWorkflow(workflowDefinition)
+        ? getWorkflow(workflowDefinition)!.inputs
+        : undefined;
+};
+
+export const getWorkflowOutputs = (workflowDefinition: WorkflowResourceDefinition) => {
+    if (!workflowDefinition) { return undefined; }
+    return getWorkflow(workflowDefinition)
+        ? getWorkflow(workflowDefinition)!.outputs
+        : undefined;
+};
+
+export const getInputLabel = (input: CommandInputParameter) => {
+    return `${input.label || input.id.split('/').pop()}`;
+};
+
+export const getIOParamId = (input: CommandInputParameter | CommandOutputParameter) => {
+    return `${input.id.split('/').pop()}`;
+};
+
+export const isRequiredInput = ({ type }: CommandInputParameter) => {
+    if (type instanceof Array) {
+        for (const t of type) {
+            if (t === CWLType.NULL) {
+                return false;
+            }
+        }
+    }
+    return true;
+};
+
+export const isPrimitiveOfType = (input: GenericCommandInputParameter<any, any>, type: CWLType) =>
+    input.type instanceof Array
+        ? input.type.indexOf(type) > -1
+        : input.type === type;
+
+export const isArrayOfType = (input: GenericCommandInputParameter<any, any>, type: CWLType) =>
+    input.type instanceof Array
+        ? (input.type.filter(t => typeof t === 'object' &&
+            t.type === 'array' &&
+            t.items === type).length > 0)
+        : (typeof input.type === 'object' &&
+            input.type.type === 'array' &&
+            input.type.items === type);
+
+export const getEnumType = (input: GenericCommandInputParameter<any, any>) => {
+    if (input.type instanceof Array) {
+        const f = input.type.filter(t => typeof t === 'object' &&
+            !(t instanceof Array) &&
+            t.type === 'enum');
+        if (f.length > 0) {
+            return f[0];
+        }
+    } else {
+        if ((typeof input.type === 'object' &&
+            !(input.type instanceof Array) &&
+            input.type.type === 'enum')) {
+            return input.type;
+        }
+    }
+    return null;
+};
+
+export const stringifyInputType = ({ type }: CommandInputParameter) => {
+    if (typeof type === 'string') {
+        return type;
+    } else if (type instanceof Array) {
+        return type.join(' | ');
+    } else if (typeof type === 'object') {
+        if (type.type === 'enum') {
+            return 'enum';
+        } else if (type.type === 'array') {
+            return `${type.items}[]`;
+        } else {
+            return 'unknown';
+        }
+    } else {
+        return 'unknown';
+    }
+};
diff --git a/services/workbench2/src/plugins.tsx b/services/workbench2/src/plugins.tsx
new file mode 100644 (file)
index 0000000..718d73a
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PluginConfig } from 'common/plugintypes';
+
+export const pluginConfig: PluginConfig = {
+    centerPanelList: [],
+    sidePanelCategories: [],
+    dialogs: [],
+    navigateToHandlers: [],
+    locationChangeHandlers: [],
+    appBarLeft: undefined,
+    appBarMiddle: undefined,
+    appBarRight: undefined,
+    accountMenuList: [],
+    enableNewButtonMatchers: [],
+    newButtonMenuList: [],
+    middlewares: []
+};
+
+// Starting here, import and register your Workbench 2 plugins. //
+
+// import { register as blankUIPluginRegister } from 'plugins/blank/index';
+// import { register as examplePluginRegister } from 'plugins/example/index';
+// import { register as rootRedirectRegister } from 'plugins/root-redirect/index';
+
+// blankUIPluginRegister(pluginConfig);
+// examplePluginRegister(pluginConfig);
+// rootRedirectRegister(pluginConfig, exampleRoutePath);
diff --git a/services/workbench2/src/plugins/README.md b/services/workbench2/src/plugins/README.md
new file mode 100644 (file)
index 0000000..b34684f
--- /dev/null
@@ -0,0 +1,65 @@
+[comment]: # (Copyright © The Arvados Authors. All rights reserved.)
+[comment]: # ()
+[comment]: # (SPDX-License-Identifier: CC-BY-SA-3.0)
+
+# Plugin support
+
+Workbench supports plugins to add new functionality to the user
+interface.  It is also possible to remove the majority of standard UI
+elements and replace them with your own, enabling you to use workbench
+as a basis for developing essentially new applications for Arvados.
+
+## Installing plugins
+
+1. Check out the source of your plugin into a directory under `arvados-workbench2/src/plugins`
+
+2. Register the plugin by editing `arvados-workbench2/src/plugins/plugins.tsx`.
+It will look something like this:
+
+```
+import { register as examplePluginRegister } from 'plugins/example/index';
+examplePluginRegister(pluginConfig);
+```
+
+3. Rebuild Workbench 2
+
+For testing/development: `yarn start`
+
+For production: `APP_NAME=arvados-workbench2-with-custom-plugins make packages`
+
+Set `APP_NAME=` to whatever you like, but it is important to name it
+differently from the standard `arvados-workbench2` to avoid confusion.
+
+## Existing plugins
+
+### example
+
+This is an example plugin showing how to add a new navigation tree
+item, displaying a new center panel, as well as adding account menu
+and "New" menu items, and showing how to use SET_PROPERTY and
+getProperty() for state.
+
+### blank
+
+This deletes all of the existing user interface.  If you want the
+application to only display your plugin's UI elements and none of the
+standard elements, you would load and register this first.
+
+### root-redirect
+
+This helper takes a path when registered.  It tweaks the navigation
+behavior so that the default starting location when the application
+loads will be the path you provide, instead of "Projects".
+
+### sample-tracker
+
+This is a a new set of user interface screens that assist with
+clinical sample tracking and analysis.  It is intended as a demo of
+how a real-world application can built using the Workbench 2
+plug-in interface.  It can be found at
+https://github.com/arvados/sample-tracker .
+
+## Developing plugins
+
+For information about the plugin API, see
+[../common/plugintypes.ts](src/common/plugintypes.ts).
diff --git a/services/workbench2/src/plugins/blank/index.tsx b/services/workbench2/src/plugins/blank/index.tsx
new file mode 100644 (file)
index 0000000..4de9813
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Example plugin.
+
+import { PluginConfig } from 'common/plugintypes';
+import React from 'react';
+
+export const register = (pluginConfig: PluginConfig) => {
+
+    pluginConfig.centerPanelList.push((elms) => []);
+
+    pluginConfig.sidePanelCategories.push((cats: string[]): string[] => []);
+
+    pluginConfig.accountMenuList.push((elms) => []);
+    pluginConfig.newButtonMenuList.push((elms) => []);
+
+    pluginConfig.appBarLeft = <span />;
+    pluginConfig.appBarMiddle = <span />;
+    pluginConfig.appBarRight = <span />;
+};
diff --git a/services/workbench2/src/plugins/example/exampleComponents.tsx b/services/workbench2/src/plugins/example/exampleComponents.tsx
new file mode 100644 (file)
index 0000000..89019ad
--- /dev/null
@@ -0,0 +1,131 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { ServiceRepository } from "services/services";
+import { Dispatch } from "redux";
+import { RootState } from 'store/store';
+import { initialize } from 'redux-form';
+import { dialogActions } from "store/dialog/dialog-actions";
+import { reduxForm, InjectedFormProps, Field, reset, startSubmit } from 'redux-form';
+import { TextField } from "components/text-field/text-field";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { withDialog } from "store/dialog/with-dialog";
+import { compose } from "redux";
+import { propertiesActions } from "store/properties/properties-actions";
+import { DispatchProp, connect } from 'react-redux';
+import { MenuItem } from "@material-ui/core";
+import { Card, CardContent, Typography } from "@material-ui/core";
+
+// This is the name of the dialog box.  It in store actions that
+// open/close the dialog box.
+export const EXAMPLE_DIALOG_FORM_NAME = "exampleFormName";
+
+// This is the name of the property that will be used to store the
+// "pressed" count
+export const propertyKey = "Example_menu_item_pressed_count";
+
+// The model backing the form.
+export interface ExampleFormDialogData {
+    pressedCount: number | string;  // Supposed to start as a number but TextField seems to turn this into a string, unfortunately.
+}
+
+// The actual component with the editing fields.  Enables editing
+// the 'pressedCount' field.
+const ExampleEditFields = () => <span>
+    <Field
+        name='pressedCount'
+        component={TextField as any}
+        type="number"
+    />
+</span>;
+
+// Callback for when the form is submitted.
+const submitEditedPressedCount = (data: ExampleFormDialogData) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(EXAMPLE_DIALOG_FORM_NAME));
+        dispatch(propertiesActions.SET_PROPERTY({
+            key: propertyKey, value: parseInt(data.pressedCount as string, 10)
+        }));
+        dispatch(dialogActions.CLOSE_DIALOG({ id: EXAMPLE_DIALOG_FORM_NAME }));
+        dispatch(reset(EXAMPLE_DIALOG_FORM_NAME));
+    };
+
+// Props for the dialog component
+type DialogExampleProps = WithDialogProps<{ updating: boolean }> & InjectedFormProps<ExampleFormDialogData>;
+
+// This is the component that renders the dialog.
+const DialogExample = (props: DialogExampleProps) =>
+    <FormDialog
+        dialogTitle="Edit pressed count"
+        formFields={ExampleEditFields}
+        submitLabel="Update pressed count"
+        {...props}
+    />;
+
+// This ties it all together, withDialog() determines if the dialog is
+// visible based on state, and reduxForm manages the values of the
+// dialog's fields.
+export const ExampleDialog = compose(
+    withDialog(EXAMPLE_DIALOG_FORM_NAME),
+    reduxForm<ExampleFormDialogData>({
+        form: EXAMPLE_DIALOG_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(submitEditedPressedCount(data));
+        }
+    })
+)(DialogExample);
+
+
+// Callback, dispatches an action to set the value of property
+// "Example_menu_item_pressed_count"
+const incrementPressedCount = (dispatch: Dispatch, pressedCount: number) => {
+    dispatch(propertiesActions.SET_PROPERTY({ key: propertyKey, value: pressedCount + 1 }));
+};
+
+// Callback, dispatches actions required to initialize and open the
+// dialog box.
+export const openExampleDialog = (pressedCount: number) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(initialize(EXAMPLE_DIALOG_FORM_NAME, { pressedCount }));
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: EXAMPLE_DIALOG_FORM_NAME, data: {}
+        }));
+    };
+
+// Props definition used for menu items.
+interface ExampleProps {
+    pressedCount: number;
+    className?: string;
+}
+
+// Called to get the props from the redux state for several of the
+// following components.
+// Gets the value of the property "Example_menu_item_pressed_count"
+// from the state and puts it in 'pressedCount'
+const exampleMapStateToProps = (state: RootState) => ({ pressedCount: state.properties[propertyKey] || 0 });
+
+// Define component for the menu item that incremens the count each time it is pressed.
+export const ExampleMenuComponent = connect(exampleMapStateToProps)(
+    ({ pressedCount, dispatch, className }: ExampleProps & DispatchProp<any>) =>
+        <MenuItem className={className} onClick={() => incrementPressedCount(dispatch, pressedCount)}>Example menu item</MenuItem >
+);
+
+// Define component for the menu item that opens the dialog box that lets you edit the count directly.
+export const ExampleDialogMenuComponent = connect(exampleMapStateToProps)(
+    ({ pressedCount, dispatch, className }: ExampleProps & DispatchProp<any>) =>
+        <MenuItem className={className} onClick={() => dispatch(openExampleDialog(pressedCount))}>Open example dialog</MenuItem >
+);
+
+// The central panel.  Displays the "pressed" count.
+export const ExamplePluginMainPanel = connect(exampleMapStateToProps)(
+    ({ pressedCount }: ExampleProps) =>
+        <Card>
+            <CardContent>
+                <Typography>
+                    This is a example main panel plugin.  The example menu item has been pressed {pressedCount} times.
+               </Typography>
+            </CardContent>
+        </Card>);
diff --git a/services/workbench2/src/plugins/example/index.tsx b/services/workbench2/src/plugins/example/index.tsx
new file mode 100644 (file)
index 0000000..5c7a1a7
--- /dev/null
@@ -0,0 +1,85 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Example workbench plugin.  The entry point is the "register" method.
+
+import { PluginConfig } from 'common/plugintypes';
+import React from 'react';
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { push } from "react-router-redux";
+import { Route, matchPath } from "react-router";
+import { RootStore } from 'store/store';
+import { activateSidePanelTreeItem } from 'store/side-panel-tree/side-panel-tree-actions';
+import { setSidePanelBreadcrumbs } from 'store/breadcrumbs/breadcrumbs-actions';
+import { Location } from 'history';
+import { handleFirstTimeLoad } from 'store/workbench/workbench-actions';
+import {
+    ExampleDialog,
+    ExamplePluginMainPanel,
+    ExampleMenuComponent,
+    ExampleDialogMenuComponent
+} from './exampleComponents';
+
+const categoryName = "Plugin Example";
+export const routePath = "/examplePlugin";
+
+export const register = (pluginConfig: PluginConfig) => {
+
+    // Add this component to the main panel.  When the app navigates
+    // to '/examplePlugin' it will render ExamplePluginMainPanel.
+    pluginConfig.centerPanelList.push((elms) => {
+        elms.push(<Route path={routePath} component={ExamplePluginMainPanel} />);
+        return elms;
+    });
+
+    // Add ExampleDialogMenuComponent to the upper-right user account menu
+    pluginConfig.accountMenuList.push((elms, menuItemClass) => {
+        elms.push(<ExampleDialogMenuComponent className={menuItemClass} />);
+        return elms;
+    });
+
+    // Add ExampleMenuComponent to the "New" button dropdown.
+    pluginConfig.newButtonMenuList.push((elms, menuItemClass) => {
+        elms.push(<ExampleMenuComponent className={menuItemClass} />);
+        return elms;
+    });
+
+    // Add a hook so that when the 'Plugin Example' entry in the left
+    // hand tree view is clicked, which calls navigateTo('Plugin Example'),
+    // it will be implemented by navigating to '/examplePlugin'
+    pluginConfig.navigateToHandlers.push((dispatch: Dispatch, getState: () => RootState, uuid: string) => {
+        if (uuid === categoryName) {
+            dispatch(push(routePath));
+            return true;
+        }
+        return false;
+    });
+
+    // Adds 'Plugin Example' to the left hand tree view.
+    pluginConfig.sidePanelCategories.push((cats: string[]): string[] => { cats.push(categoryName); return cats; });
+
+    // When the location changes to '/examplePlugin', make sure
+    // 'Plugin Example' in the left hand tree view is selected, and
+    // make sure the breadcrumbs are updated.
+    pluginConfig.locationChangeHandlers.push((store: RootStore, pathname: string): boolean => {
+        if (matchPath(pathname, { path: routePath, exact: true })) {
+            store.dispatch(handleFirstTimeLoad(
+                (dispatch: Dispatch) => {
+                    dispatch<any>(activateSidePanelTreeItem(categoryName));
+                    dispatch<any>(setSidePanelBreadcrumbs(categoryName));
+                }));
+            return true;
+        }
+        return false;
+    });
+
+    // The "New" button can enabled or disabled based on the current
+    // context or selection.  This adds a new callback to that will
+    // enable the "New" button when the location is '/examplePlugin'
+    pluginConfig.enableNewButtonMatchers.push((location: Location) => (!!matchPath(location.pathname, { path: routePath, exact: true })));
+
+    // Add the example dialog box to the list of dialog box controls.
+    pluginConfig.dialogs.push(<ExampleDialog />);
+};
diff --git a/services/workbench2/src/plugins/root-redirect/index.tsx b/services/workbench2/src/plugins/root-redirect/index.tsx
new file mode 100644 (file)
index 0000000..ee6d516
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PluginConfig } from 'common/plugintypes';
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
+import { push } from "react-router-redux";
+
+export const register = (pluginConfig: PluginConfig, redirect: string) => {
+
+    pluginConfig.navigateToHandlers.push((dispatch: Dispatch, getState: () => RootState, uuid: string) => {
+        if (uuid === SidePanelTreeCategory.PROJECTS) {
+            dispatch(push(redirect));
+            return true;
+        }
+        return false;
+    });
+};
diff --git a/services/workbench2/src/react-app-env.d.ts b/services/workbench2/src/react-app-env.d.ts
new file mode 100644 (file)
index 0000000..6431bc5
--- /dev/null
@@ -0,0 +1 @@
+/// <reference types="react-scripts" />
diff --git a/services/workbench2/src/routes/route-change-handlers.ts b/services/workbench2/src/routes/route-change-handlers.ts
new file mode 100644 (file)
index 0000000..5888745
--- /dev/null
@@ -0,0 +1,125 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { History, Location } from 'history';
+import { RootStore } from 'store/store';
+import * as Routes from 'routes/routes';
+import * as WorkbenchActions from 'store/workbench/workbench-actions';
+import { navigateToRootProject } from 'store/navigation/navigation-action';
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { contextMenuActions } from 'store/context-menu/context-menu-actions';
+import { searchBarActions } from 'store/search-bar/search-bar-actions';
+import { pluginConfig } from 'plugins';
+import { openProjectPanel } from 'store/project-panel/project-panel-action';
+
+export const addRouteChangeHandlers = (history: History, store: RootStore) => {
+    const handler = handleLocationChange(store);
+    handler(history.location);
+    history.listen(handler);
+};
+
+const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
+
+    const rootMatch = Routes.matchRootRoute(pathname);
+    const projectMatch = Routes.matchProjectRoute(pathname);
+    const collectionMatch = Routes.matchCollectionRoute(pathname);
+    const favoriteMatch = Routes.matchFavoritesRoute(pathname);
+    const publicFavoritesMatch = Routes.matchPublicFavoritesRoute(pathname);
+    const trashMatch = Routes.matchTrashRoute(pathname);
+    const processMatch = Routes.matchProcessRoute(pathname);
+    const repositoryMatch = Routes.matchRepositoriesRoute(pathname);
+    const searchResultsMatch = Routes.matchSearchResultsRoute(pathname);
+    const sharedWithMeMatch = Routes.matchSharedWithMeRoute(pathname);
+    const runProcessMatch = Routes.matchRunProcessRoute(pathname);
+    const virtualMachineUserMatch = Routes.matchUserVirtualMachineRoute(pathname);
+    const virtualMachineAdminMatch = Routes.matchAdminVirtualMachineRoute(pathname);
+    const sshKeysUserMatch = Routes.matchSshKeysUserRoute(pathname);
+    const sshKeysAdminMatch = Routes.matchSshKeysAdminRoute(pathname);
+    const instanceTypesMatch = Routes.matchInstanceTypesRoute(pathname);
+    const siteManagerMatch = Routes.matchSiteManagerRoute(pathname);
+    const keepServicesMatch = Routes.matchKeepServicesRoute(pathname);
+    const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname);
+    const myAccountMatch = Routes.matchMyAccountRoute(pathname);
+    const linkAccountMatch = Routes.matchLinkAccountRoute(pathname);
+    const usersMatch = Routes.matchUsersRoute(pathname);
+    const userProfileMatch = Routes.matchUserProfileRoute(pathname);
+    const groupsMatch = Routes.matchGroupsRoute(pathname);
+    const groupDetailsMatch = Routes.matchGroupDetailsRoute(pathname);
+    const linksMatch = Routes.matchLinksRoute(pathname);
+    const collectionsContentAddressMatch = Routes.matchCollectionsContentAddressRoute(pathname);
+    const allProcessesMatch = Routes.matchAllProcessesRoute(pathname);
+    const registeredWorkflowMatch = Routes.matchRegisteredWorkflowRoute(pathname);
+
+    store.dispatch(dialogActions.CLOSE_ALL_DIALOGS());
+    store.dispatch(contextMenuActions.CLOSE_CONTEXT_MENU());
+    store.dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
+
+    for (const locChangeFn of pluginConfig.locationChangeHandlers) {
+        if (locChangeFn(store, pathname)) {
+            return;
+        }
+    }
+
+    document.title = `Arvados (${store.getState().auth.config.uuidPrefix}) - ${pathname.slice(1)}`;
+
+    if (projectMatch) {
+        store.dispatch(openProjectPanel(projectMatch.params.id));
+    } else if (collectionMatch) {
+        store.dispatch(WorkbenchActions.loadCollection(collectionMatch.params.id));
+    } else if (favoriteMatch) {
+        store.dispatch(WorkbenchActions.loadFavorites());
+    } else if (publicFavoritesMatch) {
+        store.dispatch(WorkbenchActions.loadPublicFavorites());
+    } else if (trashMatch) {
+        store.dispatch(WorkbenchActions.loadTrash());
+    } else if (processMatch) {
+        store.dispatch(WorkbenchActions.loadProcess(processMatch.params.id));
+    } else if (rootMatch) {
+        store.dispatch(navigateToRootProject);
+    } else if (sharedWithMeMatch) {
+        store.dispatch(WorkbenchActions.loadSharedWithMe);
+    } else if (runProcessMatch) {
+        store.dispatch(WorkbenchActions.loadRunProcess);
+    } else if (searchResultsMatch) {
+        store.dispatch(WorkbenchActions.loadSearchResults);
+    } else if (virtualMachineUserMatch) {
+        store.dispatch(WorkbenchActions.loadVirtualMachines);
+    } else if (virtualMachineAdminMatch) {
+        store.dispatch(WorkbenchActions.loadVirtualMachinesAdmin);
+    } else if (repositoryMatch) {
+        store.dispatch(WorkbenchActions.loadRepositories);
+    } else if (sshKeysUserMatch) {
+        store.dispatch(WorkbenchActions.loadSshKeys);
+    } else if (sshKeysAdminMatch) {
+        store.dispatch(WorkbenchActions.loadSshKeys);
+    } else if (instanceTypesMatch) {
+        store.dispatch(WorkbenchActions.loadInstanceTypes);
+    } else if (siteManagerMatch) {
+        store.dispatch(WorkbenchActions.loadSiteManager);
+    } else if (keepServicesMatch) {
+        store.dispatch(WorkbenchActions.loadKeepServices);
+    } else if (apiClientAuthorizationsMatch) {
+        store.dispatch(WorkbenchActions.loadApiClientAuthorizations);
+    } else if (myAccountMatch) {
+        store.dispatch(WorkbenchActions.loadUserProfile());
+    } else if (linkAccountMatch) {
+        store.dispatch(WorkbenchActions.loadLinkAccount);
+    } else if (usersMatch) {
+        store.dispatch(WorkbenchActions.loadUsers);
+    } else if (userProfileMatch) {
+        store.dispatch(WorkbenchActions.loadUserProfile(userProfileMatch.params.id));
+    } else if (groupsMatch) {
+        store.dispatch(WorkbenchActions.loadGroupsPanel);
+    } else if (groupDetailsMatch) {
+        store.dispatch(WorkbenchActions.loadGroupDetailsPanel(groupDetailsMatch.params.id));
+    } else if (linksMatch) {
+        store.dispatch(WorkbenchActions.loadLinks);
+    } else if (collectionsContentAddressMatch) {
+        store.dispatch(WorkbenchActions.loadCollectionContentAddress);
+    } else if (allProcessesMatch) {
+        store.dispatch(WorkbenchActions.loadAllProcesses());
+    } else if (registeredWorkflowMatch) {
+        store.dispatch(WorkbenchActions.loadRegisteredWorkflow(registeredWorkflowMatch.params.id));
+    }
+};
diff --git a/services/workbench2/src/routes/routes.ts b/services/workbench2/src/routes/routes.ts
new file mode 100644 (file)
index 0000000..cbb09a5
--- /dev/null
@@ -0,0 +1,211 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { matchPath } from 'react-router';
+import { ResourceKind, RESOURCE_UUID_PATTERN, extractUuidKind, COLLECTION_PDH_REGEX, PORTABLE_DATA_HASH_PATTERN } from 'models/resource';
+import { getProjectUrl } from 'models/project';
+import { getCollectionUrl } from 'models/collection';
+import { Config } from 'common/config';
+import { Session } from "models/session";
+
+export interface FederationConfig {
+    localCluster: string;
+    remoteHostsConfig: { [key: string]: Config };
+    sessions: Session[];
+}
+
+export const Routes = {
+    ROOT: '/',
+    TOKEN: '/token',
+    FED_LOGIN: '/fedtoken',
+    ADD_SESSION: '/add-session',
+    PROJECTS: `/projects/:id(${RESOURCE_UUID_PATTERN})`,
+    COLLECTIONS: `/collections/:id(${RESOURCE_UUID_PATTERN})`,
+    PROCESSES: `/processes/:id(${RESOURCE_UUID_PATTERN})`,
+    FAVORITES: '/favorites',
+    TRASH: '/trash',
+    REPOSITORIES: '/repositories',
+    SHARED_WITH_ME: '/shared-with-me',
+    RUN_PROCESS: '/run-process',
+    VIRTUAL_MACHINES_ADMIN: '/virtual-machines-admin',
+    VIRTUAL_MACHINES_USER: '/virtual-machines-user',
+    WORKFLOWS: '/workflows',
+    REGISTEREDWORKFLOW: `/workflows/:id(${RESOURCE_UUID_PATTERN})`,
+    SEARCH_RESULTS: '/search-results',
+    SSH_KEYS_ADMIN: `/ssh-keys-admin`,
+    SSH_KEYS_USER: `/ssh-keys-user`,
+    INSTANCE_TYPES: `/instance-types`,
+    SITE_MANAGER: `/site-manager`,
+    MY_ACCOUNT: '/my-account',
+    LINK_ACCOUNT: '/link_account',
+    KEEP_SERVICES: `/keep-services`,
+    USERS: '/users',
+    USER_PROFILE: `/user/:id(${RESOURCE_UUID_PATTERN})`,
+    API_CLIENT_AUTHORIZATIONS: `/api_client_authorizations`,
+    GROUPS: '/groups',
+    GROUP_DETAILS: `/group/:id(${RESOURCE_UUID_PATTERN})`,
+    LINKS: '/links',
+    PUBLIC_FAVORITES: '/public-favorites',
+    COLLECTIONS_CONTENT_ADDRESS: `/collections/:id(${PORTABLE_DATA_HASH_PATTERN})`,
+    ALL_PROCESSES: '/all_processes',
+    NO_MATCH: '*',
+};
+
+export const getResourceUrl = (uuid: string) => {
+    const kind = extractUuidKind(uuid);
+    switch (kind) {
+        case ResourceKind.PROJECT:
+            return getProjectUrl(uuid);
+        case ResourceKind.USER:
+            return getProjectUrl(uuid);
+        case ResourceKind.COLLECTION:
+            return getCollectionUrl(uuid);
+        case ResourceKind.PROCESS:
+            return getProcessUrl(uuid);
+        case ResourceKind.WORKFLOW:
+            return getWorkflowUrl(uuid);
+        default:
+            return undefined;
+    }
+};
+
+/**
+ * @returns A relative or federated url for the given uuid, with a token for federated WB1 urls
+ */
+export const getNavUrl = (uuid: string, config: FederationConfig, includeToken: boolean = true): string => {
+    const path = getResourceUrl(uuid) || "";
+    const cls = uuid.substring(0, 5);
+    if (cls === config.localCluster || extractUuidKind(uuid) === ResourceKind.USER || COLLECTION_PDH_REGEX.exec(uuid)) {
+        return path;
+    } else if (config.remoteHostsConfig[cls]) {
+        let u: URL;
+        if (config.remoteHostsConfig[cls].workbench2Url) {
+            /* NOTE: wb2 presently doesn't support passing api_token
+               to arbitrary page to set credentials, only through
+               api-token route.  So for navigation to work, user needs
+               to already be logged in.  In the future we want to just
+               request the records and display in the current
+               workbench instance making this redirect unnecessary. */
+            u = new URL(config.remoteHostsConfig[cls].workbench2Url);
+        } else {
+            u = new URL(config.remoteHostsConfig[cls].workbenchUrl);
+            if (includeToken) {
+                u.search = "api_token=" + config.sessions.filter((s) => s.clusterId === cls)[0].token;
+            }
+        }
+        u.pathname = path;
+        return u.toString();
+    } else {
+        return "";
+    }
+};
+
+
+export const getProcessUrl = (uuid: string) => `/processes/${uuid}`;
+
+export const getWorkflowUrl = (uuid: string) => `/workflows/${uuid}`;
+
+export const getGroupUrl = (uuid: string) => `/group/${uuid}`;
+
+export const getUserProfileUrl = (uuid: string) => `/user/${uuid}`;
+
+export interface ResourceRouteParams {
+    id: string;
+}
+
+export const matchRootRoute = (route: string) =>
+    matchPath(route, { path: Routes.ROOT, exact: true });
+
+export const matchFavoritesRoute = (route: string) =>
+    matchPath(route, { path: Routes.FAVORITES });
+
+export const matchTrashRoute = (route: string) =>
+    matchPath(route, { path: Routes.TRASH });
+
+export const matchAllProcessesRoute = (route: string) =>
+    matchPath(route, { path: Routes.ALL_PROCESSES });
+
+export const matchRegisteredWorkflowRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.REGISTEREDWORKFLOW });
+
+export const matchProjectRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.PROJECTS });
+
+export const matchCollectionRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.COLLECTIONS });
+
+export const matchProcessRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.PROCESSES });
+
+export const matchSharedWithMeRoute = (route: string) =>
+    matchPath(route, { path: Routes.SHARED_WITH_ME });
+
+export const matchRunProcessRoute = (route: string) =>
+    matchPath(route, { path: Routes.RUN_PROCESS });
+
+export const matchWorkflowRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.WORKFLOWS });
+
+export const matchSearchResultsRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.SEARCH_RESULTS });
+
+export const matchUserVirtualMachineRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.VIRTUAL_MACHINES_USER });
+
+export const matchAdminVirtualMachineRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.VIRTUAL_MACHINES_ADMIN });
+
+export const matchRepositoriesRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.REPOSITORIES });
+
+export const matchSshKeysUserRoute = (route: string) =>
+    matchPath(route, { path: Routes.SSH_KEYS_USER });
+
+    export const matchSshKeysAdminRoute = (route: string) =>
+    matchPath(route, { path: Routes.SSH_KEYS_ADMIN });
+
+export const matchInstanceTypesRoute = (route: string) =>
+    matchPath(route, { path: Routes.INSTANCE_TYPES });
+
+export const matchSiteManagerRoute = (route: string) =>
+    matchPath(route, { path: Routes.SITE_MANAGER });
+
+export const matchMyAccountRoute = (route: string) =>
+    matchPath(route, { path: Routes.MY_ACCOUNT });
+
+export const matchLinkAccountRoute = (route: string) =>
+    matchPath(route, { path: Routes.LINK_ACCOUNT });
+
+export const matchKeepServicesRoute = (route: string) =>
+    matchPath(route, { path: Routes.KEEP_SERVICES });
+
+export const matchTokenRoute = (route: string) =>
+    matchPath(route, { path: Routes.TOKEN });
+
+export const matchFedTokenRoute = (route: string) =>
+    matchPath(route, { path: Routes.FED_LOGIN });
+
+export const matchUsersRoute = (route: string) =>
+    matchPath(route, { path: Routes.USERS });
+
+export const matchUserProfileRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.USER_PROFILE });
+
+export const matchApiClientAuthorizationsRoute = (route: string) =>
+    matchPath(route, { path: Routes.API_CLIENT_AUTHORIZATIONS });
+
+export const matchGroupsRoute = (route: string) =>
+    matchPath(route, { path: Routes.GROUPS });
+
+export const matchGroupDetailsRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.GROUP_DETAILS });
+
+export const matchLinksRoute = (route: string) =>
+    matchPath(route, { path: Routes.LINKS });
+
+export const matchPublicFavoritesRoute = (route: string) =>
+    matchPath(route, { path: Routes.PUBLIC_FAVORITES });
+
+export const matchCollectionsContentAddressRoute = (route: string) =>
+    matchPath(route, { path: Routes.COLLECTIONS_CONTENT_ADDRESS });
diff --git a/services/workbench2/src/services/ancestors-service/ancestors-service.ts b/services/workbench2/src/services/ancestors-service/ancestors-service.ts
new file mode 100644 (file)
index 0000000..188c233
--- /dev/null
@@ -0,0 +1,61 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { GroupsService } from "services/groups-service/groups-service";
+import { UserService } from '../user-service/user-service';
+import { GroupResource } from 'models/group';
+import { UserResource } from 'models/user';
+import { extractUuidObjectType, ResourceObjectType } from "models/resource";
+import { CollectionService } from "services/collection-service/collection-service";
+import { CollectionResource } from "models/collection";
+
+export class AncestorService {
+    constructor(
+        private groupsService: GroupsService,
+        private userService: UserService,
+        private collectionService: CollectionService,
+    ) { }
+
+    async ancestors(startUuid: string, endUuid: string): Promise<Array<UserResource | GroupResource | CollectionResource>> {
+        return this._ancestors(startUuid, endUuid);
+    }
+
+    private async _ancestors(startUuid: string, endUuid: string, previousUuid = ''): Promise<Array<UserResource | GroupResource | CollectionResource>> {
+
+        if (startUuid === previousUuid) {
+            return [];
+        }
+
+        const service = this.getService(extractUuidObjectType(startUuid));
+        if (service) {
+            try {
+                const resource = await service.get(startUuid, false);
+                if (startUuid === endUuid) {
+                    return [resource];
+                } else {
+                    return [
+                        ...await this._ancestors(resource.ownerUuid, endUuid, startUuid),
+                        resource
+                    ];
+                }
+            } catch (e) {
+                return [];
+            }
+        }
+        return [];
+    }
+
+    private getService = (objectType?: string) => {
+        switch (objectType) {
+            case ResourceObjectType.GROUP:
+                return this.groupsService;
+            case ResourceObjectType.USER:
+                return this.userService;
+            case ResourceObjectType.COLLECTION:
+                return this.collectionService;
+            default:
+                return undefined;
+        }
+    }
+}
diff --git a/services/workbench2/src/services/api-client-authorization-service/api-client-authorization-service.test.ts b/services/workbench2/src/services/api-client-authorization-service/api-client-authorization-service.test.ts
new file mode 100644 (file)
index 0000000..4dd01b8
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import axios, { AxiosInstance } from "axios";
+import { ApiClientAuthorizationService } from "./api-client-authorization-service";
+
+
+describe('ApiClientAuthorizationService', () => {
+    let apiClientAuthorizationService: ApiClientAuthorizationService;
+    let serverApi: AxiosInstance;
+    let actions;
+
+    beforeEach(() => {
+        serverApi = axios.create();
+        actions = {
+            progressFn: jest.fn(),
+        } as any;
+        apiClientAuthorizationService = new ApiClientAuthorizationService(serverApi, actions);
+    });
+
+    describe('createCollectionSharingToken', () => {
+        it('should return error on invalid collection uuid', () => {
+            expect(() => apiClientAuthorizationService.createCollectionSharingToken("foo")).toThrowError("UUID foo is not a collection");
+        });
+
+        it('should make a create request with proper scopes and no expiration date', async () => {
+            serverApi.post = jest.fn(() => Promise.resolve(
+                { data: { uuid: 'zzzzz-4zz18-0123456789abcde' } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            await apiClientAuthorizationService.createCollectionSharingToken(uuid);
+            expect(serverApi.post).toHaveBeenCalledWith(
+                '/api_client_authorizations', {
+                    scopes: [
+                        `GET /arvados/v1/collections/${uuid}`,
+                        `GET /arvados/v1/collections/${uuid}/`,
+                        `GET /arvados/v1/keep_services/accessible`,
+                    ]
+                }
+            );
+        });
+
+        it('should make a create request with proper scopes and expiration date', async () => {
+            serverApi.post = jest.fn(() => Promise.resolve(
+                { data: { uuid: 'zzzzz-4zz18-0123456789abcde' } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            const expDate = new Date(2022, 8, 28, 12, 0, 0);
+            await apiClientAuthorizationService.createCollectionSharingToken(uuid, expDate);
+            expect(serverApi.post).toHaveBeenCalledWith(
+                '/api_client_authorizations', {
+                    scopes: [
+                        `GET /arvados/v1/collections/${uuid}`,
+                        `GET /arvados/v1/collections/${uuid}/`,
+                        `GET /arvados/v1/keep_services/accessible`,
+                    ],
+                    expires_at: expDate.toUTCString()
+                }
+            );
+        });
+    });
+
+    describe('listCollectionSharingToken', () => {
+        it('should return error on invalid collection uuid', () => {
+            expect(() => apiClientAuthorizationService.listCollectionSharingTokens("foo")).toThrowError("UUID foo is not a collection");
+        });
+
+        it('should make a list request with proper scopes', async () => {
+            serverApi.get = jest.fn(() => Promise.resolve(
+                { data: { items: [{}] } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            await apiClientAuthorizationService.listCollectionSharingTokens(uuid);
+            expect(serverApi.get).toHaveBeenCalledWith(
+                `/api_client_authorizations`, {params: {
+                    filters: JSON.stringify([["scopes","=",[
+                        `GET /arvados/v1/collections/${uuid}`,
+                        `GET /arvados/v1/collections/${uuid}/`,
+                        'GET /arvados/v1/keep_services/accessible',
+                    ]]]),
+                    select: undefined,
+                }}
+            );
+        });
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/services/api-client-authorization-service/api-client-authorization-service.ts b/services/workbench2/src/services/api-client-authorization-service/api-client-authorization-service.ts
new file mode 100644 (file)
index 0000000..dbda0a4
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { ApiActions } from 'services/api/api-actions';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import { CommonService, ListResults } from 'services/common-service/common-service';
+import { extractUuidObjectType, ResourceObjectType } from "models/resource";
+import { FilterBuilder } from "services/api/filter-builder";
+
+export class ApiClientAuthorizationService extends CommonService<ApiClientAuthorization> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "api_client_authorizations", actions);
+    }
+
+    createCollectionSharingToken(uuid: string, expDate: Date | undefined): Promise<ApiClientAuthorization> {
+        if (extractUuidObjectType(uuid) !== ResourceObjectType.COLLECTION) {
+            throw new Error(`UUID ${uuid} is not a collection`);
+        }
+        const data = {
+            scopes: [
+                `GET /arvados/v1/collections/${uuid}`,
+                `GET /arvados/v1/collections/${uuid}/`,
+                `GET /arvados/v1/keep_services/accessible`,
+            ]
+        }
+        return expDate !== undefined
+            ? this.create({...data, expiresAt: expDate.toUTCString()})
+            : this.create(data);
+    }
+
+    listCollectionSharingTokens(uuid: string): Promise<ListResults<ApiClientAuthorization>> {
+        if (extractUuidObjectType(uuid) !== ResourceObjectType.COLLECTION) {
+            throw new Error(`UUID ${uuid} is not a collection`);
+        }
+        return this.list({
+            filters: new FilterBuilder()
+                .addEqual("scopes", [
+                    `GET /arvados/v1/collections/${uuid}`,
+                    `GET /arvados/v1/collections/${uuid}/`,
+                    "GET /arvados/v1/keep_services/accessible"
+                ]).getFilters()
+        });
+    }
+}
\ No newline at end of file
diff --git a/services/workbench2/src/services/api/api-actions.ts b/services/workbench2/src/services/api/api-actions.ts
new file mode 100644 (file)
index 0000000..00b1822
--- /dev/null
@@ -0,0 +1,11 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type ProgressFn = (id: string, working: boolean) => void;
+export type ErrorFn = (id: string, error: any, showSnackBar?: boolean) => void;
+
+export interface ApiActions {
+    progressFn: ProgressFn;
+    errorFn: ErrorFn;
+}
diff --git a/services/workbench2/src/services/api/filter-builder.test.ts b/services/workbench2/src/services/api/filter-builder.test.ts
new file mode 100644 (file)
index 0000000..a4e2b22
--- /dev/null
@@ -0,0 +1,98 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { FilterBuilder } from "./filter-builder";
+
+describe("FilterBuilder", () => {
+
+    let filters: FilterBuilder;
+
+    beforeEach(() => {
+        filters = new FilterBuilder();
+    });
+
+    it("should add 'equal' rule (string)", () => {
+        expect(
+            filters.addEqual("etag", "etagValue").getFilters()
+        ).toEqual(`["etag","=","etagValue"]`);
+    });
+
+    it("should add 'equal' rule (boolean)", () => {
+        expect(
+            filters.addEqual("is_trashed", true).getFilters()
+        ).toEqual(`["is_trashed","=",true]`);
+    });
+
+    it("should add 'like' rule", () => {
+        expect(
+            filters.addLike("etag", "etagValue").getFilters()
+        ).toEqual(`["etag","like","%etagValue%"]`);
+    });
+
+    it("should add 'ilike' rule", () => {
+        expect(
+            filters.addILike("etag", "etagValue").getFilters()
+        ).toEqual(`["etag","ilike","%etagValue%"]`);
+    });
+
+    it("should add 'contains' rule", () => {
+        expect(
+            filters.addContains("properties.someProp", "someValue").getFilters()
+        ).toEqual(`["properties.someProp","contains","someValue"]`);
+    });
+
+    it("should add 'is_a' rule", () => {
+        expect(
+            filters.addIsA("etag", "etagValue").getFilters()
+        ).toEqual(`["etag","is_a","etagValue"]`);
+    });
+
+    it("should add 'is_a' rule for set", () => {
+        expect(
+            filters.addIsA("etag", ["etagValue1", "etagValue2"]).getFilters()
+        ).toEqual(`["etag","is_a",["etagValue1","etagValue2"]]`);
+    });
+
+    it("should add 'in' rule", () => {
+        expect(
+            filters.addIn("etag", "etagValue").getFilters()
+        ).toEqual(`["etag","in","etagValue"]`);
+    });
+
+    it("should add 'in' rule for set", () => {
+        expect(
+            filters.addIn("etag", ["etagValue1", "etagValue2"]).getFilters()
+        ).toEqual(`["etag","in",["etagValue1","etagValue2"]]`);
+    });
+
+    it("should add 'not in' rule for set", () => {
+        expect(
+            filters.addNotIn("etag", ["etagValue1", "etagValue2"]).getFilters()
+        ).toEqual(`["etag","not in",["etagValue1","etagValue2"]]`);
+    });
+
+    it("should add multiple rules", () => {
+        expect(
+            filters
+                .addIn("etag", ["etagValue1", "etagValue2"])
+                .addEqual("href", "hrefValue")
+                .getFilters()
+        ).toEqual(`["etag","in",["etagValue1","etagValue2"]],["href","=","hrefValue"]`);
+    });
+
+    it("should add attribute prefix", () => {
+        expect(new FilterBuilder()
+            .addIn("etag", ["etagValue1", "etagValue2"], "myPrefix")
+            .getFilters())
+            .toEqual(`["myPrefix.etag","in",["etagValue1","etagValue2"]]`);
+    });
+
+    it('should add full text search', () => {
+        expect(
+            new FilterBuilder()
+                .addFullTextSearch('my custom search')
+                .getFilters()
+        ).toEqual(`["any","ilike","%my%"],["any","ilike","%custom%"],["any","ilike","%search%"]`);
+    });
+});
diff --git a/services/workbench2/src/services/api/filter-builder.ts b/services/workbench2/src/services/api/filter-builder.ts
new file mode 100644 (file)
index 0000000..bb97665
--- /dev/null
@@ -0,0 +1,115 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export function joinFilters(...filters: string[]) {
+    return filters.filter(s => s).join(",");
+}
+
+export class FilterBuilder {
+    constructor(private filters = "") { }
+
+    public addEqual(field: string, value?: string | string[] | boolean | null, resourcePrefix?: string) {
+        return this.addCondition(field, "=", value, "", "", resourcePrefix);
+    }
+
+    public addDistinct(field: string, value?: string | boolean | null, resourcePrefix?: string) {
+        return this.addCondition(field, "!=", value, "", "", resourcePrefix);
+    }
+
+    public addLike(field: string, value?: string, resourcePrefix?: string) {
+        return this.addCondition(field, "like", value, "%", "%", resourcePrefix);
+    }
+
+    public addILike(field: string, value?: string, resourcePrefix?: string) {
+        return this.addCondition(field, "ilike", value, "%", "%", resourcePrefix);
+    }
+
+    public addContains(field: string, value?: string, resourcePrefix?: string) {
+        return this.addCondition(field, "contains", value, "", "", resourcePrefix);
+    }
+
+    public addIsA(field: string, value?: string | string[], resourcePrefix?: string) {
+        return this.addCondition(field, "is_a", value, "", "", resourcePrefix);
+    }
+
+    public addIn(field: string, value?: string | string[], resourcePrefix?: string) {
+        return this.addCondition(field, "in", value, "", "", resourcePrefix);
+    }
+
+    public addNotIn(field: string, value?: string | string[], resourcePrefix?: string) {
+        return this.addCondition(field, "not in", value, "", "", resourcePrefix);
+    }
+
+    public addGt(field: string, value?: string, resourcePrefix?: string) {
+        return this.addCondition(field, ">", value, "", "", resourcePrefix);
+    }
+
+    public addGte(field: string, value?: string, resourcePrefix?: string) {
+        return this.addCondition(field, ">=", value, "", "", resourcePrefix);
+    }
+
+    public addLt(field: string, value?: string, resourcePrefix?: string) {
+        return this.addCondition(field, "<", value, "", "", resourcePrefix);
+    }
+
+    public addLte(field: string, value?: string, resourcePrefix?: string) {
+        return this.addCondition(field, "<=", value, "", "", resourcePrefix);
+    }
+
+    public addExists(value?: string, resourcePrefix?: string) {
+        return this.addCondition("properties", "exists", value, "", "", resourcePrefix);
+    }
+    public addDoesNotExist(field: string, resourcePrefix?: string) {
+        return this.addCondition("properties." + field, "exists", false, "", "", resourcePrefix);
+    }
+
+    public addFullTextSearch(value: string, table?: string) {
+        const regex = /"[^"]*"/;
+        const matches: any[] = [];
+
+        let match = value.match(regex);
+
+        while (match) {
+            value = value.replace(match[0], "");
+            matches.push(match[0].replace(/"/g, ''));
+            match = value.match(regex);
+        }
+
+        let searchIn = 'any';
+        if (table) {
+            searchIn = table + ".any";
+        }
+
+        const terms = value.trim().split(/(\s+)/).concat(matches);
+        terms.forEach(term => {
+            if (term !== " ") {
+                this.addCondition(searchIn, "ilike", term, "%", "%");
+            }
+        });
+        return this;
+    }
+
+    public getFilters() {
+        return this.filters;
+    }
+
+    private addCondition(field: string, cond: string, value?: string | string[] | boolean | null, prefix: string = "", postfix: string = "", resourcePrefix?: string) {
+        if (value !== undefined) {
+            if (typeof value === "string") {
+                value = `"${prefix}${value}${postfix}"`;
+            } else if (Array.isArray(value)) {
+                value = `["${value.join(`","`)}"]`;
+            } else if (value !== null) {
+                value = value ? "true" : "false";
+            }
+
+            const resPrefix = resourcePrefix
+                ? resourcePrefix + "."
+                : "";
+
+            this.filters += `${this.filters ? "," : ""}["${resPrefix}${field}","${cond}",${value}]`;
+        }
+        return this;
+    }
+}
diff --git a/services/workbench2/src/services/api/order-builder.test.ts b/services/workbench2/src/services/api/order-builder.test.ts
new file mode 100644 (file)
index 0000000..496b74a
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { OrderBuilder } from "./order-builder";
+
+describe("OrderBuilder", () => {
+    it("should build correct order query", () => {
+        const order = new OrderBuilder()
+            .addAsc("kind")
+            .addDesc("createdAt")
+            .getOrder();
+        expect(order).toEqual("kind asc,created_at desc");
+    });
+});
diff --git a/services/workbench2/src/services/api/order-builder.ts b/services/workbench2/src/services/api/order-builder.ts
new file mode 100644 (file)
index 0000000..3fc4900
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { snakeCase } from "lodash";
+import { Resource } from "models/resource";
+
+export enum OrderDirection { ASC, DESC }
+
+export class OrderBuilder<T extends Resource = Resource> {
+
+    constructor(private order: string[] = []) {}
+
+    addOrder(direction: OrderDirection, attribute: keyof T, prefix?: string) {
+        this.order.push(`${prefix ? prefix + "." : ""}${snakeCase(attribute.toString())} ${direction === OrderDirection.ASC ? "asc" : "desc"}`);
+        return this;
+    }
+
+    addAsc(attribute: keyof T, prefix?: string) {
+        return this.addOrder(OrderDirection.ASC, attribute, prefix);
+    }
+
+    addDesc(attribute: keyof T, prefix?: string) {
+        return this.addOrder(OrderDirection.DESC, attribute, prefix);
+    }
+
+    getOrder() {
+        return this.order.join(",");
+    }
+}
diff --git a/services/workbench2/src/services/api/url-builder.test.ts b/services/workbench2/src/services/api/url-builder.test.ts
new file mode 100644 (file)
index 0000000..e8da362
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { joinUrls } from "services/api/url-builder";
+
+describe("UrlBuilder", () => {
+    it("should join urls properly 1", () => {
+        expect(joinUrls('http://localhost:3000', '/main')).toEqual('http://localhost:3000/main');
+    });
+    it("should join urls properly 2", () => {
+        expect(joinUrls('http://localhost:3000/', '/main')).toEqual('http://localhost:3000/main');
+    });
+    it("should join urls properly 3", () => {
+        expect(joinUrls('http://localhost:3000//', '/main')).toEqual('http://localhost:3000/main');
+    });
+    it("should join urls properly 4", () => {
+        expect(joinUrls('http://localhost:3000', '//main')).toEqual('http://localhost:3000/main');
+    });
+    it("should join urls properly 5", () => {
+        expect(joinUrls('http://localhost:3000///', 'main')).toEqual('http://localhost:3000/main');
+    });
+    it("should join urls properly 6", () => {
+        expect(joinUrls('http://localhost:3000///', '//main')).toEqual('http://localhost:3000/main');
+    });
+    it("should join urls properly 7", () => {
+        expect(joinUrls(undefined, '//main')).toEqual('/main');
+    });
+    it("should join urls properly 8", () => {
+        expect(joinUrls(undefined, 'main')).toEqual('/main');
+    });
+    it("should join urls properly 9", () => {
+        expect(joinUrls('http://localhost:3000///', undefined)).toEqual('http://localhost:3000');
+    });
+});
diff --git a/services/workbench2/src/services/api/url-builder.ts b/services/workbench2/src/services/api/url-builder.ts
new file mode 100644 (file)
index 0000000..d94aab3
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export class UrlBuilder {
+    private readonly url: string = "";
+    private query: string = "";
+
+    constructor(host: string) {
+        this.url = host;
+    }
+
+    public addParam(param: string, value: string) {
+        if (this.query.length === 0) {
+            this.query += "?";
+        } else {
+            this.query += "&";
+        }
+        this.query += `${param}=${value}`;
+        return this;
+    }
+
+    public get() {
+        return this.url + this.query;
+    }
+}
+
+export function joinUrls(url0?: string, url1?: string) {
+    let u0 = "";
+    if (url0) {
+        let idx0 = url0.length - 1;
+        while (url0[idx0] === '/') { --idx0; }
+        u0 = url0.substring(0, idx0 + 1);
+    }
+    let u1 = "";
+    if (url1) {
+        let idx1 = 0;
+        while (url1[idx1] === '/') { ++idx1; }
+        u1 = url1.substring(idx1);
+    }
+    let url = u0;
+    if (u1.length > 0) {
+        url += '/';
+    }
+    url += u1;
+    return url;
+}
diff --git a/services/workbench2/src/services/auth-service/auth-service.ts b/services/workbench2/src/services/auth-service/auth-service.ts
new file mode 100644 (file)
index 0000000..79a6b7e
--- /dev/null
@@ -0,0 +1,228 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { User, UserPrefs, getUserDisplayName } from 'models/user';
+import { AxiosInstance } from "axios";
+import { ApiActions } from "services/api/api-actions";
+import uuid from "uuid/v4";
+import { Session, SessionStatus } from "models/session";
+import { Config } from "common/config";
+import { uniqBy } from "lodash";
+
+export const TARGET_URL = 'targetURL';
+export const API_TOKEN_KEY = 'apiToken';
+export const USER_EMAIL_KEY = 'userEmail';
+export const USER_FIRST_NAME_KEY = 'userFirstName';
+export const USER_LAST_NAME_KEY = 'userLastName';
+export const USER_UUID_KEY = 'userUuid';
+export const USER_OWNER_UUID_KEY = 'userOwnerUuid';
+export const USER_IS_ADMIN = 'isAdmin';
+export const USER_IS_ACTIVE = 'isActive';
+export const USER_USERNAME = 'username';
+export const USER_PREFS = 'prefs';
+export const HOME_CLUSTER = 'homeCluster';
+export const LOCAL_STORAGE = 'localStorage';
+export const SESSION_STORAGE = 'sessionStorage';
+
+export interface UserDetailsResponse {
+    email: string;
+    first_name: string;
+    last_name: string;
+    uuid: string;
+    owner_uuid: string;
+    is_admin: boolean;
+    is_active: boolean;
+    username: string;
+    prefs: UserPrefs;
+    can_write: boolean;
+    can_manage: boolean;
+}
+
+export class AuthService {
+
+    constructor(
+        protected apiClient: AxiosInstance,
+        protected baseUrl: string,
+        protected actions: ApiActions,
+        protected useSessionStorage: boolean = false) { }
+
+    private getStorage() {
+        if (this.useSessionStorage) {
+            return sessionStorage;
+        }
+        return localStorage;
+    }
+
+    public getStorageType() {
+        if (this.useSessionStorage) {
+            return SESSION_STORAGE;
+        }
+        return LOCAL_STORAGE;
+    }
+
+    public saveApiToken(token: string) {
+        this.removeApiToken();
+        this.getStorage().setItem(API_TOKEN_KEY, token);
+        const sp = token.split('/');
+        if (sp.length === 3) {
+            this.getStorage().setItem(HOME_CLUSTER, sp[1].substring(0, 5));
+        }
+    }
+
+    public setTargetUrl(url: string) {
+        localStorage.setItem(TARGET_URL, url);
+    }
+
+    public removeTargetURL() {
+        localStorage.removeItem(TARGET_URL);
+    }
+
+    public getTargetURL() {
+        return localStorage.getItem(TARGET_URL);
+    }
+
+    public removeApiToken() {
+        localStorage.removeItem(API_TOKEN_KEY);
+        sessionStorage.removeItem(API_TOKEN_KEY);
+    }
+
+    public getApiToken() {
+        return this.getStorage().getItem(API_TOKEN_KEY) || undefined;
+    }
+
+    public getHomeCluster() {
+        return this.getStorage().getItem(HOME_CLUSTER) || undefined;
+    }
+
+    public getApiClient() {
+        return this.apiClient;
+    }
+
+    public removeUser() {
+        [localStorage, sessionStorage].forEach((storage) => {
+            storage.removeItem(USER_EMAIL_KEY);
+            storage.removeItem(USER_FIRST_NAME_KEY);
+            storage.removeItem(USER_LAST_NAME_KEY);
+            storage.removeItem(USER_UUID_KEY);
+            storage.removeItem(USER_OWNER_UUID_KEY);
+            storage.removeItem(USER_IS_ADMIN);
+            storage.removeItem(USER_IS_ACTIVE);
+            storage.removeItem(USER_USERNAME);
+            storage.removeItem(USER_PREFS);
+            storage.removeItem(TARGET_URL);
+        });
+    }
+
+    public login(uuidPrefix: string, homeCluster: string, loginCluster: string, remoteHosts: { [key: string]: string }) {
+        const currentUrl = `${window.location.protocol}//${window.location.host}/token`;
+        const homeClusterHost = remoteHosts[homeCluster];
+        const rd = new URL(window.location.href);
+        this.setTargetUrl(rd.pathname + rd.search);
+        window.location.assign(`https://${homeClusterHost}/login?${(uuidPrefix !== homeCluster && homeCluster !== loginCluster) ? "remote=" + uuidPrefix + "&" : ""}return_to=${currentUrl}`);
+    }
+
+    public logout(expireToken: string, preservePath: boolean) {
+        const fullUrl = new URL(window.location.href);
+        const wbBase = `${fullUrl.protocol}//${fullUrl.host}`;
+        const wbPath = fullUrl.pathname + fullUrl.search;
+        const returnTo = `${wbBase}${preservePath ? wbPath : ''}`
+
+        window.location.assign(`${this.baseUrl || ""}/logout?api_token=${expireToken}&return_to=${returnTo}`);
+    }
+
+    public getUserDetails = (showErrors?: boolean): Promise<User> => {
+        const reqId = uuid();
+        this.actions.progressFn(reqId, true);
+        return this.apiClient
+            .get<UserDetailsResponse>('/users/current')
+            .then(resp => {
+                this.actions.progressFn(reqId, false);
+                const prefs = resp.data.prefs.profile ? resp.data.prefs : { profile: {} };
+                return {
+                    email: resp.data.email,
+                    firstName: resp.data.first_name,
+                    lastName: resp.data.last_name,
+                    uuid: resp.data.uuid,
+                    ownerUuid: resp.data.owner_uuid,
+                    isAdmin: resp.data.is_admin,
+                    isActive: resp.data.is_active,
+                    username: resp.data.username,
+                    canWrite: resp.data.can_write,
+                    canManage: resp.data.can_manage,
+                    prefs
+                };
+            })
+            .catch(e => {
+                this.actions.progressFn(reqId, false);
+                this.actions.errorFn(reqId, e, showErrors);
+                throw e;
+            });
+    }
+
+    public getSessions(): Session[] {
+        try {
+            const sessions = JSON.parse(this.getStorage().getItem("sessions") || '');
+            return sessions;
+        } catch {
+            return [];
+        }
+    }
+
+    public saveSessions(sessions: Session[]) {
+        this.removeSessions();
+        this.getStorage().setItem("sessions", JSON.stringify(sessions));
+    }
+
+    public removeSessions() {
+        localStorage.removeItem("sessions");
+        sessionStorage.removeItem("sessions");
+    }
+
+    public buildSessions(cfg: Config, user?: User) {
+        const currentSession = {
+            clusterId: cfg.uuidPrefix,
+            remoteHost: cfg.rootUrl,
+            baseUrl: cfg.baseUrl,
+            name: user ? getUserDisplayName(user) : '',
+            email: user ? user.email : '',
+            userIsActive: user ? user.isActive : false,
+            token: this.getApiToken(),
+            loggedIn: true,
+            active: true,
+            uuid: user ? user.uuid : '',
+            status: SessionStatus.VALIDATED,
+            apiRevision: cfg.apiRevision,
+        } as Session;
+        const localSessions = this.getSessions().map(s => ({
+            ...s,
+            active: false,
+            status: SessionStatus.INVALIDATED
+        }));
+
+        const cfgSessions = Object.keys(cfg.remoteHosts).map(clusterId => {
+            const remoteHost = cfg.remoteHosts[clusterId];
+            return {
+                clusterId,
+                remoteHost,
+                baseUrl: '',
+                name: '',
+                email: '',
+                token: '',
+                loggedIn: false,
+                active: false,
+                uuid: '',
+                status: SessionStatus.INVALIDATED,
+                apiRevision: 0,
+            } as Session;
+        });
+        const sessions = [currentSession]
+            .concat(cfgSessions)
+            .concat(localSessions)
+            .filter((r: Session) => r.clusterId !== "*");
+
+        const uniqSessions = uniqBy(sessions, 'clusterId');
+
+        return uniqSessions;
+    }
+}
diff --git a/services/workbench2/src/services/authorized-keys-service/authorized-keys-service.ts b/services/workbench2/src/services/authorized-keys-service/authorized-keys-service.ts
new file mode 100644 (file)
index 0000000..ef296e9
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { SshKeyResource } from 'models/ssh-key';
+import { CommonResourceService, CommonResourceServiceError } from 'services/common-service/common-resource-service';
+import { ApiActions } from "services/api/api-actions";
+
+export enum AuthorizedKeysServiceError {
+    UNIQUE_PUBLIC_KEY = 'UniquePublicKey',
+    INVALID_PUBLIC_KEY = 'InvalidPublicKey',
+}
+
+export class AuthorizedKeysService extends CommonResourceService<SshKeyResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "authorized_keys", actions);
+    }
+}
+
+export const getAuthorizedKeysServiceError = (errorResponse: any) => {
+    if ('errors' in errorResponse && 'errorToken' in errorResponse) {
+        const error = errorResponse.errors.join('');
+        switch (true) {
+            case /Public key does not appear to be a valid ssh-rsa or dsa public key/.test(error):
+                return AuthorizedKeysServiceError.INVALID_PUBLIC_KEY;
+            case /Public key already exists in the database, use a different key./.test(error):
+                return AuthorizedKeysServiceError.UNIQUE_PUBLIC_KEY;
+            default:
+                return CommonResourceServiceError.UNKNOWN;
+        }
+    }
+    return CommonResourceServiceError.NONE;
+};
\ No newline at end of file
diff --git a/services/workbench2/src/services/collection-service/collection-service-files-response.test.ts b/services/workbench2/src/services/collection-service/collection-service-files-response.test.ts
new file mode 100644 (file)
index 0000000..a043b7c
--- /dev/null
@@ -0,0 +1,103 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionFile } from 'models/collection-file';
+import { getFileFullPath, extractFilesData } from './collection-service-files-response';
+
+describe('collection-service-files-response', () => {
+
+    describe('extractFilesData', () => {
+        it('should correctly decode URLs & file names', () => {
+            const testCases = [
+                // input URL, input display name, expected URL, expected name
+                ['table%201%202%203', 'table 1 2 3', 'table%201%202%203', 'table 1 2 3'],
+                ['table%25&amp;%3F%2A2', 'table%&amp;?*2', 'table%25&%3F%2A2', 'table%&?*2'],
+                ["G%C3%BCnter%27s%20file.pdf", "Günter&#39;s file.pdf", "G%C3%BCnter%27s%20file.pdf", "Günter's file.pdf"],
+                ['G%25C3%25BCnter%27s%2520file.pdf', 'G%C3%BCnter&#39;s%20file.pdf', "G%25C3%25BCnter%27s%2520file.pdf", "G%C3%BCnter's%20file.pdf"]
+            ];
+
+            testCases.forEach(([inputURL, inputDisplayName, expectedURL, expectedName]) => {
+                // given
+                const collUUID = 'xxxxx-zzzzz-vvvvvvvvvvvvvvv';
+                const xmlData = `
+                <?xml version="1.0" encoding="UTF-8"?>
+                <D:multistatus xmlns:D="DAV:">
+                    <D:response>
+                        <D:href>/c=xxxxx-zzzzz-vvvvvvvvvvvvvvv/</D:href>
+                        <D:propstat>
+                            <D:prop>
+                                <D:resourcetype>
+                                    <D:collection xmlns:D="DAV:"/>
+                                </D:resourcetype>
+                                <D:supportedlock>
+                                    <D:lockentry xmlns:D="DAV:">
+                                        <D:lockscope>
+                                            <D:exclusive/>
+                                        </D:lockscope>
+                                        <D:locktype>
+                                            <D:write/>
+                                        </D:locktype>
+                                    </D:lockentry>
+                                </D:supportedlock>
+                                <D:displayname></D:displayname>
+                                <D:getlastmodified>Fri, 26 Mar 2021 14:44:08 GMT</D:getlastmodified>
+                            </D:prop>
+                            <D:status>HTTP/1.1 200 OK</D:status>
+                        </D:propstat>
+                    </D:response>
+                    <D:response>
+                        <D:href>/c=${collUUID}/${inputURL}</D:href>
+                        <D:propstat>
+                            <D:prop>
+                                <D:resourcetype></D:resourcetype>
+                                <D:getcontenttype>application/pdf</D:getcontenttype>
+                                <D:supportedlock>
+                                    <D:lockentry xmlns:D="DAV:">
+                                        <D:lockscope>
+                                            <D:exclusive/>
+                                        </D:lockscope>
+                                        <D:locktype>
+                                            <D:write/>
+                                        </D:locktype>
+                                    </D:lockentry>
+                                </D:supportedlock>
+                                <D:displayname>${inputDisplayName}</D:displayname>
+                                <D:getcontentlength>3</D:getcontentlength>
+                                <D:getlastmodified>Fri, 26 Mar 2021 14:44:08 GMT</D:getlastmodified>
+                                <D:getetag>"166feb9c9110c008325a59"</D:getetag>
+                            </D:prop>
+                            <D:status>HTTP/1.1 200 OK</D:status>
+                        </D:propstat>
+                    </D:response>
+                </D:multistatus>
+                `;
+                const parser = new DOMParser();
+                const xmlDoc = parser.parseFromString(xmlData, "text/xml");
+
+                // when
+                const result = extractFilesData(xmlDoc);
+
+                // then
+                expect(result).toEqual([{ id: `${collUUID}/${expectedName}`, name: expectedName, path: "", size: 3, type: "file", url: `/c=${collUUID}/${expectedURL}` }]);
+            });
+        });
+    });
+
+    describe('getFileFullPath', () => {
+        it('should encode weird names', async () => {
+            // given
+            const file = {
+                name: '#test',
+                path: 'http://localhost',
+            } as CollectionFile;
+
+            // when
+            const result = getFileFullPath(file);
+
+            // then
+            expect(result).toBe('http://localhost/#test');
+        });
+
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/services/collection-service/collection-service-files-response.ts b/services/workbench2/src/services/collection-service/collection-service-files-response.ts
new file mode 100644 (file)
index 0000000..db56e31
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionDirectory, CollectionFile, CollectionFileType, createCollectionDirectory, createCollectionFile } from "../../models/collection-file";
+import { getTagValue } from "common/xml";
+import { getNodeChildren, Tree, mapTree } from 'models/tree';
+
+export const sortFilesTree = (tree: Tree<CollectionDirectory | CollectionFile>) => {
+    return mapTree<CollectionDirectory | CollectionFile>(node => {
+        const children = getNodeChildren(node.id)(tree);
+
+        children.sort((a, b) =>
+            a.value.type !== b.value.type
+                ? a.value.type === CollectionFileType.DIRECTORY ? -1 : 1
+                : a.value.name.localeCompare(b.value.name)
+        );
+        return { ...node, children: children.map(child => child.id) };
+    })(tree);
+};
+
+export const extractFilesData = (document: Document) => {
+    const collectionUrlPrefix = /\/c=([^/]*)/;
+    return Array
+        .from(document.getElementsByTagName('D:response'))
+        .slice(1) // omit first element which is collection itself
+        .map(element => {
+            const name = getTagValue(element, 'D:displayname', '', true); // skip decoding as value should be already decoded
+            const size = parseInt(getTagValue(element, 'D:getcontentlength', '0', true), 10);
+            const url = getTagValue(element, 'D:href', '', true);
+            const collectionUuidMatch = collectionUrlPrefix.exec(url);
+            const collectionUuid = collectionUuidMatch ? collectionUuidMatch.pop() : '';
+            const pathArray = url.split(`/`);
+            if (!pathArray.pop()) {
+                pathArray.pop();
+            }
+            const directory = pathArray.join('/')
+                .replace(collectionUrlPrefix, '')
+                .replace(/\/\//g, '/');
+
+            const parentPath = directory.replace(/\/$/, '');
+            const data = {
+                url,
+                id: [
+                    collectionUuid ? collectionUuid : '',
+                    directory ? unescape(parentPath) : '',
+                    '/' + name
+                ].join(''),
+                name,
+                path: unescape(parentPath),
+            };
+
+            const result = getTagValue(element, 'D:resourcetype', '')
+                ? createCollectionDirectory(data)
+                : createCollectionFile({ ...data, size });
+
+            return result;
+        });
+};
+
+export const getFileFullPath = ({ name, path }: CollectionFile | CollectionDirectory) => {
+    return `${path}/${name}`;
+};
diff --git a/services/workbench2/src/services/collection-service/collection-service.test.ts b/services/workbench2/src/services/collection-service/collection-service.test.ts
new file mode 100644 (file)
index 0000000..3b4f423
--- /dev/null
@@ -0,0 +1,455 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import axios, { AxiosInstance } from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { snakeCase } from 'lodash';
+import { CollectionResource, defaultCollectionSelectedFields } from 'models/collection';
+import { AuthService } from '../auth-service/auth-service';
+import { CollectionService, emptyCollectionPdh } from './collection-service';
+
+describe('collection-service', () => {
+    let collectionService: CollectionService;
+    let serverApi: AxiosInstance;
+    let axiosMock: MockAdapter;
+    let keepWebdavClient: any;
+    let authService;
+    let actions;
+
+    beforeEach(() => {
+        serverApi = axios.create();
+        axiosMock = new MockAdapter(serverApi);
+        keepWebdavClient = {
+            delete: jest.fn(),
+            upload: jest.fn(),
+            mkdir: jest.fn(),
+        } as any;
+        authService = {} as AuthService;
+        actions = {
+            progressFn: jest.fn(),
+            errorFn: jest.fn(),
+        } as any;
+        collectionService = new CollectionService(serverApi, keepWebdavClient, authService, actions);
+        collectionService.update = jest.fn();
+    });
+
+    describe('get', () => {
+        it('should make a request with default selected fields', async () => {
+            serverApi.get = jest.fn(() => Promise.resolve(
+                { data: { items: [{}] } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            await collectionService.get(uuid);
+            expect(serverApi.get).toHaveBeenCalledWith(
+                `/collections/${uuid}`, {
+                    params: {
+                        select: JSON.stringify(defaultCollectionSelectedFields.map(snakeCase)),
+                    },
+                }
+            );
+        });
+
+        it('should be able to request specific fields', async () => {
+            serverApi.get = jest.fn(() => Promise.resolve(
+                { data: { items: [{}] } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            await collectionService.get(uuid, undefined, ['manifestText']);
+            expect(serverApi.get).toHaveBeenCalledWith(
+                `/collections/${uuid}`, {
+                    params: {
+                        select: `["manifest_text"]`
+                    },
+                }
+            );
+        });
+    });
+
+    describe('update', () => {
+        it('should call put selecting updated fields + others', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const data: Partial<CollectionResource> = {
+                name: 'foo',
+            };
+            const expected = {
+                collection: {
+                    ...data,
+                    preserve_version: true,
+                },
+                select: ['uuid', 'name', 'version', 'modified_at'],
+            }
+            collectionService = new CollectionService(serverApi, keepWebdavClient, authService, actions);
+            await collectionService.update('uuid', data);
+            expect(serverApi.put).toHaveBeenCalledWith('/collections/uuid', expected);
+        });
+    });
+
+    describe('uploadFiles', () => {
+        it('should skip if no files to upload files', async () => {
+            // given
+            const files: File[] = [];
+            const collectionUUID = '';
+
+            // when
+            await collectionService.uploadFiles(collectionUUID, files);
+
+            // then
+            expect(keepWebdavClient.upload).not.toHaveBeenCalled();
+        });
+
+        it('should upload files', async () => {
+            // given
+            const files: File[] = [{name: 'test-file1'} as File];
+            const collectionUUID = 'zzzzz-4zz18-0123456789abcde';
+
+            // when
+            await collectionService.uploadFiles(collectionUUID, files);
+
+            // then
+            expect(keepWebdavClient.upload).toHaveBeenCalledTimes(1);
+            expect(keepWebdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789abcde/test-file1");
+        });
+
+        it('should upload files with custom uplaod target', async () => {
+            // given
+            const files: File[] = [{name: 'test-file1'} as File];
+            const collectionUUID = 'zzzzz-4zz18-0123456789abcde';
+            const customTarget = 'zzzzz-4zz18-0123456789adddd/test-path/'
+
+            // when
+            await collectionService.uploadFiles(collectionUUID, files, undefined, customTarget);
+
+            // then
+            expect(keepWebdavClient.upload).toHaveBeenCalledTimes(1);
+            expect(keepWebdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789adddd/test-path/test-file1");
+        });
+    });
+
+    describe('deleteFiles', () => {
+        it('should remove no files', async () => {
+            // given
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const filePaths: string[] = [];
+            const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
+
+            // when
+            await collectionService.deleteFiles(collectionUUID, filePaths);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${collectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {},
+                }
+            );
+        });
+
+        it('should remove only root files', async () => {
+            // given
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const filePaths: string[] = ['/root/1', '/root/1/100', '/root/1/100/test.txt', '/root/2', '/root/2/200', '/root/3/300/test.txt'];
+            const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
+
+            // when
+            await collectionService.deleteFiles(collectionUUID, filePaths);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${collectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        '/root/3/300/test.txt': '',
+                        '/root/2': '',
+                        '/root/1': '',
+                    },
+                }
+            );
+        });
+
+        it('should batch remove files', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            // given
+            const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt'];
+            const collectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+
+            // when
+            await collectionService.deleteFiles(collectionUUID, filePaths);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${collectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        '/root/1': '',
+                        '/secondFile': '',
+                        '/barefile.txt': '',
+                    },
+                }
+            );
+        });
+    });
+
+    describe('renameFile', () => {
+        it('should rename file', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const collectionUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const collectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+            const oldPath = '/old/path';
+            const newPath = '/new/filename';
+
+            await collectionService.renameFile(collectionUuid, collectionPdh, oldPath, newPath);
+
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${collectionUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [newPath]: `${collectionPdh}${oldPath}`,
+                        [oldPath]: '',
+                    },
+                }
+            );
+        });
+    });
+
+    describe('copyFiles', () => {
+        it('should batch copy files', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt'];
+            const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            // when
+            await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/1`]: `${sourcePdh}/root/1`,
+                        [`${destinationPath}/secondFile`]: `${sourcePdh}/secondFile`,
+                        [`${destinationPath}/barefile.txt`]: `${sourcePdh}/barefile.txt`,
+                    },
+                }
+            );
+        });
+
+        it('should copy files from rooth', async () => {
+            // Test copying from root paths
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const filePaths: string[] = ['/'];
+            const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath);
+
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}`]: `${sourcePdh}/`,
+                    },
+                }
+            );
+        });
+
+        it('should copy files to root path', async () => {
+            // Test copying to root paths
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const filePaths: string[] = ['/'];
+            const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/';
+
+            await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath);
+
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        "/": `${sourcePdh}/`,
+                    },
+                }
+            );
+        });
+    });
+
+    describe('moveFiles', () => {
+        it('should batch move files', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            // given
+            const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt'];
+            const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+            const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            // when
+            await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(2);
+            // Verify copy
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/rootFile`]: `${srcCollectionPdh}/rootFile`,
+                        [`${destinationPath}/secondFile`]: `${srcCollectionPdh}/secondFile`,
+                        [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`,
+                        [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`,
+                    },
+                }
+            );
+            // Verify delete
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${srcCollectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        "/rootFile": "",
+                        "/secondFile": "",
+                        "/subpath/subfile": "",
+                        "/barefile.txt": "",
+                    },
+                }
+            );
+        });
+
+        it('should batch move files within collection', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            // given
+            const filePaths: string[] = ['/one', '/two', '/subpath/subfile', 'barefile.txt'];
+            const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+            const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationPath = '/destinationPath';
+
+            // when
+            await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: srcCollectionUUID}, destinationPath);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            // Verify copy
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${srcCollectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/one`]: `${srcCollectionPdh}/one`,
+                        ['/one']: '',
+                        [`${destinationPath}/two`]: `${srcCollectionPdh}/two`,
+                        ['/two']: '',
+                        [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`,
+                        ['/subpath/subfile']: '',
+                        [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`,
+                        ['/barefile.txt']: '',
+                    },
+                }
+            );
+        });
+
+        it('should abort batch move when copy fails', async () => {
+            // Simulate failure to copy
+            serverApi.put = jest.fn(() => Promise.reject({
+                data: {},
+                response: {
+                    "errors": ["error getting snapshot of \"rootFile\" from \"8cd9ce1dfa21c635b620b1bfee7aaa08+180\": file does not exist"]
+                }
+            }));
+            // given
+            const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt'];
+            const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+            const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            // when
+            try {
+                await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath);
+            } catch {}
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            // Verify copy
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/rootFile`]: `${srcCollectionPdh}/rootFile`,
+                        [`${destinationPath}/secondFile`]: `${srcCollectionPdh}/secondFile`,
+                        [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`,
+                        [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`,
+                    },
+                }
+            );
+        });
+    });
+
+    describe('createDirectory', () => {
+        it('creates empty directory', async () => {
+            // given
+            const directoryNames = [
+                {in: 'newDir', out: 'newDir'},
+                {in: '/fooDir', out: 'fooDir'},
+                {in: '/anotherPath/', out: 'anotherPath'},
+                {in: 'trailingSlash/', out: 'trailingSlash'},
+            ];
+            const collectionUuid = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
+
+            for (var i = 0; i < directoryNames.length; i++) {
+                serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+                // when
+                await collectionService.createDirectory(collectionUuid, directoryNames[i].in);
+                // then
+                expect(serverApi.put).toHaveBeenCalledTimes(1);
+                expect(serverApi.put).toHaveBeenCalledWith(
+                    `/collections/${collectionUuid}`, {
+                        collection: {
+                            preserve_version: true
+                        },
+                        replace_files: {
+                            ["/" + directoryNames[i].out]: emptyCollectionPdh,
+                        },
+                    }
+                );
+            }
+        });
+    });
+
+});
diff --git a/services/workbench2/src/services/collection-service/collection-service.ts b/services/workbench2/src/services/collection-service/collection-service.ts
new file mode 100644 (file)
index 0000000..12d31d1
--- /dev/null
@@ -0,0 +1,259 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionResource, defaultCollectionSelectedFields } from "models/collection";
+import { AxiosInstance, AxiosResponse } from "axios";
+import { CollectionFile, CollectionDirectory } from "models/collection-file";
+import { WebDAV } from "common/webdav";
+import { AuthService } from "../auth-service/auth-service";
+import { extractFilesData } from "./collection-service-files-response";
+import { TrashableResourceService } from "services/common-service/trashable-resource-service";
+import { ApiActions } from "services/api/api-actions";
+import { Session } from "models/session";
+import { CommonService } from "services/common-service/common-service";
+import { snakeCase } from "lodash";
+import { CommonResourceServiceError } from "services/common-service/common-resource-service";
+
+export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
+type CollectionPartialUpdateOrCreate =
+    | (Partial<CollectionResource> & Pick<CollectionResource, "uuid">)
+    | (Partial<CollectionResource> & Pick<CollectionResource, "ownerUuid">);
+
+type ReplaceFilesPayload = {
+    collection: Partial<CollectionResource>;
+    replace_files: {[key: string]: string};
+}
+
+export const emptyCollectionPdh = "d41d8cd98f00b204e9800998ecf8427e+0";
+export const SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE = "Source and destination cannot be the same";
+
+export class CollectionService extends TrashableResourceService<CollectionResource> {
+    constructor(serverApi: AxiosInstance, private keepWebdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
+        super(serverApi, "collections", actions, [
+            "fileCount",
+            "fileSizeTotal",
+            "replicationConfirmed",
+            "replicationConfirmedAt",
+            "storageClassesConfirmed",
+            "storageClassesConfirmedAt",
+            "unsignedManifestText",
+            "version",
+        ]);
+    }
+
+    async get(uuid: string, showErrors?: boolean, select?: string[], session?: Session) {
+        super.validateUuid(uuid);
+        const selectParam = select || defaultCollectionSelectedFields;
+        return super.get(uuid, showErrors, selectParam, session);
+    }
+
+    create(data?: Partial<CollectionResource>, showErrors?: boolean) {
+        return super.create({ ...data, preserveVersion: true }, showErrors);
+    }
+
+    update(uuid: string, data: Partial<CollectionResource>, showErrors?: boolean) {
+        const select = [...Object.keys(data), "version", "modifiedAt"];
+        return super.update(uuid, { ...data, preserveVersion: true }, showErrors, select);
+    }
+
+    async files(uuid: string) {
+        try {
+            const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
+            if (request.responseXML != null) {
+                return extractFilesData(request.responseXML);
+            }
+        } catch (e) {
+            return Promise.reject(e);
+        }
+        return Promise.reject();
+    }
+
+    private combineFilePath(parts: string[]) {
+        return parts.reduce((path, part) => {
+            // Trim leading and trailing slashes
+            const trimmedPart = part.split("/").filter(Boolean).join("/");
+            if (trimmedPart.length) {
+                const separator = path.endsWith("/") ? "" : "/";
+                return `${path}${separator}${trimmedPart}`;
+            } else {
+                return path;
+            }
+        }, "/");
+    }
+
+    private replaceFiles(data: CollectionPartialUpdateOrCreate, fileMap: {}, showErrors?: boolean) {
+        const payload: ReplaceFilesPayload = {
+            collection: {
+                preserve_version: true,
+                ...CommonService.mapKeys(snakeCase)(data),
+                // Don't send uuid in payload when creating
+                uuid: undefined,
+            },
+            replace_files: fileMap,
+        };
+        if (data.uuid) {
+            return CommonService.defaultResponse(
+                this.serverApi.put<ReplaceFilesPayload, AxiosResponse<CollectionResource>>(`/${this.resourceType}/${data.uuid}`, payload),
+                this.actions,
+                true, // mapKeys
+                showErrors
+            );
+        } else {
+            return CommonService.defaultResponse(
+                this.serverApi.post<ReplaceFilesPayload, AxiosResponse<CollectionResource>>(`/${this.resourceType}`, payload),
+                this.actions,
+                true, // mapKeys
+                showErrors
+            );
+        }
+    }
+
+    async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = "") {
+        if (collectionUuid === "" || files.length === 0) {
+            return;
+        }
+        // files have to be uploaded sequentially
+        for (let idx = 0; idx < files.length; idx++) {
+            await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation);
+        }
+        await this.update(collectionUuid, { preserveVersion: true });
+    }
+
+    async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
+        return this.replaceFiles(
+            { uuid: collectionUuid },
+            {
+                [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
+                [this.combineFilePath([oldPath])]: "",
+            }
+        );
+    }
+
+    extendFileURL = (file: CollectionDirectory | CollectionFile) => {
+        const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith("/")
+            ? this.keepWebdavClient.getBaseUrl().slice(0, -1)
+            : this.keepWebdavClient.getBaseUrl();
+        const apiToken = this.authService.getApiToken();
+        const encodedApiToken = apiToken ? encodeURI(apiToken) : "";
+        const userApiToken = `/t=${encodedApiToken}/`;
+        const splittedPrevFileUrl = file.url.split("/");
+        const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join("/")}`;
+        return {
+            ...file,
+            url,
+        };
+    };
+
+    async getFileContents(file: CollectionFile) {
+        return (await this.keepWebdavClient.get(`c=${file.id}`)).response;
+    }
+
+    private async uploadFile(
+        collectionUuid: string,
+        file: File,
+        fileId: number,
+        onProgress: UploadProgress = () => {
+            return;
+        },
+        targetLocation: string = ""
+    ) {
+        const fileURL = `c=${targetLocation !== "" ? targetLocation : collectionUuid}/${file.name}`.replace("//", "/");
+        const requestConfig = {
+            headers: {
+                "Content-Type": "text/octet-stream",
+            },
+            onUploadProgress: (e: ProgressEvent) => {
+                onProgress(fileId, e.loaded, e.total, Date.now());
+            },
+        };
+        return this.keepWebdavClient.upload(fileURL, [file], requestConfig);
+    }
+
+    deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) {
+        const optimizedFiles = files
+            .sort((a, b) => a.length - b.length)
+            .reduce((acc, currentPath) => {
+                const parentPathFound = acc.find(parentPath => currentPath.indexOf(`${parentPath}/`) > -1);
+
+                if (!parentPathFound) {
+                    return [...acc, currentPath];
+                }
+
+                return acc;
+            }, []);
+
+        const fileMap = optimizedFiles.reduce((obj, filePath) => {
+            return {
+                ...obj,
+                [this.combineFilePath([filePath])]: "",
+            };
+        }, {});
+
+        return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
+    }
+
+    copyFiles(
+        sourcePdh: string,
+        files: string[],
+        destinationCollection: CollectionPartialUpdateOrCreate,
+        destinationPath: string,
+        showErrors?: boolean
+    ) {
+        const fileMap = files.reduce((obj, sourceFile) => {
+            const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
+            return {
+                ...obj,
+                [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
+            };
+        }, {});
+
+        return this.replaceFiles(destinationCollection, fileMap, showErrors);
+    }
+
+    moveFiles(
+        sourceUuid: string,
+        sourcePdh: string,
+        files: string[],
+        destinationCollection: CollectionPartialUpdateOrCreate,
+        destinationPath: string,
+        showErrors?: boolean
+    ) {
+        if (sourceUuid === destinationCollection.uuid) {
+            let errors: CommonResourceServiceError[] = [];
+            const fileMap = files.reduce((obj, sourceFile) => {
+                const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
+                const fileDestinationPath = this.combineFilePath([destinationPath, fileBasename]);
+                const fileSourcePath = this.combineFilePath([sourceFile]);
+                const fileSourceUri = `${sourcePdh}${fileSourcePath}`;
+
+                if (fileDestinationPath !== fileSourcePath) {
+                    return {
+                        ...obj,
+                        [fileDestinationPath]: fileSourceUri,
+                        [fileSourcePath]: "",
+                    };
+                } else {
+                    errors.push(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME);
+                    return obj;
+                }
+            }, {});
+
+            if (errors.length === 0) {
+                return this.replaceFiles({ uuid: sourceUuid }, fileMap, showErrors);
+            } else {
+                return Promise.reject({ errors });
+            }
+        } else {
+            return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors).then(() => {
+                return this.deleteFiles(sourceUuid, files, showErrors);
+            });
+        }
+    }
+
+    createDirectory(collectionUuid: string, path: string, showErrors?: boolean) {
+        const fileMap = { [this.combineFilePath([path])]: emptyCollectionPdh };
+
+        return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
+    }
+}
diff --git a/services/workbench2/src/services/common-service/common-resource-service.test.ts b/services/workbench2/src/services/common-service/common-resource-service.test.ts
new file mode 100644 (file)
index 0000000..7f47f20
--- /dev/null
@@ -0,0 +1,155 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommonResourceService } from "./common-resource-service";
+import axios, { AxiosInstance } from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { Resource } from "models/resource";
+import { ApiActions } from "services/api/api-actions";
+
+const actions: ApiActions = {
+    progressFn: (id: string, working: boolean) => {},
+    errorFn: (id: string, message: string) => {}
+};
+
+export const mockResourceService = <R extends Resource, C extends CommonResourceService<R>>(
+    Service: new (client: AxiosInstance, actions: ApiActions) => C) => {
+        const axiosInstance = axios.create();
+        const service = new Service(axiosInstance, actions);
+        Object.keys(service).map(key => service[key] = jest.fn());
+        return service;
+    };
+
+describe("CommonResourceService", () => {
+    let axiosInstance: AxiosInstance;
+    let axiosMock: MockAdapter;
+
+    beforeEach(() => {
+        axiosInstance = axios.create();
+        axiosMock = new MockAdapter(axiosInstance);
+    });
+
+    it("#create", async () => {
+        axiosMock
+            .onPost("/resources")
+            .reply(200, { owner_uuid: "ownerUuidValue" });
+
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
+        const resource = await commonResourceService.create({ ownerUuid: "ownerUuidValue" });
+        expect(resource).toEqual({ ownerUuid: "ownerUuidValue" });
+    });
+
+    it("#create maps request params to snake case", async () => {
+        axiosInstance.post = jest.fn(() => Promise.resolve({data: {}}));
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
+        await commonResourceService.create({ ownerUuid: "ownerUuidValue" });
+        expect(axiosInstance.post).toHaveBeenCalledWith("/resources", {resource: {owner_uuid: "ownerUuidValue"}});
+    });
+
+    it("#create ignores fields listed as readonly", async () => {
+        axiosInstance.post = jest.fn(() => Promise.resolve({data: {}}));
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
+        // UUID fields are read-only on all resources.
+        await commonResourceService.create({ uuid: "this should be ignored", ownerUuid: "ownerUuidValue" });
+        expect(axiosInstance.post).toHaveBeenCalledWith("/resources", {resource: {owner_uuid: "ownerUuidValue"}});
+    });
+
+    it("#update ignores fields listed as readonly", async () => {
+        axiosInstance.put = jest.fn(() => Promise.resolve({data: {}}));
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
+        // UUID fields are read-only on all resources.
+        await commonResourceService.update('resource-uuid', { uuid: "this should be ignored", ownerUuid: "ownerUuidValue" });
+        expect(axiosInstance.put).toHaveBeenCalledWith("/resources/resource-uuid", {resource:  {owner_uuid: "ownerUuidValue"}});
+    });
+
+    it("#delete", async () => {
+        axiosMock
+            .onDelete("/resources/uuid")
+            .reply(200, { deleted_at: "now" });
+
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
+        const resource = await commonResourceService.delete("uuid");
+        expect(resource).toEqual({ deletedAt: "now" });
+    });
+
+    it("#get", async () => {
+        axiosMock
+            .onGet("/resources/uuid")
+            .reply(200, {
+                modified_at: "now",
+                properties: {
+                    responsible_owner_uuid: "another_owner"
+                }
+            });
+
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
+        const resource = await commonResourceService.get("uuid");
+        // Only first level keys are mapped to camel case
+        expect(resource).toEqual({
+            modifiedAt: "now",
+            properties: {
+                responsible_owner_uuid: "another_owner"
+            }
+        });
+    });
+
+    it("#list", async () => {
+        axiosMock
+            .onGet("/resources")
+            .reply(200, {
+                kind: "kind",
+                offset: 2,
+                limit: 10,
+                items: [{
+                    modified_at: "now",
+                    properties: {
+                        is_active: true
+                    }
+                }],
+                items_available: 20
+            });
+
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
+        const resource = await commonResourceService.list({ limit: 10, offset: 1 });
+        // First level keys are mapped to camel case inside "items" arrays
+        expect(resource).toEqual({
+            kind: "kind",
+            offset: 2,
+            limit: 10,
+            items: [{
+                modifiedAt: "now",
+                properties: {
+                    is_active: true
+                }
+            }],
+            itemsAvailable: 20
+        });
+    });
+
+    it("#list using POST when query string is too big", async () => {
+        axiosMock
+            .onAny("/resources")
+            .reply(200);
+        const tooBig = 'x'.repeat(1500);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
+        await commonResourceService.list({ filters: tooBig });
+        expect(axiosMock.history.get.length).toBe(0);
+        expect(axiosMock.history.post.length).toBe(1);
+        const postParams = new URLSearchParams(axiosMock.history.post[0].data);
+        expect(postParams.get('filters')).toBe(`[${tooBig}]`);
+        expect(postParams.get('_method')).toBe('GET');
+    });
+
+    it("#list using GET when query string is not too big", async () => {
+        axiosMock
+            .onAny("/resources")
+            .reply(200);
+        const notTooBig = 'x'.repeat(1480);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
+        await commonResourceService.list({ filters: notTooBig });
+        expect(axiosMock.history.post.length).toBe(0);
+        expect(axiosMock.history.get.length).toBe(1);
+        expect(axiosMock.history.get[0].params.filters).toBe(`[${notTooBig}]`);
+    });
+});
diff --git a/services/workbench2/src/services/common-service/common-resource-service.ts b/services/workbench2/src/services/common-service/common-resource-service.ts
new file mode 100644 (file)
index 0000000..907f008
--- /dev/null
@@ -0,0 +1,84 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { snakeCase } from "lodash";
+import { Resource } from "models/resource";
+import { ApiActions } from "services/api/api-actions";
+import { CommonService } from "services/common-service/common-service";
+
+export enum CommonResourceServiceError {
+    UNIQUE_NAME_VIOLATION = 'UniqueNameViolation',
+    OWNERSHIP_CYCLE = 'OwnershipCycle',
+    MODIFYING_CONTAINER_REQUEST_FINAL_STATE = 'ModifyingContainerRequestFinalState',
+    NAME_HAS_ALREADY_BEEN_TAKEN = 'NameHasAlreadyBeenTaken',
+    PERMISSION_ERROR_FORBIDDEN = 'PermissionErrorForbidden',
+    SOURCE_DESTINATION_CANNOT_BE_SAME = 'SourceDestinationCannotBeSame',
+    UNKNOWN = 'Unknown',
+    NONE = 'None'
+}
+
+export class CommonResourceService<T extends Resource> extends CommonService<T> {
+    constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions, readOnlyFields: string[] = []) {
+        super(serverApi, resourceType, actions, readOnlyFields.concat([
+            'uuid',
+            'etag',
+            'kind',
+            'canWrite',
+            'canManage',
+            'createdAt',
+            'modifiedAt',
+            'modifiedByClientUuid',
+            'modifiedByUserUuid'
+        ]));
+    }
+
+    create(data?: Partial<T>, showErrors?: boolean) {
+        let payload: any;
+        if (data !== undefined) {
+            this.readOnlyFields.forEach(field => delete data[field]);
+            payload = {
+                [this.resourceType.slice(0, -1)]: CommonService.mapKeys(snakeCase)(data),
+            };
+        }
+        return super.create(payload, showErrors);
+    }
+
+    update(uuid: string, data: Partial<T>, showErrors?: boolean, select?: string[]) {
+        let payload: any;
+        if (data !== undefined) {
+            this.readOnlyFields.forEach(field => delete data[field]);
+            payload = {
+                [this.resourceType.slice(0, -1)]: CommonService.mapKeys(snakeCase)(data),
+            };
+            if (select !== undefined && select.length > 0) {
+                payload.select = ['uuid', ...select.map(field => snakeCase(field))];
+            };
+        }
+        return super.update(uuid, payload, showErrors);
+    }
+}
+
+export const getCommonResourceServiceError = (errorResponse: any) => {
+    if (errorResponse && 'errors' in errorResponse) {
+        const error = errorResponse.errors.join('');
+        switch (true) {
+            case /UniqueViolation/.test(error):
+                return CommonResourceServiceError.UNIQUE_NAME_VIOLATION;
+            case /ownership cycle/.test(error):
+                return CommonResourceServiceError.OWNERSHIP_CYCLE;
+            case /Mounts cannot be modified in state 'Final'/.test(error):
+                return CommonResourceServiceError.MODIFYING_CONTAINER_REQUEST_FINAL_STATE;
+            case /Name has already been taken/.test(error):
+                return CommonResourceServiceError.NAME_HAS_ALREADY_BEEN_TAKEN;
+            case /403 Forbidden/.test(error):
+                return CommonResourceServiceError.PERMISSION_ERROR_FORBIDDEN;
+            case new RegExp(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME).test(error):
+                return CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME;
+            default:
+                return CommonResourceServiceError.UNKNOWN;
+        }
+    }
+    return CommonResourceServiceError.NONE;
+};
diff --git a/services/workbench2/src/services/common-service/common-service.test.ts b/services/workbench2/src/services/common-service/common-service.test.ts
new file mode 100644 (file)
index 0000000..bfb5094
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import axios, { AxiosInstance } from "axios";
+import { ApiActions } from "services/api/api-actions";
+import { CommonService } from "./common-service";
+
+const actions: ApiActions = {
+    progressFn: (id: string, working: boolean) => {},
+    errorFn: (id: string, message: string) => {}
+};
+
+describe("CommonService", () => {
+    let commonService: CommonService<any>;
+
+    beforeEach(() => {
+        commonService = new CommonService<any>({} as AxiosInstance, "resource", actions);
+    });
+
+    it("throws an exception when passing uuid as empty string to get()", () => {
+        expect(() => commonService.get("")).toThrowError("UUID cannot be empty string");
+    });
+
+    it("throws an exception when passing uuid as empty string to update()", () => {
+        expect(() => commonService.update("", {})).toThrowError("UUID cannot be empty string");
+    });
+
+    it("throws an exception when passing uuid as empty string to delete()", () => {
+        expect(() => commonService.delete("")).toThrowError("UUID cannot be empty string");
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/services/common-service/common-service.ts b/services/workbench2/src/services/common-service/common-service.ts
new file mode 100644 (file)
index 0000000..8e9fe63
--- /dev/null
@@ -0,0 +1,192 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { camelCase, isPlainObject, isArray, snakeCase } from "lodash";
+import { AxiosInstance, AxiosPromise, AxiosRequestConfig } from "axios";
+import uuid from "uuid/v4";
+import { ApiActions } from "services/api/api-actions";
+import QueryString from "query-string";
+import { Session } from "models/session";
+
+interface Errors {
+    status: number;
+    errors: string[];
+    errorToken: string;
+}
+
+export interface ListArguments {
+    limit?: number;
+    offset?: number;
+    filters?: string;
+    order?: string;
+    select?: string[];
+    distinct?: boolean;
+    count?: string;
+    includeOldVersions?: boolean;
+}
+
+export interface ListResults<T> {
+    clusterId?: string;
+    kind: string;
+    offset: number;
+    limit: number;
+    items: T[];
+    itemsAvailable: number;
+}
+
+export class CommonService<T> {
+    protected serverApi: AxiosInstance;
+    protected resourceType: string;
+    protected actions: ApiActions;
+    protected readOnlyFields: string[];
+
+    constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions, readOnlyFields: string[] = []) {
+        this.serverApi = serverApi;
+        this.resourceType = resourceType;
+        this.actions = actions;
+        this.readOnlyFields = readOnlyFields;
+    }
+
+    static mapResponseKeys = (response: { data: any }) =>
+        CommonService.mapKeys(camelCase)(response.data)
+
+    static mapKeys = (mapFn: (key: string) => string) =>
+        (value: any): any => {
+            switch (true) {
+                case isPlainObject(value):
+                    return Object
+                        .keys(value)
+                        .map(key => [key, mapFn(key)])
+                        .reduce((newValue, [key, newKey]) => ({
+                            ...newValue,
+                            [newKey]: (key === 'items') ? CommonService.mapKeys(mapFn)(value[key]) : value[key]
+                        }), {});
+                case isArray(value):
+                    return value.map(CommonService.mapKeys(mapFn));
+                default:
+                    return value;
+            }
+        }
+
+    protected validateUuid(uuid: string) {
+        if (uuid === "") {
+            throw new Error('UUID cannot be empty string');
+        }
+    }
+
+    static defaultResponse<R>(promise: AxiosPromise<R>, actions: ApiActions, mapKeys = true, showErrors = true): Promise<R> {
+        const reqId = uuid();
+        actions.progressFn(reqId, true);
+        return promise
+            .then(data => {
+                actions.progressFn(reqId, false);
+                return data;
+            })
+            .then((response: { data: any }) => {
+                return mapKeys ? CommonService.mapResponseKeys(response) : response.data;
+            })
+            .catch(({ response }) => {
+                if (response) {
+                    actions.progressFn(reqId, false);
+                    const errors = CommonService.mapResponseKeys(response) as Errors;
+                    errors.status = response.status;
+                    actions.errorFn(reqId, errors, showErrors);
+                    throw errors;
+                }
+            });
+    }
+
+    create(data?: Partial<T>, showErrors?: boolean) {
+        return CommonService.defaultResponse(
+            this.serverApi
+                .post<T>(`/${this.resourceType}`, data && CommonService.mapKeys(snakeCase)(data)),
+            this.actions,
+            true, // mapKeys
+            showErrors
+        );
+    }
+
+    delete(uuid: string, showErrors?: boolean): Promise<T> {
+        this.validateUuid(uuid);
+        return CommonService.defaultResponse(
+            this.serverApi
+                .delete(`/${this.resourceType}/${uuid}`),
+            this.actions,
+            true, // mapKeys
+            showErrors
+        );
+    }
+
+    get(uuid: string, showErrors?: boolean, select?: string[], session?: Session) {
+        this.validateUuid(uuid);
+
+        const cfg: AxiosRequestConfig = {
+            params: {
+                select: select
+                    ? `[${select.map(snakeCase).map(s => `"${s}"`).join(',')}]`
+                    : undefined
+            }
+        };
+        if (session) {
+            cfg.baseURL = session.baseUrl;
+            cfg.headers = { 'Authorization': 'Bearer ' + session.token };
+        }
+
+        return CommonService.defaultResponse(
+            this.serverApi
+                .get<T>(`/${this.resourceType}/${uuid}`, cfg),
+            this.actions,
+            true, // mapKeys
+            showErrors
+        );
+    }
+
+    list(args: ListArguments = {}, showErrors?: boolean): Promise<ListResults<T>> {
+        const { filters, select, ...other } = args;
+        const params = {
+            ...CommonService.mapKeys(snakeCase)(other),
+            filters: filters ? `[${filters}]` : undefined,
+            select: select
+                ? `[${select.map(snakeCase).map(s => `"${s}"`).join(', ')}]`
+                : undefined
+        };
+
+        if (QueryString.stringify(params).length <= 1500) {
+            return CommonService.defaultResponse(
+                this.serverApi.get(`/${this.resourceType}`, { params }),
+                this.actions,
+                true,
+                showErrors
+            );
+        } else {
+            // Using the POST special case to avoid URI length 414 errors.
+            // We must use urlencoded post body since api doesn't support form data
+            // const formData = new FormData();
+            const formData = new URLSearchParams();
+            formData.append("_method", "GET");
+            Object.keys(params).forEach(key => {
+                if (params[key] !== undefined) {
+                    formData.append(key, params[key]);
+                }
+            });
+            return CommonService.defaultResponse(
+                this.serverApi.post(`/${this.resourceType}`, formData, {}),
+                this.actions,
+                true,
+                showErrors
+            );
+        }
+    }
+
+    update(uuid: string, data: Partial<T>, showErrors?: boolean) {
+        this.validateUuid(uuid);
+        return CommonService.defaultResponse(
+            this.serverApi
+                .put<T>(`/${this.resourceType}/${uuid}`, data && CommonService.mapKeys(snakeCase)(data)),
+            this.actions,
+            undefined, // mapKeys
+            showErrors
+        );
+    }
+}
diff --git a/services/workbench2/src/services/common-service/trashable-resource-service.ts b/services/workbench2/src/services/common-service/trashable-resource-service.ts
new file mode 100644 (file)
index 0000000..5e4704b
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { snakeCase } from "lodash";
+import { AxiosInstance } from "axios";
+import { TrashableResource } from "models/resource";
+import { CommonResourceService } from "services/common-service/common-resource-service";
+import { ApiActions } from "services/api/api-actions";
+
+export class TrashableResourceService<T extends TrashableResource> extends CommonResourceService<T> {
+    constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions, readOnlyFields: string[] = []) {
+        super(serverApi, resourceType, actions, readOnlyFields);
+    }
+
+    trash(uuid: string): Promise<T> {
+        return CommonResourceService.defaultResponse(this.serverApi.post(this.resourceType + `/${uuid}/trash`), this.actions);
+    }
+
+    untrash(uuid: string): Promise<T> {
+        const params = {
+            ensure_unique_name: true,
+        };
+        return CommonResourceService.defaultResponse(
+            this.serverApi.post(this.resourceType + `/${uuid}/untrash`, {
+                params: CommonResourceService.mapKeys(snakeCase)(params),
+            }),
+            this.actions,
+            undefined,
+            false
+        );
+    }
+}
diff --git a/services/workbench2/src/services/container-request-service/container-request-service.ts b/services/workbench2/src/services/container-request-service/container-request-service.ts
new file mode 100644 (file)
index 0000000..0a7a217
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommonResourceService } from "services/common-service/common-resource-service";
+import { AxiosInstance } from "axios";
+import { ContainerRequestResource } from 'models/container-request';
+import { ApiActions } from "services/api/api-actions";
+
+export class ContainerRequestService extends CommonResourceService<ContainerRequestResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "container_requests", actions);
+    }
+}
diff --git a/services/workbench2/src/services/container-service/container-service.ts b/services/workbench2/src/services/container-service/container-service.ts
new file mode 100644 (file)
index 0000000..a3f3b0f
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommonResourceService } from "services/common-service/common-resource-service";
+import { AxiosInstance } from "axios";
+import { ContainerResource } from 'models/container';
+import { ApiActions } from "services/api/api-actions";
+
+export class ContainerService extends CommonResourceService<ContainerResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "containers", actions);
+    }
+}
diff --git a/services/workbench2/src/services/favorite-service/favorite-service.test.ts b/services/workbench2/src/services/favorite-service/favorite-service.test.ts
new file mode 100644 (file)
index 0000000..881a518
--- /dev/null
@@ -0,0 +1,90 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkService } from "../link-service/link-service";
+import { GroupsService } from "../groups-service/groups-service";
+import { FavoriteService } from "./favorite-service";
+import { LinkClass } from "models/link";
+import { mockResourceService } from "services/common-service/common-resource-service.test";
+import { FilterBuilder } from "services/api/filter-builder";
+
+describe("FavoriteService", () => {
+
+    let linkService: LinkService;
+    let groupService: GroupsService;
+
+    beforeEach(() => {
+        linkService = mockResourceService(LinkService);
+        groupService = mockResourceService(GroupsService);
+    });
+
+    it("marks resource as favorite", async () => {
+        linkService.create = jest.fn().mockReturnValue(Promise.resolve({ uuid: "newUuid" }));
+        const favoriteService = new FavoriteService(linkService, groupService);
+
+        const newFavorite = await favoriteService.create({ userUuid: "userUuid", resource: { uuid: "resourceUuid", name: "resource" } });
+
+        expect(linkService.create).toHaveBeenCalledWith({
+            ownerUuid: "userUuid",
+            tailUuid: "userUuid",
+            headUuid: "resourceUuid",
+            linkClass: LinkClass.STAR,
+            name: "resource"
+        });
+        expect(newFavorite.uuid).toEqual("newUuid");
+
+    });
+
+    it("unmarks resource as favorite", async () => {
+        const list = jest.fn().mockReturnValue(Promise.resolve({ items: [{ uuid: "linkUuid" }] }));
+        const filters = new FilterBuilder()
+            .addEqual('owner_uuid', "userUuid")
+            .addEqual('head_uuid', "resourceUuid")
+            .addEqual('link_class', LinkClass.STAR);
+        linkService.list = list;
+        linkService.delete = jest.fn().mockReturnValue(Promise.resolve({ uuid: "linkUuid" }));
+        const favoriteService = new FavoriteService(linkService, groupService);
+
+        const newFavorite = await favoriteService.delete({ userUuid: "userUuid", resourceUuid: "resourceUuid" });
+
+        expect(list.mock.calls[0][0].filters).toEqual(filters.getFilters());
+        expect(linkService.delete).toHaveBeenCalledWith("linkUuid");
+        expect(newFavorite[0].uuid).toEqual("linkUuid");
+    });
+
+    it("lists favorite resources", async () => {
+        const list = jest.fn().mockReturnValue(Promise.resolve({ items: [{ headUuid: "headUuid" }] }));
+        const listFilters = new FilterBuilder()
+            .addEqual('owner_uuid', "userUuid")
+            .addEqual('link_class', LinkClass.STAR);
+        const contents = jest.fn().mockReturnValue(Promise.resolve({ items: [{ uuid: "resourceUuid" }] }));
+        const contentFilters = new FilterBuilder().addIn('uuid', ["headUuid"]);
+        linkService.list = list;
+        groupService.contents = contents;
+        const favoriteService = new FavoriteService(linkService, groupService);
+
+        const favorites = await favoriteService.list("userUuid");
+
+        expect(list.mock.calls[0][0].filters).toEqual(listFilters.getFilters());
+        expect(contents.mock.calls[0][0]).toEqual("userUuid");
+        expect(contents.mock.calls[0][1].filters).toEqual(contentFilters.getFilters());
+        expect(favorites).toEqual({ items: [{ uuid: "resourceUuid" }] });
+    });
+
+    it("checks if resources are present in favorites", async () => {
+        const list = jest.fn().mockReturnValue(Promise.resolve({ items: [{ headUuid: "foo" }] }));
+        const listFilters = new FilterBuilder()
+            .addIn("head_uuid", ["foo", "oof"])
+            .addEqual("owner_uuid", "userUuid")
+            .addEqual("link_class", LinkClass.STAR);
+        linkService.list = list;
+        const favoriteService = new FavoriteService(linkService, groupService);
+
+        const favorites = await favoriteService.checkPresenceInFavorites("userUuid", ["foo", "oof"]);
+
+        expect(list.mock.calls[0][0].filters).toEqual(listFilters.getFilters());
+        expect(favorites).toEqual({ foo: true, oof: false });
+    });
+
+});
diff --git a/services/workbench2/src/services/favorite-service/favorite-service.ts b/services/workbench2/src/services/favorite-service/favorite-service.ts
new file mode 100644 (file)
index 0000000..8b66455
--- /dev/null
@@ -0,0 +1,88 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkService } from "../link-service/link-service";
+import { GroupsService, GroupContentsResource } from "../groups-service/groups-service";
+import { LinkClass } from "models/link";
+import { FilterBuilder, joinFilters } from "services/api/filter-builder";
+import { ListResults } from 'services/common-service/common-service';
+
+export interface FavoriteListArguments {
+    limit?: number;
+    offset?: number;
+    filters?: string;
+    linkOrder?: string;
+    contentOrder?: string;
+}
+
+export class FavoriteService {
+    constructor(
+        private linkService: LinkService,
+        private groupsService: GroupsService,
+    ) { }
+
+    create(data: { userUuid: string; resource: { uuid: string; name: string } }) {
+        return this.linkService.create({
+            ownerUuid: data.userUuid,
+            tailUuid: data.userUuid,
+            headUuid: data.resource.uuid,
+            linkClass: LinkClass.STAR,
+            name: data.resource.name
+        });
+    }
+
+    delete(data: { userUuid: string; resourceUuid: string; }) {
+        return this.linkService
+            .list({
+                filters: new FilterBuilder()
+                    .addEqual('owner_uuid', data.userUuid)
+                    .addEqual('head_uuid', data.resourceUuid)
+                    .addEqual('link_class', LinkClass.STAR)
+                    .getFilters()
+            })
+            .then(results => Promise.all(
+                results.items.map(item => this.linkService.delete(item.uuid))));
+    }
+
+    list(userUuid: string, { filters, limit, offset, linkOrder, contentOrder }: FavoriteListArguments = {}, showOnlyOwned: boolean = true): Promise<ListResults<GroupContentsResource>> {
+        const listFilters = new FilterBuilder()
+            .addEqual('owner_uuid', userUuid)
+            .addEqual('link_class', LinkClass.STAR)
+            .getFilters();
+
+        return this.linkService
+            .list({
+                filters: joinFilters(filters || '', listFilters),
+                limit,
+                offset,
+                order: linkOrder
+            })
+            .then(results => {
+                const uuids = results.items.map(item => item.headUuid);
+                return this.groupsService.contents(showOnlyOwned ? userUuid : '', {
+                    limit,
+                    offset,
+                    order: contentOrder,
+                    filters: new FilterBuilder().addIn('uuid', uuids).getFilters(),
+                    recursive: true
+                });
+            });
+    }
+
+    checkPresenceInFavorites(userUuid: string, resourceUuids: string[]): Promise<Record<string, boolean>> {
+        return this.linkService
+            .list({
+                filters: new FilterBuilder()
+                    .addIn("head_uuid", resourceUuids)
+                    .addEqual("owner_uuid", userUuid)
+                    .addEqual("link_class", LinkClass.STAR)
+                    .getFilters()
+            })
+            .then(({ items }) => resourceUuids.reduce((results, uuid) => {
+                const isFavorite = items.some(item => item.headUuid === uuid);
+                return { ...results, [uuid]: isFavorite };
+            }, {}));
+    }
+
+}
diff --git a/services/workbench2/src/services/file-viewers-config-service/file-viewers-config-service.ts b/services/workbench2/src/services/file-viewers-config-service/file-viewers-config-service.ts
new file mode 100644 (file)
index 0000000..f0dd924
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import Axios from 'axios';
+import { FileViewerList } from 'models/file-viewers-config';
+
+export class FileViewersConfigService {
+    constructor(
+        private url: string
+    ) { }
+
+    get() {
+        return Axios
+            .get<FileViewerList>(this.url)
+            .then(response => response.data);
+    }
+}
diff --git a/services/workbench2/src/services/groups-service/groups-service.test.ts b/services/workbench2/src/services/groups-service/groups-service.test.ts
new file mode 100644 (file)
index 0000000..4e53f67
--- /dev/null
@@ -0,0 +1,48 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { GroupsService } from "./groups-service";
+import { ApiActions } from "services/api/api-actions";
+
+describe("GroupsService", () => {
+
+    const axiosMock = new MockAdapter(axios);
+
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => {},
+        errorFn: (id: string, message: string) => {}
+    };
+
+    beforeEach(() => {
+        axiosMock.reset();
+    });
+
+    it("#contents", async () => {
+        axiosMock
+            .onGet("/groups/1/contents")
+            .reply(200, {
+                kind: "kind",
+                offset: 2,
+                limit: 10,
+                items: [{
+                    modified_at: "now"
+                }],
+                items_available: 20
+            });
+
+        const groupsService = new GroupsService(axios, actions);
+        const resource = await groupsService.contents("1", { limit: 10, offset: 1 });
+        expect(resource).toEqual({
+            kind: "kind",
+            offset: 2,
+            limit: 10,
+            items: [{
+                modifiedAt: "now"
+            }],
+            itemsAvailable: 20
+        });
+    });
+});
diff --git a/services/workbench2/src/services/groups-service/groups-service.ts b/services/workbench2/src/services/groups-service/groups-service.ts
new file mode 100644 (file)
index 0000000..b9f47df
--- /dev/null
@@ -0,0 +1,104 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CancelToken } from 'axios';
+import { snakeCase, camelCase } from "lodash";
+import { CommonResourceService } from 'services/common-service/common-resource-service';
+import {
+    ListResults,
+    ListArguments,
+} from 'services/common-service/common-service';
+import { AxiosInstance, AxiosRequestConfig } from 'axios';
+import { CollectionResource } from 'models/collection';
+import { ProjectResource } from 'models/project';
+import { ProcessResource } from 'models/process';
+import { WorkflowResource } from 'models/workflow';
+import { TrashableResourceService } from 'services/common-service/trashable-resource-service';
+import { ApiActions } from 'services/api/api-actions';
+import { GroupResource } from 'models/group';
+import { Session } from 'models/session';
+
+export interface ContentsArguments {
+    limit?: number;
+    offset?: number;
+    order?: string;
+    filters?: string;
+    recursive?: boolean;
+    includeTrash?: boolean;
+    excludeHomeProject?: boolean;
+    select?: string[];
+}
+
+export interface SharedArguments extends ListArguments {
+    include?: string;
+}
+
+export type GroupContentsResource =
+    | CollectionResource
+    | ProjectResource
+    | ProcessResource
+    | WorkflowResource;
+
+export class GroupsService<
+    T extends GroupResource = GroupResource
+    > extends TrashableResourceService<T> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, 'groups', actions);
+    }
+
+    async contents(uuid: string, args: ContentsArguments = {}, session?: Session, cancelToken?: CancelToken): Promise<ListResults<GroupContentsResource>> {
+        const { filters, order, select, ...other } = args;
+        const params = {
+            ...other,
+            filters: filters ? `[${filters}]` : undefined,
+            order: order ? order : undefined,
+            select: select
+                ? JSON.stringify(select.map(sel => {
+                    const sp = sel.split(".");
+                    return sp.length === 2 ? (sp[0] + "." + snakeCase(sp[1])) : snakeCase(sel);
+                }))
+                : undefined
+        };
+        const pathUrl = (uuid !== '') ? `/${uuid}/contents` : '/contents';
+        const cfg: AxiosRequestConfig = {
+            params: CommonResourceService.mapKeys(snakeCase)(params),
+        };
+
+        if (session) {
+            cfg.baseURL = session.baseUrl;
+            cfg.headers = { Authorization: 'Bearer ' + session.token };
+        }
+
+        if (cancelToken) {
+            cfg.cancelToken = cancelToken;
+        }
+
+        const response = await CommonResourceService.defaultResponse(
+            this.serverApi.get(this.resourceType + pathUrl, cfg),
+            this.actions,
+            false
+        );
+
+        return {
+            ...TrashableResourceService.mapKeys(camelCase)(response),
+            clusterId: session && session.clusterId,
+        };
+    }
+
+    shared(
+        params: SharedArguments = {}
+    ): Promise<ListResults<GroupContentsResource>> {
+        return CommonResourceService.defaultResponse(
+            this.serverApi.get(this.resourceType + '/shared', { params }),
+            this.actions
+        );
+    }
+}
+
+export enum GroupContentsResourcePrefix {
+    COLLECTION = 'collections',
+    PROJECT = 'groups',
+    PROCESS = 'container_requests',
+    WORKFLOW = 'workflows',
+}
diff --git a/services/workbench2/src/services/keep-service/keep-service.ts b/services/workbench2/src/services/keep-service/keep-service.ts
new file mode 100644 (file)
index 0000000..0af6789
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.\r
+//\r
+// SPDX-License-Identifier: AGPL-3.0\r
+\r
+import { CommonResourceService } from "services/common-service/common-resource-service";\r
+import { AxiosInstance } from "axios";\r
+import { KeepServiceResource } from "models/keep-services";\r
+import { ApiActions } from "services/api/api-actions";\r
+\r
+export class KeepService extends CommonResourceService<KeepServiceResource> {\r
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {\r
+        super(serverApi, "keep_services", actions);\r
+    }\r
+}
\ No newline at end of file
diff --git a/services/workbench2/src/services/link-account-service/link-account-service.ts b/services/workbench2/src/services/link-account-service/link-account-service.ts
new file mode 100644 (file)
index 0000000..6c03eed
--- /dev/null
@@ -0,0 +1,57 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { ApiActions } from "services/api/api-actions";
+import { AccountToLink, LinkAccountStatus } from "models/link-account";
+import { CommonService } from "services/common-service/common-service";
+
+export const USER_LINK_ACCOUNT_KEY = 'accountToLink';
+export const ACCOUNT_LINK_STATUS_KEY = 'accountLinkStatus';
+
+export class LinkAccountService {
+
+    constructor(
+        protected serverApi: AxiosInstance,
+        protected actions: ApiActions) { }
+
+    public saveAccountToLink(account: AccountToLink) {
+        sessionStorage.setItem(USER_LINK_ACCOUNT_KEY, JSON.stringify(account));
+    }
+
+    public removeAccountToLink() {
+        sessionStorage.removeItem(USER_LINK_ACCOUNT_KEY);
+    }
+
+    public getAccountToLink() {
+        const data = sessionStorage.getItem(USER_LINK_ACCOUNT_KEY);
+        return data ? JSON.parse(data) as AccountToLink : undefined;
+    }
+
+    public saveLinkOpStatus(status: LinkAccountStatus) {
+        sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, JSON.stringify(status));
+    }
+
+    public removeLinkOpStatus() {
+        sessionStorage.removeItem(ACCOUNT_LINK_STATUS_KEY);
+    }
+
+    public getLinkOpStatus() {
+        const data = sessionStorage.getItem(ACCOUNT_LINK_STATUS_KEY);
+        return data ? JSON.parse(data) as LinkAccountStatus : undefined;
+    }
+
+    public linkAccounts(newUserToken: string, newGroupUuid: string) {
+        const params = {
+            new_user_token: newUserToken,
+            new_owner_uuid: newGroupUuid,
+            redirect_to_new_user: true
+        };
+        return CommonService.defaultResponse(
+            this.serverApi.post('/users/merge', params),
+            this.actions,
+            false
+        );
+    }
+}
diff --git a/services/workbench2/src/services/link-service/link-service.ts b/services/workbench2/src/services/link-service/link-service.ts
new file mode 100644 (file)
index 0000000..db198e0
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommonResourceService } from "services/common-service/common-resource-service";
+import { LinkResource } from "models/link";
+import { AxiosInstance } from "axios";
+import { ApiActions } from "services/api/api-actions";
+
+export class LinkService<Resource extends LinkResource = LinkResource> extends CommonResourceService<Resource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "links", actions);
+    }
+}
diff --git a/services/workbench2/src/services/log-service/log-service.test.ts b/services/workbench2/src/services/log-service/log-service.test.ts
new file mode 100644 (file)
index 0000000..2519155
--- /dev/null
@@ -0,0 +1,168 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LogService } from "./log-service";
+import { ApiActions } from "services/api/api-actions";
+import axios from "axios";
+import { WebDAVRequestConfig } from "common/webdav";
+import { LogEventType } from "models/log";
+
+describe("LogService", () => {
+
+    let apiWebdavClient: any;
+    const axiosInstance = axios.create();
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => {},
+        errorFn: (id: string, message: string) => {}
+    };
+
+    beforeEach(() => {
+        apiWebdavClient = {
+            delete: jest.fn(),
+            upload: jest.fn(),
+            mkdir: jest.fn(),
+            get: jest.fn(),
+            propfind: jest.fn(),
+        } as any;
+    });
+
+    it("lists log files using propfind on live logs api endpoint", async () => {
+        const logService = new LogService(axiosInstance, apiWebdavClient, actions);
+
+        // given
+        const containerRequest = {uuid: 'zzzzz-xvhdp-000000000000000', containerUuid: 'zzzzz-dz642-000000000000000'};
+        const xmlData = `<?xml version="1.0" encoding="UTF-8"?>
+            <D:multistatus xmlns:D="DAV:">
+                    <D:response>
+                            <D:href>/arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/</D:href>
+                            <D:propstat>
+                                    <D:prop>
+                                            <D:resourcetype>
+                                                    <D:collection xmlns:D="DAV:" />
+                                            </D:resourcetype>
+                                            <D:getlastmodified>Tue, 15 Aug 2023 12:54:37 GMT</D:getlastmodified>
+                                            <D:displayname></D:displayname>
+                                            <D:supportedlock>
+                                                    <D:lockentry xmlns:D="DAV:">
+                                                            <D:lockscope>
+                                                                    <D:exclusive />
+                                                            </D:lockscope>
+                                                            <D:locktype>
+                                                                    <D:write />
+                                                            </D:locktype>
+                                                    </D:lockentry>
+                                            </D:supportedlock>
+                                    </D:prop>
+                                    <D:status>HTTP/1.1 200 OK</D:status>
+                            </D:propstat>
+                    </D:response>
+                    <D:response>
+                            <D:href>/arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/stdout.txt</D:href>
+                            <D:propstat>
+                                    <D:prop>
+                                            <D:displayname>stdout.txt</D:displayname>
+                                            <D:getcontentlength>15</D:getcontentlength>
+                                            <D:getcontenttype>text/plain; charset=utf-8</D:getcontenttype>
+                                            <D:getetag>"177b8fb161ff9f58f"</D:getetag>
+                                            <D:supportedlock>
+                                                    <D:lockentry xmlns:D="DAV:">
+                                                            <D:lockscope>
+                                                                    <D:exclusive />
+                                                            </D:lockscope>
+                                                            <D:locktype>
+                                                                    <D:write />
+                                                            </D:locktype>
+                                                    </D:lockentry>
+                                            </D:supportedlock>
+                                            <D:resourcetype></D:resourcetype>
+                                            <D:getlastmodified>Tue, 15 Aug 2023 12:54:37 GMT</D:getlastmodified>
+                                    </D:prop>
+                                    <D:status>HTTP/1.1 200 OK</D:status>
+                            </D:propstat>
+                    </D:response>
+                    <D:response>
+                            <D:href>/arvados/v1/container_requests/${containerRequest.uuid}/wrongpath.txt</D:href>
+                            <D:propstat>
+                                    <D:prop>
+                                            <D:displayname>wrongpath.txt</D:displayname>
+                                            <D:getcontentlength>15</D:getcontentlength>
+                                            <D:getcontenttype>text/plain; charset=utf-8</D:getcontenttype>
+                                            <D:getetag>"177b8fb161ff9f58f"</D:getetag>
+                                            <D:supportedlock>
+                                                    <D:lockentry xmlns:D="DAV:">
+                                                            <D:lockscope>
+                                                                    <D:exclusive />
+                                                            </D:lockscope>
+                                                            <D:locktype>
+                                                                    <D:write />
+                                                            </D:locktype>
+                                                    </D:lockentry>
+                                            </D:supportedlock>
+                                            <D:resourcetype></D:resourcetype>
+                                            <D:getlastmodified>Tue, 15 Aug 2023 12:54:37 GMT</D:getlastmodified>
+                                    </D:prop>
+                                    <D:status>HTTP/1.1 200 OK</D:status>
+                            </D:propstat>
+                    </D:response>
+            </D:multistatus>`;
+        const xmlDoc = (new DOMParser()).parseFromString(xmlData, "text/xml");
+        apiWebdavClient.propfind = jest.fn().mockReturnValue(Promise.resolve({responseXML: xmlDoc}));
+
+        // when
+        const logs = await logService.listLogFiles(containerRequest);
+
+        // then
+        expect(apiWebdavClient.propfind).toHaveBeenCalledWith(`container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}`);
+        expect(logs.length).toEqual(1);
+        expect(logs[0]).toHaveProperty('name', 'stdout.txt');
+        expect(logs[0]).toHaveProperty('type', 'file');
+    });
+
+    it("requests log file contents with correct range request", async () => {
+        const logService = new LogService(axiosInstance, apiWebdavClient, actions);
+
+        // given
+        const containerRequest = {uuid: 'zzzzz-xvhdp-000000000000000', containerUuid: 'zzzzz-dz642-000000000000000'};
+        const fileRecord = {name: `stdout.txt`};
+        const fileContents = `Line 1\nLine 2\nLine 3`;
+        apiWebdavClient.get = jest.fn().mockImplementation((path: string, options: WebDAVRequestConfig) => {
+            const matches = /bytes=([0-9]+)-([0-9]+)/.exec(options.headers?.Range || '');
+            if (matches?.length === 3) {
+                return Promise.resolve({responseText: fileContents.substring(Number(matches[1]), Number(matches[2]) + 1)})
+            }
+            return Promise.reject();
+        });
+
+        // when
+        let result = await logService.getLogFileContents(containerRequest, fileRecord, 0, 3);
+        // then
+        expect(apiWebdavClient.get).toHaveBeenCalledWith(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=0-3`}}
+        );
+        expect(result.logType).toEqual(LogEventType.STDOUT);
+        expect(result.contents).toEqual(['Line']);
+
+        // when
+        result = await logService.getLogFileContents(containerRequest, fileRecord, 0, 10);
+        // then
+        expect(apiWebdavClient.get).toHaveBeenCalledWith(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=0-10`}}
+        );
+        expect(result.logType).toEqual(LogEventType.STDOUT);
+        expect(result.contents).toEqual(['Line 1', 'Line']);
+
+        // when
+        result = await logService.getLogFileContents(containerRequest, fileRecord, 6, 14);
+        // then
+        expect(apiWebdavClient.get).toHaveBeenCalledWith(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=6-14`}}
+        );
+        expect(result.logType).toEqual(LogEventType.STDOUT);
+        expect(result.contents).toEqual(['', 'Line 2', 'L']);
+    });
+
+});
diff --git a/services/workbench2/src/services/log-service/log-service.ts b/services/workbench2/src/services/log-service/log-service.ts
new file mode 100644 (file)
index 0000000..f36044f
--- /dev/null
@@ -0,0 +1,61 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { LogEventType, LogResource } from 'models/log';
+import { CommonResourceService } from "services/common-service/common-resource-service";
+import { ApiActions } from "services/api/api-actions";
+import { WebDAV } from "common/webdav";
+import { extractFilesData } from "services/collection-service/collection-service-files-response";
+import { CollectionFile } from "models/collection-file";
+import { ContainerRequestResource } from "models/container-request";
+
+export type LogFragment = {
+    logType: LogEventType;
+    contents: string[];
+}
+
+export class LogService extends CommonResourceService<LogResource> {
+    constructor(serverApi: AxiosInstance, private apiWebdavClient: WebDAV, actions: ApiActions) {
+        super(serverApi, "logs", actions);
+    }
+
+    async listLogFiles(containerRequest: Pick<ContainerRequestResource, 'uuid' | 'containerUuid'>) {
+        const request = await this.apiWebdavClient.propfind(`container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}`);
+        if (request?.responseXML != null) {
+            return extractFilesData(request.responseXML)
+                .filter((file) => (
+                    file.path === `/arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}`
+                ));
+        }
+        return Promise.reject();
+    }
+
+    /**
+     * Fetches the specified log file contents from the given container request's container live logs endpoint
+     * @param containerRequest Container request to fetch logs for
+     * @param fileRecord Log file to fetch
+     * @param startByte First byte index of the log file to fetch
+     * @param endByte Last byte index to include in the response
+     * @returns A promise that resolves to the LogEventType and a string array of the log file contents
+     */
+    async getLogFileContents(containerRequest: Pick<ContainerRequestResource, 'uuid' | 'containerUuid'>, fileRecord: Pick<CollectionFile, 'name'>, startByte: number, endByte: number): Promise<LogFragment> {
+        const request = await this.apiWebdavClient.get(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=${startByte}-${endByte}`}}
+        );
+        const logFileType = logFileToLogType(fileRecord);
+
+        if (request?.responseText && logFileType) {
+            return {
+                logType: logFileType,
+                contents: request.responseText.split(/\r?\n/),
+            };
+        } else {
+            return Promise.reject();
+        }
+    }
+}
+
+export const logFileToLogType = (file: Pick<CollectionFile, 'name'>) => (file.name.replace(/\.(txt|json)$/, '') as LogEventType);
diff --git a/services/workbench2/src/services/permission-service/permission-service.ts b/services/workbench2/src/services/permission-service/permission-service.ts
new file mode 100644 (file)
index 0000000..3a3c1ac
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkService } from "services/link-service/link-service";
+import { PermissionResource } from "models/permission";
+import { CommonResourceService } from 'services/common-service/common-resource-service';
+import { LinkClass } from '../../models/link';
+import { ListArguments, ListResults } from 'services/common-service/common-service';
+
+export class PermissionService extends LinkService<PermissionResource> {
+
+    permissionListService = new CommonResourceService(this.serverApi, 'permissions', this.actions);
+    create(data?: Partial<PermissionResource>) {
+        return super.create({ ...data, linkClass: LinkClass.PERMISSION });
+    }
+
+    listResourcePermissions(uuid: string, args: ListArguments = {}): Promise<ListResults<PermissionResource>> {
+        const service = new CommonResourceService<PermissionResource>(this.serverApi, `permissions/${uuid}`, this.actions);
+        return service.list(args);
+    }
+
+}
diff --git a/services/workbench2/src/services/project-service/project-service.test.ts b/services/workbench2/src/services/project-service/project-service.test.ts
new file mode 100644 (file)
index 0000000..760ae85
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import axios from "axios";
+import { ProjectService } from "./project-service";
+import { FilterBuilder } from "services/api/filter-builder";
+import { ApiActions } from "services/api/api-actions";
+
+describe("CommonResourceService", () => {
+    const axiosInstance = axios.create();
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => {},
+        errorFn: (id: string, message: string) => {}
+    };
+
+    it(`#create has groupClass set to "project"`, async () => {
+        axiosInstance.post = jest.fn(() => Promise.resolve({ data: {} }));
+        const projectService = new ProjectService(axiosInstance, actions);
+        const resource = await projectService.create({ name: "nameValue" });
+        expect(axiosInstance.post).toHaveBeenCalledWith("/groups", {
+            group: {
+                name: "nameValue",
+                group_class: "project"
+            }
+        });
+    });
+
+    it("#list has groupClass filter set by default", async () => {
+        axiosInstance.get = jest.fn(() => Promise.resolve({ data: {} }));
+        const projectService = new ProjectService(axiosInstance, actions);
+        const resource = await projectService.list();
+        expect(axiosInstance.get).toHaveBeenCalledWith("/groups", {
+            params: {
+                filters: "[" + new FilterBuilder()
+                    .addIn("group_class", ["project", "filter"])
+                    .getFilters() + "]",
+                order: undefined
+            }
+        });
+    });
+});
diff --git a/services/workbench2/src/services/project-service/project-service.ts b/services/workbench2/src/services/project-service/project-service.ts
new file mode 100644 (file)
index 0000000..442a6ab
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { GroupsService } from "../groups-service/groups-service";
+import { ProjectResource } from "models/project";
+import { GroupClass } from "models/group";
+import { ListArguments } from "services/common-service/common-service";
+import { FilterBuilder, joinFilters } from "services/api/filter-builder";
+export class ProjectService extends GroupsService<ProjectResource> {
+
+    create(data: Partial<ProjectResource>, showErrors?: boolean) {
+        const projectData = { ...data, groupClass: GroupClass.PROJECT };
+        return super.create(projectData, showErrors);
+    }
+
+    list(args: ListArguments = {}) {
+        return super.list({
+            ...args,
+            filters: joinFilters(
+                args.filters || '',
+                new FilterBuilder()
+                    .addIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
+                    .getFilters()
+            )
+        });
+    }
+}
diff --git a/services/workbench2/src/services/repositories-service/repositories-service.ts b/services/workbench2/src/services/repositories-service/repositories-service.ts
new file mode 100644 (file)
index 0000000..cd30086
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { CommonResourceService } from "services/common-service/common-resource-service";
+import { RepositoryResource } from 'models/repositories';
+import { ApiActions } from 'services/api/api-actions';
+
+ export class RepositoriesService extends CommonResourceService<RepositoryResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "repositories", actions);
+    }
+
+     getAllPermissions() {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .get('repositories/get_all_permissions'),
+            this.actions
+        );
+    }
+} 
\ No newline at end of file
diff --git a/services/workbench2/src/services/search-service/search-service.ts b/services/workbench2/src/services/search-service/search-service.ts
new file mode 100644 (file)
index 0000000..726af89
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { SearchBarAdvancedFormData } from 'models/search-bar';
+
+export class SearchService {
+    private recentQueries = this.getRecentQueries();
+    private savedQueries: SearchBarAdvancedFormData[] = this.getSavedQueries();
+
+    saveRecentQuery(query: string) {
+        if (this.recentQueries.length >= MAX_NUMBER_OF_RECENT_QUERIES) {
+            this.recentQueries.shift();
+        }
+        this.recentQueries.push(query);
+        localStorage.setItem('recentQueries', JSON.stringify(this.recentQueries));
+    }
+
+    getRecentQueries(): string[] {
+        return JSON.parse(localStorage.getItem('recentQueries') || '[]');
+    }
+
+    saveQuery(data: SearchBarAdvancedFormData) {
+        this.savedQueries.push({...data});
+        localStorage.setItem('savedQueries', JSON.stringify(this.savedQueries));
+    }
+
+    editSavedQueries(data: SearchBarAdvancedFormData) {
+        const itemIndex = this.savedQueries.findIndex(item => item.queryName === data.queryName);
+        this.savedQueries[itemIndex] = {...data};
+        localStorage.setItem('savedQueries', JSON.stringify(this.savedQueries));
+    }
+
+    getSavedQueries() {
+        return JSON.parse(localStorage.getItem('savedQueries') || '[]') as SearchBarAdvancedFormData[];
+    }
+
+    deleteSavedQuery(id: number) {
+        this.savedQueries.splice(id, 1);
+        localStorage.setItem('savedQueries', JSON.stringify(this.savedQueries));
+    }
+}
+
+const MAX_NUMBER_OF_RECENT_QUERIES = 5;
diff --git a/services/workbench2/src/services/services.ts b/services/workbench2/src/services/services.ts
new file mode 100644 (file)
index 0000000..12938e8
--- /dev/null
@@ -0,0 +1,140 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import Axios from "axios";
+import { AxiosInstance } from "axios";
+import { ApiClientAuthorizationService } from 'services/api-client-authorization-service/api-client-authorization-service';
+import { AuthService } from "./auth-service/auth-service";
+import { GroupsService } from "./groups-service/groups-service";
+import { ProjectService } from "./project-service/project-service";
+import { LinkService } from "./link-service/link-service";
+import { FavoriteService } from "./favorite-service/favorite-service";
+import { CollectionService } from "./collection-service/collection-service";
+import { TagService } from "./tag-service/tag-service";
+import { KeepService } from "./keep-service/keep-service";
+import { WebDAV } from "common/webdav";
+import { Config } from "common/config";
+import { UserService } from './user-service/user-service';
+import { AncestorService } from "services/ancestors-service/ancestors-service";
+import { ResourceKind } from "models/resource";
+import { ContainerRequestService } from './container-request-service/container-request-service';
+import { ContainerService } from './container-service/container-service';
+import { LogService } from './log-service/log-service';
+import { ApiActions } from "services/api/api-actions";
+import { WorkflowService } from "services/workflow-service/workflow-service";
+import { SearchService } from 'services/search-service/search-service';
+import { PermissionService } from "services/permission-service/permission-service";
+import { VirtualMachinesService } from "services/virtual-machines-service/virtual-machines-service";
+import { RepositoriesService } from 'services/repositories-service/repositories-service';
+import { AuthorizedKeysService } from 'services/authorized-keys-service/authorized-keys-service';
+import { VocabularyService } from 'services/vocabulary-service/vocabulary-service';
+import { FileViewersConfigService } from 'services/file-viewers-config-service/file-viewers-config-service';
+import { LinkAccountService } from "./link-account-service/link-account-service";
+import parse from "parse-duration";
+
+export type ServiceRepository = ReturnType<typeof createServices>;
+
+export function setAuthorizationHeader(services: ServiceRepository, token: string) {
+    services.apiClient.defaults.headers.common = {
+        Authorization: `Bearer ${token}`
+    };
+    services.keepWebdavClient.setAuthorization(`Bearer ${token}`);
+    services.apiWebdavClient.setAuthorization(`Bearer ${token}`);
+}
+
+export function removeAuthorizationHeader(services: ServiceRepository) {
+    services.apiClient.defaults.headers.common = {};
+
+    services.keepWebdavClient.setAuthorization(undefined);
+    services.apiWebdavClient.setAuthorization(undefined);
+}
+
+export const createServices = (config: Config, actions: ApiActions, useApiClient?: AxiosInstance) => {
+    // Need to give empty 'headers' object or it will create an
+    // instance with a reference to the global default headers object,
+    // which is very bad because that means setAuthorizationHeader
+    // would update the global default instead of the instance default.
+    const apiClient = useApiClient || Axios.create({ headers: {} });
+    apiClient.defaults.baseURL = config.baseUrl;
+
+    const keepWebdavClient = new WebDAV({
+        baseURL: config.keepWebServiceUrl
+    });
+
+    const apiWebdavClient = new WebDAV({
+        baseURL: config.baseUrl
+    });
+
+    const apiClientAuthorizationService = new ApiClientAuthorizationService(apiClient, actions);
+    const authorizedKeysService = new AuthorizedKeysService(apiClient, actions);
+    const containerRequestService = new ContainerRequestService(apiClient, actions);
+    const containerService = new ContainerService(apiClient, actions);
+    const groupsService = new GroupsService(apiClient, actions);
+    const keepService = new KeepService(apiClient, actions);
+    const linkService = new LinkService(apiClient, actions);
+    const logService = new LogService(apiClient, apiWebdavClient, actions);
+    const permissionService = new PermissionService(apiClient, actions);
+    const projectService = new ProjectService(apiClient, actions);
+    const repositoriesService = new RepositoriesService(apiClient, actions);
+    const userService = new UserService(apiClient, actions);
+    const virtualMachineService = new VirtualMachinesService(apiClient, actions);
+    const workflowService = new WorkflowService(apiClient, actions);
+    const linkAccountService = new LinkAccountService(apiClient, actions);
+
+    const idleTimeout = (config && config.clusterConfig && config.clusterConfig.Workbench.IdleTimeout) || '0s';
+    const authService = new AuthService(apiClient, config.rootUrl, actions,
+        (parse(idleTimeout, 's') || 0) > 0);
+
+    const collectionService = new CollectionService(apiClient, keepWebdavClient, authService, actions);
+    const ancestorsService = new AncestorService(groupsService, userService, collectionService);
+    const favoriteService = new FavoriteService(linkService, groupsService);
+    const tagService = new TagService(linkService);
+    const searchService = new SearchService();
+    const vocabularyService = new VocabularyService(config.vocabularyUrl);
+    const fileViewersConfig = new FileViewersConfigService(config.fileViewersConfigUrl);
+
+    return {
+        ancestorsService,
+        apiClient,
+        apiClientAuthorizationService,
+        authService,
+        authorizedKeysService,
+        collectionService,
+        containerRequestService,
+        containerService,
+        favoriteService,
+        fileViewersConfig,
+        groupsService,
+        keepService,
+        linkService,
+        logService,
+        permissionService,
+        projectService,
+        repositoriesService,
+        searchService,
+        tagService,
+        userService,
+        virtualMachineService,
+        keepWebdavClient,
+        apiWebdavClient,
+        workflowService,
+        vocabularyService,
+        linkAccountService
+    };
+};
+
+export const getResourceService = (kind?: ResourceKind) => (serviceRepository: ServiceRepository) => {
+    switch (kind) {
+        case ResourceKind.USER:
+            return serviceRepository.userService;
+        case ResourceKind.GROUP:
+            return serviceRepository.groupsService;
+        case ResourceKind.COLLECTION:
+            return serviceRepository.collectionService;
+        case ResourceKind.LINK:
+            return serviceRepository.linkService;
+        default:
+            return undefined;
+    }
+};
diff --git a/services/workbench2/src/services/tag-service/tag-service.ts b/services/workbench2/src/services/tag-service/tag-service.ts
new file mode 100644 (file)
index 0000000..9ea4650
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkService } from "../link-service/link-service";
+import { LinkClass } from "models/link";
+import { FilterBuilder } from "services/api/filter-builder";
+import { TagTailType, TagResource } from "models/tag";
+import { OrderBuilder } from "services/api/order-builder";
+
+export class TagService {
+
+    constructor(private linkService: LinkService) { }
+
+    create(uuid: string, data: { key: string; value: string } ) {
+        return this.linkService
+            .create({
+                headUuid: uuid,
+                tailUuid: TagTailType.COLLECTION,
+                linkClass: LinkClass.TAG,
+                name: '',
+                properties: data
+            })
+            .then(tag => tag as TagResource );
+    }
+
+    list(uuid: string) {
+        const filters = new FilterBuilder()
+            .addEqual("head_uuid", uuid)
+            .addEqual("tail_uuid", TagTailType.COLLECTION)
+            .addEqual("link_class", LinkClass.TAG)
+            .getFilters();
+
+        const order = new OrderBuilder<TagResource>()
+            .addAsc('createdAt')
+            .getOrder();
+
+        return this.linkService
+            .list({ filters, order })
+            .then(results => {
+                return results.items.map((tag => tag as TagResource ));
+            });
+    }
+}
diff --git a/services/workbench2/src/services/user-service/user-service.ts b/services/workbench2/src/services/user-service/user-service.ts
new file mode 100644 (file)
index 0000000..75131f9
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { CommonResourceService } from "services/common-service/common-resource-service";
+import { UserResource } from "models/user";
+import { ApiActions } from "services/api/api-actions";
+import { ListResults } from "services/common-service/common-service";
+
+export class UserService extends CommonResourceService<UserResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions, readOnlyFields: string[] = []) {
+        super(serverApi, "users", actions, readOnlyFields.concat([
+            'fullName',
+            'isInvited'
+        ]));
+    }
+
+    activate(uuid: string) {
+        return CommonResourceService.defaultResponse<UserResource>(
+            this.serverApi
+                .post(this.resourceType + `/${uuid}/activate`),
+            this.actions
+        );
+    }
+
+    setup(uuid: string) {
+        return CommonResourceService.defaultResponse<ListResults<any>>(
+            this.serverApi
+                .post(this.resourceType + `/setup`, {}, { params: { uuid } }),
+            this.actions
+        );
+    }
+
+    unsetup(uuid: string) {
+        return CommonResourceService.defaultResponse<UserResource>(
+            this.serverApi
+                .post(this.resourceType + `/${uuid}/unsetup`),
+            this.actions
+        );
+    }
+}
diff --git a/services/workbench2/src/services/virtual-machines-service/virtual-machines-service.ts b/services/workbench2/src/services/virtual-machines-service/virtual-machines-service.ts
new file mode 100644 (file)
index 0000000..6e84893
--- /dev/null
@@ -0,0 +1,38 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { CommonResourceService } from "services/common-service/common-resource-service";
+import { VirtualMachineLogins, VirtualMachinesResource } from 'models/virtual-machines';
+import { ApiActions } from 'services/api/api-actions';
+
+export class VirtualMachinesService extends CommonResourceService<VirtualMachinesResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "virtual_machines", actions);
+    }
+
+    getRequestedDate(): string {
+        return localStorage.getItem('requestedDate') || '';
+    }
+
+    saveRequestedDate(date: string) {
+        localStorage.setItem('requestedDate', date);
+    }
+
+    logins(uuid: string): Promise<VirtualMachineLogins> {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .get(`virtual_machines/${uuid}/logins`),
+            this.actions
+        );
+    }
+
+    getAllLogins(): Promise<VirtualMachineLogins> {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .get('virtual_machines/get_all_logins'),
+            this.actions
+        );
+    }
+}
\ No newline at end of file
diff --git a/services/workbench2/src/services/vocabulary-service/vocabulary-service.ts b/services/workbench2/src/services/vocabulary-service/vocabulary-service.ts
new file mode 100644 (file)
index 0000000..38163f7
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import Axios from 'axios';
+import { Vocabulary } from 'models/vocabulary';
+
+export class VocabularyService {
+    constructor(
+        private url: string
+    ) { }
+
+    async getVocabulary() {
+        const response = await Axios
+            .get<Vocabulary>(this.url);
+        return response.data;
+    }
+}
diff --git a/services/workbench2/src/services/workflow-service/workflow-service.ts b/services/workbench2/src/services/workflow-service/workflow-service.ts
new file mode 100644 (file)
index 0000000..8495421
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { CommonResourceService } from "services/common-service/common-resource-service";
+import { WorkflowResource } from 'models/workflow';
+import { ApiActions } from 'services/api/api-actions';
+import { LinkService } from 'services/link-service/link-service';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { LinkClass } from 'models/link';
+import { OrderBuilder } from 'services/api/order-builder';
+
+export class WorkflowService extends CommonResourceService<WorkflowResource> {
+
+    private linksService = new LinkService(this.serverApi, this.actions);
+
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "workflows", actions);
+    }
+
+    async presets(workflowUuid: string) {
+
+        const { items: presetLinks } = await this.linksService.list({
+            filters: new FilterBuilder()
+                .addEqual('tail_uuid', workflowUuid)
+                .addEqual('link_class', LinkClass.PRESET)
+                .getFilters()
+        });
+
+        const presetUuids = presetLinks.map(link => link.headUuid);
+
+        return this.list({
+            filters: new FilterBuilder()
+                .addIn('uuid', presetUuids)
+                .getFilters(),
+            order: new OrderBuilder<WorkflowResource>()
+                .addAsc('name')
+                .getOrder(),
+        });
+
+    }
+
+}
diff --git a/services/workbench2/src/store/advanced-tab/advanced-tab.tsx b/services/workbench2/src/store/advanced-tab/advanced-tab.tsx
new file mode 100644 (file)
index 0000000..fedd551
--- /dev/null
@@ -0,0 +1,647 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { RootState } from 'store/store';
+import { ResourceKind, extractUuidKind } from 'models/resource';
+import { getResource } from 'store/resources/resources';
+import { GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { ContainerRequestResource } from 'models/container-request';
+import { CollectionResource } from 'models/collection';
+import { ProjectResource } from 'models/project';
+import { ServiceRepository } from 'services/services';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { ListResults } from 'services/common-service/common-service';
+import { RepositoryResource } from 'models/repositories';
+import { SshKeyResource } from 'models/ssh-key';
+import { VirtualMachinesResource } from 'models/virtual-machines';
+import { UserResource } from 'models/user';
+import { LinkResource } from 'models/link';
+import { WorkflowResource } from 'models/workflow';
+import { KeepServiceResource } from 'models/keep-services';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import React from 'react';
+
+export const ADVANCED_TAB_DIALOG = 'advancedTabDialog';
+
+export interface AdvancedTabDialogData {
+    uuid: string;
+    apiResponse: JSX.Element;
+    metadata: ListResults<LinkResource> | string;
+    user: UserResource | string;
+    pythonHeader: string;
+    pythonExample: string;
+    cliGetHeader: string;
+    cliGetExample: string;
+    cliUpdateHeader: string;
+    cliUpdateExample: string;
+    curlHeader: string;
+    curlExample: string;
+}
+
+enum CollectionData {
+    COLLECTION = 'collection',
+    STORAGE_CLASSES_CONFIRMED = 'storage_classes_confirmed'
+}
+
+enum ProcessData {
+    CONTAINER_REQUEST = 'container_request',
+    OUTPUT_NAME = 'output_name'
+}
+
+enum ProjectData {
+    GROUP = 'group',
+    DELETE_AT = 'delete_at'
+}
+
+enum RepositoryData {
+    REPOSITORY = 'repository',
+    CREATED_AT = 'created_at'
+}
+
+enum SshKeyData {
+    SSH_KEY = 'authorized_key',
+    CREATED_AT = 'created_at'
+}
+
+enum VirtualMachineData {
+    VIRTUAL_MACHINE = 'virtual_machine',
+    CREATED_AT = 'created_at'
+}
+
+enum ResourcePrefix {
+    REPOSITORIES = 'repositories',
+    AUTORIZED_KEYS = 'authorized_keys',
+    VIRTUAL_MACHINES = 'virtual_machines',
+    KEEP_SERVICES = 'keep_services',
+    USERS = 'users',
+    API_CLIENT_AUTHORIZATIONS = 'api_client_authorizations',
+    LINKS = 'links'
+}
+
+enum KeepServiceData {
+    KEEP_SERVICE = 'keep_services',
+    CREATED_AT = 'created_at'
+}
+
+enum UserData {
+    USER = 'user',
+    USERNAME = 'username'
+}
+
+enum ApiClientAuthorizationsData {
+    API_CLIENT_AUTHORIZATION = 'api_client_authorization',
+    DEFAULT_OWNER_UUID = 'default_owner_uuid'
+}
+
+enum LinkData {
+    LINK = 'link',
+    PROPERTIES = 'properties'
+}
+
+enum WorkflowData {
+    WORKFLOW = 'workflow',
+    CREATED_AT = 'created_at'
+}
+
+type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData | KeepServiceData | ApiClientAuthorizationsData | UserData | LinkData | WorkflowData;
+type AdvanceResourcePrefix = GroupContentsResourcePrefix | ResourcePrefix;
+type AdvanceResponseData = ContainerRequestResource | ProjectResource | CollectionResource | RepositoryResource | SshKeyResource | VirtualMachinesResource | KeepServiceResource | ApiClientAuthorization | UserResource | LinkResource | WorkflowResource | undefined;
+
+export const openAdvancedTabDialog = (uuid: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const kind = extractUuidKind(uuid);
+        switch (kind) {
+            case ResourceKind.COLLECTION:
+                const { data: dataCollection, metadata: metaCollection, user: userCollection } = await dispatch<any>(getDataForAdvancedTab(uuid));
+                const advanceDataCollection = advancedTabData({
+                    uuid,
+                    metadata: metaCollection,
+                    user: userCollection,
+                    apiResponseKind: collectionApiResponse,
+                    data: dataCollection,
+                    resourceKind: CollectionData.COLLECTION,
+                    resourcePrefix: GroupContentsResourcePrefix.COLLECTION,
+                    resourceKindProperty: CollectionData.STORAGE_CLASSES_CONFIRMED,
+                    property: dataCollection.storageClassesConfirmed
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataCollection));
+                break;
+            case ResourceKind.PROCESS:
+                const { data: dataProcess, metadata: metaProcess, user: userProcess } = await dispatch<any>(getDataForAdvancedTab(uuid));
+                const advancedDataProcess = advancedTabData({
+                    uuid,
+                    metadata: metaProcess,
+                    user: userProcess,
+                    apiResponseKind: containerRequestApiResponse,
+                    data: dataProcess,
+                    resourceKind: ProcessData.CONTAINER_REQUEST,
+                    resourcePrefix: GroupContentsResourcePrefix.PROCESS,
+                    resourceKindProperty: ProcessData.OUTPUT_NAME,
+                    property: dataProcess.outputName
+                });
+                dispatch<any>(initAdvancedTabDialog(advancedDataProcess));
+                break;
+            case ResourceKind.PROJECT:
+                const { data: dataProject, metadata: metaProject, user: userProject } = await dispatch<any>(getDataForAdvancedTab(uuid));
+                const advanceDataProject = advancedTabData({
+                    uuid,
+                    metadata: metaProject,
+                    user: userProject,
+                    apiResponseKind: groupRequestApiResponse,
+                    data: dataProject,
+                    resourceKind: ProjectData.GROUP,
+                    resourcePrefix: GroupContentsResourcePrefix.PROJECT,
+                    resourceKindProperty: ProjectData.DELETE_AT,
+                    property: dataProject.deleteAt
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataProject));
+                break;
+            case ResourceKind.REPOSITORY:
+                const dataRepository = getState().repositories.items.find(it => it.uuid === uuid);
+                const advanceDataRepository = advancedTabData({
+                    uuid,
+                    metadata: '',
+                    user: '',
+                    apiResponseKind: repositoryApiResponse,
+                    data: dataRepository,
+                    resourceKind: RepositoryData.REPOSITORY,
+                    resourcePrefix: ResourcePrefix.REPOSITORIES,
+                    resourceKindProperty: RepositoryData.CREATED_AT,
+                    property: dataRepository!.createdAt
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataRepository));
+                break;
+            case ResourceKind.SSH_KEY:
+                const dataSshKey = getState().auth.sshKeys.find(it => it.uuid === uuid);
+                const advanceDataSshKey = advancedTabData({
+                    uuid,
+                    metadata: '',
+                    user: '',
+                    apiResponseKind: sshKeyApiResponse,
+                    data: dataSshKey,
+                    resourceKind: SshKeyData.SSH_KEY,
+                    resourcePrefix: ResourcePrefix.AUTORIZED_KEYS,
+                    resourceKindProperty: SshKeyData.CREATED_AT,
+                    property: dataSshKey!.createdAt
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataSshKey));
+                break;
+            case ResourceKind.VIRTUAL_MACHINE:
+                const dataVirtualMachine = getState().virtualMachines.virtualMachines.items.find(it => it.uuid === uuid);
+                const advanceDataVirtualMachine = advancedTabData({
+                    uuid,
+                    metadata: '',
+                    user: '',
+                    apiResponseKind: virtualMachineApiResponse,
+                    data: dataVirtualMachine,
+                    resourceKind: VirtualMachineData.VIRTUAL_MACHINE,
+                    resourcePrefix: ResourcePrefix.VIRTUAL_MACHINES,
+                    resourceKindProperty: VirtualMachineData.CREATED_AT,
+                    property: dataVirtualMachine.createdAt
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataVirtualMachine));
+                break;
+            case ResourceKind.KEEP_SERVICE:
+                const dataKeepService = getState().keepServices.find(it => it.uuid === uuid);
+                const advanceDataKeepService = advancedTabData({
+                    uuid,
+                    metadata: '',
+                    user: '',
+                    apiResponseKind: keepServiceApiResponse,
+                    data: dataKeepService,
+                    resourceKind: KeepServiceData.KEEP_SERVICE,
+                    resourcePrefix: ResourcePrefix.KEEP_SERVICES,
+                    resourceKindProperty: KeepServiceData.CREATED_AT,
+                    property: dataKeepService!.createdAt
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataKeepService));
+                break;
+            case ResourceKind.USER:
+                const { resources } = getState();
+                const data = getResource<UserResource>(uuid)(resources);
+                const metadata = await services.linkService.list({
+                    filters: new FilterBuilder()
+                        .addEqual('head_uuid', uuid)
+                        .getFilters()
+                });
+                const advanceDataUser = advancedTabData({
+                    uuid,
+                    metadata,
+                    user: '',
+                    apiResponseKind: userApiResponse,
+                    data,
+                    resourceKind: UserData.USER,
+                    resourcePrefix: ResourcePrefix.USERS,
+                    resourceKindProperty: UserData.USERNAME,
+                    property: data!.username
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataUser));
+                break;
+            case ResourceKind.API_CLIENT_AUTHORIZATION:
+                const apiClientAuthorizationResources = getState().resources;
+                const dataApiClientAuthorization = getResource<ApiClientAuthorization>(uuid)(apiClientAuthorizationResources);
+                const advanceDataApiClientAuthorization = advancedTabData({
+                    uuid,
+                    metadata: '',
+                    user: '',
+                    apiResponseKind: apiClientAuthorizationApiResponse,
+                    data: dataApiClientAuthorization,
+                    resourceKind: ApiClientAuthorizationsData.API_CLIENT_AUTHORIZATION,
+                    resourcePrefix: ResourcePrefix.API_CLIENT_AUTHORIZATIONS,
+                    resourceKindProperty: ApiClientAuthorizationsData.DEFAULT_OWNER_UUID,
+                    property: dataApiClientAuthorization!.defaultOwnerUuid
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataApiClientAuthorization));
+                break;
+            case ResourceKind.LINK:
+                const linkResources = getState().resources;
+                const dataLink = getResource<LinkResource>(uuid)(linkResources);
+                const advanceDataLink = advancedTabData({
+                    uuid,
+                    metadata: '',
+                    user: '',
+                    apiResponseKind: linkApiResponse,
+                    data: dataLink,
+                    resourceKind: LinkData.LINK,
+                    resourcePrefix: ResourcePrefix.LINKS,
+                    resourceKindProperty: LinkData.PROPERTIES,
+                    property: dataLink!.properties
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataLink));
+                break;
+            case ResourceKind.WORKFLOW:
+                const wfResources = getState().resources;
+                const dataWf = getResource<WorkflowResource>(uuid)(wfResources);
+                const advanceDataWf = advancedTabData({
+                    uuid,
+                    metadata: '',
+                    user: '',
+                    apiResponseKind: wfApiResponse,
+                    data: dataWf,
+                    resourceKind: WorkflowData.WORKFLOW,
+                    resourcePrefix: GroupContentsResourcePrefix.WORKFLOW,
+                    resourceKindProperty: WorkflowData.CREATED_AT,
+                    property: dataWf!.createdAt
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataWf));
+                break;
+
+            default:
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not open advanced tab for this resource.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+const getDataForAdvancedTab = (uuid: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<any>(uuid)(resources);
+        const metadata = await services.linkService.list({
+            filters: new FilterBuilder()
+                .addEqual('head_uuid', uuid)
+                .getFilters()
+        });
+
+        return { data, metadata };
+    };
+
+const initAdvancedTabDialog = (data: AdvancedTabDialogData) => dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data });
+
+interface AdvancedTabData {
+    uuid: string;
+    metadata: ListResults<LinkResource> | string;
+    user: UserResource | string;
+    apiResponseKind: (apiResponse) => JSX.Element;
+    data: AdvanceResponseData;
+    resourceKind: AdvanceResourceKind;
+    resourcePrefix: AdvanceResourcePrefix;
+    resourceKindProperty: AdvanceResourceKind;
+    property: any;
+}
+
+const advancedTabData = ({ uuid, user, metadata, apiResponseKind, data, resourceKind, resourcePrefix, resourceKindProperty, property }: AdvancedTabData) => {
+    return {
+        uuid,
+        user,
+        metadata,
+        apiResponse: apiResponseKind(data),
+        pythonHeader: pythonHeader(resourceKind),
+        pythonExample: pythonExample(uuid, resourcePrefix),
+        cliGetHeader: cliGetHeader(resourceKind),
+        cliGetExample: cliGetExample(uuid, resourceKind),
+        cliUpdateHeader: cliUpdateHeader(resourceKind, resourceKindProperty),
+        cliUpdateExample: cliUpdateExample(uuid, resourceKind, property, resourceKindProperty),
+        curlHeader: curlHeader(resourceKind, resourceKindProperty),
+        curlExample: curlExample(uuid, resourcePrefix, property, resourceKind, resourceKindProperty),
+    };
+};
+
+const pythonHeader = (resourceKind: string) =>
+    `An example python command to get a ${resourceKind} using its uuid:`;
+
+const pythonExample = (uuid: string, resourcePrefix: string) => {
+    const pythonExample = `import arvados
+
+x = arvados.api().${resourcePrefix}().get(uuid='${uuid}').execute()`;
+
+    return pythonExample;
+};
+
+const cliGetHeader = (resourceKind: string) =>
+    `An example arv command to get a ${resourceKind} using its uuid:`;
+
+const cliGetExample = (uuid: string, resourceKind: string) => {
+    const cliGetExample = `arv ${resourceKind} get \\
+  --uuid ${uuid}`;
+
+    return cliGetExample;
+};
+
+const cliUpdateHeader = (resourceKind: string, resourceName: string) =>
+    `An example arv command to update the "${resourceName}" attribute for the current ${resourceKind}:`;
+
+const cliUpdateExample = (uuid: string, resourceKind: string, resource: string | string[], resourceName: string) => {
+    const CLIUpdateCollectionExample = `arv ${resourceKind} update \\
+  --uuid ${uuid} \\
+  --${resourceKind} '{"${resourceName}":${JSON.stringify(resource)}}'`;
+
+    return CLIUpdateCollectionExample;
+};
+
+const curlHeader = (resourceKind: string, resource: string) =>
+    `An example curl command to update the "${resource}" attribute for the current ${resourceKind}:`;
+
+const curlExample = (uuid: string, resourcePrefix: string, resource: string | string[], resourceKind: string, resourceName: string) => {
+    const curlExample = `curl -X PUT \\
+  -H "Authorization: OAuth2 $ARVADOS_API_TOKEN" \\
+  --data-urlencode ${resourceKind}@/dev/stdin \\
+  https://$ARVADOS_API_HOST/arvados/v1/${resourcePrefix}/${uuid} \\
+  <<EOF
+{
+  "${resourceName}": ${JSON.stringify(resource, null, 4)}
+}
+EOF`;
+
+    return curlExample;
+};
+
+const stringify = (item: string | null | number | boolean) =>
+    JSON.stringify(item) || 'null';
+
+const stringifyObject = (item: any) =>
+    JSON.stringify(item, null, 2) || 'null';
+
+const containerRequestApiResponse = (apiResponse: ContainerRequestResource): JSX.Element => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, properties, state, requestingContainerUuid, containerUuid,
+        containerCountMax, mounts, runtimeConstraints, containerImage, environment, cwd, command, outputPath, priority, expiresAt, filters, containerCount,
+        useExisting, schedulingParameters, outputUuid, logUuid, outputName, outputTtl } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"created_at": "${createdAt}",
+"modified_at": ${stringify(modifiedAt)},
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"name": ${stringify(name)},
+"description": ${stringify(description)},
+"properties": ${stringifyObject(properties)},
+"state": ${stringify(state)},
+"requesting_container_uuid": ${stringify(requestingContainerUuid)},
+"container_uuid": ${stringify(containerUuid)},
+"container_count_max": ${stringify(containerCountMax)},
+"mounts": ${stringifyObject(mounts)},
+"runtime_constraints": ${stringifyObject(runtimeConstraints)},
+"container_image": ${stringify(containerImage)},
+"environment": ${stringifyObject(environment)},
+"cwd": ${stringify(cwd)},
+"command": ${stringifyObject(command)},
+"output_path": ${stringify(outputPath)},
+"priority": ${stringify(priority)},
+"expires_at": ${stringify(expiresAt)},
+"filters": ${stringify(filters)},
+"container_count": ${stringify(containerCount)},
+"use_existing": ${stringify(useExisting)},
+"scheduling_parameters": ${stringifyObject(schedulingParameters)},
+"output_uuid": ${stringify(outputUuid)},
+"log_uuid": ${stringify(logUuid)},
+"output_name": ${stringify(outputName)},
+"output_ttl": ${stringify(outputTtl)}`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+const collectionApiResponse = (apiResponse: CollectionResource): JSX.Element => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, properties, portableDataHash, replicationDesired,
+        replicationConfirmedAt, replicationConfirmed, deleteAt, trashAt, isTrashed, storageClassesDesired,
+        storageClassesConfirmed, storageClassesConfirmedAt, currentVersionUuid, version, preserveVersion, fileCount, fileSizeTotal } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"created_at": "${createdAt}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"portable_data_hash": ${stringify(portableDataHash)},
+"replication_desired": ${stringify(replicationDesired)},
+"replication_confirmed_at": ${stringify(replicationConfirmedAt)},
+"replication_confirmed": ${stringify(replicationConfirmed)},
+"name": ${stringify(name)},
+"description": ${stringify(description)},
+"properties": ${stringifyObject(properties)},
+"delete_at": ${stringify(deleteAt)},
+"trash_at": ${stringify(trashAt)},
+"is_trashed": ${stringify(isTrashed)},
+"storage_classes_desired": ${JSON.stringify(storageClassesDesired, null, 2)},
+"storage_classes_confirmed": ${JSON.stringify(storageClassesConfirmed, null, 2)},
+"storage_classes_confirmed_at": ${stringify(storageClassesConfirmedAt)},
+"current_version_uuid": ${stringify(currentVersionUuid)},
+"version": ${version},
+"preserve_version": ${preserveVersion},
+"file_count": ${fileCount},
+"file_size_total": ${fileSizeTotal}`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+const groupRequestApiResponse = (apiResponse: ProjectResource): JSX.Element => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name,
+        description, groupClass, trashAt, isTrashed, deleteAt, properties,
+        canWrite, canManage } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"created_at": "${createdAt}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"name": ${stringify(name)},
+"description": ${stringify(description)},
+"group_class": ${stringify(groupClass)},
+"trash_at": ${stringify(trashAt)},
+"is_trashed": ${stringify(isTrashed)},
+"delete_at": ${stringify(deleteAt)},
+"properties": ${stringifyObject(properties)},
+"can_write": ${stringify(canWrite)},
+"can_manage": ${stringify(canManage)}`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+const repositoryApiResponse = (apiResponse: RepositoryResource): JSX.Element => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, cloneUrls } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"name": ${stringify(name)},
+"created_at": "${createdAt}",
+"clone_urls": ${stringifyObject(cloneUrls)}`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+const sshKeyApiResponse = (apiResponse: SshKeyResource): JSX.Element => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, authorizedUserUuid, expiresAt } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"authorized_user_uuid": "${authorizedUserUuid}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"name": ${stringify(name)},
+"created_at": "${createdAt}",
+"expires_at": "${expiresAt}"`;
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+const virtualMachineApiResponse = (apiResponse: VirtualMachinesResource): JSX.Element => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, hostname } = apiResponse;
+    const response = `
+"hostname": ${stringify(hostname)},
+"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"modified_at": ${stringify(modifiedAt)},
+"created_at": "${createdAt}"`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+const keepServiceApiResponse = (apiResponse: KeepServiceResource): JSX.Element => {
+    const {
+        uuid, readOnly, serviceHost, servicePort, serviceSslFlag, serviceType,
+        ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid
+    } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"service_host": "${serviceHost}",
+"service_port": "${servicePort}",
+"service_ssl_flag": "${stringify(serviceSslFlag)}",
+"service_type": "${serviceType}",
+"created_at": "${createdAt}",
+"read_only": "${stringify(readOnly)}"`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+const userApiResponse = (apiResponse: UserResource): JSX.Element => {
+    const {
+        uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid,
+        email, firstName, lastName, username, isActive, isAdmin, prefs, defaultOwnerUuid,
+    } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"created_at": "${createdAt}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"email": "${email}",
+"first_name": "${firstName}",
+"last_name": "${stringify(lastName)}",
+"username": "${username}",
+"is_active": "${isActive},
+"is_admin": "${isAdmin},
+"prefs": "${stringifyObject(prefs)},
+"default_owner_uuid": "${defaultOwnerUuid},
+"username": "${username}"`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+const apiClientAuthorizationApiResponse = (apiResponse: ApiClientAuthorization): JSX.Element => {
+    const {
+        uuid, ownerUuid, apiToken, apiClientId, userId, createdByIpAddress, lastUsedByIpAddress,
+        lastUsedAt, expiresAt, defaultOwnerUuid, scopes, updatedAt, createdAt
+    } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"api_token": "${stringify(apiToken)}",
+"api_client_id": "${stringify(apiClientId)}",
+"user_id": "${stringify(userId)}",
+"created_by_ip_address": "${stringify(createdByIpAddress)}",
+"last_used_by_ip_address": "${stringify(lastUsedByIpAddress)}",
+"last_used_at": "${stringify(lastUsedAt)}",
+"expires_at": "${stringify(expiresAt)}",
+"created_at": "${stringify(createdAt)}",
+"updated_at": "${stringify(updatedAt)}",
+"default_owner_uuid": "${stringify(defaultOwnerUuid)}",
+"scopes": "${JSON.stringify(scopes, null, 2)}"`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+const linkApiResponse = (apiResponse: LinkResource): JSX.Element => {
+    const {
+        uuid, name, headUuid, properties, headKind, tailUuid, tailKind, linkClass,
+        ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid
+    } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"name": "${name}",
+"head_uuid": "${headUuid}",
+"head_kind": "${headKind}",
+"tail_uuid": "${tailUuid}",
+"tail_kind": "${tailKind}",
+"link_class": "${linkClass}",
+"owner_uuid": "${ownerUuid}",
+"created_at": "${stringify(createdAt)}",
+"modified_at": ${stringify(modifiedAt)},
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"properties": "${JSON.stringify(properties, null, 2)}"`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
+
+
+const wfApiResponse = (apiResponse: WorkflowResource): JSX.Element => {
+    const {
+        uuid, name,
+        ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, description
+    } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"name": "${name}",
+"owner_uuid": "${ownerUuid}",
+"created_at": "${stringify(createdAt)}",
+"modified_at": ${stringify(modifiedAt)},
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)}
+"description": ${stringify(description)}`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
diff --git a/services/workbench2/src/store/all-processes-panel/all-processes-panel-action.ts b/services/workbench2/src/store/all-processes-panel/all-processes-panel-action.ts
new file mode 100644 (file)
index 0000000..c33cd82
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
+
+export const ALL_PROCESSES_PANEL_ID = "allProcessesPanel";
+export const allProcessesPanelActions = bindDataExplorerActions(ALL_PROCESSES_PANEL_ID);
+
+export const loadAllProcessesPanel = () => (dispatch: Dispatch) => {
+    dispatch(allProcessesPanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(allProcessesPanelActions.REQUEST_ITEMS());
+}
diff --git a/services/workbench2/src/store/all-processes-panel/all-processes-panel-middleware-service.ts b/services/workbench2/src/store/all-processes-panel/all-processes-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..079cf11
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { getDataExplorerColumnFilters } from "store/data-explorer/data-explorer-middleware-service";
+import { RootState } from "../store";
+import { ServiceRepository } from "services/services";
+import { joinFilters } from "services/api/filter-builder";
+import { allProcessesPanelActions } from "./all-processes-panel-action";
+import { Dispatch, MiddlewareAPI } from "redux";
+import { DataExplorer } from "store/data-explorer/data-explorer-reducer";
+import { DataColumns } from "components/data-table/data-table";
+import {
+    serializeOnlyProcessTypeFilters
+} from "../resource-type-filters/resource-type-filters";
+import { AllProcessesPanelColumnNames } from "views/all-processes-panel/all-processes-panel";
+import { ProcessesMiddlewareService } from "store/processes/processes-middleware-service";
+import { ContainerRequestResource } from 'models/container-request';
+
+export class AllProcessesPanelMiddlewareService extends ProcessesMiddlewareService {
+    constructor(services: ServiceRepository, id: string) {
+        super(services, allProcessesPanelActions, id);
+    }
+
+    getFilters(api: MiddlewareAPI<Dispatch, RootState>, dataExplorer: DataExplorer): string | null {
+        const sup = super.getFilters(api, dataExplorer);
+        if (sup === null) { return null; }
+        const columns = dataExplorer.columns as DataColumns<string, ContainerRequestResource>;
+
+        const typeFilters = serializeOnlyProcessTypeFilters(getDataExplorerColumnFilters(columns, AllProcessesPanelColumnNames.TYPE));
+        return joinFilters(sup, typeFilters);
+    }
+}
diff --git a/services/workbench2/src/store/api-client-authorizations/api-client-authorizations-actions.ts b/services/workbench2/src/store/api-client-authorizations/api-client-authorizations-actions.ts
new file mode 100644 (file)
index 0000000..47c14f9
--- /dev/null
@@ -0,0 +1,80 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from 'store/store';
+import { setBreadcrumbs } from 'store/breadcrumbs/breadcrumbs-actions';
+import { ServiceRepository } from "services/services";
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { snackbarActions } from 'store/snackbar/snackbar-actions';
+import { navigateToRootProject } from 'store/navigation/navigation-action';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { getResource } from 'store/resources/resources';
+
+
+export const API_CLIENT_AUTHORIZATION_PANEL_ID = 'apiClientAuthorizationPanelId';
+export const apiClientAuthorizationsActions = bindDataExplorerActions(API_CLIENT_AUTHORIZATION_PANEL_ID);
+
+export const API_CLIENT_AUTHORIZATION_REMOVE_DIALOG = 'apiClientAuthorizationRemoveDialog';
+export const API_CLIENT_AUTHORIZATION_ATTRIBUTES_DIALOG = 'apiClientAuthorizationAttributesDialog';
+export const API_CLIENT_AUTHORIZATION_HELP_DIALOG = 'apiClientAuthorizationHelpDialog';
+
+
+export const loadApiClientAuthorizationsPanel = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const user = getState().auth.user;
+        if (user && user.isAdmin) {
+            try {
+                dispatch(setBreadcrumbs([{ label: 'Api client authorizations' }]));
+                dispatch(apiClientAuthorizationsActions.REQUEST_ITEMS());
+            } catch (e) {
+                return;
+            }
+        } else {
+            dispatch(navigateToRootProject);
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "You don't have permissions to view this page", hideDuration: 2000 }));
+        }
+    };
+
+export const openApiClientAuthorizationAttributesDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const { resources } = getState();
+        const apiClientAuthorization = getResource<ApiClientAuthorization>(uuid)(resources);
+        dispatch(dialogActions.OPEN_DIALOG({ id: API_CLIENT_AUTHORIZATION_ATTRIBUTES_DIALOG, data: { apiClientAuthorization } }));
+    };
+
+export const openApiClientAuthorizationRemoveDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: API_CLIENT_AUTHORIZATION_REMOVE_DIALOG,
+            data: {
+                title: 'Remove api client authorization',
+                text: 'Are you sure you want to remove this api client authorization?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeApiClientAuthorization = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
+        try {
+            await services.apiClientAuthorizationService.delete(uuid);
+            dispatch(apiClientAuthorizationsActions.REQUEST_ITEMS());
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Api client authorization has been successfully removed.', hideDuration: 2000 }));
+        } catch (e) {
+            return;
+        }
+    };
+
+export const openApiClientAuthorizationsHelpDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const apiHost = getState().properties.apiHost;
+        const user = getState().auth.user;
+        const email = user ? user.email : '';
+        const apiToken = getState().auth.apiToken;
+        dispatch(dialogActions.OPEN_DIALOG({ id: API_CLIENT_AUTHORIZATION_HELP_DIALOG, data: { apiHost, apiToken, email } }));
+    };
\ No newline at end of file
diff --git a/services/workbench2/src/store/api-client-authorizations/api-client-authorizations-middleware-service.ts b/services/workbench2/src/store/api-client-authorizations/api-client-authorizations-middleware-service.ts
new file mode 100644 (file)
index 0000000..9ab0254
--- /dev/null
@@ -0,0 +1,49 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { DataExplorerMiddlewareService, dataExplorerToListParams, getOrder, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
+import { updateResources } from 'store/resources/resources-actions';
+import { apiClientAuthorizationsActions } from 'store/api-client-authorizations/api-client-authorizations-actions';
+import { ListResults } from 'services/common-service/common-service';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+
+export class ApiClientAuthorizationMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const state = api.getState();
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+        try {
+            const response = await this.services.apiClientAuthorizationService.list(getParams(dataExplorer));
+            api.dispatch(updateResources(response.items));
+            api.dispatch(setItems(response));
+        } catch {
+            api.dispatch(couldNotFetchLinks());
+        }
+    }
+}
+
+export const getParams = (dataExplorer: DataExplorer) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    order: getOrder<ApiClientAuthorization>(dataExplorer)
+});
+
+export const setItems = (listResults: ListResults<ApiClientAuthorization>) =>
+    apiClientAuthorizationsActions.SET_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
+
+const couldNotFetchLinks = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch api client authorizations.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/app-info/app-info-actions.ts b/services/workbench2/src/store/app-info/app-info-actions.ts
new file mode 100644 (file)
index 0000000..0d79595
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from 'common/unionize';
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { getBuildInfo } from 'common/app-info';
+
+export const appInfoActions = unionize({
+    SET_BUILD_INFO: ofType<string>()
+});
+
+export type AppInfoAction = UnionOf<typeof appInfoActions>;
+
+export const setBuildInfo = () => 
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) =>
+        dispatch(appInfoActions.SET_BUILD_INFO(getBuildInfo()));
+
+
+
diff --git a/services/workbench2/src/store/app-info/app-info-reducer.ts b/services/workbench2/src/store/app-info/app-info-reducer.ts
new file mode 100644 (file)
index 0000000..b095f09
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { appInfoActions, AppInfoAction } from "store/app-info/app-info-actions";
+
+export interface AppInfoState {
+    buildInfo: string;
+}
+
+const initialState = {
+    buildInfo: ''
+};
+
+export const appInfoReducer = (state: AppInfoState = initialState, action: AppInfoAction) =>
+    appInfoActions.match(action, {
+        SET_BUILD_INFO: buildInfo => ({ ...state, buildInfo }),
+        default: () => state
+    });
diff --git a/services/workbench2/src/store/auth/auth-action-session.ts b/services/workbench2/src/store/auth/auth-action-session.ts
new file mode 100644 (file)
index 0000000..7e81f2d
--- /dev/null
@@ -0,0 +1,329 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { setBreadcrumbs } from "store/breadcrumbs/breadcrumbs-actions";
+import { RootState } from "store/store";
+import { ServiceRepository, createServices, setAuthorizationHeader } from "services/services";
+import Axios, { AxiosInstance } from "axios";
+import { User, getUserDisplayName } from "models/user";
+import { authActions } from "store/auth/auth-action";
+import {
+    Config, ClusterConfigJSON, CLUSTER_CONFIG_PATH, DISCOVERY_DOC_PATH,
+    buildConfig, mockClusterConfigJSON
+} from "common/config";
+import { normalizeURLPath } from "common/url";
+import { Session, SessionStatus } from "models/session";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { AuthService } from "services/auth-service/auth-service";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import jsSHA from "jssha";
+
+const getClusterConfig = async (origin: string, apiClient: AxiosInstance): Promise<Config | null> => {
+    let configFromDD: Config | undefined;
+    try {
+        const dd = (await apiClient.get<any>(`${origin}/${DISCOVERY_DOC_PATH}`)).data;
+        configFromDD = {
+            baseUrl: normalizeURLPath(dd.baseUrl),
+            keepWebServiceUrl: dd.keepWebServiceUrl,
+            keepWebInlineServiceUrl: dd.keepWebInlineServiceUrl,
+            remoteHosts: dd.remoteHosts,
+            rootUrl: dd.rootUrl,
+            uuidPrefix: dd.uuidPrefix,
+            websocketUrl: dd.websocketUrl,
+            workbenchUrl: dd.workbenchUrl,
+            workbench2Url: dd.workbench2Url,
+            loginCluster: "",
+            vocabularyUrl: "",
+            fileViewersConfigUrl: "",
+            clusterConfig: mockClusterConfigJSON({}),
+            apiRevision: parseInt(dd.revision, 10),
+        };
+    } catch { }
+
+    // Try public config endpoint
+    try {
+        const config = (await apiClient.get<ClusterConfigJSON>(`${origin}/${CLUSTER_CONFIG_PATH}`)).data;
+        return { ...buildConfig(config), apiRevision: configFromDD ? configFromDD.apiRevision : 0 };
+    } catch { }
+
+    // Fall back to discovery document
+    if (configFromDD !== undefined) {
+        return configFromDD;
+    }
+
+    return null;
+};
+
+export const getRemoteHostConfig = async (remoteHost: string, useApiClient?: AxiosInstance): Promise<Config | null> => {
+    const apiClient = useApiClient || Axios.create({ headers: {} });
+
+    let url = remoteHost;
+    if (url.indexOf('://') < 0) {
+        url = 'https://' + url;
+    }
+    const origin = new URL(url).origin;
+
+    // Maybe it is an API server URL, try fetching config and discovery doc
+    let r = await getClusterConfig(origin, apiClient);
+    if (r !== null) {
+        return r;
+    }
+
+    // Maybe it is a Workbench2 URL, try getting config.json
+    try {
+        r = await getClusterConfig((await apiClient.get<any>(`${origin}/config.json`)).data.API_HOST, apiClient);
+        if (r !== null) {
+            return r;
+        }
+    } catch { }
+
+    // Maybe it is a Workbench1 URL, try getting status.json
+    try {
+        r = await getClusterConfig((await apiClient.get<any>(`${origin}/status.json`)).data.apiBaseURL, apiClient);
+        if (r !== null) {
+            return r;
+        }
+    } catch { }
+
+    return null;
+};
+
+const invalidV2Token = "Must be a v2 token";
+
+export const getSaltedToken = (clusterId: string, token: string) => {
+    const shaObj = new jsSHA("SHA-1", "TEXT");
+    const [ver, uuid, secret] = token.split("/");
+    if (ver !== "v2") {
+        throw new Error(invalidV2Token);
+    }
+    let salted = secret;
+    if (uuid.substring(0, 5) !== clusterId) {
+        shaObj.setHMACKey(secret, "TEXT");
+        shaObj.update(clusterId);
+        salted = shaObj.getHMAC("HEX");
+    }
+    return `v2/${uuid}/${salted}`;
+};
+
+export const getActiveSession = (sessions: Session[]): Session | undefined => sessions.find(s => s.active);
+
+export const validateCluster = async (config: Config, useToken: string):
+    Promise<{ user: User; token: string }> => {
+
+    const saltedToken = getSaltedToken(config.uuidPrefix, useToken);
+
+    const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+    setAuthorizationHeader(svc, saltedToken);
+
+    const user = await svc.authService.getUserDetails(false);
+    return {
+        user,
+        token: saltedToken,
+    };
+};
+
+export const validateSession = (session: Session, activeSession: Session, useApiClient?: AxiosInstance) =>
+    async (dispatch: Dispatch): Promise<Session> => {
+        dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED }));
+        session.loggedIn = false;
+
+        const setupSession = (baseUrl: string, user: User, token: string, apiRevision: number) => {
+            session.baseUrl = baseUrl;
+            session.token = token;
+            session.email = user.email;
+            session.userIsActive = user.isActive;
+            session.uuid = user.uuid;
+            session.name = getUserDisplayName(user);
+            session.loggedIn = true;
+            session.apiRevision = apiRevision;
+        };
+
+        let fail: Error | null = null;
+        const config = await getRemoteHostConfig(session.remoteHost, useApiClient);
+        if (config !== null) {
+            dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
+            try {
+                const { user, token } = await validateCluster(config, session.token);
+                setupSession(config.baseUrl, user, token, config.apiRevision);
+            } catch (e) {
+                fail = new Error(`Getting current user for ${session.remoteHost}: ${e.message}`);
+                try {
+                    const { user, token } = await validateCluster(config, activeSession.token);
+                    setupSession(config.baseUrl, user, token, config.apiRevision);
+                    fail = null;
+                } catch (e2) {
+                    if (e.message === invalidV2Token) {
+                        fail = new Error(`Getting current user for ${session.remoteHost}: ${e2.message}`);
+                    }
+                }
+            }
+        } else {
+            fail = new Error(`Could not get config for ${session.remoteHost}`);
+        }
+        session.status = SessionStatus.VALIDATED;
+        dispatch(authActions.UPDATE_SESSION(session));
+
+        if (fail) {
+            throw fail;
+        }
+
+        return session;
+    };
+
+export const validateSessions = (useApiClient?: AxiosInstance) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const sessions = getState().auth.sessions;
+        const activeSession = getActiveSession(sessions);
+        if (activeSession) {
+            dispatch(progressIndicatorActions.START_WORKING("sessionsValidation"));
+            for (const session of sessions) {
+                if (session.status === SessionStatus.INVALIDATED) {
+                    try {
+                       /* Here we are dispatching a function, not an
+                          action.  This is legal (it calls the
+                          function with a 'Dispatch' object as the
+                          first parameter) but the typescript
+                          annotations don't understand this case, so
+                          we get an error from typescript unless
+                          override it using Dispatch<any>.  This
+                          pattern is used in a bunch of different
+                          places in Workbench2. */
+                        await dispatch(validateSession(session, activeSession, useApiClient));
+                    } catch (e) {
+                        // Don't do anything here.  User may get
+                        // spammed with multiple messages that are not
+                        // helpful.  They can see the individual
+                        // errors by going to site manager and trying
+                        // to toggle the session.
+                    }
+                }
+            }
+            services.authService.saveSessions(getState().auth.sessions);
+            dispatch(progressIndicatorActions.STOP_WORKING("sessionsValidation"));
+        }
+    };
+
+export const addRemoteConfig = (remoteHost: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const config = await getRemoteHostConfig(remoteHost);
+        if (!config) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `Could not get config for ${remoteHost}`,
+                kind: SnackbarKind.ERROR
+            }));
+            return;
+        }
+        dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
+    };
+
+export const addSession = (remoteHost: string, token?: string, sendToLogin?: boolean) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const sessions = getState().auth.sessions;
+        const activeSession = getActiveSession(sessions);
+        let useToken: string | null = null;
+        if (token) {
+            useToken = token;
+        } else if (activeSession) {
+            useToken = activeSession.token;
+        }
+
+        if (useToken) {
+            const config = await getRemoteHostConfig(remoteHost);
+            if (!config) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: `Could not get config for ${remoteHost}`,
+                    kind: SnackbarKind.ERROR
+                }));
+                return;
+            }
+
+            try {
+                dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
+                const { user, token } = await validateCluster(config, useToken);
+                const session = {
+                    loggedIn: true,
+                    status: SessionStatus.VALIDATED,
+                    active: false,
+                    email: user.email,
+                    userIsActive: user.isActive,
+                    name: getUserDisplayName(user),
+                    uuid: user.uuid,
+                    baseUrl: config.baseUrl,
+                    clusterId: config.uuidPrefix,
+                    remoteHost,
+                    token,
+                    apiRevision: config.apiRevision,
+                };
+
+                if (sessions.find(s => s.clusterId === config.uuidPrefix)) {
+                    await dispatch(authActions.UPDATE_SESSION(session));
+                } else {
+                    await dispatch(authActions.ADD_SESSION(session));
+                }
+                services.authService.saveSessions(getState().auth.sessions);
+
+                return session;
+            } catch {
+                if (sendToLogin) {
+                    const rootUrl = new URL(config.baseUrl);
+                    rootUrl.pathname = "";
+                    window.location.href = `${rootUrl.toString()}/login?return_to=` + encodeURI(`${window.location.protocol}//${window.location.host}/add-session?baseURL=` + encodeURI(rootUrl.toString()));
+                    return;
+                }
+            }
+        }
+        return Promise.reject(new Error("Could not validate cluster"));
+    };
+
+
+export const removeSession = (clusterId: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        await dispatch(authActions.REMOVE_SESSION(clusterId));
+        services.authService.saveSessions(getState().auth.sessions);
+    };
+
+export const toggleSession = (session: Session) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const s: Session = { ...session };
+
+        if (session.loggedIn) {
+            s.loggedIn = false;
+            dispatch(authActions.UPDATE_SESSION(s));
+        } else {
+            const sessions = getState().auth.sessions;
+            const activeSession = getActiveSession(sessions);
+            if (activeSession) {
+                try {
+                    await dispatch(validateSession(s, activeSession));
+                } catch (e) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: e.message,
+                        kind: SnackbarKind.ERROR
+                    }));
+                    s.loggedIn = false;
+                    dispatch(authActions.UPDATE_SESSION(s));
+                }
+            }
+        }
+
+        services.authService.saveSessions(getState().auth.sessions);
+    };
+
+export const initSessions = (authService: AuthService, config: Config, user: User) =>
+    (dispatch: Dispatch<any>) => {
+        const sessions = authService.buildSessions(config, user);
+        dispatch(authActions.SET_SESSIONS(sessions));
+        dispatch(validateSessions(authService.getApiClient()));
+    };
+
+export const loadSiteManagerPanel = () =>
+    async (dispatch: Dispatch<any>) => {
+        try {
+            dispatch(setBreadcrumbs([{ label: 'Site Manager' }]));
+            dispatch(validateSessions());
+        } catch (e) {
+            return;
+        }
+    };
diff --git a/services/workbench2/src/store/auth/auth-action-ssh.ts b/services/workbench2/src/store/auth/auth-action-ssh.ts
new file mode 100644 (file)
index 0000000..3ba5f3a
--- /dev/null
@@ -0,0 +1,102 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from "store/dialog/dialog-actions";
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository } from "services/services";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { FormErrors, reset, startSubmit, stopSubmit } from "redux-form";
+import { KeyType } from "models/ssh-key";
+import {
+    AuthorizedKeysServiceError,
+    getAuthorizedKeysServiceError
+} from "services/authorized-keys-service/authorized-keys-service";
+import { setBreadcrumbs } from "store/breadcrumbs/breadcrumbs-actions";
+import { authActions } from "store/auth/auth-action";
+
+export const SSH_KEY_CREATE_FORM_NAME = 'sshKeyCreateFormName';
+export const SSH_KEY_PUBLIC_KEY_DIALOG = 'sshKeyPublicKeyDialog';
+export const SSH_KEY_REMOVE_DIALOG = 'sshKeyRemoveDialog';
+export const SSH_KEY_ATTRIBUTES_DIALOG = 'sshKeyAttributesDialog';
+
+export interface SshKeyCreateFormDialogData {
+    publicKey: string;
+    name: string;
+}
+
+export const openSshKeyCreateDialog = () => dialogActions.OPEN_DIALOG({ id: SSH_KEY_CREATE_FORM_NAME, data: {} });
+
+export const openPublicKeyDialog = (name: string, publicKey: string) =>
+    dialogActions.OPEN_DIALOG({ id: SSH_KEY_PUBLIC_KEY_DIALOG, data: { name, publicKey } });
+
+export const openSshKeyAttributesDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sshKey = getState().auth.sshKeys.find(it => it.uuid === uuid);
+        dispatch(dialogActions.OPEN_DIALOG({ id: SSH_KEY_ATTRIBUTES_DIALOG, data: { sshKey } }));
+    };
+
+export const openSshKeyRemoveDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: SSH_KEY_REMOVE_DIALOG,
+            data: {
+                title: 'Remove public key',
+                text: 'Are you sure you want to remove this public key?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeSshKey = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        await services.authorizedKeysService.delete(uuid);
+        dispatch(authActions.REMOVE_SSH_KEY(uuid));
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Public Key has been successfully removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+    };
+
+export const createSshKey = (data: SshKeyCreateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const { name, publicKey } = data;
+        dispatch(startSubmit(SSH_KEY_CREATE_FORM_NAME));
+        try {
+            const newSshKey = await services.authorizedKeysService.create({
+                name,
+                publicKey,
+                keyType: KeyType.SSH,
+                authorizedUserUuid: userUuid
+            });
+            dispatch(authActions.ADD_SSH_KEY(newSshKey));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: SSH_KEY_CREATE_FORM_NAME }));
+            dispatch(reset(SSH_KEY_CREATE_FORM_NAME));
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Public key has been successfully created.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } catch (e) {
+            const error = getAuthorizedKeysServiceError(e);
+            if (error === AuthorizedKeysServiceError.UNIQUE_PUBLIC_KEY) {
+                dispatch(stopSubmit(SSH_KEY_CREATE_FORM_NAME, { publicKey: 'Public key already exists.' } as FormErrors));
+            } else if (error === AuthorizedKeysServiceError.INVALID_PUBLIC_KEY) {
+                dispatch(stopSubmit(SSH_KEY_CREATE_FORM_NAME, { publicKey: 'Public key is invalid' } as FormErrors));
+            }
+        }
+    };
+
+export const loadSshKeysPanel = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            dispatch(setBreadcrumbs([{ label: 'SSH Keys' }]));
+            const response = await services.authorizedKeysService.list();
+            dispatch(authActions.SET_SSH_KEYS(response.items));
+        } catch (e) {
+            return;
+        }
+    };
diff --git a/services/workbench2/src/store/auth/auth-action.test.ts b/services/workbench2/src/store/auth/auth-action.test.ts
new file mode 100644 (file)
index 0000000..ede4192
--- /dev/null
@@ -0,0 +1,362 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { getNewExtraToken, initAuth } from "./auth-action";
+import { API_TOKEN_KEY } from "services/auth-service/auth-service";
+
+import 'jest-localstorage-mock';
+import { ServiceRepository, createServices } from "services/services";
+import { configureStore, RootStore } from "../store";
+import { createBrowserHistory } from "history";
+import { mockConfig } from 'common/config';
+import { ApiActions } from "services/api/api-actions";
+import { ACCOUNT_LINK_STATUS_KEY } from 'services/link-account-service/link-account-service';
+import Axios, { AxiosInstance } from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { ImportMock } from 'ts-mock-imports';
+import * as servicesModule from "services/services";
+import * as authActionSessionModule from "./auth-action-session";
+import { SessionStatus } from "models/session";
+import { getRemoteHostConfig } from "./auth-action-session";
+
+describe('auth-actions', () => {
+    let axiosInst: AxiosInstance;
+    let axiosMock: MockAdapter;
+
+    let store: RootStore;
+    let services: ServiceRepository;
+    const config: any = {};
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+    let importMocks: any[];
+
+    beforeEach(() => {
+        axiosInst = Axios.create({ headers: {} });
+        axiosMock = new MockAdapter(axiosInst);
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+        localStorage.clear();
+        importMocks = [];
+    });
+
+    afterEach(() => {
+        importMocks.map(m => m.restore());
+    });
+
+    it('creates an extra token', async () => {
+        axiosMock
+            .onGet("/users/current")
+            .reply(200, {
+                email: "test@test.com",
+                first_name: "John",
+                last_name: "Doe",
+                uuid: "zzzzz-tpzed-abcefg",
+                owner_uuid: "ownerUuid",
+                is_admin: false,
+                is_active: true,
+                username: "jdoe",
+                prefs: {}
+            })
+            .onGet("/api_client_authorizations/current")
+            .reply(200, {
+                expires_at: "2140-01-01T00:00:00.000Z",
+                api_token: 'extra token',
+            })
+            .onPost("/api_client_authorizations")
+            .replyOnce(200, {
+                uuid: 'zzzzz-gj3su-xxxxxxxxxx',
+                apiToken: 'extra token',
+            })
+            .onPost("/api_client_authorizations")
+            .reply(200, {
+                uuid: 'zzzzz-gj3su-xxxxxxxxxx',
+                apiToken: 'extra additional token',
+            });
+
+        importMocks.push(ImportMock.mockFunction(servicesModule, 'createServices', services));
+        sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
+        localStorage.setItem(API_TOKEN_KEY, "token");
+
+        const config: any = {
+            rootUrl: "https://zzzzz.example.com",
+            uuidPrefix: "zzzzz",
+            remoteHosts: {},
+            apiRevision: 12345678,
+            clusterConfig: {
+                Login: { LoginCluster: "" },
+                Workbench: { UserProfileFormFields: {} }
+            },
+        };
+
+        // Set up auth, confirm that no extra token was requested
+        await store.dispatch(initAuth(config))
+        expect(store.getState().auth.apiToken).toBeDefined();
+        expect(store.getState().auth.extraApiToken).toBeUndefined();
+
+        // Ask for an extra token
+        await store.dispatch(getNewExtraToken());
+        expect(store.getState().auth.apiToken).toBeDefined();
+        expect(store.getState().auth.extraApiToken).toBeDefined();
+        const extraToken = store.getState().auth.extraApiToken;
+
+        // Ask for a cached extra token
+        await store.dispatch(getNewExtraToken(true));
+        expect(store.getState().auth.extraApiToken).toBe(extraToken);
+
+        // Ask for a new extra token, should make a second api request
+        await store.dispatch(getNewExtraToken(false));
+        expect(store.getState().auth.extraApiToken).toBeDefined();
+        expect(store.getState().auth.extraApiToken).not.toBe(extraToken);
+    });
+
+    it('requests remote token data to login cluster', async () => {
+        const localClusterTokenExpiration = "2020-01-01T00:00:00.000Z";
+        const loginClusterTokenExpiration = "2140-01-01T00:00:00.000Z";
+        axiosMock
+            .onGet("/users/current")
+            .reply(200, {
+                email: "test@test.com",
+                first_name: "John",
+                last_name: "Doe",
+                uuid: "zzzz1-tpzed-abcefg",
+                owner_uuid: "ownerUuid",
+                is_admin: false,
+                is_active: true,
+                username: "jdoe",
+                prefs: {}
+            })
+            .onGet("https://zzzz1.example.com/discovery/v1/apis/arvados/v1/rest")
+            .reply(200, {
+                baseUrl: "https://zzzz1.example.com/arvados/v1",
+                keepWebServiceUrl: "",
+                keepWebInlineServiceUrl: "",
+                remoteHosts: {},
+                rootUrl: "https://zzzz1.example.com",
+                uuidPrefix: "zzzz1",
+                websocketUrl: "",
+                workbenchUrl: "",
+                workbench2Url: "",
+                revision: 12345678
+            })
+            // Local cluster -- cached token
+            .onGet("https://zzzzz.example.com/arvados/v1/api_client_authorizations/current")
+            .reply(200, {
+                uuid: 'zzzz1-gj3su-aaaaaaa',
+                expires_at: localClusterTokenExpiration,
+                api_token: 'tokensecret',
+            })
+            // Login cluster -- authoritative token copy
+            .onGet("https://zzzz1.example.com/arvados/v1/api_client_authorizations/current")
+            .reply(200, {
+                uuid: 'zzzz1-gj3su-aaaaaaa',
+                expires_at: loginClusterTokenExpiration,
+                api_token: 'tokensecret',
+            });
+
+        const config: any = {
+            rootUrl: "https://zzzzz.example.com",
+            uuidPrefix: "zzzzz",
+            remoteHosts: { zzzz1: "zzzz1.example.com" },
+            apiRevision: 12345678,
+            clusterConfig: {
+                Login: { LoginCluster: "zzzz1" },
+                Workbench: { UserProfileFormFields: {} }
+            },
+        };
+
+        const remoteHostConfig = await getRemoteHostConfig(config.remoteHosts.zzzz1, axiosInst);
+        expect(remoteHostConfig).not.toBeFalsy;
+        services = createServices(remoteHostConfig!, actions, axiosInst);
+
+        importMocks.push(ImportMock.mockFunction(authActionSessionModule, 'getRemoteHostConfig', remoteHostConfig));
+        importMocks.push(ImportMock.mockFunction(servicesModule, 'createServices', services));
+
+        sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
+        localStorage.setItem(API_TOKEN_KEY, "v2/zzzz1-gj3su-aaaaaaa/tokensecret");
+
+        await store.dispatch(initAuth(config));
+        expect(store.getState().auth.apiToken).toBeDefined();
+        expect(localClusterTokenExpiration).not.toBe(loginClusterTokenExpiration);
+        expect(store.getState().auth.apiTokenExpiration).toEqual(new Date(loginClusterTokenExpiration));
+    });
+
+    it('should initialise state with user and api token from local storage', (done) => {
+        axiosMock
+            .onGet("/users/current")
+            .reply(200, {
+                email: "test@test.com",
+                first_name: "John",
+                last_name: "Doe",
+                uuid: "zzzzz-tpzed-abcefg",
+                owner_uuid: "ownerUuid",
+                is_admin: false,
+                is_active: true,
+                username: "jdoe",
+                prefs: {}
+            })
+            .onGet("/api_client_authorizations/current")
+            .reply(200, {
+                expires_at: "2140-01-01T00:00:00.000Z"
+            })
+            .onGet("https://xc59z.example.com/discovery/v1/apis/arvados/v1/rest")
+            .reply(200, {
+                baseUrl: "https://xc59z.example.com/arvados/v1",
+                keepWebServiceUrl: "",
+                keepWebInlineServiceUrl: "",
+                remoteHosts: {},
+                rootUrl: "https://xc59z.example.com",
+                uuidPrefix: "xc59z",
+                websocketUrl: "",
+                workbenchUrl: "",
+                workbench2Url: "",
+                revision: 12345678
+            });
+
+        importMocks.push(ImportMock.mockFunction(servicesModule, 'createServices', services));
+
+        // Only test the case when a link account operation is not being cancelled
+        sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
+        localStorage.setItem(API_TOKEN_KEY, "token");
+
+        const config: any = {
+            rootUrl: "https://zzzzz.example.com",
+            uuidPrefix: "zzzzz",
+            remoteHosts: { xc59z: "xc59z.example.com" },
+            apiRevision: 12345678,
+            clusterConfig: {
+                Login: { LoginCluster: "" },
+                Workbench: { UserProfileFormFields: {} }
+            },
+        };
+
+        store.dispatch(initAuth(config));
+
+        store.subscribe(() => {
+            const auth = store.getState().auth;
+            if (auth.apiToken === "token" &&
+                auth.sessions.length === 2 &&
+                auth.sessions[0].status === SessionStatus.VALIDATED &&
+                auth.sessions[1].status === SessionStatus.VALIDATED
+            ) {
+                try {
+                    expect(auth).toEqual({
+                        apiToken: "token",
+                        apiTokenExpiration: new Date("2140-01-01T00:00:00.000Z"),
+                        apiTokenLocation: "localStorage",
+                        config: {
+                            apiRevision: 12345678,
+                            clusterConfig: {
+                                Login: {
+                                    LoginCluster: "",
+                                },
+                                Workbench: { UserProfileFormFields: {} }
+                            },
+                            remoteHosts: {
+                                "xc59z": "xc59z.example.com",
+                            },
+                            rootUrl: "https://zzzzz.example.com",
+                            uuidPrefix: "zzzzz",
+                        },
+                        sshKeys: [],
+                        extraApiToken: undefined,
+                        extraApiTokenExpiration: undefined,
+                        homeCluster: "zzzzz",
+                        localCluster: "zzzzz",
+                        loginCluster: undefined,
+                        remoteHostsConfig: {
+                            "zzzzz": {
+                                "apiRevision": 12345678,
+                                "clusterConfig": {
+                                    "Login": {
+                                        "LoginCluster": "",
+                                    },
+                                    Workbench: { UserProfileFormFields: {} }
+                                },
+                                "remoteHosts": {
+                                    "xc59z": "xc59z.example.com",
+                                },
+                                "rootUrl": "https://zzzzz.example.com",
+                                "uuidPrefix": "zzzzz",
+                            },
+                            "xc59z": mockConfig({
+                                apiRevision: 12345678,
+                                baseUrl: "https://xc59z.example.com/arvados/v1",
+                                rootUrl: "https://xc59z.example.com",
+                                uuidPrefix: "xc59z"
+                            })
+                        },
+                        remoteHosts: {
+                            zzzzz: "zzzzz.example.com",
+                            xc59z: "xc59z.example.com"
+                        },
+                        sessions: [{
+                            "active": true,
+                            "baseUrl": undefined,
+                            "clusterId": "zzzzz",
+                            "email": "test@test.com",
+                            "loggedIn": true,
+                            "remoteHost": "https://zzzzz.example.com",
+                            "status": 2,
+                            "token": "token",
+                            "name": "John Doe",
+                            "apiRevision": 12345678,
+                            "uuid": "zzzzz-tpzed-abcefg",
+                            "userIsActive": true
+                        }, {
+                            "active": false,
+                            "baseUrl": "",
+                            "clusterId": "xc59z",
+                            "email": "",
+                            "loggedIn": false,
+                            "remoteHost": "xc59z.example.com",
+                            "status": 2,
+                            "token": "",
+                            "name": "",
+                            "uuid": "",
+                            "apiRevision": 0,
+                        }],
+                        user: {
+                            email: "test@test.com",
+                            firstName: "John",
+                            lastName: "Doe",
+                            uuid: "zzzzz-tpzed-abcefg",
+                            ownerUuid: "ownerUuid",
+                            username: "jdoe",
+                            prefs: { profile: {} },
+                            isAdmin: false,
+                            isActive: true
+                        }
+                    });
+                    done();
+                } catch (e) {
+                    fail(e);
+                }
+            }
+        });
+    });
+
+
+    // TODO: Add remaining action tests
+    /*
+       it('should fire external url to login', () => {
+       const initialState = undefined;
+       window.location.assign = jest.fn();
+       reducer(initialState, authActions.LOGIN());
+       expect(window.location.assign).toBeCalledWith(
+       `/login?return_to=${window.location.protocol}//${window.location.host}/token`
+       );
+       });
+
+       it('should fire external url to logout', () => {
+       const initialState = undefined;
+       window.location.assign = jest.fn();
+       reducer(initialState, authActions.LOGOUT());
+       expect(window.location.assign).toBeCalledWith(
+       `/logout?return_to=${location.protocol}//${location.host}`
+       );
+       });
+     */
+});
diff --git a/services/workbench2/src/store/auth/auth-action.ts b/services/workbench2/src/store/auth/auth-action.ts
new file mode 100644 (file)
index 0000000..145a461
--- /dev/null
@@ -0,0 +1,170 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ofType, unionize, UnionOf } from 'common/unionize';
+import { Dispatch } from "redux";
+import { RootState } from "../store";
+import { ServiceRepository } from "services/services";
+import { SshKeyResource } from 'models/ssh-key';
+import { User } from "models/user";
+import { Session } from "models/session";
+import { Config } from 'common/config';
+import { matchTokenRoute, matchFedTokenRoute } from 'routes/routes';
+import { createServices, setAuthorizationHeader } from "services/services";
+import { cancelLinking } from 'store/link-account-panel/link-account-panel-actions';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { WORKBENCH_LOADING_SCREEN } from 'store/workbench/workbench-actions';
+import { addRemoteConfig, getRemoteHostConfig } from './auth-action-session';
+import { getTokenV2 } from 'models/api-client-authorization';
+
+export const authActions = unionize({
+    LOGIN: {},
+    LOGOUT: ofType<{ deleteLinkData: boolean, preservePath: boolean }>(),
+    SET_CONFIG: ofType<{ config: Config }>(),
+    SET_EXTRA_TOKEN: ofType<{ extraApiToken: string, extraApiTokenExpiration?: Date }>(),
+    RESET_EXTRA_TOKEN: {},
+    INIT_USER: ofType<{ user: User, token: string, tokenExpiration?: Date, tokenLocation?: string }>(),
+    USER_DETAILS_REQUEST: {},
+    USER_DETAILS_SUCCESS: ofType<User>(),
+    SET_SSH_KEYS: ofType<SshKeyResource[]>(),
+    ADD_SSH_KEY: ofType<SshKeyResource>(),
+    REMOVE_SSH_KEY: ofType<string>(),
+    SET_HOME_CLUSTER: ofType<string>(),
+    SET_SESSIONS: ofType<Session[]>(),
+    ADD_SESSION: ofType<Session>(),
+    REMOVE_SESSION: ofType<string>(),
+    UPDATE_SESSION: ofType<Session>(),
+    REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
+});
+
+export const initAuth = (config: Config) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+    // Cancel any link account ops in progress unless the user has
+    // just logged in or there has been a successful link operation
+    const data = services.linkAccountService.getLinkOpStatus();
+    if (!matchTokenRoute(window.location.pathname) &&
+        (!matchFedTokenRoute(window.location.pathname)) && data === undefined) {
+        await dispatch<any>(cancelLinking());
+    }
+    return dispatch<any>(init(config));
+};
+
+const init = (config: Config) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const remoteHosts = () => getState().auth.remoteHosts;
+    const token = services.authService.getApiToken();
+    let homeCluster = services.authService.getHomeCluster();
+    if (homeCluster && !config.remoteHosts[homeCluster]) {
+        homeCluster = undefined;
+    }
+    dispatch(authActions.SET_CONFIG({ config }));
+    Object.keys(remoteHosts()).forEach((remoteUuid: string) => {
+        const remoteHost = remoteHosts()[remoteUuid];
+        if (remoteUuid !== config.uuidPrefix) {
+            dispatch<any>(addRemoteConfig(remoteHost));
+        }
+    });
+    dispatch(authActions.SET_HOME_CLUSTER(config.loginCluster || homeCluster || config.uuidPrefix));
+
+    if (token && token !== "undefined") {
+        dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+        try {
+            await dispatch<any>(saveApiToken(token));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+        }
+    }
+};
+
+export const getConfig = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Config => {
+    const state = getState().auth;
+    return state.remoteHostsConfig[state.localCluster];
+};
+
+export const getLocalCluster = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): string => {
+    return getState().auth.localCluster;
+};
+
+export const saveApiToken = (token: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+    let config: any;
+    const tokenParts = token.split('/');
+    const auth = getState().auth;
+    config = dispatch<any>(getConfig);
+
+    // If the token is from a LoginCluster federation, get user & token data
+    // from the token issuing cluster.
+    if (!config) {
+        return;
+    }
+    const lc = (config as Config).loginCluster
+    const tokenCluster = tokenParts.length === 3
+        ? tokenParts[1].substring(0, 5)
+        : undefined;
+    if (tokenCluster && tokenCluster !== auth.localCluster &&
+        lc && lc === tokenCluster) {
+        config = await getRemoteHostConfig(auth.remoteHosts[tokenCluster]);
+    }
+
+    const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+    setAuthorizationHeader(svc, token);
+    try {
+        const user = await svc.authService.getUserDetails();
+        const client = await svc.apiClientAuthorizationService.get('current');
+        const tokenExpiration = client.expiresAt ? new Date(client.expiresAt) : undefined;
+        const tokenLocation = await svc.authService.getStorageType();
+        dispatch(authActions.INIT_USER({ user, token, tokenExpiration, tokenLocation }));
+    } catch (e) {
+        dispatch(authActions.LOGOUT({ deleteLinkData: false, preservePath: false }));
+    }
+};
+
+export const getNewExtraToken = (reuseStored: boolean = false) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const extraToken = getState().auth.extraApiToken;
+        if (reuseStored && extraToken !== undefined) {
+            const config = dispatch<any>(getConfig);
+            const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+            setAuthorizationHeader(svc, extraToken);
+            try {
+                // Check the extra token's validity before using it. Refresh its
+                // expiration date just in case it changed.
+                const client = await svc.apiClientAuthorizationService.get('current');
+                dispatch(authActions.SET_EXTRA_TOKEN({
+                    extraApiToken: extraToken,
+                    extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined,
+                }));
+                return extraToken;
+            } catch (e) {
+                dispatch(authActions.RESET_EXTRA_TOKEN());
+            }
+        }
+        const user = getState().auth.user;
+        const loginCluster = getState().auth.config.clusterConfig.Login.LoginCluster;
+        if (user === undefined) { return; }
+        if (loginCluster !== "" && getState().auth.homeCluster !== loginCluster) { return; }
+        try {
+            // Do not show errors on the create call, cluster security configuration may not
+            // allow token creation and there's no way to know that from workbench2 side in advance.
+            const client = await services.apiClientAuthorizationService.create(undefined, false);
+            const newExtraToken = getTokenV2(client);
+            dispatch(authActions.SET_EXTRA_TOKEN({
+                extraApiToken: newExtraToken,
+                extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined,
+            }));
+            return newExtraToken;
+        } catch {
+            console.warn("Cannot create new tokens with the current token, probably because of cluster's security settings.");
+            return;
+        }
+    };
+
+export const login = (uuidPrefix: string, homeCluster: string, loginCluster: string,
+    remoteHosts: { [key: string]: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        services.authService.login(uuidPrefix, homeCluster, loginCluster, remoteHosts);
+        dispatch(authActions.LOGIN());
+    };
+
+export const logout = (deleteLinkData: boolean = false, preservePath: boolean = false) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) =>
+        dispatch(authActions.LOGOUT({ deleteLinkData, preservePath }))
+
+export type AuthAction = UnionOf<typeof authActions>;
diff --git a/services/workbench2/src/store/auth/auth-middleware.test.ts b/services/workbench2/src/store/auth/auth-middleware.test.ts
new file mode 100644 (file)
index 0000000..5a0364e
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import 'jest-localstorage-mock';
+import Axios, { AxiosInstance } from "axios";
+import { createBrowserHistory } from "history";
+
+import { authMiddleware } from "./auth-middleware";
+import { RootStore, configureStore } from "../store";
+import { ServiceRepository, createServices } from "services/services";
+import { ApiActions } from "services/api/api-actions";
+import { mockConfig } from "common/config";
+import { authActions } from "./auth-action";
+import { API_TOKEN_KEY } from 'services/auth-service/auth-service';
+
+describe("AuthMiddleware", () => {
+    let store: RootStore;
+    let services: ServiceRepository;
+    let axiosInst: AxiosInstance;
+    const config: any = {};
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+
+    beforeEach(() => {
+        axiosInst = Axios.create({ headers: {} });
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+        localStorage.clear();
+    });
+
+    it("handles LOGOUT action", () => {
+        localStorage.setItem(API_TOKEN_KEY, 'someToken');
+        window.location.assign = jest.fn();
+        const next = jest.fn();
+        const middleware = authMiddleware(services)(store)(next);
+        middleware(authActions.LOGOUT({deleteLinkData: false, preservePath: false}));
+        expect(window.location.assign).toBeCalledWith(
+            `/logout?api_token=someToken&return_to=${location.protocol}//${location.host}`
+        );
+        expect(localStorage.getItem(API_TOKEN_KEY)).toBeFalsy();
+    });
+});
diff --git a/services/workbench2/src/store/auth/auth-middleware.ts b/services/workbench2/src/store/auth/auth-middleware.ts
new file mode 100644 (file)
index 0000000..1658431
--- /dev/null
@@ -0,0 +1,86 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Middleware } from "redux";
+import { authActions, } from "./auth-action";
+import { ServiceRepository, setAuthorizationHeader, removeAuthorizationHeader } from "services/services";
+import { initSessions } from "store/auth/auth-action-session";
+import { User } from "models/user";
+import { RootState } from 'store/store';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { WORKBENCH_LOADING_SCREEN } from 'store/workbench/workbench-actions';
+import { navigateToMyAccount } from 'store/navigation/navigation-action';
+
+export const authMiddleware = (services: ServiceRepository): Middleware => store => next => action => {
+    // Middleware to update external state (local storage, window
+    // title) to ensure that they stay in sync with redux state.
+
+    authActions.match(action, {
+        INIT_USER: ({ user, token }) => {
+            // The "next" method passes the action to the next
+            // middleware in the chain, or the reducer.  That means
+            // after next() returns, the action has (presumably) been
+            // applied by the reducer to update the state.
+            next(action);
+
+            const state: RootState = store.getState();
+
+            if (state.auth.apiToken) {
+                services.authService.saveApiToken(state.auth.apiToken);
+                setAuthorizationHeader(services, state.auth.apiToken);
+            } else {
+                services.authService.removeApiToken();
+                services.authService.removeSessions();
+                removeAuthorizationHeader(services);
+            }
+
+            store.dispatch<any>(initSessions(services.authService, state.auth.remoteHostsConfig[state.auth.localCluster], user));
+            if (Object.keys(state.auth.config.clusterConfig.Workbench.UserProfileFormFields).length > 0 &&
+                user.isActive &&
+                (Object.keys(user.prefs).length === 0 ||
+                    user.prefs.profile === undefined ||
+                    Object.keys(user.prefs.profile!).length === 0)) {
+                // If the user doesn't have a profile set, send them
+                // to the user profile page to encourage them to fill it out.
+                store.dispatch(navigateToMyAccount);
+            }
+            if (!user.isActive) {
+                // As a special case, if the user is inactive, they
+                // may be able to self-activate using the "activate"
+                // method.  Note, for this to work there can't be any
+                // unsigned user agreements, we assume the API server is just going to
+                // rubber-stamp our activation request.  At some point in the future we'll
+                // want to either add support for displaying/signing user
+                // agreements or get rid of self-activation.
+                // For more details, see:
+                // https://doc.arvados.org/main/admin/user-management.html
+
+                store.dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+                services.userService.activate(user.uuid).then((user: User) => {
+                    store.dispatch(authActions.INIT_USER({ user, token }));
+                    store.dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+                }).catch(() => {
+                    store.dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+                });
+            }
+        },
+        SET_CONFIG: ({ config }) => {
+            document.title = `Arvados (${config.uuidPrefix})`;
+            next(action);
+        },
+        LOGOUT: ({ deleteLinkData, preservePath }) => {
+            next(action);
+            if (deleteLinkData) {
+                services.linkAccountService.removeAccountToLink();
+            }
+            const token = services.authService.getApiToken();
+            services.authService.removeApiToken();
+            services.authService.removeSessions();
+            services.authService.removeUser();
+            removeAuthorizationHeader(services);
+            services.authService.logout(token || '', preservePath);
+        },
+        default: () => next(action)
+    });
+};
diff --git a/services/workbench2/src/store/auth/auth-reducer.test.ts b/services/workbench2/src/store/auth/auth-reducer.test.ts
new file mode 100644 (file)
index 0000000..6a1fb87
--- /dev/null
@@ -0,0 +1,92 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { authReducer, AuthState } from "./auth-reducer";
+import { AuthAction, authActions } from "./auth-action";
+
+import 'jest-localstorage-mock';
+import { createServices } from "services/services";
+import { mockConfig } from 'common/config';
+import { ApiActions } from "services/api/api-actions";
+
+describe('auth-reducer', () => {
+    let reducer: (state: AuthState | undefined, action: AuthAction) => any;
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+
+    beforeAll(() => {
+        localStorage.clear();
+        reducer = authReducer(createServices(mockConfig({}), actions));
+    });
+
+    it('should correctly initialise state', () => {
+        const initialState = undefined;
+        const user = {
+            email: "test@test.com",
+            firstName: "John",
+            lastName: "Doe",
+            uuid: "zzzzz-tpzed-xurymjxw79nv3jz",
+            ownerUuid: "ownerUuid",
+            username: "username",
+            prefs: {},
+            isAdmin: false,
+            isActive: true
+        };
+        const state = reducer(initialState, authActions.INIT_USER({ user, token: "token" }));
+        expect(state).toEqual({
+            apiToken: "token",
+            config: mockConfig({}),
+            user,
+            sshKeys: [],
+            sessions: [],
+            homeCluster: "zzzzz",
+            localCluster: "",
+            loginCluster: "",
+            remoteHosts: {},
+            remoteHostsConfig: {}
+        });
+    });
+
+    it('should set user details on success fetch', () => {
+        const initialState = undefined;
+
+        const user = {
+            email: "test@test.com",
+            firstName: "John",
+            lastName: "Doe",
+            uuid: "uuid",
+            ownerUuid: "ownerUuid",
+            username: "username",
+            prefs: {},
+            isAdmin: false,
+            isActive: true
+        };
+
+        const state = reducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
+        expect(state).toEqual({
+            apiToken: undefined,
+            config: mockConfig({}),
+            sshKeys: [],
+            sessions: [],
+            homeCluster: "uuid",
+            localCluster: "",
+            loginCluster: "",
+            remoteHosts: {},
+            remoteHostsConfig: {},
+            user: {
+                email: "test@test.com",
+                firstName: "John",
+                lastName: "Doe",
+                uuid: "uuid",
+                ownerUuid: "ownerUuid",
+                username: "username",
+                prefs: {},
+                isAdmin: false,
+                isActive: true
+            }
+        });
+    });
+});
diff --git a/services/workbench2/src/store/auth/auth-reducer.ts b/services/workbench2/src/store/auth/auth-reducer.ts
new file mode 100644 (file)
index 0000000..c109aca
--- /dev/null
@@ -0,0 +1,113 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { authActions, AuthAction } from "./auth-action";
+import { User } from "models/user";
+import { ServiceRepository } from "services/services";
+import { SshKeyResource } from 'models/ssh-key';
+import { Session } from "models/session";
+import { Config, mockConfig } from 'common/config';
+
+export interface AuthState {
+    user?: User;
+    apiToken?: string;
+    apiTokenExpiration?: Date;
+    apiTokenLocation?: string;
+    extraApiToken?: string;
+    extraApiTokenExpiration?: Date;
+    sshKeys: SshKeyResource[];
+    sessions: Session[];
+    localCluster: string;
+    homeCluster: string;
+    loginCluster: string;
+    remoteHosts: { [key: string]: string };
+    remoteHostsConfig: { [key: string]: Config };
+    config: Config;
+}
+
+const initialState: AuthState = {
+    user: undefined,
+    apiToken: undefined,
+    apiTokenExpiration: undefined,
+    apiTokenLocation: undefined,
+    extraApiToken: undefined,
+    extraApiTokenExpiration: undefined,
+    sshKeys: [],
+    sessions: [],
+    localCluster: "",
+    homeCluster: "",
+    loginCluster: "",
+    remoteHosts: {},
+    remoteHostsConfig: {},
+    config: mockConfig({})
+};
+
+export const authReducer = (services: ServiceRepository) => (state = initialState, action: AuthAction) => {
+    return authActions.match(action, {
+        SET_CONFIG: ({ config }) =>
+            ({
+                ...state,
+                config,
+                localCluster: config.uuidPrefix,
+                remoteHosts: {
+                    ...config.remoteHosts,
+                    [config.uuidPrefix]: new URL(config.rootUrl).host
+                },
+                homeCluster: config.loginCluster || config.uuidPrefix,
+                loginCluster: config.loginCluster,
+                remoteHostsConfig: {
+                    ...state.remoteHostsConfig,
+                    [config.uuidPrefix]: config
+                }
+            }),
+        REMOTE_CLUSTER_CONFIG: ({ config }) =>
+            ({
+                ...state,
+                remoteHostsConfig: {
+                    ...state.remoteHostsConfig,
+                    [config.uuidPrefix]: config
+                },
+            }),
+        SET_EXTRA_TOKEN: ({ extraApiToken, extraApiTokenExpiration }) =>
+            ({ ...state, extraApiToken, extraApiTokenExpiration }),
+        RESET_EXTRA_TOKEN: () =>
+            ({ ...state, extraApiToken: undefined, extraApiTokenExpiration: undefined }),
+        INIT_USER: ({ user, token, tokenExpiration, tokenLocation = state.apiTokenLocation }) =>
+            ({ ...state,
+                user,
+                apiToken: token,
+                apiTokenExpiration: tokenExpiration,
+                apiTokenLocation: tokenLocation,
+                homeCluster: user.uuid.substring(0, 5)
+            }),
+        LOGIN: () => state,
+        LOGOUT: () => ({ ...state, apiToken: undefined }),
+        USER_DETAILS_SUCCESS: (user: User) =>
+            ({ ...state, user, homeCluster: user.uuid.substring(0, 5) }),
+        SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => ({ ...state, sshKeys }),
+        ADD_SSH_KEY: (sshKey: SshKeyResource) =>
+            ({ ...state, sshKeys: state.sshKeys.concat(sshKey) }),
+        REMOVE_SSH_KEY: (uuid: string) =>
+            ({ ...state, sshKeys: state.sshKeys.filter((sshKey) => sshKey.uuid !== uuid) }),
+        SET_HOME_CLUSTER: (homeCluster: string) => ({ ...state, homeCluster }),
+        SET_SESSIONS: (sessions: Session[]) => ({ ...state, sessions }),
+        ADD_SESSION: (session: Session) =>
+            ({ ...state, sessions: state.sessions.concat(session) }),
+        REMOVE_SESSION: (clusterId: string) =>
+            ({
+                ...state,
+                sessions: state.sessions.filter(
+                    session => session.clusterId !== clusterId
+                )
+            }),
+        UPDATE_SESSION: (session: Session) =>
+            ({
+                ...state,
+                sessions: state.sessions.map(
+                    s => s.clusterId === session.clusterId ? session : s
+                )
+            }),
+        default: () => state
+    });
+};
diff --git a/services/workbench2/src/store/banner/banner-action.ts b/services/workbench2/src/store/banner/banner-action.ts
new file mode 100644 (file)
index 0000000..808ca82
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { unionize, UnionOf } from 'common/unionize';
+
+export const bannerReducerActions = unionize({
+    OPEN_BANNER: {},
+    CLOSE_BANNER: {},
+});
+
+export type BannerAction = UnionOf<typeof bannerReducerActions>;
+
+export const openBanner = () =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(bannerReducerActions.OPEN_BANNER());
+    };
+
+export const closeBanner = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState) => {
+        dispatch(bannerReducerActions.CLOSE_BANNER());
+    };
+
+export default {
+    openBanner,
+    closeBanner
+};
diff --git a/services/workbench2/src/store/banner/banner-reducer.ts b/services/workbench2/src/store/banner/banner-reducer.ts
new file mode 100644 (file)
index 0000000..8009f4b
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { BannerAction, bannerReducerActions } from "./banner-action";
+
+export interface BannerState {
+    isOpen: boolean;
+}
+
+const initialState = {
+    isOpen: false,
+};
+
+export const bannerReducer = (state: BannerState = initialState, action: BannerAction) =>
+    bannerReducerActions.match(action, {
+        default: () => state,
+        OPEN_BANNER: () => ({
+             ...state,
+             isOpen: true,
+        }),
+        CLOSE_BANNER: () => ({
+            ...state,
+            isOpen: false,
+       }),
+    });
diff --git a/services/workbench2/src/store/breadcrumbs/breadcrumbs-actions.ts b/services/workbench2/src/store/breadcrumbs/breadcrumbs-actions.ts
new file mode 100644 (file)
index 0000000..c3f7d8f
--- /dev/null
@@ -0,0 +1,335 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { getResource } from 'store/resources/resources';
+import { propertiesActions } from '../properties/properties-actions';
+import { getProcess } from 'store/processes/process';
+import { ServiceRepository } from 'services/services';
+import { SidePanelTreeCategory, activateSidePanelTreeItem } from 'store/side-panel-tree/side-panel-tree-actions';
+import { updateResources } from '../resources/resources-actions';
+import { ResourceKind } from 'models/resource';
+import { GroupResource } from 'models/group';
+import { extractUuidKind } from 'models/resource';
+import { UserResource } from 'models/user';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { ProcessResource } from 'models/process';
+import { OrderBuilder } from 'services/api/order-builder';
+import { Breadcrumb } from 'components/breadcrumbs/breadcrumbs';
+import { ContainerRequestResource, containerRequestFieldsNoMounts } from 'models/container-request';
+import { AdminMenuIcon, CollectionIcon, IconType, ProcessIcon, ProjectIcon, ResourceIcon, TerminalIcon, WorkflowIcon } from 'components/icon/icon';
+import { CollectionResource } from 'models/collection';
+import { getSidePanelIcon } from 'views-components/side-panel-tree/side-panel-tree';
+import { WorkflowResource } from 'models/workflow';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+
+export const BREADCRUMBS = 'breadcrumbs';
+
+export const setBreadcrumbs = (breadcrumbs: any, currentItem?: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource) => {
+    if (currentItem) {
+        const currentCrumb = resourceToBreadcrumb(currentItem)
+        if (currentCrumb.label.length) breadcrumbs.push(currentCrumb);
+    }
+    return propertiesActions.SET_PROPERTY({ key: BREADCRUMBS, value: breadcrumbs });
+};
+
+const resourceToBreadcrumbIcon = (resource: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource): IconType | undefined => {
+    switch (resource.kind) {
+        case ResourceKind.PROJECT:
+            return ProjectIcon;
+        case ResourceKind.PROCESS:
+            return ProcessIcon;
+        case ResourceKind.COLLECTION:
+            return CollectionIcon;
+        case ResourceKind.WORKFLOW:
+            return WorkflowIcon;
+        default:
+            return undefined;
+    }
+}
+
+const resourceToBreadcrumb = (resource: (CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource) & {fullName?: string}  ): Breadcrumb => ({
+    label: resource.name || resource.fullName || '',
+    uuid: resource.uuid,
+    icon: resourceToBreadcrumbIcon(resource),
+})
+
+export const setSidePanelBreadcrumbs = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs"));
+            const ancestors = await services.ancestorsService.ancestors(uuid, '');
+            dispatch(updateResources(ancestors));
+
+            let breadcrumbs: Breadcrumb[] = [];
+            const { collectionPanel: { item } } = getState();
+
+            const path = getState().router.location!.pathname;
+            const currentUuid = path.split('/')[2];
+            const uuidKind = extractUuidKind(currentUuid);
+            const rootUuid = getUserUuid(getState());
+
+            if (ancestors.find(ancestor => ancestor.uuid === rootUuid)) {
+                // Handle home project uuid root
+                breadcrumbs.push({
+                    label: SidePanelTreeCategory.PROJECTS,
+                    uuid: SidePanelTreeCategory.PROJECTS,
+                    icon: getSidePanelIcon(SidePanelTreeCategory.PROJECTS)
+                });
+            } else if (uuidKind === ResourceKind.USER) {
+                // Handle another user root project
+                const user = getResource<UserResource>(uuid)(getState().resources);
+                breadcrumbs.push({
+                    label: (user as any)?.fullName || user?.username || uuid,
+                    uuid: user?.uuid || uuid,
+                    icon: getSidePanelIcon(SidePanelTreeCategory.PROJECTS)
+                });
+            } else if (Object.values(SidePanelTreeCategory).includes(uuid as SidePanelTreeCategory)) {
+                // Handle SidePanelTreeCategory root
+                breadcrumbs.push({
+                    label: uuid,
+                    uuid: uuid,
+                    icon: getSidePanelIcon(uuid)
+                });
+            }
+
+            breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
+                ancestor.kind === ResourceKind.GROUP
+                    ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
+                    : breadcrumbs,
+                breadcrumbs);
+
+            if (uuidKind === ResourceKind.COLLECTION) {
+                const collectionItem = item ? item : await services.collectionService.get(currentUuid);
+                const parentProcessItem = await getCollectionParent(collectionItem)(services);
+                if (parentProcessItem) {
+                    const mainProcessItem = await getProcessParent(parentProcessItem)(services);
+                    mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
+            } else if (uuidKind === ResourceKind.PROCESS) {
+                const processItem = await services.containerRequestService.get(currentUuid);
+                const parentProcessItem = await getProcessParent(processItem)(services);
+                if (parentProcessItem) {
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, processItem));
+            } else if (uuidKind === ResourceKind.WORKFLOW) {
+                const workflowItem = await services.workflowService.get(currentUuid);
+                dispatch(setBreadcrumbs(breadcrumbs, workflowItem));
+            }
+            dispatch(setBreadcrumbs(breadcrumbs));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs"));
+        }
+    };
+
+export const setSharedWithMeBreadcrumbs = (uuid: string) =>
+    setCategoryBreadcrumbs(uuid, SidePanelTreeCategory.SHARED_WITH_ME);
+
+export const setTrashBreadcrumbs = (uuid: string) =>
+    setCategoryBreadcrumbs(uuid, SidePanelTreeCategory.TRASH);
+
+export const setCategoryBreadcrumbs = (uuid: string, category: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs"));
+            const ancestors = await services.ancestorsService.ancestors(uuid, '');
+            dispatch(updateResources(ancestors));
+            const initialBreadcrumbs: Breadcrumb[] = [
+                {
+                    label: category,
+                    uuid: category,
+                    icon: getSidePanelIcon(category)
+                }
+            ];
+            const { collectionPanel: { item } } = getState();
+            const path = getState().router.location!.pathname;
+            const currentUuid = path.split('/')[2];
+            const uuidKind = extractUuidKind(currentUuid);
+            let breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
+                ancestor.kind === ResourceKind.GROUP
+                    ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
+                    : breadcrumbs,
+                initialBreadcrumbs);
+            if (uuidKind === ResourceKind.COLLECTION) {
+                const collectionItem = item ? item : await services.collectionService.get(currentUuid);
+                const parentProcessItem = await getCollectionParent(collectionItem)(services);
+                if (parentProcessItem) {
+                    const mainProcessItem = await getProcessParent(parentProcessItem)(services);
+                    mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
+            } else if (uuidKind === ResourceKind.PROCESS) {
+                const processItem = await services.containerRequestService.get(currentUuid);
+                const parentProcessItem = await getProcessParent(processItem)(services);
+                if (parentProcessItem) {
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, processItem));
+            } else if (uuidKind === ResourceKind.WORKFLOW) {
+                const workflowItem = await services.workflowService.get(currentUuid);
+                dispatch(setBreadcrumbs(breadcrumbs, workflowItem));
+            }
+            dispatch(setBreadcrumbs(breadcrumbs));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs"));
+        }
+    };
+
+const getProcessParent = (childProcess: ContainerRequestResource) =>
+    async (services: ServiceRepository): Promise<ContainerRequestResource | undefined> => {
+        if (childProcess.requestingContainerUuid) {
+            const parentProcesses = await services.containerRequestService.list({
+                order: new OrderBuilder<ProcessResource>().addAsc('createdAt').getOrder(),
+                filters: new FilterBuilder().addEqual('container_uuid', childProcess.requestingContainerUuid).getFilters(),
+                select: containerRequestFieldsNoMounts,
+            });
+            if (parentProcesses.items.length > 0) {
+                return parentProcesses.items[0];
+            } else {
+                return undefined;
+            }
+        } else {
+            return undefined;
+        }
+    }
+
+const getCollectionParent = (collection: CollectionResource) =>
+    async (services: ServiceRepository): Promise<ContainerRequestResource | undefined> => {
+        const parentOutputPromise = services.containerRequestService.list({
+            order: new OrderBuilder<ProcessResource>().addAsc('createdAt').getOrder(),
+            filters: new FilterBuilder().addEqual('output_uuid', collection.uuid).getFilters(),
+            select: containerRequestFieldsNoMounts,
+        });
+        const parentLogPromise = services.containerRequestService.list({
+            order: new OrderBuilder<ProcessResource>().addAsc('createdAt').getOrder(),
+            filters: new FilterBuilder().addEqual('log_uuid', collection.uuid).getFilters(),
+            select: containerRequestFieldsNoMounts,
+        });
+        const [parentOutput, parentLog] = await Promise.all([parentOutputPromise, parentLogPromise]);
+        return parentOutput.items.length > 0 ?
+            parentOutput.items[0] :
+            parentLog.items.length > 0 ?
+                parentLog.items[0] :
+                undefined;
+    }
+
+
+export const setProjectBreadcrumbs = (uuid: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const ancestors = await services.ancestorsService.ancestors(uuid, '');
+        const rootUuid = getUserUuid(getState());
+        if (uuid === rootUuid || ancestors.find(ancestor => ancestor.uuid === rootUuid)) {
+            dispatch(setSidePanelBreadcrumbs(uuid));
+        } else {
+            dispatch(setSharedWithMeBreadcrumbs(uuid));
+            dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.SHARED_WITH_ME));
+        }
+    };
+
+export const setProcessBreadcrumbs = (processUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const { resources } = getState();
+        const process = getProcess(processUuid)(resources);
+        if (process) {
+            dispatch<any>(setProjectBreadcrumbs(process.containerRequest.ownerUuid));
+        }
+    };
+
+export const setGroupsBreadcrumbs = () =>
+    setBreadcrumbs([{
+        label: SidePanelTreeCategory.GROUPS,
+        uuid: SidePanelTreeCategory.GROUPS,
+        icon: getSidePanelIcon(SidePanelTreeCategory.GROUPS)
+    }]);
+
+export const setGroupDetailsBreadcrumbs = (groupUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+
+        const group = getResource<GroupResource>(groupUuid)(getState().resources);
+
+        const breadcrumbs: Breadcrumb[] = [
+            {
+                label: SidePanelTreeCategory.GROUPS,
+                uuid: SidePanelTreeCategory.GROUPS,
+                icon: getSidePanelIcon(SidePanelTreeCategory.GROUPS)
+            },
+            { label: group ? group.name : (await services.groupsService.get(groupUuid)).name, uuid: groupUuid },
+        ];
+
+        dispatch(setBreadcrumbs(breadcrumbs));
+
+    };
+
+export const USERS_PANEL_LABEL = 'Users';
+
+export const setUsersBreadcrumbs = () =>
+    setBreadcrumbs([{ label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL }]);
+
+export const setUserProfileBreadcrumbs = (userUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const user = getResource<UserResource>(userUuid)(getState().resources)
+                || await services.userService.get(userUuid, false);
+            const userProfileBreadcrumbs: Breadcrumb[] = [
+                { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL },
+                { label: user ? `${user.firstName} ${user.lastName}` : userUuid, uuid: userUuid },
+            ];    
+            dispatch(setBreadcrumbs(userProfileBreadcrumbs));
+        } catch (e) {
+            const breadcrumbs: Breadcrumb[] = [
+                { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL },
+                { label: userUuid, uuid: userUuid },
+            ];
+            dispatch(setBreadcrumbs(breadcrumbs));
+        }
+    };
+
+export const MY_ACCOUNT_PANEL_LABEL = 'My Account';
+
+export const setMyAccountBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: MY_ACCOUNT_PANEL_LABEL, uuid: MY_ACCOUNT_PANEL_LABEL },
+        ]));
+    };
+
+export const INSTANCE_TYPES_PANEL_LABEL = 'Instance Types';
+
+export const setInstanceTypesBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: INSTANCE_TYPES_PANEL_LABEL, uuid: INSTANCE_TYPES_PANEL_LABEL, icon: ResourceIcon },
+        ]));
+    };
+
+export const setVirtualMachinesBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: SidePanelTreeCategory.SHELL_ACCESS, uuid: SidePanelTreeCategory.SHELL_ACCESS, icon: TerminalIcon },
+        ]));
+    };
+
+export const VIRTUAL_MACHINES_ADMIN_PANEL_LABEL = 'Shell Access Admin';
+
+export const setVirtualMachinesAdminBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: VIRTUAL_MACHINES_ADMIN_PANEL_LABEL, uuid: VIRTUAL_MACHINES_ADMIN_PANEL_LABEL, icon: AdminMenuIcon },
+        ]));
+    };
+
+export const REPOSITORIES_PANEL_LABEL = 'Repositories';
+
+export const setRepositoriesBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: REPOSITORIES_PANEL_LABEL, uuid: REPOSITORIES_PANEL_LABEL, icon: AdminMenuIcon },
+        ]));
+    };
diff --git a/services/workbench2/src/store/collection-panel/collection-panel-action.ts b/services/workbench2/src/store/collection-panel/collection-panel-action.ts
new file mode 100644 (file)
index 0000000..4957321
--- /dev/null
@@ -0,0 +1,51 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { CollectionResource } from 'models/collection';
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { snackbarActions } from "../snackbar/snackbar-actions";
+import { resourcesActions } from "store/resources/resources-actions";
+import { unionize, ofType, UnionOf } from 'common/unionize';
+import { SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+
+export const collectionPanelActions = unionize({
+    SET_COLLECTION: ofType<CollectionResource>(),
+});
+
+export type CollectionPanelAction = UnionOf<typeof collectionPanelActions>;
+
+export const loadCollectionPanel = (uuid: string, forceReload = false) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { collectionPanel: { item } } = getState();
+        let collection: CollectionResource | null = null;
+        if (!item || item.uuid !== uuid || forceReload) {
+            try {
+                dispatch(progressIndicatorActions.START_WORKING(uuid + "-panel"));
+                collection = await services.collectionService.get(uuid);
+                dispatch(collectionPanelActions.SET_COLLECTION(collection));
+                dispatch(resourcesActions.SET_RESOURCES([collection]));
+            } finally {
+                dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-panel"));
+            }
+        } else {
+            collection = item;
+        }
+        dispatch<any>(loadDetailsPanel(collection.uuid));
+        return collection;
+    };
+
+export const navigateToProcess = (uuid: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            await services.containerRequestService.get(uuid);
+            dispatch<any>(navigateTo(uuid));
+        } catch {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'This process does not exist!', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
diff --git a/services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts b/services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
new file mode 100644 (file)
index 0000000..547f153
--- /dev/null
@@ -0,0 +1,144 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { Dispatch } from "redux";
+import servicesProvider from 'common/service-provider';
+import { CollectionFilesTree, CollectionFileType, createCollectionFilesTree } from "models/collection-file";
+import { ServiceRepository } from "services/services";
+import { RootState } from "../../store";
+import { snackbarActions, SnackbarKind } from "../../snackbar/snackbar-actions";
+import { dialogActions } from '../../dialog/dialog-actions';
+import { getNodeValue, mapTreeValues } from "models/tree";
+import { filterCollectionFilesBySelection } from './collection-panel-files-state';
+import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
+import { getDialog } from "store/dialog/dialog-reducer";
+import { getFileFullPath, sortFilesTree } from "services/collection-service/collection-service-files-response";
+
+export const collectionPanelFilesAction = unionize({
+    SET_COLLECTION_FILES: ofType<CollectionFilesTree>(),
+    TOGGLE_COLLECTION_FILE_COLLAPSE: ofType<{ id: string }>(),
+    TOGGLE_COLLECTION_FILE_SELECTION: ofType<{ id: string }>(),
+    SELECT_ALL_COLLECTION_FILES: ofType<{}>(),
+    UNSELECT_ALL_COLLECTION_FILES: ofType<{}>(),
+    ON_SEARCH_CHANGE: ofType<string>(),
+});
+
+export type CollectionPanelFilesAction = UnionOf<typeof collectionPanelFilesAction>;
+
+export const COLLECTION_PANEL_LOAD_FILES = 'collectionPanelLoadFiles';
+
+export const setCollectionFiles = (files, joinParents = true) => (dispatch: any) => {
+    const tree = createCollectionFilesTree(files, joinParents);
+    const sorted = sortFilesTree(tree);
+    const mapped = mapTreeValues(servicesProvider.getServices().collectionService.extendFileURL)(sorted);
+    dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES(mapped));
+};
+
+export const removeCollectionFiles = (filePaths: string[]) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const currentCollection = getState().collectionPanel.item;
+        if (currentCollection) {
+            services.collectionService.deleteFiles(currentCollection.uuid, filePaths).then(() => {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Removed.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            }).catch(e =>
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Could not remove file.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR
+                }))
+            );
+        }
+    };
+
+export const removeCollectionsSelectedFiles = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const paths = filterCollectionFilesBySelection(getState().collectionPanelFiles, true)
+            .map(getFileFullPath);
+        dispatch<any>(removeCollectionFiles(paths));
+    };
+
+export const FILE_REMOVE_DIALOG = 'fileRemoveDialog';
+
+export const openFileRemoveDialog = (fileUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const file = getNodeValue(fileUuid)(getState().collectionPanelFiles);
+        if (file) {
+            const filePath = getFileFullPath(file);
+            const isDirectory = file.type === CollectionFileType.DIRECTORY;
+            const title = isDirectory
+                ? 'Removing directory'
+                : 'Removing file';
+            const text = isDirectory
+                ? 'Are you sure you want to remove this directory?'
+                : 'Are you sure you want to remove this file?';
+            const info = isDirectory
+                ? 'Removing files will change content address.'
+                : 'Removing a file will change content address.';
+
+            dispatch(dialogActions.OPEN_DIALOG({
+                id: FILE_REMOVE_DIALOG,
+                data: {
+                    title,
+                    text,
+                    info,
+                    confirmButtonLabel: 'Remove',
+                    filePath
+                }
+            }));
+        }
+    };
+
+export const MULTIPLE_FILES_REMOVE_DIALOG = 'multipleFilesRemoveDialog';
+
+export const openMultipleFilesRemoveDialog = () =>
+    dialogActions.OPEN_DIALOG({
+        id: MULTIPLE_FILES_REMOVE_DIALOG,
+        data: {
+            title: 'Removing files',
+            text: 'Are you sure you want to remove selected files?',
+            info: 'Removing files will change content address.',
+            confirmButtonLabel: 'Remove'
+        }
+    });
+
+export const RENAME_FILE_DIALOG = 'renameFileDialog';
+export interface RenameFileDialogData {
+    name: string;
+    id: string;
+    path: string;
+}
+
+export const openRenameFileDialog = (data: RenameFileDialogData) =>
+    (dispatch: Dispatch) => {
+        dispatch(initialize(RENAME_FILE_DIALOG, data));
+        dispatch(dialogActions.OPEN_DIALOG({ id: RENAME_FILE_DIALOG, data }));
+    };
+
+export const renameFile = (newFullPath: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const dialog = getDialog<RenameFileDialogData>(getState().dialog, RENAME_FILE_DIALOG);
+        const currentCollection = getState().collectionPanel.item;
+        if (dialog && currentCollection) {
+            const file = getNodeValue(dialog.data.id)(getState().collectionPanelFiles);
+            if (file) {
+                dispatch(startSubmit(RENAME_FILE_DIALOG));
+                const oldPath = getFileFullPath(file);
+                const newPath = newFullPath;
+                services.collectionService.renameFile(currentCollection.uuid, currentCollection.portableDataHash, oldPath, newPath).then(() => {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: RENAME_FILE_DIALOG }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'File name changed.', hideDuration: 2000 }));
+                }).catch(e => {
+                    const errors: FormErrors<RenameFileDialogData, string> = {
+                        path: `Could not rename the file: ${e.responseText}`
+                    };
+                    dispatch(stopSubmit(RENAME_FILE_DIALOG, errors));
+                });
+            }
+        }
+    };
diff --git a/services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.test.ts b/services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.test.ts
new file mode 100644 (file)
index 0000000..0dbb08d
--- /dev/null
@@ -0,0 +1,120 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionPanelFilesReducer } from "./collection-panel-files-reducer";
+import { collectionPanelFilesAction } from "./collection-panel-files-actions";
+import { CollectionFile, CollectionDirectory, createCollectionFile, createCollectionDirectory } from "models/collection-file";
+import { createTree, setNode, getNodeValue, mapTreeValues, TreeNodeStatus } from "models/tree";
+import { CollectionPanelFile, CollectionPanelDirectory } from "./collection-panel-files-state";
+
+describe('CollectionPanelFilesReducer', () => {
+
+    const files: Array<CollectionFile | CollectionDirectory> = [
+        createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', path: '' }),
+        createCollectionDirectory({ id: 'Directory 2', name: 'Directory 2', path: 'Directory 1' }),
+        createCollectionDirectory({ id: 'Directory 3', name: 'Directory 3', path: '' }),
+        createCollectionDirectory({ id: 'Directory 4', name: 'Directory 4', path: 'Directory 3' }),
+        createCollectionFile({ id: 'file1.txt', name: 'file1.txt', path: 'Directory 2' }),
+        createCollectionFile({ id: 'file2.txt', name: 'file2.txt', path: 'Directory 2' }),
+        createCollectionFile({ id: 'file3.txt', name: 'file3.txt', path: 'Directory 3' }),
+        createCollectionFile({ id: 'file4.txt', name: 'file4.txt', path: 'Directory 3' }),
+        createCollectionFile({ id: 'file5.txt', name: 'file5.txt', path: 'Directory 4' }),
+    ];
+
+    const collectionFilesTree = files.reduce((tree, file) => setNode({
+        children: [],
+        id: file.id,
+        parent: file.path,
+        value: file,
+        active: false,
+        selected: false,
+        expanded: false,
+        status: TreeNodeStatus.INITIAL,
+    })(tree), createTree<CollectionFile | CollectionDirectory>());
+
+    const collectionPanelFilesTree = collectionPanelFilesReducer(
+        createTree<CollectionPanelFile | CollectionPanelDirectory>(),
+        collectionPanelFilesAction.SET_COLLECTION_FILES(collectionFilesTree));
+
+    it('SET_COLLECTION_FILES', () => {
+        expect(getNodeValue('Directory 1')(collectionPanelFilesTree)).toEqual({
+            ...createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', path: '' }),
+            collapsed: true,
+            selected: false
+        });
+    });
+
+    it('TOGGLE_COLLECTION_FILE_COLLAPSE', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id: 'Directory 3' }));
+
+        const value = getNodeValue('Directory 3')(newTree)! as CollectionPanelDirectory;
+        expect(value.collapsed).toBe(false);
+    });
+
+    it('TOGGLE_COLLECTION_FILE_SELECTION', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 3' }));
+
+        const value = getNodeValue('Directory 3')(newTree);
+        expect(value!.selected).toBe(true);
+    });
+
+    it('TOGGLE_COLLECTION_FILE_SELECTION ancestors', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 2' }));
+
+        const value = getNodeValue('Directory 1')(newTree);
+        expect(value!.selected).toBe(true);
+    });
+
+    it('TOGGLE_COLLECTION_FILE_SELECTION descendants', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 2' }));
+        expect(getNodeValue('file1.txt')(newTree)!.selected).toBe(true);
+        expect(getNodeValue('file2.txt')(newTree)!.selected).toBe(true);
+    });
+
+    it('TOGGLE_COLLECTION_FILE_SELECTION unselect ancestors', () => {
+        const [newTree] = [collectionPanelFilesTree]
+            .map(tree => collectionPanelFilesReducer(
+                tree,
+                collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 2' })))
+            .map(tree => collectionPanelFilesReducer(
+                tree,
+                collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'file1.txt' })));
+
+        expect(getNodeValue('Directory 2')(newTree)!.selected).toBe(false);
+    });
+
+    it('SELECT_ALL_COLLECTION_FILES', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
+
+        mapTreeValues((v: CollectionPanelFile | CollectionPanelDirectory) => {
+            expect(v.selected).toEqual(true);
+            return v;
+        })(newTree);
+    });
+
+    it('SELECT_ALL_COLLECTION_FILES', () => {
+        const [newTree] = [collectionPanelFilesTree]
+            .map(tree => collectionPanelFilesReducer(
+                tree,
+                collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES()))
+            .map(tree => collectionPanelFilesReducer(
+                tree,
+                collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES()));
+
+        mapTreeValues((v: CollectionPanelFile | CollectionPanelDirectory) => {
+            expect(v.selected).toEqual(false);
+            return v;
+        })(newTree);
+    });
+});
diff --git a/services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts b/services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts
new file mode 100644 (file)
index 0000000..775930b
--- /dev/null
@@ -0,0 +1,136 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile, mergeCollectionPanelFilesStates } from './collection-panel-files-state';
+import { CollectionPanelFilesAction, collectionPanelFilesAction } from "./collection-panel-files-actions";
+import { createTree, mapTreeValues, getNode, setNode, getNodeAncestorsIds, getNodeDescendantsIds, setNodeValueWith, mapTree } from "models/tree";
+import { CollectionFileType } from "models/collection-file";
+
+let fetchedFiles: any = {};
+
+export const collectionPanelFilesReducer = (state: CollectionPanelFilesState = createTree(), action: CollectionPanelFilesAction) => {
+    // Low-level tree handling setNode() func does in-place data modifications
+    // for performance reasons, so we pass a copy of 'state' to avoid side effects.
+    return collectionPanelFilesAction.match(action, {
+        SET_COLLECTION_FILES: files => {
+            fetchedFiles = files;
+            return mergeCollectionPanelFilesStates({ ...state }, mapTree(mapCollectionFileToCollectionPanelFile)(files));
+        },
+
+        TOGGLE_COLLECTION_FILE_COLLAPSE: data =>
+            toggleCollapse(data.id)({ ...state }),
+
+        TOGGLE_COLLECTION_FILE_SELECTION: data => [{ ...state }]
+            .map(toggleSelected(data.id))
+            .map(toggleAncestors(data.id))
+            .map(toggleDescendants(data.id))[0],
+
+        ON_SEARCH_CHANGE: (searchValue) => {
+            const fileIds: string[] = [];
+            const directoryIds: string[] = [];
+            const filteredFiles = Object.keys(fetchedFiles)
+                .filter((key: string) => {
+                    const node = fetchedFiles[key];
+
+                    if (node.value === undefined) {
+                        return false;
+                    }
+
+                    const { id, value: { type, name } } = node;
+
+                    if (type === CollectionFileType.DIRECTORY) {
+                        directoryIds.push(id);
+                        return true;
+                    }
+
+                    const includeFile = name.toLowerCase().indexOf(searchValue.toLowerCase()) > -1;
+
+                    if (includeFile) {
+                        fileIds.push(id);
+                    }
+
+                    return includeFile;
+                })
+                .reduce((prev, next) => {
+                    const node = JSON.parse(JSON.stringify(fetchedFiles[next]));
+                    const { value: { type }, children } = node;
+
+                    node.children = node.children.filter((key: string) => {
+                        const isFile = directoryIds.indexOf(key) === -1;
+                        return isFile ?
+                          fileIds.indexOf(key) > -1 :
+                          !!fileIds.find(id => id.indexOf(key) > -1);
+                    });
+
+                    if (type === CollectionFileType.FILE || children.length > 0) {
+                        prev[next] = node;
+                    }
+
+                    return prev;
+                }, {});
+
+            return mapTreeValues((v: CollectionPanelDirectory | CollectionPanelFile) => {
+                if (v.type === CollectionFileType.DIRECTORY) {
+                    return ({
+                        ...v,
+                        collapsed: searchValue.length === 0,
+                    });
+                }
+
+                return ({ ...v });
+            })({ ...filteredFiles });
+        },
+
+        SELECT_ALL_COLLECTION_FILES: () =>
+            mapTreeValues((v: any) => ({ ...v, selected: true }))({ ...state }),
+
+        UNSELECT_ALL_COLLECTION_FILES: () =>
+            mapTreeValues((v: any) => ({ ...v, selected: false }))({ ...state }),
+
+        default: () => state
+    }) as CollectionPanelFilesState;
+};
+
+const toggleCollapse = (id: string) => (tree: CollectionPanelFilesState) =>
+    setNodeValueWith((v: CollectionPanelDirectory | CollectionPanelFile) =>
+        v.type === CollectionFileType.DIRECTORY
+            ? { ...v, collapsed: !v.collapsed }
+            : v)(id)(tree);
+
+
+const toggleSelected = (id: string) => (tree: CollectionPanelFilesState) =>
+    setNodeValueWith((v: CollectionPanelDirectory | CollectionPanelFile) => ({ ...v, selected: !v.selected }))(id)(tree);
+
+
+const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
+    const node = getNode(id)(tree);
+    if (node && node.value.type === CollectionFileType.DIRECTORY) {
+        return getNodeDescendantsIds(id)(tree)
+            .reduce((newTree, id) =>
+                setNodeValueWith((v: any) => ({ ...v, selected: node.value.selected }))(id)(newTree), tree);
+    }
+    return tree;
+};
+
+const toggleAncestors = (id: string) => (tree: CollectionPanelFilesState) => {
+    const ancestors = getNodeAncestorsIds(id)(tree).reverse();
+    return ancestors.reduce((newTree, parent) => parent ? toggleParentNode(parent)(newTree) : newTree, tree);
+};
+
+const toggleParentNode = (id: string) => (tree: CollectionPanelFilesState) => {
+    const node = getNode(id)(tree);
+    if (node) {
+        const parentNode = getNode(node.id)(tree);
+        if (parentNode) {
+            const selected = parentNode.children
+                .map(id => getNode(id)(tree))
+                .every(node => node !== undefined && node.value.selected);
+            return setNodeValueWith((v: any) => ({ ...v, selected }))(parentNode.id)(tree);
+        }
+        return setNode(node)(tree);
+    }
+    return tree;
+};
+
+
diff --git a/services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts b/services/workbench2/src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts
new file mode 100644 (file)
index 0000000..298a5a1
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Tree, TreeNode, mapTreeValues, getNodeValue, getNodeDescendants } from 'models/tree';
+import { CollectionFile, CollectionDirectory, CollectionFileType } from 'models/collection-file';
+import { ContextMenuResource } from 'store/context-menu/context-menu-actions';
+import { CollectionResource } from 'models/collection';
+
+export type CollectionPanelFilesState = Tree<CollectionPanelDirectory | CollectionPanelFile>;
+
+export interface CollectionPanelDirectory extends CollectionDirectory {
+    collapsed: boolean;
+    selected: boolean;
+}
+
+export interface CollectionPanelFile extends CollectionFile {
+    selected: boolean;
+}
+
+export interface CollectionFileSelection {
+    collection: CollectionResource;
+    selectedPaths: string[];
+}
+
+export const mapCollectionFileToCollectionPanelFile = (node: TreeNode<CollectionDirectory | CollectionFile>): TreeNode<CollectionPanelDirectory | CollectionPanelFile> => {
+    return {
+        ...node,
+        value: node.value.type === CollectionFileType.DIRECTORY
+            ? { ...node.value, selected: false, collapsed: true }
+            : { ...node.value, selected: false }
+    };
+};
+
+export const mergeCollectionPanelFilesStates = (oldState: CollectionPanelFilesState, newState: CollectionPanelFilesState) => {
+    return mapTreeValues((value: CollectionPanelDirectory | CollectionPanelFile) => {
+        const oldValue = getNodeValue(value.id)(oldState);
+        return oldValue
+            ? oldValue.type === CollectionFileType.DIRECTORY
+                ? { ...value, collapsed: oldValue.collapsed, selected: oldValue.selected }
+                : { ...value, selected: oldValue.selected }
+            : value;
+    })(newState);
+};
+
+export const filterCollectionFilesBySelection = (tree: CollectionPanelFilesState, selected: boolean): (CollectionPanelFile | CollectionPanelDirectory)[] => {
+    const allFiles = getNodeDescendants('')(tree).map(node => node.value);
+    const selectedDirectories = allFiles.filter(file => file.selected === selected && file.type === CollectionFileType.DIRECTORY);
+    const selectedFiles = allFiles.filter(file => file.selected === selected && !selectedDirectories.some(dir => dir.id === file.path));
+    return [...selectedDirectories, ...selectedFiles]
+        .filter((value, index, array) => (
+            array.indexOf(value) === index
+        ));
+};
+
+export const getCollectionSelection = (sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => ({
+    collection: sourceCollection,
+    selectedPaths: selectedItems.map(itemsToPaths).map(trimPathUuids(sourceCollection.uuid)),
+})
+
+const itemsToPaths = (item: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)): string => ('uuid' in item) ? item.uuid : item.id;
+
+const trimPathUuids = (parentCollectionUuid: string) => (path: string) => path.replace(new RegExp(`(^${parentCollectionUuid})`), '');
diff --git a/services/workbench2/src/store/collection-panel/collection-panel-reducer.ts b/services/workbench2/src/store/collection-panel/collection-panel-reducer.ts
new file mode 100644 (file)
index 0000000..6afba66
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionPanelActions, CollectionPanelAction } from "./collection-panel-action";
+import { CollectionResource } from "models/collection";
+
+export interface CollectionPanelState {
+    item: CollectionResource | null;
+}
+
+const initialState = {
+    item: null,
+};
+
+export const collectionPanelReducer = (state: CollectionPanelState = initialState, action: CollectionPanelAction) =>
+    collectionPanelActions.match(action, {
+        default: () => state,
+        SET_COLLECTION: (item) => ({
+             ...state,
+             item,
+        }),
+    });
diff --git a/services/workbench2/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts b/services/workbench2/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts
new file mode 100644 (file)
index 0000000..2d89ccc
--- /dev/null
@@ -0,0 +1,129 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { DataExplorerMiddlewareService, getOrder } from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
+import { resourcesActions } from 'store/resources/resources-actions';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+import { collectionsContentAddressActions } from './collections-content-address-panel-actions';
+import { updateFavorites } from 'store/favorites/favorites-actions';
+import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
+import { setBreadcrumbs } from '../breadcrumbs/breadcrumbs-actions';
+import { ResourceKind, extractUuidKind } from 'models/resource';
+import { ownerNameActions } from 'store/owner-name/owner-name-actions';
+import { getUserDisplayName } from 'models/user';
+import { CollectionResource } from 'models/collection';
+import { replace } from "react-router-redux";
+import { getNavUrl } from 'routes/routes';
+
+export class CollectionsWithSameContentAddressMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
+        if (!dataExplorer) {
+            api.dispatch(collectionPanelDataExplorerIsNotSet());
+        } else {
+            try {
+                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                const userUuid = getUserUuid(api.getState());
+                const pathname = api.getState().router.location!.pathname;
+                const contentAddress = pathname.split('/')[2];
+                const response = await this.services.collectionService.list({
+                    limit: dataExplorer.rowsPerPage,
+                    offset: dataExplorer.page * dataExplorer.rowsPerPage,
+                    filters: new FilterBuilder()
+                        .addEqual('portable_data_hash', contentAddress)
+                        .addILike("name", dataExplorer.searchValue)
+                        .getFilters(),
+                    includeOldVersions: true,
+                    order: getOrder<CollectionResource>(dataExplorer)
+                });
+                const userUuids = response.items.map(it => {
+                    if (extractUuidKind(it.ownerUuid) === ResourceKind.USER) {
+                        return it.ownerUuid;
+                    } else {
+                        return '';
+                    }
+                }
+                );
+                const groupUuids = response.items.map(it => {
+                    if (extractUuidKind(it.ownerUuid) === ResourceKind.GROUP) {
+                        return it.ownerUuid;
+                    } else {
+                        return '';
+                    }
+                });
+                const responseUsers = await this.services.userService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', userUuids)
+                        .getFilters(),
+                    count: "none"
+                });
+                const responseGroups = await this.services.groupsService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', groupUuids)
+                        .getFilters(),
+                    count: "none"
+                });
+                responseUsers.items.forEach(it => {
+                    api.dispatch<any>(ownerNameActions.SET_OWNER_NAME({
+                        name: it.uuid === userUuid
+                            ? 'User: Me'
+                            : `User: ${getUserDisplayName(it)}`,
+                        uuid: it.uuid
+                    }));
+                });
+                responseGroups.items.forEach(it => {
+                    api.dispatch<any>(ownerNameActions.SET_OWNER_NAME({ name: `Project: ${it.name}`, uuid: it.uuid }));
+                });
+                api.dispatch<any>(setBreadcrumbs([{ label: 'Projects', uuid: userUuid }]));
+                api.dispatch<any>(updateFavorites(response.items.map(item => item.uuid)));
+                api.dispatch<any>(updatePublicFavorites(response.items.map(item => item.uuid)));
+                if (response.itemsAvailable === 1) {
+                    api.dispatch<any>(replace(getNavUrl(response.items[0].uuid, api.getState().auth)));
+                    api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                } else {
+                    api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                    api.dispatch(resourcesActions.SET_RESOURCES(response.items));
+                    api.dispatch(collectionsContentAddressActions.SET_ITEMS({
+                        items: response.items.map((resource: any) => resource.uuid),
+                        itemsAvailable: response.itemsAvailable,
+                        page: Math.floor(response.offset / response.limit),
+                        rowsPerPage: response.limit
+                    }));
+                }
+            } catch (e) {
+                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                api.dispatch(collectionsContentAddressActions.SET_ITEMS({
+                    items: [],
+                    itemsAvailable: 0,
+                    page: 0,
+                    rowsPerPage: dataExplorer.rowsPerPage
+                }));
+                api.dispatch(couldNotFetchCollections());
+            }
+        }
+    }
+}
+
+const collectionPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Collection panel is not ready.',
+        kind: SnackbarKind.ERROR
+    });
+
+const couldNotFetchCollections = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch collection with this content address.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/collections-content-address-panel/collections-content-address-panel-actions.ts b/services/workbench2/src/store/collections-content-address-panel/collections-content-address-panel-actions.ts
new file mode 100644 (file)
index 0000000..87b6a07
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+
+export const COLLECTIONS_CONTENT_ADDRESS_PANEL_ID = 'collectionsContentAddressPanel';
+
+export const collectionsContentAddressActions = bindDataExplorerActions(COLLECTIONS_CONTENT_ADDRESS_PANEL_ID);
+
+export const loadCollectionsContentAddressPanel = () =>
+    (dispatch: Dispatch) => {
+        dispatch(collectionsContentAddressActions.REQUEST_ITEMS());
+    };
diff --git a/services/workbench2/src/store/collections/collection-copy-actions.ts b/services/workbench2/src/store/collections/collection-copy-actions.ts
new file mode 100644 (file)
index 0000000..c332ef5
--- /dev/null
@@ -0,0 +1,84 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { FormErrors, initialize, startSubmit, stopSubmit } from "redux-form";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
+import { getResource } from "store/resources/resources";
+import { CollectionResource } from "models/collection";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+
+export const COLLECTION_COPY_FORM_NAME = "collectionCopyFormName";
+export const COLLECTION_MULTI_COPY_FORM_NAME = "collectionMultiCopyFormName";
+
+export const openCollectionCopyDialog = (resource: { name: string; uuid: string; fromContextMenu?: boolean }) => (dispatch: Dispatch) => {
+    dispatch<any>(resetPickerProjectTree());
+    dispatch<any>(initProjectsTreePicker(COLLECTION_COPY_FORM_NAME));
+    const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: "", uuid: resource.uuid, fromContextMenu: resource.fromContextMenu };
+    dispatch<any>(initialize(COLLECTION_COPY_FORM_NAME, initialData));
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_COPY_FORM_NAME, data: {} }));
+};
+
+export const openMultiCollectionCopyDialog = (resource: { name: string; uuid: string; fromContextMenu?: boolean }) => (dispatch: Dispatch) => {
+    dispatch<any>(resetPickerProjectTree());
+    dispatch<any>(initProjectsTreePicker(COLLECTION_MULTI_COPY_FORM_NAME));
+    const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: "", uuid: resource.uuid, fromContextMenu: resource.fromContextMenu };
+    dispatch<any>(initialize(COLLECTION_MULTI_COPY_FORM_NAME, initialData));
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MULTI_COPY_FORM_NAME, data: {} }));
+};
+
+export const copyCollection =
+    (resource: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const formName = resource.fromContextMenu ? COLLECTION_COPY_FORM_NAME : COLLECTION_MULTI_COPY_FORM_NAME;
+        dispatch(startSubmit(formName));
+        let collection = getResource<CollectionResource>(resource.uuid)(getState().resources);
+        try {
+            if (!collection) {
+                collection = await services.collectionService.get(resource.uuid);
+            }
+            const collManifestText = await services.collectionService.get(resource.uuid, undefined, ["manifestText"]);
+            collection.manifestText = collManifestText.manifestText;
+            const { href, ...collectionRecord } = collection;
+            const newCollection = await services.collectionService.create(
+                {
+                    ...collectionRecord,
+                    ownerUuid: resource.ownerUuid,
+                    name: resource.name,
+                },
+                false
+            );
+            dispatch(dialogActions.CLOSE_DIALOG({ id: formName }));
+            return newCollection;
+        } catch (e) {
+            console.error("Error while copying collection: ", e);
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(
+                    stopSubmit(formName, {
+                        ownerUuid: "A collection with the same name already exists in the target project.",
+                    } as FormErrors)
+                );
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: "Could not copy the collection.",
+                        hideDuration: 2000,
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
+            } else {
+                dispatch(dialogActions.CLOSE_DIALOG({ id: formName }));
+                throw new Error("Could not copy the collection.");
+            }
+            return;
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(formName));
+        }
+    };
diff --git a/services/workbench2/src/store/collections/collection-create-actions.ts b/services/workbench2/src/store/collections/collection-create-actions.ts
new file mode 100644 (file)
index 0000000..f3d1fd3
--- /dev/null
@@ -0,0 +1,88 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import {
+    reset,
+    startSubmit,
+    stopSubmit,
+    initialize,
+    FormErrors,
+    formValueSelector
+} from 'redux-form';
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { ServiceRepository } from 'services/services';
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { uploadCollectionFiles } from './collection-upload-actions';
+import { fileUploaderActions } from 'store/file-uploader/file-uploader-actions';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { isProjectOrRunProcessRoute } from 'store/projects/project-create-actions';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { CollectionResource } from "models/collection";
+
+export interface CollectionCreateFormDialogData {
+    ownerUuid: string;
+    name: string;
+    description: string;
+    storageClassesDesired: string[];
+    properties: CollectionProperties;
+}
+
+export interface CollectionProperties {
+    [key: string]: string | string[];
+}
+
+export const COLLECTION_CREATE_FORM_NAME = "collectionCreateFormName";
+export const COLLECTION_CREATE_PROPERTIES_FORM_NAME = "collectionCreatePropertiesFormName";
+export const COLLECTION_CREATE_FORM_SELECTOR = formValueSelector(COLLECTION_CREATE_FORM_NAME);
+
+export const openCollectionCreateDialog = (ownerUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { router } = getState();
+        if (!isProjectOrRunProcessRoute(router)) {
+            const userUuid = getUserUuid(getState());
+            if (!userUuid) { return; }
+            dispatch(initialize(COLLECTION_CREATE_FORM_NAME, { ownerUuid: userUuid }));
+        } else {
+            dispatch(initialize(COLLECTION_CREATE_FORM_NAME, { ownerUuid }));
+        }
+        dispatch(fileUploaderActions.CLEAR_UPLOAD());
+        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_CREATE_FORM_NAME, data: { ownerUuid } }));
+    };
+
+export const createCollection = (data: CollectionCreateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(COLLECTION_CREATE_FORM_NAME));
+        let newCollection: CollectionResource | undefined;
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(COLLECTION_CREATE_FORM_NAME));
+            newCollection = await services.collectionService.create(data, false);
+            await dispatch<any>(uploadCollectionFiles(newCollection.uuid));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME }));
+            dispatch(reset(COLLECTION_CREATE_FORM_NAME));
+            return newCollection;
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME, { name: 'Collection with the same name already exists.' } as FormErrors));
+            } else {
+                dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME));
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME }));
+                const errMsg = e.errors
+                    ? e.errors.join('')
+                    : 'There was an error while creating the collection';
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: errMsg,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR
+                }));
+                if (newCollection) { await services.collectionService.delete(newCollection.uuid); }
+            }
+            return;
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_CREATE_FORM_NAME));
+        }
+    };
diff --git a/services/workbench2/src/store/collections/collection-info-actions.ts b/services/workbench2/src/store/collections/collection-info-actions.ts
new file mode 100644 (file)
index 0000000..772def2
--- /dev/null
@@ -0,0 +1,124 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ofType, unionize } from 'common/unionize';
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { CollectionResource } from "models/collection";
+import { SshKeyResource } from 'models/ssh-key';
+import { User } from "models/user";
+import { Session } from "models/session";
+import { Config } from 'common/config';
+import { createServices, setAuthorizationHeader } from "services/services";
+import { getTokenV2 } from 'models/api-client-authorization';
+
+export const COLLECTION_WEBDAV_S3_DIALOG_NAME = 'collectionWebdavS3Dialog';
+
+export interface WebDavS3InfoDialogData {
+    uuid: string;
+    token: string;
+    downloadUrl: string;
+    collectionsUrl: string;
+    localCluster: string;
+    username: string;
+    activeTab: number;
+    collectionName: string;
+    setActiveTab: (event: any, tabNr: number) => void;
+}
+
+export const openWebDavS3InfoDialog = (uuid: string, activeTab?: number) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        await dispatch<any>(getNewExtraToken(true));
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: COLLECTION_WEBDAV_S3_DIALOG_NAME,
+            data: {
+                title: 'Open with 3rd party client',
+                token: getState().auth.extraApiToken || getState().auth.apiToken,
+                downloadUrl: getState().auth.config.keepWebServiceUrl,
+                collectionsUrl: getState().auth.config.keepWebInlineServiceUrl,
+                localCluster: getState().auth.localCluster,
+                username: getState().auth.user!.username,
+                activeTab: activeTab || 0,
+                collectionName: (getState().resources[uuid] as CollectionResource).name,
+                setActiveTab: (event: any, tabNr: number) => dispatch<any>(openWebDavS3InfoDialog(uuid, tabNr)),
+                uuid
+            }
+        }));
+    };
+
+const authActions = unionize({
+    LOGIN: {},
+    LOGOUT: ofType<{ deleteLinkData: boolean, preservePath: boolean }>(),
+    SET_CONFIG: ofType<{ config: Config }>(),
+    SET_EXTRA_TOKEN: ofType<{ extraApiToken: string, extraApiTokenExpiration?: Date }>(),
+    RESET_EXTRA_TOKEN: {},
+    INIT_USER: ofType<{ user: User, token: string, tokenExpiration?: Date, tokenLocation?: string }>(),
+    USER_DETAILS_REQUEST: {},
+    USER_DETAILS_SUCCESS: ofType<User>(),
+    SET_SSH_KEYS: ofType<SshKeyResource[]>(),
+    ADD_SSH_KEY: ofType<SshKeyResource>(),
+    REMOVE_SSH_KEY: ofType<string>(),
+    SET_HOME_CLUSTER: ofType<string>(),
+    SET_SESSIONS: ofType<Session[]>(),
+    ADD_SESSION: ofType<Session>(),
+    REMOVE_SESSION: ofType<string>(),
+    UPDATE_SESSION: ofType<Session>(),
+    REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
+});
+
+const getConfig = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Config => {
+    const state = getState().auth;
+    return state.remoteHostsConfig[state.localCluster];
+};
+
+const getNewExtraToken =
+    (reuseStored: boolean = false) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const extraToken = getState().auth.extraApiToken;
+        if (reuseStored && extraToken !== undefined) {
+            const config = dispatch<any>(getConfig);
+            const svc = createServices(config, { progressFn: () => {}, errorFn: () => {} });
+            setAuthorizationHeader(svc, extraToken);
+            try {
+                // Check the extra token's validity before using it. Refresh its
+                // expiration date just in case it changed.
+                const client = await svc.apiClientAuthorizationService.get('current');
+                dispatch(
+                    authActions.SET_EXTRA_TOKEN({
+                        extraApiToken: extraToken,
+                        extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined,
+                    })
+                );
+                return extraToken;
+            } catch (e) {
+                dispatch(authActions.RESET_EXTRA_TOKEN());
+            }
+        }
+        const user = getState().auth.user;
+        const loginCluster = getState().auth.config.clusterConfig.Login.LoginCluster;
+        if (user === undefined) {
+            return;
+        }
+        if (loginCluster !== '' && getState().auth.homeCluster !== loginCluster) {
+            return;
+        }
+        try {
+            // Do not show errors on the create call, cluster security configuration may not
+            // allow token creation and there's no way to know that from workbench2 side in advance.
+            const client = await services.apiClientAuthorizationService.create(undefined, false);
+            const newExtraToken = getTokenV2(client);
+            dispatch(
+                authActions.SET_EXTRA_TOKEN({
+                    extraApiToken: newExtraToken,
+                    extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined,
+                })
+            );
+            return newExtraToken;
+        } catch {
+            console.warn("Cannot create new tokens with the current token, probably because of cluster's security settings.");
+            return;
+        }
+    };
\ No newline at end of file
diff --git a/services/workbench2/src/store/collections/collection-move-actions.ts b/services/workbench2/src/store/collections/collection-move-actions.ts
new file mode 100644 (file)
index 0000000..56c7b24
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { startSubmit, stopSubmit, initialize, FormErrors } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
+import { getResource } from "store/resources/resources";
+import { CollectionResource } from "models/collection";
+
+export const COLLECTION_MOVE_FORM_NAME = "collectionMoveFormName";
+
+export const openMoveCollectionDialog = (resource: { name: string; uuid: string }) => (dispatch: Dispatch) => {
+    dispatch<any>(resetPickerProjectTree());
+    dispatch<any>(initProjectsTreePicker(COLLECTION_MOVE_FORM_NAME));
+    dispatch(initialize(COLLECTION_MOVE_FORM_NAME, resource));
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MOVE_FORM_NAME, data: {} }));
+};
+
+export const moveCollection =
+    (resource: MoveToFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(COLLECTION_MOVE_FORM_NAME));
+        let cachedCollection = getResource<CollectionResource>(resource.uuid)(getState().resources);
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(COLLECTION_MOVE_FORM_NAME));
+            if (!cachedCollection) {
+                cachedCollection = await services.collectionService.get(resource.uuid);
+            }
+            const collection = await services.collectionService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
+            dispatch(projectPanelActions.REQUEST_ITEMS());
+            dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
+            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
+            return { ...cachedCollection, ...collection };
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(
+                    stopSubmit(COLLECTION_MOVE_FORM_NAME, {
+                        ownerUuid: "A collection with the same name already exists in the target project.",
+                    } as FormErrors)
+                );
+            } else {
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not move the collection.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            }
+            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
+            return;
+        }
+    };
diff --git a/services/workbench2/src/store/collections/collection-partial-copy-actions.ts b/services/workbench2/src/store/collections/collection-partial-copy-actions.ts
new file mode 100644 (file)
index 0000000..a0933c6
--- /dev/null
@@ -0,0 +1,252 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { initialize, startSubmit, stopSubmit } from 'redux-form';
+import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { ServiceRepository } from 'services/services';
+import { CollectionFileSelection, CollectionPanelDirectory, CollectionPanelFile, filterCollectionFilesBySelection, getCollectionSelection } from '../collection-panel/collection-panel-files/collection-panel-files-state';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { getCommonResourceServiceError, CommonResourceServiceError } from 'services/common-service/common-resource-service';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { FileOperationLocation } from "store/tree-picker/tree-picker-actions";
+import { updateResources } from 'store/resources/resources-actions';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { ContextMenuResource } from 'store/context-menu/context-menu-actions';
+import { CollectionResource } from 'models/collection';
+
+export const COLLECTION_PARTIAL_COPY_FORM_NAME = 'COLLECTION_PARTIAL_COPY_DIALOG';
+export const COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION = 'COLLECTION_PARTIAL_COPY_TO_SELECTED_DIALOG';
+export const COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS = 'COLLECTION_PARTIAL_COPY_TO_SEPARATE_DIALOG';
+
+export interface CollectionPartialCopyToNewCollectionFormData {
+    name: string;
+    description: string;
+    projectUuid: string;
+}
+
+export interface CollectionPartialCopyToExistingCollectionFormData {
+    destination: FileOperationLocation;
+}
+
+export interface CollectionPartialCopyToSeparateCollectionsFormData {
+    name: string;
+    projectUuid: string;
+}
+
+export const openCollectionPartialCopyToNewCollectionDialog = (resource: ContextMenuResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+
+        if (sourceCollection) {
+            openCopyToNewDialog(dispatch, sourceCollection, [resource]);
+        }
+    };
+
+export const openCollectionPartialCopyMultipleToNewCollectionDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            openCopyToNewDialog(dispatch, sourceCollection, selectedItems);
+        }
+    };
+
+const openCopyToNewDialog = (dispatch: Dispatch, sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => {
+    // Get selected files
+    const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+    // Populate form initial state
+    const initialFormData = {
+        name: `Files extracted from: ${sourceCollection.name}`,
+        description: sourceCollection.description,
+        projectUuid: undefined
+    };
+    dispatch(initialize(COLLECTION_PARTIAL_COPY_FORM_NAME, initialFormData));
+    dispatch<any>(resetPickerProjectTree());
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME, data: collectionFileSelection }));
+};
+
+export const copyCollectionPartialToNewCollection = (fileSelection: CollectionFileSelection, formData: CollectionPartialCopyToNewCollectionFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_COPY_FORM_NAME));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
+
+                // Copy files
+                const updatedCollection = await services.collectionService.copyFiles(
+                    fileSelection.collection.portableDataHash,
+                    fileSelection.selectedPaths,
+                    {
+                        name: formData.name,
+                        description: formData.description,
+                        ownerUuid: formData.projectUuid,
+                        uuid: undefined,
+                    },
+                    '/',
+                    false
+                );
+                dispatch(updateResources([updatedCollection]));
+                dispatch<any>(navigateTo(updatedCollection.uuid));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'New collection created.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection with this name already exists', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not create a copy of collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_COPY_FORM_NAME));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
+            }
+        }
+    };
+
+export const openCollectionPartialCopyToExistingCollectionDialog = (resource: ContextMenuResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+
+        if (sourceCollection) {
+            openCopyToExistingDialog(dispatch, sourceCollection, [resource]);
+        }
+    };
+
+export const openCollectionPartialCopyMultipleToExistingCollectionDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            openCopyToExistingDialog(dispatch, sourceCollection, selectedItems);
+        }
+    };
+
+const openCopyToExistingDialog = (dispatch: Dispatch, sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => {
+    // Get selected files
+    const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+    // Populate form initial state
+    const initialFormData = {
+        destination: {uuid: sourceCollection.uuid, destinationPath: ''}
+    };
+    dispatch(initialize(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION, initialFormData));
+    dispatch<any>(resetPickerProjectTree());
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION, data: collectionFileSelection }));
+}
+
+export const copyCollectionPartialToExistingCollection = (fileSelection: CollectionFileSelection, formData: CollectionPartialCopyToExistingCollectionFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection && formData.destination && formData.destination.uuid) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
+
+                // Copy files
+                const updatedCollection = await services.collectionService.copyFiles(
+                    fileSelection.collection.portableDataHash,
+                    fileSelection.selectedPaths,
+                    {uuid: formData.destination.uuid},
+                    formData.destination.subpath || '/',
+                    false
+                );
+                dispatch(updateResources([updatedCollection]));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Files has been copied to selected collection.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not copy this files to selected collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
+            }
+        }
+    };
+
+export const openCollectionPartialCopyToSeparateCollectionsDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            // Get selected files
+            const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+            // Populate form initial state
+            const initialFormData = {
+                name: sourceCollection.name,
+                projectUuid: undefined
+            };
+            dispatch(initialize(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS, initialFormData));
+            dispatch<any>(resetPickerProjectTree());
+            dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS, data: collectionFileSelection }));
+        }
+    };
+
+export const copyCollectionPartialToSeparateCollections = (fileSelection: CollectionFileSelection, formData: CollectionPartialCopyToSeparateCollectionsFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS));
+
+                // Copy files
+                const collections = await Promise.all(fileSelection.selectedPaths.map((path) =>
+                    services.collectionService.copyFiles(
+                        fileSelection.collection.portableDataHash,
+                        [path],
+                        {
+                            name: `File copied from collection ${formData.name}${path}`,
+                            ownerUuid: formData.projectUuid,
+                            uuid: undefined,
+                        },
+                        '/',
+                        false
+                    )
+                ));
+                dispatch(updateResources(collections));
+                dispatch<any>(navigateTo(formData.projectUuid));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'New collections created.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection from one or more files already exists', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not create a copy of collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS));
+            }
+        }
+    };
diff --git a/services/workbench2/src/store/collections/collection-partial-move-actions.ts b/services/workbench2/src/store/collections/collection-partial-move-actions.ts
new file mode 100644 (file)
index 0000000..56f7302
--- /dev/null
@@ -0,0 +1,252 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { initialize, startSubmit, stopSubmit } from "redux-form";
+import { CommonResourceServiceError, getCommonResourceServiceError } from "services/common-service/common-resource-service";
+import { ServiceRepository } from "services/services";
+import { CollectionFileSelection, CollectionPanelDirectory, CollectionPanelFile, filterCollectionFilesBySelection, getCollectionSelection } from "store/collection-panel/collection-panel-files/collection-panel-files-state";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { navigateTo } from "store/navigation/navigation-action";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { updateResources } from "store/resources/resources-actions";
+import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions";
+import { RootState } from "store/store";
+import { FileOperationLocation } from "store/tree-picker/tree-picker-actions";
+import { CollectionResource } from "models/collection";
+import { SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE } from "services/collection-service/collection-service";
+
+export const COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION = 'COLLECTION_PARTIAL_MOVE_TO_NEW_DIALOG';
+export const COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION = 'COLLECTION_PARTIAL_MOVE_TO_SELECTED_DIALOG';
+export const COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS = 'COLLECTION_PARTIAL_MOVE_TO_SEPARATE_DIALOG';
+
+export interface CollectionPartialMoveToNewCollectionFormData {
+    name: string;
+    description: string;
+    projectUuid: string;
+}
+
+export interface CollectionPartialMoveToExistingCollectionFormData {
+    destination: FileOperationLocation;
+}
+
+export interface CollectionPartialMoveToSeparateCollectionsFormData {
+    name: string;
+    projectUuid: string;
+}
+
+export const openCollectionPartialMoveToNewCollectionDialog = (resource: ContextMenuResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+
+        if (sourceCollection) {
+            openMoveToNewDialog(dispatch, sourceCollection, [resource]);
+        }
+    };
+
+export const openCollectionPartialMoveMultipleToNewCollectionDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            openMoveToNewDialog(dispatch, sourceCollection, selectedItems);
+        }
+    };
+
+const openMoveToNewDialog = (dispatch: Dispatch, sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => {
+    // Get selected files
+    const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+    // Populate form initial state
+    const initialFormData = {
+        name: `Files moved from: ${sourceCollection.name}`,
+        description: sourceCollection.description,
+        projectUuid: undefined
+    };
+    dispatch(initialize(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION, initialFormData));
+    dispatch<any>(resetPickerProjectTree());
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION, data: collectionFileSelection }));
+}
+
+export const moveCollectionPartialToNewCollection = (fileSelection: CollectionFileSelection, formData: CollectionPartialMoveToNewCollectionFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION));
+
+                // Move files
+                const updatedCollection = await services.collectionService.moveFiles(
+                    fileSelection.collection.uuid,
+                    fileSelection.collection.portableDataHash,
+                    fileSelection.selectedPaths,
+                    {
+                        name: formData.name,
+                        description: formData.description,
+                        ownerUuid: formData.projectUuid,
+                        uuid: undefined,
+                    },
+                    '/',
+                    false
+                );
+                dispatch(updateResources([updatedCollection]));
+                dispatch<any>(navigateTo(updatedCollection.uuid));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Files have been moved to selected collection.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move files to selected collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION));
+            }
+        }
+    };
+
+export const openCollectionPartialMoveToExistingCollectionDialog = (resource: ContextMenuResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+
+        if (sourceCollection) {
+            openMoveToExistingDialog(dispatch, sourceCollection, [resource]);
+        }
+    };
+
+export const openCollectionPartialMoveMultipleToExistingCollectionDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            openMoveToExistingDialog(dispatch, sourceCollection, selectedItems);
+        }
+    };
+
+const openMoveToExistingDialog = (dispatch: Dispatch, sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => {
+    // Get selected files
+    const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+    // Populate form initial state
+    const initialFormData = {
+        destination: {uuid: sourceCollection.uuid, path: ''}
+    };
+    dispatch(initialize(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION, initialFormData));
+    dispatch<any>(resetPickerProjectTree());
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION, data: collectionFileSelection }));
+}
+
+export const moveCollectionPartialToExistingCollection = (fileSelection: CollectionFileSelection, formData: CollectionPartialMoveToExistingCollectionFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection && formData.destination && formData.destination.uuid) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION));
+
+                // Move files
+                const updatedCollection = await services.collectionService.moveFiles(
+                    fileSelection.collection.uuid,
+                    fileSelection.collection.portableDataHash,
+                    fileSelection.selectedPaths,
+                    {uuid: formData.destination.uuid},
+                    formData.destination.subpath || '/', false
+                );
+                dispatch(updateResources([updatedCollection]));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Files have been moved to selected collection.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not copy this files to selected collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION));
+            }
+        }
+    };
+
+export const openCollectionPartialMoveToSeparateCollectionsDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            // Get selected files
+            const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+            // Populate form initial state
+            const initialData = {
+                name: sourceCollection.name,
+                projectUuid: undefined
+            };
+            dispatch(initialize(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS, initialData));
+            dispatch<any>(resetPickerProjectTree());
+            dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS, data: collectionFileSelection }));
+        }
+    };
+
+export const moveCollectionPartialToSeparateCollections = (fileSelection: CollectionFileSelection, formData: CollectionPartialMoveToSeparateCollectionsFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS));
+
+                // Move files
+                const collections = await Promise.all(fileSelection.selectedPaths.map((path) =>
+                    services.collectionService.moveFiles(
+                        fileSelection.collection.uuid,
+                        fileSelection.collection.portableDataHash,
+                        [path],
+                        {
+                            name: `File moved from collection ${formData.name}${path}`,
+                            ownerUuid: formData.projectUuid,
+                            uuid: undefined,
+                        },
+                        '/',
+                        false
+                    )
+                ));
+                dispatch(updateResources(collections));
+                dispatch<any>(navigateTo(formData.projectUuid));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'New collections created.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection from one or more files already exists', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not create a copy of collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS));
+            }
+        }
+    };
diff --git a/services/workbench2/src/store/collections/collection-update-actions.ts b/services/workbench2/src/store/collections/collection-update-actions.ts
new file mode 100644 (file)
index 0000000..f6e52a4
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import {
+    FormErrors,
+    formValueSelector,
+    initialize,
+    startSubmit,
+    stopSubmit
+} from 'redux-form';
+import { RootState } from "store/store";
+import { collectionPanelActions } from "store/collection-panel/collection-panel-action";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { ServiceRepository } from "services/services";
+import { CollectionResource } from 'models/collection';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
+import { updateResources } from "../resources/resources-actions";
+import { loadDetailsPanel } from "../details-panel/details-panel-action";
+import { getResource } from "store/resources/resources";
+import { CollectionProperties } from "./collection-create-actions";
+import { loadSidePanelTreeProjects, SidePanelTreeCategory } from "store/side-panel-tree/side-panel-tree-actions";
+
+export interface CollectionUpdateFormDialogData {
+    uuid: string;
+    name: string;
+    description?: string;
+    storageClassesDesired?: string[];
+    properties?: CollectionProperties;
+}
+
+export const COLLECTION_UPDATE_FORM_NAME = 'collectionUpdateFormName';
+export const COLLECTION_UPDATE_PROPERTIES_FORM_NAME = "collectionUpdatePropertiesFormName";
+export const COLLECTION_UPDATE_FORM_SELECTOR = formValueSelector(COLLECTION_UPDATE_FORM_NAME);
+
+export const openCollectionUpdateDialog = (resource: CollectionUpdateFormDialogData) =>
+    (dispatch: Dispatch) => {
+        dispatch(initialize(COLLECTION_UPDATE_FORM_NAME, resource));
+        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME, data: {} }));
+    };
+
+export const updateCollection = (collection: CollectionUpdateFormDialogData) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuid = collection.uuid || '';
+        dispatch(startSubmit(COLLECTION_UPDATE_FORM_NAME));
+        dispatch(progressIndicatorActions.START_WORKING(COLLECTION_UPDATE_FORM_NAME));
+
+        const cachedCollection = getResource<CollectionResource>(collection.uuid)(getState().resources);
+        services.collectionService.update(uuid, {
+            name: collection.name,
+            storageClassesDesired: collection.storageClassesDesired,
+            description: collection.description,
+            properties: collection.properties }, false
+        ).then(updatedCollection => {
+            updatedCollection = {...cachedCollection, ...updatedCollection};
+            dispatch(collectionPanelActions.SET_COLLECTION(updatedCollection));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME }));
+            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPDATE_FORM_NAME));
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Collection has been successfully updated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS
+            }));
+            dispatch<any>(updateResources([updatedCollection]));
+            dispatch<any>(loadDetailsPanel(updatedCollection.uuid));
+            dispatch<any>(loadSidePanelTreeProjects(SidePanelTreeCategory.FAVORITES))
+        }).catch (e => {
+            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPDATE_FORM_NAME));
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(stopSubmit(COLLECTION_UPDATE_FORM_NAME, { name: 'Collection with the same name already exists.' } as FormErrors));
+            } else {
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME }));
+                const errMsg = e.errors
+                    ? e.errors.join('')
+                    : 'There was an error while updating the collection';
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: errMsg,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR }));
+                }
+            }
+        );
+    };
diff --git a/services/workbench2/src/store/collections/collection-upload-actions.ts b/services/workbench2/src/store/collections/collection-upload-actions.ts
new file mode 100644 (file)
index 0000000..e9c5cc3
--- /dev/null
@@ -0,0 +1,64 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { fileUploaderActions } from 'store/file-uploader/file-uploader-actions';
+import { reset, startSubmit, stopSubmit } from 'redux-form';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import * as WorkbenchActions from 'store/workbench/workbench-actions';
+
+export const uploadCollectionFiles = (collectionUuid: string, targetLocation?: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(fileUploaderActions.START_UPLOAD());
+        const files = getState().fileUploader.map(file => file.file);
+        await services.collectionService.uploadFiles(collectionUuid, files, handleUploadProgress(dispatch), targetLocation);
+        dispatch(WorkbenchActions.loadCollection(collectionUuid));
+        dispatch(fileUploaderActions.CLEAR_UPLOAD());
+    };
+
+export const COLLECTION_UPLOAD_FILES_DIALOG = 'uploadCollectionFilesDialog';
+
+export const openUploadCollectionFilesDialog = (targetLocation?: string) => (dispatch: Dispatch) => {
+    dispatch(reset(COLLECTION_UPLOAD_FILES_DIALOG));
+    dispatch(fileUploaderActions.CLEAR_UPLOAD());
+    dispatch<any>(dialogActions.OPEN_DIALOG({ id: COLLECTION_UPLOAD_FILES_DIALOG, data: { targetLocation } }));
+};
+
+export const submitCollectionFiles = (targetLocation?: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const currentCollection = getState().collectionPanel.item;
+        if (currentCollection) {
+            try {
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));
+                dispatch(startSubmit(COLLECTION_UPLOAD_FILES_DIALOG));
+                await dispatch<any>(uploadCollectionFiles(currentCollection.uuid, targetLocation));
+                dispatch(closeUploadCollectionFilesDialog());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Data has been uploaded.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));
+            } catch (e) {
+                dispatch(stopSubmit(COLLECTION_UPLOAD_FILES_DIALOG));
+                dispatch(closeUploadCollectionFilesDialog());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Data has not been uploaded. Too large file',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR
+                }));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));
+            }
+        }
+    };
+
+export const closeUploadCollectionFilesDialog = () => dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPLOAD_FILES_DIALOG });
+
+const handleUploadProgress = (dispatch: Dispatch) => (fileId: number, loaded: number, total: number, currentTime: number) => {
+    dispatch(fileUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));
+};
\ No newline at end of file
diff --git a/services/workbench2/src/store/collections/collection-version-actions.ts b/services/workbench2/src/store/collections/collection-version-actions.ts
new file mode 100644 (file)
index 0000000..7d2511e
--- /dev/null
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
+import { resourcesActions } from "../resources/resources-actions";
+import { navigateTo } from "../navigation/navigation-action";
+import { dialogActions } from "../dialog/dialog-actions";
+import { getResource } from "store/resources/resources";
+import { CollectionResource } from "models/collection";
+
+export const COLLECTION_RESTORE_VERSION_DIALOG = 'collectionRestoreVersionDialog';
+
+export const openRestoreCollectionVersionDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: COLLECTION_RESTORE_VERSION_DIALOG,
+            data: {
+                title: 'Restore version',
+                text: "This will copy the content of the selected version to the head. To make a new collection with the content of the selected version, use 'Make a copy' instead.",
+                confirmButtonLabel: 'Restore',
+                uuid
+            }
+        }));
+    };
+
+export const restoreVersion = (resourceUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            // Request the manifest text because stored old versions usually
+            // don't include them.
+            let oldVersion = getResource<CollectionResource>(resourceUuid)(getState().resources);
+            if (!oldVersion) {
+                oldVersion = await services.collectionService.get(resourceUuid);
+            }
+            const oldVersionManifest = await services.collectionService.get(resourceUuid, undefined, ['manifestText']);
+            oldVersion.manifestText = oldVersionManifest.manifestText;
+
+            const { uuid, version, ...rest} = oldVersion;
+            const headVersion = await services.collectionService.update(
+                oldVersion.currentVersionUuid,
+                { ...rest }
+            );
+            dispatch(resourcesActions.SET_RESOURCES([headVersion]));
+            dispatch<any>(navigateTo(headVersion.uuid));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `Couldn't restore version: ${e.errors[0]}`,
+                hideDuration: 2000,
+                kind: SnackbarKind.ERROR
+            }));
+        }
+    };
diff --git a/services/workbench2/src/store/context-menu/context-menu-actions.test.ts b/services/workbench2/src/store/context-menu/context-menu-actions.test.ts
new file mode 100644 (file)
index 0000000..a8b8e40
--- /dev/null
@@ -0,0 +1,149 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
+import { resourceUuidToContextMenuKind } from './context-menu-actions';
+import configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { PROJECT_PANEL_CURRENT_UUID } from '../project-panel/project-panel-action';
+import { GroupClass } from 'models/group';
+
+describe('context-menu-actions', () => {
+    describe('resourceUuidToContextMenuKind', () => {
+        const middlewares = [thunk];
+        const mockStore = configureStore(middlewares);
+        const userUuid = 'zzzzz-tpzed-bbbbbbbbbbbbbbb';
+        const otherUserUuid = 'zzzzz-tpzed-bbbbbbbbbbbbbbc';
+        const headCollectionUuid = 'zzzzz-4zz18-aaaaaaaaaaaaaaa';
+        const oldCollectionUuid = 'zzzzz-4zz18-aaaaaaaaaaaaaab';
+        const projectUuid = 'zzzzz-j7d0g-ccccccccccccccc';
+        const filterGroupUuid = 'zzzzz-j7d0g-ccccccccccccccd';
+        const linkUuid = 'zzzzz-o0j2j-0123456789abcde';
+        const containerRequestUuid = 'zzzzz-xvhdp-0123456789abcde';
+
+        it('should return the correct menu kind', () => {
+            const cases = [
+                // resourceUuid, isAdminUser, isEditable, isTrashed, forceReadonly, expected
+                [headCollectionUuid, false, true, true, false, ContextMenuKind.TRASHED_COLLECTION],
+                [headCollectionUuid, false, true, false, false, ContextMenuKind.COLLECTION],
+                [headCollectionUuid, false, true, false, true, ContextMenuKind.READONLY_COLLECTION],
+                [headCollectionUuid, false, false, true, false, ContextMenuKind.READONLY_COLLECTION],
+                [headCollectionUuid, false, false, false, false, ContextMenuKind.READONLY_COLLECTION],
+                [headCollectionUuid, true, true, true, false, ContextMenuKind.TRASHED_COLLECTION],
+                [headCollectionUuid, true, true, false, false, ContextMenuKind.COLLECTION_ADMIN],
+                [headCollectionUuid, true, false, true, false, ContextMenuKind.TRASHED_COLLECTION],
+                [headCollectionUuid, true, false, false, false, ContextMenuKind.COLLECTION_ADMIN],
+                [headCollectionUuid, true, false, false, true, ContextMenuKind.READONLY_COLLECTION],
+
+                [oldCollectionUuid, false, true, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, true, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, false, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, false, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, true, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, true, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, false, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, false, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+
+                // FIXME: WB2 doesn't currently have context menu for trashed projects
+                // [projectUuid, false, true, true, false, ContextMenuKind.TRASHED_PROJECT],
+                [projectUuid, false, true, false, false, ContextMenuKind.PROJECT],
+                [projectUuid, false, true, false, true, ContextMenuKind.READONLY_PROJECT],
+                [projectUuid, false, false, true, false, ContextMenuKind.READONLY_PROJECT],
+                [projectUuid, false, false, false, false, ContextMenuKind.READONLY_PROJECT],
+                // [projectUuid, true, true, true, false, ContextMenuKind.TRASHED_PROJECT],
+                [projectUuid, true, true, false, false, ContextMenuKind.PROJECT_ADMIN],
+                // [projectUuid, true, false, true, false, ContextMenuKind.TRASHED_PROJECT],
+                [projectUuid, true, false, false, false, ContextMenuKind.PROJECT_ADMIN],
+                [projectUuid, true, false, false, true, ContextMenuKind.READONLY_PROJECT],
+
+                [linkUuid, false, true, true, false, ContextMenuKind.LINK],
+                [linkUuid, false, true, false, false, ContextMenuKind.LINK],
+                [linkUuid, false, false, true, false, ContextMenuKind.LINK],
+                [linkUuid, false, false, false, false, ContextMenuKind.LINK],
+                [linkUuid, true, true, true, false, ContextMenuKind.LINK],
+                [linkUuid, true, true, false, false, ContextMenuKind.LINK],
+                [linkUuid, true, false, true, false, ContextMenuKind.LINK],
+                [linkUuid, true, false, false, false, ContextMenuKind.LINK],
+
+                [userUuid, false, true, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, true, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, false, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, false, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, true, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, true, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, false, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, false, false, false, ContextMenuKind.ROOT_PROJECT],
+
+                [containerRequestUuid, false, true, true, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, true, false, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, false, true, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, false, false, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, false, false, true, ContextMenuKind.READONLY_PROCESS_RESOURCE],
+                [containerRequestUuid, true, true, true, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, true, false, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, false, true, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, false, false, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, false, false, true, ContextMenuKind.READONLY_PROCESS_RESOURCE],
+            ]
+
+            cases.forEach(([resourceUuid, isAdminUser, isEditable, isTrashed, forceReadonly, expected]) => {
+                const initialState = {
+                    properties: {
+                        [PROJECT_PANEL_CURRENT_UUID]: projectUuid,
+                    },
+                    resources: {
+                        [headCollectionUuid]: {
+                            uuid: headCollectionUuid,
+                            ownerUuid: projectUuid,
+                            currentVersionUuid: headCollectionUuid,
+                            isTrashed: isTrashed,
+                        },
+                        [oldCollectionUuid]: {
+                            uuid: oldCollectionUuid,
+                            currentVersionUuid: headCollectionUuid,
+                            isTrashed: isTrashed,
+                        },
+                        [projectUuid]: {
+                            uuid: projectUuid,
+                            ownerUuid: isEditable ? userUuid : otherUserUuid,
+                            canWrite: isEditable,
+                            groupClass: GroupClass.PROJECT,
+                        },
+                        [filterGroupUuid]: {
+                            uuid: filterGroupUuid,
+                            ownerUuid: isEditable ? userUuid : otherUserUuid,
+                            canWrite: isEditable,
+                            groupClass: GroupClass.FILTER,
+                        },
+                        [linkUuid]: {
+                            uuid: linkUuid,
+                        },
+                        [userUuid]: {
+                            uuid: userUuid,
+                        },
+                        [containerRequestUuid]: {
+                            uuid: containerRequestUuid,
+                            ownerUuid: projectUuid,
+                        },
+                    },
+                    auth: {
+                        user: {
+                            uuid: userUuid,
+                            isAdmin: isAdminUser,
+                        },
+                    },
+                };
+                const store = mockStore(initialState);
+
+                let menuKind: any;
+                try {
+                    menuKind = store.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid as string, forceReadonly as boolean))
+                    expect(menuKind).toBe(expected);
+                } catch (err) {
+                    throw new Error(`menuKind for resource ${JSON.stringify(initialState.resources[resourceUuid as string])} forceReadonly: ${forceReadonly} expected to be ${expected} but got ${menuKind}.`);
+                }
+            });
+        });
+    });
+});
diff --git a/services/workbench2/src/store/context-menu/context-menu-actions.ts b/services/workbench2/src/store/context-menu/context-menu-actions.ts
new file mode 100644 (file)
index 0000000..4c31fa4
--- /dev/null
@@ -0,0 +1,326 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { ContextMenuPosition } from "./context-menu-reducer";
+import { ContextMenuKind } from "views-components/context-menu/menu-item-sort";
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getResource, getResourceWithEditableStatus } from "../resources/resources";
+import { UserResource } from "models/user";
+import { isSidePanelTreeCategory } from "store/side-panel-tree/side-panel-tree-actions";
+import { extractUuidKind, ResourceKind, EditableResource, Resource } from "models/resource";
+import { Process, isProcessCancelable } from "store/processes/process";
+import { RepositoryResource } from "models/repositories";
+import { SshKeyResource } from "models/ssh-key";
+import { VirtualMachinesResource } from "models/virtual-machines";
+import { KeepServiceResource } from "models/keep-services";
+import { ProcessResource } from "models/process";
+import { CollectionResource } from "models/collection";
+import { GroupClass, GroupResource } from "models/group";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { LinkResource } from "models/link";
+import { resourceIsFrozen } from "common/frozen-resources";
+import { ProjectResource } from "models/project";
+import { getProcess } from "store/processes/process";
+import { filterCollectionFilesBySelection } from "store/collection-panel/collection-panel-files/collection-panel-files-state";
+
+export const contextMenuActions = unionize({
+    OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition; resource: ContextMenuResource }>(),
+    CLOSE_CONTEXT_MENU: ofType<{}>(),
+});
+
+export type ContextMenuAction = UnionOf<typeof contextMenuActions>;
+
+export type ContextMenuResource = {
+    name: string;
+    uuid: string;
+    ownerUuid: string;
+    description?: string;
+    kind: ResourceKind;
+    menuKind: ContextMenuKind | string;
+    isTrashed?: boolean;
+    isEditable?: boolean;
+    outputUuid?: string;
+    workflowUuid?: string;
+    isAdmin?: boolean;
+    isFrozen?: boolean;
+    storageClassesDesired?: string[];
+    properties?: { [key: string]: string | string[] };
+    isMulti?: boolean;
+    fromContextMenu?: boolean;
+};
+
+export const isKeyboardClick = (event: React.MouseEvent<HTMLElement>) => event.nativeEvent.detail === 0;
+
+export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: ContextMenuResource) => (dispatch: Dispatch) => {
+    event.preventDefault();
+    const { left, top } = event.currentTarget.getBoundingClientRect();
+    dispatch(
+        contextMenuActions.OPEN_CONTEXT_MENU({
+            position: {
+                x: event.clientX || left,
+                y: event.clientY || top,
+            },
+            resource,
+        })
+    );
+};
+
+export const openCollectionFilesContextMenu =
+    (event: React.MouseEvent<HTMLElement>, isWritable: boolean) => (dispatch: Dispatch, getState: () => RootState) => {
+        const selectedCount = filterCollectionFilesBySelection(getState().collectionPanelFiles, true).length;
+        const multiple = selectedCount > 1;
+        dispatch<any>(
+            openContextMenu(event, {
+                name: "",
+                uuid: "",
+                ownerUuid: "",
+                description: "",
+                kind: ResourceKind.COLLECTION,
+                menuKind:
+                    selectedCount > 0
+                        ? isWritable
+                            ? multiple
+                                ? ContextMenuKind.COLLECTION_FILES_MULTIPLE
+                                : ContextMenuKind.COLLECTION_FILES
+                            : multiple
+                            ? ContextMenuKind.READONLY_COLLECTION_FILES_MULTIPLE
+                            : ContextMenuKind.READONLY_COLLECTION_FILES
+                        : ContextMenuKind.COLLECTION_FILES_NOT_SELECTED,
+            })
+        );
+    };
+
+export const openRepositoryContextMenu =
+    (event: React.MouseEvent<HTMLElement>, repository: RepositoryResource) => (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch<any>(
+            openContextMenu(event, {
+                name: "",
+                uuid: repository.uuid,
+                ownerUuid: repository.ownerUuid,
+                kind: ResourceKind.REPOSITORY,
+                menuKind: ContextMenuKind.REPOSITORY,
+            })
+        );
+    };
+
+export const openVirtualMachinesContextMenu =
+    (event: React.MouseEvent<HTMLElement>, repository: VirtualMachinesResource) => (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch<any>(
+            openContextMenu(event, {
+                name: "",
+                uuid: repository.uuid,
+                ownerUuid: repository.ownerUuid,
+                kind: ResourceKind.VIRTUAL_MACHINE,
+                menuKind: ContextMenuKind.VIRTUAL_MACHINE,
+            })
+        );
+    };
+
+export const openSshKeyContextMenu = (event: React.MouseEvent<HTMLElement>, sshKey: SshKeyResource) => (dispatch: Dispatch) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
+            uuid: sshKey.uuid,
+            ownerUuid: sshKey.ownerUuid,
+            kind: ResourceKind.SSH_KEY,
+            menuKind: ContextMenuKind.SSH_KEY,
+        })
+    );
+};
+
+export const openKeepServiceContextMenu = (event: React.MouseEvent<HTMLElement>, keepService: KeepServiceResource) => (dispatch: Dispatch) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
+            uuid: keepService.uuid,
+            ownerUuid: keepService.ownerUuid,
+            kind: ResourceKind.KEEP_SERVICE,
+            menuKind: ContextMenuKind.KEEP_SERVICE,
+        })
+    );
+};
+
+export const openApiClientAuthorizationContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => (dispatch: Dispatch) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
+            uuid: resourceUuid,
+            ownerUuid: "",
+            kind: ResourceKind.API_CLIENT_AUTHORIZATION,
+            menuKind: ContextMenuKind.API_CLIENT_AUTHORIZATION,
+        })
+    );
+};
+
+export const openRootProjectContextMenu =
+    (event: React.MouseEvent<HTMLElement>, projectUuid: string) => (dispatch: Dispatch, getState: () => RootState) => {
+        const res = getResource<UserResource>(projectUuid)(getState().resources);
+        if (res) {
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: "",
+                    uuid: res.uuid,
+                    ownerUuid: res.uuid,
+                    kind: res.kind,
+                    menuKind: ContextMenuKind.ROOT_PROJECT,
+                    isTrashed: false,
+                })
+            );
+        }
+    };
+
+export const openProjectContextMenu =
+    (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => (dispatch: Dispatch, getState: () => RootState) => {
+        const res = getResource<GroupContentsResource>(resourceUuid)(getState().resources);
+        const menuKind = dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
+        if (res && menuKind) {
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: res.name,
+                    uuid: res.uuid,
+                    kind: res.kind,
+                    menuKind,
+                    description: res.description,
+                    ownerUuid: res.ownerUuid,
+                    isTrashed: "isTrashed" in res ? res.isTrashed : false,
+                    isFrozen: !!(res as ProjectResource).frozenByUuid,
+                })
+            );
+        }
+    };
+
+export const openSidePanelContextMenu = (event: React.MouseEvent<HTMLElement>, id: string) => (dispatch: Dispatch, getState: () => RootState) => {
+    if (!isSidePanelTreeCategory(id)) {
+        const kind = extractUuidKind(id);
+        if (kind === ResourceKind.USER) {
+            dispatch<any>(openRootProjectContextMenu(event, id));
+        } else if (kind === ResourceKind.PROJECT) {
+            dispatch<any>(openProjectContextMenu(event, id));
+        }
+    }
+};
+
+export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, process: Process) => (dispatch: Dispatch, getState: () => RootState) => {
+    const res = getResource<ProcessResource>(process.containerRequest.uuid)(getState().resources);
+    if (res) {
+        dispatch<any>(
+            openContextMenu(event, {
+                uuid: res.uuid,
+                ownerUuid: res.ownerUuid,
+                kind: ResourceKind.PROCESS,
+                name: res.name,
+                description: res.description,
+                outputUuid: res.outputUuid || "",
+                workflowUuid: res.properties.template_uuid || "",
+                menuKind: isProcessCancelable(process) ? ContextMenuKind.RUNNING_PROCESS_RESOURCE : ContextMenuKind.PROCESS_RESOURCE
+            })
+        );
+    }
+};
+
+export const openPermissionEditContextMenu =
+    (event: React.MouseEvent<HTMLElement>, link: LinkResource) => (dispatch: Dispatch, getState: () => RootState) => {
+        if (link) {
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: link.name,
+                    uuid: link.uuid,
+                    kind: link.kind,
+                    menuKind: ContextMenuKind.PERMISSION_EDIT,
+                    ownerUuid: link.ownerUuid,
+                })
+            );
+        }
+    };
+
+export const openUserContextMenu = (event: React.MouseEvent<HTMLElement>, user: UserResource) => (dispatch: Dispatch, getState: () => RootState) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
+            uuid: user.uuid,
+            ownerUuid: user.ownerUuid,
+            kind: user.kind,
+            menuKind: ContextMenuKind.USER,
+        })
+    );
+};
+
+export const resourceUuidToContextMenuKind =
+    (uuid: string, readonly = false) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const { isAdmin: isAdminUser, uuid: userUuid } = getState().auth.user!;
+        const kind = extractUuidKind(uuid);
+        const resource = getResourceWithEditableStatus<GroupResource & EditableResource>(uuid, userUuid)(getState().resources);
+        const isFrozen = resourceIsFrozen(resource, getState().resources);
+        const isEditable = (isAdminUser || (resource || ({} as EditableResource)).isEditable) && !readonly && !isFrozen;
+
+        switch (kind) {
+            case ResourceKind.PROJECT:
+                if (isFrozen) {
+                    return isAdminUser ? ContextMenuKind.FROZEN_PROJECT_ADMIN : ContextMenuKind.FROZEN_PROJECT;
+                }
+
+                return isAdminUser && !readonly
+                    ? resource && resource.groupClass !== GroupClass.FILTER
+                        ? ContextMenuKind.PROJECT_ADMIN
+                        : ContextMenuKind.FILTER_GROUP_ADMIN
+                    : isEditable
+                    ? resource && resource.groupClass !== GroupClass.FILTER
+                        ? ContextMenuKind.PROJECT
+                        : ContextMenuKind.FILTER_GROUP
+                    : ContextMenuKind.READONLY_PROJECT;
+            case ResourceKind.COLLECTION:
+                const c = getResource<CollectionResource>(uuid)(getState().resources);
+                if (c === undefined) {
+                    return;
+                }
+                const isOldVersion = c.uuid !== c.currentVersionUuid;
+                const isTrashed = c.isTrashed;
+                return isOldVersion
+                    ? ContextMenuKind.OLD_VERSION_COLLECTION
+                    : isTrashed && isEditable
+                    ? ContextMenuKind.TRASHED_COLLECTION
+                    : isAdminUser && isEditable
+                    ? ContextMenuKind.COLLECTION_ADMIN
+                    : isEditable
+                    ? ContextMenuKind.COLLECTION
+                    : ContextMenuKind.READONLY_COLLECTION;
+            case ResourceKind.PROCESS:
+                return isAdminUser && isEditable
+                    ? resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process)
+                        ? ContextMenuKind.RUNNING_PROCESS_ADMIN
+                        : ContextMenuKind.PROCESS_ADMIN
+                    : readonly
+                    ? ContextMenuKind.READONLY_PROCESS_RESOURCE
+                    : resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process)
+                    ? ContextMenuKind.RUNNING_PROCESS_RESOURCE
+                    : ContextMenuKind.PROCESS_RESOURCE;
+            case ResourceKind.USER:
+                return ContextMenuKind.ROOT_PROJECT;
+            case ResourceKind.LINK:
+                return ContextMenuKind.LINK;
+            case ResourceKind.WORKFLOW:
+                return isEditable ? ContextMenuKind.WORKFLOW : ContextMenuKind.READONLY_WORKFLOW;
+            default:
+                return;
+        }
+    };
+
+export const openSearchResultsContextMenu =
+    (event: React.MouseEvent<HTMLElement>, uuid: string) => (dispatch: Dispatch, getState: () => RootState) => {
+        const res = getResource<Resource>(uuid)(getState().resources);
+        if (res) {
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: "",
+                    uuid: res.uuid,
+                    ownerUuid: "",
+                    kind: res.kind,
+                    menuKind: ContextMenuKind.SEARCH_RESULTS,
+                })
+            );
+        }
+    };
diff --git a/services/workbench2/src/store/context-menu/context-menu-filters.ts b/services/workbench2/src/store/context-menu/context-menu-filters.ts
new file mode 100644 (file)
index 0000000..53993fa
--- /dev/null
@@ -0,0 +1,40 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from "store/store";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { getUserAccountStatus, UserAccountStatus } from "store/users/users-actions";
+import { matchMyAccountRoute, matchUserProfileRoute } from "routes/routes";
+
+export const isAdmin = (state: RootState, resource: ContextMenuResource) => {
+  return state.auth.user!.isAdmin;
+}
+
+export const canActivateUser = (state: RootState, resource: ContextMenuResource) => {
+  const status = getUserAccountStatus(state, resource.uuid);
+  return status === UserAccountStatus.INACTIVE ||
+    status === UserAccountStatus.SETUP;
+};
+
+export const canDeactivateUser = (state: RootState, resource: ContextMenuResource) => {
+  const status = getUserAccountStatus(state, resource.uuid);
+  return status === UserAccountStatus.SETUP ||
+    status === UserAccountStatus.ACTIVE;
+};
+
+export const canSetupUser = (state: RootState, resource: ContextMenuResource) => {
+  const status = getUserAccountStatus(state, resource.uuid);
+  return status === UserAccountStatus.INACTIVE;
+};
+
+export const needsUserProfileLink = (state: RootState, resource: ContextMenuResource) => (
+  state.router.location ?
+    !(matchUserProfileRoute(state.router.location.pathname)
+      || matchMyAccountRoute(state.router.location.pathname)
+    ) : true
+);
+
+export const isOtherUser = (state: RootState, resource: ContextMenuResource) => {
+  return state.auth.user!.uuid !== resource.uuid;
+};
diff --git a/services/workbench2/src/store/context-menu/context-menu-reducer.ts b/services/workbench2/src/store/context-menu/context-menu-reducer.ts
new file mode 100644 (file)
index 0000000..03d9cc7
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { contextMenuActions, ContextMenuAction, ContextMenuResource } from "./context-menu-actions";
+
+export interface ContextMenuState {
+    open: boolean;
+    position: ContextMenuPosition;
+    resource?: ContextMenuResource;
+}
+
+export interface ContextMenuPosition {
+    x: number;
+    y: number;
+}
+
+const initialState = {
+    open: false,
+    position: { x: 0, y: 0 }
+};
+
+export const contextMenuReducer = (state: ContextMenuState = initialState, action: ContextMenuAction) =>
+    contextMenuActions.match(action, {
+        default: () => state,
+        OPEN_CONTEXT_MENU: ({ resource, position }) => ({ open: true, resource, position }),
+        CLOSE_CONTEXT_MENU: () => ({ ...state, open: false })
+    });
+
diff --git a/services/workbench2/src/store/copy-dialog/copy-dialog.ts b/services/workbench2/src/store/copy-dialog/copy-dialog.ts
new file mode 100644 (file)
index 0000000..dfae5c2
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface CopyFormDialogData {
+    name: string;
+    uuid: string;
+    ownerUuid: string;
+    fromContextMenu?: boolean;
+}
diff --git a/services/workbench2/src/store/data-explorer/data-explorer-action.ts b/services/workbench2/src/store/data-explorer/data-explorer-action.ts
new file mode 100644 (file)
index 0000000..a330b97
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { DataColumns, DataTableFetchMode } from "components/data-table/data-table";
+import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
+
+export enum DataTableRequestState {
+    IDLE,
+    PENDING,
+    NEED_REFRESH,
+}
+
+export const dataExplorerActions = unionize({
+    CLEAR: ofType<{ id: string }>(),
+    RESET_PAGINATION: ofType<{ id: string }>(),
+    REQUEST_ITEMS: ofType<{ id: string; criteriaChanged?: boolean, background?: boolean }>(),
+    REQUEST_STATE: ofType<{ id: string; criteriaChanged?: boolean }>(),
+    SET_FETCH_MODE: ofType<{ id: string; fetchMode: DataTableFetchMode }>(),
+    SET_COLUMNS: ofType<{ id: string; columns: DataColumns<any, any> }>(),
+    SET_FILTERS: ofType<{ id: string; columnName: string; filters: DataTableFilters }>(),
+    SET_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(),
+    APPEND_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(),
+    SET_PAGE: ofType<{ id: string; page: number }>(),
+    SET_ROWS_PER_PAGE: ofType<{ id: string; rowsPerPage: number }>(),
+    TOGGLE_COLUMN: ofType<{ id: string; columnName: string }>(),
+    TOGGLE_SORT: ofType<{ id: string; columnName: string }>(),
+    SET_EXPLORER_SEARCH_VALUE: ofType<{ id: string; searchValue: string }>(),
+    RESET_EXPLORER_SEARCH_VALUE: ofType<{ id: string }>(),
+    SET_REQUEST_STATE: ofType<{ id: string; requestState: DataTableRequestState }>(),
+    SET_IS_NOT_FOUND: ofType<{ id: string; isNotFound: boolean }>(),
+});
+
+export type DataExplorerAction = UnionOf<typeof dataExplorerActions>;
+
+export const bindDataExplorerActions = (id: string) => ({
+    CLEAR: () => dataExplorerActions.CLEAR({ id }),
+    RESET_PAGINATION: () => dataExplorerActions.RESET_PAGINATION({ id }),
+    REQUEST_ITEMS: (criteriaChanged?: boolean, background?: boolean) => dataExplorerActions.REQUEST_ITEMS({ id, criteriaChanged, background }),
+    SET_FETCH_MODE: (payload: { fetchMode: DataTableFetchMode }) => dataExplorerActions.SET_FETCH_MODE({ ...payload, id }),
+    SET_COLUMNS: (payload: { columns: DataColumns<any, any> }) => dataExplorerActions.SET_COLUMNS({ ...payload, id }),
+    SET_FILTERS: (payload: { columnName: string; filters: DataTableFilters }) => dataExplorerActions.SET_FILTERS({ ...payload, id }),
+    SET_ITEMS: (payload: { items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }) =>
+        dataExplorerActions.SET_ITEMS({ ...payload, id }),
+    APPEND_ITEMS: (payload: { items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }) =>
+        dataExplorerActions.APPEND_ITEMS({ ...payload, id }),
+    SET_PAGE: (payload: { page: number }) => dataExplorerActions.SET_PAGE({ ...payload, id }),
+    SET_ROWS_PER_PAGE: (payload: { rowsPerPage: number }) => dataExplorerActions.SET_ROWS_PER_PAGE({ ...payload, id }),
+    TOGGLE_COLUMN: (payload: { columnName: string }) => dataExplorerActions.TOGGLE_COLUMN({ ...payload, id }),
+    TOGGLE_SORT: (payload: { columnName: string }) => dataExplorerActions.TOGGLE_SORT({ ...payload, id }),
+    SET_EXPLORER_SEARCH_VALUE: (payload: { searchValue: string }) => dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ ...payload, id }),
+    RESET_EXPLORER_SEARCH_VALUE: () => dataExplorerActions.RESET_EXPLORER_SEARCH_VALUE({ id }),
+    SET_REQUEST_STATE: (payload: { requestState: DataTableRequestState }) => dataExplorerActions.SET_REQUEST_STATE({ ...payload, id }),
+    SET_IS_NOT_FOUND: (payload: { isNotFound: boolean }) => dataExplorerActions.SET_IS_NOT_FOUND({ ...payload, id }),
+});
+
+export type BoundDataExplorerActions = ReturnType<typeof bindDataExplorerActions>;
diff --git a/services/workbench2/src/store/data-explorer/data-explorer-middleware-service.ts b/services/workbench2/src/store/data-explorer/data-explorer-middleware-service.ts
new file mode 100644 (file)
index 0000000..6bb95a9
--- /dev/null
@@ -0,0 +1,80 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, MiddlewareAPI } from 'redux';
+import { RootState } from '../store';
+import { DataColumns } from 'components/data-table/data-table';
+import { DataExplorer, getSortColumn } from './data-explorer-reducer';
+import { ListResults } from 'services/common-service/common-service';
+import { createTree } from 'models/tree';
+import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
+import { OrderBuilder, OrderDirection } from 'services/api/order-builder';
+import { SortDirection } from 'components/data-table/data-column';
+import { Resource } from 'models/resource';
+
+export abstract class DataExplorerMiddlewareService {
+    protected readonly id: string;
+
+    protected constructor(id: string) {
+        this.id = id;
+    }
+
+    public getId() {
+        return this.id;
+    }
+
+    public getColumnFilters<T>(
+        columns: DataColumns<T, any>,
+        columnName: string
+    ): DataTableFilters {
+        return getDataExplorerColumnFilters(columns, columnName);
+    }
+
+    abstract requestItems(
+        api: MiddlewareAPI<Dispatch, RootState>,
+        criteriaChanged?: boolean,
+        background?: boolean
+    ): Promise<void>;
+}
+
+export const getDataExplorerColumnFilters = <T>(
+    columns: DataColumns<T, any>,
+    columnName: string
+): DataTableFilters => {
+    const column = columns.find((c) => c.name === columnName);
+    return column ? column.filters : createTree();
+};
+
+export const dataExplorerToListParams = (dataExplorer: DataExplorer) => ({
+    limit: dataExplorer.rowsPerPage,
+    offset: dataExplorer.page * dataExplorer.rowsPerPage,
+});
+
+export const getOrder = <T extends Resource = Resource>(dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<T>(dataExplorer);
+    const order = new OrderBuilder<T>();
+    if (sortColumn && sortColumn.sort) {
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC
+            ? OrderDirection.ASC
+            : OrderDirection.DESC;
+
+        // Use createdAt as a secondary sort column so we break ties consistently.
+        return order
+            .addOrder(sortDirection, sortColumn.sort.field)
+            .addOrder(OrderDirection.DESC, "createdAt")
+            .getOrder();
+    } else {
+        return order.getOrder();
+    }
+};
+
+export const listResultsToDataExplorerItemsMeta = <R>({
+    itemsAvailable,
+    offset,
+    limit,
+}: ListResults<R>) => ({
+    itemsAvailable,
+    page: Math.floor(offset / limit),
+    rowsPerPage: limit,
+});
diff --git a/services/workbench2/src/store/data-explorer/data-explorer-middleware.test.ts b/services/workbench2/src/store/data-explorer/data-explorer-middleware.test.ts
new file mode 100644 (file)
index 0000000..8bb10f0
--- /dev/null
@@ -0,0 +1,218 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DataExplorerMiddlewareService } from "./data-explorer-middleware-service";
+import { dataExplorerMiddleware } from "./data-explorer-middleware";
+import { MiddlewareAPI } from "redux";
+import { DataColumns } from "components/data-table/data-table";
+import { dataExplorerActions } from "./data-explorer-action";
+import { SortDirection } from "components/data-table/data-column";
+import { createTree } from 'models/tree';
+import { DataTableFilterItem } from "components/data-table-filters/data-table-filters-tree";
+
+
+describe("DataExplorerMiddleware", () => {
+
+    it("handles only actions that are identified by service id", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [{
+                name: "Column",
+                selected: true,
+                configurable: false,
+                sortDirection: SortDirection.NONE,
+                filters: createTree<DataTableFilterItem>(),
+                render: jest.fn()
+            }],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_PAGE({ id: "OtherId", page: 0 }));
+        middleware(dataExplorerActions.SET_PAGE({ id: "ServiceId", page: 0 }));
+        middleware(dataExplorerActions.SET_PAGE({ id: "OtherId", page: 0 }));
+        expect(api.dispatch).toHaveBeenCalledWith(dataExplorerActions.REQUEST_ITEMS({ id: "ServiceId", criteriaChanged: false }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles REQUEST_ITEMS action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [{
+                name: "Column",
+                selected: true,
+                configurable: false,
+                sortDirection: SortDirection.NONE,
+                filters: createTree<DataTableFilterItem>(),
+                render: jest.fn()
+            }],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.REQUEST_ITEMS({ id: "ServiceId" }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles SET_PAGE action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_PAGE({ id: service.getId(), page: 0 }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles SET_ROWS_PER_PAGE action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_ROWS_PER_PAGE({ id: service.getId(), rowsPerPage: 0 }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles SET_FILTERS action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_FILTERS({ id: service.getId(), columnName: "", filters: createTree() }));
+        expect(api.dispatch).toHaveBeenCalledTimes(2);
+    });
+
+    it("handles SET_ROWS_PER_PAGE action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_ROWS_PER_PAGE({ id: service.getId(), rowsPerPage: 0 }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles TOGGLE_SORT action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.TOGGLE_SORT({ id: service.getId(), columnName: "" }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles SET_SEARCH_VALUE action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ id: service.getId(), searchValue: "" }));
+        expect(api.dispatch).toHaveBeenCalledTimes(2);
+    });
+
+    it("forwards other actions", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_COLUMNS({ id: service.getId(), columns: [] }));
+        middleware(dataExplorerActions.SET_ITEMS({ id: service.getId(), items: [], rowsPerPage: 0, itemsAvailable: 0, page: 0 }));
+        middleware(dataExplorerActions.TOGGLE_COLUMN({ id: service.getId(), columnName: "" }));
+        expect(api.dispatch).toHaveBeenCalledTimes(0);
+        expect(next).toHaveBeenCalledTimes(3);
+    });
+
+});
+
+class ServiceMock extends DataExplorerMiddlewareService {
+    constructor(private config: {
+        id: string,
+        columns: DataColumns<any, any>,
+        requestItems: (api: MiddlewareAPI) => Promise<void>
+    }) {
+        super(config.id);
+    }
+
+    getColumns() {
+        return this.config.columns;
+    }
+
+    requestItems(api: MiddlewareAPI): Promise<void> {
+        this.config.requestItems(api);
+        return Promise.resolve();
+    }
+}
diff --git a/services/workbench2/src/store/data-explorer/data-explorer-middleware.ts b/services/workbench2/src/store/data-explorer/data-explorer-middleware.ts
new file mode 100644 (file)
index 0000000..3404b37
--- /dev/null
@@ -0,0 +1,113 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { Middleware } from 'redux';
+import {
+    dataExplorerActions,
+    bindDataExplorerActions,
+    DataTableRequestState,
+} from './data-explorer-action';
+import { getDataExplorer } from './data-explorer-reducer';
+import { DataExplorerMiddlewareService } from './data-explorer-middleware-service';
+
+export const dataExplorerMiddleware =
+    (service: DataExplorerMiddlewareService): Middleware =>
+        (api) =>
+            (next) => {
+                const actions = bindDataExplorerActions(service.getId());
+
+                return (action) => {
+                    const handleAction =
+                        <T extends { id: string }>(handler: (data: T) => void) =>
+                            (data: T) => {
+                                next(action);
+                                if (data.id === service.getId()) {
+                                    handler(data);
+                                }
+                            };
+                    dataExplorerActions.match(action, {
+                        SET_PAGE: handleAction(() => {
+                            api.dispatch(actions.REQUEST_ITEMS(false));
+                        }),
+                        SET_ROWS_PER_PAGE: handleAction(() => {
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        SET_FILTERS: handleAction(() => {
+                            api.dispatch(actions.RESET_PAGINATION());
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        TOGGLE_SORT: handleAction(() => {
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        SET_EXPLORER_SEARCH_VALUE: handleAction(() => {
+                            api.dispatch(actions.RESET_PAGINATION());
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        REQUEST_ITEMS: handleAction(({ criteriaChanged, background }) => {
+                            api.dispatch<any>(async (
+                                dispatch: Dispatch,
+                                getState: () => RootState,
+                                services: ServiceRepository
+                            ) => {
+                                while (true) {
+                                    let de = getDataExplorer(
+                                        getState().dataExplorer,
+                                        service.getId()
+                                    );
+                                    switch (de.requestState) {
+                                        case DataTableRequestState.IDLE:
+                                            // Start a new request.
+                                            try {
+                                                dispatch(
+                                                    actions.SET_REQUEST_STATE({
+                                                        requestState: DataTableRequestState.PENDING,
+                                                    })
+                                                );
+                                                await service.requestItems(api, criteriaChanged, background);
+                                            } catch {
+                                                dispatch(
+                                                    actions.SET_REQUEST_STATE({
+                                                        requestState: DataTableRequestState.NEED_REFRESH,
+                                                    })
+                                                );
+                                            }
+                                            // Now check if the state is still PENDING, if it moved to NEED_REFRESH
+                                            // then we need to reissue requestItems
+                                            de = getDataExplorer(
+                                                getState().dataExplorer,
+                                                service.getId()
+                                            );
+                                            const complete =
+                                                de.requestState === DataTableRequestState.PENDING;
+                                            dispatch(
+                                                actions.SET_REQUEST_STATE({
+                                                    requestState: DataTableRequestState.IDLE,
+                                                })
+                                            );
+                                            if (complete) {
+                                                return;
+                                            }
+                                            break;
+                                        case DataTableRequestState.PENDING:
+                                            // State is PENDING, move it to NEED_REFRESH so that when the current request finishes it starts a new one.
+                                            dispatch(
+                                                actions.SET_REQUEST_STATE({
+                                                    requestState: DataTableRequestState.NEED_REFRESH,
+                                                })
+                                            );
+                                            return;
+                                        case DataTableRequestState.NEED_REFRESH:
+                                            // Nothing to do right now.
+                                            return;
+                                    }
+                                }
+                            });
+                        }),
+                        default: () => next(action),
+                    });
+                };
+            };
diff --git a/services/workbench2/src/store/data-explorer/data-explorer-reducer.test.tsx b/services/workbench2/src/store/data-explorer/data-explorer-reducer.test.tsx
new file mode 100644 (file)
index 0000000..01aa729
--- /dev/null
@@ -0,0 +1,90 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dataExplorerReducer, initialDataExplorer } from "./data-explorer-reducer";
+import { dataExplorerActions } from "./data-explorer-action";
+import { DataTableFilterItem } from "../../components/data-table-filters/data-table-filters";
+import { DataColumns } from "../../components/data-table/data-table";
+import { SortDirection } from "../../components/data-table/data-column";
+
+describe('data-explorer-reducer', () => {
+    it('should set columns', () => {
+        const columns: DataColumns<any, any> = [{
+            name: "Column 1",
+            filters: [],
+            render: jest.fn(),
+            selected: true,
+            configurable: true,
+            sort: {direction: SortDirection.NONE, field: "name"}
+        }];
+        const state = dataExplorerReducer(undefined,
+            dataExplorerActions.SET_COLUMNS({ id: "Data explorer", columns }));
+        expect(state["Data explorer"].columns).toEqual(columns);
+    });
+
+    it('should toggle sorting', () => {
+        const columns: DataColumns<any, any> = [{
+            name: "Column 1",
+            filters: [],
+            render: jest.fn(),
+            selected: true,
+            sort: {direction: SortDirection.ASC, field: "name"},
+            configurable: true
+        }, {
+            name: "Column 2",
+            filters: [],
+            render: jest.fn(),
+            selected: true,
+            configurable: true,
+            sort: {direction: SortDirection.NONE, field: "name"},
+        }];
+        const state = dataExplorerReducer({ "Data explorer": { ...initialDataExplorer, columns } },
+            dataExplorerActions.TOGGLE_SORT({ id: "Data explorer", columnName: "Column 2" }));
+        expect(state["Data explorer"].columns[0].sort.direction).toEqual("none");
+        expect(state["Data explorer"].columns[1].sort.direction).toEqual("asc");
+    });
+
+    it('should set filters', () => {
+        const columns: DataColumns<any, any> = [{
+            name: "Column 1",
+            filters: [],
+            render: jest.fn(),
+            selected: true,
+            configurable: true,
+            sort: {direction: SortDirection.NONE, field: "name"}
+        }];
+
+        const filters: DataTableFilterItem[] = [{
+            name: "Filter 1",
+            selected: true
+        }];
+        const state = dataExplorerReducer({ "Data explorer": { ...initialDataExplorer, columns } },
+            dataExplorerActions.SET_FILTERS({ id: "Data explorer", columnName: "Column 1", filters }));
+        expect(state["Data explorer"].columns[0].filters).toEqual(filters);
+    });
+
+    it('should set items', () => {
+        const state = dataExplorerReducer({},
+            dataExplorerActions.SET_ITEMS({
+                id: "Data explorer",
+                items: ["Item 1", "Item 2"],
+                page: 0,
+                rowsPerPage: 10,
+                itemsAvailable: 100
+            }));
+        expect(state["Data explorer"].items).toEqual(["Item 1", "Item 2"]);
+    });
+
+    it('should set page', () => {
+        const state = dataExplorerReducer({},
+            dataExplorerActions.SET_PAGE({ id: "Data explorer", page: 2 }));
+        expect(state["Data explorer"].page).toEqual(2);
+    });
+
+    it('should set rows per page', () => {
+        const state = dataExplorerReducer({},
+            dataExplorerActions.SET_ROWS_PER_PAGE({ id: "Data explorer", rowsPerPage: 5 }));
+        expect(state["Data explorer"].rowsPerPage).toEqual(5);
+    });
+});
diff --git a/services/workbench2/src/store/data-explorer/data-explorer-reducer.ts b/services/workbench2/src/store/data-explorer/data-explorer-reducer.ts
new file mode 100644 (file)
index 0000000..2bc8caa
--- /dev/null
@@ -0,0 +1,191 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import {
+    DataColumn,
+    resetSortDirection,
+    SortDirection,
+    toggleSortDirection,
+} from 'components/data-table/data-column';
+import {
+    DataExplorerAction,
+    dataExplorerActions,
+    DataTableRequestState,
+} from './data-explorer-action';
+import {
+    DataColumns,
+    DataTableFetchMode,
+} from 'components/data-table/data-table';
+import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
+
+export interface DataExplorer {
+    fetchMode: DataTableFetchMode;
+    columns: DataColumns<any, any>;
+    items: any[];
+    itemsAvailable: number;
+    page: number;
+    rowsPerPage: number;
+    rowsPerPageOptions: number[];
+    searchValue: string;
+    working?: boolean;
+    requestState: DataTableRequestState;
+    isNotFound: boolean;
+}
+
+export const initialDataExplorer: DataExplorer = {
+    fetchMode: DataTableFetchMode.PAGINATED,
+    columns: [],
+    items: [],
+    itemsAvailable: 0,
+    page: 0,
+    rowsPerPage: 50,
+    rowsPerPageOptions: [10, 20, 50, 100, 200, 500],
+    searchValue: '',
+    requestState: DataTableRequestState.IDLE,
+    isNotFound: false,
+};
+
+export type DataExplorerState = Record<string, DataExplorer>;
+
+export const dataExplorerReducer = (
+    state: DataExplorerState = {},
+    action: DataExplorerAction
+) => {
+    return dataExplorerActions.match(action, {
+        CLEAR: ({ id }) =>
+            update(state, id, (explorer) => ({
+                ...explorer,
+                page: 0,
+                itemsAvailable: 0,
+                items: [],
+            })),
+
+        RESET_PAGINATION: ({ id }) =>
+            update(state, id, (explorer) => ({ ...explorer, page: 0 })),
+
+        SET_FETCH_MODE: ({ id, fetchMode }) =>
+            update(state, id, (explorer) => ({ ...explorer, fetchMode })),
+
+        SET_COLUMNS: ({ id, columns }) => update(state, id, setColumns(columns)),
+
+        SET_FILTERS: ({ id, columnName, filters }) =>
+            update(state, id, mapColumns(setFilters(columnName, filters))),
+
+        SET_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) => (
+            update(state, id, (explorer) => {
+                // Reject updates to pages other than current,
+                //  DataExplorer middleware should retry
+                const updatedPage = page || 0;
+                if (explorer.page === updatedPage) {
+                    return {
+                        ...explorer,
+                        items,
+                        itemsAvailable,
+                        page: updatedPage,
+                        rowsPerPage,
+                    }
+                } else {
+                    return explorer;
+                }
+            })
+        ),
+
+        APPEND_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) =>
+            update(state, id, (explorer) => ({
+                ...explorer,
+                items: state[id].items.concat(items),
+                itemsAvailable: state[id].itemsAvailable + itemsAvailable,
+                page,
+                rowsPerPage,
+            })),
+
+        SET_PAGE: ({ id, page }) =>
+            update(state, id, (explorer) => ({ ...explorer, page })),
+
+        SET_ROWS_PER_PAGE: ({ id, rowsPerPage }) =>
+            update(state, id, (explorer) => ({ ...explorer, rowsPerPage })),
+
+        SET_EXPLORER_SEARCH_VALUE: ({ id, searchValue }) =>
+            update(state, id, (explorer) => ({ ...explorer, searchValue })),
+
+        RESET_EXPLORER_SEARCH_VALUE: ({ id }) =>
+            update(state, id, (explorer) => ({ ...explorer, searchValue: '' })),
+
+        SET_REQUEST_STATE: ({ id, requestState }) =>
+            update(state, id, (explorer) => ({ ...explorer, requestState })),
+
+        TOGGLE_SORT: ({ id, columnName }) =>
+            update(state, id, mapColumns(toggleSort(columnName))),
+
+        TOGGLE_COLUMN: ({ id, columnName }) =>
+            update(state, id, mapColumns(toggleColumn(columnName))),
+
+        SET_IS_NOT_FOUND: ({ id, isNotFound }) =>
+            update(state, id, (explorer) => ({ ...explorer, isNotFound })),
+
+        default: () => state,
+    });
+};
+export const getDataExplorer = (state: DataExplorerState, id: string) => {
+    const returnValue = state[id] || initialDataExplorer;
+    return returnValue;
+};
+
+export const getSortColumn = <R>(dataExplorer: DataExplorer): DataColumn<any, R> | undefined =>
+    dataExplorer.columns.find(
+        (c: DataColumn<any, R>) => !!c.sort && c.sort.direction !== SortDirection.NONE
+    );
+
+const update = (
+    state: DataExplorerState,
+    id: string,
+    updateFn: (dataExplorer: DataExplorer) => DataExplorer
+) => ({ ...state, [id]: updateFn(getDataExplorer(state, id)) });
+
+const canUpdateColumns = (
+    prevColumns: DataColumns<any, any>,
+    nextColumns: DataColumns<any, any>
+) => {
+    if (prevColumns.length !== nextColumns.length) {
+        return true;
+    }
+    for (let i = 0; i < nextColumns.length; i++) {
+        const pc = prevColumns[i];
+        const nc = nextColumns[i];
+        if (pc.key !== nc.key || pc.name !== nc.name) {
+            return true;
+        }
+    }
+    return false;
+};
+
+const setColumns =
+    (columns: DataColumns<any, any>) => (dataExplorer: DataExplorer) => ({
+        ...dataExplorer,
+        columns: canUpdateColumns(dataExplorer.columns, columns)
+            ? columns
+            : dataExplorer.columns,
+    });
+
+const mapColumns =
+    (mapFn: (column: DataColumn<any, any>) => DataColumn<any, any>) =>
+        (dataExplorer: DataExplorer) => ({
+            ...dataExplorer,
+            columns: dataExplorer.columns.map(mapFn),
+        });
+
+const toggleSort = (columnName: string) => (column: DataColumn<any, any>) =>
+    column.name === columnName
+        ? toggleSortDirection(column)
+        : resetSortDirection(column);
+
+const toggleColumn = (columnName: string) => (column: DataColumn<any, any>) =>
+    column.name === columnName
+        ? { ...column, selected: !column.selected }
+        : column;
+
+const setFilters =
+    (columnName: string, filters: DataTableFilters) =>
+        (column: DataColumn<any, any>) =>
+            column.name === columnName ? { ...column, filters } : column;
diff --git a/services/workbench2/src/store/details-panel/details-panel-action.ts b/services/workbench2/src/store/details-panel/details-panel-action.ts
new file mode 100644 (file)
index 0000000..e14c70a
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from 'common/unionize';
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import { getResource } from 'store/resources/resources';
+import { ServiceRepository } from 'services/services';
+import { resourcesActions } from 'store/resources/resources-actions';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { OrderBuilder } from 'services/api/order-builder';
+import { CollectionResource } from 'models/collection';
+import { extractUuidKind, ResourceKind } from 'models/resource';
+
+export const SLIDE_TIMEOUT = 500;
+
+export const detailsPanelActions = unionize({
+    TOGGLE_DETAILS_PANEL: ofType<{}>(),
+    OPEN_DETAILS_PANEL: ofType<number>(),
+    LOAD_DETAILS_PANEL: ofType<string>(),
+    START_TRANSITION: ofType<{}>(),
+    END_TRANSITION: ofType<{}>(),
+});
+
+export type DetailsPanelAction = UnionOf<typeof detailsPanelActions>;
+
+export const loadDetailsPanel = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        if (getState().detailsPanel.isOpened) {
+            switch(extractUuidKind(uuid)) {
+                case ResourceKind.COLLECTION:
+                    const c = getResource<CollectionResource>(uuid)(getState().resources);
+                    dispatch<any>(refreshCollectionVersionsList(c!.currentVersionUuid));
+                    break;
+                default:
+                    break;
+            }
+        }
+        dispatch(detailsPanelActions.LOAD_DETAILS_PANEL(uuid));
+    };
+
+export const openDetailsPanel = (uuid?: string, tabNr: number = 0) =>
+    (dispatch: Dispatch) => {
+        startDetailsPanelTransition(dispatch)
+        dispatch(detailsPanelActions.OPEN_DETAILS_PANEL(tabNr));
+        if (uuid !== undefined) {
+            dispatch<any>(loadDetailsPanel(uuid));
+        }
+    };
+
+export const refreshCollectionVersionsList = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        services.collectionService.list({
+            filters: new FilterBuilder()
+                .addEqual('current_version_uuid', uuid)
+                .getFilters(),
+            includeOldVersions: true,
+            order: new OrderBuilder<CollectionResource>().addDesc("version").getOrder()
+        }).then(versions => dispatch(resourcesActions.SET_RESOURCES(versions.items))
+        ).catch(e => snackbarActions.OPEN_SNACKBAR({
+            message: `Couldn't retrieve versions: ${e.errors[0]}`,
+            hideDuration: 2000,
+            kind: SnackbarKind.ERROR })
+        );
+    };
+
+export const toggleDetailsPanel = () => (dispatch: Dispatch, getState: () => RootState) => {
+    // because of material-ui issue resizing details panel breaks tabs.
+    // triggering window resize event fixes that.
+    setTimeout(() => {
+        window.dispatchEvent(new Event('resize'));
+    }, SLIDE_TIMEOUT);
+    startDetailsPanelTransition(dispatch)
+    dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+    if (getState().detailsPanel.isOpened) {
+        dispatch<any>(loadDetailsPanel(getState().detailsPanel.resourceUuid));
+    }
+};
+
+const startDetailsPanelTransition = (dispatch) => {
+        dispatch(detailsPanelActions.START_TRANSITION())
+    setTimeout(() => {
+        dispatch(detailsPanelActions.END_TRANSITION())
+    }, SLIDE_TIMEOUT);
+}
\ No newline at end of file
diff --git a/services/workbench2/src/store/details-panel/details-panel-reducer.ts b/services/workbench2/src/store/details-panel/details-panel-reducer.ts
new file mode 100644 (file)
index 0000000..8a0e1d5
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { detailsPanelActions, DetailsPanelAction } from "./details-panel-action";
+
+export interface DetailsPanelState {
+    resourceUuid: string;
+    isOpened: boolean;
+    tabNr: number;
+    isTransitioning: boolean;
+}
+
+const initialState = {
+    resourceUuid: '',
+    isOpened: false,
+    tabNr: 0,
+    isTransitioning: false
+};
+
+export const detailsPanelReducer = (state: DetailsPanelState = initialState, action: DetailsPanelAction) =>
+    detailsPanelActions.match(action, {
+        default: () => state,
+        LOAD_DETAILS_PANEL: resourceUuid => ({ ...state, resourceUuid }),
+        OPEN_DETAILS_PANEL: tabNr => ({ ...state, isOpened: true, tabNr }),
+        TOGGLE_DETAILS_PANEL: () => ({ ...state, isOpened: !state.isOpened }),
+        START_TRANSITION: () => ({...state, isTransitioning: true}),
+        END_TRANSITION: () => ({...state, isTransitioning: false})
+    });
diff --git a/services/workbench2/src/store/dialog/dialog-actions.ts b/services/workbench2/src/store/dialog/dialog-actions.ts
new file mode 100644 (file)
index 0000000..d1c67c7
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+
+export const dialogActions = unionize({
+    OPEN_DIALOG: ofType<{ id: string, data: any }>(),
+    CLOSE_DIALOG: ofType<{ id: string }>(),
+    CLOSE_ALL_DIALOGS: ofType<{}>()
+});
+
+export type DialogAction = UnionOf<typeof dialogActions>;
diff --git a/services/workbench2/src/store/dialog/dialog-reducer.test.ts b/services/workbench2/src/store/dialog/dialog-reducer.test.ts
new file mode 100644 (file)
index 0000000..ec39395
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogReducer } from "./dialog-reducer";
+import { dialogActions } from "./dialog-actions";
+
+describe('DialogReducer', () => {
+    it('OPEN_DIALOG', () => {
+        const id = 'test id';
+        const data = 'test data';
+        const state = dialogReducer({}, dialogActions.OPEN_DIALOG({ id, data }));
+        expect(state[id]).toEqual({ open: true, data });
+    });
+
+    it('CLOSE_DIALOG', () => {
+        const id = 'test id';
+        const state = dialogReducer({}, dialogActions.CLOSE_DIALOG({ id }));
+        expect(state[id]).toEqual({ open: false, data: {} });
+    });
+    
+    it('CLOSE_DIALOG persist data', () => {
+        const id = 'test id';
+        const [newState] = [{}]
+            .map(state => dialogReducer(state, dialogActions.OPEN_DIALOG({ id, data: 'test data' })))
+            .map(state => dialogReducer(state, dialogActions.CLOSE_DIALOG({ id })));
+        
+        expect(newState[id]).toEqual({ open: false, data: 'test data' });
+    });
+});
diff --git a/services/workbench2/src/store/dialog/dialog-reducer.ts b/services/workbench2/src/store/dialog/dialog-reducer.ts
new file mode 100644 (file)
index 0000000..548d0a7
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DialogAction, dialogActions } from './dialog-actions';
+
+export type DialogState = Record<string, Dialog<any>>;
+
+export interface Dialog<T> {
+    open: boolean;
+    data: T;
+}
+
+export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
+    dialogActions.match(action, {
+        OPEN_DIALOG: ({ id, data }) => ({ ...state, [id]: { open: true, data } }),
+        CLOSE_DIALOG: ({ id }) => ({
+            ...state,
+            [id]: state[id] ? { ...state[id], open: false } : { open: false, data: {} },
+        }),
+        CLOSE_ALL_DIALOGS: () => ({}),
+        default: () => state,
+    });
+
+export const getDialog = <T>(state: DialogState, id: string) => (state[id] ? (state[id] as Dialog<T>) : undefined);
diff --git a/services/workbench2/src/store/dialog/with-dialog.ts b/services/workbench2/src/store/dialog/with-dialog.ts
new file mode 100644 (file)
index 0000000..7a25386
--- /dev/null
@@ -0,0 +1,43 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { DialogState } from './dialog-reducer';
+import { Dispatch } from 'redux';
+import { dialogActions } from './dialog-actions';
+
+export type WithDialogStateProps<T> = {
+    open: boolean;
+    data: T;
+};
+
+export type WithDialogDispatchProps = {
+    closeDialog: () => void;
+};
+
+export type WithDialogProps<T> = WithDialogStateProps<T> & WithDialogDispatchProps;
+export const withDialog =
+    (id: string) =>
+    // TODO: How to make compiler happy with & P instead of & any?
+    // eslint-disable-next-line
+    <T, P>(component: React.ComponentType<WithDialogProps<T> & any>) =>
+        connect(mapStateToProps(id), mapDispatchToProps(id))(component);
+
+const emptyData = {};
+
+export const mapStateToProps =
+    (id: string) =>
+    <T>(state: { dialog: DialogState }): WithDialogStateProps<T> => {
+        const dialog = state.dialog[id];
+        return dialog ? dialog : { open: false, data: emptyData };
+    };
+
+export const mapDispatchToProps =
+    (id: string) =>
+    (dispatch: Dispatch): WithDialogDispatchProps => ({
+        closeDialog: () => {
+            dispatch(dialogActions.CLOSE_DIALOG({ id }));
+        },
+    });
diff --git a/services/workbench2/src/store/favorite-panel/favorite-panel-action.ts b/services/workbench2/src/store/favorite-panel/favorite-panel-action.ts
new file mode 100644 (file)
index 0000000..85ede86
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
+
+export const FAVORITE_PANEL_ID = "favoritePanel";
+export const favoritePanelActions = bindDataExplorerActions(FAVORITE_PANEL_ID);
+
+export const loadFavoritePanel = () => (dispatch: Dispatch) => {
+    dispatch(favoritePanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(favoritePanelActions.REQUEST_ITEMS());
+};
\ No newline at end of file
diff --git a/services/workbench2/src/store/favorite-panel/favorite-panel-middleware-service.ts b/services/workbench2/src/store/favorite-panel/favorite-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..a15690b
--- /dev/null
@@ -0,0 +1,114 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DataExplorerMiddlewareService } from "store/data-explorer/data-explorer-middleware-service";
+import { FavoritePanelColumnNames } from "views/favorite-panel/favorite-panel";
+import { RootState } from "../store";
+import { getUserUuid } from "common/getuser";
+import { DataColumns } from "components/data-table/data-table";
+import { ServiceRepository } from "services/services";
+import { FilterBuilder } from "services/api/filter-builder";
+import { updateFavorites } from "../favorites/favorites-actions";
+import { favoritePanelActions } from "./favorite-panel-action";
+import { Dispatch, MiddlewareAPI } from "redux";
+import { resourcesActions } from "store/resources/resources-actions";
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+import { getDataExplorer } from "store/data-explorer/data-explorer-reducer";
+import { loadMissingProcessesInformation } from "store/project-panel/project-panel-middleware-service";
+import { getDataExplorerColumnFilters } from 'store/data-explorer/data-explorer-middleware-service';
+import { serializeSimpleObjectTypeFilters } from '../resource-type-filters/resource-type-filters';
+import { ResourceKind } from "models/resource";
+import { LinkClass } from "models/link";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+
+export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
+        if (!dataExplorer) {
+            api.dispatch(favoritesPanelDataExplorerIsNotSet());
+        } else {
+            const columns = dataExplorer.columns as DataColumns<string, GroupContentsResource>;
+            const typeFilters = serializeSimpleObjectTypeFilters(getDataExplorerColumnFilters(columns, FavoritePanelColumnNames.TYPE));
+
+            try {
+                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                const responseLinks = await this.services.linkService.list({
+                    filters: new FilterBuilder()
+                        .addEqual("link_class", LinkClass.STAR)
+                        .addEqual('tail_uuid', getUserUuid(api.getState()))
+                        .addEqual('tail_kind', ResourceKind.USER)
+                        .getFilters()
+                }).then(results => results);
+                const uuids = responseLinks.items.map(it => it.headUuid);
+                const groupItems: any = await this.services.groupsService.list({
+                    filters: new FilterBuilder()
+                        .addIn("uuid", uuids)
+                        .addILike("name", dataExplorer.searchValue)
+                        .addIsA("uuid", typeFilters)
+                        .getFilters()
+                });
+                const collectionItems: any = await this.services.collectionService.list({
+                    filters: new FilterBuilder()
+                        .addIn("uuid", uuids)
+                        .addILike("name", dataExplorer.searchValue)
+                        .addIsA("uuid", typeFilters)
+                        .getFilters()
+                });
+                const processItems: any = await this.services.containerRequestService.list({
+                    filters: new FilterBuilder()
+                        .addIn("uuid", uuids)
+                        .addILike("name", dataExplorer.searchValue)
+                        .addIsA("uuid", typeFilters)
+                        .getFilters()
+                });
+                const response = groupItems;
+                collectionItems.items.forEach((it: any) => {
+                    response.itemsAvailable++;
+                    response.items.push(it);
+                });
+                processItems.items.forEach((it: any) => {
+                    response.itemsAvailable++;
+                    response.items.push(it);
+                });
+
+                api.dispatch(resourcesActions.SET_RESOURCES(response.items));
+                await api.dispatch<any>(loadMissingProcessesInformation(response.items));
+                api.dispatch(favoritePanelActions.SET_ITEMS({
+                    items: response.items.map((resource: any) => resource.uuid),
+                    itemsAvailable: response.itemsAvailable,
+                    page: Math.floor(response.offset / response.limit),
+                    rowsPerPage: response.limit
+                }));
+                api.dispatch<any>(updateFavorites(response.items.map((item: any) => item.uuid)));
+                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+            } catch (e) {
+                api.dispatch(favoritePanelActions.SET_ITEMS({
+                    items: [],
+                    itemsAvailable: 0,
+                    page: 0,
+                    rowsPerPage: dataExplorer.rowsPerPage
+                }));
+                api.dispatch(couldNotFetchFavoritesContents());
+                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+            }
+        }
+    }
+}
+
+const favoritesPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Favorites panel is not ready.',
+        kind: SnackbarKind.ERROR
+    });
+
+const couldNotFetchFavoritesContents = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch favorites contents.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/favorites/favorites-actions.ts b/services/workbench2/src/store/favorites/favorites-actions.ts
new file mode 100644 (file)
index 0000000..3f4d7a8
--- /dev/null
@@ -0,0 +1,77 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { Dispatch } from "redux";
+import { RootState } from "../store";
+import { getUserUuid } from "common/getuser";
+import { checkFavorite } from "./favorites-reducer";
+import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
+import { ServiceRepository } from "services/services";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set"; 
+import { addDisabledButton, removeDisabledButton } from "store/multiselect/multiselect-actions";
+import { loadFavoritesTree} from "store/side-panel-tree/side-panel-tree-actions";
+
+export const favoritesActions = unionize({
+    TOGGLE_FAVORITE: ofType<{ resourceUuid: string }>(),
+    CHECK_PRESENCE_IN_FAVORITES: ofType<string[]>(),
+    UPDATE_FAVORITES: ofType<Record<string, boolean>>()
+});
+
+export type FavoritesAction = UnionOf<typeof favoritesActions>;
+
+export const toggleFavorite = (resource: { uuid: string; name: string }) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) {
+            return Promise.reject("No user");
+        }
+        dispatch(progressIndicatorActions.START_WORKING("toggleFavorite"));
+        dispatch<any>(addDisabledButton(ContextMenuActionNames.ADD_TO_FAVORITES))
+        dispatch(favoritesActions.TOGGLE_FAVORITE({ resourceUuid: resource.uuid }));
+        const isFavorite = checkFavorite(resource.uuid, getState().favorites);
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: isFavorite
+                ? "Removing from favorites..."
+                : "Adding to favorites...",
+            kind: SnackbarKind.INFO
+        }));
+
+        const promise: any = isFavorite
+            ? services.favoriteService.delete({ userUuid, resourceUuid: resource.uuid })
+            : services.favoriteService.create({ userUuid, resource });
+
+        return promise
+            .then(() => {
+                dispatch(favoritesActions.UPDATE_FAVORITES({ [resource.uuid]: !isFavorite }));
+                dispatch(snackbarActions.CLOSE_SNACKBAR());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: isFavorite
+                        ? "Removed from favorites"
+                        : "Added to favorites",
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+                dispatch<any>(removeDisabledButton(ContextMenuActionNames.ADD_TO_FAVORITES))
+                dispatch(progressIndicatorActions.STOP_WORKING("toggleFavorite"));
+                dispatch<any>(loadFavoritesTree())
+            })
+            .catch((e: any) => {
+                dispatch(progressIndicatorActions.STOP_WORKING("toggleFavorite"));
+                throw e;
+            });
+    };
+
+export const updateFavorites = (resourceUuids: string[]) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        dispatch(favoritesActions.CHECK_PRESENCE_IN_FAVORITES(resourceUuids));
+        services.favoriteService
+            .checkPresenceInFavorites(userUuid, resourceUuids)
+            .then((results: any) => {
+                dispatch(favoritesActions.UPDATE_FAVORITES(results));
+            });
+    };
diff --git a/services/workbench2/src/store/favorites/favorites-reducer.ts b/services/workbench2/src/store/favorites/favorites-reducer.ts
new file mode 100644 (file)
index 0000000..e38ea95
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { FavoritesAction, favoritesActions } from "./favorites-actions";
+
+export type FavoritesState = Record<string, boolean>;
+
+export const favoritesReducer = (state: FavoritesState = {}, action: FavoritesAction) => 
+    favoritesActions.match(action, {
+        UPDATE_FAVORITES: favorites => ({...state, ...favorites}),
+        default: () => state
+    });
+
+export const checkFavorite = (uuid: string, state: FavoritesState) => state[uuid] === true;
\ No newline at end of file
diff --git a/services/workbench2/src/store/file-selection/file-selection-actions.ts b/services/workbench2/src/store/file-selection/file-selection-actions.ts
new file mode 100644 (file)
index 0000000..174c906
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
+
+export const FILE_SELECTION = 'fileSelection';
+
+export const openFileSelectionDialog = () =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(resetPickerProjectTree());
+        dispatch(dialogActions.OPEN_DIALOG({ id: FILE_SELECTION, data: {} }));
+    };
\ No newline at end of file
diff --git a/services/workbench2/src/store/file-uploader/file-uploader-actions.ts b/services/workbench2/src/store/file-uploader/file-uploader-actions.ts
new file mode 100644 (file)
index 0000000..a397bbd
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { Dispatch } from "redux";
+import { RootState } from 'store/store';
+
+export interface UploadFile {
+    id: number;
+    file: File;
+    prevLoaded: number;
+    loaded: number;
+    total: number;
+    startTime: number;
+    prevTime: number;
+    currentTime: number;
+}
+
+export const fileUploaderActions = unionize({
+    CLEAR_UPLOAD: ofType(),
+    SET_UPLOAD_FILES: ofType<File[]>(),
+    UPDATE_UPLOAD_FILES: ofType<File[]>(),
+    SET_UPLOAD_PROGRESS: ofType<{ fileId: number, loaded: number, total: number, currentTime: number }>(),
+    START_UPLOAD: ofType(),
+    DELETE_UPLOAD_FILE: ofType<UploadFile>(),
+    CANCEL_FILES_UPLOAD: ofType(),
+});
+
+export type FileUploaderAction = UnionOf<typeof fileUploaderActions>;
+
+export const getFileUploaderState = () => (dispatch: Dispatch, getState: () => RootState) => {
+    return getState().fileUploader;
+};
\ No newline at end of file
diff --git a/services/workbench2/src/store/file-uploader/file-uploader-reducer.ts b/services/workbench2/src/store/file-uploader/file-uploader-reducer.ts
new file mode 100644 (file)
index 0000000..4218fbe
--- /dev/null
@@ -0,0 +1,77 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { UploadFile, fileUploaderActions, FileUploaderAction } from "./file-uploader-actions";
+import { uniqBy } from 'lodash';
+
+export type UploaderState = UploadFile[];
+
+const initialState: UploaderState = [];
+
+export const fileUploaderReducer = (state: UploaderState = initialState, action: FileUploaderAction) => {
+    return fileUploaderActions.match(action, {
+        SET_UPLOAD_FILES: files => files.map((f, idx) => ({
+            id: idx,
+            file: f,
+            prevLoaded: 0,
+            loaded: 0,
+            total: 0,
+            startTime: 0,
+            prevTime: 0,
+            currentTime: 0
+        })),
+        UPDATE_UPLOAD_FILES: files => {
+            const updateFiles = files.map((f, idx) => ({
+                id: state.length + idx,
+                file: f,
+                prevLoaded: 0,
+                loaded: 0,
+                total: 0,
+                startTime: 0,
+                prevTime: 0,
+                currentTime: 0
+            }));
+            const updatedState = state.concat(updateFiles);
+            const uniqUpdatedState = uniqBy(updatedState, 'file.name');
+
+            return uniqUpdatedState;
+        },
+        DELETE_UPLOAD_FILE: file => {
+            const idToDelete: number = file.id;
+            const updatedState = state.filter(file => file.id !== idToDelete);
+
+            return updatedState;
+        },
+        CANCEL_FILES_UPLOAD: () => {
+            state.forEach((file) => {
+                let interval = setInterval(() => {
+                    const key = Object.keys((window as any).cancelTokens).find(key => key.indexOf(file.file.name) > -1);
+    
+                    if (key) {
+                        clearInterval(interval);
+                        (window as any).cancelTokens[key]();
+                        delete (window as any).cancelTokens[key];
+                    }
+                }, 100);
+            });
+
+            return [];
+        },
+        START_UPLOAD: () => {
+            const startTime = Date.now();
+            return state.map(f => ({ ...f, startTime, prevTime: startTime }));
+        },
+        SET_UPLOAD_PROGRESS: ({ fileId, loaded, total, currentTime }) =>
+            state.map(f => f.id === fileId ? {
+                ...f,
+                prevLoaded: f.loaded,
+                loaded,
+                total,
+                prevTime: f.currentTime,
+                currentTime
+            } : f),
+        CLEAR_UPLOAD: () => [],
+        default: () => state
+    });
+};
diff --git a/services/workbench2/src/store/file-viewers/file-viewers-actions.ts b/services/workbench2/src/store/file-viewers/file-viewers-actions.ts
new file mode 100644 (file)
index 0000000..f06dab8
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { ServiceRepository } from 'services/services';
+import { propertiesActions } from 'store/properties/properties-actions';
+import { FILE_VIEWERS_PROPERTY_NAME, DEFAULT_FILE_VIEWERS } from 'store/file-viewers/file-viewers-selectors';
+import { FileViewerList } from 'models/file-viewers-config';
+
+export const loadFileViewersConfig = async (dispatch: Dispatch, _: {}, { fileViewersConfig }: ServiceRepository) => {
+    
+    let config: FileViewerList;
+    try{
+        config = await fileViewersConfig.get();
+    } catch (e){
+        config = DEFAULT_FILE_VIEWERS;
+    }
+
+    dispatch(propertiesActions.SET_PROPERTY({
+        key: FILE_VIEWERS_PROPERTY_NAME,
+        value: config,
+    }));
+
+};
diff --git a/services/workbench2/src/store/file-viewers/file-viewers-selectors.ts b/services/workbench2/src/store/file-viewers/file-viewers-selectors.ts
new file mode 100644 (file)
index 0000000..49eb7f2
--- /dev/null
@@ -0,0 +1,12 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PropertiesState, getProperty } from 'store/properties/properties';
+import { FileViewerList } from 'models/file-viewers-config';
+
+export const FILE_VIEWERS_PROPERTY_NAME = 'fileViewers';
+
+export const DEFAULT_FILE_VIEWERS: FileViewerList = [];
+export const getFileViewers = (state: PropertiesState) =>
+    getProperty<FileViewerList>(FILE_VIEWERS_PROPERTY_NAME)(state) || DEFAULT_FILE_VIEWERS;
diff --git a/services/workbench2/src/store/group-details-panel/group-details-panel-actions.ts b/services/workbench2/src/store/group-details-panel/group-details-panel-actions.ts
new file mode 100644 (file)
index 0000000..2d34511
--- /dev/null
@@ -0,0 +1,144 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { Dispatch } from 'redux';
+import { propertiesActions } from 'store/properties/properties-actions';
+import { getProperty } from 'store/properties/properties';
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { deleteGroupMember } from 'store/groups-panel/groups-panel-actions';
+import { getResource } from 'store/resources/resources';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { PermissionResource, PermissionLevel } from 'models/permission';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { LinkResource } from 'models/link';
+import { deleteResources, updateResources } from 'store/resources/resources-actions';
+import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
+import { UserProfileGroupsActions } from 'store/user-profile/user-profile-actions';
+
+export const GROUP_DETAILS_MEMBERS_PANEL_ID = 'groupDetailsMembersPanel';
+export const GROUP_DETAILS_PERMISSIONS_PANEL_ID = 'groupDetailsPermissionsPanel';
+export const MEMBER_ATTRIBUTES_DIALOG = 'memberAttributesDialog';
+export const MEMBER_REMOVE_DIALOG = 'memberRemoveDialog';
+
+export const GroupMembersPanelActions = bindDataExplorerActions(GROUP_DETAILS_MEMBERS_PANEL_ID);
+export const GroupPermissionsPanelActions = bindDataExplorerActions(GROUP_DETAILS_PERMISSIONS_PANEL_ID);
+
+export const loadGroupDetailsPanel = (groupUuid: string) =>
+    (dispatch: Dispatch) => {
+        dispatch(propertiesActions.SET_PROPERTY({ key: GROUP_DETAILS_MEMBERS_PANEL_ID, value: groupUuid }));
+        dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
+        dispatch(propertiesActions.SET_PROPERTY({ key: GROUP_DETAILS_PERMISSIONS_PANEL_ID, value: groupUuid }));
+        dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
+    };
+
+export const getCurrentGroupDetailsPanelUuid = getProperty<string>(GROUP_DETAILS_MEMBERS_PANEL_ID);
+
+export const openAddGroupMembersDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const groupUuid = getCurrentGroupDetailsPanelUuid(getState().properties);
+        if (groupUuid) {
+            dispatch<any>(openSharingDialog(groupUuid, () => {
+                dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
+            }));
+        }
+    };
+
+export const editPermissionLevel = (uuid: string, level: PermissionLevel) =>
+    async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+        try {
+            const permission = await permissionService.update(uuid, {name: level});
+            dispatch(updateResources([permission]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Permission level changed.', hideDuration: 2000 }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'Failed to update permission',
+                kind: SnackbarKind.ERROR,
+            }));
+        }
+    };
+
+export const openGroupMemberAttributes = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<PermissionResource>(uuid)(resources);
+        dispatch(dialogActions.OPEN_DIALOG({ id: MEMBER_ATTRIBUTES_DIALOG, data }));
+    };
+
+export const openRemoveGroupMemberDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: MEMBER_REMOVE_DIALOG,
+            data: {
+                title: 'Remove member',
+                text: 'Are you sure you want to remove this member from this group?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeGroupMember = (uuid: string) =>
+
+    async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        await deleteGroupMember({
+            link: {
+                uuid,
+            },
+            permissionService,
+            dispatch,
+        });
+        dispatch<any>(deleteResources([uuid]));
+        dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
+        dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+    };
+
+export const setMemberIsHidden = (memberLinkUuid: string, permissionLinkUuid: string, visible: boolean) =>
+    async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+        const memberLink = getResource<LinkResource>(memberLinkUuid)(getState().resources);
+
+        if (!visible && permissionLinkUuid) {
+            // Remove read permission
+            try {
+                await permissionService.delete(permissionLinkUuid);
+                dispatch<any>(deleteResources([permissionLinkUuid]));
+                dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Removed read permission.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                }));
+            } catch (e) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Failed to remove permission',
+                    kind: SnackbarKind.ERROR,
+                }));
+            }
+        } else if (visible && memberLink) {
+            // Create read permission
+            try {
+                const permission = await permissionService.create({
+                    headUuid: memberLink.tailUuid,
+                    tailUuid: memberLink.headUuid,
+                    name: PermissionLevel.CAN_READ,
+                });
+                dispatch(updateResources([permission]));
+                dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Created read permission.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                }));
+            } catch(e) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Failed to create permission',
+                    kind: SnackbarKind.ERROR,
+                }));
+            }
+        }
+    };
diff --git a/services/workbench2/src/store/group-details-panel/group-details-panel-members-middleware-service.test.js b/services/workbench2/src/store/group-details-panel/group-details-panel-members-middleware-service.test.js
new file mode 100644 (file)
index 0000000..d386ed3
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { initialDataExplorer } from '../data-explorer/data-explorer-reducer'
+import { getParams } from './group-details-panel-members-middleware-service'
+
+describe('group-details-panel-members-middleware', () => {
+    describe('getParams', () => {
+        it('should paginate', () => {
+            // given
+            const dataExplorer = initialDataExplorer;
+            let params = getParams(dataExplorer, 'uuid');
+
+            // expect
+            expect(params.offset).toBe(0);
+            expect(params.limit).toBe(50);
+
+            // when
+            dataExplorer.page = 1;
+            params = getParams(dataExplorer, 'uuid');
+
+            // expect
+            expect(params.offset).toBe(50);
+            expect(params.limit).toBe(50);
+        });
+    })
+})
diff --git a/services/workbench2/src/store/group-details-panel/group-details-panel-members-middleware-service.ts b/services/workbench2/src/store/group-details-panel/group-details-panel-members-middleware-service.ts
new file mode 100644 (file)
index 0000000..6f95ca4
--- /dev/null
@@ -0,0 +1,90 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, MiddlewareAPI } from "redux";
+import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from "store/data-explorer/data-explorer-middleware-service";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from "store/data-explorer/data-explorer-reducer";
+import { FilterBuilder } from 'services/api/filter-builder';
+import { updateResources } from 'store/resources/resources-actions';
+import { getCurrentGroupDetailsPanelUuid, GroupMembersPanelActions } from 'store/group-details-panel/group-details-panel-actions';
+import { LinkClass } from 'models/link';
+import { ResourceKind } from 'models/resource';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+
+export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddlewareService {
+
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
+        const groupUuid = getCurrentGroupDetailsPanelUuid(api.getState().properties);
+        if (!dataExplorer || !groupUuid) {
+            // Noop if data explorer refresh is triggered from another panel
+            return;
+        } else {
+            try {
+                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                const groupResource = await this.services.groupsService.get(groupUuid);
+                api.dispatch(updateResources([groupResource]));
+
+                const permissionsIn = await this.services.permissionService.list(getParams(dataExplorer, groupUuid));
+                api.dispatch(updateResources(permissionsIn.items));
+
+                api.dispatch(GroupMembersPanelActions.SET_ITEMS({
+                    ...listResultsToDataExplorerItemsMeta(permissionsIn),
+                    items: permissionsIn.items.map(item => item.uuid),
+                }));
+
+                const usersIn = await this.services.userService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissionsIn.items
+                            .filter((item) => item.tailKind === ResourceKind.USER)
+                            .map(item => item.tailUuid))
+                        .getFilters(),
+                    count: "none"
+                });
+                api.dispatch(updateResources(usersIn.items));
+
+                const projectsIn = await this.services.projectService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissionsIn.items
+                            .filter((item) => item.tailKind === ResourceKind.PROJECT)
+                            .map(item => item.tailUuid))
+                        .getFilters(),
+                    count: "none"
+                });
+                api.dispatch(updateResources(projectsIn.items));
+            } catch (e) {
+                api.dispatch(couldNotFetchGroupDetailsContents());
+            } finally {
+                api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
+            }
+        }
+    }
+}
+
+export const getParams = (dataExplorer: DataExplorer, groupUuid: string) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    filters: getFilters(groupUuid),
+});
+
+export const getFilters = (groupUuid: string) => {
+    const filters = new FilterBuilder()
+        .addEqual('head_uuid', groupUuid)
+        .addEqual('link_class', LinkClass.PERMISSION)
+        .getFilters();
+
+    return filters;
+};
+
+const couldNotFetchGroupDetailsContents = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch group members.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/group-details-panel/group-details-panel-permissions-middleware-service.test.js b/services/workbench2/src/store/group-details-panel/group-details-panel-permissions-middleware-service.test.js
new file mode 100644 (file)
index 0000000..1e5e3b1
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { initialDataExplorer } from '../data-explorer/data-explorer-reducer'
+import { getParams } from './group-details-panel-permissions-middleware-service'
+
+describe('group-details-panel-permissions-middleware', () => {
+    describe('getParams', () => {
+        it('should paginate', () => {
+            // given
+            const dataExplorer = initialDataExplorer;
+            let params = getParams(dataExplorer, 'uuid');
+
+            // expect
+            expect(params.offset).toBe(0);
+            expect(params.limit).toBe(50);
+
+            // when
+            dataExplorer.page = 1;
+            params = getParams(dataExplorer, 'uuid');
+
+            // expect
+            expect(params.offset).toBe(50);
+            expect(params.limit).toBe(50);
+        });
+    })
+})
diff --git a/services/workbench2/src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts b/services/workbench2/src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts
new file mode 100644 (file)
index 0000000..c6cb05f
--- /dev/null
@@ -0,0 +1,92 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, MiddlewareAPI } from "redux";
+import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from "store/data-explorer/data-explorer-middleware-service";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from "store/data-explorer/data-explorer-reducer";
+import { FilterBuilder } from 'services/api/filter-builder';
+import { updateResources } from 'store/resources/resources-actions';
+import { getCurrentGroupDetailsPanelUuid, GroupPermissionsPanelActions } from 'store/group-details-panel/group-details-panel-actions';
+import { LinkClass } from 'models/link';
+import { ResourceKind } from 'models/resource';
+
+export class GroupDetailsPanelPermissionsMiddlewareService extends DataExplorerMiddlewareService {
+
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
+        const groupUuid = getCurrentGroupDetailsPanelUuid(api.getState().properties);
+        if (!dataExplorer || !groupUuid) {
+            // No-op if data explorer is not set since refresh may be triggered from elsewhere
+        } else {
+            try {
+                const permissionsOut = await this.services.permissionService.list(getParams(dataExplorer, groupUuid));
+                api.dispatch(updateResources(permissionsOut.items));
+
+                api.dispatch(GroupPermissionsPanelActions.SET_ITEMS({
+                    ...listResultsToDataExplorerItemsMeta(permissionsOut),
+                    items: permissionsOut.items.map(item => item.uuid),
+                }));
+
+                const usersOut = await this.services.userService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissionsOut.items
+                            .filter((item) => item.headKind === ResourceKind.USER)
+                            .map(item => item.headUuid))
+                        .getFilters(),
+                    count: "none"
+                });
+                api.dispatch(updateResources(usersOut.items));
+
+                const collectionsOut = await this.services.collectionService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissionsOut.items
+                            .filter((item) => item.headKind === ResourceKind.COLLECTION)
+                            .map(item => item.headUuid))
+                        .getFilters(),
+                    count: "none"
+                });
+                api.dispatch(updateResources(collectionsOut.items));
+
+                const projectsOut = await this.services.projectService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissionsOut.items
+                            .filter((item) => item.headKind === ResourceKind.PROJECT)
+                            .map(item => item.headUuid))
+                        .getFilters(),
+                    count: "none"
+                });
+                api.dispatch(updateResources(projectsOut.items));
+            } catch (e) {
+                api.dispatch(couldNotFetchGroupDetailsContents());
+            }
+        }
+    }
+}
+
+export const getParams = (dataExplorer: DataExplorer, groupUuid: string) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    filters: getFilters(groupUuid),
+});
+
+export const getFilters = (groupUuid: string) => {
+    const filters = new FilterBuilder()
+        .addEqual('tail_uuid', groupUuid)
+        .addEqual('link_class', LinkClass.PERMISSION)
+        .getFilters();
+
+    return filters;
+};
+
+const couldNotFetchGroupDetailsContents = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch group permissions.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/groups-panel/groups-panel-actions.ts b/services/workbench2/src/store/groups-panel/groups-panel-actions.ts
new file mode 100644 (file)
index 0000000..203bf44
--- /dev/null
@@ -0,0 +1,217 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { reset, startSubmit, stopSubmit, FormErrors, initialize } from 'redux-form';
+import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { getResource } from 'store/resources/resources';
+import { GroupResource, GroupClass } from 'models/group';
+import { getCommonResourceServiceError, CommonResourceServiceError } from 'services/common-service/common-resource-service';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { PermissionLevel } from 'models/permission';
+import { PermissionService } from 'services/permission-service/permission-service';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { ProjectUpdateFormDialogData, PROJECT_UPDATE_FORM_NAME } from 'store/projects/project-update-actions';
+import { PROJECT_CREATE_FORM_NAME } from 'store/projects/project-create-actions';
+
+export const GROUPS_PANEL_ID = "groupsPanel";
+
+export const GROUP_ATTRIBUTES_DIALOG = 'groupAttributesDialog';
+export const GROUP_REMOVE_DIALOG = 'groupRemoveDialog';
+
+export const GroupsPanelActions = bindDataExplorerActions(GROUPS_PANEL_ID);
+
+export const loadGroupsPanel = () => (dispatch: Dispatch) => {
+    dispatch(GroupsPanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(GroupsPanelActions.REQUEST_ITEMS());
+};
+
+export const openCreateGroupDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(initialize(PROJECT_CREATE_FORM_NAME, {}));
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: PROJECT_CREATE_FORM_NAME,
+            data: {
+                sourcePanel: GroupClass.ROLE,
+            }
+        }));
+    };
+
+export const openGroupAttributes = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<GroupResource>(uuid)(resources);
+        dispatch(dialogActions.OPEN_DIALOG({ id: GROUP_ATTRIBUTES_DIALOG, data }));
+    };
+
+export const removeGroup = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        await services.groupsService.delete(uuid);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        dispatch<any>(loadGroupsPanel());
+    };
+
+export const openRemoveGroupDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: GROUP_REMOVE_DIALOG,
+            data: {
+                title: 'Remove group',
+                text: 'Are you sure you want to remove this group?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+// Group edit dialog uses project update dialog with sourcePanel set to reload the appropriate parts
+export const openGroupUpdateDialog = (resource: ProjectUpdateFormDialogData) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(initialize(PROJECT_UPDATE_FORM_NAME, resource));
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: PROJECT_UPDATE_FORM_NAME,
+            data: {
+                sourcePanel: GroupClass.ROLE,
+            }
+        }));
+    };
+
+export const updateGroup = (project: ProjectUpdateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuid = project.uuid || '';
+        dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME));
+        try {
+            const updatedGroup = await services.groupsService.update(uuid, { name: project.name, description: project.description });
+            dispatch(GroupsPanelActions.REQUEST_ITEMS());
+            dispatch(reset(PROJECT_UPDATE_FORM_NAME));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
+            return updatedGroup;
+        } catch (e) {
+            dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME));
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Group with the same name already exists.' } as FormErrors));
+            }
+            return ;
+        }
+    };
+
+export const createGroup = ({ name, users = [], description }: ProjectUpdateFormDialogData) =>
+    async (dispatch: Dispatch, _: {}, { groupsService, permissionService }: ServiceRepository) => {
+        dispatch(startSubmit(PROJECT_CREATE_FORM_NAME));
+        try {
+            const newGroup = await groupsService.create({ name, description, groupClass: GroupClass.ROLE });
+            for (const user of users) {
+                await addGroupMember({
+                    user,
+                    group: newGroup,
+                    dispatch,
+                    permissionService,
+                });
+            }
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME }));
+            dispatch(reset(PROJECT_CREATE_FORM_NAME));
+            dispatch<any>(loadGroupsPanel());
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `${newGroup.name} group has been created`,
+                kind: SnackbarKind.SUCCESS
+            }));
+            return newGroup;
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME, { name: 'Group with the same name already exists.' } as FormErrors));
+            }
+            return;
+        }
+    };
+
+interface AddGroupMemberArgs {
+    user: { uuid: string, name: string };
+    group: { uuid: string, name: string };
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+/**
+ * Group membership is determined by whether the group has can_read permission on an object.
+ * If a group G can_read an object A, then we say A is a member of G.
+ *
+ * [Permission model docs](https://doc.arvados.org/api/permission-model.html)
+ */
+export const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => {
+    await createPermission({
+        head: { ...group },
+        tail: { ...user },
+        permissionLevel: PermissionLevel.CAN_READ,
+        ...args,
+    });
+};
+
+interface CreatePermissionLinkArgs {
+    head: { uuid: string, name: string };
+    tail: { uuid: string, name: string };
+    permissionLevel: PermissionLevel;
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+const createPermission = async ({ head, tail, permissionLevel, dispatch, permissionService }: CreatePermissionLinkArgs) => {
+    try {
+        await permissionService.create({
+            tailUuid: tail.uuid,
+            headUuid: head.uuid,
+            name: permissionLevel,
+        });
+    } catch (e) {
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: `Could not add ${tail.name} -> ${head.name} relation`,
+            kind: SnackbarKind.ERROR,
+        }));
+    }
+};
+
+interface DeleteGroupMemberArgs {
+    link: { uuid: string };
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+export const deleteGroupMember = async ({ link, ...args }: DeleteGroupMemberArgs) => {
+    await deletePermission({
+        uuid: link.uuid,
+        ...args,
+    });
+};
+
+interface DeletePermissionLinkArgs {
+    uuid: string;
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+export const deletePermission = async ({ uuid, dispatch, permissionService }: DeletePermissionLinkArgs) => {
+    try {
+        const permissionsResponse = await permissionService.list({
+            filters: new FilterBuilder()
+                .addEqual('uuid', uuid)
+                .getFilters()
+        });
+        const [permission] = permissionsResponse.items;
+        if (permission) {
+            await permissionService.delete(permission.uuid);
+        } else {
+            throw new Error('Permission not found');
+        }
+    } catch (e) {
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: `Could not delete ${uuid} permission`,
+            kind: SnackbarKind.ERROR,
+        }));
+    }
+};
diff --git a/services/workbench2/src/store/groups-panel/groups-panel-middleware-service.test.ts b/services/workbench2/src/store/groups-panel/groups-panel-middleware-service.test.ts
new file mode 100644 (file)
index 0000000..42d88a9
--- /dev/null
@@ -0,0 +1,160 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import Axios, { AxiosInstance, AxiosResponse } from "axios";
+import { mockConfig } from "common/config";
+import { createBrowserHistory } from "history";
+import { GroupsPanelMiddlewareService } from "./groups-panel-middleware-service";
+import { dataExplorerMiddleware } from "store/data-explorer/data-explorer-middleware";
+import { Dispatch, MiddlewareAPI } from "redux";
+import { DataColumns } from "components/data-table/data-table";
+import { dataExplorerActions } from "store/data-explorer/data-explorer-action";
+import { SortDirection } from "components/data-table/data-column";
+import { createTree } from 'models/tree';
+import { DataTableFilterItem } from "components/data-table-filters/data-table-filters-tree";
+import { GROUPS_PANEL_ID } from "./groups-panel-actions";
+import { RootState, RootStore, configureStore } from "store/store";
+import { ServiceRepository, createServices } from "services/services";
+import { ApiActions } from "services/api/api-actions";
+import { ListResults } from "services/common-service/common-service";
+import { GroupResource } from "models/group";
+import { getResource } from "store/resources/resources";
+
+describe("GroupsPanelMiddlewareService", () => {
+    let axiosInst: AxiosInstance;
+    let store: RootStore;
+    let services: ServiceRepository;
+    const config: any = {};
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+
+    beforeEach(() => {
+        axiosInst = Axios.create({ headers: {} });
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+    });
+
+    it("requests group member counts and updates resource store", async () => {
+        // Given
+        const fakeUuid = "zzzzz-j7d0g-000000000000000";
+        axiosInst.get = jest.fn((url: string) => {
+            if (url === '/groups') {
+                return Promise.resolve(
+                    { data: {
+                        kind: "",
+                        offset: 0,
+                        limit: 100,
+                        items: [{
+                            can_manage: true,
+                            can_write: true,
+                            created_at: "2023-11-15T20:57:01.723043000Z",
+                            delete_at: null,
+                            description: null,
+                            etag: "0000000000000000000000000",
+                            frozen_by_uuid: null,
+                            group_class: "role",
+                            href: `/groups/${fakeUuid}`,
+                            is_trashed: false,
+                            kind: "arvados#group",
+                            modified_at: "2023-11-15T20:57:01.719986000Z",
+                            modified_by_client_uuid: null,
+                            modified_by_user_uuid: "zzzzz-tpzed-000000000000000",
+                            name: "Test Group",
+                            owner_uuid: "zzzzz-tpzed-000000000000000",
+                            properties: {},
+                            trash_at: null,
+                            uuid: fakeUuid,
+                            writable_by: [
+                                "zzzzz-tpzed-000000000000000",
+                            ]
+                        }],
+                        items_available: 1,
+                    }} as AxiosResponse);
+            } else if (url === '/links') {
+                return Promise.resolve(
+                    { data: {
+                        items: [],
+                        items_available: 234,
+                        kind: "arvados#linkList",
+                        limit: 0,
+                        offset: 0
+                    }} as AxiosResponse);
+            } else {
+                return Promise.resolve(
+                    { data: {}} as AxiosResponse);
+            }
+        });
+
+        // When
+        await store.dispatch(dataExplorerActions.REQUEST_ITEMS({id: GROUPS_PANEL_ID}));
+        // Wait for async fetching of group count promises to resolve
+        await new Promise(setImmediate);
+
+        // Expect
+        expect(axiosInst.get).toHaveBeenCalledTimes(2);
+        expect(axiosInst.get).toHaveBeenCalledWith('/groups', expect.anything());
+        expect(axiosInst.get).toHaveBeenCalledWith('/links', expect.anything());
+        const group = getResource<GroupResource>(fakeUuid)(store.getState().resources);
+        expect(group?.memberCount).toBe(234);
+    });
+
+    it('requests group member count and stores null on failure', async () => {
+        // Given
+        const fakeUuid = "zzzzz-j7d0g-000000000000000";
+        axiosInst.get = jest.fn((url: string) => {
+            if (url === '/groups') {
+                return Promise.resolve(
+                    { data: {
+                        kind: "",
+                        offset: 0,
+                        limit: 100,
+                        items: [{
+                            can_manage: true,
+                            can_write: true,
+                            created_at: "2023-11-15T20:57:01.723043000Z",
+                            delete_at: null,
+                            description: null,
+                            etag: "0000000000000000000000000",
+                            frozen_by_uuid: null,
+                            group_class: "role",
+                            href: `/groups/${fakeUuid}`,
+                            is_trashed: false,
+                            kind: "arvados#group",
+                            modified_at: "2023-11-15T20:57:01.719986000Z",
+                            modified_by_client_uuid: null,
+                            modified_by_user_uuid: "zzzzz-tpzed-000000000000000",
+                            name: "Test Group",
+                            owner_uuid: "zzzzz-tpzed-000000000000000",
+                            properties: {},
+                            trash_at: null,
+                            uuid: fakeUuid,
+                            writable_by: [
+                                "zzzzz-tpzed-000000000000000",
+                            ]
+                        }],
+                        items_available: 1,
+                    }} as AxiosResponse);
+            } else if (url === '/links') {
+                return Promise.reject();
+            } else {
+                return Promise.resolve({ data: {}} as AxiosResponse);
+            }
+        });
+
+        // When
+        await store.dispatch(dataExplorerActions.REQUEST_ITEMS({id: GROUPS_PANEL_ID}));
+        // Wait for async fetching of group count promises to resolve
+        await new Promise(setImmediate);
+
+        // Expect
+        expect(axiosInst.get).toHaveBeenCalledTimes(2);
+        expect(axiosInst.get).toHaveBeenCalledWith('/groups', expect.anything());
+        expect(axiosInst.get).toHaveBeenCalledWith('/links', expect.anything());
+        const group = getResource<GroupResource>(fakeUuid)(store.getState().resources);
+        expect(group?.memberCount).toBe(null);
+    });
+
+});
diff --git a/services/workbench2/src/store/groups-panel/groups-panel-middleware-service.ts b/services/workbench2/src/store/groups-panel/groups-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..fdfaf1c
--- /dev/null
@@ -0,0 +1,94 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, MiddlewareAPI } from "redux";
+import { DataExplorerMiddlewareService, listResultsToDataExplorerItemsMeta, dataExplorerToListParams } from "store/data-explorer/data-explorer-middleware-service";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { getDataExplorer, getSortColumn } from "store/data-explorer/data-explorer-reducer";
+import { GroupsPanelActions } from 'store/groups-panel/groups-panel-actions';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { updateResources } from 'store/resources/resources-actions';
+import { OrderBuilder, OrderDirection } from 'services/api/order-builder';
+import { GroupResource, GroupClass } from 'models/group';
+import { SortDirection } from 'components/data-table/data-column';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+
+export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
+        if (!dataExplorer) {
+            api.dispatch(groupsPanelDataExplorerIsNotSet());
+        } else {
+            try {
+                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                const sortColumn = getSortColumn<GroupResource>(dataExplorer);
+                const order = new OrderBuilder<GroupResource>();
+                if (sortColumn && sortColumn.sort) {
+                    const direction =
+                        sortColumn.sort.direction === SortDirection.ASC
+                            ? OrderDirection.ASC
+                            : OrderDirection.DESC;
+                    order.addOrder(direction, sortColumn.sort.field);
+                }
+                const filters = new FilterBuilder()
+                    .addEqual('group_class', GroupClass.ROLE)
+                    .addILike('name', dataExplorer.searchValue)
+                    .getFilters();
+                const groups = await this.services.groupsService
+                    .list({
+                        ...dataExplorerToListParams(dataExplorer),
+                        filters,
+                        order: order.getOrder(),
+                    });
+                api.dispatch(updateResources(groups.items));
+                api.dispatch(GroupsPanelActions.SET_ITEMS({
+                    ...listResultsToDataExplorerItemsMeta(groups),
+                    items: groups.items.map(item => item.uuid),
+                }));
+
+                // Get group member count
+                groups.items.map(group => (
+                    this.services.permissionService.list({
+                        limit: 0,
+                        filters: new FilterBuilder()
+                            .addEqual('head_uuid', group.uuid)
+                            .getFilters()
+                    }).then(members => {
+                        api.dispatch(updateResources([{
+                            ...group,
+                            memberCount: members.itemsAvailable,
+                        } as GroupResource]));
+                    }).catch(e => {
+                        // In case of error, store null to stop spinners and show failure icon
+                        api.dispatch(updateResources([{
+                            ...group,
+                            memberCount: null,
+                        } as GroupResource]));
+                    })
+                ));
+            } catch (e) {
+                api.dispatch(couldNotFetchGroupList());
+            } finally {
+                api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
+            }
+        }
+    }
+}
+
+const groupsPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Groups panel is not ready.',
+        kind: SnackbarKind.ERROR
+    });
+
+const couldNotFetchGroupList = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch groups.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/keep-services/keep-services-actions.ts b/services/workbench2/src/store/keep-services/keep-services-actions.ts
new file mode 100644 (file)
index 0000000..5c106f3
--- /dev/null
@@ -0,0 +1,71 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { RootState } from 'store/store';
+import { setBreadcrumbs } from 'store/breadcrumbs/breadcrumbs-actions';
+import { ServiceRepository } from "services/services";
+import { KeepServiceResource } from 'models/keep-services';
+import { dialogActions } from 'store/dialog/dialog-actions';
+import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
+import { navigateToRootProject } from 'store/navigation/navigation-action';
+
+export const keepServicesActions = unionize({
+    SET_KEEP_SERVICES: ofType<KeepServiceResource[]>(),
+    REMOVE_KEEP_SERVICE: ofType<string>()
+});
+
+export type KeepServicesActions = UnionOf<typeof keepServicesActions>;
+
+export const KEEP_SERVICE_REMOVE_DIALOG = 'keepServiceRemoveDialog';
+export const KEEP_SERVICE_ATTRIBUTES_DIALOG = 'keepServiceAttributesDialog';
+
+export const loadKeepServicesPanel = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const user = getState().auth.user;
+        if(user && user.isAdmin) {
+            try {
+                dispatch(setBreadcrumbs([{ label: 'Keep Services' }]));
+                const response = await services.keepService.list();
+                dispatch(keepServicesActions.SET_KEEP_SERVICES(response.items));
+            } catch (e) {
+                return;
+            }
+        } else {
+            dispatch(navigateToRootProject);
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "You don't have permissions to view this page", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const openKeepServiceAttributesDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const keepService = getState().keepServices.find(it => it.uuid === uuid);
+        dispatch(dialogActions.OPEN_DIALOG({ id: KEEP_SERVICE_ATTRIBUTES_DIALOG, data: { keepService } }));
+    };
+
+export const openKeepServiceRemoveDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: KEEP_SERVICE_REMOVE_DIALOG,
+            data: {
+                title: 'Remove keep service',
+                text: 'Are you sure you want to remove this keep service?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeKeepService = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        try {
+            await services.keepService.delete(uuid);
+            dispatch(keepServicesActions.REMOVE_KEEP_SERVICE(uuid));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Keep service has been successfully removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            return;
+        }
+    };
\ No newline at end of file
diff --git a/services/workbench2/src/store/keep-services/keep-services-reducer.ts b/services/workbench2/src/store/keep-services/keep-services-reducer.ts
new file mode 100644 (file)
index 0000000..a272e41
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { keepServicesActions, KeepServicesActions } from 'store/keep-services/keep-services-actions';
+import { KeepServiceResource } from 'models/keep-services';
+
+export type KeepSericesState = KeepServiceResource[];
+
+const initialState: KeepSericesState = [];
+
+export const keepServicesReducer = (state: KeepSericesState = initialState, action: KeepServicesActions): KeepSericesState =>
+    keepServicesActions.match(action, {
+        SET_KEEP_SERVICES: items => items,
+        REMOVE_KEEP_SERVICE: (uuid: string) => state.filter((keepService) => keepService.uuid !== uuid),
+        default: () => state
+    });
\ No newline at end of file
diff --git a/services/workbench2/src/store/link-account-panel/link-account-panel-actions.ts b/services/workbench2/src/store/link-account-panel/link-account-panel-actions.ts
new file mode 100644 (file)
index 0000000..5ca7494
--- /dev/null
@@ -0,0 +1,298 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository, createServices, setAuthorizationHeader } from "services/services";
+import { setBreadcrumbs } from "store/breadcrumbs/breadcrumbs-actions";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { LinkAccountType, AccountToLink, LinkAccountStatus } from "models/link-account";
+import { authActions, getConfig } from "store/auth/auth-action";
+import { unionize, ofType, UnionOf } from 'common/unionize';
+import { UserResource } from "models/user";
+import { GroupResource } from "models/group";
+import { LinkAccountPanelError, OriginatingUser } from "./link-account-panel-reducer";
+import { login, logout } from "store/auth/auth-action";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { WORKBENCH_LOADING_SCREEN } from 'store/workbench/workbench-actions';
+
+export const linkAccountPanelActions = unionize({
+    LINK_INIT: ofType<{
+        targetUser: UserResource | undefined
+    }>(),
+    LINK_LOAD: ofType<{
+        originatingUser: OriginatingUser | undefined,
+        targetUser: UserResource | undefined,
+        targetUserToken: string | undefined,
+        userToLink: UserResource | undefined,
+        userToLinkToken: string | undefined
+    }>(),
+    LINK_INVALID: ofType<{
+        originatingUser: OriginatingUser | undefined,
+        targetUser: UserResource | undefined,
+        userToLink: UserResource | undefined,
+        error: LinkAccountPanelError
+    }>(),
+    SET_SELECTED_CLUSTER: ofType<{
+        selectedCluster: string
+    }>(),
+    SET_IS_PROCESSING: ofType<{
+        isProcessing: boolean
+    }>(),
+    HAS_SESSION_DATA: {}
+});
+
+export type LinkAccountPanelAction = UnionOf<typeof linkAccountPanelActions>;
+
+function validateLink(userToLink: UserResource, targetUser: UserResource) {
+    if (userToLink.uuid === targetUser.uuid) {
+        return LinkAccountPanelError.SAME_USER;
+    }
+    else if (userToLink.isAdmin && !targetUser.isAdmin) {
+        return LinkAccountPanelError.NON_ADMIN;
+    }
+    else if (!targetUser.isActive) {
+        return LinkAccountPanelError.INACTIVE;
+    }
+    return LinkAccountPanelError.NONE;
+}
+
+const newServices = (dispatch: Dispatch<any>, token: string) => {
+    const config = dispatch<any>(getConfig);
+    const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+    setAuthorizationHeader(svc, token);
+    return svc;
+};
+
+export const checkForLinkStatus = () =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const status = services.linkAccountService.getLinkOpStatus();
+        if (status !== undefined) {
+            let msg: string;
+            let msgKind: SnackbarKind;
+            if (status.valueOf() === LinkAccountStatus.CANCELLED) {
+                msg = "Account link cancelled!";
+                msgKind = SnackbarKind.INFO;
+            }
+            else if (status.valueOf() === LinkAccountStatus.FAILED) {
+                msg = "Account link failed!";
+                msgKind = SnackbarKind.ERROR;
+            }
+            else if (status.valueOf() === LinkAccountStatus.SUCCESS) {
+                msg = "Account link success!";
+                msgKind = SnackbarKind.SUCCESS;
+            }
+            else {
+                msg = "Unknown Error!";
+                msgKind = SnackbarKind.ERROR;
+            }
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: msg, kind: msgKind, hideDuration: 3000 }));
+            services.linkAccountService.removeLinkOpStatus();
+        }
+    };
+
+export const switchUser = (user: UserResource, token: string) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(authActions.INIT_USER({ user, token }));
+    };
+
+export const linkFailed = () =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        // If the link fails, switch to the user account that originated the link operation
+        const linkState = getState().linkAccountPanel;
+        if (linkState.userToLink && linkState.userToLinkToken && linkState.targetUser && linkState.targetUserToken) {
+            if (linkState.originatingUser === OriginatingUser.TARGET_USER) {
+                dispatch(switchUser(linkState.targetUser, linkState.targetUserToken));
+            }
+            else if ((linkState.originatingUser === OriginatingUser.USER_TO_LINK)) {
+                dispatch(switchUser(linkState.userToLink, linkState.userToLinkToken));
+            }
+        }
+        services.linkAccountService.removeAccountToLink();
+        services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.FAILED);
+        window.location.reload();
+    };
+
+export const loadLinkAccountPanel = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            // If there are remote hosts, set the initial selected cluster by getting the first cluster that isn't the local cluster
+            if (getState().linkAccountPanel.selectedCluster === undefined) {
+                const localCluster = getState().auth.localCluster;
+                let selectedCluster = localCluster;
+                for (const key in getState().auth.remoteHosts) {
+                    if (key !== localCluster) {
+                        selectedCluster = key;
+                        break;
+                    }
+                }
+                dispatch(linkAccountPanelActions.SET_SELECTED_CLUSTER({ selectedCluster }));
+            }
+
+            // First check if an account link operation has completed
+            dispatch(checkForLinkStatus());
+
+            // Continue loading the link account panel
+            dispatch(setBreadcrumbs([{ label: 'Link account' }]));
+            const curUser = getState().auth.user;
+            const curToken = getState().auth.apiToken;
+            if (curUser && curToken) {
+
+                // If there is link account session data, then the user has logged in a second time
+                const linkAccountData = services.linkAccountService.getAccountToLink();
+                if (linkAccountData) {
+
+                    dispatch(linkAccountPanelActions.SET_IS_PROCESSING({ isProcessing: true }));
+                    const curUserResource = await services.userService.get(curUser.uuid);
+
+                    // Use the token of the user we are getting data for. This avoids any admin/non-admin permissions
+                    // issues since a user will always be able to query the api server for their own user data.
+                    const svc = newServices(dispatch, linkAccountData.token);
+                    const savedUserResource = await svc.userService.get(linkAccountData.userUuid);
+
+                    let params: any;
+                    if (linkAccountData.type === LinkAccountType.ACCESS_OTHER_ACCOUNT || linkAccountData.type === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
+                        params = {
+                            originatingUser: OriginatingUser.USER_TO_LINK,
+                            targetUser: curUserResource,
+                            targetUserToken: curToken,
+                            userToLink: savedUserResource,
+                            userToLinkToken: linkAccountData.token
+                        };
+                    }
+                    else if (linkAccountData.type === LinkAccountType.ADD_OTHER_LOGIN || linkAccountData.type === LinkAccountType.ADD_LOCAL_TO_REMOTE) {
+                        params = {
+                            originatingUser: OriginatingUser.TARGET_USER,
+                            targetUser: savedUserResource,
+                            targetUserToken: linkAccountData.token,
+                            userToLink: curUserResource,
+                            userToLinkToken: curToken
+                        };
+                    }
+                    else {
+                        throw new Error("Unknown link account type");
+                    }
+
+                    dispatch(switchUser(params.targetUser, params.targetUserToken));
+                    const error = validateLink(params.userToLink, params.targetUser);
+                    if (error === LinkAccountPanelError.NONE) {
+                        dispatch(linkAccountPanelActions.LINK_LOAD(params));
+                    }
+                    else {
+                        dispatch(linkAccountPanelActions.LINK_INVALID({
+                            originatingUser: params.originatingUser,
+                            targetUser: params.targetUser,
+                            userToLink: params.userToLink,
+                            error
+                        }));
+                        return;
+                    }
+                }
+                else {
+                    // If there is no link account session data, set the state to invoke the initial UI
+                    const curUserResource = await services.userService.get(curUser.uuid);
+                    dispatch(linkAccountPanelActions.LINK_INIT({ targetUser: curUserResource }));
+                    return;
+                }
+            }
+        }
+        catch (e) {
+            dispatch(linkFailed());
+        }
+        finally {
+            dispatch(linkAccountPanelActions.SET_IS_PROCESSING({ isProcessing: false }));
+        }
+    };
+
+export const startLinking = (t: LinkAccountType) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const accountToLink = { type: t, userUuid, token: services.authService.getApiToken() } as AccountToLink;
+        services.linkAccountService.saveAccountToLink(accountToLink);
+
+        const auth = getState().auth;
+        const isLocalUser = auth.user!.uuid.substring(0, 5) === auth.localCluster;
+        let homeCluster = auth.localCluster;
+        if (isLocalUser && t === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
+            homeCluster = getState().linkAccountPanel.selectedCluster!;
+        }
+
+        dispatch(logout());
+        dispatch(login(auth.localCluster, homeCluster, auth.loginCluster, auth.remoteHosts));
+    };
+
+export const getAccountLinkData = () =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        return services.linkAccountService.getAccountToLink();
+    };
+
+export const cancelLinking = (reload: boolean = false) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        let user: UserResource | undefined;
+        try {
+            // When linking is cancelled switch to the originating user (i.e. the user saved in session data)
+            dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+            const linkAccountData = services.linkAccountService.getAccountToLink();
+            if (linkAccountData) {
+                services.linkAccountService.removeAccountToLink();
+                const svc = newServices(dispatch, linkAccountData.token);
+                user = await svc.userService.get(linkAccountData.userUuid);
+                dispatch(switchUser(user, linkAccountData.token));
+                services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.CANCELLED);
+            }
+        }
+        finally {
+            if (reload) {
+                window.location.reload();
+            }
+            else {
+                dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+            }
+        }
+    };
+
+export const linkAccount = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const linkState = getState().linkAccountPanel;
+        if (linkState.userToLink && linkState.userToLinkToken && linkState.targetUser && linkState.targetUserToken) {
+
+            // First create a project owned by the target user
+            const projectName = `Migrated from ${linkState.userToLink.email} (${linkState.userToLink.uuid})`;
+            let newGroup: GroupResource;
+            try {
+                newGroup = await services.projectService.create({
+                    name: projectName,
+                    ensure_unique_name: true
+                });
+            }
+            catch (e) {
+                dispatch(linkFailed());
+                throw e;
+            }
+
+            try {
+                // The merge api links the user sending the request into the user
+                // specified in the request, so change the authorization header accordingly
+                const svc = newServices(dispatch, linkState.userToLinkToken);
+                await svc.linkAccountService.linkAccounts(linkState.targetUserToken, newGroup.uuid);
+                dispatch(switchUser(linkState.targetUser, linkState.targetUserToken));
+                services.linkAccountService.removeAccountToLink();
+                services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.SUCCESS);
+                window.location.reload();
+            }
+            catch (e) {
+                // If the link operation fails, delete the previously made project
+                try {
+                    const svc = newServices(dispatch, linkState.targetUserToken);
+                    await svc.projectService.delete(newGroup.uuid);
+                }
+                finally {
+                    dispatch(linkFailed());
+                }
+                throw e;
+            }
+        }
+    };
diff --git a/services/workbench2/src/store/link-account-panel/link-account-panel-reducer.test.ts b/services/workbench2/src/store/link-account-panel/link-account-panel-reducer.test.ts
new file mode 100644 (file)
index 0000000..b8f402d
--- /dev/null
@@ -0,0 +1,78 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { linkAccountPanelReducer, LinkAccountPanelError, LinkAccountPanelStatus, OriginatingUser } from "store/link-account-panel/link-account-panel-reducer";
+import { linkAccountPanelActions } from "store/link-account-panel/link-account-panel-actions";
+
+describe('link-account-panel-reducer', () => {
+    const initialState = undefined;
+
+    it('handles initial link account state', () => {
+        const targetUser = { } as any;
+        targetUser.username = "targetUser";
+
+        const state = linkAccountPanelReducer(initialState, linkAccountPanelActions.LINK_INIT({targetUser}));
+        expect(state).toEqual({
+            targetUser,
+            isProcessing: false,
+            selectedCluster: undefined,
+            targetUserToken: undefined,
+            userToLink: undefined,
+            userToLinkToken: undefined,
+            originatingUser: OriginatingUser.NONE,
+            error: LinkAccountPanelError.NONE,
+            status: LinkAccountPanelStatus.INITIAL
+        });
+    });
+
+    it('handles loaded link account state', () => {
+        const targetUser = { } as any;
+        targetUser.username = "targetUser";
+        const targetUserToken = "targettoken";
+
+        const userToLink = { } as any;
+        userToLink.username = "userToLink";
+        const userToLinkToken = "usertoken";
+
+        const originatingUser = OriginatingUser.TARGET_USER;
+
+        const state = linkAccountPanelReducer(initialState, linkAccountPanelActions.LINK_LOAD({
+            originatingUser, targetUser, targetUserToken, userToLink, userToLinkToken}));
+        expect(state).toEqual({
+            targetUser,
+            targetUserToken,
+            isProcessing: false,
+            selectedCluster: undefined,
+            userToLink,
+            userToLinkToken,
+            originatingUser: OriginatingUser.TARGET_USER,
+            error: LinkAccountPanelError.NONE,
+            status: LinkAccountPanelStatus.LINKING
+        });
+    });
+
+    it('handles loaded invalid account state', () => {
+        const targetUser = { } as any;
+        targetUser.username = "targetUser";
+
+        const userToLink = { } as any;
+        userToLink.username = "userToLink";
+
+        const originatingUser = OriginatingUser.TARGET_USER;
+        const error = LinkAccountPanelError.NON_ADMIN;
+
+        const state = linkAccountPanelReducer(initialState, linkAccountPanelActions.LINK_INVALID({targetUser, userToLink, originatingUser, error}));
+        expect(state).toEqual({
+            targetUser,
+            targetUserToken: undefined,
+            isProcessing: false,
+            selectedCluster: undefined,
+            userToLink,
+            userToLinkToken: undefined,
+            originatingUser: OriginatingUser.TARGET_USER,
+            error: LinkAccountPanelError.NON_ADMIN,
+            status: LinkAccountPanelStatus.ERROR
+        });
+    });
+});
diff --git a/services/workbench2/src/store/link-account-panel/link-account-panel-reducer.ts b/services/workbench2/src/store/link-account-panel/link-account-panel-reducer.ts
new file mode 100644 (file)
index 0000000..15d0781
--- /dev/null
@@ -0,0 +1,86 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { linkAccountPanelActions, LinkAccountPanelAction } from "store/link-account-panel/link-account-panel-actions";
+import { UserResource } from "models/user";
+
+export enum LinkAccountPanelStatus {
+    NONE,
+    INITIAL,
+    HAS_SESSION_DATA,
+    LINKING,
+    ERROR
+}
+
+export enum LinkAccountPanelError {
+    NONE,
+    INACTIVE,
+    NON_ADMIN,
+    SAME_USER
+}
+
+export enum OriginatingUser {
+    NONE,
+    TARGET_USER,
+    USER_TO_LINK
+}
+
+export interface LinkAccountPanelState {
+    selectedCluster: string | undefined;
+    originatingUser: OriginatingUser | undefined;
+    targetUser: UserResource | undefined;
+    targetUserToken: string | undefined;
+    userToLink: UserResource | undefined;
+    userToLinkToken: string | undefined;
+    status: LinkAccountPanelStatus;
+    error: LinkAccountPanelError;
+    isProcessing: boolean;
+}
+
+const initialState = {
+    selectedCluster: undefined,
+    originatingUser: OriginatingUser.NONE,
+    targetUser: undefined,
+    targetUserToken: undefined,
+    userToLink: undefined,
+    userToLinkToken: undefined,
+    isProcessing: false,
+    status: LinkAccountPanelStatus.NONE,
+    error: LinkAccountPanelError.NONE
+};
+
+export const linkAccountPanelReducer = (state: LinkAccountPanelState = initialState, action: LinkAccountPanelAction) =>
+    linkAccountPanelActions.match(action, {
+        default: () => state,
+        LINK_INIT: ({ targetUser }) => ({
+            ...state,
+            targetUser, targetUserToken: undefined,
+            userToLink: undefined, userToLinkToken: undefined,
+            status: LinkAccountPanelStatus.INITIAL, error: LinkAccountPanelError.NONE, originatingUser: OriginatingUser.NONE
+        }),
+        LINK_LOAD: ({ originatingUser, userToLink, targetUser, targetUserToken, userToLinkToken}) => ({
+            ...state,
+            originatingUser,
+            targetUser, targetUserToken,
+            userToLink, userToLinkToken,
+            status: LinkAccountPanelStatus.LINKING, error: LinkAccountPanelError.NONE
+        }),
+        LINK_INVALID: ({ originatingUser, targetUser, userToLink, error }) => ({
+            ...state,
+            originatingUser,
+            targetUser, targetUserToken: undefined,
+            userToLink, userToLinkToken: undefined,
+            error, status: LinkAccountPanelStatus.ERROR
+        }),
+        SET_SELECTED_CLUSTER: ({ selectedCluster }) => ({
+            ...state, selectedCluster
+        }),
+        SET_IS_PROCESSING: ({ isProcessing }) =>({
+            ...state,
+            isProcessing
+        }),
+        HAS_SESSION_DATA: () => ({
+            ...state, status: LinkAccountPanelStatus.HAS_SESSION_DATA
+        })
+    });
\ No newline at end of file
diff --git a/services/workbench2/src/store/link-panel/link-panel-actions.ts b/services/workbench2/src/store/link-panel/link-panel-actions.ts
new file mode 100644 (file)
index 0000000..52ac6a2
--- /dev/null
@@ -0,0 +1,57 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { setBreadcrumbs } from 'store/breadcrumbs/breadcrumbs-actions';
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { LinkResource } from 'models/link';
+import { getResource } from 'store/resources/resources';
+import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
+
+export const LINK_PANEL_ID = "linkPanelId";
+export const linkPanelActions = bindDataExplorerActions(LINK_PANEL_ID);
+
+export const LINK_REMOVE_DIALOG = 'linkRemoveDialog';
+export const LINK_ATTRIBUTES_DIALOG = 'linkAttributesDialog';
+
+export const loadLinkPanel = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([{ label: 'Links' }]));
+        dispatch(linkPanelActions.REQUEST_ITEMS());
+    };
+
+export const openLinkAttributesDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const { resources } = getState();
+        const link = getResource<LinkResource>(uuid)(resources);
+        dispatch(dialogActions.OPEN_DIALOG({ id: LINK_ATTRIBUTES_DIALOG, data: { link } }));
+    };
+
+export const openLinkRemoveDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: LINK_REMOVE_DIALOG,
+            data: {
+                title: 'Remove link',
+                text: 'Are you sure you want to remove this link?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeLink = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        try {
+            await services.linkService.delete(uuid);
+            dispatch(linkPanelActions.REQUEST_ITEMS());
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Link has been successfully removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            return;
+        }
+    };
\ No newline at end of file
diff --git a/services/workbench2/src/store/link-panel/link-panel-middleware-service.ts b/services/workbench2/src/store/link-panel/link-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..cc6ea8c
--- /dev/null
@@ -0,0 +1,53 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { DataExplorerMiddlewareService, dataExplorerToListParams, getOrder, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
+import { updateResources } from 'store/resources/resources-actions';
+import { ListResults } from 'services/common-service/common-service';
+import { LinkResource } from 'models/link';
+import { linkPanelActions } from 'store/link-panel/link-panel-actions';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+
+export class LinkMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const state = api.getState();
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+        try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+            const response = await this.services.linkService.list(getParams(dataExplorer));
+            api.dispatch(updateResources(response.items));
+            api.dispatch(setItems(response));
+        } catch {
+            api.dispatch(couldNotFetchLinks());
+        } finally {
+            api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
+        }
+    }
+}
+
+export const getParams = (dataExplorer: DataExplorer) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    order: getOrder<LinkResource>(dataExplorer)
+});
+
+export const setItems = (listResults: ListResults<LinkResource>) =>
+    linkPanelActions.SET_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
+
+const couldNotFetchLinks = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch links.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/move-to-dialog/move-to-dialog.ts b/services/workbench2/src/store/move-to-dialog/move-to-dialog.ts
new file mode 100644 (file)
index 0000000..e58f398
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface MoveToFormDialogData {
+    name: string;
+    uuid: string;
+    ownerUuid: string;
+    fromContextMenu?: boolean;
+}
diff --git a/services/workbench2/src/store/multiselect/multiselect-actions.tsx b/services/workbench2/src/store/multiselect/multiselect-actions.tsx
new file mode 100644 (file)
index 0000000..cc24ba3
--- /dev/null
@@ -0,0 +1,102 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { TCheckedList } from "components/data-table/data-table";
+import { ContainerRequestResource } from "models/container-request";
+import { Dispatch } from "redux";
+import { navigateTo } from "store/navigation/navigation-action";
+import { snackbarActions } from "store/snackbar/snackbar-actions";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { SnackbarKind } from "store/snackbar/snackbar-actions";
+import { ContextMenuResource } from 'store/context-menu/context-menu-actions';
+
+export const multiselectActionConstants = {
+    TOGGLE_VISIBLITY: "TOGGLE_VISIBLITY",
+    SET_CHECKEDLIST: "SET_CHECKEDLIST",
+    SELECT_ONE: 'SELECT_ONE',
+    DESELECT_ONE: "DESELECT_ONE",
+    DESELECT_ALL_OTHERS: 'DESELECT_ALL_OTHERS',
+    TOGGLE_ONE: 'TOGGLE_ONE',
+    SET_SELECTED_UUID: 'SET_SELECTED_UUID',
+    ADD_DISABLED: 'ADD_DISABLED',
+    REMOVE_DISABLED: 'REMOVE_DISABLED',
+};
+
+export const msNavigateToOutput = (resource: ContextMenuResource | ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        await services.collectionService.get(resource.outputUuid || '');
+        dispatch<any>(navigateTo(resource.outputUuid || ''));
+    } catch {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Output collection was trashed or deleted.", hideDuration: 4000, kind: SnackbarKind.WARNING }));
+    }
+};
+
+export const isExactlyOneSelected = (checkedList: TCheckedList) => {
+    let tally = 0;
+    let current = '';
+    for (const uuid in checkedList) {
+        if (checkedList[uuid] === true) {
+            tally++;
+            current = uuid;
+        }
+    }
+    return tally === 1 ? current : null
+};
+
+export const toggleMSToolbar = (isVisible: boolean) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionConstants.TOGGLE_VISIBLITY, payload: isVisible });
+    };
+};
+
+export const setCheckedListOnStore = (checkedList: TCheckedList) => {
+    return dispatch => {
+        dispatch(setSelectedUuid(isExactlyOneSelected(checkedList)))
+        dispatch({ type: multiselectActionConstants.SET_CHECKEDLIST, payload: checkedList });
+    };
+};
+
+export const selectOne = (uuid: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionConstants.SELECT_ONE, payload: uuid });
+    };
+};
+
+export const deselectOne = (uuid: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionConstants.DESELECT_ONE, payload: uuid });
+    };
+};
+
+export const deselectAllOthers = (uuid: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionConstants.DESELECT_ALL_OTHERS, payload: uuid });
+    };
+};
+
+export const toggleOne = (uuid: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionConstants.TOGGLE_ONE, payload: uuid });
+    };
+};
+
+export const setSelectedUuid = (uuid: string | null) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionConstants.SET_SELECTED_UUID, payload: uuid });
+    };
+};
+
+export const addDisabledButton = (buttonName: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionConstants.ADD_DISABLED, payload: buttonName });
+    };
+};
+
+export const removeDisabledButton = (buttonName: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionConstants.REMOVE_DISABLED, payload: buttonName });
+    };
+};
+
diff --git a/services/workbench2/src/store/multiselect/multiselect-reducer.tsx b/services/workbench2/src/store/multiselect/multiselect-reducer.tsx
new file mode 100644 (file)
index 0000000..b488932
--- /dev/null
@@ -0,0 +1,61 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { multiselectActionConstants } from "./multiselect-actions";
+import { TCheckedList } from "components/data-table/data-table";
+
+type MultiselectToolbarState = {
+    isVisible: boolean;
+    checkedList: TCheckedList;
+    selectedUuid: string;
+    disabledButtons: string[];
+};
+
+const multiselectToolbarInitialState = {
+    isVisible: false,
+    checkedList: {},
+    selectedUuid: '',
+    disabledButtons: []
+};
+
+const uncheckAllOthers = (inputList: TCheckedList, uuid: string) => {
+    const checkedlist = {...inputList}
+    for (const key in checkedlist) {
+        if (key !== uuid) checkedlist[key] = false;
+    }
+    return checkedlist;
+};
+
+const toggleOneCheck = (inputList: TCheckedList, uuid: string)=>{
+    const checkedlist = { ...inputList };
+    const isOnlyOneSelected = Object.values(checkedlist).filter(x => x === true).length === 1;
+    return { ...inputList, [uuid]: (checkedlist[uuid] && checkedlist[uuid] === true) && isOnlyOneSelected ? false : true };
+}
+
+const { TOGGLE_VISIBLITY, SET_CHECKEDLIST, SELECT_ONE, DESELECT_ONE, DESELECT_ALL_OTHERS, TOGGLE_ONE, SET_SELECTED_UUID, ADD_DISABLED, REMOVE_DISABLED } = multiselectActionConstants;
+
+export const multiselectReducer = (state: MultiselectToolbarState = multiselectToolbarInitialState, action) => {
+    switch (action.type) {
+        case TOGGLE_VISIBLITY:
+            return { ...state, isVisible: action.payload };
+        case SET_CHECKEDLIST:
+            return { ...state, checkedList: action.payload };
+        case SELECT_ONE:
+            return { ...state, checkedList: { ...state.checkedList, [action.payload]: true } };
+        case DESELECT_ONE:
+            return { ...state, checkedList: { ...state.checkedList, [action.payload]: false } };
+        case DESELECT_ALL_OTHERS:
+            return { ...state, checkedList: uncheckAllOthers(state.checkedList, action.payload) };
+        case TOGGLE_ONE:
+            return { ...state, checkedList: toggleOneCheck(state.checkedList, action.payload) };
+        case SET_SELECTED_UUID:
+            return {...state, selectedUuid: action.payload || ''}
+        case ADD_DISABLED:
+            return { ...state, disabledButtons: [...state.disabledButtons, action.payload]}
+        case REMOVE_DISABLED:
+            return { ...state, disabledButtons: state.disabledButtons.filter((button) => button !== action.payload) };
+        default:
+            return state;
+    }
+};
diff --git a/services/workbench2/src/store/navigation/navigation-action.ts b/services/workbench2/src/store/navigation/navigation-action.ts
new file mode 100644 (file)
index 0000000..e797dcf
--- /dev/null
@@ -0,0 +1,167 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose, AnyAction } from "redux";
+import { push } from "react-router-redux";
+import { ResourceKind, extractUuidKind } from "models/resource";
+import { SidePanelTreeCategory } from "../side-panel-tree/side-panel-tree-actions";
+import { Routes, getGroupUrl, getNavUrl, getUserProfileUrl } from "routes/routes";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { pluginConfig } from "plugins";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL, INSTANCE_TYPES_PANEL_LABEL, VIRTUAL_MACHINES_ADMIN_PANEL_LABEL, REPOSITORIES_PANEL_LABEL } from "store/breadcrumbs/breadcrumbs-actions";
+
+export const navigationNotAvailable = (id: string) =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: `${id} not available`,
+        hideDuration: 3000,
+        kind: SnackbarKind.ERROR,
+    });
+
+export const navigateTo = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState) => {
+    for (const navToFn of pluginConfig.navigateToHandlers) {
+        if (navToFn(dispatch, getState, uuid)) {
+            return;
+        }
+    }
+
+    const kind = extractUuidKind(uuid);
+    switch (kind) {
+        case ResourceKind.PROJECT:
+        case ResourceKind.USER:
+        case ResourceKind.COLLECTION:
+        case ResourceKind.CONTAINER_REQUEST:
+            dispatch<any>(pushOrGoto(getNavUrl(uuid, getState().auth)));
+            return;
+        case ResourceKind.VIRTUAL_MACHINE:
+            dispatch<any>(navigateToAdminVirtualMachines);
+            return;
+        case ResourceKind.WORKFLOW:
+            dispatch<any>(pushOrGoto(getNavUrl(uuid, getState().auth)));
+            return;
+    }
+
+    switch (uuid) {
+        case SidePanelTreeCategory.PROJECTS:
+            const usr = getState().auth.user;
+            if (usr) {
+                dispatch<any>(pushOrGoto(getNavUrl(usr.uuid, getState().auth)));
+            }
+            return;
+        case SidePanelTreeCategory.FAVORITES:
+            dispatch<any>(navigateToFavorites);
+            return;
+        case SidePanelTreeCategory.PUBLIC_FAVORITES:
+            dispatch(navigateToPublicFavorites);
+            return;
+        case SidePanelTreeCategory.SHARED_WITH_ME:
+            dispatch(navigateToSharedWithMe);
+            return;
+        case SidePanelTreeCategory.TRASH:
+            dispatch(navigateToTrash);
+            return;
+        case SidePanelTreeCategory.GROUPS:
+            dispatch(navigateToGroups);
+            return;
+        case SidePanelTreeCategory.ALL_PROCESSES:
+            dispatch(navigateToAllProcesses);
+            return;
+        case SidePanelTreeCategory.SHELL_ACCESS:
+            dispatch(navigateToUserVirtualMachines)
+            return;
+        case USERS_PANEL_LABEL:
+            dispatch(navigateToUsers);
+            return;
+        case MY_ACCOUNT_PANEL_LABEL:
+            dispatch(navigateToMyAccount);
+            return;
+        case INSTANCE_TYPES_PANEL_LABEL:
+            dispatch(navigateToInstanceTypes);
+            return;
+        case VIRTUAL_MACHINES_ADMIN_PANEL_LABEL:
+            dispatch(navigateToAdminVirtualMachines);
+            return;
+        case REPOSITORIES_PANEL_LABEL:
+            dispatch(navigateToRepositories);
+            return;
+    }
+
+    dispatch(navigationNotAvailable(uuid));
+};
+
+export const navigateToNotFound = push(Routes.NO_MATCH);
+
+export const navigateToRoot = push(Routes.ROOT);
+
+export const navigateToFavorites = push(Routes.FAVORITES);
+
+export const navigateToTrash = push(Routes.TRASH);
+
+export const navigateToPublicFavorites = push(Routes.PUBLIC_FAVORITES);
+
+export const navigateToWorkflows = push(Routes.WORKFLOWS);
+
+export const pushOrGoto = (url: string): AnyAction => {
+    if (url === "") {
+        return { type: "noop" };
+    } else if (url[0] === "/") {
+        return push(url);
+    } else {
+        window.location.href = url;
+        return { type: "noop" };
+    }
+};
+
+export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    navigateTo(SidePanelTreeCategory.PROJECTS)(dispatch, getState);
+};
+
+export const navigateToSharedWithMe = push(Routes.SHARED_WITH_ME);
+
+export const navigateToRunProcess = push(Routes.RUN_PROCESS);
+
+export const navigateToSearchResults = (searchValue: string) => {
+    if (searchValue !== "") {
+        return push({ pathname: Routes.SEARCH_RESULTS, search: "?q=" + encodeURIComponent(searchValue) });
+    } else {
+        return push({ pathname: Routes.SEARCH_RESULTS });
+    }
+};
+
+export const navigateToUserVirtualMachines = push(Routes.VIRTUAL_MACHINES_USER);
+
+export const navigateToAdminVirtualMachines = push(Routes.VIRTUAL_MACHINES_ADMIN);
+
+export const navigateToRepositories = push(Routes.REPOSITORIES);
+
+export const navigateToSshKeysAdmin = push(Routes.SSH_KEYS_ADMIN);
+
+export const navigateToSshKeysUser = push(Routes.SSH_KEYS_USER);
+
+export const navigateToInstanceTypes = push(Routes.INSTANCE_TYPES);
+
+export const navigateToSiteManager = push(Routes.SITE_MANAGER);
+
+export const navigateToMyAccount = push(Routes.MY_ACCOUNT);
+
+export const navigateToLinkAccount = push(Routes.LINK_ACCOUNT);
+
+export const navigateToKeepServices = push(Routes.KEEP_SERVICES);
+
+export const navigateToUsers = push(Routes.USERS);
+
+export const navigateToUserProfile = compose(push, getUserProfileUrl);
+
+export const navigateToApiClientAuthorizations = push(Routes.API_CLIENT_AUTHORIZATIONS);
+
+export const navigateToGroups = push(Routes.GROUPS);
+
+export const navigateToGroupDetails = compose(push, getGroupUrl);
+
+export const navigateToLinks = push(Routes.LINKS);
+
+export const navigateToCollectionsContentAddress = push(Routes.COLLECTIONS_CONTENT_ADDRESS);
+
+export const navigateToAllProcesses = push(Routes.ALL_PROCESSES);
diff --git a/services/workbench2/src/store/not-found-panel/not-found-panel-action.tsx b/services/workbench2/src/store/not-found-panel/not-found-panel-action.tsx
new file mode 100644 (file)
index 0000000..477bd93
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { dialogActions } from 'store/dialog/dialog-actions';
+
+export const NOT_FOUND_DIALOG_NAME = 'notFoundDialog';
+
+export const openNotFoundDialog = () =>
+    (dispatch: Dispatch) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: NOT_FOUND_DIALOG_NAME,
+            data: {},
+        }));
+    };
\ No newline at end of file
diff --git a/services/workbench2/src/store/open-in-new-tab/open-in-new-tab.actions.ts b/services/workbench2/src/store/open-in-new-tab/open-in-new-tab.actions.ts
new file mode 100644 (file)
index 0000000..2d1ab2e
--- /dev/null
@@ -0,0 +1,40 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import copy from "copy-to-clipboard";
+import { Dispatch } from "redux";
+import { getNavUrl } from "routes/routes";
+import { RootState } from "store/store";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+
+export const openInNewTabAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => {
+    const url = getNavUrl(resource.uuid, getState().auth);
+
+    if (url[0] === "/") {
+        window.open(`${window.location.origin}${url}`, "_blank", "noopener");
+    } else if (url.length) {
+        window.open(url, "_blank", "noopener");
+    }
+};
+
+export const copyToClipboardAction = (resources: Array<any>) => (dispatch: Dispatch, getState: () => RootState) => {
+    // Copy link to clipboard omits token to avoid accidental sharing
+
+    let url = getNavUrl(resources[0].uuid, getState().auth, false);
+    let wasCopied;
+
+    if (url[0] === "/") wasCopied = copy(`${window.location.origin}${url}`);
+    else if (url.length) {
+        wasCopied = copy(url);
+    }
+
+    if (wasCopied)
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Copied",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+};
diff --git a/services/workbench2/src/store/owner-name/owner-name-actions.ts b/services/workbench2/src/store/owner-name/owner-name-actions.ts
new file mode 100644 (file)
index 0000000..43843c8
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from 'common/unionize';
+
+export const ownerNameActions = unionize({
+    SET_OWNER_NAME: ofType<OwnerNameState>()
+});
+
+interface OwnerNameState {
+    name: string;
+    uuid: string;
+}
+
+export type OwnerNameAction = UnionOf<typeof ownerNameActions>;
diff --git a/services/workbench2/src/store/owner-name/owner-name-reducer.ts b/services/workbench2/src/store/owner-name/owner-name-reducer.ts
new file mode 100644 (file)
index 0000000..58df209
--- /dev/null
@@ -0,0 +1,11 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ownerNameActions, OwnerNameAction } from './owner-name-actions';
+
+export const ownerNameReducer = (state = [], action: OwnerNameAction) =>
+    ownerNameActions.match(action, {
+        SET_OWNER_NAME: data => [...state, { uuid: data.uuid, name: data.name }],
+        default: () => state,
+    });
\ No newline at end of file
diff --git a/services/workbench2/src/store/process-logs-panel/process-logs-panel-actions.ts b/services/workbench2/src/store/process-logs-panel/process-logs-panel-actions.ts
new file mode 100644 (file)
index 0000000..88b56a2
--- /dev/null
@@ -0,0 +1,368 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { ProcessLogs } from './process-logs-panel';
+import { LogEventType } from 'models/log';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { Dispatch } from 'redux';
+import { LogFragment, LogService, logFileToLogType } from 'services/log-service/log-service';
+import { Process, getProcess } from 'store/processes/process';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { CollectionFile, CollectionFileType } from "models/collection-file";
+import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
+
+const SNIPLINE = `================ ✀ ================ ✀ ========= Some log(s) were skipped ========= ✀ ================ ✀ ================`;
+const LOG_TIMESTAMP_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9}Z/;
+
+export const processLogsPanelActions = unionize({
+    RESET_PROCESS_LOGS_PANEL: ofType<{}>(),
+    INIT_PROCESS_LOGS_PANEL: ofType<{ filters: string[], logs: ProcessLogs }>(),
+    SET_PROCESS_LOGS_PANEL_FILTER: ofType<string>(),
+    ADD_PROCESS_LOGS_PANEL_ITEM: ofType<ProcessLogs>(),
+});
+
+// Max size of logs to fetch in bytes
+const maxLogFetchSize: number = 128 * 1000;
+
+type FileWithProgress = {
+    file: CollectionFile;
+    lastByte: number;
+}
+
+type SortableLine = {
+    logType: LogEventType,
+    timestamp: string;
+    contents: string;
+}
+
+export type ProcessLogsPanelAction = UnionOf<typeof processLogsPanelActions>;
+
+export const setProcessLogsPanelFilter = (filter: string) =>
+    processLogsPanelActions.SET_PROCESS_LOGS_PANEL_FILTER(filter);
+
+export const initProcessLogsPanel = (processUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
+        let process: Process | undefined;
+        try {
+            dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
+            process = getProcess(processUuid)(getState().resources);
+            if (process?.containerRequest?.uuid) {
+                // Get log file size info
+                const logFiles = await loadContainerLogFileList(process.containerRequest, logService);
+
+                // Populate lastbyte 0 for each file
+                const filesWithProgress = logFiles.map((file) => ({ file, lastByte: 0 }));
+
+                // Fetch array of LogFragments
+                const logLines = await loadContainerLogFileContents(filesWithProgress, logService, process);
+
+                // Populate initial state with filters
+                const initialState = createInitialLogPanelState(logFiles, logLines);
+                dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
+            }
+        } catch (e) {
+            // On error, populate empty state to allow polling to start
+            const initialState = createInitialLogPanelState([], []);
+            dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
+            // Only show toast on errors other than 404 since 404 is expected when logs do not exist yet
+            if (e.status !== 404) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Error loading process logs', hideDuration: 4000, kind: SnackbarKind.ERROR }));
+            }
+            if (e.status === 404 && process?.containerRequest.state === ContainerRequestState.FINAL) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING }));
+            }
+        }
+    };
+
+export const pollProcessLogs = (processUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
+        try {
+            // Get log panel state and process from store
+            const currentState = getState().processLogsPanel;
+            const process = getProcess(processUuid)(getState().resources);
+
+            // Check if container request is present and initial logs state loaded
+            if (process?.containerRequest?.uuid && Object.keys(currentState.logs).length > 0) {
+                const logFiles = await loadContainerLogFileList(process.containerRequest, logService);
+
+                // Determine byte to fetch from while filtering unchanged files
+                const filesToUpdateWithProgress = logFiles.reduce((acc, updatedFile) => {
+                    // Fetch last byte or 0 for new log files
+                    const currentStateLogLastByte = currentState.logs[logFileToLogType(updatedFile)]?.lastByte || 0;
+
+                    const isNew = !Object.keys(currentState.logs).find((currentStateLogName) => (updatedFile.name.startsWith(currentStateLogName)));
+                    const isChanged = !isNew && currentStateLogLastByte < updatedFile.size;
+
+                    if (isNew || isChanged) {
+                        return acc.concat({ file: updatedFile, lastByte: currentStateLogLastByte });
+                    } else {
+                        return acc;
+                    }
+                }, [] as FileWithProgress[]);
+
+                // Perform range request(s) for each file
+                const logFragments = await loadContainerLogFileContents(filesToUpdateWithProgress, logService, process);
+
+                if (logFragments.length) {
+                    // Convert LogFragments to ProcessLogs with All/Main sorting & line-merging
+                    const groupedLogs = groupLogs(logFiles, logFragments);
+                    await dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM(groupedLogs));
+                }
+            }
+            return Promise.resolve();
+        } catch (e) {
+            // Remove log when polling error is handled in some way instead of being ignored
+            console.error("Error occurred in pollProcessLogs:", e);
+            return Promise.reject();
+        }
+    };
+
+const loadContainerLogFileList = async (containerRequest: ContainerRequestResource, logService: LogService) => {
+    const logCollectionContents = await logService.listLogFiles(containerRequest);
+
+    // Filter only root directory files matching log event types which have bytes
+    return logCollectionContents.filter((file): file is CollectionFile => (
+        file.type === CollectionFileType.FILE &&
+        PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(logFileToLogType(file)) > -1 &&
+        file.size > 0
+    ));
+};
+
+/**
+ * Loads the contents of each file from each file's lastByte simultaneously
+ *   while respecting the maxLogFetchSize by requesting the start and end
+ *   of the desired block and inserting a snipline.
+ * @param logFilesWithProgress CollectionFiles with the last byte previously loaded
+ * @param logService
+ * @param process
+ * @returns LogFragment[] containing a single LogFragment corresponding to each input file
+ */
+const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgress[], logService: LogService, process: Process) => (
+    (await Promise.allSettled(logFilesWithProgress.filter(({ file }) => file.size > 0).map(({ file, lastByte }) => {
+        const requestSize = file.size - lastByte;
+        if (requestSize > maxLogFetchSize) {
+            const chunkSize = Math.floor(maxLogFetchSize / 2);
+            const firstChunkEnd = lastByte + chunkSize - 1;
+            return Promise.all([
+                logService.getLogFileContents(process.containerRequest, file, lastByte, firstChunkEnd),
+                logService.getLogFileContents(process.containerRequest, file, file.size - chunkSize, file.size - 1)
+            ] as Promise<(LogFragment)>[]);
+        } else {
+            return Promise.all([logService.getLogFileContents(process.containerRequest, file, lastByte, file.size - 1)]);
+        }
+    })).then((res) => {
+        if (res.length && res.every(promiseResult => (promiseResult.status === 'rejected'))) {
+            // Since allSettled does not pass promise rejection we throw an
+            //   error if every request failed
+            const error = res.find(
+                (promiseResult): promiseResult is PromiseRejectedResult => promiseResult.status === 'rejected'
+            )?.reason;
+            return Promise.reject(error);
+        }
+        return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<LogFragment[]> => (
+            // Filter out log files with rejected promises
+            //   (Promise.all rejects on any failure)
+            promiseResult.status === 'fulfilled' &&
+            // Filter out files where any fragment is empty
+            //   (prevent incorrect snipline generation or an un-resumable situation)
+            !!promiseResult.value.every(logFragment => logFragment.contents.length)
+        )).map(one => one.value)
+    })).map((logResponseSet) => {
+        // For any multi fragment response set, modify the last line of non-final chunks to include a line break and snip line
+        //   Don't add snip line as a separate line so that sorting won't reorder it
+        for (let i = 1; i < logResponseSet.length; i++) {
+            const fragment = logResponseSet[i - 1];
+            const lastLineIndex = fragment.contents.length - 1;
+            const lastLineContents = fragment.contents[lastLineIndex];
+            const newLastLine = `${lastLineContents}\n${SNIPLINE}`;
+
+            logResponseSet[i - 1].contents[lastLineIndex] = newLastLine;
+        }
+
+        // Merge LogFragment Array (representing multiple log line arrays) into single LogLine[] / LogFragment
+        return logResponseSet.reduce((acc, curr: LogFragment) => ({
+            logType: curr.logType,
+            contents: [...(acc.contents || []), ...curr.contents]
+        }), {} as LogFragment);
+    })
+);
+
+const createInitialLogPanelState = (logFiles: CollectionFile[], logFragments: LogFragment[]): { filters: string[], logs: ProcessLogs } => {
+    const logs = groupLogs(logFiles, logFragments);
+    const filters = Object.keys(logs);
+    return { filters, logs };
+}
+
+/**
+ * Converts LogFragments into ProcessLogs, grouping and sorting All/Main logs
+ * @param logFiles
+ * @param logFragments
+ * @returns ProcessLogs for the store
+ */
+const groupLogs = (logFiles: CollectionFile[], logFragments: LogFragment[]): ProcessLogs => {
+    const sortableLogFragments = mergeMultilineLoglines(logFragments);
+
+    const allLogs = mergeSortLogFragments(sortableLogFragments);
+    const mainLogs = mergeSortLogFragments(sortableLogFragments.filter((fragment) => (MAIN_EVENT_TYPES.includes(fragment.logType))));
+
+    const groupedLogs = logFragments.reduce((grouped, fragment) => ({
+        ...grouped,
+        [fragment.logType as string]: { lastByte: fetchLastByteNumber(logFiles, fragment.logType), contents: fragment.contents }
+    }), {});
+
+    return {
+        [MAIN_FILTER_TYPE]: { lastByte: undefined, contents: mainLogs },
+        [ALL_FILTER_TYPE]: { lastByte: undefined, contents: allLogs },
+        ...groupedLogs,
+    }
+};
+
+/**
+ * Checks for non-timestamped log lines and merges them with the previous line, assumes they are multi-line logs
+ *   If there is no previous line (first line has no timestamp), the line is deleted.
+ *   Only used for combined logs that need sorting by timestamp after merging
+ * @param logFragments
+ * @returns Modified LogFragment[]
+ */
+const mergeMultilineLoglines = (logFragments: LogFragment[]) => (
+    logFragments.map((fragment) => {
+        // Avoid altering the original fragment copy
+        let fragmentCopy: LogFragment = {
+            logType: fragment.logType,
+            contents: [...fragment.contents],
+        }
+        // Merge any non-timestamped lines in sortable log types with previous line
+        if (fragmentCopy.contents.length && !NON_SORTED_LOG_TYPES.includes(fragmentCopy.logType)) {
+            for (let i = 0; i < fragmentCopy.contents.length; i++) {
+                const lineContents = fragmentCopy.contents[i];
+                if (!lineContents.match(LOG_TIMESTAMP_PATTERN)) {
+                    // Partial line without timestamp detected
+                    if (i > 0) {
+                        // If not first line, copy line to previous line
+                        const previousLineContents = fragmentCopy.contents[i - 1];
+                        const newPreviousLineContents = `${previousLineContents}\n${lineContents}`;
+                        fragmentCopy.contents[i - 1] = newPreviousLineContents;
+                    }
+                    // Delete the current line and prevent iterating
+                    fragmentCopy.contents.splice(i, 1);
+                    i--;
+                }
+            }
+        }
+        return fragmentCopy;
+    })
+);
+
+/**
+ * Merges log lines of different types and sorts types that contain timestamps (are sortable)
+ * @param logFragments
+ * @returns string[] of merged and sorted log lines
+ */
+const mergeSortLogFragments = (logFragments: LogFragment[]): string[] => {
+    const sortableFragments = logFragments
+        .filter((fragment) => (!NON_SORTED_LOG_TYPES.includes(fragment.logType)));
+
+    const nonSortableLines = fragmentsToLines(logFragments
+        .filter((fragment) => (NON_SORTED_LOG_TYPES.includes(fragment.logType)))
+        .sort((a, b) => (a.logType.localeCompare(b.logType))));
+
+    return [...nonSortableLines, ...sortLogFragments(sortableFragments)];
+};
+
+/**
+ * Performs merge and sort of input log fragment lines
+ * @param logFragments set of sortable log fragments to be merged and sorted
+ * @returns A string array containing all lines, sorted by timestamp and
+ *          preserving line ordering and type grouping when timestamps match
+ */
+const sortLogFragments = (logFragments: LogFragment[]): string[] => {
+    const linesWithType: SortableLine[] = logFragments
+        // Map each logFragment into an array of SortableLine
+        .map((fragment: LogFragment): SortableLine[] => (
+            fragment.contents.map((singleLine: string) => {
+                const timestampMatch = singleLine.match(LOG_TIMESTAMP_PATTERN);
+                const timestamp = timestampMatch && timestampMatch[0] ? timestampMatch[0] : "";
+                return {
+                    logType: fragment.logType,
+                    timestamp: timestamp,
+                    contents: singleLine,
+                };
+            })
+        // Merge each array of SortableLine into single array
+        )).reduce((acc: SortableLine[], lines: SortableLine[]) => (
+            [...acc, ...lines]
+        ), [] as SortableLine[]);
+
+    return linesWithType
+        .sort(sortableLineSortFunc)
+        .map(lineWithType => lineWithType.contents);
+};
+
+/**
+ * Sort func to sort lines
+ *   Preserves original ordering of lines from the same source
+ *   Stably orders lines of differing type but same timestamp
+ *     (produces a block of same-timestamped lines of one type before a block
+ *     of same timestamped lines of another type for readability)
+ *   Sorts all other lines by contents (ie by timestamp)
+ */
+const sortableLineSortFunc = (a: SortableLine, b: SortableLine) => {
+    if (a.logType === b.logType) {
+        return 0;
+    } else if (a.timestamp === b.timestamp) {
+        return a.logType.localeCompare(b.logType);
+    } else {
+        return a.contents.localeCompare(b.contents);
+    }
+};
+
+const fragmentsToLines = (fragments: LogFragment[]): string[] => (
+    fragments.reduce((acc, fragment: LogFragment) => (
+        acc.concat(...fragment.contents)
+    ), [] as string[])
+);
+
+const fetchLastByteNumber = (logFiles: CollectionFile[], key: string) => {
+    return logFiles.find((file) => (file.name.startsWith(key)))?.size
+};
+
+export const navigateToLogCollection = (uuid: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            await services.collectionService.get(uuid);
+            dispatch<any>(navigateTo(uuid));
+        } catch {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING }));
+        }
+    };
+
+const ALL_FILTER_TYPE = 'All logs';
+
+const MAIN_FILTER_TYPE = 'Main logs';
+const MAIN_EVENT_TYPES = [
+    LogEventType.CRUNCH_RUN,
+    LogEventType.STDERR,
+    LogEventType.STDOUT,
+];
+
+const PROCESS_PANEL_LOG_EVENT_TYPES = [
+    LogEventType.ARV_MOUNT,
+    LogEventType.CRUNCH_RUN,
+    LogEventType.CRUNCHSTAT,
+    LogEventType.DISPATCH,
+    LogEventType.HOSTSTAT,
+    LogEventType.NODE_INFO,
+    LogEventType.STDERR,
+    LogEventType.STDOUT,
+    LogEventType.CONTAINER,
+    LogEventType.KEEPSTORE,
+];
+
+const NON_SORTED_LOG_TYPES = [
+    LogEventType.NODE_INFO,
+    LogEventType.CONTAINER,
+];
diff --git a/services/workbench2/src/store/process-logs-panel/process-logs-panel-reducer.ts b/services/workbench2/src/store/process-logs-panel/process-logs-panel-reducer.ts
new file mode 100644 (file)
index 0000000..e7dd356
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ProcessLogs, ProcessLogsPanel } from './process-logs-panel';
+import { ProcessLogsPanelAction, processLogsPanelActions } from './process-logs-panel-actions';
+
+const initialState: ProcessLogsPanel = {
+    filters: [],
+    selectedFilter: '',
+    logs: {},
+};
+
+export const processLogsPanelReducer = (state = initialState, action: ProcessLogsPanelAction): ProcessLogsPanel =>
+    processLogsPanelActions.match(action, {
+        RESET_PROCESS_LOGS_PANEL: () => initialState,
+        INIT_PROCESS_LOGS_PANEL: ({ filters, logs }) => ({
+            filters,
+            logs,
+            selectedFilter: filters[0] || '',
+        }),
+        SET_PROCESS_LOGS_PANEL_FILTER: selectedFilter => ({
+            ...state,
+            selectedFilter
+        }),
+        ADD_PROCESS_LOGS_PANEL_ITEM: (groupedLogs: ProcessLogs) => {
+            // Update filters
+            const newFilters = Object.keys(groupedLogs).filter((logType) => (!state.filters.includes(logType)));
+            const filters = [...state.filters, ...newFilters];
+
+            // Append new log lines
+            const logs = Object.keys(groupedLogs).reduce((acc, logType) => {
+                if (Object.keys(acc).includes(logType)) {
+                    // If log type exists, append lines and update lastByte
+                    return {...acc, [logType]: {
+                        lastByte: groupedLogs[logType].lastByte,
+                        contents: [...acc[logType].contents, ...groupedLogs[logType].contents]
+                    }};
+                } else {
+                    return {...acc, [logType]: groupedLogs[logType]};
+                }
+            }, state.logs);
+
+            return { ...state, logs, filters };
+        },
+        default: () => state,
+    });
diff --git a/services/workbench2/src/store/process-logs-panel/process-logs-panel.ts b/services/workbench2/src/store/process-logs-panel/process-logs-panel.ts
new file mode 100644 (file)
index 0000000..531d372
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { matchProcessRoute } from 'routes/routes';
+import { RouterState } from 'react-router-redux';
+
+export interface ProcessLogsPanel {
+    filters: string[];
+    selectedFilter: string;
+    logs: ProcessLogs;
+}
+
+export interface ProcessLogs {
+    [logType: string]: {lastByte: number | undefined, contents: string[]};
+}
+
+export const getProcessPanelLogs = ({ selectedFilter, logs }: ProcessLogsPanel): string[] => {
+    return logs[selectedFilter]?.contents || [];
+};
+
+export const getProcessLogsPanelCurrentUuid = (router: RouterState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchProcessRoute(pathname);
+    return match ? match.params.id : undefined;
+};
diff --git a/services/workbench2/src/store/process-panel/process-panel-actions.ts b/services/workbench2/src/store/process-panel/process-panel-actions.ts
new file mode 100644 (file)
index 0000000..60a477d
--- /dev/null
@@ -0,0 +1,216 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { getInputs, getOutputParameters, getRawInputs, getRawOutputs, loadProcess } from "store/processes/processes-actions";
+import { Dispatch } from "redux";
+import { ProcessStatus } from "store/processes/process";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { navigateTo } from "store/navigation/navigation-action";
+import { snackbarActions } from "store/snackbar/snackbar-actions";
+import { SnackbarKind } from "../snackbar/snackbar-actions";
+import { loadSubprocessPanel, subprocessPanelActions } from "../subprocess-panel/subprocess-panel-actions";
+import { initProcessLogsPanel, processLogsPanelActions } from "store/process-logs-panel/process-logs-panel-actions";
+import { CollectionFile } from "models/collection-file";
+import { ContainerRequestResource } from "models/container-request";
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
+import { CommandInputParameter, getIOParamId, WorkflowInputsData } from "models/workflow";
+import { getIOParamDisplayValue, ProcessIOParameter } from "views/process-panel/process-io-card";
+import { OutputDetails, NodeInstanceType, NodeInfo, UsageReport } from "./process-panel";
+import { AuthState } from "store/auth/auth-reducer";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { OutputDataUpdate } from "./process-panel-reducer";
+
+export const processPanelActions = unionize({
+    RESET_PROCESS_PANEL: ofType<{}>(),
+    SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: ofType<string>(),
+    SET_PROCESS_PANEL_FILTERS: ofType<string[]>(),
+    TOGGLE_PROCESS_PANEL_FILTER: ofType<string>(),
+    SET_INPUT_RAW: ofType<WorkflowInputsData | null>(),
+    SET_INPUT_PARAMS: ofType<ProcessIOParameter[] | null>(),
+    SET_OUTPUT_DATA: ofType<OutputDataUpdate | null>(),
+    SET_OUTPUT_DEFINITIONS: ofType<CommandOutputParameter[]>(),
+    SET_OUTPUT_PARAMS: ofType<ProcessIOParameter[] | null>(),
+    SET_NODE_INFO: ofType<NodeInfo>(),
+    SET_USAGE_REPORT: ofType<UsageReport>(),
+});
+
+export type ProcessPanelAction = UnionOf<typeof processPanelActions>;
+
+export const toggleProcessPanelFilter = processPanelActions.TOGGLE_PROCESS_PANEL_FILTER;
+
+export const loadProcessPanel = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState) => {
+    // Reset subprocess data explorer if navigating to new process
+    //  Avoids resetting pagination when refreshing same process
+    if (getState().processPanel.containerRequestUuid !== uuid) {
+        dispatch(subprocessPanelActions.CLEAR());
+    }
+    dispatch(processPanelActions.RESET_PROCESS_PANEL());
+    dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
+    dispatch<ProcessPanelAction>(processPanelActions.SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID(uuid));
+    await dispatch<any>(loadProcess(uuid));
+    dispatch(initProcessPanelFilters);
+    dispatch<any>(initProcessLogsPanel(uuid));
+    dispatch<any>(loadSubprocessPanel());
+};
+
+export const navigateToOutput = (resource: ContextMenuResource | ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        await services.collectionService.get(resource.outputUuid || '');
+        dispatch<any>(navigateTo(resource.outputUuid || ''));
+    } catch {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Output collection was trashed or deleted.", hideDuration: 4000, kind: SnackbarKind.WARNING }));
+    }
+};
+
+export const loadInputs =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<ProcessPanelAction>(processPanelActions.SET_INPUT_RAW(getRawInputs(containerRequest)));
+        dispatch<ProcessPanelAction>(processPanelActions.SET_INPUT_PARAMS(formatInputData(getInputs(containerRequest), getState().auth)));
+    };
+
+export const loadOutputs =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const noOutputs: OutputDetails = { raw: {} };
+
+        if (!containerRequest.outputUuid) {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_DATA({
+                uuid: containerRequest.uuid,
+                payload: noOutputs
+            }));
+            return;
+        }
+        try {
+            const propsOutputs = getRawOutputs(containerRequest);
+            const filesPromise = services.collectionService.files(containerRequest.outputUuid);
+            const collectionPromise = services.collectionService.get(containerRequest.outputUuid);
+            const [files, collection] = await Promise.all([filesPromise, collectionPromise]);
+
+            // If has propsOutput, skip fetching cwl.output.json
+            if (propsOutputs !== undefined) {
+                dispatch<ProcessPanelAction>(
+                    processPanelActions.SET_OUTPUT_DATA({
+                        uuid: containerRequest.uuid,
+                        payload: {
+                            raw: propsOutputs,
+                            pdh: collection.portableDataHash,
+                        },
+                    })
+                );
+            } else {
+                // Fetch outputs from keep
+                const outputFile = files.find(file => file.name === "cwl.output.json") as CollectionFile | undefined;
+                let outputData = outputFile ? await services.collectionService.getFileContents(outputFile) : undefined;
+                if (outputData && (outputData = JSON.parse(outputData)) && collection.portableDataHash) {
+                    dispatch<ProcessPanelAction>(
+                        processPanelActions.SET_OUTPUT_DATA({
+                            uuid: containerRequest.uuid,
+                            payload: {
+                                raw: outputData,
+                                pdh: collection.portableDataHash,
+                            },
+                        })
+                    );
+                } else {
+                    dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_DATA({ uuid: containerRequest.uuid, payload: noOutputs }));
+                }
+            }
+        } catch {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_DATA({ uuid: containerRequest.uuid, payload: noOutputs }));
+        }
+    };
+
+export const loadNodeJson =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const noLog = { nodeInfo: null };
+        if (!containerRequest.logUuid) {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO(noLog));
+            return;
+        }
+        try {
+            const filesPromise = services.collectionService.files(containerRequest.logUuid);
+            const collectionPromise = services.collectionService.get(containerRequest.logUuid);
+            const [files] = await Promise.all([filesPromise, collectionPromise]);
+
+            // Fetch node.json from keep
+            const nodeFile = files.find(file => file.name === "node.json") as CollectionFile | undefined;
+            let nodeData = nodeFile ? await services.collectionService.getFileContents(nodeFile) : undefined;
+            if (nodeData && (nodeData = JSON.parse(nodeData))) {
+                dispatch<ProcessPanelAction>(
+                    processPanelActions.SET_NODE_INFO({
+                        nodeInfo: nodeData as NodeInstanceType,
+                    })
+                );
+            } else {
+                dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO(noLog));
+            }
+
+            const usageReportFile = files.find(file => file.name === "usage_report.html") as CollectionFile | null;
+            dispatch<ProcessPanelAction>(processPanelActions.SET_USAGE_REPORT({ usageReport: usageReportFile }));
+        } catch {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO(noLog));
+            dispatch<ProcessPanelAction>(processPanelActions.SET_USAGE_REPORT({ usageReport: null }));
+        }
+    };
+
+export const loadOutputDefinitions =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        if (containerRequest && containerRequest.mounts) {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_DEFINITIONS(getOutputParameters(containerRequest)));
+        }
+    };
+
+export const updateOutputParams = () => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    const outputDefinitions = getState().processPanel.outputDefinitions;
+    const outputData = getState().processPanel.outputData;
+
+    if (outputData && outputData.raw) {
+        dispatch<ProcessPanelAction>(
+            processPanelActions.SET_OUTPUT_PARAMS(formatOutputData(outputDefinitions, outputData.raw, outputData.pdh, getState().auth))
+        );
+    }
+};
+
+export const openWorkflow = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(navigateTo(uuid));
+};
+
+export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FILTERS([
+    ProcessStatus.QUEUED,
+    ProcessStatus.COMPLETED,
+    ProcessStatus.FAILED,
+    ProcessStatus.RUNNING,
+    ProcessStatus.ONHOLD,
+    ProcessStatus.FAILING,
+    ProcessStatus.WARNING,
+    ProcessStatus.CANCELLED,
+]);
+
+export const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => {
+    return inputs.flatMap((input): ProcessIOParameter[] => {
+        const processValues = getIOParamDisplayValue(auth, input);
+        return processValues.map((thisValue, i) => ({
+            id: i === 0 ? getIOParamId(input) : "",
+            label: i === 0 ? input.label || "" : "",
+            value: thisValue,
+        }));
+    });
+};
+
+export const formatOutputData = (
+    definitions: CommandOutputParameter[],
+    values: any,
+    pdh: string | undefined,
+    auth: AuthState
+): ProcessIOParameter[] => {
+    return definitions.flatMap((output): ProcessIOParameter[] => {
+        const processValues = getIOParamDisplayValue(auth, Object.assign(output, { value: values[getIOParamId(output)] || [] }), pdh);
+        return processValues.map((thisValue, i) => ({
+            id: i === 0 ? getIOParamId(output) : "",
+            label: i === 0 ? output.label || "" : "",
+            value: thisValue,
+        }));
+    });
+};
diff --git a/services/workbench2/src/store/process-panel/process-panel-reducer.ts b/services/workbench2/src/store/process-panel/process-panel-reducer.ts
new file mode 100644 (file)
index 0000000..ab95b6a
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { OutputDetails, ProcessPanel } from "store/process-panel/process-panel";
+import { ProcessPanelAction, processPanelActions } from "store/process-panel/process-panel-actions";
+
+const initialState: ProcessPanel = {
+    containerRequestUuid: "",
+    filters: {},
+    inputRaw: null,
+    inputParams: null,
+    outputData: null,
+    nodeInfo: null,
+    outputDefinitions: [],
+    outputParams: null,
+    usageReport: null,
+};
+
+export type OutputDataUpdate = {
+    uuid: string;
+    payload: OutputDetails;
+};
+
+export const processPanelReducer = (state = initialState, action: ProcessPanelAction): ProcessPanel =>
+    processPanelActions.match(action, {
+        RESET_PROCESS_PANEL: () => initialState,
+        SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: containerRequestUuid => ({
+            ...state,
+            containerRequestUuid,
+        }),
+        SET_PROCESS_PANEL_FILTERS: statuses => {
+            const filters = statuses.reduce((filters, status) => ({ ...filters, [status]: true }), {});
+            return { ...state, filters };
+        },
+        TOGGLE_PROCESS_PANEL_FILTER: status => {
+            const filters = { ...state.filters, [status]: !state.filters[status] };
+            return { ...state, filters };
+        },
+        SET_INPUT_RAW: inputRaw => {
+            // Since mounts can disappear and reappear, only set inputs
+            //   if current state is null or new inputs has content
+            if (state.inputRaw === null || (inputRaw && Object.keys(inputRaw).length)) {
+                return { ...state, inputRaw };
+            } else {
+                return state;
+            }
+        },
+        SET_INPUT_PARAMS: inputParams => {
+            // Since mounts can disappear and reappear, only set inputs
+            //   if current state is null or new inputs has content
+            if (state.inputParams === null || (inputParams && inputParams.length)) {
+                return { ...state, inputParams };
+            } else {
+                return state;
+            }
+        },
+        SET_OUTPUT_DATA: (update: OutputDataUpdate) => {
+            //never set output to {} unless initializing
+            if (state.outputData?.raw && Object.keys(state.outputData?.raw).length && state.containerRequestUuid === update.uuid) {
+                return state;
+            }
+            return { ...state, outputData: update.payload };
+        },
+        SET_NODE_INFO: ({ nodeInfo }) => {
+            return { ...state, nodeInfo };
+        },
+        SET_OUTPUT_DEFINITIONS: outputDefinitions => {
+            // Set output definitions is only additive to avoid clearing when mounts go temporarily missing
+            if (outputDefinitions.length) {
+                return { ...state, outputDefinitions };
+            } else {
+                return state;
+            }
+        },
+        SET_OUTPUT_PARAMS: outputParams => {
+            return { ...state, outputParams };
+        },
+        SET_USAGE_REPORT: ({ usageReport }) => {
+            return { ...state, usageReport };
+        },
+        default: () => state,
+    });
diff --git a/services/workbench2/src/store/process-panel/process-panel.ts b/services/workbench2/src/store/process-panel/process-panel.ts
new file mode 100644 (file)
index 0000000..12a46a2
--- /dev/null
@@ -0,0 +1,60 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { WorkflowInputsData } from 'models/workflow';
+import { RouterState } from "react-router-redux";
+import { matchProcessRoute } from "routes/routes";
+import { ProcessIOParameter } from "views/process-panel/process-io-card";
+import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
+import { CollectionFile } from 'models/collection-file';
+
+export type OutputDetails = {
+    raw?: any;
+    pdh?: string;
+}
+
+export interface CUDAFeatures {
+    DriverVersion: string;
+    HardwareCapability: string;
+    DeviceCount: number;
+}
+
+export interface NodeInstanceType {
+    Name: string;
+    ProviderType: string;
+    VCPUs: number;
+    RAM: number;
+    Scratch: number;
+    IncludedScratch: number;
+    AddedScratch: number;
+    Price: number;
+    Preemptible: boolean;
+    CUDA: CUDAFeatures;
+};
+
+export interface NodeInfo {
+    nodeInfo: NodeInstanceType | null;
+};
+
+export interface UsageReport {
+    usageReport: CollectionFile | null;
+};
+
+export interface ProcessPanel {
+    containerRequestUuid: string;
+    filters: { [status: string]: boolean };
+    inputRaw: WorkflowInputsData | null;
+    inputParams: ProcessIOParameter[] | null;
+    outputData: OutputDetails | null;
+    outputDefinitions: CommandOutputParameter[];
+    outputParams: ProcessIOParameter[] | null;
+    nodeInfo: NodeInstanceType | null;
+    usageReport: CollectionFile | null;
+}
+
+export const getProcessPanelCurrentUuid = (router: RouterState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchProcessRoute(pathname);
+    return match ? match.params.id : undefined;
+};
diff --git a/services/workbench2/src/store/processes/process-copy-actions.test.ts b/services/workbench2/src/store/processes/process-copy-actions.test.ts
new file mode 100644 (file)
index 0000000..cb064ed
--- /dev/null
@@ -0,0 +1,483 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import { copyProcess } from './process-copy-actions';
+import { CommonService } from 'services/common-service/common-service';
+import { snakeCase } from 'lodash';
+
+configure({ adapter: new Adapter() });
+
+describe('ProcessCopyAction', () => {
+    // let props;
+    let dispatch: any, getState: any, services: any;
+
+    let sampleFailedProcess = {
+        command: [
+        "arvados-cwl-runner",
+        "--api=containers",
+        "--local",
+        "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza",
+        "/var/lib/cwl/workflow.json#main",
+        "/var/lib/cwl/cwl.input.json",
+        ],
+        container_count: 1,
+        container_count_max: 10,
+        container_image: "arvados/jobs",
+        container_uuid: "zzzzz-dz642-b9j9dtk1yikp9h0",
+        created_at: "2023-01-23T22:50:50.788284000Z",
+        cumulative_cost: 0.00120553009559028,
+        cwd: "/var/spool/cwl",
+        description: "test decsription",
+        environment: {},
+        etag: "2es6px6q7uo0yqi2i291x8gd6",
+        expires_at: null,
+        filters: null,
+        href: "/container_requests/zzzzz-xvhdp-111111111111111",
+        kind: "arvados#containerRequest",
+        log_uuid: "zzzzz-4zz18-a1gxqy9o6zyrdy8",
+        modified_at: "2023-01-24T21:13:54.772612000Z",
+        modified_by_client_uuid: "zzzzz-ozdt8-q6dzdi1lcc03155",
+        modified_by_user_uuid: "jutro-tpzed-vllbpebicy84rd5",
+        mounts: {
+        "/var/lib/cwl/cwl.input.json": {
+            capacity: 0,
+            commit: "",
+            content: {
+            input: {
+                basename: "logo.ai.no.whitespace.png",
+                class: "File",
+                location:
+                "keep:5d3238c4db721a92c98b0305a47b0485+75/logo.ai.no.whitespace.png",
+            },
+            reverse_sort: true,
+            },
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "json",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        "/var/lib/cwl/workflow.json": {
+            capacity: 0,
+            commit: "",
+            content: {
+            $graph: [
+                {
+                class: "Workflow",
+                doc: "Reverse the lines in a document, then sort those lines.",
+                id: "#main",
+                inputs: [
+                    {
+                    default: null,
+                    doc: "The input file to be processed.",
+                    id: "#main/input",
+                    type: "File",
+                    },
+                    {
+                    default: true,
+                    doc: "If true, reverse (decending) sort",
+                    id: "#main/reverse_sort",
+                    type: "boolean",
+                    },
+                ],
+                outputs: [
+                    {
+                    doc: "The output with the lines reversed and sorted.",
+                    id: "#main/output",
+                    outputSource: "#main/sorted/output",
+                    type: "File",
+                    },
+                ],
+                steps: [
+                    {
+                    id: "#main/rev",
+                    in: [{ id: "#main/rev/input", source: "#main/input" }],
+                    out: ["#main/rev/output"],
+                    run: "#revtool.cwl",
+                    },
+                    {
+                    id: "#main/sorted",
+                    in: [
+                        { id: "#main/sorted/input", source: "#main/rev/output" },
+                        {
+                        id: "#main/sorted/reverse",
+                        source: "#main/reverse_sort",
+                        },
+                    ],
+                    out: ["#main/sorted/output"],
+                    run: "#sorttool.cwl",
+                    },
+                ],
+                },
+                {
+                baseCommand: "rev",
+                class: "CommandLineTool",
+                doc: "Reverse each line using the `rev` command",
+                hints: [{ class: "ResourceRequirement", ramMin: 8 }],
+                id: "#revtool.cwl",
+                inputs: [
+                    { id: "#revtool.cwl/input", inputBinding: {}, type: "File" },
+                ],
+                outputs: [
+                    {
+                    id: "#revtool.cwl/output",
+                    outputBinding: { glob: "output.txt" },
+                    type: "File",
+                    },
+                ],
+                stdout: "output.txt",
+                },
+                {
+                baseCommand: "sort",
+                class: "CommandLineTool",
+                doc: "Sort lines using the `sort` command",
+                hints: [{ class: "ResourceRequirement", ramMin: 8 }],
+                id: "#sorttool.cwl",
+                inputs: [
+                    {
+                    id: "#sorttool.cwl/reverse",
+                    inputBinding: { position: 1, prefix: "-r" },
+                    type: "boolean",
+                    },
+                    {
+                    id: "#sorttool.cwl/input",
+                    inputBinding: { position: 2 },
+                    type: "File",
+                    },
+                ],
+                outputs: [
+                    {
+                    id: "#sorttool.cwl/output",
+                    outputBinding: { glob: "output.txt" },
+                    type: "File",
+                    },
+                ],
+                stdout: "output.txt",
+                },
+            ],
+            cwlVersion: "v1.0",
+            },
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "json",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        "/var/spool/cwl": {
+            capacity: 0,
+            commit: "",
+            content: null,
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "collection",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: true,
+        },
+        stdout: {
+            capacity: 0,
+            commit: "",
+            content: null,
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "file",
+            path: "/var/spool/cwl/cwl.output.json",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        },
+        name: "Copy of: Copy of: Copy of: revsort.cwl",
+        output_name: "Output from revsort.cwl",
+        output_path: "/var/spool/cwl",
+        output_properties: { key: "val" },
+        output_storage_classes: ["default"],
+        output_ttl: 999999,
+        output_uuid: "zzzzz-4zz18-wolwlyfxmlhmgd4",
+        owner_uuid: "zzzzz-j7d0g-yr18k784zplfeza",
+        priority: 500,
+        properties: {
+        template_uuid: "zzzzz-7fd4e-7xsza0vgfe785cy",
+        workflowName: "revsort.cwl",
+        },
+        requesting_container_uuid: null,
+        runtime_constraints: {
+        API: true,
+        cuda: { device_count: 0, driver_version: "", hardware_capability: "" },
+        keep_cache_disk: 0,
+        keep_cache_ram: 0,
+        ram: 1342177280,
+        vcpus: 1,
+        },
+        runtime_token: "",
+        scheduling_parameters: {
+        max_run_time: 0,
+        partitions: [],
+        preemptible: false,
+        },
+        state: "Final",
+        use_existing: false,
+        uuid: "zzzzz-xvhdp-111111111111111",
+    };
+
+    let expectedContainerRequest = {
+        command: [
+        "arvados-cwl-runner",
+        "--api=containers",
+        "--local",
+        "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza",
+        "/var/lib/cwl/workflow.json#main",
+        "/var/lib/cwl/cwl.input.json",
+        ],
+        container_count_max: 10,
+        container_image: "arvados/jobs",
+        cwd: "/var/spool/cwl",
+        description: "test decsription",
+        environment: {},
+        kind: "arvados#containerRequest",
+        mounts: {
+        "/var/lib/cwl/cwl.input.json": {
+            capacity: 0,
+            commit: "",
+            content: {
+            input: {
+                basename: "logo.ai.no.whitespace.png",
+                class: "File",
+                location:
+                "keep:5d3238c4db721a92c98b0305a47b0485+75/logo.ai.no.whitespace.png",
+            },
+            reverse_sort: true,
+            },
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "json",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        "/var/lib/cwl/workflow.json": {
+            capacity: 0,
+            commit: "",
+            content: {
+            $graph: [
+                {
+                class: "Workflow",
+                doc: "Reverse the lines in a document, then sort those lines.",
+                id: "#main",
+                inputs: [
+                    {
+                    default: null,
+                    doc: "The input file to be processed.",
+                    id: "#main/input",
+                    type: "File",
+                    },
+                    {
+                    default: true,
+                    doc: "If true, reverse (decending) sort",
+                    id: "#main/reverse_sort",
+                    type: "boolean",
+                    },
+                ],
+                outputs: [
+                    {
+                    doc: "The output with the lines reversed and sorted.",
+                    id: "#main/output",
+                    outputSource: "#main/sorted/output",
+                    type: "File",
+                    },
+                ],
+                steps: [
+                    {
+                    id: "#main/rev",
+                    in: [{ id: "#main/rev/input", source: "#main/input" }],
+                    out: ["#main/rev/output"],
+                    run: "#revtool.cwl",
+                    },
+                    {
+                    id: "#main/sorted",
+                    in: [
+                        {
+                        id: "#main/sorted/input",
+                        source: "#main/rev/output",
+                        },
+                        {
+                        id: "#main/sorted/reverse",
+                        source: "#main/reverse_sort",
+                        },
+                    ],
+                    out: ["#main/sorted/output"],
+                    run: "#sorttool.cwl",
+                    },
+                ],
+                },
+                {
+                baseCommand: "rev",
+                class: "CommandLineTool",
+                doc: "Reverse each line using the `rev` command",
+                hints: [{ class: "ResourceRequirement", ramMin: 8 }],
+                id: "#revtool.cwl",
+                inputs: [
+                    {
+                    id: "#revtool.cwl/input",
+                    inputBinding: {},
+                    type: "File",
+                    },
+                ],
+                outputs: [
+                    {
+                    id: "#revtool.cwl/output",
+                    outputBinding: { glob: "output.txt" },
+                    type: "File",
+                    },
+                ],
+                stdout: "output.txt",
+                },
+                {
+                baseCommand: "sort",
+                class: "CommandLineTool",
+                doc: "Sort lines using the `sort` command",
+                hints: [{ class: "ResourceRequirement", ramMin: 8 }],
+                id: "#sorttool.cwl",
+                inputs: [
+                    {
+                    id: "#sorttool.cwl/reverse",
+                    inputBinding: { position: 1, prefix: "-r" },
+                    type: "boolean",
+                    },
+                    {
+                    id: "#sorttool.cwl/input",
+                    inputBinding: { position: 2 },
+                    type: "File",
+                    },
+                ],
+                outputs: [
+                    {
+                    id: "#sorttool.cwl/output",
+                    outputBinding: { glob: "output.txt" },
+                    type: "File",
+                    },
+                ],
+                stdout: "output.txt",
+                },
+            ],
+            cwlVersion: "v1.0",
+            },
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "json",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        "/var/spool/cwl": {
+            capacity: 0,
+            commit: "",
+            content: null,
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "collection",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: true,
+        },
+        stdout: {
+            capacity: 0,
+            commit: "",
+            content: null,
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "file",
+            path: "/var/spool/cwl/cwl.output.json",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        },
+        name: "newname.cwl",
+        output_name: "Output from revsort.cwl",
+        output_path: "/var/spool/cwl",
+        output_properties: { key: "val" },
+        output_storage_classes: ["default"],
+        output_ttl: 999999,
+        owner_uuid: "zzzzz-j7d0g-000000000000000",
+        priority: 500,
+        properties: {
+        template_uuid: "zzzzz-7fd4e-7xsza0vgfe785cy",
+        workflowName: "revsort.cwl",
+        },
+        runtime_constraints: {
+        API: true,
+        cuda: {
+            device_count: 0,
+            driver_version: "",
+            hardware_capability: "",
+        },
+        keep_cache_disk: 0,
+        keep_cache_ram: 0,
+        ram: 1342177280,
+        vcpus: 1,
+        },
+        scheduling_parameters: {
+        max_run_time: 0,
+        partitions: [],
+        preemptible: false,
+        },
+        state: "Uncommitted",
+        use_existing: false,
+    };
+
+    beforeEach(() => {
+        dispatch = jest.fn();
+        services = {
+            containerRequestService: {
+                get: jest.fn().mockImplementation(async () => (CommonService.mapResponseKeys({data: sampleFailedProcess}))),
+                create: jest.fn().mockImplementation(async (data) => (CommonService.mapKeys(snakeCase)(data))),
+            },
+        };
+        getState = () => ({
+            auth: {},
+        });
+    });
+
+    it("should request the failed process and return a copy with the proper fields", async () => {
+        // when
+        const newprocess = await copyProcess({
+            name: "newname.cwl",
+            uuid: "zzzzz-xvhdp-111111111111111",
+            ownerUuid: "zzzzz-j7d0g-000000000000000",
+        })(dispatch, getState, services);
+
+        // then
+        expect(services.containerRequestService.get).toHaveBeenCalledWith("zzzzz-xvhdp-111111111111111");
+        expect(newprocess).toEqual(expectedContainerRequest);
+
+    });
+});
diff --git a/services/workbench2/src/store/processes/process-copy-actions.ts b/services/workbench2/src/store/processes/process-copy-actions.ts
new file mode 100644 (file)
index 0000000..36d7394
--- /dev/null
@@ -0,0 +1,86 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { initialize, startSubmit } from 'redux-form';
+import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
+import { getProcess } from 'store/processes/process';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { ContainerRequestState } from 'models/container-request';
+
+export const PROCESS_COPY_FORM_NAME = 'processCopyFormName';
+export const MULTI_PROCESS_COPY_FORM_NAME = 'multiProcessCopyFormName';
+
+export const openCopyProcessDialog =
+    (resource: { name: string; uuid: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const process = getProcess(resource.uuid)(getState().resources);
+        if (process) {
+            dispatch<any>(resetPickerProjectTree());
+            dispatch<any>(initProjectsTreePicker(PROCESS_COPY_FORM_NAME));
+            const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, uuid: resource.uuid, ownerUuid: '' };
+            dispatch<any>(initialize(PROCESS_COPY_FORM_NAME, initialData));
+            dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_COPY_FORM_NAME, data: {} }));
+        } else {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process not found', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const copyProcess = (resource: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(startSubmit(PROCESS_COPY_FORM_NAME));
+    try {
+        const process = await services.containerRequestService.get(resource.uuid);
+        const {
+            command,
+            containerCountMax,
+            containerImage,
+            cwd,
+            description,
+            environment,
+            kind,
+            mounts,
+            outputName,
+            outputPath,
+            outputProperties,
+            outputStorageClasses,
+            outputTtl,
+            properties,
+            runtimeConstraints,
+            schedulingParameters,
+            useExisting,
+        } = process;
+        const newProcess = await services.containerRequestService.create({
+            command,
+            containerCountMax,
+            containerImage,
+            cwd,
+            description,
+            environment,
+            kind,
+            mounts,
+            name: resource.name,
+            outputName,
+            outputPath,
+            outputProperties,
+            outputStorageClasses,
+            outputTtl,
+            ownerUuid: resource.ownerUuid,
+            priority: 500,
+            properties,
+            runtimeConstraints,
+            schedulingParameters,
+            state: ContainerRequestState.UNCOMMITTED,
+            useExisting,
+        });
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
+        return newProcess;
+    } catch (e) {
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
+        throw new Error('Could not copy the process.');
+    }
+};
diff --git a/services/workbench2/src/store/processes/process-input-actions.ts b/services/workbench2/src/store/processes/process-input-actions.ts
new file mode 100644 (file)
index 0000000..ec9352d
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import { getProcess, Process } from 'store/processes/process';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { getWorkflowInputs } from 'models/workflow';
+import { JSONMount } from 'models/mount-types';
+import { MOUNT_PATH_CWL_WORKFLOW } from 'models/process';
+
+export const PROCESS_INPUT_DIALOG_NAME = 'processInputDialog';
+
+export const openProcessInputDialog = (processUuid: string) =>
+    (dispatch: Dispatch<any>, getState: () => RootState) => {
+        const process = getProcess(processUuid)(getState().resources);
+        if (process) {
+            const data: any = process;
+            const inputs = getInputsFromWFMount(process);
+            if (inputs && inputs.length > 0) {
+                dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_INPUT_DIALOG_NAME, data }));
+            } else {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'There are no inputs in this process!', kind: SnackbarKind.ERROR }));
+            }
+        }
+    };
+
+const getInputsFromWFMount = (process: Process) => {
+    if (!process || !process.containerRequest.mounts[MOUNT_PATH_CWL_WORKFLOW] ) { return undefined; }
+    const mnt = process.containerRequest.mounts[MOUNT_PATH_CWL_WORKFLOW] as JSONMount;
+    return getWorkflowInputs(mnt.content);
+};
\ No newline at end of file
diff --git a/services/workbench2/src/store/processes/process-move-actions.ts b/services/workbench2/src/store/processes/process-move-actions.ts
new file mode 100644 (file)
index 0000000..c3ac75f
--- /dev/null
@@ -0,0 +1,53 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { startSubmit, stopSubmit, initialize, FormErrors } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { getProcess } from "store/processes/process";
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
+
+export const PROCESS_MOVE_FORM_NAME = "processMoveFormName";
+
+export const openMoveProcessDialog =
+    (resource: { name: string; uuid: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const process = getProcess(resource.uuid)(getState().resources);
+        if (process) {
+            dispatch<any>(resetPickerProjectTree());
+            dispatch<any>(initProjectsTreePicker(PROCESS_MOVE_FORM_NAME));
+            dispatch(initialize(PROCESS_MOVE_FORM_NAME, resource));
+            dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_MOVE_FORM_NAME, data: {} }));
+        } else {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process not found", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const moveProcess = (resource: MoveToFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(startSubmit(PROCESS_MOVE_FORM_NAME));
+    try {
+        const process = await services.containerRequestService.get(resource.uuid);
+        await services.containerRequestService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
+        dispatch(projectPanelActions.REQUEST_ITEMS());
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
+        return process;
+    } catch (e) {
+        const error = getCommonResourceServiceError(e);
+        if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+            dispatch(
+                stopSubmit(PROCESS_MOVE_FORM_NAME, { ownerUuid: "A process with the same name already exists in the target project." } as FormErrors)
+            );
+        } else {
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not move the process.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+        return;
+    }
+};
diff --git a/services/workbench2/src/store/processes/process-update-actions.ts b/services/workbench2/src/store/processes/process-update-actions.ts
new file mode 100644 (file)
index 0000000..c7bd2c7
--- /dev/null
@@ -0,0 +1,55 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { FormErrors, initialize, startSubmit, stopSubmit } from "redux-form";
+import { RootState } from "store/store";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { ServiceRepository } from "services/services";
+import { getProcess } from "store/processes/process";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+
+export interface ProcessUpdateFormDialogData {
+    uuid: string;
+    name: string;
+    description?: string;
+}
+
+export const PROCESS_UPDATE_FORM_NAME = "processUpdateFormName";
+
+export const openProcessUpdateDialog =
+    (resource: ProcessUpdateFormDialogData) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const process = getProcess(resource.uuid)(getState().resources);
+        if (process) {
+            dispatch(initialize(PROCESS_UPDATE_FORM_NAME, { ...resource, name: process.containerRequest.name }));
+            dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_UPDATE_FORM_NAME, data: {} }));
+        } else {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process not found", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const updateProcess =
+    (resource: ProcessUpdateFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(PROCESS_UPDATE_FORM_NAME));
+        try {
+            const updatedProcess = await services.containerRequestService.update(resource.uuid, {
+                name: resource.name,
+                description: resource.description,
+            });
+            dispatch(projectPanelActions.REQUEST_ITEMS());
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_UPDATE_FORM_NAME }));
+            return updatedProcess;
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(stopSubmit(PROCESS_UPDATE_FORM_NAME, { name: "Process with the same name already exists." } as FormErrors));
+            } else {
+                dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_UPDATE_FORM_NAME }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not update the process.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            }
+            return;
+        }
+    };
diff --git a/services/workbench2/src/store/processes/process.ts b/services/workbench2/src/store/processes/process.ts
new file mode 100644 (file)
index 0000000..a31fd9e
--- /dev/null
@@ -0,0 +1,224 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContainerRequestResource, ContainerRequestState } from '../../models/container-request';
+import { ContainerResource, ContainerState } from '../../models/container';
+import { ResourcesState, getResource } from 'store/resources/resources';
+import { filterResources } from '../resources/resources';
+import { ResourceKind, Resource, extractUuidKind } from 'models/resource';
+import { getTimeDiff } from 'common/formatters';
+import { ArvadosTheme } from 'common/custom-theme';
+
+export interface Process {
+    containerRequest: ContainerRequestResource;
+    container?: ContainerResource;
+}
+
+export enum ProcessStatus {
+    CANCELLED = 'Cancelled',
+    COMPLETED = 'Completed',
+    DRAFT = 'Draft',
+    FAILING = 'Failing',
+    FAILED = 'Failed',
+    ONHOLD = 'On hold',
+    QUEUED = 'Queued',
+    RUNNING = 'Running',
+    WARNING = 'Warning',
+    UNKNOWN = 'Unknown',
+    REUSED = 'Reused',
+    CANCELLING = 'Cancelling',
+}
+
+export const getProcess = (uuid: string) => (resources: ResourcesState): Process | undefined => {
+    if (extractUuidKind(uuid) === ResourceKind.CONTAINER_REQUEST) {
+        const containerRequest = getResource<ContainerRequestResource>(uuid)(resources);
+        if (containerRequest) {
+            if (containerRequest.containerUuid) {
+                const container = getResource<ContainerResource>(containerRequest.containerUuid)(resources);
+                if (container) {
+                    return { containerRequest, container };
+                }
+            }
+            return { containerRequest };
+        }
+    }
+    return;
+};
+
+export const getSubprocesses = (uuid: string) => (resources: ResourcesState) => {
+    const process = getProcess(uuid)(resources);
+    if (process && process.container) {
+        const containerRequests = filterResources(isSubprocess(process.container.uuid))(resources) as ContainerRequestResource[];
+        return containerRequests.reduce((subprocesses, { uuid }) => {
+            const process = getProcess(uuid)(resources);
+            return process
+                ? [...subprocesses, process]
+                : subprocesses;
+        }, []);
+    }
+    return [];
+};
+
+export const getProcessRuntime = ({ container }: Process) => {
+    if (container) {
+        if (container.startedAt === null) {
+            return 0;
+        }
+        if (container.finishedAt === null) {
+            // Count it from now
+            return new Date().getTime() - new Date(container.startedAt).getTime();
+        }
+        return getTimeDiff(container.finishedAt, container.startedAt);
+    } else {
+        return 0;
+    }
+};
+
+
+export const getProcessStatusStyles = (status: string, theme: ArvadosTheme): React.CSSProperties => {
+    let color = theme.customs.colors.grey500;
+    let running = false;
+    switch (status) {
+        case ProcessStatus.RUNNING:
+            color = theme.customs.colors.green800;
+            running = true;
+            break;
+        case ProcessStatus.COMPLETED:
+        case ProcessStatus.REUSED:
+            color = theme.customs.colors.green800;
+            break;
+        case ProcessStatus.WARNING:
+            color = theme.customs.colors.green800;
+            running = true;
+            break;
+        case ProcessStatus.FAILING:
+            color = theme.customs.colors.red900;
+            running = true;
+            break;
+        case ProcessStatus.CANCELLING:
+            color = theme.customs.colors.red900;
+            running = true;
+            break;
+        case ProcessStatus.CANCELLED:
+        case ProcessStatus.FAILED:
+            color = theme.customs.colors.red900;
+            break;
+        case ProcessStatus.QUEUED:
+            color = theme.customs.colors.grey600;
+            running = true;
+            break;
+        default:
+            color = theme.customs.colors.grey600;
+            break;
+    }
+
+    // Using color and running we build the text, border, and background style properties
+    return {
+        // Set background color when not running, otherwise use white
+        backgroundColor: running ? theme.palette.common.white : color,
+        // Set text color to status color when running, else use white text for solid button
+        color: running ? color : theme.palette.common.white,
+        // Set border color when running, else omit the style entirely
+        ...(running ? { border: `2px solid ${color}` } : {}),
+    };
+};
+
+export const getProcessStatus = ({ containerRequest, container }: Process): ProcessStatus => {
+    switch (true) {
+        case containerRequest.containerUuid && !container:
+            return ProcessStatus.UNKNOWN;
+
+        case containerRequest.state === ContainerRequestState.UNCOMMITTED:
+            return ProcessStatus.DRAFT;
+
+        case containerRequest.state === ContainerRequestState.FINAL &&
+            container?.state === ContainerState.RUNNING:
+            // It is about to be completed but we haven't
+            // gotten the updated container record yet,
+            // if we don't catch this and show it as "Running"
+            // it will flicker "Cancelled" briefly
+            return ProcessStatus.RUNNING;
+
+        case containerRequest.state === ContainerRequestState.FINAL &&
+            container?.state !== ContainerState.COMPLETE:
+            // Request was finalized before its container started (or the
+            // container was cancelled)
+            return ProcessStatus.CANCELLED;
+
+        case container && container.state === ContainerState.COMPLETE:
+            if (container?.exitCode === 0) {
+                if (containerRequest && container.finishedAt) {
+                    // don't compare on createdAt because the container can
+                    // have a slightly earlier creation time when it is created
+                    // in the same transaction as the container request.
+                    // use finishedAt because most people will assume "reused" means
+                    // no additional work needed to be done, it's possible
+                    // to share a running container but calling it "reused" in that case
+                    // is more likely to just be confusing.
+                    const finishedAt = new Date(container.finishedAt).getTime();
+                    const createdAt = new Date(containerRequest.createdAt).getTime();
+                    if (finishedAt < createdAt) {
+                        return ProcessStatus.REUSED;
+                    }
+                }
+                return ProcessStatus.COMPLETED;
+            }
+            return ProcessStatus.FAILED;
+
+        case container?.state === ContainerState.CANCELLED:
+            return ProcessStatus.CANCELLED;
+
+        case container?.state === ContainerState.QUEUED ||
+            container?.state === ContainerState.LOCKED:
+            if (containerRequest.priority === 0) {
+                return ProcessStatus.ONHOLD;
+            }
+            return ProcessStatus.QUEUED;
+
+        case container?.state === ContainerState.RUNNING:
+            if (container?.priority === 0) {
+                return ProcessStatus.CANCELLING;
+            }
+            if (!!container?.runtimeStatus.error) {
+                return ProcessStatus.FAILING;
+            }
+            if (!!container?.runtimeStatus.warning) {
+                return ProcessStatus.WARNING;
+            }
+            return ProcessStatus.RUNNING;
+
+        default:
+            return ProcessStatus.UNKNOWN;
+    }
+};
+
+export const isProcessRunning = ({ container }: Process): boolean => (
+    container?.state === ContainerState.RUNNING
+);
+
+export const isProcessRunnable = ({ containerRequest }: Process): boolean => (
+    containerRequest.state === ContainerRequestState.UNCOMMITTED
+);
+
+export const isProcessResumable = ({ containerRequest, container }: Process): boolean => (
+    containerRequest.state === ContainerRequestState.COMMITTED &&
+    containerRequest.priority === 0 &&
+    // Don't show run button when container is present & running or cancelled
+    !(container && (container.state === ContainerState.RUNNING ||
+        container.state === ContainerState.CANCELLED ||
+        container.state === ContainerState.COMPLETE))
+);
+
+export const isProcessCancelable = ({ containerRequest, container }: Process): boolean => (
+    containerRequest.priority !== null &&
+    containerRequest.priority > 0 &&
+    container !== undefined &&
+    (container.state === ContainerState.QUEUED ||
+        container.state === ContainerState.LOCKED ||
+        container.state === ContainerState.RUNNING)
+);
+
+const isSubprocess = (containerUuid: string) => (resource: Resource) =>
+    resource.kind === ResourceKind.CONTAINER_REQUEST
+    && (resource as ContainerRequestResource).requestingContainerUuid === containerUuid;
diff --git a/services/workbench2/src/store/processes/processes-actions.ts b/services/workbench2/src/store/processes/processes-actions.ts
new file mode 100644 (file)
index 0000000..eadb05e
--- /dev/null
@@ -0,0 +1,343 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { updateResources } from "store/resources/resources-actions";
+import { Process } from "./process";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { navigateToRunProcess } from "store/navigation/navigation-action";
+import { goToStep, runProcessPanelActions } from "store/run-process-panel/run-process-panel-actions";
+import { getResource } from "store/resources/resources";
+import { initialize } from "redux-form";
+import { RUN_PROCESS_BASIC_FORM, RunProcessBasicFormData } from "views/run-process-panel/run-process-basic-form";
+import { RunProcessAdvancedFormData, RUN_PROCESS_ADVANCED_FORM } from "views/run-process-panel/run-process-advanced-form";
+import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from "models/process";
+import { CommandInputParameter, getWorkflow, getWorkflowInputs, getWorkflowOutputs, WorkflowInputsData } from "models/workflow";
+import { ProjectResource } from "models/project";
+import { UserResource } from "models/user";
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
+import { ContainerResource } from "models/container";
+import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
+import { FilterBuilder } from "services/api/filter-builder";
+import { selectedToArray } from "components/multiselect-toolbar/MultiselectToolbar";
+import { Resource, ResourceKind } from "models/resource";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { CommonResourceServiceError, getCommonResourceServiceError } from "services/common-service/common-resource-service";
+
+export const loadProcess =
+    (containerRequestUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<Process | undefined> => {
+        let containerRequest: ContainerRequestResource | undefined = undefined;
+        try {
+            containerRequest = await services.containerRequestService.get(containerRequestUuid);
+            dispatch<any>(updateResources([containerRequest]));
+        } catch {
+            return undefined;
+        }
+
+        if (containerRequest.outputUuid) {
+            try {
+                const collection = await services.collectionService.get(containerRequest.outputUuid, false);
+                dispatch<any>(updateResources([collection]));
+            } catch {}
+        }
+
+        if (containerRequest.containerUuid) {
+            let container: ContainerResource | undefined = undefined;
+            try {
+                container = await services.containerService.get(containerRequest.containerUuid, false);
+                dispatch<any>(updateResources([container]));
+            } catch {}
+
+            try {
+                if (container && container.runtimeUserUuid) {
+                    const runtimeUser = await services.userService.get(container.runtimeUserUuid, false);
+                    dispatch<any>(updateResources([runtimeUser]));
+                }
+            } catch {}
+
+            return { containerRequest, container };
+        }
+        return { containerRequest };
+    };
+
+export const loadContainers =
+    (containerUuids: string[], loadMounts: boolean = true) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        let args: any = {
+            filters: new FilterBuilder().addIn("uuid", containerUuids).getFilters(),
+            limit: containerUuids.length,
+        };
+        if (!loadMounts) {
+            args.select = containerFieldsNoMounts;
+        }
+        const { items } = await services.containerService.list(args);
+        dispatch<any>(updateResources(items));
+        return items;
+    };
+
+// Until the api supports unselecting fields, we need a list of all other fields to omit mounts
+const containerFieldsNoMounts = [
+    "auth_uuid",
+    "command",
+    "container_image",
+    "cost",
+    "created_at",
+    "cwd",
+    "environment",
+    "etag",
+    "exit_code",
+    "finished_at",
+    "gateway_address",
+    "href",
+    "interactive_session_started",
+    "kind",
+    "lock_count",
+    "locked_by_uuid",
+    "log",
+    "modified_at",
+    "modified_by_client_uuid",
+    "modified_by_user_uuid",
+    "output_path",
+    "output_properties",
+    "output_storage_classes",
+    "output",
+    "owner_uuid",
+    "priority",
+    "progress",
+    "runtime_auth_scopes",
+    "runtime_constraints",
+    "runtime_status",
+    "runtime_user_uuid",
+    "scheduling_parameters",
+    "started_at",
+    "state",
+    "subrequests_cost",
+    "uuid",
+];
+
+export const cancelRunningWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await services.containerRequestService.update(uuid, { priority: 0 });
+        dispatch<any>(updateResources([process]));
+        if (process.containerUuid) {
+            const container = await services.containerService.get(process.containerUuid, false);
+            dispatch<any>(updateResources([container]));
+        }
+        return process;
+    } catch (e) {
+        throw new Error("Could not cancel the process.");
+    }
+};
+
+export const resumeOnHoldWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await services.containerRequestService.update(uuid, { priority: 500 });
+        dispatch<any>(updateResources([process]));
+        if (process.containerUuid) {
+            const container = await services.containerService.get(process.containerUuid, false);
+            dispatch<any>(updateResources([container]));
+        }
+        return process;
+    } catch (e) {
+        throw new Error("Could not resume the process.");
+    }
+};
+
+export const startWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await services.containerRequestService.update(uuid, { state: ContainerRequestState.COMMITTED });
+        if (process) {
+            dispatch<any>(updateResources([process]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process started", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } else {
+            dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR }));
+        }
+    } catch (e) {
+        dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR }));
+    }
+};
+
+export const reRunProcess =
+    (processUuid: string, workflowUuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const process = getResource<any>(processUuid)(getState().resources);
+        const workflows = getState().runProcessPanel.searchWorkflows;
+        const workflow = workflows.find(workflow => workflow.uuid === workflowUuid);
+        if (workflow && process) {
+            const mainWf = getWorkflow(process.mounts[MOUNT_PATH_CWL_WORKFLOW]);
+            if (mainWf) {
+                mainWf.inputs = getInputs(process);
+            }
+            const stringifiedDefinition = JSON.stringify(process.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
+            const newWorkflow = { ...workflow, definition: stringifiedDefinition };
+
+            const owner = getResource<ProjectResource | UserResource>(workflow.ownerUuid)(getState().resources);
+            const basicInitialData: RunProcessBasicFormData = { name: `Copy of: ${process.name}`, description: process.description, owner };
+            dispatch<any>(initialize(RUN_PROCESS_BASIC_FORM, basicInitialData));
+
+            const advancedInitialData: RunProcessAdvancedFormData = {
+                output: process.outputName,
+                runtime: process.schedulingParameters.max_run_time,
+                ram: process.runtimeConstraints.ram,
+                vcpus: process.runtimeConstraints.vcpus,
+                keep_cache_ram: process.runtimeConstraints.keep_cache_ram,
+                acr_container_image: process.containerImage,
+            };
+            dispatch<any>(initialize(RUN_PROCESS_ADVANCED_FORM, advancedInitialData));
+
+            dispatch<any>(navigateToRunProcess);
+            dispatch<any>(goToStep(1));
+            dispatch(runProcessPanelActions.SET_STEP_CHANGED(true));
+            dispatch(runProcessPanelActions.SET_SELECTED_WORKFLOW(newWorkflow));
+        } else {
+            dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `You can't re-run this process`, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+/*
+ * Fetches raw inputs from containerRequest mounts with fallback to properties
+ * Returns undefined if containerRequest not loaded
+ * Returns {} if inputs not found in mounts or props
+ */
+export const getRawInputs = (data: any): WorkflowInputsData | undefined => {
+    if (!data) {
+        return undefined;
+    }
+    const mountInput = data.mounts?.[MOUNT_PATH_CWL_INPUT]?.content;
+    const propsInput = data.properties?.cwl_input;
+    if (!mountInput && !propsInput) {
+        return {};
+    }
+    return mountInput || propsInput;
+};
+
+export const getInputs = (data: any): CommandInputParameter[] => {
+    // Definitions from mounts are needed so we return early if missing
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) {
+        return [];
+    }
+    const content = getRawInputs(data) as any;
+    // Only escape if content is falsy to allow displaying definitions if no inputs are present
+    // (Don't check raw content length)
+    if (!content) {
+        return [];
+    }
+
+    const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
+    return inputs
+        ? inputs.map((it: any) => ({
+              type: it.type,
+              id: it.id,
+              label: it.label,
+              default: content[it.id],
+              value: content[it.id.split("/").pop()] || [],
+              doc: it.doc,
+          }))
+        : [];
+};
+
+/*
+ * Fetches raw outputs from containerRequest properties
+ * Assumes containerRequest is loaded
+ */
+export const getRawOutputs = (data: any): CommandInputParameter[] | undefined => {
+    if (!data || !data.properties || !data.properties.cwl_output) {
+        return undefined;
+    }
+    return data.properties.cwl_output;
+};
+
+export type InputCollectionMount = {
+    path: string;
+    pdh: string;
+};
+
+export const getInputCollectionMounts = (data: any): InputCollectionMount[] => {
+    if (!data || !data.mounts) {
+        return [];
+    }
+    return Object.keys(data.mounts)
+        .map(key => ({
+            ...data.mounts[key],
+            path: key,
+        }))
+        .filter(mount => mount.kind === "collection" && mount.portable_data_hash && mount.path)
+        .map(mount => ({
+            path: mount.path,
+            pdh: mount.portable_data_hash,
+        }));
+};
+
+export const getOutputParameters = (data: any): CommandOutputParameter[] => {
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) {
+        return [];
+    }
+    const outputs = getWorkflowOutputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
+    return outputs
+        ? outputs.map((it: any) => ({
+              type: it.type,
+              id: it.id,
+              label: it.label,
+              doc: it.doc,
+          }))
+        : [];
+};
+
+export const openRemoveProcessDialog =
+    (resource: ContextMenuResource, numOfProcesses: Number) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const confirmationText =
+            numOfProcesses === 1
+                ? "Are you sure you want to remove this process?"
+                : `Are you sure you want to remove these ${numOfProcesses} processes?`;
+        const titleText = numOfProcesses === 1 ? "Remove process permanently" : "Remove processes permanently";
+
+        dispatch(
+            dialogActions.OPEN_DIALOG({
+                id: REMOVE_PROCESS_DIALOG,
+                data: {
+                    title: titleText,
+                    text: confirmationText,
+                    confirmButtonLabel: "Remove",
+                    uuid: resource.uuid,
+                    resource,
+                },
+            })
+        );
+    };
+
+export const REMOVE_PROCESS_DIALOG = "removeProcessDialog";
+
+export const removeProcessPermanently = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const resource = getState().dialog.removeProcessDialog.data.resource;
+    const checkedList = getState().multiselect.checkedList;
+
+    const uuidsToRemove: string[] = resource.fromContextMenu ? [resource.uuid] : selectedToArray(checkedList);
+
+    //if no items in checkedlist, default to normal context menu behavior
+    if (!uuidsToRemove.length) uuidsToRemove.push(uuid);
+
+    const processesToRemove = uuidsToRemove
+        .map(uuid => getResource(uuid)(getState().resources) as Resource)
+        .filter(resource => resource.kind === ResourceKind.PROCESS);
+
+    for (const process of processesToRemove) {
+        try {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removing ...", kind: SnackbarKind.INFO }));
+            await services.containerRequestService.delete(process.uuid, false);
+            dispatch(projectPanelActions.REQUEST_ITEMS());
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removed.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.PERMISSION_ERROR_FORBIDDEN) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Access denied`, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            } else {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Deletion failed`, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            }
+        }
+    }
+};
diff --git a/services/workbench2/src/store/processes/processes-middleware-service.ts b/services/workbench2/src/store/processes/processes-middleware-service.ts
new file mode 100644 (file)
index 0000000..3154e1a
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import {
+    DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta, getDataExplorerColumnFilters, getOrder
+} from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
+import { BoundDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { updateResources } from 'store/resources/resources-actions';
+import { ListArguments } from 'services/common-service/common-service';
+import { ProcessResource } from 'models/process';
+import { FilterBuilder, joinFilters } from 'services/api/filter-builder';
+import { DataColumns } from 'components/data-table/data-table';
+import { ProcessStatusFilter, buildProcessStatusFilters } from '../resource-type-filters/resource-type-filters';
+import { ContainerRequestResource, containerRequestFieldsNoMounts } from 'models/container-request';
+import { progressIndicatorActions } from '../progress-indicator/progress-indicator-actions';
+import { loadMissingProcessesInformation } from '../project-panel/project-panel-middleware-service';
+
+export class ProcessesMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, private actions: BoundDataExplorerActions, id: string) {
+        super(id);
+    }
+
+    getFilters(api: MiddlewareAPI<Dispatch, RootState>, dataExplorer: DataExplorer): string | null {
+        const columns = dataExplorer.columns as DataColumns<string, ContainerRequestResource>;
+        const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
+        const activeStatusFilter = Object.keys(statusColumnFilters).find(
+            filterName => statusColumnFilters[filterName].selected
+        ) || ProcessStatusFilter.ALL;
+
+        const nameFilter = new FilterBuilder().addILike("name", dataExplorer.searchValue).getFilters();
+        const statusFilter = buildProcessStatusFilters(new FilterBuilder(), activeStatusFilter).getFilters();
+
+        return joinFilters(
+            nameFilter,
+            statusFilter,
+        );
+    }
+
+    getParams(api: MiddlewareAPI<Dispatch, RootState>, dataExplorer: DataExplorer): ListArguments | null {
+        const filters = this.getFilters(api, dataExplorer)
+        if (filters === null) {
+            return null;
+        }
+        return {
+            ...dataExplorerToListParams(dataExplorer),
+            order: getOrder<ProcessResource>(dataExplorer),
+            filters
+        };
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean, background?: boolean) {
+        const state = api.getState();
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+
+        try {
+            if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); }
+
+            const params = this.getParams(api, dataExplorer);
+
+            if (params !== null) {
+                const containerRequests = await this.services.containerRequestService.list(
+                    {
+                        ...this.getParams(api, dataExplorer),
+                        select: containerRequestFieldsNoMounts
+                    });
+                api.dispatch(updateResources(containerRequests.items));
+                await api.dispatch<any>(loadMissingProcessesInformation(containerRequests.items));
+                api.dispatch(this.actions.SET_ITEMS({
+                    ...listResultsToDataExplorerItemsMeta(containerRequests),
+                    items: containerRequests.items.map(resource => resource.uuid),
+                }));
+            } else {
+                api.dispatch(this.actions.SET_ITEMS({
+                    itemsAvailable: 0,
+                    page: 0,
+                    rowsPerPage: dataExplorer.rowsPerPage,
+                    items: [],
+                }));
+            }
+            if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
+        } catch {
+            api.dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'Could not fetch process list.',
+                kind: SnackbarKind.ERROR
+            }));
+            if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
+        }
+    }
+}
diff --git a/services/workbench2/src/store/progress-indicator/progress-indicator-actions.ts b/services/workbench2/src/store/progress-indicator/progress-indicator-actions.ts
new file mode 100644 (file)
index 0000000..8019c90
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+
+export const progressIndicatorActions = unionize({
+    START_WORKING: ofType<string>(),
+    STOP_WORKING: ofType<string>(),
+    PERSIST_STOP_WORKING: ofType<string>(),
+    TOGGLE_WORKING: ofType<{ id: string, working: boolean }>()
+});
+
+export type ProgressIndicatorAction = UnionOf<typeof progressIndicatorActions>;
diff --git a/services/workbench2/src/store/progress-indicator/progress-indicator-reducer.ts b/services/workbench2/src/store/progress-indicator/progress-indicator-reducer.ts
new file mode 100644 (file)
index 0000000..67a06b8
--- /dev/null
@@ -0,0 +1,40 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ProgressIndicatorAction, progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+
+export type ProgressIndicatorState = { id: string, working: boolean }[];
+
+const initialState: ProgressIndicatorState = [];
+
+export const progressIndicatorReducer = (state: ProgressIndicatorState = initialState, action: ProgressIndicatorAction) => {
+    const stopWorking = (id: string) => state.filter(p => p.id !== id);
+
+    return progressIndicatorActions.match(action, {
+        START_WORKING: id => startWorking(id, state),
+        STOP_WORKING: id => stopWorking(id),
+        PERSIST_STOP_WORKING: id => state.map(p => ({
+            ...p,
+            working: p.id === id ? false : p.working
+        })),
+        TOGGLE_WORKING: ({ id, working }) => working ? startWorking(id, state) : stopWorking(id),
+        default: () => state,
+    });
+};
+
+const startWorking = (id: string, state: ProgressIndicatorState) => {
+    return getProgressIndicator(id)(state)
+        ? state.map(indicator => indicator.id === id
+            ? { ...indicator, working: true }
+            : indicator)
+        : state.concat({ id, working: true });
+};
+
+export function isSystemWorking(state: ProgressIndicatorState): boolean {
+    return state.length > 0 && state.reduce((working, p) => working ? true : p.working, false);
+}
+
+export const getProgressIndicator = (id: string) =>
+    (state: ProgressIndicatorState) =>
+        state.find(state => state.id === id);
diff --git a/services/workbench2/src/store/progress-indicator/with-progress.ts b/services/workbench2/src/store/progress-indicator/with-progress.ts
new file mode 100644 (file)
index 0000000..2d089fc
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+
+export type WithProgressStateProps = {
+    working: boolean;
+};
+
+export const withProgress = (id: string) =>
+    (component: React.ComponentType<WithProgressStateProps>) =>
+        connect(mapStateToProps(id))(component);
+
+export const mapStateToProps = (id: string) => (state: RootState): WithProgressStateProps => {
+    const progress = state.progressIndicator.find(p => p.id === id);
+    return { working: progress ? progress.working : false };
+};
diff --git a/services/workbench2/src/store/project-panel/project-panel-action-bind.ts b/services/workbench2/src/store/project-panel/project-panel-action-bind.ts
new file mode 100644 (file)
index 0000000..31a5f8d
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+
+const PROJECT_PANEL_ID = "projectPanel";
+
+export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID);
diff --git a/services/workbench2/src/store/project-panel/project-panel-action.ts b/services/workbench2/src/store/project-panel/project-panel-action.ts
new file mode 100644 (file)
index 0000000..305799e
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { propertiesActions } from "store/properties/properties-actions";
+import { RootState } from "store/store";
+import { getProperty } from "store/properties/properties";
+import { loadProject } from "store/workbench/workbench-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+
+export const PROJECT_PANEL_ID = "projectPanel";
+export const PROJECT_PANEL_CURRENT_UUID = "projectPanelCurrentUuid";
+export const IS_PROJECT_PANEL_TRASHED = "isProjectPanelTrashed";
+
+export const openProjectPanel = (projectUuid: string) => async (dispatch: Dispatch) => {
+    await dispatch<any>(loadProject(projectUuid));
+    dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid }));
+    dispatch(projectPanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+};
+
+export const getProjectPanelCurrentUuid = (state: RootState) => getProperty<string>(PROJECT_PANEL_CURRENT_UUID)(state.properties);
+
+export const setIsProjectPanelTrashed = (isTrashed: boolean) => propertiesActions.SET_PROPERTY({ key: IS_PROJECT_PANEL_TRASHED, value: isTrashed });
diff --git a/services/workbench2/src/store/project-panel/project-panel-middleware-service.ts b/services/workbench2/src/store/project-panel/project-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..e8d03df
--- /dev/null
@@ -0,0 +1,170 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import {
+    DataExplorerMiddlewareService,
+    dataExplorerToListParams,
+    getDataExplorerColumnFilters,
+    listResultsToDataExplorerItemsMeta,
+} from "store/data-explorer/data-explorer-middleware-service";
+import { ProjectPanelColumnNames } from "views/project-panel/project-panel";
+import { RootState } from "store/store";
+import { DataColumns } from "components/data-table/data-table";
+import { ServiceRepository } from "services/services";
+import { SortDirection } from "components/data-table/data-column";
+import { OrderBuilder, OrderDirection } from "services/api/order-builder";
+import { FilterBuilder, joinFilters } from "services/api/filter-builder";
+import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service";
+import { updateFavorites } from "store/favorites/favorites-actions";
+import { IS_PROJECT_PANEL_TRASHED, getProjectPanelCurrentUuid } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { Dispatch, MiddlewareAPI } from "redux";
+import { ProjectResource } from "models/project";
+import { updateResources } from "store/resources/resources-actions";
+import { getProperty } from "store/properties/properties";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { DataExplorer, getDataExplorer } from "store/data-explorer/data-explorer-reducer";
+import { ListResults } from "services/common-service/common-service";
+import { loadContainers } from "store/processes/processes-actions";
+import { ResourceKind } from "models/resource";
+import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
+import { serializeResourceTypeFilters, buildProcessStatusFilters } from "store/resource-type-filters/resource-type-filters";
+import { updatePublicFavorites } from "store/public-favorites/public-favorites-actions";
+import { selectedFieldsOfGroup } from "models/group";
+import { defaultCollectionSelectedFields } from "models/collection";
+import { containerRequestFieldsNoMounts } from "models/container-request";
+import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set"; 
+import { removeDisabledButton } from "store/multiselect/multiselect-actions";
+import { dataExplorerActions } from "store/data-explorer/data-explorer-action";
+
+export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean, background?: boolean) {
+        const state = api.getState();
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+        const projectUuid = getProjectPanelCurrentUuid(state);
+        const isProjectTrashed = getProperty<string>(IS_PROJECT_PANEL_TRASHED)(state.properties);
+        if (!projectUuid) {
+            api.dispatch(projectPanelCurrentUuidIsNotSet());
+        } else if (!dataExplorer) {
+            api.dispatch(projectPanelDataExplorerIsNotSet());
+        } else {
+            try {
+                api.dispatch<any>(dataExplorerActions.SET_IS_NOT_FOUND({ id: this.id, isNotFound: false }));
+                if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); }
+                const response = await this.services.groupsService.contents(projectUuid, getParams(dataExplorer, !!isProjectTrashed));
+                const resourceUuids = response.items.map(item => item.uuid);
+                api.dispatch<any>(updateFavorites(resourceUuids));
+                api.dispatch<any>(updatePublicFavorites(resourceUuids));
+                api.dispatch(updateResources(response.items));
+                await api.dispatch<any>(loadMissingProcessesInformation(response.items));
+                api.dispatch(setItems(response));
+            } catch (e) {
+                api.dispatch(
+                    projectPanelActions.SET_ITEMS({
+                        items: [],
+                        itemsAvailable: 0,
+                        page: 0,
+                        rowsPerPage: dataExplorer.rowsPerPage,
+                    })
+                );
+                if (e.status === 404) {
+                    api.dispatch<any>(dataExplorerActions.SET_IS_NOT_FOUND({ id: this.id, isNotFound: true}));
+                }
+                else {
+                    api.dispatch(couldNotFetchProjectContents());
+                }
+            } finally {
+                if (!background) { 
+                    api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                    api.dispatch<any>(removeDisabledButton(ContextMenuActionNames.MOVE_TO_TRASH))
+                }
+            }
+        }
+    }
+}
+
+export const loadMissingProcessesInformation = (resources: GroupContentsResource[]) => async (dispatch: Dispatch) => {
+    const containerUuids = resources.reduce((uuids, resource) => {
+        return resource.kind === ResourceKind.CONTAINER_REQUEST && resource.containerUuid && !uuids.includes(resource.containerUuid)
+            ? [...uuids, resource.containerUuid]
+            : uuids;
+    }, [] as string[]);
+    if (containerUuids.length > 0) {
+        await dispatch<any>(loadContainers(containerUuids, false));
+    }
+};
+
+export const setItems = (listResults: ListResults<GroupContentsResource>) =>
+    projectPanelActions.SET_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
+
+export const getParams = (dataExplorer: DataExplorer, isProjectTrashed: boolean) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    order: getOrder(dataExplorer),
+    filters: getFilters(dataExplorer),
+    includeTrash: isProjectTrashed,
+    select: selectedFieldsOfGroup.concat(defaultCollectionSelectedFields, containerRequestFieldsNoMounts),
+});
+
+export const getFilters = (dataExplorer: DataExplorer) => {
+    const columns = dataExplorer.columns as DataColumns<string, ProjectResource>;
+    const typeFilters = serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
+    const statusColumnFilters = getDataExplorerColumnFilters(columns, "Status");
+    const activeStatusFilter = Object.keys(statusColumnFilters).find(filterName => statusColumnFilters[filterName].selected);
+
+    // TODO: Extract group contents name filter
+    const nameFilters = new FilterBuilder()
+        .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION)
+        .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROCESS)
+        .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT)
+        .getFilters();
+
+    // Filter by container status
+    const statusFilters = buildProcessStatusFilters(new FilterBuilder(), activeStatusFilter || "", GroupContentsResourcePrefix.PROCESS).getFilters();
+
+    return joinFilters(statusFilters, typeFilters, nameFilters);
+};
+
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<ProjectResource>(dataExplorer);
+    const order = new OrderBuilder<ProjectResource>();
+    if (sortColumn && sortColumn.sort) {
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC;
+
+        // Use createdAt as a secondary sort column so we break ties consistently.
+        return order
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS)
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
+            .getOrder();
+    } else {
+        return order.getOrder();
+    }
+};
+
+const projectPanelCurrentUuidIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: "Project panel is not opened.",
+        kind: SnackbarKind.ERROR,
+    });
+
+const couldNotFetchProjectContents = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: "Could not fetch project contents.",
+        kind: SnackbarKind.ERROR,
+    });
+
+const projectPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: "Project panel is not ready.",
+        kind: SnackbarKind.ERROR,
+    });
diff --git a/services/workbench2/src/store/project-tree-picker/project-tree-picker-actions.ts b/services/workbench2/src/store/project-tree-picker/project-tree-picker-actions.ts
new file mode 100644 (file)
index 0000000..2e98073
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository } from "services/services";
+import { mockProjectResource } from "models/test-utils";
+import { treePickerActions, receiveTreePickerProjectsData } from "store/tree-picker/tree-picker-actions";
+import { TreePickerId } from 'models/tree';
+
+export const resetPickerProjectTree = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(treePickerActions.RESET_TREE_PICKER({ pickerId: TreePickerId.PROJECTS }));
+    dispatch<any>(treePickerActions.RESET_TREE_PICKER({ pickerId: TreePickerId.SHARED_WITH_ME }));
+    dispatch<any>(treePickerActions.RESET_TREE_PICKER({ pickerId: TreePickerId.FAVORITES }));
+
+    dispatch<any>(initPickerProjectTree());
+};
+
+export const initPickerProjectTree = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const uuid = getUserUuid(getState());
+    if (!uuid) { return; }
+    dispatch<any>(getPickerTreeProjects(uuid));
+    dispatch<any>(getSharedWithMeProjectsPickerTree(uuid));
+    dispatch<any>(getFavoritesProjectsPickerTree(uuid));
+};
+
+const getPickerTreeProjects = (uuid: string = '') => {
+    return getProjectsPickerTree(uuid, TreePickerId.PROJECTS);
+};
+
+const getSharedWithMeProjectsPickerTree = (uuid: string = '') => {
+    return getProjectsPickerTree(uuid, TreePickerId.SHARED_WITH_ME);
+};
+
+const getFavoritesProjectsPickerTree = (uuid: string = '') => {
+    return getProjectsPickerTree(uuid, TreePickerId.FAVORITES);
+};
+
+const getProjectsPickerTree = (uuid: string, kind: string) => {
+    return receiveTreePickerProjectsData(
+        '',
+        [mockProjectResource({ uuid, name: kind })],
+        kind
+    );
+};
diff --git a/services/workbench2/src/store/projects/project-create-actions.ts b/services/workbench2/src/store/projects/project-create-actions.ts
new file mode 100644 (file)
index 0000000..c15c374
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import {
+    reset,
+    startSubmit,
+    stopSubmit,
+    initialize,
+    FormErrors,
+    formValueSelector
+} from 'redux-form';
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { getCommonResourceServiceError, CommonResourceServiceError } from 'services/common-service/common-resource-service';
+import { ProjectResource } from 'models/project';
+import { ServiceRepository } from 'services/services';
+import { matchProjectRoute, matchRunProcessRoute } from 'routes/routes';
+import { RouterState } from "react-router-redux";
+import { GroupClass } from "models/group";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+
+export interface ProjectCreateFormDialogData {
+    ownerUuid: string;
+    name: string;
+    description: string;
+    properties: ProjectProperties;
+}
+
+export interface ProjectProperties {
+    [key: string]: string | string[];
+}
+
+export const PROJECT_CREATE_FORM_NAME = 'projectCreateFormName';
+export const PROJECT_CREATE_PROPERTIES_FORM_NAME = 'projectCreatePropertiesFormName';
+export const PROJECT_CREATE_FORM_SELECTOR = formValueSelector(PROJECT_CREATE_FORM_NAME);
+
+export const isProjectOrRunProcessRoute = (router: RouterState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const matchProject = matchProjectRoute(pathname);
+    const matchRunProcess = matchRunProcessRoute(pathname);
+    return Boolean(matchProject || matchRunProcess);
+};
+
+export const openProjectCreateDialog = (ownerUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { router } = getState();
+        if (!isProjectOrRunProcessRoute(router)) {
+            const userUuid = getUserUuid(getState());
+            if (!userUuid) { return; }
+            dispatch(initialize(PROJECT_CREATE_FORM_NAME, { ownerUuid: userUuid }));
+        } else {
+            dispatch(initialize(PROJECT_CREATE_FORM_NAME, { ownerUuid }));
+        }
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: PROJECT_CREATE_FORM_NAME,
+            data: {
+                sourcePanel: GroupClass.PROJECT,
+            }
+        }));
+    };
+
+export const createProject = (project: Partial<ProjectResource>) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(PROJECT_CREATE_FORM_NAME));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(PROJECT_CREATE_FORM_NAME));
+            const newProject = await services.projectService.create(project, false);
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME }));
+            dispatch(reset(PROJECT_CREATE_FORM_NAME));
+            return newProject;
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME, { name: 'Project with the same name already exists.' } as FormErrors));
+            } else {
+                dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME));
+                dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME }));
+                const errMsg = e.errors
+                    ? e.errors.join('')
+                    : 'There was an error while creating the collection';
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: errMsg,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR
+                }));
+            }
+            return undefined;
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(PROJECT_CREATE_FORM_NAME));
+        }
+    };
diff --git a/services/workbench2/src/store/projects/project-lock-actions.ts b/services/workbench2/src/store/projects/project-lock-actions.ts
new file mode 100644 (file)
index 0000000..cd72e35
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { ServiceRepository } from "services/services";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { loadResource } from "store/resources/resources-actions";
+import { RootState } from "store/store";
+import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set"; 
+import { addDisabledButton, removeDisabledButton } from "store/multiselect/multiselect-actions";
+
+export const freezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(addDisabledButton(ContextMenuActionNames.FREEZE_PROJECT))
+    const userUUID = getState().auth.user!.uuid;
+    
+    const updatedProject = await services.projectService.update(uuid, {
+        frozenByUuid: userUUID,
+    });
+    
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+    dispatch<any>(loadResource(uuid, false));
+    dispatch<any>(removeDisabledButton(ContextMenuActionNames.FREEZE_PROJECT))
+    return updatedProject;
+};
+
+export const unfreezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(addDisabledButton(ContextMenuActionNames.FREEZE_PROJECT))
+    const updatedProject = await services.projectService.update(uuid, {
+        frozenByUuid: null,
+    });
+
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+    dispatch<any>(loadResource(uuid, false));
+    dispatch<any>(removeDisabledButton(ContextMenuActionNames.FREEZE_PROJECT))
+    return updatedProject;
+};
diff --git a/services/workbench2/src/store/projects/project-move-actions.ts b/services/workbench2/src/store/projects/project-move-actions.ts
new file mode 100644 (file)
index 0000000..97cd5db
--- /dev/null
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { startSubmit, stopSubmit, initialize, FormErrors } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
+import { getUserUuid } from "common/getuser";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { loadSidePanelTreeProjects } from "../side-panel-tree/side-panel-tree-actions";
+
+export const PROJECT_MOVE_FORM_NAME = "projectMoveFormName";
+
+export const openMoveProjectDialog = (resource: any) => {
+    return (dispatch: Dispatch) => {
+        dispatch<any>(resetPickerProjectTree());
+        dispatch<any>(initProjectsTreePicker(PROJECT_MOVE_FORM_NAME));
+        dispatch(initialize(PROJECT_MOVE_FORM_NAME, resource));
+        dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_MOVE_FORM_NAME, data: {} }));
+    };
+};
+
+export const moveProject = (resource: MoveToFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const userUuid = getUserUuid(getState());
+    if (!userUuid) {
+        return;
+    }
+    dispatch(startSubmit(PROJECT_MOVE_FORM_NAME));
+    try {
+        const newProject = await services.projectService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
+        dispatch(projectPanelActions.REQUEST_ITEMS());
+
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
+        await dispatch<any>(loadSidePanelTreeProjects(userUuid));
+        return newProject;
+    } catch (e) {
+        const error = getCommonResourceServiceError(e);
+        if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+            dispatch(
+                stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: "A project with the same name already exists in the target project." } as FormErrors)
+            );
+        } else if (error === CommonResourceServiceError.OWNERSHIP_CYCLE) {
+            dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: "Cannot move a project into itself." } as FormErrors));
+        } else {
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
+            throw new Error("Could not move the project.");
+        }
+        return;
+    }
+};
diff --git a/services/workbench2/src/store/projects/project-update-actions.ts b/services/workbench2/src/store/projects/project-update-actions.ts
new file mode 100644 (file)
index 0000000..8124903
--- /dev/null
@@ -0,0 +1,80 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { FormErrors, formValueSelector, initialize, reset, startSubmit, stopSubmit } from "redux-form";
+import { RootState } from "store/store";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { ServiceRepository } from "services/services";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { GroupClass } from "models/group";
+import { Participant } from "views-components/sharing-dialog/participant-select";
+import { ProjectProperties } from "./project-create-actions";
+import { getResource } from "store/resources/resources";
+import { ProjectResource } from "models/project";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+
+export interface ProjectUpdateFormDialogData {
+    uuid: string;
+    name: string;
+    users?: Participant[];
+    description?: string;
+    properties?: ProjectProperties;
+}
+
+export const PROJECT_UPDATE_FORM_NAME = "projectUpdateFormName";
+export const PROJECT_UPDATE_PROPERTIES_FORM_NAME = "projectUpdatePropertiesFormName";
+export const PROJECT_UPDATE_FORM_SELECTOR = formValueSelector(PROJECT_UPDATE_FORM_NAME);
+
+export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) => (dispatch: Dispatch, getState: () => RootState) => {
+    // Get complete project resource from store to handle consumers passing in partial resources
+    const project = getResource<ProjectResource>(resource.uuid)(getState().resources);
+    dispatch(initialize(PROJECT_UPDATE_FORM_NAME, project));
+    dispatch(
+        dialogActions.OPEN_DIALOG({
+            id: PROJECT_UPDATE_FORM_NAME,
+            data: {
+                sourcePanel: GroupClass.PROJECT,
+            },
+        })
+    );
+};
+
+export const updateProject =
+    (project: ProjectUpdateFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuid = project.uuid || "";
+        dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME));
+        try {
+            const updatedProject = await services.projectService.update(
+                uuid,
+                {
+                    name: project.name,
+                    description: project.description,
+                    properties: project.properties,
+                },
+                false
+            );
+            dispatch(projectPanelActions.REQUEST_ITEMS());
+            dispatch(reset(PROJECT_UPDATE_FORM_NAME));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
+            return updatedProject;
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: "Project with the same name already exists." } as FormErrors));
+            } else {
+                dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
+                const errMsg = e.errors ? e.errors.join("") : "There was an error while updating the project";
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: errMsg,
+                        hideDuration: 2000,
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
+            }
+            return;
+        }
+    };
diff --git a/services/workbench2/src/store/properties/properties-actions.ts b/services/workbench2/src/store/properties/properties-actions.ts
new file mode 100644 (file)
index 0000000..99f3f50
--- /dev/null
@@ -0,0 +1,12 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from 'common/unionize';
+
+export const propertiesActions = unionize({
+    SET_PROPERTY: ofType<{ key: string, value: any }>(),
+    DELETE_PROPERTY: ofType<string>(),
+});
+
+export type PropertiesAction = UnionOf<typeof propertiesActions>;
diff --git a/services/workbench2/src/store/properties/properties-reducer.ts b/services/workbench2/src/store/properties/properties-reducer.ts
new file mode 100644 (file)
index 0000000..27fdf85
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PropertiesState, setProperty, deleteProperty } from './properties';
+import { PropertiesAction, propertiesActions } from './properties-actions';
+
+
+export const propertiesReducer = (state: PropertiesState = {}, action: PropertiesAction) =>
+    propertiesActions.match(action, {
+        SET_PROPERTY: ({ key, value }) => setProperty(key, value)(state),
+        DELETE_PROPERTY: key => deleteProperty(key)(state),
+        default: () => state,
+    });
\ No newline at end of file
diff --git a/services/workbench2/src/store/properties/properties.ts b/services/workbench2/src/store/properties/properties.ts
new file mode 100644 (file)
index 0000000..bee5b19
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type PropertiesState = { [key: string]: any };
+
+export const getProperty = <T>(id: string) =>
+    (state: PropertiesState): T | undefined =>
+        state[id];
+
+export const setProperty = <T>(id: string, data: T) =>
+    (state: PropertiesState) => ({
+        ...state,
+        [id]: data
+    });
+
+export const deleteProperty = (id: string) =>
+    (state: PropertiesState) => {
+        const newState = { ...state };
+        delete newState[id];
+        return newState;
+    };
+
diff --git a/services/workbench2/src/store/public-favorites-panel/public-favorites-action.ts b/services/workbench2/src/store/public-favorites-panel/public-favorites-action.ts
new file mode 100644 (file)
index 0000000..6e36e1f
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+
+export const PUBLIC_FAVORITE_PANEL_ID = "publicFavoritePanel";
+export const publicFavoritePanelActions = bindDataExplorerActions(PUBLIC_FAVORITE_PANEL_ID);
+
+export const loadPublicFavoritePanel = () => (dispatch: Dispatch) => {
+    dispatch(publicFavoritePanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
+};
\ No newline at end of file
diff --git a/services/workbench2/src/store/public-favorites-panel/public-favorites-middleware-service.ts b/services/workbench2/src/store/public-favorites-panel/public-favorites-middleware-service.ts
new file mode 100644 (file)
index 0000000..48d27be
--- /dev/null
@@ -0,0 +1,112 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { DataExplorerMiddlewareService, getDataExplorerColumnFilters } from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
+import { resourcesActions } from 'store/resources/resources-actions';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { FavoritePanelColumnNames } from 'views/favorite-panel/favorite-panel';
+import { publicFavoritePanelActions } from 'store/public-favorites-panel/public-favorites-action';
+import { DataColumns } from 'components/data-table/data-table';
+import { serializeSimpleObjectTypeFilters } from '../resource-type-filters/resource-type-filters';
+import { LinkClass } from 'models/link';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+
+export class PublicFavoritesMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
+        if (!dataExplorer) {
+            api.dispatch(favoritesPanelDataExplorerIsNotSet());
+        } else {
+            const columns = dataExplorer.columns as DataColumns<string, GroupContentsResource>;
+            const typeFilters = serializeSimpleObjectTypeFilters(getDataExplorerColumnFilters(columns, FavoritePanelColumnNames.TYPE));
+
+            try {
+                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                const uuidPrefix = api.getState().auth.config.uuidPrefix;
+                const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
+                const responseLinks = await this.services.linkService.list({
+                    limit: dataExplorer.rowsPerPage,
+                    offset: dataExplorer.page * dataExplorer.rowsPerPage,
+                    filters: new FilterBuilder()
+                        .addEqual('link_class', LinkClass.STAR)
+                        .addEqual('owner_uuid', publicProjectUuid)
+                        .addIsA("head_uuid", typeFilters)
+                        .getFilters()
+                });
+                const uuids = responseLinks.items.map(it => it.headUuid);
+                const groupItems: any = await this.services.groupsService.list({
+                    filters: new FilterBuilder()
+                        .addIn("uuid", uuids)
+                        .addILike("name", dataExplorer.searchValue)
+                        .addIsA("uuid", typeFilters)
+                        .getFilters()
+                });
+                const collectionItems: any = await this.services.collectionService.list({
+                    filters: new FilterBuilder()
+                        .addIn("uuid", uuids)
+                        .addILike("name", dataExplorer.searchValue)
+                        .addIsA("uuid", typeFilters)
+                        .getFilters()
+                });
+                const processItems: any = await this.services.containerRequestService.list({
+                    filters: new FilterBuilder()
+                        .addIn("uuid", uuids)
+                        .addILike("name", dataExplorer.searchValue)
+                        .addIsA("uuid", typeFilters)
+                        .getFilters()
+                });
+                const response = groupItems;
+                collectionItems.items.forEach((it: any) => {
+                    response.itemsAvailable++;
+                    response.items.push(it);
+                });
+                processItems.items.forEach((it: any) => {
+                    response.itemsAvailable++;
+                    response.items.push(it);
+                });
+                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                api.dispatch(resourcesActions.SET_RESOURCES(response.items));
+                api.dispatch(publicFavoritePanelActions.SET_ITEMS({
+                    items: response.items.map((resource: any) => resource.uuid),
+                    itemsAvailable: response.itemsAvailable,
+                    page: Math.floor(response.offset / response.limit),
+                    rowsPerPage: response.limit
+                }));
+                api.dispatch<any>(updatePublicFavorites(response.items.map((item: any) => item.uuid)));
+            } catch (e) {
+                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                api.dispatch(publicFavoritePanelActions.SET_ITEMS({
+                    items: [],
+                    itemsAvailable: 0,
+                    page: 0,
+                    rowsPerPage: dataExplorer.rowsPerPage
+                }));
+                api.dispatch(couldNotFetchPublicFavorites());
+            }
+        }
+    }
+}
+
+const favoritesPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Favorites panel is not ready.',
+        kind: SnackbarKind.ERROR
+    });
+
+const couldNotFetchPublicFavorites = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch public favorites contents.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/public-favorites/public-favorites-actions.ts b/services/workbench2/src/store/public-favorites/public-favorites-actions.ts
new file mode 100644 (file)
index 0000000..b9915db
--- /dev/null
@@ -0,0 +1,80 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { Dispatch } from "redux";
+import { RootState } from "../store";
+import { checkPublicFavorite } from "./public-favorites-reducer";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { ServiceRepository } from "services/services";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { addDisabledButton, removeDisabledButton } from "store/multiselect/multiselect-actions";
+import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { loadPublicFavoritesTree } from "store/side-panel-tree/side-panel-tree-actions";
+
+export const publicFavoritesActions = unionize({
+    TOGGLE_PUBLIC_FAVORITE: ofType<{ resourceUuid: string }>(),
+    CHECK_PRESENCE_IN_PUBLIC_FAVORITES: ofType<string[]>(),
+    UPDATE_PUBLIC_FAVORITES: ofType<Record<string, boolean>>()
+});
+
+export type PublicFavoritesAction = UnionOf<typeof publicFavoritesActions>;
+
+export const togglePublicFavorite = (resource: { uuid: string; name: string }) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+        dispatch(progressIndicatorActions.START_WORKING("togglePublicFavorite"));
+        dispatch<any>(addDisabledButton(ContextMenuActionNames.ADD_TO_PUBLIC_FAVORITES))
+        const uuidPrefix = getState().auth.config.uuidPrefix;
+        const uuid = `${uuidPrefix}-j7d0g-publicfavorites`;
+        dispatch(publicFavoritesActions.TOGGLE_PUBLIC_FAVORITE({ resourceUuid: resource.uuid }));
+        const isPublicFavorite = checkPublicFavorite(resource.uuid, getState().publicFavorites);
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: isPublicFavorite
+                ? "Removing from public favorites..."
+                : "Adding to public favorites...",
+            kind: SnackbarKind.INFO
+        }));
+
+        const promise: any = isPublicFavorite
+            ? services.favoriteService.delete({ userUuid: uuid, resourceUuid: resource.uuid })
+            : services.favoriteService.create({ userUuid: uuid, resource });
+
+        return promise
+            .then(() => {
+                dispatch(publicFavoritesActions.UPDATE_PUBLIC_FAVORITES({ [resource.uuid]: !isPublicFavorite }));
+                dispatch(snackbarActions.CLOSE_SNACKBAR());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: isPublicFavorite
+                        ? "Removed from public favorites"
+                        : "Added to public favorites",
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+                dispatch<any>(removeDisabledButton(ContextMenuActionNames.ADD_TO_PUBLIC_FAVORITES))
+                dispatch(progressIndicatorActions.STOP_WORKING("togglePublicFavorite"));
+                dispatch<any>(loadPublicFavoritesTree())
+            })
+            .catch((e: any) => {
+                dispatch(progressIndicatorActions.STOP_WORKING("togglePublicFavorite"));
+                throw e;
+            });
+    };
+
+export const updatePublicFavorites = (resourceUuids: string[]) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuidPrefix = getState().auth.config.uuidPrefix;
+        const uuid = `${uuidPrefix}-j7d0g-publicfavorites`;
+        dispatch(publicFavoritesActions.CHECK_PRESENCE_IN_PUBLIC_FAVORITES(resourceUuids));
+        services.favoriteService
+            .checkPresenceInFavorites(uuid, resourceUuids)
+            .then((results: any) => {
+                dispatch(publicFavoritesActions.UPDATE_PUBLIC_FAVORITES(results));
+            });
+    };
+
+export const getIsAdmin = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const resource = getState().auth.user!.isAdmin;
+        return resource;
+    };
diff --git a/services/workbench2/src/store/public-favorites/public-favorites-reducer.ts b/services/workbench2/src/store/public-favorites/public-favorites-reducer.ts
new file mode 100644 (file)
index 0000000..cbc7ade
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PublicFavoritesAction, publicFavoritesActions } from "./public-favorites-actions";
+
+export type PublicFavoritesState = Record<string, boolean>;
+
+export const publicFavoritesReducer = (state: PublicFavoritesState = {}, action: PublicFavoritesAction) =>
+    publicFavoritesActions.match(action, {
+        UPDATE_PUBLIC_FAVORITES: publicFavorites => ({ ...state, ...publicFavorites }),
+        default: () => state
+    });
+
+export const checkPublicFavorite = (uuid: string, state: PublicFavoritesState) => state[uuid] === true;
\ No newline at end of file
diff --git a/services/workbench2/src/store/repositories/repositories-actions.ts b/services/workbench2/src/store/repositories/repositories-actions.ts
new file mode 100644 (file)
index 0000000..80ba12f
--- /dev/null
@@ -0,0 +1,110 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository } from "services/services";
+import { navigateToRepositories } from "store/navigation/navigation-action";
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { RepositoryResource } from "models/repositories";
+import { startSubmit, reset, stopSubmit, FormErrors } from "redux-form";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+
+export const repositoriesActions = unionize({
+    SET_REPOSITORIES: ofType<any>(),
+});
+
+export type RepositoriesActions = UnionOf<typeof repositoriesActions>;
+
+export const REPOSITORIES_PANEL = 'repositoriesPanel';
+export const REPOSITORIES_SAMPLE_GIT_DIALOG = 'repositoriesSampleGitDialog';
+export const REPOSITORY_ATTRIBUTES_DIALOG = 'repositoryAttributesDialog';
+export const REPOSITORY_CREATE_FORM_NAME = 'repositoryCreateFormName';
+export const REPOSITORY_REMOVE_DIALOG = 'repositoryRemoveDialog';
+
+export const openRepositoriesSampleGitDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuidPrefix = getState().properties.uuidPrefix;
+        dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORIES_SAMPLE_GIT_DIALOG, data: { uuidPrefix } }));
+    };
+
+export const openRepositoryAttributes = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const repositoryData = getState().repositories.items.find(it => it.uuid === uuid);
+        dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORY_ATTRIBUTES_DIALOG, data: { repositoryData } }));
+    };
+
+export const openRepositoryCreateDialog = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const user = await services.userService.get(userUuid!);
+        dispatch(reset(REPOSITORY_CREATE_FORM_NAME));
+        dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORY_CREATE_FORM_NAME, data: { user } }));
+    };
+
+export const createRepository = (repository: RepositoryResource) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const user = await services.userService.get(userUuid!);
+        dispatch(startSubmit(REPOSITORY_CREATE_FORM_NAME));
+        try {
+            const newRepository = await services.repositoriesService.create({ name: `${user.username}/${repository.name}` });
+            dispatch(dialogActions.CLOSE_DIALOG({ id: REPOSITORY_CREATE_FORM_NAME }));
+            dispatch(reset(REPOSITORY_CREATE_FORM_NAME));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Repository has been successfully created.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            dispatch<any>(loadRepositoriesData());
+            return newRepository;
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.NAME_HAS_ALREADY_BEEN_TAKEN) {
+                dispatch(stopSubmit(REPOSITORY_CREATE_FORM_NAME, { name: 'Repository with the same name already exists.' } as FormErrors));
+            }
+            return undefined;
+        }
+    };
+
+export const openRemoveRepositoryDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: REPOSITORY_REMOVE_DIALOG,
+            data: {
+                title: 'Remove repository',
+                text: 'Are you sure you want to remove this repository?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeRepository = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        await services.repositoriesService.delete(uuid);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        dispatch<any>(loadRepositoriesData());
+    };
+
+const repositoriesBindedActions = bindDataExplorerActions(REPOSITORIES_PANEL);
+
+export const openRepositoriesPanel = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(navigateToRepositories);
+    };
+
+export const loadRepositoriesData = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const repositories = await services.repositoriesService.list();
+        dispatch(repositoriesActions.SET_REPOSITORIES(repositories.items));
+    };
+
+export const loadRepositoriesPanel = () =>
+    (dispatch: Dispatch) => {
+        dispatch(repositoriesBindedActions.REQUEST_ITEMS());
+    };
diff --git a/services/workbench2/src/store/repositories/repositories-reducer.ts b/services/workbench2/src/store/repositories/repositories-reducer.ts
new file mode 100644 (file)
index 0000000..cf11116
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { repositoriesActions, RepositoriesActions } from 'store/repositories/repositories-actions';
+import { RepositoryResource } from 'models/repositories';
+
+interface Repositories {
+    items: RepositoryResource[];
+}
+
+const initialState: Repositories = {
+    items: []
+};
+
+export const repositoriesReducer = (state = initialState, action: RepositoriesActions): Repositories =>
+    repositoriesActions.match(action, {
+        SET_REPOSITORIES: items => ({ ...state, items }),
+        default: () => state
+    });
\ No newline at end of file
diff --git a/services/workbench2/src/store/resource-type-filters/resource-type-filters.test.ts b/services/workbench2/src/store/resource-type-filters/resource-type-filters.test.ts
new file mode 100644 (file)
index 0000000..216a59c
--- /dev/null
@@ -0,0 +1,159 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { getInitialResourceTypeFilters, serializeResourceTypeFilters, ObjectTypeFilter, CollectionTypeFilter, ProcessTypeFilter, GroupTypeFilter, buildProcessStatusFilters, ProcessStatusFilter } from './resource-type-filters';
+import { ResourceKind } from 'models/resource';
+import { selectNode, deselectNode } from 'models/tree';
+import { pipe } from 'lodash/fp';
+import { FilterBuilder } from 'services/api/filter-builder';
+
+describe("buildProcessStatusFilters", () => {
+    [
+        [ProcessStatusFilter.ALL, ""],
+        [ProcessStatusFilter.ONHOLD, `["state","!=","Final"],["priority","=","0"],["container.state","in",["Queued","Locked"]]`],
+        [ProcessStatusFilter.COMPLETED, `["container.state","=","Complete"],["container.exit_code","=","0"]`],
+        [ProcessStatusFilter.FAILED, `["container.state","=","Complete"],["container.exit_code","!=","0"]`],
+        [ProcessStatusFilter.QUEUED, `["container.state","in",["Queued","Locked"]],["priority","!=","0"]`],
+        [ProcessStatusFilter.CANCELLED, `["container.state","=","Cancelled"]`],
+        [ProcessStatusFilter.RUNNING, `["container.state","=","Running"]`],
+    ].forEach(([status, expected]) => {
+        it(`can filter "${status}" processes`, () => {
+            const filters = buildProcessStatusFilters(new FilterBuilder(), status);
+            expect(filters.getFilters())
+                .toEqual(expected);
+        })
+    });
+});
+
+describe("serializeResourceTypeFilters", () => {
+    it("should serialize all filters", () => {
+        const filters = getInitialResourceTypeFilters();
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}","${ResourceKind.WORKFLOW}","${ResourceKind.PROCESS}"]],["collections.properties.type","not in",["log","intermediate"]],["container_requests.requesting_container_uuid","=",null]`);
+    });
+
+    it("should serialize all but collection filters", () => {
+        const filters = deselectNode(ObjectTypeFilter.COLLECTION, true)(getInitialResourceTypeFilters());
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.WORKFLOW}","${ResourceKind.PROCESS}"]],["container_requests.requesting_container_uuid","=",null]`);
+    });
+
+    it("should serialize output collections and projects", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.LOG_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION, true),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}"]],["collections.properties.type","in",["output"]]`);
+    });
+
+    it("should serialize output collections and projects", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.LOG_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION, true),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}"]],["collections.properties.type","in",["output"]]`);
+    });
+
+    it("should serialize general collections", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.PROJECT, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(CollectionTypeFilter.OUTPUT_COLLECTION, true)
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.COLLECTION}"]],["collections.properties.type","not in",["output","log","intermediate"]]`);
+    });
+
+    it("should serialize only main processes", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.PROJECT, true),
+            deselectNode(ProcessTypeFilter.CHILD_PROCESS, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROCESS}"]],["container_requests.requesting_container_uuid","=",null]`);
+    });
+
+    it("should serialize only child processes", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.PROJECT, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
+
+            selectNode(ProcessTypeFilter.CHILD_PROCESS, true),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROCESS}"]],["container_requests.requesting_container_uuid","!=",null]`);
+    });
+
+    it("should serialize all project types", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.GROUP}"]]`);
+    });
+
+    it("should serialize filter groups", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(GroupTypeFilter.PROJECT, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.GROUP}"]],["groups.group_class","=","filter"]`);
+    });
+
+    it("should serialize projects (normal)", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(GroupTypeFilter.FILTER_GROUP, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.GROUP}"]],["groups.group_class","=","project"]`);
+    });
+
+});
diff --git a/services/workbench2/src/store/resource-type-filters/resource-type-filters.ts b/services/workbench2/src/store/resource-type-filters/resource-type-filters.ts
new file mode 100644 (file)
index 0000000..791d7e8
--- /dev/null
@@ -0,0 +1,344 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { difference, pipe, values, includes, __ } from 'lodash/fp';
+import { createTree, setNode, TreeNodeStatus, TreeNode, Tree } from 'models/tree';
+import { DataTableFilterItem, DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
+import { ResourceKind } from 'models/resource';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { getSelectedNodes } from 'models/tree';
+import { CollectionType } from 'models/collection';
+import { GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
+import { ContainerState } from 'models/container';
+import { ContainerRequestState } from 'models/container-request';
+
+export enum ProcessStatusFilter {
+    ALL = 'All',
+    RUNNING = 'Running',
+    FAILED = 'Failed',
+    COMPLETED = 'Completed',
+    CANCELLED = 'Cancelled',
+    ONHOLD = 'On hold',
+    QUEUED = 'Queued'
+}
+
+export enum ObjectTypeFilter {
+    PROJECT = 'Project',
+    WORKFLOW = 'Workflow',
+    COLLECTION = 'Data collection',
+    DEFINITION = 'Definition',
+}
+
+export enum GroupTypeFilter {
+    PROJECT = 'Project (normal)',
+    FILTER_GROUP = 'Filter group',
+}
+
+export enum CollectionTypeFilter {
+    GENERAL_COLLECTION = 'General',
+    OUTPUT_COLLECTION = 'Output',
+    LOG_COLLECTION = 'Log',
+    INTERMEDIATE_COLLECTION = 'Intermediate',
+}
+
+export enum ProcessTypeFilter {
+    MAIN_PROCESS = 'Runs',
+    CHILD_PROCESS = 'Intermediate Steps',
+}
+
+const initFilter = (name: string, parent = '', isSelected?: boolean, isExpanded?: boolean) =>
+    setNode<DataTableFilterItem>({
+        id: name,
+        value: { name },
+        parent,
+        children: [],
+        active: false,
+        selected: isSelected !== undefined ? isSelected : true,
+        initialState: isSelected !== undefined ? isSelected : true,
+        expanded: isExpanded !== undefined ? isExpanded : false,
+        status: TreeNodeStatus.LOADED,
+    });
+
+export const getSimpleObjectTypeFilters = pipe(
+    (): DataTableFilters => createTree<DataTableFilterItem>(),
+    initFilter(ObjectTypeFilter.PROJECT),
+    initFilter(ObjectTypeFilter.WORKFLOW),
+    initFilter(ObjectTypeFilter.COLLECTION),
+    initFilter(ObjectTypeFilter.DEFINITION),
+);
+
+// Using pipe() with more than 7 arguments makes the return type be 'any',
+// causing compile issues.
+export const getInitialResourceTypeFilters = pipe(
+    (): DataTableFilters => createTree<DataTableFilterItem>(),
+    pipe(
+        initFilter(ObjectTypeFilter.PROJECT, '', true, true),
+        initFilter(GroupTypeFilter.PROJECT, ObjectTypeFilter.PROJECT),
+        initFilter(GroupTypeFilter.FILTER_GROUP, ObjectTypeFilter.PROJECT),
+    ),
+    pipe(
+        initFilter(ObjectTypeFilter.WORKFLOW, '', false, true),
+        initFilter(ProcessTypeFilter.MAIN_PROCESS, ObjectTypeFilter.WORKFLOW),
+        initFilter(ProcessTypeFilter.CHILD_PROCESS, ObjectTypeFilter.WORKFLOW, false),
+        initFilter(ObjectTypeFilter.DEFINITION, ObjectTypeFilter.WORKFLOW),
+    ),
+    pipe(
+        initFilter(ObjectTypeFilter.COLLECTION, '', true, true),
+        initFilter(CollectionTypeFilter.GENERAL_COLLECTION, ObjectTypeFilter.COLLECTION),
+        initFilter(CollectionTypeFilter.OUTPUT_COLLECTION, ObjectTypeFilter.COLLECTION),
+        initFilter(CollectionTypeFilter.INTERMEDIATE_COLLECTION, ObjectTypeFilter.COLLECTION, false),
+        initFilter(CollectionTypeFilter.LOG_COLLECTION, ObjectTypeFilter.COLLECTION, false),
+    ),
+
+);
+
+// Using pipe() with more than 7 arguments makes the return type be 'any',
+// causing compile issues.
+export const getInitialSearchTypeFilters = pipe(
+    (): DataTableFilters => createTree<DataTableFilterItem>(),
+    pipe(
+        initFilter(ObjectTypeFilter.PROJECT, '', true, true),
+        initFilter(GroupTypeFilter.PROJECT, ObjectTypeFilter.PROJECT),
+        initFilter(GroupTypeFilter.FILTER_GROUP, ObjectTypeFilter.PROJECT),
+    ),
+    pipe(
+        initFilter(ObjectTypeFilter.WORKFLOW, '', false, true),
+        initFilter(ProcessTypeFilter.MAIN_PROCESS, ObjectTypeFilter.WORKFLOW, false),
+        initFilter(ProcessTypeFilter.CHILD_PROCESS, ObjectTypeFilter.WORKFLOW, false),
+        initFilter(ObjectTypeFilter.DEFINITION, ObjectTypeFilter.WORKFLOW, false),
+    ),
+    pipe(
+        initFilter(ObjectTypeFilter.COLLECTION, '', true, true),
+        initFilter(CollectionTypeFilter.GENERAL_COLLECTION, ObjectTypeFilter.COLLECTION),
+        initFilter(CollectionTypeFilter.OUTPUT_COLLECTION, ObjectTypeFilter.COLLECTION),
+        initFilter(CollectionTypeFilter.INTERMEDIATE_COLLECTION, ObjectTypeFilter.COLLECTION, false),
+        initFilter(CollectionTypeFilter.LOG_COLLECTION, ObjectTypeFilter.COLLECTION, false),
+    ),
+);
+
+export const getInitialProcessTypeFilters = pipe(
+    (): DataTableFilters => createTree<DataTableFilterItem>(),
+    initFilter(ProcessTypeFilter.MAIN_PROCESS),
+    initFilter(ProcessTypeFilter.CHILD_PROCESS, '', false)
+);
+
+export const getInitialProcessStatusFilters = pipe(
+    (): DataTableFilters => createTree<DataTableFilterItem>(),
+    pipe(
+        initFilter(ProcessStatusFilter.ALL, '', true),
+        initFilter(ProcessStatusFilter.ONHOLD, '', false),
+        initFilter(ProcessStatusFilter.QUEUED, '', false),
+        initFilter(ProcessStatusFilter.RUNNING, '', false),
+        initFilter(ProcessStatusFilter.COMPLETED, '', false),
+        initFilter(ProcessStatusFilter.CANCELLED, '', false),
+        initFilter(ProcessStatusFilter.FAILED, '', false),
+    ),
+);
+
+export const getTrashPanelTypeFilters = pipe(
+    (): DataTableFilters => createTree<DataTableFilterItem>(),
+    initFilter(ObjectTypeFilter.PROJECT),
+    initFilter(ObjectTypeFilter.COLLECTION),
+    initFilter(CollectionTypeFilter.GENERAL_COLLECTION, ObjectTypeFilter.COLLECTION),
+    initFilter(CollectionTypeFilter.OUTPUT_COLLECTION, ObjectTypeFilter.COLLECTION),
+    initFilter(CollectionTypeFilter.INTERMEDIATE_COLLECTION, ObjectTypeFilter.COLLECTION),
+    initFilter(CollectionTypeFilter.LOG_COLLECTION, ObjectTypeFilter.COLLECTION),
+);
+
+const createFiltersBuilder = (filters: DataTableFilters) =>
+    ({ fb: new FilterBuilder(), selectedFilters: getSelectedNodes(filters) });
+
+const getMatchingFilters = (values: string[], filters: TreeNode<DataTableFilterItem>[]) =>
+    filters
+        .map(f => f.id)
+        .filter(includes(__, values));
+
+const objectTypeToResourceKind = (type: ObjectTypeFilter) => {
+    switch (type) {
+        case ObjectTypeFilter.PROJECT:
+            return ResourceKind.PROJECT;
+        case ObjectTypeFilter.WORKFLOW:
+            return ResourceKind.PROCESS;
+        case ObjectTypeFilter.COLLECTION:
+            return ResourceKind.COLLECTION;
+        case ObjectTypeFilter.DEFINITION:
+            return ResourceKind.WORKFLOW;
+    }
+};
+
+const serializeObjectTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => {
+    const groupFilters = getMatchingFilters(values(GroupTypeFilter), selectedFilters);
+    const collectionFilters = getMatchingFilters(values(CollectionTypeFilter), selectedFilters);
+    const processFilters = getMatchingFilters(values(ProcessTypeFilter), selectedFilters);
+    const typeFilters = pipe(
+        () => new Set(getMatchingFilters(values(ObjectTypeFilter), selectedFilters)),
+        set => groupFilters.length > 0
+            ? set.add(ObjectTypeFilter.PROJECT)
+            : set,
+        set => collectionFilters.length > 0
+            ? set.add(ObjectTypeFilter.COLLECTION)
+            : set,
+        set => processFilters.length > 0
+            ? set.add(ObjectTypeFilter.WORKFLOW)
+            : set,
+        set => Array.from(set)
+    )();
+
+    return {
+        fb: typeFilters.length > 0
+            ? fb.addIsA('uuid', typeFilters.map(objectTypeToResourceKind))
+            : fb.addIsA('uuid', ResourceKind.NONE),
+        selectedFilters,
+    };
+};
+
+const collectionTypeToPropertyValue = (type: CollectionTypeFilter) => {
+    switch (type) {
+        case CollectionTypeFilter.GENERAL_COLLECTION:
+            return CollectionType.GENERAL;
+        case CollectionTypeFilter.OUTPUT_COLLECTION:
+            return CollectionType.OUTPUT;
+        case CollectionTypeFilter.LOG_COLLECTION:
+            return CollectionType.LOG;
+        case CollectionTypeFilter.INTERMEDIATE_COLLECTION:
+            return CollectionType.INTERMEDIATE;
+        default:
+            return CollectionType.GENERAL;
+    }
+};
+
+const serializeCollectionTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
+    () => getMatchingFilters(values(CollectionTypeFilter), selectedFilters),
+    filters => filters.map(collectionTypeToPropertyValue),
+    mappedFilters => ({
+        fb: buildCollectionTypeFilters({ fb, filters: mappedFilters }),
+        selectedFilters
+    })
+)();
+
+const COLLECTION_TYPES = values(CollectionType);
+
+const NON_GENERAL_COLLECTION_TYPES = difference(COLLECTION_TYPES, [CollectionType.GENERAL]);
+
+const COLLECTION_PROPERTIES_PREFIX = `${GroupContentsResourcePrefix.COLLECTION}.properties`;
+
+const buildCollectionTypeFilters = ({ fb, filters }: { fb: FilterBuilder, filters: CollectionType[] }) => {
+    switch (true) {
+        case filters.length === 0 || filters.length === COLLECTION_TYPES.length:
+            return fb;
+        case includes(CollectionType.GENERAL, filters):
+            return fb.addNotIn('type', difference(NON_GENERAL_COLLECTION_TYPES, filters), COLLECTION_PROPERTIES_PREFIX);
+        default:
+            return fb.addIn('type', filters, COLLECTION_PROPERTIES_PREFIX);
+    }
+};
+
+const serializeGroupTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
+    () => getMatchingFilters(values(GroupTypeFilter), selectedFilters),
+    filters => filters,
+    mappedFilters => ({
+        fb: buildGroupTypeFilters({ fb, filters: mappedFilters, use_prefix: true }),
+        selectedFilters
+    })
+)();
+
+const GROUP_TYPES = values(GroupTypeFilter);
+
+const buildGroupTypeFilters = ({ fb, filters, use_prefix }: { fb: FilterBuilder, filters: string[], use_prefix: boolean }) => {
+    switch (true) {
+        case filters.length === 0 || filters.length === GROUP_TYPES.length:
+            return fb;
+        case includes(GroupTypeFilter.PROJECT, filters):
+            return fb.addEqual('groups.group_class', 'project');
+        case includes(GroupTypeFilter.FILTER_GROUP, filters):
+            return fb.addEqual('groups.group_class', 'filter');
+        default:
+            return fb;
+    }
+};
+
+const serializeProcessTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
+    () => getMatchingFilters(values(ProcessTypeFilter), selectedFilters),
+    filters => filters,
+    mappedFilters => ({
+        fb: buildProcessTypeFilters({ fb, filters: mappedFilters, use_prefix: true }),
+        selectedFilters
+    })
+)();
+
+const PROCESS_TYPES = values(ProcessTypeFilter);
+const PROCESS_PREFIX = GroupContentsResourcePrefix.PROCESS;
+
+const buildProcessTypeFilters = ({ fb, filters, use_prefix }: { fb: FilterBuilder, filters: string[], use_prefix: boolean }) => {
+    switch (true) {
+        case filters.length === 0 || filters.length === PROCESS_TYPES.length:
+            return fb;
+        case includes(ProcessTypeFilter.MAIN_PROCESS, filters):
+            return fb.addEqual('requesting_container_uuid', null, use_prefix ? PROCESS_PREFIX : '');
+        case includes(ProcessTypeFilter.CHILD_PROCESS, filters):
+            return fb.addDistinct('requesting_container_uuid', null, use_prefix ? PROCESS_PREFIX : '');
+        default:
+            return fb;
+    }
+};
+
+export const serializeResourceTypeFilters = pipe(
+    createFiltersBuilder,
+    serializeObjectTypeFilters,
+    serializeGroupTypeFilters,
+    serializeCollectionTypeFilters,
+    serializeProcessTypeFilters,
+    ({ fb }) => fb.getFilters(),
+);
+
+export const serializeOnlyProcessTypeFilters = pipe(
+    createFiltersBuilder,
+    ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
+        () => getMatchingFilters(values(ProcessTypeFilter), selectedFilters),
+        filters => filters,
+        mappedFilters => ({
+            fb: buildProcessTypeFilters({ fb, filters: mappedFilters, use_prefix: false }),
+            selectedFilters
+        })
+    )(),
+    ({ fb }) => fb.getFilters(),
+);
+
+export const serializeSimpleObjectTypeFilters = (filters: Tree<DataTableFilterItem>) => {
+    return getSelectedNodes(filters)
+        .map(f => f.id)
+        .map(objectTypeToResourceKind);
+};
+
+export const buildProcessStatusFilters = (fb: FilterBuilder, activeStatusFilter: string, resourcePrefix?: string): FilterBuilder => {
+    switch (activeStatusFilter) {
+        case ProcessStatusFilter.ONHOLD: {
+            fb.addDistinct('state', ContainerRequestState.FINAL, resourcePrefix);
+            fb.addEqual('priority', '0', resourcePrefix);
+            fb.addIn('container.state', [ContainerState.QUEUED, ContainerState.LOCKED], resourcePrefix);
+            break;
+        }
+        case ProcessStatusFilter.COMPLETED: {
+            fb.addEqual('container.state', ContainerState.COMPLETE, resourcePrefix);
+            fb.addEqual('container.exit_code', '0', resourcePrefix);
+            break;
+        }
+        case ProcessStatusFilter.FAILED: {
+            fb.addEqual('container.state', ContainerState.COMPLETE, resourcePrefix);
+            fb.addDistinct('container.exit_code', '0', resourcePrefix);
+            break;
+        }
+        case ProcessStatusFilter.QUEUED: {
+            fb.addIn('container.state', [ContainerState.QUEUED, ContainerState.LOCKED], resourcePrefix);
+            fb.addDistinct('priority', '0', resourcePrefix);
+            break;
+        }
+        case ProcessStatusFilter.CANCELLED:
+        case ProcessStatusFilter.RUNNING: {
+            fb.addEqual('container.state', activeStatusFilter, resourcePrefix);
+            break;
+        }
+    }
+    return fb;
+};
diff --git a/services/workbench2/src/store/resources/resources-actions.ts b/services/workbench2/src/store/resources/resources-actions.ts
new file mode 100644 (file)
index 0000000..aff338f
--- /dev/null
@@ -0,0 +1,119 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from 'common/unionize';
+import { extractUuidKind, Resource, ResourceWithProperties } from 'models/resource';
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { getResourceService } from 'services/services';
+import { addProperty, deleteProperty } from 'lib/resource-properties';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { getResource } from './resources';
+import { TagProperty } from 'models/tag';
+import { change, formValueSelector } from 'redux-form';
+import { ResourcePropertiesFormData } from 'views-components/resource-properties-form/resource-properties-form';
+
+export type ResourceWithDescription = Resource & { description?: string }
+
+export const resourcesActions = unionize({
+    SET_RESOURCES: ofType<ResourceWithDescription[] >(),
+    DELETE_RESOURCES: ofType<string[]>()
+});
+
+export type ResourcesAction = UnionOf<typeof resourcesActions>;
+
+export const updateResources = (resources: Resource[]) => resourcesActions.SET_RESOURCES(resources);
+
+export const deleteResources = (resources: string[]) => resourcesActions.DELETE_RESOURCES(resources);
+
+export const loadResource = (uuid: string, showErrors?: boolean) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const kind = extractUuidKind(uuid);
+            const service = getResourceService(kind)(services);
+            if (service) {
+                const resource = await service.get(uuid, showErrors);
+                dispatch<any>(updateResources([resource]));
+                return resource;
+            }
+        } catch {}
+        return undefined;
+    };
+
+export const deleteResourceProperty = (uuid: string, key: string, value: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+
+        const rsc = getResource(uuid)(resources) as ResourceWithProperties;
+        if (!rsc) { return; }
+
+        const kind = extractUuidKind(uuid);
+        const service = getResourceService(kind)(services);
+        if (!service) { return; }
+
+        const properties = Object.assign({}, rsc.properties);
+
+        try {
+            let updatedRsc = await service.update(
+                uuid, {
+                    properties: deleteProperty(properties, key, value),
+                });
+            updatedRsc = {...rsc, ...updatedRsc};
+            dispatch<any>(updateResources([updatedRsc]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const createResourceProperty = (data: TagProperty) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { uuid } = data;
+        const { resources } = getState();
+
+        const rsc = getResource(uuid)(resources) as ResourceWithProperties;
+        if (!rsc) { return; }
+
+        const kind = extractUuidKind(uuid);
+        const service = getResourceService(kind)(services);
+        if (!service) { return; }
+
+        try {
+            const key = data.keyID || data.key;
+            const value = data.valueID || data.value;
+            const properties = Object.assign({}, rsc.properties);
+            let updatedRsc = await service.update(
+                rsc.uuid, {
+                    properties: addProperty(properties, key, value),
+                }
+            );
+            updatedRsc = {...rsc, ...updatedRsc};
+            dispatch<any>(updateResources([updatedRsc]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            const errorMsg = e.errors && e.errors.length > 0 ? e.errors[0] : "Error while adding property";
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: errorMsg, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const addPropertyToResourceForm = (data: ResourcePropertiesFormData, formName: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const properties = { ...formValueSelector(formName)(getState(), 'properties') };
+        const key = data.keyID || data.key;
+        const value =  data.valueID || data.value;
+        dispatch(change(
+            formName,
+            'properties',
+            addProperty(properties, key, value)));
+    };
+
+export const removePropertyFromResourceForm = (key: string, value: string, formName: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const properties = { ...formValueSelector(formName)(getState(), 'properties') };
+        dispatch(change(
+            formName,
+            'properties',
+            deleteProperty(properties, key, value)));
+    };
diff --git a/services/workbench2/src/store/resources/resources-reducer.ts b/services/workbench2/src/store/resources/resources-reducer.ts
new file mode 100644 (file)
index 0000000..02b8f38
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { sanitizeHTML } from 'common/html-sanitize';
+import { ResourcesState, setResource, deleteResource } from './resources';
+import { ResourcesAction, resourcesActions } from './resources-actions';
+
+export const resourcesReducer = (state: ResourcesState = {}, action: ResourcesAction) => {
+    if (Array.isArray(action.payload)) {
+        for (const item of action.payload) {
+            if (typeof item === 'object' && item.description) {
+                item.description = sanitizeHTML(item.description);
+            }
+        }
+    }
+
+    return resourcesActions.match(action, {
+        SET_RESOURCES: resources => resources.reduce((state, resource) => setResource(resource.uuid, resource)(state), state),
+        DELETE_RESOURCES: ids => ids.reduce((state, id) => deleteResource(id)(state), state),
+        default: () => state,
+    });
+};
\ No newline at end of file
diff --git a/services/workbench2/src/store/resources/resources.test.ts b/services/workbench2/src/store/resources/resources.test.ts
new file mode 100644 (file)
index 0000000..64e19fe
--- /dev/null
@@ -0,0 +1,141 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { getResourceWithEditableStatus } from "./resources";
+import { ResourceKind } from "models/resource";
+
+const groupFixtures = {
+    user_uuid: 'zzzzz-tpzed-0123456789ab789',
+    user_resource_uuid: 'zzzzz-tpzed-0123456789abcde',
+    unknown_user_resource_uuid: 'zzzzz-tpzed-0123456789ab987',
+    editable_collection_resource_uuid: 'zzzzz-4zz18-0123456789ab456',
+    not_editable_collection_resource_uuid: 'zzzzz-4zz18-0123456789ab654',
+    editable_project_resource_uuid: 'zzzzz-j7d0g-0123456789ab123',
+    not_editable_project_resource_uuid: 'zzzzz-j7d0g-0123456789ab321',
+};
+
+describe('resources', () => {
+    describe('getResourceWithEditableStatus', () => {
+        const resourcesState = {
+            [groupFixtures.editable_project_resource_uuid]: {
+                uuid: groupFixtures.editable_project_resource_uuid,
+                ownerUuid: groupFixtures.user_resource_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.PROJECT,
+                canWrite: true,
+                etag: 'string',
+            },
+            [groupFixtures.editable_collection_resource_uuid]: {
+                uuid: groupFixtures.editable_collection_resource_uuid,
+                ownerUuid: groupFixtures.editable_project_resource_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.COLLECTION,
+                etag: 'string',
+            },
+            [groupFixtures.not_editable_project_resource_uuid]: {
+                uuid: groupFixtures.not_editable_project_resource_uuid,
+                ownerUuid: groupFixtures.unknown_user_resource_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.PROJECT,
+                canWrite: false,
+                etag: 'string',
+            },
+            [groupFixtures.not_editable_collection_resource_uuid]: {
+                uuid: groupFixtures.not_editable_collection_resource_uuid,
+                ownerUuid: groupFixtures.not_editable_project_resource_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.COLLECTION,
+                etag: 'string',
+            },
+            [groupFixtures.user_resource_uuid]: {
+                uuid: groupFixtures.user_resource_uuid,
+                ownerUuid: groupFixtures.user_resource_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.USER,
+                etag: 'string',
+                canWrite: true
+            }
+        };
+
+        it('should return editable user resource (resource UUID is equal to user UUID)', () => {
+            // given
+            const id = groupFixtures.user_resource_uuid;
+            const userUuid = groupFixtures.user_resource_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeTruthy();
+        });
+
+        it('should return editable project resource', () => {
+            // given
+            const id = groupFixtures.editable_project_resource_uuid;
+            const userUuid = groupFixtures.user_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeTruthy();
+        });
+
+        it('should return editable collection resource', () => {
+            // given
+            const id = groupFixtures.editable_collection_resource_uuid;
+            const userUuid = groupFixtures.user_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeTruthy();
+        });
+
+        it('should return not editable project resource', () => {
+            // given
+            const id = groupFixtures.not_editable_project_resource_uuid;
+            const userUuid = groupFixtures.user_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeFalsy();
+        });
+
+        it('should return not editable collection resource', () => {
+            // given
+            const id = groupFixtures.not_editable_collection_resource_uuid;
+            const userUuid = groupFixtures.user_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeFalsy();
+        });
+    });
+});
diff --git a/services/workbench2/src/store/resources/resources.ts b/services/workbench2/src/store/resources/resources.ts
new file mode 100644 (file)
index 0000000..bf82fac
--- /dev/null
@@ -0,0 +1,59 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource, EditableResource } from "models/resource";
+import { ResourceKind } from 'models/resource';
+import { GroupResource } from "models/group";
+
+export type ResourcesState = { [key: string]: Resource };
+
+export const getResourceWithEditableStatus = <T extends GroupResource & EditableResource>(id: string, userUuid?: string) =>
+    (state: ResourcesState): T | undefined => {
+        if (state[id] === undefined) { return; }
+
+        const resource = JSON.parse(JSON.stringify(state[id])) as T;
+
+        if (resource) {
+            if (resource.canWrite === undefined) {
+                resource.isEditable = (state[resource.ownerUuid] as GroupResource)?.canWrite;
+            } else {
+                resource.isEditable = resource.canWrite;
+            }
+        }
+
+        return resource;
+    };
+
+export const getResource = <T extends Resource = Resource>(id: string) =>
+    (state: ResourcesState): T | undefined =>
+        state[id] as T;
+
+export const setResource = <T extends Resource>(id: string, data: T) =>
+    (state: ResourcesState) => ({
+        ...state,
+        [id]: data
+    });
+
+export const deleteResource = (id: string) =>
+    (state: ResourcesState) => {
+        const newState = { ...state };
+        delete newState[id];
+        return newState;
+    };
+
+export const filterResources = (filter: (resource: Resource) => boolean) =>
+    (state: ResourcesState) =>
+        Object
+            .keys(state)
+            .reduce((resources, id) => {
+                const resource = getResource(id)(state);
+                return resource
+                    ? [...resources, resource]
+                    : resources;
+            }, [])
+            .filter(filter);
+
+export const filterResourcesByKind = (kind: ResourceKind) =>
+    (state: ResourcesState) =>
+        filterResources(resource => resource.kind === kind)(state);
diff --git a/services/workbench2/src/store/rich-text-editor-dialog/rich-text-editor-dialog-actions.tsx b/services/workbench2/src/store/rich-text-editor-dialog/rich-text-editor-dialog-actions.tsx
new file mode 100644 (file)
index 0000000..723dfac
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from "store/dialog/dialog-actions";
+
+export const RICH_TEXT_EDITOR_DIALOG_NAME = 'richTextEditorDialogName';
+export const openRichTextEditorDialog = (title: string, text: string) =>
+    dialogActions.OPEN_DIALOG({ id: RICH_TEXT_EDITOR_DIALOG_NAME, data: { title, text } });
\ No newline at end of file
diff --git a/services/workbench2/src/store/run-process-panel/run-process-panel-actions.test.ts b/services/workbench2/src/store/run-process-panel/run-process-panel-actions.test.ts
new file mode 100644 (file)
index 0000000..77c8c4a
--- /dev/null
@@ -0,0 +1,141 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { runProcess } from "./run-process-panel-actions";
+
+jest.mock("../navigation/navigation-action", () => ({
+    navigateTo: (link: any) => link,
+}));
+
+jest.mock("models/process", () => ({
+    createWorkflowMounts: jest.fn(),
+}));
+
+jest.mock("redux-form", () => ({
+    reduxForm: () => (c: any) => c,
+    getFormValues: (name: string) => () => {
+        switch (name) {
+            case "runProcessBasicForm":
+                return {
+                    name: "basicFormTestName",
+                    description: "basicFormTestDescription",
+                };
+            case "runProcessInputsForm":
+                return {};
+            default:
+                return null;
+        }
+    },
+}));
+
+describe("run-process-panel-actions", () => {
+    describe("runProcess", () => {
+        const newProcessUUID = 'newProcessUUID';
+        let dispatch: any, getState: any, services: any;
+
+        beforeEach(() => {
+            dispatch = jest.fn();
+            services = {
+                containerRequestService: {
+                    create: jest.fn().mockImplementation(async () => ({
+                        uuid: newProcessUUID,
+                    })),
+                },
+            };
+        });
+
+        it("should return when userUuid is null", async () => {
+            // given
+            getState = () => ({
+                auth: {},
+            });
+
+            // when
+            await runProcess(dispatch, getState, services);
+
+            // then
+            expect(dispatch).not.toHaveBeenCalled();
+        });
+
+        it("should run workflow with project-uuid", async () => {
+            // given
+            getState = () => ({
+                auth: {
+                    user: {
+                        email: "test@gmail.com",
+                        firstName: "TestFirstName",
+                        lastName: "TestLastName",
+                        uuid: "zzzzz-tpzed-yid70bw31f51234",
+                        ownerUuid: "zzzzz-tpzed-000000000000000",
+                        isAdmin: false,
+                        isActive: true,
+                        username: "testfirstname",
+                        prefs: {
+                            profile: {},
+                        },
+                    },
+                },
+                runProcessPanel: {
+                    processPathname: "/projects/zzzzz-tpzed-yid70bw31f51234",
+                    processOwnerUuid: "zzzzz-tpzed-yid70bw31f51234",
+                    selectedWorkflow: {
+                        href: "/workflows/zzzzz-7fd4e-2tlnerdkxnl4fjt",
+                        kind: "arvados#workflow",
+                        etag: "8gh5xlhlgo61yqscyl1spw8tc",
+                        uuid: "zzzzz-7fd4e-2tlnerdkxnl4fjt",
+                        ownerUuid: "zzzzz-tpzed-o4njwilpp4ov321",
+                        createdAt: "2020-07-15T19:40:50.296041000Z",
+                        modifiedByClientUuid: "zzzzz-ozdt8-libnr89sc5nq111",
+                        modifiedByUserUuid: "zzzzz-tpzed-o4njwilpp4ov321",
+                        modifiedAt: "2020-07-15T19:40:50.296376000Z",
+                        name: "revsort.cwl",
+                        description:
+                            "Reverse the lines in a document, then sort those lines.",
+                        definition:
+                            '{\n    "$graph": [\n        {\n            "class": "Workflow",\n            "doc": "Reverse the lines in a document, then sort those lines.",\n            "id": "#main",\n            "hints":[{"class":"http://arvados.org/cwl#WorkflowRunnerResources","acrContainerImage":"arvados/jobs:2.0.4", "ramMin": 16000}], "inputs": [\n                {\n                    "default": null,\n                    "doc": "The input file to be processed.",\n                    "id": "#main/input",\n                    "type": "File"\n                },\n                {\n                    "default": true,\n                    "doc": "If true, reverse (decending) sort",\n                    "id": "#main/reverse_sort",\n                    "type": "boolean"\n                }\n            ],\n            "outputs": [\n                {\n                    "doc": "The output with the lines reversed and sorted.",\n                    "id": "#main/output",\n                    "outputSource": "#main/sorted/output",\n                    "type": "File"\n                }\n            ],\n            "steps": [\n                {\n                    "id": "#main/rev",\n                    "in": [\n                        {\n                            "id": "#main/rev/input",\n                            "source": "#main/input"\n                        }\n                    ],\n                    "out": [\n                        "#main/rev/output"\n                    ],\n                    "run": "#revtool.cwl"\n                },\n                {\n                    "id": "#main/sorted",\n                    "in": [\n                        {\n                            "id": "#main/sorted/input",\n                            "source": "#main/rev/output"\n                        },\n                        {\n                            "id": "#main/sorted/reverse",\n                            "source": "#main/reverse_sort"\n                        }\n                    ],\n                    "out": [\n                        "#main/sorted/output"\n                    ],\n                    "run": "#sorttool.cwl"\n                }\n            ]\n        },\n        {\n            "baseCommand": "rev",\n            "class": "CommandLineTool",\n            "doc": "Reverse each line using the `rev` command",\n            "hints": [\n                {\n                    "class": "ResourceRequirement",\n                    "ramMin": 8\n                }\n            ],\n            "id": "#revtool.cwl",\n            "inputs": [\n                {\n                    "id": "#revtool.cwl/input",\n                    "inputBinding": {},\n                    "type": "File"\n                }\n            ],\n            "outputs": [\n                {\n                    "id": "#revtool.cwl/output",\n                    "outputBinding": {\n                        "glob": "output.txt"\n                    },\n                    "type": "File"\n                }\n            ],\n            "stdout": "output.txt"\n        },\n        {\n            "baseCommand": "sort",\n            "class": "CommandLineTool",\n            "doc": "Sort lines using the `sort` command",\n            "hints": [\n                {\n                    "class": "ResourceRequirement",\n                    "ramMin": 8\n                }\n            ],\n            "id": "#sorttool.cwl",\n            "inputs": [\n                {\n                    "id": "#sorttool.cwl/reverse",\n                    "inputBinding": {\n                        "position": 1,\n                        "prefix": "-r"\n                    },\n                    "type": "boolean"\n                },\n                {\n                    "id": "#sorttool.cwl/input",\n                    "inputBinding": {\n                        "position": 2\n                    },\n                    "type": "File"\n                }\n            ],\n            "outputs": [\n                {\n                    "id": "#sorttool.cwl/output",\n                    "outputBinding": {\n                        "glob": "output.txt"\n                    },\n                    "type": "File"\n                }\n            ],\n            "stdout": "output.txt"\n        }\n    ],\n    "cwlVersion": "v1.0"\n}',
+                    },
+                },
+            });
+
+            // when
+            await runProcess(dispatch, getState, services);
+
+            // then
+            expect(services.containerRequestService.create).toHaveBeenCalledWith({
+                command: [
+                    "arvados-cwl-runner",
+                    "--api=containers",
+                    "--local",
+                    "--project-uuid=zzzzz-tpzed-yid70bw31f51234",
+                    "/var/lib/cwl/workflow.json#main",
+                    "/var/lib/cwl/cwl.input.json",
+                ],
+                containerImage: "arvados/jobs:2.0.4",
+                cwd: "/var/spool/cwl",
+                description: "basicFormTestDescription",
+                mounts: undefined,
+                name: "basicFormTestName",
+                outputName: "Output from basicFormTestName",
+                outputPath: "/var/spool/cwl",
+                ownerUuid: "zzzzz-tpzed-yid70bw31f51234",
+                priority: 500,
+                properties: {
+                    workflowName: "revsort.cwl",
+                    template_uuid: "zzzzz-7fd4e-2tlnerdkxnl4fjt",
+                },
+                runtimeConstraints: {
+                    API: true,
+                    ram: 16256 * (1024 * 1024),
+                    vcpus: 1,
+                },
+                schedulingParameters: { max_run_time: undefined },
+                state: "Committed",
+                useExisting: false
+            });
+
+            // and
+            expect(dispatch).toHaveBeenCalledWith(newProcessUUID);
+        });
+    });
+});
diff --git a/services/workbench2/src/store/run-process-panel/run-process-panel-actions.ts b/services/workbench2/src/store/run-process-panel/run-process-panel-actions.ts
new file mode 100644 (file)
index 0000000..000f0cd
--- /dev/null
@@ -0,0 +1,210 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { ServiceRepository } from "services/services";
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { WorkflowResource, WorkflowRunnerResources, getWorkflow, getWorkflowInputs, parseWorkflowDefinition } from 'models/workflow';
+import { getFormValues, initialize } from 'redux-form';
+import { RUN_PROCESS_BASIC_FORM, RunProcessBasicFormData } from 'views/run-process-panel/run-process-basic-form';
+import { RUN_PROCESS_INPUTS_FORM } from 'views/run-process-panel/run-process-inputs-form';
+import { WorkflowInputsData } from 'models/workflow';
+import { createWorkflowMounts } from 'models/process';
+import { ContainerRequestState } from 'models/container-request';
+import { navigateTo } from '../navigation/navigation-action';
+import {
+    RunProcessAdvancedFormData, RUN_PROCESS_ADVANCED_FORM, VCPUS_FIELD,
+    KEEP_CACHE_RAM_FIELD, RAM_FIELD, RUNTIME_FIELD, OUTPUT_FIELD, RUNNER_IMAGE_FIELD
+} from 'views/run-process-panel/run-process-advanced-form';
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { setBreadcrumbs } from 'store/breadcrumbs/breadcrumbs-actions';
+import { getResource } from 'store/resources/resources';
+import { ProjectResource } from "models/project";
+import { UserResource } from "models/user";
+
+export const runProcessPanelActions = unionize({
+    SET_PROCESS_PATHNAME: ofType<string>(),
+    SET_PROCESS_OWNER_UUID: ofType<string>(),
+    SET_CURRENT_STEP: ofType<number>(),
+    SET_STEP_CHANGED: ofType<boolean>(),
+    SET_WORKFLOWS: ofType<WorkflowResource[]>(),
+    SET_SELECTED_WORKFLOW: ofType<WorkflowResource>(),
+    SET_WORKFLOW_PRESETS: ofType<WorkflowResource[]>(),
+    SELECT_WORKFLOW_PRESET: ofType<WorkflowResource>(),
+    SEARCH_WORKFLOWS: ofType<string>(),
+    RESET_RUN_PROCESS_PANEL: ofType<{}>(),
+});
+
+export interface RunProcessSecondStepDataFormProps {
+    name: string;
+    description: string;
+}
+
+export const SET_WORKFLOW_DIALOG = 'setWorkflowDialog';
+export const RUN_PROCESS_SECOND_STEP_FORM_NAME = 'runProcessSecondStepFormName';
+
+export type RunProcessPanelAction = UnionOf<typeof runProcessPanelActions>;
+
+export const loadRunProcessPanel = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            dispatch(setBreadcrumbs([{ label: 'Run Process' }]));
+            const response = await services.workflowService.list();
+            dispatch(runProcessPanelActions.SET_WORKFLOWS(response.items));
+        } catch (e) {
+            return;
+        }
+    };
+
+export const openSetWorkflowDialog = (workflow: WorkflowResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const selectedWorkflow = getState().runProcessPanel.selectedWorkflow;
+        const isStepChanged = getState().runProcessPanel.isStepChanged;
+        if (isStepChanged && selectedWorkflow && selectedWorkflow.uuid !== workflow.uuid) {
+            dispatch(dialogActions.OPEN_DIALOG({
+                id: SET_WORKFLOW_DIALOG,
+                data: {
+                    title: 'Form will be cleared',
+                    text: 'Changing a workflow will clean all input fields in next step.',
+                    confirmButtonLabel: 'Change Workflow',
+                    workflow
+                }
+            }));
+        } else {
+            dispatch<any>(setWorkflow(workflow, false));
+        }
+    };
+
+export const getWorkflowRunnerSettings = (workflow: WorkflowResource) => {
+    const advancedFormValues = {};
+    Object.assign(advancedFormValues, DEFAULT_ADVANCED_FORM_VALUES);
+
+    const wf = getWorkflow(parseWorkflowDefinition(workflow));
+    const hints = wf ? wf.hints : undefined;
+    if (hints) {
+        const resc = hints.find(item => item.class === 'http://arvados.org/cwl#WorkflowRunnerResources') as WorkflowRunnerResources | undefined;
+        if (resc) {
+            if (resc.ramMin) { advancedFormValues[RAM_FIELD] = resc.ramMin * (1024 * 1024); }
+            if (resc.coresMin) { advancedFormValues[VCPUS_FIELD] = resc.coresMin; }
+            if (resc.keep_cache) { advancedFormValues[KEEP_CACHE_RAM_FIELD] = resc.keep_cache * (1024 * 1024); }
+            if (resc.acrContainerImage) { advancedFormValues[RUNNER_IMAGE_FIELD] = resc.acrContainerImage; }
+        }
+    }
+    return advancedFormValues;
+};
+
+export const setWorkflow = (workflow: WorkflowResource, isWorkflowChanged = true) =>
+    (dispatch: Dispatch<any>, getState: () => RootState) => {
+        const isStepChanged = getState().runProcessPanel.isStepChanged;
+
+        const advancedFormValues = getWorkflowRunnerSettings(workflow);
+
+        let owner = getResource<ProjectResource | UserResource>(getState().runProcessPanel.processOwnerUuid)(getState().resources);
+        if (!owner || !owner.canWrite) {
+            owner = undefined;
+        }
+
+        if (isStepChanged && isWorkflowChanged) {
+            dispatch(runProcessPanelActions.SET_STEP_CHANGED(false));
+            dispatch(runProcessPanelActions.SET_SELECTED_WORKFLOW(workflow));
+            dispatch<any>(loadPresets(workflow.uuid));
+            dispatch(initialize(RUN_PROCESS_BASIC_FORM, { name: workflow.name, owner }));
+            dispatch(initialize(RUN_PROCESS_ADVANCED_FORM, advancedFormValues));
+        }
+        if (!isWorkflowChanged) {
+            dispatch(runProcessPanelActions.SET_SELECTED_WORKFLOW(workflow));
+            dispatch<any>(loadPresets(workflow.uuid));
+            dispatch(initialize(RUN_PROCESS_BASIC_FORM, { name: workflow.name, owner }));
+            dispatch(initialize(RUN_PROCESS_ADVANCED_FORM, advancedFormValues));
+        }
+    };
+
+export const loadPresets = (workflowUuid: string) =>
+    async (dispatch: Dispatch<any>, _: () => RootState, { workflowService }: ServiceRepository) => {
+        const { items } = await workflowService.presets(workflowUuid);
+        dispatch(runProcessPanelActions.SET_WORKFLOW_PRESETS(items));
+    };
+
+export const selectPreset = (preset: WorkflowResource) =>
+    (dispatch: Dispatch<any>) => {
+        dispatch(runProcessPanelActions.SELECT_WORKFLOW_PRESET(preset));
+        const inputs = getWorkflowInputs(parseWorkflowDefinition(preset)) || [];
+        const values = inputs.reduce((values, input) => ({
+            ...values,
+            [input.id]: input.default,
+        }), {});
+        dispatch(initialize(RUN_PROCESS_INPUTS_FORM, values));
+    };
+
+export const goToStep = (step: number) =>
+    (dispatch: Dispatch) => {
+        if (step === 1) {
+            dispatch(runProcessPanelActions.SET_STEP_CHANGED(true));
+        }
+        dispatch(runProcessPanelActions.SET_CURRENT_STEP(step));
+    };
+
+export const runProcess = async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    const state = getState();
+    const basicForm = getFormValues(RUN_PROCESS_BASIC_FORM)(state) as RunProcessBasicFormData;
+    const inputsForm = getFormValues(RUN_PROCESS_INPUTS_FORM)(state) as WorkflowInputsData;
+    const userUuid = getUserUuid(getState());
+    if (!userUuid) { return; }
+    const { processOwnerUuid, selectedWorkflow } = state.runProcessPanel;
+    const ownerUUid = basicForm.owner ? basicForm.owner.uuid : (processOwnerUuid ? processOwnerUuid : userUuid);
+    if (selectedWorkflow) {
+        const advancedForm = getFormValues(RUN_PROCESS_ADVANCED_FORM)(state) as RunProcessAdvancedFormData || getWorkflowRunnerSettings(selectedWorkflow);
+        const newProcessData = {
+            ownerUuid: ownerUUid,
+            name: basicForm.name,
+            description: basicForm.description,
+            state: ContainerRequestState.COMMITTED,
+            mounts: createWorkflowMounts(selectedWorkflow, normalizeInputKeys(inputsForm)),
+            runtimeConstraints: {
+                API: true,
+                vcpus: advancedForm[VCPUS_FIELD],
+                ram: (advancedForm[KEEP_CACHE_RAM_FIELD] + advancedForm[RAM_FIELD]),
+            },
+            schedulingParameters: {
+                max_run_time: advancedForm[RUNTIME_FIELD]
+            },
+            containerImage: advancedForm[RUNNER_IMAGE_FIELD],
+            cwd: '/var/spool/cwl',
+            command: [
+                'arvados-cwl-runner',
+                '--api=containers',
+                '--local',
+                `--project-uuid=${ownerUUid}`,
+                '/var/lib/cwl/workflow.json#main',
+                '/var/lib/cwl/cwl.input.json'
+            ],
+            outputPath: '/var/spool/cwl',
+            priority: 500,
+            outputName: advancedForm[OUTPUT_FIELD] ? advancedForm[OUTPUT_FIELD] : `Output from ${basicForm.name}`,
+            properties: {
+                template_uuid: selectedWorkflow.uuid,
+                workflowName: selectedWorkflow.name
+            },
+            useExisting: false
+        };
+        const newProcess = await services.containerRequestService.create(newProcessData);
+        dispatch(navigateTo(newProcess.uuid));
+    }
+};
+
+const DEFAULT_ADVANCED_FORM_VALUES: Partial<RunProcessAdvancedFormData> = {
+    [VCPUS_FIELD]: 1,
+    [RAM_FIELD]: 1073741824,
+    [KEEP_CACHE_RAM_FIELD]: 268435456,
+    [RUNNER_IMAGE_FIELD]: "arvados/jobs"
+};
+
+const normalizeInputKeys = (inputs: WorkflowInputsData): WorkflowInputsData =>
+    Object.keys(inputs).reduce((normalizedInputs, key) => ({
+        ...normalizedInputs,
+        [key.split('/').slice(1).join('/')]: inputs[key],
+    }), {});
+export const searchWorkflows = (term: string) => runProcessPanelActions.SEARCH_WORKFLOWS(term);
diff --git a/services/workbench2/src/store/run-process-panel/run-process-panel-reducer.ts b/services/workbench2/src/store/run-process-panel/run-process-panel-reducer.ts
new file mode 100644 (file)
index 0000000..fe69f95
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RunProcessPanelAction, runProcessPanelActions } from 'store/run-process-panel/run-process-panel-actions';
+import { WorkflowResource, CommandInputParameter, getWorkflowInputs, parseWorkflowDefinition } from 'models/workflow';
+
+interface RunProcessPanel {
+    processPathname: string;
+    processOwnerUuid: string;
+    currentStep: number;
+    isStepChanged: boolean;
+    workflows: WorkflowResource[];
+    searchWorkflows: WorkflowResource[];
+    selectedWorkflow: WorkflowResource | undefined;
+    presets?: WorkflowResource[];
+    selectedPreset?: WorkflowResource;
+    inputs: CommandInputParameter[];
+}
+
+const initialState: RunProcessPanel = {
+    processPathname: '',
+    processOwnerUuid: '',
+    currentStep: 0,
+    isStepChanged: false,
+    workflows: [],
+    selectedWorkflow: undefined,
+    inputs: [],
+    searchWorkflows: [],
+};
+
+export const runProcessPanelReducer = (state = initialState, action: RunProcessPanelAction): RunProcessPanel =>
+    runProcessPanelActions.match(action, {
+        SET_PROCESS_PATHNAME: processPathname => ({ ...state, processPathname }),
+        SET_PROCESS_OWNER_UUID: processOwnerUuid => ({ ...state, processOwnerUuid }),
+        SET_CURRENT_STEP: currentStep => ({ ...state, currentStep }),
+        SET_STEP_CHANGED: isStepChanged => ({ ...state, isStepChanged }),
+        SET_SELECTED_WORKFLOW: selectedWorkflow => ({
+            ...state,
+            selectedWorkflow,
+            presets: undefined,
+            selectedPreset: selectedWorkflow,
+            inputs: getWorkflowInputs(parseWorkflowDefinition(selectedWorkflow)) || [],
+        }),
+        SET_WORKFLOW_PRESETS: presets => ({
+            ...state,
+            presets,
+        }),
+        SELECT_WORKFLOW_PRESET: selectedPreset => ({
+            ...state,
+            selectedPreset,
+        }),
+        SET_WORKFLOWS: workflows => ({ ...state, workflows, searchWorkflows: workflows }),
+        SEARCH_WORKFLOWS: term => {
+            const termRegex = new RegExp(term, 'i');
+            return {
+                ...state,
+                searchWorkflows: state.workflows.filter(workflow => termRegex.test(workflow.name)),
+            };
+        },
+        RESET_RUN_PROCESS_PANEL: () => ({ ...initialState, processOwnerUuid: state.processOwnerUuid }),
+        default: () => state
+    });
\ No newline at end of file
diff --git a/services/workbench2/src/store/search-bar/search-bar-actions.test.ts b/services/workbench2/src/store/search-bar/search-bar-actions.test.ts
new file mode 100644 (file)
index 0000000..d14f3f1
--- /dev/null
@@ -0,0 +1,185 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { getAdvancedDataFromQuery, getQueryFromAdvancedData } from "store/search-bar/search-bar-actions";
+import { ResourceKind } from "models/resource";
+
+describe('search-bar-actions', () => {
+    describe('getAdvancedDataFromQuery', () => {
+        it('should correctly build advanced data record from query #1', () => {
+            const r = getAdvancedDataFromQuery('val0 has:"file size":"100mb" val2 has:"user":"daniel" is:starred val2 val0');
+            expect(r).toEqual({
+                searchValue: 'val0 val2',
+                type: undefined,
+                cluster: undefined,
+                projectUuid: undefined,
+                inTrash: false,
+                pastVersions: false,
+                dateFrom: '',
+                dateTo: '',
+                properties: [{
+                    key: 'file size',
+                    value: '100mb'
+                }, {
+                    key: 'user',
+                    value: 'daniel'
+                }],
+                saveQuery: false,
+                queryName: ''
+            });
+        });
+
+        it('should correctly build advanced data record from query #2', () => {
+            const r = getAdvancedDataFromQuery('document from:2017-08-01 pdf has:"filesize":"101mb" is:trashed type:arvados#collection cluster:c97qx is:pastVersion');
+            expect(r).toEqual({
+                searchValue: 'document pdf',
+                type: ResourceKind.COLLECTION,
+                cluster: 'c97qx',
+                projectUuid: undefined,
+                inTrash: true,
+                pastVersions: true,
+                dateFrom: '2017-08-01',
+                dateTo: '',
+                properties: [{
+                    key: 'filesize',
+                    value: '101mb'
+                }],
+                saveQuery: false,
+                queryName: ''
+            });
+        });
+    });
+
+    describe('getQueryFromAdvancedData', () => {
+        it('should build query from advanced data', () => {
+            const q = getQueryFromAdvancedData({
+                searchValue: 'document pdf',
+                type: ResourceKind.COLLECTION,
+                cluster: 'c97qx',
+                projectUuid: undefined,
+                inTrash: true,
+                pastVersions: false,
+                dateFrom: '2017-08-01',
+                dateTo: '',
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Human' },
+                    { key: 'Species', value: 'Canine' },
+                ],
+                saveQuery: false,
+                queryName: ''
+            });
+            expect(q).toBe('document pdf type:arvados#collection cluster:c97qx is:trashed from:2017-08-01 has:"file size":"101mb" has:"Species":"Human" has:"Species":"Canine"');
+        });
+
+        it('should build query from advanced data #2', () => {
+            const q = getQueryFromAdvancedData({
+                searchValue: 'document pdf',
+                type: ResourceKind.COLLECTION,
+                cluster: 'c97qx',
+                projectUuid: undefined,
+                inTrash: false,
+                pastVersions: true,
+                dateFrom: '2017-08-01',
+                dateTo: '',
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Human' },
+                    { key: 'Species', value: 'Canine' },
+                ],
+                saveQuery: false,
+                queryName: ''
+            });
+            expect(q).toBe('document pdf type:arvados#collection cluster:c97qx is:pastVersion from:2017-08-01 has:"file size":"101mb" has:"Species":"Human" has:"Species":"Canine"');
+        });
+
+        it('should add has:"key":"value" expression to query from same property key', () => {
+            const searchValue = 'document pdf has:"file size":"101mb" has:"Species":"Canine"';
+            const prevData = {
+                searchValue,
+                type: undefined,
+                cluster: undefined,
+                projectUuid: undefined,
+                inTrash: false,
+                pastVersions: false,
+                dateFrom: '',
+                dateTo: '',
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Canine' },
+                ],
+                saveQuery: false,
+                queryName: ''
+            };
+            const currData = {
+                ...prevData,
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Canine' },
+                    { key: 'Species', value: 'Human' },
+                ],
+            };
+            const q = getQueryFromAdvancedData(currData, prevData);
+            expect(q).toBe('document pdf has:"file size":"101mb" has:"Species":"Canine" has:"Species":"Human"');
+        });
+
+        it('should add has:"keyID":"valueID" expression to query when necessary', () => {
+            const searchValue = 'document pdf has:"file size":"101mb"';
+            const prevData = {
+                searchValue,
+                type: undefined,
+                cluster: undefined,
+                projectUuid: undefined,
+                inTrash: false,
+                pastVersions: false,
+                dateFrom: '',
+                dateTo: '',
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                ],
+                saveQuery: false,
+                queryName: ''
+            };
+            const currData = {
+                ...prevData,
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', keyID: 'IDTAGSPECIES', value: 'Human', valueID: 'IDVALHUMAN'},
+                ],
+            };
+            const q = getQueryFromAdvancedData(currData, prevData);
+            expect(q).toBe('document pdf has:"file size":"101mb" has:"IDTAGSPECIES":"IDVALHUMAN"');
+        });
+
+        it('should remove has:"key":"value" expression from query', () => {
+            const searchValue = 'document pdf has:"file size":"101mb" has:"Species":"Human" has:"Species":"Canine"';
+            const prevData = {
+                searchValue,
+                type: undefined,
+                cluster: undefined,
+                projectUuid: undefined,
+                inTrash: false,
+                pastVersions: false,
+                dateFrom: '',
+                dateTo: '',
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Canine' },
+                    { key: 'Species', value: 'Human' },
+                ],
+                saveQuery: false,
+                queryName: ''
+            };
+            const currData = {
+                ...prevData,
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Canine' },
+                ],
+            };
+            const q = getQueryFromAdvancedData(currData, prevData);
+            expect(q).toBe('document pdf has:"file size":"101mb" has:"Species":"Canine"');
+        });
+    });
+});
diff --git a/services/workbench2/src/store/search-bar/search-bar-actions.ts b/services/workbench2/src/store/search-bar/search-bar-actions.ts
new file mode 100644 (file)
index 0000000..396c2df
--- /dev/null
@@ -0,0 +1,432 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import axios from "axios";
+import { ofType, unionize, UnionOf } from "common/unionize";
+import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
+import { Dispatch } from 'redux';
+import { change, initialize, untouch } from 'redux-form';
+import { RootState } from 'store/store';
+import { initUserProject, treePickerActions } from 'store/tree-picker/tree-picker-actions';
+import { ServiceRepository } from 'services/services';
+import { FilterBuilder } from "services/api/filter-builder";
+import { ResourceKind, RESOURCE_UUID_REGEX, COLLECTION_PDH_REGEX } from 'models/resource';
+import { SearchView } from 'store/search-bar/search-bar-reducer';
+import { navigateTo, navigateToSearchResults } from 'store/navigation/navigation-action';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { PropertyValue, SearchBarAdvancedFormData } from 'models/search-bar';
+import { union } from "lodash";
+import { getModifiedKeysValues } from "common/objects";
+import { activateSearchBarProject } from "store/search-bar/search-bar-tree-actions";
+import { Session } from "models/session";
+import { searchResultsPanelActions } from "store/search-results-panel/search-results-panel-actions";
+import { ListResults } from "services/common-service/common-service";
+import * as parser from './search-query/arv-parser';
+import { Keywords } from './search-query/arv-parser';
+import { Vocabulary, getTagKeyLabel, getTagValueLabel } from "models/vocabulary";
+
+export const searchBarActions = unionize({
+    SET_CURRENT_VIEW: ofType<string>(),
+    OPEN_SEARCH_VIEW: ofType<{}>(),
+    CLOSE_SEARCH_VIEW: ofType<{}>(),
+    SET_SEARCH_RESULTS: ofType<GroupContentsResource[]>(),
+    SET_SEARCH_VALUE: ofType<string>(),
+    SET_SAVED_QUERIES: ofType<SearchBarAdvancedFormData[]>(),
+    SET_RECENT_QUERIES: ofType<string[]>(),
+    UPDATE_SAVED_QUERY: ofType<SearchBarAdvancedFormData[]>(),
+    SET_SELECTED_ITEM: ofType<string>(),
+    MOVE_UP: ofType<{}>(),
+    MOVE_DOWN: ofType<{}>(),
+    SELECT_FIRST_ITEM: ofType<{}>(),
+});
+
+export type SearchBarActions = UnionOf<typeof searchBarActions>;
+
+export const SEARCH_BAR_ADVANCED_FORM_NAME = 'searchBarAdvancedFormName';
+
+export const SEARCH_BAR_ADVANCED_FORM_PICKER_ID = 'searchBarAdvancedFormPickerId';
+
+export const DEFAULT_SEARCH_DEBOUNCE = 1000;
+
+export const goToView = (currentView: string) => searchBarActions.SET_CURRENT_VIEW(currentView);
+
+export const saveRecentQuery = (query: string) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) =>
+        services.searchService.saveRecentQuery(query);
+
+
+export const loadRecentQueries = () =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const recentQueries = services.searchService.getRecentQueries();
+        dispatch(searchBarActions.SET_RECENT_QUERIES(recentQueries));
+        return recentQueries;
+    };
+
+export const searchData = (searchValue: string, useCancel = false) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const currentView = getState().searchBar.currentView;
+        dispatch(searchResultsPanelActions.CLEAR());
+        dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
+        if (searchValue.length > 0) {
+            dispatch<any>(searchGroups(searchValue, 5, useCancel));
+            if (currentView === SearchView.BASIC) {
+                dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
+                dispatch(navigateToSearchResults(searchValue));
+            }
+        }
+    };
+
+export const searchAdvancedData = (data: SearchBarAdvancedFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch<any>(saveQuery(data));
+        const searchValue = getState().searchBar.searchValue;
+        dispatch(searchResultsPanelActions.CLEAR());
+        dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
+        dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
+        dispatch(navigateToSearchResults(searchValue));
+    };
+
+export const setSearchValueFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        if (data.projectObject) {
+            data.projectUuid = data.projectObject.uuid;
+        }
+        const searchValue = getState().searchBar.searchValue;
+        const value = getQueryFromAdvancedData({
+            ...data,
+            searchValue
+        }, prevData);
+        dispatch(searchBarActions.SET_SEARCH_VALUE(value));
+    };
+
+export const setAdvancedDataFromSearchValue = (search: string, vocabulary: Vocabulary) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const data = getAdvancedDataFromQuery(search, vocabulary);
+        if (data.projectUuid) {
+            data.projectObject = await services.projectService.get(data.projectUuid);
+        }
+        dispatch<any>(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data));
+        if (data.projectUuid) {
+            await dispatch<any>(activateSearchBarProject(data.projectUuid));
+            dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID, id: data.projectUuid }));
+        }
+    };
+
+const saveQuery = (data: SearchBarAdvancedFormData) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const savedQueries = services.searchService.getSavedQueries();
+        if (data.saveQuery && data.queryName) {
+            const filteredQuery = savedQueries.find(query => query.queryName === data.queryName);
+            data.searchValue = getState().searchBar.searchValue;
+            if (filteredQuery) {
+                services.searchService.editSavedQueries(data);
+                dispatch(searchBarActions.UPDATE_SAVED_QUERY(savedQueries));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Query has been successfully updated', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            } else {
+                services.searchService.saveQuery(data);
+                dispatch(searchBarActions.SET_SAVED_QUERIES(savedQueries));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Query has been successfully saved', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            }
+        }
+    };
+
+export const deleteSavedQuery = (id: number) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        services.searchService.deleteSavedQuery(id);
+        const savedSearchQueries = services.searchService.getSavedQueries();
+        dispatch(searchBarActions.SET_SAVED_QUERIES(savedSearchQueries));
+        return savedSearchQueries || [];
+    };
+
+export const editSavedQuery = (data: SearchBarAdvancedFormData) =>
+    (dispatch: Dispatch<any>) => {
+        dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.ADVANCED));
+        dispatch(searchBarActions.SET_SEARCH_VALUE(getQueryFromAdvancedData(data)));
+        dispatch<any>(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data));
+    };
+
+export const openSearchView = () =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const savedSearchQueries = services.searchService.getSavedQueries();
+        dispatch(searchBarActions.SET_SAVED_QUERIES(savedSearchQueries));
+        dispatch(loadRecentQueries());
+        dispatch(searchBarActions.OPEN_SEARCH_VIEW());
+        dispatch(searchBarActions.SELECT_FIRST_ITEM());
+    };
+
+export const closeSearchView = () =>
+    (dispatch: Dispatch<any>) => {
+        dispatch(searchBarActions.SET_SELECTED_ITEM(''));
+        dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
+    };
+
+export const closeAdvanceView = () =>
+    (dispatch: Dispatch<any>) => {
+        dispatch(searchBarActions.SET_SEARCH_VALUE(''));
+        dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
+        dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
+    };
+
+export const navigateToItem = (uuid: string) =>
+    (dispatch: Dispatch<any>) => {
+        dispatch(searchBarActions.SET_SELECTED_ITEM(''));
+        dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
+        dispatch(navigateTo(uuid));
+    };
+
+export const changeData = (searchValue: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
+        const currentView = getState().searchBar.currentView;
+        const searchValuePresent = searchValue.length > 0;
+
+        if (currentView === SearchView.ADVANCED) {
+            dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.AUTOCOMPLETE));
+        } else if (searchValuePresent) {
+            dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.AUTOCOMPLETE));
+            dispatch(searchBarActions.SET_SELECTED_ITEM(searchValue));
+        } else {
+            dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
+            dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
+            dispatch(searchBarActions.SELECT_FIRST_ITEM());
+        }
+    };
+
+export const submitData = (event: React.FormEvent<HTMLFormElement>) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        event.preventDefault();
+        const searchValue = getState().searchBar.searchValue;
+        dispatch<any>(saveRecentQuery(searchValue));
+        dispatch<any>(loadRecentQueries());
+        dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
+        if (RESOURCE_UUID_REGEX.exec(searchValue) || COLLECTION_PDH_REGEX.exec(searchValue)) {
+            dispatch<any>(navigateTo(searchValue));
+        } else {
+            dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
+            dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
+            dispatch(searchResultsPanelActions.CLEAR());
+            dispatch(navigateToSearchResults(searchValue));
+        }
+    };
+
+let cancelTokens: any[] = [];
+const searchGroups = (searchValue: string, limit: number, useCancel = false) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const currentView = getState().searchBar.currentView;
+
+        if (cancelTokens.length > 0 && useCancel) {
+            cancelTokens.forEach(cancelToken => (cancelToken as any).cancel('New search request triggered.'));
+            cancelTokens = [];
+        }
+
+        setTimeout(async () => {
+            if (searchValue || currentView === SearchView.ADVANCED) {
+                const { cluster: clusterId } = getAdvancedDataFromQuery(searchValue);
+                const sessions = getSearchSessions(clusterId, getState().auth.sessions);
+                const lists: ListResults<GroupContentsResource>[] = await Promise.all(sessions.map((session, index) => {
+                    cancelTokens.push(axios.CancelToken.source());
+                    const filters = queryToFilters(searchValue, session.apiRevision);
+                    return services.groupsService.contents('', {
+                        filters,
+                        limit,
+                        recursive: true
+                    }, session, cancelTokens[index].token);
+                }));
+
+                cancelTokens = [];
+
+                const items = lists.reduce((items, list) => items.concat(list.items), [] as GroupContentsResource[]);
+
+                if (lists.filter(list => !!(list as any).items).length !== lists.length) {
+                    dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
+                } else {
+                    dispatch(searchBarActions.SET_SEARCH_RESULTS(items));
+                }
+            }
+        }, 10);
+    };
+
+const buildQueryFromKeyMap = (data: any, keyMap: string[][]) => {
+    let value = data.searchValue;
+
+    const addRem = (field: string, key: string) => {
+        const v = data[key];
+        // Remove previous search expression.
+        if (data.hasOwnProperty(key)) {
+            let pattern: string;
+            if (v === false) {
+                pattern = `${field.replace(':', '\\:\\s*')}\\s*`;
+            } else if (key.startsWith('prop-')) {
+                // On properties, only remove key:value duplicates, allowing
+                // multiple properties with the same key.
+                const oldValue = key.slice(5).split(':')[1];
+                pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*${oldValue}\\s*`;
+            } else {
+                pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*[\\w|\\#|\\-|\\/]*\\s*`;
+            }
+            value = value.replace(new RegExp(pattern), '');
+        }
+        // Re-add it with the current search value.
+        if (v) {
+            const nv = v === true
+                ? `${field}`
+                : `${field}:${v}`;
+            // Always append to the end to keep user-entered text at the start.
+            value = value + ' ' + nv;
+        }
+    };
+    keyMap.forEach(km => addRem(km[0], km[1]));
+    return value;
+};
+
+export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) => {
+    let value = '';
+
+    const flatData = (data: SearchBarAdvancedFormData) => {
+        const fo = {
+            searchValue: data.searchValue,
+            type: data.type,
+            cluster: data.cluster,
+            projectUuid: data.projectUuid,
+            inTrash: data.inTrash,
+            pastVersions: data.pastVersions,
+            dateFrom: data.dateFrom,
+            dateTo: data.dateTo,
+        };
+        (data.properties || []).forEach(p =>
+            fo[`prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`] = `"${p.valueID || p.value}"`
+        );
+        return fo;
+    };
+
+    const keyMap = [
+        ['type', 'type'],
+        ['cluster', 'cluster'],
+        ['project', 'projectUuid'],
+        [`is:${parser.States.TRASHED}`, 'inTrash'],
+        [`is:${parser.States.PAST_VERSION}`, 'pastVersions'],
+        ['from', 'dateFrom'],
+        ['to', 'dateTo']
+    ];
+    union(data.properties, prevData ? prevData.properties : [])
+        .forEach(p => keyMap.push(
+            [`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`]
+        ));
+
+    const modified = getModifiedKeysValues(flatData(data), prevData ? flatData(prevData) : {});
+    value = buildQueryFromKeyMap(
+        { searchValue: data.searchValue, ...modified } as SearchBarAdvancedFormData, keyMap);
+
+    value = value.trim();
+    return value;
+};
+
+export const getAdvancedDataFromQuery = (query: string, vocabulary?: Vocabulary): SearchBarAdvancedFormData => {
+    const { tokens, searchString } = parser.parseSearchQuery(query);
+    const getValue = parser.getValue(tokens);
+    return {
+        searchValue: searchString,
+        type: getValue(Keywords.TYPE) as ResourceKind,
+        cluster: getValue(Keywords.CLUSTER),
+        projectUuid: getValue(Keywords.PROJECT),
+        inTrash: parser.isTrashed(tokens),
+        pastVersions: parser.isPastVersion(tokens),
+        dateFrom: getValue(Keywords.FROM) || '',
+        dateTo: getValue(Keywords.TO) || '',
+        properties: vocabulary
+            ? parser.getProperties(tokens).map(
+                p => {
+                    return {
+                        keyID: p.key,
+                        key: getTagKeyLabel(p.key, vocabulary),
+                        valueID: p.value,
+                        value: getTagValueLabel(p.key, p.value, vocabulary),
+                    };
+                })
+            : parser.getProperties(tokens),
+        saveQuery: false,
+        queryName: ''
+    };
+};
+
+export const getSearchSessions = (clusterId: string | undefined, sessions: Session[]): Session[] => {
+    return sessions.filter(s => s.loggedIn && (!clusterId || s.clusterId === clusterId));
+};
+
+export const queryToFilters = (query: string, apiRevision: number) => {
+    const data = getAdvancedDataFromQuery(query);
+    const filter = new FilterBuilder();
+    const resourceKind = data.type;
+
+    if (data.searchValue) {
+        filter.addFullTextSearch(data.searchValue);
+    }
+
+    if (data.projectUuid) {
+        filter.addEqual('owner_uuid', data.projectUuid);
+    }
+
+    if (data.dateFrom) {
+        filter.addGte('modified_at', buildDateFilter(data.dateFrom));
+    }
+
+    if (data.dateTo) {
+        filter.addLte('modified_at', buildDateFilter(data.dateTo));
+    }
+
+    data.properties.forEach(p => {
+        if (p.value) {
+            if (apiRevision < 20200212) {
+                filter
+                    .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
+                    .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION)
+                    .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROCESS);
+            } else {
+                filter
+                    .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
+                    .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION)
+                    .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROCESS);
+            }
+        }
+        filter.addExists(p.key);
+    });
+
+    return filter
+        .addIsA("uuid", buildUuidFilter(resourceKind))
+        .getFilters();
+};
+
+const buildUuidFilter = (type?: ResourceKind): ResourceKind[] => {
+    return type ? [type] : [ResourceKind.PROJECT, ResourceKind.COLLECTION, ResourceKind.PROCESS];
+};
+
+const buildDateFilter = (date?: string): string => {
+    return date ? date : '';
+};
+
+export const initAdvancedFormProjectsTree = () =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(initUserProject(SEARCH_BAR_ADVANCED_FORM_PICKER_ID));
+    };
+
+export const changeAdvancedFormProperty = (propertyField: string, value: PropertyValue[] | string = '') =>
+    (dispatch: Dispatch) => {
+        dispatch(change(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField, value));
+    };
+
+export const resetAdvancedFormProperty = (propertyField: string) =>
+    (dispatch: Dispatch) => {
+        dispatch(change(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField, null));
+        dispatch(untouch(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField));
+    };
+
+export const moveUp = () =>
+    (dispatch: Dispatch) => {
+        dispatch(searchBarActions.MOVE_UP());
+    };
+
+export const moveDown = () =>
+    (dispatch: Dispatch) => {
+        dispatch(searchBarActions.MOVE_DOWN());
+    };
diff --git a/services/workbench2/src/store/search-bar/search-bar-reducer.ts b/services/workbench2/src/store/search-bar/search-bar-reducer.ts
new file mode 100644 (file)
index 0000000..05b75bf
--- /dev/null
@@ -0,0 +1,147 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import {
+    getQueryFromAdvancedData,
+    searchBarActions,
+    SearchBarActions
+} from 'store/search-bar/search-bar-actions';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { SearchBarAdvancedFormData } from 'models/search-bar';
+
+type SearchResult = GroupContentsResource;
+export type SearchBarSelectedItem = {
+    id: string,
+    query: string
+};
+
+interface SearchBar {
+    currentView: string;
+    open: boolean;
+    searchResults: SearchResult[];
+    searchValue: string;
+    savedQueries: SearchBarAdvancedFormData[];
+    recentQueries: string[];
+    selectedItem: SearchBarSelectedItem;
+}
+
+export enum SearchView {
+    BASIC = 'basic',
+    ADVANCED = 'advanced',
+    AUTOCOMPLETE = 'autocomplete'
+}
+
+const initialState: SearchBar = {
+    currentView: SearchView.BASIC,
+    open: false,
+    searchResults: [],
+    searchValue: '',
+    savedQueries: [],
+    recentQueries: [],
+    selectedItem: {
+        id: '',
+        query: ''
+    },
+};
+
+const makeSelectedItem = (id: string, query?: string): SearchBarSelectedItem => ({ id, query: query ? query : id });
+
+const makeQueryList = (recentQueries: string[], savedQueries: SearchBarAdvancedFormData[]) => {
+    const recentIds = recentQueries.map((q, idx) => makeSelectedItem(`RQ-${idx}-${q}`, q));
+    const savedIds = savedQueries.map((q, idx) => makeSelectedItem(`SQ-${idx}-${q.queryName}`, getQueryFromAdvancedData(q)));
+    return recentIds.concat(savedIds);
+};
+
+export const searchBarReducer = (state = initialState, action: SearchBarActions): SearchBar =>
+    searchBarActions.match(action, {
+        SET_CURRENT_VIEW: currentView => ({
+            ...state,
+            currentView,
+            open: true
+        }),
+        OPEN_SEARCH_VIEW: () => ({ ...state, open: true }),
+        CLOSE_SEARCH_VIEW: () => ({ ...state, open: false }),
+        SET_SEARCH_RESULTS: searchResults => ({
+            ...state,
+            searchResults,
+            selectedItem: makeSelectedItem(searchResults.length > 0
+                ? searchResults.findIndex(r => r.uuid === state.selectedItem.id) >= 0
+                    ? state.selectedItem.id
+                    : state.searchValue
+                : state.searchValue
+            )
+        }),
+        SET_SEARCH_VALUE: searchValue => ({
+            ...state,
+            searchValue
+        }),
+        SET_SAVED_QUERIES: savedQueries => ({ ...state, savedQueries }),
+        SET_RECENT_QUERIES: recentQueries => ({ ...state, recentQueries }),
+        UPDATE_SAVED_QUERY: searchQuery => ({ ...state, savedQueries: searchQuery }),
+        SET_SELECTED_ITEM: item => ({ ...state, selectedItem: makeSelectedItem(item) }),
+        MOVE_UP: () => {
+            let selectedItem = state.selectedItem;
+            if (state.currentView === SearchView.AUTOCOMPLETE) {
+                const idx = state.searchResults.findIndex(r => r.uuid === selectedItem.id);
+                if (idx > 0) {
+                    selectedItem = makeSelectedItem(state.searchResults[idx - 1].uuid);
+                } else {
+                    selectedItem = makeSelectedItem(state.searchValue);
+                }
+            } else if (state.currentView === SearchView.BASIC) {
+                const items = makeQueryList(state.recentQueries, state.savedQueries);
+
+                const idx = items.findIndex(i => i.id === selectedItem.id);
+                if (idx > 0) {
+                    selectedItem = items[idx - 1];
+                }
+            }
+            return {
+                ...state,
+                selectedItem
+            };
+        },
+        MOVE_DOWN: () => {
+            let selectedItem = state.selectedItem;
+            if (state.currentView === SearchView.AUTOCOMPLETE) {
+                const idx = state.searchResults.findIndex(r => r.uuid === selectedItem.id);
+                if (idx >= 0 && idx < state.searchResults.length - 1) {
+                    selectedItem = makeSelectedItem(state.searchResults[idx + 1].uuid);
+                } else if (idx < 0 && state.searchResults.length > 0) {
+                    selectedItem = makeSelectedItem(state.searchResults[0].uuid);
+                }
+            } else if (state.currentView === SearchView.BASIC) {
+                const items = makeQueryList(state.recentQueries, state.savedQueries);
+
+                const idx = items.findIndex(i => i.id === selectedItem.id);
+                if (idx >= 0 && idx < items.length - 1) {
+                    selectedItem = items[idx + 1];
+                }
+
+                if (idx < 0 && items.length > 0) {
+                    selectedItem = items[0];
+                }
+            }
+            return {
+                ...state,
+                selectedItem
+            };
+        },
+        SELECT_FIRST_ITEM: () => {
+            let selectedItem = state.selectedItem;
+            if (state.currentView === SearchView.AUTOCOMPLETE) {
+                selectedItem = makeSelectedItem(state.searchValue);
+            } else if (state.currentView === SearchView.BASIC) {
+                const items = makeQueryList(state.recentQueries, state.savedQueries);
+                if (items.length > 0) {
+                    selectedItem = items[0];
+                }
+            }
+            return {
+                ...state,
+                selectedItem
+            };
+        },
+        default: () => state
+    });
diff --git a/services/workbench2/src/store/search-bar/search-bar-tree-actions.ts b/services/workbench2/src/store/search-bar/search-bar-tree-actions.ts
new file mode 100644 (file)
index 0000000..b0bad2f
--- /dev/null
@@ -0,0 +1,105 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { getTreePicker, TreePicker } from "store/tree-picker/tree-picker";
+import { getNode, getNodeAncestorsIds, initTreeNode } from "models/tree";
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository } from "services/services";
+import { treePickerActions } from "store/tree-picker/tree-picker-actions";
+import { FilterBuilder } from "services/api/filter-builder";
+import { OrderBuilder } from "services/api/order-builder";
+import { ProjectResource } from "models/project";
+import { resourcesActions } from "store/resources/resources-actions";
+import { SEARCH_BAR_ADVANCED_FORM_PICKER_ID } from "store/search-bar/search-bar-actions";
+
+const getSearchBarTreeNode = (id: string) => (treePicker: TreePicker) => {
+    const searchTree = getTreePicker(SEARCH_BAR_ADVANCED_FORM_PICKER_ID)(treePicker);
+    return searchTree
+        ? getNode(id)(searchTree)
+        : undefined;
+};
+
+export const loadSearchBarTreeProjects = (projectUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const treePicker = getTreePicker(SEARCH_BAR_ADVANCED_FORM_PICKER_ID)(getState().treePicker);
+        const node = treePicker ? getNode(projectUuid)(treePicker) : undefined;
+        if (node || projectUuid === '') {
+            await dispatch<any>(loadSearchBarProject(projectUuid));
+        }
+    };
+
+export const getSearchBarTreeNodeAncestorsIds = (id: string) => (treePicker: TreePicker) => {
+    const searchTree = getTreePicker(SEARCH_BAR_ADVANCED_FORM_PICKER_ID)(treePicker);
+    return searchTree
+        ? getNodeAncestorsIds(id)(searchTree)
+        : [];
+};
+
+export const activateSearchBarTreeBranch = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const ancestors = await services.ancestorsService.ancestors(id, userUuid);
+
+        for (const ancestor of ancestors) {
+            await dispatch<any>(loadSearchBarTreeProjects(ancestor.uuid));
+        }
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({
+            ids: [
+                ...[],
+                ...ancestors.map(ancestor => ancestor.uuid)
+            ],
+            pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID
+        }));
+        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
+    };
+
+export const expandSearchBarTreeItem = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const node = getSearchBarTreeNode(id)(getState().treePicker);
+        if (node && !node.expanded) {
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
+        }
+    };
+
+export const activateSearchBarProject = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+
+
+        /*const { treePicker } = getState();
+        const node = getSearchBarTreeNode(id)(treePicker);
+        if (node && node.status !== TreeNodeStatus.LOADED) {
+            await dispatch<any>(loadSearchBarTreeProjects(id));
+        } else if (node === undefined) {
+            await dispatch<any>(activateSearchBarTreeBranch(id));
+        }
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({
+            ids: getSearchBarTreeNodeAncestorsIds(id)(treePicker),
+            pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID
+        }));
+        dispatch<any>(expandSearchBarTreeItem(id));*/
+    };
+
+
+const loadSearchBarProject = (projectUuid: string) =>
+    async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: projectUuid, pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
+        const params = {
+            filters: new FilterBuilder()
+                .addEqual('owner_uuid', projectUuid)
+                .getFilters(),
+            order: new OrderBuilder<ProjectResource>()
+                .addAsc('name')
+                .getOrder()
+        };
+        const { items } = await services.projectService.list(params);
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id: projectUuid,
+            pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID,
+            nodes: items.map(item => initTreeNode({ id: item.uuid, value: item })),
+        }));
+        dispatch(resourcesActions.SET_RESOURCES(items));
+    };
diff --git a/services/workbench2/src/store/search-bar/search-query/arv-parser.ts b/services/workbench2/src/store/search-bar/search-query/arv-parser.ts
new file mode 100644 (file)
index 0000000..5331d2a
--- /dev/null
@@ -0,0 +1,79 @@
+
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as parser from 'store/search-bar/search-query/parser';
+
+interface Property {
+    key: string;
+    value: string;
+}
+
+export enum Keywords {
+    TYPE = 'type',
+    CLUSTER = 'cluster',
+    PROJECT = 'project',
+    IS = 'is',
+    FROM = 'from',
+    TO = 'to',
+}
+
+export enum States {
+    TRASHED = 'trashed',
+    PAST_VERSION = 'pastVersion'
+}
+
+const keyValuePattern = (key: string) => new RegExp(`${key}:([^ ]*)`);
+const propertyPattern = /has:"(.*?)":"(.*?)"/;
+
+const patterns = [
+    keyValuePattern(Keywords.TYPE),
+    keyValuePattern(Keywords.CLUSTER),
+    keyValuePattern(Keywords.PROJECT),
+    keyValuePattern(Keywords.IS),
+    keyValuePattern(Keywords.FROM),
+    keyValuePattern(Keywords.TO),
+    propertyPattern
+];
+
+export const parseSearchQuery = parser.parseSearchQuery(patterns);
+
+export const getValue = (tokens: string[]) => (key: string) => {
+    const pattern = keyValuePattern(key);
+    const token = tokens.find(t => pattern.test(t));
+    if (token) {
+        const [, value] = token.split(':');
+        return value;
+    }
+    return undefined;
+};
+
+export const getProperties = (tokens: string[]) =>
+    tokens.reduce((properties, token) => {
+        const match = token.match(propertyPattern);
+        if (match) {
+            const [, key, value] = match;
+            const newProperty = { key, value };
+            return [...properties, newProperty];
+        }
+        return properties;
+    }, [] as Property[]);
+
+
+export const isTrashed = (tokens: string[]) => isSomeState(States.TRASHED, tokens);
+
+export const isPastVersion = (tokens: string[]) => isSomeState(States.PAST_VERSION, tokens);
+
+const isSomeState = (state: string, tokens: string[]) => {
+    for (const token of tokens) {
+        const match = token.match(keyValuePattern(Keywords.IS)) || ['', ''];
+        if (match) {
+            const [, value] = match;
+            if(value === state) {
+                return true;
+            }
+        }
+    }
+    return false;
+};
diff --git a/services/workbench2/src/store/search-bar/search-query/parser.ts b/services/workbench2/src/store/search-bar/search-query/parser.ts
new file mode 100644 (file)
index 0000000..6eb01b8
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { uniq } from 'lodash/fp';
+
+export interface ParsedSearchQuery {
+    tokens: string[];
+    searchString: string;
+}
+
+export const findToken = (query: string, patterns: RegExp[]) => {
+    for (const pattern of patterns) {
+        const match = query.match(pattern);
+        if (match) {
+            return match[0];
+        }
+    }
+    return null;
+};
+
+export const findAllTokens = (query: string, patterns: RegExp[]): string[] => {
+    const token = findToken(query, patterns);
+    return token
+        ? [token].concat(findAllTokens(query.replace(token, ''), patterns))
+        : [];
+};
+
+export const findSearchString = (query: string, tokens: string[]) => {
+    const uniqueWords = uniq(tokens
+        .reduce((q, token) => q.replace(token, ''), query)
+        .split(' ')
+        .filter(word => word !== '')
+    );
+    return uniqueWords.join(' ');
+};
+
+export const parseSearchQuery = (patterns: RegExp[]) => (query: string): ParsedSearchQuery => {
+    const tokens = findAllTokens(query, patterns);
+    const searchString = findSearchString(query, tokens);
+    return { tokens, searchString };
+};
diff --git a/services/workbench2/src/store/search-results-panel/search-results-middleware-service.test.ts b/services/workbench2/src/store/search-results-panel/search-results-middleware-service.test.ts
new file mode 100644 (file)
index 0000000..34b7880
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { initialDataExplorer } from '../data-explorer/data-explorer-reducer'
+import { getParams } from './search-results-middleware-service'
+
+describe('search-results-middleware', () => {
+    describe('getParams', () => {
+        it('should use include_old_versions=true when asked', () => {
+            const dataExplorer = initialDataExplorer;
+            const query = 'Search term is:pastVersion';
+            const apiRev = 20201013;
+            const params = getParams(dataExplorer, query, apiRev);
+            expect(params.includeOldVersions).toBe(true);
+        });
+
+        it('should not use include_old_versions=true when not asked', () => {
+            const dataExplorer = initialDataExplorer;
+            const query = 'Search term';
+            const apiRev = 20201013;
+            const params = getParams(dataExplorer, query, apiRev);
+            expect(params.includeOldVersions).toBe(false);
+        });
+    })
+})
\ No newline at end of file
diff --git a/services/workbench2/src/store/search-results-panel/search-results-middleware-service.ts b/services/workbench2/src/store/search-results-panel/search-results-middleware-service.ts
new file mode 100644 (file)
index 0000000..dab83e0
--- /dev/null
@@ -0,0 +1,148 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta, getDataExplorerColumnFilters } from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
+import { updateResources } from 'store/resources/resources-actions';
+import { SortDirection } from 'components/data-table/data-column';
+import { OrderDirection, OrderBuilder } from 'services/api/order-builder';
+import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service";
+import { ListResults } from 'services/common-service/common-service';
+import { searchResultsPanelActions } from 'store/search-results-panel/search-results-panel-actions';
+import {
+    getSearchSessions,
+    queryToFilters,
+    getAdvancedDataFromQuery,
+} from 'store/search-bar/search-bar-actions';
+import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
+import { FilterBuilder, joinFilters } from 'services/api/filter-builder';
+import { DataColumns } from 'components/data-table/data-table';
+import { serializeResourceTypeFilters } from 'store//resource-type-filters/resource-type-filters';
+import { ProjectPanelColumnNames } from 'views/project-panel/project-panel';
+import { ResourceKind } from 'models/resource';
+import { ContainerRequestResource } from 'models/container-request';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+import { dataExplorerActions } from 'store/data-explorer/data-explorer-action';
+
+export class SearchResultsMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean) {
+        const state = api.getState();
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+        const searchValue = state.searchBar.searchValue;
+        const { cluster: clusterId } = getAdvancedDataFromQuery(searchValue);
+        const sessions = getSearchSessions(clusterId, state.auth.sessions);
+
+        if (searchValue.trim() === '') {
+            return;
+        }
+
+        const initial = {
+            itemsAvailable: 0,
+            items: [] as GroupContentsResource[],
+            kind: '',
+            offset: 0,
+            limit: 10
+        };
+
+        if (criteriaChanged) {
+            api.dispatch(setItems(initial));
+        }
+
+        const numberOfSessions = sessions.length;
+        let numberOfResolvedResponses = 0;
+        let totalNumItemsAvailable = 0;
+        api.dispatch(progressIndicatorActions.START_WORKING(this.id))
+        api.dispatch(dataExplorerActions.SET_IS_NOT_FOUND({ id: this.id, isNotFound: false }));
+
+        sessions.forEach(session => {
+            const params = getParams(dataExplorer, searchValue, session.apiRevision);
+            this.services.groupsService.contents('', params, session)
+                .then((response) => {
+                    api.dispatch(updateResources(response.items));
+                    api.dispatch(appendItems(response));
+                    numberOfResolvedResponses++;
+                    totalNumItemsAvailable += response.itemsAvailable;
+                    if (numberOfResolvedResponses === numberOfSessions) {
+                        api.dispatch(progressIndicatorActions.STOP_WORKING(this.id))
+                        if(totalNumItemsAvailable === 0) api.dispatch(dataExplorerActions.SET_IS_NOT_FOUND({ id: this.id, isNotFound: true }))
+                    }
+                    // Request all containers for process status to be available
+                    const containerRequests = response.items.filter((item) => item.kind === ResourceKind.CONTAINER_REQUEST) as ContainerRequestResource[];
+                    const containerUuids = containerRequests.map(container => container.containerUuid).filter(uuid => uuid !== null) as string[];
+                    containerUuids.length && this.services.containerService
+                        .list({
+                            filters: new FilterBuilder()
+                                .addIn('uuid', containerUuids)
+                                .getFilters()
+                        }, false)
+                        .then((containers) => {
+                            api.dispatch(updateResources(containers.items));
+                        });
+                    }).catch(() => {
+                        api.dispatch(couldNotFetchSearchResults(session.clusterId));
+                        api.dispatch(progressIndicatorActions.STOP_WORKING(this.id))
+                    });
+            }
+        );
+    }
+}
+
+const typeFilters = (columns: DataColumns<string, GroupContentsResource>) => serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
+
+export const getParams = (dataExplorer: DataExplorer, query: string, apiRevision: number) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    filters: joinFilters(
+        queryToFilters(query, apiRevision),
+        typeFilters(dataExplorer.columns)
+    ),
+    order: getOrder(dataExplorer),
+    includeTrash: getAdvancedDataFromQuery(query).inTrash,
+    includeOldVersions: getAdvancedDataFromQuery(query).pastVersions
+});
+
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<GroupContentsResource>(dataExplorer);
+    const order = new OrderBuilder<GroupContentsResource>();
+    if (sortColumn && sortColumn.sort) {
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC
+            ? OrderDirection.ASC
+            : OrderDirection.DESC;
+
+        // Use createdAt as a secondary sort column so we break ties consistently.
+        return order
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS)
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
+            .getOrder();
+    } else {
+        return order.getOrder();
+    }
+};
+
+export const setItems = (listResults: ListResults<GroupContentsResource>) =>
+    searchResultsPanelActions.SET_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
+
+export const appendItems = (listResults: ListResults<GroupContentsResource>) =>
+    searchResultsPanelActions.APPEND_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
+
+const couldNotFetchSearchResults = (cluster: string) =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: `Could not fetch search results from ${cluster}.`,
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/search-results-panel/search-results-panel-actions.ts b/services/workbench2/src/store/search-results-panel/search-results-panel-actions.ts
new file mode 100644 (file)
index 0000000..e1bcfa8
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { setBreadcrumbs } from 'store/breadcrumbs/breadcrumbs-actions';
+import { searchBarActions } from 'store/search-bar/search-bar-actions';
+
+export const SEARCH_RESULTS_PANEL_ID = "searchResultsPanel";
+export const searchResultsPanelActions = bindDataExplorerActions(SEARCH_RESULTS_PANEL_ID);
+
+export const loadSearchResultsPanel = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([{ label: 'Search results' }]));
+        const loc = getState().router.location;
+        if (loc !== null) {
+            const search = new URLSearchParams(loc.search);
+            const q = search.get('q');
+            if (q !== null) {
+                dispatch(searchBarActions.SET_SEARCH_VALUE(q));
+            }
+        }
+        dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
+        dispatch(searchResultsPanelActions.CLEAR());
+        dispatch(searchResultsPanelActions.REQUEST_ITEMS(true));
+    };
diff --git a/services/workbench2/src/store/shared-with-me-panel/shared-with-me-middleware-service.ts b/services/workbench2/src/store/shared-with-me-panel/shared-with-me-middleware-service.ts
new file mode 100644 (file)
index 0000000..1a2bdab
--- /dev/null
@@ -0,0 +1,91 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DataExplorerMiddlewareService, listResultsToDataExplorerItemsMeta, dataExplorerToListParams } from '../data-explorer/data-explorer-middleware-service';
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { getDataExplorer, DataExplorer } from 'store/data-explorer/data-explorer-reducer';
+import { updateFavorites } from 'store/favorites/favorites-actions';
+import { updateResources } from 'store/resources/resources-actions';
+import { loadMissingProcessesInformation, getFilters } from 'store/project-panel/project-panel-middleware-service';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { sharedWithMePanelActions } from './shared-with-me-panel-actions';
+import { ListResults } from 'services/common-service/common-service';
+import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
+import { SortDirection } from 'components/data-table/data-column';
+import { OrderBuilder, OrderDirection } from 'services/api/order-builder';
+import { ProjectResource } from 'models/project';
+import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
+import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
+import { FilterBuilder, joinFilters } from 'services/api/filter-builder';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+import { AuthState } from 'store/auth/auth-reducer';
+
+export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const state = api.getState();
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+        try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+            const response = await this.services.groupsService
+                .contents('', getParams(dataExplorer, state.auth));
+            api.dispatch<any>(updateFavorites(response.items.map(item => item.uuid)));
+            api.dispatch<any>(updatePublicFavorites(response.items.map(item => item.uuid)));
+            api.dispatch(updateResources(response.items));
+            await api.dispatch<any>(loadMissingProcessesInformation(response.items));
+            api.dispatch(setItems(response));
+        } catch (e) {
+            api.dispatch(couldNotFetchSharedItems());
+        } finally {
+            api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+        }
+    }
+}
+
+export const getParams = (dataExplorer: DataExplorer, authState: AuthState) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    order: getOrder(dataExplorer),
+    filters: joinFilters(
+        getFilters(dataExplorer),
+        new FilterBuilder().addDistinct('uuid', `${authState.config.uuidPrefix}-j7d0g-publicfavorites`).getFilters(),
+    ),
+    excludeHomeProject: true,
+});
+
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<ProjectResource>(dataExplorer);
+    const order = new OrderBuilder<ProjectResource>();
+    if (sortColumn && sortColumn.sort) {
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC
+            ? OrderDirection.ASC
+            : OrderDirection.DESC;
+
+        // Use createdAt as a secondary sort column so we break ties consistently.
+        return order
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS)
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
+            .getOrder();
+    } else {
+        return order.getOrder();
+    }
+};
+
+export const setItems = (listResults: ListResults<GroupContentsResource>) =>
+    sharedWithMePanelActions.SET_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
+
+const couldNotFetchSharedItems = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch shared items.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/shared-with-me-panel/shared-with-me-panel-actions.ts b/services/workbench2/src/store/shared-with-me-panel/shared-with-me-panel-actions.ts
new file mode 100644 (file)
index 0000000..616bd00
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
+
+export const SHARED_WITH_ME_PANEL_ID = "sharedWithMePanel";
+export const sharedWithMePanelActions = bindDataExplorerActions(SHARED_WITH_ME_PANEL_ID);
+export const loadSharedWithMePanel = () => (dispatch: Dispatch) => {
+    dispatch(sharedWithMePanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(sharedWithMePanelActions.REQUEST_ITEMS());
+};
\ No newline at end of file
diff --git a/services/workbench2/src/store/sharing-dialog/sharing-dialog-actions.ts b/services/workbench2/src/store/sharing-dialog/sharing-dialog-actions.ts
new file mode 100644 (file)
index 0000000..fb34398
--- /dev/null
@@ -0,0 +1,304 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from "store/dialog/dialog-actions";
+import { withDialog } from "store/dialog/with-dialog";
+import {
+    SHARING_DIALOG_NAME,
+    SHARING_INVITATION_FORM_NAME,
+    SharingManagementFormData,
+    SharingInvitationFormData,
+    getSharingMangementFormData,
+    SharingPublicAccessFormData,
+    VisibilityLevel,
+    SHARING_PUBLIC_ACCESS_FORM_NAME,
+} from './sharing-dialog-types';
+import { Dispatch } from 'redux';
+import { ServiceRepository } from "services/services";
+import { FilterBuilder } from 'services/api/filter-builder';
+import { initialize, getFormValues, reset } from 'redux-form';
+import { SHARING_MANAGEMENT_FORM_NAME } from 'store/sharing-dialog/sharing-dialog-types';
+import { RootState } from 'store/store';
+import { getDialog } from 'store/dialog/dialog-reducer';
+import { PermissionLevel, PermissionResource } from 'models/permission';
+import { differenceWith } from "lodash";
+import { withProgress } from "store/progress-indicator/with-progress";
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
+import {
+    extractUuidObjectType,
+    ResourceObjectType
+} from "models/resource";
+import { resourcesActions } from "store/resources/resources-actions";
+import { getPublicGroupUuid, getAllUsersGroupUuid } from "store/workflow-panel/workflow-panel-actions";
+import { getSharingPublicAccessFormData } from './sharing-dialog-types';
+
+export const openSharingDialog = (resourceUuid: string, refresh?: () => void) =>
+    (dispatch: Dispatch) => {
+        dispatch(dialogActions.OPEN_DIALOG({ id: SHARING_DIALOG_NAME, data: { resourceUuid, refresh } }));
+        dispatch<any>(loadSharingDialog);
+    };
+
+export const closeSharingDialog = () =>
+    dialogActions.CLOSE_DIALOG({ id: SHARING_DIALOG_NAME });
+
+export const connectSharingDialog = withDialog(SHARING_DIALOG_NAME);
+export const connectSharingDialogProgress = withProgress(SHARING_DIALOG_NAME);
+
+
+export const saveSharingDialogChanges = async (dispatch: Dispatch, getState: () => RootState) => {
+    dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+    await dispatch<any>(savePublicPermissionChanges);
+    await dispatch<any>(saveManagementChanges);
+    await dispatch<any>(sendInvitations);
+    dispatch(reset(SHARING_INVITATION_FORM_NAME));
+    await dispatch<any>(loadSharingDialog);
+    dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+
+    const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
+    if (dialog && dialog.data.refresh) {
+        dialog.data.refresh();
+    }
+};
+
+export interface SharingDialogData {
+    resourceUuid: string;
+    refresh: () => void;
+}
+
+export const createSharingToken = (expDate: Date | undefined) => async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => {
+    const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
+    if (dialog) {
+        const resourceUuid = dialog.data.resourceUuid;
+        if (extractUuidObjectType(resourceUuid) === ResourceObjectType.COLLECTION) {
+            dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+            try {
+                const sharingToken = await apiClientAuthorizationService.createCollectionSharingToken(resourceUuid, expDate);
+                dispatch(resourcesActions.SET_RESOURCES([sharingToken]));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Sharing URL created',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                }));
+            } catch (e) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Failed to create sharing URL',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR,
+                }));
+            } finally {
+                dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+            }
+        }
+    }
+};
+
+export const deleteSharingToken = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => {
+    dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+    try {
+        await apiClientAuthorizationService.delete(uuid);
+        dispatch(resourcesActions.DELETE_RESOURCES([uuid]));
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: 'Sharing URL removed',
+            hideDuration: 2000,
+            kind: SnackbarKind.SUCCESS,
+        }));
+    } catch (e) {
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: 'Failed to remove sharing URL',
+            hideDuration: 2000,
+            kind: SnackbarKind.ERROR,
+        }));
+    } finally {
+        dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+    }
+};
+
+const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => {
+
+    const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
+    const sharingURLsDisabled = getState().auth.config.clusterConfig.Workbench.DisableSharingURLsUI;
+    if (dialog) {
+        dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+        try {
+            const resourceUuid = dialog.data.resourceUuid;
+            await dispatch<any>(initializeManagementForm);
+            // For collections, we need to load the public sharing tokens
+            if (!sharingURLsDisabled && extractUuidObjectType(resourceUuid) === ResourceObjectType.COLLECTION) {
+                const sharingTokens = await apiClientAuthorizationService.listCollectionSharingTokens(resourceUuid);
+                dispatch(resourcesActions.SET_RESOURCES([...sharingTokens.items]));
+            }
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'You do not have access to share this item',
+                hideDuration: 2000,
+                kind: SnackbarKind.ERROR
+            }));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: SHARING_DIALOG_NAME }));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+        }
+    }
+};
+
+export const initializeManagementForm = async (dispatch: Dispatch, getState: () => RootState, { userService, groupsService, permissionService }: ServiceRepository) => {
+
+    const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
+    if (!dialog) {
+        return;
+    }
+    dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+    const resourceUuid = dialog?.data.resourceUuid;
+    const { items: permissionLinks } = await permissionService.listResourcePermissions(resourceUuid);
+    dispatch<any>(initializePublicAccessForm(permissionLinks));
+    const filters = new FilterBuilder()
+        .addIn('uuid', Array.from(new Set(permissionLinks.map(({ tailUuid }) => tailUuid))))
+        .getFilters();
+
+    const { items: users } = await userService.list({ filters, count: "none", limit: 1000 });
+    const { items: groups } = await groupsService.list({ filters, count: "none", limit: 1000 });
+
+    const getEmail = (tailUuid: string) => {
+        const user = users.find(({ uuid }) => uuid === tailUuid);
+        const group = groups.find(({ uuid }) => uuid === tailUuid);
+        return user
+            ? user.email
+            : group
+                ? group.name
+                : tailUuid;
+    };
+
+    const managementPermissions = permissionLinks
+        .map(({ tailUuid, name, uuid }) => ({
+            email: getEmail(tailUuid),
+            permissions: name as PermissionLevel,
+            permissionUuid: uuid,
+        }));
+
+    const managementFormData: SharingManagementFormData = {
+        permissions: managementPermissions,
+        initialPermissions: managementPermissions,
+    };
+
+    dispatch(initialize(SHARING_MANAGEMENT_FORM_NAME, managementFormData));
+    dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+};
+
+const initializePublicAccessForm = (permissionLinks: PermissionResource[]) =>
+    (dispatch: Dispatch, getState: () => RootState,) => {
+
+        const state = getState();
+
+        const [publicPermission] = permissionLinks
+            .filter(item => item.tailUuid === getPublicGroupUuid(state));
+
+        const [allUsersPermission] = permissionLinks
+            .filter(item => item.tailUuid === getAllUsersGroupUuid(state));
+
+        let publicAccessFormData: SharingPublicAccessFormData;
+
+        if (publicPermission) {
+            publicAccessFormData = {
+                visibility: VisibilityLevel.PUBLIC,
+                initialVisibility: VisibilityLevel.PUBLIC,
+                permissionUuid: publicPermission.uuid
+            };
+        } else if (allUsersPermission) {
+            publicAccessFormData = {
+                visibility: VisibilityLevel.ALL_USERS,
+                initialVisibility: VisibilityLevel.ALL_USERS,
+                permissionUuid: allUsersPermission.uuid
+            };
+        } else if (permissionLinks.length > 0) {
+            publicAccessFormData = {
+                visibility: VisibilityLevel.SHARED,
+                initialVisibility: VisibilityLevel.SHARED,
+                permissionUuid: ''
+            };
+        } else {
+            publicAccessFormData = {
+                visibility: VisibilityLevel.PRIVATE,
+                initialVisibility: VisibilityLevel.PRIVATE,
+                permissionUuid: ''
+            };
+        }
+
+        dispatch(initialize(SHARING_PUBLIC_ACCESS_FORM_NAME, publicAccessFormData));
+    };
+
+const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+    const state = getState();
+    const { user } = state.auth;
+    const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
+    if (dialog && user) {
+        const { permissionUuid, visibility, initialVisibility } = getSharingPublicAccessFormData(state);
+        // If visibility level changed, delete the previous link to public/all users.
+        // On PRIVATE this link will be deleted by saveManagementChanges
+        // so don't double delete (which would show an error dialog).
+        if (permissionUuid !== "" && visibility !== initialVisibility) {
+            await permissionService.delete(permissionUuid);
+        }
+        if (visibility === VisibilityLevel.ALL_USERS) {
+            await permissionService.create({
+                ownerUuid: user.uuid,
+                headUuid: dialog.data.resourceUuid,
+                tailUuid: getAllUsersGroupUuid(state),
+                name: PermissionLevel.CAN_READ,
+            });
+        } else if (visibility === VisibilityLevel.PUBLIC) {
+            await permissionService.create({
+                ownerUuid: user.uuid,
+                headUuid: dialog.data.resourceUuid,
+                tailUuid: getPublicGroupUuid(state),
+                name: PermissionLevel.CAN_READ,
+            });
+        }
+    }
+};
+
+const saveManagementChanges = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+    const state = getState();
+    const { user } = state.auth;
+    const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
+    if (dialog && user) {
+        const { initialPermissions, permissions } = getSharingMangementFormData(state);
+        const { visibility } = getSharingPublicAccessFormData(state);
+        const cancelledPermissions = visibility === VisibilityLevel.PRIVATE
+            ? initialPermissions
+            : differenceWith(
+                initialPermissions,
+                permissions,
+                (a, b) => a.permissionUuid === b.permissionUuid
+            );
+
+        const deletions = cancelledPermissions.map(async ({ permissionUuid }) => {
+            try {
+                await permissionService.delete(permissionUuid, false);
+            } catch (e) { }
+        });
+        const updates = permissions.map(async update => {
+            try {
+                await permissionService.update(update.permissionUuid, { name: update.permissions }, false);
+            } catch (e) { }
+        });
+        await Promise.all([...deletions, ...updates]);
+    }
+};
+
+const sendInvitations = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+    const state = getState();
+    const { user } = state.auth;
+    const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
+    if (dialog && user) {
+        const invitations = getFormValues(SHARING_INVITATION_FORM_NAME)(state) as SharingInvitationFormData;
+        const data = invitations.invitedPeople.map(invitee => ({
+            ownerUuid: user.uuid,
+            headUuid: dialog.data.resourceUuid,
+            tailUuid: invitee.uuid,
+            name: invitations.permissions
+        }));
+        const changes = data.map(invitation => permissionService.create(invitation));
+        await Promise.all(changes);
+    }
+};
diff --git a/services/workbench2/src/store/sharing-dialog/sharing-dialog-types.ts b/services/workbench2/src/store/sharing-dialog/sharing-dialog-types.ts
new file mode 100644 (file)
index 0000000..58ce3f0
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PermissionLevel } from 'models/permission';
+import { getFormValues, isDirty } from 'redux-form';
+import { RootState } from 'store/store';
+
+export const SHARING_DIALOG_NAME = 'SHARING_DIALOG_NAME';
+export const SHARING_PUBLIC_ACCESS_FORM_NAME = 'SHARING_PUBLIC_ACCESS_FORM_NAME';
+export const SHARING_MANAGEMENT_FORM_NAME = 'SHARING_MANAGEMENT_FORM_NAME';
+export const SHARING_INVITATION_FORM_NAME = 'SHARING_INVITATION_FORM_NAME';
+
+export enum VisibilityLevel {
+    PRIVATE = 'Private',
+    SHARED = 'Shared',
+    ALL_USERS = 'All users',
+    PUBLIC = 'Public',
+}
+
+export interface SharingPublicAccessFormData {
+    visibility: VisibilityLevel;
+    initialVisibility: VisibilityLevel;
+    permissionUuid: string;
+}
+
+export interface SharingManagementFormData {
+    permissions: SharingManagementFormDataRow[];
+    initialPermissions: SharingManagementFormDataRow[];
+}
+
+export interface SharingManagementFormDataRow {
+    email: string;
+    permissions: PermissionLevel;
+    permissionUuid: string;
+}
+
+export interface SharingInvitationFormData {
+    permissions: PermissionLevel;
+    invitedPeople: SharingInvitationFormPersonData[];
+}
+
+export interface SharingInvitationFormPersonData {
+    email: string;
+    name: string;
+    uuid: string;
+}
+
+export const getSharingMangementFormData = (state: any) =>
+    getFormValues(SHARING_MANAGEMENT_FORM_NAME)(state) as SharingManagementFormData;
+
+export const getSharingPublicAccessFormData = (state: any) =>
+    getFormValues(SHARING_PUBLIC_ACCESS_FORM_NAME)(state) as SharingPublicAccessFormData;
+
+export const hasChanges = (state: RootState) =>
+    isDirty(SHARING_PUBLIC_ACCESS_FORM_NAME)(state) ||
+    isDirty(SHARING_MANAGEMENT_FORM_NAME)(state) ||
+    (isDirty(SHARING_INVITATION_FORM_NAME)(state) && !!state.form[SHARING_INVITATION_FORM_NAME].values?.invitedPeople.length);
diff --git a/services/workbench2/src/store/side-panel-tree/side-panel-tree-actions.ts b/services/workbench2/src/store/side-panel-tree/side-panel-tree-actions.ts
new file mode 100644 (file)
index 0000000..d900c77
--- /dev/null
@@ -0,0 +1,333 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { treePickerActions } from "store/tree-picker/tree-picker-actions";
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository } from 'services/services';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { resourcesActions } from 'store/resources/resources-actions';
+import { getTreePicker, TreePicker } from 'store/tree-picker/tree-picker';
+import { getNodeAncestors, getNodeAncestorsIds, getNode, TreeNode, initTreeNode, TreeNodeStatus } from 'models/tree';
+import { ProjectResource } from 'models/project';
+import { OrderBuilder } from 'services/api/order-builder';
+import { ResourceKind } from 'models/resource';
+import { CategoriesListReducer } from 'common/plugintypes';
+import { pluginConfig } from 'plugins';
+import { LinkClass, LinkResource } from 'models/link';
+import { verifyAndUpdateLinks } from 'common/link-update-name';
+
+export enum SidePanelTreeCategory {
+    PROJECTS = 'Home Projects',
+    FAVORITES = 'My Favorites',
+    PUBLIC_FAVORITES = 'Public Favorites',
+    SHARED_WITH_ME = 'Shared with me',
+    ALL_PROCESSES = 'All Processes',
+    INSTANCE_TYPES = 'Instance Types',
+    SHELL_ACCESS = 'Shell Access',
+    GROUPS = 'Groups',
+    TRASH = 'Trash',
+}
+
+export const SIDE_PANEL_TREE = 'sidePanelTree';
+const SIDEPANEL_TREE_NODE_LIMIT = 50
+
+export const getSidePanelTree = (treePicker: TreePicker) =>
+    getTreePicker<ProjectResource | string>(SIDE_PANEL_TREE)(treePicker);
+
+export const getSidePanelTreeBranch = (uuid: string) => (treePicker: TreePicker): Array<TreeNode<ProjectResource | string>> => {
+    const tree = getSidePanelTree(treePicker);
+    if (tree) {
+        const ancestors = getNodeAncestors(uuid)(tree);
+        const node = getNode(uuid)(tree);
+        if (node) {
+            return [...ancestors, node];
+        }
+    }
+    return [];
+};
+
+let SIDE_PANEL_CATEGORIES: string[] = [
+    SidePanelTreeCategory.PROJECTS,
+    SidePanelTreeCategory.FAVORITES,
+    SidePanelTreeCategory.PUBLIC_FAVORITES,
+    SidePanelTreeCategory.SHARED_WITH_ME,
+    SidePanelTreeCategory.ALL_PROCESSES,
+    SidePanelTreeCategory.INSTANCE_TYPES,
+    SidePanelTreeCategory.SHELL_ACCESS,
+    SidePanelTreeCategory.GROUPS,
+    SidePanelTreeCategory.TRASH
+];
+
+const reduceCatsFn: (a: string[],
+    b: CategoriesListReducer) => string[] = (a, b) => b(a);
+
+SIDE_PANEL_CATEGORIES = pluginConfig.sidePanelCategories.reduce(reduceCatsFn, SIDE_PANEL_CATEGORIES);
+
+export const isSidePanelTreeCategory = (id: string) => SIDE_PANEL_CATEGORIES.some(category => category === id);
+
+
+export const initSidePanelTree = () =>
+    (dispatch: Dispatch, getState: () => RootState, { authService }: ServiceRepository) => {
+        const rootProjectUuid = getUserUuid(getState());
+        if (!rootProjectUuid) { return; }
+        const nodes = SIDE_PANEL_CATEGORIES.map(id => {
+            if (id === SidePanelTreeCategory.PROJECTS) {
+                return initTreeNode({ id: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS });
+            } else {
+                return initTreeNode({ id, value: id });
+            }
+        });
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id: '',
+            pickerId: SIDE_PANEL_TREE,
+            nodes
+        }));
+        SIDE_PANEL_CATEGORIES.forEach(category => {
+                if (category !== SidePanelTreeCategory.PROJECTS && category !== SidePanelTreeCategory.FAVORITES && category !== SidePanelTreeCategory.PUBLIC_FAVORITES ) {
+                dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+                    id: category,
+                    pickerId: SIDE_PANEL_TREE,
+                    nodes: []
+                }));
+            }
+        });
+    };
+
+export const loadSidePanelTreeProjects = (projectUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const treePicker = getTreePicker(SIDE_PANEL_TREE)(getState().treePicker);
+        const node = treePicker ? getNode(projectUuid)(treePicker) : undefined;
+        if (projectUuid === SidePanelTreeCategory.PUBLIC_FAVORITES) {
+            const unverifiedPubFaves = await dispatch<any>(loadPublicFavoritesTree());
+            verifyAndUpdateLinkNames(unverifiedPubFaves, dispatch, getState, services);
+        } else if (projectUuid === SidePanelTreeCategory.FAVORITES) {
+            const unverifiedFaves = await dispatch<any>(loadFavoritesTree());
+            await setFaves(unverifiedFaves, dispatch, getState, services);
+            verifyAndUpdateLinkNames(unverifiedFaves, dispatch, getState, services);
+        } else if (node || projectUuid !== '') {
+            await dispatch<any>(loadProject(projectUuid));
+        }
+    };
+
+const loadProject = (projectUuid: string) =>
+    async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: projectUuid, pickerId: SIDE_PANEL_TREE }));
+        const params = {
+            filters: new FilterBuilder()
+                .addEqual('owner_uuid', projectUuid)
+                .getFilters(),
+            order: new OrderBuilder<ProjectResource>()
+                .addDesc('createdAt')
+                .getOrder(),
+            limit: SIDEPANEL_TREE_NODE_LIMIT,
+        };
+
+        const { items } = await services.projectService.list(params);
+
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id: projectUuid,
+            pickerId: SIDE_PANEL_TREE,
+            nodes: items.map(item => initTreeNode({ id: item.uuid, value: item })),
+        }));
+        dispatch(resourcesActions.SET_RESOURCES(items));
+    };
+
+export const loadFavoritesTree = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: SidePanelTreeCategory.FAVORITES, pickerId: SIDE_PANEL_TREE }));
+
+    const params = {
+        filters: new FilterBuilder()
+            .addEqual('link_class', LinkClass.STAR)
+            .addEqual('tail_uuid', getUserUuid(getState()))
+            .addEqual('tail_kind', ResourceKind.USER)
+            .getFilters(),
+        order: new OrderBuilder<ProjectResource>().addDesc('createdAt').getOrder(),
+        limit: SIDEPANEL_TREE_NODE_LIMIT,
+    };
+
+    const { items } = await services.linkService.list(params);
+
+    dispatch(
+        treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id: SidePanelTreeCategory.FAVORITES,
+            pickerId: SIDE_PANEL_TREE,
+            nodes: items.map(item => initTreeNode({ id: item.headUuid, value: item.name })),
+        })
+    );
+
+    return items;
+};
+
+const setFaves = async(links: LinkResource[], dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+
+    const uuids = links.map(it => it.headUuid);
+    const groupItems: Promise<any> = services.groupsService.list({
+        filters: new FilterBuilder()
+            .addIn("uuid", uuids)
+            .getFilters()
+    });
+    const collectionItems: Promise<any> = services.collectionService.list({
+        filters: new FilterBuilder()
+            .addIn("uuid", uuids)
+            .getFilters()
+    });
+    const processItems: Promise<any> = services.containerRequestService.list({
+        filters: new FilterBuilder()
+            .addIn("uuid", uuids)
+            .getFilters()
+    });
+
+    const resolvedItems = await Promise.all([groupItems, collectionItems, processItems]);
+
+    const responseItems = resolvedItems.reduce((acc, response) => acc.concat(response.items), []);
+
+    //setting resources here so they won't be re-fetched in validation step
+    dispatch(resourcesActions.SET_RESOURCES(responseItems));
+};
+
+const verifyAndUpdateLinkNames = async (links: LinkResource[], dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const verfifiedLinks = await verifyAndUpdateLinks(links, dispatch, getState, services);
+
+    dispatch(
+        treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id: SidePanelTreeCategory.FAVORITES,
+            pickerId: SIDE_PANEL_TREE,
+            nodes: verfifiedLinks.map(item => initTreeNode({ id: item.headUuid, value: item })),
+        })
+    );
+};
+
+export const loadPublicFavoritesTree = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: SidePanelTreeCategory.PUBLIC_FAVORITES, pickerId: SIDE_PANEL_TREE }));
+
+    const uuidPrefix = getState().auth.config.uuidPrefix;
+    const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
+    const typeFilters = [ResourceKind.COLLECTION, ResourceKind.CONTAINER_REQUEST, ResourceKind.GROUP, ResourceKind.WORKFLOW];
+
+    const params = {
+        filters: new FilterBuilder()
+            .addEqual('link_class', LinkClass.STAR)
+            .addEqual('owner_uuid', publicProjectUuid)
+            .addIsA('head_uuid', typeFilters)
+            .getFilters(),
+        order: new OrderBuilder<ProjectResource>().addDesc('createdAt').getOrder(),
+        limit: SIDEPANEL_TREE_NODE_LIMIT,
+    };
+
+    const { items } = await services.linkService.list(params);
+
+    const uuids = items.map(it => it.headUuid);
+    const groupItems: Promise<any> = services.groupsService.list({
+        filters: new FilterBuilder()
+            .addIn("uuid", uuids)
+            .addIsA("uuid", typeFilters)
+            .getFilters()
+    });
+    const collectionItems: Promise<any> = services.collectionService.list({
+        filters: new FilterBuilder()
+            .addIn("uuid", uuids)
+            .addIsA("uuid", typeFilters)
+            .getFilters()
+    });
+    const processItems: Promise<any> = services.containerRequestService.list({
+        filters: new FilterBuilder()
+            .addIn("uuid", uuids)
+            .addIsA("uuid", typeFilters)
+            .getFilters()
+    });
+
+    const resolvedItems = await Promise.all([groupItems, collectionItems, processItems]);
+
+    const responseItems = resolvedItems.reduce((acc, response) => acc.concat(response.items), []);
+
+    const filteredItems = items.filter(item => responseItems.some(responseItem => responseItem.uuid === item.headUuid));
+
+    dispatch(
+        treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id: SidePanelTreeCategory.PUBLIC_FAVORITES,
+            pickerId: SIDE_PANEL_TREE,
+            nodes: filteredItems.map(item => initTreeNode({ id: item.headUuid, value: item })),
+        })
+    );
+
+    //setting resources here so they won't be re-fetched in validation step
+    dispatch(resourcesActions.SET_RESOURCES(responseItems));
+
+    return filteredItems;
+};
+
+export const activateSidePanelTreeItem = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const node = getSidePanelTreeNode(id)(getState().treePicker);
+        if (node && !node.active) {
+        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SIDE_PANEL_TREE }));
+    }
+    if (!isSidePanelTreeCategory(id)) {
+            await dispatch<any>(activateSidePanelTreeProject(id));
+        }
+    };
+
+export const activateSidePanelTreeProject = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const { treePicker } = getState();
+        const node = getSidePanelTreeNode(id)(treePicker);
+        if (node && node.status !== TreeNodeStatus.LOADED) {
+            await dispatch<any>(loadSidePanelTreeProjects(id));
+        } else if (node === undefined) {
+            await dispatch<any>(activateSidePanelTreeBranch(id));
+        }
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({
+            ids: getSidePanelTreeNodeAncestorsIds(id)(treePicker),
+            pickerId: SIDE_PANEL_TREE
+        }));
+        dispatch<any>(expandSidePanelTreeItem(id));
+    };
+
+export const activateSidePanelTreeBranch = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const ancestors = await services.ancestorsService.ancestors(id, userUuid);
+        for (const ancestor of ancestors) {
+            await dispatch<any>(loadSidePanelTreeProjects(ancestor.uuid));
+        }
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({
+            ids: ancestors.map(ancestor => ancestor.uuid),
+            pickerId: SIDE_PANEL_TREE
+        }));
+        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SIDE_PANEL_TREE }));
+    };
+
+export const toggleSidePanelTreeItemCollapse = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const node = getSidePanelTreeNode(id)(getState().treePicker);
+        if (node && node.status === TreeNodeStatus.INITIAL) {
+            await dispatch<any>(loadSidePanelTreeProjects(node.id));
+        }
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId: SIDE_PANEL_TREE }));
+    };
+
+export const expandSidePanelTreeItem = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const node = getSidePanelTreeNode(id)(getState().treePicker);
+        if (node && !node.expanded) {
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId: SIDE_PANEL_TREE }));
+        }
+    };
+
+export const getSidePanelTreeNode = (id: string) => (treePicker: TreePicker) => {
+    const sidePanelTree = getTreePicker(SIDE_PANEL_TREE)(treePicker);
+    return sidePanelTree
+        ? getNode(id)(sidePanelTree)
+        : undefined;
+};
+
+export const getSidePanelTreeNodeAncestorsIds = (id: string) => (treePicker: TreePicker) => {
+    const sidePanelTree = getTreePicker(SIDE_PANEL_TREE)(treePicker);
+    return sidePanelTree
+        ? getNodeAncestorsIds(id)(sidePanelTree)
+        : [];
+};
diff --git a/services/workbench2/src/store/side-panel/side-panel-action.ts b/services/workbench2/src/store/side-panel/side-panel-action.ts
new file mode 100644 (file)
index 0000000..644f76c
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { navigateTo } from 'store/navigation/navigation-action';
+
+export const sidePanelActions = {
+    TOGGLE_COLLAPSE: 'TOGGLE_COLLAPSE',
+    SET_CURRENT_WIDTH: 'SET_CURRENT_WIDTH'
+}
+
+export const navigateFromSidePanel = (id: string) =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(navigateTo(id));
+    };
+
+export const toggleSidePanel = (collapsedState: boolean) => {
+    return (dispatch) => {
+        dispatch({type: sidePanelActions.TOGGLE_COLLAPSE, payload: !collapsedState})
+    }
+}
+
+export const setCurrentSideWidth = (width: number) => {
+    return (dispatch) => {
+        dispatch({type: sidePanelActions.SET_CURRENT_WIDTH, payload: width})
+    }
+}
\ No newline at end of file
diff --git a/services/workbench2/src/store/side-panel/side-panel-reducer.tsx b/services/workbench2/src/store/side-panel/side-panel-reducer.tsx
new file mode 100644 (file)
index 0000000..0d9b1ad
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { sidePanelActions } from "./side-panel-action"
+
+interface SidePanelState {
+  collapsedState: boolean,
+  currentSideWidth: number
+}
+
+const sidePanelInitialState = {
+  collapsedState: false,
+  currentSideWidth: 0
+}
+
+export const sidePanelReducer = (state: SidePanelState = sidePanelInitialState, action)=>{
+  if(action.type === sidePanelActions.TOGGLE_COLLAPSE) return {...state, collapsedState: action.payload}
+  if(action.type === sidePanelActions.SET_CURRENT_WIDTH) return {...state, currentSideWidth: action.payload}
+  return state
+}
\ No newline at end of file
diff --git a/services/workbench2/src/store/snackbar/snackbar-actions.ts b/services/workbench2/src/store/snackbar/snackbar-actions.ts
new file mode 100644 (file)
index 0000000..7b6f2ef
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+
+export interface SnackbarMessage {
+    message: string;
+    hideDuration: number;
+    kind: SnackbarKind;
+    link?: string;
+}
+
+export enum SnackbarKind {
+    SUCCESS = 1,
+    ERROR = 2,
+    INFO = 3,
+    WARNING = 4
+}
+
+export const snackbarActions = unionize({
+    OPEN_SNACKBAR: ofType<{message: string; hideDuration?: number, kind?: SnackbarKind, link?: string}>(),
+    CLOSE_SNACKBAR: ofType<{}|null>(),
+    SHIFT_MESSAGES: ofType<{}>()
+});
+
+export type SnackbarAction = UnionOf<typeof snackbarActions>;
diff --git a/services/workbench2/src/store/snackbar/snackbar-reducer.ts b/services/workbench2/src/store/snackbar/snackbar-reducer.ts
new file mode 100644 (file)
index 0000000..c3fcfb0
--- /dev/null
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { SnackbarAction, snackbarActions, SnackbarKind, SnackbarMessage } from "./snackbar-actions";
+
+export interface SnackbarState {
+    messages: SnackbarMessage[];
+    open: boolean;
+}
+
+const DEFAULT_HIDE_DURATION = 3000;
+
+const initialState: SnackbarState = {
+    messages: [],
+    open: false
+};
+
+export const snackbarReducer = (state = initialState, action: SnackbarAction) => {
+    return snackbarActions.match(action, {
+        OPEN_SNACKBAR: data => {
+            return {
+                open: true,
+                messages: state.messages.concat({
+                    message: data.message,
+                    hideDuration: data.hideDuration ? data.hideDuration : DEFAULT_HIDE_DURATION,
+                    kind: data.kind ? data.kind : SnackbarKind.INFO, 
+                    link: data.link
+                })
+            };
+        },
+        CLOSE_SNACKBAR: (payload) => {
+            let newMessages: any = [...state.messages];// state.messages.filter(({ message }) => message !== payload);
+
+            if (payload === undefined || JSON.stringify(payload) === '{}') {
+                newMessages.pop();
+            } else {
+                newMessages = state.messages.filter((message, index) => index !== payload);
+            }
+
+            return {
+                ...state,
+                messages: newMessages,
+                open: newMessages.length > 0
+            }
+        },
+        SHIFT_MESSAGES: () => {
+            const messages = state.messages.filter((m, idx) => idx > 0);
+            return {
+                open: messages.length > 0,
+                messages
+            };
+        },
+        default: () => state,
+    });
+};
diff --git a/services/workbench2/src/store/store.ts b/services/workbench2/src/store/store.ts
new file mode 100644 (file)
index 0000000..ee861f1
--- /dev/null
@@ -0,0 +1,204 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from "redux";
+import { routerMiddleware, routerReducer } from "react-router-redux";
+import thunkMiddleware from "redux-thunk";
+import { History } from "history";
+import { handleRedirects } from "../common/redirect-to";
+
+import { authReducer } from "./auth/auth-reducer";
+import { authMiddleware } from "./auth/auth-middleware";
+import { dataExplorerReducer } from "./data-explorer/data-explorer-reducer";
+import { detailsPanelReducer } from "./details-panel/details-panel-reducer";
+import { contextMenuReducer } from "./context-menu/context-menu-reducer";
+import { reducer as formReducer } from "redux-form";
+import { favoritesReducer } from "./favorites/favorites-reducer";
+import { snackbarReducer } from "./snackbar/snackbar-reducer";
+import { collectionPanelFilesReducer } from "./collection-panel/collection-panel-files/collection-panel-files-reducer";
+import { dataExplorerMiddleware } from "./data-explorer/data-explorer-middleware";
+import { FAVORITE_PANEL_ID } from "./favorite-panel/favorite-panel-action";
+import { PROJECT_PANEL_ID } from "./project-panel/project-panel-action";
+import { WORKFLOW_PROCESSES_PANEL_ID } from "./workflow-panel/workflow-panel-actions";
+import { ProjectPanelMiddlewareService } from "./project-panel/project-panel-middleware-service";
+import { FavoritePanelMiddlewareService } from "./favorite-panel/favorite-panel-middleware-service";
+import { AllProcessesPanelMiddlewareService } from "./all-processes-panel/all-processes-panel-middleware-service";
+import { WorkflowProcessesMiddlewareService } from "./workflow-panel/workflow-middleware-service";
+import { collectionPanelReducer } from "./collection-panel/collection-panel-reducer";
+import { dialogReducer } from "./dialog/dialog-reducer";
+import { ServiceRepository } from "services/services";
+import { treePickerReducer, treePickerSearchReducer } from "./tree-picker/tree-picker-reducer";
+import { treePickerSearchMiddleware } from "./tree-picker/tree-picker-middleware";
+import { resourcesReducer } from "store/resources/resources-reducer";
+import { propertiesReducer } from "./properties/properties-reducer";
+import { fileUploaderReducer } from "./file-uploader/file-uploader-reducer";
+import { TrashPanelMiddlewareService } from "store/trash-panel/trash-panel-middleware-service";
+import { TRASH_PANEL_ID } from "store/trash-panel/trash-panel-action";
+import { processLogsPanelReducer } from "./process-logs-panel/process-logs-panel-reducer";
+import { processPanelReducer } from "store/process-panel/process-panel-reducer";
+import { SHARED_WITH_ME_PANEL_ID } from "store/shared-with-me-panel/shared-with-me-panel-actions";
+import { SharedWithMeMiddlewareService } from "./shared-with-me-panel/shared-with-me-middleware-service";
+import { progressIndicatorReducer } from "./progress-indicator/progress-indicator-reducer";
+import { runProcessPanelReducer } from "store/run-process-panel/run-process-panel-reducer";
+import { WorkflowMiddlewareService } from "./workflow-panel/workflow-middleware-service";
+import { WORKFLOW_PANEL_ID } from "./workflow-panel/workflow-panel-actions";
+import { appInfoReducer } from "store/app-info/app-info-reducer";
+import { searchBarReducer } from "./search-bar/search-bar-reducer";
+import { SEARCH_RESULTS_PANEL_ID } from "store/search-results-panel/search-results-panel-actions";
+import { SearchResultsMiddlewareService } from "./search-results-panel/search-results-middleware-service";
+import { virtualMachinesReducer } from "store/virtual-machines/virtual-machines-reducer";
+import { repositoriesReducer } from "store/repositories/repositories-reducer";
+import { keepServicesReducer } from "store/keep-services/keep-services-reducer";
+import { UserMiddlewareService } from "store/users/user-panel-middleware-service";
+import { USERS_PANEL_ID } from "store/users/users-actions";
+import { UserProfileGroupsMiddlewareService } from "store/user-profile/user-profile-groups-middleware-service";
+import { USER_PROFILE_PANEL_ID } from "store/user-profile/user-profile-actions";
+import { GroupsPanelMiddlewareService } from "store/groups-panel/groups-panel-middleware-service";
+import { GROUPS_PANEL_ID } from "store/groups-panel/groups-panel-actions";
+import { GroupDetailsPanelMembersMiddlewareService } from "store/group-details-panel/group-details-panel-members-middleware-service";
+import { GroupDetailsPanelPermissionsMiddlewareService } from "store/group-details-panel/group-details-panel-permissions-middleware-service";
+import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from "store/group-details-panel/group-details-panel-actions";
+import { LINK_PANEL_ID } from "store/link-panel/link-panel-actions";
+import { LinkMiddlewareService } from "store/link-panel/link-panel-middleware-service";
+import { API_CLIENT_AUTHORIZATION_PANEL_ID } from "store/api-client-authorizations/api-client-authorizations-actions";
+import { ApiClientAuthorizationMiddlewareService } from "store/api-client-authorizations/api-client-authorizations-middleware-service";
+import { PublicFavoritesMiddlewareService } from "store/public-favorites-panel/public-favorites-middleware-service";
+import { PUBLIC_FAVORITE_PANEL_ID } from "store/public-favorites-panel/public-favorites-action";
+import { publicFavoritesReducer } from "store/public-favorites/public-favorites-reducer";
+import { linkAccountPanelReducer } from "./link-account-panel/link-account-panel-reducer";
+import { CollectionsWithSameContentAddressMiddlewareService } from "store/collections-content-address-panel/collections-content-address-middleware-service";
+import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from "store/collections-content-address-panel/collections-content-address-panel-actions";
+import { ownerNameReducer } from "store/owner-name/owner-name-reducer";
+import { SubprocessMiddlewareService } from "store/subprocess-panel/subprocess-panel-middleware-service";
+import { SUBPROCESS_PANEL_ID } from "store/subprocess-panel/subprocess-panel-actions";
+import { ALL_PROCESSES_PANEL_ID } from "./all-processes-panel/all-processes-panel-action";
+import { Config } from "common/config";
+import { pluginConfig } from "plugins";
+import { MiddlewareListReducer } from "common/plugintypes";
+import { tooltipsMiddleware } from "./tooltips/tooltips-middleware";
+import { sidePanelReducer } from "./side-panel/side-panel-reducer";
+import { bannerReducer } from "./banner/banner-reducer";
+import { multiselectReducer } from "./multiselect/multiselect-reducer";
+import { composeWithDevTools } from "redux-devtools-extension";
+
+declare global {
+    interface Window {
+        __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose;
+    }
+}
+
+export type RootState = ReturnType<ReturnType<typeof createRootReducer>>;
+
+export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
+
+export function configureStore(history: History, services: ServiceRepository, config: Config): RootStore {
+    const rootReducer = createRootReducer(services);
+
+    const projectPanelMiddleware = dataExplorerMiddleware(new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID));
+    const favoritePanelMiddleware = dataExplorerMiddleware(new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID));
+    const allProcessessPanelMiddleware = dataExplorerMiddleware(new AllProcessesPanelMiddlewareService(services, ALL_PROCESSES_PANEL_ID));
+    const workflowProcessessPanelMiddleware = dataExplorerMiddleware(new WorkflowProcessesMiddlewareService(services, WORKFLOW_PROCESSES_PANEL_ID));
+    const trashPanelMiddleware = dataExplorerMiddleware(new TrashPanelMiddlewareService(services, TRASH_PANEL_ID));
+    const searchResultsPanelMiddleware = dataExplorerMiddleware(new SearchResultsMiddlewareService(services, SEARCH_RESULTS_PANEL_ID));
+    const sharedWithMePanelMiddleware = dataExplorerMiddleware(new SharedWithMeMiddlewareService(services, SHARED_WITH_ME_PANEL_ID));
+    const workflowPanelMiddleware = dataExplorerMiddleware(new WorkflowMiddlewareService(services, WORKFLOW_PANEL_ID));
+    const userPanelMiddleware = dataExplorerMiddleware(new UserMiddlewareService(services, USERS_PANEL_ID));
+    const userProfileGroupsMiddleware = dataExplorerMiddleware(new UserProfileGroupsMiddlewareService(services, USER_PROFILE_PANEL_ID));
+    const groupsPanelMiddleware = dataExplorerMiddleware(new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID));
+    const groupDetailsPanelMembersMiddleware = dataExplorerMiddleware(
+        new GroupDetailsPanelMembersMiddlewareService(services, GROUP_DETAILS_MEMBERS_PANEL_ID)
+    );
+    const groupDetailsPanelPermissionsMiddleware = dataExplorerMiddleware(
+        new GroupDetailsPanelPermissionsMiddlewareService(services, GROUP_DETAILS_PERMISSIONS_PANEL_ID)
+    );
+    const linkPanelMiddleware = dataExplorerMiddleware(new LinkMiddlewareService(services, LINK_PANEL_ID));
+    const apiClientAuthorizationMiddlewareService = dataExplorerMiddleware(
+        new ApiClientAuthorizationMiddlewareService(services, API_CLIENT_AUTHORIZATION_PANEL_ID)
+    );
+    const publicFavoritesMiddleware = dataExplorerMiddleware(new PublicFavoritesMiddlewareService(services, PUBLIC_FAVORITE_PANEL_ID));
+    const collectionsContentAddress = dataExplorerMiddleware(
+        new CollectionsWithSameContentAddressMiddlewareService(services, COLLECTIONS_CONTENT_ADDRESS_PANEL_ID)
+    );
+    const subprocessMiddleware = dataExplorerMiddleware(new SubprocessMiddlewareService(services, SUBPROCESS_PANEL_ID));
+
+    const redirectToMiddleware = (store: any) => (next: any) => (action: any) => {
+        const state = store.getState();
+
+        if (state.auth && state.auth.apiToken) {
+            handleRedirects(state.auth.apiToken, config);
+        }
+
+        return next(action);
+    };
+
+    let middlewares: Middleware[] = [
+        routerMiddleware(history),
+        thunkMiddleware.withExtraArgument(services),
+        authMiddleware(services),
+        tooltipsMiddleware(services),
+        projectPanelMiddleware,
+        favoritePanelMiddleware,
+        allProcessessPanelMiddleware,
+        trashPanelMiddleware,
+        searchResultsPanelMiddleware,
+        sharedWithMePanelMiddleware,
+        workflowPanelMiddleware,
+        userPanelMiddleware,
+        userProfileGroupsMiddleware,
+        groupsPanelMiddleware,
+        groupDetailsPanelMembersMiddleware,
+        groupDetailsPanelPermissionsMiddleware,
+        linkPanelMiddleware,
+        apiClientAuthorizationMiddlewareService,
+        publicFavoritesMiddleware,
+        collectionsContentAddress,
+        subprocessMiddleware,
+        treePickerSearchMiddleware,
+        workflowProcessessPanelMiddleware
+    ];
+
+    const reduceMiddlewaresFn: (a: Middleware[], b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services);
+
+    middlewares = pluginConfig.middlewares.reduce(reduceMiddlewaresFn, middlewares);
+
+    const enhancer = composeWithDevTools({
+        /* options */
+    })(applyMiddleware(redirectToMiddleware, ...middlewares));
+    return createStore(rootReducer, enhancer);
+}
+
+const createRootReducer = (services: ServiceRepository) =>
+    combineReducers({
+        auth: authReducer(services),
+        banner: bannerReducer,
+        collectionPanel: collectionPanelReducer,
+        collectionPanelFiles: collectionPanelFilesReducer,
+        contextMenu: contextMenuReducer,
+        dataExplorer: dataExplorerReducer,
+        detailsPanel: detailsPanelReducer,
+        dialog: dialogReducer,
+        favorites: favoritesReducer,
+        ownerName: ownerNameReducer,
+        publicFavorites: publicFavoritesReducer,
+        form: formReducer,
+        processLogsPanel: processLogsPanelReducer,
+        properties: propertiesReducer,
+        resources: resourcesReducer,
+        router: routerReducer,
+        snackbar: snackbarReducer,
+        treePicker: treePickerReducer,
+        treePickerSearch: treePickerSearchReducer,
+        fileUploader: fileUploaderReducer,
+        processPanel: processPanelReducer,
+        progressIndicator: progressIndicatorReducer,
+        runProcessPanel: runProcessPanelReducer,
+        appInfo: appInfoReducer,
+        searchBar: searchBarReducer,
+        virtualMachines: virtualMachinesReducer,
+        repositories: repositoriesReducer,
+        keepServices: keepServicesReducer,
+        linkAccountPanel: linkAccountPanelReducer,
+        sidePanel: sidePanelReducer,
+        multiselect: multiselectReducer,
+    });
diff --git a/services/workbench2/src/store/subprocess-panel/subprocess-panel-actions.ts b/services/workbench2/src/store/subprocess-panel/subprocess-panel-actions.ts
new file mode 100644 (file)
index 0000000..a67dd1f
--- /dev/null
@@ -0,0 +1,97 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { ProgressBarData } from 'components/subprocess-progress-bar/subprocess-progress-bar';
+import { ProcessStatusFilter, buildProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
+export const SUBPROCESS_PANEL_ID = "subprocessPanel";
+export const SUBPROCESS_ATTRIBUTES_DIALOG = 'subprocessAttributesDialog';
+export const subprocessPanelActions = bindDataExplorerActions(SUBPROCESS_PANEL_ID);
+
+export const loadSubprocessPanel = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(subprocessPanelActions.REQUEST_ITEMS());
+    };
+
+/**
+ * Holds a ProgressBarData status type and process count result
+ */
+type ProcessStatusBarCount = {
+    status: keyof ProgressBarData;
+    count: number;
+};
+
+/**
+ * Associates each of the limited progress bar segment types with an array of
+ * ProcessStatusFilterTypes to be combined when displayed
+ */
+type ProcessStatusMap = Record<keyof ProgressBarData, ProcessStatusFilter[]>;
+
+const statusMap: ProcessStatusMap = {
+        [ProcessStatusFilter.COMPLETED]: [ProcessStatusFilter.COMPLETED],
+        [ProcessStatusFilter.RUNNING]: [ProcessStatusFilter.RUNNING],
+        [ProcessStatusFilter.FAILED]: [ProcessStatusFilter.FAILED, ProcessStatusFilter.CANCELLED],
+        [ProcessStatusFilter.QUEUED]: [ProcessStatusFilter.QUEUED, ProcessStatusFilter.ONHOLD],
+};
+
+/**
+ * Utility type to hold a pair of associated progress bar status and process status
+ */
+type ProgressBarStatusPair = {
+    barStatus: keyof ProcessStatusMap;
+    processStatus: ProcessStatusFilter;
+};
+
+export const fetchSubprocessProgress = (requestingContainerUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<ProgressBarData | undefined> => {
+
+        const requestContainerStatusCount = async (fb: FilterBuilder) => {
+            return await services.containerRequestService.list({
+                limit: 0,
+                offset: 0,
+                filters: fb.getFilters(),
+            });
+        }
+
+        if (requestingContainerUuid) {
+            try {
+                const baseFilter = new FilterBuilder().addEqual('requesting_container_uuid', requestingContainerUuid).getFilters();
+
+                // Create return object
+                let result: ProgressBarData = {
+                    [ProcessStatusFilter.COMPLETED]: 0,
+                    [ProcessStatusFilter.RUNNING]: 0,
+                    [ProcessStatusFilter.FAILED]: 0,
+                    [ProcessStatusFilter.QUEUED]: 0,
+                }
+
+                // Create array of promises that returns the status associated with the item count
+                // Helps to make the requests simultaneously while preserving the association with the status key as a typed key
+                const promises = (Object.keys(statusMap) as Array<keyof ProcessStatusMap>)
+                    // Split statusMap into pairs of progress bar status and process status
+                    .reduce((acc, curr) => [...acc, ...statusMap[curr].map(processStatus => ({barStatus: curr, processStatus}))], [] as ProgressBarStatusPair[])
+                    .map(async (statusPair: ProgressBarStatusPair): Promise<ProcessStatusBarCount> => {
+                        // For each status pair, request count and return bar status and count
+                        const { barStatus, processStatus } = statusPair;
+                        const filter = buildProcessStatusFilters(new FilterBuilder(baseFilter), processStatus);
+                        const count = (await requestContainerStatusCount(filter)).itemsAvailable;
+                        return {status: barStatus, count};
+                    });
+
+                // Simultaneously requests each status count and apply them to the return object
+                (await Promise.all(promises)).forEach((singleResult) => {
+                    result[singleResult.status] += singleResult.count;
+                });
+                return result;
+            } catch (e) {
+                return undefined;
+            }
+        } else {
+            return undefined;
+        }
+    };
diff --git a/services/workbench2/src/store/subprocess-panel/subprocess-panel-middleware-service.ts b/services/workbench2/src/store/subprocess-panel/subprocess-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..0ac5df6
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from "../store";
+import { ServiceRepository } from "services/services";
+import { FilterBuilder, joinFilters } from "services/api/filter-builder";
+import { Dispatch, MiddlewareAPI } from "redux";
+import { DataExplorer } from "store/data-explorer/data-explorer-reducer";
+import { ProcessesMiddlewareService } from "store/processes/processes-middleware-service";
+import { subprocessPanelActions } from './subprocess-panel-actions';
+import { getProcess } from "store/processes/process";
+
+export class SubprocessMiddlewareService extends ProcessesMiddlewareService {
+    constructor(services: ServiceRepository, id: string) {
+        super(services, subprocessPanelActions, id);
+    }
+
+    getFilters(api: MiddlewareAPI<Dispatch, RootState>, dataExplorer: DataExplorer): string | null {
+        const state = api.getState();
+        const parentContainerRequestUuid = state.processPanel.containerRequestUuid;
+        if (!parentContainerRequestUuid) { return null; }
+
+        const process = getProcess(parentContainerRequestUuid)(state.resources);
+        if (!process?.container) { return null; }
+
+        const requesting_container = new FilterBuilder().addEqual('requesting_container_uuid', process.container.uuid).getFilters();
+        const sup = super.getFilters(api, dataExplorer);
+        if (sup === null) { return null; }
+
+        return joinFilters(sup, requesting_container);
+    }
+}
diff --git a/services/workbench2/src/store/token-dialog/token-dialog-actions.tsx b/services/workbench2/src/store/token-dialog/token-dialog-actions.tsx
new file mode 100644 (file)
index 0000000..6d07fa0
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from "store/dialog/dialog-actions";
+import { getProperty } from 'store/properties/properties';
+import { propertiesActions } from 'store/properties/properties-actions';
+import { RootState } from 'store/store';
+
+export const TOKEN_DIALOG_NAME = 'tokenDialog';
+const API_HOST_PROPERTY_NAME = 'apiHost';
+
+export interface TokenDialogData {
+    token: string;
+    tokenExpiration?: Date;
+    apiHost: string;
+    canCreateNewTokens: boolean;
+}
+
+export const setTokenDialogApiHost = (apiHost: string) =>
+    propertiesActions.SET_PROPERTY({ key: API_HOST_PROPERTY_NAME, value: apiHost });
+
+export const getTokenDialogData = (state: RootState): TokenDialogData => {
+    const loginCluster = state.auth.config.clusterConfig.Login.LoginCluster;
+    const canCreateNewTokens = !(loginCluster !== "" && state.auth.homeCluster !== loginCluster);
+
+    return {
+        apiHost: getProperty<string>(API_HOST_PROPERTY_NAME)(state.properties) || '',
+        token: state.auth.extraApiToken || state.auth.apiToken || '',
+        tokenExpiration: state.auth.extraApiToken
+            ? state.auth.extraApiTokenExpiration
+            : state.auth.apiTokenExpiration,
+        canCreateNewTokens,
+    };
+};
+
+export const openTokenDialog = dialogActions.OPEN_DIALOG({ id: TOKEN_DIALOG_NAME, data: {} });
diff --git a/services/workbench2/src/store/tooltips/tooltips-middleware.ts b/services/workbench2/src/store/tooltips/tooltips-middleware.ts
new file mode 100644 (file)
index 0000000..d4ea41e
--- /dev/null
@@ -0,0 +1,84 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionDirectory, CollectionFile } from "models/collection-file";
+import { Middleware, Store } from "redux";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
+import tippy, { createSingleton } from 'tippy.js';
+import 'tippy.js/dist/tippy.css';
+
+let running = false;
+let tooltipsContents = null;
+let tooltipsFetchFailed = false;
+export const TOOLTIP_LOCAL_STORAGE_KEY = "TOOLTIP_LOCAL_STORAGE_KEY";
+
+const tippySingleton = createSingleton([], {delay: 10});
+
+export const tooltipsMiddleware = (services: ServiceRepository): Middleware => (store: Store) => next => action => {
+    const state: RootState = store.getState();
+
+    if (state && state.auth && state.auth.config && state.auth.config.clusterConfig && state.auth.config.clusterConfig.Workbench) {
+        const hideTooltip = localStorage.getItem(TOOLTIP_LOCAL_STORAGE_KEY);
+        const { BannerUUID: bannerUUID } = state.auth.config.clusterConfig.Workbench;
+    
+        if (bannerUUID && !tooltipsContents && !hideTooltip && !tooltipsFetchFailed && !running) {
+            running = true;
+            fetchTooltips(services, bannerUUID);
+        } else if (tooltipsContents && !hideTooltip && !tooltipsFetchFailed) {
+            applyTooltips();
+        }
+    }
+
+    return next(action);
+};
+
+const fetchTooltips = (services, bannerUUID) => {
+    services.collectionService.files(bannerUUID)
+        .then(results => {
+            const tooltipsFile: CollectionDirectory | CollectionFile | undefined = results.find(({ name }) => name === 'tooltips.json');
+
+            if (tooltipsFile) {
+                running = true;
+                services.collectionService.getFileContents(tooltipsFile as CollectionFile)
+                    .then(data => {
+                        tooltipsContents = JSON.parse(data);
+                        applyTooltips();
+                    })
+                    .catch(() => {})
+                    .finally(() => {
+                        running = false;
+                    });
+            }  else {
+                tooltipsFetchFailed = true;
+            }
+        })
+        .catch(() => {})
+        .finally(() => {
+            running = false;
+        });
+};
+
+const applyTooltips = () => {
+    const tippyInstances: any[] = Object.keys(tooltipsContents as any)
+        .map((key) => {
+            const content = (tooltipsContents as any)[key]
+            const element = document.querySelector(key);
+
+            if (element) {
+                const hasTippyAttatched = !!(element as any)._tippy;
+
+                if (!hasTippyAttatched && tooltipsContents) {
+                    return tippy(element as any, { content });
+                }
+            }
+
+            return null;
+        })
+        .filter(data => !!data);
+
+    if (tippyInstances.length > 0) {
+        tippySingleton.setInstances(tippyInstances);
+    }
+};
\ No newline at end of file
diff --git a/services/workbench2/src/store/trash-panel/trash-panel-action.ts b/services/workbench2/src/store/trash-panel/trash-panel-action.ts
new file mode 100644 (file)
index 0000000..78b1a97
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+
+export const TRASH_PANEL_ID = "trashPanel";
+export const trashPanelActions = bindDataExplorerActions(TRASH_PANEL_ID);
+
+export const loadTrashPanel = () => (dispatch: Dispatch) => {
+    dispatch(trashPanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(trashPanelActions.REQUEST_ITEMS());
+};
\ No newline at end of file
diff --git a/services/workbench2/src/store/trash-panel/trash-panel-middleware-service.ts b/services/workbench2/src/store/trash-panel/trash-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..b0fed19
--- /dev/null
@@ -0,0 +1,115 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import {
+    DataExplorerMiddlewareService, dataExplorerToListParams,
+    listResultsToDataExplorerItemsMeta
+} from "../data-explorer/data-explorer-middleware-service";
+import { RootState } from "../store";
+import { getUserUuid } from "common/getuser";
+import { DataColumns } from "components/data-table/data-table";
+import { ServiceRepository } from "services/services";
+import { SortDirection } from "components/data-table/data-column";
+import { FilterBuilder } from "services/api/filter-builder";
+import { trashPanelActions } from "./trash-panel-action";
+import { Dispatch, MiddlewareAPI } from "redux";
+import { OrderBuilder, OrderDirection } from "services/api/order-builder";
+import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service";
+import { ProjectPanelColumnNames } from "views/project-panel/project-panel";
+import { updateFavorites } from "store/favorites/favorites-actions";
+import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { updateResources } from "store/resources/resources-actions";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { DataExplorer, getSortColumn } from "store/data-explorer/data-explorer-reducer";
+import { serializeResourceTypeFilters } from 'store//resource-type-filters/resource-type-filters';
+import { getDataExplorerColumnFilters } from 'store/data-explorer/data-explorer-middleware-service';
+import { joinFilters } from 'services/api/filter-builder';
+import { CollectionResource } from "models/collection";
+import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { removeDisabledButton } from "store/multiselect/multiselect-actions";
+export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const dataExplorer = api.getState().dataExplorer[this.getId()];
+        const columns = dataExplorer.columns as DataColumns<string, CollectionResource>;
+
+        const typeFilters = serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
+
+        const otherFilters = new FilterBuilder()
+            .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION)
+            // .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROCESS)
+            .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT)
+            .addEqual("is_trashed", true)
+            .getFilters();
+
+        const filters = joinFilters(
+            typeFilters,
+            otherFilters,
+        );
+
+        const userUuid = getUserUuid(api.getState());
+        if (!userUuid) { return; }
+        try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+            const listResults = await this.services.groupsService
+                .contents('', {
+                    ...dataExplorerToListParams(dataExplorer),
+                    order: getOrder(dataExplorer),
+                    filters,
+                    recursive: true,
+                    includeTrash: true
+                });
+            api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+
+            const items = listResults.items.map(it => it.uuid);
+
+            api.dispatch(trashPanelActions.SET_ITEMS({
+                ...listResultsToDataExplorerItemsMeta(listResults),
+                items
+            }));
+            api.dispatch<any>(updateFavorites(items));
+            api.dispatch<any>(updatePublicFavorites(items));
+            api.dispatch(updateResources(listResults.items));
+        } catch (e) {
+            api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+            api.dispatch(trashPanelActions.SET_ITEMS({
+                items: [],
+                itemsAvailable: 0,
+                page: 0,
+                rowsPerPage: dataExplorer.rowsPerPage
+            }));
+            api.dispatch(couldNotFetchTrashContents());
+        }
+        api.dispatch<any>(removeDisabledButton(ContextMenuActionNames.MOVE_TO_TRASH))
+    }
+}
+
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<GroupContentsResource>(dataExplorer);
+    const order = new OrderBuilder<GroupContentsResource>();
+    if (sortColumn && sortColumn.sort) {
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC
+            ? OrderDirection.ASC
+            : OrderDirection.DESC;
+
+        // Use createdAt as a secondary sort column so we break ties consistently.
+        return order
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
+            .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
+            .getOrder();
+    } else {
+        return order.getOrder();
+    }
+};
+
+const couldNotFetchTrashContents = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch trash contents.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/trash/trash-actions.ts b/services/workbench2/src/store/trash/trash-actions.ts
new file mode 100644 (file)
index 0000000..b6740bf
--- /dev/null
@@ -0,0 +1,129 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { trashPanelActions } from "store/trash-panel/trash-panel-action";
+import { activateSidePanelTreeItem, loadSidePanelTreeProjects } from "store/side-panel-tree/side-panel-tree-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { sharedWithMePanelActions } from "store/shared-with-me-panel/shared-with-me-panel-actions";
+import { ResourceKind } from "models/resource";
+import { navigateTo, navigateToTrash } from "store/navigation/navigation-action";
+import { matchCollectionRoute, matchSharedWithMeRoute } from "routes/routes";
+import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { addDisabledButton } from "store/multiselect/multiselect-actions";
+
+export const toggleProjectTrashed =
+    (uuid: string, ownerUuid: string, isTrashed: boolean, isMulti: boolean) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+            let errorMessage = "";
+            let successMessage = "";
+            let untrashedResource;
+            dispatch<any>(addDisabledButton(ContextMenuActionNames.MOVE_TO_TRASH))
+            try {
+                if (isTrashed) {
+                    errorMessage = "Could not restore project from trash";
+                    successMessage = "Restored project from trash";
+                     untrashedResource = await services.groupsService.untrash(uuid);
+                    dispatch<any>(isMulti || !untrashedResource ? navigateToTrash : navigateTo(uuid));
+                    dispatch<any>(activateSidePanelTreeItem(uuid));
+                } else {
+                    errorMessage = "Could not move project to trash";
+                    successMessage = "Added project to trash";
+                    await services.groupsService.trash(uuid);
+                    dispatch<any>(loadSidePanelTreeProjects(ownerUuid));
+                    
+                    const { location } = getState().router;
+                    if (matchSharedWithMeRoute(location ? location.pathname : "")) {
+                        dispatch(sharedWithMePanelActions.REQUEST_ITEMS());
+                    }
+                    else {
+                        dispatch<any>(navigateTo(ownerUuid));
+                    }
+                }
+                if (untrashedResource) {
+                        dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: successMessage,
+                            hideDuration: 2000,
+                            kind: SnackbarKind.SUCCESS,
+                        })
+                    );
+                }
+            } catch (e) {
+                if (e.status === 422) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: "Could not restore project from trash: Duplicate name at destination",
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                } else {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: errorMessage,
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                }
+            }
+        };
+
+export const toggleCollectionTrashed =
+    (uuid: string, isTrashed: boolean) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+            let errorMessage = "";
+            let successMessage = "";
+            dispatch<any>(addDisabledButton(ContextMenuActionNames.MOVE_TO_TRASH))
+            try {
+                if (isTrashed) {
+                    const { location } = getState().router;
+                    errorMessage = "Could not restore collection from trash";
+                    successMessage = "Restored from trash";
+                    await services.collectionService.untrash(uuid);
+                    if (matchCollectionRoute(location ? location.pathname : "")) {
+                        dispatch(navigateToTrash);
+                    }
+                    dispatch(trashPanelActions.REQUEST_ITEMS());
+                } else {
+                    errorMessage = "Could not move collection to trash";
+                    successMessage = "Added to trash";
+                    await services.collectionService.trash(uuid);
+                    dispatch(projectPanelActions.REQUEST_ITEMS());
+                }
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: successMessage,
+                        hideDuration: 2000,
+                        kind: SnackbarKind.SUCCESS,
+                    })
+                );
+            } catch (e) {
+                if (e.status === 422) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: "Could not restore collection from trash: Duplicate name at destination",
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                } else {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: errorMessage,
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                }
+            }
+        };
+
+export const toggleTrashed = (kind: ResourceKind, uuid: string, ownerUuid: string, isTrashed: boolean) => (dispatch: Dispatch) => {
+    if (kind === ResourceKind.PROJECT) {
+        dispatch<any>(toggleProjectTrashed(uuid, ownerUuid, isTrashed!!, false));
+    } else if (kind === ResourceKind.COLLECTION) {
+        dispatch<any>(toggleCollectionTrashed(uuid, isTrashed!!));
+    }
+};
diff --git a/services/workbench2/src/store/tree-picker/picker-id.tsx b/services/workbench2/src/store/tree-picker/picker-id.tsx
new file mode 100644 (file)
index 0000000..5734ad7
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+
+export interface PickerIdProp {
+    pickerId: string;
+}
+
+export const pickerId =
+    (id: string) =>
+    <P extends PickerIdProp>(Component: React.ComponentType<P>) =>
+    (props: P) => {
+        return (
+            <Component
+                {...props}
+                pickerId={id}
+            />
+        );
+    };
diff --git a/services/workbench2/src/store/tree-picker/tree-picker-actions.test.ts b/services/workbench2/src/store/tree-picker/tree-picker-actions.test.ts
new file mode 100644 (file)
index 0000000..7a55503
--- /dev/null
@@ -0,0 +1,189 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository, createServices } from "services/services";
+import { configureStore, RootStore } from "../store";
+import { createBrowserHistory } from "history";
+import { mockConfig } from 'common/config';
+import { ApiActions } from "services/api/api-actions";
+import Axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { ResourceKind } from 'models/resource';
+import { SHARED_PROJECT_ID, initProjectsTreePicker } from "./tree-picker-actions";
+import { CollectionResource } from "models/collection";
+import { GroupResource } from "models/group";
+import { CollectionDirectory, CollectionFile, CollectionFileType } from "models/collection-file";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { ListResults } from "services/common-service/common-service";
+
+describe('tree-picker-actions', () => {
+    const axiosInst = Axios.create({ headers: {} });
+    const axiosMock = new MockAdapter(axiosInst);
+
+    let store: RootStore;
+    let services: ServiceRepository;
+    const config: any = {};
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+    let importMocks: any[];
+
+    beforeEach(() => {
+        axiosMock.reset();
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+        localStorage.clear();
+        importMocks = [];
+    });
+
+    afterEach(() => {
+        importMocks.map(m => m.restore());
+    });
+
+    it('initializes preselected tree picker nodes', async () => {
+        const dispatchMock = jest.fn();
+        const dispatchWrapper = (action: any) => {
+            dispatchMock(action);
+            return store.dispatch(action);
+        };
+
+        const emptyCollectionUuid = "zzzzz-4zz18-000000000000000";
+        const collectionUuid = "zzzzz-4zz18-111111111111111";
+        const parentProjectUuid = "zzzzz-j7d0g-000000000000000";
+        const childCollectionUuid = "zzzzz-4zz18-222222222222222";
+
+        const fakeResources = {
+            [emptyCollectionUuid]: {
+                kind: ResourceKind.COLLECTION,
+                ownerUuid: '',
+                files: [],
+            },
+            [collectionUuid]: {
+                kind: ResourceKind.COLLECTION,
+                ownerUuid: '',
+                files: [{
+                    id: `${collectionUuid}/directory`,
+                    name: "directory",
+                    path: "",
+                    type: CollectionFileType.DIRECTORY,
+                    url: `/c=${collectionUuid}/directory/`,
+                }]
+            },
+            [parentProjectUuid]: {
+                kind: ResourceKind.GROUP,
+                ownerUuid: '',
+            },
+            [childCollectionUuid]: {
+                kind: ResourceKind.COLLECTION,
+                ownerUuid: parentProjectUuid,
+                files: [
+                    {
+                        id: `${childCollectionUuid}/mainDir`,
+                        name: "mainDir",
+                        path: "",
+                        type: CollectionFileType.DIRECTORY,
+                        url: `/c=${childCollectionUuid}/mainDir/`,
+                    },
+                    {
+                        id: `${childCollectionUuid}/mainDir/subDir`,
+                        name: "subDir",
+                        path: "/mainDir",
+                        type: CollectionFileType.DIRECTORY,
+                        url: `/c=${childCollectionUuid}/mainDir/subDir`,
+                    }
+                ],
+            },
+        };
+
+        services.ancestorsService.ancestors = jest.fn(async (startUuid, endUuid) => {
+            let ancestors: (GroupResource | CollectionResource)[] = [];
+            let uuid = startUuid;
+            while (uuid?.length && fakeResources[uuid]) {
+                const resource = fakeResources[uuid];
+                if (resource.kind === ResourceKind.COLLECTION) {
+                    ancestors.unshift({
+                        uuid, kind: resource.kind,
+                        ownerUuid: resource.ownerUuid,
+                    } as CollectionResource);
+                } else if (resource.kind === ResourceKind.GROUP) {
+                    ancestors.unshift({
+                        uuid, kind: resource.kind,
+                        ownerUuid: resource.ownerUuid,
+                    } as GroupResource);
+                }
+                uuid = resource.ownerUuid;
+            }
+            return ancestors;
+        });
+
+        services.collectionService.files = jest.fn(async (uuid): Promise<(CollectionDirectory | CollectionFile)[]> => {
+            return fakeResources[uuid]?.files || [];
+        });
+
+        services.groupsService.contents = jest.fn(async (uuid, args) => {
+            const items = Object.keys(fakeResources).map(uuid => ({...fakeResources[uuid], uuid})).filter(item => item.ownerUuid === uuid);
+            return {items: items as GroupContentsResource[], itemsAvailable: items.length} as ListResults<GroupContentsResource>;
+        });
+
+        const pickerId = "pickerId";
+
+        // When collection preselected
+        await initProjectsTreePicker(pickerId, {
+            selectedItemUuids: [emptyCollectionUuid],
+            includeDirectories: true,
+            includeFiles: false,
+            multi: true,
+        })(dispatchWrapper, store.getState, services);
+
+        // Expect ancestor service to be called
+        expect(services.ancestorsService.ancestors).toHaveBeenCalledWith(emptyCollectionUuid, '');
+        // Expect top level to be expanded and node to be selected
+        expect(store.getState().treePicker["pickerId_shared"][SHARED_PROJECT_ID].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][emptyCollectionUuid].selected).toBe(true);
+
+
+        // When collection subdirectory is preselected
+        await initProjectsTreePicker(pickerId, {
+            selectedItemUuids: [`${collectionUuid}/directory`],
+            includeDirectories: true,
+            includeFiles: false,
+            multi: true,
+        })(dispatchWrapper, store.getState, services);
+
+        // Expect ancestor service to be called
+        expect(services.ancestorsService.ancestors).toHaveBeenCalledWith(collectionUuid, '');
+        // Expect top level to be expanded and node to be selected
+        expect(store.getState().treePicker["pickerId_shared"][SHARED_PROJECT_ID].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][collectionUuid].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][collectionUuid].selected).toBe(false);
+        expect(store.getState().treePicker["pickerId_shared"][`${collectionUuid}/directory`].selected).toBe(true);
+
+
+        // When subdirectory of collection inside project is preselected
+        await initProjectsTreePicker(pickerId, {
+            selectedItemUuids: [`${childCollectionUuid}/mainDir/subDir`],
+            includeDirectories: true,
+            includeFiles: false,
+            multi: true,
+        })(dispatchWrapper, store.getState, services);
+
+        // Expect ancestor service to be called
+        expect(services.ancestorsService.ancestors).toHaveBeenCalledWith(childCollectionUuid, '');
+        // Expect parent project and collection to be expanded
+        expect(store.getState().treePicker["pickerId_shared"][SHARED_PROJECT_ID].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][parentProjectUuid].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][parentProjectUuid].selected).toBe(false);
+        expect(store.getState().treePicker["pickerId_shared"][childCollectionUuid].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][childCollectionUuid].selected).toBe(false);
+        // Expect main directory to be expanded
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir`].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir`].selected).toBe(false);
+        // Expect sub directory to be selected
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir/subDir`].expanded).toBe(false);
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir/subDir`].selected).toBe(true);
+
+
+    });
+});
diff --git a/services/workbench2/src/store/tree-picker/tree-picker-actions.ts b/services/workbench2/src/store/tree-picker/tree-picker-actions.ts
new file mode 100644 (file)
index 0000000..883847d
--- /dev/null
@@ -0,0 +1,718 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree, setNode, createTree } from 'models/tree';
+import { CollectionFileType, createCollectionFilesTree, getCollectionResourceCollectionUuid } from "models/collection-file";
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository } from 'services/services';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { pipe, values } from 'lodash/fp';
+import { ResourceKind } from 'models/resource';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { getTreePicker, TreePicker } from './tree-picker';
+import { ProjectsTreePickerItem } from './tree-picker-middleware';
+import { OrderBuilder } from 'services/api/order-builder';
+import { ProjectResource } from 'models/project';
+import { mapTree } from '../../models/tree';
+import { LinkResource, LinkClass } from "models/link";
+import { mapTreeValues } from "models/tree";
+import { sortFilesTree } from "services/collection-service/collection-service-files-response";
+import { GroupClass, GroupResource } from "models/group";
+import { CollectionResource } from "models/collection";
+import { getResource } from "store/resources/resources";
+import { updateResources } from "store/resources/resources-actions";
+import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions";
+
+export const treePickerActions = unionize({
+    LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
+    LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
+    APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
+    TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
+    EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
+    EXPAND_TREE_PICKER_NODE_ANCESTORS: ofType<{ id: string, pickerId: string }>(),
+    ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
+    DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
+    TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string, cascade: boolean }>(),
+    SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
+    DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
+    EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
+    RESET_TREE_PICKER: ofType<{ pickerId: string }>()
+});
+
+export type TreePickerAction = UnionOf<typeof treePickerActions>;
+
+export interface LoadProjectParams {
+    includeCollections?: boolean;
+    includeDirectories?: boolean;
+    includeFiles?: boolean;
+    includeFilterGroups?: boolean;
+    options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
+}
+
+export const treePickerSearchActions = unionize({
+    SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
+    SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
+    SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
+    REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
+});
+
+export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
+
+export const getProjectsTreePickerIds = (pickerId: string) => ({
+    home: `${pickerId}_home`,
+    shared: `${pickerId}_shared`,
+    favorites: `${pickerId}_favorites`,
+    publicFavorites: `${pickerId}_publicFavorites`,
+    search: `${pickerId}_search`,
+});
+
+export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
+    pipe(
+        () => values(getProjectsTreePickerIds(pickerId)),
+
+        ids => ids
+            .map(id => getTreePicker<Value>(id)(state)),
+
+        trees => trees
+            .map(getNodeDescendants(''))
+            .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
+
+        allNodes => allNodes
+            .reduce((map, node) =>
+                filter(node)
+                    ? map.set(node.id, node)
+                    : map, new Map<string, TreeNode<Value>>())
+            .values(),
+
+        uniqueNodes => Array.from(uniqueNodes),
+    )();
+export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
+    getAllNodes<Value>(pickerId, node => node.selected)(state);
+
+interface TreePickerPreloadParams {
+    selectedItemUuids: string[];
+    includeDirectories: boolean;
+    includeFiles: boolean;
+    multi: boolean;
+}
+
+export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
+        dispatch<any>(initUserProject(home));
+        dispatch<any>(initSharedProject(shared));
+        dispatch<any>(initFavoritesProject(favorites));
+        dispatch<any>(initPublicFavoritesProject(publicFavorites));
+        dispatch<any>(initSearchProject(search));
+
+        if (preloadParams && preloadParams.selectedItemUuids.length) {
+            await dispatch<any>(loadInitialValue(
+                preloadParams.selectedItemUuids,
+                pickerId,
+                preloadParams.includeDirectories,
+                preloadParams.includeFiles,
+                preloadParams.multi
+            ));
+        }
+    };
+
+interface ReceiveTreePickerDataParams<T> {
+    data: T[];
+    extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
+    id: string;
+    pickerId: string;
+}
+
+export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
+    (dispatch: Dispatch) => {
+        const { data, extractNodeData, id, pickerId, } = params;
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id,
+            nodes: data.map(item => initTreeNode(extractNodeData(item))),
+            pickerId,
+        }));
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
+    };
+
+interface LoadProjectParamsWithId extends LoadProjectParams {
+    id: string;
+    pickerId: string;
+    loadShared?: boolean;
+    searchProjects?: boolean;
+}
+
+/**
+ * loadProject is used to load or refresh a project node in a tree picker
+ *   Errors are caught and a toast is shown if the project fails to load
+ */
+export const loadProject = (params: LoadProjectParamsWithId) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const {
+            id,
+            pickerId,
+            includeCollections = false,
+            includeDirectories = false,
+            includeFiles = false,
+            includeFilterGroups = false,
+            loadShared = false,
+            options,
+            searchProjects = false
+        } = params;
+
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
+
+        let filterB = new FilterBuilder();
+
+        filterB = (includeCollections && !searchProjects)
+            ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
+            : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
+
+        const state = getState();
+
+        if (state.treePickerSearch.collectionFilterValues[pickerId]) {
+            filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
+        } else {
+            filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
+        }
+
+        if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
+            filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
+        }
+
+        const filters = filterB.getFilters();
+
+        const itemLimit = 200;
+
+        try {
+            const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
+            dispatch<any>(updateResources(items));
+
+            if (itemsAvailable > itemLimit) {
+                items.push({
+                    uuid: "more-items-available",
+                    kind: ResourceKind.WORKFLOW,
+                    name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
+                    description: "",
+                    definition: "",
+                    ownerUuid: "",
+                    createdAt: "",
+                    modifiedByClientUuid: "",
+                    modifiedByUserUuid: "",
+                    modifiedAt: "",
+                    href: "",
+                    etag: ""
+                });
+            }
+
+            dispatch<any>(receiveTreePickerData<GroupContentsResource>({
+                id,
+                pickerId,
+                data: items.filter((item) => {
+                    if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
+                        return false;
+                    }
+
+                    if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                        return false;
+                    }
+
+                    return true;
+                }),
+                extractNodeData: item => (
+                    item.uuid === "more-items-available" ?
+                        {
+                            id: item.uuid,
+                            value: item,
+                            status: TreeNodeStatus.LOADED
+                        }
+                        : {
+                            id: item.uuid,
+                            value: item,
+                            status: item.kind === ResourceKind.PROJECT
+                                ? TreeNodeStatus.INITIAL
+                                : includeDirectories || includeFiles
+                                    ? TreeNodeStatus.INITIAL
+                                    : TreeNodeStatus.LOADED
+                        }),
+            }));
+        } catch(e) {
+            console.error("Failed to load project into tree picker:", e);;
+            dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
+
+        const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
+        if (picker) {
+
+            const node = getNode(id)(picker);
+            if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
+                const files = (await services.collectionService.files(node.value.uuid))
+                    .filter((file) => (
+                        (includeFiles) ||
+                        (includeDirectories && file.type === CollectionFileType.DIRECTORY)
+                    ));
+                const tree = createCollectionFilesTree(files);
+                const sorted = sortFilesTree(tree);
+                const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
+
+                // await tree modifications so that consumers can guarantee node presence
+                await dispatch(
+                    treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
+                        id,
+                        pickerId,
+                        subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
+                    }));
+
+                // Expand collection root node
+                dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
+            }
+        }
+    };
+
+export const HOME_PROJECT_ID = 'Home Projects';
+export const initUserProject = (pickerId: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const uuid = getUserUuid(getState());
+        if (uuid) {
+            dispatch(receiveTreePickerData({
+                id: '',
+                pickerId,
+                data: [{ uuid, name: HOME_PROJECT_ID }],
+                extractNodeData: value => ({
+                    id: value.uuid,
+                    status: TreeNodeStatus.INITIAL,
+                    value,
+                }),
+            }));
+        }
+    };
+export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const uuid = getUserUuid(getState());
+        if (uuid) {
+            dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
+        }
+    };
+
+export const SHARED_PROJECT_ID = 'Shared with me';
+export const initSharedProject = (pickerId: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(receiveTreePickerData({
+            id: '',
+            pickerId,
+            data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
+            extractNodeData: value => ({
+                id: value.uuid,
+                status: TreeNodeStatus.INITIAL,
+                value,
+            }),
+        }));
+    };
+
+type PickerItemPreloadData = {
+    itemId: string;
+    mainItemUuid: string;
+    ancestors: (GroupResource | CollectionResource)[];
+    isHomeProjectItem: boolean;
+}
+
+type PickerTreePreloadData = {
+    tree: Tree<GroupResource | CollectionResource>;
+    pickerTreeId: string;
+    pickerTreeRootUuid: string;
+};
+
+export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const homeUuid = getUserUuid(getState());
+
+        // Request ancestor trees in paralell and save home project status
+        const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
+            const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
+
+            const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
+            .filter(item =>
+                item.kind === ResourceKind.GROUP ||
+                item.kind === ResourceKind.COLLECTION
+            ) as (GroupResource | CollectionResource)[];
+
+            if (ancestors.length === 0) {
+                return Promise.reject({item: itemId});
+            }
+
+            const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
+
+            return {
+                itemId,
+                mainItemUuid,
+                ancestors,
+                isHomeProjectItem,
+            };
+        })).then((res) => {
+            // Show toast if any selections failed to restore
+            const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'));
+            if (rejectedPromises.length) {
+                rejectedPromises.forEach(item => {
+                    console.error("The following item failed to load into the tree picker", item.reason);
+                });
+                dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
+            }
+            // Filter out any failed promises and map to resulting preload data with ancestors
+            return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
+                promiseResult.status === 'fulfilled'
+            )).map(res => res.value)
+        });
+
+        // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
+        const initialTreePreloadData: PickerTreePreloadData[] = [
+            pickerItemsData.filter((item) => item.isHomeProjectItem),
+            pickerItemsData.filter((item) => !item.isHomeProjectItem),
+        ]
+            .filter((items) => items.length > 0)
+            .map((itemGroup) =>
+                itemGroup.reduce(
+                    (preloadTree, itemData) => ({
+                        tree: createInitialPickerTree(
+                            itemData.ancestors,
+                            itemData.mainItemUuid,
+                            preloadTree.tree
+                        ),
+                        pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
+                        pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
+                    }),
+                    {
+                        tree: createTree<GroupResource | CollectionResource>(),
+                        pickerTreeId: '',
+                        pickerTreeRootUuid: '',
+                    } as PickerTreePreloadData
+                )
+            );
+
+        // Load initial trees into corresponding picker store
+        await Promise.all(initialTreePreloadData.map(preloadTree => (
+            dispatch(
+                treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
+                    id: preloadTree.pickerTreeRootUuid,
+                    pickerId: preloadTree.pickerTreeId,
+                    subtree: preloadTree.tree,
+                })
+            )
+        )));
+
+        // Await loading collection before attempting to select items
+        await Promise.all(pickerItemsData.map(async itemData => {
+            const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
+
+            // Selected item resides in collection subpath
+            if (itemData.itemId.includes('/')) {
+                // Load collection into tree
+                // loadCollection includes more than dispatched actions and must be awaited
+                await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
+            }
+            // Expand nodes down to destination
+            dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
+        }));
+
+        // Select or activate nodes
+        pickerItemsData.forEach(itemData => {
+            const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
+
+            if (multi) {
+                dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
+            } else {
+                dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
+            }
+        });
+
+        // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
+        await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
+    }
+
+const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
+    const { home, shared } = getProjectsTreePickerIds(pickerId);
+    return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
+};
+
+const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
+    return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
+};
+
+export const FAVORITES_PROJECT_ID = 'Favorites';
+export const initFavoritesProject = (pickerId: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(receiveTreePickerData({
+            id: '',
+            pickerId,
+            data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
+            extractNodeData: value => ({
+                id: value.uuid,
+                status: TreeNodeStatus.INITIAL,
+                value,
+            }),
+        }));
+    };
+
+export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
+export const initPublicFavoritesProject = (pickerId: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(receiveTreePickerData({
+            id: '',
+            pickerId,
+            data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
+            extractNodeData: value => ({
+                id: value.uuid,
+                status: TreeNodeStatus.INITIAL,
+                value,
+            }),
+        }));
+    };
+
+export const SEARCH_PROJECT_ID = 'Search all Projects';
+export const initSearchProject = (pickerId: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(receiveTreePickerData({
+            id: '',
+            pickerId,
+            data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
+            extractNodeData: value => ({
+                id: value.uuid,
+                status: TreeNodeStatus.INITIAL,
+                value,
+            }),
+        }));
+    };
+
+
+interface LoadFavoritesProjectParams {
+    pickerId: string;
+    includeCollections?: boolean;
+    includeDirectories?: boolean;
+    includeFiles?: boolean;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+}
+
+export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
+    options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
+        const uuid = getUserUuid(getState());
+        if (uuid) {
+            const filters = pipe(
+                (fb: FilterBuilder) => includeCollections
+                    ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
+                    : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
+                fb => fb.getFilters(),
+            )(new FilterBuilder());
+
+            const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
+
+            dispatch<any>(receiveTreePickerData<GroupContentsResource>({
+                id: 'Favorites',
+                pickerId,
+                data: items.filter((item) => {
+                    if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
+                        return false;
+                    }
+
+                    if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                        return false;
+                    }
+
+                    return true;
+                }),
+                extractNodeData: item => ({
+                    id: item.uuid,
+                    value: item,
+                    status: item.kind === ResourceKind.PROJECT
+                        ? TreeNodeStatus.INITIAL
+                        : includeDirectories || includeFiles
+                            ? TreeNodeStatus.INITIAL
+                            : TreeNodeStatus.LOADED
+                }),
+            }));
+        }
+    };
+
+export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
+        const uuidPrefix = getState().auth.config.uuidPrefix;
+        const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
+
+        const filters = pipe(
+            (fb: FilterBuilder) => includeCollections
+                ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
+                : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
+            fb => fb
+                .addEqual('link_class', LinkClass.STAR)
+                .addEqual('owner_uuid', publicProjectUuid)
+                .getFilters(),
+        )(new FilterBuilder());
+
+        const { items } = await services.linkService.list({ filters });
+
+        dispatch<any>(receiveTreePickerData<LinkResource>({
+            id: 'Public Favorites',
+            pickerId,
+            data: items.filter(item => {
+                if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
+                    return false;
+                }
+
+                return true;
+            }),
+            extractNodeData: item => ({
+                id: item.headUuid,
+                value: item,
+                status: item.headKind === ResourceKind.PROJECT
+                    ? TreeNodeStatus.INITIAL
+                    : includeDirectories || includeFiles
+                        ? TreeNodeStatus.INITIAL
+                        : TreeNodeStatus.LOADED
+            }),
+        }));
+    };
+
+export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id,
+            nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
+            pickerId,
+        }));
+
+        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
+    };
+
+export const loadProjectTreePickerProjects = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
+
+
+        const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
+        const { items } = await services.projectService.list(buildParams(ownerUuid));
+
+        dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
+    };
+
+export const loadFavoriteTreePickerProjects = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const parentId = getUserUuid(getState()) || '';
+
+        if (id === '') {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
+            const { items } = await services.favoriteService.list(parentId);
+            dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
+        } else {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
+            const { items } = await services.projectService.list(buildParams(id));
+            dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
+        }
+
+    };
+
+export const loadPublicFavoriteTreePickerProjects = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const parentId = getUserUuid(getState()) || '';
+
+        if (id === '') {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
+            const { items } = await services.favoriteService.list(parentId);
+            dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
+        } else {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
+            const { items } = await services.projectService.list(buildParams(id));
+            dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
+        }
+
+    };
+
+const buildParams = (ownerUuid: string) => {
+    return {
+        filters: new FilterBuilder()
+            .addEqual('owner_uuid', ownerUuid)
+            .getFilters(),
+        order: new OrderBuilder<ProjectResource>()
+            .addAsc('name')
+            .getOrder()
+    };
+};
+
+/**
+ * Given a tree picker item, return collection uuid and path
+ *   if the item represents a valid target/destination location
+ */
+export type FileOperationLocation = {
+    name: string;
+    uuid: string;
+    pdh?: string;
+    subpath: string;
+}
+export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
+        if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
+            return {
+                name: item.name,
+                uuid: item.uuid,
+                pdh: item.portableDataHash,
+                subpath: '/',
+            };
+        } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
+            const uuid = getCollectionResourceCollectionUuid(item.id);
+            if (uuid) {
+                const collection = getResource<CollectionResource>(uuid)(getState().resources);
+                if (collection) {
+                    const itemPath = [item.path, item.name].join('/');
+
+                    return {
+                        name: item.name,
+                        uuid,
+                        pdh: collection.portableDataHash,
+                        subpath: itemPath,
+                    };
+                }
+            }
+        }
+        return undefined;
+    };
+
+/**
+ * Create an expanded tree picker subtree from array of nested projects/collection
+ *   First item is assumed to be root and gets empty parent id
+ *   Nodes must be sorted from top down to prevent orphaned nodes
+ */
+export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
+    return sortedAncestors
+        .reduce((tree, item, index) => {
+            if (getNode(item.uuid)(tree)) {
+                return tree;
+            } else {
+                return setNode({
+                    children: [],
+                    id: item.uuid,
+                    parent: index === 0 ? '' : item.ownerUuid,
+                    value: item,
+                    active: false,
+                    selected: false,
+                    expanded: false,
+                    status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
+                })(tree);
+            }
+        }, initialTree);
+};
+
+export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
+    let id = location.uuid;
+    if (location.subpath.length && location.subpath !== '/') {
+        id = id + location.subpath;
+    }
+    return id;
+}
diff --git a/services/workbench2/src/store/tree-picker/tree-picker-middleware.ts b/services/workbench2/src/store/tree-picker/tree-picker-middleware.ts
new file mode 100644 (file)
index 0000000..6f748a9
--- /dev/null
@@ -0,0 +1,122 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, MiddlewareAPI } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { Middleware } from "redux";
+import { getNode, getNodeDescendantsIds, TreeNodeStatus } from 'models/tree';
+import { getTreePicker } from './tree-picker';
+import {
+    treePickerSearchActions, loadProject, loadFavoritesProject, loadPublicFavoritesProject,
+    SHARED_PROJECT_ID, FAVORITES_PROJECT_ID, PUBLIC_FAVORITES_PROJECT_ID, SEARCH_PROJECT_ID
+} from "./tree-picker-actions";
+import { LinkResource } from "models/link";
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { CollectionDirectory, CollectionFile } from 'models/collection-file';
+
+export interface ProjectsTreePickerRootItem {
+    id: string;
+    name: string;
+}
+
+export type ProjectsTreePickerItem = ProjectsTreePickerRootItem | GroupContentsResource | CollectionDirectory | CollectionFile | LinkResource;
+
+export const treePickerSearchMiddleware: Middleware = store => next => action => {
+    let isSearchAction = false;
+    let searchChanged = false;
+
+    treePickerSearchActions.match(action, {
+        SET_TREE_PICKER_PROJECT_SEARCH: ({ pickerId, projectSearchValue }) => {
+            isSearchAction = true;
+            searchChanged = store.getState().treePickerSearch.projectSearchValues[pickerId] !== projectSearchValue;
+        },
+
+        SET_TREE_PICKER_COLLECTION_FILTER: ({ pickerId, collectionFilterValue }) => {
+            isSearchAction = true;
+            searchChanged = store.getState().treePickerSearch.collectionFilterValues[pickerId] !== collectionFilterValue;
+        },
+
+        REFRESH_TREE_PICKER: refreshPickers(store),
+        default: () => { }
+    });
+
+    if (isSearchAction && !searchChanged) {
+        return;
+    }
+
+    // pass it on to the reducer
+    const r = next(action);
+
+    treePickerSearchActions.match(action, {
+        SET_TREE_PICKER_PROJECT_SEARCH: ({ pickerId }) =>
+            store.dispatch<any>((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+                const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
+                if (picker) {
+                    const loadParams = getState().treePickerSearch.loadProjectParams[pickerId];
+                    dispatch<any>(loadProject({
+                        ...loadParams,
+                        id: SEARCH_PROJECT_ID,
+                        pickerId: pickerId,
+                        searchProjects: true
+                    }));
+                }
+            }),
+
+        SET_TREE_PICKER_COLLECTION_FILTER: refreshPickers(store),
+        default: () => { }
+    });
+
+    return r;
+}
+
+const refreshPickers = (store: MiddlewareAPI) => ({ pickerId }) =>
+    store.dispatch<any>((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
+        if (picker) {
+            const loadParams = getState().treePickerSearch.loadProjectParams[pickerId];
+            getNodeDescendantsIds('')(picker)
+                .map(id => {
+                    const node = getNode(id)(picker);
+                    if (node && node.status !== TreeNodeStatus.INITIAL) {
+                        if (node.id.substring(6, 11) === 'tpzed' || node.id.substring(6, 11) === 'j7d0g') {
+                            dispatch<any>(loadProject({
+                                ...loadParams,
+                                id: node.id,
+                                pickerId: pickerId,
+                            }));
+                        }
+                        if (node.id === SHARED_PROJECT_ID) {
+                            dispatch<any>(loadProject({
+                                ...loadParams,
+                                id: node.id,
+                                pickerId: pickerId,
+                                loadShared: true
+                            }));
+                        }
+                        if (node.id === SEARCH_PROJECT_ID) {
+                            dispatch<any>(loadProject({
+                                ...loadParams,
+                                id: node.id,
+                                pickerId: pickerId,
+                                searchProjects: true
+                            }));
+                        }
+                        if (node.id === FAVORITES_PROJECT_ID) {
+                            dispatch<any>(loadFavoritesProject({
+                                ...loadParams,
+                                pickerId: pickerId,
+                            }));
+                        }
+                        if (node.id === PUBLIC_FAVORITES_PROJECT_ID) {
+                            dispatch<any>(loadPublicFavoritesProject({
+                                ...loadParams,
+                                pickerId: pickerId,
+                            }));
+                        }
+                    }
+                    return id;
+                });
+        }
+    })
diff --git a/services/workbench2/src/store/tree-picker/tree-picker-reducer.test.ts b/services/workbench2/src/store/tree-picker/tree-picker-reducer.test.ts
new file mode 100644 (file)
index 0000000..2a5229c
--- /dev/null
@@ -0,0 +1,105 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { createTree, getNodeChildrenIds, getNode, TreeNodeStatus } from 'models/tree';
+import { pipe } from 'lodash/fp';
+import { treePickerReducer } from "./tree-picker-reducer";
+import { treePickerActions } from "./tree-picker-actions";
+import { TreePicker } from './tree-picker';
+import { initTreeNode } from 'models/tree';
+
+describe('TreePickerReducer', () => {
+    it('LOAD_TREE_PICKER_NODE - initial state', () => {
+        const tree = createTree<{}>();
+        const newState = treePickerReducer({}, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1', pickerId: "projects" }));
+        expect(newState).toEqual({ 'projects': tree });
+    });
+
+    it('LOAD_TREE_PICKER_NODE', () => {
+        const node = initTreeNode({ id: '1', value: '1' });
+        const newState = pipe(
+            (state: TreePicker) => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node], pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1', pickerId: "projects" }))
+        )({ projects: createTree<{}>() });
+
+        expect(getNode('1')(newState.projects)).toEqual({
+            ...initTreeNode({ id: '1', value: '1' }),
+            status: TreeNodeStatus.PENDING
+        });
+    });
+
+    it('LOAD_TREE_PICKER_NODE_SUCCESS - initial state', () => {
+        const subNode = initTreeNode({ id: '1.1', value: '1.1' });
+        const newState = treePickerReducer({}, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [subNode], pickerId: "projects" }));
+        expect(getNodeChildrenIds('')(newState.projects)).toEqual(['1.1']);
+    });
+
+    it('LOAD_TREE_PICKER_NODE_SUCCESS', () => {
+        const node = initTreeNode({ id: '1', value: '1' });
+        const subNode = initTreeNode({ id: '1.1', value: '1.1' });
+        const newState = pipe(
+            (state: TreePicker) => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node], pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '1', nodes: [subNode], pickerId: "projects" }))
+        )({ projects: createTree<{}>() });
+        expect(getNodeChildrenIds('1')(newState.projects)).toEqual(['1.1']);
+        expect(getNode('1')(newState.projects)).toEqual({
+            ...initTreeNode({ id: '1', value: '1' }),
+            children: ['1.1'],
+            status: TreeNodeStatus.LOADED
+        });
+    });
+
+    it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - expanded', () => {
+        const node = initTreeNode({ id: '1', value: '1' });
+        const newState = pipe(
+            (state: TreePicker) => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node], pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1', pickerId: "projects" }))
+        )({ projects: createTree<{}>() });
+        expect(getNode('1')(newState.projects)).toEqual({
+            ...initTreeNode({ id: '1', value: '1' }),
+            expanded: true
+        });
+    });
+
+    it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - expanded', () => {
+        const node = initTreeNode({ id: '1', value: '1' });
+        const newState = pipe(
+            (state: TreePicker) => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node], pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1', pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1', pickerId: "projects" })),
+        )({ projects: createTree<{}>() });
+        expect(getNode('1')(newState.projects)).toEqual({
+            ...initTreeNode({ id: '1', value: '1' }),
+            expanded: false
+        });
+    });
+
+    it('ACTIVATE_TREE_PICKER_NODE', () => {
+        const node = initTreeNode({ id: '1', value: '1' });
+        const newState = pipe(
+            (state: TreePicker) => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node], pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: '1', pickerId: "projects" })),
+        )({ projects: createTree<{}>() });
+        expect(getNode('1')(newState.projects)).toEqual({
+            ...initTreeNode({ id: '1', value: '1' }),
+            active: true
+        });
+    });
+
+    it('TOGGLE_TREE_PICKER_NODE_SELECTION', () => {
+        const node = initTreeNode({ id: '1', value: '1' });
+        const subNode = initTreeNode({ id: '1.1', value: '1.1' });
+        const newState = pipe(
+            (state: TreePicker) => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node], pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '1', nodes: [subNode], pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: '1.1', pickerId: "projects", cascade: true })),
+        )({ projects: createTree<{}>() });
+        expect(getNode('1')(newState.projects)).toEqual({
+            ...initTreeNode({ id: '1', value: '1' }),
+            selected: true,
+            children: ['1.1'],
+            status: TreeNodeStatus.LOADED,
+        });
+    });
+});
diff --git a/services/workbench2/src/store/tree-picker/tree-picker-reducer.ts b/services/workbench2/src/store/tree-picker/tree-picker-reducer.ts
new file mode 100644 (file)
index 0000000..84d5ed0
--- /dev/null
@@ -0,0 +1,107 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import {
+    createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus,
+    expandNode, deactivateNode, selectNodes, deselectNodes,
+    activateNode, getNode, toggleNodeCollapse, toggleNodeSelection, appendSubtree, expandNodeAncestors
+} from 'models/tree';
+import { TreePicker } from "./tree-picker";
+import { treePickerActions, treePickerSearchActions, TreePickerAction, TreePickerSearchAction, LoadProjectParams } from "./tree-picker-actions";
+import { compose } from "redux";
+import { pipe } from 'lodash/fp';
+
+export const treePickerReducer = (state: TreePicker = {}, action: TreePickerAction) =>
+    treePickerActions.match(action, {
+        LOAD_TREE_PICKER_NODE: ({ id, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, setNodeStatus(id)(TreeNodeStatus.PENDING)),
+
+        LOAD_TREE_PICKER_NODE_SUCCESS: ({ id, nodes, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, compose(receiveNodes(nodes)(id), setNodeStatus(id)(TreeNodeStatus.LOADED))),
+
+        APPEND_TREE_PICKER_NODE_SUBTREE: ({ id, subtree, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, compose(appendSubtree(id, subtree), setNodeStatus(id)(TreeNodeStatus.LOADED))),
+
+        TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ id, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, toggleNodeCollapse(id)),
+
+        EXPAND_TREE_PICKER_NODE: ({ id, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, expandNode(id)),
+
+        EXPAND_TREE_PICKER_NODE_ANCESTORS: ({ id, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, expandNodeAncestors(id)),
+
+        ACTIVATE_TREE_PICKER_NODE: ({ id, pickerId, relatedTreePickers = [] }) =>
+            pipe(
+                () => relatedTreePickers.reduce(
+                    (state, relatedPickerId) => updateOrCreatePicker(state, relatedPickerId, deactivateNode),
+                    state
+                ),
+                state => updateOrCreatePicker(state, pickerId, activateNode(id))
+            )(),
+
+        DEACTIVATE_TREE_PICKER_NODE: ({ pickerId }) =>
+            updateOrCreatePicker(state, pickerId, deactivateNode),
+
+        TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId, cascade }) =>
+            updateOrCreatePicker(state, pickerId, toggleNodeSelection(id, cascade)),
+
+        SELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) =>
+            updateOrCreatePicker(state, pickerId, selectNodes(id, cascade)),
+
+        DESELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) =>
+            updateOrCreatePicker(state, pickerId, deselectNodes(id, cascade)),
+
+        RESET_TREE_PICKER: ({ pickerId }) =>
+            updateOrCreatePicker(state, pickerId, createTree),
+
+        EXPAND_TREE_PICKER_NODES: ({ pickerId, ids }) =>
+            updateOrCreatePicker(state, pickerId, expandNode(...ids)),
+
+        default: () => state
+    });
+
+const updateOrCreatePicker = <V>(state: TreePicker, pickerId: string, func: (value: Tree<V>) => Tree<V>) => {
+    const picker = state[pickerId] || createTree();
+    const updatedPicker = func(picker);
+    return { ...state, [pickerId]: updatedPicker };
+};
+
+const receiveNodes = <V>(nodes: Array<TreeNode<V>>) => (parent: string) => (state: Tree<V>) => {
+    const parentNode = getNode(parent)(state);
+    let newState = state;
+    if (parentNode) {
+        newState = setNode({ ...parentNode, children: [] })(state);
+    }
+    return nodes.reduce((tree, node) => {
+        const preexistingNode = getNode(node.id)(state);
+        if (preexistingNode) {
+            node = { ...preexistingNode, value: node.value };
+        }
+        return setNode({ ...node, parent })(tree);
+    }, newState);
+};
+
+interface TreePickerSearch {
+    projectSearchValues: { [pickerId: string]: string };
+    collectionFilterValues: { [pickerId: string]: string };
+    loadProjectParams: { [pickerId: string]: LoadProjectParams };
+}
+
+export const treePickerSearchReducer = (state: TreePickerSearch = { projectSearchValues: {}, collectionFilterValues: {}, loadProjectParams: {} }, action: TreePickerSearchAction) =>
+    treePickerSearchActions.match(action, {
+        SET_TREE_PICKER_PROJECT_SEARCH: ({ pickerId, projectSearchValue }) => ({
+            ...state, projectSearchValues: { ...state.projectSearchValues, [pickerId]: projectSearchValue }
+        }),
+
+        SET_TREE_PICKER_COLLECTION_FILTER: ({ pickerId, collectionFilterValue }) => ({
+            ...state, collectionFilterValues: { ...state.collectionFilterValues, [pickerId]: collectionFilterValue }
+        }),
+
+        SET_TREE_PICKER_LOAD_PARAMS: ({ pickerId, params }) => ({
+            ...state, loadProjectParams: { ...state.loadProjectParams, [pickerId]: params }
+        }),
+
+        default: () => state
+    });
diff --git a/services/workbench2/src/store/tree-picker/tree-picker.ts b/services/workbench2/src/store/tree-picker/tree-picker.ts
new file mode 100644 (file)
index 0000000..22e445a
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Tree } from "models/tree";
+import { TreeItemStatus } from 'components/tree/tree';
+export type TreePicker = { [key: string]: Tree<any> };
+
+export const getTreePicker = <Value = {}>(id: string) => (state: TreePicker): Tree<Value> | undefined => state[id];
+
+export const createTreePickerNode = (data: { nodeId: string, value: any }) => ({
+    ...data,
+    selected: false,
+    collapsed: true,
+    status: TreeItemStatus.INITIAL
+});
diff --git a/services/workbench2/src/store/user-profile/user-profile-actions.ts b/services/workbench2/src/store/user-profile/user-profile-actions.ts
new file mode 100644 (file)
index 0000000..44b17c6
--- /dev/null
@@ -0,0 +1,177 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { RootState } from "store/store";
+import { Dispatch } from 'redux';
+import { initialize, reset } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+import { propertiesActions } from 'store/properties/properties-actions';
+import { getProperty } from 'store/properties/properties';
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { deleteResources, updateResources } from "store/resources/resources-actions";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { filterResources } from "store/resources/resources";
+import { ResourceKind } from "models/resource";
+import { LinkClass, LinkResource } from "models/link";
+import { BuiltinGroups, getBuiltinGroupUuid } from "models/group";
+
+export const USER_PROFILE_PANEL_ID = 'userProfilePanel';
+export const USER_PROFILE_FORM = 'userProfileForm';
+export const DEACTIVATE_DIALOG = 'deactivateDialog';
+export const SETUP_DIALOG = 'setupDialog';
+export const ACTIVATE_DIALOG = 'activateDialog';
+export const IS_PROFILE_INACCESSIBLE = 'isProfileInaccessible';
+
+export const UserProfileGroupsActions = bindDataExplorerActions(USER_PROFILE_PANEL_ID);
+
+export const getCurrentUserProfilePanelUuid = getProperty<string>(USER_PROFILE_PANEL_ID);
+export const getUserProfileIsInaccessible = getProperty<boolean>(IS_PROFILE_INACCESSIBLE);
+
+export const loadUserProfilePanel = (userUuid?: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        // Reset isInacessible to ensure error screen is hidden
+        dispatch(propertiesActions.SET_PROPERTY({ key: IS_PROFILE_INACCESSIBLE, value: false }));
+        // Get user uuid from route or use current user uuid
+        const uuid = userUuid || getState().auth.user?.uuid;
+        if (uuid) {
+            await dispatch(propertiesActions.SET_PROPERTY({ key: USER_PROFILE_PANEL_ID, value: uuid }));
+            try {
+                const user = await services.userService.get(uuid, false, ["uuid", "first_name", "last_name", "email", "username", "prefs", "is_admin", "is_active"]);
+                dispatch(initialize(USER_PROFILE_FORM, user));
+                dispatch(updateResources([user]));
+                dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+            } catch (e) {
+                if (e.status === 404) {
+                    await dispatch(propertiesActions.SET_PROPERTY({ key: IS_PROFILE_INACCESSIBLE, value: true }));
+                    dispatch(reset(USER_PROFILE_FORM));
+                } else {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: 'Could not load user profile',
+                        kind: SnackbarKind.ERROR
+                    }));
+                }
+            }
+        }
+    }
+
+export const saveEditedUser = (resource: any) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const user = await services.userService.update(resource.uuid, resource);
+            dispatch(updateResources([user]));
+            dispatch(initialize(USER_PROFILE_FORM, user));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Profile has been updated.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Could not update profile",
+                kind: SnackbarKind.ERROR,
+            }));
+        }
+    };
+
+export const openSetupDialog = (uuid: string) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: SETUP_DIALOG,
+            data: {
+                title: 'Setup user',
+                text: 'Are you sure you want to setup this user?',
+                confirmButtonLabel: 'Confirm',
+                uuid
+            }
+        }));
+    };
+
+export const openActivateDialog = (uuid: string) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: ACTIVATE_DIALOG,
+            data: {
+                title: 'Activate user',
+                text: 'Are you sure you want to activate this user?',
+                confirmButtonLabel: 'Confirm',
+                uuid
+            }
+        }));
+    };
+
+export const openDeactivateDialog = (uuid: string) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: DEACTIVATE_DIALOG,
+            data: {
+                title: 'Deactivate user',
+                text: 'Are you sure you want to deactivate this user?',
+                confirmButtonLabel: 'Confirm',
+                uuid
+            }
+        }));
+    };
+
+export const setup = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const resources = await services.userService.setup(uuid);
+            dispatch(updateResources(resources.items));
+
+            // Refresh data explorer
+            dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been setup", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        } finally {
+            dispatch(dialogActions.CLOSE_DIALOG({ id: SETUP_DIALOG }));
+        }
+    };
+
+export const activate = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const user = await services.userService.activate(uuid);
+            dispatch(updateResources([user]));
+
+            // Refresh data explorer
+            dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been activated", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const deactivate = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const { resources, auth } = getState();
+            // Call unsetup
+            const user = await services.userService.unsetup(uuid);
+            dispatch(updateResources([user]));
+
+            // Find and remove all users membership
+            const allUsersGroupUuid = getBuiltinGroupUuid(auth.localCluster, BuiltinGroups.ALL);
+            const memberships = filterResources((resource: LinkResource) =>
+                resource.kind === ResourceKind.LINK &&
+                resource.linkClass === LinkClass.PERMISSION &&
+                resource.headUuid === allUsersGroupUuid &&
+                resource.tailUuid === uuid
+            )(resources);
+            // Remove all users membership locally
+            dispatch<any>(deleteResources(memberships.map(link => link.uuid)));
+
+            // Refresh data explorer
+            dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "User has been deactivated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Could not deactivate user",
+                kind: SnackbarKind.ERROR,
+            }));
+        }
+    };
diff --git a/services/workbench2/src/store/user-profile/user-profile-groups-middleware-service.ts b/services/workbench2/src/store/user-profile/user-profile-groups-middleware-service.ts
new file mode 100644 (file)
index 0000000..a8a650a
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { DataExplorerMiddlewareService, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { getCurrentUserProfilePanelUuid, UserProfileGroupsActions } from 'store/user-profile/user-profile-actions';
+import { updateResources } from 'store/resources/resources-actions';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { LinkClass } from 'models/link';
+import { ResourceKind } from 'models/resource';
+import { GroupClass } from 'models/group';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+
+export class UserProfileGroupsMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const state = api.getState();
+        const userUuid = getCurrentUserProfilePanelUuid(state.properties);
+        try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+
+            // Get user
+            const user = await this.services.userService.get(userUuid || '');
+            api.dispatch(updateResources([user]));
+
+            // Get user's group memberships
+            const groupMembershipLinks = await this.services.permissionService.list({
+                filters: new FilterBuilder()
+                    .addEqual('tail_uuid', userUuid)
+                    .addEqual('link_class', LinkClass.PERMISSION)
+                    .addEqual('head_kind', ResourceKind.GROUP)
+                    .getFilters()
+            });
+            // Update resources, includes "project" groups
+            api.dispatch(updateResources(groupMembershipLinks.items));
+
+            // Get user's groups details and filter to role groups
+            const groups = await this.services.groupsService.list({
+                filters: new FilterBuilder()
+                    .addIn('uuid', groupMembershipLinks.items
+                        .map(item => item.headUuid))
+                    .addEqual('group_class', GroupClass.ROLE)
+                    .getFilters(),
+                count: "none"
+            });
+            api.dispatch(updateResources(groups.items));
+
+            // Get permission links for only role groups
+            const roleGroupMembershipLinks = await this.services.permissionService.list({
+                filters: new FilterBuilder()
+                    .addIn('head_uuid', groups.items.map(item => item.uuid))
+                    .addEqual('tail_uuid', userUuid)
+                    .addEqual('link_class', LinkClass.PERMISSION)
+                    .addEqual('head_kind', ResourceKind.GROUP)
+                    .getFilters()
+            });
+
+            api.dispatch(UserProfileGroupsActions.SET_ITEMS({
+                ...listResultsToDataExplorerItemsMeta(roleGroupMembershipLinks),
+                items: roleGroupMembershipLinks.items.map(item => item.uuid),
+            }));
+        } catch {
+            api.dispatch(couldNotFetchGroups());
+        } finally {
+            api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
+        }
+    }
+}
+
+const couldNotFetchGroups = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch groups.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/users/user-panel-middleware-service.ts b/services/workbench2/src/store/users/user-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..b8b914c
--- /dev/null
@@ -0,0 +1,94 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
+import { updateResources } from 'store/resources/resources-actions';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { SortDirection } from 'components/data-table/data-column';
+import { OrderDirection, OrderBuilder } from 'services/api/order-builder';
+import { ListResults } from 'services/common-service/common-service';
+import { userBindedActions } from 'store/users/users-actions';
+import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
+import { UserResource } from 'models/user';
+import { UserPanelColumnNames } from 'views/user-panel/user-panel';
+import { BuiltinGroups, getBuiltinGroupUuid } from 'models/group';
+import { LinkClass } from 'models/link';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+
+export class UserMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const state = api.getState();
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+        try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+            const users = await this.services.userService.list(getParams(dataExplorer));
+            api.dispatch(updateResources(users.items));
+            api.dispatch(setItems(users));
+
+            // Get "all users" group memberships
+            const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL);
+            const allUserMemberships = await this.services.permissionService.list({
+                filters: new FilterBuilder()
+                    .addEqual('head_uuid', allUsersGroupUuid)
+                    .addEqual('link_class', LinkClass.PERMISSION)
+                    .getFilters()
+            });
+            api.dispatch(updateResources(allUserMemberships.items));
+        } catch {
+            api.dispatch(couldNotFetchUsers());
+        } finally {
+            api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
+        }
+    }
+}
+
+const getParams = (dataExplorer: DataExplorer) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    order: getOrder(dataExplorer),
+    filters: new FilterBuilder()
+        .addFullTextSearch(dataExplorer.searchValue)
+        .getFilters()
+});
+
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<UserResource>(dataExplorer);
+    const order = new OrderBuilder<UserResource>();
+    if (sortColumn && sortColumn.sort) {
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC
+            ? OrderDirection.ASC
+            : OrderDirection.DESC;
+
+        if (sortColumn.name === UserPanelColumnNames.NAME) {
+            order.addOrder(sortDirection, "firstName")
+                .addOrder(sortDirection, "lastName");
+        } else {
+            order.addOrder(sortDirection, sortColumn.sort.field);
+        }
+
+        // Use createdAt as a secondary sort column so we break ties consistently.
+        order.addOrder(OrderDirection.DESC, "createdAt");
+    }
+    return order.getOrder();
+};
+
+export const setItems = (listResults: ListResults<UserResource>) =>
+    userBindedActions.SET_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
+
+const couldNotFetchUsers = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch users.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/services/workbench2/src/store/users/users-actions.ts b/services/workbench2/src/store/users/users-actions.ts
new file mode 100644 (file)
index 0000000..4c789db
--- /dev/null
@@ -0,0 +1,177 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository } from "services/services";
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { startSubmit, reset, stopSubmit } from "redux-form";
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { UserResource } from "models/user";
+import { filterResources, getResource } from 'store/resources/resources';
+import { navigateTo, navigateToUsers, navigateToRootProject } from "store/navigation/navigation-action";
+import { authActions } from 'store/auth/auth-action';
+import { getTokenV2 } from "models/api-client-authorization";
+import { VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD } from "store/virtual-machines/virtual-machines-actions";
+import { PermissionLevel } from "models/permission";
+import { updateResources } from "store/resources/resources-actions";
+import { BuiltinGroups, getBuiltinGroupUuid } from "models/group";
+import { LinkClass, LinkResource } from "models/link";
+import { ResourceKind } from "models/resource";
+
+export const USERS_PANEL_ID = 'usersPanel';
+export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog';
+export const USER_CREATE_FORM_NAME = 'userCreateFormName';
+
+export interface UserCreateFormDialogData {
+    email: string;
+    [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: string;
+    [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: string[];
+}
+
+export const userBindedActions = bindDataExplorerActions(USERS_PANEL_ID);
+
+export const openUserAttributes = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<UserResource>(uuid)(resources);
+        dispatch(dialogActions.OPEN_DIALOG({ id: USER_ATTRIBUTES_DIALOG, data }));
+    };
+
+export const loginAs = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (userUuid === uuid) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'You are already logged in as this user',
+                kind: SnackbarKind.WARNING
+            }));
+        } else {
+            try {
+                const { resources } = getState();
+                const data = getResource<UserResource>(uuid)(resources);
+                const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid }, false);
+                if (data) {
+                    dispatch<any>(authActions.INIT_USER({ user: data, token: getTokenV2(client) }));
+                    window.location.reload();
+                    dispatch<any>(navigateToRootProject);
+                }
+            } catch (e) {
+                if (e.status === 403) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: 'You do not have permission to login as this user',
+                        kind: SnackbarKind.WARNING
+                    }));
+                } else {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: 'Failed to login as this user',
+                        kind: SnackbarKind.ERROR
+                    }));
+                }
+            }
+        }
+    };
+
+export const openUserCreateDialog = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const user = await services.userService.get(userUuid!);
+        const virtualMachines = await services.virtualMachineService.list();
+        dispatch(reset(USER_CREATE_FORM_NAME));
+        dispatch(dialogActions.OPEN_DIALOG({ id: USER_CREATE_FORM_NAME, data: { user, ...virtualMachines } }));
+    };
+
+export const openUserProjects = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(navigateTo(uuid));
+    };
+
+export const createUser = (data: UserCreateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(USER_CREATE_FORM_NAME));
+        try {
+            const newUser = await services.userService.create({
+                email: data.email,
+            });
+            dispatch(updateResources([newUser]));
+
+            if (data[VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]) {
+                const permission = await services.permissionService.create({
+                    headUuid: data[VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD],
+                    tailUuid: newUser.uuid,
+                    name: PermissionLevel.CAN_LOGIN,
+                    properties: {
+                        username: newUser.username,
+                        groups: data.groups,
+                    }
+                });
+                dispatch(updateResources([permission]));
+            }
+
+            dispatch(dialogActions.CLOSE_DIALOG({ id: USER_CREATE_FORM_NAME }));
+            dispatch(reset(USER_CREATE_FORM_NAME));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been successfully created.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            dispatch<any>(loadUsersPanel());
+            dispatch(userBindedActions.REQUEST_ITEMS());
+            return newUser;
+        } catch (e) {
+            return;
+        } finally {
+            dispatch(stopSubmit(USER_CREATE_FORM_NAME));
+        }
+    };
+
+export const openUserPanel = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const user = getState().auth.user;
+        if (user && user.isAdmin) {
+            dispatch<any>(navigateToUsers);
+        } else {
+            dispatch<any>(navigateToRootProject);
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "You don't have permissions to view this page", hideDuration: 2000 }));
+        }
+    };
+
+export const toggleIsAdmin = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<UserResource>(uuid)(resources);
+        const isAdmin = data!.isAdmin;
+        const newActivity = await services.userService.update(uuid, { isAdmin: !isAdmin });
+        dispatch<any>(loadUsersPanel());
+        return newActivity;
+    };
+
+export const loadUsersPanel = () =>
+    (dispatch: Dispatch) => {
+        dispatch(userBindedActions.RESET_EXPLORER_SEARCH_VALUE());
+        dispatch(userBindedActions.REQUEST_ITEMS());
+    };
+
+export enum UserAccountStatus {
+        ACTIVE = 'Active',
+        INACTIVE = 'Inactive',
+        SETUP = 'Setup',
+    }
+
+export const getUserAccountStatus = (state: RootState, uuid: string) => {
+    const user = getResource<UserResource>(uuid)(state.resources);
+    // Get membership links for all users group
+    const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL);
+    const permissions = filterResources((resource: LinkResource) =>
+        resource.kind === ResourceKind.LINK &&
+        resource.linkClass === LinkClass.PERMISSION &&
+        resource.headUuid === allUsersGroupUuid &&
+        resource.tailUuid === uuid
+    )(state.resources);
+
+    return user && user.isActive
+        ? UserAccountStatus.ACTIVE
+        : permissions.length > 0
+            ? UserAccountStatus.SETUP
+            : UserAccountStatus.INACTIVE;
+}
diff --git a/services/workbench2/src/store/virtual-machines/virtual-machines-actions.ts b/services/workbench2/src/store/virtual-machines/virtual-machines-actions.ts
new file mode 100644 (file)
index 0000000..12172e7
--- /dev/null
@@ -0,0 +1,283 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from 'store/store';
+import { ServiceRepository } from "services/services";
+import { navigateToUserVirtualMachines, navigateToAdminVirtualMachines, navigateToRootProject } from "store/navigation/navigation-action";
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { formatDate } from "common/formatters";
+import { unionize, ofType, UnionOf } from "common/unionize";
+import { VirtualMachineLogins } from 'models/virtual-machines';
+import { FilterBuilder } from "services/api/filter-builder";
+import { ListResults } from "services/common-service/common-service";
+import { dialogActions } from 'store/dialog/dialog-actions';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { PermissionLevel } from "models/permission";
+import { deleteResources, updateResources } from 'store/resources/resources-actions';
+import { Participant } from "views-components/sharing-dialog/participant-select";
+import { initialize, reset } from "redux-form";
+import { getUserDisplayName, UserResource } from "models/user";
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+
+export const virtualMachinesActions = unionize({
+    SET_REQUESTED_DATE: ofType<string>(),
+    SET_VIRTUAL_MACHINES: ofType<ListResults<any>>(),
+    SET_LOGINS: ofType<VirtualMachineLogins>(),
+    SET_LINKS: ofType<ListResults<any>>()
+});
+
+export type VirtualMachineActions = UnionOf<typeof virtualMachinesActions>;
+
+export const VIRTUAL_MACHINES_PANEL = 'virtualMachinesPanel';
+export const VIRTUAL_MACHINE_ATTRIBUTES_DIALOG = 'virtualMachineAttributesDialog';
+export const VIRTUAL_MACHINE_REMOVE_DIALOG = 'virtualMachineRemoveDialog';
+export const VIRTUAL_MACHINE_ADD_LOGIN_DIALOG = 'virtualMachineAddLoginDialog';
+export const VIRTUAL_MACHINE_ADD_LOGIN_FORM = 'virtualMachineAddLoginForm';
+export const VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG = 'virtualMachineRemoveLoginDialog';
+
+export const VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD = 'uuid';
+export const VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD = 'vmUuid';
+export const VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD = 'user';
+export const VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD = 'groups';
+export const VIRTUAL_MACHINE_ADD_LOGIN_EXCLUDE = 'excludedPerticipants';
+
+export const openUserVirtualMachines = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(navigateToUserVirtualMachines);
+    };
+
+export const openAdminVirtualMachines = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const user = getState().auth.user;
+        if (user && user.isAdmin) {
+            dispatch<any>(navigateToAdminVirtualMachines);
+        } else {
+            dispatch<any>(navigateToRootProject);
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "You don't have permissions to view this page", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const openVirtualMachineAttributes = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const virtualMachineData = getState().virtualMachines.virtualMachines.items.find(it => it.uuid === uuid);
+        dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ATTRIBUTES_DIALOG, data: { virtualMachineData } }));
+    };
+
+const loadRequestedDate = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const date = services.virtualMachineService.getRequestedDate();
+        dispatch(virtualMachinesActions.SET_REQUESTED_DATE(date));
+    };
+
+export const loadVirtualMachinesAdminData = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            dispatch(progressIndicatorActions.START_WORKING("virtual-machines-admin"));
+            dispatch<any>(loadRequestedDate());
+
+            const virtualMachines = await services.virtualMachineService.list();
+            dispatch(updateResources(virtualMachines.items));
+            dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
+
+
+            const logins = await services.permissionService.list({
+                filters: new FilterBuilder()
+                    .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
+                    .addEqual('name', PermissionLevel.CAN_LOGIN)
+                    .getFilters(),
+                limit: 1000
+            });
+            dispatch(updateResources(logins.items));
+            dispatch(virtualMachinesActions.SET_LINKS(logins));
+
+            const users = await services.userService.list({
+                filters: new FilterBuilder()
+                    .addIn('uuid', logins.items.map(item => item.tailUuid))
+                    .getFilters(),
+                count: "none", // Necessary for federated queries
+                limit: 1000
+            });
+            dispatch(updateResources(users.items));
+
+            const getAllLogins = await services.virtualMachineService.getAllLogins();
+            dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING("virtual-machines-admin"));
+        }
+    };
+
+export const loadVirtualMachinesUserData = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            dispatch(progressIndicatorActions.START_WORKING("virtual-machines-user"));
+
+            dispatch<any>(loadRequestedDate());
+            const user = getState().auth.user;
+            const virtualMachines = await services.virtualMachineService.list();
+            const virtualMachinesUuids = virtualMachines.items.map(it => it.uuid);
+            const links = await services.linkService.list({
+                filters: new FilterBuilder()
+                    .addIn("head_uuid", virtualMachinesUuids)
+                    .addEqual("tail_uuid", user?.uuid)
+                    .getFilters()
+            });
+            dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
+            dispatch(virtualMachinesActions.SET_LINKS(links));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING("virtual-machines-user"));
+        }
+    };
+
+export const openAddVirtualMachineLoginDialog = (vmUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        // Get login permissions of vm
+        const virtualMachines = await services.virtualMachineService.list();
+        dispatch(updateResources(virtualMachines.items));
+        const logins = await services.permissionService.list({
+            filters: new FilterBuilder()
+                .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
+                .addEqual('name', PermissionLevel.CAN_LOGIN)
+                .getFilters()
+        });
+        dispatch(updateResources(logins.items));
+
+        dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {
+            [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid,
+            [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: [],
+        }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { excludedParticipants: logins.items.map(it => it.tailUuid) } }));
+    }
+
+export const openEditVirtualMachineLoginDialog = (permissionUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const login = await services.permissionService.get(permissionUuid);
+        const user = await services.userService.get(login.tailUuid);
+        dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {
+            [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid,
+            [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: { name: getUserDisplayName(user, true, true), uuid: login.tailUuid },
+            [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups,
+        }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { updating: true } }));
+    }
+
+export interface AddLoginFormData {
+    [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: string;
+    [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: string;
+    [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: Participant;
+    [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: string[];
+}
+
+
+export const addUpdateVirtualMachineLogin = ({ uuid, vmUuid, user, groups }: AddLoginFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        let userResource: UserResource | undefined = undefined;
+        try {
+            // Get user
+            userResource = await services.userService.get(user.uuid, false);
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            return;
+        }
+        try {
+            if (uuid) {
+                const permission = await services.permissionService.update(uuid, {
+                    tailUuid: userResource.uuid,
+                    name: PermissionLevel.CAN_LOGIN,
+                    properties: {
+                        username: userResource.username,
+                        groups,
+                    }
+                });
+                dispatch(updateResources([permission]));
+            } else {
+                const permission = await services.permissionService.create({
+                    headUuid: vmUuid,
+                    tailUuid: userResource.uuid,
+                    name: PermissionLevel.CAN_LOGIN,
+                    properties: {
+                        username: userResource.username,
+                        groups,
+                    }
+                });
+                dispatch(updateResources([permission]));
+            }
+
+            dispatch(reset(VIRTUAL_MACHINE_ADD_LOGIN_FORM));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG }));
+            dispatch<any>(loadVirtualMachinesAdminData());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `Permission updated`,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const openRemoveVirtualMachineLoginDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG,
+            data: {
+                title: 'Remove login permission',
+                text: 'Are you sure you want to remove this permission?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeVirtualMachineLogin = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            await services.permissionService.delete(uuid);
+            dispatch<any>(deleteResources([uuid]));
+
+            dispatch<any>(loadVirtualMachinesAdminData());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `Login permission removed`,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const saveRequestedDate = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const date = formatDate((new Date()).toISOString());
+        services.virtualMachineService.saveRequestedDate(date);
+        dispatch<any>(loadRequestedDate());
+    };
+
+export const openRemoveVirtualMachineDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: VIRTUAL_MACHINE_REMOVE_DIALOG,
+            data: {
+                title: 'Remove virtual machine',
+                text: 'Are you sure you want to remove this virtual machine?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeVirtualMachine = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        await services.virtualMachineService.delete(uuid);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        dispatch<any>(loadVirtualMachinesAdminData());
+    };
+
+const virtualMachinesBindedActions = bindDataExplorerActions(VIRTUAL_MACHINES_PANEL);
+
+export const loadVirtualMachinesPanel = () =>
+    (dispatch: Dispatch) => {
+        dispatch(virtualMachinesBindedActions.REQUEST_ITEMS());
+    };
diff --git a/services/workbench2/src/store/virtual-machines/virtual-machines-reducer.ts b/services/workbench2/src/store/virtual-machines/virtual-machines-reducer.ts
new file mode 100644 (file)
index 0000000..8ac3545
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { virtualMachinesActions, VirtualMachineActions } from 'store/virtual-machines/virtual-machines-actions';
+import { ListResults } from 'services/common-service/common-service';
+import { VirtualMachineLogins } from 'models/virtual-machines';
+
+interface VirtualMachines {
+    date: string;
+    virtualMachines: ListResults<any>;
+    logins: VirtualMachineLogins;
+    links: ListResults<any>;
+}
+
+const initialState: VirtualMachines = {
+    date: '',
+    virtualMachines: {
+        kind: '',
+        offset: 0,
+        limit: 0,
+        itemsAvailable: 0,
+        items: []
+    },
+    logins: {
+        kind: '',
+        items: []
+    },
+    links: {
+        kind: '',
+        offset: 0,
+        limit: 0,
+        itemsAvailable: 0,
+        items: []
+    }
+};
+
+export const virtualMachinesReducer = (state = initialState, action: VirtualMachineActions): VirtualMachines =>
+    virtualMachinesActions.match(action, {
+        SET_REQUESTED_DATE: date => ({ ...state, date }),
+        SET_VIRTUAL_MACHINES: virtualMachines => ({ ...state, virtualMachines }),
+        SET_LOGINS: logins => ({ ...state, logins }),
+        SET_LINKS: links => ({ ...state, links }),
+        default: () => state
+    });
diff --git a/services/workbench2/src/store/vocabulary/vocabulary-actions.ts b/services/workbench2/src/store/vocabulary/vocabulary-actions.ts
new file mode 100644 (file)
index 0000000..d73c01f
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { ServiceRepository } from 'services/services';
+import { propertiesActions } from 'store/properties/properties-actions';
+import { VOCABULARY_PROPERTY_NAME, DEFAULT_VOCABULARY } from './vocabulary-selectors';
+import { isVocabulary } from 'models/vocabulary';
+
+export const loadVocabulary = async (dispatch: Dispatch, _: {}, { vocabularyService }: ServiceRepository) => {
+    const vocabulary = await vocabularyService.getVocabulary();
+    dispatch(propertiesActions.SET_PROPERTY({
+        key: VOCABULARY_PROPERTY_NAME,
+        value: isVocabulary(vocabulary)
+            ? vocabulary
+            : DEFAULT_VOCABULARY,
+    }));
+};
diff --git a/services/workbench2/src/store/vocabulary/vocabulary-selectors.ts b/services/workbench2/src/store/vocabulary/vocabulary-selectors.ts
new file mode 100644 (file)
index 0000000..52fb0b9
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PropertiesState, getProperty } from 'store/properties/properties';
+import { Vocabulary } from 'models/vocabulary';
+
+export const VOCABULARY_PROPERTY_NAME = 'vocabulary';
+
+export const DEFAULT_VOCABULARY: Vocabulary = {
+    strict_tags: false,
+    tags: {},
+};
+
+export const getVocabulary = (state: PropertiesState) =>
+    getProperty<Vocabulary>(VOCABULARY_PROPERTY_NAME)(state) || DEFAULT_VOCABULARY;
diff --git a/services/workbench2/src/store/workbench/workbench-actions.ts b/services/workbench2/src/store/workbench/workbench-actions.ts
new file mode 100644 (file)
index 0000000..83c457f
--- /dev/null
@@ -0,0 +1,885 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getUserUuid } from "common/getuser";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { favoritePanelActions, loadFavoritePanel } from "store/favorite-panel/favorite-panel-action";
+import { getProjectPanelCurrentUuid, setIsProjectPanelTrashed } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import {
+    activateSidePanelTreeItem,
+    initSidePanelTree,
+    loadSidePanelTreeProjects,
+    SidePanelTreeCategory,
+    SIDE_PANEL_TREE,
+} from "store/side-panel-tree/side-panel-tree-actions";
+import { updateResources } from "store/resources/resources-actions";
+import { projectPanelColumns } from "views/project-panel/project-panel";
+import { favoritePanelColumns } from "views/favorite-panel/favorite-panel";
+import { matchRootRoute } from "routes/routes";
+import {
+    setGroupDetailsBreadcrumbs,
+    setGroupsBreadcrumbs,
+    setProcessBreadcrumbs,
+    setSharedWithMeBreadcrumbs,
+    setSidePanelBreadcrumbs,
+    setTrashBreadcrumbs,
+    setUsersBreadcrumbs,
+    setMyAccountBreadcrumbs,
+    setUserProfileBreadcrumbs,
+    setInstanceTypesBreadcrumbs,
+    setVirtualMachinesBreadcrumbs,
+    setVirtualMachinesAdminBreadcrumbs,
+    setRepositoriesBreadcrumbs,
+} from "store/breadcrumbs/breadcrumbs-actions";
+import { navigateTo, navigateToRootProject } from "store/navigation/navigation-action";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { ServiceRepository } from "services/services";
+import { getResource } from "store/resources/resources";
+import * as projectCreateActions from "store/projects/project-create-actions";
+import * as projectMoveActions from "store/projects/project-move-actions";
+import * as projectUpdateActions from "store/projects/project-update-actions";
+import * as collectionCreateActions from "store/collections/collection-create-actions";
+import * as collectionCopyActions from "store/collections/collection-copy-actions";
+import * as collectionMoveActions from "store/collections/collection-move-actions";
+import * as processesActions from "store/processes/processes-actions";
+import * as processMoveActions from "store/processes/process-move-actions";
+import * as processUpdateActions from "store/processes/process-update-actions";
+import * as processCopyActions from "store/processes/process-copy-actions";
+import { trashPanelColumns } from "views/trash-panel/trash-panel";
+import { loadTrashPanel, trashPanelActions } from "store/trash-panel/trash-panel-action";
+import { loadProcessPanel } from "store/process-panel/process-panel-actions";
+import { loadSharedWithMePanel, sharedWithMePanelActions } from "store/shared-with-me-panel/shared-with-me-panel-actions";
+import { sharedWithMePanelColumns } from "views/shared-with-me-panel/shared-with-me-panel";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { workflowPanelActions } from "store/workflow-panel/workflow-panel-actions";
+import { loadSshKeysPanel } from "store/auth/auth-action-ssh";
+import { loadLinkAccountPanel, linkAccountPanelActions } from "store/link-account-panel/link-account-panel-actions";
+import { loadSiteManagerPanel } from "store/auth/auth-action-session";
+import { workflowPanelColumns } from "views/workflow-panel/workflow-panel-view";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { getProgressIndicator } from "store/progress-indicator/progress-indicator-reducer";
+import { extractUuidKind, Resource, ResourceKind } from "models/resource";
+import { FilterBuilder } from "services/api/filter-builder";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { MatchCases, ofType, unionize, UnionOf } from "common/unionize";
+import { loadRunProcessPanel } from "store/run-process-panel/run-process-panel-actions";
+import { collectionPanelActions, loadCollectionPanel } from "store/collection-panel/collection-panel-action";
+import { CollectionResource } from "models/collection";
+import { WorkflowResource } from "models/workflow";
+import { loadSearchResultsPanel, searchResultsPanelActions } from "store/search-results-panel/search-results-panel-actions";
+import { searchResultsPanelColumns } from "views/search-results-panel/search-results-panel-view";
+import { loadVirtualMachinesPanel } from "store/virtual-machines/virtual-machines-actions";
+import { loadRepositoriesPanel } from "store/repositories/repositories-actions";
+import { loadKeepServicesPanel } from "store/keep-services/keep-services-actions";
+import { loadUsersPanel, userBindedActions } from "store/users/users-actions";
+import * as userProfilePanelActions from "store/user-profile/user-profile-actions";
+import { linkPanelActions, loadLinkPanel } from "store/link-panel/link-panel-actions";
+import { linkPanelColumns } from "views/link-panel/link-panel-root";
+import { userPanelColumns } from "views/user-panel/user-panel";
+import { loadApiClientAuthorizationsPanel, apiClientAuthorizationsActions } from "store/api-client-authorizations/api-client-authorizations-actions";
+import { apiClientAuthorizationPanelColumns } from "views/api-client-authorization-panel/api-client-authorization-panel-root";
+import * as groupPanelActions from "store/groups-panel/groups-panel-actions";
+import { groupsPanelColumns } from "views/groups-panel/groups-panel";
+import * as groupDetailsPanelActions from "store/group-details-panel/group-details-panel-actions";
+import { groupDetailsMembersPanelColumns, groupDetailsPermissionsPanelColumns } from "views/group-details-panel/group-details-panel";
+import { DataTableFetchMode } from "components/data-table/data-table";
+import { loadPublicFavoritePanel, publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { publicFavoritePanelColumns } from "views/public-favorites-panel/public-favorites-panel";
+import {
+    loadCollectionsContentAddressPanel,
+    collectionsContentAddressActions,
+} from "store/collections-content-address-panel/collections-content-address-panel-actions";
+import { collectionContentAddressPanelColumns } from "views/collection-content-address-panel/collection-content-address-panel";
+import { subprocessPanelActions } from "store/subprocess-panel/subprocess-panel-actions";
+import { subprocessPanelColumns } from "views/subprocess-panel/subprocess-panel-root";
+import { loadAllProcessesPanel, allProcessesPanelActions } from "../all-processes-panel/all-processes-panel-action";
+import { allProcessesPanelColumns } from "views/all-processes-panel/all-processes-panel";
+import { userProfileGroupsColumns } from "views/user-profile-panel/user-profile-panel-root";
+import { selectedToArray, selectedToKindSet } from "components/multiselect-toolbar/MultiselectToolbar";
+import { deselectOne } from "store/multiselect/multiselect-actions";
+import { treePickerActions } from "store/tree-picker/tree-picker-actions";
+import { workflowProcessesPanelColumns } from "views/workflow-panel/workflow-processes-panel-root";
+import { workflowProcessesPanelActions } from "store/workflow-panel/workflow-panel-actions";
+
+export const WORKBENCH_LOADING_SCREEN = "workbenchLoadingScreen";
+
+export const isWorkbenchLoading = (state: RootState) => {
+    const progress = getProgressIndicator(WORKBENCH_LOADING_SCREEN)(state.progressIndicator);
+    return progress ? progress.working : false;
+};
+
+export const handleFirstTimeLoad = (action: any) => async (dispatch: Dispatch<any>, getState: () => RootState) => {
+    try {
+        await dispatch(action);
+    } catch (e) {
+        snackbarActions.OPEN_SNACKBAR({
+            message: "Error " + e,
+            hideDuration: 8000,
+            kind: SnackbarKind.WARNING,
+        })
+    } finally {
+        if (isWorkbenchLoading(getState())) {
+            dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+        }
+    }
+};
+
+export const loadWorkbench = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+    const { auth, router } = getState();
+    const { user } = auth;
+    if (user) {
+        dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns }));
+        dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns }));
+        dispatch(
+            allProcessesPanelActions.SET_COLUMNS({
+                columns: allProcessesPanelColumns,
+            })
+        );
+        dispatch(
+            publicFavoritePanelActions.SET_COLUMNS({
+                columns: publicFavoritePanelColumns,
+            })
+        );
+        dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns }));
+        dispatch(sharedWithMePanelActions.SET_COLUMNS({ columns: sharedWithMePanelColumns }));
+        dispatch(workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns }));
+        dispatch(
+            searchResultsPanelActions.SET_FETCH_MODE({
+                fetchMode: DataTableFetchMode.INFINITE,
+            })
+        );
+        dispatch(
+            searchResultsPanelActions.SET_COLUMNS({
+                columns: searchResultsPanelColumns,
+            })
+        );
+        dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns }));
+        dispatch(
+            groupPanelActions.GroupsPanelActions.SET_COLUMNS({
+                columns: groupsPanelColumns,
+            })
+        );
+        dispatch(
+            groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({
+                columns: groupDetailsMembersPanelColumns,
+            })
+        );
+        dispatch(
+            groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({
+                columns: groupDetailsPermissionsPanelColumns,
+            })
+        );
+        dispatch(
+            userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({
+                columns: userProfileGroupsColumns,
+            })
+        );
+        dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns }));
+        dispatch(
+            apiClientAuthorizationsActions.SET_COLUMNS({
+                columns: apiClientAuthorizationPanelColumns,
+            })
+        );
+        dispatch(
+            collectionsContentAddressActions.SET_COLUMNS({
+                columns: collectionContentAddressPanelColumns,
+            })
+        );
+        dispatch(subprocessPanelActions.SET_COLUMNS({ columns: subprocessPanelColumns }));
+        dispatch(workflowProcessesPanelActions.SET_COLUMNS({ columns: workflowProcessesPanelColumns }));
+
+        if (services.linkAccountService.getAccountToLink()) {
+            dispatch(linkAccountPanelActions.HAS_SESSION_DATA());
+        }
+
+        dispatch<any>(initSidePanelTree());
+        if (router.location) {
+            const match = matchRootRoute(router.location.pathname);
+            if (match) {
+                dispatch<any>(navigateToRootProject);
+            }
+        }
+    } else {
+        dispatch(userIsNotAuthenticated);
+    }
+};
+
+export const loadFavorites = () =>
+    handleFirstTimeLoad((dispatch: Dispatch) => {
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.FAVORITES));
+        dispatch<any>(loadFavoritePanel());
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES));
+    });
+
+export const loadCollectionContentAddress = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadCollectionsContentAddressPanel());
+});
+
+export const loadTrash = () =>
+    handleFirstTimeLoad((dispatch: Dispatch) => {
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH));
+        dispatch<any>(loadTrashPanel());
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.TRASH));
+    });
+
+export const loadAllProcesses = () =>
+    handleFirstTimeLoad((dispatch: Dispatch) => {
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.ALL_PROCESSES));
+        dispatch<any>(loadAllProcessesPanel());
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.ALL_PROCESSES));
+    });
+
+export const loadProject = (uuid: string) =>
+    handleFirstTimeLoad(async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        dispatch(setIsProjectPanelTrashed(false));
+        if (!userUuid) {
+            return;
+        }
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
+            if (extractUuidKind(uuid) === ResourceKind.USER && userUuid !== uuid) {
+                // Load another users home projects
+                dispatch(finishLoadingProject(uuid));
+                dispatch<any>(setSidePanelBreadcrumbs(uuid));
+            } else if (userUuid !== uuid) {
+                await dispatch(finishLoadingProject(uuid));
+                const match = await loadGroupContentsResource({
+                    uuid,
+                    userUuid,
+                    services,
+                });
+                match({
+                    OWNED: async () => {
+                        await dispatch(activateSidePanelTreeItem(uuid));
+                        dispatch<any>(setSidePanelBreadcrumbs(uuid));
+                    },
+                    SHARED: async () => {
+                        await dispatch(activateSidePanelTreeItem(uuid));
+                        dispatch<any>(setSharedWithMeBreadcrumbs(uuid));
+                    },
+                    TRASHED: async () => {
+                        await dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH));
+                        dispatch<any>(setTrashBreadcrumbs(uuid));
+                        dispatch(setIsProjectPanelTrashed(true));
+                    },
+                });
+            } else {
+                await dispatch(finishLoadingProject(userUuid));
+                await dispatch(activateSidePanelTreeItem(userUuid));
+                dispatch<any>(setSidePanelBreadcrumbs(userUuid));
+            }
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
+        }
+    });
+
+export const createProject = (data: projectCreateActions.ProjectCreateFormDialogData) => async (dispatch: Dispatch) => {
+    const newProject = await dispatch<any>(projectCreateActions.createProject(data));
+    if (newProject) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Project has been successfully created.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        await dispatch<any>(loadSidePanelTreeProjects(newProject.ownerUuid));
+        dispatch<any>(navigateTo(newProject.uuid));
+    }
+};
+
+export const moveProject =
+    (data: MoveToFormDialogData, isSecondaryMove = false) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+            const checkedList = getState().multiselect.checkedList;
+            const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+            //if no items in checkedlist default to normal context menu behavior
+            if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid);
+
+            const sourceUuid = getResource(data.uuid)(getState().resources)?.ownerUuid;
+            const destinationUuid = data.ownerUuid;
+
+            const projectsToMove: MoveableResource[] = uuidsToMove
+                .map(uuid => getResource(uuid)(getState().resources) as MoveableResource)
+                .filter(resource => resource.kind === ResourceKind.PROJECT);
+
+            for (const project of projectsToMove) {
+                await moveSingleProject(project);
+            }
+
+            //omly propagate if this call is the original
+            if (!isSecondaryMove) {
+                const kindsToMove: Set<string> = selectedToKindSet(checkedList);
+                kindsToMove.delete(ResourceKind.PROJECT);
+
+                kindsToMove.forEach(kind => {
+                    secondaryMove[kind](data, true)(dispatch, getState, services);
+                });
+            }
+
+            async function moveSingleProject(project: MoveableResource) {
+                try {
+                    const oldProject: MoveToFormDialogData = { name: project.name, uuid: project.uuid, ownerUuid: data.ownerUuid };
+                    const oldOwnerUuid = oldProject ? oldProject.ownerUuid : "";
+                    const movedProject = await dispatch<any>(projectMoveActions.moveProject(oldProject));
+                    if (movedProject) {
+                        dispatch(
+                            snackbarActions.OPEN_SNACKBAR({
+                                message: "Project has been moved",
+                                hideDuration: 2000,
+                                kind: SnackbarKind.SUCCESS,
+                            })
+                        );
+                        await dispatch<any>(reloadProjectMatchingUuid([oldOwnerUuid, movedProject.ownerUuid, movedProject.uuid]));
+                    }
+                } catch (e) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: !!(project as any).frozenByUuid ? 'Could not move frozen project.' : e.message,
+                            hideDuration: 2000,
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                }
+            }
+            if (sourceUuid) await dispatch<any>(loadSidePanelTreeProjects(sourceUuid));
+            await dispatch<any>(loadSidePanelTreeProjects(destinationUuid));
+        };
+
+export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    const updatedProject = await dispatch<any>(projectUpdateActions.updateProject(data));
+    if (updatedProject) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Project has been successfully updated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        await dispatch<any>(loadSidePanelTreeProjects(updatedProject.ownerUuid));
+        dispatch<any>(reloadProjectMatchingUuid([updatedProject.ownerUuid, updatedProject.uuid]));
+    }
+};
+
+export const updateGroup = (data: projectUpdateActions.ProjectUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    const updatedGroup = await dispatch<any>(groupPanelActions.updateGroup(data));
+    if (updatedGroup) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Group has been successfully updated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        await dispatch<any>(loadSidePanelTreeProjects(updatedGroup.ownerUuid));
+        dispatch<any>(reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid]));
+    }
+};
+
+export const loadCollection = (uuid: string) =>
+    handleFirstTimeLoad(async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
+            if (userUuid) {
+                const match = await loadGroupContentsResource({
+                    uuid,
+                    userUuid,
+                    services,
+                });
+                let collection: CollectionResource | undefined;
+                let breadcrumbfunc:
+                    | ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>)
+                    | undefined;
+                let sidepanel: string | undefined;
+                match({
+                    OWNED: thecollection => {
+                        collection = thecollection as CollectionResource;
+                        sidepanel = collection.ownerUuid;
+                        breadcrumbfunc = setSidePanelBreadcrumbs;
+                    },
+                    SHARED: thecollection => {
+                        collection = thecollection as CollectionResource;
+                        sidepanel = collection.ownerUuid;
+                        breadcrumbfunc = setSharedWithMeBreadcrumbs;
+                    },
+                    TRASHED: thecollection => {
+                        collection = thecollection as CollectionResource;
+                        sidepanel = SidePanelTreeCategory.TRASH;
+                        breadcrumbfunc = () => setTrashBreadcrumbs("");
+                    },
+                });
+                if (collection && breadcrumbfunc && sidepanel) {
+                    dispatch(updateResources([collection]));
+                    await dispatch<any>(finishLoadingProject(collection.ownerUuid));
+                    dispatch(collectionPanelActions.SET_COLLECTION(collection));
+                    await dispatch(activateSidePanelTreeItem(sidepanel));
+                    dispatch(breadcrumbfunc(collection.ownerUuid));
+                    dispatch(loadCollectionPanel(collection.uuid));
+                }
+            }
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
+        }
+    });
+
+export const createCollection = (data: collectionCreateActions.CollectionCreateFormDialogData) => async (dispatch: Dispatch) => {
+    const collection = await dispatch<any>(collectionCreateActions.createCollection(data));
+    if (collection) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Collection has been successfully created.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        dispatch<any>(updateResources([collection]));
+        dispatch<any>(navigateTo(collection.uuid));
+    }
+};
+
+export const copyCollection = (data: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const checkedList = getState().multiselect.checkedList;
+    const uuidsToCopy: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+    //if no items in checkedlist && no items passed in, default to normal context menu behavior
+    if (!uuidsToCopy.length) uuidsToCopy.push(data.uuid);
+
+    const collectionsToCopy: CollectionCopyResource[] = uuidsToCopy
+        .map(uuid => getResource(uuid)(getState().resources) as CollectionCopyResource)
+        .filter(resource => resource.kind === ResourceKind.COLLECTION);
+
+    for (const collection of collectionsToCopy) {
+        await copySingleCollection({ ...collection, ownerUuid: data.ownerUuid } as CollectionCopyResource);
+    }
+
+    async function copySingleCollection(copyToProject: CollectionCopyResource) {
+        const newName = data.fromContextMenu || collectionsToCopy.length === 1 ? data.name : `Copy of: ${copyToProject.name}`;
+        try {
+            const collection = await dispatch<any>(
+                collectionCopyActions.copyCollection({
+                    ...copyToProject,
+                    name: newName,
+                    fromContextMenu: collectionsToCopy.length === 1 ? true : data.fromContextMenu,
+                })
+            );
+            if (copyToProject && collection) {
+                await dispatch<any>(reloadProjectMatchingUuid([copyToProject.uuid]));
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: "Collection has been copied.",
+                        hideDuration: 3000,
+                        kind: SnackbarKind.SUCCESS,
+                        link: collection.ownerUuid,
+                    })
+                );
+                dispatch<any>(deselectOne(copyToProject.uuid));
+            }
+        } catch (e) {
+            dispatch(
+                snackbarActions.OPEN_SNACKBAR({
+                    message: e.message,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR,
+                })
+            );
+        }
+    }
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+};
+
+export const moveCollection =
+    (data: MoveToFormDialogData, isSecondaryMove = false) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+            const checkedList = getState().multiselect.checkedList;
+            const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+            //if no items in checkedlist && no items passed in, default to normal context menu behavior
+            if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid);
+
+            const collectionsToMove: MoveableResource[] = uuidsToMove
+                .map(uuid => getResource(uuid)(getState().resources) as MoveableResource)
+                .filter(resource => resource.kind === ResourceKind.COLLECTION);
+
+            for (const collection of collectionsToMove) {
+                await moveSingleCollection(collection);
+            }
+
+            //omly propagate if this call is the original
+            if (!isSecondaryMove) {
+                const kindsToMove: Set<string> = selectedToKindSet(checkedList);
+                kindsToMove.delete(ResourceKind.COLLECTION);
+
+                kindsToMove.forEach(kind => {
+                    secondaryMove[kind](data, true)(dispatch, getState, services);
+                });
+            }
+
+            async function moveSingleCollection(collection: MoveableResource) {
+                try {
+                    const oldCollection: MoveToFormDialogData = { name: collection.name, uuid: collection.uuid, ownerUuid: data.ownerUuid };
+                    const movedCollection = await dispatch<any>(collectionMoveActions.moveCollection(oldCollection));
+                    dispatch<any>(updateResources([movedCollection]));
+                    dispatch<any>(reloadProjectMatchingUuid([movedCollection.ownerUuid]));
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: "Collection has been moved.",
+                            hideDuration: 2000,
+                            kind: SnackbarKind.SUCCESS,
+                        })
+                    );
+                } catch (e) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: e.message,
+                            hideDuration: 2000,
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                }
+            }
+        };
+
+export const loadProcess = (uuid: string) =>
+    handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState) => {
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
+            dispatch<any>(loadProcessPanel(uuid));
+            const process = await dispatch<any>(processesActions.loadProcess(uuid));
+            if (process) {
+                await dispatch<any>(finishLoadingProject(process.containerRequest.ownerUuid));
+                await dispatch<any>(activateSidePanelTreeItem(process.containerRequest.ownerUuid));
+                dispatch<any>(setProcessBreadcrumbs(uuid));
+                dispatch<any>(loadDetailsPanel(uuid));
+            }
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
+        }
+    });
+
+export const loadRegisteredWorkflow = (uuid: string) =>
+    handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (userUuid) {
+            const match = await loadGroupContentsResource({
+                uuid,
+                userUuid,
+                services,
+            });
+            let workflow: WorkflowResource | undefined;
+            let breadcrumbfunc:
+                | ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>)
+                | undefined;
+            match({
+                OWNED: async theworkflow => {
+                    workflow = theworkflow as WorkflowResource;
+                    breadcrumbfunc = setSidePanelBreadcrumbs;
+                },
+                SHARED: async theworkflow => {
+                    workflow = theworkflow as WorkflowResource;
+                    breadcrumbfunc = setSharedWithMeBreadcrumbs;
+                },
+                TRASHED: () => { },
+            });
+            if (workflow && breadcrumbfunc) {
+                dispatch(updateResources([workflow]));
+                await dispatch<any>(finishLoadingProject(workflow.ownerUuid));
+                await dispatch<any>(activateSidePanelTreeItem(workflow.ownerUuid));
+                dispatch<any>(breadcrumbfunc(workflow.ownerUuid));
+                dispatch(workflowProcessesPanelActions.REQUEST_ITEMS());
+            }
+        }
+    });
+
+export const updateProcess = (data: processUpdateActions.ProcessUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    try {
+        const process = await dispatch<any>(processUpdateActions.updateProcess(data));
+        if (process) {
+            dispatch(
+                snackbarActions.OPEN_SNACKBAR({
+                    message: "Process has been successfully updated.",
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                })
+            );
+            dispatch<any>(updateResources([process]));
+            dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
+        }
+    } catch (e) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: e.message,
+                hideDuration: 2000,
+                kind: SnackbarKind.ERROR,
+            })
+        );
+    }
+};
+
+export const moveProcess =
+    (data: MoveToFormDialogData, isSecondaryMove = false) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+            const checkedList = getState().multiselect.checkedList;
+            const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+            //if no items in checkedlist && no items passed in, default to normal context menu behavior
+            if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid);
+
+            const processesToMove: MoveableResource[] = uuidsToMove
+                .map(uuid => getResource(uuid)(getState().resources) as MoveableResource)
+                .filter(resource => resource.kind === ResourceKind.PROCESS);
+
+            for (const process of processesToMove) {
+                await moveSingleProcess(process);
+            }
+
+            //omly propagate if this call is the original
+            if (!isSecondaryMove) {
+                const kindsToMove: Set<string> = selectedToKindSet(checkedList);
+                kindsToMove.delete(ResourceKind.PROCESS);
+
+                kindsToMove.forEach(kind => {
+                    secondaryMove[kind](data, true)(dispatch, getState, services);
+                });
+            }
+
+            async function moveSingleProcess(process: MoveableResource) {
+                try {
+                    const oldProcess: MoveToFormDialogData = { name: process.name, uuid: process.uuid, ownerUuid: data.ownerUuid };
+                    const movedProcess = await dispatch<any>(processMoveActions.moveProcess(oldProcess));
+                    dispatch<any>(updateResources([movedProcess]));
+                    dispatch<any>(reloadProjectMatchingUuid([movedProcess.ownerUuid]));
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: "Process has been moved.",
+                            hideDuration: 2000,
+                            kind: SnackbarKind.SUCCESS,
+                        })
+                    );
+                } catch (e) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: e.message,
+                            hideDuration: 2000,
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                }
+            }
+        };
+
+export const copyProcess = (data: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await dispatch<any>(processCopyActions.copyProcess(data));
+        dispatch<any>(updateResources([process]));
+        dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Process has been copied.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        dispatch<any>(navigateTo(process.uuid));
+    } catch (e) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: e.message,
+                hideDuration: 2000,
+                kind: SnackbarKind.ERROR,
+            })
+        );
+    }
+};
+
+export const resourceIsNotLoaded = (uuid: string) =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: `Resource identified by ${uuid} is not loaded.`,
+        kind: SnackbarKind.ERROR,
+    });
+
+export const userIsNotAuthenticated = snackbarActions.OPEN_SNACKBAR({
+    message: "User is not authenticated",
+    kind: SnackbarKind.ERROR,
+});
+
+export const couldNotLoadUser = snackbarActions.OPEN_SNACKBAR({
+    message: "Could not load user",
+    kind: SnackbarKind.ERROR,
+});
+
+export const reloadProjectMatchingUuid =
+    (matchingUuids: string[]) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState());
+        if (currentProjectPanelUuid && matchingUuids.some(uuid => uuid === currentProjectPanelUuid)) {
+            dispatch<any>(loadProject(currentProjectPanelUuid));
+        }
+    };
+
+export const loadSharedWithMe = handleFirstTimeLoad(async (dispatch: Dispatch) => {
+    dispatch<any>(loadSharedWithMePanel());
+    await dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.SHARED_WITH_ME));
+    await dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.SHARED_WITH_ME));
+});
+
+export const loadRunProcess = handleFirstTimeLoad(async (dispatch: Dispatch) => {
+    await dispatch<any>(loadRunProcessPanel());
+});
+
+export const loadPublicFavorites = () =>
+    handleFirstTimeLoad((dispatch: Dispatch) => {
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.PUBLIC_FAVORITES));
+        dispatch<any>(loadPublicFavoritePanel());
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES));
+    });
+
+export const loadSearchResults = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSearchResultsPanel());
+});
+
+export const loadLinks = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadLinkPanel());
+});
+
+export const loadVirtualMachines = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadVirtualMachinesPanel());
+    dispatch(setVirtualMachinesBreadcrumbs());
+    dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.SHELL_ACCESS));
+});
+
+export const loadVirtualMachinesAdmin = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadVirtualMachinesPanel());
+    dispatch(setVirtualMachinesAdminBreadcrumbs());
+    dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SIDE_PANEL_TREE }))
+});
+
+export const loadRepositories = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadRepositoriesPanel());
+    dispatch(setRepositoriesBreadcrumbs());
+});
+
+export const loadSshKeys = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSshKeysPanel());
+});
+
+export const loadInstanceTypes = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.INSTANCE_TYPES));
+    dispatch(setInstanceTypesBreadcrumbs());
+});
+
+export const loadSiteManager = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSiteManagerPanel());
+});
+
+export const loadUserProfile = (userUuid?: string) =>
+    handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+        if (userUuid) {
+            dispatch(setUserProfileBreadcrumbs(userUuid));
+            dispatch(userProfilePanelActions.loadUserProfilePanel(userUuid));
+        } else {
+            dispatch(setMyAccountBreadcrumbs());
+            dispatch(userProfilePanelActions.loadUserProfilePanel());
+        }
+    });
+
+export const loadLinkAccount = handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+    dispatch(loadLinkAccountPanel());
+});
+
+export const loadKeepServices = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadKeepServicesPanel());
+});
+
+export const loadUsers = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadUsersPanel());
+    dispatch(setUsersBreadcrumbs());
+});
+
+export const loadApiClientAuthorizations = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadApiClientAuthorizationsPanel());
+});
+
+export const loadGroupsPanel = handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+    dispatch(setGroupsBreadcrumbs());
+    dispatch(groupPanelActions.loadGroupsPanel());
+});
+
+export const loadGroupDetailsPanel = (groupUuid: string) =>
+    handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+        dispatch(setGroupDetailsBreadcrumbs(groupUuid));
+        dispatch(groupDetailsPanelActions.loadGroupDetailsPanel(groupUuid));
+    });
+
+const finishLoadingProject = (project: GroupContentsResource | string) => async (dispatch: Dispatch<any>) => {
+    const uuid = typeof project === "string" ? project : project.uuid;
+    dispatch(loadDetailsPanel(uuid));
+    if (typeof project !== "string") {
+        dispatch(updateResources([project]));
+    }
+};
+
+const loadGroupContentsResource = async (params: { uuid: string; userUuid: string; services: ServiceRepository }) => {
+    const filters = new FilterBuilder().addEqual("uuid", params.uuid).getFilters();
+    const { items } = await params.services.groupsService.contents(params.userUuid, {
+        filters,
+        recursive: true,
+        includeTrash: true,
+    });
+    const resource = items.shift();
+    let handler: GroupContentsHandler;
+    if (resource) {
+        handler =
+            (resource.kind === ResourceKind.COLLECTION || resource.kind === ResourceKind.PROJECT) && resource.isTrashed
+                ? groupContentsHandlers.TRASHED(resource)
+                : groupContentsHandlers.OWNED(resource);
+    } else {
+        const kind = extractUuidKind(params.uuid);
+        let resource: GroupContentsResource;
+        if (kind === ResourceKind.COLLECTION) {
+            resource = await params.services.collectionService.get(params.uuid);
+        } else if (kind === ResourceKind.PROJECT) {
+            resource = await params.services.projectService.get(params.uuid);
+        } else if (kind === ResourceKind.WORKFLOW) {
+            resource = await params.services.workflowService.get(params.uuid);
+        } else if (kind === ResourceKind.CONTAINER_REQUEST) {
+            resource = await params.services.containerRequestService.get(params.uuid);
+        } else {
+            throw new Error("loadGroupContentsResource unsupported kind " + kind);
+        }
+        handler = groupContentsHandlers.SHARED(resource);
+    }
+    return (cases: MatchCases<typeof groupContentsHandlersRecord, GroupContentsHandler, void>) => groupContentsHandlers.match(handler, cases);
+};
+
+const groupContentsHandlersRecord = {
+    TRASHED: ofType<GroupContentsResource>(),
+    SHARED: ofType<GroupContentsResource>(),
+    OWNED: ofType<GroupContentsResource>(),
+};
+
+const groupContentsHandlers = unionize(groupContentsHandlersRecord);
+
+type GroupContentsHandler = UnionOf<typeof groupContentsHandlers>;
+
+type CollectionCopyResource = Resource & { name: string; fromContextMenu: boolean };
+
+type MoveableResource = Resource & { name: string };
+
+type MoveFunc = (
+    data: MoveToFormDialogData,
+    isSecondaryMove?: boolean
+) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>;
+
+const secondaryMove: Record<string, MoveFunc> = {
+    [ResourceKind.PROJECT]: moveProject,
+    [ResourceKind.PROCESS]: moveProcess,
+    [ResourceKind.COLLECTION]: moveCollection,
+};
diff --git a/services/workbench2/src/store/workflow-panel/workflow-middleware-service.ts b/services/workbench2/src/store/workflow-panel/workflow-middleware-service.ts
new file mode 100644 (file)
index 0000000..aa34218
--- /dev/null
@@ -0,0 +1,86 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { DataExplorerMiddlewareService, dataExplorerToListParams, getOrder, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
+import { updateResources } from 'store/resources/resources-actions';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { WorkflowResource } from 'models/workflow';
+import { ListResults } from 'services/common-service/common-service';
+import { workflowPanelActions } from 'store/workflow-panel/workflow-panel-actions';
+import { matchRegisteredWorkflowRoute } from 'routes/routes';
+import { ProcessesMiddlewareService } from "store/processes/processes-middleware-service";
+import { workflowProcessesPanelActions } from "./workflow-panel-actions";
+import { joinFilters } from "services/api/filter-builder";
+
+export class WorkflowMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const state = api.getState();
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+        try {
+            const response = await this.services.workflowService.list(getParams(dataExplorer));
+            api.dispatch(updateResources(response.items));
+            api.dispatch(setItems(response));
+        } catch {
+            api.dispatch(couldNotFetchWorkflows());
+        }
+    }
+}
+
+export const getParams = (dataExplorer: DataExplorer) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    order: getOrder<WorkflowResource>(dataExplorer),
+    filters: getFilters(dataExplorer)
+});
+
+export const getFilters = (dataExplorer: DataExplorer) => {
+    const filters = new FilterBuilder()
+        .addILike("name", dataExplorer.searchValue)
+        .getFilters();
+    return filters;
+};
+
+export const setItems = (listResults: ListResults<WorkflowResource>) =>
+    workflowPanelActions.SET_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
+
+const couldNotFetchWorkflows = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch workflows.',
+        kind: SnackbarKind.ERROR
+    });
+
+
+export class WorkflowProcessesMiddlewareService extends ProcessesMiddlewareService {
+    constructor(services: ServiceRepository, id: string) {
+        super(services, workflowProcessesPanelActions, id);
+    }
+
+    getFilters(api: MiddlewareAPI<Dispatch, RootState>, dataExplorer: DataExplorer): string | null {
+        const state = api.getState();
+
+        if (!state.router.location) { return null; }
+
+        const registeredWorkflowMatch = matchRegisteredWorkflowRoute(state.router.location.pathname);
+        if (!registeredWorkflowMatch) { return null; }
+
+        const workflow_uuid = registeredWorkflowMatch.params.id;
+
+        const requesting_container = new FilterBuilder().addEqual('properties.template_uuid', workflow_uuid).getFilters();
+        const sup = super.getFilters(api, dataExplorer);
+        if (sup === null) { return null; }
+
+        return joinFilters(sup, requesting_container);
+    }
+}
diff --git a/services/workbench2/src/store/workflow-panel/workflow-panel-actions.test.ts b/services/workbench2/src/store/workflow-panel/workflow-panel-actions.test.ts
new file mode 100644 (file)
index 0000000..1a7cad8
--- /dev/null
@@ -0,0 +1,88 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { API_TOKEN_KEY } from "services/auth-service/auth-service";
+
+import 'jest-localstorage-mock';
+import { ServiceRepository, createServices } from "services/services";
+import { configureStore, RootStore } from "../store";
+import { createBrowserHistory } from "history";
+import { Config, mockConfig } from 'common/config';
+import { ApiActions } from "services/api/api-actions";
+import { ACCOUNT_LINK_STATUS_KEY } from 'services/link-account-service/link-account-service';
+import Axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { ImportMock } from 'ts-mock-imports';
+import * as servicesModule from "services/services";
+import { SessionStatus } from "models/session";
+import { openRunProcess } from './workflow-panel-actions';
+import { runProcessPanelActions } from 'store/run-process-panel/run-process-panel-actions';
+import { initialize } from 'redux-form';
+import { RUN_PROCESS_BASIC_FORM } from 'views/run-process-panel/run-process-basic-form';
+import { RUN_PROCESS_INPUTS_FORM } from 'views/run-process-panel/run-process-inputs-form';
+import { ResourceKind } from 'models/resource';
+import { WorkflowResource } from 'models/workflow';
+
+describe('workflow-panel-actions', () => {
+    const axiosInst = Axios.create({ headers: {} });
+    const axiosMock = new MockAdapter(axiosInst);
+
+    let store: RootStore;
+    let services: ServiceRepository;
+    const config: any = {};
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+    let importMocks: any[];
+
+    beforeEach(() => {
+        axiosMock.reset();
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+        localStorage.clear();
+        importMocks = [];
+    });
+
+    afterEach(() => {
+        importMocks.map(m => m.restore());
+    });
+
+    it('opens the run process panel', async () => {
+        const wflist: WorkflowResource[] = [{
+            uuid: "zzzzz-7fd4e-0123456789abcde",
+            name: "foo",
+            description: "",
+            definition: "$graph: []",
+            kind: ResourceKind.WORKFLOW,
+            ownerUuid: "",
+            createdAt: "",
+            modifiedByClientUuid: "",
+            modifiedByUserUuid: "",
+            modifiedAt: "",
+            href: "",
+            etag: ""
+        }];
+        axiosMock
+            .onGet("/workflows")
+            .reply(200, {
+                items: wflist
+            }).onGet("/links")
+            .reply(200, {
+                items: []
+            });
+
+        const dispatchMock = jest.fn();
+        const dispatchWrapper = (action: any) => {
+            dispatchMock(action);
+            return store.dispatch(action);
+        };
+
+        await openRunProcess("zzzzz-7fd4e-0123456789abcde", "zzzzz-tpzed-0123456789abcde", "testing", { inputparm: "value" })(dispatchWrapper, store.getState, services);
+        expect(dispatchMock).toHaveBeenCalledWith(runProcessPanelActions.SET_WORKFLOWS(wflist));
+        expect(dispatchMock).toHaveBeenCalledWith(runProcessPanelActions.SET_SELECTED_WORKFLOW(wflist[0]));
+        expect(dispatchMock).toHaveBeenCalledWith(initialize(RUN_PROCESS_BASIC_FORM, { name: "testing" }));
+        expect(dispatchMock).toHaveBeenCalledWith(initialize(RUN_PROCESS_INPUTS_FORM, { inputparm: "value" }));
+    });
+});
diff --git a/services/workbench2/src/store/workflow-panel/workflow-panel-actions.ts b/services/workbench2/src/store/workflow-panel/workflow-panel-actions.ts
new file mode 100644 (file)
index 0000000..37b96bd
--- /dev/null
@@ -0,0 +1,129 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { propertiesActions } from 'store/properties/properties-actions';
+import { getProperty } from 'store/properties/properties';
+import { navigateToRunProcess, navigateTo } from 'store/navigation/navigation-action';
+import {
+    goToStep,
+    runProcessPanelActions,
+    loadPresets,
+    getWorkflowRunnerSettings
+} from 'store/run-process-panel/run-process-panel-actions';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { initialize } from 'redux-form';
+import { RUN_PROCESS_BASIC_FORM } from 'views/run-process-panel/run-process-basic-form';
+import { RUN_PROCESS_INPUTS_FORM } from 'views/run-process-panel/run-process-inputs-form';
+import { RUN_PROCESS_ADVANCED_FORM } from 'views/run-process-panel/run-process-advanced-form';
+import { getResource } from 'store/resources/resources';
+import { ProjectResource } from 'models/project';
+import { UserResource } from 'models/user';
+import { getWorkflowInputs, parseWorkflowDefinition } from 'models/workflow';
+
+export const WORKFLOW_PANEL_ID = "workflowPanel";
+const UUID_PREFIX_PROPERTY_NAME = 'uuidPrefix';
+const WORKFLOW_PANEL_DETAILS_UUID = 'workflowPanelDetailsUuid';
+export const workflowPanelActions = bindDataExplorerActions(WORKFLOW_PANEL_ID);
+
+export const WORKFLOW_PROCESSES_PANEL_ID = "workflowProcessesPanel";
+export const workflowProcessesPanelActions = bindDataExplorerActions(WORKFLOW_PROCESSES_PANEL_ID);
+
+export const loadWorkflowPanel = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(workflowPanelActions.REQUEST_ITEMS());
+        const response = await services.workflowService.list();
+        dispatch(runProcessPanelActions.SET_WORKFLOWS(response.items));
+    };
+
+export const setUuidPrefix = (uuidPrefix: string) =>
+    propertiesActions.SET_PROPERTY({ key: UUID_PREFIX_PROPERTY_NAME, value: uuidPrefix });
+
+export const getUuidPrefix = (state: RootState) => {
+    return state.properties.uuidPrefix;
+};
+
+export const openRunProcess = (workflowUuid: string, ownerUuid?: string, name?: string, inputObj?: { [key: string]: any }) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const response = await services.workflowService.list();
+        dispatch(runProcessPanelActions.SET_WORKFLOWS(response.items));
+        
+        const workflows = getState().runProcessPanel.searchWorkflows;
+        const listedWorkflow = workflows.find(workflow => workflow.uuid === workflowUuid);
+        const workflow = listedWorkflow || await services.workflowService.get(workflowUuid);
+        if (workflow) {
+            dispatch<any>(navigateToRunProcess);
+            dispatch<any>(goToStep(1));
+            dispatch(runProcessPanelActions.SET_STEP_CHANGED(true));
+            dispatch(runProcessPanelActions.SET_SELECTED_WORKFLOW(workflow));
+            dispatch<any>(loadPresets(workflow.uuid));
+
+            dispatch(initialize(RUN_PROCESS_ADVANCED_FORM, getWorkflowRunnerSettings(workflow)));
+            let owner;
+            if (ownerUuid) {
+                // Must be writable.
+                owner = getResource<ProjectResource | UserResource>(ownerUuid)(getState().resources);
+                if (!owner || !owner.canWrite) {
+                    owner = undefined;
+                }
+            }
+            if (owner) {
+                dispatch(runProcessPanelActions.SET_PROCESS_OWNER_UUID(owner.uuid));
+            }
+
+            dispatch(initialize(RUN_PROCESS_BASIC_FORM, { name, owner }));
+
+            const definition = parseWorkflowDefinition(workflow);
+            if (definition) {
+                const inputs = getWorkflowInputs(definition);
+                if (inputs) {
+                    const values = inputs.reduce((values, input) => ({
+                        ...values,
+                        [input.id]: input.default,
+                    }), {});
+                    dispatch(initialize(RUN_PROCESS_INPUTS_FORM, values));
+                }
+            }
+
+            if (inputObj) {
+                dispatch(initialize(RUN_PROCESS_INPUTS_FORM, inputObj));
+            }
+        } else {
+            dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `You can't run this process` }));
+        }
+    };
+
+export const getPublicUserUuid = (state: RootState) => {
+    const prefix = state.auth.localCluster;
+    return `${prefix}-tpzed-anonymouspublic`;
+};
+export const getPublicGroupUuid = (state: RootState) => {
+    const prefix = state.auth.localCluster;
+    return `${prefix}-j7d0g-anonymouspublic`;
+};
+export const getAllUsersGroupUuid = (state: RootState) => {
+    const prefix = state.auth.localCluster;
+    return `${prefix}-j7d0g-fffffffffffffff`;
+};
+
+export const showWorkflowDetails = (uuid: string) =>
+    propertiesActions.SET_PROPERTY({ key: WORKFLOW_PANEL_DETAILS_UUID, value: uuid });
+
+export const getWorkflowDetails = (state: RootState) => {
+    const uuid = getProperty<string>(WORKFLOW_PANEL_DETAILS_UUID)(state.properties);
+    const workflows = state.runProcessPanel.workflows;
+    const workflow = workflows.find(workflow => workflow.uuid === uuid);
+    return workflow || undefined;
+};
+
+export const deleteWorkflow = (workflowUuid: string, ownerUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(navigateTo(ownerUuid));
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        await services.workflowService.delete(workflowUuid);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+    };
diff --git a/services/workbench2/src/validators/is-float.tsx b/services/workbench2/src/validators/is-float.tsx
new file mode 100644 (file)
index 0000000..5cbfb83
--- /dev/null
@@ -0,0 +1,11 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { isNumber } from 'lodash';
+
+const ERROR_MESSAGE = 'This field must be a float';
+
+export const isFloat = (value: any) => {
+    return isNumber(value) ? undefined : ERROR_MESSAGE;
+};
diff --git a/services/workbench2/src/validators/is-integer.tsx b/services/workbench2/src/validators/is-integer.tsx
new file mode 100644 (file)
index 0000000..84e7f24
--- /dev/null
@@ -0,0 +1,11 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { isInteger as isInt } from 'lodash';
+
+const ERROR_MESSAGE = 'This field can only contain integer values';
+
+export const isInteger = (value: any) => {
+    return isInt(value) ? undefined : ERROR_MESSAGE;
+};
diff --git a/services/workbench2/src/validators/is-number.tsx b/services/workbench2/src/validators/is-number.tsx
new file mode 100644 (file)
index 0000000..b43eec9
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { isNumber as isNum } from 'lodash';
+const ERROR_MESSAGE = 'This field can only contain numeric values';
+
+export const isNumber = (value: any) => {
+    return !isNaN(value) && isNum(value) ? undefined : ERROR_MESSAGE;
+};
diff --git a/services/workbench2/src/validators/is-remote-host.tsx b/services/workbench2/src/validators/is-remote-host.tsx
new file mode 100644 (file)
index 0000000..b5e2231
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+
+const ERROR_MESSAGE = 'Remote host is invalid';
+
+export const isRemoteHost = (value: string) => {
+    return value.match(/\w+\.\w+\.\w+/i) ? undefined : ERROR_MESSAGE;
+};
diff --git a/services/workbench2/src/validators/is-rsa-key.test.tsx b/services/workbench2/src/validators/is-rsa-key.test.tsx
new file mode 100644 (file)
index 0000000..067d774
--- /dev/null
@@ -0,0 +1,39 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { isRsaKey } from './is-rsa-key';
+
+describe('rsa-key-validator', () => {
+    const rsaKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDPpavAS1wUq2+j7PgwkDS+9lm43AkdGxZo+T8qm6ZcB009EUEXya3lQolA52gg/i5aGZg4LT3t1OKxbsaClMd7sNZXYrMW9vd/utvGgAlNEbE/yXsEl2kpxt8lz7RI1XLnoWcV+aKyrsiKdrMKnZyG8CBxKdtzxHzWRl4N1BGrFJf/RnUWJv2VvM/h4/O+KXIjFokPkJ1F8yQChp5OKGkBKGXQ1vV4LjXqEXGVlgiQFM4U2NvCA8hXQR8mYm1vOsTYJzoSsnb+ewbXlVH5d7XsR5S2ULOr88vuYN/P4DF/Q3pEBi7BOyee61P3eHvhCNtb+jQMt59Vj/96y5C/reTMRo2R3B4bmX+Zxr3+DCC5tO1y+U5V39fu7cweimKXc78QDGGAVN0kz4P6P137b5WkCYIozeiBvWRsbGIlHjlGu9+0WuotdluD+OrTguuZ2zr8f32ijddO6y0J+aIdmTxQPxtmcQuRtpRfquoJGLhWAJH6mNZKbWkqqVfd5BA0TYs=';
+    const badKey = 'ssh-rsa bad'
+
+    const ERROR_MESSAGE = 'Public key is invalid';
+
+    describe('rsaKeyValidation', () => {
+        it('should accept keys with comment', () => {
+            // then
+            expect(isRsaKey(rsaKey + " firstlast@example.com")).toBeUndefined();
+        });
+
+        it('should accept keys without comment', () => {
+            // then
+            expect(isRsaKey(rsaKey)).toBeUndefined();
+        });
+
+        it('should reject keys with trailing whitespace', () => {
+            // then
+            expect(isRsaKey(rsaKey + " ")).toBe(ERROR_MESSAGE);
+            expect(isRsaKey(rsaKey + "\n")).toBe(ERROR_MESSAGE);
+            expect(isRsaKey(rsaKey + "\r\n")).toBe(ERROR_MESSAGE);
+            expect(isRsaKey(rsaKey + "\t")).toBe(ERROR_MESSAGE);
+        });
+
+        it('should reject invalid keys', () => {
+            // then
+            expect(isRsaKey(badKey)).toBe(ERROR_MESSAGE);
+        });
+
+    });
+
+});
diff --git a/services/workbench2/src/validators/is-rsa-key.tsx b/services/workbench2/src/validators/is-rsa-key.tsx
new file mode 100644 (file)
index 0000000..d41b092
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+
+const ERROR_MESSAGE = 'Public key is invalid';
+
+export const isRsaKey = (value: any) => {
+    return value.match(/ssh-rsa AAAA[0-9A-Za-z+/]+[=]{0,3}(( [^@]+@[^@]+)|$)/i) ? undefined : ERROR_MESSAGE;
+};
diff --git a/services/workbench2/src/validators/max-length.tsx b/services/workbench2/src/validators/max-length.tsx
new file mode 100644 (file)
index 0000000..370aa4c
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const ERROR_MESSAGE = 'Maximum string length of this field is: ';
+export const DEFAULT_MAX_VALUE = 60;
+
+export const maxLength: any = (maxLengthValue = DEFAULT_MAX_VALUE, errorMessage = ERROR_MESSAGE) => {
+    return (value: string) => {
+        if (value) {
+            return  value && value.length <= maxLengthValue ? undefined : `${errorMessage || ERROR_MESSAGE} ${maxLengthValue}`;
+        }
+
+        return undefined;
+    };
+};
diff --git a/services/workbench2/src/validators/min-length.tsx b/services/workbench2/src/validators/min-length.tsx
new file mode 100644 (file)
index 0000000..9b26953
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const ERROR_MESSAGE = (minLength: number) => `Min length is ${minLength}`;
+
+export const minLength =
+    (minLength: number, errorMessage = ERROR_MESSAGE) =>
+        (value: { length: number }) =>
+            value && value.length >= minLength ? undefined : errorMessage(minLength);
diff --git a/services/workbench2/src/validators/min.tsx b/services/workbench2/src/validators/min.tsx
new file mode 100644 (file)
index 0000000..e326a70
--- /dev/null
@@ -0,0 +1,12 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { isNumber } from 'lodash';
+
+export const ERROR_MESSAGE = (minValue: number) => `Minimum value is ${minValue}`;
+
+export const min =
+    (minValue: number, errorMessage = ERROR_MESSAGE) =>
+        (value: any) =>
+            isNumber(value) && value >= minValue ? undefined : errorMessage(minValue);
diff --git a/services/workbench2/src/validators/optional.tsx b/services/workbench2/src/validators/optional.tsx
new file mode 100644 (file)
index 0000000..da3a825
--- /dev/null
@@ -0,0 +1,7 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const optional = (validator: (value: any) => string | undefined) =>
+    (value: any) =>
+        value === undefined || value === null || value === ''  ? undefined : validator(value);
\ No newline at end of file
diff --git a/services/workbench2/src/validators/require.tsx b/services/workbench2/src/validators/require.tsx
new file mode 100644 (file)
index 0000000..fbba02a
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const ERROR_MESSAGE = 'This field is required.';
+
+export const require: any = (value: string) => {
+    return value && value.length > 0 ? undefined : ERROR_MESSAGE;
+};
diff --git a/services/workbench2/src/validators/valid-name.tsx b/services/workbench2/src/validators/valid-name.tsx
new file mode 100644 (file)
index 0000000..89bb3f9
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const disallowDotName = /^\.{1,2}$/;
+export const disallowSlash = /\//;
+export const disallowLeadingWhitespaces = /^\s+/;
+export const disallowTrailingWhitespaces = /\s+$/;
+
+export const validName = (value: string) => {
+    return [disallowDotName, disallowSlash].find(aRule => value.match(aRule) !== null)
+        ? "Name cannot be '.' or '..' or contain '/' characters"
+        : undefined;
+};
+
+export const validNameAllowSlash = (value: string) => {
+    return [disallowDotName].find(aRule => value.match(aRule) !== null)
+        ? "Name cannot be '.' or '..'"
+        : undefined;
+};
+
+export const validFileName = (value: string) => {
+    return [
+        disallowLeadingWhitespaces,
+        disallowTrailingWhitespaces
+    ].find(aRule => value.match(aRule) !== null)
+        ? `Leading/trailing whitespaces not allowed on '${value}'`
+        : undefined;
+};
+
+export const validFilePath = (filePath: string) => {
+    const errors = filePath.split('/').map(pathPart => {
+        if (pathPart === "") { return "Empty dir name not allowed"; }
+        return validNameAllowSlash(pathPart) || validFileName(pathPart);
+    });
+    return errors.filter(e => e !== undefined)[0];
+};
\ No newline at end of file
diff --git a/services/workbench2/src/validators/validators.tsx b/services/workbench2/src/validators/validators.tsx
new file mode 100644 (file)
index 0000000..87a4c1f
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { require } from './require';
+import { maxLength } from './max-length';
+import { isRsaKey } from './is-rsa-key';
+import { isRemoteHost } from "./is-remote-host";
+import { validFilePath, validName, validNameAllowSlash } from "./valid-name";
+
+export const TAG_KEY_VALIDATION = [maxLength(255)];
+export const TAG_VALUE_VALIDATION = [maxLength(255)];
+
+export const PROJECT_NAME_VALIDATION = [require, validName, maxLength(255)];
+export const PROJECT_NAME_VALIDATION_ALLOW_SLASH = [require, validNameAllowSlash, maxLength(255)];
+
+export const COLLECTION_NAME_VALIDATION = [require, validName, maxLength(255)];
+export const COLLECTION_NAME_VALIDATION_ALLOW_SLASH = [require, validNameAllowSlash, maxLength(255)];
+export const COLLECTION_DESCRIPTION_VALIDATION = [maxLength(255)];
+export const COLLECTION_PROJECT_VALIDATION = [require];
+
+export const COPY_NAME_VALIDATION = [require, maxLength(255)];
+export const COPY_FILE_VALIDATION = [require];
+export const RENAME_FILE_VALIDATION = [require, validFilePath];
+
+export const MOVE_TO_VALIDATION = [require];
+
+export const PROCESS_NAME_VALIDATION = [require, maxLength(255)];
+export const PROCESS_DESCRIPTION_VALIDATION = [maxLength(255)];
+
+export const REPOSITORY_NAME_VALIDATION = [require, maxLength(255)];
+
+export const USER_EMAIL_VALIDATION = [require, maxLength(255)];
+export const PROFILE_EMAIL_VALIDATION = [maxLength(255)];
+export const PROFILE_URL_VALIDATION = [maxLength(255)];
+export const USER_LENGTH_VALIDATION = [maxLength(255)];
+
+export const SSH_KEY_PUBLIC_VALIDATION = [require, isRsaKey, maxLength(1024)];
+export const SSH_KEY_NAME_VALIDATION = [require, maxLength(255)];
+
+export const SITE_MANAGER_REMOTE_HOST_VALIDATION = [require, isRemoteHost, maxLength(255)];
+
+export const MY_ACCOUNT_VALIDATION = [require];
+
+export const CHOOSE_VM_VALIDATION = [require];
diff --git a/services/workbench2/src/views-components/add-session/add-session.tsx b/services/workbench2/src/views-components/add-session/add-session.tsx
new file mode 100644 (file)
index 0000000..bcfb531
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RouteProps } from "react-router";
+import React from "react";
+import { connect, DispatchProp } from "react-redux";
+import { getUrlParameter } from "common/url";
+import { navigateToSiteManager } from "store/navigation/navigation-action";
+import { addSession } from "store/auth/auth-action-session";
+
+export const AddSession = connect()(
+    class extends React.Component<RouteProps & DispatchProp<any>, {}> {
+        componentDidMount() {
+            const search = this.props.location ? this.props.location.search : "";
+            const apiToken = getUrlParameter(search, 'api_token');
+            const baseURL = getUrlParameter(search, 'baseURL');
+
+            this.props.dispatch(addSession(baseURL, apiToken));
+            this.props.dispatch(navigateToSiteManager);
+        }
+        render() {
+            return <div />;
+        }
+    }
+);
diff --git a/services/workbench2/src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx b/services/workbench2/src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx
new file mode 100644 (file)
index 0000000..3505fae
--- /dev/null
@@ -0,0 +1,125 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogActions, Button, StyleRulesCallback, WithStyles, withStyles, DialogTitle, DialogContent, Tabs, Tab, DialogContentText } from '@material-ui/core';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { withDialog } from "store/dialog/with-dialog";
+import { compose } from 'redux';
+import { AdvancedTabDialogData, ADVANCED_TAB_DIALOG } from "store/advanced-tab/advanced-tab";
+import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet";
+import { MetadataTab } from 'views-components/advanced-tab-dialog/metadataTab';
+import { LinkResource } from "models/link";
+import { ListResults } from "services/common-service/common-service";
+
+type CssRules = 'content' | 'codeSnippet' | 'spacing';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    content: {
+        paddingTop: theme.spacing.unit * 3,
+        minHeight: '400px',
+        minWidth: '1232px'
+    },
+    codeSnippet: {
+        borderRadius: theme.spacing.unit * 0.5,
+        border: '1px solid',
+        borderColor: theme.palette.grey["400"],
+        maxHeight: '400px'
+    },
+    spacing: {
+        paddingBottom: theme.spacing.unit * 2
+    },
+});
+
+export const AdvancedTabDialog = compose(
+    withDialog(ADVANCED_TAB_DIALOG),
+    withStyles(styles),
+)(
+    class extends React.Component<WithDialogProps<AdvancedTabDialogData> & WithStyles<CssRules>>{
+        state = {
+            value: 0,
+        };
+
+        componentDidMount() {
+            this.setState({ value: 0 });
+        }
+
+        handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+            this.setState({ value });
+        }
+        render() {
+            const { classes, open, closeDialog } = this.props;
+            const { value } = this.state;
+            const {
+                apiResponse,
+                metadata,
+                pythonHeader,
+                pythonExample,
+                cliGetHeader,
+                cliGetExample,
+                cliUpdateHeader,
+                cliUpdateExample,
+                curlHeader,
+                curlExample,
+                uuid,
+            } = this.props.data;
+            return <Dialog
+                open={open}
+                maxWidth="lg"
+                onClose={closeDialog}
+                onExit={() => this.setState({ value: 0 })} >
+                <DialogTitle>API Details</DialogTitle>
+                <Tabs value={value} onChange={this.handleChange} fullWidth>
+                    <Tab label="API RESPONSE" />
+                    <Tab label="METADATA" />
+                    <Tab label="PYTHON EXAMPLE" />
+                    <Tab label="CLI EXAMPLE" />
+                    <Tab label="CURL EXAMPLE" />
+                </Tabs>
+                <DialogContent className={classes.content}>
+                    {value === 0 && <div>{dialogContentExample(apiResponse, classes)}</div>}
+                    {value === 1 && <div>
+                        {metadata !== '' && (metadata as ListResults<LinkResource>).items.length > 0 ?
+                            <MetadataTab items={(metadata as ListResults<LinkResource>).items} uuid={uuid} />
+                            : dialogContentHeader('(No metadata links found)')}
+                    </div>}
+                    {value === 2 && dialogContent(pythonHeader, pythonExample, classes)}
+                    {value === 3 && <div>
+                        {dialogContent(cliGetHeader, cliGetExample, classes)}
+                        {dialogContent(cliUpdateHeader, cliUpdateExample, classes)}
+                    </div>}
+                    {value === 4 && dialogContent(curlHeader, curlExample, classes)}
+                </DialogContent>
+                <DialogActions>
+                    <Button data-cy="close-advanced-dialog" variant='text' color='primary' onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>;
+        }
+    }
+);
+
+const dialogContent = (header: string, example: string, classes: any) =>
+    <div className={classes.spacing}>
+        {dialogContentHeader(header)}
+        {dialogContentExample(example, classes)}
+    </div>;
+
+const dialogContentHeader = (header: string) =>
+    <DialogContentText>
+        {header}
+    </DialogContentText>;
+
+const dialogContentExample = (example: JSX.Element | string, classes: any) => {
+    // Pass string to lines param or JSX to child props
+    const stringData = example && (example as string).length ? (example as string) : undefined;
+    return <DefaultCodeSnippet
+        apiResponse
+        className={classes.codeSnippet}
+        lines={stringData ? [stringData] : []}
+    >
+        {React.isValidElement(example) ? (example as JSX.Element) : undefined}
+    </DefaultCodeSnippet>;
+}
diff --git a/services/workbench2/src/views-components/advanced-tab-dialog/metadataTab.tsx b/services/workbench2/src/views-components/advanced-tab-dialog/metadataTab.tsx
new file mode 100644 (file)
index 0000000..1b950d2
--- /dev/null
@@ -0,0 +1,55 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Table, TableHead, TableCell, TableRow, TableBody, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+
+type CssRules = 'cell';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    cell: {
+        paddingRight: theme.spacing.unit * 2
+    }
+});
+
+interface MetadataTable {
+    uuid: string;
+    linkClass: string;
+    name: string;
+    tailUuid: string;
+    headUuid: string;
+    properties: any;
+}
+
+interface MetadataProps {
+    items: MetadataTable[];
+    uuid: string;
+}
+
+export const MetadataTab = withStyles(styles)((props: MetadataProps & WithStyles<CssRules>) =>
+    <Table>
+        <TableHead>
+            <TableRow>
+                <TableCell>uuid</TableCell>
+                <TableCell>link_class</TableCell>
+                <TableCell>name</TableCell>
+                <TableCell>tail</TableCell>
+                <TableCell>head</TableCell>
+                <TableCell>properties</TableCell>
+            </TableRow>
+        </TableHead>
+        <TableBody>
+            {props.items.map((it, index) =>
+                <TableRow key={index}>
+                    <TableCell className={props.classes.cell}>{it.uuid}</TableCell>
+                    <TableCell className={props.classes.cell}>{it.linkClass}</TableCell>
+                    <TableCell className={props.classes.cell}>{it.name}</TableCell>
+                    <TableCell className={props.classes.cell}>{it.tailUuid}</TableCell>
+                    <TableCell className={props.classes.cell}>{it.headUuid === props.uuid ? 'this' : it.headUuid}</TableCell>
+                    <TableCell className={props.classes.cell}>{JSON.stringify(it.properties)}</TableCell>
+                </TableRow>
+            )}
+        </TableBody>
+    </Table>
+);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/api-client-authorizations-dialog/attributes-dialog.tsx b/services/workbench2/src/views-components/api-client-authorizations-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..de31f52
--- /dev/null
@@ -0,0 +1,78 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { compose } from 'redux';
+import {
+    withStyles, Dialog, DialogTitle, DialogContent, DialogActions,
+    Button, StyleRulesCallback, WithStyles, Grid
+} from '@material-ui/core';
+import { WithDialogProps, withDialog } from "store/dialog/with-dialog";
+import { API_CLIENT_AUTHORIZATION_ATTRIBUTES_DIALOG } from 'store/api-client-authorizations/api-client-authorizations-actions';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import { formatDate } from 'common/formatters';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        fontSize: '0.875rem',
+        '& div:nth-child(odd)': {
+            textAlign: 'right',
+            color: theme.palette.grey["500"]
+        }
+    }
+});
+
+interface AttributesKeepServiceDialogDataProps {
+    apiClientAuthorization: ApiClientAuthorization;
+}
+
+export const AttributesApiClientAuthorizationDialog = compose(
+    withDialog(API_CLIENT_AUTHORIZATION_ATTRIBUTES_DIALOG),
+    withStyles(styles))(
+        ({ open, closeDialog, data, classes }: WithDialogProps<AttributesKeepServiceDialogDataProps> & WithStyles<CssRules>) =>
+            <Dialog open={open} onClose={closeDialog} fullWidth maxWidth='sm'>
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    {data.apiClientAuthorization && <Grid container direction="row" spacing={16} className={classes.root}>
+                        <Grid item xs={5}>UUID</Grid>
+                        <Grid item xs={7}>{data.apiClientAuthorization.uuid}</Grid>
+                        <Grid item xs={5}>Owner uuid</Grid>
+                        <Grid item xs={7}>{data.apiClientAuthorization.ownerUuid}</Grid>
+                        <Grid item xs={5}>API Client ID</Grid>
+                        <Grid item xs={7}>{data.apiClientAuthorization.apiClientId}</Grid>
+                        <Grid item xs={5}>API Token</Grid>
+                        <Grid item xs={7}>{data.apiClientAuthorization.apiToken}</Grid>
+                        <Grid item xs={5}>Created by IP address</Grid>
+                        <Grid item xs={7}>{data.apiClientAuthorization.createdByIpAddress || '(none)'}</Grid>
+                        <Grid item xs={5}>Default owner</Grid>
+                        <Grid item xs={7}>{data.apiClientAuthorization.defaultOwnerUuid || '(none)'}</Grid>
+                        <Grid item xs={5}>Expires at</Grid>
+                        <Grid item xs={7}>{formatDate(data.apiClientAuthorization.expiresAt) || '(none)'}</Grid>
+                        <Grid item xs={5}>Last used at</Grid>
+                        <Grid item xs={7}>{formatDate(data.apiClientAuthorization.lastUsedAt) || '(none)'}</Grid>
+                        <Grid item xs={5}>Last used by IP address</Grid>
+                        <Grid item xs={7}>{data.apiClientAuthorization.lastUsedByIpAddress || '(none)'}</Grid>
+                        <Grid item xs={5}>Scopes</Grid>
+                        <Grid item xs={7}>{JSON.stringify(data.apiClientAuthorization.scopes || '(none)')}</Grid>
+                        <Grid item xs={5}>User ID</Grid>
+                        <Grid item xs={7}>{data.apiClientAuthorization.userId || '(none)'}</Grid>
+                        <Grid item xs={5}>Created at</Grid>
+                        <Grid item xs={7}>{formatDate(data.apiClientAuthorization.createdAt) || '(none)'}</Grid>
+                        <Grid item xs={5}>Updated at</Grid>
+                        <Grid item xs={7}>{formatDate(data.apiClientAuthorization.updatedAt) || '(none)'}</Grid>
+                    </Grid>}
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    );
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/api-client-authorizations-dialog/help-dialog.tsx b/services/workbench2/src/views-components/api-client-authorizations-dialog/help-dialog.tsx
new file mode 100644 (file)
index 0000000..094b01a
--- /dev/null
@@ -0,0 +1,66 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from "@material-ui/core";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { withDialog } from 'store/dialog/with-dialog';
+import { DefaultCodeSnippet } from 'components/default-code-snippet/default-code-snippet';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { compose } from "redux";
+import { API_CLIENT_AUTHORIZATION_HELP_DIALOG } from 'store/api-client-authorizations/api-client-authorizations-actions';
+
+type CssRules = 'codeSnippet';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    codeSnippet: {
+        borderRadius: theme.spacing.unit * 0.5,
+        border: `1px solid ${theme.palette.grey["400"]}`,
+        '& pre': {
+            fontSize: '0.815rem'
+        }
+    }
+});
+
+interface HelpApiClientAuthorizationDataProps {
+    apiHost: string;
+    apiToken: string;
+    email: string;
+}
+
+export const HelpApiClientAuthorizationDialog = compose(
+    withDialog(API_CLIENT_AUTHORIZATION_HELP_DIALOG),
+    withStyles(styles))(
+        (props: WithDialogProps<HelpApiClientAuthorizationDataProps> & WithStyles<CssRules>) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth='md'>
+                <DialogTitle>HELP:</DialogTitle>
+                <DialogContent>
+                    <DefaultCodeSnippet
+                        className={props.classes.codeSnippet}
+                        lines={[snippetText(props.data)]} />
+                        {/* // lines={snippetText2(props.data)} /> */}
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const snippetText = (data: HelpApiClientAuthorizationDataProps) => `### Pasting the following lines at a shell prompt will allow Arvados SDKs
+### to authenticate to your account, ${data.email}
+
+read ARVADOS_API_TOKEN <<EOF
+${data.apiToken}
+EOF
+export ARVADOS_API_TOKEN ARVADOS_API_HOST=${data.apiHost}
+unset ARVADOS_API_HOST_INSECURE`;
diff --git a/services/workbench2/src/views-components/api-client-authorizations-dialog/remove-dialog.tsx b/services/workbench2/src/views-components/api-client-authorizations-dialog/remove-dialog.tsx
new file mode 100644 (file)
index 0000000..996b7b0
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { API_CLIENT_AUTHORIZATION_REMOVE_DIALOG, removeApiClientAuthorization } from 'store/api-client-authorizations/api-client-authorizations-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeApiClientAuthorization(props.data.uuid));
+    }
+});
+
+export const RemoveApiClientAuthorizationDialog = compose(
+    withDialog(API_CLIENT_AUTHORIZATION_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/api-token/api-token.tsx b/services/workbench2/src/views-components/api-token/api-token.tsx
new file mode 100644 (file)
index 0000000..e57ba26
--- /dev/null
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RouteProps } from "react-router";
+import React from "react";
+import { RootState } from "store/store";
+import { connect, DispatchProp } from "react-redux";
+import { saveApiToken } from "store/auth/auth-action";
+import { getUrlParameter } from "common/url";
+import { AuthService } from "services/auth-service/auth-service";
+import { navigateToRootProject, navigateToLinkAccount } from "store/navigation/navigation-action";
+import { Config } from "common/config";
+import { getAccountLinkData } from "store/link-account-panel/link-account-panel-actions";
+import { replace } from "react-router-redux";
+import { User } from "models/user";
+
+interface ApiTokenProps {
+    authService: AuthService;
+    config: Config;
+    loadMainApp: boolean;
+    user?: User;
+}
+
+export const ApiToken = connect((state: RootState) => ({
+    user: state.auth.user,
+}), null)(
+    class extends React.Component<ApiTokenProps & RouteProps & DispatchProp<any>, {}> {
+        componentDidMount() {
+            const search = this.props.location ? this.props.location.search : "";
+            const apiToken = getUrlParameter(search, 'api_token');
+            this.props.dispatch<any>(saveApiToken(apiToken));
+        }
+
+        componentDidUpdate() {
+            const redirectURL = this.props.authService.getTargetURL();
+
+            if (this.props.loadMainApp && this.props.user) {
+                if (redirectURL) {
+                    this.props.authService.removeTargetURL();
+                    this.props.dispatch(replace(redirectURL));
+                }
+                else if (this.props.dispatch(getAccountLinkData())) {
+                    this.props.dispatch(navigateToLinkAccount);
+                }
+                else {
+                    this.props.dispatch(navigateToRootProject);
+                }
+            }
+        }
+
+        render() {
+            return <div />;
+        }
+    }
+);
diff --git a/services/workbench2/src/views-components/auto-logout/auto-logout.test.tsx b/services/workbench2/src/views-components/auto-logout/auto-logout.test.tsx
new file mode 100644 (file)
index 0000000..c9b9e2b
--- /dev/null
@@ -0,0 +1,62 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { configure, mount } from "enzyme";
+import Adapter from 'enzyme-adapter-react-16';
+import { AutoLogoutComponent, AutoLogoutProps, LAST_ACTIVE_TIMESTAMP } from './auto-logout';
+
+configure({ adapter: new Adapter() });
+
+describe('<AutoLogoutComponent />', () => {
+    let props: AutoLogoutProps;
+    const sessionIdleTimeout = 300;
+    const lastWarningDuration = 60;
+    const eventListeners = {};
+    jest.useFakeTimers();
+
+    beforeAll(() => {
+        window.addEventListener = jest.fn((event, cb) => {
+            eventListeners[event] = cb;
+        });
+    });
+
+    beforeEach(() => {
+        props = {
+            sessionIdleTimeout: sessionIdleTimeout,
+            lastWarningDuration: lastWarningDuration,
+            doLogout: jest.fn(),
+            doWarn: jest.fn(),
+            doCloseWarn: jest.fn(),
+        };
+        mount(<div><AutoLogoutComponent {...props} /></div>);
+    });
+
+    it('should logout after idle timeout', () => {
+        jest.runTimersToTime((sessionIdleTimeout-1)*1000);
+        expect(props.doLogout).not.toBeCalled();
+        jest.runTimersToTime(1*1000);
+        expect(props.doLogout).toBeCalled();
+    });
+
+    it('should warn the user previous to close the session', () => {
+        jest.runTimersToTime((sessionIdleTimeout-lastWarningDuration-1)*1000);
+        expect(props.doWarn).not.toBeCalled();
+        jest.runTimersToTime(1*1000);
+        expect(props.doWarn).toBeCalled();
+    });
+
+    it('should reset the idle timer when activity event is received', () => {
+        jest.runTimersToTime((sessionIdleTimeout-lastWarningDuration-1)*1000);
+        expect(props.doWarn).not.toBeCalled();
+        // Simulate activity from other window/tab
+        eventListeners.storage({
+            key: LAST_ACTIVE_TIMESTAMP,
+            newValue: '42' // value currently doesn't matter
+        })
+        jest.runTimersToTime(1*1000);
+        // Warning should not appear because idle timer was reset
+        expect(props.doWarn).not.toBeCalled();
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/auto-logout/auto-logout.tsx b/services/workbench2/src/views-components/auto-logout/auto-logout.tsx
new file mode 100644 (file)
index 0000000..b4bef2b
--- /dev/null
@@ -0,0 +1,110 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { useIdleTimer } from "react-idle-timer";
+import { Dispatch } from "redux";
+
+import { RootState } from "store/store";
+import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions";
+import { logout } from "store/auth/auth-action";
+import parse from "parse-duration";
+import React from "react";
+import { min } from "lodash";
+
+interface AutoLogoutDataProps {
+    sessionIdleTimeout: number;
+    lastWarningDuration: number;
+}
+
+interface AutoLogoutActionProps {
+    doLogout: () => void;
+    doWarn: (message: string, duration: number) => void;
+    doCloseWarn: () => void;
+}
+
+const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps => ({
+    sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
+    lastWarningDuration: ownProps.lastWarningDuration || 60,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): AutoLogoutActionProps => ({
+    doLogout: () => dispatch<any>(logout(true, true)),
+    doWarn: (message: string, duration: number) =>
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message, hideDuration: duration, kind: SnackbarKind.WARNING })),
+    doCloseWarn: () => dispatch(snackbarActions.CLOSE_SNACKBAR()),
+});
+
+export type AutoLogoutProps = AutoLogoutDataProps & AutoLogoutActionProps;
+
+const debounce = (delay: number | undefined, fn: Function) => {
+    let timerId: NodeJS.Timer | null;
+    return (...args: any[]) => {
+        if (timerId) { clearTimeout(timerId); }
+        timerId = setTimeout(() => {
+            fn(...args);
+            timerId = null;
+        }, delay);
+    };
+};
+
+export const LAST_ACTIVE_TIMESTAMP = 'lastActiveTimestamp';
+
+export const AutoLogoutComponent = (props: AutoLogoutProps) => {
+    let logoutTimer: NodeJS.Timer;
+    const lastWarningDuration = min([props.lastWarningDuration, props.sessionIdleTimeout])! * 1000;
+
+    // Runs once after render
+    React.useEffect(() => {
+        window.addEventListener('storage', handleStorageEvents);
+        // Component cleanup
+        return () => {
+            window.removeEventListener('storage', handleStorageEvents);
+        };
+    });
+
+    const handleStorageEvents = (e: StorageEvent) => {
+        if (e.key === LAST_ACTIVE_TIMESTAMP) {
+            // Other tab activity detected by a localStorage change event.
+            debounce(500, () => {
+                handleOnActive();
+                reset();
+            })();
+        }
+    };
+
+    const handleOnIdle = () => {
+        logoutTimer = setTimeout(
+            () => props.doLogout(), lastWarningDuration);
+        props.doWarn(
+            "Your session is about to be closed due to inactivity",
+            lastWarningDuration);
+    };
+
+    const handleOnActive = () => {
+        if (logoutTimer) { clearTimeout(logoutTimer); }
+        props.doCloseWarn();
+    };
+
+    const handleOnAction = () => {
+        // Notify the other tabs there was some activity.
+        const now = (new Date()).getTime();
+        localStorage.setItem(LAST_ACTIVE_TIMESTAMP, now.toString());
+    };
+
+    const { reset } = useIdleTimer({
+        timeout: (props.lastWarningDuration < props.sessionIdleTimeout)
+            ? 1000 * (props.sessionIdleTimeout - props.lastWarningDuration)
+            : 1,
+        onIdle: handleOnIdle,
+        onActive: handleOnActive,
+        onAction: handleOnAction,
+        debounce: 500
+    });
+
+    return <span />;
+};
+
+export const AutoLogout = connect(mapStateToProps, mapDispatchToProps)(AutoLogoutComponent);
diff --git a/services/workbench2/src/views-components/baner/banner.test.tsx b/services/workbench2/src/views-components/baner/banner.test.tsx
new file mode 100644 (file)
index 0000000..1e82008
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { configure, shallow, mount } from "enzyme";
+import { BannerComponent } from './banner';
+import { Button } from "@material-ui/core";
+import Adapter from "enzyme-adapter-react-16";
+import servicesProvider from '../../common/service-provider';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('../../common/service-provider', () => ({
+    getServices: jest.fn(),
+}));
+
+describe('<BannerComponent />', () => {
+
+    let props;
+
+    beforeEach(() => {
+        props = {
+            isOpen: false,
+            bannerUUID: undefined,
+            keepWebInlineServiceUrl: '',
+            openBanner: jest.fn(),
+            closeBanner: jest.fn(),
+            classes: {} as any,
+        }
+    });
+
+    it('renders without crashing', () => {
+        // when
+        const banner = shallow(<BannerComponent {...props} />);
+        
+        // then
+        expect(banner.find(Button)).toHaveLength(1);
+    });
+
+    it('calls collectionService', () => {
+        // given
+        props.isOpen = true;
+        props.bannerUUID = '123';
+        const mocks = {
+            collectionService: {
+                files: jest.fn(() => ({ then: (callback) => callback([{ name: 'banner.html' }]) })),
+                getFileContents: jest.fn(() => ({ then: (callback) => callback('<h1>Test</h1>') }))
+            }
+        };
+        (servicesProvider.getServices as any).mockImplementation(() => mocks);
+
+        // when
+        const banner = mount(<BannerComponent {...props} />);
+
+        // then
+        expect(servicesProvider.getServices).toHaveBeenCalled();
+        expect(mocks.collectionService.files).toHaveBeenCalled();
+        expect(mocks.collectionService.getFileContents).toHaveBeenCalled();
+        expect(banner.html()).toContain('<h1>Test</h1>');
+    });
+});
+
diff --git a/services/workbench2/src/views-components/baner/banner.tsx b/services/workbench2/src/views-components/baner/banner.tsx
new file mode 100644 (file)
index 0000000..ac5b894
--- /dev/null
@@ -0,0 +1,114 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useState, useCallback, useEffect } from "react";
+import { Dialog, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from "@material-ui/core";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import bannerActions from "store/banner/banner-action";
+import { ArvadosTheme } from "common/custom-theme";
+import servicesProvider from "common/service-provider";
+import { Dispatch } from "redux";
+import { sanitizeHTML } from "common/html-sanitize";
+
+type CssRules = "dialogContent" | "dialogContentIframe";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    dialogContent: {
+        minWidth: "550px",
+        minHeight: "500px",
+        display: "block",
+    },
+    dialogContentIframe: {
+        minWidth: "550px",
+        minHeight: "500px",
+    },
+});
+
+interface BannerProps {
+    isOpen: boolean;
+    bannerUUID?: string;
+    keepWebInlineServiceUrl: string;
+}
+
+type BannerComponentProps = BannerProps &
+    WithStyles<CssRules> & {
+        openBanner: Function;
+        closeBanner: Function;
+    };
+
+const mapStateToProps = (state: RootState): BannerProps => ({
+    isOpen: state.banner.isOpen,
+    bannerUUID: state.auth.config.clusterConfig.Workbench.BannerUUID,
+    keepWebInlineServiceUrl: state.auth.config.keepWebInlineServiceUrl,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    openBanner: () => dispatch<any>(bannerActions.openBanner()),
+    closeBanner: () => dispatch<any>(bannerActions.closeBanner()),
+});
+
+export const BANNER_LOCAL_STORAGE_KEY = "bannerFileData";
+
+export const BannerComponent = (props: BannerComponentProps) => {
+    const { isOpen, openBanner, closeBanner, bannerUUID, keepWebInlineServiceUrl } = props;
+    const [bannerContents, setBannerContents] = useState(`<h1>Loading ...</h1>`);
+
+    const onConfirm = useCallback(() => {
+        closeBanner();
+    }, [closeBanner]);
+
+    useEffect(() => {
+        if (!!bannerUUID && bannerUUID !== "") {
+            servicesProvider
+                .getServices()
+                .collectionService.files(bannerUUID)
+                .then(results => {
+                    const bannerFileData = results.find(({ name }) => name === "banner.html");
+                    const result = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY);
+
+                    if (result && result === JSON.stringify(bannerFileData) && !isOpen) {
+                        return;
+                    }
+
+                    if (bannerFileData) {
+                        servicesProvider
+                            .getServices()
+                            .collectionService.getFileContents(bannerFileData)
+                            .then(data => {
+                                setBannerContents(data);
+                                openBanner();
+                                localStorage.setItem(BANNER_LOCAL_STORAGE_KEY, JSON.stringify(bannerFileData));
+                            });
+                    }
+                });
+        }
+    }, [bannerUUID, keepWebInlineServiceUrl, openBanner, isOpen]);
+
+    return (
+        <Dialog
+            open={isOpen}
+            maxWidth="md"
+        >
+            <div data-cy="confirmation-dialog">
+                <DialogContent className={props.classes.dialogContent}>
+                    <div dangerouslySetInnerHTML={{ __html: sanitizeHTML(bannerContents) }}></div>
+                </DialogContent>
+                <DialogActions style={{ margin: "0px 24px 24px" }}>
+                    <Button
+                        data-cy="confirmation-dialog-ok-btn"
+                        variant="contained"
+                        color="primary"
+                        type="submit"
+                        onClick={onConfirm}
+                    >
+                        Close
+                    </Button>
+                </DialogActions>
+            </div>
+        </Dialog>
+    );
+};
+
+export const Banner = withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(BannerComponent));
diff --git a/services/workbench2/src/views-components/breadcrumbs/breadcrumbs.ts b/services/workbench2/src/views-components/breadcrumbs/breadcrumbs.ts
new file mode 100644 (file)
index 0000000..7e78aac
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Breadcrumb, Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from 'components/breadcrumbs/breadcrumbs';
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import { getProperty } from '../../store/properties/properties';
+import { BREADCRUMBS } from '../../store/breadcrumbs/breadcrumbs-actions';
+import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions';
+
+type BreadcrumbsDataProps = Pick<BreadcrumbsProps, 'items' | 'resources'>;
+type BreadcrumbsActionProps = Pick<BreadcrumbsProps, 'onClick' | 'onContextMenu'>;
+
+const mapStateToProps = () => ({ properties, resources }: RootState): BreadcrumbsDataProps => ({
+    items: (getProperty<Breadcrumb[]>(BREADCRUMBS)(properties) || []),
+    resources,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): BreadcrumbsActionProps => ({
+    onClick: (navFunc, { uuid }: Breadcrumb) => {
+        dispatch<any>(navFunc(uuid));
+    },
+    onContextMenu: (event, breadcrumb: Breadcrumb) => {
+        dispatch<any>(openSidePanelContextMenu(event, breadcrumb.uuid));
+    }
+});
+
+export const Breadcrumbs = connect(mapStateToProps(), mapDispatchToProps)(BreadcrumbsComponent);
diff --git a/services/workbench2/src/views-components/collection-panel-files/collection-panel-files.ts b/services/workbench2/src/views-components/collection-panel-files/collection-panel-files.ts
new file mode 100644 (file)
index 0000000..c5bd1d7
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import {
+    CollectionPanelFiles as Component,
+    CollectionPanelFilesProps
+} from "components/collection-panel-files/collection-panel-files";
+import { RootState } from "store/store";
+import { Dispatch } from "redux";
+import { collectionPanelFilesAction } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
+import { openContextMenu, openCollectionFilesContextMenu } from 'store/context-menu/context-menu-actions';
+import { openUploadCollectionFilesDialog } from 'store/collections/collection-upload-actions';
+import { ResourceKind } from "models/resource";
+import { openDetailsPanel } from 'store/details-panel/details-panel-action';
+
+const mapStateToProps = (state: RootState): Pick<CollectionPanelFilesProps, "currentItemUuid"> => ({
+    currentItemUuid: state.detailsPanel.resourceUuid
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onSearchChange' | 'onFileClick' | 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
+    onUploadDataClick: (targetLocation?: string) => {
+        dispatch<any>(openUploadCollectionFilesDialog(targetLocation));
+    },
+    onCollapseToggle: (id) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
+    },
+    onSelectionToggle: (event, item) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: item.id }));
+    },
+    onItemMenuOpen: (event, item, isWritable) => {
+        const isDirectory = item.data.type === 'directory';
+        dispatch<any>(openContextMenu(
+            event,
+            {
+                menuKind: isWritable
+                    ? isDirectory
+                        ? ContextMenuKind.COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.COLLECTION_FILE_ITEM
+                    : isDirectory
+                        ? ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+                kind: ResourceKind.COLLECTION,
+                name: item.data.name,
+                uuid: item.id,
+                ownerUuid: ''
+            }
+        ));
+    },
+    onSearchChange: (searchValue: string) => {
+        dispatch(collectionPanelFilesAction.ON_SEARCH_CHANGE(searchValue));
+    },
+    onOptionsMenuOpen: (event, isWritable) => {
+        dispatch<any>(openCollectionFilesContextMenu(event, isWritable));
+    },
+    onFileClick: (id) => {
+        dispatch<any>(openDetailsPanel(id));
+    },
+});
+
+export const CollectionPanelFiles = connect(mapStateToProps, mapDispatchToProps)(Component);
diff --git a/services/workbench2/src/views-components/collection-properties/create-collection-properties-form.tsx b/services/workbench2/src/views-components/collection-properties/create-collection-properties-form.tsx
new file mode 100644 (file)
index 0000000..fb18bb1
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm, change } from 'redux-form';
+import { withStyles } from '@material-ui/core';
+import {
+    COLLECTION_CREATE_PROPERTIES_FORM_NAME,
+    COLLECTION_CREATE_FORM_NAME
+} from 'store/collections/collection-create-actions';
+import {
+    ResourcePropertiesForm,
+    ResourcePropertiesFormData
+} from 'views-components/resource-properties-form/resource-properties-form';
+import { addPropertyToResourceForm } from 'store/resources/resources-actions';
+import { PROPERTY_VALUE_FIELD_NAME } from 'views-components/resource-properties-form/property-value-field';
+
+const Form = withStyles(
+    ({ spacing }) => (
+        { container:
+            {
+                margin: 0,
+            }
+        })
+    )(ResourcePropertiesForm);
+
+export const CreateCollectionPropertiesForm = reduxForm<ResourcePropertiesFormData>({
+    form: COLLECTION_CREATE_PROPERTIES_FORM_NAME,
+    onSubmit: (data, dispatch) => {
+        dispatch(addPropertyToResourceForm(data, COLLECTION_CREATE_FORM_NAME));
+        dispatch(change(COLLECTION_CREATE_PROPERTIES_FORM_NAME, PROPERTY_VALUE_FIELD_NAME, ''));
+    }
+})(Form);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/collection-properties/update-collection-properties-form.tsx b/services/workbench2/src/views-components/collection-properties/update-collection-properties-form.tsx
new file mode 100644 (file)
index 0000000..3ab425f
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm, change } from 'redux-form';
+import { withStyles } from '@material-ui/core';
+import {
+    COLLECTION_UPDATE_FORM_NAME,
+    COLLECTION_UPDATE_PROPERTIES_FORM_NAME
+} from 'store/collections/collection-update-actions';
+import {
+    ResourcePropertiesForm,
+    ResourcePropertiesFormData
+} from 'views-components/resource-properties-form/resource-properties-form';
+import { addPropertyToResourceForm } from 'store/resources/resources-actions';
+import { PROPERTY_VALUE_FIELD_NAME } from 'views-components/resource-properties-form/property-value-field';
+
+const Form = withStyles(
+    ({ spacing }) => (
+        { container:
+            {
+                margin: 0,
+            }
+        })
+    )(ResourcePropertiesForm);
+
+export const UpdateCollectionPropertiesForm = reduxForm<ResourcePropertiesFormData>({
+    form: COLLECTION_UPDATE_PROPERTIES_FORM_NAME,
+    onSubmit: (data, dispatch) => {
+        dispatch(addPropertyToResourceForm(data, COLLECTION_UPDATE_FORM_NAME));
+        dispatch(change(COLLECTION_UPDATE_PROPERTIES_FORM_NAME, PROPERTY_VALUE_FIELD_NAME, ''));
+    }
+})(Form);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/collections-dialog/restore-version-dialog.ts b/services/workbench2/src/views-components/collections-dialog/restore-version-dialog.ts
new file mode 100644 (file)
index 0000000..5443507
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { COLLECTION_RESTORE_VERSION_DIALOG, restoreVersion } from 'store/collections/collection-version-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(restoreVersion(props.data.uuid));
+    }
+});
+
+export const RestoreCollectionVersionDialog = compose(
+    withDialog(COLLECTION_RESTORE_VERSION_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/api-client-authorization-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/api-client-authorization-action-set.ts
new file mode 100644 (file)
index 0000000..4a01864
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import {
+    openApiClientAuthorizationAttributesDialog,
+    openApiClientAuthorizationRemoveDialog,
+} from "store/api-client-authorizations/api-client-authorizations-actions";
+import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+import { ContextMenuActionSet, ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
+
+export const apiClientAuthorizationActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                    dispatch<any>(openApiClientAuthorizationAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                    dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                    dispatch<any>(openApiClientAuthorizationRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/collection-action-set.test.ts b/services/workbench2/src/views-components/context-menu/action-sets/collection-action-set.test.ts
new file mode 100644 (file)
index 0000000..9182f3f
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionActionSet, readOnlyCollectionActionSet } from "./collection-action-set";
+
+describe('collection-action-set', () => {
+    const flattCollectionActionSet = collectionActionSet.reduce((prev, next) => prev.concat(next), []);
+    const flattReadOnlyCollectionActionSet = readOnlyCollectionActionSet.reduce((prev, next) => prev.concat(next), []);
+    describe('collectionActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattCollectionActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should contain readOnlyCollectionActionSet items', () => {
+            // then
+            expect(flattCollectionActionSet)
+                .toEqual(expect.arrayContaining(flattReadOnlyCollectionActionSet));
+        })
+    });
+
+    describe('readOnlyCollectionActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattReadOnlyCollectionActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should not contain collectionActionSet items', () => {
+            // then
+            expect(flattReadOnlyCollectionActionSet)
+                .not.toEqual(expect.arrayContaining(flattCollectionActionSet));
+        })
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/collection-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/collection-action-set.ts
new file mode 100644 (file)
index 0000000..7f8ad9e
--- /dev/null
@@ -0,0 +1,167 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuAction, ContextMenuActionSet, ContextMenuActionNames } from "../context-menu-action-set";
+import { ToggleFavoriteAction } from "../actions/favorite-action";
+import { toggleFavorite } from "store/favorites/favorites-actions";
+import {
+    RenameIcon,
+    ShareIcon,
+    MoveToIcon,
+    CopyIcon,
+    DetailsIcon,
+    AdvancedIcon,
+    OpenIcon,
+    Link,
+    RestoreVersionIcon,
+    FolderSharedIcon,
+} from "components/icon/icon";
+import { openCollectionUpdateDialog } from "store/collections/collection-update-actions";
+import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
+import { openMoveCollectionDialog } from "store/collections/collection-move-actions";
+import { openCollectionCopyDialog, openMultiCollectionCopyDialog } from "store/collections/collection-copy-actions";
+import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions";
+import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
+import { toggleCollectionTrashed } from "store/trash/trash-actions";
+import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
+import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
+import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { openRestoreCollectionVersionDialog } from "store/collections/collection-version-actions";
+import { TogglePublicFavoriteAction } from "../actions/public-favorite-action";
+import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
+import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+
+const toggleFavoriteAction: ContextMenuAction = {
+    component: ToggleFavoriteAction,
+    name: ContextMenuActionNames.ADD_TO_FAVORITES,
+    execute: (dispatch, resources) => {
+        for (const resource of [...resources]) {
+            dispatch<any>(toggleFavorite(resource)).then(() => {
+                dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+            });
+        }
+    },
+};
+const commonActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: OpenIcon,
+            name: ContextMenuActionNames.OPEN_IN_NEW_TAB,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openInNewTabAction(resources[0]));
+            },
+        },
+        {
+            icon: Link,
+            name: ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD,
+            execute: (dispatch, resources) => {
+                dispatch<any>(copyToClipboardAction(resources));
+            },
+        },
+        {
+            icon: CopyIcon,
+            name: ContextMenuActionNames.MAKE_A_COPY,
+            execute: (dispatch, resources) => {
+                if (resources[0].fromContextMenu || resources.length === 1) dispatch<any>(openCollectionCopyDialog(resources[0]));
+                else dispatch<any>(openMultiCollectionCopyDialog(resources[0]));
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: ContextMenuActionNames.VIEW_DETAILS,
+            execute: dispatch => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: ContextMenuActionNames.API_DETAILS,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
+
+export const readOnlyCollectionActionSet: ContextMenuActionSet = [
+    [
+        ...commonActionSet.reduce((prev, next) => prev.concat(next), []),
+        toggleFavoriteAction,
+        {
+            icon: FolderSharedIcon,
+            name: ContextMenuActionNames.OPEN_WITH_3RD_PARTY_CLIENT,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openWebDavS3InfoDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
+
+export const collectionActionSet: ContextMenuActionSet = [
+    [
+        ...readOnlyCollectionActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            icon: RenameIcon,
+            name: ContextMenuActionNames.EDIT_COLLECTION,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionUpdateDialog(resources[0]));
+            },
+        },
+        {
+            icon: ShareIcon,
+            name: ContextMenuActionNames.SHARE,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSharingDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: MoveToIcon,
+            name: ContextMenuActionNames.MOVE_TO,
+            execute: (dispatch, resources) => dispatch<any>(openMoveCollectionDialog(resources[0])),
+        },
+        {
+            component: ToggleTrashAction,
+            name: ContextMenuActionNames.MOVE_TO_TRASH,
+            execute: (dispatch, resources: ContextMenuResource[]) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
+                }
+            },
+        },
+    ],
+];
+
+export const collectionAdminActionSet: ContextMenuActionSet = [
+    [
+        ...collectionActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            component: TogglePublicFavoriteAction,
+            name: ContextMenuActionNames.ADD_TO_PUBLIC_FAVORITES,
+            execute: (dispatch, resources) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(togglePublicFavorite(resource)).then(() => {
+                        dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
+                    });
+                }
+            },
+        },
+    ],
+];
+
+export const oldCollectionVersionActionSet: ContextMenuActionSet = [
+    [
+        ...commonActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            icon: RestoreVersionIcon,
+            name: ContextMenuActionNames.RESTORE_VERSION,
+            execute: (dispatch, resources) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(openRestoreCollectionVersionDialog(resource.uuid));
+                }
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/collection-files-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/collection-files-action-set.ts
new file mode 100644 (file)
index 0000000..a117cbc
--- /dev/null
@@ -0,0 +1,115 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuAction, ContextMenuActionSet, ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { collectionPanelFilesAction, openMultipleFilesRemoveDialog } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import {
+    openCollectionPartialCopyMultipleToNewCollectionDialog,
+    openCollectionPartialCopyMultipleToExistingCollectionDialog,
+    openCollectionPartialCopyToSeparateCollectionsDialog
+} from 'store/collections/collection-partial-copy-actions';
+import { openCollectionPartialMoveMultipleToExistingCollectionDialog, openCollectionPartialMoveMultipleToNewCollectionDialog, openCollectionPartialMoveToSeparateCollectionsDialog } from "store/collections/collection-partial-move-actions";
+import { FileCopyIcon, FileMoveIcon, RemoveIcon, SelectAllIcon, SelectNoneIcon } from "components/icon/icon";
+
+const copyActions: ContextMenuAction[] = [
+    {
+        name: ContextMenuActionNames.COPY_SELECTED_INTO_NEW_COLLECTION,
+        icon: FileCopyIcon,
+        execute: dispatch => {
+            dispatch<any>(openCollectionPartialCopyMultipleToNewCollectionDialog());
+        }
+    },
+    {
+        name: ContextMenuActionNames.COPY_SELECTED_INTO_EXISTING_COLLECTION,
+        icon: FileCopyIcon,
+        execute: dispatch => {
+            dispatch<any>(openCollectionPartialCopyMultipleToExistingCollectionDialog());
+        }
+    },
+];
+
+const copyActionsMultiple: ContextMenuAction[] = [
+    ...copyActions,
+    {
+        name: ContextMenuActionNames.COPY_SELECTED_INTO_SEPARATE_COLLECTIONS,
+        icon: FileCopyIcon,
+        execute: dispatch => {
+            dispatch<any>(openCollectionPartialCopyToSeparateCollectionsDialog());
+        }
+    }
+];
+
+const moveActions: ContextMenuAction[] = [
+    {
+        name: ContextMenuActionNames.MOVE_SELECTED_INTO_NEW_COLLECTION,
+        icon: FileMoveIcon,
+        execute: dispatch => {
+            dispatch<any>(openCollectionPartialMoveMultipleToNewCollectionDialog());
+        }
+    },
+    {
+        name: ContextMenuActionNames.MOVE_SELECTED_INTO_EXISTING_COLLECTION,
+        icon: FileMoveIcon,
+        execute: dispatch => {
+            dispatch<any>(openCollectionPartialMoveMultipleToExistingCollectionDialog());
+        }
+    },
+];
+
+const moveActionsMultiple: ContextMenuAction[] = [
+    ...moveActions,
+    {
+        name: ContextMenuActionNames.MOVE_SELECTED_INTO_SEPARATE_COLLECTIONS,
+        icon: FileMoveIcon,
+        execute: dispatch => {
+            dispatch<any>(openCollectionPartialMoveToSeparateCollectionsDialog());
+        }
+    }
+];
+
+const selectActions: ContextMenuAction[] = [
+    {
+        name: ContextMenuActionNames.SELECT_ALL,
+        icon: SelectAllIcon,
+        execute: dispatch => {
+            dispatch(collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
+        }
+    },
+    {
+        name: ContextMenuActionNames.UNSELECT_ALL,
+        icon: SelectNoneIcon,
+        execute: dispatch => {
+            dispatch(collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES());
+        }
+    },
+];
+
+const removeAction: ContextMenuAction = {
+    name: ContextMenuActionNames.REMOVE_SELECTED,
+    icon: RemoveIcon,
+    execute: dispatch => {
+        dispatch(openMultipleFilesRemoveDialog());
+    }
+};
+
+// These action sets are used on the multi-select actions button.
+export const readOnlyCollectionFilesActionSet: ContextMenuActionSet = [
+    selectActions,
+    copyActions,
+];
+
+export const readOnlyCollectionFilesMultipleActionSet: ContextMenuActionSet = [
+    selectActions,
+    copyActionsMultiple,
+];
+
+export const collectionFilesActionSet: ContextMenuActionSet = readOnlyCollectionFilesActionSet.concat([[
+    removeAction,
+    ...moveActions
+]]);
+
+export const collectionFilesMultipleActionSet: ContextMenuActionSet = readOnlyCollectionFilesMultipleActionSet.concat([[
+    removeAction,
+    ...moveActionsMultiple
+]]);
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
new file mode 100644 (file)
index 0000000..68edb13
--- /dev/null
@@ -0,0 +1,107 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from "../context-menu-action-set";
+import { FileCopyIcon, FileMoveIcon, RemoveIcon, RenameIcon } from "components/icon/icon";
+import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
+import { openFileRemoveDialog, openRenameFileDialog } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { CollectionFileViewerAction } from "views-components/context-menu/actions/collection-file-viewer-action";
+import { CollectionCopyToClipboardAction } from "../actions/collection-copy-to-clipboard-action";
+import {
+    openCollectionPartialMoveToExistingCollectionDialog,
+    openCollectionPartialMoveToNewCollectionDialog,
+} from "store/collections/collection-partial-move-actions";
+import {
+    openCollectionPartialCopyToExistingCollectionDialog,
+    openCollectionPartialCopyToNewCollectionDialog,
+} from "store/collections/collection-partial-copy-actions";
+
+export const readOnlyCollectionDirectoryItemActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.COPY_ITEM_INTO_NEW_COLLECTION,
+            icon: FileCopyIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialCopyToNewCollectionDialog(resources[0]));
+            },
+        },
+        {
+            name: ContextMenuActionNames.COPY_ITEM_INTO_EXISTING_COLLECTION,
+            icon: FileCopyIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialCopyToExistingCollectionDialog(resources[0]));
+            },
+        },
+        {
+            component: CollectionFileViewerAction,
+            name: ContextMenuActionNames.OPEN_IN_NEW_TAB,
+            execute: () => {
+                return;
+            },
+        },
+        {
+            component: CollectionCopyToClipboardAction,
+            name: ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD,
+            execute: () => {
+                return;
+            },
+        },
+    ],
+];
+
+export const readOnlyCollectionFileItemActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: DownloadCollectionFileAction,
+            name: ContextMenuActionNames.DOWNLOAD,
+            execute: () => {
+                return;
+            },
+        },
+        ...readOnlyCollectionDirectoryItemActionSet.reduce((prev, next) => prev.concat(next), []),
+    ],
+];
+
+const writableActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.MOVE_ITEM_INTO_NEW_COLLECTION,
+            icon: FileMoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialMoveToNewCollectionDialog(resources[0]));
+            },
+        },
+        {
+            name: ContextMenuActionNames.MOVE_ITEM_INTO_EXISTING_COLLECTION,
+            icon: FileMoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialMoveToExistingCollectionDialog(resources[0]));
+            },
+        },
+        {
+            name: ContextMenuActionNames.RENAME,
+            icon: RenameIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(
+                    openRenameFileDialog({
+                        name: resources[0].name,
+                        id: resources[0].uuid,
+                        path: resources[0].uuid.split("/").slice(1).join("/"),
+                    })
+                );
+            },
+        },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openFileRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
+
+export const collectionDirectoryItemActionSet: ContextMenuActionSet = readOnlyCollectionDirectoryItemActionSet.concat(writableActionSet);
+
+export const collectionFileItemActionSet: ContextMenuActionSet = readOnlyCollectionFileItemActionSet.concat(writableActionSet);
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/collection-files-not-selected-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/collection-files-not-selected-action-set.ts
new file mode 100644 (file)
index 0000000..b457efd
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { collectionPanelFilesAction } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { SelectAllIcon } from "components/icon/icon";
+
+export const collectionFilesNotSelectedActionSet: ContextMenuActionSet = [[{
+    name: ContextMenuActionNames.SELECT_ALL,
+    icon: SelectAllIcon,
+    execute: dispatch => {
+        dispatch(collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
+    }
+}]];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/favorite-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/favorite-action-set.ts
new file mode 100644 (file)
index 0000000..115eec9
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from '../context-menu-action-set';
+import { ToggleFavoriteAction } from '../actions/favorite-action';
+import { toggleFavorite } from 'store/favorites/favorites-actions';
+import { favoritePanelActions } from 'store/favorite-panel/favorite-panel-action';
+
+export const favoriteActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleFavoriteAction,
+            name: ContextMenuActionNames.ADD_TO_FAVORITES,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) =>
+                    dispatch<any>(toggleFavorite(resource)).then(() => {
+                        dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+                    })
+                );
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/group-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/group-action-set.ts
new file mode 100644 (file)
index 0000000..2c7f164
--- /dev/null
@@ -0,0 +1,41 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { RenameIcon, AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openGroupAttributes, openRemoveGroupDialog, openGroupUpdateDialog } from 'store/groups-panel/groups-panel-actions';
+
+export const groupActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.RENAME,
+            icon: RenameIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openGroupUpdateDialog(resources[0]))
+            },
+        },
+        {
+            name: ContextMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openGroupAttributes(resources[0].uuid))
+            },
+        },
+        {
+            name: ContextMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveGroupDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/group-member-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/group-member-action-set.ts
new file mode 100644 (file)
index 0000000..6b9611c
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openGroupMemberAttributes, openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions';
+
+export const groupMemberActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openGroupMemberAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openRemoveGroupMemberDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/keep-service-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/keep-service-action-set.ts
new file mode 100644 (file)
index 0000000..67ef034
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { openKeepServiceAttributesDialog, openKeepServiceRemoveDialog } from 'store/keep-services/keep-services-actions';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { ContextMenuActionSet, ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+
+export const keepServiceActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openKeepServiceAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openKeepServiceRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/link-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/link-action-set.ts
new file mode 100644 (file)
index 0000000..89356c0
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { openLinkAttributesDialog, openLinkRemoveDialog } from 'store/link-panel/link-panel-actions';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { ContextMenuActionSet, ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+
+export const linkActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openLinkAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openLinkRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/permission-edit-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/permission-edit-action-set.ts
new file mode 100644 (file)
index 0000000..3ae4513
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { CanReadIcon, CanManageIcon, CanWriteIcon } from 'components/icon/icon';
+import { editPermissionLevel } from 'store/group-details-panel/group-details-panel-actions';
+import { PermissionLevel } from 'models/permission';
+
+export const permissionEditActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.READ,
+            icon: CanReadIcon,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(editPermissionLevel(resource.uuid, PermissionLevel.CAN_READ)));
+            },
+        },
+        {
+            name: ContextMenuActionNames.WRITE,
+            icon: CanWriteIcon,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(editPermissionLevel(resource.uuid, PermissionLevel.CAN_WRITE)));
+            },
+        },
+        {
+            name: ContextMenuActionNames.MANAGE,
+            icon: CanManageIcon,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(editPermissionLevel(resource.uuid, PermissionLevel.CAN_MANAGE)));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/process-resource-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/process-resource-action-set.ts
new file mode 100644 (file)
index 0000000..0203e3f
--- /dev/null
@@ -0,0 +1,149 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from "../context-menu-action-set";
+import { ToggleFavoriteAction } from "../actions/favorite-action";
+import { toggleFavorite } from "store/favorites/favorites-actions";
+import {
+    RenameIcon,
+    DetailsIcon,
+    RemoveIcon,
+    ReRunProcessIcon,
+    OutputIcon,
+    AdvancedIcon,
+    OpenIcon,
+    StopIcon,
+} from "components/icon/icon";
+import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
+import { openProcessUpdateDialog } from "store/processes/process-update-actions";
+import { openCopyProcessDialog } from "store/processes/process-copy-actions";
+import { openRemoveProcessDialog } from "store/processes/processes-actions";
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
+import { navigateToOutput } from "store/process-panel/process-panel-actions";
+import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+import { TogglePublicFavoriteAction } from "../actions/public-favorite-action";
+import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
+import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { cancelRunningWorkflow } from "store/processes/processes-actions";
+
+export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleFavoriteAction,
+            name: ContextMenuActionNames.ADD_TO_FAVORITES,
+            execute: (dispatch, resources) => {
+                dispatch<any>(toggleFavorite(resources[0])).then(() => {
+                    dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+                });
+            },
+        },
+        {
+            icon: OpenIcon,
+            name: ContextMenuActionNames.OPEN_IN_NEW_TAB,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openInNewTabAction(resources[0]));
+            },
+        },
+        {
+            icon: ReRunProcessIcon,
+            name: ContextMenuActionNames.COPY_AND_RERUN_PROCESS,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCopyProcessDialog(resources[0]));
+            },
+        },
+        {
+            icon: OutputIcon,
+            name: ContextMenuActionNames.OUTPUTS,
+            execute: (dispatch, resources) => {
+                if (resources[0]) {
+                    dispatch<any>(navigateToOutput(resources[0]));
+                }
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: ContextMenuActionNames.VIEW_DETAILS,
+            execute: dispatch => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: ContextMenuActionNames.API_DETAILS,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
+
+export const processResourceActionSet: ContextMenuActionSet = [
+    [
+        ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            icon: RenameIcon,
+            name: ContextMenuActionNames.EDIT_PROCESS,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openProcessUpdateDialog(resources[0]));
+            },
+        },
+        // removed until auto-move children is implemented
+        // {
+        //     icon: MoveToIcon,
+        //     name: ContextMenuActionNames.MOVE_TO,
+        //     execute: (dispatch, resources) => {
+        //         dispatch<any>(openMoveProcessDialog(resources[0]));
+        //     },
+        // },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveProcessDialog(resources[0], resources.length));
+            },
+        },
+    ],
+];
+
+const runningProcessOnlyActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.CANCEL,
+            icon: StopIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(cancelRunningWorkflow(resources[0].uuid));
+            },
+        },
+    ]
+];
+
+export const processResourceAdminActionSet: ContextMenuActionSet = [
+    [
+        ...processResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            component: TogglePublicFavoriteAction,
+            name: ContextMenuActionNames.ADD_TO_PUBLIC_FAVORITES,
+            execute: (dispatch, resources) => {
+                dispatch<any>(togglePublicFavorite(resources[0])).then(() => {
+                    dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
+                });
+            },
+        },
+    ],
+];
+
+export const runningProcessResourceActionSet = [
+    [
+        ...processResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+        ...runningProcessOnlyActionSet.reduce((prev, next) => prev.concat(next), []),
+    ],
+];
+
+export const runningProcessResourceAdminActionSet: ContextMenuActionSet = [
+    [
+        ...processResourceAdminActionSet.reduce((prev, next) => prev.concat(next), []),
+        ...runningProcessOnlyActionSet.reduce((prev, next) => prev.concat(next), []),
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/project-action-set.test.ts b/services/workbench2/src/views-components/context-menu/action-sets/project-action-set.test.ts
new file mode 100644 (file)
index 0000000..1932194
--- /dev/null
@@ -0,0 +1,50 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { filterGroupActionSet, projectActionSet, readOnlyProjectActionSet } from "./project-action-set";
+
+describe('project-action-set', () => {
+    const flattProjectActionSet = projectActionSet.reduce((prev, next) => prev.concat(next), []);
+    const flattReadOnlyProjectActionSet = readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []);
+    const flattFilterGroupActionSet = filterGroupActionSet.reduce((prev, next) => prev.concat(next), []);
+
+    describe('projectActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattProjectActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should contain readOnlyProjectActionSet items', () => {
+            // then
+            expect(flattProjectActionSet)
+                .toEqual(expect.arrayContaining(flattReadOnlyProjectActionSet));
+        })
+    });
+
+    describe('readOnlyProjectActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattReadOnlyProjectActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should not contain projectActionSet items', () => {
+            // then
+            expect(flattReadOnlyProjectActionSet)
+                .not.toEqual(expect.arrayContaining(flattProjectActionSet));
+        })
+    });
+
+    describe('filterGroupActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattFilterGroupActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should not contain projectActionSet items', () => {
+            // then
+            expect(flattFilterGroupActionSet)
+                .not.toEqual(expect.arrayContaining(flattProjectActionSet));
+        })
+    });
+});
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/project-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/project-action-set.ts
new file mode 100644 (file)
index 0000000..8ef968e
--- /dev/null
@@ -0,0 +1,173 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from "../context-menu-action-set";
+import { NewProjectIcon, RenameIcon, MoveToIcon, DetailsIcon, AdvancedIcon, OpenIcon, Link, FolderSharedIcon } from "components/icon/icon";
+import { ToggleFavoriteAction } from "../actions/favorite-action";
+import { toggleFavorite } from "store/favorites/favorites-actions";
+import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
+import { openMoveProjectDialog } from "store/projects/project-move-actions";
+import { openProjectCreateDialog } from "store/projects/project-create-actions";
+import { openProjectUpdateDialog } from "store/projects/project-update-actions";
+import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
+import { toggleProjectTrashed } from "store/trash/trash-actions";
+import { ShareIcon } from "components/icon/icon";
+import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
+import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
+import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions";
+import { ToggleLockAction } from "../actions/lock-action";
+import { freezeProject, unfreezeProject } from "store/projects/project-lock-actions";
+
+export const toggleFavoriteAction = {
+    component: ToggleFavoriteAction,
+    name: ContextMenuActionNames.ADD_TO_FAVORITES,
+    execute: (dispatch, resources) => {
+        dispatch(toggleFavorite(resources[0])).then(() => {
+            dispatch(favoritePanelActions.REQUEST_ITEMS());
+        });
+    },
+};
+
+export const openInNewTabMenuAction = {
+    icon: OpenIcon,
+    name: ContextMenuActionNames.OPEN_IN_NEW_TAB,
+    execute: (dispatch, resources) => {
+        dispatch(openInNewTabAction(resources[0]));
+    },
+};
+
+export const copyToClipboardMenuAction = {
+    icon: Link,
+    name: ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD,
+    execute: (dispatch, resources) => {
+        dispatch(copyToClipboardAction(resources));
+    },
+};
+
+export const viewDetailsAction = {
+    icon: DetailsIcon,
+    name: ContextMenuActionNames.VIEW_DETAILS,
+    execute: dispatch => {
+        dispatch(toggleDetailsPanel());
+    },
+};
+
+export const advancedAction = {
+    icon: AdvancedIcon,
+    name: ContextMenuActionNames.API_DETAILS,
+    execute: (dispatch, resources) => {
+        dispatch(openAdvancedTabDialog(resources[0].uuid));
+    },
+};
+
+export const openWith3rdPartyClientAction = {
+    icon: FolderSharedIcon,
+    name: ContextMenuActionNames.OPEN_WITH_3RD_PARTY_CLIENT,
+    execute: (dispatch, resources) => {
+        dispatch(openWebDavS3InfoDialog(resources[0].uuid));
+    },
+};
+
+export const editProjectAction = {
+    icon: RenameIcon,
+    name: ContextMenuActionNames.EDIT_PROJECT,
+    execute: (dispatch, resources) => {
+        dispatch(openProjectUpdateDialog(resources[0]));
+    },
+};
+
+export const shareAction = {
+    icon: ShareIcon,
+    name: ContextMenuActionNames.SHARE,
+    execute: (dispatch, resources) => {
+        dispatch(openSharingDialog(resources[0].uuid));
+    },
+};
+
+export const moveToAction = {
+    icon: MoveToIcon,
+    name: ContextMenuActionNames.MOVE_TO,
+    execute: (dispatch, resource) => {
+        dispatch(openMoveProjectDialog(resource[0]));
+    },
+};
+
+export const toggleTrashAction = {
+    component: ToggleTrashAction,
+    name: ContextMenuActionNames.MOVE_TO_TRASH,
+    execute: (dispatch, resources) => {
+        dispatch(toggleProjectTrashed(resources[0].uuid, resources[0].ownerUuid, resources[0].isTrashed!!, resources.length > 1));
+    },
+};
+
+export const freezeProjectAction = {
+    component: ToggleLockAction,
+    name: ContextMenuActionNames.FREEZE_PROJECT,
+    execute: (dispatch, resources) => {
+        if (resources[0].isFrozen) {
+            dispatch(unfreezeProject(resources[0].uuid));
+        } else {
+            dispatch(freezeProject(resources[0].uuid));
+        }
+    },
+};
+
+export const newProjectAction: any = {
+    icon: NewProjectIcon,
+    name: ContextMenuActionNames.NEW_PROJECT,
+    execute: (dispatch, resources): void => {
+        dispatch(openProjectCreateDialog(resources[0].uuid));
+    },
+};
+
+export const readOnlyProjectActionSet: ContextMenuActionSet = [
+    [toggleFavoriteAction, openInNewTabMenuAction, copyToClipboardMenuAction, viewDetailsAction, advancedAction, openWith3rdPartyClientAction],
+];
+
+export const filterGroupActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+    ],
+];
+
+export const frozenActionSet: ContextMenuActionSet = [
+    [
+        shareAction,
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        freezeProjectAction,
+    ],
+];
+
+export const projectActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+        newProjectAction,
+        freezeProjectAction,
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/project-admin-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/project-admin-action-set.ts
new file mode 100644 (file)
index 0000000..937b43e
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from "../context-menu-action-set";
+import { TogglePublicFavoriteAction } from "views-components/context-menu/actions/public-favorite-action";
+import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
+import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+
+import {
+    shareAction,
+    toggleFavoriteAction,
+    openInNewTabMenuAction,
+    copyToClipboardMenuAction,
+    viewDetailsAction,
+    advancedAction,
+    openWith3rdPartyClientAction,
+    freezeProjectAction,
+    editProjectAction,
+    moveToAction,
+    toggleTrashAction,
+    newProjectAction,
+} from "views-components/context-menu/action-sets/project-action-set";
+
+export const togglePublicFavoriteAction = {
+    component: TogglePublicFavoriteAction,
+    name: ContextMenuActionNames.ADD_TO_PUBLIC_FAVORITES,
+    execute: (dispatch, resources) => {
+        dispatch(togglePublicFavorite(resources[0])).then(() => {
+            dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
+        });
+    },
+};
+
+export const projectAdminActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+        newProjectAction,
+        freezeProjectAction,
+        togglePublicFavoriteAction,
+    ],
+];
+
+export const filterGroupAdminActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+        togglePublicFavoriteAction,
+    ],
+];
+
+export const frozenAdminActionSet: ContextMenuActionSet = [
+    [
+        shareAction,
+        togglePublicFavoriteAction,
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        freezeProjectAction,
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/repository-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/repository-action-set.ts
new file mode 100644 (file)
index 0000000..20edbe4
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, ShareIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openRepositoryAttributes, openRemoveRepositoryDialog } from 'store/repositories/repositories-actions';
+import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
+
+export const repositoryActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openRepositoryAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.SHARE,
+            icon: ShareIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openSharingDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openRemoveRepositoryDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/resource-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/resource-action-set.ts
new file mode 100644 (file)
index 0000000..6909df8
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from '../context-menu-action-set';
+import { ToggleFavoriteAction } from '../actions/favorite-action';
+import { toggleFavorite } from 'store/favorites/favorites-actions';
+
+export const resourceActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleFavoriteAction,
+            name: ContextMenuActionNames.ADD_TO_FAVORITES,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(toggleFavorite(resource)));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/root-project-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/root-project-action-set.ts
new file mode 100644 (file)
index 0000000..8fcdbf0
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from '../context-menu-action-set';
+import { openCollectionCreateDialog } from 'store/collections/collection-create-actions';
+import { NewProjectIcon, CollectionIcon } from 'components/icon/icon';
+import { openProjectCreateDialog } from 'store/projects/project-create-actions';
+
+export const rootProjectActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: NewProjectIcon,
+            name: ContextMenuActionNames.NEW_PROJECT,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openProjectCreateDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: CollectionIcon,
+            name: ContextMenuActionNames.NEW_COLLECTION,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openCollectionCreateDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/search-results-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/search-results-action-set.ts
new file mode 100644 (file)
index 0000000..debaf2d
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from '../context-menu-action-set';
+import { DetailsIcon, AdvancedIcon, OpenIcon, Link } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import { copyToClipboardAction, openInNewTabAction } from 'store/open-in-new-tab/open-in-new-tab.actions';
+
+export const searchResultsActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: OpenIcon,
+            name: ContextMenuActionNames.OPEN_IN_NEW_TAB,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(openInNewTabAction(resource)));
+            },
+        },
+        {
+            icon: Link,
+            name: ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD,
+            execute: (dispatch, resources) => {
+                dispatch<any>(copyToClipboardAction(resources));
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: ContextMenuActionNames.VIEW_DETAILS,
+            execute: (dispatch) => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: ContextMenuActionNames.API_DETAILS,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/ssh-key-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/ssh-key-action-set.ts
new file mode 100644 (file)
index 0000000..2a64f17
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+import { openSshKeyRemoveDialog, openSshKeyAttributesDialog } from 'store/auth/auth-action-ssh';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+
+export const sshKeyActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSshKeyAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSshKeyRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/trash-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/trash-action-set.ts
new file mode 100644 (file)
index 0000000..8a27904
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from '../context-menu-action-set';
+import { ToggleTrashAction } from 'views-components/context-menu/actions/trash-action';
+import { toggleTrashed } from 'store/trash/trash-actions';
+
+export const trashActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleTrashAction,
+            name: ContextMenuActionNames.MOVE_TO_TRASH,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(toggleTrashed(resource.kind, resource.uuid, resource.ownerUuid, resource.isTrashed!!)));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/trashed-collection-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/trashed-collection-action-set.ts
new file mode 100644 (file)
index 0000000..ea66deb
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from '../context-menu-action-set';
+import { DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RestoreFromTrashIcon } from 'components/icon/icon';
+import { toggleCollectionTrashed } from 'store/trash/trash-actions';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+
+export const trashedCollectionActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: DetailsIcon,
+            name: ContextMenuActionNames.VIEW_DETAILS,
+            execute: (dispatch) => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: ProvenanceGraphIcon,
+            name: ContextMenuActionNames.PROVENANCE_GRAPH,
+            execute: (dispatch, resource) => {
+                // add code
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: ContextMenuActionNames.API_DETAILS,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: RestoreFromTrashIcon,
+            name: ContextMenuActionNames.RESTORE,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(toggleCollectionTrashed(resource.uuid, true)));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/user-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/user-action-set.ts
new file mode 100644 (file)
index 0000000..953ed6e
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import {
+    AdvancedIcon,
+    ProjectIcon,
+    AttributesIcon,
+    DeactivateUserIcon,
+    UserPanelIcon,
+    LoginAsIcon,
+    AdminMenuIcon,
+    ActiveIcon,
+} from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { loginAs, openUserAttributes, openUserProjects } from 'store/users/users-actions';
+import { openSetupDialog, openDeactivateDialog, openActivateDialog } from 'store/user-profile/user-profile-actions';
+import { navigateToUserProfile } from 'store/navigation/navigation-action';
+import {
+    canActivateUser,
+    canDeactivateUser,
+    canSetupUser,
+    isAdmin,
+    needsUserProfileLink,
+    isOtherUser,
+} from 'store/context-menu/context-menu-filters';
+
+export const userActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openUserAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.HOME_PROJECT,
+            icon: ProjectIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openUserProjects(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.ACCOUNT_SETTINGS,
+            icon: UserPanelIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(navigateToUserProfile(resources[0].uuid));
+            },
+            filters: [needsUserProfileLink],
+        },
+    ],
+    [
+        {
+            name: ContextMenuActionNames.ACTIVATE_USER,
+            icon: ActiveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openActivateDialog(resources[0].uuid));
+            },
+            filters: [isAdmin, canActivateUser],
+        },
+        {
+            name: ContextMenuActionNames.SETUP_USER,
+            icon: AdminMenuIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSetupDialog(resources[0].uuid));
+            },
+            filters: [isAdmin, canSetupUser],
+        },
+        {
+            name: ContextMenuActionNames.DEACTIVATE_USER,
+            icon: DeactivateUserIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openDeactivateDialog(resources[0].uuid));
+            },
+            filters: [isAdmin, canDeactivateUser],
+        },
+        {
+            name: ContextMenuActionNames.LOGIN_AS_USER,
+            icon: LoginAsIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(loginAs(resources[0].uuid));
+            },
+            filters: [isAdmin, isOtherUser],
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts
new file mode 100644 (file)
index 0000000..11d94cc
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openVirtualMachineAttributes, openRemoveVirtualMachineDialog } from 'store/virtual-machines/virtual-machines-actions';
+
+export const virtualMachineActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: ContextMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openVirtualMachineAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: ContextMenuActionNames.REMOVE,
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveVirtualMachineDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/workflow-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/workflow-action-set.ts
new file mode 100644 (file)
index 0000000..f03340d
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet, ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { openRunProcess, deleteWorkflow } from "store/workflow-panel/workflow-panel-actions";
+import { DetailsIcon, AdvancedIcon, OpenIcon, Link, StartIcon, TrashIcon } from "components/icon/icon";
+import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
+import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+
+export const readOnlyWorkflowActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: OpenIcon,
+            name: ContextMenuActionNames.OPEN_IN_NEW_TAB,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openInNewTabAction(resources[0]));
+            },
+        },
+        {
+            icon: Link,
+            name: ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD,
+            execute: (dispatch, resources) => {
+                dispatch<any>(copyToClipboardAction(resources));
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: ContextMenuActionNames.VIEW_DETAILS,
+            execute: dispatch => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: ContextMenuActionNames.API_DETAILS,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: StartIcon,
+            name: ContextMenuActionNames.RUN_WORKFLOW,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRunProcess(resources[0].uuid, resources[0].ownerUuid, resources[0].name));
+            },
+        },
+    ],
+];
+
+export const workflowActionSet: ContextMenuActionSet = [
+    [
+        ...readOnlyWorkflowActionSet[0],
+        {
+            icon: TrashIcon,
+            name: ContextMenuActionNames.DELETE_WORKFLOW,
+            execute: (dispatch, resources) => {
+                dispatch<any>(deleteWorkflow(resources[0].uuid, resources[0].ownerUuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/context-menu/actions/collection-copy-to-clipboard-action.tsx b/services/workbench2/src/views-components/context-menu/actions/collection-copy-to-clipboard-action.tsx
new file mode 100644 (file)
index 0000000..dac3858
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { RootState } from "../../../store/store";
+import { getNodeValue } from "models/tree";
+import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
+import { CopyToClipboardAction } from "./copy-to-clipboard-action";
+
+const mapStateToProps = (state: RootState) => {
+    const { resource } = state.contextMenu;
+    const currentCollectionUuid = state.collectionPanel.item ? state.collectionPanel.item.uuid : '';
+    const { keepWebServiceUrl } = state.auth.config;
+    if (resource && [
+        ContextMenuKind.COLLECTION_FILE_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+        ContextMenuKind.COLLECTION_DIRECTORY_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM ].indexOf(resource.menuKind as ContextMenuKind) > -1) {
+        const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
+        if (file) {
+            return {
+                href: file.url.replace(keepWebServiceUrl, ''),
+                kind: 'file',
+                currentCollectionUuid
+            };
+        }
+    }
+    return {};
+};
+
+export const CollectionCopyToClipboardAction = connect(mapStateToProps)(CopyToClipboardAction);
diff --git a/services/workbench2/src/views-components/context-menu/actions/collection-file-viewer-action.test.tsx b/services/workbench2/src/views-components/context-menu/actions/collection-file-viewer-action.test.tsx
new file mode 100644 (file)
index 0000000..9d8acad
--- /dev/null
@@ -0,0 +1,117 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { mount, configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import configureMockStore from 'redux-mock-store'
+import { Provider } from 'react-redux';
+import { CollectionFileViewerAction } from './collection-file-viewer-action';
+import { ContextMenuKind } from '../menu-item-sort';
+import { createTree, initTreeNode, setNode, getNodeValue } from "models/tree";
+import { getInlineFileUrl, sanitizeToken } from "./helpers";
+
+const middlewares = [];
+const mockStore = configureMockStore(middlewares);
+
+configure({ adapter: new Adapter() });
+
+describe('CollectionFileViewerAction', () => {
+    let defaultStore;
+    const fileUrl = "https://download.host:12345/c=abcde-4zz18-abcdefghijklmno/t=v2/token2/token3/cat.jpg";
+    const insecureKeepInlineUrl = "https://download.host:12345/";
+    const secureKeepInlineUrl = "https://*.collections.host:12345/";
+
+    beforeEach(() => {
+        let filesTree = createTree();
+        let data = {id: "000", value: {"url": fileUrl}};
+        filesTree = setNode(initTreeNode(data))(filesTree);
+
+        defaultStore = {
+            auth: {
+                config: {
+                    keepWebServiceUrl: "https://download.host:12345/",
+                    keepWebInlineServiceUrl: insecureKeepInlineUrl,
+                    clusterConfig: {
+                        Collections: {
+                          TrustAllContent: false
+                        }
+                    }
+                }
+            },
+            contextMenu: {
+                resource: {
+                    uuid: "000",
+                    menuKind: ContextMenuKind.COLLECTION_FILE_ITEM,
+                }
+            },
+            collectionPanel: {
+                item: {
+                    uuid: ""
+                }
+            },
+            collectionPanelFiles: filesTree
+        };
+    });
+
+    it('should hide open in new tab when unsafe', () => {
+        // given
+        const store = mockStore(defaultStore);
+
+        // when
+        const wrapper = mount(<Provider store={store}>
+            <CollectionFileViewerAction />
+        </Provider>);
+
+        // then
+        expect(wrapper).not.toBeUndefined();
+
+        // and
+        expect(wrapper.find("a")).toHaveLength(0);
+    });
+
+    it('should show open in new tab when TrustAllContent=true', () => {
+        // given
+        let initialState = defaultStore;
+        initialState.auth.config.clusterConfig.Collections.TrustAllContent = true;
+        const store = mockStore(initialState);
+
+        // when
+        const wrapper = mount(<Provider store={store}>
+            <CollectionFileViewerAction />
+        </Provider>);
+
+        // then
+        expect(wrapper).not.toBeUndefined();
+
+        // and
+        expect(wrapper.find("a").prop("href"))
+            .toEqual(sanitizeToken(getInlineFileUrl(fileUrl,
+                initialState.auth.config.keepWebServiceUrl,
+                initialState.auth.config.keepWebInlineServiceUrl))
+            );
+    });
+
+    it('should show open in new tab when inline url is secure', () => {
+        // given
+        let initialState = defaultStore;
+        initialState.auth.config.keepWebInlineServiceUrl = secureKeepInlineUrl;
+        const store = mockStore(initialState);
+
+        // when
+        const wrapper = mount(<Provider store={store}>
+            <CollectionFileViewerAction />
+        </Provider>);
+
+        // then
+        expect(wrapper).not.toBeUndefined();
+
+        // and
+        expect(wrapper.find("a").prop("href"))
+            .toEqual(sanitizeToken(getInlineFileUrl(fileUrl,
+                initialState.auth.config.keepWebServiceUrl,
+                initialState.auth.config.keepWebInlineServiceUrl))
+            );
+    });
+});
diff --git a/services/workbench2/src/views-components/context-menu/actions/collection-file-viewer-action.tsx b/services/workbench2/src/views-components/context-menu/actions/collection-file-viewer-action.tsx
new file mode 100644 (file)
index 0000000..06b79bd
--- /dev/null
@@ -0,0 +1,41 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { RootState } from "../../../store/store";
+import { FileViewerAction } from 'views-components/context-menu/actions/file-viewer-action';
+import { getNodeValue } from "models/tree";
+import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
+import { getInlineFileUrl, sanitizeToken, isInlineFileUrlSafe } from "./helpers";
+
+const mapStateToProps = (state: RootState) => {
+    const { resource } = state.contextMenu;
+    const currentCollectionUuid = state.collectionPanel.item ? state.collectionPanel.item.uuid : '';
+    if (resource && [
+        ContextMenuKind.COLLECTION_FILE_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+        ContextMenuKind.COLLECTION_DIRECTORY_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM ].indexOf(resource.menuKind as ContextMenuKind) > -1) {
+        const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
+        const shouldShowInlineUrl = isInlineFileUrlSafe(
+                                file ? file.url : "",
+                                state.auth.config.keepWebServiceUrl,
+                                state.auth.config.keepWebInlineServiceUrl
+                              ) || state.auth.config.clusterConfig.Collections.TrustAllContent;
+        if (file && shouldShowInlineUrl) {
+            const fileUrl = sanitizeToken(getInlineFileUrl(
+                file.url,
+                state.auth.config.keepWebServiceUrl,
+                state.auth.config.keepWebInlineServiceUrl), true);
+            return {
+                href: fileUrl,
+                kind: 'file',
+                currentCollectionUuid
+            };
+        }
+    }
+    return {};
+};
+
+export const CollectionFileViewerAction = connect(mapStateToProps)(FileViewerAction);
diff --git a/services/workbench2/src/views-components/context-menu/actions/context-menu-divider.tsx b/services/workbench2/src/views-components/context-menu/actions/context-menu-divider.tsx
new file mode 100644 (file)
index 0000000..77955c2
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { ContextMenuAction } from '../context-menu-action-set';
+import { Divider as DividerComponent, StyleRulesCallback, withStyles } from '@material-ui/core';
+import { WithStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { VerticalLineDivider } from 'components/icon/icon';
+
+type CssRules = 'horizontal' | 'vertical';
+
+const styles:StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+  horizontal: {
+      backgroundColor: 'black',
+  },
+  vertical: {
+    color: theme.palette.grey["400"],
+    margin: 'auto 0',
+    transform: 'scaleY(1.25)',
+  },
+});
+
+export const VerticalLine = withStyles(styles)((props: WithStyles<CssRules>) => {
+  return  <VerticalLineDivider className={props.classes.vertical} />;
+});
+
+export const HorizontalLine = withStyles(styles)((props: WithStyles<CssRules>) => {
+  return  <DividerComponent variant='middle' className={props.classes.horizontal} />;
+});
+
+export const horizontalMenuDivider: ContextMenuAction = {
+  name: 'Divider',
+  icon: () => null,
+  component: VerticalLine,
+  execute: () => null,
+};
+
+export const verticalMenuDivider: ContextMenuAction = {
+  name: 'Divider',
+  icon: () => null,
+  component: HorizontalLine,
+  execute: () => null,
+};
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/context-menu/actions/copy-to-clipboard-action.test.tsx b/services/workbench2/src/views-components/context-menu/actions/copy-to-clipboard-action.test.tsx
new file mode 100644 (file)
index 0000000..8982873
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { shallow, configure } from 'enzyme';
+import { ListItem } from "@material-ui/core";
+import Adapter from 'enzyme-adapter-react-16';
+import { CopyToClipboardAction } from './copy-to-clipboard-action';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('copy-to-clipboard', () => jest.fn());
+
+describe('CopyToClipboardAction', () => {
+    let props;
+
+    beforeEach(() => {
+        props = {
+            onClick: jest.fn(),
+            href: 'https://collections.example.com/c=zzzzz-4zz18-k0hamvtwyit6q56/t=xxxxxxxx/LIMS/1.html',
+        };
+    });
+
+    it('should render properly and handle click', () => {
+        // when
+        const wrapper = shallow(<CopyToClipboardAction {...props} />);
+        wrapper.find(ListItem).simulate('click');
+
+        // then
+        expect(wrapper).not.toBeUndefined();
+
+        // and
+        expect(props.onClick).toHaveBeenCalled();
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx b/services/workbench2/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx
new file mode 100644 (file)
index 0000000..7b6501d
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import copy from 'copy-to-clipboard';
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { Link } from "components/icon/icon";
+import { getCollectionItemClipboardUrl } from "./helpers";
+
+export const CopyToClipboardAction = (props: { href?: any, download?: any, onClick?: () => void, kind?: string, currentCollectionUuid?: string; }) => {
+    const copyToClipboard = () => {
+        if (props.href) {
+            const clipboardUrl = getCollectionItemClipboardUrl(props.href, true, true);
+            copy(clipboardUrl);
+        }
+
+        if (props.onClick) {
+            props.onClick();
+        }
+    };
+
+    return props.href
+        ? <ListItem button onClick={copyToClipboard}>
+            <ListItemIcon>
+                <Link />
+            </ListItemIcon>
+            <ListItemText>
+                Copy link to clipboard
+            </ListItemText>
+        </ListItem>
+        : null;
+};
diff --git a/services/workbench2/src/views-components/context-menu/actions/download-action.test.tsx b/services/workbench2/src/views-components/context-menu/actions/download-action.test.tsx
new file mode 100644 (file)
index 0000000..e3fcfd1
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import axios from 'axios';
+import { configure, shallow } from "enzyme";
+import Adapter from 'enzyme-adapter-react-16';
+import { ListItem } from '@material-ui/core';
+import JSZip from 'jszip';
+import { DownloadAction } from './download-action';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('axios');
+
+jest.mock('file-saver', () => ({
+    saveAs: jest.fn(),
+}));
+
+const mock = {
+    file: jest.fn(),
+    generateAsync: jest.fn().mockImplementation(() => Promise.resolve('test')),
+};
+
+jest.mock('jszip', () => jest.fn().mockImplementation(() => mock));
+
+describe('<DownloadAction />', () => {
+    let props;
+    let zip;
+
+    beforeEach(() => {
+        props = {};
+        zip = new JSZip();
+        (axios as any).get.mockImplementationOnce(() => Promise.resolve({ data: '1234' }));
+    });
+
+    it('should return null if missing href or kind of file in props', () => {
+        // when
+        const wrapper = shallow(<DownloadAction {...props} />);
+
+        // then
+        expect(wrapper.html()).toBeNull();
+    });
+
+    it('should return a element', () => {
+        // setup
+        props.href = '#';
+
+        // when
+        const wrapper = shallow(<DownloadAction {...props} />);
+
+        // then
+        expect(wrapper.html()).not.toBeNull();
+    });
+
+    it('should handle download', () => {
+        // setup
+        props = {
+            href: ['file1'],
+            kind: 'files',
+            download: [],
+            currentCollectionUuid: '123412-123123'
+        };
+        const wrapper = shallow(<DownloadAction {...props} />);
+
+        // when
+        wrapper.find(ListItem).simulate('click');
+
+        // then
+        expect(axios.get).toHaveBeenCalledWith(props.href[0]);
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/context-menu/actions/download-action.tsx b/services/workbench2/src/views-components/context-menu/actions/download-action.tsx
new file mode 100644 (file)
index 0000000..352f84b
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { ListItemIcon, ListItemText, ListItem } from '@material-ui/core';
+import { DownloadIcon } from '../../../components/icon/icon';
+import JSZip from 'jszip';
+import FileSaver from 'file-saver';
+import axios from 'axios';
+
+export const DownloadAction = (props: { href?: any, download?: any, onClick?: () => void, kind?: string, currentCollectionUuid?: string; }) => {
+    const downloadProps = props.download ? { download: props.download } : {};
+
+    const createZip = (fileUrls: string[], download: string[]) => {
+        let id = 1;
+        const zip = new JSZip();
+        const filteredFileUrls = fileUrls
+            .filter((href: string) => {
+                const letter = href.split('').pop();
+                return letter !== '/';
+            });
+
+        filteredFileUrls.forEach((href: string) => {
+            axios.get(href).then(response => response).then(({ data }: any) => {
+                const splittedByDot = href.split('.');
+                if (splittedByDot[splittedByDot.length - 1] !== 'json') {
+                    if (filteredFileUrls.length === id) {
+                        zip.file(download[id - 1], data);
+                        zip.generateAsync({ type: 'blob' }).then((content) => {
+                            FileSaver.saveAs(content, `download-${props.currentCollectionUuid}.zip`);
+                        });
+                    } else {
+                        zip.file(download[id - 1], data);
+                        zip.generateAsync({ type: 'blob' });
+                    }
+                } else {
+                    zip.file(download[id - 1], JSON.stringify(data));
+                    zip.generateAsync({ type: 'blob' });
+                }
+                id++;
+            });
+        });
+    };
+
+    return props.href || props.kind === 'files'
+        ? <a
+            style={{ textDecoration: 'none' }}
+            href={props.kind === 'files' ? undefined : `${props.href}&disposition=attachment`}
+            onClick={props.onClick}
+            {...downloadProps}>
+            <ListItem button onClick={() => props.kind === 'files' ? createZip(props.href, props.download) : undefined}>
+                {props.kind !== 'files' ?
+                    <ListItemIcon>
+                        <DownloadIcon />
+                    </ListItemIcon> : <span />}
+                <ListItemText>
+                    {props.kind === 'files' ? 'Download selected' : 'Download'}
+                </ListItemText>
+            </ListItem>
+        </a>
+        : null;
+};
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/context-menu/actions/download-collection-file-action.tsx b/services/workbench2/src/views-components/context-menu/actions/download-collection-file-action.tsx
new file mode 100644 (file)
index 0000000..3b04e29
--- /dev/null
@@ -0,0 +1,40 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { RootState } from "../../../store/store";
+import { DownloadAction } from "./download-action";
+import { getNodeValue } from "../../../models/tree";
+import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
+import { filterCollectionFilesBySelection } from "store/collection-panel/collection-panel-files/collection-panel-files-state";
+import { sanitizeToken } from "./helpers";
+
+const mapStateToProps = (state: RootState) => {
+    const { resource } = state.contextMenu;
+    const currentCollectionUuid = state.collectionPanel.item ? state.collectionPanel.item.uuid : '';
+    if (resource && [
+        ContextMenuKind.COLLECTION_FILE_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+        ContextMenuKind.COLLECTION_DIRECTORY_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM ].indexOf(resource.menuKind as ContextMenuKind) > -1) {
+        const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
+        if (file) {
+            return {
+                href: sanitizeToken(file.url, true),
+                kind: 'file',
+                currentCollectionUuid
+            };
+        }
+    } else {
+        const files = filterCollectionFilesBySelection(state.collectionPanelFiles, true);
+        return {
+            href: files.map(file => file.url),
+            kind: 'files',
+            currentCollectionUuid
+        };
+    }
+    return {};
+};
+
+export const DownloadCollectionFileAction = connect(mapStateToProps)(DownloadAction);
diff --git a/services/workbench2/src/views-components/context-menu/actions/favorite-action.tsx b/services/workbench2/src/views-components/context-menu/actions/favorite-action.tsx
new file mode 100644 (file)
index 0000000..b7e7dd6
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { AddFavoriteIcon, RemoveFavoriteIcon } from "components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+
+const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({
+    isFavorite: state.contextMenu.resource !== undefined && state.favorites[state.contextMenu.resource.uuid] === true,
+    onClick: props.onClick
+});
+
+export const ToggleFavoriteAction = connect(mapStateToProps)((props: { isFavorite: boolean, onClick: () => void }) =>
+    <ListItem
+        button
+        onClick={props.onClick}>
+        <ListItemIcon>
+            {props.isFavorite
+                ? <RemoveFavoriteIcon />
+                : <AddFavoriteIcon />}
+        </ListItemIcon>
+        <ListItemText style={{ textDecoration: 'none' }}>
+            {props.isFavorite
+                ? <>Remove from favorites</>
+                : <>Add to favorites</>}
+        </ListItemText>
+    </ListItem >);
diff --git a/services/workbench2/src/views-components/context-menu/actions/file-viewer-action.test.tsx b/services/workbench2/src/views-components/context-menu/actions/file-viewer-action.test.tsx
new file mode 100644 (file)
index 0000000..23bc75f
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { shallow, configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import { FileViewerAction } from './file-viewer-action';
+
+configure({ adapter: new Adapter() });
+
+describe('FileViewerAction', () => {
+    let props;
+
+    beforeEach(() => {
+        props = {
+            onClick: jest.fn(),
+            href: 'https://collections.example.com/c=zzzzz-4zz18-k0hamvtwyit6q56/t=xxxxxxx/LIMS/1.html',
+        };
+    });
+
+    it('should render properly and handle click', () => {
+        // when
+        const wrapper = shallow(<FileViewerAction {...props} />);
+        wrapper.find('a').simulate('click');
+
+        // then
+        expect(wrapper).not.toBeUndefined();
+
+        // and
+        expect(props.onClick).toHaveBeenCalled();
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/context-menu/actions/file-viewer-action.tsx b/services/workbench2/src/views-components/context-menu/actions/file-viewer-action.tsx
new file mode 100644 (file)
index 0000000..c4bba3a
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { OpenIcon } from "components/icon/icon";
+
+export const FileViewerAction = (props: any) => {
+    return props.href
+        ? <a
+            style={{ textDecoration: 'none' }}
+            href={props.href}
+            target="_blank"
+            rel="noopener noreferrer"
+            onClick={props.onClick}>
+            <ListItem button>
+                <ListItemIcon>
+                    <OpenIcon />
+                </ListItemIcon>
+                <ListItemText>
+                    Open in new tab
+                </ListItemText>
+            </ListItem>
+        </a>
+        : null;
+};
diff --git a/services/workbench2/src/views-components/context-menu/actions/file-viewer-actions.tsx b/services/workbench2/src/views-components/context-menu/actions/file-viewer-actions.tsx
new file mode 100644 (file)
index 0000000..6eebda2
--- /dev/null
@@ -0,0 +1,82 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { ListItemText, ListItem, ListItemIcon, Icon } from "@material-ui/core";
+import { RootState } from 'store/store';
+import { getNodeValue } from 'models/tree';
+import { CollectionDirectory, CollectionFile, CollectionFileType } from 'models/collection-file';
+import { FileViewerList, FileViewer } from 'models/file-viewers-config';
+import { getFileViewers } from 'store/file-viewers/file-viewers-selectors';
+import { connect } from 'react-redux';
+import { OpenIcon } from 'components/icon/icon';
+
+interface FileViewerActionProps {
+    fileUrl: string;
+    viewers: FileViewerList;
+}
+
+const mapStateToProps = (state: RootState): FileViewerActionProps => {
+    const { resource } = state.contextMenu;
+    if (resource) {
+        const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
+        if (file) {
+            const fileViewers = getFileViewers(state.properties);
+            return {
+                fileUrl: file.url,
+                viewers: fileViewers.filter(enabledViewers(file)),
+            };
+        }
+    }
+    return {
+        fileUrl: '',
+        viewers: [],
+    };
+};
+
+const enabledViewers = (file: CollectionFile | CollectionDirectory) =>
+    ({ extensions, collections }: FileViewer) => {
+        if (collections && file.type === CollectionFileType.DIRECTORY) {
+            return true;
+        } else if (extensions) {
+            return extensions.some(extension => file.name.endsWith(extension));
+        } else {
+            return true;
+        }
+    };
+
+const fillViewerUrl = (fileUrl: string, { url, filePathParam }: FileViewer) => {
+    const viewerUrl = new URL(url);
+    viewerUrl.searchParams.append(filePathParam, fileUrl);
+    return viewerUrl.href;
+};
+
+export const FileViewerActions = connect(mapStateToProps)(
+    ({ fileUrl, viewers, onClick }: FileViewerActionProps & { onClick: () => void }) =>
+        <>
+            {viewers.map(viewer =>
+                <ListItem
+                    button
+                    component='a'
+                    key={viewer.name}
+                    style={{ textDecoration: 'none' }}
+                    href={fillViewerUrl(fileUrl, viewer)}
+                    onClick={onClick}
+                    rel="noopener"
+                    target='_blank'>
+                    <ListItemIcon>
+                        {
+                            viewer.iconUrl
+                                ? <Icon>
+                                    <img src={viewer.iconUrl} />
+                                </Icon>
+                                : <OpenIcon />
+                        }
+                    </ListItemIcon>
+                    <ListItemText>
+                        {viewer.name}
+                    </ListItemText>
+                </ListItem>
+            )}
+        </>);
diff --git a/services/workbench2/src/views-components/context-menu/actions/helpers.test.ts b/services/workbench2/src/views-components/context-menu/actions/helpers.test.ts
new file mode 100644 (file)
index 0000000..7776d0e
--- /dev/null
@@ -0,0 +1,70 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { sanitizeToken, getCollectionItemClipboardUrl, getInlineFileUrl } from "./helpers";
+
+describe('helpers', () => {
+    // given
+    const url = 'https://example.com/c=zzzzz-4zz18-0123456789abcde/t=v2/a/b/LIMS/1.html';
+    const urlWithPdh = 'https://example.com/c=012345678901234567890123456789aa+0/t=v2/a/b/LIMS/1.html';
+
+    describe('sanitizeToken', () => {
+        it('should sanitize token from the url', () => {
+            // when
+            const result = sanitizeToken(url);
+
+            // then
+            expect(result).toBe('https://example.com/c=zzzzz-4zz18-0123456789abcde/LIMS/1.html?api_token=v2/a/b');
+        });
+    });
+
+    describe('getClipboardUrl', () => {
+        it('should add redirectTo query param', () => {
+            // when
+            const result = getCollectionItemClipboardUrl(url);
+
+            // then
+            expect(result).toBe('http://localhost?redirectToDownload=https://example.com/c=zzzzz-4zz18-0123456789abcde/LIMS/1.html');
+        });
+    });
+
+    describe('getInlineFileUrl', () => {
+        it('should add the collection\'s uuid to the hostname', () => {
+            // when
+            const webDavUrlA = 'https://*.collections.example.com/';
+            const webDavUrlB = 'https://*--collections.example.com/';
+            const webDavDownloadUrl = 'https://example.com/';
+
+            // then
+            expect(getInlineFileUrl(url, webDavDownloadUrl, webDavUrlA))
+                .toBe('https://zzzzz-4zz18-0123456789abcde.collections.example.com/t=v2/a/b/LIMS/1.html');
+            expect(getInlineFileUrl(url, webDavDownloadUrl, webDavUrlB))
+                .toBe('https://zzzzz-4zz18-0123456789abcde--collections.example.com/t=v2/a/b/LIMS/1.html');
+            expect(getInlineFileUrl(urlWithPdh, webDavDownloadUrl, webDavUrlA))
+                .toBe('https://012345678901234567890123456789aa-0.collections.example.com/t=v2/a/b/LIMS/1.html');
+            expect(getInlineFileUrl(urlWithPdh, webDavDownloadUrl, webDavUrlB))
+                .toBe('https://012345678901234567890123456789aa-0--collections.example.com/t=v2/a/b/LIMS/1.html');
+        });
+
+        it('should keep the url the same when no inline url available', () => {
+            // when
+            const webDavUrl = '';
+            const webDavDownloadUrl = 'https://example.com/';
+            const result = getInlineFileUrl(url, webDavDownloadUrl, webDavUrl);
+
+            // then
+            expect(result).toBe('https://example.com/c=zzzzz-4zz18-0123456789abcde/t=v2/a/b/LIMS/1.html');
+        });
+
+        it('should replace the url when available', () => {
+            // when
+            const webDavUrl = 'https://download.example.com/';
+            const webDavDownloadUrl = 'https://example.com/';
+            const result = getInlineFileUrl(url, webDavDownloadUrl, webDavUrl);
+
+            // then
+            expect(result).toBe('https://download.example.com/c=zzzzz-4zz18-0123456789abcde/t=v2/a/b/LIMS/1.html');
+        });
+    });
+});
diff --git a/services/workbench2/src/views-components/context-menu/actions/helpers.ts b/services/workbench2/src/views-components/context-menu/actions/helpers.ts
new file mode 100644 (file)
index 0000000..9140e45
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { REDIRECT_TO_DOWNLOAD_KEY, REDIRECT_TO_PREVIEW_KEY } from "common/redirect-to";
+import { extractUuidKind, ResourceKind } from "models/resource";
+
+export const sanitizeToken = (href: string, tokenAsQueryParam = true): string => {
+    const [prefix, suffix] = href.split('/t=');
+    const [token1, token2, token3, ...rest] = suffix.split('/');
+    const token = `${token1}/${token2}/${token3}`;
+    const sep = href.indexOf("?") > -1 ? "&" : "?";
+
+    return `${[prefix, ...rest].join('/')}${tokenAsQueryParam ? `${sep}api_token=${token}` : ''}`;
+};
+
+/**
+ * @returns A shareable token-free WB2 url that redirects to keep-web after login
+ */
+export const getCollectionItemClipboardUrl = (href: string, shouldSanitizeToken = true, inline = false): string => {
+    const { origin } = window.location;
+    const url = shouldSanitizeToken ? sanitizeToken(href, false) : href;
+    const redirectKey = inline ? REDIRECT_TO_PREVIEW_KEY : REDIRECT_TO_DOWNLOAD_KEY;
+
+    return shouldSanitizeToken ? `${origin}?${redirectKey}=${url}` : `${origin}${url}`;
+};
+
+export const getInlineFileUrl = (url: string, keepWebSvcUrl: string, keepWebInlineSvcUrl: string): string => {
+    const collMatch = url.match(/\/c=([a-z0-9-+]+)\//);
+    if (collMatch === null) { return ''; }
+    if (extractUuidKind(collMatch[1]) !== ResourceKind.COLLECTION) { return ''; }
+    const collId = collMatch[1].replace('+', '-');
+    let inlineUrl = keepWebInlineSvcUrl !== ""
+        ? url.replace(keepWebSvcUrl, keepWebInlineSvcUrl)
+        : url;
+    let uuidOnHostname = false;
+    // Inline URLs as 'https://*.collections.example.com' or
+    // 'https://*--collections.example.com' should get the uuid on their hostnames
+    // See: https://doc.arvados.org/v2.1/api/keep-web-urls.html
+    if (inlineUrl.indexOf('*.') > -1) {
+        inlineUrl = inlineUrl.replace('*.', `${collId}.`);
+        uuidOnHostname = true;
+    } else if (inlineUrl.indexOf('*--') > -1) {
+        inlineUrl = inlineUrl.replace('*--', `${collId}--`);
+        uuidOnHostname = true;
+    }
+    if (uuidOnHostname) {
+        inlineUrl = inlineUrl.replace(`/c=${collMatch[1]}`, '');
+    }
+    return inlineUrl;
+};
+
+export const isInlineFileUrlSafe = (url: string, keepWebSvcUrl: string, keepWebInlineSvcUrl: string): boolean => {
+  let inlineUrl = keepWebInlineSvcUrl !== ""
+      ? url.replace(keepWebSvcUrl, keepWebInlineSvcUrl)
+      : url;
+  return inlineUrl.indexOf('*.') > -1;
+}
diff --git a/services/workbench2/src/views-components/context-menu/actions/lock-action.tsx b/services/workbench2/src/views-components/context-menu/actions/lock-action.tsx
new file mode 100644 (file)
index 0000000..99eb756
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { FreezeIcon, UnfreezeIcon } from "components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { ProjectResource } from "models/project";
+import { withRouter, RouteComponentProps } from "react-router";
+import { resourceIsFrozen } from "common/frozen-resources";
+
+const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({
+    isAdmin: !!state.auth.user?.isAdmin,
+    isLocked: !!(state.resources[state.contextMenu.resource!.uuid] as ProjectResource).frozenByUuid,
+    canManage: (state.resources[state.contextMenu.resource!.uuid] as ProjectResource).canManage,
+    canUnfreeze: !state.auth.remoteHostsConfig[state.auth.homeCluster]?.clusterConfig?.API?.UnfreezeProjectRequiresAdmin,
+    resource: state.contextMenu.resource,
+    resources: state.resources,
+    onClick: props.onClick
+});
+
+export const ToggleLockAction = withRouter(connect(mapStateToProps)((props: {
+    resource: any,
+    resources: any,
+    onClick: () => void,
+    state: RootState, isAdmin: boolean, isLocked: boolean, canManage: boolean, canUnfreeze: boolean,
+} & RouteComponentProps) =>
+    (props.canManage && !props.isLocked) || (props.isLocked && props.canManage && (props.canUnfreeze || props.isAdmin))  ? 
+        resourceIsFrozen(props.resource, props.resources) ? null :
+            <ListItem
+                button
+                onClick={props.onClick} >
+                <ListItemIcon>
+                    {props.isLocked
+                        ? <UnfreezeIcon />
+                        : <FreezeIcon />}
+                </ListItemIcon>
+                <ListItemText style={{ textDecoration: 'none' }}>
+                    {props.isLocked
+                        ? <>Unfreeze project</>
+                        : <>Freeze project</>}
+                </ListItemText>
+            </ListItem > : null));
diff --git a/services/workbench2/src/views-components/context-menu/actions/public-favorite-action.tsx b/services/workbench2/src/views-components/context-menu/actions/public-favorite-action.tsx
new file mode 100644 (file)
index 0000000..8608901
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { PublicFavoriteIcon } from "components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+
+const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({
+    isPublicFavorite: state.contextMenu.resource !== undefined && state.publicFavorites[state.contextMenu.resource.uuid] === true,
+    onClick: props.onClick
+});
+
+export const TogglePublicFavoriteAction = connect(mapStateToProps)((props: { isPublicFavorite: boolean, onClick: () => void }) =>
+    <ListItem
+        button
+        onClick={props.onClick}>
+        <ListItemIcon>
+            {props.isPublicFavorite
+                ? <PublicFavoriteIcon />
+                : <PublicFavoriteIcon />}
+        </ListItemIcon>
+        <ListItemText style={{ textDecoration: 'none' }}>
+            {props.isPublicFavorite
+                ? <>Remove from public favorites</>
+                : <>Add to public favorites</>}
+        </ListItemText>
+    </ListItem >);
diff --git a/services/workbench2/src/views-components/context-menu/actions/trash-action.tsx b/services/workbench2/src/views-components/context-menu/actions/trash-action.tsx
new file mode 100644 (file)
index 0000000..e52ead8
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { RestoreFromTrashIcon, TrashIcon } from "components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+
+const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({
+    isTrashed: state.contextMenu.resource && state.contextMenu.resource.isTrashed,
+    onClick: props.onClick
+});
+
+export const ToggleTrashAction = connect(mapStateToProps)((props: { isTrashed?: boolean, onClick: () => void }) =>
+    <ListItem button
+        onClick={props.onClick}>
+        <ListItemIcon>
+            {props.isTrashed
+                ? <RestoreFromTrashIcon/>
+                : <TrashIcon/>}
+        </ListItemIcon>
+        <ListItemText style={{ textDecoration: 'none' }}>
+            {props.isTrashed ? "Restore" : "Move to trash"}
+        </ListItemText>
+    </ListItem >);
diff --git a/services/workbench2/src/views-components/context-menu/context-menu-action-set.ts b/services/workbench2/src/views-components/context-menu/context-menu-action-set.ts
new file mode 100644 (file)
index 0000000..38de735
--- /dev/null
@@ -0,0 +1,68 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { ContextMenuItem } from "components/context-menu/context-menu";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+
+export enum ContextMenuActionNames {
+    ACCOUNT_SETTINGS = 'Account settings',
+    ACTIVATE_USER = 'Activate user',
+    ADD_TO_FAVORITES = 'Add to favorites',
+    ADD_TO_PUBLIC_FAVORITES = 'Add to public favorites',
+    ATTRIBUTES = 'Attributes',
+    API_DETAILS = 'API Details',
+    CANCEL = 'CANCEL',
+    COPY_AND_RERUN_PROCESS = 'Copy and re-run process',
+    COPY_ITEM_INTO_EXISTING_COLLECTION = 'Copy item into existing collection',
+    COPY_ITEM_INTO_NEW_COLLECTION = 'Copy item into new collection',
+    COPY_SELECTED_INTO_EXISTING_COLLECTION = 'Copy selected into existing collection',
+    COPY_SELECTED_INTO_SEPARATE_COLLECTIONS = 'Copy selected into separate collections',
+    COPY_SELECTED_INTO_NEW_COLLECTION = 'Copy selected into new collection',
+    COPY_LINK_TO_CLIPBOARD = 'Copy link to clipboard',
+    DEACTIVATE_USER = 'Deactivate user',
+    DELETE_WORKFLOW = 'Delete Workflow',
+    DIVIDER = 'Divider',
+    DOWNLOAD = 'Download',
+    EDIT_COLLECTION = 'Edit collection',
+    EDIT_PROCESS = 'Edit process',
+    EDIT_PROJECT = 'Edit project',
+    FREEZE_PROJECT = 'Freeze project',
+    HOME_PROJECT = 'Home project',
+    LOGIN_AS_USER = 'Login as user',
+    MAKE_A_COPY = 'Make a copy',
+    MANAGE = 'Manage',
+    MOVE_ITEM_INTO_EXISTING_COLLECTION = 'Move item into existing collection',
+    MOVE_ITEM_INTO_NEW_COLLECTION = 'Move item into new collection',
+    MOVE_SELECTED_INTO_EXISTING_COLLECTION = 'Move selected into existing collection',
+    MOVE_SELECTED_INTO_NEW_COLLECTION = 'Move selected into new collection',
+    MOVE_SELECTED_INTO_SEPARATE_COLLECTIONS = 'Move selected into separate collections',
+    MOVE_TO = 'Move to',
+    MOVE_TO_TRASH = 'Move to trash',
+    NEW_COLLECTION = 'New collection',
+    NEW_PROJECT = 'New project',
+    OPEN_IN_NEW_TAB = 'Open in new tab',
+    OPEN_WITH_3RD_PARTY_CLIENT = 'Open with 3rd party client',
+    OUTPUTS = 'Outputs',
+    PROVENANCE_GRAPH = 'Provenance graph',
+    READ = 'Read',
+    REMOVE = 'Remove',
+    REMOVE_SELECTED = 'Remove selected',
+    RENAME = 'Rename',
+    RESTORE = 'Restore',
+    RESTORE_VERSION = 'Restore version',
+    RUN_WORKFLOW = 'Run Workflow',
+    SELECT_ALL = 'Select all',
+    SETUP_USER = 'Setup user',
+    SHARE = 'Share',
+    UNSELECT_ALL = 'Unselect all',
+    VIEW_DETAILS = 'View details',
+    WRITE = 'Write',
+}
+
+export interface ContextMenuAction extends ContextMenuItem {
+    execute(dispatch: Dispatch, resources: ContextMenuResource[], state?: any): void;
+}
+
+export type ContextMenuActionSet = Array<Array<ContextMenuAction>>;
diff --git a/services/workbench2/src/views-components/context-menu/context-menu.tsx b/services/workbench2/src/views-components/context-menu/context-menu.tsx
new file mode 100644 (file)
index 0000000..a173399
--- /dev/null
@@ -0,0 +1,75 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { contextMenuActions, ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { ContextMenu as ContextMenuComponent, ContextMenuProps, ContextMenuItem } from "components/context-menu/context-menu";
+import { createAnchorAt } from "components/popover/helpers";
+import { ContextMenuActionSet, ContextMenuAction } from "./context-menu-action-set";
+import { Dispatch } from "redux";
+import { memoize } from "lodash";
+import { sortMenuItems, ContextMenuKind, menuDirection } from "./menu-item-sort";
+
+type DataProps = Pick<ContextMenuProps, "anchorEl" | "items" | "open"> & { resource?: ContextMenuResource };
+
+const mapStateToProps = (state: RootState): DataProps => {
+    const { open, position, resource } = state.contextMenu;
+    const filteredItems = getMenuActionSet(resource).map(group =>
+        group.filter(item => {
+            if (resource && item.filters) {
+                // Execute all filters on this item, every returns true IFF all filters return true
+                return item.filters.every(filter => filter(state, resource));
+            } else {
+                return true;
+            }
+        })
+    );
+
+    return {
+        anchorEl: resource ? createAnchorAt(position) : undefined,
+        items: filteredItems,
+        open,
+        resource,
+    };
+};
+
+type ActionProps = Pick<ContextMenuProps, "onClose"> & { onItemClick: (item: ContextMenuItem, resource?: ContextMenuResource) => void };
+const mapDispatchToProps = (dispatch: Dispatch): ActionProps => ({
+    onClose: () => {
+        dispatch(contextMenuActions.CLOSE_CONTEXT_MENU());
+    },
+    onItemClick: (action: ContextMenuAction, resource?: ContextMenuResource) => {
+        dispatch(contextMenuActions.CLOSE_CONTEXT_MENU());
+        if (resource) {
+            action.execute(dispatch, [resource]);
+        }
+    },
+});
+
+const handleItemClick = memoize(
+    (resource: DataProps["resource"], onItemClick: ActionProps["onItemClick"]): ContextMenuProps["onItemClick"] =>
+        item => {
+            onItemClick(item, { ...resource, fromContextMenu: true } as ContextMenuResource);
+        }
+);
+
+const mergeProps = ({ resource, ...dataProps }: DataProps, actionProps: ActionProps): ContextMenuProps => ({
+    ...dataProps,
+    ...actionProps,
+    onItemClick: handleItemClick(resource, actionProps.onItemClick),
+});
+
+export const ContextMenu = connect(mapStateToProps, mapDispatchToProps, mergeProps)(ContextMenuComponent);
+
+const menuActionSets = new Map<string, ContextMenuActionSet>();
+
+export const addMenuActionSet = (name: ContextMenuKind, itemSet: ContextMenuActionSet) => {
+    const sorted = itemSet.map(items => sortMenuItems(name, items, menuDirection.VERTICAL));
+    menuActionSets.set(name, sorted);
+};
+
+const emptyActionSet: ContextMenuActionSet = [];
+const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet =>
+    resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet;
diff --git a/services/workbench2/src/views-components/context-menu/menu-item-sort.ts b/services/workbench2/src/views-components/context-menu/menu-item-sort.ts
new file mode 100644 (file)
index 0000000..f331c60
--- /dev/null
@@ -0,0 +1,182 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuAction } from './context-menu-action-set';
+import { ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { sortByProperty } from 'common/array-utils';
+import { horizontalMenuDivider, verticalMenuDivider } from './actions/context-menu-divider';
+import { MultiSelectMenuAction } from 'views-components/multiselect-toolbar/ms-menu-actions';
+
+export enum ContextMenuKind {
+    API_CLIENT_AUTHORIZATION = "ApiClientAuthorization",
+    ROOT_PROJECT = "RootProject",
+    PROJECT = "Project",
+    FILTER_GROUP = "FilterGroup",
+    READONLY_PROJECT = "ReadOnlyProject",
+    FROZEN_PROJECT = "FrozenProject",
+    FROZEN_PROJECT_ADMIN = "FrozenProjectAdmin",
+    PROJECT_ADMIN = "ProjectAdmin",
+    FILTER_GROUP_ADMIN = "FilterGroupAdmin",
+    RESOURCE = "Resource",
+    FAVORITE = "Favorite",
+    TRASH = "Trash",
+    COLLECTION_FILES = "CollectionFiles",
+    COLLECTION_FILES_MULTIPLE = "CollectionFilesMultiple",
+    READONLY_COLLECTION_FILES = "ReadOnlyCollectionFiles",
+    READONLY_COLLECTION_FILES_MULTIPLE = "ReadOnlyCollectionFilesMultiple",
+    COLLECTION_FILES_NOT_SELECTED = "CollectionFilesNotSelected",
+    COLLECTION_FILE_ITEM = "CollectionFileItem",
+    COLLECTION_DIRECTORY_ITEM = "CollectionDirectoryItem",
+    READONLY_COLLECTION_FILE_ITEM = "ReadOnlyCollectionFileItem",
+    READONLY_COLLECTION_DIRECTORY_ITEM = "ReadOnlyCollectionDirectoryItem",
+    COLLECTION = "Collection",
+    COLLECTION_ADMIN = "CollectionAdmin",
+    READONLY_COLLECTION = "ReadOnlyCollection",
+    OLD_VERSION_COLLECTION = "OldVersionCollection",
+    TRASHED_COLLECTION = "TrashedCollection",
+    PROCESS = "Process",
+    RUNNING_PROCESS_ADMIN = "RunningProcessAdmin",
+    PROCESS_ADMIN = "ProcessAdmin",
+    RUNNING_PROCESS_RESOURCE = "RunningProcessResource",
+    PROCESS_RESOURCE = "ProcessResource",
+    READONLY_PROCESS_RESOURCE = "ReadOnlyProcessResource",
+    PROCESS_LOGS = "ProcessLogs",
+    REPOSITORY = "Repository",
+    SSH_KEY = "SshKey",
+    VIRTUAL_MACHINE = "VirtualMachine",
+    KEEP_SERVICE = "KeepService",
+    USER = "User",
+    GROUPS = "Group",
+    GROUP_MEMBER = "GroupMember",
+    PERMISSION_EDIT = "PermissionEdit",
+    LINK = "Link",
+    WORKFLOW = "Workflow",
+    READONLY_WORKFLOW = "ReadOnlyWorkflow",
+    SEARCH_RESULTS = "SearchResults",
+    MULTI = "Multi",
+}
+
+
+
+const processOrder = [
+    ContextMenuActionNames.VIEW_DETAILS,
+    ContextMenuActionNames.OPEN_IN_NEW_TAB,
+    ContextMenuActionNames.OUTPUTS,
+    ContextMenuActionNames.API_DETAILS,
+    ContextMenuActionNames.DIVIDER,
+    ContextMenuActionNames.EDIT_PROCESS,
+    ContextMenuActionNames.COPY_AND_RERUN_PROCESS,
+    ContextMenuActionNames.CANCEL,
+    ContextMenuActionNames.REMOVE,
+    ContextMenuActionNames.DIVIDER,
+    ContextMenuActionNames.ADD_TO_FAVORITES,
+    ContextMenuActionNames.ADD_TO_PUBLIC_FAVORITES,
+];
+
+const projectOrder = [
+    ContextMenuActionNames.VIEW_DETAILS,
+    ContextMenuActionNames.OPEN_IN_NEW_TAB,
+    ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD,
+    ContextMenuActionNames.OPEN_WITH_3RD_PARTY_CLIENT,
+    ContextMenuActionNames.API_DETAILS,
+    ContextMenuActionNames.DIVIDER,
+    ContextMenuActionNames.SHARE,
+    ContextMenuActionNames.NEW_PROJECT,
+    ContextMenuActionNames.EDIT_PROJECT,
+    ContextMenuActionNames.MOVE_TO,
+    ContextMenuActionNames.MOVE_TO_TRASH,
+    ContextMenuActionNames.DIVIDER,
+    ContextMenuActionNames.FREEZE_PROJECT,
+    ContextMenuActionNames.ADD_TO_FAVORITES,
+    ContextMenuActionNames.ADD_TO_PUBLIC_FAVORITES,
+];
+
+const collectionOrder = [
+    ContextMenuActionNames.VIEW_DETAILS,
+    ContextMenuActionNames.OPEN_IN_NEW_TAB,
+    ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD,
+    ContextMenuActionNames.OPEN_WITH_3RD_PARTY_CLIENT,
+    ContextMenuActionNames.API_DETAILS,
+    ContextMenuActionNames.DIVIDER,
+    ContextMenuActionNames.SHARE,
+    ContextMenuActionNames.EDIT_COLLECTION,
+    ContextMenuActionNames.MOVE_TO,
+    ContextMenuActionNames.MAKE_A_COPY,
+    ContextMenuActionNames.MOVE_TO_TRASH,
+    ContextMenuActionNames.DIVIDER,
+    ContextMenuActionNames.ADD_TO_FAVORITES,
+    ContextMenuActionNames.ADD_TO_PUBLIC_FAVORITES,
+];
+
+const workflowOrder = [
+    ContextMenuActionNames.VIEW_DETAILS,
+    ContextMenuActionNames.OPEN_IN_NEW_TAB,
+    ContextMenuActionNames.COPY_LINK_TO_CLIPBOARD,
+    ContextMenuActionNames.API_DETAILS,
+    ContextMenuActionNames.DIVIDER,
+    ContextMenuActionNames.RUN_WORKFLOW,
+    ContextMenuActionNames.DELETE_WORKFLOW,
+]
+
+const defaultMultiOrder = [
+    ContextMenuActionNames.MOVE_TO,
+    ContextMenuActionNames.MAKE_A_COPY,
+    ContextMenuActionNames.MOVE_TO_TRASH,
+];
+
+const kindToOrder: Record<string, ContextMenuActionNames[]> = {
+    [ContextMenuKind.MULTI]: defaultMultiOrder,
+
+    [ContextMenuKind.PROCESS]: processOrder,
+    [ContextMenuKind.PROCESS_ADMIN]: processOrder,
+    [ContextMenuKind.PROCESS_RESOURCE]: processOrder,
+    [ContextMenuKind.RUNNING_PROCESS_ADMIN]: processOrder,
+    [ContextMenuKind.RUNNING_PROCESS_RESOURCE]: processOrder,
+
+    [ContextMenuKind.PROJECT]: projectOrder,
+    [ContextMenuKind.PROJECT_ADMIN]: projectOrder,
+    [ContextMenuKind.FROZEN_PROJECT]: projectOrder,
+    [ContextMenuKind.FROZEN_PROJECT_ADMIN]: projectOrder,
+
+    [ContextMenuKind.COLLECTION]: collectionOrder,
+    [ContextMenuKind.COLLECTION_ADMIN]: collectionOrder,
+    [ContextMenuKind.READONLY_COLLECTION]: collectionOrder,
+    [ContextMenuKind.OLD_VERSION_COLLECTION]: collectionOrder,
+
+    [ContextMenuKind.WORKFLOW]: workflowOrder,
+    [ContextMenuKind.READONLY_WORKFLOW]: workflowOrder,
+};
+
+export const menuDirection = {
+    VERTICAL: 'vertical',
+    HORIZONTAL: 'horizontal'
+}
+
+export const sortMenuItems = (menuKind: ContextMenuKind, menuItems: ContextMenuAction[], orthagonality: string): ContextMenuAction[] | MultiSelectMenuAction[] => {
+
+    const preferredOrder = kindToOrder[menuKind];
+    //if no specified order, sort by name
+    if (!preferredOrder) return menuItems.sort(sortByProperty("name"));
+
+    const bucketMap = new Map();
+    const leftovers: ContextMenuAction[] = [];
+
+    // if we have multiple dividers, we need each of them to have a different "name" property
+    let count = 0;
+
+    preferredOrder.forEach((name) => {
+        if (name === ContextMenuActionNames.DIVIDER) {
+            count++;
+            bucketMap.set(`${name}-${count}`, orthagonality === menuDirection.VERTICAL ? verticalMenuDivider : horizontalMenuDivider)
+        } else {
+            bucketMap.set(name, null)
+        }
+    });
+    [...menuItems].forEach((item) => {
+        if (bucketMap.has(item.name)) bucketMap.set(item.name, item);
+        else leftovers.push(item);
+    });
+
+    return Array.from(bucketMap.values()).concat(leftovers).filter((item) => item !== null);
+};
diff --git a/services/workbench2/src/views-components/data-explorer/data-explorer.tsx b/services/workbench2/src/views-components/data-explorer/data-explorer.tsx
new file mode 100644 (file)
index 0000000..643949a
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { DataExplorer as DataExplorerComponent } from "components/data-explorer/data-explorer";
+import { getDataExplorer } from "store/data-explorer/data-explorer-reducer";
+import { Dispatch } from "redux";
+import { dataExplorerActions } from "store/data-explorer/data-explorer-action";
+import { DataColumn } from "components/data-table/data-column";
+import { DataColumns, TCheckedList } from "components/data-table/data-table";
+import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
+import { toggleMSToolbar, setCheckedListOnStore } from "store/multiselect/multiselect-actions";
+
+interface Props {
+    id: string;
+    onRowClick: (item: any) => void;
+    onContextMenu?: (event: React.MouseEvent<HTMLElement>, item: any, isAdmin?: boolean) => void;
+    onRowDoubleClick: (item: any) => void;
+    extractKey?: (item: any) => React.Key;
+    working?: boolean;
+}
+
+const mapStateToProps = ({ progressIndicator, dataExplorer, router, multiselect, detailsPanel, properties}: RootState, { id }: Props) => {
+    const working = !!progressIndicator.some(p => p.id === id && p.working);
+    const dataExplorerState = getDataExplorer(dataExplorer, id);
+    const currentRoute = router.location ? router.location.pathname : "";
+    const isDetailsResourceChecked = multiselect.checkedList[detailsPanel.resourceUuid]
+    const isOnlyOneSelected = Object.values(multiselect.checkedList).filter(x => x === true).length === 1;
+    const currentItemUuid =
+        currentRoute === '/workflows' ? properties.workflowPanelDetailsUuid : isDetailsResourceChecked && isOnlyOneSelected ? detailsPanel.resourceUuid : multiselect.selectedUuid;
+    const isMSToolbarVisible = multiselect.isVisible;
+    return {
+        ...dataExplorerState,
+        currentRoute: currentRoute,
+        paperKey: currentRoute,
+        currentItemUuid,
+        isMSToolbarVisible,
+        checkedList: multiselect.checkedList,
+        working,
+    };
+};
+
+const mapDispatchToProps = () => {
+    return (dispatch: Dispatch, { id, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({
+        onSetColumns: (columns: DataColumns<any, any>) => {
+            dispatch(dataExplorerActions.SET_COLUMNS({ id, columns }));
+        },
+
+        onSearch: (searchValue: string) => {
+            dispatch(dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ id, searchValue }));
+        },
+
+        onColumnToggle: (column: DataColumn<any, any>) => {
+            dispatch(dataExplorerActions.TOGGLE_COLUMN({ id, columnName: column.name }));
+        },
+
+        onSortToggle: (column: DataColumn<any, any>) => {
+            dispatch(dataExplorerActions.TOGGLE_SORT({ id, columnName: column.name }));
+        },
+
+        onFiltersChange: (filters: DataTableFilters, column: DataColumn<any, any>) => {
+            dispatch(dataExplorerActions.SET_FILTERS({ id, columnName: column.name, filters }));
+        },
+
+        onChangePage: (page: number) => {
+            dispatch(dataExplorerActions.SET_PAGE({ id, page }));
+        },
+
+        onChangeRowsPerPage: (rowsPerPage: number) => {
+            dispatch(dataExplorerActions.SET_ROWS_PER_PAGE({ id, rowsPerPage }));
+        },
+
+        onLoadMore: (page: number) => {
+            dispatch(dataExplorerActions.SET_PAGE({ id, page }));
+        },
+
+        toggleMSToolbar: (isVisible: boolean) => {
+            dispatch<any>(toggleMSToolbar(isVisible));
+        },
+
+        setCheckedListOnStore: (checkedList: TCheckedList) => {
+            dispatch<any>(setCheckedListOnStore(checkedList));
+        },
+
+        onRowClick,
+
+        onRowDoubleClick,
+
+        onContextMenu,
+    });
+};
+
+export const DataExplorer = connect(mapStateToProps, mapDispatchToProps)(DataExplorerComponent);
diff --git a/services/workbench2/src/views-components/data-explorer/renderers.test.tsx b/services/workbench2/src/views-components/data-explorer/renderers.test.tsx
new file mode 100644 (file)
index 0000000..eb33d12
--- /dev/null
@@ -0,0 +1,254 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { mount, configure } from 'enzyme';
+import { GroupMembersCount, ProcessStatus, ResourceFileSize } from './renderers';
+import Adapter from "enzyme-adapter-react-16";
+import { Provider } from 'react-redux';
+import configureMockStore from 'redux-mock-store'
+import { ResourceKind } from '../../models/resource';
+import { ContainerRequestState as CR } from '../../models/container-request';
+import { ContainerState as C } from '../../models/container';
+import { ProcessStatus as PS } from '../../store/processes/process';
+import { MuiThemeProvider } from '@material-ui/core';
+import { CustomTheme } from 'common/custom-theme';
+import { InlinePulser} from 'components/loading/inline-pulser';
+import { ErrorIcon } from "components/icon/icon";
+
+const middlewares = [];
+const mockStore = configureMockStore(middlewares);
+
+configure({ adapter: new Adapter() });
+
+describe('renderers', () => {
+    let props: any = null;
+
+    describe('ProcessStatus', () => {
+        props = {
+            uuid: 'zzzzz-xvhdp-zzzzzzzzzzzzzzz',
+            theme: {
+                customs: {
+                    colors: {
+                        // Color values are arbitrary, but they should be
+                        // representative of the colors used in the UI.
+                        green800: 'rgb(0, 255, 0)',
+                        red900: 'rgb(255, 0, 0)',
+                        orange: 'rgb(240, 173, 78)',
+                        grey600: 'rgb(128, 128, 128)',
+                    }
+                },
+                spacing: {
+                    unit: 8,
+                },
+                palette: {
+                    common: {
+                        white: 'rgb(255, 255, 255)',
+                    },
+                },
+            },
+        };
+
+        [
+            // CR Status ; Priority ; C Status ; Exit Code ; C RuntimeStatus ; Expected label ; Expected bg color ; Expected fg color
+            [CR.COMMITTED, 1, C.RUNNING, null, {}, PS.RUNNING, props.theme.palette.common.white, props.theme.customs.colors.green800],
+            [CR.COMMITTED, 1, C.RUNNING, null, { error: 'whoops' }, PS.FAILING, props.theme.palette.common.white, props.theme.customs.colors.red900],
+            [CR.COMMITTED, 1, C.RUNNING, null, { warning: 'watch out!' }, PS.WARNING, props.theme.palette.common.white, props.theme.customs.colors.green800],
+            [CR.FINAL, 1, C.CANCELLED, null, {}, PS.CANCELLED, props.theme.customs.colors.red900, props.theme.palette.common.white],
+            [CR.FINAL, 1, C.COMPLETE, 137, {}, PS.FAILED, props.theme.customs.colors.red900, props.theme.palette.common.white],
+            [CR.FINAL, 1, C.COMPLETE, 0, {}, PS.COMPLETED, props.theme.customs.colors.green800, props.theme.palette.common.white],
+            [CR.COMMITTED, 0, C.LOCKED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey600, props.theme.palette.common.white],
+            [CR.COMMITTED, 0, C.QUEUED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey600, props.theme.palette.common.white],
+            [CR.COMMITTED, 1, C.LOCKED, null, {}, PS.QUEUED, props.theme.palette.common.white, props.theme.customs.colors.grey600],
+            [CR.COMMITTED, 1, C.QUEUED, null, {}, PS.QUEUED, props.theme.palette.common.white, props.theme.customs.colors.grey600],
+        ].forEach(([crState, crPrio, cState, exitCode, rs, eLabel, eColor, tColor]) => {
+            it(`should render the state label '${eLabel}' and color '${eColor}' for CR state=${crState}, priority=${crPrio}, C state=${cState}, exitCode=${exitCode} and RuntimeStatus=${JSON.stringify(rs)}`, () => {
+                const containerUuid = 'zzzzz-dz642-zzzzzzzzzzzzzzz';
+                const store = mockStore({
+                    resources: {
+                        [props.uuid]: {
+                            kind: ResourceKind.CONTAINER_REQUEST,
+                            state: crState,
+                            containerUuid: containerUuid,
+                            priority: crPrio,
+                        },
+                        [containerUuid]: {
+                            kind: ResourceKind.CONTAINER,
+                            state: cState,
+                            runtimeStatus: rs,
+                            exitCode: exitCode,
+                        },
+                    }
+                });
+
+                const wrapper = mount(<Provider store={store}>
+                    <ProcessStatus {...props} />
+                </Provider>);
+
+                expect(wrapper.text()).toEqual(eLabel);
+                expect(getComputedStyle(wrapper.getDOMNode())
+                    .getPropertyValue('color')).toEqual(tColor);
+                expect(getComputedStyle(wrapper.getDOMNode())
+                    .getPropertyValue('background-color')).toEqual(eColor);
+            });
+        })
+    });
+
+    describe('ResourceFileSize', () => {
+        beforeEach(() => {
+            props = {
+                uuid: 'UUID',
+            };
+        });
+
+        it('should render collection fileSizeTotal', () => {
+            // given
+            const store = mockStore({
+                resources: {
+                    [props.uuid]: {
+                        kind: ResourceKind.COLLECTION,
+                        fileSizeTotal: 100,
+                    }
+                }
+            });
+
+            // when
+            const wrapper = mount(<Provider store={store}>
+                <ResourceFileSize {...props}></ResourceFileSize>
+            </Provider>);
+
+            // then
+            expect(wrapper.text()).toContain('100 B');
+        });
+
+        it('should render 0 B as file size', () => {
+            // given
+            const store = mockStore({ resources: {} });
+
+            // when
+            const wrapper = mount(<Provider store={store}>
+                <ResourceFileSize {...props}></ResourceFileSize>
+            </Provider>);
+
+            // then
+            expect(wrapper.text()).toContain('0 B');
+        });
+
+        it('should render empty string for non collection resource', () => {
+            // given
+            const store1 = mockStore({
+                resources: {
+                    [props.uuid]: {
+                        kind: ResourceKind.PROJECT,
+                    }
+                }
+            });
+            const store2 = mockStore({
+                resources: {
+                    [props.uuid]: {
+                        kind: ResourceKind.PROJECT,
+                    }
+                }
+            });
+
+            // when
+            const wrapper1 = mount(<Provider store={store1}>
+                <ResourceFileSize {...props}></ResourceFileSize>
+            </Provider>);
+            const wrapper2 = mount(<Provider store={store2}>
+                <ResourceFileSize {...props}></ResourceFileSize>
+            </Provider>);
+
+            // then
+            expect(wrapper1.text()).toContain('');
+            expect(wrapper2.text()).toContain('');
+        });
+    });
+
+    describe('GroupMembersCount', () => {
+        let fakeGroup;
+        beforeEach(() => {
+            props = {
+                uuid: 'zzzzz-j7d0g-000000000000000',
+            };
+            fakeGroup = {
+                "canManage": true,
+                "canWrite": true,
+                "createdAt": "2020-09-24T22:52:57.546521000Z",
+                "deleteAt": null,
+                "description": "Test Group",
+                "etag": "0000000000000000000000000",
+                "frozenByUuid": null,
+                "groupClass": "role",
+                "href": `/groups/${props.uuid}`,
+                "isTrashed": false,
+                "kind": ResourceKind.GROUP,
+                "modifiedAt": "2020-09-24T22:52:57.545669000Z",
+                "modifiedByClientUuid": null,
+                "modifiedByUserUuid": "zzzzz-tpzed-000000000000000",
+                "name": "System group",
+                "ownerUuid": "zzzzz-tpzed-000000000000000",
+                "properties": {},
+                "trashAt": null,
+                "uuid": props.uuid,
+                "writableBy": [
+                    "zzzzz-tpzed-000000000000000",
+                ]
+            };
+        });
+
+        it('shows loading group count when no memberCount', () => {
+            // Given
+            const store = mockStore({resources: {
+                [props.uuid]: fakeGroup,
+            }});
+
+            const wrapper = mount(<Provider store={store}>
+                <MuiThemeProvider theme={CustomTheme}>
+                    <GroupMembersCount {...props} />
+                </MuiThemeProvider>
+            </Provider>);
+
+            expect(wrapper.find(InlinePulser)).toHaveLength(1);
+        });
+
+        it('shows group count when memberCount present', () => {
+            // Given
+            const store = mockStore({resources: {
+                [props.uuid]: {
+                    ...fakeGroup,
+                    "memberCount": 765,
+                }
+            }});
+
+            const wrapper = mount(<Provider store={store}>
+                <MuiThemeProvider theme={CustomTheme}>
+                    <GroupMembersCount {...props} />
+                </MuiThemeProvider>
+            </Provider>);
+
+            expect(wrapper.text()).toBe("765");
+        });
+
+        it('shows group count error icon when memberCount is null', () => {
+            // Given
+            const store = mockStore({resources: {
+                [props.uuid]: {
+                    ...fakeGroup,
+                    "memberCount": null,
+                }
+            }});
+
+            const wrapper = mount(<Provider store={store}>
+                <MuiThemeProvider theme={CustomTheme}>
+                    <GroupMembersCount {...props} />
+                </MuiThemeProvider>
+            </Provider>);
+
+            expect(wrapper.find(ErrorIcon)).toHaveLength(1);
+        });
+
+    });
+
+});
diff --git a/services/workbench2/src/views-components/data-explorer/renderers.tsx b/services/workbench2/src/views-components/data-explorer/renderers.tsx
new file mode 100644 (file)
index 0000000..91b06c2
--- /dev/null
@@ -0,0 +1,1164 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox, Chip, withTheme } from "@material-ui/core";
+import { FavoriteStar, PublicFavoriteStar } from "../favorite-star/favorite-star";
+import { Resource, ResourceKind, TrashableResource } from "models/resource";
+import {
+    FreezeIcon,
+    ProjectIcon,
+    FilterGroupIcon,
+    CollectionIcon,
+    ProcessIcon,
+    DefaultIcon,
+    ShareIcon,
+    CollectionOldVersionIcon,
+    WorkflowIcon,
+    RemoveIcon,
+    RenameIcon,
+    ActiveIcon,
+    SetupIcon,
+    InactiveIcon,
+    ErrorIcon,
+} from "components/icon/icon";
+import { formatDate, formatFileSize, formatTime } from "common/formatters";
+import { resourceLabel } from "common/labels";
+import { connect, DispatchProp } from "react-redux";
+import { RootState } from "store/store";
+import { getResource, filterResources } from "store/resources/resources";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { getProcess, Process, getProcessStatus, getProcessStatusStyles, getProcessRuntime } from "store/processes/process";
+import { ArvadosTheme } from "common/custom-theme";
+import { compose, Dispatch } from "redux";
+import { WorkflowResource } from "models/workflow";
+import { ResourceStatus as WorkflowStatus } from "views/workflow-panel/workflow-panel-view";
+import { getUuidPrefix, openRunProcess } from "store/workflow-panel/workflow-panel-actions";
+import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
+import { getUserFullname, getUserDisplayName, User, UserResource } from "models/user";
+import { toggleIsAdmin } from "store/users/users-actions";
+import { LinkClass, LinkResource } from "models/link";
+import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from "store/navigation/navigation-action";
+import { withResourceData } from "views-components/data-explorer/with-resources";
+import { CollectionResource } from "models/collection";
+import { IllegalNamingWarning } from "components/warning/warning";
+import { loadResource } from "store/resources/resources-actions";
+import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from "models/group";
+import { openRemoveGroupMemberDialog } from "store/group-details-panel/group-details-panel-actions";
+import { setMemberIsHidden } from "store/group-details-panel/group-details-panel-actions";
+import { formatPermissionLevel } from "views-components/sharing-dialog/permission-select";
+import { PermissionLevel } from "models/permission";
+import { openPermissionEditContextMenu } from "store/context-menu/context-menu-actions";
+import { VirtualMachinesResource } from "models/virtual-machines";
+import { CopyToClipboardSnackbar } from "components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar";
+import { ProjectResource } from "models/project";
+import { ProcessResource } from "models/process";
+import { InlinePulser } from "components/loading/inline-pulser";
+
+const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
+    const navFunc = "groupClass" in item && item.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo;
+    return (
+        <Grid
+            container
+            alignItems="center"
+            wrap="nowrap"
+            spacing={16}
+        >
+            <Grid item>{renderIcon(item)}</Grid>
+            <Grid item>
+                <Typography
+                    color="primary"
+                    style={{ width: "auto", cursor: "pointer" }}
+                    onClick={(ev) => {
+                        ev.stopPropagation()
+                        dispatch<any>(navFunc(item.uuid))
+                    }}
+                >
+                    {item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION ? <IllegalNamingWarning name={item.name} /> : null}
+                    {item.name}
+                </Typography>
+            </Grid>
+            <Grid item>
+                <Typography variant="caption">
+                    <FavoriteStar resourceUuid={item.uuid} />
+                    <PublicFavoriteStar resourceUuid={item.uuid} />
+                    {item.kind === ResourceKind.PROJECT && <FrozenProject item={item} />}
+                </Typography>
+            </Grid>
+        </Grid>
+    );
+};
+
+const FrozenProject = (props: { item: ProjectResource }) => {
+    const [fullUsername, setFullusername] = React.useState<any>(null);
+    const getFullName = React.useCallback(() => {
+        if (props.item.frozenByUuid) {
+            setFullusername(<UserNameFromID uuid={props.item.frozenByUuid} />);
+        }
+    }, [props.item, setFullusername]);
+
+    if (props.item.frozenByUuid) {
+        return (
+            <Tooltip
+                onOpen={getFullName}
+                enterDelay={500}
+                title={<span>Project was frozen by {fullUsername}</span>}
+            >
+                <FreezeIcon style={{ fontSize: "inherit" }} />
+            </Tooltip>
+        );
+    } else {
+        return null;
+    }
+};
+
+export const ResourceName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return resource;
+})((resource: GroupContentsResource & DispatchProp<any>) => renderName(resource.dispatch, resource));
+
+const renderIcon = (item: GroupContentsResource) => {
+    switch (item.kind) {
+        case ResourceKind.PROJECT:
+            if (item.groupClass === GroupClass.FILTER) {
+                return <FilterGroupIcon />;
+            }
+            return <ProjectIcon />;
+        case ResourceKind.COLLECTION:
+            if (item.uuid === item.currentVersionUuid) {
+                return <CollectionIcon />;
+            }
+            return <CollectionOldVersionIcon />;
+        case ResourceKind.PROCESS:
+            return <ProcessIcon />;
+        case ResourceKind.WORKFLOW:
+            return <WorkflowIcon />;
+        default:
+            return <DefaultIcon />;
+    }
+};
+
+const renderDate = (date?: string) => {
+    return (
+        <Typography
+            noWrap
+            style={{ minWidth: "100px" }}
+        >
+            {formatDate(date)}
+        </Typography>
+    );
+};
+
+const renderWorkflowName = (item: WorkflowResource) => (
+    <Grid
+        container
+        alignItems="center"
+        wrap="nowrap"
+        spacing={16}
+    >
+        <Grid item>{renderIcon(item)}</Grid>
+        <Grid item>
+            <Typography
+                color="primary"
+                style={{ width: "100px" }}
+            >
+                {item.name}
+            </Typography>
+        </Grid>
+    </Grid>
+);
+
+export const ResourceWorkflowName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
+    return resource;
+})(renderWorkflowName);
+
+const getPublicUuid = (uuidPrefix: string) => {
+    return `${uuidPrefix}-tpzed-anonymouspublic`;
+};
+
+const resourceShare = (dispatch: Dispatch, uuidPrefix: string, ownerUuid?: string, uuid?: string) => {
+    const isPublic = ownerUuid === getPublicUuid(uuidPrefix);
+    return (
+        <div>
+            {!isPublic && uuid && (
+                <Tooltip title="Share">
+                    <IconButton onClick={() => dispatch<any>(openSharingDialog(uuid))}>
+                        <ShareIcon />
+                    </IconButton>
+                </Tooltip>
+            )}
+        </div>
+    );
+};
+
+export const ResourceShare = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
+    const uuidPrefix = getUuidPrefix(state);
+    return {
+        uuid: resource ? resource.uuid : "",
+        ownerUuid: resource ? resource.ownerUuid : "",
+        uuidPrefix,
+    };
+})((props: { ownerUuid?: string; uuidPrefix: string; uuid?: string } & DispatchProp<any>) =>
+    resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid)
+);
+
+// User Resources
+const renderFirstName = (item: { firstName: string }) => {
+    return <Typography noWrap>{item.firstName}</Typography>;
+};
+
+export const ResourceFirstName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return resource || { firstName: "" };
+})(renderFirstName);
+
+const renderLastName = (item: { lastName: string }) => <Typography noWrap>{item.lastName}</Typography>;
+
+export const ResourceLastName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return resource || { lastName: "" };
+})(renderLastName);
+
+const renderFullName = (dispatch: Dispatch, item: { uuid: string; firstName: string; lastName: string }, link?: boolean) => {
+    const displayName = (item.firstName + " " + item.lastName).trim() || item.uuid;
+    return link ? (
+        <Typography
+            noWrap
+            color="primary"
+            style={{ cursor: "pointer" }}
+            onClick={() => dispatch<any>(navigateToUserProfile(item.uuid))}
+        >
+            {displayName}
+        </Typography>
+    ) : (
+        <Typography noWrap>{displayName}</Typography>
+    );
+};
+
+export const UserResourceFullName = connect((state: RootState, props: { uuid: string; link?: boolean }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return { item: resource || { uuid: "", firstName: "", lastName: "" }, link: props.link };
+})((props: { item: { uuid: string; firstName: string; lastName: string }; link?: boolean } & DispatchProp<any>) =>
+    renderFullName(props.dispatch, props.item, props.link)
+);
+
+const renderUuid = (item: { uuid: string }) => (
+    <Typography
+        data-cy="uuid"
+        noWrap
+    >
+        {item.uuid}
+        {(item.uuid && <CopyToClipboardSnackbar value={item.uuid} />) || "-"}
+    </Typography>
+);
+
+const renderUuidCopyIcon = (item: { uuid: string }) => (
+    <Typography
+        data-cy="uuid"
+        noWrap
+    >
+        {(item.uuid && <CopyToClipboardSnackbar value={item.uuid} />) || "-"}
+    </Typography>
+);
+
+export const ResourceUuid = connect(
+    (state: RootState, props: { uuid: string }) => getResource<UserResource>(props.uuid)(state.resources) || { uuid: "" }
+)(renderUuid);
+
+const renderEmail = (item: { email: string }) => <Typography noWrap>{item.email}</Typography>;
+
+export const ResourceEmail = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return resource || { email: "" };
+})(renderEmail);
+
+enum UserAccountStatus {
+    ACTIVE = "Active",
+    INACTIVE = "Inactive",
+    SETUP = "Setup",
+    UNKNOWN = "",
+}
+
+const renderAccountStatus = (props: { status: UserAccountStatus }) => (
+    <Grid
+        container
+        alignItems="center"
+        wrap="nowrap"
+        spacing={8}
+        data-cy="account-status"
+    >
+        <Grid item>
+            {(() => {
+                switch (props.status) {
+                    case UserAccountStatus.ACTIVE:
+                        return <ActiveIcon style={{ color: "#4caf50", verticalAlign: "middle" }} />;
+                    case UserAccountStatus.SETUP:
+                        return <SetupIcon style={{ color: "#2196f3", verticalAlign: "middle" }} />;
+                    case UserAccountStatus.INACTIVE:
+                        return <InactiveIcon style={{ color: "#9e9e9e", verticalAlign: "middle" }} />;
+                    default:
+                        return <></>;
+                }
+            })()}
+        </Grid>
+        <Grid item>
+            <Typography noWrap>{props.status}</Typography>
+        </Grid>
+    </Grid>
+);
+
+const getUserAccountStatus = (state: RootState, props: { uuid: string }) => {
+    const user = getResource<UserResource>(props.uuid)(state.resources);
+    // Get membership links for all users group
+    const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL);
+    const permissions = filterResources(
+        (resource: LinkResource) =>
+            resource.kind === ResourceKind.LINK &&
+            resource.linkClass === LinkClass.PERMISSION &&
+            resource.headUuid === allUsersGroupUuid &&
+            resource.tailUuid === props.uuid
+    )(state.resources);
+
+    if (user) {
+        return user.isActive
+            ? { status: UserAccountStatus.ACTIVE }
+            : permissions.length > 0
+            ? { status: UserAccountStatus.SETUP }
+            : { status: UserAccountStatus.INACTIVE };
+    } else {
+        return { status: UserAccountStatus.UNKNOWN };
+    }
+};
+
+export const ResourceLinkTailAccountStatus = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    return link && link.tailKind === ResourceKind.USER ? getUserAccountStatus(state, { uuid: link.tailUuid }) : { status: UserAccountStatus.UNKNOWN };
+})(renderAccountStatus);
+
+export const UserResourceAccountStatus = connect(getUserAccountStatus)(renderAccountStatus);
+
+const renderIsHidden = (props: {
+    memberLinkUuid: string;
+    permissionLinkUuid: string;
+    visible: boolean;
+    canManage: boolean;
+    setMemberIsHidden: (memberLinkUuid: string, permissionLinkUuid: string, hide: boolean) => void;
+}) => {
+    if (props.memberLinkUuid) {
+        return (
+            <Checkbox
+                data-cy="user-visible-checkbox"
+                color="primary"
+                checked={props.visible}
+                disabled={!props.canManage}
+                onClick={e => {
+                    e.stopPropagation();
+                    props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible);
+                }}
+            />
+        );
+    } else {
+        return <Typography />;
+    }
+};
+
+export const ResourceLinkTailIsVisible = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const member = getResource<Resource>(link?.tailUuid || "")(state.resources);
+        const group = getResource<GroupResource>(link?.headUuid || "")(state.resources);
+        const permissions = filterResources((resource: LinkResource) => {
+            return (
+                resource.linkClass === LinkClass.PERMISSION &&
+                resource.headUuid === link?.tailUuid &&
+                resource.tailUuid === group?.uuid &&
+                resource.name === PermissionLevel.CAN_READ
+            );
+        })(state.resources);
+
+        const permissionLinkUuid = permissions.length > 0 ? permissions[0].uuid : "";
+        const isVisible = link && group && permissions.length > 0;
+        // Consider whether the current user canManage this resurce in addition when it's possible
+        const isBuiltin = isBuiltinGroup(link?.headUuid || "");
+
+        return member?.kind === ResourceKind.USER
+            ? { memberLinkUuid: link?.uuid, permissionLinkUuid, visible: isVisible, canManage: !isBuiltin }
+            : { memberLinkUuid: "", permissionLinkUuid: "", visible: false, canManage: false };
+    },
+    { setMemberIsHidden }
+)(renderIsHidden);
+
+const renderIsAdmin = (props: { uuid: string; isAdmin: boolean; toggleIsAdmin: (uuid: string) => void }) => (
+    <Checkbox
+        color="primary"
+        checked={props.isAdmin}
+        onClick={e => {
+            e.stopPropagation();
+            props.toggleIsAdmin(props.uuid);
+        }}
+    />
+);
+
+export const ResourceIsAdmin = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<UserResource>(props.uuid)(state.resources);
+        return resource || { isAdmin: false };
+    },
+    { toggleIsAdmin }
+)(renderIsAdmin);
+
+const renderUsername = (item: { username: string; uuid: string }) => <Typography noWrap>{item.username || item.uuid}</Typography>;
+
+export const ResourceUsername = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return resource || { username: "", uuid: props.uuid };
+})(renderUsername);
+
+// Virtual machine resource
+
+const renderHostname = (item: { hostname: string }) => <Typography noWrap>{item.hostname}</Typography>;
+
+export const VirtualMachineHostname = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<VirtualMachinesResource>(props.uuid)(state.resources);
+    return resource || { hostname: "" };
+})(renderHostname);
+
+const renderVirtualMachineLogin = (login: { user: string }) => <Typography noWrap>{login.user}</Typography>;
+
+export const VirtualMachineLogin = connect((state: RootState, props: { linkUuid: string }) => {
+    const permission = getResource<LinkResource>(props.linkUuid)(state.resources);
+    const user = getResource<UserResource>(permission?.tailUuid || "")(state.resources);
+
+    return { user: user?.username || permission?.tailUuid || "" };
+})(renderVirtualMachineLogin);
+
+// Common methods
+const renderCommonData = (data: string) => <Typography noWrap>{data}</Typography>;
+
+const renderCommonDate = (date: string) => <Typography noWrap>{formatDate(date)}</Typography>;
+
+export const CommonUuid = withResourceData("uuid", renderCommonData);
+
+// Api Client Authorizations
+export const TokenApiClientId = withResourceData("apiClientId", renderCommonData);
+
+export const TokenApiToken = withResourceData("apiToken", renderCommonData);
+
+export const TokenCreatedByIpAddress = withResourceData("createdByIpAddress", renderCommonDate);
+
+export const TokenDefaultOwnerUuid = withResourceData("defaultOwnerUuid", renderCommonData);
+
+export const TokenExpiresAt = withResourceData("expiresAt", renderCommonDate);
+
+export const TokenLastUsedAt = withResourceData("lastUsedAt", renderCommonDate);
+
+export const TokenLastUsedByIpAddress = withResourceData("lastUsedByIpAddress", renderCommonData);
+
+export const TokenScopes = withResourceData("scopes", renderCommonData);
+
+export const TokenUserId = withResourceData("userId", renderCommonData);
+
+const clusterColors = [
+    ["#f44336", "#fff"],
+    ["#2196f3", "#fff"],
+    ["#009688", "#fff"],
+    ["#cddc39", "#fff"],
+    ["#ff9800", "#fff"],
+];
+
+export const ResourceCluster = (props: { uuid: string }) => {
+    const CLUSTER_ID_LENGTH = 5;
+    const pos = props.uuid.length > CLUSTER_ID_LENGTH ? props.uuid.indexOf("-") : 5;
+    const clusterId = pos >= CLUSTER_ID_LENGTH ? props.uuid.substring(0, pos) : "";
+    const ci =
+        pos >= CLUSTER_ID_LENGTH
+            ? ((props.uuid.charCodeAt(0) * props.uuid.charCodeAt(1) + props.uuid.charCodeAt(2)) * props.uuid.charCodeAt(3) +
+                  props.uuid.charCodeAt(4)) %
+              clusterColors.length
+            : 0;
+    return (
+        <span
+            style={{
+                backgroundColor: clusterColors[ci][0],
+                color: clusterColors[ci][1],
+                padding: "2px 7px",
+                borderRadius: 3,
+            }}
+        >
+            {clusterId}
+        </span>
+    );
+};
+
+// Links Resources
+const renderLinkName = (item: { name: string }) => <Typography noWrap>{item.name || "-"}</Typography>;
+
+export const ResourceLinkName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    return resource || { name: "" };
+})(renderLinkName);
+
+const renderLinkClass = (item: { linkClass: string }) => <Typography noWrap>{item.linkClass}</Typography>;
+
+export const ResourceLinkClass = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    return resource || { linkClass: "" };
+})(renderLinkClass);
+
+const getResourceDisplayName = (resource: Resource): string => {
+    if ((resource as UserResource).kind === ResourceKind.USER && typeof (resource as UserResource).firstName !== "undefined") {
+        // We can be sure the resource is UserResource
+        return getUserDisplayName(resource as UserResource);
+    } else {
+        return (resource as GroupContentsResource).name;
+    }
+};
+
+const renderResourceLink = (dispatch: Dispatch, item: Resource ) => {
+    var displayName = getResourceDisplayName(item);
+
+    return (
+        <Typography
+            noWrap
+            color="primary"
+            style={{ cursor: "pointer" }}
+            onClick={() => {
+                item.kind === ResourceKind.GROUP && (item as GroupResource).groupClass === "role"
+                    ? dispatch<any>(navigateToGroupDetails(item.uuid))
+                    : item.kind === ResourceKind.USER
+                    ? dispatch<any>(navigateToUserProfile(item.uuid))
+                    : dispatch<any>(navigateTo(item.uuid));
+            }}
+        >
+            {resourceLabel(item.kind, item && item.kind === ResourceKind.GROUP ? (item as GroupResource).groupClass || "" : "")}:{" "}
+            {displayName || item.uuid}
+        </Typography>
+    );
+};
+
+export const ResourceLinkTail = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    const tailResource = getResource<Resource>(resource?.tailUuid || "")(state.resources);
+
+    return {
+        item: tailResource || { uuid: resource?.tailUuid || "", kind: resource?.tailKind || ResourceKind.NONE },
+    };
+})((props: { item: Resource } & DispatchProp<any>) => renderResourceLink(props.dispatch, props.item));
+
+export const ResourceLinkHead = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    const headResource = getResource<Resource>(resource?.headUuid || "")(state.resources);
+
+    return {
+        item: headResource || { uuid: resource?.headUuid || "", kind: resource?.headKind || ResourceKind.NONE },
+    };
+})((props: { item: Resource } & DispatchProp<any>) => renderResourceLink(props.dispatch, props.item));
+
+export const ResourceLinkUuid = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    return resource || { uuid: "" };
+})(renderUuid);
+
+export const ResourceLinkHeadUuid = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const headResource = getResource<Resource>(link?.headUuid || "")(state.resources);
+
+    return headResource || { uuid: "" };
+})(renderUuid);
+
+export const ResourceLinkTailUuid = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const tailResource = getResource<Resource>(link?.tailUuid || "")(state.resources);
+
+    return tailResource || { uuid: "" };
+})(renderUuid);
+
+const renderLinkDelete = (dispatch: Dispatch, item: LinkResource, canManage: boolean) => {
+    if (item.uuid) {
+        return canManage ? (
+            <Typography noWrap>
+                <IconButton
+                    data-cy="resource-delete-button"
+                    onClick={() => dispatch<any>(openRemoveGroupMemberDialog(item.uuid))}
+                >
+                    <RemoveIcon />
+                </IconButton>
+            </Typography>
+        ) : (
+            <Typography noWrap>
+                <IconButton
+                    disabled
+                    data-cy="resource-delete-button"
+                >
+                    <RemoveIcon />
+                </IconButton>
+            </Typography>
+        );
+    } else {
+        return <Typography noWrap></Typography>;
+    }
+};
+
+export const ResourceLinkDelete = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const isBuiltin = isBuiltinGroup(link?.headUuid || "") || isBuiltinGroup(link?.tailUuid || "");
+
+    return {
+        item: link || { uuid: "", kind: ResourceKind.NONE },
+        canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
+    };
+})((props: { item: LinkResource; canManage: boolean } & DispatchProp<any>) => renderLinkDelete(props.dispatch, props.item, props.canManage));
+
+export const ResourceLinkTailEmail = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const resource = getResource<UserResource>(link?.tailUuid || "")(state.resources);
+
+    return resource || { email: "" };
+})(renderEmail);
+
+export const ResourceLinkTailUsername = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const resource = getResource<UserResource>(link?.tailUuid || "")(state.resources);
+
+    return resource || { username: "" };
+})(renderUsername);
+
+const renderPermissionLevel = (dispatch: Dispatch, link: LinkResource, canManage: boolean) => {
+    return (
+        <Typography noWrap>
+            {formatPermissionLevel(link.name as PermissionLevel)}
+            {canManage ? (
+                <IconButton
+                    data-cy="edit-permission-button"
+                    onClick={event => dispatch<any>(openPermissionEditContextMenu(event, link))}
+                >
+                    <RenameIcon />
+                </IconButton>
+            ) : (
+                ""
+            )}
+        </Typography>
+    );
+};
+
+export const ResourceLinkHeadPermissionLevel = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const isBuiltin = isBuiltinGroup(link?.headUuid || "") || isBuiltinGroup(link?.tailUuid || "");
+
+    return {
+        link: link || { uuid: "", name: "", kind: ResourceKind.NONE },
+        canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
+    };
+})((props: { link: LinkResource; canManage: boolean } & DispatchProp<any>) => renderPermissionLevel(props.dispatch, props.link, props.canManage));
+
+export const ResourceLinkTailPermissionLevel = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const isBuiltin = isBuiltinGroup(link?.headUuid || "") || isBuiltinGroup(link?.tailUuid || "");
+
+    return {
+        link: link || { uuid: "", name: "", kind: ResourceKind.NONE },
+        canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
+    };
+})((props: { link: LinkResource; canManage: boolean } & DispatchProp<any>) => renderPermissionLevel(props.dispatch, props.link, props.canManage));
+
+const getResourceLinkCanManage = (state: RootState, link: LinkResource) => {
+    const headResource = getResource<Resource>(link.headUuid)(state.resources);
+    if (headResource && headResource.kind === ResourceKind.GROUP) {
+        return (headResource as GroupResource).canManage;
+    } else {
+        // true for now
+        return true;
+    }
+};
+
+// Process Resources
+const resourceRunProcess = (dispatch: Dispatch, uuid: string) => {
+    return (
+        <div>
+            {uuid && (
+                <Tooltip title="Run process">
+                    <IconButton onClick={() => dispatch<any>(openRunProcess(uuid))}>
+                        <ProcessIcon />
+                    </IconButton>
+                </Tooltip>
+            )}
+        </div>
+    );
+};
+
+export const ResourceRunProcess = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
+    return {
+        uuid: resource ? resource.uuid : "",
+    };
+})((props: { uuid: string } & DispatchProp<any>) => resourceRunProcess(props.dispatch, props.uuid));
+
+const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => {
+    if (ownerUuid === getPublicUuid(uuidPrefix)) {
+        return renderStatus(WorkflowStatus.PUBLIC);
+    } else {
+        return renderStatus(WorkflowStatus.PRIVATE);
+    }
+};
+
+const renderStatus = (status: string) => (
+    <Typography
+        noWrap
+        style={{ width: "60px" }}
+    >
+        {status}
+    </Typography>
+);
+
+export const ResourceWorkflowStatus = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
+    const uuidPrefix = getUuidPrefix(state);
+    return {
+        ownerUuid: resource ? resource.ownerUuid : "",
+        uuidPrefix,
+    };
+})((props: { ownerUuid?: string; uuidPrefix: string }) => renderWorkflowStatus(props.uuidPrefix, props.ownerUuid));
+
+export const ResourceContainerUuid = connect((state: RootState, props: { uuid: string }) => {
+    const process = getProcess(props.uuid)(state.resources);
+    return { uuid: process?.container?.uuid ? process?.container?.uuid : "" };
+})((props: { uuid: string }) => renderUuid({ uuid: props.uuid }));
+
+enum ColumnSelection {
+    OUTPUT_UUID = "outputUuid",
+    LOG_UUID = "logUuid",
+}
+
+const renderUuidLinkWithCopyIcon = (dispatch: Dispatch, item: ProcessResource, column: string) => {
+    const selectedColumnUuid = item[column];
+    return (
+        <Grid
+            container
+            alignItems="center"
+            wrap="nowrap"
+        >
+            <Grid item>
+                {selectedColumnUuid ? (
+                    <Typography
+                        color="primary"
+                        style={{ width: "auto", cursor: "pointer" }}
+                        noWrap
+                        onClick={() => dispatch<any>(navigateTo(selectedColumnUuid))}
+                    >
+                        {selectedColumnUuid}
+                    </Typography>
+                ) : (
+                    "-"
+                )}
+            </Grid>
+            <Grid item>{selectedColumnUuid && renderUuidCopyIcon({ uuid: selectedColumnUuid })}</Grid>
+        </Grid>
+    );
+};
+
+export const ResourceOutputUuid = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<ProcessResource>(props.uuid)(state.resources);
+    return resource;
+})((process: ProcessResource & DispatchProp<any>) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.OUTPUT_UUID));
+
+export const ResourceLogUuid = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<ProcessResource>(props.uuid)(state.resources);
+    return resource;
+})((process: ProcessResource & DispatchProp<any>) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.LOG_UUID));
+
+export const ResourceParentProcess = connect((state: RootState, props: { uuid: string }) => {
+    const process = getProcess(props.uuid)(state.resources);
+    return { parentProcess: process?.containerRequest?.requestingContainerUuid || "" };
+})((props: { parentProcess: string }) => renderUuid({ uuid: props.parentProcess }));
+
+export const ResourceModifiedByUserUuid = connect((state: RootState, props: { uuid: string }) => {
+    const process = getProcess(props.uuid)(state.resources);
+    return { userUuid: process?.containerRequest?.modifiedByUserUuid || "" };
+})((props: { userUuid: string }) => renderUuid({ uuid: props.userUuid }));
+
+export const ResourceCreatedAtDate = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return { date: resource ? resource.createdAt : "" };
+})((props: { date: string }) => renderDate(props.date));
+
+export const ResourceLastModifiedDate = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return { date: resource ? resource.modifiedAt : "" };
+})((props: { date: string }) => renderDate(props.date));
+
+export const ResourceTrashDate = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<TrashableResource>(props.uuid)(state.resources);
+    return { date: resource ? resource.trashAt : "" };
+})((props: { date: string }) => renderDate(props.date));
+
+export const ResourceDeleteDate = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<TrashableResource>(props.uuid)(state.resources);
+    return { date: resource ? resource.deleteAt : "" };
+})((props: { date: string }) => renderDate(props.date));
+
+export const renderFileSize = (fileSize?: number) => (
+    <Typography
+        noWrap
+        style={{ minWidth: "45px" }}
+    >
+        {formatFileSize(fileSize)}
+    </Typography>
+);
+
+export const ResourceFileSize = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+
+    if (resource && resource.kind !== ResourceKind.COLLECTION) {
+        return { fileSize: "" };
+    }
+
+    return { fileSize: resource ? resource.fileSizeTotal : 0 };
+})((props: { fileSize?: number }) => renderFileSize(props.fileSize));
+
+const renderOwner = (owner: string) => <Typography noWrap>{owner || "-"}</Typography>;
+
+export const ResourceOwner = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return { owner: resource ? resource.ownerUuid : "" };
+})((props: { owner: string }) => renderOwner(props.owner));
+
+export const ResourceOwnerName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    const ownerNameState = state.ownerName;
+    const ownerName = ownerNameState.find(it => it.uuid === resource!.ownerUuid);
+    return { owner: ownerName ? ownerName!.name : resource!.ownerUuid };
+})((props: { owner: string }) => renderOwner(props.owner));
+
+export const ResourceUUID = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+    return { uuid: resource ? resource.uuid : "" };
+})((props: { uuid: string }) => renderUuid({ uuid: props.uuid }));
+
+const renderVersion = (version: number) => {
+    return <Typography>{version ?? "-"}</Typography>;
+};
+
+export const ResourceVersion = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+    return { version: resource ? resource.version : "" };
+})((props: { version: number }) => renderVersion(props.version));
+
+const renderPortableDataHash = (portableDataHash: string | null) => (
+    <Typography noWrap>
+        {portableDataHash ? (
+            <>
+                {portableDataHash}
+                <CopyToClipboardSnackbar value={portableDataHash} />
+            </>
+        ) : (
+            "-"
+        )}
+    </Typography>
+);
+
+export const ResourcePortableDataHash = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+    return { portableDataHash: resource ? resource.portableDataHash : "" };
+})((props: { portableDataHash: string }) => renderPortableDataHash(props.portableDataHash));
+
+const renderFileCount = (fileCount: number) => {
+    return <Typography>{fileCount ?? "-"}</Typography>;
+};
+
+export const ResourceFileCount = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+    return { fileCount: resource ? resource.fileCount : "" };
+})((props: { fileCount: number }) => renderFileCount(props.fileCount));
+
+const userFromID = connect((state: RootState, props: { uuid: string }) => {
+    let userFullname = "";
+    const resource = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
+
+    if (resource) {
+        userFullname = getUserFullname(resource as User) || (resource as GroupContentsResource).name;
+    }
+
+    return { uuid: props.uuid, userFullname };
+});
+
+const ownerFromResourceId = compose(
+    connect((state: RootState, props: { uuid: string }) => {
+        const childResource = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
+        return { uuid: childResource ? (childResource as Resource).ownerUuid : "" };
+    }),
+    userFromID
+);
+
+const _resourceWithName = withStyles(
+    {},
+    { withTheme: true }
+)((props: { uuid: string; userFullname: string; dispatch: Dispatch; theme: ArvadosTheme }) => {
+    const { uuid, userFullname, dispatch, theme } = props;
+    if (userFullname === "") {
+        dispatch<any>(loadResource(uuid, false));
+        return (
+            <Typography
+                style={{ color: theme.palette.primary.main }}
+                inline
+            >
+                {uuid}
+            </Typography>
+        );
+    }
+
+    return (
+        <Typography
+            style={{ color: theme.palette.primary.main }}
+            inline
+        >
+            {userFullname} ({uuid})
+        </Typography>
+    );
+});
+
+const _resourceWithNameLink = withStyles(
+    {},
+    { withTheme: true }
+)((props: { uuid: string; userFullname: string; dispatch: Dispatch; theme: ArvadosTheme }) => {
+    const { uuid, userFullname, dispatch, theme } = props;
+    if (!userFullname) {
+        dispatch<any>(loadResource(uuid, false));
+    }
+
+    return (
+        <Typography
+            style={{ color: theme.palette.primary.main, cursor: 'pointer' }}
+            inline
+            noWrap
+            onClick={() => dispatch<any>(navigateTo(uuid))}
+        >
+            {userFullname ? userFullname : uuid}
+        </Typography>
+    )
+});
+
+
+export const ResourceOwnerWithNameLink = ownerFromResourceId(_resourceWithNameLink);
+
+export const ResourceOwnerWithName = ownerFromResourceId(_resourceWithName);
+
+export const ResourceWithName = userFromID(_resourceWithName);
+
+export const UserNameFromID = compose(userFromID)((props: { uuid: string; displayAsText?: string; userFullname: string; dispatch: Dispatch }) => {
+    const { uuid, userFullname, dispatch } = props;
+
+    if (userFullname === "") {
+        dispatch<any>(loadResource(uuid, false));
+    }
+    return <span>{userFullname ? userFullname : uuid}</span>;
+});
+
+export const ResponsiblePerson = compose(
+    connect((state: RootState, props: { uuid: string; parentRef: HTMLElement | null }) => {
+        let responsiblePersonName: string = "";
+        let responsiblePersonUUID: string = "";
+        let responsiblePersonProperty: string = "";
+
+        if (state.auth.config.clusterConfig.Collections.ManagedProperties) {
+            let index = 0;
+            const keys = Object.keys(state.auth.config.clusterConfig.Collections.ManagedProperties);
+
+            while (!responsiblePersonProperty && keys[index]) {
+                const key = keys[index];
+                if (state.auth.config.clusterConfig.Collections.ManagedProperties[key].Function === "original_owner") {
+                    responsiblePersonProperty = key;
+                }
+                index++;
+            }
+        }
+
+        let resource: Resource | undefined = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
+
+        while (resource && resource.kind !== ResourceKind.USER && responsiblePersonProperty) {
+            responsiblePersonUUID = (resource as CollectionResource).properties[responsiblePersonProperty];
+            resource = getResource<GroupContentsResource & UserResource>(responsiblePersonUUID)(state.resources);
+        }
+
+        if (resource && resource.kind === ResourceKind.USER) {
+            responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name;
+        }
+
+        return { uuid: responsiblePersonUUID, responsiblePersonName, parentRef: props.parentRef };
+    }),
+    withStyles({}, { withTheme: true })
+)((props: { uuid: string | null; responsiblePersonName: string; parentRef: HTMLElement | null; theme: ArvadosTheme }) => {
+    const { uuid, responsiblePersonName, parentRef, theme } = props;
+
+    if (!uuid && parentRef) {
+        parentRef.style.display = "none";
+        return null;
+    } else if (parentRef) {
+        parentRef.style.display = "block";
+    }
+
+    if (!responsiblePersonName) {
+        return (
+            <Typography
+                style={{ color: theme.palette.primary.main }}
+                inline
+                noWrap
+            >
+                {uuid}
+            </Typography>
+        );
+    }
+
+    return (
+        <Typography
+            style={{ color: theme.palette.primary.main }}
+            inline
+            noWrap
+        >
+            {responsiblePersonName} ({uuid})
+        </Typography>
+    );
+});
+
+const renderType = (type: string, subtype: string) => <Typography noWrap>{resourceLabel(type, subtype)}</Typography>;
+
+export const ResourceType = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return { type: resource ? resource.kind : "", subtype: resource && resource.kind === ResourceKind.GROUP ? resource.groupClass : "" };
+})((props: { type: string; subtype: string }) => renderType(props.type, props.subtype));
+
+export const ResourceStatus = connect((state: RootState, props: { uuid: string }) => {
+    return { resource: getResource<GroupContentsResource>(props.uuid)(state.resources) };
+})((props: { resource: GroupContentsResource }) =>
+    props.resource && props.resource.kind === ResourceKind.COLLECTION ? (
+        <CollectionStatus uuid={props.resource.uuid} />
+    ) : (
+        <ProcessStatus uuid={props.resource.uuid} />
+    )
+);
+
+export const CollectionStatus = connect((state: RootState, props: { uuid: string }) => {
+    return { collection: getResource<CollectionResource>(props.uuid)(state.resources) };
+})((props: { collection: CollectionResource }) =>
+    props.collection.uuid !== props.collection.currentVersionUuid ? (
+        <Typography>version {props.collection.version}</Typography>
+    ) : (
+        <Typography>head version</Typography>
+    )
+);
+
+export const CollectionName = connect((state: RootState, props: { uuid: string; className?: string }) => {
+    return {
+        collection: getResource<CollectionResource>(props.uuid)(state.resources),
+        uuid: props.uuid,
+        className: props.className,
+    };
+})((props: { collection: CollectionResource; uuid: string; className?: string }) => (
+    <Typography className={props.className}>{props.collection?.name || props.uuid}</Typography>
+));
+
+export const ProcessStatus = compose(
+    connect((state: RootState, props: { uuid: string }) => {
+        return { process: getProcess(props.uuid)(state.resources) };
+    }),
+    withStyles({}, { withTheme: true })
+)((props: { process?: Process; theme: ArvadosTheme }) =>
+    props.process ? (
+        <Chip
+            label={getProcessStatus(props.process)}
+            style={{
+                height: props.theme.spacing.unit * 3,
+                width: props.theme.spacing.unit * 12,
+                ...getProcessStatusStyles(getProcessStatus(props.process), props.theme),
+                fontSize: "0.875rem",
+                borderRadius: props.theme.spacing.unit * 0.625,
+            }}
+        />
+    ) : (
+        <Typography>-</Typography>
+    )
+);
+
+export const ProcessStartDate = connect((state: RootState, props: { uuid: string }) => {
+    const process = getProcess(props.uuid)(state.resources);
+    return { date: process && process.container ? process.container.startedAt : "" };
+})((props: { date: string }) => renderDate(props.date));
+
+export const renderRunTime = (time: number) => (
+    <Typography
+        noWrap
+        style={{ minWidth: "45px" }}
+    >
+        {formatTime(time, true)}
+    </Typography>
+);
+
+interface ContainerRunTimeProps {
+    process: Process;
+}
+
+interface ContainerRunTimeState {
+    runtime: number;
+}
+
+export const ContainerRunTime = connect((state: RootState, props: { uuid: string }) => {
+    return { process: getProcess(props.uuid)(state.resources) };
+})(
+    class extends React.Component<ContainerRunTimeProps, ContainerRunTimeState> {
+        private timer: any;
+
+        constructor(props: ContainerRunTimeProps) {
+            super(props);
+            this.state = { runtime: this.getRuntime() };
+        }
+
+        getRuntime() {
+            return this.props.process ? getProcessRuntime(this.props.process) : 0;
+        }
+
+        updateRuntime() {
+            this.setState({ runtime: this.getRuntime() });
+        }
+
+        componentDidMount() {
+            this.timer = setInterval(this.updateRuntime.bind(this), 5000);
+        }
+
+        componentWillUnmount() {
+            clearInterval(this.timer);
+        }
+
+        render() {
+            return this.props.process ? renderRunTime(this.state.runtime) : <Typography>-</Typography>;
+        }
+    }
+);
+
+export const GroupMembersCount = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const group = getResource<GroupResource>(props.uuid)(state.resources);
+
+        return {
+            value: group?.memberCount,
+        };
+
+    }
+)(withTheme()((props: {value: number | null | undefined, theme:ArvadosTheme}) => {
+    if (props.value === undefined) {
+        // Loading
+        return <Typography component={"div"}>
+            <InlinePulser />
+        </Typography>;
+    } else if (props.value === null) {
+        // Error
+        return <Typography>
+            <Tooltip title="Failed to load member count">
+                <ErrorIcon style={{color: props.theme.customs.colors.greyL}}/>
+            </Tooltip>
+        </Typography>;
+    } else {
+        return <Typography children={props.value} />;
+    }
+}));
diff --git a/services/workbench2/src/views-components/data-explorer/with-resources.tsx b/services/workbench2/src/views-components/data-explorer/with-resources.tsx
new file mode 100644 (file)
index 0000000..deeabe9
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import { getResource } from 'store/resources/resources';
+import { Resource } from 'models/resource';
+
+interface WithResourceProps {
+    resource?: Resource;
+}
+
+export const withResource = (component: React.ComponentType<WithResourceProps & { uuid: string }>) =>
+    connect<WithResourceProps>(
+        (state: RootState, props: { uuid: string }): WithResourceProps => ({
+            resource: getResource(props.uuid)(state.resources)
+        })
+    )(component);
+
+export const getDataFromResource = (property: string, resource?: Resource) => {
+    return resource && resource[property] ? resource[property] : '(none)';
+};
+
+export const withResourceData = (property: string, render: (data: any) => React.ReactElement<any>) =>
+    withResource(({ resource }) => render(getDataFromResource(property, resource)));
diff --git a/services/workbench2/src/views-components/details-panel/collection-details.tsx b/services/workbench2/src/views-components/details-panel/collection-details.tsx
new file mode 100644 (file)
index 0000000..4431465
--- /dev/null
@@ -0,0 +1,227 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { CollectionIcon, RenameIcon } from 'components/icon/icon';
+import { CollectionResource } from 'models/collection';
+import { DetailsData } from "./details-data";
+import { CollectionDetailsAttributes } from 'views/collection-panel/collection-panel';
+import { RootState } from 'store/store';
+import { filterResources, getResource, ResourcesState } from 'store/resources/resources';
+import { connect } from 'react-redux';
+import { Button, Grid, ListItem, StyleRulesCallback, Typography, withStyles, WithStyles } from '@material-ui/core';
+import { formatDate, formatFileSize } from 'common/formatters';
+import { UserNameFromID } from '../data-explorer/renderers';
+import { Dispatch } from 'redux';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
+import { openCollectionUpdateDialog } from 'store/collections/collection-update-actions';
+import { resourceIsFrozen } from 'common/frozen-resources';
+
+export type CssRules = 'versionBrowserHeader'
+    | 'versionBrowserItem'
+    | 'versionBrowserField'
+    | 'editButton'
+    | 'editIcon'
+    | 'tag';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    versionBrowserHeader: {
+        textAlign: 'center',
+        fontWeight: 'bold',
+    },
+    versionBrowserItem: {
+        flexWrap: 'wrap',
+    },
+    versionBrowserField: {
+        textAlign: 'center',
+    },
+    editIcon: {
+        paddingRight: theme.spacing.unit/2,
+        fontSize: '1.125rem',
+    },
+    editButton: {
+        boxShadow: 'none',
+        padding: '2px 10px 2px 5px',
+        fontSize: '0.75rem'
+    },
+    tag: {
+        marginRight: theme.spacing.unit / 2,
+        marginBottom: theme.spacing.unit / 2
+    },
+});
+
+export class CollectionDetails extends DetailsData<CollectionResource> {
+
+    getIcon(className?: string) {
+        return <CollectionIcon className={className} />;
+    }
+
+    getTabLabels() {
+        return ['Details', 'Versions'];
+    }
+
+    getDetails({tabNr}) {
+        switch (tabNr) {
+            case 0:
+                return this.getCollectionInfo();
+            case 1:
+                return this.getVersionBrowser();
+            default:
+                return <div />;
+        }
+    }
+
+    private getCollectionInfo() {
+        return <CollectionInfo />;
+    }
+
+    private getVersionBrowser() {
+        return <CollectionVersionBrowser />;
+    }
+}
+
+interface CollectionInfoDataProps {
+    resources: ResourcesState;
+    currentCollection: CollectionResource | undefined;
+}
+
+interface CollectionInfoDispatchProps {
+    editCollection: (collection: CollectionResource | undefined) => void;
+}
+
+const ciMapStateToProps = (state: RootState): CollectionInfoDataProps => {
+    return {
+        resources: state.resources,
+        currentCollection: getResource<CollectionResource>(state.detailsPanel.resourceUuid)(state.resources),
+    };
+};
+
+const ciMapDispatchToProps = (dispatch: Dispatch): CollectionInfoDispatchProps => ({
+    editCollection: (collection: CollectionResource) =>
+        dispatch<any>(openCollectionUpdateDialog({
+            uuid: collection.uuid,
+            name: collection.name,
+            description: collection.description,
+            properties: collection.properties,
+            storageClassesDesired: collection.storageClassesDesired,
+        })),
+});
+
+type CollectionInfoProps = CollectionInfoDataProps & CollectionInfoDispatchProps & WithStyles<CssRules>;
+
+const CollectionInfo = withStyles(styles)(
+    connect(ciMapStateToProps, ciMapDispatchToProps)(
+        ({ currentCollection, resources, editCollection, classes }: CollectionInfoProps) =>
+            currentCollection !== undefined
+                ? <div>
+                    <Button
+                        disabled={resourceIsFrozen(currentCollection, resources)}
+                        className={classes.editButton} variant='contained'
+                        data-cy='details-panel-edit-btn' color='primary' size='small'
+                        onClick={() => editCollection(currentCollection)}>
+                        <RenameIcon className={classes.editIcon} /> Edit
+                    </Button>
+                    <CollectionDetailsAttributes classes={classes} twoCol={false} item={currentCollection} />
+                </div>
+                : <div />
+    )
+);
+
+interface CollectionVersionBrowserProps {
+    currentCollection: CollectionResource | undefined;
+    versions: CollectionResource[];
+}
+
+interface CollectionVersionBrowserDispatchProps {
+    showVersion: (c: CollectionResource) => void;
+    handleContextMenu: (event: React.MouseEvent<HTMLElement>, collection: CollectionResource) => void;
+}
+
+const vbMapStateToProps = (state: RootState): CollectionVersionBrowserProps => {
+    const currentCollection = getResource<CollectionResource>(state.detailsPanel.resourceUuid)(state.resources);
+    const versions = (currentCollection
+        && filterResources(rsc =>
+            (rsc as CollectionResource).currentVersionUuid === currentCollection.currentVersionUuid)(state.resources)
+                .sort((a: CollectionResource, b: CollectionResource) => b.version - a.version) as CollectionResource[])
+        || [];
+    return { currentCollection, versions };
+};
+
+const vbMapDispatchToProps = () =>
+    (dispatch: Dispatch): CollectionVersionBrowserDispatchProps => ({
+        showVersion: (collection) => dispatch<any>(navigateTo(collection.uuid)),
+        handleContextMenu: (event: React.MouseEvent<HTMLElement>, collection: CollectionResource) => {
+            const menuKind = dispatch<any>(resourceUuidToContextMenuKind(collection.uuid));
+            if (collection && menuKind) {
+                dispatch<any>(openContextMenu(event, {
+                    name: collection.name,
+                    uuid: collection.uuid,
+                    description: collection.description,
+                    storageClassesDesired: collection.storageClassesDesired,
+                    ownerUuid: collection.ownerUuid,
+                    isTrashed: collection.isTrashed,
+                    kind: collection.kind,
+                    menuKind
+                }));
+            }
+        },
+    });
+
+const CollectionVersionBrowser = withStyles(styles)(
+    connect(vbMapStateToProps, vbMapDispatchToProps)(
+        ({ currentCollection, versions, showVersion, handleContextMenu, classes }: CollectionVersionBrowserProps & CollectionVersionBrowserDispatchProps & WithStyles<CssRules>) => {
+            return <div data-cy="collection-version-browser">
+                <Grid container>
+                    <Grid item xs={2}>
+                        <Typography variant="caption" className={classes.versionBrowserHeader}>
+                            Nr
+                        </Typography>
+                    </Grid>
+                    <Grid item xs={4}>
+                        <Typography variant="caption" className={classes.versionBrowserHeader}>
+                            Size
+                        </Typography>
+                    </Grid>
+                    <Grid item xs={6}>
+                        <Typography variant="caption" className={classes.versionBrowserHeader}>
+                            Date
+                        </Typography>
+                    </Grid>
+                { versions.map(item => {
+                    const isSelectedVersion = !!(currentCollection && currentCollection.uuid === item.uuid);
+                    return (
+                        <ListItem button style={{padding: '4px'}}
+                            data-cy={`collection-version-browser-select-${item.version}`}
+                            key={item.version}
+                            onClick={e => showVersion(item)}
+                            onContextMenu={event => handleContextMenu(event, item)}
+                            selected={isSelectedVersion}
+                            className={classes.versionBrowserItem}>
+                            <Grid item xs={2}>
+                                <Typography variant="caption" className={classes.versionBrowserField}>
+                                    {item.version}
+                                </Typography>
+                            </Grid>
+                            <Grid item xs={4}>
+                                <Typography variant="caption" className={classes.versionBrowserField}>
+                                    {formatFileSize(item.fileSizeTotal)}
+                                </Typography>
+                            </Grid>
+                            <Grid item xs={6}>
+                                <Typography variant="caption" className={classes.versionBrowserField}>
+                                    {formatDate(item.modifiedAt)}
+                                </Typography>
+                            </Grid>
+                            <Grid item xs={12}>
+                                <Typography variant="caption" className={classes.versionBrowserField}>
+                                    Modified by: <UserNameFromID uuid={item.modifiedByUserUuid} />
+                                </Typography>
+                            </Grid>
+                        </ListItem>
+                    );
+                })}
+                </Grid>
+            </div>;
+        }));
diff --git a/services/workbench2/src/views-components/details-panel/details-data.tsx b/services/workbench2/src/views-components/details-panel/details-data.tsx
new file mode 100644 (file)
index 0000000..bcca325
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { DetailsResource } from "models/details";
+
+interface GetDetailsParams {
+  tabNr?: number
+  showPreview?: boolean
+}
+
+export abstract class DetailsData<T extends DetailsResource = DetailsResource> {
+    constructor(protected item: T) { }
+
+    getTitle(): string {
+        return this.item.name || 'Projects';
+    }
+
+    getTabLabels(): string[] {
+        return ['Details'];
+    }
+
+    abstract getIcon(className?: string): React.ReactElement<any>;
+    abstract getDetails({tabNr, showPreview}: GetDetailsParams): React.ReactElement<any>;
+}
diff --git a/services/workbench2/src/views-components/details-panel/details-panel.tsx b/services/workbench2/src/views-components/details-panel/details-panel.tsx
new file mode 100644 (file)
index 0000000..2653a21
--- /dev/null
@@ -0,0 +1,222 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { IconButton, Tabs, Tab, Typography, Grid, Tooltip } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { Transition } from 'react-transition-group';
+import { ArvadosTheme } from 'common/custom-theme';
+import classnames from "classnames";
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import { CloseIcon } from 'components/icon/icon';
+import { EmptyResource } from 'models/empty';
+import { Dispatch } from "redux";
+import { ResourceKind } from "models/resource";
+import { ProjectDetails } from "./project-details";
+import { CollectionDetails } from "./collection-details";
+import { ProcessDetails } from "./process-details";
+import { EmptyDetails } from "./empty-details";
+import { WorkflowDetails } from "./workflow-details";
+import { DetailsData } from "./details-data";
+import { DetailsResource } from "models/details";
+import { Config } from 'common/config';
+import { isInlineFileUrlSafe } from "../context-menu/actions/helpers";
+import { getResource } from 'store/resources/resources';
+import { toggleDetailsPanel, SLIDE_TIMEOUT, openDetailsPanel } from 'store/details-panel/details-panel-action';
+import { FileDetails } from 'views-components/details-panel/file-details';
+import { getNode } from 'models/tree';
+import { resourceIsFrozen } from 'common/frozen-resources';
+
+type CssRules = 'root' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'tabContainer';
+
+const DRAWER_WIDTH = 320;
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        background: theme.palette.background.paper,
+        borderLeft: `1px solid ${theme.palette.divider}`,
+        height: '100%',
+        overflow: 'hidden',
+        transition: `width ${SLIDE_TIMEOUT}ms ease`,
+        width: 0,
+    },
+    opened: {
+        width: DRAWER_WIDTH,
+    },
+    container: {
+        maxWidth: 'none',
+        width: DRAWER_WIDTH,
+    },
+    headerContainer: {
+        color: theme.palette.grey["600"],
+        margin: `${theme.spacing.unit}px 0`,
+        textAlign: 'center',
+    },
+    headerIcon: {
+        fontSize: '2.125rem',
+    },
+    tabContainer: {
+        overflow: 'auto',
+        padding: theme.spacing.unit * 1,
+    },
+});
+
+const EMPTY_RESOURCE: EmptyResource = { kind: undefined, name: 'Projects' };
+
+const getItem = (res: DetailsResource): DetailsData => {
+    if ('kind' in res) {
+        switch (res.kind) {
+            case ResourceKind.PROJECT:
+                return new ProjectDetails(res);
+            case ResourceKind.COLLECTION:
+                return new CollectionDetails(res);
+            case ResourceKind.PROCESS:
+                return new ProcessDetails(res);
+            case ResourceKind.WORKFLOW:
+                return new WorkflowDetails(res);
+            default:
+                return new EmptyDetails(res);
+        }
+    } else {
+        return new FileDetails(res);
+    }
+};
+
+const mapStateToProps = ({ auth, detailsPanel, resources, collectionPanelFiles, multiselect, router }: RootState) => {
+    const isDetailsResourceChecked = multiselect.checkedList[detailsPanel.resourceUuid]
+    const currentRoute = router.location ? router.location.pathname : "";
+    const currentItemUuid = isDetailsResourceChecked || currentRoute.includes('collections') ? detailsPanel.resourceUuid : multiselect.selectedUuid ? multiselect.selectedUuid : currentRoute.split('/')[2];
+    const resource = getResource(currentItemUuid)(resources) as DetailsResource | undefined;
+    const file = resource
+        ? undefined
+        : getNode(detailsPanel.resourceUuid)(collectionPanelFiles);
+
+    let isFrozen = false;
+    if (resource) {
+        isFrozen = resourceIsFrozen(resource, resources);
+    }
+
+    return {
+        isFrozen,
+        authConfig: auth.config,
+        isOpened: detailsPanel.isOpened,
+        tabNr: detailsPanel.tabNr,
+        res: resource || (file && file.value) || EMPTY_RESOURCE,
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onCloseDrawer: () => {
+        dispatch<any>(toggleDetailsPanel());
+    },
+    setActiveTab: (tabNr: number) => {
+        dispatch<any>(openDetailsPanel(undefined, tabNr));
+    },
+});
+
+export interface DetailsPanelDataProps {
+    onCloseDrawer: () => void;
+    setActiveTab: (tabNr: number) => void;
+    authConfig: Config;
+    isOpened: boolean;
+    tabNr: number;
+    res: DetailsResource;
+    isFrozen: boolean;
+}
+
+type DetailsPanelProps = DetailsPanelDataProps & WithStyles<CssRules>;
+
+export const DetailsPanel = withStyles(styles)(
+    connect(mapStateToProps, mapDispatchToProps)(
+        class extends React.Component<DetailsPanelProps> {
+            shouldComponentUpdate(nextProps: DetailsPanelProps) {
+                if ('etag' in nextProps.res && 'etag' in this.props.res &&
+                    nextProps.res.etag === this.props.res.etag &&
+                    nextProps.isOpened === this.props.isOpened &&
+                    nextProps.tabNr === this.props.tabNr) {
+                    return false;
+                }
+                return true;
+            }
+
+            handleChange = (event: any, value: number) => {
+                this.props.setActiveTab(value);
+            }
+
+            render() {
+                const { classes, isOpened } = this.props;
+                return (
+                    <Grid
+                        container
+                        direction="column"
+                        className={classnames([classes.root, { [classes.opened]: isOpened }])}>
+                        <Transition
+                            in={isOpened}
+                            timeout={SLIDE_TIMEOUT}
+                            unmountOnExit>
+                            {isOpened ? this.renderContent() : <div />}
+                        </Transition>
+                    </Grid>
+                );
+            }
+
+            renderContent() {
+                const { classes, onCloseDrawer, res, tabNr, authConfig } = this.props;
+
+                let shouldShowInlinePreview = false;
+                if (!('kind' in res)) {
+                    shouldShowInlinePreview = isInlineFileUrlSafe(
+                        res ? res.url : "",
+                        authConfig.keepWebServiceUrl,
+                        authConfig.keepWebInlineServiceUrl
+                    ) || authConfig.clusterConfig.Collections.TrustAllContent;
+                }
+
+                const item = getItem(res);
+                return <Grid
+                    data-cy='details-panel'
+                    container
+                    direction="column"
+                    item
+                    xs
+                    className={classes.container} >
+                    <Grid
+                        item
+                        className={classes.headerContainer}
+                        container
+                        alignItems='center'
+                        justify='space-around'
+                        wrap="nowrap">
+                        <Grid item xs={2}>
+                            {item.getIcon(classes.headerIcon)}
+                        </Grid>
+                        <Grid item xs={8}>
+                            <Tooltip title={item.getTitle()}>
+                                <Typography variant='h6' noWrap>
+                                    {item.getTitle()}
+                                </Typography>
+                            </Tooltip>
+                        </Grid>
+                        <Grid item>
+                            <IconButton color="inherit" onClick={onCloseDrawer}>
+                                <CloseIcon />
+                            </IconButton>
+                        </Grid>
+                    </Grid>
+                    <Grid item>
+                        <Tabs onChange={this.handleChange}
+                            value={(item.getTabLabels().length >= tabNr + 1) ? tabNr : 0}>
+                            {item.getTabLabels().map((tabLabel, idx) =>
+                                <Tab key={`tab-label-${idx}`} disableRipple label={tabLabel} />)
+                            }
+                        </Tabs>
+                    </Grid>
+                    <Grid item xs className={this.props.classes.tabContainer} >
+                        {item.getDetails({ tabNr, showPreview: shouldShowInlinePreview })}
+                    </Grid>
+                </Grid >;
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/views-components/details-panel/empty-details.tsx b/services/workbench2/src/views-components/details-panel/empty-details.tsx
new file mode 100644 (file)
index 0000000..d5430f2
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { DefaultIcon, ProjectsIcon } from 'components/icon/icon';
+import { EmptyResource } from 'models/empty';
+import { DetailsData } from "./details-data";
+import { DefaultView } from 'components/default-view/default-view';
+
+export class EmptyDetails extends DetailsData<EmptyResource> {
+    getIcon(className?: string) {
+        return <ProjectsIcon className={className}/>;
+    }
+
+    getDetails() {
+        return <DefaultView icon={DefaultIcon} messages={['Select a file or folder to view its details.']} />;
+    }
+}
diff --git a/services/workbench2/src/views-components/details-panel/file-details.tsx b/services/workbench2/src/views-components/details-panel/file-details.tsx
new file mode 100644 (file)
index 0000000..7b128c2
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { DetailsData } from "./details-data";
+import { CollectionFile, CollectionDirectory, CollectionFileType } from 'models/collection-file';
+import { getIcon } from 'components/file-tree/file-tree-item';
+import { DetailsAttribute } from 'components/details-attribute/details-attribute';
+import { formatFileSize } from 'common/formatters';
+import { FileThumbnail } from 'components/file-tree/file-thumbnail';
+import isImage from 'is-image';
+
+export class FileDetails extends DetailsData<CollectionFile | CollectionDirectory> {
+
+    getIcon(className?: string) {
+        const Icon = getIcon(this.item.type);
+        return <Icon className={className} />;
+    }
+
+    getDetails({showPreview}) {
+        const { item } = this;
+        return item.type === CollectionFileType.FILE
+            ? <>
+                <DetailsAttribute label='Size' value={formatFileSize(item.size)} />
+                {
+                    isImage(item.url) && showPreview && <>
+                        <DetailsAttribute label='Preview' />
+                        <FileThumbnail file={item} />
+                    </>
+                }
+            </>
+            : <div />;
+    }
+}
diff --git a/services/workbench2/src/views-components/details-panel/process-details.tsx b/services/workbench2/src/views-components/details-panel/process-details.tsx
new file mode 100644 (file)
index 0000000..bb0e8a4
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { ProcessIcon } from 'components/icon/icon';
+import { ProcessResource } from 'models/process';
+import { DetailsData } from "./details-data";
+import { ProcessDetailsAttributes } from 'views/process-panel/process-details-attributes';
+
+export class ProcessDetails extends DetailsData<ProcessResource> {
+
+    getIcon(className?: string) {
+        return <ProcessIcon className={className} />;
+    }
+
+    getDetails() {
+        return <ProcessDetailsAttributes request={this.item} />;
+    }
+}
diff --git a/services/workbench2/src/views-components/details-panel/project-details.tsx b/services/workbench2/src/views-components/details-panel/project-details.tsx
new file mode 100644 (file)
index 0000000..7dc6709
--- /dev/null
@@ -0,0 +1,120 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { ProjectIcon, RenameIcon, FilterGroupIcon } from 'components/icon/icon';
+import { ProjectResource } from 'models/project';
+import { formatDate } from 'common/formatters';
+import { ResourceKind } from 'models/resource';
+import { resourceLabel } from 'common/labels';
+import { DetailsData } from "./details-data";
+import { DetailsAttribute } from "components/details-attribute/details-attribute";
+import { RichTextEditorLink } from 'components/rich-text-editor-link/rich-text-editor-link';
+import { withStyles, StyleRulesCallback, WithStyles, Button } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { Dispatch } from 'redux';
+import { getPropertyChip } from '../resource-properties-form/property-chip';
+import { ResourceWithName } from '../data-explorer/renderers';
+import { GroupClass } from "models/group";
+import { openProjectUpdateDialog, ProjectUpdateFormDialogData } from 'store/projects/project-update-actions';
+import { RootState } from 'store/store';
+import { ResourcesState } from 'store/resources/resources';
+import { resourceIsFrozen } from 'common/frozen-resources';
+
+export class ProjectDetails extends DetailsData<ProjectResource> {
+    getIcon(className?: string) {
+        if (this.item.groupClass === GroupClass.FILTER) {
+            return <FilterGroupIcon className={className} />;
+        }
+        return <ProjectIcon className={className} />;
+    }
+
+    getDetails() {
+        return <ProjectDetailsComponent project={this.item} />;
+    }
+}
+
+type CssRules = 'tag' | 'editIcon' | 'editButton';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    tag: {
+        marginRight: theme.spacing.unit / 2,
+        marginBottom: theme.spacing.unit / 2,
+    },
+    editIcon: {
+        paddingRight: theme.spacing.unit / 2,
+        fontSize: '1.125rem',
+    },
+    editButton: {
+        boxShadow: 'none',
+        padding: '2px 10px 2px 5px',
+        fontSize: '0.75rem'
+    },
+});
+
+interface ProjectDetailsComponentDataProps {
+    project: ProjectResource;
+}
+
+interface ProjectDetailsComponentActionProps {
+    onClick: (prj: ProjectUpdateFormDialogData) => () => void;
+}
+
+const mapStateToProps = (state: RootState): { resources: ResourcesState } => {
+    return {
+        resources: state.resources
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onClick: (prj: ProjectUpdateFormDialogData) =>
+        () => dispatch<any>(openProjectUpdateDialog(prj)),
+});
+
+type ProjectDetailsComponentProps = ProjectDetailsComponentDataProps & ProjectDetailsComponentActionProps & WithStyles<CssRules>;
+
+const ProjectDetailsComponent = connect(mapStateToProps, mapDispatchToProps)(
+    withStyles(styles)(
+        ({ classes, project, resources, onClick }: ProjectDetailsComponentProps & { resources: ResourcesState }) => <div>
+            {project.groupClass !== GroupClass.FILTER ?
+                <Button onClick={onClick({
+                    uuid: project.uuid,
+                    name: project.name,
+                    description: project.description,
+                    properties: project.properties,
+                })}
+                    disabled={resourceIsFrozen(project, resources)}
+                    className={classes.editButton} variant='contained'
+                    data-cy='details-panel-edit-btn' color='primary' size='small'>
+                    <RenameIcon className={classes.editIcon} /> Edit
+                </Button>
+                : ''
+            }
+            <DetailsAttribute label='Type' value={project.groupClass === GroupClass.FILTER ? 'Filter group' : resourceLabel(ResourceKind.PROJECT)} />
+            <DetailsAttribute label='Owner' linkToUuid={project.ownerUuid}
+                uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+            <DetailsAttribute label='Last modified' value={formatDate(project.modifiedAt)} />
+            <DetailsAttribute label='Created at' value={formatDate(project.createdAt)} />
+            <DetailsAttribute label='UUID' linkToUuid={project.uuid} value={project.uuid} />
+            <DetailsAttribute label='Description'>
+                {project.description ?
+                    <RichTextEditorLink
+                        title={`Description of ${project.name}`}
+                        content={project.description}
+                        label='Show full description' />
+                    : '---'
+                }
+            </DetailsAttribute>
+            <DetailsAttribute label='Properties' />
+            {
+                Object.keys(project.properties).map(k =>
+                    Array.isArray(project.properties[k])
+                        ? project.properties[k].map((v: string) =>
+                            getPropertyChip(k, v, undefined, classes.tag))
+                        : getPropertyChip(k, project.properties[k], undefined, classes.tag)
+                )
+            }
+        </div>
+    ));
diff --git a/services/workbench2/src/views-components/details-panel/workflow-details.tsx b/services/workbench2/src/views-components/details-panel/workflow-details.tsx
new file mode 100644 (file)
index 0000000..ca224b1
--- /dev/null
@@ -0,0 +1,172 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WorkflowIcon, StartIcon } from 'components/icon/icon';
+import {
+    WorkflowResource, parseWorkflowDefinition, getWorkflowInputs,
+    getWorkflowOutputs, getWorkflow
+} from 'models/workflow';
+import { DetailsData } from "./details-data";
+import { DetailsAttribute } from 'components/details-attribute/details-attribute';
+import { ResourceWithName } from 'views-components/data-explorer/renderers';
+import { formatDate } from "common/formatters";
+import { Grid } from '@material-ui/core';
+import { withStyles, StyleRulesCallback, WithStyles, Button } from '@material-ui/core';
+import { openRunProcess } from "store/workflow-panel/workflow-panel-actions";
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ProcessIOParameter } from 'views/process-panel/process-io-card';
+import { formatInputData, formatOutputData } from 'store/process-panel/process-panel-actions';
+import { AuthState } from 'store/auth/auth-reducer';
+import { RootState } from 'store/store';
+import { getPropertyChip } from "views-components/resource-properties-form/property-chip";
+
+export interface WorkflowDetailsCardDataProps {
+    workflow?: WorkflowResource;
+}
+
+export interface WorkflowDetailsCardActionProps {
+    onClick: (wf: WorkflowResource) => () => void;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onClick: (wf: WorkflowResource) =>
+        () => wf && dispatch<any>(openRunProcess(wf.uuid, wf.ownerUuid, wf.name)),
+});
+
+type CssRules = 'runButton' | 'propertyTag';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    runButton: {
+        backgroundColor: theme.customs.colors.green700,
+        '&:hover': {
+            backgroundColor: theme.customs.colors.green800,
+        },
+        marginRight: "5px",
+        boxShadow: 'none',
+        padding: '2px 10px 2px 5px',
+        marginLeft: 'auto'
+    },
+    propertyTag: {
+        marginRight: theme.spacing.unit / 2,
+        marginBottom: theme.spacing.unit / 2
+    },
+});
+
+interface AuthStateDataProps {
+    auth: AuthState;
+};
+
+export interface RegisteredWorkflowPanelDataProps {
+    item: WorkflowResource;
+    workflowCollection: string;
+    inputParams: ProcessIOParameter[];
+    outputParams: ProcessIOParameter[];
+    gitprops: { [key: string]: string; };
+};
+
+export const getRegisteredWorkflowPanelData = (item: WorkflowResource, auth: AuthState): RegisteredWorkflowPanelDataProps => {
+    let inputParams: ProcessIOParameter[] = [];
+    let outputParams: ProcessIOParameter[] = [];
+    let workflowCollection = "";
+    const gitprops: { [key: string]: string; } = {};
+
+    // parse definition
+    const wfdef = parseWorkflowDefinition(item);
+
+    if (wfdef) {
+        const inputs = getWorkflowInputs(wfdef);
+        if (inputs) {
+            inputs.forEach(elm => {
+                if (elm.default !== undefined && elm.default !== null) {
+                    elm.value = elm.default;
+                }
+            });
+            inputParams = formatInputData(inputs, auth);
+        }
+
+        const outputs = getWorkflowOutputs(wfdef);
+        if (outputs) {
+            outputParams = formatOutputData(outputs, {}, undefined, auth);
+        }
+
+        const wf = getWorkflow(wfdef);
+        if (wf) {
+            const REGEX = /keep:([0-9a-f]{32}\+\d+)\/.*/;
+            if (wf["steps"]) {
+                const pdh = wf["steps"][0].run.match(REGEX);
+                if (pdh) {
+                    workflowCollection = pdh[1];
+                }
+            }
+        }
+
+        for (const elm in wfdef) {
+            if (elm.startsWith("http://arvados.org/cwl#git")) {
+                gitprops[elm.substr(23)] = wfdef[elm]
+            }
+        }
+    }
+
+    return { item, workflowCollection, inputParams, outputParams, gitprops };
+};
+
+const mapStateToProps = (state: RootState): AuthStateDataProps => {
+    return { auth: state.auth };
+};
+
+export const WorkflowDetailsAttributes = connect(mapStateToProps, mapDispatchToProps)(
+    withStyles(styles)(
+        ({ workflow, onClick, auth, classes }: WorkflowDetailsCardDataProps & AuthStateDataProps & WorkflowDetailsCardActionProps & WithStyles<CssRules>) => {
+            if (!workflow) {
+                return <Grid />
+            }
+
+            const data = getRegisteredWorkflowPanelData(workflow, auth);
+            return <Grid container>
+                <Button onClick={workflow && onClick(workflow)} className={classes.runButton} variant='contained'
+                    data-cy='workflow-details-panel-run-btn' color='primary' size='small'>
+                    <StartIcon />
+                    Run Workflow
+                </Button>
+                <Grid item xs={12} >
+                    <DetailsAttribute
+                        label={"Workflow UUID"}
+                        linkToUuid={workflow?.uuid} />
+                </Grid>
+                <Grid item xs={12} >
+                    <DetailsAttribute
+                        label='Owner' linkToUuid={workflow?.ownerUuid}
+                        uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+                </Grid>
+                <Grid item xs={12}>
+                    <DetailsAttribute label='Created at' value={formatDate(workflow?.createdAt)} />
+                </Grid>
+                <Grid item xs={12}>
+                    <DetailsAttribute label='Last modified' value={formatDate(workflow?.modifiedAt)} />
+                </Grid>
+                <Grid item xs={12} data-cy="workflow-details-attributes-modifiedby-user">
+                    <DetailsAttribute
+                        label='Last modified by user' linkToUuid={workflow?.modifiedByUserUuid}
+                        uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+                </Grid>
+                <Grid item xs={12} md={12}>
+                    <DetailsAttribute label='Properties' />
+                    {Object.keys(data.gitprops).map(k =>
+                        getPropertyChip(k, data.gitprops[k], undefined, classes.propertyTag))}
+                </Grid>
+            </Grid >;
+        }));
+
+export class WorkflowDetails extends DetailsData<WorkflowResource> {
+    getIcon(className?: string) {
+        return <WorkflowIcon className={className} />;
+    }
+
+    getDetails() {
+        return <WorkflowDetailsAttributes workflow={this.item} />;
+    }
+}
diff --git a/services/workbench2/src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx b/services/workbench2/src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx
new file mode 100644 (file)
index 0000000..eb95d1f
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialCopyToExistingCollectionFormData } from 'store/collections/collection-partial-copy-actions';
+import { PickerIdProp } from "store/tree-picker/picker-id";
+import { DirectoryPickerField } from 'views-components/form-fields/collection-form-fields';
+
+type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyToExistingCollectionFormData>;
+
+export const DialogCollectionPartialCopyToExistingCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Copy to existing collection'
+        formFields={CollectionPartialCopyFields(props.pickerId)}
+        submitLabel='Copy files'
+        enableWhenPristine
+        {...props}
+    />;
+
+const CollectionPartialCopyFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <DirectoryPickerField {...{ pickerId }}/>
+            </>);
diff --git a/services/workbench2/src/views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection.tsx b/services/workbench2/src/views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection.tsx
new file mode 100644 (file)
index 0000000..6b5a775
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { CollectionNameField, CollectionDescriptionField, CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialCopyToNewCollectionFormData } from 'store/collections/collection-partial-copy-actions';
+import { PickerIdProp } from "store/tree-picker/picker-id";
+
+type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyToNewCollectionFormData>;
+
+export const DialogCollectionPartialCopyToNewCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Copy to new collection'
+        formFields={CollectionPartialCopyFields(props.pickerId)}
+        submitLabel='Create collection'
+        {...props}
+    />;
+
+const CollectionPartialCopyFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <CollectionNameField />
+                <CollectionDescriptionField />
+                <CollectionProjectPickerField {...{ pickerId }} />
+            </>);
diff --git a/services/workbench2/src/views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections.tsx b/services/workbench2/src/views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections.tsx
new file mode 100644 (file)
index 0000000..32f706a
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialCopyToSeparateCollectionsFormData } from 'store/collections/collection-partial-copy-actions';
+import { PickerIdProp } from "store/tree-picker/picker-id";
+
+type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyToSeparateCollectionsFormData>;
+
+export const DialogCollectionPartialCopyToSeparateCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Copy to separate collections'
+        formFields={CollectionPartialCopyFields(props.pickerId)}
+        submitLabel='Create collections'
+        {...props}
+    />;
+
+const CollectionPartialCopyFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <CollectionProjectPickerField {...{ pickerId }} />
+            </>);
diff --git a/services/workbench2/src/views-components/dialog-copy/dialog-copy.tsx b/services/workbench2/src/views-components/dialog-copy/dialog-copy.tsx
new file mode 100644 (file)
index 0000000..71d0dab
--- /dev/null
@@ -0,0 +1,64 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { InjectedFormProps, Field } from "redux-form";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { FormDialog } from "components/form-dialog/form-dialog";
+import { ProjectTreePickerField } from "views-components/projects-tree-picker/tree-picker-field";
+import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from "validators/validators";
+import { TextField } from "components/text-field/text-field";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { PickerIdProp } from "store/tree-picker/picker-id";
+
+type CopyFormDialogProps = WithDialogProps<string> & InjectedFormProps<CopyFormDialogData>;
+
+export const DialogCopy = (props: CopyFormDialogProps & PickerIdProp) => {
+    return (
+        <FormDialog
+            dialogTitle="Make a copy"
+            formFields={CopyDialogFields(props.pickerId)}
+            submitLabel="Copy"
+            {...props}
+        />
+    );
+};
+
+const CopyDialogFields = memoize((pickerId: string) => () => (
+    <>
+        <Field
+            name="name"
+            component={TextField as any}
+            validate={COPY_NAME_VALIDATION}
+            label="Enter a new name for the copy"
+        />
+        <Field
+            name="ownerUuid"
+            component={ProjectTreePickerField}
+            validate={COPY_FILE_VALIDATION}
+            pickerId={pickerId}
+        />
+    </>
+));
+
+export const DialogMultiCopy = (props: CopyFormDialogProps & PickerIdProp) => {
+    return (
+        <FormDialog
+            dialogTitle="Make Copies"
+            formFields={CopyMultiDialogFields(props.pickerId)}
+            submitLabel="Copy"
+            {...props}
+        />
+    );
+};
+
+const CopyMultiDialogFields = memoize((pickerId: string) => () => (
+    <Field
+        name="ownerUuid"
+        component={ProjectTreePickerField}
+        validate={COPY_FILE_VALIDATION}
+        pickerId={pickerId}
+    />
+));
diff --git a/services/workbench2/src/views-components/dialog-copy/dialog-process-rerun.tsx b/services/workbench2/src/views-components/dialog-copy/dialog-process-rerun.tsx
new file mode 100644 (file)
index 0000000..a5d8f3a
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { memoize } from 'lodash/fp';
+import { InjectedFormProps, Field } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { ProjectTreePickerField } from 'views-components/projects-tree-picker/tree-picker-field';
+import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from 'validators/validators';
+import { TextField } from 'components/text-field/text-field';
+import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
+import { PickerIdProp } from 'store/tree-picker/picker-id';
+
+type ProcessRerunFormDialogProps = WithDialogProps<string> & InjectedFormProps<CopyFormDialogData>;
+
+export const DialogProcessRerun = (props: ProcessRerunFormDialogProps & PickerIdProp) => (
+    <FormDialog dialogTitle='Choose location for re-run' formFields={CopyDialogFields(props.pickerId)} submitLabel='Copy' {...props} />
+);
+
+const CopyDialogFields = memoize((pickerId: string) => () => (
+    <>
+        <Field name='name' component={TextField as any} validate={COPY_NAME_VALIDATION} label='Enter a new name for the copy' />
+        <Field name='ownerUuid' component={ProjectTreePickerField} validate={COPY_FILE_VALIDATION} pickerId={pickerId} />
+    </>
+));
diff --git a/services/workbench2/src/views-components/dialog-create/dialog-collection-create.tsx b/services/workbench2/src/views-components/dialog-create/dialog-collection-create.tsx
new file mode 100644 (file)
index 0000000..17a24e4
--- /dev/null
@@ -0,0 +1,60 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps, Field } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { CollectionCreateFormDialogData, COLLECTION_CREATE_FORM_NAME } from 'store/collections/collection-create-actions';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import {
+    CollectionNameField,
+    CollectionDescriptionField,
+    CollectionStorageClassesField
+} from 'views-components/form-fields/collection-form-fields';
+import { FileUploaderField } from '../file-uploader/file-uploader';
+import { ResourceParentField } from '../form-fields/resource-form-fields';
+import { CreateCollectionPropertiesForm } from 'views-components/collection-properties/create-collection-properties-form';
+import { FormGroup, FormLabel, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { resourcePropertiesList } from 'views-components/resource-properties/resource-properties-list';
+
+type CssRules = 'propertiesForm';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    propertiesForm: {
+        marginTop: theme.spacing.unit * 2,
+        marginBottom: theme.spacing.unit * 2,
+    },
+});
+
+type DialogCollectionProps = WithDialogProps<{}> & InjectedFormProps<CollectionCreateFormDialogData>;
+
+export const DialogCollectionCreate = (props: DialogCollectionProps) =>
+    <FormDialog
+        dialogTitle='New collection'
+        formFields={CollectionAddFields as any}
+        submitLabel='Create a Collection'
+        {...props}
+    />;
+
+const CreateCollectionPropertiesList = resourcePropertiesList(COLLECTION_CREATE_FORM_NAME);
+
+const CollectionAddFields = withStyles(styles)(
+    ({ classes }: WithStyles<CssRules>) => <span>
+        <ResourceParentField />
+        <CollectionNameField />
+        <CollectionDescriptionField />
+        <div className={classes.propertiesForm}>
+            <FormLabel>Properties</FormLabel>
+            <FormGroup>
+                <CreateCollectionPropertiesForm />
+                <CreateCollectionPropertiesList />
+            </FormGroup>
+        </div>
+        <CollectionStorageClassesField defaultClasses={['default']} />
+        <Field
+            name='files'
+            label='Files'
+            component={FileUploaderField} />
+    </span>);
+
diff --git a/services/workbench2/src/views-components/dialog-create/dialog-project-create.tsx b/services/workbench2/src/views-components/dialog-create/dialog-project-create.tsx
new file mode 100644 (file)
index 0000000..d85a304
--- /dev/null
@@ -0,0 +1,82 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { ProjectCreateFormDialogData, PROJECT_CREATE_FORM_NAME } from 'store/projects/project-create-actions';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { ProjectNameField, ProjectDescriptionField, UsersField } from 'views-components/form-fields/project-form-fields';
+import { CreateProjectPropertiesForm } from 'views-components/project-properties/create-project-properties-form';
+import { ResourceParentField } from '../form-fields/resource-form-fields';
+import { FormGroup, FormLabel, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { resourcePropertiesList } from 'views-components/resource-properties/resource-properties-list';
+import { GroupClass } from 'models/group';
+
+type CssRules = 'propertiesForm' | 'description';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    propertiesForm: {
+        marginTop: theme.spacing.unit * 2,
+        marginBottom: theme.spacing.unit * 2,
+    },
+    description: {
+        marginTop: theme.spacing.unit * 2,
+        marginBottom: theme.spacing.unit * 2,
+    },
+});
+
+type DialogProjectProps = WithDialogProps<{sourcePanel: GroupClass}> & InjectedFormProps<ProjectCreateFormDialogData>;
+
+export const DialogProjectCreate = (props: DialogProjectProps) => {
+    let title = 'New Project';
+    let fields = ProjectAddFields;
+    const sourcePanel = props.data.sourcePanel || '';
+
+    if (sourcePanel === GroupClass.ROLE) {
+        title = 'New Group';
+        fields = GroupAddFields;
+    }
+
+    return <FormDialog
+        dialogTitle={title}
+        formFields={fields as any}
+        submitLabel='Create'
+        {...props}
+    />;
+};
+
+const CreateProjectPropertiesList = resourcePropertiesList(PROJECT_CREATE_FORM_NAME);
+
+const ProjectAddFields = withStyles(styles)(
+    ({ classes }: WithStyles<CssRules>) => <span>
+        <ResourceParentField />
+        <ProjectNameField />
+        <div className={classes.description}>
+            <ProjectDescriptionField />
+        </div>
+        <div className={classes.propertiesForm}>
+            <FormLabel>Properties</FormLabel>
+            <FormGroup>
+                <CreateProjectPropertiesForm />
+                <CreateProjectPropertiesList />
+            </FormGroup>
+        </div>
+    </span>);
+
+const GroupAddFields = withStyles(styles)(
+    ({ classes }: WithStyles<CssRules>) => <span>
+        <ProjectNameField />
+        <UsersField />
+        <div className={classes.description}>
+            <ProjectDescriptionField />
+        </div>
+        <div className={classes.propertiesForm}>
+            <FormLabel>Properties</FormLabel>
+            <FormGroup>
+                <CreateProjectPropertiesForm />
+                <CreateProjectPropertiesList />
+            </FormGroup>
+        </div>
+    </span>);
diff --git a/services/workbench2/src/views-components/dialog-create/dialog-repository-create.tsx b/services/workbench2/src/views-components/dialog-create/dialog-repository-create.tsx
new file mode 100644 (file)
index 0000000..46a33f4
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { RepositoryNameField } from 'views-components/form-fields/repository-form-fields';
+
+type DialogRepositoryProps = WithDialogProps<{}> & InjectedFormProps<any>;
+
+export const DialogRepositoryCreate = (props: DialogRepositoryProps) =>
+    <FormDialog
+        dialogTitle='Add new repository'
+        formFields={RepositoryNameField}
+        submitLabel='CREATE REPOSITORY'
+        {...props}
+    />;
+
+
diff --git a/services/workbench2/src/views-components/dialog-create/dialog-ssh-key-create.tsx b/services/workbench2/src/views-components/dialog-create/dialog-ssh-key-create.tsx
new file mode 100644 (file)
index 0000000..6337f0a
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { SshKeyPublicField, SshKeyNameField } from 'views-components/form-fields/ssh-key-form-fields';
+import { SshKeyCreateFormDialogData } from 'store/auth/auth-action-ssh';
+
+type DialogSshKeyProps = WithDialogProps<{}> & InjectedFormProps<SshKeyCreateFormDialogData>;
+
+export const DialogSshKeyCreate = (props: DialogSshKeyProps) =>
+    <FormDialog
+        dialogTitle='Add new SSH key'
+        formFields={SshKeyAddFields}
+        submitLabel='Add new ssh key'
+        {...props}
+    />;
+
+const SshKeyAddFields = () => <span>
+    <SshKeyPublicField />
+    <SshKeyNameField />
+</span>;
diff --git a/services/workbench2/src/views-components/dialog-create/dialog-user-create.tsx b/services/workbench2/src/views-components/dialog-create/dialog-user-create.tsx
new file mode 100644 (file)
index 0000000..6be7b28
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { UserEmailField, UserVirtualMachineField, UserGroupsVirtualMachineField } from 'views-components/form-fields/user-form-fields';
+import { UserCreateFormDialogData } from 'store/users/users-actions';
+import { UserResource } from 'models/user';
+import { VirtualMachinesResource } from 'models/virtual-machines';
+
+export type DialogUserProps = WithDialogProps<{}> & InjectedFormProps<UserCreateFormDialogData>;
+
+interface DataProps {
+    user: UserResource;
+    items: VirtualMachinesResource[];
+}
+
+export const UserRepositoryCreate = (props: DialogUserProps) =>
+    <FormDialog
+        dialogTitle='New user'
+        formFields={UserAddFields}
+        submitLabel='ADD NEW USER'
+        {...props}
+    />;
+
+const UserAddFields = (props: DialogUserProps) => <span>
+    <UserEmailField />
+    <UserVirtualMachineField data={props.data as DataProps}/>
+    <UserGroupsVirtualMachineField />
+</span>;
diff --git a/services/workbench2/src/views-components/dialog-forms/copy-collection-dialog.ts b/services/workbench2/src/views-components/dialog-forms/copy-collection-dialog.ts
new file mode 100644 (file)
index 0000000..220b5a2
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { withDialog } from "store/dialog/with-dialog";
+import { reduxForm } from "redux-form";
+import { COLLECTION_COPY_FORM_NAME, COLLECTION_MULTI_COPY_FORM_NAME } from "store/collections/collection-copy-actions";
+import { DialogCopy, DialogMultiCopy } from "views-components/dialog-copy/dialog-copy";
+import { copyCollection } from "store/workbench/workbench-actions";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const CopyCollectionDialog = compose(
+    withDialog(COLLECTION_COPY_FORM_NAME),
+    reduxForm<CopyFormDialogData>({
+        form: COLLECTION_COPY_FORM_NAME,
+        touchOnChange: true,
+        onSubmit: (data, dispatch) => {
+            dispatch(copyCollection(data));
+        },
+    }),
+    pickerId(COLLECTION_COPY_FORM_NAME)
+)(DialogCopy);
+
+export const CopyMultiCollectionDialog = compose(
+    withDialog(COLLECTION_MULTI_COPY_FORM_NAME),
+    reduxForm<CopyFormDialogData>({
+        form: COLLECTION_MULTI_COPY_FORM_NAME,
+        touchOnChange: true,
+        onSubmit: (data, dispatch) => {
+            dispatch(copyCollection(data));
+        },
+    }),
+    pickerId(COLLECTION_MULTI_COPY_FORM_NAME)
+)(DialogMultiCopy);
diff --git a/services/workbench2/src/views-components/dialog-forms/copy-process-dialog.ts b/services/workbench2/src/views-components/dialog-forms/copy-process-dialog.ts
new file mode 100644 (file)
index 0000000..8afa58d
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from 'redux';
+import { withDialog } from 'store/dialog/with-dialog';
+import { reduxForm } from 'redux-form';
+import { PROCESS_COPY_FORM_NAME } from 'store/processes/process-copy-actions';
+import { DialogProcessRerun } from 'views-components/dialog-copy/dialog-process-rerun';
+import { copyProcess } from 'store/workbench/workbench-actions';
+import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
+import { pickerId } from 'store/tree-picker/picker-id';
+
+export const CopyProcessDialog = compose(
+    withDialog(PROCESS_COPY_FORM_NAME),
+    reduxForm<CopyFormDialogData>({
+        form: PROCESS_COPY_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(copyProcess(data));
+        },
+    }),
+    pickerId(PROCESS_COPY_FORM_NAME)
+)(DialogProcessRerun);
diff --git a/services/workbench2/src/views-components/dialog-forms/create-collection-dialog.ts b/services/workbench2/src/views-components/dialog-forms/create-collection-dialog.ts
new file mode 100644 (file)
index 0000000..d989d43
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "store/dialog/with-dialog";
+import { COLLECTION_CREATE_FORM_NAME, CollectionCreateFormDialogData } from 'store/collections/collection-create-actions';
+import { DialogCollectionCreate } from "views-components/dialog-create/dialog-collection-create";
+import { createCollection } from "store/workbench/workbench-actions";
+
+export const CreateCollectionDialog = compose(
+    withDialog(COLLECTION_CREATE_FORM_NAME),
+    reduxForm<CollectionCreateFormDialogData>({
+        form: COLLECTION_CREATE_FORM_NAME,
+        touchOnChange: true,
+        onSubmit: (data, dispatch) => {
+            // Somehow an extra field called 'files' gets added, copy
+            // the data object to get rid of it.
+            dispatch(createCollection({
+                ownerUuid: data.ownerUuid,
+                name: data.name,
+                description: data.description,
+                storageClassesDesired: data.storageClassesDesired,
+                properties: data.properties,
+            }));
+        }
+    })
+)(DialogCollectionCreate);
diff --git a/services/workbench2/src/views-components/dialog-forms/create-project-dialog.ts b/services/workbench2/src/views-components/dialog-forms/create-project-dialog.ts
new file mode 100644 (file)
index 0000000..5c30281
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "store/dialog/with-dialog";
+import { PROJECT_CREATE_FORM_NAME, ProjectCreateFormDialogData } from 'store/projects/project-create-actions';
+import { DialogProjectCreate } from 'views-components/dialog-create/dialog-project-create';
+import { createProject } from "store/workbench/workbench-actions";
+import { GroupClass } from "models/group";
+import { createGroup } from "store/groups-panel/groups-panel-actions";
+
+export const CreateProjectDialog = compose(
+    withDialog(PROJECT_CREATE_FORM_NAME),
+    reduxForm<ProjectCreateFormDialogData>({
+        form: PROJECT_CREATE_FORM_NAME,
+        onSubmit: (data, dispatch, props) => {
+            switch (props.data.sourcePanel) {
+                case GroupClass.PROJECT:
+                    dispatch(createProject(data));
+                    break;
+                case GroupClass.ROLE:
+                    dispatch(createGroup(data));
+                    break;
+                default:
+                    break;
+            }
+        }
+    })
+)(DialogProjectCreate);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/dialog-forms/create-repository-dialog.ts b/services/workbench2/src/views-components/dialog-forms/create-repository-dialog.ts
new file mode 100644 (file)
index 0000000..bce3392
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "store/dialog/with-dialog";
+import { createRepository, REPOSITORY_CREATE_FORM_NAME } from "store/repositories/repositories-actions";
+import { DialogRepositoryCreate } from "views-components/dialog-create/dialog-repository-create";
+
+export const CreateRepositoryDialog = compose(
+    withDialog(REPOSITORY_CREATE_FORM_NAME),
+    reduxForm<any>({
+        form: REPOSITORY_CREATE_FORM_NAME,
+        onSubmit: (repositoryName, dispatch) => {
+            dispatch(createRepository(repositoryName));
+        }
+    })
+)(DialogRepositoryCreate);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/dialog-forms/create-ssh-key-dialog.ts b/services/workbench2/src/views-components/dialog-forms/create-ssh-key-dialog.ts
new file mode 100644 (file)
index 0000000..d947ead
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "store/dialog/with-dialog";
+import {
+    SSH_KEY_CREATE_FORM_NAME,
+    createSshKey,
+    SshKeyCreateFormDialogData
+} from 'store/auth/auth-action-ssh';
+import { DialogSshKeyCreate } from 'views-components/dialog-create/dialog-ssh-key-create';
+
+export const CreateSshKeyDialog = compose(
+    withDialog(SSH_KEY_CREATE_FORM_NAME),
+    reduxForm<SshKeyCreateFormDialogData>({
+        form: SSH_KEY_CREATE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(createSshKey(data));
+        }
+    })
+)(DialogSshKeyCreate);
diff --git a/services/workbench2/src/views-components/dialog-forms/create-user-dialog.ts b/services/workbench2/src/views-components/dialog-forms/create-user-dialog.ts
new file mode 100644 (file)
index 0000000..d4d11be
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "store/dialog/with-dialog";
+import { USER_CREATE_FORM_NAME, createUser, UserCreateFormDialogData } from "store/users/users-actions";
+import { UserRepositoryCreate } from "views-components/dialog-create/dialog-user-create";
+
+export const CreateUserDialog = compose(
+    withDialog(USER_CREATE_FORM_NAME),
+    reduxForm<UserCreateFormDialogData>({
+        form: USER_CREATE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(createUser(data));
+        }
+    })
+)(UserRepositoryCreate);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/dialog-forms/files-upload-collection-dialog.ts b/services/workbench2/src/views-components/dialog-forms/files-upload-collection-dialog.ts
new file mode 100644 (file)
index 0000000..81dbb0e
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "store/dialog/with-dialog";
+import { CollectionCreateFormDialogData } from 'store/collections/collection-create-actions';
+import { COLLECTION_UPLOAD_FILES_DIALOG, submitCollectionFiles } from 'store/collections/collection-upload-actions';
+import { DialogCollectionFilesUpload } from 'views-components/dialog-upload/dialog-collection-files-upload';
+
+export const FilesUploadCollectionDialog = compose(
+    withDialog(COLLECTION_UPLOAD_FILES_DIALOG),
+    reduxForm<CollectionCreateFormDialogData>({
+        form: COLLECTION_UPLOAD_FILES_DIALOG,
+        onSubmit: (data, dispatch, dialog: any) => {
+            const targetLocation = (dialog.data || {}).targetLocation;
+            dispatch(submitCollectionFiles(targetLocation));
+        }
+    })
+)(DialogCollectionFilesUpload);
diff --git a/services/workbench2/src/views-components/dialog-forms/move-collection-dialog.ts b/services/workbench2/src/views-components/dialog-forms/move-collection-dialog.ts
new file mode 100644 (file)
index 0000000..14cecad
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { withDialog } from "store/dialog/with-dialog";
+import { reduxForm } from 'redux-form';
+import { DialogMoveTo } from 'views-components/dialog-move/dialog-move-to';
+import { COLLECTION_MOVE_FORM_NAME } from 'store/collections/collection-move-actions';
+import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
+import { moveCollection } from 'store/workbench/workbench-actions';
+import { pickerId } from 'store/tree-picker/picker-id';
+
+export const MoveCollectionDialog = compose(
+    withDialog(COLLECTION_MOVE_FORM_NAME),
+    reduxForm<MoveToFormDialogData>({
+        form: COLLECTION_MOVE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(moveCollection(data));
+        }
+    }),
+    pickerId(COLLECTION_MOVE_FORM_NAME),
+)(DialogMoveTo);
diff --git a/services/workbench2/src/views-components/dialog-forms/move-process-dialog.ts b/services/workbench2/src/views-components/dialog-forms/move-process-dialog.ts
new file mode 100644 (file)
index 0000000..d09e6b8
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from 'redux';
+import { withDialog } from "store/dialog/with-dialog";
+import { reduxForm } from 'redux-form';
+import { PROCESS_MOVE_FORM_NAME } from 'store/processes/process-move-actions';
+import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
+import { DialogMoveTo } from 'views-components/dialog-move/dialog-move-to';
+import { moveProcess } from 'store/workbench/workbench-actions';
+import { pickerId } from 'store/tree-picker/picker-id';
+
+export const MoveProcessDialog = compose(
+    withDialog(PROCESS_MOVE_FORM_NAME),
+    reduxForm<MoveToFormDialogData>({
+        form: PROCESS_MOVE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(moveProcess(data));
+        }
+    }),
+    pickerId(PROCESS_MOVE_FORM_NAME),
+)(DialogMoveTo);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/dialog-forms/move-project-dialog.ts b/services/workbench2/src/views-components/dialog-forms/move-project-dialog.ts
new file mode 100644 (file)
index 0000000..345040d
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { withDialog } from "store/dialog/with-dialog";
+import { reduxForm } from "redux-form";
+import { PROJECT_MOVE_FORM_NAME } from "store/projects/project-move-actions";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { DialogMoveTo } from "views-components/dialog-move/dialog-move-to";
+import { moveProject } from "store/workbench/workbench-actions";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const MoveProjectDialog = compose(
+    withDialog(PROJECT_MOVE_FORM_NAME),
+    reduxForm<MoveToFormDialogData>({
+        form: PROJECT_MOVE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(moveProject(data));
+        },
+    }),
+    pickerId(PROJECT_MOVE_FORM_NAME)
+)(DialogMoveTo);
diff --git a/services/workbench2/src/views-components/dialog-forms/partial-copy-to-existing-collection-dialog.ts b/services/workbench2/src/views-components/dialog-forms/partial-copy-to-existing-collection-dialog.ts
new file mode 100644 (file)
index 0000000..dd0d0cb
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { CollectionPartialCopyToExistingCollectionFormData, copyCollectionPartialToExistingCollection, COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION } from 'store/collections/collection-partial-copy-actions';
+import { DialogCollectionPartialCopyToExistingCollection } from "views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialCopyToExistingCollectionDialog = compose(
+    withDialog(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION),
+    reduxForm<CollectionPartialCopyToExistingCollectionFormData>({
+        form: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(copyCollectionPartialToExistingCollection(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION),
+)(DialogCollectionPartialCopyToExistingCollection);
diff --git a/services/workbench2/src/views-components/dialog-forms/partial-copy-to-new-collection-dialog.ts b/services/workbench2/src/views-components/dialog-forms/partial-copy-to-new-collection-dialog.ts
new file mode 100644 (file)
index 0000000..3a321de
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { CollectionPartialCopyToNewCollectionFormData, copyCollectionPartialToNewCollection, COLLECTION_PARTIAL_COPY_FORM_NAME } from 'store/collections/collection-partial-copy-actions';
+import { DialogCollectionPartialCopyToNewCollection } from "views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialCopyToNewCollectionDialog = compose(
+    withDialog(COLLECTION_PARTIAL_COPY_FORM_NAME),
+    reduxForm<CollectionPartialCopyToNewCollectionFormData>({
+        form: COLLECTION_PARTIAL_COPY_FORM_NAME,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(copyCollectionPartialToNewCollection(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_COPY_FORM_NAME),
+)(DialogCollectionPartialCopyToNewCollection);
diff --git a/services/workbench2/src/views-components/dialog-forms/partial-copy-to-separate-collections-dialog.ts b/services/workbench2/src/views-components/dialog-forms/partial-copy-to-separate-collections-dialog.ts
new file mode 100644 (file)
index 0000000..78fdd3a
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS, CollectionPartialCopyToSeparateCollectionsFormData, copyCollectionPartialToSeparateCollections } from 'store/collections/collection-partial-copy-actions';
+import { DialogCollectionPartialCopyToSeparateCollection } from "views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialCopyToSeparateCollectionsDialog = compose(
+    withDialog(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS),
+    reduxForm<CollectionPartialCopyToSeparateCollectionsFormData>({
+        form: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(copyCollectionPartialToSeparateCollections(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS),
+)(DialogCollectionPartialCopyToSeparateCollection);
diff --git a/services/workbench2/src/views-components/dialog-forms/partial-move-to-existing-collection-dialog.ts b/services/workbench2/src/views-components/dialog-forms/partial-move-to-existing-collection-dialog.ts
new file mode 100644 (file)
index 0000000..e8d51f1
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION, CollectionPartialMoveToExistingCollectionFormData, moveCollectionPartialToExistingCollection } from "store/collections/collection-partial-move-actions";
+import { DialogCollectionPartialMoveToExistingCollection } from "views-components/dialog-move/dialog-collection-partial-move-to-existing-collection";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialMoveToExistingCollectionDialog = compose(
+    withDialog(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION),
+    reduxForm<CollectionPartialMoveToExistingCollectionFormData>({
+        form: COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(moveCollectionPartialToExistingCollection(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION),
+)(DialogCollectionPartialMoveToExistingCollection);
diff --git a/services/workbench2/src/views-components/dialog-forms/partial-move-to-new-collection-dialog.ts b/services/workbench2/src/views-components/dialog-forms/partial-move-to-new-collection-dialog.ts
new file mode 100644 (file)
index 0000000..103e1e1
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION, CollectionPartialMoveToNewCollectionFormData, moveCollectionPartialToNewCollection } from "store/collections/collection-partial-move-actions";
+import { DialogCollectionPartialMoveToNewCollection } from "views-components/dialog-move/dialog-collection-partial-move-to-new-collection";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialMoveToNewCollectionDialog = compose(
+    withDialog(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION),
+    reduxForm<CollectionPartialMoveToNewCollectionFormData>({
+        form: COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(moveCollectionPartialToNewCollection(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION),
+)(DialogCollectionPartialMoveToNewCollection);
diff --git a/services/workbench2/src/views-components/dialog-forms/partial-move-to-separate-collections-dialog.ts b/services/workbench2/src/views-components/dialog-forms/partial-move-to-separate-collections-dialog.ts
new file mode 100644 (file)
index 0000000..8f7ea59
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS, CollectionPartialMoveToSeparateCollectionsFormData, moveCollectionPartialToSeparateCollections } from "store/collections/collection-partial-move-actions";
+import { DialogCollectionPartialMoveToSeparateCollections } from "views-components/dialog-move/dialog-collection-partial-move-to-separate-collections";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialMoveToSeparateCollectionsDialog = compose(
+    withDialog(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS),
+    reduxForm<CollectionPartialMoveToSeparateCollectionsFormData>({
+        form: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(moveCollectionPartialToSeparateCollections(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS),
+)(DialogCollectionPartialMoveToSeparateCollections);
diff --git a/services/workbench2/src/views-components/dialog-forms/update-collection-dialog.ts b/services/workbench2/src/views-components/dialog-forms/update-collection-dialog.ts
new file mode 100644 (file)
index 0000000..e5d52f0
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose, Dispatch } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "store/dialog/with-dialog";
+import { DialogCollectionUpdate } from 'views-components/dialog-update/dialog-collection-update';
+import {
+    COLLECTION_UPDATE_FORM_NAME,
+    CollectionUpdateFormDialogData,
+    updateCollection
+} from 'store/collections/collection-update-actions';
+
+export const UpdateCollectionDialog = compose(
+    withDialog(COLLECTION_UPDATE_FORM_NAME),
+    reduxForm<CollectionUpdateFormDialogData>({
+        touchOnChange: true,
+        form: COLLECTION_UPDATE_FORM_NAME,
+        onSubmit: (data: CollectionUpdateFormDialogData, dispatch: Dispatch) => {
+            dispatch<any>(updateCollection(data));
+        }
+    })
+)(DialogCollectionUpdate);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/dialog-forms/update-process-dialog.ts b/services/workbench2/src/views-components/dialog-forms/update-process-dialog.ts
new file mode 100644 (file)
index 0000000..73f92bd
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "store/dialog/with-dialog";
+import { DialogProcessUpdate } from 'views-components/dialog-update/dialog-process-update';
+import { PROCESS_UPDATE_FORM_NAME, ProcessUpdateFormDialogData } from 'store/processes/process-update-actions';
+import { updateProcess } from "store/workbench/workbench-actions";
+
+export const UpdateProcessDialog = compose(
+    withDialog(PROCESS_UPDATE_FORM_NAME),
+    reduxForm<ProcessUpdateFormDialogData>({
+        form: PROCESS_UPDATE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(updateProcess(data));
+        }
+    })
+)(DialogProcessUpdate);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/dialog-forms/update-project-dialog.ts b/services/workbench2/src/views-components/dialog-forms/update-project-dialog.ts
new file mode 100644 (file)
index 0000000..9462090
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "store/dialog/with-dialog";
+import { DialogProjectUpdate } from 'views-components/dialog-update/dialog-project-update';
+import { PROJECT_UPDATE_FORM_NAME, ProjectUpdateFormDialogData } from 'store/projects/project-update-actions';
+import { updateProject, updateGroup } from 'store/workbench/workbench-actions';
+import { GroupClass } from "models/group";
+
+export const UpdateProjectDialog = compose(
+    withDialog(PROJECT_UPDATE_FORM_NAME),
+    reduxForm<ProjectUpdateFormDialogData>({
+        form: PROJECT_UPDATE_FORM_NAME,
+        onSubmit: (data, dispatch, props) => {
+            switch (props.data.sourcePanel) {
+                case GroupClass.PROJECT:
+                    dispatch(updateProject(data));
+                    break;
+                case GroupClass.ROLE:
+                    dispatch(updateGroup(data));
+                    break;
+                default:
+                    break;
+            }
+        }
+    })
+)(DialogProjectUpdate);
diff --git a/services/workbench2/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx b/services/workbench2/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx
new file mode 100644 (file)
index 0000000..5cd4996
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialMoveToExistingCollectionFormData } from "store/collections/collection-partial-move-actions";
+import { PickerIdProp } from "store/tree-picker/picker-id";
+import { DirectoryPickerField } from 'views-components/form-fields/collection-form-fields';
+
+type DialogCollectionPartialMoveProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialMoveToExistingCollectionFormData>;
+
+export const DialogCollectionPartialMoveToExistingCollection = (props: DialogCollectionPartialMoveProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Move to existing collection'
+        formFields={CollectionPartialMoveFields(props.pickerId)}
+        submitLabel='Move files'
+        enableWhenPristine
+        {...props}
+    />;
+
+const CollectionPartialMoveFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <DirectoryPickerField {...{ pickerId }}/>
+            </>);
diff --git a/services/workbench2/src/views-components/dialog-move/dialog-collection-partial-move-to-new-collection.tsx b/services/workbench2/src/views-components/dialog-move/dialog-collection-partial-move-to-new-collection.tsx
new file mode 100644 (file)
index 0000000..a33f377
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { CollectionNameField, CollectionDescriptionField, CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialMoveToNewCollectionFormData } from "store/collections/collection-partial-move-actions";
+import { PickerIdProp } from "store/tree-picker/picker-id";
+
+type DialogCollectionPartialMoveProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialMoveToNewCollectionFormData>;
+
+export const DialogCollectionPartialMoveToNewCollection = (props: DialogCollectionPartialMoveProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Move to new collection'
+        formFields={CollectionPartialMoveFields(props.pickerId)}
+        submitLabel='Create collection'
+        {...props}
+    />;
+
+const CollectionPartialMoveFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <CollectionNameField />
+                <CollectionDescriptionField />
+                <CollectionProjectPickerField {...{ pickerId }} />
+            </>);
diff --git a/services/workbench2/src/views-components/dialog-move/dialog-collection-partial-move-to-separate-collections.tsx b/services/workbench2/src/views-components/dialog-move/dialog-collection-partial-move-to-separate-collections.tsx
new file mode 100644 (file)
index 0000000..1b71662
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialMoveToSeparateCollectionsFormData } from "store/collections/collection-partial-move-actions";
+import { PickerIdProp } from "store/tree-picker/picker-id";
+
+type DialogCollectionPartialMoveProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialMoveToSeparateCollectionsFormData>;
+
+export const DialogCollectionPartialMoveToSeparateCollections = (props: DialogCollectionPartialMoveProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Move to separate collections'
+        formFields={CollectionPartialMoveFields(props.pickerId)}
+        submitLabel='Create collections'
+        {...props}
+    />;
+
+const CollectionPartialMoveFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <CollectionProjectPickerField {...{ pickerId }} />
+            </>);
diff --git a/services/workbench2/src/views-components/dialog-move/dialog-move-to.tsx b/services/workbench2/src/views-components/dialog-move/dialog-move-to.tsx
new file mode 100644 (file)
index 0000000..26ad569
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from 'lodash/fp';
+import { InjectedFormProps, Field } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { ProjectTreePickerField } from 'views-components/projects-tree-picker/tree-picker-field';
+import { MOVE_TO_VALIDATION } from 'validators/validators';
+import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
+import { PickerIdProp } from "store/tree-picker/picker-id";
+
+export const DialogMoveTo = (props: WithDialogProps<string> & InjectedFormProps<MoveToFormDialogData> & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Move to'
+        formFields={MoveToDialogFields(props.pickerId)}
+        submitLabel='Move'
+        {...props}
+    />;
+
+const MoveToDialogFields = memoize(
+    (pickerId: string) => () =>
+        <Field
+            name="ownerUuid"
+            pickerId={pickerId}
+            component={ProjectTreePickerField}
+            validate={MOVE_TO_VALIDATION} />);
+
diff --git a/services/workbench2/src/views-components/dialog-update/dialog-collection-update.tsx b/services/workbench2/src/views-components/dialog-update/dialog-collection-update.tsx
new file mode 100644 (file)
index 0000000..d77d10f
--- /dev/null
@@ -0,0 +1,52 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { CollectionUpdateFormDialogData, COLLECTION_UPDATE_FORM_NAME } from 'store/collections/collection-update-actions';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import {
+    CollectionNameField,
+    CollectionDescriptionField,
+    CollectionStorageClassesField
+} from 'views-components/form-fields/collection-form-fields';
+import { UpdateCollectionPropertiesForm } from 'views-components/collection-properties/update-collection-properties-form';
+import { FormGroup, FormLabel, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
+import { resourcePropertiesList } from 'views-components/resource-properties/resource-properties-list';
+
+type CssRules = 'propertiesForm';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    propertiesForm: {
+        marginTop: theme.spacing.unit * 2,
+        marginBottom: theme.spacing.unit * 2,
+    },
+});
+
+type DialogCollectionProps = WithDialogProps<{}> & InjectedFormProps<CollectionUpdateFormDialogData>;
+
+export const DialogCollectionUpdate = (props: DialogCollectionProps) =>
+    <FormDialog
+        dialogTitle='Edit Collection'
+        formFields={CollectionEditFields as any}
+        submitLabel='Save'
+        {...props}
+    />;
+
+const UpdateCollectionPropertiesList = resourcePropertiesList(COLLECTION_UPDATE_FORM_NAME);
+
+const CollectionEditFields = withStyles(styles)(
+    ({ classes }: WithStyles<CssRules>) => <span>
+        <CollectionNameField />
+        <CollectionDescriptionField />
+        <div className={classes.propertiesForm}>
+            <FormLabel>Properties</FormLabel>
+            <FormGroup>
+                <UpdateCollectionPropertiesForm />
+                <UpdateCollectionPropertiesList />
+            </FormGroup>
+        </div>
+        <CollectionStorageClassesField />
+    </span>);
diff --git a/services/workbench2/src/views-components/dialog-update/dialog-process-update.tsx b/services/workbench2/src/views-components/dialog-update/dialog-process-update.tsx
new file mode 100644 (file)
index 0000000..8b8f432
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { ProcessUpdateFormDialogData } from 'store/processes/process-update-actions';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { ProcessNameField, ProcessDescriptionField } from 'views-components/form-fields/process-form-fields';
+
+type DialogProcessProps = WithDialogProps<{}> & InjectedFormProps<ProcessUpdateFormDialogData>;
+
+export const DialogProcessUpdate = (props: DialogProcessProps) =>
+    <FormDialog
+        dialogTitle='Edit Process'
+        formFields={ProcessEditFields}
+        submitLabel='Save'
+        {...props}
+    />;
+
+const ProcessEditFields = () => <span>
+    <ProcessNameField />
+    <ProcessDescriptionField />
+</span>;
diff --git a/services/workbench2/src/views-components/dialog-update/dialog-project-update.tsx b/services/workbench2/src/views-components/dialog-update/dialog-project-update.tsx
new file mode 100644 (file)
index 0000000..a6ac65b
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { ProjectUpdateFormDialogData, PROJECT_UPDATE_FORM_NAME } from 'store/projects/project-update-actions';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { ProjectNameField, ProjectDescriptionField } from 'views-components/form-fields/project-form-fields';
+import { GroupClass } from 'models/group';
+import { FormGroup, FormLabel, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { UpdateProjectPropertiesForm } from 'views-components/project-properties/update-project-properties-form';
+import { resourcePropertiesList } from 'views-components/resource-properties/resource-properties-list';
+
+type CssRules = 'propertiesForm' | 'description';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    propertiesForm: {
+        marginTop: theme.spacing.unit * 2,
+        marginBottom: theme.spacing.unit * 2,
+    },
+    description: {
+        marginTop: theme.spacing.unit * 2,
+        marginBottom: theme.spacing.unit * 2,
+    },
+});
+
+type DialogProjectProps = WithDialogProps<{sourcePanel: GroupClass}> & InjectedFormProps<ProjectUpdateFormDialogData>;
+
+export const DialogProjectUpdate = (props: DialogProjectProps) => {
+    let title = 'Edit Project';
+    const sourcePanel = props.data.sourcePanel || '';
+
+    if (sourcePanel === GroupClass.ROLE) {
+        title = 'Edit Group';
+    }
+
+    return <FormDialog
+        dialogTitle={title}
+        formFields={ProjectEditFields as any}
+        submitLabel='Save'
+        {...props}
+    />;
+};
+
+const UpdateProjectPropertiesList = resourcePropertiesList(PROJECT_UPDATE_FORM_NAME);
+
+// Also used as "Group Edit Fields"
+const ProjectEditFields = withStyles(styles)(
+    ({ classes }: WithStyles<CssRules>) => <span>
+        <ProjectNameField />
+        <div className={classes.description}>
+            <ProjectDescriptionField />
+        </div>
+        <div className={classes.propertiesForm}>
+            <FormLabel>Properties</FormLabel>
+            <FormGroup>
+                <UpdateProjectPropertiesForm />
+                <UpdateProjectPropertiesList />
+            </FormGroup>
+        </div>
+    </span>);
diff --git a/services/workbench2/src/views-components/dialog-upload/dialog-collection-files-upload.tsx b/services/workbench2/src/views-components/dialog-upload/dialog-collection-files-upload.tsx
new file mode 100644 (file)
index 0000000..f65bdab
--- /dev/null
@@ -0,0 +1,48 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { InjectedFormProps, Field } from 'redux-form';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { CollectionCreateFormDialogData } from 'store/collections/collection-create-actions';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { require } from 'validators/require';
+import { FileUploaderField } from 'views-components/file-uploader/file-uploader';
+import { WarningCollection } from 'components/warning-collection/warning-collection';
+import { fileUploaderActions } from 'store/file-uploader/file-uploader-actions';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+
+type DialogCollectionFilesUploadProps = WithDialogProps<{}> & InjectedFormProps<CollectionCreateFormDialogData>;
+
+export const DialogCollectionFilesUpload = (props: DialogCollectionFilesUploadProps) => {
+
+    return <FormDialog
+        dialogTitle='Upload data'
+        formFields={UploadCollectionFilesFields}
+        submitLabel='Upload data'
+        doNotDisableCancel
+        cancelCallback={() => {
+            const { submitting, dispatch } = (props as any);
+
+            if (submitting) {
+                dispatch(progressIndicatorActions.STOP_WORKING('uploadCollectionFilesDialog'));
+                dispatch(fileUploaderActions.CANCEL_FILES_UPLOAD());
+                dispatch(fileUploaderActions.CLEAR_UPLOAD());
+            }
+        }}
+        {...props}
+    />;
+}
+
+const UploadCollectionFilesFields = () => <>
+    <Field
+        name='files'
+        validate={FILES_FIELD_VALIDATION}
+        component={FileUploaderField} />
+    <WarningCollection text="Uploading new files will change content address." />
+</>;
+
+const FILES_FIELD_VALIDATION = [require];
+
+
diff --git a/services/workbench2/src/views-components/favorite-star/favorite-star.tsx b/services/workbench2/src/views-components/favorite-star/favorite-star.tsx
new file mode 100644 (file)
index 0000000..f21fcdc
--- /dev/null
@@ -0,0 +1,41 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { FavoriteIcon, PublicFavoriteIcon } from "components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { withStyles, StyleRulesCallback, WithStyles, Tooltip } from "@material-ui/core";
+
+type CssRules = "icon";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    icon: {
+        fontSize: "inherit"
+    }
+});
+
+const mapStateToProps = (state: RootState, props: { resourceUuid: string; className?: string; }) => ({
+    ...props,
+    isFavoriteVisible: state.favorites[props.resourceUuid],
+    isPublicFavoriteVisible: state.publicFavorites[props.resourceUuid]
+});
+
+export const FavoriteStar = connect(mapStateToProps)(
+    withStyles(styles)((props: { isFavoriteVisible: boolean; className?: string; } & WithStyles<CssRules>) => {
+        if (props.isFavoriteVisible) {
+            return <Tooltip enterDelay={500} title="Favorite"><FavoriteIcon className={props.className || props.classes.icon} /></Tooltip>;
+        } else {
+            return null;
+        }
+    }));
+
+export const PublicFavoriteStar = connect(mapStateToProps)(
+    withStyles(styles)((props: { isPublicFavoriteVisible: boolean; className?: string; } & WithStyles<CssRules>) => {
+        if (props.isPublicFavoriteVisible) {
+            return <Tooltip enterDelay={500} title="Public Favorite"><PublicFavoriteIcon className={props.className || props.classes.icon} /></Tooltip>;
+        } else {
+            return null;
+        }
+    }));
diff --git a/services/workbench2/src/views-components/file-remove-dialog/file-remove-dialog.ts b/services/workbench2/src/views-components/file-remove-dialog/file-remove-dialog.ts
new file mode 100644 (file)
index 0000000..6f03b12
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from 'store/dialog/with-dialog';
+import { RootState } from 'store/store';
+import { removeCollectionFiles, FILE_REMOVE_DIALOG } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions';
+
+const mapStateToProps = (state: RootState, props: WithDialogProps<{ filePath: string }>) => ({
+    filePath: props.data.filePath
+});
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<{ filePath: string }>) => ({
+    onConfirm: (filePath: string) => {
+        props.closeDialog();
+        dispatch<any>(removeCollectionFiles([filePath]));
+    }
+});
+
+const mergeProps = (
+    stateProps: { filePath: string },
+    dispatchProps: { onConfirm: (filePath: string) => void },
+    props: WithDialogProps<{ filePath: string }>) => ({
+        onConfirm: () => dispatchProps.onConfirm(stateProps.filePath),
+        ...props
+    });
+
+// TODO: Remove as any
+export const [FileRemoveDialog] = [ConfirmationDialog]
+    .map(connect(mapStateToProps, mapDispatchToProps, mergeProps) as any)
+    .map(withDialog(FILE_REMOVE_DIALOG));
diff --git a/services/workbench2/src/views-components/file-remove-dialog/multiple-files-remove-dialog.ts b/services/workbench2/src/views-components/file-remove-dialog/multiple-files-remove-dialog.ts
new file mode 100644 (file)
index 0000000..88e43b9
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { MULTIPLE_FILES_REMOVE_DIALOG, removeCollectionsSelectedFiles } from "../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeCollectionsSelectedFiles());
+    }
+});
+
+export const [MultipleFilesRemoveDialog] = [ConfirmationDialog]
+    .map(connect(undefined, mapDispatchToProps))
+    .map(withDialog(MULTIPLE_FILES_REMOVE_DIALOG));
diff --git a/services/workbench2/src/views-components/file-uploader/file-uploader.tsx b/services/workbench2/src/views-components/file-uploader/file-uploader.tsx
new file mode 100644 (file)
index 0000000..be59261
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { FileUpload } from 'components/file-upload/file-upload';
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import { FileUploadProps } from '../../components/file-upload/file-upload';
+import { Dispatch } from 'redux';
+import { fileUploaderActions, getFileUploaderState } from 'store/file-uploader/file-uploader-actions';
+import { WrappedFieldProps } from 'redux-form';
+import { Typography } from '@material-ui/core';
+
+export type FileUploaderProps = Pick<FileUploadProps, 'disabled' | 'onDrop'>;
+
+const mapStateToProps = (state: RootState, { disabled }: FileUploaderProps): Pick<FileUploadProps, 'files' | 'disabled'> => ({
+    disabled,
+    files: state.fileUploader,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch, { onDrop }: FileUploaderProps): Pick<FileUploadProps, 'onDrop' | 'onDelete'> => ({
+    onDrop: files => {
+        const state = dispatch<any>(getFileUploaderState());
+        if (files.length > 0 && state.length === 0) {
+            dispatch(fileUploaderActions.SET_UPLOAD_FILES(files));
+            onDrop(files);
+        } else if (files.length > 0 && state.length > 0) {
+            dispatch(fileUploaderActions.UPDATE_UPLOAD_FILES(files));
+            onDrop(files);
+        }
+    },
+    onDelete: file => dispatch(fileUploaderActions.DELETE_UPLOAD_FILE(file))
+});
+
+export const FileUploader = connect(mapStateToProps, mapDispatchToProps)(FileUpload);
+
+export const FileUploaderField = (props: WrappedFieldProps & { label?: string }) =>
+    <>
+        <Typography variant='caption'>{props.label}</Typography>
+        <FileUploader disabled={false} onDrop={props.input.onChange} />
+    </>;
diff --git a/services/workbench2/src/views-components/form-fields/collection-form-fields.tsx b/services/workbench2/src/views-components/form-fields/collection-form-fields.tsx
new file mode 100644 (file)
index 0000000..7d5fcf8
--- /dev/null
@@ -0,0 +1,91 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Field, Validator } from "redux-form";
+import { TextField } from "components/text-field/text-field";
+import {
+    COLLECTION_NAME_VALIDATION, COLLECTION_NAME_VALIDATION_ALLOW_SLASH,
+    COLLECTION_DESCRIPTION_VALIDATION, COLLECTION_PROJECT_VALIDATION
+} from "validators/validators";
+import { ProjectTreePickerField, CollectionTreePickerField, DirectoryTreePickerField } from "views-components/projects-tree-picker/tree-picker-field";
+import { PickerIdProp } from 'store/tree-picker/picker-id';
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { MultiCheckboxField } from "components/checkbox-field/checkbox-field";
+import { getStorageClasses } from "common/config";
+import { ERROR_MESSAGE } from "validators/require";
+
+interface CollectionNameFieldProps {
+    validate: Validator[];
+}
+
+// See implementation note on declaration of ProjectNameField
+
+export const CollectionNameField = connect(
+    (state: RootState) => {
+        return {
+            validate: (state.auth.config.clusterConfig.Collections.ForwardSlashNameSubstitution === "" ?
+                COLLECTION_NAME_VALIDATION : COLLECTION_NAME_VALIDATION_ALLOW_SLASH)
+        };
+    })((props: CollectionNameFieldProps) =>
+        <span data-cy='name-field'><Field
+            name='name'
+            component={TextField as any}
+            validate={props.validate}
+            label="Collection Name"
+            autoFocus={true} /></span>
+    );
+
+export const CollectionDescriptionField = () =>
+    <Field
+        name='description'
+        component={TextField as any}
+        validate={COLLECTION_DESCRIPTION_VALIDATION}
+        label="Description - optional" />;
+
+export const CollectionProjectPickerField = (props: PickerIdProp) =>
+    <Field
+        name="projectUuid"
+        pickerId={props.pickerId}
+        component={ProjectTreePickerField}
+        validate={COLLECTION_PROJECT_VALIDATION} />;
+
+export const CollectionPickerField = (props: PickerIdProp) =>
+    <Field
+        name="collectionUuid"
+        pickerId={props.pickerId}
+        component={CollectionTreePickerField}
+        validate={COLLECTION_PROJECT_VALIDATION} />;
+
+const validateDirectory = (val) => (val && val.uuid ? undefined : ERROR_MESSAGE);
+
+export const DirectoryPickerField = (props: PickerIdProp) =>
+    <Field
+        name="destination"
+        pickerId={props.pickerId}
+        component={DirectoryTreePickerField as any}
+        validate={validateDirectory} />;
+
+interface StorageClassesProps {
+    items: string[];
+    defaultClasses?: string[];
+}
+
+export const CollectionStorageClassesField = connect(
+    (state: RootState) => {
+        return {
+            items: getStorageClasses(state.auth.config)
+        };
+    })(
+    (props: StorageClassesProps) =>
+        <Field
+            name='storageClassesDesired'
+            label='Storage classes'
+            minSelection={1}
+            rowLayout={true}
+            defaultValues={props.defaultClasses}
+            helperText='At least one class should be selected'
+            component={MultiCheckboxField}
+            items={props.items} />);
diff --git a/services/workbench2/src/views-components/form-fields/process-form-fields.tsx b/services/workbench2/src/views-components/form-fields/process-form-fields.tsx
new file mode 100644 (file)
index 0000000..60a51b1
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Field } from "redux-form";
+import { TextField } from "components/text-field/text-field";
+import { PROCESS_NAME_VALIDATION, PROCESS_DESCRIPTION_VALIDATION } from "validators/validators";
+
+export const ProcessNameField = () =>
+    <Field
+        name='name'
+        component={TextField as any}
+        validate={PROCESS_NAME_VALIDATION}
+        label="Process Name" />;
+
+export const ProcessDescriptionField = () =>
+    <Field
+        name='description'
+        component={TextField as any}
+        validate={PROCESS_DESCRIPTION_VALIDATION}
+        label="Process Description" />;
diff --git a/services/workbench2/src/views-components/form-fields/project-form-fields.tsx b/services/workbench2/src/views-components/form-fields/project-form-fields.tsx
new file mode 100644 (file)
index 0000000..6ef723d
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Field, FieldArray, Validator, WrappedFieldArrayProps } from "redux-form";
+import { TextField, RichEditorTextField } from "components/text-field/text-field";
+import { PROJECT_NAME_VALIDATION, PROJECT_NAME_VALIDATION_ALLOW_SLASH } from "validators/validators";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { Participant, ParticipantSelect } from "views-components/sharing-dialog/participant-select";
+
+interface ProjectNameFieldProps {
+    validate: Validator[];
+    label?: string;
+}
+
+// Validation behavior depends on the value of ForwardSlashNameSubstitution.
+//
+// Redux form doesn't let you pass anonymous functions to 'validate'
+// -- it fails with a very confusing recursive-update-exceeded error.
+// So we can't construct the validation function on the fly.
+//
+// As a workaround, use ForwardSlashNameSubstitution to choose between one of two const-defined validators.
+
+export const ProjectNameField = connect(
+    (state: RootState) => {
+        return {
+            validate: (state.auth.config.clusterConfig.Collections.ForwardSlashNameSubstitution === "" ?
+                PROJECT_NAME_VALIDATION : PROJECT_NAME_VALIDATION_ALLOW_SLASH)
+        };
+    })((props: ProjectNameFieldProps) =>
+        <span data-cy='name-field'><Field
+            name='name'
+            component={TextField as any}
+            validate={props.validate}
+            label={props.label || "Project Name"}
+            autoFocus={true} /></span>
+    );
+
+export const ProjectDescriptionField = () =>
+    <Field
+        name='description'
+        component={RichEditorTextField as any}
+        label="Description - optional" />;
+
+export const UsersField = () =>
+        <span data-cy='users-field'><FieldArray
+            name="users"
+            component={UsersSelect as any} /></span>;
+
+export const UsersSelect = ({ fields }: WrappedFieldArrayProps<Participant>) =>
+        <ParticipantSelect
+            onlyPeople
+            label='Search for users to add to the group'
+            items={fields.getAll() || []}
+            onSelect={fields.push}
+            onDelete={fields.remove} />;
diff --git a/services/workbench2/src/views-components/form-fields/repository-form-fields.tsx b/services/workbench2/src/views-components/form-fields/repository-form-fields.tsx
new file mode 100644 (file)
index 0000000..8d2359e
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Field } from "redux-form";
+import { TextField } from "components/text-field/text-field";
+import { REPOSITORY_NAME_VALIDATION } from "validators/validators";
+import { Grid } from "@material-ui/core";
+
+export const RepositoryNameField = (props: any) =>
+    <Grid container style={{ marginTop: '0', paddingTop: '24px' }}>
+        <Grid item xs={3}>
+            {props.data.user.username}/
+        </Grid>
+        <Grid item xs={7} style={{ bottom: '24px', position: 'relative' }}>
+            <Field
+                name='name'
+                component={TextField as any}
+                validate={REPOSITORY_NAME_VALIDATION}
+                label="Name"
+                autoFocus={true} />
+        </Grid>
+        <Grid item xs={2}>
+            .git
+        </Grid>
+        <Grid item xs={12}>
+            It may take a minute or two before you can clone your new repository.
+        </Grid>
+    </Grid>;
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/form-fields/resource-form-fields.tsx b/services/workbench2/src/views-components/form-fields/resource-form-fields.tsx
new file mode 100644 (file)
index 0000000..f2bb97f
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { Field } from "redux-form";
+import { ResourcesState, getResource } from "store/resources/resources";
+import { GroupResource } from "models/group";
+import { TextField } from "components/text-field/text-field";
+import { getUserUuid } from "common/getuser";
+
+interface ResourceParentFieldProps {
+    resources: ResourcesState;
+    userUuid: string|undefined;
+}
+
+export const ResourceParentField = connect(
+    (state: RootState) => {
+        return {
+            resources: state.resources,
+            userUuid: getUserUuid(state),
+        };
+    })
+    ((props: ResourceParentFieldProps) =>
+        <span data-cy='parent-field'><Field
+            name='ownerUuid'
+            disabled={true}
+            label='Parent project'
+            format={
+                (value, name) => {
+                    if (value === props.userUuid) {
+                        return 'Home project';
+                    }
+                    const rsc = getResource<GroupResource>(value)(props.resources);
+                    if (rsc !== undefined) {
+                        return `${rsc.name} (${rsc.uuid})`;
+                    }
+                    return value;
+                }
+            }
+            component={TextField as any} /></span>
+    );
diff --git a/services/workbench2/src/views-components/form-fields/search-bar-form-fields.tsx b/services/workbench2/src/views-components/form-fields/search-bar-form-fields.tsx
new file mode 100644 (file)
index 0000000..47633a0
--- /dev/null
@@ -0,0 +1,99 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Field, FieldArray } from 'redux-form';
+import { TextField, DateTextField } from "components/text-field/text-field";
+import { CheckboxField } from 'components/checkbox-field/checkbox-field';
+import { NativeSelectField } from 'components/select-field/select-field';
+import { ResourceKind } from 'models/resource';
+import { SearchBarAdvancedPropertiesView } from 'views-components/search-bar/search-bar-advanced-properties-view';
+import { PropertyKeyField, } from 'views-components/resource-properties-form/property-key-field';
+import { PropertyValueField } from 'views-components/resource-properties-form/property-value-field';
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { ProjectInput, ProjectCommandInputParameter } from 'views/run-process-panel/inputs/project-input';
+
+export const SearchBarTypeField = () =>
+    <Field
+        name='type'
+        component={NativeSelectField as any}
+        items={[
+            { key: '', value: 'Any' },
+            { key: ResourceKind.COLLECTION, value: 'Collection' },
+            { key: ResourceKind.PROJECT, value: 'Project' },
+            { key: ResourceKind.PROCESS, value: 'Process' }
+        ]} />;
+
+
+interface SearchBarClusterFieldProps {
+    clusters: { key: string, value: string }[];
+}
+
+export const SearchBarClusterField = connect(
+    (state: RootState) => ({
+        clusters: [{ key: '', value: 'Any' }].concat(
+            state.auth.sessions
+                .filter(s => s.loggedIn)
+                .map(s => ({
+                    key: s.clusterId,
+                    value: s.clusterId
+                })))
+    }))((props: SearchBarClusterFieldProps) => <Field
+        name='cluster'
+        component={NativeSelectField as any}
+        items={props.clusters} />
+    );
+
+export const SearchBarProjectField = () =>
+    <ProjectInput required={false} input={{
+        id: "projectObject",
+        label: "Limit search to Project"
+    } as ProjectCommandInputParameter}
+        options={{ showOnlyOwned: false, showOnlyWritable: false }} />
+
+export const SearchBarTrashField = () =>
+    <Field
+        name='inTrash'
+        component={CheckboxField}
+        label="In trash" />;
+
+export const SearchBarPastVersionsField = () =>
+    <Field
+        name='pastVersions'
+        component={CheckboxField}
+        label="Past versions" />;
+
+export const SearchBarDateFromField = () =>
+    <Field
+        name='dateFrom'
+        component={DateTextField as any} />;
+
+export const SearchBarDateToField = () =>
+    <Field
+        name='dateTo'
+        component={DateTextField as any} />;
+
+export const SearchBarPropertiesField = () =>
+    <FieldArray
+        name="properties"
+        component={SearchBarAdvancedPropertiesView as any} />;
+
+export const SearchBarKeyField = () =>
+    <PropertyKeyField skipValidation={true} />;
+
+export const SearchBarValueField = () =>
+    <PropertyValueField skipValidation={true} />;
+
+export const SearchBarSaveSearchField = () =>
+    <Field
+        name='saveQuery'
+        component={CheckboxField}
+        label="Save query" />;
+
+export const SearchBarQuerySearchField = () =>
+    <Field
+        name='queryName'
+        component={TextField as any}
+        label="Query name" />;
diff --git a/services/workbench2/src/views-components/form-fields/ssh-key-form-fields.tsx b/services/workbench2/src/views-components/form-fields/ssh-key-form-fields.tsx
new file mode 100644 (file)
index 0000000..2121725
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Field } from "redux-form";
+import { TextField } from "components/text-field/text-field";
+import { SSH_KEY_PUBLIC_VALIDATION, SSH_KEY_NAME_VALIDATION } from "validators/validators";
+
+export const SshKeyPublicField = () =>
+    <Field
+        name='publicKey'
+        component={TextField as any}
+        validate={SSH_KEY_PUBLIC_VALIDATION}
+        autoFocus={true}
+        label="Public Key" />;
+
+export const SshKeyNameField = () =>
+    <Field
+        name='name'
+        component={TextField as any}
+        validate={SSH_KEY_NAME_VALIDATION}
+        label="Name" />;
+
+
diff --git a/services/workbench2/src/views-components/form-fields/user-form-fields.tsx b/services/workbench2/src/views-components/form-fields/user-form-fields.tsx
new file mode 100644 (file)
index 0000000..12fc91e
--- /dev/null
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Field } from "redux-form";
+import { TextField } from "components/text-field/text-field";
+import { USER_EMAIL_VALIDATION, CHOOSE_VM_VALIDATION } from "validators/validators";
+import { NativeSelectField } from "components/select-field/select-field";
+import { InputLabel } from "@material-ui/core";
+import { VirtualMachinesResource } from "models/virtual-machines";
+import { VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD } from "store/virtual-machines/virtual-machines-actions";
+import { GroupArrayInput } from "views-components/virtual-machines-dialog/group-array-input";
+
+interface VirtualMachinesProps {
+    data: {
+        items: VirtualMachinesResource[];
+    };
+}
+
+export const UserEmailField = () =>
+    <Field
+        name='email'
+        component={TextField as any}
+        validate={USER_EMAIL_VALIDATION}
+        autoFocus={true}
+        label="Email" />;
+
+export const RequiredUserVirtualMachineField = ({ data }: VirtualMachinesProps) =>
+    <div style={{ marginBottom: '21px' }}>
+        <InputLabel>Virtual Machine</InputLabel>
+        <Field
+            name={VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD}
+            component={NativeSelectField as any}
+            validate={CHOOSE_VM_VALIDATION}
+            items={getVirtualMachinesList(data.items)} />
+    </div>;
+
+export const UserVirtualMachineField = ({ data }: VirtualMachinesProps) =>
+    <div style={{ marginBottom: '21px' }}>
+        <InputLabel>Virtual Machine</InputLabel>
+        <Field
+            name={VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD}
+            component={NativeSelectField as any}
+            items={getVirtualMachinesList(data.items)} />
+    </div>;
+
+export const UserGroupsVirtualMachineField = () =>
+    <GroupArrayInput
+        name={VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD}
+        input={{id:"Add groups to VM login (eg: docker, sudo)", disabled:false}}
+        required={false}
+    />
+
+const getVirtualMachinesList = (virtualMachines: VirtualMachinesResource[]) =>
+    [{ key: "", value: "" }].concat(virtualMachines.map(it => ({ key: it.uuid, value: it.hostname })));
diff --git a/services/workbench2/src/views-components/groups-dialog/attributes-dialog.tsx b/services/workbench2/src/views-components/groups-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..6f3490d
--- /dev/null
@@ -0,0 +1,101 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Grid } from "@material-ui/core";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { withDialog } from 'store/dialog/with-dialog';
+import { WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { compose } from "redux";
+import { GroupResource } from "models/group";
+import { GROUP_ATTRIBUTES_DIALOG } from "store/groups-panel/groups-panel-actions";
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles = withStyles<CssRules>((theme: ArvadosTheme) => ({
+    rightContainer: {
+        textAlign: 'right',
+        paddingRight: theme.spacing.unit * 2,
+        color: theme.palette.grey["500"]
+    },
+    leftContainer: {
+        textAlign: 'left',
+        paddingLeft: theme.spacing.unit * 2
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    },
+}));
+
+interface GroupAttributesDataProps {
+    data: GroupResource;
+}
+
+type GroupAttributesProps = GroupAttributesDataProps & WithStyles<CssRules>;
+
+export const GroupAttributesDialog = compose(
+    withDialog(GROUP_ATTRIBUTES_DIALOG),
+    styles)(
+        (props: WithDialogProps<GroupAttributesProps> & GroupAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant='body1' className={props.classes.spacing}>
+                        {props.data && attributes(props.data, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (group: GroupResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, deleteAt, description, etag, href, isTrashed, trashAt} = group;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    {name && <Grid item>Name</Grid>}
+                    {ownerUuid && <Grid item>Owner uuid</Grid>}
+                    {createdAt && <Grid item>Created at</Grid>}
+                    {modifiedAt && <Grid item>Modified at</Grid>}
+                    {modifiedByUserUuid && <Grid item>Modified by user uuid</Grid>}
+                    {modifiedByClientUuid && <Grid item>Modified by client uuid</Grid>}
+                    {uuid && <Grid item>uuid</Grid>}
+                    {deleteAt && <Grid item>Delete at</Grid>}
+                    {description && <Grid item>Description</Grid>}
+                    {etag && <Grid item>Etag</Grid>}
+                    {href && <Grid item>Href</Grid>}
+                    {isTrashed && <Grid item>Is trashed</Grid>}
+                    {trashAt && <Grid item>Trashed at</Grid>}
+                </Grid>
+                <Grid item xs={7} className={classes.leftContainer}>
+                    <Grid item>{name}</Grid>
+                    <Grid item>{ownerUuid}</Grid>
+                    <Grid item>{createdAt}</Grid>
+                    <Grid item>{modifiedAt}</Grid>
+                    <Grid item>{modifiedByUserUuid}</Grid>
+                    <Grid item>{modifiedByClientUuid}</Grid>
+                    <Grid item>{uuid}</Grid>
+                    <Grid item>{deleteAt}</Grid>
+                    <Grid item>{description}</Grid>
+                    <Grid item>{etag}</Grid>
+                    <Grid item>{href}</Grid>
+                    <Grid item>{isTrashed}</Grid>
+                    <Grid item>{trashAt}</Grid>
+                </Grid>
+            </Grid>
+        </span>
+    );
+};
diff --git a/services/workbench2/src/views-components/groups-dialog/member-attributes-dialog.tsx b/services/workbench2/src/views-components/groups-dialog/member-attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..d8bb0c5
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Grid } from "@material-ui/core";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { withDialog } from 'store/dialog/with-dialog';
+import { WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { compose } from "redux";
+import { PermissionResource } from "models/permission";
+import { MEMBER_ATTRIBUTES_DIALOG } from 'store/group-details-panel/group-details-panel-actions';
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles = withStyles<CssRules>((theme: ArvadosTheme) => ({
+    rightContainer: {
+        textAlign: 'right',
+        paddingRight: theme.spacing.unit * 2,
+        color: theme.palette.grey["500"]
+    },
+    leftContainer: {
+        textAlign: 'left',
+        paddingLeft: theme.spacing.unit * 2
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    },
+}));
+
+interface GroupAttributesDataProps {
+    data: PermissionResource;
+}
+
+type GroupAttributesProps = GroupAttributesDataProps & WithStyles<CssRules>;
+
+export const GroupMemberAttributesDialog = compose(
+    withDialog(MEMBER_ATTRIBUTES_DIALOG),
+    styles)(
+        (props: WithDialogProps<GroupAttributesProps> & GroupAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant='body1' className={props.classes.spacing}>
+                        {props.data && attributes(props.data, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (memberGroup: PermissionResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, etag, href, linkClass } = memberGroup;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    {name && <Grid item>Name</Grid>}
+                    {ownerUuid && <Grid item>Owner uuid</Grid>}
+                    {createdAt && <Grid item>Created at</Grid>}
+                    {modifiedAt && <Grid item>Modified at</Grid>}
+                    {modifiedByUserUuid && <Grid item>Modified by user uuid</Grid>}
+                    {modifiedByClientUuid && <Grid item>Modified by client uuid</Grid>}
+                    {uuid && <Grid item>uuid</Grid>}
+                    {linkClass && <Grid item>Link Class</Grid>}
+                    {etag && <Grid item>Etag</Grid>}
+                    {href && <Grid item>Href</Grid>}
+                </Grid>
+                <Grid item xs={7} className={classes.leftContainer}>
+                    <Grid item>{name}</Grid>
+                    <Grid item>{ownerUuid}</Grid>
+                    <Grid item>{createdAt}</Grid>
+                    <Grid item>{modifiedAt}</Grid>
+                    <Grid item>{modifiedByUserUuid}</Grid>
+                    <Grid item>{modifiedByClientUuid}</Grid>
+                    <Grid item>{uuid}</Grid>
+                    <Grid item>{linkClass}</Grid>
+                    <Grid item>{etag}</Grid>
+                    <Grid item>{href}</Grid>
+                </Grid>
+            </Grid>
+        </span>
+    );
+};
diff --git a/services/workbench2/src/views-components/groups-dialog/member-remove-dialog.ts b/services/workbench2/src/views-components/groups-dialog/member-remove-dialog.ts
new file mode 100644 (file)
index 0000000..e232b3e
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { removeGroupMember, MEMBER_REMOVE_DIALOG } from 'store/group-details-panel/group-details-panel-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeGroupMember(props.data.uuid));
+    }
+});
+
+export const RemoveGroupMemberDialog = compose(
+    withDialog(MEMBER_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/groups-dialog/remove-dialog.ts b/services/workbench2/src/views-components/groups-dialog/remove-dialog.ts
new file mode 100644 (file)
index 0000000..29291f5
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { removeGroup, GROUP_REMOVE_DIALOG } from 'store/groups-panel/groups-panel-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeGroup(props.data.uuid));
+    }
+});
+
+export const RemoveGroupDialog = compose(
+    withDialog(GROUP_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/keep-services-dialog/attributes-dialog.tsx b/services/workbench2/src/views-components/keep-services-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..d38aa15
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { compose } from 'redux';
+import {
+    withStyles, Dialog, DialogTitle, DialogContent, DialogActions,
+    Button, StyleRulesCallback, WithStyles, Grid
+} from '@material-ui/core';
+import { WithDialogProps, withDialog } from "store/dialog/with-dialog";
+import { KEEP_SERVICE_ATTRIBUTES_DIALOG } from 'store/keep-services/keep-services-actions';
+import { ArvadosTheme } from 'common/custom-theme';
+import { KeepServiceResource } from 'models/keep-services';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        fontSize: '0.875rem',
+        '& div:nth-child(odd)': {
+            textAlign: 'right',
+            color: theme.palette.grey["500"]
+        }
+    }
+});
+
+interface AttributesKeepServiceDialogDataProps {
+    keepService: KeepServiceResource;
+}
+
+export const AttributesKeepServiceDialog = compose(
+    withDialog(KEEP_SERVICE_ATTRIBUTES_DIALOG),
+    withStyles(styles))(
+        ({ open, closeDialog, data, classes }: WithDialogProps<AttributesKeepServiceDialogDataProps> & WithStyles<CssRules>) =>
+            <Dialog open={open} onClose={closeDialog} fullWidth maxWidth='sm'>
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    {data.keepService && <Grid container direction="row" spacing={16} className={classes.root}>
+                        <Grid item xs={5}>UUID</Grid>
+                        <Grid item xs={7}>{data.keepService.uuid}</Grid>
+                        <Grid item xs={5}>Read only</Grid>
+                        <Grid item xs={7}>{JSON.stringify(data.keepService.readOnly)}</Grid>
+                        <Grid item xs={5}>Service host</Grid>
+                        <Grid item xs={7}>{data.keepService.serviceHost}</Grid>
+                        <Grid item xs={5}>Service port</Grid>
+                        <Grid item xs={7}>{data.keepService.servicePort}</Grid>
+                        <Grid item xs={5}>Service SSL flag</Grid>
+                        <Grid item xs={7}>{JSON.stringify(data.keepService.serviceSslFlag)}</Grid>
+                        <Grid item xs={5}>Service type</Grid>
+                        <Grid item xs={7}>{data.keepService.serviceType}</Grid>
+                        <Grid item xs={5}>Owner uuid</Grid>
+                        <Grid item xs={7}>{data.keepService.ownerUuid}</Grid>
+                        <Grid item xs={5}>Created at</Grid>
+                        <Grid item xs={7}>{data.keepService.createdAt}</Grid>
+                        <Grid item xs={5}>Modified at</Grid>
+                        <Grid item xs={7}>{data.keepService.modifiedAt}</Grid>
+                        <Grid item xs={5}>Modified by user uuid</Grid>
+                        <Grid item xs={7}>{data.keepService.modifiedByUserUuid}</Grid>
+                        <Grid item xs={5}>Modified by client uuid</Grid>
+                        <Grid item xs={7}>{data.keepService.modifiedByClientUuid}</Grid>
+                    </Grid>}
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    );
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/keep-services-dialog/remove-dialog.tsx b/services/workbench2/src/views-components/keep-services-dialog/remove-dialog.tsx
new file mode 100644 (file)
index 0000000..dc7dd39
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { KEEP_SERVICE_REMOVE_DIALOG, removeKeepService } from 'store/keep-services/keep-services-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeKeepService(props.data.uuid));
+    }
+});
+
+export const RemoveKeepServiceDialog = compose(
+    withDialog(KEEP_SERVICE_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/links-dialog/attributes-dialog.tsx b/services/workbench2/src/views-components/links-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..a882aa9
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { compose } from 'redux';
+import { withStyles, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, WithStyles, Grid } from '@material-ui/core';
+import { WithDialogProps, withDialog } from "store/dialog/with-dialog";
+import { LINK_ATTRIBUTES_DIALOG } from 'store/link-panel/link-panel-actions';
+import { ArvadosTheme } from 'common/custom-theme';
+import { LinkResource } from 'models/link';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        fontSize: '0.875rem',
+        '& div:nth-child(odd)': {
+            textAlign: 'right',
+            color: theme.palette.grey["500"]
+        }
+    }
+});
+
+interface AttributesLinkDialogDataProps {
+    link: LinkResource;
+}
+
+export const AttributesLinkDialog = compose(
+    withDialog(LINK_ATTRIBUTES_DIALOG),
+    withStyles(styles))(
+    ({ open, closeDialog, data, classes }: WithDialogProps<AttributesLinkDialogDataProps> & WithStyles<CssRules>) =>
+            <Dialog open={open}
+                onClose={closeDialog}
+                fullWidth
+                maxWidth='sm'>
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    {data.link && <Grid container direction="row" spacing={16} className={classes.root}>
+                        <Grid item xs={5}>Uuid</Grid>
+                        <Grid item xs={7}>{data.link.uuid}</Grid>
+                        <Grid item xs={5}>Name</Grid>
+                        <Grid item xs={7}>{data.link.name}</Grid>
+                        <Grid item xs={5}>Head uuid</Grid>
+                        <Grid item xs={7}>{data.link.headUuid}</Grid>
+                        <Grid item xs={5}>Head kind</Grid>
+                        <Grid item xs={7}>{data.link.headKind}</Grid>
+                        <Grid item xs={5}>Tail uuid</Grid>
+                        <Grid item xs={7}>{data.link.tailUuid}</Grid>
+                        <Grid item xs={5}>Link class</Grid>
+                        <Grid item xs={7}>{data.link.linkClass}</Grid>
+                        <Grid item xs={5}>Owner uuid</Grid>
+                        <Grid item xs={7}>{data.link.ownerUuid}</Grid>
+                        <Grid item xs={5}>Created at</Grid>
+                        <Grid item xs={7}>{data.link.createdAt}</Grid>
+                        <Grid item xs={5}>Modified at</Grid>
+                        <Grid item xs={7}>{data.link.modifiedAt}</Grid>
+                        <Grid item xs={5}>Modified by user uuid</Grid>
+                        <Grid item xs={7}>{data.link.modifiedByUserUuid}</Grid>
+                        <Grid item xs={5}>Modified by client uuid</Grid>
+                        <Grid item xs={7}>{data.link.modifiedByClientUuid}</Grid>
+                    </Grid>}
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    );
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/links-dialog/remove-dialog.tsx b/services/workbench2/src/views-components/links-dialog/remove-dialog.tsx
new file mode 100644 (file)
index 0000000..8597bd1
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { LINK_REMOVE_DIALOG, removeLink } from 'store/link-panel/link-panel-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeLink(props.data.uuid));
+    }
+});
+
+export const RemoveLinkDialog = compose(
+    withDialog(LINK_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/login-form/login-form.tsx b/services/workbench2/src/views-components/login-form/login-form.tsx
new file mode 100644 (file)
index 0000000..7d71078
--- /dev/null
@@ -0,0 +1,159 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { useState, useEffect, useRef } from 'react';
+import { withStyles, WithStyles, StyleRulesCallback } from '@material-ui/core/styles';
+import CircularProgress from '@material-ui/core/CircularProgress';
+import { Button, Card, CardContent, TextField, CardActions } from '@material-ui/core';
+import { green } from '@material-ui/core/colors';
+import { AxiosPromise } from 'axios';
+import { DispatchProp } from 'react-redux';
+import { saveApiToken } from 'store/auth/auth-action';
+import { navigateToRootProject } from 'store/navigation/navigation-action';
+import { replace } from 'react-router-redux';
+import { PasswordLoginResponse } from 'views/login-panel/login-panel';
+
+type CssRules = 'root' | 'loginBtn' | 'card' | 'wrapper' | 'progress';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    root: {
+        display: 'flex',
+        flexWrap: 'wrap',
+        width: '100%',
+        margin: `${theme.spacing.unit} auto`
+    },
+    loginBtn: {
+        marginTop: theme.spacing.unit,
+        flexGrow: 1
+    },
+    card: {
+        marginTop: theme.spacing.unit,
+        width: '100%'
+    },
+    wrapper: {
+        margin: theme.spacing.unit,
+        position: 'relative',
+    },
+    progress: {
+        color: green[500],
+        position: 'absolute',
+        top: '50%',
+        left: '50%',
+        marginTop: -12,
+        marginLeft: -12,
+    },
+});
+
+type LoginFormProps = DispatchProp<any> & WithStyles<CssRules> & {
+    handleSubmit: (username: string, password: string) => AxiosPromise<PasswordLoginResponse>;
+    loginLabel?: string,
+};
+
+export const LoginForm = withStyles(styles)(
+    ({ handleSubmit, loginLabel, dispatch, classes }: LoginFormProps) => {
+        const userInput = useRef<HTMLInputElement>(null);
+        const [username, setUsername] = useState('');
+        const [password, setPassword] = useState('');
+        const [isButtonDisabled, setIsButtonDisabled] = useState(true);
+        const [isSubmitting, setSubmitting] = useState(false);
+        const [helperText, setHelperText] = useState('');
+        const [error, setError] = useState(false);
+
+        useEffect(() => {
+            setError(false);
+            setHelperText('');
+            if (username.trim() && password.trim()) {
+                setIsButtonDisabled(false);
+            } else {
+                setIsButtonDisabled(true);
+            }
+        }, [username, password]);
+
+        // This only runs once after render.
+        useEffect(() => {
+            setFocus();
+        }, []);
+
+        const setFocus = () => {
+            userInput.current!.focus();
+        };
+
+        const handleLogin = () => {
+            setError(false);
+            setHelperText('');
+            setSubmitting(true);
+            handleSubmit(username, password)
+                .then((response) => {
+                    setSubmitting(false);
+                    if (response.data.uuid && response.data.api_token) {
+                        const apiToken = `v2/${response.data.uuid}/${response.data.api_token}`;
+                        const rd = new URL(window.location.href);
+                        const rdUrl = rd.pathname + rd.search;
+                        dispatch<any>(saveApiToken(apiToken)).finally(
+                            () => {
+                                if ((new URL(window.location.href).pathname) !== '/my-account') {
+                                    rdUrl === '/' ? dispatch(navigateToRootProject) : dispatch(replace(rdUrl))
+                                }
+                            }
+                        );
+                    } else {
+                        setError(true);
+                        setHelperText(response.data.message || 'Please try again');
+                        setFocus();
+                    }
+                })
+                .catch((err) => {
+                    setError(true);
+                    setSubmitting(false);
+                    setHelperText(`${(err.response && err.response.data && err.response.data.errors[0]) || 'Error logging in: ' + err}`);
+                    setFocus();
+                });
+        };
+
+        const handleKeyPress = (e: any) => {
+            if (e.keyCode === 13 || e.which === 13) {
+                if (!isButtonDisabled) {
+                    handleLogin();
+                }
+            }
+        };
+
+        return (
+            <React.Fragment>
+                <form className={classes.root} noValidate autoComplete="off">
+                    <Card className={classes.card}>
+                        <div className={classes.wrapper}>
+                            <CardContent>
+                                <TextField
+                                    inputRef={userInput}
+                                    disabled={isSubmitting}
+                                    error={error} fullWidth id="username" type="email"
+                                    label="Username" margin="normal"
+                                    onChange={(e) => setUsername(e.target.value)}
+                                    onKeyPress={(e) => handleKeyPress(e)}
+                                />
+                                <TextField
+                                    disabled={isSubmitting}
+                                    error={error} fullWidth id="password" type="password"
+                                    label="Password" margin="normal"
+                                    helperText={helperText}
+                                    onChange={(e) => setPassword(e.target.value)}
+                                    onKeyPress={(e) => handleKeyPress(e)}
+                                />
+                            </CardContent>
+                            <CardActions>
+                                <Button variant="contained" size="large" color="primary"
+                                    className={classes.loginBtn} onClick={() => handleLogin()}
+                                    disabled={isSubmitting || isButtonDisabled}>
+                                    {loginLabel || 'Log in'}
+                                </Button>
+                            </CardActions>
+                            {isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
+                        </div>
+                    </Card>
+                </form>
+            </React.Fragment>
+        );
+    });
diff --git a/services/workbench2/src/views-components/main-app-bar/account-menu.test.tsx b/services/workbench2/src/views-components/main-app-bar/account-menu.test.tsx
new file mode 100644 (file)
index 0000000..1d7b77a
--- /dev/null
@@ -0,0 +1,51 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import Adapter from 'enzyme-adapter-react-16';
+import {configure, shallow } from 'enzyme';
+
+import { AccountMenuComponent } from './account-menu';
+
+configure({ adapter: new Adapter() });
+
+describe('<AccountMenu />', () => {
+    let props;
+    let wrapper;
+
+    beforeEach(() => {
+      props = {
+        classes: {},
+        user: {
+            email: 'email@example.com',
+            firstName: 'User',
+            lastName: 'Test',
+            uuid: 'zzzzz-tpzed-testuseruuid',
+            ownerUuid: '',
+            username: 'testuser',
+            prefs: {},
+            isAdmin: false,
+            isActive: true
+        },
+        currentRoute: '',
+        workbenchURL: '',
+        localCluser: 'zzzzz',
+        dispatch: jest.fn(),
+      };
+    });
+
+    describe('Logout Menu Item', () => {
+        beforeEach(() => {
+            wrapper = shallow(<AccountMenuComponent {...props} />).dive();
+        });
+
+        it('should dispatch a logout action when clicked', () => {
+            wrapper.find('[data-cy="logout-menuitem"]').simulate('click');
+            expect(props.dispatch).toHaveBeenCalledWith({
+                payload: {deleteLinkData: true, preservePath: false},
+                type: 'LOGOUT',
+            });
+        });
+    });
+});
diff --git a/services/workbench2/src/views-components/main-app-bar/account-menu.tsx b/services/workbench2/src/views-components/main-app-bar/account-menu.tsx
new file mode 100644 (file)
index 0000000..690b403
--- /dev/null
@@ -0,0 +1,91 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { MenuItem, Divider } from "@material-ui/core";
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { User, getUserDisplayName } from "models/user";
+import { DropdownMenu } from "components/dropdown-menu/dropdown-menu";
+import { UserPanelIcon } from "components/icon/icon";
+import { DispatchProp, connect } from 'react-redux';
+import { authActions, getNewExtraToken } from 'store/auth/auth-action';
+import { RootState } from "store/store";
+import { openTokenDialog } from 'store/token-dialog/token-dialog-actions';
+import { openRepositoriesPanel } from "store/repositories/repositories-actions";
+import {
+    navigateToSiteManager,
+    navigateToSshKeysUser,
+    navigateToMyAccount,
+    navigateToLinkAccount,
+} from 'store/navigation/navigation-action';
+import { openUserVirtualMachines } from "store/virtual-machines/virtual-machines-actions";
+import { pluginConfig } from 'plugins';
+import { ElementListReducer } from 'common/plugintypes';
+
+interface AccountMenuProps {
+    user?: User;
+    currentRoute: string;
+    workbenchURL: string;
+    apiToken?: string;
+    localCluster: string;
+}
+
+const mapStateToProps = (state: RootState): AccountMenuProps => ({
+    user: state.auth.user,
+    currentRoute: state.router.location ? state.router.location.pathname : '',
+    workbenchURL: state.auth.config.workbenchUrl,
+    apiToken: state.auth.apiToken,
+    localCluster: state.auth.localCluster
+});
+
+type CssRules = 'link';
+
+const styles: StyleRulesCallback<CssRules> = () => ({
+    link: {
+        textDecoration: 'none',
+        color: 'inherit'
+    }
+});
+
+export const AccountMenuComponent =
+    ({ user, dispatch, currentRoute, workbenchURL, apiToken, localCluster, classes }: AccountMenuProps & DispatchProp<any> & WithStyles<CssRules>) => {
+        let accountMenuItems = <>
+            <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Shell Access</MenuItem>
+            <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
+            <MenuItem onClick={() => {
+                dispatch<any>(getNewExtraToken(true));
+                dispatch(openTokenDialog);
+            }}>Get API token</MenuItem>
+            <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
+            <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
+            <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
+            <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
+        </>;
+
+        const reduceItemsFn: (a: React.ReactElement[],
+            b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a);
+
+        accountMenuItems = React.createElement(React.Fragment, null,
+            pluginConfig.accountMenuList.reduce(reduceItemsFn, React.Children.toArray(accountMenuItems.props.children)));
+
+        return user
+            ? <DropdownMenu
+                icon={<UserPanelIcon />}
+                id="account-menu"
+                title="Account Management"
+                key={currentRoute}>
+                <MenuItem disabled>
+                    {getUserDisplayName(user)} {user.uuid.substring(0, 5) !== localCluster && `(${user.uuid.substring(0, 5)})`}
+                </MenuItem>
+                {user.isActive && accountMenuItems}
+                <Divider />
+                <MenuItem data-cy="logout-menuitem"
+                    onClick={() => dispatch(authActions.LOGOUT({ deleteLinkData: true, preservePath: false }))}>
+                    Logout
+                </MenuItem>
+            </DropdownMenu>
+            : null;
+    };
+
+export const AccountMenu = withStyles(styles)(connect(mapStateToProps)(AccountMenuComponent));
diff --git a/services/workbench2/src/views-components/main-app-bar/admin-menu.tsx b/services/workbench2/src/views-components/main-app-bar/admin-menu.tsx
new file mode 100644 (file)
index 0000000..3059062
--- /dev/null
@@ -0,0 +1,43 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { MenuItem } from "@material-ui/core";
+import { User } from "models/user";
+import { DropdownMenu } from "components/dropdown-menu/dropdown-menu";
+import { AdminMenuIcon } from "components/icon/icon";
+import { DispatchProp, connect } from 'react-redux';
+import { RootState } from "store/store";
+import { openRepositoriesPanel } from "store/repositories/repositories-actions";
+import * as NavigationAction from 'store/navigation/navigation-action';
+import { openAdminVirtualMachines } from "store/virtual-machines/virtual-machines-actions";
+import { openUserPanel } from "store/users/users-actions";
+interface AdminMenuProps {
+    user?: User;
+    currentRoute: string;
+}
+
+const mapStateToProps = (state: RootState): AdminMenuProps => ({
+    user: state.auth.user,
+    currentRoute: state.router.location ? state.router.location.pathname : ''
+});
+
+export const AdminMenu = connect(mapStateToProps)(
+    ({ user, dispatch, currentRoute }: AdminMenuProps & DispatchProp<any>) =>
+        user
+            ? <DropdownMenu
+                icon={<AdminMenuIcon />}
+                id="admin-menu"
+                title="Admin Panel"
+                key={currentRoute}>
+                <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
+                <MenuItem onClick={() => dispatch(openAdminVirtualMachines())}>Shell Access</MenuItem>
+                <MenuItem onClick={() => dispatch(NavigationAction.navigateToSshKeysAdmin)}>Ssh Keys</MenuItem>
+                <MenuItem onClick={() => dispatch(NavigationAction.navigateToApiClientAuthorizations)}>Api Tokens</MenuItem>
+                <MenuItem onClick={() => dispatch(openUserPanel())}>Users</MenuItem>
+                <MenuItem onClick={() => dispatch(NavigationAction.navigateToGroups)}>Groups</MenuItem>
+                <MenuItem onClick={() => dispatch(NavigationAction.navigateToKeepServices)}>Keep Services</MenuItem>
+                <MenuItem onClick={() => dispatch(NavigationAction.navigateToLinks)}>Links</MenuItem>
+            </DropdownMenu>
+            : null);
diff --git a/services/workbench2/src/views-components/main-app-bar/anonymous-menu.tsx b/services/workbench2/src/views-components/main-app-bar/anonymous-menu.tsx
new file mode 100644 (file)
index 0000000..c3eb88c
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Button } from '@material-ui/core';
+import { DispatchProp, connect } from 'react-redux';
+import { login } from 'store/auth/auth-action';
+
+export const AnonymousMenu = connect()(
+    ({ dispatch }: DispatchProp<any>) =>
+        <Button
+            color="inherit"
+            onClick={() => dispatch(login("", "", "", {}))}>
+            Sign in
+        </Button>);
diff --git a/services/workbench2/src/views-components/main-app-bar/help-menu.tsx b/services/workbench2/src/views-components/main-app-bar/help-menu.tsx
new file mode 100644 (file)
index 0000000..af76e4f
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { MenuItem, Typography } from "@material-ui/core";
+import { DropdownMenu } from "components/dropdown-menu/dropdown-menu";
+import { ImportContactsIcon, HelpIcon } from "components/icon/icon";
+import { ArvadosTheme } from 'common/custom-theme';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { RootState } from "store/store";
+import { compose } from "redux";
+import { connect } from "react-redux";
+
+type CssRules = 'link' | 'icon' | 'title' | 'linkTitle';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    link: {
+        textDecoration: 'none',
+        color: 'inherit',
+        width: '100%',
+        display: 'flex'
+    },
+    icon: {
+        width: '16px',
+        height: '16px'
+    },
+    title: {
+        paddingBottom: theme.spacing.unit * 0.5,
+        paddingLeft: theme.spacing.unit * 2,
+        paddingTop: theme.spacing.unit * 0.5,
+        outline: 'none',
+    },
+    linkTitle: {
+        marginLeft: theme.spacing.unit
+    },
+});
+
+const links = [
+    {
+        title: "Tutorials and User guide",
+        link: "http://doc.arvados.org/user/",
+    },
+    {
+        title: "API Reference",
+        link: "http://doc.arvados.org/api/",
+    },
+    {
+        title: "SDK Reference",
+        link: "http://doc.arvados.org/sdk/"
+    },
+];
+
+interface HelpMenuProps {
+    currentRoute: string;
+}
+
+const mapStateToProps = ({ router }: RootState) => ({
+    currentRoute: router.location ? router.location.pathname : '',
+});
+
+export const HelpMenu = compose(
+    connect(mapStateToProps),
+    withStyles(styles))(
+        ({ classes, currentRoute }: HelpMenuProps & WithStyles<CssRules>) =>
+            <DropdownMenu
+                icon={<HelpIcon />}
+                id="help-menu"
+                title="Help"
+                key={currentRoute}>
+                <MenuItem disabled>Help</MenuItem>
+                {
+                    links.map(link =>
+                        <MenuItem key={link.title}>
+                            <a href={link.link} target="_blank" rel="noopener noreferrer" className={classes.link}>
+                                <ImportContactsIcon className={classes.icon} />
+                                <Typography className={classes.linkTitle}>{link.title}</Typography>
+                            </a>
+                        </MenuItem>
+                    )
+                }
+            </DropdownMenu>
+    );
diff --git a/services/workbench2/src/views-components/main-app-bar/main-app-bar.tsx b/services/workbench2/src/views-components/main-app-bar/main-app-bar.tsx
new file mode 100644 (file)
index 0000000..c57d5cd
--- /dev/null
@@ -0,0 +1,89 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { AppBar, Toolbar, Typography, Grid } from "@material-ui/core";
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { Link } from "react-router-dom";
+import { User } from "models/user";
+import { SearchBar } from "views-components/search-bar/search-bar";
+import { Routes } from 'routes/routes';
+import { NotificationsMenu } from "views-components/main-app-bar/notifications-menu";
+import { AccountMenu } from "views-components/main-app-bar/account-menu";
+import { HelpMenu } from 'views-components/main-app-bar/help-menu';
+import { ReactNode } from "react";
+import { AdminMenu } from "views-components/main-app-bar/admin-menu";
+import { pluginConfig } from 'plugins';
+import { sanitizeHTML } from "common/html-sanitize";
+
+type CssRules = 'toolbar' | 'link';
+
+const styles: StyleRulesCallback<CssRules> = () => ({
+    link: {
+        textDecoration: 'none',
+        color: 'inherit'
+    },
+    toolbar: {
+        height: '56px',
+    }
+});
+
+interface MainAppBarDataProps {
+    user?: User;
+    buildInfo?: string;
+    children?: ReactNode;
+    uuidPrefix: string;
+    siteBanner: string;
+    sidePanelIsCollapsed: boolean;
+}
+
+export type MainAppBarProps = MainAppBarDataProps & WithStyles<CssRules>;
+
+export const MainAppBar = withStyles(styles)(
+    (props: MainAppBarProps) => {
+        return <AppBar position="absolute">
+            <Toolbar className={props.classes.toolbar}>
+                <Grid container justify="space-between">
+                    {pluginConfig.appBarLeft || <Grid container item xs={3} direction="column" justify="center">
+                        <Typography variant='h6' color="inherit" noWrap>
+                            <Link to={Routes.ROOT} className={props.classes.link}>
+                                <span dangerouslySetInnerHTML={{ __html: sanitizeHTML(props.siteBanner) }} /> ({props.uuidPrefix})
+                </Link>
+                        </Typography>
+                        <Typography variant="caption" color="inherit">
+                            
+                            {props.buildInfo}</Typography>
+                    </Grid>}
+                    <Grid
+                        item
+                        xs={6}
+                        container
+                        alignItems="center">
+                        {pluginConfig.appBarMiddle || (props.user && props.user.isActive && <SearchBar />)}
+                    </Grid>
+                    <Grid
+                        item
+                        xs={3}
+                        container
+                        alignItems="center"
+                        justify="flex-end"
+                        wrap="nowrap">
+                        {props.user ? <>
+                            <NotificationsMenu />
+                            <AccountMenu />
+                            {pluginConfig.appBarRight ||
+                                <>
+                                    {props.user.isAdmin && <AdminMenu />}
+                                    <HelpMenu />
+                                </>}
+                        </> :
+                            pluginConfig.appBarRight || <HelpMenu />
+                        }
+                    </Grid>
+                </Grid>
+            </Toolbar>
+            {props.children}
+        </AppBar>;
+    }
+);
diff --git a/services/workbench2/src/views-components/main-app-bar/notifications-menu.tsx b/services/workbench2/src/views-components/main-app-bar/notifications-menu.tsx
new file mode 100644 (file)
index 0000000..631d316
--- /dev/null
@@ -0,0 +1,96 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { Badge, MenuItem } from "@material-ui/core";
+import { DropdownMenu } from "components/dropdown-menu/dropdown-menu";
+import { NotificationIcon } from "components/icon/icon";
+import bannerActions from "store/banner/banner-action";
+import { BANNER_LOCAL_STORAGE_KEY } from "views-components/baner/banner";
+import { RootState } from "store/store";
+import { TOOLTIP_LOCAL_STORAGE_KEY } from "store/tooltips/tooltips-middleware";
+import { useCallback } from "react";
+
+const mapStateToProps = (state: RootState): NotificationsMenuProps => ({
+    isOpen: state.banner.isOpen,
+    bannerUUID: state.auth.config.clusterConfig.Workbench.BannerUUID,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    openBanner: () => dispatch<any>(bannerActions.openBanner()),
+});
+
+type NotificationsMenuProps = {
+    isOpen: boolean;
+    bannerUUID?: string;
+};
+
+type NotificationsMenuComponentProps = NotificationsMenuProps & {
+    openBanner: any;
+};
+
+export const NotificationsMenuComponent = (props: NotificationsMenuComponentProps) => {
+    const { isOpen, openBanner } = props;
+    const bannerResult = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY);
+    const tooltipResult = localStorage.getItem(TOOLTIP_LOCAL_STORAGE_KEY);
+    const menuItems: any[] = [];
+
+    if (!isOpen && bannerResult) {
+        menuItems.push(
+            <MenuItem onClick={openBanner} data-cy="restore-banner-li">
+                <span>Restore Banner</span>
+            </MenuItem>
+        );
+    }
+
+    const toggleTooltips = useCallback(() => {
+        if (tooltipResult) {
+            localStorage.removeItem(TOOLTIP_LOCAL_STORAGE_KEY);
+        } else {
+            localStorage.setItem(TOOLTIP_LOCAL_STORAGE_KEY, "true");
+        }
+        window.location.reload();
+    }, [tooltipResult]);
+
+    if (tooltipResult) {
+        menuItems.push(
+            <MenuItem onClick={toggleTooltips} data-cy="enable-tooltip-toggle">
+                <span>Enable tooltips</span>
+            </MenuItem>
+        );
+    } else {
+        menuItems.push(
+            <MenuItem onClick={toggleTooltips} data-cy="disable-tooltip-toggle">
+                <span>Disable tooltips</span>
+            </MenuItem>
+        );
+    }
+
+    if (menuItems.length === 0) {
+        menuItems.push(<MenuItem>You are up to date</MenuItem>);
+    }
+
+    return (
+        <DropdownMenu
+            icon={
+                <Badge
+                    badgeContent={0}
+                    color="primary"
+                >
+                    <NotificationIcon />
+                </Badge>
+            }
+            id="account-menu"
+            title="Notifications"
+        >
+            {menuItems.map((item, i) => (
+                <div key={i}>{item}</div>
+            ))}
+        </DropdownMenu>
+    );
+};
+
+export const NotificationsMenu = connect(mapStateToProps, mapDispatchToProps)(NotificationsMenuComponent);
diff --git a/services/workbench2/src/views-components/main-content-bar/main-content-bar.tsx b/services/workbench2/src/views-components/main-content-bar/main-content-bar.tsx
new file mode 100644 (file)
index 0000000..3f4de30
--- /dev/null
@@ -0,0 +1,90 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+
+import { Toolbar, StyleRulesCallback, IconButton, Tooltip, Grid, WithStyles, withStyles } from "@material-ui/core";
+import { DetailsIcon } from "components/icon/icon";
+import { Breadcrumbs } from "views-components/breadcrumbs/breadcrumbs";
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import * as Routes from 'routes/routes';
+import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import RefreshButton from "components/refresh-button/refresh-button";
+import { loadSidePanelTreeProjects } from "store/side-panel-tree/side-panel-tree-actions";
+import { Dispatch } from "redux";
+
+type CssRules = 'mainBar' | 'breadcrumbContainer' | 'infoTooltip';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    mainBar: {
+        flexWrap: 'nowrap',
+    },
+    breadcrumbContainer: {
+        overflow: 'hidden',
+    },
+    infoTooltip: {
+        marginTop: '-10px',
+        marginLeft: '10px',
+    }
+});
+
+interface MainContentBarProps {
+    onRefreshPage: () => void;
+    onDetailsPanelToggle: () => void;
+    buttonVisible: boolean;
+}
+
+const isButtonVisible = ({ router }: RootState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    return Routes.matchCollectionsContentAddressRoute(pathname) ||
+        Routes.matchPublicFavoritesRoute(pathname) ||
+        Routes.matchGroupDetailsRoute(pathname) ||
+        Routes.matchGroupsRoute(pathname) ||
+        Routes.matchUsersRoute(pathname) ||
+        Routes.matchSearchResultsRoute(pathname) ||
+        Routes.matchSharedWithMeRoute(pathname) ||
+        Routes.matchProcessRoute(pathname) ||
+        Routes.matchCollectionRoute(pathname) ||
+        Routes.matchProjectRoute(pathname) ||
+        Routes.matchAllProcessesRoute(pathname) ||
+        Routes.matchTrashRoute(pathname) ||
+        Routes.matchFavoritesRoute(pathname);
+};
+
+const mapStateToProps = (state: RootState) => ({
+    buttonVisible: isButtonVisible(state),
+    projectUuid: state.detailsPanel.resourceUuid,
+});
+
+const mapDispatchToProps = () => (dispatch: Dispatch) => ({
+    onDetailsPanelToggle: () => dispatch<any>(toggleDetailsPanel()),
+    onRefreshButtonClick: (id) => {
+        dispatch<any>(loadSidePanelTreeProjects(id));
+    }
+});
+
+export const MainContentBar = connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(
+    (props: MainContentBarProps & WithStyles<CssRules> & any) =>
+        <Toolbar><Grid container className={props.classes.mainBar}>
+            <Grid container item xs alignItems="center" className={props.classes.breadcrumbContainer}>
+                <Breadcrumbs />
+            </Grid>
+            <Grid item>
+                <RefreshButton onClick={() => {
+                    props.onRefreshButtonClick(props.projectUuid);
+                }} />
+            </Grid>
+            <Grid item>
+                {props.buttonVisible && <Tooltip title="Additional Info">
+                    <IconButton data-cy="additional-info-icon"
+                        color="inherit"
+                        className={props.classes.infoTooltip}
+                        onClick={props.onDetailsPanelToggle}>
+                        <DetailsIcon />
+                    </IconButton>
+                </Tooltip>}
+            </Grid>
+        </Grid></Toolbar>
+));
diff --git a/services/workbench2/src/views-components/multiselect-toolbar/ms-collection-action-set.ts b/services/workbench2/src/views-components/multiselect-toolbar/ms-collection-action-set.ts
new file mode 100644 (file)
index 0000000..19709fa
--- /dev/null
@@ -0,0 +1,107 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { MoveToIcon, CopyIcon, RenameIcon, ShareIcon } from "components/icon/icon";
+import { openMoveCollectionDialog } from "store/collections/collection-move-actions";
+import { openCollectionCopyDialog, openMultiCollectionCopyDialog } from "store/collections/collection-copy-actions";
+import { toggleCollectionTrashed } from "store/trash/trash-actions";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { msCommonActionSet, MultiSelectMenuActionSet, MultiSelectMenuAction } from "./ms-menu-actions";
+import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { TrashIcon, Link, FolderSharedIcon } from "components/icon/icon";
+import { openCollectionUpdateDialog } from "store/collections/collection-update-actions";
+import { copyToClipboardAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions";
+import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
+
+
+const { MAKE_A_COPY, MOVE_TO, MOVE_TO_TRASH, EDIT_COLLECTION, OPEN_IN_NEW_TAB, OPEN_WITH_3RD_PARTY_CLIENT, COPY_LINK_TO_CLIPBOARD, VIEW_DETAILS, API_DETAILS, ADD_TO_FAVORITES, SHARE} = ContextMenuActionNames;
+
+const msCopyCollection: MultiSelectMenuAction = {
+    name: MAKE_A_COPY,
+    icon: CopyIcon,
+    hasAlts: false,
+    isForMulti: true,
+    execute: (dispatch, [...resources]) => {
+        if (resources[0].fromContextMenu || resources.length === 1) dispatch<any>(openCollectionCopyDialog(resources[0]));
+        else dispatch<any>(openMultiCollectionCopyDialog(resources[0]));
+    },
+}
+
+const msMoveCollection: MultiSelectMenuAction = {
+    name: MOVE_TO,
+    icon: MoveToIcon,
+    hasAlts: false,
+    isForMulti: true,
+    execute: (dispatch, resources) => dispatch<any>(openMoveCollectionDialog(resources[0])),
+}
+
+const msToggleTrashAction: MultiSelectMenuAction = {
+    name: MOVE_TO_TRASH,
+    icon: TrashIcon,
+    isForMulti: true,
+    hasAlts: false,
+    execute: (dispatch, resources: ContextMenuResource[]) => {
+        for (const resource of [...resources]) {
+            dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
+        }
+    },
+}
+
+const msEditCollection: MultiSelectMenuAction = {
+    name: ContextMenuActionNames.EDIT_COLLECTION,
+    icon: RenameIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openCollectionUpdateDialog(resources[0]));
+    },
+}
+
+const msCopyToClipboardMenuAction: MultiSelectMenuAction  = {
+    name: COPY_LINK_TO_CLIPBOARD,
+    icon: Link,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(copyToClipboardAction(resources));
+    },
+};
+
+const msOpenWith3rdPartyClientAction: MultiSelectMenuAction  = {
+    name: OPEN_WITH_3RD_PARTY_CLIENT,
+    icon: FolderSharedIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openWebDavS3InfoDialog(resources[0].uuid));
+    },
+};
+
+const msShareAction: MultiSelectMenuAction  = {
+    name: SHARE,
+    icon: ShareIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openSharingDialog(resources[0].uuid));
+    },
+};
+
+export const msCollectionActionSet: MultiSelectMenuActionSet = [
+    [
+        ...msCommonActionSet,
+        msCopyCollection,
+        msMoveCollection,
+        msToggleTrashAction,
+        msEditCollection,
+        msCopyToClipboardMenuAction,
+        msOpenWith3rdPartyClientAction,
+        msShareAction,
+    ],
+];
+
+export const msReadOnlyCollectionActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_LINK_TO_CLIPBOARD, MAKE_A_COPY, VIEW_DETAILS, API_DETAILS, ADD_TO_FAVORITES, OPEN_WITH_3RD_PARTY_CLIENT]);
+export const msCommonCollectionActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_LINK_TO_CLIPBOARD, MAKE_A_COPY, VIEW_DETAILS, API_DETAILS, OPEN_WITH_3RD_PARTY_CLIENT, EDIT_COLLECTION, SHARE, MOVE_TO, ADD_TO_FAVORITES, MOVE_TO_TRASH])
+export const msOldCollectionActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_LINK_TO_CLIPBOARD, MAKE_A_COPY, VIEW_DETAILS, API_DETAILS, OPEN_WITH_3RD_PARTY_CLIENT, EDIT_COLLECTION, SHARE, MOVE_TO, ADD_TO_FAVORITES, MOVE_TO_TRASH])
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/multiselect-toolbar/ms-menu-actions.ts b/services/workbench2/src/views-components/multiselect-toolbar/ms-menu-actions.ts
new file mode 100644 (file)
index 0000000..12840cd
--- /dev/null
@@ -0,0 +1,108 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { IconType } from 'components/icon/icon';
+import { ResourcesState } from 'store/resources/resources';
+import { FavoritesState } from 'store/favorites/favorites-reducer';
+import { ContextMenuResource } from 'store/context-menu/context-menu-actions';
+import { AddFavoriteIcon, AdvancedIcon, DetailsIcon, OpenIcon, PublicFavoriteIcon, RemoveFavoriteIcon } from 'components/icon/icon';
+import { checkFavorite } from 'store/favorites/favorites-reducer';
+import { toggleFavorite } from 'store/favorites/favorites-actions';
+import { favoritePanelActions } from 'store/favorite-panel/favorite-panel-action';
+import { openInNewTabAction } from 'store/open-in-new-tab/open-in-new-tab.actions';
+import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
+import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { PublicFavoritesState } from 'store/public-favorites/public-favorites-reducer';
+import { ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+
+export type MultiSelectMenuAction = {
+    name: string;
+    icon: IconType;
+    hasAlts: boolean;
+    altName?: string;
+    altIcon?: IconType;
+    isForMulti: boolean;
+    useAlts?: (uuid: string | null, iconProps: {resources: ResourcesState, favorites: FavoritesState, publicFavorites: PublicFavoritesState}) => boolean;
+    execute(dispatch: Dispatch, resources: ContextMenuResource[], state?: any): void;
+    adminOnly?: boolean;
+};
+
+export type MultiSelectMenuActionSet = MultiSelectMenuAction[][];
+
+const { ADD_TO_FAVORITES, ADD_TO_PUBLIC_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS } = ContextMenuActionNames;
+
+const msToggleFavoriteAction: MultiSelectMenuAction = {
+    name: ADD_TO_FAVORITES,
+    icon: AddFavoriteIcon,
+    hasAlts: true,
+    altName: 'Remove from Favorites',
+    altIcon: RemoveFavoriteIcon,
+    isForMulti: false,
+    useAlts: (uuid: string, iconProps) => {
+        return checkFavorite(uuid, iconProps.favorites);
+    },
+    execute: (dispatch, resources) => {
+        dispatch<any>(toggleFavorite(resources[0])).then(() => {
+            dispatch(favoritePanelActions.REQUEST_ITEMS());
+        });
+    },
+};
+
+const msOpenInNewTabMenuAction: MultiSelectMenuAction  = {
+    name: OPEN_IN_NEW_TAB,
+    icon: OpenIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openInNewTabAction(resources[0]));
+    },
+};
+
+const msViewDetailsAction: MultiSelectMenuAction  = {
+    name: VIEW_DETAILS,
+    icon: DetailsIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch) => {
+        dispatch<any>(toggleDetailsPanel());
+    },
+};
+
+const msAdvancedAction: MultiSelectMenuAction  = {
+    name: API_DETAILS,
+    icon: AdvancedIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+    },
+};
+
+const msTogglePublicFavoriteAction: MultiSelectMenuAction = {
+    name: ADD_TO_PUBLIC_FAVORITES,
+    icon: PublicFavoriteIcon,
+    hasAlts: true,
+    altName: 'Remove from public favorites',
+    altIcon: PublicFavoriteIcon,
+    isForMulti: false,
+    useAlts: (uuid: string, iconProps) => {
+        return iconProps.publicFavorites[uuid] === true
+    },
+    execute: (dispatch, resources) => {
+        dispatch<any>(togglePublicFavorite(resources[0])).then(() => {
+            dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
+        });
+    },
+};
+
+export const msCommonActionSet = [
+    msToggleFavoriteAction,
+    msOpenInNewTabMenuAction,
+    msViewDetailsAction,
+    msAdvancedAction,
+    msTogglePublicFavoriteAction
+];
diff --git a/services/workbench2/src/views-components/multiselect-toolbar/ms-process-action-set.ts b/services/workbench2/src/views-components/multiselect-toolbar/ms-process-action-set.ts
new file mode 100644 (file)
index 0000000..73aebe2
--- /dev/null
@@ -0,0 +1,98 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RemoveIcon, ReRunProcessIcon, OutputIcon, RenameIcon, StopIcon } from "components/icon/icon";
+import { openCopyProcessDialog } from "store/processes/process-copy-actions";
+import { openRemoveProcessDialog } from "store/processes/processes-actions";
+import { MultiSelectMenuAction, MultiSelectMenuActionSet, msCommonActionSet } from "./ms-menu-actions";
+import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { openProcessUpdateDialog } from "store/processes/process-update-actions";
+import { msNavigateToOutput } from "store/multiselect/multiselect-actions";
+import { cancelRunningWorkflow } from "store/processes/processes-actions";
+
+const msCopyAndRerunProcess: MultiSelectMenuAction = {
+    name: ContextMenuActionNames.COPY_AND_RERUN_PROCESS,
+    icon: ReRunProcessIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        for (const resource of [...resources]) {
+            dispatch<any>(openCopyProcessDialog(resource));
+        }
+    },
+}
+
+const msRemoveProcess: MultiSelectMenuAction = {
+    name: ContextMenuActionNames.REMOVE,
+    icon: RemoveIcon,
+    hasAlts: false,
+    isForMulti: true,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openRemoveProcessDialog(resources[0], resources.length));
+    },
+}
+
+// removed until auto-move children is implemented
+// const msMoveTo: MultiSelectMenuAction = {
+//     name: ContextMenuActionNames.MOVE_TO,
+//     icon: MoveToIcon,
+//     hasAlts: false,
+//     isForMulti: true,
+//     execute: (dispatch, resources) => {
+//         dispatch<any>(openMoveProcessDialog(resources[0]));
+//     },
+// }
+
+const msViewOutputs: MultiSelectMenuAction = {
+    name: ContextMenuActionNames.OUTPUTS,
+    icon: OutputIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+                if (resources[0]) {
+            dispatch<any>(msNavigateToOutput(resources[0]));
+        }
+    },
+}
+
+const msEditProcess: MultiSelectMenuAction = {
+    name: ContextMenuActionNames.EDIT_PROCESS,
+    icon: RenameIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openProcessUpdateDialog(resources[0]));
+    },
+}
+
+const msCancelProcess: MultiSelectMenuAction = {
+    name: ContextMenuActionNames.CANCEL,
+    icon: StopIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(cancelRunningWorkflow(resources[0].uuid));
+    },
+}
+
+export const msProcessActionSet: MultiSelectMenuActionSet = [
+    [
+        ...msCommonActionSet,
+        msCopyAndRerunProcess,
+        msRemoveProcess,
+        // msMoveTo,
+        msViewOutputs,
+        msEditProcess,
+        msCancelProcess
+    ]
+];
+
+const {REMOVE, COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, SHARE, ADD_TO_PUBLIC_FAVORITES, OUTPUTS, EDIT_PROCESS, CANCEL } = ContextMenuActionNames
+
+export const msCommonProcessActionFilter = new Set([REMOVE, COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, SHARE, OUTPUTS, EDIT_PROCESS ]);
+export const msRunningProcessActionFilter = new Set([REMOVE, COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, SHARE, OUTPUTS, EDIT_PROCESS, CANCEL ]);
+
+export const msReadOnlyProcessActionFilter = new Set([COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, OUTPUTS ]);
+export const msAdminProcessActionFilter = new Set([REMOVE, COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, SHARE, ADD_TO_PUBLIC_FAVORITES, OUTPUTS, EDIT_PROCESS ]);
+
diff --git a/services/workbench2/src/views-components/multiselect-toolbar/ms-project-action-set.ts b/services/workbench2/src/views-components/multiselect-toolbar/ms-project-action-set.ts
new file mode 100644 (file)
index 0000000..0723eaa
--- /dev/null
@@ -0,0 +1,171 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { MultiSelectMenuAction, MultiSelectMenuActionSet, msCommonActionSet } from 'views-components/multiselect-toolbar/ms-menu-actions';
+import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set";
+import { openMoveProjectDialog } from 'store/projects/project-move-actions';
+import { toggleProjectTrashed } from 'store/trash/trash-actions';
+import {
+    FreezeIcon,
+    MoveToIcon,
+    NewProjectIcon,
+    RenameIcon,
+    UnfreezeIcon,
+    ShareIcon,
+} from 'components/icon/icon';
+import { RestoreFromTrashIcon, TrashIcon, FolderSharedIcon, Link } from 'components/icon/icon';
+import { getResource } from 'store/resources/resources';
+import { openProjectCreateDialog } from 'store/projects/project-create-actions';
+import { openProjectUpdateDialog } from 'store/projects/project-update-actions';
+import { freezeProject, unfreezeProject } from 'store/projects/project-lock-actions';
+import { openWebDavS3InfoDialog } from 'store/collections/collection-info-actions';
+import { copyToClipboardAction } from 'store/open-in-new-tab/open-in-new-tab.actions';
+import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
+
+const {
+    ADD_TO_FAVORITES,
+    ADD_TO_PUBLIC_FAVORITES,
+    OPEN_IN_NEW_TAB,
+    COPY_LINK_TO_CLIPBOARD,
+    VIEW_DETAILS,
+    API_DETAILS,
+    OPEN_WITH_3RD_PARTY_CLIENT,
+    EDIT_PROJECT,
+    SHARE,
+    MOVE_TO,
+    MOVE_TO_TRASH,
+    FREEZE_PROJECT,
+    NEW_PROJECT,
+} = ContextMenuActionNames;
+
+const msCopyToClipboardMenuAction: MultiSelectMenuAction  = {
+    name: COPY_LINK_TO_CLIPBOARD,
+    icon: Link,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(copyToClipboardAction(resources));
+    },
+};
+
+const msEditProjectAction: MultiSelectMenuAction = {
+    name: EDIT_PROJECT,
+    icon: RenameIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openProjectUpdateDialog(resources[0]));
+    },
+};
+
+const msMoveToAction: MultiSelectMenuAction = {
+    name: MOVE_TO,
+    icon: MoveToIcon,
+    hasAlts: false,
+    isForMulti: true,
+    execute: (dispatch, resource) => {
+        dispatch<any>(openMoveProjectDialog(resource[0]));
+    },
+};
+
+const msOpenWith3rdPartyClientAction: MultiSelectMenuAction  = {
+    name: OPEN_WITH_3RD_PARTY_CLIENT,
+    icon: FolderSharedIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openWebDavS3InfoDialog(resources[0].uuid));
+    },
+};
+
+export const msToggleTrashAction: MultiSelectMenuAction = {
+    name: MOVE_TO_TRASH,
+    icon: TrashIcon,
+    hasAlts: true,
+    altName: 'Restore from Trash',
+    altIcon: RestoreFromTrashIcon,
+    isForMulti: true,
+    useAlts: (uuid, iconProps) => {
+        return uuid ? (getResource(uuid)(iconProps.resources) as any).isTrashed : false;
+    },
+    execute: (dispatch, resources) => {
+        for (const resource of [...resources]) {
+            dispatch<any>(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!, resources.length > 1));
+        }
+    },
+};
+
+const msFreezeProjectAction: MultiSelectMenuAction = {
+    name: FREEZE_PROJECT,
+    icon: FreezeIcon,
+    hasAlts: true,
+    altName: 'Unfreeze Project',
+    altIcon: UnfreezeIcon,
+    isForMulti: false,
+    useAlts: (uuid, iconProps) => {
+        return uuid ? !!(getResource(uuid)(iconProps.resources) as any).frozenByUuid : false;
+    },
+    execute: (dispatch, resources) => {
+        if ((resources[0] as any).frozenByUuid) {
+            dispatch<any>(unfreezeProject(resources[0].uuid));
+        } else {
+            dispatch<any>(freezeProject(resources[0].uuid));
+        }
+    },
+};
+
+const msNewProjectAction: MultiSelectMenuAction = {
+    name: NEW_PROJECT,
+    icon: NewProjectIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources): void => {
+        dispatch<any>(openProjectCreateDialog(resources[0].uuid));
+    },
+};
+
+const msShareAction: MultiSelectMenuAction  = {
+    name: SHARE,
+    icon: ShareIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openSharingDialog(resources[0].uuid));
+    },
+};
+
+export const msProjectActionSet: MultiSelectMenuActionSet = [
+    [
+        ...msCommonActionSet,
+        msEditProjectAction,
+        msMoveToAction,
+        msToggleTrashAction,
+        msNewProjectAction,
+        msFreezeProjectAction,
+        msOpenWith3rdPartyClientAction,
+        msCopyToClipboardMenuAction,
+        msShareAction,
+    ],
+];
+
+export const msCommonProjectActionFilter = new Set<string>([
+    ADD_TO_FAVORITES,
+    MOVE_TO_TRASH,
+    API_DETAILS,
+    COPY_LINK_TO_CLIPBOARD,
+    EDIT_PROJECT,
+    FREEZE_PROJECT,
+    MOVE_TO,
+    NEW_PROJECT,
+    OPEN_IN_NEW_TAB,
+    OPEN_WITH_3RD_PARTY_CLIENT,
+    SHARE,
+    VIEW_DETAILS,
+]);
+export const msReadOnlyProjectActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_LINK_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_WITH_3RD_PARTY_CLIENT, VIEW_DETAILS,]);
+export const msFrozenProjectActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_LINK_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_WITH_3RD_PARTY_CLIENT, VIEW_DETAILS, SHARE, FREEZE_PROJECT])
+export const msAdminFrozenProjectActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_LINK_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_WITH_3RD_PARTY_CLIENT, VIEW_DETAILS, SHARE, FREEZE_PROJECT, ADD_TO_PUBLIC_FAVORITES])
+
+export const msFilterGroupActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_LINK_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_WITH_3RD_PARTY_CLIENT, VIEW_DETAILS, SHARE, MOVE_TO_TRASH, EDIT_PROJECT, MOVE_TO])
+export const msAdminFilterGroupActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_LINK_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_WITH_3RD_PARTY_CLIENT, VIEW_DETAILS, SHARE, MOVE_TO_TRASH, EDIT_PROJECT, MOVE_TO, ADD_TO_PUBLIC_FAVORITES])
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/multiselect-toolbar/ms-workflow-action-set.ts b/services/workbench2/src/views-components/multiselect-toolbar/ms-workflow-action-set.ts
new file mode 100644 (file)
index 0000000..9c5cdd7
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { openRunProcess, deleteWorkflow } from 'store/workflow-panel/workflow-panel-actions';
+import { StartIcon, TrashIcon, Link } from 'components/icon/icon';
+import { MultiSelectMenuAction, MultiSelectMenuActionSet, msCommonActionSet } from './ms-menu-actions';
+import { ContextMenuActionNames } from 'views-components/context-menu/context-menu-action-set';
+import { copyToClipboardAction } from 'store/open-in-new-tab/open-in-new-tab.actions';
+import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
+import { ShareIcon } from 'components/icon/icon';
+
+const { OPEN_IN_NEW_TAB, COPY_LINK_TO_CLIPBOARD, VIEW_DETAILS, API_DETAILS, RUN_WORKFLOW, DELETE_WORKFLOW, SHARE } = ContextMenuActionNames;
+
+const msRunWorkflow: MultiSelectMenuAction = {
+    name: RUN_WORKFLOW,
+    icon: StartIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openRunProcess(resources[0].uuid, resources[0].ownerUuid, resources[0].name));
+    },
+};
+
+const msDeleteWorkflow: MultiSelectMenuAction = {
+    name: DELETE_WORKFLOW,
+    icon: TrashIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(deleteWorkflow(resources[0].uuid, resources[0].ownerUuid));
+    },
+};
+
+const msCopyToClipboardMenuAction: MultiSelectMenuAction  = {
+    name: COPY_LINK_TO_CLIPBOARD,
+    icon: Link,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(copyToClipboardAction(resources));
+    },
+};
+
+const msShareAction: MultiSelectMenuAction  = {
+    name: SHARE,
+    icon: ShareIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openSharingDialog(resources[0].uuid));
+    },
+};
+
+export const msWorkflowActionSet: MultiSelectMenuActionSet = [[...msCommonActionSet, msRunWorkflow, msDeleteWorkflow, msCopyToClipboardMenuAction, msShareAction]];
+
+export const msReadOnlyWorkflowActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_LINK_TO_CLIPBOARD, VIEW_DETAILS, API_DETAILS, RUN_WORKFLOW ]);
+export const msWorkflowActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_LINK_TO_CLIPBOARD, VIEW_DETAILS, API_DETAILS, RUN_WORKFLOW, DELETE_WORKFLOW]);
diff --git a/services/workbench2/src/views-components/not-found-dialog/not-found-dialog.tsx b/services/workbench2/src/views-components/not-found-dialog/not-found-dialog.tsx
new file mode 100644 (file)
index 0000000..eee64b6
--- /dev/null
@@ -0,0 +1,65 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { RootState } from 'store/store';
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { NOT_FOUND_DIALOG_NAME } from 'store/not-found-panel/not-found-panel-action';
+import { Dialog, DialogContent, DialogActions, Button, withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { NotFoundPanel } from "views/not-found-panel/not-found-panel";
+
+type CssRules = 'tag';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    tag: {
+        marginRight: theme.spacing.unit,
+        marginBottom: theme.spacing.unit
+    }
+});
+
+interface NotFoundDialogDataProps {
+
+}
+
+interface NotFoundDialogActionProps {
+
+}
+
+const mapStateToProps = (state: RootState): NotFoundDialogDataProps => ({
+
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): NotFoundDialogActionProps => ({
+
+});
+
+type NotFoundDialogProps =  NotFoundDialogDataProps & NotFoundDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
+
+export const NotFoundDialog = connect(mapStateToProps, mapDispatchToProps)(
+    withStyles(styles)(
+    withDialog(NOT_FOUND_DIALOG_NAME)(
+        ({ open, closeDialog }: NotFoundDialogProps) =>
+            <Dialog open={open}
+                onClose={closeDialog}
+                fullWidth
+                maxWidth='md'
+                disableBackdropClick
+                disableEscapeKeyDown>
+                <DialogContent>
+                    <NotFoundPanel notWrapped />
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    )
+));
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/process-input-dialog/process-input-dialog.tsx b/services/workbench2/src/views-components/process-input-dialog/process-input-dialog.tsx
new file mode 100644 (file)
index 0000000..9a18688
--- /dev/null
@@ -0,0 +1,49 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogActions, Button, CardHeader, DialogContent } from '@material-ui/core';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { withDialog } from "store/dialog/with-dialog";
+import { PROCESS_INPUT_DIALOG_NAME } from 'store/processes/process-input-actions';
+import { RunProcessInputsForm } from "views/run-process-panel/run-process-inputs-form";
+import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from "models/process";
+import { getWorkflowInputs } from "models/workflow";
+
+export const ProcessInputDialog = withDialog(PROCESS_INPUT_DIALOG_NAME)(
+    (props: WithDialogProps<any>) =>
+        <Dialog
+            open={props.open}
+            maxWidth={false}
+            onClose={props.closeDialog}>
+            <CardHeader
+                title="Inputs - Pipeline template that generates a config file from a template" />
+            <DialogContent>
+                <RunProcessInputsForm inputs={getInputs(props.data.containerRequest)} />
+            </DialogContent>
+            <DialogActions>
+                <Button
+                    variant='text'
+                    color='primary'
+                    onClick={props.closeDialog}>
+                    Close
+                </Button>
+            </DialogActions>
+        </Dialog>
+);
+
+const getInputs = (data: any) => {
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
+    const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
+    return inputs
+        ? inputs.map( (it: any) => (
+            {
+                type: it.type,
+                id: it.id,
+                label: it.label,
+                value: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id.split('/').pop()] || [],
+                disabled: true
+            }))
+        : [];
+};
diff --git a/services/workbench2/src/views-components/process-remove-dialog/process-remove-dialog.tsx b/services/workbench2/src/views-components/process-remove-dialog/process-remove-dialog.tsx
new file mode 100644 (file)
index 0000000..99bfd97
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { removeProcessPermanently, REMOVE_PROCESS_DIALOG } from 'store/processes/processes-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeProcessPermanently(props.data.uuid));
+    }
+});
+
+export const RemoveProcessDialog = compose(
+    withDialog(REMOVE_PROCESS_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
diff --git a/services/workbench2/src/views-components/process-runtime-status/process-runtime-status.tsx b/services/workbench2/src/views-components/process-runtime-status/process-runtime-status.tsx
new file mode 100644 (file)
index 0000000..4761e12
--- /dev/null
@@ -0,0 +1,124 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    ExpansionPanel,
+    ExpansionPanelDetails,
+    ExpansionPanelSummary,
+    Paper,
+    StyleRulesCallback,
+    Typography,
+    withStyles,
+    WithStyles
+} from "@material-ui/core";
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import { RuntimeStatus } from "models/runtime-status";
+import { ArvadosTheme } from 'common/custom-theme';
+import classNames from 'classnames';
+
+type CssRules = 'root'
+    | 'heading'
+    | 'summary'
+    | 'summaryText'
+    | 'details'
+    | 'detailsText'
+    | 'error'
+    | 'errorColor'
+    | 'warning'
+    | 'warningColor'
+    | 'paperRoot';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        marginBottom: theme.spacing.unit * 1,
+    },
+    heading: {
+        fontSize: '1rem',
+    },
+    summary: {
+        paddingLeft: theme.spacing.unit * 1,
+        paddingRight: theme.spacing.unit * 1,
+    },
+    summaryText: {
+        whiteSpace: 'pre-line',
+    },
+    details: {
+        paddingLeft: theme.spacing.unit * 1,
+        paddingRight: theme.spacing.unit * 1,
+    },
+    detailsText: {
+        fontSize: '0.8rem',
+        marginTop: '0px',
+        marginBottom: '0px',
+        whiteSpace: 'pre-line',
+    },
+    errorColor: {
+        color: theme.customs.colors.grey700,
+    },
+    error: {
+        backgroundColor: theme.customs.colors.red100,
+
+    },
+    warning: {
+        backgroundColor: theme.customs.colors.yellow100,
+    },
+    warningColor: {
+        color: theme.customs.colors.grey700,
+    },
+    paperRoot: {
+        minHeight: theme.spacing.unit * 6,
+        display: 'flex',
+        alignItems: 'center',
+    },
+});
+export interface ProcessRuntimeStatusDataProps {
+    runtimeStatus: RuntimeStatus | undefined;
+    containerCount: number;
+}
+
+type ProcessRuntimeStatusProps = ProcessRuntimeStatusDataProps & WithStyles<CssRules>;
+
+export const ProcessRuntimeStatus = withStyles(styles)(
+    ({ runtimeStatus, containerCount, classes }: ProcessRuntimeStatusProps) => {
+    return <div className={classes.root}>
+        { runtimeStatus?.error &&
+        <div data-cy='process-runtime-status-error'><ExpansionPanel className={classes.error} elevation={0}>
+            <ExpansionPanelSummary className={classNames(classes.summary, classes.detailsText)} expandIcon={<ExpandMoreIcon />}>
+                <Typography className={classNames(classes.heading, classes.errorColor)}>
+                    {`Error: ${runtimeStatus.error }`}
+                </Typography>
+            </ExpansionPanelSummary>
+            <ExpansionPanelDetails className={classes.details}>
+                <Typography className={classNames(classes.errorColor, classes.detailsText)}>
+                    {runtimeStatus?.errorDetail || 'No additional error details available'}
+                </Typography>
+            </ExpansionPanelDetails>
+        </ExpansionPanel></div>
+        }
+        { runtimeStatus?.warning &&
+        <div data-cy='process-runtime-status-warning' ><ExpansionPanel className={classes.warning} elevation={0}>
+            <ExpansionPanelSummary className={classNames(classes.summary, classes.detailsText)} expandIcon={<ExpandMoreIcon />}>
+                <Typography className={classNames(classes.heading, classes.warningColor)}>
+                    {`Warning: ${runtimeStatus.warning }`}
+                </Typography>
+            </ExpansionPanelSummary>
+            <ExpansionPanelDetails className={classes.details}>
+                <Typography className={classNames(classes.warningColor, classes.detailsText)}>
+                    {runtimeStatus?.warningDetail || 'No additional warning details available'}
+                </Typography>
+            </ExpansionPanelDetails>
+        </ExpansionPanel></div>
+        }
+        { containerCount > 1 &&
+        <div data-cy='process-runtime-status-retry-warning' >
+            <Paper className={classNames(classes.warning, classes.paperRoot)} elevation={0}>
+                <Typography className={classNames(classes.heading, classes.summary, classes.warningColor)}>
+                    {`Warning: Process retried ${containerCount - 1} time${containerCount > 2 ? 's' : ''} due to failure.`}
+                </Typography>
+            </Paper>
+        </div>
+        }
+    </div>
+});
diff --git a/services/workbench2/src/views-components/project-properties/create-project-properties-form.tsx b/services/workbench2/src/views-components/project-properties/create-project-properties-form.tsx
new file mode 100644 (file)
index 0000000..5a6d9df
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm, change } from 'redux-form';
+import { withStyles } from '@material-ui/core';
+import {
+    PROJECT_CREATE_PROPERTIES_FORM_NAME,
+    PROJECT_CREATE_FORM_NAME
+} from 'store/projects/project-create-actions';
+import {
+    ResourcePropertiesForm,
+    ResourcePropertiesFormData
+} from 'views-components/resource-properties-form/resource-properties-form';
+import { addPropertyToResourceForm } from 'store/resources/resources-actions';
+import { PROPERTY_VALUE_FIELD_NAME } from 'views-components/resource-properties-form/property-value-field';
+
+const Form = withStyles(
+    ({ spacing }) => (
+        { container:
+            {
+                margin: 0,
+            }
+        })
+    )(ResourcePropertiesForm);
+
+export const CreateProjectPropertiesForm = reduxForm<ResourcePropertiesFormData>({
+    form: PROJECT_CREATE_PROPERTIES_FORM_NAME,
+    onSubmit: (data, dispatch) => {
+        dispatch(addPropertyToResourceForm(data, PROJECT_CREATE_FORM_NAME));
+        dispatch(change(PROJECT_CREATE_PROPERTIES_FORM_NAME, PROPERTY_VALUE_FIELD_NAME, ''));
+    }
+})(Form);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/project-properties/update-project-properties-form.tsx b/services/workbench2/src/views-components/project-properties/update-project-properties-form.tsx
new file mode 100644 (file)
index 0000000..9bce50a
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm, change } from 'redux-form';
+import { withStyles } from '@material-ui/core';
+import {
+    PROJECT_UPDATE_PROPERTIES_FORM_NAME,
+    PROJECT_UPDATE_FORM_NAME
+} from 'store/projects/project-update-actions';
+import {
+    ResourcePropertiesForm,
+    ResourcePropertiesFormData
+} from 'views-components/resource-properties-form/resource-properties-form';
+import { addPropertyToResourceForm } from 'store/resources/resources-actions';
+import { PROPERTY_VALUE_FIELD_NAME } from 'views-components/resource-properties-form/property-value-field';
+
+const Form = withStyles(
+    ({ spacing }) => (
+        { container:
+            {
+                margin: 0,
+            }
+        })
+    )(ResourcePropertiesForm);
+
+export const UpdateProjectPropertiesForm = reduxForm<ResourcePropertiesFormData>({
+    form: PROJECT_UPDATE_PROPERTIES_FORM_NAME,
+    onSubmit: (data, dispatch) => {
+        dispatch(addPropertyToResourceForm(data, PROJECT_UPDATE_FORM_NAME));
+        dispatch(change(PROJECT_UPDATE_PROPERTIES_FORM_NAME, PROPERTY_VALUE_FIELD_NAME, ''));
+    }
+})(Form);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/projects-tree-picker/favorites-tree-picker.tsx b/services/workbench2/src/views-components/projects-tree-picker/favorites-tree-picker.tsx
new file mode 100644 (file)
index 0000000..7e63152
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from 'react-redux';
+import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { Dispatch } from 'redux';
+import { FavoriteIcon } from 'components/icon/icon';
+import { loadFavoritesProject } from 'store/tree-picker/tree-picker-actions';
+
+export const FavoritesTreePicker = connect(() => ({
+    rootItemIcon: FavoriteIcon,
+}), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadFavoritesProject({ pickerId, includeCollections, includeDirectories, includeFiles }, options));
+    },
+}))(ProjectsTreePicker);
diff --git a/services/workbench2/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx b/services/workbench2/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx
new file mode 100644 (file)
index 0000000..70797f3
--- /dev/null
@@ -0,0 +1,131 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { isEqual } from 'lodash/fp';
+import { TreeItem, TreeItemStatus } from 'components/tree/tree';
+import { ProjectResource } from "models/project";
+import { treePickerActions } from "store/tree-picker/tree-picker-actions";
+import { ListItemTextIcon } from "components/list-item-text-icon/list-item-text-icon";
+import { ProjectIcon, FileInputIcon, IconType, CollectionIcon } from 'components/icon/icon';
+import { loadProject, loadCollection } from 'store/tree-picker/tree-picker-actions';
+import { ProjectsTreePickerItem, ProjectsTreePickerRootItem } from 'store/tree-picker/tree-picker-middleware';
+import { ResourceKind } from 'models/resource';
+import { TreePickerProps, TreePicker } from "views-components/tree-picker/tree-picker";
+import { CollectionFileType } from 'models/collection-file';
+
+
+type PickedTreePickerProps = Pick<TreePickerProps<ProjectsTreePickerItem>, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>;
+
+export interface ProjectsTreePickerDataProps {
+    cascadeSelection: boolean;
+    includeCollections?: boolean;
+    includeDirectories?: boolean;
+    includeFiles?: boolean;
+    rootItemIcon: IconType;
+    showSelection?: boolean;
+    relatedTreePickers?: string[];
+    disableActivation?: string[];
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+    loadRootItem: (item: TreeItem<ProjectsTreePickerRootItem>, pickerId: string,
+        includeCollections?: boolean, includeDirectories?: boolean, includeFiles?: boolean, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) => void;
+}
+
+export type ProjectsTreePickerProps = ProjectsTreePickerDataProps & Partial<PickedTreePickerProps>;
+
+const mapStateToProps = (_: any, { rootItemIcon, showSelection, cascadeSelection }: ProjectsTreePickerProps) => ({
+    render: renderTreeItem(rootItemIcon),
+    showSelection: isSelectionVisible(showSelection, cascadeSelection),
+});
+
+const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollections, includeDirectories, includeFiles, relatedTreePickers, options, ...props }: ProjectsTreePickerProps): PickedTreePickerProps => ({
+    onContextMenu: () => { return; },
+    toggleItemActive: (event, item, pickerId) => {
+
+        const { disableActivation = [] } = props;
+        if (disableActivation.some(isEqual(item.id))) {
+            return;
+        }
+
+        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: item.id, pickerId, relatedTreePickers }));
+        if (props.toggleItemActive) {
+            props.toggleItemActive(event, item, pickerId);
+        }
+    },
+    toggleItemOpen: (_, item, pickerId) => {
+        const { id, data, status } = item;
+        if (status === TreeItemStatus.INITIAL) {
+            if ('kind' in data) {
+                dispatch<any>(
+                    data.kind === ResourceKind.COLLECTION
+                        ? loadCollection(id, pickerId, includeDirectories, includeFiles)
+                        : loadProject({ id, pickerId, includeCollections, includeDirectories, includeFiles, options })
+                );
+            } else if (!('type' in data) && loadRootItem) {
+                loadRootItem(item as TreeItem<ProjectsTreePickerRootItem>, pickerId, includeCollections, includeDirectories, includeFiles, options);
+            }
+        } else if (status === TreeItemStatus.LOADED) {
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
+        }
+    },
+    toggleItemSelection: (event, item, pickerId) => {
+        dispatch<any>(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: item.id, pickerId, cascade: props.cascadeSelection }));
+        if (props.toggleItemSelection) {
+            props.toggleItemSelection(event, item, pickerId);
+        }
+    },
+});
+
+export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)(TreePicker);
+
+const getProjectPickerIcon = ({ data }: TreeItem<ProjectsTreePickerItem>, rootIcon: IconType): IconType => {
+    if ('headKind' in data) {
+        switch (data.headKind) {
+            case ResourceKind.COLLECTION:
+                return CollectionIcon;
+            default:
+                return ProjectIcon;
+        }
+    }
+    if ('kind' in data) {
+        switch (data.kind) {
+            case ResourceKind.COLLECTION:
+                return CollectionIcon;
+            default:
+                return ProjectIcon;
+        }
+    } else if ('type' in data) {
+        switch (data.type) {
+            case CollectionFileType.FILE:
+                return FileInputIcon;
+            default:
+                return ProjectIcon;
+        }
+    } else {
+        return rootIcon;
+    }
+};
+
+const isSelectionVisible = (shouldBeVisible: boolean | undefined, cascadeSelection: boolean) =>
+    ({ status, items, data }: TreeItem<ProjectsTreePickerItem>): boolean => {
+        if (shouldBeVisible) {
+            if (!cascadeSelection && 'kind' in data && data.kind === ResourceKind.COLLECTION) {
+                // In non-casecade mode collections are selectable without being loaded
+                return true;
+            } else if (items && items.length > 0) {
+                return items.every(isSelectionVisible(shouldBeVisible, cascadeSelection));
+            }
+            return status === TreeItemStatus.LOADED;
+        }
+        return false;
+    };
+
+const renderTreeItem = (rootItemIcon: IconType) => (item: TreeItem<ProjectResource>) =>
+    <ListItemTextIcon
+        icon={getProjectPickerIcon(item, rootItemIcon)}
+        name={item.data.name}
+        isActive={item.active}
+        hasMargin={true} />;
diff --git a/services/workbench2/src/views-components/projects-tree-picker/home-tree-picker.tsx b/services/workbench2/src/views-components/projects-tree-picker/home-tree-picker.tsx
new file mode 100644 (file)
index 0000000..3f71a58
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from 'react-redux';
+import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { Dispatch } from 'redux';
+import { loadUserProject } from 'store/tree-picker/tree-picker-actions';
+import { ProjectsIcon } from 'components/icon/icon';
+
+export const HomeTreePicker = connect(() => ({
+    rootItemIcon: ProjectsIcon,
+}), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadUserProject(pickerId, includeCollections, includeDirectories, includeFiles, options));
+    },
+}))(ProjectsTreePicker);
diff --git a/services/workbench2/src/views-components/projects-tree-picker/projects-tree-picker.tsx b/services/workbench2/src/views-components/projects-tree-picker/projects-tree-picker.tsx
new file mode 100644 (file)
index 0000000..16f6cce
--- /dev/null
@@ -0,0 +1,189 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Dispatch } from 'redux';
+import { connect, DispatchProp } from 'react-redux';
+import { RootState } from 'store/store';
+import { values, pipe } from 'lodash/fp';
+import { HomeTreePicker } from 'views-components/projects-tree-picker/home-tree-picker';
+import { SharedTreePicker } from 'views-components/projects-tree-picker/shared-tree-picker';
+import { FavoritesTreePicker } from 'views-components/projects-tree-picker/favorites-tree-picker';
+import { SearchProjectsPicker } from 'views-components/projects-tree-picker/search-projects-picker';
+import {
+    getProjectsTreePickerIds, treePickerActions, treePickerSearchActions, initProjectsTreePicker,
+    SHARED_PROJECT_ID, FAVORITES_PROJECT_ID
+} from 'store/tree-picker/tree-picker-actions';
+import { TreeItem } from 'components/tree/tree';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
+import { PublicFavoritesTreePicker } from './public-favorites-tree-picker';
+import { SearchInput } from 'components/search-input/search-input';
+import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+
+export interface ToplevelPickerProps {
+    currentUuids?: string[];
+    pickerId: string;
+    cascadeSelection: boolean;
+    includeCollections?: boolean;
+    includeDirectories?: boolean;
+    includeFiles?: boolean;
+    showSelection?: boolean;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+    toggleItemActive?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<ProjectsTreePickerItem>, pickerId: string) => void;
+    toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<ProjectsTreePickerItem>, pickerId: string) => void;
+}
+
+interface ProjectsTreePickerSearchProps {
+    projectSearch: string;
+    collectionFilter: string;
+}
+
+interface ProjectsTreePickerActionProps {
+    onProjectSearch: (value: string) => void;
+    onCollectionFilter: (value: string) => void;
+}
+
+const mapStateToProps = (state: RootState, props: ToplevelPickerProps): ProjectsTreePickerSearchProps => {
+    const { search } = getProjectsTreePickerIds(props.pickerId);
+    return {
+        ...props,
+        projectSearch: state.treePickerSearch.projectSearchValues[search],
+        collectionFilter: state.treePickerSearch.collectionFilterValues[search],
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch, props: ToplevelPickerProps): (ProjectsTreePickerActionProps & DispatchProp) => {
+    const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(props.pickerId);
+    const params = {
+        includeCollections: props.includeCollections,
+        includeDirectories: props.includeDirectories,
+        includeFiles: props.includeFiles,
+        options: props.options
+    };
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: home, params }));
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: shared, params }));
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: favorites, params }));
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: publicFavorites, params }));
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: search, params }));
+
+    return {
+        onProjectSearch: (projectSearchValue: string) => dispatch(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH({ pickerId: search, projectSearchValue })),
+        onCollectionFilter: (collectionFilterValue: string) => {
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: home, collectionFilterValue }));
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: shared, collectionFilterValue }));
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: favorites, collectionFilterValue }));
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: publicFavorites, collectionFilterValue }));
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: search, collectionFilterValue }));
+        },
+        dispatch
+    }
+};
+
+type CssRules = 'pickerHeight' | 'searchFlex' | 'scrolledBox';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    pickerHeight: {
+        height: "100%",
+        display: "flex",
+        flexDirection: "column",
+    },
+    searchFlex: {
+        display: "flex",
+        justifyContent: "space-around",
+        paddingBottom: "1em"
+    },
+    scrolledBox: {
+        overflow: "scroll"
+    }
+});
+
+type ProjectsTreePickerCombinedProps = ToplevelPickerProps & ProjectsTreePickerSearchProps & ProjectsTreePickerActionProps & DispatchProp & WithStyles<CssRules>;
+
+export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)(
+    withStyles(styles)(
+        class FileInputComponent extends React.Component<ProjectsTreePickerCombinedProps> {
+
+            componentDidMount() {
+                const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(this.props.pickerId);
+
+                const preloadParams = this.props.currentUuids ? {
+                    selectedItemUuids: this.props.currentUuids,
+                    includeDirectories: !!this.props.includeDirectories,
+                    includeFiles: !!this.props.includeFiles,
+                    multi: !!this.props.showSelection,
+                } : undefined;
+                this.props.dispatch<any>(initProjectsTreePicker(this.props.pickerId, preloadParams));
+
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH({ pickerId: search, projectSearchValue: "" }));
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: search, collectionFilterValue: "" }));
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: home, collectionFilterValue: "" }));
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: shared, collectionFilterValue: "" }));
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: favorites, collectionFilterValue: "" }));
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: publicFavorites, collectionFilterValue: "" }));
+            }
+
+            componentWillUnmount() {
+                const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(this.props.pickerId);
+                // Release all the state, we don't need it to hang around forever.
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: search }));
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: home }));
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: shared }));
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: favorites }));
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: publicFavorites }));
+            }
+
+            render() {
+                const pickerId = this.props.pickerId;
+                const onProjectSearch = this.props.onProjectSearch;
+                const onCollectionFilter = this.props.onCollectionFilter;
+
+                const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
+                const relatedTreePickers = getRelatedTreePickers(pickerId);
+                const p = {
+                    cascadeSelection: this.props.cascadeSelection,
+                    includeCollections: this.props.includeCollections,
+                    includeDirectories: this.props.includeDirectories,
+                    includeFiles: this.props.includeFiles,
+                    showSelection: this.props.showSelection,
+                    options: this.props.options,
+                    toggleItemActive: this.props.toggleItemActive,
+                    toggleItemSelection: this.props.toggleItemSelection,
+                    relatedTreePickers,
+                    disableActivation,
+                };
+                return <div className={this.props.classes.pickerHeight} >
+                    <span className={this.props.classes.searchFlex}>
+                        <SearchInput value="" label="Search for a Project" selfClearProp='' onSearch={onProjectSearch} debounce={500} />
+                        {this.props.includeCollections &&
+                            <SearchInput value="" label="Filter Collections list in Projects" selfClearProp='' onSearch={onCollectionFilter} debounce={500} />}
+                    </span>
+
+                    <div className={this.props.classes.scrolledBox}>
+                        {this.props.projectSearch ?
+                            <div data-cy="projects-tree-search-picker">
+                                <SearchProjectsPicker {...p} pickerId={search} />
+                            </div>
+                            :
+                            <>
+                                <div data-cy="projects-tree-home-tree-picker">
+                                    <HomeTreePicker {...p} pickerId={home} />
+                                </div>
+                                <div data-cy="projects-tree-shared-tree-picker">
+                                    <SharedTreePicker {...p} pickerId={shared} />
+                                </div>
+                                <div data-cy="projects-tree-public-favourites-tree-picker">
+                                    <PublicFavoritesTreePicker {...p} pickerId={publicFavorites} />
+                                </div>
+                                <div data-cy="projects-tree-favourites-tree-picker">
+                                    <FavoritesTreePicker {...p} pickerId={favorites} />
+                                </div>
+                            </>}
+                    </div>
+                </div >;
+            }
+        }));
+
+const getRelatedTreePickers = pipe(getProjectsTreePickerIds, values);
+const disableActivation = [SHARED_PROJECT_ID, FAVORITES_PROJECT_ID];
diff --git a/services/workbench2/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx b/services/workbench2/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx
new file mode 100644 (file)
index 0000000..ca03f72
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from 'react-redux';
+import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { Dispatch } from 'redux';
+import { PublicFavoriteIcon } from 'components/icon/icon';
+import { loadPublicFavoritesProject } from 'store/tree-picker/tree-picker-actions';
+
+export const PublicFavoritesTreePicker = connect(() => ({
+    rootItemIcon: PublicFavoriteIcon,
+}), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadPublicFavoritesProject({ pickerId, includeCollections, includeDirectories, includeFiles, options }));
+    },
+}))(ProjectsTreePicker);
diff --git a/services/workbench2/src/views-components/projects-tree-picker/search-projects-picker.tsx b/services/workbench2/src/views-components/projects-tree-picker/search-projects-picker.tsx
new file mode 100644 (file)
index 0000000..2888050
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from 'react-redux';
+import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { Dispatch } from 'redux';
+import { SearchIcon } from 'components/icon/icon';
+import { loadProject } from 'store/tree-picker/tree-picker-actions';
+import { SEARCH_PROJECT_ID } from 'store/tree-picker/tree-picker-actions';
+
+export const SearchProjectsPicker = connect(() => ({
+    rootItemIcon: SearchIcon,
+}), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadProject({ id: SEARCH_PROJECT_ID, pickerId, includeCollections, includeDirectories, includeFiles, searchProjects: true, options }));
+    },
+}))(ProjectsTreePicker);
diff --git a/services/workbench2/src/views-components/projects-tree-picker/shared-tree-picker.tsx b/services/workbench2/src/views-components/projects-tree-picker/shared-tree-picker.tsx
new file mode 100644 (file)
index 0000000..1914cd9
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from 'react-redux';
+import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { Dispatch } from 'redux';
+import { ShareMeIcon } from 'components/icon/icon';
+import { loadProject } from 'store/tree-picker/tree-picker-actions';
+import { SHARED_PROJECT_ID } from 'store/tree-picker/tree-picker-actions';
+
+export const SharedTreePicker = connect(() => ({
+    rootItemIcon: ShareMeIcon,
+}), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadProject({ id: SHARED_PROJECT_ID, pickerId, includeCollections, includeDirectories, includeFiles, loadShared: true, options }));
+    },
+}))(ProjectsTreePicker);
diff --git a/services/workbench2/src/views-components/projects-tree-picker/tree-picker-field.tsx b/services/workbench2/src/views-components/projects-tree-picker/tree-picker-field.tsx
new file mode 100644 (file)
index 0000000..75cf40c
--- /dev/null
@@ -0,0 +1,88 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Typography } from "@material-ui/core";
+import { TreeItem } from "components/tree/tree";
+import { WrappedFieldProps } from 'redux-form';
+import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
+import { PickerIdProp } from 'store/tree-picker/picker-id';
+import { FileOperationLocation, getFileOperationLocation } from "store/tree-picker/tree-picker-actions";
+import { connect } from "react-redux";
+import { Dispatch } from "redux";
+
+export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
+    <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
+        <div style={{ flexBasis: '275px', flexShrink: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
+            <ProjectsTreePicker
+                pickerId={props.pickerId}
+                toggleItemActive={handleChange(props)}
+                cascadeSelection={false}
+                options={{ showOnlyOwned: false, showOnlyWritable: true }} />
+            {props.meta.dirty && props.meta.error &&
+                <Typography variant='caption' color='error'>
+                    {props.meta.error}
+                </Typography>}
+        </div>
+    </div>;
+
+const handleChange = (props: WrappedFieldProps) =>
+    (_: any, { id }: TreeItem<ProjectsTreePickerItem>) =>
+        props.input.onChange(id);
+
+export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
+    <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
+        <div style={{ flexBasis: '275px', flexShrink: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
+            <ProjectsTreePicker
+                pickerId={props.pickerId}
+                toggleItemActive={handleChange(props)}
+                cascadeSelection={false}
+                options={{ showOnlyOwned: false, showOnlyWritable: true }}
+                includeCollections />
+            {props.meta.dirty && props.meta.error &&
+                <Typography variant='caption' color='error'>
+                    {props.meta.error}
+                </Typography>}
+        </div>
+    </div>;
+
+type ProjectsTreePickerActionProps = {
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
+}
+
+const projectsTreePickerMapDispatchToProps = (dispatch: Dispatch): ProjectsTreePickerActionProps => ({
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
+});
+
+type ProjectsTreePickerCombinedProps = ProjectsTreePickerActionProps & WrappedFieldProps & PickerIdProp;
+
+export const DirectoryTreePickerField = connect(null, projectsTreePickerMapDispatchToProps)(
+    class DirectoryTreePickerFieldComponent extends React.Component<ProjectsTreePickerCombinedProps> {
+
+        handleDirectoryChange = (props: WrappedFieldProps) =>
+            async (_: any, { data }: TreeItem<ProjectsTreePickerItem>) => {
+                const location = await this.props.getFileOperationLocation(data);
+                props.input.onChange(location || '');
+            }
+
+        render() {
+            return <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
+                <div style={{ flexBasis: '275px', flexShrink: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
+                    <ProjectsTreePicker
+                        currentUuids={[this.props.input.value.uuid]}
+                        pickerId={this.props.pickerId}
+                        toggleItemActive={this.handleDirectoryChange(this.props)}
+                        cascadeSelection={false}
+                        options={{ showOnlyOwned: false, showOnlyWritable: true }}
+                        includeCollections
+                        includeDirectories />
+                    {this.props.meta.dirty && this.props.meta.error &&
+                        <Typography variant='caption' color='error'>
+                            {this.props.meta.error}
+                        </Typography>}
+                </div>
+            </div>;
+        }
+    });
diff --git a/services/workbench2/src/views-components/remove-dialog/remove-dialog.tsx b/services/workbench2/src/views-components/remove-dialog/remove-dialog.tsx
new file mode 100644 (file)
index 0000000..544cbf6
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from "@material-ui/core";
+import { withDialog } from "store/dialog/with-dialog";
+import { dialogActions } from "store/dialog/dialog-actions";
+
+export const REMOVE_DIALOG = 'removeCollectionFilesDialog';
+
+export const RemoveDialog = withDialog(REMOVE_DIALOG)(
+    (props) =>
+        <Dialog open={props.open}>
+            <DialogTitle>{`Removing ${props.data}`}</DialogTitle>
+            <DialogContent>
+                {`Are you sure you want to remove ${props.data}?`}
+            </DialogContent>
+            <DialogActions>
+                <Button
+                    variant='text'
+                    color='primary'
+                    onClick={props.closeDialog}>
+                    Cancel
+                </Button>
+                <Button variant='contained' color='primary'>
+                    Remove
+                </Button>
+            </DialogActions>
+        </Dialog>
+);
+
+export const openRemoveDialog = (removedDataName: string) =>
+    dialogActions.OPEN_DIALOG({ id: REMOVE_DIALOG, data: removedDataName });
diff --git a/services/workbench2/src/views-components/rename-file-dialog/rename-file-dialog.tsx b/services/workbench2/src/views-components/rename-file-dialog/rename-file-dialog.tsx
new file mode 100644 (file)
index 0000000..b67697b
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { compose, Dispatch } from 'redux';
+import { reduxForm, InjectedFormProps, Field } from 'redux-form';
+import { withDialog, WithDialogProps } from 'store/dialog/with-dialog';
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { DialogContentText } from '@material-ui/core';
+import { TextField } from 'components/text-field/text-field';
+import { RENAME_FILE_DIALOG, RenameFileDialogData, renameFile } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions';
+import { WarningCollection } from 'components/warning-collection/warning-collection';
+import { RENAME_FILE_VALIDATION } from 'validators/validators';
+
+export const RenameFileDialog = compose(
+    withDialog(RENAME_FILE_DIALOG),
+    reduxForm({
+        form: RENAME_FILE_DIALOG,
+        touchOnChange: true,
+        onSubmit: (data: { path: string }, dispatch: Dispatch) => {
+            dispatch<any>(renameFile(data.path));
+        }
+    })
+)((props: WithDialogProps<RenameFileDialogData> & InjectedFormProps<{ name: string, path: string }>) =>
+    <FormDialog
+        dialogTitle='Rename'
+        formFields={RenameDialogFormFields}
+        submitLabel='Ok'
+        {...props}
+    />);
+
+const RenameDialogFormFields = (props: WithDialogProps<RenameFileDialogData>) => <>
+    <DialogContentText>
+        {`Please, enter a new name for ${props.data.name}`}
+    </DialogContentText>
+    <Field
+        name='path'
+        component={TextField as any}
+        autoFocus={true}
+        validate={RENAME_FILE_VALIDATION}
+    />
+    <WarningCollection text="Renaming a file will change the collection's content address." />
+</>;
diff --git a/services/workbench2/src/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog.tsx b/services/workbench2/src/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog.tsx
new file mode 100644 (file)
index 0000000..a76ab0f
--- /dev/null
@@ -0,0 +1,78 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@material-ui/core";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { withDialog } from 'store/dialog/with-dialog';
+import { REPOSITORIES_SAMPLE_GIT_DIALOG } from "store/repositories/repositories-actions";
+import { DefaultCodeSnippet } from 'components/default-code-snippet/default-code-snippet';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { compose } from "redux";
+
+type CssRules = 'codeSnippet' | 'link' | 'spacing';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    codeSnippet: {
+        borderRadius: theme.spacing.unit * 0.5,
+        border: '1px solid',
+        borderColor: theme.palette.grey["400"],
+    },
+    link: {
+        textDecoration: 'none',
+        color: theme.palette.primary.main,
+        "&:hover": {
+            color: theme.palette.primary.dark,
+            transition: 'all 0.5s ease'
+        }
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    }
+});
+
+interface RepositoriesSampleGitDataProps {
+    uuidPrefix: string;
+}
+
+type RepositoriesSampleGitProps = RepositoriesSampleGitDataProps & WithStyles<CssRules>;
+
+export const RepositoriesSampleGitDialog = compose(
+    withDialog(REPOSITORIES_SAMPLE_GIT_DIALOG),
+    withStyles(styles))(
+        (props: WithDialogProps<RepositoriesSampleGitProps> & RepositoriesSampleGitProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth='sm'>
+                <DialogTitle>Sample git quick start:</DialogTitle>
+                <DialogContent>
+                    <DefaultCodeSnippet
+                        className={props.classes.codeSnippet}
+                        lines={[snippetText(props.data.uuidPrefix)]} />
+                    <Typography variant='body1' className={props.classes.spacing}>
+                        See also:
+                        <div><a href="https://doc.arvados.org/user/getting_started/ssh-access-unix.html" className={props.classes.link} target="_blank" rel="noopener noreferrer">SSH access</a></div>
+                        <div><a href="https://doc.arvados.org/user/tutorials/tutorial-firstscript.html" className={props.classes.link} target="_blank" rel="noopener noreferrer">Writing a Crunch Script</a></div>
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const snippetText = (uuidPrefix: string) => `git clone git@git.${uuidPrefix}.arvadosapi.com:arvados.git
+cd arvados
+# edit files
+git add the/files/you/changed
+git commit
+git push
+`;
diff --git a/services/workbench2/src/views-components/repository-attributes-dialog/repository-attributes-dialog.tsx b/services/workbench2/src/views-components/repository-attributes-dialog/repository-attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..1771e3c
--- /dev/null
@@ -0,0 +1,89 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Grid } from "@material-ui/core";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { withDialog } from 'store/dialog/with-dialog';
+import { REPOSITORY_ATTRIBUTES_DIALOG } from "store/repositories/repositories-actions";
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { compose } from "redux";
+import { RepositoryResource } from "models/repositories";
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    rightContainer: {
+        textAlign: 'right',
+        paddingRight: theme.spacing.unit * 2,
+        color: theme.palette.grey["500"]
+    },
+    leftContainer: {
+        textAlign: 'left',
+        paddingLeft: theme.spacing.unit * 2
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    },
+});
+
+interface RepositoryAttributesDataProps {
+    repositoryData: RepositoryResource;
+}
+
+type RepositoryAttributesProps = RepositoryAttributesDataProps & WithStyles<CssRules>;
+
+export const RepositoryAttributesDialog = compose(
+    withDialog(REPOSITORY_ATTRIBUTES_DIALOG),
+    withStyles(styles))(
+        (props: WithDialogProps<RepositoryAttributesProps> & RepositoryAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant='body1' className={props.classes.spacing}>
+                        {props.data.repositoryData && attributes(props.data.repositoryData, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (repositoryData: RepositoryResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name } = repositoryData;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    <Grid item>Name</Grid>
+                    <Grid item>Owner uuid</Grid>
+                    <Grid item>Created at</Grid>
+                    <Grid item>Modified at</Grid>
+                    <Grid item>Modified by user uuid</Grid>
+                    <Grid item>Modified by client uuid</Grid>
+                    <Grid item>uuid</Grid>
+                </Grid>
+                <Grid item xs={7} className={classes.leftContainer}>
+                    <Grid item>{name}</Grid>
+                    <Grid item>{ownerUuid}</Grid>
+                    <Grid item>{createdAt}</Grid>
+                    <Grid item>{modifiedAt}</Grid>
+                    <Grid item>{modifiedByUserUuid}</Grid>
+                    <Grid item>{modifiedByClientUuid}</Grid>
+                    <Grid item>{uuid}</Grid>
+                </Grid>
+            </Grid>
+        </span>
+    );
+};
diff --git a/services/workbench2/src/views-components/repository-remove-dialog/repository-remove-dialog.ts b/services/workbench2/src/views-components/repository-remove-dialog/repository-remove-dialog.ts
new file mode 100644 (file)
index 0000000..510c14c
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { removeRepository, REPOSITORY_REMOVE_DIALOG } from 'store/repositories/repositories-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeRepository(props.data.uuid));
+    }
+});
+
+export const RemoveRepositoryDialog = compose(
+    withDialog(REPOSITORY_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/resource-properties-form/property-chip.tsx b/services/workbench2/src/views-components/resource-properties-form/property-chip.tsx
new file mode 100644 (file)
index 0000000..24b5c0a
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Chip } from '@material-ui/core';
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { getVocabulary } from 'store/vocabulary/vocabulary-selectors';
+import { Dispatch } from 'redux';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { getTagValueLabel, getTagKeyLabel, Vocabulary } from 'models/vocabulary';
+
+interface PropertyChipComponentDataProps {
+    propKey: string;
+    propValue: string;
+    className: string;
+    vocabulary: Vocabulary;
+}
+
+interface PropertyChipComponentActionProps {
+    onDelete?: () => void;
+    onCopy: (message: string) => void;
+}
+
+type PropertyChipComponentProps = PropertyChipComponentActionProps & PropertyChipComponentDataProps;
+
+const mapStateToProps = ({ properties }: RootState) => ({
+    vocabulary: getVocabulary(properties),
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onCopy: (message: string) => dispatch(snackbarActions.OPEN_SNACKBAR({
+        message,
+        hideDuration: 2000,
+        kind: SnackbarKind.SUCCESS
+    }))
+});
+
+// Renders a Chip with copyable-on-click tag:value data based on the vocabulary
+export const PropertyChipComponent = connect(mapStateToProps, mapDispatchToProps)(
+    ({ propKey, propValue, vocabulary, className, onCopy, onDelete }: PropertyChipComponentProps) => {
+        const label = `${getTagKeyLabel(propKey, vocabulary)}: ${getTagValueLabel(propKey, propValue, vocabulary)}`;
+        return (
+            <CopyToClipboard key={propKey} text={label} onCopy={() => onCopy("Copied to clipboard")}>
+                <Chip onDelete={onDelete} key={propKey}
+                    className={className} label={label} />
+            </CopyToClipboard>
+        );
+    }
+);
+
+export const getPropertyChip = (k: string, v: string, handleDelete: any, className: string) =>
+    <PropertyChipComponent
+        key={`${k}-${v}`} className={className}
+        onDelete={handleDelete}
+        propKey={k} propValue={v} />;
diff --git a/services/workbench2/src/views-components/resource-properties-form/property-field-common.tsx b/services/workbench2/src/views-components/resource-properties-form/property-field-common.tsx
new file mode 100644 (file)
index 0000000..dad1728
--- /dev/null
@@ -0,0 +1,71 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from 'react-redux';
+import { change, WrappedFieldMetaProps, WrappedFieldInputProps, WrappedFieldProps } from 'redux-form';
+import { Vocabulary, PropFieldSuggestion } from 'models/vocabulary';
+import { RootState } from 'store/store';
+import { getVocabulary } from 'store/vocabulary/vocabulary-selectors';
+
+export interface VocabularyProp {
+    vocabulary: Vocabulary;
+}
+
+export interface ValidationProp {
+    skipValidation?: boolean;
+    clearPropertyKeyOnSelect?: boolean;
+}
+
+export const mapStateToProps = (state: RootState, ownProps: ValidationProp): VocabularyProp & ValidationProp => ({
+    skipValidation: ownProps.skipValidation,
+    vocabulary: getVocabulary(state.properties),
+});
+
+export const connectVocabulary = connect(mapStateToProps);
+
+export const ITEMS_PLACEHOLDER: string[] = [];
+
+export const hasError = ({ touched, invalid }: WrappedFieldMetaProps) =>
+    touched && invalid;
+
+export const getErrorMsg = (meta: WrappedFieldMetaProps) =>
+    hasError(meta)
+        ? meta.error
+        : '';
+
+export const buildProps = ({ input, meta }: WrappedFieldProps) => {
+    return {
+        value: input.value,
+        items: ITEMS_PLACEHOLDER,
+        renderSuggestion: (item: PropFieldSuggestion) => item.label,
+        error: hasError(meta),
+        helperText: getErrorMsg(meta),
+    };
+};
+
+// Attempts to match a manually typed value label with a value ID, when the user
+// doesn't select the value from the suggestions list.
+export const handleBlur = (
+    fieldName: string,
+    formName: string,
+    { dispatch }: WrappedFieldMetaProps,
+    { onBlur, value }: WrappedFieldInputProps,
+    fieldValue: string) =>
+    () => {
+        dispatch(change(formName, fieldName, fieldValue));
+        onBlur(value);
+    };
+
+// When selecting a property value, save its ID for later usage.
+export const handleSelect = (
+    fieldName: string,
+    formName: string,
+    { onChange }: WrappedFieldInputProps,
+    { dispatch }: WrappedFieldMetaProps) =>
+    (item: PropFieldSuggestion) => {
+        if (item) {
+            onChange(item.label);
+            dispatch(change(formName, fieldName, item.id));
+        }
+    };
diff --git a/services/workbench2/src/views-components/resource-properties-form/property-key-field.tsx b/services/workbench2/src/views-components/resource-properties-form/property-key-field.tsx
new file mode 100644 (file)
index 0000000..0b08ad6
--- /dev/null
@@ -0,0 +1,108 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WrappedFieldProps, Field, FormName, reset, change, WrappedFieldInputProps, WrappedFieldMetaProps } from 'redux-form';
+import { memoize } from 'lodash';
+import { Autocomplete } from 'components/autocomplete/autocomplete';
+import {
+    Vocabulary,
+    getTags,
+    getTagKeyID,
+    getTagKeyLabel,
+    getPreferredTags,
+    PropFieldSuggestion
+} from 'models/vocabulary';
+import {
+    handleSelect,
+    handleBlur,
+    connectVocabulary,
+    VocabularyProp,
+    ValidationProp,
+    buildProps
+} from 'views-components/resource-properties-form/property-field-common';
+import { TAG_KEY_VALIDATION } from 'validators/validators';
+import { escapeRegExp } from 'common/regexp';
+import { ChangeEvent } from 'react';
+
+export const PROPERTY_KEY_FIELD_NAME = 'key';
+export const PROPERTY_KEY_FIELD_ID = 'keyID';
+
+export const PropertyKeyField = connectVocabulary(
+    ({ vocabulary, skipValidation, clearPropertyKeyOnSelect }: VocabularyProp & ValidationProp) =>
+        <span data-cy='property-field-key'>
+        <Field
+            clearPropertyKeyOnSelect
+            name={PROPERTY_KEY_FIELD_NAME}
+            component={PropertyKeyInput}
+            vocabulary={vocabulary}
+            validate={skipValidation ? undefined : getValidation(vocabulary)} />
+        </span>
+);
+
+const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp & { clearPropertyKeyOnSelect?: boolean }) =>
+    <FormName children={data => (
+        <Autocomplete
+            {...buildProps(props)}
+            label='Key'
+            suggestions={getSuggestions(props.input.value, vocabulary)}
+            renderSuggestion={
+                (s: PropFieldSuggestion) => s.synonyms && s.synonyms.length > 0
+                    ? `${s.label} (${s.synonyms.join('; ')})`
+                    : s.label
+            }
+            onFocus={() => {
+                if (props.clearPropertyKeyOnSelect && props.input.value) {
+                    props.meta.dispatch(reset(props.meta.form));
+                }
+            }}
+            onSelect={handleSelect(PROPERTY_KEY_FIELD_ID, data.form, props.input, props.meta)}
+            onBlur={() => {
+                // Case-insensitive search for the key in the vocabulary
+                const foundKeyID = getTagKeyID(props.input.value, vocabulary);
+                if (foundKeyID !== '') {
+                    props.input.value = getTagKeyLabel(foundKeyID, vocabulary);
+                }
+                handleBlur(PROPERTY_KEY_FIELD_ID, data.form, props.meta, props.input, foundKeyID)();
+            }}
+            onChange={(e: ChangeEvent<HTMLInputElement>) => {
+                const newValue = e.currentTarget.value;
+                handleChange(data.form, props.input, props.meta, newValue);
+            }}
+        />
+    )} />;
+
+const getValidation = memoize(
+    (vocabulary: Vocabulary) =>
+        vocabulary.strict_tags
+            ? [...TAG_KEY_VALIDATION, matchTags(vocabulary)]
+            : TAG_KEY_VALIDATION);
+
+const matchTags = (vocabulary: Vocabulary) =>
+    (value: string) =>
+        getTags(vocabulary).find(tag => tag.label === value)
+            ? undefined
+            : 'Incorrect key';
+
+const getSuggestions = (value: string, vocabulary: Vocabulary): PropFieldSuggestion[] => {
+    const re = new RegExp(escapeRegExp(value), "i");
+    return getPreferredTags(vocabulary, value).filter(
+        tag => (tag.label !== value && re.test(tag.label)) ||
+            (tag.synonyms && tag.synonyms.some(s => re.test(s))));
+};
+
+const handleChange = (
+    formName: string,
+    { onChange }: WrappedFieldInputProps,
+    { dispatch }: WrappedFieldMetaProps,
+    value: string) => {
+        // Properties' values are dependant on the keys, if any value is
+        // pre-existant, a change on the property key should mean that the
+        // previous value is invalid, so we better reset the whole form before
+        // setting the new tag key.
+        dispatch(reset(formName));
+
+        onChange(value);
+        dispatch(change(formName, PROPERTY_KEY_FIELD_NAME, value));
+    };
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/resource-properties-form/property-value-field.tsx b/services/workbench2/src/views-components/resource-properties-form/property-value-field.tsx
new file mode 100644 (file)
index 0000000..8941d44
--- /dev/null
@@ -0,0 +1,112 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WrappedFieldProps, Field, formValues, FormName, WrappedFieldInputProps, WrappedFieldMetaProps, change } from 'redux-form';
+import { compose } from 'redux';
+import { Autocomplete } from 'components/autocomplete/autocomplete';
+import { Vocabulary, isStrictTag, getTagValues, getTagValueID, getTagValueLabel, PropFieldSuggestion, getPreferredTagValues } from 'models/vocabulary';
+import { PROPERTY_KEY_FIELD_ID, PROPERTY_KEY_FIELD_NAME } from 'views-components/resource-properties-form/property-key-field';
+import {
+    handleSelect,
+    handleBlur,
+    VocabularyProp,
+    ValidationProp,
+    connectVocabulary,
+    buildProps
+} from 'views-components/resource-properties-form/property-field-common';
+import { TAG_VALUE_VALIDATION } from 'validators/validators';
+import { escapeRegExp } from 'common/regexp';
+import { ChangeEvent } from 'react';
+
+interface PropertyKeyProp {
+    propertyKeyId: string;
+    propertyKeyName: string;
+}
+
+interface PropertyValueInputProp {
+    disabled: boolean;
+}
+
+type PropertyValueFieldProps = VocabularyProp & PropertyKeyProp & ValidationProp & PropertyValueInputProp;
+
+export const PROPERTY_VALUE_FIELD_NAME = 'value';
+export const PROPERTY_VALUE_FIELD_ID = 'valueID';
+
+const connectVocabularyAndPropertyKey = compose(
+    connectVocabulary,
+    formValues({
+        propertyKeyId: PROPERTY_KEY_FIELD_ID,
+        propertyKeyName: PROPERTY_KEY_FIELD_NAME,
+    }),
+);
+
+export const PropertyValueField = connectVocabularyAndPropertyKey(
+    ({ skipValidation, ...props }: PropertyValueFieldProps) =>
+        <span data-cy='property-field-value'>
+        <Field
+            name={PROPERTY_VALUE_FIELD_NAME}
+            component={PropertyValueInput}
+            validate={skipValidation ? undefined : getValidation(props)}
+            {...{...props, disabled: !props.propertyKeyName}} />
+        </span>
+);
+
+const PropertyValueInput = ({ vocabulary, propertyKeyId, propertyKeyName, ...props }: WrappedFieldProps & PropertyValueFieldProps) =>
+    <FormName children={data => (
+        <Autocomplete
+            {...buildProps(props)}
+            label='Value'
+            disabled={props.disabled}
+            suggestions={getSuggestions(props.input.value, propertyKeyId, vocabulary)}
+            renderSuggestion={
+                (s: PropFieldSuggestion) => s.synonyms && s.synonyms.length > 0
+                    ? `${s.label} (${s.synonyms.join('; ')})`
+                    : s.label
+            }
+            onSelect={handleSelect(PROPERTY_VALUE_FIELD_ID, data.form, props.input, props.meta)}
+            onBlur={() => {
+                // Case-insensitive search for the value in the vocabulary
+                const foundValueID =  getTagValueID(propertyKeyId, props.input.value, vocabulary);
+                if (foundValueID !== '') {
+                    props.input.value = getTagValueLabel(propertyKeyId, foundValueID, vocabulary);
+                }
+                handleBlur(PROPERTY_VALUE_FIELD_ID, data.form, props.meta, props.input, foundValueID)();
+            }}
+            onChange={(e: ChangeEvent<HTMLInputElement>) => {
+                const newValue = e.currentTarget.value;
+                const tagValueID = getTagValueID(propertyKeyId, newValue, vocabulary);
+                handleChange(data.form, tagValueID, props.input, props.meta, newValue);
+            }}
+        />
+    )} />;
+
+const getValidation = (props: PropertyValueFieldProps) =>
+    isStrictTag(props.propertyKeyId, props.vocabulary)
+        ? [...TAG_VALUE_VALIDATION, matchTagValues(props)]
+        : TAG_VALUE_VALIDATION;
+
+const matchTagValues = ({ vocabulary, propertyKeyId }: PropertyValueFieldProps) =>
+    (value: string) =>
+        getTagValues(propertyKeyId, vocabulary).find(v => !value || v.label === value)
+            ? undefined
+            : 'Incorrect value';
+
+const getSuggestions = (value: string, tagName: string, vocabulary: Vocabulary) => {
+    const re = new RegExp(escapeRegExp(value), "i");
+    return getPreferredTagValues(tagName, vocabulary, value).filter(
+        val => (val.label !== value && re.test(val.label)) ||
+            (val.synonyms && val.synonyms.some(s => re.test(s))));
+};
+
+const handleChange = (
+    formName: string,
+    tagValueID: string,
+    { onChange }: WrappedFieldInputProps,
+    { dispatch }: WrappedFieldMetaProps,
+    value: string) => {
+        onChange(value);
+        dispatch(change(formName, PROPERTY_VALUE_FIELD_NAME, value));
+        dispatch(change(formName, PROPERTY_VALUE_FIELD_ID, tagValueID));
+    };
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/resource-properties-form/resource-properties-form.tsx b/services/workbench2/src/views-components/resource-properties-form/resource-properties-form.tsx
new file mode 100644 (file)
index 0000000..0147312
--- /dev/null
@@ -0,0 +1,64 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { RootState } from 'store/store';
+import { connect } from 'react-redux';
+import { formValueSelector, InjectedFormProps } from 'redux-form';
+import { Grid, withStyles, WithStyles } from '@material-ui/core';
+import { PropertyKeyField, PROPERTY_KEY_FIELD_NAME, PROPERTY_KEY_FIELD_ID } from './property-key-field';
+import { PropertyValueField, PROPERTY_VALUE_FIELD_NAME, PROPERTY_VALUE_FIELD_ID } from './property-value-field';
+import { ProgressButton } from 'components/progress-button/progress-button';
+import { GridClassKey } from '@material-ui/core/Grid';
+
+const AddButton = withStyles(theme => ({
+    root: { marginTop: theme.spacing.unit }
+}))(ProgressButton);
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        applySelector: (selector) => selector(state, 'key', 'value', 'keyID', 'valueID')
+    }
+}
+
+interface ApplySelector {
+    applySelector: (selector) => any;
+}
+
+export interface ResourcePropertiesFormData {
+    uuid: string;
+    [PROPERTY_KEY_FIELD_NAME]: string;
+    [PROPERTY_KEY_FIELD_ID]: string;
+    [PROPERTY_VALUE_FIELD_NAME]: string;
+    [PROPERTY_VALUE_FIELD_ID]: string;
+    clearPropertyKeyOnSelect?: boolean;
+}
+
+type ResourcePropertiesFormProps = {uuid: string; clearPropertyKeyOnSelect?: boolean } & InjectedFormProps<ResourcePropertiesFormData, {uuid: string;}> & WithStyles<GridClassKey> & ApplySelector;
+
+export const ResourcePropertiesForm = connect(mapStateToProps)(({ handleSubmit, change, submitting, invalid, classes, uuid, clearPropertyKeyOnSelect, applySelector,  ...props }: ResourcePropertiesFormProps ) => {
+    change('uuid', uuid); // Sets the uuid field to the uuid of the resource.
+    const propertyValue = applySelector(formValueSelector(props.form));
+    return <form data-cy='resource-properties-form' onSubmit={handleSubmit}>
+        <Grid container spacing={16} classes={classes}>
+            <Grid item xs>
+                <PropertyKeyField clearPropertyKeyOnSelect />
+            </Grid>
+            <Grid item xs>
+                <PropertyValueField />
+            </Grid>
+            <Grid item>
+                <AddButton
+                    data-cy='property-add-btn'
+                    disabled={invalid || !(propertyValue.key && propertyValue.value)}
+                    loading={submitting}
+                    color='primary'
+                    variant='contained'
+                    type='submit'>
+                    Add
+                </AddButton>
+            </Grid>
+        </Grid>
+    </form>}
+);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/resource-properties/resource-properties-list.tsx b/services/workbench2/src/views-components/resource-properties/resource-properties-list.tsx
new file mode 100644 (file)
index 0000000..47d7729
--- /dev/null
@@ -0,0 +1,66 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+import {
+    withStyles,
+    StyleRulesCallback,
+    WithStyles,
+} from '@material-ui/core';
+import { RootState } from 'store/store';
+import { ArvadosTheme } from 'common/custom-theme';
+import { getPropertyChip } from '../resource-properties-form/property-chip';
+import { removePropertyFromResourceForm } from 'store/resources/resources-actions';
+import { formValueSelector } from 'redux-form';
+
+type CssRules = 'tag';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    tag: {
+        marginRight: theme.spacing.unit,
+        marginBottom: theme.spacing.unit
+    }
+});
+
+interface ResourcePropertiesListDataProps {
+    properties: {[key: string]: string | string[]};
+}
+
+interface ResourcePropertiesListActionProps {
+    handleDelete: (key: string, value: string) => void;
+}
+
+type ResourcePropertiesListProps = ResourcePropertiesListDataProps &
+ResourcePropertiesListActionProps & WithStyles<CssRules>;
+
+const List = withStyles(styles)(
+    ({ classes, handleDelete, properties }: ResourcePropertiesListProps) =>
+        <div data-cy="resource-properties-list">
+            {properties &&
+                Object.keys(properties).map(k =>
+                    Array.isArray(properties[k])
+                    ? (properties[k] as string[]).map((v: string) =>
+                        getPropertyChip(
+                            k, v,
+                            () => handleDelete(k, v),
+                            classes.tag))
+                    : getPropertyChip(
+                        k, (properties[k] as string),
+                        () => handleDelete(k, (properties[k] as string)),
+                        classes.tag))
+                }
+        </div>
+);
+
+export const resourcePropertiesList = (formName: string) =>
+    connect(
+        (state: RootState): ResourcePropertiesListDataProps => ({
+            properties: formValueSelector(formName)(state, 'properties')
+        }),
+        (dispatch: Dispatch): ResourcePropertiesListActionProps => ({
+                handleDelete: (key: string, value: string) => dispatch<any>(removePropertyFromResourceForm(key, value, formName))
+        })
+    )(List);
diff --git a/services/workbench2/src/views-components/rich-text-editor-dialog/rich-text-editor-dialog.tsx b/services/workbench2/src/views-components/rich-text-editor-dialog/rich-text-editor-dialog.tsx
new file mode 100644 (file)
index 0000000..95ae6d4
--- /dev/null
@@ -0,0 +1,67 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import {
+    Dialog, 
+    DialogTitle, 
+    DialogContent, 
+    DialogActions, 
+    Button,
+    StyleRulesCallback,
+    WithStyles,
+    withStyles
+} from "@material-ui/core";
+import { ArvadosTheme } from 'common/custom-theme';
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { withDialog } from 'store/dialog/with-dialog';
+import { RICH_TEXT_EDITOR_DIALOG_NAME } from "store/rich-text-editor-dialog/rich-text-editor-dialog-actions";
+import RichTextEditor from 'react-rte';
+
+type CssRules = 'rte';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    rte: {
+        fontFamily: 'Arial',
+        '& a': {
+            textDecoration: 'none',
+            color: theme.palette.primary.main,
+            '&:hover': {
+                cursor: 'pointer',
+                textDecoration: 'underline'
+            }
+        }
+    },
+
+});
+
+export interface RichTextEditorDialogDataProps {
+    title: string;
+    text: string;
+}
+
+export const RichTextEditorDialog = withStyles(styles)(withDialog(RICH_TEXT_EDITOR_DIALOG_NAME)(
+    (props: WithDialogProps<RichTextEditorDialogDataProps> & WithStyles<CssRules>) =>
+        <Dialog open={props.open}
+            onClose={props.closeDialog}
+            fullWidth
+            maxWidth='md'>
+            <DialogTitle>{props.data.title}</DialogTitle>
+            <DialogContent>
+                <RichTextEditor
+                    className={props.classes.rte}
+                    value={props.data.text ?
+                        RichTextEditor.createValueFromString(props.data.text.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&'), 'html') : ''}
+                    readOnly={true} />
+            </DialogContent>
+            <DialogActions>
+                <Button
+                    variant='text'
+                    color='primary'
+                    onClick={props.closeDialog}>
+                    Close
+                </Button>
+            </DialogActions>
+        </Dialog>)
+);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/run-process-dialog/change-workflow-dialog.ts b/services/workbench2/src/views-components/run-process-dialog/change-workflow-dialog.ts
new file mode 100644 (file)
index 0000000..2bce72f
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { RootState } from 'store/store';
+import { setWorkflow, SET_WORKFLOW_DIALOG } from 'store/run-process-panel/run-process-panel-actions';
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { WorkflowResource } from 'models/workflow';
+
+const mapStateToProps = (state: RootState, props: WithDialogProps<{ workflow: WorkflowResource }>) => ({
+    workflow: props.data.workflow
+});
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: (workflow: WorkflowResource) => {
+        props.closeDialog();
+        dispatch<any>(setWorkflow(workflow));
+    }
+});
+
+const mergeProps = (
+    stateProps: { workflow: WorkflowResource },
+    dispatchProps: { onConfirm: (workflow: WorkflowResource) => void },
+    props: WithDialogProps<{ workflow: WorkflowResource }>) => ({
+        onConfirm: () => dispatchProps.onConfirm(stateProps.workflow),
+        ...props
+    });
+
+export const [ChangeWorkflowDialog] = [ConfirmationDialog]
+    .map(connect(mapStateToProps, mapDispatchToProps, mergeProps) as any)
+    .map(withDialog(SET_WORKFLOW_DIALOG));
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/search-bar/search-bar-advanced-properties-view.tsx b/services/workbench2/src/views-components/search-bar/search-bar-advanced-properties-view.tsx
new file mode 100644 (file)
index 0000000..6ebef15
--- /dev/null
@@ -0,0 +1,123 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Dispatch, compose } from 'redux';
+import { connect } from 'react-redux';
+import { InjectedFormProps, formValueSelector } from 'redux-form';
+import { Grid, withStyles, StyleRulesCallback, WithStyles, Button } from '@material-ui/core';
+import { RootState } from 'store/store';
+import {
+    SEARCH_BAR_ADVANCED_FORM_NAME,
+    changeAdvancedFormProperty,
+    resetAdvancedFormProperty
+} from 'store/search-bar/search-bar-actions';
+import { PropertyValue } from 'models/search-bar';
+import { ArvadosTheme } from 'common/custom-theme';
+import { SearchBarKeyField, SearchBarValueField } from 'views-components/form-fields/search-bar-form-fields';
+import { Chips } from 'components/chips/chips';
+import { formatPropertyValue } from "common/formatters";
+import { Vocabulary } from 'models/vocabulary';
+import { connectVocabulary } from '../resource-properties-form/property-field-common';
+import { isEqual } from 'lodash';
+
+type CssRules = 'label' | 'button';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    label: {
+        color: theme.palette.grey["500"],
+        fontSize: '0.8125rem',
+        alignSelf: 'center'
+    },
+    button: {
+        boxShadow: 'none'
+    }
+});
+
+interface SearchBarAdvancedPropertiesViewDataProps {
+    submitting: boolean;
+    invalid: boolean;
+    pristine: boolean;
+    propertyValues: PropertyValue;
+    fields: PropertyValue[];
+    vocabulary: Vocabulary;
+}
+
+interface SearchBarAdvancedPropertiesViewActionProps {
+    setProps: () => void;
+    addProp: (propertyValues: PropertyValue, properties: PropertyValue[]) => void;
+    getAllFields: (propertyValues: PropertyValue[]) => PropertyValue[] | [];
+}
+
+type SearchBarAdvancedPropertiesViewProps = SearchBarAdvancedPropertiesViewDataProps
+    & SearchBarAdvancedPropertiesViewActionProps
+    & InjectedFormProps & WithStyles<CssRules>;
+
+const selector = formValueSelector(SEARCH_BAR_ADVANCED_FORM_NAME);
+const mapStateToProps = (state: RootState) => {
+    return {
+        propertyValues: selector(state, 'key', 'value', 'keyID', 'valueID')
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    setProps: (propertyValues: PropertyValue[]) => {
+        dispatch<any>(changeAdvancedFormProperty('properties', propertyValues));
+    },
+    addProp: (propertyValue: PropertyValue, properties: PropertyValue[]) => {
+        // Remove potential duplicates
+        properties = properties.filter(x => ! isEqual(
+            {
+                key: x.keyID || x.key,
+                value: x.valueID || x.value
+            }, {
+                key: propertyValue.keyID || propertyValue.key,
+                value: propertyValue.valueID || propertyValue.value
+            }));
+        dispatch<any>(changeAdvancedFormProperty(
+            'properties',
+            [...properties, propertyValue]
+        ));
+        dispatch<any>(resetAdvancedFormProperty('key'));
+        dispatch<any>(resetAdvancedFormProperty('value'));
+        dispatch<any>(resetAdvancedFormProperty('keyID'));
+        dispatch<any>(resetAdvancedFormProperty('valueID'));
+    },
+    getAllFields: (fields: any) => {
+        return fields.getAll() || [];
+    }
+});
+
+export const SearchBarAdvancedPropertiesView = compose(
+    connectVocabulary,
+    connect(mapStateToProps, mapDispatchToProps))(
+    withStyles(styles)(
+        ({ classes, fields, propertyValues, setProps, addProp, getAllFields, vocabulary }: SearchBarAdvancedPropertiesViewProps) =>
+            <Grid container item xs={12} spacing={16}>
+                <Grid item xs={2} className={classes.label}>Properties</Grid>
+                <Grid item xs={4}>
+                    <SearchBarKeyField />
+                </Grid>
+                <Grid item xs={4}>
+                    <SearchBarValueField />
+                </Grid>
+                <Grid container item xs={2} justify='flex-end' alignItems="center">
+                    <Button className={classes.button} onClick={() => addProp(propertyValues, getAllFields(fields))}
+                        color="primary"
+                        size='small'
+                        variant="contained"
+                        disabled={!Boolean(propertyValues.key && propertyValues.value)}>
+                        Add
+                    </Button>
+                </Grid>
+                <Grid item xs={2} />
+                <Grid container item xs={10} spacing={8}>
+                    <Chips values={getAllFields(fields)}
+                        deletable
+                        onChange={setProps}
+                        getLabel={(field: PropertyValue) => formatPropertyValue(field, vocabulary)} />
+                </Grid>
+            </Grid>
+    )
+);
diff --git a/services/workbench2/src/views-components/search-bar/search-bar-advanced-view.tsx b/services/workbench2/src/views-components/search-bar/search-bar-advanced-view.tsx
new file mode 100644 (file)
index 0000000..323f07b
--- /dev/null
@@ -0,0 +1,190 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { reduxForm, InjectedFormProps, reset } from 'redux-form';
+import { compose, Dispatch } from 'redux';
+import { Paper, StyleRulesCallback, withStyles, WithStyles, Button, Grid, IconButton, CircularProgress } from '@material-ui/core';
+import {
+    SEARCH_BAR_ADVANCED_FORM_NAME, SEARCH_BAR_ADVANCED_FORM_PICKER_ID,
+    searchAdvancedData,
+    setSearchValueFromAdvancedData
+} from 'store/search-bar/search-bar-actions';
+import { ArvadosTheme } from 'common/custom-theme';
+import { CloseIcon } from 'components/icon/icon';
+import { SearchBarAdvancedFormData } from 'models/search-bar';
+import {
+    SearchBarTypeField, SearchBarClusterField, SearchBarProjectField, SearchBarTrashField,
+    SearchBarDateFromField, SearchBarDateToField, SearchBarPropertiesField,
+    SearchBarSaveSearchField, SearchBarQuerySearchField, SearchBarPastVersionsField
+} from 'views-components/form-fields/search-bar-form-fields';
+import { treePickerActions } from "store/tree-picker/tree-picker-actions";
+
+type CssRules = 'container' | 'closeIcon' | 'label' | 'buttonWrapper'
+    | 'button' | 'circularProgress' | 'searchView' | 'selectGrid';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    container: {
+        padding: theme.spacing.unit * 2,
+        borderBottom: `1px solid ${theme.palette.grey["200"]}`,
+        position: 'relative',
+    },
+    closeIcon: {
+        position: 'absolute',
+        top: '12px',
+        right: '12px'
+    },
+    label: {
+        color: theme.palette.grey["500"],
+        fontSize: '0.8125rem',
+        alignSelf: 'center'
+    },
+    buttonWrapper: {
+        marginRight: '14px',
+        marginTop: '14px',
+        position: 'relative',
+    },
+    button: {
+        boxShadow: 'none'
+    },
+    circularProgress: {
+        position: 'absolute',
+        top: 0,
+        bottom: 0,
+        left: 0,
+        right: 0,
+        margin: 'auto'
+    },
+    searchView: {
+        color: theme.palette.common.black,
+        borderRadius: `0 0 ${theme.spacing.unit / 2}px ${theme.spacing.unit / 2}px`
+    },
+    selectGrid: {
+        marginBottom: theme.spacing.unit * 2
+    }
+});
+
+// ToDo: maybe we should remove invalid and prostine
+interface SearchBarAdvancedViewFormDataProps {
+    submitting: boolean;
+    invalid: boolean;
+    pristine: boolean;
+}
+
+// ToDo: maybe we should remove tags
+export interface SearchBarAdvancedViewDataProps {
+    tags: any;
+    saveQuery: boolean;
+}
+
+export interface SearchBarAdvancedViewActionProps {
+    closeAdvanceView: () => void;
+}
+
+type SearchBarAdvancedViewProps = SearchBarAdvancedViewActionProps & SearchBarAdvancedViewDataProps;
+
+type SearchBarAdvancedViewFormProps = SearchBarAdvancedViewProps & SearchBarAdvancedViewFormDataProps
+    & InjectedFormProps & WithStyles<CssRules>;
+
+const validate = (values: any) => {
+    const errors: any = {};
+
+    if (values.dateFrom && values.dateTo) {
+        if (new Date(values.dateFrom).getTime() > new Date(values.dateTo).getTime()) {
+            errors.dateFrom = 'Invalid date';
+        }
+    }
+
+    return errors;
+};
+
+export const SearchBarAdvancedView = compose(
+    reduxForm<SearchBarAdvancedFormData, SearchBarAdvancedViewProps>({
+        form: SEARCH_BAR_ADVANCED_FORM_NAME,
+        validate,
+        onSubmit: (data: SearchBarAdvancedFormData, dispatch: Dispatch) => {
+            dispatch<any>(searchAdvancedData(data));
+            dispatch(reset(SEARCH_BAR_ADVANCED_FORM_NAME));
+            dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
+        },
+        onChange: (data: SearchBarAdvancedFormData, dispatch: Dispatch, props: any, prevData: SearchBarAdvancedFormData) => {
+            dispatch<any>(setSearchValueFromAdvancedData(data, prevData));
+        },
+    }),
+    withStyles(styles))(
+        ({ classes, closeAdvanceView, handleSubmit, submitting, invalid, pristine, tags, saveQuery }: SearchBarAdvancedViewFormProps) =>
+            <Paper className={classes.searchView}>
+                <form onSubmit={handleSubmit}>
+                    <Grid container direction="column" justify="flex-start" alignItems="flex-start">
+                        <Grid item xs={12} container className={classes.container}>
+                            <Grid item container xs={12} className={classes.selectGrid}>
+                                <Grid item xs={2} className={classes.label}>Type</Grid>
+                                <Grid item xs={5}>
+                                    <SearchBarTypeField />
+                                </Grid>
+                            </Grid>
+                            <Grid item container xs={12} className={classes.selectGrid}>
+                                <Grid item xs={2} className={classes.label}>Cluster</Grid>
+                                <Grid item xs={5}>
+                                    <SearchBarClusterField />
+                                </Grid>
+                            </Grid>
+                            <Grid item container xs={12}>
+                                <Grid item xs={2} className={classes.label}>Project</Grid>
+                                <Grid item xs={10}>
+                                    <SearchBarProjectField />
+                                </Grid>
+                            </Grid>
+                            <Grid item container xs={12}>
+                                <Grid item xs={2} className={classes.label} />
+                                <Grid item xs={5}>
+                                    <SearchBarTrashField />
+                                </Grid>
+                                <Grid item xs={5}>
+                                    <SearchBarPastVersionsField />
+                                </Grid>
+                            </Grid>
+                            <IconButton onClick={closeAdvanceView} className={classes.closeIcon}>
+                                <CloseIcon />
+                            </IconButton>
+                        </Grid>
+                        <Grid container item xs={12} className={classes.container} spacing={16}>
+                            <Grid item xs={2} className={classes.label}>Date modified</Grid>
+                            <Grid item xs={4}>
+                                <SearchBarDateFromField />
+                            </Grid>
+                            <Grid item xs={4}>
+                                <SearchBarDateToField />
+                            </Grid>
+                        </Grid>
+                        <Grid container item xs={12} className={classes.container}>
+                            <SearchBarPropertiesField />
+                            <Grid container item xs={12} justify="flex-start" alignItems="center" spacing={16}>
+                                <Grid item xs={2} className={classes.label} />
+                                <Grid item xs={4}>
+                                    <SearchBarSaveSearchField />
+                                </Grid>
+                                <Grid item xs={4}>
+                                    {saveQuery && <SearchBarQuerySearchField />}
+                                </Grid>
+                            </Grid>
+                            <Grid container item xs={12} justify='flex-end'>
+                                <div className={classes.buttonWrapper}>
+                                    <Button type="submit" className={classes.button}
+                                        // ToDo: create easier condition
+                                        // Question: do we need this condition?
+                                        // disabled={invalid || submitting || pristine || !!(tags && tags.values && ((tags.values.key) || (tags.values.value)) && !Object.keys(tags.values).find(el => el !== 'value' && el !== 'key'))}
+                                        color="primary"
+                                        size='small'
+                                        variant="contained">
+                                        Search
+                                    </Button>
+                                    {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
+                                </div>
+                            </Grid>
+                        </Grid>
+                    </Grid>
+                </form>
+            </Paper>
+    );
diff --git a/services/workbench2/src/views-components/search-bar/search-bar-autocomplete-view.tsx b/services/workbench2/src/views-components/search-bar/search-bar-autocomplete-view.tsx
new file mode 100644 (file)
index 0000000..885f7fd
--- /dev/null
@@ -0,0 +1,59 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Paper, StyleRulesCallback, withStyles, WithStyles, List, ListItem, ListItemText } from '@material-ui/core';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+import Highlighter from "react-highlight-words";
+import { SearchBarSelectedItem } from "store/search-bar/search-bar-reducer";
+
+type CssRules = 'searchView' | 'list' | 'listItem';
+
+const styles: StyleRulesCallback<CssRules> = theme => {
+    return {
+        searchView: {
+            borderRadius: `0 0 ${theme.spacing.unit / 2}px ${theme.spacing.unit / 2}px`
+        },
+        list: {
+            padding: 0
+        },
+        listItem: {
+            paddingLeft: theme.spacing.unit,
+            paddingRight: theme.spacing.unit * 2,
+        }
+    };
+};
+
+export interface SearchBarAutocompleteViewDataProps {
+    searchResults: GroupContentsResource[];
+    searchValue?: string;
+    selectedItem: SearchBarSelectedItem;
+}
+
+export interface SearchBarAutocompleteViewActionProps {
+    navigateTo: (uuid: string) => void;
+}
+
+type SearchBarAutocompleteViewProps = SearchBarAutocompleteViewDataProps & SearchBarAutocompleteViewActionProps & WithStyles<CssRules>;
+
+export const SearchBarAutocompleteView = withStyles(styles)(
+    ({ classes, searchResults, searchValue, navigateTo, selectedItem }: SearchBarAutocompleteViewProps) => {
+        return <Paper className={classes.searchView}>
+            <List component="nav" className={classes.list}>
+                <ListItem button className={classes.listItem} selected={!selectedItem || searchValue === selectedItem.id}>
+                    <ListItemText secondary={searchValue}/>
+                </ListItem>
+                {searchResults.map((item: GroupContentsResource) =>
+                    <ListItem button key={item.uuid} className={classes.listItem} selected={item.uuid === selectedItem.id}>
+                        <ListItemText secondary={getFormattedText(item.name, searchValue)}
+                                      onClick={() => navigateTo(item.uuid)}/>
+                    </ListItem>
+                )}
+            </List>
+        </Paper>;
+    });
+
+const getFormattedText = (textToHighlight: string, searchString = '') => {
+    return <Highlighter searchWords={[searchString]} autoEscape={true} textToHighlight={textToHighlight} />;
+};
diff --git a/services/workbench2/src/views-components/search-bar/search-bar-basic-view.tsx b/services/workbench2/src/views-components/search-bar/search-bar-basic-view.tsx
new file mode 100644 (file)
index 0000000..e880c43
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Paper, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
+import {
+    SearchBarRecentQueries,
+    SearchBarRecentQueriesActionProps
+} from 'views-components/search-bar/search-bar-recent-queries';
+import {
+    SearchBarSavedQueries,
+    SearchBarSavedQueriesDataProps,
+    SearchBarSavedQueriesActionProps
+} from 'views-components/search-bar/search-bar-save-queries';
+
+type CssRules = 'advanced' | 'label' | 'root';
+
+const styles: StyleRulesCallback<CssRules> = theme => {
+    return {
+        root: {
+            color: theme.palette.common.black,
+            borderRadius: `0 0 ${theme.spacing.unit / 2}px ${theme.spacing.unit / 2}px`
+        },
+        advanced: {
+            display: 'flex',
+            justifyContent: 'flex-end',
+            padding: theme.spacing.unit,
+            fontSize: '0.875rem',
+            cursor: 'pointer',
+            color: theme.palette.primary.main
+        },
+        label: {
+            fontSize: '0.775rem',
+            padding: `${theme.spacing.unit}px ${theme.spacing.unit}px `,
+            color: theme.palette.grey["900"],
+            background: 'white',
+            textAlign: 'right',
+            fontWeight: 'bold'
+        }
+    };
+};
+
+export type SearchBarBasicViewDataProps = SearchBarSavedQueriesDataProps;
+
+export type SearchBarBasicViewActionProps = {
+    onSetView: (currentView: string) => void;
+    onSearch: (searchValue: string) => void;
+} & SearchBarRecentQueriesActionProps & SearchBarSavedQueriesActionProps;
+
+type SearchBarBasicViewProps = SearchBarBasicViewDataProps & SearchBarBasicViewActionProps & WithStyles<CssRules>;
+
+export const SearchBarBasicView = withStyles(styles)(
+    ({ classes, onSetView, loadRecentQueries, deleteSavedQuery, savedQueries, onSearch, editSavedQuery, selectedItem }: SearchBarBasicViewProps) =>
+        <Paper className={classes.root}>
+            <div className={classes.label}>{"Recent queries"}</div>
+            <SearchBarRecentQueries
+                onSearch={onSearch}
+                loadRecentQueries={loadRecentQueries}
+                selectedItem={selectedItem} />
+            <div className={classes.label}>{"Saved queries"}</div>
+            <SearchBarSavedQueries
+                onSearch={onSearch}
+                savedQueries={savedQueries}
+                editSavedQuery={editSavedQuery}
+                deleteSavedQuery={deleteSavedQuery}
+                selectedItem={selectedItem} />
+        </Paper>
+);
diff --git a/services/workbench2/src/views-components/search-bar/search-bar-recent-queries.tsx b/services/workbench2/src/views-components/search-bar/search-bar-recent-queries.tsx
new file mode 100644 (file)
index 0000000..1d5c46c
--- /dev/null
@@ -0,0 +1,48 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { withStyles, WithStyles, StyleRulesCallback, List, ListItem, ListItemText } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { SearchBarSelectedItem } from "store/search-bar/search-bar-reducer";
+
+type CssRules = 'root' | 'listItem' | 'listItemText';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        padding: '0px'
+    },
+    listItem: {
+        paddingLeft: theme.spacing.unit,
+        paddingRight: theme.spacing.unit * 2,
+    },
+    listItemText: {
+        fontSize: '0.8125rem',
+        color: theme.palette.grey["900"]
+    }
+});
+
+export interface SearchBarRecentQueriesDataProps {
+    selectedItem: SearchBarSelectedItem;
+}
+
+export interface SearchBarRecentQueriesActionProps {
+    onSearch: (searchValue: string) => void;
+    loadRecentQueries: () => string[];
+}
+
+type SearchBarRecentQueriesProps = SearchBarRecentQueriesDataProps & SearchBarRecentQueriesActionProps & WithStyles<CssRules>;
+
+export const SearchBarRecentQueries = withStyles(styles)(
+    ({ classes, onSearch, loadRecentQueries, selectedItem }: SearchBarRecentQueriesProps) =>
+        <List component="nav" className={classes.root}>
+            {loadRecentQueries().map((query, index) =>
+                <ListItem button key={index} className={classes.listItem} selected={`RQ-${index}-${query}` === selectedItem.id}>
+                    <ListItemText disableTypography
+                        secondary={query}
+                        onClick={() => onSearch(query)}
+                        className={classes.listItemText} />
+                </ListItem>
+            )}
+        </List>);
diff --git a/services/workbench2/src/views-components/search-bar/search-bar-save-queries.tsx b/services/workbench2/src/views-components/search-bar/search-bar-save-queries.tsx
new file mode 100644 (file)
index 0000000..1b765b9
--- /dev/null
@@ -0,0 +1,71 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { withStyles, WithStyles, StyleRulesCallback, List, ListItem, ListItemText, ListItemSecondaryAction, Tooltip, IconButton } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { RemoveIcon, EditSavedQueryIcon } from 'components/icon/icon';
+import { SearchBarAdvancedFormData } from 'models/search-bar';
+import { SearchBarSelectedItem } from "store/search-bar/search-bar-reducer";
+import { getQueryFromAdvancedData } from "store/search-bar/search-bar-actions";
+
+type CssRules = 'root' | 'listItem' | 'listItemText' | 'button';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        padding: '0px'
+    },
+    listItem: {
+        paddingLeft: theme.spacing.unit,
+        paddingRight: theme.spacing.unit * 2
+    },
+    listItemText: {
+        fontSize: '0.8125rem',
+        color: theme.palette.grey["900"]
+    },
+    button: {
+        padding: '6px',
+        marginRight: theme.spacing.unit
+    }
+});
+
+export interface SearchBarSavedQueriesDataProps {
+    savedQueries: SearchBarAdvancedFormData[];
+    selectedItem: SearchBarSelectedItem;
+}
+
+export interface SearchBarSavedQueriesActionProps {
+    onSearch: (searchValue: string) => void;
+    deleteSavedQuery: (id: number) => void;
+    editSavedQuery: (data: SearchBarAdvancedFormData, id: number) => void;
+}
+
+type SearchBarSavedQueriesProps = SearchBarSavedQueriesDataProps
+    & SearchBarSavedQueriesActionProps
+    & WithStyles<CssRules>;
+
+export const SearchBarSavedQueries = withStyles(styles)(
+    ({ classes, savedQueries, onSearch, editSavedQuery, deleteSavedQuery, selectedItem }: SearchBarSavedQueriesProps) =>
+        <List component="nav" className={classes.root}>
+            {savedQueries.map((query, index) =>
+                <ListItem button key={index} className={classes.listItem} selected={`SQ-${index}-${query.queryName}` === selectedItem.id}>
+                    <ListItemText disableTypography
+                        secondary={query.queryName}
+                        onClick={() => onSearch(getQueryFromAdvancedData(query))}
+                        className={classes.listItemText} />
+                    <ListItemSecondaryAction>
+                        <Tooltip title="Edit">
+                            <IconButton aria-label="Edit" onClick={() => editSavedQuery(query, index)} className={classes.button}>
+                                <EditSavedQueryIcon />
+                            </IconButton>
+                        </Tooltip>
+                        <Tooltip title="Remove">
+                            <IconButton aria-label="Remove" onClick={() => deleteSavedQuery(index)} className={classes.button}>
+                                <RemoveIcon />
+                            </IconButton>
+                        </Tooltip>
+                    </ListItemSecondaryAction>
+                </ListItem>
+            )}
+    </List>);
diff --git a/services/workbench2/src/views-components/search-bar/search-bar-view.test.tsx b/services/workbench2/src/views-components/search-bar/search-bar-view.test.tsx
new file mode 100644 (file)
index 0000000..2f7ed65
--- /dev/null
@@ -0,0 +1,85 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { mount, configure } from "enzyme";
+import Adapter from 'enzyme-adapter-react-16';
+
+
+configure({ adapter: new Adapter() });
+
+describe("<SearchBarView />", () => {
+
+    jest.useFakeTimers();
+
+    let onSearch: () => void;
+
+    beforeEach(() => {
+        onSearch = jest.fn();
+    });
+
+    describe("on input value change", () => {
+        // TODO fix tests and delete beneath one
+        it("fix tests", () => {
+            const test = 1;
+            expect(test).toBe(1);
+        });
+        // it("calls onSearch after default timeout", () => {
+        //     const searchBar = mount(<SearchBarView onSearch={onSearch} value="current value" {...mockSearchProps()} />);
+        //     searchBar.find("input").simulate("change", { target: { value: "current value" } });
+        //     expect(onSearch).not.toBeCalled();
+        //     jest.runTimersToTime(DEFAULT_SEARCH_DEBOUNCE);
+        //     expect(onSearch).toBeCalledWith("current value");
+        // });
+
+        // it("calls onSearch after the time specified in props has passed", () => {
+        //     const searchBar = mount(<SearchBarView onSearch={onSearch} value="current value" debounce={2000} {...mockSearchProps()} />);
+        //     searchBar.find("input").simulate("change", { target: { value: "current value" } });
+        //     jest.runTimersToTime(1000);
+        //     expect(onSearch).not.toBeCalled();
+        //     jest.runTimersToTime(1000);
+        //     expect(onSearch).toBeCalledWith("current value");
+        // });
+
+        // it("calls onSearch only once after no change happened during the specified time", () => {
+        //     const searchBar = mount(<SearchBarView onSearch={onSearch} value="current value" debounce={1000} {...mockSearchProps()} />);
+        //     searchBar.find("input").simulate("change", { target: { value: "current value" } });
+        //     jest.runTimersToTime(500);
+        //     searchBar.find("input").simulate("change", { target: { value: "changed value" } });
+        //     jest.runTimersToTime(1000);
+        //     expect(onSearch).toHaveBeenCalledTimes(1);
+        // });
+
+        // it("calls onSearch again after the specified time has passed since previous call", () => {
+        //     const searchBar = mount(<SearchBarView onSearch={onSearch} value="latest value" debounce={1000} {...mockSearchProps()} />);
+        //     searchBar.find("input").simulate("change", { target: { value: "current value" } });
+        //     jest.runTimersToTime(500);
+        //     searchBar.find("input").simulate("change", { target: { value: "intermediate value" } });
+        //     jest.runTimersToTime(1000);
+        //     expect(onSearch).toBeCalledWith("intermediate value");
+        //     searchBar.find("input").simulate("change", { target: { value: "latest value" } });
+        //     jest.runTimersToTime(1000);
+        //     expect(onSearch).toBeCalledWith("latest value");
+        //     expect(onSearch).toHaveBeenCalledTimes(2);
+
+        // });
+    });
+});
+
+const mockSearchProps = () => ({
+    currentView: '',
+    open: true,
+    onSetView: jest.fn(),
+    openView: jest.fn(),
+    loseView: jest.fn(),
+    closeView: jest.fn(),
+    saveRecentQuery: jest.fn(),
+    loadRecentQueries: () => ['test'],
+    saveQuery: jest.fn(),
+    deleteSavedQuery: jest.fn(),
+    openSearchView: jest.fn(),
+    editSavedQuery: jest.fn(),
+    navigateTo: jest.fn(),
+    searchDataOnEnter: jest.fn()
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/search-bar/search-bar-view.tsx b/services/workbench2/src/views-components/search-bar/search-bar-view.tsx
new file mode 100644 (file)
index 0000000..eba281c
--- /dev/null
@@ -0,0 +1,255 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { compose } from "redux";
+import { IconButton, Paper, StyleRulesCallback, withStyles, WithStyles, Tooltip, InputAdornment, Input } from "@material-ui/core";
+import SearchIcon from "@material-ui/icons/Search";
+import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown";
+import { ArvadosTheme } from "common/custom-theme";
+import { SearchView } from "store/search-bar/search-bar-reducer";
+import { SearchBarBasicView, SearchBarBasicViewDataProps, SearchBarBasicViewActionProps } from "views-components/search-bar/search-bar-basic-view";
+import {
+    SearchBarAutocompleteView,
+    SearchBarAutocompleteViewDataProps,
+    SearchBarAutocompleteViewActionProps,
+} from "views-components/search-bar/search-bar-autocomplete-view";
+import {
+    SearchBarAdvancedView,
+    SearchBarAdvancedViewDataProps,
+    SearchBarAdvancedViewActionProps,
+} from "views-components/search-bar/search-bar-advanced-view";
+import { KEY_CODE_DOWN, KEY_CODE_ESC, KEY_CODE_UP, KEY_ENTER } from "common/codes";
+import { debounce } from "debounce";
+import { Vocabulary } from "models/vocabulary";
+import { connectVocabulary } from "../resource-properties-form/property-field-common";
+
+type CssRules = "container" | "containerSearchViewOpened" | "input" | "view";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => {
+    return {
+        container: {
+            position: "relative",
+            width: "100%",
+            borderRadius: theme.spacing.unit / 2,
+            zIndex: theme.zIndex.modal,
+        },
+        containerSearchViewOpened: {
+            position: "relative",
+            width: "100%",
+            borderRadius: `${theme.spacing.unit / 2}px ${theme.spacing.unit / 2}px 0 0`,
+            zIndex: theme.zIndex.modal,
+        },
+        input: {
+            border: "none",
+            padding: `0`,
+        },
+        view: {
+            position: "absolute",
+            width: "100%",
+            zIndex: 1,
+        },
+    };
+};
+
+export type SearchBarDataProps = SearchBarViewDataProps &
+    SearchBarAutocompleteViewDataProps &
+    SearchBarAdvancedViewDataProps &
+    SearchBarBasicViewDataProps;
+
+interface SearchBarViewDataProps {
+    searchValue: string;
+    currentView: string;
+    isPopoverOpen: boolean;
+    debounce?: number;
+    vocabulary?: Vocabulary;
+}
+
+export type SearchBarActionProps = SearchBarViewActionProps &
+    SearchBarAutocompleteViewActionProps &
+    SearchBarAdvancedViewActionProps &
+    SearchBarBasicViewActionProps;
+
+interface SearchBarViewActionProps {
+    onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+    onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
+    onSetView: (currentView: string) => void;
+    closeView: () => void;
+    openSearchView: () => void;
+    loadRecentQueries: () => string[];
+    moveUp: () => void;
+    moveDown: () => void;
+    setAdvancedDataFromSearchValue: (search: string, vocabulary?: Vocabulary) => void;
+}
+
+type SearchBarViewProps = SearchBarDataProps & SearchBarActionProps & WithStyles<CssRules>;
+
+const handleKeyDown = (e: React.KeyboardEvent, props: SearchBarViewProps) => {
+    if (e.keyCode === KEY_CODE_DOWN) {
+        e.preventDefault();
+        if (!props.isPopoverOpen) {
+            props.onSetView(SearchView.AUTOCOMPLETE);
+            props.openSearchView();
+        } else {
+            props.moveDown();
+        }
+    } else if (e.keyCode === KEY_CODE_UP) {
+        e.preventDefault();
+        props.moveUp();
+    } else if (e.keyCode === KEY_CODE_ESC) {
+        e.preventDefault();
+        props.closeView();
+    } else if (e.keyCode === KEY_ENTER) {
+        if (props.currentView === SearchView.BASIC) {
+            e.preventDefault();
+            props.onSearch(props.selectedItem.query);
+        } else if (props.currentView === SearchView.AUTOCOMPLETE) {
+            if (props.selectedItem.id !== props.searchValue) {
+                e.preventDefault();
+                props.navigateTo(props.selectedItem.id);
+            }
+        }
+    }
+};
+
+const handleInputClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
+    if (props.searchValue) {
+        props.onSetView(SearchView.AUTOCOMPLETE);
+    } else {
+        props.onSetView(SearchView.BASIC);
+    }
+    props.openSearchView();
+};
+
+const handleDropdownClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
+    e.stopPropagation();
+    if (props.isPopoverOpen && props.currentView === SearchView.ADVANCED) {
+        props.closeView();
+    } else {
+        props.setAdvancedDataFromSearchValue(props.searchValue, props.vocabulary);
+        props.onSetView(SearchView.ADVANCED);
+    }
+};
+
+export const SearchBarView = compose(
+    connectVocabulary,
+    withStyles(styles)
+)(
+    class extends React.Component<SearchBarViewProps> {
+        debouncedSearch = debounce(() => {
+            this.props.onSearch(this.props.searchValue);
+        }, 1000);
+
+        handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+            this.debouncedSearch();
+            this.props.onChange(event);
+        };
+
+        handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+            this.debouncedSearch.clear();
+            this.props.onSubmit(event);
+        };
+
+        componentWillUnmount() {
+            this.debouncedSearch.clear();
+        }
+
+        render() {
+            const { children, ...props } = this.props;
+            const { classes, isPopoverOpen } = this.props;
+            return (
+                <>
+                    {isPopoverOpen && <Backdrop onClick={props.closeView} />}
+
+                    <Paper className={isPopoverOpen ? classes.containerSearchViewOpened : classes.container}>
+                        <form
+                            data-cy="searchbar-parent-form"
+                            onSubmit={this.handleSubmit}>
+                            <Input
+                                data-cy="searchbar-input-field"
+                                className={classes.input}
+                                onChange={this.handleChange}
+                                placeholder="Search"
+                                value={props.searchValue}
+                                fullWidth={true}
+                                disableUnderline={true}
+                                onClick={e => handleInputClick(e, props)}
+                                onKeyDown={e => handleKeyDown(e, props)}
+                                startAdornment={
+                                    <InputAdornment position="start">
+                                        <Tooltip title="Search">
+                                            <IconButton type="submit">
+                                                <SearchIcon />
+                                            </IconButton>
+                                        </Tooltip>
+                                    </InputAdornment>
+                                }
+                                endAdornment={
+                                    <InputAdornment position="end">
+                                        <Tooltip title="Advanced search">
+                                            <IconButton onClick={e => handleDropdownClick(e, props)}>
+                                                <ArrowDropDownIcon />
+                                            </IconButton>
+                                        </Tooltip>
+                                    </InputAdornment>
+                                }
+                            />
+                        </form>
+                        <div className={classes.view}>{isPopoverOpen && getView({ ...props })}</div>
+                    </Paper>
+                </>
+            );
+        }
+    }
+);
+
+const getView = (props: SearchBarViewProps) => {
+    switch (props.currentView) {
+        case SearchView.AUTOCOMPLETE:
+            return (
+                <SearchBarAutocompleteView
+                    navigateTo={props.navigateTo}
+                    searchResults={props.searchResults}
+                    searchValue={props.searchValue}
+                    selectedItem={props.selectedItem}
+                />
+            );
+        case SearchView.ADVANCED:
+            return (
+                <SearchBarAdvancedView
+                    closeAdvanceView={props.closeAdvanceView}
+                    tags={props.tags}
+                    saveQuery={props.saveQuery}
+                />
+            );
+        default:
+            return (
+                <SearchBarBasicView
+                    onSetView={props.onSetView}
+                    onSearch={props.onSearch}
+                    loadRecentQueries={props.loadRecentQueries}
+                    savedQueries={props.savedQueries}
+                    deleteSavedQuery={props.deleteSavedQuery}
+                    editSavedQuery={props.editSavedQuery}
+                    selectedItem={props.selectedItem}
+                />
+            );
+    }
+};
+
+const Backdrop = withStyles<"backdrop">(theme => ({
+    backdrop: {
+        position: "fixed",
+        top: 0,
+        right: 0,
+        bottom: 0,
+        left: 0,
+        zIndex: theme.zIndex.modal,
+    },
+}))(({ classes, ...props }: WithStyles<"backdrop"> & React.HTMLProps<HTMLDivElement>) => (
+    <div
+        className={classes.backdrop}
+        {...props}
+    />
+));
diff --git a/services/workbench2/src/views-components/search-bar/search-bar.tsx b/services/workbench2/src/views-components/search-bar/search-bar.tsx
new file mode 100644 (file)
index 0000000..6a4d2a6
--- /dev/null
@@ -0,0 +1,57 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import {
+    goToView,
+    searchData,
+    deleteSavedQuery,
+    loadRecentQueries,
+    openSearchView,
+    closeSearchView,
+    closeAdvanceView,
+    navigateToItem,
+    editSavedQuery,
+    changeData,
+    submitData, moveUp, moveDown, setAdvancedDataFromSearchValue, SEARCH_BAR_ADVANCED_FORM_NAME
+} from 'store/search-bar/search-bar-actions';
+import { SearchBarView, SearchBarActionProps, SearchBarDataProps } from 'views-components/search-bar/search-bar-view';
+import { SearchBarAdvancedFormData } from 'models/search-bar';
+import { Vocabulary } from 'models/vocabulary';
+
+const mapStateToProps = ({ searchBar, form }: RootState): SearchBarDataProps => {
+    return {
+        searchValue: searchBar.searchValue,
+        currentView: searchBar.currentView,
+        isPopoverOpen: searchBar.open,
+        searchResults: searchBar.searchResults,
+        selectedItem: searchBar.selectedItem,
+        savedQueries: searchBar.savedQueries,
+        tags: form[SEARCH_BAR_ADVANCED_FORM_NAME],
+        saveQuery: form[SEARCH_BAR_ADVANCED_FORM_NAME] &&
+            form[SEARCH_BAR_ADVANCED_FORM_NAME].values &&
+            form[SEARCH_BAR_ADVANCED_FORM_NAME].values!.saveQuery
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): SearchBarActionProps => ({
+    onSearch: (valueSearch: string) => dispatch<any>(searchData(valueSearch, true)),
+    onChange: (event: React.ChangeEvent<HTMLInputElement>) => dispatch<any>(changeData(event.target.value)),
+    onSetView: (currentView: string) => dispatch(goToView(currentView)),
+    onSubmit: (event: React.FormEvent<HTMLFormElement>) => dispatch<any>(submitData(event)),
+    closeView: () => dispatch<any>(closeSearchView()),
+    closeAdvanceView: () => dispatch<any>(closeAdvanceView()),
+    loadRecentQueries: () => dispatch<any>(loadRecentQueries()),
+    deleteSavedQuery: (id: number) => dispatch<any>(deleteSavedQuery(id)),
+    openSearchView: () => dispatch<any>(openSearchView()),
+    navigateTo: (uuid: string) => dispatch<any>(navigateToItem(uuid)),
+    editSavedQuery: (data: SearchBarAdvancedFormData) => dispatch<any>(editSavedQuery(data)),
+    moveUp: () => dispatch<any>(moveUp()),
+    moveDown: () => dispatch<any>(moveDown()),
+    setAdvancedDataFromSearchValue: (search: string, vocabulary: Vocabulary) => dispatch<any>(setAdvancedDataFromSearchValue(search, vocabulary))
+});
+
+export const SearchBar = connect(mapStateToProps, mapDispatchToProps)(SearchBarView);
diff --git a/services/workbench2/src/views-components/sharing-dialog/participant-select.tsx b/services/workbench2/src/views-components/sharing-dialog/participant-select.tsx
new file mode 100644 (file)
index 0000000..058d723
--- /dev/null
@@ -0,0 +1,181 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Autocomplete } from 'components/autocomplete/autocomplete';
+import { connect, DispatchProp } from 'react-redux';
+import { ServiceRepository } from 'services/services';
+import { FilterBuilder } from '../../services/api/filter-builder';
+import { debounce } from 'debounce';
+import { ListItemText, Typography } from '@material-ui/core';
+import { noop } from 'lodash/fp';
+import { GroupClass, GroupResource } from 'models/group';
+import { getUserDetailsString, getUserDisplayName, UserResource } from 'models/user';
+import { Resource, ResourceKind } from 'models/resource';
+import { ListResults } from 'services/common-service/common-service';
+
+export interface Participant {
+    name: string;
+    tooltip: string;
+    uuid: string;
+}
+
+type ParticipantResource = GroupResource | UserResource;
+
+interface ParticipantSelectProps {
+    items: Participant[];
+    excludedParticipants?: string[];
+    label?: string;
+    autofocus?: boolean;
+    onlyPeople?: boolean;
+    onlyActive?: boolean;
+    disabled?: boolean;
+
+    onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
+    onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
+    onCreate?: (person: Participant) => void;
+    onDelete?: (index: number) => void;
+    onSelect?: (person: Participant) => void;
+}
+
+interface ParticipantSelectState {
+    value: string;
+    suggestions: ParticipantResource[];
+}
+
+const getDisplayName = (item: GroupResource | UserResource, detailed: boolean) => {
+    switch (item.kind) {
+        case ResourceKind.USER:
+            return getUserDisplayName(item, detailed, detailed);
+        case ResourceKind.GROUP:
+            return item.name + `(${`(${(item as Resource).uuid})`})`;
+        default:
+            return (item as Resource).uuid;
+    }
+};
+
+const getDisplayTooltip = (item: GroupResource | UserResource) => {
+    switch (item.kind) {
+        case ResourceKind.USER:
+            return getUserDetailsString(item);
+        case ResourceKind.GROUP:
+            return item.name + `(${`(${(item as Resource).uuid})`})`;
+        default:
+            return (item as Resource).uuid;
+    }
+};
+
+export const ParticipantSelect = connect()(
+    class ParticipantSelect extends React.Component<ParticipantSelectProps & DispatchProp, ParticipantSelectState> {
+        state: ParticipantSelectState = {
+            value: '',
+            suggestions: []
+        };
+
+        render() {
+            const { label = 'Add people and groups' } = this.props;
+
+            return (
+                <Autocomplete
+                    label={label}
+                    value={this.state.value}
+                    items={this.props.items}
+                    suggestions={this.state.suggestions}
+                    autofocus={this.props.autofocus}
+                    onChange={this.handleChange}
+                    onCreate={this.handleCreate}
+                    onSelect={this.handleSelect}
+                    onDelete={this.props.onDelete && !this.props.disabled ? this.handleDelete : undefined}
+                    onFocus={this.props.onFocus}
+                    onBlur={this.onBlur}
+                    renderChipValue={this.renderChipValue}
+                    renderChipTooltip={this.renderChipTooltip}
+                    renderSuggestion={this.renderSuggestion}
+                    disabled={this.props.disabled} />
+            );
+        }
+
+        onBlur = (e) => {
+            if (this.props.onBlur) {
+                this.props.onBlur(e);
+            }
+            setTimeout(() => this.setState({ value: '', suggestions: [] }), 200);
+        }
+
+        renderChipValue(chipValue: Participant) {
+            const { name, uuid } = chipValue;
+            return name || uuid;
+        }
+
+        renderChipTooltip(item: Participant) {
+            return item.tooltip;
+        }
+
+        renderSuggestion(item: ParticipantResource) {
+            return (
+                <ListItemText>
+                    <Typography noWrap>{getDisplayName(item, true)}</Typography>
+                </ListItemText>
+            );
+        }
+
+        handleDelete = (_: Participant, index: number) => {
+            const { onDelete = noop } = this.props;
+            onDelete(index);
+        }
+
+        handleCreate = () => {
+            const { onCreate } = this.props;
+            if (onCreate) {
+                this.setState({ value: '', suggestions: [] });
+                onCreate({
+                    name: '',
+                    tooltip: '',
+                    uuid: this.state.value,
+                });
+            }
+        }
+
+        handleSelect = (selection: ParticipantResource) => {
+            const { uuid } = selection;
+            const { onSelect = noop } = this.props;
+            this.setState({ value: '', suggestions: [] });
+            onSelect({
+                name: getDisplayName(selection, false),
+                tooltip: getDisplayTooltip(selection),
+                uuid,
+            });
+        }
+
+        handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+            this.setState({ value: event.target.value }, this.getSuggestions);
+        }
+
+        getSuggestions = debounce(() => this.props.dispatch<any>(this.requestSuggestions), 500);
+
+        requestSuggestions = async (_: void, __: void, { userService, groupsService }: ServiceRepository) => {
+            const { value } = this.state;
+            const limit = 5; // FIXME: Does this provide a good UX?
+
+            const filterUsers = new FilterBuilder()
+                .addILike('any', value)
+                .addEqual('is_active', this.props.onlyActive || undefined)
+                .addNotIn('uuid', this.props.excludedParticipants)
+                .getFilters();
+            const userItems: ListResults<any> = await userService.list({ filters: filterUsers, limit, count: "none" });
+
+            const filterGroups = new FilterBuilder()
+                .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
+                .addNotIn('uuid', this.props.excludedParticipants)
+                .addILike('name', value)
+                .getFilters();
+
+            const groupItems: ListResults<any> = await groupsService.list({ filters: filterGroups, limit, count: "none" });
+            this.setState({
+                suggestions: this.props.onlyPeople
+                    ? userItems.items
+                    : userItems.items.concat(groupItems.items)
+            });
+        }
+    });
diff --git a/services/workbench2/src/views-components/sharing-dialog/permission-select.tsx b/services/workbench2/src/views-components/sharing-dialog/permission-select.tsx
new file mode 100644 (file)
index 0000000..5201ba0
--- /dev/null
@@ -0,0 +1,77 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { MenuItem, Select } from '@material-ui/core';
+import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
+import Edit from '@material-ui/icons/Edit';
+import Computer from '@material-ui/icons/Computer';
+import { SelectProps } from '@material-ui/core/Select';
+import { SelectItem } from './select-item';
+import { PermissionLevel } from '../../models/permission';
+
+export enum PermissionSelectValue {
+    READ = 'Read',
+    WRITE = 'Write',
+    MANAGE = 'Manage',
+}
+
+export const parsePermissionLevel = (value: PermissionSelectValue) => {
+    switch (value) {
+        case PermissionSelectValue.READ:
+            return PermissionLevel.CAN_READ;
+        case PermissionSelectValue.WRITE:
+            return PermissionLevel.CAN_WRITE;
+        case PermissionSelectValue.MANAGE:
+            return PermissionLevel.CAN_MANAGE;
+        default:
+            return PermissionLevel.NONE;
+    }
+};
+
+export const formatPermissionLevel = (value: PermissionLevel) => {
+    switch (value) {
+        case PermissionLevel.CAN_READ:
+            return PermissionSelectValue.READ;
+        case PermissionLevel.CAN_WRITE:
+            return PermissionSelectValue.WRITE;
+        case PermissionLevel.CAN_MANAGE:
+            return PermissionSelectValue.MANAGE;
+        default:
+            return PermissionSelectValue.READ;
+    }
+};
+
+
+export const PermissionSelect = (props: SelectProps) =>
+    <Select
+        {...props}
+        disableUnderline
+        renderValue={renderPermissionItem}>
+        <MenuItem value={PermissionSelectValue.READ}>
+            {renderPermissionItem(PermissionSelectValue.READ)}
+        </MenuItem>
+        <MenuItem value={PermissionSelectValue.WRITE}>
+            {renderPermissionItem(PermissionSelectValue.WRITE)}
+        </MenuItem>
+        <MenuItem value={PermissionSelectValue.MANAGE}>
+            {renderPermissionItem(PermissionSelectValue.MANAGE)}
+        </MenuItem>
+    </Select>;
+
+const renderPermissionItem = (value: string) =>
+    <SelectItem {...{ value, icon: getIcon(value) }} />;
+
+const getIcon = (value: string) => {
+    switch (value) {
+        case PermissionSelectValue.READ:
+            return RemoveRedEye;
+        case PermissionSelectValue.WRITE:
+            return Edit;
+        case PermissionSelectValue.MANAGE:
+            return Computer;
+        default:
+            return Computer;
+    }
+};
diff --git a/services/workbench2/src/views-components/sharing-dialog/select-item.tsx b/services/workbench2/src/views-components/sharing-dialog/select-item.tsx
new file mode 100644 (file)
index 0000000..74b0c4d
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Grid, withStyles, StyleRulesCallback } from '@material-ui/core';
+import { WithStyles } from '@material-ui/core/styles';
+import { SvgIconProps } from '@material-ui/core/SvgIcon';
+
+type SelectItemClasses = 'value' | 'icon';
+
+const permissionItemStyles: StyleRulesCallback<SelectItemClasses> = theme => ({
+    value: {
+        marginLeft: theme.spacing.unit,
+    },
+    icon: {
+        margin: `-${theme.spacing.unit / 2}px 0`
+    }
+});
+
+/**
+ * This component should be used as a child of MenuItem component.
+ */
+export const SelectItem = withStyles(permissionItemStyles)(
+    ({ value, icon: Icon, classes }: { value: string, icon: React.ComponentType<SvgIconProps> } & WithStyles<SelectItemClasses>) => {
+        return (
+            <Grid container alignItems='center'>
+                <Icon className={classes.icon} />
+                <span className={classes.value}>
+                    {value}
+                </span>
+            </Grid>);
+    });
+
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-dialog-component.test.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-dialog-component.test.tsx
new file mode 100644 (file)
index 0000000..2fc4d01
--- /dev/null
@@ -0,0 +1,76 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { mount, configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import { Provider } from 'react-redux';
+import { combineReducers, createStore } from 'redux';
+
+import SharingDialogComponent, {
+    SharingDialogComponentProps,
+} from './sharing-dialog-component';
+import {
+    extractUuidObjectType,
+    ResourceObjectType
+} from 'models/resource';
+
+configure({ adapter: new Adapter() });
+
+describe("<SharingDialogComponent />", () => {
+    let props: SharingDialogComponentProps;
+    let store;
+
+    beforeEach(() => {
+        const initialAuthState = {
+            config: {
+                keepWebServiceUrl: 'http://example.com/',
+                keepWebInlineServiceUrl: 'http://*.collections.example.com/',
+                clusterConfig: {
+                    Users: {
+                        AnonymousUserToken: ""
+                    }
+                }
+            }
+        }
+        store = createStore(combineReducers({
+            auth: (state: any = initialAuthState, action: any) => state,
+        }));
+
+        props = {
+            open: true,
+            loading: false,
+            saveEnabled: false,
+            sharedResourceUuid: 'zzzzz-4zz18-zzzzzzzzzzzzzzz',
+            privateAccess: true,
+            sharingURLsNr: 2,
+            sharingURLsDisabled: false,
+            onClose: jest.fn(),
+            onSave: jest.fn(),
+            onCreateSharingToken: jest.fn(),
+            refreshPermissions: jest.fn(),
+        };
+    });
+
+    it("show sharing urls tab on collections when not disabled", () => {
+        expect(props.sharingURLsDisabled).toBe(false);
+        expect(props.sharingURLsNr).toBe(2);
+        expect(extractUuidObjectType(props.sharedResourceUuid) === ResourceObjectType.COLLECTION).toBe(true);
+        let wrapper = mount(<Provider store={store}><SharingDialogComponent {...props} /></Provider>);
+        expect(wrapper.html()).toContain('Sharing URLs (2)');
+
+        // disable Sharing URLs UI
+        props.sharingURLsDisabled = true;
+        wrapper = mount(<Provider store={store}><SharingDialogComponent {...props} /></Provider>);
+        expect(wrapper.html()).not.toContain('Sharing URLs');
+    });
+
+    it("does not show sharing urls on non-collection resources", () => {
+        props.sharedResourceUuid = 'zzzzz-j7d0g-0123456789abcde';
+        expect(extractUuidObjectType(props.sharedResourceUuid) === ResourceObjectType.COLLECTION).toBe(false);
+        expect(props.sharingURLsDisabled).toBe(false);
+        let wrapper = mount(<Provider store={store}><SharingDialogComponent {...props} /></Provider>);
+        expect(wrapper.html()).not.toContain('Sharing URLs');
+    });
+});
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-dialog-component.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-dialog-component.tsx
new file mode 100644 (file)
index 0000000..0108221
--- /dev/null
@@ -0,0 +1,233 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    Dialog,
+    DialogTitle,
+    Button,
+    Grid,
+    DialogContent,
+    CircularProgress,
+    Paper,
+    Tabs,
+    Tab,
+    Checkbox,
+    FormControlLabel,
+    Typography,
+} from '@material-ui/core';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles
+} from '@material-ui/core/styles';
+import { DialogActions } from 'components/dialog-actions/dialog-actions';
+import { SharingURLsContent } from './sharing-urls';
+import {
+    extractUuidObjectType,
+    ResourceObjectType
+} from 'models/resource';
+import { SharingInvitationForm } from './sharing-invitation-form';
+import { SharingManagementForm } from './sharing-management-form';
+import {
+    BasePicker,
+    Calendar,
+    MuiPickersUtilsProvider,
+    TimePickerView
+} from 'material-ui-pickers';
+import DateFnsUtils from "@date-io/date-fns";
+import moment from 'moment';
+import { SharingPublicAccessForm } from './sharing-public-access-form';
+
+export interface SharingDialogDataProps {
+    open: boolean;
+    loading: boolean;
+    saveEnabled: boolean;
+    sharedResourceUuid: string;
+    sharingURLsNr: number;
+    privateAccess: boolean;
+    sharingURLsDisabled: boolean;
+    permissions: any[];
+}
+export interface SharingDialogActionProps {
+    onClose: () => void;
+    onSave: () => void;
+    onCreateSharingToken: (d: Date | undefined) => () => void;
+    refreshPermissions: () => void;
+}
+enum SharingDialogTab {
+    PERMISSIONS = 0,
+    URLS = 1,
+}
+export type SharingDialogComponentProps = SharingDialogDataProps & SharingDialogActionProps;
+
+export default (props: SharingDialogComponentProps) => {
+    const { open, loading, saveEnabled, sharedResourceUuid,
+        sharingURLsNr, privateAccess, sharingURLsDisabled,
+        onClose, onSave, onCreateSharingToken, refreshPermissions } = props;
+    const showTabs = !sharingURLsDisabled && extractUuidObjectType(sharedResourceUuid) === ResourceObjectType.COLLECTION;
+    const [tabNr, setTabNr] = React.useState<number>(SharingDialogTab.PERMISSIONS);
+    const [expDate, setExpDate] = React.useState<Date>();
+    const [withExpiration, setWithExpiration] = React.useState<boolean>(false);
+
+    // Sets up the dialog depending on the resource type
+    if (!showTabs && tabNr !== SharingDialogTab.PERMISSIONS) {
+        setTabNr(SharingDialogTab.PERMISSIONS);
+    }
+
+    React.useEffect(() => {
+        if (!withExpiration) {
+            setExpDate(undefined);
+        } else {
+            setExpDate(moment().add(2, 'hour').minutes(0).seconds(0).toDate());
+        }
+    }, [withExpiration]);
+
+    return <Dialog
+        {...{ open, onClose }}
+        className="sharing-dialog"
+        fullWidth
+        maxWidth='sm'
+        disableBackdropClick={saveEnabled}
+        >
+        <DialogTitle>
+            Sharing settings
+        </DialogTitle>
+        {showTabs &&
+            <Tabs value={tabNr}
+                onChange={(_, tb) => {
+                    if (tb === SharingDialogTab.PERMISSIONS) {
+                        refreshPermissions();
+                    }
+                    setTabNr(tb)
+                }
+                }>
+                <Tab label="With users/groups" />
+                <Tab label={`Sharing URLs ${sharingURLsNr > 0 ? '(' + sharingURLsNr + ')' : ''}`} disabled={saveEnabled} />
+            </Tabs>
+        }
+        <DialogContent>
+            {tabNr === SharingDialogTab.PERMISSIONS &&
+                <Grid container direction='column' spacing={24}>
+                    <Grid item>
+                        <SharingInvitationForm onSave={onSave} />
+                    </Grid>
+                    <Grid item>
+                        <SharingManagementForm onSave={onSave} />
+                    </Grid>
+                    <Grid item>
+                        <SharingPublicAccessForm onSave={onSave} />
+                    </Grid>
+                </Grid>
+            }
+            {tabNr === SharingDialogTab.URLS &&
+                <SharingURLsContent uuid={sharedResourceUuid} />
+            }
+        </DialogContent>
+        <DialogActions>
+            <Grid container spacing={8}>
+                {tabNr === SharingDialogTab.URLS && withExpiration && <>
+                    <Grid item container direction='row' md={12}>
+                        <MuiPickersUtilsProvider utils={DateFnsUtils}>
+                            <BasePicker autoOk value={expDate} onChange={setExpDate}>
+                                {({ date, handleChange }) => (<>
+                                    <Grid item md={6}>
+                                        <Calendar date={date} minDate={new Date()} maxDate={undefined}
+                                            onChange={handleChange} />
+                                    </Grid>
+                                    <Grid item md={6}>
+                                        <TimePickerView type="hours" date={date} ampm={false}
+                                            onMinutesChange={() => { }}
+                                            onSecondsChange={() => { }}
+                                            onHourChange={handleChange}
+                                        />
+                                    </Grid>
+                                </>)}
+                            </BasePicker>
+                        </MuiPickersUtilsProvider>
+                    </Grid>
+                    <Grid item md={12}>
+                        <Typography variant='caption' align='center'>
+                            Maximum expiration date may be limited by the cluster configuration.
+                        </Typography>
+                    </Grid>
+                </>
+                }
+                {tabNr === SharingDialogTab.PERMISSIONS && !sharingURLsDisabled &&
+                    privateAccess && sharingURLsNr > 0 &&
+                    <Grid item md={12}>
+                        <Typography variant='caption' align='center' color='error'>
+                            Although there aren't specific permissions set, this is publicly accessible via Sharing URL(s).
+                        </Typography>
+                    </Grid>
+                }
+                <Grid item xs />
+                {tabNr === SharingDialogTab.URLS && <>
+                    <Grid item><FormControlLabel
+                        control={<Checkbox color="primary" checked={withExpiration}
+                            onChange={(e) => setWithExpiration(e.target.checked)} />}
+                        label="With expiration" />
+                    </Grid>
+                    <Grid item>
+                        <Button variant="contained" color="primary"
+                            disabled={expDate !== undefined && expDate <= new Date()}
+                            onClick={onCreateSharingToken(expDate)}>
+                            Create sharing URL
+                        </Button>
+                    </Grid>
+                </>
+                }
+                <Grid item>
+                    <Button onClick={() => {
+                        onClose();
+                        setWithExpiration(false);
+                        }}
+                        disabled={saveEnabled}
+                        color='primary'
+                        size='small'
+                        style={{ marginLeft: '10px' }}
+                        >
+                            Close
+                    </Button>
+                    <Button onClick={() => {
+                            onSave();
+                        }}
+                        data-cy="add-invited-people"
+                        disabled={!saveEnabled}
+                        color='primary'
+                        variant='contained'
+                        size='small'
+                        style={{ marginLeft: '10px' }}
+                        >
+                            Save
+                    </Button>
+                </Grid>
+            </Grid>
+        </DialogActions>
+        {
+            loading && <LoadingIndicator />
+        }
+    </Dialog>;
+};
+
+const loadingIndicatorStyles: StyleRulesCallback<'root'> = theme => ({
+    root: {
+        position: 'absolute',
+        top: 0,
+        right: 0,
+        bottom: 0,
+        left: 0,
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        backgroundColor: 'rgba(255, 255, 255, 0.8)',
+    },
+});
+
+const LoadingIndicator = withStyles(loadingIndicatorStyles)(
+    (props: WithStyles<'root'>) =>
+        <Paper classes={props.classes}>
+            <CircularProgress />
+        </Paper>
+);
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-dialog.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-dialog.tsx
new file mode 100644 (file)
index 0000000..1c9e4d0
--- /dev/null
@@ -0,0 +1,80 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose, Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import { formValueSelector } from 'redux-form'
+import {
+    connectSharingDialog,
+    saveSharingDialogChanges,
+    connectSharingDialogProgress,
+    SharingDialogData,
+    createSharingToken,
+    initializeManagementForm
+} from 'store/sharing-dialog/sharing-dialog-actions';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import SharingDialogComponent, {
+    SharingDialogDataProps,
+    SharingDialogActionProps
+} from './sharing-dialog-component';
+import {
+    getSharingPublicAccessFormData,
+    hasChanges,
+    SHARING_DIALOG_NAME,
+    SHARING_MANAGEMENT_FORM_NAME,
+    VisibilityLevel
+} from 'store/sharing-dialog/sharing-dialog-types';
+import { WithProgressStateProps } from 'store/progress-indicator/with-progress';
+import { getDialog } from 'store/dialog/dialog-reducer';
+import { filterResources } from 'store/resources/resources';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import { ResourceKind } from 'models/resource';
+
+type Props = WithDialogProps<string> & WithProgressStateProps;
+
+const sharingManagementFormSelector = formValueSelector(SHARING_MANAGEMENT_FORM_NAME);
+
+const mapStateToProps = (state: RootState, { working, ...props }: Props): SharingDialogDataProps => {
+    const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
+    const sharedResourceUuid = dialog?.data.resourceUuid || '';
+    const sharingURLsDisabled = state.auth.config.clusterConfig.Workbench.DisableSharingURLsUI;
+    return ({
+        ...props,
+        permissions: sharingManagementFormSelector(state, 'permissions'),
+        saveEnabled: hasChanges(state),
+        loading: working,
+        sharedResourceUuid,
+        sharingURLsDisabled,
+        sharingURLsNr: !sharingURLsDisabled
+            ? (filterResources((resource: ApiClientAuthorization) =>
+                resource.kind === ResourceKind.API_CLIENT_AUTHORIZATION &&
+                resource.scopes.includes(`GET /arvados/v1/collections/${sharedResourceUuid}`) &&
+                resource.scopes.includes(`GET /arvados/v1/collections/${sharedResourceUuid}/`) &&
+                resource.scopes.includes('GET /arvados/v1/keep_services/accessible')
+            )(state.resources) as ApiClientAuthorization[]).length
+            : 0,
+        privateAccess: getSharingPublicAccessFormData(state)?.visibility === VisibilityLevel.PRIVATE,
+    })
+};
+
+const mapDispatchToProps = (dispatch: Dispatch, { ...props }: Props): SharingDialogActionProps => ({
+    ...props,
+    onClose: props.closeDialog,
+    onSave: () => {
+        setTimeout(() => dispatch<any>(saveSharingDialogChanges), 0);
+    },
+    onCreateSharingToken: (d: Date) => () => {
+        dispatch<any>(createSharingToken(d));
+    },
+    refreshPermissions: () => {
+        dispatch<any>(initializeManagementForm);
+    }
+});
+
+export const SharingDialog = compose(
+    connectSharingDialog,
+    connectSharingDialogProgress,
+    connect(mapStateToProps, mapDispatchToProps)
+)(SharingDialogComponent);
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-invitation-form-component.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-invitation-form-component.tsx
new file mode 100644 (file)
index 0000000..19fcf58
--- /dev/null
@@ -0,0 +1,60 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Field, WrappedFieldProps, FieldArray, WrappedFieldArrayProps } from 'redux-form';
+import { Grid, FormControl, InputLabel, StyleRulesCallback, Divider } from '@material-ui/core';
+import { PermissionSelect, parsePermissionLevel, formatPermissionLevel } from './permission-select';
+import { ParticipantSelect, Participant } from './participant-select';
+import { WithStyles } from '@material-ui/core/styles';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { ArvadosTheme } from 'common/custom-theme';
+
+type SharingStyles = 'root';
+
+const styles: StyleRulesCallback<SharingStyles> = (theme: ArvadosTheme) => ({
+    root: {
+        padding: `${theme.spacing.unit}px 0`,
+    },
+});
+
+const SharingInvitationFormComponent = (props: { onSave: () => void }) => <StyledSharingInvitationFormComponent onSave={props.onSave} />
+
+export default SharingInvitationFormComponent;
+
+const StyledSharingInvitationFormComponent = withStyles(styles)(
+    ({ classes }: { onSave: () => void } & WithStyles<SharingStyles>) =>
+        <Grid container spacing={8} wrap='nowrap' className={classes.root} >
+            <Grid data-cy="invite-people-field" item xs={8}>
+                <InvitedPeopleField />
+            </Grid>
+            <Grid data-cy="permission-select-field" item xs={4} container wrap='nowrap'>
+                <PermissionSelectField />
+            </Grid>
+        </Grid >);
+
+const InvitedPeopleField = () =>
+    <FieldArray
+        name='invitedPeople'
+        component={InvitedPeopleFieldComponent as any} />;
+
+
+const InvitedPeopleFieldComponent = ({ fields }: WrappedFieldArrayProps<Participant>) =>
+    <ParticipantSelect
+        items={fields.getAll() || []}
+        onSelect={fields.push}
+        onDelete={fields.remove} />;
+
+const PermissionSelectField = () =>
+    <Field
+        name='permissions'
+        component={PermissionSelectComponent}
+        format={formatPermissionLevel}
+        parse={parsePermissionLevel} />;
+
+const PermissionSelectComponent = ({ input }: WrappedFieldProps) =>
+    <FormControl fullWidth>
+        <InputLabel>Authorization</InputLabel>
+        <PermissionSelect {...input} />
+    </FormControl>;
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-invitation-form.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-invitation-form.tsx
new file mode 100644 (file)
index 0000000..46f94dd
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm } from 'redux-form';
+import SharingInvitationFormComponent from './sharing-invitation-form-component';
+import { SHARING_INVITATION_FORM_NAME } from 'store/sharing-dialog/sharing-dialog-types';
+import { PermissionLevel } from 'models/permission';
+
+interface InvitationFormData {
+    permissions: PermissionLevel;
+    invitedPeople: string[];
+}
+
+interface SaveProps {
+    onSave: () => void;
+}
+
+export const SharingInvitationForm =
+    reduxForm<InvitationFormData, SaveProps>({
+        form: SHARING_INVITATION_FORM_NAME,
+        initialValues: {
+            permissions: PermissionLevel.CAN_READ,
+            invitedPeople: [],
+        }
+    })(SharingInvitationFormComponent);
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-management-form-component.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-management-form-component.tsx
new file mode 100644 (file)
index 0000000..fa3cc46
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Grid, StyleRulesCallback, Divider, IconButton, Typography, Tooltip } from '@material-ui/core';
+import {
+    Field,
+    WrappedFieldProps,
+    WrappedFieldArrayProps,
+    FieldArray,
+    FieldArrayFieldsProps,
+    InjectedFormProps
+} from 'redux-form';
+import { PermissionSelect, formatPermissionLevel, parsePermissionLevel } from './permission-select';
+import { WithStyles } from '@material-ui/core/styles';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { CloseIcon } from 'components/icon/icon';
+import { ArvadosTheme } from 'common/custom-theme';
+
+export interface SaveProps {
+    onSave: () => void;
+}
+
+const headerStyles: StyleRulesCallback<'heading'> = (theme: ArvadosTheme) => ({
+    heading: {
+        fontSize: '1.25rem',
+    }
+});
+
+export const SharingManagementFormComponent = withStyles(headerStyles)(
+    ({ classes, onSave }: WithStyles<'heading'> & SaveProps & InjectedFormProps<{}, SaveProps>) =>
+        <>
+            <Typography className={classes.heading}>People with access</Typography>
+            <FieldArray<{ onSave: () => void }> name='permissions' component={SharingManagementFieldArray as any} props={{ onSave }} />
+        </>);
+
+export default SharingManagementFormComponent;
+
+const SharingManagementFieldArray = ({ fields, onSave }: { onSave: () => void } & WrappedFieldArrayProps<{ email: string }>) =>
+    <div>{fields.map((field, index, fields) =>
+        <PermissionManagementRow key={field} {...{ field, index, fields }} onSave={onSave} />)}
+    </div>;
+
+const permissionManagementRowStyles: StyleRulesCallback<'root'> = theme => ({
+    root: {
+        padding: `${theme.spacing.unit}px 0`,
+    }
+});
+
+const PermissionManagementRow = withStyles(permissionManagementRowStyles)(
+    ({ field, index, fields, classes, onSave }: { field: string, index: number, fields: FieldArrayFieldsProps<{ email: string }>, onSave: () => void; } & WithStyles<'root'>) =>
+        <>
+            <Grid container alignItems='center' spacing={8} wrap='nowrap' className={classes.root}>
+                <Grid item xs={7}>
+                    <Typography noWrap variant='subtitle1'>{fields.get(index).email}</Typography>
+                </Grid>
+                <Grid item xs={1} container wrap='nowrap'>
+                    <Tooltip title='Remove access'>
+                        <IconButton onClick={() => { fields.remove(index); onSave(); }}>
+                            <CloseIcon />
+                        </IconButton>
+                    </Tooltip>
+                </Grid>
+                <Grid item xs={4} container wrap='nowrap'>
+                    <Field
+                        name={`${field}.permissions` as string}
+                        component={PermissionSelectComponent}
+                        format={formatPermissionLevel}
+                        parse={parsePermissionLevel}
+                        onChange={onSave}
+                    />
+                    
+                </Grid>
+            </Grid>
+            <Divider />
+        </>
+);
+
+const PermissionSelectComponent = ({ input }: WrappedFieldProps) =>
+    <PermissionSelect fullWidth disableUnderline {...input} />;
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-management-form.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-management-form.tsx
new file mode 100644 (file)
index 0000000..662199b
--- /dev/null
@@ -0,0 +1,11 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm } from 'redux-form';
+import { SharingManagementFormComponent, SaveProps } from './sharing-management-form-component';
+import { SHARING_MANAGEMENT_FORM_NAME } from 'store/sharing-dialog/sharing-dialog-types';
+
+export const SharingManagementForm = reduxForm<{}, SaveProps>(
+    { form: SHARING_MANAGEMENT_FORM_NAME }
+)(SharingManagementFormComponent);
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-public-access-form-component.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-public-access-form-component.tsx
new file mode 100644 (file)
index 0000000..161cff5
--- /dev/null
@@ -0,0 +1,66 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Grid, StyleRulesCallback, Typography } from '@material-ui/core';
+import { Field, WrappedFieldProps } from 'redux-form';
+import { WithStyles } from '@material-ui/core/styles';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { VisibilityLevelSelect } from './visibility-level-select';
+import { VisibilityLevel } from 'store/sharing-dialog/sharing-dialog-types';
+
+const sharingPublicAccessStyles: StyleRulesCallback<'root'> = theme => ({
+    root: {
+        padding: `${theme.spacing.unit * 2}px 0`,
+    },
+    heading: {
+        fontSize: '1.25rem',
+    }
+});
+
+interface AccessProps {
+    visibility: VisibilityLevel;
+    includePublic: boolean;
+    onSave: () => void;
+}
+
+const SharingPublicAccessForm = withStyles(sharingPublicAccessStyles)(
+    ({ classes, visibility, includePublic, onSave }: WithStyles<'root' | 'heading'> & AccessProps) =>
+        <>
+            <Typography className={classes.heading}>General access</Typography>
+            <Grid container alignItems='center' className={classes.root}>
+                <Grid item xs={8}>
+                    <Typography variant='subtitle1'>
+                        {renderVisibilityInfo(visibility)}
+                    </Typography>
+                </Grid>
+                <Grid item xs={4} wrap='nowrap'>
+                    <Field<{ includePublic: boolean }> name='visibility' component={VisibilityLevelSelectComponent} includePublic={includePublic} onChange={onSave} />
+                </Grid>
+            </Grid>
+        </>
+);
+
+const renderVisibilityInfo = (visibility: VisibilityLevel) => {
+    switch (visibility) {
+        case VisibilityLevel.PUBLIC:
+            return 'Shared with anyone on the Internet';
+        case VisibilityLevel.ALL_USERS:
+            return 'Shared with all users on this cluster';
+        case VisibilityLevel.SHARED:
+            return 'Shared with specific people';
+        case VisibilityLevel.PRIVATE:
+            return 'Not shared';
+        default:
+            return '';
+    }
+};
+
+const SharingPublicAccessFormComponent = ({ visibility, includePublic, onSave }: AccessProps) =>
+    <SharingPublicAccessForm {...{ visibility, includePublic, onSave }} />;
+
+export default SharingPublicAccessFormComponent;
+
+const VisibilityLevelSelectComponent = ({ input, includePublic }: { includePublic: boolean } & WrappedFieldProps) =>
+    <VisibilityLevelSelect fullWidth disableUnderline includePublic={includePublic} {...input} />;
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-public-access-form.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-public-access-form.tsx
new file mode 100644 (file)
index 0000000..eb337c3
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm } from 'redux-form';
+import { compose } from 'redux';
+import { connect } from 'react-redux';
+import SharingPublicAccessFormComponent from './sharing-public-access-form-component';
+import { SHARING_PUBLIC_ACCESS_FORM_NAME, VisibilityLevel } from 'store/sharing-dialog/sharing-dialog-types';
+import { RootState } from 'store/store';
+import { getSharingPublicAccessFormData } from '../../store/sharing-dialog/sharing-dialog-types';
+
+interface SaveProps {
+    onSave: () => void;
+}
+
+export const SharingPublicAccessForm = compose(
+    reduxForm<{}, SaveProps>(
+        { form: SHARING_PUBLIC_ACCESS_FORM_NAME }
+    ),
+    connect(
+        (state: RootState) => {
+            const { visibility } = getSharingPublicAccessFormData(state) || { visibility: VisibilityLevel.PRIVATE };
+            const includePublic = state.auth.config.clusterConfig.Users.AnonymousUserToken.length > 0;
+            return { visibility, includePublic };
+        }
+    )
+)(SharingPublicAccessFormComponent);
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-urls-component.test.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-urls-component.test.tsx
new file mode 100644 (file)
index 0000000..cf3884c
--- /dev/null
@@ -0,0 +1,72 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { mount, configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+
+import {
+    SharingURLsComponent,
+    SharingURLsComponentProps
+} from './sharing-urls-component';
+
+configure({ adapter: new Adapter() });
+
+describe("<SharingURLsComponent />", () => {
+    let props: SharingURLsComponentProps;
+    let wrapper;
+
+    beforeEach(() => {
+        props = {
+            collectionUuid: 'collection-uuid',
+            sharingURLsPrefix: 'sharing-urls-prefix',
+            sharingTokens: [
+                {
+                    uuid: 'token-uuid1',
+                    apiToken: 'aaaaaaaaaa',
+                    expiresAt: '2009-01-03T18:15:00Z',
+                },
+                {
+                    uuid: 'token-uuid2',
+                    apiToken: 'bbbbbbbbbb',
+                    expiresAt: '2009-01-03T18:15:01Z',
+                },
+            ],
+            onCopy: jest.fn(),
+            onDeleteSharingToken: jest.fn(),
+        };
+        wrapper = mount(<SharingURLsComponent {...props} />);
+    });
+
+    it("renders a list of sharing URLs", () => {
+        expect(wrapper.find('a').length).toBe(2);
+        // Check 1st URL
+        expect(wrapper.find('a').at(0).text()).toContain(`Token aaaaaaaa... expiring at: ${new Date(props.sharingTokens[0].expiresAt).toLocaleString()}`);
+        expect(wrapper.find('a').at(0).props().href).toBe(`${props.sharingURLsPrefix}/c=${props.collectionUuid}/t=${props.sharingTokens[0].apiToken}/_/`);
+        // Check 2nd URL
+        expect(wrapper.find('a').at(1).text()).toContain(`Token bbbbbbbb... expiring at: ${new Date(props.sharingTokens[1].expiresAt).toLocaleString()}`);
+        expect(wrapper.find('a').at(1).props().href).toBe(`${props.sharingURLsPrefix}/c=${props.collectionUuid}/t=${props.sharingTokens[1].apiToken}/_/`);
+    });
+
+    it("renders a list URLs with collection UUIDs as subdomains", () => {
+        props.sharingURLsPrefix = '*.sharing-urls-prefix';
+        const sharingPrefix = '.sharing-urls-prefix';
+        wrapper = mount(<SharingURLsComponent {...props} />);
+        expect(wrapper.find('a').at(0).props().href).toBe(`${props.collectionUuid}${sharingPrefix}/t=${props.sharingTokens[0].apiToken}/_/`);
+        expect(wrapper.find('a').at(1).props().href).toBe(`${props.collectionUuid}${sharingPrefix}/t=${props.sharingTokens[1].apiToken}/_/`);
+    });
+
+    it("renders a list of URLs with no expiration", () => {
+        props.sharingTokens[0].expiresAt = null;
+        props.sharingTokens[1].expiresAt = null;
+        wrapper = mount(<SharingURLsComponent {...props} />);
+        expect(wrapper.find('a').at(0).text()).toContain(`Token aaaaaaaa... with no expiration date`);
+        expect(wrapper.find('a').at(1).text()).toContain(`Token bbbbbbbb... with no expiration date`);
+    });
+
+    it("calls delete token handler when delete button is clicked", () => {
+        wrapper.find('button').at(0).simulate('click');
+        expect(props.onDeleteSharingToken).toHaveBeenCalledWith(props.sharingTokens[0].uuid);
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-urls-component.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-urls-component.tsx
new file mode 100644 (file)
index 0000000..7bb05fa
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    Grid,
+    IconButton,
+    Link,
+    StyleRulesCallback,
+    Tooltip,
+    Typography,
+    WithStyles,
+    withStyles
+} from '@material-ui/core';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import { CopyIcon, CloseIcon } from 'components/icon/icon';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { ArvadosTheme } from 'common/custom-theme';
+import moment from 'moment';
+
+type CssRules = 'sharingUrlText'
+    | 'sharingUrlButton'
+    | 'sharingUrlList'
+    | 'sharingUrlRow';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    sharingUrlText: {
+        fontSize: '1rem',
+    },
+    sharingUrlButton: {
+        color: theme.palette.grey["500"],
+        cursor: 'pointer',
+        '& svg': {
+            fontSize: '1rem'
+        },
+        verticalAlign: 'middle',
+    },
+    sharingUrlList: {
+        marginTop: '1rem',
+    },
+    sharingUrlRow: {
+        borderBottom: `1px solid ${theme.palette.grey["300"]}`,
+    },
+});
+
+export interface SharingURLsComponentDataProps {
+    collectionUuid: string;
+    sharingTokens: ApiClientAuthorization[];
+    sharingURLsPrefix: string;
+}
+
+export interface SharingURLsComponentActionProps {
+    onDeleteSharingToken: (uuid: string) => void;
+    onCopy: (message: string) => void;
+}
+
+export type SharingURLsComponentProps = SharingURLsComponentDataProps & SharingURLsComponentActionProps;
+
+export const SharingURLsComponent = withStyles(styles)((props: SharingURLsComponentProps & WithStyles<CssRules>) => <Grid container direction='column' spacing={24} className={props.classes.sharingUrlList}>
+    {props.sharingTokens.length > 0
+        ? props.sharingTokens
+            .sort((a, b) => (new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()))
+            .map(token => {
+                const url = props.sharingURLsPrefix.includes('*')
+                    ? `${props.sharingURLsPrefix.replace('*', props.collectionUuid)}/t=${token.apiToken}/_/`
+                    : `${props.sharingURLsPrefix}/c=${props.collectionUuid}/t=${token.apiToken}/_/`;
+                const expDate = new Date(token.expiresAt);
+                const urlLabel = !!token.expiresAt
+                    ? `Token ${token.apiToken.slice(0, 8)}... expiring at: ${expDate.toLocaleString()} (${moment(expDate).fromNow()})`
+                    : `Token ${token.apiToken.slice(0, 8)}... with no expiration date`;
+
+                return <Grid container alignItems='center' key={token.uuid} className={props.classes.sharingUrlRow}>
+                    <Grid item>
+                        <Link className={props.classes.sharingUrlText} href={url} target='_blank' rel="noopener">
+                            {urlLabel}
+                        </Link>
+                    </Grid>
+                    <Grid item xs />
+                    <Grid item>
+                        <span className={props.classes.sharingUrlButton}><Tooltip title='Copy link to clipboard'>
+                            <CopyToClipboard text={url} onCopy={() => props.onCopy('Sharing URL copied')}>
+                                <CopyIcon />
+                            </CopyToClipboard>
+                        </Tooltip></span>
+                        <span data-cy='remove-url-btn' className={props.classes.sharingUrlButton}><Tooltip title='Remove'>
+                            <IconButton onClick={() => props.onDeleteSharingToken(token.uuid)}>
+                                <CloseIcon />
+                            </IconButton>
+                        </Tooltip></span>
+                    </Grid>
+                </Grid>
+            })
+        : <Grid item><Typography>No sharing URLs</Typography></Grid>}
+</Grid>);
diff --git a/services/workbench2/src/views-components/sharing-dialog/sharing-urls.tsx b/services/workbench2/src/views-components/sharing-dialog/sharing-urls.tsx
new file mode 100644 (file)
index 0000000..6fbf799
--- /dev/null
@@ -0,0 +1,53 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import { filterResources } from 'store/resources/resources';
+import { ResourceKind } from 'models/resource';
+import {
+    SharingURLsComponent,
+    SharingURLsComponentActionProps,
+    SharingURLsComponentDataProps
+} from './sharing-urls-component';
+import {
+    snackbarActions,
+    SnackbarKind
+} from 'store/snackbar/snackbar-actions';
+import { deleteSharingToken } from 'store/sharing-dialog/sharing-dialog-actions';
+
+const mapStateToProps =
+    (state: RootState, ownProps: { uuid: string }): SharingURLsComponentDataProps => {
+        const sharingTokens = filterResources(
+            (resource: ApiClientAuthorization) =>
+                resource.kind === ResourceKind.API_CLIENT_AUTHORIZATION  &&
+                resource.scopes.includes(`GET /arvados/v1/collections/${ownProps.uuid}`) &&
+                resource.scopes.includes(`GET /arvados/v1/collections/${ownProps.uuid}/`) &&
+                resource.scopes.includes('GET /arvados/v1/keep_services/accessible')
+            )(state.resources) as ApiClientAuthorization[];
+        const sharingURLsPrefix = state.auth.config.keepWebInlineServiceUrl;
+        return {
+            collectionUuid: ownProps.uuid,
+            sharingTokens,
+            sharingURLsPrefix,
+        }
+    }
+
+const mapDispatchToProps = (dispatch: Dispatch): SharingURLsComponentActionProps => ({
+    onDeleteSharingToken(uuid: string) {
+        dispatch<any>(deleteSharingToken(uuid));
+    },
+    onCopy(message: string) {
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message,
+            hideDuration: 2000,
+            kind: SnackbarKind.SUCCESS
+        }));
+    },
+})
+
+export const SharingURLsContent = connect(mapStateToProps, mapDispatchToProps)(SharingURLsComponent)
+
diff --git a/services/workbench2/src/views-components/sharing-dialog/visibility-level-select.tsx b/services/workbench2/src/views-components/sharing-dialog/visibility-level-select.tsx
new file mode 100644 (file)
index 0000000..b90bc79
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { MenuItem, Select, withStyles, StyleRulesCallback } from '@material-ui/core';
+import Lock from '@material-ui/icons/Lock';
+import People from '@material-ui/icons/People';
+import Public from '@material-ui/icons/Public';
+import { WithStyles } from '@material-ui/core/styles';
+import { SelectProps } from '@material-ui/core/Select';
+import { SelectItem } from './select-item';
+import { VisibilityLevel } from 'store/sharing-dialog/sharing-dialog-types';
+
+
+type VisibilityLevelSelectClasses = 'root';
+
+const VisibilityLevelSelectStyles: StyleRulesCallback<VisibilityLevelSelectClasses> = theme => ({
+    root: {
+    }
+});
+export const VisibilityLevelSelect = withStyles(VisibilityLevelSelectStyles)(
+    ({ classes, includePublic, ...props }: { includePublic: boolean } & SelectProps & WithStyles<VisibilityLevelSelectClasses>) =>
+        <Select
+            {...props}
+            renderValue={renderPermissionItem}
+            inputProps={{ classes }}>
+            {includePublic && <MenuItem value={VisibilityLevel.PUBLIC}>
+                {renderPermissionItem(VisibilityLevel.PUBLIC)}
+            </MenuItem>}
+            <MenuItem value={VisibilityLevel.ALL_USERS}>
+                {renderPermissionItem(VisibilityLevel.ALL_USERS)}
+            </MenuItem>
+            <MenuItem value={VisibilityLevel.SHARED}>
+                {renderPermissionItem(VisibilityLevel.SHARED)}
+            </MenuItem>
+            <MenuItem value={VisibilityLevel.PRIVATE}>
+                {renderPermissionItem(VisibilityLevel.PRIVATE)}
+            </MenuItem>
+        </Select>);
+
+const renderPermissionItem = (value: string) =>
+    <SelectItem {...{ value, icon: getIcon(value) }} />;
+
+const getIcon = (value: string) => {
+    switch (value) {
+        case VisibilityLevel.PUBLIC:
+            return Public;
+        case VisibilityLevel.ALL_USERS:
+            return Public;
+        case VisibilityLevel.SHARED:
+            return People;
+        case VisibilityLevel.PRIVATE:
+            return Lock;
+        default:
+            return Lock;
+    }
+};
diff --git a/services/workbench2/src/views-components/side-panel-button/side-panel-button.test.tsx b/services/workbench2/src/views-components/side-panel-button/side-panel-button.test.tsx
new file mode 100644 (file)
index 0000000..9704be7
--- /dev/null
@@ -0,0 +1,75 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { isProjectTrashed } from './side-panel-button';
+
+describe('<SidePanelButton />', () => {
+    describe('isProjectTrashed', () => {
+        it('should return false if project is undefined', () => {
+            // given
+            const proj = undefined;
+            const resources = {};
+
+            // when
+            const result = isProjectTrashed(proj, resources);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+
+        it('should return false if parent project is undefined', () => {
+            // given
+            const proj = {};
+            const resources = {};
+
+            // when
+            const result = isProjectTrashed(proj, resources);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+
+        it('should return false for owner', () => {
+            // given
+            const proj = {
+                ownerUuid: 'ce8i5-tpzed-000000000000000',
+            };
+            const resources = {};
+
+            // when
+            const result = isProjectTrashed(proj, resources);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+
+        it('should return true for trashed', () => {
+            // given
+            const proj = {
+                isTrashed: true,
+            };
+            const resources = {};
+
+            // when
+            const result = isProjectTrashed(proj, resources);
+
+            // then
+            expect(result).toBeTruthy();
+        });
+
+        it('should return false for undefined parent projects', () => {
+            // given
+            const proj = {
+                ownerUuid: 'ce8i5-j7d0g-000000000000000',
+            };
+            const resources = {};
+
+            // when
+            const result = isProjectTrashed(proj, resources);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/side-panel-button/side-panel-button.tsx b/services/workbench2/src/views-components/side-panel-button/side-panel-button.tsx
new file mode 100644 (file)
index 0000000..47b2316
--- /dev/null
@@ -0,0 +1,175 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import { RootState } from 'store/store';
+import { ArvadosTheme } from 'common/custom-theme';
+import { PopoverOrigin } from '@material-ui/core/Popover';
+import { StyleRulesCallback, WithStyles, withStyles, Toolbar, Grid, Button, MenuItem, Menu } from '@material-ui/core';
+import { AddIcon, CollectionIcon, ProcessIcon, ProjectIcon } from 'components/icon/icon';
+import { openProjectCreateDialog } from 'store/projects/project-create-actions';
+import { openCollectionCreateDialog } from 'store/collections/collection-create-actions';
+import { navigateToRunProcess } from 'store/navigation/navigation-action';
+import { runProcessPanelActions } from 'store/run-process-panel/run-process-panel-actions';
+import { getUserUuid } from 'common/getuser';
+import { matchProjectRoute } from 'routes/routes';
+import { GroupClass, GroupResource } from 'models/group';
+import { ResourcesState, getResource } from 'store/resources/resources';
+import { extractUuidKind, ResourceKind } from 'models/resource';
+import { pluginConfig } from 'plugins';
+import { ElementListReducer } from 'common/plugintypes';
+import { Location } from 'history';
+import { ProjectResource } from 'models/project';
+
+type CssRules = 'button' | 'menuItem' | 'icon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    button: {
+        boxShadow: 'none',
+        padding: '2px 10px 2px 5px',
+        fontSize: '0.75rem'
+    },
+    menuItem: {
+        fontSize: '0.875rem',
+        color: theme.palette.grey["700"]
+    },
+    icon: {
+        marginRight: theme.spacing.unit
+    }
+});
+
+interface SidePanelDataProps {
+    location: Location;
+    currentItemId: string;
+    resources: ResourcesState;
+    currentUserUUID: string | undefined;
+}
+
+interface SidePanelState {
+    anchorEl: any;
+}
+
+type SidePanelProps = SidePanelDataProps & DispatchProp & WithStyles<CssRules>;
+
+const transformOrigin: PopoverOrigin = {
+    vertical: -50,
+    horizontal: 0
+};
+
+export const isProjectTrashed = (proj: GroupResource | undefined, resources: ResourcesState): boolean => {
+    if (proj === undefined) { return false; }
+    if (proj.isTrashed) { return true; }
+    if (extractUuidKind(proj.ownerUuid) === ResourceKind.USER) { return false; }
+    const parentProj = getResource<GroupResource>(proj.ownerUuid)(resources);
+    return isProjectTrashed(parentProj, resources);
+};
+
+export const SidePanelButton = withStyles(styles)(
+    connect((state: RootState) => ({
+        currentItemId: state.router.location
+            ? state.router.location.pathname.split('/').slice(-1)[0]
+            : null,
+        location: state.router.location,
+        resources: state.resources,
+        currentUserUUID: getUserUuid(state),
+    }))(
+        class extends React.Component<SidePanelProps> {
+
+            state: SidePanelState = {
+                anchorEl: undefined
+            };
+
+            render() {
+                const { classes, location, resources, currentUserUUID, currentItemId } = this.props;
+                const { anchorEl } = this.state;
+                let enabled = false;
+                if (currentItemId === currentUserUUID) {
+                    enabled = true;
+                } else if (matchProjectRoute(location ? location.pathname : '')) {
+                    const currentProject = getResource<ProjectResource>(currentItemId)(resources);
+                    if (currentProject && currentProject.canWrite &&
+                        !currentProject.frozenByUuid &&
+                        !isProjectTrashed(currentProject, resources) &&
+                        currentProject.groupClass !== GroupClass.FILTER) {
+                        enabled = true;
+                    }
+                }
+
+                for (const enableFn of pluginConfig.enableNewButtonMatchers) {
+                    if (enableFn(location, currentItemId, currentUserUUID, resources)) {
+                        enabled = true;
+                    }
+                }
+
+                let menuItems = <>
+                    <MenuItem data-cy='side-panel-new-collection' className={classes.menuItem} onClick={this.handleNewCollectionClick}>
+                        <CollectionIcon className={classes.icon} /> New collection
+                    </MenuItem>
+                    <MenuItem data-cy='side-panel-run-process' className={classes.menuItem} onClick={this.handleRunProcessClick}>
+                        <ProcessIcon className={classes.icon} /> Run a workflow
+                    </MenuItem>
+                    <MenuItem data-cy='side-panel-new-project' className={classes.menuItem} onClick={this.handleNewProjectClick}>
+                        <ProjectIcon className={classes.icon} /> New project
+                    </MenuItem>
+                </>;
+
+                const reduceItemsFn: (a: React.ReactElement[], b: ElementListReducer) => React.ReactElement[] =
+                    (a, b) => b(a, classes.menuItem);
+
+                menuItems = React.createElement(React.Fragment, null,
+                    pluginConfig.newButtonMenuList.reduce(reduceItemsFn, React.Children.toArray(menuItems.props.children)));
+
+                return <Toolbar style={{paddingRight: 0}}>
+                    <Grid container>
+                        <Grid container item xs alignItems="center" justify="flex-start">
+                            <Button data-cy="side-panel-button" variant="contained" disabled={!enabled}
+                                color="primary" size="small" className={classes.button}
+                                aria-owns={anchorEl ? 'aside-menu-list' : undefined}
+                                aria-haspopup="true"
+                                onClick={this.handleOpen}>
+                                <AddIcon />
+                                New
+                            </Button>
+                            <Menu
+                                id='aside-menu-list'
+                                anchorEl={anchorEl}
+                                open={Boolean(anchorEl)}
+                                onClose={this.handleClose}
+                                onClick={this.handleClose}
+                                transformOrigin={transformOrigin}>
+                                {menuItems}
+                            </Menu>
+                        </Grid>
+                    </Grid>
+                </Toolbar>;
+            }
+
+            handleNewProjectClick = () => {
+                this.props.dispatch<any>(openProjectCreateDialog(this.props.currentItemId));
+            }
+
+            handleRunProcessClick = () => {
+                const location = this.props.location;
+                this.props.dispatch(runProcessPanelActions.RESET_RUN_PROCESS_PANEL());
+                this.props.dispatch(runProcessPanelActions.SET_PROCESS_PATHNAME(location.pathname));
+                this.props.dispatch(runProcessPanelActions.SET_PROCESS_OWNER_UUID(this.props.currentItemId));
+
+                this.props.dispatch<any>(navigateToRunProcess);
+            }
+
+            handleNewCollectionClick = () => {
+                this.props.dispatch<any>(openCollectionCreateDialog(this.props.currentItemId));
+            }
+
+            handleClose = () => {
+                this.setState({ anchorEl: undefined });
+            }
+
+            handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
+                this.setState({ anchorEl: event.currentTarget });
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/views-components/side-panel-toggle/side-panel-toggle.tsx b/services/workbench2/src/views-components/side-panel-toggle/side-panel-toggle.tsx
new file mode 100644 (file)
index 0000000..a7ec9d7
--- /dev/null
@@ -0,0 +1,60 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Tooltip, IconButton } from '@material-ui/core';
+import { connect } from 'react-redux';
+import { toggleSidePanel } from "store/side-panel/side-panel-action";
+import { RootState } from 'store/store';
+
+type collapseButtonProps = {
+    isCollapsed: boolean;
+    toggleSidePanel: (collapsedState: boolean) => void
+}
+
+export const COLLAPSE_ICON_SIZE = 35
+
+const SidePanelToggle = (props: collapseButtonProps) => {
+    const collapseButtonIconStyles = {
+        root: {
+            width: `${COLLAPSE_ICON_SIZE}px`,
+            height: `${COLLAPSE_ICON_SIZE}px`,
+            marginTop: '0.4rem',
+            marginLeft: '0.7rem',
+            paddingTop: '1rem',
+            paddingRight: '1rem'
+        },
+        icon: {
+            opacity: '0.5',
+            marginBottom: '0.54rem'
+        },
+    }
+
+    return <Tooltip disableFocusListener title="Toggle Side Panel">
+        <IconButton data-cy="side-panel-toggle" style={collapseButtonIconStyles.root} onClick={() => { props.toggleSidePanel(props.isCollapsed) }}>
+            <div>
+                {props.isCollapsed ?
+                    <img style={{...collapseButtonIconStyles.icon, marginLeft:'0.25rem'}} src='/mui-start-icon.svg' alt='an arrow pointing right'/>
+                    :
+                    <img style={{ ...collapseButtonIconStyles.icon, transform: "rotate(180deg)"}} src='/mui-start-icon.svg' alt='an arrow pointing right'/>}
+            </div>
+        </IconButton>
+    </Tooltip>
+};
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        isCollapsed: state.sidePanel.collapsedState
+    }
+}
+
+const mapDispatchToProps = (dispatch) => {
+    return {
+        toggleSidePanel: (collapsedState) => {
+            return dispatch(toggleSidePanel(collapsedState))
+        }
+    }
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(SidePanelToggle)
diff --git a/services/workbench2/src/views-components/side-panel-tree/side-panel-tree.tsx b/services/workbench2/src/views-components/side-panel-tree/side-panel-tree.tsx
new file mode 100644 (file)
index 0000000..f338687
--- /dev/null
@@ -0,0 +1,93 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
+import { TreeItem } from "components/tree/tree";
+import { ProjectResource } from "models/project";
+import { ListItemTextIcon } from "components/list-item-text-icon/list-item-text-icon";
+import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon, TerminalIcon, ResourceIcon } from 'components/icon/icon';
+import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
+import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions';
+import { noop } from 'lodash';
+import { ResourceKind } from "models/resource";
+import { IllegalNamingWarning } from "components/warning/warning";
+import { GroupClass } from "models/group";
+
+export interface SidePanelTreeProps {
+    onItemActivation: (id: string) => void;
+    sidePanelProgress?: boolean;
+    isCollapsed?: boolean
+    setCurrentSideWidth: (width: number) => void
+}
+
+type SidePanelTreeActionProps = Pick<TreePickerProps<ProjectResource | string>, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>;
+
+const mapDispatchToProps = (dispatch: Dispatch, props: SidePanelTreeProps): SidePanelTreeActionProps => ({
+    onContextMenu: (event, { id }) => {
+        dispatch<any>(openSidePanelContextMenu(event, id));
+    },
+    toggleItemActive: (_, { id }) => {
+        dispatch<any>(activateSidePanelTreeItem(id));
+        props.onItemActivation(id);
+    },
+    toggleItemOpen: (_, { id }) => {
+        dispatch<any>(toggleSidePanelTreeItemCollapse(id));
+    },
+    toggleItemSelection: noop,
+});
+
+export const SidePanelTree = connect(undefined, mapDispatchToProps)(
+    (props: SidePanelTreeActionProps) =>
+        <div data-cy="side-panel-tree">
+            <TreePicker {...props} render={renderSidePanelItem} pickerId={SIDE_PANEL_TREE} />
+        </div>);
+
+const renderSidePanelItem = (item: TreeItem<ProjectResource>) => {
+    const name = typeof item.data === 'string' ? item.data : item.data.name;
+    const warn = typeof item.data !== 'string' && item.data.kind === ResourceKind.PROJECT
+        ? <IllegalNamingWarning name={name} />
+        : undefined;
+    return <ListItemTextIcon
+        icon={getProjectPickerIcon(item)}
+        name={name}
+        nameDecorator={warn}
+        isActive={item.active}
+        hasMargin={true}
+    />;
+};
+
+const getProjectPickerIcon = (item: TreeItem<ProjectResource | string>) =>
+    typeof item.data === 'string'
+        ? getSidePanelIcon(item.data)
+        : (item.data && item.data.groupClass === GroupClass.FILTER)
+            ? FilterGroupIcon
+            : ProjectsIcon;
+
+export const getSidePanelIcon = (category: string) => {
+    switch (category) {
+        case SidePanelTreeCategory.FAVORITES:
+            return FavoriteIcon;
+        case SidePanelTreeCategory.PROJECTS:
+            return ProjectsIcon;
+        case SidePanelTreeCategory.SHARED_WITH_ME:
+            return ShareMeIcon;
+        case SidePanelTreeCategory.TRASH:
+            return TrashIcon;
+        case SidePanelTreeCategory.PUBLIC_FAVORITES:
+            return PublicFavoriteIcon;
+        case SidePanelTreeCategory.ALL_PROCESSES:
+            return ProcessIcon;
+        case SidePanelTreeCategory.INSTANCE_TYPES:
+            return ResourceIcon;
+        case SidePanelTreeCategory.GROUPS:
+            return GroupsIcon;
+        case SidePanelTreeCategory.SHELL_ACCESS:
+            return TerminalIcon
+        default:
+            return ProjectIcon;
+    }
+};
diff --git a/services/workbench2/src/views-components/side-panel/side-panel-collapsed.tsx b/services/workbench2/src/views-components/side-panel/side-panel-collapsed.tsx
new file mode 100644 (file)
index 0000000..4ac91f4
--- /dev/null
@@ -0,0 +1,163 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { ReactElement } from 'react'
+import { connect } from 'react-redux'
+import { ProjectsIcon, ProcessIcon, FavoriteIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon, ResourceIcon } from 'components/icon/icon'
+import { TerminalIcon } from 'components/icon/icon'
+import { IconButton, List, ListItem, Tooltip } from '@material-ui/core'
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'
+import { ArvadosTheme } from 'common/custom-theme'
+import { navigateTo, navigateToInstanceTypes } from 'store/navigation/navigation-action'
+import { RootState } from 'store/store'
+import { Dispatch } from 'redux'
+import {
+    navigateToSharedWithMe,
+    navigateToPublicFavorites,
+    navigateToFavorites,
+    navigateToGroups,
+    navigateToAllProcesses,
+    navigateToTrash,
+} from 'store/navigation/navigation-action'
+import { navigateToUserVirtualMachines } from 'store/navigation/navigation-action'
+import { RouterAction } from 'react-router-redux'
+import { User } from 'models/user'
+
+type CssRules = 'button' | 'unselected' | 'selected'
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    button: {
+        width: '40px',
+        height: '40px',
+        paddingLeft: '-2rem',
+        marginLeft: '-0.6rem',
+        marginBottom: '-1rem'
+    },
+    unselected: {
+        color: theme.customs.colors.grey600,
+    },
+    selected: {
+        color: theme.palette.primary.main,
+    },
+})
+
+enum SidePanelCollapsedCategory {
+    PROJECTS = 'Home Projects',
+    FAVORITES = 'My Favorites',
+    PUBLIC_FAVORITES = 'Public Favorites',
+    SHARED_WITH_ME = 'Shared with me',
+    ALL_PROCESSES = 'All Processes',
+    INSTANCE_TYPES = 'Instance Types',
+    SHELL_ACCESS = 'Shell Access',
+    GROUPS = 'Groups',
+    TRASH = 'Trash',
+}
+
+type TCollapsedCategory = {
+    name: SidePanelCollapsedCategory
+    icon: ReactElement
+    navTarget: RouterAction | ''
+}
+
+const sidePanelCollapsedCategories: TCollapsedCategory[] = [
+    {
+        name: SidePanelCollapsedCategory.PROJECTS,
+        icon: <ProjectsIcon />,
+        navTarget: '',
+    },
+    {
+        name: SidePanelCollapsedCategory.FAVORITES,
+        icon: <FavoriteIcon />,
+        navTarget: navigateToFavorites,
+    },
+    {
+        name: SidePanelCollapsedCategory.PUBLIC_FAVORITES,
+        icon: <PublicFavoriteIcon />,
+        navTarget: navigateToPublicFavorites,
+    },
+    {
+        name: SidePanelCollapsedCategory.SHARED_WITH_ME,
+        icon: <ShareMeIcon />,
+        navTarget: navigateToSharedWithMe,
+    },
+    {
+        name: SidePanelCollapsedCategory.ALL_PROCESSES,
+        icon: <ProcessIcon />,
+        navTarget: navigateToAllProcesses,
+    },
+    {
+        name: SidePanelCollapsedCategory.INSTANCE_TYPES,
+        icon: <ResourceIcon />,
+        navTarget: navigateToInstanceTypes,
+    },
+    {
+        name: SidePanelCollapsedCategory.SHELL_ACCESS,
+        icon: <TerminalIcon />,
+        navTarget: navigateToUserVirtualMachines,
+    },
+    {
+        name: SidePanelCollapsedCategory.GROUPS,
+        icon: <GroupsIcon style={{marginLeft: '2px', scale: '85%'}}/>,
+        navTarget: navigateToGroups,
+    },
+    {
+        name: SidePanelCollapsedCategory.TRASH,
+        icon: <TrashIcon />,
+        navTarget: navigateToTrash,
+    },
+]
+
+type SidePanelCollapsedProps = {
+    user: User;
+    selectedPath: string;
+    navToHome: (uuid: string) => void;
+    navTo: (navTarget: RouterAction | '') => void;
+};
+
+const mapStateToProps = ({auth, properties }: RootState) => {
+        return {
+            user: auth.user,
+            selectedPath: properties.breadcrumbs
+                ? properties.breadcrumbs[0].label
+                : SidePanelCollapsedCategory.PROJECTS,
+        }
+}
+
+const mapDispatchToProps = (dispatch: Dispatch) => {
+    return {
+        navToHome: (navTarget) => dispatch<any>(navigateTo(navTarget)),
+        navTo: (navTarget) => dispatch<any>(navTarget),
+    }
+}
+
+export const SidePanelCollapsed = withStyles(styles)(
+    connect(mapStateToProps, mapDispatchToProps)(({ classes, user, selectedPath, navToHome, navTo }: WithStyles & SidePanelCollapsedProps) => {
+
+        const handleClick = (cat: TCollapsedCategory) => {
+            if (cat.name === SidePanelCollapsedCategory.PROJECTS) navToHome(user.uuid)
+            else navTo(cat.navTarget)
+        }
+
+        const { button, unselected, selected } = classes
+        return (
+            <List data-cy='side-panel-collapsed'>
+                {sidePanelCollapsedCategories.map((cat) => (
+                    <ListItem
+                        key={cat.name}
+                        data-cy={`collapsed-${cat.name.toLowerCase().replace(/\s+/g, '-')}`}
+                        onClick={() => handleClick(cat)}
+                        >
+                        <Tooltip
+                            className={selectedPath === cat.name ? selected : unselected}
+                            title={cat.name}
+                            disableFocusListener
+                            >
+                            <IconButton className={button}>{cat.icon}</IconButton>
+                        </Tooltip>
+                    </ListItem>
+                ))}
+            </List>
+        );
+    })
+)
diff --git a/services/workbench2/src/views-components/side-panel/side-panel.tsx b/services/workbench2/src/views-components/side-panel/side-panel.tsx
new file mode 100644 (file)
index 0000000..e19daef
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useRef, useEffect } from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { SidePanelTree, SidePanelTreeProps } from 'views-components/side-panel-tree/side-panel-tree';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { navigateFromSidePanel, setCurrentSideWidth } from 'store/side-panel/side-panel-action';
+import { Grid } from '@material-ui/core';
+import { SidePanelButton } from 'views-components/side-panel-button/side-panel-button';
+import { RootState } from 'store/store';
+import SidePanelToggle from 'views-components/side-panel-toggle/side-panel-toggle';
+import { SidePanelCollapsed } from './side-panel-collapsed';
+
+const DRAWER_WIDTH = 240;
+
+type CssRules = 'root' | 'topButtonContainer';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        background: theme.palette.background.paper,
+        borderRight: `1px solid ${theme.palette.divider}`,
+        height: '100%',
+        overflowX: 'auto',
+        width: DRAWER_WIDTH,
+    },
+    topButtonContainer: {
+        display: 'flex',
+        justifyContent: 'space-between'
+    }
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): SidePanelTreeProps => ({
+    onItemActivation: id => {
+        dispatch<any>(navigateFromSidePanel(id));
+    },
+    setCurrentSideWidth: width => {
+        dispatch<any>(setCurrentSideWidth(width))
+    }
+});
+
+const mapStateToProps = ({ router, sidePanel, detailsPanel }: RootState) => ({
+    currentRoute: router.location ? router.location.pathname : '',
+    isCollapsed: sidePanel.collapsedState,
+    isDetailsPanelTransitioning: detailsPanel.isTransitioning
+});
+
+export const SidePanel = withStyles(styles)(
+    connect(mapStateToProps, mapDispatchToProps)(
+        ({ classes, ...props }: WithStyles<CssRules> & SidePanelTreeProps & { currentRoute: string, isDetailsPanelTransitioning: boolean }) =>{
+
+        const splitPaneRef = useRef<any>(null)
+
+        useEffect(()=>{
+            const splitPane = splitPaneRef?.current as Element
+
+            if (!splitPane) return;
+
+            const observer = new ResizeObserver((entries)=>{
+                const width = entries[0].contentRect.width
+                props.setCurrentSideWidth(width)
+            })
+
+            observer.observe(splitPane)
+
+            return ()=> observer.disconnect()
+        }, [props])
+
+            return (
+                <Grid item xs>
+                        {props.isCollapsed ? 
+                            <div ref={splitPaneRef}>
+                        <>
+
+                            <SidePanelToggle />
+                            <SidePanelCollapsed />
+                        </>
+                        </div>
+                            :
+                            <>
+                        <div ref={splitPaneRef}>
+                            <Grid className={classes.topButtonContainer}>
+                                <SidePanelButton key={props.currentRoute} />
+                                <SidePanelToggle/>
+                            </Grid>
+                            <SidePanelTree {...props} />
+                        </div>
+                        </>
+                        }
+                    </Grid>
+        )}
+    ));
diff --git a/services/workbench2/src/views-components/snackbar/snackbar.tsx b/services/workbench2/src/views-components/snackbar/snackbar.tsx
new file mode 100644 (file)
index 0000000..1887f0b
--- /dev/null
@@ -0,0 +1,165 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { Button, IconButton, StyleRulesCallback, WithStyles, withStyles, SnackbarContent } from '@material-ui/core';
+import MaterialSnackbar, { SnackbarOrigin } from "@material-ui/core/Snackbar";
+import { snackbarActions, SnackbarKind, SnackbarMessage } from "store/snackbar/snackbar-actions";
+import { navigateTo } from 'store/navigation/navigation-action';
+import WarningIcon from '@material-ui/icons/Warning';
+import CheckCircleIcon from '@material-ui/icons/CheckCircle';
+import ErrorIcon from '@material-ui/icons/Error';
+import InfoIcon from '@material-ui/icons/Info';
+import CloseIcon from '@material-ui/icons/Close';
+import { ArvadosTheme } from "common/custom-theme";
+import { amber, green } from "@material-ui/core/colors";
+import classNames from 'classnames';
+
+interface SnackbarDataProps {
+    anchorOrigin?: SnackbarOrigin;
+    autoHideDuration?: number;
+    open: boolean;
+    messages: SnackbarMessage[];
+}
+
+interface SnackbarEventProps {
+    onClose?: (event: React.SyntheticEvent<any>, reason: string, message?: string) => void;
+    onExited: () => void;
+    onClick: (uuid: string) => void;
+}
+
+const mapStateToProps = (state: RootState): SnackbarDataProps => {
+    const messages = state.snackbar.messages;
+    return {
+        anchorOrigin: { vertical: "bottom", horizontal: "right" },
+        open: state.snackbar.open,
+        messages,
+        autoHideDuration: messages.length > 0 ? messages[0].hideDuration : 0
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): SnackbarEventProps => ({
+    onClose: (event: any, reason: string, id: undefined) => {
+        if (reason !== "clickaway") {
+            dispatch(snackbarActions.CLOSE_SNACKBAR(id));
+        }
+    },
+    onExited: () => {
+        dispatch(snackbarActions.SHIFT_MESSAGES());
+    },
+    onClick: (uuid: string) => {
+        dispatch<any>(navigateTo(uuid));
+    }
+});
+
+type CssRules = "success" | "error" | "info" | "warning" | "icon" | "iconVariant" | "message" | "linkButton" | "snackbarContent";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    success: {
+        backgroundColor: green[600]
+    },
+    error: {
+        backgroundColor: theme.palette.error.dark
+    },
+    info: {
+        backgroundColor: theme.palette.primary.main
+    },
+    warning: {
+        backgroundColor: amber[700]
+    },
+    icon: {
+        fontSize: 20
+    },
+    iconVariant: {
+        opacity: 0.9,
+        marginRight: theme.spacing.unit
+    },
+    message: {
+        display: 'flex',
+        alignItems: 'center'
+    },
+    linkButton: {
+        fontWeight: 'bolder'
+    },
+    snackbarContent: {
+        marginBottom: '1rem'
+    }
+});
+
+type SnackbarProps = SnackbarDataProps & SnackbarEventProps & WithStyles<CssRules>;
+
+export const Snackbar = withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(
+    (props: SnackbarProps) => {
+        const { classes } = props;
+
+        const variants = {
+            [SnackbarKind.INFO]: [InfoIcon, classes.info],
+            [SnackbarKind.WARNING]: [WarningIcon, classes.warning],
+            [SnackbarKind.SUCCESS]: [CheckCircleIcon, classes.success],
+            [SnackbarKind.ERROR]: [ErrorIcon, classes.error]
+        };
+
+        return (
+            <MaterialSnackbar
+                open={props.open}
+                onClose={props.onClose}
+                onExited={props.onExited}
+                anchorOrigin={props.anchorOrigin}
+                autoHideDuration={props.autoHideDuration}>
+                <div data-cy="snackbar">
+                    {
+                         props.messages.map((message, index) => {
+                            const [Icon, cssClass] = variants[message.kind];
+
+                            return <SnackbarContent
+                                key={`${index}-${message.message}`}
+                                className={classNames(cssClass, classes.snackbarContent)}
+                                aria-describedby="client-snackbar"
+                                message={
+                                    <span id="client-snackbar" className={classes.message}>
+                                        <Icon className={classNames(classes.icon, classes.iconVariant)} />
+                                        {message.message}
+                                    </span>
+                                }
+                                action={actions(message, props.onClick, props.onClose, classes, index, props.autoHideDuration)}
+                            />
+                         })
+                    }
+                </div>
+            </MaterialSnackbar>
+        );
+    }
+));
+
+const actions = (props: SnackbarMessage, onClick, onClose, classes, index, autoHideDuration) => {
+    if (onClose && autoHideDuration) {
+        setTimeout(onClose, autoHideDuration);
+    }
+
+    const actions = [
+        <IconButton
+            key="close"
+            aria-label="Close"
+            color="inherit"
+            onClick={e => onClose && onClose(e, '', index)}>
+            <CloseIcon className={classes.icon} />
+        </IconButton>
+    ];
+    if (props.link) {
+        actions.splice(0, 0,
+            <Button key="goTo"
+                aria-label="goTo"
+                size="small"
+                color="inherit"
+                className={classes.linkButton}
+                onClick={() => onClick(props.link)}>
+                <span data-cy='snackbar-goto-action'>Go To</span>
+            </Button>
+        );
+    }
+    return actions;
+};
diff --git a/services/workbench2/src/views-components/ssh-keys-dialog/attributes-dialog.tsx b/services/workbench2/src/views-components/ssh-keys-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..abed665
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { compose } from 'redux';
+import { withStyles, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, WithStyles, Grid } from '@material-ui/core';
+import { WithDialogProps, withDialog } from "store/dialog/with-dialog";
+import { SSH_KEY_ATTRIBUTES_DIALOG } from 'store/auth/auth-action-ssh';
+import { ArvadosTheme } from 'common/custom-theme';
+import { SshKeyResource } from "models/ssh-key";
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        fontSize: '0.875rem',
+        '& div:nth-child(odd)': {
+            textAlign: 'right',
+            color: theme.palette.grey["500"]
+        }
+    }
+});
+
+interface AttributesSshKeyDialogDataProps {
+    sshKey: SshKeyResource;
+}
+
+export const AttributesSshKeyDialog = compose(
+    withDialog(SSH_KEY_ATTRIBUTES_DIALOG),
+    withStyles(styles))(
+        ({ open, closeDialog, data, classes }: WithDialogProps<AttributesSshKeyDialogDataProps> & WithStyles<CssRules>) =>
+            <Dialog open={open}
+                onClose={closeDialog}
+                fullWidth
+                maxWidth='sm'>
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    {data.sshKey && <Grid container direction="row" spacing={16} className={classes.root}>
+                        <Grid item xs={5}>Name</Grid>
+                        <Grid item xs={7}>{data.sshKey.name}</Grid>
+                        <Grid item xs={5}>uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.uuid}</Grid>
+                        <Grid item xs={5}>Owner uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.ownerUuid}</Grid>
+                        <Grid item xs={5}>Authorized user uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.authorizedUserUuid}</Grid>
+                        <Grid item xs={5}>Created at</Grid>
+                        <Grid item xs={7}>{data.sshKey.createdAt}</Grid>
+                        <Grid item xs={5}>Modified at</Grid>
+                        <Grid item xs={7}>{data.sshKey.modifiedAt}</Grid>
+                        <Grid item xs={5}>Expires at</Grid>
+                        <Grid item xs={7}>{data.sshKey.expiresAt}</Grid>
+                        <Grid item xs={5}>Modified by user uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.modifiedByUserUuid}</Grid>
+                        <Grid item xs={5}>Modified by client uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.modifiedByClientUuid}</Grid>
+                    </Grid>}
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    );
diff --git a/services/workbench2/src/views-components/ssh-keys-dialog/public-key-dialog.tsx b/services/workbench2/src/views-components/ssh-keys-dialog/public-key-dialog.tsx
new file mode 100644 (file)
index 0000000..dfcade6
--- /dev/null
@@ -0,0 +1,55 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { compose } from 'redux';
+import { withStyles, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { WithDialogProps, withDialog } from "store/dialog/with-dialog";
+import { SSH_KEY_PUBLIC_KEY_DIALOG } from 'store/auth/auth-action-ssh';
+import { ArvadosTheme } from 'common/custom-theme';
+import { DefaultCodeSnippet } from 'components/default-code-snippet/default-code-snippet';
+
+type CssRules = 'codeSnippet';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    codeSnippet: {
+        borderRadius: theme.spacing.unit * 0.5,
+        border: '1px solid',
+        borderColor: theme.palette.grey["400"],
+        '& pre': {
+            wordWrap: 'break-word',
+            whiteSpace: 'pre-wrap'
+        }
+    },
+});
+
+interface PublicKeyDialogDataProps {
+    name: string;
+    publicKey: string;
+}
+
+export const PublicKeyDialog = compose(
+    withDialog(SSH_KEY_PUBLIC_KEY_DIALOG),
+    withStyles(styles))(
+        ({ open, closeDialog, data, classes }: WithDialogProps<PublicKeyDialogDataProps> & WithStyles<CssRules>) =>
+            <Dialog open={open}
+                onClose={closeDialog}
+                fullWidth
+                maxWidth='sm'>
+                <DialogTitle>{data.name} - SSH Key</DialogTitle>
+                <DialogContent>
+                    {data && data.publicKey && <DefaultCodeSnippet
+                        className={classes.codeSnippet}
+                        lines={data.publicKey.split(' ')} />}
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    );
diff --git a/services/workbench2/src/views-components/ssh-keys-dialog/remove-dialog.tsx b/services/workbench2/src/views-components/ssh-keys-dialog/remove-dialog.tsx
new file mode 100644 (file)
index 0000000..fecaf76
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { SSH_KEY_REMOVE_DIALOG, removeSshKey } from 'store/auth/auth-action-ssh';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeSshKey(props.data.uuid));
+    }
+});
+
+export const RemoveSshKeyDialog = compose(
+    withDialog(SSH_KEY_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
diff --git a/services/workbench2/src/views-components/token-dialog/token-dialog.test.tsx b/services/workbench2/src/views-components/token-dialog/token-dialog.test.tsx
new file mode 100644 (file)
index 0000000..ec68bea
--- /dev/null
@@ -0,0 +1,114 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// This mocks react-copy-to-clipboard's dependency module to avoid warnings
+// from jest when running tests. As we're not testing copy-to-clipboard, it's
+// safe to just mock it.
+// https://github.com/nkbt/react-copy-to-clipboard/issues/106#issuecomment-605227151
+jest.mock('copy-to-clipboard', () => {
+  return jest.fn();
+});
+
+import React from 'react';
+import { Button } from '@material-ui/core';
+import { mount, configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { TokenDialogComponent } from './token-dialog';
+import { combineReducers, createStore } from 'redux';
+import { Provider } from 'react-redux';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('toggle-selection', () => () => () => null);
+
+describe('<CurrentTokenDialog />', () => {
+  let props;
+  let wrapper;
+  let store;
+
+  beforeEach(() => {
+    props = {
+      classes: {},
+      token: 'xxxtokenxxx',
+      apiHost: 'example.com',
+      open: true,
+      dispatch: jest.fn(),
+    };
+
+    const initialAuthState = {
+      localCluster: "zzzzz",
+      remoteHostsConfig: {},
+      sessions: {},
+    };
+
+    store = createStore(combineReducers({
+      auth: (state: any = initialAuthState, action: any) => state,
+    }));
+  });
+
+  describe('Get API Token dialog', () => {
+    beforeEach(() => {
+      wrapper = mount(
+        <Provider store={store}>
+          <TokenDialogComponent {...props} />
+        </Provider>
+      );
+    });
+
+    it('should include API host and token', () => {
+      expect(wrapper.html()).toContain('export ARVADOS_API_HOST=example.com');
+      expect(wrapper.html()).toContain('export ARVADOS_API_TOKEN=xxxtokenxxx');
+    });
+
+    it('should show the token expiration if present', () => {
+      expect(props.tokenExpiration).toBeUndefined();
+      expect(wrapper.html()).toContain('This token does not have an expiration date');
+
+      const someDate = '2140-01-01T00:00:00.000Z'
+      props.tokenExpiration = new Date(someDate);
+      wrapper = mount(
+        <Provider store={store}>
+          <TokenDialogComponent {...props} />
+        </Provider>);
+      expect(wrapper.html()).toContain(props.tokenExpiration.toLocaleString());
+    });
+
+    it('should show a create new token button when allowed', () => {
+      expect(props.canCreateNewTokens).toBeFalsy();
+      expect(wrapper.html()).not.toContain('GET NEW TOKEN');
+
+      props.canCreateNewTokens = true;
+      wrapper = mount(
+        <Provider store={store}>
+          <TokenDialogComponent {...props} />
+        </Provider>);
+      expect(wrapper.html()).toContain('GET NEW TOKEN');
+    });
+  });
+
+  describe('Copy link to clipboard button', () => {
+    beforeEach(() => {
+      wrapper = mount(
+        <Provider store={store}>
+          <TokenDialogComponent {...props} />
+        </Provider>);
+    });
+
+    it('should copy API TOKEN to the clipboard', () => {
+      // when
+      wrapper.find(CopyToClipboard).find(Button).simulate('click');
+
+      // and
+      expect(props.dispatch).toHaveBeenCalledWith({
+        payload: {
+          hideDuration: 2000,
+          kind: 1,
+          message: 'Shell code block copied',
+        },
+        type: 'OPEN_SNACKBAR',
+      });
+    });
+  });
+});
diff --git a/services/workbench2/src/views-components/token-dialog/token-dialog.tsx b/services/workbench2/src/views-components/token-dialog/token-dialog.tsx
new file mode 100644 (file)
index 0000000..ad1093d
--- /dev/null
@@ -0,0 +1,162 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    Dialog,
+    DialogActions,
+    DialogTitle,
+    DialogContent,
+    WithStyles,
+    withStyles,
+    StyleRulesCallback,
+    Button,
+    Typography
+} from '@material-ui/core';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { ArvadosTheme } from 'common/custom-theme';
+import { withDialog } from 'store/dialog/with-dialog';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { connect, DispatchProp } from 'react-redux';
+import {
+    TokenDialogData,
+    getTokenDialogData,
+    TOKEN_DIALOG_NAME,
+} from 'store/token-dialog/token-dialog-actions';
+import { DefaultCodeSnippet } from 'components/default-code-snippet/default-code-snippet';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { getNewExtraToken } from 'store/auth/auth-action';
+import { DetailsAttributeComponent } from 'components/details-attribute/details-attribute';
+import moment from 'moment';
+
+type CssRules = 'link' | 'paper' | 'button' | 'actionButton' | 'codeBlock';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    link: {
+        color: theme.palette.primary.main,
+        textDecoration: 'none',
+        margin: '0px 4px'
+    },
+    paper: {
+        padding: theme.spacing.unit,
+        marginBottom: theme.spacing.unit * 2,
+        backgroundColor: theme.palette.grey["200"],
+        border: `1px solid ${theme.palette.grey["300"]}`
+    },
+    button: {
+        fontSize: '0.8125rem',
+        fontWeight: 600
+    },
+    actionButton: {
+        boxShadow: 'none',
+        marginTop: theme.spacing.unit * 2,
+        marginBottom: theme.spacing.unit * 2,
+        marginRight: theme.spacing.unit * 2,
+    },
+    codeBlock: {
+        fontSize: '0.8125rem',
+    },
+});
+
+type TokenDialogProps = TokenDialogData & WithDialogProps<{}> & WithStyles<CssRules> & DispatchProp;
+
+export class TokenDialogComponent extends React.Component<TokenDialogProps> {
+    onCopy = (message: string) => {
+        this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+            message,
+            hideDuration: 2000,
+            kind: SnackbarKind.SUCCESS
+        }));
+    }
+
+    onGetNewToken = async () => {
+        const newToken = await this.props.dispatch<any>(getNewExtraToken());
+        if (newToken) {
+            this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'New token retrieved',
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } else {
+            this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'Creating new tokens is not allowed',
+                hideDuration: 2000,
+                kind: SnackbarKind.WARNING
+            }));
+        }
+    }
+
+    getSnippet = ({ apiHost, token }: TokenDialogData) =>
+        `HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
+export ARVADOS_API_TOKEN=${token}
+export ARVADOS_API_HOST=${apiHost}
+unset ARVADOS_API_HOST_INSECURE`
+
+    render() {
+        const { classes, open, closeDialog, ...data } = this.props;
+        const tokenExpiration = data.tokenExpiration
+            ? `${data.tokenExpiration.toLocaleString()} (${moment(data.tokenExpiration).fromNow()})`
+            : `This token does not have an expiration date`;
+
+        return <Dialog
+            open={open}
+            onClose={closeDialog}
+            fullWidth={true}
+            maxWidth='md'>
+            <DialogTitle>Get API Token</DialogTitle>
+            <DialogContent>
+                <Typography paragraph={true}>
+                    The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions.
+                    <Typography component='span'>
+                        For more information see
+                        <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' rel="noopener" className={classes.link}>
+                            Getting an API token.
+                        </a>
+                    </Typography>
+                </Typography>
+
+                <DetailsAttributeComponent label='API Host' value={data.apiHost} copyValue={data.apiHost} onCopy={this.onCopy} />
+                <DetailsAttributeComponent label='API Token' value={data.token} copyValue={data.token} onCopy={this.onCopy} />
+                <DetailsAttributeComponent label='Token expiration' value={tokenExpiration} />
+                {this.props.canCreateNewTokens && <Button
+                    onClick={() => this.onGetNewToken()}
+                    color="primary"
+                    size="small"
+                    variant="contained"
+                    className={classes.actionButton}
+                >
+                    GET NEW TOKEN
+                </Button>}
+
+                <Typography paragraph={true}>
+                    Paste the following lines at a shell prompt to set up the necessary environment for Arvados SDKs to authenticate to your account.
+                </Typography>
+                <DefaultCodeSnippet className={classes.codeBlock} lines={[this.getSnippet(data)]} />
+                <CopyToClipboard text={this.getSnippet(data)} onCopy={() => this.onCopy('Shell code block copied')}>
+                    <Button
+                        color="primary"
+                        size="small"
+                        variant="contained"
+                        className={classes.actionButton}
+                    >
+                        Copy link to clipBOARD
+                    </Button>
+                </CopyToClipboard>
+                <Typography>
+                    Arvados
+                    <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' rel="noopener" className={classes.link}>virtual machines</a>
+                    do this for you automatically. This setup is needed only when you use the API remotely (e.g., from your own workstation).
+                </Typography>
+            </DialogContent>
+            <DialogActions>
+                <Button onClick={closeDialog} className={classes.button} color="primary">CLOSE</Button>
+            </DialogActions>
+        </Dialog>;
+    }
+}
+
+export const TokenDialog =
+    withStyles(styles)(
+        connect(getTokenDialogData)(
+            withDialog(TOKEN_DIALOG_NAME)(TokenDialogComponent)));
diff --git a/services/workbench2/src/views-components/tree-picker/tree-picker.ts b/services/workbench2/src/views-components/tree-picker/tree-picker.ts
new file mode 100644 (file)
index 0000000..1b6d2a2
--- /dev/null
@@ -0,0 +1,92 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Tree, TreeProps, TreeItem, TreeItemStatus } from "components/tree/tree";
+import { RootState } from "store/store";
+import { getNodeChildrenIds, Tree as Ttree, createTree, getNode, TreeNodeStatus } from 'models/tree';
+import { Dispatch } from "redux";
+import { initTreeNode } from '../../models/tree';
+import { ResourcesState } from "store/resources/resources";
+
+type Callback<T> = (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>, pickerId: string) => void;
+export interface TreePickerProps<T> {
+    pickerId: string;
+    onContextMenu: Callback<T>;
+    toggleItemOpen: Callback<T>;
+    toggleItemActive: Callback<T>;
+    toggleItemSelection: Callback<T>;
+}
+
+const flatTree = (itemsIdMap: Map<string, any>, depth: number, items?: any): [] => {
+    return items ? items
+        .map((item: any) => addToItemsIdMap(item, itemsIdMap))
+        .reduce((prev: Array<any>, next: any) => {
+            const { items } = next;
+            prev.push({ ...next, depth });
+            prev.push(...(next.open ? flatTree(itemsIdMap, depth + 1, items) : []));
+            return prev;
+        }, []) : [];
+};
+
+const addToItemsIdMap = <T>(item: TreeItem<T>, itemsIdMap: Map<string, TreeItem<T>>) => {
+    itemsIdMap[item.id] = item;
+    return item;
+};
+
+const mapStateToProps =
+    <T>(state: RootState, props: TreePickerProps<T>): Pick<TreeProps<T>, 'items' | 'disableRipple' | 'itemsMap'> => {
+        const itemsIdMap: Map<string, TreeItem<T>> = new Map();
+        const tree = state.treePicker[props.pickerId] || createTree();
+        return {
+            disableRipple: true,
+            items: getNodeChildrenIds('')(tree)
+                .map(treePickerToTreeItems(tree, state.resources))
+                .map(item => addToItemsIdMap(item, itemsIdMap))
+                .map(parentItem => ({
+                    ...parentItem,
+                    flatTree: true,
+                    items: flatTree(itemsIdMap, 2, parentItem.items || []),
+                })),
+            itemsMap: itemsIdMap,
+        };
+    };
+
+const mapDispatchToProps = (_: Dispatch, props: TreePickerProps<any>): Pick<TreeProps<any>, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive' | 'toggleItemSelection'> => ({
+    onContextMenu: (event, item) => props.onContextMenu(event, item, props.pickerId),
+    toggleItemActive: (event, item) => props.toggleItemActive(event, item, props.pickerId),
+    toggleItemOpen: (event, item) => props.toggleItemOpen(event, item, props.pickerId),
+    toggleItemSelection: (event, item) => props.toggleItemSelection(event, item, props.pickerId),
+});
+
+export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(Tree);
+
+const treePickerToTreeItems = (tree: Ttree<any>, resources: ResourcesState) =>
+    (id: string): TreeItem<any> => {
+        const node = getNode(id)(tree) || initTreeNode({ id: '', value: 'InvalidNode' });
+        const items = getNodeChildrenIds(node.id)(tree)
+            .map(treePickerToTreeItems(tree, resources));
+        const resource = resources[node.id];
+        return {
+            active: node.active,
+            data: resource ? { ...resource, name: node.value.name || node.value } : node.value,
+            id: node.id,
+            items: items.length > 0 ? items : undefined,
+            open: node.expanded,
+            selected: node.selected,
+            status: treeNodeStatusToTreeItem(node.status),
+        };
+    };
+
+export const treeNodeStatusToTreeItem = (status: TreeNodeStatus) => {
+    switch (status) {
+        case TreeNodeStatus.INITIAL:
+            return TreeItemStatus.INITIAL;
+        case TreeNodeStatus.PENDING:
+            return TreeItemStatus.PENDING;
+        case TreeNodeStatus.LOADED:
+            return TreeItemStatus.LOADED;
+    }
+};
+
diff --git a/services/workbench2/src/views-components/user-dialog/activate-dialog.tsx b/services/workbench2/src/views-components/user-dialog/activate-dialog.tsx
new file mode 100644 (file)
index 0000000..79e8330
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { activate, ACTIVATE_DIALOG } from 'store/user-profile/user-profile-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(activate(props.data.uuid));
+    }
+});
+
+export const ActivateDialog = compose(
+    withDialog(ACTIVATE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
diff --git a/services/workbench2/src/views-components/user-dialog/attributes-dialog.tsx b/services/workbench2/src/views-components/user-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..5686688
--- /dev/null
@@ -0,0 +1,100 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Grid } from "@material-ui/core";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { withDialog } from 'store/dialog/with-dialog';
+import { WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { compose } from "redux";
+import { USER_ATTRIBUTES_DIALOG } from "store/users/users-actions";
+import { UserResource } from "models/user";
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles = withStyles<CssRules>((theme: ArvadosTheme) => ({
+    rightContainer: {
+        textAlign: 'right',
+        paddingRight: theme.spacing.unit * 2,
+        color: theme.palette.grey["500"]
+    },
+    leftContainer: {
+        textAlign: 'left',
+        paddingLeft: theme.spacing.unit * 2
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    },
+}));
+
+interface UserAttributesDataProps {
+    data: UserResource;
+}
+
+type UserAttributesProps = UserAttributesDataProps & WithStyles<CssRules>;
+
+export const UserAttributesDialog = compose(
+    withDialog(USER_ATTRIBUTES_DIALOG),
+    styles)(
+        (props: WithDialogProps<UserAttributesProps> & UserAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant='body1' className={props.classes.spacing}>
+                        {props.data && attributes(props.data, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (user: UserResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid,
+        firstName, lastName, username, email, isActive, isAdmin } = user;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    {uuid && <Grid item>Uuid</Grid>}
+                    {firstName && <Grid item>First name</Grid>}
+                    {lastName && <Grid item>Last name</Grid>}
+                    {email && <Grid item>Email</Grid>}
+                    {username && <Grid item>Username</Grid>}
+                    {isActive && <Grid item>Is active</Grid>}
+                    {isAdmin && <Grid item>Is admin</Grid>}
+                    {createdAt && <Grid item>Created at</Grid>}
+                    {modifiedAt && <Grid item>Modified at</Grid>}
+                    {ownerUuid && <Grid item>Owner uuid</Grid>}
+                    {modifiedByUserUuid && <Grid item>Modified by user uuid</Grid>}
+                    {modifiedByClientUuid && <Grid item>Modified by client uuid</Grid>}
+                </Grid>
+                <Grid item xs={7} className={classes.leftContainer}>
+                    <Grid item>{uuid}</Grid>
+                    <Grid item>{firstName}</Grid>
+                    <Grid item>{lastName}</Grid>
+                    <Grid item>{email}</Grid>
+                    <Grid item>{username}</Grid>
+                    <Grid item>{isActive}</Grid>
+                    <Grid item>{isAdmin}</Grid>
+                    <Grid item>{createdAt}</Grid>
+                    <Grid item>{modifiedAt}</Grid>
+                    <Grid item>{ownerUuid}</Grid>
+                    <Grid item>{modifiedByUserUuid}</Grid>
+                    <Grid item>{modifiedByClientUuid}</Grid>
+                </Grid>
+            </Grid>
+        </span>
+    );
+};
diff --git a/services/workbench2/src/views-components/user-dialog/deactivate-dialog.tsx b/services/workbench2/src/views-components/user-dialog/deactivate-dialog.tsx
new file mode 100644 (file)
index 0000000..8aefa92
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { deactivate, DEACTIVATE_DIALOG } from 'store/user-profile/user-profile-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(deactivate(props.data.uuid));
+    }
+});
+
+export const DeactivateDialog = compose(
+    withDialog(DEACTIVATE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
diff --git a/services/workbench2/src/views-components/user-dialog/setup-dialog.tsx b/services/workbench2/src/views-components/user-dialog/setup-dialog.tsx
new file mode 100644 (file)
index 0000000..3a2fd35
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { setup, SETUP_DIALOG } from 'store/user-profile/user-profile-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(setup(props.data.uuid));
+    }
+});
+
+export const SetupDialog = compose(
+    withDialog(SETUP_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
diff --git a/services/workbench2/src/views-components/virtual-machines-dialog/add-login-dialog.tsx b/services/workbench2/src/views-components/virtual-machines-dialog/add-login-dialog.tsx
new file mode 100644 (file)
index 0000000..b591bb8
--- /dev/null
@@ -0,0 +1,82 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { compose } from "redux";
+import { reduxForm, InjectedFormProps, Field, GenericField } from 'redux-form';
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, VIRTUAL_MACHINE_ADD_LOGIN_FORM, addUpdateVirtualMachineLogin, AddLoginFormData, VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD } from 'store/virtual-machines/virtual-machines-actions';
+import { ParticipantSelect } from 'views-components/sharing-dialog/participant-select';
+import { GroupArrayInput, GroupArrayDataProps } from 'views-components/virtual-machines-dialog/group-array-input';
+
+export const VirtualMachineAddLoginDialog = compose(
+    withDialog(VIRTUAL_MACHINE_ADD_LOGIN_DIALOG),
+    reduxForm<AddLoginFormData>({
+        form: VIRTUAL_MACHINE_ADD_LOGIN_FORM,
+        onSubmit: (data, dispatch) => {
+            dispatch(addUpdateVirtualMachineLogin(data));
+        }
+    })
+)(
+    (props: CreateGroupDialogComponentProps) => {
+        const [hasPartialGroupInput, setPartialGroupInput] = React.useState<boolean>(false);
+
+        return <FormDialog
+            dialogTitle={props.data.updating ? "Update login permission" : "Add login permission"}
+            formFields={AddLoginFormFields}
+            submitLabel={props.data.updating ? "Update" : "Add"}
+            {...props}
+            data={{
+                ...props.data,
+                setPartialGroupInput,
+                hasPartialGroupInput,
+            }}
+            invalid={props.invalid || hasPartialGroupInput}
+        />;
+    }
+);
+
+type CreateGroupDialogComponentProps = WithDialogProps<{updating: boolean}> & GroupArrayDataProps & InjectedFormProps<AddLoginFormData>;
+
+const AddLoginFormFields = (props) => {
+    return <>
+        <ParticipantField
+            name={VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD}
+            component={props.data.updating ? ReadOnlyUserSelect : UserSelect}
+            excludedParticipants={props.data.excludedParticipants}
+        />
+        <GroupArrayInput
+            name={VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD}
+            input={{id:"Add groups to VM login (eg: docker, sudo)", disabled:false}}
+            required={false}
+            setPartialGroupInput={props.data.setPartialGroupInput}
+            hasPartialGroupInput={props.data.hasPartialGroupInput}
+        />
+    </>;
+}
+
+interface UserFieldProps {
+    excludedParticipants: string[];
+}
+
+const ParticipantField = Field as new () => GenericField<UserFieldProps>;
+
+const UserSelect = (props) =>
+    <ParticipantSelect
+        onlyPeople
+        onlyActive
+        label='Search for user to grant login permission'
+        items={props.input.value ? [props.input.value] : []}
+        excludedParticipants={props.excludedParticipants}
+        onSelect={props.input.onChange}
+        onDelete={() => (props.input.onChange(''))} />;
+
+const ReadOnlyUserSelect = (props) =>
+        <ParticipantSelect
+            onlyPeople
+            onlyActive
+            label='User'
+            items={props.input.value ? [props.input.value] : []}
+            disabled={true} />;
diff --git a/services/workbench2/src/views-components/virtual-machines-dialog/attributes-dialog.tsx b/services/workbench2/src/views-components/virtual-machines-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..4960c1b
--- /dev/null
@@ -0,0 +1,89 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Grid } from "@material-ui/core";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { withDialog } from 'store/dialog/with-dialog';
+import { VIRTUAL_MACHINE_ATTRIBUTES_DIALOG } from "store/virtual-machines/virtual-machines-actions";
+import { WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { compose } from "redux";
+import { VirtualMachinesResource } from "models/virtual-machines";
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles = withStyles<CssRules>((theme: ArvadosTheme) => ({
+    rightContainer: {
+        textAlign: 'right',
+        paddingRight: theme.spacing.unit * 2,
+        color: theme.palette.grey["500"]
+    },
+    leftContainer: {
+        textAlign: 'left',
+        paddingLeft: theme.spacing.unit * 2
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    },
+}));
+
+interface VirtualMachineAttributesDataProps {
+    virtualMachineData: VirtualMachinesResource;
+}
+
+type VirtualMachineAttributesProps = VirtualMachineAttributesDataProps & WithStyles<CssRules>;
+
+export const VirtualMachineAttributesDialog = compose(
+    withDialog(VIRTUAL_MACHINE_ATTRIBUTES_DIALOG),
+    styles)(
+        (props: WithDialogProps<VirtualMachineAttributesProps> & VirtualMachineAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant='body1' className={props.classes.spacing}>
+                        {props.data.virtualMachineData && attributes(props.data.virtualMachineData, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (virtualMachine: VirtualMachinesResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, hostname } = virtualMachine;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    <Grid item>Hostname</Grid>
+                    <Grid item>Owner uuid</Grid>
+                    <Grid item>Created at</Grid>
+                    <Grid item>Modified at</Grid>
+                    <Grid item>Modified by user uuid</Grid>
+                    <Grid item>Modified by client uuid</Grid>
+                    <Grid item>uuid</Grid>
+                </Grid>
+                <Grid item xs={7} className={classes.leftContainer}>
+                    <Grid item>{hostname}</Grid>
+                    <Grid item>{ownerUuid}</Grid>
+                    <Grid item>{createdAt}</Grid>
+                    <Grid item>{modifiedAt}</Grid>
+                    <Grid item>{modifiedByUserUuid}</Grid>
+                    <Grid item>{modifiedByClientUuid}</Grid>
+                    <Grid item>{uuid}</Grid>
+                </Grid>
+            </Grid>
+        </span>
+    );
+};
diff --git a/services/workbench2/src/views-components/virtual-machines-dialog/group-array-input.tsx b/services/workbench2/src/views-components/virtual-machines-dialog/group-array-input.tsx
new file mode 100644 (file)
index 0000000..cba9af6
--- /dev/null
@@ -0,0 +1,103 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StringArrayCommandInputParameter } from 'models/workflow';
+import { Field, GenericField } from 'redux-form';
+import { GenericInputProps } from 'views/run-process-panel/inputs/generic-input';
+import { ChipsInput } from 'components/chips-input/chips-input';
+import { identity } from 'lodash';
+import { withStyles, WithStyles, FormGroup, Input, InputLabel, FormControl, FormHelperText } from '@material-ui/core';
+import classnames from "classnames";
+import { ArvadosTheme } from 'common/custom-theme';
+
+export interface GroupArrayDataProps {
+  hasPartialGroupInput?: boolean;
+  setPartialGroupInput?: (value: boolean) => void;
+}
+
+interface GroupArrayFieldProps {
+  commandInput: StringArrayCommandInputParameter;
+}
+
+const GroupArrayField = Field as new () => GenericField<GroupArrayDataProps & GroupArrayFieldProps>;
+
+export interface GroupArrayInputProps {
+  name: string;
+  input: StringArrayCommandInputParameter;
+  required: boolean;
+}
+
+type CssRules = 'chips' | 'partialInputHelper' | 'partialInputHelperVisible';
+
+const styles = (theme: ArvadosTheme) => ({
+    chips: {
+        marginTop: "16px",
+    },
+    partialInputHelper: {
+        textAlign: 'right' as 'right',
+        visibility: 'hidden' as 'hidden',
+        color: theme.palette.error.dark,
+    },
+    partialInputHelperVisible: {
+        visibility: 'visible' as 'visible',
+    }
+});
+
+export const GroupArrayInput = ({name, input, setPartialGroupInput, hasPartialGroupInput}: GroupArrayInputProps & GroupArrayDataProps) => {
+  return <GroupArrayField
+      name={name}
+      commandInput={input}
+      component={GroupArrayInputComponent as any}
+      setPartialGroupInput={setPartialGroupInput}
+      hasPartialGroupInput={hasPartialGroupInput}
+      />;
+}
+
+const GroupArrayInputComponent = (props: GenericInputProps & GroupArrayDataProps) => {
+  return <FormGroup>
+        <FormControl fullWidth error={props.meta.error}>
+          <InputLabel shrink={props.meta.active || props.input.value.length > 0}>{props.commandInput.id}</InputLabel>
+          <StyledInputComponent {...props} />
+        </FormControl>
+    </FormGroup>;
+    };
+
+const StyledInputComponent = withStyles(styles)(
+  class InputComponent extends React.PureComponent<GenericInputProps & WithStyles<CssRules> & GroupArrayDataProps>{
+      render() {
+          const { classes } = this.props;
+          const { commandInput, input, meta, hasPartialGroupInput } = this.props;
+          return <>
+            <ChipsInput
+                deletable={!commandInput.disabled}
+                orderable={!commandInput.disabled}
+                disabled={commandInput.disabled}
+                values={input.value}
+                onChange={this.handleChange}
+                handleFocus={input.onFocus}
+                createNewValue={identity}
+                inputComponent={Input}
+                chipsClassName={classes.chips}
+                pattern={/[_a-z][-0-9_a-z]*/ig}
+                onPartialInput={this.props.setPartialGroupInput}
+                inputProps={{
+                    error: meta.error || hasPartialGroupInput,
+                }} />
+                <FormHelperText className={classnames([classes.partialInputHelper, ...(hasPartialGroupInput ? [classes.partialInputHelperVisible] : [])])}>
+                  Press enter to complete group name
+                </FormHelperText>
+          </>;
+      }
+
+      handleChange = (values: {}[]) => {
+        const { input, meta } = this.props;
+          if (!meta.touched) {
+              input.onBlur(values);
+          }
+          input.onChange(values);
+      }
+
+  }
+);
diff --git a/services/workbench2/src/views-components/virtual-machines-dialog/remove-dialog.tsx b/services/workbench2/src/views-components/virtual-machines-dialog/remove-dialog.tsx
new file mode 100644 (file)
index 0000000..df4642a
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { VIRTUAL_MACHINE_REMOVE_DIALOG, removeVirtualMachine } from 'store/virtual-machines/virtual-machines-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeVirtualMachine(props.data.uuid));
+    }
+});
+
+export const RemoveVirtualMachineDialog = compose(
+    withDialog(VIRTUAL_MACHINE_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/services/workbench2/src/views-components/virtual-machines-dialog/remove-login-dialog.tsx b/services/workbench2/src/views-components/virtual-machines-dialog/remove-login-dialog.tsx
new file mode 100644 (file)
index 0000000..60a485f
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG, removeVirtualMachineLogin, loadVirtualMachinesAdminData } from 'store/virtual-machines/virtual-machines-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeVirtualMachineLogin(props.data.uuid));
+        dispatch<any>(loadVirtualMachinesAdminData());
+    }
+});
+
+export const RemoveVirtualMachineLoginDialog = compose(
+    withDialog(VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
diff --git a/services/workbench2/src/views-components/webdav-s3-dialog/webdav-s3-dialog.test.tsx b/services/workbench2/src/views-components/webdav-s3-dialog/webdav-s3-dialog.test.tsx
new file mode 100644 (file)
index 0000000..d654f6e
--- /dev/null
@@ -0,0 +1,132 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { mount, configure } from 'enzyme';
+import Adapter from "enzyme-adapter-react-16";
+import { MuiThemeProvider, WithStyles } from '@material-ui/core';
+import { CustomTheme } from 'common/custom-theme';
+import { WebDavS3InfoDialog, CssRules } from './webdav-s3-dialog';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { WebDavS3InfoDialogData, COLLECTION_WEBDAV_S3_DIALOG_NAME } from 'store/collections/collection-info-actions';
+import { Provider } from "react-redux";
+import { createStore, combineReducers } from 'redux';
+
+configure({ adapter: new Adapter() });
+
+describe('WebDavS3InfoDialog', () => {
+    let props: WithDialogProps<WebDavS3InfoDialogData> & WithStyles<CssRules>;
+    let store;
+
+    beforeEach(() => {
+        const initialDialogState = {
+            [COLLECTION_WEBDAV_S3_DIALOG_NAME]: {
+                open: true,
+                data: {
+                    uuid: "zzzzz-4zz18-b1f8tbldjrm8885",
+                    token: "v2/zzzzb-jjjjj-123123/xxxtokenxxx",
+                    downloadUrl: "https://download.example.com",
+                    collectionsUrl: "https://collections.example.com",
+                    localCluster: "zzzzz",
+                    username: "bobby",
+                    activeTab: 0,
+                    setActiveTab: (event: any, tabNr: number) => { }
+                }
+            }
+        };
+        const initialAuthState = {
+            localCluster: "zzzzz",
+            remoteHostsConfig: {},
+            sessions: {},
+        };
+        store = createStore(combineReducers({
+            dialog: (state: any = initialDialogState, action: any) => state,
+            auth: (state: any = initialAuthState, action: any) => state,
+        }));
+
+        props = {
+            classes: {
+                details: 'details',
+            }
+        };
+    });
+
+    it('render cyberduck tab', () => {
+        store.getState().dialog[COLLECTION_WEBDAV_S3_DIALOG_NAME].data.activeTab = 0;
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <Provider store={store}>
+                    <WebDavS3InfoDialog {...props} />
+                </Provider>
+            </MuiThemeProvider>
+        );
+
+        // then
+        expect(wrapper.text()).toContain("davs://bobby@download.example.com/c=zzzzz-4zz18-b1f8tbldjrm8885");
+    });
+
+    it('render win/mac tab', () => {
+        store.getState().dialog[COLLECTION_WEBDAV_S3_DIALOG_NAME].data.activeTab = 1;
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <Provider store={store}>
+                    <WebDavS3InfoDialog {...props} />
+                </Provider>
+            </MuiThemeProvider>
+        );
+
+        // then
+        expect(wrapper.text()).toContain("https://download.example.com/c=zzzzz-4zz18-b1f8tbldjrm8885");
+    });
+
+    it('render s3 tab with federated token', () => {
+        store.getState().dialog[COLLECTION_WEBDAV_S3_DIALOG_NAME].data.activeTab = 2;
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <Provider store={store}>
+                    <WebDavS3InfoDialog {...props} />
+                </Provider>
+            </MuiThemeProvider>
+        );
+
+        // then
+        expect(wrapper.text()).toContain("Secret Keyv2_zzzzb-jjjjj-123123_xxxtokenxxx");
+    });
+
+    it('render s3 tab with local token', () => {
+        store.getState().dialog[COLLECTION_WEBDAV_S3_DIALOG_NAME].data.activeTab = 2;
+        store.getState().dialog[COLLECTION_WEBDAV_S3_DIALOG_NAME].data.token = "v2/zzzzz-jjjjj-123123/xxxtokenxxx";
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <Provider store={store}>
+                    <WebDavS3InfoDialog {...props} />
+                </Provider>
+            </MuiThemeProvider>
+        );
+
+        // then
+        expect(wrapper.text()).toContain("Access Keyzzzzz-jjjjj-123123Secret Keyxxxtokenxxx");
+    });
+
+    it('render cyberduck tab with wildcard DNS', () => {
+        store.getState().dialog[COLLECTION_WEBDAV_S3_DIALOG_NAME].data.activeTab = 0;
+        store.getState().dialog[COLLECTION_WEBDAV_S3_DIALOG_NAME].data.collectionsUrl = "https://*.collections.example.com";
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <Provider store={store}>
+                    <WebDavS3InfoDialog {...props} />
+                </Provider>
+            </MuiThemeProvider>
+        );
+
+        // then
+        expect(wrapper.text()).toContain("davs://bobby@zzzzz-4zz18-b1f8tbldjrm8885.collections.example.com");
+    });
+
+});
diff --git a/services/workbench2/src/views-components/webdav-s3-dialog/webdav-s3-dialog.tsx b/services/workbench2/src/views-components/webdav-s3-dialog/webdav-s3-dialog.tsx
new file mode 100644 (file)
index 0000000..a32044a
--- /dev/null
@@ -0,0 +1,300 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Dialog, DialogActions, Button, StyleRulesCallback, WithStyles, withStyles, CardHeader, Tab, Tabs } from '@material-ui/core';
+import { withDialog } from "store/dialog/with-dialog";
+import { COLLECTION_WEBDAV_S3_DIALOG_NAME, WebDavS3InfoDialogData } from 'store/collections/collection-info-actions';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { compose } from 'redux';
+import { DetailsAttribute } from "components/details-attribute/details-attribute";
+import { DownloadIcon } from "components/icon/icon";
+import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet";
+
+export type CssRules = 'details' | 'downloadButton' | 'detailsAttrValWithCode';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    details: {
+        marginLeft: theme.spacing.unit * 3,
+        marginRight: theme.spacing.unit * 3,
+    },
+    downloadButton: {
+        marginTop: theme.spacing.unit * 2,
+    },
+    detailsAttrValWithCode: {
+        display: "flex",
+        alignItems: "center",
+    }
+});
+
+interface TabPanelData {
+    children: React.ReactElement<any>[];
+    value: number;
+    index: number;
+}
+
+function TabPanel(props: TabPanelData) {
+    const { children, value, index } = props;
+
+    return (
+        <div
+            role="tabpanel"
+            hidden={value !== index}
+            id={`simple-tabpanel-${index}`}
+            aria-labelledby={`simple-tab-${index}`}
+        >
+            {value === index && children}
+        </div>
+    );
+}
+
+const isValidIpAddress = (ipAddress: string): Boolean => {
+    if (/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(ipAddress)) {
+        return true;
+    }
+
+    return false;
+};
+
+const mountainduckTemplate = ({
+    uuid,
+    username,
+    cyberDavStr,
+    collectionsUrl
+}: any) => {
+
+    return `<?xml version="1.0" encoding="UTF-8"?>
+        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+        <plist version="1.0">
+        <dict>
+            <key>Protocol</key>
+            <string>davs</string>
+            <key>Provider</key>
+            <string>iterate GmbH</string>
+            <key>UUID</key>
+            <string>${uuid}</string>
+            <key>Hostname</key>
+            <string>${collectionsUrl.replace('https://', ``).replace('*', uuid).split(':')[0]}</string>
+            <key>Port</key>
+            <string>${(cyberDavStr.split(':')[2] || '443').split('/')[0]}</string>
+            <key>Username</key>
+            <string>${username}</string>${isValidIpAddress(collectionsUrl.replace('https://', ``).split(':')[0]) ?
+            `
+            <key>Path</key>
+            <string>/c=${uuid}</string>` : ''}
+            <key>Labels</key>
+            <array>
+            </array>
+        </dict>
+        </plist>`.split(/\r?\n/).join('\n');
+};
+
+const downloadMountainduckFileHandler = (filename: string, text: string) => {
+    const element = document.createElement('a');
+    element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
+    element.setAttribute('download', filename);
+
+    element.style.display = 'none';
+    document.body.appendChild(element);
+
+    element.click();
+
+    document.body.removeChild(element);
+};
+
+export const WebDavS3InfoDialog = compose(
+    withDialog(COLLECTION_WEBDAV_S3_DIALOG_NAME),
+    withStyles(styles),
+)(
+    (props: WithDialogProps<WebDavS3InfoDialogData> & WithStyles<CssRules>) => {
+        if (!props.data.downloadUrl) { return null; }
+
+        let winDav;
+        let cyberDav;
+
+        if (props.data.collectionsUrl.indexOf("*") > -1) {
+            const withuuid = props.data.collectionsUrl.replace("*", props.data.uuid);
+            winDav = new URL(withuuid);
+            cyberDav = new URL(withuuid);
+        } else {
+            winDav = new URL(props.data.downloadUrl);
+            cyberDav = new URL(props.data.downloadUrl);
+            winDav.pathname = `/c=${props.data.uuid}`;
+            cyberDav.pathname = `/c=${props.data.uuid}`;
+        }
+
+        cyberDav.username = props.data.username;
+        const cyberDavStr = "dav" + cyberDav.toString().slice(4);
+
+        const s3endpoint = new URL(props.data.collectionsUrl.replace(/\/\*(--[^.]+)?\./, "/"));
+
+        const sp = props.data.token.split("/");
+        let tokenUuid;
+        let tokenSecret;
+        if (sp.length === 3 && sp[0] === "v2" && sp[1].slice(0, 5) === props.data.localCluster) {
+            tokenUuid = sp[1];
+            tokenSecret = sp[2];
+        } else {
+            tokenUuid = props.data.token.replace(/\//g, "_");
+            tokenSecret = tokenUuid;
+        }
+
+        const isCollection = (props.data.uuid.indexOf("-4zz18-") === 5);
+
+        let activeTab = props.data.activeTab;
+        if (!isCollection) {
+            activeTab = 2;
+        }
+
+        const wgetCommand = `wget --http-user=${props.data.username} --http-passwd=${props.data.token} --mirror --no-parent --no-host --cut-dirs=0 ${winDav.toString()}`;
+        const curlCommand = `curl -O -u ${props.data.username}:${props.data.token} ${winDav.toString()}`;
+
+        return <Dialog
+            open={props.open}
+            maxWidth="md"
+            onClose={props.closeDialog}
+            style={{ alignSelf: 'stretch' }}>
+            <CardHeader
+                title={`Open with 3rd party client`} />
+            <div className={props.classes.details} >
+                <Tabs value={activeTab} onChange={props.data.setActiveTab}>
+                    {isCollection && <Tab value={0} key="cyberduck" label="WebDAV" />}
+                    {isCollection && <Tab value={1} key="windows" label="Windows or MacOS" />}
+                    <Tab value={2} key="s3" label="S3 bucket" />
+                    {isCollection && <Tab value={3} key="cli" label="wget / curl" />}
+                </Tabs>
+
+                <TabPanel index={1} value={activeTab}>
+                    <h2>Settings</h2>
+
+                    <DetailsAttribute
+                        label='Internet address'
+                        value={<a href={winDav.toString()} target="_blank" rel="noopener noreferrer">{winDav.toString()}</a>}
+                        copyValue={winDav.toString()} />
+
+                    <DetailsAttribute
+                        label='Username'
+                        value={props.data.username}
+                        copyValue={props.data.username} />
+
+                    <DetailsAttribute
+                        label='Password'
+                        value={props.data.token}
+                        copyValue={props.data.token} />
+
+                    <h3>Windows</h3>
+                    <ol>
+                        <li>Open File Explorer</li>
+                        <li>Click on "This PC", then go to Computer &rarr; Add a Network Location</li>
+                        <li>Click Next, then choose "Add a custom network location", then click Next</li>
+                        <li>Use the "internet address" and credentials listed under Settings, above</li>
+                    </ol>
+
+                    <h3>MacOS</h3>
+                    <ol>
+                        <li>Open Finder</li>
+                        <li>Click Go &rarr; Connect to server</li>
+                        <li>Use the "internet address" and credentials listed under Settings, above</li>
+                    </ol>
+                </TabPanel>
+
+                <TabPanel index={0} value={activeTab}>
+                    <DetailsAttribute
+                        label='Server'
+                        value={<a href={cyberDavStr} target="_blank" rel="noopener noreferrer">{cyberDavStr}</a>}
+                        copyValue={cyberDavStr} />
+
+                    <DetailsAttribute
+                        label='Username'
+                        value={props.data.username}
+                        copyValue={props.data.username} />
+
+                    <DetailsAttribute
+                        label='Password'
+                        value={props.data.token}
+                        copyValue={props.data.token} />
+
+                    <h3>Cyberduck/Mountain Duck</h3>
+
+                    <Button
+                        data-cy='download-button'
+                        className={props.classes.downloadButton}
+                        onClick={() => downloadMountainduckFileHandler(`${props.data.collectionName || props.data.uuid}.duck`, mountainduckTemplate({ ...props.data, cyberDavStr }))}
+                        variant='contained'
+                        color='primary'
+                        size='small'>
+                        <DownloadIcon />
+                        Download Cyber/Mountain Duck bookmark
+                    </Button>
+
+                    <h3>GNOME</h3>
+                    <ol>
+                        <li>Open Files</li>
+                        <li>Select +Other Locations</li>
+                        <li>Connect to Server &rarr; Enter server address</li>
+                    </ol>
+
+                </TabPanel>
+
+                <TabPanel index={2} value={activeTab}>
+                    <DetailsAttribute
+                        label='Endpoint'
+                        value={s3endpoint.host}
+                        copyValue={s3endpoint.host} />
+
+                    <DetailsAttribute
+                        label='Bucket'
+                        value={props.data.uuid}
+                        copyValue={props.data.uuid} />
+
+                    <DetailsAttribute
+                        label='Access Key'
+                        value={tokenUuid}
+                        copyValue={tokenUuid} />
+
+                    <DetailsAttribute
+                        label='Secret Key'
+                        value={tokenSecret}
+                        copyValue={tokenSecret} />
+
+                </TabPanel>
+
+                <TabPanel index={3} value={activeTab}>
+
+                    <DetailsAttribute
+                        label='Wget command'
+                        copyValue={wgetCommand}
+                        classValue={props.classes.detailsAttrValWithCode}>
+                        <DefaultCodeSnippet
+                            lines={[wgetCommand]} />
+                    </DetailsAttribute>
+
+                    <DetailsAttribute
+                        label='Curl command'
+                        copyValue={curlCommand}
+                        classValue={props.classes.detailsAttrValWithCode}>
+                        <DefaultCodeSnippet
+                            lines={[curlCommand]} />
+                    </DetailsAttribute>
+
+                    <p>
+                        Note: This curl command downloads single files.
+                        Append the desired filename to the end of the URL.
+                    </p>
+
+                </TabPanel>
+
+            </div>
+            <DialogActions>
+                <Button
+                    variant='text'
+                    color='primary'
+                    onClick={props.closeDialog}>
+                    Close
+                </Button>
+            </DialogActions>
+
+        </Dialog >;
+    }
+);
diff --git a/services/workbench2/src/views/all-processes-panel/all-processes-panel.tsx b/services/workbench2/src/views/all-processes-panel/all-processes-panel.tsx
new file mode 100644 (file)
index 0000000..88360eb
--- /dev/null
@@ -0,0 +1,169 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { connect, DispatchProp } from "react-redux";
+import { DataColumns } from "components/data-table/data-table";
+import { RouteComponentProps } from "react-router";
+import { DataTableFilterItem } from "components/data-table-filters/data-table-filters";
+import { SortDirection } from "components/data-table/data-column";
+import { ResourceKind } from "models/resource";
+import { ArvadosTheme } from "common/custom-theme";
+import { ALL_PROCESSES_PANEL_ID } from "store/all-processes-panel/all-processes-panel-action";
+import {
+    ProcessStatus,
+    ResourceName,
+    ResourceOwnerWithName,
+    ResourceType,
+    ContainerRunTime,
+    ResourceCreatedAtDate,
+} from "views-components/data-explorer/renderers";
+import { ProcessIcon } from "components/icon/icon";
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { navigateTo } from "store/navigation/navigation-action";
+import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
+import { RootState } from "store/store";
+import { createTree } from "models/tree";
+import { getInitialProcessStatusFilters, getInitialProcessTypeFilters } from "store/resource-type-filters/resource-type-filters";
+import { getProcess } from "store/processes/process";
+import { ResourcesState } from "store/resources/resources";
+import { toggleOne, deselectAllOthers } from "store/multiselect/multiselect-actions";
+
+type CssRules = "toolbar" | "button" | "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    toolbar: {
+        paddingBottom: theme.spacing.unit * 3,
+        textAlign: "right",
+    },
+    button: {
+        marginLeft: theme.spacing.unit,
+    },
+    root: {
+        width: "100%",
+    },
+});
+
+export enum AllProcessesPanelColumnNames {
+    NAME = "Name",
+    STATUS = "Status",
+    TYPE = "Type",
+    OWNER = "Owner",
+    CREATED_AT = "Created at",
+    RUNTIME = "Run Time",
+}
+
+export interface AllProcessesPanelFilter extends DataTableFilterItem {
+    type: ResourceKind | ContainerRequestState;
+}
+
+export const allProcessesPanelColumns: DataColumns<string, ContainerRequestResource> = [
+    {
+        name: AllProcessesPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: "name" },
+        filters: createTree(),
+        render: uuid => <ResourceName uuid={uuid} />,
+    },
+    {
+        name: AllProcessesPanelColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        mutuallyExclusiveFilters: true,
+        filters: getInitialProcessStatusFilters(),
+        render: uuid => <ProcessStatus uuid={uuid} />,
+    },
+    {
+        name: AllProcessesPanelColumnNames.TYPE,
+        selected: true,
+        configurable: true,
+        filters: getInitialProcessTypeFilters(),
+        render: uuid => <ResourceType uuid={uuid} />,
+    },
+    {
+        name: AllProcessesPanelColumnNames.OWNER,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceOwnerWithName uuid={uuid} />,
+    },
+    {
+        name: AllProcessesPanelColumnNames.CREATED_AT,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.DESC, field: "createdAt" },
+        filters: createTree(),
+        render: uuid => <ResourceCreatedAtDate uuid={uuid} />,
+    },
+    {
+        name: AllProcessesPanelColumnNames.RUNTIME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ContainerRunTime uuid={uuid} />,
+    },
+];
+
+interface AllProcessesPanelDataProps {
+    resources: ResourcesState;
+}
+
+interface AllProcessesPanelActionProps {
+    onItemClick: (item: string) => void;
+    onDialogOpen: (ownerUuid: string) => void;
+    onItemDoubleClick: (item: string) => void;
+}
+const mapStateToProps = (state: RootState): AllProcessesPanelDataProps => ({
+    resources: state.resources,
+});
+
+type AllProcessesPanelProps = AllProcessesPanelDataProps &
+    AllProcessesPanelActionProps &
+    DispatchProp &
+    WithStyles<CssRules> &
+    RouteComponentProps<{ id: string }>;
+
+export const AllProcessesPanel = withStyles(styles)(
+    connect(mapStateToProps)(
+        class extends React.Component<AllProcessesPanelProps> {
+            handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+                const process = getProcess(resourceUuid)(this.props.resources);
+                if (process) {
+                    this.props.dispatch<any>(openProcessContextMenu(event, process));
+                }
+                this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
+            };
+
+            handleRowDoubleClick = (uuid: string) => {
+                this.props.dispatch<any>(navigateTo(uuid));
+            };
+
+            handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
+                this.props.dispatch<any>(deselectAllOthers(uuid))
+                this.props.dispatch<any>(loadDetailsPanel(uuid));
+            };
+
+            render() {
+                return (
+                    <div className={this.props.classes.root}>
+                        <DataExplorer
+                            id={ALL_PROCESSES_PANEL_ID}
+                            onRowClick={this.handleRowClick}
+                            onRowDoubleClick={this.handleRowDoubleClick}
+                            onContextMenu={this.handleContextMenu}
+                            contextMenuColumn={true}
+                            defaultViewIcon={ProcessIcon}
+                            defaultViewMessages={["Processes list empty."]}
+                        />
+                    </div>
+                );
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/views/api-client-authorization-panel/api-client-authorization-panel-root.tsx b/services/workbench2/src/views/api-client-authorization-panel/api-client-authorization-panel-root.tsx
new file mode 100644 (file)
index 0000000..3d41574
--- /dev/null
@@ -0,0 +1,147 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback, WithStyles, withStyles
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ShareMeIcon } from 'components/icon/icon';
+import { createTree } from 'models/tree';
+import { DataColumns } from 'components/data-table/data-table';
+import { SortDirection } from 'components/data-table/data-column';
+import { API_CLIENT_AUTHORIZATION_PANEL_ID } from '../../store/api-client-authorizations/api-client-authorizations-actions';
+import { DataExplorer } from 'views-components/data-explorer/data-explorer';
+import { ResourcesState } from 'store/resources/resources';
+import {
+    CommonUuid, TokenApiClientId, TokenApiToken, TokenCreatedByIpAddress, TokenDefaultOwnerUuid, TokenExpiresAt,
+    TokenLastUsedAt, TokenLastUsedByIpAddress, TokenScopes, TokenUserId
+} from 'views-components/data-explorer/renderers';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    }
+});
+
+
+export enum ApiClientAuthorizationPanelColumnNames {
+    UUID = 'UUID',
+    API_CLIENT_ID = 'API Client ID',
+    API_TOKEN = 'API Token',
+    CREATED_BY_IP_ADDRESS = 'Created by IP address',
+    DEFAULT_OWNER_UUID = 'Default owner',
+    EXPIRES_AT = 'Expires at',
+    LAST_USED_AT = 'Last used at',
+    LAST_USED_BY_IP_ADDRESS = 'Last used by IP address',
+    SCOPES = 'Scopes',
+    USER_ID = 'User ID'
+}
+
+export const apiClientAuthorizationPanelColumns: DataColumns<string, ApiClientAuthorization> = [
+    {
+        name: ApiClientAuthorizationPanelColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "uuid"},
+        filters: createTree(),
+        render: uuid => <CommonUuid uuid={uuid} />
+    },
+    {
+        name: ApiClientAuthorizationPanelColumnNames.API_CLIENT_ID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <TokenApiClientId uuid={uuid} />
+    },
+    {
+        name: ApiClientAuthorizationPanelColumnNames.API_TOKEN,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <TokenApiToken uuid={uuid} />
+    },
+    {
+        name: ApiClientAuthorizationPanelColumnNames.CREATED_BY_IP_ADDRESS,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <TokenCreatedByIpAddress uuid={uuid} />
+    },
+    {
+        name: ApiClientAuthorizationPanelColumnNames.DEFAULT_OWNER_UUID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <TokenDefaultOwnerUuid uuid={uuid} />
+    },
+    {
+        name: ApiClientAuthorizationPanelColumnNames.EXPIRES_AT,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <TokenExpiresAt uuid={uuid} />
+    },
+    {
+        name: ApiClientAuthorizationPanelColumnNames.LAST_USED_AT,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <TokenLastUsedAt uuid={uuid} />
+    },
+    {
+        name: ApiClientAuthorizationPanelColumnNames.LAST_USED_BY_IP_ADDRESS,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <TokenLastUsedByIpAddress uuid={uuid} />
+    },
+    {
+        name: ApiClientAuthorizationPanelColumnNames.SCOPES,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <TokenScopes uuid={uuid} />
+    },
+    {
+        name: ApiClientAuthorizationPanelColumnNames.USER_ID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <TokenUserId uuid={uuid} />
+    }
+];
+
+const DEFAULT_MESSAGE = 'Your api client authorization list is empty.';
+
+export interface ApiClientAuthorizationPanelRootActionProps {
+    onItemClick: (item: string) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: string) => void;
+    onItemDoubleClick: (item: string) => void;
+}
+
+export interface ApiClientAuthorizationPanelRootDataProps {
+    resources: ResourcesState;
+}
+
+type ApiClientAuthorizationPanelRootProps = ApiClientAuthorizationPanelRootActionProps
+    & ApiClientAuthorizationPanelRootDataProps & WithStyles<CssRules>;
+
+export const ApiClientAuthorizationPanelRoot = withStyles(styles)(
+    ({ classes, onItemDoubleClick, onItemClick, onContextMenu }: ApiClientAuthorizationPanelRootProps) =>
+        <div className={classes.root}><DataExplorer
+            id={API_CLIENT_AUTHORIZATION_PANEL_ID}
+            onRowClick={onItemClick}
+            onRowDoubleClick={onItemDoubleClick}
+            onContextMenu={onContextMenu}
+            contextMenuColumn={true}
+            hideColumnSelector
+            hideSearchInput
+            defaultViewIcon={ShareMeIcon}
+            defaultViewMessages={[DEFAULT_MESSAGE]} />
+        </div>
+);
diff --git a/services/workbench2/src/views/api-client-authorization-panel/api-client-authorization-panel.tsx b/services/workbench2/src/views/api-client-authorization-panel/api-client-authorization-panel.tsx
new file mode 100644 (file)
index 0000000..9604bf5
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import {
+    ApiClientAuthorizationPanelRoot,
+    ApiClientAuthorizationPanelRootDataProps,
+    ApiClientAuthorizationPanelRootActionProps
+} from 'views/api-client-authorization-panel/api-client-authorization-panel-root';
+import { openApiClientAuthorizationContextMenu } from 'store/context-menu/context-menu-actions';
+
+const mapStateToProps = (state: RootState): ApiClientAuthorizationPanelRootDataProps => {
+    return {
+        resources: state.resources
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): ApiClientAuthorizationPanelRootActionProps => ({
+    onContextMenu: (event, apiClientAuthorization) => {
+        dispatch<any>(openApiClientAuthorizationContextMenu(event, apiClientAuthorization));
+    },
+    onItemClick: (resourceUuid: string) => { return; },
+    onItemDoubleClick: uuid => { return; },
+});
+
+export const ApiClientAuthorizationPanel = connect(mapStateToProps, mapDispatchToProps)(ApiClientAuthorizationPanelRoot);
\ No newline at end of file
diff --git a/services/workbench2/src/views/collection-content-address-panel/collection-content-address-panel.tsx b/services/workbench2/src/views/collection-content-address-panel/collection-content-address-panel.tsx
new file mode 100644 (file)
index 0000000..ea23ce5
--- /dev/null
@@ -0,0 +1,174 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Button
+} from '@material-ui/core';
+import { CollectionIcon } from 'components/icon/icon';
+import { ArvadosTheme } from 'common/custom-theme';
+import { BackIcon } from 'components/icon/icon';
+import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from 'store/collections-content-address-panel/collections-content-address-panel-actions';
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { Dispatch } from 'redux';
+import {
+    resourceUuidToContextMenuKind,
+    openContextMenu
+} from 'store/context-menu/context-menu-actions';
+import { ResourceKind } from 'models/resource';
+import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
+import { connect } from 'react-redux';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { DataColumns } from 'components/data-table/data-table';
+import { SortDirection } from 'components/data-table/data-column';
+import { createTree } from 'models/tree';
+import {
+    ResourceName,
+    ResourceOwnerName,
+    ResourceLastModifiedDate,
+    ResourceStatus
+} from 'views-components/data-explorer/renderers';
+import { getResource, ResourcesState } from 'store/resources/resources';
+import { RootState } from 'store/store';
+import { CollectionResource } from 'models/collection';
+
+type CssRules = 'backLink' | 'backIcon' | 'root' | 'content';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    backLink: {
+        fontSize: '12px',
+        fontWeight: 600,
+        display: 'flex',
+        alignItems: 'center',
+        padding: theme.spacing.unit,
+        marginBottom: theme.spacing.unit,
+        color: theme.palette.grey["500"],
+    },
+    backIcon: {
+        marginRight: theme.spacing.unit
+    },
+    root: {
+        width: '100%',
+    },
+    content: {
+        // reserve space for the content address bar
+        height: `calc(100% - ${theme.spacing.unit * 7}px)`,
+    },
+});
+
+enum CollectionContentAddressPanelColumnNames {
+    COLLECTION_WITH_THIS_ADDRESS = "Collection with this address",
+    STATUS = "Status",
+    LOCATION = "Location",
+    LAST_MODIFIED = "Last modified"
+}
+
+export const collectionContentAddressPanelColumns: DataColumns<string, CollectionResource> = [
+    {
+        name: CollectionContentAddressPanelColumnNames.COLLECTION_WITH_THIS_ADDRESS,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "uuid"},
+        filters: createTree(),
+        render: uuid => <ResourceName uuid={uuid} />
+    },
+    {
+        name: CollectionContentAddressPanelColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceStatus uuid={uuid} />
+    },
+    {
+        name: CollectionContentAddressPanelColumnNames.LOCATION,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceOwnerName uuid={uuid} />
+    },
+    {
+        name: CollectionContentAddressPanelColumnNames.LAST_MODIFIED,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.DESC, field: "modifiedAt"},
+        filters: createTree(),
+        render: uuid => <ResourceLastModifiedDate uuid={uuid} />
+    }
+];
+
+interface CollectionContentAddressPanelActionProps {
+    onContextMenu: (resources: ResourcesState) => (event: React.MouseEvent<any>, uuid: string) => void;
+    onItemClick: (item: string) => void;
+    onItemDoubleClick: (item: string) => void;
+}
+
+interface CollectionContentAddressPanelDataProps {
+    resources: ResourcesState;
+}
+
+const mapStateToProps = ({ resources }: RootState): CollectionContentAddressPanelDataProps => ({
+    resources
+})
+
+const mapDispatchToProps = (dispatch: Dispatch): CollectionContentAddressPanelActionProps => ({
+    onContextMenu: (resources: ResourcesState) => (event, resourceUuid) => {
+        const resource = getResource<CollectionResource>(resourceUuid)(resources);
+        const kind = dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
+        if (kind) {
+            dispatch<any>(openContextMenu(event, {
+                name: resource ? resource.name : '',
+                description: resource ? resource.description : '',
+                storageClassesDesired: resource ? resource.storageClassesDesired : [],
+                uuid: resourceUuid,
+                ownerUuid: '',
+                kind: ResourceKind.NONE,
+                menuKind: kind
+            }));
+        }
+        dispatch<any>(loadDetailsPanel(resourceUuid));
+    },
+    onItemClick: (uuid: string) => {
+        dispatch<any>(loadDetailsPanel(uuid));
+    },
+    onItemDoubleClick: uuid => {
+        dispatch<any>(navigateTo(uuid));
+    }
+});
+
+interface CollectionContentAddressDataProps {
+    match: {
+        params: { id: string }
+    };
+}
+
+export const CollectionsContentAddressPanel = withStyles(styles)(
+    connect(mapStateToProps, mapDispatchToProps)(
+        class extends React.Component<CollectionContentAddressPanelActionProps & CollectionContentAddressPanelDataProps & CollectionContentAddressDataProps & WithStyles<CssRules>> {
+            render() {
+                return <div className={this.props.classes.root}>
+                    <Button
+                        onClick={() => window.history.back()}
+                        className={this.props.classes.backLink}>
+                        <BackIcon className={this.props.classes.backIcon} />
+                        Back
+                    </Button>
+                    <div className={this.props.classes.content}><DataExplorer
+                        id={COLLECTIONS_CONTENT_ADDRESS_PANEL_ID}
+                        hideSearchInput
+                        onRowClick={this.props.onItemClick}
+                        onRowDoubleClick={this.props.onItemDoubleClick}
+                        onContextMenu={this.props.onContextMenu(this.props.resources)}
+                        contextMenuColumn={true}
+                        title={`Content address: ${this.props.match.params.id}`}
+                        defaultViewIcon={CollectionIcon}
+                        defaultViewMessages={['Collections with this content address not found.']} />
+                    </div>
+                </div>;
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/views/collection-panel/collection-panel.tsx b/services/workbench2/src/views/collection-panel/collection-panel.tsx
new file mode 100644 (file)
index 0000000..2898345
--- /dev/null
@@ -0,0 +1,372 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    IconButton,
+    Grid,
+    Tooltip,
+    Typography,
+    Card, CardHeader, CardContent
+} from '@material-ui/core';
+import { connect, DispatchProp } from "react-redux";
+import { RouteComponentProps } from 'react-router';
+import { ArvadosTheme } from 'common/custom-theme';
+import { RootState } from 'store/store';
+import { MoreVerticalIcon, CollectionIcon, ReadOnlyIcon, CollectionOldVersionIcon } from 'components/icon/icon';
+import { DetailsAttribute } from 'components/details-attribute/details-attribute';
+import { CollectionResource, getCollectionUrl } from 'models/collection';
+import { CollectionPanelFiles } from 'views-components/collection-panel-files/collection-panel-files';
+import { navigateToProcess } from 'store/collection-panel/collection-panel-action';
+import { getResource } from 'store/resources/resources';
+import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
+import { formatDate, formatFileSize } from "common/formatters";
+import { openDetailsPanel } from 'store/details-panel/details-panel-action';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { getPropertyChip } from 'views-components/resource-properties-form/property-chip';
+import { IllegalNamingWarning } from 'components/warning/warning';
+import { GroupResource } from 'models/group';
+import { UserResource } from 'models/user';
+import { getUserUuid } from 'common/getuser';
+import { Link } from 'react-router-dom';
+import { Link as ButtonLink } from '@material-ui/core';
+import { ResourceWithName, ResponsiblePerson } from 'views-components/data-explorer/renderers';
+import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
+import { resourceIsFrozen } from 'common/frozen-resources';
+import { NotFoundView } from 'views/not-found-panel/not-found-panel';
+
+type CssRules = 'root'
+    | 'button'
+    | 'infoCard'
+    | 'propertiesCard'
+    | 'filesCard'
+    | 'iconHeader'
+    | 'tag'
+    | 'label'
+    | 'value'
+    | 'link'
+    | 'centeredLabel'
+    | 'warningLabel'
+    | 'collectionName'
+    | 'readOnlyIcon'
+    | 'header'
+    | 'title'
+    | 'avatar'
+    | 'content';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    },
+    button: {
+        cursor: 'pointer'
+    },
+    infoCard: {
+    },
+    propertiesCard: {
+        padding: 0,
+    },
+    filesCard: {
+        padding: 0,
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL
+    },
+    tag: {
+        marginRight: theme.spacing.unit / 2,
+        marginBottom: theme.spacing.unit / 2
+    },
+    label: {
+        fontSize: '0.875rem',
+    },
+    centeredLabel: {
+        fontSize: '0.875rem',
+        textAlign: 'center'
+    },
+    warningLabel: {
+        fontStyle: 'italic'
+    },
+    collectionName: {
+        flexDirection: 'column',
+    },
+    value: {
+        textTransform: 'none',
+        fontSize: '0.875rem'
+    },
+    link: {
+        fontSize: '0.875rem',
+        color: theme.palette.primary.main,
+        '&:hover': {
+            cursor: 'pointer'
+        }
+    },
+    readOnlyIcon: {
+        marginLeft: theme.spacing.unit,
+        fontSize: 'small',
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.green700,
+    },
+    avatar: {
+        alignSelf: 'flex-start',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    content: {
+        padding: theme.spacing.unit * 1.0,
+        paddingTop: theme.spacing.unit * 0.5,
+        '&:last-child': {
+            paddingBottom: theme.spacing.unit * 1,
+        }
+    }
+});
+
+interface CollectionPanelDataProps {
+    item: CollectionResource;
+    isWritable: boolean;
+    isOldVersion: boolean;
+    isLoadingFiles: boolean;
+}
+
+type CollectionPanelProps = CollectionPanelDataProps & DispatchProp & WithStyles<CssRules>
+
+export const CollectionPanel = withStyles(styles)(connect(
+    (state: RootState, props: RouteComponentProps<{ id: string }>) => {
+        const currentUserUUID = getUserUuid(state);
+        const item = getResource<CollectionResource>(props.match.params.id)(state.resources);
+        let isWritable = false;
+        const isOldVersion = item && item.currentVersionUuid !== item.uuid;
+        if (item && !isOldVersion) {
+            if (item.ownerUuid === currentUserUUID) {
+                isWritable = true;
+            } else {
+                const itemOwner = getResource<GroupResource | UserResource>(item.ownerUuid)(state.resources);
+                if (itemOwner) {
+                    isWritable = itemOwner.canWrite;
+                }
+            }
+        }
+
+        if (item && isWritable) {
+            isWritable = !resourceIsFrozen(item, state.resources);
+        }
+
+        return { item, isWritable, isOldVersion };
+    })(
+        class extends React.Component<CollectionPanelProps> {
+            render() {
+                const { classes, item, dispatch, isWritable, isOldVersion } = this.props;
+                const panelsData: MPVPanelState[] = [
+                    { name: "Details" },
+                    { name: "Files" },
+                ];
+                return item
+                    ? <MPVContainer className={classes.root} spacing={8} direction="column" justify-content="flex-start" wrap="nowrap" panelStates={panelsData}>
+                        <MPVPanelContent xs="auto" data-cy='collection-info-panel'>
+                            <Card className={classes.infoCard}>
+                                <CardHeader
+                                    className={classes.header}
+                                    classes={{
+                                        content: classes.title,
+                                        avatar: classes.avatar,
+                                    }}
+                                    avatar={<IconButton onClick={this.openCollectionDetails}>
+                                        {isOldVersion
+                                            ? <CollectionOldVersionIcon className={classes.iconHeader} />
+                                            : <CollectionIcon className={classes.iconHeader} />}
+                                    </IconButton>}
+                                    title={
+                                        <span>
+                                            <IllegalNamingWarning name={item.name} />
+                                            {item.name}
+                                            {isWritable ||
+                                                <Tooltip title="Read-only">
+                                                    <ReadOnlyIcon data-cy="read-only-icon" className={classes.readOnlyIcon} />
+                                                </Tooltip>}
+                                        </span>
+                                    }
+                                    action={
+                                        <Tooltip title="Actions" disableFocusListener>
+                                            <IconButton
+                                                data-cy='collection-panel-options-btn'
+                                                aria-label="Actions"
+                                                onClick={this.handleContextMenu}>
+                                                <MoreVerticalIcon />
+                                            </IconButton>
+                                        </Tooltip>
+                                    }
+                                />
+                                <CardContent className={classes.content}>
+                                    <Typography variant="caption">
+                                        {item.description}
+                                    </Typography>
+                                    <CollectionDetailsAttributes item={item} classes={classes} twoCol={true} showVersionBrowser={() => dispatch<any>(openDetailsPanel(item.uuid, 1))} />
+                                    {(item.properties.container_request || item.properties.containerRequest) &&
+                                        <span onClick={() => dispatch<any>(navigateToProcess(item.properties.container_request || item.properties.containerRequest))}>
+                                            <DetailsAttribute classLabel={classes.link} label='Link to process' />
+                                        </span>
+                                    }
+                                    {isOldVersion &&
+                                        <Typography className={classes.warningLabel} variant="caption">
+                                            This is an old version. Make a copy to make changes. Go to the <Link to={getCollectionUrl(item.currentVersionUuid)}>head version</Link> for sharing options.
+                                        </Typography>
+                                    }
+                                </CardContent>
+                            </Card>
+                        </MPVPanelContent>
+                        <MPVPanelContent xs>
+                            <Card className={classes.filesCard}>
+                                <CollectionPanelFiles isWritable={isWritable} />
+                            </Card>
+                        </MPVPanelContent>
+                    </MPVContainer >
+                    : <NotFoundView
+                        icon={CollectionIcon}
+                        messages={["Collection not found"]}
+                    />
+                    ;
+            }
+
+            handleContextMenu = (event: React.MouseEvent<any>) => {
+                const { uuid, ownerUuid, name, description,
+                    kind, storageClassesDesired, properties } = this.props.item;
+                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(uuid));
+                const resource = {
+                    uuid,
+                    ownerUuid,
+                    name,
+                    description,
+                    storageClassesDesired,
+                    kind,
+                    menuKind,
+                    properties,
+                };
+                // Avoid expanding/collapsing the panel
+                event.stopPropagation();
+                this.props.dispatch<any>(openContextMenu(event, resource));
+            }
+
+            onCopy = (message: string) =>
+                this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }))
+
+            openCollectionDetails = (e: React.MouseEvent<HTMLElement>) => {
+                const { item } = this.props;
+                if (item) {
+                    e.stopPropagation();
+                    this.props.dispatch<any>(openDetailsPanel(item.uuid));
+                }
+            }
+
+            titleProps = {
+                onClick: this.openCollectionDetails
+            };
+
+        }
+    )
+);
+
+interface CollectionDetailsProps {
+    item: CollectionResource;
+    classes?: any;
+    twoCol?: boolean;
+    showVersionBrowser?: () => void;
+}
+
+export const CollectionDetailsAttributes = (props: CollectionDetailsProps) => {
+    const item = props.item;
+    const classes = props.classes || { label: '', value: '', button: '', tag: '' };
+    const isOldVersion = item && item.currentVersionUuid !== item.uuid;
+    const mdSize = props.twoCol ? 6 : 12;
+    const showVersionBrowser = props.showVersionBrowser;
+    const responsiblePersonRef = React.useRef(null);
+    return <Grid container>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label={isOldVersion ? "This version's UUID" : "Collection UUID"}
+                linkToUuid={item.uuid} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label={isOldVersion ? "This version's PDH" : "Portable data hash"}
+                linkToUuid={item.portableDataHash} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Owner' linkToUuid={item.ownerUuid}
+                uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+        </Grid>
+        <div data-cy="responsible-person-wrapper" ref={responsiblePersonRef}>
+            <Grid item xs={12} md={12}>
+                <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                    label='Responsible person' linkToUuid={item.ownerUuid}
+                    uuidEnhancer={(uuid: string) => <ResponsiblePerson uuid={item.uuid} parentRef={responsiblePersonRef.current} />} />
+            </Grid>
+        </div>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Head version'
+                value={isOldVersion ? undefined : 'this one'}
+                linkToUuid={isOldVersion ? item.currentVersionUuid : undefined} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute
+                classLabel={classes.label} classValue={classes.value}
+                label='Version number'
+                value={showVersionBrowser !== undefined
+                    ? <Tooltip title="Open version browser"><ButtonLink underline='none' className={classes.button} onClick={() => showVersionBrowser()}>
+                        {<span data-cy='collection-version-number'>{item.version}</span>}
+                    </ButtonLink></Tooltip>
+                    : item.version
+                }
+            />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Created at' value={formatDate(item.createdAt)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Last modified' value={formatDate(item.modifiedAt)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Number of files' value={<span data-cy='collection-file-count'>{item.fileCount}</span>} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Content size' value={formatFileSize(item.fileSizeTotal)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Storage classes' value={item.storageClassesDesired ? item.storageClassesDesired.join(', ') : ["default"]} />
+        </Grid>
+
+        {/*
+            NOTE: The property list should be kept at the bottom, because it spans
+            the entire available width, without regards of the twoCol prop.
+          */}
+        <Grid item xs={12} md={12}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Properties' />
+            {Object.keys(item.properties).length > 0
+                ? Object.keys(item.properties).map(k =>
+                    Array.isArray(item.properties[k])
+                        ? item.properties[k].map((v: string) =>
+                            getPropertyChip(k, v, undefined, classes.tag))
+                        : getPropertyChip(k, item.properties[k], undefined, classes.tag))
+                : <div className={classes.value}>No properties</div>}
+        </Grid>
+    </Grid>;
+};
diff --git a/services/workbench2/src/views/favorite-panel/favorite-panel.tsx b/services/workbench2/src/views/favorite-panel/favorite-panel.tsx
new file mode 100644 (file)
index 0000000..2409870
--- /dev/null
@@ -0,0 +1,193 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { connect, DispatchProp } from 'react-redux';
+import { DataColumns } from 'components/data-table/data-table';
+import { RouteComponentProps } from 'react-router';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
+import { ResourceKind } from 'models/resource';
+import { ArvadosTheme } from 'common/custom-theme';
+import { FAVORITE_PANEL_ID } from "store/favorite-panel/favorite-panel-action";
+import {
+    ProcessStatus,
+    ResourceFileSize,
+    ResourceLastModifiedDate,
+    ResourceName,
+    ResourceOwnerWithName,
+    ResourceType
+} from 'views-components/data-explorer/renderers';
+import { FavoriteIcon } from 'components/icon/icon';
+import {
+    openContextMenu,
+    resourceUuidToContextMenuKind
+} from 'store/context-menu/context-menu-actions';
+import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { ContainerRequestState } from "models/container-request";
+import { FavoritesState } from 'store/favorites/favorites-reducer';
+import { RootState } from 'store/store';
+import { createTree } from 'models/tree';
+import { getSimpleObjectTypeFilters } from 'store/resource-type-filters/resource-type-filters';
+import { getResource, ResourcesState } from 'store/resources/resources';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { GroupClass, GroupResource } from 'models/group';
+import { getProperty } from 'store/properties/properties';
+import { PROJECT_PANEL_CURRENT_UUID } from 'store/project-panel/project-panel-action';
+import { CollectionResource } from 'models/collection';
+import { toggleOne, deselectAllOthers } from 'store/multiselect/multiselect-actions';
+
+type CssRules = "toolbar" | "button" | "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    toolbar: {
+        paddingBottom: theme.spacing.unit * 3,
+        textAlign: "right"
+    },
+    button: {
+        marginLeft: theme.spacing.unit
+    },
+    root: {
+        width: '100%',
+    },
+});
+
+export enum FavoritePanelColumnNames {
+    NAME = "Name",
+    STATUS = "Status",
+    TYPE = "Type",
+    OWNER = "Owner",
+    FILE_SIZE = "File size",
+    LAST_MODIFIED = "Last modified"
+}
+
+export interface FavoritePanelFilter extends DataTableFilterItem {
+    type: ResourceKind | ContainerRequestState;
+}
+
+export const favoritePanelColumns: DataColumns<string, GroupContentsResource> = [
+    {
+        name: FavoritePanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceName uuid={uuid} />
+    },
+    {
+        name: "Status",
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ProcessStatus uuid={uuid} />
+    },
+    {
+        name: FavoritePanelColumnNames.TYPE,
+        selected: true,
+        configurable: true,
+        filters: getSimpleObjectTypeFilters(),
+        render: uuid => <ResourceType uuid={uuid} />
+    },
+    {
+        name: FavoritePanelColumnNames.OWNER,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceOwnerWithName uuid={uuid} />
+    },
+    {
+        name: FavoritePanelColumnNames.FILE_SIZE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceFileSize uuid={uuid} />
+    },
+    {
+        name: FavoritePanelColumnNames.LAST_MODIFIED,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLastModifiedDate uuid={uuid} />
+    }
+];
+
+interface FavoritePanelDataProps {
+    currentItemId: any;
+    favorites: FavoritesState;
+    resources: ResourcesState;
+    userUuid: string;
+}
+
+interface FavoritePanelActionProps {
+    onItemClick: (item: string) => void;
+    onDialogOpen: (ownerUuid: string) => void;
+    onItemDoubleClick: (item: string) => void;
+}
+const mapStateToProps = (state : RootState): FavoritePanelDataProps => ({
+    favorites: state.favorites,
+    resources: state.resources,
+    userUuid: state.auth.user!.uuid,
+    currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties),
+});
+
+type FavoritePanelProps = FavoritePanelDataProps & FavoritePanelActionProps & DispatchProp
+    & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+
+export const FavoritePanel = withStyles(styles)(
+    connect(mapStateToProps)(
+        class extends React.Component<FavoritePanelProps> {
+
+            handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+                const { resources } = this.props;
+                const resource = getResource<GroupContentsResource>(resourceUuid)(resources);
+
+                let readonly = false;
+                const project = getResource<GroupResource>(this.props.currentItemId)(resources);
+
+                if (project && project.groupClass === GroupClass.FILTER) {
+                    readonly = true;
+                }
+
+                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid, readonly));
+
+                if (menuKind && resource) {
+                    this.props.dispatch<any>(openContextMenu(event, {
+                        name: resource.name,
+                        uuid: resource.uuid,
+                        ownerUuid: resource.ownerUuid,
+                        isTrashed: ('isTrashed' in resource) ? resource.isTrashed: false,
+                        kind: resource.kind,
+                        menuKind,
+                        description: resource.description,
+                        storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
+                    }));
+                }
+                this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
+            }
+
+            handleRowDoubleClick = (uuid: string) => {
+                this.props.dispatch<any>(navigateTo(uuid));
+            }
+
+            handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
+                this.props.dispatch<any>(deselectAllOthers(uuid))
+                this.props.dispatch<any>(loadDetailsPanel(uuid));
+            }
+
+            render() {
+                return <div className={this.props.classes.root}><DataExplorer
+                    id={FAVORITE_PANEL_ID}
+                    onRowClick={this.handleRowClick}
+                    onRowDoubleClick={this.handleRowDoubleClick}
+                    onContextMenu={this.handleContextMenu}
+                    contextMenuColumn={true}
+                    defaultViewIcon={FavoriteIcon}
+                    defaultViewMessages={['Your favorites list is empty.']} />
+                </div>;
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/views/group-details-panel/group-details-panel.tsx b/services/workbench2/src/views/group-details-panel/group-details-panel.tsx
new file mode 100644 (file)
index 0000000..fdbc204
--- /dev/null
@@ -0,0 +1,229 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { DataColumns } from 'components/data-table/data-table';
+import { ResourceLinkHeadUuid, ResourceLinkTailUsername, ResourceLinkHeadPermissionLevel, ResourceLinkTailPermissionLevel, ResourceLinkHead, ResourceLinkTail, ResourceLinkDelete, ResourceLinkTailAccountStatus, ResourceLinkTailIsVisible } from 'views-components/data-explorer/renderers';
+import { createTree } from 'models/tree';
+import { noop } from 'lodash/fp';
+import { RootState } from 'store/store';
+import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID, openAddGroupMembersDialog, getCurrentGroupDetailsPanelUuid } from 'store/group-details-panel/group-details-panel-actions';
+import { openContextMenu } from 'store/context-menu/context-menu-actions';
+import { ResourcesState, getResource } from 'store/resources/resources';
+import { Grid, Button, Tabs, Tab, Paper, WithStyles, withStyles, StyleRulesCallback } from '@material-ui/core';
+import { AddIcon, UserPanelIcon, KeyIcon } from 'components/icon/icon';
+import { getUserUuid } from 'common/getuser';
+import { GroupResource, isBuiltinGroup } from 'models/group';
+import { ArvadosTheme } from 'common/custom-theme';
+import { PermissionResource } from 'models/permission';
+
+type CssRules = "root" | "content";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    },
+    content: {
+        // reserve space for the tab bar
+        height: `calc(100% - ${theme.spacing.unit * 7}px)`,
+    }
+});
+
+export enum GroupDetailsPanelMembersColumnNames {
+    FULL_NAME = "Name",
+    USERNAME = "Username",
+    STATUS = "Account Status",
+    VISIBLE = "Visible to other members",
+    PERMISSION = "Permission",
+    REMOVE = "Remove",
+}
+
+export enum GroupDetailsPanelPermissionsColumnNames {
+    NAME = "Name",
+    PERMISSION = "Permission",
+    UUID = "UUID",
+    REMOVE = "Remove",
+}
+
+const MEMBERS_DEFAULT_MESSAGE = 'Members list is empty.';
+const PERMISSIONS_DEFAULT_MESSAGE = 'Permissions list is empty.';
+
+export const groupDetailsMembersPanelColumns: DataColumns<string, PermissionResource> = [
+    {
+        name: GroupDetailsPanelMembersColumnNames.FULL_NAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkTail uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelMembersColumnNames.USERNAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkTailUsername uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelMembersColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkTailAccountStatus uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelMembersColumnNames.VISIBLE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkTailIsVisible uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelMembersColumnNames.PERMISSION,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkTailPermissionLevel uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelMembersColumnNames.REMOVE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkDelete uuid={uuid} />
+    },
+];
+
+export const groupDetailsPermissionsPanelColumns: DataColumns<string, PermissionResource> = [
+    {
+        name: GroupDetailsPanelPermissionsColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHead uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelPermissionsColumnNames.PERMISSION,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHeadPermissionLevel uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelPermissionsColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHeadUuid uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelPermissionsColumnNames.REMOVE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkDelete uuid={uuid} />
+    },
+];
+
+const mapStateToProps = (state: RootState) => {
+    const groupUuid = getCurrentGroupDetailsPanelUuid(state.properties);
+    const group = getResource<GroupResource>(groupUuid || '')(state.resources);
+    const userUuid = getUserUuid(state);
+
+    return {
+        resources: state.resources,
+        groupCanManage: userUuid && !isBuiltinGroup(group?.uuid || '')
+            ? group?.canManage
+            : false,
+    };
+};
+
+const mapDispatchToProps = {
+    onContextMenu: openContextMenu,
+    onAddUser: openAddGroupMembersDialog,
+};
+
+export interface GroupDetailsPanelProps {
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
+    onAddUser: () => void;
+    resources: ResourcesState;
+    groupCanManage: boolean;
+}
+
+export const GroupDetailsPanel = withStyles(styles)(connect(
+    mapStateToProps, mapDispatchToProps
+)(
+    class GroupDetailsPanel extends React.Component<GroupDetailsPanelProps & WithStyles<CssRules>> {
+        state = {
+            value: 0,
+        };
+
+        componentDidMount() {
+            this.setState({ value: 0 });
+        }
+
+        render() {
+            const { value } = this.state;
+            return (
+                <Paper className={this.props.classes.root}>
+                    <Tabs value={value} onChange={this.handleChange} variant="fullWidth">
+                        <Tab data-cy="group-details-members-tab" label="MEMBERS" />
+                        <Tab data-cy="group-details-permissions-tab" label="PERMISSIONS" />
+                    </Tabs>
+                    <div className={this.props.classes.content}>
+                        {value === 0 &&
+                            <DataExplorer
+                                id={GROUP_DETAILS_MEMBERS_PANEL_ID}
+                                data-cy="group-members-data-explorer"
+                                onRowClick={noop}
+                                onRowDoubleClick={noop}
+                                onContextMenu={noop}
+                                contextMenuColumn={false}
+                                defaultViewIcon={UserPanelIcon}
+                                defaultViewMessages={[MEMBERS_DEFAULT_MESSAGE]}
+                                hideColumnSelector
+                                hideSearchInput
+                                actions={
+                                    this.props.groupCanManage &&
+                                    <Grid container justify='flex-end'>
+                                        <Button
+                                            data-cy="group-member-add"
+                                            variant="contained"
+                                            color="primary"
+                                            onClick={this.props.onAddUser}>
+                                            <AddIcon /> Add user
+                                        </Button>
+                                    </Grid>
+                                }
+                                paperProps={{
+                                    elevation: 0,
+                                }} />
+                        }
+                        {value === 1 &&
+                            <DataExplorer
+                                id={GROUP_DETAILS_PERMISSIONS_PANEL_ID}
+                                data-cy="group-permissions-data-explorer"
+                                onRowClick={noop}
+                                onRowDoubleClick={noop}
+                                onContextMenu={noop}
+                                contextMenuColumn={false}
+                                defaultViewIcon={KeyIcon}
+                                defaultViewMessages={[PERMISSIONS_DEFAULT_MESSAGE]}
+                                hideColumnSelector
+                                hideSearchInput
+                                paperProps={{
+                                    elevation: 0,
+                                }} />
+                        }
+                    </div>
+                </Paper>
+            );
+        }
+
+        handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+            this.setState({ value });
+        }
+    }));
diff --git a/services/workbench2/src/views/groups-panel/groups-panel.tsx b/services/workbench2/src/views/groups-panel/groups-panel.tsx
new file mode 100644 (file)
index 0000000..e7f682b
--- /dev/null
@@ -0,0 +1,122 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { Grid, Button, StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { DataColumns } from 'components/data-table/data-table';
+import { SortDirection } from 'components/data-table/data-column';
+import { GroupMembersCount, ResourceUuid } from 'views-components/data-explorer/renderers';
+import { AddIcon } from 'components/icon/icon';
+import { ResourceName } from 'views-components/data-explorer/renderers';
+import { createTree } from 'models/tree';
+import { GROUPS_PANEL_ID, openCreateGroupDialog } from 'store/groups-panel/groups-panel-actions';
+import { noop } from 'lodash/fp';
+import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
+import { getResource, ResourcesState } from 'store/resources/resources';
+import { GroupResource } from 'models/group';
+import { RootState } from 'store/store';
+import { openContextMenu } from 'store/context-menu/context-menu-actions';
+import { ArvadosTheme } from 'common/custom-theme';
+
+type CssRules = "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    }
+});
+
+export enum GroupsPanelColumnNames {
+    GROUP = "Name",
+    UUID = "UUID",
+    MEMBERS = "Members",
+}
+
+export const groupsPanelColumns: DataColumns<string, GroupResource> = [
+    {
+        name: GroupsPanelColumnNames.GROUP,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.ASC, field: "name"},
+        filters: createTree(),
+        render: uuid => <ResourceName uuid={uuid} />
+    },
+    {
+        name: GroupsPanelColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceUuid uuid={uuid} />,
+    },
+    {
+        name: GroupsPanelColumnNames.MEMBERS,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <GroupMembersCount uuid={uuid} />,
+    },
+];
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        resources: state.resources
+    };
+};
+
+const mapDispatchToProps = {
+    onContextMenu: openContextMenu,
+    onNewGroup: openCreateGroupDialog,
+};
+
+export interface GroupsPanelProps {
+    onNewGroup: () => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
+    resources: ResourcesState;
+}
+
+export const GroupsPanel = withStyles(styles)(connect(
+    mapStateToProps, mapDispatchToProps
+)(
+    class GroupsPanel extends React.Component<GroupsPanelProps & WithStyles<CssRules>> {
+
+        render() {
+            return (
+                <div className={this.props.classes.root}><DataExplorer
+                    id={GROUPS_PANEL_ID}
+                    data-cy="groups-panel-data-explorer"
+                    onRowClick={noop}
+                    onRowDoubleClick={noop}
+                    onContextMenu={this.handleContextMenu}
+                    contextMenuColumn={true}
+                    hideColumnSelector
+                    actions={
+                        <Grid container justify='flex-end'>
+                            <Button
+                                data-cy="groups-panel-new-group"
+                                variant="contained"
+                                color="primary"
+                                onClick={this.props.onNewGroup}>
+                                <AddIcon /> New group
+                        </Button>
+                        </Grid>
+                    } /></div>
+            );
+        }
+
+        handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+            const resource = getResource<GroupResource>(resourceUuid)(this.props.resources);
+            if (resource) {
+                this.props.onContextMenu(event, {
+                    name: resource.name,
+                    uuid: resource.uuid,
+                    description: resource.description,
+                    ownerUuid: resource.ownerUuid,
+                    kind: resource.kind,
+                    menuKind: ContextMenuKind.GROUPS
+                });
+            }
+        }
+    }));
diff --git a/services/workbench2/src/views/inactive-panel/inactive-panel.test.tsx b/services/workbench2/src/views/inactive-panel/inactive-panel.test.tsx
new file mode 100644 (file)
index 0000000..c694f9d
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { mount, configure } from 'enzyme';
+import Adapter from "enzyme-adapter-react-16";
+import { CustomTheme } from 'common/custom-theme';
+import { InactivePanelStateProps, CssRules, InactivePanelRoot } from './inactive-panel';
+import { MuiThemeProvider, StyledComponentProps } from '@material-ui/core';
+
+configure({ adapter: new Adapter() });
+
+describe('InactivePanel', () => {
+    let props: InactivePanelStateProps & StyledComponentProps<CssRules>;
+
+    beforeEach(() => {
+        props = {
+            classes: {
+                root: 'root',
+                title: 'title',
+                ontop: 'ontop',
+            },
+            isLoginClusterFederation: false,
+            inactivePageText: 'Inactive page content',
+        };
+    });
+
+    it('should render content and link account option', () => {
+        // given
+        const expectedMessage = "Inactive page content";
+        const expectedLinkAccountText = 'If you would like to use this login to access another account click "Link Account"';
+
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <InactivePanelRoot {...props} />
+            </MuiThemeProvider>
+            );
+
+        // then
+        expect(wrapper.find('p').first().text()).toContain(expectedMessage);
+        expect(wrapper.find('p').at(1).text()).toContain(expectedLinkAccountText);
+    })
+
+    it('should render content and link account warning on LoginCluster federations', () => {
+        // given
+        props.isLoginClusterFederation = true;
+        const expectedMessage = "Inactive page content";
+        const expectedLinkAccountText = 'If you would like to use this login to access another account, please contact your administrator';
+
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <InactivePanelRoot {...props} />
+            </MuiThemeProvider>
+            );
+
+        // then
+        expect(wrapper.find('p').first().text()).toContain(expectedMessage);
+        expect(wrapper.find('p').at(1).text()).toContain(expectedLinkAccountText);
+    })
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views/inactive-panel/inactive-panel.tsx b/services/workbench2/src/views/inactive-panel/inactive-panel.tsx
new file mode 100644 (file)
index 0000000..be76570
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { Grid, Typography, Button } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { navigateToLinkAccount } from 'store/navigation/navigation-action';
+import { RootState } from 'store/store';
+import { sanitizeHTML } from 'common/html-sanitize';
+
+export type CssRules = 'root' | 'ontop' | 'title';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        position: 'relative',
+        backgroundColor: theme.palette.grey["200"],
+        background: 'url("arvados-logo-big.png") no-repeat center center',
+        backgroundBlendMode: 'soft-light',
+    },
+    ontop: {
+        zIndex: 10
+    },
+    title: {
+        marginBottom: theme.spacing.unit * 6,
+        color: theme.palette.grey["800"]
+    }
+});
+
+export interface InactivePanelActionProps {
+    startLinking: () => void;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): InactivePanelActionProps => ({
+    startLinking: () => {
+        dispatch<any>(navigateToLinkAccount);
+    }
+});
+
+const mapStateToProps = (state: RootState): InactivePanelStateProps => ({
+    inactivePageText: state.auth.config.clusterConfig.Workbench.InactivePageHTML,
+    isLoginClusterFederation: state.auth.config.clusterConfig.Login.LoginCluster !== '',
+});
+
+export interface InactivePanelStateProps {
+    inactivePageText: string;
+    isLoginClusterFederation: boolean;
+}
+
+type InactivePanelProps = WithStyles<CssRules> & InactivePanelActionProps & InactivePanelStateProps;
+
+export const InactivePanelRoot = ({ classes, startLinking, inactivePageText, isLoginClusterFederation }: InactivePanelProps) =>
+    <Grid container justify="center" alignItems="center" direction="column" spacing={24}
+        className={classes.root}
+        style={{ marginTop: 56, height: "100%" }}>
+        <Grid item>
+            <Typography>
+                <span dangerouslySetInnerHTML={{ __html: sanitizeHTML(inactivePageText) }} style={{ margin: "1em" }} />
+            </Typography>
+        </Grid>
+        { !isLoginClusterFederation
+        ? <><Grid item>
+            <Typography align="center">
+            If you would like to use this login to access another account click "Link Account".
+            </Typography>
+        </Grid>
+        <Grid item>
+            <Button className={classes.ontop} color="primary" variant="contained" onClick={() => startLinking()}>
+                Link Account
+            </Button>
+        </Grid></>
+        : <><Grid item>
+            <Typography align="center">
+                If you would like to use this login to access another account, please contact your administrator.
+            </Typography>
+        </Grid></> }
+    </Grid >;
+
+export const InactivePanel = connect(mapStateToProps, mapDispatchToProps)(
+    withStyles(styles)(InactivePanelRoot));
diff --git a/services/workbench2/src/views/instance-types-panel/instance-types-panel.test.tsx b/services/workbench2/src/views/instance-types-panel/instance-types-panel.test.tsx
new file mode 100644 (file)
index 0000000..a9d87b9
--- /dev/null
@@ -0,0 +1,112 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { configure, mount } from "enzyme";
+import { InstanceTypesPanel, calculateKeepBufferOverhead, discountRamByPercent } from './instance-types-panel';
+import Adapter from "enzyme-adapter-react-16";
+import { combineReducers, createStore } from "redux";
+import { Provider } from "react-redux";
+import { formatFileSize, formatCWLResourceSize } from 'common/formatters';
+
+configure({ adapter: new Adapter() });
+
+describe('<InstanceTypesPanel />', () => {
+
+    // let props;
+    let store;
+
+    const initialAuthState = {
+        config: {
+            clusterConfig: {
+                InstanceTypes: {
+                    "normalType" : {
+                        ProviderType: "provider",
+                        Price: 0.123,
+                        VCPUs: 6,
+                        Preemptible: false,
+                        IncludedScratch: 1000,
+                        RAM: 5000,
+                    },
+                    "gpuType" : {
+                        ProviderType: "gpuProvider",
+                        Price: 0.456,
+                        VCPUs: 8,
+                        Preemptible: true,
+                        IncludedScratch: 500,
+                        RAM: 6000,
+                        CUDA: {
+                            DeviceCount: 1,
+                            HardwareCapability: '8.6',
+                            DriverVersion: '11.4',
+                        },
+                    },
+                },
+                Containers: {
+                    ReserveExtraRAM: 1000,
+                }
+            }
+        }
+    }
+
+    beforeEach(() => {
+
+        store = createStore(combineReducers({
+            auth: (state: any = initialAuthState, action: any) => state,
+        }));
+    });
+
+    it('renders instance types', () => {
+        // when
+        const panel = mount(
+            <Provider store={store}>
+                <InstanceTypesPanel />
+            </Provider>);
+
+        // then
+        Object.keys(initialAuthState.config.clusterConfig.InstanceTypes).forEach((instanceKey) => {
+            const instanceType = initialAuthState.config.clusterConfig.InstanceTypes[instanceKey];
+            const item = panel.find(`Grid[data-cy="${instanceKey}"]`)
+
+            expect(item.find('h6').text()).toContain(instanceKey);
+            expect(item.text()).toContain(`Provider type${instanceType.ProviderType}`);
+            expect(item.text()).toContain(`Price$${instanceType.Price}`);
+            expect(item.text()).toContain(`Cores${instanceType.VCPUs}`);
+            expect(item.text()).toContain(`Preemptible${instanceType.Preemptible.toString()}`);
+            expect(item.text()).toContain(`Max disk request${formatCWLResourceSize(instanceType.IncludedScratch)} (${formatFileSize(instanceType.IncludedScratch)})`);
+            if (instanceType.CUDA && instanceType.CUDA.DeviceCount > 0) {
+                expect(item.text()).toContain(`CUDA GPUs${instanceType.CUDA.DeviceCount}`);
+                expect(item.text()).toContain(`Hardware capability${instanceType.CUDA.HardwareCapability}`);
+                expect(item.text()).toContain(`Driver version${instanceType.CUDA.DriverVersion}`);
+            }
+        });
+    });
+});
+
+describe('calculateKeepBufferOverhead', () => {
+    it('should calculate correct buffer size', () => {
+        const testCases = [
+            {input: 0, output: (220<<20)},
+            {input: 1, output: (220<<20) + ((1<<26) * (11/10))},
+            {input: 2, output: (220<<20) + 2*((1<<26) * (11/10))},
+        ];
+
+        for (const {input, output} of testCases) {
+            expect(calculateKeepBufferOverhead(input)).toBe(output);
+        }
+    });
+});
+
+describe('discountRamByPercent', () => {
+    it('should inflate ram requirement by 5% of final amount', () => {
+        const testCases = [
+            {input: 0, output: 0},
+            {input: 114, output: 120},
+        ];
+
+        for (const {input, output} of testCases) {
+            expect(discountRamByPercent(input)).toBe(output);
+        }
+    });
+});
diff --git a/services/workbench2/src/views/instance-types-panel/instance-types-panel.tsx b/services/workbench2/src/views/instance-types-panel/instance-types-panel.tsx
new file mode 100644 (file)
index 0000000..2f240c8
--- /dev/null
@@ -0,0 +1,148 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Typography, Grid } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ResourceIcon } from 'components/icon/icon';
+import { RootState } from 'store/store';
+import { connect } from 'react-redux';
+import { ClusterConfigJSON } from 'common/config';
+import { NotFoundView } from 'views/not-found-panel/not-found-panel';
+import { formatCWLResourceSize, formatCost, formatFileSize } from 'common/formatters';
+import { DetailsAttribute } from 'components/details-attribute/details-attribute';
+import { DefaultCodeSnippet } from 'components/default-code-snippet/default-code-snippet';
+
+type CssRules = 'root' | 'infoBox' | 'instanceType';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+       width: "calc(100% + 20px)",
+       margin: "0 -10px",
+       overflow: 'auto'
+    },
+    infoBox: {
+        padding: "0 10px 10px",
+    },
+    instanceType: {
+        padding: "10px",
+    },
+});
+
+type InstanceTypesPanelConnectedProps = {config: ClusterConfigJSON};
+
+type InstanceTypesPanelRootProps = InstanceTypesPanelConnectedProps & WithStyles<CssRules>;
+
+const mapStateToProps = ({auth}: RootState): InstanceTypesPanelConnectedProps => ({
+    config: auth.config.clusterConfig,
+});
+
+export const InstanceTypesPanel = withStyles(styles)(connect(mapStateToProps)(
+    ({ config, classes }: InstanceTypesPanelRootProps) => {
+
+        const instances = config.InstanceTypes || {};
+
+        return <Grid className={classes.root} container direction="row">
+            <Grid className={classes.infoBox} item xs={12}>
+                <Card>
+                    <CardContent>
+                        <Typography variant="body2">
+                            These are the cloud compute instance types
+                            configured for this cluster. The core count and
+                            maximum RAM request correspond to the greatest
+                            values you can put in the CWL Workflow
+                            ResourceRequest{" "}
+                            <DefaultCodeSnippet
+                                inline
+                                lines={["minCores"]}
+                            />{" "}
+                            and{" "}
+                            <DefaultCodeSnippet inline lines={["minRAM"]} />{" "}
+                            and still be scheduled on that instance type.
+                        </Typography>
+                    </CardContent>
+                </Card>
+            </Grid>
+            {Object.keys(instances).length > 0 ?
+                Object.keys(instances)
+                    .sort((a, b) => {
+                        const typeA = instances[a];
+                        const typeB = instances[b];
+
+                        if (typeA.Price !== typeB.Price) {
+                            return typeA.Price - typeB.Price;
+                        } else {
+                            return typeA.ProviderType.localeCompare(typeB.ProviderType);
+                        }
+                    }).map((instanceKey) => {
+                        const instanceType = instances[instanceKey];
+                        const maxDiskRequest = instanceType.IncludedScratch;
+                        const keepBufferOverhead = calculateKeepBufferOverhead(instanceType.VCPUs);
+                        const maxRamRequest = discountRamByPercent(instanceType.RAM - config.Containers.ReserveExtraRAM - keepBufferOverhead);
+
+                        return <Grid data-cy={instanceKey} className={classes.instanceType} item sm={6} xs={12} key={instanceKey}>
+                            <Card>
+                                <CardContent>
+                                    <Typography variant="h6">
+                                        {instanceKey}
+                                    </Typography>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Provider type" value={instanceType.ProviderType} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Price" value={formatCost(instanceType.Price)} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Cores" value={instanceType.VCPUs} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Max RAM request" value={`${formatCWLResourceSize(maxRamRequest)} (${formatFileSize(maxRamRequest)})`} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Max disk request" value={`${formatCWLResourceSize(maxDiskRequest)} (${formatFileSize(maxDiskRequest)})`} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Preemptible" value={instanceType.Preemptible.toString()} />
+                                    </Grid>
+                                    {instanceType.CUDA && instanceType.CUDA.DeviceCount > 0 ?
+                                        <>
+                                            <Grid item xs={12}>
+                                                <DetailsAttribute label="CUDA GPUs" value={instanceType.CUDA.DeviceCount} />
+                                            </Grid>
+                                            <Grid item xs={12}>
+                                                <DetailsAttribute label="Hardware capability" value={instanceType.CUDA.HardwareCapability} />
+                                            </Grid>
+                                            <Grid item xs={12}>
+                                                <DetailsAttribute label="Driver version" value={instanceType.CUDA.DriverVersion} />
+                                            </Grid>
+                                        </> : <></>
+                                    }
+                                </CardContent>
+                            </Card>
+                        </Grid>;
+                    }) :
+                <NotFoundView
+                    icon={ResourceIcon}
+                    messages={["No instances found"]}
+                />
+            }
+        </Grid>;
+    }
+));
+
+export const calculateKeepBufferOverhead = (coreCount: number): number => {
+    // TODO replace with exported server config
+    const buffersPerVCPU = 1;
+
+    // Returns 220 MiB + 64MiB+10% per buffer
+    return (220 << 20) + (buffersPerVCPU * coreCount * (1 << 26) * (11/10))
+};
+
+export const discountRamByPercent = (requestedRamBytes: number): number => {
+    // TODO replace this with exported server config or remove when no longer
+    // used by server in ram calculation
+    const discountPercent = 5;
+
+    return requestedRamBytes * 100 / (100-discountPercent);
+};
diff --git a/services/workbench2/src/views/keep-service-panel/keep-service-panel-root.tsx b/services/workbench2/src/views/keep-service-panel/keep-service-panel-root.tsx
new file mode 100644 (file)
index 0000000..7cc7795
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Grid, Table, TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton, Checkbox } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { MoreVerticalIcon } from 'components/icon/icon';
+import { KeepServiceResource } from 'models/keep-services';
+
+type CssRules = 'root' | 'tableRow';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+        overflow: 'auto'
+    },
+    tableRow: {
+        '& td, th': {
+            whiteSpace: 'nowrap'
+        }
+    }
+});
+
+export interface KeepServicePanelRootActionProps {
+    openRowOptions: (event: React.MouseEvent<HTMLElement>, keepService: KeepServiceResource) => void;
+}
+
+export interface KeepServicePanelRootDataProps {
+    keepServices: KeepServiceResource[];
+    hasKeepSerices: boolean;
+}
+
+type KeepServicePanelRootProps = KeepServicePanelRootActionProps & KeepServicePanelRootDataProps & WithStyles<CssRules>;
+
+export const KeepServicePanelRoot = withStyles(styles)(
+    ({ classes, hasKeepSerices, keepServices, openRowOptions }: KeepServicePanelRootProps) =>
+        <Card className={classes.root}>
+            <CardContent>
+                {hasKeepSerices && <Grid container direction="row">
+                    <Grid item xs={12}>
+                        <Table>
+                            <TableHead>
+                                <TableRow className={classes.tableRow}>
+                                    <TableCell>UUID</TableCell>
+                                    <TableCell>Read only</TableCell>
+                                    <TableCell>Service host</TableCell>
+                                    <TableCell>Service port</TableCell>
+                                    <TableCell>Service SSL flag</TableCell>
+                                    <TableCell>Service type</TableCell>
+                                    <TableCell />
+                                </TableRow>
+                            </TableHead>
+                            <TableBody>
+                                {keepServices.map((keepService, index) =>
+                                    <TableRow key={index} className={classes.tableRow}>
+                                        <TableCell>{keepService.uuid}</TableCell>
+                                        <TableCell>
+                                            <Checkbox
+                                                disableRipple
+                                                color="primary"
+                                                checked={keepService.readOnly} />
+                                        </TableCell>
+                                        <TableCell>{keepService.serviceHost}</TableCell>
+                                        <TableCell>{keepService.servicePort}</TableCell>
+                                        <TableCell>
+                                            <Checkbox
+                                                disableRipple
+                                                color="primary"
+                                                checked={keepService.serviceSslFlag} />
+                                        </TableCell>
+                                        <TableCell>{keepService.serviceType}</TableCell>
+                                        <TableCell>
+                                            <Tooltip title="More options" disableFocusListener>
+                                                <IconButton onClick={event => openRowOptions(event, keepService)}>
+                                                    <MoreVerticalIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        </TableCell>
+                                    </TableRow>)}
+                            </TableBody>
+                        </Table>
+                    </Grid>
+                </Grid>}
+            </CardContent>
+        </Card>
+);
diff --git a/services/workbench2/src/views/keep-service-panel/keep-service-panel.tsx b/services/workbench2/src/views/keep-service-panel/keep-service-panel.tsx
new file mode 100644 (file)
index 0000000..29ae907
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { 
+    KeepServicePanelRoot, 
+    KeepServicePanelRootDataProps, 
+    KeepServicePanelRootActionProps 
+} from 'views/keep-service-panel/keep-service-panel-root';
+import { openKeepServiceContextMenu } from 'store/context-menu/context-menu-actions';
+
+const mapStateToProps = (state: RootState): KeepServicePanelRootDataProps => {
+    return {
+        keepServices: state.keepServices,
+        hasKeepSerices: state.keepServices.length > 0
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): KeepServicePanelRootActionProps => ({
+    openRowOptions: (event, keepService) => {
+        dispatch<any>(openKeepServiceContextMenu(event, keepService));
+    }
+});
+
+export const KeepServicePanel = connect(mapStateToProps, mapDispatchToProps)(KeepServicePanelRoot);
\ No newline at end of file
diff --git a/services/workbench2/src/views/link-account-panel/link-account-panel-root.tsx b/services/workbench2/src/views/link-account-panel/link-account-panel-root.tsx
new file mode 100644 (file)
index 0000000..490e47d
--- /dev/null
@@ -0,0 +1,208 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardContent,
+    Button,
+    Grid,
+    Select,
+    CircularProgress
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { UserResource } from "models/user";
+import { LinkAccountType } from "models/link-account";
+import { formatDate } from "common/formatters";
+import { LinkAccountPanelStatus, LinkAccountPanelError } from "store/link-account-panel/link-account-panel-reducer";
+import { Config } from 'common/config';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+        overflow: 'auto',
+        display: 'flex'
+    }
+});
+
+export interface LinkAccountPanelRootDataProps {
+    targetUser?: UserResource;
+    userToLink?: UserResource;
+    remoteHostsConfig: { [key: string]: Config };
+    hasRemoteHosts: boolean;
+    localCluster: string;
+    loginCluster: string;
+    status: LinkAccountPanelStatus;
+    error: LinkAccountPanelError;
+    selectedCluster?: string;
+    isProcessing: boolean;
+}
+
+export interface LinkAccountPanelRootActionProps {
+    startLinking: (type: LinkAccountType) => void;
+    cancelLinking: () => void;
+    linkAccount: () => void;
+    setSelectedCluster: (cluster: string) => void;
+}
+
+function displayUser(user: UserResource, showCreatedAt: boolean = false, showCluster: boolean = false) {
+    const disp: JSX.Element[] = [];
+    disp.push(<span><b>{user.email}</b> ({user.username}, {user.uuid})</span>);
+    if (showCluster) {
+        const homeCluster = user.uuid.substring(0, 5);
+        disp.push(<span> hosted on cluster <b>{homeCluster}</b> and </span>);
+    }
+    if (showCreatedAt) {
+        disp.push(<span> created on <b>{formatDate(user.createdAt)}</b></span>);
+    }
+    return disp;
+}
+
+function isLocalUser(uuid: string, localCluster: string) {
+    return uuid.substring(0, 5) === localCluster;
+}
+
+type LinkAccountPanelRootProps = LinkAccountPanelRootDataProps & LinkAccountPanelRootActionProps & WithStyles<CssRules>;
+
+export const LinkAccountPanelRoot = withStyles(styles)(
+    ({ classes, targetUser, userToLink, status, isProcessing, error, startLinking, cancelLinking, linkAccount,
+        remoteHostsConfig, hasRemoteHosts, selectedCluster, setSelectedCluster, localCluster, loginCluster }: LinkAccountPanelRootProps) => {
+
+        // If a LoginFederation is configured, the self-serve account linking is not
+        // currently available.
+        if (loginCluster !== "") {
+            return <Card className={classes.root}><CardContent>
+                <Grid container spacing={16}>
+                    <Grid item xs={12}>
+                        If you would like to link this account to another one, please contact your administrator.
+                    </Grid>
+                </Grid>
+            </CardContent></Card>;
+        }
+        return <Card className={classes.root}><CardContent>
+            { isProcessing && <Grid container item direction="column" alignContent="center" spacing={24}>
+                <Grid item>
+                    Loading user info. Please wait.
+                </Grid>
+                <Grid item style={{ alignSelf: 'center' }}>
+                    <CircularProgress />
+                </Grid>
+            </Grid> }
+
+            { !isProcessing && status === LinkAccountPanelStatus.INITIAL && targetUser && <div>
+                { isLocalUser(targetUser.uuid, localCluster)
+                ? <Grid container spacing={24}>
+                    <Grid container item direction="column" spacing={24}>
+                        <Grid item>
+                            You are currently logged in as {displayUser(targetUser, true)}
+                        </Grid>
+                        <Grid item>
+                            You can link Arvados accounts. After linking, either login will take you to the same account.
+                        </Grid >
+                    </Grid>
+                    <Grid container item direction="row" spacing={24}>
+                        <Grid item>
+                            <Button disabled={!targetUser.isActive} color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_OTHER_LOGIN)}>
+                                Add another login to this account
+                            </Button>
+                        </Grid>
+                        <Grid item>
+                            <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_ACCOUNT)}>
+                                Use this login to access another account
+                            </Button>
+                        </Grid>
+                    </Grid>
+                    {hasRemoteHosts && selectedCluster && <Grid container item direction="column" spacing={24}>
+                        <Grid item>
+                            You can also link {displayUser(targetUser, false)} with an account from a remote cluster.
+                        </Grid>
+                        <Grid item>
+                            Please select the cluster that hosts the account you want to link with:
+                            <Select id="remoteHostsDropdown" native defaultValue={selectedCluster} style={{ marginLeft: "1em" }}
+                                onChange={(event) => setSelectedCluster(event.target.value)}>
+                                {Object.keys(remoteHostsConfig).map((k) => k !== localCluster ? <option key={k} value={k}>{k}</option> : null)}
+                            </Select>
+                        </Grid>
+                        <Grid item>
+                            <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT)}>
+                                Link with an account on&nbsp;{hasRemoteHosts ? <label>{selectedCluster} </label> : null}
+                            </Button>
+                        </Grid>
+                    </Grid>}
+                </Grid>
+                : <Grid container spacing={24}>
+                    <Grid container item direction="column" spacing={24}>
+                        <Grid item>
+                            You are currently logged in as {displayUser(targetUser, true, true)}
+                        </Grid>
+                        { targetUser.isActive
+                        ? (loginCluster === ""
+                            ? <> <Grid item>
+                                This a remote account. You can link a local Arvados account to this one.
+                                After linking, you can access the local account's data by logging into the
+                                <b>{localCluster}</b> cluster as user <b>{targetUser.email}</b>
+                                from <b>{targetUser.uuid.substring(0, 5)}</b>.
+                            </Grid >
+                            <Grid item>
+                                <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_LOCAL_TO_REMOTE)}>
+                                    Link an account from {localCluster} to this account
+                                </Button>
+                            </Grid></>
+                            : <Grid item>Please visit cluster
+                                <a href={remoteHostsConfig[loginCluster].workbench2Url + "/link_account"}>{loginCluster}</a> to perform account linking.
+                            </Grid> )
+                        : <Grid item>
+                            This an inactive remote account. An administrator must activate your
+                            account before you can proceed.  After your accounts is activated,
+                            you can link a local Arvados account hosted by the <b>{localCluster}</b> cluster to this one.
+                        </Grid> }
+                    </Grid>
+                </Grid> }
+            </div> }
+
+            {!isProcessing && (status === LinkAccountPanelStatus.LINKING || status === LinkAccountPanelStatus.ERROR) && userToLink && targetUser &&
+                <Grid container spacing={24}>
+                    {status === LinkAccountPanelStatus.LINKING && <Grid container item direction="column" spacing={24}>
+                        <Grid item>
+                            Clicking 'Link accounts' will link {displayUser(userToLink, true, !isLocalUser(targetUser.uuid, localCluster))} to {displayUser(targetUser, true, !isLocalUser(targetUser.uuid, localCluster))}.
+                        </Grid>
+                        {(isLocalUser(targetUser.uuid, localCluster)) && <Grid item>
+                            After linking, logging in as {displayUser(userToLink)} will log you into the same account as {displayUser(targetUser)}.
+                        </Grid>}
+                        <Grid item>
+                            Any object owned by {displayUser(userToLink)} will be transfered to {displayUser(targetUser)}.
+                        </Grid>
+                        {!isLocalUser(targetUser.uuid, localCluster) && <Grid item>
+                            You can access <b>{userToLink.email}</b> data by logging into <b>{localCluster}</b> with the <b>{targetUser.email}</b> account.
+                        </Grid>}
+                    </Grid>}
+                    {error === LinkAccountPanelError.NON_ADMIN && <Grid item>
+                        Cannot link admin account {displayUser(userToLink)} to non-admin account {displayUser(targetUser)}.
+                    </Grid>}
+                    {error === LinkAccountPanelError.SAME_USER && <Grid item>
+                        Cannot link {displayUser(targetUser)} to the same account.
+                    </Grid>}
+                    {error === LinkAccountPanelError.INACTIVE && <Grid item>
+                        Cannot link account {displayUser(userToLink)} to inactive account {displayUser(targetUser)}.
+                    </Grid>}
+                    <Grid container item direction="row" spacing={24}>
+                        <Grid item>
+                            <Button variant="contained" onClick={() => cancelLinking()}>
+                                Cancel
+                            </Button>
+                        </Grid>
+                        <Grid item>
+                            <Button disabled={status === LinkAccountPanelStatus.ERROR} color="primary" variant="contained" onClick={() => linkAccount()}>
+                                Link accounts
+                            </Button>
+                        </Grid>
+                    </Grid>
+                </Grid>}
+        </CardContent></Card>;
+    });
diff --git a/services/workbench2/src/views/link-account-panel/link-account-panel.tsx b/services/workbench2/src/views/link-account-panel/link-account-panel.tsx
new file mode 100644 (file)
index 0000000..65a8217
--- /dev/null
@@ -0,0 +1,38 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { startLinking, linkAccount, linkAccountPanelActions, cancelLinking } from 'store/link-account-panel/link-account-panel-actions';
+import { LinkAccountType } from 'models/link-account';
+import {
+    LinkAccountPanelRoot,
+    LinkAccountPanelRootDataProps,
+    LinkAccountPanelRootActionProps
+} from 'views/link-account-panel/link-account-panel-root';
+
+const mapStateToProps = (state: RootState): LinkAccountPanelRootDataProps => {
+    return {
+        remoteHostsConfig: state.auth.remoteHostsConfig,
+        hasRemoteHosts: Object.keys(state.auth.remoteHosts).length > 1 && state.auth.loginCluster === "",
+        selectedCluster: state.linkAccountPanel.selectedCluster,
+        localCluster: state.auth.localCluster,
+        loginCluster: state.auth.loginCluster,
+        targetUser: state.linkAccountPanel.targetUser,
+        userToLink: state.linkAccountPanel.userToLink,
+        status: state.linkAccountPanel.status,
+        error: state.linkAccountPanel.error,
+        isProcessing: state.linkAccountPanel.isProcessing
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): LinkAccountPanelRootActionProps => ({
+    startLinking: (type: LinkAccountType) => dispatch<any>(startLinking(type)),
+    cancelLinking: () => dispatch<any>(cancelLinking(true)),
+    linkAccount: () => dispatch<any>(linkAccount()),
+    setSelectedCluster: (selectedCluster: string) => dispatch<any>(linkAccountPanelActions.SET_SELECTED_CLUSTER({ selectedCluster }))
+});
+
+export const LinkAccountPanel = connect(mapStateToProps, mapDispatchToProps)(LinkAccountPanelRoot);
diff --git a/services/workbench2/src/views/link-panel/link-panel-root.tsx b/services/workbench2/src/views/link-panel/link-panel-root.tsx
new file mode 100644 (file)
index 0000000..f75275a
--- /dev/null
@@ -0,0 +1,100 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { LINK_PANEL_ID } from 'store/link-panel/link-panel-actions';
+import { DataExplorer } from 'views-components/data-explorer/data-explorer';
+import { SortDirection } from 'components/data-table/data-column';
+import { DataColumns } from 'components/data-table/data-table';
+import { ResourcesState } from 'store/resources/resources';
+import { ShareMeIcon } from 'components/icon/icon';
+import { createTree } from 'models/tree';
+import {
+    ResourceLinkUuid, ResourceLinkHead, ResourceLinkTail,
+    ResourceLinkClass, ResourceLinkName }
+from 'views-components/data-explorer/renderers';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { LinkResource } from 'models/link';
+
+type CssRules = "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    }
+});
+
+export enum LinkPanelColumnNames {
+    NAME = "Name",
+    LINK_CLASS = "Link Class",
+    TAIL = "Tail",
+    HEAD = 'Head',
+    UUID = "UUID"
+}
+
+export const linkPanelColumns: DataColumns<string, LinkResource> = [
+    {
+        name: LinkPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "name"},
+        filters: createTree(),
+        render: uuid => <ResourceLinkName uuid={uuid} />
+    },
+    {
+        name: LinkPanelColumnNames.LINK_CLASS,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkClass uuid={uuid} />
+    },
+    {
+        name: LinkPanelColumnNames.TAIL,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkTail uuid={uuid} />
+    },
+    {
+        name: LinkPanelColumnNames.HEAD,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHead uuid={uuid} />
+    },
+    {
+        name: LinkPanelColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkUuid uuid={uuid} />
+    }
+];
+
+export interface LinkPanelRootDataProps {
+    resources: ResourcesState;
+}
+
+export interface LinkPanelRootActionProps {
+    onItemClick: (item: string) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: string) => void;
+    onItemDoubleClick: (item: string) => void;
+}
+
+export type LinkPanelRootProps = LinkPanelRootDataProps & LinkPanelRootActionProps & WithStyles<CssRules>;
+
+export const LinkPanelRoot = withStyles(styles)((props: LinkPanelRootProps) => {
+    return <div className={props.classes.root}><DataExplorer
+        id={LINK_PANEL_ID}
+        onRowClick={props.onItemClick}
+        onRowDoubleClick={props.onItemDoubleClick}
+        onContextMenu={props.onContextMenu}
+        contextMenuColumn={true}
+        hideColumnSelector
+        hideSearchInput
+        defaultViewIcon={ShareMeIcon}
+        defaultViewMessages={['Your link list is empty.']} />
+    </div>;
+});
diff --git a/services/workbench2/src/views/link-panel/link-panel.tsx b/services/workbench2/src/views/link-panel/link-panel.tsx
new file mode 100644 (file)
index 0000000..b66aafb
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { RootState } from 'store/store';
+import {
+    openContextMenu,
+    resourceUuidToContextMenuKind
+} from 'store/context-menu/context-menu-actions';
+import {
+    LinkPanelRoot,
+    LinkPanelRootActionProps,
+    LinkPanelRootDataProps
+} from 'views/link-panel/link-panel-root';
+import { ResourceKind } from 'models/resource';
+
+const mapStateToProps = (state: RootState): LinkPanelRootDataProps => {
+    return {
+        resources: state.resources
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): LinkPanelRootActionProps => ({
+    onContextMenu: (event, resourceUuid) => {
+        const kind = dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
+        if (kind) {
+            dispatch<any>(openContextMenu(event, {
+                name: '',
+                uuid: resourceUuid,
+                ownerUuid: '',
+                kind: ResourceKind.LINK,
+                menuKind: kind
+            }));
+        }
+    },
+    onItemClick: (resourceUuid: string) => { return; },
+    onItemDoubleClick: uuid => { return; }
+});
+
+export const LinkPanel = connect(mapStateToProps, mapDispatchToProps)(LinkPanelRoot);
\ No newline at end of file
diff --git a/services/workbench2/src/views/login-panel/login-panel.test.tsx b/services/workbench2/src/views/login-panel/login-panel.test.tsx
new file mode 100644 (file)
index 0000000..b490acd
--- /dev/null
@@ -0,0 +1,136 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { requirePasswordLogin } from './login-panel';
+
+describe('<LoginPanel />', () => {
+    describe('requirePasswordLogin', () => {
+        it('should return false if no config specified', () => {
+            // given
+            const config = null;
+
+            // when
+            const result = requirePasswordLogin(config);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+
+        it('should return false if no config.clusterConfig specified', () => {
+            // given
+            const config = {};
+
+            // when
+            const result = requirePasswordLogin(config);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+
+        it('should return false if no config.clusterConfig.Login specified', () => {
+            // given
+            const config = {
+                clusterConfig: {},
+            };
+
+            // when
+            const result = requirePasswordLogin(config);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+
+        it('should return false if no config.clusterConfig.Login.LDAP and config.clusterConfig.Login.PAM specified', () => {
+            // given
+            const config = {
+                clusterConfig: {
+                    Login: {}
+                },
+            };
+
+            // when
+            const result = requirePasswordLogin(config);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+
+        it('should return false if config.clusterConfig.Login.LDAP.Enable and config.clusterConfig.Login.PAM.Enable not specified', () => {
+            // given
+            const config = {
+                clusterConfig: {
+                    Login: {
+                        PAM: {},
+                        LDAP: {},
+                    },
+                },
+            };
+
+            // when
+            const result = requirePasswordLogin(config);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+
+        it('should return value from config.clusterConfig.Login.LDAP.Enable', () => {
+            // given
+            const config = {
+                clusterConfig: {
+                    Login: {
+                        PAM: {},
+                        LDAP: {
+                            Enable: true
+                        },
+                    },
+                },
+            };
+
+            // when
+            const result = requirePasswordLogin(config);
+
+            // then
+            expect(result).toBeTruthy();
+        });
+
+        it('should return value from config.clusterConfig.Login.PAM.Enable', () => {
+            // given
+            const config = {
+                clusterConfig: {
+                    Login: {
+                        LDAP: {},
+                        PAM: {
+                            Enable: true
+                        },
+                    },
+                },
+            };
+
+            // when
+            const result = requirePasswordLogin(config);
+
+            // then
+            expect(result).toBeTruthy();
+        });
+
+        it('should return false for not specified config option config.clusterConfig.Login.NOT_EXISTING.Enable', () => {
+            // given
+            const config = {
+                clusterConfig: {
+                    Login: {
+                        NOT_EXISTING: {
+                            Enable: true
+                        },
+                    },
+                },
+            };
+
+            // when
+            const result = requirePasswordLogin(config);
+
+            // then
+            expect(result).toBeFalsy();
+        });
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views/login-panel/login-panel.tsx b/services/workbench2/src/views/login-panel/login-panel.tsx
new file mode 100644 (file)
index 0000000..452a666
--- /dev/null
@@ -0,0 +1,135 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import { Grid, Typography, Button, Select } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { login, authActions } from 'store/auth/auth-action';
+import { ArvadosTheme } from 'common/custom-theme';
+import { RootState } from 'store/store';
+import { LoginForm } from 'views-components/login-form/login-form';
+import Axios, { AxiosResponse } from 'axios';
+import { Config } from 'common/config';
+import { sanitizeHTML } from 'common/html-sanitize';
+
+type CssRules = 'root' | 'container' | 'title' | 'content' | 'content__bolder' | 'button';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        position: 'relative',
+        backgroundColor: theme.palette.grey["200"],
+        '&::after': {
+            content: `''`,
+            position: 'absolute',
+            top: 0,
+            left: 0,
+            bottom: 0,
+            right: 0,
+            opacity: 0.2,
+        }
+    },
+    container: {
+        width: '560px',
+        zIndex: 10
+    },
+    title: {
+        marginBottom: theme.spacing.unit * 6,
+        color: theme.palette.grey["800"]
+    },
+    content: {
+        marginBottom: theme.spacing.unit * 3,
+        lineHeight: '1.2rem',
+        color: theme.palette.grey["800"]
+    },
+    'content__bolder': {
+        fontWeight: 'bolder'
+    },
+    button: {
+        boxShadow: 'none'
+    }
+});
+
+export type PasswordLoginResponse = {
+    uuid?: string;
+    api_token?: string;
+    message?: string;
+};
+
+const doPasswordLogin = (url: string) => (username: string, password: string) => {
+    const formData: string[] = [];
+    formData.push('username='+encodeURIComponent(username));
+    formData.push('password='+encodeURIComponent(password));
+    return Axios.post<string, AxiosResponse<PasswordLoginResponse>>(`${url}/arvados/v1/users/authenticate`, formData.join('&'), {
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        },
+    });
+};
+
+type LoginPanelProps = DispatchProp<any> & WithStyles<CssRules> & {
+    remoteHosts: { [key: string]: string },
+    homeCluster: string,
+    localCluster: string,
+    loginCluster: string,
+    welcomePage: string,
+    passwordLogin: boolean,
+};
+
+const loginOptions = ['LDAP', 'PAM', 'Test'];
+
+export const requirePasswordLogin = (config: Config): boolean => {
+    if (config && config.clusterConfig && config.clusterConfig.Login) {
+        return loginOptions
+            .filter(loginOption => !!config.clusterConfig.Login[loginOption])
+            .map(loginOption => config.clusterConfig.Login[loginOption].Enable)
+            .find(enabled => enabled === true) || false;
+    }
+    return false;
+};
+
+export const LoginPanel = withStyles(styles)(
+    connect((state: RootState) => ({
+        remoteHosts: state.auth.remoteHosts,
+        homeCluster: state.auth.homeCluster,
+        localCluster: state.auth.localCluster,
+        loginCluster: state.auth.loginCluster,
+        welcomePage: state.auth.config.clusterConfig.Workbench.WelcomePageHTML,
+        passwordLogin: requirePasswordLogin(state.auth.remoteHostsConfig[state.auth.loginCluster || state.auth.homeCluster]),
+        }))(({ classes, dispatch, remoteHosts, homeCluster, localCluster, loginCluster, welcomePage, passwordLogin }: LoginPanelProps) => {
+        const loginBtnLabel = `Log in${(localCluster !== homeCluster && loginCluster !== homeCluster) ? " to "+localCluster+" with user from "+homeCluster : ''}`;
+
+        return (<Grid container justify="center" alignItems="center"
+            className={classes.root}
+            style={{ marginTop: 56, overflowY: "auto", height: "100%" }}>
+            <Grid item className={classes.container}>
+                <Typography component="div">
+                    <div dangerouslySetInnerHTML={{ __html: sanitizeHTML(welcomePage) }} style={{ margin: "1em" }} />
+                </Typography>
+                {Object.keys(remoteHosts).length > 1 && loginCluster === "" &&
+
+                    <Typography component="div" align="right">
+                        <label>Please select the cluster that hosts your user account:</label>
+                        <Select native value={homeCluster} style={{ margin: "1em" }}
+                            onChange={(event) => dispatch(authActions.SET_HOME_CLUSTER(event.target.value))}>
+                            {Object.keys(remoteHosts).map((k) => <option key={k} value={k}>{k}</option>)}
+                        </Select>
+                    </Typography>}
+
+                {passwordLogin
+                ? <Typography component="div">
+                    <LoginForm dispatch={dispatch}
+                        loginLabel={loginBtnLabel}
+                        handleSubmit={doPasswordLogin(`https://${remoteHosts[loginCluster || homeCluster]}`)}/>
+                </Typography>
+                : <Typography component="div" align="right">
+                    <Button variant="contained" color="primary" style={{ margin: "1em" }}
+                        className={classes.button}
+                        onClick={() => dispatch(login(localCluster, homeCluster, loginCluster, remoteHosts))}>
+                        {loginBtnLabel}
+                    </Button>
+                </Typography>}
+            </Grid>
+        </Grid >);}
+    ));
diff --git a/services/workbench2/src/views/main-panel/main-panel-root.tsx b/services/workbench2/src/views/main-panel/main-panel-root.tsx
new file mode 100644 (file)
index 0000000..cdfd0c3
--- /dev/null
@@ -0,0 +1,79 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles, Grid, LinearProgress } from '@material-ui/core';
+import { User } from "models/user";
+import { ArvadosTheme } from 'common/custom-theme';
+import { WorkbenchPanel } from 'views/workbench/workbench';
+import { LoginPanel } from 'views/login-panel/login-panel';
+import { InactivePanel } from 'views/inactive-panel/inactive-panel';
+import { WorkbenchLoadingScreen } from 'views/workbench/workbench-loading-screen';
+import { MainAppBar } from 'views-components/main-app-bar/main-app-bar';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        overflow: 'hidden',
+        width: '100vw',
+        height: '100vh'
+    }
+});
+
+export interface MainPanelRootDataProps {
+    user?: User;
+    working: boolean;
+    loading: boolean;
+    buildInfo: string;
+    uuidPrefix: string;
+    isNotLinking: boolean;
+    isLinkingPath: boolean;
+    siteBanner: string;
+    sessionIdleTimeout: number;
+    sidePanelIsCollapsed: boolean;
+    isTransitioning: boolean;
+    currentSideWidth: number;
+}
+
+interface MainPanelRootDispatchProps {
+    toggleSidePanel: () => void
+}
+
+type MainPanelRootProps = MainPanelRootDataProps & MainPanelRootDispatchProps & WithStyles<CssRules>;
+
+export const MainPanelRoot = withStyles(styles)(
+    ({ classes, loading, working, user, buildInfo, uuidPrefix,
+        isNotLinking, isLinkingPath, siteBanner, sessionIdleTimeout, 
+        sidePanelIsCollapsed, isTransitioning, currentSideWidth}: MainPanelRootProps) =>{
+        return loading
+            ? <WorkbenchLoadingScreen />
+            : <>
+            {isNotLinking && <MainAppBar
+                user={user}
+                buildInfo={buildInfo}
+                uuidPrefix={uuidPrefix}
+                siteBanner={siteBanner}
+                sidePanelIsCollapsed={sidePanelIsCollapsed}
+                >
+                {working
+                    ? <LinearProgress color="secondary" data-cy="linear-progress" />
+                    : null}
+            </MainAppBar>}
+            <Grid container direction="column" className={classes.root}>
+                {user
+                    ? (user.isActive || (!user.isActive && isLinkingPath)
+                    ? <WorkbenchPanel 
+                        isNotLinking={isNotLinking}
+                        isUserActive={user.isActive}
+                        sessionIdleTimeout={sessionIdleTimeout}
+                        sidePanelIsCollapsed={sidePanelIsCollapsed}
+                        isTransitioning={isTransitioning}
+                        currentSideWidth={currentSideWidth}/>
+                    : <InactivePanel />)
+                    : <LoginPanel />}
+            </Grid>
+        </>
+    }
+);
diff --git a/services/workbench2/src/views/main-panel/main-panel.tsx b/services/workbench2/src/views/main-panel/main-panel.tsx
new file mode 100644 (file)
index 0000000..264390a
--- /dev/null
@@ -0,0 +1,40 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { connect } from 'react-redux';
+import parse from 'parse-duration';
+import { MainPanelRoot, MainPanelRootDataProps } from 'views/main-panel/main-panel-root';
+import { isSystemWorking } from 'store/progress-indicator/progress-indicator-reducer';
+import { isWorkbenchLoading } from 'store/workbench/workbench-actions';
+import { LinkAccountPanelStatus } from 'store/link-account-panel/link-account-panel-reducer';
+import { matchLinkAccountRoute } from 'routes/routes';
+import { toggleSidePanel } from "store/side-panel/side-panel-action";
+
+const mapStateToProps = (state: RootState): MainPanelRootDataProps => {
+    return {
+        user: state.auth.user,
+        working: isSystemWorking(state.progressIndicator),
+        loading: isWorkbenchLoading(state),
+        buildInfo: state.appInfo.buildInfo,
+        uuidPrefix: state.auth.localCluster,
+        isNotLinking: state.linkAccountPanel.status === LinkAccountPanelStatus.NONE || state.linkAccountPanel.status === LinkAccountPanelStatus.INITIAL,
+        isLinkingPath: state.router.location ? matchLinkAccountRoute(state.router.location.pathname) !== null : false,
+        siteBanner: state.auth.config.clusterConfig.Workbench.SiteName,
+        sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
+        sidePanelIsCollapsed: state.sidePanel.collapsedState,
+        isTransitioning: state.detailsPanel.isTransitioning,
+        currentSideWidth: state.sidePanel.currentSideWidth
+    };
+};
+
+const mapDispatchToProps = (dispatch) => {
+    return {
+        toggleSidePanel: (collapsedState)=>{
+            return dispatch(toggleSidePanel(collapsedState))
+        }
+    }
+};
+
+export const MainPanel = connect(mapStateToProps, mapDispatchToProps)(MainPanelRoot);
diff --git a/services/workbench2/src/views/not-found-panel/not-found-panel-root.test.tsx b/services/workbench2/src/views/not-found-panel/not-found-panel-root.test.tsx
new file mode 100644 (file)
index 0000000..d2acd77
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { mount, configure } from 'enzyme';
+import Adapter from "enzyme-adapter-react-16";
+import { StyledComponentProps, MuiThemeProvider } from '@material-ui/core';
+import { ClusterConfigJSON } from 'common/config';
+import { CustomTheme } from 'common/custom-theme';
+import { NotFoundPanelRoot, NotFoundPanelRootDataProps, CssRules } from './not-found-panel-root';
+
+configure({ adapter: new Adapter() });
+
+describe('NotFoundPanelRoot', () => {
+    let props: NotFoundPanelRootDataProps & StyledComponentProps<CssRules>;
+
+    beforeEach(() => {
+        props = {
+            classes: {
+                root: 'root',
+                title: 'title',
+                active: 'active',
+            },
+            clusterConfig: {
+                Mail: {
+                    SupportEmailAddress: 'support@example.com'
+                }
+            } as ClusterConfigJSON,
+            location: null,
+        };
+    });
+
+    it('should render component', () => {
+        // given
+        const expectedMessage = "The page you requested was not found";
+
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <NotFoundPanelRoot {...props} />
+            </MuiThemeProvider>
+            );
+
+        // then
+        expect(wrapper.find('p').text()).toContain(expectedMessage);
+    });
+
+    it('should render component without email url when no email', () => {
+        // setup
+        props.clusterConfig.Mail.SupportEmailAddress = '';
+
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <NotFoundPanelRoot {...props} />
+            </MuiThemeProvider>
+            );
+
+        // then
+        expect(wrapper.find('a').length).toBe(0);
+    });
+
+    it('should render component with additional message and email url', () => {
+        // given
+        const hash = '123hash123';
+        const pathname = `/collections/${hash}`;
+
+        // setup
+        props.location = {
+            pathname,
+        } as any;
+
+        // when
+        const wrapper = mount(
+            <MuiThemeProvider theme={CustomTheme}>
+                <NotFoundPanelRoot {...props} />
+            </MuiThemeProvider>
+            );
+
+        // then
+        expect(wrapper.find('p').first().text()).toContain(hash);
+
+        // and
+        expect(wrapper.find('a').length).toBe(1);
+    });
+});
\ No newline at end of file
diff --git a/services/workbench2/src/views/not-found-panel/not-found-panel-root.tsx b/services/workbench2/src/views/not-found-panel/not-found-panel-root.tsx
new file mode 100644 (file)
index 0000000..e5d8507
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Location } from 'history';
+import { StyleRulesCallback, WithStyles, withStyles, Paper, Grid } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ClusterConfigJSON } from 'common/config';
+
+export type CssRules = 'root' | 'title' | 'active';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        overflow: 'hidden',
+        width: '100vw',
+        height: '100vh'
+    },
+    title: {
+        paddingLeft: theme.spacing.unit * 3,
+        paddingTop: theme.spacing.unit * 3,
+        paddingBottom: theme.spacing.unit * 3,
+        fontSize: '18px'
+    },
+    active: {
+        color: theme.customs.colors.grey700,
+        textDecoration: 'none',
+    }
+});
+
+export interface NotFoundPanelOwnProps {
+    notWrapped?: boolean;
+}
+
+export interface NotFoundPanelRootDataProps {
+    location: Location<any> | null;
+    clusterConfig: ClusterConfigJSON;
+}
+
+type NotFoundPanelRootProps = NotFoundPanelRootDataProps & NotFoundPanelOwnProps & WithStyles<CssRules>;
+
+const getAdditionalMessage = (location: Location | null) => {
+    if (!location) {
+        return null;
+    }
+
+    const { pathname } = location;
+
+    if (pathname.indexOf('collections') > -1) {
+        const uuidHash = pathname.replace('/collections/', '');
+
+        return (
+            <p>
+                Please make sure that provided UUID/ObjectHash '{uuidHash}' is valid.
+            </p>
+        );
+    }
+
+    return null;
+};
+
+const getEmailLink = (email: string, classes: Record<CssRules, string>) => {
+    const { location: { href: windowHref } } = window;
+    const href = `mailto:${email}?body=${encodeURIComponent('Problem while viewing page ')}${encodeURIComponent(windowHref)}&subject=${encodeURIComponent('Workbench problem report')}`;
+
+    return (<a
+        className={classes.active}
+        href={href}>
+        email us
+    </a>);
+};
+
+
+export const NotFoundPanelRoot = withStyles(styles)(
+    ({ classes, clusterConfig, location, notWrapped }: NotFoundPanelRootProps) => {
+
+        const content = <Grid container justify="space-between" wrap="nowrap" alignItems="center">
+            <div data-cy="not-found-content" className={classes.title}>
+                <h2>Not Found</h2>
+                {getAdditionalMessage(location)}
+                <p>
+                    The page you requested was not found,&nbsp;
+                    {
+                        !!clusterConfig.Mail && clusterConfig.Mail.SupportEmailAddress ?
+                            getEmailLink(clusterConfig.Mail.SupportEmailAddress, classes) :
+                            'email us'
+                    }
+                    &nbsp;if you suspect this is a bug.
+                </p>
+            </div>
+        </Grid>;
+
+        return !notWrapped ? <Paper data-cy="not-found-page"> {content}</Paper> : content;
+    }
+);
diff --git a/services/workbench2/src/views/not-found-panel/not-found-panel.tsx b/services/workbench2/src/views/not-found-panel/not-found-panel.tsx
new file mode 100644 (file)
index 0000000..f54c00c
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import React from 'react';
+import { connect } from 'react-redux';
+import { NotFoundPanelRoot, NotFoundPanelRootDataProps } from 'views/not-found-panel/not-found-panel-root';
+import { Grid } from '@material-ui/core';
+import { DefaultView } from "components/default-view/default-view";
+import { IconType } from 'components/icon/icon';
+
+const mapStateToProps = (state: RootState): NotFoundPanelRootDataProps => {
+    return {
+        location: state.router.location,
+        clusterConfig: state.auth.config.clusterConfig,
+    };
+};
+
+const mapDispatchToProps = null;
+
+export const NotFoundPanel = connect(mapStateToProps, mapDispatchToProps)
+    (NotFoundPanelRoot) as any;
+
+export interface NotFoundViewDataProps {
+    messages: string[];
+    icon?: IconType;
+}
+
+// TODO: optionally pass in the UUID and check if the
+// reason the item is not found is because
+// it or a parent project is actually in the trash.
+// If so, offer to untrash the item or the parent project.
+export const NotFoundView =
+    ({ messages, icon: Icon }: NotFoundViewDataProps) =>
+        <Grid
+            container
+            alignItems="center"
+            justify="center"
+            style={{ minHeight: "100%" }}
+            data-cy="not-found-view">
+            <DefaultView
+                icon={Icon}
+                messages={messages}
+            />
+        </Grid>;
diff --git a/services/workbench2/src/views/process-panel/process-cmd-card.tsx b/services/workbench2/src/views/process-panel/process-cmd-card.tsx
new file mode 100644 (file)
index 0000000..6cef09b
--- /dev/null
@@ -0,0 +1,145 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Typography,
+    Grid,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { CloseIcon, CommandIcon, CopyIcon } from 'components/icon/icon';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { DefaultVirtualCodeSnippet } from 'components/default-code-snippet/default-virtual-code-snippet';
+import { Process } from 'store/processes/process';
+import shellescape from 'shell-escape';
+import CopyResultToClipboard from 'components/copy-to-clipboard/copy-result-to-clipboard';
+
+type CssRules = 'card' | 'content' | 'title' | 'header' | 'avatar' | 'iconHeader';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: 0,
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL,
+    },
+    avatar: {
+        alignSelf: 'flex-start',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    content: {
+        height: `calc(100% - ${theme.spacing.unit * 6}px)`,
+        padding: theme.spacing.unit * 1.0,
+        paddingTop: 0,
+        '&:last-child': {
+            paddingBottom: theme.spacing.unit * 1,
+        }
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.greyD,
+        fontSize: '1.875rem'
+    },
+});
+
+interface ProcessCmdCardDataProps {
+  process: Process;
+  onCopy: (text: string) => void;
+}
+
+type ProcessCmdCardProps = ProcessCmdCardDataProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessCmdCard = withStyles(styles)(
+  ({
+    process,
+    onCopy,
+    classes,
+    doHidePanel,
+  }: ProcessCmdCardProps) => {
+
+    const formatLine = (lines: string[], index: number): string => {
+      // Escape each arg separately
+      let line = shellescape([lines[index]])
+      // Indent lines after the first
+      const indent = index > 0 ? '  ' : '';
+      // Add backslash "escaped linebreak"
+      const lineBreak = lines.length > 1 && index < lines.length - 1 ? ' \\' : '';
+
+      return `${indent}${line}${lineBreak}`;
+    };
+
+    const formatClipboardText = (command: string[]) => (): string => (
+      command.map((v) =>
+        shellescape([v]) // Escape each arg separately
+      ).join(' ')
+    );
+
+    return (
+      <Card className={classes.card}>
+        <CardHeader
+          className={classes.header}
+          classes={{
+            content: classes.title,
+            avatar: classes.avatar,
+          }}
+          avatar={<CommandIcon className={classes.iconHeader} />}
+          title={
+            <Typography noWrap variant="h6" color="inherit">
+              Command
+            </Typography>
+          }
+          action={
+            <Grid container direction="row" alignItems="center">
+              <Grid item>
+                <Tooltip title="Copy link to clipboard" disableFocusListener>
+                  <IconButton>
+                    <CopyResultToClipboard
+                      getText={formatClipboardText(process.containerRequest.command)}
+                      onCopy={() => onCopy("Command copied to clipboard")}
+                    >
+                      <CopyIcon />
+                    </CopyResultToClipboard>
+                  </IconButton>
+                </Tooltip>
+              </Grid>
+              <Grid item>
+                {doHidePanel && (
+                  <Tooltip
+                    title={`Close Command Panel`}
+                    disableFocusListener
+                  >
+                    <IconButton onClick={doHidePanel}>
+                      <CloseIcon />
+                    </IconButton>
+                  </Tooltip>
+                )}
+              </Grid>
+            </Grid>
+          }
+        />
+        <CardContent className={classes.content}>
+          <DefaultVirtualCodeSnippet
+            lines={process.containerRequest.command}
+            lineFormatter={formatLine}
+            linked
+          />
+        </CardContent>
+      </Card>
+    );
+  }
+);
diff --git a/services/workbench2/src/views/process-panel/process-details-attributes.tsx b/services/workbench2/src/views/process-panel/process-details-attributes.tsx
new file mode 100644 (file)
index 0000000..5c666ac
--- /dev/null
@@ -0,0 +1,199 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Grid, StyleRulesCallback, withStyles } from "@material-ui/core";
+import { Dispatch } from 'redux';
+import { formatCost, formatDate } from "common/formatters";
+import { resourceLabel } from "common/labels";
+import { DetailsAttribute } from "components/details-attribute/details-attribute";
+import { ResourceKind } from "models/resource";
+import { CollectionName, ContainerRunTime, ResourceWithName } from "views-components/data-explorer/renderers";
+import { getProcess, getProcessStatus } from "store/processes/process";
+import { RootState } from "store/store";
+import { connect } from "react-redux";
+import { ProcessResource, MOUNT_PATH_CWL_WORKFLOW } from "models/process";
+import { ContainerResource } from "models/container";
+import { navigateToOutput, openWorkflow } from "store/process-panel/process-panel-actions";
+import { ArvadosTheme } from "common/custom-theme";
+import { ProcessRuntimeStatus } from "views-components/process-runtime-status/process-runtime-status";
+import { getPropertyChip } from "views-components/resource-properties-form/property-chip";
+import { ContainerRequestResource } from "models/container-request";
+import { filterResources } from "store/resources/resources";
+import { JSONMount } from 'models/mount-types';
+import { getCollectionUrl } from 'models/collection';
+
+type CssRules = 'link' | 'propertyTag';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    link: {
+        fontSize: '0.875rem',
+        color: theme.palette.primary.main,
+        '&:hover': {
+            cursor: 'pointer'
+        }
+    },
+    propertyTag: {
+        marginRight: theme.spacing.unit / 2,
+        marginBottom: theme.spacing.unit / 2
+    },
+});
+
+const mapStateToProps = (state: RootState, props: { request: ProcessResource }) => {
+    const process = getProcess(props.request.uuid)(state.resources);
+
+    let workflowCollection = "";
+    let workflowPath = "";
+    if (process?.containerRequest?.mounts && process.containerRequest.mounts[MOUNT_PATH_CWL_WORKFLOW]) {
+        const wf = process.containerRequest.mounts[MOUNT_PATH_CWL_WORKFLOW] as JSONMount;
+
+        if (wf.content["$graph"] &&
+            wf.content["$graph"].length > 0 &&
+            wf.content["$graph"][0] &&
+            wf.content["$graph"][0]["steps"] &&
+            wf.content["$graph"][0]["steps"][0]) {
+
+            const REGEX = /keep:([0-9a-f]{32}\+\d+)\/(.*)/;
+            const pdh = wf.content["$graph"][0]["steps"][0].run.match(REGEX);
+            if (pdh) {
+                workflowCollection = pdh[1];
+                workflowPath = pdh[2];
+            }
+        }
+    }
+
+    return {
+        container: process?.container,
+        workflowCollection,
+        workflowPath,
+        subprocesses: filterResources((resource: ContainerRequestResource) =>
+            resource.kind === ResourceKind.CONTAINER_REQUEST &&
+            resource.requestingContainerUuid === process?.containerRequest.containerUuid
+        )(state.resources),
+    };
+};
+
+interface ProcessDetailsAttributesActionProps {
+    navigateToOutput: (resource: ContainerRequestResource) => void;
+    openWorkflow: (uuid: string) => void;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): ProcessDetailsAttributesActionProps => ({
+    navigateToOutput: (resource) => dispatch<any>(navigateToOutput(resource)),
+    openWorkflow: (uuid) => dispatch<any>(openWorkflow(uuid)),
+});
+
+export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
+    connect(mapStateToProps, mapDispatchToProps)(
+        (props: {
+            request: ProcessResource, container?: ContainerResource, subprocesses: ContainerRequestResource[],
+            workflowCollection, workflowPath,
+            twoCol?: boolean, hideProcessPanelRedundantFields?: boolean, classes: Record<CssRules, string>
+        } & ProcessDetailsAttributesActionProps) => {
+            const containerRequest = props.request;
+            const container = props.container;
+            const subprocesses = props.subprocesses;
+            const classes = props.classes;
+            const mdSize = props.twoCol ? 6 : 12;
+            const workflowCollection = props.workflowCollection;
+            const workflowPath = props.workflowPath;
+            const filteredPropertyKeys = Object.keys(containerRequest.properties)
+                .filter(k => (typeof containerRequest.properties[k] !== 'object'));
+            const hasTotalCost = containerRequest && containerRequest.cumulativeCost > 0;
+            const totalCostNotReady = container && container.cost > 0 && container.state === "Running" && containerRequest && containerRequest.cumulativeCost === 0 && subprocesses.length > 0;
+            return <Grid container>
+                <Grid item xs={12}>
+                    <ProcessRuntimeStatus runtimeStatus={container?.runtimeStatus} containerCount={containerRequest.containerCount} />
+                </Grid>
+                {!props.hideProcessPanelRedundantFields && <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROCESS)} />
+                </Grid>}
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Container request UUID' linkToUuid={containerRequest.uuid} value={containerRequest.uuid} />
+                </Grid>
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Docker image locator'
+                        linkToUuid={containerRequest.containerImage} value={containerRequest.containerImage} />
+                </Grid>
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute
+                        label='Owner' linkToUuid={containerRequest.ownerUuid}
+                        uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+                </Grid>
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Container UUID' value={containerRequest.containerUuid} />
+                </Grid>
+                {!props.hideProcessPanelRedundantFields && <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Status' value={getProcessStatus({ containerRequest, container })} />
+                </Grid>}
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Created at' value={formatDate(containerRequest.createdAt)} />
+                </Grid>
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Started at' value={container ? formatDate(container.startedAt) : "(none)"} />
+                </Grid>
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Finished at' value={container ? formatDate(container.finishedAt) : "(none)"} />
+                </Grid>
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Container run time'>
+                        <ContainerRunTime uuid={containerRequest.uuid} />
+                    </DetailsAttribute>
+                </Grid>
+                {(containerRequest && containerRequest.modifiedByUserUuid) && <Grid item xs={12} md={mdSize} data-cy="process-details-attributes-modifiedby-user">
+                    <DetailsAttribute
+                        label='Submitted by' linkToUuid={containerRequest.modifiedByUserUuid}
+                        uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+                </Grid>}
+                {(container && container.runtimeUserUuid && container.runtimeUserUuid !== containerRequest.modifiedByUserUuid) && <Grid item xs={12} md={mdSize} data-cy="process-details-attributes-runtime-user">
+                    <DetailsAttribute
+                        label='Run as' linkToUuid={container.runtimeUserUuid}
+                        uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+                </Grid>}
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Requesting container UUID' value={containerRequest.requestingContainerUuid || "(none)"} />
+                </Grid>
+                <Grid item xs={6}>
+                    <DetailsAttribute label='Output collection' />
+                    {containerRequest.outputUuid && <span onClick={() => props.navigateToOutput(containerRequest!)}>
+                        <CollectionName className={classes.link} uuid={containerRequest.outputUuid} />
+                    </span>}
+                </Grid>
+                {container && <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Cost' value={
+                        `${hasTotalCost ? formatCost(containerRequest.cumulativeCost) + ' total, ' : (totalCostNotReady ? 'total pending completion, ' : '')}${container.cost > 0 ? formatCost(container.cost) : 'not available'} for this container`
+                    } />
+
+                    {container && workflowCollection && <Grid item xs={12} md={mdSize}>
+                        <DetailsAttribute label='Workflow code' link={getCollectionUrl(workflowCollection)} value={workflowPath} />
+                    </Grid>}
+                </Grid>}
+                {containerRequest.properties.template_uuid &&
+                    <Grid item xs={12} md={mdSize}>
+                        <span onClick={() => props.openWorkflow(containerRequest.properties.template_uuid)}>
+                            <DetailsAttribute classValue={classes.link}
+                                label='Workflow' value={containerRequest.properties.workflowName} />
+                        </span>
+                    </Grid>}
+                <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Priority' value={containerRequest.priority} />
+                </Grid>
+                {/*
+                       NOTE: The property list should be kept at the bottom, because it spans
+                       the entire available width, without regards of the twoCol prop.
+                       */}
+                <Grid item xs={12} md={12}>
+                    <DetailsAttribute label='Properties' />
+                    {filteredPropertyKeys.length > 0
+                        ? filteredPropertyKeys.map(k =>
+                            Array.isArray(containerRequest.properties[k])
+                                ? containerRequest.properties[k].map((v: string) =>
+                                    getPropertyChip(k, v, undefined, classes.propertyTag))
+                                : getPropertyChip(k, containerRequest.properties[k], undefined, classes.propertyTag))
+                        : <div>No properties</div>}
+                </Grid>
+            </Grid>;
+        }
+    )
+);
diff --git a/services/workbench2/src/views/process-panel/process-details-card.tsx b/services/workbench2/src/views/process-panel/process-details-card.tsx
new file mode 100644 (file)
index 0000000..37f01dd
--- /dev/null
@@ -0,0 +1,159 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Typography,
+    Button,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { CloseIcon, MoreVerticalIcon, ProcessIcon, StartIcon, StopIcon } from 'components/icon/icon';
+import { Process, isProcessRunnable, isProcessResumable, isProcessCancelable } from 'store/processes/process';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { ProcessDetailsAttributes } from './process-details-attributes';
+import { ProcessStatus } from 'views-components/data-explorer/renderers';
+import classNames from 'classnames';
+
+type CssRules = 'card' | 'content' | 'title' | 'header' | 'cancelButton' | 'avatar' | 'iconHeader' | 'actionButton';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL,
+    },
+    avatar: {
+        alignSelf: 'flex-start',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    content: {
+        padding: theme.spacing.unit * 1.0,
+        paddingTop: theme.spacing.unit * 0.5,
+        '&:last-child': {
+            paddingBottom: theme.spacing.unit * 1,
+        }
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.green700,
+    },
+    actionButton: {
+        padding: "0px 5px 0 0",
+        marginRight: "5px",
+        fontSize: '0.78rem',
+    },
+    cancelButton: {
+        color: theme.palette.common.white,
+        backgroundColor: theme.customs.colors.red900,
+        '&:hover': {
+            backgroundColor: theme.customs.colors.red900,
+        },
+        '& svg': {
+            fontSize: '22px',
+        },
+    },
+});
+
+export interface ProcessDetailsCardDataProps {
+    process: Process;
+    cancelProcess: (uuid: string) => void;
+    startProcess: (uuid: string) => void;
+    resumeOnHoldWorkflow: (uuid: string) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
+}
+
+type ProcessDetailsCardProps = ProcessDetailsCardDataProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessDetailsCard = withStyles(styles)(
+    ({ cancelProcess, startProcess, resumeOnHoldWorkflow, onContextMenu, classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
+        let runAction: ((uuid: string) => void) | undefined = undefined;
+        if (isProcessRunnable(process)) {
+            runAction = startProcess;
+        } else if (isProcessResumable(process)) {
+            runAction = resumeOnHoldWorkflow;
+        }
+
+        return <Card className={classes.card}>
+            <CardHeader
+                className={classes.header}
+                classes={{
+                    content: classes.title,
+                    avatar: classes.avatar,
+                }}
+                avatar={<ProcessIcon className={classes.iconHeader} />}
+                title={
+                    <Tooltip title={process.containerRequest.name} placement="bottom-start">
+                        <Typography noWrap variant='h6'>
+                            {process.containerRequest.name}
+                        </Typography>
+                    </Tooltip>
+                }
+                subheader={
+                    <Tooltip title={getDescription(process)} placement="bottom-start">
+                        <Typography noWrap variant='body1' color='inherit'>
+                            {getDescription(process)}
+                        </Typography>
+                    </Tooltip>}
+                action={
+                    <div>
+                        {runAction !== undefined &&
+                            <Button
+                                data-cy="process-run-button"
+                                variant="contained"
+                                size="small"
+                                color="primary"
+                                className={classes.actionButton}
+                                onClick={() => runAction && runAction(process.containerRequest.uuid)}>
+                                <StartIcon />
+                                Run
+                            </Button>}
+                        {isProcessCancelable(process) &&
+                            <Button
+                                data-cy="process-cancel-button"
+                                variant="contained"
+                                size="small"
+                                color="primary"
+                                className={classNames(classes.actionButton, classes.cancelButton)}
+                                onClick={() => cancelProcess(process.containerRequest.uuid)}>
+                                <StopIcon />
+                                Cancel
+                            </Button>}
+                        <ProcessStatus uuid={process.containerRequest.uuid} />
+                        <Tooltip title="More options" disableFocusListener>
+                            <IconButton
+                                aria-label="More options"
+                                onClick={event => onContextMenu(event)}>
+                                <MoreVerticalIcon />
+                            </IconButton>
+                        </Tooltip>
+                        {doHidePanel &&
+                            <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                                <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                            </Tooltip>}
+                    </div>
+                } />
+            <CardContent className={classes.content}>
+                <ProcessDetailsAttributes request={process.containerRequest} twoCol hideProcessPanelRedundantFields />
+            </CardContent>
+        </Card>;
+    }
+);
+
+const getDescription = (process: Process) =>
+    process.containerRequest.description || '(no-description)';
diff --git a/services/workbench2/src/views/process-panel/process-io-card.test.tsx b/services/workbench2/src/views/process-panel/process-io-card.test.tsx
new file mode 100644 (file)
index 0000000..c0feead
--- /dev/null
@@ -0,0 +1,241 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { mount, configure } from 'enzyme';
+import { combineReducers, createStore } from "redux";
+import { CircularProgress, MuiThemeProvider, Tab, TableBody } from "@material-ui/core";
+import { CustomTheme } from 'common/custom-theme';
+import Adapter from "enzyme-adapter-react-16";
+import { Provider } from 'react-redux';
+import { ProcessIOCard, ProcessIOCardType } from './process-io-card';
+import { DefaultView } from "components/default-view/default-view";
+import { DefaultVirtualCodeSnippet } from "components/default-code-snippet/default-virtual-code-snippet";
+import { ProcessOutputCollectionFiles } from './process-output-collection-files';
+import { MemoryRouter } from 'react-router-dom';
+
+// Mock collection files component since it just needs to exist
+jest.mock('views/process-panel/process-output-collection-files');
+// Mock autosizer for the io panel virtual list
+jest.mock('react-virtualized-auto-sizer', () => ({ children }: any) => children({ height: 600, width: 600 }));
+
+configure({ adapter: new Adapter() });
+
+describe('renderers', () => {
+    let store;
+
+    describe('ProcessStatus', () => {
+
+        beforeEach(() => {
+            store = createStore(combineReducers({
+                auth: (state: any = {}, action: any) => state,
+            }));
+        });
+
+        it('shows main process input loading when raw or params null', () => {
+            // when
+            let panel = mount(
+                <Provider store={store}>
+                    <MuiThemeProvider theme={CustomTheme}>
+                        <ProcessIOCard
+                            label={ProcessIOCardType.INPUT}
+                            process={false} // Treat as a main process, no requestingContainerUuid
+                            params={null}
+                            raw={{}}
+                        />
+                    </MuiThemeProvider>
+                </Provider>
+                );
+
+            // then
+            expect(panel.find(Tab).exists()).toBeFalsy();
+            expect(panel.find(CircularProgress));
+
+            // when
+            panel = mount(
+                <Provider store={store}>
+                    <MuiThemeProvider theme={CustomTheme}>
+                        <ProcessIOCard
+                            label={ProcessIOCardType.INPUT}
+                            process={false} // Treat as a main process, no requestingContainerUuid
+                            params={[]}
+                            raw={null}
+                        />
+                    </MuiThemeProvider>
+                </Provider>
+                );
+
+            // then
+            expect(panel.find(Tab).exists()).toBeFalsy();
+            expect(panel.find(CircularProgress));
+        });
+
+        it('shows main process empty params and raw', () => {
+            // when
+            let panel = mount(
+                <Provider store={store}>
+                    <MuiThemeProvider theme={CustomTheme}>
+                        <ProcessIOCard
+                            label={ProcessIOCardType.INPUT}
+                            process={false} // Treat as a main process, no requestingContainerUuid
+                            params={[]}
+                            raw={{}}
+                        />
+                    </MuiThemeProvider>
+                </Provider>
+                );
+
+            // then
+            expect(panel.find(CircularProgress).exists()).toBeFalsy();
+            expect(panel.find(Tab).exists()).toBeFalsy();
+            expect(panel.find(DefaultView).text()).toEqual('No parameters found');
+        });
+
+        it('shows main process with raw', () => {
+            // when
+            const raw = {some: 'data'};
+            let panel = mount(
+                <Provider store={store}>
+                    <MuiThemeProvider theme={CustomTheme}>
+                        <ProcessIOCard
+                            label={ProcessIOCardType.INPUT}
+                            process={false} // Treat as a main process, no requestingContainerUuid
+                            params={[]}
+                            raw={raw}
+                        />
+                    </MuiThemeProvider>
+                </Provider>
+                );
+
+            // then
+            expect(panel.find(CircularProgress).exists()).toBeFalsy();
+            expect(panel.find(Tab).length).toBe(1);
+            expect(panel.find(DefaultVirtualCodeSnippet).text()).toContain(JSON.stringify(raw, null, 2).replace(/\n/g, ''));
+        });
+
+        it('shows main process with params', () => {
+            // when
+            const parameters = [{id: 'someId', label: 'someLabel', value: {display: 'someValue'}}];
+            let panel = mount(
+                <Provider store={store}>
+                    <MuiThemeProvider theme={CustomTheme}>
+                        <ProcessIOCard
+                            label={ProcessIOCardType.INPUT}
+                            process={false} // Treat as a main process, no requestingContainerUuid
+                            params={parameters}
+                            raw={{}}
+                        />
+                    </MuiThemeProvider>
+                </Provider>
+                );
+
+            // then
+            expect(panel.find(CircularProgress).exists()).toBeFalsy();
+            expect(panel.find(Tab).length).toBe(2); // Empty raw is shown if parameters are present
+            expect(panel.find(TableBody).text()).toContain('someId');
+            expect(panel.find(TableBody).text()).toContain('someLabel');
+            expect(panel.find(TableBody).text()).toContain('someValue');
+        });
+
+        // Subprocess
+
+        it('shows subprocess loading', () => {
+            // when
+            const subprocess = {containerRequest: {requestingContainerUuid: 'xyz'}};
+            let panel = mount(
+                <Provider store={store}>
+                    <MuiThemeProvider theme={CustomTheme}>
+                        <ProcessIOCard
+                            label={ProcessIOCardType.INPUT}
+                            process={subprocess} // Treat as a subprocess without outputUuid
+                            params={null}
+                            raw={null}
+                        />
+                    </MuiThemeProvider>
+                </Provider>
+                );
+
+            // then
+            expect(panel.find(Tab).exists()).toBeFalsy();
+            expect(panel.find(CircularProgress));
+        });
+
+        it('shows subprocess mounts', () => {
+            // when
+            const subprocess = {containerRequest: {requestingContainerUuid: 'xyz'}};
+            const sampleMount = {path: '/', pdh: 'abcdef12abcdef12abcdef12abcdef12+0'};
+            let panel = mount(
+                <Provider store={store}>
+                    <MemoryRouter>
+                        <MuiThemeProvider theme={CustomTheme}>
+                            <ProcessIOCard
+                                label={ProcessIOCardType.INPUT}
+                                process={subprocess} // Treat as a subprocess without outputUuid
+                                params={null}
+                                raw={null}
+                                mounts={[sampleMount]}
+                            />
+                        </MuiThemeProvider>
+                    </MemoryRouter>
+                </Provider>
+                );
+
+            // then
+            expect(panel.find(CircularProgress).exists()).toBeFalsy();
+            expect(panel.find(Tab).length).toBe(1); // Empty raw is hidden in subprocesses
+            expect(panel.find(TableBody).text()).toContain(sampleMount.pdh);
+
+        });
+
+        it('shows subprocess output collection', () => {
+            // when
+            const subprocess = {containerRequest: {requestingContainerUuid: 'xyz'}};
+            const outputCollection = '123456789';
+            let panel = mount(
+                <Provider store={store}>
+                    <MuiThemeProvider theme={CustomTheme}>
+                        <ProcessIOCard
+                            label={ProcessIOCardType.OUTPUT}
+                            process={subprocess} // Treat as a subprocess with outputUuid
+                            outputUuid={outputCollection}
+                            params={null}
+                            raw={null}
+                        />
+                    </MuiThemeProvider>
+                </Provider>
+                );
+
+            // then
+            expect(panel.find(CircularProgress).exists()).toBeFalsy();
+            expect(panel.find(Tab).length).toBe(1); // Unloaded raw is hidden in subprocesses
+            expect(panel.find(ProcessOutputCollectionFiles).prop('currentItemUuid')).toBe(outputCollection);
+        });
+
+        it('shows empty subprocess raw', () => {
+            // when
+            const subprocess = {containerRequest: {requestingContainerUuid: 'xyz'}};
+            const outputCollection = '123456789';
+            let panel = mount(
+                <Provider store={store}>
+                    <MuiThemeProvider theme={CustomTheme}>
+                        <ProcessIOCard
+                            label={ProcessIOCardType.OUTPUT}
+                            process={subprocess} // Treat as a subprocess with outputUuid
+                            outputUuid={outputCollection}
+                            params={null}
+                            raw={{}}
+                        />
+                    </MuiThemeProvider>
+                </Provider>
+                );
+
+            // then
+            expect(panel.find(CircularProgress).exists()).toBeFalsy();
+            expect(panel.find(Tab).length).toBe(2); // Empty raw is visible in subprocesses
+            expect(panel.find(Tab).first().text()).toBe('Collection');
+            expect(panel.find(ProcessOutputCollectionFiles).prop('currentItemUuid')).toBe(outputCollection);
+        });
+
+    });
+});
diff --git a/services/workbench2/src/views/process-panel/process-io-card.tsx b/services/workbench2/src/views/process-panel/process-io-card.tsx
new file mode 100644 (file)
index 0000000..9fce7e8
--- /dev/null
@@ -0,0 +1,1029 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { ReactElement, memo, useState } from "react";
+import { Dispatch } from "redux";
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Typography,
+    Tabs,
+    Tab,
+    Table,
+    TableHead,
+    TableBody,
+    TableRow,
+    TableCell,
+    Paper,
+    Grid,
+    Chip,
+    CircularProgress,
+} from "@material-ui/core";
+import { ArvadosTheme } from "common/custom-theme";
+import { CloseIcon, InputIcon, OutputIcon, MaximizeIcon, UnMaximizeIcon, InfoIcon } from "components/icon/icon";
+import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
+import {
+    BooleanCommandInputParameter,
+    CommandInputParameter,
+    CWLType,
+    Directory,
+    DirectoryArrayCommandInputParameter,
+    DirectoryCommandInputParameter,
+    EnumCommandInputParameter,
+    FileArrayCommandInputParameter,
+    FileCommandInputParameter,
+    FloatArrayCommandInputParameter,
+    FloatCommandInputParameter,
+    IntArrayCommandInputParameter,
+    IntCommandInputParameter,
+    isArrayOfType,
+    isPrimitiveOfType,
+    StringArrayCommandInputParameter,
+    StringCommandInputParameter,
+    getEnumType,
+} from "models/workflow";
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
+import { File } from "models/workflow";
+import { getInlineFileUrl } from "views-components/context-menu/actions/helpers";
+import { AuthState } from "store/auth/auth-reducer";
+import mime from "mime";
+import { DefaultView } from "components/default-view/default-view";
+import { getNavUrl } from "routes/routes";
+import { Link as RouterLink } from "react-router-dom";
+import { Link as MuiLink } from "@material-ui/core";
+import { InputCollectionMount } from "store/processes/processes-actions";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { ProcessOutputCollectionFiles } from "./process-output-collection-files";
+import { Process } from "store/processes/process";
+import { navigateTo } from "store/navigation/navigation-action";
+import classNames from "classnames";
+import { DefaultVirtualCodeSnippet } from "components/default-code-snippet/default-virtual-code-snippet";
+import { KEEP_URL_REGEX } from "models/resource";
+import { FixedSizeList } from 'react-window';
+import AutoSizer from "react-virtualized-auto-sizer";
+import { LinkProps } from "@material-ui/core/Link";
+
+type CssRules =
+    | "card"
+    | "content"
+    | "title"
+    | "header"
+    | "avatar"
+    | "iconHeader"
+    | "tableWrapper"
+    | "paramTableRoot"
+    | "paramTableCellText"
+    | "mountsTableRoot"
+    | "jsonWrapper"
+    | "keepLink"
+    | "collectionLink"
+    | "secondaryVal"
+    | "emptyValue"
+    | "noBorderRow"
+    | "symmetricTabs"
+    | "wrapTooltip";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: "100%",
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: 0,
+    },
+    iconHeader: {
+        fontSize: "1.875rem",
+        color: theme.customs.colors.greyL,
+    },
+    avatar: {
+        alignSelf: "flex-start",
+        paddingTop: theme.spacing.unit * 0.5,
+    },
+    // Card content
+    content: {
+        height: `calc(100% - ${theme.spacing.unit * 6}px)`,
+        padding: theme.spacing.unit * 1.0,
+        paddingTop: 0,
+        "&:last-child": {
+            paddingBottom: theme.spacing.unit * 1,
+        },
+    },
+    // Card title
+    title: {
+        overflow: "hidden",
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.greyD,
+        fontSize: "1.875rem",
+    },
+    // Applies to table tab and collection table content
+    tableWrapper: {
+        height: "auto",
+        maxHeight: `calc(100% - ${theme.spacing.unit * 6}px)`,
+        overflow: "auto",
+        // Use flexbox to keep scrolling at the virtual list level
+        display: "flex",
+        flexDirection: "column",
+        alignItems: "stretch", // Stretches output collection to full width
+
+    },
+
+    // Param table virtual list styles
+    paramTableRoot: {
+        display: "flex",
+        flexDirection: "column",
+        overflow: "hidden",
+        // Flex header
+        "& thead tr": {
+            alignItems: "end",
+            "& th": {
+                padding: "4px 25px 10px",
+            },
+        },
+        "& tbody": {
+            height: "100vh", // Must be constrained by panel maxHeight
+        },
+        // Flex header/body rows
+        "& thead tr, & > tbody tr": {
+            display: "flex",
+            // Flex header/body cells
+            "& th, & td": {
+                flexGrow: 1,
+                flexShrink: 1,
+                flexBasis: 0,
+                overflow: "hidden",
+            },
+            // Column width overrides
+            "& th:nth-of-type(1), & td:nth-of-type(1)": {
+                flexGrow: 0.7,
+            },
+            "& th:nth-last-of-type(1), & td:nth-last-of-type(1)": {
+                flexGrow: 2,
+            },
+        },
+        // Flex body rows
+        "& tbody tr": {
+            height: "40px",
+            // Flex body cells
+            "& td": {
+                padding: "2px 25px 2px",
+                overflow: "hidden",
+                display: "flex",
+                flexDirection: "row",
+                alignItems: "center",
+                whiteSpace: "nowrap",
+            },
+        },
+    },
+    // Param value cell typography styles
+    paramTableCellText: {
+        overflow: "hidden",
+        display: "flex",
+        // Every cell contents requires a wrapper for the ellipsis
+        // since adding ellipses to an anchor element parent results in misaligned tooltip
+        "& a, & span": {
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+        },
+        '& pre': {
+            margin: 0,
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+        },
+    },
+    mountsTableRoot: {
+        width: "100%",
+        "& thead th": {
+            verticalAlign: "bottom",
+            paddingBottom: "10px",
+        },
+        "& td, & th": {
+            paddingRight: "25px",
+        },
+    },
+    // JSON tab wrapper
+    jsonWrapper: {
+        height: `calc(100% - ${theme.spacing.unit * 6}px)`,
+    },
+    keepLink: {
+        color: theme.palette.primary.main,
+        textDecoration: "none",
+        // Overflow wrap for mounts table
+        overflowWrap: "break-word",
+        cursor: "pointer",
+    },
+    // Output collection tab link
+    collectionLink: {
+        margin: "10px",
+        "& a": {
+            color: theme.palette.primary.main,
+            textDecoration: "none",
+            overflowWrap: "break-word",
+            cursor: "pointer",
+        },
+    },
+    secondaryVal: {
+        paddingLeft: "20px",
+    },
+    emptyValue: {
+        color: theme.customs.colors.grey700,
+    },
+    noBorderRow: {
+        "& td": {
+            borderBottom: "none",
+            paddingTop: "2px",
+            paddingBottom: "2px",
+        },
+        height: "24px",
+    },
+    symmetricTabs: {
+        "& button": {
+            flexBasis: "0",
+        },
+    },
+    wrapTooltip: {
+        maxWidth: "600px",
+        wordWrap: "break-word",
+    },
+});
+
+export enum ProcessIOCardType {
+    INPUT = "Input Parameters",
+    OUTPUT = "Output Parameters",
+}
+export interface ProcessIOCardDataProps {
+    process?: Process;
+    label: ProcessIOCardType;
+    params: ProcessIOParameter[] | null;
+    raw: any;
+    mounts?: InputCollectionMount[];
+    outputUuid?: string;
+    forceShowParams?: boolean;
+}
+
+export interface ProcessIOCardActionProps {
+    navigateTo: (uuid: string) => void;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
+    navigateTo: uuid => dispatch<any>(navigateTo(uuid)),
+});
+
+type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessIOCard = withStyles(styles)(
+    connect(
+        null,
+        mapDispatchToProps
+    )(
+        ({
+            classes,
+            label,
+            params,
+            raw,
+            mounts,
+            outputUuid,
+            doHidePanel,
+            doMaximizePanel,
+            doUnMaximizePanel,
+            panelMaximized,
+            panelName,
+            process,
+            navigateTo,
+            forceShowParams,
+        }: ProcessIOCardProps) => {
+            const [mainProcTabState, setMainProcTabState] = useState(0);
+            const [subProcTabState, setSubProcTabState] = useState(0);
+            const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+                setMainProcTabState(value);
+            };
+            const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+                setSubProcTabState(value);
+            };
+
+            const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
+            const mainProcess = !(process && process!.containerRequest.requestingContainerUuid);
+            const showParamTable = mainProcess || forceShowParams;
+
+            const loading = raw === null || raw === undefined || params === null;
+
+            const hasRaw = !!(raw && Object.keys(raw).length > 0);
+            const hasParams = !!(params && params.length > 0);
+            // isRawLoaded allows subprocess panel to display raw even if it's {}
+            const isRawLoaded = !!(raw && Object.keys(raw).length >= 0);
+
+            // Subprocess
+            const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
+            const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
+            // Subprocess should not show loading if hasOutputCollection or hasInputMounts
+            const subProcessLoading = loading && !hasOutputCollecton && !hasInputMounts;
+
+            return (
+                <Card
+                    className={classes.card}
+                    data-cy="process-io-card"
+                >
+                    <CardHeader
+                        className={classes.header}
+                        classes={{
+                            content: classes.title,
+                            avatar: classes.avatar,
+                        }}
+                        avatar={<PanelIcon className={classes.iconHeader} />}
+                        title={
+                            <Typography
+                                noWrap
+                                variant="h6"
+                                color="inherit"
+                            >
+                                {label}
+                            </Typography>
+                        }
+                        action={
+                            <div>
+                                {doUnMaximizePanel && panelMaximized && (
+                                    <Tooltip
+                                        title={`Unmaximize ${panelName || "panel"}`}
+                                        disableFocusListener
+                                    >
+                                        <IconButton onClick={doUnMaximizePanel}>
+                                            <UnMaximizeIcon />
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                                {doMaximizePanel && !panelMaximized && (
+                                    <Tooltip
+                                        title={`Maximize ${panelName || "panel"}`}
+                                        disableFocusListener
+                                    >
+                                        <IconButton onClick={doMaximizePanel}>
+                                            <MaximizeIcon />
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                                {doHidePanel && (
+                                    <Tooltip
+                                        title={`Close ${panelName || "panel"}`}
+                                        disableFocusListener
+                                    >
+                                        <IconButton
+                                            disabled={panelMaximized}
+                                            onClick={doHidePanel}
+                                        >
+                                            <CloseIcon />
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                            </div>
+                        }
+                    />
+                    <CardContent className={classes.content}>
+                        {showParamTable ? (
+                            <>
+                                {/* raw is undefined until params are loaded */}
+                                {loading && (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <CircularProgress />
+                                    </Grid>
+                                )}
+                                {/* Once loaded, either raw or params may still be empty
+                                  *   Raw when all params are empty
+                                  *   Params when raw is provided by containerRequest properties but workflow mount is absent for preview
+                                  */}
+                                {!loading && (hasRaw || hasParams) && (
+                                    <>
+                                        <Tabs
+                                            value={mainProcTabState}
+                                            onChange={handleMainProcTabChange}
+                                            variant="fullWidth"
+                                            className={classes.symmetricTabs}
+                                        >
+                                            {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
+                                            {hasParams && <Tab label="Parameters" />}
+                                            {!forceShowParams && <Tab label="JSON" />}
+                                            {hasOutputCollecton && <Tab label="Collection" />}
+                                        </Tabs>
+                                        {mainProcTabState === 0 && params && hasParams && (
+                                            <div className={classes.tableWrapper}>
+                                                <ProcessIOPreview
+                                                    data={params}
+                                                    valueLabel={forceShowParams ? "Default value" : "Value"}
+                                                />
+                                            </div>
+                                        )}
+                                        {(mainProcTabState === 1 || !hasParams) && (
+                                            <div className={classes.jsonWrapper}>
+                                                <ProcessIORaw data={raw} />
+                                            </div>
+                                        )}
+                                        {mainProcTabState === 2 && hasOutputCollecton && (
+                                            <>
+                                                {outputUuid && (
+                                                    <Typography className={classes.collectionLink}>
+                                                        Output Collection:{" "}
+                                                        <MuiLink
+                                                            className={classes.keepLink}
+                                                            onClick={() => {
+                                                                navigateTo(outputUuid || "");
+                                                            }}
+                                                        >
+                                                            {outputUuid}
+                                                        </MuiLink>
+                                                    </Typography>
+                                                )}
+                                                <ProcessOutputCollectionFiles
+                                                    isWritable={false}
+                                                    currentItemUuid={outputUuid}
+                                                />
+                                            </>
+                                        )}
+
+                                    </>
+                                )}
+                                {!loading && !hasRaw && !hasParams && (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <DefaultView messages={["No parameters found"]} />
+                                    </Grid>
+                                )}
+                            </>
+                        ) : (
+                            // Subprocess
+                            <>
+                                {subProcessLoading ? (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <CircularProgress />
+                                    </Grid>
+                                ) : !subProcessLoading && (hasInputMounts || hasOutputCollecton || isRawLoaded) ? (
+                                    <>
+                                        <Tabs
+                                            value={subProcTabState}
+                                            onChange={handleSubProcTabChange}
+                                            variant="fullWidth"
+                                            className={classes.symmetricTabs}
+                                        >
+                                            {hasInputMounts && <Tab label="Collections" />}
+                                            {hasOutputCollecton && <Tab label="Collection" />}
+                                            {isRawLoaded && <Tab label="JSON" />}
+                                        </Tabs>
+                                        {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
+                                        {subProcTabState === 0 && hasOutputCollecton && (
+                                            <div className={classes.tableWrapper}>
+                                                <>
+                                                    {outputUuid && (
+                                                        <Typography className={classes.collectionLink}>
+                                                            Output Collection:{" "}
+                                                            <MuiLink
+                                                                className={classes.keepLink}
+                                                                onClick={() => {
+                                                                    navigateTo(outputUuid || "");
+                                                                }}
+                                                            >
+                                                                {outputUuid}
+                                                            </MuiLink>
+                                                        </Typography>
+                                                    )}
+                                                    <ProcessOutputCollectionFiles
+                                                        isWritable={false}
+                                                        currentItemUuid={outputUuid}
+                                                    />
+                                                </>
+                                            </div>
+                                        )}
+                                        {isRawLoaded && (subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
+                                            <div className={classes.jsonWrapper}>
+                                                <ProcessIORaw data={raw} />
+                                            </div>
+                                        )}
+                                    </>
+                                ) : (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <DefaultView messages={["No data to display"]} />
+                                    </Grid>
+                                )}
+                            </>
+                        )}
+                    </CardContent>
+                </Card>
+            );
+        }
+    )
+);
+
+export type ProcessIOValue = {
+    display: ReactElement<any, any>;
+    imageUrl?: string;
+    collection?: ReactElement<any, any>;
+    secondary?: boolean;
+};
+
+export type ProcessIOParameter = {
+    id: string;
+    label: string;
+    value: ProcessIOValue;
+};
+
+interface ProcessIOPreviewDataProps {
+    data: ProcessIOParameter[];
+    valueLabel: string;
+}
+
+type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
+
+const ProcessIOPreview = memo(
+    withStyles(styles)(({ classes, data, valueLabel }: ProcessIOPreviewProps) => {
+        const showLabel = data.some((param: ProcessIOParameter) => param.label);
+
+        const hasMoreValues = (index: number) => (
+            data[index+1] && !isMainRow(data[index+1])
+        );
+
+        const isMainRow = (param: ProcessIOParameter) => (
+            param &&
+            ((param.id || param.label) &&
+            !param.value.secondary)
+        );
+
+        const RenderRow = ({index, style}) => {
+            const param = data[index];
+
+            const rowClasses = {
+                [classes.noBorderRow]: hasMoreValues(index),
+            };
+
+            return <TableRow
+                style={style}
+                className={classNames(rowClasses)}
+                data-cy={isMainRow(param) ? "process-io-param" : ""}>
+                <TableCell>
+                    <Tooltip title={param.id}>
+                        <Typography className={classes.paramTableCellText}>
+                            <span>
+                                {param.id}
+                            </span>
+                        </Typography>
+                    </Tooltip>
+                </TableCell>
+                {showLabel && <TableCell>
+                    <Tooltip title={param.label}>
+                        <Typography className={classes.paramTableCellText}>
+                            <span>
+                                {param.label}
+                            </span>
+                        </Typography>
+                    </Tooltip>
+                </TableCell>}
+                <TableCell>
+                    <ProcessValuePreview
+                        value={param.value}
+                    />
+                </TableCell>
+                <TableCell>
+                    <Typography className={classes.paramTableCellText}>
+                        {/** Collection is an anchor so doesn't require wrapper element */}
+                        {param.value.collection}
+                    </Typography>
+                </TableCell>
+            </TableRow>;
+        };
+
+        return (
+            <Table
+                className={classes.paramTableRoot}
+                aria-label="Process IO Preview"
+            >
+                <TableHead>
+                    <TableRow>
+                        <TableCell>Name</TableCell>
+                        {showLabel && <TableCell>Label</TableCell>}
+                        <TableCell>{valueLabel}</TableCell>
+                        <TableCell>Collection</TableCell>
+                    </TableRow>
+                </TableHead>
+                <TableBody>
+                    <AutoSizer>
+                        {({ height, width }) =>
+                            <FixedSizeList
+                                height={height}
+                                itemCount={data.length}
+                                itemSize={40}
+                                width={width}
+                            >
+                                {RenderRow}
+                            </FixedSizeList>
+                        }
+                    </AutoSizer>
+                </TableBody>
+            </Table>
+        );
+    })
+);
+
+interface ProcessValuePreviewProps {
+    value: ProcessIOValue;
+}
+
+const ProcessValuePreview = withStyles(styles)(({ value, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) => (
+    <Typography className={classNames(classes.paramTableCellText, value.secondary && classes.secondaryVal)}>
+        {value.display}
+    </Typography>
+));
+
+interface ProcessIORawDataProps {
+    data: ProcessIOParameter[];
+}
+
+const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
+    <Paper elevation={0} style={{minWidth: "100%", height: "100%"}}>
+        <DefaultVirtualCodeSnippet
+            lines={JSON.stringify(data, null, 2).split('\n')}
+            linked
+        />
+    </Paper>
+));
+
+interface ProcessInputMountsDataProps {
+    mounts: InputCollectionMount[];
+}
+
+type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
+
+const ProcessInputMounts = withStyles(styles)(
+    connect((state: RootState) => ({
+        auth: state.auth,
+    }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
+        <Table
+            className={classes.mountsTableRoot}
+            aria-label="Process Input Mounts"
+        >
+            <TableHead>
+                <TableRow>
+                    <TableCell>Path</TableCell>
+                    <TableCell>Portable Data Hash</TableCell>
+                </TableRow>
+            </TableHead>
+            <TableBody>
+                {mounts.map(mount => (
+                    <TableRow key={mount.path}>
+                        <TableCell>
+                            <pre>{mount.path}</pre>
+                        </TableCell>
+                        <TableCell>
+                            <RouterLink
+                                to={getNavUrl(mount.pdh, auth)}
+                                className={classes.keepLink}
+                            >
+                                {mount.pdh}
+                            </RouterLink>
+                        </TableCell>
+                    </TableRow>
+                ))}
+            </TableBody>
+        </Table>
+    ))
+);
+
+type FileWithSecondaryFiles = {
+    secondaryFiles: File[];
+};
+
+export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
+    switch (true) {
+        case isPrimitiveOfType(input, CWLType.BOOLEAN):
+            const boolValue = (input as BooleanCommandInputParameter).value;
+            return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
+                ? [{ display: <PrimitiveTooltip data={boolValue}>{renderPrimitiveValue(boolValue, false)}</PrimitiveTooltip> }]
+                : [{ display: <EmptyValue /> }];
+
+        case isPrimitiveOfType(input, CWLType.INT):
+        case isPrimitiveOfType(input, CWLType.LONG):
+            const intValue = (input as IntCommandInputParameter).value;
+            return intValue !== undefined &&
+                // Missing values are empty array
+                !(Array.isArray(intValue) && intValue.length === 0)
+                ? [{ display: <PrimitiveTooltip data={intValue}>{renderPrimitiveValue(intValue, false)}</PrimitiveTooltip> }]
+                : [{ display: <EmptyValue /> }];
+
+        case isPrimitiveOfType(input, CWLType.FLOAT):
+        case isPrimitiveOfType(input, CWLType.DOUBLE):
+            const floatValue = (input as FloatCommandInputParameter).value;
+            return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
+                ? [{ display: <PrimitiveTooltip data={floatValue}>{renderPrimitiveValue(floatValue, false)}</PrimitiveTooltip> }]
+                : [{ display: <EmptyValue /> }];
+
+        case isPrimitiveOfType(input, CWLType.STRING):
+            const stringValue = (input as StringCommandInputParameter).value || undefined;
+            return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
+                ? [{ display: <PrimitiveTooltip data={stringValue}>{renderPrimitiveValue(stringValue, false)}</PrimitiveTooltip> }]
+                : [{ display: <EmptyValue /> }];
+
+        case isPrimitiveOfType(input, CWLType.FILE):
+            const mainFile = (input as FileCommandInputParameter).value;
+            // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
+            const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
+            const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
+            const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
+            return files.length
+                ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
+                : [{ display: <EmptyValue /> }];
+
+        case isPrimitiveOfType(input, CWLType.DIRECTORY):
+            const directory = (input as DirectoryCommandInputParameter).value;
+            return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
+                ? [directoryToProcessIOValue(directory, auth, pdh)]
+                : [{ display: <EmptyValue /> }];
+
+        case getEnumType(input) !== null:
+            const enumValue = (input as EnumCommandInputParameter).value;
+            return enumValue !== undefined && enumValue ? [{ display: <PrimitiveTooltip data={enumValue}>{enumValue}</PrimitiveTooltip> }] : [{ display: <EmptyValue /> }];
+
+        case isArrayOfType(input, CWLType.STRING):
+            const strArray = (input as StringArrayCommandInputParameter).value || [];
+            return strArray.length ? [{ display: <PrimitiveArrayTooltip data={strArray}>{strArray.map(val => renderPrimitiveValue(val, true))}</PrimitiveArrayTooltip> }] : [{ display: <EmptyValue /> }];
+
+        case isArrayOfType(input, CWLType.INT):
+        case isArrayOfType(input, CWLType.LONG):
+            const intArray = (input as IntArrayCommandInputParameter).value || [];
+            return intArray.length ? [{ display: <PrimitiveArrayTooltip data={intArray}>{intArray.map(val => renderPrimitiveValue(val, true))}</PrimitiveArrayTooltip> }] : [{ display: <EmptyValue /> }];
+
+        case isArrayOfType(input, CWLType.FLOAT):
+        case isArrayOfType(input, CWLType.DOUBLE):
+            const floatArray = (input as FloatArrayCommandInputParameter).value || [];
+            return floatArray.length ? [{ display: <PrimitiveArrayTooltip data={floatArray}>{floatArray.map(val => renderPrimitiveValue(val, true))}</PrimitiveArrayTooltip> }] : [{ display: <EmptyValue /> }];
+
+        case isArrayOfType(input, CWLType.FILE):
+            const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
+            const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
+
+            // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering
+            let fileArrayValues: ProcessIOValue[] = [];
+            for (let i = 0; i < fileArrayMainFiles.length; i++) {
+                const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
+                fileArrayValues.push(
+                    // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
+                    ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
+                    ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh))
+                );
+            }
+
+            return fileArrayValues.length ? fileArrayValues : [{ display: <EmptyValue /> }];
+
+        case isArrayOfType(input, CWLType.DIRECTORY):
+            const directories = (input as DirectoryArrayCommandInputParameter).value || [];
+            return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
+
+        default:
+            return [{ display: <UnsupportedValue /> }];
+    }
+};
+
+interface PrimitiveTooltipProps {
+    data: boolean | number | string;
+}
+
+const PrimitiveTooltip = (props: React.PropsWithChildren<PrimitiveTooltipProps>) => (
+    <Tooltip title={typeof props.data !== 'object' ? String(props.data) : ""}>
+        <pre>{props.children}</pre>
+    </Tooltip>
+);
+
+interface PrimitiveArrayTooltipProps {
+    data: string[];
+}
+
+const PrimitiveArrayTooltip = (props: React.PropsWithChildren<PrimitiveArrayTooltipProps>) => (
+    <Tooltip title={props.data.join(', ')}>
+        <span>{props.children}</span>
+    </Tooltip>
+);
+
+
+const renderPrimitiveValue = (value: any, asChip: boolean) => {
+    const isObject = typeof value === "object";
+    if (!isObject) {
+        return asChip ? (
+            <Chip
+                key={value}
+                label={String(value)}
+                style={{marginRight: "10px"}}
+            />
+        ) : (
+            <>{String(value)}</>
+        );
+    } else {
+        return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
+    }
+};
+
+/*
+ * @returns keep url without keep: prefix
+ */
+const getKeepUrl = (file: File | Directory, pdh?: string): string => {
+    const isKeepUrl = file.location?.startsWith("keep:") || false;
+    const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
+    return keepUrl || "";
+};
+
+interface KeepUrlProps {
+    auth: AuthState;
+    res: File | Directory;
+    pdh?: string;
+}
+
+const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
+    const keepUrl = getKeepUrl(res, pdh);
+    return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
+};
+
+const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
+    const pdhUrl = getResourcePdhUrl(res, pdh);
+    // Passing a pdh always returns a relative wb2 collection url
+    const pdhWbPath = getNavUrl(pdhUrl, auth);
+    return pdhUrl && pdhWbPath ? (
+        <Tooltip title={<>View collection in Workbench<br />{pdhUrl}</>}>
+            <RouterLink
+                to={pdhWbPath}
+                className={classes.keepLink}
+            >
+                {pdhUrl}
+            </RouterLink>
+        </Tooltip>
+    ) : (
+        <></>
+    );
+});
+
+const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
+    const keepUrl = getKeepUrl(res, pdh);
+    const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
+    const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
+
+    const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
+    return keepUrlPathNav ? (
+        <Tooltip classes={{tooltip: classes.wrapTooltip}} title={<>View in keep-web<br />{keepUrlPath || "/"}</>}>
+            <a
+                className={classes.keepLink}
+                href={keepUrlPathNav}
+                target="_blank"
+                rel="noopener noreferrer"
+            >
+                {keepUrlPath || "/"}
+            </a>
+        </Tooltip>
+    ) : (
+        <EmptyValue />
+    );
+});
+
+const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
+    let keepUrl = getKeepUrl(file, pdh);
+    return getInlineFileUrl(
+        `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
+        auth.config.keepWebServiceUrl,
+        auth.config.keepWebInlineServiceUrl
+    );
+};
+
+const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
+    const keepUrl = getKeepUrl(file, pdh);
+    return getInlineFileUrl(
+        `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
+        auth.config.keepWebServiceUrl,
+        auth.config.keepWebInlineServiceUrl
+    );
+};
+
+const isFileImage = (basename?: string): boolean => {
+    return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
+};
+
+const isFileUrl = (location?: string): boolean =>
+    !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
+
+const normalizeDirectoryLocation = (directory: Directory): Directory => {
+    if (!directory.location) {
+        return directory;
+    }
+    return {
+        ...directory,
+        location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
+    };
+};
+
+const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
+    if (isExternalValue(directory)) {
+        return { display: <UnsupportedValue /> };
+    }
+
+    const normalizedDirectory = normalizeDirectoryLocation(directory);
+    return {
+        display: (
+            <KeepUrlPath
+                auth={auth}
+                res={normalizedDirectory}
+                pdh={pdh}
+            />
+        ),
+        collection: (
+            <KeepUrlBase
+                auth={auth}
+                res={normalizedDirectory}
+                pdh={pdh}
+            />
+        ),
+    };
+};
+
+type MuiLinkWithTooltipProps = WithStyles<CssRules> & React.PropsWithChildren<LinkProps>;
+
+const MuiLinkWithTooltip = withStyles(styles)((props: MuiLinkWithTooltipProps) => (
+    <Tooltip title={props.title} classes={{tooltip: props.classes.wrapTooltip}}>
+        <MuiLink {...props}>
+            {props.children}
+        </MuiLink>
+    </Tooltip>
+));
+
+const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
+    if (isExternalValue(file)) {
+        return { display: <UnsupportedValue /> };
+    }
+
+    if (isFileUrl(file.location)) {
+        return {
+            display: (
+                <MuiLinkWithTooltip
+                    href={file.location}
+                    target="_blank"
+                    rel="noopener"
+                    title={file.location}
+                >
+                    {file.location}
+                </MuiLinkWithTooltip>
+            ),
+            secondary,
+        };
+    }
+
+    const resourcePdh = getResourcePdhUrl(file, pdh);
+    return {
+        display: (
+            <KeepUrlPath
+                auth={auth}
+                res={file}
+                pdh={pdh}
+            />
+        ),
+        secondary,
+        imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
+        collection:
+            resourcePdh !== mainFilePdh ? (
+                <KeepUrlBase
+                    auth={auth}
+                    res={file}
+                    pdh={pdh}
+                />
+            ) : (
+                <></>
+            ),
+    };
+};
+
+const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
+
+export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
+
+const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
+
+const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
+    <Chip
+        icon={<InfoIcon />}
+        label={"Cannot display value"}
+    />
+));
diff --git a/services/workbench2/src/views/process-panel/process-log-card.tsx b/services/workbench2/src/views/process-panel/process-log-card.tsx
new file mode 100644 (file)
index 0000000..b141abf
--- /dev/null
@@ -0,0 +1,192 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useState } from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Grid,
+    Typography,
+} from '@material-ui/core';
+import { useAsyncInterval } from 'common/use-async-interval';
+import { ArvadosTheme } from 'common/custom-theme';
+import {
+    CloseIcon,
+    CollectionIcon,
+    CopyIcon,
+    LogIcon,
+    MaximizeIcon,
+    UnMaximizeIcon,
+    TextDecreaseIcon,
+    TextIncreaseIcon,
+    WordWrapOffIcon,
+    WordWrapOnIcon,
+} from 'components/icon/icon';
+import { Process, isProcessRunning } from 'store/processes/process';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import {
+    FilterOption,
+    ProcessLogForm
+} from 'views/process-panel/process-log-form';
+import { ProcessLogCodeSnippet } from 'views/process-panel/process-log-code-snippet';
+import { DefaultView } from 'components/default-view/default-view';
+import { CodeSnippetDataProps } from 'components/code-snippet/code-snippet';
+import CopyToClipboard from 'react-copy-to-clipboard';
+
+type CssRules = 'card' | 'content' | 'title' | 'iconHeader' | 'header' | 'root' | 'logViewer' | 'logViewerContainer';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    content: {
+        padding: theme.spacing.unit * 0,
+        height: '100%',
+    },
+    logViewer: {
+        height: '100%',
+        overflowY: 'scroll', // Required for MacOS's Safari -- See #19687
+    },
+    logViewerContainer: {
+        height: '100%',
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.greyD
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL
+    },
+    root: {
+        height: '100%',
+    },
+});
+
+export interface ProcessLogsCardDataProps {
+    process: Process;
+    selectedFilter: FilterOption;
+    filters: FilterOption[];
+}
+
+export interface ProcessLogsCardActionProps {
+    onLogFilterChange: (filter: FilterOption) => void;
+    navigateToLog: (uuid: string) => void;
+    onCopy: (text: string) => void;
+    pollProcessLogs: (processUuid: string) => Promise<void>;
+}
+
+type ProcessLogsCardProps = ProcessLogsCardDataProps
+    & ProcessLogsCardActionProps
+    & CodeSnippetDataProps
+    & WithStyles<CssRules>
+    & MPVPanelProps;
+
+export const ProcessLogsCard = withStyles(styles)(
+    ({ classes, process, filters, selectedFilter, lines,
+        onLogFilterChange, navigateToLog, onCopy, pollProcessLogs,
+        doHidePanel, doMaximizePanel, doUnMaximizePanel, panelMaximized, panelName }: ProcessLogsCardProps) => {
+        const [wordWrap, setWordWrap] = useState<boolean>(true);
+        const [fontSize, setFontSize] = useState<number>(3);
+        const fontBaseSize = 10;
+        const fontStepSize = 1;
+
+        useAsyncInterval(() => (
+            pollProcessLogs(process.containerRequest.uuid)
+        ), isProcessRunning(process) ? 2000 : null);
+
+        return <Grid item className={classes.root} xs={12}>
+            <Card className={classes.card}>
+                <CardHeader className={classes.header}
+                    avatar={<LogIcon className={classes.iconHeader} />}
+                    action={<Grid container direction='row' alignItems='center'>
+                        <Grid item>
+                            <ProcessLogForm selectedFilter={selectedFilter}
+                                filters={filters} onChange={onLogFilterChange} />
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title="Decrease font size" disableFocusListener>
+                                <IconButton onClick={() => fontSize > 1 && setFontSize(fontSize-1)}>
+                                    <TextDecreaseIcon />
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title="Increase font size" disableFocusListener>
+                                <IconButton onClick={() => fontSize < 5 && setFontSize(fontSize+1)}>
+                                    <TextIncreaseIcon />
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title="Copy link to clipboard" disableFocusListener>
+                                <IconButton>
+                                    <CopyToClipboard text={lines.join()} onCopy={() => onCopy("Log copied to clipboard")}>
+                                        <CopyIcon />
+                                    </CopyToClipboard>
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title={`${wordWrap ? 'Disable' : 'Enable'} word wrapping`} disableFocusListener>
+                                <IconButton onClick={() => setWordWrap(!wordWrap)}>
+                                    {wordWrap ? <WordWrapOffIcon /> : <WordWrapOnIcon />}
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title="Go to Log collection" disableFocusListener>
+                                <IconButton onClick={() => navigateToLog(process.containerRequest.logUuid!)}>
+                                    <CollectionIcon />
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        { doUnMaximizePanel && panelMaximized &&
+                        <Tooltip title={`Unmaximize ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doUnMaximizePanel}><UnMaximizeIcon /></IconButton>
+                        </Tooltip> }
+                        { doMaximizePanel && !panelMaximized &&
+                        <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
+                        </Tooltip> }
+                        { doHidePanel &&
+                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton disabled={panelMaximized} onClick={doHidePanel}><CloseIcon /></IconButton>
+                        </Tooltip> }
+                    </Grid>}
+                    title={
+                        <Typography noWrap variant='h6' className={classes.title}>
+                            Logs
+                        </Typography>}
+                />
+                <CardContent className={classes.content}>
+                    {lines.length > 0
+                        ? < Grid
+                            className={classes.logViewerContainer}
+                            container
+                            spacing={24}
+                            direction='column'>
+                            <Grid className={classes.logViewer} item xs>
+                                <ProcessLogCodeSnippet fontSize={fontBaseSize+(fontStepSize*fontSize)} wordWrap={wordWrap} lines={lines} />
+                            </Grid>
+                        </Grid>
+                        : <DefaultView
+                            icon={LogIcon}
+                            messages={['No logs yet']} />
+                    }
+                </CardContent>
+            </Card>
+        </Grid >
+});
diff --git a/services/workbench2/src/views/process-panel/process-log-code-snippet.tsx b/services/workbench2/src/views/process-panel/process-log-code-snippet.tsx
new file mode 100644 (file)
index 0000000..f42dcaf
--- /dev/null
@@ -0,0 +1,132 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useEffect, useRef, useState } from 'react';
+import {
+    MuiThemeProvider,
+    createMuiTheme,
+    StyleRulesCallback,
+    withStyles,
+    WithStyles
+} from '@material-ui/core/styles';
+import grey from '@material-ui/core/colors/grey';
+import { ArvadosTheme } from 'common/custom-theme';
+import { Link, Typography } from '@material-ui/core';
+import { navigationNotAvailable } from 'store/navigation/navigation-action';
+import { Dispatch } from 'redux';
+import { connect, DispatchProp } from 'react-redux';
+import classNames from 'classnames';
+import { FederationConfig, getNavUrl } from 'routes/routes';
+import { RootState } from 'store/store';
+
+type CssRules = 'root' | 'wordWrapOn' | 'wordWrapOff' | 'logText';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        boxSizing: 'border-box',
+        overflow: 'auto',
+        backgroundColor: '#000',
+        height: `calc(100% - ${theme.spacing.unit * 4}px)`, // so that horizontal scollbar is visible
+        "& a": {
+            color: theme.palette.primary.main,
+        },
+    },
+    logText: {
+        padding: `0 ${theme.spacing.unit * 0.5}px`,
+    },
+    wordWrapOn: {
+        overflowWrap: 'anywhere',
+    },
+    wordWrapOff: {
+        whiteSpace: 'nowrap',
+    },
+});
+
+const theme = createMuiTheme({
+    overrides: {
+        MuiTypography: {
+            body2: {
+                color: grey["200"]
+            }
+        }
+    },
+    typography: {
+        fontFamily: 'monospace',
+        useNextVariants: true,
+    }
+});
+
+interface ProcessLogCodeSnippetProps {
+    lines: string[];
+    fontSize: number;
+    wordWrap?: boolean;
+}
+
+interface ProcessLogCodeSnippetAuthProps {
+    auth: FederationConfig;
+}
+
+const renderLinks = (fontSize: number, auth: FederationConfig, dispatch: Dispatch) => (text: string) => {
+    // Matches UUIDs & PDHs
+    const REGEX = /[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}|[0-9a-f]{32}\+\d+/g;
+    const links = text.match(REGEX);
+    if (!links) {
+        return <Typography style={{ fontSize: fontSize }}>{text}</Typography>;
+    }
+    return <Typography style={{ fontSize: fontSize }}>
+        {text.split(REGEX).map((part, index) =>
+            <React.Fragment key={index}>
+                {part}
+                {links[index] &&
+                    <Link onClick={() => {
+                        const url = getNavUrl(links[index], auth)
+                        if (url) {
+                            window.open(`${window.location.origin}${url}`, '_blank', "noopener");
+                        } else {
+                            dispatch(navigationNotAvailable(links[index]));
+                        }
+                    }}
+                        style={{ cursor: 'pointer' }}>
+                        {links[index]}
+                    </Link>}
+            </React.Fragment>
+        )}
+    </Typography>;
+};
+
+const mapStateToProps = (state: RootState): ProcessLogCodeSnippetAuthProps => ({
+    auth: state.auth,
+});
+
+export const ProcessLogCodeSnippet = withStyles(styles)(connect(mapStateToProps)(
+    ({ classes, lines, fontSize, auth, dispatch, wordWrap }: ProcessLogCodeSnippetProps & WithStyles<CssRules> & ProcessLogCodeSnippetAuthProps & DispatchProp) => {
+        const [followMode, setFollowMode] = useState<boolean>(true);
+        const scrollRef = useRef<HTMLDivElement>(null);
+
+        useEffect(() => {
+            if (followMode && scrollRef.current && lines.length > 0) {
+                // Scroll to bottom
+                scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+            }
+        }, [followMode, lines, scrollRef]);
+
+        return <MuiThemeProvider theme={theme}>
+            <div ref={scrollRef} className={classes.root}
+                onScroll={(e) => {
+                    const elem = e.target as HTMLDivElement;
+                    if (elem.scrollTop + (elem.clientHeight * 1.1) >= elem.scrollHeight) {
+                        setFollowMode(true);
+                    } else {
+                        setFollowMode(false);
+                    }
+                }}>
+                {lines.map((line: string, index: number) =>
+                    <Typography key={index} component="span"
+                        className={classNames(classes.logText, wordWrap ? classes.wordWrapOn : classes.wordWrapOff)}>
+                        {renderLinks(fontSize, auth, dispatch)(line)}
+                    </Typography>
+                )}
+            </div>
+        </MuiThemeProvider>
+    }));
diff --git a/services/workbench2/src/views/process-panel/process-log-form.tsx b/services/workbench2/src/views/process-panel/process-log-form.tsx
new file mode 100644 (file)
index 0000000..1f63e28
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    withStyles,
+    WithStyles,
+    StyleRulesCallback,
+    FormControl,
+    Select,
+    MenuItem,
+    Input
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+
+type CssRules = 'formControl';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    formControl: {
+        minWidth: theme.spacing.unit * 15,
+    }
+});
+
+export interface FilterOption {
+    label: string;
+    value: string;
+}
+
+export interface ProcessLogFormDataProps {
+    selectedFilter: FilterOption;
+    filters: FilterOption[];
+}
+
+export interface ProcessLogFormActionProps {
+    onChange: (filter: FilterOption) => void;
+}
+
+type ProcessLogFormProps = ProcessLogFormDataProps & ProcessLogFormActionProps & WithStyles<CssRules>;
+
+export const ProcessLogForm = withStyles(styles)(
+    ({ classes, selectedFilter, onChange, filters }: ProcessLogFormProps) =>
+        <form autoComplete="off" data-cy="process-logs-filter">
+            <FormControl className={classes.formControl}>
+                <Select
+                    value={selectedFilter.value}
+                    onChange={({ target }) => onChange({ label: target.innerText, value: target.value })}
+                    input={<Input name="eventType" id="log-label-placeholder" />}
+                    name="eventType">
+                    {
+                        filters.map(option =>
+                            <MenuItem key={option.value} value={option.value}>{option.label}</MenuItem>
+                        )
+                    }
+                </Select>
+            </FormControl>
+        </form>
+);
\ No newline at end of file
diff --git a/services/workbench2/src/views/process-panel/process-output-collection-files.ts b/services/workbench2/src/views/process-panel/process-output-collection-files.ts
new file mode 100644 (file)
index 0000000..c816556
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import {
+    CollectionPanelFiles as Component,
+    CollectionPanelFilesProps
+} from "components/collection-panel-files/collection-panel-files";
+import { Dispatch } from "redux";
+import { collectionPanelFilesAction } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
+import { openContextMenu, openCollectionFilesContextMenu } from 'store/context-menu/context-menu-actions';
+import { openUploadCollectionFilesDialog } from 'store/collections/collection-upload-actions';
+import { ResourceKind } from "models/resource";
+import { openDetailsPanel } from 'store/details-panel/details-panel-action';
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onSearchChange' | 'onFileClick' | 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
+    onUploadDataClick: (targetLocation?: string) => {
+        dispatch<any>(openUploadCollectionFilesDialog(targetLocation));
+    },
+    onCollapseToggle: (id) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
+    },
+    onSelectionToggle: (event, item) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: item.id }));
+    },
+    onItemMenuOpen: (event, item, isWritable) => {
+        const isDirectory = item.data.type === 'directory';
+        dispatch<any>(openContextMenu(
+            event,
+            {
+                menuKind: isWritable
+                    ? isDirectory
+                        ? ContextMenuKind.COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.COLLECTION_FILE_ITEM
+                    : isDirectory
+                        ? ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+                kind: ResourceKind.COLLECTION,
+                name: item.data.name,
+                uuid: item.id,
+                ownerUuid: ''
+            }
+        ));
+    },
+    onSearchChange: (searchValue: string) => {
+        dispatch(collectionPanelFilesAction.ON_SEARCH_CHANGE(searchValue));
+    },
+    onOptionsMenuOpen: (event, isWritable) => {
+        dispatch<any>(openCollectionFilesContextMenu(event, isWritable));
+    },
+    onFileClick: (id) => {
+        dispatch<any>(openDetailsPanel(id));
+    },
+});
+
+export const ProcessOutputCollectionFiles = connect(null, mapDispatchToProps)(Component);
diff --git a/services/workbench2/src/views/process-panel/process-panel-root.tsx b/services/workbench2/src/views/process-panel/process-panel-root.tsx
new file mode 100644 (file)
index 0000000..c8e93aa
--- /dev/null
@@ -0,0 +1,225 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
+import { ProcessIcon } from "components/icon/icon";
+import { Process } from "store/processes/process";
+import { SubprocessPanel } from "views/subprocess-panel/subprocess-panel";
+import { SubprocessFilterDataProps } from "components/subprocess-filter/subprocess-filter";
+import { MPVContainer, MPVPanelContent, MPVPanelState } from "components/multi-panel-view/multi-panel-view";
+import { ArvadosTheme } from "common/custom-theme";
+import { ProcessDetailsCard } from "./process-details-card";
+import { ProcessIOCard, ProcessIOCardType, ProcessIOParameter } from "./process-io-card";
+import { ProcessResourceCard } from "./process-resource-card";
+import { getProcessPanelLogs, ProcessLogsPanel } from "store/process-logs-panel/process-logs-panel";
+import { ProcessLogsCard } from "./process-log-card";
+import { FilterOption } from "views/process-panel/process-log-form";
+import { getInputCollectionMounts } from "store/processes/processes-actions";
+import { WorkflowInputsData } from "models/workflow";
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
+import { AuthState } from "store/auth/auth-reducer";
+import { ProcessCmdCard } from "./process-cmd-card";
+import { ContainerRequestResource } from "models/container-request";
+import { OutputDetails, NodeInstanceType } from "store/process-panel/process-panel";
+import { NotFoundView } from 'views/not-found-panel/not-found-panel';
+
+type CssRules = "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: "100%",
+    },
+});
+
+export interface ProcessPanelRootDataProps {
+    process?: Process;
+    subprocesses: Array<Process>;
+    filters: Array<SubprocessFilterDataProps>;
+    processLogsPanel: ProcessLogsPanel;
+    auth: AuthState;
+    inputRaw: WorkflowInputsData | null;
+    inputParams: ProcessIOParameter[] | null;
+    outputData: OutputDetails | null;
+    outputDefinitions: CommandOutputParameter[];
+    outputParams: ProcessIOParameter[] | null;
+    nodeInfo: NodeInstanceType | null;
+    usageReport: string | null;
+}
+
+export interface ProcessPanelRootActionProps {
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, process: Process) => void;
+    onToggle: (status: string) => void;
+    cancelProcess: (uuid: string) => void;
+    startProcess: (uuid: string) => void;
+    resumeOnHoldWorkflow: (uuid: string) => void;
+    onLogFilterChange: (filter: FilterOption) => void;
+    navigateToLog: (uuid: string) => void;
+    onCopyToClipboard: (uuid: string) => void;
+    loadInputs: (containerRequest: ContainerRequestResource) => void;
+    loadOutputs: (containerRequest: ContainerRequestResource) => void;
+    loadNodeJson: (containerRequest: ContainerRequestResource) => void;
+    loadOutputDefinitions: (containerRequest: ContainerRequestResource) => void;
+    updateOutputParams: () => void;
+    pollProcessLogs: (processUuid: string) => Promise<void>;
+}
+
+export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps & WithStyles<CssRules>;
+
+const panelsData: MPVPanelState[] = [
+    { name: "Details" },
+    { name: "Logs", visible: true },
+    { name: "Subprocesses" },
+    { name: "Outputs" },
+    { name: "Inputs" },
+    { name: "Command" },
+    { name: "Resources" },
+];
+
+export const ProcessPanelRoot = withStyles(styles)(
+    ({
+        process,
+        auth,
+        processLogsPanel,
+        inputRaw,
+        inputParams,
+        outputData,
+        outputDefinitions,
+        outputParams,
+        nodeInfo,
+        usageReport,
+        loadInputs,
+        loadOutputs,
+        loadNodeJson,
+        loadOutputDefinitions,
+        updateOutputParams,
+        ...props
+    }: ProcessPanelRootProps) => {
+        const outputUuid = process?.containerRequest.outputUuid;
+        const containerRequest = process?.containerRequest;
+        const inputMounts = getInputCollectionMounts(process?.containerRequest);
+
+        React.useEffect(() => {
+            if (containerRequest) {
+                // Load inputs from mounts or props
+                loadInputs(containerRequest);
+                // Fetch raw output (loads from props or keep)
+                loadOutputs(containerRequest);
+                // Loads output definitions from mounts into store
+                loadOutputDefinitions(containerRequest);
+                // load the assigned instance type from node.json in
+                // the log collection
+                loadNodeJson(containerRequest);
+            }
+        }, [containerRequest, loadInputs, loadOutputs, loadOutputDefinitions, loadNodeJson]);
+
+        const maxHeight = "100%";
+
+        // Trigger processing output params when raw or definitions change
+        React.useEffect(() => {
+            updateOutputParams();
+        }, [outputData, outputDefinitions, updateOutputParams]);
+
+        return process ? (
+            <MPVContainer
+                className={props.classes.root}
+                spacing={8}
+                panelStates={panelsData}
+                justify-content="flex-start"
+                direction="column"
+                wrap="nowrap">
+                <MPVPanelContent
+                    forwardProps
+                    xs="auto"
+                    data-cy="process-details">
+                    <ProcessDetailsCard
+                        process={process}
+                        onContextMenu={event => props.onContextMenu(event, process)}
+                        cancelProcess={props.cancelProcess}
+                        startProcess={props.startProcess}
+                        resumeOnHoldWorkflow={props.resumeOnHoldWorkflow}
+                    />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    minHeight={maxHeight}
+                    maxHeight={maxHeight}
+                    data-cy="process-logs">
+                    <ProcessLogsCard
+                        onCopy={props.onCopyToClipboard}
+                        process={process}
+                        lines={getProcessPanelLogs(processLogsPanel)}
+                        selectedFilter={{
+                            label: processLogsPanel.selectedFilter,
+                            value: processLogsPanel.selectedFilter,
+                        }}
+                        filters={processLogsPanel.filters.map(filter => ({ label: filter, value: filter }))}
+                        onLogFilterChange={props.onLogFilterChange}
+                        navigateToLog={props.navigateToLog}
+                        pollProcessLogs={props.pollProcessLogs}
+                    />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight={maxHeight}
+                    data-cy="process-children">
+                    <SubprocessPanel process={process} />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight={maxHeight}
+                    data-cy="process-outputs">
+                    <ProcessIOCard
+                        label={ProcessIOCardType.OUTPUT}
+                        process={process}
+                        params={outputParams}
+                        raw={outputData?.raw}
+                        outputUuid={outputUuid || ""}
+                    />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight={maxHeight}
+                    data-cy="process-inputs">
+                    <ProcessIOCard
+                        label={ProcessIOCardType.INPUT}
+                        process={process}
+                        params={inputParams}
+                        raw={inputRaw}
+                        mounts={inputMounts}
+                    />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs="auto"
+                    maxHeight={"50%"}
+                    data-cy="process-cmd">
+                    <ProcessCmdCard
+                        onCopy={props.onCopyToClipboard}
+                        process={process}
+                    />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    data-cy="process-resources">
+                    <ProcessResourceCard
+                        process={process}
+                        nodeInfo={nodeInfo}
+                        usageReport={usageReport}
+                    />
+                </MPVPanelContent>
+            </MPVContainer>
+        ) : (
+            <NotFoundView
+                icon={ProcessIcon}
+                messages={["Process not found"]}
+            />
+        );
+    }
+);
diff --git a/services/workbench2/src/views/process-panel/process-panel.tsx b/services/workbench2/src/views/process-panel/process-panel.tsx
new file mode 100644 (file)
index 0000000..f305290
--- /dev/null
@@ -0,0 +1,91 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from "store/store";
+import { connect } from "react-redux";
+import { getProcess, getSubprocesses, Process, getProcessStatus } from "store/processes/process";
+import { Dispatch } from "redux";
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { ProcessPanelRootDataProps, ProcessPanelRootActionProps, ProcessPanelRoot } from "./process-panel-root";
+import { getProcessPanelCurrentUuid, ProcessPanel as ProcessPanelState } from "store/process-panel/process-panel";
+import { groupBy } from "lodash";
+import {
+    loadInputs,
+    loadOutputDefinitions,
+    loadOutputs,
+    toggleProcessPanelFilter,
+    updateOutputParams,
+    loadNodeJson,
+} from "store/process-panel/process-panel-actions";
+import { cancelRunningWorkflow, resumeOnHoldWorkflow, startWorkflow } from "store/processes/processes-actions";
+import { navigateToLogCollection, pollProcessLogs, setProcessLogsPanelFilter } from "store/process-logs-panel/process-logs-panel-actions";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { getInlineFileUrl } from "views-components/context-menu/actions/helpers";
+
+const mapStateToProps = ({ router, auth, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
+    const uuid = getProcessPanelCurrentUuid(router) || "";
+    const subprocesses = getSubprocesses(uuid)(resources);
+    const process = getProcess(uuid)(resources);
+    return {
+        process,
+        subprocesses: subprocesses.filter(subprocess => processPanel.filters[getProcessStatus(subprocess)]),
+        filters: getFilters(processPanel, subprocesses),
+        processLogsPanel: processLogsPanel,
+        auth: auth,
+        inputRaw: processPanel.inputRaw,
+        inputParams: processPanel.inputParams,
+        outputData: processPanel.outputData,
+        outputDefinitions: processPanel.outputDefinitions,
+        outputParams: processPanel.outputParams,
+        nodeInfo: processPanel.nodeInfo,
+        usageReport: (process || null) && processPanel.usageReport && getInlineFileUrl(
+            `${auth.config.keepWebServiceUrl}${processPanel.usageReport.url}?api_token=${auth.apiToken}`,
+            auth.config.keepWebServiceUrl,
+            auth.config.keepWebInlineServiceUrl
+        ),
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps => ({
+    onCopyToClipboard: (message: string) => {
+        dispatch<any>(
+            snackbarActions.OPEN_SNACKBAR({
+                message,
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+    },
+    onContextMenu: (event, process) => {
+        if (process) {
+            dispatch<any>(openProcessContextMenu(event, process));
+        }
+    },
+    onToggle: status => {
+        dispatch<any>(toggleProcessPanelFilter(status));
+    },
+    cancelProcess: uuid => dispatch<any>(cancelRunningWorkflow(uuid)),
+    startProcess: uuid => dispatch<any>(startWorkflow(uuid)),
+    resumeOnHoldWorkflow: uuid => dispatch<any>(resumeOnHoldWorkflow(uuid)),
+    onLogFilterChange: filter => dispatch(setProcessLogsPanelFilter(filter.value)),
+    navigateToLog: uuid => dispatch<any>(navigateToLogCollection(uuid)),
+    loadInputs: containerRequest => dispatch<any>(loadInputs(containerRequest)),
+    loadOutputs: containerRequest => dispatch<any>(loadOutputs(containerRequest)),
+    loadOutputDefinitions: containerRequest => dispatch<any>(loadOutputDefinitions(containerRequest)),
+    updateOutputParams: () => dispatch<any>(updateOutputParams()),
+    loadNodeJson: containerRequest => dispatch<any>(loadNodeJson(containerRequest)),
+    pollProcessLogs: processUuid => dispatch<any>(pollProcessLogs(processUuid)),
+});
+
+const getFilters = (processPanel: ProcessPanelState, processes: Process[]) => {
+    const grouppedProcesses = groupBy(processes, getProcessStatus);
+    return Object.keys(processPanel.filters).map(filter => ({
+        label: filter,
+        value: (grouppedProcesses[filter] || []).length,
+        checked: processPanel.filters[filter],
+        key: filter,
+    }));
+};
+
+export const ProcessPanel = connect(mapStateToProps, mapDispatchToProps)(ProcessPanelRoot);
diff --git a/services/workbench2/src/views/process-panel/process-resource-card.tsx b/services/workbench2/src/views/process-panel/process-resource-card.tsx
new file mode 100644 (file)
index 0000000..d738ed0
--- /dev/null
@@ -0,0 +1,227 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Typography,
+    Grid,
+    Link,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import {
+    CloseIcon,
+    MaximizeIcon,
+    ResourceIcon,
+    UnMaximizeIcon,
+    ShowChartIcon,
+} from 'components/icon/icon';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { connect } from 'react-redux';
+import { Process } from 'store/processes/process';
+import { NodeInstanceType } from 'store/process-panel/process-panel';
+import { DetailsAttribute } from "components/details-attribute/details-attribute";
+import { formatFileSize } from "common/formatters";
+import { MountKind } from 'models/mount-types';
+
+interface ProcessResourceCardDataProps {
+    process: Process;
+    nodeInfo: NodeInstanceType | null;
+    usageReport: string | null;
+}
+
+type CssRules = "card" | "header" | "title" | "avatar" | "iconHeader" | "content" | "sectionH3" | "reportButton";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    header: {
+        paddingBottom: "0px"
+    },
+    title: {
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    avatar: {
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL,
+    },
+    content: {
+        paddingTop: "0px",
+        maxHeight: `calc(100% - ${theme.spacing.unit * 7.5}px)`,
+        overflow: "auto"
+    },
+    sectionH3: {
+        margin: "0.5em",
+        color: theme.customs.colors.greyD,
+        fontSize: "0.8125rem",
+        textTransform: "uppercase",
+    },
+    reportButton: {
+    }
+});
+
+type ProcessResourceCardProps = ProcessResourceCardDataProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessResourceCard = withStyles(styles)(connect()(
+    ({ classes, nodeInfo, usageReport, doHidePanel, doMaximizePanel, doUnMaximizePanel, panelMaximized, panelName, process, }: ProcessResourceCardProps) => {
+        let diskRequest = 0;
+        if (process.container?.mounts) {
+            for (const mnt in process.container.mounts) {
+                const mp = process.container.mounts[mnt];
+                if (mp.kind === MountKind.TEMPORARY_DIRECTORY) {
+                    diskRequest += mp.capacity;
+                }
+            }
+        }
+
+        return <Card className={classes.card} data-cy="process-resources-card">
+            <CardHeader
+                className={classes.header}
+                classes={{
+                    content: classes.title,
+                    avatar: classes.avatar,
+                }}
+                avatar={<ResourceIcon className={classes.iconHeader} />}
+                title={
+                    <Typography noWrap variant='h6' color='inherit'>
+                        Resources
+                    </Typography>
+                }
+                action={
+                    <div>
+                        {usageReport && <Link href={usageReport} className={classes.reportButton} target="_blank"><ShowChartIcon /> Resource usage report</Link>}
+                        {doUnMaximizePanel && panelMaximized &&
+                            <Tooltip title={`Unmaximize ${panelName || 'panel'}`} disableFocusListener>
+                                <IconButton onClick={doUnMaximizePanel}><UnMaximizeIcon /></IconButton>
+                            </Tooltip>}
+                        {doMaximizePanel && !panelMaximized &&
+                            <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
+                                <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
+                            </Tooltip>}
+                        {doHidePanel &&
+                            <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                                <IconButton disabled={panelMaximized} onClick={doHidePanel}><CloseIcon /></IconButton>
+                            </Tooltip>}
+                    </div>
+                } />
+            <CardContent className={classes.content}>
+                <Grid container>
+                    <Grid item xs={4}>
+                        <h3 className={classes.sectionH3}>Requested Resources</h3>
+                        <Grid container>
+                            <Grid item xs={12}>
+                                <DetailsAttribute label="Cores" value={process.container?.runtimeConstraints.vcpus} />
+                            </Grid>
+                            <Grid item xs={12}>
+                                <DetailsAttribute label="RAM*" value={formatFileSize(process.container?.runtimeConstraints.ram)} />
+                            </Grid>
+                            <Grid item xs={12}>
+                                <DetailsAttribute label="Disk" value={formatFileSize(diskRequest)} />
+                            </Grid>
+
+                            {process.container?.runtimeConstraints.cuda &&
+                                process.container?.runtimeConstraints.cuda.device_count > 0 ?
+                                <>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="CUDA devices" value={process.container?.runtimeConstraints.cuda.device_count} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="CUDA driver version" value={process.container?.runtimeConstraints.cuda.driver_version} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="CUDA hardware capability" value={process.container?.runtimeConstraints.cuda.hardware_capability} />
+                                    </Grid>
+                                </> : null}
+
+                            {process.container?.runtimeConstraints.keep_cache_ram &&
+                                process.container?.runtimeConstraints.keep_cache_ram > 0 ?
+                                <Grid item xs={12}>
+                                    <DetailsAttribute label="Keep cache (RAM)" value={formatFileSize(process.container?.runtimeConstraints.keep_cache_ram)} />
+                                </Grid> : null}
+
+                            {process.container?.runtimeConstraints.keep_cache_disk &&
+                                process.container?.runtimeConstraints.keep_cache_disk > 0 ?
+                                <Grid item xs={12}>
+                                    <DetailsAttribute label="Keep cache (disk)" value={formatFileSize(process.container?.runtimeConstraints.keep_cache_disk)} />
+                                </Grid> : null}
+
+                            {process.container?.runtimeConstraints.API ? <Grid item xs={12}>
+                                <DetailsAttribute label="API access" value={process.container?.runtimeConstraints.API.toString()} />
+                            </Grid> : null}
+
+                        </Grid>
+                    </Grid>
+
+
+                    <Grid item xs={8}>
+                        <h3 className={classes.sectionH3}>Assigned Instance Type</h3>
+                        {nodeInfo === null ? <Grid item xs={8}>
+                            No instance type recorded
+                        </Grid>
+                            :
+                            <Grid container>
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Cores" value={nodeInfo.VCPUs} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Provider type" value={nodeInfo.ProviderType} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="RAM" value={formatFileSize(nodeInfo.RAM)} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Price" value={"$" + nodeInfo.Price.toString()} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Disk" value={formatFileSize(nodeInfo.IncludedScratch + nodeInfo.AddedScratch)} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Preemptible" value={nodeInfo.Preemptible.toString()} />
+                                </Grid>
+
+                                {nodeInfo.CUDA && nodeInfo.CUDA.DeviceCount > 0 &&
+                                    <>
+                                        <Grid item xs={6}>
+                                            <DetailsAttribute label="CUDA devices" value={nodeInfo.CUDA.DeviceCount} />
+                                        </Grid>
+
+                                        <Grid item xs={6}>
+                                        </Grid>
+
+                                        <Grid item xs={6}>
+                                            <DetailsAttribute label="CUDA driver version" value={nodeInfo.CUDA.DriverVersion} />
+                                        </Grid>
+
+                                        <Grid item xs={6}>
+                                        </Grid>
+
+                                        <Grid item xs={6}>
+                                            <DetailsAttribute label="CUDA hardware capability" value={nodeInfo.CUDA.HardwareCapability} />
+                                        </Grid>
+                                    </>
+                                }
+                            </Grid>}
+                    </Grid>
+                </Grid>
+                <Typography>* RAM available to the program is limited to Requested RAM, not Instance RAM</Typography>
+            </CardContent>
+        </Card >;
+    }
+));
diff --git a/services/workbench2/src/views/project-panel/project-panel.tsx b/services/workbench2/src/views/project-panel/project-panel.tsx
new file mode 100644 (file)
index 0000000..2ddfca8
--- /dev/null
@@ -0,0 +1,327 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { DispatchProp, connect } from 'react-redux';
+import { RouteComponentProps } from 'react-router';
+import { StyleRulesCallback, WithStyles } from '@material-ui/core';
+
+import { DataExplorer } from 'views-components/data-explorer/data-explorer';
+import { DataColumns } from 'components/data-table/data-table';
+import { RootState } from 'store/store';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
+import { ContainerRequestState } from 'models/container-request';
+import { SortDirection } from 'components/data-table/data-column';
+import { ResourceKind, Resource } from 'models/resource';
+import {
+    ResourceName,
+    ProcessStatus as ResourceStatus,
+    ResourceType,
+    ResourceOwnerWithName,
+    ResourcePortableDataHash,
+    ResourceFileSize,
+    ResourceFileCount,
+    ResourceUUID,
+    ResourceContainerUuid,
+    ContainerRunTime,
+    ResourceOutputUuid,
+    ResourceLogUuid,
+    ResourceParentProcess,
+    ResourceModifiedByUserUuid,
+    ResourceVersion,
+    ResourceCreatedAtDate,
+    ResourceLastModifiedDate,
+    ResourceTrashDate,
+    ResourceDeleteDate,
+} from 'views-components/data-explorer/renderers';
+import { ProjectIcon } from 'components/icon/icon';
+import { ResourcesState, getResource } from 'store/resources/resources';
+import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
+import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { getProperty } from 'store/properties/properties';
+import { PROJECT_PANEL_CURRENT_UUID } from 'store/project-panel/project-panel-action';
+import { ArvadosTheme } from 'common/custom-theme';
+import { createTree } from 'models/tree';
+import { getInitialResourceTypeFilters, getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { GroupClass, GroupResource } from 'models/group';
+import { CollectionResource } from 'models/collection';
+import { resourceIsFrozen } from 'common/frozen-resources';
+import { ProjectResource } from 'models/project';
+import { deselectAllOthers, toggleOne } from 'store/multiselect/multiselect-actions';
+
+type CssRules = 'root' | 'button' ;
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    },
+    button: {
+        marginLeft: theme.spacing.unit,
+    },
+});
+
+export enum ProjectPanelColumnNames {
+    NAME = 'Name',
+    STATUS = 'Status',
+    TYPE = 'Type',
+    OWNER = 'Owner',
+    PORTABLE_DATA_HASH = 'Portable Data Hash',
+    FILE_SIZE = 'File Size',
+    FILE_COUNT = 'File Count',
+    UUID = 'UUID',
+    CONTAINER_UUID = 'Container UUID',
+    RUNTIME = 'Runtime',
+    OUTPUT_UUID = 'Output UUID',
+    LOG_UUID = 'Log UUID',
+    PARENT_PROCESS = 'Parent Process UUID',
+    MODIFIED_BY_USER_UUID = 'Modified by User UUID',
+    VERSION = 'Version',
+    CREATED_AT = 'Date Created',
+    LAST_MODIFIED = 'Last Modified',
+    TRASH_AT = 'Trash at',
+    DELETE_AT = 'Delete at',
+}
+
+export interface ProjectPanelFilter extends DataTableFilterItem {
+    type: ResourceKind | ContainerRequestState;
+}
+
+export const projectPanelColumns: DataColumns<string, ProjectResource> = [
+    {
+        name: ProjectPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'name' },
+        filters: createTree(),
+        render: (uuid) => <ResourceName uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        mutuallyExclusiveFilters: true,
+        filters: getInitialProcessStatusFilters(),
+        render: (uuid) => <ResourceStatus uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.TYPE,
+        selected: true,
+        configurable: true,
+        filters: getInitialResourceTypeFilters(),
+        render: (uuid) => <ResourceType uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.OWNER,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceOwnerWithName uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.PORTABLE_DATA_HASH,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourcePortableDataHash uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.FILE_SIZE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceFileSize uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.FILE_COUNT,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceFileCount uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceUUID uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.CONTAINER_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceContainerUuid uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.RUNTIME,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ContainerRunTime uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.OUTPUT_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceOutputUuid uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.LOG_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceLogUuid uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.PARENT_PROCESS,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceParentProcess uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.MODIFIED_BY_USER_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceModifiedByUserUuid uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.VERSION,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceVersion uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.CREATED_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'createdAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceCreatedAtDate uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.LAST_MODIFIED,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.DESC, field: 'modifiedAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceLastModifiedDate uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.TRASH_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'trashAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceTrashDate uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.DELETE_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'deleteAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceDeleteDate uuid={uuid} />,
+    },
+];
+
+export const PROJECT_PANEL_ID = 'projectPanel';
+
+const DEFAULT_VIEW_MESSAGES = ['Your project is empty.', 'Please create a project or create a collection and upload a data.'];
+
+interface ProjectPanelDataProps {
+    currentItemId: string;
+    resources: ResourcesState;
+    project: GroupResource;
+    isAdmin: boolean;
+    userUuid: string;
+    dataExplorerItems: any;
+    working: boolean;
+}
+
+type ProjectPanelProps = ProjectPanelDataProps & DispatchProp & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+
+const mapStateToProps = (state: RootState) => {
+    const currentItemId = getProperty<string>(PROJECT_PANEL_CURRENT_UUID)(state.properties);
+    const project = getResource<GroupResource>(currentItemId || "")(state.resources);
+    return {
+        currentItemId,
+        project,
+        resources: state.resources,
+        userUuid: state.auth.user!.uuid,
+    };
+}
+
+export const ProjectPanel = withStyles(styles)(
+    connect(mapStateToProps)(
+        class extends React.Component<ProjectPanelProps> {
+
+            render() {
+                const { classes } = this.props;
+                return <div data-cy='project-panel' className={classes.root}>
+                    <DataExplorer
+                        id={PROJECT_PANEL_ID}
+                        onRowClick={this.handleRowClick}
+                        onRowDoubleClick={this.handleRowDoubleClick}
+                        onContextMenu={this.handleContextMenu}
+                        contextMenuColumn={true}
+                        defaultViewIcon={ProjectIcon}
+                        defaultViewMessages={DEFAULT_VIEW_MESSAGES}
+                    />
+                </div>
+            }
+
+            isCurrentItemChild = (resource: Resource) => {
+                return resource.ownerUuid === this.props.currentItemId;
+            };
+
+            handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+                const { resources, isAdmin } = this.props;
+                const resource = getResource<GroupContentsResource>(resourceUuid)(resources);
+                // When viewing the contents of a filter group, all contents should be treated as read only.
+                let readonly = false;
+                const project = getResource<GroupResource>(this.props.currentItemId)(resources);
+                if (project && project.groupClass === GroupClass.FILTER) {
+                    readonly = true;
+                }
+
+                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid, readonly));
+                if (menuKind && resource) {
+                    this.props.dispatch<any>(
+                        openContextMenu(event, {
+                            name: resource.name,
+                            uuid: resource.uuid,
+                            ownerUuid: resource.ownerUuid,
+                            isTrashed: 'isTrashed' in resource ? resource.isTrashed : false,
+                            kind: resource.kind,
+                            menuKind,
+                            isAdmin,
+                            isFrozen: resourceIsFrozen(resource, resources),
+                            description: resource.description,
+                            storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
+                            properties: 'properties' in resource ? resource.properties : {},
+                        })
+                    );
+                }
+                this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
+            };
+
+            handleRowDoubleClick = (uuid: string) => {
+                this.props.dispatch<any>(navigateTo(uuid));
+            };
+
+            handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
+                this.props.dispatch<any>(deselectAllOthers(uuid))
+                this.props.dispatch<any>(loadDetailsPanel(uuid));
+            };
+        }
+    )
+);
diff --git a/services/workbench2/src/views/public-favorites-panel/public-favorites-panel.tsx b/services/workbench2/src/views/public-favorites-panel/public-favorites-panel.tsx
new file mode 100644 (file)
index 0000000..a0c4675
--- /dev/null
@@ -0,0 +1,177 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { connect, DispatchProp } from 'react-redux';
+import { DataColumns } from 'components/data-table/data-table';
+import { RouteComponentProps } from 'react-router';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
+import { ResourceKind } from 'models/resource';
+import { ArvadosTheme } from 'common/custom-theme';
+import {
+    ProcessStatus,
+    ResourceFileSize,
+    ResourceLastModifiedDate,
+    ResourceType,
+    ResourceName,
+    ResourceOwnerWithName
+} from 'views-components/data-explorer/renderers';
+import { PublicFavoriteIcon } from 'components/icon/icon';
+import { Dispatch } from 'redux';
+import {
+    openContextMenu,
+    resourceUuidToContextMenuKind
+} from 'store/context-menu/context-menu-actions';
+import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { ContainerRequestState } from "models/container-request";
+import { RootState } from 'store/store';
+import { createTree } from 'models/tree';
+import { getSimpleObjectTypeFilters } from 'store/resource-type-filters/resource-type-filters';
+import { PUBLIC_FAVORITE_PANEL_ID } from 'store/public-favorites-panel/public-favorites-action';
+import { PublicFavoritesState } from 'store/public-favorites/public-favorites-reducer';
+import { getResource, ResourcesState } from 'store/resources/resources';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { CollectionResource } from 'models/collection';
+import { toggleOne, deselectAllOthers } from 'store/multiselect/multiselect-actions';
+
+type CssRules = "toolbar" | "button" | "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    toolbar: {
+        paddingBottom: theme.spacing.unit * 3,
+        textAlign: "right"
+    },
+    button: {
+        marginLeft: theme.spacing.unit
+    },
+    root: {
+        width: '100%',
+    },
+});
+
+export enum PublicFavoritePanelColumnNames {
+    NAME = "Name",
+    STATUS = "Status",
+    TYPE = "Type",
+    OWNER = "Owner",
+    FILE_SIZE = "File size",
+    LAST_MODIFIED = "Last modified"
+}
+
+export interface FavoritePanelFilter extends DataTableFilterItem {
+    type: ResourceKind | ContainerRequestState;
+}
+
+export const publicFavoritePanelColumns: DataColumns<string, GroupContentsResource> = [
+    {
+        name: PublicFavoritePanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceName uuid={uuid} />
+    },
+    {
+        name: "Status",
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ProcessStatus uuid={uuid} />
+    },
+    {
+        name: PublicFavoritePanelColumnNames.TYPE,
+        selected: true,
+        configurable: true,
+        filters: getSimpleObjectTypeFilters(),
+        render: uuid => <ResourceType uuid={uuid} />
+    },
+    {
+        name: PublicFavoritePanelColumnNames.OWNER,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceOwnerWithName uuid={uuid} />
+    },
+    {
+        name: PublicFavoritePanelColumnNames.FILE_SIZE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceFileSize uuid={uuid} />
+    },
+    {
+        name: PublicFavoritePanelColumnNames.LAST_MODIFIED,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLastModifiedDate uuid={uuid} />
+    }
+];
+
+interface PublicFavoritePanelDataProps {
+    publicFavorites: PublicFavoritesState;
+    resources: ResourcesState;
+}
+
+interface PublicFavoritePanelActionProps {
+    onItemClick: (item: string) => void;
+    onContextMenu: (resources: ResourcesState) => (event: React.MouseEvent<HTMLElement>, item: string) => void;
+    onDialogOpen: (ownerUuid: string) => void;
+    onItemDoubleClick: (item: string) => void;
+}
+const mapStateToProps = ({ publicFavorites, resources }: RootState): PublicFavoritePanelDataProps => ({
+    publicFavorites,
+    resources,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): PublicFavoritePanelActionProps => ({
+    onContextMenu: (resources: ResourcesState) => (event, resourceUuid) => {
+        const resource = getResource<GroupContentsResource>(resourceUuid)(resources);
+        const kind = dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
+        if (kind && resource) {
+            dispatch<any>(openContextMenu(event, {
+                name: resource.name,
+                description: resource.description,
+                storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
+                uuid: resourceUuid,
+                ownerUuid: '',
+                kind: ResourceKind.NONE,
+                menuKind: kind
+            }));
+        }
+        dispatch<any>(loadDetailsPanel(resourceUuid));
+    },
+    onDialogOpen: (ownerUuid: string) => { return; },
+    onItemClick: (uuid: string) => {
+                dispatch<any>(toggleOne(uuid))
+                dispatch<any>(deselectAllOthers(uuid))
+                dispatch<any>(loadDetailsPanel(uuid));
+    },
+    onItemDoubleClick: uuid => {
+        dispatch<any>(navigateTo(uuid));
+    }
+});
+
+type FavoritePanelProps = PublicFavoritePanelDataProps & PublicFavoritePanelActionProps & DispatchProp
+    & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+
+export const PublicFavoritePanel = withStyles(styles)(
+    connect(mapStateToProps, mapDispatchToProps)(
+        class extends React.Component<FavoritePanelProps> {
+            render() {
+                return <div className={this.props.classes.root}><DataExplorer
+                    id={PUBLIC_FAVORITE_PANEL_ID}
+                    onRowClick={this.props.onItemClick}
+                    onRowDoubleClick={this.props.onItemDoubleClick}
+                    onContextMenu={this.props.onContextMenu(this.props.resources)}
+                    contextMenuColumn={true}
+                    defaultViewIcon={PublicFavoriteIcon}
+                    defaultViewMessages={['Public favorites list is empty.']} />
+                </div>;
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/views/repositories-panel/repositories-panel.tsx b/services/workbench2/src/views/repositories-panel/repositories-panel.tsx
new file mode 100644 (file)
index 0000000..3ec5c56
--- /dev/null
@@ -0,0 +1,154 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, IconButton } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { Link } from 'react-router-dom';
+import { Dispatch, compose } from 'redux';
+import { RootState } from 'store/store';
+import { HelpIcon, AddIcon, MoreVerticalIcon } from 'components/icon/icon';
+import { loadRepositoriesData, openRepositoriesSampleGitDialog, openRepositoryCreateDialog } from 'store/repositories/repositories-actions';
+import { RepositoryResource } from 'models/repositories';
+import { openRepositoryContextMenu } from 'store/context-menu/context-menu-actions';
+import { Routes } from 'routes/routes';
+
+
+type CssRules = 'link' | 'button' | 'icon' | 'iconRow' | 'moreOptionsButton' | 'moreOptions' | 'cloneUrls';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    link: {
+        textDecoration: 'none',
+        color: theme.palette.primary.main,
+        "&:hover": {
+            color: theme.palette.primary.dark,
+            transition: 'all 0.5s ease'
+        }
+    },
+    button: {
+        textAlign: 'right',
+        alignSelf: 'center'
+    },
+    icon: {
+        cursor: 'pointer',
+        color: theme.palette.grey["500"],
+        "&:hover": {
+            color: theme.palette.common.black,
+            transition: 'all 0.5s ease'
+        }
+    },
+    iconRow: {
+        paddingTop: theme.spacing.unit * 2,
+        textAlign: 'right'
+    },
+    moreOptionsButton: {
+        padding: 0
+    },
+    moreOptions: {
+        textAlign: 'right',
+        '&:last-child': {
+            paddingRight: 0
+        }
+    },
+    cloneUrls: {
+        whiteSpace: 'pre-wrap'
+    }
+});
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        repositories: state.repositories.items
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<RepositoriesActionProps, 'onOptionsMenuOpen' | 'loadRepositories' | 'openRepositoriesSampleGitDialog' | 'openRepositoryCreateDialog'> => ({
+    loadRepositories: () => dispatch<any>(loadRepositoriesData()),
+    onOptionsMenuOpen: (event, repository) => {
+        dispatch<any>(openRepositoryContextMenu(event, repository));
+    },
+    openRepositoriesSampleGitDialog: () => dispatch<any>(openRepositoriesSampleGitDialog()),
+    openRepositoryCreateDialog: () => dispatch<any>(openRepositoryCreateDialog())
+});
+
+interface RepositoriesActionProps {
+    loadRepositories: () => void;
+    onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, repository: RepositoryResource) => void;
+    openRepositoriesSampleGitDialog: () => void;
+    openRepositoryCreateDialog: () => void;
+}
+
+interface RepositoriesDataProps {
+    repositories: RepositoryResource[];
+}
+
+
+type RepositoriesProps = RepositoriesDataProps & RepositoriesActionProps & WithStyles<CssRules>;
+
+export const RepositoriesPanel = compose(
+    withStyles(styles),
+    connect(mapStateToProps, mapDispatchToProps))(
+        class extends React.Component<RepositoriesProps> {
+            componentDidMount() {
+                this.props.loadRepositories();
+            }
+            render() {
+                const { classes, repositories, onOptionsMenuOpen, openRepositoriesSampleGitDialog, openRepositoryCreateDialog } = this.props;
+                return (
+                    <Card>
+                        <CardContent>
+                            <Grid container direction="row">
+                                <Grid item xs={8}>
+                                    <Typography variant='body1'>
+                                        When you are using an Arvados virtual machine, you should clone the https:// URLs. This will authenticate automatically using your API token. <br />
+                                        In order to clone git repositories using SSH, <Link to={Routes.SSH_KEYS_USER} className={classes.link}>add an SSH key to your account</Link> and clone the git@ URLs.
+                                    </Typography>
+                                </Grid>
+                                <Grid item xs={4} className={classes.button}>
+                                    <Button variant="contained" color="primary" onClick={openRepositoryCreateDialog}>
+                                        <AddIcon /> NEW REPOSITORY
+                                    </Button>
+                                </Grid>
+                            </Grid>
+                            <Grid item xs={12}>
+                                <div className={classes.iconRow}>
+                                    <Tooltip title="Sample git quick start">
+                                        <IconButton className={classes.moreOptionsButton} onClick={openRepositoriesSampleGitDialog}>
+                                            <HelpIcon className={classes.icon} />
+                                        </IconButton>
+                                    </Tooltip>
+                                </div>
+                            </Grid>
+                            <Grid item xs={12}>
+                                {repositories && <Table>
+                                    <TableHead>
+                                        <TableRow>
+                                            <TableCell>Name</TableCell>
+                                            <TableCell>URL</TableCell>
+                                            <TableCell />
+                                        </TableRow>
+                                    </TableHead>
+                                    <TableBody>
+                                        {repositories.map((repository, index) =>
+                                            <TableRow key={index}>
+                                                <TableCell>{repository.name}</TableCell>
+                                                <TableCell className={classes.cloneUrls}>{repository.cloneUrls.join("\n")}</TableCell>
+                                                <TableCell className={classes.moreOptions}>
+                                                    <Tooltip title="More options" disableFocusListener>
+                                                        <IconButton onClick={event => onOptionsMenuOpen(event, repository)} className={classes.moreOptionsButton}>
+                                                            <MoreVerticalIcon />
+                                                        </IconButton>
+                                                    </Tooltip>
+                                                </TableCell>
+                                            </TableRow>)}
+                                    </TableBody>
+                                </Table>}
+                            </Grid>
+                        </CardContent>
+                    </Card>
+                );
+            }
+        }
+    );
diff --git a/services/workbench2/src/views/run-process-panel/inputs/boolean-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/boolean-input.tsx
new file mode 100644 (file)
index 0000000..c8ef885
--- /dev/null
@@ -0,0 +1,39 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { memoize } from 'lodash/fp';
+import { BooleanCommandInputParameter } from 'models/workflow';
+import { Field } from 'redux-form';
+import { Switch } from '@material-ui/core';
+import { GenericInputProps, GenericInput } from './generic-input';
+
+export interface BooleanInputProps {
+    input: BooleanCommandInputParameter;
+}
+export const BooleanInput = ({ input }: BooleanInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={BooleanInputComponent}
+        normalize={normalize}
+    />;
+
+const normalize = (_: any, prevValue: boolean) => !prevValue;
+
+const BooleanInputComponent = (props: GenericInputProps) =>
+    <GenericInput
+        component={Input}
+        {...props} />;
+
+const Input = ({ input, commandInput }: GenericInputProps) =>
+    <Switch
+        color='primary'
+        checked={input.value}
+        onChange={handleChange(input.onChange, input.value)}
+        disabled={commandInput.disabled} />;
+
+const handleChange = memoize(
+    (onChange: (value: string) => void, value: string) => () => onChange(value)
+);
diff --git a/services/workbench2/src/views/run-process-panel/inputs/directory-array-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/directory-array-input.tsx
new file mode 100644 (file)
index 0000000..dd5bb2f
--- /dev/null
@@ -0,0 +1,306 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    isRequiredInput,
+    DirectoryArrayCommandInputParameter,
+    Directory,
+    CWLType
+} from 'models/workflow';
+import { Field } from 'redux-form';
+import { ERROR_MESSAGE } from 'validators/require';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, WithStyles, Typography } from '@material-ui/core';
+import { GenericInputProps, GenericInput } from './generic-input';
+import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
+import { connect, DispatchProp } from 'react-redux';
+import { initProjectsTreePicker, getSelectedNodes, treePickerActions, getProjectsTreePickerIds, FileOperationLocation, getFileOperationLocation, fileOperationLocationToPickerId } from 'store/tree-picker/tree-picker-actions';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
+import { createSelector, createStructuredSelector } from 'reselect';
+import { ChipsInput } from 'components/chips-input/chips-input';
+import { identity, values, noop } from 'lodash';
+import { InputProps } from '@material-ui/core/Input';
+import { TreePicker } from 'store/tree-picker/tree-picker';
+import { RootState } from 'store/store';
+import { Chips } from 'components/chips/chips';
+import withStyles, { StyleRulesCallback } from '@material-ui/core/styles/withStyles';
+import { CollectionResource } from 'models/collection';
+import { PORTABLE_DATA_HASH_PATTERN, ResourceKind } from 'models/resource';
+import { Dispatch } from 'redux';
+import { CollectionDirectory, CollectionFileType } from 'models/collection-file';
+
+const LOCATION_REGEX = new RegExp("^(?:keep:)?(" + PORTABLE_DATA_HASH_PATTERN + ")(/.*)?$");
+export interface DirectoryArrayInputProps {
+    input: DirectoryArrayCommandInputParameter;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+}
+
+export const DirectoryArrayInput = ({ input }: DirectoryArrayInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={DirectoryArrayInputComponent as any}
+        parse={parseDirectories}
+        format={formatDirectories}
+        validate={validationSelector(input)} />;
+
+interface FormattedDirectory {
+    name: string;
+    portableDataHash: string;
+    subpath: string;
+}
+
+const parseDirectories = (directories: FileOperationLocation[] | string) =>
+    typeof directories === 'string'
+        ? undefined
+        : directories.map(parse);
+
+const parse = (directory: FileOperationLocation): Directory => ({
+    class: CWLType.DIRECTORY,
+    basename: directory.name,
+    location: `keep:${directory.pdh}${directory.subpath}`,
+});
+
+const formatDirectories = (directories: Directory[] = []): FormattedDirectory[] =>
+    directories ? directories.map(format).filter((dir): dir is FormattedDirectory => Boolean(dir)) : [];
+
+const format = ({ location = '', basename = '' }: Directory): FormattedDirectory | undefined => {
+    const match = LOCATION_REGEX.exec(location);
+
+    if (match) {
+        return {
+            portableDataHash: match[1],
+            subpath: match[2],
+            name: basename,
+        };
+    }
+    return undefined;
+};
+
+const validationSelector = createSelector(
+    isRequiredInput,
+    isRequired => isRequired
+        ? [required]
+        : undefined
+);
+
+const required = (value?: Directory[]) =>
+    value && value.length > 0
+        ? undefined
+        : ERROR_MESSAGE;
+interface DirectoryArrayInputComponentState {
+    open: boolean;
+    directories: FileOperationLocation[];
+}
+
+interface DirectoryArrayInputDataProps {
+    treePickerState: TreePicker;
+}
+
+const treePickerSelector = (state: RootState) => state.treePicker;
+
+const mapStateToProps = createStructuredSelector({
+    treePickerState: treePickerSelector,
+});
+
+interface DirectoryArrayInputActionProps {
+    initProjectsTreePicker: (pickerId: string) => void;
+    selectTreePickerNode: (pickerId: string, id: string | string[]) => void;
+    deselectTreePickerNode: (pickerId: string, id: string | string[]) => void;
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): DirectoryArrayInputActionProps => ({
+    initProjectsTreePicker: (pickerId: string) => dispatch<any>(initProjectsTreePicker(pickerId)),
+    selectTreePickerNode: (pickerId: string, id: string | string[]) =>
+        dispatch<any>(treePickerActions.SELECT_TREE_PICKER_NODE({
+            pickerId, id, cascade: false
+        })),
+    deselectTreePickerNode: (pickerId: string, id: string | string[]) =>
+        dispatch<any>(treePickerActions.DESELECT_TREE_PICKER_NODE({
+            pickerId, id, cascade: false
+        })),
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
+});
+
+const DirectoryArrayInputComponent = connect(mapStateToProps, mapDispatchToProps)(
+    class DirectoryArrayInputComponent extends React.Component<GenericInputProps & DirectoryArrayInputDataProps & DirectoryArrayInputActionProps & DispatchProp & {
+        options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+    }, DirectoryArrayInputComponentState> {
+        state: DirectoryArrayInputComponentState = {
+            open: false,
+            directories: [],
+        };
+
+        directoryRefreshTimeout = -1;
+
+        componentDidMount() {
+            this.props.initProjectsTreePicker(this.props.commandInput.id);
+        }
+
+        render() {
+            return <>
+                <this.input />
+                <this.dialog />
+            </>;
+        }
+
+        openDialog = () => {
+            this.setState({ open: true });
+        }
+
+        closeDialog = () => {
+            this.setState({ open: false });
+        }
+
+        submit = () => {
+            this.closeDialog();
+            this.props.input.onChange(this.state.directories);
+        }
+
+        setDirectoriesFromResources = async (directories: (CollectionResource | CollectionDirectory)[]) => {
+            const locations = (await Promise.all(
+                directories.map(directory => (this.props.getFileOperationLocation(directory)))
+            )).filter((location): location is FileOperationLocation => (
+                location !== undefined
+            ));
+
+            this.setDirectories(locations);
+        }
+
+        refreshDirectories = () => {
+            clearTimeout(this.directoryRefreshTimeout);
+            this.directoryRefreshTimeout = window.setTimeout(this.setDirectoriesFromTree);
+        }
+
+        setDirectoriesFromTree = () => {
+            const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
+            const initialDirectories: (CollectionResource | CollectionDirectory)[] = [];
+            const directories = nodes
+                .reduce((directories, { value }) =>
+                    (('kind' in value && value.kind === ResourceKind.COLLECTION) ||
+                    ('type' in value && value.type === CollectionFileType.DIRECTORY))
+                        ? directories.concat(value)
+                        : directories, initialDirectories);
+            this.setDirectoriesFromResources(directories);
+        }
+
+        setDirectories = (locations: FileOperationLocation[]) => {
+            const deletedDirectories = this.state.directories
+                .reduce((deletedDirectories, directory) =>
+                    locations.some(({ uuid, subpath }) => uuid === directory.uuid && subpath === directory.subpath)
+                        ? deletedDirectories
+                        : [...deletedDirectories, directory]
+                    , [] as FileOperationLocation[]);
+
+            this.setState({ directories: locations });
+
+            const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
+            ids.forEach(pickerId => {
+                this.props.deselectTreePickerNode(
+                    pickerId,
+                    deletedDirectories.map(fileOperationLocationToPickerId)
+                );
+            });
+        };
+
+        input = () =>
+            <GenericInput
+                component={this.chipsInput}
+                {...this.props} />
+
+        chipsInput = () =>
+            <ChipsInput
+                values={this.props.input.value}
+                onChange={noop}
+                disabled={this.props.commandInput.disabled}
+                createNewValue={identity}
+                getLabel={(data: FormattedDirectory) => data.name}
+                inputComponent={this.textInput} />
+
+        textInput = (props: InputProps) =>
+            <Input
+                {...props}
+                error={this.props.meta.touched && !!this.props.meta.error}
+                readOnly
+                onClick={!this.props.commandInput.disabled ? this.openDialog : undefined}
+                onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined}
+                onBlur={this.props.input.onBlur}
+                disabled={this.props.commandInput.disabled} />
+
+        dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
+            root: {
+                display: 'flex',
+                flexDirection: 'column',
+            },
+            pickerWrapper: {
+                display: 'flex',
+                flexDirection: 'column',
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
+            },
+            tree: {
+                flex: 3,
+                overflow: 'auto',
+            },
+            divider: {
+                margin: `${spacing.unit}px 0`,
+            },
+            chips: {
+                flex: 1,
+                overflow: 'auto',
+                padding: `${spacing.unit}px 0`,
+                overflowX: 'hidden',
+            },
+        });
+
+        dialog = withStyles(this.dialogContentStyles)(
+            ({ classes }: WithStyles<DialogContentCssRules>) =>
+                <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    maxWidth='md' >
+                    <DialogTitle>Choose directories</DialogTitle>
+                    <DialogContent className={classes.root}>
+                        <div className={classes.pickerWrapper}>
+                            <div className={classes.tree}>
+                                <ProjectsTreePicker
+                                    pickerId={this.props.commandInput.id}
+                                    currentUuids={this.state.directories.map(dir => fileOperationLocationToPickerId(dir))}
+                                    includeCollections
+                                    includeDirectories
+                                    showSelection
+                                    cascadeSelection={false}
+                                    options={this.props.options}
+                                    toggleItemSelection={this.refreshDirectories} />
+                            </div>
+                            <Divider />
+                            <div className={classes.chips}>
+                                <Typography variant='subtitle1'>Selected collections ({this.state.directories.length}):</Typography>
+                                <Chips
+                                    orderable
+                                    deletable
+                                    values={this.state.directories}
+                                    onChange={this.setDirectories}
+                                    getLabel={(directory: CollectionResource) => directory.name} />
+                            </div>
+                        </div>
+
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            data-cy='ok-button'
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog>
+        );
+
+    });
+
+type DialogContentCssRules = 'root' | 'pickerWrapper' | 'tree' | 'divider' | 'chips';
diff --git a/services/workbench2/src/views/run-process-panel/inputs/directory-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/directory-input.tsx
new file mode 100644 (file)
index 0000000..63c990f
--- /dev/null
@@ -0,0 +1,168 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import { memoize } from 'lodash/fp';
+import { Field } from 'redux-form';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
+import {
+    isRequiredInput,
+    DirectoryCommandInputParameter,
+    CWLType,
+    Directory
+} from 'models/workflow';
+import { GenericInputProps, GenericInput } from './generic-input';
+import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
+import { FileOperationLocation, getFileOperationLocation, initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { TreeItem } from 'components/tree/tree';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
+import { ERROR_MESSAGE } from 'validators/require';
+import { Dispatch } from 'redux';
+
+export interface DirectoryInputProps {
+    input: DirectoryCommandInputParameter;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+}
+
+type DialogContentCssRules = 'root' | 'pickerWrapper';
+
+export const DirectoryInput = ({ input, options }: DirectoryInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={DirectoryInputComponent as any}
+        format={format}
+        parse={parse}
+        {...{
+            options
+        }}
+        validate={getValidation(input)} />;
+
+const format = (value?: Directory) => value ? value.basename : '';
+
+const parse = (directory: FileOperationLocation): Directory => ({
+    class: CWLType.DIRECTORY,
+    location: `keep:${directory.pdh}${directory.subpath}`,
+    basename: directory.name,
+});
+
+const getValidation = memoize(
+    (input: DirectoryCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? (directory?: Directory) => directory ? undefined : ERROR_MESSAGE
+            : () => undefined,
+    ])
+);
+
+interface DirectoryInputComponentState {
+    open: boolean;
+    directory?: FileOperationLocation;
+}
+
+interface DirectoryInputActionProps {
+    initProjectsTreePicker: (pickerId: string) => void;
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): DirectoryInputActionProps => ({
+    initProjectsTreePicker: (pickerId: string) => dispatch<any>(initProjectsTreePicker(pickerId)),
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
+});
+
+const DirectoryInputComponent = connect(null, mapDispatchToProps)(
+    class FileInputComponent extends React.Component<GenericInputProps & DirectoryInputActionProps & DispatchProp & {
+        options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+    }, DirectoryInputComponentState> {
+        state: DirectoryInputComponentState = {
+            open: false,
+        };
+
+        componentDidMount() {
+            this.props.initProjectsTreePicker(this.props.commandInput.id);
+        }
+
+        render() {
+            return <>
+                {this.renderInput()}
+                <this.dialog />
+            </>;
+        }
+
+        openDialog = () => {
+            this.setState({ open: true });
+        }
+
+        closeDialog = () => {
+            this.setState({ open: false });
+        }
+
+        submit = () => {
+            this.closeDialog();
+            this.props.input.onChange(this.state.directory);
+        }
+
+        setDirectory = async (_: {}, { data: item }: TreeItem<ProjectsTreePickerItem>) => {
+            const location = await this.props.getFileOperationLocation(item);
+            this.setState({ directory: location });
+        }
+
+        renderInput() {
+            return <GenericInput
+                component={props =>
+                    <Input
+                        readOnly
+                        fullWidth
+                        value={props.input.value}
+                        error={props.meta.touched && !!props.meta.error}
+                        disabled={props.commandInput.disabled}
+                        onClick={!this.props.commandInput.disabled ? this.openDialog : undefined}
+                        onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined} />}
+                {...this.props} />;
+        }
+
+        dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
+            root: {
+                display: 'flex',
+                flexDirection: 'column',
+            },
+            pickerWrapper: {
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
+            },
+        });
+
+        dialog = withStyles(this.dialogContentStyles)(
+            ({ classes }: WithStyles<DialogContentCssRules>) =>
+                <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    data-cy="choose-a-directory-dialog"
+                    maxWidth='md'>
+                    <DialogTitle>Choose a directory</DialogTitle>
+                    <DialogContent className={classes.root}>
+                        <div className={classes.pickerWrapper}>
+                            <ProjectsTreePicker
+                                pickerId={this.props.commandInput.id}
+                                includeCollections
+                                includeDirectories
+                                cascadeSelection={false}
+                                options={this.props.options}
+                                toggleItemActive={this.setDirectory} />
+                        </div>
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            disabled={!this.state.directory}
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog>
+        );
+
+    });
diff --git a/services/workbench2/src/views/run-process-panel/inputs/enum-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/enum-input.tsx
new file mode 100644 (file)
index 0000000..207a30a
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Field } from 'redux-form';
+import { memoize } from 'lodash/fp';
+import { require } from 'validators/require';
+import { Select, MenuItem } from '@material-ui/core';
+import { EnumCommandInputParameter, CommandInputEnumSchema, isRequiredInput, getEnumType } from 'models/workflow';
+import { GenericInputProps, GenericInput } from './generic-input';
+
+export interface EnumInputProps {
+    input: EnumCommandInputParameter;
+}
+
+const getValidation = memoize(
+    (input: EnumCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? require
+            : () => undefined,
+    ]));
+
+const emptyToNull = value => {
+    if (value === '') {
+        return null;
+    } else {
+        return value;
+    }
+};
+
+export const EnumInput = ({ input }: EnumInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={EnumInputComponent}
+        validate={getValidation(input)}
+        normalize={emptyToNull}
+    />;
+
+const EnumInputComponent = (props: GenericInputProps) =>
+    <GenericInput
+        component={Input}
+        {...props} />;
+
+const Input = (props: GenericInputProps) => {
+    const type = getEnumType(props.commandInput) as CommandInputEnumSchema;
+    return <Select
+        value={props.input.value}
+        onChange={props.input.onChange}
+        disabled={props.commandInput.disabled} >
+        {(isRequiredInput(props.commandInput) ? [] : [<MenuItem key={'_empty'} value={''} />]).concat(type.symbols.map(symbol =>
+            <MenuItem key={symbol} value={extractValue(symbol)}>
+                {extractValue(symbol)}
+            </MenuItem>))}
+    </Select>;
+};
+
+/**
+ * Values in workflow definition have an absolute form, for example:
+ *
+ * ```#input_collector.cwl/enum_type/Pathway table```
+ *
+ * We want a value that is in form accepted by backend.
+ * According to the example above, the correct value is:
+ *
+ * ```Pathway table```
+ */
+const extractValue = (symbol: string) => symbol.split('/').pop();
diff --git a/services/workbench2/src/views/run-process-panel/inputs/file-array-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/file-array-input.tsx
new file mode 100644 (file)
index 0000000..9933873
--- /dev/null
@@ -0,0 +1,294 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    isRequiredInput,
+    FileArrayCommandInputParameter,
+    File,
+    CWLType
+} from 'models/workflow';
+import { Field } from 'redux-form';
+import { ERROR_MESSAGE } from 'validators/require';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, WithStyles, Typography } from '@material-ui/core';
+import { GenericInputProps, GenericInput } from './generic-input';
+import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
+import { connect, DispatchProp } from 'react-redux';
+import { initProjectsTreePicker, getSelectedNodes, treePickerActions, getProjectsTreePickerIds } from 'store/tree-picker/tree-picker-actions';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
+import { CollectionFile, CollectionFileType } from 'models/collection-file';
+import { createSelector, createStructuredSelector } from 'reselect';
+import { ChipsInput } from 'components/chips-input/chips-input';
+import { identity, values, noop } from 'lodash';
+import { InputProps } from '@material-ui/core/Input';
+import { TreePicker } from 'store/tree-picker/tree-picker';
+import { RootState } from 'store/store';
+import { Chips } from 'components/chips/chips';
+import withStyles, { StyleRulesCallback } from '@material-ui/core/styles/withStyles';
+
+export interface FileArrayInputProps {
+    input: FileArrayCommandInputParameter;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+}
+export const FileArrayInput = ({ input }: FileArrayInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={FileArrayInputComponent as any}
+        parse={parseFiles}
+        format={formatFiles}
+        validate={validationSelector(input)} />;
+
+const parseFiles = (files: CollectionFile[] | string) =>
+    typeof files === 'string'
+        ? undefined
+        : files.map(parse);
+
+const parse = (file: CollectionFile): File => ({
+    class: CWLType.FILE,
+    basename: file.name,
+    location: `keep:${file.id}`,
+    path: file.path,
+});
+
+const formatFiles = (files: File[] = []) =>
+    files ? files.map(format) : [];
+
+const format = (file: File): CollectionFile => ({
+    id: file.location
+        ? file.location.replace('keep:', '')
+        : '',
+    name: file.basename || '',
+    path: file.path || '',
+    size: 0,
+    type: CollectionFileType.FILE,
+    url: '',
+});
+
+const validationSelector = createSelector(
+    isRequiredInput,
+    isRequired => isRequired
+        ? [required]
+        : undefined
+);
+
+const required = (value?: File[]) =>
+    value && value.length > 0
+        ? undefined
+        : ERROR_MESSAGE;
+interface FileArrayInputComponentState {
+    open: boolean;
+    files: CollectionFile[];
+}
+
+interface FileArrayInputComponentProps {
+    treePickerState: TreePicker;
+}
+
+const treePickerSelector = (state: RootState) => state.treePicker;
+
+const mapStateToProps = createStructuredSelector({
+    treePickerState: treePickerSelector,
+});
+
+const FileArrayInputComponent = connect(mapStateToProps)(
+    class FileArrayInputComponent extends React.Component<FileArrayInputComponentProps & GenericInputProps & DispatchProp & {
+        options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+    }, FileArrayInputComponentState> {
+        state: FileArrayInputComponentState = {
+            open: false,
+            files: [],
+        };
+
+        fileRefreshTimeout = -1;
+
+        componentDidMount() {
+            this.props.dispatch<any>(
+                initProjectsTreePicker(this.props.commandInput.id));
+        }
+
+        render() {
+            return <>
+                <this.input />
+                <this.dialog />
+            </>;
+        }
+
+        openDialog = () => {
+            this.setFilesFromProps(this.props.input.value);
+            this.setState({ open: true });
+        }
+
+        closeDialog = () => {
+            this.setState({ open: false });
+        }
+
+        submit = () => {
+            this.closeDialog();
+            this.props.input.onChange(this.state.files);
+        }
+
+        setFiles = (files: CollectionFile[]) => {
+
+            const deletedFiles = this.state.files
+                .reduce((deletedFiles, file) =>
+                    files.some(({ id }) => id === file.id)
+                        ? deletedFiles
+                        : [...deletedFiles, file]
+                    , []);
+
+            this.setState({ files });
+
+            const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
+            ids.forEach(pickerId => {
+                this.props.dispatch(
+                    treePickerActions.DESELECT_TREE_PICKER_NODE({
+                        pickerId,
+                        id: deletedFiles.map(({ id }) => id),
+                        cascade: true,
+                    })
+                );
+            });
+
+        }
+
+        setFilesFromProps = (files: CollectionFile[]) => {
+
+            const addedFiles = files
+                .reduce((addedFiles, file) =>
+                    this.state.files.some(({ id }) => id === file.id)
+                        ? addedFiles
+                        : [...addedFiles, file]
+                    , []);
+
+            const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
+            ids.forEach(pickerId => {
+                this.props.dispatch(
+                    treePickerActions.SELECT_TREE_PICKER_NODE({
+                        pickerId,
+                        id: addedFiles.map(({ id }) => id),
+                        cascade: true,
+                    })
+                );
+            });
+
+            this.setFiles(files);
+        }
+
+        refreshFiles = () => {
+            clearTimeout(this.fileRefreshTimeout);
+            this.fileRefreshTimeout = window.setTimeout(this.setSelectedFiles);
+        }
+
+        setSelectedFiles = () => {
+            const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
+            const initialFiles: CollectionFile[] = [];
+            const files = nodes
+                .reduce((files, { value }) =>
+                    'type' in value && value.type === CollectionFileType.FILE
+                        ? files.concat(value)
+                        : files, initialFiles);
+
+            this.setFiles(files);
+        }
+        input = () =>
+            <GenericInput
+                component={this.chipsInput}
+                {...this.props} />
+
+        chipsInput = () =>
+            <ChipsInput
+                values={this.props.input.value}
+                disabled={this.props.commandInput.disabled}
+                onChange={noop}
+                createNewValue={identity}
+                getLabel={(file: CollectionFile) => file.name}
+                inputComponent={this.textInput} />
+
+        textInput = (props: InputProps) =>
+            <Input
+                {...props}
+                error={this.props.meta.touched && !!this.props.meta.error}
+                readOnly
+                disabled={this.props.commandInput.disabled}
+                onClick={!this.props.commandInput.disabled ? this.openDialog : undefined}
+                onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined}
+                onBlur={this.props.input.onBlur} />
+
+        dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
+            root: {
+                display: 'flex',
+                flexDirection: 'column',
+            },
+            pickerWrapper: {
+                display: 'flex',
+                flexDirection: 'column',
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
+            },
+            tree: {
+                flex: 3,
+                overflow: 'auto',
+            },
+            divider: {
+                margin: `${spacing.unit}px 0`,
+            },
+            chips: {
+                flex: 1,
+                overflow: 'auto',
+                padding: `${spacing.unit}px 0`,
+                overflowX: 'hidden',
+            },
+        })
+
+
+        dialog = withStyles(this.dialogContentStyles)(
+            ({ classes }: WithStyles<DialogContentCssRules>) =>
+                <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    maxWidth='md' >
+                    <DialogTitle>Choose files</DialogTitle>
+                    <DialogContent className={classes.root}>
+                        <div className={classes.pickerWrapper}>
+                            <div className={classes.tree}>
+                                <ProjectsTreePicker
+                                    pickerId={this.props.commandInput.id}
+                                    includeCollections
+                                    includeDirectories
+                                    includeFiles
+                                    showSelection
+                                    cascadeSelection={true}
+                                    options={this.props.options}
+                                    toggleItemSelection={this.refreshFiles} />
+                            </div>
+                            <Divider />
+                            <div className={classes.chips}>
+                                <Typography variant='subtitle1'>Selected files ({this.state.files.length}):</Typography>
+                                <Chips
+                                    orderable
+                                    deletable
+                                    values={this.state.files}
+                                    onChange={this.setFiles}
+                                    getLabel={(file: CollectionFile) => file.name} />
+                            </div>
+                        </div>
+
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            data-cy='ok-button'
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog>
+        );
+
+    });
+
+type DialogContentCssRules = 'root' | 'pickerWrapper' | 'tree' | 'divider' | 'chips';
diff --git a/services/workbench2/src/views/run-process-panel/inputs/file-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/file-input.tsx
new file mode 100644 (file)
index 0000000..6970e2a
--- /dev/null
@@ -0,0 +1,162 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { memoize } from 'lodash/fp';
+import {
+    isRequiredInput,
+    FileCommandInputParameter,
+    File,
+    CWLType
+} from 'models/workflow';
+import { Field } from 'redux-form';
+import { ERROR_MESSAGE } from 'validators/require';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
+import { GenericInputProps, GenericInput } from './generic-input';
+import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
+import { connect, DispatchProp } from 'react-redux';
+import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { TreeItem } from 'components/tree/tree';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
+import { CollectionFile, CollectionFileType } from 'models/collection-file';
+
+export interface FileInputProps {
+    input: FileCommandInputParameter;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+}
+
+type DialogContentCssRules = 'root' | 'pickerWrapper';
+
+export const FileInput = ({ input, options }: FileInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={FileInputComponent as any}
+        format={format}
+        parse={parse}
+        {...{
+            options
+        }}
+        validate={getValidation(input)} />;
+
+const format = (value?: File) => value ? value.basename : '';
+
+const parse = (file: CollectionFile): File => ({
+    class: CWLType.FILE,
+    location: `keep:${file.id}`,
+    basename: file.name,
+});
+
+const getValidation = memoize(
+    (input: FileCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? (file?: File) => file ? undefined : ERROR_MESSAGE
+            : () => undefined,
+    ]));
+
+interface FileInputComponentState {
+    open: boolean;
+    file?: CollectionFile;
+}
+
+const FileInputComponent = connect()(
+    class FileInputComponent extends React.Component<GenericInputProps & DispatchProp & {
+        options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+    }, FileInputComponentState> {
+        state: FileInputComponentState = {
+            open: false,
+        };
+
+        componentDidMount() {
+            this.props.dispatch<any>(
+                initProjectsTreePicker(this.props.commandInput.id));
+        }
+
+        render() {
+            return <>
+                {this.renderInput()}
+                <this.dialog />
+            </>;
+        }
+
+        openDialog = () => {
+            this.componentDidMount();
+            this.setState({ open: true });
+        }
+
+        closeDialog = () => {
+            this.setState({ open: false });
+        }
+
+        submit = () => {
+            this.closeDialog();
+            this.props.input.onChange(this.state.file);
+        }
+
+        setFile = (_: {}, { data }: TreeItem<ProjectsTreePickerItem>) => {
+            if ('type' in data && data.type === CollectionFileType.FILE) {
+                this.setState({ file: data });
+            } else {
+                this.setState({ file: undefined });
+            }
+        }
+
+        renderInput() {
+            return <GenericInput
+                component={props =>
+                    <Input
+                        readOnly
+                        fullWidth
+                        disabled={props.commandInput.disabled}
+                        value={props.input.value}
+                        error={props.meta.touched && !!props.meta.error}
+                        onClick={!props.commandInput.disabled ? this.openDialog : undefined}
+                        onKeyPress={!props.commandInput.disabled ? this.openDialog : undefined} />}
+                {...this.props} />;
+        }
+
+        dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
+            root: {
+                display: 'flex',
+                flexDirection: 'column',
+            },
+            pickerWrapper: {
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
+            },
+        });
+
+        dialog = withStyles(this.dialogContentStyles)(
+            ({ classes }: WithStyles<DialogContentCssRules>) =>
+                <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    data-cy="choose-a-file-dialog"
+                    maxWidth='md'>
+                    <DialogTitle>Choose a file</DialogTitle>
+                    <DialogContent className={classes.root}>
+                        <div className={classes.pickerWrapper}>
+                            <ProjectsTreePicker
+                                pickerId={this.props.commandInput.id}
+                                includeCollections
+                                includeDirectories
+                                includeFiles
+                                cascadeSelection={false}
+                                options={this.props.options}
+                                toggleItemActive={this.setFile} />
+                        </div>
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            disabled={!this.state.file}
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog >
+        );
+    });
diff --git a/services/workbench2/src/views/run-process-panel/inputs/float-array-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/float-array-input.tsx
new file mode 100644 (file)
index 0000000..3f0a533
--- /dev/null
@@ -0,0 +1,65 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { isRequiredInput, FloatArrayCommandInputParameter } from 'models/workflow';
+import { Field } from 'redux-form';
+import { ERROR_MESSAGE } from 'validators/require';
+import { GenericInputProps, GenericInput } from 'views/run-process-panel/inputs/generic-input';
+import { ChipsInput } from 'components/chips-input/chips-input';
+import { createSelector } from 'reselect';
+import { FloatInput } from 'components/float-input/float-input';
+
+export interface FloatArrayInputProps {
+    input: FloatArrayCommandInputParameter;
+}
+export const FloatArrayInput = ({ input }: FloatArrayInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={FloatArrayInputComponent}
+        validate={validationSelector(input)} />;
+
+
+const validationSelector = createSelector(
+    isRequiredInput,
+    isRequired => isRequired
+        ? [required]
+        : undefined
+);
+
+const required = (value: string[]) =>
+    value && value.length > 0
+        ? undefined
+        : ERROR_MESSAGE;
+
+const FloatArrayInputComponent = (props: GenericInputProps) =>
+    <GenericInput
+        component={InputComponent}
+        {...props} />;
+
+class InputComponent extends React.PureComponent<GenericInputProps>{
+    render() {
+        const { commandInput, input, meta } = this.props;
+        return <ChipsInput
+            deletable={!commandInput.disabled}
+            orderable={!commandInput.disabled}
+            disabled={commandInput.disabled}
+            values={input.value}
+            onChange={this.handleChange}
+            createNewValue={parseFloat}
+            inputComponent={FloatInput}
+            inputProps={{
+                error: meta.error,
+            }} />;
+    }
+
+    handleChange = (values: {}[]) => {
+        const { input, meta } = this.props;
+        if (!meta.touched) {
+            input.onBlur(values);
+        }
+        input.onChange(values);
+    }
+}
diff --git a/services/workbench2/src/views/run-process-panel/inputs/float-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/float-input.tsx
new file mode 100644 (file)
index 0000000..14b06fd
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { memoize } from 'lodash/fp';
+import { FloatCommandInputParameter, isRequiredInput } from 'models/workflow';
+import { Field } from 'redux-form';
+import { isNumber } from 'validators/is-number';
+import { GenericInputProps, GenericInput } from './generic-input';
+import { FloatInput as FloatInputComponent } from 'components/float-input/float-input';
+export interface FloatInputProps {
+    input: FloatCommandInputParameter;
+}
+export const FloatInput = ({ input }: FloatInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={Input}
+        parse={parseFloat}
+        format={format}
+        validate={getValidation(input)} />;
+
+const format = (value: any) => isNaN(value) ? '' : JSON.stringify(value);
+
+const getValidation = memoize(
+    (input: FloatCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? isNumber
+            : () => undefined,])
+);
+
+const Input = (props: GenericInputProps) =>
+    <GenericInput
+        component={InputComponent}
+        {...props} />;
+
+const InputComponent = ({ input, meta, commandInput }: GenericInputProps) =>
+    <FloatInputComponent
+        fullWidth
+        error={meta.touched && !!meta.error}
+        disabled={commandInput.disabled}
+        {...input} />;
+
diff --git a/services/workbench2/src/views/run-process-panel/inputs/generic-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/generic-input.tsx
new file mode 100644 (file)
index 0000000..963998f
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WrappedFieldProps } from 'redux-form';
+import { FormGroup, FormLabel, FormHelperText } from '@material-ui/core';
+import { GenericCommandInputParameter, getInputLabel, isRequiredInput } from 'models/workflow';
+
+export type GenericInputProps = WrappedFieldProps & {
+    commandInput: GenericCommandInputParameter<any, any>;
+};
+
+type GenericInputContainerProps = GenericInputProps & {
+    component: React.ComponentType<GenericInputProps>;
+    required?: boolean;
+};
+export const GenericInput = ({ component: Component, ...props }: GenericInputContainerProps) => {
+    return <FormGroup>
+        <FormLabel
+            focused={props.meta.active}
+            required={props.required !== undefined ? props.required : isRequiredInput(props.commandInput)}
+            error={props.meta.touched && !!props.meta.error}>
+            {getInputLabel(props.commandInput)}
+        </FormLabel>
+        <Component {...props} />
+        <FormHelperText error={props.meta.touched && !!props.meta.error}>
+            {
+                props.meta.touched && props.meta.error
+                    ? props.meta.error
+                    : props.commandInput.doc
+            }
+        </FormHelperText>
+    </FormGroup>;
+};
diff --git a/services/workbench2/src/views/run-process-panel/inputs/int-array-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/int-array-input.tsx
new file mode 100644 (file)
index 0000000..8077f28
--- /dev/null
@@ -0,0 +1,65 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { isRequiredInput, IntArrayCommandInputParameter } from 'models/workflow';
+import { Field } from 'redux-form';
+import { ERROR_MESSAGE } from 'validators/require';
+import { GenericInputProps, GenericInput } from 'views/run-process-panel/inputs/generic-input';
+import { ChipsInput } from 'components/chips-input/chips-input';
+import { createSelector } from 'reselect';
+import { IntInput } from 'components/int-input/int-input';
+
+export interface IntArrayInputProps {
+    input: IntArrayCommandInputParameter;
+}
+export const IntArrayInput = ({ input }: IntArrayInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={IntArrayInputComponent}
+        validate={validationSelector(input)} />;
+
+
+const validationSelector = createSelector(
+    isRequiredInput,
+    isRequired => isRequired
+        ? [required]
+        : undefined
+);
+
+const required = (value: string[]) =>
+    value && value.length > 0
+        ? undefined
+        : ERROR_MESSAGE;
+
+const IntArrayInputComponent = (props: GenericInputProps) =>
+    <GenericInput
+        component={InputComponent}
+        {...props} />;
+
+class InputComponent extends React.PureComponent<GenericInputProps>{
+    render() {
+        const { commandInput, input, meta } = this.props;
+        return <ChipsInput
+            deletable={!commandInput.disabled}
+            orderable={!commandInput.disabled}
+            disabled={commandInput.disabled}
+            values={input.value}
+            onChange={this.handleChange}
+            createNewValue={value => parseInt(value, 10)}
+            inputComponent={IntInput}
+            inputProps={{
+                error: meta.error,
+            }} />;
+    }
+
+    handleChange = (values: {}[]) => {
+        const { input, meta } = this.props;
+        if (!meta.touched) {
+            input.onBlur(values);
+        }
+        input.onChange(values);
+    }
+}
diff --git a/services/workbench2/src/views/run-process-panel/inputs/int-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/int-input.tsx
new file mode 100644 (file)
index 0000000..aa4fe9b
--- /dev/null
@@ -0,0 +1,49 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { memoize } from 'lodash/fp';
+import { IntCommandInputParameter, isRequiredInput } from 'models/workflow';
+import { Field } from 'redux-form';
+import { isInteger } from 'validators/is-integer';
+import { GenericInputProps, GenericInput } from 'views/run-process-panel/inputs/generic-input';
+import { IntInput as IntInputComponent } from 'components/int-input/int-input';
+
+export interface IntInputProps {
+    input: IntCommandInputParameter;
+}
+export const IntInput = ({ input }: IntInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={InputComponent}
+        parse={parse}
+        format={format}
+        validate={getValidation(input)} />;
+
+export const parse = (value: any) => value === '' ? '' : parseInt(value, 10);
+
+export const format = (value: any) => isNaN(value) ? '' : JSON.stringify(value);
+
+const getValidation = memoize(
+    (input: IntCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? isInteger
+            : () => undefined,
+    ]));
+
+const InputComponent = (props: GenericInputProps) =>
+    <GenericInput
+        component={Input}
+        {...props} />;
+
+
+const Input = (props: GenericInputProps) =>
+    <IntInputComponent
+        fullWidth
+        type='number'
+        error={props.meta.touched && !!props.meta.error}
+        disabled={props.commandInput.disabled}
+        {...props.input} />;
+
diff --git a/services/workbench2/src/views/run-process-panel/inputs/project-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/project-input.tsx
new file mode 100644 (file)
index 0000000..438bbe8
--- /dev/null
@@ -0,0 +1,159 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import { Field } from 'redux-form';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, withStyles, WithStyles, StyleRulesCallback } from '@material-ui/core';
+import {
+    GenericCommandInputParameter
+} from 'models/workflow';
+import { GenericInput, GenericInputProps } from './generic-input';
+import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
+import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { TreeItem } from 'components/tree/tree';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
+import { ProjectResource } from 'models/project';
+import { ResourceKind } from 'models/resource';
+import { RootState } from 'store/store';
+import { getUserUuid } from 'common/getuser';
+
+export type ProjectCommandInputParameter = GenericCommandInputParameter<ProjectResource, ProjectResource>;
+
+const require: any = (value?: ProjectResource) => (value === undefined);
+
+export interface ProjectInputProps {
+    required: boolean;
+    input: ProjectCommandInputParameter;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+}
+
+type DialogContentCssRules = 'root' | 'pickerWrapper';
+
+export const ProjectInput = ({ required, input, options }: ProjectInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={ProjectInputComponent as any}
+        format={format}
+        validate={required ? require : undefined}
+        {...{
+            options,
+            required
+        }} />;
+
+const format = (value?: ProjectResource) => value ? value.name : '';
+
+interface ProjectInputComponentState {
+    open: boolean;
+    project?: ProjectResource;
+}
+
+interface HasUserUuid {
+    userUuid: string;
+};
+
+const mapStateToProps = (state: RootState) => ({ userUuid: getUserUuid(state) });
+
+export const ProjectInputComponent = connect(mapStateToProps)(
+    class ProjectInputComponent extends React.Component<GenericInputProps & DispatchProp & HasUserUuid & {
+        options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+        required?: boolean;
+    }, ProjectInputComponentState> {
+        state: ProjectInputComponentState = {
+            open: false,
+        };
+
+        componentDidMount() {
+            this.props.dispatch<any>(
+                initProjectsTreePicker(this.props.commandInput.id));
+        }
+
+        render() {
+            return <>
+                {this.renderInput()}
+                <this.dialog />
+            </>;
+        }
+
+        openDialog = () => {
+            this.componentDidMount();
+            this.setState({ open: true });
+        }
+
+        closeDialog = () => {
+            this.setState({ open: false });
+        }
+
+        submit = () => {
+            this.closeDialog();
+            this.props.input.onChange(this.state.project);
+        }
+
+        setProject = (_: {}, { data }: TreeItem<ProjectsTreePickerItem>) => {
+            if ('kind' in data && data.kind === ResourceKind.PROJECT) {
+                this.setState({ project: data });
+            } else {
+                this.setState({ project: undefined });
+            }
+        }
+
+        invalid = () => (!this.state.project || !this.state.project.canWrite);
+
+        renderInput() {
+            return <GenericInput
+                component={props =>
+                    <Input
+                        readOnly
+                        fullWidth
+                        value={props.input.value}
+                        error={props.meta.touched && !!props.meta.error}
+                        disabled={props.commandInput.disabled}
+                        onClick={!this.props.commandInput.disabled ? this.openDialog : undefined}
+                        onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined} />}
+                {...this.props} />;
+        }
+
+        dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
+            root: {
+                display: 'flex',
+                flexDirection: 'column',
+            },
+            pickerWrapper: {
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
+            },
+        });
+
+        dialog = withStyles(this.dialogContentStyles)(
+            ({ classes }: WithStyles<DialogContentCssRules>) =>
+                this.state.open ? <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    data-cy="choose-a-project-dialog"
+                    maxWidth='md'>
+                    <DialogTitle>Choose a project</DialogTitle>
+                    <DialogContent className={classes.root}>
+                        <div className={classes.pickerWrapper}>
+                            <ProjectsTreePicker
+                                pickerId={this.props.commandInput.id}
+                                cascadeSelection={false}
+                                options={this.props.options}
+                                toggleItemActive={this.setProject} />
+                        </div>
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            disabled={this.invalid()}
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog> : null
+        );
+
+    });
diff --git a/services/workbench2/src/views/run-process-panel/inputs/string-array-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/string-array-input.tsx
new file mode 100644 (file)
index 0000000..8955009
--- /dev/null
@@ -0,0 +1,66 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { isRequiredInput, StringArrayCommandInputParameter } from 'models/workflow';
+import { Field } from 'redux-form';
+import { ERROR_MESSAGE } from 'validators/require';
+import { GenericInputProps, GenericInput } from 'views/run-process-panel/inputs/generic-input';
+import { ChipsInput } from 'components/chips-input/chips-input';
+import { identity } from 'lodash';
+import { createSelector } from 'reselect';
+import { Input } from '@material-ui/core';
+
+export interface StringArrayInputProps {
+    input: StringArrayCommandInputParameter;
+}
+export const StringArrayInput = ({ input }: StringArrayInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={StringArrayInputComponent}
+        validate={validationSelector(input)} />;
+
+
+const validationSelector = createSelector(
+    isRequiredInput,
+    isRequired => isRequired
+        ? [required]
+        : undefined
+);
+
+const required = (value: string[] = []) =>
+    value && value.length > 0
+        ? undefined
+        : ERROR_MESSAGE;
+
+const StringArrayInputComponent = (props: GenericInputProps) =>
+    <GenericInput
+        component={InputComponent}
+        {...props} />;
+
+class InputComponent extends React.PureComponent<GenericInputProps>{
+    render() {
+        const { commandInput, input, meta } = this.props;
+        return <ChipsInput
+            deletable={!commandInput.disabled}
+            orderable={!commandInput.disabled}
+            disabled={commandInput.disabled}
+            values={input.value}
+            onChange={this.handleChange}
+            createNewValue={identity}
+            inputComponent={Input}
+            inputProps={{
+                error: meta.error
+            }} />;
+    }
+
+    handleChange = (values: {}[]) => {
+        const { input, meta } = this.props;
+        if (!meta.touched) {
+            input.onBlur(values);
+        }
+        input.onChange(values);
+    }
+}
diff --git a/services/workbench2/src/views/run-process-panel/inputs/string-input.tsx b/services/workbench2/src/views/run-process-panel/inputs/string-input.tsx
new file mode 100644 (file)
index 0000000..543100d
--- /dev/null
@@ -0,0 +1,40 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { memoize } from 'lodash/fp';
+import { isRequiredInput, StringCommandInputParameter } from 'models/workflow';
+import { Field } from 'redux-form';
+import { require } from 'validators/require';
+import { GenericInputProps, GenericInput } from 'views/run-process-panel/inputs/generic-input';
+import { Input as MaterialInput } from '@material-ui/core';
+
+export interface StringInputProps {
+    input: StringCommandInputParameter;
+}
+export const StringInput = ({ input }: StringInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={StringInputComponent}
+        validate={getValidation(input)} />;
+
+const getValidation = memoize(
+    (input: StringCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? require
+            : () => undefined,
+    ]));
+
+const StringInputComponent = (props: GenericInputProps) =>
+    <GenericInput
+        component={Input}
+        {...props} />;
+
+const Input = (props: GenericInputProps) =>
+    <MaterialInput
+        fullWidth
+        error={props.meta.touched && !!props.meta.error}
+        disabled={props.commandInput.disabled}
+        {...props.input} />;
\ No newline at end of file
diff --git a/services/workbench2/src/views/run-process-panel/run-process-advanced-form.tsx b/services/workbench2/src/views/run-process-panel/run-process-advanced-form.tsx
new file mode 100644 (file)
index 0000000..52abfe8
--- /dev/null
@@ -0,0 +1,112 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { ExpansionPanel, ExpansionPanelDetails, ExpansionPanelSummary } from '@material-ui/core';
+import { reduxForm, Field } from 'redux-form';
+import { Grid } from '@material-ui/core';
+import { TextField } from 'components/text-field/text-field';
+import { ExpandIcon } from 'components/icon/icon';
+import * as IntInput from './inputs/int-input';
+import { min } from 'validators/min';
+import { optional } from 'validators/optional';
+
+export const RUN_PROCESS_ADVANCED_FORM = 'runProcessAdvancedForm';
+
+export const OUTPUT_FIELD = 'output';
+export const RUNTIME_FIELD = 'runtime';
+export const RAM_FIELD = 'ram';
+export const VCPUS_FIELD = 'vcpus';
+export const KEEP_CACHE_RAM_FIELD = 'keep_cache_ram';
+export const RUNNER_IMAGE_FIELD = 'acr_container_image';
+
+export interface RunProcessAdvancedFormData {
+    [OUTPUT_FIELD]?: string;
+    [RUNTIME_FIELD]?: number;
+    [RAM_FIELD]: number;
+    [VCPUS_FIELD]: number;
+    [KEEP_CACHE_RAM_FIELD]: number;
+    [RUNNER_IMAGE_FIELD]: string;
+}
+
+export const RunProcessAdvancedForm =
+    reduxForm<RunProcessAdvancedFormData>({
+        form: RUN_PROCESS_ADVANCED_FORM,
+    })(() =>
+        <form>
+            <ExpansionPanel elevation={0}>
+                <ExpansionPanelSummary style={{ padding: 0 }} expandIcon={<ExpandIcon />}>
+                    Advanced
+                </ExpansionPanelSummary>
+                <ExpansionPanelDetails style={{ padding: 0 }}>
+                    <Grid container spacing={32}>
+                        <Grid item xs={12} md={6}>
+                            <Field
+                                name={OUTPUT_FIELD}
+                                component={TextField as any}
+                                label="Output name" />
+                        </Grid>
+                        <Grid item xs={12} md={6}>
+                            <Field
+                                name={RUNTIME_FIELD}
+                                component={TextField as any}
+                                helperText="Maximum running time (in seconds) that this container will be allowed to run before being cancelled."
+                                label="Runtime limit"
+                                parse={IntInput.parse}
+                                format={IntInput.format}
+                                type='number'
+                                validate={runtimeValidation} />
+                        </Grid>
+                        <Grid item xs={12} md={6}>
+                            <Field
+                                name={RAM_FIELD}
+                                component={TextField as any}
+                                label="RAM"
+                                helperText="Number of ram bytes to be used to run this process."
+                                parse={IntInput.parse}
+                                format={IntInput.format}
+                                type='number'
+                                required
+                                validate={ramValidation} />
+                        </Grid>
+                        <Grid item xs={12} md={6}>
+                            <Field
+                                name={VCPUS_FIELD}
+                                component={TextField as any}
+                                label="VCPUs"
+                                helperText="Number of cores to be used to run this process."
+                                parse={IntInput.parse}
+                                format={IntInput.format}
+                                type='number'
+                                required
+                                validate={vcpusValidation} />
+                        </Grid>
+                        <Grid item xs={12} md={6}>
+                            <Field
+                                name={KEEP_CACHE_RAM_FIELD}
+                                component={TextField as any}
+                                label="Keep cache RAM"
+                                helperText="Number of keep cache bytes to be used to run this process."
+                                parse={IntInput.parse}
+                                format={IntInput.format}
+                                type='number'
+                                validate={keepCacheRamValidation} />
+                        </Grid>
+                        <Grid item xs={12} md={6}>
+                            <Field
+                                name={RUNNER_IMAGE_FIELD}
+                                component={TextField as any}
+                                label='Runner'
+                                required
+                                helperText='The container image with arvados-cwl-runner that will execute this workflow.' />
+                        </Grid>
+                    </Grid>
+                </ExpansionPanelDetails>
+            </ExpansionPanel>
+        </form >);
+
+const ramValidation = [min(0)];
+const vcpusValidation = [min(1)];
+const keepCacheRamValidation = [optional(min(0))];
+const runtimeValidation = [optional(min(1))];
diff --git a/services/workbench2/src/views/run-process-panel/run-process-basic-form.tsx b/services/workbench2/src/views/run-process-panel/run-process-basic-form.tsx
new file mode 100644 (file)
index 0000000..a6f7a70
--- /dev/null
@@ -0,0 +1,50 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { reduxForm, Field } from 'redux-form';
+import { Grid } from '@material-ui/core';
+import { TextField } from 'components/text-field/text-field';
+import { ProjectInput, ProjectCommandInputParameter } from 'views/run-process-panel/inputs/project-input';
+import { PROCESS_NAME_VALIDATION } from 'validators/validators';
+import { ProjectResource } from 'models/project';
+import { UserResource } from 'models/user';
+
+export const RUN_PROCESS_BASIC_FORM = 'runProcessBasicForm';
+
+export interface RunProcessBasicFormData {
+    name: string;
+    description: string;
+    owner?: ProjectResource | UserResource;
+}
+
+export const RunProcessBasicForm =
+    reduxForm<RunProcessBasicFormData>({
+        form: RUN_PROCESS_BASIC_FORM
+    })(() =>
+        <form>
+            <Grid container spacing={32}>
+                <Grid item xs={12} md={6}>
+                    <Field
+                        name='name'
+                        component={TextField as any}
+                        label="Name for this workflow run"
+                        required
+                        validate={PROCESS_NAME_VALIDATION} />
+                </Grid>
+                <Grid item xs={12} md={6}>
+                    <Field
+                        name='description'
+                        component={TextField as any}
+                        label="Optional description of this workflow run" />
+                </Grid>
+                <Grid item xs={12} md={6}>
+                    <ProjectInput required input={{
+                        id: "owner",
+                        label: "Project where the workflow will run"
+                    } as ProjectCommandInputParameter}
+                        options={{ showOnlyOwned: false, showOnlyWritable: true }} />
+                </Grid>
+            </Grid>
+        </form>);
diff --git a/services/workbench2/src/views/run-process-panel/run-process-first-step.tsx b/services/workbench2/src/views/run-process-panel/run-process-first-step.tsx
new file mode 100644 (file)
index 0000000..ed6d564
--- /dev/null
@@ -0,0 +1,92 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, withStyles, Grid, Button, WithStyles, List, ListItem, ListItemText, ListItemIcon } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { WorkflowResource } from 'models/workflow';
+import { WorkflowIcon } from 'components/icon/icon';
+import { WorkflowDetailsCard } from 'views/workflow-panel/workflow-description-card';
+import { SearchInput } from 'components/search-input/search-input';
+
+type CssRules = 'root' | 'searchGrid' | 'workflowDetailsGrid' | 'list' | 'listItem' | 'itemSelected' | 'listItemText' | 'listItemIcon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        alignSelf: 'flex-start'
+    },
+    searchGrid: {
+        marginBottom: theme.spacing.unit * 2
+    },
+    workflowDetailsGrid: {
+        borderLeft: `1px solid ${theme.palette.grey["300"]}`
+    },
+    list: {
+        maxHeight: 300,
+        position: 'relative',
+        overflow: 'auto'
+    },
+    listItem: {
+        padding: theme.spacing.unit,
+    },
+    itemSelected: {
+        backgroundColor: 'rgba(3, 190, 171, 0.3) !important'
+    },
+    listItemText: {
+        fontSize: '0.875rem'
+    },
+    listItemIcon: {
+        color: theme.customs.colors.red900
+    }
+});
+
+export interface RunProcessFirstStepDataProps {
+    workflows: WorkflowResource[];
+    selectedWorkflow: WorkflowResource | undefined;
+}
+
+export interface RunProcessFirstStepActionProps {
+    onSearch: (term: string) => void;
+    onSetStep: (step: number) => void;
+    onSetWorkflow: (workflow: WorkflowResource) => void;
+}
+
+type RunProcessFirstStepProps = RunProcessFirstStepDataProps & RunProcessFirstStepActionProps & WithStyles<CssRules>;
+
+export const RunProcessFirstStep = withStyles(styles)(
+    ({ onSearch, onSetStep, onSetWorkflow, workflows, selectedWorkflow, classes }: RunProcessFirstStepProps) =>
+        <Grid container spacing={16}>
+            <Grid container item xs={6} className={classes.root}>
+                <Grid item xs={12} className={classes.searchGrid}>
+                    <SearchInput selfClearProp={JSON.stringify(selectedWorkflow)} value='' onSearch={onSearch} />
+                </Grid>
+                <Grid item xs={12}>
+                    <List className={classes.list}>
+                        {workflows.map(workflow => (
+                            <ListItem key={workflow.uuid} button
+                                classes={{ root: classes.listItem, selected: classes.itemSelected}}
+                                selected={selectedWorkflow && (selectedWorkflow.uuid === workflow.uuid)}
+                                onClick={() => onSetWorkflow(workflow)}>
+                                <ListItemIcon>
+                                    <WorkflowIcon className={classes.listItemIcon}/>
+                                </ListItemIcon>
+                                <ListItemText className={classes.listItemText} primary={workflow.name} disableTypography={true} />
+                            </ListItem>
+                        ))}
+                    </List>
+                </Grid>
+            </Grid>
+            <Grid item xs={6} className={classes.workflowDetailsGrid}>
+                <WorkflowDetailsCard workflow={selectedWorkflow}/>
+            </Grid>
+            <Grid item xs={12}>
+                <Button variant="contained" color="primary"
+                    data-cy="run-process-next-button"
+                    disabled={!(!!selectedWorkflow)}
+                    onClick={() => onSetStep(1)}>
+                    Next
+                </Button>
+            </Grid>
+        </Grid>
+);
diff --git a/services/workbench2/src/views/run-process-panel/run-process-inputs-form.tsx b/services/workbench2/src/views/run-process-panel/run-process-inputs-form.tsx
new file mode 100644 (file)
index 0000000..ca402ab
--- /dev/null
@@ -0,0 +1,120 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { reduxForm, InjectedFormProps } from 'redux-form';
+import { CommandInputParameter, CWLType, IntCommandInputParameter, BooleanCommandInputParameter, FileCommandInputParameter, DirectoryCommandInputParameter, DirectoryArrayCommandInputParameter, FloatArrayCommandInputParameter, IntArrayCommandInputParameter } from 'models/workflow';
+import { IntInput } from 'views/run-process-panel/inputs/int-input';
+import { StringInput } from 'views/run-process-panel/inputs/string-input';
+import { StringCommandInputParameter, FloatCommandInputParameter, isPrimitiveOfType, WorkflowInputsData, EnumCommandInputParameter, isArrayOfType, StringArrayCommandInputParameter, FileArrayCommandInputParameter, getEnumType } from '../../models/workflow';
+import { FloatInput } from 'views/run-process-panel/inputs/float-input';
+import { BooleanInput } from './inputs/boolean-input';
+import { FileInput } from './inputs/file-input';
+import { connect } from 'react-redux';
+import { compose } from 'redux';
+import { Grid, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
+import { EnumInput } from './inputs/enum-input';
+import { DirectoryInput } from './inputs/directory-input';
+import { StringArrayInput } from './inputs/string-array-input';
+import { createStructuredSelector, createSelector } from 'reselect';
+import { FileArrayInput } from './inputs/file-array-input';
+import { DirectoryArrayInput } from './inputs/directory-array-input';
+import { FloatArrayInput } from './inputs/float-array-input';
+import { IntArrayInput } from './inputs/int-array-input';
+
+export const RUN_PROCESS_INPUTS_FORM = 'runProcessInputsForm';
+
+export interface RunProcessInputFormProps {
+    inputs: CommandInputParameter[];
+}
+
+const inputsSelector = (props: RunProcessInputFormProps) =>
+    props.inputs;
+
+const initialValuesSelector = createSelector(
+    inputsSelector,
+    inputs => inputs.reduce(
+        (values, input) => ({ ...values, [input.id]: input.value || input.default }),
+        {}));
+
+const propsSelector = createStructuredSelector({
+    initialValues: initialValuesSelector,
+});
+
+const mapStateToProps = (_: any, props: RunProcessInputFormProps) =>
+    propsSelector(props);
+
+export const RunProcessInputsForm = compose(
+    connect(mapStateToProps),
+    reduxForm<WorkflowInputsData, RunProcessInputFormProps>({
+        form: RUN_PROCESS_INPUTS_FORM
+    }))(
+        (props: InjectedFormProps & RunProcessInputFormProps) =>
+            <form>
+                <Grid container spacing={32}>
+                    {props.inputs.map(input =>
+                        <InputItem input={input} key={input.id} />)}
+                </Grid>
+            </form>);
+
+type CssRules = 'inputItem';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    inputItem: {
+        marginBottom: theme.spacing.unit * 2,
+    }
+});
+
+const InputItem = withStyles(styles)(
+    (props: WithStyles<CssRules> & { input: CommandInputParameter }) =>
+        <Grid item xs={12} md={6} className={props.classes.inputItem}>
+            {getInputComponent(props.input)}
+        </Grid>);
+
+const getInputComponent = (input: CommandInputParameter) => {
+    switch (true) {
+        case isPrimitiveOfType(input, CWLType.BOOLEAN):
+            return <BooleanInput input={input as BooleanCommandInputParameter} />;
+
+        case isPrimitiveOfType(input, CWLType.INT):
+        case isPrimitiveOfType(input, CWLType.LONG):
+            return <IntInput input={input as IntCommandInputParameter} />;
+
+        case isPrimitiveOfType(input, CWLType.FLOAT):
+        case isPrimitiveOfType(input, CWLType.DOUBLE):
+            return <FloatInput input={input as FloatCommandInputParameter} />;
+
+        case isPrimitiveOfType(input, CWLType.STRING):
+            return <StringInput input={input as StringCommandInputParameter} />;
+
+        case isPrimitiveOfType(input, CWLType.FILE):
+            return <FileInput options={{ showOnlyOwned: false, showOnlyWritable: false }} input={input as FileCommandInputParameter} />;
+
+        case isPrimitiveOfType(input, CWLType.DIRECTORY):
+            return <DirectoryInput options={{ showOnlyOwned: false, showOnlyWritable: false }} input={input as DirectoryCommandInputParameter} />;
+
+        case getEnumType(input) !== null:
+            return <EnumInput input={input as EnumCommandInputParameter} />;
+
+        case isArrayOfType(input, CWLType.STRING):
+            return <StringArrayInput input={input as StringArrayCommandInputParameter} />;
+
+        case isArrayOfType(input, CWLType.INT):
+        case isArrayOfType(input, CWLType.LONG):
+            return <IntArrayInput input={input as IntArrayCommandInputParameter} />;
+
+        case isArrayOfType(input, CWLType.FLOAT):
+        case isArrayOfType(input, CWLType.DOUBLE):
+            return <FloatArrayInput input={input as FloatArrayCommandInputParameter} />;
+
+        case isArrayOfType(input, CWLType.FILE):
+            return <FileArrayInput options={{ showOnlyOwned: false, showOnlyWritable: false }} input={input as FileArrayCommandInputParameter} />;
+
+        case isArrayOfType(input, CWLType.DIRECTORY):
+            return <DirectoryArrayInput options={{ showOnlyOwned: false, showOnlyWritable: false }} input={input as DirectoryArrayCommandInputParameter} />;
+
+        default:
+            return null;
+    }
+};
diff --git a/services/workbench2/src/views/run-process-panel/run-process-panel-root.tsx b/services/workbench2/src/views/run-process-panel/run-process-panel-root.tsx
new file mode 100644 (file)
index 0000000..9dd5aa3
--- /dev/null
@@ -0,0 +1,50 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Stepper, Step, StepLabel, StepContent, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
+import { RunProcessFirstStepDataProps, RunProcessFirstStepActionProps, RunProcessFirstStep } from 'views/run-process-panel/run-process-first-step';
+import { RunProcessSecondStepForm } from './run-process-second-step';
+
+export type RunProcessPanelRootDataProps = {
+    currentStep: number;
+} & RunProcessFirstStepDataProps;
+
+export type RunProcessPanelRootActionProps = RunProcessFirstStepActionProps & {
+    runProcess: () => void;
+};
+
+type RunProcessPanelRootProps = RunProcessPanelRootDataProps & RunProcessPanelRootActionProps;
+
+type CssRules = 'stepper';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    stepper: {
+        overflow: "scroll",
+    }
+});
+
+export const RunProcessPanelRoot = withStyles(styles)(
+    ({ runProcess, currentStep, onSearch, onSetStep, onSetWorkflow, workflows, selectedWorkflow, classes }: WithStyles<CssRules> & RunProcessPanelRootProps) =>
+        <Stepper activeStep={currentStep} orientation="vertical" elevation={2} className={classes.stepper}>
+            <Step>
+                <StepLabel>Choose a workflow</StepLabel>
+                <StepContent>
+                    <RunProcessFirstStep
+                        workflows={workflows}
+                        selectedWorkflow={selectedWorkflow}
+                        onSearch={onSearch}
+                        onSetStep={onSetStep}
+                        onSetWorkflow={onSetWorkflow} />
+                </StepContent>
+            </Step>
+            <Step>
+                <StepLabel>Select inputs</StepLabel>
+                <StepContent>
+                    <RunProcessSecondStepForm
+                        goBack={() => onSetStep(0)}
+                        runProcess={runProcess} />
+                </StepContent>
+            </Step>
+        </Stepper>);
diff --git a/services/workbench2/src/views/run-process-panel/run-process-panel.tsx b/services/workbench2/src/views/run-process-panel/run-process-panel.tsx
new file mode 100644 (file)
index 0000000..9fb5a21
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import { RunProcessPanelRootDataProps, RunProcessPanelRootActionProps, RunProcessPanelRoot } from 'views/run-process-panel/run-process-panel-root';
+import { goToStep, runProcess, searchWorkflows, openSetWorkflowDialog } from 'store/run-process-panel/run-process-panel-actions';
+import { WorkflowResource } from 'models/workflow';
+
+const mapStateToProps = ({ runProcessPanel }: RootState): RunProcessPanelRootDataProps => {
+    return {
+        workflows: runProcessPanel.searchWorkflows,
+        currentStep: runProcessPanel.currentStep,
+        selectedWorkflow: runProcessPanel.selectedWorkflow
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): RunProcessPanelRootActionProps => ({
+    onSetStep: (step: number) => {
+        dispatch<any>(goToStep(step));
+    },
+    onSetWorkflow: (workflow: WorkflowResource) => {
+        dispatch<any>(openSetWorkflowDialog(workflow));
+    },
+    runProcess: () => {
+        dispatch<any>(runProcess);
+    },
+    onSearch: (term: string) => {
+        dispatch<any>(searchWorkflows(term));
+    }
+});
+
+export const RunProcessPanel = connect(mapStateToProps, mapDispatchToProps)(RunProcessPanelRoot);
\ No newline at end of file
diff --git a/services/workbench2/src/views/run-process-panel/run-process-second-step.tsx b/services/workbench2/src/views/run-process-panel/run-process-second-step.tsx
new file mode 100644 (file)
index 0000000..2f41ded
--- /dev/null
@@ -0,0 +1,72 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Grid, Button } from '@material-ui/core';
+import { RunProcessBasicForm, RUN_PROCESS_BASIC_FORM } from './run-process-basic-form';
+import { RunProcessInputsForm } from 'views/run-process-panel/run-process-inputs-form';
+import { CommandInputParameter, WorkflowResource } from 'models/workflow';
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import { isValid } from 'redux-form';
+import { RUN_PROCESS_INPUTS_FORM } from './run-process-inputs-form';
+import { RunProcessAdvancedForm, RUN_PROCESS_ADVANCED_FORM } from './run-process-advanced-form';
+import { createStructuredSelector } from 'reselect';
+import { selectPreset } from 'store/run-process-panel/run-process-panel-actions';
+
+export interface RunProcessSecondStepFormDataProps {
+    inputs: CommandInputParameter[];
+    workflow?: WorkflowResource;
+    presets?: WorkflowResource[];
+    selectedPreset?: WorkflowResource;
+    valid: boolean;
+}
+
+export interface RunProcessSecondStepFormActionProps {
+    goBack: () => void;
+    runProcess: () => void;
+    onPresetChange: (preset: WorkflowResource) => void;
+}
+
+const selectedWorkflowSelector = (state: RootState) =>
+    state.runProcessPanel.selectedWorkflow;
+
+const presetsSelector = (state: RootState) =>
+    state.runProcessPanel.presets;
+
+const selectedPresetSelector = (state: RootState) =>
+    state.runProcessPanel.selectedPreset;
+
+const inputsSelector = (state: RootState) =>
+    state.runProcessPanel.inputs;
+
+const validSelector = (state: RootState) =>
+    isValid(RUN_PROCESS_BASIC_FORM)(state) && isValid(RUN_PROCESS_INPUTS_FORM)(state) && isValid(RUN_PROCESS_ADVANCED_FORM)(state);
+
+const mapStateToProps = createStructuredSelector({
+    inputs: inputsSelector,
+    valid: validSelector,
+    workflow: selectedWorkflowSelector,
+    presets: presetsSelector,
+    selectedPreset: selectedPresetSelector,
+});
+
+export type RunProcessSecondStepFormProps = RunProcessSecondStepFormDataProps & RunProcessSecondStepFormActionProps;
+export const RunProcessSecondStepForm = connect(mapStateToProps, { onPresetChange: selectPreset })(
+    ({ inputs, workflow, selectedPreset, presets, onPresetChange, valid, goBack, runProcess }: RunProcessSecondStepFormProps) =>
+        <Grid container spacing={16} data-cy="new-process-panel">
+            <Grid item xs={12}>
+                <RunProcessBasicForm />
+                <RunProcessInputsForm inputs={inputs} />
+                <RunProcessAdvancedForm />
+            </Grid>
+            <Grid item xs={12}>
+                <Button color="primary" onClick={goBack}>
+                    Back
+                </Button>
+                <Button disabled={!valid} variant="contained" color="primary" onClick={runProcess}>
+                    Run workflow
+                </Button>
+            </Grid>
+        </Grid>);
diff --git a/services/workbench2/src/views/run-process-panel/workflow-preset-select.tsx b/services/workbench2/src/views/run-process-panel/workflow-preset-select.tsx
new file mode 100644 (file)
index 0000000..c30cb70
--- /dev/null
@@ -0,0 +1,68 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Select, FormControl, InputLabel, MenuItem, Tooltip, withStyles, WithStyles } from '@material-ui/core';
+import { WorkflowResource } from 'models/workflow';
+import { DetailsIcon } from 'components/icon/icon';
+
+export interface WorkflowPresetSelectProps {
+    workflow: WorkflowResource;
+    selectedPreset: WorkflowResource;
+    presets: WorkflowResource[];
+    onChange: (preset: WorkflowResource) => void;
+}
+
+type CssRules = 'root' | 'icon';
+
+export const WorkflowPresetSelect = withStyles<CssRules>(theme => ({
+    root: {
+        display: 'flex',
+    },
+    icon: {
+        color: theme.palette.text.hint,
+        marginTop: 18,
+        marginLeft: 8,
+    },
+}))(
+    class extends React.Component<WorkflowPresetSelectProps & WithStyles<CssRules>> {
+
+        render() {
+
+            const { selectedPreset, workflow, presets, classes } = this.props;
+
+            return (
+                <div className={classes.root}>
+                    <FormControl fullWidth>
+                        <InputLabel>Preset</InputLabel>
+                        <Select
+                            value={selectedPreset.uuid}
+                            onChange={this.handleChange}>
+                            <MenuItem value={workflow.uuid}>
+                                <em>Default</em>
+                            </MenuItem>
+                            {presets.map(
+                                ({ uuid, name }) => <MenuItem key={uuid} value={uuid}>{name}</MenuItem>
+                            )}
+                        </Select>
+                    </FormControl>
+                    <Tooltip title='List of already defined set of inputs to run a workflow'>
+                        <DetailsIcon className={classes.icon} />
+                    </Tooltip>
+                </div >
+            );
+        }
+
+        handleChange = ({ target }: React.ChangeEvent<HTMLSelectElement>) => {
+
+            const { workflow, presets, onChange } = this.props;
+
+            const selectedPreset = [workflow, ...presets]
+                .find(({ uuid }) => uuid === target.value);
+
+            if (selectedPreset) {
+                onChange(selectedPreset);
+            }
+        }
+    });
diff --git a/services/workbench2/src/views/search-results-panel/search-results-panel-view.tsx b/services/workbench2/src/views/search-results-panel/search-results-panel-view.tsx
new file mode 100644 (file)
index 0000000..e9693b5
--- /dev/null
@@ -0,0 +1,184 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useEffect, useCallback, useState } from 'react';
+import { SortDirection } from 'components/data-table/data-column';
+import { DataColumns } from 'components/data-table/data-table';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
+import { extractUuidKind, ResourceKind } from 'models/resource';
+import { ContainerRequestState } from 'models/container-request';
+import { SEARCH_RESULTS_PANEL_ID } from 'store/search-results-panel/search-results-panel-actions';
+import { DataExplorer } from 'views-components/data-explorer/data-explorer';
+import {
+    ResourceCluster,
+    ResourceFileSize,
+    ResourceLastModifiedDate,
+    ResourceName,
+    ResourceOwnerWithName,
+    ResourceStatus,
+    ResourceType
+} from 'views-components/data-explorer/renderers';
+import servicesProvider from 'common/service-provider';
+import { createTree } from 'models/tree';
+import { getInitialSearchTypeFilters } from 'store/resource-type-filters/resource-type-filters';
+import { SearchResultsPanelProps } from "./search-results-panel";
+import { Routes } from 'routes/routes';
+import { Link } from 'react-router-dom';
+import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { getSearchSessions } from 'store/search-bar/search-bar-actions';
+import { camelCase } from 'lodash';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+
+export enum SearchResultsPanelColumnNames {
+    CLUSTER = "Cluster",
+    NAME = "Name",
+    STATUS = "Status",
+    TYPE = 'Type',
+    OWNER = "Owner",
+    FILE_SIZE = "File size",
+    LAST_MODIFIED = "Last modified"
+}
+
+export type CssRules = 'siteManagerLink' | 'searchResults';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    searchResults: {
+        width: '100%'
+    },
+    siteManagerLink: {
+        marginRight: theme.spacing.unit * 2,
+        float: 'right'
+    }
+});
+
+export interface WorkflowPanelFilter extends DataTableFilterItem {
+    type: ResourceKind | ContainerRequestState;
+}
+
+export const searchResultsPanelColumns: DataColumns<string, GroupContentsResource> = [
+    {
+        name: SearchResultsPanelColumnNames.CLUSTER,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid: string) => <ResourceCluster uuid={uuid} />
+    },
+    {
+        name: SearchResultsPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: "name" },
+        filters: createTree(),
+        render: (uuid: string) => <ResourceName uuid={uuid} />
+    },
+    {
+        name: SearchResultsPanelColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceStatus uuid={uuid} />
+    },
+    {
+        name: SearchResultsPanelColumnNames.TYPE,
+        selected: true,
+        configurable: true,
+        filters: getInitialSearchTypeFilters(),
+        render: (uuid: string) => <ResourceType uuid={uuid} />,
+    },
+    {
+        name: SearchResultsPanelColumnNames.OWNER,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceOwnerWithName uuid={uuid} />
+    },
+    {
+        name: SearchResultsPanelColumnNames.FILE_SIZE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceFileSize uuid={uuid} />
+    },
+    {
+        name: SearchResultsPanelColumnNames.LAST_MODIFIED,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.DESC, field: "modifiedAt" },
+        filters: createTree(),
+        render: uuid => <ResourceLastModifiedDate uuid={uuid} />
+    }
+];
+
+export const SearchResultsPanelView = withStyles(styles, { withTheme: true })(
+    (props: SearchResultsPanelProps & WithStyles<CssRules, true>) => {
+        const homeCluster = props.user.uuid.substring(0, 5);
+        const loggedIn = props.sessions.filter((ss) => ss.loggedIn && ss.userIsActive);
+        const [selectedItem, setSelectedItem] = useState('');
+        const [itemPath, setItemPath] = useState<string[]>([]);
+
+        useEffect(() => {
+            let tmpPath: string[] = [];
+
+            (async () => {
+                if (selectedItem !== '') {
+                    let searchUuid = selectedItem;
+                    let itemKind = extractUuidKind(searchUuid);
+
+                    while (itemKind !== ResourceKind.USER) {
+                        const clusterId = searchUuid.split('-')[0];
+                        const serviceType = camelCase(itemKind?.replace('arvados#', ''));
+                        const service = Object.values(servicesProvider.getServices())
+                            .filter(({ resourceType }) => !!resourceType)
+                            .find(({ resourceType }) => camelCase(resourceType).indexOf(serviceType) > -1);
+                        const sessions = getSearchSessions(clusterId, props.sessions);
+
+                        if (sessions.length > 0) {
+                            const session = sessions[0];
+                            const { name, ownerUuid } = await (service as any).get(searchUuid, false, undefined, session);
+                            tmpPath.push(name);
+                            searchUuid = ownerUuid;
+                            itemKind = extractUuidKind(searchUuid);
+                        } else {
+                            break;
+                        }
+                    }
+
+                    tmpPath.push(props.user.uuid === searchUuid ? 'Projects' : 'Shared with me');
+                    setItemPath(tmpPath);
+                }
+            })();
+
+            // eslint-disable-next-line react-hooks/exhaustive-deps
+        }, [selectedItem]);
+
+        const onItemClick = useCallback((uuid) => {
+            setSelectedItem(uuid);
+            props.onItemClick(uuid);
+            // eslint-disable-next-line react-hooks/exhaustive-deps
+        }, [props.onItemClick]);
+
+        return <span data-cy='search-results' className={props.classes.searchResults}>
+            <DataExplorer
+                id={SEARCH_RESULTS_PANEL_ID}
+                onRowClick={onItemClick}
+                onRowDoubleClick={props.onItemDoubleClick}
+                onContextMenu={props.onContextMenu}
+                contextMenuColumn={false}
+                elementPath={`/ ${itemPath.reverse().join(' / ')}`}
+                hideSearchInput
+                title={
+                    <div>
+                        {loggedIn.length === 1 ?
+                            <span>Searching local cluster <ResourceCluster uuid={props.localCluster} /></span>
+                            : <span>Searching clusters: {loggedIn.map((ss) => <span key={ss.clusterId}>
+                                <a href={props.remoteHostsConfig[ss.clusterId] && props.remoteHostsConfig[ss.clusterId].workbench2Url} style={{ textDecoration: 'none' }}> <ResourceCluster uuid={ss.clusterId} /></a>
+                            </span>)}</span>}
+                        {loggedIn.length === 1 && props.localCluster !== homeCluster ?
+                            <span>To search multiple clusters, <a href={props.remoteHostsConfig[homeCluster] && props.remoteHostsConfig[homeCluster].workbench2Url}> start from your home Workbench.</a></span>
+                            : <span style={{ marginLeft: "2em" }}>Use <Link to={Routes.SITE_MANAGER} >Site Manager</Link> to manage which clusters will be searched.</span>}
+                    </div >
+                }
+            /></span>;
+    });
diff --git a/services/workbench2/src/views/search-results-panel/search-results-panel.tsx b/services/workbench2/src/views/search-results-panel/search-results-panel.tsx
new file mode 100644 (file)
index 0000000..0b69ff2
--- /dev/null
@@ -0,0 +1,59 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { navigateTo } from 'store/navigation/navigation-action';
+import { openSearchResultsContextMenu } from 'store/context-menu/context-menu-actions';
+import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
+import { SearchResultsPanelView } from 'views/search-results-panel/search-results-panel-view';
+import { RootState } from 'store/store';
+import { SearchBarAdvancedFormData } from 'models/search-bar';
+import { User } from "models/user";
+import { Config } from 'common/config';
+import { Session } from "models/session";
+import { toggleOne, deselectAllOthers } from "store/multiselect/multiselect-actions";
+
+export interface SearchResultsPanelDataProps {
+    data: SearchBarAdvancedFormData;
+    user: User;
+    sessions: Session[];
+    remoteHostsConfig: { [key: string]: Config };
+    localCluster: string;
+}
+
+export interface SearchResultsPanelActionProps {
+    onItemClick: (item: string) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: string) => void;
+    onDialogOpen: (ownerUuid: string) => void;
+    onItemDoubleClick: (item: string) => void;
+}
+
+export type SearchResultsPanelProps = SearchResultsPanelDataProps & SearchResultsPanelActionProps;
+
+const mapStateToProps = (rootState: RootState) => {
+    return {
+        user: rootState.auth.user,
+        sessions: rootState.auth.sessions,
+        remoteHostsConfig: rootState.auth.remoteHostsConfig,
+        localCluster: rootState.auth.localCluster,
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): SearchResultsPanelActionProps => ({
+    onContextMenu: (event, resourceUuid) => {
+        dispatch<any>(openSearchResultsContextMenu(event, resourceUuid));
+    },
+    onDialogOpen: (ownerUuid: string) => { return; },
+    onItemClick: (resourceUuid: string) => {
+        dispatch<any>(toggleOne(resourceUuid))
+        dispatch<any>(deselectAllOthers(resourceUuid))
+        dispatch<any>(loadDetailsPanel(resourceUuid));
+    },
+    onItemDoubleClick: uuid => {
+        dispatch<any>(navigateTo(uuid));
+    }
+});
+
+export const SearchResultsPanel = connect(mapStateToProps, mapDispatchToProps)(SearchResultsPanelView);
diff --git a/services/workbench2/src/views/shared-with-me-panel/shared-with-me-panel.tsx b/services/workbench2/src/views/shared-with-me-panel/shared-with-me-panel.tsx
new file mode 100644 (file)
index 0000000..e8f80d6
--- /dev/null
@@ -0,0 +1,290 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { connect, DispatchProp } from 'react-redux';
+import { RootState } from 'store/store';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ShareMeIcon } from 'components/icon/icon';
+import { ResourcesState, getResource } from 'store/resources/resources';
+import { ResourceKind } from 'models/resource';
+import { navigateTo } from "store/navigation/navigation-action";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { SHARED_WITH_ME_PANEL_ID } from 'store/shared-with-me-panel/shared-with-me-panel-actions';
+import {
+    openContextMenu,
+    resourceUuidToContextMenuKind
+} from 'store/context-menu/context-menu-actions';
+import {
+    ResourceName,
+    ProcessStatus as ResourceStatus,
+    ResourceType,
+    ResourceOwnerWithNameLink,
+    ResourcePortableDataHash,
+    ResourceFileSize,
+    ResourceFileCount,
+    ResourceUUID,
+    ResourceContainerUuid,
+    ContainerRunTime,
+    ResourceOutputUuid,
+    ResourceLogUuid,
+    ResourceParentProcess,
+    ResourceModifiedByUserUuid,
+    ResourceVersion,
+    ResourceCreatedAtDate,
+    ResourceLastModifiedDate,
+    ResourceTrashDate,
+    ResourceDeleteDate,
+} from 'views-components/data-explorer/renderers';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { toggleOne, deselectAllOthers } from 'store/multiselect/multiselect-actions';
+import { DataColumns } from 'components/data-table/data-table';
+import { ContainerRequestState } from 'models/container-request';
+import { ProjectResource } from 'models/project';
+import { createTree } from 'models/tree';
+import { SortDirection } from 'components/data-table/data-column';
+import { getInitialResourceTypeFilters, getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
+
+type CssRules = "toolbar" | "button" | "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    toolbar: {
+        paddingBottom: theme.spacing.unit * 3,
+        textAlign: "right"
+    },
+    button: {
+        marginLeft: theme.spacing.unit
+    },
+    root: {
+        width: '100%',
+    },
+});
+
+export enum SharedWithMePanelColumnNames {
+    NAME = 'Name',
+    STATUS = 'Status',
+    TYPE = 'Type',
+    OWNER = 'Owner',
+    PORTABLE_DATA_HASH = 'Portable Data Hash',
+    FILE_SIZE = 'File Size',
+    FILE_COUNT = 'File Count',
+    UUID = 'UUID',
+    CONTAINER_UUID = 'Container UUID',
+    RUNTIME = 'Runtime',
+    OUTPUT_UUID = 'Output UUID',
+    LOG_UUID = 'Log UUID',
+    PARENT_PROCESS = 'Parent Process UUID',
+    MODIFIED_BY_USER_UUID = 'Modified by User UUID',
+    VERSION = 'Version',
+    CREATED_AT = 'Date Created',
+    LAST_MODIFIED = 'Last Modified',
+    TRASH_AT = 'Trash at',
+    DELETE_AT = 'Delete at',
+}
+
+export interface ProjectPanelFilter extends DataTableFilterItem {
+    type: ResourceKind | ContainerRequestState;
+}
+
+export const sharedWithMePanelColumns: DataColumns<string, ProjectResource> = [
+    {
+        name: SharedWithMePanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'name' },
+        filters: createTree(),
+        render: (uuid) => <ResourceName uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        mutuallyExclusiveFilters: true,
+        filters: getInitialProcessStatusFilters(),
+        render: (uuid) => <ResourceStatus uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.TYPE,
+        selected: true,
+        configurable: true,
+        filters: getInitialResourceTypeFilters(),
+        render: (uuid) => <ResourceType uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.OWNER,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceOwnerWithNameLink uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.PORTABLE_DATA_HASH,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourcePortableDataHash uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.FILE_SIZE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceFileSize uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.FILE_COUNT,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceFileCount uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceUUID uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.CONTAINER_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceContainerUuid uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.RUNTIME,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ContainerRunTime uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.OUTPUT_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceOutputUuid uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.LOG_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceLogUuid uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.PARENT_PROCESS,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceParentProcess uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.MODIFIED_BY_USER_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceModifiedByUserUuid uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.VERSION,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceVersion uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.CREATED_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'createdAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceCreatedAtDate uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.LAST_MODIFIED,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.DESC, field: 'modifiedAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceLastModifiedDate uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.TRASH_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'trashAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceTrashDate uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.DELETE_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'deleteAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceDeleteDate uuid={uuid} />,
+    },
+];
+
+
+interface SharedWithMePanelDataProps {
+    resources: ResourcesState;
+    userUuid: string;
+}
+
+type SharedWithMePanelProps = SharedWithMePanelDataProps & DispatchProp & WithStyles<CssRules>;
+
+export const SharedWithMePanel = withStyles(styles)(
+    connect((state: RootState) => ({
+        resources: state.resources,
+        userUuid: state.auth.user!.uuid,
+    }))(
+        class extends React.Component<SharedWithMePanelProps> {
+            render() {
+                return <div className={this.props.classes.root}><DataExplorer
+                    id={SHARED_WITH_ME_PANEL_ID}
+                    onRowClick={this.handleRowClick}
+                    onRowDoubleClick={this.handleRowDoubleClick}
+                    onContextMenu={this.handleContextMenu}
+                    contextMenuColumn={false}
+                    defaultViewIcon={ShareMeIcon}
+                    defaultViewMessages={['No shared items']} />
+                </div>;
+            }
+
+            handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+                const { resources } = this.props;
+                const resource = getResource<GroupContentsResource>(resourceUuid)(resources);
+                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
+                if (menuKind && resource) {
+                    this.props.dispatch<any>(openContextMenu(event, {
+                        name: resource.name,
+                        uuid: resource.uuid,
+                        description: resource.description,
+                        ownerUuid: resource.ownerUuid,
+                        isTrashed: ('isTrashed' in resource) ? resource.isTrashed: false,
+                        kind: resource.kind,
+                        menuKind
+                    }));
+                }
+                this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
+            }
+
+            handleRowDoubleClick = (uuid: string) => {
+                this.props.dispatch<any>(navigateTo(uuid));
+            }
+
+            handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
+                this.props.dispatch<any>(deselectAllOthers(uuid))
+                this.props.dispatch<any>(loadDetailsPanel(uuid));
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/views/site-manager-panel/site-manager-panel-root.tsx b/services/workbench2/src/views/site-manager-panel/site-manager-panel-root.tsx
new file mode 100644 (file)
index 0000000..246bc87
--- /dev/null
@@ -0,0 +1,205 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    Card,
+    CardContent,
+    CircularProgress,
+    Grid,
+    IconButton,
+    StyleRulesCallback,
+    Table,
+    TableBody,
+    TableCell,
+    TableHead,
+    TableRow,
+    Typography,
+    WithStyles,
+    withStyles
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { Session, SessionStatus } from "models/session";
+import Button from "@material-ui/core/Button";
+import { compose, Dispatch } from "redux";
+import { Field, FormErrors, InjectedFormProps, reduxForm, reset, stopSubmit } from "redux-form";
+import { TextField } from "components/text-field/text-field";
+import { addSession } from "store/auth/auth-action-session";
+import { SITE_MANAGER_REMOTE_HOST_VALIDATION } from "validators/validators";
+import { Config } from 'common/config';
+import { ResourceCluster } from 'views-components/data-explorer/renderers';
+import { TrashIcon } from "components/icon/icon";
+
+type CssRules = 'root' | 'link' | 'buttonContainer' | 'table' | 'tableRow' |
+    'remoteSiteInfo' | 'buttonAdd' | 'buttonLoggedIn' | 'buttonLoggedOut' |
+    'statusCell';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+        overflow: 'auto'
+    },
+    link: {
+        color: theme.palette.primary.main,
+        textDecoration: 'none',
+        margin: '0px 4px'
+    },
+    buttonContainer: {
+        textAlign: 'right'
+    },
+    table: {
+        marginTop: theme.spacing.unit
+    },
+    tableRow: {
+        '& td, th': {
+            whiteSpace: 'nowrap'
+        }
+    },
+    statusCell: {
+        minWidth: 160
+    },
+    remoteSiteInfo: {
+        marginTop: 20
+    },
+    buttonAdd: {
+        marginLeft: 10,
+        marginTop: theme.spacing.unit * 3
+    },
+    buttonLoggedIn: {
+        minHeight: theme.spacing.unit,
+        padding: 5,
+        color: '#fff',
+        backgroundColor: '#009966',
+        '&:hover': {
+            backgroundColor: '#008450',
+        }
+    },
+    buttonLoggedOut: {
+        minHeight: theme.spacing.unit,
+        padding: 5,
+        color: '#000',
+        backgroundColor: '#FFC414',
+        '&:hover': {
+            backgroundColor: '#eaaf14',
+        }
+    }
+});
+
+export interface SiteManagerPanelRootActionProps {
+    toggleSession: (session: Session) => void;
+    removeSession: (session: Session) => void;
+}
+
+export interface SiteManagerPanelRootDataProps {
+    sessions: Session[];
+    remoteHostsConfig: { [key: string]: Config };
+    localClusterConfig: Config;
+}
+
+type SiteManagerPanelRootProps = SiteManagerPanelRootDataProps & SiteManagerPanelRootActionProps & WithStyles<CssRules> & InjectedFormProps;
+const SITE_MANAGER_FORM_NAME = 'siteManagerForm';
+
+const submitSession = (remoteHost: string) =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(addSession(remoteHost, undefined, true)).then(() => {
+            dispatch(reset(SITE_MANAGER_FORM_NAME));
+        }).catch((e: any) => {
+            const errors = {
+                remoteHost: e
+            } as FormErrors;
+            dispatch(stopSubmit(SITE_MANAGER_FORM_NAME, errors));
+        });
+    };
+
+export const SiteManagerPanelRoot = compose(
+    reduxForm<{ remoteHost: string }>({
+        form: SITE_MANAGER_FORM_NAME,
+        touchOnBlur: false,
+        onSubmit: (data, dispatch) => {
+            dispatch(submitSession(data.remoteHost));
+        }
+    }),
+    withStyles(styles))
+    (({ classes, sessions, handleSubmit, toggleSession, removeSession, localClusterConfig, remoteHostsConfig }: SiteManagerPanelRootProps) =>
+        <Card className={classes.root}>
+            <CardContent>
+                <Grid container direction="row">
+                    <Grid item xs={12}>
+                        <Typography paragraph={true} >
+                            You can log in to multiple Arvados sites here, then use the multi-site search page to search collections and projects on all sites at once.
+                   </Typography>
+                    </Grid>
+                </Grid>
+                <Grid item xs={12}>
+                    {sessions.length > 0 && <Table className={classes.table}>
+                        <TableHead>
+                            <TableRow className={classes.tableRow}>
+                                <TableCell>Cluster ID</TableCell>
+                                <TableCell>Host</TableCell>
+                                <TableCell>Email</TableCell>
+                                <TableCell>UUID</TableCell>
+                                <TableCell>Status</TableCell>
+                                <TableCell>Actions</TableCell>
+                            </TableRow>
+                        </TableHead>
+                        <TableBody>
+                            {sessions.map((session, index) => {
+                                const validating = session.status === SessionStatus.BEING_VALIDATED;
+                                return <TableRow key={index} className={classes.tableRow}>
+                                    <TableCell>{remoteHostsConfig[session.clusterId] ?
+                                        <a href={remoteHostsConfig[session.clusterId].workbench2Url} style={{ textDecoration: 'none' }}> <ResourceCluster uuid={session.clusterId} /></a>
+                                        : session.clusterId}</TableCell>
+                                    <TableCell>{session.remoteHost}</TableCell>
+                                    <TableCell>{validating ? <CircularProgress size={20} /> : session.email}</TableCell>
+                                    <TableCell>{validating ? <CircularProgress size={20} /> : session.uuid}</TableCell>
+                                    <TableCell className={classes.statusCell}>
+                                        <Button fullWidth
+                                            disabled={validating || session.status === SessionStatus.INVALIDATED || session.active}
+                                            className={session.loggedIn ? classes.buttonLoggedIn : classes.buttonLoggedOut}
+                                            onClick={() => toggleSession(session)}>
+                                            {validating ? "Validating"
+                                                : (session.loggedIn ?
+                                                    (session.userIsActive ? "Logged in" : "Inactive")
+                                                    : "Logged out")}
+                                        </Button>
+                                    </TableCell>
+                                    <TableCell>
+                                        {session.clusterId !== localClusterConfig.uuidPrefix &&
+                                            !localClusterConfig.clusterConfig.RemoteClusters[session.clusterId] &&
+                                            <IconButton onClick={() => removeSession(session)}>
+                                                <TrashIcon />
+                                            </IconButton>}
+                                    </TableCell>
+                                </TableRow>;
+                            })}
+                        </TableBody>
+                    </Table>}
+                </Grid>
+                <form onSubmit={handleSubmit}>
+                    <Grid container direction="row">
+                        <Grid item xs={12}>
+                            <Typography paragraph={true} className={classes.remoteSiteInfo}>
+                                To add a remote Arvados site, paste the remote site's host here (see "ARVADOS_API_HOST" on the "current token" page).
+                        </Typography>
+                        </Grid>
+                        <Grid item xs={8}>
+                            <Field
+                                name='remoteHost'
+                                validate={SITE_MANAGER_REMOTE_HOST_VALIDATION}
+                                component={TextField as any}
+                                placeholder="zzzz.arvadosapi.com"
+                                margin="normal"
+                                label="New cluster"
+                                autoFocus />
+                        </Grid>
+                        <Grid item xs={3}>
+                            <Button type="submit" variant="contained" color="primary"
+                                className={classes.buttonAdd}>
+                                {"ADD"}</Button>
+                        </Grid>
+                    </Grid>
+                </form>
+            </CardContent>
+        </Card>
+    );
diff --git a/services/workbench2/src/views/site-manager-panel/site-manager-panel.tsx b/services/workbench2/src/views/site-manager-panel/site-manager-panel.tsx
new file mode 100644 (file)
index 0000000..7f4658b
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import {
+    SiteManagerPanelRoot, SiteManagerPanelRootActionProps,
+    SiteManagerPanelRootDataProps
+} from "views/site-manager-panel/site-manager-panel-root";
+import { Session } from "models/session";
+import { toggleSession, removeSession } from "store/auth/auth-action-session";
+
+const mapStateToProps = (state: RootState): SiteManagerPanelRootDataProps => {
+    return {
+        sessions: state.auth.sessions,
+        remoteHostsConfig: state.auth.remoteHostsConfig,
+        localClusterConfig: state.auth.remoteHostsConfig[state.auth.localCluster]
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): SiteManagerPanelRootActionProps => ({
+    toggleSession: (session: Session) => {
+        dispatch<any>(toggleSession(session));
+    },
+    removeSession: (session: Session) => {
+        dispatch<any>(removeSession(session.clusterId));
+    },
+});
+
+export const SiteManagerPanel = connect(mapStateToProps, mapDispatchToProps)(SiteManagerPanelRoot);
diff --git a/services/workbench2/src/views/ssh-key-panel/ssh-key-admin-panel.tsx b/services/workbench2/src/views/ssh-key-panel/ssh-key-admin-panel.tsx
new file mode 100644 (file)
index 0000000..72a8c4c
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { openSshKeyCreateDialog, openPublicKeyDialog } from 'store/auth/auth-action-ssh';
+import { openSshKeyContextMenu } from 'store/context-menu/context-menu-actions';
+import { SshKeyPanelRoot, SshKeyPanelRootDataProps, SshKeyPanelRootActionProps } from 'views/ssh-key-panel/ssh-key-panel-root';
+
+const mapStateToProps = (state: RootState): SshKeyPanelRootDataProps => {
+    return {
+        sshKeys: state.auth.sshKeys,
+        hasKeys: state.auth.sshKeys!.length > 0
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): SshKeyPanelRootActionProps => ({
+    openSshKeyCreateDialog: () => {
+        dispatch<any>(openSshKeyCreateDialog());
+    },
+    openRowOptions: (event, sshKey) => {
+        dispatch<any>(openSshKeyContextMenu(event, sshKey));
+    },
+    openPublicKeyDialog: (name: string, publicKey: string) => {
+        dispatch<any>(openPublicKeyDialog(name, publicKey));
+    }
+});
+
+export const SshKeyAdminPanel = connect(mapStateToProps, mapDispatchToProps)(SshKeyPanelRoot);
diff --git a/services/workbench2/src/views/ssh-key-panel/ssh-key-panel-root.tsx b/services/workbench2/src/views/ssh-key-panel/ssh-key-panel-root.tsx
new file mode 100644 (file)
index 0000000..344352a
--- /dev/null
@@ -0,0 +1,116 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Button, Typography, Grid, Table, TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { SshKeyResource } from 'models/ssh-key';
+import { AddIcon, MoreVerticalIcon, KeyIcon } from 'components/icon/icon';
+
+type CssRules = 'root' | 'link' | 'buttonContainer' | 'table' | 'tableRow' | 'keyIcon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+        overflow: 'auto'
+    },
+    link: {
+        color: theme.palette.primary.main,
+        textDecoration: 'none',
+        margin: '0px 4px'
+    },
+    buttonContainer: {
+        textAlign: 'right'
+    },
+    table: {
+        marginTop: theme.spacing.unit
+    },
+    tableRow: {
+        '& td, th': {
+            whiteSpace: 'nowrap'
+        }
+    },
+    keyIcon: {
+        color: theme.palette.primary.main
+    }
+});
+
+export interface SshKeyPanelRootActionProps {
+    openSshKeyCreateDialog: () => void;
+    openRowOptions: (event: React.MouseEvent<HTMLElement>, sshKey: SshKeyResource) => void;
+    openPublicKeyDialog: (name: string, publicKey: string) => void;
+}
+
+export interface SshKeyPanelRootDataProps {
+    sshKeys: SshKeyResource[];
+    hasKeys: boolean;
+}
+
+type SshKeyPanelRootProps = SshKeyPanelRootDataProps & SshKeyPanelRootActionProps & WithStyles<CssRules>;
+
+export const SshKeyPanelRoot = withStyles(styles)(
+    ({ classes, sshKeys, openSshKeyCreateDialog, openPublicKeyDialog, hasKeys, openRowOptions }: SshKeyPanelRootProps) =>
+        <Card className={classes.root}>
+            <CardContent>
+                <Grid container direction="row">
+                    <Grid item xs={8}>
+                        {!hasKeys && <Typography paragraph={true} >
+                            You have not yet set up an SSH public key for use with Arvados.
+                            <a href='https://doc.arvados.org/user/getting_started/ssh-access-unix.html'
+                                target='blank' rel="noopener" className={classes.link}>
+                                Learn more.
+                            </a>
+                        </Typography>}
+                        {!hasKeys && <Typography paragraph={true}>
+                            When you have an SSH key you would like to use, add it using button below.
+                        </Typography>}
+                    </Grid>
+                    <Grid item xs={4} className={classes.buttonContainer}>
+                        <Button onClick={openSshKeyCreateDialog} color="primary" variant="contained">
+                            <AddIcon /> Add New Ssh Key
+                        </Button>
+                    </Grid>
+                </Grid>
+                <Grid item xs={12}>
+                    {hasKeys && <Table className={classes.table}>
+                        <TableHead>
+                            <TableRow className={classes.tableRow}>
+                                <TableCell>Name</TableCell>
+                                <TableCell>UUID</TableCell>
+                                <TableCell>Authorized user</TableCell>
+                                <TableCell>Expires at</TableCell>
+                                <TableCell>Key type</TableCell>
+                                <TableCell>Public Key</TableCell>
+                                <TableCell />
+                            </TableRow>
+                        </TableHead>
+                        <TableBody>
+                            {sshKeys.map((sshKey, index) =>
+                                <TableRow key={index} className={classes.tableRow}>
+                                    <TableCell>{sshKey.name}</TableCell>
+                                    <TableCell>{sshKey.uuid}</TableCell>
+                                    <TableCell>{sshKey.authorizedUserUuid}</TableCell>
+                                    <TableCell>{sshKey.expiresAt || '(none)'}</TableCell>
+                                    <TableCell>{sshKey.keyType}</TableCell>
+                                    <TableCell>
+                                        <Tooltip title="Public Key" disableFocusListener>
+                                            <IconButton onClick={() => openPublicKeyDialog(sshKey.name, sshKey.publicKey)}>
+                                                <KeyIcon className={classes.keyIcon} />
+                                            </IconButton>
+                                        </Tooltip>
+                                    </TableCell>
+                                    <TableCell>
+                                        <Tooltip title="More options" disableFocusListener>
+                                            <IconButton onClick={event => openRowOptions(event, sshKey)}>
+                                                <MoreVerticalIcon />
+                                            </IconButton>
+                                        </Tooltip>
+                                    </TableCell>
+                                </TableRow>)}
+                        </TableBody>
+                    </Table>}
+                </Grid>
+            </CardContent>
+        </Card>
+);
diff --git a/services/workbench2/src/views/ssh-key-panel/ssh-key-panel.tsx b/services/workbench2/src/views/ssh-key-panel/ssh-key-panel.tsx
new file mode 100644 (file)
index 0000000..24ca346
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { openSshKeyCreateDialog, openPublicKeyDialog } from 'store/auth/auth-action-ssh';
+import { openSshKeyContextMenu } from 'store/context-menu/context-menu-actions';
+import { SshKeyPanelRoot, SshKeyPanelRootDataProps, SshKeyPanelRootActionProps } from 'views/ssh-key-panel/ssh-key-panel-root';
+
+const mapStateToProps = (state: RootState): SshKeyPanelRootDataProps => {
+    const sshKeys = state.auth.sshKeys.filter((key) => {
+      return key.authorizedUserUuid === (state.auth.user ? state.auth.user.uuid : null);
+    });
+
+    return {
+        sshKeys: sshKeys,
+        hasKeys: sshKeys!.length > 0
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): SshKeyPanelRootActionProps => ({
+    openSshKeyCreateDialog: () => {
+        dispatch<any>(openSshKeyCreateDialog());
+    },
+    openRowOptions: (event, sshKey) => {
+        dispatch<any>(openSshKeyContextMenu(event, sshKey));
+    },
+    openPublicKeyDialog: (name: string, publicKey: string) => {
+        dispatch<any>(openPublicKeyDialog(name, publicKey));
+    }
+});
+
+export const SshKeyPanel = connect(mapStateToProps, mapDispatchToProps)(SshKeyPanelRoot);
diff --git a/services/workbench2/src/views/subprocess-panel/subprocess-panel-root.tsx b/services/workbench2/src/views/subprocess-panel/subprocess-panel-root.tsx
new file mode 100644 (file)
index 0000000..65c723f
--- /dev/null
@@ -0,0 +1,130 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { DataColumns } from 'components/data-table/data-table';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
+import { ContainerRequestState } from 'models/container-request';
+import { SortDirection } from 'components/data-table/data-column';
+import { ResourceKind } from 'models/resource';
+import { ResourceCreatedAtDate, ProcessStatus, ContainerRunTime } from 'views-components/data-explorer/renderers';
+import { ProcessIcon } from 'components/icon/icon';
+import { ResourceName } from 'views-components/data-explorer/renderers';
+import { SUBPROCESS_PANEL_ID } from 'store/subprocess-panel/subprocess-panel-actions';
+import { createTree } from 'models/tree';
+import { getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
+import { ResourcesState } from 'store/resources/resources';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { StyleRulesCallback, Typography, WithStyles, withStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ProcessResource } from 'models/process';
+import { SubprocessProgressBar } from 'components/subprocess-progress-bar/subprocess-progress-bar';
+import { Process } from 'store/processes/process';
+
+type CssRules = 'iconHeader' | 'cardHeader';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL,
+        marginRight: theme.spacing.unit * 2,
+    },
+    cardHeader: {
+        display: 'flex',
+    },
+});
+
+export enum SubprocessPanelColumnNames {
+    NAME = "Name",
+    STATUS = "Status",
+    CREATED_AT = "Created At",
+    RUNTIME = "Run Time"
+}
+
+export interface SubprocessPanelFilter extends DataTableFilterItem {
+    type: ResourceKind | ContainerRequestState;
+}
+
+export const subprocessPanelColumns: DataColumns<string, ProcessResource> = [
+    {
+        name: SubprocessPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "name"},
+        filters: createTree(),
+        render: uuid => <ResourceName uuid={uuid} />
+    },
+    {
+        name: SubprocessPanelColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        mutuallyExclusiveFilters: true,
+        filters: getInitialProcessStatusFilters(),
+        render: uuid => <ProcessStatus uuid={uuid} />,
+    },
+    {
+        name: SubprocessPanelColumnNames.CREATED_AT,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.DESC, field: "createdAt"},
+        filters: createTree(),
+        render: uuid => <ResourceCreatedAtDate uuid={uuid} />
+    },
+    {
+        name: SubprocessPanelColumnNames.RUNTIME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ContainerRunTime uuid={uuid} />
+    }
+];
+
+export interface SubprocessPanelDataProps {
+    process: Process;
+    resources: ResourcesState;
+}
+
+export interface SubprocessPanelActionProps {
+    onRowClick: (item: string) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: string, resources: ResourcesState) => void;
+    onItemDoubleClick: (item: string) => void;
+}
+
+type SubprocessPanelProps = SubprocessPanelActionProps & SubprocessPanelDataProps;
+
+const DEFAULT_VIEW_MESSAGES = [
+    'No subprocesses available for listing.',
+    'The current process may not have any or none matches current filtering.'
+];
+
+type SubProcessesTitleProps = WithStyles<CssRules>;
+
+const SubProcessesTitle = withStyles(styles)(
+    ({classes}: SubProcessesTitleProps) =>
+        <div className={classes.cardHeader}>
+            <ProcessIcon className={classes.iconHeader} /><span></span>
+            <Typography noWrap variant='h6' color='inherit'>
+                Subprocesses
+            </Typography>
+        </div>
+);
+
+export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps) => {
+    return <DataExplorer
+        id={SUBPROCESS_PANEL_ID}
+        onRowClick={props.onRowClick}
+        onRowDoubleClick={props.onItemDoubleClick}
+        onContextMenu={(event, item) => props.onContextMenu(event, item, props.resources)}
+        contextMenuColumn={true}
+        defaultViewIcon={ProcessIcon}
+        defaultViewMessages={DEFAULT_VIEW_MESSAGES}
+        doHidePanel={props.doHidePanel}
+        doMaximizePanel={props.doMaximizePanel}
+        doUnMaximizePanel={props.doUnMaximizePanel}
+        panelMaximized={props.panelMaximized}
+        panelName={props.panelName}
+        title={<SubProcessesTitle/>}
+        progressBar={<SubprocessProgressBar process={props.process} />} />;
+};
diff --git a/services/workbench2/src/views/subprocess-panel/subprocess-panel.tsx b/services/workbench2/src/views/subprocess-panel/subprocess-panel.tsx
new file mode 100644 (file)
index 0000000..a4eb5e6
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { SubprocessPanelRoot, SubprocessPanelActionProps, SubprocessPanelDataProps } from "views/subprocess-panel/subprocess-panel-root";
+import { RootState } from "store/store";
+import { navigateTo } from "store/navigation/navigation-action";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { getProcess } from "store/processes/process";
+import { toggleOne, deselectAllOthers } from 'store/multiselect/multiselect-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch): SubprocessPanelActionProps => ({
+    onContextMenu: (event, resourceUuid, resources) => {
+        const process = getProcess(resourceUuid)(resources);
+        if (process) {
+            dispatch<any>(openProcessContextMenu(event, process));
+        }
+    },
+    onRowClick: (uuid: string) => {
+        dispatch<any>(toggleOne(uuid))
+        dispatch<any>(deselectAllOthers(uuid))
+        dispatch<any>(loadDetailsPanel(uuid));
+    },
+    onItemDoubleClick: uuid => {
+        dispatch<any>(navigateTo(uuid));
+    },
+});
+
+const mapStateToProps = (state: RootState): Omit<SubprocessPanelDataProps,'process'> => ({
+    resources: state.resources,
+});
+
+export const SubprocessPanel = connect(mapStateToProps, mapDispatchToProps)(SubprocessPanelRoot);
diff --git a/services/workbench2/src/views/trash-panel/trash-panel.tsx b/services/workbench2/src/views/trash-panel/trash-panel.tsx
new file mode 100644 (file)
index 0000000..31804fb
--- /dev/null
@@ -0,0 +1,188 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { IconButton, StyleRulesCallback, WithStyles, withStyles, Tooltip } from '@material-ui/core';
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { connect, DispatchProp } from 'react-redux';
+import { DataColumns } from 'components/data-table/data-table';
+import { RootState } from 'store/store';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
+import { SortDirection } from 'components/data-table/data-column';
+import { ResourceKind, TrashableResource } from 'models/resource';
+import { ArvadosTheme } from 'common/custom-theme';
+import { RestoreFromTrashIcon, TrashIcon } from 'components/icon/icon';
+import { TRASH_PANEL_ID } from "store/trash-panel/trash-panel-action";
+import { getProperty } from "store/properties/properties";
+import { PROJECT_PANEL_CURRENT_UUID } from "store/project-panel/project-panel-action";
+import { openContextMenu } from "store/context-menu/context-menu-actions";
+import { getResource, ResourcesState } from "store/resources/resources";
+import {
+    ResourceDeleteDate,
+    ResourceFileSize,
+    ResourceName,
+    ResourceTrashDate,
+    ResourceType
+} from "views-components/data-explorer/renderers";
+import { navigateTo } from "store/navigation/navigation-action";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { toggleTrashed } from "store/trash/trash-actions";
+import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
+import { Dispatch } from "redux";
+import { createTree } from 'models/tree';
+import {
+    getTrashPanelTypeFilters
+} from 'store/resource-type-filters/resource-type-filters';
+import { CollectionResource } from 'models/collection';
+import { toggleOne, deselectAllOthers } from 'store/multiselect/multiselect-actions';
+
+type CssRules = "toolbar" | "button" | "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    toolbar: {
+        paddingBottom: theme.spacing.unit * 3,
+        textAlign: "right"
+    },
+    button: {
+        marginLeft: theme.spacing.unit
+    },
+    root: {
+        width: '100%',
+    },
+});
+
+export enum TrashPanelColumnNames {
+    NAME = "Name",
+    TYPE = "Type",
+    FILE_SIZE = "File size",
+    TRASHED_DATE = "Trashed date",
+    TO_BE_DELETED = "To be deleted"
+}
+
+export interface TrashPanelFilter extends DataTableFilterItem {
+    type: ResourceKind;
+}
+
+export const ResourceRestore =
+    connect((state: RootState, props: { uuid: string, dispatch?: Dispatch<any> }) => {
+        const resource = getResource<TrashableResource>(props.uuid)(state.resources);
+        return { resource, dispatch: props.dispatch };
+    })((props: { resource?: TrashableResource, dispatch?: Dispatch<any> }) =>
+        <Tooltip title="Restore">
+            <IconButton style={{ padding: '0' }} onClick={() => {
+                if (props.resource && props.dispatch) {
+                    props.dispatch(toggleTrashed(
+                        props.resource.kind,
+                        props.resource.uuid,
+                        props.resource.ownerUuid,
+                        props.resource.isTrashed
+                    ));
+                }}}
+            >
+                <RestoreFromTrashIcon />
+            </IconButton>
+        </Tooltip>
+    );
+
+export const trashPanelColumns: DataColumns<string, CollectionResource> = [
+    {
+        name: TrashPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "name"},
+        filters: createTree(),
+        render: uuid => <ResourceName uuid={uuid} />
+    },
+    {
+        name: TrashPanelColumnNames.TYPE,
+        selected: true,
+        configurable: true,
+        filters: getTrashPanelTypeFilters(),
+        render: uuid => <ResourceType uuid={uuid} />,
+    },
+    {
+        name: TrashPanelColumnNames.FILE_SIZE,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "fileSizeTotal"},
+        filters: createTree(),
+        render: uuid => <ResourceFileSize uuid={uuid} />
+    },
+    {
+        name: TrashPanelColumnNames.TRASHED_DATE,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.DESC, field: "trashAt"},
+        filters: createTree(),
+        render: uuid => <ResourceTrashDate uuid={uuid} />
+    },
+    {
+        name: TrashPanelColumnNames.TO_BE_DELETED,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "deleteAt"},
+        filters: createTree(),
+        render: uuid => <ResourceDeleteDate uuid={uuid} />
+    },
+    {
+        name: '',
+        selected: true,
+        configurable: false,
+        filters: createTree(),
+        render: uuid => <ResourceRestore uuid={uuid} />
+    }
+];
+
+interface TrashPanelDataProps {
+    currentItemId: string;
+    resources: ResourcesState;
+}
+
+type TrashPanelProps = TrashPanelDataProps & DispatchProp & WithStyles<CssRules>;
+
+export const TrashPanel = withStyles(styles)(
+    connect((state: RootState) => ({
+        currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties),
+        resources: state.resources
+    }))(
+        class extends React.Component<TrashPanelProps> {
+            render() {
+                return <div className={this.props.classes.root}><DataExplorer
+                    id={TRASH_PANEL_ID}
+                    onRowClick={this.handleRowClick}
+                    onRowDoubleClick={this.handleRowDoubleClick}
+                    onContextMenu={this.handleContextMenu}
+                    contextMenuColumn={false}
+                    defaultViewIcon={TrashIcon}
+                    defaultViewMessages={['Your trash list is empty.']} />
+                </div>;
+            }
+
+            handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+                const resource = getResource<TrashableResource>(resourceUuid)(this.props.resources);
+                if (resource) {
+                    this.props.dispatch<any>(openContextMenu(event, {
+                        name: '',
+                        uuid: resource.uuid,
+                        ownerUuid: resource.ownerUuid,
+                        isTrashed: resource.isTrashed,
+                        kind: resource.kind,
+                        menuKind: ContextMenuKind.TRASH
+                    }));
+                }
+                this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
+            }
+
+            handleRowDoubleClick = (uuid: string) => {
+                this.props.dispatch<any>(navigateTo(uuid));
+            }
+
+            handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
+                this.props.dispatch<any>(deselectAllOthers(uuid))
+                this.props.dispatch<any>(loadDetailsPanel(uuid));
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/views/user-panel/user-panel.tsx b/services/workbench2/src/views/user-panel/user-panel.tsx
new file mode 100644 (file)
index 0000000..950262d
--- /dev/null
@@ -0,0 +1,165 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WithStyles, withStyles, Paper, Typography } from '@material-ui/core';
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { connect, DispatchProp } from 'react-redux';
+import { DataColumns } from 'components/data-table/data-table';
+import { RootState } from 'store/store';
+import { SortDirection } from 'components/data-table/data-column';
+import { openUserContextMenu } from "store/context-menu/context-menu-actions";
+import { getResource, ResourcesState } from "store/resources/resources";
+import {
+    UserResourceFullName,
+    ResourceUuid,
+    ResourceEmail,
+    ResourceIsAdmin,
+    ResourceUsername,
+    UserResourceAccountStatus,
+} from "views-components/data-explorer/renderers";
+import { navigateToUserProfile } from "store/navigation/navigation-action";
+import { createTree } from 'models/tree';
+import { compose, Dispatch } from 'redux';
+import { UserResource } from 'models/user';
+import { ShareMeIcon } from 'components/icon/icon';
+import { USERS_PANEL_ID, openUserCreateDialog } from 'store/users/users-actions';
+import { noop } from 'lodash';
+
+type UserPanelRules = "button" | 'root';
+
+const styles = withStyles<UserPanelRules>(theme => ({
+    button: {
+        marginTop: theme.spacing.unit,
+        marginRight: theme.spacing.unit * 2,
+        textAlign: 'right',
+        alignSelf: 'center'
+    },
+    root: {
+        width: '100%',
+    },
+}));
+
+export enum UserPanelColumnNames {
+    NAME = "Name",
+    UUID = "Uuid",
+    EMAIL = "Email",
+    STATUS = "Account Status",
+    ADMIN = "Admin",
+    REDIRECT_TO_USER = "Redirect to user",
+    USERNAME = "Username"
+}
+
+export const userPanelColumns: DataColumns<string, UserResource> = [
+    {
+        name: UserPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "firstName"},
+        filters: createTree(),
+        render: uuid => <UserResourceFullName uuid={uuid} link={true} />
+    },
+    {
+        name: UserPanelColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "uuid"},
+        filters: createTree(),
+        render: uuid => <ResourceUuid uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.EMAIL,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "email"},
+        filters: createTree(),
+        render: uuid => <ResourceEmail uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <UserResourceAccountStatus uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.ADMIN,
+        selected: true,
+        configurable: false,
+        filters: createTree(),
+        render: uuid => <ResourceIsAdmin uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.USERNAME,
+        selected: true,
+        configurable: false,
+        sort: {direction: SortDirection.NONE, field: "username"},
+        filters: createTree(),
+        render: uuid => <ResourceUsername uuid={uuid} />
+    }
+];
+
+interface UserPanelDataProps {
+    resources: ResourcesState;
+}
+
+interface UserPanelActionProps {
+    openUserCreateDialog: () => void;
+    handleRowClick: (uuid: string) => void;
+    handleContextMenu: (event, resource: UserResource) => void;
+}
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        resources: state.resources
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    openUserCreateDialog: () => dispatch<any>(openUserCreateDialog()),
+    handleRowClick: (uuid: string) => dispatch<any>(navigateToUserProfile(uuid)),
+    handleContextMenu: (event, resource: UserResource) => dispatch<any>(openUserContextMenu(event, resource)),
+});
+
+type UserPanelProps = UserPanelDataProps & UserPanelActionProps & DispatchProp & WithStyles<UserPanelRules>;
+
+export const UserPanel = compose(
+    styles,
+    connect(mapStateToProps, mapDispatchToProps))(
+        class extends React.Component<UserPanelProps> {
+            render() {
+                return <Paper className={this.props.classes.root}>
+                    <DataExplorer
+                        id={USERS_PANEL_ID}
+                        title={
+                            <>
+                                <Typography>
+                                    User records are created automatically on first log in.
+                                </Typography>
+                                <Typography>
+                                    To add a new user, add them to your configured log in provider.
+                                </Typography>
+                            </>}
+                        onRowClick={noop}
+                        onRowDoubleClick={noop}
+                        onContextMenu={this.handleContextMenu}
+                        contextMenuColumn={true}
+                        hideColumnSelector
+                        paperProps={{
+                            elevation: 0,
+                        }}
+                        defaultViewIcon={ShareMeIcon}
+                        defaultViewMessages={['Your user list is empty.']} />
+                </Paper>;
+            }
+
+            handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+                event.stopPropagation();
+                const resource = getResource<UserResource>(resourceUuid)(this.props.resources);
+                if (resource) {
+                    this.props.handleContextMenu(event, resource);
+                }
+            }
+        }
+    );
diff --git a/services/workbench2/src/views/user-profile-panel/user-profile-panel-root.tsx b/services/workbench2/src/views/user-profile-panel/user-profile-panel-root.tsx
new file mode 100644 (file)
index 0000000..4a20837
--- /dev/null
@@ -0,0 +1,357 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Field, InjectedFormProps } from "redux-form";
+import { DispatchProp } from 'react-redux';
+import { UserResource } from 'models/user';
+import { TextField } from "components/text-field/text-field";
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { NativeSelectField } from "components/select-field/select-field";
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    CardContent,
+    Button,
+    Typography,
+    Grid,
+    InputLabel,
+    Tabs, Tab,
+    Paper,
+    Tooltip,
+    IconButton,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { PROFILE_EMAIL_VALIDATION, PROFILE_URL_VALIDATION } from "validators/validators";
+import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions';
+import { noop } from 'lodash';
+import { DetailsIcon, GroupsIcon, MoreVerticalIcon } from 'components/icon/icon';
+import { DataColumns } from 'components/data-table/data-table';
+import { ResourceLinkHeadUuid, ResourceLinkHeadPermissionLevel, ResourceLinkHead, ResourceLinkDelete, ResourceLinkTailIsVisible, UserResourceAccountStatus } from 'views-components/data-explorer/renderers';
+import { createTree } from 'models/tree';
+import { getResource, ResourcesState } from 'store/resources/resources';
+import { DefaultView } from 'components/default-view/default-view';
+import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar';
+import { PermissionResource } from 'models/permission';
+
+type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon' | 'userProfileFormMessage';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+        overflow: 'auto'
+    },
+    emptyRoot: {
+        width: '100%',
+        overflow: 'auto',
+        padding: theme.spacing.unit * 4,
+    },
+    gridItem: {
+        height: 45,
+        marginBottom: 20
+    },
+    label: {
+        fontSize: '0.675rem',
+        color: theme.palette.grey['600']
+    },
+    readOnlyValue: {
+        fontSize: '0.875rem',
+    },
+    title: {
+        fontSize: '1.1rem',
+    },
+    description: {
+        color: theme.palette.grey["600"]
+    },
+    actions: {
+        display: 'flex',
+        justifyContent: 'flex-end'
+    },
+    content: {
+        // reserve space for the tab bar
+        height: `calc(100% - ${theme.spacing.unit * 7}px)`,
+    },
+    copyIcon: {
+        marginLeft: theme.spacing.unit,
+        color: theme.palette.grey["500"],
+        cursor: 'pointer',
+        display: 'inline',
+        '& svg': {
+            fontSize: '1rem'
+        }
+    },
+    userProfileFormMessage: {
+        fontSize: '1.1rem',
+    }
+});
+
+export interface UserProfilePanelRootActionProps {
+    handleContextMenu: (event, resource: UserResource) => void;
+}
+
+export interface UserProfilePanelRootDataProps {
+    isAdmin: boolean;
+    isSelf: boolean;
+    isPristine: boolean;
+    isValid: boolean;
+    isInaccessible: boolean;
+    userUuid: string;
+    resources: ResourcesState;
+    localCluster: string;
+    userProfileFormMessage: string;
+}
+
+const RoleTypes = [
+    { key: '', value: '' },
+    { key: 'Bio-informatician', value: 'Bio-informatician' },
+    { key: 'Data Scientist', value: 'Data Scientist' },
+    { key: 'Analyst', value: 'Analyst' },
+    { key: 'Researcher', value: 'Researcher' },
+    { key: 'Software Developer', value: 'Software Developer' },
+    { key: 'System Administrator', value: 'System Administrator' },
+    { key: 'Other', value: 'Other' }
+];
+
+type UserProfilePanelRootProps = InjectedFormProps<{}> & UserProfilePanelRootActionProps & UserProfilePanelRootDataProps & DispatchProp & WithStyles<CssRules>;
+
+export enum UserProfileGroupsColumnNames {
+    NAME = "Name",
+    PERMISSION = "Permission",
+    VISIBLE = "Visible to other members",
+    UUID = "UUID",
+    REMOVE = "Remove",
+}
+
+enum TABS {
+    PROFILE = "PROFILE",
+    GROUPS = "GROUPS",
+
+}
+
+export const userProfileGroupsColumns: DataColumns<string, PermissionResource> = [
+    {
+        name: UserProfileGroupsColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHead uuid={uuid} />
+    },
+    {
+        name: UserProfileGroupsColumnNames.PERMISSION,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHeadPermissionLevel uuid={uuid} />
+    },
+    {
+        name: UserProfileGroupsColumnNames.VISIBLE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkTailIsVisible uuid={uuid} />
+    },
+    {
+        name: UserProfileGroupsColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHeadUuid uuid={uuid} />
+    },
+    {
+        name: UserProfileGroupsColumnNames.REMOVE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkDelete uuid={uuid} />
+    },
+];
+
+const ReadOnlyField = withStyles(styles)(
+    (props: ({ label: string, input: { value: string } }) & WithStyles<CssRules>) => (
+        <Grid item xs={12} data-cy="field">
+            <Typography className={props.classes.label}>
+                {props.label}
+            </Typography>
+            <Typography className={props.classes.readOnlyValue} data-cy="value">
+                {props.input.value}
+            </Typography>
+        </Grid>
+    )
+);
+
+export const UserProfilePanelRoot = withStyles(styles)(
+    class extends React.Component<UserProfilePanelRootProps> {
+        state = {
+            value: TABS.PROFILE,
+        };
+
+        componentDidMount() {
+            this.setState({ value: TABS.PROFILE });
+        }
+
+        render() {
+            if (this.props.isInaccessible) {
+                return (
+                    <Paper className={this.props.classes.emptyRoot}>
+                        <CardContent>
+                            <DefaultView icon={DetailsIcon} messages={['This user does not exist or your account does not have permission to view it']} />
+                        </CardContent>
+                    </Paper>
+                );
+            } else {
+                return <Paper className={this.props.classes.root}>
+                    <Tabs value={this.state.value} onChange={this.handleChange} variant={"fullWidth"}>
+                        <Tab label={TABS.PROFILE} value={TABS.PROFILE} />
+                        <Tab label={TABS.GROUPS} value={TABS.GROUPS} />
+                    </Tabs>
+                    {this.state.value === TABS.PROFILE &&
+                        <CardContent>
+                            <Grid container justify="space-between">
+                                <Grid item>
+                                    <Typography className={this.props.classes.title}>
+                                        {this.props.userUuid}
+                                        <CopyToClipboardSnackbar value={this.props.userUuid} />
+                                    </Typography>
+                                </Grid>
+                                <Grid item>
+                                    <Grid container alignItems="center">
+                                        <Grid item style={{ marginRight: '10px' }}><UserResourceAccountStatus uuid={this.props.userUuid} /></Grid>
+                                        <Grid item>
+                                            <Tooltip title="Actions" disableFocusListener>
+                                                <IconButton
+                                                    data-cy='user-profile-panel-options-btn'
+                                                    aria-label="Actions"
+                                                    onClick={(event) => this.handleContextMenu(event, this.props.userUuid)}>
+                                                    <MoreVerticalIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        </Grid>
+                                    </Grid>
+                                </Grid>
+                            </Grid>
+                            <form onSubmit={this.props.handleSubmit} data-cy="profile-form">
+                                <Grid container spacing={24}>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12} data-cy="firstName">
+                                        <Field
+                                            label="First name"
+                                            name="firstName"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12} data-cy="lastName">
+                                        <Field
+                                            label="Last name"
+                                            name="lastName"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12} data-cy="email">
+                                        <Field
+                                            label="E-mail"
+                                            name="email"
+                                            component={ReadOnlyField as any}
+                                            disabled
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12} data-cy="username">
+                                        <Field
+                                            label="Username"
+                                            name="username"
+                                            component={ReadOnlyField as any}
+                                            disabled
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} xs={12}>
+                                        <span className={this.props.classes.userProfileFormMessage}>{this.props.userProfileFormMessage}</span>
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
+                                        <Field
+                                            label="Organization"
+                                            name="prefs.profile.organization"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
+                                        <Field
+                                            label="E-mail at Organization"
+                                            name="prefs.profile.organization_email"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                            validate={PROFILE_EMAIL_VALIDATION}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
+                                        <InputLabel className={this.props.classes.label} htmlFor="prefs.profile.role">Role</InputLabel>
+                                        <Field
+                                            id="prefs.profile.role"
+                                            name="prefs.profile.role"
+                                            component={NativeSelectField as any}
+                                            items={RoleTypes}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
+                                        <Field
+                                            label="Website"
+                                            name="prefs.profile.website_url"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                            validate={PROFILE_URL_VALIDATION}
+                                        />
+                                    </Grid>
+                                    <Grid item sm={12}>
+                                        <Grid container direction="row" justify="flex-end">
+                                            <Button color="primary" onClick={this.props.reset} disabled={this.props.isPristine}>Discard changes</Button>
+                                            <Button
+                                                color="primary"
+                                                variant="contained"
+                                                type="submit"
+                                                disabled={this.props.isPristine || this.props.invalid || this.props.submitting}>
+                                                Save changes
+                                            </Button>
+                                        </Grid>
+                                    </Grid>
+                                </Grid>
+                            </form >
+                        </CardContent>
+                    }
+                    {this.state.value === TABS.GROUPS &&
+                        <div className={this.props.classes.content}>
+                            <DataExplorer
+                                id={USER_PROFILE_PANEL_ID}
+                                data-cy="user-profile-groups-data-explorer"
+                                onRowClick={noop}
+                                onRowDoubleClick={noop}
+                                onContextMenu={noop}
+                                contextMenuColumn={false}
+                                hideColumnSelector
+                                hideSearchInput
+                                paperProps={{
+                                    elevation: 0,
+                                }}
+                                defaultViewIcon={GroupsIcon}
+                                defaultViewMessages={['Group list is empty.']} />
+                        </div>}
+                </Paper >;
+            }
+        }
+
+        handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+            this.setState({ value });
+        }
+
+        handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+            event.stopPropagation();
+            const resource = getResource<UserResource>(resourceUuid)(this.props.resources);
+            if (resource) {
+                this.props.handleContextMenu(event, resource);
+            }
+        }
+
+    }
+);
diff --git a/services/workbench2/src/views/user-profile-panel/user-profile-panel.tsx b/services/workbench2/src/views/user-profile-panel/user-profile-panel.tsx
new file mode 100644 (file)
index 0000000..040cbc6
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { compose, Dispatch } from 'redux';
+import { reduxForm, isPristine, isValid } from 'redux-form';
+import { connect } from 'react-redux';
+import { UserResource } from 'models/user';
+import { getUserProfileIsInaccessible, saveEditedUser } from 'store/user-profile/user-profile-actions';
+import { UserProfilePanelRoot, UserProfilePanelRootDataProps } from 'views/user-profile-panel/user-profile-panel-root';
+import { USER_PROFILE_FORM } from "store/user-profile/user-profile-actions";
+import { matchUserProfileRoute } from 'routes/routes';
+import { openUserContextMenu } from 'store/context-menu/context-menu-actions';
+
+const mapStateToProps = (state: RootState): UserProfilePanelRootDataProps => {
+    const pathname = state.router.location ? state.router.location.pathname : '';
+    const match = matchUserProfileRoute(pathname);
+    const uuid = match ? match.params.id : state.auth.user?.uuid || '';
+
+    return {
+        isAdmin: state.auth.user!.isAdmin,
+        isSelf: state.auth.user!.uuid === uuid,
+        isPristine: isPristine(USER_PROFILE_FORM)(state),
+        isValid: isValid(USER_PROFILE_FORM)(state),
+        isInaccessible: getUserProfileIsInaccessible(state.properties) || false,
+        localCluster: state.auth.localCluster,
+        userUuid: uuid,
+        resources: state.resources,
+        userProfileFormMessage: state.auth.config.clusterConfig.Workbench.UserProfileFormMessage,
+    }
+};
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    handleContextMenu: (event, resource: UserResource) => dispatch<any>(openUserContextMenu(event, resource)),
+});
+
+export const UserProfilePanel = compose(
+    connect(mapStateToProps, mapDispatchToProps),
+    reduxForm({
+        form: USER_PROFILE_FORM,
+        onSubmit: (data, dispatch) => {
+            dispatch(saveEditedUser(data));
+        }
+    }))(UserProfilePanelRoot);
diff --git a/services/workbench2/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx b/services/workbench2/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx
new file mode 100644 (file)
index 0000000..20665f1
--- /dev/null
@@ -0,0 +1,149 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { Grid, Card, Chip, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, IconButton } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { compose, Dispatch } from 'redux';
+import { loadVirtualMachinesAdminData, openAddVirtualMachineLoginDialog, openRemoveVirtualMachineLoginDialog, openEditVirtualMachineLoginDialog } from 'store/virtual-machines/virtual-machines-actions';
+import { RootState } from 'store/store';
+import { ListResults } from 'services/common-service/common-service';
+import { MoreVerticalIcon, AddUserIcon } from 'components/icon/icon';
+import { VirtualMachineLogins, VirtualMachinesResource } from 'models/virtual-machines';
+import { openVirtualMachinesContextMenu } from 'store/context-menu/context-menu-actions';
+import { ResourceUuid, VirtualMachineHostname, VirtualMachineLogin } from 'views-components/data-explorer/renderers';
+
+type CssRules = 'moreOptionsButton' | 'moreOptions' | 'chipsRoot' | 'vmTableWrapper';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    moreOptionsButton: {
+        padding: 0
+    },
+    moreOptions: {
+        textAlign: 'right',
+        '&:last-child': {
+            paddingRight: 0
+        }
+    },
+    chipsRoot: {
+        margin: `0px -${theme.spacing.unit / 2}px`,
+    },
+    vmTableWrapper: {
+        overflowX: 'auto',
+    },
+});
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        userUuid: state.auth.user!.uuid,
+        ...state.virtualMachines
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<VirtualMachinesPanelActionProps, 'loadVirtualMachinesData' | 'onOptionsMenuOpen' | 'onAddLogin' | 'onDeleteLogin' | 'onLoginEdit'> => ({
+    loadVirtualMachinesData: () => dispatch<any>(loadVirtualMachinesAdminData()),
+    onOptionsMenuOpen: (event, virtualMachine) => {
+        dispatch<any>(openVirtualMachinesContextMenu(event, virtualMachine));
+    },
+    onAddLogin: (uuid: string) => {
+        dispatch<any>(openAddVirtualMachineLoginDialog(uuid));
+    },
+    onDeleteLogin: (uuid: string) => {
+        dispatch<any>(openRemoveVirtualMachineLoginDialog(uuid));
+    },
+    onLoginEdit: (uuid: string) => {
+        dispatch<any>(openEditVirtualMachineLoginDialog(uuid));
+    },
+});
+
+interface VirtualMachinesPanelDataProps {
+    virtualMachines: ListResults<any>;
+    logins: VirtualMachineLogins;
+    links: ListResults<any>;
+    userUuid: string;
+}
+
+interface VirtualMachinesPanelActionProps {
+    loadVirtualMachinesData: () => string;
+    onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, virtualMachine: VirtualMachinesResource) => void;
+    onAddLogin: (uuid: string) => void;
+    onDeleteLogin: (uuid: string) => void;
+    onLoginEdit: (uuid: string) => void;
+}
+
+type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles<CssRules>;
+
+export const VirtualMachineAdminPanel = compose(
+    withStyles(styles),
+    connect(mapStateToProps, mapDispatchToProps))(
+        class extends React.Component<VirtualMachineProps> {
+            componentDidMount() {
+                this.props.loadVirtualMachinesData();
+            }
+
+            render() {
+                const { virtualMachines } = this.props;
+                return (
+                    <Grid container spacing={16}>
+                        {virtualMachines.itemsAvailable > 0 && <CardContentWithVirtualMachines {...this.props} />}
+                    </Grid>
+                );
+            }
+        }
+    );
+
+const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
+    <Grid item xs={12}>
+        <Card>
+            <CardContent className={props.classes.vmTableWrapper}>
+                {virtualMachinesTable(props)}
+            </CardContent>
+        </Card>
+    </Grid>;
+
+const virtualMachinesTable = (props: VirtualMachineProps) =>
+    <Table data-cy="vm-admin-table">
+        <TableHead>
+            <TableRow>
+                <TableCell>Uuid</TableCell>
+                <TableCell>Host name</TableCell>
+                <TableCell>Logins</TableCell>
+                <TableCell />
+                <TableCell />
+            </TableRow>
+        </TableHead>
+        <TableBody>
+            {props.virtualMachines.items.map((machine, index) =>
+                <TableRow key={index}>
+                    <TableCell><ResourceUuid uuid={machine.uuid} /></TableCell>
+                    <TableCell><VirtualMachineHostname uuid={machine.uuid} /></TableCell>
+                    <TableCell>
+                        <Grid container spacing={8} className={props.classes.chipsRoot}>
+                            {props.links.items.filter((link) => (link.headUuid === machine.uuid)).map((permission, i) => (
+                                <Grid item key={i}>
+                                    <Chip label={<VirtualMachineLogin linkUuid={permission.uuid} />} onDelete={event => props.onDeleteLogin(permission.uuid)} onClick={event => props.onLoginEdit(permission.uuid)} />
+                                </Grid>
+                            ))}
+                        </Grid>
+                    </TableCell>
+                    <TableCell>
+                        <Tooltip title="Add Login Permission" disableFocusListener>
+                            <IconButton onClick={event => props.onAddLogin(machine.uuid)} className={props.classes.moreOptionsButton}>
+                                <AddUserIcon />
+                            </IconButton>
+                        </Tooltip>
+                    </TableCell>
+                    <TableCell className={props.classes.moreOptions}>
+                        <Tooltip title="More options" disableFocusListener>
+                            <IconButton onClick={event => props.onOptionsMenuOpen(event, machine)} className={props.classes.moreOptionsButton}>
+                                <MoreVerticalIcon />
+                            </IconButton>
+                        </Tooltip>
+                    </TableCell>
+                </TableRow>
+            )}
+        </TableBody>
+    </Table>;
diff --git a/services/workbench2/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx b/services/workbench2/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx
new file mode 100644 (file)
index 0000000..f75b36a
--- /dev/null
@@ -0,0 +1,277 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, Chip } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { compose, Dispatch } from 'redux';
+import { saveRequestedDate, loadVirtualMachinesUserData } from 'store/virtual-machines/virtual-machines-actions';
+import { RootState } from 'store/store';
+import { ListResults } from 'services/common-service/common-service';
+import { HelpIcon } from 'components/icon/icon';
+import { SESSION_STORAGE } from "services/auth-service/auth-service";
+// import * as CopyToClipboard from 'react-copy-to-clipboard';
+import parse from "parse-duration";
+import { CopyIcon } from 'components/icon/icon';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { sanitizeHTML } from 'common/html-sanitize';
+
+type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'tableWrapper' | 'webshellButton';
+
+const EXTRA_TOKEN = "exraToken";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    button: {
+        marginTop: theme.spacing.unit,
+        marginBottom: theme.spacing.unit
+    },
+    codeSnippet: {
+        borderRadius: theme.spacing.unit * 0.5,
+        border: '1px solid',
+        borderColor: theme.palette.grey["400"],
+    },
+    link: {
+        textDecoration: 'none',
+        color: theme.palette.primary.main,
+        "&:hover": {
+            color: theme.palette.primary.dark,
+            transition: 'all 0.5s ease'
+        }
+    },
+    linkIcon: {
+        textDecoration: 'none',
+        color: theme.palette.grey["500"],
+        textAlign: 'right',
+        "&:hover": {
+            color: theme.palette.common.black,
+            transition: 'all 0.5s ease'
+        }
+    },
+    rightAlign: {
+        textAlign: "right"
+    },
+    cardWithoutMachines: {
+        display: 'flex'
+    },
+    icon: {
+        textAlign: "right",
+        marginTop: theme.spacing.unit
+    },
+    chipsRoot: {
+        margin: `0px -${theme.spacing.unit / 2}px`,
+    },
+    copyIcon: {
+        marginLeft: theme.spacing.unit,
+        color: theme.palette.grey["500"],
+        cursor: 'pointer',
+        display: 'inline',
+        '& svg': {
+            fontSize: '1rem'
+        }
+    },
+    tableWrapper: {
+        overflowX: 'auto',
+    },
+    webshellButton: {
+        textTransform: "initial",
+    },
+});
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        requestedDate: state.virtualMachines.date,
+        userUuid: state.auth.user!.uuid,
+        helpText: state.auth.config.clusterConfig.Workbench.SSHHelpPageHTML,
+        hostSuffix: state.auth.config.clusterConfig.Workbench.SSHHelpHostSuffix || "",
+        token: state.auth.extraApiToken || state.auth.apiToken || '',
+        tokenLocation: state.auth.extraApiToken ? EXTRA_TOKEN : (state.auth.apiTokenLocation || ''),
+        webshellUrl: state.auth.config.clusterConfig.Services.WebShell.ExternalURL,
+        idleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
+        ...state.virtualMachines
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<VirtualMachinesPanelActionProps, 'loadVirtualMachinesData' | 'saveRequestedDate' | 'onCopy'> => ({
+    saveRequestedDate: () => dispatch<any>(saveRequestedDate()),
+    loadVirtualMachinesData: () => dispatch<any>(loadVirtualMachinesUserData()),
+    onCopy: (message: string) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message,
+            hideDuration: 2000,
+            kind: SnackbarKind.SUCCESS
+        }));
+    },
+});
+
+interface VirtualMachinesPanelDataProps {
+    requestedDate: string;
+    virtualMachines: ListResults<any>;
+    userUuid: string;
+    links: ListResults<any>;
+    helpText: string;
+    hostSuffix: string;
+    token: string;
+    tokenLocation: string;
+    webshellUrl: string;
+    idleTimeout: number;
+}
+
+interface VirtualMachinesPanelActionProps {
+    saveRequestedDate: () => void;
+    loadVirtualMachinesData: () => string;
+    onCopy: (message: string) => void;
+}
+
+type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles<CssRules>;
+
+export const VirtualMachineUserPanel = compose(
+    withStyles(styles),
+    connect(mapStateToProps, mapDispatchToProps))(
+        class extends React.Component<VirtualMachineProps> {
+            componentDidMount() {
+                this.props.loadVirtualMachinesData();
+            }
+
+            render() {
+                const { virtualMachines, links } = this.props;
+                return (
+                    <Grid container spacing={16} data-cy="vm-user-panel">
+                        {virtualMachines.itemsAvailable === 0 && <CardContentWithoutVirtualMachines {...this.props} />}
+                        {virtualMachines.itemsAvailable > 0 && links.itemsAvailable > 0 && <CardContentWithVirtualMachines {...this.props} />}
+                        {<CardSSHSection {...this.props} />}
+                    </Grid>
+                );
+            }
+        }
+    );
+
+const CardContentWithoutVirtualMachines = (props: VirtualMachineProps) =>
+    <Grid item xs={12}>
+        <Card>
+            <CardContent className={props.classes.cardWithoutMachines}>
+                <Grid item xs={6}>
+                    <Typography variant='body1'>
+                        You do not have access to any virtual machines. Some Arvados features require using the command line. You may request access to a hosted virtual machine with the command line shell.
+                    </Typography>
+                </Grid>
+                <Grid item xs={6} className={props.classes.rightAlign}>
+                    {virtualMachineSendRequest(props)}
+                </Grid>
+            </CardContent>
+        </Card>
+    </Grid>;
+
+const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
+    <Grid item xs={12}>
+        <Card>
+            <CardContent>
+                <span>
+                    <div className={props.classes.rightAlign}>
+                        {virtualMachineSendRequest(props)}
+                    </div>
+                    <div className={props.classes.icon}>
+                        <a href="https://doc.arvados.org/user/getting_started/vm-login-with-webshell.html" target="_blank" rel="noopener noreferrer" className={props.classes.linkIcon}>
+                            <Tooltip title="Access VM using webshell">
+                                <HelpIcon />
+                            </Tooltip>
+                        </a>
+                    </div>
+                    <div className={props.classes.tableWrapper}>
+                        {virtualMachinesTable(props)}
+                    </div>
+                </span>
+
+            </CardContent>
+        </Card>
+    </Grid>;
+
+const virtualMachineSendRequest = (props: VirtualMachineProps) =>
+    <span>
+        <Button variant="contained" color="primary" className={props.classes.button} onClick={props.saveRequestedDate}>
+            SEND REQUEST FOR SHELL ACCESS
+        </Button>
+        {props.requestedDate &&
+            <Typography >
+                A request for shell access was sent on {props.requestedDate}
+            </Typography>}
+    </span>;
+
+const virtualMachinesTable = (props: VirtualMachineProps) =>
+    <Table data-cy="vm-user-table">
+        <TableHead>
+            <TableRow>
+                <TableCell>Host name</TableCell>
+                <TableCell>Login name</TableCell>
+                <TableCell>Groups</TableCell>
+                <TableCell>Command line</TableCell>
+                <TableCell>Web shell</TableCell>
+            </TableRow>
+        </TableHead>
+        <TableBody>
+            {props.virtualMachines.items.map(it =>
+                props.links.items.map(lk => {
+                    if (lk.tailUuid === props.userUuid && lk.headUuid === it.uuid) {
+                        const username = lk.properties.username;
+                        const command = `ssh ${username}@${it.hostname}${props.hostSuffix}`;
+                        let tokenParam = "";
+                        if (props.tokenLocation === SESSION_STORAGE || props.tokenLocation === EXTRA_TOKEN) {
+                            tokenParam = `&token=${encodeURIComponent(props.token)}`;
+                        }
+                        const loginHref = `/webshell/?host=${encodeURIComponent(props.webshellUrl + '/' + it.hostname)}&timeout=${props.idleTimeout}&login=${encodeURIComponent(username)}${tokenParam}`;
+                        return <TableRow key={lk.uuid}>
+                            <TableCell>{it.hostname}</TableCell>
+                            <TableCell>{username}</TableCell>
+                            <TableCell>
+                                <Grid container spacing={8} className={props.classes.chipsRoot}>
+                                    {
+                                        (lk.properties.groups || []).map((group, i) => (
+                                            <Grid item key={i}>
+                                                <Chip label={group} />
+                                            </Grid>
+                                        ))
+                                    }
+                                </Grid>
+                            </TableCell>
+                            <TableCell>
+                                {command}
+                                <Tooltip title="Copy link to clipboard">
+                                    <span className={props.classes.copyIcon}>
+                                        <CopyToClipboard text={command || ""} onCopy={() => props.onCopy!("Copied")}>
+                                            <CopyIcon />
+                                        </CopyToClipboard>
+                                    </span>
+                                </Tooltip>
+                            </TableCell>
+                            <TableCell>
+                                <Button
+                                    className={props.classes.webshellButton}
+                                    variant="contained"
+                                    size="small"
+                                    href={loginHref}
+                                    target="_blank"
+                                    rel="noopener">
+                                    Log in as {username}
+                                </Button>
+                            </TableCell>
+                        </TableRow>;
+                    }
+                    return null;
+                }
+                ))}
+        </TableBody>
+    </Table>;
+
+const CardSSHSection = (props: VirtualMachineProps) =>
+    <Grid item xs={12}>
+        <Card>
+            <CardContent>
+                <Typography>
+                    <div dangerouslySetInnerHTML={{ __html: sanitizeHTML(props.helpText) }} style={{ margin: "1em" }} />
+                </Typography>
+            </CardContent>
+        </Card>
+    </Grid>;
diff --git a/services/workbench2/src/views/workbench/fed-login.tsx b/services/workbench2/src/views/workbench/fed-login.tsx
new file mode 100644 (file)
index 0000000..595f136
--- /dev/null
@@ -0,0 +1,54 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import { User } from "models/user";
+import { getSaltedToken } from 'store/auth/auth-action-session';
+import { Config } from 'common/config';
+
+export interface FedLoginProps {
+    user?: User;
+    apiToken?: string;
+    localCluster: string;
+    remoteHostsConfig: { [key: string]: Config };
+}
+
+const mapStateToProps = ({ auth }: RootState) => ({
+    user: auth.user,
+    apiToken: auth.apiToken,
+    remoteHostsConfig: auth.remoteHostsConfig,
+    localCluster: auth.localCluster,
+});
+
+export const FedLogin = connect(mapStateToProps)(
+    class extends React.Component<FedLoginProps> {
+        render() {
+            const { apiToken, user, localCluster, remoteHostsConfig } = this.props;
+            if (!apiToken || !user || !user.uuid.startsWith(localCluster)) {
+                return <></>;
+            }
+            return <div id={"fedtoken-iframe-div"}>
+                {Object.keys(remoteHostsConfig)
+                    .map((k) => {
+                        if (k === localCluster) {
+                            return null;
+                        }
+                        if (!remoteHostsConfig[k].workbench2Url) {
+                            console.log(`Cluster ${k} does not define workbench2Url.  Federated login / cross-site linking to ${k} is unavailable.  Tell the admin of ${k} to set Services->Workbench2->ExternalURL in config.yml.`);
+                            return null;
+                        }
+                        const fedtoken = (remoteHostsConfig[k].loginCluster === localCluster)
+                            ? apiToken : getSaltedToken(k, apiToken);
+                        return <iframe key={k} title={k} src={`${remoteHostsConfig[k].workbench2Url}/fedtoken?api_token=${fedtoken}`} style={{
+                            height: 0,
+                            width: 0,
+                            visibility: "hidden"
+                        }}
+                        />;
+                    })}
+            </div>;
+        }
+    });
diff --git a/services/workbench2/src/views/workbench/workbench-loading-screen.tsx b/services/workbench2/src/views/workbench/workbench-loading-screen.tsx
new file mode 100644 (file)
index 0000000..1b70ece
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+import { Grid, CircularProgress } from '@material-ui/core';
+
+type CssRules = 'root' | 'img';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    img: {
+        marginBottom: theme.spacing.unit * 4
+    },
+    root: {
+        background: theme.palette.background.default,
+        bottom: 0,
+        left: 0,
+        position: 'fixed',
+        right: 0,
+        top: 0,
+        zIndex: theme.zIndex.appBar + 1,
+    }
+});
+
+export const WorkbenchLoadingScreen = withStyles(styles)(({ classes }: WithStyles<CssRules>) =>
+    <Grid container direction="column" alignItems='center' justify='center' className={classes.root}>
+        <img src='/arvados_logo.png' alt='Arvados logo' className={classes.img} />
+        <CircularProgress data-cy='loading-spinner' />
+    </Grid>
+);
diff --git a/services/workbench2/src/views/workbench/workbench.test.tsx b/services/workbench2/src/views/workbench/workbench.test.tsx
new file mode 100644 (file)
index 0000000..fe5dff8
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { WorkbenchPanel } from './workbench';
+import { Provider } from "react-redux";
+import { configureStore } from "store/store";
+import { createBrowserHistory } from "history";
+import { ConnectedRouter } from "react-router-redux";
+import { MuiThemeProvider } from '@material-ui/core/styles';
+import { CustomTheme } from 'common/custom-theme';
+import { createServices } from "services/services";
+import 'jest-localstorage-mock';
+
+jest.mock('views-components/baner/banner', () => ({ Banner: () => 'Banner' }))
+
+const history = createBrowserHistory();
+
+it('renders without crashing', () => {
+    const div = document.createElement('div');
+    const services = createServices("/arvados/v1");
+       services.authService.getUuid = jest.fn().mockReturnValueOnce('test');
+    const store = configureStore(createBrowserHistory(), services);
+    ReactDOM.render(
+        <MuiThemeProvider theme={CustomTheme}>
+            <Provider store={store}>
+                <ConnectedRouter history={history}>
+                    <WorkbenchPanel />
+                </ConnectedRouter>
+            </Provider>
+        </MuiThemeProvider>,
+    div);
+    ReactDOM.unmountComponentAtNode(div);
+});
diff --git a/services/workbench2/src/views/workbench/workbench.tsx b/services/workbench2/src/views/workbench/workbench.tsx
new file mode 100644 (file)
index 0000000..3020e0d
--- /dev/null
@@ -0,0 +1,464 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useEffect, useState } from "react";
+import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core/styles";
+import { Route, Switch } from "react-router";
+import { ProjectPanel } from "views/project-panel/project-panel";
+import { DetailsPanel } from "views-components/details-panel/details-panel";
+import { ArvadosTheme } from "common/custom-theme";
+import { ContextMenu } from "views-components/context-menu/context-menu";
+import { FavoritePanel } from "../favorite-panel/favorite-panel";
+import { TokenDialog } from "views-components/token-dialog/token-dialog";
+import { RichTextEditorDialog } from "views-components/rich-text-editor-dialog/rich-text-editor-dialog";
+import { Snackbar } from "views-components/snackbar/snackbar";
+import { CollectionPanel } from "../collection-panel/collection-panel";
+import { RenameFileDialog } from "views-components/rename-file-dialog/rename-file-dialog";
+import { FileRemoveDialog } from "views-components/file-remove-dialog/file-remove-dialog";
+import { MultipleFilesRemoveDialog } from "views-components/file-remove-dialog/multiple-files-remove-dialog";
+import { Routes } from "routes/routes";
+import { SidePanel } from "views-components/side-panel/side-panel";
+import { ProcessPanel } from "views/process-panel/process-panel";
+import { ChangeWorkflowDialog } from "views-components/run-process-dialog/change-workflow-dialog";
+import { CreateProjectDialog } from "views-components/dialog-forms/create-project-dialog";
+import { CreateCollectionDialog } from "views-components/dialog-forms/create-collection-dialog";
+import { CopyCollectionDialog, CopyMultiCollectionDialog } from "views-components/dialog-forms/copy-collection-dialog";
+import { CopyProcessDialog } from "views-components/dialog-forms/copy-process-dialog";
+import { UpdateCollectionDialog } from "views-components/dialog-forms/update-collection-dialog";
+import { UpdateProcessDialog } from "views-components/dialog-forms/update-process-dialog";
+import { UpdateProjectDialog } from "views-components/dialog-forms/update-project-dialog";
+import { MoveProcessDialog } from "views-components/dialog-forms/move-process-dialog";
+import { MoveProjectDialog } from "views-components/dialog-forms/move-project-dialog";
+import { MoveCollectionDialog } from "views-components/dialog-forms/move-collection-dialog";
+import { FilesUploadCollectionDialog } from "views-components/dialog-forms/files-upload-collection-dialog";
+import { PartialCopyToNewCollectionDialog } from "views-components/dialog-forms/partial-copy-to-new-collection-dialog";
+import { PartialCopyToExistingCollectionDialog } from "views-components/dialog-forms/partial-copy-to-existing-collection-dialog";
+import { PartialCopyToSeparateCollectionsDialog } from "views-components/dialog-forms/partial-copy-to-separate-collections-dialog";
+import { PartialMoveToNewCollectionDialog } from "views-components/dialog-forms/partial-move-to-new-collection-dialog";
+import { PartialMoveToExistingCollectionDialog } from "views-components/dialog-forms/partial-move-to-existing-collection-dialog";
+import { PartialMoveToSeparateCollectionsDialog } from "views-components/dialog-forms/partial-move-to-separate-collections-dialog";
+import { RemoveProcessDialog } from "views-components/process-remove-dialog/process-remove-dialog";
+import { MainContentBar } from "views-components/main-content-bar/main-content-bar";
+import { Grid } from "@material-ui/core";
+import { TrashPanel } from "views/trash-panel/trash-panel";
+import { SharedWithMePanel } from "views/shared-with-me-panel/shared-with-me-panel";
+import { RunProcessPanel } from "views/run-process-panel/run-process-panel";
+import SplitterLayout from "react-splitter-layout";
+import { WorkflowPanel } from "views/workflow-panel/workflow-panel";
+import { RegisteredWorkflowPanel } from "views/workflow-panel/registered-workflow-panel";
+import { SearchResultsPanel } from "views/search-results-panel/search-results-panel";
+import { SshKeyPanel } from "views/ssh-key-panel/ssh-key-panel";
+import { SshKeyAdminPanel } from "views/ssh-key-panel/ssh-key-admin-panel";
+import { SiteManagerPanel } from "views/site-manager-panel/site-manager-panel";
+import { UserProfilePanel } from "views/user-profile-panel/user-profile-panel";
+import { SharingDialog } from "views-components/sharing-dialog/sharing-dialog";
+import { NotFoundDialog } from "views-components/not-found-dialog/not-found-dialog";
+import { AdvancedTabDialog } from "views-components/advanced-tab-dialog/advanced-tab-dialog";
+import { ProcessInputDialog } from "views-components/process-input-dialog/process-input-dialog";
+import { VirtualMachineUserPanel } from "views/virtual-machine-panel/virtual-machine-user-panel";
+import { VirtualMachineAdminPanel } from "views/virtual-machine-panel/virtual-machine-admin-panel";
+import { RepositoriesPanel } from "views/repositories-panel/repositories-panel";
+import { KeepServicePanel } from "views/keep-service-panel/keep-service-panel";
+import { ApiClientAuthorizationPanel } from "views/api-client-authorization-panel/api-client-authorization-panel";
+import { LinkPanel } from "views/link-panel/link-panel";
+import { RepositoriesSampleGitDialog } from "views-components/repositories-sample-git-dialog/repositories-sample-git-dialog";
+import { RepositoryAttributesDialog } from "views-components/repository-attributes-dialog/repository-attributes-dialog";
+import { CreateRepositoryDialog } from "views-components/dialog-forms/create-repository-dialog";
+import { RemoveRepositoryDialog } from "views-components/repository-remove-dialog/repository-remove-dialog";
+import { CreateSshKeyDialog } from "views-components/dialog-forms/create-ssh-key-dialog";
+import { PublicKeyDialog } from "views-components/ssh-keys-dialog/public-key-dialog";
+import { RemoveApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/remove-dialog";
+import { RemoveKeepServiceDialog } from "views-components/keep-services-dialog/remove-dialog";
+import { RemoveLinkDialog } from "views-components/links-dialog/remove-dialog";
+import { RemoveSshKeyDialog } from "views-components/ssh-keys-dialog/remove-dialog";
+import { VirtualMachineAttributesDialog } from "views-components/virtual-machines-dialog/attributes-dialog";
+import { RemoveVirtualMachineDialog } from "views-components/virtual-machines-dialog/remove-dialog";
+import { RemoveVirtualMachineLoginDialog } from "views-components/virtual-machines-dialog/remove-login-dialog";
+import { VirtualMachineAddLoginDialog } from "views-components/virtual-machines-dialog/add-login-dialog";
+import { AttributesApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/attributes-dialog";
+import { AttributesKeepServiceDialog } from "views-components/keep-services-dialog/attributes-dialog";
+import { AttributesLinkDialog } from "views-components/links-dialog/attributes-dialog";
+import { AttributesSshKeyDialog } from "views-components/ssh-keys-dialog/attributes-dialog";
+import { UserPanel } from "views/user-panel/user-panel";
+import { UserAttributesDialog } from "views-components/user-dialog/attributes-dialog";
+import { CreateUserDialog } from "views-components/dialog-forms/create-user-dialog";
+import { HelpApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/help-dialog";
+import { DeactivateDialog } from "views-components/user-dialog/deactivate-dialog";
+import { ActivateDialog } from "views-components/user-dialog/activate-dialog";
+import { SetupDialog } from "views-components/user-dialog/setup-dialog";
+import { GroupsPanel } from "views/groups-panel/groups-panel";
+import { RemoveGroupDialog } from "views-components/groups-dialog/remove-dialog";
+import { GroupAttributesDialog } from "views-components/groups-dialog/attributes-dialog";
+import { GroupDetailsPanel } from "views/group-details-panel/group-details-panel";
+import { RemoveGroupMemberDialog } from "views-components/groups-dialog/member-remove-dialog";
+import { GroupMemberAttributesDialog } from "views-components/groups-dialog/member-attributes-dialog";
+import { PublicFavoritePanel } from "views/public-favorites-panel/public-favorites-panel";
+import { LinkAccountPanel } from "views/link-account-panel/link-account-panel";
+import { FedLogin } from "./fed-login";
+import { CollectionsContentAddressPanel } from "views/collection-content-address-panel/collection-content-address-panel";
+import { AllProcessesPanel } from "../all-processes-panel/all-processes-panel";
+import { NotFoundPanel } from "../not-found-panel/not-found-panel";
+import { AutoLogout } from "views-components/auto-logout/auto-logout";
+import { RestoreCollectionVersionDialog } from "views-components/collections-dialog/restore-version-dialog";
+import { WebDavS3InfoDialog } from "views-components/webdav-s3-dialog/webdav-s3-dialog";
+import { pluginConfig } from "plugins";
+import { ElementListReducer } from "common/plugintypes";
+import { COLLAPSE_ICON_SIZE } from "views-components/side-panel-toggle/side-panel-toggle";
+import { Banner } from "views-components/baner/banner";
+import { InstanceTypesPanel } from "views/instance-types-panel/instance-types-panel";
+
+type CssRules = "root" | "container" | "splitter" | "asidePanel" | "contentWrapper" | "content";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        paddingTop: theme.spacing.unit * 7,
+        background: theme.palette.background.default,
+    },
+    container: {
+        position: "relative",
+    },
+    splitter: {
+        "& > .layout-splitter": {
+            width: "3px",
+        },
+        "& > .layout-splitter-disabled": {
+            pointerEvents: "none",
+            cursor: "pointer",
+        },
+    },
+    asidePanel: {
+        paddingTop: theme.spacing.unit,
+        height: "100%",
+    },
+    contentWrapper: {
+        paddingTop: theme.spacing.unit,
+        minWidth: 0,
+    },
+    content: {
+        minWidth: 0,
+        paddingLeft: theme.spacing.unit * 3,
+        paddingRight: theme.spacing.unit * 3,
+        // Reserve vertical space for app bar + MainContentBar
+        minHeight: `calc(100vh - ${theme.spacing.unit * 16}px)`,
+        display: "flex",
+    },
+});
+
+interface WorkbenchDataProps {
+    isUserActive: boolean;
+    isNotLinking: boolean;
+    sessionIdleTimeout: number;
+    sidePanelIsCollapsed: boolean;
+    isTransitioning: boolean;
+    currentSideWidth: number;
+}
+
+type WorkbenchPanelProps = WithStyles<CssRules> & WorkbenchDataProps;
+
+const defaultSplitterSize = 90;
+
+const getSplitterInitialSize = () => {
+    const splitterSize = localStorage.getItem("splitterSize");
+    return splitterSize ? Number(splitterSize) : defaultSplitterSize;
+};
+
+const saveSplitterSize = (size: number) => localStorage.setItem("splitterSize", size.toString());
+
+let routes = (
+    <>
+        <Route
+            path={Routes.PROJECTS}
+            component={ProjectPanel}
+        />
+        <Route
+            path={Routes.COLLECTIONS}
+            component={CollectionPanel}
+        />
+        <Route
+            path={Routes.FAVORITES}
+            component={FavoritePanel}
+        />
+        <Route
+            path={Routes.ALL_PROCESSES}
+            component={AllProcessesPanel}
+        />
+        <Route
+            path={Routes.PROCESSES}
+            component={ProcessPanel}
+        />
+        <Route
+            path={Routes.TRASH}
+            component={TrashPanel}
+        />
+        <Route
+            path={Routes.SHARED_WITH_ME}
+            component={SharedWithMePanel}
+        />
+        <Route
+            path={Routes.RUN_PROCESS}
+            component={RunProcessPanel}
+        />
+        <Route
+            path={Routes.REGISTEREDWORKFLOW}
+            component={RegisteredWorkflowPanel}
+        />
+        <Route
+            path={Routes.WORKFLOWS}
+            component={WorkflowPanel}
+        />
+        <Route
+            path={Routes.SEARCH_RESULTS}
+            component={SearchResultsPanel}
+        />
+        <Route
+            path={Routes.VIRTUAL_MACHINES_USER}
+            component={VirtualMachineUserPanel}
+        />
+        <Route
+            path={Routes.VIRTUAL_MACHINES_ADMIN}
+            component={VirtualMachineAdminPanel}
+        />
+        <Route
+            path={Routes.REPOSITORIES}
+            component={RepositoriesPanel}
+        />
+        <Route
+            path={Routes.SSH_KEYS_USER}
+            component={SshKeyPanel}
+        />
+        <Route
+            path={Routes.SSH_KEYS_ADMIN}
+            component={SshKeyAdminPanel}
+        />
+        <Route
+            path={Routes.INSTANCE_TYPES}
+            component={InstanceTypesPanel}
+        />
+        <Route
+            path={Routes.SITE_MANAGER}
+            component={SiteManagerPanel}
+        />
+        <Route
+            path={Routes.KEEP_SERVICES}
+            component={KeepServicePanel}
+        />
+        <Route
+            path={Routes.USERS}
+            component={UserPanel}
+        />
+        <Route
+            path={Routes.API_CLIENT_AUTHORIZATIONS}
+            component={ApiClientAuthorizationPanel}
+        />
+        <Route
+            path={Routes.MY_ACCOUNT}
+            component={UserProfilePanel}
+        />
+        <Route
+            path={Routes.USER_PROFILE}
+            component={UserProfilePanel}
+        />
+        <Route
+            path={Routes.GROUPS}
+            component={GroupsPanel}
+        />
+        <Route
+            path={Routes.GROUP_DETAILS}
+            component={GroupDetailsPanel}
+        />
+        <Route
+            path={Routes.LINKS}
+            component={LinkPanel}
+        />
+        <Route
+            path={Routes.PUBLIC_FAVORITES}
+            component={PublicFavoritePanel}
+        />
+        <Route
+            path={Routes.LINK_ACCOUNT}
+            component={LinkAccountPanel}
+        />
+        <Route
+            path={Routes.COLLECTIONS_CONTENT_ADDRESS}
+            component={CollectionsContentAddressPanel}
+        />
+    </>
+);
+
+const reduceRoutesFn: (a: React.ReactElement[], b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a);
+
+routes = React.createElement(
+    React.Fragment,
+    null,
+    pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children))
+);
+
+export const WorkbenchPanel = withStyles(styles)((props: WorkbenchPanelProps) => {
+const { classes, sidePanelIsCollapsed, isNotLinking, isTransitioning, isUserActive, sessionIdleTimeout, currentSideWidth } = props
+
+    const applyCollapsedState = (savedWidthInPx) => {
+        const rightPanel: Element = document.getElementsByClassName("layout-pane")[1];
+        const totalWidth: number = document.getElementsByClassName("splitter-layout")[0]?.clientWidth;
+        const savedWidthInPercent = (savedWidthInPx / totalWidth) * 100
+        const rightPanelExpandedWidth = (totalWidth - COLLAPSE_ICON_SIZE) / (totalWidth / 100);
+
+        if(isTransitioning && !!rightPanel) {
+            rightPanel.setAttribute('style', `width: ${sidePanelIsCollapsed ? `calc(${savedWidthInPercent}% - 1rem)` : `${getSplitterInitialSize()}%`};`)
+        }
+
+        if (rightPanel) {
+            rightPanel.setAttribute("style", `width: ${sidePanelIsCollapsed ? `calc(${rightPanelExpandedWidth}% - 1rem)` : `${getSplitterInitialSize()}%`};`);
+        }
+        const splitter = document.getElementsByClassName("layout-splitter")[0];
+        sidePanelIsCollapsed ? splitter?.classList.add("layout-splitter-disabled") : splitter?.classList.remove("layout-splitter-disabled");
+    };
+
+    const [savedWidth, setSavedWidth] = useState<number>(0)
+
+    useEffect(()=>{
+        if (isTransitioning) setSavedWidth(currentSideWidth)
+    }, [isTransitioning, currentSideWidth])
+
+    useEffect(()=>{
+        if (isTransitioning) applyCollapsedState(savedWidth);
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [isTransitioning, savedWidth])
+
+    applyCollapsedState(savedWidth);
+
+    return (
+        <Grid
+            container
+            item
+            xs
+            className={classes.root}
+        >
+            {sessionIdleTimeout > 0 && <AutoLogout />}
+            <Grid
+                container
+                item
+                xs
+                className={classes.container}
+            >
+                <SplitterLayout
+                    customClassName={classes.splitter}
+                    percentage={true}
+                    primaryIndex={0}
+                    primaryMinSize={10}
+                    secondaryInitialSize={getSplitterInitialSize()}
+                    secondaryMinSize={40}
+                    onSecondaryPaneSizeChange={saveSplitterSize}
+                >
+                    {isUserActive && isNotLinking && (
+                        <Grid
+                            container
+                            item
+                            xs
+                            component="aside"
+                            direction="column"
+                            className={classes.asidePanel}
+                        >
+                            <SidePanel />
+                        </Grid>
+                    )}
+                    <Grid
+                        container
+                        item
+                        xs
+                        component="main"
+                        direction="column"
+                        className={classes.contentWrapper}
+                    >
+                        <Grid
+                            item
+                            xs
+                        >
+                            {isNotLinking && <MainContentBar />}
+                        </Grid>
+                        <Grid
+                            item
+                            xs
+                            className={classes.content}
+                        >
+                            <Switch>
+                                {routes.props.children}
+                                <Route
+                                    path={Routes.NO_MATCH}
+                                    component={NotFoundPanel}
+                                />
+                            </Switch>
+                        </Grid>
+                    </Grid>
+                </SplitterLayout>
+            </Grid>
+            <Grid item>
+                <DetailsPanel />
+            </Grid>
+            <AdvancedTabDialog />
+            <AttributesApiClientAuthorizationDialog />
+            <AttributesKeepServiceDialog />
+            <AttributesLinkDialog />
+            <AttributesSshKeyDialog />
+            <ChangeWorkflowDialog />
+            <ContextMenu />
+            <CopyCollectionDialog />
+            <CopyMultiCollectionDialog />
+            <CopyProcessDialog />
+            <CreateCollectionDialog />
+            <CreateProjectDialog />
+            <CreateRepositoryDialog />
+            <CreateSshKeyDialog />
+            <CreateUserDialog />
+            <TokenDialog />
+            <FileRemoveDialog />
+            <FilesUploadCollectionDialog />
+            <GroupAttributesDialog />
+            <GroupMemberAttributesDialog />
+            <HelpApiClientAuthorizationDialog />
+            <MoveCollectionDialog />
+            <MoveProcessDialog />
+            <MoveProjectDialog />
+            <MultipleFilesRemoveDialog />
+            <PublicKeyDialog />
+            <PartialCopyToNewCollectionDialog />
+            <PartialCopyToExistingCollectionDialog />
+            <PartialCopyToSeparateCollectionsDialog />
+            <PartialMoveToNewCollectionDialog />
+            <PartialMoveToExistingCollectionDialog />
+            <PartialMoveToSeparateCollectionsDialog />
+            <ProcessInputDialog />
+            <RestoreCollectionVersionDialog />
+            <RemoveApiClientAuthorizationDialog />
+            <RemoveGroupDialog />
+            <RemoveGroupMemberDialog />
+            <RemoveKeepServiceDialog />
+            <RemoveLinkDialog />
+            <RemoveProcessDialog />
+            <RemoveRepositoryDialog />
+            <RemoveSshKeyDialog />
+            <RemoveVirtualMachineDialog />
+            <RemoveVirtualMachineLoginDialog />
+            <VirtualMachineAddLoginDialog />
+            <RenameFileDialog />
+            <RepositoryAttributesDialog />
+            <RepositoriesSampleGitDialog />
+            <RichTextEditorDialog />
+            <SharingDialog />
+            <NotFoundDialog />
+            <Snackbar />
+            <UpdateCollectionDialog />
+            <UpdateProcessDialog />
+            <UpdateProjectDialog />
+            <UserAttributesDialog />
+            <DeactivateDialog />
+            <ActivateDialog />
+            <SetupDialog />
+            <VirtualMachineAttributesDialog />
+            <FedLogin />
+            <WebDavS3InfoDialog />
+            <Banner />
+            {React.createElement(React.Fragment, null, pluginConfig.dialogs)}
+        </Grid>
+    );
+});
diff --git a/services/workbench2/src/views/workflow-panel/registered-workflow-panel.tsx b/services/workbench2/src/views/workflow-panel/registered-workflow-panel.tsx
new file mode 100644 (file)
index 0000000..aa4c1b2
--- /dev/null
@@ -0,0 +1,235 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Tooltip,
+    Typography,
+    Card,
+    CardHeader,
+    CardContent,
+    IconButton
+} from '@material-ui/core';
+import { connect, DispatchProp } from "react-redux";
+import { RouteComponentProps } from 'react-router';
+import { ArvadosTheme } from 'common/custom-theme';
+import { RootState } from 'store/store';
+import { WorkflowIcon, MoreVerticalIcon } from 'components/icon/icon';
+import { WorkflowResource } from 'models/workflow';
+import { ProcessOutputCollectionFiles } from 'views/process-panel/process-output-collection-files';
+import { WorkflowDetailsAttributes, RegisteredWorkflowPanelDataProps, getRegisteredWorkflowPanelData } from 'views-components/details-panel/workflow-details';
+import { getResource } from 'store/resources/resources';
+import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
+import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
+import { ProcessIOCard, ProcessIOCardType } from 'views/process-panel/process-io-card';
+import { NotFoundView } from 'views/not-found-panel/not-found-panel';
+import { WorkflowProcessesPanel } from './workflow-processes-panel';
+
+type CssRules = 'root'
+    | 'button'
+    | 'infoCard'
+    | 'propertiesCard'
+    | 'filesCard'
+    | 'iconHeader'
+    | 'tag'
+    | 'label'
+    | 'value'
+    | 'link'
+    | 'centeredLabel'
+    | 'warningLabel'
+    | 'collectionName'
+    | 'readOnlyIcon'
+    | 'header'
+    | 'title'
+    | 'avatar'
+    | 'content';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    },
+    button: {
+        cursor: 'pointer'
+    },
+    infoCard: {
+    },
+    propertiesCard: {
+        padding: 0,
+    },
+    filesCard: {
+        padding: 0,
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL
+    },
+    tag: {
+        marginRight: theme.spacing.unit / 2,
+        marginBottom: theme.spacing.unit / 2
+    },
+    label: {
+        fontSize: '0.875rem',
+    },
+    centeredLabel: {
+        fontSize: '0.875rem',
+        textAlign: 'center'
+    },
+    warningLabel: {
+        fontStyle: 'italic'
+    },
+    collectionName: {
+        flexDirection: 'column',
+    },
+    value: {
+        textTransform: 'none',
+        fontSize: '0.875rem'
+    },
+    link: {
+        fontSize: '0.875rem',
+        color: theme.palette.primary.main,
+        '&:hover': {
+            cursor: 'pointer'
+        }
+    },
+    readOnlyIcon: {
+        marginLeft: theme.spacing.unit,
+        fontSize: 'small',
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.green700,
+    },
+    avatar: {
+        alignSelf: 'flex-start',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    content: {
+        padding: theme.spacing.unit * 1.0,
+        paddingTop: theme.spacing.unit * 0.5,
+        '&:last-child': {
+            paddingBottom: theme.spacing.unit * 1,
+        }
+    }
+});
+
+type RegisteredWorkflowPanelProps = RegisteredWorkflowPanelDataProps & DispatchProp & WithStyles<CssRules>
+
+export const RegisteredWorkflowPanel = withStyles(styles)(connect(
+    (state: RootState, props: RouteComponentProps<{ id: string }>) => {
+        const item = getResource<WorkflowResource>(props.match.params.id)(state.resources);
+        if (item) {
+            return getRegisteredWorkflowPanelData(item, state.auth);
+        }
+        return { item, inputParams: [], outputParams: [], workflowCollection: "", gitprops: {} };
+    })(
+        class extends React.Component<RegisteredWorkflowPanelProps> {
+            render() {
+                const { classes, item, inputParams, outputParams, workflowCollection } = this.props;
+                const panelsData: MPVPanelState[] = [
+                    { name: "Details" },
+                    { name: "Runs" },
+                    { name: "Outputs" },
+                    { name: "Inputs" },
+                    { name: "Definition" },
+                ];
+                return item
+                    ? <MPVContainer className={classes.root} spacing={8} direction="column" justify-content="flex-start" wrap="nowrap" panelStates={panelsData}>
+                        <MPVPanelContent xs="auto" data-cy='registered-workflow-info-panel'>
+                            <Card className={classes.infoCard}>
+                                <CardHeader
+                                    className={classes.header}
+                                    classes={{
+                                        content: classes.title,
+                                        avatar: classes.avatar,
+                                    }}
+                                    avatar={<WorkflowIcon className={classes.iconHeader} />}
+                                    title={
+                                        <Tooltip title={item.name} placement="bottom-start">
+                                            <Typography noWrap variant='h6'>
+                                                {item.name}
+                                            </Typography>
+                                        </Tooltip>
+                                    }
+                                    subheader={
+                                        <Tooltip title={item.description || '(no-description)'} placement="bottom-start">
+                                            <Typography noWrap variant='body1' color='inherit'>
+                                                {item.description || '(no-description)'}
+                                            </Typography>
+                                        </Tooltip>}
+                                    action={
+                                        <Tooltip title="More options" disableFocusListener>
+                                            <IconButton
+                                                aria-label="More options"
+                                                onClick={event => this.handleContextMenu(event)}>
+                                                <MoreVerticalIcon />
+                                            </IconButton>
+                                        </Tooltip>}
+
+                                />
+
+                                <CardContent className={classes.content}>
+                                    <WorkflowDetailsAttributes workflow={item} />
+                                </CardContent>
+                            </Card>
+                        </MPVPanelContent>
+                        <MPVPanelContent forwardProps xs maxHeight="100%">
+                            <WorkflowProcessesPanel />
+                        </MPVPanelContent>
+                        <MPVPanelContent forwardProps xs data-cy="process-outputs" maxHeight="100%">
+                            <ProcessIOCard
+                                label={ProcessIOCardType.OUTPUT}
+                                params={outputParams}
+                                raw={{}}
+                                forceShowParams={true}
+                            />
+                        </MPVPanelContent>
+                        <MPVPanelContent forwardProps xs data-cy="process-inputs" maxHeight="100%">
+                            <ProcessIOCard
+                                label={ProcessIOCardType.INPUT}
+                                params={inputParams}
+                                raw={{}}
+                                forceShowParams={true}
+                            />
+                        </MPVPanelContent>
+                        <MPVPanelContent xs maxHeight="100%">
+                            <Card className={classes.filesCard}>
+                                <CardHeader title="Workflow Definition" />
+                                <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={workflowCollection} />
+                            </Card>
+                        </MPVPanelContent>
+                    </MPVContainer>
+                    :
+                    <NotFoundView
+                        icon={WorkflowIcon}
+                        messages={["Workflow not found"]}
+                    />
+            }
+
+            handleContextMenu = (event: React.MouseEvent<any>) => {
+                const { uuid, ownerUuid, name, description,
+                    kind } = this.props.item;
+                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(uuid));
+                const resource = {
+                    uuid,
+                    ownerUuid,
+                    name,
+                    description,
+                    kind,
+                    menuKind,
+                };
+                // Avoid expanding/collapsing the panel
+                event.stopPropagation();
+                this.props.dispatch<any>(openContextMenu(event, resource));
+            }
+        }
+    )
+);
diff --git a/services/workbench2/src/views/workflow-panel/workflow-description-card.tsx b/services/workbench2/src/views/workflow-panel/workflow-description-card.tsx
new file mode 100644 (file)
index 0000000..df1b281
--- /dev/null
@@ -0,0 +1,135 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    CardContent,
+    Tab,
+    Tabs,
+    Table,
+    TableHead,
+    TableCell,
+    TableBody,
+    TableRow,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { WorkflowIcon } from 'components/icon/icon';
+import { DataTableDefaultView } from 'components/data-table-default-view/data-table-default-view';
+import { parseWorkflowDefinition, getWorkflowInputs, getInputLabel, stringifyInputType } from 'models/workflow';
+import { WorkflowDetailsCardDataProps, WorkflowDetailsAttributes } from 'views-components/details-panel/workflow-details';
+
+export type CssRules = 'root' | 'tab' | 'inputTab' | 'graphTab' | 'graphTabWithChosenWorkflow' | 'descriptionTab' | 'inputsTable';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        height: '100%'
+    },
+    tab: {
+        minWidth: '33%'
+    },
+    inputTab: {
+        overflow: 'auto',
+        maxHeight: '300px',
+        marginTop: theme.spacing.unit
+    },
+    graphTab: {
+        marginTop: theme.spacing.unit,
+    },
+    graphTabWithChosenWorkflow: {
+        overflow: 'auto',
+        height: '450px',
+        marginTop: theme.spacing.unit,
+    },
+    descriptionTab: {
+        overflow: 'auto',
+        maxHeight: '300px',
+        marginTop: theme.spacing.unit,
+    },
+    inputsTable: {
+        tableLayout: 'fixed',
+    },
+});
+
+type WorkflowDetailsCardProps = WorkflowDetailsCardDataProps & WithStyles<CssRules>;
+
+export const WorkflowDetailsCard = withStyles(styles)(
+    class extends React.Component<WorkflowDetailsCardProps> {
+        state = {
+            value: 0,
+        };
+
+        handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+            this.setState({ value });
+        }
+
+        render() {
+            const { classes, workflow } = this.props;
+            const { value } = this.state;
+            return <div className={classes.root}>
+                <Tabs value={value} onChange={this.handleChange} centered={true}>
+                    <Tab className={classes.tab} label="Description" />
+                    <Tab className={classes.tab} label="Inputs" />
+                    <Tab className={classes.tab} label="Details" />
+                </Tabs>
+                {value === 0 && <CardContent className={classes.descriptionTab}>
+                    {workflow ? <div>
+                        {workflow.description}
+                    </div> : (
+                        <DataTableDefaultView
+                            icon={WorkflowIcon}
+                            messages={['Please select a workflow to see its description.']} />
+                    )}
+                </CardContent>}
+                {value === 1 && <CardContent className={classes.inputTab}>
+                    {workflow
+                        ? this.renderInputsTable()
+                        : <DataTableDefaultView
+                            icon={WorkflowIcon}
+                            messages={['Please select a workflow to see its inputs.']} />
+                    }
+                </CardContent>}
+                {value === 2 && <CardContent className={classes.descriptionTab}>
+                    {workflow
+                        ? <WorkflowDetailsAttributes workflow={workflow} />
+                        : <DataTableDefaultView
+                            icon={WorkflowIcon}
+                            messages={['Please select a workflow to see its details.']} />
+                    }
+                </CardContent>}
+            </div>;
+        }
+
+        get inputs() {
+            if (this.props.workflow) {
+                const definition = parseWorkflowDefinition(this.props.workflow);
+                if (definition) {
+                    return getWorkflowInputs(definition);
+                }
+            }
+            return undefined;
+        }
+
+        renderInputsTable() {
+            return <Table className={this.props.classes.inputsTable}>
+                <TableHead>
+                    <TableRow>
+                        <TableCell>Label</TableCell>
+                        <TableCell>Type</TableCell>
+                        <TableCell>Description</TableCell>
+                    </TableRow>
+                </TableHead>
+                <TableBody>
+                    {this.inputs && this.inputs.map(input =>
+                        <TableRow key={input.id}>
+                            <TableCell>{getInputLabel(input)}</TableCell>
+                            <TableCell>{stringifyInputType(input)}</TableCell>
+                            <TableCell>{input.doc}</TableCell>
+                        </TableRow>)}
+                </TableBody>
+            </Table>;
+        }
+    });
diff --git a/services/workbench2/src/views/workflow-panel/workflow-graph.tsx b/services/workbench2/src/views/workflow-panel/workflow-graph.tsx
new file mode 100644 (file)
index 0000000..3f4aac2
--- /dev/null
@@ -0,0 +1,72 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { WorkflowResource } from "models/workflow";
+import { WorkflowFactory } from "cwlts/models";
+import * as yaml from 'js-yaml';
+import "lib/cwl-svg/assets/styles/themes/rabix-dark/theme.css";
+import "lib/cwl-svg/plugins/port-drag/theme.dark.css";
+import "lib/cwl-svg/plugins/selection/theme.dark.css";
+import {
+    SelectionPlugin,
+    SVGArrangePlugin,
+    SVGEdgeHoverPlugin,
+    SVGNodeMovePlugin,
+    SVGPortDragPlugin, Workflow,
+    ZoomPlugin
+} from "lib/cwl-svg";
+
+interface WorkflowGraphProps {
+    workflow: WorkflowResource;
+}
+export class WorkflowGraph extends React.Component<WorkflowGraphProps, {}> {
+    private svgRoot: React.RefObject<SVGSVGElement> = React.createRef();
+
+    setGraph() {
+        const graphs = yaml.safeLoad(this.props.workflow.definition, { json: true });
+
+        let workflowGraph = graphs;
+        if (graphs.$graph) {
+          workflowGraph = graphs.$graph.find((g: any) => g.class === 'Workflow');
+        }
+
+        const model = WorkflowFactory.from(workflowGraph);
+
+        const workflow = new Workflow({
+            model,
+            svgRoot: this.svgRoot.current!,
+            plugins: [
+                new SVGArrangePlugin(),
+                new SVGEdgeHoverPlugin(),
+                new SVGNodeMovePlugin({
+                    movementSpeed: 2
+                }),
+                new SVGPortDragPlugin(),
+                new SelectionPlugin(),
+                new ZoomPlugin(),
+            ]
+        });
+        workflow.draw();
+    }
+
+    componentDidMount() {
+        this.setGraph();
+    }
+
+    componentDidUpdate() {
+        this.setGraph();
+    }
+
+    render() {
+        return <svg
+            ref={this.svgRoot}
+            className="cwl-workflow"
+            style={{
+                width: '100%',
+                height: '100%'
+            }}
+        />;
+    }
+}
diff --git a/services/workbench2/src/views/workflow-panel/workflow-panel-view.tsx b/services/workbench2/src/views/workflow-panel/workflow-panel-view.tsx
new file mode 100644 (file)
index 0000000..7d9d746
--- /dev/null
@@ -0,0 +1,142 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { WorkflowIcon } from 'components/icon/icon';
+import { WORKFLOW_PANEL_ID } from 'store/workflow-panel/workflow-panel-actions';
+import {
+    ResourceLastModifiedDate,
+    ResourceWorkflowName,
+    ResourceWorkflowStatus,
+    ResourceShare,
+    ResourceRunProcess
+} from "views-components/data-explorer/renderers";
+import { SortDirection } from 'components/data-table/data-column';
+import { DataColumns } from 'components/data-table/data-table';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
+import { Grid, Paper } from '@material-ui/core';
+import { WorkflowDetailsCard } from './workflow-description-card';
+import { WorkflowResource } from 'models/workflow';
+import { createTree } from 'models/tree';
+
+export enum WorkflowPanelColumnNames {
+    NAME = "Name",
+    AUTHORISATION = "Authorisation",
+    LAST_MODIFIED = "Last modified",
+    SHARE = 'Share'
+}
+
+export interface WorkflowPanelFilter extends DataTableFilterItem {
+    type: ResourceStatus;
+}
+
+export interface WorkflowPanelDataProps {
+    workflow?: WorkflowResource;
+}
+
+export interface WorfklowPanelActionProps {
+    handleRowDoubleClick: (workflowUuid: string) => void;
+    handleRowClick: (workflowUuid: string) => void;
+}
+
+export type WorkflowPanelProps = WorkflowPanelDataProps & WorfklowPanelActionProps;
+
+export enum ResourceStatus {
+    PUBLIC = "Public",
+    PRIVATE = "Private",
+    SHARED = "Shared"
+}
+
+// TODO: restore filters
+// const resourceStatus = (type: string) => {
+//     switch (type) {
+//         case ResourceStatus.PUBLIC:
+//             return "Public";
+//         case ResourceStatus.PRIVATE:
+//             return "Private";
+//         case ResourceStatus.SHARED:
+//             return "Shared";
+//         default:
+//             return "Unknown";
+//     }
+// };
+
+export const workflowPanelColumns: DataColumns<string, WorkflowResource> = [
+    {
+        name: WorkflowPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.ASC, field: "name"},
+        filters: createTree(),
+        render: (uuid: string) => <ResourceWorkflowName uuid={uuid} />
+    },
+    {
+        name: WorkflowPanelColumnNames.AUTHORISATION,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        // TODO: restore filters
+        // filters: [
+        //     {
+        //         name: resourceStatus(ResourceStatus.PUBLIC),
+        //         selected: true,
+        //         type: ResourceStatus.PUBLIC
+        //     },
+        //     {
+        //         name: resourceStatus(ResourceStatus.PRIVATE),
+        //         selected: true,
+        //         type: ResourceStatus.PRIVATE
+        //     },
+        //     {
+        //         name: resourceStatus(ResourceStatus.SHARED),
+        //         selected: true,
+        //         type: ResourceStatus.SHARED
+        //     }
+        // ],
+        render: (uuid: string) => <ResourceWorkflowStatus uuid={uuid} />,
+    },
+    {
+        name: WorkflowPanelColumnNames.LAST_MODIFIED,
+        selected: true,
+        configurable: true,
+        sort: {direction: SortDirection.NONE, field: "modifiedAt"},
+        filters: createTree(),
+        render: (uuid: string) => <ResourceLastModifiedDate uuid={uuid} />
+    },
+    {
+        name: '',
+        selected: true,
+        configurable: false,
+        filters: createTree(),
+        render: (uuid: string) => <ResourceShare uuid={uuid} />
+    },
+    {
+        name: '',
+        selected: true,
+        configurable: false,
+        filters: createTree(),
+        render: (uuid: string) => <ResourceRunProcess uuid={uuid} />
+    }
+];
+
+export const WorkflowPanelView = (props: WorkflowPanelProps) => {
+    return <Grid container spacing={16} style={{ minHeight: '500px' }}>
+        <Grid item xs={6}>
+            <DataExplorer
+                id={WORKFLOW_PANEL_ID}
+                onRowClick={props.handleRowClick}
+                onRowDoubleClick={props.handleRowDoubleClick}
+                contextMenuColumn={false}
+                onContextMenu={e => e}
+                defaultViewIcon={WorkflowIcon}
+                defaultViewMessages={['Workflow list is empty.']} />
+        </Grid>
+        <Grid item xs={6}>
+            <Paper style={{ height: '100%' }}>
+                <WorkflowDetailsCard workflow={props.workflow} />
+            </Paper>
+        </Grid>
+    </Grid>;
+};
diff --git a/services/workbench2/src/views/workflow-panel/workflow-panel.tsx b/services/workbench2/src/views/workflow-panel/workflow-panel.tsx
new file mode 100644 (file)
index 0000000..9a645d8
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { navigateTo } from 'store/navigation/navigation-action';
+import { WorkflowPanelView } from 'views/workflow-panel/workflow-panel-view';
+import { WorfklowPanelActionProps, WorkflowPanelDataProps } from './workflow-panel-view';
+import { showWorkflowDetails, getWorkflowDetails } from 'store/workflow-panel/workflow-panel-actions';
+import { RootState } from 'store/store';
+
+const mapStateToProps = (state: RootState): WorkflowPanelDataProps => ({
+    workflow: getWorkflowDetails(state)
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): WorfklowPanelActionProps => ({
+    handleRowDoubleClick: (uuid: string) => {
+        dispatch<any>(navigateTo(uuid));
+    },
+
+    handleRowClick: (uuid: string) => {
+        dispatch(showWorkflowDetails(uuid));
+    }
+});
+
+export const WorkflowPanel = connect(mapStateToProps, mapDispatchToProps)(WorkflowPanelView);
diff --git a/services/workbench2/src/views/workflow-panel/workflow-processes-panel-root.tsx b/services/workbench2/src/views/workflow-panel/workflow-processes-panel-root.tsx
new file mode 100644 (file)
index 0000000..64f24a2
--- /dev/null
@@ -0,0 +1,126 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { DataColumns } from 'components/data-table/data-table';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
+import { ContainerRequestState } from 'models/container-request';
+import { SortDirection } from 'components/data-table/data-column';
+import { ResourceKind } from 'models/resource';
+import { ResourceCreatedAtDate, ProcessStatus, ContainerRunTime } from 'views-components/data-explorer/renderers';
+import { ProcessIcon } from 'components/icon/icon';
+import { ResourceName } from 'views-components/data-explorer/renderers';
+import { WORKFLOW_PROCESSES_PANEL_ID } from 'store/workflow-panel/workflow-panel-actions';
+import { createTree } from 'models/tree';
+import { getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
+import { ResourcesState } from 'store/resources/resources';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { StyleRulesCallback, Typography, WithStyles, withStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ProcessResource } from 'models/process';
+
+type CssRules = 'iconHeader' | 'cardHeader';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL,
+        marginRight: theme.spacing.unit * 2,
+    },
+    cardHeader: {
+        display: 'flex',
+    },
+});
+
+export enum WorkflowProcessesPanelColumnNames {
+    NAME = "Name",
+    STATUS = "Status",
+    CREATED_AT = "Created At",
+    RUNTIME = "Run Time"
+}
+
+export interface WorkflowProcessesPanelFilter extends DataTableFilterItem {
+    type: ResourceKind | ContainerRequestState;
+}
+
+export const workflowProcessesPanelColumns: DataColumns<string, ProcessResource> = [
+    {
+        name: WorkflowProcessesPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: "name" },
+        filters: createTree(),
+        render: uuid => <ResourceName uuid={uuid} />
+    },
+    {
+        name: WorkflowProcessesPanelColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        mutuallyExclusiveFilters: true,
+        filters: getInitialProcessStatusFilters(),
+        render: uuid => <ProcessStatus uuid={uuid} />,
+    },
+    {
+        name: WorkflowProcessesPanelColumnNames.CREATED_AT,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.DESC, field: "createdAt" },
+        filters: createTree(),
+        render: uuid => <ResourceCreatedAtDate uuid={uuid} />
+    },
+    {
+        name: WorkflowProcessesPanelColumnNames.RUNTIME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ContainerRunTime uuid={uuid} />
+    }
+];
+
+export interface WorkflowProcessesPanelDataProps {
+    resources: ResourcesState;
+}
+
+export interface WorkflowProcessesPanelActionProps {
+    onItemClick: (item: string) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: string, resources: ResourcesState) => void;
+    onItemDoubleClick: (item: string) => void;
+}
+
+type WorkflowProcessesPanelProps = WorkflowProcessesPanelActionProps & WorkflowProcessesPanelDataProps;
+
+const DEFAULT_VIEW_MESSAGES = [
+    'No processes available for listing.',
+    'The current process may not have any or none matches current filtering.'
+];
+
+type WorkflowProcessesTitleProps = WithStyles<CssRules>;
+
+const WorkflowProcessesTitle = withStyles(styles)(
+    ({ classes }: WorkflowProcessesTitleProps) =>
+        <div className={classes.cardHeader}>
+            <ProcessIcon className={classes.iconHeader} /><span></span>
+            <Typography noWrap variant='h6' color='inherit'>
+                Run History
+            </Typography>
+        </div>
+);
+
+export const WorkflowProcessesPanelRoot = (props: WorkflowProcessesPanelProps & MPVPanelProps) => {
+    return <DataExplorer
+        id={WORKFLOW_PROCESSES_PANEL_ID}
+        onRowClick={props.onItemClick}
+        onRowDoubleClick={props.onItemDoubleClick}
+        onContextMenu={(event, item) => props.onContextMenu(event, item, props.resources)}
+        contextMenuColumn={true}
+        defaultViewIcon={ProcessIcon}
+        defaultViewMessages={DEFAULT_VIEW_MESSAGES}
+        doHidePanel={props.doHidePanel}
+        doMaximizePanel={props.doMaximizePanel}
+        doUnMaximizePanel={props.doUnMaximizePanel}
+        panelMaximized={props.panelMaximized}
+        panelName={props.panelName}
+        title={<WorkflowProcessesTitle />} />;
+};
diff --git a/services/workbench2/src/views/workflow-panel/workflow-processes-panel.tsx b/services/workbench2/src/views/workflow-panel/workflow-processes-panel.tsx
new file mode 100644 (file)
index 0000000..48077f9
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { WorkflowProcessesPanelRoot, WorkflowProcessesPanelActionProps, WorkflowProcessesPanelDataProps } from "views/workflow-panel/workflow-processes-panel-root";
+import { RootState } from "store/store";
+import { navigateTo } from "store/navigation/navigation-action";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { getProcess } from "store/processes/process";
+import { toggleOne, deselectAllOthers } from 'store/multiselect/multiselect-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch): WorkflowProcessesPanelActionProps => ({
+    onContextMenu: (event, resourceUuid, resources) => {
+        const process = getProcess(resourceUuid)(resources);
+        if (process) {
+            dispatch<any>(openProcessContextMenu(event, process));
+        }
+    },
+    onItemClick: (uuid: string) => {
+        dispatch<any>(toggleOne(uuid))
+        dispatch<any>(deselectAllOthers(uuid))
+        dispatch<any>(loadDetailsPanel(uuid));
+    },
+    onItemDoubleClick: uuid => {
+        dispatch<any>(navigateTo(uuid));
+    },
+});
+
+const mapStateToProps = (state: RootState): WorkflowProcessesPanelDataProps => ({
+    resources: state.resources,
+});
+
+export const WorkflowProcessesPanel = connect(mapStateToProps, mapDispatchToProps)(WorkflowProcessesPanelRoot);
diff --git a/services/workbench2/src/websocket/resource-event-message.ts b/services/workbench2/src/websocket/resource-event-message.ts
new file mode 100644 (file)
index 0000000..420f25a
--- /dev/null
@@ -0,0 +1,16 @@
+import { LogEventType } from '../models/log';
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface ResourceEventMessage<Properties = {}> {
+    eventAt: string;
+    eventType: LogEventType;
+    id: string;
+    msgID: string;
+    objectKind: string;
+    objectOwnerUuid: string;
+    objectUuid: string;
+    properties: Properties;
+    uuid: string;
+}
diff --git a/services/workbench2/src/websocket/websocket-service.ts b/services/workbench2/src/websocket/websocket-service.ts
new file mode 100644 (file)
index 0000000..a919449
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AuthService } from 'services/auth-service/auth-service';
+import { ResourceEventMessage } from './resource-event-message';
+import { camelCase } from 'lodash';
+import { CommonResourceService } from "services/common-service/common-resource-service";
+
+type MessageListener = (message: ResourceEventMessage) => void;
+
+export class WebSocketService {
+    private ws: WebSocket;
+    private messageListener: MessageListener;
+
+    constructor(private url: string, private authService: AuthService) { }
+
+    connect() {
+        if (this.ws) {
+            this.ws.close();
+        }
+        this.ws = new WebSocket(this.getUrl());
+        this.ws.addEventListener('message', this.handleMessage);
+        this.ws.addEventListener('open', this.handleOpen);
+    }
+
+    setMessageListener = (listener: MessageListener) => {
+        this.messageListener = listener;
+    }
+
+    private getUrl() {
+        return `${this.url}?api_token=${this.authService.getApiToken()}`;
+    }
+
+    private handleMessage = (event: MessageEvent) => {
+        if (this.messageListener) {
+            const data = JSON.parse(event.data);
+            const message = CommonResourceService.mapKeys(camelCase)(data);
+            this.messageListener(message);
+        }
+    }
+
+    private handleOpen = () => {
+        this.ws.send('{"method":"subscribe"}');
+    }
+
+}
diff --git a/services/workbench2/src/websocket/websocket.ts b/services/workbench2/src/websocket/websocket.ts
new file mode 100644 (file)
index 0000000..1b74b11
--- /dev/null
@@ -0,0 +1,76 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootStore } from "store/store";
+import { AuthService } from "services/auth-service/auth-service";
+import { Config } from "common/config";
+import { WebSocketService } from "./websocket-service";
+import { ResourceEventMessage } from "./resource-event-message";
+import { ResourceKind } from "models/resource";
+import { loadProcess } from "store/processes/processes-actions";
+import { getProcess, getSubprocesses } from "store/processes/process";
+import { LogEventType } from "models/log";
+import { subprocessPanelActions } from "store/subprocess-panel/subprocess-panel-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { getProjectPanelCurrentUuid } from "store/project-panel/project-panel-action";
+import { allProcessesPanelActions } from "store/all-processes-panel/all-processes-panel-action";
+import { loadCollection } from "store/workbench/workbench-actions";
+import { matchAllProcessesRoute, matchProjectRoute, matchProcessRoute } from "routes/routes";
+
+export const initWebSocket = (config: Config, authService: AuthService, store: RootStore) => {
+    if (config.websocketUrl) {
+        const webSocketService = new WebSocketService(config.websocketUrl, authService);
+        webSocketService.setMessageListener(messageListener(store));
+        webSocketService.connect();
+    } else {
+        console.warn("WARNING: Websocket ExternalURL is not set on the cluster config.");
+    }
+};
+
+const messageListener = (store: RootStore) => (message: ResourceEventMessage) => {
+    if (message.eventType === LogEventType.CREATE || message.eventType === LogEventType.UPDATE) {
+        const state = store.getState();
+        const location = state.router.location ? state.router.location.pathname : "";
+        switch (message.objectKind) {
+            case ResourceKind.COLLECTION:
+                const currentCollection = state.collectionPanel.item;
+                if (currentCollection && currentCollection.uuid === message.objectUuid) {
+                    store.dispatch(loadCollection(message.objectUuid));
+                }
+                return;
+            case ResourceKind.CONTAINER_REQUEST:
+                if (matchProcessRoute(location)) {
+                    if (state.processPanel.containerRequestUuid === message.objectUuid) {
+                        store.dispatch(loadProcess(message.objectUuid));
+                    }
+                    const proc = getProcess(state.processPanel.containerRequestUuid)(state.resources);
+                    if (proc && proc.container && proc.container.uuid === message.properties["new_attributes"]["requesting_container_uuid"]) {
+                        store.dispatch(subprocessPanelActions.REQUEST_ITEMS(false, true));
+                        return;
+                    }
+                }
+            // fall through, this will happen for container requests as well.
+            case ResourceKind.CONTAINER:
+                if (matchProcessRoute(location)) {
+                    // refresh only if this is a subprocess of the currently displayed process.
+                    const subproc = getSubprocesses(state.processPanel.containerRequestUuid)(state.resources);
+                    for (const sb of subproc) {
+                        if (sb.containerRequest.uuid === message.objectUuid || (sb.container && sb.container.uuid === message.objectUuid)) {
+                            store.dispatch(subprocessPanelActions.REQUEST_ITEMS(false, true));
+                            break;
+                        }
+                    }
+                }
+                if (matchAllProcessesRoute(location)) {
+                    store.dispatch(allProcessesPanelActions.REQUEST_ITEMS(false, true));
+                }
+                if (matchProjectRoute(location) && message.objectOwnerUuid === getProjectPanelCurrentUuid(state)) {
+                    store.dispatch(projectPanelActions.REQUEST_ITEMS(false, true));
+                }
+                return;
+            default:
+                return;
+        }
+    }
+};
diff --git a/services/workbench2/tools/arvados_config.yml b/services/workbench2/tools/arvados_config.yml
new file mode 100644 (file)
index 0000000..ba41c51
--- /dev/null
@@ -0,0 +1,40 @@
+Clusters:
+  zzzzz:
+    ManagementToken: e687950a23c3a9bceec28c6223a06c79
+    SystemRootToken: systemusertesttoken1234567890aoeuidhtnsqjkxbmwvzpy
+    API:
+      RequestTimeout: 30s
+      VocabularyPath: ""
+      MaxTokenLifetime: 24h
+    TLS:
+      Insecure: true
+    Collections:
+      CollectionVersioning: true
+      PreserveVersionIfIdle: -1s
+      BlobSigningKey: zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc
+      TrustAllContent: true
+      ForwardSlashNameSubstitution: /
+      ManagedProperties:
+        original_owner_uuid: {Function: original_owner, Protected: true}
+    Login:
+      TrustPrivateNetworks: true
+      Test:
+        Enable: true
+        Users:
+          randomuser1234:
+            Email: randomuser1234@example.invalid
+            Password: topsecret
+    StorageClasses:
+      default:
+        Default: true
+      foo: {}
+      bar: {}
+    Volumes:
+      zzzzz-nyw5e-000000000000000:
+        StorageClasses:
+          default: true
+          foo: true
+      zzzzz-nyw5e-000000000000001:
+        StorageClasses:
+          default: true
+          bar: true
diff --git a/services/workbench2/tools/example-vocabulary.json b/services/workbench2/tools/example-vocabulary.json
new file mode 100644 (file)
index 0000000..59d4de7
--- /dev/null
@@ -0,0 +1,213 @@
+{
+    "strict_tags": false,
+    "tags": {
+        "IDTAGFRUITS": {
+            "strict": false,
+            "labels": [
+                {"label": "Fruit"}
+            ],
+            "values": {
+                "IDVALFRUITS1": {
+                    "labels": [
+                        {"label": "Pineapple"}
+                    ]
+                },
+                "IDVALFRUITS2": {
+                    "labels": [
+                        {"label": "Tomato"}
+                    ]
+                },
+                "IDVALFRUITS3": {
+                    "labels": [
+                        {"label": "Orange"}
+                    ]
+                },
+                "IDVALFRUITS4": {
+                    "labels": [
+                        {"label": "Banana"}
+                    ]
+                },
+                "IDVALFRUITS5": {
+                    "labels": [
+                        {"label": "Advocado"}
+                    ]
+                },
+                "IDVALFRUITS6": {
+                    "labels": [
+                        {"label": "Lemon"}
+                    ]
+                },
+                "IDVALFRUITS7": {
+                    "labels": [
+                        {"label": "Apple"}
+                    ]
+                },
+                "IDVALFRUITS8": {
+                    "labels": [
+                        {"label": "Peach"}
+                    ]
+                },
+                "IDVALFRUITS9": {
+                    "labels": [
+                        {"label": "Strawberry"}
+                    ]
+                }
+            }
+        },
+        "IDTAGANIMALS": {
+            "strict": false,
+            "labels": [
+                {"label": "Animal" },
+                {"label": "Creature"}
+            ],
+            "values": {
+                "IDVALANIMALS1": {
+                    "labels": [
+                        {"label": "Human"},
+                        {"label": "Homo sapiens"}
+                    ]
+                },
+                "IDVALANIMALS2": {
+                    "labels": [
+                        {"label": "Dog"},
+                        {"label": "Canis lupus familiaris"}
+                    ]
+                },
+                "IDVALANIMALS3": {
+                    "labels": [
+                        {"label": "Elephant"},
+                        {"label": "Loxodonta"}
+                    ]
+                },
+                "IDVALANIMALS4": {
+                    "labels": [
+                        {"label": "Eagle"},
+                        {"label": "Haliaeetus leucocephalus"}
+                    ]
+                }
+            }
+        },
+        "IDTAGCOLORS": {
+            "strict": false,
+            "labels": [
+                {"label": "Color"}
+            ],
+            "values": {
+                "IDVALCOLORS1": {
+                    "labels": [
+                        {"label": "Yellow"}
+                    ]
+                },
+                "IDVALCOLORS2": {
+                    "labels": [
+                        {"label": "Red"}
+                    ]
+                },
+                "IDVALCOLORS3": {
+                    "labels": [
+                        {"label": "Magenta"}
+                    ]
+                },
+                "IDVALCOLORS4": {
+                    "labels": [
+                        {"label": "Green"}
+                    ]
+                }
+            }
+        },
+        "IDTAGCOMMENT": {
+            "labels": [
+                {"label": "Comment"},
+                {"label": "Text"}
+            ]
+        },
+        "IDTAGCATEGORIES": {
+            "strict": true,
+            "labels": [
+                {"label": "Category"}
+            ],
+            "values": {
+                "IDTAGCAT1": {
+                    "labels": [
+                        {"label": "Experimental"}
+                    ]
+                },
+                "IDTAGCAT2": {
+                    "labels": [
+                        {"label": "Development"}
+                    ]
+                },
+                "IDTAGCAT3": {
+                    "labels": [
+                        {"label": "Production"}
+                    ]
+                }
+            }
+        },
+        "IDTAGIMPORTANCES": {
+            "strict": true,
+            "labels": [
+                {"label": "Importance"},
+                {"label": "Priority"}
+            ],
+            "values": {
+                "IDVALIMPORTANCES1": {
+                    "labels": [
+                        {"label": "Critical"},
+                        {"label": "Urgent"},
+                        {"label": "High"}
+                    ]
+                },
+                "IDVALIMPORTANCES2": {
+                    "labels": [
+                        {"label": "Normal"},
+                        {"label": "Moderate"}
+                    ]
+                },
+                "IDVALIMPORTANCES3": {
+                    "labels": [
+                        {"label": "Low"}
+                    ]
+                }
+            }
+        },
+        "IDTAGSIZES": {
+            "strict": true,
+            "labels": [
+                {"label": "Size"}
+            ],
+            "values": {
+                "IDVALSIZES1": {
+                    "labels": [
+                        {"label": "XS"},
+                        {"label": "x-small"}
+                    ]
+                },
+                "IDVALSIZES2": {
+                    "labels": [
+                        {"label": "S"},
+                        {"label": "small"}
+                    ]
+                },
+                "IDVALSIZES3": {
+                    "labels": [
+                        {"label": "M"},
+                        {"label": "medium"}
+                    ]
+                },
+                "IDVALSIZES4": {
+                    "labels": [
+                        {"label": "L"},
+                        {"label": "large"}
+                    ]
+                },
+                "IDVALSIZES5": {
+                    "labels": [
+                        {"label": "XL"},
+                        {"label": "x-large"}
+                    ]
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/services/workbench2/tools/run-integration-tests.sh b/services/workbench2/tools/run-integration-tests.sh
new file mode 100755 (executable)
index 0000000..b2745b3
--- /dev/null
@@ -0,0 +1,142 @@
+#!/bin/bash
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+set -e -o pipefail
+
+cleanup() {
+    set -x
+    set +e +o pipefail
+    kill ${arvboot_PID} ${consume_stdout_PID} ${wb2_PID} ${consume_wb2_stdout_PID}
+    wait ${arvboot_PID} ${consume_stdout_PID} ${wb2_PID} ${consume_wb2_stdout_PID} || true
+    echo >&2 "done"
+}
+
+random_free_port() {
+    while port=$(shuf -n1 -i $(cat /proc/sys/net/ipv4/ip_local_port_range | tr '\011' '-'))
+    netstat -atun | grep -q ":$port\s" ; do
+        continue
+    done
+    echo $port
+}
+
+usage() {
+    echo "Usage: ${0} [options]"
+    echo "Options:"
+    echo "  -i            Run Cypress in interactive mode."
+    echo "  -a PATH       Arvados dir. If PATH doesn't exist, a repo clone is downloaded there."
+    echo "  -w PATH       Workbench2 dir. Default: Current working directory"
+    exit 0
+}
+
+# Allow self-signed certs on 'wait-on'
+export NODE_TLS_REJECT_UNAUTHORIZED=0
+
+ARVADOS_DIR="unset"
+CYPRESS_MODE="run"
+WB2_DIR=`pwd`
+
+while getopts "ia:w:" o; do
+    case "${o}" in
+        i)
+            # Interactive mode
+            CYPRESS_MODE="open"
+            ;;
+        a)
+            ARVADOS_DIR=${OPTARG}
+            ;;
+        w)
+            WB2_DIR=${OPTARG}
+            ;;
+        *)
+            echo "Invalid Option: -$OPTARG" 1>&2
+            usage
+            ;;
+    esac
+done
+shift $((OPTIND-1))
+
+if [ "${ARVADOS_DIR}" = "unset" ]; then
+  echo "ARVADOS_DIR is unset, using git working dir"
+  ARVADOS_DIR=$(env -C "$WB2_DIR" git rev-parse --show-toplevel)
+fi
+
+echo "ARVADOS_DIR is ${ARVADOS_DIR}"
+
+ARVADOS_LOG=${ARVADOS_DIR}/arvados.log
+ARVADOS_CONF=${WB2_DIR}/tools/arvados_config.yml
+VOCABULARY_CONF=${WB2_DIR}/tools/example-vocabulary.json
+
+if [ ! -f "${WB2_DIR}/src/index.tsx" ]; then
+    echo "ERROR: '${WB2_DIR}' isn't workbench2's directory"
+    usage
+fi
+
+if [ ! -f ${ARVADOS_CONF} ]; then
+    echo "ERROR: Arvados config file ${ARVADOS_CONF} not found"
+    exit 1
+fi
+
+if [ -f "${WB2_DIR}/public/config.json" ]; then
+    echo "ERROR: Please move public/config.json file out of the way"
+    exit 1
+fi
+
+GOPATH="$(go env GOPATH)"
+
+if [ ! -x ${GOPATH}/bin/arvados-server ]; then
+    echo "Building & installing arvados-server..."
+    cd ${ARVADOS_DIR}
+    GOFLAGS=-buildvcs=false go mod download || exit 1
+    cd cmd/arvados-server
+    GOFLAGS=-buildvcs=false go install
+    cd -
+
+    echo "Installing dev dependencies..."
+    ${GOPATH}/bin/arvados-server install -type test || exit 1
+fi
+
+echo "Launching arvados in test mode..."
+TMPSUBDIR=$(mktemp -d -p /tmp | cut -d \/ -f3) # Removes the /tmp/ part for the regex below
+TMPDIR=/tmp/${TMPSUBDIR}
+cp ${VOCABULARY_CONF} ${TMPDIR}/voc.json
+cp ${ARVADOS_CONF} ${TMPDIR}/arvados.yml
+sed -i "s/VocabularyPath: \".*\"/VocabularyPath: \"\/tmp\/${TMPSUBDIR}\/voc.json\"/" ${TMPDIR}/arvados.yml
+coproc arvboot (${GOPATH}/bin/arvados-server boot \
+    -type test \
+    -source "${ARVADOS_DIR}" \
+    -config ${TMPDIR}/arvados.yml \
+    -no-workbench1 \
+    -no-workbench2 \
+    -own-temporary-database \
+    -timeout 20m 2> ${ARVADOS_LOG})
+trap cleanup ERR EXIT
+
+read controllerInfo <&"${arvboot[0]}" || exit 1
+controllerURL=`echo "$controllerInfo" | awk '{print $1;}'`;
+echo "Arvados up and running at ${controllerURL}"
+IFS='/' ; read -ra controllerHostPort <<< "${controllerURL}" ; unset IFS
+controllerHostPort=${controllerHostPort[2]}
+
+# Copy coproc's stdout to stderr, to ensure `arvados-server boot`
+# doesn't get blocked trying to write stdout.
+exec 7<&"${arvboot[0]}"; coproc consume_stdout (cat <&7 >&2)
+
+cd ${WB2_DIR}
+echo "Launching workbench2..."
+WB2_PORT=`random_free_port`
+coproc wb2 (PORT=${WB2_PORT} \
+    REACT_APP_ARVADOS_API_HOST=${controllerHostPort} \
+    yarn start)
+exec 8<&"${wb2[0]}"; coproc consume_wb2_stdout (cat <&8 >&2)
+
+# Wait for workbench2 to be up.
+# Using https-get to avoid false positive 'ready' detection.
+yarn run wait-on --timeout 300000 https-get://127.0.0.1:${WB2_PORT} || exit 1
+
+echo "Running tests..."
+CYPRESS_system_token=systemusertesttoken1234567890aoeuidhtnsqjkxbmwvzpy \
+    CYPRESS_controller_url=${controllerURL} \
+    CYPRESS_BASE_URL=https://127.0.0.1:${WB2_PORT} \
+    yarn run cypress ${CYPRESS_MODE} "$@"
diff --git a/services/workbench2/tsconfig.json b/services/workbench2/tsconfig.json
new file mode 100644 (file)
index 0000000..08f7108
--- /dev/null
@@ -0,0 +1,52 @@
+{
+  "compilerOptions": {
+    "outDir": "build/dist",
+    "module": "esnext",
+    "target": "es5",
+    "lib": [
+      "es6",
+      "es2020",
+      "dom"
+    ],
+    "sourceMap": true,
+    "allowJs": true,
+    "jsx": "preserve",
+    "moduleResolution": "node",
+    "rootDir": "src",
+    "baseUrl": "src",
+    "forceConsistentCasingInFileNames": true,
+    "noImplicitReturns": true,
+    "noImplicitThis": true,
+    "noImplicitAny": false,
+    "strictNullChecks": true,
+    "suppressImplicitAnyIndexErrors": true,
+    "noUnusedLocals": false,
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "strict": false,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "incremental": true,
+    "noEmit": true,
+    "alwaysStrict": false,
+    "strictFunctionTypes": false,
+    "strictPropertyInitialization": false,
+  },
+  "exclude": [
+    "node_modules",
+    "build",
+    "scripts",
+    "acceptance-tests",
+    "webpack",
+    "jest",
+    "cypress",
+    "src/setupTests.ts",
+    "**/*.test.tsx"
+  ],
+  "include": [
+    "src"
+  ]
+}
diff --git a/services/workbench2/tsconfig.prod.json b/services/workbench2/tsconfig.prod.json
new file mode 100644 (file)
index 0000000..4144216
--- /dev/null
@@ -0,0 +1,3 @@
+{
+  "extends": "./tsconfig.json"
+}
\ No newline at end of file
diff --git a/services/workbench2/tsconfig.test.json b/services/workbench2/tsconfig.test.json
new file mode 100644 (file)
index 0000000..2c7b284
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "module": "commonjs"
+  }
+}
diff --git a/services/workbench2/tslint.json b/services/workbench2/tslint.json
new file mode 100644 (file)
index 0000000..2a7c3c0
--- /dev/null
@@ -0,0 +1,33 @@
+{
+  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier", "tslint-etc"],
+  "rules": {
+    "ordered-imports": false,
+    "member-ordering": false,
+    "object-literal-sort-keys": false,
+    "interface-name": false,
+    "no-empty-interface": false,
+    "member-access": false,
+    "jsx-boolean-value": false,
+    "jsx-no-lambda": false,
+    "no-debugger": false,
+    "no-console": false,
+    "no-shadowed-variable": false,
+    "semicolon": true,
+    "array-type": false,
+    "interface-over-type-literal": false,
+    "no-empty": false,
+    "no-bitwise": false,
+    "ban-types": false,
+    "no-unused-declaration": true
+  },
+  "linterOptions": {
+    "exclude": [
+      "config/**/*.js",
+      "node_modules/**/*.ts",
+      "src/lib/**",
+      "src/**/*.test.ts",
+      "coverage/lcov-report/*.js",
+      "src/common/custom-theme.ts"
+    ]
+  }
+}
diff --git a/services/workbench2/typings/global.d.ts b/services/workbench2/typings/global.d.ts
new file mode 100644 (file)
index 0000000..93aa3cf
--- /dev/null
@@ -0,0 +1,20 @@
+declare interface Window {
+  __REDUX_DEVTOOLS_EXTENSION__: any;
+  __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any;
+}
+
+declare interface NodeModule {
+  hot?: { accept: (path: string, callback: () => void) => void };
+}
+
+declare interface System {
+  import<T = any>(module: string): Promise<T>
+}
+declare var System: System;
+
+declare module 'react-splitter-layout';
+declare module 'react-rte';
+
+declare module 'is-image' {
+  export default function isImage(value: string): boolean;
+}
\ No newline at end of file
diff --git a/services/workbench2/typings/images.d.ts b/services/workbench2/typings/images.d.ts
new file mode 100644 (file)
index 0000000..397cc9b
--- /dev/null
@@ -0,0 +1,3 @@
+declare module '*.svg'
+declare module '*.png'
+declare module '*.jpg'
diff --git a/services/workbench2/version-at-commit.sh b/services/workbench2/version-at-commit.sh
new file mode 100755 (executable)
index 0000000..e42b875
--- /dev/null
@@ -0,0 +1,51 @@
+#!/bin/bash
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+set -e -o pipefail
+commit="$1"
+versionglob="[0-9].[0-9]*.[0-9]*"
+devsuffix="~dev"
+
+# automatically assign version
+#
+# handles the following cases:
+#
+# 1. commit is directly tagged.  print that.
+#
+# 2. commit is on main or a development branch, the nearest tag is older
+#    than commit where this branch joins main.
+#    -> take greatest version tag in repo X.Y.Z and assign X.(Y+1).0
+#
+# 3. commit is on a release branch, the nearest tag is newer
+#    than the commit where this branch joins main.
+#    -> take nearest tag X.Y.Z and assign X.Y.(Z+1)
+
+tagged=$(git tag --points-at "$commit")
+
+if [[ -n "$tagged" ]] ; then
+    echo $tagged
+else
+    # 1. get the nearest tag with 'git describe'
+    # 2. get the merge base between this commit and main
+    # 3. if the tag is an ancestor of the merge base,
+    #    (tag is older than merge base) increment minor version
+    #    else, tag is newer than merge base, so increment point version
+
+    nearest_tag=$(git describe --tags --abbrev=0 --match "$versionglob" "$commit")
+    merge_base=$(git merge-base origin/main "$commit")
+
+    if git merge-base --is-ancestor "$nearest_tag" "$merge_base" ; then
+        # x.(y+1).0~devTIMESTAMP, where x.y.z is the newest version that does not contain $commit
+       # grep reads the list of tags (-f) that contain $commit and filters them out (-v)
+       # this prevents a newer tag from retroactively changing the versions of everything before it
+        v=$(git tag | grep -vFf <(git tag --contains "$commit") | sort -Vr | head -n1 | perl -pe 's/(\d+)\.(\d+)\.\d+.*/"$1.".($2+1).".0"/e')
+    else
+        # x.y.(z+1)~devTIMESTAMP, where x.y.z is the latest released ancestor of $commit
+        v=$(echo $nearest_tag | perl -pe 's/(\d+)$/$1+1/e')
+    fi
+    isodate=$(TZ=UTC git log -n1 --format=%cd --date=iso "$commit")
+    ts=$(TZ=UTC date --date="$isodate" "+%Y%m%d%H%M%S")
+    echo "${v}${devsuffix}${ts}"
+fi
diff --git a/services/workbench2/yarn.lock b/services/workbench2/yarn.lock
new file mode 100644 (file)
index 0000000..de1da9a
--- /dev/null
@@ -0,0 +1,20227 @@
+# This file is generated by running "yarn install" inside your project.
+# Manual changes might be lost - proceed with caution!
+
+__metadata:
+  version: 6
+  cacheKey: 8
+
+"@ampproject/remapping@npm:^2.2.0":
+  version: 2.3.0
+  resolution: "@ampproject/remapping@npm:2.3.0"
+  dependencies:
+    "@jridgewell/gen-mapping": ^0.3.5
+    "@jridgewell/trace-mapping": ^0.3.24
+  checksum: d3ad7b89d973df059c4e8e6d7c972cbeb1bb2f18f002a3bd04ae0707da214cb06cc06929b65aa2313b9347463df2914772298bae8b1d7973f246bb3f2ab3e8f0
+  languageName: node
+  linkType: hard
+
+"@babel/code-frame@npm:7.8.3":
+  version: 7.8.3
+  resolution: "@babel/code-frame@npm:7.8.3"
+  dependencies:
+    "@babel/highlight": ^7.8.3
+  checksum: 5f3172b0c8d5db625fb88c9f6ab909cb164645152176dfa14c927c19c0774c41fa9ba494cb19cb5d152a414bd6732c41eae708f9f635e02a4ed0889ac239fe4c
+  languageName: node
+  linkType: hard
+
+"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.14.5, @babel/code-frame@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/code-frame@npm:7.14.5"
+  dependencies:
+    "@babel/highlight": ^7.14.5
+  checksum: 0adbe4f8d91586f764f524e57631f582ab988b2ef504391a5d89db29bfaaf7c67c237798ed4a249b6a2d7135852cf94d3d07ce6b9739dd1df1f271d5ed069565
+  languageName: node
+  linkType: hard
+
+"@babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.24.1, @babel/code-frame@npm:^7.24.2":
+  version: 7.24.2
+  resolution: "@babel/code-frame@npm:7.24.2"
+  dependencies:
+    "@babel/highlight": ^7.24.2
+    picocolors: ^1.0.0
+  checksum: 70e867340cfe09ca5488b2f36372c45cabf43c79a5b6426e6df5ef0611ff5dfa75a57dda841895693de6008f32c21a7c97027a8c7bcabd63a7d17416cbead6f8
+  languageName: node
+  linkType: hard
+
+"@babel/compat-data@npm:^7.13.11, @babel/compat-data@npm:^7.14.5, @babel/compat-data@npm:^7.14.7, @babel/compat-data@npm:^7.9.0":
+  version: 7.14.7
+  resolution: "@babel/compat-data@npm:7.14.7"
+  checksum: dcf7a72cb650206857a98cce1ab0973e67689f19afc3b30cabff6dbddf563f188d54d3b3f92a70c6bc1feb9049d8b2e601540e1d435b6866c77bffad0a441c9f
+  languageName: node
+  linkType: hard
+
+"@babel/compat-data@npm:^7.23.5":
+  version: 7.24.4
+  resolution: "@babel/compat-data@npm:7.24.4"
+  checksum: 52ce371658dc7796c9447c9cb3b9c0659370d141b76997f21c5e0028cca4d026ca546b84bc8d157ce7ca30bd353d89f9238504eb8b7aefa9b1f178b4c100c2d4
+  languageName: node
+  linkType: hard
+
+"@babel/core@npm:7.9.0":
+  version: 7.9.0
+  resolution: "@babel/core@npm:7.9.0"
+  dependencies:
+    "@babel/code-frame": ^7.8.3
+    "@babel/generator": ^7.9.0
+    "@babel/helper-module-transforms": ^7.9.0
+    "@babel/helpers": ^7.9.0
+    "@babel/parser": ^7.9.0
+    "@babel/template": ^7.8.6
+    "@babel/traverse": ^7.9.0
+    "@babel/types": ^7.9.0
+    convert-source-map: ^1.7.0
+    debug: ^4.1.0
+    gensync: ^1.0.0-beta.1
+    json5: ^2.1.2
+    lodash: ^4.17.13
+    resolve: ^1.3.2
+    semver: ^5.4.1
+    source-map: ^0.5.0
+  checksum: 0886b35c9cda80628bc61e47172c79d51ab1d1e693f95c037df371bf0a84ca5cd72c0183fb3d01f47c59395d7805d2e79d46660488d73b9966db5fb726ad561c
+  languageName: node
+  linkType: hard
+
+"@babel/core@npm:^7.0.0":
+  version: 7.24.4
+  resolution: "@babel/core@npm:7.24.4"
+  dependencies:
+    "@ampproject/remapping": ^2.2.0
+    "@babel/code-frame": ^7.24.2
+    "@babel/generator": ^7.24.4
+    "@babel/helper-compilation-targets": ^7.23.6
+    "@babel/helper-module-transforms": ^7.23.3
+    "@babel/helpers": ^7.24.4
+    "@babel/parser": ^7.24.4
+    "@babel/template": ^7.24.0
+    "@babel/traverse": ^7.24.1
+    "@babel/types": ^7.24.0
+    convert-source-map: ^2.0.0
+    debug: ^4.1.0
+    gensync: ^1.0.0-beta.2
+    json5: ^2.2.3
+    semver: ^6.3.1
+  checksum: 15ecad7581f3329995956ba461961b1af7bed48901f14fe962ccd3217edca60049e9e6ad4ce48134618397e6c90230168c842e2c28e47ef1f16c97dbbf663c61
+  languageName: node
+  linkType: hard
+
+"@babel/core@npm:^7.1.0, @babel/core@npm:^7.4.5":
+  version: 7.14.6
+  resolution: "@babel/core@npm:7.14.6"
+  dependencies:
+    "@babel/code-frame": ^7.14.5
+    "@babel/generator": ^7.14.5
+    "@babel/helper-compilation-targets": ^7.14.5
+    "@babel/helper-module-transforms": ^7.14.5
+    "@babel/helpers": ^7.14.6
+    "@babel/parser": ^7.14.6
+    "@babel/template": ^7.14.5
+    "@babel/traverse": ^7.14.5
+    "@babel/types": ^7.14.5
+    convert-source-map: ^1.7.0
+    debug: ^4.1.0
+    gensync: ^1.0.0-beta.2
+    json5: ^2.1.2
+    semver: ^6.3.0
+    source-map: ^0.5.0
+  checksum: 6ede604d8de7a103c087b96a58548a3d27efb9e53de6ecc84f4b4ca947cd91f02b0289fc04557b04eb6e31243dbeabdcdb8fd520a1780f284333f56eb1b58913
+  languageName: node
+  linkType: hard
+
+"@babel/generator@npm:^7.14.5, @babel/generator@npm:^7.4.0, @babel/generator@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/generator@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+    jsesc: ^2.5.1
+    source-map: ^0.5.0
+  checksum: 7fcfeaf17e8e76ea91c66dc86c776d2112f52ce0315d3f4ca6a74b6eada0be1592d1ea6286d7241d3f634b63717ceef5d180d041a0b3dca9d071ba2e5fa7c77b
+  languageName: node
+  linkType: hard
+
+"@babel/generator@npm:^7.24.1, @babel/generator@npm:^7.24.4":
+  version: 7.24.4
+  resolution: "@babel/generator@npm:7.24.4"
+  dependencies:
+    "@babel/types": ^7.24.0
+    "@jridgewell/gen-mapping": ^0.3.5
+    "@jridgewell/trace-mapping": ^0.3.25
+    jsesc: ^2.5.1
+  checksum: 1b6146c31386c9df3eb594a2c36b5c98da4f67f7c06edb3d68a442b92516b21bb5ba3ad7dbe0058fe76625ed24d66923e15c95b0df75ef1907d4068921a699b8
+  languageName: node
+  linkType: hard
+
+"@babel/helper-annotate-as-pure@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-annotate-as-pure@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: 18cefedda60003c2551dabe0e4ad278ef0507682680892c60e9f7cb75ae1dc9a065cddb3ce9964da76f220bf972af5262619eeac4b84c2b8aba1b031961215cc
+  languageName: node
+  linkType: hard
+
+"@babel/helper-builder-binary-assignment-operator-visitor@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-builder-binary-assignment-operator-visitor@npm:7.14.5"
+  dependencies:
+    "@babel/helper-explode-assignable-expression": ^7.14.5
+    "@babel/types": ^7.14.5
+  checksum: 0d3571edff0a96d625503a3fd79643f66f8a5204e75c4351276c0d194240e1debe322a70ef9ff47952bd77ac76792f42d732922b00b5bd8b6e2c99909dc4f49b
+  languageName: node
+  linkType: hard
+
+"@babel/helper-compilation-targets@npm:^7.13.0, @babel/helper-compilation-targets@npm:^7.14.5, @babel/helper-compilation-targets@npm:^7.8.7":
+  version: 7.14.5
+  resolution: "@babel/helper-compilation-targets@npm:7.14.5"
+  dependencies:
+    "@babel/compat-data": ^7.14.5
+    "@babel/helper-validator-option": ^7.14.5
+    browserslist: ^4.16.6
+    semver: ^6.3.0
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 02df2c6d1bc5f2336f380945aa266a3a65d057c5eff6be667235a8005048b21f69e4aaebc8e43ccfc2fb406688383ae8e572f257413febf244772e5e7af5fd7f
+  languageName: node
+  linkType: hard
+
+"@babel/helper-compilation-targets@npm:^7.23.6":
+  version: 7.23.6
+  resolution: "@babel/helper-compilation-targets@npm:7.23.6"
+  dependencies:
+    "@babel/compat-data": ^7.23.5
+    "@babel/helper-validator-option": ^7.23.5
+    browserslist: ^4.22.2
+    lru-cache: ^5.1.1
+    semver: ^6.3.1
+  checksum: c630b98d4527ac8fe2c58d9a06e785dfb2b73ec71b7c4f2ddf90f814b5f75b547f3c015f110a010fd31f76e3864daaf09f3adcd2f6acdbfb18a8de3a48717590
+  languageName: node
+  linkType: hard
+
+"@babel/helper-create-class-features-plugin@npm:^7.14.5, @babel/helper-create-class-features-plugin@npm:^7.14.6, @babel/helper-create-class-features-plugin@npm:^7.8.3":
+  version: 7.14.6
+  resolution: "@babel/helper-create-class-features-plugin@npm:7.14.6"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.14.5
+    "@babel/helper-function-name": ^7.14.5
+    "@babel/helper-member-expression-to-functions": ^7.14.5
+    "@babel/helper-optimise-call-expression": ^7.14.5
+    "@babel/helper-replace-supers": ^7.14.5
+    "@babel/helper-split-export-declaration": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 9d9c3c6f469bc5da4e5819979d0f70bf8a824967661743800741b5560cfa3cf811d52ab14dc00dd6e839814f8db39cf3118c08d550c487680969c40c9ccf2e2a
+  languageName: node
+  linkType: hard
+
+"@babel/helper-create-regexp-features-plugin@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-create-regexp-features-plugin@npm:7.14.5"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.14.5
+    regexpu-core: ^4.7.1
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: c2636d0a6ea6d57eb3603ba9b223fd6ec273a3d8171eb8d84a357ff028cd747ab383b1d7cef84a4df5f9aebb321d43599895f562f3c8aa96314d4847aa59710e
+  languageName: node
+  linkType: hard
+
+"@babel/helper-define-polyfill-provider@npm:^0.2.2":
+  version: 0.2.3
+  resolution: "@babel/helper-define-polyfill-provider@npm:0.2.3"
+  dependencies:
+    "@babel/helper-compilation-targets": ^7.13.0
+    "@babel/helper-module-imports": ^7.12.13
+    "@babel/helper-plugin-utils": ^7.13.0
+    "@babel/traverse": ^7.13.0
+    debug: ^4.1.1
+    lodash.debounce: ^4.0.8
+    resolve: ^1.14.2
+    semver: ^6.1.2
+  peerDependencies:
+    "@babel/core": ^7.4.0-0
+  checksum: 797699fe870e45bdbc7c4128963427f7d6240609b700b3f2c0a2f2f187e5f848ba704bcfe58d7d91796cabc5001fae01746b3efda113beb5b5b824927cf59fdb
+  languageName: node
+  linkType: hard
+
+"@babel/helper-environment-visitor@npm:^7.22.20":
+  version: 7.22.20
+  resolution: "@babel/helper-environment-visitor@npm:7.22.20"
+  checksum: d80ee98ff66f41e233f36ca1921774c37e88a803b2f7dca3db7c057a5fea0473804db9fb6729e5dbfd07f4bed722d60f7852035c2c739382e84c335661590b69
+  languageName: node
+  linkType: hard
+
+"@babel/helper-explode-assignable-expression@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-explode-assignable-expression@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: f3b34c54ad26e48e1409f21aaac8ee5b5fa3bd2917ce4df496f57daec12b6132b2d5c2618da807458e97bc2d7894c5bf505cc96789e0c289dcc9948d7844bb03
+  languageName: node
+  linkType: hard
+
+"@babel/helper-function-name@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-function-name@npm:7.14.5"
+  dependencies:
+    "@babel/helper-get-function-arity": ^7.14.5
+    "@babel/template": ^7.14.5
+    "@babel/types": ^7.14.5
+  checksum: fd8ffa82f7622b6e9a6294fb3b98b42e743ab2a8e3c329367667a960b5b98b48bc5ebf8be7308981f1985b9f3c69e1a3b4a91c8944ae97c31803240da92fb3c8
+  languageName: node
+  linkType: hard
+
+"@babel/helper-function-name@npm:^7.23.0":
+  version: 7.23.0
+  resolution: "@babel/helper-function-name@npm:7.23.0"
+  dependencies:
+    "@babel/template": ^7.22.15
+    "@babel/types": ^7.23.0
+  checksum: e44542257b2d4634a1f979244eb2a4ad8e6d75eb6761b4cfceb56b562f7db150d134bc538c8e6adca3783e3bc31be949071527aa8e3aab7867d1ad2d84a26e10
+  languageName: node
+  linkType: hard
+
+"@babel/helper-get-function-arity@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-get-function-arity@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: a60779918b677a35e177bb4f46babfd54e9790587b6a4f076092a9eff2a940cbeacdeb10c94331b26abfe838769554d72293d16df897246cfccd1444e5e27cb7
+  languageName: node
+  linkType: hard
+
+"@babel/helper-hoist-variables@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-hoist-variables@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: 35af58eebffca10988de7003e044ce2d27212aea72ac6d2c4604137da7f1e193cc694d8d60805d0d0beaf3d990f6f2dcc2622c52e3d3148e37017a29cacf2e56
+  languageName: node
+  linkType: hard
+
+"@babel/helper-hoist-variables@npm:^7.22.5":
+  version: 7.22.5
+  resolution: "@babel/helper-hoist-variables@npm:7.22.5"
+  dependencies:
+    "@babel/types": ^7.22.5
+  checksum: 394ca191b4ac908a76e7c50ab52102669efe3a1c277033e49467913c7ed6f7c64d7eacbeabf3bed39ea1f41731e22993f763b1edce0f74ff8563fd1f380d92cc
+  languageName: node
+  linkType: hard
+
+"@babel/helper-member-expression-to-functions@npm:^7.14.5":
+  version: 7.14.7
+  resolution: "@babel/helper-member-expression-to-functions@npm:7.14.7"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: 1768b849224002d7a8553226ad73e1e957fb6184b68234d5df7a45cf8e4453ed1208967c1cace1a4d973b223ddc881d105e372945ec688f09485dff0e8ed6180
+  languageName: node
+  linkType: hard
+
+"@babel/helper-module-imports@npm:^7.12.13, @babel/helper-module-imports@npm:^7.14.5, @babel/helper-module-imports@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/helper-module-imports@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: b98279908698a50a22634e683924cb25eb93edf1bf28ac65691dfa82d7a1a4dae4e6b12b8ef9f9a50171ca484620bce544f270873c53505d8a45364c5b665c0c
+  languageName: node
+  linkType: hard
+
+"@babel/helper-module-imports@npm:^7.22.15":
+  version: 7.24.3
+  resolution: "@babel/helper-module-imports@npm:7.24.3"
+  dependencies:
+    "@babel/types": ^7.24.0
+  checksum: c23492189ba97a1ec7d37012336a5661174e8b88194836b6bbf90d13c3b72c1db4626263c654454986f924c6da8be7ba7f9447876d709cd00bd6ffde6ec00796
+  languageName: node
+  linkType: hard
+
+"@babel/helper-module-transforms@npm:^7.14.5, @babel/helper-module-transforms@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/helper-module-transforms@npm:7.14.5"
+  dependencies:
+    "@babel/helper-module-imports": ^7.14.5
+    "@babel/helper-replace-supers": ^7.14.5
+    "@babel/helper-simple-access": ^7.14.5
+    "@babel/helper-split-export-declaration": ^7.14.5
+    "@babel/helper-validator-identifier": ^7.14.5
+    "@babel/template": ^7.14.5
+    "@babel/traverse": ^7.14.5
+    "@babel/types": ^7.14.5
+  checksum: f5d64c0242ec8949ee09069a634d28ae750ab22f9533ea90eab9eaf3405032a33b0b329a63fac0a7901482efb8a388a06279f7544225a0bc3c1b92b306ab2b6e
+  languageName: node
+  linkType: hard
+
+"@babel/helper-module-transforms@npm:^7.23.3":
+  version: 7.23.3
+  resolution: "@babel/helper-module-transforms@npm:7.23.3"
+  dependencies:
+    "@babel/helper-environment-visitor": ^7.22.20
+    "@babel/helper-module-imports": ^7.22.15
+    "@babel/helper-simple-access": ^7.22.5
+    "@babel/helper-split-export-declaration": ^7.22.6
+    "@babel/helper-validator-identifier": ^7.22.20
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 5d0895cfba0e16ae16f3aa92fee108517023ad89a855289c4eb1d46f7aef4519adf8e6f971e1d55ac20c5461610e17213f1144097a8f932e768a9132e2278d71
+  languageName: node
+  linkType: hard
+
+"@babel/helper-optimise-call-expression@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-optimise-call-expression@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: c7af558c63eb5449bf2249f1236d892ed54a400cb6c721756cde573b996c12c64dee6b57fa18ad1a0025d152e6f689444f7ea32997a1d56e1af66c3eda18843d
+  languageName: node
+  linkType: hard
+
+"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.13.0, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.8.0, @babel/helper-plugin-utils@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/helper-plugin-utils@npm:7.14.5"
+  checksum: fe20e90a24d02770a60ebe80ab9f0dfd7258503cea8006c71709ac9af1aa3e47b0de569499673f11ea6c99597f8c0e4880ae1d505986e61101b69716820972fe
+  languageName: node
+  linkType: hard
+
+"@babel/helper-remap-async-to-generator@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-remap-async-to-generator@npm:7.14.5"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.14.5
+    "@babel/helper-wrap-function": ^7.14.5
+    "@babel/types": ^7.14.5
+  checksum: 022594a15caed0d3bbac52e27eef0f20f9dceb85921b682df55f3bb21dee6fea645b03663e84fdfaadc6b88f4b83b012858520813c15e88728bbc5e16bf3fa29
+  languageName: node
+  linkType: hard
+
+"@babel/helper-replace-supers@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-replace-supers@npm:7.14.5"
+  dependencies:
+    "@babel/helper-member-expression-to-functions": ^7.14.5
+    "@babel/helper-optimise-call-expression": ^7.14.5
+    "@babel/traverse": ^7.14.5
+    "@babel/types": ^7.14.5
+  checksum: 35d33cfe473f9fb5cc1110ee259686179ecd07e00e07d9eb03de998e47f49d59fc2e183cf6be0793fd6bec24510b893415e52ace93ae940f94663c4a02c6fbd0
+  languageName: node
+  linkType: hard
+
+"@babel/helper-simple-access@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-simple-access@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: cd795416bd10dd2f1bdebb36f1af08bf263024fdbf789cfda5dd1fbf4fea1fd0375e21d0bcb910a7d49b09b7480340797dcdfc888fbc895aeae45c145358ad75
+  languageName: node
+  linkType: hard
+
+"@babel/helper-simple-access@npm:^7.22.5":
+  version: 7.22.5
+  resolution: "@babel/helper-simple-access@npm:7.22.5"
+  dependencies:
+    "@babel/types": ^7.22.5
+  checksum: fe9686714caf7d70aedb46c3cce090f8b915b206e09225f1e4dbc416786c2fdbbee40b38b23c268b7ccef749dd2db35f255338fb4f2444429874d900dede5ad2
+  languageName: node
+  linkType: hard
+
+"@babel/helper-skip-transparent-expression-wrappers@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: d16937eb08d57d2577902fa6d05ac4b1695602babd9dff9890fa8e56b593fdc997ad24de13fdaf15617036bfacf3493ea569898a5ac0538c2a831aa163f18985
+  languageName: node
+  linkType: hard
+
+"@babel/helper-split-export-declaration@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-split-export-declaration@npm:7.14.5"
+  dependencies:
+    "@babel/types": ^7.14.5
+  checksum: 93437025a33747bfd37d6d5a9cdac8f4b6b3e5c0c53c0e24c5444575e731ea64fd5471a51a039fd74ff3378f916ea2d69d9f10274d253ed6f832952be2fd65f0
+  languageName: node
+  linkType: hard
+
+"@babel/helper-split-export-declaration@npm:^7.22.6":
+  version: 7.22.6
+  resolution: "@babel/helper-split-export-declaration@npm:7.22.6"
+  dependencies:
+    "@babel/types": ^7.22.5
+  checksum: e141cace583b19d9195f9c2b8e17a3ae913b7ee9b8120246d0f9ca349ca6f03cb2c001fd5ec57488c544347c0bb584afec66c936511e447fd20a360e591ac921
+  languageName: node
+  linkType: hard
+
+"@babel/helper-string-parser@npm:^7.23.4":
+  version: 7.24.1
+  resolution: "@babel/helper-string-parser@npm:7.24.1"
+  checksum: 8404e865b06013979a12406aab4c0e8d2e377199deec09dfe9f57b833b0c9ce7b6e8c1c553f2da8d0bcd240c5005bd7a269f4fef0d628aeb7d5fe035c436fb67
+  languageName: node
+  linkType: hard
+
+"@babel/helper-validator-identifier@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-validator-identifier@npm:7.14.5"
+  checksum: 6366bceab4498785defc083a1bd96344f788d90a1aa7a6f18d6813c1d3d134640bfc05690453c0b79bbfc820472cf5b29110dfddaca1f8e2763dfe1bd5df0b88
+  languageName: node
+  linkType: hard
+
+"@babel/helper-validator-identifier@npm:^7.16.7":
+  version: 7.16.7
+  resolution: "@babel/helper-validator-identifier@npm:7.16.7"
+  checksum: dbb3db9d184343152520a209b5684f5e0ed416109cde82b428ca9c759c29b10c7450657785a8b5c5256aa74acc6da491c1f0cf6b784939f7931ef82982051b69
+  languageName: node
+  linkType: hard
+
+"@babel/helper-validator-identifier@npm:^7.22.20":
+  version: 7.22.20
+  resolution: "@babel/helper-validator-identifier@npm:7.22.20"
+  checksum: 136412784d9428266bcdd4d91c32bcf9ff0e8d25534a9d94b044f77fe76bc50f941a90319b05aafd1ec04f7d127cd57a179a3716009ff7f3412ef835ada95bdc
+  languageName: node
+  linkType: hard
+
+"@babel/helper-validator-option@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-validator-option@npm:7.14.5"
+  checksum: 1b25c34a5cb3d8602280f33b9ab687d2a77895e3616458d0f70ddc450ada9b05e342c44f322bc741d51b252e84cff6ec44ae93d622a3354828579a643556b523
+  languageName: node
+  linkType: hard
+
+"@babel/helper-validator-option@npm:^7.23.5":
+  version: 7.23.5
+  resolution: "@babel/helper-validator-option@npm:7.23.5"
+  checksum: 537cde2330a8aede223552510e8a13e9c1c8798afee3757995a7d4acae564124fe2bf7e7c3d90d62d3657434a74340a274b3b3b1c6f17e9a2be1f48af29cb09e
+  languageName: node
+  linkType: hard
+
+"@babel/helper-wrap-function@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/helper-wrap-function@npm:7.14.5"
+  dependencies:
+    "@babel/helper-function-name": ^7.14.5
+    "@babel/template": ^7.14.5
+    "@babel/traverse": ^7.14.5
+    "@babel/types": ^7.14.5
+  checksum: d5c4bec02396f00d305ae2b60cfa5f3ec27d196a71b88107745b6be4fe257ebe54deedb6ee3997c8c9a2cc5c2571d567c22e9b866109490a2aa7f79a1a2272e2
+  languageName: node
+  linkType: hard
+
+"@babel/helpers@npm:^7.14.6, @babel/helpers@npm:^7.9.0":
+  version: 7.14.6
+  resolution: "@babel/helpers@npm:7.14.6"
+  dependencies:
+    "@babel/template": ^7.14.5
+    "@babel/traverse": ^7.14.5
+    "@babel/types": ^7.14.5
+  checksum: fe4e73975b062a8b8b95f499f4ac1064c9a53d4ee83cc273c2420250f6a46b59f1f5e35050d41ebe04efd7885a28ceea6f4f16d8eb091e24622f2a4a5eb20f23
+  languageName: node
+  linkType: hard
+
+"@babel/helpers@npm:^7.24.4":
+  version: 7.24.4
+  resolution: "@babel/helpers@npm:7.24.4"
+  dependencies:
+    "@babel/template": ^7.24.0
+    "@babel/traverse": ^7.24.1
+    "@babel/types": ^7.24.0
+  checksum: ecd2dc0b3b32e24b97fa3bcda432dd3235b77c2be1e16eafc35b8ef8f6c461faa99796a8bc2431a408c98b4aabfd572c160e2b67ecea4c5c9dd3a8314a97994a
+  languageName: node
+  linkType: hard
+
+"@babel/highlight@npm:^7.14.5, @babel/highlight@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/highlight@npm:7.14.5"
+  dependencies:
+    "@babel/helper-validator-identifier": ^7.14.5
+    chalk: ^2.0.0
+    js-tokens: ^4.0.0
+  checksum: 4e4b22fb886c939551d73307de16232c186fdb4d8ec8f514541b058feaecdba5234788a0740ca5bcd28777f4108596c39ac4b7463684c63b3812f6071e3fb88f
+  languageName: node
+  linkType: hard
+
+"@babel/highlight@npm:^7.24.2":
+  version: 7.24.2
+  resolution: "@babel/highlight@npm:7.24.2"
+  dependencies:
+    "@babel/helper-validator-identifier": ^7.22.20
+    chalk: ^2.4.2
+    js-tokens: ^4.0.0
+    picocolors: ^1.0.0
+  checksum: 5f17b131cc3ebf3ab285a62cf98a404aef1bd71a6be045e748f8d5bf66d6a6e1aefd62f5972c84369472e8d9f22a614c58a89cd331eb60b7ba965b31b1bbeaf5
+  languageName: node
+  linkType: hard
+
+"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.5, @babel/parser@npm:^7.14.6, @babel/parser@npm:^7.4.3, @babel/parser@npm:^7.7.0, @babel/parser@npm:^7.9.0":
+  version: 7.14.7
+  resolution: "@babel/parser@npm:7.14.7"
+  bin:
+    parser: ./bin/babel-parser.js
+  checksum: 0d7acc8cf9c19ccd0e80ab0608953f32f4375f3867c080211270e7bb4bb94c551fd1fc3f49b3cc92a4eec356cf507801f5c93c4c72996968bdc4c28815fe0550
+  languageName: node
+  linkType: hard
+
+"@babel/parser@npm:^7.24.0, @babel/parser@npm:^7.24.1, @babel/parser@npm:^7.24.4":
+  version: 7.24.4
+  resolution: "@babel/parser@npm:7.24.4"
+  bin:
+    parser: ./bin/babel-parser.js
+  checksum: 94c9e3e592894cd6fc57c519f4e06b65463df9be5f01739bb0d0bfce7ffcf99b3c2fdadd44dc59cc858ba2739ce6e469813a941c2f2dfacf333a3b2c9c5c8465
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-skip-transparent-expression-wrappers": ^7.14.5
+    "@babel/plugin-proposal-optional-chaining": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.13.0
+  checksum: 17331fd4c1de860ac78aa3195eb5bd058c4eb24a8f2c6e719f079f9c86cbdb53d9a8affc2f9f78b6fc257afef03811922c2d16addad5d5f6224d2820da1c9f45
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-async-generator-functions@npm:^7.14.7, @babel/plugin-proposal-async-generator-functions@npm:^7.8.3":
+  version: 7.14.7
+  resolution: "@babel/plugin-proposal-async-generator-functions@npm:7.14.7"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-remap-async-to-generator": ^7.14.5
+    "@babel/plugin-syntax-async-generators": ^7.8.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 09343a79385615f8d5f95aaef7c44af5e899c82f030f3d73546c2ffffa567c0949f0405052d7e32f643c0eb2a23590a5050f4606855b3506246d3d60e46f32e3
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-class-properties@npm:7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-proposal-class-properties@npm:7.8.3"
+  dependencies:
+    "@babel/helper-create-class-features-plugin": ^7.8.3
+    "@babel/helper-plugin-utils": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 5ac3435b0393b09162234f8e4c56c6321f038d205af447563f8389e7ed447904c4cdedf958e6a5ed092692fcc2eff980913efff19589c0969dd9846be4710867
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-class-properties@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-class-properties@npm:7.14.5"
+  dependencies:
+    "@babel/helper-create-class-features-plugin": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: fe2aa0a44f8ea121e10c856d6fb4fca418dc42451258ef6ed29321ca740080fba420ebd3d6700d0456c34c2ab2044f9ce4308498321f52a93184ff5adb015aae
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-class-static-block@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-class-static-block@npm:7.14.5"
+  dependencies:
+    "@babel/helper-create-class-features-plugin": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-class-static-block": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.12.0
+  checksum: 0275d0643dacd08638c2d3c129158ad0c2dea6a26e78fa4b2129811a29460ff9a6459d1955a19bfa3b9ed67ba2bb3c88676823ad207b2de4f0c65e0c3751d75c
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-decorators@npm:7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-proposal-decorators@npm:7.8.3"
+  dependencies:
+    "@babel/helper-create-class-features-plugin": ^7.8.3
+    "@babel/helper-plugin-utils": ^7.8.3
+    "@babel/plugin-syntax-decorators": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: f9aa852dd77657d29c19b16bc3a7d8c30e5964a9aa8eb541bf440f34659693631793d4c798c92e344e767c9ccc43baae9e595d86e589bfa4bb157fc07a5e0c05
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-dynamic-import@npm:^7.14.5, @babel/plugin-proposal-dynamic-import@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-dynamic-import@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-dynamic-import": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 47be4b5f8824f8690b47d99a34d52de0e6c19d0b99f26c1f9a2e4cc49e05082bcef7248c610bb3830ae84cec928713c7774f4929fca4fa72df570df7a76a9d2b
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-export-namespace-from@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-export-namespace-from@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-export-namespace-from": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: b3f4e0cc196f7ad9132816bb350124e8932bc047ab946e431f85bae9649b0de384c54261a60c050a2b8220703408fc089f90349ad008ed69a70944a6f3048d0e
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-json-strings@npm:^7.14.5, @babel/plugin-proposal-json-strings@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-json-strings@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-json-strings": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 51dafe70237860569c9c27dc6a0db83e149bf7babb0fcafa9dbcd55a960b443f7b5bb695956c6e116e46b3dbd2a6777ead62bcad843aff8c1916c1be56e2f504
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-logical-assignment-operators@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-logical-assignment-operators@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-logical-assignment-operators": ^7.10.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 08b6dbc991c4824b0d8bfabf46c8254fce02d2df04627b8849cf15a4b6de75629c10c7c83d1e6834cdcebfc98b16264ce2dd32aa9c0fae900ed2af807d5ac42b
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-nullish-coalescing-operator@npm:7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-proposal-nullish-coalescing-operator@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.3
+    "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 36a87fa8f0ca709f66671ebd1af3f865fd1798e59cbf57f8db71cf69ef15ee8cf21ec54833b34c5e77b8a5a60d5b4e9ae949c00fec8eb320d5bc299bfcc3eae1
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-nullish-coalescing-operator@npm:^7.14.5, @babel/plugin-proposal-nullish-coalescing-operator@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-nullish-coalescing-operator@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 033d9483c2feb74928fbb83a73948eb1179c8852d2ae507fbfc37752d2dbf702c9ad0daaf1eaa029f81b12b7e2470061b4f611db88b7293f0e9a71eba288a430
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-numeric-separator@npm:7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-proposal-numeric-separator@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.3
+    "@babel/plugin-syntax-numeric-separator": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: fd2d926e5bba27180e79c7511cb62d423a74690419f97ae1804b4a36632017ab2ef763ce2a84810b7c350a5d3e42a8e263780dadefa4ad6288923a2d13950170
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-numeric-separator@npm:^7.14.5, @babel/plugin-proposal-numeric-separator@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-numeric-separator@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-numeric-separator": ^7.10.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 22093297ec9aed3938b39f4efa1b518252fe7b0835902c3066f0ae6a864ac253b986a4a21a6092aa068d0702d7b09bed74e56cf39f2da8b4f3f43e0747bffb62
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-object-rest-spread@npm:^7.14.7, @babel/plugin-proposal-object-rest-spread@npm:^7.9.0":
+  version: 7.14.7
+  resolution: "@babel/plugin-proposal-object-rest-spread@npm:7.14.7"
+  dependencies:
+    "@babel/compat-data": ^7.14.7
+    "@babel/helper-compilation-targets": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-object-rest-spread": ^7.8.3
+    "@babel/plugin-transform-parameters": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: a35192868166fb5a62003a56ce2c266f74ae680f1d9589652c4495145240dd138a9505301bb5adca069cb874d6f0f733dc2f3d1d05f71a06019735c29c4d1a11
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-optional-catch-binding@npm:^7.14.5, @babel/plugin-proposal-optional-catch-binding@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-optional-catch-binding@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-optional-catch-binding": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: f9c1b2b34fef1bde85feeb0b438131f526056161e10b6fb91c74a5828ad39d2a20521b5c3cefc7367a7e5fc792b7c7e607bf278d7999b5d89824c34af3174eae
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-optional-chaining@npm:7.9.0":
+  version: 7.9.0
+  resolution: "@babel/plugin-proposal-optional-chaining@npm:7.9.0"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.3
+    "@babel/plugin-syntax-optional-chaining": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: da2d4cbf6fe1b3579c83b83cd6b7deb1fa4f907b53eceed8906cf60b0ed02f3dde01bb891040dea67e316865bb8c890ca3272a422d359d2d0a7826c7250572d3
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-optional-chaining@npm:^7.14.5, @babel/plugin-proposal-optional-chaining@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-optional-chaining@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-skip-transparent-expression-wrappers": ^7.14.5
+    "@babel/plugin-syntax-optional-chaining": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 9e39e20d162bea2241b4c24ea8a339f872a04954a5155c606bf2437edaa1a15b8a517daee4b2b09cfd42d826b93c57f080aa9fbb13c60a8f3a7a72963badf2df
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-private-methods@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-private-methods@npm:7.14.5"
+  dependencies:
+    "@babel/helper-create-class-features-plugin": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: badacc1d68c8cf92a7ba973e3c283bc3aebf586a6573b6d18a96461ce18039d4cdc0135edac1b810df8d92cfca628115d98a0ad83ed8f15bf15eaff21539bf32
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-private-property-in-object@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-private-property-in-object@npm:7.14.5"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.14.5
+    "@babel/helper-create-class-features-plugin": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-private-property-in-object": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: a11da6a52eb13d6dcb6ed36993a81e9746404f6e83d32be16142911b7e5768293d8c4c5373d182ef25cb94d0b18c0c27a07f4553be042ee2dc49f7179f8cbfe2
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-unicode-property-regex@npm:^7.14.5, @babel/plugin-proposal-unicode-property-regex@npm:^7.4.4, @babel/plugin-proposal-unicode-property-regex@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-proposal-unicode-property-regex@npm:7.14.5"
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 58bd3277a972a33d101d29ab4f52e964b6e8ec218eb84f764b4ea67bf8ed362909760812d3f7451ee5e54dc273bd81bc5a00cd2c13e8fb64a47ec117cb69d51b
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-async-generators@npm:^7.8.0, @babel/plugin-syntax-async-generators@npm:^7.8.4":
+  version: 7.8.4
+  resolution: "@babel/plugin-syntax-async-generators@npm:7.8.4"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 7ed1c1d9b9e5b64ef028ea5e755c0be2d4e5e4e3d6cf7df757b9a8c4cfa4193d268176d0f1f7fbecdda6fe722885c7fda681f480f3741d8a2d26854736f05367
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-class-properties@npm:^7.12.13":
+  version: 7.12.13
+  resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.12.13
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 24f34b196d6342f28d4bad303612d7ff566ab0a013ce89e775d98d6f832969462e7235f3e7eaf17678a533d4be0ba45d3ae34ab4e5a9dcbda5d98d49e5efa2fc
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-class-static-block@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-class-static-block@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 3e80814b5b6d4fe17826093918680a351c2d34398a914ce6e55d8083d72a9bdde4fbaf6a2dcea0e23a03de26dc2917ae3efd603d27099e2b98380345703bf948
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-decorators@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-decorators@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 7e725deeba3848e8e1b57bc8a74c1a852aa253b9ffd293aa0bc043b93e1e7b669414caae3d20c653d2fab907a9388e526f2138e3783b22e49272098566cf9734
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-dynamic-import@npm:^7.8.0, @babel/plugin-syntax-dynamic-import@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-dynamic-import@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: ce307af83cf433d4ec42932329fad25fa73138ab39c7436882ea28742e1c0066626d224e0ad2988724c82644e41601cef607b36194f695cb78a1fcdc959637bd
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-export-namespace-from@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-export-namespace-from@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 85740478be5b0de185228e7814451d74ab8ce0a26fcca7613955262a26e99e8e15e9da58f60c754b84515d4c679b590dbd3f2148f0f58025f4ae706f1c5a5d4a
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-flow@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-flow@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: ba6c81325930283bed75c59f92bd7f5873beb006e35fdb092f62498d1f1ecb90f3eaa3d586400ad48dd6d03c63d2bf59a72998e431bab2bd20b3137bd2b10ac0
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-json-strings@npm:^7.8.0, @babel/plugin-syntax-json-strings@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-json-strings@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: bf5aea1f3188c9a507e16efe030efb996853ca3cadd6512c51db7233cc58f3ac89ff8c6bdfb01d30843b161cfe7d321e1bf28da82f7ab8d7e6bc5464666f354a
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-jsx@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-jsx@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 3a2ba87534b0f9ee70eba0754d2ae544825c25afd98efb8e42b41399e02de4cc5b1f70fc5ce444fb7a7e5b09972c289eed2f00917be5b88d67407f4cbde8e960
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4":
+  version: 7.10.4
+  resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.10.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: aff33577037e34e515911255cdbb1fd39efee33658aa00b8a5fd3a4b903585112d037cce1cc9e4632f0487dc554486106b79ccd5ea63a2e00df4363f6d4ff886
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-nullish-coalescing-operator@npm:^7.8.0, @babel/plugin-syntax-nullish-coalescing-operator@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-nullish-coalescing-operator@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 87aca4918916020d1fedba54c0e232de408df2644a425d153be368313fdde40d96088feed6c4e5ab72aac89be5d07fef2ddf329a15109c5eb65df006bf2580d1
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-numeric-separator@npm:^7.10.4, @babel/plugin-syntax-numeric-separator@npm:^7.8.0, @babel/plugin-syntax-numeric-separator@npm:^7.8.3":
+  version: 7.10.4
+  resolution: "@babel/plugin-syntax-numeric-separator@npm:7.10.4"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.10.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 01ec5547bd0497f76cc903ff4d6b02abc8c05f301c88d2622b6d834e33a5651aa7c7a3d80d8d57656a4588f7276eba357f6b7e006482f5b564b7a6488de493a1
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-object-rest-spread@npm:^7.0.0, @babel/plugin-syntax-object-rest-spread@npm:^7.8.0, @babel/plugin-syntax-object-rest-spread@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-object-rest-spread@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: fddcf581a57f77e80eb6b981b10658421bc321ba5f0a5b754118c6a92a5448f12a0c336f77b8abf734841e102e5126d69110a306eadb03ca3e1547cab31f5cbf
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-optional-catch-binding@npm:^7.8.0, @babel/plugin-syntax-optional-catch-binding@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-optional-catch-binding@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 910d90e72bc90ea1ce698e89c1027fed8845212d5ab588e35ef91f13b93143845f94e2539d831dc8d8ededc14ec02f04f7bd6a8179edd43a326c784e7ed7f0b9
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-optional-chaining@npm:^7.8.0, @babel/plugin-syntax-optional-chaining@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-optional-chaining@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: eef94d53a1453361553c1f98b68d17782861a04a392840341bc91780838dd4e695209c783631cf0de14c635758beafb6a3a65399846ffa4386bff90639347f30
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-private-property-in-object@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-private-property-in-object@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: b317174783e6e96029b743ccff2a67d63d38756876e7e5d0ba53a322e38d9ca452c13354a57de1ad476b4c066dbae699e0ca157441da611117a47af88985ecda
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-top-level-await@npm:^7.14.5, @babel/plugin-syntax-top-level-await@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-top-level-await@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: bbd1a56b095be7820029b209677b194db9b1d26691fe999856462e66b25b281f031f3dfd91b1619e9dcf95bebe336211833b854d0fb8780d618e35667c2d0d7e
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-typescript@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-typescript@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 5447d13b31aeeeaa5c2b945e60a598642dedca480f11d3232b0927aeb6a6bb8201a0025f509bc23851da4bf126f69b0522790edbd58f4560f0a4984cabd0d126
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-arrow-functions@npm:^7.14.5, @babel/plugin-transform-arrow-functions@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-arrow-functions@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 126196ea0107e97f711c0d48d8d1e01a30f5a5e127628f7367658b4c5832182c4e28914294408374690c5bfbb4ad4fe6560068d8bf370cafe8d4fe23599aaa95
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-async-to-generator@npm:^7.14.5, @babel/plugin-transform-async-to-generator@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-async-to-generator@npm:7.14.5"
+  dependencies:
+    "@babel/helper-module-imports": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-remap-async-to-generator": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 4c47016c5f65adaa5836054fcc99402f1d295aedd7ebd44e6df128a90977952f2a8abdf3b3d0aa5a9e1186184da538452c4d9a3b1482376759c6962627201da5
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-block-scoped-functions@npm:^7.14.5, @babel/plugin-transform-block-scoped-functions@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 9994d9f107308b21be043de115fe1d06956807d93a3039ddab54333d1fbb39ad50cc5f9eccaedf5317f4699230e923662254974f3a974c4f000e986837bc020a
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-block-scoping@npm:^7.14.5, @babel/plugin-transform-block-scoping@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-block-scoping@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: d317d636d0475317302e9c8b01cf9214fac3ff9353b23d0d16285f196f5c7b95b7864a8e8eaf51a3e1b650649203855f80a58b7a2caef4b0ee9793e7349a0ec5
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-classes@npm:^7.14.5, @babel/plugin-transform-classes@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-classes@npm:7.14.5"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.14.5
+    "@babel/helper-function-name": ^7.14.5
+    "@babel/helper-optimise-call-expression": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-replace-supers": ^7.14.5
+    "@babel/helper-split-export-declaration": ^7.14.5
+    globals: ^11.1.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 42fc333a0d8a6a90b5c75e90d2ec21494f711ab7c58f2d074d95726cdd38f137e74653602a82d2d1a3e9bc504b5eff62418d70048514b672c9bd108bfb866e25
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-computed-properties@npm:^7.14.5, @babel/plugin-transform-computed-properties@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-computed-properties@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 87bd4c46255359ab8d53d0e9b5aa5e1ef218c1447874bd8c2eff759d3a2b5fe6b3ec55046babe0087f7e3890f6167524c729737e912080ea1c9758a559765130
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-destructuring@npm:^7.14.7, @babel/plugin-transform-destructuring@npm:^7.8.3":
+  version: 7.14.7
+  resolution: "@babel/plugin-transform-destructuring@npm:7.14.7"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 0b0cf8ed9fb92c53e3888c17402c4f1e8f329f05a759829b559df883b19b442d3950b7f319df419d0cff122ea76fc8b3b55779fdbb9e394e5f058419a8d5ba14
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-dotall-regex@npm:^7.14.5, @babel/plugin-transform-dotall-regex@npm:^7.4.4, @babel/plugin-transform-dotall-regex@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-dotall-regex@npm:7.14.5"
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 4da3dac9580823c1fe8aaedf6109d3a26d17ad7ef7d1b278ddbcd7c148e02c465cf49250794529a34bac0bda6b53db558ae08d185a96b76efaaa17a5da3911df
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-duplicate-keys@npm:^7.14.5, @babel/plugin-transform-duplicate-keys@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-duplicate-keys@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: c6c951d2f7ed528a8103d08293d4aaf95efa38c697e7b2b27b7e6c9780280484373e2f7ef8d77daf17dffdc86748fbf75e776e0542b1c7b17e29308bc31ebd8c
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-exponentiation-operator@npm:^7.14.5, @babel/plugin-transform-exponentiation-operator@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-exponentiation-operator@npm:7.14.5"
+  dependencies:
+    "@babel/helper-builder-binary-assignment-operator-visitor": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 7588a582d0bc5c80fda7f1c631354a35a9a7d284dd80ccaf2bbfd086a39a9d6461718dc7dd45a3ca59228593270a7c6a907a9cbe7ddc349d80c7342af0263c5c
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-flow-strip-types@npm:7.9.0":
+  version: 7.9.0
+  resolution: "@babel/plugin-transform-flow-strip-types@npm:7.9.0"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.3
+    "@babel/plugin-syntax-flow": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 4bc74b721db01e91c6d591f927b53fa509d69d5c49911f31d9202df984cf2eda8397346826647cb660f72676dd3e5a2498187f587e7e181c0ca66b28b1252591
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-for-of@npm:^7.14.5, @babel/plugin-transform-for-of@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-for-of@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: aeb76eb11d10b2390996001e2fd529bbaf3695edd306d24e4eba87b8137c10a6afda3896017f88fcf40fd2334cc424c0a111fad34e10c747e81e577e5957e328
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-function-name@npm:^7.14.5, @babel/plugin-transform-function-name@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-function-name@npm:7.14.5"
+  dependencies:
+    "@babel/helper-function-name": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 3db2fa1bcd21b76a91ce78db8ebca047fdadbf198f816e2621e531a751a0d40976cf2a25262dee9352fd0c53bff5b25fddefadebdbb4ba3da6d89b849ab075b6
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-literals@npm:^7.14.5, @babel/plugin-transform-literals@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-literals@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 2341cfaaf8ac7199c578407ea4de41205d3d74c5a48899aa96c41b08c09d18c46d9018fdc6a2f69f0bccc2662223afc47b60130ae4ff36a79351fface71a61f3
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-member-expression-literals@npm:^7.14.5, @babel/plugin-transform-member-expression-literals@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-member-expression-literals@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: a94ff910e8d0e28effd58c64f2d15c9772ea4c209644f116fd81dc5c93ce232304f42ef14d5ec2baf095c824786698fcf6c1d4c91952dc3762350f4ec0eb1f17
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-modules-amd@npm:^7.14.5, @babel/plugin-transform-modules-amd@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-modules-amd@npm:7.14.5"
+  dependencies:
+    "@babel/helper-module-transforms": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    babel-plugin-dynamic-import-node: ^2.3.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 963d9ebb11b282d5c5f462e3e1ad6991e60fb4d190b5a7aa0d9937e0fa83d89cf5f94268f0b0b343576f2cee0cf545bcaf40da40eb8b9dca5c79840fd86a65ed
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-modules-commonjs@npm:^7.14.5, @babel/plugin-transform-modules-commonjs@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-modules-commonjs@npm:7.14.5"
+  dependencies:
+    "@babel/helper-module-transforms": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-simple-access": ^7.14.5
+    babel-plugin-dynamic-import-node: ^2.3.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 5cc41ee904e421c32f692ce10985190bc8f995df63ee1fd899ea80ce50b4b8408c7f2fddf16e01345244fc5702c8b9c0772afdd934e325c4e468840daa9bee04
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-modules-systemjs@npm:^7.14.5, @babel/plugin-transform-modules-systemjs@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-modules-systemjs@npm:7.14.5"
+  dependencies:
+    "@babel/helper-hoist-variables": ^7.14.5
+    "@babel/helper-module-transforms": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-validator-identifier": ^7.14.5
+    babel-plugin-dynamic-import-node: ^2.3.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 3ca0bb1c0c22a3d705476186afa9fc86398ae4662afc259ff29c1942e3c8770f4bdadaf67418a21816964d4e1eaf07412eeabccccfaa9d45eac735f971ad148b
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-modules-umd@npm:^7.14.5, @babel/plugin-transform-modules-umd@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-modules-umd@npm:7.14.5"
+  dependencies:
+    "@babel/helper-module-transforms": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 455ff383bed47e104d4b2b32f11bc5a44a25c797fad26b5eab9b8a81856f9945350b45ad28b9b20b0bbf324832c7a826c9c3d6f865e85c26a1771663132e4145
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.14.7, @babel/plugin-transform-named-capturing-groups-regex@npm:^7.8.3":
+  version: 7.14.7
+  resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.14.7"
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 3c68bc77cce387750ecd32d33e9ad0f0968245fbe03b36ec8dddc52bee3ee84757205db3b3b4fc605e055f08769312ef4dbf4a0c8adb8f02eb04b142ffcdf265
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-new-target@npm:^7.14.5, @babel/plugin-transform-new-target@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-new-target@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 5b806c86926cd0b03fa2f22cf21a6d6a86e5831b80e8a1e898877acd3a03fd07078e45da33b671200ec98a5c7ac9be2f3592cd88933e262feffba248ca7ca4e7
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-object-super@npm:^7.14.5, @babel/plugin-transform-object-super@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-object-super@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-replace-supers": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 88477a8b27e76042ffbff1345088422f5b3135346d69f264e71d90b3749a3d73d5a579c97a33cd11c61c5d499a655911c7cd97dbe68edb36e090dfd5f154d777
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-parameters@npm:^7.14.5, @babel/plugin-transform-parameters@npm:^7.8.7":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-parameters@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 932bc616be7b5542ba2371c85cfcc579a8556b9e5a5ea5535b7f0ec5b68284ed2a3724ae181f1a22719b5ea6539c82f5fcee37d9f45f08ed72eb9e43a0940b56
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-property-literals@npm:^7.14.5, @babel/plugin-transform-property-literals@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-property-literals@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 426e7b13a048220314e35bd4e6732640293c616173ef05ceca3a2bfadd043199e35ec693f1604f77178c3a88bea241b6d7ce92d8fc837faeb37117ad7866350f
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-react-constant-elements@npm:^7.0.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-react-constant-elements@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 7e4168535cd3ae1bae5acf8d7cc77a2bd885f8abed46672160631e23ded0c7e0be5152cefb1f87b123c4e3c38a542ca0ce06b3b0d8e7b7694f43687b63c0a9fb
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-react-display-name@npm:7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-transform-react-display-name@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 2712345cdf6aa62ca4da3e749a247c99210658cd3c22ad7579052fb451ca6af482d4dee77ae295c3d12efc603aa0ee4a00399347d8e8ad962aa8715037a17e8e
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-react-display-name@npm:^7.14.5, @babel/plugin-transform-react-display-name@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-react-display-name@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: d7ca35d5e8d7d91ac82b17e1bd68dd4a7dcfae54da95b28d072907799503e2ec234f34dd869c9fee299a29e73e7b5ce3d4c748cf2a29c25d39f9523be130dba3
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-react-jsx-development@npm:^7.14.5, @babel/plugin-transform-react-jsx-development@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-react-jsx-development@npm:7.14.5"
+  dependencies:
+    "@babel/plugin-transform-react-jsx": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: b49d6e703aeb4fbaacbb8449418dc3c599bcb3ce608cb900ed21a288c3bce42a33209524693b1978766b645aa2b751c15aa9da5337cc6ac2a79fd9b7c9ae9246
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-react-jsx-self@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-react-jsx-self@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: b1b19d3aa0d383fd06e085bcb5462a310dd844a073cc608115a3582ed88ca23d1511dc75cfa81369c2a254e14428b0e6482e6c48bdef346764d801882de8012f
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-react-jsx-source@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-react-jsx-source@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: e7e7336bbd07d6c1a281bac1b242e8cb8172f3b1e1d9d214160ab220142fbefc5d79786d57bf197b18f4c694edfc7614dddae2f990adb4b7484146635b58dfe6
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-react-jsx@npm:^7.14.5, @babel/plugin-transform-react-jsx@npm:^7.9.1":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-react-jsx@npm:7.14.5"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.14.5
+    "@babel/helper-module-imports": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-jsx": ^7.14.5
+    "@babel/types": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 4be6ba0a0303691ce7e16363da1ae446a5cd6eb63ba5729cd7af21b0e7927c07bb8595482836cbda0f41b39fa979c37f4504ef7c23729085f84fac1659615542
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-react-pure-annotations@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-react-pure-annotations@npm:7.14.5"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 3b62cc6af2c838eabc28c07473eab1392b41a5db2f0f326b1ba3ec52b95529e1c46098d6a259c7debb6a17489445828b89f7737a6fb85345ea5d27e4819a31fe
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-regenerator@npm:^7.14.5, @babel/plugin-transform-regenerator@npm:^7.8.7":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-regenerator@npm:7.14.5"
+  dependencies:
+    regenerator-transform: ^0.14.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: f606bc04da7d0cfd651914cb144e85a0ea6fe20ee453ed21d002747cc47b09c853bc97166c32dc47e959581b772d9883f7d96d1c8e795c81ed21dbbb300e3aa7
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-reserved-words@npm:^7.14.5, @babel/plugin-transform-reserved-words@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-reserved-words@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 8a40d7b48e1b4a549272d603e7b28ead70213e12353d65edd07156b7169d7933cee8b79987b54f374f3c41b835d941aca4b13b8aa23a922c94113af2131ca686
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-runtime@npm:7.9.0":
+  version: 7.9.0
+  resolution: "@babel/plugin-transform-runtime@npm:7.9.0"
+  dependencies:
+    "@babel/helper-module-imports": ^7.8.3
+    "@babel/helper-plugin-utils": ^7.8.3
+    resolve: ^1.8.1
+    semver: ^5.5.1
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 48d6a6ada6bb0a08179ab23e7b63b7e5430abb1a6b21f3979524c5574cd0de033abe196c4ec0ff4470a13b87ee367acf4b21ed0ae9294659ee530b89278786fd
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-shorthand-properties@npm:^7.14.5, @babel/plugin-transform-shorthand-properties@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-shorthand-properties@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 60cdd17e347a6a0973c8ea5c08ae4b3f8e59ce0e188453c4bda045d2a5c34495af8e0e9393631aa9f3fd51282455b9c5d6ba07e262576171dbe2b4094bdaf8ad
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-spread@npm:^7.14.6, @babel/plugin-transform-spread@npm:^7.8.3":
+  version: 7.14.6
+  resolution: "@babel/plugin-transform-spread@npm:7.14.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-skip-transparent-expression-wrappers": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 20c11de962dd7ddab110d6c4ab9f3c0bea97393ce09cbe4e46be53182c3df0577eaf0e31aaa2d76344ae21ed3a3b7e779fe814b845d188e11a6031c619648b89
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-sticky-regex@npm:^7.14.5, @babel/plugin-transform-sticky-regex@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-sticky-regex@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 6d77e0641c4c72203d592d54fdb11770de22a34d659d3335e4c537e95b930d03142b11f1d41d103da3de063c628a0f34bdd4c6534b591bc59d9ce67fafb836dc
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-template-literals@npm:^7.14.5, @babel/plugin-transform-template-literals@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-template-literals@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 56d273470c16e83bac1bfab5057a64f23191b51460a009b522b3b29806d7a9f64cbd94323836ceb997c4f331b85564f952eb5566c7bd140d0b278f0191a31985
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-typeof-symbol@npm:^7.14.5, @babel/plugin-transform-typeof-symbol@npm:^7.8.4":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-typeof-symbol@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 1e71ec00ea8b64522b8677c030f334cc5b3833a5b7269a152a2ba7a6b36f0e0a4333a61072e69113e4062e71554d4751ef2e3ddd5e81994978123323f266981c
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-typescript@npm:^7.9.0":
+  version: 7.14.6
+  resolution: "@babel/plugin-transform-typescript@npm:7.14.6"
+  dependencies:
+    "@babel/helper-create-class-features-plugin": ^7.14.6
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/plugin-syntax-typescript": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: cb3117cfc9c8ebf9612b137eb660448e79a876a189fcad6b79641faa7200073bbfd08bf0e63c7ddb3a35b3d31457d6e90cf63565e64446a73866290dc97353fa
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-unicode-escapes@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-unicode-escapes@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 2a6979c5b886d9c7d9d3887374d75384542fe05a71eb7738b2cde659386089a930d37d1a34ffb4b87def98fbed3526d78b7cd5dd9bffde4d406b368faba81b7d
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-unicode-regex@npm:^7.14.5, @babel/plugin-transform-unicode-regex@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-transform-unicode-regex@npm:7.14.5"
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 1b7a4c0dc6b07390f991e7cac8409f7a1ae74495d94b9e1fb5a716d5362a349a35717cfad883074e3f80e16bb630bbd1986a3436f739f6b01c30a96ef3f9ea9a
+  languageName: node
+  linkType: hard
+
+"@babel/preset-env@npm:7.9.0":
+  version: 7.9.0
+  resolution: "@babel/preset-env@npm:7.9.0"
+  dependencies:
+    "@babel/compat-data": ^7.9.0
+    "@babel/helper-compilation-targets": ^7.8.7
+    "@babel/helper-module-imports": ^7.8.3
+    "@babel/helper-plugin-utils": ^7.8.3
+    "@babel/plugin-proposal-async-generator-functions": ^7.8.3
+    "@babel/plugin-proposal-dynamic-import": ^7.8.3
+    "@babel/plugin-proposal-json-strings": ^7.8.3
+    "@babel/plugin-proposal-nullish-coalescing-operator": ^7.8.3
+    "@babel/plugin-proposal-numeric-separator": ^7.8.3
+    "@babel/plugin-proposal-object-rest-spread": ^7.9.0
+    "@babel/plugin-proposal-optional-catch-binding": ^7.8.3
+    "@babel/plugin-proposal-optional-chaining": ^7.9.0
+    "@babel/plugin-proposal-unicode-property-regex": ^7.8.3
+    "@babel/plugin-syntax-async-generators": ^7.8.0
+    "@babel/plugin-syntax-dynamic-import": ^7.8.0
+    "@babel/plugin-syntax-json-strings": ^7.8.0
+    "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.0
+    "@babel/plugin-syntax-numeric-separator": ^7.8.0
+    "@babel/plugin-syntax-object-rest-spread": ^7.8.0
+    "@babel/plugin-syntax-optional-catch-binding": ^7.8.0
+    "@babel/plugin-syntax-optional-chaining": ^7.8.0
+    "@babel/plugin-syntax-top-level-await": ^7.8.3
+    "@babel/plugin-transform-arrow-functions": ^7.8.3
+    "@babel/plugin-transform-async-to-generator": ^7.8.3
+    "@babel/plugin-transform-block-scoped-functions": ^7.8.3
+    "@babel/plugin-transform-block-scoping": ^7.8.3
+    "@babel/plugin-transform-classes": ^7.9.0
+    "@babel/plugin-transform-computed-properties": ^7.8.3
+    "@babel/plugin-transform-destructuring": ^7.8.3
+    "@babel/plugin-transform-dotall-regex": ^7.8.3
+    "@babel/plugin-transform-duplicate-keys": ^7.8.3
+    "@babel/plugin-transform-exponentiation-operator": ^7.8.3
+    "@babel/plugin-transform-for-of": ^7.9.0
+    "@babel/plugin-transform-function-name": ^7.8.3
+    "@babel/plugin-transform-literals": ^7.8.3
+    "@babel/plugin-transform-member-expression-literals": ^7.8.3
+    "@babel/plugin-transform-modules-amd": ^7.9.0
+    "@babel/plugin-transform-modules-commonjs": ^7.9.0
+    "@babel/plugin-transform-modules-systemjs": ^7.9.0
+    "@babel/plugin-transform-modules-umd": ^7.9.0
+    "@babel/plugin-transform-named-capturing-groups-regex": ^7.8.3
+    "@babel/plugin-transform-new-target": ^7.8.3
+    "@babel/plugin-transform-object-super": ^7.8.3
+    "@babel/plugin-transform-parameters": ^7.8.7
+    "@babel/plugin-transform-property-literals": ^7.8.3
+    "@babel/plugin-transform-regenerator": ^7.8.7
+    "@babel/plugin-transform-reserved-words": ^7.8.3
+    "@babel/plugin-transform-shorthand-properties": ^7.8.3
+    "@babel/plugin-transform-spread": ^7.8.3
+    "@babel/plugin-transform-sticky-regex": ^7.8.3
+    "@babel/plugin-transform-template-literals": ^7.8.3
+    "@babel/plugin-transform-typeof-symbol": ^7.8.4
+    "@babel/plugin-transform-unicode-regex": ^7.8.3
+    "@babel/preset-modules": ^0.1.3
+    "@babel/types": ^7.9.0
+    browserslist: ^4.9.1
+    core-js-compat: ^3.6.2
+    invariant: ^2.2.2
+    levenary: ^1.1.1
+    semver: ^5.5.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 5578f6163488ed945c7c318402388d742aef91021ed4c080cba87e22975f0e32f0caadb309fe8687f7a44b2ad7153e4b00f69b7b665f2a2ffd6d43732317f877
+  languageName: node
+  linkType: hard
+
+"@babel/preset-env@npm:^7.4.5":
+  version: 7.14.7
+  resolution: "@babel/preset-env@npm:7.14.7"
+  dependencies:
+    "@babel/compat-data": ^7.14.7
+    "@babel/helper-compilation-targets": ^7.14.5
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-validator-option": ^7.14.5
+    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ^7.14.5
+    "@babel/plugin-proposal-async-generator-functions": ^7.14.7
+    "@babel/plugin-proposal-class-properties": ^7.14.5
+    "@babel/plugin-proposal-class-static-block": ^7.14.5
+    "@babel/plugin-proposal-dynamic-import": ^7.14.5
+    "@babel/plugin-proposal-export-namespace-from": ^7.14.5
+    "@babel/plugin-proposal-json-strings": ^7.14.5
+    "@babel/plugin-proposal-logical-assignment-operators": ^7.14.5
+    "@babel/plugin-proposal-nullish-coalescing-operator": ^7.14.5
+    "@babel/plugin-proposal-numeric-separator": ^7.14.5
+    "@babel/plugin-proposal-object-rest-spread": ^7.14.7
+    "@babel/plugin-proposal-optional-catch-binding": ^7.14.5
+    "@babel/plugin-proposal-optional-chaining": ^7.14.5
+    "@babel/plugin-proposal-private-methods": ^7.14.5
+    "@babel/plugin-proposal-private-property-in-object": ^7.14.5
+    "@babel/plugin-proposal-unicode-property-regex": ^7.14.5
+    "@babel/plugin-syntax-async-generators": ^7.8.4
+    "@babel/plugin-syntax-class-properties": ^7.12.13
+    "@babel/plugin-syntax-class-static-block": ^7.14.5
+    "@babel/plugin-syntax-dynamic-import": ^7.8.3
+    "@babel/plugin-syntax-export-namespace-from": ^7.8.3
+    "@babel/plugin-syntax-json-strings": ^7.8.3
+    "@babel/plugin-syntax-logical-assignment-operators": ^7.10.4
+    "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.3
+    "@babel/plugin-syntax-numeric-separator": ^7.10.4
+    "@babel/plugin-syntax-object-rest-spread": ^7.8.3
+    "@babel/plugin-syntax-optional-catch-binding": ^7.8.3
+    "@babel/plugin-syntax-optional-chaining": ^7.8.3
+    "@babel/plugin-syntax-private-property-in-object": ^7.14.5
+    "@babel/plugin-syntax-top-level-await": ^7.14.5
+    "@babel/plugin-transform-arrow-functions": ^7.14.5
+    "@babel/plugin-transform-async-to-generator": ^7.14.5
+    "@babel/plugin-transform-block-scoped-functions": ^7.14.5
+    "@babel/plugin-transform-block-scoping": ^7.14.5
+    "@babel/plugin-transform-classes": ^7.14.5
+    "@babel/plugin-transform-computed-properties": ^7.14.5
+    "@babel/plugin-transform-destructuring": ^7.14.7
+    "@babel/plugin-transform-dotall-regex": ^7.14.5
+    "@babel/plugin-transform-duplicate-keys": ^7.14.5
+    "@babel/plugin-transform-exponentiation-operator": ^7.14.5
+    "@babel/plugin-transform-for-of": ^7.14.5
+    "@babel/plugin-transform-function-name": ^7.14.5
+    "@babel/plugin-transform-literals": ^7.14.5
+    "@babel/plugin-transform-member-expression-literals": ^7.14.5
+    "@babel/plugin-transform-modules-amd": ^7.14.5
+    "@babel/plugin-transform-modules-commonjs": ^7.14.5
+    "@babel/plugin-transform-modules-systemjs": ^7.14.5
+    "@babel/plugin-transform-modules-umd": ^7.14.5
+    "@babel/plugin-transform-named-capturing-groups-regex": ^7.14.7
+    "@babel/plugin-transform-new-target": ^7.14.5
+    "@babel/plugin-transform-object-super": ^7.14.5
+    "@babel/plugin-transform-parameters": ^7.14.5
+    "@babel/plugin-transform-property-literals": ^7.14.5
+    "@babel/plugin-transform-regenerator": ^7.14.5
+    "@babel/plugin-transform-reserved-words": ^7.14.5
+    "@babel/plugin-transform-shorthand-properties": ^7.14.5
+    "@babel/plugin-transform-spread": ^7.14.6
+    "@babel/plugin-transform-sticky-regex": ^7.14.5
+    "@babel/plugin-transform-template-literals": ^7.14.5
+    "@babel/plugin-transform-typeof-symbol": ^7.14.5
+    "@babel/plugin-transform-unicode-escapes": ^7.14.5
+    "@babel/plugin-transform-unicode-regex": ^7.14.5
+    "@babel/preset-modules": ^0.1.4
+    "@babel/types": ^7.14.5
+    babel-plugin-polyfill-corejs2: ^0.2.2
+    babel-plugin-polyfill-corejs3: ^0.2.2
+    babel-plugin-polyfill-regenerator: ^0.2.2
+    core-js-compat: ^3.15.0
+    semver: ^6.3.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: ebebc20ada68c92b67375926021d576af3636a279aee7625c1e234880355c8669188483aecfff2d478c1caa9fcf18b569ea329060b479236b04baed2bdf796d5
+  languageName: node
+  linkType: hard
+
+"@babel/preset-modules@npm:^0.1.3, @babel/preset-modules@npm:^0.1.4":
+  version: 0.1.4
+  resolution: "@babel/preset-modules@npm:0.1.4"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.0.0
+    "@babel/plugin-proposal-unicode-property-regex": ^7.4.4
+    "@babel/plugin-transform-dotall-regex": ^7.4.4
+    "@babel/types": ^7.4.4
+    esutils: ^2.0.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 7c6500be06be9a341e377eb63292a4a22d0da2b4fb8c68714aff703ddb341cbd58e37d4119d64fc3e602f73801103af471fca2c60b4c1e48e08eea3e6b1afc93
+  languageName: node
+  linkType: hard
+
+"@babel/preset-react@npm:7.9.1":
+  version: 7.9.1
+  resolution: "@babel/preset-react@npm:7.9.1"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.3
+    "@babel/plugin-transform-react-display-name": ^7.8.3
+    "@babel/plugin-transform-react-jsx": ^7.9.1
+    "@babel/plugin-transform-react-jsx-development": ^7.9.0
+    "@babel/plugin-transform-react-jsx-self": ^7.9.0
+    "@babel/plugin-transform-react-jsx-source": ^7.9.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 68efd5246694927d513a27bf4bb4b3894f489b5dc9eea883e7eda82341d152a3f3d6187fb2ed1b080064fa6a111ef53182ec43049ad766fb10af4440ef9bc62b
+  languageName: node
+  linkType: hard
+
+"@babel/preset-react@npm:^7.0.0":
+  version: 7.14.5
+  resolution: "@babel/preset-react@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-validator-option": ^7.14.5
+    "@babel/plugin-transform-react-display-name": ^7.14.5
+    "@babel/plugin-transform-react-jsx": ^7.14.5
+    "@babel/plugin-transform-react-jsx-development": ^7.14.5
+    "@babel/plugin-transform-react-pure-annotations": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 413c507f853b95c71ecb64f29ea7b0786464a237c54977b03a4410dd837b03bfa55df81d0e337f9792d9abc61f4bf3d616f857d00a36ff4ede79407c143ac865
+  languageName: node
+  linkType: hard
+
+"@babel/preset-typescript@npm:7.9.0":
+  version: 7.9.0
+  resolution: "@babel/preset-typescript@npm:7.9.0"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.3
+    "@babel/plugin-transform-typescript": ^7.9.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: fbdd8b3d0493df4c4a88a59ca616331658e219b4c294584834a34a5148c991b28173c8f244c3b4fcaee93cd1bad6c99c0a84bd0a47a4b3cc8e759f35a0d6a3eb
+  languageName: node
+  linkType: hard
+
+"@babel/runtime-corejs2@npm:^7.0.0":
+  version: 7.24.4
+  resolution: "@babel/runtime-corejs2@npm:7.24.4"
+  dependencies:
+    core-js: ^2.6.12
+    regenerator-runtime: ^0.14.0
+  checksum: f164006b7b63093ff407bc84988427bf56f01b1764a1eca2478139dcff131411e7c2ea10657e116ae47ccc0e96b165c8329a96336d2d421a05bb34d0292d3521
+  languageName: node
+  linkType: hard
+
+"@babel/runtime-corejs3@npm:^7.12.1":
+  version: 7.14.7
+  resolution: "@babel/runtime-corejs3@npm:7.14.7"
+  dependencies:
+    core-js-pure: ^3.15.0
+    regenerator-runtime: ^0.13.4
+  checksum: 9e49fc27e4de9fd5a97069aeeb0746cf0e42afe2068fa717a8abec740782a58d6bb1dc635c37a7bd47c40f3945fabad6308a44915520e15c7651f34b24b89d6f
+  languageName: node
+  linkType: hard
+
+"@babel/runtime@npm:7.0.0":
+  version: 7.0.0
+  resolution: "@babel/runtime@npm:7.0.0"
+  dependencies:
+    regenerator-runtime: ^0.12.0
+  checksum: 7dbeb4bfa5762b80b4137f21009d2b754dd1adba0390d909d53397c510c1e708677302774d69386f936a26fb898f055c32a0c668e2d908d78d36f76d0c3d04dd
+  languageName: node
+  linkType: hard
+
+"@babel/runtime@npm:7.9.0":
+  version: 7.9.0
+  resolution: "@babel/runtime@npm:7.9.0"
+  dependencies:
+    regenerator-runtime: ^0.13.4
+  checksum: dc9b50c1893460e71d93c299e659b61b41a37780e78c1e1404b360ca275d44b79107c0d986d74ac8163757f02384cda94d8315bded40deafe72ca07f7761a098
+  languageName: node
+  linkType: hard
+
+"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.3.4, @babel/runtime@npm:^7.4.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
+  version: 7.14.6
+  resolution: "@babel/runtime@npm:7.14.6"
+  dependencies:
+    regenerator-runtime: ^0.13.4
+  checksum: 927ffed7871f2ed29f967a8dad7a72aa10662f93b6735a89d664a161fa4dc2074b8947ca159a8a0a49cec9a71c8de473d7c2b22d3de0ee4d7dd06d24a7f98018
+  languageName: node
+  linkType: hard
+
+"@babel/template@npm:^7.14.5, @babel/template@npm:^7.4.0, @babel/template@npm:^7.8.6":
+  version: 7.14.5
+  resolution: "@babel/template@npm:7.14.5"
+  dependencies:
+    "@babel/code-frame": ^7.14.5
+    "@babel/parser": ^7.14.5
+    "@babel/types": ^7.14.5
+  checksum: 4939199c5b1ca8940e14c87f30f4fab5f35c909bef88447131075349027546927b4e3e08e50db5c2db2024f2c6585a4fe571c739c835ac980f7a4ada2dd8a623
+  languageName: node
+  linkType: hard
+
+"@babel/template@npm:^7.22.15, @babel/template@npm:^7.24.0":
+  version: 7.24.0
+  resolution: "@babel/template@npm:7.24.0"
+  dependencies:
+    "@babel/code-frame": ^7.23.5
+    "@babel/parser": ^7.24.0
+    "@babel/types": ^7.24.0
+  checksum: f257b003c071a0cecdbfceca74185f18fe62c055469ab5c1d481aab12abeebed328e67e0a19fd978a2a8de97b28953fa4bc3da6d038a7345fdf37923b9fcdec8
+  languageName: node
+  linkType: hard
+
+"@babel/traverse@npm:^7.1.0, @babel/traverse@npm:^7.13.0, @babel/traverse@npm:^7.14.5, @babel/traverse@npm:^7.24.1, @babel/traverse@npm:^7.4.3, @babel/traverse@npm:^7.7.0, @babel/traverse@npm:^7.9.0":
+  version: 7.24.1
+  resolution: "@babel/traverse@npm:7.24.1"
+  dependencies:
+    "@babel/code-frame": ^7.24.1
+    "@babel/generator": ^7.24.1
+    "@babel/helper-environment-visitor": ^7.22.20
+    "@babel/helper-function-name": ^7.23.0
+    "@babel/helper-hoist-variables": ^7.22.5
+    "@babel/helper-split-export-declaration": ^7.22.6
+    "@babel/parser": ^7.24.1
+    "@babel/types": ^7.24.0
+    debug: ^4.3.1
+    globals: ^11.1.0
+  checksum: 92a5ca906abfba9df17666d2001ab23f18600035f706a687055a0e392a690ae48d6fec67c8bd4ef19ba18699a77a5b7f85727e36b83f7d110141608fe0c24fe9
+  languageName: node
+  linkType: hard
+
+"@babel/types@npm:^7.0.0, @babel/types@npm:^7.14.5, @babel/types@npm:^7.3.0, @babel/types@npm:^7.4.0, @babel/types@npm:^7.4.4, @babel/types@npm:^7.7.0, @babel/types@npm:^7.9.0":
+  version: 7.14.5
+  resolution: "@babel/types@npm:7.14.5"
+  dependencies:
+    "@babel/helper-validator-identifier": ^7.14.5
+    to-fast-properties: ^2.0.0
+  checksum: 7c1ab6e8bdf438d44236034cab10f7d0f1971179bc405dca26733a9b89dd87dd692dc49a238a7495075bc41a9a17fb6f08b4d1da45ea6ddcce1e5c8593574aea
+  languageName: node
+  linkType: hard
+
+"@babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.24.0":
+  version: 7.24.0
+  resolution: "@babel/types@npm:7.24.0"
+  dependencies:
+    "@babel/helper-string-parser": ^7.23.4
+    "@babel/helper-validator-identifier": ^7.22.20
+    to-fast-properties: ^2.0.0
+  checksum: 4b574a37d490f621470ff36a5afaac6deca5546edcb9b5e316d39acbb20998e9c2be42f3fc0bf2b55906fc49ff2a5a6a097e8f5a726ee3f708a0b0ca93aed807
+  languageName: node
+  linkType: hard
+
+"@babel/types@npm:^7.8.3":
+  version: 7.17.0
+  resolution: "@babel/types@npm:7.17.0"
+  dependencies:
+    "@babel/helper-validator-identifier": ^7.16.7
+    to-fast-properties: ^2.0.0
+  checksum: 12e5a287986fe557188e87b2c5202223f1dc83d9239a196ab936fdb9f8c1eb0be717ff19f934b5fad4e29a75586d5798f74bed209bccea1c20376b9952056f0e
+  languageName: node
+  linkType: hard
+
+"@cnakazawa/watch@npm:^1.0.3":
+  version: 1.0.4
+  resolution: "@cnakazawa/watch@npm:1.0.4"
+  dependencies:
+    exec-sh: ^0.3.2
+    minimist: ^1.2.0
+  bin:
+    watch: cli.js
+  checksum: 88f395ca0af2f3c0665b8ce7bb29e83647ec5d141e8735712aeeee4117081555436712966b6957aa1c461f6f826a4d23b0034e379c443a10e919f81c8748bf29
+  languageName: node
+  linkType: hard
+
+"@colors/colors@npm:1.5.0":
+  version: 1.5.0
+  resolution: "@colors/colors@npm:1.5.0"
+  checksum: d64d5260bed1d5012ae3fc617d38d1afc0329fec05342f4e6b838f46998855ba56e0a73833f4a80fa8378c84810da254f76a8a19c39d038260dc06dc4e007425
+  languageName: node
+  linkType: hard
+
+"@coreui/coreui@npm:^4.3.2":
+  version: 4.3.2
+  resolution: "@coreui/coreui@npm:4.3.2"
+  dependencies:
+    postcss-combine-duplicated-selectors: ^10.0.3
+  peerDependencies:
+    "@popperjs/core": ^2.11.6
+  checksum: 88fc70f4f681bb796e1d81ca8472a3d36bfcf92866fc7c6810ead850bc371c99bca123a94abb0fafdf2935972d130005cd62b485406631cfd9abd8f38e14be15
+  languageName: node
+  linkType: hard
+
+"@coreui/react@npm:^4.11.0":
+  version: 4.11.0
+  resolution: "@coreui/react@npm:4.11.0"
+  peerDependencies:
+    "@coreui/coreui": 4.3.0
+    react: ">=17"
+    react-dom: ">=17"
+  checksum: 75c9394125e41e24fb5855b82cba93c9abeea080f9ee5bcc063ff2e581318b85c5bbef6f2c5300f5fd7a3450743488daa29b4baee6feabec38a009a452876a88
+  languageName: node
+  linkType: hard
+
+"@csstools/convert-colors@npm:^1.4.0":
+  version: 1.4.0
+  resolution: "@csstools/convert-colors@npm:1.4.0"
+  checksum: 26069eeb845a506934c821c203feb97f5de634c5fbeb9978505a2271d6cfdb0ce400240fca9620a4ef2e68953928ea25aab92ea8454e0edf5cd074066d9ad57b
+  languageName: node
+  linkType: hard
+
+"@csstools/normalize.css@npm:^10.1.0":
+  version: 10.1.0
+  resolution: "@csstools/normalize.css@npm:10.1.0"
+  checksum: c0adedd58e16b73b6588377ca505bfbc3f6273ab1ba1b55dd343aa5e4c0bf81bd74f051a1317a0d383bdcd59af665ba34db00b0c51c7dbc176c1a536175c2b03
+  languageName: node
+  linkType: hard
+
+"@cypress/request@npm:^3.0.0":
+  version: 3.0.1
+  resolution: "@cypress/request@npm:3.0.1"
+  dependencies:
+    aws-sign2: ~0.7.0
+    aws4: ^1.8.0
+    caseless: ~0.12.0
+    combined-stream: ~1.0.6
+    extend: ~3.0.2
+    forever-agent: ~0.6.1
+    form-data: ~2.3.2
+    http-signature: ~1.3.6
+    is-typedarray: ~1.0.0
+    isstream: ~0.1.2
+    json-stringify-safe: ~5.0.1
+    mime-types: ~2.1.19
+    performance-now: ^2.1.0
+    qs: 6.10.4
+    safe-buffer: ^5.1.2
+    tough-cookie: ^4.1.3
+    tunnel-agent: ^0.6.0
+    uuid: ^8.3.2
+  checksum: 7175522ebdbe30e3c37973e204c437c23ce659e58d5939466615bddcd58d778f3a8ea40f087b965ae8b8138ea8d102b729c6eb18c6324f121f3778f4a2e8e727
+  languageName: node
+  linkType: hard
+
+"@cypress/xvfb@npm:^1.2.4":
+  version: 1.2.4
+  resolution: "@cypress/xvfb@npm:1.2.4"
+  dependencies:
+    debug: ^3.1.0
+    lodash.once: ^4.1.1
+  checksum: 7bdcdaeb1bb692ec9d9bf8ec52538aa0bead6764753f4a067a171a511807a43fab016f7285a56bef6a606c2467ff3f1365e1ad2d2d583b81beed849ee1573fd1
+  languageName: node
+  linkType: hard
+
+"@date-io/core@npm:^1.3.13":
+  version: 1.3.13
+  resolution: "@date-io/core@npm:1.3.13"
+  checksum: 5a9e9d1de20f0346a3c7d2d5946190caef4bfb0b64d82ba1f4c566657a9192667c94ebe7f438d11d4286d9c190974daad4fb2159294225cd8af4d9a140239879
+  languageName: node
+  linkType: hard
+
+"@date-io/date-fns@npm:1":
+  version: 1.3.13
+  resolution: "@date-io/date-fns@npm:1.3.13"
+  dependencies:
+    "@date-io/core": ^1.3.13
+  peerDependencies:
+    date-fns: ^2.0.0
+  checksum: 0026c0e538ea4add57a11936ff6bdb07e99f25275f8bb28c4702bbb7e82c3a41b3e8124132aa719180d462c01a26a3b4801e41b7349cdb73813749d4bf5e8fbd
+  languageName: node
+  linkType: hard
+
+"@emotion/is-prop-valid@npm:1.2.1":
+  version: 1.2.1
+  resolution: "@emotion/is-prop-valid@npm:1.2.1"
+  dependencies:
+    "@emotion/memoize": ^0.8.1
+  checksum: 8f42dc573a3fad79b021479becb639b8fe3b60bdd1081a775d32388bca418ee53074c7602a4c845c5f75fa6831eb1cbdc4d208cc0299f57014ed3a02abcad16a
+  languageName: node
+  linkType: hard
+
+"@emotion/memoize@npm:^0.8.1":
+  version: 0.8.1
+  resolution: "@emotion/memoize@npm:0.8.1"
+  checksum: a19cc01a29fcc97514948eaab4dc34d8272e934466ed87c07f157887406bc318000c69ae6f813a9001c6a225364df04249842a50e692ef7a9873335fbcc141b0
+  languageName: node
+  linkType: hard
+
+"@emotion/unitless@npm:0.8.0":
+  version: 0.8.0
+  resolution: "@emotion/unitless@npm:0.8.0"
+  checksum: 176141117ed23c0eb6e53a054a69c63e17ae532ec4210907a20b2208f91771821835f1c63dd2ec63e30e22fcc984026d7f933773ee6526dd038e0850919fae7a
+  languageName: node
+  linkType: hard
+
+"@fortawesome/fontawesome-common-types@npm:^0.2.28":
+  version: 0.2.35
+  resolution: "@fortawesome/fontawesome-common-types@npm:0.2.35"
+  checksum: fc5e0e9182d52ccac244754beafae907f5623350e8437fa5e118278927988436a28d16700ee4b03b2fee96c736c1e45f2d58158a355fd1d187faebc4e3782f30
+  languageName: node
+  linkType: hard
+
+"@fortawesome/fontawesome-svg-core@npm:1.2.28":
+  version: 1.2.28
+  resolution: "@fortawesome/fontawesome-svg-core@npm:1.2.28"
+  dependencies:
+    "@fortawesome/fontawesome-common-types": ^0.2.28
+  checksum: 6536c6582cfdc5c9ef98ef190dd481c50c78f5be396cb8bdf557258ff969b3c9c66186eb07f6a7e1c9ac1b68cf8e89f4ba2a0b5a1a2e32bd65e5f06b7f3a9500
+  languageName: node
+  linkType: hard
+
+"@fortawesome/free-solid-svg-icons@npm:5.13.0":
+  version: 5.13.0
+  resolution: "@fortawesome/free-solid-svg-icons@npm:5.13.0"
+  dependencies:
+    "@fortawesome/fontawesome-common-types": ^0.2.28
+  checksum: c697891bfcb2b393e5b0126684332acb74d81d74327ab4952498e40480292e551cbf47fc4ac35e71a6dc6c5e34a1642b0c0cea134eb5b69f774253da57acf20b
+  languageName: node
+  linkType: hard
+
+"@fortawesome/react-fontawesome@npm:0.1.9":
+  version: 0.1.9
+  resolution: "@fortawesome/react-fontawesome@npm:0.1.9"
+  dependencies:
+    prop-types: ^15.7.2
+  peerDependencies:
+    "@fortawesome/fontawesome-svg-core": ^1.2.20
+    react: 16.x
+  checksum: 404dd10663cfc7429a4f1600433a3e41d233a167b1278f98bb8e29dfcb9673abd1392ed43846781e6e9e5e7331c2e566e149da503635b126fc48eaafc8fa3914
+  languageName: node
+  linkType: hard
+
+"@gar/promisify@npm:^1.0.1, @gar/promisify@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "@gar/promisify@npm:1.1.3"
+  checksum: 4059f790e2d07bf3c3ff3e0fec0daa8144fe35c1f6e0111c9921bd32106adaa97a4ab096ad7dab1e28ee6a9060083c4d1a4ada42a7f5f3f7a96b8812e2b757c1
+  languageName: node
+  linkType: hard
+
+"@hapi/address@npm:2.x.x":
+  version: 2.1.4
+  resolution: "@hapi/address@npm:2.1.4"
+  checksum: 10341c3b650746c79ac2c57118efb05d45d850b33aef82a6f2ba89419fdbf1b6d337c8b085cc9bc1af7a5fb18379c07edaf3be7584426f40bd370ed6de29e965
+  languageName: node
+  linkType: hard
+
+"@hapi/address@npm:^4.0.1":
+  version: 4.1.0
+  resolution: "@hapi/address@npm:4.1.0"
+  dependencies:
+    "@hapi/hoek": ^9.0.0
+  checksum: 01e8257c9428be2a2a0441b24dcab2396a6ca20f53799fd921035b45f9f62a6014f1a0dfaf2dd10912a8b0098a9ba53c3f0863a67e7acbadddeb3d2785591cfc
+  languageName: node
+  linkType: hard
+
+"@hapi/bourne@npm:1.x.x":
+  version: 1.3.2
+  resolution: "@hapi/bourne@npm:1.3.2"
+  checksum: 8403a2e8297fbb49a0e4fca30e874590d96ecaf7165740804037ff30625f3fdea6353d9f7f4422607eb069a3f471900a3037df34285a95135d15c6a8b10094b0
+  languageName: node
+  linkType: hard
+
+"@hapi/formula@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "@hapi/formula@npm:2.0.0"
+  checksum: 902da057c53027d9356de15e53a8af9ea4795d3fb78c066668b36cfca54abd2d707860469618448e7de02eb8364ead86e53b839dbd363e1aeb65ee9a195e5fad
+  languageName: node
+  linkType: hard
+
+"@hapi/hoek@npm:8.x.x, @hapi/hoek@npm:^8.3.0":
+  version: 8.5.1
+  resolution: "@hapi/hoek@npm:8.5.1"
+  checksum: 8f8ce36be4f5e5d7a712072d4a028a4a95beec7a7da3a7a6e9a0c07d697f04c19b37d93751db352c314ea831f7e2120569a035dc6a351ed8c0444f1d3b275621
+  languageName: node
+  linkType: hard
+
+"@hapi/hoek@npm:^9.0.0":
+  version: 9.2.0
+  resolution: "@hapi/hoek@npm:9.2.0"
+  checksum: 57103bb5074d24ffd876f559bac6b312f2f58fe0f21dbfb0b8941032cba4fd37d92249db366516e1f68e2033834b87001c1558f523b48130b21f823f1e35b91a
+  languageName: node
+  linkType: hard
+
+"@hapi/joi@npm:^15.0.0":
+  version: 15.1.1
+  resolution: "@hapi/joi@npm:15.1.1"
+  dependencies:
+    "@hapi/address": 2.x.x
+    "@hapi/bourne": 1.x.x
+    "@hapi/hoek": 8.x.x
+    "@hapi/topo": 3.x.x
+  checksum: 5bc3df9d43d4a53c41fd172d2958a4a846dbacbe2a2abe16830059109c436776d7be98144f14af9d328ebd107dbebafe55e00a9032a905aef45aadff988b54bf
+  languageName: node
+  linkType: hard
+
+"@hapi/joi@npm:^17.1.1":
+  version: 17.1.1
+  resolution: "@hapi/joi@npm:17.1.1"
+  dependencies:
+    "@hapi/address": ^4.0.1
+    "@hapi/formula": ^2.0.0
+    "@hapi/hoek": ^9.0.0
+    "@hapi/pinpoint": ^2.0.0
+    "@hapi/topo": ^5.0.0
+  checksum: 803d77e19e26802860de22b8d1ae3435679844202703d20bfd6246a8c6aa00143ce48d0e8beecf55812334e8e89ac04b79301264d4a3f87c980eb0c0646b33ab
+  languageName: node
+  linkType: hard
+
+"@hapi/pinpoint@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "@hapi/pinpoint@npm:2.0.0"
+  checksum: ed011c0af4eeab75f7ea2b4657f16a93e31d449a43b40a4f398e2f1a0752357af2badd7b3f3e2da9554cb9d13adb52bde123b250acef1709260954d0301eed16
+  languageName: node
+  linkType: hard
+
+"@hapi/topo@npm:3.x.x":
+  version: 3.1.6
+  resolution: "@hapi/topo@npm:3.1.6"
+  dependencies:
+    "@hapi/hoek": ^8.3.0
+  checksum: 34278bc13b4023d6d0d7c913605a254b2d728dc6489cd465269eebaa7d8d2d81cda3f2157398dca87d3cb9e1a8aa8a1158ce2564c71a8e289b807c144e3b4c1e
+  languageName: node
+  linkType: hard
+
+"@hapi/topo@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "@hapi/topo@npm:5.0.0"
+  dependencies:
+    "@hapi/hoek": ^9.0.0
+  checksum: 8aa81f71696f88d7daeab4547e120e43c6ab78081a4f215eec5103dd858f3122a703512cdacc43aa7e27d99607345165acfeb2ee69e556e63afd50c5c57a36c3
+  languageName: node
+  linkType: hard
+
+"@jest/console@npm:^24.7.1, @jest/console@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/console@npm:24.9.0"
+  dependencies:
+    "@jest/source-map": ^24.9.0
+    chalk: ^2.0.1
+    slash: ^2.0.0
+  checksum: ee6468c4aeeb8752126e92e20b0ffbf32abda731e9b7865b63b60bd569c3536e9c901efcec4d81c506a7c6fea2a970ace8262190961aba31dedbfeaa3459d78b
+  languageName: node
+  linkType: hard
+
+"@jest/core@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/core@npm:24.9.0"
+  dependencies:
+    "@jest/console": ^24.7.1
+    "@jest/reporters": ^24.9.0
+    "@jest/test-result": ^24.9.0
+    "@jest/transform": ^24.9.0
+    "@jest/types": ^24.9.0
+    ansi-escapes: ^3.0.0
+    chalk: ^2.0.1
+    exit: ^0.1.2
+    graceful-fs: ^4.1.15
+    jest-changed-files: ^24.9.0
+    jest-config: ^24.9.0
+    jest-haste-map: ^24.9.0
+    jest-message-util: ^24.9.0
+    jest-regex-util: ^24.3.0
+    jest-resolve: ^24.9.0
+    jest-resolve-dependencies: ^24.9.0
+    jest-runner: ^24.9.0
+    jest-runtime: ^24.9.0
+    jest-snapshot: ^24.9.0
+    jest-util: ^24.9.0
+    jest-validate: ^24.9.0
+    jest-watcher: ^24.9.0
+    micromatch: ^3.1.10
+    p-each-series: ^1.0.0
+    realpath-native: ^1.1.0
+    rimraf: ^2.5.4
+    slash: ^2.0.0
+    strip-ansi: ^5.0.0
+  checksum: 44d63883bc410ea2448eb359c417b92d9dd5fb9bec51f28bde2bd87ade705c4f0f6698f0c251a679204e860bf865120c58725cf397465862c99a70327bcb99fc
+  languageName: node
+  linkType: hard
+
+"@jest/environment@npm:^24.3.0, @jest/environment@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/environment@npm:24.9.0"
+  dependencies:
+    "@jest/fake-timers": ^24.9.0
+    "@jest/transform": ^24.9.0
+    "@jest/types": ^24.9.0
+    jest-mock: ^24.9.0
+  checksum: 6a663c05713ad0cd1dc7c5bf715a3e5e655e73ee02497ab0a9dea4fe0855226504535c504d265c054c8b4bafb1216dff0e7e0e3b4ed064bda4c3d6efe74fe369
+  languageName: node
+  linkType: hard
+
+"@jest/fake-timers@npm:^24.3.0, @jest/fake-timers@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/fake-timers@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+    jest-message-util: ^24.9.0
+    jest-mock: ^24.9.0
+  checksum: d49ab33e28b070d5be75659ed89d4b79e74012c8c28ecf51cf9b89732ba5b2a57129787dd144949c048a0460ed62f1e32079a4b10d896c75bde024699d7a2c5c
+  languageName: node
+  linkType: hard
+
+"@jest/reporters@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/reporters@npm:24.9.0"
+  dependencies:
+    "@jest/environment": ^24.9.0
+    "@jest/test-result": ^24.9.0
+    "@jest/transform": ^24.9.0
+    "@jest/types": ^24.9.0
+    chalk: ^2.0.1
+    exit: ^0.1.2
+    glob: ^7.1.2
+    istanbul-lib-coverage: ^2.0.2
+    istanbul-lib-instrument: ^3.0.1
+    istanbul-lib-report: ^2.0.4
+    istanbul-lib-source-maps: ^3.0.1
+    istanbul-reports: ^2.2.6
+    jest-haste-map: ^24.9.0
+    jest-resolve: ^24.9.0
+    jest-runtime: ^24.9.0
+    jest-util: ^24.9.0
+    jest-worker: ^24.6.0
+    node-notifier: ^5.4.2
+    slash: ^2.0.0
+    source-map: ^0.6.0
+    string-length: ^2.0.0
+  checksum: 588539d0d9a5e483e5e09c1dd7c42b6490199cb0588a9ae8eb1b2c34a74cf7da0bba5dd425c19307a9d95a075bfc4feb0221d3847b9542a3a727342e3f30e5a1
+  languageName: node
+  linkType: hard
+
+"@jest/source-map@npm:^24.3.0, @jest/source-map@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/source-map@npm:24.9.0"
+  dependencies:
+    callsites: ^3.0.0
+    graceful-fs: ^4.1.15
+    source-map: ^0.6.0
+  checksum: 00479faf6854d5d183b94465db1a0876980ced72bf26cb6a2fe8c04977dc2692e6529faa6b64269492d1d9cab51feebaac9d453d1e6bb1306fc15777143b72af
+  languageName: node
+  linkType: hard
+
+"@jest/test-result@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/test-result@npm:24.9.0"
+  dependencies:
+    "@jest/console": ^24.9.0
+    "@jest/types": ^24.9.0
+    "@types/istanbul-lib-coverage": ^2.0.0
+  checksum: 7145c7baa289798881160b3cfa5b2466b2636238a52b77cf46e5468ffe2881fb8fb8d4966155a8d508b26a8d29a302a9eb9037de1a371e5dc9bb6e94837c0ae7
+  languageName: node
+  linkType: hard
+
+"@jest/test-sequencer@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/test-sequencer@npm:24.9.0"
+  dependencies:
+    "@jest/test-result": ^24.9.0
+    jest-haste-map: ^24.9.0
+    jest-runner: ^24.9.0
+    jest-runtime: ^24.9.0
+  checksum: 049bea54743925b361bf10acce8a1de8e9a2ac9b5158044d484f3fc5748f975d52d8260e9ff2621fc29b5b586a17e54693670c7dfa75b09f5e83e87f2a63aac2
+  languageName: node
+  linkType: hard
+
+"@jest/transform@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/transform@npm:24.9.0"
+  dependencies:
+    "@babel/core": ^7.1.0
+    "@jest/types": ^24.9.0
+    babel-plugin-istanbul: ^5.1.0
+    chalk: ^2.0.1
+    convert-source-map: ^1.4.0
+    fast-json-stable-stringify: ^2.0.0
+    graceful-fs: ^4.1.15
+    jest-haste-map: ^24.9.0
+    jest-regex-util: ^24.9.0
+    jest-util: ^24.9.0
+    micromatch: ^3.1.10
+    pirates: ^4.0.1
+    realpath-native: ^1.1.0
+    slash: ^2.0.0
+    source-map: ^0.6.1
+    write-file-atomic: 2.4.1
+  checksum: 0153bcd6a9b464c85ee8b67c360f745ab8e41b1b363220f1f12ed644a667dceb6666366017f7f849a8f6cde960020b638b8557eae852af0537520b0903881fbd
+  languageName: node
+  linkType: hard
+
+"@jest/types@npm:^24.3.0, @jest/types@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "@jest/types@npm:24.9.0"
+  dependencies:
+    "@types/istanbul-lib-coverage": ^2.0.0
+    "@types/istanbul-reports": ^1.1.1
+    "@types/yargs": ^13.0.0
+  checksum: 603698f774cf22f9d16a0e0fac9e10e7db21052aebfa33db154c8a5940e0eb1fa9c079a8c91681041ad3aeee2adfa950608dd0c663130316ba034b8bca7b301c
+  languageName: node
+  linkType: hard
+
+"@jest/types@npm:^26.6.2":
+  version: 26.6.2
+  resolution: "@jest/types@npm:26.6.2"
+  dependencies:
+    "@types/istanbul-lib-coverage": ^2.0.0
+    "@types/istanbul-reports": ^3.0.0
+    "@types/node": "*"
+    "@types/yargs": ^15.0.0
+    chalk: ^4.0.0
+  checksum: a0bd3d2f22f26ddb23f41fddf6e6a30bf4fab2ce79ec1cb6ce6fdfaf90a72e00f4c71da91ec61e13db3b10c41de22cf49d07c57ff2b59171d64b29f909c1d8d6
+  languageName: node
+  linkType: hard
+
+"@jridgewell/gen-mapping@npm:^0.3.5":
+  version: 0.3.5
+  resolution: "@jridgewell/gen-mapping@npm:0.3.5"
+  dependencies:
+    "@jridgewell/set-array": ^1.2.1
+    "@jridgewell/sourcemap-codec": ^1.4.10
+    "@jridgewell/trace-mapping": ^0.3.24
+  checksum: ff7a1764ebd76a5e129c8890aa3e2f46045109dabde62b0b6c6a250152227647178ff2069ea234753a690d8f3c4ac8b5e7b267bbee272bffb7f3b0a370ab6e52
+  languageName: node
+  linkType: hard
+
+"@jridgewell/resolve-uri@npm:^3.1.0":
+  version: 3.1.2
+  resolution: "@jridgewell/resolve-uri@npm:3.1.2"
+  checksum: 83b85f72c59d1c080b4cbec0fef84528963a1b5db34e4370fa4bd1e3ff64a0d80e0cee7369d11d73c704e0286fb2865b530acac7a871088fbe92b5edf1000870
+  languageName: node
+  linkType: hard
+
+"@jridgewell/set-array@npm:^1.2.1":
+  version: 1.2.1
+  resolution: "@jridgewell/set-array@npm:1.2.1"
+  checksum: 832e513a85a588f8ed4f27d1279420d8547743cc37fcad5a5a76fc74bb895b013dfe614d0eed9cb860048e6546b798f8f2652020b4b2ba0561b05caa8c654b10
+  languageName: node
+  linkType: hard
+
+"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14":
+  version: 1.4.15
+  resolution: "@jridgewell/sourcemap-codec@npm:1.4.15"
+  checksum: b881c7e503db3fc7f3c1f35a1dd2655a188cc51a3612d76efc8a6eb74728bef5606e6758ee77423e564092b4a518aba569bbb21c9bac5ab7a35b0c6ae7e344c8
+  languageName: node
+  linkType: hard
+
+"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25":
+  version: 0.3.25
+  resolution: "@jridgewell/trace-mapping@npm:0.3.25"
+  dependencies:
+    "@jridgewell/resolve-uri": ^3.1.0
+    "@jridgewell/sourcemap-codec": ^1.4.14
+  checksum: 9d3c40d225e139987b50c48988f8717a54a8c994d8a948ee42e1412e08988761d0754d7d10b803061cc3aebf35f92a5dbbab493bd0e1a9ef9e89a2130e83ba34
+  languageName: node
+  linkType: hard
+
+"@material-ui/core@npm:3.9.3":
+  version: 3.9.3
+  resolution: "@material-ui/core@npm:3.9.3"
+  dependencies:
+    "@babel/runtime": ^7.2.0
+    "@material-ui/system": ^3.0.0-alpha.0
+    "@material-ui/utils": ^3.0.0-alpha.2
+    "@types/jss": ^9.5.6
+    "@types/react-transition-group": ^2.0.8
+    brcast: ^3.0.1
+    classnames: ^2.2.5
+    csstype: ^2.5.2
+    debounce: ^1.1.0
+    deepmerge: ^3.0.0
+    dom-helpers: ^3.2.1
+    hoist-non-react-statics: ^3.2.1
+    is-plain-object: ^2.0.4
+    jss: ^9.8.7
+    jss-camel-case: ^6.0.0
+    jss-default-unit: ^8.0.2
+    jss-global: ^3.0.0
+    jss-nested: ^6.0.1
+    jss-props-sort: ^6.0.0
+    jss-vendor-prefixer: ^7.0.0
+    normalize-scroll-left: ^0.1.2
+    popper.js: ^1.14.1
+    prop-types: ^15.6.0
+    react-event-listener: ^0.6.2
+    react-transition-group: ^2.2.1
+    recompose: 0.28.0 - 0.30.0
+    warning: ^4.0.1
+  peerDependencies:
+    react: ^16.3.0
+    react-dom: ^16.3.0
+  checksum: 127681c84d17b95b571feb53593ce284c012de8eb30688ddcec221eea27bf6243dc353d63c43b58a0868cf46699b19651e841d51d070d35c713f232c3c3dd66a
+  languageName: node
+  linkType: hard
+
+"@material-ui/icons@npm:3.0.1":
+  version: 3.0.1
+  resolution: "@material-ui/icons@npm:3.0.1"
+  dependencies:
+    "@babel/runtime": 7.0.0
+    recompose: ^0.29.0
+  peerDependencies:
+    "@material-ui/core": ^3.0.0
+    react: ^16.3.0
+    react-dom: ^16.3.0
+  checksum: 25ae5aff782f987ef82e06dd21d28be669f58f33734652ae35b0963fb2bc8ae990f00e4b67865431d0770845ef749a08afd3a3fcb383f94c55e3f7bed1da931b
+  languageName: node
+  linkType: hard
+
+"@material-ui/system@npm:^3.0.0-alpha.0":
+  version: 3.0.0-alpha.2
+  resolution: "@material-ui/system@npm:3.0.0-alpha.2"
+  dependencies:
+    "@babel/runtime": ^7.2.0
+    deepmerge: ^3.0.0
+    prop-types: ^15.6.0
+    warning: ^4.0.1
+  peerDependencies:
+    react: ^16.3.0
+    react-dom: ^16.3.0
+  checksum: 3aacb8b4afe2a820844b90905cfcd86ab934108cd719f11713ee46f8a4da30cb8c50e86219e1440b3c6dd333a09d4123cd1990d9c51862c1d56ec2b36e4d7ded
+  languageName: node
+  linkType: hard
+
+"@material-ui/utils@npm:^3.0.0-alpha.2":
+  version: 3.0.0-alpha.3
+  resolution: "@material-ui/utils@npm:3.0.0-alpha.3"
+  dependencies:
+    "@babel/runtime": ^7.2.0
+    prop-types: ^15.6.0
+    react-is: ^16.6.3
+  peerDependencies:
+    react: ^16.3.0
+    react-dom: ^16.3.0
+  checksum: 589a16245338c374b604a793d9f7a05bd932c9710f78261e46176b74a61d5d308b3e36dab688988ee5177d689c53d9e9da4649eca009ea11d8aac948db27af50
+  languageName: node
+  linkType: hard
+
+"@mrmlnc/readdir-enhanced@npm:^2.2.1":
+  version: 2.2.1
+  resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1"
+  dependencies:
+    call-me-maybe: ^1.0.1
+    glob-to-regexp: ^0.3.0
+  checksum: d3b82b29368821154ce8e10bef5ccdbfd070d3e9601643c99ea4607e56f3daeaa4e755dd6d2355da20762c695c1b0570543d9f84b48f70c211ec09c4aaada2e1
+  languageName: node
+  linkType: hard
+
+"@nodelib/fs.scandir@npm:2.1.5":
+  version: 2.1.5
+  resolution: "@nodelib/fs.scandir@npm:2.1.5"
+  dependencies:
+    "@nodelib/fs.stat": 2.0.5
+    run-parallel: ^1.1.9
+  checksum: a970d595bd23c66c880e0ef1817791432dbb7acbb8d44b7e7d0e7a22f4521260d4a83f7f9fd61d44fda4610105577f8f58a60718105fb38352baed612fd79e59
+  languageName: node
+  linkType: hard
+
+"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2":
+  version: 2.0.5
+  resolution: "@nodelib/fs.stat@npm:2.0.5"
+  checksum: 012480b5ca9d97bff9261571dbbec7bbc6033f69cc92908bc1ecfad0792361a5a1994bc48674b9ef76419d056a03efadfce5a6cf6dbc0a36559571a7a483f6f0
+  languageName: node
+  linkType: hard
+
+"@nodelib/fs.stat@npm:^1.1.2":
+  version: 1.1.3
+  resolution: "@nodelib/fs.stat@npm:1.1.3"
+  checksum: 318deab369b518a34778cdaa0054dd28a4381c0c78e40bbd20252f67d084b1d7bf9295fea4423de2c19ac8e1a34f120add9125f481b2a710f7068bcac7e3e305
+  languageName: node
+  linkType: hard
+
+"@nodelib/fs.walk@npm:^1.2.3":
+  version: 1.2.7
+  resolution: "@nodelib/fs.walk@npm:1.2.7"
+  dependencies:
+    "@nodelib/fs.scandir": 2.1.5
+    fastq: ^1.6.0
+  checksum: f5286c39c2f9cc0e89b2cbee6b735c5cf572c37f9c0a47a16ce3c1d9ba5d488f3153976ceb1b984ad09dbd8d1de620fab3e7b0ef2b64a006267d0895a16ce95c
+  languageName: node
+  linkType: hard
+
+"@npmcli/fs@npm:^1.0.0":
+  version: 1.1.1
+  resolution: "@npmcli/fs@npm:1.1.1"
+  dependencies:
+    "@gar/promisify": ^1.0.1
+    semver: ^7.3.5
+  checksum: f5ad92f157ed222e4e31c352333d0901df02c7c04311e42a81d8eb555d4ec4276ea9c635011757de20cc476755af33e91622838de573b17e52e2e7703f0a9965
+  languageName: node
+  linkType: hard
+
+"@npmcli/fs@npm:^2.1.0":
+  version: 2.1.2
+  resolution: "@npmcli/fs@npm:2.1.2"
+  dependencies:
+    "@gar/promisify": ^1.1.3
+    semver: ^7.3.5
+  checksum: 405074965e72d4c9d728931b64d2d38e6ea12066d4fad651ac253d175e413c06fe4350970c783db0d749181da8fe49c42d3880bd1cbc12cd68e3a7964d820225
+  languageName: node
+  linkType: hard
+
+"@npmcli/move-file@npm:^1.0.1":
+  version: 1.1.2
+  resolution: "@npmcli/move-file@npm:1.1.2"
+  dependencies:
+    mkdirp: ^1.0.4
+    rimraf: ^3.0.2
+  checksum: c96381d4a37448ea280951e46233f7e541058cf57a57d4094dd4bdcaae43fa5872b5f2eb6bfb004591a68e29c5877abe3cdc210cb3588cbf20ab2877f31a7de7
+  languageName: node
+  linkType: hard
+
+"@npmcli/move-file@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "@npmcli/move-file@npm:2.0.1"
+  dependencies:
+    mkdirp: ^1.0.4
+    rimraf: ^3.0.2
+  checksum: 52dc02259d98da517fae4cb3a0a3850227bdae4939dda1980b788a7670636ca2b4a01b58df03dd5f65c1e3cb70c50fa8ce5762b582b3f499ec30ee5ce1fd9380
+  languageName: node
+  linkType: hard
+
+"@phenomnomnominal/tsquery@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "@phenomnomnominal/tsquery@npm:3.0.0"
+  dependencies:
+    esquery: ^1.0.1
+  peerDependencies:
+    typescript: ^3
+  checksum: b5fb69000de88861b51140a04065ec9b68569341d1a5d9614937efaf9040ff03e6a3d6fb0b469655b6a32e9526586bb7c2573eedaa10e1d734597bf8857fcb88
+  languageName: node
+  linkType: hard
+
+"@popperjs/core@npm:^2.9.0":
+  version: 2.11.6
+  resolution: "@popperjs/core@npm:2.11.6"
+  checksum: 47fb328cec1924559d759b48235c78574f2d71a8a6c4c03edb6de5d7074078371633b91e39bbf3f901b32aa8af9b9d8f82834856d2f5737a23475036b16817f0
+  languageName: node
+  linkType: hard
+
+"@sinonjs/commons@npm:^1, @sinonjs/commons@npm:^1.3.0, @sinonjs/commons@npm:^1.4.0, @sinonjs/commons@npm:^1.7.0":
+  version: 1.8.3
+  resolution: "@sinonjs/commons@npm:1.8.3"
+  dependencies:
+    type-detect: 4.0.8
+  checksum: 6159726db5ce6bf9f2297f8427f7ca5b3dff45b31e5cee23496f1fa6ef0bb4eab878b23fb2c5e6446381f6a66aba4968ef2fc255c1180d753d4b8c271636a2e5
+  languageName: node
+  linkType: hard
+
+"@sinonjs/commons@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "@sinonjs/commons@npm:3.0.0"
+  dependencies:
+    type-detect: 4.0.8
+  checksum: b4b5b73d4df4560fb8c0c7b38c7ad4aeabedd362f3373859d804c988c725889cde33550e4bcc7cd316a30f5152a2d1d43db71b6d0c38f5feef71fd8d016763f8
+  languageName: node
+  linkType: hard
+
+"@sinonjs/fake-timers@npm:^10.3.0":
+  version: 10.3.0
+  resolution: "@sinonjs/fake-timers@npm:10.3.0"
+  dependencies:
+    "@sinonjs/commons": ^3.0.0
+  checksum: 614d30cb4d5201550c940945d44c9e0b6d64a888ff2cd5b357f95ad6721070d6b8839cd10e15b76bf5e14af0bcc1d8f9ec00d49a46318f1f669a4bec1d7f3148
+  languageName: node
+  linkType: hard
+
+"@sinonjs/formatio@npm:^3.2.1":
+  version: 3.2.2
+  resolution: "@sinonjs/formatio@npm:3.2.2"
+  dependencies:
+    "@sinonjs/commons": ^1
+    "@sinonjs/samsam": ^3.1.0
+  checksum: ddc30698df9b0aa59204da93ca94fd04bc5672ac03c798c0a487c35d514d7d8e0ce0e4c72386fc80e29f59cb1cc54eeea3b7f804281b3c4e3dd2394de56c6e4a
+  languageName: node
+  linkType: hard
+
+"@sinonjs/samsam@npm:^3.1.0, @sinonjs/samsam@npm:^3.3.1":
+  version: 3.3.3
+  resolution: "@sinonjs/samsam@npm:3.3.3"
+  dependencies:
+    "@sinonjs/commons": ^1.3.0
+    array-from: ^2.1.1
+    lodash: ^4.17.15
+  checksum: 340f2f62dec3fa1e5e9300a75996bd12ab9d2da4f6b453fa5d1ee101cc5e58eb218af2e2584b496d41ba31c3c0854d47a691fd9a0d8b9092f3cb6a5c7a080856
+  languageName: node
+  linkType: hard
+
+"@sinonjs/text-encoding@npm:^0.7.1":
+  version: 0.7.1
+  resolution: "@sinonjs/text-encoding@npm:0.7.1"
+  checksum: 130de0bb568c5f8a611ec21d1a4e3f80ab0c5ec333010f49cfc1adc5cba6d8808699c8a587a46b0f0b016a1f4c1389bc96141e773e8460fcbb441875b2e91ba7
+  languageName: node
+  linkType: hard
+
+"@svgr/babel-plugin-add-jsx-attribute@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "@svgr/babel-plugin-add-jsx-attribute@npm:4.2.0"
+  checksum: 3e67319517c4dbed247ca1c28050028fd8990d0745d28424c70db0999143b8e19f2dba563546e1acb842e89d4732149257462b432d6e8935712eba935c5928c3
+  languageName: node
+  linkType: hard
+
+"@svgr/babel-plugin-remove-jsx-attribute@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "@svgr/babel-plugin-remove-jsx-attribute@npm:4.2.0"
+  checksum: cc831c7b77a333548771190bcb50ad5f121c4cd5f397a7628b6c14df93a69e89a1d4a0b36b0bceda91f46c6fed3074406851252368aa6772248b1023f1b903f0
+  languageName: node
+  linkType: hard
+
+"@svgr/babel-plugin-remove-jsx-empty-expression@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "@svgr/babel-plugin-remove-jsx-empty-expression@npm:4.2.0"
+  checksum: dedd4c4b9947b44daa02ab7846f8931463f70eca62bc58a16ffec2dd3538ec12e4e654ce7f4fcea79f88f176bba4548352035a5da0e7bc56c6197d44e0e0bd9c
+  languageName: node
+  linkType: hard
+
+"@svgr/babel-plugin-replace-jsx-attribute-value@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "@svgr/babel-plugin-replace-jsx-attribute-value@npm:4.2.0"
+  checksum: 5c0af9239454ddfa5cadceff6a5292e04601ddb60ccaa78197ff825b9577d868b277d22225be31118926249e79f12f5dc44c03e284838325230e421c98d497ed
+  languageName: node
+  linkType: hard
+
+"@svgr/babel-plugin-svg-dynamic-title@npm:^4.3.3":
+  version: 4.3.3
+  resolution: "@svgr/babel-plugin-svg-dynamic-title@npm:4.3.3"
+  checksum: 401964bb8aa4bcc9fab5f3eca4b73099f6c8a984b791a1b97a5544d6da1108f92ddc32275de8e5a12e330f129532ded6a804efcda20338b2062ce3087309f317
+  languageName: node
+  linkType: hard
+
+"@svgr/babel-plugin-svg-em-dimensions@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "@svgr/babel-plugin-svg-em-dimensions@npm:4.2.0"
+  checksum: f3a86e2e29d1bc67a42bf80240680be5ca59219bc63193e517619385c9888a13eb369cc97bdecbed16b392db7f3faa56cf397f1be215e2ce27316249f9deff97
+  languageName: node
+  linkType: hard
+
+"@svgr/babel-plugin-transform-react-native-svg@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "@svgr/babel-plugin-transform-react-native-svg@npm:4.2.0"
+  checksum: b847d9356fd67844bdb0bf1492f84e3a2adc17ee3e89d3fb26734382155d5b192a5575114d3aafb5bc2e364e4e536ce56c25c92aba1c4c08cef7a5441922cfa4
+  languageName: node
+  linkType: hard
+
+"@svgr/babel-plugin-transform-svg-component@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "@svgr/babel-plugin-transform-svg-component@npm:4.2.0"
+  checksum: 17084831fb03b78d868155f24e47c6d9a92215a7519d17604cdd000f4a2f873ad16c499a7b421c7e2577e7e5ac3dfe02eb693965881a65fe141ad57600e117e1
+  languageName: node
+  linkType: hard
+
+"@svgr/babel-preset@npm:^4.3.3":
+  version: 4.3.3
+  resolution: "@svgr/babel-preset@npm:4.3.3"
+  dependencies:
+    "@svgr/babel-plugin-add-jsx-attribute": ^4.2.0
+    "@svgr/babel-plugin-remove-jsx-attribute": ^4.2.0
+    "@svgr/babel-plugin-remove-jsx-empty-expression": ^4.2.0
+    "@svgr/babel-plugin-replace-jsx-attribute-value": ^4.2.0
+    "@svgr/babel-plugin-svg-dynamic-title": ^4.3.3
+    "@svgr/babel-plugin-svg-em-dimensions": ^4.2.0
+    "@svgr/babel-plugin-transform-react-native-svg": ^4.2.0
+    "@svgr/babel-plugin-transform-svg-component": ^4.2.0
+  checksum: a1ccdd34ac96ecb73f8ebb02a111602935b59cfa824fa9b9c20c38bc88bb9f176caab602f81c1dc3b00b0d56795ebc3d10153e97466291345cfc8eaf92e13d5f
+  languageName: node
+  linkType: hard
+
+"@svgr/core@npm:^4.3.3":
+  version: 4.3.3
+  resolution: "@svgr/core@npm:4.3.3"
+  dependencies:
+    "@svgr/plugin-jsx": ^4.3.3
+    camelcase: ^5.3.1
+    cosmiconfig: ^5.2.1
+  checksum: 014c7dae4e1523ffdb6662a7396975476b802614d5477780b71e292c2fe789faa3e0572ce845b3dbd45098b0e3affdfc63dea742e316c5d1bac2d2c77afd8838
+  languageName: node
+  linkType: hard
+
+"@svgr/hast-util-to-babel-ast@npm:^4.3.2":
+  version: 4.3.2
+  resolution: "@svgr/hast-util-to-babel-ast@npm:4.3.2"
+  dependencies:
+    "@babel/types": ^7.4.4
+  checksum: 0d68084731afd1818ddbaecfc9201a24e10370f88c894eaaf48da9c4db93cd4bf5b7a8e03d1fcd4446165718e5ee124450ecab9f9a22208f87b2fa223ea6d3ca
+  languageName: node
+  linkType: hard
+
+"@svgr/plugin-jsx@npm:^4.3.3":
+  version: 4.3.3
+  resolution: "@svgr/plugin-jsx@npm:4.3.3"
+  dependencies:
+    "@babel/core": ^7.4.5
+    "@svgr/babel-preset": ^4.3.3
+    "@svgr/hast-util-to-babel-ast": ^4.3.2
+    svg-parser: ^2.0.0
+  checksum: 880ae8c6b841c84a71ef3b1b6954089925f4b5f4a1f31767b2ce9004d7f72bfff7d66af05099a3e48612f10b242206d97a0991d366f3648c3e8df5c63cf665f5
+  languageName: node
+  linkType: hard
+
+"@svgr/plugin-svgo@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "@svgr/plugin-svgo@npm:4.3.1"
+  dependencies:
+    cosmiconfig: ^5.2.1
+    merge-deep: ^3.0.2
+    svgo: ^1.2.2
+  checksum: 8d68f29d9c7d9c00fc079de25b58e0f83365cddc0e079e792ec9d76c7a676269029d22466aa9ab8493f0794130711fb6f20e9779cfa197f84da20285c37f2a3c
+  languageName: node
+  linkType: hard
+
+"@svgr/webpack@npm:4.3.3":
+  version: 4.3.3
+  resolution: "@svgr/webpack@npm:4.3.3"
+  dependencies:
+    "@babel/core": ^7.4.5
+    "@babel/plugin-transform-react-constant-elements": ^7.0.0
+    "@babel/preset-env": ^7.4.5
+    "@babel/preset-react": ^7.0.0
+    "@svgr/core": ^4.3.3
+    "@svgr/plugin-jsx": ^4.3.3
+    "@svgr/plugin-svgo": ^4.3.1
+    loader-utils: ^1.2.3
+  checksum: e5ec59ee492c73c26cd22220ac1038fb61681cb22048485aa68edf850328be6effe93900fbb60dab735fad7e6939a598c5c9fe46768c1cb74c1b3a3330555e43
+  languageName: node
+  linkType: hard
+
+"@tootallnate/once@npm:1":
+  version: 1.1.2
+  resolution: "@tootallnate/once@npm:1.1.2"
+  checksum: e1fb1bbbc12089a0cb9433dc290f97bddd062deadb6178ce9bcb93bb7c1aecde5e60184bc7065aec42fe1663622a213493c48bbd4972d931aae48315f18e1be9
+  languageName: node
+  linkType: hard
+
+"@tootallnate/once@npm:2":
+  version: 2.0.0
+  resolution: "@tootallnate/once@npm:2.0.0"
+  checksum: ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8
+  languageName: node
+  linkType: hard
+
+"@types/babel__core@npm:^7.1.0":
+  version: 7.1.14
+  resolution: "@types/babel__core@npm:7.1.14"
+  dependencies:
+    "@babel/parser": ^7.1.0
+    "@babel/types": ^7.0.0
+    "@types/babel__generator": "*"
+    "@types/babel__template": "*"
+    "@types/babel__traverse": "*"
+  checksum: de4a1a4905e4fb66e9b5ea185704b209892fa104b6aec8705021a3ddf0ff017234c41a1b0bffb0acf2c361afd5352c2d216e3548c8a702ba2558ab63f0bf2200
+  languageName: node
+  linkType: hard
+
+"@types/babel__generator@npm:*":
+  version: 7.6.2
+  resolution: "@types/babel__generator@npm:7.6.2"
+  dependencies:
+    "@babel/types": ^7.0.0
+  checksum: b7764309e5f292c4a15fb587ba610e7fa290e1a2824efe16c0608abdb835de310147b4410f067bb25d817ba72bfc65c6aa0018933b02a774e744dbe51befeab6
+  languageName: node
+  linkType: hard
+
+"@types/babel__template@npm:*":
+  version: 7.4.0
+  resolution: "@types/babel__template@npm:7.4.0"
+  dependencies:
+    "@babel/parser": ^7.1.0
+    "@babel/types": ^7.0.0
+  checksum: 5262dc75e66fe0531b046d19f5c39d1b7e3419e340624229b52757cdedb295cb5658494b64eb234bd18cab7740c45c1d72ed2f16d1d189a765df2dc4efeed1af
+  languageName: node
+  linkType: hard
+
+"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6":
+  version: 7.11.1
+  resolution: "@types/babel__traverse@npm:7.11.1"
+  dependencies:
+    "@babel/types": ^7.3.0
+  checksum: 7bcf7fd0c88687929467d8be08460a7b216b2df5080338bc0575f1b9dbc51ba467b44063802ebbbea1249d5e2a87fed1f02d18b36c1723cd4d957cca70d3a89b
+  languageName: node
+  linkType: hard
+
+"@types/cheerio@npm:*":
+  version: 0.22.29
+  resolution: "@types/cheerio@npm:0.22.29"
+  dependencies:
+    "@types/node": "*"
+  checksum: 4f872a0469f4bbd5b0c56dcf792a1fb138d2baab8772629fbbf0d283f8d3c810f4011949eb709b5777902098ae77c94fb703e79bf3068dba7cc0f6784f20bdf5
+  languageName: node
+  linkType: hard
+
+"@types/classnames@npm:2.2.6":
+  version: 2.2.6
+  resolution: "@types/classnames@npm:2.2.6"
+  checksum: dcc323640840b5fd05102480e87cd913efb385093cb48e5861f6fb4fa3649f295bdaedcfbf82cb1b11f98b97e33edf1638473a85320fe4a91717349e37f2ac76
+  languageName: node
+  linkType: hard
+
+"@types/debounce@npm:3.0.0":
+  version: 3.0.0
+  resolution: "@types/debounce@npm:3.0.0"
+  checksum: af99f44f8ce90388aa5a909066c64b4ddc5efdff72bccf7dcef1fe8a04f03d03a7a42cc1f42ff14620c66923f92600ccfeb768bd77ad250f12a6ebca116ca719
+  languageName: node
+  linkType: hard
+
+"@types/dompurify@npm:^3.0.3":
+  version: 3.0.3
+  resolution: "@types/dompurify@npm:3.0.3"
+  dependencies:
+    "@types/trusted-types": "*"
+  checksum: ff629277db4d19d836b0d878e93efb27d876d1073db81507c39d44d509b30ee3bcdc9e951dbbf9574b1fc6c52e1eaa95abf4279fa45aca281868717f8a7298da
+  languageName: node
+  linkType: hard
+
+"@types/enzyme-adapter-react-16@npm:1.0.3":
+  version: 1.0.3
+  resolution: "@types/enzyme-adapter-react-16@npm:1.0.3"
+  dependencies:
+    "@types/enzyme": "*"
+  checksum: 5137b6f80cb59c980e05b0e4df9d7998702d1193b0b6b499f4be0fad03cbefd064859965c8de1882239750b006271752b9328cd24860e109983d41a6f1ed5df0
+  languageName: node
+  linkType: hard
+
+"@types/enzyme@npm:*":
+  version: 3.10.8
+  resolution: "@types/enzyme@npm:3.10.8"
+  dependencies:
+    "@types/cheerio": "*"
+    "@types/react": "*"
+  checksum: 84febe0558d7985cd5f824e8adc2816c050616d5c7334cc6a6520c7ebac1bb6d200aa5ca057f2a8f4d6e81f2cddf89994d6c8a6cca395fb9d29d4d6e437b3c71
+  languageName: node
+  linkType: hard
+
+"@types/enzyme@npm:3.1.14":
+  version: 3.1.14
+  resolution: "@types/enzyme@npm:3.1.14"
+  dependencies:
+    "@types/cheerio": "*"
+    "@types/react": "*"
+  checksum: a0bf97288b2fd9e9e39812bf57ed8ffbeabf16571e6827fb1fec37a28d050f4eb64d71c7e7e9c9f4170e55c49771466adbff97ea94213612596edc32b6037d35
+  languageName: node
+  linkType: hard
+
+"@types/file-saver@npm:2.0.0":
+  version: 2.0.0
+  resolution: "@types/file-saver@npm:2.0.0"
+  checksum: 954711195611ed05c64c1b0d4fa33300f856cfeb41847d74bd15764adcec10c5ae7646f6897840f816ca1b412205c55d818569717538210bb7aa9f40e4c201fb
+  languageName: node
+  linkType: hard
+
+"@types/glob@npm:^7.1.1":
+  version: 7.1.3
+  resolution: "@types/glob@npm:7.1.3"
+  dependencies:
+    "@types/minimatch": "*"
+    "@types/node": "*"
+  checksum: e0eef12285f548f15d887145590594a04ccce7f7e645fb047cbac18cb093f25d507ffbcc725312294c224bb78cf980fce33e5807de8d6f8a868b4186253499d4
+  languageName: node
+  linkType: hard
+
+"@types/history@npm:*":
+  version: 4.7.8
+  resolution: "@types/history@npm:4.7.8"
+  checksum: 9c867532afd80f72a7101a5bb3c10c1ad7cc8c2c14e92fdbc54d5fed0ef8cdb6060c0f261a39884e243424636c6ff85d8aaffb984e2edaac348d2f216f9eedeb
+  languageName: node
+  linkType: hard
+
+"@types/is-image@npm:3.0.0":
+  version: 3.0.0
+  resolution: "@types/is-image@npm:3.0.0"
+  dependencies:
+    is-image: "*"
+  checksum: 2dd53d02bdd9a0353ac7364bb87ac5fab207e52603873df8be0aa1fa1c843004e8972af043c87ae3fdfed15ec6f8cafa01dfc3191ccbf599457c2cdfbeb234eb
+  languageName: node
+  linkType: hard
+
+"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0":
+  version: 2.0.3
+  resolution: "@types/istanbul-lib-coverage@npm:2.0.3"
+  checksum: 0650cba4be8f464bee89b9de0b71a5ea3b5cc676ce24e1196b5d6a51542ce9e613ae4549bf19756bb33dbbbb32b47931040266100062bfb197c597d73e341eb0
+  languageName: node
+  linkType: hard
+
+"@types/istanbul-lib-report@npm:*":
+  version: 3.0.0
+  resolution: "@types/istanbul-lib-report@npm:3.0.0"
+  dependencies:
+    "@types/istanbul-lib-coverage": "*"
+  checksum: 656398b62dc288e1b5226f8880af98087233cdb90100655c989a09f3052b5775bf98ba58a16c5ae642fb66c61aba402e07a9f2bff1d1569e3b306026c59f3f36
+  languageName: node
+  linkType: hard
+
+"@types/istanbul-reports@npm:^1.1.1":
+  version: 1.1.2
+  resolution: "@types/istanbul-reports@npm:1.1.2"
+  dependencies:
+    "@types/istanbul-lib-coverage": "*"
+    "@types/istanbul-lib-report": "*"
+  checksum: 00866e815d1e68d0a590d691506937b79d8d65ad8eab5ed34dbfee66136c7c0f4ea65327d32046d5fe469f22abea2b294987591dc66365ebc3991f7e413b2d78
+  languageName: node
+  linkType: hard
+
+"@types/istanbul-reports@npm:^3.0.0":
+  version: 3.0.1
+  resolution: "@types/istanbul-reports@npm:3.0.1"
+  dependencies:
+    "@types/istanbul-lib-report": "*"
+  checksum: f1ad54bc68f37f60b30c7915886b92f86b847033e597f9b34f2415acdbe5ed742fa559a0a40050d74cdba3b6a63c342cac1f3a64dba5b68b66a6941f4abd7903
+  languageName: node
+  linkType: hard
+
+"@types/jest@npm:26.0.23":
+  version: 26.0.23
+  resolution: "@types/jest@npm:26.0.23"
+  dependencies:
+    jest-diff: ^26.0.0
+    pretty-format: ^26.0.0
+  checksum: 69db26061e6be34de2a440c8a470b651c53ba6ee0057614a278c4f756ff00281f46cc075b24e5bd761f399f175f49d0a5758b50dd921342a8592461548dea29a
+  languageName: node
+  linkType: hard
+
+"@types/js-yaml@npm:3.11.2":
+  version: 3.11.2
+  resolution: "@types/js-yaml@npm:3.11.2"
+  checksum: 95dc14d0a55aa0d728a6e1a8fec5b61d8e46056f985e90cfaa09ed8e3f5961a2ec1389e32d988d82b384362c807e70f8db3f24b07da4f1aef040adbb01746482
+  languageName: node
+  linkType: hard
+
+"@types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.7":
+  version: 7.0.7
+  resolution: "@types/json-schema@npm:7.0.7"
+  checksum: ea3b409235862d28122751158f4054e729e31ad844bd7b8b23868f38c518047b1c0e8e4e7cc293e02c31a2fb8cfc8a4506c2de2a745cf78b218e064fb8898cd4
+  languageName: node
+  linkType: hard
+
+"@types/jss@npm:^9.5.6":
+  version: 9.5.8
+  resolution: "@types/jss@npm:9.5.8"
+  dependencies:
+    csstype: ^2.0.0
+    indefinite-observable: ^1.0.1
+  checksum: 6e51707529a15f2f5ff94555ecb555d29427fd10c4f3d2d29474934292e365c2dfaa4ad30b2ab46946201cbca7f6e8df56513a22fc6ceca10a979db8338935c5
+  languageName: node
+  linkType: hard
+
+"@types/jssha@npm:0.0.29":
+  version: 0.0.29
+  resolution: "@types/jssha@npm:0.0.29"
+  checksum: 97e77d29f4402d3c7160c293b4ec3f6222751136094ecc162197c1f48c9f23467f8166f51a8dcf2ac4c9f4e1593a58fcbfb37edefea3bbd5b9de12d5ce33c182
+  languageName: node
+  linkType: hard
+
+"@types/jszip@npm:3.1.5":
+  version: 3.1.5
+  resolution: "@types/jszip@npm:3.1.5"
+  dependencies:
+    "@types/node": "*"
+  checksum: 77a4ece530138d103eaa6762ac60d6d9051b0d235cdb55ca68e58b2abafefb917802a2a788d4eb8c24911be2589e52dfb41e2ac587dc9a06d4276044ede776a9
+  languageName: node
+  linkType: hard
+
+"@types/lodash@npm:4.14.116":
+  version: 4.14.116
+  resolution: "@types/lodash@npm:4.14.116"
+  checksum: 6fea370e3abd1f7a5834a6e57cdd5dbe1061744c80c60b5f0ee14b90dd1b8d1155cfd50055412bd3423a4db49e3e85a8e4e2576b71af312b6ea54d46770384b5
+  languageName: node
+  linkType: hard
+
+"@types/minimatch@npm:*":
+  version: 3.0.4
+  resolution: "@types/minimatch@npm:3.0.4"
+  checksum: 583a174116b56f405e8f45680fd06ee674442543cd875b8570a046bd2695fdcfb84ffd8b7ef4c84e11e2ba0fe7e467fc6fd95e134d389ebcefc2ddefd01ea9c8
+  languageName: node
+  linkType: hard
+
+"@types/minimist@npm:^1.2.0":
+  version: 1.2.3
+  resolution: "@types/minimist@npm:1.2.3"
+  checksum: 666ea4f8c39dcbdfbc3171fe6b3902157c845cc9cb8cee33c10deb706cda5e0cc80f98ace2d6d29f6774b0dc21180c96cd73c592a1cbefe04777247c7ba0e84b
+  languageName: node
+  linkType: hard
+
+"@types/node@npm:*, @types/node@npm:15.12.4":
+  version: 15.12.4
+  resolution: "@types/node@npm:15.12.4"
+  checksum: 1731c610eb87d2051d32d0f64f2b14b4e75443ab40ab4882e50fdaa4e5e1013240a6d249213bec1f83b2c3ba24f6d5233159a4deac0bb51751809f5cb6082735
+  languageName: node
+  linkType: hard
+
+"@types/normalize-package-data@npm:^2.4.0":
+  version: 2.4.2
+  resolution: "@types/normalize-package-data@npm:2.4.2"
+  checksum: 2132e4054711e6118de967ae3a34f8c564e58d71fbcab678ec2c34c14659f638a86c35a0fd45237ea35a4a03079cf0a485e3f97736ffba5ed647bfb5da086b03
+  languageName: node
+  linkType: hard
+
+"@types/parse-json@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "@types/parse-json@npm:4.0.0"
+  checksum: fd6bce2b674b6efc3db4c7c3d336bd70c90838e8439de639b909ce22f3720d21344f52427f1d9e57b265fcb7f6c018699b99e5e0c208a1a4823014269a6bf35b
+  languageName: node
+  linkType: hard
+
+"@types/prop-types@npm:*":
+  version: 15.7.3
+  resolution: "@types/prop-types@npm:15.7.3"
+  checksum: 41831d53c44c9eeafdaf9762bcb4553c13a3bbf990745ed9065a1cc3581b80633113b53fd49b202bf51731b258da5d0a9aa09c9035d5af7f78b0f6bc273f1325
+  languageName: node
+  linkType: hard
+
+"@types/q@npm:^1.5.1":
+  version: 1.5.4
+  resolution: "@types/q@npm:1.5.4"
+  checksum: 0842d7d71b5f102dcc2d78f893d0b42c1149f8cdc194d09e7a00be3187999ee3041e535357344818f8fee1b5e216b06bb7df7754d0fe08bd8aca38d3c45f1af6
+  languageName: node
+  linkType: hard
+
+"@types/react-copy-to-clipboard@npm:5.0.0":
+  version: 5.0.0
+  resolution: "@types/react-copy-to-clipboard@npm:5.0.0"
+  dependencies:
+    "@types/react": "*"
+  checksum: 498c9edcda5dbc78c594d3f1565b843a30251d24cc56f4365f90624552acdd03bb3342b2fbcbafdb660a59245368d977d19460679e00288e1327b53c4f1e6bbf
+  languageName: node
+  linkType: hard
+
+"@types/react-dom@npm:17.0.8":
+  version: 17.0.8
+  resolution: "@types/react-dom@npm:17.0.8"
+  dependencies:
+    "@types/react": "*"
+  checksum: 9ad1220297bede365e0d2aae4bc4a2deee3247da9da62d45bcd770812d246d46c219b3f58755b08423a4339c3c654fddd6c2b16b27151d09213b527a7cde8910
+  languageName: node
+  linkType: hard
+
+"@types/react-dropzone@npm:4.2.2":
+  version: 4.2.2
+  resolution: "@types/react-dropzone@npm:4.2.2"
+  dependencies:
+    "@types/react": "*"
+  checksum: 6cc84edf0badd31db5a869131486793aa648319b628087418994f556a66ae56a3cc2a78d730960551d1d0d0ad7d33b9481b91a1e37e3d71ce0796c3fc266341f
+  languageName: node
+  linkType: hard
+
+"@types/react-highlight-words@npm:0.12.0":
+  version: 0.12.0
+  resolution: "@types/react-highlight-words@npm:0.12.0"
+  dependencies:
+    "@types/react": "*"
+  checksum: 7ab09d26c2ef69053c621954f4a5276f05f09992785bddfa79781760a14a6761f5d7c65b3b47b195d7b7a542f128bb56cf3030c2961b2c04d9900175e94b67c1
+  languageName: node
+  linkType: hard
+
+"@types/react-redux@npm:6.0.9":
+  version: 6.0.9
+  resolution: "@types/react-redux@npm:6.0.9"
+  dependencies:
+    "@types/react": "*"
+    redux: ^4.0.0
+  checksum: 95e40be019421fa01cacce377dc9882661cde132f3521098b90a9e1a38cc02c1336a58567ede0cac290feb9f5a4a3c7f884f12967b530231badae1c0b49b6bed
+  languageName: node
+  linkType: hard
+
+"@types/react-router-dom@npm:4.3.1":
+  version: 4.3.1
+  resolution: "@types/react-router-dom@npm:4.3.1"
+  dependencies:
+    "@types/history": "*"
+    "@types/react": "*"
+    "@types/react-router": "*"
+  checksum: 9f2d94519ca94848673fe5e21fa05d7d62de0a0be7483d38f9c6897affea4fe6a6394cf8e7a4a077d14ec29914277c1f0dcf0fc993e895d99fd2702a49ae04bf
+  languageName: node
+  linkType: hard
+
+"@types/react-router-redux@npm:5.0.16":
+  version: 5.0.16
+  resolution: "@types/react-router-redux@npm:5.0.16"
+  dependencies:
+    "@types/history": "*"
+    "@types/react": "*"
+    "@types/react-router": "*"
+    redux: ">= 3.7.2"
+  checksum: 5cde7e735e8fec12403a0ba7a3b68893bc323986f5d382a6da9ed729b2f99d1574f5da05ab60bcfd267fadd58608eed58bbcd86ee70e12b5e573ab7575b796fb
+  languageName: node
+  linkType: hard
+
+"@types/react-router@npm:*":
+  version: 5.1.15
+  resolution: "@types/react-router@npm:5.1.15"
+  dependencies:
+    "@types/history": "*"
+    "@types/react": "*"
+  checksum: b27c4a0b0fea6e31edbcbf1b1c2590575ad454016701997df48ffd26c08f7c18fab5433e9dc8b3f3131c13aac1a8eacb2fd21f77c03c1b1550cabf4aeee127a2
+  languageName: node
+  linkType: hard
+
+"@types/react-router@npm:4.0.31":
+  version: 4.0.31
+  resolution: "@types/react-router@npm:4.0.31"
+  dependencies:
+    "@types/history": "*"
+    "@types/react": "*"
+  checksum: d9220d9718ca99ba43455f97742403011416a49adc6730b3142901d96a93054ea0cf9ca55954c4667a36834469cde37894d7fd2a2b8016206f995f9570cb8359
+  languageName: node
+  linkType: hard
+
+"@types/react-text-mask@npm:^5.4.3":
+  version: 5.4.11
+  resolution: "@types/react-text-mask@npm:5.4.11"
+  dependencies:
+    "@types/react": "*"
+  checksum: 4defba1467e61b73bfdae74d0b1bea0f27846aabf5283f137fa372ef05bf23accfdf04fffaba33272e9eff5abf00a74863e9c24ca6974c731d73f3fae6efc577
+  languageName: node
+  linkType: hard
+
+"@types/react-transition-group@npm:^2.0.8":
+  version: 2.9.2
+  resolution: "@types/react-transition-group@npm:2.9.2"
+  dependencies:
+    "@types/react": "*"
+  checksum: 6f30fffc221339de90bd3e999f32328618cfaedfcaa501603f5ddc5bed1c2dbaa0d51347c3a25e5f8fd3041c671aabd2b7bfbcad611a7636adcd9da4e0666fa5
+  languageName: node
+  linkType: hard
+
+"@types/react-virtualized-auto-sizer@npm:1.0.0":
+  version: 1.0.0
+  resolution: "@types/react-virtualized-auto-sizer@npm:1.0.0"
+  dependencies:
+    "@types/react": "*"
+  checksum: f98ecd3e22d7921e7f27cd883dcf5d61d11f383a7a09ec3a84873996dbf5057d57d8f5bbcf6d49ffd6918c166972e349f96657eb6133d406fa9a3a703f843f01
+  languageName: node
+  linkType: hard
+
+"@types/react-window@npm:1.8.2":
+  version: 1.8.2
+  resolution: "@types/react-window@npm:1.8.2"
+  dependencies:
+    "@types/react": "*"
+  checksum: c127ed420d881510fe647539342e7c494802aab12fd6cb61f9f8ba47ef16d3683e632b7a6a07eb0d284ea8f0953ae7941eafa2c51c0bcb3b176d009eac09c79a
+  languageName: node
+  linkType: hard
+
+"@types/react@npm:*, @types/react@npm:17.0.11":
+  version: 17.0.11
+  resolution: "@types/react@npm:17.0.11"
+  dependencies:
+    "@types/prop-types": "*"
+    "@types/scheduler": "*"
+    csstype: ^3.0.2
+  checksum: 89e80ee8e08988abca0266e5e131f57b2e18f326bebfa0ed0a06b0bca29621a5a8202d617254a053f659b9c18090c52cae0aa475a6c6036b8858030f1d448e47
+  languageName: node
+  linkType: hard
+
+"@types/redux-devtools@npm:3.0.44":
+  version: 3.0.44
+  resolution: "@types/redux-devtools@npm:3.0.44"
+  dependencies:
+    "@types/react": "*"
+    redux: ^3.6.0
+  checksum: 7d4af410a5fd0010c2c573d6355ed6abe76d7ec9643b03145bdda0f3cd7592158e9852626fcc7b4103e8e53a850dac2abf8e3979402e8bda4842bc813058bbd0
+  languageName: node
+  linkType: hard
+
+"@types/redux-form@npm:7.4.12":
+  version: 7.4.12
+  resolution: "@types/redux-form@npm:7.4.12"
+  dependencies:
+    "@types/react": "*"
+    redux: ^3.6.0 || ^4.0.0
+  checksum: b878af577f634f2b338a5de5ef252540e7b5098dc43053595454b094711469fb2f9656e3acecc6cb69a1759f8aba64b7700d091a6d1225e6820ad37327937a28
+  languageName: node
+  linkType: hard
+
+"@types/redux-mock-store@npm:1.0.2":
+  version: 1.0.2
+  resolution: "@types/redux-mock-store@npm:1.0.2"
+  dependencies:
+    redux: ^4.0.5
+  checksum: 606db3134861681599a6c2ab106e1c910103407ed942b219805f533d256e3921b50c4e7e7ec5fc31cece74e321b12f888a4941eb2e2028d579c7f5b73d3fc3b8
+  languageName: node
+  linkType: hard
+
+"@types/scheduler@npm:*":
+  version: 0.16.1
+  resolution: "@types/scheduler@npm:0.16.1"
+  checksum: 2ff8034df029a6cbb3623b05fa895cac4fc504806a8e948ebe29675a1edfa5ac04faac7611016076b3ffefc2037bbe344ad1978304059b2d4c78e513ec43c7bf
+  languageName: node
+  linkType: hard
+
+"@types/shell-escape@npm:^0.2.0":
+  version: 0.2.0
+  resolution: "@types/shell-escape@npm:0.2.0"
+  checksum: 020696ed313eeb02deb2abcc581e8b570be6f9ee662892339965b524bb4fbdc9a97b6520d914117740ec11147b0b1aa52358b8e03fa214c2da99743adb196853
+  languageName: node
+  linkType: hard
+
+"@types/sinon@npm:7.5":
+  version: 7.5.2
+  resolution: "@types/sinon@npm:7.5.2"
+  checksum: 6493964780f7547ad1295f1b5fb3eaaa66a1ffcec6e891facbf58a304d50a7f1819819522ab43c75be53105cd87d3160a196a25595b3cc394f0776a208652fee
+  languageName: node
+  linkType: hard
+
+"@types/sinonjs__fake-timers@npm:8.1.1":
+  version: 8.1.1
+  resolution: "@types/sinonjs__fake-timers@npm:8.1.1"
+  checksum: ca09d54d47091d87020824a73f026300fa06b17cd9f2f9b9387f28b549364b141ef194ee28db762f6588de71d8febcd17f753163cb7ea116b8387c18e80ebd5c
+  languageName: node
+  linkType: hard
+
+"@types/sizzle@npm:^2.3.2":
+  version: 2.3.3
+  resolution: "@types/sizzle@npm:2.3.3"
+  checksum: 586a9fb1f6ff3e325e0f2cc1596a460615f0bc8a28f6e276ac9b509401039dd242fa8b34496d3a30c52f5b495873922d09a9e76c50c2ab2bcc70ba3fb9c4e160
+  languageName: node
+  linkType: hard
+
+"@types/stack-utils@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "@types/stack-utils@npm:1.0.1"
+  checksum: 9dc052b575acfeca3f165fb19d87b7b2989d54ed7d64a7eeb0b7587bc5795ef1f2c2b1511a44dcf0831ef35b8ce3486f97fcbfdd50c01f68aa297de31502c9d9
+  languageName: node
+  linkType: hard
+
+"@types/stylis@npm:4.2.0":
+  version: 4.2.0
+  resolution: "@types/stylis@npm:4.2.0"
+  checksum: 02a47584acd2fcb664f7d8270a69686c83752bdfb855f804015d33116a2b09c0b2ac535213a4a7b6d3a78b2915b22b4024cce067ae979beee0e4f8f5fdbc26a9
+  languageName: node
+  linkType: hard
+
+"@types/trusted-types@npm:*":
+  version: 2.0.4
+  resolution: "@types/trusted-types@npm:2.0.4"
+  checksum: 5256c4576cd1c90d33ddd9cc9cbd4f202b39c98cbe8b7f74963298f9eb2159c285ea5c25a6181b4c594d8d75641765bff85d72c2d251ad076e6529ce0eeedd1c
+  languageName: node
+  linkType: hard
+
+"@types/uuid@npm:3.4.4":
+  version: 3.4.4
+  resolution: "@types/uuid@npm:3.4.4"
+  dependencies:
+    "@types/node": "*"
+  checksum: 90931569766a48b2deac5945f1b5e411bfb408518c6ed09dc44d5d717946a6fe7eb20b86de39683aed88f26289672b2867ed20d228eca67825d83583078ed58f
+  languageName: node
+  linkType: hard
+
+"@types/yargs-parser@npm:*":
+  version: 20.2.0
+  resolution: "@types/yargs-parser@npm:20.2.0"
+  checksum: 54cf3f8d2c7db47e200e8c96e05b3b33ee554e78d29f3db55f04ca4b86dc6b8ff6b1349f5772268ce2d365cde0a0f4fdd92bf5933c2be2c1ea3f19f0b4599e1f
+  languageName: node
+  linkType: hard
+
+"@types/yargs@npm:^13.0.0":
+  version: 13.0.11
+  resolution: "@types/yargs@npm:13.0.11"
+  dependencies:
+    "@types/yargs-parser": "*"
+  checksum: efcbcccd20eab773970c2f103efaf69901924ab3bfc69cc5603ece0be7626937242b2f952b7ebc3708c121f8507e1d0633eb4cc04843433bf3d8b133b83bb811
+  languageName: node
+  linkType: hard
+
+"@types/yargs@npm:^15.0.0":
+  version: 15.0.13
+  resolution: "@types/yargs@npm:15.0.13"
+  dependencies:
+    "@types/yargs-parser": "*"
+  checksum: a6ebb0ec63f168280e02370fcf24ff29c3eb0335e1c46e3b34e04d32eb7c818446e0b7de8badb339b07c0ddba322827ce13ccb604d14a0de422335ae56b2120d
+  languageName: node
+  linkType: hard
+
+"@types/yargs@npm:^17.0.0":
+  version: 17.0.0
+  resolution: "@types/yargs@npm:17.0.0"
+  dependencies:
+    "@types/yargs-parser": "*"
+  checksum: a9963dd351737a4f03eeda631848e1e0a8d687a8d463c882a5446ca0b420cd78b7b1c87e22e7a48429809281dd9dd1c0bfe8442100d4172919c30c7294f11e8b
+  languageName: node
+  linkType: hard
+
+"@types/yauzl@npm:^2.9.1":
+  version: 2.10.3
+  resolution: "@types/yauzl@npm:2.10.3"
+  dependencies:
+    "@types/node": "*"
+  checksum: 5ee966ea7bd6b2802f31ad4281c92c4c0b6dfa593c378a2582c58541fa113bec3d70eb0696b34ad95e8e6861a884cba6c3e351285816693ed176222f840a8c08
+  languageName: node
+  linkType: hard
+
+"@typescript-eslint/eslint-plugin@npm:^2.10.0":
+  version: 4.28.0
+  resolution: "@typescript-eslint/eslint-plugin@npm:4.28.0"
+  dependencies:
+    "@typescript-eslint/experimental-utils": 4.28.0
+    "@typescript-eslint/scope-manager": 4.28.0
+    debug: ^4.3.1
+    functional-red-black-tree: ^1.0.1
+    regexpp: ^3.1.0
+    semver: ^7.3.5
+    tsutils: ^3.21.0
+  peerDependencies:
+    "@typescript-eslint/parser": ^4.0.0
+    eslint: ^5.0.0 || ^6.0.0 || ^7.0.0
+  peerDependenciesMeta:
+    typescript:
+      optional: true
+  checksum: fb52430e3a219e45c014c8a8407ccd2830da45e6fc134d0138b99faf45e580d3ad9a3405cad98726183779d94640366c532e4519783a2a99fe072dc73705b8ad
+  languageName: node
+  linkType: hard
+
+"@typescript-eslint/experimental-utils@npm:4.28.0":
+  version: 4.28.0
+  resolution: "@typescript-eslint/experimental-utils@npm:4.28.0"
+  dependencies:
+    "@types/json-schema": ^7.0.7
+    "@typescript-eslint/scope-manager": 4.28.0
+    "@typescript-eslint/types": 4.28.0
+    "@typescript-eslint/typescript-estree": 4.28.0
+    eslint-scope: ^5.1.1
+    eslint-utils: ^3.0.0
+  peerDependencies:
+    eslint: "*"
+  checksum: 29bcee0d581ad20043532b6b1fa0c2e844ab35a99aa67478fbb68b7be5889dc8aee1f52b72c3a51d8f4cf57e1f0ac664d92738b3eb5aea9aa8a8c72aa30a74b7
+  languageName: node
+  linkType: hard
+
+"@typescript-eslint/parser@npm:^2.10.0":
+  version: 4.28.0
+  resolution: "@typescript-eslint/parser@npm:4.28.0"
+  dependencies:
+    "@typescript-eslint/scope-manager": 4.28.0
+    "@typescript-eslint/types": 4.28.0
+    "@typescript-eslint/typescript-estree": 4.28.0
+    debug: ^4.3.1
+  peerDependencies:
+    eslint: ^5.0.0 || ^6.0.0 || ^7.0.0
+  peerDependenciesMeta:
+    typescript:
+      optional: true
+  checksum: 08c167db5c36776a638afe52130e0bf20bfc94598da3f178171cfafb72703fc508b17667b4584cd6bea4c8c6d6922af8f7a45e9a7d6419f83fe2f6c893845d96
+  languageName: node
+  linkType: hard
+
+"@typescript-eslint/scope-manager@npm:4.28.0":
+  version: 4.28.0
+  resolution: "@typescript-eslint/scope-manager@npm:4.28.0"
+  dependencies:
+    "@typescript-eslint/types": 4.28.0
+    "@typescript-eslint/visitor-keys": 4.28.0
+  checksum: cdab7ef35e18cf2c9a25a4e588cb464bd7e6a3e3f9a7dceaa567fa04202b68901b5c9dc108c3ff7b92215c373274c9d105b8061ae97d2600039345c53d33a4ae
+  languageName: node
+  linkType: hard
+
+"@typescript-eslint/types@npm:4.28.0":
+  version: 4.28.0
+  resolution: "@typescript-eslint/types@npm:4.28.0"
+  checksum: 5d167f32e93f5854e78315a3a59beaf45bf9fdc5dc6a01d02c22152c08c8871022b5c41281c5355f44be1f9d36f9d24a67087a5c0dcfeea25477eb5918b5c410
+  languageName: node
+  linkType: hard
+
+"@typescript-eslint/typescript-estree@npm:4.28.0":
+  version: 4.28.0
+  resolution: "@typescript-eslint/typescript-estree@npm:4.28.0"
+  dependencies:
+    "@typescript-eslint/types": 4.28.0
+    "@typescript-eslint/visitor-keys": 4.28.0
+    debug: ^4.3.1
+    globby: ^11.0.3
+    is-glob: ^4.0.1
+    semver: ^7.3.5
+    tsutils: ^3.21.0
+  peerDependenciesMeta:
+    typescript:
+      optional: true
+  checksum: 680ec9a48cc702eba81a1a9101d6635fac10977c930cdb79a94c975b611c0276cc30e13f41630faeb28446cdcc0afb50bd389042629b35a6dd51b6d6774e0973
+  languageName: node
+  linkType: hard
+
+"@typescript-eslint/visitor-keys@npm:4.28.0":
+  version: 4.28.0
+  resolution: "@typescript-eslint/visitor-keys@npm:4.28.0"
+  dependencies:
+    "@typescript-eslint/types": 4.28.0
+    eslint-visitor-keys: ^2.0.0
+  checksum: a2f5cec756946d924e3a4f5e89d600da82d500690575620513eb700e9ab65226f62910a16ed3a2e1e9c46d7171ee6c5f23ff82e222dcd65e4b6e14f712534d71
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/ast@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/ast@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/helper-module-context": 1.8.5
+    "@webassemblyjs/helper-wasm-bytecode": 1.8.5
+    "@webassemblyjs/wast-parser": 1.8.5
+  checksum: eee2593fd09ea888ed5ed8919e681a479f9d997061f97264020c8d4b0be11ab3c4e8646463afd021c8c40f1d92023cad1a09559ccdef6e24fdb4290e021a368b
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/floating-point-hex-parser@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.8.5"
+  checksum: 68a1ff458355fb6b1553c8f7e2df6d76623bf5ef895f3fc30de620b88d1e68224643c8daf517d19b75d4e10a7f663c038b9912970edcae6f5a4fdb85b630bfc3
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/helper-api-error@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/helper-api-error@npm:1.8.5"
+  checksum: 83e3c62a67f94cc36b2b8c4785aa92c15fc5d95a0e22c4b6c39dace583dd9c47c88bc4dea032a959b511d33db26eeb8de9b7be6f5d343f89ebdbd17c11520827
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/helper-buffer@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/helper-buffer@npm:1.8.5"
+  checksum: 5eeb48b135d5ca013c8876228a3a2ccfba98d87dfe12fcf6921e0acf7a272070f369e4e4e8a7f34f2cf22e8faaade24a39a9bcfba76498f103f051384b0f55b3
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/helper-code-frame@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/helper-code-frame@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/wast-printer": 1.8.5
+  checksum: 80ca0fdc18c39ba1fe3f139f657f62159b7269ca153f10c5b984f5140a83e3fb8c18565f4443afbce144622b9eb8d16034beb52efc91b69e1e107e15b9f5a6c6
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/helper-fsm@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/helper-fsm@npm:1.8.5"
+  checksum: 5026861c39518cf7f8fa6a88ccad8e251d906130a9ccfe2a49da5eb5321bfdf0861f31e5269f76687259f96cc8143f09a9df73a3836c44cb5445bdc01f77fd91
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/helper-module-context@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/helper-module-context@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/ast": 1.8.5
+    mamacro: ^0.0.3
+  checksum: 519ff898993e9331b21bf22dbb85c91378f5c227e7075a4cd580c8e51dfd1372847c722b2e49564d2609b54e10283d68cad6d243b8a95d7833f60c6eb33a5259
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/helper-wasm-bytecode@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.8.5"
+  checksum: ac560cafe93e5ef07d892cea8ed5f1cb2b7cb8777a335fa92d99068eef650fbc37077e2ac8861bcaed337ac613db477741603554d9784373d41acaeffefd2c01
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/helper-wasm-section@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/helper-wasm-section@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/ast": 1.8.5
+    "@webassemblyjs/helper-buffer": 1.8.5
+    "@webassemblyjs/helper-wasm-bytecode": 1.8.5
+    "@webassemblyjs/wasm-gen": 1.8.5
+  checksum: f8af22bf904d43d2700708bcb61ebfe1241cb57a4ef3e1400327c072d43b34bf5a04c39493a5d7691cca0590cec0cb0935cad7111764593cdb0840e637edac9d
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/ieee754@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/ieee754@npm:1.8.5"
+  dependencies:
+    "@xtuc/ieee754": ^1.2.0
+  checksum: 20230eb79e4bdf812f49ae73ca145a0a8d0fa1ec8a6353b5a36e57c1955ecc7245f277bfb1bf839e041fff7f300948d938b0672bae9d5764519ed0b6a6aa1bdb
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/leb128@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/leb128@npm:1.8.5"
+  dependencies:
+    "@xtuc/long": 4.2.2
+  checksum: c41603eba2d4052bf14e9213bfa80534dcbafacc782bd8faef80394cf2a93a4aece465416a5132aff2ec8339381003689850b72899828c236313e3653fb47214
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/utf8@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/utf8@npm:1.8.5"
+  checksum: 6aac4440996a160f268762a3dad1ef4a02f4d06fe3a7a0189556adbbbc34ed9ec54a2eadc2adb0aea2ba3430e9dbe20ab461df4f224eed73c9066904b17013e4
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/wasm-edit@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/wasm-edit@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/ast": 1.8.5
+    "@webassemblyjs/helper-buffer": 1.8.5
+    "@webassemblyjs/helper-wasm-bytecode": 1.8.5
+    "@webassemblyjs/helper-wasm-section": 1.8.5
+    "@webassemblyjs/wasm-gen": 1.8.5
+    "@webassemblyjs/wasm-opt": 1.8.5
+    "@webassemblyjs/wasm-parser": 1.8.5
+    "@webassemblyjs/wast-printer": 1.8.5
+  checksum: 7298a60bd4914a7d13bceebce4a130f412056eb40a9d9a27d102bf173a0b369cb0d4be3303abcd08c9482695afe79080e896ace4f2ecabbb0247e2f1829fd4ca
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/wasm-gen@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/wasm-gen@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/ast": 1.8.5
+    "@webassemblyjs/helper-wasm-bytecode": 1.8.5
+    "@webassemblyjs/ieee754": 1.8.5
+    "@webassemblyjs/leb128": 1.8.5
+    "@webassemblyjs/utf8": 1.8.5
+  checksum: d861e0233aff09e4841624f6554e32fc3056c232a2b3a9cf92bfcc3f9f34f850e240b6dec70977ae55afd5e5cea3e8d260292cccb1803dc26da4fdcee72b4637
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/wasm-opt@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/wasm-opt@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/ast": 1.8.5
+    "@webassemblyjs/helper-buffer": 1.8.5
+    "@webassemblyjs/wasm-gen": 1.8.5
+    "@webassemblyjs/wasm-parser": 1.8.5
+  checksum: 44b18c328b919ba4510d58b4dfe6244edac8c21cd2b6cf7167ad58feb0ddc61217c98521b2f0ffc0e388a0b5469b060a6908e8cc7753ab72945204b4a87dd31b
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/wasm-parser@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/wasm-parser@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/ast": 1.8.5
+    "@webassemblyjs/helper-api-error": 1.8.5
+    "@webassemblyjs/helper-wasm-bytecode": 1.8.5
+    "@webassemblyjs/ieee754": 1.8.5
+    "@webassemblyjs/leb128": 1.8.5
+    "@webassemblyjs/utf8": 1.8.5
+  checksum: ea80e9ba6d8f8ba7c3177eda500a41226b5a0373b92a32e8ce81b4562fd4bec37a67ff1a833a378a811307cf1ec4f54f44207c6bbc82fb45709a6cb60d86458f
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/wast-parser@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/wast-parser@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/ast": 1.8.5
+    "@webassemblyjs/floating-point-hex-parser": 1.8.5
+    "@webassemblyjs/helper-api-error": 1.8.5
+    "@webassemblyjs/helper-code-frame": 1.8.5
+    "@webassemblyjs/helper-fsm": 1.8.5
+    "@xtuc/long": 4.2.2
+  checksum: ec0b28f0c558950024521808c1e12b4597023b0ef914aed0eb9078bcb4daaa34555a46efe35406b8edb899b008782b1a43d96c6485c45e98ce9745fe17196817
+  languageName: node
+  linkType: hard
+
+"@webassemblyjs/wast-printer@npm:1.8.5":
+  version: 1.8.5
+  resolution: "@webassemblyjs/wast-printer@npm:1.8.5"
+  dependencies:
+    "@webassemblyjs/ast": 1.8.5
+    "@webassemblyjs/wast-parser": 1.8.5
+    "@xtuc/long": 4.2.2
+  checksum: 7c53f5f694b9820cef5e58653a85f5e9b0eba4e59013a2e0fcf3562d7e70501b0202d73ebadbd14b5845ecf958e3639bdde5a197a4245dded722f2015ec45e2a
+  languageName: node
+  linkType: hard
+
+"@xtuc/ieee754@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "@xtuc/ieee754@npm:1.2.0"
+  checksum: ac56d4ca6e17790f1b1677f978c0c6808b1900a5b138885d3da21732f62e30e8f0d9120fcf8f6edfff5100ca902b46f8dd7c1e3f903728634523981e80e2885a
+  languageName: node
+  linkType: hard
+
+"@xtuc/long@npm:4.2.2":
+  version: 4.2.2
+  resolution: "@xtuc/long@npm:4.2.2"
+  checksum: 8ed0d477ce3bc9c6fe2bf6a6a2cc316bb9c4127c5a7827bae947fa8ec34c7092395c5a283cc300c05b5fa01cbbfa1f938f410a7bf75db7c7846fea41949989ec
+  languageName: node
+  linkType: hard
+
+"abab@npm:^2.0.0":
+  version: 2.0.5
+  resolution: "abab@npm:2.0.5"
+  checksum: 0ec951b46d5418c2c2f923021ec193eaebdb4e802ffd5506286781b454be722a13a8430f98085cd3e204918401d9130ec6cc8f5ae19be315b3a0e857d83196e1
+  languageName: node
+  linkType: hard
+
+"abbrev@npm:1":
+  version: 1.1.1
+  resolution: "abbrev@npm:1.1.1"
+  checksum: a4a97ec07d7ea112c517036882b2ac22f3109b7b19077dc656316d07d308438aac28e4d9746dc4d84bf6b1e75b4a7b0a5f3cb30592419f128ca9a8cee3bcfa17
+  languageName: node
+  linkType: hard
+
+"accepts@npm:~1.3.4, accepts@npm:~1.3.5":
+  version: 1.3.7
+  resolution: "accepts@npm:1.3.7"
+  dependencies:
+    mime-types: ~2.1.24
+    negotiator: 0.6.2
+  checksum: 27fc8060ffc69481ff6719cd3ee06387d2b88381cb0ce626f087781bbd02201a645a9febc8e7e7333558354b33b1d2f922ad13560be4ec1b7ba9e76fc1c1241d
+  languageName: node
+  linkType: hard
+
+"accepts@npm:~1.3.8":
+  version: 1.3.8
+  resolution: "accepts@npm:1.3.8"
+  dependencies:
+    mime-types: ~2.1.34
+    negotiator: 0.6.3
+  checksum: 50c43d32e7b50285ebe84b613ee4a3aa426715a7d131b65b786e2ead0fd76b6b60091b9916d3478a75f11f162628a2139991b6c03ab3f1d9ab7c86075dc8eab4
+  languageName: node
+  linkType: hard
+
+"acorn-globals@npm:^4.1.0, acorn-globals@npm:^4.3.0":
+  version: 4.3.4
+  resolution: "acorn-globals@npm:4.3.4"
+  dependencies:
+    acorn: ^6.0.1
+    acorn-walk: ^6.0.1
+  checksum: c31bfde102d8a104835e9591c31dd037ec771449f9c86a6b1d2ac3c7c336694f828cfabba7687525b094f896a854affbf1afe6e1b12c0d998be6bab5d49c9663
+  languageName: node
+  linkType: hard
+
+"acorn-jsx@npm:^5.2.0":
+  version: 5.3.1
+  resolution: "acorn-jsx@npm:5.3.1"
+  peerDependencies:
+    acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+  checksum: daf441a9d7b59c0ea1f7fe2934c48aca604a007455129ce35fa62ec3d4c8363e2efc2d4da636d18ce0049979260ba07d8b42bc002ae95182916d2c90901529c2
+  languageName: node
+  linkType: hard
+
+"acorn-walk@npm:^6.0.1":
+  version: 6.2.0
+  resolution: "acorn-walk@npm:6.2.0"
+  checksum: ea241a5d96338f1e8030aafae72a91ff0ec4360e2775e44a2fdb2eb618b07fc309e000a5126056631ac7f00fe8bd9bbd23fcb6d018eee4ba11086eb36c1b2e61
+  languageName: node
+  linkType: hard
+
+"acorn@npm:^5.5.3":
+  version: 5.7.4
+  resolution: "acorn@npm:5.7.4"
+  bin:
+    acorn: bin/acorn
+  checksum: f51392a4d25c7705fadb890f784c59cde4ac1c5452ccd569fa59bd2191b7951b4a6398348ab7ea08a54f0bc0a56c13776710f4e1bae9de441e4d33e2015ad1e0
+  languageName: node
+  linkType: hard
+
+"acorn@npm:^6.0.1, acorn@npm:^6.0.4, acorn@npm:^6.2.1":
+  version: 6.4.2
+  resolution: "acorn@npm:6.4.2"
+  bin:
+    acorn: bin/acorn
+  checksum: 44b07053729db7f44d28343eed32247ed56dc4a6ec6dff2b743141ecd6b861406bbc1c20bf9d4f143ea7dd08add5dc8c290582756539bc03a8db605050ce2fb4
+  languageName: node
+  linkType: hard
+
+"acorn@npm:^7.1.1":
+  version: 7.4.1
+  resolution: "acorn@npm:7.4.1"
+  bin:
+    acorn: bin/acorn
+  checksum: 1860f23c2107c910c6177b7b7be71be350db9e1080d814493fae143ae37605189504152d1ba8743ba3178d0b37269ce1ffc42b101547fdc1827078f82671e407
+  languageName: node
+  linkType: hard
+
+"acorn@npm:^8.5.0":
+  version: 8.7.0
+  resolution: "acorn@npm:8.7.0"
+  bin:
+    acorn: bin/acorn
+  checksum: e0f79409d68923fbf1aa6d4166f3eedc47955320d25c89a20cc822e6ba7c48c5963d5bc657bc242d68f7a4ac9faf96eef033e8f73656da6c640d4219935fdfd0
+  languageName: node
+  linkType: hard
+
+"address@npm:1.1.2, address@npm:^1.0.1":
+  version: 1.1.2
+  resolution: "address@npm:1.1.2"
+  checksum: d966deee6ab9a0f96ed1d25dc73e91a248f64479c91f9daeb15237b8e3c39a02faac4e6afe8987ef9e5aea60a1593cef5882b7456ab2e6196fc0229a93ec39c2
+  languageName: node
+  linkType: hard
+
+"adjust-sourcemap-loader@npm:3.0.0":
+  version: 3.0.0
+  resolution: "adjust-sourcemap-loader@npm:3.0.0"
+  dependencies:
+    loader-utils: ^2.0.0
+    regex-parser: ^2.2.11
+  checksum: 5ceabea85219fcafed06f7d1aafb37dc761c6435e4ded2a8c6b01c69844250aa94ef65a4d07210dc7566c2d8b4c9ba8897518db596a550461eed26fbeb76b96f
+  languageName: node
+  linkType: hard
+
+"agent-base@npm:6, agent-base@npm:^6.0.2":
+  version: 6.0.2
+  resolution: "agent-base@npm:6.0.2"
+  dependencies:
+    debug: 4
+  checksum: f52b6872cc96fd5f622071b71ef200e01c7c4c454ee68bc9accca90c98cfb39f2810e3e9aa330435835eedc8c23f4f8a15267f67c6e245d2b33757575bdac49d
+  languageName: node
+  linkType: hard
+
+"agentkeepalive@npm:^4.1.3":
+  version: 4.5.0
+  resolution: "agentkeepalive@npm:4.5.0"
+  dependencies:
+    humanize-ms: ^1.2.1
+  checksum: 13278cd5b125e51eddd5079f04d6fe0914ac1b8b91c1f3db2c1822f99ac1a7457869068997784342fe455d59daaff22e14fb7b8c3da4e741896e7e31faf92481
+  languageName: node
+  linkType: hard
+
+"agentkeepalive@npm:^4.2.1":
+  version: 4.2.1
+  resolution: "agentkeepalive@npm:4.2.1"
+  dependencies:
+    debug: ^4.1.0
+    depd: ^1.1.2
+    humanize-ms: ^1.2.1
+  checksum: 39cb49ed8cf217fd6da058a92828a0a84e0b74c35550f82ee0a10e1ee403c4b78ade7948be2279b188b7a7303f5d396ea2738b134731e464bf28de00a4f72a18
+  languageName: node
+  linkType: hard
+
+"aggregate-error@npm:^3.0.0":
+  version: 3.1.0
+  resolution: "aggregate-error@npm:3.1.0"
+  dependencies:
+    clean-stack: ^2.0.0
+    indent-string: ^4.0.0
+  checksum: 1101a33f21baa27a2fa8e04b698271e64616b886795fd43c31068c07533c7b3facfcaf4e9e0cab3624bd88f729a592f1c901a1a229c9e490eafce411a8644b79
+  languageName: node
+  linkType: hard
+
+"airbnb-prop-types@npm:^2.16.0":
+  version: 2.16.0
+  resolution: "airbnb-prop-types@npm:2.16.0"
+  dependencies:
+    array.prototype.find: ^2.1.1
+    function.prototype.name: ^1.1.2
+    is-regex: ^1.1.0
+    object-is: ^1.1.2
+    object.assign: ^4.1.0
+    object.entries: ^1.1.2
+    prop-types: ^15.7.2
+    prop-types-exact: ^1.2.0
+    react-is: ^16.13.1
+  peerDependencies:
+    react: ^0.14 || ^15.0.0 || ^16.0.0-alpha
+  checksum: 393a5988b99f122c4b935296a6b8c8cbd10345418d67d547cdbcd71d57636cb9abdf9d6556940f70d0b76c3f83448627376557a75b5faf570fb8d262ed4a472f
+  languageName: node
+  linkType: hard
+
+"ajv-errors@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "ajv-errors@npm:1.0.1"
+  peerDependencies:
+    ajv: ">=5.0.0"
+  checksum: 2c9fc02cf58f9aae5bace61ebd1b162e1ea372ae9db5999243ba5e32a9a78c0d635d29ae085f652c61c941a43af0b2b1acdb255e29d44dc43a6e021085716d8c
+  languageName: node
+  linkType: hard
+
+"ajv-keywords@npm:^3.1.0, ajv-keywords@npm:^3.4.1, ajv-keywords@npm:^3.5.2":
+  version: 3.5.2
+  resolution: "ajv-keywords@npm:3.5.2"
+  peerDependencies:
+    ajv: ^6.9.1
+  checksum: 7dc5e5931677a680589050f79dcbe1fefbb8fea38a955af03724229139175b433c63c68f7ae5f86cf8f65d55eb7c25f75a046723e2e58296707617ca690feae9
+  languageName: node
+  linkType: hard
+
+"ajv@npm:^6.1.0, ajv@npm:^6.1.1, ajv@npm:^6.10.0, ajv@npm:^6.10.2, ajv@npm:^6.12.3, ajv@npm:^6.12.4":
+  version: 6.12.6
+  resolution: "ajv@npm:6.12.6"
+  dependencies:
+    fast-deep-equal: ^3.1.1
+    fast-json-stable-stringify: ^2.0.0
+    json-schema-traverse: ^0.4.1
+    uri-js: ^4.2.2
+  checksum: 874972efe5c4202ab0a68379481fbd3d1b5d0a7bd6d3cc21d40d3536ebff3352a2a1fabb632d4fd2cc7fe4cbdcd5ed6782084c9bbf7f32a1536d18f9da5007d4
+  languageName: node
+  linkType: hard
+
+"alphanum-sort@npm:^1.0.0":
+  version: 1.0.2
+  resolution: "alphanum-sort@npm:1.0.2"
+  checksum: 5a32d0b3c0944e65d22ff3ae2f88d7a4f8d88a78a703033caeae33f2944915e053d283d02f630dc94823edc7757148ecdcf39fd687a5117bda5c10133a03a7d8
+  languageName: node
+  linkType: hard
+
+"amdefine@npm:>=0.0.4":
+  version: 1.0.1
+  resolution: "amdefine@npm:1.0.1"
+  checksum: 9d4e15b94641643a9385b2841b4cb2bcf4e8e2f741ea4bd475c93ad7bab261ad4ed827a32e9c549b38b98759c4526c173ae4e6dde8caeb75ee5cebedc9863762
+  languageName: node
+  linkType: hard
+
+"ansi-colors@npm:^3.0.0":
+  version: 3.2.4
+  resolution: "ansi-colors@npm:3.2.4"
+  checksum: 026c51880e9f8eb59b112669a87dbea4469939ff94b131606303bbd697438a6691b16b9db3027aa9bf132a244214e83ab1508b998496a34d2aea5b437ac9e62d
+  languageName: node
+  linkType: hard
+
+"ansi-colors@npm:^4.1.1":
+  version: 4.1.3
+  resolution: "ansi-colors@npm:4.1.3"
+  checksum: a9c2ec842038a1fabc7db9ece7d3177e2fe1c5dc6f0c51ecfbf5f39911427b89c00b5dc6b8bd95f82a26e9b16aaae2e83d45f060e98070ce4d1333038edceb0e
+  languageName: node
+  linkType: hard
+
+"ansi-escapes@npm:^3.0.0":
+  version: 3.2.0
+  resolution: "ansi-escapes@npm:3.2.0"
+  checksum: 0f94695b677ea742f7f1eed961f7fd8d05670f744c6ad1f8f635362f6681dcfbc1575cb05b43abc7bb6d67e25a75fb8c7ea8f2a57330eb2c76b33f18cb2cef0a
+  languageName: node
+  linkType: hard
+
+"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.0":
+  version: 4.3.2
+  resolution: "ansi-escapes@npm:4.3.2"
+  dependencies:
+    type-fest: ^0.21.3
+  checksum: 93111c42189c0a6bed9cdb4d7f2829548e943827ee8479c74d6e0b22ee127b2a21d3f8b5ca57723b8ef78ce011fbfc2784350eb2bde3ccfccf2f575fa8489815
+  languageName: node
+  linkType: hard
+
+"ansi-html@npm:0.0.7":
+  version: 0.0.7
+  resolution: "ansi-html@npm:0.0.7"
+  bin:
+    ansi-html: ./bin/ansi-html
+  checksum: 9b839ce99650b4c2d83621d67d68622d27e7948b54f7a4386f2218a3997ee4e684e5a6e8d290880c3f3260e8d90c2613c59c7028f04992ad5c8d99d3a0fcc02c
+  languageName: node
+  linkType: hard
+
+"ansi-regex@npm:^2.0.0":
+  version: 2.1.1
+  resolution: "ansi-regex@npm:2.1.1"
+  checksum: 190abd03e4ff86794f338a31795d262c1dfe8c91f7e01d04f13f646f1dcb16c5800818f886047876f1272f065570ab86b24b99089f8b68a0e11ff19aed4ca8f1
+  languageName: node
+  linkType: hard
+
+"ansi-regex@npm:^3.0.0":
+  version: 3.0.1
+  resolution: "ansi-regex@npm:3.0.1"
+  checksum: 09daf180c5f59af9850c7ac1bd7fda85ba596cc8cbeb210826e90755f06c818af86d9fa1e6e8322fab2c3b9e9b03f56c537b42241139f824dd75066a1e7257cc
+  languageName: node
+  linkType: hard
+
+"ansi-regex@npm:^4.0.0, ansi-regex@npm:^4.1.0":
+  version: 4.1.1
+  resolution: "ansi-regex@npm:4.1.1"
+  checksum: b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888
+  languageName: node
+  linkType: hard
+
+"ansi-regex@npm:^5.0.0, ansi-regex@npm:^5.0.1":
+  version: 5.0.1
+  resolution: "ansi-regex@npm:5.0.1"
+  checksum: 2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b
+  languageName: node
+  linkType: hard
+
+"ansi-styles@npm:^2.2.1":
+  version: 2.2.1
+  resolution: "ansi-styles@npm:2.2.1"
+  checksum: ebc0e00381f2a29000d1dac8466a640ce11943cef3bda3cd0020dc042e31e1058ab59bf6169cd794a54c3a7338a61ebc404b7c91e004092dd20e028c432c9c2c
+  languageName: node
+  linkType: hard
+
+"ansi-styles@npm:^3.2.0, ansi-styles@npm:^3.2.1":
+  version: 3.2.1
+  resolution: "ansi-styles@npm:3.2.1"
+  dependencies:
+    color-convert: ^1.9.0
+  checksum: d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665
+  languageName: node
+  linkType: hard
+
+"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0":
+  version: 4.3.0
+  resolution: "ansi-styles@npm:4.3.0"
+  dependencies:
+    color-convert: ^2.0.1
+  checksum: 513b44c3b2105dd14cc42a19271e80f386466c4be574bccf60b627432f9198571ebf4ab1e4c3ba17347658f4ee1711c163d574248c0c1cdc2d5917a0ad582ec4
+  languageName: node
+  linkType: hard
+
+"anymatch@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "anymatch@npm:2.0.0"
+  dependencies:
+    micromatch: ^3.1.4
+    normalize-path: ^2.1.1
+  checksum: f7bb1929842b4585cdc28edbb385767d499ce7d673f96a8f11348d2b2904592ffffc594fe9229b9a1e9e4dccb9329b7692f9f45e6a11dcefbb76ecdc9ab740f6
+  languageName: node
+  linkType: hard
+
+"anymatch@npm:~3.1.2":
+  version: 3.1.2
+  resolution: "anymatch@npm:3.1.2"
+  dependencies:
+    normalize-path: ^3.0.0
+    picomatch: ^2.0.4
+  checksum: 985163db2292fac9e5a1e072bf99f1b5baccf196e4de25a0b0b81865ebddeb3b3eb4480734ef0a2ac8c002845396b91aa89121f5b84f93981a4658164a9ec6e9
+  languageName: node
+  linkType: hard
+
+"aproba@npm:^1.0.3 || ^2.0.0":
+  version: 2.0.0
+  resolution: "aproba@npm:2.0.0"
+  checksum: 5615cadcfb45289eea63f8afd064ab656006361020e1735112e346593856f87435e02d8dcc7ff0d11928bc7d425f27bc7c2a84f6c0b35ab0ff659c814c138a24
+  languageName: node
+  linkType: hard
+
+"aproba@npm:^1.1.1":
+  version: 1.2.0
+  resolution: "aproba@npm:1.2.0"
+  checksum: 0fca141966559d195072ed047658b6e6c4fe92428c385dd38e288eacfc55807e7b4989322f030faff32c0f46bb0bc10f1e0ac32ec22d25315a1e5bbc0ebb76dc
+  languageName: node
+  linkType: hard
+
+"arch@npm:^2.2.0":
+  version: 2.2.0
+  resolution: "arch@npm:2.2.0"
+  checksum: e21b7635029fe8e9cdd5a026f9a6c659103e63fff423834323cdf836a1bb240a72d0c39ca8c470f84643385cf581bd8eda2cad8bf493e27e54bd9783abe9101f
+  languageName: node
+  linkType: hard
+
+"are-we-there-yet@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "are-we-there-yet@npm:2.0.0"
+  dependencies:
+    delegates: ^1.0.0
+    readable-stream: ^3.6.0
+  checksum: 6c80b4fd04ecee6ba6e737e0b72a4b41bdc64b7d279edfc998678567ff583c8df27e27523bc789f2c99be603ffa9eaa612803da1d886962d2086e7ff6fa90c7c
+  languageName: node
+  linkType: hard
+
+"are-we-there-yet@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "are-we-there-yet@npm:3.0.0"
+  dependencies:
+    delegates: ^1.0.0
+    readable-stream: ^3.6.0
+  checksum: 348edfdd931b0b50868b55402c01c3f64df1d4c229ab6f063539a5025fd6c5f5bb8a0cab409bbed8d75d34762d22aa91b7c20b4204eb8177063158d9ba792981
+  languageName: node
+  linkType: hard
+
+"argparse@npm:^1.0.7":
+  version: 1.0.10
+  resolution: "argparse@npm:1.0.10"
+  dependencies:
+    sprintf-js: ~1.0.2
+  checksum: 7ca6e45583a28de7258e39e13d81e925cfa25d7d4aacbf806a382d3c02fcb13403a07fb8aeef949f10a7cfe4a62da0e2e807b348a5980554cc28ee573ef95945
+  languageName: node
+  linkType: hard
+
+"aria-query@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "aria-query@npm:3.0.0"
+  dependencies:
+    ast-types-flow: 0.0.7
+    commander: ^2.11.0
+  checksum: 52861d7d31321a23f27e5f95a437ddafd20e5eee03ff6e4319eeb1e98dce103f03ccaea34acb5bf2810580f71a9ac1658200fa3d49435279e99df2908f213f1b
+  languageName: node
+  linkType: hard
+
+"arity-n@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "arity-n@npm:1.0.4"
+  checksum: 3d76e16907f7b8a9452690c1efc301d0fbecea457365797eccfbade9b8d1653175b2c38343201bf26fdcbf0bcbb31eab6d912e7c008c6d19042301dc0be80a73
+  languageName: node
+  linkType: hard
+
+"arr-diff@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "arr-diff@npm:4.0.0"
+  checksum: ea7c8834842ad3869297f7915689bef3494fd5b102ac678c13ffccab672d3d1f35802b79e90c4cfec2f424af3392e44112d1ccf65da34562ed75e049597276a0
+  languageName: node
+  linkType: hard
+
+"arr-flatten@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "arr-flatten@npm:1.1.0"
+  checksum: 963fe12564fca2f72c055f3f6c206b9e031f7c433a0c66ca9858b484821f248c5b1e5d53c8e4989d80d764cd776cf6d9b160ad05f47bdc63022bfd63b5455e22
+  languageName: node
+  linkType: hard
+
+"arr-union@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "arr-union@npm:3.1.0"
+  checksum: b5b0408c6eb7591143c394f3be082fee690ddd21f0fdde0a0a01106799e847f67fcae1b7e56b0a0c173290e29c6aca9562e82b300708a268bc8f88f3d6613cb9
+  languageName: node
+  linkType: hard
+
+"array-equal@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "array-equal@npm:1.0.0"
+  checksum: 3f68045806357db9b2fa1ad583e42a659de030633118a0cd35ee4975cb20db3b9a3d36bbec9b5afe70011cf989eefd215c12fe0ce08c498f770859ca6e70688a
+  languageName: node
+  linkType: hard
+
+"array-find-index@npm:^1.0.1":
+  version: 1.0.2
+  resolution: "array-find-index@npm:1.0.2"
+  checksum: aac128bf369e1ac6c06ff0bb330788371c0e256f71279fb92d745e26fb4b9db8920e485b4ec25e841c93146bf71a34dcdbcefa115e7e0f96927a214d237b7081
+  languageName: node
+  linkType: hard
+
+"array-flatten@npm:1.1.1":
+  version: 1.1.1
+  resolution: "array-flatten@npm:1.1.1"
+  checksum: a9925bf3512d9dce202112965de90c222cd59a4fbfce68a0951d25d965cf44642931f40aac72309c41f12df19afa010ecadceb07cfff9ccc1621e99d89ab5f3b
+  languageName: node
+  linkType: hard
+
+"array-flatten@npm:^2.1.0":
+  version: 2.1.2
+  resolution: "array-flatten@npm:2.1.2"
+  checksum: e8988aac1fbfcdaae343d08c9a06a6fddd2c6141721eeeea45c3cf523bf4431d29a46602929455ed548c7a3e0769928cdc630405427297e7081bd118fdec9262
+  languageName: node
+  linkType: hard
+
+"array-from@npm:^2.1.1":
+  version: 2.1.1
+  resolution: "array-from@npm:2.1.1"
+  checksum: 4cd5fa27aa6133b99a57c2881d2a8a66ec59b8e17a0c900f7e8ac9a0a2fae450ed682b67435467bfa71ac9328d025a760c5c46a95586a352180c5a79fc13015d
+  languageName: node
+  linkType: hard
+
+"array-includes@npm:^3.0.3, array-includes@npm:^3.1.1":
+  version: 3.1.3
+  resolution: "array-includes@npm:3.1.3"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.0-next.2
+    get-intrinsic: ^1.1.1
+    is-string: ^1.0.5
+  checksum: eaab8812412b5ec921c8fe678a9d61f501b12f6c72e271e0e8652fe7f4145276cc7ad79ff303ac4ed69cbf5135155bfb092b1b6d552e423e75106d1c887da150
+  languageName: node
+  linkType: hard
+
+"array-union@npm:^1.0.1":
+  version: 1.0.2
+  resolution: "array-union@npm:1.0.2"
+  dependencies:
+    array-uniq: ^1.0.1
+  checksum: 82cec6421b6e6766556c484835a6d476a873f1b71cace5ab2b4f1b15b1e3162dc4da0d16f7a2b04d4aec18146c6638fe8f661340b31ba8e469fd811a1b45dc8d
+  languageName: node
+  linkType: hard
+
+"array-union@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "array-union@npm:2.1.0"
+  checksum: 5bee12395cba82da674931df6d0fea23c4aa4660cb3b338ced9f828782a65caa232573e6bf3968f23e0c5eb301764a382cef2f128b170a9dc59de0e36c39f98d
+  languageName: node
+  linkType: hard
+
+"array-uniq@npm:^1.0.1":
+  version: 1.0.3
+  resolution: "array-uniq@npm:1.0.3"
+  checksum: 1625f06b093d8bf279b81adfec6e72951c0857d65b5e3f65f053fffe9f9dd61c2fc52cff57e38a4700817e7e3f01a4faa433d505ea9e33cdae4514c334e0bf9e
+  languageName: node
+  linkType: hard
+
+"array-unique@npm:^0.3.2":
+  version: 0.3.2
+  resolution: "array-unique@npm:0.3.2"
+  checksum: da344b89cfa6b0a5c221f965c21638bfb76b57b45184a01135382186924f55973cd9b171d4dad6bf606c6d9d36b0d721d091afdc9791535ead97ccbe78f8a888
+  languageName: node
+  linkType: hard
+
+"array.prototype.filter@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "array.prototype.filter@npm:1.0.0"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.0
+    es-array-method-boxes-properly: ^1.0.0
+    is-string: ^1.0.5
+  checksum: 4721be69847a20aa01c3ac048806c86347e017be6b3ffcc1cb68ca46002220cea2ae0588f5d3504eeff45a9d8df28ef4ba1e1e0af292a29974d87266974aed3d
+  languageName: node
+  linkType: hard
+
+"array.prototype.find@npm:^2.1.1":
+  version: 2.1.1
+  resolution: "array.prototype.find@npm:2.1.1"
+  dependencies:
+    define-properties: ^1.1.3
+    es-abstract: ^1.17.4
+  checksum: 805574b1446324ace4211b4942503dd4c7e043491fa67860d6935ae5f35b33cf36647da8c19bed7e19287c5088f1d02688d4fd5ab6c34944f510220f4b7011ea
+  languageName: node
+  linkType: hard
+
+"array.prototype.flat@npm:^1.2.1, array.prototype.flat@npm:^1.2.3":
+  version: 1.2.4
+  resolution: "array.prototype.flat@npm:1.2.4"
+  dependencies:
+    call-bind: ^1.0.0
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.0-next.1
+  checksum: 1ec5d9887ae45e70e4b993e801b440ae5ddcd0d2c6d1dbe214c311e91436152f510916bdac82b066693544b9801a3c510dfbec8a278ababf8de7eb0bde74636f
+  languageName: node
+  linkType: hard
+
+"arrify@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "arrify@npm:1.0.1"
+  checksum: 745075dd4a4624ff0225c331dacb99be501a515d39bcb7c84d24660314a6ec28e68131b137e6f7e16318170842ce97538cd298fc4cd6b2cc798e0b957f2747e7
+  languageName: node
+  linkType: hard
+
+"arvados-workbench-2@workspace:.":
+  version: 0.0.0-use.local
+  resolution: "arvados-workbench-2@workspace:."
+  dependencies:
+    "@babel/core": ^7.0.0
+    "@babel/runtime-corejs2": ^7.0.0
+    "@coreui/coreui": ^4.3.2
+    "@coreui/react": ^4.11.0
+    "@date-io/date-fns": 1
+    "@fortawesome/fontawesome-svg-core": 1.2.28
+    "@fortawesome/free-solid-svg-icons": 5.13.0
+    "@fortawesome/react-fontawesome": 0.1.9
+    "@material-ui/core": 3.9.3
+    "@material-ui/icons": 3.0.1
+    "@sinonjs/fake-timers": ^10.3.0
+    "@types/classnames": 2.2.6
+    "@types/debounce": 3.0.0
+    "@types/dompurify": ^3.0.3
+    "@types/enzyme": 3.1.14
+    "@types/enzyme-adapter-react-16": 1.0.3
+    "@types/file-saver": 2.0.0
+    "@types/is-image": 3.0.0
+    "@types/jest": 26.0.23
+    "@types/js-yaml": 3.11.2
+    "@types/jssha": 0.0.29
+    "@types/jszip": 3.1.5
+    "@types/lodash": 4.14.116
+    "@types/node": 15.12.4
+    "@types/react": 17.0.11
+    "@types/react-copy-to-clipboard": 5.0.0
+    "@types/react-dom": 17.0.8
+    "@types/react-dropzone": 4.2.2
+    "@types/react-highlight-words": 0.12.0
+    "@types/react-redux": 6.0.9
+    "@types/react-router": 4.0.31
+    "@types/react-router-dom": 4.3.1
+    "@types/react-router-redux": 5.0.16
+    "@types/react-virtualized-auto-sizer": 1.0.0
+    "@types/react-window": 1.8.2
+    "@types/redux-devtools": 3.0.44
+    "@types/redux-form": 7.4.12
+    "@types/redux-mock-store": 1.0.2
+    "@types/shell-escape": ^0.2.0
+    "@types/sinon": 7.5
+    "@types/uuid": 3.4.4
+    axios: ^0.28.1
+    axios-mock-adapter: 1.17.0
+    bootstrap: ^5.3.2
+    caniuse-lite: 1.0.30001606
+    classnames: 2.2.6
+    cwlts: 1.15.29
+    cypress: ^13.6.6
+    cypress-wait-until: ^3.0.1
+    date-fns: ^2.28.0
+    debounce: 1.2.0
+    dompurify: ^3.0.6
+    elliptic: 6.5.4
+    enzyme: 3.11.0
+    enzyme-adapter-react-16: 1.15.6
+    file-saver: 2.0.1
+    fstream: 1.0.12
+    is-image: 3.0.0
+    jest-localstorage-mock: 2.2.0
+    js-yaml: 3.13.1
+    jssha: 2.3.1
+    jszip: ^3.10.1
+    lodash: ^4.17.21
+    lodash-es: ^4.17.21
+    lodash.mergewith: 4.6.2
+    lodash.template: 4.5.0
+    material-ui-pickers: ^2.2.4
+    mem: 4.0.0
+    mime: ^3.0.0
+    moment: ^2.29.4
+    node-sass: ^9.0.0
+    node-sass-chokidar: ^2.0.0
+    parse-duration: 0.4.4
+    prop-types: 15.7.2
+    query-string: 6.9.0
+    react: 16.14.0
+    react-copy-to-clipboard: 5.0.3
+    react-dnd: 5.0.0
+    react-dnd-html5-backend: 5.0.1
+    react-dom: 16.14.0
+    react-dropzone: 5.1.1
+    react-highlight-words: 0.14.0
+    react-idle-timer: 4.3.6
+    react-loader-spinner: ^6.1.6
+    react-redux: 5.0.7
+    react-router: 4.3.1
+    react-router-dom: 4.3.1
+    react-router-redux: 5.0.0-alpha.9
+    react-rte: ^0.16.5
+    react-scripts: 3.4.4
+    react-splitter-layout: 3.0.1
+    react-transition-group: 2.5.0
+    react-virtualized-auto-sizer: 1.0.2
+    react-window: 1.8.5
+    redux: 4.0.3
+    redux-devtools: 3.4.1
+    redux-devtools-extension: ^2.13.9
+    redux-form: 7.4.2
+    redux-mock-store: 1.5.4
+    redux-thunk: 2.3.0
+    reselect: 4.0.0
+    set-value: 2.0.1
+    shell-escape: ^0.2.0
+    sinon: 7.3
+    tippy.js: ^6.3.7
+    ts-mock-imports: 1.3.7
+    tslint: 5.20.0
+    tslint-etc: 1.6.0
+    typescript: 4.3.4
+    unionize: 2.1.2
+    uuid: 3.3.2
+    wait-on: 4.0.2
+    yamljs: 0.3.0
+  languageName: unknown
+  linkType: soft
+
+"asap@npm:^2.0.6, asap@npm:~2.0.3, asap@npm:~2.0.6":
+  version: 2.0.6
+  resolution: "asap@npm:2.0.6"
+  checksum: b296c92c4b969e973260e47523207cd5769abd27c245a68c26dc7a0fe8053c55bb04360237cb51cab1df52be939da77150ace99ad331fb7fb13b3423ed73ff3d
+  languageName: node
+  linkType: hard
+
+"asn1.js@npm:^4.10.1":
+  version: 4.10.1
+  resolution: "asn1.js@npm:4.10.1"
+  dependencies:
+    bn.js: ^4.0.0
+    inherits: ^2.0.1
+    minimalistic-assert: ^1.0.0
+  checksum: 9289a1a55401238755e3142511d7b8f6fc32f08c86ff68bd7100da8b6c186179dd6b14234fba2f7f6099afcd6758a816708485efe44bc5b2a6ec87d9ceeddbb5
+  languageName: node
+  linkType: hard
+
+"asn1.js@npm:^5.2.0":
+  version: 5.4.1
+  resolution: "asn1.js@npm:5.4.1"
+  dependencies:
+    bn.js: ^4.0.0
+    inherits: ^2.0.1
+    minimalistic-assert: ^1.0.0
+    safer-buffer: ^2.1.0
+  checksum: 3786a101ac6f304bd4e9a7df79549a7561950a13d4bcaec0c7790d44c80d147c1a94ba3d4e663673406064642a40b23fcd6c82a9952468e386c1a1376d747f9a
+  languageName: node
+  linkType: hard
+
+"asn1@npm:~0.2.3":
+  version: 0.2.4
+  resolution: "asn1@npm:0.2.4"
+  dependencies:
+    safer-buffer: ~2.1.0
+  checksum: aa5d6f77b1e0597df53824c68cfe82d1d89ce41cb3520148611f025fbb3101b2d25dd6a40ad34e4fac10f6b19ed5e8628cd4b7d212261e80e83f02b39ee5663c
+  languageName: node
+  linkType: hard
+
+"assert-plus@npm:1.0.0, assert-plus@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "assert-plus@npm:1.0.0"
+  checksum: 19b4340cb8f0e6a981c07225eacac0e9d52c2644c080198765d63398f0075f83bbc0c8e95474d54224e297555ad0d631c1dcd058adb1ddc2437b41a6b424ac64
+  languageName: node
+  linkType: hard
+
+"assert@npm:^1.1.1":
+  version: 1.5.0
+  resolution: "assert@npm:1.5.0"
+  dependencies:
+    object-assign: ^4.1.1
+    util: 0.10.3
+  checksum: 9be48435f726029ae7020c5888a3566bf4d617687aab280827f2e4029644b6515a9519ea10d018b342147c02faf73d9e9419e780e8937b3786ee4945a0ca71e5
+  languageName: node
+  linkType: hard
+
+"assign-symbols@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "assign-symbols@npm:1.0.0"
+  checksum: c0eb895911d05b6b2d245154f70461c5e42c107457972e5ebba38d48967870dee53bcdf6c7047990586daa80fab8dab3cc6300800fbd47b454247fdedd859a2c
+  languageName: node
+  linkType: hard
+
+"ast-types-flow@npm:0.0.7, ast-types-flow@npm:^0.0.7":
+  version: 0.0.7
+  resolution: "ast-types-flow@npm:0.0.7"
+  checksum: a26dcc2182ffee111cad7c471759b0bda22d3b7ebacf27c348b22c55f16896b18ab0a4d03b85b4020dce7f3e634b8f00b593888f622915096ea1927fa51866c4
+  languageName: node
+  linkType: hard
+
+"astral-regex@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "astral-regex@npm:1.0.0"
+  checksum: 93417fc0879531cd95ace2560a54df865c9461a3ac0714c60cbbaa5f1f85d2bee85489e78d82f70b911b71ac25c5f05fc5a36017f44c9bb33c701bee229ff848
+  languageName: node
+  linkType: hard
+
+"astral-regex@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "astral-regex@npm:2.0.0"
+  checksum: 876231688c66400473ba505731df37ea436e574dd524520294cc3bbc54ea40334865e01fa0d074d74d036ee874ee7e62f486ea38bc421ee8e6a871c06f011766
+  languageName: node
+  linkType: hard
+
+"async-each@npm:^1.0.1":
+  version: 1.0.3
+  resolution: "async-each@npm:1.0.3"
+  checksum: 868651cfeb209970b367fbb96df1e1c8dc0b22c681cda7238417005ab2a5fbd944ee524b43f2692977259a57b7cc2547e03ff68f2b5113dbdf953d48cc078dc3
+  languageName: node
+  linkType: hard
+
+"async-foreach@npm:^0.1.3":
+  version: 0.1.3
+  resolution: "async-foreach@npm:0.1.3"
+  checksum: cc43dee65de4decfa521d9444fb87edb2d475e7125d7f63d0d12004d12953e382135a2ea89a83b145ee1b9ec140550c804e1bfca24085d6faeb52c2902acd1f1
+  languageName: node
+  linkType: hard
+
+"async-limiter@npm:~1.0.0":
+  version: 1.0.1
+  resolution: "async-limiter@npm:1.0.1"
+  checksum: 2b849695b465d93ad44c116220dee29a5aeb63adac16c1088983c339b0de57d76e82533e8e364a93a9f997f28bbfc6a92948cefc120652bd07f3b59f8d75cf2b
+  languageName: node
+  linkType: hard
+
+"async@npm:^2.6.2":
+  version: 2.6.4
+  resolution: "async@npm:2.6.4"
+  dependencies:
+    lodash: ^4.17.14
+  checksum: a52083fb32e1ebe1d63e5c5624038bb30be68ff07a6c8d7dfe35e47c93fc144bd8652cbec869e0ac07d57dde387aa5f1386be3559cdee799cb1f789678d88e19
+  languageName: node
+  linkType: hard
+
+"async@npm:^3.2.0":
+  version: 3.2.4
+  resolution: "async@npm:3.2.4"
+  checksum: 43d07459a4e1d09b84a20772414aa684ff4de085cbcaec6eea3c7a8f8150e8c62aa6cd4e699fe8ee93c3a5b324e777d34642531875a0817a35697522c1b02e89
+  languageName: node
+  linkType: hard
+
+"asynckit@npm:^0.4.0":
+  version: 0.4.0
+  resolution: "asynckit@npm:0.4.0"
+  checksum: 7b78c451df768adba04e2d02e63e2d0bf3b07adcd6e42b4cf665cb7ce899bedd344c69a1dcbce355b5f972d597b25aaa1c1742b52cffd9caccb22f348114f6be
+  languageName: node
+  linkType: hard
+
+"at-least-node@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "at-least-node@npm:1.0.0"
+  checksum: 463e2f8e43384f1afb54bc68485c436d7622acec08b6fad269b421cb1d29cebb5af751426793d0961ed243146fe4dc983402f6d5a51b720b277818dbf6f2e49e
+  languageName: node
+  linkType: hard
+
+"atob@npm:^2.1.2":
+  version: 2.1.2
+  resolution: "atob@npm:2.1.2"
+  bin:
+    atob: bin/atob.js
+  checksum: dfeeeb70090c5ebea7be4b9f787f866686c645d9f39a0d184c817252d0cf08455ed25267d79c03254d3be1f03ac399992a792edcd5ffb9c91e097ab5ef42833a
+  languageName: node
+  linkType: hard
+
+"attr-accept@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "attr-accept@npm:1.1.3"
+  dependencies:
+    core-js: ^2.5.0
+  checksum: 836c0e863719c0355e8ad4214e4c81f24dd9e724233153200189732da691f73e6f13316b8ac456d75749a89780f0da67e44387e46dfc3fd9a517a84a0e45acbc
+  languageName: node
+  linkType: hard
+
+"autobind-decorator@npm:^2.1.0":
+  version: 2.4.0
+  resolution: "autobind-decorator@npm:2.4.0"
+  checksum: 6fcc922580d3585a3aeef1a480f935c0827b1a4505b9e39ff9bcad9039958bd47b27a4e35152f566e01befb2924701dbc9f744ec29eeb880c99eef8e39fce4a3
+  languageName: node
+  linkType: hard
+
+"autoprefixer@npm:^9.6.1":
+  version: 9.8.6
+  resolution: "autoprefixer@npm:9.8.6"
+  dependencies:
+    browserslist: ^4.12.0
+    caniuse-lite: ^1.0.30001109
+    colorette: ^1.2.1
+    normalize-range: ^0.1.2
+    num2fraction: ^1.2.2
+    postcss: ^7.0.32
+    postcss-value-parser: ^4.1.0
+  bin:
+    autoprefixer: bin/autoprefixer
+  checksum: 46987bc3de6612f0276c3643061901e33cc5721d07aaeb6f0daf237554448884a59c0b17087bf0f00a07d940abcb5a6eaf2203b962c24fe33d52f76aa845cb70
+  languageName: node
+  linkType: hard
+
+"aws-sign2@npm:~0.7.0":
+  version: 0.7.0
+  resolution: "aws-sign2@npm:0.7.0"
+  checksum: b148b0bb0778098ad8cf7e5fc619768bcb51236707ca1d3e5b49e41b171166d8be9fdc2ea2ae43d7decf02989d0aaa3a9c4caa6f320af95d684de9b548a71525
+  languageName: node
+  linkType: hard
+
+"aws4@npm:^1.8.0":
+  version: 1.11.0
+  resolution: "aws4@npm:1.11.0"
+  checksum: 5a00d045fd0385926d20ebebcfba5ec79d4482fe706f63c27b324d489a04c68edb0db99ed991e19eda09cb8c97dc2452059a34d97545cebf591d7a2b5a10999f
+  languageName: node
+  linkType: hard
+
+"axios-mock-adapter@npm:1.17.0":
+  version: 1.17.0
+  resolution: "axios-mock-adapter@npm:1.17.0"
+  dependencies:
+    deep-equal: ^1.0.1
+  peerDependencies:
+    axios: ">= 0.9.0"
+  checksum: 2f462212f030925ba3d11968ebc6947c9169e590cf05a74d18c927ca4727b49ae69dbc8835a19f518697da991e46f7b7baf28b62765dea8945bfcaaa6941426c
+  languageName: node
+  linkType: hard
+
+"axios@npm:^0.28.1":
+  version: 0.28.1
+  resolution: "axios@npm:0.28.1"
+  dependencies:
+    follow-redirects: ^1.15.0
+    form-data: ^4.0.0
+    proxy-from-env: ^1.1.0
+  checksum: 5115a38d79064d07437c5a28f15841e3607634040e3120ec06a2c4367a7d07cf213b16496eab53b6f58ebc5fb377a440ba9ed4782529b14449a1e285734bfb54
+  languageName: node
+  linkType: hard
+
+"axobject-query@npm:^2.0.2":
+  version: 2.2.0
+  resolution: "axobject-query@npm:2.2.0"
+  checksum: 96b8c7d807ca525f41ad9b286186e2089b561ba63a6d36c3e7d73dc08150714660995c7ad19cda05784458446a0793b45246db45894631e13853f48c1aa3117f
+  languageName: node
+  linkType: hard
+
+"babel-code-frame@npm:^6.22.0":
+  version: 6.26.0
+  resolution: "babel-code-frame@npm:6.26.0"
+  dependencies:
+    chalk: ^1.1.3
+    esutils: ^2.0.2
+    js-tokens: ^3.0.2
+  checksum: 9410c3d5a921eb02fa409675d1a758e493323a49e7b9dddb7a2a24d47e61d39ab1129dd29f9175836eac9ce8b1d4c0a0718fcdc57ce0b865b529fd250dbab313
+  languageName: node
+  linkType: hard
+
+"babel-eslint@npm:10.1.0":
+  version: 10.1.0
+  resolution: "babel-eslint@npm:10.1.0"
+  dependencies:
+    "@babel/code-frame": ^7.0.0
+    "@babel/parser": ^7.7.0
+    "@babel/traverse": ^7.7.0
+    "@babel/types": ^7.7.0
+    eslint-visitor-keys: ^1.0.0
+    resolve: ^1.12.0
+  peerDependencies:
+    eslint: ">= 4.12.1"
+  checksum: bdc1f62b6b0f9c4d5108c96d835dad0c0066bc45b7c020fcb2d6a08107cf69c9217a99d3438dbd701b2816896190c4283ba04270ed9a8349ee07bd8dafcdc050
+  languageName: node
+  linkType: hard
+
+"babel-extract-comments@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "babel-extract-comments@npm:1.0.0"
+  dependencies:
+    babylon: ^6.18.0
+  checksum: 6345c688ccb56a7b750223afb42c1ddc83865b8ac33d7b808b5ad5e3619624563cf8324fbacdcf41cf073a40d935468a05f806e1a7622b0186fa5dad1232a07b
+  languageName: node
+  linkType: hard
+
+"babel-jest@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "babel-jest@npm:24.9.0"
+  dependencies:
+    "@jest/transform": ^24.9.0
+    "@jest/types": ^24.9.0
+    "@types/babel__core": ^7.1.0
+    babel-plugin-istanbul: ^5.1.0
+    babel-preset-jest: ^24.9.0
+    chalk: ^2.4.2
+    slash: ^2.0.0
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 205f0d701a202edb483a1f8cc79557f777d20df42656f1a1c2e7ef368f8f53f9d4c4af08ea812d98b61ab12cc5f146db4573a301880770d1dc5748624cc51711
+  languageName: node
+  linkType: hard
+
+"babel-loader@npm:8.1.0":
+  version: 8.1.0
+  resolution: "babel-loader@npm:8.1.0"
+  dependencies:
+    find-cache-dir: ^2.1.0
+    loader-utils: ^1.4.0
+    mkdirp: ^0.5.3
+    pify: ^4.0.1
+    schema-utils: ^2.6.5
+  peerDependencies:
+    "@babel/core": ^7.0.0
+    webpack: ">=2"
+  checksum: fdbcae91cc43366206320a1cbe40d358a64ba2dfaa561fbd690efe0db6256c9d27ab7600f7c84041fbc4c2a6f0279175b1f8d1fa5ed17ec30bbd734da84a1bc0
+  languageName: node
+  linkType: hard
+
+"babel-plugin-dynamic-import-node@npm:^2.3.3":
+  version: 2.3.3
+  resolution: "babel-plugin-dynamic-import-node@npm:2.3.3"
+  dependencies:
+    object.assign: ^4.1.0
+  checksum: c9d24415bcc608d0db7d4c8540d8002ac2f94e2573d2eadced137a29d9eab7e25d2cbb4bc6b9db65cf6ee7430f7dd011d19c911a9a778f0533b4a05ce8292c9b
+  languageName: node
+  linkType: hard
+
+"babel-plugin-istanbul@npm:^5.1.0":
+  version: 5.2.0
+  resolution: "babel-plugin-istanbul@npm:5.2.0"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.0.0
+    find-up: ^3.0.0
+    istanbul-lib-instrument: ^3.3.0
+    test-exclude: ^5.2.3
+  checksum: 46e31a53d1c08a4b738c988871e94dd83e534b3d49248c45c9e63d04d221aa787d8c4f32576e1fade26dbab7cabeae665cbf5eb067aaef74500048dfef365c80
+  languageName: node
+  linkType: hard
+
+"babel-plugin-jest-hoist@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "babel-plugin-jest-hoist@npm:24.9.0"
+  dependencies:
+    "@types/babel__traverse": ^7.0.6
+  checksum: 9f0d23fcf94448e302e201665d7232303a548107adf545590b09f22a747755387cb9dc676d22884a298b17d11ede5401436e1b70fa574eee3efa61ad1230c8e6
+  languageName: node
+  linkType: hard
+
+"babel-plugin-macros@npm:2.8.0":
+  version: 2.8.0
+  resolution: "babel-plugin-macros@npm:2.8.0"
+  dependencies:
+    "@babel/runtime": ^7.7.2
+    cosmiconfig: ^6.0.0
+    resolve: ^1.12.0
+  checksum: 59b09a21cf3ae1e14186c1b021917d004b49b953824b24953a54c6502da79e8051d4ac31cfd4a0ae7f6ea5ddf1f7edd93df4895dd3c3982a5b2431859c2889ac
+  languageName: node
+  linkType: hard
+
+"babel-plugin-named-asset-import@npm:^0.3.6":
+  version: 0.3.7
+  resolution: "babel-plugin-named-asset-import@npm:0.3.7"
+  peerDependencies:
+    "@babel/core": ^7.1.0
+  checksum: 4c9a42a2762f3d596a09105d05991525a0553d095030459d0f71449b023801ccc43e90fa20b618c52283dc61ca528a4a59df244e5b1dd583867786088eb473b7
+  languageName: node
+  linkType: hard
+
+"babel-plugin-polyfill-corejs2@npm:^0.2.2":
+  version: 0.2.2
+  resolution: "babel-plugin-polyfill-corejs2@npm:0.2.2"
+  dependencies:
+    "@babel/compat-data": ^7.13.11
+    "@babel/helper-define-polyfill-provider": ^0.2.2
+    semver: ^6.1.1
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: eee45ecce743e06840d29936a7f4a9f9eca19552ba010e9f3676c6a2697ab815230f39953296b72f09665de0e8fffe260e52b348011a9ddba36cfa7eec6f8c51
+  languageName: node
+  linkType: hard
+
+"babel-plugin-polyfill-corejs3@npm:^0.2.2":
+  version: 0.2.3
+  resolution: "babel-plugin-polyfill-corejs3@npm:0.2.3"
+  dependencies:
+    "@babel/helper-define-polyfill-provider": ^0.2.2
+    core-js-compat: ^3.14.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: e390c5317b35808633d32db2c1718aef6af788df148adc6fa54e56d2266896ad2da2d200163f392e06ae1ebd1a0feaeaf18d7a337dea70387429618898b90a68
+  languageName: node
+  linkType: hard
+
+"babel-plugin-polyfill-regenerator@npm:^0.2.2":
+  version: 0.2.2
+  resolution: "babel-plugin-polyfill-regenerator@npm:0.2.2"
+  dependencies:
+    "@babel/helper-define-polyfill-provider": ^0.2.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 3e32e318fd91d65c3af2bb363189f00d3839f07a73a08813b553553e07da205162091b428dd5b6ffb6ea4caf531ff43ebc54197b0a5a9dc2fc5c7e9a650e946d
+  languageName: node
+  linkType: hard
+
+"babel-plugin-syntax-object-rest-spread@npm:^6.8.0":
+  version: 6.13.0
+  resolution: "babel-plugin-syntax-object-rest-spread@npm:6.13.0"
+  checksum: 14083f2783c760f5f199160f48e42ad4505fd35fc7cf9c4530812b176705259562b77db6d3ddc5e3cbce9e9b2b61ec9db3065941f0949b68e77cae3e395a6eef
+  languageName: node
+  linkType: hard
+
+"babel-plugin-transform-object-rest-spread@npm:^6.26.0":
+  version: 6.26.0
+  resolution: "babel-plugin-transform-object-rest-spread@npm:6.26.0"
+  dependencies:
+    babel-plugin-syntax-object-rest-spread: ^6.8.0
+    babel-runtime: ^6.26.0
+  checksum: aad583fb0d08073678838f77fa822788b9a0b842ba33e34f8d131246852f7ed31cfb5fdf57644dec952f84dcae862a27dbf3d12ccbee6bdb0aed6e7ed13ca9ba
+  languageName: node
+  linkType: hard
+
+"babel-plugin-transform-react-remove-prop-types@npm:0.4.24":
+  version: 0.4.24
+  resolution: "babel-plugin-transform-react-remove-prop-types@npm:0.4.24"
+  checksum: 54afe56d67f0d118c9da23996f39948e502a152b3f582eb6e8f163fcb76c2c1ea4e0cdd4f9fac5c0ef050eab4fe0a950b0b74aae6237bcc0d31d8fc4cc808d1a
+  languageName: node
+  linkType: hard
+
+"babel-preset-jest@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "babel-preset-jest@npm:24.9.0"
+  dependencies:
+    "@babel/plugin-syntax-object-rest-spread": ^7.0.0
+    babel-plugin-jest-hoist: ^24.9.0
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: d32ab6255e36ed06ef1cc53089b261a74c171d17758792979c2992d4fcb97982f67f837156bbef38042eb11751496a783dee61aafcbf2d7449ed94d52483bee2
+  languageName: node
+  linkType: hard
+
+"babel-preset-react-app@npm:^9.1.2":
+  version: 9.1.2
+  resolution: "babel-preset-react-app@npm:9.1.2"
+  dependencies:
+    "@babel/core": 7.9.0
+    "@babel/plugin-proposal-class-properties": 7.8.3
+    "@babel/plugin-proposal-decorators": 7.8.3
+    "@babel/plugin-proposal-nullish-coalescing-operator": 7.8.3
+    "@babel/plugin-proposal-numeric-separator": 7.8.3
+    "@babel/plugin-proposal-optional-chaining": 7.9.0
+    "@babel/plugin-transform-flow-strip-types": 7.9.0
+    "@babel/plugin-transform-react-display-name": 7.8.3
+    "@babel/plugin-transform-runtime": 7.9.0
+    "@babel/preset-env": 7.9.0
+    "@babel/preset-react": 7.9.1
+    "@babel/preset-typescript": 7.9.0
+    "@babel/runtime": 7.9.0
+    babel-plugin-macros: 2.8.0
+    babel-plugin-transform-react-remove-prop-types: 0.4.24
+  checksum: ebdf90c922394ba3c72a326e14c5deff45292fdb46e114d5d83e9a1cf9cb433262254def4347767f5c7aa0924f0795dadae5c82bbc3acd77111c0b1df9316cd9
+  languageName: node
+  linkType: hard
+
+"babel-runtime@npm:^6.23.0, babel-runtime@npm:^6.26.0":
+  version: 6.26.0
+  resolution: "babel-runtime@npm:6.26.0"
+  dependencies:
+    core-js: ^2.4.0
+    regenerator-runtime: ^0.11.0
+  checksum: 8aeade94665e67a73c1ccc10f6fd42ba0c689b980032b70929de7a6d9a12eb87ef51902733f8fefede35afea7a5c3ef7e916a64d503446c1eedc9e3284bd3d50
+  languageName: node
+  linkType: hard
+
+"babylon@npm:^6.18.0":
+  version: 6.18.0
+  resolution: "babylon@npm:6.18.0"
+  bin:
+    babylon: ./bin/babylon.js
+  checksum: 0777ae0c735ce1cbfc856d627589ed9aae212b84fb0c03c368b55e6c5d3507841780052808d0ad46e18a2ba516e93d55eeed8cd967f3b2938822dfeccfb2a16d
+  languageName: node
+  linkType: hard
+
+"balanced-match@npm:^1.0.0":
+  version: 1.0.2
+  resolution: "balanced-match@npm:1.0.2"
+  checksum: 9706c088a283058a8a99e0bf91b0a2f75497f185980d9ffa8b304de1d9e58ebda7c72c07ebf01dadedaac5b2907b2c6f566f660d62bd336c3468e960403b9d65
+  languageName: node
+  linkType: hard
+
+"base64-js@npm:^1.0.2, base64-js@npm:^1.3.1":
+  version: 1.5.1
+  resolution: "base64-js@npm:1.5.1"
+  checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
+  languageName: node
+  linkType: hard
+
+"base@npm:^0.11.1":
+  version: 0.11.2
+  resolution: "base@npm:0.11.2"
+  dependencies:
+    cache-base: ^1.0.1
+    class-utils: ^0.3.5
+    component-emitter: ^1.2.1
+    define-property: ^1.0.0
+    isobject: ^3.0.1
+    mixin-deep: ^1.2.0
+    pascalcase: ^0.1.1
+  checksum: a4a146b912e27eea8f66d09cb0c9eab666f32ce27859a7dfd50f38cd069a2557b39f16dba1bc2aecb3b44bf096738dd207b7970d99b0318423285ab1b1994edd
+  languageName: node
+  linkType: hard
+
+"batch@npm:0.6.1":
+  version: 0.6.1
+  resolution: "batch@npm:0.6.1"
+  checksum: 61f9934c7378a51dce61b915586191078ef7f1c3eca707fdd58b96ff2ff56d9e0af2bdab66b1462301a73c73374239e6542d9821c0af787f3209a23365d07e7f
+  languageName: node
+  linkType: hard
+
+"bcrypt-pbkdf@npm:^1.0.0":
+  version: 1.0.2
+  resolution: "bcrypt-pbkdf@npm:1.0.2"
+  dependencies:
+    tweetnacl: ^0.14.3
+  checksum: 4edfc9fe7d07019609ccf797a2af28351736e9d012c8402a07120c4453a3b789a15f2ee1530dc49eee8f7eb9379331a8dd4b3766042b9e502f74a68e7f662291
+  languageName: node
+  linkType: hard
+
+"big.js@npm:^5.2.2":
+  version: 5.2.2
+  resolution: "big.js@npm:5.2.2"
+  checksum: b89b6e8419b097a8fb4ed2399a1931a68c612bce3cfd5ca8c214b2d017531191070f990598de2fc6f3f993d91c0f08aa82697717f6b3b8732c9731866d233c9e
+  languageName: node
+  linkType: hard
+
+"binary-extensions@npm:^1.0.0":
+  version: 1.13.1
+  resolution: "binary-extensions@npm:1.13.1"
+  checksum: ad7747f33c07e94ba443055de130b50c8b8b130a358bca064c580d91769ca6a69c7ac65ca008ff044ed4541d2c6ad45496e1fadbef5218a68770996b6a2194d7
+  languageName: node
+  linkType: hard
+
+"binary-extensions@npm:^2.0.0":
+  version: 2.2.0
+  resolution: "binary-extensions@npm:2.2.0"
+  checksum: ccd267956c58d2315f5d3ea6757cf09863c5fc703e50fbeb13a7dc849b812ef76e3cf9ca8f35a0c48498776a7478d7b4a0418e1e2b8cb9cb9731f2922aaad7f8
+  languageName: node
+  linkType: hard
+
+"bindings@npm:^1.5.0":
+  version: 1.5.0
+  resolution: "bindings@npm:1.5.0"
+  dependencies:
+    file-uri-to-path: 1.0.0
+  checksum: 65b6b48095717c2e6105a021a7da4ea435aa8d3d3cd085cb9e85bcb6e5773cf318c4745c3f7c504412855940b585bdf9b918236612a1c7a7942491de176f1ae7
+  languageName: node
+  linkType: hard
+
+"blob-util@npm:^2.0.2":
+  version: 2.0.2
+  resolution: "blob-util@npm:2.0.2"
+  checksum: d543e6b92e4ca715ca33c78e89a07a2290d43e5b2bc897d7ec588c5c7bbf59df93e45225ac0c9258aa6ce4320358990f99c9288f1c48280f8ec5d7a2e088d19b
+  languageName: node
+  linkType: hard
+
+"bluebird@npm:^3.5.5, bluebird@npm:^3.7.2":
+  version: 3.7.2
+  resolution: "bluebird@npm:3.7.2"
+  checksum: 869417503c722e7dc54ca46715f70e15f4d9c602a423a02c825570862d12935be59ed9c7ba34a9b31f186c017c23cac6b54e35446f8353059c101da73eac22ef
+  languageName: node
+  linkType: hard
+
+"bn.js@npm:^4.0.0, bn.js@npm:^4.1.0, bn.js@npm:^4.11.9":
+  version: 4.12.0
+  resolution: "bn.js@npm:4.12.0"
+  checksum: 39afb4f15f4ea537b55eaf1446c896af28ac948fdcf47171961475724d1bb65118cca49fa6e3d67706e4790955ec0e74de584e45c8f1ef89f46c812bee5b5a12
+  languageName: node
+  linkType: hard
+
+"bn.js@npm:^5.0.0":
+  version: 5.2.0
+  resolution: "bn.js@npm:5.2.0"
+  checksum: 6117170393200f68b35a061ecbf55d01dd989302e7b3c798a3012354fa638d124f0b2f79e63f77be5556be80322a09c40339eda6413ba7468524c0b6d4b4cb7a
+  languageName: node
+  linkType: hard
+
+"bn.js@npm:^5.2.1":
+  version: 5.2.1
+  resolution: "bn.js@npm:5.2.1"
+  checksum: 3dd8c8d38055fedfa95c1d5fc3c99f8dd547b36287b37768db0abab3c239711f88ff58d18d155dd8ad902b0b0cee973747b7ae20ea12a09473272b0201c9edd3
+  languageName: node
+  linkType: hard
+
+"body-parser@npm:1.20.2":
+  version: 1.20.2
+  resolution: "body-parser@npm:1.20.2"
+  dependencies:
+    bytes: 3.1.2
+    content-type: ~1.0.5
+    debug: 2.6.9
+    depd: 2.0.0
+    destroy: 1.2.0
+    http-errors: 2.0.0
+    iconv-lite: 0.4.24
+    on-finished: 2.4.1
+    qs: 6.11.0
+    raw-body: 2.5.2
+    type-is: ~1.6.18
+    unpipe: 1.0.0
+  checksum: 14d37ec638ab5c93f6099ecaed7f28f890d222c650c69306872e00b9efa081ff6c596cd9afb9930656aae4d6c4e1c17537bea12bb73c87a217cb3cfea8896737
+  languageName: node
+  linkType: hard
+
+"bonjour@npm:^3.5.0":
+  version: 3.5.0
+  resolution: "bonjour@npm:3.5.0"
+  dependencies:
+    array-flatten: ^2.1.0
+    deep-equal: ^1.0.1
+    dns-equal: ^1.0.0
+    dns-txt: ^2.0.2
+    multicast-dns: ^6.0.1
+    multicast-dns-service-types: ^1.1.0
+  checksum: 2cfbe9fa861f4507b5ff3853eeae3ef03a231ede2b7363efedd80880ea3c0576f64416f98056c96e429ed68ff38dc4a70c0583d1eb4dab72e491ca44a0f03444
+  languageName: node
+  linkType: hard
+
+"boolbase@npm:^1.0.0, boolbase@npm:~1.0.0":
+  version: 1.0.0
+  resolution: "boolbase@npm:1.0.0"
+  checksum: 3e25c80ef626c3a3487c73dbfc70ac322ec830666c9ad915d11b701142fab25ec1e63eff2c450c74347acfd2de854ccde865cd79ef4db1683f7c7b046ea43bb0
+  languageName: node
+  linkType: hard
+
+"bootstrap@npm:^5.3.2":
+  version: 5.3.2
+  resolution: "bootstrap@npm:5.3.2"
+  peerDependencies:
+    "@popperjs/core": ^2.11.8
+  checksum: d5580b253d121ffc137388d41da58dce8d15f1ccd574e12f28d4a08e7649ca15e95db645b2b677cb8025bccd446bff04138fc0fe64f8cba0ccc5dc004a8644cf
+  languageName: node
+  linkType: hard
+
+"brace-expansion@npm:^1.1.7":
+  version: 1.1.11
+  resolution: "brace-expansion@npm:1.1.11"
+  dependencies:
+    balanced-match: ^1.0.0
+    concat-map: 0.0.1
+  checksum: faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07
+  languageName: node
+  linkType: hard
+
+"brace-expansion@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "brace-expansion@npm:2.0.1"
+  dependencies:
+    balanced-match: ^1.0.0
+  checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1
+  languageName: node
+  linkType: hard
+
+"braces@npm:^2.3.1, braces@npm:^2.3.2":
+  version: 2.3.2
+  resolution: "braces@npm:2.3.2"
+  dependencies:
+    arr-flatten: ^1.1.0
+    array-unique: ^0.3.2
+    extend-shallow: ^2.0.1
+    fill-range: ^4.0.0
+    isobject: ^3.0.1
+    repeat-element: ^1.1.2
+    snapdragon: ^0.8.1
+    snapdragon-node: ^2.0.1
+    split-string: ^3.0.2
+    to-regex: ^3.0.1
+  checksum: e30dcb6aaf4a31c8df17d848aa283a65699782f75ad61ae93ec25c9729c66cf58e66f0000a9fec84e4add1135bb7da40f7cb9601b36bebcfa9ca58e8d5c07de0
+  languageName: node
+  linkType: hard
+
+"braces@npm:^3.0.2, braces@npm:~3.0.2":
+  version: 3.0.2
+  resolution: "braces@npm:3.0.2"
+  dependencies:
+    fill-range: ^7.0.1
+  checksum: e2a8e769a863f3d4ee887b5fe21f63193a891c68b612ddb4b68d82d1b5f3ff9073af066c343e9867a393fe4c2555dcb33e89b937195feb9c1613d259edfcd459
+  languageName: node
+  linkType: hard
+
+"brcast@npm:^3.0.1":
+  version: 3.0.2
+  resolution: "brcast@npm:3.0.2"
+  checksum: 7abae42088c6ffad9ff9e0fc756607a1764e299d737b3007fa49a73a38b3fda1e51362713f645db7991878ca388de94942011188450324c3debd08509315fa94
+  languageName: node
+  linkType: hard
+
+"brorand@npm:^1.0.1, brorand@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "brorand@npm:1.1.0"
+  checksum: 8a05c9f3c4b46572dec6ef71012b1946db6cae8c7bb60ccd4b7dd5a84655db49fe043ecc6272e7ef1f69dc53d6730b9e2a3a03a8310509a3d797a618cbee52be
+  languageName: node
+  linkType: hard
+
+"browser-process-hrtime@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "browser-process-hrtime@npm:1.0.0"
+  checksum: e30f868cdb770b1201afb714ad1575dd86366b6e861900884665fb627109b3cc757c40067d3bfee1ff2a29c835257ea30725a8018a9afd02ac1c24b408b1e45f
+  languageName: node
+  linkType: hard
+
+"browser-resolve@npm:^1.11.3":
+  version: 1.11.3
+  resolution: "browser-resolve@npm:1.11.3"
+  dependencies:
+    resolve: 1.1.7
+  checksum: 431bfc1a17406362a3010a2c35503eb7d1253dbcb8081c1ce236ddb0b954a33d52dcaf0b07f64c0f20394d6eeec1be4f6551da3734ce9ed5dcc38e876c96d5d5
+  languageName: node
+  linkType: hard
+
+"browserify-aes@npm:^1.0.0, browserify-aes@npm:^1.0.4, browserify-aes@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "browserify-aes@npm:1.2.0"
+  dependencies:
+    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
+  checksum: 4a17c3eb55a2aa61c934c286f34921933086bf6d67f02d4adb09fcc6f2fc93977b47d9d884c25619144fccd47b3b3a399e1ad8b3ff5a346be47270114bcf7104
+  languageName: node
+  linkType: hard
+
+"browserify-cipher@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "browserify-cipher@npm:1.0.1"
+  dependencies:
+    browserify-aes: ^1.0.4
+    browserify-des: ^1.0.0
+    evp_bytestokey: ^1.0.0
+  checksum: 2d8500acf1ee535e6bebe808f7a20e4c3a9e2ed1a6885fff1facbfd201ac013ef030422bec65ca9ece8ffe82b03ca580421463f9c45af6c8415fd629f4118c13
+  languageName: node
+  linkType: hard
+
+"browserify-des@npm:^1.0.0":
+  version: 1.0.2
+  resolution: "browserify-des@npm:1.0.2"
+  dependencies:
+    cipher-base: ^1.0.1
+    des.js: ^1.0.0
+    inherits: ^2.0.1
+    safe-buffer: ^5.1.2
+  checksum: b15a3e358a1d78a3b62ddc06c845d02afde6fc826dab23f1b9c016e643e7b1fda41de628d2110b712f6a44fb10cbc1800bc6872a03ddd363fb50768e010395b7
+  languageName: node
+  linkType: hard
+
+"browserify-rsa@npm:^4.0.0, browserify-rsa@npm:^4.1.0":
+  version: 4.1.0
+  resolution: "browserify-rsa@npm:4.1.0"
+  dependencies:
+    bn.js: ^5.0.0
+    randombytes: ^2.0.1
+  checksum: 155f0c135873efc85620571a33d884aa8810e40176125ad424ec9d85016ff105a07f6231650914a760cca66f29af0494087947b7be34880dd4599a0cd3c38e54
+  languageName: node
+  linkType: hard
+
+"browserify-sign@npm:^4.0.0":
+  version: 4.2.3
+  resolution: "browserify-sign@npm:4.2.3"
+  dependencies:
+    bn.js: ^5.2.1
+    browserify-rsa: ^4.1.0
+    create-hash: ^1.2.0
+    create-hmac: ^1.1.7
+    elliptic: ^6.5.5
+    hash-base: ~3.0
+    inherits: ^2.0.4
+    parse-asn1: ^5.1.7
+    readable-stream: ^2.3.8
+    safe-buffer: ^5.2.1
+  checksum: 403a8061d229ae31266670345b4a7c00051266761d2c9bbeb68b1a9bcb05f68143b16110cf23a171a5d6716396a1f41296282b3e73eeec0a1871c77f0ff4ee6b
+  languageName: node
+  linkType: hard
+
+"browserify-zlib@npm:^0.2.0":
+  version: 0.2.0
+  resolution: "browserify-zlib@npm:0.2.0"
+  dependencies:
+    pako: ~1.0.5
+  checksum: 5cd9d6a665190fedb4a97dfbad8dabc8698d8a507298a03f42c734e96d58ca35d3c7d4085e283440bbca1cd1938cff85031728079bedb3345310c58ab1ec92d6
+  languageName: node
+  linkType: hard
+
+"browserslist@npm:4.10.0":
+  version: 4.10.0
+  resolution: "browserslist@npm:4.10.0"
+  dependencies:
+    caniuse-lite: ^1.0.30001035
+    electron-to-chromium: ^1.3.378
+    node-releases: ^1.1.52
+    pkg-up: ^3.1.0
+  bin:
+    browserslist: cli.js
+  checksum: 35fdd9653656008a4f7a42026faa3e5ff3c5da83a39b7163675ae96985cbf8607beba55a877f2cf68f34cba7c8bb95418683664b663a051f094eb6d73dd4baf5
+  languageName: node
+  linkType: hard
+
+"browserslist@npm:^4.0.0, browserslist@npm:^4.12.0, browserslist@npm:^4.16.6, browserslist@npm:^4.6.2, browserslist@npm:^4.6.4, browserslist@npm:^4.9.1":
+  version: 4.22.1
+  resolution: "browserslist@npm:4.22.1"
+  dependencies:
+    caniuse-lite: ^1.0.30001541
+    electron-to-chromium: ^1.4.535
+    node-releases: ^2.0.13
+    update-browserslist-db: ^1.0.13
+  bin:
+    browserslist: cli.js
+  checksum: 7e6b10c53f7dd5d83fd2b95b00518889096382539fed6403829d447e05df4744088de46a571071afb447046abc3c66ad06fbc790e70234ec2517452e32ffd862
+  languageName: node
+  linkType: hard
+
+"browserslist@npm:^4.22.2":
+  version: 4.23.0
+  resolution: "browserslist@npm:4.23.0"
+  dependencies:
+    caniuse-lite: ^1.0.30001587
+    electron-to-chromium: ^1.4.668
+    node-releases: ^2.0.14
+    update-browserslist-db: ^1.0.13
+  bin:
+    browserslist: cli.js
+  checksum: 436f49e796782ca751ebab7edc010cfc9c29f68536f387666cd70ea22f7105563f04dd62c6ff89cb24cc3254d17cba385f979eeeb3484d43e012412ff7e75def
+  languageName: node
+  linkType: hard
+
+"bser@npm:2.1.1":
+  version: 2.1.1
+  resolution: "bser@npm:2.1.1"
+  dependencies:
+    node-int64: ^0.4.0
+  checksum: 9ba4dc58ce86300c862bffc3ae91f00b2a03b01ee07f3564beeeaf82aa243b8b03ba53f123b0b842c190d4399b94697970c8e7cf7b1ea44b61aa28c3526a4449
+  languageName: node
+  linkType: hard
+
+"buffer-crc32@npm:~0.2.3":
+  version: 0.2.13
+  resolution: "buffer-crc32@npm:0.2.13"
+  checksum: 06252347ae6daca3453b94e4b2f1d3754a3b146a111d81c68924c22d91889a40623264e95e67955b1cb4a68cbedf317abeabb5140a9766ed248973096db5ce1c
+  languageName: node
+  linkType: hard
+
+"buffer-from@npm:^1.0.0":
+  version: 1.1.1
+  resolution: "buffer-from@npm:1.1.1"
+  checksum: ccc53b69736008bff764497367c4d24879ba7122bc619ee499ff47eef3a5b885ca496e87272e7ebffa0bec3804c83f84041c616f6e3318f40624e27c1d80f045
+  languageName: node
+  linkType: hard
+
+"buffer-indexof@npm:^1.0.0":
+  version: 1.1.1
+  resolution: "buffer-indexof@npm:1.1.1"
+  checksum: 0967abc2981a8e7d776324c6b84811e4d84a7ead89b54a3bb8791437f0c4751afd060406b06db90a436f1cf771867331b5ecf5c4aca95b4ccb9f6cb146c22ebc
+  languageName: node
+  linkType: hard
+
+"buffer-xor@npm:^1.0.3":
+  version: 1.0.3
+  resolution: "buffer-xor@npm:1.0.3"
+  checksum: 10c520df29d62fa6e785e2800e586a20fc4f6dfad84bcdbd12e1e8a83856de1cb75c7ebd7abe6d036bbfab738a6cf18a3ae9c8e5a2e2eb3167ca7399ce65373a
+  languageName: node
+  linkType: hard
+
+"buffer@npm:^4.3.0":
+  version: 4.9.2
+  resolution: "buffer@npm:4.9.2"
+  dependencies:
+    base64-js: ^1.0.2
+    ieee754: ^1.1.4
+    isarray: ^1.0.0
+  checksum: 8801bc1ba08539f3be70eee307a8b9db3d40f6afbfd3cf623ab7ef41dffff1d0a31de0addbe1e66e0ca5f7193eeb667bfb1ecad3647f8f1b0750de07c13295c3
+  languageName: node
+  linkType: hard
+
+"buffer@npm:^5.7.1":
+  version: 5.7.1
+  resolution: "buffer@npm:5.7.1"
+  dependencies:
+    base64-js: ^1.3.1
+    ieee754: ^1.1.13
+  checksum: e2cf8429e1c4c7b8cbd30834ac09bd61da46ce35f5c22a78e6c2f04497d6d25541b16881e30a019c6fd3154150650ccee27a308eff3e26229d788bbdeb08ab84
+  languageName: node
+  linkType: hard
+
+"builtin-modules@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "builtin-modules@npm:1.1.1"
+  checksum: 0fbf69ffe77fecf11c441b9a7d1e664bb8119a7d3004831d9bd6ce0eacfd5d121ed4b667172870b5f66ecfce4bd54f7c20060d21c339c29049a7a5dd2bb7bf8c
+  languageName: node
+  linkType: hard
+
+"builtin-status-codes@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "builtin-status-codes@npm:3.0.0"
+  checksum: 1119429cf4b0d57bf76b248ad6f529167d343156ebbcc4d4e4ad600484f6bc63002595cbb61b67ad03ce55cd1d3c4711c03bbf198bf24653b8392420482f3773
+  languageName: node
+  linkType: hard
+
+"bytes@npm:3.0.0":
+  version: 3.0.0
+  resolution: "bytes@npm:3.0.0"
+  checksum: a2b386dd8188849a5325f58eef69c3b73c51801c08ffc6963eddc9be244089ba32d19347caf6d145c86f315ae1b1fc7061a32b0c1aa6379e6a719090287ed101
+  languageName: node
+  linkType: hard
+
+"bytes@npm:3.1.2":
+  version: 3.1.2
+  resolution: "bytes@npm:3.1.2"
+  checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e
+  languageName: node
+  linkType: hard
+
+"cacache@npm:^12.0.2":
+  version: 12.0.4
+  resolution: "cacache@npm:12.0.4"
+  dependencies:
+    bluebird: ^3.5.5
+    chownr: ^1.1.1
+    figgy-pudding: ^3.5.1
+    glob: ^7.1.4
+    graceful-fs: ^4.1.15
+    infer-owner: ^1.0.3
+    lru-cache: ^5.1.1
+    mississippi: ^3.0.0
+    mkdirp: ^0.5.1
+    move-concurrently: ^1.0.1
+    promise-inflight: ^1.0.1
+    rimraf: ^2.6.3
+    ssri: ^6.0.1
+    unique-filename: ^1.1.1
+    y18n: ^4.0.0
+  checksum: c88a72f36939b2523533946ffb27828443db5bf5995d761b35ae17af1eb6c8e20ac55b00b74c2ca900b2e1e917f0afba6847bf8cc16bee05ccca6aa150e0830c
+  languageName: node
+  linkType: hard
+
+"cacache@npm:^13.0.1":
+  version: 13.0.1
+  resolution: "cacache@npm:13.0.1"
+  dependencies:
+    chownr: ^1.1.2
+    figgy-pudding: ^3.5.1
+    fs-minipass: ^2.0.0
+    glob: ^7.1.4
+    graceful-fs: ^4.2.2
+    infer-owner: ^1.0.4
+    lru-cache: ^5.1.1
+    minipass: ^3.0.0
+    minipass-collect: ^1.0.2
+    minipass-flush: ^1.0.5
+    minipass-pipeline: ^1.2.2
+    mkdirp: ^0.5.1
+    move-concurrently: ^1.0.1
+    p-map: ^3.0.0
+    promise-inflight: ^1.0.1
+    rimraf: ^2.7.1
+    ssri: ^7.0.0
+    unique-filename: ^1.1.1
+  checksum: 733e65de5a0db3f1c181aa780f60ff121b5efd9b7c0851e1e1f213df768a790882d4d5af987fb0cfa70c5c6c4834e0474a291ac8872d227056f7ea12c1447092
+  languageName: node
+  linkType: hard
+
+"cacache@npm:^15.2.0, cacache@npm:^15.3.0":
+  version: 15.3.0
+  resolution: "cacache@npm:15.3.0"
+  dependencies:
+    "@npmcli/fs": ^1.0.0
+    "@npmcli/move-file": ^1.0.1
+    chownr: ^2.0.0
+    fs-minipass: ^2.0.0
+    glob: ^7.1.4
+    infer-owner: ^1.0.4
+    lru-cache: ^6.0.0
+    minipass: ^3.1.1
+    minipass-collect: ^1.0.2
+    minipass-flush: ^1.0.5
+    minipass-pipeline: ^1.2.2
+    mkdirp: ^1.0.3
+    p-map: ^4.0.0
+    promise-inflight: ^1.0.1
+    rimraf: ^3.0.2
+    ssri: ^8.0.1
+    tar: ^6.0.2
+    unique-filename: ^1.1.1
+  checksum: a07327c27a4152c04eb0a831c63c00390d90f94d51bb80624a66f4e14a6b6360bbf02a84421267bd4d00ca73ac9773287d8d7169e8d2eafe378d2ce140579db8
+  languageName: node
+  linkType: hard
+
+"cacache@npm:^16.1.0":
+  version: 16.1.3
+  resolution: "cacache@npm:16.1.3"
+  dependencies:
+    "@npmcli/fs": ^2.1.0
+    "@npmcli/move-file": ^2.0.0
+    chownr: ^2.0.0
+    fs-minipass: ^2.1.0
+    glob: ^8.0.1
+    infer-owner: ^1.0.4
+    lru-cache: ^7.7.1
+    minipass: ^3.1.6
+    minipass-collect: ^1.0.2
+    minipass-flush: ^1.0.5
+    minipass-pipeline: ^1.2.4
+    mkdirp: ^1.0.4
+    p-map: ^4.0.0
+    promise-inflight: ^1.0.1
+    rimraf: ^3.0.2
+    ssri: ^9.0.0
+    tar: ^6.1.11
+    unique-filename: ^2.0.0
+  checksum: d91409e6e57d7d9a3a25e5dcc589c84e75b178ae8ea7de05cbf6b783f77a5fae938f6e8fda6f5257ed70000be27a681e1e44829251bfffe4c10216002f8f14e6
+  languageName: node
+  linkType: hard
+
+"cache-base@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "cache-base@npm:1.0.1"
+  dependencies:
+    collection-visit: ^1.0.0
+    component-emitter: ^1.2.1
+    get-value: ^2.0.6
+    has-value: ^1.0.0
+    isobject: ^3.0.1
+    set-value: ^2.0.0
+    to-object-path: ^0.3.0
+    union-value: ^1.0.0
+    unset-value: ^1.0.0
+  checksum: 9114b8654fe2366eedc390bad0bcf534e2f01b239a888894e2928cb58cdc1e6ea23a73c6f3450dcfd2058aa73a8a981e723cd1e7c670c047bf11afdc65880107
+  languageName: node
+  linkType: hard
+
+"cachedir@npm:^2.3.0":
+  version: 2.3.0
+  resolution: "cachedir@npm:2.3.0"
+  checksum: ec90cb0f2e6336e266aa748dbadf3da9e0b20e843e43f1591acab7a3f1451337dc2f26cb9dd833ae8cfefeffeeb43ef5b5ff62782a685f4e3c2305dd98482fcb
+  languageName: node
+  linkType: hard
+
+"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "call-bind@npm:1.0.2"
+  dependencies:
+    function-bind: ^1.1.1
+    get-intrinsic: ^1.0.2
+  checksum: f8e31de9d19988a4b80f3e704788c4a2d6b6f3d17cfec4f57dc29ced450c53a49270dc66bf0fbd693329ee948dd33e6c90a329519aef17474a4d961e8d6426b0
+  languageName: node
+  linkType: hard
+
+"call-me-maybe@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "call-me-maybe@npm:1.0.1"
+  checksum: d19e9d6ac2c6a83fb1215718b64c5e233f688ebebb603bdfe4af59cde952df1f2b648530fab555bf290ea910d69d7d9665ebc916e871e0e194f47c2e48e4886b
+  languageName: node
+  linkType: hard
+
+"caller-callsite@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "caller-callsite@npm:2.0.0"
+  dependencies:
+    callsites: ^2.0.0
+  checksum: b685e9d126d9247b320cfdfeb3bc8da0c4be28d8fb98c471a96bc51aab3130099898a2fe3bf0308f0fe048d64c37d6d09f563958b9afce1a1e5e63d879c128a2
+  languageName: node
+  linkType: hard
+
+"caller-path@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "caller-path@npm:2.0.0"
+  dependencies:
+    caller-callsite: ^2.0.0
+  checksum: 3e12ccd0c71ec10a057aac69e3ec175b721ca858c640df021ef0d25999e22f7c1d864934b596b7d47038e9b56b7ec315add042abbd15caac882998b50102fb12
+  languageName: node
+  linkType: hard
+
+"callsites@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "callsites@npm:2.0.0"
+  checksum: be2f67b247df913732b7dec1ec0bbfcdbaea263e5a95968b19ec7965affae9496b970e3024317e6d4baa8e28dc6ba0cec03f46fdddc2fdcc51396600e53c2623
+  languageName: node
+  linkType: hard
+
+"callsites@npm:^3.0.0":
+  version: 3.1.0
+  resolution: "callsites@npm:3.1.0"
+  checksum: 072d17b6abb459c2ba96598918b55868af677154bec7e73d222ef95a8fdb9bbf7dae96a8421085cdad8cd190d86653b5b6dc55a4484f2e5b2e27d5e0c3fc15b3
+  languageName: node
+  linkType: hard
+
+"camel-case@npm:^4.1.1":
+  version: 4.1.2
+  resolution: "camel-case@npm:4.1.2"
+  dependencies:
+    pascal-case: ^3.1.2
+    tslib: ^2.0.3
+  checksum: bcbd25cd253b3cbc69be3f535750137dbf2beb70f093bdc575f73f800acc8443d34fd52ab8f0a2413c34f1e8203139ffc88428d8863e4dfe530cfb257a379ad6
+  languageName: node
+  linkType: hard
+
+"camelcase-keys@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "camelcase-keys@npm:2.1.0"
+  dependencies:
+    camelcase: ^2.0.0
+    map-obj: ^1.0.0
+  checksum: 97d2993da5db44d45e285910c70a54ce7f83a2be05afceaafd9831f7aeaf38a48dcdede5ca3aae2b2694852281d38dc459706e346942c5df0bf755f4133f5c39
+  languageName: node
+  linkType: hard
+
+"camelcase-keys@npm:^6.2.2":
+  version: 6.2.2
+  resolution: "camelcase-keys@npm:6.2.2"
+  dependencies:
+    camelcase: ^5.3.1
+    map-obj: ^4.0.0
+    quick-lru: ^4.0.1
+  checksum: 43c9af1adf840471e54c68ab3e5fe8a62719a6b7dbf4e2e86886b7b0ff96112c945736342b837bd2529ec9d1c7d1934e5653318478d98e0cf22c475c04658e2a
+  languageName: node
+  linkType: hard
+
+"camelcase@npm:5.3.1, camelcase@npm:^5.0.0, camelcase@npm:^5.3.1":
+  version: 5.3.1
+  resolution: "camelcase@npm:5.3.1"
+  checksum: e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b
+  languageName: node
+  linkType: hard
+
+"camelcase@npm:^2.0.0":
+  version: 2.1.1
+  resolution: "camelcase@npm:2.1.1"
+  checksum: 20a3ef08f348de832631d605362ffe447d883ada89617144a82649363ed5860923b021f8e09681624ef774afb93ff3597cfbcf8aaf0574f65af7648f1aea5e50
+  languageName: node
+  linkType: hard
+
+"camelcase@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "camelcase@npm:3.0.0"
+  checksum: ae4fe1c17c8442a3a345a6b7d2393f028ab7a7601af0c352ad15d1ab97ca75112e19e29c942b2a214898e160194829b68923bce30e018d62149c6d84187f1673
+  languageName: node
+  linkType: hard
+
+"camelize@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "camelize@npm:1.0.1"
+  checksum: 91d8611d09af725e422a23993890d22b2b72b4cabf7239651856950c76b4bf53fe0d0da7c5e4db05180e898e4e647220e78c9fbc976113bd96d603d1fcbfcb99
+  languageName: node
+  linkType: hard
+
+"caniuse-api@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "caniuse-api@npm:3.0.0"
+  dependencies:
+    browserslist: ^4.0.0
+    caniuse-lite: ^1.0.0
+    lodash.memoize: ^4.1.2
+    lodash.uniq: ^4.5.0
+  checksum: db2a229383b20d0529b6b589dde99d7b6cb56ba371366f58cbbfa2929c9f42c01f873e2b6ef641d4eda9f0b4118de77dbb2805814670bdad4234bf08e720b0b4
+  languageName: node
+  linkType: hard
+
+"caniuse-lite@npm:1.0.30001606, caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001541, caniuse-lite@npm:^1.0.30001587":
+  version: 1.0.30001606
+  resolution: "caniuse-lite@npm:1.0.30001606"
+  checksum: fcf2d799d8cb159f4f5b44cd9d2171b18df4bcfdf2770cc8a79c4bb0bc5fd19ed089854223865ced32eacffb60a0a9257c8a1d0ef239e9dc3909f587727e9bb5
+  languageName: node
+  linkType: hard
+
+"capture-exit@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "capture-exit@npm:2.0.0"
+  dependencies:
+    rsvp: ^4.8.4
+  checksum: 0b9f10daca09e521da9599f34c8e7af14ad879c336e2bdeb19955b375398ae1c5bcc91ac9f2429944343057ee9ed028b1b2fb28816c384e0e55d70c439b226f4
+  languageName: node
+  linkType: hard
+
+"case-sensitive-paths-webpack-plugin@npm:2.3.0":
+  version: 2.3.0
+  resolution: "case-sensitive-paths-webpack-plugin@npm:2.3.0"
+  checksum: 2fa78f7a495d7e73e66d1f528eac5abde65df797c9487624eeae9815a409ba6d584d8fbfe8b6c89157292fbb08d0ee6cc3418fe7f8c75b83fb2c8e29c30f205d
+  languageName: node
+  linkType: hard
+
+"caseless@npm:~0.12.0":
+  version: 0.12.0
+  resolution: "caseless@npm:0.12.0"
+  checksum: b43bd4c440aa1e8ee6baefee8063b4850fd0d7b378f6aabc796c9ec8cb26d27fb30b46885350777d9bd079c5256c0e1329ad0dc7c2817e0bb466810ebb353751
+  languageName: node
+  linkType: hard
+
+"chalk@npm:2.4.2, chalk@npm:^2.0.0, chalk@npm:^2.0.1, chalk@npm:^2.1.0, chalk@npm:^2.3.0, chalk@npm:^2.4.1, chalk@npm:^2.4.2":
+  version: 2.4.2
+  resolution: "chalk@npm:2.4.2"
+  dependencies:
+    ansi-styles: ^3.2.1
+    escape-string-regexp: ^1.0.5
+    supports-color: ^5.3.0
+  checksum: ec3661d38fe77f681200f878edbd9448821924e0f93a9cefc0e26a33b145f1027a2084bf19967160d11e1f03bfe4eaffcabf5493b89098b2782c3fe0b03d80c2
+  languageName: node
+  linkType: hard
+
+"chalk@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "chalk@npm:1.1.3"
+  dependencies:
+    ansi-styles: ^2.2.1
+    escape-string-regexp: ^1.0.2
+    has-ansi: ^2.0.0
+    strip-ansi: ^3.0.0
+    supports-color: ^2.0.0
+  checksum: 9d2ea6b98fc2b7878829eec223abcf404622db6c48396a9b9257f6d0ead2acf18231ae368d6a664a83f272b0679158da12e97b5229f794939e555cc574478acd
+  languageName: node
+  linkType: hard
+
+"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2":
+  version: 4.1.2
+  resolution: "chalk@npm:4.1.2"
+  dependencies:
+    ansi-styles: ^4.1.0
+    supports-color: ^7.1.0
+  checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc
+  languageName: node
+  linkType: hard
+
+"change-emitter@npm:^0.1.2":
+  version: 0.1.6
+  resolution: "change-emitter@npm:0.1.6"
+  checksum: 0ed494ba9901ca56ea6f942668fd294465c334a9a0981dca96da5aea5e387c0023a630d7c658c1b532d203db54c928ddca2564e434b4a8b7f6d39155d09db255
+  languageName: node
+  linkType: hard
+
+"chardet@npm:^0.7.0":
+  version: 0.7.0
+  resolution: "chardet@npm:0.7.0"
+  checksum: 6fd5da1f5d18ff5712c1e0aed41da200d7c51c28f11b36ee3c7b483f3696dabc08927fc6b227735eb8f0e1215c9a8abd8154637f3eff8cada5959df7f58b024d
+  languageName: node
+  linkType: hard
+
+"check-more-types@npm:^2.24.0":
+  version: 2.24.0
+  resolution: "check-more-types@npm:2.24.0"
+  checksum: b09080ec3404d20a4b0ead828994b2e5913236ef44ed3033a27062af0004cf7d2091fbde4b396bf13b7ce02fb018bc9960b48305e6ab2304cd82d73ed7a51ef4
+  languageName: node
+  linkType: hard
+
+"cheerio-select@npm:^1.5.0":
+  version: 1.5.0
+  resolution: "cheerio-select@npm:1.5.0"
+  dependencies:
+    css-select: ^4.1.3
+    css-what: ^5.0.1
+    domelementtype: ^2.2.0
+    domhandler: ^4.2.0
+    domutils: ^2.7.0
+  checksum: d4506d8b9ad330a18f9de3a5a22138d0804063e92aac2fc020384cc52ab86d2194d2ae614fc87f0e2a62b6a6dd0c28ad23669cec64331172a9f99ad604863010
+  languageName: node
+  linkType: hard
+
+"cheerio@npm:^1.0.0-rc.3":
+  version: 1.0.0-rc.10
+  resolution: "cheerio@npm:1.0.0-rc.10"
+  dependencies:
+    cheerio-select: ^1.5.0
+    dom-serializer: ^1.3.2
+    domhandler: ^4.2.0
+    htmlparser2: ^6.1.0
+    parse5: ^6.0.1
+    parse5-htmlparser2-tree-adapter: ^6.0.1
+    tslib: ^2.2.0
+  checksum: ace2f9c5809737534b1320d11d48762013694fa905b4deacac81a634edac178c1b0534f79d7b1896a88ce489db6cb539f222317996b21c8b6923ce413dcc1a2f
+  languageName: node
+  linkType: hard
+
+"chokidar@npm:^2.1.8":
+  version: 2.1.8
+  resolution: "chokidar@npm:2.1.8"
+  dependencies:
+    anymatch: ^2.0.0
+    async-each: ^1.0.1
+    braces: ^2.3.2
+    fsevents: ^1.2.7
+    glob-parent: ^3.1.0
+    inherits: ^2.0.3
+    is-binary-path: ^1.0.0
+    is-glob: ^4.0.0
+    normalize-path: ^3.0.0
+    path-is-absolute: ^1.0.0
+    readdirp: ^2.2.1
+    upath: ^1.1.1
+  dependenciesMeta:
+    fsevents:
+      optional: true
+  checksum: 0c43e89cbf0268ef1e1f41ce8ec5233c7ba022c6f3282c2ef6530e351d42396d389a1148c5a040f291cf1f4083a4c6b2f51dad3f31c726442ea9a337de316bcf
+  languageName: node
+  linkType: hard
+
+"chokidar@npm:^3.3.0, chokidar@npm:^3.4.0, chokidar@npm:^3.4.1":
+  version: 3.5.3
+  resolution: "chokidar@npm:3.5.3"
+  dependencies:
+    anymatch: ~3.1.2
+    braces: ~3.0.2
+    fsevents: ~2.3.2
+    glob-parent: ~5.1.2
+    is-binary-path: ~2.1.0
+    is-glob: ~4.0.1
+    normalize-path: ~3.0.0
+    readdirp: ~3.6.0
+  dependenciesMeta:
+    fsevents:
+      optional: true
+  checksum: b49fcde40176ba007ff361b198a2d35df60d9bb2a5aab228279eb810feae9294a6b4649ab15981304447afe1e6ffbf4788ad5db77235dc770ab777c6e771980c
+  languageName: node
+  linkType: hard
+
+"chownr@npm:^1.1.1, chownr@npm:^1.1.2":
+  version: 1.1.4
+  resolution: "chownr@npm:1.1.4"
+  checksum: 115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d
+  languageName: node
+  linkType: hard
+
+"chownr@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "chownr@npm:2.0.0"
+  checksum: c57cf9dd0791e2f18a5ee9c1a299ae6e801ff58fee96dc8bfd0dcb4738a6ce58dd252a3605b1c93c6418fe4f9d5093b28ffbf4d66648cb2a9c67eaef9679be2f
+  languageName: node
+  linkType: hard
+
+"chrome-trace-event@npm:^1.0.2":
+  version: 1.0.3
+  resolution: "chrome-trace-event@npm:1.0.3"
+  checksum: cb8b1fc7e881aaef973bd0c4a43cd353c2ad8323fb471a041e64f7c2dd849cde4aad15f8b753331a32dda45c973f032c8a03b8177fc85d60eaa75e91e08bfb97
+  languageName: node
+  linkType: hard
+
+"ci-info@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "ci-info@npm:2.0.0"
+  checksum: 3b374666a85ea3ca43fa49aa3a048d21c9b475c96eb13c133505d2324e7ae5efd6a454f41efe46a152269e9b6a00c9edbe63ec7fa1921957165aae16625acd67
+  languageName: node
+  linkType: hard
+
+"ci-info@npm:^3.2.0":
+  version: 3.9.0
+  resolution: "ci-info@npm:3.9.0"
+  checksum: 6b19dc9b2966d1f8c2041a838217299718f15d6c4b63ae36e4674edd2bee48f780e94761286a56aa59eb305a85fbea4ddffb7630ec063e7ec7e7e5ad42549a87
+  languageName: node
+  linkType: hard
+
+"cipher-base@npm:^1.0.0, cipher-base@npm:^1.0.1, cipher-base@npm:^1.0.3":
+  version: 1.0.4
+  resolution: "cipher-base@npm:1.0.4"
+  dependencies:
+    inherits: ^2.0.1
+    safe-buffer: ^5.0.1
+  checksum: 47d3568dbc17431a339bad1fe7dff83ac0891be8206911ace3d3b818fc695f376df809bea406e759cdea07fff4b454fa25f1013e648851bec790c1d75763032e
+  languageName: node
+  linkType: hard
+
+"class-autobind@npm:^0.1.4":
+  version: 0.1.4
+  resolution: "class-autobind@npm:0.1.4"
+  checksum: a45d1caebab8e30d5e2e21080d2d1e6f42fb51fe1b3a08f5dcd706254afd2c22792dcb12ddf73a98e5d0e63856a9ff82b55e435a591beb75f2c79d444fa93ce7
+  languageName: node
+  linkType: hard
+
+"class-utils@npm:^0.3.5":
+  version: 0.3.6
+  resolution: "class-utils@npm:0.3.6"
+  dependencies:
+    arr-union: ^3.1.0
+    define-property: ^0.2.5
+    isobject: ^3.0.0
+    static-extend: ^0.1.1
+  checksum: be108900801e639e50f96a7e4bfa8867c753a7750a7603879f3981f8b0a89cba657497a2d5f40cd4ea557ff15d535a100818bb486baf6e26fe5d7872e75f1078
+  languageName: node
+  linkType: hard
+
+"classnames@npm:2.2.6":
+  version: 2.2.6
+  resolution: "classnames@npm:2.2.6"
+  checksum: 09a4fda780158aa8399079898eabeeca0c48c28641d9e4de140db7412e5e346843039ded1af0152f755afc2cc246ff8c3d6f227bf0dcb004e070b7fa14ec54cc
+  languageName: node
+  linkType: hard
+
+"classnames@npm:^2.2.5":
+  version: 2.3.1
+  resolution: "classnames@npm:2.3.1"
+  checksum: 14db8889d56c267a591f08b0834989fe542d47fac659af5a539e110cc4266694e8de86e4e3bbd271157dbd831361310a8293e0167141e80b0f03a0f175c80960
+  languageName: node
+  linkType: hard
+
+"clean-css@npm:^4.2.3":
+  version: 4.2.3
+  resolution: "clean-css@npm:4.2.3"
+  dependencies:
+    source-map: ~0.6.0
+  checksum: 613129973a038b8bb13e3975ad6b679feccb8c98f2a9d03e6bec9e60291ef1e6b5037ee8cb09a3470751adc52f43782b1dcb4cb049360230b48062d6e3314072
+  languageName: node
+  linkType: hard
+
+"clean-stack@npm:^2.0.0":
+  version: 2.2.0
+  resolution: "clean-stack@npm:2.2.0"
+  checksum: 2ac8cd2b2f5ec986a3c743935ec85b07bc174d5421a5efc8017e1f146a1cf5f781ae962618f416352103b32c9cd7e203276e8c28241bbe946160cab16149fb68
+  languageName: node
+  linkType: hard
+
+"cli-cursor@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "cli-cursor@npm:3.1.0"
+  dependencies:
+    restore-cursor: ^3.1.0
+  checksum: 2692784c6cd2fd85cfdbd11f53aea73a463a6d64a77c3e098b2b4697a20443f430c220629e1ca3b195ea5ac4a97a74c2ee411f3807abf6df2b66211fec0c0a29
+  languageName: node
+  linkType: hard
+
+"cli-table3@npm:~0.6.1":
+  version: 0.6.3
+  resolution: "cli-table3@npm:0.6.3"
+  dependencies:
+    "@colors/colors": 1.5.0
+    string-width: ^4.2.0
+  dependenciesMeta:
+    "@colors/colors":
+      optional: true
+  checksum: 09897f68467973f827c04e7eaadf13b55f8aec49ecd6647cc276386ea660059322e2dd8020a8b6b84d422dbdd619597046fa89cbbbdc95b2cea149a2df7c096c
+  languageName: node
+  linkType: hard
+
+"cli-truncate@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "cli-truncate@npm:2.1.0"
+  dependencies:
+    slice-ansi: ^3.0.0
+    string-width: ^4.2.0
+  checksum: bf1e4e6195392dc718bf9cd71f317b6300dc4a9191d052f31046b8773230ece4fa09458813bf0e3455a5e68c0690d2ea2c197d14a8b85a7b5e01c97f4b5feb5d
+  languageName: node
+  linkType: hard
+
+"cli-width@npm:^2.0.0":
+  version: 2.2.1
+  resolution: "cli-width@npm:2.2.1"
+  checksum: 3c21b897a2ff551ae5b3c3ab32c866ed2965dcf7fb442f81adf0e27f4a397925c8f84619af7bcc6354821303f6ee9b2aa31d248306174f32c287986158cf4eed
+  languageName: node
+  linkType: hard
+
+"cli-width@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "cli-width@npm:3.0.0"
+  checksum: 4c94af3769367a70e11ed69aa6095f1c600c0ff510f3921ab4045af961820d57c0233acfa8b6396037391f31b4c397e1f614d234294f979ff61430a6c166c3f6
+  languageName: node
+  linkType: hard
+
+"cliui@npm:^3.2.0":
+  version: 3.2.0
+  resolution: "cliui@npm:3.2.0"
+  dependencies:
+    string-width: ^1.0.1
+    strip-ansi: ^3.0.1
+    wrap-ansi: ^2.0.0
+  checksum: c68d1dbc3e347bfe79ed19cc7f48007d5edd6cd8438342e32073e0b4e311e3c44e1f4f19221462bc6590de56c2df520e427533a9dde95dee25710bec322746ad
+  languageName: node
+  linkType: hard
+
+"cliui@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "cliui@npm:5.0.0"
+  dependencies:
+    string-width: ^3.1.0
+    strip-ansi: ^5.2.0
+    wrap-ansi: ^5.1.0
+  checksum: 0bb8779efe299b8f3002a73619eaa8add4081eb8d1c17bc4fedc6240557fb4eacdc08fe87c39b002eacb6cfc117ce736b362dbfd8bf28d90da800e010ee97df4
+  languageName: node
+  linkType: hard
+
+"cliui@npm:^7.0.2":
+  version: 7.0.4
+  resolution: "cliui@npm:7.0.4"
+  dependencies:
+    string-width: ^4.2.0
+    strip-ansi: ^6.0.0
+    wrap-ansi: ^7.0.0
+  checksum: ce2e8f578a4813806788ac399b9e866297740eecd4ad1823c27fd344d78b22c5f8597d548adbcc46f0573e43e21e751f39446c5a5e804a12aace402b7a315d7f
+  languageName: node
+  linkType: hard
+
+"cliui@npm:^8.0.1":
+  version: 8.0.1
+  resolution: "cliui@npm:8.0.1"
+  dependencies:
+    string-width: ^4.2.0
+    strip-ansi: ^6.0.1
+    wrap-ansi: ^7.0.0
+  checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56
+  languageName: node
+  linkType: hard
+
+"clone-deep@npm:^0.2.4":
+  version: 0.2.4
+  resolution: "clone-deep@npm:0.2.4"
+  dependencies:
+    for-own: ^0.1.3
+    is-plain-object: ^2.0.1
+    kind-of: ^3.0.2
+    lazy-cache: ^1.0.3
+    shallow-clone: ^0.1.2
+  checksum: bcf9752052130c270c47d3e1c357497354b91d682f507e0079bec5950975b3293b619d9e100d70874606d716f2376e84956b045759a09af703e1038ecad6c438
+  languageName: node
+  linkType: hard
+
+"clone-deep@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "clone-deep@npm:4.0.1"
+  dependencies:
+    is-plain-object: ^2.0.4
+    kind-of: ^6.0.2
+    shallow-clone: ^3.0.0
+  checksum: 770f912fe4e6f21873c8e8fbb1e99134db3b93da32df271d00589ea4a29dbe83a9808a322c93f3bcaf8584b8b4fa6fc269fc8032efbaa6728e0c9886c74467d2
+  languageName: node
+  linkType: hard
+
+"clsx@npm:^1.0.2":
+  version: 1.1.1
+  resolution: "clsx@npm:1.1.1"
+  checksum: ff052650329773b9b245177305fc4c4dc3129f7b2be84af4f58dc5defa99538c61d4207be7419405a5f8f3d92007c954f4daba5a7b74e563d5de71c28c830063
+  languageName: node
+  linkType: hard
+
+"co@npm:^4.6.0":
+  version: 4.6.0
+  resolution: "co@npm:4.6.0"
+  checksum: 5210d9223010eb95b29df06a91116f2cf7c8e0748a9013ed853b53f362ea0e822f1e5bb054fb3cefc645239a4cf966af1f6133a3b43f40d591f3b68ed6cf0510
+  languageName: node
+  linkType: hard
+
+"coa@npm:^2.0.2":
+  version: 2.0.2
+  resolution: "coa@npm:2.0.2"
+  dependencies:
+    "@types/q": ^1.5.1
+    chalk: ^2.4.1
+    q: ^1.1.2
+  checksum: 44736914aac2160d3d840ed64432a90a3bb72285a0cd6a688eb5cabdf15d15a85eee0915b3f6f2a4659d5075817b1cb577340d3c9cbb47d636d59ab69f819552
+  languageName: node
+  linkType: hard
+
+"code-point-at@npm:^1.0.0":
+  version: 1.1.0
+  resolution: "code-point-at@npm:1.1.0"
+  checksum: 17d5666611f9b16d64fdf48176d9b7fb1c7d1c1607a189f7e600040a11a6616982876af148230336adb7d8fe728a559f743a4e29db3747e3b1a32fa7f4529681
+  languageName: node
+  linkType: hard
+
+"collection-visit@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "collection-visit@npm:1.0.0"
+  dependencies:
+    map-visit: ^1.0.0
+    object-visit: ^1.0.0
+  checksum: 15d9658fe6eb23594728346adad5433b86bb7a04fd51bbab337755158722f9313a5376ef479de5b35fbc54140764d0d39de89c339f5d25b959ed221466981da9
+  languageName: node
+  linkType: hard
+
+"color-convert@npm:^1.9.0, color-convert@npm:^1.9.1":
+  version: 1.9.3
+  resolution: "color-convert@npm:1.9.3"
+  dependencies:
+    color-name: 1.1.3
+  checksum: fd7a64a17cde98fb923b1dd05c5f2e6f7aefda1b60d67e8d449f9328b4e53b228a428fd38bfeaeb2db2ff6b6503a776a996150b80cdf224062af08a5c8a3a203
+  languageName: node
+  linkType: hard
+
+"color-convert@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "color-convert@npm:2.0.1"
+  dependencies:
+    color-name: ~1.1.4
+  checksum: 79e6bdb9fd479a205c71d89574fccfb22bd9053bd98c6c4d870d65c132e5e904e6034978e55b43d69fcaa7433af2016ee203ce76eeba9cfa554b373e7f7db336
+  languageName: node
+  linkType: hard
+
+"color-name@npm:1.1.3":
+  version: 1.1.3
+  resolution: "color-name@npm:1.1.3"
+  checksum: 09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d
+  languageName: node
+  linkType: hard
+
+"color-name@npm:^1.0.0, color-name@npm:~1.1.4":
+  version: 1.1.4
+  resolution: "color-name@npm:1.1.4"
+  checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610
+  languageName: node
+  linkType: hard
+
+"color-string@npm:^1.5.4":
+  version: 1.5.5
+  resolution: "color-string@npm:1.5.5"
+  dependencies:
+    color-name: ^1.0.0
+    simple-swizzle: ^0.2.2
+  checksum: 4f19c2042c8953973a3c71a53e53da9fa54194cc1e0270bdbe431b14476b3faed054eb1c960910a8c2b631e7c67daccf79f8579eaa2d16dc99c3739c66f98ab1
+  languageName: node
+  linkType: hard
+
+"color-support@npm:^1.1.2, color-support@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "color-support@npm:1.1.3"
+  bin:
+    color-support: bin.js
+  checksum: 9b7356817670b9a13a26ca5af1c21615463b500783b739b7634a0c2047c16cef4b2865d7576875c31c3cddf9dd621fa19285e628f20198b233a5cfdda6d0793b
+  languageName: node
+  linkType: hard
+
+"color@npm:^3.0.0":
+  version: 3.1.3
+  resolution: "color@npm:3.1.3"
+  dependencies:
+    color-convert: ^1.9.1
+    color-string: ^1.5.4
+  checksum: d52a77ae239e1cdb55d9920e73d730df69a05cec9cb5d9b83a3e311b23009fd4053f4a88e7f6152207db498838f10e3ba4b1661a64a3acb41a50b14944214f26
+  languageName: node
+  linkType: hard
+
+"colorette@npm:^1.2.1":
+  version: 1.2.2
+  resolution: "colorette@npm:1.2.2"
+  checksum: 69fec14ddaedd0f5b00e4bae40dc4bc61f7050ebdc82983a595d6fd64e650b9dc3c033fff378775683138e992e0ddd8717ac7c7cec4d089679dcfbe3cd921b04
+  languageName: node
+  linkType: hard
+
+"colorette@npm:^2.0.16":
+  version: 2.0.20
+  resolution: "colorette@npm:2.0.20"
+  checksum: 0c016fea2b91b733eb9f4bcdb580018f52c0bc0979443dad930e5037a968237ac53d9beb98e218d2e9235834f8eebce7f8e080422d6194e957454255bde71d3d
+  languageName: node
+  linkType: hard
+
+"combined-stream@npm:^1.0.6, combined-stream@npm:^1.0.8, combined-stream@npm:~1.0.6":
+  version: 1.0.8
+  resolution: "combined-stream@npm:1.0.8"
+  dependencies:
+    delayed-stream: ~1.0.0
+  checksum: 49fa4aeb4916567e33ea81d088f6584749fc90c7abec76fd516bf1c5aa5c79f3584b5ba3de6b86d26ddd64bae5329c4c7479343250cfe71c75bb366eae53bb7c
+  languageName: node
+  linkType: hard
+
+"commander@npm:^2.11.0, commander@npm:^2.12.1, commander@npm:^2.19.0, commander@npm:^2.20.0":
+  version: 2.20.3
+  resolution: "commander@npm:2.20.3"
+  checksum: ab8c07884e42c3a8dbc5dd9592c606176c7eb5c1ca5ff274bcf907039b2c41de3626f684ea75ccf4d361ba004bbaff1f577d5384c155f3871e456bdf27becf9e
+  languageName: node
+  linkType: hard
+
+"commander@npm:^4.1.1":
+  version: 4.1.1
+  resolution: "commander@npm:4.1.1"
+  checksum: d7b9913ff92cae20cb577a4ac6fcc121bd6223319e54a40f51a14740a681ad5c574fd29a57da478a5f234a6fa6c52cbf0b7c641353e03c648b1ae85ba670b977
+  languageName: node
+  linkType: hard
+
+"commander@npm:^6.2.1":
+  version: 6.2.1
+  resolution: "commander@npm:6.2.1"
+  checksum: d7090410c0de6bc5c67d3ca41c41760d6d268f3c799e530aafb73b7437d1826bbf0d2a3edac33f8b57cc9887b4a986dce307fa5557e109be40eadb7c43b21742
+  languageName: node
+  linkType: hard
+
+"common-tags@npm:^1.8.0":
+  version: 1.8.0
+  resolution: "common-tags@npm:1.8.0"
+  checksum: fb0cc9420d149176f2bd2b1fc9e6df622cd34eccaca60b276aa3253a7c9241e8a8ed1ec0702b2679eba7e47aeef721869c686bbd7257b75b5c44993c8462cd7f
+  languageName: node
+  linkType: hard
+
+"commondir@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "commondir@npm:1.0.1"
+  checksum: 59715f2fc456a73f68826285718503340b9f0dd89bfffc42749906c5cf3d4277ef11ef1cca0350d0e79204f00f1f6d83851ececc9095dc88512a697ac0b9bdcb
+  languageName: node
+  linkType: hard
+
+"component-emitter@npm:^1.2.1":
+  version: 1.3.0
+  resolution: "component-emitter@npm:1.3.0"
+  checksum: b3c46de38ffd35c57d1c02488355be9f218e582aec72d72d1b8bbec95a3ac1b38c96cd6e03ff015577e68f550fbb361a3bfdbd9bb248be9390b7b3745691be6b
+  languageName: node
+  linkType: hard
+
+"compose-function@npm:3.0.3":
+  version: 3.0.3
+  resolution: "compose-function@npm:3.0.3"
+  dependencies:
+    arity-n: ^1.0.4
+  checksum: 9f17d431e3ee4797c844f2870e13494079882ac3dbc54c143b7d99967b371908e0ce7ceb71c6aed61e2ecddbcd7bb437d91428a3d0e6569aee17a87fcbc7918f
+  languageName: node
+  linkType: hard
+
+"compressible@npm:~2.0.16":
+  version: 2.0.18
+  resolution: "compressible@npm:2.0.18"
+  dependencies:
+    mime-db: ">= 1.43.0 < 2"
+  checksum: 58321a85b375d39230405654721353f709d0c1442129e9a17081771b816302a012471a9b8f4864c7dbe02eef7f2aaac3c614795197092262e94b409c9be108f0
+  languageName: node
+  linkType: hard
+
+"compression@npm:^1.7.4":
+  version: 1.7.4
+  resolution: "compression@npm:1.7.4"
+  dependencies:
+    accepts: ~1.3.5
+    bytes: 3.0.0
+    compressible: ~2.0.16
+    debug: 2.6.9
+    on-headers: ~1.0.2
+    safe-buffer: 5.1.2
+    vary: ~1.1.2
+  checksum: 35c0f2eb1f28418978615dc1bc02075b34b1568f7f56c62d60f4214d4b7cc00d0f6d282b5f8a954f59872396bd770b6b15ffd8aa94c67d4bce9b8887b906999b
+  languageName: node
+  linkType: hard
+
+"concat-map@npm:0.0.1":
+  version: 0.0.1
+  resolution: "concat-map@npm:0.0.1"
+  checksum: 902a9f5d8967a3e2faf138d5cb784b9979bad2e6db5357c5b21c568df4ebe62bcb15108af1b2253744844eb964fc023fbd9afbbbb6ddd0bcc204c6fb5b7bf3af
+  languageName: node
+  linkType: hard
+
+"concat-stream@npm:^1.5.0":
+  version: 1.6.2
+  resolution: "concat-stream@npm:1.6.2"
+  dependencies:
+    buffer-from: ^1.0.0
+    inherits: ^2.0.3
+    readable-stream: ^2.2.2
+    typedarray: ^0.0.6
+  checksum: 1ef77032cb4459dcd5187bd710d6fc962b067b64ec6a505810de3d2b8cc0605638551b42f8ec91edf6fcd26141b32ef19ad749239b58fae3aba99187adc32285
+  languageName: node
+  linkType: hard
+
+"confusing-browser-globals@npm:^1.0.9":
+  version: 1.0.10
+  resolution: "confusing-browser-globals@npm:1.0.10"
+  checksum: 7ccdc44c2ca419cf6576c3e4336106e18d1c5337f547e461342f51aec4a10f96fdfe45414b522be3c7d24ea0b62bf4372cd37768022e4d6161707ffb2c0987e6
+  languageName: node
+  linkType: hard
+
+"connect-history-api-fallback@npm:^1.6.0":
+  version: 1.6.0
+  resolution: "connect-history-api-fallback@npm:1.6.0"
+  checksum: 804ca2be28c999032ecd37a9f71405e5d7b7a4b3defcebbe41077bb8c5a0a150d7b59f51dcc33b2de30bc7e217a31d10f8cfad27e8e74c2fc7655eeba82d6e7e
+  languageName: node
+  linkType: hard
+
+"console-browserify@npm:^1.1.0":
+  version: 1.2.0
+  resolution: "console-browserify@npm:1.2.0"
+  checksum: 226591eeff8ed68e451dffb924c1fb750c654d54b9059b3b261d360f369d1f8f70650adecf2c7136656236a4bfeb55c39281b5d8a55d792ebbb99efd3d848d52
+  languageName: node
+  linkType: hard
+
+"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "console-control-strings@npm:1.1.0"
+  checksum: 8755d76787f94e6cf79ce4666f0c5519906d7f5b02d4b884cf41e11dcd759ed69c57da0670afd9236d229a46e0f9cf519db0cd829c6dca820bb5a5c3def584ed
+  languageName: node
+  linkType: hard
+
+"constants-browserify@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "constants-browserify@npm:1.0.0"
+  checksum: f7ac8c6d0b6e4e0c77340a1d47a3574e25abd580bfd99ad707b26ff7618596cf1a5e5ce9caf44715e9e01d4a5d12cb3b4edaf1176f34c19adb2874815a56e64f
+  languageName: node
+  linkType: hard
+
+"contains-path@npm:^0.1.0":
+  version: 0.1.0
+  resolution: "contains-path@npm:0.1.0"
+  checksum: 94ecfd944e0bc51be8d3fc596dcd17d705bd4c8a1a627952a3a8c5924bac01c7ea19034cf40b4b4f89e576cdead130a7e5fd38f5f7f07ef67b4b261d875871e3
+  languageName: node
+  linkType: hard
+
+"content-disposition@npm:0.5.4":
+  version: 0.5.4
+  resolution: "content-disposition@npm:0.5.4"
+  dependencies:
+    safe-buffer: 5.2.1
+  checksum: afb9d545e296a5171d7574fcad634b2fdf698875f4006a9dd04a3e1333880c5c0c98d47b560d01216fb6505a54a2ba6a843ee3a02ec86d7e911e8315255f56c3
+  languageName: node
+  linkType: hard
+
+"content-type@npm:~1.0.4":
+  version: 1.0.4
+  resolution: "content-type@npm:1.0.4"
+  checksum: 3d93585fda985d1554eca5ebd251994327608d2e200978fdbfba21c0c679914d5faf266d17027de44b34a72c7b0745b18584ecccaa7e1fdfb6a68ac7114f12e0
+  languageName: node
+  linkType: hard
+
+"content-type@npm:~1.0.5":
+  version: 1.0.5
+  resolution: "content-type@npm:1.0.5"
+  checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766
+  languageName: node
+  linkType: hard
+
+"convert-source-map@npm:1.7.0":
+  version: 1.7.0
+  resolution: "convert-source-map@npm:1.7.0"
+  dependencies:
+    safe-buffer: ~5.1.1
+  checksum: bcd2e3ea7d37f96b85a6e362c8a89402ccc73757256e3ee53aa2c22fe915adb854c66b1f81111be815a3a6a6ce3c58e8001858e883c9d5b4fe08a853fa865967
+  languageName: node
+  linkType: hard
+
+"convert-source-map@npm:^0.3.3":
+  version: 0.3.5
+  resolution: "convert-source-map@npm:0.3.5"
+  checksum: 33b209aa8f33bcaa9a22f2dbf6bfb71f4a429d8e948068d61b6087304e3194c30016d1e02e842184e653b74442c7e2dd2e7db97532b67f556aded3d8b4377a2c
+  languageName: node
+  linkType: hard
+
+"convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.7.0":
+  version: 1.8.0
+  resolution: "convert-source-map@npm:1.8.0"
+  dependencies:
+    safe-buffer: ~5.1.1
+  checksum: 985d974a2d33e1a2543ada51c93e1ba2f73eaed608dc39f229afc78f71dcc4c8b7d7c684aa647e3c6a3a204027444d69e53e169ce94e8d1fa8d7dee80c9c8fed
+  languageName: node
+  linkType: hard
+
+"convert-source-map@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "convert-source-map@npm:2.0.0"
+  checksum: 63ae9933be5a2b8d4509daca5124e20c14d023c820258e484e32dc324d34c2754e71297c94a05784064ad27615037ef677e3f0c00469fb55f409d2bb21261035
+  languageName: node
+  linkType: hard
+
+"cookie-signature@npm:1.0.6":
+  version: 1.0.6
+  resolution: "cookie-signature@npm:1.0.6"
+  checksum: f4e1b0a98a27a0e6e66fd7ea4e4e9d8e038f624058371bf4499cfcd8f3980be9a121486995202ba3fca74fbed93a407d6d54d43a43f96fd28d0bd7a06761591a
+  languageName: node
+  linkType: hard
+
+"cookie@npm:0.6.0":
+  version: 0.6.0
+  resolution: "cookie@npm:0.6.0"
+  checksum: f56a7d32a07db5458e79c726b77e3c2eff655c36792f2b6c58d351fb5f61531e5b1ab7f46987150136e366c65213cbe31729e02a3eaed630c3bf7334635fb410
+  languageName: node
+  linkType: hard
+
+"copy-concurrently@npm:^1.0.0":
+  version: 1.0.5
+  resolution: "copy-concurrently@npm:1.0.5"
+  dependencies:
+    aproba: ^1.1.1
+    fs-write-stream-atomic: ^1.0.8
+    iferr: ^0.1.5
+    mkdirp: ^0.5.1
+    rimraf: ^2.5.4
+    run-queue: ^1.0.0
+  checksum: 63c169f582e09445260988f697b2d07793d439dfc31e97c8999707bd188dd94d1c7f2ca3533c7786fb75f03a3f2f54ad1ee08055f95f61bb8d2e862498c1d460
+  languageName: node
+  linkType: hard
+
+"copy-descriptor@npm:^0.1.0":
+  version: 0.1.1
+  resolution: "copy-descriptor@npm:0.1.1"
+  checksum: d4b7b57b14f1d256bb9aa0b479241048afd7f5bcf22035fc7b94e8af757adeae247ea23c1a774fe44869fd5694efba4a969b88d966766c5245fdee59837fe45b
+  languageName: node
+  linkType: hard
+
+"copy-to-clipboard@npm:^3":
+  version: 3.3.1
+  resolution: "copy-to-clipboard@npm:3.3.1"
+  dependencies:
+    toggle-selection: ^1.0.6
+  checksum: 3c7b1c333dc6a4b2e9905f52e4df6bbd34ff9f9c97ecd3ca55378a6bc1c191bb12a3252e6289c7b436e9188cff0360d393c0161626851d2301607860bbbdcfd5
+  languageName: node
+  linkType: hard
+
+"core-js-compat@npm:^3.14.0, core-js-compat@npm:^3.15.0, core-js-compat@npm:^3.6.2":
+  version: 3.15.1
+  resolution: "core-js-compat@npm:3.15.1"
+  dependencies:
+    browserslist: ^4.16.6
+    semver: 7.0.0
+  checksum: cf2fb3406c7fd82edee3ccf9e55e538cf75da79845d5dbffaf979cb9e73e26943ee6e7d07c5cbc50c5909fba1c5a4ca499d0f249fdb491da45b40f8584a4c761
+  languageName: node
+  linkType: hard
+
+"core-js-pure@npm:^3.15.0":
+  version: 3.15.1
+  resolution: "core-js-pure@npm:3.15.1"
+  checksum: e4053f6f3ab4268f991d76f4c3f918cfa5a95182d0c5ddcc32d381bc208318b5817db8fb01d531363a7d110b46ea1c6ffb14e832f661bfea3e213d52d9b92658
+  languageName: node
+  linkType: hard
+
+"core-js@npm:^1.0.0":
+  version: 1.2.7
+  resolution: "core-js@npm:1.2.7"
+  checksum: 0b76371bfa98708351cde580f9287e2360d2209920e738ae950ae74ad08639a2e063541020bf666c28778956fc356ed9fe56d962129c88a87a6a4a0612526c75
+  languageName: node
+  linkType: hard
+
+"core-js@npm:^2.4.0, core-js@npm:^2.5.0, core-js@npm:^2.6.12":
+  version: 2.6.12
+  resolution: "core-js@npm:2.6.12"
+  checksum: 44fa9934a85f8c78d61e0c8b7b22436330471ffe59ec5076fe7f324d6e8cf7f824b14b1c81ca73608b13bdb0fef035bd820989bf059767ad6fa13123bb8bd016
+  languageName: node
+  linkType: hard
+
+"core-js@npm:^3.5.0, core-js@npm:^3.6.4":
+  version: 3.15.1
+  resolution: "core-js@npm:3.15.1"
+  checksum: d44c1099b4028bee17990473df0b508ad0f6701aba9e13055183fe4a8bd1459e9e22f22b8e6c0b0a6ac0974b404672df47d52be3341a776a227fc368f2aa1fbe
+  languageName: node
+  linkType: hard
+
+"core-util-is@npm:1.0.2, core-util-is@npm:~1.0.0":
+  version: 1.0.2
+  resolution: "core-util-is@npm:1.0.2"
+  checksum: 7a4c925b497a2c91421e25bf76d6d8190f0b2359a9200dbeed136e63b2931d6294d3b1893eda378883ed363cd950f44a12a401384c609839ea616befb7927dab
+  languageName: node
+  linkType: hard
+
+"cosmiconfig@npm:^5.0.0, cosmiconfig@npm:^5.2.1":
+  version: 5.2.1
+  resolution: "cosmiconfig@npm:5.2.1"
+  dependencies:
+    import-fresh: ^2.0.0
+    is-directory: ^0.3.1
+    js-yaml: ^3.13.1
+    parse-json: ^4.0.0
+  checksum: 8b6f1d3c8a5ffdf663a952f17af0761adf210b7a5933d0fe8988f3ca3a1f0e1e5cbbb74d5b419c15933dd2fdcaec31dbc5cc85cb8259a822342b93b529eff89c
+  languageName: node
+  linkType: hard
+
+"cosmiconfig@npm:^6.0.0":
+  version: 6.0.0
+  resolution: "cosmiconfig@npm:6.0.0"
+  dependencies:
+    "@types/parse-json": ^4.0.0
+    import-fresh: ^3.1.0
+    parse-json: ^5.0.0
+    path-type: ^4.0.0
+    yaml: ^1.7.2
+  checksum: 8eed7c854b91643ecb820767d0deb038b50780ecc3d53b0b19e03ed8aabed4ae77271198d1ae3d49c3b110867edf679f5faad924820a8d1774144a87cb6f98fc
+  languageName: node
+  linkType: hard
+
+"create-ecdh@npm:^4.0.0":
+  version: 4.0.4
+  resolution: "create-ecdh@npm:4.0.4"
+  dependencies:
+    bn.js: ^4.1.0
+    elliptic: ^6.5.3
+  checksum: 0dd7fca9711d09e152375b79acf1e3f306d1a25ba87b8ff14c2fd8e68b83aafe0a7dd6c4e540c9ffbdd227a5fa1ad9b81eca1f233c38bb47770597ba247e614b
+  languageName: node
+  linkType: hard
+
+"create-hash@npm:^1.1.0, create-hash@npm:^1.1.2, create-hash@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "create-hash@npm:1.2.0"
+  dependencies:
+    cipher-base: ^1.0.1
+    inherits: ^2.0.1
+    md5.js: ^1.3.4
+    ripemd160: ^2.0.1
+    sha.js: ^2.4.0
+  checksum: 02a6ae3bb9cd4afee3fabd846c1d8426a0e6b495560a977ba46120c473cb283be6aa1cace76b5f927cf4e499c6146fb798253e48e83d522feba807d6b722eaa9
+  languageName: node
+  linkType: hard
+
+"create-hmac@npm:^1.1.0, create-hmac@npm:^1.1.4, create-hmac@npm:^1.1.7":
+  version: 1.1.7
+  resolution: "create-hmac@npm:1.1.7"
+  dependencies:
+    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
+  checksum: ba12bb2257b585a0396108c72830e85f882ab659c3320c83584b1037f8ab72415095167ced80dc4ce8e446a8ecc4b2acf36d87befe0707d73b26cf9dc77440ed
+  languageName: node
+  linkType: hard
+
+"cross-fetch@npm:^3.0.4":
+  version: 3.1.8
+  resolution: "cross-fetch@npm:3.1.8"
+  dependencies:
+    node-fetch: ^2.6.12
+  checksum: 78f993fa099eaaa041122ab037fe9503ecbbcb9daef234d1d2e0b9230a983f64d645d088c464e21a247b825a08dc444a6e7064adfa93536d3a9454b4745b3632
+  languageName: node
+  linkType: hard
+
+"cross-spawn@npm:7.0.1":
+  version: 7.0.1
+  resolution: "cross-spawn@npm:7.0.1"
+  dependencies:
+    path-key: ^3.1.0
+    shebang-command: ^2.0.0
+    which: ^2.0.1
+  checksum: 5c1c52be2d24f0ada793920bf0beca61ea9cc03bb5c400617ddfd2c03f10ed86a0c39fb67bcf2cee91ec4dd7e9f1595ed9c40f84352d2881937bf861281f651a
+  languageName: node
+  linkType: hard
+
+"cross-spawn@npm:^6.0.0, cross-spawn@npm:^6.0.5":
+  version: 6.0.5
+  resolution: "cross-spawn@npm:6.0.5"
+  dependencies:
+    nice-try: ^1.0.4
+    path-key: ^2.0.1
+    semver: ^5.5.0
+    shebang-command: ^1.2.0
+    which: ^1.2.9
+  checksum: f893bb0d96cd3d5751d04e67145bdddf25f99449531a72e82dcbbd42796bbc8268c1076c6b3ea51d4d455839902804b94bc45dfb37ecbb32ea8e54a6741c3ab9
+  languageName: node
+  linkType: hard
+
+"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3":
+  version: 7.0.3
+  resolution: "cross-spawn@npm:7.0.3"
+  dependencies:
+    path-key: ^3.1.0
+    shebang-command: ^2.0.0
+    which: ^2.0.1
+  checksum: 671cc7c7288c3a8406f3c69a3ae2fc85555c04169e9d611def9a675635472614f1c0ed0ef80955d5b6d4e724f6ced67f0ad1bb006c2ea643488fcfef994d7f52
+  languageName: node
+  linkType: hard
+
+"crypto-browserify@npm:^3.11.0":
+  version: 3.12.0
+  resolution: "crypto-browserify@npm:3.12.0"
+  dependencies:
+    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
+  checksum: c1609af82605474262f3eaa07daa0b2140026bd264ab316d4bf1170272570dbe02f0c49e29407fe0d3634f96c507c27a19a6765fb856fed854a625f9d15618e2
+  languageName: node
+  linkType: hard
+
+"css-blank-pseudo@npm:^0.1.4":
+  version: 0.1.4
+  resolution: "css-blank-pseudo@npm:0.1.4"
+  dependencies:
+    postcss: ^7.0.5
+  bin:
+    css-blank-pseudo: cli.js
+  checksum: f995a6ca5dbb867af4b30c3dc872a8f0b27ad120442c34796eef7f9c4dcf014249522aaa0a2da3c101c4afa5d7d376436bb978ae1b2c02deddec283fad30c998
+  languageName: node
+  linkType: hard
+
+"css-color-keywords@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "css-color-keywords@npm:1.0.0"
+  checksum: 8f125e3ad477bd03c77b533044bd9e8a6f7c0da52d49bbc0bbe38327b3829d6ba04d368ca49dd9ff3b667d2fc8f1698d891c198bbf8feade1a5501bf5a296408
+  languageName: node
+  linkType: hard
+
+"css-color-names@npm:0.0.4, css-color-names@npm:^0.0.4":
+  version: 0.0.4
+  resolution: "css-color-names@npm:0.0.4"
+  checksum: 9c6106320430a9da3a13daab8d8b4def39113edbfb68042444585d9a214af5fd5cb384b9be45124bc75f88261d461b517e00e278f4d2e0ab5a619b182f9f0e2d
+  languageName: node
+  linkType: hard
+
+"css-declaration-sorter@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "css-declaration-sorter@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.1
+    timsort: ^0.3.0
+  checksum: c38c00245c6706bd1127a6a2807bbdea3a2621c1f4e4bcb4710f6736c15c4ec414e02213adeab2171623351616090cb96374f683b90ec2aad18903066c4526d7
+  languageName: node
+  linkType: hard
+
+"css-has-pseudo@npm:^0.10.0":
+  version: 0.10.0
+  resolution: "css-has-pseudo@npm:0.10.0"
+  dependencies:
+    postcss: ^7.0.6
+    postcss-selector-parser: ^5.0.0-rc.4
+  bin:
+    css-has-pseudo: cli.js
+  checksum: 88d891ba18f821e8a94d821ecdd723c606019462664c7d86e7d8731622bd26f9d55582e494bcc2a62f9399cc7b89049ddc8a9d1e8f1bf1a133c2427739d2d334
+  languageName: node
+  linkType: hard
+
+"css-loader@npm:3.4.2":
+  version: 3.4.2
+  resolution: "css-loader@npm:3.4.2"
+  dependencies:
+    camelcase: ^5.3.1
+    cssesc: ^3.0.0
+    icss-utils: ^4.1.1
+    loader-utils: ^1.2.3
+    normalize-path: ^3.0.0
+    postcss: ^7.0.23
+    postcss-modules-extract-imports: ^2.0.0
+    postcss-modules-local-by-default: ^3.0.2
+    postcss-modules-scope: ^2.1.1
+    postcss-modules-values: ^3.0.0
+    postcss-value-parser: ^4.0.2
+    schema-utils: ^2.6.0
+  peerDependencies:
+    webpack: ^4.0.0 || ^5.0.0
+  checksum: dbd80f052b41ea7c33d96a2fbeabca82773f7e3567300c636ffb079ffcf8ba111b02f315346942334ed27ffc137323c9a4ac1e446eaed5837abbdd3fdd371a0c
+  languageName: node
+  linkType: hard
+
+"css-prefers-color-scheme@npm:^3.1.1":
+  version: 3.1.1
+  resolution: "css-prefers-color-scheme@npm:3.1.1"
+  dependencies:
+    postcss: ^7.0.5
+  bin:
+    css-prefers-color-scheme: cli.js
+  checksum: ba69a86b006818ffe3548bcbeb5e4e8139b8b6cf45815a3b3dddd12cd9acf3d8ac3b94e63fe0abd34e0683cf43ed8c2344e3bd472bbf02a6eb40c7bbf565d587
+  languageName: node
+  linkType: hard
+
+"css-select-base-adapter@npm:^0.1.1":
+  version: 0.1.1
+  resolution: "css-select-base-adapter@npm:0.1.1"
+  checksum: c107e9cfa53a23427e4537451a67358375e656baa3322345a982d3c2751fb3904002aae7e5d72386c59f766fe6b109d1ffb43eeab1c16f069f7a3828eb17851c
+  languageName: node
+  linkType: hard
+
+"css-select@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "css-select@npm:2.1.0"
+  dependencies:
+    boolbase: ^1.0.0
+    css-what: ^3.2.1
+    domutils: ^1.7.0
+    nth-check: ^1.0.2
+  checksum: 0c4099910f2411e2a9103cf92ea6a4ad738b57da75bcf73d39ef2c14a00ef36e5f16cb863211c901320618b24ace74da6333442d82995cafd5040077307de462
+  languageName: node
+  linkType: hard
+
+"css-select@npm:^4.1.3":
+  version: 4.3.0
+  resolution: "css-select@npm:4.3.0"
+  dependencies:
+    boolbase: ^1.0.0
+    css-what: ^6.0.1
+    domhandler: ^4.3.1
+    domutils: ^2.8.0
+    nth-check: ^2.0.1
+  checksum: d6202736839194dd7f910320032e7cfc40372f025e4bf21ca5bf6eb0a33264f322f50ba9c0adc35dadd342d3d6fae5ca244779a4873afbfa76561e343f2058e0
+  languageName: node
+  linkType: hard
+
+"css-to-react-native@npm:3.2.0":
+  version: 3.2.0
+  resolution: "css-to-react-native@npm:3.2.0"
+  dependencies:
+    camelize: ^1.0.0
+    css-color-keywords: ^1.0.0
+    postcss-value-parser: ^4.0.2
+  checksum: 263be65e805aef02c3f20c064665c998a8c35293e1505dbe6e3054fb186b01a9897ac6cf121f9840e5a9dfe3fb3994f6fcd0af84a865f1df78ba5bf89e77adce
+  languageName: node
+  linkType: hard
+
+"css-tree@npm:1.0.0-alpha.37":
+  version: 1.0.0-alpha.37
+  resolution: "css-tree@npm:1.0.0-alpha.37"
+  dependencies:
+    mdn-data: 2.0.4
+    source-map: ^0.6.1
+  checksum: 0e419a1388ec0fbbe92885fba4a557f9fb0e077a2a1fad629b7245bbf7b4ef5df49e6877401b952b09b9057ffe1a3dba74f6fdfbf7b2223a5a35bce27ff2307d
+  languageName: node
+  linkType: hard
+
+"css-tree@npm:^1.1.2":
+  version: 1.1.3
+  resolution: "css-tree@npm:1.1.3"
+  dependencies:
+    mdn-data: 2.0.14
+    source-map: ^0.6.1
+  checksum: 79f9b81803991b6977b7fcb1588799270438274d89066ce08f117f5cdb5e20019b446d766c61506dd772c839df84caa16042d6076f20c97187f5abe3b50e7d1f
+  languageName: node
+  linkType: hard
+
+"css-vendor@npm:^0.3.8":
+  version: 0.3.8
+  resolution: "css-vendor@npm:0.3.8"
+  dependencies:
+    is-in-browser: ^1.0.2
+  checksum: 0a2e0cd0d4adbfdb6236950e7f9697b8a9b294eb2ae7c95996a95d273d2a63316ce793cb4654ae048aa3c129327124d2a29aada9935a0c284f9cc341c2768c8a
+  languageName: node
+  linkType: hard
+
+"css-what@npm:^3.2.1, css-what@npm:^5.0.1":
+  version: 5.0.1
+  resolution: "css-what@npm:5.0.1"
+  checksum: 7a3de33a1c130d32d711cce4e0fa747be7a9afe6b5f2c6f3d56bc2765f150f6034f5dd5fe263b9359a1c371c01847399602d74b55322c982742b336d998602cd
+  languageName: node
+  linkType: hard
+
+"css-what@npm:^6.0.1":
+  version: 6.1.0
+  resolution: "css-what@npm:6.1.0"
+  checksum: b975e547e1e90b79625918f84e67db5d33d896e6de846c9b584094e529f0c63e2ab85ee33b9daffd05bff3a146a1916bec664e18bb76dd5f66cbff9fc13b2bbe
+  languageName: node
+  linkType: hard
+
+"css@npm:^2.0.0":
+  version: 2.2.4
+  resolution: "css@npm:2.2.4"
+  dependencies:
+    inherits: ^2.0.3
+    source-map: ^0.6.1
+    source-map-resolve: ^0.5.2
+    urix: ^0.1.0
+  checksum: a35d483c5ccc04bcde3b1e7393d58ad3eee1dd6956df0f152de38e46a17c0ee193c30eec6b1e59831ad0e74599385732000e95987fcc9cb2b16c6d951bae49e1
+  languageName: node
+  linkType: hard
+
+"cssdb@npm:^4.4.0":
+  version: 4.4.0
+  resolution: "cssdb@npm:4.4.0"
+  checksum: 521dd2135da1ab93612a4161eb1024cfc7b155a35d95f9867d328cc88ad57fdd959aa88ea8f4e6cea3a82bca91b76570dc1abb18bfd902c6889973956a03e497
+  languageName: node
+  linkType: hard
+
+"cssesc@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "cssesc@npm:2.0.0"
+  bin:
+    cssesc: bin/cssesc
+  checksum: 5e50886c2aca3f492fe808dbd146d30eb1c6f31fbe6093979a8376e39d171d989279199f6f3f1a42464109e082e0e42bc33eeff9467fb69bf346f5ba5853c3c6
+  languageName: node
+  linkType: hard
+
+"cssesc@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "cssesc@npm:3.0.0"
+  bin:
+    cssesc: bin/cssesc
+  checksum: f8c4ababffbc5e2ddf2fa9957dda1ee4af6048e22aeda1869d0d00843223c1b13ad3f5d88b51caa46c994225eacb636b764eb807a8883e2fb6f99b4f4e8c48b2
+  languageName: node
+  linkType: hard
+
+"cssnano-preset-default@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "cssnano-preset-default@npm:4.0.8"
+  dependencies:
+    css-declaration-sorter: ^4.0.1
+    cssnano-util-raw-cache: ^4.0.1
+    postcss: ^7.0.0
+    postcss-calc: ^7.0.1
+    postcss-colormin: ^4.0.3
+    postcss-convert-values: ^4.0.1
+    postcss-discard-comments: ^4.0.2
+    postcss-discard-duplicates: ^4.0.2
+    postcss-discard-empty: ^4.0.1
+    postcss-discard-overridden: ^4.0.1
+    postcss-merge-longhand: ^4.0.11
+    postcss-merge-rules: ^4.0.3
+    postcss-minify-font-values: ^4.0.2
+    postcss-minify-gradients: ^4.0.2
+    postcss-minify-params: ^4.0.2
+    postcss-minify-selectors: ^4.0.2
+    postcss-normalize-charset: ^4.0.1
+    postcss-normalize-display-values: ^4.0.2
+    postcss-normalize-positions: ^4.0.2
+    postcss-normalize-repeat-style: ^4.0.2
+    postcss-normalize-string: ^4.0.2
+    postcss-normalize-timing-functions: ^4.0.2
+    postcss-normalize-unicode: ^4.0.1
+    postcss-normalize-url: ^4.0.1
+    postcss-normalize-whitespace: ^4.0.2
+    postcss-ordered-values: ^4.1.2
+    postcss-reduce-initial: ^4.0.3
+    postcss-reduce-transforms: ^4.0.2
+    postcss-svgo: ^4.0.3
+    postcss-unique-selectors: ^4.0.1
+  checksum: eb32c9fdd8bd4683e33d62284b6a9c4eb705b745235f4bb51a5571e1eb6738f636958fc9a6218fb51de43e0e2f74386a705b4c7ff2d1dcc611647953ba6ce159
+  languageName: node
+  linkType: hard
+
+"cssnano-util-get-arguments@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "cssnano-util-get-arguments@npm:4.0.0"
+  checksum: 34222a1e848d573b74892eda7d7560c5422efa56f87d2b5242f9791593c6aa4ddc9d55e8e1708fb2f0d6f87c456314b78d93d3eec97d946ff756c63b09b72222
+  languageName: node
+  linkType: hard
+
+"cssnano-util-get-match@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "cssnano-util-get-match@npm:4.0.0"
+  checksum: 56eacea0eb3d923359c9714ab25edde5eb4859e495954615d5529e81cdfabc2d41b57055c7f6a2f08e7d89df3a2794ef659306b539505d7f4e7202b897396fc2
+  languageName: node
+  linkType: hard
+
+"cssnano-util-raw-cache@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "cssnano-util-raw-cache@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.0
+  checksum: 66a23e5e5255ff65d0f49f135d0ddfdb96433aeceb2708a31e4b4a652110755f103f6c91e0f439c8f3052818eb2b04ebf6334680a810296290e2c3467c14202b
+  languageName: node
+  linkType: hard
+
+"cssnano-util-same-parent@npm:^4.0.0":
+  version: 4.0.1
+  resolution: "cssnano-util-same-parent@npm:4.0.1"
+  checksum: 97c6b3f670ee9d1d6342b6a1daf9867d5c08644365dc146bd76defd356069112148e382ca86fc3e6c55adf0687974f03535bba34df95efb468b266d2319c7b66
+  languageName: node
+  linkType: hard
+
+"cssnano@npm:^4.1.10":
+  version: 4.1.11
+  resolution: "cssnano@npm:4.1.11"
+  dependencies:
+    cosmiconfig: ^5.0.0
+    cssnano-preset-default: ^4.0.8
+    is-resolvable: ^1.0.0
+    postcss: ^7.0.0
+  checksum: 2453fbe9f9f9e2ffe87dc5c718578f1b801fc7b82eaad12f5564c84bb0faf1774ea52e01874ecd29d1782aa7d0d84f0dbc95001eed9866ebd9bc523638999c9b
+  languageName: node
+  linkType: hard
+
+"csso@npm:^4.0.2":
+  version: 4.2.0
+  resolution: "csso@npm:4.2.0"
+  dependencies:
+    css-tree: ^1.1.2
+  checksum: 380ba9663da3bcea58dee358a0d8c4468bb6539be3c439dc266ac41c047217f52fd698fb7e4b6b6ccdfb8cf53ef4ceed8cc8ceccb8dfca2aa628319826b5b998
+  languageName: node
+  linkType: hard
+
+"cssom@npm:0.3.x, cssom@npm:>= 0.3.2 < 0.4.0, cssom@npm:^0.3.4":
+  version: 0.3.8
+  resolution: "cssom@npm:0.3.8"
+  checksum: 24beb3087c76c0d52dd458be9ee1fbc80ac771478a9baef35dd258cdeb527c68eb43204dd439692bb2b1ae5272fa5f2946d10946edab0d04f1078f85e06bc7f6
+  languageName: node
+  linkType: hard
+
+"cssstyle@npm:^1.0.0, cssstyle@npm:^1.1.1":
+  version: 1.4.0
+  resolution: "cssstyle@npm:1.4.0"
+  dependencies:
+    cssom: 0.3.x
+  checksum: 7efb9731d68dd042f32e0e3bbc7c1096653ba521f21ab1c5b158862321e4fcbfb51070641b834fadc8dd070a634dd43f328177e00d1b8481b5143a3e09f3d3f6
+  languageName: node
+  linkType: hard
+
+"csstype@npm:3.1.2":
+  version: 3.1.2
+  resolution: "csstype@npm:3.1.2"
+  checksum: e1a52e6c25c1314d6beef5168da704ab29c5186b877c07d822bd0806717d9a265e8493a2e35ca7e68d0f5d472d43fac1cdce70fd79fd0853dff81f3028d857b5
+  languageName: node
+  linkType: hard
+
+"csstype@npm:^2.0.0, csstype@npm:^2.5.2":
+  version: 2.6.17
+  resolution: "csstype@npm:2.6.17"
+  checksum: 6eee5cf81a4b1b2f0e8707b4accd7504f7cceb4b5a496d58c6e4fcea1a70c1443a975e45d722c892d372ffe788fa278ddfe4d70c5f881375f34e48bb99c70ecc
+  languageName: node
+  linkType: hard
+
+"csstype@npm:^3.0.2":
+  version: 3.0.8
+  resolution: "csstype@npm:3.0.8"
+  checksum: 5939a003858a31a32cbc52a8f45496aa0c2bcb4629b21c5bc14a7ddcac1a3d4adfd655f56843dc14940f60563378e9444af2c9c373b3f212601b9eeb6740b8db
+  languageName: node
+  linkType: hard
+
+"currently-unhandled@npm:^0.4.1":
+  version: 0.4.1
+  resolution: "currently-unhandled@npm:0.4.1"
+  dependencies:
+    array-find-index: ^1.0.1
+  checksum: 1f59fe10b5339b54b1a1eee110022f663f3495cf7cf2f480686e89edc7fa8bfe42dbab4b54f85034bc8b092a76cc7becbc2dad4f9adad332ab5831bec39ad540
+  languageName: node
+  linkType: hard
+
+"cwlts@npm:1.15.29":
+  version: 1.15.29
+  resolution: "cwlts@npm:1.15.29"
+  dependencies:
+    ajv: ^6.1.1
+    js-yaml: ^3.10.0
+  checksum: 485f922bdbf8702dae9479de6690975da076c904f1c66deaa3616c5c8b755ab2274c9556f364162117159f69bd9d3f96d128eb54176f05d6f43c7a9f86bccce1
+  languageName: node
+  linkType: hard
+
+"cyclist@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "cyclist@npm:1.0.1"
+  checksum: 3cc2fdeb358599ca0ea96f5ecf2fc530ccab7ed1f8aa1a894aebfacd2009281bd7380cb9b30db02a18cdd00b3ed1d7ce81a3b11fe56e33a6a0fe4424dc592fbe
+  languageName: node
+  linkType: hard
+
+"cypress-wait-until@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "cypress-wait-until@npm:3.0.1"
+  checksum: 487626011bf260b2e6cda68f1ced6cb4bb09013e479cd12681eeb577f788d5fd46c95040add9849d4ee1109c8e553330aadb42f2a252e8656bb8c3dbf776a087
+  languageName: node
+  linkType: hard
+
+"cypress@npm:^13.6.6":
+  version: 13.6.6
+  resolution: "cypress@npm:13.6.6"
+  dependencies:
+    "@cypress/request": ^3.0.0
+    "@cypress/xvfb": ^1.2.4
+    "@types/sinonjs__fake-timers": 8.1.1
+    "@types/sizzle": ^2.3.2
+    arch: ^2.2.0
+    blob-util: ^2.0.2
+    bluebird: ^3.7.2
+    buffer: ^5.7.1
+    cachedir: ^2.3.0
+    chalk: ^4.1.0
+    check-more-types: ^2.24.0
+    cli-cursor: ^3.1.0
+    cli-table3: ~0.6.1
+    commander: ^6.2.1
+    common-tags: ^1.8.0
+    dayjs: ^1.10.4
+    debug: ^4.3.4
+    enquirer: ^2.3.6
+    eventemitter2: 6.4.7
+    execa: 4.1.0
+    executable: ^4.1.1
+    extract-zip: 2.0.1
+    figures: ^3.2.0
+    fs-extra: ^9.1.0
+    getos: ^3.2.1
+    is-ci: ^3.0.1
+    is-installed-globally: ~0.4.0
+    lazy-ass: ^1.6.0
+    listr2: ^3.8.3
+    lodash: ^4.17.21
+    log-symbols: ^4.0.0
+    minimist: ^1.2.8
+    ospath: ^1.2.2
+    pretty-bytes: ^5.6.0
+    process: ^0.11.10
+    proxy-from-env: 1.0.0
+    request-progress: ^3.0.0
+    semver: ^7.5.3
+    supports-color: ^8.1.1
+    tmp: ~0.2.1
+    untildify: ^4.0.0
+    yauzl: ^2.10.0
+  bin:
+    cypress: bin/cypress
+  checksum: 8a7db7d2941ea9fd698b9311b4f23fb6491038fe57e4c19b29a1ee58a25f9d98646674f876c1068a97428c2e81548bb0dd8701cd08e84c6b17ed75f9c2266908
+  languageName: node
+  linkType: hard
+
+"d@npm:1, d@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "d@npm:1.0.1"
+  dependencies:
+    es5-ext: ^0.10.50
+    type: ^1.0.1
+  checksum: 49ca0639c7b822db670de93d4fbce44b4aa072cd848c76292c9978a8cd0fff1028763020ff4b0f147bd77bfe29b4c7f82e0f71ade76b2a06100543cdfd948d19
+  languageName: node
+  linkType: hard
+
+"d@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "d@npm:1.0.2"
+  dependencies:
+    es5-ext: ^0.10.64
+    type: ^2.7.2
+  checksum: 775db1e8ced6707cddf64a5840522fcf5475d38ef49a5d615be0ac47f86ef64d15f5a73de1522b09327cc466d4dc35ea83dbfeed456f7a0fdcab138deb800355
+  languageName: node
+  linkType: hard
+
+"damerau-levenshtein@npm:^1.0.4":
+  version: 1.0.7
+  resolution: "damerau-levenshtein@npm:1.0.7"
+  checksum: ec8161cb381523e0db9b5c9b64863736da3197808b6fdc4a3a2ca764c0b4357e9232a4c5592220fb18755a91240b8fee7b13ab1b269fbbdc5f68c36f0053aceb
+  languageName: node
+  linkType: hard
+
+"dashdash@npm:^1.12.0":
+  version: 1.14.1
+  resolution: "dashdash@npm:1.14.1"
+  dependencies:
+    assert-plus: ^1.0.0
+  checksum: 3634c249570f7f34e3d34f866c93f866c5b417f0dd616275decae08147dcdf8fccfaa5947380ccfb0473998ea3a8057c0b4cd90c875740ee685d0624b2983598
+  languageName: node
+  linkType: hard
+
+"data-urls@npm:^1.0.0, data-urls@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "data-urls@npm:1.1.0"
+  dependencies:
+    abab: ^2.0.0
+    whatwg-mimetype: ^2.2.0
+    whatwg-url: ^7.0.0
+  checksum: dc4bd9621df0dff336d7c4c0517c792488ef3cf11cd37e72ab80f3a7f0a0aa14bad677ac97cf22c87c6eb9518e58b98590e1c8c756b56240940f0e470c81612e
+  languageName: node
+  linkType: hard
+
+"date-fns@npm:^2.28.0":
+  version: 2.28.0
+  resolution: "date-fns@npm:2.28.0"
+  checksum: a0516b2e4f99b8bffc6cc5193349f185f195398385bdcaf07f17c2c4a24473c99d933eb0018be4142a86a6d46cb0b06be6440ad874f15e795acbedd6fd727a1f
+  languageName: node
+  linkType: hard
+
+"dayjs@npm:^1.10.4":
+  version: 1.11.10
+  resolution: "dayjs@npm:1.11.10"
+  checksum: a6b5a3813b8884f5cd557e2e6b7fa569f4c5d0c97aca9558e38534af4f2d60daafd3ff8c2000fed3435cfcec9e805bcebd99f90130c6d1c5ef524084ced588c4
+  languageName: node
+  linkType: hard
+
+"debounce@npm:1.2.0":
+  version: 1.2.0
+  resolution: "debounce@npm:1.2.0"
+  checksum: e39cb593ae26344921f5a2681b40b703bdd22bc43b179f0e7515176c790997932b3a0ee6ea9864f384c6ac58cecc08158fb102c3632d5d88ab621f8230ee39ff
+  languageName: node
+  linkType: hard
+
+"debounce@npm:^1.1.0":
+  version: 1.2.1
+  resolution: "debounce@npm:1.2.1"
+  checksum: 682a89506d9e54fb109526f4da255c5546102fbb8e3ae75eef3b04effaf5d4853756aee97475cd4650641869794e44f410eeb20ace2b18ea592287ab2038519e
+  languageName: node
+  linkType: hard
+
+"debug@npm:2.6.9, debug@npm:^2.2.0, debug@npm:^2.3.3, debug@npm:^2.6.0, debug@npm:^2.6.9":
+  version: 2.6.9
+  resolution: "debug@npm:2.6.9"
+  dependencies:
+    ms: 2.0.0
+  checksum: d2f51589ca66df60bf36e1fa6e4386b318c3f1e06772280eea5b1ae9fd3d05e9c2b7fd8a7d862457d00853c75b00451aa2d7459b924629ee385287a650f58fe6
+  languageName: node
+  linkType: hard
+
+"debug@npm:4":
+  version: 4.3.3
+  resolution: "debug@npm:4.3.3"
+  dependencies:
+    ms: 2.1.2
+  peerDependenciesMeta:
+    supports-color:
+      optional: true
+  checksum: 14472d56fe4a94dbcfaa6dbed2dd3849f1d72ba78104a1a328047bb564643ca49df0224c3a17fa63533fd11dd3d4c8636cd861191232a2c6735af00cc2d4de16
+  languageName: node
+  linkType: hard
+
+"debug@npm:^3.1.0, debug@npm:^3.1.1, debug@npm:^3.2.5, debug@npm:^3.2.7":
+  version: 3.2.7
+  resolution: "debug@npm:3.2.7"
+  dependencies:
+    ms: ^2.1.1
+  checksum: b3d8c5940799914d30314b7c3304a43305fd0715581a919dacb8b3176d024a782062368405b47491516d2091d6462d4d11f2f4974a405048094f8bfebfa3071c
+  languageName: node
+  linkType: hard
+
+"debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "debug@npm:4.3.1"
+  dependencies:
+    ms: 2.1.2
+  peerDependenciesMeta:
+    supports-color:
+      optional: true
+  checksum: 2c3352e37d5c46b0d203317cd45ea0e26b2c99f2d9dfec8b128e6ceba90dfb65425f5331bf3020fe9929d7da8c16758e737f4f3bfc0fce6b8b3d503bae03298b
+  languageName: node
+  linkType: hard
+
+"debug@npm:^4.3.3, debug@npm:^4.3.4":
+  version: 4.3.4
+  resolution: "debug@npm:4.3.4"
+  dependencies:
+    ms: 2.1.2
+  peerDependenciesMeta:
+    supports-color:
+      optional: true
+  checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708
+  languageName: node
+  linkType: hard
+
+"decamelize-keys@npm:^1.1.0":
+  version: 1.1.1
+  resolution: "decamelize-keys@npm:1.1.1"
+  dependencies:
+    decamelize: ^1.1.0
+    map-obj: ^1.0.0
+  checksum: fc645fe20b7bda2680bbf9481a3477257a7f9304b1691036092b97ab04c0ab53e3bf9fcc2d2ae382536568e402ec41fb11e1d4c3836a9abe2d813dd9ef4311e0
+  languageName: node
+  linkType: hard
+
+"decamelize@npm:^1.1.0, decamelize@npm:^1.1.1, decamelize@npm:^1.1.2, decamelize@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "decamelize@npm:1.2.0"
+  checksum: ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa
+  languageName: node
+  linkType: hard
+
+"decode-uri-component@npm:^0.2.0":
+  version: 0.2.2
+  resolution: "decode-uri-component@npm:0.2.2"
+  checksum: 95476a7d28f267292ce745eac3524a9079058bbb35767b76e3ee87d42e34cd0275d2eb19d9d08c3e167f97556e8a2872747f5e65cbebcac8b0c98d83e285f139
+  languageName: node
+  linkType: hard
+
+"deep-equal@npm:^1.0.1":
+  version: 1.1.1
+  resolution: "deep-equal@npm:1.1.1"
+  dependencies:
+    is-arguments: ^1.0.4
+    is-date-object: ^1.0.1
+    is-regex: ^1.0.4
+    object-is: ^1.0.1
+    object-keys: ^1.1.1
+    regexp.prototype.flags: ^1.2.0
+  checksum: f92686f2c5bcdf714a75a5fa7a9e47cb374a8ec9307e717b8d1ce61f56a75aaebf5619c2a12b8087a705b5a2f60d0292c35f8b58cb1f72e3268a3a15cab9f78d
+  languageName: node
+  linkType: hard
+
+"deep-is@npm:~0.1.3":
+  version: 0.1.3
+  resolution: "deep-is@npm:0.1.3"
+  checksum: c15b04c3848a89880c94e25b077c19b47d9a30dd99048e70e5f95d943e7b246bee1da0c1376b56b01bc045be2cae7d9b1c856e68e47e9805634327de7c6cb6d5
+  languageName: node
+  linkType: hard
+
+"deepmerge@npm:^3.0.0":
+  version: 3.3.0
+  resolution: "deepmerge@npm:3.3.0"
+  checksum: 4322195389e0170a0443c07b36add19b90249802c4b47b96265fdc5f5d8beddf491d5e50cbc5bfd65f85ccf76598173083863c202f5463b3b667aca8be75d5ac
+  languageName: node
+  linkType: hard
+
+"default-gateway@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "default-gateway@npm:4.2.0"
+  dependencies:
+    execa: ^1.0.0
+    ip-regex: ^2.1.0
+  checksum: 1f5be765471689c6bab33e0c8b87363c3e2485cc1ab78904d383a8a8293a79f684da2a3303744b112503f986af4ea87d917c63a468ed913e9b0c31588c02d6a4
+  languageName: node
+  linkType: hard
+
+"define-properties@npm:^1.1.2, define-properties@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "define-properties@npm:1.1.3"
+  dependencies:
+    object-keys: ^1.0.12
+  checksum: da80dba55d0cd76a5a7ab71ef6ea0ebcb7b941f803793e4e0257b384cb772038faa0c31659d244e82c4342edef841c1a1212580006a05a5068ee48223d787317
+  languageName: node
+  linkType: hard
+
+"define-property@npm:^0.2.5":
+  version: 0.2.5
+  resolution: "define-property@npm:0.2.5"
+  dependencies:
+    is-descriptor: ^0.1.0
+  checksum: 85af107072b04973b13f9e4128ab74ddfda48ec7ad2e54b193c0ffb57067c4ce5b7786a7b4ae1f24bd03e87c5d18766b094571810b314d7540f86d4354dbd394
+  languageName: node
+  linkType: hard
+
+"define-property@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "define-property@npm:1.0.0"
+  dependencies:
+    is-descriptor: ^1.0.0
+  checksum: 5fbed11dace44dd22914035ba9ae83ad06008532ca814d7936a53a09e897838acdad5b108dd0688cc8d2a7cf0681acbe00ee4136cf36743f680d10517379350a
+  languageName: node
+  linkType: hard
+
+"define-property@npm:^2.0.2":
+  version: 2.0.2
+  resolution: "define-property@npm:2.0.2"
+  dependencies:
+    is-descriptor: ^1.0.2
+    isobject: ^3.0.1
+  checksum: 3217ed53fc9eed06ba8da6f4d33e28c68a82e2f2a8ab4d562c4920d8169a166fe7271453675e6c69301466f36a65d7f47edf0cf7f474b9aa52a5ead9c1b13c99
+  languageName: node
+  linkType: hard
+
+"del@npm:^4.1.1":
+  version: 4.1.1
+  resolution: "del@npm:4.1.1"
+  dependencies:
+    "@types/glob": ^7.1.1
+    globby: ^6.1.0
+    is-path-cwd: ^2.0.0
+    is-path-in-cwd: ^2.0.0
+    p-map: ^2.0.0
+    pify: ^4.0.1
+    rimraf: ^2.6.3
+  checksum: 521f7da44bd79da841c06d573923d1f64f423aee8b8219c973478d3150ce1dcc024d03ad605929292adbff56d6448bca60d96dcdd2d8a53b46dbcb27e265c94b
+  languageName: node
+  linkType: hard
+
+"delayed-stream@npm:~1.0.0":
+  version: 1.0.0
+  resolution: "delayed-stream@npm:1.0.0"
+  checksum: 46fe6e83e2cb1d85ba50bd52803c68be9bd953282fa7096f51fc29edd5d67ff84ff753c51966061e5ba7cb5e47ef6d36a91924eddb7f3f3483b1c560f77a0020
+  languageName: node
+  linkType: hard
+
+"delegates@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "delegates@npm:1.0.0"
+  checksum: a51744d9b53c164ba9c0492471a1a2ffa0b6727451bdc89e31627fdf4adda9d51277cfcbfb20f0a6f08ccb3c436f341df3e92631a3440226d93a8971724771fd
+  languageName: node
+  linkType: hard
+
+"depd@npm:2.0.0":
+  version: 2.0.0
+  resolution: "depd@npm:2.0.0"
+  checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a
+  languageName: node
+  linkType: hard
+
+"depd@npm:^1.1.2, depd@npm:~1.1.2":
+  version: 1.1.2
+  resolution: "depd@npm:1.1.2"
+  checksum: 6b406620d269619852885ce15965272b829df6f409724415e0002c8632ab6a8c0a08ec1f0bd2add05dc7bd7507606f7e2cc034fa24224ab829580040b835ecd9
+  languageName: node
+  linkType: hard
+
+"des.js@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "des.js@npm:1.0.1"
+  dependencies:
+    inherits: ^2.0.1
+    minimalistic-assert: ^1.0.0
+  checksum: 1ec2eedd7ed6bd61dd5e0519fd4c96124e93bb22de8a9d211b02d63e5dd152824853d919bb2090f965cc0e3eb9c515950a9836b332020d810f9c71feb0fd7df4
+  languageName: node
+  linkType: hard
+
+"destroy@npm:1.2.0":
+  version: 1.2.0
+  resolution: "destroy@npm:1.2.0"
+  checksum: 0acb300b7478a08b92d810ab229d5afe0d2f4399272045ab22affa0d99dbaf12637659411530a6fcd597a9bdac718fc94373a61a95b4651bbc7b83684a565e38
+  languageName: node
+  linkType: hard
+
+"detect-newline@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "detect-newline@npm:2.1.0"
+  checksum: c55146fd5b97a9ce914f17f85a01466c9e8679289e2d390588b027a58f2e090dbc38457923072369c603b8904f982f87b78fee17e48d5706f35571642f4599f8
+  languageName: node
+  linkType: hard
+
+"detect-node@npm:^2.0.4":
+  version: 2.1.0
+  resolution: "detect-node@npm:2.1.0"
+  checksum: 832184ec458353e41533ac9c622f16c19f7c02d8b10c303dfd3a756f56be93e903616c0bb2d4226183c9351c15fc0b3dba41a17a2308262afabcfa3776e6ae6e
+  languageName: node
+  linkType: hard
+
+"detect-port-alt@npm:1.1.6":
+  version: 1.1.6
+  resolution: "detect-port-alt@npm:1.1.6"
+  dependencies:
+    address: ^1.0.1
+    debug: ^2.6.0
+  bin:
+    detect: ./bin/detect-port
+    detect-port: ./bin/detect-port
+  checksum: 9dc37b1fa4a9dd6d4889e1045849b8d841232b598d1ca888bf712f4035b07a17cf6d537465a0d7323250048d3a5a0540e3b7cf89457efc222f96f77e2c40d16a
+  languageName: node
+  linkType: hard
+
+"diff-sequences@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "diff-sequences@npm:24.9.0"
+  checksum: b81f906ff1737e0a65e8f7ee3ad1d27b426dcc25498731365aeaccc32333da3bf3a7100c963c7104f12c8e64e545114d4fe4c0b90daf2565b0b00b79f0df45c4
+  languageName: node
+  linkType: hard
+
+"diff-sequences@npm:^26.6.2":
+  version: 26.6.2
+  resolution: "diff-sequences@npm:26.6.2"
+  checksum: 79af871776ef149a7ff3345d6b1bf37fe6e81f68632aa5542787851f6f60fba19b0be22fdd1e06046f56ae7382763ccfe94a982c39ee72bd107aef435ecbc0cf
+  languageName: node
+  linkType: hard
+
+"diff@npm:^3.5.0":
+  version: 3.5.0
+  resolution: "diff@npm:3.5.0"
+  checksum: 00842950a6551e26ce495bdbce11047e31667deea546527902661f25cc2e73358967ebc78cf86b1a9736ec3e14286433225f9970678155753a6291c3bca5227b
+  languageName: node
+  linkType: hard
+
+"diff@npm:^4.0.1":
+  version: 4.0.2
+  resolution: "diff@npm:4.0.2"
+  checksum: f2c09b0ce4e6b301c221addd83bf3f454c0bc00caa3dd837cf6c127d6edf7223aa2bbe3b688feea110b7f262adbfc845b757c44c8a9f8c0c5b15d8fa9ce9d20d
+  languageName: node
+  linkType: hard
+
+"diffie-hellman@npm:^5.0.0":
+  version: 5.0.3
+  resolution: "diffie-hellman@npm:5.0.3"
+  dependencies:
+    bn.js: ^4.1.0
+    miller-rabin: ^4.0.0
+    randombytes: ^2.0.0
+  checksum: 0e620f322170c41076e70181dd1c24e23b08b47dbb92a22a644f3b89b6d3834b0f8ee19e37916164e5eb1ee26d2aa836d6129f92723995267250a0b541811065
+  languageName: node
+  linkType: hard
+
+"dir-glob@npm:2.0.0":
+  version: 2.0.0
+  resolution: "dir-glob@npm:2.0.0"
+  dependencies:
+    arrify: ^1.0.1
+    path-type: ^3.0.0
+  checksum: adc4dc5dd9d2cc0a9ce864e52f9ac1c93e34487720fbed68bdf94cef7a9d88be430cc565300750571589dd35e168d0b286120317c0797f83a7cd8e6d9c69fcb7
+  languageName: node
+  linkType: hard
+
+"dir-glob@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "dir-glob@npm:3.0.1"
+  dependencies:
+    path-type: ^4.0.0
+  checksum: fa05e18324510d7283f55862f3161c6759a3f2f8dbce491a2fc14c8324c498286c54282c1f0e933cb930da8419b30679389499b919122952a4f8592362ef4615
+  languageName: node
+  linkType: hard
+
+"discontinuous-range@npm:1.0.0":
+  version: 1.0.0
+  resolution: "discontinuous-range@npm:1.0.0"
+  checksum: 8ee88d7082445b6eadc7c03bebe6dc978f96760c45e9f65d16ca66174d9e086a9e3855ee16acf65625e1a07a846a17de674f02a5964a6aebe5963662baf8b5c8
+  languageName: node
+  linkType: hard
+
+"dnd-core@npm:^4.0.5":
+  version: 4.0.5
+  resolution: "dnd-core@npm:4.0.5"
+  dependencies:
+    asap: ^2.0.6
+    invariant: ^2.2.4
+    lodash: ^4.17.10
+    redux: ^4.0.0
+  checksum: 76a85d5525778ed67ea2943e805f3c3ae6c22f4ab550a2aee9d601c9af1541f98140f0fc1e81d58ceac08f649a8d4e912b380ce6185875dbefb84a2afc4cff84
+  languageName: node
+  linkType: hard
+
+"dns-equal@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "dns-equal@npm:1.0.0"
+  checksum: a8471ac849c7c13824f053babea1bc26e2f359394dd5a460f8340d8abd13434be01e3327a5c59d212f8c8997817450efd3f3ac77bec709b21979cf0235644524
+  languageName: node
+  linkType: hard
+
+"dns-packet@npm:^1.3.1":
+  version: 1.3.4
+  resolution: "dns-packet@npm:1.3.4"
+  dependencies:
+    ip: ^1.1.0
+    safe-buffer: ^5.0.1
+  checksum: 7dd87f85cb4f9d1a99c03470730e3d9385e67dc94f6c13868c4034424a5378631e492f9f1fbc43d3c42f319fbbfe18b6488bb9527c32d34692c52bf1f5eedf69
+  languageName: node
+  linkType: hard
+
+"dns-txt@npm:^2.0.2":
+  version: 2.0.2
+  resolution: "dns-txt@npm:2.0.2"
+  dependencies:
+    buffer-indexof: ^1.0.0
+  checksum: 80130b665379ecd991687ae079fbee25d091e03e4c4cef41e7643b977849ac48c2f56bfcb3727e53594d29029b833749811110d9f3fbee1b26a6e6f8096a5cef
+  languageName: node
+  linkType: hard
+
+"doctrine@npm:1.5.0":
+  version: 1.5.0
+  resolution: "doctrine@npm:1.5.0"
+  dependencies:
+    esutils: ^2.0.2
+    isarray: ^1.0.0
+  checksum: 7ce8102a05cbb9d942d49db5461d2f3dd1208ebfed929bf1c04770a1ef6ef540b792e63c45eae4c51f8b16075e0af4a73581a06bad31c37ceb0988f2e398509b
+  languageName: node
+  linkType: hard
+
+"doctrine@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "doctrine@npm:2.1.0"
+  dependencies:
+    esutils: ^2.0.2
+  checksum: a45e277f7feaed309fe658ace1ff286c6e2002ac515af0aaf37145b8baa96e49899638c7cd47dccf84c3d32abfc113246625b3ac8f552d1046072adee13b0dc8
+  languageName: node
+  linkType: hard
+
+"doctrine@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "doctrine@npm:3.0.0"
+  dependencies:
+    esutils: ^2.0.2
+  checksum: fd7673ca77fe26cd5cba38d816bc72d641f500f1f9b25b83e8ce28827fe2da7ad583a8da26ab6af85f834138cf8dae9f69b0cd6ab925f52ddab1754db44d99ce
+  languageName: node
+  linkType: hard
+
+"dom-converter@npm:^0.2.0":
+  version: 0.2.0
+  resolution: "dom-converter@npm:0.2.0"
+  dependencies:
+    utila: ~0.4
+  checksum: ea52fe303f5392e48dea563abef0e6fb3a478b8dbe3c599e99bb5d53981c6c38fc4944e56bb92a8ead6bb989d10b7914722ae11febbd2fd0910e33b9fc4aaa77
+  languageName: node
+  linkType: hard
+
+"dom-helpers@npm:^3.2.1, dom-helpers@npm:^3.3.1, dom-helpers@npm:^3.4.0":
+  version: 3.4.0
+  resolution: "dom-helpers@npm:3.4.0"
+  dependencies:
+    "@babel/runtime": ^7.1.2
+  checksum: 58d9f1c4a96daf77eddc63ae1236b826e1cddd6db66bbf39b18d7e21896d99365b376593352d52a60969d67fa4a8dbef26adc1439fa2c1b355efa37cacbaf637
+  languageName: node
+  linkType: hard
+
+"dom-serializer@npm:0":
+  version: 0.2.2
+  resolution: "dom-serializer@npm:0.2.2"
+  dependencies:
+    domelementtype: ^2.0.1
+    entities: ^2.0.0
+  checksum: 376344893e4feccab649a14ca1a46473e9961f40fe62479ea692d4fee4d9df1c00ca8654811a79c1ca7b020096987e1ca4fb4d7f8bae32c1db800a680a0e5d5e
+  languageName: node
+  linkType: hard
+
+"dom-serializer@npm:^1.0.1, dom-serializer@npm:^1.3.2":
+  version: 1.3.2
+  resolution: "dom-serializer@npm:1.3.2"
+  dependencies:
+    domelementtype: ^2.0.1
+    domhandler: ^4.2.0
+    entities: ^2.0.0
+  checksum: bff48714944d67b160db71ba244fb0f3fe72e77ef2ec8414e2eeb56f2d926e404a13456b8b83a5392e217ba47dec2ec0c368801b31481813e94d185276c3e964
+  languageName: node
+  linkType: hard
+
+"domain-browser@npm:^1.1.1":
+  version: 1.2.0
+  resolution: "domain-browser@npm:1.2.0"
+  checksum: 8f1235c7f49326fb762f4675795246a6295e7dd566b4697abec24afdba2460daa7dfbd1a73d31efbf5606b3b7deadb06ce47cf06f0a476e706153d62a4ff2b90
+  languageName: node
+  linkType: hard
+
+"domelementtype@npm:1":
+  version: 1.3.1
+  resolution: "domelementtype@npm:1.3.1"
+  checksum: 7893da40218ae2106ec6ffc146b17f203487a52f5228b032ea7aa470e41dfe03e1bd762d0ee0139e792195efda765434b04b43cddcf63207b098f6ae44b36ad6
+  languageName: node
+  linkType: hard
+
+"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0":
+  version: 2.2.0
+  resolution: "domelementtype@npm:2.2.0"
+  checksum: 24cb386198640cd58aa36f8c987f2ea61859929106d06ffcc8f547e70cb2ed82a6dc56dcb8252b21fba1f1ea07df6e4356d60bfe57f77114ca1aed6828362629
+  languageName: node
+  linkType: hard
+
+"domexception@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "domexception@npm:1.0.1"
+  dependencies:
+    webidl-conversions: ^4.0.2
+  checksum: f564a9c0915dcb83ceefea49df14aaed106b1468fbe505119e8bcb0b77e242534f3aba861978537c0fc9dc6f35b176d0ffc77b3e342820fb27a8f215e7ae4d52
+  languageName: node
+  linkType: hard
+
+"domhandler@npm:^4.0.0, domhandler@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "domhandler@npm:4.2.0"
+  dependencies:
+    domelementtype: ^2.2.0
+  checksum: 7921ac317d6899525a4e6a6038137307271522175a73db58233e13c7860987e15e86654583b2c0fd02fc46a602f9bd86fd2671af13b9068b72e8b229f07b3d03
+  languageName: node
+  linkType: hard
+
+"domhandler@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "domhandler@npm:4.3.1"
+  dependencies:
+    domelementtype: ^2.2.0
+  checksum: 4c665ceed016e1911bf7d1dadc09dc888090b64dee7851cccd2fcf5442747ec39c647bb1cb8c8919f8bbdd0f0c625a6bafeeed4b2d656bbecdbae893f43ffaaa
+  languageName: node
+  linkType: hard
+
+"dompurify@npm:^3.0.6":
+  version: 3.0.6
+  resolution: "dompurify@npm:3.0.6"
+  checksum: e5c6cdc5fe972a9d0859d939f1d86320de275be00bbef7bd5591c80b1e538935f6ce236624459a1b0c84ecd7c6a1e248684aa4637512659fccc0ce7c353828a6
+  languageName: node
+  linkType: hard
+
+"domutils@npm:^1.7.0":
+  version: 1.7.0
+  resolution: "domutils@npm:1.7.0"
+  dependencies:
+    dom-serializer: 0
+    domelementtype: 1
+  checksum: f60a725b1f73c1ae82f4894b691601ecc6ecb68320d87923ac3633137627c7865725af813ae5d188ad3954283853bcf46779eb50304ec5d5354044569fcefd2b
+  languageName: node
+  linkType: hard
+
+"domutils@npm:^2.5.2, domutils@npm:^2.7.0":
+  version: 2.7.0
+  resolution: "domutils@npm:2.7.0"
+  dependencies:
+    dom-serializer: ^1.0.1
+    domelementtype: ^2.2.0
+    domhandler: ^4.2.0
+  checksum: a4da0fcc4c54f6b338111caa11c672e18968d6280e7a1ed5e01b8b09b7dc0829ab5e03821349f5b57e34811f7e96e89b8dddbe06bb8e395cf117342424667b7d
+  languageName: node
+  linkType: hard
+
+"domutils@npm:^2.8.0":
+  version: 2.8.0
+  resolution: "domutils@npm:2.8.0"
+  dependencies:
+    dom-serializer: ^1.0.1
+    domelementtype: ^2.2.0
+    domhandler: ^4.2.0
+  checksum: abf7434315283e9aadc2a24bac0e00eab07ae4313b40cc239f89d84d7315ebdfd2fb1b5bf750a96bc1b4403d7237c7b2ebf60459be394d625ead4ca89b934391
+  languageName: node
+  linkType: hard
+
+"dot-case@npm:^3.0.4":
+  version: 3.0.4
+  resolution: "dot-case@npm:3.0.4"
+  dependencies:
+    no-case: ^3.0.4
+    tslib: ^2.0.3
+  checksum: a65e3519414856df0228b9f645332f974f2bf5433370f544a681122eab59e66038fc3349b4be1cdc47152779dac71a5864f1ccda2f745e767c46e9c6543b1169
+  languageName: node
+  linkType: hard
+
+"dot-prop@npm:^5.2.0":
+  version: 5.3.0
+  resolution: "dot-prop@npm:5.3.0"
+  dependencies:
+    is-obj: ^2.0.0
+  checksum: d5775790093c234ef4bfd5fbe40884ff7e6c87573e5339432870616331189f7f5d86575c5b5af2dcf0f61172990f4f734d07844b1f23482fff09e3c4bead05ea
+  languageName: node
+  linkType: hard
+
+"dotenv-expand@npm:5.1.0":
+  version: 5.1.0
+  resolution: "dotenv-expand@npm:5.1.0"
+  checksum: 8017675b7f254384915d55f9eb6388e577cf0a1231a28d54b0ca03b782be9501b0ac90ac57338636d395fa59051e6209e9b44b8ddf169ce6076dffb5dea227d3
+  languageName: node
+  linkType: hard
+
+"dotenv@npm:8.2.0":
+  version: 8.2.0
+  resolution: "dotenv@npm:8.2.0"
+  checksum: ad4c8e0df3e24b4811c8e93377d048a10a9b213dcd9f062483b4a2d3168f08f10ec9c618c23f5639060d230ccdb174c08761479e9baa29610aa978e1ee66df76
+  languageName: node
+  linkType: hard
+
+"draft-js-export-html@npm:>=0.6.0":
+  version: 1.4.1
+  resolution: "draft-js-export-html@npm:1.4.1"
+  dependencies:
+    draft-js-utils: ^1.4.0
+  peerDependencies:
+    draft-js: ">=0.10.0"
+    immutable: 3.x.x
+  checksum: 41a95522abcd3f12933ef2656fc25356f6d7e5e67689dc3ae50a3927cc129e17d45ac648fc20723f335a08f6e696de04a73db61013920692486a5f99398e3af1
+  languageName: node
+  linkType: hard
+
+"draft-js-export-markdown@npm:>=0.3.0":
+  version: 1.4.0
+  resolution: "draft-js-export-markdown@npm:1.4.0"
+  dependencies:
+    draft-js-utils: ^1.4.0
+  peerDependencies:
+    draft-js: ">=0.10.0"
+    immutable: 3.x.x
+  checksum: 6f50a1f2ae022c187a0866d87357125208d99ff485594e0e5586bab76aac3ff1bd8ded53f0c85c5b87d8d6120ae9340e229e7f905c76dfaa737feefc1981f566
+  languageName: node
+  linkType: hard
+
+"draft-js-import-element@npm:^1.4.0":
+  version: 1.4.0
+  resolution: "draft-js-import-element@npm:1.4.0"
+  dependencies:
+    draft-js-utils: ^1.4.0
+    synthetic-dom: ^1.4.0
+  peerDependencies:
+    draft-js: ">=0.10.0"
+    immutable: 3.x.x
+  checksum: 4ae41b4788775c11e191824475ada363a1634dab6bc5264e92fe27483a27cbe0ea460b4f8cff6c72507108edd3878c2edb67b5cd19e243ef71e401b0c412df0e
+  languageName: node
+  linkType: hard
+
+"draft-js-import-html@npm:>=0.4.0":
+  version: 1.4.1
+  resolution: "draft-js-import-html@npm:1.4.1"
+  dependencies:
+    draft-js-import-element: ^1.4.0
+  peerDependencies:
+    draft-js: ">=0.10.0"
+    immutable: 3.x.x
+  checksum: 35d4ddbdf1ef86184f78ffc95ec493ea172df1ace0f0ba030f4faaf2d1da5f499ed8f28f7e738282fa4d6b806468ec2656ec47512327b106afc187a653729b5e
+  languageName: node
+  linkType: hard
+
+"draft-js-import-markdown@npm:>=0.3.0":
+  version: 1.4.0
+  resolution: "draft-js-import-markdown@npm:1.4.0"
+  dependencies:
+    draft-js-import-element: ^1.4.0
+    synthetic-dom: ^1.4.0
+  peerDependencies:
+    draft-js: ">=0.10.0"
+    immutable: 3.x.x
+  checksum: ade20326cec36a402731e5c47aeb2c44eb4a6549e8276d21fe31ad87ec20363f70784a5dbe7c576c0b8dd5b06e771596489a6587b9fdd58c866037761ac27f11
+  languageName: node
+  linkType: hard
+
+"draft-js-utils@npm:>=0.2.0, draft-js-utils@npm:^1.4.0":
+  version: 1.4.0
+  resolution: "draft-js-utils@npm:1.4.0"
+  peerDependencies:
+    draft-js: ">=0.10.0"
+    immutable: 3.x.x
+  checksum: f05fb12cc614efab8597fc80c455ff0dd196ae63be6acbf0324be2920efa88492cf8374d91d18a5eb53e2445b726fc5df0168f2dddc695adfd42f284656f5871
+  languageName: node
+  linkType: hard
+
+"draft-js@npm:>=0.10.0":
+  version: 0.11.7
+  resolution: "draft-js@npm:0.11.7"
+  dependencies:
+    fbjs: ^2.0.0
+    immutable: ~3.7.4
+    object-assign: ^4.1.1
+  peerDependencies:
+    react: ">=0.14.0"
+    react-dom: ">=0.14.0"
+  checksum: b6d127a5e22838d3d43e736762c689c8d67dc632e9eb53b025c2d9504f892bcd0e05c8137f63051ae7b6ecb8b722c8f3923a13453583023e85b999cc0f6f93f7
+  languageName: node
+  linkType: hard
+
+"duplexer@npm:^0.1.1":
+  version: 0.1.2
+  resolution: "duplexer@npm:0.1.2"
+  checksum: 62ba61a830c56801db28ff6305c7d289b6dc9f859054e8c982abd8ee0b0a14d2e9a8e7d086ffee12e868d43e2bbe8a964be55ddbd8c8957714c87373c7a4f9b0
+  languageName: node
+  linkType: hard
+
+"duplexify@npm:^3.4.2, duplexify@npm:^3.6.0":
+  version: 3.7.1
+  resolution: "duplexify@npm:3.7.1"
+  dependencies:
+    end-of-stream: ^1.0.0
+    inherits: ^2.0.1
+    readable-stream: ^2.0.0
+    stream-shift: ^1.0.0
+  checksum: 3c2ed2223d956a5da713dae12ba8295acb61d9acd966ccbba938090d04f4574ca4dca75cca089b5077c2d7e66101f32e6ea9b36a78ca213eff574e7a8b8accf2
+  languageName: node
+  linkType: hard
+
+"ecc-jsbn@npm:~0.1.1":
+  version: 0.1.2
+  resolution: "ecc-jsbn@npm:0.1.2"
+  dependencies:
+    jsbn: ~0.1.0
+    safer-buffer: ^2.1.0
+  checksum: 22fef4b6203e5f31d425f5b711eb389e4c6c2723402e389af394f8411b76a488fa414d309d866e2b577ce3e8462d344205545c88a8143cc21752a5172818888a
+  languageName: node
+  linkType: hard
+
+"ee-first@npm:1.1.1":
+  version: 1.1.1
+  resolution: "ee-first@npm:1.1.1"
+  checksum: 1b4cac778d64ce3b582a7e26b218afe07e207a0f9bfe13cc7395a6d307849cfe361e65033c3251e00c27dd060cab43014c2d6b2647676135e18b77d2d05b3f4f
+  languageName: node
+  linkType: hard
+
+"electron-to-chromium@npm:^1.3.378":
+  version: 1.3.758
+  resolution: "electron-to-chromium@npm:1.3.758"
+  checksum: 2fec13dcdd1b24a2314d309566bd08c7f0ce383787e64ea43c14a7fc2a11c8a76fdb9a56ce7a1da6137e1ef46365f999d10c656f2fb6b9ff792ea3ae808ebb86
+  languageName: node
+  linkType: hard
+
+"electron-to-chromium@npm:^1.4.535":
+  version: 1.4.540
+  resolution: "electron-to-chromium@npm:1.4.540"
+  checksum: 78a48690a5cca3f89544d4e33a11e3101adb0b220da64078f67e167b396cbcd85044853cb88a9453444796599fe157c190ca5ebd00e9daf668ed5a9df3d0bba8
+  languageName: node
+  linkType: hard
+
+"electron-to-chromium@npm:^1.4.668":
+  version: 1.4.729
+  resolution: "electron-to-chromium@npm:1.4.729"
+  checksum: fc7d28957d2aa72c57220e8b60e86f523d782a413440d2a8f38563844343b62e6caee9bf866019ba0839eb6e0c247297c6057d86152fa45855f32da88c44bd90
+  languageName: node
+  linkType: hard
+
+"elliptic@npm:6.5.4, elliptic@npm:^6.5.3":
+  version: 6.5.4
+  resolution: "elliptic@npm:6.5.4"
+  dependencies:
+    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
+  checksum: d56d21fd04e97869f7ffcc92e18903b9f67f2d4637a23c860492fbbff5a3155fd9ca0184ce0c865dd6eb2487d234ce9551335c021c376cd2d3b7cb749c7d10f4
+  languageName: node
+  linkType: hard
+
+"elliptic@npm:^6.5.5":
+  version: 6.5.5
+  resolution: "elliptic@npm:6.5.5"
+  dependencies:
+    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
+  checksum: ec9105e4469eb3b32b0ee2579756c888ddf3f99d259aa0d65fccb906ee877768aaf8880caae73e3e669c9a4adeb3eb1945703aa974ec5000d2d33a239f4567eb
+  languageName: node
+  linkType: hard
+
+"emoji-regex@npm:^7.0.1, emoji-regex@npm:^7.0.2":
+  version: 7.0.3
+  resolution: "emoji-regex@npm:7.0.3"
+  checksum: 9159b2228b1511f2870ac5920f394c7e041715429a68459ebe531601555f11ea782a8e1718f969df2711d38c66268174407cbca57ce36485544f695c2dfdc96e
+  languageName: node
+  linkType: hard
+
+"emoji-regex@npm:^8.0.0":
+  version: 8.0.0
+  resolution: "emoji-regex@npm:8.0.0"
+  checksum: d4c5c39d5a9868b5fa152f00cada8a936868fd3367f33f71be515ecee4c803132d11b31a6222b2571b1e5f7e13890156a94880345594d0ce7e3c9895f560f192
+  languageName: node
+  linkType: hard
+
+"emojis-list@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "emojis-list@npm:2.1.0"
+  checksum: fb61fa6356dfcc9fbe6db8e334c29da365a34d3d82a915cb59621883d3023d804fd5edad5acd42b8eec016936e81d3b38e2faf921b32e073758374253afe1272
+  languageName: node
+  linkType: hard
+
+"emojis-list@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "emojis-list@npm:3.0.0"
+  checksum: ddaaa02542e1e9436c03970eeed445f4ed29a5337dfba0fe0c38dfdd2af5da2429c2a0821304e8a8d1cadf27fdd5b22ff793571fa803ae16852a6975c65e8e70
+  languageName: node
+  linkType: hard
+
+"encodeurl@npm:~1.0.2":
+  version: 1.0.2
+  resolution: "encodeurl@npm:1.0.2"
+  checksum: e50e3d508cdd9c4565ba72d2012e65038e5d71bdc9198cb125beb6237b5b1ade6c0d343998da9e170fb2eae52c1bed37d4d6d98a46ea423a0cddbed5ac3f780c
+  languageName: node
+  linkType: hard
+
+"encoding@npm:^0.1.11, encoding@npm:^0.1.12, encoding@npm:^0.1.13":
+  version: 0.1.13
+  resolution: "encoding@npm:0.1.13"
+  dependencies:
+    iconv-lite: ^0.6.2
+  checksum: bb98632f8ffa823996e508ce6a58ffcf5856330fde839ae42c9e1f436cc3b5cc651d4aeae72222916545428e54fd0f6aa8862fd8d25bdbcc4589f1e3f3715e7f
+  languageName: node
+  linkType: hard
+
+"end-of-stream@npm:^1.0.0, end-of-stream@npm:^1.1.0":
+  version: 1.4.4
+  resolution: "end-of-stream@npm:1.4.4"
+  dependencies:
+    once: ^1.4.0
+  checksum: 530a5a5a1e517e962854a31693dbb5c0b2fc40b46dad2a56a2deec656ca040631124f4795823acc68238147805f8b021abbe221f4afed5ef3c8e8efc2024908b
+  languageName: node
+  linkType: hard
+
+"enhanced-resolve@npm:^4.1.0":
+  version: 4.5.0
+  resolution: "enhanced-resolve@npm:4.5.0"
+  dependencies:
+    graceful-fs: ^4.1.2
+    memory-fs: ^0.5.0
+    tapable: ^1.0.0
+  checksum: 4d87488584c4d67d356ef4ba04978af4b2d4d18190cb859efac8e8475a34d5d6c069df33faa5a0a22920b0586dbf330f6a08d52bb15a8771a9ce4d70a2da74ba
+  languageName: node
+  linkType: hard
+
+"enquirer@npm:^2.3.6":
+  version: 2.4.1
+  resolution: "enquirer@npm:2.4.1"
+  dependencies:
+    ansi-colors: ^4.1.1
+    strip-ansi: ^6.0.1
+  checksum: f080f11a74209647dbf347a7c6a83c8a47ae1ebf1e75073a808bc1088eb780aa54075bfecd1bcdb3e3c724520edb8e6ee05da031529436b421b71066fcc48cb5
+  languageName: node
+  linkType: hard
+
+"entities@npm:^2.0.0":
+  version: 2.2.0
+  resolution: "entities@npm:2.2.0"
+  checksum: 19010dacaf0912c895ea262b4f6128574f9ccf8d4b3b65c7e8334ad0079b3706376360e28d8843ff50a78aabcb8f08f0a32dbfacdc77e47ed77ca08b713669b3
+  languageName: node
+  linkType: hard
+
+"env-paths@npm:^2.2.0":
+  version: 2.2.1
+  resolution: "env-paths@npm:2.2.1"
+  checksum: 65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e
+  languageName: node
+  linkType: hard
+
+"enzyme-adapter-react-16@npm:1.15.6":
+  version: 1.15.6
+  resolution: "enzyme-adapter-react-16@npm:1.15.6"
+  dependencies:
+    enzyme-adapter-utils: ^1.14.0
+    enzyme-shallow-equal: ^1.0.4
+    has: ^1.0.3
+    object.assign: ^4.1.2
+    object.values: ^1.1.2
+    prop-types: ^15.7.2
+    react-is: ^16.13.1
+    react-test-renderer: ^16.0.0-0
+    semver: ^5.7.0
+  peerDependencies:
+    enzyme: ^3.0.0
+    react: ^16.0.0-0
+    react-dom: ^16.0.0-0
+  checksum: b0f31037c7595558d504c060e19db542723789a41e0598b97345b89855cb03ac86a706440106ef5d4a6c95431e455ea0cad58ca5b287bdb771915b5c6210da84
+  languageName: node
+  linkType: hard
+
+"enzyme-adapter-utils@npm:^1.14.0":
+  version: 1.14.0
+  resolution: "enzyme-adapter-utils@npm:1.14.0"
+  dependencies:
+    airbnb-prop-types: ^2.16.0
+    function.prototype.name: ^1.1.3
+    has: ^1.0.3
+    object.assign: ^4.1.2
+    object.fromentries: ^2.0.3
+    prop-types: ^15.7.2
+    semver: ^5.7.1
+  peerDependencies:
+    react: 0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0
+  checksum: a96a0a1bdf66417ff751e465c33733f58127b043013ec288429bc9113defa4f8ac23d806be4f3cf399cf23401cd3fdd88383ea146bc1d8f1e4258ecf35611c62
+  languageName: node
+  linkType: hard
+
+"enzyme-shallow-equal@npm:^1.0.1, enzyme-shallow-equal@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "enzyme-shallow-equal@npm:1.0.4"
+  dependencies:
+    has: ^1.0.3
+    object-is: ^1.1.2
+  checksum: 54bbad0955683f09252568bfcb9d7e934a27c06634057db9e82b54c0d9f7a27b6160d77643177d973c133b87d404f284cc6aa0481c0a1c81cdff05b072e2bb49
+  languageName: node
+  linkType: hard
+
+"enzyme@npm:3.11.0":
+  version: 3.11.0
+  resolution: "enzyme@npm:3.11.0"
+  dependencies:
+    array.prototype.flat: ^1.2.3
+    cheerio: ^1.0.0-rc.3
+    enzyme-shallow-equal: ^1.0.1
+    function.prototype.name: ^1.1.2
+    has: ^1.0.3
+    html-element-map: ^1.2.0
+    is-boolean-object: ^1.0.1
+    is-callable: ^1.1.5
+    is-number-object: ^1.0.4
+    is-regex: ^1.0.5
+    is-string: ^1.0.5
+    is-subset: ^0.1.1
+    lodash.escape: ^4.0.1
+    lodash.isequal: ^4.5.0
+    object-inspect: ^1.7.0
+    object-is: ^1.0.2
+    object.assign: ^4.1.0
+    object.entries: ^1.1.1
+    object.values: ^1.1.1
+    raf: ^3.4.1
+    rst-selector-parser: ^2.2.3
+    string.prototype.trim: ^1.2.1
+  checksum: 69ae80049c3f405122b8e619f1cf8b04f32b3cc2b6134c29ed8c0f05e87a0b15080f1121096ec211954a710f4787300af9157078c863012de87eee16e98e64ea
+  languageName: node
+  linkType: hard
+
+"err-code@npm:^2.0.2":
+  version: 2.0.3
+  resolution: "err-code@npm:2.0.3"
+  checksum: 8b7b1be20d2de12d2255c0bc2ca638b7af5171142693299416e6a9339bd7d88fc8d7707d913d78e0993176005405a236b066b45666b27b797252c771156ace54
+  languageName: node
+  linkType: hard
+
+"errno@npm:^0.1.3, errno@npm:~0.1.7":
+  version: 0.1.8
+  resolution: "errno@npm:0.1.8"
+  dependencies:
+    prr: ~1.0.1
+  bin:
+    errno: cli.js
+  checksum: 1271f7b9fbb3bcbec76ffde932485d1e3561856d21d847ec613a9722ee924cdd4e523a62dc71a44174d91e898fe21fdc8d5b50823f4b5e0ce8c35c8271e6ef4a
+  languageName: node
+  linkType: hard
+
+"error-ex@npm:^1.2.0, error-ex@npm:^1.3.1":
+  version: 1.3.2
+  resolution: "error-ex@npm:1.3.2"
+  dependencies:
+    is-arrayish: ^0.2.1
+  checksum: c1c2b8b65f9c91b0f9d75f0debaa7ec5b35c266c2cac5de412c1a6de86d4cbae04ae44e510378cb14d032d0645a36925d0186f8bb7367bcc629db256b743a001
+  languageName: node
+  linkType: hard
+
+"es-abstract@npm:^1.17.2, es-abstract@npm:^1.17.4, es-abstract@npm:^1.18.0, es-abstract@npm:^1.18.0-next.1, es-abstract@npm:^1.18.0-next.2, es-abstract@npm:^1.18.2":
+  version: 1.18.3
+  resolution: "es-abstract@npm:1.18.3"
+  dependencies:
+    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
+  checksum: 6bbf526b5a60cdbd390397644facbf654fc6616564614533a5ce223ecc185f7812a1f45c3ab6d0334b4ff2e8f554237539f4d05a0fceb036be24dd5d1ec022b0
+  languageName: node
+  linkType: hard
+
+"es-array-method-boxes-properly@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "es-array-method-boxes-properly@npm:1.0.0"
+  checksum: 2537fcd1cecf187083890bc6f5236d3a26bf39237433587e5bf63392e88faae929dbba78ff0120681a3f6f81c23fe3816122982c160d63b38c95c830b633b826
+  languageName: node
+  linkType: hard
+
+"es-to-primitive@npm:^1.2.1":
+  version: 1.2.1
+  resolution: "es-to-primitive@npm:1.2.1"
+  dependencies:
+    is-callable: ^1.1.4
+    is-date-object: ^1.0.1
+    is-symbol: ^1.0.2
+  checksum: 4ead6671a2c1402619bdd77f3503991232ca15e17e46222b0a41a5d81aebc8740a77822f5b3c965008e631153e9ef0580540007744521e72de8e33599fca2eed
+  languageName: node
+  linkType: hard
+
+"es5-ext@npm:^0.10.35, es5-ext@npm:^0.10.50, es5-ext@npm:^0.10.62, es5-ext@npm:^0.10.64, es5-ext@npm:~0.10.14":
+  version: 0.10.64
+  resolution: "es5-ext@npm:0.10.64"
+  dependencies:
+    es6-iterator: ^2.0.3
+    es6-symbol: ^3.1.3
+    esniff: ^2.0.1
+    next-tick: ^1.1.0
+  checksum: 01179fab0769fdbef213062222f99d0346724dbaccf04b87c0e6ee7f0c97edabf14be647ca1321f0497425ea7145de0fd278d1b3f3478864b8933e7136a5c645
+  languageName: node
+  linkType: hard
+
+"es6-error@npm:^4.1.1":
+  version: 4.1.1
+  resolution: "es6-error@npm:4.1.1"
+  checksum: ae41332a51ec1323da6bbc5d75b7803ccdeddfae17c41b6166ebbafc8e8beb7a7b80b884b7fab1cc80df485860ac3c59d78605e860bb4f8cd816b3d6ade0d010
+  languageName: node
+  linkType: hard
+
+"es6-iterator@npm:2.0.3, es6-iterator@npm:^2.0.3":
+  version: 2.0.3
+  resolution: "es6-iterator@npm:2.0.3"
+  dependencies:
+    d: 1
+    es5-ext: ^0.10.35
+    es6-symbol: ^3.1.1
+  checksum: 6e48b1c2d962c21dee604b3d9f0bc3889f11ed5a8b33689155a2065d20e3107e2a69cc63a71bd125aeee3a589182f8bbcb5c8a05b6a8f38fa4205671b6d09697
+  languageName: node
+  linkType: hard
+
+"es6-symbol@npm:^3.1.1":
+  version: 3.1.3
+  resolution: "es6-symbol@npm:3.1.3"
+  dependencies:
+    d: ^1.0.1
+    ext: ^1.1.2
+  checksum: cd49722c2a70f011eb02143ef1c8c70658d2660dead6641e160b94619f408b9cf66425515787ffe338affdf0285ad54f4eae30ea5bd510e33f8659ec53bcaa70
+  languageName: node
+  linkType: hard
+
+"es6-symbol@npm:^3.1.3":
+  version: 3.1.4
+  resolution: "es6-symbol@npm:3.1.4"
+  dependencies:
+    d: ^1.0.2
+    ext: ^1.7.0
+  checksum: 52125ec4b5d1b6b93b8d3d42830bb19f8da21080ffcf45253b614bc6ff3e31349be202fb745d4d1af6778cdf5e38fea30e0c7e7dc37e2aecd44acc43502055f9
+  languageName: node
+  linkType: hard
+
+"escalade@npm:^3.1.1":
+  version: 3.1.1
+  resolution: "escalade@npm:3.1.1"
+  checksum: a3e2a99f07acb74b3ad4989c48ca0c3140f69f923e56d0cba0526240ee470b91010f9d39001f2a4a313841d237ede70a729e92125191ba5d21e74b106800b133
+  languageName: node
+  linkType: hard
+
+"escape-html@npm:~1.0.3":
+  version: 1.0.3
+  resolution: "escape-html@npm:1.0.3"
+  checksum: 6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24
+  languageName: node
+  linkType: hard
+
+"escape-string-regexp@npm:2.0.0, escape-string-regexp@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "escape-string-regexp@npm:2.0.0"
+  checksum: 9f8a2d5743677c16e85c810e3024d54f0c8dea6424fad3c79ef6666e81dd0846f7437f5e729dfcdac8981bc9e5294c39b4580814d114076b8d36318f46ae4395
+  languageName: node
+  linkType: hard
+
+"escape-string-regexp@npm:^1.0.2, escape-string-regexp@npm:^1.0.5":
+  version: 1.0.5
+  resolution: "escape-string-regexp@npm:1.0.5"
+  checksum: 6092fda75c63b110c706b6a9bfde8a612ad595b628f0bd2147eea1d3406723020810e591effc7db1da91d80a71a737a313567c5abb3813e8d9c71f4aa595b410
+  languageName: node
+  linkType: hard
+
+"escodegen@npm:^1.11.0, escodegen@npm:^1.9.1":
+  version: 1.14.3
+  resolution: "escodegen@npm:1.14.3"
+  dependencies:
+    esprima: ^4.0.1
+    estraverse: ^4.2.0
+    esutils: ^2.0.2
+    optionator: ^0.8.1
+    source-map: ~0.6.1
+  dependenciesMeta:
+    source-map:
+      optional: true
+  bin:
+    escodegen: bin/escodegen.js
+    esgenerate: bin/esgenerate.js
+  checksum: 381cdc4767ecdb221206bbbab021b467bbc2a6f5c9a99c9e6353040080bdd3dfe73d7604ad89a47aca6ea7d58bc635f6bd3fbc8da9a1998e9ddfa8372362ccd0
+  languageName: node
+  linkType: hard
+
+"eslint-config-react-app@npm:^5.2.1":
+  version: 5.2.1
+  resolution: "eslint-config-react-app@npm:5.2.1"
+  dependencies:
+    confusing-browser-globals: ^1.0.9
+  peerDependencies:
+    "@typescript-eslint/eslint-plugin": 2.x
+    "@typescript-eslint/parser": 2.x
+    babel-eslint: 10.x
+    eslint: 6.x
+    eslint-plugin-flowtype: 3.x || 4.x
+    eslint-plugin-import: 2.x
+    eslint-plugin-jsx-a11y: 6.x
+    eslint-plugin-react: 7.x
+    eslint-plugin-react-hooks: 1.x || 2.x
+  checksum: 8af6801f29d7314611e111a1593e91d412d41cde6719303ee6db7de65d78ed4b53e9197497765bb2deed65e6bfd73bf7e74da58cab3f66838c2927880b21eeba
+  languageName: node
+  linkType: hard
+
+"eslint-import-resolver-node@npm:^0.3.2":
+  version: 0.3.4
+  resolution: "eslint-import-resolver-node@npm:0.3.4"
+  dependencies:
+    debug: ^2.6.9
+    resolve: ^1.13.1
+  checksum: a0db55ec26c5bb385c8681af6b8d6dee16768d5f27dff72c3113407d0f028f28e56dcb1cc3a4689c79396a5f6a9c24bd0cac9a2c9c588c7d7357d24a42bec876
+  languageName: node
+  linkType: hard
+
+"eslint-loader@npm:3.0.3":
+  version: 3.0.3
+  resolution: "eslint-loader@npm:3.0.3"
+  dependencies:
+    fs-extra: ^8.1.0
+    loader-fs-cache: ^1.0.2
+    loader-utils: ^1.2.3
+    object-hash: ^2.0.1
+    schema-utils: ^2.6.1
+  peerDependencies:
+    eslint: ^5.0.0 || ^6.0.0
+    webpack: ^4.0.0 || ^5.0.0
+  checksum: 5151ec134e26fb7caa20c4e7cf443e4400a48092008f8adebb7fef8ad4a6301d8f19a9f9c5aa78fec65b2d2594037edaef4ceae4769230cd7912566cd4ec7970
+  languageName: node
+  linkType: hard
+
+"eslint-module-utils@npm:^2.4.1":
+  version: 2.6.1
+  resolution: "eslint-module-utils@npm:2.6.1"
+  dependencies:
+    debug: ^3.2.7
+    pkg-dir: ^2.0.0
+  checksum: 3cc43a36a0075d300db6a3946203ec92249b6da1539694ef205a43b4ccfbc2eaf4961475d4b89c24b12c187d6bfd882c7c7d0b2ce02adb40c2dedb7fd022a7e2
+  languageName: node
+  linkType: hard
+
+"eslint-plugin-flowtype@npm:4.6.0":
+  version: 4.6.0
+  resolution: "eslint-plugin-flowtype@npm:4.6.0"
+  dependencies:
+    lodash: ^4.17.15
+  peerDependencies:
+    eslint: ">=6.1.0"
+  checksum: 2256be93a2b49b9440defea2823349b0d3c428437c1e8868fb02d7f21a5d7ab48e53d8afb05fea713da2c3ee8c0d3dbe6e8c9efc798a86aebdb0cffc0f9dfc7a
+  languageName: node
+  linkType: hard
+
+"eslint-plugin-import@npm:2.20.1":
+  version: 2.20.1
+  resolution: "eslint-plugin-import@npm:2.20.1"
+  dependencies:
+    array-includes: ^3.0.3
+    array.prototype.flat: ^1.2.1
+    contains-path: ^0.1.0
+    debug: ^2.6.9
+    doctrine: 1.5.0
+    eslint-import-resolver-node: ^0.3.2
+    eslint-module-utils: ^2.4.1
+    has: ^1.0.3
+    minimatch: ^3.0.4
+    object.values: ^1.1.0
+    read-pkg-up: ^2.0.0
+    resolve: ^1.12.0
+  peerDependencies:
+    eslint: 2.x - 6.x
+  checksum: 9c2f06fb5907afa12023df8b7b881ee345b11ba0930891966de5d009932bce4af7d1a8749037e15435d189796b26ba7190456d90a4ee7a8ce661e021a8b833d4
+  languageName: node
+  linkType: hard
+
+"eslint-plugin-jsx-a11y@npm:6.2.3":
+  version: 6.2.3
+  resolution: "eslint-plugin-jsx-a11y@npm:6.2.3"
+  dependencies:
+    "@babel/runtime": ^7.4.5
+    aria-query: ^3.0.0
+    array-includes: ^3.0.3
+    ast-types-flow: ^0.0.7
+    axobject-query: ^2.0.2
+    damerau-levenshtein: ^1.0.4
+    emoji-regex: ^7.0.2
+    has: ^1.0.3
+    jsx-ast-utils: ^2.2.1
+  peerDependencies:
+    eslint: ^3 || ^4 || ^5 || ^6
+  checksum: 2e9f0ff28567e141479968a860f5670009a403250054970c714bf723e1f8c9ae7cddeb2bf13ee9f6882af333588645a06c10a417aa2733084813d162dec6c235
+  languageName: node
+  linkType: hard
+
+"eslint-plugin-react-hooks@npm:^1.6.1":
+  version: 1.7.0
+  resolution: "eslint-plugin-react-hooks@npm:1.7.0"
+  peerDependencies:
+    eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
+  checksum: ea16d0cf4aaa0a30db05860bd892f3432a4cc299983a5adece092c021c4ee359e7e7277ff1e7207d1f550fae5f08a8b3aa2698f36e82cdebb756a8a3e1c842eb
+  languageName: node
+  linkType: hard
+
+"eslint-plugin-react@npm:7.19.0":
+  version: 7.19.0
+  resolution: "eslint-plugin-react@npm:7.19.0"
+  dependencies:
+    array-includes: ^3.1.1
+    doctrine: ^2.1.0
+    has: ^1.0.3
+    jsx-ast-utils: ^2.2.3
+    object.entries: ^1.1.1
+    object.fromentries: ^2.0.2
+    object.values: ^1.1.1
+    prop-types: ^15.7.2
+    resolve: ^1.15.1
+    semver: ^6.3.0
+    string.prototype.matchall: ^4.0.2
+    xregexp: ^4.3.0
+  peerDependencies:
+    eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
+  checksum: c15bc7aa27670ceb1410e9c703072c13d8cd6117af92fcdffcc531bbaf4e4ae4266f52583779178a71c7f917860ac7cce4677e4dd428d3d1f9883fe3a819e81e
+  languageName: node
+  linkType: hard
+
+"eslint-scope@npm:^4.0.3":
+  version: 4.0.3
+  resolution: "eslint-scope@npm:4.0.3"
+  dependencies:
+    esrecurse: ^4.1.0
+    estraverse: ^4.1.1
+  checksum: c5f835f681884469991fe58d76a554688d9c9e50811299ccd4a8f79993a039f5bcb0ee6e8de2b0017d97c794b5832ef3b21c9aac66228e3aa0f7a0485bcfb65b
+  languageName: node
+  linkType: hard
+
+"eslint-scope@npm:^5.0.0, eslint-scope@npm:^5.1.1":
+  version: 5.1.1
+  resolution: "eslint-scope@npm:5.1.1"
+  dependencies:
+    esrecurse: ^4.3.0
+    estraverse: ^4.1.1
+  checksum: 47e4b6a3f0cc29c7feedee6c67b225a2da7e155802c6ea13bbef4ac6b9e10c66cd2dcb987867ef176292bf4e64eccc680a49e35e9e9c669f4a02bac17e86abdb
+  languageName: node
+  linkType: hard
+
+"eslint-utils@npm:^1.4.3":
+  version: 1.4.3
+  resolution: "eslint-utils@npm:1.4.3"
+  dependencies:
+    eslint-visitor-keys: ^1.1.0
+  checksum: a20630e686034107138272f245c460f6d77705d1f4bb0628c1a1faf59fc800f441188916b3ec3b957394dc405aa200a3017dfa2b0fff0976e307a4e645a18d1e
+  languageName: node
+  linkType: hard
+
+"eslint-utils@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "eslint-utils@npm:3.0.0"
+  dependencies:
+    eslint-visitor-keys: ^2.0.0
+  peerDependencies:
+    eslint: ">=5"
+  checksum: 0668fe02f5adab2e5a367eee5089f4c39033af20499df88fe4e6aba2015c20720404d8c3d6349b6f716b08fdf91b9da4e5d5481f265049278099c4c836ccb619
+  languageName: node
+  linkType: hard
+
+"eslint-visitor-keys@npm:^1.0.0, eslint-visitor-keys@npm:^1.1.0":
+  version: 1.3.0
+  resolution: "eslint-visitor-keys@npm:1.3.0"
+  checksum: 37a19b712f42f4c9027e8ba98c2b06031c17e0c0a4c696cd429bd9ee04eb43889c446f2cd545e1ff51bef9593fcec94ecd2c2ef89129fcbbf3adadbef520376a
+  languageName: node
+  linkType: hard
+
+"eslint-visitor-keys@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "eslint-visitor-keys@npm:2.1.0"
+  checksum: e3081d7dd2611a35f0388bbdc2f5da60b3a3c5b8b6e928daffff7391146b434d691577aa95064c8b7faad0b8a680266bcda0a42439c18c717b80e6718d7e267d
+  languageName: node
+  linkType: hard
+
+"eslint@npm:^6.6.0":
+  version: 6.8.0
+  resolution: "eslint@npm:6.8.0"
+  dependencies:
+    "@babel/code-frame": ^7.0.0
+    ajv: ^6.10.0
+    chalk: ^2.1.0
+    cross-spawn: ^6.0.5
+    debug: ^4.0.1
+    doctrine: ^3.0.0
+    eslint-scope: ^5.0.0
+    eslint-utils: ^1.4.3
+    eslint-visitor-keys: ^1.1.0
+    espree: ^6.1.2
+    esquery: ^1.0.1
+    esutils: ^2.0.2
+    file-entry-cache: ^5.0.1
+    functional-red-black-tree: ^1.0.1
+    glob-parent: ^5.0.0
+    globals: ^12.1.0
+    ignore: ^4.0.6
+    import-fresh: ^3.0.0
+    imurmurhash: ^0.1.4
+    inquirer: ^7.0.0
+    is-glob: ^4.0.0
+    js-yaml: ^3.13.1
+    json-stable-stringify-without-jsonify: ^1.0.1
+    levn: ^0.3.0
+    lodash: ^4.17.14
+    minimatch: ^3.0.4
+    mkdirp: ^0.5.1
+    natural-compare: ^1.4.0
+    optionator: ^0.8.3
+    progress: ^2.0.0
+    regexpp: ^2.0.1
+    semver: ^6.1.2
+    strip-ansi: ^5.2.0
+    strip-json-comments: ^3.0.1
+    table: ^5.2.3
+    text-table: ^0.2.0
+    v8-compile-cache: ^2.0.3
+  bin:
+    eslint: ./bin/eslint.js
+  checksum: d4edbe69589ef194e7d3470a18632560c5399a5f685295bd59a11cddba4c6f7e03a137a15a21389f8f85712ebd82d0a628ee4e9cd4391113556029c486616e25
+  languageName: node
+  linkType: hard
+
+"esniff@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "esniff@npm:2.0.1"
+  dependencies:
+    d: ^1.0.1
+    es5-ext: ^0.10.62
+    event-emitter: ^0.3.5
+    type: ^2.7.2
+  checksum: d814c0e5c39bce9925b2e65b6d8767af72c9b54f35a65f9f3d6e8c606dce9aebe35a9599d30f15b0807743f88689f445163cfb577a425de4fb8c3c5bc16710cc
+  languageName: node
+  linkType: hard
+
+"espree@npm:^6.1.2":
+  version: 6.2.1
+  resolution: "espree@npm:6.2.1"
+  dependencies:
+    acorn: ^7.1.1
+    acorn-jsx: ^5.2.0
+    eslint-visitor-keys: ^1.1.0
+  checksum: 99c508950b5b9f53d008d781d2abb7a4ef3496ea699306fb6eb737c7e513aa594644314364c50ec27abb220124c6851fff64a6b62c358479534369904849360b
+  languageName: node
+  linkType: hard
+
+"esprima@npm:^4.0.0, esprima@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "esprima@npm:4.0.1"
+  bin:
+    esparse: ./bin/esparse.js
+    esvalidate: ./bin/esvalidate.js
+  checksum: b45bc805a613dbea2835278c306b91aff6173c8d034223fa81498c77dcbce3b2931bf6006db816f62eacd9fd4ea975dfd85a5b7f3c6402cfd050d4ca3c13a628
+  languageName: node
+  linkType: hard
+
+"esquery@npm:^1.0.1":
+  version: 1.4.0
+  resolution: "esquery@npm:1.4.0"
+  dependencies:
+    estraverse: ^5.1.0
+  checksum: a0807e17abd7fbe5fbd4fab673038d6d8a50675cdae6b04fbaa520c34581be0c5fa24582990e8acd8854f671dd291c78bb2efb9e0ed5b62f33bac4f9cf820210
+  languageName: node
+  linkType: hard
+
+"esrecurse@npm:^4.1.0, esrecurse@npm:^4.3.0":
+  version: 4.3.0
+  resolution: "esrecurse@npm:4.3.0"
+  dependencies:
+    estraverse: ^5.2.0
+  checksum: ebc17b1a33c51cef46fdc28b958994b1dc43cd2e86237515cbc3b4e5d2be6a811b2315d0a1a4d9d340b6d2308b15322f5c8291059521cc5f4802f65e7ec32837
+  languageName: node
+  linkType: hard
+
+"estraverse@npm:^4.1.1, estraverse@npm:^4.2.0":
+  version: 4.3.0
+  resolution: "estraverse@npm:4.3.0"
+  checksum: a6299491f9940bb246124a8d44b7b7a413a8336f5436f9837aaa9330209bd9ee8af7e91a654a3545aee9c54b3308e78ee360cef1d777d37cfef77d2fa33b5827
+  languageName: node
+  linkType: hard
+
+"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0":
+  version: 5.2.0
+  resolution: "estraverse@npm:5.2.0"
+  checksum: ec11b70d946bf5d7f76f91db38ef6f08109ac1b36cda293a26e678e58df4719f57f67b9ec87042afdd1f0267cee91865be3aa48d2161765a93defab5431be7b8
+  languageName: node
+  linkType: hard
+
+"esutils@npm:^2.0.2":
+  version: 2.0.3
+  resolution: "esutils@npm:2.0.3"
+  checksum: 22b5b08f74737379a840b8ed2036a5fb35826c709ab000683b092d9054e5c2a82c27818f12604bfc2a9a76b90b6834ef081edbc1c7ae30d1627012e067c6ec87
+  languageName: node
+  linkType: hard
+
+"etag@npm:~1.8.1":
+  version: 1.8.1
+  resolution: "etag@npm:1.8.1"
+  checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff
+  languageName: node
+  linkType: hard
+
+"event-emitter@npm:^0.3.5":
+  version: 0.3.5
+  resolution: "event-emitter@npm:0.3.5"
+  dependencies:
+    d: 1
+    es5-ext: ~0.10.14
+  checksum: 27c1399557d9cd7e0aa0b366c37c38a4c17293e3a10258e8b692a847dd5ba9fb90429c3a5a1eeff96f31f6fa03ccbd31d8ad15e00540b22b22f01557be706030
+  languageName: node
+  linkType: hard
+
+"eventemitter2@npm:6.4.7":
+  version: 6.4.7
+  resolution: "eventemitter2@npm:6.4.7"
+  checksum: 1b36a77e139d6965ebf3a36c01fa00c089ae6b80faa1911e52888f40b3a7057b36a2cc45dcd1ad87cda3798fe7b97a0aabcbb8175a8b96092a23bb7d0f039e66
+  languageName: node
+  linkType: hard
+
+"eventemitter3@npm:^4.0.0":
+  version: 4.0.7
+  resolution: "eventemitter3@npm:4.0.7"
+  checksum: 1875311c42fcfe9c707b2712c32664a245629b42bb0a5a84439762dd0fd637fc54d078155ea83c2af9e0323c9ac13687e03cfba79b03af9f40c89b4960099374
+  languageName: node
+  linkType: hard
+
+"events@npm:^3.0.0":
+  version: 3.3.0
+  resolution: "events@npm:3.3.0"
+  checksum: f6f487ad2198aa41d878fa31452f1a3c00958f46e9019286ff4787c84aac329332ab45c9cdc8c445928fc6d7ded294b9e005a7fce9426488518017831b272780
+  languageName: node
+  linkType: hard
+
+"eventsource@npm:^1.0.7":
+  version: 1.1.2
+  resolution: "eventsource@npm:1.1.2"
+  checksum: fe8f2ac3c70b1b63ee3cef5c0a28680cb00b5747bfda1d9835695fab3ed602be41c5c799b1fc997b34b02633573fead25b12b036bdf5212f23a6aa9f59212e9b
+  languageName: node
+  linkType: hard
+
+"evp_bytestokey@npm:^1.0.0, evp_bytestokey@npm:^1.0.3":
+  version: 1.0.3
+  resolution: "evp_bytestokey@npm:1.0.3"
+  dependencies:
+    md5.js: ^1.3.4
+    node-gyp: latest
+    safe-buffer: ^5.1.1
+  checksum: ad4e1577f1a6b721c7800dcc7c733fe01f6c310732bb5bf2240245c2a5b45a38518b91d8be2c610611623160b9d1c0e91f1ce96d639f8b53e8894625cf20fa45
+  languageName: node
+  linkType: hard
+
+"exec-sh@npm:^0.3.2":
+  version: 0.3.6
+  resolution: "exec-sh@npm:0.3.6"
+  checksum: 0be4f06929c8e4834ea4812f29fe59e2dfcc1bc3fc4b4bb71acb38a500c3b394628a05ef7ba432520bc6c5ec4fadab00cc9c513c4ff6a32104965af302e998e0
+  languageName: node
+  linkType: hard
+
+"execa@npm:4.1.0":
+  version: 4.1.0
+  resolution: "execa@npm:4.1.0"
+  dependencies:
+    cross-spawn: ^7.0.0
+    get-stream: ^5.0.0
+    human-signals: ^1.1.1
+    is-stream: ^2.0.0
+    merge-stream: ^2.0.0
+    npm-run-path: ^4.0.0
+    onetime: ^5.1.0
+    signal-exit: ^3.0.2
+    strip-final-newline: ^2.0.0
+  checksum: e30d298934d9c52f90f3847704fd8224e849a081ab2b517bbc02f5f7732c24e56a21f14cb96a08256deffeb2d12b2b7cb7e2b014a12fb36f8d3357e06417ed55
+  languageName: node
+  linkType: hard
+
+"execa@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "execa@npm:1.0.0"
+  dependencies:
+    cross-spawn: ^6.0.0
+    get-stream: ^4.0.0
+    is-stream: ^1.1.0
+    npm-run-path: ^2.0.0
+    p-finally: ^1.0.0
+    signal-exit: ^3.0.0
+    strip-eof: ^1.0.0
+  checksum: ddf1342c1c7d02dd93b41364cd847640f6163350d9439071abf70bf4ceb1b9b2b2e37f54babb1d8dc1df8e0d8def32d0e81e74a2e62c3e1d70c303eb4c306bc4
+  languageName: node
+  linkType: hard
+
+"executable@npm:^4.1.1":
+  version: 4.1.1
+  resolution: "executable@npm:4.1.1"
+  dependencies:
+    pify: ^2.2.0
+  checksum: f01927ce59bccec804e171bf859a26e362c1f50aa9ebc69f7cafdcce3859d29d4b6267fd47237c18b0a1830614bd3f0ee14b7380d9bad18a4e7af9b5f0b6984f
+  languageName: node
+  linkType: hard
+
+"exit@npm:^0.1.2":
+  version: 0.1.2
+  resolution: "exit@npm:0.1.2"
+  checksum: abc407f07a875c3961e4781dfcb743b58d6c93de9ab263f4f8c9d23bb6da5f9b7764fc773f86b43dd88030444d5ab8abcb611cb680fba8ca075362b77114bba3
+  languageName: node
+  linkType: hard
+
+"expand-brackets@npm:^2.1.4":
+  version: 2.1.4
+  resolution: "expand-brackets@npm:2.1.4"
+  dependencies:
+    debug: ^2.3.3
+    define-property: ^0.2.5
+    extend-shallow: ^2.0.1
+    posix-character-classes: ^0.1.0
+    regex-not: ^1.0.0
+    snapdragon: ^0.8.1
+    to-regex: ^3.0.1
+  checksum: 1781d422e7edfa20009e2abda673cadb040a6037f0bd30fcd7357304f4f0c284afd420d7622722ca4a016f39b6d091841ab57b401c1f7e2e5131ac65b9f14fa1
+  languageName: node
+  linkType: hard
+
+"expect@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "expect@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+    ansi-styles: ^3.2.0
+    jest-get-type: ^24.9.0
+    jest-matcher-utils: ^24.9.0
+    jest-message-util: ^24.9.0
+    jest-regex-util: ^24.9.0
+  checksum: bfce2243543dd10e3c2047bbe6fc99b7b150cea71b198ddd8feb2e7ebfef1a3dd46ec7519e05d23a20b30c242b13dad97551368a690731d9a591f6f863528cee
+  languageName: node
+  linkType: hard
+
+"express@npm:^4.17.1":
+  version: 4.19.2
+  resolution: "express@npm:4.19.2"
+  dependencies:
+    accepts: ~1.3.8
+    array-flatten: 1.1.1
+    body-parser: 1.20.2
+    content-disposition: 0.5.4
+    content-type: ~1.0.4
+    cookie: 0.6.0
+    cookie-signature: 1.0.6
+    debug: 2.6.9
+    depd: 2.0.0
+    encodeurl: ~1.0.2
+    escape-html: ~1.0.3
+    etag: ~1.8.1
+    finalhandler: 1.2.0
+    fresh: 0.5.2
+    http-errors: 2.0.0
+    merge-descriptors: 1.0.1
+    methods: ~1.1.2
+    on-finished: 2.4.1
+    parseurl: ~1.3.3
+    path-to-regexp: 0.1.7
+    proxy-addr: ~2.0.7
+    qs: 6.11.0
+    range-parser: ~1.2.1
+    safe-buffer: 5.2.1
+    send: 0.18.0
+    serve-static: 1.15.0
+    setprototypeof: 1.2.0
+    statuses: 2.0.1
+    type-is: ~1.6.18
+    utils-merge: 1.0.1
+    vary: ~1.1.2
+  checksum: 212dbd6c2c222a96a61bc927639c95970a53b06257080bb9e2838adb3bffdb966856551fdad1ab5dd654a217c35db94f987d0aa88d48fb04d306340f5f34dca5
+  languageName: node
+  linkType: hard
+
+"ext@npm:^1.1.2":
+  version: 1.4.0
+  resolution: "ext@npm:1.4.0"
+  dependencies:
+    type: ^2.0.0
+  checksum: 70acfb68763ad888b34a1c8f2fd9ae5e7265c2470a58a7204645fea07fdbb802512944ea3820db5e643369a9364a98f01732c72e3f2ee577bc2582c3e7e370e3
+  languageName: node
+  linkType: hard
+
+"ext@npm:^1.7.0":
+  version: 1.7.0
+  resolution: "ext@npm:1.7.0"
+  dependencies:
+    type: ^2.7.2
+  checksum: ef481f9ef45434d8c867cfd09d0393b60945b7c8a1798bedc4514cb35aac342ccb8d8ecb66a513e6a2b4ec1e294a338e3124c49b29736f8e7c735721af352c31
+  languageName: node
+  linkType: hard
+
+"extend-shallow@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "extend-shallow@npm:2.0.1"
+  dependencies:
+    is-extendable: ^0.1.0
+  checksum: 8fb58d9d7a511f4baf78d383e637bd7d2e80843bd9cd0853649108ea835208fb614da502a553acc30208e1325240bb7cc4a68473021612496bb89725483656d8
+  languageName: node
+  linkType: hard
+
+"extend-shallow@npm:^3.0.0, extend-shallow@npm:^3.0.2":
+  version: 3.0.2
+  resolution: "extend-shallow@npm:3.0.2"
+  dependencies:
+    assign-symbols: ^1.0.0
+    is-extendable: ^1.0.1
+  checksum: a920b0cd5838a9995ace31dfd11ab5e79bf6e295aa566910ce53dff19f4b1c0fda2ef21f26b28586c7a2450ca2b42d97bd8c0f5cec9351a819222bf861e02461
+  languageName: node
+  linkType: hard
+
+"extend@npm:~3.0.2":
+  version: 3.0.2
+  resolution: "extend@npm:3.0.2"
+  checksum: a50a8309ca65ea5d426382ff09f33586527882cf532931cb08ca786ea3146c0553310bda688710ff61d7668eba9f96b923fe1420cdf56a2c3eaf30fcab87b515
+  languageName: node
+  linkType: hard
+
+"external-editor@npm:^3.0.3":
+  version: 3.1.0
+  resolution: "external-editor@npm:3.1.0"
+  dependencies:
+    chardet: ^0.7.0
+    iconv-lite: ^0.4.24
+    tmp: ^0.0.33
+  checksum: 1c2a616a73f1b3435ce04030261bed0e22d4737e14b090bb48e58865da92529c9f2b05b893de650738d55e692d071819b45e1669259b2b354bc3154d27a698c7
+  languageName: node
+  linkType: hard
+
+"extglob@npm:^2.0.4":
+  version: 2.0.4
+  resolution: "extglob@npm:2.0.4"
+  dependencies:
+    array-unique: ^0.3.2
+    define-property: ^1.0.0
+    expand-brackets: ^2.1.4
+    extend-shallow: ^2.0.1
+    fragment-cache: ^0.2.1
+    regex-not: ^1.0.0
+    snapdragon: ^0.8.1
+    to-regex: ^3.0.1
+  checksum: a41531b8934735b684cef5e8c5a01d0f298d7d384500ceca38793a9ce098125aab04ee73e2d75d5b2901bc5dddd2b64e1b5e3bf19139ea48bac52af4a92f1d00
+  languageName: node
+  linkType: hard
+
+"extract-zip@npm:2.0.1":
+  version: 2.0.1
+  resolution: "extract-zip@npm:2.0.1"
+  dependencies:
+    "@types/yauzl": ^2.9.1
+    debug: ^4.1.1
+    get-stream: ^5.1.0
+    yauzl: ^2.10.0
+  dependenciesMeta:
+    "@types/yauzl":
+      optional: true
+  bin:
+    extract-zip: cli.js
+  checksum: 8cbda9debdd6d6980819cc69734d874ddd71051c9fe5bde1ef307ebcedfe949ba57b004894b585f758b7c9eeeea0e3d87f2dda89b7d25320459c2c9643ebb635
+  languageName: node
+  linkType: hard
+
+"extsprintf@npm:1.3.0":
+  version: 1.3.0
+  resolution: "extsprintf@npm:1.3.0"
+  checksum: cee7a4a1e34cffeeec18559109de92c27517e5641991ec6bab849aa64e3081022903dd53084f2080d0d2530803aa5ee84f1e9de642c365452f9e67be8f958ce2
+  languageName: node
+  linkType: hard
+
+"extsprintf@npm:^1.2.0":
+  version: 1.4.0
+  resolution: "extsprintf@npm:1.4.0"
+  checksum: 184dc8a413eb4b1ff16bdce797340e7ded4d28511d56a1c9afa5a95bcff6ace154063823eaf0206dbbb0d14059d74f382a15c34b7c0636fa74a7e681295eb67e
+  languageName: node
+  linkType: hard
+
+"fast-deep-equal@npm:^3.1.1":
+  version: 3.1.3
+  resolution: "fast-deep-equal@npm:3.1.3"
+  checksum: e21a9d8d84f53493b6aa15efc9cfd53dd5b714a1f23f67fb5dc8f574af80df889b3bce25dc081887c6d25457cce704e636395333abad896ccdec03abaf1f3f9d
+  languageName: node
+  linkType: hard
+
+"fast-glob@npm:^2.0.2":
+  version: 2.2.7
+  resolution: "fast-glob@npm:2.2.7"
+  dependencies:
+    "@mrmlnc/readdir-enhanced": ^2.2.1
+    "@nodelib/fs.stat": ^1.1.2
+    glob-parent: ^3.1.0
+    is-glob: ^4.0.0
+    merge2: ^1.2.3
+    micromatch: ^3.1.10
+  checksum: 304ccff1d437fcc44ae0168b0c3899054b92e0fd6af6ad7c3ccc82ab4ddd210b99c7c739d60ee3686da2aa165cd1a31810b31fd91f7c2a575d297342a9fc0534
+  languageName: node
+  linkType: hard
+
+"fast-glob@npm:^3.2.9":
+  version: 3.3.1
+  resolution: "fast-glob@npm:3.3.1"
+  dependencies:
+    "@nodelib/fs.stat": ^2.0.2
+    "@nodelib/fs.walk": ^1.2.3
+    glob-parent: ^5.1.2
+    merge2: ^1.3.0
+    micromatch: ^4.0.4
+  checksum: b6f3add6403e02cf3a798bfbb1183d0f6da2afd368f27456010c0bc1f9640aea308243d4cb2c0ab142f618276e65ecb8be1661d7c62a7b4e5ba774b9ce5432e5
+  languageName: node
+  linkType: hard
+
+"fast-json-stable-stringify@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "fast-json-stable-stringify@npm:2.1.0"
+  checksum: b191531e36c607977e5b1c47811158733c34ccb3bfde92c44798929e9b4154884378536d26ad90dfecd32e1ffc09c545d23535ad91b3161a27ddbb8ebe0cbecb
+  languageName: node
+  linkType: hard
+
+"fast-levenshtein@npm:~2.0.6":
+  version: 2.0.6
+  resolution: "fast-levenshtein@npm:2.0.6"
+  checksum: 92cfec0a8dfafd9c7a15fba8f2cc29cd0b62b85f056d99ce448bbcd9f708e18ab2764bda4dd5158364f4145a7c72788538994f0d1787b956ef0d1062b0f7c24c
+  languageName: node
+  linkType: hard
+
+"fastq@npm:^1.6.0":
+  version: 1.11.0
+  resolution: "fastq@npm:1.11.0"
+  dependencies:
+    reusify: ^1.0.4
+  checksum: 9db0ceea9280c5f207da40c562a4e574913c18933cd74b880b01bf8e81a9a6e368ec71e89c9c1b9f4066d0275cc22600efd6dde87f713217acbf67076481734b
+  languageName: node
+  linkType: hard
+
+"faye-websocket@npm:^0.10.0":
+  version: 0.10.0
+  resolution: "faye-websocket@npm:0.10.0"
+  dependencies:
+    websocket-driver: ">=0.5.1"
+  checksum: 5a2989ec5effc832bd219e3af934966b5a2a2605dd83b995a04edae5d34207ef930635f5c8456b8b7b4209bfb8f7ea991e41594f150a04faa53fca1ee4eb31b6
+  languageName: node
+  linkType: hard
+
+"faye-websocket@npm:~0.11.1":
+  version: 0.11.4
+  resolution: "faye-websocket@npm:0.11.4"
+  dependencies:
+    websocket-driver: ">=0.5.1"
+  checksum: d49a62caf027f871149fc2b3f3c7104dc6d62744277eb6f9f36e2d5714e847d846b9f7f0d0b7169b25a012e24a594cde11a93034b30732e4c683f20b8a5019fa
+  languageName: node
+  linkType: hard
+
+"fb-watchman@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "fb-watchman@npm:2.0.1"
+  dependencies:
+    bser: 2.1.1
+  checksum: 8510230778ab3a51c27dffb1b76ef2c24fab672a42742d3c0a45c2e9d1e5f20210b1fbca33486088da4a9a3958bde96b5aec0a63aac9894b4e9df65c88b2cbd6
+  languageName: node
+  linkType: hard
+
+"fbjs-css-vars@npm:^1.0.0":
+  version: 1.0.2
+  resolution: "fbjs-css-vars@npm:1.0.2"
+  checksum: 72baf6d22c45b75109118b4daecb6c8016d4c83c8c0f23f683f22e9d7c21f32fff6201d288df46eb561e3c7d4bb4489b8ad140b7f56444c453ba407e8bd28511
+  languageName: node
+  linkType: hard
+
+"fbjs@npm:^0.8.1":
+  version: 0.8.18
+  resolution: "fbjs@npm:0.8.18"
+  dependencies:
+    core-js: ^1.0.0
+    isomorphic-fetch: ^2.1.1
+    loose-envify: ^1.0.0
+    object-assign: ^4.1.0
+    promise: ^7.1.1
+    setimmediate: ^1.0.5
+    ua-parser-js: ^0.7.30
+  checksum: 668731b946a765908c9cbe51d5160f973abb78004b3d122587c3e930e3e1ddcc0ce2b17f2a8637dc9d733e149aa580f8d3035a35cc2d3bc78b78f1b19aab90e2
+  languageName: node
+  linkType: hard
+
+"fbjs@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "fbjs@npm:2.0.0"
+  dependencies:
+    core-js: ^3.6.4
+    cross-fetch: ^3.0.4
+    fbjs-css-vars: ^1.0.0
+    loose-envify: ^1.0.0
+    object-assign: ^4.1.0
+    promise: ^7.1.1
+    setimmediate: ^1.0.5
+    ua-parser-js: ^0.7.18
+  checksum: 449799568370c0350775e67a6b6d3f399a1b07df466f4ceb10a5d8ef238c26709fe7c1dac57578028c58496ffb797c119b0bea691e36a4e620f2130e7e90e3a3
+  languageName: node
+  linkType: hard
+
+"fd-slicer@npm:~1.1.0":
+  version: 1.1.0
+  resolution: "fd-slicer@npm:1.1.0"
+  dependencies:
+    pend: ~1.2.0
+  checksum: c8585fd5713f4476eb8261150900d2cb7f6ff2d87f8feb306ccc8a1122efd152f1783bdb2b8dc891395744583436bfd8081d8e63ece0ec8687eeefea394d4ff2
+  languageName: node
+  linkType: hard
+
+"figgy-pudding@npm:^3.5.1":
+  version: 3.5.2
+  resolution: "figgy-pudding@npm:3.5.2"
+  checksum: 4090bd66193693dcda605e44d6b8715d8fb5c92a67acd57826e55cf816a342f550d57e5638f822b39366e1b2fdb244e99b3068a37213aa1d6c1bf602b8fde5ae
+  languageName: node
+  linkType: hard
+
+"figures@npm:^3.0.0, figures@npm:^3.2.0":
+  version: 3.2.0
+  resolution: "figures@npm:3.2.0"
+  dependencies:
+    escape-string-regexp: ^1.0.5
+  checksum: 85a6ad29e9aca80b49b817e7c89ecc4716ff14e3779d9835af554db91bac41c0f289c418923519392a1e582b4d10482ad282021330cd045bb7b80c84152f2a2b
+  languageName: node
+  linkType: hard
+
+"file-entry-cache@npm:^5.0.1":
+  version: 5.0.1
+  resolution: "file-entry-cache@npm:5.0.1"
+  dependencies:
+    flat-cache: ^2.0.1
+  checksum: 9014b17766815d59b8b789633aed005242ef857348c09be558bd85b4a24e16b0ad1e0e5229ccea7a2109f74ef1b3db1a559b58afe12b884f09019308711376fd
+  languageName: node
+  linkType: hard
+
+"file-loader@npm:4.3.0":
+  version: 4.3.0
+  resolution: "file-loader@npm:4.3.0"
+  dependencies:
+    loader-utils: ^1.2.3
+    schema-utils: ^2.5.0
+  peerDependencies:
+    webpack: ^4.0.0
+  checksum: a005ac5599e96631e8ead32db874283ef821c121e93997b0d6f853db1284bcd7832e1ac59d39a21c201de22b6e33146996c28bd8c486893a5191c334a00f61b2
+  languageName: node
+  linkType: hard
+
+"file-saver@npm:2.0.1":
+  version: 2.0.1
+  resolution: "file-saver@npm:2.0.1"
+  checksum: 7911dd5770033dfdc6c5a385c471fc76a14147e1f445ee101b4af81d949decf40deee3fdf2df8aedf2088fdad917d9c0e0e987032f4c44b92ce5901528fa4e2b
+  languageName: node
+  linkType: hard
+
+"file-uri-to-path@npm:1.0.0":
+  version: 1.0.0
+  resolution: "file-uri-to-path@npm:1.0.0"
+  checksum: b648580bdd893a008c92c7ecc96c3ee57a5e7b6c4c18a9a09b44fb5d36d79146f8e442578bc0e173dc027adf3987e254ba1dfd6e3ec998b7c282873010502144
+  languageName: node
+  linkType: hard
+
+"filesize@npm:6.0.1":
+  version: 6.0.1
+  resolution: "filesize@npm:6.0.1"
+  checksum: 2e3f9b09a32086e068162a1caf4b6b438aba23389086bf77cf67c410698ed12baf01c928507777b8b2d3e1e2578f2e74f219608a0f7ea210a74e33082c0caab1
+  languageName: node
+  linkType: hard
+
+"fill-range@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "fill-range@npm:4.0.0"
+  dependencies:
+    extend-shallow: ^2.0.1
+    is-number: ^3.0.0
+    repeat-string: ^1.6.1
+    to-regex-range: ^2.1.0
+  checksum: dbb5102467786ab42bc7a3ec7380ae5d6bfd1b5177b2216de89e4a541193f8ba599a6db84651bd2c58c8921db41b8cc3d699ea83b477342d3ce404020f73c298
+  languageName: node
+  linkType: hard
+
+"fill-range@npm:^7.0.1":
+  version: 7.0.1
+  resolution: "fill-range@npm:7.0.1"
+  dependencies:
+    to-regex-range: ^5.0.1
+  checksum: cc283f4e65b504259e64fd969bcf4def4eb08d85565e906b7d36516e87819db52029a76b6363d0f02d0d532f0033c9603b9e2d943d56ee3b0d4f7ad3328ff917
+  languageName: node
+  linkType: hard
+
+"finalhandler@npm:1.2.0":
+  version: 1.2.0
+  resolution: "finalhandler@npm:1.2.0"
+  dependencies:
+    debug: 2.6.9
+    encodeurl: ~1.0.2
+    escape-html: ~1.0.3
+    on-finished: 2.4.1
+    parseurl: ~1.3.3
+    statuses: 2.0.1
+    unpipe: ~1.0.0
+  checksum: 92effbfd32e22a7dff2994acedbd9bcc3aa646a3e919ea6a53238090e87097f8ef07cced90aa2cc421abdf993aefbdd5b00104d55c7c5479a8d00ed105b45716
+  languageName: node
+  linkType: hard
+
+"find-cache-dir@npm:^0.1.1":
+  version: 0.1.1
+  resolution: "find-cache-dir@npm:0.1.1"
+  dependencies:
+    commondir: ^1.0.1
+    mkdirp: ^0.5.1
+    pkg-dir: ^1.0.0
+  checksum: b5d9d68c1ff8c222124bb19089a405be9a3d0333e713ae989d980342c35690dfddd05f0fb456ec11846579e30e0f0e18293d20632662506cd2fa2c7237783479
+  languageName: node
+  linkType: hard
+
+"find-cache-dir@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "find-cache-dir@npm:2.1.0"
+  dependencies:
+    commondir: ^1.0.1
+    make-dir: ^2.0.0
+    pkg-dir: ^3.0.0
+  checksum: 60ad475a6da9f257df4e81900f78986ab367d4f65d33cf802c5b91e969c28a8762f098693d7a571b6e4dd4c15166c2da32ae2d18b6766a18e2071079448fdce4
+  languageName: node
+  linkType: hard
+
+"find-cache-dir@npm:^3.3.1":
+  version: 3.3.1
+  resolution: "find-cache-dir@npm:3.3.1"
+  dependencies:
+    commondir: ^1.0.1
+    make-dir: ^3.0.2
+    pkg-dir: ^4.1.0
+  checksum: 0f7c22b65e07f9b486b4560227d014fab1e79ffbbfbafb87d113a2e878510bd620ef6fdff090e5248bb2846d28851d19e42bfdc7c50687966acc106328e7abf1
+  languageName: node
+  linkType: hard
+
+"find-up@npm:4.1.0, find-up@npm:^4.0.0, find-up@npm:^4.1.0":
+  version: 4.1.0
+  resolution: "find-up@npm:4.1.0"
+  dependencies:
+    locate-path: ^5.0.0
+    path-exists: ^4.0.0
+  checksum: 4c172680e8f8c1f78839486e14a43ef82e9decd0e74145f40707cc42e7420506d5ec92d9a11c22bd2c48fb0c384ea05dd30e10dd152fefeec6f2f75282a8b844
+  languageName: node
+  linkType: hard
+
+"find-up@npm:^1.0.0":
+  version: 1.1.2
+  resolution: "find-up@npm:1.1.2"
+  dependencies:
+    path-exists: ^2.0.0
+    pinkie-promise: ^2.0.0
+  checksum: a2cb9f4c9f06ee3a1e92ed71d5aed41ac8ae30aefa568132f6c556fac7678a5035126153b59eaec68da78ac409eef02503b2b059706bdbf232668d7245e3240a
+  languageName: node
+  linkType: hard
+
+"find-up@npm:^2.0.0, find-up@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "find-up@npm:2.1.0"
+  dependencies:
+    locate-path: ^2.0.0
+  checksum: 43284fe4da09f89011f08e3c32cd38401e786b19226ea440b75386c1b12a4cb738c94969808d53a84f564ede22f732c8409e3cfc3f7fb5b5c32378ad0bbf28bd
+  languageName: node
+  linkType: hard
+
+"find-up@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "find-up@npm:3.0.0"
+  dependencies:
+    locate-path: ^3.0.0
+  checksum: 38eba3fe7a66e4bc7f0f5a1366dc25508b7cfc349f852640e3678d26ad9a6d7e2c43eff0a472287de4a9753ef58f066a0ea892a256fa3636ad51b3fe1e17fae9
+  languageName: node
+  linkType: hard
+
+"flat-cache@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "flat-cache@npm:2.0.1"
+  dependencies:
+    flatted: ^2.0.0
+    rimraf: 2.6.3
+    write: 1.0.3
+  checksum: 0f5e66467658039e6fcaaccb363b28f43906ba72fab7ff2a4f6fcd5b4899679e13ca46d9fc6cc48b68ac925ae93137106d4aaeb79874c13f21f87a361705f1b1
+  languageName: node
+  linkType: hard
+
+"flatted@npm:^2.0.0":
+  version: 2.0.2
+  resolution: "flatted@npm:2.0.2"
+  checksum: 473c754db7a529e125a22057098f1a4c905ba17b8cc269c3acf77352f0ffa6304c851eb75f6a1845f74461f560e635129ca6b0b8a78fb253c65cea4de3d776f2
+  languageName: node
+  linkType: hard
+
+"flatten@npm:^1.0.2":
+  version: 1.0.3
+  resolution: "flatten@npm:1.0.3"
+  checksum: 5c57379816f1692aaa79fbc6390e0a0644e5e8442c5783ed57c6d315468eddbc53a659eaa03c9bb1e771b0f4a9bd8dd8a2620286bf21fd6538a7857321fdfb20
+  languageName: node
+  linkType: hard
+
+"flush-write-stream@npm:^1.0.0":
+  version: 1.1.1
+  resolution: "flush-write-stream@npm:1.1.1"
+  dependencies:
+    inherits: ^2.0.3
+    readable-stream: ^2.3.6
+  checksum: 42e07747f83bcd4e799da802e621d6039787749ffd41f5517f8c4f786ee967e31ba32b09f8b28a9c6f67bd4f5346772e604202df350e8d99f4141771bae31279
+  languageName: node
+  linkType: hard
+
+"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.0":
+  version: 1.15.6
+  resolution: "follow-redirects@npm:1.15.6"
+  peerDependenciesMeta:
+    debug:
+      optional: true
+  checksum: a62c378dfc8c00f60b9c80cab158ba54e99ba0239a5dd7c81245e5a5b39d10f0c35e249c3379eae719ff0285fff88c365dd446fab19dee771f1d76252df1bbf5
+  languageName: node
+  linkType: hard
+
+"for-each@npm:^0.3.3":
+  version: 0.3.3
+  resolution: "for-each@npm:0.3.3"
+  dependencies:
+    is-callable: ^1.1.3
+  checksum: 6c48ff2bc63362319c65e2edca4a8e1e3483a2fabc72fbe7feaf8c73db94fc7861bd53bc02c8a66a0c1dd709da6b04eec42e0abdd6b40ce47305ae92a25e5d28
+  languageName: node
+  linkType: hard
+
+"for-in@npm:^0.1.3":
+  version: 0.1.8
+  resolution: "for-in@npm:0.1.8"
+  checksum: f5bdad7811700ee6a0f96b33d72a1db966aea75a1f03c7245d147f8369305e709f53a55ee7ae8eaddcfa85c7c89bca78472be8f1bc605475ce5bb2c70f77f8da
+  languageName: node
+  linkType: hard
+
+"for-in@npm:^1.0.1, for-in@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "for-in@npm:1.0.2"
+  checksum: 09f4ae93ce785d253ac963d94c7f3432d89398bf25ac7a24ed034ca393bf74380bdeccc40e0f2d721a895e54211b07c8fad7132e8157827f6f7f059b70b4043d
+  languageName: node
+  linkType: hard
+
+"for-own@npm:^0.1.3":
+  version: 0.1.5
+  resolution: "for-own@npm:0.1.5"
+  dependencies:
+    for-in: ^1.0.1
+  checksum: 07eb0a2e98eb55ce13b56dd11ef4fb5e619ba7380aaec388b9eec1946153d74fa734ce409e8434020557e9489a50c34bc004d55754f5863bf7d77b441d8dee8c
+  languageName: node
+  linkType: hard
+
+"forever-agent@npm:~0.6.1":
+  version: 0.6.1
+  resolution: "forever-agent@npm:0.6.1"
+  checksum: 766ae6e220f5fe23676bb4c6a99387cec5b7b62ceb99e10923376e27bfea72f3c3aeec2ba5f45f3f7ba65d6616965aa7c20b15002b6860833bb6e394dea546a8
+  languageName: node
+  linkType: hard
+
+"fork-ts-checker-webpack-plugin@npm:3.1.1":
+  version: 3.1.1
+  resolution: "fork-ts-checker-webpack-plugin@npm:3.1.1"
+  dependencies:
+    babel-code-frame: ^6.22.0
+    chalk: ^2.4.1
+    chokidar: ^3.3.0
+    micromatch: ^3.1.10
+    minimatch: ^3.0.4
+    semver: ^5.6.0
+    tapable: ^1.0.0
+    worker-rpc: ^0.1.0
+  checksum: c7b278e569ec7d5a1c73808b80870dda87f6171dc33c7cf06090c441c54753e1a5886b8256269fd2e100cda980b3f12db02d02ae2008fc067c81e948a173f35f
+  languageName: node
+  linkType: hard
+
+"form-data@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "form-data@npm:4.0.0"
+  dependencies:
+    asynckit: ^0.4.0
+    combined-stream: ^1.0.8
+    mime-types: ^2.1.12
+  checksum: 01135bf8675f9d5c61ff18e2e2932f719ca4de964e3be90ef4c36aacfc7b9cb2fceb5eca0b7e0190e3383fe51c5b37f4cb80b62ca06a99aaabfcfd6ac7c9328c
+  languageName: node
+  linkType: hard
+
+"form-data@npm:~2.3.2":
+  version: 2.3.3
+  resolution: "form-data@npm:2.3.3"
+  dependencies:
+    asynckit: ^0.4.0
+    combined-stream: ^1.0.6
+    mime-types: ^2.1.12
+  checksum: 10c1780fa13dbe1ff3100114c2ce1f9307f8be10b14bf16e103815356ff567b6be39d70fc4a40f8990b9660012dc24b0f5e1dde1b6426166eb23a445ba068ca3
+  languageName: node
+  linkType: hard
+
+"forwarded@npm:0.2.0":
+  version: 0.2.0
+  resolution: "forwarded@npm:0.2.0"
+  checksum: fd27e2394d8887ebd16a66ffc889dc983fbbd797d5d3f01087c020283c0f019a7d05ee85669383d8e0d216b116d720fc0cef2f6e9b7eb9f4c90c6e0bc7fd28e6
+  languageName: node
+  linkType: hard
+
+"fragment-cache@npm:^0.2.1":
+  version: 0.2.1
+  resolution: "fragment-cache@npm:0.2.1"
+  dependencies:
+    map-cache: ^0.2.2
+  checksum: 1cbbd0b0116b67d5790175de0038a11df23c1cd2e8dcdbade58ebba5594c2d641dade6b4f126d82a7b4a6ffc2ea12e3d387dbb64ea2ae97cf02847d436f60fdc
+  languageName: node
+  linkType: hard
+
+"fresh@npm:0.5.2":
+  version: 0.5.2
+  resolution: "fresh@npm:0.5.2"
+  checksum: 13ea8b08f91e669a64e3ba3a20eb79d7ca5379a81f1ff7f4310d54e2320645503cc0c78daedc93dfb6191287295f6479544a649c64d8e41a1c0fb0c221552346
+  languageName: node
+  linkType: hard
+
+"from2@npm:^2.1.0":
+  version: 2.3.0
+  resolution: "from2@npm:2.3.0"
+  dependencies:
+    inherits: ^2.0.1
+    readable-stream: ^2.0.0
+  checksum: 6080eba0793dce32f475141fb3d54cc15f84ee52e420ee22ac3ab0ad639dc95a1875bc6eb9c0e1140e94972a36a89dc5542491b85f1ab8df0c126241e0f1a61b
+  languageName: node
+  linkType: hard
+
+"fs-extra@npm:^4.0.2":
+  version: 4.0.3
+  resolution: "fs-extra@npm:4.0.3"
+  dependencies:
+    graceful-fs: ^4.1.2
+    jsonfile: ^4.0.0
+    universalify: ^0.1.0
+  checksum: c5ae3c7043ad7187128e619c0371da01b58694c1ffa02c36fb3f5b459925d9c27c3cb1e095d9df0a34a85ca993d8b8ff6f6ecef868fd5ebb243548afa7fc0936
+  languageName: node
+  linkType: hard
+
+"fs-extra@npm:^7.0.0":
+  version: 7.0.1
+  resolution: "fs-extra@npm:7.0.1"
+  dependencies:
+    graceful-fs: ^4.1.2
+    jsonfile: ^4.0.0
+    universalify: ^0.1.0
+  checksum: 141b9dccb23b66a66cefdd81f4cda959ff89282b1d721b98cea19ba08db3dcbe6f862f28841f3cf24bb299e0b7e6c42303908f65093cb7e201708e86ea5a8dcf
+  languageName: node
+  linkType: hard
+
+"fs-extra@npm:^8.1.0":
+  version: 8.1.0
+  resolution: "fs-extra@npm:8.1.0"
+  dependencies:
+    graceful-fs: ^4.2.0
+    jsonfile: ^4.0.0
+    universalify: ^0.1.0
+  checksum: bf44f0e6cea59d5ce071bba4c43ca76d216f89e402dc6285c128abc0902e9b8525135aa808adad72c9d5d218e9f4bcc63962815529ff2f684ad532172a284880
+  languageName: node
+  linkType: hard
+
+"fs-extra@npm:^9.1.0":
+  version: 9.1.0
+  resolution: "fs-extra@npm:9.1.0"
+  dependencies:
+    at-least-node: ^1.0.0
+    graceful-fs: ^4.2.0
+    jsonfile: ^6.0.1
+    universalify: ^2.0.0
+  checksum: ba71ba32e0faa74ab931b7a0031d1523c66a73e225de7426e275e238e312d07313d2da2d33e34a52aa406c8763ade5712eb3ec9ba4d9edce652bcacdc29e6b20
+  languageName: node
+  linkType: hard
+
+"fs-minipass@npm:^2.0.0, fs-minipass@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "fs-minipass@npm:2.1.0"
+  dependencies:
+    minipass: ^3.0.0
+  checksum: 1b8d128dae2ac6cc94230cc5ead341ba3e0efaef82dab46a33d171c044caaa6ca001364178d42069b2809c35a1c3c35079a32107c770e9ffab3901b59af8c8b1
+  languageName: node
+  linkType: hard
+
+"fs-write-stream-atomic@npm:^1.0.8":
+  version: 1.0.10
+  resolution: "fs-write-stream-atomic@npm:1.0.10"
+  dependencies:
+    graceful-fs: ^4.1.2
+    iferr: ^0.1.5
+    imurmurhash: ^0.1.4
+    readable-stream: 1 || 2
+  checksum: 43c2d6817b72127793abc811ebf87a135b03ac7cbe41cdea9eeacf59b23e6e29b595739b083e9461303d525687499a1aaefcec3e5ff9bc82b170edd3dc467ccc
+  languageName: node
+  linkType: hard
+
+"fs.realpath@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "fs.realpath@npm:1.0.0"
+  checksum: 99ddea01a7e75aa276c250a04eedeffe5662bce66c65c07164ad6264f9de18fb21be9433ead460e54cff20e31721c811f4fb5d70591799df5f85dce6d6746fd0
+  languageName: node
+  linkType: hard
+
+"fsevents@npm:2.1.2":
+  version: 2.1.2
+  resolution: "fsevents@npm:2.1.2"
+  dependencies:
+    node-gyp: latest
+  checksum: 63fe1ba77b63d5da5dde6112c5f0eb161b9d18a61427a8a49d661eeed080189d99e8f9da11bb6b75ecd5129a69edc5757d60a4eb0bbada6de68d5156c382c5e1
+  conditions: os=darwin
+  languageName: node
+  linkType: hard
+
+"fsevents@npm:^1.2.7":
+  version: 1.2.13
+  resolution: "fsevents@npm:1.2.13"
+  dependencies:
+    bindings: ^1.5.0
+    nan: ^2.12.1
+  checksum: ae855aa737aaa2f9167e9f70417cf6e45a5cd11918e1fee9923709a0149be52416d765433b4aeff56c789b1152e718cd1b13ddec6043b78cdda68260d86383c1
+  conditions: os=darwin
+  languageName: node
+  linkType: hard
+
+"fsevents@npm:~2.3.2":
+  version: 2.3.2
+  resolution: "fsevents@npm:2.3.2"
+  dependencies:
+    node-gyp: latest
+  checksum: 97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f
+  conditions: os=darwin
+  languageName: node
+  linkType: hard
+
+"fsevents@patch:fsevents@2.1.2#~builtin<compat/fsevents>":
+  version: 2.1.2
+  resolution: "fsevents@patch:fsevents@npm%3A2.1.2#~builtin<compat/fsevents>::version=2.1.2&hash=18f3a7"
+  dependencies:
+    node-gyp: latest
+  conditions: os=darwin
+  languageName: node
+  linkType: hard
+
+"fsevents@patch:fsevents@^1.2.7#~builtin<compat/fsevents>":
+  version: 1.2.13
+  resolution: "fsevents@patch:fsevents@npm%3A1.2.13#~builtin<compat/fsevents>::version=1.2.13&hash=18f3a7"
+  dependencies:
+    bindings: ^1.5.0
+    nan: ^2.12.1
+  conditions: os=darwin
+  languageName: node
+  linkType: hard
+
+"fsevents@patch:fsevents@~2.3.2#~builtin<compat/fsevents>":
+  version: 2.3.2
+  resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin<compat/fsevents>::version=2.3.2&hash=18f3a7"
+  dependencies:
+    node-gyp: latest
+  conditions: os=darwin
+  languageName: node
+  linkType: hard
+
+"fstream@npm:1.0.12":
+  version: 1.0.12
+  resolution: "fstream@npm:1.0.12"
+  dependencies:
+    graceful-fs: ^4.1.2
+    inherits: ~2.0.0
+    mkdirp: ">=0.5 0"
+    rimraf: 2
+  checksum: e6998651aeb85fd0f0a8a68cec4d05a3ada685ecc4e3f56e0d063d0564a4fc39ad11a856f9020f926daf869fc67f7a90e891def5d48e4cadab875dc313094536
+  languageName: node
+  linkType: hard
+
+"function-bind@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "function-bind@npm:1.1.1"
+  checksum: b32fbaebb3f8ec4969f033073b43f5c8befbb58f1a79e12f1d7490358150359ebd92f49e72ff0144f65f2c48ea2a605bff2d07965f548f6474fd8efd95bf361a
+  languageName: node
+  linkType: hard
+
+"function.prototype.name@npm:^1.1.2, function.prototype.name@npm:^1.1.3":
+  version: 1.1.4
+  resolution: "function.prototype.name@npm:1.1.4"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.0-next.2
+    functions-have-names: ^1.2.2
+  checksum: 2dd516ba0ddf81cc616257153ffb8f2d77bd6618374beb20c854b047051d643d023797996b36993e920eb0fcfb77de98dd28c1a9ed75db7fc23163e3e687d2e6
+  languageName: node
+  linkType: hard
+
+"functional-red-black-tree@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "functional-red-black-tree@npm:1.0.1"
+  checksum: ca6c170f37640e2d94297da8bb4bf27a1d12bea3e00e6a3e007fd7aa32e37e000f5772acf941b4e4f3cf1c95c3752033d0c509af157ad8f526e7f00723b9eb9f
+  languageName: node
+  linkType: hard
+
+"functions-have-names@npm:^1.2.2":
+  version: 1.2.2
+  resolution: "functions-have-names@npm:1.2.2"
+  checksum: 25f44b6d1c41ac86ffdf41f25d1de81c0a5b4a3fcf4307a33cdfb23b9d4bd5d0d8bf312eaef5ad368c6500c8a9e19f692b8ce9f96aaab99db9dd936554165558
+  languageName: node
+  linkType: hard
+
+"gauge@npm:^3.0.0":
+  version: 3.0.2
+  resolution: "gauge@npm:3.0.2"
+  dependencies:
+    aproba: ^1.0.3 || ^2.0.0
+    color-support: ^1.1.2
+    console-control-strings: ^1.0.0
+    has-unicode: ^2.0.1
+    object-assign: ^4.1.1
+    signal-exit: ^3.0.0
+    string-width: ^4.2.3
+    strip-ansi: ^6.0.1
+    wide-align: ^1.1.2
+  checksum: 81296c00c7410cdd48f997800155fbead4f32e4f82109be0719c63edc8560e6579946cc8abd04205297640691ec26d21b578837fd13a4e96288ab4b40b1dc3e9
+  languageName: node
+  linkType: hard
+
+"gauge@npm:^4.0.0":
+  version: 4.0.2
+  resolution: "gauge@npm:4.0.2"
+  dependencies:
+    ansi-regex: ^5.0.1
+    aproba: ^1.0.3 || ^2.0.0
+    color-support: ^1.1.3
+    console-control-strings: ^1.1.0
+    has-unicode: ^2.0.1
+    signal-exit: ^3.0.7
+    string-width: ^4.2.3
+    strip-ansi: ^6.0.1
+    wide-align: ^1.1.5
+  checksum: 65077b87a7138bf465c7ea9541a81cdaeba42224f8650427529d47dda99c0a9273b596a8ee54a62af2a04a31682fa49de9b35ef7dd52ed8da5f0436d288ead23
+  languageName: node
+  linkType: hard
+
+"gaze@npm:^1.0.0":
+  version: 1.1.3
+  resolution: "gaze@npm:1.1.3"
+  dependencies:
+    globule: ^1.0.0
+  checksum: d5fd375a029c07346154806a076bde21290598179d01ffbe7bc3e54092fa65814180bd27fc2b577582737733eec77cdbb7a572a4e73dff934dde60317223cde6
+  languageName: node
+  linkType: hard
+
+"gensync@npm:^1.0.0-beta.1, gensync@npm:^1.0.0-beta.2":
+  version: 1.0.0-beta.2
+  resolution: "gensync@npm:1.0.0-beta.2"
+  checksum: a7437e58c6be12aa6c90f7730eac7fa9833dc78872b4ad2963d2031b00a3367a93f98aec75f9aaac7220848e4026d67a8655e870b24f20a543d103c0d65952ec
+  languageName: node
+  linkType: hard
+
+"get-caller-file@npm:^1.0.1":
+  version: 1.0.3
+  resolution: "get-caller-file@npm:1.0.3"
+  checksum: 2b90a7f848896abcebcdc0acc627a435bcf05b9cd280599bc980ebfcdc222416c3df12c24c4845f69adc4346728e8966f70b758f9369f3534182791dfbc25c05
+  languageName: node
+  linkType: hard
+
+"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5":
+  version: 2.0.5
+  resolution: "get-caller-file@npm:2.0.5"
+  checksum: b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9
+  languageName: node
+  linkType: hard
+
+"get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.0, get-intrinsic@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "get-intrinsic@npm:1.1.1"
+  dependencies:
+    function-bind: ^1.1.1
+    has: ^1.0.3
+    has-symbols: ^1.0.1
+  checksum: a9fe2ca8fa3f07f9b0d30fb202bcd01f3d9b9b6b732452e79c48e79f7d6d8d003af3f9e38514250e3553fdc83c61650851cb6870832ac89deaaceb08e3721a17
+  languageName: node
+  linkType: hard
+
+"get-own-enumerable-property-symbols@npm:^3.0.0":
+  version: 3.0.2
+  resolution: "get-own-enumerable-property-symbols@npm:3.0.2"
+  checksum: 8f0331f14159f939830884799f937343c8c0a2c330506094bc12cbee3665d88337fe97a4ea35c002cc2bdba0f5d9975ad7ec3abb925015cdf2a93e76d4759ede
+  languageName: node
+  linkType: hard
+
+"get-stdin@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "get-stdin@npm:4.0.1"
+  checksum: 4f73d3fe0516bc1f3dc7764466a68ad7c2ba809397a02f56c2a598120e028430fcff137a648a01876b2adfb486b4bc164119f98f1f7d7c0abd63385bdaa0113f
+  languageName: node
+  linkType: hard
+
+"get-stream@npm:^4.0.0":
+  version: 4.1.0
+  resolution: "get-stream@npm:4.1.0"
+  dependencies:
+    pump: ^3.0.0
+  checksum: 443e1914170c15bd52ff8ea6eff6dfc6d712b031303e36302d2778e3de2506af9ee964d6124010f7818736dcfde05c04ba7ca6cc26883106e084357a17ae7d73
+  languageName: node
+  linkType: hard
+
+"get-stream@npm:^5.0.0, get-stream@npm:^5.1.0":
+  version: 5.2.0
+  resolution: "get-stream@npm:5.2.0"
+  dependencies:
+    pump: ^3.0.0
+  checksum: 8bc1a23174a06b2b4ce600df38d6c98d2ef6d84e020c1ddad632ad75bac4e092eeb40e4c09e0761c35fc2dbc5e7fff5dab5e763a383582c4a167dd69a905bd12
+  languageName: node
+  linkType: hard
+
+"get-value@npm:^2.0.3, get-value@npm:^2.0.6":
+  version: 2.0.6
+  resolution: "get-value@npm:2.0.6"
+  checksum: 5c3b99cb5398ea8016bf46ff17afc5d1d286874d2ad38ca5edb6e87d75c0965b0094cb9a9dddef2c59c23d250702323539a7fbdd870620db38c7e7d7ec87c1eb
+  languageName: node
+  linkType: hard
+
+"getos@npm:^3.2.1":
+  version: 3.2.1
+  resolution: "getos@npm:3.2.1"
+  dependencies:
+    async: ^3.2.0
+  checksum: 42fd78a66d47cebd3e09de5566cc0044e034b08f4a000a310dbd89a77b02c65d8f4002554bfa495ea5bdc4fa9d515f5ac785a7cc474ba45383cc697f865eeaf1
+  languageName: node
+  linkType: hard
+
+"getpass@npm:^0.1.1":
+  version: 0.1.7
+  resolution: "getpass@npm:0.1.7"
+  dependencies:
+    assert-plus: ^1.0.0
+  checksum: ab18d55661db264e3eac6012c2d3daeafaab7a501c035ae0ccb193c3c23e9849c6e29b6ac762b9c2adae460266f925d55a3a2a3a3c8b94be2f222df94d70c046
+  languageName: node
+  linkType: hard
+
+"glob-parent@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "glob-parent@npm:3.1.0"
+  dependencies:
+    is-glob: ^3.1.0
+    path-dirname: ^1.0.0
+  checksum: 653d559237e89a11b9934bef3f392ec42335602034c928590544d383ff5ef449f7b12f3cfa539708e74bc0a6c28ab1fe51d663cc07463cdf899ba92afd85a855
+  languageName: node
+  linkType: hard
+
+"glob-parent@npm:^5.0.0, glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2":
+  version: 5.1.2
+  resolution: "glob-parent@npm:5.1.2"
+  dependencies:
+    is-glob: ^4.0.1
+  checksum: f4f2bfe2425296e8a47e36864e4f42be38a996db40420fe434565e4480e3322f18eb37589617a98640c5dc8fdec1a387007ee18dbb1f3f5553409c34d17f425e
+  languageName: node
+  linkType: hard
+
+"glob-to-regexp@npm:^0.3.0":
+  version: 0.3.0
+  resolution: "glob-to-regexp@npm:0.3.0"
+  checksum: d34b3219d860042d508c4893b67617cd16e2668827e445ff39cff9f72ef70361d3dc24f429e003cdfb6607c75c9664b8eadc41d2eeb95690af0b0d3113c1b23b
+  languageName: node
+  linkType: hard
+
+"glob@npm:^7.0.0, glob@npm:^7.0.3, glob@npm:^7.0.5, glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:~7.1.1":
+  version: 7.1.7
+  resolution: "glob@npm:7.1.7"
+  dependencies:
+    fs.realpath: ^1.0.0
+    inflight: ^1.0.4
+    inherits: 2
+    minimatch: ^3.0.4
+    once: ^1.3.0
+    path-is-absolute: ^1.0.0
+  checksum: b61f48973bbdcf5159997b0874a2165db572b368b931135832599875919c237fc05c12984e38fe828e69aa8a921eb0e8a4997266211c517c9cfaae8a93988bb8
+  languageName: node
+  linkType: hard
+
+"glob@npm:^8.0.1":
+  version: 8.1.0
+  resolution: "glob@npm:8.1.0"
+  dependencies:
+    fs.realpath: ^1.0.0
+    inflight: ^1.0.4
+    inherits: 2
+    minimatch: ^5.0.1
+    once: ^1.3.0
+  checksum: 92fbea3221a7d12075f26f0227abac435de868dd0736a17170663783296d0dd8d3d532a5672b4488a439bf5d7fb85cdd07c11185d6cd39184f0385cbdfb86a47
+  languageName: node
+  linkType: hard
+
+"global-dirs@npm:^3.0.0":
+  version: 3.0.1
+  resolution: "global-dirs@npm:3.0.1"
+  dependencies:
+    ini: 2.0.0
+  checksum: 70147b80261601fd40ac02a104581432325c1c47329706acd773f3a6ce99bb36d1d996038c85ccacd482ad22258ec233c586b6a91535b1a116b89663d49d6438
+  languageName: node
+  linkType: hard
+
+"global-modules@npm:2.0.0":
+  version: 2.0.0
+  resolution: "global-modules@npm:2.0.0"
+  dependencies:
+    global-prefix: ^3.0.0
+  checksum: d6197f25856c878c2fb5f038899f2dca7cbb2f7b7cf8999660c0104972d5cfa5c68b5a0a77fa8206bb536c3903a4615665acb9709b4d80846e1bb47eaef65430
+  languageName: node
+  linkType: hard
+
+"global-prefix@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "global-prefix@npm:3.0.0"
+  dependencies:
+    ini: ^1.3.5
+    kind-of: ^6.0.2
+    which: ^1.3.1
+  checksum: 8a82fc1d6f22c45484a4e34656cc91bf021a03e03213b0035098d605bfc612d7141f1e14a21097e8a0413b4884afd5b260df0b6a25605ce9d722e11f1df2881d
+  languageName: node
+  linkType: hard
+
+"globals@npm:^11.1.0":
+  version: 11.12.0
+  resolution: "globals@npm:11.12.0"
+  checksum: 67051a45eca3db904aee189dfc7cd53c20c7d881679c93f6146ddd4c9f4ab2268e68a919df740d39c71f4445d2b38ee360fc234428baea1dbdfe68bbcb46979e
+  languageName: node
+  linkType: hard
+
+"globals@npm:^12.1.0":
+  version: 12.4.0
+  resolution: "globals@npm:12.4.0"
+  dependencies:
+    type-fest: ^0.8.1
+  checksum: 7ae5ee16a96f1e8d71065405f57da0e33267f6b070cd36a5444c7780dd28639b48b92413698ac64f04bf31594f9108878bd8cb158ecdf759c39e05634fefcca6
+  languageName: node
+  linkType: hard
+
+"globby@npm:8.0.2":
+  version: 8.0.2
+  resolution: "globby@npm:8.0.2"
+  dependencies:
+    array-union: ^1.0.1
+    dir-glob: 2.0.0
+    fast-glob: ^2.0.2
+    glob: ^7.1.2
+    ignore: ^3.3.5
+    pify: ^3.0.0
+    slash: ^1.0.0
+  checksum: 87dc31e0b812d3a6beee200555c252591d23ef12f8347bce3b61fa185a99fbe7ae1694ed30cc01a353e27369d6a8e1e50a97f1c5e2547fa7b1d87d8392ff9264
+  languageName: node
+  linkType: hard
+
+"globby@npm:^11.0.3":
+  version: 11.1.0
+  resolution: "globby@npm:11.1.0"
+  dependencies:
+    array-union: ^2.1.0
+    dir-glob: ^3.0.1
+    fast-glob: ^3.2.9
+    ignore: ^5.2.0
+    merge2: ^1.4.1
+    slash: ^3.0.0
+  checksum: b4be8885e0cfa018fc783792942d53926c35c50b3aefd3fdcfb9d22c627639dc26bd2327a40a0b74b074100ce95bb7187bfeae2f236856aa3de183af7a02aea6
+  languageName: node
+  linkType: hard
+
+"globby@npm:^6.1.0":
+  version: 6.1.0
+  resolution: "globby@npm:6.1.0"
+  dependencies:
+    array-union: ^1.0.1
+    glob: ^7.0.3
+    object-assign: ^4.0.1
+    pify: ^2.0.0
+    pinkie-promise: ^2.0.0
+  checksum: 18109d6b9d55643d2b98b59c3cfae7073ccfe39829632f353d516cc124d836c2ddebe48a23f04af63d66a621b6d86dd4cbd7e6af906f2458a7fe510ffc4bd424
+  languageName: node
+  linkType: hard
+
+"globule@npm:^1.0.0":
+  version: 1.3.2
+  resolution: "globule@npm:1.3.2"
+  dependencies:
+    glob: ~7.1.1
+    lodash: ~4.17.10
+    minimatch: ~3.0.2
+  checksum: 2e79c8c0bb8405c92abe43d633b737a511b4791fbca21646adf0dae2ff27c2a95a702347808cd4292e7730668e95fa5de164811f40f86f1774b7a9ff8ed0d1ec
+  languageName: node
+  linkType: hard
+
+"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.2":
+  version: 4.2.6
+  resolution: "graceful-fs@npm:4.2.6"
+  checksum: 792e64aafda05a151289f83eaa16aff34ef259658cefd65393883d959409f5a2389b0ec9ebf28f3d21f1b0ddc8f594a1162ae9b18e2b507a6799a70706ec573d
+  languageName: node
+  linkType: hard
+
+"graceful-fs@npm:^4.2.6":
+  version: 4.2.9
+  resolution: "graceful-fs@npm:4.2.9"
+  checksum: 68ea4e07ff2c041ada184f9278b830375f8e0b75154e3f080af6b70f66172fabb4108d19b3863a96b53fc068a310b9b6493d86d1291acc5f3861eb4b79d26ad6
+  languageName: node
+  linkType: hard
+
+"growly@npm:^1.3.0":
+  version: 1.3.0
+  resolution: "growly@npm:1.3.0"
+  checksum: 53cdecd4c16d7d9154a9061a9ccb87d602e957502ca69b529d7d1b2436c2c0b700ec544fc6b3e4cd115d59b81e62e44ce86bd0521403b579d3a2a97d7ce72a44
+  languageName: node
+  linkType: hard
+
+"gzip-size@npm:5.1.1":
+  version: 5.1.1
+  resolution: "gzip-size@npm:5.1.1"
+  dependencies:
+    duplexer: ^0.1.1
+    pify: ^4.0.1
+  checksum: 6451ba2210877368f6d9ee9b4dc0d14501671472801323bf81fbd38bdeb8525f40a78be45a59d0182895d51e6b60c6314b7d02bd6ed40e7225a01e8d038aac1b
+  languageName: node
+  linkType: hard
+
+"handle-thing@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "handle-thing@npm:2.0.1"
+  checksum: 68071f313062315cd9dce55710e9496873945f1dd425107007058fc1629f93002a7649fcc3e464281ce02c7e809a35f5925504ab8105d972cf649f1f47cb7d6c
+  languageName: node
+  linkType: hard
+
+"har-schema@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "har-schema@npm:2.0.0"
+  checksum: d8946348f333fb09e2bf24cc4c67eabb47c8e1d1aa1c14184c7ffec1140a49ec8aa78aa93677ae452d71d5fc0fdeec20f0c8c1237291fc2bcb3f502a5d204f9b
+  languageName: node
+  linkType: hard
+
+"har-validator@npm:~5.1.3":
+  version: 5.1.5
+  resolution: "har-validator@npm:5.1.5"
+  dependencies:
+    ajv: ^6.12.3
+    har-schema: ^2.0.0
+  checksum: b998a7269ca560d7f219eedc53e2c664cd87d487e428ae854a6af4573fc94f182fe9d2e3b92ab968249baec7ebaf9ead69cf975c931dc2ab282ec182ee988280
+  languageName: node
+  linkType: hard
+
+"hard-rejection@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "hard-rejection@npm:2.1.0"
+  checksum: 7baaf80a0c7fff4ca79687b4060113f1529589852152fa935e6787a2bc96211e784ad4588fb3048136ff8ffc9dfcf3ae385314a5b24db32de20bea0d1597f9dc
+  languageName: node
+  linkType: hard
+
+"harmony-reflect@npm:^1.4.6":
+  version: 1.6.2
+  resolution: "harmony-reflect@npm:1.6.2"
+  checksum: 2e5bae414cd2bfae5476147f9935dc69ee9b9a413206994dcb94c5b3208d4555da3d4313aff6fd14bd9991c1e3ef69cdda5c8fac1eb1d7afc064925839339b8c
+  languageName: node
+  linkType: hard
+
+"has-ansi@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "has-ansi@npm:2.0.0"
+  dependencies:
+    ansi-regex: ^2.0.0
+  checksum: 1b51daa0214440db171ff359d0a2d17bc20061164c57e76234f614c91dbd2a79ddd68dfc8ee73629366f7be45a6df5f2ea9de83f52e1ca24433f2cc78c35d8ec
+  languageName: node
+  linkType: hard
+
+"has-bigints@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "has-bigints@npm:1.0.1"
+  checksum: 44ab55868174470065d2e0f8f6def1c990d12b82162a8803c679699fa8a39f966e336f2a33c185092fe8aea7e8bf2e85f1c26add5f29d98f2318bd270096b183
+  languageName: node
+  linkType: hard
+
+"has-flag@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "has-flag@npm:3.0.0"
+  checksum: 4a15638b454bf086c8148979aae044dd6e39d63904cd452d970374fa6a87623423da485dfb814e7be882e05c096a7ccf1ebd48e7e7501d0208d8384ff4dea73b
+  languageName: node
+  linkType: hard
+
+"has-flag@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "has-flag@npm:4.0.0"
+  checksum: 261a1357037ead75e338156b1f9452c016a37dcd3283a972a30d9e4a87441ba372c8b81f818cd0fbcd9c0354b4ae7e18b9e1afa1971164aef6d18c2b6095a8ad
+  languageName: node
+  linkType: hard
+
+"has-symbols@npm:^1.0.1, has-symbols@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "has-symbols@npm:1.0.2"
+  checksum: 2309c426071731be792b5be43b3da6fb4ed7cbe8a9a6bcfca1862587709f01b33d575ce8f5c264c1eaad09fca2f9a8208c0a2be156232629daa2dd0c0740976b
+  languageName: node
+  linkType: hard
+
+"has-unicode@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "has-unicode@npm:2.0.1"
+  checksum: 1eab07a7436512db0be40a710b29b5dc21fa04880b7f63c9980b706683127e3c1b57cb80ea96d47991bdae2dfe479604f6a1ba410106ee1046a41d1bd0814400
+  languageName: node
+  linkType: hard
+
+"has-value@npm:^0.3.1":
+  version: 0.3.1
+  resolution: "has-value@npm:0.3.1"
+  dependencies:
+    get-value: ^2.0.3
+    has-values: ^0.1.4
+    isobject: ^2.0.0
+  checksum: 29e2a1e6571dad83451b769c7ce032fce6009f65bccace07c2962d3ad4d5530b6743d8f3229e4ecf3ea8e905d23a752c5f7089100c1f3162039fa6dc3976558f
+  languageName: node
+  linkType: hard
+
+"has-value@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "has-value@npm:1.0.0"
+  dependencies:
+    get-value: ^2.0.6
+    has-values: ^1.0.0
+    isobject: ^3.0.0
+  checksum: b9421d354e44f03d3272ac39fd49f804f19bc1e4fa3ceef7745df43d6b402053f828445c03226b21d7d934a21ac9cf4bc569396dc312f496ddff873197bbd847
+  languageName: node
+  linkType: hard
+
+"has-values@npm:^0.1.4":
+  version: 0.1.4
+  resolution: "has-values@npm:0.1.4"
+  checksum: ab1c4bcaf811ccd1856c11cfe90e62fca9e2b026ebe474233a3d282d8d67e3b59ed85b622c7673bac3db198cb98bd1da2b39300a2f98e453729b115350af49bc
+  languageName: node
+  linkType: hard
+
+"has-values@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "has-values@npm:1.0.0"
+  dependencies:
+    is-number: ^3.0.0
+    kind-of: ^4.0.0
+  checksum: 77e6693f732b5e4cf6c38dfe85fdcefad0fab011af74995c3e83863fabf5e3a836f406d83565816baa0bc0a523c9410db8b990fe977074d61aeb6d8f4fcffa11
+  languageName: node
+  linkType: hard
+
+"has@npm:^1.0.0, has@npm:^1.0.3":
+  version: 1.0.3
+  resolution: "has@npm:1.0.3"
+  dependencies:
+    function-bind: ^1.1.1
+  checksum: b9ad53d53be4af90ce5d1c38331e712522417d017d5ef1ebd0507e07c2fbad8686fffb8e12ddecd4c39ca9b9b47431afbb975b8abf7f3c3b82c98e9aad052792
+  languageName: node
+  linkType: hard
+
+"hash-base@npm:^3.0.0":
+  version: 3.1.0
+  resolution: "hash-base@npm:3.1.0"
+  dependencies:
+    inherits: ^2.0.4
+    readable-stream: ^3.6.0
+    safe-buffer: ^5.2.0
+  checksum: 26b7e97ac3de13cb23fc3145e7e3450b0530274a9562144fc2bf5c1e2983afd0e09ed7cc3b20974ba66039fad316db463da80eb452e7373e780cbee9a0d2f2dc
+  languageName: node
+  linkType: hard
+
+"hash-base@npm:~3.0":
+  version: 3.0.4
+  resolution: "hash-base@npm:3.0.4"
+  dependencies:
+    inherits: ^2.0.1
+    safe-buffer: ^5.0.1
+  checksum: 878465a0dfcc33cce195c2804135352c590d6d10980adc91a9005fd377e77f2011256c2b7cfce472e3f2e92d561d1bf3228d2da06348a9017ce9a258b3b49764
+  languageName: node
+  linkType: hard
+
+"hash.js@npm:^1.0.0, hash.js@npm:^1.0.3":
+  version: 1.1.7
+  resolution: "hash.js@npm:1.1.7"
+  dependencies:
+    inherits: ^2.0.3
+    minimalistic-assert: ^1.0.1
+  checksum: e350096e659c62422b85fa508e4b3669017311aa4c49b74f19f8e1bc7f3a54a584fdfd45326d4964d6011f2b2d882e38bea775a96046f2a61b7779a979629d8f
+  languageName: node
+  linkType: hard
+
+"he@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "he@npm:1.2.0"
+  bin:
+    he: bin/he
+  checksum: 3d4d6babccccd79c5c5a3f929a68af33360d6445587d628087f39a965079d84f18ce9c3d3f917ee1e3978916fc833bb8b29377c3b403f919426f91bc6965e7a7
+  languageName: node
+  linkType: hard
+
+"hex-color-regex@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "hex-color-regex@npm:1.1.0"
+  checksum: 44fa1b7a26d745012f3bfeeab8015f60514f72d2fcf10dce33068352456b8d71a2e6bc5a17f933ab470da2c5ab1e3e04b05caf3fefe3c1cabd7e02e516fc8784
+  languageName: node
+  linkType: hard
+
+"highlight-words-core@npm:^1.2.0":
+  version: 1.2.2
+  resolution: "highlight-words-core@npm:1.2.2"
+  checksum: 737758a8a572c82919552b031df300016164b7d0db6a819d24bc6c7ca2279d3cd6d03497728930d6402423c7a3fc2f42c628a9b01b025c704a0b56a635377511
+  languageName: node
+  linkType: hard
+
+"history@npm:^4.7.2":
+  version: 4.10.1
+  resolution: "history@npm:4.10.1"
+  dependencies:
+    "@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
+  checksum: addd84bc4683929bae4400419b5af132ff4e4e9b311a0d4e224579ea8e184a6b80d7f72c55927e4fa117f69076a9e47ce082d8d0b422f1a9ddac7991490ca1d0
+  languageName: node
+  linkType: hard
+
+"hmac-drbg@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "hmac-drbg@npm:1.0.1"
+  dependencies:
+    hash.js: ^1.0.3
+    minimalistic-assert: ^1.0.0
+    minimalistic-crypto-utils: ^1.0.1
+  checksum: bd30b6a68d7f22d63f10e1888aee497d7c2c5c0bb469e66bbdac99f143904d1dfe95f8131f95b3e86c86dd239963c9d972fcbe147e7cffa00e55d18585c43fe0
+  languageName: node
+  linkType: hard
+
+"hoist-non-react-statics@npm:^2.3.1, hoist-non-react-statics@npm:^2.5.0, hoist-non-react-statics@npm:^2.5.4":
+  version: 2.5.5
+  resolution: "hoist-non-react-statics@npm:2.5.5"
+  checksum: ee2d05e5c7e1398ad84a15b0327f66bd78f38a8e0015e852f954b36434e32eb7e942d5357505020a3a1147f247b165bf1e69d72393e3accab67cafdafeb86230
+  languageName: node
+  linkType: hard
+
+"hoist-non-react-statics@npm:^3.2.1":
+  version: 3.3.2
+  resolution: "hoist-non-react-statics@npm:3.3.2"
+  dependencies:
+    react-is: ^16.7.0
+  checksum: b1538270429b13901ee586aa44f4cc3ecd8831c061d06cb8322e50ea17b3f5ce4d0e2e66394761e6c8e152cd8c34fb3b4b690116c6ce2bd45b18c746516cb9e8
+  languageName: node
+  linkType: hard
+
+"hosted-git-info@npm:^2.1.4":
+  version: 2.8.9
+  resolution: "hosted-git-info@npm:2.8.9"
+  checksum: c955394bdab888a1e9bb10eb33029e0f7ce5a2ac7b3f158099dc8c486c99e73809dca609f5694b223920ca2174db33d32b12f9a2a47141dc59607c29da5a62dd
+  languageName: node
+  linkType: hard
+
+"hosted-git-info@npm:^4.0.1":
+  version: 4.1.0
+  resolution: "hosted-git-info@npm:4.1.0"
+  dependencies:
+    lru-cache: ^6.0.0
+  checksum: c3f87b3c2f7eb8c2748c8f49c0c2517c9a95f35d26f4bf54b2a8cba05d2e668f3753548b6ea366b18ec8dadb4e12066e19fa382a01496b0ffa0497eb23cbe461
+  languageName: node
+  linkType: hard
+
+"hpack.js@npm:^2.1.6":
+  version: 2.1.6
+  resolution: "hpack.js@npm:2.1.6"
+  dependencies:
+    inherits: ^2.0.1
+    obuf: ^1.0.0
+    readable-stream: ^2.0.1
+    wbuf: ^1.1.0
+  checksum: 2de144115197967ad6eeee33faf41096c6ba87078703c5cb011632dcfbffeb45784569e0cf02c317bd79c48375597c8ec88c30fff5bb0b023e8f654fb6e9c06e
+  languageName: node
+  linkType: hard
+
+"hsl-regex@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "hsl-regex@npm:1.0.0"
+  checksum: de9ee1bf39de1b83cc3fa0fa1cc337f29f14911e79411d66347365c54fab6b109eea2dd741eaa02486e24de31627ad7bf4453f22224fb55a2fe2b58166fa63b8
+  languageName: node
+  linkType: hard
+
+"hsla-regex@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "hsla-regex@npm:1.0.0"
+  checksum: 9aa6eb9ff6c102d2395435aa5d1d91eae20043c4b1497c543d8db501c05f3edacd9a07fb34a987059d7902dba415af4cb4e610f751859ae8e7525df4ffcd085f
+  languageName: node
+  linkType: hard
+
+"html-element-map@npm:^1.2.0":
+  version: 1.3.1
+  resolution: "html-element-map@npm:1.3.1"
+  dependencies:
+    array.prototype.filter: ^1.0.0
+    call-bind: ^1.0.2
+  checksum: 7408da008d37bfa76b597e298ae0ed530258065deb29fbd73d40f7cbd123b654d1022a7a8cfbe713e57d90c5bef844399f5c8a46cde7d55c91d305024c921d08
+  languageName: node
+  linkType: hard
+
+"html-encoding-sniffer@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "html-encoding-sniffer@npm:1.0.2"
+  dependencies:
+    whatwg-encoding: ^1.0.1
+  checksum: b874df6750451b7642fbe8e998c6bdd2911b0f42ad2927814b717bf1f4b082b0904b6178a1bfbc40117bf5799777993b0825e7713ca0fca49844e5aec03aa0e2
+  languageName: node
+  linkType: hard
+
+"html-entities@npm:^1.3.1":
+  version: 1.4.0
+  resolution: "html-entities@npm:1.4.0"
+  checksum: 4b73ffb9eead200f99146e4fbe70acb0af2fea136901a131fc3a782e9ef876a7cbb07dec303ca1f8804232b812249dbf3643a270c9c524852065d9224a8dcdd0
+  languageName: node
+  linkType: hard
+
+"html-escaper@npm:^2.0.0":
+  version: 2.0.2
+  resolution: "html-escaper@npm:2.0.2"
+  checksum: d2df2da3ad40ca9ee3a39c5cc6475ef67c8f83c234475f24d8e9ce0dc80a2c82df8e1d6fa78ddd1e9022a586ea1bd247a615e80a5cd9273d90111ddda7d9e974
+  languageName: node
+  linkType: hard
+
+"html-minifier-terser@npm:^5.0.1":
+  version: 5.1.1
+  resolution: "html-minifier-terser@npm:5.1.1"
+  dependencies:
+    camel-case: ^4.1.1
+    clean-css: ^4.2.3
+    commander: ^4.1.1
+    he: ^1.2.0
+    param-case: ^3.0.3
+    relateurl: ^0.2.7
+    terser: ^4.6.3
+  bin:
+    html-minifier-terser: cli.js
+  checksum: 75ff3ff886631b9ecb3035acb8e7dd98c599bb4d4618ad6f7e487ee9752987dddcf6848dc3c1ab1d7fc1ad4484337c2ce39c19eac17b0342b4b15e4294c8a904
+  languageName: node
+  linkType: hard
+
+"html-webpack-plugin@npm:4.0.0-beta.11":
+  version: 4.0.0-beta.11
+  resolution: "html-webpack-plugin@npm:4.0.0-beta.11"
+  dependencies:
+    html-minifier-terser: ^5.0.1
+    loader-utils: ^1.2.3
+    lodash: ^4.17.15
+    pretty-error: ^2.1.1
+    tapable: ^1.1.3
+    util.promisify: 1.0.0
+  peerDependencies:
+    webpack: ^4.0.0
+  checksum: ea34c7a12d20f938c59e6b5f404aaddac4689ec622995b748ce13e0016e52a199ff25a837b905dd756bebcfb35465435d4c455ed36e16bae3d3dc5e0706d0030
+  languageName: node
+  linkType: hard
+
+"htmlparser2@npm:^6.1.0":
+  version: 6.1.0
+  resolution: "htmlparser2@npm:6.1.0"
+  dependencies:
+    domelementtype: ^2.0.1
+    domhandler: ^4.0.0
+    domutils: ^2.5.2
+    entities: ^2.0.0
+  checksum: 81a7b3d9c3bb9acb568a02fc9b1b81ffbfa55eae7f1c41ae0bf840006d1dbf54cb3aa245b2553e2c94db674840a9f0fdad7027c9a9d01a062065314039058c4e
+  languageName: node
+  linkType: hard
+
+"http-cache-semantics@npm:^4.1.0":
+  version: 4.1.1
+  resolution: "http-cache-semantics@npm:4.1.1"
+  checksum: 83ac0bc60b17a3a36f9953e7be55e5c8f41acc61b22583060e8dedc9dd5e3607c823a88d0926f9150e571f90946835c7fe150732801010845c72cd8bbff1a236
+  languageName: node
+  linkType: hard
+
+"http-deceiver@npm:^1.2.7":
+  version: 1.2.7
+  resolution: "http-deceiver@npm:1.2.7"
+  checksum: 64d7d1ae3a6933eb0e9a94e6f27be4af45a53a96c3c34e84ff57113787105a89fff9d1c3df263ef63add823df019b0e8f52f7121e32393bb5ce9a713bf100b41
+  languageName: node
+  linkType: hard
+
+"http-errors@npm:2.0.0":
+  version: 2.0.0
+  resolution: "http-errors@npm:2.0.0"
+  dependencies:
+    depd: 2.0.0
+    inherits: 2.0.4
+    setprototypeof: 1.2.0
+    statuses: 2.0.1
+    toidentifier: 1.0.1
+  checksum: 9b0a3782665c52ce9dc658a0d1560bcb0214ba5699e4ea15aefb2a496e2ca83db03ebc42e1cce4ac1f413e4e0d2d736a3fd755772c556a9a06853ba2a0b7d920
+  languageName: node
+  linkType: hard
+
+"http-errors@npm:~1.6.2":
+  version: 1.6.3
+  resolution: "http-errors@npm:1.6.3"
+  dependencies:
+    depd: ~1.1.2
+    inherits: 2.0.3
+    setprototypeof: 1.1.0
+    statuses: ">= 1.4.0 < 2"
+  checksum: a9654ee027e3d5de305a56db1d1461f25709ac23267c6dc28cdab8323e3f96caa58a9a6a5e93ac15d7285cee0c2f019378c3ada9026e7fe19c872d695f27de7c
+  languageName: node
+  linkType: hard
+
+"http-parser-js@npm:>=0.5.1":
+  version: 0.5.3
+  resolution: "http-parser-js@npm:0.5.3"
+  checksum: 6f3142c5f60ad995a6895a1dc4f70f8cef0910745366e97cbcb99caa604590dbcc11006b00989ad306837d6b820e9bfc6e801c4060ed19a0e4df83caa8577cb5
+  languageName: node
+  linkType: hard
+
+"http-proxy-agent@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "http-proxy-agent@npm:4.0.1"
+  dependencies:
+    "@tootallnate/once": 1
+    agent-base: 6
+    debug: 4
+  checksum: c6a5da5a1929416b6bbdf77b1aca13888013fe7eb9d59fc292e25d18e041bb154a8dfada58e223fc7b76b9b2d155a87e92e608235201f77d34aa258707963a82
+  languageName: node
+  linkType: hard
+
+"http-proxy-agent@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "http-proxy-agent@npm:5.0.0"
+  dependencies:
+    "@tootallnate/once": 2
+    agent-base: 6
+    debug: 4
+  checksum: e2ee1ff1656a131953839b2a19cd1f3a52d97c25ba87bd2559af6ae87114abf60971e498021f9b73f9fd78aea8876d1fb0d4656aac8a03c6caa9fc175f22b786
+  languageName: node
+  linkType: hard
+
+"http-proxy-middleware@npm:0.19.1":
+  version: 0.19.1
+  resolution: "http-proxy-middleware@npm:0.19.1"
+  dependencies:
+    http-proxy: ^1.17.0
+    is-glob: ^4.0.0
+    lodash: ^4.17.11
+    micromatch: ^3.1.10
+  checksum: 64df0438417a613bb22b3689d9652a1b7a56f10b145a463f95f4e8a9b9a351f2c63bc5fd3a9cd710baec224897733b6f299cb7f974ea82769b2a4f1e074764ac
+  languageName: node
+  linkType: hard
+
+"http-proxy@npm:^1.17.0":
+  version: 1.18.1
+  resolution: "http-proxy@npm:1.18.1"
+  dependencies:
+    eventemitter3: ^4.0.0
+    follow-redirects: ^1.0.0
+    requires-port: ^1.0.0
+  checksum: f5bd96bf83e0b1e4226633dbb51f8b056c3e6321917df402deacec31dd7fe433914fc7a2c1831cf7ae21e69c90b3a669b8f434723e9e8b71fd68afe30737b6a5
+  languageName: node
+  linkType: hard
+
+"http-signature@npm:~1.2.0":
+  version: 1.2.0
+  resolution: "http-signature@npm:1.2.0"
+  dependencies:
+    assert-plus: ^1.0.0
+    jsprim: ^1.2.2
+    sshpk: ^1.7.0
+  checksum: 3324598712266a9683585bb84a75dec4fd550567d5e0dd4a0fff6ff3f74348793404d3eeac4918fa0902c810eeee1a86419e4a2e92a164132dfe6b26743fb47c
+  languageName: node
+  linkType: hard
+
+"http-signature@npm:~1.3.6":
+  version: 1.3.6
+  resolution: "http-signature@npm:1.3.6"
+  dependencies:
+    assert-plus: ^1.0.0
+    jsprim: ^2.0.2
+    sshpk: ^1.14.1
+  checksum: 10be2af4764e71fee0281392937050201ee576ac755c543f570d6d87134ce5e858663fe999a7adb3e4e368e1e356d0d7fec6b9542295b875726ff615188e7a0c
+  languageName: node
+  linkType: hard
+
+"https-browserify@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "https-browserify@npm:1.0.0"
+  checksum: 09b35353e42069fde2435760d13f8a3fb7dd9105e358270e2e225b8a94f811b461edd17cb57594e5f36ec1218f121c160ddceeec6e8be2d55e01dcbbbed8cbae
+  languageName: node
+  linkType: hard
+
+"https-proxy-agent@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "https-proxy-agent@npm:5.0.0"
+  dependencies:
+    agent-base: 6
+    debug: 4
+  checksum: 165bfb090bd26d47693597661298006841ab733d0c7383a8cb2f17373387a94c903a3ac687090aa739de05e379ab6f868bae84ab4eac288ad85c328cd1ec9e53
+  languageName: node
+  linkType: hard
+
+"human-signals@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "human-signals@npm:1.1.1"
+  checksum: d587647c9e8ec24e02821b6be7de5a0fc37f591f6c4e319b3054b43fd4c35a70a94c46fc74d8c1a43c47fde157d23acd7421f375e1c1365b09a16835b8300205
+  languageName: node
+  linkType: hard
+
+"humanize-ms@npm:^1.2.1":
+  version: 1.2.1
+  resolution: "humanize-ms@npm:1.2.1"
+  dependencies:
+    ms: ^2.0.0
+  checksum: 9c7a74a2827f9294c009266c82031030eae811ca87b0da3dceb8d6071b9bde22c9f3daef0469c3c533cc67a97d8a167cd9fc0389350e5f415f61a79b171ded16
+  languageName: node
+  linkType: hard
+
+"hyphenate-style-name@npm:^1.0.2":
+  version: 1.0.4
+  resolution: "hyphenate-style-name@npm:1.0.4"
+  checksum: 4f5bf4b055089754924babebaa23c17845937bcca6aee95d5d015f8fa1e6814279002bd6a9e541e3fac2cd02519fc76305396727066c57c8e21a7e73e7a12137
+  languageName: node
+  linkType: hard
+
+"iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24":
+  version: 0.4.24
+  resolution: "iconv-lite@npm:0.4.24"
+  dependencies:
+    safer-buffer: ">= 2.1.2 < 3"
+  checksum: bd9f120f5a5b306f0bc0b9ae1edeb1577161503f5f8252a20f1a9e56ef8775c9959fd01c55f2d3a39d9a8abaf3e30c1abeb1895f367dcbbe0a8fd1c9ca01c4f6
+  languageName: node
+  linkType: hard
+
+"iconv-lite@npm:^0.6.2":
+  version: 0.6.3
+  resolution: "iconv-lite@npm:0.6.3"
+  dependencies:
+    safer-buffer: ">= 2.1.2 < 3.0.0"
+  checksum: 3f60d47a5c8fc3313317edfd29a00a692cc87a19cac0159e2ce711d0ebc9019064108323b5e493625e25594f11c6236647d8e256fbe7a58f4a3b33b89e6d30bf
+  languageName: node
+  linkType: hard
+
+"icss-utils@npm:^4.0.0, icss-utils@npm:^4.1.1":
+  version: 4.1.1
+  resolution: "icss-utils@npm:4.1.1"
+  dependencies:
+    postcss: ^7.0.14
+  checksum: a4ca2c6b82cb3eb879d635bd4028d74bca174edc49ee48ef5f01988489747d340a389d5a0ac6f6887a5c24ab8fc4386c781daab32a7ade5344a2edff66207635
+  languageName: node
+  linkType: hard
+
+"identity-obj-proxy@npm:3.0.0":
+  version: 3.0.0
+  resolution: "identity-obj-proxy@npm:3.0.0"
+  dependencies:
+    harmony-reflect: ^1.4.6
+  checksum: 97559f8ea2aeaa1a880d279d8c49550dce01148321e00a2102cda5ddf9ce622fa1d7f3efc7bed63458af78889de888fdaebaf31c816312298bb3fdd0ef8aaf2c
+  languageName: node
+  linkType: hard
+
+"ieee754@npm:^1.1.13, ieee754@npm:^1.1.4":
+  version: 1.2.1
+  resolution: "ieee754@npm:1.2.1"
+  checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e
+  languageName: node
+  linkType: hard
+
+"iferr@npm:^0.1.5":
+  version: 0.1.5
+  resolution: "iferr@npm:0.1.5"
+  checksum: a18d19b6ad06a2d5412c0d37f6364869393ef6d1688d59d00082c1f35c92399094c031798340612458cd832f4f2e8b13bc9615934a7d8b0c53061307a3816aa1
+  languageName: node
+  linkType: hard
+
+"ignore@npm:^3.3.5":
+  version: 3.3.10
+  resolution: "ignore@npm:3.3.10"
+  checksum: 23e8cc776e367b56615ab21b78decf973a35dfca5522b39d9b47643d8168473b0d1f18dd1321a1bab466a12ea11a2411903f3b21644f4d5461ee0711ec8678bd
+  languageName: node
+  linkType: hard
+
+"ignore@npm:^4.0.6":
+  version: 4.0.6
+  resolution: "ignore@npm:4.0.6"
+  checksum: 248f82e50a430906f9ee7f35e1158e3ec4c3971451dd9f99c9bc1548261b4db2b99709f60ac6c6cac9333494384176cc4cc9b07acbe42d52ac6a09cad734d800
+  languageName: node
+  linkType: hard
+
+"ignore@npm:^5.2.0":
+  version: 5.2.4
+  resolution: "ignore@npm:5.2.4"
+  checksum: 3d4c309c6006e2621659311783eaea7ebcd41fe4ca1d78c91c473157ad6666a57a2df790fe0d07a12300d9aac2888204d7be8d59f9aaf665b1c7fcdb432517ef
+  languageName: node
+  linkType: hard
+
+"image-extensions@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "image-extensions@npm:1.1.0"
+  checksum: e69d4385231b8ab166f8fd428b44bde64ea4601eaff89dbff83ab5811c2964e71e4c6124aceab57f90cdfe3cf012aaeaa50aa34a998b5fcfc581a660c706709d
+  languageName: node
+  linkType: hard
+
+"immediate@npm:~3.0.5":
+  version: 3.0.6
+  resolution: "immediate@npm:3.0.6"
+  checksum: f9b3486477555997657f70318cc8d3416159f208bec4cca3ff3442fd266bc23f50f0c9bd8547e1371a6b5e82b821ec9a7044a4f7b944798b25aa3cc6d5e63e62
+  languageName: node
+  linkType: hard
+
+"immer@npm:1.10.0":
+  version: 1.10.0
+  resolution: "immer@npm:1.10.0"
+  checksum: 8bdce9ebd81861dcef21725bc0f9cc456c2051188b7c001bcd9b9dffb9519cd897ab207f475b5425b83767a4b1fba76b4665e3f3c41171e24ea938c3cd02d035
+  languageName: node
+  linkType: hard
+
+"immutable@npm:^3.8.1":
+  version: 3.8.2
+  resolution: "immutable@npm:3.8.2"
+  checksum: 41909b386950ff84ca3cfca77c74cfc87d225a914e98e6c57996fa81a328da61a7c32216d6d5abad40f54747ffdc5c4b02b102e6ad1a504c1752efde8041f964
+  languageName: node
+  linkType: hard
+
+"immutable@npm:~3.7.4":
+  version: 3.7.6
+  resolution: "immutable@npm:3.7.6"
+  checksum: 8cccfb22d3ecf14fe0c474612e96d6bb5d117493e7639fe6642fb81e78c9ac4b698dd8a322c105001a709ad873ffc90e30bad7db5d9a3ef0b54a6e1db0258e8e
+  languageName: node
+  linkType: hard
+
+"import-cwd@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "import-cwd@npm:2.1.0"
+  dependencies:
+    import-from: ^2.1.0
+  checksum: b8786fa3578f3df55370352bf61f99c2d8e6ee9b5741a07503d5a73d99281d141330a8faf87078e67527be4558f758356791ee5efb4b0112ac5eaed0f07de544
+  languageName: node
+  linkType: hard
+
+"import-fresh@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "import-fresh@npm:2.0.0"
+  dependencies:
+    caller-path: ^2.0.0
+    resolve-from: ^3.0.0
+  checksum: 610255f9753cc6775df00be08e9f43691aa39f7703e3636c45afe22346b8b545e600ccfe100c554607546fc8e861fa149a0d1da078c8adedeea30fff326eef79
+  languageName: node
+  linkType: hard
+
+"import-fresh@npm:^3.0.0, import-fresh@npm:^3.1.0":
+  version: 3.3.0
+  resolution: "import-fresh@npm:3.3.0"
+  dependencies:
+    parent-module: ^1.0.0
+    resolve-from: ^4.0.0
+  checksum: 2cacfad06e652b1edc50be650f7ec3be08c5e5a6f6d12d035c440a42a8cc028e60a5b99ca08a77ab4d6b1346da7d971915828f33cdab730d3d42f08242d09baa
+  languageName: node
+  linkType: hard
+
+"import-from@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "import-from@npm:2.1.0"
+  dependencies:
+    resolve-from: ^3.0.0
+  checksum: 91f6f89f46a07227920ef819181bb52eb93023ccc0bdf00224fdfb326f8f753e279ad06819f39a02bb88c9d3a4606adc85b0cc995285e5d65feeb59f1421a1d4
+  languageName: node
+  linkType: hard
+
+"import-local@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "import-local@npm:2.0.0"
+  dependencies:
+    pkg-dir: ^3.0.0
+    resolve-cwd: ^2.0.0
+  bin:
+    import-local-fixture: fixtures/cli.js
+  checksum: b8469252483624379fd65d53c82f3658b32a1136f7168bfeea961a4ea7ca10a45786ea2b02e0006408f9cd22d2f33305a6f17a64e4d5a03274a50942c5e7c949
+  languageName: node
+  linkType: hard
+
+"imurmurhash@npm:^0.1.4":
+  version: 0.1.4
+  resolution: "imurmurhash@npm:0.1.4"
+  checksum: 7cae75c8cd9a50f57dadd77482359f659eaebac0319dd9368bcd1714f55e65badd6929ca58569da2b6494ef13fdd5598cd700b1eba23f8b79c5f19d195a3ecf7
+  languageName: node
+  linkType: hard
+
+"indefinite-observable@npm:^1.0.1":
+  version: 1.0.2
+  resolution: "indefinite-observable@npm:1.0.2"
+  dependencies:
+    symbol-observable: 1.2.0
+  checksum: 69a337967f48fca18989f9d68ad98c7220ed9b499bf00330ff72669a9583cb7f8e82f801da10720f720edf1313f427c77ce793350bdc413c952cec8ce112fc12
+  languageName: node
+  linkType: hard
+
+"indent-string@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "indent-string@npm:2.1.0"
+  dependencies:
+    repeating: ^2.0.0
+  checksum: 2fe7124311435f4d7a98f0a314d8259a4ec47ecb221110a58e2e2073e5f75c8d2b4f775f2ed199598fbe20638917e57423096539455ca8bff8eab113c9bee12c
+  languageName: node
+  linkType: hard
+
+"indent-string@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "indent-string@npm:4.0.0"
+  checksum: 824cfb9929d031dabf059bebfe08cf3137365e112019086ed3dcff6a0a7b698cb80cf67ccccde0e25b9e2d7527aa6cc1fed1ac490c752162496caba3e6699612
+  languageName: node
+  linkType: hard
+
+"indexes-of@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "indexes-of@npm:1.0.1"
+  checksum: 4f9799b1739a62f3e02d09f6f4162cf9673025282af7fa36e790146e7f4e216dad3e776a25b08536c093209c9fcb5ea7bd04b082d42686a45f58ff401d6da32e
+  languageName: node
+  linkType: hard
+
+"infer-owner@npm:^1.0.3, infer-owner@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "infer-owner@npm:1.0.4"
+  checksum: 181e732764e4a0611576466b4b87dac338972b839920b2a8cde43642e4ed6bd54dc1fb0b40874728f2a2df9a1b097b8ff83b56d5f8f8e3927f837fdcb47d8a89
+  languageName: node
+  linkType: hard
+
+"inflight@npm:^1.0.4":
+  version: 1.0.6
+  resolution: "inflight@npm:1.0.6"
+  dependencies:
+    once: ^1.3.0
+    wrappy: 1
+  checksum: f4f76aa072ce19fae87ce1ef7d221e709afb59d445e05d47fba710e85470923a75de35bfae47da6de1b18afc3ce83d70facf44cfb0aff89f0a3f45c0a0244dfd
+  languageName: node
+  linkType: hard
+
+"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.0, inherits@npm:~2.0.1, inherits@npm:~2.0.3":
+  version: 2.0.4
+  resolution: "inherits@npm:2.0.4"
+  checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1
+  languageName: node
+  linkType: hard
+
+"inherits@npm:2.0.1":
+  version: 2.0.1
+  resolution: "inherits@npm:2.0.1"
+  checksum: 6536b9377296d4ce8ee89c5c543cb75030934e61af42dba98a428e7d026938c5985ea4d1e3b87743a5b834f40ed1187f89c2d7479e9d59e41d2d1051aefba07b
+  languageName: node
+  linkType: hard
+
+"inherits@npm:2.0.3":
+  version: 2.0.3
+  resolution: "inherits@npm:2.0.3"
+  checksum: 78cb8d7d850d20a5e9a7f3620db31483aa00ad5f722ce03a55b110e5a723539b3716a3b463e2b96ce3fe286f33afc7c131fa2f91407528ba80cea98a7545d4c0
+  languageName: node
+  linkType: hard
+
+"ini@npm:2.0.0":
+  version: 2.0.0
+  resolution: "ini@npm:2.0.0"
+  checksum: e7aadc5fb2e4aefc666d74ee2160c073995a4061556b1b5b4241ecb19ad609243b9cceafe91bae49c219519394bbd31512516cb22a3b1ca6e66d869e0447e84e
+  languageName: node
+  linkType: hard
+
+"ini@npm:^1.3.5":
+  version: 1.3.8
+  resolution: "ini@npm:1.3.8"
+  checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3
+  languageName: node
+  linkType: hard
+
+"inquirer@npm:7.0.4":
+  version: 7.0.4
+  resolution: "inquirer@npm:7.0.4"
+  dependencies:
+    ansi-escapes: ^4.2.1
+    chalk: ^2.4.2
+    cli-cursor: ^3.1.0
+    cli-width: ^2.0.0
+    external-editor: ^3.0.3
+    figures: ^3.0.0
+    lodash: ^4.17.15
+    mute-stream: 0.0.8
+    run-async: ^2.2.0
+    rxjs: ^6.5.3
+    string-width: ^4.1.0
+    strip-ansi: ^5.1.0
+    through: ^2.3.6
+  checksum: 01a87cdbe74e7eb5ca770580f0d6bcad0269e6b0da97107aa9e2b37446a795aac63a63865d33410e964441499f9ac34a84c2e97c40d1abe2e57efc7f0d5b416d
+  languageName: node
+  linkType: hard
+
+"inquirer@npm:^7.0.0":
+  version: 7.3.3
+  resolution: "inquirer@npm:7.3.3"
+  dependencies:
+    ansi-escapes: ^4.2.1
+    chalk: ^4.1.0
+    cli-cursor: ^3.1.0
+    cli-width: ^3.0.0
+    external-editor: ^3.0.3
+    figures: ^3.0.0
+    lodash: ^4.17.19
+    mute-stream: 0.0.8
+    run-async: ^2.4.0
+    rxjs: ^6.6.0
+    string-width: ^4.1.0
+    strip-ansi: ^6.0.0
+    through: ^2.3.6
+  checksum: 4d387fc1eb6126acbd58cbdb9ad99d2887d181df86ab0c2b9abdf734e751093e2d5882c2b6dc7144d9ab16b7ab30a78a1d7f01fb6a2850a44aeb175d1e3f8778
+  languageName: node
+  linkType: hard
+
+"internal-ip@npm:^4.3.0":
+  version: 4.3.0
+  resolution: "internal-ip@npm:4.3.0"
+  dependencies:
+    default-gateway: ^4.2.0
+    ipaddr.js: ^1.9.0
+  checksum: c970433c84d9a6b46e2c9f5ab7785d3105b856d0a566891bf919241b5a884c5c1c9bf8e915aebb822a86c14b1b6867e58c1eaf5cd49eb023368083069d1a4a9a
+  languageName: node
+  linkType: hard
+
+"internal-slot@npm:^1.0.3":
+  version: 1.0.3
+  resolution: "internal-slot@npm:1.0.3"
+  dependencies:
+    get-intrinsic: ^1.1.0
+    has: ^1.0.3
+    side-channel: ^1.0.4
+  checksum: 1944f92e981e47aebc98a88ff0db579fd90543d937806104d0b96557b10c1f170c51fb777b97740a8b6ddeec585fca8c39ae99fd08a8e058dfc8ab70937238bf
+  languageName: node
+  linkType: hard
+
+"invariant@npm:^2.0.0, invariant@npm:^2.1.0, invariant@npm:^2.2.2, invariant@npm:^2.2.4":
+  version: 2.2.4
+  resolution: "invariant@npm:2.2.4"
+  dependencies:
+    loose-envify: ^1.0.0
+  checksum: cc3182d793aad82a8d1f0af697b462939cb46066ec48bbf1707c150ad5fad6406137e91a262022c269702e01621f35ef60269f6c0d7fd178487959809acdfb14
+  languageName: node
+  linkType: hard
+
+"invert-kv@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "invert-kv@npm:1.0.0"
+  checksum: aebeee31dda3b3d25ffd242e9a050926e7fe5df642d60953ab183aca1a7d1ffb39922eb2618affb0e850cf2923116f0da1345367759d88d097df5da1f1e1590e
+  languageName: node
+  linkType: hard
+
+"ip-regex@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "ip-regex@npm:2.1.0"
+  checksum: 331d95052aa53ce245745ea0fc3a6a1e2e3c8d6da65fa8ea52bf73768c1b22a9ac50629d1d2b08c04e7b3ac4c21b536693c149ce2c2615ee4796030e5b3e3cba
+  languageName: node
+  linkType: hard
+
+"ip@npm:^1.1.0, ip@npm:^1.1.5":
+  version: 1.1.9
+  resolution: "ip@npm:1.1.9"
+  checksum: b6d91fd45a856e3bd6d4f601ea0619d90f3517638f6918ebd079f959a8a6308568d8db5ef4fdf037e0d9cfdcf264f46833dfeea81ca31309cf0a7eb4b1307b84
+  languageName: node
+  linkType: hard
+
+"ip@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "ip@npm:2.0.1"
+  checksum: d765c9fd212b8a99023a4cde6a558a054c298d640fec1020567494d257afd78ca77e37126b1a3ef0e053646ced79a816bf50621d38d5e768cdde0431fa3b0d35
+  languageName: node
+  linkType: hard
+
+"ipaddr.js@npm:1.9.1, ipaddr.js@npm:^1.9.0":
+  version: 1.9.1
+  resolution: "ipaddr.js@npm:1.9.1"
+  checksum: f88d3825981486f5a1942414c8d77dd6674dd71c065adcfa46f578d677edcb99fda25af42675cb59db492fdf427b34a5abfcde3982da11a8fd83a500b41cfe77
+  languageName: node
+  linkType: hard
+
+"is-absolute-url@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "is-absolute-url@npm:2.1.0"
+  checksum: 781e8cf8a2af54b1b7a92f269244d96c66224030d91120e734ebeebbce044c167767e1389789d8aaf82f9e429cb20ae93d6d0acfe6c4b53d2bd6ebb47a236d76
+  languageName: node
+  linkType: hard
+
+"is-absolute-url@npm:^3.0.3":
+  version: 3.0.3
+  resolution: "is-absolute-url@npm:3.0.3"
+  checksum: 5159b51d065d9ad29e16a2f78d6c0e41c43227caf90a45e659c54ea6fd50ef0595b1871ce392e84b1df7cfdcad9a8e66eec0813a029112188435abf115accb16
+  languageName: node
+  linkType: hard
+
+"is-accessor-descriptor@npm:^0.1.6":
+  version: 0.1.6
+  resolution: "is-accessor-descriptor@npm:0.1.6"
+  dependencies:
+    kind-of: ^3.0.2
+  checksum: 3d629a086a9585bc16a83a8e8a3416f400023301855cafb7ccc9a1d63145b7480f0ad28877dcc2cce09492c4ec1c39ef4c071996f24ee6ac626be4217b8ffc8a
+  languageName: node
+  linkType: hard
+
+"is-accessor-descriptor@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "is-accessor-descriptor@npm:1.0.0"
+  dependencies:
+    kind-of: ^6.0.0
+  checksum: 8e475968e9b22f9849343c25854fa24492dbe8ba0dea1a818978f9f1b887339190b022c9300d08c47fe36f1b913d70ce8cbaca00369c55a56705fdb7caed37fe
+  languageName: node
+  linkType: hard
+
+"is-arguments@npm:^1.0.4":
+  version: 1.1.0
+  resolution: "is-arguments@npm:1.1.0"
+  dependencies:
+    call-bind: ^1.0.0
+  checksum: c32f8b5052061de83b2cd17e0e87ec914ac96e55bbd184e07f9b78b8360d80f7f9a34060d44ee8684249664609213f57447e0f63798e7c265ec811fd242b0077
+  languageName: node
+  linkType: hard
+
+"is-arrayish@npm:^0.2.1":
+  version: 0.2.1
+  resolution: "is-arrayish@npm:0.2.1"
+  checksum: eef4417e3c10e60e2c810b6084942b3ead455af16c4509959a27e490e7aee87cfb3f38e01bbde92220b528a0ee1a18d52b787e1458ee86174d8c7f0e58cd488f
+  languageName: node
+  linkType: hard
+
+"is-arrayish@npm:^0.3.1":
+  version: 0.3.2
+  resolution: "is-arrayish@npm:0.3.2"
+  checksum: 977e64f54d91c8f169b59afcd80ff19227e9f5c791fa28fa2e5bce355cbaf6c2c356711b734656e80c9dd4a854dd7efcf7894402f1031dfc5de5d620775b4d5f
+  languageName: node
+  linkType: hard
+
+"is-bigint@npm:^1.0.1":
+  version: 1.0.2
+  resolution: "is-bigint@npm:1.0.2"
+  checksum: 5268edbde844583d8d5ce86f8e47669bf9dd9b3d4de0238b25bb2ddfc620b47e0e226171a906f19ac4c10debba160353fb98c134d0309898495e1b691efcfb80
+  languageName: node
+  linkType: hard
+
+"is-binary-path@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "is-binary-path@npm:1.0.1"
+  dependencies:
+    binary-extensions: ^1.0.0
+  checksum: a803c99e9d898170c3b44a86fbdc0736d3d7fcbe737345433fb78e810b9fe30c982657782ad0e676644ba4693ddf05601a7423b5611423218663d6b533341ac9
+  languageName: node
+  linkType: hard
+
+"is-binary-path@npm:~2.1.0":
+  version: 2.1.0
+  resolution: "is-binary-path@npm:2.1.0"
+  dependencies:
+    binary-extensions: ^2.0.0
+  checksum: 84192eb88cff70d320426f35ecd63c3d6d495da9d805b19bc65b518984b7c0760280e57dbf119b7e9be6b161784a5a673ab2c6abe83abb5198a432232ad5b35c
+  languageName: node
+  linkType: hard
+
+"is-boolean-object@npm:^1.0.1, is-boolean-object@npm:^1.1.0":
+  version: 1.1.1
+  resolution: "is-boolean-object@npm:1.1.1"
+  dependencies:
+    call-bind: ^1.0.2
+  checksum: 95b832242638b8495d012538716761122dfc4a930baf2aa676e0bc344fe39cda2364c739893a6d07d10863ced67cc95e11884732104d7904bd0d896033414d11
+  languageName: node
+  linkType: hard
+
+"is-buffer@npm:^1.0.2, is-buffer@npm:^1.1.5":
+  version: 1.1.6
+  resolution: "is-buffer@npm:1.1.6"
+  checksum: 4a186d995d8bbf9153b4bd9ff9fd04ae75068fe695d29025d25e592d9488911eeece84eefbd8fa41b8ddcc0711058a71d4c466dcf6f1f6e1d83830052d8ca707
+  languageName: node
+  linkType: hard
+
+"is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.1.5, is-callable@npm:^1.2.3":
+  version: 1.2.3
+  resolution: "is-callable@npm:1.2.3"
+  checksum: 084a732afd78e14a40cd5f6f34001edd500f43bb542991c1305b88842cab5f2fb6b48f0deed4cd72270b2e71cab3c3a56c69b324e3a02d486f937824bb7de553
+  languageName: node
+  linkType: hard
+
+"is-ci@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "is-ci@npm:2.0.0"
+  dependencies:
+    ci-info: ^2.0.0
+  bin:
+    is-ci: bin.js
+  checksum: 77b869057510f3efa439bbb36e9be429d53b3f51abd4776eeea79ab3b221337fe1753d1e50058a9e2c650d38246108beffb15ccfd443929d77748d8c0cc90144
+  languageName: node
+  linkType: hard
+
+"is-ci@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "is-ci@npm:3.0.1"
+  dependencies:
+    ci-info: ^3.2.0
+  bin:
+    is-ci: bin.js
+  checksum: 192c66dc7826d58f803ecae624860dccf1899fc1f3ac5505284c0a5cf5f889046ffeb958fa651e5725d5705c5bcb14f055b79150ea5fcad7456a9569de60260e
+  languageName: node
+  linkType: hard
+
+"is-color-stop@npm:^1.0.0":
+  version: 1.1.0
+  resolution: "is-color-stop@npm:1.1.0"
+  dependencies:
+    css-color-names: ^0.0.4
+    hex-color-regex: ^1.1.0
+    hsl-regex: ^1.0.0
+    hsla-regex: ^1.0.0
+    rgb-regex: ^1.0.1
+    rgba-regex: ^1.0.0
+  checksum: 778dd52a603ab8da827925aa4200fe6733b667b216495a04110f038b925dc5ef58babe759b94ffc4e44fcf439328695770873937f59d6045f676322b97f3f92d
+  languageName: node
+  linkType: hard
+
+"is-core-module@npm:^2.2.0":
+  version: 2.4.0
+  resolution: "is-core-module@npm:2.4.0"
+  dependencies:
+    has: ^1.0.3
+  checksum: c498902d4c4d0e8eba3a2e8293ccd442158cfe49a71d7cfad136ccf9902b6a41de34ddaa86cdc95c8b7c22f872e59572d8a5d994cbec04c8ecf27ffe75137119
+  languageName: node
+  linkType: hard
+
+"is-core-module@npm:^2.5.0":
+  version: 2.13.0
+  resolution: "is-core-module@npm:2.13.0"
+  dependencies:
+    has: ^1.0.3
+  checksum: 053ab101fb390bfeb2333360fd131387bed54e476b26860dc7f5a700bbf34a0ec4454f7c8c4d43e8a0030957e4b3db6e16d35e1890ea6fb654c833095e040355
+  languageName: node
+  linkType: hard
+
+"is-data-descriptor@npm:^0.1.4":
+  version: 0.1.4
+  resolution: "is-data-descriptor@npm:0.1.4"
+  dependencies:
+    kind-of: ^3.0.2
+  checksum: 5c622e078ba933a78338ae398a3d1fc5c23332b395312daf4f74bab4afb10d061cea74821add726cb4db8b946ba36217ee71a24fe71dd5bca4632edb7f6aad87
+  languageName: node
+  linkType: hard
+
+"is-data-descriptor@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "is-data-descriptor@npm:1.0.0"
+  dependencies:
+    kind-of: ^6.0.0
+  checksum: e705e6816241c013b05a65dc452244ee378d1c3e3842bd140beabe6e12c0d700ef23c91803f971aa7b091fb0573c5da8963af34a2b573337d87bc3e1f53a4e6d
+  languageName: node
+  linkType: hard
+
+"is-date-object@npm:^1.0.1":
+  version: 1.0.4
+  resolution: "is-date-object@npm:1.0.4"
+  checksum: 20ce7b73fda926b4dfad2457e0d6fa04bb0a4cf555456d68918e334cbf80ac30523155adac420be0c8a4bc126fafe0874c4cfc0ffe0d97bac6333a8f02de1b94
+  languageName: node
+  linkType: hard
+
+"is-descriptor@npm:^0.1.0":
+  version: 0.1.6
+  resolution: "is-descriptor@npm:0.1.6"
+  dependencies:
+    is-accessor-descriptor: ^0.1.6
+    is-data-descriptor: ^0.1.4
+    kind-of: ^5.0.0
+  checksum: 0f780c1b46b465f71d970fd7754096ffdb7b69fd8797ca1f5069c163eaedcd6a20ec4a50af669075c9ebcfb5266d2e53c8b227e485eefdb0d1fee09aa1dd8ab6
+  languageName: node
+  linkType: hard
+
+"is-descriptor@npm:^1.0.0, is-descriptor@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "is-descriptor@npm:1.0.2"
+  dependencies:
+    is-accessor-descriptor: ^1.0.0
+    is-data-descriptor: ^1.0.0
+    kind-of: ^6.0.2
+  checksum: 2ed623560bee035fb67b23e32ce885700bef8abe3fbf8c909907d86507b91a2c89a9d3a4d835a4d7334dd5db0237a0aeae9ca109c1e4ef1c0e7b577c0846ab5a
+  languageName: node
+  linkType: hard
+
+"is-directory@npm:^0.3.1":
+  version: 0.3.1
+  resolution: "is-directory@npm:0.3.1"
+  checksum: dce9a9d3981e38f2ded2a80848734824c50ee8680cd09aa477bef617949715cfc987197a2ca0176c58a9fb192a1a0d69b535c397140d241996a609d5906ae524
+  languageName: node
+  linkType: hard
+
+"is-docker@npm:^2.0.0":
+  version: 2.2.1
+  resolution: "is-docker@npm:2.2.1"
+  bin:
+    is-docker: cli.js
+  checksum: 3fef7ddbf0be25958e8991ad941901bf5922ab2753c46980b60b05c1bf9c9c2402d35e6dc32e4380b980ef5e1970a5d9d5e5aa2e02d77727c3b6b5e918474c56
+  languageName: node
+  linkType: hard
+
+"is-extendable@npm:^0.1.0, is-extendable@npm:^0.1.1":
+  version: 0.1.1
+  resolution: "is-extendable@npm:0.1.1"
+  checksum: 3875571d20a7563772ecc7a5f36cb03167e9be31ad259041b4a8f73f33f885441f778cee1f1fe0085eb4bc71679b9d8c923690003a36a6a5fdf8023e6e3f0672
+  languageName: node
+  linkType: hard
+
+"is-extendable@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "is-extendable@npm:1.0.1"
+  dependencies:
+    is-plain-object: ^2.0.4
+  checksum: db07bc1e9de6170de70eff7001943691f05b9d1547730b11be01c0ebfe67362912ba743cf4be6fd20a5e03b4180c685dad80b7c509fe717037e3eee30ad8e84f
+  languageName: node
+  linkType: hard
+
+"is-extglob@npm:^2.1.0, is-extglob@npm:^2.1.1":
+  version: 2.1.1
+  resolution: "is-extglob@npm:2.1.1"
+  checksum: df033653d06d0eb567461e58a7a8c9f940bd8c22274b94bf7671ab36df5719791aae15eef6d83bbb5e23283967f2f984b8914559d4449efda578c775c4be6f85
+  languageName: node
+  linkType: hard
+
+"is-finite@npm:^1.0.0":
+  version: 1.1.0
+  resolution: "is-finite@npm:1.1.0"
+  checksum: 532b97ed3d03e04c6bd203984d9e4ba3c0c390efee492bad5d1d1cd1802a68ab27adbd3ef6382f6312bed6c8bb1bd3e325ea79a8dc8fe080ed7a06f5f97b93e7
+  languageName: node
+  linkType: hard
+
+"is-fullwidth-code-point@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "is-fullwidth-code-point@npm:1.0.0"
+  dependencies:
+    number-is-nan: ^1.0.0
+  checksum: 4d46a7465a66a8aebcc5340d3b63a56602133874af576a9ca42c6f0f4bd787a743605771c5f246db77da96605fefeffb65fc1dbe862dcc7328f4b4d03edf5a57
+  languageName: node
+  linkType: hard
+
+"is-fullwidth-code-point@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "is-fullwidth-code-point@npm:2.0.0"
+  checksum: eef9c6e15f68085fec19ff6a978a6f1b8f48018fd1265035552078ee945573594933b09bbd6f562553e2a241561439f1ef5339276eba68d272001343084cfab8
+  languageName: node
+  linkType: hard
+
+"is-fullwidth-code-point@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "is-fullwidth-code-point@npm:3.0.0"
+  checksum: 44a30c29457c7fb8f00297bce733f0a64cd22eca270f83e58c105e0d015e45c019491a4ab2faef91ab51d4738c670daff901c799f6a700e27f7314029e99e348
+  languageName: node
+  linkType: hard
+
+"is-generator-fn@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "is-generator-fn@npm:2.1.0"
+  checksum: a6ad5492cf9d1746f73b6744e0c43c0020510b59d56ddcb78a91cbc173f09b5e6beff53d75c9c5a29feb618bfef2bf458e025ecf3a57ad2268e2fb2569f56215
+  languageName: node
+  linkType: hard
+
+"is-glob@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "is-glob@npm:3.1.0"
+  dependencies:
+    is-extglob: ^2.1.0
+  checksum: 9d483bca84f16f01230f7c7c8c63735248fe1064346f292e0f6f8c76475fd20c6f50fc19941af5bec35f85d6bf26f4b7768f39a48a5f5fdc72b408dc74e07afc
+  languageName: node
+  linkType: hard
+
+"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:~4.0.1":
+  version: 4.0.1
+  resolution: "is-glob@npm:4.0.1"
+  dependencies:
+    is-extglob: ^2.1.1
+  checksum: 84627cad11b4e745f5db5a163f32c47b711585a5ff6e14f8f8d026db87f4cdd3e2c95f6fa1f94ad22e469f36d819ae2814f03f9c668b164422ac3354a94672d3
+  languageName: node
+  linkType: hard
+
+"is-image@npm:*, is-image@npm:3.0.0":
+  version: 3.0.0
+  resolution: "is-image@npm:3.0.0"
+  dependencies:
+    image-extensions: ^1.1.0
+  checksum: 7587619fc0e6325540dffdda84a8a52b0b79841aba7bcc474a4308a1b6432aa4c81c26b67e2576b7987cccde0a11f705545dab907310da92c150d56b094188e5
+  languageName: node
+  linkType: hard
+
+"is-in-browser@npm:^1.0.2, is-in-browser@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "is-in-browser@npm:1.1.3"
+  checksum: 178491f97f6663c0574565701b76f41633dbe065e4bd8d518ce017a8fa25e5109ecb6a3bd8bd55c0aba11b208f86b9f0f9c91f3664e148ebf618b74a74fcaf09
+  languageName: node
+  linkType: hard
+
+"is-installed-globally@npm:~0.4.0":
+  version: 0.4.0
+  resolution: "is-installed-globally@npm:0.4.0"
+  dependencies:
+    global-dirs: ^3.0.0
+    is-path-inside: ^3.0.2
+  checksum: 3359840d5982d22e9b350034237b2cda2a12bac1b48a721912e1ab8e0631dd07d45a2797a120b7b87552759a65ba03e819f1bd63f2d7ab8657ec0b44ee0bf399
+  languageName: node
+  linkType: hard
+
+"is-lambda@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "is-lambda@npm:1.0.1"
+  checksum: 93a32f01940220532e5948538699ad610d5924ac86093fcee83022252b363eb0cc99ba53ab084a04e4fb62bf7b5731f55496257a4c38adf87af9c4d352c71c35
+  languageName: node
+  linkType: hard
+
+"is-negative-zero@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "is-negative-zero@npm:2.0.1"
+  checksum: a46f2e0cb5e16fdb8f2011ed488979386d7e68d381966682e3f4c98fc126efe47f26827912baca2d06a02a644aee458b9cba307fb389f6b161e759125db7a3b8
+  languageName: node
+  linkType: hard
+
+"is-number-object@npm:^1.0.4":
+  version: 1.0.5
+  resolution: "is-number-object@npm:1.0.5"
+  checksum: 8c217b4a16632fc3a900121792e4293f2d2d3c73158895deca4593aa4779995203fc6f31b57b47d90df981936a82ea4e8e8a3af2e5ed646cf979287c1d201089
+  languageName: node
+  linkType: hard
+
+"is-number@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "is-number@npm:3.0.0"
+  dependencies:
+    kind-of: ^3.0.2
+  checksum: 0c62bf8e9d72c4dd203a74d8cfc751c746e75513380fef420cda8237e619a988ee43e678ddb23c87ac24d91ac0fe9f22e4ffb1301a50310c697e9d73ca3994e9
+  languageName: node
+  linkType: hard
+
+"is-number@npm:^7.0.0":
+  version: 7.0.0
+  resolution: "is-number@npm:7.0.0"
+  checksum: 456ac6f8e0f3111ed34668a624e45315201dff921e5ac181f8ec24923b99e9f32ca1a194912dc79d539c97d33dba17dc635202ff0b2cf98326f608323276d27a
+  languageName: node
+  linkType: hard
+
+"is-obj@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "is-obj@npm:1.0.1"
+  checksum: 3ccf0efdea12951e0b9c784e2b00e77e87b2f8bd30b42a498548a8afcc11b3287342a2030c308e473e93a7a19c9ea7854c99a8832a476591c727df2a9c79796c
+  languageName: node
+  linkType: hard
+
+"is-obj@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "is-obj@npm:2.0.0"
+  checksum: c9916ac8f4621962a42f5e80e7ffdb1d79a3fab7456ceaeea394cd9e0858d04f985a9ace45be44433bf605673c8be8810540fe4cc7f4266fc7526ced95af5a08
+  languageName: node
+  linkType: hard
+
+"is-path-cwd@npm:^2.0.0":
+  version: 2.2.0
+  resolution: "is-path-cwd@npm:2.2.0"
+  checksum: 46a840921bb8cc0dc7b5b423a14220e7db338072a4495743a8230533ce78812dc152548c86f4b828411fe98c5451959f07cf841c6a19f611e46600bd699e8048
+  languageName: node
+  linkType: hard
+
+"is-path-in-cwd@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "is-path-in-cwd@npm:2.1.0"
+  dependencies:
+    is-path-inside: ^2.1.0
+  checksum: 6b01b3f8c9172e9682ea878d001836a0cc5a78cbe6236024365d478c2c9e384da2417e5f21f2ad2da2761d0465309fc5baf6e71187d2a23f0058da69790f7f48
+  languageName: node
+  linkType: hard
+
+"is-path-inside@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "is-path-inside@npm:2.1.0"
+  dependencies:
+    path-is-inside: ^1.0.2
+  checksum: 6ca34dbd84d5c50a3ee1547afb6ada9b06d556a4ff42da9b303797e4acc3ac086516a4833030aa570f397f8c58dacabd57ee8e6c2ce8b2396a986ad2af10fcaf
+  languageName: node
+  linkType: hard
+
+"is-path-inside@npm:^3.0.2":
+  version: 3.0.3
+  resolution: "is-path-inside@npm:3.0.3"
+  checksum: abd50f06186a052b349c15e55b182326f1936c89a78bf6c8f2b707412517c097ce04bc49a0ca221787bc44e1049f51f09a2ffb63d22899051988d3a618ba13e9
+  languageName: node
+  linkType: hard
+
+"is-plain-obj@npm:^1.0.0, is-plain-obj@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "is-plain-obj@npm:1.1.0"
+  checksum: 0ee04807797aad50859652a7467481816cbb57e5cc97d813a7dcd8915da8195dc68c436010bf39d195226cde6a2d352f4b815f16f26b7bf486a5754290629931
+  languageName: node
+  linkType: hard
+
+"is-plain-object@npm:^2.0.1, is-plain-object@npm:^2.0.3, is-plain-object@npm:^2.0.4":
+  version: 2.0.4
+  resolution: "is-plain-object@npm:2.0.4"
+  dependencies:
+    isobject: ^3.0.1
+  checksum: 2a401140cfd86cabe25214956ae2cfee6fbd8186809555cd0e84574f88de7b17abacb2e477a6a658fa54c6083ecbda1e6ae404c7720244cd198903848fca70ca
+  languageName: node
+  linkType: hard
+
+"is-promise@npm:^2.1.0":
+  version: 2.2.2
+  resolution: "is-promise@npm:2.2.2"
+  checksum: 18bf7d1c59953e0ad82a1ed963fb3dc0d135c8f299a14f89a17af312fc918373136e56028e8831700e1933519630cc2fd4179a777030330fde20d34e96f40c78
+  languageName: node
+  linkType: hard
+
+"is-regex@npm:^1.0.4, is-regex@npm:^1.0.5, is-regex@npm:^1.1.0, is-regex@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "is-regex@npm:1.1.3"
+  dependencies:
+    call-bind: ^1.0.2
+    has-symbols: ^1.0.2
+  checksum: 19a831a1ba88d09bb43ab30194672e6ae1461caff27254d2c160ed63c95015155ad8784e80995e46a637d0880da8f4ed63b5c3242af1b49c0b5c4666a7a2d3d8
+  languageName: node
+  linkType: hard
+
+"is-regexp@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "is-regexp@npm:1.0.0"
+  checksum: be692828e24cba479ec33644326fa98959ec68ba77965e0291088c1a741feaea4919d79f8031708f85fd25e39de002b4520622b55460660b9c369e6f7187faef
+  languageName: node
+  linkType: hard
+
+"is-resolvable@npm:^1.0.0":
+  version: 1.1.0
+  resolution: "is-resolvable@npm:1.1.0"
+  checksum: 2ddff983be0cabc2c8d60246365755f8fb322f5fb9db834740d3e694c635c1b74c1bd674cf221e072fc4bd911ef3f08f2247d390e476f7e80af9092443193c68
+  languageName: node
+  linkType: hard
+
+"is-root@npm:2.1.0":
+  version: 2.1.0
+  resolution: "is-root@npm:2.1.0"
+  checksum: 37eea0822a2a9123feb58a9d101558ba276771a6d830f87005683349a9acff15958a9ca590a44e778c6b335660b83e85c744789080d734f6081a935a4880aee2
+  languageName: node
+  linkType: hard
+
+"is-stream@npm:^1.0.1, is-stream@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "is-stream@npm:1.1.0"
+  checksum: 063c6bec9d5647aa6d42108d4c59723d2bd4ae42135a2d4db6eadbd49b7ea05b750fd69d279e5c7c45cf9da753ad2c00d8978be354d65aa9f6bb434969c6a2ae
+  languageName: node
+  linkType: hard
+
+"is-stream@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "is-stream@npm:2.0.0"
+  checksum: 4dc47738e26bc4f1b3be9070b6b9e39631144f204fc6f87db56961220add87c10a999ba26cf81699f9ef9610426f69cb08a4713feff8deb7d8cadac907826935
+  languageName: node
+  linkType: hard
+
+"is-string@npm:^1.0.5, is-string@npm:^1.0.6":
+  version: 1.0.6
+  resolution: "is-string@npm:1.0.6"
+  checksum: 9990bf0abf2eea6255f0218f82ba1bcfc8d27923af99bcbb2c77ec5eae4ddbe6c23f1f916d6f19f9e9aa57ec7cd8a91a3e026a34e207c51af35fced1ad50bba8
+  languageName: node
+  linkType: hard
+
+"is-subset@npm:^0.1.1":
+  version: 0.1.1
+  resolution: "is-subset@npm:0.1.1"
+  checksum: 97b8d7852af165269b7495095691a6ce6cf20bdfa1f846f97b4560ee190069686107af4e277fbd93aa0845c4d5db704391460ff6e9014aeb73264ba87893df44
+  languageName: node
+  linkType: hard
+
+"is-symbol@npm:^1.0.2, is-symbol@npm:^1.0.3":
+  version: 1.0.4
+  resolution: "is-symbol@npm:1.0.4"
+  dependencies:
+    has-symbols: ^1.0.2
+  checksum: 92805812ef590738d9de49d677cd17dfd486794773fb6fa0032d16452af46e9b91bb43ffe82c983570f015b37136f4b53b28b8523bfb10b0ece7a66c31a54510
+  languageName: node
+  linkType: hard
+
+"is-typedarray@npm:~1.0.0":
+  version: 1.0.0
+  resolution: "is-typedarray@npm:1.0.0"
+  checksum: 3508c6cd0a9ee2e0df2fa2e9baabcdc89e911c7bd5cf64604586697212feec525aa21050e48affb5ffc3df20f0f5d2e2cf79b08caa64e1ccc9578e251763aef7
+  languageName: node
+  linkType: hard
+
+"is-unicode-supported@npm:^0.1.0":
+  version: 0.1.0
+  resolution: "is-unicode-supported@npm:0.1.0"
+  checksum: a2aab86ee7712f5c2f999180daaba5f361bdad1efadc9610ff5b8ab5495b86e4f627839d085c6530363c6d6d4ecbde340fb8e54bdb83da4ba8e0865ed5513c52
+  languageName: node
+  linkType: hard
+
+"is-utf8@npm:^0.2.0":
+  version: 0.2.1
+  resolution: "is-utf8@npm:0.2.1"
+  checksum: 167ccd2be869fc228cc62c1a28df4b78c6b5485d15a29027d3b5dceb09b383e86a3522008b56dcac14b592b22f0a224388718c2505027a994fd8471465de54b3
+  languageName: node
+  linkType: hard
+
+"is-windows@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "is-windows@npm:1.0.2"
+  checksum: 438b7e52656fe3b9b293b180defb4e448088e7023a523ec21a91a80b9ff8cdb3377ddb5b6e60f7c7de4fa8b63ab56e121b6705fe081b3cf1b828b0a380009ad7
+  languageName: node
+  linkType: hard
+
+"is-wsl@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "is-wsl@npm:1.1.0"
+  checksum: ea157d232351e68c92bd62fc541771096942fe72f69dff452dd26dcc31466258c570a3b04b8cda2e01cd2968255b02951b8670d08ea4ed76d6b1a646061ac4fe
+  languageName: node
+  linkType: hard
+
+"is-wsl@npm:^2.1.1":
+  version: 2.2.0
+  resolution: "is-wsl@npm:2.2.0"
+  dependencies:
+    is-docker: ^2.0.0
+  checksum: 20849846ae414997d290b75e16868e5261e86ff5047f104027026fd61d8b5a9b0b3ade16239f35e1a067b3c7cc02f70183cb661010ed16f4b6c7c93dad1b19d8
+  languageName: node
+  linkType: hard
+
+"isarray@npm:0.0.1":
+  version: 0.0.1
+  resolution: "isarray@npm:0.0.1"
+  checksum: 49191f1425681df4a18c2f0f93db3adb85573bcdd6a4482539d98eac9e705d8961317b01175627e860516a2fc45f8f9302db26e5a380a97a520e272e2a40a8d4
+  languageName: node
+  linkType: hard
+
+"isarray@npm:1.0.0, isarray@npm:^1.0.0, isarray@npm:~1.0.0":
+  version: 1.0.0
+  resolution: "isarray@npm:1.0.0"
+  checksum: f032df8e02dce8ec565cf2eb605ea939bdccea528dbcf565cdf92bfa2da9110461159d86a537388ef1acef8815a330642d7885b29010e8f7eac967c9993b65ab
+  languageName: node
+  linkType: hard
+
+"isexe@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "isexe@npm:2.0.0"
+  checksum: 26bf6c5480dda5161c820c5b5c751ae1e766c587b1f951ea3fcfc973bafb7831ae5b54a31a69bd670220e42e99ec154475025a468eae58ea262f813fdc8d1c62
+  languageName: node
+  linkType: hard
+
+"isobject@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "isobject@npm:2.1.0"
+  dependencies:
+    isarray: 1.0.0
+  checksum: 811c6f5a866877d31f0606a88af4a45f282544de886bf29f6a34c46616a1ae2ed17076cc6bf34c0128f33eecf7e1fcaa2c82cf3770560d3e26810894e96ae79f
+  languageName: node
+  linkType: hard
+
+"isobject@npm:^3.0.0, isobject@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "isobject@npm:3.0.1"
+  checksum: db85c4c970ce30693676487cca0e61da2ca34e8d4967c2e1309143ff910c207133a969f9e4ddb2dc6aba670aabce4e0e307146c310350b298e74a31f7d464703
+  languageName: node
+  linkType: hard
+
+"isomorphic-fetch@npm:^2.1.1":
+  version: 2.2.1
+  resolution: "isomorphic-fetch@npm:2.2.1"
+  dependencies:
+    node-fetch: ^1.0.1
+    whatwg-fetch: ">=0.10.0"
+  checksum: bb5daa7c3785d6742f4379a81e55b549a469503f7c9bf9411b48592e86632cf5e8fe8ea878dba185c0f33eb7c510c23abdeb55aebfdf5d3c70f031ced68c5424
+  languageName: node
+  linkType: hard
+
+"isstream@npm:~0.1.2":
+  version: 0.1.2
+  resolution: "isstream@npm:0.1.2"
+  checksum: 1eb2fe63a729f7bdd8a559ab552c69055f4f48eb5c2f03724430587c6f450783c8f1cd936c1c952d0a927925180fcc892ebd5b174236cf1065d4bd5bdb37e963
+  languageName: node
+  linkType: hard
+
+"istanbul-lib-coverage@npm:^2.0.2, istanbul-lib-coverage@npm:^2.0.5":
+  version: 2.0.5
+  resolution: "istanbul-lib-coverage@npm:2.0.5"
+  checksum: c83bf39dc722d2a3e7c98b16643f2fef719fd59adf23441ad8a1e6422bb1f3367ac7d4c42ac45d0d87413476891947b6ffbdecf2184047436336aa0c28bbfc15
+  languageName: node
+  linkType: hard
+
+"istanbul-lib-instrument@npm:^3.0.1, istanbul-lib-instrument@npm:^3.3.0":
+  version: 3.3.0
+  resolution: "istanbul-lib-instrument@npm:3.3.0"
+  dependencies:
+    "@babel/generator": ^7.4.0
+    "@babel/parser": ^7.4.3
+    "@babel/template": ^7.4.0
+    "@babel/traverse": ^7.4.3
+    "@babel/types": ^7.4.0
+    istanbul-lib-coverage: ^2.0.5
+    semver: ^6.0.0
+  checksum: 5ff86440c2f4afe83603f899721e43f9bbc0049ebf4e7fd696ea361d0c9ae5c831c656eec07c13f42ba934fc808c78f42a7884f1a08349802bc9bfa5af760571
+  languageName: node
+  linkType: hard
+
+"istanbul-lib-report@npm:^2.0.4":
+  version: 2.0.8
+  resolution: "istanbul-lib-report@npm:2.0.8"
+  dependencies:
+    istanbul-lib-coverage: ^2.0.5
+    make-dir: ^2.1.0
+    supports-color: ^6.1.0
+  checksum: eef53d35ea750fd971bc7abf2cf1350615804e4dee5a7ee6e13cff45ff36b518970baaeef4bf019d46149581f9d10c3f3675083cf6625da6cc3d4d4b4c670374
+  languageName: node
+  linkType: hard
+
+"istanbul-lib-source-maps@npm:^3.0.1":
+  version: 3.0.6
+  resolution: "istanbul-lib-source-maps@npm:3.0.6"
+  dependencies:
+    debug: ^4.1.1
+    istanbul-lib-coverage: ^2.0.5
+    make-dir: ^2.1.0
+    rimraf: ^2.6.3
+    source-map: ^0.6.1
+  checksum: 1c6ebc81331ab4d831910db3e98da1ee4e3e96f64c2fb533e1b73516305f020b44765fa2937f24eee4adb11be22a1fa42c04786e0d697d4893987a1a5180a541
+  languageName: node
+  linkType: hard
+
+"istanbul-reports@npm:^2.2.6":
+  version: 2.2.7
+  resolution: "istanbul-reports@npm:2.2.7"
+  dependencies:
+    html-escaper: ^2.0.0
+  checksum: 138604c86fe4a386c4ba23c103aa64f3d867548cb1ac9961cafe912004bde601180d7ece918a76e2e0078b94e503b77aa696d6e6f68a0d8698abbf0923d78285
+  languageName: node
+  linkType: hard
+
+"jest-changed-files@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-changed-files@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+    execa: ^1.0.0
+    throat: ^4.0.0
+  checksum: f40e901e6ac2e6f47730b610c3dbef44a9235d556ba53b23926d45e6334c1c5989fd255140753d3270d5e63371ae69084e0867c11b8322030edab51e1ff1b8b7
+  languageName: node
+  linkType: hard
+
+"jest-cli@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-cli@npm:24.9.0"
+  dependencies:
+    "@jest/core": ^24.9.0
+    "@jest/test-result": ^24.9.0
+    "@jest/types": ^24.9.0
+    chalk: ^2.0.1
+    exit: ^0.1.2
+    import-local: ^2.0.0
+    is-ci: ^2.0.0
+    jest-config: ^24.9.0
+    jest-util: ^24.9.0
+    jest-validate: ^24.9.0
+    prompts: ^2.0.1
+    realpath-native: ^1.1.0
+    yargs: ^13.3.0
+  bin:
+    jest: ./bin/jest.js
+  checksum: 8fc975da02e6793352a3508fae1523c094ed44633dc5e651aa1f01e49b9d4be8353422fd5dc7f01e464f6aafee13b3210daf3d11ce466c8959071251bdb0dc09
+  languageName: node
+  linkType: hard
+
+"jest-config@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-config@npm:24.9.0"
+  dependencies:
+    "@babel/core": ^7.1.0
+    "@jest/test-sequencer": ^24.9.0
+    "@jest/types": ^24.9.0
+    babel-jest: ^24.9.0
+    chalk: ^2.0.1
+    glob: ^7.1.1
+    jest-environment-jsdom: ^24.9.0
+    jest-environment-node: ^24.9.0
+    jest-get-type: ^24.9.0
+    jest-jasmine2: ^24.9.0
+    jest-regex-util: ^24.3.0
+    jest-resolve: ^24.9.0
+    jest-util: ^24.9.0
+    jest-validate: ^24.9.0
+    micromatch: ^3.1.10
+    pretty-format: ^24.9.0
+    realpath-native: ^1.1.0
+  checksum: 87268fcab5322775601181f4ee17d51102ba153b1e0dc68a55075e44109b372f4925fe9c361cca6a72d5934f806b16f8331f0efad8b6b296a6f7bffcb7a34cb9
+  languageName: node
+  linkType: hard
+
+"jest-diff@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-diff@npm:24.9.0"
+  dependencies:
+    chalk: ^2.0.1
+    diff-sequences: ^24.9.0
+    jest-get-type: ^24.9.0
+    pretty-format: ^24.9.0
+  checksum: 462ccb128cb1b64eb285d28245d0c5bfc230cb063624bd117550d6dbc94332f606828a5de86938611d1e6a78489e576c496737ae139084f6049a56b768ad6402
+  languageName: node
+  linkType: hard
+
+"jest-diff@npm:^26.0.0":
+  version: 26.6.2
+  resolution: "jest-diff@npm:26.6.2"
+  dependencies:
+    chalk: ^4.0.0
+    diff-sequences: ^26.6.2
+    jest-get-type: ^26.3.0
+    pretty-format: ^26.6.2
+  checksum: d00d297f31e1ac0252127089892432caa7a11c69bde29cf3bb6c7a839c8afdb95cf1fd401f9df16a4422745da2e6a5d94b428b30666a2540c38e1c5699915c2d
+  languageName: node
+  linkType: hard
+
+"jest-docblock@npm:^24.3.0":
+  version: 24.9.0
+  resolution: "jest-docblock@npm:24.9.0"
+  dependencies:
+    detect-newline: ^2.1.0
+  checksum: 0b2321a4ac5b2b59f9183f805d4c50223635e53ce76080c406da3d499916972b70ce8809fda6d0616b2ce606dd201be36be6b4c8c62ae2c0e62f14cfa3bfcbdb
+  languageName: node
+  linkType: hard
+
+"jest-each@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-each@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+    chalk: ^2.0.1
+    jest-get-type: ^24.9.0
+    jest-util: ^24.9.0
+    pretty-format: ^24.9.0
+  checksum: 93dc198e1dbea985816e3739b8a6e8622f1ee7b3f8b97d074aa8d512b4f81b8b70b30dcdcb5f735b3407bbd0fe5a9ac06e38cbf6499f7ab302daff2832c49683
+  languageName: node
+  linkType: hard
+
+"jest-environment-jsdom-fourteen@npm:1.0.1":
+  version: 1.0.1
+  resolution: "jest-environment-jsdom-fourteen@npm:1.0.1"
+  dependencies:
+    "@jest/environment": ^24.3.0
+    "@jest/fake-timers": ^24.3.0
+    "@jest/types": ^24.3.0
+    jest-mock: ^24.0.0
+    jest-util: ^24.0.0
+    jsdom: ^14.1.0
+  checksum: 39b34962c44260b69a58bab74ba36c6746db70947e6a44695ea26776bda2a9d9fd66edd1f6c36e9f456e5e0993633339f0db86fc452e0f1dfcaa9336a0656a35
+  languageName: node
+  linkType: hard
+
+"jest-environment-jsdom@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-environment-jsdom@npm:24.9.0"
+  dependencies:
+    "@jest/environment": ^24.9.0
+    "@jest/fake-timers": ^24.9.0
+    "@jest/types": ^24.9.0
+    jest-mock: ^24.9.0
+    jest-util: ^24.9.0
+    jsdom: ^11.5.1
+  checksum: 093e7f25735e52a1ff01673f0e3921e3e8228d2e902762bf102f1c34cd206e9b73aa83dcd0598e101c6cf4c23e99e5c84df84084258268a696c3007d6990f701
+  languageName: node
+  linkType: hard
+
+"jest-environment-node@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-environment-node@npm:24.9.0"
+  dependencies:
+    "@jest/environment": ^24.9.0
+    "@jest/fake-timers": ^24.9.0
+    "@jest/types": ^24.9.0
+    jest-mock: ^24.9.0
+    jest-util: ^24.9.0
+  checksum: 61a446f7cbab96b1777f53bcbb45ecda139a2473c7a093a9420f0018824ec307b93f920f9e188b5f11b605d0ed14798396c97113aedb66c2801b29367a5dc8d2
+  languageName: node
+  linkType: hard
+
+"jest-get-type@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-get-type@npm:24.9.0"
+  checksum: 821e6cd46434c917370cd362fbc4ce564c6e22780351f3ca468b230fbbc657ae19905ed5cdcc5e112d81a2c79cbd3fbcbe0dd44dc62860414b60ea223009958c
+  languageName: node
+  linkType: hard
+
+"jest-get-type@npm:^26.3.0":
+  version: 26.3.0
+  resolution: "jest-get-type@npm:26.3.0"
+  checksum: 1cc6465ae4f5e880be22ba52fd270fa64c21994915f81b41f8f7553a7957dd8e077cc8d03035de9412e2d739f8bad6a032ebb5dab5805692a5fb9e20dd4ea666
+  languageName: node
+  linkType: hard
+
+"jest-haste-map@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-haste-map@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+    anymatch: ^2.0.0
+    fb-watchman: ^2.0.0
+    fsevents: ^1.2.7
+    graceful-fs: ^4.1.15
+    invariant: ^2.2.4
+    jest-serializer: ^24.9.0
+    jest-util: ^24.9.0
+    jest-worker: ^24.9.0
+    micromatch: ^3.1.10
+    sane: ^4.0.3
+    walker: ^1.0.7
+  dependenciesMeta:
+    fsevents:
+      optional: true
+  checksum: 3ec2d60863c315d52a32b2d1df3cc8bb5403f7d8bf159e556c878db09dedc4d1fb4e4d5f56cb67c92663b334d49ef8b768375b0d153adebf4d48a7b6959e71b3
+  languageName: node
+  linkType: hard
+
+"jest-jasmine2@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-jasmine2@npm:24.9.0"
+  dependencies:
+    "@babel/traverse": ^7.1.0
+    "@jest/environment": ^24.9.0
+    "@jest/test-result": ^24.9.0
+    "@jest/types": ^24.9.0
+    chalk: ^2.0.1
+    co: ^4.6.0
+    expect: ^24.9.0
+    is-generator-fn: ^2.0.0
+    jest-each: ^24.9.0
+    jest-matcher-utils: ^24.9.0
+    jest-message-util: ^24.9.0
+    jest-runtime: ^24.9.0
+    jest-snapshot: ^24.9.0
+    jest-util: ^24.9.0
+    pretty-format: ^24.9.0
+    throat: ^4.0.0
+  checksum: 0ce903a12f5c237565e033d6e97bbb22d3131f918d4f715f6908950d820424c780b2f7020b9771001cede4e0a76bd06592fff99924b84cafbc8353feb38667aa
+  languageName: node
+  linkType: hard
+
+"jest-leak-detector@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-leak-detector@npm:24.9.0"
+  dependencies:
+    jest-get-type: ^24.9.0
+    pretty-format: ^24.9.0
+  checksum: ab54f8ca8f9abf76db9f681b8add50a17767e7b15459710ece030bd034e1fad47c67da73562408779839138dc7423a08f387f5930efdd800eac67d5653badce8
+  languageName: node
+  linkType: hard
+
+"jest-localstorage-mock@npm:2.2.0":
+  version: 2.2.0
+  resolution: "jest-localstorage-mock@npm:2.2.0"
+  checksum: cc661de0194ff307f93518c5a19497d896b2c527addea16ed98c6273ca753133fc91984110b04ffd27c7b294c9ee2e2a4a4c881e08ab513d084fe098ca6321f6
+  languageName: node
+  linkType: hard
+
+"jest-matcher-utils@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-matcher-utils@npm:24.9.0"
+  dependencies:
+    chalk: ^2.0.1
+    jest-diff: ^24.9.0
+    jest-get-type: ^24.9.0
+    pretty-format: ^24.9.0
+  checksum: e9dcd4c7a0bf52dccb4890de7ac2da3e857af067e71633b730fdc865dd271b8a2c3d68a2761d5ca6060ea4a455be42176f58462006468b8eb7c216921251e2ee
+  languageName: node
+  linkType: hard
+
+"jest-message-util@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-message-util@npm:24.9.0"
+  dependencies:
+    "@babel/code-frame": ^7.0.0
+    "@jest/test-result": ^24.9.0
+    "@jest/types": ^24.9.0
+    "@types/stack-utils": ^1.0.1
+    chalk: ^2.0.1
+    micromatch: ^3.1.10
+    slash: ^2.0.0
+    stack-utils: ^1.0.1
+  checksum: c173117b245090967db4853c28c3452ad2987a10caf28161abbfeb8d96be13f0d9e25422df10162bcc5e46860887e35ec4b4963f85392c4a625e4c37ad242f0b
+  languageName: node
+  linkType: hard
+
+"jest-mock@npm:^24.0.0, jest-mock@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-mock@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+  checksum: 823feac37b003543fe81e05d5d8a1ec69cdf9ae5b797582a3e90424ec476120ce42a11e6b1d8231958e01232d4e40e57207cf2c56197d63d309bdeaf63fcf804
+  languageName: node
+  linkType: hard
+
+"jest-pnp-resolver@npm:^1.2.1":
+  version: 1.2.2
+  resolution: "jest-pnp-resolver@npm:1.2.2"
+  peerDependencies:
+    jest-resolve: "*"
+  peerDependenciesMeta:
+    jest-resolve:
+      optional: true
+  checksum: bd85dcc0e76e0eb0c3d56382ec140f08d25ff4068cda9d0e360bb78fb176cb726d0beab82dc0e8694cafd09f55fee7622b8bcb240afa5fad301f4ed3eebb4f47
+  languageName: node
+  linkType: hard
+
+"jest-regex-util@npm:^24.3.0, jest-regex-util@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-regex-util@npm:24.9.0"
+  checksum: 94299972501ae5dfc3932673b263fd15dba5e28698571687a28cc59b5a173edcbf52b992f4d5a6eded9da5b7e1468d263ef96a1564267832799b41c2986fc423
+  languageName: node
+  linkType: hard
+
+"jest-resolve-dependencies@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-resolve-dependencies@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+    jest-regex-util: ^24.3.0
+    jest-snapshot: ^24.9.0
+  checksum: 126627777e7382b7ecc5b342f5f7b0e247a99e35895ee59282e7066c611d58ff2bd6a7332628e44e221a52361b8ecd1d9de41ba20d240f9b621ee80b6aebf820
+  languageName: node
+  linkType: hard
+
+"jest-resolve@npm:24.9.0, jest-resolve@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-resolve@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+    browser-resolve: ^1.11.3
+    chalk: ^2.0.1
+    jest-pnp-resolver: ^1.2.1
+    realpath-native: ^1.1.0
+  checksum: 60a84cbd75d5cdab1ad29c8ed62e43fbc374c906e5a0f166fae5170f91c863ee9372aaab7dbdb3a06a38b0362131fa7c907c114be76a8bc1aeac47013ec308e4
+  languageName: node
+  linkType: hard
+
+"jest-runner@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-runner@npm:24.9.0"
+  dependencies:
+    "@jest/console": ^24.7.1
+    "@jest/environment": ^24.9.0
+    "@jest/test-result": ^24.9.0
+    "@jest/types": ^24.9.0
+    chalk: ^2.4.2
+    exit: ^0.1.2
+    graceful-fs: ^4.1.15
+    jest-config: ^24.9.0
+    jest-docblock: ^24.3.0
+    jest-haste-map: ^24.9.0
+    jest-jasmine2: ^24.9.0
+    jest-leak-detector: ^24.9.0
+    jest-message-util: ^24.9.0
+    jest-resolve: ^24.9.0
+    jest-runtime: ^24.9.0
+    jest-util: ^24.9.0
+    jest-worker: ^24.6.0
+    source-map-support: ^0.5.6
+    throat: ^4.0.0
+  checksum: cb5c9fe598ca4ce8d13c2cf8b1649573e1bc73a50eb9438719b33970fed35ee75f731d64090d3392990f077ac1974119d094e311f503884eab42fa10081bd8a3
+  languageName: node
+  linkType: hard
+
+"jest-runtime@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-runtime@npm:24.9.0"
+  dependencies:
+    "@jest/console": ^24.7.1
+    "@jest/environment": ^24.9.0
+    "@jest/source-map": ^24.3.0
+    "@jest/transform": ^24.9.0
+    "@jest/types": ^24.9.0
+    "@types/yargs": ^13.0.0
+    chalk: ^2.0.1
+    exit: ^0.1.2
+    glob: ^7.1.3
+    graceful-fs: ^4.1.15
+    jest-config: ^24.9.0
+    jest-haste-map: ^24.9.0
+    jest-message-util: ^24.9.0
+    jest-mock: ^24.9.0
+    jest-regex-util: ^24.3.0
+    jest-resolve: ^24.9.0
+    jest-snapshot: ^24.9.0
+    jest-util: ^24.9.0
+    jest-validate: ^24.9.0
+    realpath-native: ^1.1.0
+    slash: ^2.0.0
+    strip-bom: ^3.0.0
+    yargs: ^13.3.0
+  bin:
+    jest-runtime: ./bin/jest-runtime.js
+  checksum: 924afebac3f1aaf8d9d6dec1b949d1c082b59a26c1b8917a7c47bf9bd27ad05544d534748119616b7f4e99ff50f546f25ca8b3f9bf32a34504355b8059bd0d45
+  languageName: node
+  linkType: hard
+
+"jest-serializer@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-serializer@npm:24.9.0"
+  checksum: 56d70bd50ebd71de7a38e1f94ef2fdf1293c3810ef6d372b69238263625d3df1e6749417872bc6be0515e39832f4c40df03c74d20d8f0f43efd14ea21e22178d
+  languageName: node
+  linkType: hard
+
+"jest-snapshot@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-snapshot@npm:24.9.0"
+  dependencies:
+    "@babel/types": ^7.0.0
+    "@jest/types": ^24.9.0
+    chalk: ^2.0.1
+    expect: ^24.9.0
+    jest-diff: ^24.9.0
+    jest-get-type: ^24.9.0
+    jest-matcher-utils: ^24.9.0
+    jest-message-util: ^24.9.0
+    jest-resolve: ^24.9.0
+    mkdirp: ^0.5.1
+    natural-compare: ^1.4.0
+    pretty-format: ^24.9.0
+    semver: ^6.2.0
+  checksum: 474dc05ededdb8b39fb79801498fcd16c1a13a01b4701a27172be0ee3ebc5640e2bfb2780a9afa49bd825b19fc2be1e2ec5fc3d501afa76a5f7bc40f0120aaf3
+  languageName: node
+  linkType: hard
+
+"jest-util@npm:^24.0.0, jest-util@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-util@npm:24.9.0"
+  dependencies:
+    "@jest/console": ^24.9.0
+    "@jest/fake-timers": ^24.9.0
+    "@jest/source-map": ^24.9.0
+    "@jest/test-result": ^24.9.0
+    "@jest/types": ^24.9.0
+    callsites: ^3.0.0
+    chalk: ^2.0.1
+    graceful-fs: ^4.1.15
+    is-ci: ^2.0.0
+    mkdirp: ^0.5.1
+    slash: ^2.0.0
+    source-map: ^0.6.0
+  checksum: ee84238bfb8c4aa60830b546e0e5dbdff53bbe55a1462f023182130ee7f1f3aac2dce0ab8395ab72b93e5a889fa12a55cebeeab04352a623d00d29c262dfbeb0
+  languageName: node
+  linkType: hard
+
+"jest-validate@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-validate@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+    camelcase: ^5.3.1
+    chalk: ^2.0.1
+    jest-get-type: ^24.9.0
+    leven: ^3.1.0
+    pretty-format: ^24.9.0
+  checksum: 8e9abc2b605a10e9872bd7cc9cd676641b781b16f22028b7ed59cb3243e942065229e804bf5aa3c9e2d62a1444dd492193155bb7e02d9e6e330faa0afbb6dd9f
+  languageName: node
+  linkType: hard
+
+"jest-watch-typeahead@npm:0.4.2":
+  version: 0.4.2
+  resolution: "jest-watch-typeahead@npm:0.4.2"
+  dependencies:
+    ansi-escapes: ^4.2.1
+    chalk: ^2.4.1
+    jest-regex-util: ^24.9.0
+    jest-watcher: ^24.3.0
+    slash: ^3.0.0
+    string-length: ^3.1.0
+    strip-ansi: ^5.0.0
+  checksum: d65675b8a374307199852693feecf76c16e455910478eb1495d51ec5be66d08b6601e17274249ecce42454452bb202c7fea95262a3cfb5b16c8d50833e46f0db
+  languageName: node
+  linkType: hard
+
+"jest-watcher@npm:^24.3.0, jest-watcher@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-watcher@npm:24.9.0"
+  dependencies:
+    "@jest/test-result": ^24.9.0
+    "@jest/types": ^24.9.0
+    "@types/yargs": ^13.0.0
+    ansi-escapes: ^3.0.0
+    chalk: ^2.0.1
+    jest-util: ^24.9.0
+    string-length: ^2.0.0
+  checksum: c0ceec6e854ee73a196064e51471fe01ff743ca78df8f4ef1c78194a0fd4f43ece26d2c55d011e258ac7ae0f37eaecbe3cc100defb604124d90cd9473538a97b
+  languageName: node
+  linkType: hard
+
+"jest-worker@npm:^24.6.0, jest-worker@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "jest-worker@npm:24.9.0"
+  dependencies:
+    merge-stream: ^2.0.0
+    supports-color: ^6.1.0
+  checksum: bd23b6c8728dcf3bad0d84543ea1bc4a95ccd3b5a40f9e2796d527ab0e87dc6afa6c30cc7b67845dce1cfe7894753812d19793de605db1976b7ac08930671bff
+  languageName: node
+  linkType: hard
+
+"jest-worker@npm:^25.4.0":
+  version: 25.5.0
+  resolution: "jest-worker@npm:25.5.0"
+  dependencies:
+    merge-stream: ^2.0.0
+    supports-color: ^7.0.0
+  checksum: 773ad5c680f7c47c023e90a63faffe041dc297c19df90d31768598d700517ef31ad5e3289e68bdf85ab7eca91efde8134f8646472747f47ae3f60c96a37d1c4b
+  languageName: node
+  linkType: hard
+
+"jest@npm:24.9.0":
+  version: 24.9.0
+  resolution: "jest@npm:24.9.0"
+  dependencies:
+    import-local: ^2.0.0
+    jest-cli: ^24.9.0
+  bin:
+    jest: ./bin/jest.js
+  checksum: 7bc61d47f94b18d52f354d785a9743883045222d0f1309a1131f0843479bdf8d98de1d62b9f519a562e99f883c51bd8af6a52f9e5a19596dae97d835abbc2cff
+  languageName: node
+  linkType: hard
+
+"js-base64@npm:^2.1.8, js-base64@npm:^2.4.9":
+  version: 2.6.4
+  resolution: "js-base64@npm:2.6.4"
+  checksum: 5f4084078d6c46f8529741d110df84b14fac3276b903760c21fa8cc8521370d607325dfe1c1a9fbbeaae1ff8e602665aaeef1362427d8fef704f9e3659472ce8
+  languageName: node
+  linkType: hard
+
+"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "js-tokens@npm:4.0.0"
+  checksum: 8a95213a5a77deb6cbe94d86340e8d9ace2b93bc367790b260101d2f36a2eaf4e4e22d9fa9cf459b38af3a32fb4190e638024cf82ec95ef708680e405ea7cc78
+  languageName: node
+  linkType: hard
+
+"js-tokens@npm:^3.0.2":
+  version: 3.0.2
+  resolution: "js-tokens@npm:3.0.2"
+  checksum: ff24cf90e6e4ac446eba56e604781c1aaf3bdaf9b13a00596a0ebd972fa3b25dc83c0f0f67289c33252abb4111e0d14e952a5d9ffb61f5c22532d555ebd8d8a9
+  languageName: node
+  linkType: hard
+
+"js-yaml@npm:3.13.1":
+  version: 3.13.1
+  resolution: "js-yaml@npm:3.13.1"
+  dependencies:
+    argparse: ^1.0.7
+    esprima: ^4.0.0
+  bin:
+    js-yaml: bin/js-yaml.js
+  checksum: 7511b764abb66d8aa963379f7d2a404f078457d106552d05a7b556d204f7932384e8477513c124749fa2de52eb328961834562bd09924902c6432e40daa408bc
+  languageName: node
+  linkType: hard
+
+"js-yaml@npm:^3.10.0, js-yaml@npm:^3.13.1":
+  version: 3.14.1
+  resolution: "js-yaml@npm:3.14.1"
+  dependencies:
+    argparse: ^1.0.7
+    esprima: ^4.0.0
+  bin:
+    js-yaml: bin/js-yaml.js
+  checksum: bef146085f472d44dee30ec34e5cf36bf89164f5d585435a3d3da89e52622dff0b188a580e4ad091c3341889e14cb88cac6e4deb16dc5b1e9623bb0601fc255c
+  languageName: node
+  linkType: hard
+
+"jsbn@npm:~0.1.0":
+  version: 0.1.1
+  resolution: "jsbn@npm:0.1.1"
+  checksum: e5ff29c1b8d965017ef3f9c219dacd6e40ad355c664e277d31246c90545a02e6047018c16c60a00f36d561b3647215c41894f5d869ada6908a2e0ce4200c88f2
+  languageName: node
+  linkType: hard
+
+"jsdom@npm:^11.5.1":
+  version: 11.12.0
+  resolution: "jsdom@npm:11.12.0"
+  dependencies:
+    abab: ^2.0.0
+    acorn: ^5.5.3
+    acorn-globals: ^4.1.0
+    array-equal: ^1.0.0
+    cssom: ">= 0.3.2 < 0.4.0"
+    cssstyle: ^1.0.0
+    data-urls: ^1.0.0
+    domexception: ^1.0.1
+    escodegen: ^1.9.1
+    html-encoding-sniffer: ^1.0.2
+    left-pad: ^1.3.0
+    nwsapi: ^2.0.7
+    parse5: 4.0.0
+    pn: ^1.1.0
+    request: ^2.87.0
+    request-promise-native: ^1.0.5
+    sax: ^1.2.4
+    symbol-tree: ^3.2.2
+    tough-cookie: ^2.3.4
+    w3c-hr-time: ^1.0.1
+    webidl-conversions: ^4.0.2
+    whatwg-encoding: ^1.0.3
+    whatwg-mimetype: ^2.1.0
+    whatwg-url: ^6.4.1
+    ws: ^5.2.0
+    xml-name-validator: ^3.0.0
+  checksum: 1dab757e92ce857df648ebec3dbe487954f886652faf9d97953c3b502958b1e4487e147baef5494718294e8625ae238e68354db710456fa73c394fb93dbfc68b
+  languageName: node
+  linkType: hard
+
+"jsdom@npm:^14.1.0":
+  version: 14.1.0
+  resolution: "jsdom@npm:14.1.0"
+  dependencies:
+    abab: ^2.0.0
+    acorn: ^6.0.4
+    acorn-globals: ^4.3.0
+    array-equal: ^1.0.0
+    cssom: ^0.3.4
+    cssstyle: ^1.1.1
+    data-urls: ^1.1.0
+    domexception: ^1.0.1
+    escodegen: ^1.11.0
+    html-encoding-sniffer: ^1.0.2
+    nwsapi: ^2.1.3
+    parse5: 5.1.0
+    pn: ^1.1.0
+    request: ^2.88.0
+    request-promise-native: ^1.0.5
+    saxes: ^3.1.9
+    symbol-tree: ^3.2.2
+    tough-cookie: ^2.5.0
+    w3c-hr-time: ^1.0.1
+    w3c-xmlserializer: ^1.1.2
+    webidl-conversions: ^4.0.2
+    whatwg-encoding: ^1.0.5
+    whatwg-mimetype: ^2.3.0
+    whatwg-url: ^7.0.0
+    ws: ^6.1.2
+    xml-name-validator: ^3.0.0
+  checksum: c8ece2c4324be30536411a5ef9e52ebccefeb1605bd1ba31d14e40ab576a40a0e7d009bd89edd0e422654e4518383bb1f4ab6f574ccecaf98e5839c200fd7772
+  languageName: node
+  linkType: hard
+
+"jsesc@npm:^2.5.1":
+  version: 2.5.2
+  resolution: "jsesc@npm:2.5.2"
+  bin:
+    jsesc: bin/jsesc
+  checksum: 4dc190771129e12023f729ce20e1e0bfceac84d73a85bc3119f7f938843fe25a4aeccb54b6494dce26fcf263d815f5f31acdefac7cc9329efb8422a4f4d9fa9d
+  languageName: node
+  linkType: hard
+
+"jsesc@npm:~0.5.0":
+  version: 0.5.0
+  resolution: "jsesc@npm:0.5.0"
+  bin:
+    jsesc: bin/jsesc
+  checksum: b8b44cbfc92f198ad972fba706ee6a1dfa7485321ee8c0b25f5cedd538dcb20cde3197de16a7265430fce8277a12db066219369e3d51055038946039f6e20e17
+  languageName: node
+  linkType: hard
+
+"json-parse-better-errors@npm:^1.0.1, json-parse-better-errors@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "json-parse-better-errors@npm:1.0.2"
+  checksum: ff2b5ba2a70e88fd97a3cb28c1840144c5ce8fae9cbeeddba15afa333a5c407cf0e42300cd0a2885dbb055227fe68d405070faad941beeffbfde9cf3b2c78c5d
+  languageName: node
+  linkType: hard
+
+"json-parse-even-better-errors@npm:^2.3.0":
+  version: 2.3.1
+  resolution: "json-parse-even-better-errors@npm:2.3.1"
+  checksum: 798ed4cf3354a2d9ccd78e86d2169515a0097a5c133337807cdf7f1fc32e1391d207ccfc276518cc1d7d8d4db93288b8a50ba4293d212ad1336e52a8ec0a941f
+  languageName: node
+  linkType: hard
+
+"json-schema-traverse@npm:^0.4.1":
+  version: 0.4.1
+  resolution: "json-schema-traverse@npm:0.4.1"
+  checksum: 7486074d3ba247769fda17d5181b345c9fb7d12e0da98b22d1d71a5db9698d8b4bd900a3ec1a4ffdd60846fc2556274a5c894d0c48795f14cb03aeae7b55260b
+  languageName: node
+  linkType: hard
+
+"json-schema@npm:0.4.0":
+  version: 0.4.0
+  resolution: "json-schema@npm:0.4.0"
+  checksum: 66389434c3469e698da0df2e7ac5a3281bcff75e797a5c127db7c5b56270e01ae13d9afa3c03344f76e32e81678337a8c912bdbb75101c62e487dc3778461d72
+  languageName: node
+  linkType: hard
+
+"json-stable-stringify-without-jsonify@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "json-stable-stringify-without-jsonify@npm:1.0.1"
+  checksum: cff44156ddce9c67c44386ad5cddf91925fe06b1d217f2da9c4910d01f358c6e3989c4d5a02683c7a5667f9727ff05831f7aa8ae66c8ff691c556f0884d49215
+  languageName: node
+  linkType: hard
+
+"json-stable-stringify@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "json-stable-stringify@npm:1.0.1"
+  dependencies:
+    jsonify: ~0.0.0
+  checksum: 65d6cbf0fca72a4136999f65f4401cf39a129f7aeff0fdd987ac3d3423a2113659294045fb8377e6e20d865cac32b1b8d70f3d87346c9786adcee60661d96ca5
+  languageName: node
+  linkType: hard
+
+"json-stringify-safe@npm:~5.0.1":
+  version: 5.0.1
+  resolution: "json-stringify-safe@npm:5.0.1"
+  checksum: 48ec0adad5280b8a96bb93f4563aa1667fd7a36334f79149abd42446d0989f2ddc58274b479f4819f1f00617957e6344c886c55d05a4e15ebb4ab931e4a6a8ee
+  languageName: node
+  linkType: hard
+
+"json3@npm:^3.3.2":
+  version: 3.3.3
+  resolution: "json3@npm:3.3.3"
+  checksum: 55eda204a4c70d11b7d5caa5cb64c76a3aa54d5df72d07bdf446b922fd7cb8657b0732f68e0c36790f55e195e0a429c299144ff05430bbe93bc2a7c81ad3472b
+  languageName: node
+  linkType: hard
+
+"json5@npm:^1.0.1":
+  version: 1.0.2
+  resolution: "json5@npm:1.0.2"
+  dependencies:
+    minimist: ^1.2.0
+  bin:
+    json5: lib/cli.js
+  checksum: 866458a8c58a95a49bef3adba929c625e82532bcff1fe93f01d29cb02cac7c3fe1f4b79951b7792c2da9de0b32871a8401a6e3c5b36778ad852bf5b8a61165d7
+  languageName: node
+  linkType: hard
+
+"json5@npm:^2.1.2, json5@npm:^2.2.3":
+  version: 2.2.3
+  resolution: "json5@npm:2.2.3"
+  bin:
+    json5: lib/cli.js
+  checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349
+  languageName: node
+  linkType: hard
+
+"jsonfile@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "jsonfile@npm:4.0.0"
+  dependencies:
+    graceful-fs: ^4.1.6
+  dependenciesMeta:
+    graceful-fs:
+      optional: true
+  checksum: 6447d6224f0d31623eef9b51185af03ac328a7553efcee30fa423d98a9e276ca08db87d71e17f2310b0263fd3ffa6c2a90a6308367f661dc21580f9469897c9e
+  languageName: node
+  linkType: hard
+
+"jsonfile@npm:^6.0.1":
+  version: 6.1.0
+  resolution: "jsonfile@npm:6.1.0"
+  dependencies:
+    graceful-fs: ^4.1.6
+    universalify: ^2.0.0
+  dependenciesMeta:
+    graceful-fs:
+      optional: true
+  checksum: 7af3b8e1ac8fe7f1eccc6263c6ca14e1966fcbc74b618d3c78a0a2075579487547b94f72b7a1114e844a1e15bb00d440e5d1720bfc4612d790a6f285d5ea8354
+  languageName: node
+  linkType: hard
+
+"jsonify@npm:~0.0.0":
+  version: 0.0.0
+  resolution: "jsonify@npm:0.0.0"
+  checksum: d8d4ed476c116e6987a460dcb82f22284686caae9f498ac87b0502c1765ac1522f4f450a4cad4cc368d202fd3b27a3860735140a82867fc6d558f5f199c38bce
+  languageName: node
+  linkType: hard
+
+"jsprim@npm:^1.2.2":
+  version: 1.4.2
+  resolution: "jsprim@npm:1.4.2"
+  dependencies:
+    assert-plus: 1.0.0
+    extsprintf: 1.3.0
+    json-schema: 0.4.0
+    verror: 1.10.0
+  checksum: 2ad1b9fdcccae8b3d580fa6ced25de930eaa1ad154db21bbf8478a4d30bbbec7925b5f5ff29b933fba9412b16a17bd484a8da4fdb3663b5e27af95dd693bab2a
+  languageName: node
+  linkType: hard
+
+"jsprim@npm:^2.0.2":
+  version: 2.0.2
+  resolution: "jsprim@npm:2.0.2"
+  dependencies:
+    assert-plus: 1.0.0
+    extsprintf: 1.3.0
+    json-schema: 0.4.0
+    verror: 1.10.0
+  checksum: d175f6b1991e160cb0aa39bc857da780e035611986b5492f32395411879fdaf4e513d98677f08f7352dac93a16b66b8361c674b86a3fa406e2e7af6b26321838
+  languageName: node
+  linkType: hard
+
+"jss-camel-case@npm:^6.0.0":
+  version: 6.1.0
+  resolution: "jss-camel-case@npm:6.1.0"
+  dependencies:
+    hyphenate-style-name: ^1.0.2
+  peerDependencies:
+    jss: ^9.7.0
+  checksum: f20ad892cddc30241b5127d648233b513ce57b2a6ed2f710cd995510f8d06e1418cd8f6e8e50dc6bd60b2e8a35159bfdeb40143938e0f2cdc19fd39279953789
+  languageName: node
+  linkType: hard
+
+"jss-default-unit@npm:^8.0.2":
+  version: 8.0.2
+  resolution: "jss-default-unit@npm:8.0.2"
+  peerDependencies:
+    jss: ^9.4.0
+  checksum: 5277c5ccc3d56f5c137d6d65f0fc36d15eb22ef08b5949e887ee75e9f4f25197e25f2a99706b70a2828cee010fd8ed7484fb1bb2086fe327f2b24b1bdcc22abb
+  languageName: node
+  linkType: hard
+
+"jss-global@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "jss-global@npm:3.0.0"
+  peerDependencies:
+    jss: ^9.0.0
+  checksum: e3fa80d8251ba5f183d9b0b4416d64e8f5d285cfdb595cc600daf1a1366b67bca573939bb71d98b6596bf4d6f2c95711cf53a358af3d63cd7945dbb434c6547b
+  languageName: node
+  linkType: hard
+
+"jss-nested@npm:^6.0.1":
+  version: 6.0.1
+  resolution: "jss-nested@npm:6.0.1"
+  dependencies:
+    warning: ^3.0.0
+  peerDependencies:
+    jss: ^9.0.0
+  checksum: 437bdacc559be0b4b5bc1faa2bc77b5c6cf14733fefbf73a34bb7335786b9f08e4e79b3d73cf83b386959adcd8fa9d725877912e96d9a0921ed38baa1a69b8d9
+  languageName: node
+  linkType: hard
+
+"jss-props-sort@npm:^6.0.0":
+  version: 6.0.0
+  resolution: "jss-props-sort@npm:6.0.0"
+  peerDependencies:
+    jss: ^9.0.0
+  checksum: 82a04f625a2f2b3a71b2fcb00b1ed0478137c83dc47c323d24c7ea88d08c9ea295b061f99cf264db133fef3435366d6da594724f7713e0415dc50b4eff2aeb53
+  languageName: node
+  linkType: hard
+
+"jss-vendor-prefixer@npm:^7.0.0":
+  version: 7.0.0
+  resolution: "jss-vendor-prefixer@npm:7.0.0"
+  dependencies:
+    css-vendor: ^0.3.8
+  peerDependencies:
+    jss: ^9.0.0
+  checksum: 8ec3608711833e79da7ccfffd8177e752f16093589fa28d3a24ec19e6588fbc6a07cc5cacf59ef4c111ab1d0fb72e07b88cb3444eb8c0c6ed4cc8107da984633
+  languageName: node
+  linkType: hard
+
+"jss@npm:^9.8.7":
+  version: 9.8.7
+  resolution: "jss@npm:9.8.7"
+  dependencies:
+    is-in-browser: ^1.1.3
+    symbol-observable: ^1.1.0
+    warning: ^3.0.0
+  checksum: ebb264cc893fb8c17a0277875947f8f72e01f5be991bf7f61a39abb861e1276e75ee402fdd2be9ce827af839a1a08bd81ad86a4e108d57fd7d7ebb2361837a53
+  languageName: node
+  linkType: hard
+
+"jssha@npm:2.3.1":
+  version: 2.3.1
+  resolution: "jssha@npm:2.3.1"
+  checksum: 3c0daa0b570b171592a3ed49496e6ec41d305eeed97279bbee0f0e537f77afc0371fd842cdaae6acc5d3c0aaf5ab7785e298330119576c2c634c2334d871b50c
+  languageName: node
+  linkType: hard
+
+"jsx-ast-utils@npm:^2.2.1, jsx-ast-utils@npm:^2.2.3":
+  version: 2.4.1
+  resolution: "jsx-ast-utils@npm:2.4.1"
+  dependencies:
+    array-includes: ^3.1.1
+    object.assign: ^4.1.0
+  checksum: 833477231266631e0def7ab5fa5da386790130ce5f9ab5db22fb3a8e67ee0adba9082ff27687e5c64c893af00beeb2285a7309cbc40c5edbcafdaf4e9de069a1
+  languageName: node
+  linkType: hard
+
+"jszip@npm:^3.10.1":
+  version: 3.10.1
+  resolution: "jszip@npm:3.10.1"
+  dependencies:
+    lie: ~3.3.0
+    pako: ~1.0.2
+    readable-stream: ~2.3.6
+    setimmediate: ^1.0.5
+  checksum: abc77bfbe33e691d4d1ac9c74c8851b5761fba6a6986630864f98d876f3fcc2d36817dfc183779f32c00157b5d53a016796677298272a714ae096dfe6b1c8b60
+  languageName: node
+  linkType: hard
+
+"just-extend@npm:^4.0.2":
+  version: 4.2.1
+  resolution: "just-extend@npm:4.2.1"
+  checksum: ff9fdede240fad313efeeeb68a660b942e5586d99c0058064c78884894a2690dc09bba44c994ad4e077e45d913fef01a9240c14a72c657b53687ac58de53b39c
+  languageName: node
+  linkType: hard
+
+"killable@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "killable@npm:1.0.1"
+  checksum: 911a85c6e390c19d72c4e3149347cf44042cbd7d18c3c6c5e4f706fdde6e0ed532473392e282c7ef27f518407e6cb7d2a0e71a2ae8d8d8f8ffdb68891a29a68a
+  languageName: node
+  linkType: hard
+
+"kind-of@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "kind-of@npm:2.0.1"
+  dependencies:
+    is-buffer: ^1.0.2
+  checksum: 043df2943e113bca612d26224947395e9673bb3808d94aed30e47fbf0bafd618e2a29ff0ca2d5498f64332c320fff07f0aa9d6edfc20906a93c1b8792f11759c
+  languageName: node
+  linkType: hard
+
+"kind-of@npm:^3.0.2, kind-of@npm:^3.0.3, kind-of@npm:^3.2.0":
+  version: 3.2.2
+  resolution: "kind-of@npm:3.2.2"
+  dependencies:
+    is-buffer: ^1.1.5
+  checksum: e898df8ca2f31038f27d24f0b8080da7be274f986bc6ed176f37c77c454d76627619e1681f6f9d2e8d2fd7557a18ecc419a6bb54e422abcbb8da8f1a75e4b386
+  languageName: node
+  linkType: hard
+
+"kind-of@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "kind-of@npm:4.0.0"
+  dependencies:
+    is-buffer: ^1.1.5
+  checksum: 1b9e7624a8771b5a2489026e820f3bbbcc67893e1345804a56b23a91e9069965854d2a223a7c6ee563c45be9d8c6ff1ef87f28ed5f0d1a8d00d9dcbb067c529f
+  languageName: node
+  linkType: hard
+
+"kind-of@npm:^5.0.0":
+  version: 5.1.0
+  resolution: "kind-of@npm:5.1.0"
+  checksum: f2a0102ae0cf19c4a953397e552571bad2b588b53282874f25fca7236396e650e2db50d41f9f516bd402536e4df968dbb51b8e69e4d5d4a7173def78448f7bab
+  languageName: node
+  linkType: hard
+
+"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2, kind-of@npm:^6.0.3":
+  version: 6.0.3
+  resolution: "kind-of@npm:6.0.3"
+  checksum: 3ab01e7b1d440b22fe4c31f23d8d38b4d9b91d9f291df683476576493d5dfd2e03848a8b05813dd0c3f0e835bc63f433007ddeceb71f05cb25c45ae1b19c6d3b
+  languageName: node
+  linkType: hard
+
+"kleur@npm:^3.0.3":
+  version: 3.0.3
+  resolution: "kleur@npm:3.0.3"
+  checksum: df82cd1e172f957bae9c536286265a5cdbd5eeca487cb0a3b2a7b41ef959fc61f8e7c0e9aeea9c114ccf2c166b6a8dd45a46fd619c1c569d210ecd2765ad5169
+  languageName: node
+  linkType: hard
+
+"last-call-webpack-plugin@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "last-call-webpack-plugin@npm:3.0.0"
+  dependencies:
+    lodash: ^4.17.5
+    webpack-sources: ^1.1.0
+  checksum: 23c25a2397c9f75b769b5238ab798873e857baf2363d471d186c9f05212457943f0de16181f33aeecbfd42116b72a0f343fe8910d5d8010f24956d95d536c743
+  languageName: node
+  linkType: hard
+
+"lazy-ass@npm:^1.6.0":
+  version: 1.6.0
+  resolution: "lazy-ass@npm:1.6.0"
+  checksum: 5a3ebb17915b03452320804466345382a6c25ac782ec4874fecdb2385793896cd459be2f187dc7def8899180c32ee0ab9a1aa7fe52193ac3ff3fe29bb0591729
+  languageName: node
+  linkType: hard
+
+"lazy-cache@npm:^0.2.3":
+  version: 0.2.7
+  resolution: "lazy-cache@npm:0.2.7"
+  checksum: b4538aff20db586c354f31de3ed59ea2c8d5dc4f01141bf49f07601e7ca0d7ed43a3f49362ade49b1e18ab1f3d121df0f2c9ea9b599b44dd54fb0c0db253c8b9
+  languageName: node
+  linkType: hard
+
+"lazy-cache@npm:^1.0.3":
+  version: 1.0.4
+  resolution: "lazy-cache@npm:1.0.4"
+  checksum: e6650c22e5de1cc3f4a0c25d2b35fe9cd400473c1b3562be9fceadf8f368d708b54d24f5aa51b321b090da65b36426823a8f706b8dbdd68270db0daba812c5d3
+  languageName: node
+  linkType: hard
+
+"lcid@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "lcid@npm:1.0.0"
+  dependencies:
+    invert-kv: ^1.0.0
+  checksum: e8c7a4db07663068c5c44b650938a2bc41aa992037eebb69376214320f202c1250e70b50c32f939e28345fd30c2d35b8e8cd9a19d5932c398246a864ce54843d
+  languageName: node
+  linkType: hard
+
+"left-pad@npm:^1.3.0":
+  version: 1.3.0
+  resolution: "left-pad@npm:1.3.0"
+  checksum: 13fa96e17b70a54836490de22d4bab706e2ed508338bbabecfac72ecce445a74139c5b009a8112252cab8fc4ab7ac4ebd870e5b35bd236b443b12be96f8745ac
+  languageName: node
+  linkType: hard
+
+"leven@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "leven@npm:3.1.0"
+  checksum: 638401d534585261b6003db9d99afd244dfe82d75ddb6db5c0df412842d5ab30b2ef18de471aaec70fe69a46f17b4ae3c7f01d8a4e6580ef7adb9f4273ad1e55
+  languageName: node
+  linkType: hard
+
+"levenary@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "levenary@npm:1.1.1"
+  dependencies:
+    leven: ^3.1.0
+  checksum: d292b002e278c2b7e33fe0856920363a6abe61373c04c702bce3dfc324069a52b52ceb8c87d6b6032a074020425e56f2fd0c0a99f577511fabd1674a12df3282
+  languageName: node
+  linkType: hard
+
+"levn@npm:^0.3.0, levn@npm:~0.3.0":
+  version: 0.3.0
+  resolution: "levn@npm:0.3.0"
+  dependencies:
+    prelude-ls: ~1.1.2
+    type-check: ~0.3.2
+  checksum: 0d084a524231a8246bb10fec48cdbb35282099f6954838604f3c7fc66f2e16fa66fd9cc2f3f20a541a113c4dafdf181e822c887c8a319c9195444e6c64ac395e
+  languageName: node
+  linkType: hard
+
+"lie@npm:~3.3.0":
+  version: 3.3.0
+  resolution: "lie@npm:3.3.0"
+  dependencies:
+    immediate: ~3.0.5
+  checksum: 33102302cf19766f97919a6a98d481e01393288b17a6aa1f030a3542031df42736edde8dab29ffdbf90bebeffc48c761eb1d064dc77592ca3ba3556f9fe6d2a8
+  languageName: node
+  linkType: hard
+
+"lines-and-columns@npm:^1.1.6":
+  version: 1.1.6
+  resolution: "lines-and-columns@npm:1.1.6"
+  checksum: 198a5436b1fa5cf703bae719c01c686b076f0ad7e1aafd95a58d626cabff302dc0414822126f2f80b58a8c3d66cda8a7b6da064f27130f87e1d3506d6dfd0d68
+  languageName: node
+  linkType: hard
+
+"listr2@npm:^3.8.3":
+  version: 3.14.0
+  resolution: "listr2@npm:3.14.0"
+  dependencies:
+    cli-truncate: ^2.1.0
+    colorette: ^2.0.16
+    log-update: ^4.0.0
+    p-map: ^4.0.0
+    rfdc: ^1.3.0
+    rxjs: ^7.5.1
+    through: ^2.3.8
+    wrap-ansi: ^7.0.0
+  peerDependencies:
+    enquirer: ">= 2.3.0 < 3"
+  peerDependenciesMeta:
+    enquirer:
+      optional: true
+  checksum: fdb8b2d6bdf5df9371ebd5082bee46c6d0ca3d1e5f2b11fbb5a127839855d5f3da9d4968fce94f0a5ec67cac2459766abbb1faeef621065ebb1829b11ef9476d
+  languageName: node
+  linkType: hard
+
+"load-json-file@npm:^1.0.0":
+  version: 1.1.0
+  resolution: "load-json-file@npm:1.1.0"
+  dependencies:
+    graceful-fs: ^4.1.2
+    parse-json: ^2.2.0
+    pify: ^2.0.0
+    pinkie-promise: ^2.0.0
+    strip-bom: ^2.0.0
+  checksum: 0e4e4f380d897e13aa236246a917527ea5a14e4fc34d49e01ce4e7e2a1e08e2740ee463a03fb021c04f594f29a178f4adb994087549d7c1c5315fcd29bf9934b
+  languageName: node
+  linkType: hard
+
+"load-json-file@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "load-json-file@npm:2.0.0"
+  dependencies:
+    graceful-fs: ^4.1.2
+    parse-json: ^2.2.0
+    pify: ^2.0.0
+    strip-bom: ^3.0.0
+  checksum: 7f212bbf08a8c9aab087ead07aa220d1f43d83ec1c4e475a00a8d9bf3014eb29ebe901db8554627dcfb70184c274d05b7379f1e9678fe8297ae74dc495212049
+  languageName: node
+  linkType: hard
+
+"load-json-file@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "load-json-file@npm:4.0.0"
+  dependencies:
+    graceful-fs: ^4.1.2
+    parse-json: ^4.0.0
+    pify: ^3.0.0
+    strip-bom: ^3.0.0
+  checksum: 8f5d6d93ba64a9620445ee9bde4d98b1eac32cf6c8c2d20d44abfa41a6945e7969456ab5f1ca2fb06ee32e206c9769a20eec7002fe290de462e8c884b6b8b356
+  languageName: node
+  linkType: hard
+
+"loader-fs-cache@npm:^1.0.2":
+  version: 1.0.3
+  resolution: "loader-fs-cache@npm:1.0.3"
+  dependencies:
+    find-cache-dir: ^0.1.1
+    mkdirp: ^0.5.1
+  checksum: 39781412e10bb0d6b5ca1afa9a4bd65e1827c5c51ef9ff746ae3fe8ce0e2cfa3fb96492d6619d8ab305407d20be82a9b244c439df0207f6ced4b98f2861bd372
+  languageName: node
+  linkType: hard
+
+"loader-runner@npm:^2.4.0":
+  version: 2.4.0
+  resolution: "loader-runner@npm:2.4.0"
+  checksum: e27eebbca5347a03f6b1d1bce5b2736a4984fb742f872c0a4d68e62de10f7637613e79a464d3bcd77c246d9c70fcac112bb4a3123010eb527e8b203a614647db
+  languageName: node
+  linkType: hard
+
+"loader-utils@npm:1.2.3":
+  version: 1.2.3
+  resolution: "loader-utils@npm:1.2.3"
+  dependencies:
+    big.js: ^5.2.2
+    emojis-list: ^2.0.0
+    json5: ^1.0.1
+  checksum: 385407fc2683b6d664276fd41df962296de4a15030bb24389de77b175570c3b56bd896869376ba14cf8b33a9e257e17a91d395739ba7e23b5b68a8749a41df7e
+  languageName: node
+  linkType: hard
+
+"loader-utils@npm:^1.1.0, loader-utils@npm:^1.2.3, loader-utils@npm:^1.4.0":
+  version: 1.4.2
+  resolution: "loader-utils@npm:1.4.2"
+  dependencies:
+    big.js: ^5.2.2
+    emojis-list: ^3.0.0
+    json5: ^1.0.1
+  checksum: eb6fb622efc0ffd1abdf68a2022f9eac62bef8ec599cf8adb75e94d1d338381780be6278534170e99edc03380a6d29bc7eb1563c89ce17c5fed3a0b17f1ad804
+  languageName: node
+  linkType: hard
+
+"loader-utils@npm:^2.0.0":
+  version: 2.0.4
+  resolution: "loader-utils@npm:2.0.4"
+  dependencies:
+    big.js: ^5.2.2
+    emojis-list: ^3.0.0
+    json5: ^2.1.2
+  checksum: a5281f5fff1eaa310ad5e1164095689443630f3411e927f95031ab4fb83b4a98f388185bb1fe949e8ab8d4247004336a625e9255c22122b815bb9a4c5d8fc3b7
+  languageName: node
+  linkType: hard
+
+"locate-path@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "locate-path@npm:2.0.0"
+  dependencies:
+    p-locate: ^2.0.0
+    path-exists: ^3.0.0
+  checksum: 02d581edbbbb0fa292e28d96b7de36b5b62c2fa8b5a7e82638ebb33afa74284acf022d3b1e9ae10e3ffb7658fbc49163fcd5e76e7d1baaa7801c3e05a81da755
+  languageName: node
+  linkType: hard
+
+"locate-path@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "locate-path@npm:3.0.0"
+  dependencies:
+    p-locate: ^3.0.0
+    path-exists: ^3.0.0
+  checksum: 53db3996672f21f8b0bf2a2c645ae2c13ffdae1eeecfcd399a583bce8516c0b88dcb4222ca6efbbbeb6949df7e46860895be2c02e8d3219abd373ace3bfb4e11
+  languageName: node
+  linkType: hard
+
+"locate-path@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "locate-path@npm:5.0.0"
+  dependencies:
+    p-locate: ^4.1.0
+  checksum: 83e51725e67517287d73e1ded92b28602e3ae5580b301fe54bfb76c0c723e3f285b19252e375712316774cf52006cb236aed5704692c32db0d5d089b69696e30
+  languageName: node
+  linkType: hard
+
+"lodash-es@npm:^4.17.10, lodash-es@npm:^4.17.21, lodash-es@npm:^4.17.5, lodash-es@npm:^4.2.1":
+  version: 4.17.21
+  resolution: "lodash-es@npm:4.17.21"
+  checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2
+  languageName: node
+  linkType: hard
+
+"lodash._reinterpolate@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "lodash._reinterpolate@npm:3.0.0"
+  checksum: 06d2d5f33169604fa5e9f27b6067ed9fb85d51a84202a656901e5ffb63b426781a601508466f039c720af111b0c685d12f1a5c14ff8df5d5f27e491e562784b2
+  languageName: node
+  linkType: hard
+
+"lodash.debounce@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "lodash.debounce@npm:4.0.8"
+  checksum: a3f527d22c548f43ae31c861ada88b2637eb48ac6aa3eb56e82d44917971b8aa96fbb37aa60efea674dc4ee8c42074f90f7b1f772e9db375435f6c83a19b3bc6
+  languageName: node
+  linkType: hard
+
+"lodash.escape@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "lodash.escape@npm:4.0.1"
+  checksum: fcb54f457497256964d619d5cccbd80a961916fca60df3fe0fa3e7f052715c2944c0ed5aefb4f9e047d127d44aa2d55555f3350cb42c6549e9e293fb30b41e7f
+  languageName: node
+  linkType: hard
+
+"lodash.flattendeep@npm:^4.4.0":
+  version: 4.4.0
+  resolution: "lodash.flattendeep@npm:4.4.0"
+  checksum: 8521c919acac3d4bcf0aaf040c1ca9cb35d6c617e2d72e9b4d51c9a58b4366622cd6077441a18be626c3f7b28227502b3bf042903d447b056ee7e0b11d45c722
+  languageName: node
+  linkType: hard
+
+"lodash.isequal@npm:^4.5.0":
+  version: 4.5.0
+  resolution: "lodash.isequal@npm:4.5.0"
+  checksum: da27515dc5230eb1140ba65ff8de3613649620e8656b19a6270afe4866b7bd461d9ba2ac8a48dcc57f7adac4ee80e1de9f965d89d4d81a0ad52bb3eec2609644
+  languageName: node
+  linkType: hard
+
+"lodash.isplainobject@npm:^4.0.6":
+  version: 4.0.6
+  resolution: "lodash.isplainobject@npm:4.0.6"
+  checksum: 29c6351f281e0d9a1d58f1a4c8f4400924b4c79f18dfc4613624d7d54784df07efaff97c1ff2659f3e085ecf4fff493300adc4837553104cef2634110b0d5337
+  languageName: node
+  linkType: hard
+
+"lodash.memoize@npm:^4.1.2":
+  version: 4.1.2
+  resolution: "lodash.memoize@npm:4.1.2"
+  checksum: 9ff3942feeccffa4f1fafa88d32f0d24fdc62fd15ded5a74a5f950ff5f0c6f61916157246744c620173dddf38d37095a92327d5fd3861e2063e736a5c207d089
+  languageName: node
+  linkType: hard
+
+"lodash.mergewith@npm:4.6.2":
+  version: 4.6.2
+  resolution: "lodash.mergewith@npm:4.6.2"
+  checksum: a6db2a9339752411f21b956908c404ec1e088e783a65c8b29e30ae5b3b6384f82517662d6f425cc97c2070b546cc2c7daaa8d33f78db7b6e9be06cd834abdeb8
+  languageName: node
+  linkType: hard
+
+"lodash.once@npm:^4.1.1":
+  version: 4.1.1
+  resolution: "lodash.once@npm:4.1.1"
+  checksum: d768fa9f9b4e1dc6453be99b753906f58990e0c45e7b2ca5a3b40a33111e5d17f6edf2f768786e2716af90a8e78f8f91431ab8435f761fef00f9b0c256f6d245
+  languageName: node
+  linkType: hard
+
+"lodash.sortby@npm:^4.7.0":
+  version: 4.7.0
+  resolution: "lodash.sortby@npm:4.7.0"
+  checksum: db170c9396d29d11fe9a9f25668c4993e0c1331bcb941ddbd48fb76f492e732add7f2a47cfdf8e9d740fa59ac41bbfaf931d268bc72aab3ab49e9f89354d718c
+  languageName: node
+  linkType: hard
+
+"lodash.template@npm:4.5.0, lodash.template@npm:^4.4.0":
+  version: 4.5.0
+  resolution: "lodash.template@npm:4.5.0"
+  dependencies:
+    lodash._reinterpolate: ^3.0.0
+    lodash.templatesettings: ^4.0.0
+  checksum: ca64e5f07b6646c9d3dbc0fe3aaa995cb227c4918abd1cef7a9024cd9c924f2fa389a0ec4296aa6634667e029bc81d4bbdb8efbfde11df76d66085e6c529b450
+  languageName: node
+  linkType: hard
+
+"lodash.templatesettings@npm:^4.0.0":
+  version: 4.2.0
+  resolution: "lodash.templatesettings@npm:4.2.0"
+  dependencies:
+    lodash._reinterpolate: ^3.0.0
+  checksum: 863e025478b092997e11a04e9d9e735875eeff1ffcd6c61742aa8272e3c2cddc89ce795eb9726c4e74cef5991f722897ff37df7738a125895f23fc7d12a7bb59
+  languageName: node
+  linkType: hard
+
+"lodash.uniq@npm:^4.5.0":
+  version: 4.5.0
+  resolution: "lodash.uniq@npm:4.5.0"
+  checksum: a4779b57a8d0f3c441af13d9afe7ecff22dd1b8ce1129849f71d9bbc8e8ee4e46dfb4b7c28f7ad3d67481edd6e51126e4e2a6ee276e25906d10f7140187c392d
+  languageName: node
+  linkType: hard
+
+"lodash@npm:>=3.5 <5, lodash@npm:^4.0.0, lodash@npm:^4.17.10, lodash@npm:^4.17.11, lodash@npm:^4.17.13, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.5, lodash@npm:^4.2.0, lodash@npm:^4.2.1, lodash@npm:~4.17.10":
+  version: 4.17.21
+  resolution: "lodash@npm:4.17.21"
+  checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7
+  languageName: node
+  linkType: hard
+
+"log-symbols@npm:^4.0.0":
+  version: 4.1.0
+  resolution: "log-symbols@npm:4.1.0"
+  dependencies:
+    chalk: ^4.1.0
+    is-unicode-supported: ^0.1.0
+  checksum: fce1497b3135a0198803f9f07464165e9eb83ed02ceb2273930a6f8a508951178d8cf4f0378e9d28300a2ed2bc49050995d2bd5f53ab716bb15ac84d58c6ef74
+  languageName: node
+  linkType: hard
+
+"log-update@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "log-update@npm:4.0.0"
+  dependencies:
+    ansi-escapes: ^4.3.0
+    cli-cursor: ^3.1.0
+    slice-ansi: ^4.0.0
+    wrap-ansi: ^6.2.0
+  checksum: ae2f85bbabc1906034154fb7d4c4477c79b3e703d22d78adee8b3862fa913942772e7fa11713e3d96fb46de4e3cabefbf5d0a544344f03b58d3c4bff52aa9eb2
+  languageName: node
+  linkType: hard
+
+"loglevel@npm:^1.6.8":
+  version: 1.7.1
+  resolution: "loglevel@npm:1.7.1"
+  checksum: 715a4ae69ad75d4d3bd04e4f6e9edbc4cae4db34d1e7f54f426d8cebe2dd9fef891ca3789e839d927cdbc5fad73d789e998db0af2f11f4c40219c272bc923823
+  languageName: node
+  linkType: hard
+
+"lolex@npm:^4.0.1":
+  version: 4.2.0
+  resolution: "lolex@npm:4.2.0"
+  checksum: 0a83bfe1748fc745515dff9b3f722422918f5f6975104d7e576fc32c06b5398ee0c58525684068d4de49cdd49874e00b5e2b2d72ce8c5d829dab86c8c08ca31f
+  languageName: node
+  linkType: hard
+
+"lolex@npm:^5.0.1":
+  version: 5.1.2
+  resolution: "lolex@npm:5.1.2"
+  dependencies:
+    "@sinonjs/commons": ^1.7.0
+  checksum: 7eb468d4ef4746c024d23cb2b75f679f79449a9d5cbe11abadf2f3b147c1d7ffe28816438bedfb8a75c58357a625c2f9ba197b050c226d2b3f0c4a956cf556fb
+  languageName: node
+  linkType: hard
+
+"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.2.0, loose-envify@npm:^1.3.1, loose-envify@npm:^1.4.0":
+  version: 1.4.0
+  resolution: "loose-envify@npm:1.4.0"
+  dependencies:
+    js-tokens: ^3.0.0 || ^4.0.0
+  bin:
+    loose-envify: cli.js
+  checksum: 6517e24e0cad87ec9888f500c5b5947032cdfe6ef65e1c1936a0c48a524b81e65542c9c3edc91c97d5bddc806ee2a985dbc79be89215d613b1de5db6d1cfe6f4
+  languageName: node
+  linkType: hard
+
+"loud-rejection@npm:^1.0.0":
+  version: 1.6.0
+  resolution: "loud-rejection@npm:1.6.0"
+  dependencies:
+    currently-unhandled: ^0.4.1
+    signal-exit: ^3.0.0
+  checksum: 750e12defde34e8cbf263c2bff16f028a89b56e022ad6b368aa7c39495b5ac33f2349a8d00665a9b6d25c030b376396524d8a31eb0dde98aaa97956d7324f927
+  languageName: node
+  linkType: hard
+
+"lower-case@npm:^2.0.2":
+  version: 2.0.2
+  resolution: "lower-case@npm:2.0.2"
+  dependencies:
+    tslib: ^2.0.3
+  checksum: 83a0a5f159ad7614bee8bf976b96275f3954335a84fad2696927f609ddae902802c4f3312d86668722e668bef41400254807e1d3a7f2e8c3eede79691aa1f010
+  languageName: node
+  linkType: hard
+
+"lru-cache@npm:^5.1.1":
+  version: 5.1.1
+  resolution: "lru-cache@npm:5.1.1"
+  dependencies:
+    yallist: ^3.0.2
+  checksum: c154ae1cbb0c2206d1501a0e94df349653c92c8cbb25236d7e85190bcaf4567a03ac6eb43166fabfa36fd35623694da7233e88d9601fbf411a9a481d85dbd2cb
+  languageName: node
+  linkType: hard
+
+"lru-cache@npm:^6.0.0":
+  version: 6.0.0
+  resolution: "lru-cache@npm:6.0.0"
+  dependencies:
+    yallist: ^4.0.0
+  checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297
+  languageName: node
+  linkType: hard
+
+"lru-cache@npm:^7.4.1":
+  version: 7.4.2
+  resolution: "lru-cache@npm:7.4.2"
+  checksum: 389b3b13b3a0406087b5ff3a913358161aa0b17e55e5a2626c343fa701026ce176483043ed6062ea188edbcd9ffab47a136a0f6e6d9b42b4236c8c49523a6ac5
+  languageName: node
+  linkType: hard
+
+"lru-cache@npm:^7.7.1":
+  version: 7.18.3
+  resolution: "lru-cache@npm:7.18.3"
+  checksum: e550d772384709deea3f141af34b6d4fa392e2e418c1498c078de0ee63670f1f46f5eee746e8ef7e69e1c895af0d4224e62ee33e66a543a14763b0f2e74c1356
+  languageName: node
+  linkType: hard
+
+"make-dir@npm:^2.0.0, make-dir@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "make-dir@npm:2.1.0"
+  dependencies:
+    pify: ^4.0.1
+    semver: ^5.6.0
+  checksum: 043548886bfaf1820323c6a2997e6d2fa51ccc2586ac14e6f14634f7458b4db2daf15f8c310e2a0abd3e0cddc64df1890d8fc7263033602c47bb12cbfcf86aab
+  languageName: node
+  linkType: hard
+
+"make-dir@npm:^3.0.2":
+  version: 3.1.0
+  resolution: "make-dir@npm:3.1.0"
+  dependencies:
+    semver: ^6.0.0
+  checksum: 484200020ab5a1fdf12f393fe5f385fc8e4378824c940fba1729dcd198ae4ff24867bc7a5646331e50cead8abff5d9270c456314386e629acec6dff4b8016b78
+  languageName: node
+  linkType: hard
+
+"make-fetch-happen@npm:^10.0.3":
+  version: 10.0.5
+  resolution: "make-fetch-happen@npm:10.0.5"
+  dependencies:
+    agentkeepalive: ^4.2.1
+    cacache: ^15.3.0
+    http-cache-semantics: ^4.1.0
+    http-proxy-agent: ^5.0.0
+    https-proxy-agent: ^5.0.0
+    is-lambda: ^1.0.1
+    lru-cache: ^7.4.1
+    minipass: ^3.1.6
+    minipass-collect: ^1.0.2
+    minipass-fetch: ^2.0.2
+    minipass-flush: ^1.0.5
+    minipass-pipeline: ^1.2.4
+    negotiator: ^0.6.3
+    promise-retry: ^2.0.1
+    socks-proxy-agent: ^6.1.1
+    ssri: ^8.0.1
+  checksum: 150071e41262bffafccafa764bab0e6a929a46be5abc1dfee414ffb821d052942596f3660ff84b17e4d6c44f51b2d2741f6d02a49e2dad44e06c94c3baf624fa
+  languageName: node
+  linkType: hard
+
+"make-fetch-happen@npm:^10.0.4":
+  version: 10.2.1
+  resolution: "make-fetch-happen@npm:10.2.1"
+  dependencies:
+    agentkeepalive: ^4.2.1
+    cacache: ^16.1.0
+    http-cache-semantics: ^4.1.0
+    http-proxy-agent: ^5.0.0
+    https-proxy-agent: ^5.0.0
+    is-lambda: ^1.0.1
+    lru-cache: ^7.7.1
+    minipass: ^3.1.6
+    minipass-collect: ^1.0.2
+    minipass-fetch: ^2.0.3
+    minipass-flush: ^1.0.5
+    minipass-pipeline: ^1.2.4
+    negotiator: ^0.6.3
+    promise-retry: ^2.0.1
+    socks-proxy-agent: ^7.0.0
+    ssri: ^9.0.0
+  checksum: 2332eb9a8ec96f1ffeeea56ccefabcb4193693597b132cd110734d50f2928842e22b84cfa1508e921b8385cdfd06dda9ad68645fed62b50fff629a580f5fb72c
+  languageName: node
+  linkType: hard
+
+"make-fetch-happen@npm:^9.1.0":
+  version: 9.1.0
+  resolution: "make-fetch-happen@npm:9.1.0"
+  dependencies:
+    agentkeepalive: ^4.1.3
+    cacache: ^15.2.0
+    http-cache-semantics: ^4.1.0
+    http-proxy-agent: ^4.0.1
+    https-proxy-agent: ^5.0.0
+    is-lambda: ^1.0.1
+    lru-cache: ^6.0.0
+    minipass: ^3.1.3
+    minipass-collect: ^1.0.2
+    minipass-fetch: ^1.3.2
+    minipass-flush: ^1.0.5
+    minipass-pipeline: ^1.2.4
+    negotiator: ^0.6.2
+    promise-retry: ^2.0.1
+    socks-proxy-agent: ^6.0.0
+    ssri: ^8.0.0
+  checksum: 0eb371c85fdd0b1584fcfdf3dc3c62395761b3c14658be02620c310305a9a7ecf1617a5e6fb30c1d081c5c8aaf177fa133ee225024313afabb7aa6a10f1e3d04
+  languageName: node
+  linkType: hard
+
+"makeerror@npm:1.0.x":
+  version: 1.0.11
+  resolution: "makeerror@npm:1.0.11"
+  dependencies:
+    tmpl: 1.0.x
+  checksum: 9a62ec2d9648c5329fdc4bc7d779a7305f32b1e55422a4f14244bc890bb43287fe013eb8d965e92a0cf4c443f3e59265b1fc3125eaedb0c2361e28b1a8de565d
+  languageName: node
+  linkType: hard
+
+"mamacro@npm:^0.0.3":
+  version: 0.0.3
+  resolution: "mamacro@npm:0.0.3"
+  checksum: ed3f970007248e377cd3a141866e2d6ba0ef09344b4ed1d80dcce6b5d6cdec6a50675894cc5249efdefeace60dd430afcf4af6cbd6bf975d79feb9c4b703fbc2
+  languageName: node
+  linkType: hard
+
+"map-age-cleaner@npm:^0.1.1":
+  version: 0.1.3
+  resolution: "map-age-cleaner@npm:0.1.3"
+  dependencies:
+    p-defer: ^1.0.0
+  checksum: cb2804a5bcb3cbdfe4b59066ea6d19f5e7c8c196cd55795ea4c28f792b192e4c442426ae52524e5e1acbccf393d3bddacefc3d41f803e66453f6c4eda3650bc1
+  languageName: node
+  linkType: hard
+
+"map-cache@npm:^0.2.2":
+  version: 0.2.2
+  resolution: "map-cache@npm:0.2.2"
+  checksum: 3067cea54285c43848bb4539f978a15dedc63c03022abeec6ef05c8cb6829f920f13b94bcaf04142fc6a088318e564c4785704072910d120d55dbc2e0c421969
+  languageName: node
+  linkType: hard
+
+"map-obj@npm:^1.0.0, map-obj@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "map-obj@npm:1.0.1"
+  checksum: 9949e7baec2a336e63b8d4dc71018c117c3ce6e39d2451ccbfd3b8350c547c4f6af331a4cbe1c83193d7c6b786082b6256bde843db90cb7da2a21e8fcc28afed
+  languageName: node
+  linkType: hard
+
+"map-obj@npm:^4.0.0":
+  version: 4.3.0
+  resolution: "map-obj@npm:4.3.0"
+  checksum: fbc554934d1a27a1910e842bc87b177b1a556609dd803747c85ece420692380827c6ae94a95cce4407c054fa0964be3bf8226f7f2cb2e9eeee432c7c1985684e
+  languageName: node
+  linkType: hard
+
+"map-visit@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "map-visit@npm:1.0.0"
+  dependencies:
+    object-visit: ^1.0.0
+  checksum: c27045a5021c344fc19b9132eb30313e441863b2951029f8f8b66f79d3d8c1e7e5091578075a996f74e417479506fe9ede28c44ca7bc351a61c9d8073daec36a
+  languageName: node
+  linkType: hard
+
+"material-ui-pickers@npm:^2.2.4":
+  version: 2.2.4
+  resolution: "material-ui-pickers@npm:2.2.4"
+  dependencies:
+    "@types/react-text-mask": ^5.4.3
+    clsx: ^1.0.2
+    react-event-listener: ^0.6.6
+    react-text-mask: ^5.4.3
+    react-transition-group: ^2.5.3
+    tslib: ^1.9.3
+  peerDependencies:
+    "@material-ui/core": ^3.2.0
+    prop-types: ^15.6.0
+    react: ^16.3.0
+    react-dom: ^16.3.0
+  checksum: be93e30a824c347ede9f82c6adc92748807ebc9665f00ed86b62b580748ca03470823871337d554659d6a6cb6d5898d3636a7fed9e4f2d9cbfa295c196d8c008
+  languageName: node
+  linkType: hard
+
+"md5.js@npm:^1.3.4":
+  version: 1.3.5
+  resolution: "md5.js@npm:1.3.5"
+  dependencies:
+    hash-base: ^3.0.0
+    inherits: ^2.0.1
+    safe-buffer: ^5.1.2
+  checksum: 098494d885684bcc4f92294b18ba61b7bd353c23147fbc4688c75b45cb8590f5a95fd4584d742415dcc52487f7a1ef6ea611cfa1543b0dc4492fe026357f3f0c
+  languageName: node
+  linkType: hard
+
+"mdn-data@npm:2.0.14":
+  version: 2.0.14
+  resolution: "mdn-data@npm:2.0.14"
+  checksum: 9d0128ed425a89f4cba8f787dca27ad9408b5cb1b220af2d938e2a0629d17d879a34d2cb19318bdb26c3f14c77dd5dfbae67211f5caaf07b61b1f2c5c8c7dc16
+  languageName: node
+  linkType: hard
+
+"mdn-data@npm:2.0.4":
+  version: 2.0.4
+  resolution: "mdn-data@npm:2.0.4"
+  checksum: add3c95e6d03d301b8a8bcfee3de33f4d07e4c5eee5b79f18d6d737de717e22472deadf67c1a8563983c0b603e10d7df40aa8e5fddf18884dfe118ccec7ae329
+  languageName: node
+  linkType: hard
+
+"media-typer@npm:0.3.0":
+  version: 0.3.0
+  resolution: "media-typer@npm:0.3.0"
+  checksum: af1b38516c28ec95d6b0826f6c8f276c58aec391f76be42aa07646b4e39d317723e869700933ca6995b056db4b09a78c92d5440dc23657e6764be5d28874bba1
+  languageName: node
+  linkType: hard
+
+"mem@npm:4.0.0":
+  version: 4.0.0
+  resolution: "mem@npm:4.0.0"
+  dependencies:
+    map-age-cleaner: ^0.1.1
+    mimic-fn: ^1.0.0
+    p-is-promise: ^1.1.0
+  checksum: aa5eceee24d005b1706bfc3c768194d5b5c0e9b0100f15914e95250aab55d70701aa21b7ab8c482962df081e2ae4bbffe93740d9defca14f7aadaf0c23e60ecd
+  languageName: node
+  linkType: hard
+
+"memoize-one@npm:>=3.1.1 <6":
+  version: 5.2.1
+  resolution: "memoize-one@npm:5.2.1"
+  checksum: a3cba7b824ebcf24cdfcd234aa7f86f3ad6394b8d9be4c96ff756dafb8b51c7f71320785fbc2304f1af48a0467cbbd2a409efc9333025700ed523f254cb52e3d
+  languageName: node
+  linkType: hard
+
+"memoize-one@npm:^4.0.0":
+  version: 4.1.0
+  resolution: "memoize-one@npm:4.1.0"
+  checksum: 5a0c5a0795c4af01649797dc7a417d441edda6e5933510aebd47b57a1ea84d31c42092aee03b0d1d969c102b57d45ba673b593228cd507c82e577a69847dfbae
+  languageName: node
+  linkType: hard
+
+"memory-fs@npm:^0.4.1":
+  version: 0.4.1
+  resolution: "memory-fs@npm:0.4.1"
+  dependencies:
+    errno: ^0.1.3
+    readable-stream: ^2.0.1
+  checksum: 6db6c8682eff836664ca9b5b6052ae38d21713dda9d0ef4700fa5c0599a8bc16b2093bee75ac3dedbe59fb2222d368f25bafaa62ba143c41051359cbcb005044
+  languageName: node
+  linkType: hard
+
+"memory-fs@npm:^0.5.0":
+  version: 0.5.0
+  resolution: "memory-fs@npm:0.5.0"
+  dependencies:
+    errno: ^0.1.3
+    readable-stream: ^2.0.1
+  checksum: a9f25b0a8ecfb7324277393f19ef68e6ba53b9e6e4b526bbf2ba23055c5440fbf61acc7bf66bfd980e9eb4951a4790f6f777a9a3abd36603f22c87e8a64d3d6b
+  languageName: node
+  linkType: hard
+
+"meow@npm:^3.7.0":
+  version: 3.7.0
+  resolution: "meow@npm:3.7.0"
+  dependencies:
+    camelcase-keys: ^2.0.0
+    decamelize: ^1.1.2
+    loud-rejection: ^1.0.0
+    map-obj: ^1.0.1
+    minimist: ^1.1.3
+    normalize-package-data: ^2.3.4
+    object-assign: ^4.0.1
+    read-pkg-up: ^1.0.1
+    redent: ^1.0.0
+    trim-newlines: ^1.0.0
+  checksum: 65a412e5d0d643615508007a9292799bb3e4e690597d54c9e98eb0ca3adb7b8ca8899f41ea7cb7d8277129cdcd9a1a60202b31f88e0034e6aaae02894d80999a
+  languageName: node
+  linkType: hard
+
+"meow@npm:^9.0.0":
+  version: 9.0.0
+  resolution: "meow@npm:9.0.0"
+  dependencies:
+    "@types/minimist": ^1.2.0
+    camelcase-keys: ^6.2.2
+    decamelize: ^1.2.0
+    decamelize-keys: ^1.1.0
+    hard-rejection: ^2.1.0
+    minimist-options: 4.1.0
+    normalize-package-data: ^3.0.0
+    read-pkg-up: ^7.0.1
+    redent: ^3.0.0
+    trim-newlines: ^3.0.0
+    type-fest: ^0.18.0
+    yargs-parser: ^20.2.3
+  checksum: 99799c47247f4daeee178e3124f6ef6f84bde2ba3f37652865d5d8f8b8adcf9eedfc551dd043e2455cd8206545fd848e269c0c5ab6b594680a0ad4d3617c9639
+  languageName: node
+  linkType: hard
+
+"merge-deep@npm:^3.0.2":
+  version: 3.0.3
+  resolution: "merge-deep@npm:3.0.3"
+  dependencies:
+    arr-union: ^3.1.0
+    clone-deep: ^0.2.4
+    kind-of: ^3.0.2
+  checksum: d2eb367b8300327c66a3e1e01eb06251f51b440bf5bfa5f0f8065ae95bf3af620d21fcd0ab2eb50e74f5119aac40ffd26c85e3bf82f79082e8757675f5885d3d
+  languageName: node
+  linkType: hard
+
+"merge-descriptors@npm:1.0.1":
+  version: 1.0.1
+  resolution: "merge-descriptors@npm:1.0.1"
+  checksum: 5abc259d2ae25bb06d19ce2b94a21632583c74e2a9109ee1ba7fd147aa7362b380d971e0251069f8b3eb7d48c21ac839e21fa177b335e82c76ec172e30c31a26
+  languageName: node
+  linkType: hard
+
+"merge-stream@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "merge-stream@npm:2.0.0"
+  checksum: 6fa4dcc8d86629705cea944a4b88ef4cb0e07656ebf223fa287443256414283dd25d91c1cd84c77987f2aec5927af1a9db6085757cb43d90eb170ebf4b47f4f4
+  languageName: node
+  linkType: hard
+
+"merge2@npm:^1.2.3, merge2@npm:^1.3.0, merge2@npm:^1.4.1":
+  version: 1.4.1
+  resolution: "merge2@npm:1.4.1"
+  checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2
+  languageName: node
+  linkType: hard
+
+"methods@npm:~1.1.2":
+  version: 1.1.2
+  resolution: "methods@npm:1.1.2"
+  checksum: 0917ff4041fa8e2f2fda5425a955fe16ca411591fbd123c0d722fcf02b73971ed6f764d85f0a6f547ce49ee0221ce2c19a5fa692157931cecb422984f1dcd13a
+  languageName: node
+  linkType: hard
+
+"microevent.ts@npm:~0.1.1":
+  version: 0.1.1
+  resolution: "microevent.ts@npm:0.1.1"
+  checksum: 7874fcdb3f0dfa4e996d3ea63b3b9882874ae7d22be28d51ae20da24c712e9e28e5011d988095c27dd2b32e37c0ad7425342a71b89adb8e808ec7194fadf4a7a
+  languageName: node
+  linkType: hard
+
+"micromatch@npm:^3.1.10, micromatch@npm:^3.1.4":
+  version: 3.1.10
+  resolution: "micromatch@npm:3.1.10"
+  dependencies:
+    arr-diff: ^4.0.0
+    array-unique: ^0.3.2
+    braces: ^2.3.1
+    define-property: ^2.0.2
+    extend-shallow: ^3.0.2
+    extglob: ^2.0.4
+    fragment-cache: ^0.2.1
+    kind-of: ^6.0.2
+    nanomatch: ^1.2.9
+    object.pick: ^1.3.0
+    regex-not: ^1.0.0
+    snapdragon: ^0.8.1
+    to-regex: ^3.0.2
+  checksum: ad226cba4daa95b4eaf47b2ca331c8d2e038d7b41ae7ed0697cde27f3f1d6142881ab03d4da51b65d9d315eceb5e4cdddb3fbb55f5f72cfa19cf3ea469d054dc
+  languageName: node
+  linkType: hard
+
+"micromatch@npm:^4.0.4":
+  version: 4.0.5
+  resolution: "micromatch@npm:4.0.5"
+  dependencies:
+    braces: ^3.0.2
+    picomatch: ^2.3.1
+  checksum: 02a17b671c06e8fefeeb6ef996119c1e597c942e632a21ef589154f23898c9c6a9858526246abb14f8bca6e77734aa9dcf65476fca47cedfb80d9577d52843fc
+  languageName: node
+  linkType: hard
+
+"miller-rabin@npm:^4.0.0":
+  version: 4.0.1
+  resolution: "miller-rabin@npm:4.0.1"
+  dependencies:
+    bn.js: ^4.0.0
+    brorand: ^1.0.1
+  bin:
+    miller-rabin: bin/miller-rabin
+  checksum: 00cd1ab838ac49b03f236cc32a14d29d7d28637a53096bf5c6246a032a37749c9bd9ce7360cbf55b41b89b7d649824949ff12bc8eee29ac77c6b38eada619ece
+  languageName: node
+  linkType: hard
+
+"mime-db@npm:1.48.0, mime-db@npm:>= 1.43.0 < 2":
+  version: 1.48.0
+  resolution: "mime-db@npm:1.48.0"
+  checksum: d778392e474a5e78c24eef5a2894261f0ed168d2762c1ac2a115aa34c2274c9426178b92a6cc55e9edb8f13e7e9b8116380b0e61db9ff6d763e62876a65eea57
+  languageName: node
+  linkType: hard
+
+"mime-db@npm:1.52.0":
+  version: 1.52.0
+  resolution: "mime-db@npm:1.52.0"
+  checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f
+  languageName: node
+  linkType: hard
+
+"mime-types@npm:^2.1.12, mime-types@npm:~2.1.17, mime-types@npm:~2.1.19, mime-types@npm:~2.1.24":
+  version: 2.1.31
+  resolution: "mime-types@npm:2.1.31"
+  dependencies:
+    mime-db: 1.48.0
+  checksum: eb1612aa96403823c7a2ccb1a39d58ce11477e685560186e7d369d8164260fd6fc1eeb56fa23acb6a4050583f417b2a685b69c23eb2bd8ed169fb0c6e323740a
+  languageName: node
+  linkType: hard
+
+"mime-types@npm:~2.1.34":
+  version: 2.1.35
+  resolution: "mime-types@npm:2.1.35"
+  dependencies:
+    mime-db: 1.52.0
+  checksum: 89a5b7f1def9f3af5dad6496c5ed50191ae4331cc5389d7c521c8ad28d5fdad2d06fd81baf38fed813dc4e46bb55c8145bb0ff406330818c9cf712fb2e9b3836
+  languageName: node
+  linkType: hard
+
+"mime@npm:1.6.0":
+  version: 1.6.0
+  resolution: "mime@npm:1.6.0"
+  bin:
+    mime: cli.js
+  checksum: fef25e39263e6d207580bdc629f8872a3f9772c923c7f8c7e793175cee22777bbe8bba95e5d509a40aaa292d8974514ce634ae35769faa45f22d17edda5e8557
+  languageName: node
+  linkType: hard
+
+"mime@npm:^2.4.4":
+  version: 2.5.2
+  resolution: "mime@npm:2.5.2"
+  bin:
+    mime: cli.js
+  checksum: dd3c93d433d41a09f6a1cfa969b653b769899f3bd573e7bfcea33bdc8b0cc4eba57daa2f95937369c2bd2b6d39d62389b11a4309fe40d1d3a1b736afdedad0ff
+  languageName: node
+  linkType: hard
+
+"mime@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "mime@npm:3.0.0"
+  bin:
+    mime: cli.js
+  checksum: f43f9b7bfa64534e6b05bd6062961681aeb406a5b53673b53b683f27fcc4e739989941836a355eef831f4478923651ecc739f4a5f6e20a76487b432bfd4db928
+  languageName: node
+  linkType: hard
+
+"mimic-fn@npm:^1.0.0":
+  version: 1.2.0
+  resolution: "mimic-fn@npm:1.2.0"
+  checksum: 69c08205156a1f4906d9c46f9b4dc08d18a50176352e77fdeb645cedfe9f20c0b19865d465bd2dec27a5c432347f24dc07fc3695e11159d193f892834233e939
+  languageName: node
+  linkType: hard
+
+"mimic-fn@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "mimic-fn@npm:2.1.0"
+  checksum: d2421a3444848ce7f84bd49115ddacff29c15745db73f54041edc906c14b131a38d05298dae3081667627a59b2eb1ca4b436ff2e1b80f69679522410418b478a
+  languageName: node
+  linkType: hard
+
+"min-indent@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "min-indent@npm:1.0.1"
+  checksum: bfc6dd03c5eaf623a4963ebd94d087f6f4bbbfd8c41329a7f09706b0cb66969c4ddd336abeb587bc44bc6f08e13bf90f0b374f9d71f9f01e04adc2cd6f083ef1
+  languageName: node
+  linkType: hard
+
+"mini-css-extract-plugin@npm:0.9.0":
+  version: 0.9.0
+  resolution: "mini-css-extract-plugin@npm:0.9.0"
+  dependencies:
+    loader-utils: ^1.1.0
+    normalize-url: 1.9.1
+    schema-utils: ^1.0.0
+    webpack-sources: ^1.1.0
+  peerDependencies:
+    webpack: ^4.4.0
+  checksum: e5cf437c15e4adf119d3a5af1bb604c880bc90a637aaf0535c8db68219efec42dcace1c54789422dec05d76ced98c44ef89ae44a3c556e34936fdbdd4743a210
+  languageName: node
+  linkType: hard
+
+"minimalistic-assert@npm:^1.0.0, minimalistic-assert@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "minimalistic-assert@npm:1.0.1"
+  checksum: cc7974a9268fbf130fb055aff76700d7e2d8be5f761fb5c60318d0ed010d839ab3661a533ad29a5d37653133385204c503bfac995aaa4236f4e847461ea32ba7
+  languageName: node
+  linkType: hard
+
+"minimalistic-crypto-utils@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "minimalistic-crypto-utils@npm:1.0.1"
+  checksum: 6e8a0422b30039406efd4c440829ea8f988845db02a3299f372fceba56ffa94994a9c0f2fd70c17f9969eedfbd72f34b5070ead9656a34d3f71c0bd72583a0ed
+  languageName: node
+  linkType: hard
+
+"minimatch@npm:3.0.4":
+  version: 3.0.4
+  resolution: "minimatch@npm:3.0.4"
+  dependencies:
+    brace-expansion: ^1.1.7
+  checksum: 66ac295f8a7b59788000ea3749938b0970344c841750abd96694f80269b926ebcafad3deeb3f1da2522978b119e6ae3a5869b63b13a7859a456b3408bd18a078
+  languageName: node
+  linkType: hard
+
+"minimatch@npm:^3.0.4":
+  version: 3.1.2
+  resolution: "minimatch@npm:3.1.2"
+  dependencies:
+    brace-expansion: ^1.1.7
+  checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a
+  languageName: node
+  linkType: hard
+
+"minimatch@npm:^5.0.1":
+  version: 5.1.6
+  resolution: "minimatch@npm:5.1.6"
+  dependencies:
+    brace-expansion: ^2.0.1
+  checksum: 7564208ef81d7065a370f788d337cd80a689e981042cb9a1d0e6580b6c6a8c9279eba80010516e258835a988363f99f54a6f711a315089b8b42694f5da9d0d77
+  languageName: node
+  linkType: hard
+
+"minimatch@npm:~3.0.2":
+  version: 3.0.8
+  resolution: "minimatch@npm:3.0.8"
+  dependencies:
+    brace-expansion: ^1.1.7
+  checksum: 850cca179cad715133132693e6963b0db64ab0988c4d211415b087fc23a3e46321e2c5376a01bf5623d8782aba8bdf43c571e2e902e51fdce7175c7215c29f8b
+  languageName: node
+  linkType: hard
+
+"minimist-options@npm:4.1.0":
+  version: 4.1.0
+  resolution: "minimist-options@npm:4.1.0"
+  dependencies:
+    arrify: ^1.0.1
+    is-plain-obj: ^1.1.0
+    kind-of: ^6.0.3
+  checksum: 8c040b3068811e79de1140ca2b708d3e203c8003eb9a414c1ab3cd467fc5f17c9ca02a5aef23bedc51a7f8bfbe77f87e9a7e31ec81fba304cda675b019496f4e
+  languageName: node
+  linkType: hard
+
+"minimist@npm:^1.1.1, minimist@npm:^1.1.3, minimist@npm:^1.2.0, minimist@npm:^1.2.5, minimist@npm:^1.2.8":
+  version: 1.2.8
+  resolution: "minimist@npm:1.2.8"
+  checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0
+  languageName: node
+  linkType: hard
+
+"minipass-collect@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "minipass-collect@npm:1.0.2"
+  dependencies:
+    minipass: ^3.0.0
+  checksum: 14df761028f3e47293aee72888f2657695ec66bd7d09cae7ad558da30415fdc4752bbfee66287dcc6fd5e6a2fa3466d6c484dc1cbd986525d9393b9523d97f10
+  languageName: node
+  linkType: hard
+
+"minipass-fetch@npm:^1.3.2":
+  version: 1.4.1
+  resolution: "minipass-fetch@npm:1.4.1"
+  dependencies:
+    encoding: ^0.1.12
+    minipass: ^3.1.0
+    minipass-sized: ^1.0.3
+    minizlib: ^2.0.0
+  dependenciesMeta:
+    encoding:
+      optional: true
+  checksum: ec93697bdb62129c4e6c0104138e681e30efef8c15d9429dd172f776f83898471bc76521b539ff913248cc2aa6d2b37b652c993504a51cc53282563640f29216
+  languageName: node
+  linkType: hard
+
+"minipass-fetch@npm:^2.0.2":
+  version: 2.0.3
+  resolution: "minipass-fetch@npm:2.0.3"
+  dependencies:
+    encoding: ^0.1.13
+    minipass: ^3.1.6
+    minipass-sized: ^1.0.3
+    minizlib: ^2.1.2
+  dependenciesMeta:
+    encoding:
+      optional: true
+  checksum: 78a4a509b1e73f5e63b84065969790373b36b04da586744815c7f7f80013f1d786797842cc33cfa517eba0ad2f2142eb0ac808c46738da1c53e0e3aeed81df11
+  languageName: node
+  linkType: hard
+
+"minipass-fetch@npm:^2.0.3":
+  version: 2.1.2
+  resolution: "minipass-fetch@npm:2.1.2"
+  dependencies:
+    encoding: ^0.1.13
+    minipass: ^3.1.6
+    minipass-sized: ^1.0.3
+    minizlib: ^2.1.2
+  dependenciesMeta:
+    encoding:
+      optional: true
+  checksum: 3f216be79164e915fc91210cea1850e488793c740534985da017a4cbc7a5ff50506956d0f73bb0cb60e4fe91be08b6b61ef35101706d3ef5da2c8709b5f08f91
+  languageName: node
+  linkType: hard
+
+"minipass-flush@npm:^1.0.5":
+  version: 1.0.5
+  resolution: "minipass-flush@npm:1.0.5"
+  dependencies:
+    minipass: ^3.0.0
+  checksum: 56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf
+  languageName: node
+  linkType: hard
+
+"minipass-pipeline@npm:^1.2.2, minipass-pipeline@npm:^1.2.4":
+  version: 1.2.4
+  resolution: "minipass-pipeline@npm:1.2.4"
+  dependencies:
+    minipass: ^3.0.0
+  checksum: b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b
+  languageName: node
+  linkType: hard
+
+"minipass-sized@npm:^1.0.3":
+  version: 1.0.3
+  resolution: "minipass-sized@npm:1.0.3"
+  dependencies:
+    minipass: ^3.0.0
+  checksum: 79076749fcacf21b5d16dd596d32c3b6bf4d6e62abb43868fac21674078505c8b15eaca4e47ed844985a4514854f917d78f588fcd029693709417d8f98b2bd60
+  languageName: node
+  linkType: hard
+
+"minipass@npm:^3.0.0, minipass@npm:^3.1.1":
+  version: 3.1.3
+  resolution: "minipass@npm:3.1.3"
+  dependencies:
+    yallist: ^4.0.0
+  checksum: 74b623c1f996caafa66772301b66a1b634b20270f0d1a731ef86195d5a1a5f9984a773a1e88a6cecfd264d6c471c4c0fc8574cd96488f01c8f74c0b600021e55
+  languageName: node
+  linkType: hard
+
+"minipass@npm:^3.1.0, minipass@npm:^3.1.3":
+  version: 3.3.6
+  resolution: "minipass@npm:3.3.6"
+  dependencies:
+    yallist: ^4.0.0
+  checksum: a30d083c8054cee83cdcdc97f97e4641a3f58ae743970457b1489ce38ee1167b3aaf7d815cd39ec7a99b9c40397fd4f686e83750e73e652b21cb516f6d845e48
+  languageName: node
+  linkType: hard
+
+"minipass@npm:^3.1.6":
+  version: 3.1.6
+  resolution: "minipass@npm:3.1.6"
+  dependencies:
+    yallist: ^4.0.0
+  checksum: 57a04041413a3531a65062452cb5175f93383ef245d6f4a2961d34386eb9aa8ac11ac7f16f791f5e8bbaf1dfb1ef01596870c88e8822215db57aa591a5bb0a77
+  languageName: node
+  linkType: hard
+
+"minipass@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "minipass@npm:5.0.0"
+  checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea
+  languageName: node
+  linkType: hard
+
+"minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2":
+  version: 2.1.2
+  resolution: "minizlib@npm:2.1.2"
+  dependencies:
+    minipass: ^3.0.0
+    yallist: ^4.0.0
+  checksum: f1fdeac0b07cf8f30fcf12f4b586795b97be856edea22b5e9072707be51fc95d41487faec3f265b42973a304fe3a64acd91a44a3826a963e37b37bafde0212c3
+  languageName: node
+  linkType: hard
+
+"mississippi@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "mississippi@npm:3.0.0"
+  dependencies:
+    concat-stream: ^1.5.0
+    duplexify: ^3.4.2
+    end-of-stream: ^1.1.0
+    flush-write-stream: ^1.0.0
+    from2: ^2.1.0
+    parallel-transform: ^1.1.0
+    pump: ^3.0.0
+    pumpify: ^1.3.3
+    stream-each: ^1.1.0
+    through2: ^2.0.0
+  checksum: 84b3d9889621d293f9a596bafe60df863b330c88fc19215ced8f603c605fc7e1bf06f8e036edf301bd630a03fd5d9d7d23d5d6b9a4802c30ca864d800f0bd9f8
+  languageName: node
+  linkType: hard
+
+"mixin-deep@npm:^1.2.0":
+  version: 1.3.2
+  resolution: "mixin-deep@npm:1.3.2"
+  dependencies:
+    for-in: ^1.0.2
+    is-extendable: ^1.0.1
+  checksum: 820d5a51fcb7479f2926b97f2c3bb223546bc915e6b3a3eb5d906dda871bba569863595424a76682f2b15718252954644f3891437cb7e3f220949bed54b1750d
+  languageName: node
+  linkType: hard
+
+"mixin-object@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "mixin-object@npm:2.0.1"
+  dependencies:
+    for-in: ^0.1.3
+    is-extendable: ^0.1.1
+  checksum: 7d0eb7c2f06435fcc01d132824b4c973a0df689a117d8199d79911b506363b6f4f86a84458a63f3acfa7388f3052612cfe27105400b4932678452925a9739a4c
+  languageName: node
+  linkType: hard
+
+"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.5, mkdirp@npm:~0.5.1":
+  version: 0.5.5
+  resolution: "mkdirp@npm:0.5.5"
+  dependencies:
+    minimist: ^1.2.5
+  bin:
+    mkdirp: bin/cmd.js
+  checksum: 3bce20ea525f9477befe458ab85284b0b66c8dc3812f94155af07c827175948cdd8114852ac6c6d82009b13c1048c37f6d98743eb019651ee25c39acc8aabe7d
+  languageName: node
+  linkType: hard
+
+"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "mkdirp@npm:1.0.4"
+  bin:
+    mkdirp: bin/cmd.js
+  checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f
+  languageName: node
+  linkType: hard
+
+"moment@npm:^2.29.4":
+  version: 2.29.4
+  resolution: "moment@npm:2.29.4"
+  checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e
+  languageName: node
+  linkType: hard
+
+"moo@npm:^0.5.0":
+  version: 0.5.1
+  resolution: "moo@npm:0.5.1"
+  checksum: 2d8c013f1f9aad8e5c7a9d4a03dbb4eecd91b9fe5e9446fbc7561fd38d4d161c742434acff385722542fe7b360fce9c586da62442379e62e4158ad49c7e1a6b7
+  languageName: node
+  linkType: hard
+
+"move-concurrently@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "move-concurrently@npm:1.0.1"
+  dependencies:
+    aproba: ^1.1.1
+    copy-concurrently: ^1.0.0
+    fs-write-stream-atomic: ^1.0.8
+    mkdirp: ^0.5.1
+    rimraf: ^2.5.4
+    run-queue: ^1.0.3
+  checksum: 4ea3296c150b09e798177847f673eb5783f8ca417ba806668d2c631739f653e1a735f19fb9b6e2f5e25ee2e4c0a6224732237a8e4f84c764e99d7462d258209e
+  languageName: node
+  linkType: hard
+
+"ms@npm:2.0.0":
+  version: 2.0.0
+  resolution: "ms@npm:2.0.0"
+  checksum: 0e6a22b8b746d2e0b65a430519934fefd41b6db0682e3477c10f60c76e947c4c0ad06f63ffdf1d78d335f83edee8c0aa928aa66a36c7cd95b69b26f468d527f4
+  languageName: node
+  linkType: hard
+
+"ms@npm:2.1.2":
+  version: 2.1.2
+  resolution: "ms@npm:2.1.2"
+  checksum: 673cdb2c3133eb050c745908d8ce632ed2c02d85640e2edb3ace856a2266a813b30c613569bf3354fdf4ea7d1a1494add3bfa95e2713baa27d0c2c71fc44f58f
+  languageName: node
+  linkType: hard
+
+"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1":
+  version: 2.1.3
+  resolution: "ms@npm:2.1.3"
+  checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d
+  languageName: node
+  linkType: hard
+
+"multicast-dns-service-types@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "multicast-dns-service-types@npm:1.1.0"
+  checksum: 0979fca1cce85484d256e4db3af591d941b41a61f134da3607213d2624c12ed5b8a246565cb19a9b3cb542819e8fbc71a90b07e77023ee6a9515540fe1d371f7
+  languageName: node
+  linkType: hard
+
+"multicast-dns@npm:^6.0.1":
+  version: 6.2.3
+  resolution: "multicast-dns@npm:6.2.3"
+  dependencies:
+    dns-packet: ^1.3.1
+    thunky: ^1.0.2
+  bin:
+    multicast-dns: cli.js
+  checksum: f515b49ca964429ab48a4ac8041fcf969c927aeb49ab65288bd982e52c849a870fc3b03565780b0d194a1a02da8821f28b6425e48e95b8107bc9fcc92f571a6f
+  languageName: node
+  linkType: hard
+
+"mute-stream@npm:0.0.8":
+  version: 0.0.8
+  resolution: "mute-stream@npm:0.0.8"
+  checksum: ff48d251fc3f827e5b1206cda0ffdaec885e56057ee86a3155e1951bc940fd5f33531774b1cc8414d7668c10a8907f863f6561875ee6e8768931a62121a531a1
+  languageName: node
+  linkType: hard
+
+"nan@npm:^2.12.1, nan@npm:^2.13.2":
+  version: 2.14.2
+  resolution: "nan@npm:2.14.2"
+  dependencies:
+    node-gyp: latest
+  checksum: 7a269139b66a7d37470effb7fb36a8de8cc3b5ffba6e40bb8e0545307911fe5ebf94797ec62f655ecde79c237d169899f8bd28256c66a32cbc8284faaf94c3f4
+  languageName: node
+  linkType: hard
+
+"nan@npm:^2.17.0":
+  version: 2.18.0
+  resolution: "nan@npm:2.18.0"
+  dependencies:
+    node-gyp: latest
+  checksum: 4fe42f58456504eab3105c04a5cffb72066b5f22bd45decf33523cb17e7d6abc33cca2a19829407b9000539c5cb25f410312d4dc5b30220167a3594896ea6a0a
+  languageName: node
+  linkType: hard
+
+"nanoid@npm:^3.3.6":
+  version: 3.3.7
+  resolution: "nanoid@npm:3.3.7"
+  bin:
+    nanoid: bin/nanoid.cjs
+  checksum: d36c427e530713e4ac6567d488b489a36582ef89da1d6d4e3b87eded11eb10d7042a877958c6f104929809b2ab0bafa17652b076cdf84324aa75b30b722204f2
+  languageName: node
+  linkType: hard
+
+"nanomatch@npm:^1.2.9":
+  version: 1.2.13
+  resolution: "nanomatch@npm:1.2.13"
+  dependencies:
+    arr-diff: ^4.0.0
+    array-unique: ^0.3.2
+    define-property: ^2.0.2
+    extend-shallow: ^3.0.2
+    fragment-cache: ^0.2.1
+    is-windows: ^1.0.2
+    kind-of: ^6.0.2
+    object.pick: ^1.3.0
+    regex-not: ^1.0.0
+    snapdragon: ^0.8.1
+    to-regex: ^3.0.1
+  checksum: 54d4166d6ef08db41252eb4e96d4109ebcb8029f0374f9db873bd91a1f896c32ec780d2a2ea65c0b2d7caf1f28d5e1ea33746a470f32146ac8bba821d80d38d8
+  languageName: node
+  linkType: hard
+
+"natural-compare@npm:^1.4.0":
+  version: 1.4.0
+  resolution: "natural-compare@npm:1.4.0"
+  checksum: 23ad088b08f898fc9b53011d7bb78ec48e79de7627e01ab5518e806033861bef68d5b0cd0e2205c2f36690ac9571ff6bcb05eb777ced2eeda8d4ac5b44592c3d
+  languageName: node
+  linkType: hard
+
+"nearley@npm:^2.7.10":
+  version: 2.20.1
+  resolution: "nearley@npm:2.20.1"
+  dependencies:
+    commander: ^2.19.0
+    moo: ^0.5.0
+    railroad-diagrams: ^1.0.0
+    randexp: 0.4.6
+  bin:
+    nearley-railroad: bin/nearley-railroad.js
+    nearley-test: bin/nearley-test.js
+    nearley-unparse: bin/nearley-unparse.js
+    nearleyc: bin/nearleyc.js
+  checksum: 42c2c330c13c7991b48221c5df00f4352c2f8851636ae4d1f8ca3c8e193fc1b7668c78011d1cad88cca4c1c4dc087425420629c19cc286d7598ec15533aaef26
+  languageName: node
+  linkType: hard
+
+"negotiator@npm:0.6.2":
+  version: 0.6.2
+  resolution: "negotiator@npm:0.6.2"
+  checksum: dfddaff6c06792f1c4c3809e29a427b8daef8cd437c83b08dd51d7ee11bbd1c29d9512d66b801144d6c98e910ffd8723f2432e0cbf8b18d41d2a09599c975ab3
+  languageName: node
+  linkType: hard
+
+"negotiator@npm:0.6.3, negotiator@npm:^0.6.2, negotiator@npm:^0.6.3":
+  version: 0.6.3
+  resolution: "negotiator@npm:0.6.3"
+  checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9
+  languageName: node
+  linkType: hard
+
+"neo-async@npm:^2.5.0, neo-async@npm:^2.6.1":
+  version: 2.6.2
+  resolution: "neo-async@npm:2.6.2"
+  checksum: deac9f8d00eda7b2e5cd1b2549e26e10a0faa70adaa6fdadca701cc55f49ee9018e427f424bac0c790b7c7e2d3068db97f3093f1093975f2acb8f8818b936ed9
+  languageName: node
+  linkType: hard
+
+"next-tick@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "next-tick@npm:1.1.0"
+  checksum: 83b5cf36027a53ee6d8b7f9c0782f2ba87f4858d977342bfc3c20c21629290a2111f8374d13a81221179603ffc4364f38374b5655d17b6a8f8a8c77bdea4fe8b
+  languageName: node
+  linkType: hard
+
+"nice-try@npm:^1.0.4":
+  version: 1.0.5
+  resolution: "nice-try@npm:1.0.5"
+  checksum: 0b4af3b5bb5d86c289f7a026303d192a7eb4417231fe47245c460baeabae7277bcd8fd9c728fb6bd62c30b3e15cd6620373e2cf33353b095d8b403d3e8a15aff
+  languageName: node
+  linkType: hard
+
+"nise@npm:^1.4.10":
+  version: 1.5.3
+  resolution: "nise@npm:1.5.3"
+  dependencies:
+    "@sinonjs/formatio": ^3.2.1
+    "@sinonjs/text-encoding": ^0.7.1
+    just-extend: ^4.0.2
+    lolex: ^5.0.1
+    path-to-regexp: ^1.7.0
+  checksum: ec3af21345dcaf34650a6f5420a11e0fd21a836ac5960f5e8523c301ee98465abf88b958f7b3084ecc6e0a7133e5cf7963f4df176b90b423c4da4984f1ebd75e
+  languageName: node
+  linkType: hard
+
+"no-case@npm:^3.0.4":
+  version: 3.0.4
+  resolution: "no-case@npm:3.0.4"
+  dependencies:
+    lower-case: ^2.0.2
+    tslib: ^2.0.3
+  checksum: 0b2ebc113dfcf737d48dde49cfebf3ad2d82a8c3188e7100c6f375e30eafbef9e9124aadc3becef237b042fd5eb0aad2fd78669c20972d045bbe7fea8ba0be5c
+  languageName: node
+  linkType: hard
+
+"node-fetch@npm:^1.0.1":
+  version: 1.7.3
+  resolution: "node-fetch@npm:1.7.3"
+  dependencies:
+    encoding: ^0.1.11
+    is-stream: ^1.0.1
+  checksum: 3bb0528c05d541316ebe52770d71ee25a6dce334df4231fd55df41a644143e07f068637488c18a5b0c43f05041dbd3346752f9e19b50df50569a802484544d5b
+  languageName: node
+  linkType: hard
+
+"node-fetch@npm:^2.6.12":
+  version: 2.7.0
+  resolution: "node-fetch@npm:2.7.0"
+  dependencies:
+    whatwg-url: ^5.0.0
+  peerDependencies:
+    encoding: ^0.1.0
+  peerDependenciesMeta:
+    encoding:
+      optional: true
+  checksum: d76d2f5edb451a3f05b15115ec89fc6be39de37c6089f1b6368df03b91e1633fd379a7e01b7ab05089a25034b2023d959b47e59759cb38d88341b2459e89d6e5
+  languageName: node
+  linkType: hard
+
+"node-forge@npm:^0.10.0":
+  version: 0.10.0
+  resolution: "node-forge@npm:0.10.0"
+  checksum: 5aa6dc9922e424a20ef101d2f517418e2bc9cfc0255dd22e0701c0fad1568445f510ee67f6f3fcdf085812c4ca1b847b8ba45683b34776828e41f5c1794e42e1
+  languageName: node
+  linkType: hard
+
+"node-gyp@npm:^8.4.1":
+  version: 8.4.1
+  resolution: "node-gyp@npm:8.4.1"
+  dependencies:
+    env-paths: ^2.2.0
+    glob: ^7.1.4
+    graceful-fs: ^4.2.6
+    make-fetch-happen: ^9.1.0
+    nopt: ^5.0.0
+    npmlog: ^6.0.0
+    rimraf: ^3.0.2
+    semver: ^7.3.5
+    tar: ^6.1.2
+    which: ^2.0.2
+  bin:
+    node-gyp: bin/node-gyp.js
+  checksum: 341710b5da39d3660e6a886b37e210d33f8282047405c2e62c277bcc744c7552c5b8b972ebc3a7d5c2813794e60cc48c3ebd142c46d6e0321db4db6c92dd0355
+  languageName: node
+  linkType: hard
+
+"node-gyp@npm:latest":
+  version: 9.0.0
+  resolution: "node-gyp@npm:9.0.0"
+  dependencies:
+    env-paths: ^2.2.0
+    glob: ^7.1.4
+    graceful-fs: ^4.2.6
+    make-fetch-happen: ^10.0.3
+    nopt: ^5.0.0
+    npmlog: ^6.0.0
+    rimraf: ^3.0.2
+    semver: ^7.3.5
+    tar: ^6.1.2
+    which: ^2.0.2
+  bin:
+    node-gyp: bin/node-gyp.js
+  checksum: 4d8ef8860f7e4f4d86c91db3f519d26ed5cc23b48fe54543e2afd86162b4acbd14f21de42a5db344525efb69a991e021b96a68c70c6e2d5f4a5cb770793da6d3
+  languageName: node
+  linkType: hard
+
+"node-int64@npm:^0.4.0":
+  version: 0.4.0
+  resolution: "node-int64@npm:0.4.0"
+  checksum: d0b30b1ee6d961851c60d5eaa745d30b5c95d94bc0e74b81e5292f7c42a49e3af87f1eb9e89f59456f80645d679202537de751b7d72e9e40ceea40c5e449057e
+  languageName: node
+  linkType: hard
+
+"node-libs-browser@npm:^2.2.1":
+  version: 2.2.1
+  resolution: "node-libs-browser@npm:2.2.1"
+  dependencies:
+    assert: ^1.1.1
+    browserify-zlib: ^0.2.0
+    buffer: ^4.3.0
+    console-browserify: ^1.1.0
+    constants-browserify: ^1.0.0
+    crypto-browserify: ^3.11.0
+    domain-browser: ^1.1.1
+    events: ^3.0.0
+    https-browserify: ^1.0.0
+    os-browserify: ^0.3.0
+    path-browserify: 0.0.1
+    process: ^0.11.10
+    punycode: ^1.2.4
+    querystring-es3: ^0.2.0
+    readable-stream: ^2.3.3
+    stream-browserify: ^2.0.1
+    stream-http: ^2.7.2
+    string_decoder: ^1.0.0
+    timers-browserify: ^2.0.4
+    tty-browserify: 0.0.0
+    url: ^0.11.0
+    util: ^0.11.0
+    vm-browserify: ^1.0.1
+  checksum: 41fa7927378edc0cb98a8cc784d3f4a47e43378d3b42ec57a23f81125baa7287c4b54d6d26d062072226160a3ce4d8b7a62e873d2fb637aceaddf71f5a26eca0
+  languageName: node
+  linkType: hard
+
+"node-modules-regexp@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "node-modules-regexp@npm:1.0.0"
+  checksum: 99541903536c5ce552786f0fca7f06b88df595e62e423c21fa86a1674ee2363dad1f7482d1bec20b4bd9fa5f262f88e6e5cb788fc56411113f2fe2e97783a3a7
+  languageName: node
+  linkType: hard
+
+"node-notifier@npm:^5.4.2":
+  version: 5.4.5
+  resolution: "node-notifier@npm:5.4.5"
+  dependencies:
+    growly: ^1.3.0
+    is-wsl: ^1.1.0
+    semver: ^5.5.0
+    shellwords: ^0.1.1
+    which: ^1.3.0
+  checksum: 8de174eb055a2ec55c1b0beede9328e8f9d4e32e7eacb7e3e2fddff48534105d0e2e10b4947dd422cc0602c65141317499c6fb1dc3b8ba03c775fb159e360bef
+  languageName: node
+  linkType: hard
+
+"node-releases@npm:^1.1.52":
+  version: 1.1.73
+  resolution: "node-releases@npm:1.1.73"
+  checksum: 44a6caec3330538a669c156fa84833725ae92b317585b106e08ab292c14da09f30cb913c10f1a7402180a51b10074832d4e045b6c3512d74c37d86b41a69e63b
+  languageName: node
+  linkType: hard
+
+"node-releases@npm:^2.0.13":
+  version: 2.0.13
+  resolution: "node-releases@npm:2.0.13"
+  checksum: 17ec8f315dba62710cae71a8dad3cd0288ba943d2ece43504b3b1aa8625bf138637798ab470b1d9035b0545996f63000a8a926e0f6d35d0996424f8b6d36dda3
+  languageName: node
+  linkType: hard
+
+"node-releases@npm:^2.0.14":
+  version: 2.0.14
+  resolution: "node-releases@npm:2.0.14"
+  checksum: 59443a2f77acac854c42d321bf1b43dea0aef55cd544c6a686e9816a697300458d4e82239e2d794ea05f7bbbc8a94500332e2d3ac3f11f52e4b16cbe638b3c41
+  languageName: node
+  linkType: hard
+
+"node-sass-chokidar@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "node-sass-chokidar@npm:2.0.0"
+  dependencies:
+    async-foreach: ^0.1.3
+    chokidar: ^3.4.0
+    get-stdin: ^4.0.1
+    glob: ^7.0.3
+    meow: ^3.7.0
+    node-sass: ^7.0.1
+    sass-graph: ^2.2.4
+    stdout-stream: ^1.4.0
+  bin:
+    node-sass-chokidar: bin/node-sass-chokidar
+  checksum: 5aeffc93cddf5cc32d0e86de4999e56e3cdccb1d86b5ed211e2d661f4e579bac19c078ca791662e2aaff9752ba2e18ce87324c07de5b3222064a4c9703856d9c
+  languageName: node
+  linkType: hard
+
+"node-sass@npm:^7.0.1":
+  version: 7.0.3
+  resolution: "node-sass@npm:7.0.3"
+  dependencies:
+    async-foreach: ^0.1.3
+    chalk: ^4.1.2
+    cross-spawn: ^7.0.3
+    gaze: ^1.0.0
+    get-stdin: ^4.0.1
+    glob: ^7.0.3
+    lodash: ^4.17.15
+    meow: ^9.0.0
+    nan: ^2.13.2
+    node-gyp: ^8.4.1
+    npmlog: ^5.0.0
+    request: ^2.88.0
+    sass-graph: ^4.0.1
+    stdout-stream: ^1.4.0
+    true-case-path: ^1.0.2
+  bin:
+    node-sass: bin/node-sass
+  checksum: 7d577d0fb68948959f367341e6cfc2858aa37abc5fadbd9e6b477ed0d192bebf7f8516d0b53c27be30ab05d5cd62d8a9bab08cc4442ef901b02cb51d864b4419
+  languageName: node
+  linkType: hard
+
+"node-sass@npm:^9.0.0":
+  version: 9.0.0
+  resolution: "node-sass@npm:9.0.0"
+  dependencies:
+    async-foreach: ^0.1.3
+    chalk: ^4.1.2
+    cross-spawn: ^7.0.3
+    gaze: ^1.0.0
+    get-stdin: ^4.0.1
+    glob: ^7.0.3
+    lodash: ^4.17.15
+    make-fetch-happen: ^10.0.4
+    meow: ^9.0.0
+    nan: ^2.17.0
+    node-gyp: ^8.4.1
+    sass-graph: ^4.0.1
+    stdout-stream: ^1.4.0
+    true-case-path: ^2.2.1
+  bin:
+    node-sass: bin/node-sass
+  checksum: b15fa76b1564c37d65cde7556731e3c09b49c74a6919cd5cff6f71ddbe454bd1ad9e458f5f02f0f81f43919b8755b5f56cf657fa4e32a0a2644a48fbc07147bb
+  languageName: node
+  linkType: hard
+
+"nopt@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "nopt@npm:5.0.0"
+  dependencies:
+    abbrev: 1
+  bin:
+    nopt: bin/nopt.js
+  checksum: d35fdec187269503843924e0114c0c6533fb54bbf1620d0f28b4b60ba01712d6687f62565c55cc20a504eff0fbe5c63e22340c3fad549ad40469ffb611b04f2f
+  languageName: node
+  linkType: hard
+
+"normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.3.4, normalize-package-data@npm:^2.5.0":
+  version: 2.5.0
+  resolution: "normalize-package-data@npm:2.5.0"
+  dependencies:
+    hosted-git-info: ^2.1.4
+    resolve: ^1.10.0
+    semver: 2 || 3 || 4 || 5
+    validate-npm-package-license: ^3.0.1
+  checksum: 7999112efc35a6259bc22db460540cae06564aa65d0271e3bdfa86876d08b0e578b7b5b0028ee61b23f1cae9fc0e7847e4edc0948d3068a39a2a82853efc8499
+  languageName: node
+  linkType: hard
+
+"normalize-package-data@npm:^3.0.0":
+  version: 3.0.3
+  resolution: "normalize-package-data@npm:3.0.3"
+  dependencies:
+    hosted-git-info: ^4.0.1
+    is-core-module: ^2.5.0
+    semver: ^7.3.4
+    validate-npm-package-license: ^3.0.1
+  checksum: bbcee00339e7c26fdbc760f9b66d429258e2ceca41a5df41f5df06cc7652de8d82e8679ff188ca095cad8eff2b6118d7d866af2b68400f74602fbcbce39c160a
+  languageName: node
+  linkType: hard
+
+"normalize-path@npm:^2.1.1":
+  version: 2.1.1
+  resolution: "normalize-path@npm:2.1.1"
+  dependencies:
+    remove-trailing-separator: ^1.0.1
+  checksum: 7e9cbdcf7f5b8da7aa191fbfe33daf290cdcd8c038f422faf1b8a83c972bf7a6d94c5be34c4326cb00fb63bc0fd97d9fbcfaf2e5d6142332c2cd36d2e1b86cea
+  languageName: node
+  linkType: hard
+
+"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0":
+  version: 3.0.0
+  resolution: "normalize-path@npm:3.0.0"
+  checksum: 88eeb4da891e10b1318c4b2476b6e2ecbeb5ff97d946815ffea7794c31a89017c70d7f34b3c2ebf23ef4e9fc9fb99f7dffe36da22011b5b5c6ffa34f4873ec20
+  languageName: node
+  linkType: hard
+
+"normalize-range@npm:^0.1.2":
+  version: 0.1.2
+  resolution: "normalize-range@npm:0.1.2"
+  checksum: 9b2f14f093593f367a7a0834267c24f3cb3e887a2d9809c77d8a7e5fd08738bcd15af46f0ab01cc3a3d660386f015816b5c922cea8bf2ee79777f40874063184
+  languageName: node
+  linkType: hard
+
+"normalize-scroll-left@npm:^0.1.2":
+  version: 0.1.2
+  resolution: "normalize-scroll-left@npm:0.1.2"
+  checksum: 817d5a659ba6f14f458cd03a5a0f98ec2a9d7e6c63c160f2db164827b87bb77c0237752890a18d11054fe628907493c5f4d8914907585ec9fbba3de26d2a6815
+  languageName: node
+  linkType: hard
+
+"normalize-url@npm:1.9.1":
+  version: 1.9.1
+  resolution: "normalize-url@npm:1.9.1"
+  dependencies:
+    object-assign: ^4.0.1
+    prepend-http: ^1.0.0
+    query-string: ^4.1.0
+    sort-keys: ^1.0.0
+  checksum: 4b03c22bebbb822874ce3b9204367ad1f27c314ae09b13aa201de730b3cf95f00dadf378277a56062322968c95c06e5764d01474d26af8b43d20bc4c8c491f84
+  languageName: node
+  linkType: hard
+
+"normalize-url@npm:^3.0.0":
+  version: 3.3.0
+  resolution: "normalize-url@npm:3.3.0"
+  checksum: f6aa4a1a94c3b799812f3e7fc987fb4599d869bfa8e9a160b6f2c5a2b4e62ada998d64dca30d9e20769d8bd95d3da1da3d4841dba2cc3c4d85364e1eb46219a2
+  languageName: node
+  linkType: hard
+
+"npm-run-path@npm:^2.0.0":
+  version: 2.0.2
+  resolution: "npm-run-path@npm:2.0.2"
+  dependencies:
+    path-key: ^2.0.0
+  checksum: acd5ad81648ba4588ba5a8effb1d98d2b339d31be16826a118d50f182a134ac523172101b82eab1d01cb4c2ba358e857d54cfafd8163a1ffe7bd52100b741125
+  languageName: node
+  linkType: hard
+
+"npm-run-path@npm:^4.0.0":
+  version: 4.0.1
+  resolution: "npm-run-path@npm:4.0.1"
+  dependencies:
+    path-key: ^3.0.0
+  checksum: 5374c0cea4b0bbfdfae62da7bbdf1e1558d338335f4cacf2515c282ff358ff27b2ecb91ffa5330a8b14390ac66a1e146e10700440c1ab868208430f56b5f4d23
+  languageName: node
+  linkType: hard
+
+"npmlog@npm:^5.0.0":
+  version: 5.0.1
+  resolution: "npmlog@npm:5.0.1"
+  dependencies:
+    are-we-there-yet: ^2.0.0
+    console-control-strings: ^1.1.0
+    gauge: ^3.0.0
+    set-blocking: ^2.0.0
+  checksum: 516b2663028761f062d13e8beb3f00069c5664925871a9b57989642ebe09f23ab02145bf3ab88da7866c4e112cafff72401f61a672c7c8a20edc585a7016ef5f
+  languageName: node
+  linkType: hard
+
+"npmlog@npm:^6.0.0":
+  version: 6.0.1
+  resolution: "npmlog@npm:6.0.1"
+  dependencies:
+    are-we-there-yet: ^3.0.0
+    console-control-strings: ^1.1.0
+    gauge: ^4.0.0
+    set-blocking: ^2.0.0
+  checksum: f1a4078a73ebc89896a832bbf869f491c32ecb12e0434b9a7499878ce8f29f22e72befe3c53cd8cdc9dbf4b4057297e783ab0b6746a8b067734de6205af4d538
+  languageName: node
+  linkType: hard
+
+"nth-check@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "nth-check@npm:1.0.2"
+  dependencies:
+    boolbase: ~1.0.0
+  checksum: 59e115fdd75b971d0030f42ada3aac23898d4c03aa13371fa8b3339d23461d1badf3fde5aad251fb956aaa75c0a3b9bfcd07c08a34a83b4f9dadfdce1d19337c
+  languageName: node
+  linkType: hard
+
+"nth-check@npm:^2.0.1":
+  version: 2.1.1
+  resolution: "nth-check@npm:2.1.1"
+  dependencies:
+    boolbase: ^1.0.0
+  checksum: 5afc3dafcd1573b08877ca8e6148c52abd565f1d06b1eb08caf982e3fa289a82f2cae697ffb55b5021e146d60443f1590a5d6b944844e944714a5b549675bcd3
+  languageName: node
+  linkType: hard
+
+"num2fraction@npm:^1.2.2":
+  version: 1.2.2
+  resolution: "num2fraction@npm:1.2.2"
+  checksum: 1da9c6797b505d3f5b17c7f694c4fa31565bdd5c0e5d669553253aed848a580804cd285280e8a73148bd9628839267daee4967f24b53d4e893e44b563e412635
+  languageName: node
+  linkType: hard
+
+"number-is-nan@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "number-is-nan@npm:1.0.1"
+  checksum: 13656bc9aa771b96cef209ffca31c31a03b507ca6862ba7c3f638a283560620d723d52e626d57892c7fff475f4c36ac07f0600f14544692ff595abff214b9ffb
+  languageName: node
+  linkType: hard
+
+"nwsapi@npm:^2.0.7, nwsapi@npm:^2.1.3":
+  version: 2.2.0
+  resolution: "nwsapi@npm:2.2.0"
+  checksum: 5ef4a9bc0c1a5b7f2e014aa6a4b359a257503b796618ed1ef0eb852098f77e772305bb0e92856e4bbfa3e6c75da48c0113505c76f144555ff38867229c2400a7
+  languageName: node
+  linkType: hard
+
+"oauth-sign@npm:~0.9.0":
+  version: 0.9.0
+  resolution: "oauth-sign@npm:0.9.0"
+  checksum: 8f5497a127967866a3c67094c21efd295e46013a94e6e828573c62220e9af568cc1d2d04b16865ba583e430510fa168baf821ea78f355146d8ed7e350fc44c64
+  languageName: node
+  linkType: hard
+
+"object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1":
+  version: 4.1.1
+  resolution: "object-assign@npm:4.1.1"
+  checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f
+  languageName: node
+  linkType: hard
+
+"object-copy@npm:^0.1.0":
+  version: 0.1.0
+  resolution: "object-copy@npm:0.1.0"
+  dependencies:
+    copy-descriptor: ^0.1.0
+    define-property: ^0.2.5
+    kind-of: ^3.0.3
+  checksum: a9e35f07e3a2c882a7e979090360d1a20ab51d1fa19dfdac3aa8873b328a7c4c7683946ee97c824ae40079d848d6740a3788fa14f2185155dab7ed970a72c783
+  languageName: node
+  linkType: hard
+
+"object-hash@npm:^2.0.1":
+  version: 2.2.0
+  resolution: "object-hash@npm:2.2.0"
+  checksum: 55ba841e3adce9c4f1b9b46b41983eda40f854e0d01af2802d3ae18a7085a17168d6b81731d43fdf1d6bcbb3c9f9c56d22c8fea992203ad90a38d7d919bc28f1
+  languageName: node
+  linkType: hard
+
+"object-inspect@npm:^1.10.3, object-inspect@npm:^1.7.0, object-inspect@npm:^1.9.0":
+  version: 1.10.3
+  resolution: "object-inspect@npm:1.10.3"
+  checksum: 9a56db2e0146fe94a7a9c78f677a2a28eec11d0ae13430e0bb2cb908fdd2d3feb7dbba7c638b9b7f88ace01d9a937227a8801709d13afb76613775aeb68632d3
+  languageName: node
+  linkType: hard
+
+"object-is@npm:^1.0.1, object-is@npm:^1.0.2, object-is@npm:^1.1.2":
+  version: 1.1.5
+  resolution: "object-is@npm:1.1.5"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+  checksum: 989b18c4cba258a6b74dc1d74a41805c1a1425bce29f6cabb50dcb1a6a651ea9104a1b07046739a49a5bb1bc49727bcb00efd5c55f932f6ea04ec8927a7901fe
+  languageName: node
+  linkType: hard
+
+"object-keys@npm:^1.0.12, object-keys@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "object-keys@npm:1.1.1"
+  checksum: b363c5e7644b1e1b04aa507e88dcb8e3a2f52b6ffd0ea801e4c7a62d5aa559affe21c55a07fd4b1fd55fc03a33c610d73426664b20032405d7b92a1414c34d6a
+  languageName: node
+  linkType: hard
+
+"object-visit@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "object-visit@npm:1.0.1"
+  dependencies:
+    isobject: ^3.0.0
+  checksum: b0ee07f5bf3bb881b881ff53b467ebbde2b37ebb38649d6944a6cd7681b32eedd99da9bd1e01c55facf81f54ed06b13af61aba6ad87f0052982995e09333f790
+  languageName: node
+  linkType: hard
+
+"object.assign@npm:^4.1.0, object.assign@npm:^4.1.2":
+  version: 4.1.2
+  resolution: "object.assign@npm:4.1.2"
+  dependencies:
+    call-bind: ^1.0.0
+    define-properties: ^1.1.3
+    has-symbols: ^1.0.1
+    object-keys: ^1.1.1
+  checksum: d621d832ed7b16ac74027adb87196804a500d80d9aca536fccb7ba48d33a7e9306a75f94c1d29cbfa324bc091bfc530bc24789568efdaee6a47fcfa298993814
+  languageName: node
+  linkType: hard
+
+"object.entries@npm:^1.1.0, object.entries@npm:^1.1.1, object.entries@npm:^1.1.2":
+  version: 1.1.4
+  resolution: "object.entries@npm:1.1.4"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.2
+  checksum: 1ddd2e28f5ecfe2369fe198439ec0457529f3eec85c7f43870be8de3ec3d98024b014ddb4a769ca48925e47ed76c69a51d8bf2c9886ed43174e3a1d33c2dbe38
+  languageName: node
+  linkType: hard
+
+"object.fromentries@npm:^2.0.2, object.fromentries@npm:^2.0.3":
+  version: 2.0.4
+  resolution: "object.fromentries@npm:2.0.4"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.0-next.2
+    has: ^1.0.3
+  checksum: 1e8e991c43a463a6389c6ee6935ef3843931fb012c5eed2ec30e3d5cf3760cb853f527723cdc98fb770d9c0cd068449448b03c303f527e7926a97d43daaa5c66
+  languageName: node
+  linkType: hard
+
+"object.getownpropertydescriptors@npm:^2.0.3, object.getownpropertydescriptors@npm:^2.1.0, object.getownpropertydescriptors@npm:^2.1.1":
+  version: 2.1.2
+  resolution: "object.getownpropertydescriptors@npm:2.1.2"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.0-next.2
+  checksum: 6c1c0162a2bea912f092dbf48699998d6f4b788a9884ee99ba41ddf25c3f0924ec56c6a55738c4ae3bd91d1203813a9a8e18e6fff1f477e2626cdbcd1a5f3ca8
+  languageName: node
+  linkType: hard
+
+"object.pick@npm:^1.3.0":
+  version: 1.3.0
+  resolution: "object.pick@npm:1.3.0"
+  dependencies:
+    isobject: ^3.0.1
+  checksum: 77fb6eed57c67adf75e9901187e37af39f052ef601cb4480386436561357eb9e459e820762f01fd02c5c1b42ece839ad393717a6d1850d848ee11fbabb3e580a
+  languageName: node
+  linkType: hard
+
+"object.values@npm:^1.1.0, object.values@npm:^1.1.1, object.values@npm:^1.1.2":
+  version: 1.1.4
+  resolution: "object.values@npm:1.1.4"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.2
+  checksum: 1a2f1e9d0bcfc299b8491170a50e6e7ca23392641d7781a8528e96c72f0013ba7ee731792ff8586c8eaec0328acda16c59622924c82c58bd0eb5c4ee67794856
+  languageName: node
+  linkType: hard
+
+"obuf@npm:^1.0.0, obuf@npm:^1.1.2":
+  version: 1.1.2
+  resolution: "obuf@npm:1.1.2"
+  checksum: 41a2ba310e7b6f6c3b905af82c275bf8854896e2e4c5752966d64cbcd2f599cfffd5932006bcf3b8b419dfdacebb3a3912d5d94e10f1d0acab59876c8757f27f
+  languageName: node
+  linkType: hard
+
+"on-finished@npm:2.4.1":
+  version: 2.4.1
+  resolution: "on-finished@npm:2.4.1"
+  dependencies:
+    ee-first: 1.1.1
+  checksum: d20929a25e7f0bb62f937a425b5edeb4e4cde0540d77ba146ec9357f00b0d497cdb3b9b05b9c8e46222407d1548d08166bff69cc56dfa55ba0e4469228920ff0
+  languageName: node
+  linkType: hard
+
+"on-headers@npm:~1.0.2":
+  version: 1.0.2
+  resolution: "on-headers@npm:1.0.2"
+  checksum: 2bf13467215d1e540a62a75021e8b318a6cfc5d4fc53af8e8f84ad98dbcea02d506c6d24180cd62e1d769c44721ba542f3154effc1f7579a8288c9f7873ed8e5
+  languageName: node
+  linkType: hard
+
+"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0":
+  version: 1.4.0
+  resolution: "once@npm:1.4.0"
+  dependencies:
+    wrappy: 1
+  checksum: cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68
+  languageName: node
+  linkType: hard
+
+"onetime@npm:^5.1.0":
+  version: 5.1.2
+  resolution: "onetime@npm:5.1.2"
+  dependencies:
+    mimic-fn: ^2.1.0
+  checksum: 2478859ef817fc5d4e9c2f9e5728512ddd1dbc9fb7829ad263765bb6d3b91ce699d6e2332eef6b7dff183c2f490bd3349f1666427eaba4469fba0ac38dfd0d34
+  languageName: node
+  linkType: hard
+
+"open@npm:^7.0.2":
+  version: 7.4.2
+  resolution: "open@npm:7.4.2"
+  dependencies:
+    is-docker: ^2.0.0
+    is-wsl: ^2.1.1
+  checksum: 3333900ec0e420d64c23b831bc3467e57031461d843c801f569b2204a1acc3cd7b3ec3c7897afc9dde86491dfa289708eb92bba164093d8bd88fb2c231843c91
+  languageName: node
+  linkType: hard
+
+"opn@npm:^5.5.0":
+  version: 5.5.0
+  resolution: "opn@npm:5.5.0"
+  dependencies:
+    is-wsl: ^1.1.0
+  checksum: 35b677b5a1fd6c8cb1996b0607671ba79f7ce9fa029217d54eafaf6bee13eb7e700691c6a415009140fd02a435fffdfd143875f3b233b60f3f9d631c6f6b81a0
+  languageName: node
+  linkType: hard
+
+"optimize-css-assets-webpack-plugin@npm:5.0.3":
+  version: 5.0.3
+  resolution: "optimize-css-assets-webpack-plugin@npm:5.0.3"
+  dependencies:
+    cssnano: ^4.1.10
+    last-call-webpack-plugin: ^3.0.0
+  peerDependencies:
+    webpack: ^4.0.0
+  checksum: 334eb9cb83643bba259946034d15ab123fd503d646f07edd1731efe57cf1c086c4fe28f804da8171316bbfa175c5f24913ae4337059045785cf7dacac303228d
+  languageName: node
+  linkType: hard
+
+"optionator@npm:^0.8.1, optionator@npm:^0.8.3":
+  version: 0.8.3
+  resolution: "optionator@npm:0.8.3"
+  dependencies:
+    deep-is: ~0.1.3
+    fast-levenshtein: ~2.0.6
+    levn: ~0.3.0
+    prelude-ls: ~1.1.2
+    type-check: ~0.3.2
+    word-wrap: ~1.2.3
+  checksum: b8695ddf3d593203e25ab0900e265d860038486c943ff8b774f596a310f8ceebdb30c6832407a8198ba3ec9debe1abe1f51d4aad94843612db3b76d690c61d34
+  languageName: node
+  linkType: hard
+
+"os-browserify@npm:^0.3.0":
+  version: 0.3.0
+  resolution: "os-browserify@npm:0.3.0"
+  checksum: 16e37ba3c0e6a4c63443c7b55799ce4066d59104143cb637ecb9fce586d5da319cdca786ba1c867abbe3890d2cbf37953f2d51eea85e20dd6c4570d6c54bfebf
+  languageName: node
+  linkType: hard
+
+"os-locale@npm:^1.4.0":
+  version: 1.4.0
+  resolution: "os-locale@npm:1.4.0"
+  dependencies:
+    lcid: ^1.0.0
+  checksum: 0161a1b6b5a8492f99f4b47fe465df9fc521c55ba5414fce6444c45e2500487b8ed5b40a47a98a2363fe83ff04ab033785300ed8df717255ec4c3b625e55b1fb
+  languageName: node
+  linkType: hard
+
+"os-tmpdir@npm:~1.0.2":
+  version: 1.0.2
+  resolution: "os-tmpdir@npm:1.0.2"
+  checksum: 5666560f7b9f10182548bf7013883265be33620b1c1b4a4d405c25be2636f970c5488ff3e6c48de75b55d02bde037249fe5dbfbb4c0fb7714953d56aed062e6d
+  languageName: node
+  linkType: hard
+
+"ospath@npm:^1.2.2":
+  version: 1.2.2
+  resolution: "ospath@npm:1.2.2"
+  checksum: 505f48a4f4f1c557d6c656ec985707726e3714721680139be037613e903aa8c8fa4ddd8d1342006f9b2dc0065e6e20f8b7bea2ee05354f31257044790367b347
+  languageName: node
+  linkType: hard
+
+"p-defer@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "p-defer@npm:1.0.0"
+  checksum: 4271b935c27987e7b6f229e5de4cdd335d808465604644cb7b4c4c95bef266735859a93b16415af8a41fd663ee9e3b97a1a2023ca9def613dba1bad2a0da0c7b
+  languageName: node
+  linkType: hard
+
+"p-each-series@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "p-each-series@npm:1.0.0"
+  dependencies:
+    p-reduce: ^1.0.0
+  checksum: 5acdaedd36e0c7b9617f4924dccfd681cbe4dd9f98b0eb0fde7c00dc701eeceaba55c0dc1dfde13207bdab3715a4c5040d806d7ddc493f27498110bdc1e9dd5d
+  languageName: node
+  linkType: hard
+
+"p-finally@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "p-finally@npm:1.0.0"
+  checksum: 93a654c53dc805dd5b5891bab16eb0ea46db8f66c4bfd99336ae929323b1af2b70a8b0654f8f1eae924b2b73d037031366d645f1fd18b3d30cbd15950cc4b1d4
+  languageName: node
+  linkType: hard
+
+"p-is-promise@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "p-is-promise@npm:1.1.0"
+  checksum: 64d7c6cda18af2c91c04209e5856c54d1a9818662d2320b34153d446645f431307e04406969a1be00cad680288e86dcf97b9eb39edd5dc4d0b1bd714ee85e13b
+  languageName: node
+  linkType: hard
+
+"p-limit@npm:^1.1.0":
+  version: 1.3.0
+  resolution: "p-limit@npm:1.3.0"
+  dependencies:
+    p-try: ^1.0.0
+  checksum: 281c1c0b8c82e1ac9f81acd72a2e35d402bf572e09721ce5520164e9de07d8274451378a3470707179ad13240535558f4b277f02405ad752e08c7d5b0d54fbfd
+  languageName: node
+  linkType: hard
+
+"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0, p-limit@npm:^2.3.0":
+  version: 2.3.0
+  resolution: "p-limit@npm:2.3.0"
+  dependencies:
+    p-try: ^2.0.0
+  checksum: 84ff17f1a38126c3314e91ecfe56aecbf36430940e2873dadaa773ffe072dc23b7af8e46d4b6485d302a11673fe94c6b67ca2cfbb60c989848b02100d0594ac1
+  languageName: node
+  linkType: hard
+
+"p-locate@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "p-locate@npm:2.0.0"
+  dependencies:
+    p-limit: ^1.1.0
+  checksum: e2dceb9b49b96d5513d90f715780f6f4972f46987dc32a0e18bc6c3fc74a1a5d73ec5f81b1398af5e58b99ea1ad03fd41e9181c01fa81b4af2833958696e3081
+  languageName: node
+  linkType: hard
+
+"p-locate@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "p-locate@npm:3.0.0"
+  dependencies:
+    p-limit: ^2.0.0
+  checksum: 83991734a9854a05fe9dbb29f707ea8a0599391f52daac32b86f08e21415e857ffa60f0e120bfe7ce0cc4faf9274a50239c7895fc0d0579d08411e513b83a4ae
+  languageName: node
+  linkType: hard
+
+"p-locate@npm:^4.1.0":
+  version: 4.1.0
+  resolution: "p-locate@npm:4.1.0"
+  dependencies:
+    p-limit: ^2.2.0
+  checksum: 513bd14a455f5da4ebfcb819ef706c54adb09097703de6aeaa5d26fe5ea16df92b48d1ac45e01e3944ce1e6aa2a66f7f8894742b8c9d6e276e16cd2049a2b870
+  languageName: node
+  linkType: hard
+
+"p-map@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "p-map@npm:2.1.0"
+  checksum: 9e3ad3c9f6d75a5b5661bcad78c91f3a63849189737cd75e4f1225bf9ac205194e5c44aac2ef6f09562b1facdb9bd1425584d7ac375bfaa17b3f1a142dab936d
+  languageName: node
+  linkType: hard
+
+"p-map@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "p-map@npm:3.0.0"
+  dependencies:
+    aggregate-error: ^3.0.0
+  checksum: 49b0fcbc66b1ef9cd379de1b4da07fa7a9f84b41509ea3f461c31903623aaba8a529d22f835e0d77c7cb9fcc16e4fae71e308fd40179aea514ba68f27032b5d5
+  languageName: node
+  linkType: hard
+
+"p-map@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "p-map@npm:4.0.0"
+  dependencies:
+    aggregate-error: ^3.0.0
+  checksum: cb0ab21ec0f32ddffd31dfc250e3afa61e103ef43d957cc45497afe37513634589316de4eb88abdfd969fe6410c22c0b93ab24328833b8eb1ccc087fc0442a1c
+  languageName: node
+  linkType: hard
+
+"p-reduce@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "p-reduce@npm:1.0.0"
+  checksum: 7b0f25c861ca2319c1fd6d28d1421edca12eb5b780b2f2bcdb418e634b4c2ef07bd85f75ad41594474ec512e5505b49c36e7b22a177d43c60cc014576eab8888
+  languageName: node
+  linkType: hard
+
+"p-retry@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "p-retry@npm:3.0.1"
+  dependencies:
+    retry: ^0.12.0
+  checksum: 702efc63fc13ef7fc0bab9a1b08432ab38a0236efcbce64af0cf692030ba6ed8009f29ba66e3301cb98dc69ef33e7ccab29ba1ac2bea897f802f81f4f7e468dd
+  languageName: node
+  linkType: hard
+
+"p-try@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "p-try@npm:1.0.0"
+  checksum: 3b5303f77eb7722144154288bfd96f799f8ff3e2b2b39330efe38db5dd359e4fb27012464cd85cb0a76e9b7edd1b443568cb3192c22e7cffc34989df0bafd605
+  languageName: node
+  linkType: hard
+
+"p-try@npm:^2.0.0":
+  version: 2.2.0
+  resolution: "p-try@npm:2.2.0"
+  checksum: f8a8e9a7693659383f06aec604ad5ead237c7a261c18048a6e1b5b85a5f8a067e469aa24f5bc009b991ea3b058a87f5065ef4176793a200d4917349881216cae
+  languageName: node
+  linkType: hard
+
+"pako@npm:~1.0.2, pako@npm:~1.0.5":
+  version: 1.0.11
+  resolution: "pako@npm:1.0.11"
+  checksum: 1be2bfa1f807608c7538afa15d6f25baa523c30ec870a3228a89579e474a4d992f4293859524e46d5d87fd30fa17c5edf34dbef0671251d9749820b488660b16
+  languageName: node
+  linkType: hard
+
+"parallel-transform@npm:^1.1.0":
+  version: 1.2.0
+  resolution: "parallel-transform@npm:1.2.0"
+  dependencies:
+    cyclist: ^1.0.1
+    inherits: ^2.0.3
+    readable-stream: ^2.1.5
+  checksum: ab6ddc1a662cefcfb3d8d546a111763d3b223f484f2e9194e33aefd8f6760c319d0821fd22a00a3adfbd45929b50d2c84cc121389732f013c2ae01c226269c27
+  languageName: node
+  linkType: hard
+
+"param-case@npm:^3.0.3":
+  version: 3.0.4
+  resolution: "param-case@npm:3.0.4"
+  dependencies:
+    dot-case: ^3.0.4
+    tslib: ^2.0.3
+  checksum: b34227fd0f794e078776eb3aa6247442056cb47761e9cd2c4c881c86d84c64205f6a56ef0d70b41ee7d77da02c3f4ed2f88e3896a8fefe08bdfb4deca037c687
+  languageName: node
+  linkType: hard
+
+"parent-module@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "parent-module@npm:1.0.1"
+  dependencies:
+    callsites: ^3.0.0
+  checksum: 6ba8b255145cae9470cf5551eb74be2d22281587af787a2626683a6c20fbb464978784661478dd2a3f1dad74d1e802d403e1b03c1a31fab310259eec8ac560ff
+  languageName: node
+  linkType: hard
+
+"parse-asn1@npm:^5.0.0":
+  version: 5.1.6
+  resolution: "parse-asn1@npm:5.1.6"
+  dependencies:
+    asn1.js: ^5.2.0
+    browserify-aes: ^1.0.0
+    evp_bytestokey: ^1.0.0
+    pbkdf2: ^3.0.3
+    safe-buffer: ^5.1.1
+  checksum: 9243311d1f88089bc9f2158972aa38d1abd5452f7b7cabf84954ed766048fe574d434d82c6f5a39b988683e96fb84cd933071dda38927e03469dc8c8d14463c7
+  languageName: node
+  linkType: hard
+
+"parse-asn1@npm:^5.1.7":
+  version: 5.1.7
+  resolution: "parse-asn1@npm:5.1.7"
+  dependencies:
+    asn1.js: ^4.10.1
+    browserify-aes: ^1.2.0
+    evp_bytestokey: ^1.0.3
+    hash-base: ~3.0
+    pbkdf2: ^3.1.2
+    safe-buffer: ^5.2.1
+  checksum: 93c7194c1ed63a13e0b212d854b5213ad1aca0ace41c66b311e97cca0519cf9240f79435a0306a3b412c257f0ea3f1953fd0d9549419a0952c9e995ab361fd6c
+  languageName: node
+  linkType: hard
+
+"parse-duration@npm:0.4.4":
+  version: 0.4.4
+  resolution: "parse-duration@npm:0.4.4"
+  checksum: 8ddb1bcc1c8831281f923cc66cba2682ab58d6793abd100fb613e83e82754dbce3c2fd7fc0c0bbb2bebd92371a8c196317823f591bc34a8ef217dac1710d827d
+  languageName: node
+  linkType: hard
+
+"parse-json@npm:^2.2.0":
+  version: 2.2.0
+  resolution: "parse-json@npm:2.2.0"
+  dependencies:
+    error-ex: ^1.2.0
+  checksum: dda78a63e57a47b713a038630868538f718a7ca0cd172a36887b0392ccf544ed0374902eb28f8bf3409e8b71d62b79d17062f8543afccf2745f9b0b2d2bb80ca
+  languageName: node
+  linkType: hard
+
+"parse-json@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "parse-json@npm:4.0.0"
+  dependencies:
+    error-ex: ^1.3.1
+    json-parse-better-errors: ^1.0.1
+  checksum: 0fe227d410a61090c247e34fa210552b834613c006c2c64d9a05cfe9e89cf8b4246d1246b1a99524b53b313e9ac024438d0680f67e33eaed7e6f38db64cfe7b5
+  languageName: node
+  linkType: hard
+
+"parse-json@npm:^5.0.0":
+  version: 5.2.0
+  resolution: "parse-json@npm:5.2.0"
+  dependencies:
+    "@babel/code-frame": ^7.0.0
+    error-ex: ^1.3.1
+    json-parse-even-better-errors: ^2.3.0
+    lines-and-columns: ^1.1.6
+  checksum: 62085b17d64da57f40f6afc2ac1f4d95def18c4323577e1eced571db75d9ab59b297d1d10582920f84b15985cbfc6b6d450ccbf317644cfa176f3ed982ad87e2
+  languageName: node
+  linkType: hard
+
+"parse5-htmlparser2-tree-adapter@npm:^6.0.1":
+  version: 6.0.1
+  resolution: "parse5-htmlparser2-tree-adapter@npm:6.0.1"
+  dependencies:
+    parse5: ^6.0.1
+  checksum: 1848378b355d027915645c13f13f982e60502d201f53bc2067a508bf2dba4aac08219fc781dcd160167f5f50f0c73f58d20fa4fb3d90ee46762c20234fa90a6d
+  languageName: node
+  linkType: hard
+
+"parse5@npm:4.0.0":
+  version: 4.0.0
+  resolution: "parse5@npm:4.0.0"
+  checksum: 2123cec690689fed44e6c76aa8a08215d2dadece7eff7b35156dda7485e6a232c9b737313688ee715eb0678b6a87a31026927dd74690154f8a0811059845ba46
+  languageName: node
+  linkType: hard
+
+"parse5@npm:5.1.0":
+  version: 5.1.0
+  resolution: "parse5@npm:5.1.0"
+  checksum: 13c44c6d47035a3cc75303655ae5630dc264f9b9ab8344feb3f79ca195d8b57a2a246af902abef1d780ad1eee92eb9b88cd03098a7ee7dd111f032152ebaf0a6
+  languageName: node
+  linkType: hard
+
+"parse5@npm:^6.0.1":
+  version: 6.0.1
+  resolution: "parse5@npm:6.0.1"
+  checksum: 7d569a176c5460897f7c8f3377eff640d54132b9be51ae8a8fa4979af940830b2b0c296ce75e5bd8f4041520aadde13170dbdec44889975f906098ea0002f4bd
+  languageName: node
+  linkType: hard
+
+"parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
+  version: 1.3.3
+  resolution: "parseurl@npm:1.3.3"
+  checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2
+  languageName: node
+  linkType: hard
+
+"pascal-case@npm:^3.1.2":
+  version: 3.1.2
+  resolution: "pascal-case@npm:3.1.2"
+  dependencies:
+    no-case: ^3.0.4
+    tslib: ^2.0.3
+  checksum: ba98bfd595fc91ef3d30f4243b1aee2f6ec41c53b4546bfa3039487c367abaa182471dcfc830a1f9e1a0df00c14a370514fa2b3a1aacc68b15a460c31116873e
+  languageName: node
+  linkType: hard
+
+"pascalcase@npm:^0.1.1":
+  version: 0.1.1
+  resolution: "pascalcase@npm:0.1.1"
+  checksum: f83681c3c8ff75fa473a2bb2b113289952f802ff895d435edd717e7cb898b0408cbdb247117a938edcbc5d141020909846cc2b92c47213d764e2a94d2ad2b925
+  languageName: node
+  linkType: hard
+
+"path-browserify@npm:0.0.1":
+  version: 0.0.1
+  resolution: "path-browserify@npm:0.0.1"
+  checksum: ae8dcd45d0d3cfbaf595af4f206bf3ed82d77f72b4877ae7e77328079e1468c84f9386754bb417d994d5a19bf47882fd253565c18441cd5c5c90ae5187599e35
+  languageName: node
+  linkType: hard
+
+"path-dirname@npm:^1.0.0":
+  version: 1.0.2
+  resolution: "path-dirname@npm:1.0.2"
+  checksum: 0d2f6604ae05a252a0025318685f290e2764ecf9c5436f203cdacfc8c0b17c24cdedaa449d766beb94ab88cc7fc70a09ec21e7933f31abc2b719180883e5e33f
+  languageName: node
+  linkType: hard
+
+"path-exists@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "path-exists@npm:2.1.0"
+  dependencies:
+    pinkie-promise: ^2.0.0
+  checksum: fdb734f1d00f225f7a0033ce6d73bff6a7f76ea08936abf0e5196fa6e54a645103538cd8aedcb90d6d8c3fa3705ded0c58a4da5948ae92aa8834892c1ab44a84
+  languageName: node
+  linkType: hard
+
+"path-exists@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "path-exists@npm:3.0.0"
+  checksum: 96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a
+  languageName: node
+  linkType: hard
+
+"path-exists@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "path-exists@npm:4.0.0"
+  checksum: 505807199dfb7c50737b057dd8d351b82c033029ab94cb10a657609e00c1bc53b951cfdbccab8de04c5584d5eff31128ce6afd3db79281874a5ef2adbba55ed1
+  languageName: node
+  linkType: hard
+
+"path-is-absolute@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "path-is-absolute@npm:1.0.1"
+  checksum: 060840f92cf8effa293bcc1bea81281bd7d363731d214cbe5c227df207c34cd727430f70c6037b5159c8a870b9157cba65e775446b0ab06fd5ecc7e54615a3b8
+  languageName: node
+  linkType: hard
+
+"path-is-inside@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "path-is-inside@npm:1.0.2"
+  checksum: 0b5b6c92d3018b82afb1f74fe6de6338c4c654de4a96123cb343f2b747d5606590ac0c890f956ed38220a4ab59baddfd7b713d78a62d240b20b14ab801fa02cb
+  languageName: node
+  linkType: hard
+
+"path-key@npm:^2.0.0, path-key@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "path-key@npm:2.0.1"
+  checksum: f7ab0ad42fe3fb8c7f11d0c4f849871e28fbd8e1add65c370e422512fc5887097b9cf34d09c1747d45c942a8c1e26468d6356e2df3f740bf177ab8ca7301ebfd
+  languageName: node
+  linkType: hard
+
+"path-key@npm:^3.0.0, path-key@npm:^3.1.0":
+  version: 3.1.1
+  resolution: "path-key@npm:3.1.1"
+  checksum: 55cd7a9dd4b343412a8386a743f9c746ef196e57c823d90ca3ab917f90ab9f13dd0ded27252ba49dbdfcab2b091d998bc446f6220cd3cea65db407502a740020
+  languageName: node
+  linkType: hard
+
+"path-parse@npm:^1.0.6":
+  version: 1.0.7
+  resolution: "path-parse@npm:1.0.7"
+  checksum: 49abf3d81115642938a8700ec580da6e830dde670be21893c62f4e10bd7dd4c3742ddc603fe24f898cba7eb0c6bc1777f8d9ac14185d34540c6d4d80cd9cae8a
+  languageName: node
+  linkType: hard
+
+"path-to-regexp@npm:0.1.7":
+  version: 0.1.7
+  resolution: "path-to-regexp@npm:0.1.7"
+  checksum: 69a14ea24db543e8b0f4353305c5eac6907917031340e5a8b37df688e52accd09e3cebfe1660b70d76b6bd89152f52183f28c74813dbf454ba1a01c82a38abce
+  languageName: node
+  linkType: hard
+
+"path-to-regexp@npm:^1.7.0":
+  version: 1.8.0
+  resolution: "path-to-regexp@npm:1.8.0"
+  dependencies:
+    isarray: 0.0.1
+  checksum: 709f6f083c0552514ef4780cb2e7e4cf49b0cc89a97439f2b7cc69a608982b7690fb5d1720a7473a59806508fc2dae0be751ba49f495ecf89fd8fbc62abccbcd
+  languageName: node
+  linkType: hard
+
+"path-type@npm:^1.0.0":
+  version: 1.1.0
+  resolution: "path-type@npm:1.1.0"
+  dependencies:
+    graceful-fs: ^4.1.2
+    pify: ^2.0.0
+    pinkie-promise: ^2.0.0
+  checksum: 59a4b2c0e566baf4db3021a1ed4ec09a8b36fca960a490b54a6bcefdb9987dafe772852982b6011cd09579478a96e57960a01f75fa78a794192853c9d468fc79
+  languageName: node
+  linkType: hard
+
+"path-type@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "path-type@npm:2.0.0"
+  dependencies:
+    pify: ^2.0.0
+  checksum: 749dc0c32d4ebe409da155a0022f9be3d08e6fd276adb3dfa27cb2486519ab2aa277d1453b3fde050831e0787e07b0885a75653fefcc82d883753c5b91121b1c
+  languageName: node
+  linkType: hard
+
+"path-type@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "path-type@npm:3.0.0"
+  dependencies:
+    pify: ^3.0.0
+  checksum: 735b35e256bad181f38fa021033b1c33cfbe62ead42bb2222b56c210e42938eecb272ae1949f3b6db4ac39597a61b44edd8384623ec4d79bfdc9a9c0f12537a6
+  languageName: node
+  linkType: hard
+
+"path-type@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "path-type@npm:4.0.0"
+  checksum: 5b1e2daa247062061325b8fdbfd1fb56dde0a448fb1455453276ea18c60685bdad23a445dc148cf87bc216be1573357509b7d4060494a6fd768c7efad833ee45
+  languageName: node
+  linkType: hard
+
+"pbkdf2@npm:^3.0.3, pbkdf2@npm:^3.1.2":
+  version: 3.1.2
+  resolution: "pbkdf2@npm:3.1.2"
+  dependencies:
+    create-hash: ^1.1.2
+    create-hmac: ^1.1.4
+    ripemd160: ^2.0.1
+    safe-buffer: ^5.0.1
+    sha.js: ^2.4.8
+  checksum: 2c950a100b1da72123449208e231afc188d980177d021d7121e96a2de7f2abbc96ead2b87d03d8fe5c318face097f203270d7e27908af9f471c165a4e8e69c92
+  languageName: node
+  linkType: hard
+
+"pend@npm:~1.2.0":
+  version: 1.2.0
+  resolution: "pend@npm:1.2.0"
+  checksum: 6c72f5243303d9c60bd98e6446ba7d30ae29e3d56fdb6fae8767e8ba6386f33ee284c97efe3230a0d0217e2b1723b8ab490b1bbf34fcbb2180dbc8a9de47850d
+  languageName: node
+  linkType: hard
+
+"performance-now@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "performance-now@npm:2.1.0"
+  checksum: 534e641aa8f7cba160f0afec0599b6cecefbb516a2e837b512be0adbe6c1da5550e89c78059c7fabc5c9ffdf6627edabe23eb7c518c4500067a898fa65c2b550
+  languageName: node
+  linkType: hard
+
+"picocolors@npm:^0.2.1":
+  version: 0.2.1
+  resolution: "picocolors@npm:0.2.1"
+  checksum: 3b0f441f0062def0c0f39e87b898ae7461c3a16ffc9f974f320b44c799418cabff17780ee647fda42b856a1dc45897e2c62047e1b546d94d6d5c6962f45427b2
+  languageName: node
+  linkType: hard
+
+"picocolors@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "picocolors@npm:1.0.0"
+  checksum: a2e8092dd86c8396bdba9f2b5481032848525b3dc295ce9b57896f931e63fc16f79805144321f72976383fc249584672a75cc18d6777c6b757603f372f745981
+  languageName: node
+  linkType: hard
+
+"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1":
+  version: 2.3.0
+  resolution: "picomatch@npm:2.3.0"
+  checksum: 16818720ea7c5872b6af110760dee856c8e4cd79aed1c7a006d076b1cc09eff3ae41ca5019966694c33fbd2e1cc6ea617ab10e4adac6df06556168f13be3fca2
+  languageName: node
+  linkType: hard
+
+"picomatch@npm:^2.3.1":
+  version: 2.3.1
+  resolution: "picomatch@npm:2.3.1"
+  checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf
+  languageName: node
+  linkType: hard
+
+"pify@npm:^2.0.0, pify@npm:^2.2.0":
+  version: 2.3.0
+  resolution: "pify@npm:2.3.0"
+  checksum: 9503aaeaf4577acc58642ad1d25c45c6d90288596238fb68f82811c08104c800e5a7870398e9f015d82b44ecbcbef3dc3d4251a1cbb582f6e5959fe09884b2ba
+  languageName: node
+  linkType: hard
+
+"pify@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "pify@npm:3.0.0"
+  checksum: 6cdcbc3567d5c412450c53261a3f10991665d660961e06605decf4544a61a97a54fefe70a68d5c37080ff9d6f4cf51444c90198d1ba9f9309a6c0d6e9f5c4fde
+  languageName: node
+  linkType: hard
+
+"pify@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "pify@npm:4.0.1"
+  checksum: 9c4e34278cb09987685fa5ef81499c82546c033713518f6441778fbec623fc708777fe8ac633097c72d88470d5963094076c7305cafc7ad340aae27cfacd856b
+  languageName: node
+  linkType: hard
+
+"pinkie-promise@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "pinkie-promise@npm:2.0.1"
+  dependencies:
+    pinkie: ^2.0.0
+  checksum: b53a4a2e73bf56b6f421eef711e7bdcb693d6abb474d57c5c413b809f654ba5ee750c6a96dd7225052d4b96c4d053cdcb34b708a86fceed4663303abee52fcca
+  languageName: node
+  linkType: hard
+
+"pinkie@npm:^2.0.0":
+  version: 2.0.4
+  resolution: "pinkie@npm:2.0.4"
+  checksum: b12b10afea1177595aab036fc220785488f67b4b0fc49e7a27979472592e971614fa1c728e63ad3e7eb748b4ec3c3dbd780819331dad6f7d635c77c10537b9db
+  languageName: node
+  linkType: hard
+
+"pirates@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "pirates@npm:4.0.1"
+  dependencies:
+    node-modules-regexp: ^1.0.0
+  checksum: 091e232aac19f0049a681838fa9fcb4af824b5b1eb0e9325aa07b9d13245bfe3e4fa57a7766b9fdcd19cb89f2c15c688b46023be3047cb288023a0c079d3b2a3
+  languageName: node
+  linkType: hard
+
+"pkg-dir@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "pkg-dir@npm:1.0.0"
+  dependencies:
+    find-up: ^1.0.0
+  checksum: ce49878797dd81a5cee1cb7f05fdd431729309e4854c9f83d7748491b9d25c5f8ef04b3b7658134361fa036934c0aaa7fc7f984e46970dd227aa490f3869d36a
+  languageName: node
+  linkType: hard
+
+"pkg-dir@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "pkg-dir@npm:2.0.0"
+  dependencies:
+    find-up: ^2.1.0
+  checksum: 8c72b712305b51e1108f0ffda5ec1525a8307e54a5855db8fb1dcf77561a5ae98e2ba3b4814c9806a679f76b2f7e5dd98bde18d07e594ddd9fdd25e9cf242ea1
+  languageName: node
+  linkType: hard
+
+"pkg-dir@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "pkg-dir@npm:3.0.0"
+  dependencies:
+    find-up: ^3.0.0
+  checksum: 70c9476ffefc77552cc6b1880176b71ad70bfac4f367604b2b04efd19337309a4eec985e94823271c7c0e83946fa5aeb18cd360d15d10a5d7533e19344bfa808
+  languageName: node
+  linkType: hard
+
+"pkg-dir@npm:^4.1.0":
+  version: 4.2.0
+  resolution: "pkg-dir@npm:4.2.0"
+  dependencies:
+    find-up: ^4.0.0
+  checksum: 9863e3f35132bf99ae1636d31ff1e1e3501251d480336edb1c211133c8d58906bed80f154a1d723652df1fda91e01c7442c2eeaf9dc83157c7ae89087e43c8d6
+  languageName: node
+  linkType: hard
+
+"pkg-up@npm:3.1.0, pkg-up@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "pkg-up@npm:3.1.0"
+  dependencies:
+    find-up: ^3.0.0
+  checksum: 5bac346b7c7c903613c057ae3ab722f320716199d753f4a7d053d38f2b5955460f3e6ab73b4762c62fd3e947f58e04f1343e92089e7bb6091c90877406fcd8c8
+  languageName: node
+  linkType: hard
+
+"pn@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "pn@npm:1.1.0"
+  checksum: e4654186dc92a187c8c7fe4ccda902f4d39dd9c10f98d1c5a08ce5fad5507ef1e33ddb091240c3950bee81bd201b4c55098604c433a33b5e8bdd97f38b732fa0
+  languageName: node
+  linkType: hard
+
+"pnp-webpack-plugin@npm:1.6.4":
+  version: 1.6.4
+  resolution: "pnp-webpack-plugin@npm:1.6.4"
+  dependencies:
+    ts-pnp: ^1.1.6
+  checksum: 0606a63db96400b07f182300168298da9518727a843f9e10cf5045d2a102a4be06bb18c73dc481281e3e0f1ed8d04ef0d285a342b6dcd0eff1340e28e5d2328d
+  languageName: node
+  linkType: hard
+
+"popper.js@npm:^1.14.1":
+  version: 1.16.1
+  resolution: "popper.js@npm:1.16.1"
+  checksum: c56ae5001ec50a77ee297a8061a0221d99d25c7348d2e6bcd3e45a0d0f32a1fd81bca29d46cb0d4bdf13efb77685bd6a0ce93f9eb3c608311a461f945fffedbe
+  languageName: node
+  linkType: hard
+
+"portfinder@npm:^1.0.26":
+  version: 1.0.28
+  resolution: "portfinder@npm:1.0.28"
+  dependencies:
+    async: ^2.6.2
+    debug: ^3.1.1
+    mkdirp: ^0.5.5
+  checksum: 91fef602f13f8f4c64385d0ad2a36cc9dc6be0b8d10a2628ee2c3c7b9917ab4fefb458815b82cea2abf4b785cd11c9b4e2d917ac6fa06f14b6fa880ca8f8928c
+  languageName: node
+  linkType: hard
+
+"posix-character-classes@npm:^0.1.0":
+  version: 0.1.1
+  resolution: "posix-character-classes@npm:0.1.1"
+  checksum: dedb99913c60625a16050cfed2fb5c017648fc075be41ac18474e1c6c3549ef4ada201c8bd9bd006d36827e289c571b6092e1ef6e756cdbab2fd7046b25c6442
+  languageName: node
+  linkType: hard
+
+"postcss-attribute-case-insensitive@npm:^4.0.1":
+  version: 4.0.2
+  resolution: "postcss-attribute-case-insensitive@npm:4.0.2"
+  dependencies:
+    postcss: ^7.0.2
+    postcss-selector-parser: ^6.0.2
+  checksum: e9cf4b61f443bf302dcd1110ef38d6a808fa38ae5d85bfd0aaaa6d35bef3825e0434f1aed8eb9596a5d88f21580ce8b9cd0098414d8490293ef71149695cae9a
+  languageName: node
+  linkType: hard
+
+"postcss-browser-comments@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "postcss-browser-comments@npm:3.0.0"
+  dependencies:
+    postcss: ^7
+  peerDependencies:
+    browserslist: ^4
+  checksum: 6e8cfae4c71cf7b5d4741e19021f3e3d81d772372a9e12f5c675e25bc3ea45fe5154fd0ee055ee041aee8b484c59875fdf15df3cec5e7fd4cf3209bc5ef0b515
+  languageName: node
+  linkType: hard
+
+"postcss-calc@npm:^7.0.1":
+  version: 7.0.5
+  resolution: "postcss-calc@npm:7.0.5"
+  dependencies:
+    postcss: ^7.0.27
+    postcss-selector-parser: ^6.0.2
+    postcss-value-parser: ^4.0.2
+  checksum: 03640d493fb0e557634ab23e5d1eb527b014fb491ac3e62b45e28f5a6ef57e25a209f82040ce54c40d5a1a7307597a55d3fa6e8cece0888261a66bc75e39a68b
+  languageName: node
+  linkType: hard
+
+"postcss-color-functional-notation@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "postcss-color-functional-notation@npm:2.0.1"
+  dependencies:
+    postcss: ^7.0.2
+    postcss-values-parser: ^2.0.0
+  checksum: 0bfd1fa93bc54a07240d821d091093256511f70f0df5349e27e4d8b034ee3345f0ae58674ce425be6a91cc934325b2ce36ecddbf958fa8805fed6647cf671348
+  languageName: node
+  linkType: hard
+
+"postcss-color-gray@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "postcss-color-gray@npm:5.0.0"
+  dependencies:
+    "@csstools/convert-colors": ^1.4.0
+    postcss: ^7.0.5
+    postcss-values-parser: ^2.0.0
+  checksum: 81a62b3e2c170ffadc085c1643a7b5f1c153837d7ca228b07df88b9aeb0ec9088a92f8d919a748137ead3936e8dac2606e32b14b5166a59143642c8573949db5
+  languageName: node
+  linkType: hard
+
+"postcss-color-hex-alpha@npm:^5.0.3":
+  version: 5.0.3
+  resolution: "postcss-color-hex-alpha@npm:5.0.3"
+  dependencies:
+    postcss: ^7.0.14
+    postcss-values-parser: ^2.0.1
+  checksum: 0a0ccb42c7c6a271ffd3c8b123b9c67744827d4b810b759731bc702fea1e00f05f08479ec7cbd8dfa47bc20510830a69f1e316a5724b9e53d5fdc6fabf90afc4
+  languageName: node
+  linkType: hard
+
+"postcss-color-mod-function@npm:^3.0.3":
+  version: 3.0.3
+  resolution: "postcss-color-mod-function@npm:3.0.3"
+  dependencies:
+    "@csstools/convert-colors": ^1.4.0
+    postcss: ^7.0.2
+    postcss-values-parser: ^2.0.0
+  checksum: ecbf74e9395527aaf3e83b90b1a6c9bba0a1904038d8acef1f530d50a68d912d6b1af8df690342f942be8b89fa7dfaa35ae67cb5fb48013cb389ecb8c74deadb
+  languageName: node
+  linkType: hard
+
+"postcss-color-rebeccapurple@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "postcss-color-rebeccapurple@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.2
+    postcss-values-parser: ^2.0.0
+  checksum: a7b1a204dfc5163ac4195cc3cb0c7b1bba9561feab49d24be8a17d695d6b69fd92f3da23d638260fe7e9d5076cf81bb798b25134fa2a2fbf7f74b0dda2829a96
+  languageName: node
+  linkType: hard
+
+"postcss-colormin@npm:^4.0.3":
+  version: 4.0.3
+  resolution: "postcss-colormin@npm:4.0.3"
+  dependencies:
+    browserslist: ^4.0.0
+    color: ^3.0.0
+    has: ^1.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: 9b2eab73cd227cbf296f1a2a6466047f6c70b918c3844535531fd87f31d7878e1a8d81e8803ffe2ee8c3330ea5bec65e358a0e0f33defcd758975064e07fe928
+  languageName: node
+  linkType: hard
+
+"postcss-combine-duplicated-selectors@npm:^10.0.3":
+  version: 10.0.3
+  resolution: "postcss-combine-duplicated-selectors@npm:10.0.3"
+  dependencies:
+    postcss-selector-parser: ^6.0.4
+  peerDependencies:
+    postcss: ^8.1.0
+  checksum: 45c3dff41d0cddb510752ed92fe8c7fc66e5cf88f4988314655419d3ecdf1dc66f484a25ee73f4f292da5da851a0fdba0ec4d59bdedeee935d05b26d31d997ed
+  languageName: node
+  linkType: hard
+
+"postcss-convert-values@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "postcss-convert-values@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: 71cac73f5befeb8bc16274e2aaabe1b8e0cb42a8b8641dc2aa61b1c502697b872a682c36f370cce325553bbfc859c38f2b064fae6f6469b1cada79e733559261
+  languageName: node
+  linkType: hard
+
+"postcss-custom-media@npm:^7.0.8":
+  version: 7.0.8
+  resolution: "postcss-custom-media@npm:7.0.8"
+  dependencies:
+    postcss: ^7.0.14
+  checksum: 3786eb10f238b22dc620cfcc9257779e27d8cee4510b3209d0ab67310e07dc68b69f3359db7a911f5e76df466f73d078fc80100943fe2e8fa9bcacf226705a2d
+  languageName: node
+  linkType: hard
+
+"postcss-custom-properties@npm:^8.0.11":
+  version: 8.0.11
+  resolution: "postcss-custom-properties@npm:8.0.11"
+  dependencies:
+    postcss: ^7.0.17
+    postcss-values-parser: ^2.0.1
+  checksum: cb1b47459a23ff2e48714c5d48d50070d573ef829dc7e57189d1b38c6fba0de7084f1acefbd84c61dd67e30bd9a7d154b22f195547728a9dc5f76f7d3f03ffea
+  languageName: node
+  linkType: hard
+
+"postcss-custom-selectors@npm:^5.1.2":
+  version: 5.1.2
+  resolution: "postcss-custom-selectors@npm:5.1.2"
+  dependencies:
+    postcss: ^7.0.2
+    postcss-selector-parser: ^5.0.0-rc.3
+  checksum: 26c83d348448f4ab5931cc1621606b09a6b1171e25fac2404073f3e298e77494ac87d4a21009679503b4895452810e93e618b5af26b4c7180a9013f283bb8088
+  languageName: node
+  linkType: hard
+
+"postcss-dir-pseudo-class@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "postcss-dir-pseudo-class@npm:5.0.0"
+  dependencies:
+    postcss: ^7.0.2
+    postcss-selector-parser: ^5.0.0-rc.3
+  checksum: 703156fc65f259ec2e86ba51d18370a6d3b71f2e6473c7d65694676a8f0152137b1997bc0a53f7f373c8c3e4d63c72f7b5e2049f2ef3a7276b49409395722044
+  languageName: node
+  linkType: hard
+
+"postcss-discard-comments@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-discard-comments@npm:4.0.2"
+  dependencies:
+    postcss: ^7.0.0
+  checksum: b087d47649160b7c6236aba028d27f1796a0dcb21e9ffd0da62271171fc31b7f150ee6c7a24fa97e3f5cd1af92e0dc41cb2e2680a175da53f1e536c441bda56a
+  languageName: node
+  linkType: hard
+
+"postcss-discard-duplicates@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-discard-duplicates@npm:4.0.2"
+  dependencies:
+    postcss: ^7.0.0
+  checksum: bd83647a8e5ea34b0cfe563d0c1410a0c9e742011aa67955709c5ecd2d2bb03b7016053781e975e4c802127d2f9a0cd9c22f1f2783b9d7b1c35487d60f7ea540
+  languageName: node
+  linkType: hard
+
+"postcss-discard-empty@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "postcss-discard-empty@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.0
+  checksum: 529b177bd2417fa5c8887891369b4538b858d767461192974a796814265794e08e0e624a9f4c566ed9f841af3faddb7e7a9c05c45cbbe2fb1f092f65bd227f5c
+  languageName: node
+  linkType: hard
+
+"postcss-discard-overridden@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "postcss-discard-overridden@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.0
+  checksum: b34d8cf58e4d13d99a3a9459f4833f1248ca897316bbb927375590feba35c24a0304084a6174a7bf3fe4ba3d5e5e9baf15ea938e7e5744e56915fa7ef6d91ee0
+  languageName: node
+  linkType: hard
+
+"postcss-double-position-gradients@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "postcss-double-position-gradients@npm:1.0.0"
+  dependencies:
+    postcss: ^7.0.5
+    postcss-values-parser: ^2.0.0
+  checksum: d2c4515b38a131ece44dba331aea2b3f9de646e30873b49f03fa8906179a3c43ddc43183bc4df609d8af0834e7c266ec3a63eaa4b3e96aa445d98ecdc12d2544
+  languageName: node
+  linkType: hard
+
+"postcss-env-function@npm:^2.0.2":
+  version: 2.0.2
+  resolution: "postcss-env-function@npm:2.0.2"
+  dependencies:
+    postcss: ^7.0.2
+    postcss-values-parser: ^2.0.0
+  checksum: 0cfa2e6cad5123cce39dcf5af332ec3b0e3e09b54d5142225f255914079d2afda3f1052e60f4b6d3bccf7eb9d592325b7421f1ecc6674ccb13c267a721fc3128
+  languageName: node
+  linkType: hard
+
+"postcss-flexbugs-fixes@npm:4.1.0":
+  version: 4.1.0
+  resolution: "postcss-flexbugs-fixes@npm:4.1.0"
+  dependencies:
+    postcss: ^7.0.0
+  checksum: b5f2c39f4315a0eacfc23cafe6d20cff36e4605d266aa38f261e1db7f65e913e5fe3044d952d9435850f67525d5b1c7cc22eb6edeb51e19657c7a9a53b361dc5
+  languageName: node
+  linkType: hard
+
+"postcss-focus-visible@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "postcss-focus-visible@npm:4.0.0"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: a3c93fbb578608f60c5256d0989ae32fd9100f76fa053880e82bfeb43751e81a3a9e69bd8338e06579b7f56b230a80fb2cc671eff134f2682dcbec9bbb8658ae
+  languageName: node
+  linkType: hard
+
+"postcss-focus-within@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "postcss-focus-within@npm:3.0.0"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: 2a31292cd9b929a2dd3171fc4ed287ea4a93c6ec8df1d634503fb97b8b30b33a2970b5e0df60634c60ff887923ab28641b624d566533096950e0a384705e9b90
+  languageName: node
+  linkType: hard
+
+"postcss-font-variant@npm:^4.0.0":
+  version: 4.0.1
+  resolution: "postcss-font-variant@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: d09836cd848e8c24d144484b6b9b175df26dca59e1a1579e790c7f3dcaea00944a8d0b6ac543f4c128de7b30fab9a0aef544d54789b3b55fd850770b172d980d
+  languageName: node
+  linkType: hard
+
+"postcss-gap-properties@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "postcss-gap-properties@npm:2.0.0"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: c842d105c9403e34a8fac7bdef33a63fcb6bde038b04b20cae1e719e1966632887545576af99a4a6f302c98ca029c6f0d746419f498ef7f6821177ba676e6c25
+  languageName: node
+  linkType: hard
+
+"postcss-image-set-function@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "postcss-image-set-function@npm:3.0.1"
+  dependencies:
+    postcss: ^7.0.2
+    postcss-values-parser: ^2.0.0
+  checksum: 43958d7c1f80077e60e066bdf61bc326bcac64c272f17fd7a0585a6934fb1ffc7ba7f560a39849f597e4d28b8ae3addd9279c7145b9478d2d91a7c54c2fefd8b
+  languageName: node
+  linkType: hard
+
+"postcss-initial@npm:^3.0.0":
+  version: 3.0.4
+  resolution: "postcss-initial@npm:3.0.4"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: 710ab6cabc5970912c04314099f5334e7d901235014bb1462657e29f8dc97b6e51caa35f0beba7e5dbe440589ef9c1df13a89bc53d6e6aa664573b945f1630bb
+  languageName: node
+  linkType: hard
+
+"postcss-lab-function@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "postcss-lab-function@npm:2.0.1"
+  dependencies:
+    "@csstools/convert-colors": ^1.4.0
+    postcss: ^7.0.2
+    postcss-values-parser: ^2.0.0
+  checksum: 598229a7a05803b18cccde28114833e910367c5954341bea03c7d7b7b5a667dfb6a77ef9dd4a16d80fdff8b10dd44c478602a7d56e43687c8687af3710b4706f
+  languageName: node
+  linkType: hard
+
+"postcss-load-config@npm:^2.0.0":
+  version: 2.1.2
+  resolution: "postcss-load-config@npm:2.1.2"
+  dependencies:
+    cosmiconfig: ^5.0.0
+    import-cwd: ^2.0.0
+  checksum: 2e6d3a499512a03c19b0090f4143861612d613511d57122879d9fd545558d2a9fcbe85a2b0faf2ec32bbce0e62d22d2b544d91cbc4d4dfb3f22f841f8271fbc6
+  languageName: node
+  linkType: hard
+
+"postcss-loader@npm:3.0.0":
+  version: 3.0.0
+  resolution: "postcss-loader@npm:3.0.0"
+  dependencies:
+    loader-utils: ^1.1.0
+    postcss: ^7.0.0
+    postcss-load-config: ^2.0.0
+    schema-utils: ^1.0.0
+  checksum: a6a922cbcc225ef57fb88c8248f91195869cd11e0d2b0b0fe84bc89a3074437d592d79a9fc39e50218677b7ba3a41b0e1c7e8f9666e59d41a196d7ab022c5805
+  languageName: node
+  linkType: hard
+
+"postcss-logical@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "postcss-logical@npm:3.0.0"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: 5278661b78a093661c9cac8c04666d457734bf156f83d8c67f6034c00e8d4b3a26fce32a8a4a251feae3c7587f42556412dca980e100d0c920ee55e878f7b8ee
+  languageName: node
+  linkType: hard
+
+"postcss-media-minmax@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "postcss-media-minmax@npm:4.0.0"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: 8a4d94e25089bb5a66c6742bcdd263fce2fea391438151a85b442b7f8b66323bbca552b59a93efd6bcabcfd41845ddd4149bd56d156b008f8d7d04bc84d9fb11
+  languageName: node
+  linkType: hard
+
+"postcss-merge-longhand@npm:^4.0.11":
+  version: 4.0.11
+  resolution: "postcss-merge-longhand@npm:4.0.11"
+  dependencies:
+    css-color-names: 0.0.4
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+    stylehacks: ^4.0.0
+  checksum: 45082b492d4d771c1607707d04dbcaece85a100011109886af9460a7868720de1121e290a6442360e2668db510edef579194197d1b534e9fb6c8df7a6cb86a4d
+  languageName: node
+  linkType: hard
+
+"postcss-merge-rules@npm:^4.0.3":
+  version: 4.0.3
+  resolution: "postcss-merge-rules@npm:4.0.3"
+  dependencies:
+    browserslist: ^4.0.0
+    caniuse-api: ^3.0.0
+    cssnano-util-same-parent: ^4.0.0
+    postcss: ^7.0.0
+    postcss-selector-parser: ^3.0.0
+    vendors: ^1.0.0
+  checksum: ed0f3880e1076e5b2a08e4cff35b50dc7dfbd337e6ba16a0ca157e28268cfa1d6c6d821e902d319757f32a7d36f944cad51be76f8b34858d1d7a637e7b585919
+  languageName: node
+  linkType: hard
+
+"postcss-minify-font-values@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-minify-font-values@npm:4.0.2"
+  dependencies:
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: add296b3bc88501283d65b54ad83552f47c98dd403740a70d8dfeef6d30a21d4a1f40191ffef1029a9474e9580a73e84ef644e99ede76c5a2474579b583f4b34
+  languageName: node
+  linkType: hard
+
+"postcss-minify-gradients@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-minify-gradients@npm:4.0.2"
+  dependencies:
+    cssnano-util-get-arguments: ^4.0.0
+    is-color-stop: ^1.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: b83de019cc392192d64182fa6f609383904ef69013d71cda5d06fadab92b4daa73f5be0d0254c5eb0805405e5e1b9c44e49ca6bc629c4c7a24a8164a30b40d46
+  languageName: node
+  linkType: hard
+
+"postcss-minify-params@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-minify-params@npm:4.0.2"
+  dependencies:
+    alphanum-sort: ^1.0.0
+    browserslist: ^4.0.0
+    cssnano-util-get-arguments: ^4.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+    uniqs: ^2.0.0
+  checksum: 15e7f196b3408ab3f55f1a7c9fa8aeea7949fdd02be28af232dd2e47bb7722e0e0a416d6b2c4550ba333a485b775da1bc35c19c9be7b6de855166d2e85d7b28f
+  languageName: node
+  linkType: hard
+
+"postcss-minify-selectors@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-minify-selectors@npm:4.0.2"
+  dependencies:
+    alphanum-sort: ^1.0.0
+    has: ^1.0.0
+    postcss: ^7.0.0
+    postcss-selector-parser: ^3.0.0
+  checksum: a214809b620e50296417838804c3978d5f0a5ddfd48916780d77c1e0348c9ed0baa4b1f3905511b0f06b77340b5378088cc3188517c0848e8b7a53a71ef36c2b
+  languageName: node
+  linkType: hard
+
+"postcss-modules-extract-imports@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "postcss-modules-extract-imports@npm:2.0.0"
+  dependencies:
+    postcss: ^7.0.5
+  checksum: 154790fe5954aaa12f300aa9aa782fae8b847138459c8f533ea6c8f29439dd66b4d9a49e0bf6f8388fa0df898cc03d61c84678e3b0d4b47cac5a4334a7151a9f
+  languageName: node
+  linkType: hard
+
+"postcss-modules-local-by-default@npm:^3.0.2":
+  version: 3.0.3
+  resolution: "postcss-modules-local-by-default@npm:3.0.3"
+  dependencies:
+    icss-utils: ^4.1.1
+    postcss: ^7.0.32
+    postcss-selector-parser: ^6.0.2
+    postcss-value-parser: ^4.1.0
+  checksum: 0267633eaf80e72a3abf391b6e34c5b344a1bdfb1421543d3ed43fc757e053e0fcc1a2eb06d959a8f435776e8dc80288b59bfc34d61e5e021d47b747c417c5a1
+  languageName: node
+  linkType: hard
+
+"postcss-modules-scope@npm:^2.1.1":
+  version: 2.2.0
+  resolution: "postcss-modules-scope@npm:2.2.0"
+  dependencies:
+    postcss: ^7.0.6
+    postcss-selector-parser: ^6.0.0
+  checksum: c611181df924275ca1ffea261149c229488d6921054896879ca98feeb0913f9b00f4f160654beb2cb243a2989036c269baa96778eeacaaa399a4604b6e2fea17
+  languageName: node
+  linkType: hard
+
+"postcss-modules-values@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "postcss-modules-values@npm:3.0.0"
+  dependencies:
+    icss-utils: ^4.0.0
+    postcss: ^7.0.6
+  checksum: f1aea0b9c6798b39ec02a6d2310924bb9bfbddb4579668c2d4e2205ca7a68c656b85d5720f9bba3629d611f36667fe04ab889ea3f9a6b569a0a0d57b4f2f4e99
+  languageName: node
+  linkType: hard
+
+"postcss-nesting@npm:^7.0.0":
+  version: 7.0.1
+  resolution: "postcss-nesting@npm:7.0.1"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: 4056be95759e8b25477f19aff7202b57dd27eeef41d31f7ca14e4c87d16ffb40e4db3f518fc85bd28b20e183f5e5399b56b52fcc79affd556e13a98bbc678169
+  languageName: node
+  linkType: hard
+
+"postcss-normalize-charset@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "postcss-normalize-charset@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.0
+  checksum: f233f48d61eb005da217e5bfa58f4143165cb525ceea2de4fd88e4172a33712e8b63258ffa089c867875a498c408f293a380ea9e6f40076de550d8053f50e5bc
+  languageName: node
+  linkType: hard
+
+"postcss-normalize-display-values@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-normalize-display-values@npm:4.0.2"
+  dependencies:
+    cssnano-util-get-match: ^4.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: c5b857ca05f30a3efc6211cdaa5c9306f3eb0dbac141047d451a418d2bfd3e54be0bd4481d61c640096152d3078881a8dc3dec61913ff7f01ab4fc6df1a14732
+  languageName: node
+  linkType: hard
+
+"postcss-normalize-positions@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-normalize-positions@npm:4.0.2"
+  dependencies:
+    cssnano-util-get-arguments: ^4.0.0
+    has: ^1.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: 291612d0879e6913010937f1193ab56ae1cfd8a274665330ccbedbe72f59c36db3f688b0a3faa4c6689cfd03dff0c27702c6acfce9b1f697a022bfcee3cd4fc4
+  languageName: node
+  linkType: hard
+
+"postcss-normalize-repeat-style@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-normalize-repeat-style@npm:4.0.2"
+  dependencies:
+    cssnano-util-get-arguments: ^4.0.0
+    cssnano-util-get-match: ^4.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: 2160b2a6fe4f9671ad5d044755f0e04cfb5f255db607505fd4c74e7c806315c9dca914e74bb02f5f768de7b70939359d05c3f9b23ae8f72551d8fdeabf79a1fb
+  languageName: node
+  linkType: hard
+
+"postcss-normalize-string@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-normalize-string@npm:4.0.2"
+  dependencies:
+    has: ^1.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: 9d40753ceb4f7854ed690ecd5fe4ea142280b14441dd11e188e573e58af93df293efdc77311f1c599431df785a3bb614dfe4bdacc3081ee3fe8c95916c849b2f
+  languageName: node
+  linkType: hard
+
+"postcss-normalize-timing-functions@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-normalize-timing-functions@npm:4.0.2"
+  dependencies:
+    cssnano-util-get-match: ^4.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: 8dfd711f5cdb49b823a92d1cd56d40f66f3686e257804495ef59d5d7f71815b6d19412a1ff25d40971bf6e146b1fa0517a6cc1a4c286b36c5cee6ed08a1952db
+  languageName: node
+  linkType: hard
+
+"postcss-normalize-unicode@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "postcss-normalize-unicode@npm:4.0.1"
+  dependencies:
+    browserslist: ^4.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: 2b1da17815f8402651a72012fd385b5111e84002baf98b649e0c1fc91298b65bb0e431664f6df8a99b23217259ecec242b169c0f18bf26e727af02eaf475fb07
+  languageName: node
+  linkType: hard
+
+"postcss-normalize-url@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "postcss-normalize-url@npm:4.0.1"
+  dependencies:
+    is-absolute-url: ^2.0.0
+    normalize-url: ^3.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: fcaab832d8b773568197b41406517a9e5fc7704f2fac7185bd0e13b19961e1ce9f1c762e4ffa470de7baa6a82ae8ae5ccf6b1bbeec6e95216d22ce6ab514fe04
+  languageName: node
+  linkType: hard
+
+"postcss-normalize-whitespace@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-normalize-whitespace@npm:4.0.2"
+  dependencies:
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: 378a6eadb09ccc5ca2289e8daf98ce7366ae53342c4df7898ef5fae68138884d6c1241493531635458351b2805218bf55ceecae0fd289e5696ab15c78966abbb
+  languageName: node
+  linkType: hard
+
+"postcss-normalize@npm:8.0.1":
+  version: 8.0.1
+  resolution: "postcss-normalize@npm:8.0.1"
+  dependencies:
+    "@csstools/normalize.css": ^10.1.0
+    browserslist: ^4.6.2
+    postcss: ^7.0.17
+    postcss-browser-comments: ^3.0.0
+    sanitize.css: ^10.0.0
+  checksum: 3109075389b91a09a790c3cd62a4e8c147bab2113cffa7ea2e776982352796816bc56b7f08ed7f7175c24e5d9c46171a07f95eeee00cfecddac6e3b4c9888dd0
+  languageName: node
+  linkType: hard
+
+"postcss-ordered-values@npm:^4.1.2":
+  version: 4.1.2
+  resolution: "postcss-ordered-values@npm:4.1.2"
+  dependencies:
+    cssnano-util-get-arguments: ^4.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: 4a6f6a427a0165e1fa4f04dbe53a88708c73ea23e5b23ce312366ca8d85d83af450154a54f0e5df6c5712f945c180b6a364c3682dc995940b93228bb26658a96
+  languageName: node
+  linkType: hard
+
+"postcss-overflow-shorthand@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "postcss-overflow-shorthand@npm:2.0.0"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: 553be1b7f9645017d33b654f9a436ce4f4406066c3056ca4c7ee06c21c2964fbe3437a9a3f998137efb6a17c1a79ee7e8baa39332c7dd9874aac8b69a3ad08b0
+  languageName: node
+  linkType: hard
+
+"postcss-page-break@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "postcss-page-break@npm:2.0.0"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: 65a4453883e904ca0f337d3a988a1b5a090e2e8bc2855913cb0b4b741158e6ea2e4eed9b33f5989e7ae55faa0f7b83cdc09693d600ac4c86ce804ae381ec48a4
+  languageName: node
+  linkType: hard
+
+"postcss-place@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "postcss-place@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.2
+    postcss-values-parser: ^2.0.0
+  checksum: 26b2a443b0a8fcb6774d00036fa351633798a655ccd609da2d561fbd6561b0ba6f6b6d89e15fb074389fadb7da4cbc59c48ba75f1f5fdc478c020febb4e2b557
+  languageName: node
+  linkType: hard
+
+"postcss-preset-env@npm:6.7.0":
+  version: 6.7.0
+  resolution: "postcss-preset-env@npm:6.7.0"
+  dependencies:
+    autoprefixer: ^9.6.1
+    browserslist: ^4.6.4
+    caniuse-lite: ^1.0.30000981
+    css-blank-pseudo: ^0.1.4
+    css-has-pseudo: ^0.10.0
+    css-prefers-color-scheme: ^3.1.1
+    cssdb: ^4.4.0
+    postcss: ^7.0.17
+    postcss-attribute-case-insensitive: ^4.0.1
+    postcss-color-functional-notation: ^2.0.1
+    postcss-color-gray: ^5.0.0
+    postcss-color-hex-alpha: ^5.0.3
+    postcss-color-mod-function: ^3.0.3
+    postcss-color-rebeccapurple: ^4.0.1
+    postcss-custom-media: ^7.0.8
+    postcss-custom-properties: ^8.0.11
+    postcss-custom-selectors: ^5.1.2
+    postcss-dir-pseudo-class: ^5.0.0
+    postcss-double-position-gradients: ^1.0.0
+    postcss-env-function: ^2.0.2
+    postcss-focus-visible: ^4.0.0
+    postcss-focus-within: ^3.0.0
+    postcss-font-variant: ^4.0.0
+    postcss-gap-properties: ^2.0.0
+    postcss-image-set-function: ^3.0.1
+    postcss-initial: ^3.0.0
+    postcss-lab-function: ^2.0.1
+    postcss-logical: ^3.0.0
+    postcss-media-minmax: ^4.0.0
+    postcss-nesting: ^7.0.0
+    postcss-overflow-shorthand: ^2.0.0
+    postcss-page-break: ^2.0.0
+    postcss-place: ^4.0.1
+    postcss-pseudo-class-any-link: ^6.0.0
+    postcss-replace-overflow-wrap: ^3.0.0
+    postcss-selector-matches: ^4.0.0
+    postcss-selector-not: ^4.0.0
+  checksum: 209cbb63443a1631aa97ccfc3b95b1ff519ddaeb672f84d6af501bd9e9ad6727680b5b1bffb8209322e47d93029a69df6064f75cd0b7633b6df943cbef33f22e
+  languageName: node
+  linkType: hard
+
+"postcss-pseudo-class-any-link@npm:^6.0.0":
+  version: 6.0.0
+  resolution: "postcss-pseudo-class-any-link@npm:6.0.0"
+  dependencies:
+    postcss: ^7.0.2
+    postcss-selector-parser: ^5.0.0-rc.3
+  checksum: d7dc3bba45df2966f8512c082a9cc341e63edac14d915ad9f41c62c452cd306d82da6baeee757dd4e7deafe3fa33b26c16e5236c670916bbb7ff4b4723453541
+  languageName: node
+  linkType: hard
+
+"postcss-reduce-initial@npm:^4.0.3":
+  version: 4.0.3
+  resolution: "postcss-reduce-initial@npm:4.0.3"
+  dependencies:
+    browserslist: ^4.0.0
+    caniuse-api: ^3.0.0
+    has: ^1.0.0
+    postcss: ^7.0.0
+  checksum: 5ad1a955cb20f5b1792ff8cc35894621edc23ee77397cc7e9692d269882fb4451655633947e0407fe20bd127d09d0b7e693034c64417bf8bf1034a83c6e71668
+  languageName: node
+  linkType: hard
+
+"postcss-reduce-transforms@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "postcss-reduce-transforms@npm:4.0.2"
+  dependencies:
+    cssnano-util-get-match: ^4.0.0
+    has: ^1.0.0
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+  checksum: e6a351d5da7ecf276ddda350635b15bce8e14af08aee1c8a0e8d9c2ab2631eab33b06f3c2f31c6f9c76eedbfc23f356d86da3539e011cde3e335a2cac9d91dc1
+  languageName: node
+  linkType: hard
+
+"postcss-replace-overflow-wrap@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "postcss-replace-overflow-wrap@npm:3.0.0"
+  dependencies:
+    postcss: ^7.0.2
+  checksum: 8c5b512a1172dd3d7b4a06d56d3b64c76dea01ca0950b546f83ae993f83aa95f933239e18deed0a5f3d2ef47840de55fa73498c4a46bfbe7bd892eb0dd8b606c
+  languageName: node
+  linkType: hard
+
+"postcss-safe-parser@npm:4.0.1":
+  version: 4.0.1
+  resolution: "postcss-safe-parser@npm:4.0.1"
+  dependencies:
+    postcss: ^7.0.0
+  checksum: e4db1e5153521cfa77c046ea5c2600605339148c1ed039c61e8acea37e74ceea245f4ec4047bcea7782a34866a9c4b1321981c35374f211c292e8648e2ac4e33
+  languageName: node
+  linkType: hard
+
+"postcss-selector-matches@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "postcss-selector-matches@npm:4.0.0"
+  dependencies:
+    balanced-match: ^1.0.0
+    postcss: ^7.0.2
+  checksum: 724f6cb345477691909468268a456f978ad3bae9ecd9908b2bb55c55c5f3c6d54a1fe50ce3956d93b122d05fc36677a8e4a34eed07bccda969c3f8baa43669a6
+  languageName: node
+  linkType: hard
+
+"postcss-selector-not@npm:^4.0.0":
+  version: 4.0.1
+  resolution: "postcss-selector-not@npm:4.0.1"
+  dependencies:
+    balanced-match: ^1.0.0
+    postcss: ^7.0.2
+  checksum: 08fbd3e5ca273f3b767bd35d6bd033647a68f59b596d8aec19a9089b750539bdf85121ed7fd00a7763174a55c75c22a309d75d306127e23dc396069781efbaa4
+  languageName: node
+  linkType: hard
+
+"postcss-selector-parser@npm:^3.0.0":
+  version: 3.1.2
+  resolution: "postcss-selector-parser@npm:3.1.2"
+  dependencies:
+    dot-prop: ^5.2.0
+    indexes-of: ^1.0.1
+    uniq: ^1.0.1
+  checksum: 85b754bf3b5f671cddd75a199589e5b03da114ec119aa4628ab7f35f76134b25296d18a68f745e39780c379d66d3919ae7a1b6129aeec5049cedb9ba4c660803
+  languageName: node
+  linkType: hard
+
+"postcss-selector-parser@npm:^5.0.0-rc.3, postcss-selector-parser@npm:^5.0.0-rc.4":
+  version: 5.0.0
+  resolution: "postcss-selector-parser@npm:5.0.0"
+  dependencies:
+    cssesc: ^2.0.0
+    indexes-of: ^1.0.1
+    uniq: ^1.0.1
+  checksum: e49d21455e06d2cb9bf2a615bf3e605e0603c2c430a84c37a34f8baedaf3e8f9d0059a085d3e0483cbfa04c0d4153c7da28e7ac0ada319efdefe407df11dc1d4
+  languageName: node
+  linkType: hard
+
+"postcss-selector-parser@npm:^6.0.0, postcss-selector-parser@npm:^6.0.2":
+  version: 6.0.6
+  resolution: "postcss-selector-parser@npm:6.0.6"
+  dependencies:
+    cssesc: ^3.0.0
+    util-deprecate: ^1.0.2
+  checksum: 3602758798048bffbd6a97d6f009b32a993d6fd2cc70775bb59593e803d7fa8738822ecffb2fafc745edf7fad297dad53c30d2cfe78446a7d3f4a4a258cb15b2
+  languageName: node
+  linkType: hard
+
+"postcss-selector-parser@npm:^6.0.4":
+  version: 6.0.13
+  resolution: "postcss-selector-parser@npm:6.0.13"
+  dependencies:
+    cssesc: ^3.0.0
+    util-deprecate: ^1.0.2
+  checksum: f89163338a1ce3b8ece8e9055cd5a3165e79a15e1c408e18de5ad8f87796b61ec2d48a2902d179ae0c4b5de10fccd3a325a4e660596549b040bc5ad1b465f096
+  languageName: node
+  linkType: hard
+
+"postcss-svgo@npm:^4.0.3":
+  version: 4.0.3
+  resolution: "postcss-svgo@npm:4.0.3"
+  dependencies:
+    postcss: ^7.0.0
+    postcss-value-parser: ^3.0.0
+    svgo: ^1.0.0
+  checksum: 6f5264241193ca3ba748fdf43c88ef692948d2ae38787398dc90089061fed884064ec14ee244fce07f19c419d1b058c77e135407d0932b09e93e528581ce3e10
+  languageName: node
+  linkType: hard
+
+"postcss-unique-selectors@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "postcss-unique-selectors@npm:4.0.1"
+  dependencies:
+    alphanum-sort: ^1.0.0
+    postcss: ^7.0.0
+    uniqs: ^2.0.0
+  checksum: 272eb1fa17d6ea513b5f4d2f694ef30fa690795ce388aef7bf3967fd3bcec7a9a3c8da380e74961ded8d98253a6ed18fb380b29da00e2fe03e74813e7765ea71
+  languageName: node
+  linkType: hard
+
+"postcss-value-parser@npm:^3.0.0":
+  version: 3.3.1
+  resolution: "postcss-value-parser@npm:3.3.1"
+  checksum: 62cd26e1cdbcf2dcc6bcedf3d9b409c9027bc57a367ae20d31dd99da4e206f730689471fd70a2abe866332af83f54dc1fa444c589e2381bf7f8054c46209ce16
+  languageName: node
+  linkType: hard
+
+"postcss-value-parser@npm:^4.0.2, postcss-value-parser@npm:^4.1.0":
+  version: 4.1.0
+  resolution: "postcss-value-parser@npm:4.1.0"
+  checksum: 68a9ea27c780fa3cc350be37b47cc46385c61dd9627990909230e0e9c3debf6d5beb49006bd743a2e506cdd6fa7d07637f2d9504a394f67cc3011d1ff0134886
+  languageName: node
+  linkType: hard
+
+"postcss-values-parser@npm:^2.0.0, postcss-values-parser@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "postcss-values-parser@npm:2.0.1"
+  dependencies:
+    flatten: ^1.0.2
+    indexes-of: ^1.0.1
+    uniq: ^1.0.1
+  checksum: 050877880937e15af8d18bf48902e547e2123d7cc32c1f215b392642bc5e2598a87a341995d62f38e450aab4186b8afeb2c9541934806d458ad8b117020b2ebf
+  languageName: node
+  linkType: hard
+
+"postcss@npm:7.0.21":
+  version: 7.0.21
+  resolution: "postcss@npm:7.0.21"
+  dependencies:
+    chalk: ^2.4.2
+    source-map: ^0.6.1
+    supports-color: ^6.1.0
+  checksum: 5c11d58a4ffd54ddaf2f2f18ef7be10fc44405559ee56b52e41db8305d1b184d162138994dcce506ab77eef7283887a72d1b81cd1036c7fee106f50af0ef86d3
+  languageName: node
+  linkType: hard
+
+"postcss@npm:8.4.31":
+  version: 8.4.31
+  resolution: "postcss@npm:8.4.31"
+  dependencies:
+    nanoid: ^3.3.6
+    picocolors: ^1.0.0
+    source-map-js: ^1.0.2
+  checksum: 1d8611341b073143ad90486fcdfeab49edd243377b1f51834dc4f6d028e82ce5190e4f11bb2633276864503654fb7cab28e67abdc0fbf9d1f88cad4a0ff0beea
+  languageName: node
+  linkType: hard
+
+"postcss@npm:^7, postcss@npm:^7.0.0, postcss@npm:^7.0.1, postcss@npm:^7.0.14, postcss@npm:^7.0.17, postcss@npm:^7.0.2, postcss@npm:^7.0.23, postcss@npm:^7.0.27, postcss@npm:^7.0.32, postcss@npm:^7.0.5, postcss@npm:^7.0.6":
+  version: 7.0.39
+  resolution: "postcss@npm:7.0.39"
+  dependencies:
+    picocolors: ^0.2.1
+    source-map: ^0.6.1
+  checksum: 4ac793f506c23259189064bdc921260d869a115a82b5e713973c5af8e94fbb5721a5cc3e1e26840500d7e1f1fa42a209747c5b1a151918a9bc11f0d7ed9048e3
+  languageName: node
+  linkType: hard
+
+"prelude-ls@npm:~1.1.2":
+  version: 1.1.2
+  resolution: "prelude-ls@npm:1.1.2"
+  checksum: c4867c87488e4a0c233e158e4d0d5565b609b105d75e4c05dc760840475f06b731332eb93cc8c9cecb840aa8ec323ca3c9a56ad7820ad2e63f0261dadcb154e4
+  languageName: node
+  linkType: hard
+
+"prepend-http@npm:^1.0.0":
+  version: 1.0.4
+  resolution: "prepend-http@npm:1.0.4"
+  checksum: 01e7baf4ad38af02257b99098543469332fc42ae50df33d97a124bf8172295907352fa6138c9b1610c10c6dd0847ca736e53fda736387cc5cf8fcffe96b47f29
+  languageName: node
+  linkType: hard
+
+"pretty-bytes@npm:^5.1.0, pretty-bytes@npm:^5.6.0":
+  version: 5.6.0
+  resolution: "pretty-bytes@npm:5.6.0"
+  checksum: 9c082500d1e93434b5b291bd651662936b8bd6204ec9fa17d563116a192d6d86b98f6d328526b4e8d783c07d5499e2614a807520249692da9ec81564b2f439cd
+  languageName: node
+  linkType: hard
+
+"pretty-error@npm:^2.1.1":
+  version: 2.1.2
+  resolution: "pretty-error@npm:2.1.2"
+  dependencies:
+    lodash: ^4.17.20
+    renderkid: ^2.0.4
+  checksum: 16775d06f9a695d17103414d610b1281f9535ee1f2da1ce1e1b9be79584a114aa7eac6dcdcc5ef151756d3c014dfd4ac1c7303ed8016d0cec12437cfdf4021c6
+  languageName: node
+  linkType: hard
+
+"pretty-format@npm:^24.9.0":
+  version: 24.9.0
+  resolution: "pretty-format@npm:24.9.0"
+  dependencies:
+    "@jest/types": ^24.9.0
+    ansi-regex: ^4.0.0
+    ansi-styles: ^3.2.0
+    react-is: ^16.8.4
+  checksum: ba9291c8dafd50d2fea1fbad5d2863a6f94e0c8835cce9778ec03bc11bb0f52b9ed0e4ee56aaa331d022ccae2fe52b92f73465a0af58fd0edb59deb6391c6847
+  languageName: node
+  linkType: hard
+
+"pretty-format@npm:^26.0.0, pretty-format@npm:^26.6.2":
+  version: 26.6.2
+  resolution: "pretty-format@npm:26.6.2"
+  dependencies:
+    "@jest/types": ^26.6.2
+    ansi-regex: ^5.0.0
+    ansi-styles: ^4.0.0
+    react-is: ^17.0.1
+  checksum: e3b808404d7e1519f0df1aa1f25cee0054ab475775c6b2b8c5568ff23194a92d54bf93274139b6f584ca70fd773be4eaa754b0e03f12bb0a8d1426b07f079976
+  languageName: node
+  linkType: hard
+
+"process-nextick-args@npm:~2.0.0":
+  version: 2.0.1
+  resolution: "process-nextick-args@npm:2.0.1"
+  checksum: 1d38588e520dab7cea67cbbe2efdd86a10cc7a074c09657635e34f035277b59fbb57d09d8638346bf7090f8e8ebc070c96fa5fd183b777fff4f5edff5e9466cf
+  languageName: node
+  linkType: hard
+
+"process@npm:^0.11.10":
+  version: 0.11.10
+  resolution: "process@npm:0.11.10"
+  checksum: bfcce49814f7d172a6e6a14d5fa3ac92cc3d0c3b9feb1279774708a719e19acd673995226351a082a9ae99978254e320ccda4240ddc474ba31a76c79491ca7c3
+  languageName: node
+  linkType: hard
+
+"progress@npm:^2.0.0":
+  version: 2.0.3
+  resolution: "progress@npm:2.0.3"
+  checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7
+  languageName: node
+  linkType: hard
+
+"promise-inflight@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "promise-inflight@npm:1.0.1"
+  checksum: 22749483091d2c594261517f4f80e05226d4d5ecc1fc917e1886929da56e22b5718b7f2a75f3807e7a7d471bc3be2907fe92e6e8f373ddf5c64bae35b5af3981
+  languageName: node
+  linkType: hard
+
+"promise-retry@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "promise-retry@npm:2.0.1"
+  dependencies:
+    err-code: ^2.0.2
+    retry: ^0.12.0
+  checksum: f96a3f6d90b92b568a26f71e966cbbc0f63ab85ea6ff6c81284dc869b41510e6cdef99b6b65f9030f0db422bf7c96652a3fff9f2e8fb4a0f069d8f4430359429
+  languageName: node
+  linkType: hard
+
+"promise@npm:^7.1.1":
+  version: 7.3.1
+  resolution: "promise@npm:7.3.1"
+  dependencies:
+    asap: ~2.0.3
+  checksum: 475bb069130179fbd27ed2ab45f26d8862376a137a57314cf53310bdd85cc986a826fd585829be97ebc0aaf10e9d8e68be1bfe5a4a0364144b1f9eedfa940cf1
+  languageName: node
+  linkType: hard
+
+"promise@npm:^8.0.3":
+  version: 8.1.0
+  resolution: "promise@npm:8.1.0"
+  dependencies:
+    asap: ~2.0.6
+  checksum: 89b71a56154ed7d66a73236d8e8351a9c59adddba3929ecc845f75421ff37fc08ea0c67ad76cd5c0b0d81812c7d07a32bed27e7df5fcc960c6d68b0c1cd771f7
+  languageName: node
+  linkType: hard
+
+"prompts@npm:^2.0.1":
+  version: 2.4.1
+  resolution: "prompts@npm:2.4.1"
+  dependencies:
+    kleur: ^3.0.3
+    sisteransi: ^1.0.5
+  checksum: 05bf4865870665067b14fc54ced6c96e353f58f57658351e16bb8c12c017402582696fb42d97306b7c98efc0e2cc1ebf27ab573448d5a5da2ac18991cc9e4cad
+  languageName: node
+  linkType: hard
+
+"prop-types-exact@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "prop-types-exact@npm:1.2.0"
+  dependencies:
+    has: ^1.0.3
+    object.assign: ^4.1.0
+    reflect.ownkeys: ^0.2.0
+  checksum: 21676a16d5b2623c345ca938554faba7bf29c6ad589eac3f490eda2207bcfd8d25cb3dfda5e5f8e6805239aabd2c6943f7bfbe726a1de708bae2b7a01c03eead
+  languageName: node
+  linkType: hard
+
+"prop-types@npm:15.7.2, prop-types@npm:^15.5.7, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2":
+  version: 15.7.2
+  resolution: "prop-types@npm:15.7.2"
+  dependencies:
+    loose-envify: ^1.4.0
+    object-assign: ^4.1.1
+    react-is: ^16.8.1
+  checksum: 5eef82fdda64252c7e75aa5c8cc28a24bbdece0f540adb60ce67c205cf978a5bd56b83e4f269f91c6e4dcfd80b36f2a2dec24d362e278913db2086ca9c6f9430
+  languageName: node
+  linkType: hard
+
+"prop-types@npm:^15.5.6":
+  version: 15.8.1
+  resolution: "prop-types@npm:15.8.1"
+  dependencies:
+    loose-envify: ^1.4.0
+    object-assign: ^4.1.1
+    react-is: ^16.13.1
+  checksum: c056d3f1c057cb7ff8344c645450e14f088a915d078dcda795041765047fa080d38e5d626560ccaac94a4e16e3aa15f3557c1a9a8d1174530955e992c675e459
+  languageName: node
+  linkType: hard
+
+"proxy-addr@npm:~2.0.7":
+  version: 2.0.7
+  resolution: "proxy-addr@npm:2.0.7"
+  dependencies:
+    forwarded: 0.2.0
+    ipaddr.js: 1.9.1
+  checksum: 29c6990ce9364648255454842f06f8c46fcd124d3e6d7c5066df44662de63cdc0bad032e9bf5a3d653ff72141cc7b6019873d685708ac8210c30458ad99f2b74
+  languageName: node
+  linkType: hard
+
+"proxy-from-env@npm:1.0.0":
+  version: 1.0.0
+  resolution: "proxy-from-env@npm:1.0.0"
+  checksum: 292e28d1de0c315958d71d8315eb546dd3cd8c8cbc2dab7c54eeb9f5c17f421771964ad0b5e1f77011bab2305bdae42e1757ce33bdb1ccc3e87732322a8efcf1
+  languageName: node
+  linkType: hard
+
+"proxy-from-env@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "proxy-from-env@npm:1.1.0"
+  checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4
+  languageName: node
+  linkType: hard
+
+"prr@npm:~1.0.1":
+  version: 1.0.1
+  resolution: "prr@npm:1.0.1"
+  checksum: 3bca2db0479fd38f8c4c9439139b0c42dcaadcc2fbb7bb8e0e6afaa1383457f1d19aea9e5f961d5b080f1cfc05bfa1fe9e45c97a1d3fd6d421950a73d3108381
+  languageName: node
+  linkType: hard
+
+"psl@npm:^1.1.28":
+  version: 1.8.0
+  resolution: "psl@npm:1.8.0"
+  checksum: 6150048ed2da3f919478bee8a82f3828303bc0fc730fb015a48f83c9977682c7b28c60ab01425a72d82a2891a1681627aa530a991d50c086b48a3be27744bde7
+  languageName: node
+  linkType: hard
+
+"psl@npm:^1.1.33":
+  version: 1.9.0
+  resolution: "psl@npm:1.9.0"
+  checksum: 20c4277f640c93d393130673f392618e9a8044c6c7bf61c53917a0fddb4952790f5f362c6c730a9c32b124813e173733f9895add8d26f566ed0ea0654b2e711d
+  languageName: node
+  linkType: hard
+
+"public-encrypt@npm:^4.0.0":
+  version: 4.0.3
+  resolution: "public-encrypt@npm:4.0.3"
+  dependencies:
+    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
+  checksum: 215d446e43cef021a20b67c1df455e5eea134af0b1f9b8a35f9e850abf32991b0c307327bc5b9bc07162c288d5cdb3d4a783ea6c6640979ed7b5017e3e0c9935
+  languageName: node
+  linkType: hard
+
+"pump@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "pump@npm:2.0.1"
+  dependencies:
+    end-of-stream: ^1.1.0
+    once: ^1.3.1
+  checksum: e9f26a17be00810bff37ad0171edb35f58b242487b0444f92fb7d78bc7d61442fa9b9c5bd93a43fd8fd8ddd3cc75f1221f5e04c790f42907e5baab7cf5e2b931
+  languageName: node
+  linkType: hard
+
+"pump@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "pump@npm:3.0.0"
+  dependencies:
+    end-of-stream: ^1.1.0
+    once: ^1.3.1
+  checksum: e42e9229fba14732593a718b04cb5e1cfef8254544870997e0ecd9732b189a48e1256e4e5478148ecb47c8511dca2b09eae56b4d0aad8009e6fac8072923cfc9
+  languageName: node
+  linkType: hard
+
+"pumpify@npm:^1.3.3":
+  version: 1.5.1
+  resolution: "pumpify@npm:1.5.1"
+  dependencies:
+    duplexify: ^3.6.0
+    inherits: ^2.0.3
+    pump: ^2.0.0
+  checksum: 26ca412ec8d665bd0d5e185c1b8f627728eff603440d75d22a58e421e3c66eaf86ec6fc6a6efc54808ecef65979279fa8e99b109a23ec1fa8d79f37e6978c9bd
+  languageName: node
+  linkType: hard
+
+"punycode@npm:1.3.2":
+  version: 1.3.2
+  resolution: "punycode@npm:1.3.2"
+  checksum: b8807fd594b1db33335692d1f03e8beeddde6fda7fbb4a2e32925d88d20a3aa4cd8dcc0c109ccaccbd2ba761c208dfaaada83007087ea8bfb0129c9ef1b99ed6
+  languageName: node
+  linkType: hard
+
+"punycode@npm:^1.2.4":
+  version: 1.4.1
+  resolution: "punycode@npm:1.4.1"
+  checksum: fa6e698cb53db45e4628559e557ddaf554103d2a96a1d62892c8f4032cd3bc8871796cae9eabc1bc700e2b6677611521ce5bb1d9a27700086039965d0cf34518
+  languageName: node
+  linkType: hard
+
+"punycode@npm:^2.1.0, punycode@npm:^2.1.1":
+  version: 2.1.1
+  resolution: "punycode@npm:2.1.1"
+  checksum: 823bf443c6dd14f669984dea25757b37993f67e8d94698996064035edd43bed8a5a17a9f12e439c2b35df1078c6bec05a6c86e336209eb1061e8025c481168e8
+  languageName: node
+  linkType: hard
+
+"q@npm:^1.1.2":
+  version: 1.5.1
+  resolution: "q@npm:1.5.1"
+  checksum: 147baa93c805bc1200ed698bdf9c72e9e42c05f96d007e33a558b5fdfd63e5ea130e99313f28efc1783e90e6bdb4e48b67a36fcc026b7b09202437ae88a1fb12
+  languageName: node
+  linkType: hard
+
+"qs@npm:6.10.4":
+  version: 6.10.4
+  resolution: "qs@npm:6.10.4"
+  dependencies:
+    side-channel: ^1.0.4
+  checksum: 31e4fedd759d01eae52dde6692abab175f9af3e639993c5caaa513a2a3607b34d8058d3ae52ceeccf37c3025f22ed5e90e9ddd6c2537e19c0562ddd10dc5b1eb
+  languageName: node
+  linkType: hard
+
+"qs@npm:6.11.0":
+  version: 6.11.0
+  resolution: "qs@npm:6.11.0"
+  dependencies:
+    side-channel: ^1.0.4
+  checksum: 6e1f29dd5385f7488ec74ac7b6c92f4d09a90408882d0c208414a34dd33badc1a621019d4c799a3df15ab9b1d0292f97c1dd71dc7c045e69f81a8064e5af7297
+  languageName: node
+  linkType: hard
+
+"qs@npm:~6.5.2":
+  version: 6.5.3
+  resolution: "qs@npm:6.5.3"
+  checksum: 6f20bf08cabd90c458e50855559539a28d00b2f2e7dddcb66082b16a43188418cb3cb77cbd09268bcef6022935650f0534357b8af9eeb29bf0f27ccb17655692
+  languageName: node
+  linkType: hard
+
+"query-string@npm:6.9.0":
+  version: 6.9.0
+  resolution: "query-string@npm:6.9.0"
+  dependencies:
+    decode-uri-component: ^0.2.0
+    split-on-first: ^1.0.0
+    strict-uri-encode: ^2.0.0
+  checksum: d471bcbd3c4379a3fbfdc02b825f26360145c917aec744fe219c1103d5604724bbb1ca73fec71c1de8a255a158b9bb2e148c9b847655666e4398486f866808f1
+  languageName: node
+  linkType: hard
+
+"query-string@npm:^4.1.0":
+  version: 4.3.4
+  resolution: "query-string@npm:4.3.4"
+  dependencies:
+    object-assign: ^4.1.0
+    strict-uri-encode: ^1.0.0
+  checksum: 3b2bae6a8454cf0edf11cf1aa4d1f920398bbdabc1c39222b9bb92147e746fcd97faf00e56f494728fb66b2961b495ba0fde699d5d3bd06b11472d664b36c6cf
+  languageName: node
+  linkType: hard
+
+"querystring-es3@npm:^0.2.0":
+  version: 0.2.1
+  resolution: "querystring-es3@npm:0.2.1"
+  checksum: 691e8d6b8b157e7cd49ae8e83fcf86de39ab3ba948c25abaa94fba84c0986c641aa2f597770848c64abce290ed17a39c9df6df737dfa7e87c3b63acc7d225d61
+  languageName: node
+  linkType: hard
+
+"querystring@npm:0.2.0":
+  version: 0.2.0
+  resolution: "querystring@npm:0.2.0"
+  checksum: 8258d6734f19be27e93f601758858c299bdebe71147909e367101ba459b95446fbe5b975bf9beb76390156a592b6f4ac3a68b6087cea165c259705b8b4e56a69
+  languageName: node
+  linkType: hard
+
+"querystringify@npm:^2.1.1":
+  version: 2.2.0
+  resolution: "querystringify@npm:2.2.0"
+  checksum: 5641ea231bad7ef6d64d9998faca95611ed4b11c2591a8cae741e178a974f6a8e0ebde008475259abe1621cb15e692404e6b6626e927f7b849d5c09392604b15
+  languageName: node
+  linkType: hard
+
+"queue-microtask@npm:^1.2.2":
+  version: 1.2.3
+  resolution: "queue-microtask@npm:1.2.3"
+  checksum: b676f8c040cdc5b12723ad2f91414d267605b26419d5c821ff03befa817ddd10e238d22b25d604920340fd73efd8ba795465a0377c4adf45a4a41e4234e42dc4
+  languageName: node
+  linkType: hard
+
+"quick-lru@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "quick-lru@npm:4.0.1"
+  checksum: bea46e1abfaa07023e047d3cf1716a06172c4947886c053ede5c50321893711577cb6119360f810cc3ffcd70c4d7db4069c3cee876b358ceff8596e062bd1154
+  languageName: node
+  linkType: hard
+
+"raf@npm:^3.4.1":
+  version: 3.4.1
+  resolution: "raf@npm:3.4.1"
+  dependencies:
+    performance-now: ^2.1.0
+  checksum: 50ba284e481c8185dbcf45fc4618ba3aec580bb50c9121385d5698cb6012fe516d2015b1df6dd407a7b7c58d44be8086108236affbce1861edd6b44637c8cd52
+  languageName: node
+  linkType: hard
+
+"railroad-diagrams@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "railroad-diagrams@npm:1.0.0"
+  checksum: 9e312af352b5ed89c2118edc0c06cef2cc039681817f65266719606e4e91ff6ae5374c707cc9033fe29a82c2703edf3c63471664f97f0167c85daf6f93496319
+  languageName: node
+  linkType: hard
+
+"randexp@npm:0.4.6":
+  version: 0.4.6
+  resolution: "randexp@npm:0.4.6"
+  dependencies:
+    discontinuous-range: 1.0.0
+    ret: ~0.1.10
+  checksum: 3c0d440a3f89d6d36844aa4dd57b5cdb0cab938a41956a16da743d3a3578ab32538fc41c16cc0984b6938f2ae4cbc0216967e9829e52191f70e32690d8e3445d
+  languageName: node
+  linkType: hard
+
+"randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "randombytes@npm:2.1.0"
+  dependencies:
+    safe-buffer: ^5.1.0
+  checksum: d779499376bd4cbb435ef3ab9a957006c8682f343f14089ed5f27764e4645114196e75b7f6abf1cbd84fd247c0cb0651698444df8c9bf30e62120fbbc52269d6
+  languageName: node
+  linkType: hard
+
+"randomfill@npm:^1.0.3":
+  version: 1.0.4
+  resolution: "randomfill@npm:1.0.4"
+  dependencies:
+    randombytes: ^2.0.5
+    safe-buffer: ^5.1.0
+  checksum: 33734bb578a868d29ee1b8555e21a36711db084065d94e019a6d03caa67debef8d6a1bfd06a2b597e32901ddc761ab483a85393f0d9a75838f1912461d4dbfc7
+  languageName: node
+  linkType: hard
+
+"range-parser@npm:^1.2.1, range-parser@npm:~1.2.1":
+  version: 1.2.1
+  resolution: "range-parser@npm:1.2.1"
+  checksum: 0a268d4fea508661cf5743dfe3d5f47ce214fd6b7dec1de0da4d669dd4ef3d2144468ebe4179049eff253d9d27e719c88dae55be64f954e80135a0cada804ec9
+  languageName: node
+  linkType: hard
+
+"raw-body@npm:2.5.2":
+  version: 2.5.2
+  resolution: "raw-body@npm:2.5.2"
+  dependencies:
+    bytes: 3.1.2
+    http-errors: 2.0.0
+    iconv-lite: 0.4.24
+    unpipe: 1.0.0
+  checksum: ba1583c8d8a48e8fbb7a873fdbb2df66ea4ff83775421bfe21ee120140949ab048200668c47d9ae3880012f6e217052690628cf679ddfbd82c9fc9358d574676
+  languageName: node
+  linkType: hard
+
+"react-app-polyfill@npm:^1.0.6":
+  version: 1.0.6
+  resolution: "react-app-polyfill@npm:1.0.6"
+  dependencies:
+    core-js: ^3.5.0
+    object-assign: ^4.1.1
+    promise: ^8.0.3
+    raf: ^3.4.1
+    regenerator-runtime: ^0.13.3
+    whatwg-fetch: ^3.0.0
+  checksum: d38fb0e5f773eb618e39832e78e34b2382a33a2f633ecbc7aba3af819134938f25f4b6915f40dcbb46efa1096efdfabe44030165142000dcf522f564db7cb3b9
+  languageName: node
+  linkType: hard
+
+"react-copy-to-clipboard@npm:5.0.3":
+  version: 5.0.3
+  resolution: "react-copy-to-clipboard@npm:5.0.3"
+  dependencies:
+    copy-to-clipboard: ^3
+    prop-types: ^15.5.8
+  peerDependencies:
+    react: ^15.3.0 || ^16.0.0 || ^17.0.0
+  checksum: b10d846ffa283d3de4cccc8e25927cb32d5ab8bf3d21586e1665459859653583971f7db6594176a807d42bce2a443c2c0a094b1657eb75cf04b95dfb64a22d84
+  languageName: node
+  linkType: hard
+
+"react-dev-utils@npm:^10.2.1":
+  version: 10.2.1
+  resolution: "react-dev-utils@npm:10.2.1"
+  dependencies:
+    "@babel/code-frame": 7.8.3
+    address: 1.1.2
+    browserslist: 4.10.0
+    chalk: 2.4.2
+    cross-spawn: 7.0.1
+    detect-port-alt: 1.1.6
+    escape-string-regexp: 2.0.0
+    filesize: 6.0.1
+    find-up: 4.1.0
+    fork-ts-checker-webpack-plugin: 3.1.1
+    global-modules: 2.0.0
+    globby: 8.0.2
+    gzip-size: 5.1.1
+    immer: 1.10.0
+    inquirer: 7.0.4
+    is-root: 2.1.0
+    loader-utils: 1.2.3
+    open: ^7.0.2
+    pkg-up: 3.1.0
+    react-error-overlay: ^6.0.7
+    recursive-readdir: 2.2.2
+    shell-quote: 1.7.2
+    strip-ansi: 6.0.0
+    text-table: 0.2.0
+  checksum: af58950075c69d5b179b5d527d59fe7072b18258042c412665a4e7425b796a4af24456e05b93ff837bdeec84746cd7d9ed9dce2119a8d57139b8ff71a6053dfc
+  languageName: node
+  linkType: hard
+
+"react-dnd-html5-backend@npm:5.0.1":
+  version: 5.0.1
+  resolution: "react-dnd-html5-backend@npm:5.0.1"
+  dependencies:
+    autobind-decorator: ^2.1.0
+    dnd-core: ^4.0.5
+    lodash: ^4.17.10
+    shallowequal: ^1.0.2
+  checksum: f10ecb65f300ce4ee0cfe0cad34e10a938aa9ae9ec10801eb5037f190b1af7fc7ec2003b0d024ce16016972a4765b1cdfeb345a22c9e2c781849d931ff219275
+  languageName: node
+  linkType: hard
+
+"react-dnd@npm:5.0.0":
+  version: 5.0.0
+  resolution: "react-dnd@npm:5.0.0"
+  dependencies:
+    dnd-core: ^4.0.5
+    hoist-non-react-statics: ^2.5.0
+    invariant: ^2.1.0
+    lodash: ^4.17.10
+    recompose: ^0.27.1
+    shallowequal: ^1.0.2
+  peerDependencies:
+    react: ">= 16.3"
+  checksum: 5ac7a14ae8dd5392fa584a5067a46bfe33b9f1827b5a9643a61df70609a475ce6d8b67e399f482646e93d149d254031362852f9fe835411fa1ae3a479f1f46f2
+  languageName: node
+  linkType: hard
+
+"react-dom@npm:16.14.0":
+  version: 16.14.0
+  resolution: "react-dom@npm:16.14.0"
+  dependencies:
+    loose-envify: ^1.1.0
+    object-assign: ^4.1.1
+    prop-types: ^15.6.2
+    scheduler: ^0.19.1
+  peerDependencies:
+    react: ^16.14.0
+  checksum: 5a5c49da0f106b2655a69f96c622c347febcd10532db391c262b26aec225b235357d9da1834103457683482ab1b229af7a50f6927a6b70e53150275e31785544
+  languageName: node
+  linkType: hard
+
+"react-dropzone@npm:5.1.1":
+  version: 5.1.1
+  resolution: "react-dropzone@npm:5.1.1"
+  dependencies:
+    attr-accept: ^1.1.3
+    prop-types: ^15.6.2
+  peerDependencies:
+    react: ">=0.14.0"
+  checksum: 95e4caafc05e5c1c1f53b5f2b394c1795e2955db07b5c0cf71580355d38beab17463879cfd3eb2c63a76b317ff9f261cbb1554d801a7ebeab1ed23afb07630a6
+  languageName: node
+  linkType: hard
+
+"react-error-overlay@npm:^6.0.7":
+  version: 6.0.9
+  resolution: "react-error-overlay@npm:6.0.9"
+  checksum: 695853bc885e798008a00c10d8d94e5ac91626e8130802fea37345f9c037f41b80104345db2ee87f225feb4a4ef71b0df572b17c378a6d397b6815f6d4a84293
+  languageName: node
+  linkType: hard
+
+"react-event-listener@npm:^0.6.2, react-event-listener@npm:^0.6.6":
+  version: 0.6.6
+  resolution: "react-event-listener@npm:0.6.6"
+  dependencies:
+    "@babel/runtime": ^7.2.0
+    prop-types: ^15.6.0
+    warning: ^4.0.1
+  peerDependencies:
+    react: ^16.3.0
+  checksum: 0287e0ae8cbf0a4c03889ffc2b745f5494b3edea0f4667357d709f5646dd78c7289ce305b6f473ec6ac5789f8a937c86b7e543d0249f0bf787bccdb1eab7ecd0
+  languageName: node
+  linkType: hard
+
+"react-highlight-words@npm:0.14.0":
+  version: 0.14.0
+  resolution: "react-highlight-words@npm:0.14.0"
+  dependencies:
+    highlight-words-core: ^1.2.0
+    memoize-one: ^4.0.0
+    prop-types: ^15.5.8
+  peerDependencies:
+    react: ^0.14.0 || ^15.0.0 || ^16.0.0-0
+  checksum: 9b0795eb2e78a098cb9385e5cc3e0884301c1dc89aeca95aad9b65d36519810ff0cd272fa188277c7aca76847fa58d4c4d3e47116df80f6b37528038695a63d0
+  languageName: node
+  linkType: hard
+
+"react-idle-timer@npm:4.3.6":
+  version: 4.3.6
+  resolution: "react-idle-timer@npm:4.3.6"
+  peerDependencies:
+    prop-types: ^15.x.x
+    react: ^16.x.x
+    react-dom: ^16.x.x
+  checksum: dc3b61121721a11f8824eec7a233c67ddd3e7b8b396fe776a97d42b3df8c776da7b7401202e9b8d1b7ac69e7d624567c290125c9debf9038248d2643aa9e40a5
+  languageName: node
+  linkType: hard
+
+"react-is@npm:^16.13.1, react-is@npm:^16.6.3, react-is@npm:^16.7.0, react-is@npm:^16.8.1, react-is@npm:^16.8.4, react-is@npm:^16.8.6":
+  version: 16.13.1
+  resolution: "react-is@npm:16.13.1"
+  checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f
+  languageName: node
+  linkType: hard
+
+"react-is@npm:^17.0.1":
+  version: 17.0.2
+  resolution: "react-is@npm:17.0.2"
+  checksum: 9d6d111d8990dc98bc5402c1266a808b0459b5d54830bbea24c12d908b536df7883f268a7868cfaedde3dd9d4e0d574db456f84d2e6df9c4526f99bb4b5344d8
+  languageName: node
+  linkType: hard
+
+"react-is@npm:^18.2.0":
+  version: 18.2.0
+  resolution: "react-is@npm:18.2.0"
+  checksum: e72d0ba81b5922759e4aff17e0252bd29988f9642ed817f56b25a3e217e13eea8a7f2322af99a06edb779da12d5d636e9fda473d620df9a3da0df2a74141d53e
+  languageName: node
+  linkType: hard
+
+"react-lifecycles-compat@npm:^3.0.2, react-lifecycles-compat@npm:^3.0.4":
+  version: 3.0.4
+  resolution: "react-lifecycles-compat@npm:3.0.4"
+  checksum: a904b0fc0a8eeb15a148c9feb7bc17cec7ef96e71188280061fc340043fd6d8ee3ff233381f0e8f95c1cf926210b2c4a31f38182c8f35ac55057e453d6df204f
+  languageName: node
+  linkType: hard
+
+"react-loader-spinner@npm:^6.1.6":
+  version: 6.1.6
+  resolution: "react-loader-spinner@npm:6.1.6"
+  dependencies:
+    react-is: ^18.2.0
+    styled-components: ^6.1.2
+  peerDependencies:
+    react: ^16.0.0 || ^17.0.0 || ^18.0.0
+    react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
+  checksum: 07fbb2de7aaf9348c4c67116e25100a0a9511e51cf45be69948d618113361059a9a9688d87c142cebd80dcf6832a91f0eee7f4b303d106bd6677c51caa6aa5e3
+  languageName: node
+  linkType: hard
+
+"react-redux@npm:5.0.7":
+  version: 5.0.7
+  resolution: "react-redux@npm:5.0.7"
+  dependencies:
+    hoist-non-react-statics: ^2.5.0
+    invariant: ^2.0.0
+    lodash: ^4.17.5
+    lodash-es: ^4.17.5
+    loose-envify: ^1.1.0
+    prop-types: ^15.6.0
+  peerDependencies:
+    react: ^0.14.0 || ^15.0.0-0 || ^16.0.0-0
+    redux: ^2.0.0 || ^3.0.0 || ^4.0.0-0
+  checksum: 7dbbf7e0d89aa5fc921f78ea873c458c4bb2c747b544959e21e3e1aea9e6b84944b8f26af64fdd109f4f6c7672495605b2e76d82c1374c808abd62c341f0da99
+  languageName: node
+  linkType: hard
+
+"react-router-dom@npm:4.3.1":
+  version: 4.3.1
+  resolution: "react-router-dom@npm:4.3.1"
+  dependencies:
+    history: ^4.7.2
+    invariant: ^2.2.4
+    loose-envify: ^1.3.1
+    prop-types: ^15.6.1
+    react-router: ^4.3.1
+    warning: ^4.0.1
+  peerDependencies:
+    react: ">=15"
+  checksum: e73b12fc97d1019a63c6ab5862a491634b8d9e5a44f954b0831913f5853faccab364eac3c1eb3563c74998efeb8675f857a4fa9aa1f6d3b46368557f5a6f935b
+  languageName: node
+  linkType: hard
+
+"react-router-redux@npm:5.0.0-alpha.9":
+  version: 5.0.0-alpha.9
+  resolution: "react-router-redux@npm:5.0.0-alpha.9"
+  dependencies:
+    history: ^4.7.2
+    prop-types: ^15.6.0
+    react-router: ^4.2.0
+  peerDependencies:
+    react: ">=15"
+  checksum: aa1b20805833f640fe2ef0bd91435f4f69c28523876038ce3943696c7d267cfd21c3d5f5e32e1117d2b92beb3b5002d4869686c95f27a03745a319168742e41f
+  languageName: node
+  linkType: hard
+
+"react-router@npm:4.3.1, react-router@npm:^4.2.0, react-router@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "react-router@npm:4.3.1"
+  dependencies:
+    history: ^4.7.2
+    hoist-non-react-statics: ^2.5.0
+    invariant: ^2.2.4
+    loose-envify: ^1.3.1
+    path-to-regexp: ^1.7.0
+    prop-types: ^15.6.1
+    warning: ^4.0.1
+  peerDependencies:
+    react: ">=15"
+  checksum: 144f2167e4589ec1eea3d9d178cf18571ee20bbd4abe8a46518dda91cd28db9da78c11b52cfd92d1bdb1e3d38b9751fa173ed85ced4e43a76f89cacd3502cf88
+  languageName: node
+  linkType: hard
+
+"react-rte@npm:^0.16.5":
+  version: 0.16.5
+  resolution: "react-rte@npm:0.16.5"
+  dependencies:
+    babel-runtime: ^6.23.0
+    class-autobind: ^0.1.4
+    classnames: ^2.2.5
+    draft-js: ">=0.10.0"
+    draft-js-export-html: ">=0.6.0"
+    draft-js-export-markdown: ">=0.3.0"
+    draft-js-import-html: ">=0.4.0"
+    draft-js-import-markdown: ">=0.3.0"
+    draft-js-utils: ">=0.2.0"
+    immutable: ^3.8.1
+  peerDependencies:
+    react: 0.14.x || 15.x.x || 16.x.x || 17.x.x
+    react-dom: 0.14.x || 15.x.x || 16.x.x || 17.x.x
+  checksum: 3af94acd7790989c44babc7b1327a0a047a1a7fd03f13d5c1ef2d276e949d7346a8b1b875b8457c2624e5c0cdcb6e3980f967280c52ff2f92d8234debec01c03
+  languageName: node
+  linkType: hard
+
+"react-scripts@npm:3.4.4":
+  version: 3.4.4
+  resolution: "react-scripts@npm:3.4.4"
+  dependencies:
+    "@babel/core": 7.9.0
+    "@svgr/webpack": 4.3.3
+    "@typescript-eslint/eslint-plugin": ^2.10.0
+    "@typescript-eslint/parser": ^2.10.0
+    babel-eslint: 10.1.0
+    babel-jest: ^24.9.0
+    babel-loader: 8.1.0
+    babel-plugin-named-asset-import: ^0.3.6
+    babel-preset-react-app: ^9.1.2
+    camelcase: ^5.3.1
+    case-sensitive-paths-webpack-plugin: 2.3.0
+    css-loader: 3.4.2
+    dotenv: 8.2.0
+    dotenv-expand: 5.1.0
+    eslint: ^6.6.0
+    eslint-config-react-app: ^5.2.1
+    eslint-loader: 3.0.3
+    eslint-plugin-flowtype: 4.6.0
+    eslint-plugin-import: 2.20.1
+    eslint-plugin-jsx-a11y: 6.2.3
+    eslint-plugin-react: 7.19.0
+    eslint-plugin-react-hooks: ^1.6.1
+    file-loader: 4.3.0
+    fs-extra: ^8.1.0
+    fsevents: 2.1.2
+    html-webpack-plugin: 4.0.0-beta.11
+    identity-obj-proxy: 3.0.0
+    jest: 24.9.0
+    jest-environment-jsdom-fourteen: 1.0.1
+    jest-resolve: 24.9.0
+    jest-watch-typeahead: 0.4.2
+    mini-css-extract-plugin: 0.9.0
+    optimize-css-assets-webpack-plugin: 5.0.3
+    pnp-webpack-plugin: 1.6.4
+    postcss-flexbugs-fixes: 4.1.0
+    postcss-loader: 3.0.0
+    postcss-normalize: 8.0.1
+    postcss-preset-env: 6.7.0
+    postcss-safe-parser: 4.0.1
+    react-app-polyfill: ^1.0.6
+    react-dev-utils: ^10.2.1
+    resolve: 1.15.0
+    resolve-url-loader: 3.1.2
+    sass-loader: 8.0.2
+    semver: 6.3.0
+    style-loader: 0.23.1
+    terser-webpack-plugin: 2.3.8
+    ts-pnp: 1.1.6
+    url-loader: 2.3.0
+    webpack: 4.42.0
+    webpack-dev-server: 3.11.0
+    webpack-manifest-plugin: 2.2.0
+    workbox-webpack-plugin: 4.3.1
+  peerDependencies:
+    typescript: ^3.2.1
+  dependenciesMeta:
+    fsevents:
+      optional: true
+  peerDependenciesMeta:
+    typescript:
+      optional: true
+  bin:
+    react-scripts: bin/react-scripts.js
+  checksum: a3ea2dfbecb595c471b23153c86fcd9da09093576f90646ca79e2653baaf209c1c5b73eeb4ed02f591e91e2505a6b963ec4de7f4ef76f2e52c73839d735dc680
+  languageName: node
+  linkType: hard
+
+"react-splitter-layout@npm:3.0.1":
+  version: 3.0.1
+  resolution: "react-splitter-layout@npm:3.0.1"
+  peerDependencies:
+    prop-types: ^15.5.0
+    react: ^15.5.0 || ^16.0.0
+  checksum: 8e90478ecbfc2ec912826394d1d2f21493372910d8825b6be244e567f1f8ce6314e57b2fa251b408e3f0c13d0621f4cb674e4fce782793baca61b0963a700a1c
+  languageName: node
+  linkType: hard
+
+"react-test-renderer@npm:^16.0.0-0":
+  version: 16.14.0
+  resolution: "react-test-renderer@npm:16.14.0"
+  dependencies:
+    object-assign: ^4.1.1
+    prop-types: ^15.6.2
+    react-is: ^16.8.6
+    scheduler: ^0.19.1
+  peerDependencies:
+    react: ^16.14.0
+  checksum: 96eb8a2566e67ebd246ef6e1b36d8c8498c68ebfdb94ca8399c19b4e3b73368caf0ffbe44767593e3499f2f58b4b5e57ba0565a47628048d2ab01b23a422724e
+  languageName: node
+  linkType: hard
+
+"react-text-mask@npm:^5.4.3":
+  version: 5.4.3
+  resolution: "react-text-mask@npm:5.4.3"
+  dependencies:
+    prop-types: ^15.5.6
+  peerDependencies:
+    react: ^0.14.0 || ^15.0.0 || ^16.0.0
+  checksum: ee9c560f47d2f67d0193636eeea36852503d6d7bfd16d75ecb8170256606923d786bbb3511971deedbd01136340acf597fe2b6ba0be3cddb2a17a602767eb7b9
+  languageName: node
+  linkType: hard
+
+"react-transition-group@npm:2.5.0":
+  version: 2.5.0
+  resolution: "react-transition-group@npm:2.5.0"
+  dependencies:
+    dom-helpers: ^3.3.1
+    loose-envify: ^1.4.0
+    prop-types: ^15.6.2
+    react-lifecycles-compat: ^3.0.4
+  peerDependencies:
+    react: ">=15.0.0"
+    react-dom: ">=15.0.0"
+  checksum: 3ff021611b513475d26da17b3c50b93c59a6f62a6e54719bf5a9b15e400f3c042d67afeb9cfb58080464e79cb5dc89e9514edd2aa1e41557782b733849e97004
+  languageName: node
+  linkType: hard
+
+"react-transition-group@npm:^2.2.1, react-transition-group@npm:^2.5.3":
+  version: 2.9.0
+  resolution: "react-transition-group@npm:2.9.0"
+  dependencies:
+    dom-helpers: ^3.4.0
+    loose-envify: ^1.4.0
+    prop-types: ^15.6.2
+    react-lifecycles-compat: ^3.0.4
+  peerDependencies:
+    react: ">=15.0.0"
+    react-dom: ">=15.0.0"
+  checksum: d8c9e50aabdc2cfc324e5cdb0ad1c6eecb02e1c0cd007b26d5b30ccf49015e900683dd489348c71fba4055858308d9ba7019e0d37d0e8d37bd46ed098788f670
+  languageName: node
+  linkType: hard
+
+"react-virtualized-auto-sizer@npm:1.0.2":
+  version: 1.0.2
+  resolution: "react-virtualized-auto-sizer@npm:1.0.2"
+  peerDependencies:
+    react: ^15.3.0 || ^16.0.0-alpha
+    react-dom: ^15.3.0 || ^16.0.0-alpha
+  checksum: e29dc664c3d1f151f799792b6b1c079a279aa13557c7beb8622d30f366a09c398c0d966d2f749861435e68b31309c76c3ac8640b132c4b346162a4253e022e23
+  languageName: node
+  linkType: hard
+
+"react-window@npm:1.8.5":
+  version: 1.8.5
+  resolution: "react-window@npm:1.8.5"
+  dependencies:
+    "@babel/runtime": ^7.0.0
+    memoize-one: ">=3.1.1 <6"
+  peerDependencies:
+    react: ^15.0.0 || ^16.0.0
+    react-dom: ^15.0.0 || ^16.0.0
+  checksum: 9abd462c67aaa72669616017eda2cd660ff073c6ec9a85fead12e1689a2b6e55c8d5f863bf515dd83424c41786f60b4a70aefbf9d8ae1e0a569fc6ede63797c3
+  languageName: node
+  linkType: hard
+
+"react@npm:16.14.0":
+  version: 16.14.0
+  resolution: "react@npm:16.14.0"
+  dependencies:
+    loose-envify: ^1.1.0
+    object-assign: ^4.1.1
+    prop-types: ^15.6.2
+  checksum: 8484f3ecb13414526f2a7412190575fc134da785c02695eb92bb6028c930bfe1c238d7be2a125088fec663cc7cda0a3623373c46807cf2c281f49c34b79881ac
+  languageName: node
+  linkType: hard
+
+"read-pkg-up@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "read-pkg-up@npm:1.0.1"
+  dependencies:
+    find-up: ^1.0.0
+    read-pkg: ^1.0.0
+  checksum: d18399a0f46e2da32beb2f041edd0cda49d2f2cc30195a05c759ef3ed9b5e6e19ba1ad1bae2362bdec8c6a9f2c3d18f4d5e8c369e808b03d498d5781cb9122c7
+  languageName: node
+  linkType: hard
+
+"read-pkg-up@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "read-pkg-up@npm:2.0.0"
+  dependencies:
+    find-up: ^2.0.0
+    read-pkg: ^2.0.0
+  checksum: 22f9026fb72219ecd165f94f589461c70a88461dc7ea0d439a310ef2a5271ff176a4df4e5edfad087d8ac89b8553945eb209476b671e8ed081c990f30fc40b27
+  languageName: node
+  linkType: hard
+
+"read-pkg-up@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "read-pkg-up@npm:4.0.0"
+  dependencies:
+    find-up: ^3.0.0
+    read-pkg: ^3.0.0
+  checksum: dd867d9a912707bc11340aebc91780be9f36f34ee1d27a5dafb8520e0cb6344138b80eb8bf8325bebf519d26ecf14cbf6190d9e5f765f0120da5ede4013f4d13
+  languageName: node
+  linkType: hard
+
+"read-pkg-up@npm:^7.0.1":
+  version: 7.0.1
+  resolution: "read-pkg-up@npm:7.0.1"
+  dependencies:
+    find-up: ^4.1.0
+    read-pkg: ^5.2.0
+    type-fest: ^0.8.1
+  checksum: e4e93ce70e5905b490ca8f883eb9e48b5d3cebc6cd4527c25a0d8f3ae2903bd4121c5ab9c5a3e217ada0141098eeb661313c86fa008524b089b8ed0b7f165e44
+  languageName: node
+  linkType: hard
+
+"read-pkg@npm:^1.0.0":
+  version: 1.1.0
+  resolution: "read-pkg@npm:1.1.0"
+  dependencies:
+    load-json-file: ^1.0.0
+    normalize-package-data: ^2.3.2
+    path-type: ^1.0.0
+  checksum: a0f5d5e32227ec8e6a028dd5c5134eab229768dcb7a5d9a41a284ed28ad4b9284fecc47383dc1593b5694f4de603a7ffaee84b738956b9b77e0999567485a366
+  languageName: node
+  linkType: hard
+
+"read-pkg@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "read-pkg@npm:2.0.0"
+  dependencies:
+    load-json-file: ^2.0.0
+    normalize-package-data: ^2.3.2
+    path-type: ^2.0.0
+  checksum: 85c5bf35f2d96acdd756151ba83251831bb2b1040b7d96adce70b2cb119b5320417f34876de0929f2d06c67f3df33ef4636427df3533913876f9ef2487a6f48f
+  languageName: node
+  linkType: hard
+
+"read-pkg@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "read-pkg@npm:3.0.0"
+  dependencies:
+    load-json-file: ^4.0.0
+    normalize-package-data: ^2.3.2
+    path-type: ^3.0.0
+  checksum: 398903ebae6c7e9965419a1062924436cc0b6f516c42c4679a90290d2f87448ed8f977e7aa2dbba4aa1ac09248628c43e493ac25b2bc76640e946035200e34c6
+  languageName: node
+  linkType: hard
+
+"read-pkg@npm:^5.2.0":
+  version: 5.2.0
+  resolution: "read-pkg@npm:5.2.0"
+  dependencies:
+    "@types/normalize-package-data": ^2.4.0
+    normalize-package-data: ^2.5.0
+    parse-json: ^5.0.0
+    type-fest: ^0.6.0
+  checksum: eb696e60528b29aebe10e499ba93f44991908c57d70f2d26f369e46b8b9afc208ef11b4ba64f67630f31df8b6872129e0a8933c8c53b7b4daf0eace536901222
+  languageName: node
+  linkType: hard
+
+"readable-stream@npm:1 || 2, readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.1.5, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.3, readable-stream@npm:^2.3.6, readable-stream@npm:~2.3.6":
+  version: 2.3.7
+  resolution: "readable-stream@npm:2.3.7"
+  dependencies:
+    core-util-is: ~1.0.0
+    inherits: ~2.0.3
+    isarray: ~1.0.0
+    process-nextick-args: ~2.0.0
+    safe-buffer: ~5.1.1
+    string_decoder: ~1.1.1
+    util-deprecate: ~1.0.1
+  checksum: e4920cf7549a60f8aaf694d483a0e61b2a878b969d224f89b3bc788b8d920075132c4b55a7494ee944c7b6a9a0eada28a7f6220d80b0312ece70bbf08eeca755
+  languageName: node
+  linkType: hard
+
+"readable-stream@npm:^2.3.8":
+  version: 2.3.8
+  resolution: "readable-stream@npm:2.3.8"
+  dependencies:
+    core-util-is: ~1.0.0
+    inherits: ~2.0.3
+    isarray: ~1.0.0
+    process-nextick-args: ~2.0.0
+    safe-buffer: ~5.1.1
+    string_decoder: ~1.1.1
+    util-deprecate: ~1.0.1
+  checksum: 65645467038704f0c8aaf026a72fbb588a9e2ef7a75cd57a01702ee9db1c4a1e4b03aaad36861a6a0926546a74d174149c8c207527963e0c2d3eee2f37678a42
+  languageName: node
+  linkType: hard
+
+"readable-stream@npm:^3.0.6, readable-stream@npm:^3.6.0":
+  version: 3.6.0
+  resolution: "readable-stream@npm:3.6.0"
+  dependencies:
+    inherits: ^2.0.3
+    string_decoder: ^1.1.1
+    util-deprecate: ^1.0.1
+  checksum: d4ea81502d3799439bb955a3a5d1d808592cf3133350ed352aeaa499647858b27b1c4013984900238b0873ec8d0d8defce72469fb7a83e61d53f5ad61cb80dc8
+  languageName: node
+  linkType: hard
+
+"readdirp@npm:^2.2.1":
+  version: 2.2.1
+  resolution: "readdirp@npm:2.2.1"
+  dependencies:
+    graceful-fs: ^4.1.11
+    micromatch: ^3.1.10
+    readable-stream: ^2.0.2
+  checksum: 3879b20f1a871e0e004a14fbf1776e65ee0b746a62f5a416010808b37c272ac49b023c47042c7b1e281cba75a449696635bc64c397ed221ea81d853a8f2ed79a
+  languageName: node
+  linkType: hard
+
+"readdirp@npm:~3.6.0":
+  version: 3.6.0
+  resolution: "readdirp@npm:3.6.0"
+  dependencies:
+    picomatch: ^2.2.1
+  checksum: 1ced032e6e45670b6d7352d71d21ce7edf7b9b928494dcaba6f11fba63180d9da6cd7061ebc34175ffda6ff529f481818c962952004d273178acd70f7059b320
+  languageName: node
+  linkType: hard
+
+"realpath-native@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "realpath-native@npm:1.1.0"
+  dependencies:
+    util.promisify: ^1.0.0
+  checksum: 75ef0595dea6186384b785a9e0993c58ec604f8be2e39b602fec6d7837c7f770af4a4eb3c81f864a7d81c518a7167a6eaabbc7695b7a88c56e1ef04b91c1d586
+  languageName: node
+  linkType: hard
+
+"recompose@npm:0.28.0 - 0.30.0":
+  version: 0.30.0
+  resolution: "recompose@npm:0.30.0"
+  dependencies:
+    "@babel/runtime": ^7.0.0
+    change-emitter: ^0.1.2
+    fbjs: ^0.8.1
+    hoist-non-react-statics: ^2.3.1
+    react-lifecycles-compat: ^3.0.2
+    symbol-observable: ^1.0.4
+  peerDependencies:
+    react: ^0.14.0 || ^15.0.0 || ^16.0.0
+  checksum: 18e58252336d0628b22db1e38407d32e836648e6d5c9453ba37c9f8030138b3429ee3952b053a13b60311f8b60893b207a761466bb293083542db0cf317b7a41
+  languageName: node
+  linkType: hard
+
+"recompose@npm:^0.27.1":
+  version: 0.27.1
+  resolution: "recompose@npm:0.27.1"
+  dependencies:
+    babel-runtime: ^6.26.0
+    change-emitter: ^0.1.2
+    fbjs: ^0.8.1
+    hoist-non-react-statics: ^2.3.1
+    react-lifecycles-compat: ^3.0.2
+    symbol-observable: ^1.0.4
+  peerDependencies:
+    react: ^0.14.0 || ^15.0.0 || ^16.0.0
+  checksum: f61eef0202ab5202190cd3de66f78fb460d5b90c41ca448abbee901cedb46381e6f3e4c4be9b00ae23f9f7fe5c6db8c28a0e3848f0681ce906d911a39bc53e60
+  languageName: node
+  linkType: hard
+
+"recompose@npm:^0.29.0":
+  version: 0.29.0
+  resolution: "recompose@npm:0.29.0"
+  dependencies:
+    "@babel/runtime": ^7.0.0
+    change-emitter: ^0.1.2
+    fbjs: ^0.8.1
+    hoist-non-react-statics: ^2.3.1
+    react-lifecycles-compat: ^3.0.2
+    symbol-observable: ^1.0.4
+  peerDependencies:
+    react: ^0.14.0 || ^15.0.0 || ^16.0.0
+  checksum: 57392dc3a14d524b471c9d8b1372eded9f19f13ded84b83dda1a31026f046647926bdecc5e3bf47b5db945e0db749e927b62aba4894694d4a2d3b54bda7ae367
+  languageName: node
+  linkType: hard
+
+"recursive-readdir@npm:2.2.2":
+  version: 2.2.2
+  resolution: "recursive-readdir@npm:2.2.2"
+  dependencies:
+    minimatch: 3.0.4
+  checksum: a6b22994d76458443d4a27f5fd7147ac63ad31bba972666a291d511d4d819ee40ff71ba7524c14f6a565b8cfaf7f48b318f971804b913cf538d58f04e25d1fee
+  languageName: node
+  linkType: hard
+
+"redent@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "redent@npm:1.0.0"
+  dependencies:
+    indent-string: ^2.1.0
+    strip-indent: ^1.0.1
+  checksum: 2bb8f76fda9c9f44e26620047b0ba9dd1834b0a80309d0badcc23fdcf7bb27a7ca74e66b683baa0d4b8cb5db787f11be086504036d63447976f409dd3e73fd7d
+  languageName: node
+  linkType: hard
+
+"redent@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "redent@npm:3.0.0"
+  dependencies:
+    indent-string: ^4.0.0
+    strip-indent: ^3.0.0
+  checksum: fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b
+  languageName: node
+  linkType: hard
+
+"redux-devtools-extension@npm:^2.13.9":
+  version: 2.13.9
+  resolution: "redux-devtools-extension@npm:2.13.9"
+  peerDependencies:
+    redux: ^3.1.0 || ^4.0.0
+  checksum: 603d48fd6acf3922ef373b251ab3fdbb990035e90284191047b29d25b06ea18122bc4ef01e0704ccae495acb27ab5e47b560937e98213605dd88299470025db9
+  languageName: node
+  linkType: hard
+
+"redux-devtools-instrument@npm:^1.0.1":
+  version: 1.10.0
+  resolution: "redux-devtools-instrument@npm:1.10.0"
+  dependencies:
+    lodash: ^4.17.19
+    symbol-observable: ^1.2.0
+  peerDependencies:
+    redux: ^3.4.0 || ^4.0.0
+  checksum: 16836ff893070e39ee7c7d429fa2478775216765cadfdda7ac1ca8931936da99a7b9c2047632853e0ee40e5001511183e707a46d9f5c4fedb605618f343aa5d7
+  languageName: node
+  linkType: hard
+
+"redux-devtools@npm:3.4.1":
+  version: 3.4.1
+  resolution: "redux-devtools@npm:3.4.1"
+  dependencies:
+    lodash: ^4.2.0
+    prop-types: ^15.5.7
+    redux-devtools-instrument: ^1.0.1
+  peerDependencies:
+    react: ^0.14.9 || ^15.3.0 || ^16.0.0
+    react-redux: ^4.0.0 || ^5.0.0
+    redux: ^3.5.2
+  checksum: 6ef1b86c9d95fccd5365c133a85abcdef3f867aff015114c0f1bcd4b7ca3059ace53f33c07fb2fe44adcfbf9b57055067ab516df860fcc2633b1f99578a89ec9
+  languageName: node
+  linkType: hard
+
+"redux-form@npm:7.4.2":
+  version: 7.4.2
+  resolution: "redux-form@npm:7.4.2"
+  dependencies:
+    es6-error: ^4.1.1
+    hoist-non-react-statics: ^2.5.4
+    invariant: ^2.2.4
+    is-promise: ^2.1.0
+    lodash: ^4.17.10
+    lodash-es: ^4.17.10
+    prop-types: ^15.6.1
+    react-lifecycles-compat: ^3.0.4
+  peerDependencies:
+    react: ^15.0.0-0 || ^16.0.0-0
+    react-redux: ^4.3.0 || ^5.0.0
+    redux: ^3.0.0 || ^4.0.0
+  checksum: 4791b4809c59089dbf80da1a332c74c5ee543d473eb0610e9ece98bf584b28d0713d228b591e426cd07053f79d09a53452cd464c3879e8ebedb297058c2e7df3
+  languageName: node
+  linkType: hard
+
+"redux-mock-store@npm:1.5.4":
+  version: 1.5.4
+  resolution: "redux-mock-store@npm:1.5.4"
+  dependencies:
+    lodash.isplainobject: ^4.0.6
+  checksum: 571eab2cca3e46321969025af865ddd780804c811be9db277fddf772d86e3ea67b4ef1b19ea3866417d41eb1b73605103020321f68cf68f379a52679a6823d12
+  languageName: node
+  linkType: hard
+
+"redux-thunk@npm:2.3.0":
+  version: 2.3.0
+  resolution: "redux-thunk@npm:2.3.0"
+  checksum: d13f442ffc91249b534bf14884c33feff582894be2562169637dc9d4d70aec6423bfe6d66f88c46ac027ac1c0cd07d6c2dd4a61cf7695b8e43491de679df9bcf
+  languageName: node
+  linkType: hard
+
+"redux@npm:4.0.3":
+  version: 4.0.3
+  resolution: "redux@npm:4.0.3"
+  dependencies:
+    loose-envify: ^1.4.0
+    symbol-observable: ^1.2.0
+  checksum: 8890907d594ada3ecc8ce2169b40b4f125462302262153b0628288ad3058c9af28eb21fa151a3f21f401f6cd12dc0ac869e803b22d7579b1050b6f229017b98a
+  languageName: node
+  linkType: hard
+
+"redux@npm:>= 3.7.2, redux@npm:^3.6.0 || ^4.0.0, redux@npm:^4.0.0, redux@npm:^4.0.5":
+  version: 4.1.0
+  resolution: "redux@npm:4.1.0"
+  dependencies:
+    "@babel/runtime": ^7.9.2
+  checksum: 322d5f4b49cbbdb3f64f04e9279cabbdea9a698024b530dc98563eb598b6bd55ff8a715208e3ee09db9802a2f426c991c78906b1c6491ebb52e7310e55ee5cdf
+  languageName: node
+  linkType: hard
+
+"redux@npm:^3.6.0":
+  version: 3.7.2
+  resolution: "redux@npm:3.7.2"
+  dependencies:
+    lodash: ^4.2.1
+    lodash-es: ^4.2.1
+    loose-envify: ^1.1.0
+    symbol-observable: ^1.0.3
+  checksum: c349b77e68d009bc530d3cb6252a6a3e43e20a6e52f9483a048b24cd2f266d9bfa6f0bbd4769d40fe36795e2f7a7a884c3ddc92c13e82efd3328890f94821091
+  languageName: node
+  linkType: hard
+
+"reflect.ownkeys@npm:^0.2.0":
+  version: 0.2.0
+  resolution: "reflect.ownkeys@npm:0.2.0"
+  checksum: 9530b166569e547c2cf25ade3cdc39c662212feeccf3e0ed46e6d8abf92f5683c82d7857011cee6230bf648eb0b99b6b419a007012b8571dcd4bb4d818d3b88d
+  languageName: node
+  linkType: hard
+
+"regenerate-unicode-properties@npm:^8.2.0":
+  version: 8.2.0
+  resolution: "regenerate-unicode-properties@npm:8.2.0"
+  dependencies:
+    regenerate: ^1.4.0
+  checksum: ee7db70ab25b95f2e3f39537089fc3eddba0b39fc9b982d6602f127996ce873d8c55584d5428486ca00dc0a85d174d943354943cd4a745cda475c8fe314b4f8a
+  languageName: node
+  linkType: hard
+
+"regenerate@npm:^1.4.0":
+  version: 1.4.2
+  resolution: "regenerate@npm:1.4.2"
+  checksum: 3317a09b2f802da8db09aa276e469b57a6c0dd818347e05b8862959c6193408242f150db5de83c12c3fa99091ad95fb42a6db2c3329bfaa12a0ea4cbbeb30cb0
+  languageName: node
+  linkType: hard
+
+"regenerator-runtime@npm:^0.11.0":
+  version: 0.11.1
+  resolution: "regenerator-runtime@npm:0.11.1"
+  checksum: 3c97bd2c7b2b3247e6f8e2147a002eb78c995323732dad5dc70fac8d8d0b758d0295e7015b90d3d444446ae77cbd24b9f9123ec3a77018e81d8999818301b4f4
+  languageName: node
+  linkType: hard
+
+"regenerator-runtime@npm:^0.12.0":
+  version: 0.12.1
+  resolution: "regenerator-runtime@npm:0.12.1"
+  checksum: 348c401336bcebe2be17fd4f24c5b0a1ed75bff3024dc817a69cdc776b48b98c7f6f3b98e1baa4220569440bb9215e1fff3dcb01c8aad3ff2ed3732e30d017bf
+  languageName: node
+  linkType: hard
+
+"regenerator-runtime@npm:^0.13.3, regenerator-runtime@npm:^0.13.4":
+  version: 0.13.7
+  resolution: "regenerator-runtime@npm:0.13.7"
+  checksum: 52b66e6669152c0b1bccd95c8e11aabbfe67bb97bdf00e223bdf723b0f0052d4da5c02001d4c4bef576bdc5bcdc38a20496d1b5363b65c950c8434ed5071d9e0
+  languageName: node
+  linkType: hard
+
+"regenerator-runtime@npm:^0.14.0":
+  version: 0.14.1
+  resolution: "regenerator-runtime@npm:0.14.1"
+  checksum: 9f57c93277b5585d3c83b0cf76be47b473ae8c6d9142a46ce8b0291a04bb2cf902059f0f8445dcabb3fb7378e5fe4bb4ea1e008876343d42e46d3b484534ce38
+  languageName: node
+  linkType: hard
+
+"regenerator-transform@npm:^0.14.2":
+  version: 0.14.5
+  resolution: "regenerator-transform@npm:0.14.5"
+  dependencies:
+    "@babel/runtime": ^7.8.4
+  checksum: a467a3b652b4ec26ff964e9c5f1817523a73fc44cb928b8d21ff11aebeac5d10a84d297fe02cea9f282bcec81a0b0d562237da69ef0f40a0160b30a4fa98bc94
+  languageName: node
+  linkType: hard
+
+"regex-not@npm:^1.0.0, regex-not@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "regex-not@npm:1.0.2"
+  dependencies:
+    extend-shallow: ^3.0.2
+    safe-regex: ^1.1.0
+  checksum: 3081403de79559387a35ef9d033740e41818a559512668cef3d12da4e8a29ef34ee13c8ed1256b07e27ae392790172e8a15c8a06b72962fd4550476cde3d8f77
+  languageName: node
+  linkType: hard
+
+"regex-parser@npm:^2.2.11":
+  version: 2.2.11
+  resolution: "regex-parser@npm:2.2.11"
+  checksum: 78200331ec0cc372302d287a4946c38681eb5fe435453fca572cb53cac0ba579e5eb3b9e25eac24c0c80a555fb3ea7a637814a35da1e9bc88e8819110ae5de24
+  languageName: node
+  linkType: hard
+
+"regexp.prototype.flags@npm:^1.2.0, regexp.prototype.flags@npm:^1.3.1":
+  version: 1.3.1
+  resolution: "regexp.prototype.flags@npm:1.3.1"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+  checksum: 343595db5a6bbbb3bfbda881f9c74832cfa9fc0039e64a43843f6bb9158b78b921055266510800ed69d4997638890b17a46d55fd9f32961f53ae56ac3ec4dd05
+  languageName: node
+  linkType: hard
+
+"regexpp@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "regexpp@npm:2.0.1"
+  checksum: 1f41cf80ac08514c6665812e3dcc0673569431d3285db27053f8b237a758992fb55d6ddfbc264db399ff4f7a7db432900ca3a029daa28a75e0436231872091b1
+  languageName: node
+  linkType: hard
+
+"regexpp@npm:^3.1.0":
+  version: 3.2.0
+  resolution: "regexpp@npm:3.2.0"
+  checksum: a78dc5c7158ad9ddcfe01aa9144f46e192ddbfa7b263895a70a5c6c73edd9ce85faf7c0430e59ac38839e1734e275b9c3de5c57ee3ab6edc0e0b1bdebefccef8
+  languageName: node
+  linkType: hard
+
+"regexpu-core@npm:^4.7.1":
+  version: 4.7.1
+  resolution: "regexpu-core@npm:4.7.1"
+  dependencies:
+    regenerate: ^1.4.0
+    regenerate-unicode-properties: ^8.2.0
+    regjsgen: ^0.5.1
+    regjsparser: ^0.6.4
+    unicode-match-property-ecmascript: ^1.0.4
+    unicode-match-property-value-ecmascript: ^1.2.0
+  checksum: 368b4aab72132ba3c8bd114822572c920d390ae99d3d219e0c7f872c6a0a3b1fbe30c88188ff90ec6f8e681667fa8e51d84a78bb05c460996a0df6a060b7ae80
+  languageName: node
+  linkType: hard
+
+"regjsgen@npm:^0.5.1":
+  version: 0.5.2
+  resolution: "regjsgen@npm:0.5.2"
+  checksum: 87c83d8488affae2493a823904de1a29a1867a07433c5e1142ad749b5606c5589b305fe35bfcc0972cf5a3b0d66b1f7999009e541be39a5d42c6041c59e2fb52
+  languageName: node
+  linkType: hard
+
+"regjsparser@npm:^0.6.4":
+  version: 0.6.9
+  resolution: "regjsparser@npm:0.6.9"
+  dependencies:
+    jsesc: ~0.5.0
+  bin:
+    regjsparser: bin/parser
+  checksum: 1c439ec46a0be7834ec82fbb109396e088b6b73f0e9562cd67c37e3bdf85cc7cffe0192b3324da4491c7f709ce2b06fb2d59e12f0f9836b2e0cf26d5e54263aa
+  languageName: node
+  linkType: hard
+
+"relateurl@npm:^0.2.7":
+  version: 0.2.7
+  resolution: "relateurl@npm:0.2.7"
+  checksum: 5891e792eae1dfc3da91c6fda76d6c3de0333a60aa5ad848982ebb6dccaa06e86385fb1235a1582c680a3d445d31be01c6bfc0804ebbcab5aaf53fa856fde6b6
+  languageName: node
+  linkType: hard
+
+"remove-trailing-separator@npm:^1.0.1":
+  version: 1.1.0
+  resolution: "remove-trailing-separator@npm:1.1.0"
+  checksum: d3c20b5a2d987db13e1cca9385d56ecfa1641bae143b620835ac02a6b70ab88f68f117a0021838db826c57b31373d609d52e4f31aca75fc490c862732d595419
+  languageName: node
+  linkType: hard
+
+"renderkid@npm:^2.0.4":
+  version: 2.0.7
+  resolution: "renderkid@npm:2.0.7"
+  dependencies:
+    css-select: ^4.1.3
+    dom-converter: ^0.2.0
+    htmlparser2: ^6.1.0
+    lodash: ^4.17.21
+    strip-ansi: ^3.0.1
+  checksum: d3d7562531fb8104154d4aa6aa977707783616318014088378a6c5bbc36318ada9289543d380ede707e531b7f5b96229e87d1b8944f675e5ec3686e62692c7c7
+  languageName: node
+  linkType: hard
+
+"repeat-element@npm:^1.1.2":
+  version: 1.1.4
+  resolution: "repeat-element@npm:1.1.4"
+  checksum: 1edd0301b7edad71808baad226f0890ba709443f03a698224c9ee4f2494c317892dc5211b2ba8cbea7194a9ddbcac01e283bd66de0467ab24ee1fc1a3711d8a9
+  languageName: node
+  linkType: hard
+
+"repeat-string@npm:^1.6.1":
+  version: 1.6.1
+  resolution: "repeat-string@npm:1.6.1"
+  checksum: 1b809fc6db97decdc68f5b12c4d1a671c8e3f65ec4a40c238bc5200e44e85bcc52a54f78268ab9c29fcf5fe4f1343e805420056d1f30fa9a9ee4c2d93e3cc6c0
+  languageName: node
+  linkType: hard
+
+"repeating@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "repeating@npm:2.0.1"
+  dependencies:
+    is-finite: ^1.0.0
+  checksum: d2db0b69c5cb0c14dd750036e0abcd6b3c3f7b2da3ee179786b755cf737ca15fa0fff417ca72de33d6966056f4695440e680a352401fc02c95ade59899afbdd0
+  languageName: node
+  linkType: hard
+
+"request-progress@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "request-progress@npm:3.0.0"
+  dependencies:
+    throttleit: ^1.0.0
+  checksum: 6ea1761dcc8a8b7b5894afd478c0286aa31bd69438d7050294bd4fd0d0b3e09b5cde417d38deef9c49809039c337d8744e4bb49d8632b0c3e4ffa5e8a687e0fd
+  languageName: node
+  linkType: hard
+
+"request-promise-core@npm:1.1.4":
+  version: 1.1.4
+  resolution: "request-promise-core@npm:1.1.4"
+  dependencies:
+    lodash: ^4.17.19
+  peerDependencies:
+    request: ^2.34
+  checksum: c798bafd552961e36fbf5023b1d081e81c3995ab390f1bc8ef38a711ba3fe4312eb94dbd61887073d7356c3499b9380947d7f62faa805797c0dc50f039425699
+  languageName: node
+  linkType: hard
+
+"request-promise-native@npm:^1.0.5, request-promise-native@npm:^1.0.8":
+  version: 1.0.9
+  resolution: "request-promise-native@npm:1.0.9"
+  dependencies:
+    request-promise-core: 1.1.4
+    stealthy-require: ^1.1.1
+    tough-cookie: ^2.3.3
+  peerDependencies:
+    request: ^2.34
+  checksum: 3e2c694eefac88cb20beef8911ad57a275ab3ccbae0c4ca6c679fffb09d5fd502458aab08791f0814ca914b157adab2d4e472597c97a73be702918e41725ed69
+  languageName: node
+  linkType: hard
+
+"request@npm:^2.87.0, request@npm:^2.88.0, request@npm:^2.88.2":
+  version: 2.88.2
+  resolution: "request@npm:2.88.2"
+  dependencies:
+    aws-sign2: ~0.7.0
+    aws4: ^1.8.0
+    caseless: ~0.12.0
+    combined-stream: ~1.0.6
+    extend: ~3.0.2
+    forever-agent: ~0.6.1
+    form-data: ~2.3.2
+    har-validator: ~5.1.3
+    http-signature: ~1.2.0
+    is-typedarray: ~1.0.0
+    isstream: ~0.1.2
+    json-stringify-safe: ~5.0.1
+    mime-types: ~2.1.19
+    oauth-sign: ~0.9.0
+    performance-now: ^2.1.0
+    qs: ~6.5.2
+    safe-buffer: ^5.1.2
+    tough-cookie: ~2.5.0
+    tunnel-agent: ^0.6.0
+    uuid: ^3.3.2
+  checksum: 4e112c087f6eabe7327869da2417e9d28fcd0910419edd2eb17b6acfc4bfa1dad61954525949c228705805882d8a98a86a0ea12d7f739c01ee92af7062996983
+  languageName: node
+  linkType: hard
+
+"require-directory@npm:^2.1.1":
+  version: 2.1.1
+  resolution: "require-directory@npm:2.1.1"
+  checksum: fb47e70bf0001fdeabdc0429d431863e9475e7e43ea5f94ad86503d918423c1543361cc5166d713eaa7029dd7a3d34775af04764bebff99ef413111a5af18c80
+  languageName: node
+  linkType: hard
+
+"require-main-filename@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "require-main-filename@npm:1.0.1"
+  checksum: 1fef30754da961f4e13c450c3eb60c7ae898a529c6ad6fa708a70bd2eed01564ceb299187b2899f5562804d797a059f39a5789884d0ac7b7ae1defc68fba4abf
+  languageName: node
+  linkType: hard
+
+"require-main-filename@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "require-main-filename@npm:2.0.0"
+  checksum: e9e294695fea08b076457e9ddff854e81bffbe248ed34c1eec348b7abbd22a0d02e8d75506559e2265e96978f3c4720bd77a6dad84755de8162b357eb6c778c7
+  languageName: node
+  linkType: hard
+
+"requires-port@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "requires-port@npm:1.0.0"
+  checksum: eee0e303adffb69be55d1a214e415cf42b7441ae858c76dfc5353148644f6fd6e698926fc4643f510d5c126d12a705e7c8ed7e38061113bdf37547ab356797ff
+  languageName: node
+  linkType: hard
+
+"reselect@npm:4.0.0":
+  version: 4.0.0
+  resolution: "reselect@npm:4.0.0"
+  checksum: ac7dfc9ef2cdb42b6fc87a856f3ce904c2e4363a2bc1e6fb7eea5f78902a6f506e4388e6509752984877c6dbfe501100c076671d334799eb5a1bfe9936cb2c12
+  languageName: node
+  linkType: hard
+
+"resolve-cwd@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "resolve-cwd@npm:2.0.0"
+  dependencies:
+    resolve-from: ^3.0.0
+  checksum: e7c16880c460656e77f102d537a6dc82b3657d9173697cd6ea82ffce37df96f6c1fc79d0bb35fd73fff8871ac13f21b4396958b5f0a13e5b99c97d69f5e319fa
+  languageName: node
+  linkType: hard
+
+"resolve-from@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "resolve-from@npm:3.0.0"
+  checksum: fff9819254d2d62b57f74e5c2ca9c0bdd425ca47287c4d801bc15f947533148d858229ded7793b0f59e61e49e782fffd6722048add12996e1bd4333c29669062
+  languageName: node
+  linkType: hard
+
+"resolve-from@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "resolve-from@npm:4.0.0"
+  checksum: f4ba0b8494846a5066328ad33ef8ac173801a51739eb4d63408c847da9a2e1c1de1e6cbbf72699211f3d13f8fc1325648b169bd15eb7da35688e30a5fb0e4a7f
+  languageName: node
+  linkType: hard
+
+"resolve-pathname@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "resolve-pathname@npm:3.0.0"
+  checksum: 6147241ba42c423dbe83cb067a2b4af4f60908c3af57e1ea567729cc71416c089737fe2a73e9e79e7a60f00f66c91e4b45ad0d37cd4be2d43fec44963ef14368
+  languageName: node
+  linkType: hard
+
+"resolve-url-loader@npm:3.1.2":
+  version: 3.1.2
+  resolution: "resolve-url-loader@npm:3.1.2"
+  dependencies:
+    adjust-sourcemap-loader: 3.0.0
+    camelcase: 5.3.1
+    compose-function: 3.0.3
+    convert-source-map: 1.7.0
+    es6-iterator: 2.0.3
+    loader-utils: 1.2.3
+    postcss: 7.0.21
+    rework: 1.0.1
+    rework-visit: 1.0.0
+    source-map: 0.6.1
+  checksum: 02e559af8d10a8fda8d2cb1c61290b932787309309839288820438b4f25339a8c8cbd52598af89c1c1d277133d74914407e7a760e49acd966425a038798a6e70
+  languageName: node
+  linkType: hard
+
+"resolve-url@npm:^0.2.1":
+  version: 0.2.1
+  resolution: "resolve-url@npm:0.2.1"
+  checksum: 7b7035b9ed6e7bc7d289e90aef1eab5a43834539695dac6416ca6e91f1a94132ae4796bbd173cdacfdc2ade90b5f38a3fb6186bebc1b221cd157777a23b9ad14
+  languageName: node
+  linkType: hard
+
+"resolve@npm:1.1.7":
+  version: 1.1.7
+  resolution: "resolve@npm:1.1.7"
+  checksum: afd20873fbde7641c9125efe3f940c2a99f6b1f90f1b7b743e744bdaac1cb105b2e4e0317bcc052ed7e31d57afa86b394a4dc9a1b33a297977be134fdf0250ab
+  languageName: node
+  linkType: hard
+
+"resolve@npm:1.15.0":
+  version: 1.15.0
+  resolution: "resolve@npm:1.15.0"
+  dependencies:
+    path-parse: ^1.0.6
+  checksum: 6d5a48c4ddaad067d7d0d0557c58d0db342d878abccd4843af81ffd439b865157e8bf38029d05fdc02137da5bc7528d5120e3c270b38db2cb26973ffa9e2bebc
+  languageName: node
+  linkType: hard
+
+"resolve@npm:^1.10.0, resolve@npm:^1.12.0, resolve@npm:^1.13.1, resolve@npm:^1.14.2, resolve@npm:^1.15.1, resolve@npm:^1.3.2, resolve@npm:^1.8.1":
+  version: 1.20.0
+  resolution: "resolve@npm:1.20.0"
+  dependencies:
+    is-core-module: ^2.2.0
+    path-parse: ^1.0.6
+  checksum: 40cf70b2cde00ef57f99daf2dc63c6a56d6c14a1b7fc51735d06a6f0a3b97cb67b4fb7ef6c747b4e13a7baba83b0ef625d7c4ce92a483cd5af923c3b65fd16fe
+  languageName: node
+  linkType: hard
+
+"resolve@patch:resolve@1.1.7#~builtin<compat/resolve>":
+  version: 1.1.7
+  resolution: "resolve@patch:resolve@npm%3A1.1.7#~builtin<compat/resolve>::version=1.1.7&hash=07638b"
+  checksum: e9dbca78600ae56835c43a09f1276876c883e4b4bbd43e2683fa140671519d2bdebeb1c1576ca87c8c508ae2987b3ec481645ac5d3054b0f23254cfc1ce49942
+  languageName: node
+  linkType: hard
+
+"resolve@patch:resolve@1.15.0#~builtin<compat/resolve>":
+  version: 1.15.0
+  resolution: "resolve@patch:resolve@npm%3A1.15.0#~builtin<compat/resolve>::version=1.15.0&hash=07638b"
+  dependencies:
+    path-parse: ^1.0.6
+  checksum: fd308e2054e85f829a29e2a5f4634942272b9bfc2771f95fbd1c2068f47a7f4f0f5d4ccb11cb4522f9a8490edd036299f7a85e76327ed8b91bd1a41d314e2bf0
+  languageName: node
+  linkType: hard
+
+"resolve@patch:resolve@^1.10.0#~builtin<compat/resolve>, resolve@patch:resolve@^1.12.0#~builtin<compat/resolve>, resolve@patch:resolve@^1.13.1#~builtin<compat/resolve>, resolve@patch:resolve@^1.14.2#~builtin<compat/resolve>, resolve@patch:resolve@^1.15.1#~builtin<compat/resolve>, resolve@patch:resolve@^1.3.2#~builtin<compat/resolve>, resolve@patch:resolve@^1.8.1#~builtin<compat/resolve>":
+  version: 1.20.0
+  resolution: "resolve@patch:resolve@npm%3A1.20.0#~builtin<compat/resolve>::version=1.20.0&hash=07638b"
+  dependencies:
+    is-core-module: ^2.2.0
+    path-parse: ^1.0.6
+  checksum: a0dd7d16a8e47af23afa9386df2dff10e3e0debb2c7299a42e581d9d9b04d7ad5d2c53f24f1e043f7b3c250cbdc71150063e53d0b6559683d37f790b7c8c3cd5
+  languageName: node
+  linkType: hard
+
+"restore-cursor@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "restore-cursor@npm:3.1.0"
+  dependencies:
+    onetime: ^5.1.0
+    signal-exit: ^3.0.2
+  checksum: f877dd8741796b909f2a82454ec111afb84eb45890eb49ac947d87991379406b3b83ff9673a46012fca0d7844bb989f45cc5b788254cf1a39b6b5a9659de0630
+  languageName: node
+  linkType: hard
+
+"ret@npm:~0.1.10":
+  version: 0.1.15
+  resolution: "ret@npm:0.1.15"
+  checksum: d76a9159eb8c946586567bd934358dfc08a36367b3257f7a3d7255fdd7b56597235af23c6afa0d7f0254159e8051f93c918809962ebd6df24ca2a83dbe4d4151
+  languageName: node
+  linkType: hard
+
+"retry@npm:^0.12.0":
+  version: 0.12.0
+  resolution: "retry@npm:0.12.0"
+  checksum: 623bd7d2e5119467ba66202d733ec3c2e2e26568074923bc0585b6b99db14f357e79bdedb63cab56cec47491c4a0da7e6021a7465ca6dc4f481d3898fdd3158c
+  languageName: node
+  linkType: hard
+
+"reusify@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "reusify@npm:1.0.4"
+  checksum: c3076ebcc22a6bc252cb0b9c77561795256c22b757f40c0d8110b1300723f15ec0fc8685e8d4ea6d7666f36c79ccc793b1939c748bf36f18f542744a4e379fcc
+  languageName: node
+  linkType: hard
+
+"rework-visit@npm:1.0.0":
+  version: 1.0.0
+  resolution: "rework-visit@npm:1.0.0"
+  checksum: 969ca1f4e5bf4a1755c464a9b498da51eb3f28a798cf73da2cf0a3a3ab7b21a2f05c9d3bfa5fb81c8aaf5487dd31679efa67b8d0f418277ef5deb2a230b17c81
+  languageName: node
+  linkType: hard
+
+"rework@npm:1.0.1":
+  version: 1.0.1
+  resolution: "rework@npm:1.0.1"
+  dependencies:
+    convert-source-map: ^0.3.3
+    css: ^2.0.0
+  checksum: 13e5054d81ac84eee488fd4bacd20d08f35683bd8e296b4358e7f0a41b2d30a959313b7794f388f336705ad18d36af6ee7080e1b6c1313ecf33bc51d1bd95971
+  languageName: node
+  linkType: hard
+
+"rfdc@npm:^1.3.0":
+  version: 1.3.1
+  resolution: "rfdc@npm:1.3.1"
+  checksum: d5d1e930aeac7e0e0a485f97db1356e388bdbeff34906d206fe524dd5ada76e95f186944d2e68307183fdc39a54928d4426bbb6734851692cfe9195efba58b79
+  languageName: node
+  linkType: hard
+
+"rgb-regex@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "rgb-regex@npm:1.0.1"
+  checksum: b270ce8bc14782d2d21d3184c1e6c65b465476d8f03e72b93ef57c95710a452b2fe280e1d516c88873aec06efd7f71373e673f114b9d99f3a4f9a0393eb00126
+  languageName: node
+  linkType: hard
+
+"rgba-regex@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "rgba-regex@npm:1.0.0"
+  checksum: 7f2cd271572700faea50753d82524cb2b98f17a5b9722965c7076f6cd674fe545f28145b7ef2cccabc9eca2475c793db16862cd5e7b3784a9f4b8d6496431057
+  languageName: node
+  linkType: hard
+
+"rimraf@npm:2, rimraf@npm:^2.5.4, rimraf@npm:^2.6.3, rimraf@npm:^2.7.1":
+  version: 2.7.1
+  resolution: "rimraf@npm:2.7.1"
+  dependencies:
+    glob: ^7.1.3
+  bin:
+    rimraf: ./bin.js
+  checksum: cdc7f6eacb17927f2a075117a823e1c5951792c6498ebcce81ca8203454a811d4cf8900314154d3259bb8f0b42ab17f67396a8694a54cae3283326e57ad250cd
+  languageName: node
+  linkType: hard
+
+"rimraf@npm:2.6.3":
+  version: 2.6.3
+  resolution: "rimraf@npm:2.6.3"
+  dependencies:
+    glob: ^7.1.3
+  bin:
+    rimraf: ./bin.js
+  checksum: 3ea587b981a19016297edb96d1ffe48af7e6af69660e3b371dbfc73722a73a0b0e9be5c88089fbeeb866c389c1098e07f64929c7414290504b855f54f901ab10
+  languageName: node
+  linkType: hard
+
+"rimraf@npm:^3.0.0, rimraf@npm:^3.0.2":
+  version: 3.0.2
+  resolution: "rimraf@npm:3.0.2"
+  dependencies:
+    glob: ^7.1.3
+  bin:
+    rimraf: bin.js
+  checksum: 87f4164e396f0171b0a3386cc1877a817f572148ee13a7e113b238e48e8a9f2f31d009a92ec38a591ff1567d9662c6b67fd8818a2dbbaed74bc26a87a2a4a9a0
+  languageName: node
+  linkType: hard
+
+"ripemd160@npm:^2.0.0, ripemd160@npm:^2.0.1":
+  version: 2.0.2
+  resolution: "ripemd160@npm:2.0.2"
+  dependencies:
+    hash-base: ^3.0.0
+    inherits: ^2.0.1
+  checksum: 006accc40578ee2beae382757c4ce2908a826b27e2b079efdcd2959ee544ddf210b7b5d7d5e80467807604244e7388427330f5c6d4cd61e6edaddc5773ccc393
+  languageName: node
+  linkType: hard
+
+"rst-selector-parser@npm:^2.2.3":
+  version: 2.2.3
+  resolution: "rst-selector-parser@npm:2.2.3"
+  dependencies:
+    lodash.flattendeep: ^4.4.0
+    nearley: ^2.7.10
+  checksum: fbfb2f6a7d4c9b3e013ef555ac06e5dba444e0d37dc959b94c507b6c34093ef10fe98141338d9cac58e5ae0f9453a5ef7f85af3d5e6386b237c1b3552debe4a0
+  languageName: node
+  linkType: hard
+
+"rsvp@npm:^4.8.4":
+  version: 4.8.5
+  resolution: "rsvp@npm:4.8.5"
+  checksum: 2d8ef30d8febdf05bdf856ccca38001ae3647e41835ca196bc1225333f79b94ae44def733121ca549ccc36209c9b689f6586905e2a043873262609744da8efc1
+  languageName: node
+  linkType: hard
+
+"run-async@npm:^2.2.0, run-async@npm:^2.4.0":
+  version: 2.4.1
+  resolution: "run-async@npm:2.4.1"
+  checksum: a2c88aa15df176f091a2878eb840e68d0bdee319d8d97bbb89112223259cebecb94bc0defd735662b83c2f7a30bed8cddb7d1674eb48ae7322dc602b22d03797
+  languageName: node
+  linkType: hard
+
+"run-parallel@npm:^1.1.9":
+  version: 1.2.0
+  resolution: "run-parallel@npm:1.2.0"
+  dependencies:
+    queue-microtask: ^1.2.2
+  checksum: cb4f97ad25a75ebc11a8ef4e33bb962f8af8516bb2001082ceabd8902e15b98f4b84b4f8a9b222e5d57fc3bd1379c483886ed4619367a7680dad65316993021d
+  languageName: node
+  linkType: hard
+
+"run-queue@npm:^1.0.0, run-queue@npm:^1.0.3":
+  version: 1.0.3
+  resolution: "run-queue@npm:1.0.3"
+  dependencies:
+    aproba: ^1.1.1
+  checksum: c4541e18b5e056af60f398f2f1b3d89aae5c093d1524bf817c5ee68bcfa4851ad9976f457a9aea135b1d0d72ee9a91c386e3d136bcd95b699c367cd09c70be53
+  languageName: node
+  linkType: hard
+
+"rxjs@npm:^6.5.3, rxjs@npm:^6.5.5, rxjs@npm:^6.6.0":
+  version: 6.6.7
+  resolution: "rxjs@npm:6.6.7"
+  dependencies:
+    tslib: ^1.9.0
+  checksum: bc334edef1bb8bbf56590b0b25734ba0deaf8825b703256a93714308ea36dff8a11d25533671adf8e104e5e8f256aa6fdfe39b2e248cdbd7a5f90c260acbbd1b
+  languageName: node
+  linkType: hard
+
+"rxjs@npm:^7.5.1":
+  version: 7.8.1
+  resolution: "rxjs@npm:7.8.1"
+  dependencies:
+    tslib: ^2.1.0
+  checksum: de4b53db1063e618ec2eca0f7965d9137cabe98cf6be9272efe6c86b47c17b987383df8574861bcced18ebd590764125a901d5506082be84a8b8e364bf05f119
+  languageName: node
+  linkType: hard
+
+"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1":
+  version: 5.1.2
+  resolution: "safe-buffer@npm:5.1.2"
+  checksum: f2f1f7943ca44a594893a852894055cf619c1fbcb611237fc39e461ae751187e7baf4dc391a72125e0ac4fb2d8c5c0b3c71529622e6a58f46b960211e704903c
+  languageName: node
+  linkType: hard
+
+"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0":
+  version: 5.2.1
+  resolution: "safe-buffer@npm:5.2.1"
+  checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491
+  languageName: node
+  linkType: hard
+
+"safe-regex@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "safe-regex@npm:1.1.0"
+  dependencies:
+    ret: ~0.1.10
+  checksum: 9a8bba57c87a841f7997b3b951e8e403b1128c1a4fd1182f40cc1a20e2d490593d7c2a21030fadfea320c8e859219019e136f678c6689ed5960b391b822f01d5
+  languageName: node
+  linkType: hard
+
+"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:^2.0.2, safer-buffer@npm:^2.1.0, safer-buffer@npm:~2.1.0":
+  version: 2.1.2
+  resolution: "safer-buffer@npm:2.1.2"
+  checksum: cab8f25ae6f1434abee8d80023d7e72b598cf1327164ddab31003c51215526801e40b66c5e65d658a0af1e9d6478cadcb4c745f4bd6751f97d8644786c0978b0
+  languageName: node
+  linkType: hard
+
+"sane@npm:^4.0.3":
+  version: 4.1.0
+  resolution: "sane@npm:4.1.0"
+  dependencies:
+    "@cnakazawa/watch": ^1.0.3
+    anymatch: ^2.0.0
+    capture-exit: ^2.0.0
+    exec-sh: ^0.3.2
+    execa: ^1.0.0
+    fb-watchman: ^2.0.0
+    micromatch: ^3.1.4
+    minimist: ^1.1.1
+    walker: ~1.0.5
+  bin:
+    sane: ./src/cli.js
+  checksum: 97716502d456c0d38670a902a4ea943d196dcdf998d1e40532d8f3e24e25d7eddfd4c3579025a1eee8eac09a48dfd05fba61a2156c56704e7feaa450eb249f7c
+  languageName: node
+  linkType: hard
+
+"sanitize.css@npm:^10.0.0":
+  version: 10.0.0
+  resolution: "sanitize.css@npm:10.0.0"
+  checksum: 99932e53e864b83562a421f57383c9747ab03c51872437eb56170639cd6c634a945517e25d1b7005d10c8dc863f71c61c573e3452474d4ef25bcf5f7344e4ce3
+  languageName: node
+  linkType: hard
+
+"sass-graph@npm:^2.2.4":
+  version: 2.2.6
+  resolution: "sass-graph@npm:2.2.6"
+  dependencies:
+    glob: ^7.0.0
+    lodash: ^4.0.0
+    scss-tokenizer: ^0.2.3
+    yargs: ^7.0.0
+  bin:
+    sassgraph: bin/sassgraph
+  checksum: 1fb1719c659fdea00a9f55be9722c5902c3d1f1a0919d2e5ceb8a318064f2b214981d98b7d7fecaafc25f522302f919a948351e4ae1d1680b9c045d563550a93
+  languageName: node
+  linkType: hard
+
+"sass-graph@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "sass-graph@npm:4.0.1"
+  dependencies:
+    glob: ^7.0.0
+    lodash: ^4.17.11
+    scss-tokenizer: ^0.4.3
+    yargs: ^17.2.1
+  bin:
+    sassgraph: bin/sassgraph
+  checksum: 896f99253bd77a429a95e483ebddee946e195b61d3f84b3e1ccf8ad843265ec0585fa40bf55fbf354c5f57eb9fd0349834a8b190cd2161ab1234cb9af10e3601
+  languageName: node
+  linkType: hard
+
+"sass-loader@npm:8.0.2":
+  version: 8.0.2
+  resolution: "sass-loader@npm:8.0.2"
+  dependencies:
+    clone-deep: ^4.0.1
+    loader-utils: ^1.2.3
+    neo-async: ^2.6.1
+    schema-utils: ^2.6.1
+    semver: ^6.3.0
+  peerDependencies:
+    fibers: ">= 3.1.0"
+    node-sass: ^4.0.0
+    sass: ^1.3.0
+    webpack: ^4.36.0 || ^5.0.0
+  peerDependenciesMeta:
+    fibers:
+      optional: true
+    node-sass:
+      optional: true
+    sass:
+      optional: true
+  checksum: 3e9ba97432fcf1092600a31501298f37a0a913f86086b841740f9f8371ee33de55b9740b31563b089524aeb9020fbc51126730fa51d18b2e724a4ada71e2ff81
+  languageName: node
+  linkType: hard
+
+"sax@npm:^1.2.4, sax@npm:~1.2.4":
+  version: 1.2.4
+  resolution: "sax@npm:1.2.4"
+  checksum: d3df7d32b897a2c2f28e941f732c71ba90e27c24f62ee918bd4d9a8cfb3553f2f81e5493c7f0be94a11c1911b643a9108f231dd6f60df3fa9586b5d2e3e9e1fe
+  languageName: node
+  linkType: hard
+
+"saxes@npm:^3.1.9":
+  version: 3.1.11
+  resolution: "saxes@npm:3.1.11"
+  dependencies:
+    xmlchars: ^2.1.1
+  checksum: 3b69918c013fffae51c561f629a0f620c02dba70f762dab38f3cd92676dfe5edf1f0a523ca567882838f1a80e26e4671a8c2c689afa05c68f45a78261445aba0
+  languageName: node
+  linkType: hard
+
+"scheduler@npm:^0.19.1":
+  version: 0.19.1
+  resolution: "scheduler@npm:0.19.1"
+  dependencies:
+    loose-envify: ^1.1.0
+    object-assign: ^4.1.1
+  checksum: 73e185a59e2ff5aa3609f5b9cb97ddd376f89e1610579d29939d952411ca6eb7a24907a4ea4556569dacb931467a1a4a56d94fe809ef713aa76748642cd96a6c
+  languageName: node
+  linkType: hard
+
+"schema-utils@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "schema-utils@npm:1.0.0"
+  dependencies:
+    ajv: ^6.1.0
+    ajv-errors: ^1.0.0
+    ajv-keywords: ^3.1.0
+  checksum: e8273b4f6eff9ddf4a4f4c11daf7b96b900237bf8859c86fa1e9b4fab416b72d7ea92468f8db89c18a3499a1070206e1c8a750c83b42d5325fc659cbb55eee88
+  languageName: node
+  linkType: hard
+
+"schema-utils@npm:^2.5.0, schema-utils@npm:^2.6.0, schema-utils@npm:^2.6.1, schema-utils@npm:^2.6.5, schema-utils@npm:^2.6.6":
+  version: 2.7.1
+  resolution: "schema-utils@npm:2.7.1"
+  dependencies:
+    "@types/json-schema": ^7.0.5
+    ajv: ^6.12.4
+    ajv-keywords: ^3.5.2
+  checksum: 32c62fc9e28edd101e1bd83453a4216eb9bd875cc4d3775e4452b541908fa8f61a7bbac8ffde57484f01d7096279d3ba0337078e85a918ecbeb72872fb09fb2b
+  languageName: node
+  linkType: hard
+
+"scss-tokenizer@npm:^0.2.3":
+  version: 0.2.3
+  resolution: "scss-tokenizer@npm:0.2.3"
+  dependencies:
+    js-base64: ^2.1.8
+    source-map: ^0.4.2
+  checksum: ad78bba4466ff7aa6449931a57a980479223c3cad9eccf2180251c2f6fce5b3d982a51f924709e0a0bb2d328dedbb2fad0ccb2a5fdc175513a27cb4e8cf8cfd2
+  languageName: node
+  linkType: hard
+
+"scss-tokenizer@npm:^0.4.3":
+  version: 0.4.3
+  resolution: "scss-tokenizer@npm:0.4.3"
+  dependencies:
+    js-base64: ^2.4.9
+    source-map: ^0.7.3
+  checksum: f3697bb155ae23d88c7cd0275988a73231fe675fbbd250b4e56849ba66319fc249a597f3799a92f9890b12007f00f8f6a7f441283e634679e2acdb2287a341d1
+  languageName: node
+  linkType: hard
+
+"select-hose@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "select-hose@npm:2.0.0"
+  checksum: d7e5fcc695a4804209d232a1b18624a5134be334d4e1114b0721f7a5e72bd73da483dcf41528c1af4f4f4892ad7cfd6a1e55c8ffb83f9c9fe723b738db609dbb
+  languageName: node
+  linkType: hard
+
+"selfsigned@npm:^1.10.7":
+  version: 1.10.11
+  resolution: "selfsigned@npm:1.10.11"
+  dependencies:
+    node-forge: ^0.10.0
+  checksum: 1fd8fd317dc0b7d713d12d828131ac03c53abf41c4538b263fecd37bbc15688526c631654049ff00806b757ccb85492de6a13d6fefcad5cb54926631e48a76e1
+  languageName: node
+  linkType: hard
+
+"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.3.0, semver@npm:^5.4.1, semver@npm:^5.5.0, semver@npm:^5.5.1, semver@npm:^5.6.0, semver@npm:^5.7.0, semver@npm:^5.7.1":
+  version: 5.7.2
+  resolution: "semver@npm:5.7.2"
+  bin:
+    semver: bin/semver
+  checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686
+  languageName: node
+  linkType: hard
+
+"semver@npm:6.3.0":
+  version: 6.3.0
+  resolution: "semver@npm:6.3.0"
+  bin:
+    semver: ./bin/semver.js
+  checksum: 1b26ecf6db9e8292dd90df4e781d91875c0dcc1b1909e70f5d12959a23c7eebb8f01ea581c00783bbee72ceeaad9505797c381756326073850dc36ed284b21b9
+  languageName: node
+  linkType: hard
+
+"semver@npm:7.0.0":
+  version: 7.0.0
+  resolution: "semver@npm:7.0.0"
+  bin:
+    semver: bin/semver.js
+  checksum: 272c11bf8d083274ef79fe40a81c55c184dff84dd58e3c325299d0927ba48cece1f020793d138382b85f89bab5002a35a5ba59a3a68a7eebbb597eb733838778
+  languageName: node
+  linkType: hard
+
+"semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0, semver@npm:^6.3.1":
+  version: 6.3.1
+  resolution: "semver@npm:6.3.1"
+  bin:
+    semver: bin/semver.js
+  checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2
+  languageName: node
+  linkType: hard
+
+"semver@npm:^7.3.4, semver@npm:^7.3.5":
+  version: 7.5.4
+  resolution: "semver@npm:7.5.4"
+  dependencies:
+    lru-cache: ^6.0.0
+  bin:
+    semver: bin/semver.js
+  checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3
+  languageName: node
+  linkType: hard
+
+"semver@npm:^7.5.3":
+  version: 7.6.0
+  resolution: "semver@npm:7.6.0"
+  dependencies:
+    lru-cache: ^6.0.0
+  bin:
+    semver: bin/semver.js
+  checksum: 7427f05b70786c696640edc29fdd4bc33b2acf3bbe1740b955029044f80575fc664e1a512e4113c3af21e767154a94b4aa214bf6cd6e42a1f6dba5914e0b208c
+  languageName: node
+  linkType: hard
+
+"send@npm:0.18.0":
+  version: 0.18.0
+  resolution: "send@npm:0.18.0"
+  dependencies:
+    debug: 2.6.9
+    depd: 2.0.0
+    destroy: 1.2.0
+    encodeurl: ~1.0.2
+    escape-html: ~1.0.3
+    etag: ~1.8.1
+    fresh: 0.5.2
+    http-errors: 2.0.0
+    mime: 1.6.0
+    ms: 2.1.3
+    on-finished: 2.4.1
+    range-parser: ~1.2.1
+    statuses: 2.0.1
+  checksum: 74fc07ebb58566b87b078ec63e5a3e41ecd987e4272ba67b7467e86c6ad51bc6b0b0154133b6d8b08a2ddda360464f71382f7ef864700f34844a76c8027817a8
+  languageName: node
+  linkType: hard
+
+"serialize-javascript@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "serialize-javascript@npm:4.0.0"
+  dependencies:
+    randombytes: ^2.1.0
+  checksum: 3273b3394b951671fcf388726e9577021870dfbf85e742a1183fb2e91273e6101bdccea81ff230724f6659a7ee4cef924b0ff9baca32b79d9384ec37caf07302
+  languageName: node
+  linkType: hard
+
+"serve-index@npm:^1.9.1":
+  version: 1.9.1
+  resolution: "serve-index@npm:1.9.1"
+  dependencies:
+    accepts: ~1.3.4
+    batch: 0.6.1
+    debug: 2.6.9
+    escape-html: ~1.0.3
+    http-errors: ~1.6.2
+    mime-types: ~2.1.17
+    parseurl: ~1.3.2
+  checksum: e2647ce13379485b98a53ba2ea3fbad4d44b57540d00663b02b976e426e6194d62ac465c0d862cb7057f65e0de8ab8a684aa095427a4b8612412eca0d300d22f
+  languageName: node
+  linkType: hard
+
+"serve-static@npm:1.15.0":
+  version: 1.15.0
+  resolution: "serve-static@npm:1.15.0"
+  dependencies:
+    encodeurl: ~1.0.2
+    escape-html: ~1.0.3
+    parseurl: ~1.3.3
+    send: 0.18.0
+  checksum: af57fc13be40d90a12562e98c0b7855cf6e8bd4c107fe9a45c212bf023058d54a1871b1c89511c3958f70626fff47faeb795f5d83f8cf88514dbaeb2b724464d
+  languageName: node
+  linkType: hard
+
+"set-blocking@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "set-blocking@npm:2.0.0"
+  checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02
+  languageName: node
+  linkType: hard
+
+"set-value@npm:2.0.1, set-value@npm:^2.0.0, set-value@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "set-value@npm:2.0.1"
+  dependencies:
+    extend-shallow: ^2.0.1
+    is-extendable: ^0.1.1
+    is-plain-object: ^2.0.3
+    split-string: ^3.0.1
+  checksum: 09a4bc72c94641aeae950eb60dc2755943b863780fcc32e441eda964b64df5e3f50603d5ebdd33394ede722528bd55ed43aae26e9df469b4d32e2292b427b601
+  languageName: node
+  linkType: hard
+
+"setimmediate@npm:^1.0.4, setimmediate@npm:^1.0.5":
+  version: 1.0.5
+  resolution: "setimmediate@npm:1.0.5"
+  checksum: c9a6f2c5b51a2dabdc0247db9c46460152ffc62ee139f3157440bd48e7c59425093f42719ac1d7931f054f153e2d26cf37dfeb8da17a794a58198a2705e527fd
+  languageName: node
+  linkType: hard
+
+"setprototypeof@npm:1.1.0":
+  version: 1.1.0
+  resolution: "setprototypeof@npm:1.1.0"
+  checksum: 27cb44304d6c9e1a23bc6c706af4acaae1a7aa1054d4ec13c05f01a99fd4887109a83a8042b67ad90dbfcd100d43efc171ee036eb080667172079213242ca36e
+  languageName: node
+  linkType: hard
+
+"setprototypeof@npm:1.2.0":
+  version: 1.2.0
+  resolution: "setprototypeof@npm:1.2.0"
+  checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89
+  languageName: node
+  linkType: hard
+
+"sha.js@npm:^2.4.0, sha.js@npm:^2.4.8":
+  version: 2.4.11
+  resolution: "sha.js@npm:2.4.11"
+  dependencies:
+    inherits: ^2.0.1
+    safe-buffer: ^5.0.1
+  bin:
+    sha.js: ./bin.js
+  checksum: ebd3f59d4b799000699097dadb831c8e3da3eb579144fd7eb7a19484cbcbb7aca3c68ba2bb362242eb09e33217de3b4ea56e4678184c334323eca24a58e3ad07
+  languageName: node
+  linkType: hard
+
+"shallow-clone@npm:^0.1.2":
+  version: 0.1.2
+  resolution: "shallow-clone@npm:0.1.2"
+  dependencies:
+    is-extendable: ^0.1.1
+    kind-of: ^2.0.1
+    lazy-cache: ^0.2.3
+    mixin-object: ^2.0.1
+  checksum: cc4c85c6e42186fec33a81a85622c48dbcfdf280f3a7bd0800b4de57df8e365a8760aa2e31dd79df365b317dddb2fd0bbd92be0aab14dbd2de6a65992eab2177
+  languageName: node
+  linkType: hard
+
+"shallow-clone@npm:^3.0.0":
+  version: 3.0.1
+  resolution: "shallow-clone@npm:3.0.1"
+  dependencies:
+    kind-of: ^6.0.2
+  checksum: 39b3dd9630a774aba288a680e7d2901f5c0eae7b8387fc5c8ea559918b29b3da144b7bdb990d7ccd9e11be05508ac9e459ce51d01fd65e583282f6ffafcba2e7
+  languageName: node
+  linkType: hard
+
+"shallowequal@npm:1.1.0, shallowequal@npm:^1.0.2":
+  version: 1.1.0
+  resolution: "shallowequal@npm:1.1.0"
+  checksum: f4c1de0837f106d2dbbfd5d0720a5d059d1c66b42b580965c8f06bb1db684be8783538b684092648c981294bf817869f743a066538771dbecb293df78f765e00
+  languageName: node
+  linkType: hard
+
+"shebang-command@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "shebang-command@npm:1.2.0"
+  dependencies:
+    shebang-regex: ^1.0.0
+  checksum: 9eed1750301e622961ba5d588af2212505e96770ec376a37ab678f965795e995ade7ed44910f5d3d3cb5e10165a1847f52d3348c64e146b8be922f7707958908
+  languageName: node
+  linkType: hard
+
+"shebang-command@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "shebang-command@npm:2.0.0"
+  dependencies:
+    shebang-regex: ^3.0.0
+  checksum: 6b52fe87271c12968f6a054e60f6bde5f0f3d2db483a1e5c3e12d657c488a15474121a1d55cd958f6df026a54374ec38a4a963988c213b7570e1d51575cea7fa
+  languageName: node
+  linkType: hard
+
+"shebang-regex@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "shebang-regex@npm:1.0.0"
+  checksum: 404c5a752cd40f94591dfd9346da40a735a05139dac890ffc229afba610854d8799aaa52f87f7e0c94c5007f2c6af55bdcaeb584b56691926c5eaf41dc8f1372
+  languageName: node
+  linkType: hard
+
+"shebang-regex@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "shebang-regex@npm:3.0.0"
+  checksum: 1a2bcae50de99034fcd92ad4212d8e01eedf52c7ec7830eedcf886622804fe36884278f2be8be0ea5fde3fd1c23911643a4e0f726c8685b61871c8908af01222
+  languageName: node
+  linkType: hard
+
+"shell-escape@npm:^0.2.0":
+  version: 0.2.0
+  resolution: "shell-escape@npm:0.2.0"
+  checksum: 0d87f1ae22ad22a74e148348ceaf64721e3024f83c90afcfb527318ce10ece654dd62e103dd89a242f2f4e4ce3cecdef63e3d148c40e5fabca8ba6c508f97d9f
+  languageName: node
+  linkType: hard
+
+"shell-quote@npm:1.7.2":
+  version: 1.7.2
+  resolution: "shell-quote@npm:1.7.2"
+  checksum: efad426fb25d8a54d06363f1f45774aa9e195f62f14fa696d542b44bfe418ab41206448b63af18d726c62e099e66d9a3f4f44858b9ea2ce4b794b41b802672d1
+  languageName: node
+  linkType: hard
+
+"shellwords@npm:^0.1.1":
+  version: 0.1.1
+  resolution: "shellwords@npm:0.1.1"
+  checksum: 8d73a5e9861f5e5f1068e2cfc39bc0002400fe58558ab5e5fa75630d2c3adf44ca1fac81957609c8320d5533e093802fcafc72904bf1a32b95de3c19a0b1c0d4
+  languageName: node
+  linkType: hard
+
+"side-channel@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "side-channel@npm:1.0.4"
+  dependencies:
+    call-bind: ^1.0.0
+    get-intrinsic: ^1.0.2
+    object-inspect: ^1.9.0
+  checksum: 351e41b947079c10bd0858364f32bb3a7379514c399edb64ab3dce683933483fc63fb5e4efe0a15a2e8a7e3c436b6a91736ddb8d8c6591b0460a24bb4a1ee245
+  languageName: node
+  linkType: hard
+
+"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2":
+  version: 3.0.3
+  resolution: "signal-exit@npm:3.0.3"
+  checksum: f0169d3f1263d06df32ca072b0bf33b34c6f8f0341a7a1621558a2444dfbe8f5fec76b35537fcc6f0bc4944bdb5336fe0bdcf41a5422c4e45a1dba3f45475e6c
+  languageName: node
+  linkType: hard
+
+"signal-exit@npm:^3.0.7":
+  version: 3.0.7
+  resolution: "signal-exit@npm:3.0.7"
+  checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318
+  languageName: node
+  linkType: hard
+
+"simple-swizzle@npm:^0.2.2":
+  version: 0.2.2
+  resolution: "simple-swizzle@npm:0.2.2"
+  dependencies:
+    is-arrayish: ^0.3.1
+  checksum: a7f3f2ab5c76c4472d5c578df892e857323e452d9f392e1b5cf74b74db66e6294a1e1b8b390b519fa1b96b5b613f2a37db6cffef52c3f1f8f3c5ea64eb2d54c0
+  languageName: node
+  linkType: hard
+
+"sinon@npm:7.3":
+  version: 7.3.2
+  resolution: "sinon@npm:7.3.2"
+  dependencies:
+    "@sinonjs/commons": ^1.4.0
+    "@sinonjs/formatio": ^3.2.1
+    "@sinonjs/samsam": ^3.3.1
+    diff: ^3.5.0
+    lolex: ^4.0.1
+    nise: ^1.4.10
+    supports-color: ^5.5.0
+  checksum: d200d21203b6ca4b4d4c67a2995f3f89a616f2a8c95d8025c6fa194ffcca3cb1ed7eed7a27493a9b33850df859c374e64f279a0dfb1aa67adbe171d72163daf5
+  languageName: node
+  linkType: hard
+
+"sisteransi@npm:^1.0.5":
+  version: 1.0.5
+  resolution: "sisteransi@npm:1.0.5"
+  checksum: aba6438f46d2bfcef94cf112c835ab395172c75f67453fe05c340c770d3c402363018ae1ab4172a1026a90c47eaccf3af7b6ff6fa749a680c2929bd7fa2b37a4
+  languageName: node
+  linkType: hard
+
+"slash@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "slash@npm:1.0.0"
+  checksum: 4b6e21b1fba6184a7e2efb1dd173f692d8a845584c1bbf9dc818ff86f5a52fc91b413008223d17cc684604ee8bb9263a420b1182027ad9762e35388434918860
+  languageName: node
+  linkType: hard
+
+"slash@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "slash@npm:2.0.0"
+  checksum: 512d4350735375bd11647233cb0e2f93beca6f53441015eea241fe784d8068281c3987fbaa93e7ef1c38df68d9c60013045c92837423c69115297d6169aa85e6
+  languageName: node
+  linkType: hard
+
+"slash@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "slash@npm:3.0.0"
+  checksum: 94a93fff615f25a999ad4b83c9d5e257a7280c90a32a7cb8b4a87996e4babf322e469c42b7f649fd5796edd8687652f3fb452a86dc97a816f01113183393f11c
+  languageName: node
+  linkType: hard
+
+"slice-ansi@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "slice-ansi@npm:2.1.0"
+  dependencies:
+    ansi-styles: ^3.2.0
+    astral-regex: ^1.0.0
+    is-fullwidth-code-point: ^2.0.0
+  checksum: 4e82995aa59cef7eb03ef232d73c2239a15efa0ace87a01f3012ebb942e963fbb05d448ce7391efcd52ab9c32724164aba2086f5143e0445c969221dde3b6b1e
+  languageName: node
+  linkType: hard
+
+"slice-ansi@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "slice-ansi@npm:3.0.0"
+  dependencies:
+    ansi-styles: ^4.0.0
+    astral-regex: ^2.0.0
+    is-fullwidth-code-point: ^3.0.0
+  checksum: 5ec6d022d12e016347e9e3e98a7eb2a592213a43a65f1b61b74d2c78288da0aded781f665807a9f3876b9daa9ad94f64f77d7633a0458876c3a4fdc4eb223f24
+  languageName: node
+  linkType: hard
+
+"slice-ansi@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "slice-ansi@npm:4.0.0"
+  dependencies:
+    ansi-styles: ^4.0.0
+    astral-regex: ^2.0.0
+    is-fullwidth-code-point: ^3.0.0
+  checksum: 4a82d7f085b0e1b070e004941ada3c40d3818563ac44766cca4ceadd2080427d337554f9f99a13aaeb3b4a94d9964d9466c807b3d7b7541d1ec37ee32d308756
+  languageName: node
+  linkType: hard
+
+"smart-buffer@npm:^4.2.0":
+  version: 4.2.0
+  resolution: "smart-buffer@npm:4.2.0"
+  checksum: b5167a7142c1da704c0e3af85c402002b597081dd9575031a90b4f229ca5678e9a36e8a374f1814c8156a725d17008ae3bde63b92f9cfd132526379e580bec8b
+  languageName: node
+  linkType: hard
+
+"snapdragon-node@npm:^2.0.1":
+  version: 2.1.1
+  resolution: "snapdragon-node@npm:2.1.1"
+  dependencies:
+    define-property: ^1.0.0
+    isobject: ^3.0.0
+    snapdragon-util: ^3.0.1
+  checksum: 9bb57d759f9e2a27935dbab0e4a790137adebace832b393e350a8bf5db461ee9206bb642d4fe47568ee0b44080479c8b4a9ad0ebe3712422d77edf9992a672fd
+  languageName: node
+  linkType: hard
+
+"snapdragon-util@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "snapdragon-util@npm:3.0.1"
+  dependencies:
+    kind-of: ^3.2.0
+  checksum: 684997dbe37ec995c03fd3f412fba2b711fc34cb4010452b7eb668be72e8811a86a12938b511e8b19baf853b325178c56d8b78d655305e5cfb0bb8b21677e7b7
+  languageName: node
+  linkType: hard
+
+"snapdragon@npm:^0.8.1":
+  version: 0.8.2
+  resolution: "snapdragon@npm:0.8.2"
+  dependencies:
+    base: ^0.11.1
+    debug: ^2.2.0
+    define-property: ^0.2.5
+    extend-shallow: ^2.0.1
+    map-cache: ^0.2.2
+    source-map: ^0.5.6
+    source-map-resolve: ^0.5.0
+    use: ^3.1.0
+  checksum: a197f242a8f48b11036563065b2487e9b7068f50a20dd81d9161eca6af422174fc158b8beeadbe59ce5ef172aa5718143312b3aebaae551c124b7824387c8312
+  languageName: node
+  linkType: hard
+
+"sockjs-client@npm:1.4.0":
+  version: 1.4.0
+  resolution: "sockjs-client@npm:1.4.0"
+  dependencies:
+    debug: ^3.2.5
+    eventsource: ^1.0.7
+    faye-websocket: ~0.11.1
+    inherits: ^2.0.3
+    json3: ^3.3.2
+    url-parse: ^1.4.3
+  checksum: 42fabe709b5478ca50f483add67e058ab01c5aaae926d73e483e53f26c14edc0820cdbd420e3bbc4e090c1007bf21c054b800a7a1e275b171352f246df1300a3
+  languageName: node
+  linkType: hard
+
+"sockjs@npm:0.3.20":
+  version: 0.3.20
+  resolution: "sockjs@npm:0.3.20"
+  dependencies:
+    faye-websocket: ^0.10.0
+    uuid: ^3.4.0
+    websocket-driver: 0.6.5
+  checksum: dc0ac013ab57bae5b5b9e3ca809ce06b7f19ade8de47d48a5919e2b6889a864705bce300f9ad02a969d57fea0c911fdcbacdea5e66aec2bc2638b3c8b1c2ede8
+  languageName: node
+  linkType: hard
+
+"socks-proxy-agent@npm:^6.0.0":
+  version: 6.2.1
+  resolution: "socks-proxy-agent@npm:6.2.1"
+  dependencies:
+    agent-base: ^6.0.2
+    debug: ^4.3.3
+    socks: ^2.6.2
+  checksum: 9ca089d489e5ee84af06741135c4b0d2022977dad27ac8d649478a114cdce87849e8d82b7c22b51501a4116e231241592946fc7fae0afc93b65030ee57084f58
+  languageName: node
+  linkType: hard
+
+"socks-proxy-agent@npm:^6.1.1":
+  version: 6.1.1
+  resolution: "socks-proxy-agent@npm:6.1.1"
+  dependencies:
+    agent-base: ^6.0.2
+    debug: ^4.3.1
+    socks: ^2.6.1
+  checksum: 9a8a4f791bba0060315cf7291ca6f9db37d6fc280fd0860d73d8887d3efe4c22e823aa25a8d5375f6079279f8dc91b50c075345179bf832bfe3c7c26d3582e3c
+  languageName: node
+  linkType: hard
+
+"socks-proxy-agent@npm:^7.0.0":
+  version: 7.0.0
+  resolution: "socks-proxy-agent@npm:7.0.0"
+  dependencies:
+    agent-base: ^6.0.2
+    debug: ^4.3.3
+    socks: ^2.6.2
+  checksum: 720554370154cbc979e2e9ce6a6ec6ced205d02757d8f5d93fe95adae454fc187a5cbfc6b022afab850a5ce9b4c7d73e0f98e381879cf45f66317a4895953846
+  languageName: node
+  linkType: hard
+
+"socks@npm:^2.6.1":
+  version: 2.6.2
+  resolution: "socks@npm:2.6.2"
+  dependencies:
+    ip: ^1.1.5
+    smart-buffer: ^4.2.0
+  checksum: dd9194293059d737759d5c69273850ad4149f448426249325c4bea0e340d1cf3d266c3b022694b0dcf5d31f759de23657244c481fc1e8322add80b7985c36b5e
+  languageName: node
+  linkType: hard
+
+"socks@npm:^2.6.2":
+  version: 2.7.1
+  resolution: "socks@npm:2.7.1"
+  dependencies:
+    ip: ^2.0.0
+    smart-buffer: ^4.2.0
+  checksum: 259d9e3e8e1c9809a7f5c32238c3d4d2a36b39b83851d0f573bfde5f21c4b1288417ce1af06af1452569cd1eb0841169afd4998f0e04ba04656f6b7f0e46d748
+  languageName: node
+  linkType: hard
+
+"sort-keys@npm:^1.0.0":
+  version: 1.1.2
+  resolution: "sort-keys@npm:1.1.2"
+  dependencies:
+    is-plain-obj: ^1.0.0
+  checksum: 5963fd191a2a185a5ec86f06e47721e8e04713eda43bb04ae60d2a8afb21241553dd5bc9d863ed2bd7c3d541b609b0c8d0e58836b1a3eb6764c09c094bcc8b00
+  languageName: node
+  linkType: hard
+
+"source-list-map@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "source-list-map@npm:2.0.1"
+  checksum: 806efc6f75e7cd31e4815e7a3aaf75a45c704871ea4075cb2eb49882c6fca28998f44fc5ac91adb6de03b2882ee6fb02f951fdc85e6a22b338c32bfe19557938
+  languageName: node
+  linkType: hard
+
+"source-map-js@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "source-map-js@npm:1.0.2"
+  checksum: c049a7fc4deb9a7e9b481ae3d424cc793cb4845daa690bc5a05d428bf41bf231ced49b4cf0c9e77f9d42fdb3d20d6187619fc586605f5eabe995a316da8d377c
+  languageName: node
+  linkType: hard
+
+"source-map-resolve@npm:^0.5.0, source-map-resolve@npm:^0.5.2":
+  version: 0.5.3
+  resolution: "source-map-resolve@npm:0.5.3"
+  dependencies:
+    atob: ^2.1.2
+    decode-uri-component: ^0.2.0
+    resolve-url: ^0.2.1
+    source-map-url: ^0.4.0
+    urix: ^0.1.0
+  checksum: c73fa44ac00783f025f6ad9e038ab1a2e007cd6a6b86f47fe717c3d0765b4a08d264f6966f3bd7cd9dbcd69e4832783d5472e43247775b2a550d6f2155d24bae
+  languageName: node
+  linkType: hard
+
+"source-map-support@npm:^0.5.6, source-map-support@npm:~0.5.12":
+  version: 0.5.19
+  resolution: "source-map-support@npm:0.5.19"
+  dependencies:
+    buffer-from: ^1.0.0
+    source-map: ^0.6.0
+  checksum: c72802fdba9cb62b92baef18cc14cc4047608b77f0353e6c36dd993444149a466a2845332c5540d4a6630957254f0f68f4ef5a0120c33d2e83974c51a05afbac
+  languageName: node
+  linkType: hard
+
+"source-map-url@npm:^0.4.0":
+  version: 0.4.1
+  resolution: "source-map-url@npm:0.4.1"
+  checksum: 64c5c2c77aff815a6e61a4120c309ae4cac01298d9bcbb3deb1b46a4dd4c46d4a1eaeda79ec9f684766ae80e8dc86367b89326ce9dd2b89947bd9291fc1ac08c
+  languageName: node
+  linkType: hard
+
+"source-map@npm:0.6.1, source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.0, source-map@npm:~0.6.1":
+  version: 0.6.1
+  resolution: "source-map@npm:0.6.1"
+  checksum: 59ce8640cf3f3124f64ac289012c2b8bd377c238e316fb323ea22fbfe83da07d81e000071d7242cad7a23cd91c7de98e4df8830ec3f133cb6133a5f6e9f67bc2
+  languageName: node
+  linkType: hard
+
+"source-map@npm:^0.4.2":
+  version: 0.4.4
+  resolution: "source-map@npm:0.4.4"
+  dependencies:
+    amdefine: ">=0.0.4"
+  checksum: b31992fcb4a2a6c335617f187bd36f392896dfcc111830ebdb8b716923cf6554b665833b975fc998bdf3a63881b2c8b4c5c34fda0280357b8c18fe6aa5d148ea
+  languageName: node
+  linkType: hard
+
+"source-map@npm:^0.5.0, source-map@npm:^0.5.6":
+  version: 0.5.7
+  resolution: "source-map@npm:0.5.7"
+  checksum: 5dc2043b93d2f194142c7f38f74a24670cd7a0063acdaf4bf01d2964b402257ae843c2a8fa822ad5b71013b5fcafa55af7421383da919752f22ff488bc553f4d
+  languageName: node
+  linkType: hard
+
+"source-map@npm:^0.7.3":
+  version: 0.7.4
+  resolution: "source-map@npm:0.7.4"
+  checksum: 01cc5a74b1f0e1d626a58d36ad6898ea820567e87f18dfc9d24a9843a351aaa2ec09b87422589906d6ff1deed29693e176194dc88bcae7c9a852dc74b311dbf5
+  languageName: node
+  linkType: hard
+
+"spdx-correct@npm:^3.0.0":
+  version: 3.1.1
+  resolution: "spdx-correct@npm:3.1.1"
+  dependencies:
+    spdx-expression-parse: ^3.0.0
+    spdx-license-ids: ^3.0.0
+  checksum: 77ce438344a34f9930feffa61be0eddcda5b55fc592906ef75621d4b52c07400a97084d8701557b13f7d2aae0cb64f808431f469e566ef3fe0a3a131dcb775a6
+  languageName: node
+  linkType: hard
+
+"spdx-exceptions@npm:^2.1.0":
+  version: 2.3.0
+  resolution: "spdx-exceptions@npm:2.3.0"
+  checksum: cb69a26fa3b46305637123cd37c85f75610e8c477b6476fa7354eb67c08128d159f1d36715f19be6f9daf4b680337deb8c65acdcae7f2608ba51931540687ac0
+  languageName: node
+  linkType: hard
+
+"spdx-expression-parse@npm:^3.0.0":
+  version: 3.0.1
+  resolution: "spdx-expression-parse@npm:3.0.1"
+  dependencies:
+    spdx-exceptions: ^2.1.0
+    spdx-license-ids: ^3.0.0
+  checksum: a1c6e104a2cbada7a593eaa9f430bd5e148ef5290d4c0409899855ce8b1c39652bcc88a725259491a82601159d6dc790bedefc9016c7472f7de8de7361f8ccde
+  languageName: node
+  linkType: hard
+
+"spdx-license-ids@npm:^3.0.0":
+  version: 3.0.9
+  resolution: "spdx-license-ids@npm:3.0.9"
+  checksum: 021c632a458b3f5144587350ee1e6d0da8e211c4acdeb511a89699ac7de5e9b84860aaea38b6b9b631b9fed8d3199a763b6baf96db4a2a77dc7c9c8ee6172288
+  languageName: node
+  linkType: hard
+
+"spdy-transport@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "spdy-transport@npm:3.0.0"
+  dependencies:
+    debug: ^4.1.0
+    detect-node: ^2.0.4
+    hpack.js: ^2.1.6
+    obuf: ^1.1.2
+    readable-stream: ^3.0.6
+    wbuf: ^1.7.3
+  checksum: 0fcaad3b836fb1ec0bdd39fa7008b9a7a84a553f12be6b736a2512613b323207ffc924b9551cef0378f7233c85916cff1118652e03a730bdb97c0e042243d56c
+  languageName: node
+  linkType: hard
+
+"spdy@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "spdy@npm:4.0.2"
+  dependencies:
+    debug: ^4.1.0
+    handle-thing: ^2.0.0
+    http-deceiver: ^1.2.7
+    select-hose: ^2.0.0
+    spdy-transport: ^3.0.0
+  checksum: 2c739d0ff6f56ad36d2d754d0261d5ec358457bea7cbf77b1b05b0c6464f2ce65b85f196305f50b7bd9120723eb94bae9933466f28e67e5cd8cde4e27f1d75f8
+  languageName: node
+  linkType: hard
+
+"split-on-first@npm:^1.0.0":
+  version: 1.1.0
+  resolution: "split-on-first@npm:1.1.0"
+  checksum: 16ff85b54ddcf17f9147210a4022529b343edbcbea4ce977c8f30e38408b8d6e0f25f92cd35b86a524d4797f455e29ab89eb8db787f3c10708e0b47ebf528d30
+  languageName: node
+  linkType: hard
+
+"split-string@npm:^3.0.1, split-string@npm:^3.0.2":
+  version: 3.1.0
+  resolution: "split-string@npm:3.1.0"
+  dependencies:
+    extend-shallow: ^3.0.0
+  checksum: ae5af5c91bdc3633628821bde92fdf9492fa0e8a63cf6a0376ed6afde93c701422a1610916f59be61972717070119e848d10dfbbd5024b7729d6a71972d2a84c
+  languageName: node
+  linkType: hard
+
+"sprintf-js@npm:~1.0.2":
+  version: 1.0.3
+  resolution: "sprintf-js@npm:1.0.3"
+  checksum: 19d79aec211f09b99ec3099b5b2ae2f6e9cdefe50bc91ac4c69144b6d3928a640bb6ae5b3def70c2e85a2c3d9f5ec2719921e3a59d3ca3ef4b2fd1a4656a0df3
+  languageName: node
+  linkType: hard
+
+"sshpk@npm:^1.14.1":
+  version: 1.18.0
+  resolution: "sshpk@npm:1.18.0"
+  dependencies:
+    asn1: ~0.2.3
+    assert-plus: ^1.0.0
+    bcrypt-pbkdf: ^1.0.0
+    dashdash: ^1.12.0
+    ecc-jsbn: ~0.1.1
+    getpass: ^0.1.1
+    jsbn: ~0.1.0
+    safer-buffer: ^2.0.2
+    tweetnacl: ~0.14.0
+  bin:
+    sshpk-conv: bin/sshpk-conv
+    sshpk-sign: bin/sshpk-sign
+    sshpk-verify: bin/sshpk-verify
+  checksum: 01d43374eee3a7e37b3b82fdbecd5518cbb2e47ccbed27d2ae30f9753f22bd6ffad31225cb8ef013bc3fb7785e686cea619203ee1439a228f965558c367c3cfa
+  languageName: node
+  linkType: hard
+
+"sshpk@npm:^1.7.0":
+  version: 1.16.1
+  resolution: "sshpk@npm:1.16.1"
+  dependencies:
+    asn1: ~0.2.3
+    assert-plus: ^1.0.0
+    bcrypt-pbkdf: ^1.0.0
+    dashdash: ^1.12.0
+    ecc-jsbn: ~0.1.1
+    getpass: ^0.1.1
+    jsbn: ~0.1.0
+    safer-buffer: ^2.0.2
+    tweetnacl: ~0.14.0
+  bin:
+    sshpk-conv: bin/sshpk-conv
+    sshpk-sign: bin/sshpk-sign
+    sshpk-verify: bin/sshpk-verify
+  checksum: 5e76afd1cedc780256f688b7c09327a8a650902d18e284dfeac97489a735299b03c3e72c6e8d22af03dbbe4d6f123fdfd5f3c4ed6bedbec72b9529a55051b857
+  languageName: node
+  linkType: hard
+
+"ssri@npm:^6.0.1":
+  version: 6.0.2
+  resolution: "ssri@npm:6.0.2"
+  dependencies:
+    figgy-pudding: ^3.5.1
+  checksum: 7c2e5d442f6252559c8987b7114bcf389fe5614bf65de09ba3e6f9a57b9b65b2967de348fcc3acccff9c069adb168140dd2c5fc2f6f4a779e604a27ef1f7d551
+  languageName: node
+  linkType: hard
+
+"ssri@npm:^7.0.0":
+  version: 7.1.1
+  resolution: "ssri@npm:7.1.1"
+  dependencies:
+    figgy-pudding: ^3.5.1
+    minipass: ^3.1.1
+  checksum: 8bdb3c198a3cebda54344b3cd9599338c18a4b29f1c857c0ab98cb39ff11a36b4cb6ea5a388c22bd71ac1ae6d8129103336173f77487d94d772eeb9aa0c8545f
+  languageName: node
+  linkType: hard
+
+"ssri@npm:^8.0.0, ssri@npm:^8.0.1":
+  version: 8.0.1
+  resolution: "ssri@npm:8.0.1"
+  dependencies:
+    minipass: ^3.1.1
+  checksum: bc447f5af814fa9713aa201ec2522208ae0f4d8f3bda7a1f445a797c7b929a02720436ff7c478fb5edc4045adb02b1b88d2341b436a80798734e2494f1067b36
+  languageName: node
+  linkType: hard
+
+"ssri@npm:^9.0.0":
+  version: 9.0.1
+  resolution: "ssri@npm:9.0.1"
+  dependencies:
+    minipass: ^3.1.1
+  checksum: fb58f5e46b6923ae67b87ad5ef1c5ab6d427a17db0bead84570c2df3cd50b4ceb880ebdba2d60726588272890bae842a744e1ecce5bd2a2a582fccd5068309eb
+  languageName: node
+  linkType: hard
+
+"stable@npm:^0.1.8":
+  version: 0.1.8
+  resolution: "stable@npm:0.1.8"
+  checksum: 2ff482bb100285d16dd75cd8f7c60ab652570e8952c0bfa91828a2b5f646a0ff533f14596ea4eabd48bb7f4aeea408dce8f8515812b975d958a4cc4fa6b9dfeb
+  languageName: node
+  linkType: hard
+
+"stack-utils@npm:^1.0.1":
+  version: 1.0.5
+  resolution: "stack-utils@npm:1.0.5"
+  dependencies:
+    escape-string-regexp: ^2.0.0
+  checksum: f82baf8d89536252a55c76866d5be3d04c96b09693a8d2ab3794b9fdec3674e05bd3f3d19345093e2cbba116a1f8f413858e0537bc3c81c605249261c3d26182
+  languageName: node
+  linkType: hard
+
+"static-extend@npm:^0.1.1":
+  version: 0.1.2
+  resolution: "static-extend@npm:0.1.2"
+  dependencies:
+    define-property: ^0.2.5
+    object-copy: ^0.1.0
+  checksum: 8657485b831f79e388a437260baf22784540417a9b29e11572c87735df24c22b84eda42107403a64b30861b2faf13df9f7fc5525d51f9d1d2303aba5cbf4e12c
+  languageName: node
+  linkType: hard
+
+"statuses@npm:2.0.1":
+  version: 2.0.1
+  resolution: "statuses@npm:2.0.1"
+  checksum: 18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb
+  languageName: node
+  linkType: hard
+
+"statuses@npm:>= 1.4.0 < 2":
+  version: 1.5.0
+  resolution: "statuses@npm:1.5.0"
+  checksum: c469b9519de16a4bb19600205cffb39ee471a5f17b82589757ca7bd40a8d92ebb6ed9f98b5a540c5d302ccbc78f15dc03cc0280dd6e00df1335568a5d5758a5c
+  languageName: node
+  linkType: hard
+
+"stdout-stream@npm:^1.4.0":
+  version: 1.4.1
+  resolution: "stdout-stream@npm:1.4.1"
+  dependencies:
+    readable-stream: ^2.0.1
+  checksum: 205bee8c3ba4e1e1d471b9302764405d2ee5dd272af6e9a71c95a9af6cf2ad8f4d102099a917c591ba9e14c1b2b5f5244f7a526e9d3cf311327cecd7c2bd4c2e
+  languageName: node
+  linkType: hard
+
+"stealthy-require@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "stealthy-require@npm:1.1.1"
+  checksum: 6805b857a9f3a6a1079fc6652278038b81011f2a5b22cbd559f71a6c02087e6f1df941eb10163e3fdc5391ab5807aa46758d4258547c1f5ede31e6d9bfda8dd3
+  languageName: node
+  linkType: hard
+
+"stream-browserify@npm:^2.0.1":
+  version: 2.0.2
+  resolution: "stream-browserify@npm:2.0.2"
+  dependencies:
+    inherits: ~2.0.1
+    readable-stream: ^2.0.2
+  checksum: 8de7bcab5582e9a931ae1a4768be7efe8fa4b0b95fd368d16d8cf3e494b897d6b0a7238626de5d71686e53bddf417fd59d106cfa3af0ec055f61a8d1f8fc77b3
+  languageName: node
+  linkType: hard
+
+"stream-each@npm:^1.1.0":
+  version: 1.2.3
+  resolution: "stream-each@npm:1.2.3"
+  dependencies:
+    end-of-stream: ^1.1.0
+    stream-shift: ^1.0.0
+  checksum: f243de78e9fcc60757994efc4e8ecae9f01a4b2c6a505d786b11fcaa68b1a75ca54afc1669eac9e08f19ff0230792fc40d0f3e3e2935d76971b4903af18b76ab
+  languageName: node
+  linkType: hard
+
+"stream-http@npm:^2.7.2":
+  version: 2.8.3
+  resolution: "stream-http@npm:2.8.3"
+  dependencies:
+    builtin-status-codes: ^3.0.0
+    inherits: ^2.0.1
+    readable-stream: ^2.3.6
+    to-arraybuffer: ^1.0.0
+    xtend: ^4.0.0
+  checksum: f57dfaa21a015f72e6ce6b199cf1762074cfe8acf0047bba8f005593754f1743ad0a91788f95308d9f3829ad55742399ad27b4624432f2752a08e62ef4346e05
+  languageName: node
+  linkType: hard
+
+"stream-shift@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "stream-shift@npm:1.0.1"
+  checksum: 59b82b44b29ec3699b5519a49b3cedcc6db58c72fb40c04e005525dfdcab1c75c4e0c180b923c380f204bed78211b9bad8faecc7b93dece4d004c3f6ec75737b
+  languageName: node
+  linkType: hard
+
+"strict-uri-encode@npm:^1.0.0":
+  version: 1.1.0
+  resolution: "strict-uri-encode@npm:1.1.0"
+  checksum: 9466d371f7b36768d43f7803f26137657559e4c8b0161fb9e320efb8edba3ae22f8e99d4b0d91da023b05a13f62ec5412c3f4f764b5788fac11d1fea93720bb3
+  languageName: node
+  linkType: hard
+
+"strict-uri-encode@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "strict-uri-encode@npm:2.0.0"
+  checksum: eaac4cf978b6fbd480f1092cab8b233c9b949bcabfc9b598dd79a758f7243c28765ef7639c876fa72940dac687181b35486ea01ff7df3e65ce3848c64822c581
+  languageName: node
+  linkType: hard
+
+"string-length@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "string-length@npm:2.0.0"
+  dependencies:
+    astral-regex: ^1.0.0
+    strip-ansi: ^4.0.0
+  checksum: 3a339b63fd39d6a1077dfbbe3279545e1b67fa4b0a558906158cf0121632b280f34c8768ec7270fb25db732d6323eceb9c7254f6026509694b6a7533ca8cb89e
+  languageName: node
+  linkType: hard
+
+"string-length@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "string-length@npm:3.1.0"
+  dependencies:
+    astral-regex: ^1.0.0
+    strip-ansi: ^5.2.0
+  checksum: b09ccacc2f96ba3ade9f2b3163901e05f668a2b14bc353853165c1f3b19185421ac004e9957b62827083d163e049c41a1b15170e252eaf44fdd686553c372714
+  languageName: node
+  linkType: hard
+
+"string-width@npm:^1.0.1, string-width@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "string-width@npm:1.0.2"
+  dependencies:
+    code-point-at: ^1.0.0
+    is-fullwidth-code-point: ^1.0.0
+    strip-ansi: ^3.0.0
+  checksum: 5c79439e95bc3bd7233a332c5f5926ab2ee90b23816ed4faa380ce3b2576d7800b0a5bb15ae88ed28737acc7ea06a518c2eef39142dd727adad0e45c776cd37e
+  languageName: node
+  linkType: hard
+
+"string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.2.3":
+  version: 4.2.3
+  resolution: "string-width@npm:4.2.3"
+  dependencies:
+    emoji-regex: ^8.0.0
+    is-fullwidth-code-point: ^3.0.0
+    strip-ansi: ^6.0.1
+  checksum: e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb
+  languageName: node
+  linkType: hard
+
+"string-width@npm:^3.0.0, string-width@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "string-width@npm:3.1.0"
+  dependencies:
+    emoji-regex: ^7.0.1
+    is-fullwidth-code-point: ^2.0.0
+    strip-ansi: ^5.1.0
+  checksum: 57f7ca73d201682816d573dc68bd4bb8e1dff8dc9fcf10470fdfc3474135c97175fec12ea6a159e67339b41e86963112355b64529489af6e7e70f94a7caf08b2
+  languageName: node
+  linkType: hard
+
+"string-width@npm:^4.1.0, string-width@npm:^4.2.0":
+  version: 4.2.2
+  resolution: "string-width@npm:4.2.2"
+  dependencies:
+    emoji-regex: ^8.0.0
+    is-fullwidth-code-point: ^3.0.0
+    strip-ansi: ^6.0.0
+  checksum: 343e089b0e66e0f72aab4ad1d9b6f2c9cc5255844b0c83fd9b53f2a3b3fd0421bdd6cb05be96a73117eb012db0887a6c1d64ca95aaa50c518e48980483fea0ab
+  languageName: node
+  linkType: hard
+
+"string.prototype.matchall@npm:^4.0.2":
+  version: 4.0.5
+  resolution: "string.prototype.matchall@npm:4.0.5"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.2
+    get-intrinsic: ^1.1.1
+    has-symbols: ^1.0.2
+    internal-slot: ^1.0.3
+    regexp.prototype.flags: ^1.3.1
+    side-channel: ^1.0.4
+  checksum: 0a9d64661ecf089e7712aed18a4b0d7e4093ae1dfc6d8134747a98271564065a2a667a3408fced4a77137528b3b2c0efe9d37868acae000ee13d0857a3d0f430
+  languageName: node
+  linkType: hard
+
+"string.prototype.trim@npm:^1.2.1":
+  version: 1.2.4
+  resolution: "string.prototype.trim@npm:1.2.4"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+    es-abstract: ^1.18.0-next.2
+  checksum: 81b9c5ebe9b24c16be560f02d13f98cb6000e638998e883bbab97fe3518dfbf16464ff76fd66979b5bce6ad689b468dad62afb36b5fbf8461a730c665b51b2de
+  languageName: node
+  linkType: hard
+
+"string.prototype.trimend@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "string.prototype.trimend@npm:1.0.4"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+  checksum: 17e5aa45c3983f582693161f972c1c1fa4bbbdf22e70e582b00c91b6575f01680dc34e83005b98e31abe4d5d29e0b21fcc24690239c106c7b2315aade6a898ac
+  languageName: node
+  linkType: hard
+
+"string.prototype.trimstart@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "string.prototype.trimstart@npm:1.0.4"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.1.3
+  checksum: 3fb06818d3cccac5fa3f5f9873d984794ca0e9f6616fae6fcc745885d9efed4e17fe15f832515d9af5e16c279857fdbffdfc489ca4ed577811b017721b30302f
+  languageName: node
+  linkType: hard
+
+"string_decoder@npm:^1.0.0, string_decoder@npm:^1.1.1":
+  version: 1.3.0
+  resolution: "string_decoder@npm:1.3.0"
+  dependencies:
+    safe-buffer: ~5.2.0
+  checksum: 8417646695a66e73aefc4420eb3b84cc9ffd89572861fe004e6aeb13c7bc00e2f616247505d2dbbef24247c372f70268f594af7126f43548565c68c117bdeb56
+  languageName: node
+  linkType: hard
+
+"string_decoder@npm:~1.1.1":
+  version: 1.1.1
+  resolution: "string_decoder@npm:1.1.1"
+  dependencies:
+    safe-buffer: ~5.1.0
+  checksum: 9ab7e56f9d60a28f2be697419917c50cac19f3e8e6c28ef26ed5f4852289fe0de5d6997d29becf59028556f2c62983790c1d9ba1e2a3cc401768ca12d5183a5b
+  languageName: node
+  linkType: hard
+
+"stringify-object@npm:^3.3.0":
+  version: 3.3.0
+  resolution: "stringify-object@npm:3.3.0"
+  dependencies:
+    get-own-enumerable-property-symbols: ^3.0.0
+    is-obj: ^1.0.1
+    is-regexp: ^1.0.0
+  checksum: 6827a3f35975cfa8572e8cd3ed4f7b262def260af18655c6fde549334acdac49ddba69f3c861ea5a6e9c5a4990fe4ae870b9c0e6c31019430504c94a83b7a154
+  languageName: node
+  linkType: hard
+
+"strip-ansi@npm:6.0.0":
+  version: 6.0.0
+  resolution: "strip-ansi@npm:6.0.0"
+  dependencies:
+    ansi-regex: ^5.0.0
+  checksum: 04c3239ede44c4d195b0e66c0ad58b932f08bec7d05290416d361ff908ad282ecdaf5d9731e322c84f151d427436bde01f05b7422c3ec26dd927586736b0e5d0
+  languageName: node
+  linkType: hard
+
+"strip-ansi@npm:^3.0.0, strip-ansi@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "strip-ansi@npm:3.0.1"
+  dependencies:
+    ansi-regex: ^2.0.0
+  checksum: 9b974de611ce5075c70629c00fa98c46144043db92ae17748fb780f706f7a789e9989fd10597b7c2053ae8d1513fd707816a91f1879b2f71e6ac0b6a863db465
+  languageName: node
+  linkType: hard
+
+"strip-ansi@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "strip-ansi@npm:4.0.0"
+  dependencies:
+    ansi-regex: ^3.0.0
+  checksum: d9186e6c0cf78f25274f6750ee5e4a5725fb91b70fdd79aa5fe648eab092a0ec5b9621b22d69d4534a56319f75d8944efbd84e3afa8d4ad1b9a9491f12c84eca
+  languageName: node
+  linkType: hard
+
+"strip-ansi@npm:^5.0.0, strip-ansi@npm:^5.1.0, strip-ansi@npm:^5.2.0":
+  version: 5.2.0
+  resolution: "strip-ansi@npm:5.2.0"
+  dependencies:
+    ansi-regex: ^4.1.0
+  checksum: bdb5f76ade97062bd88e7723aa019adbfacdcba42223b19ccb528ffb9fb0b89a5be442c663c4a3fb25268eaa3f6ea19c7c3fbae830bd1562d55adccae1fcec46
+  languageName: node
+  linkType: hard
+
+"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
+  version: 6.0.1
+  resolution: "strip-ansi@npm:6.0.1"
+  dependencies:
+    ansi-regex: ^5.0.1
+  checksum: f3cd25890aef3ba6e1a74e20896c21a46f482e93df4a06567cebf2b57edabb15133f1f94e57434e0a958d61186087b1008e89c94875d019910a213181a14fc8c
+  languageName: node
+  linkType: hard
+
+"strip-bom@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "strip-bom@npm:2.0.0"
+  dependencies:
+    is-utf8: ^0.2.0
+  checksum: 08efb746bc67b10814cd03d79eb31bac633393a782e3f35efbc1b61b5165d3806d03332a97f362822cf0d4dd14ba2e12707fcff44fe1c870c48a063a0c9e4944
+  languageName: node
+  linkType: hard
+
+"strip-bom@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "strip-bom@npm:3.0.0"
+  checksum: 8d50ff27b7ebe5ecc78f1fe1e00fcdff7af014e73cf724b46fb81ef889eeb1015fc5184b64e81a2efe002180f3ba431bdd77e300da5c6685d702780fbf0c8d5b
+  languageName: node
+  linkType: hard
+
+"strip-comments@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "strip-comments@npm:1.0.2"
+  dependencies:
+    babel-extract-comments: ^1.0.0
+    babel-plugin-transform-object-rest-spread: ^6.26.0
+  checksum: 19e6f659a617566aef011b29ef9ce50da0db24556073d9c8065c73072f89bf1238d1fcaaa485933fee038a50a09bb04493097f66e622cdfc3a114f5e9e99ee24
+  languageName: node
+  linkType: hard
+
+"strip-eof@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "strip-eof@npm:1.0.0"
+  checksum: 40bc8ddd7e072f8ba0c2d6d05267b4e0a4800898c3435b5fb5f5a21e6e47dfaff18467e7aa0d1844bb5d6274c3097246595841fbfeb317e541974ee992cac506
+  languageName: node
+  linkType: hard
+
+"strip-final-newline@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "strip-final-newline@npm:2.0.0"
+  checksum: 69412b5e25731e1938184b5d489c32e340605bb611d6140344abc3421b7f3c6f9984b21dff296dfcf056681b82caa3bb4cc996a965ce37bcfad663e92eae9c64
+  languageName: node
+  linkType: hard
+
+"strip-indent@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "strip-indent@npm:1.0.1"
+  dependencies:
+    get-stdin: ^4.0.1
+  bin:
+    strip-indent: cli.js
+  checksum: 81ad9a0b8a558bdbd05b66c6c437b9ab364aa2b5479ed89969ca7908e680e21b043d40229558c434b22b3d640622e39b66288e0456d601981ac9289de9700fbd
+  languageName: node
+  linkType: hard
+
+"strip-indent@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "strip-indent@npm:3.0.0"
+  dependencies:
+    min-indent: ^1.0.0
+  checksum: 18f045d57d9d0d90cd16f72b2313d6364fd2cb4bf85b9f593523ad431c8720011a4d5f08b6591c9d580f446e78855c5334a30fb91aa1560f5d9f95ed1b4a0530
+  languageName: node
+  linkType: hard
+
+"strip-json-comments@npm:^3.0.1":
+  version: 3.1.1
+  resolution: "strip-json-comments@npm:3.1.1"
+  checksum: 492f73e27268f9b1c122733f28ecb0e7e8d8a531a6662efbd08e22cccb3f9475e90a1b82cab06a392f6afae6d2de636f977e231296400d0ec5304ba70f166443
+  languageName: node
+  linkType: hard
+
+"style-loader@npm:0.23.1":
+  version: 0.23.1
+  resolution: "style-loader@npm:0.23.1"
+  dependencies:
+    loader-utils: ^1.1.0
+    schema-utils: ^1.0.0
+  checksum: 0a513a2d881e88bbfd574750df3dc61f57424684458d94cb6ae41e635d03abfa8974bb591eab9051650082c5f5502994dc17c7ca9fb0fc9e8d31f651f6737479
+  languageName: node
+  linkType: hard
+
+"styled-components@npm:^6.1.2":
+  version: 6.1.8
+  resolution: "styled-components@npm:6.1.8"
+  dependencies:
+    "@emotion/is-prop-valid": 1.2.1
+    "@emotion/unitless": 0.8.0
+    "@types/stylis": 4.2.0
+    css-to-react-native: 3.2.0
+    csstype: 3.1.2
+    postcss: 8.4.31
+    shallowequal: 1.1.0
+    stylis: 4.3.1
+    tslib: 2.5.0
+  peerDependencies:
+    react: ">= 16.8.0"
+    react-dom: ">= 16.8.0"
+  checksum: 367858097ca57911cc310ddf95d16fed162fbb1d2f187366b33ce5e6e22c324f9bcc7206686624a3edd15e3e9605875c8c041ac5ffb430bbee98f1ad0be71604
+  languageName: node
+  linkType: hard
+
+"stylehacks@npm:^4.0.0":
+  version: 4.0.3
+  resolution: "stylehacks@npm:4.0.3"
+  dependencies:
+    browserslist: ^4.0.0
+    postcss: ^7.0.0
+    postcss-selector-parser: ^3.0.0
+  checksum: 8acf28ea609bee6d7ba40121bcf53af8d899c1ec04f2c08de9349b8292b84b8aa7f82e14c623ae6956decf5b7a7eeea5472ab8e48de7bdcdb6d76640444f6753
+  languageName: node
+  linkType: hard
+
+"stylis@npm:4.3.1":
+  version: 4.3.1
+  resolution: "stylis@npm:4.3.1"
+  checksum: d365f1b008677b2147e8391e9cf20094a4202a5f9789562e7d9d0a3bd6f0b3067d39e8fd17cce5323903a56f6c45388e3d839e9c0bb5a738c91726992b14966d
+  languageName: node
+  linkType: hard
+
+"supports-color@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "supports-color@npm:2.0.0"
+  checksum: 602538c5812b9006404370b5a4b885d3e2a1f6567d314f8b4a41974ffe7d08e525bf92ae0f9c7030e3b4c78e4e34ace55d6a67a74f1571bc205959f5972f88f0
+  languageName: node
+  linkType: hard
+
+"supports-color@npm:^5.3.0, supports-color@npm:^5.5.0":
+  version: 5.5.0
+  resolution: "supports-color@npm:5.5.0"
+  dependencies:
+    has-flag: ^3.0.0
+  checksum: 95f6f4ba5afdf92f495b5a912d4abee8dcba766ae719b975c56c084f5004845f6f5a5f7769f52d53f40e21952a6d87411bafe34af4a01e65f9926002e38e1dac
+  languageName: node
+  linkType: hard
+
+"supports-color@npm:^6.1.0":
+  version: 6.1.0
+  resolution: "supports-color@npm:6.1.0"
+  dependencies:
+    has-flag: ^3.0.0
+  checksum: 74358f9535c83ee113fbaac354b11e808060f6e7d8722082ee43af3578469134e89d00026dce2a6b93ce4e5b89d0e9a10f638b2b9f64c7838c2fb2883a47b3d5
+  languageName: node
+  linkType: hard
+
+"supports-color@npm:^7.0.0, supports-color@npm:^7.1.0":
+  version: 7.2.0
+  resolution: "supports-color@npm:7.2.0"
+  dependencies:
+    has-flag: ^4.0.0
+  checksum: 3dda818de06ebbe5b9653e07842d9479f3555ebc77e9a0280caf5a14fb877ffee9ed57007c3b78f5a6324b8dbeec648d9e97a24e2ed9fdb81ddc69ea07100f4a
+  languageName: node
+  linkType: hard
+
+"supports-color@npm:^8.1.1":
+  version: 8.1.1
+  resolution: "supports-color@npm:8.1.1"
+  dependencies:
+    has-flag: ^4.0.0
+  checksum: c052193a7e43c6cdc741eb7f378df605636e01ad434badf7324f17fb60c69a880d8d8fcdcb562cf94c2350e57b937d7425ab5b8326c67c2adc48f7c87c1db406
+  languageName: node
+  linkType: hard
+
+"svg-parser@npm:^2.0.0":
+  version: 2.0.4
+  resolution: "svg-parser@npm:2.0.4"
+  checksum: b3de6653048212f2ae7afe4a423e04a76ec6d2d06e1bf7eacc618a7c5f7df7faa5105561c57b94579ec831fbbdbf5f190ba56a9205ff39ed13eabdf8ab086ddf
+  languageName: node
+  linkType: hard
+
+"svgo@npm:^1.0.0, svgo@npm:^1.2.2":
+  version: 1.3.2
+  resolution: "svgo@npm:1.3.2"
+  dependencies:
+    chalk: ^2.4.1
+    coa: ^2.0.2
+    css-select: ^2.0.0
+    css-select-base-adapter: ^0.1.1
+    css-tree: 1.0.0-alpha.37
+    csso: ^4.0.2
+    js-yaml: ^3.13.1
+    mkdirp: ~0.5.1
+    object.values: ^1.1.0
+    sax: ~1.2.4
+    stable: ^0.1.8
+    unquote: ~1.1.1
+    util.promisify: ~1.0.0
+  bin:
+    svgo: ./bin/svgo
+  checksum: 28a5680a61245eb4a1603bc03459095bb01ad5ebd23e95882d886c3c81752313c0a9a9fe48dd0bcbb9a27c52e11c603640df952971573b2b550d9e15a9ee6116
+  languageName: node
+  linkType: hard
+
+"symbol-observable@npm:1.2.0, symbol-observable@npm:^1.0.3, symbol-observable@npm:^1.0.4, symbol-observable@npm:^1.1.0, symbol-observable@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "symbol-observable@npm:1.2.0"
+  checksum: 48ffbc22e3d75f9853b3ff2ae94a44d84f386415110aea5effc24d84c502e03a4a6b7a8f75ebaf7b585780bda34eb5d6da3121f826a6f93398429d30032971b6
+  languageName: node
+  linkType: hard
+
+"symbol-tree@npm:^3.2.2":
+  version: 3.2.4
+  resolution: "symbol-tree@npm:3.2.4"
+  checksum: 6e8fc7e1486b8b54bea91199d9535bb72f10842e40c79e882fc94fb7b14b89866adf2fd79efa5ebb5b658bc07fb459ccce5ac0e99ef3d72f474e74aaf284029d
+  languageName: node
+  linkType: hard
+
+"synthetic-dom@npm:^1.4.0":
+  version: 1.4.0
+  resolution: "synthetic-dom@npm:1.4.0"
+  checksum: 5f8a377e74729849cfb3eacc489a7c6018f5ebb10fbf2621178a858c26e343aed900fd3be407ce7c3ea8150db8ebc2e8dc1326423e6f26b2e61eca7a3066c80c
+  languageName: node
+  linkType: hard
+
+"table@npm:^5.2.3":
+  version: 5.4.6
+  resolution: "table@npm:5.4.6"
+  dependencies:
+    ajv: ^6.10.2
+    lodash: ^4.17.14
+    slice-ansi: ^2.1.0
+    string-width: ^3.0.0
+  checksum: 9e35d3efa788edc17237eef8852f8e4b9178efd65a7d115141777b2ee77df4b7796c05f4ed3712d858f98894ac5935a481ceeb6dcb9895e2f67a61cce0e63b6c
+  languageName: node
+  linkType: hard
+
+"tapable@npm:^1.0.0, tapable@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "tapable@npm:1.1.3"
+  checksum: 53ff4e7c3900051c38cc4faab428ebfd7e6ad0841af5a7ac6d5f3045c5b50e88497bfa8295b4b3fbcadd94993c9e358868b78b9fb249a76cb8b018ac8dccafd7
+  languageName: node
+  linkType: hard
+
+"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2":
+  version: 6.2.1
+  resolution: "tar@npm:6.2.1"
+  dependencies:
+    chownr: ^2.0.0
+    fs-minipass: ^2.0.0
+    minipass: ^5.0.0
+    minizlib: ^2.1.1
+    mkdirp: ^1.0.3
+    yallist: ^4.0.0
+  checksum: f1322768c9741a25356c11373bce918483f40fa9a25c69c59410c8a1247632487edef5fe76c5f12ac51a6356d2f1829e96d2bc34098668a2fc34d76050ac2b6c
+  languageName: node
+  linkType: hard
+
+"terser-webpack-plugin@npm:2.3.8":
+  version: 2.3.8
+  resolution: "terser-webpack-plugin@npm:2.3.8"
+  dependencies:
+    cacache: ^13.0.1
+    find-cache-dir: ^3.3.1
+    jest-worker: ^25.4.0
+    p-limit: ^2.3.0
+    schema-utils: ^2.6.6
+    serialize-javascript: ^4.0.0
+    source-map: ^0.6.1
+    terser: ^4.6.12
+    webpack-sources: ^1.4.3
+  peerDependencies:
+    webpack: ^4.0.0 || ^5.0.0
+  checksum: a772d7d58a4730b619f71c4a8d7cf1fa90ded0d01b4fb9a094437c3380e3c35ce78caa030c2867a10cdd12527dfc2fb46bee949bd067ee0cd41e9890cbd85263
+  languageName: node
+  linkType: hard
+
+"terser-webpack-plugin@npm:^1.4.3":
+  version: 1.4.5
+  resolution: "terser-webpack-plugin@npm:1.4.5"
+  dependencies:
+    cacache: ^12.0.2
+    find-cache-dir: ^2.1.0
+    is-wsl: ^1.1.0
+    schema-utils: ^1.0.0
+    serialize-javascript: ^4.0.0
+    source-map: ^0.6.1
+    terser: ^4.1.2
+    webpack-sources: ^1.4.0
+    worker-farm: ^1.7.0
+  peerDependencies:
+    webpack: ^4.0.0
+  checksum: 02aada80927d3c8105d69cb00384d307b73aed67d180db5d20023a8d649149f3803ad50f9cd2ef9eb2622005de87e677198ecc5088f51422bfac5d4d57472d0e
+  languageName: node
+  linkType: hard
+
+"terser@npm:^4.1.2, terser@npm:^4.6.12, terser@npm:^4.6.3":
+  version: 4.8.1
+  resolution: "terser@npm:4.8.1"
+  dependencies:
+    commander: ^2.20.0
+    source-map: ~0.6.1
+    source-map-support: ~0.5.12
+  bin:
+    terser: bin/terser
+  checksum: b342819bf7e82283059aaa3f22bb74deb1862d07573ba5a8947882190ad525fd9b44a15074986be083fd379c58b9a879457a330b66dcdb77b485c44267f9a55a
+  languageName: node
+  linkType: hard
+
+"test-exclude@npm:^5.2.3":
+  version: 5.2.3
+  resolution: "test-exclude@npm:5.2.3"
+  dependencies:
+    glob: ^7.1.3
+    minimatch: ^3.0.4
+    read-pkg-up: ^4.0.0
+    require-main-filename: ^2.0.0
+  checksum: 3a67bee51b0afb0b7a51b649a7dacd920d929de2b3eccb52fa818f0b0bf2ebfced1d1a77a206b74f95c50f6682e313eedb8000cfdd5ac2f9cc6ed8a32fc4ff2e
+  languageName: node
+  linkType: hard
+
+"text-table@npm:0.2.0, text-table@npm:^0.2.0":
+  version: 0.2.0
+  resolution: "text-table@npm:0.2.0"
+  checksum: b6937a38c80c7f84d9c11dd75e49d5c44f71d95e810a3250bd1f1797fc7117c57698204adf676b71497acc205d769d65c16ae8fa10afad832ae1322630aef10a
+  languageName: node
+  linkType: hard
+
+"throat@npm:^4.0.0":
+  version: 4.1.0
+  resolution: "throat@npm:4.1.0"
+  checksum: 43519b0cea6d3b2a8fe056fcbc319e289037be67d2204d4d33513d20d6ee9da6255f7ba8c89e2ec8c97b0f188a910b8666def38d1058d2bf4a39613812c36d98
+  languageName: node
+  linkType: hard
+
+"throttleit@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "throttleit@npm:1.0.0"
+  checksum: 1b2db4d2454202d589e8236c07a69d2fab838876d370030ebea237c34c0a7d1d9cf11c29f994531ebb00efd31e9728291042b7754f2798a8352ec4463455b659
+  languageName: node
+  linkType: hard
+
+"through2@npm:^2.0.0":
+  version: 2.0.5
+  resolution: "through2@npm:2.0.5"
+  dependencies:
+    readable-stream: ~2.3.6
+    xtend: ~4.0.1
+  checksum: beb0f338aa2931e5660ec7bf3ad949e6d2e068c31f4737b9525e5201b824ac40cac6a337224856b56bd1ddd866334bbfb92a9f57cd6f66bc3f18d3d86fc0fe50
+  languageName: node
+  linkType: hard
+
+"through@npm:^2.3.6, through@npm:^2.3.8":
+  version: 2.3.8
+  resolution: "through@npm:2.3.8"
+  checksum: a38c3e059853c494af95d50c072b83f8b676a9ba2818dcc5b108ef252230735c54e0185437618596c790bbba8fcdaef5b290405981ffa09dce67b1f1bf190cbd
+  languageName: node
+  linkType: hard
+
+"thunky@npm:^1.0.2":
+  version: 1.1.0
+  resolution: "thunky@npm:1.1.0"
+  checksum: 993096c472b6b8f30e29dc777a8d17720e4cab448375041f20c0cb802a09a7fb2217f2a3e8cdc11851faa71c957e2db309357367fc9d7af3cb7a4d00f4b66034
+  languageName: node
+  linkType: hard
+
+"timers-browserify@npm:^2.0.4":
+  version: 2.0.12
+  resolution: "timers-browserify@npm:2.0.12"
+  dependencies:
+    setimmediate: ^1.0.4
+  checksum: ec37ae299066bef6c464dcac29c7adafba1999e7227a9bdc4e105a459bee0f0b27234a46bfd7ab4041da79619e06a58433472867a913d01c26f8a203f87cee70
+  languageName: node
+  linkType: hard
+
+"timsort@npm:^0.3.0":
+  version: 0.3.0
+  resolution: "timsort@npm:0.3.0"
+  checksum: 1a66cb897dacabd7dd7c91b7e2301498ca9e224de2edb9e42d19f5b17c4b6dc62a8d4cbc64f28be82aaf1541cb5a78ab49aa818f42a2989ebe049a64af731e2a
+  languageName: node
+  linkType: hard
+
+"tiny-invariant@npm:^1.0.2":
+  version: 1.1.0
+  resolution: "tiny-invariant@npm:1.1.0"
+  checksum: 27d29bbb9e1d1d86e25766711c28ad91af6d67c87d561167077ac7fbce5212b97bbfe875e70bc369808e075748c825864c9b61f0e9f8652275ec86bcf4dcc924
+  languageName: node
+  linkType: hard
+
+"tiny-warning@npm:^1.0.0":
+  version: 1.0.3
+  resolution: "tiny-warning@npm:1.0.3"
+  checksum: da62c4acac565902f0624b123eed6dd3509bc9a8d30c06e017104bedcf5d35810da8ff72864400ad19c5c7806fc0a8323c68baf3e326af7cb7d969f846100d71
+  languageName: node
+  linkType: hard
+
+"tippy.js@npm:^6.3.7":
+  version: 6.3.7
+  resolution: "tippy.js@npm:6.3.7"
+  dependencies:
+    "@popperjs/core": ^2.9.0
+  checksum: cac955318a65288e8d2dca05059878b003c6e66f92c94f7810f5bc5448eb6646abdf7dacc9bd00020e2611592598d0aae3a28ec9a45349a159603c3fdddce5fb
+  languageName: node
+  linkType: hard
+
+"tmp@npm:^0.0.33":
+  version: 0.0.33
+  resolution: "tmp@npm:0.0.33"
+  dependencies:
+    os-tmpdir: ~1.0.2
+  checksum: 902d7aceb74453ea02abbf58c203f4a8fc1cead89b60b31e354f74ed5b3fb09ea817f94fb310f884a5d16987dd9fa5a735412a7c2dd088dd3d415aa819ae3a28
+  languageName: node
+  linkType: hard
+
+"tmp@npm:~0.2.1":
+  version: 0.2.1
+  resolution: "tmp@npm:0.2.1"
+  dependencies:
+    rimraf: ^3.0.0
+  checksum: 8b1214654182575124498c87ca986ac53dc76ff36e8f0e0b67139a8d221eaecfdec108c0e6ec54d76f49f1f72ab9325500b246f562b926f85bcdfca8bf35df9e
+  languageName: node
+  linkType: hard
+
+"tmpl@npm:1.0.x":
+  version: 1.0.5
+  resolution: "tmpl@npm:1.0.5"
+  checksum: cd922d9b853c00fe414c5a774817be65b058d54a2d01ebb415840960406c669a0fc632f66df885e24cb022ec812739199ccbdb8d1164c3e513f85bfca5ab2873
+  languageName: node
+  linkType: hard
+
+"to-arraybuffer@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "to-arraybuffer@npm:1.0.1"
+  checksum: 31433c10b388722729f5da04c6b2a06f40dc84f797bb802a5a171ced1e599454099c6c5bc5118f4b9105e7d049d3ad9d0f71182b77650e4fdb04539695489941
+  languageName: node
+  linkType: hard
+
+"to-fast-properties@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "to-fast-properties@npm:2.0.0"
+  checksum: be2de62fe58ead94e3e592680052683b1ec986c72d589e7b21e5697f8744cdbf48c266fa72f6c15932894c10187b5f54573a3bcf7da0bfd964d5caf23d436168
+  languageName: node
+  linkType: hard
+
+"to-object-path@npm:^0.3.0":
+  version: 0.3.0
+  resolution: "to-object-path@npm:0.3.0"
+  dependencies:
+    kind-of: ^3.0.2
+  checksum: 9425effee5b43e61d720940fa2b889623f77473d459c2ce3d4a580a4405df4403eec7be6b857455908070566352f9e2417304641ed158dda6f6a365fe3e66d70
+  languageName: node
+  linkType: hard
+
+"to-regex-range@npm:^2.1.0":
+  version: 2.1.1
+  resolution: "to-regex-range@npm:2.1.1"
+  dependencies:
+    is-number: ^3.0.0
+    repeat-string: ^1.6.1
+  checksum: 46093cc14be2da905cc931e442d280b2e544e2bfdb9a24b3cf821be8d342f804785e5736c108d5be026021a05d7b38144980a61917eee3c88de0a5e710e10320
+  languageName: node
+  linkType: hard
+
+"to-regex-range@npm:^5.0.1":
+  version: 5.0.1
+  resolution: "to-regex-range@npm:5.0.1"
+  dependencies:
+    is-number: ^7.0.0
+  checksum: f76fa01b3d5be85db6a2a143e24df9f60dd047d151062d0ba3df62953f2f697b16fe5dad9b0ac6191c7efc7b1d9dcaa4b768174b7b29da89d4428e64bc0a20ed
+  languageName: node
+  linkType: hard
+
+"to-regex@npm:^3.0.1, to-regex@npm:^3.0.2":
+  version: 3.0.2
+  resolution: "to-regex@npm:3.0.2"
+  dependencies:
+    define-property: ^2.0.2
+    extend-shallow: ^3.0.2
+    regex-not: ^1.0.2
+    safe-regex: ^1.1.0
+  checksum: 4ed4a619059b64e204aad84e4e5f3ea82d97410988bcece7cf6cbfdbf193d11bff48cf53842d88b8bb00b1bfc0d048f61f20f0709e6f393fd8fe0122662d9db4
+  languageName: node
+  linkType: hard
+
+"toggle-selection@npm:^1.0.6":
+  version: 1.0.6
+  resolution: "toggle-selection@npm:1.0.6"
+  checksum: a90dc80ed1e7b18db8f4e16e86a5574f87632dc729cfc07d9ea3ced50021ad42bb4e08f22c0913e0b98e3837b0b717e0a51613c65f30418e21eb99da6556a74c
+  languageName: node
+  linkType: hard
+
+"toidentifier@npm:1.0.1":
+  version: 1.0.1
+  resolution: "toidentifier@npm:1.0.1"
+  checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45
+  languageName: node
+  linkType: hard
+
+"tough-cookie@npm:^2.3.3, tough-cookie@npm:^2.3.4, tough-cookie@npm:^2.5.0, tough-cookie@npm:~2.5.0":
+  version: 2.5.0
+  resolution: "tough-cookie@npm:2.5.0"
+  dependencies:
+    psl: ^1.1.28
+    punycode: ^2.1.1
+  checksum: 16a8cd090224dd176eee23837cbe7573ca0fa297d7e468ab5e1c02d49a4e9a97bb05fef11320605eac516f91d54c57838a25864e8680e27b069a5231d8264977
+  languageName: node
+  linkType: hard
+
+"tough-cookie@npm:^4.1.3":
+  version: 4.1.3
+  resolution: "tough-cookie@npm:4.1.3"
+  dependencies:
+    psl: ^1.1.33
+    punycode: ^2.1.1
+    universalify: ^0.2.0
+    url-parse: ^1.5.3
+  checksum: c9226afff36492a52118432611af083d1d8493a53ff41ec4ea48e5b583aec744b989e4280bcf476c910ec1525a89a4a0f1cae81c08b18fb2ec3a9b3a72b91dcc
+  languageName: node
+  linkType: hard
+
+"tr46@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "tr46@npm:1.0.1"
+  dependencies:
+    punycode: ^2.1.0
+  checksum: 96d4ed46bc161db75dbf9247a236ea0bfcaf5758baae6749e92afab0bc5a09cb59af21788ede7e55080f2bf02dce3e4a8f2a484cc45164e29f4b5e68f7cbcc1a
+  languageName: node
+  linkType: hard
+
+"tr46@npm:~0.0.3":
+  version: 0.0.3
+  resolution: "tr46@npm:0.0.3"
+  checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3
+  languageName: node
+  linkType: hard
+
+"trim-newlines@npm:^1.0.0, trim-newlines@npm:^3.0.0":
+  version: 3.0.1
+  resolution: "trim-newlines@npm:3.0.1"
+  checksum: b530f3fadf78e570cf3c761fb74fef655beff6b0f84b29209bac6c9622db75ad1417f4a7b5d54c96605dcd72734ad44526fef9f396807b90839449eb543c6206
+  languageName: node
+  linkType: hard
+
+"true-case-path@npm:^1.0.2":
+  version: 1.0.3
+  resolution: "true-case-path@npm:1.0.3"
+  dependencies:
+    glob: ^7.1.2
+  checksum: 2e2e3bf37b4b05db2e2a1d60329960a4aa697ad7a89bd97c66f5f4da83977897c29c704276e62bca62d055d8078065bc08a1c7a01f409de11c6592af8b442cbe
+  languageName: node
+  linkType: hard
+
+"true-case-path@npm:^2.2.1":
+  version: 2.2.1
+  resolution: "true-case-path@npm:2.2.1"
+  checksum: fd5f1c2a87a122a65ffb1f84b580366be08dac7f552ea0fa4b5a6ab0a013af950b0e752beddb1c6c1652e6d6a2b293b7b3fd86a5a1706242ad365b68f1b5c6f1
+  languageName: node
+  linkType: hard
+
+"ts-mock-imports@npm:1.3.7":
+  version: 1.3.7
+  resolution: "ts-mock-imports@npm:1.3.7"
+  peerDependencies:
+    sinon: ">= 4.1.2"
+    typescript: ">=2.6.1"
+  checksum: 22f048d1c2df13a3c44cb7391fae54fd1234651bea21c32a981b36b4e5d5143f48a8910006f3200f52e518121a73e03e4aa417a8d6c7c7c6573b93039becc9bd
+  languageName: node
+  linkType: hard
+
+"ts-pnp@npm:1.1.6":
+  version: 1.1.6
+  resolution: "ts-pnp@npm:1.1.6"
+  peerDependenciesMeta:
+    typescript:
+      optional: true
+  checksum: 78a05096ac3b1391bbb8d0b292d76d433f841fb3e698b9bdff961ca1f794e0644e3f0b93d78c6b42dd0e90ef34676cf164855e6010f7b71fed63f2102a387eb9
+  languageName: node
+  linkType: hard
+
+"ts-pnp@npm:^1.1.6":
+  version: 1.2.0
+  resolution: "ts-pnp@npm:1.2.0"
+  peerDependenciesMeta:
+    typescript:
+      optional: true
+  checksum: c2a698b85d521298fe6f2435fbf2d3dc5834b423ea25abd321805ead3f399dbeedce7ca09492d7eb005b9d2c009c6b9587055bc3ab273dc6b9e40eefd7edb5b2
+  languageName: node
+  linkType: hard
+
+"tslib@npm:2.5.0":
+  version: 2.5.0
+  resolution: "tslib@npm:2.5.0"
+  checksum: ae3ed5f9ce29932d049908ebfdf21b3a003a85653a9a140d614da6b767a93ef94f460e52c3d787f0e4f383546981713f165037dc2274df212ea9f8a4541004e1
+  languageName: node
+  linkType: hard
+
+"tslib@npm:^1.8.0, tslib@npm:^1.8.1, tslib@npm:^1.9.0, tslib@npm:^1.9.3":
+  version: 1.14.1
+  resolution: "tslib@npm:1.14.1"
+  checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd
+  languageName: node
+  linkType: hard
+
+"tslib@npm:^2.0.3, tslib@npm:^2.2.0":
+  version: 2.3.0
+  resolution: "tslib@npm:2.3.0"
+  checksum: 8869694c26e4a7b56d449662fd54a4f9ba872c889d991202c74462bd99f10e61d5bd63199566c4284c0f742277736292a969642cc7b590f98727a7cae9529122
+  languageName: node
+  linkType: hard
+
+"tslib@npm:^2.1.0":
+  version: 2.6.2
+  resolution: "tslib@npm:2.6.2"
+  checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad
+  languageName: node
+  linkType: hard
+
+"tslint-etc@npm:1.6.0":
+  version: 1.6.0
+  resolution: "tslint-etc@npm:1.6.0"
+  dependencies:
+    "@phenomnomnominal/tsquery": ^3.0.0
+    tslib: ^1.8.0
+    tsutils: ^3.0.0
+    tsutils-etc: ^1.0.0
+  peerDependencies:
+    tslint: ^5.0.0
+    typescript: ^2.3.0 || ^3.0.0
+  checksum: 09a5eec08905282cddf1408e47abcc0a01da6713ac287dceccb735d16415c191f50fc0dcb490d7cb1a6835f0844f5cc5e95651967f565af61c475ab50ac7cb1b
+  languageName: node
+  linkType: hard
+
+"tslint@npm:5.20.0":
+  version: 5.20.0
+  resolution: "tslint@npm:5.20.0"
+  dependencies:
+    "@babel/code-frame": ^7.0.0
+    builtin-modules: ^1.1.1
+    chalk: ^2.3.0
+    commander: ^2.12.1
+    diff: ^4.0.1
+    glob: ^7.1.1
+    js-yaml: ^3.13.1
+    minimatch: ^3.0.4
+    mkdirp: ^0.5.1
+    resolve: ^1.3.2
+    semver: ^5.3.0
+    tslib: ^1.8.0
+    tsutils: ^2.29.0
+  peerDependencies:
+    typescript: ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev"
+  bin:
+    tslint: ./bin/tslint
+  checksum: ca1dc92e0bf3b7c4509372842dee34b630cbf6a9e099d2f93d1989b116996a66849dc6b370f05dda418c4d48931e954e182354002de6ac94d582b274b559c731
+  languageName: node
+  linkType: hard
+
+"tsutils-etc@npm:^1.0.0":
+  version: 1.3.4
+  resolution: "tsutils-etc@npm:1.3.4"
+  dependencies:
+    "@types/yargs": ^17.0.0
+    yargs: ^17.0.0
+  peerDependencies:
+    tsutils: ^3.0.0
+  bin:
+    ts-flags: bin/ts-flags
+    ts-kind: bin/ts-kind
+  checksum: 1228a66338137d4795313d6327d787be3620ab1800975eaba8eb54456795bdce9f3ffdfa43229399891d58f70e589294b1129908edb2b8207fab4c36f37c3530
+  languageName: node
+  linkType: hard
+
+"tsutils@npm:^2.29.0":
+  version: 2.29.0
+  resolution: "tsutils@npm:2.29.0"
+  dependencies:
+    tslib: ^1.8.1
+  peerDependencies:
+    typescript: ">=2.1.0 || >=2.1.0-dev || >=2.2.0-dev || >=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >= 3.0.0-dev || >= 3.1.0-dev"
+  checksum: 5d681bab79979e3b4d61dd0d1e6c1dd6a79b5608cf8dec5a5ee599ac8b5921107870bcf037140b8dce85a479df78aee0ffa61c1b3d8e5660748af36551946616
+  languageName: node
+  linkType: hard
+
+"tsutils@npm:^3.0.0, tsutils@npm:^3.21.0":
+  version: 3.21.0
+  resolution: "tsutils@npm:3.21.0"
+  dependencies:
+    tslib: ^1.8.1
+  peerDependencies:
+    typescript: ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
+  checksum: 1843f4c1b2e0f975e08c4c21caa4af4f7f65a12ac1b81b3b8489366826259323feb3fc7a243123453d2d1a02314205a7634e048d4a8009921da19f99755cdc48
+  languageName: node
+  linkType: hard
+
+"tty-browserify@npm:0.0.0":
+  version: 0.0.0
+  resolution: "tty-browserify@npm:0.0.0"
+  checksum: a06f746acc419cb2527ba19b6f3bd97b4a208c03823bfb37b2982629d2effe30ebd17eaed0d7e2fc741f3c4f2a0c43455bd5fb4194354b378e78cfb7ca687f59
+  languageName: node
+  linkType: hard
+
+"tunnel-agent@npm:^0.6.0":
+  version: 0.6.0
+  resolution: "tunnel-agent@npm:0.6.0"
+  dependencies:
+    safe-buffer: ^5.0.1
+  checksum: 05f6510358f8afc62a057b8b692f05d70c1782b70db86d6a1e0d5e28a32389e52fa6e7707b6c5ecccacc031462e4bc35af85ecfe4bbc341767917b7cf6965711
+  languageName: node
+  linkType: hard
+
+"tweetnacl@npm:^0.14.3, tweetnacl@npm:~0.14.0":
+  version: 0.14.5
+  resolution: "tweetnacl@npm:0.14.5"
+  checksum: 6061daba1724f59473d99a7bb82e13f211cdf6e31315510ae9656fefd4779851cb927adad90f3b488c8ed77c106adc0421ea8055f6f976ff21b27c5c4e918487
+  languageName: node
+  linkType: hard
+
+"type-check@npm:~0.3.2":
+  version: 0.3.2
+  resolution: "type-check@npm:0.3.2"
+  dependencies:
+    prelude-ls: ~1.1.2
+  checksum: dd3b1495642731bc0e1fc40abe5e977e0263005551ac83342ecb6f4f89551d106b368ec32ad3fb2da19b3bd7b2d1f64330da2ea9176d8ddbfe389fb286eb5124
+  languageName: node
+  linkType: hard
+
+"type-detect@npm:4.0.8":
+  version: 4.0.8
+  resolution: "type-detect@npm:4.0.8"
+  checksum: 62b5628bff67c0eb0b66afa371bd73e230399a8d2ad30d852716efcc4656a7516904570cd8631a49a3ce57c10225adf5d0cbdcb47f6b0255fe6557c453925a15
+  languageName: node
+  linkType: hard
+
+"type-fest@npm:^0.18.0":
+  version: 0.18.1
+  resolution: "type-fest@npm:0.18.1"
+  checksum: e96dcee18abe50ec82dab6cbc4751b3a82046da54c52e3b2d035b3c519732c0b3dd7a2fa9df24efd1a38d953d8d4813c50985f215f1957ee5e4f26b0fe0da395
+  languageName: node
+  linkType: hard
+
+"type-fest@npm:^0.21.3":
+  version: 0.21.3
+  resolution: "type-fest@npm:0.21.3"
+  checksum: e6b32a3b3877f04339bae01c193b273c62ba7bfc9e325b8703c4ee1b32dc8fe4ef5dfa54bf78265e069f7667d058e360ae0f37be5af9f153b22382cd55a9afe0
+  languageName: node
+  linkType: hard
+
+"type-fest@npm:^0.6.0":
+  version: 0.6.0
+  resolution: "type-fest@npm:0.6.0"
+  checksum: b2188e6e4b21557f6e92960ec496d28a51d68658018cba8b597bd3ef757721d1db309f120ae987abeeda874511d14b776157ff809f23c6d1ce8f83b9b2b7d60f
+  languageName: node
+  linkType: hard
+
+"type-fest@npm:^0.8.1":
+  version: 0.8.1
+  resolution: "type-fest@npm:0.8.1"
+  checksum: d61c4b2eba24009033ae4500d7d818a94fd6d1b481a8111612ee141400d5f1db46f199c014766b9fa9b31a6a7374d96fc748c6d688a78a3ce5a33123839becb7
+  languageName: node
+  linkType: hard
+
+"type-is@npm:~1.6.18":
+  version: 1.6.18
+  resolution: "type-is@npm:1.6.18"
+  dependencies:
+    media-typer: 0.3.0
+    mime-types: ~2.1.24
+  checksum: 2c8e47675d55f8b4e404bcf529abdf5036c537a04c2b20177bcf78c9e3c1da69da3942b1346e6edb09e823228c0ee656ef0e033765ec39a70d496ef601a0c657
+  languageName: node
+  linkType: hard
+
+"type@npm:^1.0.1":
+  version: 1.2.0
+  resolution: "type@npm:1.2.0"
+  checksum: dae8c64f82c648b985caf321e9dd6e8b7f4f2e2d4f846fc6fd2c8e9dc7769382d8a52369ddbaccd59aeeceb0df7f52fb339c465be5f2e543e81e810e413451ee
+  languageName: node
+  linkType: hard
+
+"type@npm:^2.0.0":
+  version: 2.5.0
+  resolution: "type@npm:2.5.0"
+  checksum: 0fe1bb4e8ba298b2b245fdc6bca6178887e29e2134d231e468366615b3adffd651d464eb51d8b15f8cfd168577c282a17e19bf80f036a60d4df16308a83a93c4
+  languageName: node
+  linkType: hard
+
+"type@npm:^2.7.2":
+  version: 2.7.2
+  resolution: "type@npm:2.7.2"
+  checksum: 0f42379a8adb67fe529add238a3e3d16699d95b42d01adfe7b9a7c5da297f5c1ba93de39265ba30ffeb37dfd0afb3fb66ae09f58d6515da442219c086219f6f4
+  languageName: node
+  linkType: hard
+
+"typedarray@npm:^0.0.6":
+  version: 0.0.6
+  resolution: "typedarray@npm:0.0.6"
+  checksum: 33b39f3d0e8463985eeaeeacc3cb2e28bc3dfaf2a5ed219628c0b629d5d7b810b0eb2165f9f607c34871d5daa92ba1dc69f49051cf7d578b4cbd26c340b9d1b1
+  languageName: node
+  linkType: hard
+
+"typescript@npm:4.3.4":
+  version: 4.3.4
+  resolution: "typescript@npm:4.3.4"
+  bin:
+    tsc: bin/tsc
+    tsserver: bin/tsserver
+  checksum: 75e1f2769c7ff38c718523d05eaf1c2611dbf92c0ab0f85f603ead9bb23416af2009a5dac46e76ef6a207a8508fa53f51b43a41f2a91b1241b53cd744c16128c
+  languageName: node
+  linkType: hard
+
+"typescript@patch:typescript@4.3.4#~builtin<compat/typescript>":
+  version: 4.3.4
+  resolution: "typescript@patch:typescript@npm%3A4.3.4#~builtin<compat/typescript>::version=4.3.4&hash=bda367"
+  bin:
+    tsc: bin/tsc
+    tsserver: bin/tsserver
+  checksum: 6ccc2e1148e172da119ea4b72c66395a0c18a53884d21fb82bb4503a948a7169e9961defe24a359040a3d77bf5ff338945804296e0e27c87b5bd22ea1d25781b
+  languageName: node
+  linkType: hard
+
+"ua-parser-js@npm:^0.7.18, ua-parser-js@npm:^0.7.30":
+  version: 0.7.36
+  resolution: "ua-parser-js@npm:0.7.36"
+  checksum: 04e18e7f6bf4964a10d74131ea9784c7f01d0c2d3b96f73340ac0a1f8e83d010b99fd7d425e7a2100fa40c58b72f6201408cbf4baa2df1103637f96fb59f2a30
+  languageName: node
+  linkType: hard
+
+"unbox-primitive@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "unbox-primitive@npm:1.0.1"
+  dependencies:
+    function-bind: ^1.1.1
+    has-bigints: ^1.0.1
+    has-symbols: ^1.0.2
+    which-boxed-primitive: ^1.0.2
+  checksum: 89d950e18fb45672bc6b3c961f1e72c07beb9640c7ceed847b571ba6f7d2af570ae1a2584cfee268b9d9ea1e3293f7e33e0bc29eaeb9f8e8a0bab057ff9e6bba
+  languageName: node
+  linkType: hard
+
+"unicode-canonical-property-names-ecmascript@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "unicode-canonical-property-names-ecmascript@npm:1.0.4"
+  checksum: cc1973b18d0e1a151711e5551f87f4b3086c4f542cd5142aa691307d5720fd725fa7d36c24e12e944e108b91c72554237b0c236772d35592839434da5506c40f
+  languageName: node
+  linkType: hard
+
+"unicode-match-property-ecmascript@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "unicode-match-property-ecmascript@npm:1.0.4"
+  dependencies:
+    unicode-canonical-property-names-ecmascript: ^1.0.4
+    unicode-property-aliases-ecmascript: ^1.0.4
+  checksum: 08e269fac71b5ace0f8331df9e87b9b533fe97b00c43ea58de69ae81816581490f846050e0c472279a3e7434524feba99915a93816f90dbbc0a30bcbd082da88
+  languageName: node
+  linkType: hard
+
+"unicode-match-property-value-ecmascript@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "unicode-match-property-value-ecmascript@npm:1.2.0"
+  checksum: 2e663cfec8e2cf317b69613566314979f717034ea8f58a237dd63234795044a87337410064fe839774d71e1d7e12195520e9edd69ed8e28f2a9eb28a2db38595
+  languageName: node
+  linkType: hard
+
+"unicode-property-aliases-ecmascript@npm:^1.0.4":
+  version: 1.1.0
+  resolution: "unicode-property-aliases-ecmascript@npm:1.1.0"
+  checksum: 1a96dc462d251bb1c5237f7bc77956b29f01cefce7f3e7448430742930961557c3d1515a9669715ebb06209bf01072e2f78ba1627247017daa84346414bc02f1
+  languageName: node
+  linkType: hard
+
+"union-value@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "union-value@npm:1.0.1"
+  dependencies:
+    arr-union: ^3.1.0
+    get-value: ^2.0.6
+    is-extendable: ^0.1.1
+    set-value: ^2.0.1
+  checksum: a3464097d3f27f6aa90cf103ed9387541bccfc006517559381a10e0dffa62f465a9d9a09c9b9c3d26d0f4cbe61d4d010e2fbd710fd4bf1267a768ba8a774b0ba
+  languageName: node
+  linkType: hard
+
+"unionize@npm:2.1.2":
+  version: 2.1.2
+  resolution: "unionize@npm:2.1.2"
+  checksum: d733d5c60fbfbfb6fcd4e22da3e3a65331d2ea4ab97a5e69706a2b8b9f5163be6418df69892f00f9f938594cc5506a5c2b2f66151cce3b5d61762c052dd46aeb
+  languageName: node
+  linkType: hard
+
+"uniq@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "uniq@npm:1.0.1"
+  checksum: 8206535f83745ea83f9da7035f3b983fd6ed5e35b8ed7745441944e4065b616bc67cf0d0a23a86b40ee0074426f0607f0a138f9b78e124eb6a7a6a6966055709
+  languageName: node
+  linkType: hard
+
+"uniqs@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "uniqs@npm:2.0.0"
+  checksum: 5ace63e0521fd1ae2c161b3fa167cf6846fc45a71c00496729e0146402c3ae467c6f025a68fbd6766300a9bfbac9f240f2f0198164283bef48012b39db83f81f
+  languageName: node
+  linkType: hard
+
+"unique-filename@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "unique-filename@npm:1.1.1"
+  dependencies:
+    unique-slug: ^2.0.0
+  checksum: cf4998c9228cc7647ba7814e255dec51be43673903897b1786eff2ac2d670f54d4d733357eb08dea969aa5e6875d0e1bd391d668fbdb5a179744e7c7551a6f80
+  languageName: node
+  linkType: hard
+
+"unique-filename@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "unique-filename@npm:2.0.1"
+  dependencies:
+    unique-slug: ^3.0.0
+  checksum: 807acf3381aff319086b64dc7125a9a37c09c44af7620bd4f7f3247fcd5565660ac12d8b80534dcbfd067e6fe88a67e621386dd796a8af828d1337a8420a255f
+  languageName: node
+  linkType: hard
+
+"unique-slug@npm:^2.0.0":
+  version: 2.0.2
+  resolution: "unique-slug@npm:2.0.2"
+  dependencies:
+    imurmurhash: ^0.1.4
+  checksum: 5b6876a645da08d505dedb970d1571f6cebdf87044cb6b740c8dbb24f0d6e1dc8bdbf46825fd09f994d7cf50760e6f6e063cfa197d51c5902c00a861702eb75a
+  languageName: node
+  linkType: hard
+
+"unique-slug@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "unique-slug@npm:3.0.0"
+  dependencies:
+    imurmurhash: ^0.1.4
+  checksum: 49f8d915ba7f0101801b922062ee46b7953256c93ceca74303bd8e6413ae10aa7e8216556b54dc5382895e8221d04f1efaf75f945c2e4a515b4139f77aa6640c
+  languageName: node
+  linkType: hard
+
+"universalify@npm:^0.1.0":
+  version: 0.1.2
+  resolution: "universalify@npm:0.1.2"
+  checksum: 40cdc60f6e61070fe658ca36016a8f4ec216b29bf04a55dce14e3710cc84c7448538ef4dad3728d0bfe29975ccd7bfb5f414c45e7b78883567fb31b246f02dff
+  languageName: node
+  linkType: hard
+
+"universalify@npm:^0.2.0":
+  version: 0.2.0
+  resolution: "universalify@npm:0.2.0"
+  checksum: e86134cb12919d177c2353196a4cc09981524ee87abf621f7bc8d249dbbbebaec5e7d1314b96061497981350df786e4c5128dbf442eba104d6e765bc260678b5
+  languageName: node
+  linkType: hard
+
+"universalify@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "universalify@npm:2.0.0"
+  checksum: 2406a4edf4a8830aa6813278bab1f953a8e40f2f63a37873ffa9a3bc8f9745d06cc8e88f3572cb899b7e509013f7f6fcc3e37e8a6d914167a5381d8440518c44
+  languageName: node
+  linkType: hard
+
+"unpipe@npm:1.0.0, unpipe@npm:~1.0.0":
+  version: 1.0.0
+  resolution: "unpipe@npm:1.0.0"
+  checksum: 4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2
+  languageName: node
+  linkType: hard
+
+"unquote@npm:~1.1.1":
+  version: 1.1.1
+  resolution: "unquote@npm:1.1.1"
+  checksum: 71745867d09cba44ba2d26cb71d6dda7045a98b14f7405df4faaf2b0c90d24703ad027a9d90ba9a6e0d096de2c8d56f864fd03f1c0498c0b7a3990f73b4c8f5f
+  languageName: node
+  linkType: hard
+
+"unset-value@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "unset-value@npm:1.0.0"
+  dependencies:
+    has-value: ^0.3.1
+    isobject: ^3.0.0
+  checksum: 5990ecf660672be2781fc9fb322543c4aa592b68ed9a3312fa4df0e9ba709d42e823af090fc8f95775b4cd2c9a5169f7388f0cec39238b6d0d55a69fc2ab6b29
+  languageName: node
+  linkType: hard
+
+"untildify@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "untildify@npm:4.0.0"
+  checksum: 39ced9c418a74f73f0a56e1ba4634b4d959422dff61f4c72a8e39f60b99380c1b45ed776fbaa0a4101b157e4310d873ad7d114e8534ca02609b4916bb4187fb9
+  languageName: node
+  linkType: hard
+
+"upath@npm:^1.1.1":
+  version: 1.2.0
+  resolution: "upath@npm:1.2.0"
+  checksum: 4c05c094797cb733193a0784774dbea5b1889d502fc9f0572164177e185e4a59ba7099bf0b0adf945b232e2ac60363f9bf18aac9b2206fb99cbef971a8455445
+  languageName: node
+  linkType: hard
+
+"update-browserslist-db@npm:^1.0.13":
+  version: 1.0.13
+  resolution: "update-browserslist-db@npm:1.0.13"
+  dependencies:
+    escalade: ^3.1.1
+    picocolors: ^1.0.0
+  peerDependencies:
+    browserslist: ">= 4.21.0"
+  bin:
+    update-browserslist-db: cli.js
+  checksum: 1e47d80182ab6e4ad35396ad8b61008ae2a1330221175d0abd37689658bdb61af9b705bfc41057fd16682474d79944fb2d86767c5ed5ae34b6276b9bed353322
+  languageName: node
+  linkType: hard
+
+"uri-js@npm:^4.2.2":
+  version: 4.4.1
+  resolution: "uri-js@npm:4.4.1"
+  dependencies:
+    punycode: ^2.1.0
+  checksum: 7167432de6817fe8e9e0c9684f1d2de2bb688c94388f7569f7dbdb1587c9f4ca2a77962f134ec90be0cc4d004c939ff0d05acc9f34a0db39a3c797dada262633
+  languageName: node
+  linkType: hard
+
+"urix@npm:^0.1.0":
+  version: 0.1.0
+  resolution: "urix@npm:0.1.0"
+  checksum: 4c076ecfbf3411e888547fe844e52378ab5ada2d2f27625139011eada79925e77f7fbf0e4016d45e6a9e9adb6b7e64981bd49b22700c7c401c5fc15f423303b3
+  languageName: node
+  linkType: hard
+
+"url-loader@npm:2.3.0":
+  version: 2.3.0
+  resolution: "url-loader@npm:2.3.0"
+  dependencies:
+    loader-utils: ^1.2.3
+    mime: ^2.4.4
+    schema-utils: ^2.5.0
+  peerDependencies:
+    file-loader: "*"
+    webpack: ^4.0.0
+  peerDependenciesMeta:
+    file-loader:
+      optional: true
+  checksum: c0a8a6e728331e2189a6538373b9ce4b5589389805c4e98a0386ee5d18cc4ba4c5dec5514d8c852dd533b857461c30c3efc135ed07b6b31a96c5a6fb812a4757
+  languageName: node
+  linkType: hard
+
+"url-parse@npm:^1.4.3, url-parse@npm:^1.5.3":
+  version: 1.5.10
+  resolution: "url-parse@npm:1.5.10"
+  dependencies:
+    querystringify: ^2.1.1
+    requires-port: ^1.0.0
+  checksum: fbdba6b1d83336aca2216bbdc38ba658d9cfb8fc7f665eb8b17852de638ff7d1a162c198a8e4ed66001ddbf6c9888d41e4798912c62b4fd777a31657989f7bdf
+  languageName: node
+  linkType: hard
+
+"url@npm:^0.11.0":
+  version: 0.11.0
+  resolution: "url@npm:0.11.0"
+  dependencies:
+    punycode: 1.3.2
+    querystring: 0.2.0
+  checksum: 50d100d3dd2d98b9fe3ada48cadb0b08aa6be6d3ac64112b867b56b19be4bfcba03c2a9a0d7922bfd7ac17d4834e88537749fe182430dfd9b68e520175900d90
+  languageName: node
+  linkType: hard
+
+"use@npm:^3.1.0":
+  version: 3.1.1
+  resolution: "use@npm:3.1.1"
+  checksum: 08a130289f5238fcbf8f59a18951286a6e660d17acccc9d58d9b69dfa0ee19aa038e8f95721b00b432c36d1629a9e32a464bf2e7e0ae6a244c42ddb30bdd8b33
+  languageName: node
+  linkType: hard
+
+"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
+  version: 1.0.2
+  resolution: "util-deprecate@npm:1.0.2"
+  checksum: 474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2
+  languageName: node
+  linkType: hard
+
+"util.promisify@npm:1.0.0":
+  version: 1.0.0
+  resolution: "util.promisify@npm:1.0.0"
+  dependencies:
+    define-properties: ^1.1.2
+    object.getownpropertydescriptors: ^2.0.3
+  checksum: 482e857d676adee506c5c3a10212fd6a06a51d827a9b6d5396a8e593db53b4bb7064f77c5071357d8cd76072542de5cc1c08bc6d7c10cf43fa22dc3bc67556f1
+  languageName: node
+  linkType: hard
+
+"util.promisify@npm:^1.0.0":
+  version: 1.1.1
+  resolution: "util.promisify@npm:1.1.1"
+  dependencies:
+    call-bind: ^1.0.0
+    define-properties: ^1.1.3
+    for-each: ^0.3.3
+    has-symbols: ^1.0.1
+    object.getownpropertydescriptors: ^2.1.1
+  checksum: ea371c30b90576862487ae4efd7182aa5855019549a4019d82629acc2709e8ccb0f38944403eebec622fff8ebb44ac3f46a52d745d5f543d30606132a4905f96
+  languageName: node
+  linkType: hard
+
+"util.promisify@npm:~1.0.0":
+  version: 1.0.1
+  resolution: "util.promisify@npm:1.0.1"
+  dependencies:
+    define-properties: ^1.1.3
+    es-abstract: ^1.17.2
+    has-symbols: ^1.0.1
+    object.getownpropertydescriptors: ^2.1.0
+  checksum: d823c75b3fc66510018596f128a6592c98991df38bc0464a633bdf9134e2de0a1a33199c5c21cc261048a3982d7a19e032ecff8835b3c587f843deba96063e37
+  languageName: node
+  linkType: hard
+
+"util@npm:0.10.3":
+  version: 0.10.3
+  resolution: "util@npm:0.10.3"
+  dependencies:
+    inherits: 2.0.1
+  checksum: bd800f5d237a82caddb61723a6cbe45297d25dd258651a31335a4d5d981fd033cb4771f82db3d5d59b582b187cb69cfe727dc6f4d8d7826f686ee6c07ce611e0
+  languageName: node
+  linkType: hard
+
+"util@npm:^0.11.0":
+  version: 0.11.1
+  resolution: "util@npm:0.11.1"
+  dependencies:
+    inherits: 2.0.3
+  checksum: 80bee6a2edf5ab08dcb97bfe55ca62289b4e66f762ada201f2c5104cb5e46474c8b334f6504d055c0e6a8fda10999add9bcbd81ba765e7f37b17dc767331aa55
+  languageName: node
+  linkType: hard
+
+"utila@npm:~0.4":
+  version: 0.4.0
+  resolution: "utila@npm:0.4.0"
+  checksum: 97ffd3bd2bb80c773429d3fb8396469115cd190dded1e733f190d8b602bd0a1bcd6216b7ce3c4395ee3c79e3c879c19d268dbaae3093564cb169ad1212d436f4
+  languageName: node
+  linkType: hard
+
+"utils-merge@npm:1.0.1":
+  version: 1.0.1
+  resolution: "utils-merge@npm:1.0.1"
+  checksum: c81095493225ecfc28add49c106ca4f09cdf56bc66731aa8dabc2edbbccb1e1bfe2de6a115e5c6a380d3ea166d1636410b62ef216bb07b3feb1cfde1d95d5080
+  languageName: node
+  linkType: hard
+
+"uuid@npm:3.3.2":
+  version: 3.3.2
+  resolution: "uuid@npm:3.3.2"
+  bin:
+    uuid: ./bin/uuid
+  checksum: 8793629d2799f500aeea9fcd0aec6c4e9fbcc4d62ed42159ad96be345c3fffac1bbf61a23e18e2782600884fee05e6d4012ce4b70d0037c8e987533ae6a77870
+  languageName: node
+  linkType: hard
+
+"uuid@npm:^3.3.2, uuid@npm:^3.4.0":
+  version: 3.4.0
+  resolution: "uuid@npm:3.4.0"
+  bin:
+    uuid: ./bin/uuid
+  checksum: 58de2feed61c59060b40f8203c0e4ed7fd6f99d42534a499f1741218a1dd0c129f4aa1de797bcf822c8ea5da7e4137aa3673431a96dae729047f7aca7b27866f
+  languageName: node
+  linkType: hard
+
+"uuid@npm:^8.3.2":
+  version: 8.3.2
+  resolution: "uuid@npm:8.3.2"
+  bin:
+    uuid: dist/bin/uuid
+  checksum: 5575a8a75c13120e2f10e6ddc801b2c7ed7d8f3c8ac22c7ed0c7b2ba6383ec0abda88c905085d630e251719e0777045ae3236f04c812184b7c765f63a70e58df
+  languageName: node
+  linkType: hard
+
+"v8-compile-cache@npm:^2.0.3":
+  version: 2.3.0
+  resolution: "v8-compile-cache@npm:2.3.0"
+  checksum: adb0a271eaa2297f2f4c536acbfee872d0dd26ec2d76f66921aa7fc437319132773483344207bdbeee169225f4739016d8d2dbf0553913a52bb34da6d0334f8e
+  languageName: node
+  linkType: hard
+
+"validate-npm-package-license@npm:^3.0.1":
+  version: 3.0.4
+  resolution: "validate-npm-package-license@npm:3.0.4"
+  dependencies:
+    spdx-correct: ^3.0.0
+    spdx-expression-parse: ^3.0.0
+  checksum: 35703ac889d419cf2aceef63daeadbe4e77227c39ab6287eeb6c1b36a746b364f50ba22e88591f5d017bc54685d8137bc2d328d0a896e4d3fd22093c0f32a9ad
+  languageName: node
+  linkType: hard
+
+"value-equal@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "value-equal@npm:1.0.1"
+  checksum: bb7ae1facc76b5cf8071aeb6c13d284d023fdb370478d10a5d64508e0e6e53bb459c4bbe34258df29d82e6f561f874f0105eba38de0e61fe9edd0bdce07a77a2
+  languageName: node
+  linkType: hard
+
+"vary@npm:~1.1.2":
+  version: 1.1.2
+  resolution: "vary@npm:1.1.2"
+  checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b
+  languageName: node
+  linkType: hard
+
+"vendors@npm:^1.0.0":
+  version: 1.0.4
+  resolution: "vendors@npm:1.0.4"
+  checksum: 4b16e0bc18dbdd7ac8dd745c776c08f6c73e9a7f620ffd9faf94a3d86a35feaf4c6cb1bbdb304d2381548a30d0abe69b83eeb1b7b1bf5bb33935e64b28812681
+  languageName: node
+  linkType: hard
+
+"verror@npm:1.10.0":
+  version: 1.10.0
+  resolution: "verror@npm:1.10.0"
+  dependencies:
+    assert-plus: ^1.0.0
+    core-util-is: 1.0.2
+    extsprintf: ^1.2.0
+  checksum: c431df0bedf2088b227a4e051e0ff4ca54df2c114096b0c01e1cbaadb021c30a04d7dd5b41ab277bcd51246ca135bf931d4c4c796ecae7a4fef6d744ecef36ea
+  languageName: node
+  linkType: hard
+
+"vm-browserify@npm:^1.0.1":
+  version: 1.1.2
+  resolution: "vm-browserify@npm:1.1.2"
+  checksum: 10a1c50aab54ff8b4c9042c15fc64aefccce8d2fb90c0640403242db0ee7fb269f9b102bdb69cfb435d7ef3180d61fd4fb004a043a12709abaf9056cfd7e039d
+  languageName: node
+  linkType: hard
+
+"w3c-hr-time@npm:^1.0.1":
+  version: 1.0.2
+  resolution: "w3c-hr-time@npm:1.0.2"
+  dependencies:
+    browser-process-hrtime: ^1.0.0
+  checksum: ec3c2dacbf8050d917bbf89537a101a08c2e333b4c19155f7d3bedde43529d4339db6b3d049d9610789cb915f9515f8be037e0c54c079e9d4735c50b37ed52b9
+  languageName: node
+  linkType: hard
+
+"w3c-xmlserializer@npm:^1.1.2":
+  version: 1.1.2
+  resolution: "w3c-xmlserializer@npm:1.1.2"
+  dependencies:
+    domexception: ^1.0.1
+    webidl-conversions: ^4.0.2
+    xml-name-validator: ^3.0.0
+  checksum: 1683e083d0dfc1529988f8956510a3a26e90738b41c4df0c7eb95283bfbeabeb492308117dcd32afef2a141e2a959ddf10ce562983d91b9f474a530b9dcdd337
+  languageName: node
+  linkType: hard
+
+"wait-on@npm:4.0.2":
+  version: 4.0.2
+  resolution: "wait-on@npm:4.0.2"
+  dependencies:
+    "@hapi/joi": ^17.1.1
+    lodash: ^4.17.15
+    minimist: ^1.2.5
+    request: ^2.88.2
+    request-promise-native: ^1.0.8
+    rxjs: ^6.5.5
+  bin:
+    wait-on: bin/wait-on
+  checksum: d5088aa152cf81fe10fd197efb50eb1f0bd983f00c101f0a1f587d87f9a8c9dd396d2b478ef9f042f8bd920bf79eaecbacbcd779d69ee51b32359e86e024745f
+  languageName: node
+  linkType: hard
+
+"walker@npm:^1.0.7, walker@npm:~1.0.5":
+  version: 1.0.7
+  resolution: "walker@npm:1.0.7"
+  dependencies:
+    makeerror: 1.0.x
+  checksum: 4038fcf92f6ab0288267ad05008aec9e089a759f1bd32e1ea45cc2eb498eb12095ec43cf8ca2bf23a465f4580a0d33b25b89f450ba521dd27083cbc695ee6bf5
+  languageName: node
+  linkType: hard
+
+"warning@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "warning@npm:3.0.0"
+  dependencies:
+    loose-envify: ^1.0.0
+  checksum: c9f99a12803aab81b29858e7dc3415bf98b41baee3a4c3acdeb680d98c47b6e17490f1087dccc54432deed5711a5ce0ebcda2b27e9b5eb054c32ae50acb4419c
+  languageName: node
+  linkType: hard
+
+"warning@npm:^4.0.1":
+  version: 4.0.3
+  resolution: "warning@npm:4.0.3"
+  dependencies:
+    loose-envify: ^1.0.0
+  checksum: 4f2cb6a9575e4faf71ddad9ad1ae7a00d0a75d24521c193fa464f30e6b04027bd97aa5d9546b0e13d3a150ab402eda216d59c1d0f2d6ca60124d96cd40dfa35c
+  languageName: node
+  linkType: hard
+
+"watchpack-chokidar2@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "watchpack-chokidar2@npm:2.0.1"
+  dependencies:
+    chokidar: ^2.1.8
+  checksum: acf0f9ebca0c0b2fd1fe87ba557670477a6c0410bf1a653a726e68eb0620aa94fd9a43027a160a76bc793a21ea12e215e1e87dafe762682c13ef92ad4daf7b58
+  languageName: node
+  linkType: hard
+
+"watchpack@npm:^1.6.0":
+  version: 1.7.5
+  resolution: "watchpack@npm:1.7.5"
+  dependencies:
+    chokidar: ^3.4.1
+    graceful-fs: ^4.1.2
+    neo-async: ^2.5.0
+    watchpack-chokidar2: ^2.0.1
+  dependenciesMeta:
+    chokidar:
+      optional: true
+    watchpack-chokidar2:
+      optional: true
+  checksum: 8b7cb8c8df8f4dd0e8ac47693c0141c4f020a4b031411247d600eca31522fde6f1f9a3a6f6518b46e71f7971b0ed5734c08c60d7fdd2530e7262776286f69236
+  languageName: node
+  linkType: hard
+
+"wbuf@npm:^1.1.0, wbuf@npm:^1.7.3":
+  version: 1.7.3
+  resolution: "wbuf@npm:1.7.3"
+  dependencies:
+    minimalistic-assert: ^1.0.0
+  checksum: 2abc306c96930b757972a1c4650eb6b25b5d99f24088714957f88629e137db569368c5de0e57986c89ea70db2f1df9bba11a87cb6d0c8694b6f53a0159fab3bf
+  languageName: node
+  linkType: hard
+
+"webidl-conversions@npm:^3.0.0":
+  version: 3.0.1
+  resolution: "webidl-conversions@npm:3.0.1"
+  checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c
+  languageName: node
+  linkType: hard
+
+"webidl-conversions@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "webidl-conversions@npm:4.0.2"
+  checksum: c93d8dfe908a0140a4ae9c0ebc87a33805b416a33ee638a605b551523eec94a9632165e54632f6d57a39c5f948c4bab10e0e066525e9a4b87a79f0d04fbca374
+  languageName: node
+  linkType: hard
+
+"webpack-dev-middleware@npm:^3.7.2":
+  version: 3.7.3
+  resolution: "webpack-dev-middleware@npm:3.7.3"
+  dependencies:
+    memory-fs: ^0.4.1
+    mime: ^2.4.4
+    mkdirp: ^0.5.1
+    range-parser: ^1.2.1
+    webpack-log: ^2.0.0
+  peerDependencies:
+    webpack: ^4.0.0 || ^5.0.0
+  checksum: faa3cdd7b82d23c35b8f45903556eadd92b0795c76f3e08e234d53f7bab3de13331096a71968e7e9905770ae5de7a4f75ddf09f66d1e0bbabfecbb30db0f71e3
+  languageName: node
+  linkType: hard
+
+"webpack-dev-server@npm:3.11.0":
+  version: 3.11.0
+  resolution: "webpack-dev-server@npm:3.11.0"
+  dependencies:
+    ansi-html: 0.0.7
+    bonjour: ^3.5.0
+    chokidar: ^2.1.8
+    compression: ^1.7.4
+    connect-history-api-fallback: ^1.6.0
+    debug: ^4.1.1
+    del: ^4.1.1
+    express: ^4.17.1
+    html-entities: ^1.3.1
+    http-proxy-middleware: 0.19.1
+    import-local: ^2.0.0
+    internal-ip: ^4.3.0
+    ip: ^1.1.5
+    is-absolute-url: ^3.0.3
+    killable: ^1.0.1
+    loglevel: ^1.6.8
+    opn: ^5.5.0
+    p-retry: ^3.0.1
+    portfinder: ^1.0.26
+    schema-utils: ^1.0.0
+    selfsigned: ^1.10.7
+    semver: ^6.3.0
+    serve-index: ^1.9.1
+    sockjs: 0.3.20
+    sockjs-client: 1.4.0
+    spdy: ^4.0.2
+    strip-ansi: ^3.0.1
+    supports-color: ^6.1.0
+    url: ^0.11.0
+    webpack-dev-middleware: ^3.7.2
+    webpack-log: ^2.0.0
+    ws: ^6.2.1
+    yargs: ^13.3.2
+  peerDependencies:
+    webpack: ^4.0.0 || ^5.0.0
+  peerDependenciesMeta:
+    webpack-cli:
+      optional: true
+  bin:
+    webpack-dev-server: bin/webpack-dev-server.js
+  checksum: d0f9519d53ef05c87030654b66455b984adc065ca29c1b7ca75d70dc6e7a818a643b2a8613ad014a916c9be52df54fe0dede4f0a7bc638b8c73088d7710e7e0a
+  languageName: node
+  linkType: hard
+
+"webpack-log@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "webpack-log@npm:2.0.0"
+  dependencies:
+    ansi-colors: ^3.0.0
+    uuid: ^3.3.2
+  checksum: 4757179310995e20633ec2d77a8c1ac11e4135c84745f57148692f8195f1c0f8ec122c77d0dc16fc484b7d301df6674f36c9fc6b1ff06b5cf142abaaf5d24f4f
+  languageName: node
+  linkType: hard
+
+"webpack-manifest-plugin@npm:2.2.0":
+  version: 2.2.0
+  resolution: "webpack-manifest-plugin@npm:2.2.0"
+  dependencies:
+    fs-extra: ^7.0.0
+    lodash: ">=3.5 <5"
+    object.entries: ^1.1.0
+    tapable: ^1.0.0
+  peerDependencies:
+    webpack: 2 || 3 || 4
+  checksum: ed1387774031a59bc1bd5f79150e7a49dcf5048a6d5e9652672637bed7f93df6220cbd88b2e371e7c8c8e7640b3a8ed6895f771c6b05a8bb90b721f82001ac25
+  languageName: node
+  linkType: hard
+
+"webpack-sources@npm:^1.1.0, webpack-sources@npm:^1.4.0, webpack-sources@npm:^1.4.1, webpack-sources@npm:^1.4.3":
+  version: 1.4.3
+  resolution: "webpack-sources@npm:1.4.3"
+  dependencies:
+    source-list-map: ^2.0.0
+    source-map: ~0.6.1
+  checksum: 37463dad8d08114930f4bc4882a9602941f07c9f0efa9b6bc78738cd936275b990a596d801ef450d022bb005b109b9f451dd087db2f3c9baf53e8e22cf388f79
+  languageName: node
+  linkType: hard
+
+"webpack@npm:4.42.0":
+  version: 4.42.0
+  resolution: "webpack@npm:4.42.0"
+  dependencies:
+    "@webassemblyjs/ast": 1.8.5
+    "@webassemblyjs/helper-module-context": 1.8.5
+    "@webassemblyjs/wasm-edit": 1.8.5
+    "@webassemblyjs/wasm-parser": 1.8.5
+    acorn: ^6.2.1
+    ajv: ^6.10.2
+    ajv-keywords: ^3.4.1
+    chrome-trace-event: ^1.0.2
+    enhanced-resolve: ^4.1.0
+    eslint-scope: ^4.0.3
+    json-parse-better-errors: ^1.0.2
+    loader-runner: ^2.4.0
+    loader-utils: ^1.2.3
+    memory-fs: ^0.4.1
+    micromatch: ^3.1.10
+    mkdirp: ^0.5.1
+    neo-async: ^2.6.1
+    node-libs-browser: ^2.2.1
+    schema-utils: ^1.0.0
+    tapable: ^1.1.3
+    terser-webpack-plugin: ^1.4.3
+    watchpack: ^1.6.0
+    webpack-sources: ^1.4.1
+  bin:
+    webpack: bin/webpack.js
+  checksum: c297519655f431d749abf0383a0c565d3fdad14baef70fc96b09285f1da6cdad22b24f7f1974ef07314f0d088235ad2e26090ebd497fb4b7fbe5e8372117d734
+  languageName: node
+  linkType: hard
+
+"websocket-driver@npm:0.6.5":
+  version: 0.6.5
+  resolution: "websocket-driver@npm:0.6.5"
+  dependencies:
+    websocket-extensions: ">=0.1.1"
+  checksum: f9feb459d9abea0bffce618c1c29b73fcddfaefdd2fc0d7348218628dd78eaf57b5c616364e0ec53917f48e33976a8bb6b604fa649b9b63210f265613e090271
+  languageName: node
+  linkType: hard
+
+"websocket-driver@npm:>=0.5.1":
+  version: 0.7.4
+  resolution: "websocket-driver@npm:0.7.4"
+  dependencies:
+    http-parser-js: ">=0.5.1"
+    safe-buffer: ">=5.1.0"
+    websocket-extensions: ">=0.1.1"
+  checksum: fffe5a33fe8eceafd21d2a065661d09e38b93877eae1de6ab5d7d2734c6ed243973beae10ae48c6613cfd675f200e5a058d1e3531bc9e6c5d4f1396ff1f0bfb9
+  languageName: node
+  linkType: hard
+
+"websocket-extensions@npm:>=0.1.1":
+  version: 0.1.4
+  resolution: "websocket-extensions@npm:0.1.4"
+  checksum: 5976835e68a86afcd64c7a9762ed85f2f27d48c488c707e67ba85e717b90fa066b98ab33c744d64255c9622d349eedecf728e65a5f921da71b58d0e9591b9038
+  languageName: node
+  linkType: hard
+
+"whatwg-encoding@npm:^1.0.1, whatwg-encoding@npm:^1.0.3, whatwg-encoding@npm:^1.0.5":
+  version: 1.0.5
+  resolution: "whatwg-encoding@npm:1.0.5"
+  dependencies:
+    iconv-lite: 0.4.24
+  checksum: 5be4efe111dce29ddee3448d3915477fcc3b28f991d9cf1300b4e50d6d189010d47bca2f51140a844cf9b726e8f066f4aee72a04d687bfe4f2ee2767b2f5b1e6
+  languageName: node
+  linkType: hard
+
+"whatwg-fetch@npm:>=0.10.0, whatwg-fetch@npm:^3.0.0":
+  version: 3.6.2
+  resolution: "whatwg-fetch@npm:3.6.2"
+  checksum: ee976b7249e7791edb0d0a62cd806b29006ad7ec3a3d89145921ad8c00a3a67e4be8f3fb3ec6bc7b58498724fd568d11aeeeea1f7827e7e1e5eae6c8a275afed
+  languageName: node
+  linkType: hard
+
+"whatwg-mimetype@npm:^2.1.0, whatwg-mimetype@npm:^2.2.0, whatwg-mimetype@npm:^2.3.0":
+  version: 2.3.0
+  resolution: "whatwg-mimetype@npm:2.3.0"
+  checksum: 23eb885940bcbcca4ff841c40a78e9cbb893ec42743993a42bf7aed16085b048b44b06f3402018931687153550f9a32d259dfa524e4f03577ab898b6965e5383
+  languageName: node
+  linkType: hard
+
+"whatwg-url@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "whatwg-url@npm:5.0.0"
+  dependencies:
+    tr46: ~0.0.3
+    webidl-conversions: ^3.0.0
+  checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c
+  languageName: node
+  linkType: hard
+
+"whatwg-url@npm:^6.4.1":
+  version: 6.5.0
+  resolution: "whatwg-url@npm:6.5.0"
+  dependencies:
+    lodash.sortby: ^4.7.0
+    tr46: ^1.0.1
+    webidl-conversions: ^4.0.2
+  checksum: a10bd5e29f4382cd19789c2a7bbce25416e606b6fefc241c7fe34a2449de5bc5709c165bd13634eda433942d917ca7386a52841780b82dc37afa8141c31a8ebd
+  languageName: node
+  linkType: hard
+
+"whatwg-url@npm:^7.0.0":
+  version: 7.1.0
+  resolution: "whatwg-url@npm:7.1.0"
+  dependencies:
+    lodash.sortby: ^4.7.0
+    tr46: ^1.0.1
+    webidl-conversions: ^4.0.2
+  checksum: fecb07c87290b47d2ec2fb6d6ca26daad3c9e211e0e531dd7566e7ff95b5b3525a57d4f32640ad4adf057717e0c215731db842ad761e61d947e81010e05cf5fd
+  languageName: node
+  linkType: hard
+
+"which-boxed-primitive@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "which-boxed-primitive@npm:1.0.2"
+  dependencies:
+    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
+  checksum: 53ce774c7379071729533922adcca47220228405e1895f26673bbd71bdf7fb09bee38c1d6399395927c6289476b5ae0629863427fd151491b71c4b6cb04f3a5e
+  languageName: node
+  linkType: hard
+
+"which-module@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "which-module@npm:1.0.0"
+  checksum: 98434f7deb36350cb543c1f15612188541737e1f12d39b23b1c371dff5cf4aa4746210f2bdec202d5fe9da8682adaf8e3f7c44c520687d30948cfc59d5534edb
+  languageName: node
+  linkType: hard
+
+"which-module@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "which-module@npm:2.0.0"
+  checksum: 809f7fd3dfcb2cdbe0180b60d68100c88785084f8f9492b0998c051d7a8efe56784492609d3f09ac161635b78ea29219eb1418a98c15ce87d085bce905705c9c
+  languageName: node
+  linkType: hard
+
+"which@npm:^1.2.9, which@npm:^1.3.0, which@npm:^1.3.1":
+  version: 1.3.1
+  resolution: "which@npm:1.3.1"
+  dependencies:
+    isexe: ^2.0.0
+  bin:
+    which: ./bin/which
+  checksum: f2e185c6242244b8426c9df1510e86629192d93c1a986a7d2a591f2c24869e7ffd03d6dac07ca863b2e4c06f59a4cc9916c585b72ee9fa1aa609d0124df15e04
+  languageName: node
+  linkType: hard
+
+"which@npm:^2.0.1, which@npm:^2.0.2":
+  version: 2.0.2
+  resolution: "which@npm:2.0.2"
+  dependencies:
+    isexe: ^2.0.0
+  bin:
+    node-which: ./bin/node-which
+  checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1
+  languageName: node
+  linkType: hard
+
+"wide-align@npm:^1.1.2, wide-align@npm:^1.1.5":
+  version: 1.1.5
+  resolution: "wide-align@npm:1.1.5"
+  dependencies:
+    string-width: ^1.0.2 || 2 || 3 || 4
+  checksum: d5fc37cd561f9daee3c80e03b92ed3e84d80dde3365a8767263d03dacfc8fa06b065ffe1df00d8c2a09f731482fcacae745abfbb478d4af36d0a891fad4834d3
+  languageName: node
+  linkType: hard
+
+"word-wrap@npm:~1.2.3":
+  version: 1.2.5
+  resolution: "word-wrap@npm:1.2.5"
+  checksum: f93ba3586fc181f94afdaff3a6fef27920b4b6d9eaefed0f428f8e07adea2a7f54a5f2830ce59406c8416f033f86902b91eb824072354645eea687dff3691ccb
+  languageName: node
+  linkType: hard
+
+"workbox-background-sync@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-background-sync@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: 25564fb0adc36396ea60308c4f8184cffe245eca9bd931a8154fc25736297071448be43de85b0b477da74e61410cdf60a295b25d4d3e780fa36b73ef983cc678
+  languageName: node
+  linkType: hard
+
+"workbox-broadcast-update@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-broadcast-update@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: f62035645d37b0763f09a5b688dbdba14b28ac69c2b8d609b6a68be888c8a9c384186cde01fc1c41ac9d45e383320a6cf743a9209d7390d97d27c61c5ace64f3
+  languageName: node
+  linkType: hard
+
+"workbox-build@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-build@npm:4.3.1"
+  dependencies:
+    "@babel/runtime": ^7.3.4
+    "@hapi/joi": ^15.0.0
+    common-tags: ^1.8.0
+    fs-extra: ^4.0.2
+    glob: ^7.1.3
+    lodash.template: ^4.4.0
+    pretty-bytes: ^5.1.0
+    stringify-object: ^3.3.0
+    strip-comments: ^1.0.2
+    workbox-background-sync: ^4.3.1
+    workbox-broadcast-update: ^4.3.1
+    workbox-cacheable-response: ^4.3.1
+    workbox-core: ^4.3.1
+    workbox-expiration: ^4.3.1
+    workbox-google-analytics: ^4.3.1
+    workbox-navigation-preload: ^4.3.1
+    workbox-precaching: ^4.3.1
+    workbox-range-requests: ^4.3.1
+    workbox-routing: ^4.3.1
+    workbox-strategies: ^4.3.1
+    workbox-streams: ^4.3.1
+    workbox-sw: ^4.3.1
+    workbox-window: ^4.3.1
+  checksum: 3bf0f400512b621a67f2f7ab9f1beb7964c12cb12186da1a2a51ec456a8b63e0c9a2e0fbd31c003aecd2779fb4061e8cad73b8fe94e790e141aa110169d6504b
+  languageName: node
+  linkType: hard
+
+"workbox-cacheable-response@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-cacheable-response@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: c281f40388891a7920b7ecf73a61b0b9274174c17d703ef2a4c6ecb2e0a277ff447c24205594e50a922adca40de39767ebc34c79cfba9040abf10e4b879142b5
+  languageName: node
+  linkType: hard
+
+"workbox-core@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-core@npm:4.3.1"
+  checksum: c3e31bb24c4bfbc2be129c7745c12512c6e061dfa032b0dbe3620aa1b15fe12df433c6f39f17bcaebef2d2826a5ca18760b778d12c86876295e2cf121725ca09
+  languageName: node
+  linkType: hard
+
+"workbox-expiration@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-expiration@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: c1bfa47278720d1729a88562b1e2a5d0d7d27d6b625190ad6db2a3518ad4907833b1b9182a6e7dae687e4f12e13047b102b1b82f6fe9529523c82e74729d023a
+  languageName: node
+  linkType: hard
+
+"workbox-google-analytics@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-google-analytics@npm:4.3.1"
+  dependencies:
+    workbox-background-sync: ^4.3.1
+    workbox-core: ^4.3.1
+    workbox-routing: ^4.3.1
+    workbox-strategies: ^4.3.1
+  checksum: 225cea09758767bba9be553578e5d6f509ef055149a07df0b366c6f17dcd98220a939e48d1cacb3132f1d4d2e896d093a6ee00744498522b3aa25d48e9f21eb4
+  languageName: node
+  linkType: hard
+
+"workbox-navigation-preload@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-navigation-preload@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: 50c2bc59b66f980e5d5c9798f8e8883a6fd5af982ccfd4938e17de126cb2f4a614b143e3cff8862e140ccb7db3ce695162c98be8cf798d69e41266b20f74a74c
+  languageName: node
+  linkType: hard
+
+"workbox-precaching@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-precaching@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: afac7991d4f1d660d0fa97437f18a3e67ed978991c4f1f159b0bb3d267f3d6b6aa34f0a7f505e298acb0d66af33224b0f1b8eac0f05c39a319d1a3b4203c6ee5
+  languageName: node
+  linkType: hard
+
+"workbox-range-requests@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-range-requests@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: bf0a2daebc4611c97f83c068911f7724e8c92c7270ee40f1e815fc227eb29a920fced0ce88421b413ca688574d1b5731bc45b0c34a208f9e0eace4d2b302eb5f
+  languageName: node
+  linkType: hard
+
+"workbox-routing@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-routing@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: fb8bc5f67246c418b6fd15d9763a4200633cc099edb13d4a266ddf8c23f5a0c1fe2e2fc8380928eb1c1ee0d821d677355706e294113650638bd809c589ae24d4
+  languageName: node
+  linkType: hard
+
+"workbox-strategies@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-strategies@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: dc49af50ddc9c240160f997e195cbe57efe8cb764eb2652174778bc44f3697b9680784a00af2c55ad56d41ed507c4140c4985c2f74ee9d4b3f68e99c889a54f4
+  languageName: node
+  linkType: hard
+
+"workbox-streams@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-streams@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: 7a06e4a10eb30ed6ba90ed6647049355db251d970e9f3d1e3f4d20b4ca9d25082275301d31c0f506761bbc494956cd542c073db67d6a6bb4ff069f5e77bce510
+  languageName: node
+  linkType: hard
+
+"workbox-sw@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-sw@npm:4.3.1"
+  checksum: 349a9b1a3c9b57dc1925a8709f9af3e90d6c6b8e56f10c88c70236abdf5ba8e3a66f8c004356fc1cb7c24cfabf0f162b132930454e1fb390d29e2ce46696f3a5
+  languageName: node
+  linkType: hard
+
+"workbox-webpack-plugin@npm:4.3.1":
+  version: 4.3.1
+  resolution: "workbox-webpack-plugin@npm:4.3.1"
+  dependencies:
+    "@babel/runtime": ^7.0.0
+    json-stable-stringify: ^1.0.1
+    workbox-build: ^4.3.1
+  peerDependencies:
+    webpack: ^2.0.0 || ^3.0.0 || ^4.0.0
+  checksum: 7282d849d96c90a82b985784279d28bf1a95534429b3f95cc9f028142f10cea78e13a608f7e29a545e4f82cbe7b081a6e82ad57131dc09604c239a4b53a7a860
+  languageName: node
+  linkType: hard
+
+"workbox-window@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "workbox-window@npm:4.3.1"
+  dependencies:
+    workbox-core: ^4.3.1
+  checksum: 60b854fb0febdde236b0285eb050131043446a0c011629f8480224b1cee3a2f9f13f4c851f4a30dd8a76aa58503436c16f17662ef0eb40d0e1c630842165e718
+  languageName: node
+  linkType: hard
+
+"worker-farm@npm:^1.7.0":
+  version: 1.7.0
+  resolution: "worker-farm@npm:1.7.0"
+  dependencies:
+    errno: ~0.1.7
+  checksum: eab917530e1feddf157ec749e9c91b73a886142daa7fdf3490bccbf7b548b2576c43ab8d0a98e72ac755cbc101ca8647a7b1ff2485fddb9e8f53c40c77f5a719
+  languageName: node
+  linkType: hard
+
+"worker-rpc@npm:^0.1.0":
+  version: 0.1.1
+  resolution: "worker-rpc@npm:0.1.1"
+  dependencies:
+    microevent.ts: ~0.1.1
+  checksum: 8f8607506172f44c05490f3ccf13e5c1f430eeb9b6116a405919c186b8b17add13bbb22467a0dbcd18ec7fcb080709a15738182e0003c5fbe2144721ea00f357
+  languageName: node
+  linkType: hard
+
+"wrap-ansi@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "wrap-ansi@npm:2.1.0"
+  dependencies:
+    string-width: ^1.0.1
+    strip-ansi: ^3.0.1
+  checksum: 2dacd4b3636f7a53ee13d4d0fe7fa2ed9ad81e9967e17231924ea88a286ec4619a78288de8d41881ee483f4449ab2c0287cde8154ba1bd0126c10271101b2ee3
+  languageName: node
+  linkType: hard
+
+"wrap-ansi@npm:^5.1.0":
+  version: 5.1.0
+  resolution: "wrap-ansi@npm:5.1.0"
+  dependencies:
+    ansi-styles: ^3.2.0
+    string-width: ^3.0.0
+    strip-ansi: ^5.0.0
+  checksum: 9b48c862220e541eb0daa22661b38b947973fc57054e91be5b0f2dcc77741a6875ccab4ebe970a394b4682c8dfc17e888266a105fb8b0a9b23c19245e781ceae
+  languageName: node
+  linkType: hard
+
+"wrap-ansi@npm:^6.2.0":
+  version: 6.2.0
+  resolution: "wrap-ansi@npm:6.2.0"
+  dependencies:
+    ansi-styles: ^4.0.0
+    string-width: ^4.1.0
+    strip-ansi: ^6.0.0
+  checksum: 6cd96a410161ff617b63581a08376f0cb9162375adeb7956e10c8cd397821f7eb2a6de24eb22a0b28401300bf228c86e50617cd568209b5f6775b93c97d2fe3a
+  languageName: node
+  linkType: hard
+
+"wrap-ansi@npm:^7.0.0":
+  version: 7.0.0
+  resolution: "wrap-ansi@npm:7.0.0"
+  dependencies:
+    ansi-styles: ^4.0.0
+    string-width: ^4.1.0
+    strip-ansi: ^6.0.0
+  checksum: a790b846fd4505de962ba728a21aaeda189b8ee1c7568ca5e817d85930e06ef8d1689d49dbf0e881e8ef84436af3a88bc49115c2e2788d841ff1b8b5b51a608b
+  languageName: node
+  linkType: hard
+
+"wrappy@npm:1":
+  version: 1.0.2
+  resolution: "wrappy@npm:1.0.2"
+  checksum: 159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5
+  languageName: node
+  linkType: hard
+
+"write-file-atomic@npm:2.4.1":
+  version: 2.4.1
+  resolution: "write-file-atomic@npm:2.4.1"
+  dependencies:
+    graceful-fs: ^4.1.11
+    imurmurhash: ^0.1.4
+    signal-exit: ^3.0.2
+  checksum: 9a032212214fb281fa7004e53115dfe38cd6f7191902ac7b691524c42f565f9083f2bb810aa30936b25559ed9f9b1772a2e385c29e5e7e4ef1253388610acdf1
+  languageName: node
+  linkType: hard
+
+"write@npm:1.0.3":
+  version: 1.0.3
+  resolution: "write@npm:1.0.3"
+  dependencies:
+    mkdirp: ^0.5.1
+  checksum: 6496197ceb2d6faeeb8b5fe2659ca804e801e4989dff9fb8a66fe76179ce4ccc378c982ef906733caea1220c8dbe05a666d82127959ac4456e70111af8b8df73
+  languageName: node
+  linkType: hard
+
+"ws@npm:^5.2.0":
+  version: 5.2.3
+  resolution: "ws@npm:5.2.3"
+  dependencies:
+    async-limiter: ~1.0.0
+  checksum: bdb2223a40c2c68cf91b25a6c9b8c67d5275378ec6187f343314d3df7530e55b77cb9fe79fb1c6a9758389ac5aefc569d24236924b5c65c5dbbaff409ef739fc
+  languageName: node
+  linkType: hard
+
+"ws@npm:^6.1.2, ws@npm:^6.2.1":
+  version: 6.2.2
+  resolution: "ws@npm:6.2.2"
+  dependencies:
+    async-limiter: ~1.0.0
+  checksum: aec3154ec51477c094ac2cb5946a156e17561a581fa27005cbf22c53ac57f8d4e5f791dd4bbba6a488602cb28778c8ab7df06251d590507c3c550fd8ebeee949
+  languageName: node
+  linkType: hard
+
+"xml-name-validator@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "xml-name-validator@npm:3.0.0"
+  checksum: b3ac459afed783c285bb98e4960bd1f3ba12754fd4f2320efa0f9181ca28928c53cc75ca660d15d205e81f92304419afe94c531c7cfb3e0649aa6d140d53ecb0
+  languageName: node
+  linkType: hard
+
+"xmlchars@npm:^2.1.1":
+  version: 2.2.0
+  resolution: "xmlchars@npm:2.2.0"
+  checksum: 8c70ac94070ccca03f47a81fcce3b271bd1f37a591bf5424e787ae313fcb9c212f5f6786e1fa82076a2c632c0141552babcd85698c437506dfa6ae2d58723062
+  languageName: node
+  linkType: hard
+
+"xregexp@npm:^4.3.0":
+  version: 4.4.1
+  resolution: "xregexp@npm:4.4.1"
+  dependencies:
+    "@babel/runtime-corejs3": ^7.12.1
+  checksum: 134d70116655f0de90725a0d2aaf73b2a69f8b4cd7f1908e394c7ff4de53819a0a2d9595e1722d71334a33d9392071b1f983f5954c57d83ab3e451116d9f8499
+  languageName: node
+  linkType: hard
+
+"xtend@npm:^4.0.0, xtend@npm:~4.0.1":
+  version: 4.0.2
+  resolution: "xtend@npm:4.0.2"
+  checksum: ac5dfa738b21f6e7f0dd6e65e1b3155036d68104e67e5d5d1bde74892e327d7e5636a076f625599dc394330a731861e87343ff184b0047fef1360a7ec0a5a36a
+  languageName: node
+  linkType: hard
+
+"y18n@npm:^3.2.1":
+  version: 3.2.2
+  resolution: "y18n@npm:3.2.2"
+  checksum: 6154fd7544f8bbf5b18cdf77692ed88d389be49c87238ecb4e0d6a5276446cd2a5c29cc4bdbdddfc7e4e498b08df9d7e38df4a1453cf75eecfead392246ea74a
+  languageName: node
+  linkType: hard
+
+"y18n@npm:^4.0.0":
+  version: 4.0.3
+  resolution: "y18n@npm:4.0.3"
+  checksum: 014dfcd9b5f4105c3bb397c1c8c6429a9df004aa560964fb36732bfb999bfe83d45ae40aeda5b55d21b1ee53d8291580a32a756a443e064317953f08025b1aa4
+  languageName: node
+  linkType: hard
+
+"y18n@npm:^5.0.5":
+  version: 5.0.8
+  resolution: "y18n@npm:5.0.8"
+  checksum: 54f0fb95621ee60898a38c572c515659e51cc9d9f787fb109cef6fde4befbe1c4602dc999d30110feee37456ad0f1660fa2edcfde6a9a740f86a290999550d30
+  languageName: node
+  linkType: hard
+
+"yallist@npm:^3.0.2":
+  version: 3.1.1
+  resolution: "yallist@npm:3.1.1"
+  checksum: 48f7bb00dc19fc635a13a39fe547f527b10c9290e7b3e836b9a8f1ca04d4d342e85714416b3c2ab74949c9c66f9cebb0473e6bc353b79035356103b47641285d
+  languageName: node
+  linkType: hard
+
+"yallist@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "yallist@npm:4.0.0"
+  checksum: 343617202af32df2a15a3be36a5a8c0c8545208f3d3dfbc6bb7c3e3b7e8c6f8e7485432e4f3b88da3031a6e20afa7c711eded32ddfb122896ac5d914e75848d5
+  languageName: node
+  linkType: hard
+
+"yaml@npm:^1.7.2":
+  version: 1.10.2
+  resolution: "yaml@npm:1.10.2"
+  checksum: ce4ada136e8a78a0b08dc10b4b900936912d15de59905b2bf415b4d33c63df1d555d23acb2a41b23cf9fb5da41c256441afca3d6509de7247daa062fd2c5ea5f
+  languageName: node
+  linkType: hard
+
+"yamljs@npm:0.3.0":
+  version: 0.3.0
+  resolution: "yamljs@npm:0.3.0"
+  dependencies:
+    argparse: ^1.0.7
+    glob: ^7.0.5
+  bin:
+    json2yaml: ./bin/json2yaml
+    yaml2json: ./bin/yaml2json
+  checksum: 76b770d34c7b9babdc4508e4c7c0cbdf371e17129cc027095d9eac0ae5b841c1b16fc2d625ebb542cc299ed4593478abdfcca172b3f0169e0939c6f2ed2e81a4
+  languageName: node
+  linkType: hard
+
+"yargs-parser@npm:^13.1.2":
+  version: 13.1.2
+  resolution: "yargs-parser@npm:13.1.2"
+  dependencies:
+    camelcase: ^5.0.0
+    decamelize: ^1.2.0
+  checksum: c8bb6f44d39a4acd94462e96d4e85469df865de6f4326e0ab1ac23ae4a835e5dd2ddfe588317ebf80c3a7e37e741bd5cb0dc8d92bcc5812baefb7df7c885e86b
+  languageName: node
+  linkType: hard
+
+"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3":
+  version: 20.2.9
+  resolution: "yargs-parser@npm:20.2.9"
+  checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3
+  languageName: node
+  linkType: hard
+
+"yargs-parser@npm:^21.1.1":
+  version: 21.1.1
+  resolution: "yargs-parser@npm:21.1.1"
+  checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c
+  languageName: node
+  linkType: hard
+
+"yargs-parser@npm:^5.0.1":
+  version: 5.0.1
+  resolution: "yargs-parser@npm:5.0.1"
+  dependencies:
+    camelcase: ^3.0.0
+    object.assign: ^4.1.0
+  checksum: 8eff7f3653afc9185cb917ee034d189c1ba4bc0fd5005c9588442e25557e9bf69c7331663a6f9a2bb897cd4c1544ba9675ed3335133e19e660a3086fedc259db
+  languageName: node
+  linkType: hard
+
+"yargs@npm:^13.3.0, yargs@npm:^13.3.2":
+  version: 13.3.2
+  resolution: "yargs@npm:13.3.2"
+  dependencies:
+    cliui: ^5.0.0
+    find-up: ^3.0.0
+    get-caller-file: ^2.0.1
+    require-directory: ^2.1.1
+    require-main-filename: ^2.0.0
+    set-blocking: ^2.0.0
+    string-width: ^3.0.0
+    which-module: ^2.0.0
+    y18n: ^4.0.0
+    yargs-parser: ^13.1.2
+  checksum: 75c13e837eb2bb25717957ba58d277e864efc0cca7f945c98bdf6477e6ec2f9be6afa9ed8a876b251a21423500c148d7b91e88dee7adea6029bdec97af1ef3e8
+  languageName: node
+  linkType: hard
+
+"yargs@npm:^17.0.0":
+  version: 17.0.1
+  resolution: "yargs@npm:17.0.1"
+  dependencies:
+    cliui: ^7.0.2
+    escalade: ^3.1.1
+    get-caller-file: ^2.0.5
+    require-directory: ^2.1.1
+    string-width: ^4.2.0
+    y18n: ^5.0.5
+    yargs-parser: ^20.2.2
+  checksum: 4ffffa5a82647e5d07840b64bed88c365b901d3d4a4c51745dddb10d177902d85014026d7224aae18c42df9ca3f75a41c5aff556e5342e2f8ffc5177d149cd17
+  languageName: node
+  linkType: hard
+
+"yargs@npm:^17.2.1":
+  version: 17.7.2
+  resolution: "yargs@npm:17.7.2"
+  dependencies:
+    cliui: ^8.0.1
+    escalade: ^3.1.1
+    get-caller-file: ^2.0.5
+    require-directory: ^2.1.1
+    string-width: ^4.2.3
+    y18n: ^5.0.5
+    yargs-parser: ^21.1.1
+  checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a
+  languageName: node
+  linkType: hard
+
+"yargs@npm:^7.0.0":
+  version: 7.1.2
+  resolution: "yargs@npm:7.1.2"
+  dependencies:
+    camelcase: ^3.0.0
+    cliui: ^3.2.0
+    decamelize: ^1.1.1
+    get-caller-file: ^1.0.1
+    os-locale: ^1.4.0
+    read-pkg-up: ^1.0.1
+    require-directory: ^2.1.1
+    require-main-filename: ^1.0.1
+    set-blocking: ^2.0.0
+    string-width: ^1.0.2
+    which-module: ^1.0.0
+    y18n: ^3.2.1
+    yargs-parser: ^5.0.1
+  checksum: 0c330ce1338cd9f293157bf8955af6833ae59032ab1bc936510ce7a216de9bb65b05b39a82ff0e7359bfb643342cc05de5049ce50ee9404b0818f65911fb59a5
+  languageName: node
+  linkType: hard
+
+"yauzl@npm:^2.10.0":
+  version: 2.10.0
+  resolution: "yauzl@npm:2.10.0"
+  dependencies:
+    buffer-crc32: ~0.2.3
+    fd-slicer: ~1.1.0
+  checksum: 7f21fe0bbad6e2cb130044a5d1d0d5a0e5bf3d8d4f8c4e6ee12163ce798fee3de7388d22a7a0907f563ac5f9d40f8699a223d3d5c1718da90b0156da6904022b
+  languageName: node
+  linkType: hard
index a67df1511723fecf169f004a42abf1cabceec511..cdb5c467fe9f5ca8f25f2306e0a6b50476a3fe41 100644 (file)
@@ -6,11 +6,11 @@
 // cache-invalidation event feed at "ws://.../websocket") to
 // websocket clients.
 //
-// Installation and configuration
+// Installation and configuration
 //
 // See https://doc.arvados.org/install/install-ws.html.
 //
-// Developer info
+// Developer info
 //
 // See https://dev.arvados.org/projects/arvados/wiki/Hacking_websocket_server.
 package ws
index c989c0ca559b1a1cff472b2cc1bdb95b4fd021ce..8b6a2e81bbccafb75f0b7c3f70e362dc027a2b5d 100644 (file)
@@ -26,7 +26,7 @@ type eventSource interface {
 }
 
 type event struct {
-       LogID    uint64
+       LogID    int64
        Received time.Time
        Ready    time.Time
        Serial   uint64
index 3593c3aebd58ceae6932e9667eca43aba8a8c0cf..e0269701c9715f5673650750dc7c00ba0b66159d 100644 (file)
@@ -19,6 +19,11 @@ import (
        "github.com/sirupsen/logrus"
 )
 
+var (
+       listenerPingInterval = time.Minute
+       testSlowPing         = false
+)
+
 type pgEventSource struct {
        DataSource   string
        MaxOpenConns int
@@ -248,22 +253,36 @@ func (ps *pgEventSource) Run() {
        }()
 
        var serial uint64
-       ticker := time.NewTicker(time.Minute)
-       defer ticker.Stop()
+
+       go func() {
+               ticker := time.NewTicker(listenerPingInterval)
+               defer ticker.Stop()
+               for {
+                       select {
+                       case <-ctx.Done():
+                               ps.Logger.Debug("ctx done")
+                               return
+
+                       case <-ticker.C:
+                               ps.Logger.Debug("listener ping")
+                               if testSlowPing {
+                                       time.Sleep(time.Second / 2)
+                               }
+                               err := ps.pqListener.Ping()
+                               if err != nil {
+                                       ps.listenerProblem(-1, fmt.Errorf("pqListener ping failed: %s", err))
+                                       continue
+                               }
+                       }
+               }
+       }()
+
        for {
                select {
                case <-ctx.Done():
                        ps.Logger.Debug("ctx done")
                        return
 
-               case <-ticker.C:
-                       ps.Logger.Debug("listener ping")
-                       err := ps.pqListener.Ping()
-                       if err != nil {
-                               ps.listenerProblem(-1, fmt.Errorf("pqListener ping failed: %s", err))
-                               continue
-                       }
-
                case pqEvent, ok := <-ps.pqListener.Notify:
                        if !ok {
                                ps.Logger.Error("pqListener Notify chan closed")
@@ -281,7 +300,7 @@ func (ps *pgEventSource) Run() {
                                ps.Logger.WithField("pqEvent", pqEvent).Error("unexpected notify from wrong channel")
                                continue
                        }
-                       logID, err := strconv.ParseUint(pqEvent.Extra, 10, 64)
+                       logID, err := strconv.ParseInt(pqEvent.Extra, 10, 64)
                        if err != nil {
                                ps.Logger.WithField("pqEvent", pqEvent).Error("bad notify payload")
                                continue
index b7b8ac3006f3fa6af19de31737af82129dbf8642..d02b1999392bdce12dcb62f65a007945ece1a012 100644 (file)
@@ -80,14 +80,14 @@ func (*eventSourceSuite) TestEventSource(c *check.C) {
                        for i := 0; i <= si; i++ {
                                ev := <-sinks[si].Channel()
                                c.Logf("sink %d received event %d", si, i)
-                               c.Check(ev.LogID, check.Equals, uint64(i))
+                               c.Check(ev.LogID, check.Equals, int64(i))
                                row := ev.Detail()
                                if i == 0 {
                                        // no matching row, null event
                                        c.Check(row, check.IsNil)
                                } else {
                                        c.Check(row, check.NotNil)
-                                       c.Check(row.ID, check.Equals, uint64(i))
+                                       c.Check(row.ID, check.Equals, int64(i))
                                        c.Check(row.UUID, check.Not(check.Equals), "")
                                }
                        }
index 912643ad97c6374006b3fd4b00f90d340157d687..8b6e9b97728257e12e86cd6fe306965be2e0a981 100644 (file)
@@ -38,9 +38,6 @@ func (h *handler) Handle(ws wsConn, logger logrus.FieldLogger, eventSource event
        ctx, cancel := context.WithCancel(ws.Request().Context())
        defer cancel()
 
-       incoming := eventSource.NewSink()
-       defer incoming.Stop()
-
        queue := make(chan interface{}, h.QueueSize)
        h.mtx.Lock()
        h.lastDelay[queue] = 0
@@ -163,6 +160,9 @@ func (h *handler) Handle(ws wsConn, logger logrus.FieldLogger, eventSource event
                ticker := time.NewTicker(h.PingTimeout)
                defer ticker.Stop()
 
+               incoming := eventSource.NewSink()
+               defer incoming.Stop()
+
                for {
                        select {
                        case <-ctx.Done():
index ac895f80e5fd7ae7933558fbfa6e6acb97a6c7b0..f71ca61167dc70d7dded2623e6edfde6c1f3cb7c 100644 (file)
@@ -24,10 +24,10 @@ type permChecker interface {
        Check(ctx context.Context, uuid string) (bool, error)
 }
 
-func newPermChecker(ac arvados.Client) permChecker {
-       ac.AuthToken = ""
+func newPermChecker(ac *arvados.Client) permChecker {
        return &cachingPermChecker{
-               Client:     &ac,
+               ac:         ac,
+               token:      "-",
                cache:      make(map[string]cacheEnt),
                maxCurrent: 16,
        }
@@ -39,7 +39,8 @@ type cacheEnt struct {
 }
 
 type cachingPermChecker struct {
-       *arvados.Client
+       ac         *arvados.Client
+       token      string
        cache      map[string]cacheEnt
        maxCurrent int
 
@@ -49,17 +50,17 @@ type cachingPermChecker struct {
 }
 
 func (pc *cachingPermChecker) SetToken(token string) {
-       if pc.Client.AuthToken == token {
+       if pc.token == token {
                return
        }
-       pc.Client.AuthToken = token
+       pc.token = token
        pc.cache = make(map[string]cacheEnt)
 }
 
 func (pc *cachingPermChecker) Check(ctx context.Context, uuid string) (bool, error) {
        pc.nChecks++
        logger := ctxlog.FromContext(ctx).
-               WithField("token", pc.Client.AuthToken).
+               WithField("token", pc.token).
                WithField("uuid", uuid)
        pc.tidy()
        now := time.Now()
@@ -67,15 +68,19 @@ func (pc *cachingPermChecker) Check(ctx context.Context, uuid string) (bool, err
                logger.WithField("allowed", perm.allowed).Debug("cache hit")
                return perm.allowed, nil
        }
-       var buf map[string]interface{}
-       path, err := pc.PathForUUID("get", uuid)
+
+       path, err := pc.ac.PathForUUID("get", uuid)
        if err != nil {
                pc.nInvalid++
                return false, err
        }
 
        pc.nMisses++
-       err = pc.RequestAndDecode(&buf, "GET", path, nil, url.Values{
+       ctx = arvados.ContextWithAuthorization(ctx, "Bearer "+pc.token)
+       ctx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Minute))
+       defer cancel()
+       var buf map[string]interface{}
+       err = pc.ac.RequestAndDecodeContext(ctx, &buf, "GET", path, nil, url.Values{
                "include_trash": {"true"},
                "select":        {`["uuid"]`},
        })
@@ -86,6 +91,9 @@ func (pc *cachingPermChecker) Check(ctx context.Context, uuid string) (bool, err
        } else if txErr, ok := err.(*arvados.TransactionError); ok && pc.isNotAllowed(txErr.StatusCode) {
                allowed = false
        } else {
+               // If "context deadline exceeded", "client
+               // disconnected", HTTP 5xx, network error, etc., don't
+               // cache the result.
                logger.WithError(err).Error("lookup error")
                return false, err
        }
index 023656c01fd93dc3a912283682ffc9eda59c7e6b..2a22eae609d4c154d5ccff8174ee316662b7da4c 100644 (file)
@@ -17,7 +17,11 @@ var _ = check.Suite(&permSuite{})
 type permSuite struct{}
 
 func (s *permSuite) TestCheck(c *check.C) {
-       pc := newPermChecker(*(arvados.NewClientFromEnv())).(*cachingPermChecker)
+       client := arvados.NewClientFromEnv()
+       // Disable auto-retry
+       client.Timeout = 0
+
+       pc := newPermChecker(client).(*cachingPermChecker)
        setToken := func(label, token string) {
                c.Logf("...%s token %q", label, token)
                pc.SetToken(token)
@@ -69,7 +73,7 @@ func (s *permSuite) TestCheck(c *check.C) {
        pc.SetToken(arvadostest.ActiveToken)
 
        c.Log("...network error")
-       pc.Client.APIHost = "127.0.0.1:9"
+       pc.ac.APIHost = "127.0.0.1:9"
        wantError(arvadostest.UserAgreementCollection)
        wantError(arvadostest.FooBarDirCollection)
 
index 761e22e16c2cd4fd5025341e9ad284eb74b6f8c7..9a4a239ea195952a01a3726d2e53c52c892f0e93 100644 (file)
@@ -7,6 +7,7 @@ package ws
 import (
        "context"
        "fmt"
+       "time"
 
        "git.arvados.org/arvados.git/lib/cmd"
        "git.arvados.org/arvados.git/lib/service"
@@ -24,6 +25,7 @@ func newHandler(ctx context.Context, cluster *arvados.Cluster, token string, reg
        if err != nil {
                return service.ErrorHandler(ctx, cluster, fmt.Errorf("error initializing client from cluster config: %s", err))
        }
+       client.Timeout = time.Minute
        eventSource := &pgEventSource{
                DataSource:   cluster.PostgreSQL.Connection.String(),
                MaxOpenConns: cluster.PostgreSQL.ConnectionPool,
@@ -45,7 +47,7 @@ func newHandler(ctx context.Context, cluster *arvados.Cluster, token string, reg
                cluster:        cluster,
                client:         client,
                eventSource:    eventSource,
-               newPermChecker: func() permChecker { return newPermChecker(*client) },
+               newPermChecker: func() permChecker { return newPermChecker(client) },
                done:           done,
                reg:            reg,
        }
index 309352b39edbd329aa031ec0c6194791341acec9..98ec762147ac1a57c30ec70b0261affcf3644683 100644 (file)
@@ -30,6 +30,7 @@ var (
                "name",
                "owner_uuid",
                "portable_data_hash",
+               "requesting_container_uuid",
                "state",
        }
 
@@ -201,9 +202,9 @@ func (sub *v0subscribe) sendOldEvents(sess *v0session) {
                return
        }
 
-       var ids []uint64
+       var ids []int64
        for rows.Next() {
-               var id uint64
+               var id int64
                err := rows.Scan(&id)
                if err != nil {
                        sess.log.WithError(err).Error("sendOldEvents row Scan failed")
index 7986cc7b08f95598ae4756be0aa1ca3dea2e2f7b..7d15543c05277cc5f7056af7116443ee55a1cc51 100644 (file)
@@ -6,13 +6,17 @@ package ws
 
 import (
        "bytes"
+       "context"
        "encoding/json"
+       "errors"
        "fmt"
        "io"
+       "math/rand"
        "net/url"
        "os"
        "strings"
        "sync"
+       "sync/atomic"
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
@@ -35,7 +39,7 @@ type v0Suite struct {
        token        string
        toDelete     []string
        wg           sync.WaitGroup
-       ignoreLogID  uint64
+       ignoreLogID  int64
 }
 
 func (s *v0Suite) SetUpTest(c *check.C) {
@@ -60,15 +64,14 @@ func (s *v0Suite) deleteTestObjects(c *check.C) {
        ac.AuthToken = arvadostest.AdminToken
        for _, path := range s.toDelete {
                err := ac.RequestAndDecode(nil, "DELETE", path, nil, nil)
-               if err != nil {
-                       panic(err)
-               }
+               c.Check(err, check.IsNil)
        }
        s.toDelete = nil
 }
 
 func (s *v0Suite) TestFilters(c *check.C) {
-       conn, r, w := s.testClient()
+       conn, r, w, err := s.testClient()
+       c.Assert(err, check.IsNil)
        defer conn.Close()
 
        cmd := func(method, eventType string, status int) {
@@ -86,7 +89,7 @@ func (s *v0Suite) TestFilters(c *check.C) {
        cmd("unsubscribe", "create", 200)
        cmd("unsubscribe", "update", 200)
 
-       go s.emitEvents(nil)
+       go s.emitEvents(c, nil, nil)
        lg := s.expectLog(c, r)
        c.Check(lg.EventType, check.Equals, "update")
 
@@ -111,7 +114,8 @@ func (s *v0Suite) TestLastLogID(c *check.C) {
        // Connecting connEarly (before sending the early events) lets
        // us confirm all of the "early" events have already passed
        // through the server.
-       connEarly, rEarly, wEarly := s.testClient()
+       connEarly, rEarly, wEarly, err := s.testClient()
+       c.Assert(err, check.IsNil)
        defer connEarly.Close()
        c.Check(wEarly.Encode(map[string]interface{}{
                "method": "subscribe",
@@ -120,7 +124,7 @@ func (s *v0Suite) TestLastLogID(c *check.C) {
 
        // Send the early events.
        uuidChan := make(chan string, 1)
-       s.emitEvents(uuidChan)
+       s.emitEvents(c, uuidChan, nil)
        uuidEarly := <-uuidChan
 
        // Wait for the early events to pass through.
@@ -128,7 +132,8 @@ func (s *v0Suite) TestLastLogID(c *check.C) {
 
        // Connect the client that wants to get old events via
        // last_log_id.
-       conn, r, w := s.testClient()
+       conn, r, w, err := s.testClient()
+       c.Assert(err, check.IsNil)
        defer conn.Close()
 
        c.Check(w.Encode(map[string]interface{}{
@@ -138,12 +143,13 @@ func (s *v0Suite) TestLastLogID(c *check.C) {
        s.expectStatus(c, r, 200)
 
        checkLogs(r, uuidEarly)
-       s.emitEvents(uuidChan)
+       s.emitEvents(c, uuidChan, nil)
        checkLogs(r, <-uuidChan)
 }
 
 func (s *v0Suite) TestPermission(c *check.C) {
-       conn, r, w := s.testClient()
+       conn, r, w, err := s.testClient()
+       c.Assert(err, check.IsNil)
        defer conn.Close()
 
        c.Check(w.Encode(map[string]interface{}{
@@ -154,9 +160,9 @@ func (s *v0Suite) TestPermission(c *check.C) {
        uuidChan := make(chan string, 2)
        go func() {
                s.token = arvadostest.AdminToken
-               s.emitEvents(uuidChan)
+               s.emitEvents(c, uuidChan, nil)
                s.token = arvadostest.ActiveToken
-               s.emitEvents(uuidChan)
+               s.emitEvents(c, uuidChan, nil)
        }()
 
        wrongUUID := <-uuidChan
@@ -182,9 +188,12 @@ func (s *v0Suite) TestEventTypeDelete(c *check.C) {
        for i := range clients {
                uuidChan := make(chan string, 1)
                s.token = clients[i].token
-               s.emitEvents(uuidChan)
+               s.emitEvents(c, uuidChan, nil)
                clients[i].uuid = <-uuidChan
-               clients[i].conn, clients[i].r, clients[i].w = s.testClient()
+
+               var err error
+               clients[i].conn, clients[i].r, clients[i].w, err = s.testClient()
+               c.Assert(err, check.IsNil)
 
                c.Check(clients[i].w.Encode(map[string]interface{}{
                        "method": "subscribe",
@@ -202,6 +211,41 @@ func (s *v0Suite) TestEventTypeDelete(c *check.C) {
        }
 }
 
+func (s *v0Suite) TestEventPropertiesFields(c *check.C) {
+       ac := arvados.NewClientFromEnv()
+       ac.AuthToken = s.token
+
+       conn, r, w, err := s.testClient()
+       c.Assert(err, check.IsNil)
+       defer conn.Close()
+
+       c.Check(w.Encode(map[string]interface{}{
+               "method":  "subscribe",
+               "filters": [][]string{{"object_uuid", "=", arvadostest.RunningContainerUUID}},
+       }), check.IsNil)
+       s.expectStatus(c, r, 200)
+
+       err = ac.RequestAndDecode(nil, "POST", "arvados/v1/logs", s.jsonBody("log", map[string]interface{}{
+               "object_uuid": arvadostest.RunningContainerUUID,
+               "event_type":  "update",
+               "properties": map[string]interface{}{
+                       "new_attributes": map[string]interface{}{
+                               "name":                      "namevalue",
+                               "requesting_container_uuid": "uuidvalue",
+                               "state":                     "statevalue",
+                       },
+               },
+       }), nil)
+       c.Assert(err, check.IsNil)
+
+       lg := s.expectLog(c, r)
+       c.Check(lg.ObjectUUID, check.Equals, arvadostest.RunningContainerUUID)
+       c.Check(lg.EventType, check.Equals, "update")
+       c.Check(lg.Properties["new_attributes"].(map[string]interface{})["requesting_container_uuid"], check.Equals, "uuidvalue")
+       c.Check(lg.Properties["new_attributes"].(map[string]interface{})["name"], check.Equals, "namevalue")
+       c.Check(lg.Properties["new_attributes"].(map[string]interface{})["state"], check.Equals, "statevalue")
+}
+
 // Trashing/deleting a collection produces an "update" event with
 // properties["new_attributes"]["is_trashed"] == true.
 func (s *v0Suite) TestTrashedCollection(c *check.C) {
@@ -213,7 +257,8 @@ func (s *v0Suite) TestTrashedCollection(c *check.C) {
        c.Assert(err, check.IsNil)
        s.ignoreLogID = s.lastLogID(c)
 
-       conn, r, w := s.testClient()
+       conn, r, w, err := s.testClient()
+       c.Assert(err, check.IsNil)
        defer conn.Close()
 
        c.Check(w.Encode(map[string]interface{}{
@@ -232,7 +277,8 @@ func (s *v0Suite) TestTrashedCollection(c *check.C) {
 }
 
 func (s *v0Suite) TestSendBadJSON(c *check.C) {
-       conn, r, w := s.testClient()
+       conn, r, w, err := s.testClient()
+       c.Assert(err, check.IsNil)
        defer conn.Close()
 
        c.Check(w.Encode(map[string]interface{}{
@@ -240,7 +286,7 @@ func (s *v0Suite) TestSendBadJSON(c *check.C) {
        }), check.IsNil)
        s.expectStatus(c, r, 200)
 
-       _, err := fmt.Fprint(conn, "^]beep\n")
+       _, err = fmt.Fprint(conn, "^]beep\n")
        c.Check(err, check.IsNil)
        s.expectStatus(c, r, 400)
 
@@ -251,12 +297,13 @@ func (s *v0Suite) TestSendBadJSON(c *check.C) {
 }
 
 func (s *v0Suite) TestSubscribe(c *check.C) {
-       conn, r, w := s.testClient()
+       conn, r, w, err := s.testClient()
+       c.Assert(err, check.IsNil)
        defer conn.Close()
 
-       s.emitEvents(nil)
+       s.emitEvents(c, nil, nil)
 
-       err := w.Encode(map[string]interface{}{"21": 12})
+       err = w.Encode(map[string]interface{}{"21": 12})
        c.Check(err, check.IsNil)
        s.expectStatus(c, r, 400)
 
@@ -265,7 +312,7 @@ func (s *v0Suite) TestSubscribe(c *check.C) {
        s.expectStatus(c, r, 200)
 
        uuidChan := make(chan string, 1)
-       go s.emitEvents(uuidChan)
+       go s.emitEvents(c, uuidChan, nil)
        uuid := <-uuidChan
 
        for _, etype := range []string{"create", "blip", "update"} {
@@ -277,11 +324,104 @@ func (s *v0Suite) TestSubscribe(c *check.C) {
        }
 }
 
+func (s *v0Suite) TestManyEventsAndSubscribers(c *check.C) {
+       // Frequent slow listener pings create the conditions for a
+       // deadlock issue with the lib/pq example listener usage.
+       //
+       // Specifically: a lib/pq/example/listen-style event loop can
+       // deadlock if enough (~32) server notifications arrive after
+       // the event loop decides to call Ping (e.g., while
+       // listener.Ping() is waiting for a response from the server,
+       // or in the time.Sleep() invoked by testSlowPing).
+       //
+       // (*ListenerConn)listenerConnLoop() doesn't see the server's
+       // ping response until it finishes sending a previous
+       // notification through its internal queue to
+       // (*Listener)listenerConnLoop(), which is blocked on sending
+       // to our Notify channel, which is blocked on waiting for the
+       // Ping response.
+       defer func(d time.Duration) {
+               listenerPingInterval = d
+               testSlowPing = false
+       }(listenerPingInterval)
+       listenerPingInterval = time.Second / 2
+       testSlowPing = true
+       // Restart the test server in order to get one that uses our
+       // test globals.
+       s.TearDownTest(c)
+       s.SetUpTest(c)
+
+       done := make(chan struct{})
+       defer close(done)
+       go s.emitEvents(c, nil, done)
+
+       // We will expect to receive at least one event during each
+       // one-second interval while the test is running.
+       t0 := time.Now()
+       seconds := 10
+       receivedPerSecond := make([]int64, seconds)
+
+       ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Duration(seconds)*time.Second))
+       defer cancel()
+       for clientID := 0; clientID < 100; clientID++ {
+               clientID := clientID
+               go func() {
+                       for ctx.Err() == nil {
+                               conn, r, w, err := s.testClient()
+                               if ctx.Err() != nil {
+                                       return
+                               }
+                               c.Assert(err, check.IsNil)
+                               defer conn.Close()
+                               err = w.Encode(map[string]interface{}{"method": "subscribe", "filters": []string{}})
+                               if ctx.Err() != nil {
+                                       return
+                               }
+                               c.Check(err, check.IsNil)
+                               s.expectStatus(c, r, 200)
+                               for {
+                                       if clientID%10 == 0 {
+                                               // slow client
+                                               time.Sleep(time.Second / 20)
+                                       } else if rand.Float64() < 0.01 {
+                                               // disconnect+reconnect
+                                               break
+                                       }
+                                       var lg arvados.Log
+                                       err := r.Decode(&lg)
+                                       if ctx.Err() != nil {
+                                               return
+                                       }
+                                       if errors.Is(err, io.EOF) {
+                                               break
+                                       }
+                                       c.Check(err, check.IsNil)
+                                       if i := int(time.Since(t0) / time.Second); i < seconds {
+                                               atomic.AddInt64(&receivedPerSecond[i], 1)
+                                       }
+                               }
+                               conn.Close()
+                       }
+               }()
+       }
+       <-ctx.Done()
+       c.Log("done")
+       for i, n := range receivedPerSecond {
+               c.Logf("t<%d n=%d", i+1, n)
+               c.Check(int64(n), check.Not(check.Equals), int64(0))
+       }
+}
+
 // Generate some events by creating and updating a workflow object,
 // and creating a custom log entry (event_type="blip") about the newly
-// created workflow. If uuidChan is not nil, send the new workflow
-// UUID to uuidChan as soon as it's known.
-func (s *v0Suite) emitEvents(uuidChan chan<- string) {
+// created workflow.
+//
+// If uuidChan is not nil, send the new workflow UUID to uuidChan as
+// soon as it's known.
+//
+// If done is not nil, keep generating events until done receives or
+// closes.
+func (s *v0Suite) emitEvents(c *check.C, uuidChan chan<- string, done <-chan struct{}) {
        s.wg.Add(1)
        defer s.wg.Done()
 
@@ -291,20 +431,33 @@ func (s *v0Suite) emitEvents(uuidChan chan<- string) {
                Name: "ws_test",
        }
        err := ac.RequestAndDecode(wf, "POST", "arvados/v1/workflows", s.jsonBody("workflow", `{"name":"ws_test"}`), map[string]interface{}{"ensure_unique_name": true})
-       if err != nil {
-               panic(err)
-       }
+       c.Assert(err, check.IsNil)
+       s.toDelete = append(s.toDelete, "arvados/v1/workflows/"+wf.UUID)
        if uuidChan != nil {
                uuidChan <- wf.UUID
        }
-       lg := &arvados.Log{}
-       err = ac.RequestAndDecode(lg, "POST", "arvados/v1/logs", s.jsonBody("log", map[string]interface{}{
-               "object_uuid": wf.UUID,
-               "event_type":  "blip",
-               "properties": map[string]interface{}{
-                       "beep": "boop",
-               },
-       }), nil)
+       for i := 0; ; i++ {
+               lg := &arvados.Log{}
+               err = ac.RequestAndDecode(lg, "POST", "arvados/v1/logs", s.jsonBody("log", map[string]interface{}{
+                       "object_uuid": wf.UUID,
+                       "event_type":  "blip",
+                       "properties": map[string]interface{}{
+                               "beep": "boop",
+                       },
+               }), nil)
+               s.toDelete = append(s.toDelete, "arvados/v1/logs/"+lg.UUID)
+               if done != nil {
+                       select {
+                       case <-done:
+                       default:
+                               if i%50 == 0 {
+                                       time.Sleep(100 * time.Millisecond)
+                               }
+                               continue
+                       }
+               }
+               break
+       }
        if err != nil {
                panic(err)
        }
@@ -312,7 +465,6 @@ func (s *v0Suite) emitEvents(uuidChan chan<- string) {
        if err != nil {
                panic(err)
        }
-       s.toDelete = append(s.toDelete, "arvados/v1/workflows/"+wf.UUID, "arvados/v1/logs/"+lg.UUID)
 }
 
 func (s *v0Suite) jsonBody(rscName string, ob interface{}) io.Reader {
@@ -339,32 +491,34 @@ func (s *v0Suite) expectLog(c *check.C, r *json.Decoder) *arvados.Log {
        lg := &arvados.Log{}
        ok := make(chan struct{})
        go func() {
+               defer close(ok)
                for lg.ID <= s.ignoreLogID {
-                       c.Check(r.Decode(lg), check.IsNil)
+                       c.Assert(r.Decode(lg), check.IsNil)
                }
-               close(ok)
        }()
        select {
        case <-time.After(10 * time.Second):
-               panic("timed out")
+               c.Error("timed out")
+               c.FailNow()
+               return lg
        case <-ok:
                return lg
        }
 }
 
-func (s *v0Suite) testClient() (*websocket.Conn, *json.Decoder, *json.Encoder) {
+func (s *v0Suite) testClient() (*websocket.Conn, *json.Decoder, *json.Encoder, error) {
        srv := s.serviceSuite.srv
        conn, err := websocket.Dial(strings.Replace(srv.URL, "http", "ws", 1)+"/websocket?api_token="+s.token, "", srv.URL)
        if err != nil {
-               panic(err)
+               return nil, nil, nil, err
        }
        w := json.NewEncoder(conn)
        r := json.NewDecoder(conn)
-       return conn, r, w
+       return conn, r, w, nil
 }
 
-func (s *v0Suite) lastLogID(c *check.C) uint64 {
-       var lastID uint64
+func (s *v0Suite) lastLogID(c *check.C) int64 {
+       var lastID int64
        c.Assert(testDB().QueryRow(`SELECT MAX(id) FROM logs`).Scan(&lastID), check.IsNil)
        return lastID
 }
index b3b9a5fcb441900535954012ebc0ee05f77bf10f..13583ba288eeae87d580bbafa577365bb38deafd 100755 (executable)
@@ -44,18 +44,10 @@ if test -z "$ARVADOS_ROOT" ; then
     ARVADOS_ROOT="$ARVBOX_DATA/arvados"
 fi
 
-if test -z "$WORKBENCH2_ROOT" ; then
-    WORKBENCH2_ROOT="$ARVBOX_DATA/workbench2"
-fi
-
 if test -z "$ARVADOS_BRANCH" ; then
     ARVADOS_BRANCH=main
 fi
 
-if test -z "$WORKBENCH2_BRANCH" ; then
-    WORKBENCH2_BRANCH=main
-fi
-
 # Update this to the docker tag for the version on releases.
 DEFAULT_TAG=
 
@@ -134,7 +126,6 @@ wait_for_arvbox() {
 docker_run_dev() {
     docker run \
            "--volume=$ARVADOS_ROOT:/usr/src/arvados:rw" \
-           "--volume=$WORKBENCH2_ROOT:/usr/src/workbench2:rw" \
            "--volume=$PG_DATA:/var/lib/postgresql:rw" \
            "--volume=$VAR_DATA:$ARVADOS_CONTAINER_PATH:rw" \
            "--volume=$PASSENGER:/var/lib/passenger:rw" \
@@ -252,10 +243,6 @@ run() {
             git clone https://git.arvados.org/arvados.git "$ARVADOS_ROOT"
            git -C "$ARVADOS_ROOT" checkout $ARVADOS_BRANCH
         fi
-        if ! test -d "$WORKBENCH2_ROOT" ; then
-            git clone https://git.arvados.org/arvados-workbench2.git "$WORKBENCH2_ROOT"
-           git -C "$ARVADOS_ROOT" checkout $WORKBENCH2_BRANCH
-        fi
 
         if [[ "$CONFIG" = test ]] ; then
 
@@ -405,7 +392,6 @@ build() {
     docker build --build-arg=BUILDTYPE=$BUILDTYPE $NO_CACHE \
           --build-arg=go_version=$GO_VERSION \
           --build-arg=arvados_version=$ARVADOS_BRANCH \
-          --build-arg=workbench2_version=$WORKBENCH2_BRANCH \
           --build-arg=workdir=/tools/arvbox/lib/arvbox/docker \
           -t arvados/arvbox-base:$GITHEAD \
           -f "$ARVBOX_DOCKER/Dockerfile.base" \
@@ -414,7 +400,6 @@ build() {
     docker build $NO_CACHE \
           --build-arg=go_version=$GO_VERSION \
           --build-arg=arvados_version=$ARVADOS_BRANCH \
-          --build-arg=workbench2_version=$WORKBENCH2_BRANCH \
           -t arvados/arvbox-$BUILDTYPE:$GITHEAD \
           -f "$ARVBOX_DOCKER/Dockerfile.$BUILDTYPE" \
           "$ARVBOX_DOCKER"
@@ -431,6 +416,14 @@ check() {
             exit 1
         ;;
     esac
+
+    user_watches=$(/usr/sbin/sysctl fs.inotify.max_user_watches)
+    [[ $user_watches =~ fs.inotify.max_user_watches\ =\ ([0-9]+) ]] && value=${BASH_REMATCH[1]}
+    if [[ "$value" -lt 256000 ]] ; then
+       echo "Not enough file system listeners ($value), to fix this run:"
+       echo "sudo sh -c 'echo fs.inotify.max_user_watches=524288 >> /etc/sysctl.d/local.conf && sysctl --system'"
+       exit 1
+    fi
 }
 
 subcmd="$1"
@@ -604,7 +597,6 @@ case "$subcmd" in
                "$ARVBOX_BASE/$1/gopath" \
                "$ARVBOX_BASE/$1/Rlibs" \
                "$ARVBOX_BASE/$1/arvados" \
-               "$ARVBOX_BASE/$1/workbench2" \
                "$ARVBOX_BASE/$2"
             echo "Created new arvbox $2"
             echo "export ARVBOX_CONTAINER=$2"
@@ -646,6 +638,8 @@ sv stop keepproxy
 cd /usr/src/arvados/services/api
 export DISABLE_DATABASE_ENVIRONMENT_CHECK=1
 export RAILS_ENV=development
+export GEM_HOME=/var/lib/arvados-arvbox/.gem
+env
 bin/bundle exec rake db:drop
 rm $ARVADOS_CONTAINER_PATH/api_database_setup
 rm $ARVADOS_CONTAINER_PATH/superuser_token
index 8f20850ef4a29970e3c85b3d8d8a25f260a60035..d8b240883169e72b6914a5f23ca7d62f8aef9447 100644 (file)
@@ -13,7 +13,7 @@ ARG BUILDTYPE
 # tree, and use the $arvados_version commit (passed in via an argument).
 
 ###########################################################################################################
-FROM debian:10-slim as dev
+FROM debian:11-slim as dev
 ENV DEBIAN_FRONTEND noninteractive
 
 RUN apt-get update && \
@@ -42,7 +42,7 @@ RUN --mount=type=bind,target=/usr/src/arvados \
     go install
 
 ###########################################################################################################
-FROM debian:10-slim as demo
+FROM debian:11-slim as demo
 ENV DEBIAN_FRONTEND noninteractive
 
 RUN apt-get update && \
@@ -75,14 +75,14 @@ RUN cd /usr/src && \
 FROM ${BUILDTYPE} as base
 
 ###########################################################################################################
-FROM debian:10
+FROM debian:11
 ENV DEBIAN_FRONTEND noninteractive
 
 # The arvbox-specific dependencies are
-#  gnupg2 runit python3-pip python3-setuptools python3-yaml shellinabox netcat less
+#  gnupg2 runit python3-dev python3-venv shellinabox netcat-openbsd less
 RUN apt-get update && \
     apt-get -yq --no-install-recommends -o Acquire::Retries=6 install \
-    gnupg2 runit python3-pip python3-setuptools python3-yaml shellinabox netcat less vim-tiny && \
+    gnupg2 runit python3-dev python3-venv shellinabox netcat-openbsd less vim-tiny && \
     apt-get clean
 
 ENV GOPATH /var/lib/gopath
@@ -93,6 +93,17 @@ COPY --from=base $GOPATH/bin/arvados-server $GOPATH/bin/arvados-server
 RUN $GOPATH/bin/arvados-server --version
 RUN $GOPATH/bin/arvados-server install -type test
 
+# Set up a virtualenv for all Python tools in arvbox.
+# This is used mainly by the `sdk` service, but `doc` and internal scripts
+# also rely on it.
+# 1. Install wheel just to modernize the virtualenv.
+# 2. Install setuptools as an sdk build dependency; PyYAML for all tests
+#    and yml_override.py; and pdoc for the doc service.
+# Everything else is installed by the sdk service on boot.
+RUN python3 -m venv /opt/arvados-py \
+ && /opt/arvados-py/bin/pip install --no-cache-dir wheel \
+ && /opt/arvados-py/bin/pip install --no-cache-dir setuptools PyYAML pdoc
+
 RUN /etc/init.d/postgresql start && \
     su postgres -c 'dropuser arvados' && \
     su postgres -c 'createuser -s arvbox' && \
index 98e6e6cf57c2b7ce6cc035bf59c74825da0b48dd..81a5369f5ebcb9a9a749c1a4d713458466e2c897 100644 (file)
@@ -4,13 +4,10 @@
 
 FROM arvados/arvbox-base
 ARG arvados_version
-ARG workbench2_version=main
 
 RUN cd /usr/src && \
     git clone --no-checkout https://git.arvados.org/arvados.git && \
     git -C arvados checkout ${arvados_version} && \
-    git clone --no-checkout https://git.arvados.org/arvados-workbench2.git workbench2 && \
-    git -C workbench2 checkout ${workbench2_version} && \
     chown -R 1000:1000 /usr/src
 
 # avoid rebuilding arvados-server, it's already been built as part of the base image
@@ -20,7 +17,6 @@ ADD service/ /var/lib/arvbox/service
 RUN ln -sf /var/lib/arvbox/service /etc
 RUN mkdir -p $ARVADOS_CONTAINER_PATH
 RUN echo "production" > $ARVADOS_CONTAINER_PATH/api_rails_env
-RUN echo "production" > $ARVADOS_CONTAINER_PATH/workbench_rails_env
 
 # for the federation tests, the dev server watches a lot of files,
 # and we run three instances of the docker container. Bump up the
@@ -38,7 +34,6 @@ RUN /usr/local/lib/arvbox/createusers.sh
 RUN sudo -u arvbox /var/lib/arvbox/service/api/run-service --only-deps
 RUN sudo -u arvbox /var/lib/arvbox/service/workbench2/run-service --only-deps
 RUN sudo -u arvbox /var/lib/arvbox/service/keep-web/run-service --only-deps
-RUN sudo -u arvbox /var/lib/arvbox/service/workbench/run-service --only-deps
 RUN sudo -u arvbox /var/lib/arvbox/service/doc/run-service --only-deps
 RUN sudo -u arvbox /var/lib/arvbox/service/vm/run-service --only-deps
 RUN sudo -u arvbox /var/lib/arvbox/service/keepproxy/run-service --only-deps
index e9c296a190453e43f3093d4f3aa392be01ee1783..e3d18edd621690eaa190c9b92bd98bb4efad02ec 100644 (file)
@@ -9,7 +9,6 @@ ADD service/ /var/lib/arvbox/service
 RUN ln -sf /var/lib/arvbox/service /etc
 RUN mkdir -p $ARVADOS_CONTAINER_PATH
 RUN echo "development" > $ARVADOS_CONTAINER_PATH/api_rails_env
-RUN echo "development" > $ARVADOS_CONTAINER_PATH/workbench_rails_env
 
 RUN mkdir /etc/test-service && \
     ln -sf /var/lib/arvbox/service/postgres /etc/test-service && \
index dfc9d1fece777629d65088867fd48c347bbd2feb..9b55181c9169a52c6a8eb7fda8760eedcade0d9a 100755 (executable)
@@ -56,11 +56,6 @@ if ! (psql postgres -c "\du" | grep "^ arvados ") >/dev/null ; then
 fi
 psql postgres -c "ALTER USER arvados WITH SUPERUSER;"
 
-if ! test -s $ARVADOS_CONTAINER_PATH/workbench_secret_token ; then
-  ruby -e 'puts rand(2**400).to_s(36)' > $ARVADOS_CONTAINER_PATH/workbench_secret_token
-fi
-workbench_secret_key_base=$(cat $ARVADOS_CONTAINER_PATH/workbench_secret_token)
-
 if test -s $ARVADOS_CONTAINER_PATH/api_rails_env ; then
   database_env=$(cat $ARVADOS_CONTAINER_PATH/api_rails_env)
 else
@@ -77,7 +72,7 @@ Clusters:
         InternalURLs:
           "http://localhost:${services[api]}": {}
       Workbench1:
-        ExternalURL: "https://$localip:${services[workbench]}"
+        ExternalURL: "https://$localip:${services[workbench2-ssl]}"
       Workbench2:
         ExternalURL: "https://$localip:${services[workbench2-ssl]}"
       Keepproxy:
@@ -138,9 +133,6 @@ Clusters:
       AutoSetupNewUsers: true
       AutoSetupNewUsersWithVmUUID: $vm_uuid
       AutoSetupNewUsersWithRepository: true
-    Workbench:
-      SecretKeyBase: $workbench_secret_key_base
-      ArvadosDocsite: http://$localip:${services[doc]}/
     Git:
       GitCommand: /usr/share/gitolite3/gitolite-shell
       GitoliteHome: $ARVADOS_CONTAINER_PATH/git
@@ -175,7 +167,6 @@ chmod og-rw \
       $ARVADOS_CONTAINER_PATH/management_token \
       $ARVADOS_CONTAINER_PATH/system_root_token \
       $ARVADOS_CONTAINER_PATH/api_database_pw \
-      $ARVADOS_CONTAINER_PATH/workbench_secret_token \
       $ARVADOS_CONTAINER_PATH/superuser_token \
 set -e
 
index d900f0377207a7a0717ec49c84643e8a9367aff9..54ec9403ad9135179682eca94817b7be6973d6e9 100644 (file)
@@ -2,11 +2,11 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-export RUBY_VERSION=2.7.0
-export BUNDLER_VERSION=2.2.19
+export RUBY_VERSION=3.2.2
+export BUNDLER_VERSION=2.4.22
 
 export DEBIAN_FRONTEND=noninteractive
-export PATH=${PATH}:/usr/local/go/bin:/var/lib/arvados/bin:/usr/src/arvados/sdk/cli/binstubs
+export PATH=${PATH}:/usr/local/go/bin:/var/lib/arvados/bin:/opt/arvados-py/bin:/usr/src/arvados/sdk/cli/binstubs
 export npm_config_cache=/var/lib/npm
 export npm_config_cache_min=Infinity
 export R_LIBS=/var/lib/Rlibs
@@ -35,9 +35,8 @@ server_cert_key=$ARVADOS_CONTAINER_PATH/server-cert-${localip}.key
 
 declare -A services
 services=(
-  [workbench]=443
   [workbench2]=3000
-  [workbench2-ssl]=3001
+  [workbench2-ssl]=443
   [api]=8004
   [controller]=8003
   [controller-ssl]=8000
@@ -68,43 +67,55 @@ fi
 
 run_bundler() {
     flock $GEMLOCK /var/lib/arvados/bin/gem install --no-document --user bundler:$BUNDLER_VERSION
-    if test -f Gemfile.lock ; then
-        frozen=--frozen
-    else
-        frozen=""
-    fi
+
     BUNDLER=bundle
     if test -x $PWD/bin/bundle ; then
-       # If present, use the one associated with rails workbench or API
+       # If present, use the one associated with rails API
        BUNDLER=$PWD/bin/bundle
     fi
 
+    # Use Gemfile.lock only if it is git tracked.
+    if git ls-files --error-unmatch Gemfile.lock ; then
+       flock $GEMLOCK $BUNDLER config set --local frozen true
+    else
+       flock $GEMLOCK $BUNDLER config set --local frozen false
+    fi
+    flock $GEMLOCK $BUNDLER config set --local deployment false
+
     if test -z "$(flock $GEMLOCK /var/lib/arvados/bin/gem list | grep 'arvados[[:blank:]].*[0-9.]*dev')" ; then
         (cd /usr/src/arvados/sdk/ruby && \
         /var/lib/arvados/bin/gem build arvados.gemspec && flock $GEMLOCK /var/lib/arvados/bin/gem install $(ls -1 *.gem | sort -r | head -n1))
     fi
-    if ! flock $GEMLOCK $BUNDLER install --verbose --local --no-deployment $frozen "$@" ; then
-        flock $GEMLOCK $BUNDLER install --verbose --no-deployment $frozen "$@"
+
+    if ! flock $GEMLOCK $BUNDLER install --verbose --local "$@" ; then
+        flock $GEMLOCK $BUNDLER install --verbose "$@"
     fi
 }
 
-PYCMD=""
-pip_install() {
-    pushd /var/lib/pip
-    for p in $(ls http*.tar.gz) $(ls http*.tar.bz2) $(ls http*.whl) $(ls http*.zip) ; do
-        if test -f $p ; then
-            ln -sf $p $(echo $p | sed 's/.*%2F\(.*\)/\1/')
-        fi
-    done
-    popd
-
-    if [ "$PYCMD" = "python3" ]; then
-        if ! pip3 install --prefix /usr/local --no-index --find-links /var/lib/pip $1 ; then
-            pip3 install --prefix /usr/local $1
-        fi
-    else
-        if ! pip install --no-index --find-links /var/lib/pip $1 ; then
-            pip install $1
-        fi
+bundler_binstubs() {
+    BUNDLER=bundle
+    if test -x $PWD/bin/bundle ; then
+       # If present, use the one associated with rails API
+       BUNDLER=$PWD/bin/bundle
     fi
+    flock $GEMLOCK $BUNDLER binstubs --all
+}
+
+# Usage: Pass any number of directories. Relative directories will be taken as
+# relative to /usr/src/arvados. This function will build an sdist from each,
+# then pip install them all in the arvbox virtualenv.
+pip_install_sdist() {
+    local sdist_dir="$(mktemp --directory --tmpdir py_sdist.XXXXXXXX)"
+    trap 'rm -rf "$sdist_dir"' RETURN
+    local src_dir
+    for src_dir in "$@"; do
+        case "$src_dir" in
+            /*) ;;
+            *) src_dir="/usr/src/arvados/$src_dir" ;;
+        esac
+        env -C "$src_dir" /opt/arvados-py/bin/python3 setup.py sdist --dist-dir="$sdist_dir" \
+            || return
+    done
+    /opt/arvados-py/bin/pip install "$sdist_dir"/* || return
+    return
 }
index 4cafd8c09c2f6fbbd0758b53a9a0a1368765159d..9224b80f52dafa5321d4f85fcd38840f6cfa8c5b 100755 (executable)
@@ -14,6 +14,7 @@ if ! grep "^arvbox:" /etc/passwd >/dev/null 2>/dev/null ; then
     mkdir -p $ARVADOS_CONTAINER_PATH/git \
           /var/lib/passenger /var/lib/gopath \
           /var/lib/pip /var/lib/npm
+    /opt/arvados-py/bin/pip config --site set global.cache-dir /var/lib/pip
 
     if test -z "$ARVBOX_HOME" ; then
         ARVBOX_HOME=$ARVADOS_CONTAINER_PATH
@@ -31,7 +32,7 @@ if ! grep "^arvbox:" /etc/passwd >/dev/null 2>/dev/null ; then
     useradd --groups docker crunch
 
     if [[ "$1" != --no-chown ]] ; then
-        chown arvbox:arvbox -R /usr/local $ARVADOS_CONTAINER_PATH \
+        chown arvbox:arvbox -R /usr/local /opt/arvados-py $ARVADOS_CONTAINER_PATH \
               /var/lib/passenger /var/lib/postgresql \
               /var/lib/nginx /var/log/nginx /etc/ssl/private \
               /var/lib/gopath /var/lib/pip /var/lib/npm
@@ -43,7 +44,7 @@ if ! grep "^arvbox:" /etc/passwd >/dev/null 2>/dev/null ; then
     echo "arvbox    ALL=(crunch) NOPASSWD: ALL" >> /etc/sudoers
 
     cat <<EOF > /etc/profile.d/paths.sh
-export PATH=/var/lib/arvados/bin:/usr/local/bin:/usr/bin:/bin:/usr/src/arvados/sdk/cli/binstubs
+export PATH=/var/lib/arvados/bin:/usr/local/bin:/usr/bin:/bin:/opt/arvados-py/bin:/usr/src/arvados/sdk/cli/binstubs
 export npm_config_cache=/var/lib/npm
 export npm_config_cache_min=Infinity
 export R_LIBS=/var/lib/Rlibs
index ab046b11d42751d3939cc4aaee6cba73f055f598..cb44b984b72d72641551f7c5a0a71efdd3cdfed5 100755 (executable)
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/opt/arvados-py/bin/python3
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: AGPL-3.0
index 03eac65cec5cdbc3bf0defaaaf644b7b312e9399..a5af64281b13f7bf11f359cd74e055d52d337d85 100644 (file)
@@ -7,6 +7,7 @@ export GOPATH=/var/lib/gopath
 mkdir -p $GOPATH
 
 cd /usr/src/arvados
+RUNSU=""
 if [[ $UID = 0 ]] ; then
   RUNSU="/usr/local/lib/arvbox/runsu.sh"
 fi
index e9092f37022c7020e376fbb4f6c95b07e1af0804..0a04918012aa65952bd97d221755c37b86c624ed 100755 (executable)
@@ -17,16 +17,16 @@ fi
 cd /usr/src/arvados/doc
 run_bundler --without=development
 
-# Generating the R docs is expensive, so for development if the file
-# "no-sdk" exists then skip the R stuff.
-if [[ ! -f /usr/src/arvados/doc/no-sdk ]] ; then
-    cd /usr/src/arvados/sdk/R
-    R --quiet --vanilla --file=install_deps.R
+# Generating the Python and R docs is expensive, so for development if the file
+# "no-sdk" exists then skip installing R stuff.
+if [[ ! -f no-sdk ]] ; then
+    env -C ../sdk/R R --quiet --vanilla --file=install_deps.R
 fi
 
 if test "$1" = "--only-deps" ; then
     exit
 fi
 
-cd /usr/src/arvados/doc
+# Active the arvbox virtualenv so we can import pdoc for PySDK doc generation.
+. /opt/arvados-py/bin/activate
 flock $GEMLOCK bundle exec rake generate baseurl=http://$localip:${services[doc]} arvados_api_host=$localip:${services[controller-ssl]} arvados_workbench_host=http://$localip
index 991927be70645fc06ee3544663272db2fe2b8c23..528c6be8274a145a3e4cd3ad61e7972078f5d6ea 100755 (executable)
@@ -111,6 +111,62 @@ http {
     server_name workbench2;
     ssl_certificate "${server_cert}";
     ssl_certificate_key "${server_cert_key}";
+
+    # REDIRECTS FROM WORKBENCH 1 TO WORKBENCH 2
+
+    # Paths that are not redirected because wb1 and wb2 have similar enough paths
+    # that a redirect is pointless and would create a redirect loop.
+    # rewrite ^/api_client_authorizations.* /api_client_authorizations redirect;
+    # rewrite ^/repositories.* /repositories redirect;
+    # rewrite ^/links.* /links redirect;
+    # rewrite ^/projects.* /projects redirect;
+    # rewrite ^/trash /trash redirect;
+
+    # Redirects that include a uuid
+    rewrite ^/work_units/(.*) /processes/\$1 redirect;
+    rewrite ^/container_requests/(.*) /processes/\$1 redirect;
+    rewrite ^/users/(.*) /user/\$1 redirect;
+    rewrite ^/groups/(.*) /group/\$1 redirect;
+
+    # Special file download redirects
+    if (\$arg_disposition = attachment) {
+      rewrite ^/collections/([^/]*)/(.*) /?redirectToDownload=/c=\$1/\$2? redirect;
+    }
+    if (\$arg_disposition = inline) {
+      rewrite ^/collections/([^/]*)/(.*) /?redirectToPreview=/c=\$1/\$2? redirect;
+    }
+
+    # Redirects that go to a roughly equivalent page
+    rewrite ^/virtual_machines.* /virtual-machines-admin redirect;
+    rewrite ^/users/.*/virtual_machines /virtual-machines-user redirect;
+    rewrite ^/authorized_keys.* /ssh-keys-admin redirect;
+    rewrite ^/users/.*/ssh_keys /ssh-keys-user redirect;
+    rewrite ^/containers.* /all_processes redirect;
+    rewrite ^/container_requests /all_processes redirect;
+    rewrite ^/job.* /all_processes redirect;
+    rewrite ^/users/link_account /link_account redirect;
+    rewrite ^/keep_services.* /keep-services redirect;
+    rewrite ^/trash_items.* /trash redirect;
+
+    # Redirects that don't have a good mapping and
+    # just go to root.
+    rewrite ^/themes.* / redirect;
+    rewrite ^/keep_disks.* / redirect;
+    rewrite ^/user_agreements.* / redirect;
+    rewrite ^/nodes.* / redirect;
+    rewrite ^/humans.* / redirect;
+    rewrite ^/traits.* / redirect;
+    rewrite ^/sessions.* / redirect;
+    rewrite ^/logout.* / redirect;
+    rewrite ^/logged_out.* / redirect;
+    rewrite ^/current_token / redirect;
+    rewrite ^/logs.* / redirect;
+    rewrite ^/factory_jobs.* / redirect;
+    rewrite ^/uploaded_datasets.* / redirect;
+    rewrite ^/specimens.* / redirect;
+    rewrite ^/pipeline_templates.* / redirect;
+    rewrite ^/pipeline_instances.* / redirect;
+
     location  / {
       proxy_pass http://workbench2;
       proxy_set_header Host \$http_host;
index 3569fd31264b2dfd849d653dcda065faa8bbf644..2819ee2d9618398cb7371dc60d70f4f56992e384 100755 (executable)
@@ -6,7 +6,7 @@
 exec 2>&1
 set -eux -o pipefail
 
-PGVERSION=11
+PGVERSION=$(psql --version | grep -E -o '[0-9]+' | head -n1)
 
 if ! test -d /var/lib/postgresql/$PGVERSION/main ; then
     /usr/lib/postgresql/$PGVERSION/bin/initdb --locale=en_US.UTF-8 -D /var/lib/postgresql/$PGVERSION/main
index 1e9aae0c45eb6a4685324c6edcc99504f6bf3dff..2ba955fb1e8d1dc4af4b37309bd3d52210f5d3f5 100755 (executable)
@@ -64,15 +64,10 @@ fi
 if ! [[ -z "$waiting" ]] ; then
     if ps x | grep -v grep | grep "bundle install" > /dev/null; then
         gemcount=$(ls /var/lib/arvados/lib/ruby/gems/*/gems /var/lib/arvados-arvbox/.gem/ruby/*/gems 2>/dev/null | wc -l)
-
-        gemlockcount=0
-        for l in /usr/src/arvados/services/api/Gemfile.lock \
-                     /usr/src/arvados/apps/workbench/Gemfile.lock ; do
-            gc=$(cat $l \
-                        | grep -vE "(GEM|PLATFORMS|DEPENDENCIES|BUNDLED|GIT|$^|remote:|specs:|revision:)" \
-                        | sed 's/^ *//' | sed 's/(.*)//' | sed 's/ *$//' | sort | uniq | wc -l)
-            gemlockcount=$(($gemlockcount + $gc))
-        done
+        lockfile=/usr/src/arvados/services/api/Gemfile.lock
+        gemlockcount=$(cat $lockfile \
+                           | grep -vE "(GEM|PLATFORMS|DEPENDENCIES|BUNDLED|GIT|$^|remote:|specs:|revision:)" \
+                           | sed 's/^ *//' | sed 's/(.*)//' | sed 's/ *$//' | sort | uniq | wc -l)
         waiting="$waiting (installing ruby gems $gemcount of about $gemlockcount)"
     fi
 
@@ -89,7 +84,6 @@ fi
 
 echo
 echo "Your Arvados-in-a-box is ready!"
-echo "Workbench is hosted at https://$localip"
 echo "Workbench2 is hosted at https://$localip:${services[workbench2-ssl]}"
 echo "Documentation is hosted at http://$localip:${services[doc]}"
 
index d3ff7e868345b383fb7c98e27a88a36ad44db1ed..006759df981c80412c9f67d9244995151776d628 100755 (executable)
@@ -8,30 +8,10 @@ set -eux -o pipefail
 
 . /usr/local/lib/arvbox/common.sh
 
-mkdir -p ~/.pip /var/lib/pip
-cat > ~/.pip/pip.conf <<EOF
-[global]
-download_cache = /var/lib/pip
-EOF
-
 cd /usr/src/arvados/sdk/ruby
 run_bundler --binstubs=binstubs
 
 cd /usr/src/arvados/sdk/cli
 run_bundler --binstubs=binstubs
 
-export PYCMD=python3
-
-pip_install wheel
-
-cd /usr/src/arvados/sdk/python
-$PYCMD setup.py sdist
-pip_install $(ls dist/arvados-python-client-*.tar.gz | tail -n1)
-
-cd /usr/src/arvados/services/fuse
-$PYCMD setup.py sdist
-pip_install $(ls dist/arvados_fuse-*.tar.gz | tail -n1)
-
-cd /usr/src/arvados/sdk/cwl
-$PYCMD setup.py sdist
-pip_install $(ls dist/arvados-cwl-runner-*.tar.gz | tail -n1)
+pip_install_sdist sdk/python services/fuse tools/crunchstat-summary sdk/cwl
diff --git a/tools/arvbox/lib/arvbox/docker/service/workbench/log/main/.gitstub b/tools/arvbox/lib/arvbox/docker/service/workbench/log/main/.gitstub
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/tools/arvbox/lib/arvbox/docker/service/workbench/log/run b/tools/arvbox/lib/arvbox/docker/service/workbench/log/run
deleted file mode 120000 (symlink)
index d6aef4a..0000000
+++ /dev/null
@@ -1 +0,0 @@
-/usr/local/lib/arvbox/logger
\ No newline at end of file
diff --git a/tools/arvbox/lib/arvbox/docker/service/workbench/run b/tools/arvbox/lib/arvbox/docker/service/workbench/run
deleted file mode 100755 (executable)
index 1b0ca47..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/bash
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-set -e
-
-.  /usr/local/lib/arvbox/common.sh
-
-/usr/local/lib/arvbox/runsu.sh $0-service $1
-
-cd /usr/src/arvados/apps/workbench
-
-rm -rf tmp
-mkdir tmp
-chown arvbox:arvbox tmp
-
-if test -s $ARVADOS_CONTAINER_PATH/workbench_rails_env ; then
-  export RAILS_ENV=$(cat $ARVADOS_CONTAINER_PATH/workbench_rails_env)
-else
-  export RAILS_ENV=development
-fi
-
-if test "$1" != "--only-deps" ; then
-    openssl verify -CAfile $root_cert $server_cert
-    exec binstubs/passenger start --port=${services[workbench]} \
-        --ssl --ssl-certificate=$ARVADOS_CONTAINER_PATH/server-cert-${localip}.pem \
-        --ssl-certificate-key=$ARVADOS_CONTAINER_PATH/server-cert-${localip}.key \
-         --user arvbox
-fi
diff --git a/tools/arvbox/lib/arvbox/docker/service/workbench/run-service b/tools/arvbox/lib/arvbox/docker/service/workbench/run-service
deleted file mode 100755 (executable)
index d8332eb..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/bin/bash
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-exec 2>&1
-set -ex -o pipefail
-
-.  /usr/local/lib/arvbox/common.sh
-
-if test "$1" != "--only-deps" ; then
-  while [ ! -f $ARVADOS_CONTAINER_PATH/api.ready ]; do
-    sleep 1
-  done
-fi
-
-cd /usr/src/arvados/apps/workbench
-
-if test -s $ARVADOS_CONTAINER_PATH/workbench_rails_env ; then
-  export RAILS_ENV=$(cat $ARVADOS_CONTAINER_PATH/workbench_rails_env)
-else
-  export RAILS_ENV=development
-fi
-
-run_bundler --without=development --binstubs=binstubs
-binstubs/passenger-config build-native-support
-binstubs/passenger-config install-standalone-runtime
-mkdir -p /usr/src/arvados/apps/workbench/tmp
-
-if test "$1" = "--only-deps" ; then
-   # Workaround for validation that asserts there's a download URL
-   # configured, which breaks rake if it is missing.
-cat >config/application.yml <<EOF
-$RAILS_ENV:
-  keep_web_url: https://example.com/c=%{uuid_or_pdh}
-EOF
-   RAILS_GROUPS=assets flock $GEMLOCK bin/bundle exec rake npm:install
-   rm config/application.yml
-   exit
-fi
-
-set -u
-
-secret_token=$(cat $ARVADOS_CONTAINER_PATH/workbench_secret_token)
-
-RAILS_GROUPS=assets flock $GEMLOCK bin/bundle exec rake npm:install
-flock $GEMLOCK bin/bundle exec rake assets:precompile
index 5268c7e17e198866f29e1bf70afdca33131ea129..851cbb18e47650d8c05aa4692510a25ce6bc6518 100755 (executable)
@@ -14,7 +14,7 @@ if test "$1" != "--only-deps" ; then
   done
 fi
 
-cd /usr/src/workbench2
+cd /usr/src/arvados/services/workbench2
 
 yarn install
 
@@ -24,11 +24,11 @@ fi
 
 API_HOST=${localip}:${services[controller-ssl]}
 
-if test -f /usr/src/workbench2/public/API_HOST ; then
-    API_HOST=$(cat /usr/src/workbench2/public/API_HOST)
+if test -f /usr/src/arvados/services/workbench2/public/API_HOST ; then
+    API_HOST=$(cat /usr/src/arvados/services/workbench2/public/API_HOST)
 fi
 
-cat <<EOF > /usr/src/workbench2/public/config.json
+cat <<EOF > /usr/src/arvados/services/workbench2/public/config.json
 {
   "API_HOST": "$API_HOST"
 }
@@ -58,7 +58,7 @@ fi
 # Can't use "yarn start", need to run the dev server script
 # directly so that the TERM signal from "sv restart" gets to the
 # right process.
-export VERSION=$(./version-at-commit.sh)
+export VERSION=$(./version-at-commit.sh HEAD)
 export BROWSER=none
 export CI=true
 export HTTPS=false
index 7f35ac1d686984fbbc51101f8aa1a508e8ae28e0..5f9ee68e4fc7c2480e1ed4f4bb0ca61b28c0edfc 100755 (executable)
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/opt/arvados-py/bin/python3
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: AGPL-3.0
@@ -10,12 +10,12 @@ fn = sys.argv[1]
 
 try:
     with open(fn+".override") as f:
-        b = yaml.load(f)
+        b = yaml.safe_load(f)
 except IOError:
     exit()
 
 with open(fn) as f:
-    a = yaml.load(f)
+    a = yaml.safe_load(f)
 
 def recursiveMerge(a, b):
     if isinstance(a, dict) and isinstance(b, dict):
@@ -27,4 +27,4 @@ def recursiveMerge(a, b):
         return b
 
 with open(fn, "w") as f:
-    yaml.dump(recursiveMerge(a, b), f)
+    yaml.safe_dump(recursiveMerge(a, b), f)
index 505f60ed7851cdafd378abf89e73a2857db7a970..6c7aa9c419407ae5dab8c1f26987da716e1dec67 100644 (file)
@@ -4,7 +4,7 @@
     "aws_access_key": "",
     "aws_profile": "",
     "aws_secret_key": "",
-    "aws_source_ami": "ami-031283ff8a43b021c",
+    "aws_source_ami": "ami-0a9d5908c7201e91d",
     "aws_ebs_autoscale": "",
     "aws_associate_public_ip_address": "",
     "aws_ena_support": "",
index d186f4c52e57ce39d52484a5954edeb2d84ba530..370c3f3a3a2794b4889adc545db556a56958d3e6 100644 (file)
@@ -68,8 +68,7 @@ wait_for_apt_locks && $SUDO DEBIAN_FRONTEND=noninteractive apt-get -qq --yes ins
   libcurl4-openssl-dev \
   lvm2 \
   cryptsetup \
-  xfsprogs \
-  squashfs-tools
+  xfsprogs
 
 # Install the Arvados packages we need
 wait_for_apt_locks && $SUDO DEBIAN_FRONTEND=noninteractive apt-get -qq --yes install \
@@ -81,17 +80,17 @@ wait_for_apt_locks && $SUDO DEBIAN_FRONTEND=noninteractive apt-get -qq --yes ins
 dockerversion=5:20.10.13~3-0
 if [[ "$DIST" =~ ^debian ]]; then
   family="debian"
-  if [ "$DIST" == "debian10" ]; then
-    distro="buster"
-  elif [ "$DIST" == "debian11" ]; then
+  if [ "$DIST" == "debian11" ]; then
     distro="bullseye"
+  elif [ "$DIST" == "debian12" ]; then
+    distro="bookworm"
   fi
 elif [[ "$DIST" =~ ^ubuntu ]]; then
   family="ubuntu"
-  if [ "$DIST" == "ubuntu1804" ]; then
-    distro="bionic"
-  elif [ "$DIST" == "ubuntu2004" ]; then
+  if [ "$DIST" == "ubuntu2004" ]; then
     distro="focal"
+  elif [ "$DIST" == "ubuntu2204" ]; then
+    distro="jammy"
   fi
 else
   echo "Unsupported distribution $DIST"
@@ -114,34 +113,6 @@ $SUDO systemctl daemon-reload
 # and the BootProbeCommand might be "docker ps -q"
 $SUDO systemctl disable docker
 
-# Get Go and build singularity
-mkdir -p /var/lib/arvados
-rm -rf /var/lib/arvados/go/
-curl -s https://storage.googleapis.com/golang/go${GOVERSION}.linux-amd64.tar.gz | tar -C /var/lib/arvados -xzf -
-ln -sf /var/lib/arvados/go/bin/* /usr/local/bin/
-
-singularityversion=3.9.9
-curl -Ls https://github.com/sylabs/singularity/archive/refs/tags/v${singularityversion}.tar.gz | tar -C /var/lib/arvados -xzf -
-cd /var/lib/arvados/singularity-${singularityversion}
-
-# build dependencies for singularity
-wait_for_apt_locks && $SUDO DEBIAN_FRONTEND=noninteractive apt-get -qq --yes install \
-  make build-essential libssl-dev uuid-dev cryptsetup
-
-echo $singularityversion > VERSION
-./mconfig --prefix=/var/lib/arvados
-make -C ./builddir
-make -C ./builddir install
-ln -sf /var/lib/arvados/bin/* /usr/local/bin/
-
-# set `mksquashfs mem` in the singularity config file if it is configured
-if [ "$MKSQUASHFS_MEM" != "" ]; then
-  echo "mksquashfs mem = ${MKSQUASHFS_MEM}" >> /var/lib/arvados/etc/singularity/singularity.conf
-fi
-
-# Print singularity version installed
-singularity --version
-
 # Remove unattended-upgrades if it is installed
 wait_for_apt_locks && $SUDO DEBIAN_FRONTEND=noninteractive apt-get -qq --yes remove unattended-upgrades --purge
 
@@ -149,8 +120,8 @@ wait_for_apt_locks && $SUDO DEBIAN_FRONTEND=noninteractive apt-get -qq --yes rem
 $SUDO mkdir -p /etc/arvados/docker-cleaner
 $SUDO echo -e "{\n  \"Quota\": \"10G\",\n  \"RemoveStoppedContainers\": \"always\"\n}" > /etc/arvados/docker-cleaner/docker-cleaner.json
 
-# Enable cgroup accounting
-$SUDO sed -i 's/GRUB_CMDLINE_LINUX=""/GRUB_CMDLINE_LINUX="cgroup_enable=memory swapaccount=1"/g' /etc/default/grub
+# Enable cgroup accounting (forcing cgroups v1)
+$SUDO echo 'GRUB_CMDLINE_LINUX="$GRUB_CMDLINE_LINUX cgroup_enable=memory swapaccount=1 systemd.unified_cgroup_hierarchy=0"' >> /etc/default/grub
 $SUDO update-grub
 
 # Make sure user_allow_other is set in fuse.conf
@@ -187,7 +158,7 @@ else
   unzip -q /tmp/awscliv2.zip -d /tmp && $SUDO /tmp/aws/install
   # Pinned to v2.4.5 because we apply a patch below
   #export EBS_AUTOSCALE_VERSION=$(curl --silent "https://api.github.com/repos/awslabs/amazon-ebs-autoscale/releases/latest" | jq -r .tag_name)
-  export EBS_AUTOSCALE_VERSION="5ca6e24e05787b8ae1184c2a10db80053ddd3038"
+  export EBS_AUTOSCALE_VERSION="ee323f0751c2b6f733692e805b51b9bf3c251bac"
   cd /opt && $SUDO git clone https://github.com/arvados/amazon-ebs-autoscale.git
   cd /opt/amazon-ebs-autoscale && $SUDO git checkout $EBS_AUTOSCALE_VERSION
 
@@ -215,8 +186,7 @@ if [ "$NVIDIA_GPU_SUPPORT" == "1" ]; then
   $SUDO apt-key adv --fetch-keys https://developer.download.nvidia.com/compute/cuda/repos/$DIST/x86_64/3bf863cc.pub
   $SUDO apt-get -y install software-properties-common
   $SUDO add-apt-repository "deb https://developer.download.nvidia.com/compute/cuda/repos/$DIST/x86_64/ /"
-  # Ubuntu 18.04's add-apt-repository does not understand 'contrib'
-  $SUDO add-apt-repository contrib || true
+  $SUDO add-apt-repository contrib
   $SUDO apt-get update
   $SUDO apt-get -y install cuda
 
@@ -248,4 +218,36 @@ if [ "$NVIDIA_GPU_SUPPORT" == "1" ]; then
   $SUDO systemctl disable nvidia-persistenced.service
 fi
 
+# Get Go and build singularity
+mkdir -p /var/lib/arvados
+rm -rf /var/lib/arvados/go/
+curl -s https://storage.googleapis.com/golang/go${GOVERSION}.linux-amd64.tar.gz | tar -C /var/lib/arvados -xzf -
+ln -sf /var/lib/arvados/go/bin/* /usr/local/bin/
+
+singularityversion=3.10.4
+cd /var/lib/arvados
+git clone --recurse-submodules https://github.com/sylabs/singularity
+cd singularity
+git checkout v${singularityversion}
+
+# build dependencies for singularity
+wait_for_apt_locks && $SUDO DEBIAN_FRONTEND=noninteractive apt-get -qq --yes install \
+                           make build-essential libssl-dev uuid-dev cryptsetup \
+                           squashfs-tools libglib2.0-dev libseccomp-dev
+
+
+echo $singularityversion > VERSION
+./mconfig --prefix=/var/lib/arvados
+make -C ./builddir
+make -C ./builddir install
+ln -sf /var/lib/arvados/bin/* /usr/local/bin/
+
+# set `mksquashfs mem` in the singularity config file if it is configured
+if [ "$MKSQUASHFS_MEM" != "" ]; then
+  echo "mksquashfs mem = ${MKSQUASHFS_MEM}" >> /var/lib/arvados/etc/singularity/singularity.conf
+fi
+
+# Print singularity version installed
+singularity --version
+
 $SUDO apt-get clean
index abc63a2e9246526612f3a00c7bb2f86bcfb91a18..d9790fb45ce6e2d334d64b64d72f843e30378f33 100644 (file)
@@ -22,14 +22,21 @@ ensure_umount() {
 # First make sure docker is not using /tmp, then unmount everything under it.
 if [ -d /etc/sv/docker.io ]
 then
+  # TODO: Actually detect Docker state with runit
+  DOCKER_ACTIVE=true
   sv stop docker.io || service stop docker.io || true
 else
-  systemctl disable --now docker.service docker.socket || true
+  if systemctl --quiet is-active docker.service docker.socket; then
+    systemctl stop docker.service docker.socket || true
+    DOCKER_ACTIVE=true
+  else
+    DOCKER_ACTIVE=false
+  fi
 fi
 
 ensure_umount "$MOUNTPATH/docker/aufs"
 
-/bin/bash /opt/amazon-ebs-autoscale/install.sh -f lvm.ext4 -m $MOUNTPATH 2>&1 > /var/log/ebs-autoscale-install.log
+/bin/bash /opt/amazon-ebs-autoscale/install.sh --imdsv2 -f lvm.ext4 -m $MOUNTPATH 2>&1 > /var/log/ebs-autoscale-install.log
 
 # Make sure docker uses the big partition
 cat <<EOF > /etc/docker/daemon.json
@@ -38,13 +45,18 @@ cat <<EOF > /etc/docker/daemon.json
 }
 EOF
 
+if ! $DOCKER_ACTIVE; then
+  # Nothing else to do
+  exit 0
+fi
+
 # restart docker
 if [ -d /etc/sv/docker.io ]
 then
   ## runit
   sv up docker.io
 else
-  systemctl enable --now docker.service docker.socket
+  systemctl start docker.service docker.socket || true
 fi
 
 end=$((SECONDS+60))
index a76dc121096527101ee5c35e2434625d205252fb..726ff0cdcd4d20ff308e32d69edc7a054bd2af1b 100644 (file)
@@ -119,9 +119,16 @@ mkfs.xfs -f "$CRYPTPATH"
 # First make sure docker is not using /tmp, then unmount everything under it.
 if [ -d /etc/sv/docker.io ]
 then
+  # TODO: Actually detect Docker state with runit
+  DOCKER_ACTIVE=true
   sv stop docker.io || service stop docker.io || true
 else
-  systemctl disable --now docker.service docker.socket || true
+  if systemctl --quiet is-active docker.service docker.socket; then
+    systemctl stop docker.service docker.socket || true
+    DOCKER_ACTIVE=true
+  else
+    DOCKER_ACTIVE=false
+  fi
 fi
 
 ensure_umount "$MOUNTPATH/docker/aufs"
@@ -137,13 +144,18 @@ cat <<EOF > /etc/docker/daemon.json
 }
 EOF
 
+if ! $DOCKER_ACTIVE; then
+  # Nothing else to do
+  exit 0
+fi
+
 # restart docker
 if [ -d /etc/sv/docker.io ]
 then
   ## runit
   sv up docker.io
 else
-  systemctl enable --now docker.service docker.socket || true
+  systemctl start docker.service docker.socket || true
 fi
 
 end=$((SECONDS+60))
index d8eec3d9ee98bcdf1bd2ea603d237c5265c1750d..794b6afe4261cba9c6bfc4c5dd3fee9d6bb6c19b 100644 (file)
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+#
+# This file runs in one of three modes:
+#
+# 1. If the ARVADOS_BUILDING_VERSION environment variable is set, it writes
+#    _version.py and generates dependencies based on that value.
+# 2. If running from an arvados Git checkout, it writes _version.py
+#    and generates dependencies from Git.
+# 3. Otherwise, we expect this is source previously generated from Git, and
+#    it reads _version.py and generates dependencies from it.
 
-import subprocess
-import time
 import os
 import re
+import runpy
+import subprocess
 import sys
 
-SETUP_DIR = os.path.dirname(os.path.abspath(__file__))
-VERSION_PATHS = {
-        SETUP_DIR,
-        os.path.abspath(os.path.join(SETUP_DIR, "../../sdk/python")),
-        os.path.abspath(os.path.join(SETUP_DIR, "../../build/version-at-commit.sh"))
-        }
+from pathlib import Path
+
+# These maps explain the relationships between different Python modules in
+# the arvados repository. We use these to help generate setup.py.
+PACKAGE_DEPENDENCY_MAP = {
+    'arvados-cwl-runner': ['arvados-python-client', 'crunchstat_summary'],
+    'arvados-user-activity': ['arvados-python-client'],
+    'arvados_fuse': ['arvados-python-client'],
+    'crunchstat_summary': ['arvados-python-client'],
+}
+PACKAGE_MODULE_MAP = {
+    'arvados-cwl-runner': 'arvados_cwl',
+    'arvados-docker-cleaner': 'arvados_docker',
+    'arvados-python-client': 'arvados',
+    'arvados-user-activity': 'arvados_user_activity',
+    'arvados_fuse': 'arvados_fuse',
+    'crunchstat_summary': 'crunchstat_summary',
+}
+PACKAGE_SRCPATH_MAP = {
+    'arvados-cwl-runner': Path('sdk', 'cwl'),
+    'arvados-docker-cleaner': Path('services', 'dockercleaner'),
+    'arvados-python-client': Path('sdk', 'python'),
+    'arvados-user-activity': Path('tools', 'user-activity'),
+    'arvados_fuse': Path('services', 'fuse'),
+    'crunchstat_summary': Path('tools', 'crunchstat-summary'),
+}
+
+ENV_VERSION = os.environ.get("ARVADOS_BUILDING_VERSION")
+SETUP_DIR = Path(__file__).absolute().parent
+try:
+    REPO_PATH = Path(subprocess.check_output(
+        ['git', '-C', str(SETUP_DIR), 'rev-parse', '--show-toplevel'],
+        stderr=subprocess.DEVNULL,
+        text=True,
+    ).rstrip('\n'))
+except (subprocess.CalledProcessError, OSError):
+    REPO_PATH = None
+else:
+    # Verify this is the arvados monorepo
+    if all((REPO_PATH / path).exists() for path in PACKAGE_SRCPATH_MAP.values()):
+        PACKAGE_NAME, = (
+            pkg_name for pkg_name, path in PACKAGE_SRCPATH_MAP.items()
+            if (REPO_PATH / path) == SETUP_DIR
+        )
+        MODULE_NAME = PACKAGE_MODULE_MAP[PACKAGE_NAME]
+        VERSION_SCRIPT_PATH = Path(REPO_PATH, 'build', 'version-at-commit.sh')
+    else:
+        REPO_PATH = None
+if REPO_PATH is None:
+    (PACKAGE_NAME, MODULE_NAME), = (
+        (pkg_name, mod_name)
+        for pkg_name, mod_name in PACKAGE_MODULE_MAP.items()
+        if (SETUP_DIR / mod_name).is_dir()
+    )
+
+def short_tests_only(arglist=sys.argv):
+    try:
+        arglist.remove('--short-tests-only')
+    except ValueError:
+        return False
+    else:
+        return True
+
+def git_log_output(path, *args):
+    return subprocess.check_output(
+        ['git', '-C', str(REPO_PATH),
+         'log', '--first-parent', '--max-count=1',
+         *args, str(path)],
+        text=True,
+    ).rstrip('\n')
 
 def choose_version_from():
-    ts = {}
-    for path in VERSION_PATHS:
-        ts[subprocess.check_output(
-            ['git', 'log', '--first-parent', '--max-count=1',
-             '--format=format:%ct', path]).strip()] = path
-
-    sorted_ts = sorted(ts.items())
-    getver = sorted_ts[-1][1]
-    print("Using "+getver+" for version number calculation of "+SETUP_DIR, file=sys.stderr)
+    ver_paths = [SETUP_DIR, VERSION_SCRIPT_PATH, *(
+        PACKAGE_SRCPATH_MAP[pkg]
+        for pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ())
+    )]
+    getver = max(ver_paths, key=lambda path: git_log_output(path, '--format=format:%ct'))
+    print(f"Using {getver} for version number calculation of {SETUP_DIR}", file=sys.stderr)
     return getver
 
 def git_version_at_commit():
     curdir = choose_version_from()
-    myhash = subprocess.check_output(['git', 'log', '-n1', '--first-parent',
-                                       '--format=%H', curdir]).strip()
-    myversion = subprocess.check_output([SETUP_DIR+'/../../build/version-at-commit.sh', myhash]).strip().decode()
-    return myversion
+    myhash = git_log_output(curdir, '--format=%H')
+    return subprocess.check_output(
+        [str(VERSION_SCRIPT_PATH), myhash],
+        text=True,
+    ).rstrip('\n')
 
 def save_version(setup_dir, module, v):
-    v = v.replace("~dev", ".dev").replace("~rc", "rc")
-    with open(os.path.join(setup_dir, module, "_version.py"), 'wt') as fp:
-        return fp.write("__version__ = '%s'\n" % v)
+    with Path(setup_dir, module, '_version.py').open('w') as fp:
+        print(f"__version__ = {v!r}", file=fp)
 
 def read_version(setup_dir, module):
-    with open(os.path.join(setup_dir, module, "_version.py"), 'rt') as fp:
-        return re.match("__version__ = '(.*)'$", fp.read()).groups()[0]
-
-def get_version(setup_dir, module):
-    env_version = os.environ.get("ARVADOS_BUILDING_VERSION")
+    file_vars = runpy.run_path(Path(setup_dir, module, '_version.py'))
+    return file_vars['__version__']
 
-    if env_version:
-        save_version(setup_dir, module, env_version)
+def get_version(setup_dir=SETUP_DIR, module=MODULE_NAME):
+    if ENV_VERSION:
+        version = ENV_VERSION
+    elif REPO_PATH is None:
+        return read_version(setup_dir, module)
     else:
-        try:
-            save_version(setup_dir, module, git_version_at_commit())
-        except (subprocess.CalledProcessError, OSError) as err:
-            print("ERROR: {0}".format(err), file=sys.stderr)
-            pass
+        version = git_version_at_commit()
+    version = version.replace("~dev", ".dev").replace("~rc", "rc")
+    save_version(setup_dir, module, version)
+    return version
+
+def iter_dependencies(version=None):
+    if version is None:
+        version = get_version()
+    # A packaged development release should be installed with other
+    # development packages built from the same source, but those
+    # dependencies may have earlier "dev" versions (read: less recent
+    # Git commit timestamps). This compatible version dependency
+    # expresses that as closely as possible. Allowing versions
+    # compatible with .dev0 allows any development release.
+    # Regular expression borrowed partially from
+    # <https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers-regex>
+    dep_ver, match_count = re.subn(r'\.dev(0|[1-9][0-9]*)$', '.dev0', version, 1)
+    dep_op = '~=' if match_count else '=='
+    for dep_pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ()):
+        yield f'{dep_pkg}{dep_op}{dep_ver}'
 
-    return read_version(setup_dir, module)
+# Called from calculate_python_sdk_cwl_package_versions() in run-library.sh
+if __name__ == '__main__':
+    print(get_version())
index 9bdf3589ab6ef0589dcac19ef3f44194220f84ba..610766e198589078bfe4601f452c3088b2a73f50 100644 (file)
@@ -3,6 +3,9 @@
 # SPDX-License-Identifier: AGPL-3.0
 
 import logging
+import sys
+
 
 logger = logging.getLogger(__name__)
-logger.addHandler(logging.NullHandler())
+logger.addHandler(logging.StreamHandler(stream=sys.stderr))
+logger.setLevel(logging.WARNING)
index ec7acb8083928f6f35f2af7835ee92f8a4a895dc..c5a1068eff9b9e54b607ff814f13734ee367e8d5 100644 (file)
@@ -7,8 +7,10 @@ import gzip
 from io import open
 import logging
 import sys
+import arvados
 
-from crunchstat_summary import logger, summarizer
+from crunchstat_summary import logger, summarizer, reader
+from crunchstat_summary._version import __version__
 
 
 class ArgumentParser(argparse.ArgumentParser):
@@ -28,9 +30,6 @@ class ArgumentParser(argparse.ArgumentParser):
             help='[Deprecated] Look up the specified container find its container request '
             'and read its log data from Keep (or from the Arvados event log, '
             'if the job is still running)')
-        src.add_argument(
-            '--pipeline-instance', type=str, metavar='UUID',
-            help='[Deprecated] Summarize each component of the given pipeline instance (historical pre-1.4)')
         src.add_argument(
             '--log-file', type=str,
             help='Read log data from a regular file')
@@ -46,6 +45,9 @@ class ArgumentParser(argparse.ArgumentParser):
         self.add_argument(
             '--verbose', '-v', action='count', default=0,
             help='Log more information (once for progress, twice for debug)')
+        self.add_argument('--version', action='version',
+                         version="%s %s" % (sys.argv[0], __version__),
+                         help='Print version and exit.')
 
 
 class UTF8Decode(object):
@@ -82,10 +84,9 @@ class Command(object):
         kwargs = {
             'skip_child_jobs': self.args.skip_child_jobs,
             'threads': self.args.threads,
+            'arv': arvados.api('v1')
         }
-        if self.args.pipeline_instance:
-            self.summer = summarizer.NewSummarizer(self.args.pipeline_instance, **kwargs)
-        elif self.args.job:
+        if self.args.job:
             self.summer = summarizer.NewSummarizer(self.args.job, **kwargs)
         elif self.args.container:
             self.summer = summarizer.NewSummarizer(self.args.container, **kwargs)
@@ -94,9 +95,9 @@ class Command(object):
                 fh = UTF8Decode(gzip.open(self.args.log_file))
             else:
                 fh = open(self.args.log_file, mode = 'r', encoding = 'utf-8')
-            self.summer = summarizer.Summarizer(fh, **kwargs)
+            self.summer = summarizer.Summarizer(reader.StubReader(fh), **kwargs)
         else:
-            self.summer = summarizer.Summarizer(sys.stdin, **kwargs)
+            self.summer = summarizer.Summarizer(reader.StubReader(sys.stdin), **kwargs)
         return self.summer.run()
 
     def report(self):
index 52e5534ef179f1124c90084e68596b8a43bf08e4..76c92107042bf9663324489c6e5ed9a0d4576981 100644 (file)
@@ -40,9 +40,7 @@ window.onload = function() {
         },
     }
     chartdata.forEach(function(section, section_idx) {
-        var h1 = document.createElement('h1');
-        h1.appendChild(document.createTextNode(section.label));
-        document.body.appendChild(h1);
+        var chartDiv = document.getElementById("chart");
         section.charts.forEach(function(chart, chart_idx) {
             // Skip chart if every series has zero data points
             if (0 == chart.data.reduce(function(len, series) {
@@ -54,7 +52,7 @@ window.onload = function() {
             var div = document.createElement('div');
             div.setAttribute('id', id);
             div.setAttribute('style', 'width: 100%; height: 150px');
-            document.body.appendChild(div);
+            chartDiv.appendChild(div);
             chart.options.valueFormatter = function(y) {
             }
             chart.options.axes = {
@@ -68,6 +66,17 @@ window.onload = function() {
                     valueFormatter: fmt.iso,
                 },
             }
+            var div2 = document.createElement('div');
+            div2.setAttribute('style', 'width: 150px; height: 150px');
+            chart.options.labelsDiv = div2;
+            chart.options.labelsSeparateLines = true;
+
+            var div3 = document.createElement('div');
+            div3.setAttribute('style', 'display: flex; padding-bottom: 16px');
+            div3.appendChild(div);
+            div3.appendChild(div2);
+            chartDiv.appendChild(div3);
+
             charts[id] = new Dygraph(div, chart.data, chart.options);
         });
     });
index 8ccdbc2fcf04e45ca3ab3ec6e2270933d050ea1c..0198d765c3533df4cdeb42096fedc0cd57d20051 100644 (file)
@@ -4,6 +4,7 @@
 
 import arvados
 import itertools
+import json
 import queue
 import threading
 
@@ -11,24 +12,26 @@ from crunchstat_summary import logger
 
 
 class CollectionReader(object):
-    def __init__(self, collection_id):
+    def __init__(self, collection_id, api_client=None, collection_object=None):
         self._collection_id = collection_id
         self._label = collection_id
         self._readers = []
+        self._api_client = api_client
+        self._collection = collection_object or arvados.collection.CollectionReader(self._collection_id, api_client=self._api_client)
 
     def __str__(self):
         return self._label
 
     def __iter__(self):
         logger.debug('load collection %s', self._collection_id)
-        collection = arvados.collection.CollectionReader(self._collection_id)
-        filenames = [filename for filename in collection]
+
+        filenames = [filename for filename in self._collection]
         # Crunch2 has multiple stats files
         if len(filenames) > 1:
             filenames = ['crunchstat.txt', 'arv-mount.txt']
         for filename in filenames:
             try:
-                self._readers.append(collection.open(filename))
+                self._readers.append(self._collection.open(filename, "rt"))
             except IOError:
                 logger.warn('Unable to open %s', filename)
         self._label = "{}/{}".format(self._collection_id, filenames[0])
@@ -43,6 +46,14 @@ class CollectionReader(object):
                 reader.close()
             self._readers = []
 
+    def node_info(self):
+        try:
+            with self._collection.open("node.json", "rt") as f:
+                return json.load(f)
+        except IOError:
+            logger.warn('Unable to open node.json')
+        return {}
+
 
 class LiveLogReader(object):
     EOF = None
@@ -63,7 +74,7 @@ class LiveLogReader(object):
             ['event_type', 'in', self.event_types]]
         try:
             while True:
-                page = arvados.api().logs().index(
+                page = arvados.api().logs().list(
                     limit=1000,
                     order=['id asc'],
                     filters=filters + [['id','>',str(last_id)]],
@@ -105,3 +116,25 @@ class LiveLogReader(object):
 
     def __exit__(self, exc_type, exc_val, exc_tb):
         pass
+
+    def node_info(self):
+        return {}
+
+class StubReader(object):
+    def __init__(self, fh):
+        self.fh = fh
+
+    def __str__(self):
+        return ""
+
+    def __iter__(self):
+        return iter(self.fh)
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        pass
+
+    def node_info(self):
+        return {}
index 463c552c4f1eb5caf0868337858197a747bc8fa8..bc41fdae33272d3df98ad8c998bf5a05db308120 100644 (file)
@@ -12,16 +12,17 @@ import itertools
 import math
 import re
 import sys
-import threading
 import _strptime
+import arvados.util
+
+from concurrent.futures import ThreadPoolExecutor
 
-from arvados.api import OrderedJsonModel
 from crunchstat_summary import logger
 
 # Recommend memory constraints that are this multiple of an integral
 # number of GiB. (Actual nodes tend to be sold in sizes like 8 GiB
 # that have amounts like 7.5 GiB according to the kernel.)
-AVAILABLE_RAM_RATIO = 0.95
+AVAILABLE_RAM_RATIO = 0.90
 MB=2**20
 
 # Workaround datetime.datetime.strptime() thread-safety bug by calling
@@ -64,8 +65,11 @@ class Summarizer(object):
         # are already suitable.  If applicable, the subclass
         # constructor will overwrite this with something useful.
         self.existing_constraints = {}
+        self.node_info = {}
+        self.cost = 0
+        self.arv_config = {}
 
-        logger.debug("%s: logdata %s", self.label, logdata)
+        logger.info("%s: logdata %s", self.label, logdata)
 
     def run(self):
         logger.debug("%s: parsing logdata %s", self.label, self._logdata)
@@ -73,78 +77,23 @@ class Summarizer(object):
             self._run(logdata)
 
     def _run(self, logdata):
-        self.detected_crunch1 = False
-        for line in logdata:
-            if not self.detected_crunch1 and '-8i9sb-' in line:
-                self.detected_crunch1 = True
-
-            if self.detected_crunch1:
-                m = re.search(r'^\S+ \S+ \d+ (?P<seq>\d+) job_task (?P<task_uuid>\S+)$', line)
-                if m:
-                    seq = int(m.group('seq'))
-                    uuid = m.group('task_uuid')
-                    self.seq_to_uuid[seq] = uuid
-                    logger.debug('%s: seq %d is task %s', self.label, seq, uuid)
-                    continue
-
-                m = re.search(r'^\S+ \S+ \d+ (?P<seq>\d+) (success in|failure \(#., permanent\) after) (?P<elapsed>\d+) seconds', line)
-                if m:
-                    task_id = self.seq_to_uuid[int(m.group('seq'))]
-                    elapsed = int(m.group('elapsed'))
-                    self.task_stats[task_id]['time'] = {'elapsed': elapsed}
-                    if elapsed > self.stats_max['time']['elapsed']:
-                        self.stats_max['time']['elapsed'] = elapsed
-                    continue
+        if not self.node_info:
+            self.node_info = logdata.node_info()
 
-                m = re.search(r'^\S+ \S+ \d+ (?P<seq>\d+) stderr Queued job (?P<uuid>\S+)$', line)
-                if m:
-                    uuid = m.group('uuid')
-                    if self._skip_child_jobs:
-                        logger.warning('%s: omitting stats from child job %s'
-                                       ' because --skip-child-jobs flag is on',
-                                       self.label, uuid)
-                        continue
-                    logger.debug('%s: follow %s', self.label, uuid)
-                    child_summarizer = NewSummarizer(uuid)
-                    child_summarizer.stats_max = self.stats_max
-                    child_summarizer.task_stats = self.task_stats
-                    child_summarizer.tasks = self.tasks
-                    child_summarizer.starttime = self.starttime
-                    child_summarizer.run()
-                    logger.debug('%s: done %s', self.label, uuid)
-                    continue
-
-                # 2017-12-02_17:15:08 e51c5-8i9sb-mfp68stkxnqdd6m 63676 0 stderr crunchstat: keepcalls 0 put 2576 get -- interval 10.0000 seconds 0 put 2576 get
-                m = re.search(r'^(?P<timestamp>[^\s.]+)(\.\d+)? (?P<job_uuid>\S+) \d+ (?P<seq>\d+) stderr (?P<crunchstat>crunchstat: )(?P<category>\S+) (?P<current>.*?)( -- interval (?P<interval>.*))?\n$', line)
-                if not m:
-                    continue
-            else:
-                # crunch2
-                # 2017-12-01T16:56:24.723509200Z crunchstat: keepcalls 0 put 3 get -- interval 10.0000 seconds 0 put 3 get
-                m = re.search(r'^(?P<timestamp>\S+) (?P<crunchstat>crunchstat: )?(?P<category>\S+) (?P<current>.*?)( -- interval (?P<interval>.*))?\n$', line)
-                if not m:
-                    continue
+        for line in logdata:
+            # crunch2
+            # 2017-12-01T16:56:24.723509200Z crunchstat: keepcalls 0 put 3 get -- interval 10.0000 seconds 0 put 3 get
+            m = re.search(r'^(?P<timestamp>\S+) (?P<crunchstat>crunchstat: )?(?P<category>\S+) (?P<current>.*?)( -- interval (?P<interval>.*))?\n$', line)
+            if not m:
+                continue
 
             if self.label is None:
                 try:
                     self.label = m.group('job_uuid')
                 except IndexError:
                     self.label = 'label #1'
-            category = m.group('category')
-            if category.endswith(':'):
-                # "stderr crunchstat: notice: ..."
-                continue
-            elif category in ('error', 'caught'):
-                continue
-            elif category in ('read', 'open', 'cgroup', 'CID', 'Running'):
-                # "stderr crunchstat: read /proc/1234/net/dev: ..."
-                # (old logs are less careful with unprefixed error messages)
-                continue
 
-            if self.detected_crunch1:
-                task_id = self.seq_to_uuid[int(m.group('seq'))]
-            else:
-                task_id = 'container'
+            task_id = 'container'
             task = self.tasks[task_id]
 
             # Use the first and last crunchstat timestamps as
@@ -173,12 +122,23 @@ class Summarizer(object):
             if self.finishtime is None or timestamp > self.finishtime:
                 self.finishtime = timestamp
 
-            if (not self.detected_crunch1) and task.starttime is not None and task.finishtime is not None:
+            if task.starttime is not None and task.finishtime is not None:
                 elapsed = (task.finishtime - task.starttime).seconds
                 self.task_stats[task_id]['time'] = {'elapsed': elapsed}
                 if elapsed > self.stats_max['time']['elapsed']:
                     self.stats_max['time']['elapsed'] = elapsed
 
+            category = m.group('category')
+            if category.endswith(':'):
+                # "stderr crunchstat: notice: ..."
+                continue
+            elif category in ('error', 'caught'):
+                continue
+            elif category in ('read', 'open', 'cgroup', 'CID', 'Running'):
+                # "stderr crunchstat: read /proc/1234/net/dev: ..."
+                # (old logs are less careful with unprefixed error messages)
+                continue
+
             this_interval_s = None
             for group in ['current', 'interval']:
                 if not m.group(group):
@@ -245,34 +205,73 @@ class Summarizer(object):
                     self.job_tot[category][stat] += val
         logger.debug('%s: done totals', self.label)
 
+        if self.stats_max['time'].get('elapsed', 0) > 20:
+            # needs to have executed for at least 20 seconds or we may
+            # not have collected any metrics and these warnings are duds.
+            missing_category = {
+                'cpu': 'CPU',
+                'mem': 'memory',
+                'net:': 'network I/O',
+                'statfs': 'storage space',
+            }
+            for task_stat in self.task_stats.values():
+                for category in task_stat.keys():
+                    for checkcat in missing_category:
+                        if checkcat.endswith(':'):
+                            if category.startswith(checkcat):
+                                missing_category.pop(checkcat)
+                                break
+                        else:
+                            if category == checkcat:
+                                missing_category.pop(checkcat)
+                                break
+            for catlabel in missing_category.values():
+                logger.warning('%s: %s stats are missing -- possible cluster configuration issue',
+                            self.label, catlabel)
+
     def long_label(self):
         label = self.label
         if hasattr(self, 'process') and self.process['uuid'] not in label:
             label = '{} ({})'.format(label, self.process['uuid'])
-        if self.finishtime:
-            label += ' -- elapsed time '
-            s = (self.finishtime - self.starttime).total_seconds()
-            if s > 86400:
-                label += '{}d'.format(int(s/86400))
-            if s > 3600:
-                label += '{}h'.format(int(s/3600) % 24)
-            if s > 60:
-                label += '{}m'.format(int(s/60) % 60)
-            label += '{}s'.format(int(s) % 60)
+        return label
+
+    def elapsed_time(self):
+        if not self.finishtime:
+            return ""
+        label = ""
+        s = (self.finishtime - self.starttime).total_seconds()
+        if s > 86400:
+            label += '{}d '.format(int(s/86400))
+        if s > 3600:
+            label += '{}h '.format(int(s/3600) % 24)
+        if s > 60:
+            label += '{}m '.format(int(s/60) % 60)
+        label += '{}s'.format(int(s) % 60)
         return label
 
     def text_report(self):
         if not self.tasks:
             return "(no report generated)\n"
         return "\n".join(itertools.chain(
-            self._text_report_gen(),
-            self._recommend_gen())) + "\n"
+            self._text_report_table_gen(lambda x: "\t".join(x),
+                                  lambda x: "\t".join(x)),
+            self._text_report_agg_gen(lambda x: "# {}: {}{}".format(x[0], x[1], x[2])),
+            self._recommend_gen(lambda x: "#!! "+x))) + "\n"
 
     def html_report(self):
-        return WEBCHART_CLASS(self.label, [self]).html()
+        tophtml = """{}\n<table class='aggtable'><tbody>{}</tbody></table>\n""".format(
+            "\n".join(self._recommend_gen(lambda x: "<p>{}</p>".format(x))),
+            "\n".join(self._text_report_agg_gen(lambda x: "<tr><th>{}</th><td>{}{}</td></tr>".format(*x))))
+
+        bottomhtml = """<table class='metricstable'><tbody>{}</tbody></table>\n""".format(
+            "\n".join(self._text_report_table_gen(lambda x: "<tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr>".format(*x),
+                                                        lambda x: "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>".format(*x))))
+        label = self.long_label()
 
-    def _text_report_gen(self):
-        yield "\t".join(['category', 'metric', 'task_max', 'task_max_rate', 'job_total'])
+        return WEBCHART_CLASS(label, [self]).html(tophtml, bottomhtml)
+
+    def _text_report_table_gen(self, headerformat, rowformat):
+        yield headerformat(['category', 'metric', 'task_max', 'task_max_rate', 'job_total'])
         for category, stat_max in sorted(self.stats_max.items()):
             for stat, val in sorted(stat_max.items()):
                 if stat.endswith('__rate'):
@@ -280,66 +279,135 @@ class Summarizer(object):
                 max_rate = self._format(stat_max.get(stat+'__rate', '-'))
                 val = self._format(val)
                 tot = self._format(self.job_tot[category].get(stat, '-'))
-                yield "\t".join([category, stat, str(val), max_rate, tot])
-        for args in (
-                ('Number of tasks: {}',
+                yield rowformat([category, stat, str(val), max_rate, tot])
+
+    def _text_report_agg_gen(self, aggformat):
+        by_single_task = ""
+        if len(self.tasks) > 1:
+            by_single_task = " by a single task"
+
+        metrics = [
+            ('Elapsed time',
+             self.elapsed_time(),
+             None,
+             ''),
+
+            ('Estimated cost',
+             '${:.3f}'.format(self.cost),
+             None,
+             '') if self.cost > 0 else None,
+
+            ('Assigned instance type',
+             self.node_info.get('ProviderType'),
+             None,
+             '') if self.node_info.get('ProviderType') else None,
+
+            ('Instance hourly price',
+             '${:.3f}'.format(self.node_info.get('Price')),
+             None,
+             '') if self.node_info.get('Price') else None,
+
+            ('Max CPU usage in a single interval',
+             self.stats_max['cpu']['user+sys__rate'],
+             lambda x: x * 100,
+             '%'),
+
+            ('Overall CPU usage',
+             float(self.job_tot['cpu']['user+sys']) /
+             self.job_tot['time']['elapsed']
+             if self.job_tot['time']['elapsed'] > 0 else 0,
+             lambda x: x * 100,
+             '%'),
+
+            ('Requested CPU cores',
+             self.existing_constraints.get(self._map_runtime_constraint('vcpus')),
+             None,
+             '') if self.existing_constraints.get(self._map_runtime_constraint('vcpus')) else None,
+
+            ('Instance VCPUs',
+             self.node_info.get('VCPUs'),
+             None,
+             '') if self.node_info.get('VCPUs') else None,
+
+            ('Max memory used{}'.format(by_single_task),
+             self.stats_max['mem']['rss'],
+             lambda x: x / 2**20,
+             'MB'),
+
+            ('Requested RAM',
+             self.existing_constraints.get(self._map_runtime_constraint('ram')),
+             lambda x: x / 2**20,
+             'MB') if self.existing_constraints.get(self._map_runtime_constraint('ram')) else None,
+
+            ('Maximum RAM request for this instance type',
+             (self.node_info.get('RAM') - self.arv_config.get('Containers', {}).get('ReserveExtraRAM', 0))*.95,
+             lambda x: x / 2**20,
+             'MB') if self.node_info.get('RAM') else None,
+
+            ('Max network traffic{}'.format(by_single_task),
+             self.stats_max['net:eth0']['tx+rx'] +
+             self.stats_max['net:keep0']['tx+rx'],
+             lambda x: x / 1e9,
+             'GB'),
+
+            ('Max network speed in a single interval',
+             self.stats_max['net:eth0']['tx+rx__rate'] +
+             self.stats_max['net:keep0']['tx+rx__rate'],
+             lambda x: x / 1e6,
+             'MB/s'),
+
+            ('Keep cache miss rate',
+             (float(self.job_tot['keepcache']['miss']) /
+              float(self.job_tot['keepcalls']['get']))
+             if self.job_tot['keepcalls']['get'] > 0 else 0,
+             lambda x: x * 100.0,
+             '%'),
+
+            ('Keep cache utilization',
+             (float(self.job_tot['blkio:0:0']['read']) /
+              float(self.job_tot['net:keep0']['rx']))
+             if self.job_tot['net:keep0']['rx'] > 0 else 0,
+             lambda x: x * 100.0,
+             '%'),
+
+            ('Temp disk utilization',
+             (float(self.job_tot['statfs']['used']) /
+              float(self.job_tot['statfs']['total']))
+             if self.job_tot['statfs']['total'] > 0 else 0,
+             lambda x: x * 100.0,
+             '%'),
+        ]
+
+        if len(self.tasks) > 1:
+            metrics.insert(0, ('Number of tasks',
                  len(self.tasks),
-                 None),
-                ('Max CPU time spent by a single task: {}s',
-                 self.stats_max['cpu']['user+sys'],
-                 None),
-                ('Max CPU usage in a single interval: {}%',
-                 self.stats_max['cpu']['user+sys__rate'],
-                 lambda x: x * 100),
-                ('Overall CPU usage: {}%',
-                 float(self.job_tot['cpu']['user+sys']) /
-                 self.job_tot['time']['elapsed']
-                 if self.job_tot['time']['elapsed'] > 0 else 0,
-                 lambda x: x * 100),
-                ('Max memory used by a single task: {}GB',
-                 self.stats_max['mem']['rss'],
-                 lambda x: x / 1e9),
-                ('Max network traffic in a single task: {}GB',
-                 self.stats_max['net:eth0']['tx+rx'] +
-                 self.stats_max['net:keep0']['tx+rx'],
-                 lambda x: x / 1e9),
-                ('Max network speed in a single interval: {}MB/s',
-                 self.stats_max['net:eth0']['tx+rx__rate'] +
-                 self.stats_max['net:keep0']['tx+rx__rate'],
-                 lambda x: x / 1e6),
-                ('Keep cache miss rate {}%',
-                 (float(self.job_tot['keepcache']['miss']) /
-                 float(self.job_tot['keepcalls']['get']))
-                 if self.job_tot['keepcalls']['get'] > 0 else 0,
-                 lambda x: x * 100.0),
-                ('Keep cache utilization {}%',
-                 (float(self.job_tot['blkio:0:0']['read']) /
-                 float(self.job_tot['net:keep0']['rx']))
-                 if self.job_tot['net:keep0']['rx'] > 0 else 0,
-                 lambda x: x * 100.0),
-               ('Temp disk utilization {}%',
-                 (float(self.job_tot['statfs']['used']) /
-                 float(self.job_tot['statfs']['total']))
-                 if self.job_tot['statfs']['total'] > 0 else 0,
-                 lambda x: x * 100.0),
-                ):
-            format_string, val, transform = args
+                 None,
+                 ''))
+        for args in metrics:
+            if args is None:
+                continue
+            format_string, val, transform, suffix = args
             if val == float('-Inf'):
                 continue
             if transform:
                 val = transform(val)
-            yield "# "+format_string.format(self._format(val))
+            yield aggformat((format_string, self._format(val), suffix))
 
-    def _recommend_gen(self):
+    def _recommend_gen(self, recommendformat):
         # TODO recommend fixing job granularity if elapsed time is too short
+
+        if self.stats_max['time'].get('elapsed', 0) <= 20:
+            # Not enough data
+            return []
+
         return itertools.chain(
-            self._recommend_cpu(),
-            self._recommend_ram(),
-            self._recommend_keep_cache(),
-            self._recommend_temp_disk(),
+            self._recommend_cpu(recommendformat),
+            self._recommend_ram(recommendformat),
+            self._recommend_keep_cache(recommendformat),
+            self._recommend_temp_disk(recommendformat),
             )
 
-    def _recommend_cpu(self):
+    def _recommend_cpu(self, recommendformat):
         """Recommend asking for 4 cores if max CPU usage was 333%"""
 
         constraint_key = self._map_runtime_constraint('vcpus')
@@ -353,19 +421,17 @@ class Summarizer(object):
         asked_cores = self.existing_constraints.get(constraint_key)
         if asked_cores is None:
             asked_cores = 1
-        # TODO: This should be more nuanced in cases where max >> avg
-        if used_cores < asked_cores:
-            yield (
-                '#!! {} max CPU usage was {}% -- '
-                'try reducing runtime_constraints to "{}":{}'
+
+        if used_cores < (asked_cores*.5):
+            yield recommendformat(
+                '{} peak CPU usage was only {}% out of possible {}% ({} cores requested)'
             ).format(
                 self.label,
                 math.ceil(cpu_max_rate*100),
-                constraint_key,
-                int(used_cores))
+                asked_cores*100, asked_cores)
 
     # FIXME: This needs to be updated to account for current a-d-c algorithms
-    def _recommend_ram(self):
+    def _recommend_ram(self, recommendformat):
         """Recommend an economical RAM constraint for this job.
 
         Nodes that are advertised as "8 gibibytes" actually have what
@@ -404,55 +470,63 @@ class Summarizer(object):
         if used_bytes == float('-Inf'):
             logger.warning('%s: no memory usage data', self.label)
             return
+        if not self.existing_constraints.get(constraint_key):
+            return
         used_mib = math.ceil(float(used_bytes) / MB)
-        asked_mib = self.existing_constraints.get(constraint_key)
+        asked_mib = self.existing_constraints.get(constraint_key) / MB
 
         nearlygibs = lambda mebibytes: mebibytes/AVAILABLE_RAM_RATIO/1024
-        if used_mib > 0 and (asked_mib is None or (
-                math.ceil(nearlygibs(used_mib)) < nearlygibs(asked_mib))):
-            yield (
-                '#!! {} max RSS was {} MiB -- '
-                'try reducing runtime_constraints to "{}":{}'
+        ratio = 0.5
+        recommend_mib = int(math.ceil(nearlygibs(used_mib/ratio))*AVAILABLE_RAM_RATIO*1024)
+        if used_mib > 0 and (used_mib / asked_mib) < ratio and asked_mib > recommend_mib:
+            yield recommendformat(
+                '{} peak RAM usage was only {}% ({} MiB used / {} MiB requested)'
             ).format(
                 self.label,
+                int(math.ceil(100*(used_mib / asked_mib))),
                 int(used_mib),
-                constraint_key,
-                int(math.ceil(nearlygibs(used_mib))*AVAILABLE_RAM_RATIO*1024*(MB)/self._runtime_constraint_mem_unit()))
+                int(asked_mib))
+
+    def _recommend_keep_cache(self, recommendformat):
+        """Recommend increasing keep cache if utilization < 50%.
+
+        This means the amount of data returned to the program is less
+        than 50% of the amount of data actually downloaded by
+        arv-mount.
+        """
 
-    def _recommend_keep_cache(self):
-        """Recommend increasing keep cache if utilization < 80%"""
-        constraint_key = self._map_runtime_constraint('keep_cache_ram')
         if self.job_tot['net:keep0']['rx'] == 0:
             return
+
+        miss_rate = (float(self.job_tot['keepcache']['miss']) /
+                     float(self.job_tot['keepcalls']['get']))
+
         utilization = (float(self.job_tot['blkio:0:0']['read']) /
                        float(self.job_tot['net:keep0']['rx']))
         # FIXME: the default on this get won't work correctly
-        asked_cache = self.existing_constraints.get(constraint_key, 256) * self._runtime_constraint_mem_unit()
+        asked_cache = self.existing_constraints.get('keep_cache_ram') or self.existing_constraints.get('keep_cache_disk')
 
-        if utilization < 0.8:
-            yield (
-                '#!! {} Keep cache utilization was {:.2f}% -- '
-                'try doubling runtime_constraints to "{}":{} (or more)'
+        if utilization < 0.5 and miss_rate > .05:
+            yield recommendformat(
+                '{} Keep cache utilization was only {:.2f}% and miss rate was {:.2f}% -- '
+                'recommend increasing keep_cache'
             ).format(
                 self.label,
                 utilization * 100.0,
-                constraint_key,
-                math.ceil(asked_cache * 2 / self._runtime_constraint_mem_unit()))
+                miss_rate * 100.0)
 
 
-    def _recommend_temp_disk(self):
-        """Recommend decreasing temp disk if utilization < 50%"""
-        total = float(self.job_tot['statfs']['total'])
-        utilization = (float(self.job_tot['statfs']['used']) / total) if total > 0 else 0.0
+    def _recommend_temp_disk(self, recommendformat):
+        """This recommendation is disabled for the time being.  It was
+        using the total disk on the node and not the amount of disk
+        requested, so it would trigger a false positive basically
+        every time.  To get the amount of disk requested we need to
+        fish it out of the mounts, which is extra work I don't want do
+        right now.  You can find the old code at commit 616d135e77
 
-        if utilization < 50.8 and total > 0:
-            yield (
-                '#!! {} max temp disk utilization was {:.0f}% of {:.0f} MiB -- '
-                'consider reducing "tmpdirMin" and/or "outdirMin"'
-            ).format(
-                self.label,
-                utilization * 100.0,
-                total / MB)
+        """
+
+        return []
 
 
     def _format(self, val):
@@ -467,18 +541,11 @@ class Summarizer(object):
     def _runtime_constraint_mem_unit(self):
         if hasattr(self, 'runtime_constraint_mem_unit'):
             return self.runtime_constraint_mem_unit
-        elif self.detected_crunch1:
-            return JobSummarizer.runtime_constraint_mem_unit
         else:
             return ContainerRequestSummarizer.runtime_constraint_mem_unit
 
     def _map_runtime_constraint(self, key):
-        if hasattr(self, 'map_runtime_constraint'):
-            return self.map_runtime_constraint[key]
-        elif self.detected_crunch1:
-            return JobSummarizer.map_runtime_constraint[key]
-        else:
-            return key
+        return key
 
 
 class CollectionSummarizer(Summarizer):
@@ -497,7 +564,7 @@ def NewSummarizer(process_or_uuid, **kwargs):
     else:
         uuid = process_or_uuid
         process = None
-        arv = arvados.api('v1', model=OrderedJsonModel())
+        arv = kwargs.get("arv") or arvados.api('v1')
 
     if '-dz642-' in uuid:
         if process is None:
@@ -510,14 +577,6 @@ def NewSummarizer(process_or_uuid, **kwargs):
         if process is None:
             process = arv.container_requests().get(uuid=uuid).execute()
         klass = ContainerRequestTreeSummarizer
-    elif '-8i9sb-' in uuid:
-        if process is None:
-            process = arv.jobs().get(uuid=uuid).execute()
-        klass = JobTreeSummarizer
-    elif '-d1hrv-' in uuid:
-        if process is None:
-            process = arv.pipeline_instances().get(uuid=uuid).execute()
-        klass = PipelineSummarizer
     elif '-4zz18-' in uuid:
         return CollectionSummarizer(collection_id=uuid)
     else:
@@ -531,6 +590,7 @@ class ProcessSummarizer(Summarizer):
     def __init__(self, process, label=None, **kwargs):
         rdr = None
         self.process = process
+        arv = kwargs.get("arv") or arvados.api('v1')
         if label is None:
             label = self.process.get('name', self.process['uuid'])
         # Pre-Arvados v1.4 everything is in 'log'
@@ -538,7 +598,10 @@ class ProcessSummarizer(Summarizer):
         log_collection = self.process.get('log', self.process.get('log_uuid'))
         if log_collection and self.process.get('state') != 'Uncommitted': # arvados.util.CR_UNCOMMITTED:
             try:
-                rdr = crunchstat_summary.reader.CollectionReader(log_collection)
+                rdr = crunchstat_summary.reader.CollectionReader(
+                    log_collection,
+                    api_client=arv,
+                    collection_object=kwargs.get("collection_object"))
             except arvados.errors.NotFoundError as e:
                 logger.warning("Trying event logs after failing to read "
                                "log collection %s: %s", self.process['log'], e)
@@ -546,17 +609,11 @@ class ProcessSummarizer(Summarizer):
             uuid = self.process.get('container_uuid', self.process.get('uuid'))
             rdr = crunchstat_summary.reader.LiveLogReader(uuid)
             label = label + ' (partial)'
+
         super(ProcessSummarizer, self).__init__(rdr, label=label, **kwargs)
         self.existing_constraints = self.process.get('runtime_constraints', {})
-
-
-class JobSummarizer(ProcessSummarizer):
-    runtime_constraint_mem_unit = MB
-    map_runtime_constraint = {
-        'keep_cache_ram': 'keep_cache_mb_per_task',
-        'ram': 'min_ram_mb_per_node',
-        'vcpus': 'min_cores_per_node',
-    }
+        self.arv_config = arv.config()
+        self.cost = self.process.get('cost', 0)
 
 
 class ContainerRequestSummarizer(ProcessSummarizer):
@@ -565,26 +622,26 @@ class ContainerRequestSummarizer(ProcessSummarizer):
 
 class MultiSummarizer(object):
     def __init__(self, children={}, label=None, threads=1, **kwargs):
-        self.throttle = threading.Semaphore(threads)
         self.children = children
         self.label = label
-
-    def run_and_release(self, target, *args, **kwargs):
-        try:
-            return target(*args, **kwargs)
-        finally:
-            self.throttle.release()
+        self.threadcount = threads
 
     def run(self):
-        threads = []
-        for child in self.children.values():
-            self.throttle.acquire()
-            t = threading.Thread(target=self.run_and_release, args=(child.run, ))
-            t.daemon = True
-            t.start()
-            threads.append(t)
-        for t in threads:
-            t.join()
+        if self.threadcount > 1 and len(self.children) > 1:
+            completed = 0
+            def run_and_progress(child):
+                try:
+                    child.run()
+                except Exception as e:
+                    logger.exception("parse error")
+                completed += 1
+                logger.info("%s/%s summarized %s", completed, len(self.children), child.label)
+            with ThreadPoolExecutor(max_workers=self.threadcount) as tpe:
+                for child in self.children.values():
+                    tpe.submit(run_and_progress, child)
+        else:
+            for child in self.children.values():
+                child.run()
 
     def text_report(self):
         txt = ''
@@ -612,57 +669,26 @@ class MultiSummarizer(object):
         return d
 
     def html_report(self):
-        return WEBCHART_CLASS(self.label, iter(self._descendants().values())).html()
+        tophtml = ""
+        bottomhtml = ""
+        label = self.label
+        if len(self._descendants()) == 1:
+            summarizer = next(iter(self._descendants().values()))
+            tophtml = """{}\n<table class='aggtable'><tbody>{}</tbody></table>\n""".format(
+                "\n".join(summarizer._recommend_gen(lambda x: "<p>{}</p>".format(x))),
+                "\n".join(summarizer._text_report_agg_gen(lambda x: "<tr><th>{}</th><td>{}{}</td></tr>".format(*x))))
 
+            bottomhtml = """<table class='metricstable'><tbody>{}</tbody></table>\n""".format(
+                "\n".join(summarizer._text_report_table_gen(lambda x: "<tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr>".format(*x),
+                                                            lambda x: "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>".format(*x))))
+            label = summarizer.long_label()
 
-class JobTreeSummarizer(MultiSummarizer):
-    """Summarizes a job and all children listed in its components field."""
-    def __init__(self, job, label=None, **kwargs):
-        arv = arvados.api('v1', model=OrderedJsonModel())
-        label = label or job.get('name', job['uuid'])
-        children = collections.OrderedDict()
-        children[job['uuid']] = JobSummarizer(job, label=label, **kwargs)
-        if job.get('components', None):
-            preloaded = {}
-            for j in arv.jobs().index(
-                    limit=len(job['components']),
-                    filters=[['uuid','in',list(job['components'].values())]]).execute()['items']:
-                preloaded[j['uuid']] = j
-            for cname in sorted(job['components'].keys()):
-                child_uuid = job['components'][cname]
-                j = (preloaded.get(child_uuid) or
-                     arv.jobs().get(uuid=child_uuid).execute())
-                children[child_uuid] = JobTreeSummarizer(job=j, label=cname, **kwargs)
-
-        super(JobTreeSummarizer, self).__init__(
-            children=children,
-            label=label,
-            **kwargs)
-
-
-class PipelineSummarizer(MultiSummarizer):
-    def __init__(self, instance, **kwargs):
-        children = collections.OrderedDict()
-        for cname, component in instance['components'].items():
-            if 'job' not in component:
-                logger.warning(
-                    "%s: skipping component with no job assigned", cname)
-            else:
-                logger.info(
-                    "%s: job %s", cname, component['job']['uuid'])
-                summarizer = JobTreeSummarizer(component['job'], label=cname, **kwargs)
-                summarizer.label = '{} {}'.format(
-                    cname, component['job']['uuid'])
-                children[cname] = summarizer
-        super(PipelineSummarizer, self).__init__(
-            children=children,
-            label=instance['uuid'],
-            **kwargs)
+        return WEBCHART_CLASS(label, iter(self._descendants().values())).html(tophtml, bottomhtml)
 
 
 class ContainerRequestTreeSummarizer(MultiSummarizer):
     def __init__(self, root, skip_child_jobs=False, **kwargs):
-        arv = arvados.api('v1', model=OrderedJsonModel())
+        arv = kwargs.get("arv") or arvados.api('v1')
 
         label = kwargs.pop('label', None) or root.get('name') or root['uuid']
         root['name'] = label
@@ -678,22 +704,15 @@ class ContainerRequestTreeSummarizer(MultiSummarizer):
             summer.sort_key = sort_key
             children[current['uuid']] = summer
 
-            page_filters = []
-            while True:
-                child_crs = arv.container_requests().index(
-                    order=['uuid asc'],
-                    filters=page_filters+[
-                        ['requesting_container_uuid', '=', current['container_uuid']]],
-                ).execute()
-                if not child_crs['items']:
-                    break
-                elif skip_child_jobs:
-                    logger.warning('%s: omitting stats from %d child containers'
-                                   ' because --skip-child-jobs flag is on',
-                                   label, child_crs['items_available'])
-                    break
-                page_filters = [['uuid', '>', child_crs['items'][-1]['uuid']]]
-                for cr in child_crs['items']:
+            if skip_child_jobs:
+                child_crs = arv.container_requests().list(filters=[['requesting_container_uuid', '=', current['container_uuid']]],
+                                                          limit=0).execute()
+                logger.warning('%s: omitting stats from child containers'
+                               ' because --skip-child-jobs flag is on',
+                               label, child_crs['items_available'])
+            else:
+                for cr in arvados.util.keyset_list_all(arv.container_requests().list,
+                                                       filters=[['requesting_container_uuid', '=', current['container_uuid']]]):
                     if cr['container_uuid']:
                         logger.debug('%s: container req %s', current['uuid'], cr['uuid'])
                         cr['name'] = cr.get('name') or cr['uuid']
index 31afcf64e906166788bf06b9caa4ed191ead13c9..f959661246f0dffc55cef06bbece384978f3b86a 100644 (file)
@@ -20,19 +20,91 @@ class WebChart(object):
     JSLIB = None
     JSASSET = None
 
+    STYLE = '''
+        body {
+          background: #fafafa;
+          font-family: "Roboto", "Helvetica", "Arial", sans-serif;
+          font-size: 0.875rem;
+          color: rgba(0, 0, 0, 0.87);
+          font-weight: 400;
+        }
+        .card {
+          background: #ffffff;
+          box-shadow: 0px 1px 5px 0px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 3px 1px -2px rgba(0,0,0,0.12);
+          border-radius: 4px;
+          margin: 20px;
+        }
+        .content {
+          padding: 2px 16px 8px 16px;
+        }
+        table {
+          border-spacing: 0px;
+        }
+        tr {
+          height: 36px;
+          text-align: left;
+        }
+        th {
+          padding-right: 4em;
+          border-top: 1px solid rgba(224, 224, 224, 1);
+        }
+        td {
+          padding-right: 2em;
+          border-top: 1px solid rgba(224, 224, 224, 1);
+        }
+        #chart {
+          margin-left: -20px;
+        }
+    '''
+
     def __init__(self, label, summarizers):
         self.label = label
         self.summarizers = summarizers
 
-    def html(self):
+    def html(self, beforechart='', afterchart=''):
         return '''<!doctype html><html><head>
         <title>{} stats</title>
         <script type="text/javascript" src="{}"></script>
         <script type="text/javascript">{}</script>
+        <style>
+        {}
+        </style>
         {}
-        </head><body></body></html>
+        </head>
+        <body>
+        <div class="card">
+          <div class="content">
+            <h1>{}</h1>
+          </div>
+        </div>
+        <div class="card">
+          <div class="content" id="tophtml">
+          <h2>Summary</h2>
+          {}
+          </div>
+        </div>
+        <div class="card">
+          <div class="content">
+            <h2>Graph</h2>
+            <div id="chart"></div>
+          </div>
+        </div>
+        <div class="card">
+          <div class="content" id="bottomhtml">
+          <h2>Metrics</h2>
+          {}
+          </div>
+        </div>
+        </body>
+        </html>
         '''.format(escape(self.label),
-                   self.JSLIB, self.js(), self.headHTML())
+                   self.JSLIB,
+                   self.js(),
+                   self.STYLE,
+                   self.headHTML(),
+                   escape(self.label),
+                   beforechart,
+                   afterchart)
 
     def js(self):
         return 'var chartdata = {};\n{}'.format(
index a881390e47e893fb1e22eb46926716f58f2c7c9e..24a6bf5e4f9155ddf446738b7f4a157c62273213 100755 (executable)
@@ -10,21 +10,10 @@ import re
 
 from setuptools import setup, find_packages
 
-SETUP_DIR = os.path.dirname(__file__) or '.'
-README = os.path.join(SETUP_DIR, 'README.rst')
-
 import arvados_version
-version = arvados_version.get_version(SETUP_DIR, "crunchstat_summary")
-if os.environ.get('ARVADOS_BUILDING_VERSION', False):
-    pysdk_dep = "=={}".format(version)
-else:
-    # On dev releases, arvados-python-client may have a different timestamp
-    pysdk_dep = "<={}".format(version)
-
-short_tests_only = False
-if '--short-tests-only' in sys.argv:
-    short_tests_only = True
-    sys.argv.remove('--short-tests-only')
+version = arvados_version.get_version()
+short_tests_only = arvados_version.short_tests_only()
+README = os.path.join(arvados_version.SETUP_DIR, 'README.rst')
 
 setup(name='crunchstat_summary',
       version=version,
@@ -43,8 +32,9 @@ setup(name='crunchstat_summary',
           ('share/doc/crunchstat_summary', ['agpl-3.0.txt']),
       ],
       install_requires=[
-          'arvados-python-client{}'.format(pysdk_dep),
+          *arvados_version.iter_dependencies(version),
       ],
+      python_requires="~=3.8",
       test_suite='tests',
       tests_require=['pbr<1.7.0', 'mock>=1.0'],
       zip_safe=False,
index 5152e577f5c5a17f3ef57b0c644592f5de14fcb6..e00faafb00f272738605b2b09201dfb61efc09ca 100644 (file)
@@ -1,7 +1,7 @@
 category       metric  task_max        task_max_rate   job_total
 blkio:0:0      read    0       0       0
 blkio:0:0      write   0       0       0
-cpu    cpus    20      -       -
+cpu    cpus    20.00   -       -
 cpu    sys     0.39    0.04    0.39
 cpu    user    2.06    0.20    2.06
 cpu    user+sys        2.45    0.24    2.45
@@ -25,15 +25,14 @@ statfs      available       397744787456    -       397744787456
 statfs total   402611240960    -       402611240960
 statfs used    4870303744      52426.18        4866453504
 time   elapsed 20      -       20
-# Number of tasks: 1
-# Max CPU time spent by a single task: 2.45s
+# Elapsed time: 20s
 # Max CPU usage in a single interval: 23.70%
 # Overall CPU usage: 12.25%
-# Max memory used by a single task: 0.07GB
-# Max network traffic in a single task: 0.00GB
+# Requested CPU cores: 1
+# Max memory used: 66.30MB
+# Requested RAM: 2500.00MB
+# Max network traffic: 0.00GB
 # Max network speed in a single interval: 0.00MB/s
-# Keep cache miss rate 0.00%
-# Keep cache utilization 0.00%
-# Temp disk utilization 1.21%
-#!! container max RSS was 67 MiB -- try reducing runtime_constraints to "ram":1020054732
-#!! container max temp disk utilization was 1% of 383960 MiB -- consider reducing "tmpdirMin" and/or "outdirMin"
+# Keep cache miss rate: 0.00%
+# Keep cache utilization: 0.00%
+# Temp disk utilization: 1.21%
index f77059b82496f5825d9d634847a2b0537efaed72..6afdf9aa69d756c6edc2352b2dc37b8d997ac3c4 100644 (file)
@@ -11,13 +11,12 @@ net:keep0   rx      0       0       0
 net:keep0      tx      0       0       0
 net:keep0      tx+rx   0       0       0
 time   elapsed 10      -       10
-# Number of tasks: 1
-# Max CPU time spent by a single task: 0s
+# Elapsed time: 10s
 # Max CPU usage in a single interval: 0%
 # Overall CPU usage: 0.00%
-# Max memory used by a single task: 0.00GB
-# Max network traffic in a single task: 0.00GB
+# Max memory used: 0.00MB
+# Max network traffic: 0.00GB
 # Max network speed in a single interval: 0.00MB/s
-# Keep cache miss rate 0.00%
-# Keep cache utilization 0.00%
-# Temp disk utilization 0.00%
+# Keep cache miss rate: 0.00%
+# Keep cache utilization: 0.00%
+# Temp disk utilization: 0.00%
index fc01ce9a8f124e2fe3d88ef20394e966700b2326..680af69470362595a9dd9e4bb6b75bcc04b0494a 100644 (file)
Binary files a/tools/crunchstat-summary/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-crunchstat.txt.gz and b/tools/crunchstat-summary/tests/container_request_9tee4-xvhdp-kk0ja1cl8b2kr1y-crunchstat.txt.gz differ
index b17c7005936cee279c69537cb94251845250d9cf..fa1ad04e7b5171adf3e96d6248a7c29d506eea7b 100644 (file)
@@ -1,5 +1,5 @@
 category       metric  task_max        task_max_rate   job_total
-cpu    cpus    20      -       -
+cpu    cpus    20.00   -       -
 cpu    sys     0.39    0.04    0.39
 cpu    user    2.06    0.20    2.06
 cpu    user+sys        2.45    0.24    2.45
@@ -14,15 +14,12 @@ statfs      available       397744787456    -       397744787456
 statfs total   402611240960    -       402611240960
 statfs used    4870303744      52426.18        4866453504
 time   elapsed 20      -       20
-# Number of tasks: 1
-# Max CPU time spent by a single task: 2.45s
+# Elapsed time: 20s
 # Max CPU usage in a single interval: 23.70%
 # Overall CPU usage: 12.25%
-# Max memory used by a single task: 0.07GB
-# Max network traffic in a single task: 0.00GB
+# Max memory used: 66.30MB
+# Max network traffic: 0.00GB
 # Max network speed in a single interval: 0.00MB/s
-# Keep cache miss rate 0.00%
-# Keep cache utilization 0.00%
-# Temp disk utilization 1.21%
-#!! label #1 max RSS was 67 MiB -- try reducing runtime_constraints to "ram":1020054732
-#!! label #1 max temp disk utilization was 1% of 383960 MiB -- consider reducing "tmpdirMin" and/or "outdirMin"
+# Keep cache miss rate: 0.00%
+# Keep cache utilization: 0.00%
+# Temp disk utilization: 1.21%
index 5152e577f5c5a17f3ef57b0c644592f5de14fcb6..e00faafb00f272738605b2b09201dfb61efc09ca 100644 (file)
@@ -1,7 +1,7 @@
 category       metric  task_max        task_max_rate   job_total
 blkio:0:0      read    0       0       0
 blkio:0:0      write   0       0       0
-cpu    cpus    20      -       -
+cpu    cpus    20.00   -       -
 cpu    sys     0.39    0.04    0.39
 cpu    user    2.06    0.20    2.06
 cpu    user+sys        2.45    0.24    2.45
@@ -25,15 +25,14 @@ statfs      available       397744787456    -       397744787456
 statfs total   402611240960    -       402611240960
 statfs used    4870303744      52426.18        4866453504
 time   elapsed 20      -       20
-# Number of tasks: 1
-# Max CPU time spent by a single task: 2.45s
+# Elapsed time: 20s
 # Max CPU usage in a single interval: 23.70%
 # Overall CPU usage: 12.25%
-# Max memory used by a single task: 0.07GB
-# Max network traffic in a single task: 0.00GB
+# Requested CPU cores: 1
+# Max memory used: 66.30MB
+# Requested RAM: 2500.00MB
+# Max network traffic: 0.00GB
 # Max network speed in a single interval: 0.00MB/s
-# Keep cache miss rate 0.00%
-# Keep cache utilization 0.00%
-# Temp disk utilization 1.21%
-#!! container max RSS was 67 MiB -- try reducing runtime_constraints to "ram":1020054732
-#!! container max temp disk utilization was 1% of 383960 MiB -- consider reducing "tmpdirMin" and/or "outdirMin"
+# Keep cache miss rate: 0.00%
+# Keep cache utilization: 0.00%
+# Temp disk utilization: 1.21%
index bf6dd5ceaff9a0e689e9caa7afb6009c724261a5..2b93639281c8a659358f074a4c84f281841bfe12 100644 (file)
@@ -1,9 +1,9 @@
-2016-01-07_00:15:33 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr 
+2016-01-07_00:15:33 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr
 2016-01-07_00:15:33 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr old error message:
 2016-01-07_00:15:33 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr crunchstat: read /proc/3305/net/dev: open /proc/3305/net/dev: no such file or directory
-2016-01-07_00:15:34 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr 
+2016-01-07_00:15:34 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr
 2016-01-07_00:15:34 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr new error message:
 2016-01-07_00:15:34 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr crunchstat: error reading /proc/3305/net/dev: open /proc/3305/net/dev: no such file or directory
 2016-01-07_00:15:34 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr
 2016-01-07_00:15:34 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr cancelled job:
-2016-01-07_00:15:34 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr crunchstat: caught signal: interrupt
+2016-01-07_00:15:59 tb05z-8i9sb-khsk5rmf4xjdcbl 20819 0 stderr crunchstat: caught signal: interrupt
diff --git a/tools/crunchstat-summary/tests/logfile_20151204190335.txt.gz b/tools/crunchstat-summary/tests/logfile_20151204190335.txt.gz
deleted file mode 100644 (file)
index 0042cc5..0000000
Binary files a/tools/crunchstat-summary/tests/logfile_20151204190335.txt.gz and /dev/null differ
diff --git a/tools/crunchstat-summary/tests/logfile_20151204190335.txt.gz.report b/tools/crunchstat-summary/tests/logfile_20151204190335.txt.gz.report
deleted file mode 100644 (file)
index 1fb56c7..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-category       metric  task_max        task_max_rate   job_total
-blkio:0:0      read    0       0       0
-blkio:0:0      write   0       0       0
-cpu    cpus    8       -       -
-cpu    sys     1.92    0.04    1.92
-cpu    user    3.83    0.09    3.83
-cpu    user+sys        5.75    0.13    5.75
-fuseops        read    0       0       0
-fuseops        write   0       0       0
-keepcache      hit     0       0       0
-keepcache      miss    0       0       0
-keepcalls      get     0       0       0
-keepcalls      put     0       0       0
-mem    cache   1678139392      -       -
-mem    pgmajfault      0       -       0
-mem    rss     349814784       -       -
-mem    swap    0       -       -
-net:eth0       rx      1754364530      41658344.87     1754364530
-net:eth0       tx      38837956        920817.97       38837956
-net:eth0       tx+rx   1793202486      42579162.83     1793202486
-net:keep0      rx      0       0       0
-net:keep0      tx      0       0       0
-net:keep0      tx+rx   0       0       0
-time   elapsed 80      -       80
-# Number of tasks: 1
-# Max CPU time spent by a single task: 5.75s
-# Max CPU usage in a single interval: 13.00%
-# Overall CPU usage: 7.19%
-# Max memory used by a single task: 0.35GB
-# Max network traffic in a single task: 1.79GB
-# Max network speed in a single interval: 42.58MB/s
-# Keep cache miss rate 0.00%
-# Keep cache utilization 0.00%
-# Temp disk utilization 0.00%
-#!! 4xphq-8i9sb-jq0ekny1xou3zoh max RSS was 334 MiB -- try reducing runtime_constraints to "min_ram_mb_per_node":972
diff --git a/tools/crunchstat-summary/tests/logfile_20151210063411.txt.gz b/tools/crunchstat-summary/tests/logfile_20151210063411.txt.gz
deleted file mode 100644 (file)
index 78afb98..0000000
Binary files a/tools/crunchstat-summary/tests/logfile_20151210063411.txt.gz and /dev/null differ
diff --git a/tools/crunchstat-summary/tests/logfile_20151210063411.txt.gz.report b/tools/crunchstat-summary/tests/logfile_20151210063411.txt.gz.report
deleted file mode 100644 (file)
index f567233..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-category       metric  task_max        task_max_rate   job_total
-cpu    cpus    8       -       -
-cpu    sys     0       -       0.00
-cpu    user    0       -       0.00
-cpu    user+sys        0       -       0.00
-mem    cache   12288   -       -
-mem    pgmajfault      0       -       0
-mem    rss     856064  -       -
-mem    swap    0       -       -
-net:eth0       rx      90      -       90
-net:eth0       tx      90      -       90
-net:eth0       tx+rx   180     -       180
-time   elapsed 2       -       4
-# Number of tasks: 2
-# Max CPU time spent by a single task: 0s
-# Max CPU usage in a single interval: 0%
-# Overall CPU usage: 0.00%
-# Max memory used by a single task: 0.00GB
-# Max network traffic in a single task: 0.00GB
-# Max network speed in a single interval: 0.00MB/s
-# Keep cache miss rate 0.00%
-# Keep cache utilization 0.00%
-# Temp disk utilization 0.00%
-#!! 4xphq-8i9sb-zvb2ocfycpomrup max RSS was 1 MiB -- try reducing runtime_constraints to "min_ram_mb_per_node":972
diff --git a/tools/crunchstat-summary/tests/logfile_20151210063439.txt.gz b/tools/crunchstat-summary/tests/logfile_20151210063439.txt.gz
deleted file mode 100644 (file)
index 49018f7..0000000
Binary files a/tools/crunchstat-summary/tests/logfile_20151210063439.txt.gz and /dev/null differ
diff --git a/tools/crunchstat-summary/tests/logfile_20151210063439.txt.gz.report b/tools/crunchstat-summary/tests/logfile_20151210063439.txt.gz.report
deleted file mode 100644 (file)
index ab0febb..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-category       metric  task_max        task_max_rate   job_total
-cpu    cpus    8       -       -
-cpu    sys     0       -       0.00
-cpu    user    0       -       0.00
-cpu    user+sys        0       -       0.00
-mem    cache   8192    -       -
-mem    pgmajfault      0       -       0
-mem    rss     450560  -       -
-mem    swap    0       -       -
-net:eth0       rx      90      -       90
-net:eth0       tx      90      -       90
-net:eth0       tx+rx   180     -       180
-time   elapsed 2       -       3
-# Number of tasks: 2
-# Max CPU time spent by a single task: 0s
-# Max CPU usage in a single interval: 0%
-# Overall CPU usage: 0.00%
-# Max memory used by a single task: 0.00GB
-# Max network traffic in a single task: 0.00GB
-# Max network speed in a single interval: 0.00MB/s
-# Keep cache miss rate 0.00%
-# Keep cache utilization 0.00%
-# Temp disk utilization 0.00%
-#!! 4xphq-8i9sb-v831jm2uq0g2g9x max RSS was 1 MiB -- try reducing runtime_constraints to "min_ram_mb_per_node":972
index fb23eab39e9072f9b44ac5e3b766d25c524e5668..5a20d3283f813341cc47e51b5e46231dc92b6829 100644 (file)
@@ -8,21 +8,32 @@ import crunchstat_summary.command
 import difflib
 import glob
 import gzip
-from io import open
+import io
+import logging
 import mock
 import os
 import sys
 import unittest
 
 from crunchstat_summary.command import UTF8Decode
+from crunchstat_summary import logger, reader
 
 TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
 
 
-class ReportDiff(unittest.TestCase):
+class TestCase(unittest.TestCase):
+    def setUp(self):
+        self.logbuf = io.StringIO()
+        self.loghandler = logging.StreamHandler(stream=self.logbuf)
+        logger.addHandler(self.loghandler)
+        logger.setLevel(logging.WARNING)
+
+    def tearDown(self):
+        logger.removeHandler(self.loghandler)
+
     def diff_known_report(self, logfile, cmd):
         expectfile = logfile+'.report'
-        with open(expectfile, encoding='utf-8') as f:
+        with io.open(expectfile, encoding='utf-8') as f:
             expect = f.readlines()
         self.diff_report(cmd, expect, expectfile=expectfile)
 
@@ -32,7 +43,7 @@ class ReportDiff(unittest.TestCase):
             expect, got, fromfile=expectfile, tofile="(generated)")))
 
 
-class SummarizeFile(ReportDiff):
+class SummarizeFile(TestCase):
     def test_example_files(self):
         for fnm in glob.glob(os.path.join(TESTS_DIR, '*.txt.gz')):
             logfile = os.path.join(TESTS_DIR, fnm)
@@ -43,7 +54,7 @@ class SummarizeFile(ReportDiff):
             self.diff_known_report(logfile, cmd)
 
 
-class HTMLFromFile(ReportDiff):
+class HTMLFromFile(TestCase):
     def test_example_files(self):
         # Note we don't test the output content at all yet; we're
         # mainly just verifying the --format=html option isn't ignored
@@ -54,20 +65,20 @@ class HTMLFromFile(ReportDiff):
                 ['--format=html', '--log-file', logfile])
             cmd = crunchstat_summary.command.Command(args)
             cmd.run()
-            if sys.version_info >= (3,2):
-                self.assertRegex(cmd.report(), r'(?is)<html>.*</html>\s*$')
-            else:
-                self.assertRegexpMatches(cmd.report(), r'(?is)<html>.*</html>\s*$')
+            self.assertRegex(cmd.report(), r'(?is)<html>.*</html>\s*$')
 
 
-class SummarizeEdgeCases(unittest.TestCase):
+class SummarizeEdgeCases(TestCase):
     def test_error_messages(self):
-        logfile = open(os.path.join(TESTS_DIR, 'crunchstat_error_messages.txt'), encoding='utf-8')
-        s = crunchstat_summary.summarizer.Summarizer(logfile)
+        logfile = io.open(os.path.join(TESTS_DIR, 'crunchstat_error_messages.txt'), encoding='utf-8')
+        s = crunchstat_summary.summarizer.Summarizer(reader.StubReader(logfile))
         s.run()
+        self.assertRegex(self.logbuf.getvalue(), r'CPU stats are missing -- possible cluster configuration issue')
+        self.assertRegex(self.logbuf.getvalue(), r'memory stats are missing -- possible cluster configuration issue')
+        self.assertRegex(self.logbuf.getvalue(), r'network I/O stats are missing -- possible cluster configuration issue')
+        self.assertRegex(self.logbuf.getvalue(), r'storage space stats are missing -- possible cluster configuration issue')
 
-
-class SummarizeContainerCommon(ReportDiff):
+class SummarizeContainerCommon(TestCase):
     fake_container = {
         'uuid': '9tee4-dz642-lymtndkpy39eibk',
         'created_at': '2017-08-18T14:27:25.371388141',
@@ -94,20 +105,19 @@ class SummarizeContainerCommon(ReportDiff):
     @mock.patch('arvados.api')
     def check_common(self, mock_api, mock_cr):
         items = [ {'items':[self.fake_request]}] + [{'items':[]}] * 100
-        # Index and list mean the same thing, but are used in different places in the
-        # code. It's fragile, but exploit that fact to distinguish the two uses.
-        mock_api().container_requests().index().execute.return_value = {'items': [] }  # child_crs
         mock_api().container_requests().list().execute.side_effect = items # parent request
         mock_api().container_requests().get().execute.return_value = self.fake_request
         mock_api().containers().get().execute.return_value = self.fake_container
         mock_cr().__iter__.return_value = [
             'crunch-run.txt', 'stderr.txt', 'node-info.txt',
             'container.json', 'crunchstat.txt', 'arv-mount.txt']
-        def _open(n):
+        def _open(n, mode):
             if n == "crunchstat.txt":
                 return UTF8Decode(gzip.open(self.logfile))
             elif n == "arv-mount.txt":
                 return UTF8Decode(gzip.open(self.arvmountlog))
+            elif n == "node.json":
+                return io.StringIO("{}")
         mock_cr().open.side_effect = _open
         args = crunchstat_summary.command.ArgumentParser().parse_args(
             self.arg_strings)
@@ -133,184 +143,5 @@ class SummarizeContainerRequest(SummarizeContainerCommon):
 
     def test_container_request(self):
         self.check_common()
-
-
-class SummarizeJob(ReportDiff):
-    fake_job_uuid = '4xphq-8i9sb-jq0ekny1xou3zoh'
-    fake_log_id = 'fake-log-collection-id'
-    fake_job = {
-        'uuid': fake_job_uuid,
-        'log': fake_log_id,
-    }
-    logfile = os.path.join(TESTS_DIR, 'logfile_20151204190335.txt.gz')
-
-    @mock.patch('arvados.collection.CollectionReader')
-    @mock.patch('arvados.api')
-    def test_job_report(self, mock_api, mock_cr):
-        mock_api().jobs().get().execute.return_value = self.fake_job
-        mock_cr().__iter__.return_value = ['fake-logfile.txt']
-        mock_cr().open.return_value = UTF8Decode(gzip.open(self.logfile))
-        args = crunchstat_summary.command.ArgumentParser().parse_args(
-            ['--job', self.fake_job_uuid])
-        cmd = crunchstat_summary.command.Command(args)
-        cmd.run()
-        self.diff_known_report(self.logfile, cmd)
-        mock_api().jobs().get.assert_called_with(uuid=self.fake_job_uuid)
-        mock_cr.assert_called_with(self.fake_log_id)
-        mock_cr().open.assert_called_with('fake-logfile.txt')
-
-
-class SummarizePipeline(ReportDiff):
-    fake_instance = {
-        'uuid': 'zzzzz-d1hrv-i3e77t9z5y8j9cc',
-        'owner_uuid': 'zzzzz-tpzed-xurymjxw79nv3jz',
-        'components': collections.OrderedDict([
-            ['foo', {
-                'job': {
-                    'uuid': 'zzzzz-8i9sb-000000000000000',
-                    'log': 'fake-log-pdh-0',
-                    'runtime_constraints': {
-                        'min_ram_mb_per_node': 900,
-                        'min_cores_per_node': 1,
-                    },
-                },
-            }],
-            ['bar', {
-                'job': {
-                    'uuid': 'zzzzz-8i9sb-000000000000001',
-                    'log': 'fake-log-pdh-1',
-                    'runtime_constraints': {
-                        'min_ram_mb_per_node': 900,
-                        'min_cores_per_node': 1,
-                    },
-                },
-            }],
-            ['no-job-assigned', {}],
-            ['unfinished-job', {
-                'job': {
-                    'uuid': 'zzzzz-8i9sb-xxxxxxxxxxxxxxx',
-                },
-            }],
-            ['baz', {
-                'job': {
-                    'uuid': 'zzzzz-8i9sb-000000000000002',
-                    'log': 'fake-log-pdh-2',
-                    'runtime_constraints': {
-                        'min_ram_mb_per_node': 900,
-                        'min_cores_per_node': 1,
-                    },
-                },
-            }]]),
-    }
-
-    @mock.patch('arvados.collection.CollectionReader')
-    @mock.patch('arvados.api')
-    def test_pipeline(self, mock_api, mock_cr):
-        logfile = os.path.join(TESTS_DIR, 'logfile_20151204190335.txt.gz')
-        mock_api().pipeline_instances().get().execute. \
-            return_value = self.fake_instance
-        mock_cr().__iter__.return_value = ['fake-logfile.txt']
-        mock_cr().open.side_effect = [UTF8Decode(gzip.open(logfile)) for _ in range(3)]
-        args = crunchstat_summary.command.ArgumentParser().parse_args(
-            ['--pipeline-instance', self.fake_instance['uuid']])
-        cmd = crunchstat_summary.command.Command(args)
-        cmd.run()
-
-        with open(logfile+'.report', encoding='utf-8') as f:
-            job_report = [line for line in f if not line.startswith('#!! ')]
-        expect = (
-            ['### Summary for foo (zzzzz-8i9sb-000000000000000)\n'] +
-            job_report + ['\n'] +
-            ['### Summary for bar (zzzzz-8i9sb-000000000000001)\n'] +
-            job_report + ['\n'] +
-            ['### Summary for unfinished-job (partial) (zzzzz-8i9sb-xxxxxxxxxxxxxxx)\n',
-             '(no report generated)\n',
-             '\n'] +
-            ['### Summary for baz (zzzzz-8i9sb-000000000000002)\n'] +
-            job_report)
-        self.diff_report(cmd, expect)
-        mock_cr.assert_has_calls(
-            [
-                mock.call('fake-log-pdh-0'),
-                mock.call('fake-log-pdh-1'),
-                mock.call('fake-log-pdh-2'),
-            ], any_order=True)
-        mock_cr().open.assert_called_with('fake-logfile.txt')
-
-
-class SummarizeACRJob(ReportDiff):
-    fake_job = {
-        'uuid': 'zzzzz-8i9sb-i3e77t9z5y8j9cc',
-        'owner_uuid': 'zzzzz-tpzed-xurymjxw79nv3jz',
-        'components': {
-            'foo': 'zzzzz-8i9sb-000000000000000',
-            'bar': 'zzzzz-8i9sb-000000000000001',
-            'unfinished-job': 'zzzzz-8i9sb-xxxxxxxxxxxxxxx',
-            'baz': 'zzzzz-8i9sb-000000000000002',
-        }
-    }
-    fake_jobs_index = { 'items': [
-        {
-            'uuid': 'zzzzz-8i9sb-000000000000000',
-            'log': 'fake-log-pdh-0',
-            'runtime_constraints': {
-                'min_ram_mb_per_node': 900,
-                'min_cores_per_node': 1,
-            },
-        },
-        {
-            'uuid': 'zzzzz-8i9sb-000000000000001',
-            'log': 'fake-log-pdh-1',
-            'runtime_constraints': {
-                'min_ram_mb_per_node': 900,
-                'min_cores_per_node': 1,
-            },
-        },
-        {
-            'uuid': 'zzzzz-8i9sb-xxxxxxxxxxxxxxx',
-        },
-        {
-            'uuid': 'zzzzz-8i9sb-000000000000002',
-            'log': 'fake-log-pdh-2',
-            'runtime_constraints': {
-                'min_ram_mb_per_node': 900,
-                'min_cores_per_node': 1,
-            },
-        },
-    ]}
-    @mock.patch('arvados.collection.CollectionReader')
-    @mock.patch('arvados.api')
-    def test_acr_job(self, mock_api, mock_cr):
-        logfile = os.path.join(TESTS_DIR, 'logfile_20151204190335.txt.gz')
-        mock_api().jobs().index().execute.return_value = self.fake_jobs_index
-        mock_api().jobs().get().execute.return_value = self.fake_job
-        mock_cr().__iter__.return_value = ['fake-logfile.txt']
-        mock_cr().open.side_effect = [UTF8Decode(gzip.open(logfile)) for _ in range(3)]
-        args = crunchstat_summary.command.ArgumentParser().parse_args(
-            ['--job', self.fake_job['uuid']])
-        cmd = crunchstat_summary.command.Command(args)
-        cmd.run()
-
-        with open(logfile+'.report', encoding='utf-8') as f:
-            job_report = [line for line in f if not line.startswith('#!! ')]
-        expect = (
-            ['### Summary for zzzzz-8i9sb-i3e77t9z5y8j9cc (partial) (zzzzz-8i9sb-i3e77t9z5y8j9cc)\n',
-             '(no report generated)\n',
-             '\n'] +
-            ['### Summary for bar (zzzzz-8i9sb-000000000000001)\n'] +
-            job_report + ['\n'] +
-            ['### Summary for baz (zzzzz-8i9sb-000000000000002)\n'] +
-            job_report + ['\n'] +
-            ['### Summary for foo (zzzzz-8i9sb-000000000000000)\n'] +
-            job_report + ['\n'] +
-            ['### Summary for unfinished-job (partial) (zzzzz-8i9sb-xxxxxxxxxxxxxxx)\n',
-             '(no report generated)\n']
-        )
-        self.diff_report(cmd, expect)
-        mock_cr.assert_has_calls(
-            [
-                mock.call('fake-log-pdh-0'),
-                mock.call('fake-log-pdh-1'),
-                mock.call('fake-log-pdh-2'),
-            ], any_order=True)
-        mock_cr().open.assert_called_with('fake-logfile.txt')
+        self.assertNotRegex(self.logbuf.getvalue(), r'stats are missing')
+        self.assertNotRegex(self.logbuf.getvalue(), r'possible cluster configuration issue')
index 4dcb47a8da02e3eea9edddf5e612dff660076147..5bd7136eaa8d060d4d78a83a492917258b887e4e 100644 (file)
@@ -48,6 +48,7 @@ func (s *ServerRequiredSuite) TearDownSuite(c *C) {
 }
 
 func (s *ServerRequiredSuite) SetUpTest(c *C) {
+       logBuffer.Reset()
        logOutput := io.MultiWriter(&logBuffer)
        log.SetOutput(logOutput)
 }
@@ -55,7 +56,7 @@ func (s *ServerRequiredSuite) SetUpTest(c *C) {
 func (s *ServerRequiredSuite) TearDownTest(c *C) {
        arvadostest.StopKeep(2)
        log.SetOutput(os.Stdout)
-       log.Printf("%v", logBuffer.String())
+       c.Log(logBuffer.String())
 }
 
 func (s *DoMainTestSuite) SetUpSuite(c *C) {
@@ -226,7 +227,9 @@ func (s *ServerRequiredSuite) TestBlockCheck_BadSignature(c *C) {
        setupTestData(c)
        err := performKeepBlockCheck(kc, blobSignatureTTL, "badblobsigningkey", []string{TestHash, TestHash2}, false)
        c.Assert(err.Error(), Equals, "Block verification failed for 2 out of 2 blocks with matching prefix")
-       checkErrorLog(c, []string{TestHash, TestHash2}, "Error verifying block", "HTTP 403")
+       // older versions of keepstore return 403 Forbidden for
+       // invalid signatures, newer versions return 400 Bad Request.
+       checkErrorLog(c, []string{TestHash, TestHash2}, "Error verifying block", "HTTP 40[03]")
        // verbose logging not requested
        c.Assert(strings.Contains(logBuffer.String(), "Verifying block 1 of 2"), Equals, false)
 }
index 1acd8d8b98a07c1785702820277076740ae15565..6d06a18322e53e77509e3cfe7629143f39831170 100644 (file)
@@ -15,7 +15,6 @@
 // fill your storage volumes with random data if you leave it running,
 // which can cost you money or leave you with too little room for
 // useful data.
-//
 package main
 
 import (
index 7e519f775ba9bb4d500e578c891a55d50f1fae34..43c6be08225a806a6e72195f0eab3cb1f83ebf02 100644 (file)
@@ -193,6 +193,7 @@ func setupKeepClient(config apiConfig, keepServicesJSON string, isDst bool, repl
                        return kc, 0, err
                }
        }
+       kc.DiskCacheSize = keepclient.DiskCacheDisabled
 
        if isDst {
                // Get default replications value from destination, if it is not already provided
index dc5b957125c731d13ae51969b7a47ec765ffcf33..1d2d6b5c1917115e39775bb3066273aa9de62106 100644 (file)
@@ -161,7 +161,7 @@ func testNoCrosstalk(c *C, testData string, kc1, kc2 *keepclient.KeepClient) {
        locator, _, err := kc1.PutB([]byte(testData))
        c.Assert(err, Equals, nil)
 
-       locator = strings.Split(locator, "+")[0]
+       locator = strings.Join(strings.Split(locator, "+")[:2], "+")
        _, _, _, err = kc2.Get(keepclient.SignLocator(locator, kc2.Arvados.ApiToken, time.Now().AddDate(0, 0, 1), blobSignatureTTL, []byte(blobSigningKey)))
        c.Assert(err, NotNil)
        c.Check(err.Error(), Equals, "Block not found")
@@ -330,7 +330,7 @@ func (s *ServerRequiredSuite) TestErrorDuringRsync_ErrorGettingBlockFromSrc(c *C
 
        err := performKeepRsync(kcSrc, kcDst, blobSignatureTTL, blobSigningKey, "")
        c.Assert(err, NotNil)
-       c.Check(err.Error(), Matches, ".*HTTP 403 \"Forbidden\".*")
+       c.Check(err.Error(), Matches, ".*HTTP 400 \"invalid signature\".*")
 }
 
 // Test rsync with error during Put to src.
diff --git a/tools/salt-install/common.sh b/tools/salt-install/common.sh
new file mode 100644 (file)
index 0000000..5392da7
--- /dev/null
@@ -0,0 +1,69 @@
+##########################################################
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: CC-BY-SA-3.0
+
+# This is generic logic used by provision.sh & installer.sh scripts
+
+if [[ -s ${CONFIG_FILE} && -s ${CONFIG_FILE}.secrets ]]; then
+  source ${CONFIG_FILE}.secrets
+  source ${CONFIG_FILE}
+else
+  echo >&2 "You don't seem to have a config file with initial values."
+  echo >&2 "Please create a '${CONFIG_FILE}' & '${CONFIG_FILE}.secrets' files as described in"
+  echo >&2 "  * https://doc.arvados.org/install/salt-single-host.html#single_host, or"
+  echo >&2 "  * https://doc.arvados.org/install/salt-multi-host.html#multi_host_multi_hostnames"
+  exit 1
+fi
+
+USE_SSH_JUMPHOST=${USE_SSH_JUMPHOST:-}
+DISABLED_CONTROLLER=""
+DATABASE_POSTGRESQL_DEFAULT_VERSION=15
+
+# Comma-separated list of nodes. This is used to dynamically adjust
+# salt pillars.
+NODELIST=""
+for node in "${!NODES[@]}"; do
+  if [ -z "$NODELIST" ]; then
+    NODELIST="$node"
+  else
+    NODELIST="$NODELIST,$node"
+  fi
+done
+
+# The mapping of roles to nodes. This is used to dynamically adjust
+# salt pillars.
+for node in "${!NODES[@]}"; do
+  roles="${NODES[$node]}"
+
+  # Split the comma-separated roles into an array
+  IFS=',' read -ra roles_array <<< "$roles"
+
+  for role in "${roles_array[@]}"; do
+    if [ -n "${ROLE2NODES[$role]:-}" ]; then
+      ROLE2NODES["$role"]="${ROLE2NODES[$role]},$node"
+    else
+      ROLE2NODES["$role"]=$node
+    fi
+  done
+done
+
+# Sets TLS certificate expiration thresholds
+TLS_EXPIRATION_YELLOW=5184000 # > 2 months
+TLS_EXPIRATION_GREEN=15552000 # > 6 months
+if [[ "${SSL_MODE}" == "lets-encrypt" ]]; then
+  TLS_EXPIRATION_YELLOW=1900800 # > 22 days
+  TLS_EXPIRATION_GREEN=2505600 # > 29 days
+fi
+
+# Auto-detects load-balancing mode
+if [ -z "${ROLE2NODES['balancer']:-}" ]; then
+  ENABLE_BALANCER="no"
+else
+  ENABLE_BALANCER="yes"
+fi
+
+# Auto-sets PG version if needed
+if [[ -n "${ROLE2NODES['database']:-}" || "${NODELIST}" == "localhost" ]]; then
+  DATABASE_POSTGRESQL_VERSION="${DATABASE_POSTGRESQL_VERSION:-${DATABASE_POSTGRESQL_DEFAULT_VERSION}}"
+fi
\ No newline at end of file
index dc9043217ed20bdef72c17546e4072cd485fef9b..3597fff5b07ce08e4bb93219b0af150fad395104 100644 (file)
@@ -5,14 +5,18 @@ Add the certificates for your hosts in this directory.
 
 The nodes requiring certificates are:
 
-* CLUSTER.DOMAIN
-* collections.CLUSTER.DOMAIN
-* \*.collections.CLUSTER.DOMAIN
-* download.CLUSTER.DOMAIN
-* keep.CLUSTER.DOMAIN
-* workbench.CLUSTER.DOMAIN
-* workbench2.CLUSTER.DOMAIN
-* ws.CLUSTER.DOMAIN
+* DOMAIN
+* collections.DOMAIN
+* controller.DOMAIN
+* \*.collections.DOMAIN
+* grafana.DOMAIN
+* download.DOMAIN
+* keep.DOMAIN
+* prometheus.DOMAIN
+* shell.DOMAIN
+* workbench.DOMAIN
+* workbench2.DOMAIN
+* ws.DOMAIN
 
 They can be individual certificates or a wildcard certificate for all of them.
 
diff --git a/tools/salt-install/config_examples/multi_host/aws/dashboards/arvados_overview.json b/tools/salt-install/config_examples/multi_host/aws/dashboards/arvados_overview.json
new file mode 100644 (file)
index 0000000..3b3fd83
--- /dev/null
@@ -0,0 +1,1871 @@
+{
+  "__inputs": [
+    {
+      "name": "DS_PROMETHEUS",
+      "label": "Prometheus",
+      "description": "",
+      "type": "datasource",
+      "pluginId": "prometheus",
+      "pluginName": "Prometheus"
+    }
+  ],
+  "__elements": {},
+  "__requires": [
+    {
+      "type": "grafana",
+      "id": "grafana",
+      "name": "Grafana",
+      "version": "10.2.0"
+    },
+    {
+      "type": "panel",
+      "id": "graph",
+      "name": "Graph (old)",
+      "version": ""
+    },
+    {
+      "type": "datasource",
+      "id": "prometheus",
+      "name": "Prometheus",
+      "version": "1.0.0"
+    },
+    {
+      "type": "panel",
+      "id": "table",
+      "name": "Table",
+      "version": ""
+    }
+  ],
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "target": {
+          "limit": 100,
+          "matchAny": false,
+          "tags": [],
+          "type": "dashboard"
+        },
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "fiscalYearStartMonth": 0,
+  "graphTooltip": 0,
+  "id": null,
+  "links": [],
+  "liveNow": false,
+  "panels": [
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "custom": {
+            "align": "center",
+            "cellOptions": {
+              "type": "auto"
+            },
+            "inspect": false
+          },
+          "decimals": 2,
+          "mappings": [],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              }
+            ]
+          },
+          "unit": "dtdurations"
+        },
+        "overrides": [
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Earliest SSL cert expiration"
+            },
+            "properties": [
+              {
+                "id": "thresholds",
+                "value": {
+                  "mode": "absolute",
+                  "steps": [
+                    {
+                      "color": "red",
+                      "value": null
+                    },
+                    {
+                      "color": "yellow",
+                      "value": __TLS_EXPIRATION_YELLOW__
+                    },
+                    {
+                      "color": "transparent",
+                      "value": __TLS_EXPIRATION_GREEN__
+                    }
+                  ]
+                }
+              },
+              {
+                "id": "custom.cellOptions",
+                "value": {
+                  "type": "color-background"
+                }
+              }
+            ]
+          }
+        ]
+      },
+      "gridPos": {
+        "h": 3,
+        "w": 24,
+        "x": 0,
+        "y": 0
+      },
+      "id": 35,
+      "links": [],
+      "options": {
+        "cellHeight": "sm",
+        "footer": {
+          "countRows": false,
+          "fields": "",
+          "reducer": [
+            "sum"
+          ],
+          "show": false
+        },
+        "showHeader": false
+      },
+      "pluginVersion": "10.2.0",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "editorMode": "code",
+          "exemplar": false,
+          "expr": "min(probe_ssl_earliest_cert_expiry)-time()",
+          "format": "time_series",
+          "instant": true,
+          "legendFormat": "__auto",
+          "range": false,
+          "refId": "A"
+        }
+      ],
+      "title": "Earliest SSL certificate expiration",
+      "transformations": [
+        {
+          "id": "organize",
+          "options": {
+            "excludeByName": {
+              "Time": true
+            },
+            "indexByName": {},
+            "renameByName": {
+              "Time": "",
+              "min(probe_ssl_earliest_cert_expiry)-time()": "Earliest SSL cert expiration"
+            }
+          }
+        }
+      ],
+      "type": "table"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 4,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 3
+      },
+      "hiddenSeries": false,
+      "id": 34,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "$$hashKey": "object:424",
+          "alias": "/out/",
+          "stack": "B",
+          "transform": "negative-Y"
+        }
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "sum(rate(arvados_keepstore_volume_io_bytes{}[1m])) without (operation,device_id)",
+          "interval": "",
+          "legendFormat": "{{ instance }} {{ direction }}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Keepstore bandwidth [1m]",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:159",
+          "format": "Bps",
+          "logBase": 1,
+          "show": true
+        },
+        {
+          "$$hashKey": "object:160",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 12,
+        "y": 3
+      },
+      "hiddenSeries": false,
+      "id": 14,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_dispatchcloud_containers_running{}",
+          "interval": "",
+          "legendFormat": "# containers",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Containers running",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:973",
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:974",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 8,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 11
+      },
+      "hiddenSeries": false,
+      "id": 8,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "sum(rate(arvados_keepstore_volume_operations{}[1m])) without (operation,device_id)",
+          "interval": "",
+          "legendFormat": "{{instance}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Keepstore volume operations rate/second",
+      "tooltip": {
+        "shared": true,
+        "sort": 2,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:982",
+          "format": "short",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:983",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 6,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 12,
+        "y": 11
+      },
+      "hiddenSeries": false,
+      "id": 12,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_dispatchcloud_queue_entries{}",
+          "interval": "",
+          "legendFormat": "{{instance_type}} {{state}}",
+          "refId": "A"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_dispatchcloud_containers_allocated_not_started{}",
+          "interval": "",
+          "legendFormat": "allocated, not started",
+          "refId": "B"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_dispatchcloud_containers_not_allocated_over_quota{}",
+          "interval": "",
+          "legendFormat": "not allocated, over quota",
+          "refId": "C"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Queue: # containers per {state, instance type}",
+      "tooltip": {
+        "shared": true,
+        "sort": 2,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:4306",
+          "format": "short",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:4307",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 8,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 19
+      },
+      "hiddenSeries": false,
+      "id": 10,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_keepstore_bufferpool_inuse_buffers{}",
+          "interval": "",
+          "legendFormat": "{{instance}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Keepstore buffers in use",
+      "tooltip": {
+        "shared": true,
+        "sort": 2,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:929",
+          "format": "short",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:930",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 12,
+        "y": 19
+      },
+      "hiddenSeries": false,
+      "id": 24,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_dispatchcloud_containers_longest_wait_time_seconds{}",
+          "interval": "",
+          "legendFormat": "Longest wait time",
+          "refId": "A"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "rate(arvados_dispatchcloud_containers_time_from_queue_to_crunch_run_seconds_sum{}[10m]) / rate(arvados_dispatchcloud_containers_time_from_queue_to_crunch_run_seconds_count{}[10m])",
+          "interval": "",
+          "legendFormat": "avg wait time [10m]",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Container wait times",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:138",
+          "format": "s",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:139",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 27
+      },
+      "hiddenSeries": false,
+      "id": 6,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_keep_total_bytes{}",
+          "interval": "",
+          "legendFormat": "Total stored",
+          "refId": "A"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_keep_overreplicated_bytes{}",
+          "interval": "",
+          "legendFormat": "Overreplicated",
+          "refId": "B"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_keep_underreplicated_bytes{}",
+          "interval": "",
+          "legendFormat": "Underreplicated",
+          "refId": "C"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_keep_lost_bytes{}",
+          "interval": "",
+          "legendFormat": "Lost",
+          "refId": "D"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Total bytes by type",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:304",
+          "decimals": 2,
+          "format": "decbytes",
+          "label": "",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:305",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": true,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 12,
+        "y": 27
+      },
+      "hiddenSeries": false,
+      "id": 22,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "rate(arvados_dispatchcloud_instances_time_to_ssh_seconds_sum{}[10m]) / rate(arvados_dispatchcloud_instances_time_to_ssh_seconds_count{}[10m])",
+          "hide": false,
+          "interval": "",
+          "legendFormat": "ssh",
+          "refId": "A"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "rate(arvados_dispatchcloud_instances_time_to_ready_for_container_seconds_sum{}[10m]) / rate(arvados_dispatchcloud_instances_time_to_ready_for_container_seconds_count{}[10m])",
+          "interval": "",
+          "legendFormat": "ready",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Instance time to ... avg [10m]",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:113",
+          "format": "s",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:114",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 35
+      },
+      "hiddenSeries": false,
+      "id": 32,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_concurrent_requests{}",
+          "interval": "",
+          "legendFormat": "{{instance}}_{{queue}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Concurrent requests",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:109",
+          "format": "short",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:110",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 12,
+        "y": 35
+      },
+      "hiddenSeries": false,
+      "id": 2,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_dispatchcloud_boot_outcomes{}",
+          "interval": "",
+          "legendFormat": "{{outcome}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Boot outcomes",
+      "tooltip": {
+        "shared": true,
+        "sort": 2,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:921",
+          "format": "short",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:922",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 43
+      },
+      "hiddenSeries": false,
+      "id": 16,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "sum(arvados_dispatchcloud_instances_price{})",
+          "interval": "",
+          "intervalFactor": 10,
+          "legendFormat": "cost ($)",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Cost",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:623",
+          "format": "short",
+          "label": "$ / hour",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:624",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 12,
+        "y": 43
+      },
+      "hiddenSeries": false,
+      "id": 4,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_dispatchcloud_instances_disappeared{}",
+          "interval": "",
+          "legendFormat": "{{state}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "instance state before disappearance",
+      "tooltip": {
+        "shared": true,
+        "sort": 2,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:1025",
+          "format": "short",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:1026",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 51
+      },
+      "hiddenSeries": false,
+      "id": 18,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_dispatchcloud_instances_price{}",
+          "interval": "",
+          "intervalFactor": 10,
+          "legendFormat": "{{category}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Cost by node state",
+      "tooltip": {
+        "shared": true,
+        "sort": 2,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:574",
+          "format": "short",
+          "label": "$ / hour",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:575",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 12,
+        "y": 51
+      },
+      "hiddenSeries": false,
+      "id": 26,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "rate(arvados_dispatchcloud_instances_time_from_shutdown_request_to_disappearance_seconds_sum{}[10m]) / rate(arvados_dispatchcloud_instances_time_from_shutdown_request_to_disappearance_seconds_count{}[10m])",
+          "interval": "",
+          "legendFormat": "shutdown to disappearance",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Instances time from shutdown to disappearance avg[10m]",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:450",
+          "format": "s",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:451",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 59
+      },
+      "hiddenSeries": false,
+      "id": 20,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "arvados_dispatchcloud_instances_total{}",
+          "instant": false,
+          "interval": "",
+          "legendFormat": "{{instance_type}} : {{category}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [
+        {
+          "$$hashKey": "object:540",
+          "colorMode": "critical",
+          "fill": true,
+          "line": true,
+          "op": "gt",
+          "yaxis": "left"
+        }
+      ],
+      "timeRegions": [],
+      "title": "Nodes by state",
+      "tooltip": {
+        "shared": true,
+        "sort": 2,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:723",
+          "format": "short",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:724",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 67
+      },
+      "hiddenSeries": false,
+      "id": 28,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null as zero",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "rate(arvados_dispatchcloud_instances_run_probe_duration_seconds_sum{}[10m]) / rate(arvados_dispatchcloud_instances_run_probe_duration_seconds_count{}[10m])",
+          "interval": "",
+          "legendFormat": "{{outcome}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "run probe duration avg[10m]",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:125",
+          "format": "s",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:126",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 75
+      },
+      "hiddenSeries": false,
+      "id": 30,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "10.2.0",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "delta(arvados_dispatchcloud_instances_run_probe_duration_seconds_count{}[1m])",
+          "instant": false,
+          "interval": "",
+          "legendFormat": "{{outcome}}",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "run probe count by outcome -- delta[1m]",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:149",
+          "format": "short",
+          "logBase": 10,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:150",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    }
+  ],
+  "refresh": "10s",
+  "revision": 1,
+  "schemaVersion": 38,
+  "tags": [],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-1h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ]
+  },
+  "timezone": "",
+  "title": "Arvados cluster overview",
+  "uid": "ArvadosClusterOverviewDashboard",
+  "version": 1,
+  "weekStart": ""
+}
\ No newline at end of file
diff --git a/tools/salt-install/config_examples/multi_host/aws/dashboards/node-exporter-full_rev30.json b/tools/salt-install/config_examples/multi_host/aws/dashboards/node-exporter-full_rev30.json
new file mode 100644 (file)
index 0000000..3b43496
--- /dev/null
@@ -0,0 +1,23200 @@
+{
+  "__inputs": [
+    {
+      "name": "DS_PROMETHEUS",
+      "label": "prometheus",
+      "description": "",
+      "type": "datasource",
+      "pluginId": "prometheus",
+      "pluginName": "Prometheus"
+    }
+  ],
+  "__elements": {},
+  "__requires": [
+    {
+      "type": "panel",
+      "id": "gauge",
+      "name": "Gauge",
+      "version": ""
+    },
+    {
+      "type": "grafana",
+      "id": "grafana",
+      "name": "Grafana",
+      "version": "9.2.3"
+    },
+    {
+      "type": "datasource",
+      "id": "prometheus",
+      "name": "Prometheus",
+      "version": "1.0.0"
+    },
+    {
+      "type": "panel",
+      "id": "stat",
+      "name": "Stat",
+      "version": ""
+    },
+    {
+      "type": "panel",
+      "id": "timeseries",
+      "name": "Time series",
+      "version": ""
+    }
+  ],
+  "annotations": {
+    "list": [
+      {
+        "$$hashKey": "object:1058",
+        "builtIn": 1,
+        "datasource": {
+          "type": "datasource",
+          "uid": "grafana"
+        },
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "target": {
+          "limit": 100,
+          "matchAny": false,
+          "tags": [],
+          "type": "dashboard"
+        },
+        "type": "dashboard"
+      },
+      {
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "enable": true,
+        "expr": "changes(node_boot_time_seconds{instance=\"$node\"}[$__rate_interval])",
+        "iconColor": "red",
+        "name": "Reboot"
+      }
+    ]
+  },
+  "editable": true,
+  "fiscalYearStartMonth": 0,
+  "gnetId": 1860,
+  "graphTooltip": 0,
+  "id": null,
+  "links": [
+    {
+      "icon": "external link",
+      "tags": [],
+      "targetBlank": true,
+      "title": "GitHub",
+      "type": "link",
+      "url": "https://github.com/rfmoz/grafana-dashboards"
+    },
+    {
+      "icon": "external link",
+      "tags": [],
+      "targetBlank": true,
+      "title": "Grafana",
+      "type": "link",
+      "url": "https://grafana.com/grafana/dashboards/1860"
+    }
+  ],
+  "liveNow": false,
+  "panels": [
+    {
+      "collapsed": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 0
+      },
+      "id": 261,
+      "panels": [],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Quick CPU / Mem / Disk",
+      "type": "row"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Busy state of all CPU cores together",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "max": 100,
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 85
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 95
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 4,
+        "w": 3,
+        "x": 0,
+        "y": 1
+      },
+      "id": 20,
+      "links": [],
+      "options": {
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "editorMode": "code",
+          "expr": "(sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode!=\"idle\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))) * 100",
+          "hide": false,
+          "intervalFactor": 1,
+          "legendFormat": "",
+          "range": true,
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "CPU Busy",
+      "type": "gauge"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Busy state of all CPU cores together (5 min average)",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "max": 100,
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 85
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 95
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 4,
+        "w": 3,
+        "x": 3,
+        "y": 1
+      },
+      "id": 155,
+      "links": [],
+      "options": {
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "avg(node_load5{instance=\"$node\",job=\"$job\"}) /  count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)) * 100",
+          "format": "time_series",
+          "hide": false,
+          "intervalFactor": 1,
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "Sys Load (5m avg)",
+      "type": "gauge"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Busy state of all CPU cores together (15 min average)",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "max": 100,
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 85
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 95
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 4,
+        "w": 3,
+        "x": 6,
+        "y": 1
+      },
+      "id": 19,
+      "links": [],
+      "options": {
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "avg(node_load15{instance=\"$node\",job=\"$job\"}) /  count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)) * 100",
+          "hide": false,
+          "intervalFactor": 1,
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "Sys Load (15m avg)",
+      "type": "gauge"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Non available RAM memory",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "decimals": 0,
+          "mappings": [],
+          "max": 100,
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 80
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 90
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 4,
+        "w": 3,
+        "x": 9,
+        "y": 1
+      },
+      "hideTimeOverride": false,
+      "id": 16,
+      "links": [],
+      "options": {
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "((node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"}) / (node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"} )) * 100",
+          "format": "time_series",
+          "hide": true,
+          "intervalFactor": 1,
+          "refId": "A",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "100 - ((node_memory_MemAvailable_bytes{instance=\"$node\",job=\"$job\"} * 100) / node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"})",
+          "format": "time_series",
+          "hide": false,
+          "intervalFactor": 1,
+          "refId": "B",
+          "step": 240
+        }
+      ],
+      "title": "RAM Used",
+      "type": "gauge"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Used Swap",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "max": 100,
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 10
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 25
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 4,
+        "w": 3,
+        "x": 12,
+        "y": 1
+      },
+      "id": 21,
+      "links": [],
+      "options": {
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "((node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapFree_bytes{instance=\"$node\",job=\"$job\"}) / (node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} )) * 100",
+          "intervalFactor": 1,
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "SWAP Used",
+      "type": "gauge"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Used Root FS",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "max": 100,
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 80
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 90
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 4,
+        "w": 3,
+        "x": 15,
+        "y": 1
+      },
+      "id": 154,
+      "links": [],
+      "options": {
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "100 - ((node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",mountpoint=\"/\",fstype!=\"rootfs\"} * 100) / node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",mountpoint=\"/\",fstype!=\"rootfs\"})",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "Root FS Used",
+      "type": "gauge"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Total number of CPU cores",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "short"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 2,
+        "w": 2,
+        "x": 18,
+        "y": 1
+      },
+      "id": 14,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "none",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu))",
+          "interval": "",
+          "intervalFactor": 1,
+          "legendFormat": "",
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "CPU Cores",
+      "type": "stat"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "System uptime",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "decimals": 1,
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "s"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 2,
+        "w": 4,
+        "x": 20,
+        "y": 1
+      },
+      "hideTimeOverride": true,
+      "id": 15,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "none",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "node_time_seconds{instance=\"$node\",job=\"$job\"} - node_boot_time_seconds{instance=\"$node\",job=\"$job\"}",
+          "intervalFactor": 1,
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "Uptime",
+      "type": "stat"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Total RootFS",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "decimals": 0,
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 70
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 90
+              }
+            ]
+          },
+          "unit": "bytes"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 2,
+        "w": 2,
+        "x": 18,
+        "y": 3
+      },
+      "id": 23,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "none",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",mountpoint=\"/\",fstype!=\"rootfs\"}",
+          "format": "time_series",
+          "hide": false,
+          "intervalFactor": 1,
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "RootFS Total",
+      "type": "stat"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Total RAM",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "decimals": 0,
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "bytes"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 2,
+        "w": 2,
+        "x": 20,
+        "y": 3
+      },
+      "id": 75,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "none",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"}",
+          "intervalFactor": 1,
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "RAM Total",
+      "type": "stat"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Total SWAP",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "decimals": 0,
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "bytes"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 2,
+        "w": 2,
+        "x": 22,
+        "y": 3
+      },
+      "id": 18,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "none",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "9.2.3",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"}",
+          "intervalFactor": 1,
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "SWAP Total",
+      "type": "stat"
+    },
+    {
+      "collapsed": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 5
+      },
+      "id": 263,
+      "panels": [],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Basic CPU / Mem / Net / Disk",
+      "type": "row"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Basic CPU info",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "palette-classic"
+          },
+          "custom": {
+            "axisCenteredZero": false,
+            "axisColorMode": "text",
+            "axisLabel": "",
+            "axisPlacement": "auto",
+            "barAlignment": 0,
+            "drawStyle": "line",
+            "fillOpacity": 40,
+            "gradientMode": "none",
+            "hideFrom": {
+              "legend": false,
+              "tooltip": false,
+              "viz": false
+            },
+            "lineInterpolation": "smooth",
+            "lineWidth": 1,
+            "pointSize": 5,
+            "scaleDistribution": {
+              "type": "linear"
+            },
+            "showPoints": "never",
+            "spanNulls": false,
+            "stacking": {
+              "group": "A",
+              "mode": "percent"
+            },
+            "thresholdsStyle": {
+              "mode": "off"
+            }
+          },
+          "links": [],
+          "mappings": [],
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "percentunit"
+        },
+        "overrides": [
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Busy Iowait"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#890F02",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Idle"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#052B51",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Busy Iowait"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#890F02",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Idle"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#7EB26D",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Busy System"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#EAB839",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Busy User"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#0A437C",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Busy Other"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#6D1F62",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          }
+        ]
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 6
+      },
+      "id": 77,
+      "links": [],
+      "options": {
+        "legend": {
+          "calcs": [],
+          "displayMode": "list",
+          "placement": "bottom",
+          "showLegend": true,
+          "width": 250
+        },
+        "tooltip": {
+          "mode": "multi",
+          "sort": "desc"
+        }
+      },
+      "pluginVersion": "9.2.0",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "editorMode": "code",
+          "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"system\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+          "format": "time_series",
+          "hide": false,
+          "intervalFactor": 1,
+          "legendFormat": "Busy System",
+          "range": true,
+          "refId": "A",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "editorMode": "code",
+          "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"user\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+          "format": "time_series",
+          "hide": false,
+          "intervalFactor": 1,
+          "legendFormat": "Busy User",
+          "range": true,
+          "refId": "B",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "editorMode": "code",
+          "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"iowait\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "Busy Iowait",
+          "range": true,
+          "refId": "C",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "editorMode": "code",
+          "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=~\".*irq\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "Busy IRQs",
+          "range": true,
+          "refId": "D",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "editorMode": "code",
+          "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode!='idle',mode!='user',mode!='system',mode!='iowait',mode!='irq',mode!='softirq'}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "Busy Other",
+          "range": true,
+          "refId": "E",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "editorMode": "code",
+          "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"idle\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "Idle",
+          "range": true,
+          "refId": "F",
+          "step": 240
+        }
+      ],
+      "title": "CPU Basic",
+      "type": "timeseries"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Basic memory usage",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "palette-classic"
+          },
+          "custom": {
+            "axisCenteredZero": false,
+            "axisColorMode": "text",
+            "axisLabel": "",
+            "axisPlacement": "auto",
+            "barAlignment": 0,
+            "drawStyle": "line",
+            "fillOpacity": 40,
+            "gradientMode": "none",
+            "hideFrom": {
+              "legend": false,
+              "tooltip": false,
+              "viz": false
+            },
+            "lineInterpolation": "linear",
+            "lineWidth": 1,
+            "pointSize": 5,
+            "scaleDistribution": {
+              "type": "linear"
+            },
+            "showPoints": "never",
+            "spanNulls": false,
+            "stacking": {
+              "group": "A",
+              "mode": "normal"
+            },
+            "thresholdsStyle": {
+              "mode": "off"
+            }
+          },
+          "links": [],
+          "mappings": [],
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "bytes"
+        },
+        "overrides": [
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Apps"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#629E51",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Buffers"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#614D93",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Cache"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#6D1F62",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Cached"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#511749",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Committed"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#508642",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Free"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#0A437C",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#CFFAFF",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Inactive"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#584477",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "PageTables"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#0A50A1",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Page_Tables"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#0A50A1",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "RAM_Free"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#E0F9D7",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "SWAP Used"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#BF1B00",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Slab"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#806EB7",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Slab_Cache"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#E0752D",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Swap"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#BF1B00",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Swap Used"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#BF1B00",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Swap_Cache"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#C15C17",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Swap_Free"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#2F575E",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Unused"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#EAB839",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "RAM Total"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#E0F9D7",
+                  "mode": "fixed"
+                }
+              },
+              {
+                "id": "custom.fillOpacity",
+                "value": 0
+              },
+              {
+                "id": "custom.stacking",
+                "value": {
+                  "group": false,
+                  "mode": "normal"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "RAM Cache + Buffer"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#052B51",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "RAM Free"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#7EB26D",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Avaliable"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#DEDAF7",
+                  "mode": "fixed"
+                }
+              },
+              {
+                "id": "custom.fillOpacity",
+                "value": 0
+              },
+              {
+                "id": "custom.stacking",
+                "value": {
+                  "group": false,
+                  "mode": "normal"
+                }
+              }
+            ]
+          }
+        ]
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 6
+      },
+      "id": 78,
+      "links": [],
+      "options": {
+        "legend": {
+          "calcs": [],
+          "displayMode": "list",
+          "placement": "bottom",
+          "showLegend": true,
+          "width": 350
+        },
+        "tooltip": {
+          "mode": "multi",
+          "sort": "none"
+        }
+      },
+      "pluginVersion": "9.2.0",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"}",
+          "format": "time_series",
+          "hide": false,
+          "intervalFactor": 1,
+          "legendFormat": "RAM Total",
+          "refId": "A",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"} - (node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"} + node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"} + node_memory_SReclaimable_bytes{instance=\"$node\",job=\"$job\"})",
+          "format": "time_series",
+          "hide": false,
+          "intervalFactor": 1,
+          "legendFormat": "RAM Used",
+          "refId": "B",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"} + node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"} + node_memory_SReclaimable_bytes{instance=\"$node\",job=\"$job\"}",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "RAM Cache + Buffer",
+          "refId": "C",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"}",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "RAM Free",
+          "refId": "D",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "(node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapFree_bytes{instance=\"$node\",job=\"$job\"})",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "SWAP Used",
+          "refId": "E",
+          "step": 240
+        }
+      ],
+      "title": "Memory Basic",
+      "type": "timeseries"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Basic network info per interface",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "palette-classic"
+          },
+          "custom": {
+            "axisCenteredZero": false,
+            "axisColorMode": "text",
+            "axisLabel": "",
+            "axisPlacement": "auto",
+            "barAlignment": 0,
+            "drawStyle": "line",
+            "fillOpacity": 40,
+            "gradientMode": "none",
+            "hideFrom": {
+              "legend": false,
+              "tooltip": false,
+              "viz": false
+            },
+            "lineInterpolation": "linear",
+            "lineWidth": 1,
+            "pointSize": 5,
+            "scaleDistribution": {
+              "type": "linear"
+            },
+            "showPoints": "never",
+            "spanNulls": false,
+            "stacking": {
+              "group": "A",
+              "mode": "none"
+            },
+            "thresholdsStyle": {
+              "mode": "off"
+            }
+          },
+          "links": [],
+          "mappings": [],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "bps"
+        },
+        "overrides": [
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Recv_bytes_eth2"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#7EB26D",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Recv_bytes_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#0A50A1",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Recv_drop_eth2"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#6ED0E0",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Recv_drop_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#E0F9D7",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Recv_errs_eth2"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#BF1B00",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Recv_errs_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#CCA300",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Trans_bytes_eth2"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#7EB26D",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Trans_bytes_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#0A50A1",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Trans_drop_eth2"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#6ED0E0",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Trans_drop_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#E0F9D7",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Trans_errs_eth2"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#BF1B00",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Trans_errs_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#CCA300",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "recv_bytes_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#0A50A1",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "recv_drop_eth0"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#99440A",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "recv_drop_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#967302",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "recv_errs_eth0"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#BF1B00",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "recv_errs_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#890F02",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "trans_bytes_eth0"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#7EB26D",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "trans_bytes_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#0A50A1",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "trans_drop_eth0"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#99440A",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "trans_drop_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#967302",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "trans_errs_eth0"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#BF1B00",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "trans_errs_lo"
+            },
+            "properties": [
+              {
+                "id": "color",
+                "value": {
+                  "fixedColor": "#890F02",
+                  "mode": "fixed"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byRegexp",
+              "options": "/.*trans.*/"
+            },
+            "properties": [
+              {
+                "id": "custom.transform",
+                "value": "negative-Y"
+              }
+            ]
+          }
+        ]
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 13
+      },
+      "id": 74,
+      "links": [],
+      "options": {
+        "legend": {
+          "calcs": [],
+          "displayMode": "list",
+          "placement": "bottom",
+          "showLegend": true
+        },
+        "tooltip": {
+          "mode": "multi",
+          "sort": "none"
+        }
+      },
+      "pluginVersion": "9.2.0",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "irate(node_network_receive_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "recv {{device}}",
+          "refId": "A",
+          "step": 240
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "irate(node_network_transmit_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "trans {{device}} ",
+          "refId": "B",
+          "step": 240
+        }
+      ],
+      "title": "Network Traffic Basic",
+      "type": "timeseries"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "description": "Disk space used of all filesystems mounted",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "palette-classic"
+          },
+          "custom": {
+            "axisCenteredZero": false,
+            "axisColorMode": "text",
+            "axisLabel": "",
+            "axisPlacement": "auto",
+            "barAlignment": 0,
+            "drawStyle": "line",
+            "fillOpacity": 40,
+            "gradientMode": "none",
+            "hideFrom": {
+              "legend": false,
+              "tooltip": false,
+              "viz": false
+            },
+            "lineInterpolation": "linear",
+            "lineWidth": 1,
+            "pointSize": 5,
+            "scaleDistribution": {
+              "type": "linear"
+            },
+            "showPoints": "never",
+            "spanNulls": false,
+            "stacking": {
+              "group": "A",
+              "mode": "none"
+            },
+            "thresholdsStyle": {
+              "mode": "off"
+            }
+          },
+          "links": [],
+          "mappings": [],
+          "max": 100,
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 13
+      },
+      "id": 152,
+      "links": [],
+      "options": {
+        "legend": {
+          "calcs": [],
+          "displayMode": "list",
+          "placement": "bottom",
+          "showLegend": true
+        },
+        "tooltip": {
+          "mode": "multi",
+          "sort": "none"
+        }
+      },
+      "pluginVersion": "9.2.0",
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "expr": "100 - ((node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'} * 100) / node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'})",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "{{mountpoint}}",
+          "refId": "A",
+          "step": 240
+        }
+      ],
+      "title": "Disk Space Used Basic",
+      "type": "timeseries"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 20
+      },
+      "id": 265,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "percentage",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 70,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "smooth",
+                "lineWidth": 2,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "percent"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green",
+                    "value": null
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "percentunit"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Idle - Waiting for something to happen"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Iowait - Waiting for I/O to complete"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Irq - Servicing interrupts"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Nice - Niced processes executing in user mode"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Softirq - Servicing softirqs"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Steal - Time spent in other operating systems when running in a virtualized environment"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCE2DE",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "System - Processes executing in kernel mode"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "User - Normal processes executing in user mode"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#5195CE",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 12,
+            "w": 12,
+            "x": 0,
+            "y": 7
+          },
+          "id": 3,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 250
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "desc"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"system\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "System - Processes executing in kernel mode",
+              "range": true,
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"user\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "User - Normal processes executing in user mode",
+              "range": true,
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"nice\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Nice - Niced processes executing in user mode",
+              "range": true,
+              "refId": "C",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"iowait\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Iowait - Waiting for I/O to complete",
+              "range": true,
+              "refId": "E",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"irq\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Irq - Servicing interrupts",
+              "range": true,
+              "refId": "F",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"softirq\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Softirq - Servicing softirqs",
+              "range": true,
+              "refId": "G",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"steal\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Steal - Time spent in other operating systems when running in a virtualized environment",
+              "range": true,
+              "refId": "H",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"idle\"}[$__rate_interval])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])))",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Idle - Waiting for something to happen",
+              "range": true,
+              "refId": "J",
+              "step": 240
+            }
+          ],
+          "title": "CPU",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 40,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "normal"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green",
+                    "value": null
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Apps"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#629E51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A437C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#CFFAFF",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "RAM_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap - Swap memory usage"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#2F575E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused - Free memory unassigned"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Hardware Corrupted - *./"
+                },
+                "properties": [
+                  {
+                    "id": "custom.stacking",
+                    "value": {
+                      "group": false,
+                      "mode": "normal"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 12,
+            "w": 12,
+            "x": 12,
+            "y": 7
+          },
+          "id": 24,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 350
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"} - node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"} - node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"} - node_memory_Slab_bytes{instance=\"$node\",job=\"$job\"} - node_memory_PageTables_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapCached_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Apps - Memory used by user-space applications",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_PageTables_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "PageTables - Memory used to map between virtual and physical memory addresses",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_SwapCached_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "SwapCache - Memory that keeps track of pages that have been fetched from swap but not yet been modified",
+              "refId": "C",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Slab_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Slab - Memory used by the kernel to cache data structures for its own use (caches like inode, dentry, etc)",
+              "refId": "D",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Cache - Parked file data (file content) cache",
+              "refId": "E",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Buffers - Block device (e.g. harddisk) cache",
+              "refId": "F",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Unused - Free memory unassigned",
+              "refId": "G",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "(node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapFree_bytes{instance=\"$node\",job=\"$job\"})",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Swap - Swap space used",
+              "refId": "H",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_HardwareCorrupted_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working",
+              "refId": "I",
+              "step": 240
+            }
+          ],
+          "title": "Memory Stack",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bits out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 40,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green",
+                    "value": null
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "receive_packets_eth0"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "receive_packets_lo"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "transmit_packets_eth0"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "transmit_packets_lo"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Trans.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 12,
+            "w": 12,
+            "x": 0,
+            "y": 19
+          },
+          "id": 84,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_receive_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Receive",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_transmit_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Transmit",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 40,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green",
+                    "value": null
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 12,
+            "w": 12,
+            "x": 12,
+            "y": 19
+          },
+          "id": 156,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'} - node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{mountpoint}}",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Disk Space Used",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "IO read (-) / write (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green",
+                    "value": null
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "iops"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Read.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda2_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BA43A9",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda3_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F4D598",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#962D82",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#9AC48A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#65C5DB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9934E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCEACA",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9E2D2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 12,
+            "w": 12,
+            "x": 0,
+            "y": 31
+          },
+          "id": 229,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_reads_completed_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])",
+              "intervalFactor": 4,
+              "legendFormat": "{{device}} - Reads completed",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_writes_completed_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Writes completed",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Disk IOps",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes read (-) / write (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 40,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green",
+                    "value": null
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "Bps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "io time"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#890F02",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*read*./"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byType",
+                  "options": "time"
+                },
+                "properties": [
+                  {
+                    "id": "custom.axisPlacement",
+                    "value": "hidden"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 12,
+            "w": 12,
+            "x": 12,
+            "y": 31
+          },
+          "id": 42,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_read_bytes_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Successfully read bytes",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_written_bytes_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Successfully written bytes",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "I/O Usage Read / Write",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "%util",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 40,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green",
+                    "value": null
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "percentunit"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "io time"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#890F02",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byType",
+                  "options": "time"
+                },
+                "properties": [
+                  {
+                    "id": "custom.axisPlacement",
+                    "value": "hidden"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 12,
+            "w": 12,
+            "x": 0,
+            "y": 43
+          },
+          "id": 127,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_io_time_seconds_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"} [$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}}",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "I/O Utilization",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "percentage",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "bars",
+                "fillOpacity": 70,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "smooth",
+                "lineWidth": 2,
+                "pointSize": 3,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "mappings": [],
+              "max": 1,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green",
+                    "value": null
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "percentunit"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/^Guest - /"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#5195ce",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/^GuestNice - /"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#c15c17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 12,
+            "w": 12,
+            "x": 12,
+            "y": 43
+          },
+          "id": 319,
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "desc"
+            }
+          },
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_guest_seconds_total{instance=\"$node\",job=\"$job\", mode=\"user\"}[1m])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[1m])))",
+              "hide": false,
+              "legendFormat": "Guest - Time spent running a virtual CPU for a guest operating system",
+              "range": true,
+              "refId": "A"
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "editorMode": "code",
+              "expr": "sum by(instance) (irate(node_cpu_guest_seconds_total{instance=\"$node\",job=\"$job\", mode=\"nice\"}[1m])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[1m])))",
+              "hide": false,
+              "legendFormat": "GuestNice - Time spent running a niced guest  (virtual CPU for guest operating system)",
+              "range": true,
+              "refId": "B"
+            }
+          ],
+          "title": "CPU spent seconds in guests (VMs)",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "CPU / Memory / Net / Disk",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 21
+      },
+      "id": 266,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "normal"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Apps"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#629E51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A437C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#CFFAFF",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "RAM_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#2F575E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 38
+          },
+          "id": 136,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 350
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Inactive_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Inactive - Memory which has been less recently used.  It is more eligible to be reclaimed for other purposes",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Active_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Active - Memory that has been used more recently and usually not reclaimed unless absolutely necessary",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Memory Active / Inactive",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Apps"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#629E51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A437C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#CFFAFF",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "RAM_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#2F575E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*CommitLimit - *./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 38
+          },
+          "id": 135,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 350
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Committed_AS_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Committed_AS - Amount of memory presently allocated on the system",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_CommitLimit_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "CommitLimit - Amount of  memory currently available to be allocated on the system",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Memory Commited",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "normal"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Apps"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#629E51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A437C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#CFFAFF",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "RAM_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#2F575E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 48
+          },
+          "id": 191,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 350
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Inactive_file_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Inactive_file - File-backed memory on inactive LRU list",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Inactive_anon_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Inactive_anon - Anonymous and swap cache on inactive LRU list, including tmpfs (shmem)",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Active_file_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Active_file - File-backed memory on active LRU list",
+              "refId": "C",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Active_anon_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Active_anon - Anonymous and swap cache on active least-recently-used (LRU) list, including tmpfs",
+              "refId": "D",
+              "step": 240
+            }
+          ],
+          "title": "Memory Active / Inactive Detail",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#99440A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#58140C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Dirty"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#B7DBAB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Mapped"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM + Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "VmallocUsed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 48
+          },
+          "id": 130,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Writeback_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Writeback - Memory which is actively being written back to disk",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_WritebackTmp_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "WritebackTmp - Memory used by FUSE for temporary writeback buffers",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Dirty_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Dirty - Memory which is waiting to get written back to the disk",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "Memory Writeback and Dirty",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Apps"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#629E51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A437C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#CFFAFF",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "RAM_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#2F575E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "ShmemHugePages - Memory used by shared memory (shmem) and tmpfs allocated  with huge pages"
+                },
+                "properties": [
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "ShmemHugePages - Memory used by shared memory (shmem) and tmpfs allocated  with huge pages"
+                },
+                "properties": [
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 58
+          },
+          "id": 138,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 350
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Mapped_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Mapped - Used memory in mapped pages files which have been mmaped, such as libraries",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Shmem_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Shmem - Used shared memory (shared between several processes, thus including RAM disks)",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_ShmemHugePages_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "ShmemHugePages - Memory used by shared memory (shmem) and tmpfs allocated  with huge pages",
+              "refId": "C",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_ShmemPmdMapped_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "ShmemPmdMapped - Ammount of shared (shmem/tmpfs) memory backed by huge pages",
+              "refId": "D",
+              "step": 240
+            }
+          ],
+          "title": "Memory Shared and Mapped",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "normal"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#99440A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#58140C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Dirty"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#B7DBAB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Mapped"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM + Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "VmallocUsed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 58
+          },
+          "id": 131,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_SUnreclaim_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "SUnreclaim - Part of Slab, that cannot be reclaimed on memory pressure",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_SReclaimable_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "SReclaimable - Part of Slab, that might be reclaimed, such as caches",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Memory Slab",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#99440A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#58140C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Dirty"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#B7DBAB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Mapped"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM + Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "VmallocUsed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 68
+          },
+          "id": 70,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_VmallocChunk_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "VmallocChunk - Largest contigious block of vmalloc area which is free",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_VmallocTotal_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "VmallocTotal - Total size of vmalloc memory area",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_VmallocUsed_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "VmallocUsed - Amount of vmalloc area which is used",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "Memory Vmalloc",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Apps"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#629E51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A437C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#CFFAFF",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "RAM_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#2F575E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 68
+          },
+          "id": 159,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 350
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Bounce_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Bounce - Memory used for block device bounce buffers",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Memory Bounce",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#99440A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#58140C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Dirty"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#B7DBAB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Mapped"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM + Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "VmallocUsed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Inactive *./"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 78
+          },
+          "id": 129,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_AnonHugePages_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "AnonHugePages - Memory in anonymous huge pages",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_AnonPages_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "AnonPages - Memory in user pages not backed by files",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Memory Anonymous",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Apps"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#629E51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A437C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#CFFAFF",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "RAM_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#2F575E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 78
+          },
+          "id": 160,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 350
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_KernelStack_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "KernelStack - Kernel memory stack. This is not reclaimable",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Percpu_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "PerCPU - Per CPU memory allocated dynamically by loadable modules",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Memory Kernel / CPU",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "pages",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#99440A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#58140C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Dirty"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#B7DBAB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Mapped"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM + Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "VmallocUsed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 88
+          },
+          "id": 140,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_HugePages_Free{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "HugePages_Free - Huge pages in the pool that are not yet allocated",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_HugePages_Rsvd{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "HugePages_Rsvd - Huge pages for which a commitment to allocate from the pool has been made, but no allocation has yet been made",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_HugePages_Surp{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "HugePages_Surp - Huge pages in the pool above the value in /proc/sys/vm/nr_hugepages",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "Memory HugePages Counter",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#99440A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#58140C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Dirty"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#B7DBAB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Mapped"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM + Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "VmallocUsed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 88
+          },
+          "id": 71,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_HugePages_Total{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "HugePages - Total size of the pool of huge pages",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Hugepagesize_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Hugepagesize - Huge Page size",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Memory HugePages Size",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#99440A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#58140C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Dirty"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#B7DBAB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Mapped"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM + Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "VmallocUsed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 98
+          },
+          "id": 128,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_DirectMap1G_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "DirectMap1G - Amount of pages mapped as this size",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_DirectMap2M_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "DirectMap2M - Amount of pages mapped as this size",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_DirectMap4k_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "DirectMap4K - Amount of pages mapped as this size",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "Memory DirectMap",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Apps"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#629E51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A437C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#CFFAFF",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "RAM_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#2F575E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 98
+          },
+          "id": 137,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 350
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Unevictable_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Unevictable - Amount of unevictable memory that can't be swapped out for a variety of reasons",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_Mlocked_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "MLocked - Size of pages locked to memory using the mlock() system call",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Memory Unevictable and MLocked",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#99440A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#58140C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Dirty"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#B7DBAB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Mapped"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM + Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "VmallocUsed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 108
+          },
+          "id": 132,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_memory_NFS_Unstable_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "NFS Unstable - Memory in NFS pages sent to the server, but not yet commited to the storage",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Memory NFS",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Memory Meminfo",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 22
+      },
+      "id": 267,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "pages out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*out/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 25
+          },
+          "id": 176,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_vmstat_pgpgin{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Pagesin - Page in operations",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_vmstat_pgpgout{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Pagesout - Page out operations",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Memory Pages In / Out",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "pages out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*out/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 25
+          },
+          "id": 22,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_vmstat_pswpin{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Pswpin - Pages swapped in",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_vmstat_pswpout{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Pswpout - Pages swapped out",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Memory Pages Swap In / Out",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "faults",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "normal"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Apps"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#629E51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A437C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#CFFAFF",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "RAM_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#806EB7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#2F575E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Unused"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Pgfault - Page major and minor fault operations"
+                },
+                "properties": [
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  },
+                  {
+                    "id": "custom.stacking",
+                    "value": {
+                      "group": false,
+                      "mode": "normal"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 35
+          },
+          "id": 175,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 350
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_vmstat_pgfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Pgfault - Page major and minor fault operations",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_vmstat_pgmajfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Pgmajfault - Major page fault operations",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_vmstat_pgfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])  - irate(node_vmstat_pgmajfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Pgminfault - Minor page fault operations",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "Memory Page Faults",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#99440A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Buffers"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#58140C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6D1F62",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Cached"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Committed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#508642",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Dirty"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Free"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#B7DBAB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Mapped"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PageTables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Page_Tables"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Slab_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Swap_Cache"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C15C17",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#511749",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total RAM + Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#052B51",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Total Swap"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "VmallocUsed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 35
+          },
+          "id": 307,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_vmstat_oom_kill{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "oom killer invocations ",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "OOM Killer",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Memory Vmstat",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 23
+      },
+      "id": 293,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "seconds",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "s"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Variation*./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#890F02",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 40
+          },
+          "id": 260,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_timex_estimated_error_seconds{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Estimated error in seconds",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_timex_offset_seconds{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Time offset in between local system and reference clock",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_timex_maxerror_seconds{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Maximum error in seconds",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "Time Syncronized Drift",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 40
+          },
+          "id": 291,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_timex_loop_time_constant{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Phase-locked loop time adjust",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Time PLL Adjust",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Variation*./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#890F02",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 50
+          },
+          "id": 168,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_timex_sync_status{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Is clock synchronized to a reliable server (1 = yes, 0 = no)",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_timex_frequency_adjustment_ratio{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Local clock frequency adjustment",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Time Syncronized Status",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "seconds",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "s"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 50
+          },
+          "id": 294,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_timex_tick_seconds{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Seconds between clock ticks",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_timex_tai_offset_seconds{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "International Atomic Time (TAI) offset",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Time Misc",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "System Timesync",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 24
+      },
+      "id": 312,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 27
+          },
+          "id": 62,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_procs_blocked{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Processes blocked waiting for I/O to complete",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_procs_running{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Processes in runnable state",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Processes Status",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "normal"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 27
+          },
+          "id": 315,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_processes_state{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{ state }}",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Processes State",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "forks / sec",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 37
+          },
+          "id": 148,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_forks_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Processes forks second",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Processes  Forks",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "decbytes"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Max.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 37
+          },
+          "id": 149,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(process_virtual_memory_bytes{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Processes virtual memory size in bytes",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "process_resident_memory_max_bytes{instance=\"$node\",job=\"$job\"}",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Maximum amount of virtual memory available in bytes",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(process_virtual_memory_bytes{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Processes virtual memory size in bytes",
+              "refId": "C",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(process_virtual_memory_max_bytes{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Maximum amount of virtual memory available in bytes",
+              "refId": "D",
+              "step": 240
+            }
+          ],
+          "title": "Processes Memory",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "PIDs limit"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F2495C",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 47
+          },
+          "id": 313,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_processes_pids{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Number of PIDs",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_processes_max_processes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "PIDs limit",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "PIDs Number and Limit",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "seconds",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "s"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*waiting.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 47
+          },
+          "id": 305,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_schedstat_running_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "CPU {{ cpu }} - seconds spent running a process",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_schedstat_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "CPU {{ cpu }} - seconds spent by processing waiting for this CPU",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Process schedule stats Running / Waiting",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Threads limit"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F2495C",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 57
+          },
+          "id": 314,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_processes_threads{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Allocated threads",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_processes_max_threads{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Threads limit",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Threads Number and Limit",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "System Processes",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 25
+      },
+      "id": 269,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 42
+          },
+          "id": 8,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_context_switches_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Context switches",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_intr_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "Interrupts",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Context Switches / Interrupts",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 42
+          },
+          "id": 7,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_load1{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 4,
+              "legendFormat": "Load 1m",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_load5{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 4,
+              "legendFormat": "Load 5m",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_load15{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 4,
+              "legendFormat": "Load 15m",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "System Load",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Critical*./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Max*./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 52
+          },
+          "id": 259,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_interrupts_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{ type }} - {{ info }}",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Interrupts Detail",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 52
+          },
+          "id": 306,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_schedstat_timeslices_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "CPU {{ cpu }}",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Schedule timeslices executed by each cpu",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 62
+          },
+          "id": 151,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_entropy_available_bits{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Entropy available to random number generators",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Entropy",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "seconds",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "s"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 62
+          },
+          "id": 308,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(process_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Time spent",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "CPU time spent in user and system contexts",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Max*./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#890F02",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 72
+          },
+          "id": 64,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "process_max_fds{instance=\"$node\",job=\"$job\"}",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Maximum open file descriptors",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "process_open_fds{instance=\"$node\",job=\"$job\"}",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Open file descriptors",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "File Descriptors",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "System Misc",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 26
+      },
+      "id": 304,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "temperature",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "celsius"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Critical*./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Max*./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 43
+          },
+          "id": 158,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_hwmon_temp_celsius{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{ chip }} {{ sensor }} temp",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_hwmon_temp_crit_alarm_celsius{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": true,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{ chip }} {{ sensor }} Critical Alarm",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_hwmon_temp_crit_celsius{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{ chip }} {{ sensor }} Critical",
+              "refId": "C",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_hwmon_temp_crit_hyst_celsius{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": true,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{ chip }} {{ sensor }} Critical Historical",
+              "refId": "D",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_hwmon_temp_max_celsius{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": true,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{ chip }} {{ sensor }} Max",
+              "refId": "E",
+              "step": 240
+            }
+          ],
+          "title": "Hardware temperature monitor",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Max*./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 43
+          },
+          "id": 300,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_cooling_device_cur_state{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Current {{ name }} in {{ type }}",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_cooling_device_max_state{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Max {{ name }} in {{ type }}",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Throttle cooling device",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 53
+          },
+          "id": 302,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_power_supply_online{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{ power_supply }} online",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Power supply",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Hardware Misc",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 27
+      },
+      "id": 296,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 30
+          },
+          "id": 297,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_systemd_socket_accepted_connections_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{ name }} Connections",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Systemd Sockets",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "normal"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Failed"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F2495C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Inactive"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FF9830",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Active"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#73BF69",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Deactivating"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FFCB7D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "Activating"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#C8F2C2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 30
+          },
+          "id": 298,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"activating\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Activating",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"active\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Active",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"deactivating\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Deactivating",
+              "refId": "C",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"failed\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Failed",
+              "refId": "D",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"inactive\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Inactive",
+              "refId": "E",
+              "step": 240
+            }
+          ],
+          "title": "Systemd Units State",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Systemd",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 28
+      },
+      "id": 270,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "The number (after merges) of I/O requests completed per second for the device",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "IO read (-) / write (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "iops"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Read.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda2_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BA43A9",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda3_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F4D598",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#962D82",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#9AC48A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#65C5DB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9934E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCEACA",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9E2D2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 31
+          },
+          "id": 9,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_reads_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "intervalFactor": 4,
+              "legendFormat": "{{device}} - Reads completed",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_writes_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Writes completed",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Disk IOps Completed",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "The number of bytes read from or written to the device per second",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes read (-) / write (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "Bps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Read.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda2_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BA43A9",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda3_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F4D598",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#962D82",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#9AC48A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#65C5DB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9934E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCEACA",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9E2D2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 31
+          },
+          "id": 33,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_read_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 4,
+              "legendFormat": "{{device}} - Read bytes",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_written_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Written bytes",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Disk R/W Data",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "The average time for requests issued to the device to be served. This includes the time spent by the requests in queue and the time spent servicing them.",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "time. read (-) / write (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 30,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "s"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Read.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda2_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BA43A9",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda3_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F4D598",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#962D82",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#9AC48A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#65C5DB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9934E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCEACA",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9E2D2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 41
+          },
+          "id": 37,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_read_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval]) / irate(node_disk_reads_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 4,
+              "legendFormat": "{{device}} - Read wait time avg",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_write_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval]) / irate(node_disk_writes_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Write wait time avg",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Disk Average Wait Time",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "The average queue length of the requests that were issued to the device",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "aqu-sz",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "none"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda2_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BA43A9",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda3_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F4D598",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#962D82",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#9AC48A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#65C5DB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9934E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCEACA",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9E2D2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 41
+          },
+          "id": 35,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_io_time_weighted_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "interval": "",
+              "intervalFactor": 4,
+              "legendFormat": "{{device}}",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Average Queue Size",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "The number of read and write requests merged per second that were queued to the device",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "I/Os",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "iops"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Read.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda2_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BA43A9",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda3_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F4D598",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#962D82",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#9AC48A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#65C5DB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9934E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCEACA",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9E2D2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 51
+          },
+          "id": 133,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_reads_merged_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Read merged",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_writes_merged_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Write merged",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Disk R/W Merged",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "Percentage of elapsed time during which I/O requests were issued to the device (bandwidth utilization for the device). Device saturation occurs when this value is close to 100% for devices serving requests serially.  But for devices  serving requests in parallel, such as RAID arrays and modern SSDs, this number does not reflect their performance limits.",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "%util",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 30,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "percentunit"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda2_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BA43A9",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda3_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F4D598",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#962D82",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#9AC48A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#65C5DB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9934E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCEACA",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9E2D2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 51
+          },
+          "id": 36,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_io_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "interval": "",
+              "intervalFactor": 4,
+              "legendFormat": "{{device}} - IO",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_discard_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "interval": "",
+              "intervalFactor": 4,
+              "legendFormat": "{{device}} - discard",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Time Spent Doing I/Os",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "The number of outstanding requests at the instant the sample was taken. Incremented as requests are given to appropriate struct request_queue and decremented as they finish.",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "Outstanding req.",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "none"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda2_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BA43A9",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda3_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F4D598",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#962D82",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#9AC48A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#65C5DB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9934E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCEACA",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9E2D2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 61
+          },
+          "id": 34,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_disk_io_now{instance=\"$node\",job=\"$job\"}",
+              "interval": "",
+              "intervalFactor": 4,
+              "legendFormat": "{{device}} - IO now",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Instantaneous Queue Size",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "IOs",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "iops"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EAB839",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#6ED0E0",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EF843C",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#584477",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda2_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BA43A9",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sda3_.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F4D598",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#0A50A1",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#BF1B00",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdb3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0752D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#962D82",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#614D93",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdc3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#9AC48A",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#65C5DB",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9934E",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#EA6460",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde1.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E0F9D7",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sdd2.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#FCEACA",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*sde3.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F9E2D2",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 61
+          },
+          "id": 301,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_discards_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "interval": "",
+              "intervalFactor": 4,
+              "legendFormat": "{{device}} - Discards completed",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_disk_discards_merged_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Discards merged",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Disk IOps Discards completed / merged",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Storage Disk",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 29
+      },
+      "id": 271,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 46
+          },
+          "id": 43,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "{{mountpoint}} - Available",
+              "metric": "",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filesystem_free_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}",
+              "format": "time_series",
+              "hide": true,
+              "intervalFactor": 1,
+              "legendFormat": "{{mountpoint}} - Free",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}",
+              "format": "time_series",
+              "hide": true,
+              "intervalFactor": 1,
+              "legendFormat": "{{mountpoint}} - Size",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "Filesystem space available",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "file nodes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 46
+          },
+          "id": 41,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filesystem_files_free{instance=\"$node\",job=\"$job\",device!~'rootfs'}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "{{mountpoint}} - Free file nodes",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "File Nodes Free",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "files",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 56
+          },
+          "id": 28,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "single",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filefd_maximum{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 4,
+              "legendFormat": "Max open files",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filefd_allocated{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "Open files",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "File Descriptor",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "file Nodes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 56
+          },
+          "id": 219,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filesystem_files{instance=\"$node\",job=\"$job\",device!~'rootfs'}",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "{{mountpoint}} - File nodes total",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "File Nodes Size",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "normal"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "max": 1,
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "/ ReadOnly"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#890F02",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 66
+          },
+          "id": 44,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filesystem_readonly{instance=\"$node\",job=\"$job\",device!~'rootfs'}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{mountpoint}} - ReadOnly",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_filesystem_device_error{instance=\"$node\",job=\"$job\",device!~'rootfs',fstype!~'tmpfs'}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{mountpoint}} - Device error",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Filesystem in ReadOnly / Error",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Storage Filesystem",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 30
+      },
+      "id": 272,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "packets out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "pps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "receive_packets_eth0"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "receive_packets_lo"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "transmit_packets_eth0"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#7EB26D",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "transmit_packets_lo"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#E24D42",
+                      "mode": "fixed"
+                    }
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Trans.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 33
+          },
+          "id": 60,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_receive_packets_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Receive",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_transmit_packets_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Transmit",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic by Packets",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "packets out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "pps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Trans.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 33
+          },
+          "id": 142,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_receive_errs_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Receive errors",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_transmit_errs_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Rransmit errors",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic Errors",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "packets out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "pps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Trans.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 43
+          },
+          "id": 143,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_receive_drop_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Receive drop",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_transmit_drop_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Transmit drop",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic Drop",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "packets out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "pps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Trans.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 43
+          },
+          "id": 141,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_receive_compressed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Receive compressed",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_transmit_compressed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Transmit compressed",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic Compressed",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "packets out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "pps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Trans.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 53
+          },
+          "id": 146,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_receive_multicast_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Receive multicast",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic Multicast",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "packets out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "pps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Trans.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 53
+          },
+          "id": 144,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_receive_fifo_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Receive fifo",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_transmit_fifo_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Transmit fifo",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic Fifo",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "packets out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "pps"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Trans.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 63
+          },
+          "id": 145,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_receive_frame_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Receive frame",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic Frame",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 63
+          },
+          "id": 231,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_transmit_carrier_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Statistic transmit_carrier",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic Carrier",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Trans.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 73
+          },
+          "id": 232,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_network_transmit_colls_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{device}} - Transmit colls",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Network Traffic Colls",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "entries",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byName",
+                  "options": "NF conntrack limit"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#890F02",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 73
+          },
+          "id": 61,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_nf_conntrack_entries{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "NF conntrack entries",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_nf_conntrack_entries_limit{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "NF conntrack limit",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "NF Contrack",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "Entries",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 83
+          },
+          "id": 230,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_arp_entries{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{ device }} - ARP entries",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "ARP Entries",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "decimals": 0,
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 83
+          },
+          "id": 288,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_network_mtu_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{ device }} - Bytes",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "MTU",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "decimals": 0,
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 93
+          },
+          "id": 280,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_network_speed_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{ device }} - Speed",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Speed",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "packets",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "decimals": 0,
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "none"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 93
+          },
+          "id": 289,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_network_transmit_queue_length{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{ device }} -   Interface transmit queue length",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Queue Length",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "packetes drop (-) / process (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Dropped.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 103
+          },
+          "id": 290,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_softnet_processed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "CPU {{cpu}} - Processed",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_softnet_dropped_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "CPU {{cpu}} - Dropped",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Softnet Packets",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 103
+          },
+          "id": 310,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_softnet_times_squeezed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "CPU {{cpu}} - Squeezed",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Softnet Out of Quota",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 113
+          },
+          "id": 309,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_network_up{operstate=\"up\",instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "{{interface}} - Operational state UP",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_network_carrier{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "instant": false,
+              "legendFormat": "{{device}} - Physical link state",
+              "refId": "B"
+            }
+          ],
+          "title": "Network Operational Status",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Network Traffic",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 31
+      },
+      "id": 273,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 48
+          },
+          "id": 63,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_TCP_alloc{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "TCP_alloc - Allocated sockets",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_TCP_inuse{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "TCP_inuse - Tcp sockets currently in use",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_TCP_mem{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": true,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "TCP_mem - Used memory for tcp",
+              "refId": "C",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_TCP_orphan{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "TCP_orphan - Orphan sockets",
+              "refId": "D",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_TCP_tw{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "TCP_tw - Sockets wating close",
+              "refId": "E",
+              "step": 240
+            }
+          ],
+          "title": "Sockstat TCP",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 48
+          },
+          "id": 124,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_UDPLITE_inuse{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "UDPLITE_inuse - Udplite sockets currently in use",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_UDP_inuse{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "UDP_inuse - Udp sockets currently in use",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_UDP_mem{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "UDP_mem - Used memory for udp",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "Sockstat UDP",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 58
+          },
+          "id": 125,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_FRAG_inuse{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "FRAG_inuse - Frag sockets currently in use",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_RAW_inuse{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "RAW_inuse - Raw sockets currently in use",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "Sockstat FRAG / RAW",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "bytes",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "bytes"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 58
+          },
+          "id": 220,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_TCP_mem_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "mem_bytes - TCP sockets in that state",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_UDP_mem_bytes{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "mem_bytes - UDP sockets in that state",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_FRAG_memory{instance=\"$node\",job=\"$job\"}",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "FRAG_memory - Used memory for frag",
+              "refId": "C"
+            }
+          ],
+          "title": "Sockstat Memory Size",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "sockets",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 68
+          },
+          "id": 126,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_sockstat_sockets_used{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Sockets_used - Sockets currently in use",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Sockstat Used",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Network Sockstat",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 32
+      },
+      "id": 274,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "octects out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Out.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 49
+          },
+          "id": 221,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_IpExt_InOctets{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "InOctets - Received octets",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_IpExt_OutOctets{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "intervalFactor": 1,
+              "legendFormat": "OutOctets - Sent octets",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Netstat IP In / Out Octets",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "datagrams",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 49
+          },
+          "id": 81,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true,
+              "width": 300
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Ip_Forwarding{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "Forwarding - IP forwarding",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Netstat IP Forwarding",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "messages out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Out.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 59
+          },
+          "id": 115,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Icmp_InMsgs{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "InMsgs -  Messages which the entity received. Note that this counter includes all those counted by icmpInErrors",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Icmp_OutMsgs{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "OutMsgs - Messages which this entity attempted to send. Note that this counter includes all those counted by icmpOutErrors",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "ICMP In / Out",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "messages out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Out.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 59
+          },
+          "id": 50,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Icmp_InErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "InErrors - Messages which the entity received but determined as having ICMP-specific errors (bad ICMP checksums, bad length, etc.)",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "ICMP Errors",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "datagrams out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Out.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Snd.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 69
+          },
+          "id": 55,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Udp_InDatagrams{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "InDatagrams - Datagrams received",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Udp_OutDatagrams{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "OutDatagrams - Datagrams sent",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "UDP In / Out",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "datagrams",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 69
+          },
+          "id": 109,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Udp_InErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "InErrors - UDP Datagrams that could not be delivered to an application",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Udp_NoPorts{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "NoPorts - UDP Datagrams received on a port with no listener",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_UdpLite_InErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "interval": "",
+              "legendFormat": "InErrors Lite - UDPLite Datagrams that could not be delivered to an application",
+              "refId": "C"
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Udp_RcvbufErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "RcvbufErrors - UDP buffer errors received",
+              "refId": "D",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Udp_SndbufErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "SndbufErrors - UDP buffer errors send",
+              "refId": "E",
+              "step": 240
+            }
+          ],
+          "title": "UDP Errors",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "datagrams out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Out.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              },
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Snd.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 79
+          },
+          "id": 299,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Tcp_InSegs{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "instant": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "InSegs - Segments received, including those received in error. This count includes segments received on currently established connections",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Tcp_OutSegs{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "OutSegs - Segments sent, including those on current connections but excluding those containing only retransmitted octets",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "TCP In / Out",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 79
+          },
+          "id": 104,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_TcpExt_ListenOverflows{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "ListenOverflows - Times the listen queue of a socket overflowed",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_TcpExt_ListenDrops{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "ListenDrops - SYNs to LISTEN sockets ignored",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_TcpExt_TCPSynRetrans{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "TCPSynRetrans - SYN-SYN/ACK retransmits to break down retransmissions in SYN, fast/timeout retransmits",
+              "refId": "C",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Tcp_RetransSegs{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "interval": "",
+              "legendFormat": "RetransSegs - Segments retransmitted - that is, the number of TCP segments transmitted containing one or more previously transmitted octets",
+              "refId": "D"
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Tcp_InErrs{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "interval": "",
+              "legendFormat": "InErrs - Segments received in error (e.g., bad TCP checksums)",
+              "refId": "E"
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Tcp_OutRsts{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "interval": "",
+              "legendFormat": "OutRsts - Segments sent with RST flag",
+              "refId": "F"
+            }
+          ],
+          "title": "TCP Errors",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "connections",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*MaxConn *./"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#890F02",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.fillOpacity",
+                    "value": 0
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 89
+          },
+          "id": 85,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_netstat_Tcp_CurrEstab{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "CurrEstab - TCP connections for which the current state is either ESTABLISHED or CLOSE- WAIT",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_netstat_Tcp_MaxConn{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "MaxConn - Limit on the total number of TCP connections the entity can support (Dinamic is \"-1\")",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "TCP Connections",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter out (-) / in (+)",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*Sent.*/"
+                },
+                "properties": [
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 89
+          },
+          "id": 91,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_TcpExt_SyncookiesFailed{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "SyncookiesFailed - Invalid SYN cookies received",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_TcpExt_SyncookiesRecv{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "SyncookiesRecv - SYN cookies received",
+              "refId": "B",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_TcpExt_SyncookiesSent{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "SyncookiesSent - SYN cookies sent",
+              "refId": "C",
+              "step": 240
+            }
+          ],
+          "title": "TCP SynCookie",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "connections",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "min": 0,
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 99
+          },
+          "id": 82,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Tcp_ActiveOpens{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "ActiveOpens - TCP connections that have made a direct transition to the SYN-SENT state from the CLOSED state",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "irate(node_netstat_Tcp_PassiveOpens{instance=\"$node\",job=\"$job\"}[$__rate_interval])",
+              "format": "time_series",
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "PassiveOpens - TCP connections that have made a direct transition to the SYN-RCVD state from the LISTEN state",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "TCP Direct Transition",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Network Netstat",
+      "type": "row"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 33
+      },
+      "id": 279,
+      "panels": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "seconds",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "normal"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "s"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 0,
+            "y": 50
+          },
+          "id": 40,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_scrape_collector_duration_seconds{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{collector}} - Scrape duration",
+              "refId": "A",
+              "step": 240
+            }
+          ],
+          "title": "Node Exporter Scrape Time",
+          "type": "timeseries"
+        },
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "description": "",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "counter",
+                "axisPlacement": "auto",
+                "barAlignment": 0,
+                "drawStyle": "line",
+                "fillOpacity": 20,
+                "gradientMode": "none",
+                "hideFrom": {
+                  "legend": false,
+                  "tooltip": false,
+                  "viz": false
+                },
+                "lineInterpolation": "linear",
+                "lineStyle": {
+                  "fill": "solid"
+                },
+                "lineWidth": 1,
+                "pointSize": 5,
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "never",
+                "spanNulls": false,
+                "stacking": {
+                  "group": "A",
+                  "mode": "none"
+                },
+                "thresholdsStyle": {
+                  "mode": "off"
+                }
+              },
+              "links": [],
+              "mappings": [],
+              "thresholds": {
+                "mode": "absolute",
+                "steps": [
+                  {
+                    "color": "green"
+                  },
+                  {
+                    "color": "red",
+                    "value": 80
+                  }
+                ]
+              },
+              "unit": "short"
+            },
+            "overrides": [
+              {
+                "matcher": {
+                  "id": "byRegexp",
+                  "options": "/.*error.*/"
+                },
+                "properties": [
+                  {
+                    "id": "color",
+                    "value": {
+                      "fixedColor": "#F2495C",
+                      "mode": "fixed"
+                    }
+                  },
+                  {
+                    "id": "custom.transform",
+                    "value": "negative-Y"
+                  }
+                ]
+              }
+            ]
+          },
+          "gridPos": {
+            "h": 10,
+            "w": 12,
+            "x": 12,
+            "y": 50
+          },
+          "id": 157,
+          "links": [],
+          "options": {
+            "legend": {
+              "calcs": [
+                "mean",
+                "lastNotNull",
+                "max",
+                "min"
+              ],
+              "displayMode": "table",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "9.2.0",
+          "targets": [
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_scrape_collector_success{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{collector}} - Scrape success",
+              "refId": "A",
+              "step": 240
+            },
+            {
+              "datasource": {
+                "type": "prometheus",
+                "uid": "${DS_PROMETHEUS}"
+              },
+              "expr": "node_textfile_scrape_error{instance=\"$node\",job=\"$job\"}",
+              "format": "time_series",
+              "hide": false,
+              "interval": "",
+              "intervalFactor": 1,
+              "legendFormat": "{{collector}} - Scrape textfile error (1 = true)",
+              "refId": "B",
+              "step": 240
+            }
+          ],
+          "title": "Node Exporter Scrape",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "prometheus",
+            "uid": "${DS_PROMETHEUS}"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Node Exporter",
+      "type": "row"
+    }
+  ],
+  "refresh": false,
+  "schemaVersion": 37,
+  "style": "dark",
+  "tags": [
+    "linux"
+  ],
+  "templating": {
+    "list": [
+      {
+        "current": {
+          "selected": false,
+          "text": "default",
+          "value": "default"
+        },
+        "hide": 0,
+        "includeAll": false,
+        "label": "datasource",
+        "multi": false,
+        "name": "DS_PROMETHEUS",
+        "options": [],
+        "query": "prometheus",
+        "refresh": 1,
+        "regex": "",
+        "skipUrlSync": false,
+        "type": "datasource"
+      },
+      {
+        "current": {},
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "definition": "",
+        "hide": 0,
+        "includeAll": false,
+        "label": "Job",
+        "multi": false,
+        "name": "job",
+        "options": [],
+        "query": {
+          "query": "label_values(node_uname_info, job)",
+          "refId": "Prometheus-job-Variable-Query"
+        },
+        "refresh": 1,
+        "regex": "",
+        "skipUrlSync": false,
+        "sort": 1,
+        "tagValuesQuery": "",
+        "tagsQuery": "",
+        "type": "query",
+        "useTags": false
+      },
+      {
+        "current": {},
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "definition": "label_values(node_uname_info{job=\"$job\"}, instance)",
+        "hide": 0,
+        "includeAll": false,
+        "label": "Host:",
+        "multi": false,
+        "name": "node",
+        "options": [],
+        "query": {
+          "query": "label_values(node_uname_info{job=\"$job\"}, instance)",
+          "refId": "Prometheus-node-Variable-Query"
+        },
+        "refresh": 1,
+        "regex": "",
+        "skipUrlSync": false,
+        "sort": 1,
+        "tagValuesQuery": "",
+        "tagsQuery": "",
+        "type": "query",
+        "useTags": false
+      },
+      {
+        "current": {
+          "selected": false,
+          "text": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+",
+          "value": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+"
+        },
+        "hide": 2,
+        "includeAll": false,
+        "multi": false,
+        "name": "diskdevices",
+        "options": [
+          {
+            "selected": true,
+            "text": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+",
+            "value": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+"
+          }
+        ],
+        "query": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+",
+        "skipUrlSync": false,
+        "type": "custom"
+      }
+    ]
+  },
+  "time": {
+    "from": "now-24h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "browser",
+  "title": "Node exporter",
+  "uid": "NodeExporterDashboard",
+  "version": 9,
+  "weekStart": ""
+}
\ No newline at end of file
diff --git a/tools/salt-install/config_examples/multi_host/aws/dashboards/postgresql_exporter.json b/tools/salt-install/config_examples/multi_host/aws/dashboards/postgresql_exporter.json
new file mode 100644 (file)
index 0000000..0539b5a
--- /dev/null
@@ -0,0 +1,1826 @@
+{
+  "__inputs": [
+    {
+      "name": "DS_PROMETHEUS",
+      "label": "Prometheus",
+      "description": "",
+      "type": "datasource",
+      "pluginId": "prometheus",
+      "pluginName": "Prometheus"
+    }
+  ],
+  "__elements": [],
+  "__requires": [
+    {
+      "type": "panel",
+      "id": "gauge",
+      "name": "Gauge",
+      "version": ""
+    },
+    {
+      "type": "grafana",
+      "id": "grafana",
+      "name": "Grafana",
+      "version": "8.4.5"
+    },
+    {
+      "type": "panel",
+      "id": "graph",
+      "name": "Graph (old)",
+      "version": ""
+    },
+    {
+      "type": "datasource",
+      "id": "prometheus",
+      "name": "Prometheus",
+      "version": "1.0.0"
+    },
+    {
+      "type": "panel",
+      "id": "stat",
+      "name": "Stat",
+      "version": ""
+    }
+  ],
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "target": {
+          "limit": 100,
+          "matchAny": false,
+          "tags": [],
+          "type": "dashboard"
+        },
+        "type": "dashboard"
+      }
+    ]
+  },
+  "description": "Dashbord works with postgres_exporter for prometheus",
+  "editable": true,
+  "fiscalYearStartMonth": 0,
+  "gnetId": 3742,
+  "graphTooltip": 0,
+  "id": null,
+  "iteration": 1678370081292,
+  "links": [],
+  "liveNow": false,
+  "panels": [
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "max": 200,
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 50
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 90
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 5,
+        "w": 4,
+        "x": 0,
+        "y": 0
+      },
+      "id": 16,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "/^iowait$/",
+          "values": false
+        },
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true
+      },
+      "pluginVersion": "8.4.5",
+      "targets": [
+        {
+          "expr": "sum(rate(node_cpu_seconds_total{instance=\"$host\", mode=\"iowait\"}[$interval])) by (mode)* 100 / scalar(count(node_cpu_seconds_total{mode=\"user\", instance=\"$host\"})) or sum(irate(node_cpu_seconds_total{instance=\"$host\", mode=\"iowait\"}[5m])) by (mode) * 100 / scalar(count(node_cpu_seconds_total{mode=\"user\", instance=\"$host\"}))",
+          "format": "time_series",
+          "interval": "",
+          "intervalFactor": 2,
+          "legendFormat": "{{mode}}",
+          "refId": "A",
+          "step": 4
+        }
+      ],
+      "title": "Current IOwait",
+      "type": "gauge"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "max": 100,
+          "min": 0,
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 50
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 5,
+        "w": 4,
+        "x": 4,
+        "y": 0
+      },
+      "id": 15,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "/^user$/",
+          "values": false
+        },
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true
+      },
+      "pluginVersion": "8.4.5",
+      "targets": [
+        {
+          "expr": "sum(rate(node_cpu_seconds_total{instance=\"$host\", mode=\"user\"}[$interval])) by (mode)* 100 / scalar(count(node_cpu_seconds_total{mode=\"user\", instance=\"$host\"})) or sum(irate(node_cpu_seconds_total{instance=\"$host\", mode=\"user\"}[5m])) by (mode) * 100 / scalar(count(node_cpu_seconds_total{mode=\"user\", instance=\"$host\"}))",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 2,
+          "legendFormat": "{{mode}}",
+          "refId": "A",
+          "step": 4
+        }
+      ],
+      "title": "Current CPU",
+      "type": "gauge"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 50
+              },
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": 90
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 5,
+        "w": 2,
+        "x": 8,
+        "y": 0
+      },
+      "id": 17,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "background",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "/^Used$/",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "8.4.5",
+      "targets": [
+        {
+          "expr": "(node_memory_MemTotal_bytes{instance=\"$host\"} - (node_memory_MemFree_bytes{instance=\"$host\"} + node_memory_Buffers_bytes{instance=\"$host\"} + node_memory_Cached_bytes{instance=\"$host\"})) / node_memory_MemTotal_bytes{instance=\"$host\"} * 100",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Used",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "title": "RAM used",
+      "type": "stat"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "rgba(245, 54, 54, 0.9)",
+                "value": null
+              },
+              {
+                "color": "rgba(237, 129, 40, 0.89)",
+                "value": 50
+              },
+              {
+                "color": "rgba(50, 172, 45, 0.97)",
+                "value": 90
+              }
+            ]
+          },
+          "unit": "percent"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 5,
+        "w": 2,
+        "x": 10,
+        "y": 0
+      },
+      "id": 19,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "background",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "/^Used$/",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "8.4.5",
+      "targets": [
+        {
+          "expr": "(node_memory_Cached_bytes{instance=\"$host\"} * 100) / node_memory_MemTotal_bytes{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Used",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "title": "RAM cached",
+      "type": "stat"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "decbytes"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 5,
+        "w": 4,
+        "x": 12,
+        "y": 0
+      },
+      "id": 10,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "none",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "8.4.5",
+      "targets": [
+        {
+          "expr": "SUM(pg_stat_database_tup_fetched{datname=~\"$datname\", instance=~\"$instance\"})",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "refId": "A",
+          "step": 4
+        }
+      ],
+      "title": "Current fetch data",
+      "type": "stat"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "decbytes"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 5,
+        "w": 4,
+        "x": 16,
+        "y": 0
+      },
+      "id": 11,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "none",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "8.4.5",
+      "targets": [
+        {
+          "expr": "SUM(pg_stat_database_tup_inserted{datname=~\"$datname\", instance=~\"$instance\"})",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "refId": "A",
+          "step": 4
+        }
+      ],
+      "title": "Current insert data",
+      "type": "stat"
+    },
+    {
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [
+            {
+              "options": {
+                "match": "null",
+                "result": {
+                  "text": "N/A"
+                }
+              },
+              "type": "special"
+            }
+          ],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          },
+          "unit": "decbytes"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 5,
+        "w": 4,
+        "x": 20,
+        "y": 0
+      },
+      "id": 12,
+      "links": [],
+      "maxDataPoints": 100,
+      "options": {
+        "colorMode": "none",
+        "graphMode": "none",
+        "justifyMode": "auto",
+        "orientation": "horizontal",
+        "reduceOptions": {
+          "calcs": [
+            "lastNotNull"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "textMode": "auto"
+      },
+      "pluginVersion": "8.4.5",
+      "targets": [
+        {
+          "expr": "SUM(pg_stat_database_tup_updated{datname=~\"$datname\", instance=~\"$instance\"})",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "refId": "A",
+          "step": 4
+        }
+      ],
+      "title": "Current update data",
+      "type": "stat"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 5
+      },
+      "hiddenSeries": false,
+      "id": 5,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": false,
+        "show": true,
+        "sort": "current",
+        "sortDesc": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "pg_stat_database_tup_fetched{datname=~\"$datname\", instance=~\"$instance\"} != 0",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "legendFormat": "{{instance}}, {{datname}}",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Fetch data (SELECT)",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 5
+      },
+      "hiddenSeries": false,
+      "id": 6,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": false,
+        "show": true,
+        "sort": "current",
+        "sortDesc": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "pg_stat_database_tup_inserted{datname=~\"$datname\", instance=~\"$instance\"} != 0",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "legendFormat": "{{instance}}, {{datname}}",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Insert data",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 5
+      },
+      "hiddenSeries": false,
+      "id": 8,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": false,
+        "show": true,
+        "sort": "current",
+        "sortDesc": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "pg_stat_database_tup_updated{datname=~\"$datname\", instance=~\"$instance\"} != 0",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "legendFormat": "{{instance}}, {{datname}}",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Update data",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 12
+      },
+      "hiddenSeries": false,
+      "id": 1,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": false,
+        "show": true,
+        "sort": "current",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": false,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "connected",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 3,
+      "points": true,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "pg_stat_activity_count{datname=~\"$datname\", instance=~\"$instance\", state=\"active\"} !=0",
+          "format": "time_series",
+          "interval": "",
+          "intervalFactor": 2,
+          "legendFormat": "{{instance}},{{datname}},state : {{state}}",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Active sessions",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "decimals": 0,
+          "format": "none",
+          "logBase": 1,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 12
+      },
+      "hiddenSeries": false,
+      "id": 4,
+      "legend": {
+        "alignAsTable": true,
+        "avg": false,
+        "current": true,
+        "max": true,
+        "min": false,
+        "show": true,
+        "sort": "current",
+        "sortDesc": false,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "pg_stat_activity_count{datname=~\"$datname\", instance=~\"$instance\", state=~\"idle|idle in transaction|idle in transaction (aborted)\"} !=0",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "legendFormat": "{{instance}},{{datname}},state : {{state}}",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Idle sessions",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 2,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 12
+      },
+      "hiddenSeries": false,
+      "id": 20,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": false,
+        "show": true,
+        "sort": "current",
+        "sortDesc": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": true,
+      "targets": [
+        {
+          "expr": "rate(node_disk_io_time_seconds_total{device=~\"$device\", instance=\"$host\"}[$interval])/10 or irate(node_disk_io_time_seconds_total{device=~\"$device\", instance=\"$host\"}[5m])/10",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 2,
+          "legendFormat": "{{ device }}",
+          "refId": "A",
+          "step": 4
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Disk IO Utilization",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:333",
+          "format": "percent",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:334",
+          "format": "short",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 19
+      },
+      "hiddenSeries": false,
+      "id": 7,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": false,
+        "show": true,
+        "sort": "current",
+        "sortDesc": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "pg_stat_database_tup_deleted{datname=~\"$datname\", instance=~\"$instance\"} != 0",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "legendFormat": "{{instance}}, {{datname}}",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Delete data",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 19
+      },
+      "hiddenSeries": false,
+      "id": 14,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": false,
+        "show": true,
+        "sort": "total",
+        "sortDesc": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "pg_stat_database_tup_returned{datname=~\"$datname\", instance=~\"$instance\"} != 0",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "legendFormat": "{{instance}}, {{datname}}",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Return data",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 19
+      },
+      "hiddenSeries": false,
+      "id": 3,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "hideEmpty": false,
+        "max": true,
+        "min": false,
+        "rightSide": false,
+        "show": true,
+        "sort": "current",
+        "sortDesc": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "pg_locks_count{datname=~\"$datname\", instance=~\"$instance\", mode=~\"$mode\"} != 0",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "legendFormat": "{{instance}},{{datname}},{{mode}}",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Lock tables",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:386",
+          "decimals": 0,
+          "format": "short",
+          "logBase": 1,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:387",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 26
+      },
+      "hiddenSeries": false,
+      "id": 9,
+      "legend": {
+        "alignAsTable": true,
+        "avg": false,
+        "current": true,
+        "max": false,
+        "min": false,
+        "rightSide": true,
+        "show": true,
+        "sort": "current",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "1 - node_filesystem_free_bytes{instance=~\"$host\", fstype!~\"rootfs|selinuxfs|autofs|rpc_pipefs|tmpfs\"} / node_filesystem_size_bytes{fstype!~\"rootfs|selinuxfs|autofs|rpc_pipefs|tmpfs\"}",
+          "format": "time_series",
+          "interval": "",
+          "intervalFactor": 2,
+          "legendFormat": "{{instance}} , {{mountpoint}}",
+          "refId": "A",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Disk Use",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:173",
+          "format": "percentunit",
+          "logBase": 1,
+          "show": true
+        },
+        {
+          "$$hashKey": "object:174",
+          "format": "short",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": {
+        "type": "prometheus",
+        "uid": "${DS_PROMETHEUS}"
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 26
+      },
+      "hiddenSeries": false,
+      "id": 13,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "hideEmpty": true,
+        "hideZero": true,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "current",
+        "sortDesc": true,
+        "total": true,
+        "values": true
+      },
+      "lines": false,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pluginVersion": "8.4.5",
+      "pointradius": 1,
+      "points": true,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "(rate(node_disk_read_time_seconds_total{device=~\"$device\", instance=\"$host\"}[$interval]) / rate(node_disk_reads_completed_total{device=~\"$device\", instance=\"$host\"}[$interval])) or (irate(node_disk_read_seconds_total{device=~\"$device\", instance=\"$host\"}[5m]) / irate(node_disk_reads_completed_total{device=~\"$device\", instance=\"$host\"}[5m]))",
+          "format": "time_series",
+          "hide": false,
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Read: {{ device }}",
+          "refId": "A",
+          "step": 2
+        },
+        {
+          "expr": "(rate(node_disk_write_time_seconds_total{device=~\"$device\", instance=\"$host\"}[$interval]) / rate(node_disk_writes_completed_total{device=~\"$device\", instance=\"$host\"}[$interval])) or (irate(node_disk_write_time_seconds_total{device=~\"$device\", instance=\"$host\"}[5m]) / irate(node_disk_writes_completed_total{device=~\"$device\", instance=\"$host\"}[5m]))",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Write: {{ device }}",
+          "refId": "B",
+          "step": 2
+        }
+      ],
+      "thresholds": [],
+      "timeRegions": [],
+      "title": "Disk Latency",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "mode": "time",
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:437",
+          "format": "ms",
+          "logBase": 2,
+          "show": true
+        },
+        {
+          "$$hashKey": "object:438",
+          "format": "ms",
+          "logBase": 1,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false
+      }
+    }
+  ],
+  "refresh": false,
+  "schemaVersion": 35,
+  "style": "dark",
+  "tags": [
+    "postgres"
+  ],
+  "templating": {
+    "list": [
+      {
+        "auto": true,
+        "auto_count": 200,
+        "auto_min": "1s",
+        "current": {
+          "selected": false,
+          "text": "auto",
+          "value": "$__auto_interval_interval"
+        },
+        "hide": 0,
+        "label": "Interval",
+        "name": "interval",
+        "options": [
+          {
+            "selected": true,
+            "text": "auto",
+            "value": "$__auto_interval_interval"
+          },
+          {
+            "selected": false,
+            "text": "1s",
+            "value": "1s"
+          },
+          {
+            "selected": false,
+            "text": "5s",
+            "value": "5s"
+          },
+          {
+            "selected": false,
+            "text": "1m",
+            "value": "1m"
+          },
+          {
+            "selected": false,
+            "text": "5m",
+            "value": "5m"
+          },
+          {
+            "selected": false,
+            "text": "1h",
+            "value": "1h"
+          },
+          {
+            "selected": false,
+            "text": "6h",
+            "value": "6h"
+          },
+          {
+            "selected": false,
+            "text": "1d",
+            "value": "1d"
+          }
+        ],
+        "query": "1s,5s,1m,5m,1h,6h,1d",
+        "queryValue": "",
+        "refresh": 2,
+        "skipUrlSync": false,
+        "type": "interval"
+      },
+      {
+        "current": {},
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "definition": "label_values(node_disk_reads_completed_total, instance)",
+        "hide": 0,
+        "includeAll": false,
+        "label": "Host",
+        "multi": false,
+        "name": "host",
+        "options": [],
+        "query": {
+          "query": "label_values(node_disk_reads_completed_total, instance)",
+          "refId": "Prometheus-host-Variable-Query"
+        },
+        "refresh": 1,
+        "regex": "",
+        "skipUrlSync": false,
+        "sort": 1,
+        "tagValuesQuery": "",
+        "tagsQuery": "",
+        "type": "query",
+        "useTags": false
+      },
+      {
+        "current": {},
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "definition": "label_values(node_disk_reads_completed_total{instance=\"$host\", device!~\"dm-.+\"}, device)",
+        "hide": 0,
+        "includeAll": true,
+        "label": "Device",
+        "multi": true,
+        "name": "device",
+        "options": [],
+        "query": {
+          "query": "label_values(node_disk_reads_completed_total{instance=\"$host\", device!~\"dm-.+\"}, device)",
+          "refId": "Prometheus-device-Variable-Query"
+        },
+        "refresh": 1,
+        "regex": "",
+        "skipUrlSync": false,
+        "sort": 1,
+        "tagValuesQuery": "",
+        "tagsQuery": "",
+        "type": "query",
+        "useTags": false
+      },
+      {
+        "current": {},
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "definition": "",
+        "hide": 0,
+        "includeAll": true,
+        "label": "Instance",
+        "multi": true,
+        "name": "instance",
+        "options": [],
+        "query": {
+          "query": "label_values({job=~\"postgresql|postgresql01|postgresql02|postgresql03\"}, instance)",
+          "refId": "Prometheus-instance-Variable-Query"
+        },
+        "refresh": 1,
+        "regex": "",
+        "skipUrlSync": false,
+        "sort": 1,
+        "tagValuesQuery": "",
+        "tagsQuery": "",
+        "type": "query",
+        "useTags": false
+      },
+      {
+        "current": {},
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "definition": "",
+        "hide": 0,
+        "includeAll": true,
+        "label": "Database",
+        "multi": true,
+        "name": "datname",
+        "options": [],
+        "query": {
+          "query": "label_values(datname)",
+          "refId": "Prometheus-datname-Variable-Query"
+        },
+        "refresh": 1,
+        "regex": "",
+        "skipUrlSync": false,
+        "sort": 1,
+        "tagValuesQuery": "",
+        "tagsQuery": "",
+        "type": "query",
+        "useTags": false
+      },
+      {
+        "current": {},
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "definition": "",
+        "hide": 0,
+        "includeAll": true,
+        "label": "Lock table",
+        "multi": true,
+        "name": "mode",
+        "options": [],
+        "query": {
+          "query": "label_values({mode=~\"accessexclusivelock|accesssharelock|exclusivelock|rowexclusivelock|rowsharelock|sharelock|sharerowexclusivelock|shareupdateexclusivelock\"}, mode)",
+          "refId": "Prometheus-mode-Variable-Query"
+        },
+        "refresh": 1,
+        "regex": "",
+        "skipUrlSync": false,
+        "sort": 0,
+        "tagValuesQuery": "",
+        "tagsQuery": "",
+        "type": "query",
+        "useTags": false
+      }
+    ]
+  },
+  "time": {
+    "from": "now-5m",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "",
+  "title": "Postgres exporter",
+  "uid": "PGExporterDashboard",
+  "version": 9,
+  "weekStart": ""
+}
\ No newline at end of file
diff --git a/tools/salt-install/config_examples/multi_host/aws/dashboards/ssl-certificate-monitor.json b/tools/salt-install/config_examples/multi_host/aws/dashboards/ssl-certificate-monitor.json
new file mode 100644 (file)
index 0000000..fc4f8c6
--- /dev/null
@@ -0,0 +1,606 @@
+{
+    "__inputs": [
+      {
+        "name": "DS_PROMETHEUS",
+        "label": "Prometheus",
+        "description": "",
+        "type": "datasource",
+        "pluginId": "prometheus",
+        "pluginName": "Prometheus"
+      }
+    ],
+    "__elements": {},
+    "__requires": [
+      {
+        "type": "grafana",
+        "id": "grafana",
+        "name": "Grafana",
+        "version": "10.1.5"
+      },
+      {
+        "type": "datasource",
+        "id": "prometheus",
+        "name": "Prometheus",
+        "version": "1.0.0"
+      },
+      {
+        "type": "panel",
+        "id": "table",
+        "name": "Table",
+        "version": ""
+      }
+    ],
+    "annotations": {
+      "list": [
+        {
+          "builtIn": 1,
+          "datasource": {
+            "type": "datasource",
+            "uid": "grafana"
+          },
+          "enable": true,
+          "hide": true,
+          "iconColor": "rgba(0, 211, 255, 1)",
+          "name": "Annotations & Alerts",
+          "type": "dashboard"
+        }
+      ]
+    },
+    "description": "",
+    "editable": true,
+    "fiscalYearStartMonth": 0,
+    "gnetId": 13230,
+    "graphTooltip": 0,
+    "id": null,
+    "links": [],
+    "liveNow": false,
+    "panels": [
+      {
+        "datasource": {
+          "type": "prometheus",
+          "uid": "${DS_PROMETHEUS}"
+        },
+        "description": "",
+        "fieldConfig": {
+          "defaults": {
+            "custom": {
+              "align": "auto",
+              "cellOptions": {
+                "type": "auto"
+              },
+              "filterable": false,
+              "inspect": false
+            },
+            "mappings": [],
+            "thresholds": {
+              "mode": "absolute",
+              "steps": [
+                {
+                  "color": "green",
+                  "value": null
+                },
+                {
+                  "color": "red",
+                  "value": 80
+                }
+              ]
+            }
+          },
+          "overrides": [
+            {
+              "matcher": {
+                "id": "byName",
+                "options": "instance"
+              },
+              "properties": [
+                {
+                  "id": "custom.width",
+                  "value": 500
+                },
+                {
+                  "id": "displayName",
+                  "value": "Instance"
+                }
+              ]
+            },
+            {
+              "matcher": {
+                "id": "byName",
+                "options": "Value #B"
+              },
+              "properties": [
+                {
+                  "id": "custom.cellOptions",
+                  "value": {
+                    "mode": "lcd",
+                    "type": "gauge"
+                  }
+                },
+                {
+                  "id": "max",
+                  "value": 0.5
+                },
+                {
+                  "id": "displayName",
+                  "value": "Connect Time"
+                },
+                {
+                  "id": "thresholds",
+                  "value": {
+                    "mode": "absolute",
+                    "steps": [
+                      {
+                        "color": "green",
+                        "value": null
+                      },
+                      {
+                        "color": "#EAB839",
+                        "value": 0.2
+                      },
+                      {
+                        "color": "red",
+                        "value": 0.4
+                      }
+                    ]
+                  }
+                }
+              ]
+            },
+            {
+              "matcher": {
+                "id": "byName",
+                "options": "Value #A"
+              },
+              "properties": [
+                {
+                  "id": "decimals",
+                  "value": 2
+                },
+                {
+                  "id": "displayName",
+                  "value": "Certificate expires in"
+                },
+                {
+                  "id": "thresholds",
+                  "value": {
+                    "mode": "absolute",
+                    "steps": [
+                      {
+                        "color": "semi-dark-red",
+                        "value": null
+                      },
+                      {
+                        "color": "semi-dark-yellow",
+                        "value": __TLS_EXPIRATION_YELLOW__
+                      },
+                      {
+                        "color": "semi-dark-green",
+                        "value": __TLS_EXPIRATION_GREEN__
+                      }
+                    ]
+                  }
+                },
+                {
+                  "id": "custom.cellOptions",
+                  "value": {
+                    "mode": "gradient",
+                    "type": "color-background"
+                  }
+                },
+                {
+                  "id": "custom.width",
+                  "value": 220
+                },
+                {
+                  "id": "custom.align",
+                  "value": "left"
+                },
+                {
+                  "id": "unit",
+                  "value": "dtdurations"
+                }
+              ]
+            },
+            {
+              "matcher": {
+                "id": "byName",
+                "options": "Value #D"
+              },
+              "properties": [
+                {
+                  "id": "displayName",
+                  "value": "HTTP Response"
+                },
+                {
+                  "id": "thresholds",
+                  "value": {
+                    "mode": "absolute",
+                    "steps": [
+                      {
+                        "color": "green",
+                        "value": null
+                      },
+                      {
+                        "color": "#EAB839",
+                        "value": 300
+                      },
+                      {
+                        "color": "red",
+                        "value": 400
+                      }
+                    ]
+                  }
+                },
+                {
+                  "id": "custom.cellOptions",
+                  "value": {
+                    "mode": "gradient",
+                    "type": "color-background"
+                  }
+                },
+                {
+                  "id": "custom.align",
+                  "value": "center"
+                },
+                {
+                  "id": "custom.width",
+                  "value": 150
+                }
+              ]
+            },
+            {
+              "matcher": {
+                "id": "byName",
+                "options": "Value #C"
+              },
+              "properties": [
+                {
+                  "id": "displayName",
+                  "value": "Transfer Time"
+                },
+                {
+                  "id": "max",
+                  "value": 0.5
+                },
+                {
+                  "id": "custom.cellOptions",
+                  "value": {
+                    "mode": "lcd",
+                    "type": "gauge"
+                  }
+                },
+                {
+                  "id": "thresholds",
+                  "value": {
+                    "mode": "absolute",
+                    "steps": [
+                      {
+                        "color": "green",
+                        "value": null
+                      },
+                      {
+                        "color": "#EAB839",
+                        "value": 0.125
+                      },
+                      {
+                        "color": "red",
+                        "value": 0.3
+                      }
+                    ]
+                  }
+                }
+              ]
+            },
+            {
+              "matcher": {
+                "id": "byName",
+                "options": "Value #E"
+              },
+              "properties": [
+                {
+                  "id": "displayName",
+                  "value": "TLS Time"
+                },
+                {
+                  "id": "custom.cellOptions",
+                  "value": {
+                    "mode": "lcd",
+                    "type": "gauge"
+                  }
+                },
+                {
+                  "id": "max",
+                  "value": 1
+                },
+                {
+                  "id": "thresholds",
+                  "value": {
+                    "mode": "absolute",
+                    "steps": [
+                      {
+                        "color": "green",
+                        "value": null
+                      },
+                      {
+                        "color": "#EAB839",
+                        "value": 0.5
+                      },
+                      {
+                        "color": "red",
+                        "value": 0.9
+                      }
+                    ]
+                  }
+                }
+              ]
+            },
+            {
+              "matcher": {
+                "id": "byName",
+                "options": "Value #F"
+              },
+              "properties": [
+                {
+                  "id": "displayName",
+                  "value": "Processing Time"
+                },
+                {
+                  "id": "max",
+                  "value": 0.5
+                },
+                {
+                  "id": "custom.cellOptions",
+                  "value": {
+                    "mode": "lcd",
+                    "type": "gauge"
+                  }
+                },
+                {
+                  "id": "thresholds",
+                  "value": {
+                    "mode": "absolute",
+                    "steps": [
+                      {
+                        "color": "green",
+                        "value": null
+                      },
+                      {
+                        "color": "#EAB839",
+                        "value": 0.25
+                      },
+                      {
+                        "color": "red",
+                        "value": 0.4
+                      }
+                    ]
+                  }
+                }
+              ]
+            },
+            {
+              "matcher": {
+                "id": "byName",
+                "options": "Value #G"
+              },
+              "properties": [
+                {
+                  "id": "displayName",
+                  "value": "Resolve Time"
+                },
+                {
+                  "id": "custom.cellOptions",
+                  "value": {
+                    "mode": "lcd",
+                    "type": "gauge"
+                  }
+                },
+                {
+                  "id": "max",
+                  "value": 0.01
+                },
+                {
+                  "id": "thresholds",
+                  "value": {
+                    "mode": "absolute",
+                    "steps": [
+                      {
+                        "color": "green",
+                        "value": null
+                      },
+                      {
+                        "color": "#EAB839",
+                        "value": 0.005
+                      },
+                      {
+                        "color": "red",
+                        "value": 0.009
+                      }
+                    ]
+                  }
+                }
+              ]
+            }
+          ]
+        },
+        "gridPos": {
+          "h": 22,
+          "w": 24,
+          "x": 0,
+          "y": 0
+        },
+        "id": 2,
+        "options": {
+          "cellHeight": "sm",
+          "footer": {
+            "countRows": false,
+            "fields": "",
+            "reducer": [
+              "sum"
+            ],
+            "show": false
+          },
+          "frameIndex": 1,
+          "showHeader": true,
+          "sortBy": [
+            {
+              "desc": false,
+              "displayName": "Certificate expires in"
+            }
+          ]
+        },
+        "pluginVersion": "10.1.5",
+        "targets": [
+          {
+            "datasource": {
+              "type": "prometheus",
+              "uid": "${DS_PROMETHEUS}"
+            },
+            "expr": "probe_ssl_earliest_cert_expiry-time()",
+            "format": "table",
+            "hide": false,
+            "instant": true,
+            "interval": "",
+            "legendFormat": "",
+            "refId": "A"
+          },
+          {
+            "datasource": {
+              "type": "prometheus",
+              "uid": "${DS_PROMETHEUS}"
+            },
+            "expr": "probe_http_status_code",
+            "format": "table",
+            "instant": true,
+            "interval": "",
+            "legendFormat": "",
+            "refId": "D"
+          },
+          {
+            "datasource": {
+              "type": "prometheus",
+              "uid": "${DS_PROMETHEUS}"
+            },
+            "expr": "probe_http_duration_seconds{phase=\"resolve\"}",
+            "format": "table",
+            "instant": true,
+            "interval": "",
+            "legendFormat": "",
+            "refId": "G"
+          },
+          {
+            "datasource": {
+              "type": "prometheus",
+              "uid": "${DS_PROMETHEUS}"
+            },
+            "expr": "probe_http_duration_seconds{phase=\"connect\"}",
+            "format": "table",
+            "instant": true,
+            "interval": "",
+            "legendFormat": "",
+            "refId": "B"
+          },
+          {
+            "datasource": {
+              "type": "prometheus",
+              "uid": "${DS_PROMETHEUS}"
+            },
+            "expr": "probe_http_duration_seconds{phase=\"tls\"}",
+            "format": "table",
+            "instant": true,
+            "interval": "",
+            "legendFormat": "",
+            "refId": "E"
+          },
+          {
+            "datasource": {
+              "type": "prometheus",
+              "uid": "${DS_PROMETHEUS}"
+            },
+            "expr": "probe_http_duration_seconds{phase=\"processing\"}",
+            "format": "table",
+            "instant": true,
+            "interval": "",
+            "legendFormat": "",
+            "refId": "F"
+          },
+          {
+            "datasource": {
+              "type": "prometheus",
+              "uid": "${DS_PROMETHEUS}"
+            },
+            "expr": "probe_http_duration_seconds{phase=\"transfer\"}",
+            "format": "table",
+            "instant": true,
+            "interval": "",
+            "legendFormat": "",
+            "refId": "C"
+          }
+        ],
+        "title": "Certificate & Connection Monitoring",
+        "transformations": [
+          {
+            "id": "seriesToColumns",
+            "options": {
+              "byField": "instance"
+            }
+          },
+          {
+            "id": "organize",
+            "options": {
+              "excludeByName": {
+                "Time": true,
+                "Time 1": true,
+                "Time 2": true,
+                "Time 3": true,
+                "Time 4": true,
+                "Time 5": true,
+                "Time 6": true,
+                "Time 7": true,
+                "__name__": true,
+                "__name__ 1": true,
+                "__name__ 2": true,
+                "__name__ 3": true,
+                "__name__ 4": true,
+                "__name__ 5": true,
+                "__name__ 6": true,
+                "job": true,
+                "job 1": true,
+                "job 2": true,
+                "job 3": true,
+                "job 4": true,
+                "job 5": true,
+                "job 6": true,
+                "job 7": true,
+                "phase": true,
+                "phase 1": true,
+                "phase 2": true,
+                "phase 3": true,
+                "phase 4": true,
+                "phase 5": true
+              },
+              "indexByName": {},
+              "renameByName": {}
+            }
+          }
+        ],
+        "type": "table"
+      }
+    ],
+    "refresh": "",
+    "schemaVersion": 38,
+    "style": "dark",
+    "tags": [],
+    "templating": {
+      "list": []
+    },
+    "time": {
+      "from": "now-6h",
+      "to": "now"
+    },
+    "timepicker": {},
+    "timezone": "",
+    "title": "SSL Certificate Monitor",
+    "uid": "r8eWoHpGz",
+    "version": 4,
+    "weekStart": ""
+  }
\ No newline at end of file
index 25f68ca047a8482e94b77f5e8e2286765cf967c6..16e686ab8069747227a9a206c100496f2929b8ce 100644 (file)
@@ -3,6 +3,15 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+{%- set _workers = ("__CONTROLLER_MAX_WORKERS__" or grains['num_cpus']*2)|int %}
+{%- set max_workers = [_workers, 8]|max %}
+{%- set max_reqs = ("__CONTROLLER_MAX_QUEUED_REQUESTS__" or 128)|int %}
+{%- set max_tunnels = ("__CONTROLLER_MAX_GATEWAY_TUNNELS__" or 1000)|int %}
+{%- set database_host = ("__DATABASE_EXTERNAL_SERVICE_HOST_OR_IP__" or "__DATABASE_INT_IP__") %}
+{%- set database_name = "__DATABASE_NAME__" %}
+{%- set database_user = "__DATABASE_USER__" %}
+{%- set database_password = "__DATABASE_PASSWORD__" %}
+
 # The variables commented out are the default values that the formula uses.
 # The uncommented values are REQUIRED values. If you don't set them, running
 # this formula will fail.
@@ -46,7 +55,8 @@ arvados:
     #     - ruby-dev
     #     - zlib1g-dev
 
-  # config:
+  config:
+    check_command: /usr/bin/arvados-server config-check -strict=false -config
   #   file: /etc/arvados/config.yml
   #   user: root
   ## IMPORTANT!!!!!
@@ -68,10 +78,10 @@ arvados:
     database:
       # max concurrent connections per arvados server daemon
       # connection_pool_max: 32
-      name: __CLUSTER___arvados
-      host: __DATABASE_INT_IP__
-      password: "__DATABASE_PASSWORD__"
-      user: __CLUSTER___arvados
+      name: {{ database_name }}
+      host: {{ database_host }}
+      password: {{ database_password }}
+      user: {{ database_user }}
       encoding: en_US.utf8
       client_encoding: UTF8
 
@@ -84,7 +94,7 @@ arvados:
     resources:
       virtual_machines:
         shell:
-          name: shell.__CLUSTER__.__DOMAIN__
+          name: shell.__DOMAIN__
           backend: __SHELL_INT_IP__
           port: 4200
 
@@ -97,7 +107,7 @@ arvados:
     ### KEYS
     secrets:
       blob_signing_key: __BLOB_SIGNING_KEY__
-      workbench_secret_key: __WORKBENCH_SECRET_KEY__
+      workbench_secret_key: "deprecated"
 
     Login:
       Test:
@@ -107,29 +117,32 @@ arvados:
             Email: __INITIAL_USER_EMAIL__
             Password: __INITIAL_USER_PASSWORD__
 
+    ### API
+    API:
+      MaxConcurrentRailsRequests: {{ max_workers * 2 }}
+      MaxConcurrentRequests: {{ max_reqs }}
+      MaxQueuedRequests: {{ max_reqs }}
+      MaxGatewayTunnels: {{ max_tunnels }}
+
     ### CONTAINERS
+    {%- set dispatcher_ssh_privkey = "__DISPATCHER_SSH_PRIVKEY__" %}
     Containers:
       MaxRetryAttempts: 10
       CloudVMs:
         ResourceTags:
           Name: __CLUSTER__-compute-node
         BootProbeCommand: 'systemctl is-system-running'
-        ImageID: ami-FIXMEFIXMEFIXMEFI
+        ImageID: __COMPUTE_AMI__
         Driver: ec2
         DriverParameters:
-          Region: FIXME
+          Region: __COMPUTE_AWS_REGION__
           EBSVolumeType: gp3
-          AdminUsername: FIXME
+          AdminUsername: __COMPUTE_USER__
           ### This SG should allow SSH from the dispatcher to the compute nodes
-          SecurityGroupIDs: ['sg-FIXMEFIXMEFIXMEFI']
-          SubnetID: subnet-FIXMEFIXMEFIXMEFI
-          IAMInstanceProfile: __CLUSTER__-keepstore-00-iam-role
-      DispatchPrivateKey: |
-        -----BEGIN OPENSSH PRIVATE KEY-----
-        Read https://doc.arvados.org/install/crunch2-cloud/install-compute-node.html#sshkeypair
-        for details on how to create this key.
-        FIXMEFIXMEFIXME replace this with your dispatcher ssh private key
-        -----END OPENSSH PRIVATE KEY-----
+          SecurityGroupIDs: ['__COMPUTE_SG__']
+          SubnetID: __COMPUTE_SUBNET__
+          IAMInstanceProfile: __CLUSTER__-compute-node-00-iam-role
+      DispatchPrivateKey: {{ dispatcher_ssh_privkey|yaml_dquote }}
 
     ### VOLUMES
     ## This should usually match all your `keepstore` instances
@@ -140,10 +153,14 @@ arvados:
         Replication: 2
         Driver: S3
         DriverParameters:
-          UseAWSS3v2Driver: true
-          Bucket: __CLUSTER__-nyw5e-000000000000000-volume
-          IAMRole: __CLUSTER__-keepstore-00-iam-role
-          Region: FIXME
+          Bucket: __KEEP_AWS_S3_BUCKET__
+          IAMRole: __KEEP_AWS_IAM_ROLE__
+          Region: __KEEP_AWS_REGION__
+          # IMPORTANT: The default value for PrefixLength is 0, and should not
+          # be changed once the volume is in use. For new installations it's
+          # recommended to set it to 3 for better performance.
+          # See: https://doc.arvados.org/install/configure-s3-object-storage.html
+          PrefixLength: 3
 
     Users:
       NewUsersAreActive: true
@@ -153,42 +170,41 @@ arvados:
 
     Services:
       Controller:
-        ExternalURL: 'https://__CLUSTER__.__DOMAIN__:__CONTROLLER_EXT_SSL_PORT__'
+        ExternalURL: 'https://__DOMAIN__:__CONTROLLER_EXT_SSL_PORT__'
         InternalURLs:
           'http://localhost:8003': {}
       DispatchCloud:
         InternalURLs:
-          'http://__CONTROLLER_INT_IP__:9006': {}
+          'http://__DISPATCHER_INT_IP__:9006': {}
       Keepbalance:
         InternalURLs:
-          'http://localhost:9005': {}
+          'http://__KEEPBALANCE_INT_IP__:9005': {}
       Keepproxy:
-        ExternalURL: 'https://keep.__CLUSTER__.__DOMAIN__:__KEEP_EXT_SSL_PORT__'
+        ExternalURL: 'https://keep.__DOMAIN__:__KEEP_EXT_SSL_PORT__'
         InternalURLs:
           'http://localhost:25107': {}
       Keepstore:
         InternalURLs:
           'http://__KEEPSTORE0_INT_IP__:25107': {}
-          'http://__KEEPSTORE1_INT_IP__:25107': {}
       RailsAPI:
         InternalURLs:
           'http://localhost:8004': {}
       WebDAV:
-        ExternalURL: 'https://*.collections.__CLUSTER__.__DOMAIN__:__KEEPWEB_EXT_SSL_PORT__/'
+        ExternalURL: 'https://*.collections.__DOMAIN__:__KEEPWEB_EXT_SSL_PORT__/'
         InternalURLs:
-          'http://localhost:9002': {}
+          'http://__KEEPWEB_INT_IP__:9002': {}
       WebDAVDownload:
-        ExternalURL: 'https://download.__CLUSTER__.__DOMAIN__:__KEEPWEB_EXT_SSL_PORT__'
+        ExternalURL: 'https://download.__DOMAIN__:__KEEPWEB_EXT_SSL_PORT__'
       WebShell:
-        ExternalURL: 'https://webshell.__CLUSTER__.__DOMAIN__:__KEEPWEB_EXT_SSL_PORT__'
+        ExternalURL: 'https://webshell.__DOMAIN__:__KEEPWEB_EXT_SSL_PORT__'
       Websocket:
-        ExternalURL: 'wss://ws.__CLUSTER__.__DOMAIN__/websocket'
+        ExternalURL: 'wss://ws.__DOMAIN__/websocket'
         InternalURLs:
           'http://localhost:8005': {}
       Workbench1:
-        ExternalURL: 'https://workbench.__CLUSTER__.__DOMAIN__:__WORKBENCH1_EXT_SSL_PORT__'
+        ExternalURL: 'https://workbench.__DOMAIN__:__WORKBENCH1_EXT_SSL_PORT__'
       Workbench2:
-        ExternalURL: 'https://workbench2.__CLUSTER__.__DOMAIN__:__WORKBENCH2_EXT_SSL_PORT__'
+        ExternalURL: 'https://workbench2.__DOMAIN__:__WORKBENCH2_EXT_SSL_PORT__'
 
     InstanceTypes:
       t3small:
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/grafana.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/grafana.sls
new file mode 100644 (file)
index 0000000..b466156
--- /dev/null
@@ -0,0 +1,30 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+grafana:
+  pkg:
+    name: grafana
+    use_upstream_archive: false
+    use_upstream_repo: true
+    repo:
+      humanname: grafana_official
+      name: deb https://apt.grafana.com/ stable main
+      file: /etc/apt/sources.list.d/grafana.list
+      key_url: https://apt.grafana.com/gpg.key
+      require_in:
+        - pkg: grafana
+  config:
+    default:
+      instance_name: __DOMAIN__
+    security:
+      admin_user: {{ "__MONITORING_USERNAME__" | yaml_dquote }}
+      admin_password: {{ "__MONITORING_PASSWORD__" | yaml_dquote }}
+      admin_email: {{ "__MONITORING_EMAIL__" | yaml_dquote }}
+    server:
+      protocol: http
+      http_addr: 127.0.0.1
+      http_port: 3000
+      domain: grafana.__DOMAIN__
+      root_url: https://grafana.__DOMAIN__
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_balancer_configuration.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_balancer_configuration.sls
new file mode 100644 (file)
index 0000000..f2de52d
--- /dev/null
@@ -0,0 +1,10 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+### LETSENCRYPT
+letsencrypt:
+  domainsets:
+    __BALANCER_NODENAME__:
+      - __DOMAIN__
index 1f088a8a7d8b670902a20c68bf63310e9e0ea81a..d0ecb54df694bdce1de5f48ff929c07ce18968ab 100644 (file)
@@ -6,5 +6,5 @@
 ### LETSENCRYPT
 letsencrypt:
   domainsets:
-    controller.__CLUSTER__.__DOMAIN__:
-      - __CLUSTER__.__DOMAIN__
+    controller.__DOMAIN__:
+      - __DOMAIN__
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_grafana_configuration.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_grafana_configuration.sls
new file mode 100644 (file)
index 0000000..c92a962
--- /dev/null
@@ -0,0 +1,10 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+### LETSENCRYPT
+letsencrypt:
+  domainsets:
+    grafana.__DOMAIN__:
+      - grafana.__DOMAIN__
index b2945e611f44de3f85a16c46f834b72a7cf45e79..c174386a5a0f878c57c2ec9fff267c7416a083c5 100644 (file)
@@ -6,5 +6,5 @@
 ### LETSENCRYPT
 letsencrypt:
   domainsets:
-    keepproxy.__CLUSTER__.__DOMAIN__:
-      - keep.__CLUSTER__.__DOMAIN__
+    keepproxy.__DOMAIN__:
+      - keep.__DOMAIN__
index f95d7e619d4cb7971dc73026c75a9a35f08ba8d0..f77d17c877274c7aab4f315077dc7d1bf4fc2d99 100644 (file)
@@ -6,8 +6,8 @@
 ### LETSENCRYPT
 letsencrypt:
   domainsets:
-    download.__CLUSTER__.__DOMAIN__:
-      - download.__CLUSTER__.__DOMAIN__
-    collections.__CLUSTER__.__DOMAIN__:
-      - collections.__CLUSTER__.__DOMAIN__
-      - '*.collections.__CLUSTER__.__DOMAIN__'
+    download.__DOMAIN__:
+      - download.__DOMAIN__
+    collections.__DOMAIN__:
+      - collections.__DOMAIN__
+      - '*.collections.__DOMAIN__'
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_prometheus_configuration.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/letsencrypt_prometheus_configuration.sls
new file mode 100644 (file)
index 0000000..a352bc2
--- /dev/null
@@ -0,0 +1,10 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+### LETSENCRYPT
+letsencrypt:
+  domainsets:
+    prometheus.__DOMAIN__:
+      - prometheus.__DOMAIN__
index 17e6422f420f0aad181695b0c40cd18a27d3a28f..538719f7f35e4c42cb80642fa2235ca00357da2f 100644 (file)
@@ -6,5 +6,5 @@
 ### LETSENCRYPT
 letsencrypt:
   domainsets:
-    webshell.__CLUSTER__.__DOMAIN__:
-      - webshell.__CLUSTER__.__DOMAIN__
+    webshell.__DOMAIN__:
+      - webshell.__DOMAIN__
index 6515b3bd0b38e4420a801d1a251ddb37fc153907..f4d2227611d5f47aa3825ad3417f2592024ccf9b 100644 (file)
@@ -6,5 +6,5 @@
 ### LETSENCRYPT
 letsencrypt:
   domainsets:
-    websocket.__CLUSTER__.__DOMAIN__:
-      - ws.__CLUSTER__.__DOMAIN__
+    websocket.__DOMAIN__:
+      - ws.__DOMAIN__
index 2bcf2b7841e5fd553a1370d1be34e59e8e230c83..0ea0179a281db8841d554e7fde142280d041792b 100644 (file)
@@ -6,5 +6,5 @@
 ### LETSENCRYPT
 letsencrypt:
   domainsets:
-    workbench2.__CLUSTER__.__DOMAIN__:
-      - workbench2.__CLUSTER__.__DOMAIN__
+    workbench2.__DOMAIN__:
+      - workbench2.__DOMAIN__
index 9ef348719423c21ab29ea1895ce4fb8db157bf16..cfff3ea8fcf26d04057b8df7620e9e21169969db 100644 (file)
@@ -6,5 +6,5 @@
 ### LETSENCRYPT
 letsencrypt:
   domainsets:
-    workbench.__CLUSTER__.__DOMAIN__:
-      - workbench.__CLUSTER__.__DOMAIN__
+    workbench.__DOMAIN__:
+      - workbench.__DOMAIN__
index 9fbf90dd2c478b0ebf64be603a6e6511f468cf10..bfe0386e9316fe848bccf5e775d452c1462e653c 100644 (file)
@@ -22,7 +22,7 @@ nginx:
             - server_name: api
             - root: /var/www/arvados-api/current/public
             - index:  index.html index.htm
-            - access_log: /var/log/nginx/api.__CLUSTER__.__DOMAIN__-upstream.access.log combined
-            - error_log: /var/log/nginx/api.__CLUSTER__.__DOMAIN__-upstream.error.log
+            - access_log: /var/log/nginx/api.__DOMAIN__-upstream.access.log combined
+            - error_log: /var/log/nginx/api.__DOMAIN__-upstream.error.log
             - passenger_enabled: 'on'
             - client_max_body_size: 128m
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/nginx_balancer_configuration.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/nginx_balancer_configuration.sls
new file mode 100644 (file)
index 0000000..485cf9c
--- /dev/null
@@ -0,0 +1,121 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+{%- import_yaml "ssl_key_encrypted.sls" as ssl_key_encrypted_pillar %}
+{%- set domain = "__DOMAIN__" %}
+{%- set balancer_backends = "__CONTROLLER_NODES__".split(",") %}
+{%- set controller_nr = balancer_backends|length %}
+{%- set disabled_controller = "__DISABLED_CONTROLLER__" %}
+{%- set max_reqs = ("__CONTROLLER_MAX_QUEUED_REQUESTS__" or 128)|int %}
+{%- set max_tunnels = ("__CONTROLLER_MAX_GATEWAY_TUNNELS__" or 1000)|int %}
+
+### NGINX
+nginx:
+  ### SERVER
+  server:
+    config:
+      worker_rlimit_nofile: {{ (max_reqs + max_tunnels) * 5 * controller_nr }}
+      events:
+        worker_connections: {{ (max_reqs + max_tunnels) * 5 * controller_nr }}
+      ### STREAMS
+      http:
+        'geo $external_client':
+          default: 1
+          '127.0.0.0/8': 0
+          '__CLUSTER_INT_CIDR__': 0
+        upstream controller_upstream:
+        {%- for backend in balancer_backends %}
+          {%- if disabled_controller == "" or not backend.startswith(disabled_controller) %}
+          'server {{ backend }}:80': ''
+          {%- else %}
+          'server {{ backend }}:80 down': ''
+          {% endif %}
+        {%- endfor %}
+
+  ### SNIPPETS
+  snippets:
+    # Based on https://ssl-config.mozilla.org/#server=nginx&version=1.14.2&config=intermediate&openssl=1.1.1d&guideline=5.4
+    ssl_hardening_default.conf:
+      - ssl_session_timeout: 1d
+      - ssl_session_cache: 'shared:arvadosSSL:10m'
+      - ssl_session_tickets: 'off'
+
+      # intermediate configuration
+      - ssl_protocols: TLSv1.2 TLSv1.3
+      - ssl_ciphers: ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
+      - ssl_prefer_server_ciphers: 'off'
+
+      # HSTS (ngx_http_headers_module is required) (63072000 seconds)
+      - add_header: 'Strict-Transport-Security "max-age=63072000" always'
+
+      # OCSP stapling
+      - ssl_stapling: 'on'
+      - ssl_stapling_verify: 'on'
+
+      # verify chain of trust of OCSP response using Root CA and Intermediate certs
+      # - ssl_trusted_certificate /path/to/root_CA_cert_plus_intermediates
+
+      # curl https://ssl-config.mozilla.org/ffdhe2048.txt > /path/to/dhparam
+      # - ssl_dhparam: /path/to/dhparam
+
+      # replace with the IP address of your resolver
+      # - resolver: 127.0.0.1
+
+  ### SITES
+  servers:
+    managed:
+      # Remove default webserver
+      default:
+        enabled: false
+      ### DEFAULT
+      arvados_balancer_default.conf:
+        enabled: true
+        overwrite: true
+        config:
+          - server:
+            - server_name: {{ domain }}
+            - listen:
+              - 80 default
+            - location /.well-known:
+              - root: /var/www
+            - location /:
+              - return: '301 https://$host$request_uri'
+
+      arvados_balancer_ssl.conf:
+        enabled: true
+        overwrite: true
+        requires:
+          __CERT_REQUIRES__
+        config:
+          - server:
+            - server_name: {{ domain }}
+            - listen:
+              - __CONTROLLER_EXT_SSL_PORT__ http2 ssl
+            - index: index.html index.htm
+            - location /:
+              - proxy_pass: 'http://controller_upstream'
+              - proxy_read_timeout: 300
+              - proxy_connect_timeout: 90
+              - proxy_redirect: 'off'
+              - proxy_set_header: X-Forwarded-Proto https
+              - proxy_set_header: 'Host $http_host'
+              - proxy_set_header: 'X-Real-IP $remote_addr'
+              - proxy_set_header: 'X-Forwarded-For $proxy_add_x_forwarded_for'
+              - proxy_set_header: 'X-External-Client $external_client'
+              - proxy_set_header: 'Upgrade $http_upgrade'
+              - proxy_set_header: 'Connection "upgrade"'
+              - proxy_max_temp_file_size: 0
+              - proxy_request_buffering: 'off'
+              - proxy_buffering: 'off'
+              - proxy_http_version: '1.1'
+            - include: snippets/ssl_hardening_default.conf
+            - ssl_certificate: __CERT_PEM__
+            - ssl_certificate_key: __CERT_KEY__
+            {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
+            - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
+            {%- endif %}
+            - access_log: /var/log/nginx/{{ domain }}.access.log combined
+            - error_log: /var/log/nginx/{{ domain }}.error.log
+            - client_max_body_size: 128m
index b349ded3281ac9acc3e52b733cff79b5c3a518be..1c10847f76a9b199894d45e70c0da3cda6dfb4a8 100644 (file)
@@ -15,7 +15,7 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: '~^(.*\.)?collections\.__CLUSTER__\.__DOMAIN__'
+            - server_name: '~^(.*\.)?collections\.__DOMAIN__'
             - listen:
               - 80
             - location /:
@@ -29,7 +29,7 @@ nginx:
           __CERT_REQUIRES__
         config:
           - server:
-            - server_name: '~^(.*\.)?collections\.__CLUSTER__\.__DOMAIN__'
+            - server_name: '~^(.*\.)?collections\.__DOMAIN__'
             - listen:
               - __KEEPWEB_EXT_SSL_PORT__ http2 ssl
             - index: index.html index.htm
@@ -52,5 +52,5 @@ nginx:
             {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
             - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
             {%- endif %}
-            - access_log: /var/log/nginx/collections.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/collections.__CLUSTER__.__DOMAIN__.error.log
+            - access_log: /var/log/nginx/collections.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/collections.__DOMAIN__.error.log
index a48810e833cded5703adfcabe67104c5526e494f..5bd67a6ce4b1b7bbeeef6dd7744f902cec85eff3 100644 (file)
@@ -4,6 +4,8 @@
 # SPDX-License-Identifier: AGPL-3.0
 
 {%- import_yaml "ssl_key_encrypted.sls" as ssl_key_encrypted_pillar %}
+{%- set balanced_controller = ("__ENABLE_BALANCER__"|to_bool) %}
+{%- set server_name = grains['fqdn'] if balanced_controller else "__DOMAIN__" %}
 
 ### NGINX
 nginx:
@@ -28,14 +30,36 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: __CLUSTER__.__DOMAIN__
+            - server_name: {{ server_name }}
             - listen:
               - 80 default
             - location /.well-known:
               - root: /var/www
+            {%- if balanced_controller %}
+            {%- set balancer_ip = salt['cmd.run']("getent hosts __BALANCER_NODENAME__ | awk '{print $1 ; exit}'", python_shell=True) %}
+            {%- set prometheus_ip = salt['cmd.run']("getent hosts __PROMETHEUS_NODENAME__ | awk '{print $1 ; exit}'", python_shell=True) %}
+            - index: index.html index.htm
+            - location /:
+              - allow: {{ balancer_ip }}
+              - allow: {{ prometheus_ip }}
+              - deny: all
+              - proxy_pass: 'http://controller_upstream'
+              - proxy_read_timeout: 300
+              - proxy_connect_timeout: 90
+              - proxy_redirect: 'off'
+              - proxy_max_temp_file_size: 0
+              - proxy_request_buffering: 'off'
+              - proxy_buffering: 'off'
+              - proxy_http_version: '1.1'
+            - access_log: /var/log/nginx/{{ server_name }}.access.log combined
+            - error_log: /var/log/nginx/{{ server_name }}.error.log
+            - client_max_body_size: 128m
+            {%- else %}
             - location /:
               - return: '301 https://$host$request_uri'
+            {%- endif %}
 
+      {%- if not balanced_controller %}
       arvados_controller_ssl.conf:
         enabled: true
         overwrite: true
@@ -43,7 +67,7 @@ nginx:
           __CERT_REQUIRES__
         config:
           - server:
-            - server_name: __CLUSTER__.__DOMAIN__
+            - server_name: {{ server_name }}
             - listen:
               - __CONTROLLER_EXT_SSL_PORT__ http2 ssl
             - index: index.html index.htm
@@ -69,6 +93,7 @@ nginx:
             {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
             - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
             {%- endif %}
-            - access_log: /var/log/nginx/controller.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/controller.__CLUSTER__.__DOMAIN__.error.log
+            - access_log: /var/log/nginx/{{ server_name }}.access.log combined
+            - error_log: /var/log/nginx/{{ server_name }}.error.log
             - client_max_body_size: 128m
+      {%- endif %}
index a183475a461a65ab769758ab7d1a8b252c2508fb..4470a388a951ce09730f3573762b70dc3e6e7625 100644 (file)
@@ -15,7 +15,7 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: download.__CLUSTER__.__DOMAIN__
+            - server_name: download.__DOMAIN__
             - listen:
               - 80
             - location /:
@@ -29,7 +29,7 @@ nginx:
           __CERT_REQUIRES__
         config:
           - server:
-            - server_name: download.__CLUSTER__.__DOMAIN__
+            - server_name: download.__DOMAIN__
             - listen:
               - __KEEPWEB_EXT_SSL_PORT__ http2 ssl
             - index: index.html index.htm
@@ -52,5 +52,5 @@ nginx:
             {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
             - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
             {%- endif %}
-            - access_log: /var/log/nginx/download.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/download.__CLUSTER__.__DOMAIN__.error.log
+            - access_log: /var/log/nginx/download.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/download.__DOMAIN__.error.log
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/nginx_grafana_configuration.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/nginx_grafana_configuration.sls
new file mode 100644 (file)
index 0000000..9e1d726
--- /dev/null
@@ -0,0 +1,62 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+{%- import_yaml "ssl_key_encrypted.sls" as ssl_key_encrypted_pillar %}
+
+### NGINX
+nginx:
+  ### SERVER
+  server:
+    config:
+      ### STREAMS
+      http:
+        upstream grafana_upstream:
+          - server: '127.0.0.1:3000 fail_timeout=10s'
+
+  ### SITES
+  servers:
+    managed:
+      ### GRAFANA
+      grafana:
+        enabled: true
+        overwrite: true
+        config:
+          - server:
+            - server_name: grafana.__DOMAIN__
+            - listen:
+              - 80
+            - location /.well-known:
+              - root: /var/www
+            - location /:
+              - return: '301 https://$host$request_uri'
+
+      grafana-ssl:
+        enabled: true
+        overwrite: true
+        requires:
+          __CERT_REQUIRES__
+        config:
+          - server:
+            - server_name: grafana.__DOMAIN__
+            - listen:
+              - 443 http2 ssl
+            - index: index.html index.htm
+            - location /:
+              - proxy_pass: 'http://grafana_upstream'
+              - proxy_read_timeout: 300
+              - proxy_connect_timeout: 90
+              - proxy_redirect: 'off'
+              - proxy_set_header: X-Forwarded-Proto https
+              - proxy_set_header: 'Host $http_host'
+              - proxy_set_header: 'X-Real-IP $remote_addr'
+              - proxy_set_header: 'X-Forwarded-For $proxy_add_x_forwarded_for'
+            - ssl_certificate: __CERT_PEM__
+            - ssl_certificate_key: __CERT_KEY__
+            - include: snippets/ssl_hardening_default.conf
+            {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
+            - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
+            {%- endif %}
+            - access_log: /var/log/nginx/grafana.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/grafana.__DOMAIN__.error.log
index c8deaebe97c26ea15ec9a43cfd8cb5a06ed78627..63c318fc2487f76f51b5f5f5a8b274158c663b5d 100644 (file)
@@ -23,7 +23,7 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: keep.__CLUSTER__.__DOMAIN__
+            - server_name: keep.__DOMAIN__
             - listen:
               - 80
             - location /:
@@ -36,7 +36,7 @@ nginx:
           __CERT_REQUIRES__
         config:
           - server:
-            - server_name: keep.__CLUSTER__.__DOMAIN__
+            - server_name: keep.__DOMAIN__
             - listen:
               - __KEEP_EXT_SSL_PORT__ http2 ssl
             - index: index.html index.htm
@@ -60,5 +60,5 @@ nginx:
             {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
             - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
             {%- endif %}
-            - access_log: /var/log/nginx/keepproxy.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/keepproxy.__CLUSTER__.__DOMAIN__.error.log
+            - access_log: /var/log/nginx/keepproxy.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/keepproxy.__DOMAIN__.error.log
index 441140e80dff233726dde0c891b0c54a42e1eeac..ae2fe6a32c4f118978aae06a5e3878d82b9babcb 100644 (file)
@@ -12,4 +12,4 @@ nginx:
       ### STREAMS
       http:
         upstream collections_downloads_upstream:
-          - server: 'localhost:9002 fail_timeout=10s'
+          - server: '__KEEPWEB_INT_IP__:9002 fail_timeout=10s'
index c14fbd1f59214263bca6634a20a3b11bf4ea08cf..0c9ef1c36e6ff8629ac5b14158fb4b9d6fa57da4 100644 (file)
 {%- set passenger_ruby = '/usr/local/rvm/wrappers/default/ruby'
                            if grains.osfinger in ('CentOS Linux-7', 'Ubuntu-18.04', 'Debian-10') else
                          '/usr/bin/ruby' %}
+{%- set _workers = ("__CONTROLLER_MAX_WORKERS__" or grains['num_cpus']*2)|int %}
+{%- set max_workers = [_workers, 8]|max %}
+{%- set max_reqs = ("__CONTROLLER_MAX_QUEUED_REQUESTS__" or 128)|int %}
+{%- set max_tunnels = ("__CONTROLLER_MAX_GATEWAY_TUNNELS__" or 1000)|int %}
 
 ### NGINX
 nginx:
@@ -21,6 +25,15 @@ nginx:
   ### PASSENGER
   passenger:
     passenger_ruby: {{ passenger_ruby }}
+    passenger_max_pool_size: {{ max_workers }}
+
+    # Make the passenger queue small (twice the concurrency, so
+    # there's at most one pending request for each busy worker)
+    # because controller reorders requests based on priority, and
+    # won't send more than API.MaxConcurrentRailsRequests to passenger
+    # (which is max_workers * 2), so things that are moved to the head
+    # of the line get processed quickly.
+    passenger_max_request_queue_size: {{ max_workers * 2 + 1 }}
 
   ### SERVER
   server:
@@ -36,36 +49,16 @@ nginx:
       # include: 'modules-enabled/*.conf'
       load_module: {{ passenger_mod }}
       {% endif %}
-      worker_processes: 4
-
-  ### SNIPPETS
-  snippets:
-    # Based on https://ssl-config.mozilla.org/#server=nginx&version=1.14.2&config=intermediate&openssl=1.1.1d&guideline=5.4
-    ssl_hardening_default.conf:
-      - ssl_session_timeout: 1d
-      - ssl_session_cache: 'shared:arvadosSSL:10m'
-      - ssl_session_tickets: 'off'
-
-      # intermediate configuration
-      - ssl_protocols: TLSv1.2 TLSv1.3
-      - ssl_ciphers: ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
-      - ssl_prefer_server_ciphers: 'off'
-
-      # HSTS (ngx_http_headers_module is required) (63072000 seconds)
-      - add_header: 'Strict-Transport-Security "max-age=63072000" always'
-
-      # OCSP stapling
-      - ssl_stapling: 'on'
-      - ssl_stapling_verify: 'on'
-
-      # verify chain of trust of OCSP response using Root CA and Intermediate certs
-      # - ssl_trusted_certificate /path/to/root_CA_cert_plus_intermediates
-
-      # curl https://ssl-config.mozilla.org/ffdhe2048.txt > /path/to/dhparam
-      # - ssl_dhparam: /path/to/dhparam
+      worker_processes: {{ max_workers }}
 
-      # replace with the IP address of your resolver
-      # - resolver: 127.0.0.1
+      # Each client request is up to 3 connections (1 with client, 1 proxy to
+      # controller, then potentially 1 from controller back to
+      # passenger).  Each connection consumes a file descriptor.
+      # That's how we get these calculations
+      # (we're multiplying by 5 instead to be on the safe side)
+      worker_rlimit_nofile: {{ (max_reqs + max_tunnels) * 5 + 1 }}
+      events:
+        worker_connections: {{ (max_reqs + max_tunnels) * 5 + 1 }}
 
   ### SITES
   servers:
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/nginx_prometheus_configuration.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/nginx_prometheus_configuration.sls
new file mode 100644 (file)
index 0000000..5e82a9a
--- /dev/null
@@ -0,0 +1,64 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+{%- import_yaml "ssl_key_encrypted.sls" as ssl_key_encrypted_pillar %}
+
+### NGINX
+nginx:
+  ### SERVER
+  server:
+    config:
+      ### STREAMS
+      http:
+        upstream prometheus_upstream:
+          - server: '127.0.0.1:9090 fail_timeout=10s'
+
+  ### SITES
+  servers:
+    managed:
+      ### PROMETHEUS
+      prometheus:
+        enabled: true
+        overwrite: true
+        config:
+          - server:
+            - server_name: prometheus.__DOMAIN__
+            - listen:
+              - 80
+            - location /.well-known:
+              - root: /var/www
+            - location /:
+              - return: '301 https://$host$request_uri'
+
+      prometheus-ssl:
+        enabled: true
+        overwrite: true
+        requires:
+          __CERT_REQUIRES__
+        config:
+          - server:
+            - server_name: prometheus.__DOMAIN__
+            - listen:
+              - 443 http2 ssl
+            - index: index.html index.htm
+            - location /:
+              - proxy_pass: 'http://prometheus_upstream'
+              - proxy_read_timeout: 300
+              - proxy_connect_timeout: 90
+              - proxy_redirect: 'off'
+              - proxy_set_header: X-Forwarded-Proto https
+              - proxy_set_header: 'Host $http_host'
+              - proxy_set_header: 'X-Real-IP $remote_addr'
+              - proxy_set_header: 'X-Forwarded-For $proxy_add_x_forwarded_for'
+            - ssl_certificate: __CERT_PEM__
+            - ssl_certificate_key: __CERT_KEY__
+            - include: snippets/ssl_hardening_default.conf
+            {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
+            - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
+            {%- endif %}
+            - auth_basic: '"Restricted Area"'
+            - auth_basic_user_file: htpasswd
+            - access_log: /var/log/nginx/prometheus.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/prometheus.__DOMAIN__.error.log
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/nginx_snippets.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/nginx_snippets.sls
new file mode 100644 (file)
index 0000000..dfe17b5
--- /dev/null
@@ -0,0 +1,35 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+### NGINX
+nginx:
+  ### SNIPPETS
+  snippets:
+    # Based on https://ssl-config.mozilla.org/#server=nginx&version=1.14.2&config=intermediate&openssl=1.1.1d&guideline=5.4
+    ssl_hardening_default.conf:
+      - ssl_session_timeout: 1d
+      - ssl_session_cache: 'shared:arvadosSSL:10m'
+      - ssl_session_tickets: 'off'
+
+      # intermediate configuration
+      - ssl_protocols: TLSv1.2 TLSv1.3
+      - ssl_ciphers: ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
+      - ssl_prefer_server_ciphers: 'off'
+
+      # HSTS (ngx_http_headers_module is required) (63072000 seconds)
+      - add_header: 'Strict-Transport-Security "max-age=63072000" always'
+
+      # OCSP stapling
+      - ssl_stapling: 'on'
+      - ssl_stapling_verify: 'on'
+
+      # verify chain of trust of OCSP response using Root CA and Intermediate certs
+      # - ssl_trusted_certificate /path/to/root_CA_cert_plus_intermediates
+
+      # curl https://ssl-config.mozilla.org/ffdhe2048.txt > /path/to/dhparam
+      # - ssl_dhparam: /path/to/dhparam
+
+      # replace with the IP address of your resolver
+      # - resolver: 127.0.0.1
index 3a0a23d95f31b6e27a2b43f2c8934f0c67d5aa92..41471ab7a335e24d67f034715472109cec5d439f 100644 (file)
@@ -14,7 +14,7 @@ nginx:
       ### STREAMS
       http:
         upstream webshell_upstream:
-          - server: 'shell.__CLUSTER__.__DOMAIN__:4200 fail_timeout=10s'
+          - server: 'shell.__DOMAIN__:4200 fail_timeout=10s'
 
   ### SITES
   servers:
@@ -24,7 +24,7 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: webshell.__CLUSTER__.__DOMAIN__
+            - server_name: webshell.__DOMAIN__
             - listen:
               - 80
             - location /:
@@ -37,11 +37,11 @@ nginx:
           __CERT_REQUIRES__
         config:
           - server:
-            - server_name: webshell.__CLUSTER__.__DOMAIN__
+            - server_name: webshell.__DOMAIN__
             - listen:
               - __WEBSHELL_EXT_SSL_PORT__ http2 ssl
             - index: index.html index.htm
-            - location /shell.__CLUSTER__.__DOMAIN__:
+            - location /shell.__DOMAIN__:
               - proxy_pass: 'http://webshell_upstream'
               - proxy_read_timeout: 90
               - proxy_connect_timeout: 90
@@ -76,6 +76,6 @@ nginx:
             {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
             - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
             {%- endif %}
-            - access_log: /var/log/nginx/webshell.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/webshell.__CLUSTER__.__DOMAIN__.error.log
+            - access_log: /var/log/nginx/webshell.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/webshell.__DOMAIN__.error.log
 
index 36246d751de5e0a10dbd21d5892259b1f9b4b6e9..f80eeb96b6cb3b379bdd0f3615a5a008125b2069 100644 (file)
@@ -23,7 +23,7 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: ws.__CLUSTER__.__DOMAIN__
+            - server_name: ws.__DOMAIN__
             - listen:
               - 80
             - location /:
@@ -36,7 +36,7 @@ nginx:
           __CERT_REQUIRES__
         config:
           - server:
-            - server_name: ws.__CLUSTER__.__DOMAIN__
+            - server_name: ws.__DOMAIN__
             - listen:
               - __CONTROLLER_EXT_SSL_PORT__ http2 ssl
             - index: index.html index.htm
@@ -61,5 +61,5 @@ nginx:
             {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
             - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
             {%- endif %}
-            - access_log: /var/log/nginx/ws.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/ws.__CLUSTER__.__DOMAIN__.error.log
+            - access_log: /var/log/nginx/ws.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/ws.__DOMAIN__.error.log
index 47eafeeece9699e3de228d1e578c39751b3da53d..081be151efd18025c523f8d6af277ffd727edd08 100644 (file)
@@ -21,7 +21,7 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: workbench2.__CLUSTER__.__DOMAIN__
+            - server_name: workbench2.__DOMAIN__
             - listen:
               - 80
             - location /:
@@ -34,22 +34,18 @@ nginx:
           __CERT_REQUIRES__
         config:
           - server:
-            - server_name: workbench2.__CLUSTER__.__DOMAIN__
+            - server_name: workbench2.__DOMAIN__
             - listen:
               - __CONTROLLER_EXT_SSL_PORT__ http2 ssl
-            - index: index.html index.htm
+
             - location /:
-              - root: /var/www/arvados-workbench2/workbench2
-              - try_files: '$uri $uri/ /index.html'
-              - 'if (-f $document_root/maintenance.html)':
-                - return: 503
-            - location /config.json:
-              - return: {{ "200 '" ~ '{"API_HOST":"__CLUSTER__.__DOMAIN__:__CONTROLLER_EXT_SSL_PORT__"}' ~ "'" }}
+              - return: '301 https://workbench.__DOMAIN__$request_uri'
+
             - include: snippets/ssl_hardening_default.conf
             - ssl_certificate: __CERT_PEM__
             - ssl_certificate_key: __CERT_KEY__
             {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
             - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
             {%- endif %}
-            - access_log: /var/log/nginx/workbench2.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/workbench2.__CLUSTER__.__DOMAIN__.error.log
+            - access_log: /var/log/nginx/workbench2.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/workbench2.__DOMAIN__.error.log
index 82fd24756de7ce1c307adb2e3c975ec329b0f38a..822ba4981414a40c70737efda6362d1682535e7e 100644 (file)
@@ -12,15 +12,6 @@ arvados:
 
 ### NGINX
 nginx:
-  ### SERVER
-  server:
-    config:
-
-      ### STREAMS
-      http:
-        upstream workbench_upstream:
-          - server: 'localhost:9000 fail_timeout=10s'
-
   ### SITES
   servers:
     managed:
@@ -30,7 +21,7 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: workbench.__CLUSTER__.__DOMAIN__
+            - server_name: workbench.__DOMAIN__
             - listen:
               - 80
             - location /:
@@ -42,39 +33,92 @@ nginx:
         requires:
           __CERT_REQUIRES__
         config:
+          # Maps WB1 '/actions?uuid=X' URLs to their equivalent on WB2
+          - 'map $request_uri $actions_redirect':
+            - '~^/actions\?uuid=(.*-4zz18-.*)': '/collections/$1'
+            - '~^/actions\?uuid=(.*-j7d0g-.*)': '/projects/$1'
+            - '~^/actions\?uuid=(.*-tpzed-.*)': '/projects/$1'
+            - '~^/actions\?uuid=(.*-7fd4e-.*)': '/workflows/$1'
+            - '~^/actions\?uuid=(.*-xvhdp-.*)': '/processes/$1'
+            - '~^/actions\?uuid=(.*)': '/'
+            - default: 0
+
           - server:
-            - server_name: workbench.__CLUSTER__.__DOMAIN__
+            - server_name: workbench.__DOMAIN__
             - listen:
               - __CONTROLLER_EXT_SSL_PORT__ http2 ssl
             - index: index.html index.htm
+
+    # REDIRECTS FROM WORKBENCH 1 TO WORKBENCH 2
+
+    # Paths that are not redirected because wb1 and wb2 have similar enough paths
+    # that a redirect is pointless and would create a redirect loop.
+    # rewrite ^/api_client_authorizations.* /api_client_authorizations redirect;
+    # rewrite ^/repositories.* /repositories redirect;
+    # rewrite ^/links.* /links redirect;
+    # rewrite ^/projects.* /projects redirect;
+    # rewrite ^/trash /trash redirect;
+
+            # WB1 '/actions?uuid=X' URL Redirects
+            - 'if ($actions_redirect)':
+              - return: '301 $actions_redirect'
+
+    # Redirects that include a uuid
+            - rewrite: '^/work_units/(.*) /processes/$1 redirect'
+            - rewrite: '^/container_requests/(.*) /processes/$1 redirect'
+            - rewrite: '^/users/(.*) /user/$1 redirect'
+            - rewrite: '^/groups/(.*) /group/$1 redirect'
+
+    # Special file download redirects
+            - 'if ($arg_disposition = attachment)':
+              - rewrite: '^/collections/([^/]*)/(.*) /?redirectToDownload=/c=$1/$2? redirect'
+
+            - 'if ($arg_disposition = inline)':
+              - rewrite: '^/collections/([^/]*)/(.*) /?redirectToPreview=/c=$1/$2? redirect'
+
+    # Redirects that go to a roughly equivalent page
+            - rewrite: '^/virtual_machines.* /virtual-machines-admin redirect'
+            - rewrite: '^/users/.*/virtual_machines /virtual-machines-user redirect'
+            - rewrite: '^/authorized_keys.* /ssh-keys-admin redirect'
+            - rewrite: '^/users/.*/ssh_keys /ssh-keys-user redirect'
+            - rewrite: '^/containers.* /all_processes redirect'
+            - rewrite: '^/container_requests /all_processes redirect'
+            - rewrite: '^/job.* /all_processes redirect'
+            - rewrite: '^/users/link_account /link_account redirect'
+            - rewrite: '^/keep_services.* /keep-services redirect'
+            - rewrite: '^/trash_items.* /trash redirect'
+
+    # Redirects that don't have a good mapping and
+    # just go to root.
+            - rewrite: '^/themes.* / redirect'
+            - rewrite: '^/keep_disks.* / redirect'
+            - rewrite: '^/user_agreements.* / redirect'
+            - rewrite: '^/nodes.* / redirect'
+            - rewrite: '^/humans.* / redirect'
+            - rewrite: '^/traits.* / redirect'
+            - rewrite: '^/sessions.* / redirect'
+            - rewrite: '^/logout.* / redirect'
+            - rewrite: '^/logged_out.* / redirect'
+            - rewrite: '^/current_token / redirect'
+            - rewrite: '^/logs.* / redirect'
+            - rewrite: '^/factory_jobs.* / redirect'
+            - rewrite: '^/uploaded_datasets.* / redirect'
+            - rewrite: '^/specimens.* / redirect'
+            - rewrite: '^/pipeline_templates.* / redirect'
+            - rewrite: '^/pipeline_instances.* / redirect'
+
             - location /:
-              - proxy_pass: 'http://workbench_upstream'
-              - proxy_read_timeout: 300
-              - proxy_connect_timeout: 90
-              - proxy_redirect: 'off'
-              - proxy_set_header: X-Forwarded-Proto https
-              - proxy_set_header: 'Host $http_host'
-              - proxy_set_header: 'X-Real-IP $remote_addr'
-              - proxy_set_header: 'X-Forwarded-For $proxy_add_x_forwarded_for'
+              - root: /var/www/arvados-workbench2/workbench2
+              - try_files: '$uri $uri/ /index.html'
+              - 'if (-f $document_root/maintenance.html)':
+                - return: 503
+            - location /config.json:
+              - return: {{ "200 '" ~ '{"API_HOST":"__DOMAIN__:__CONTROLLER_EXT_SSL_PORT__"}' ~ "'" }}
             - include: snippets/ssl_hardening_default.conf
             - ssl_certificate: __CERT_PEM__
             - ssl_certificate_key: __CERT_KEY__
             {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
             - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
             {%- endif %}
-            - access_log: /var/log/nginx/workbench.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/workbench.__CLUSTER__.__DOMAIN__.error.log
-
-      arvados_workbench_upstream:
-        enabled: true
-        overwrite: true
-        config:
-          - server:
-            - listen: 'localhost:9000'
-            - server_name: workbench
-            - root: /var/www/arvados-workbench/current/public
-            - index:  index.html index.htm
-            - passenger_enabled: 'on'
-            # yamllint disable-line rule:line-length
-            - access_log: /var/log/nginx/workbench.__CLUSTER__.__DOMAIN__-upstream.access.log combined
-            - error_log: /var/log/nginx/workbench.__CLUSTER__.__DOMAIN__-upstream.error.log
+            - access_log: /var/log/nginx/workbench2.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/workbench2.__DOMAIN__.error.log
index d6320da24651612e760178fa598bdd0fb6353b83..1f5d8df83d71e116b71aca8b23f4b250f737c6dc 100644 (file)
@@ -3,10 +3,18 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+{%- set domain = "__DOMAIN__" %}
+{%- set controller_nodes = "__CONTROLLER_NODES__".split(",") %}
+{%- set websocket_ip = "__WEBSOCKET_INT_IP__" %}
+{%- set keepbalance_ip = "__KEEPBALANCE_INT_IP__" %}
+{%- set pg_version = "__DATABASE_POSTGRESQL_VERSION__" %}
+
 ### POSTGRESQL
 postgres:
+  pkgs_extra:
+    - postgresql-contrib
   use_upstream_repo: true
-  version: '12'
+  version: {{ pg_version }}
   postgresconf: |-
     listen_addresses = '*'  # listen on all interfaces
   acls:
@@ -15,24 +23,24 @@ postgres:
     - ['host', 'all', 'all', '127.0.0.1/32', 'md5']
     - ['host', 'all', 'all', '::1/128', 'md5']
     - ['host', '__CLUSTER___arvados', '__CLUSTER___arvados', '127.0.0.1/32']
-    - ['host', '__CLUSTER___arvados', '__CLUSTER___arvados', '__CONTROLLER_INT_IP__/32']
+    - ['host', '__CLUSTER___arvados', '__CLUSTER___arvados', '{{ websocket_ip }}/32']
+    - ['host', '__CLUSTER___arvados', '__CLUSTER___arvados', '{{ keepbalance_ip }}/32']
+    {%- for controller_hostname in controller_nodes %}
+    {%- set controller_ip = salt['cmd.run']("getent hosts "+controller_hostname+" | awk '{print $1 ; exit}'", python_shell=True) %}
+    - ['host', '__CLUSTER___arvados', '__CLUSTER___arvados', '{{ controller_ip }}/32']
+    {%- endfor %}
   users:
     __CLUSTER___arvados:
       ensure: present
       password: "__DATABASE_PASSWORD__"
-
-  # tablespaces:
-  #   arvados_tablespace:
-  #     directory: /path/to/some/tbspace/arvados_tbsp
-  #     owner: arvados
-
+    prometheus:
+      ensure: present
   databases:
     __CLUSTER___arvados:
       owner: __CLUSTER___arvados
       template: template0
       lc_ctype: en_US.utf8
       lc_collate: en_US.utf8
-      # tablespace: arvados_tablespace
       schemas:
         public:
           owner: __CLUSTER___arvados
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/prometheus_node_exporter.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/prometheus_node_exporter.sls
new file mode 100644 (file)
index 0000000..74a5664
--- /dev/null
@@ -0,0 +1,17 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+### PROMETHEUS
+prometheus:
+  wanted:
+    component:
+      - node_exporter
+  pkg:
+    use_upstream_repo: true
+    component:
+      node_exporter:
+        service:
+          args:
+            collector.textfile.directory: /var/lib/prometheus/node-exporter
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/prometheus_pg_exporter.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/prometheus_pg_exporter.sls
new file mode 100644 (file)
index 0000000..62f654e
--- /dev/null
@@ -0,0 +1,14 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+prometheus_pg_exporter:
+  enabled: true
+
+### PROMETHEUS
+prometheus:
+  wanted:
+    component:
+      - postgres_exporter
+      - node_exporter
diff --git a/tools/salt-install/config_examples/multi_host/aws/pillars/prometheus_server.sls b/tools/salt-install/config_examples/multi_host/aws/pillars/prometheus_server.sls
new file mode 100644 (file)
index 0000000..e6714ae
--- /dev/null
@@ -0,0 +1,233 @@
+---
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+{%- set controller_nodes = "__CONTROLLER_NODES__".split(',') %}
+{%- set enable_balancer = ("__ENABLE_BALANCER__"|to_bool) %}
+{%- set data_retention_time = "__PROMETHEUS_DATA_RETENTION_TIME__" %}
+
+### PROMETHEUS
+prometheus:
+  wanted:
+    component:
+      - prometheus
+      - alertmanager
+      - node_exporter
+      - blackbox_exporter
+  pkg:
+    use_upstream_repo: false
+    use_upstream_archive: true
+    component:
+      blackbox_exporter:
+        config_file: /etc/prometheus/blackbox_exporter.yml
+        config:
+          modules:
+            http_2xx:
+              prober: http
+              timeout: 5s
+              http:
+                valid_http_versions: [HTTP/1.1, HTTP/2]
+                valid_status_codes: [200]
+                method: GET
+                tls_config:
+                  insecure_skip_verify: true # Avoid failures on self-signed certs
+                fail_if_ssl: false
+                fail_if_not_ssl: true
+            http_2xx_mngmt_token:
+              prober: http
+              timeout: 5s
+              http:
+                valid_http_versions: [HTTP/1.1, HTTP/2]
+                valid_status_codes: [200]
+                method: GET
+                bearer_token: __MANAGEMENT_TOKEN__
+                tls_config:
+                  insecure_skip_verify: true # Avoid failures on self-signed certs
+                fail_if_ssl: false
+                fail_if_not_ssl: true
+            http_2xx_basic_auth:
+              prober: http
+              timeout: 5s
+              http:
+                valid_http_versions: [HTTP/1.1, HTTP/2]
+                valid_status_codes: [200]
+                method: GET
+                basic_auth:
+                  username: "__MONITORING_USERNAME__"
+                  password: "__MONITORING_PASSWORD__"
+                tls_config:
+                  insecure_skip_verify: true # Avoid failures on self-signed certs
+                fail_if_ssl: false
+                fail_if_not_ssl: true
+      prometheus:
+        service:
+           args:
+             storage.tsdb.retention.time: {{ data_retention_time }}
+        config:
+          global:
+            scrape_interval: 15s
+            evaluation_interval: 15s
+          rule_files:
+            - rules.yml
+
+          scrape_configs:
+            - job_name: prometheus
+              # metrics_path defaults to /metrics
+              # scheme defaults to http.
+              static_configs:
+              - targets: ['localhost:9090']
+                labels:
+                  instance: mon.__CLUSTER__
+                  cluster: __CLUSTER__
+
+            - job_name: http_probe
+              metrics_path: /probe
+              params:
+                module: [http_2xx]
+              static_configs:
+                - targets: ['https://workbench.__DOMAIN__']
+                  labels:
+                    instance: workbench.__CLUSTER__
+                - targets: ['https://workbench2.__DOMAIN__']
+                  labels:
+                    instance: workbench2.__CLUSTER__
+                - targets: ['https://webshell.__DOMAIN__']
+                  labels:
+                    instance: webshell.__CLUSTER__
+              relabel_configs:
+                - source_labels: [__address__]
+                  target_label: __param_target
+                - source_labels: [__param_target]
+                  target_label: instance
+                - target_label: __address__
+                  replacement: 127.0.0.1:9115          # blackbox exporter.
+
+            - job_name: http_probe_mngmt_token
+              metrics_path: /probe
+              params:
+                module: [http_2xx_mngmt_token]
+              static_configs:
+                - targets: ['https://__DOMAIN__/_health/ping']
+                  labels:
+                    instance: controller.__CLUSTER__
+                - targets: ['https://download.__DOMAIN__/_health/ping']
+                  labels:
+                    instance: download.__CLUSTER__
+                - targets: ['https://ws.__DOMAIN__/_health/ping']
+                  labels:
+                    instance: ws.__CLUSTER__
+              relabel_configs:
+                - source_labels: [__address__]
+                  target_label: __param_target
+                - source_labels: [__param_target]
+                  target_label: instance
+                - target_label: __address__
+                  replacement: 127.0.0.1:9115          # blackbox exporter.
+
+            - job_name: http_probe_basic_auth
+              metrics_path: /probe
+              params:
+                module: [http_2xx_basic_auth]
+              static_configs:
+                - targets: ['https://grafana.__DOMAIN__']
+                  labels:
+                    instance: grafana.__CLUSTER__
+                - targets: ['https://prometheus.__DOMAIN__']
+                  labels:
+                    instance: prometheus.__CLUSTER__
+              relabel_configs:
+                - source_labels: [__address__]
+                  target_label: __param_target
+                - source_labels: [__param_target]
+                  target_label: instance
+                - target_label: __address__
+                  replacement: 127.0.0.1:9115          # blackbox exporter.
+
+            ## Arvados unique jobs
+            - job_name: arvados_ws
+              bearer_token: __MANAGEMENT_TOKEN__
+              scheme: https
+              static_configs:
+                - targets: ['ws.__DOMAIN__:443']
+                  labels:
+                    instance: ws.__CLUSTER__
+                    cluster: __CLUSTER__
+            - job_name: arvados_controller
+              bearer_token: __MANAGEMENT_TOKEN__
+              {%- if enable_balancer %}
+              scheme: http
+              {%- else %}
+              scheme: https
+              {%- endif %}
+              static_configs:
+                {%- if enable_balancer %}
+                  {%- for controller in controller_nodes %}
+                - targets: ['{{ controller }}']
+                  labels:
+                    instance: {{ controller.split('.')[0] }}.__CLUSTER__
+                    cluster: __CLUSTER__
+                  {%- endfor %}
+                {%- else %}
+                - targets: ['__DOMAIN__:443']
+                  labels:
+                    instance: controller.__CLUSTER__
+                    cluster: __CLUSTER__
+                {%- endif %}
+            - job_name: keep_web
+              bearer_token: __MANAGEMENT_TOKEN__
+              scheme: https
+              static_configs:
+                - targets: ['keep.__DOMAIN__:443']
+                  labels:
+                    instance: keep-web.__CLUSTER__
+                    cluster: __CLUSTER__
+            - job_name: keep_balance
+              bearer_token: __MANAGEMENT_TOKEN__
+              static_configs:
+                - targets: ['__KEEPBALANCE_INT_IP__:9005']
+                  labels:
+                    instance: keep-balance.__CLUSTER__
+                    cluster: __CLUSTER__
+            - job_name: keepstore
+              bearer_token: __MANAGEMENT_TOKEN__
+              static_configs:
+                - targets: ['__KEEPSTORE0_INT_IP__:25107']
+                  labels:
+                    instance: keep0.__CLUSTER__
+                    cluster: __CLUSTER__
+            - job_name: arvados_dispatch_cloud
+              bearer_token: __MANAGEMENT_TOKEN__
+              static_configs:
+                - targets: ['__DISPATCHER_INT_IP__:9006']
+                  labels:
+                    instance: arvados-dispatch-cloud.__CLUSTER__
+                    cluster: __CLUSTER__
+
+            {%- if "__DATABASE_INT_IP__" != "" %}
+            # Database
+            - job_name: postgresql
+              static_configs:
+                - targets: [
+                    '__DATABASE_INT_IP__:9187',
+                    '__DATABASE_INT_IP__:3903'
+                  ]
+                  labels:
+                    instance: database.__CLUSTER__
+                    cluster: __CLUSTER__
+            {%- endif %}
+
+            # Nodes
+            {%- set node_list = "__NODELIST__".split(',') %}
+            {%- set nodes = [] %}
+            {%- for node in node_list %}
+              {%- set _ = nodes.append(node.split('.')[0]) %}
+            {%- endfor %}
+            - job_name: node
+              static_configs:
+                {% for node in nodes %}
+                - targets: [ "{{ node }}.__DOMAIN__:9100" ]
+                  labels:
+                    instance: "{{ node }}.__CLUSTER__"
+                    cluster: __CLUSTER__
+                {% endfor %}
index 5a7d9a269a5817c0c8be6570703b2d48b6f485d0..132a2d63828520740b82f2236e3e889b1435c88a 100644 (file)
@@ -24,18 +24,40 @@ extra_custom_certs_file_directory_certs_dir:
   {%- for cert in certs %}
     {%- set cert_file = 'arvados-' ~ cert ~ '.pem' %}
     {%- set key_file = 'arvados-' ~ cert ~ '.key' %}
-    {% for c in [cert_file, key_file] %}
-extra_custom_certs_file_copy_{{ c }}:
+extra_custom_certs_{{ cert }}_cert_file_copy:
   file.copy:
-    - name: {{ dest_cert_dir }}/{{ c }}
-    - source: {{ orig_cert_dir }}/{{ c }}
+    - name: {{ dest_cert_dir }}/{{ cert_file }}
+    - source: {{ orig_cert_dir }}/{{ cert_file }}
     - force: true
     - user: root
     - group: root
     - mode: 0640
-    - unless: cmp {{ dest_cert_dir }}/{{ c }} {{ orig_cert_dir }}/{{ c }}
+    - unless: cmp {{ dest_cert_dir }}/{{ cert_file }} {{ orig_cert_dir }}/{{ cert_file }}
     - require:
       - file: extra_custom_certs_file_directory_certs_dir
-    {%- endfor %}
+
+extra_custom_certs_{{ cert }}_key_file_copy:
+  file.copy:
+    - name: {{ dest_cert_dir }}/{{ key_file }}
+    - source: {{ orig_cert_dir }}/{{ key_file }}
+    - force: true
+    - user: root
+    - group: root
+    - mode: 0640
+    - unless: cmp {{ dest_cert_dir }}/{{ key_file }} {{ orig_cert_dir }}/{{ key_file }}
+    - require:
+      - file: extra_custom_certs_file_directory_certs_dir
+
+extra_nginx_service_reload_on_{{ cert }}_certs_changes:
+  cmd.run:
+    - name: systemctl reload nginx
+    - require:
+      - file: extra_custom_certs_{{ cert }}_cert_file_copy
+      - file: extra_custom_certs_{{ cert }}_key_file_copy
+    - onchanges:
+      - file: extra_custom_certs_{{ cert }}_cert_file_copy
+      - file: extra_custom_certs_{{ cert }}_key_file_copy
+    - onlyif:
+      - test $(openssl rsa -modulus -noout -in {{ dest_cert_dir }}/{{ key_file }}) == $(openssl x509 -modulus -noout -in {{ dest_cert_dir }}/{{ cert_file }})
   {%- endfor %}
 {%- endif %}
diff --git a/tools/salt-install/config_examples/multi_host/aws/states/grafana_admin_user.sls b/tools/salt-install/config_examples/multi_host/aws/states/grafana_admin_user.sls
new file mode 100644 (file)
index 0000000..6ccc8db
--- /dev/null
@@ -0,0 +1,13 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+{%- set grafana_server = salt['pillar.get']('grafana', {}) %}
+
+{%- if grafana_server %}
+extra_grafana_admin_user:
+  cmd.run:
+    - name: grafana-cli admin reset-admin-password {{ grafana_server.config.security.admin_password }}
+    - require:
+      - service: grafana-service-running-service-running
+{%- endif %}
\ No newline at end of file
diff --git a/tools/salt-install/config_examples/multi_host/aws/states/grafana_dashboards.sls b/tools/salt-install/config_examples/multi_host/aws/states/grafana_dashboards.sls
new file mode 100644 (file)
index 0000000..0e7e208
--- /dev/null
@@ -0,0 +1,48 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+{%- set grafana_server = salt['pillar.get']('grafana', {}) %}
+{%- set grafana_dashboards_orig_dir = '/srv/salt/dashboards' %}
+{%- set grafana_dashboards_dest_dir = '/var/lib/grafana/dashboards' %}
+
+{%- if grafana_server %}
+extra_grafana_dashboard_directory:
+  file.directory:
+    - name: {{ grafana_dashboards_dest_dir }}
+    - require:
+      - pkg: grafana-package-install-pkg-installed
+
+extra_grafana_dashboard_default_yaml:
+  file.managed:
+    - name: /etc/grafana/provisioning/dashboards/default.yaml
+    - contents: |
+        apiVersion: 1
+        providers:
+          - name: 'General'
+            folder: 'Arvados Cluster'
+            type: file
+            options:
+              path: {{ grafana_dashboards_dest_dir }}
+    - require:
+      - pkg: grafana-package-install-pkg-installed
+      - file: extra_grafana_dashboard_directory
+
+extra_grafana_dashboard_files:
+  file.copy:
+    - name: {{ grafana_dashboards_dest_dir }}
+    - source: {{ grafana_dashboards_orig_dir }}
+    - force: true
+    - recurse: true
+    - require:
+      - file: extra_grafana_dashboard_default_yaml
+
+extra_grafana_dashboards_service_restart:
+  cmd.run:
+    - name: systemctl restart grafana-server
+    - require:
+      - file: extra_grafana_dashboard_default_yaml
+    - onchanges:
+      - file: extra_grafana_dashboard_default_yaml
+      - file: extra_grafana_dashboard_files
+{%- endif %}
\ No newline at end of file
diff --git a/tools/salt-install/config_examples/multi_host/aws/states/grafana_datasource.sls b/tools/salt-install/config_examples/multi_host/aws/states/grafana_datasource.sls
new file mode 100644 (file)
index 0000000..c4c0278
--- /dev/null
@@ -0,0 +1,28 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+{%- set grafana_server = salt['pillar.get']('grafana', {}) %}
+
+{%- if grafana_server %}
+extra_grafana_datasource_prometheus:
+  file.managed:
+    - name: /etc/grafana/provisioning/datasources/prometheus.yaml
+    - contents: |
+        apiVersion: 1
+        datasources:
+          - name: Prometheus
+            type: prometheus
+            uid: ArvadosPromDataSource
+            url: http://127.0.0.1:9090
+            is_default: true
+    - require:
+      - pkg: grafana-package-install-pkg-installed
+
+  cmd.run:
+    - name: systemctl restart grafana-server
+    - require:
+      - file: extra_grafana_datasource_prometheus
+    - onchanges:
+      - file: extra_grafana_datasource_prometheus
+{%- endif %}
\ No newline at end of file
index 6e0deb49c67903f1dfa5ddfb3a0d8e9e0b83e4c3..42f492e8119ecdf124dc021a78e5b2ff4ed5fc04 100644 (file)
@@ -8,12 +8,15 @@
 {%- set tpldir = curr_tpldir %}
 
 #CRUDE, but functional
+
+{%- if "__DATABASE_INT_IP__" != "" %}
 extra_extra_hosts_entries_etc_hosts_database_host_present:
   host.present:
     - ip: __DATABASE_INT_IP__
     - names:
       - db.{{ arvados.cluster.name }}.{{ arvados.cluster.domain }}
       - database.{{ arvados.cluster.name }}.{{ arvados.cluster.domain }}
+{%- endif %}
 
 extra_extra_hosts_entries_etc_hosts_api_host_present:
   host.present:
@@ -69,9 +72,3 @@ extra_extra_hosts_entries_etc_hosts_keep0_host_present:
     - ip: __KEEPSTORE0_INT_IP__
     - names:
       - keep0.{{ arvados.cluster.name }}.{{ arvados.cluster.domain }}
-
-extra_extra_hosts_entries_etc_hosts_keep1_host_present:
-  host.present:
-    - ip: __KEEPSTORE1_INT_IP__
-    - names:
-      - keep1.{{ arvados.cluster.name }}.{{ arvados.cluster.domain }}
diff --git a/tools/salt-install/config_examples/multi_host/aws/states/nginx_prometheus_configuration.sls b/tools/salt-install/config_examples/multi_host/aws/states/nginx_prometheus_configuration.sls
new file mode 100644 (file)
index 0000000..412afd4
--- /dev/null
@@ -0,0 +1,22 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+{%- if salt['pillar.get']('nginx:servers:managed:prometheus-ssl') %}
+
+extra_nginx_prometheus_conf_user___MONITORING_USERNAME__:
+  webutil.user_exists:
+    - name: __MONITORING_USERNAME__
+    - password: {{ "__MONITORING_PASSWORD__" | yaml_dquote }}
+    - htpasswd_file: /etc/nginx/htpasswd
+    - options: d
+    - force: true
+    - require:
+      - pkg: extra_nginx_prometheus_conf_pkgs
+      - pkg: nginx_install
+
+extra_nginx_prometheus_conf_pkgs:
+  pkg.installed:
+    - name: apache2-utils
+
+{%- endif %}
\ No newline at end of file
diff --git a/tools/salt-install/config_examples/multi_host/aws/states/prometheus_pg_exporter.sls b/tools/salt-install/config_examples/multi_host/aws/states/prometheus_pg_exporter.sls
new file mode 100644 (file)
index 0000000..dee2099
--- /dev/null
@@ -0,0 +1,82 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+{%- set prometheus_pg_exporter = pillar.get('prometheus_pg_exporter', {'enabled': False}) %}
+
+{%- if prometheus_pg_exporter.enabled %}
+### PACKAGES
+monitoring_required_pkgs:
+  pkg.installed:
+    - name: mtail
+
+### FILES
+prometheus_pg_exporter_etc_default:
+  file.managed:
+    - name: /etc/default/prometheus-postgres-exporter
+    - contents: |
+        ### This file managed by Salt, do not edit by hand!!
+        #
+        # For details, check /usr/share/doc/prometheus-postgres-exporter/README.Debian
+        DATA_SOURCE_NAME='user=prometheus host=/run/postgresql dbname=postgres'
+    - require:
+      - pkg: prometheus-package-install-postgres_exporter-installed
+
+mtail_postgresql_conf:
+  file.managed:
+    - name: /etc/mtail/postgresql.mtail
+    - contents: |
+        ########################################################################
+        # File managed by Salt.
+        # Your changes will be overwritten.
+        ########################################################################
+
+        # Parser for postgresql's log statement duration
+
+        gauge postgresql_statement_duration_seconds by statement
+
+        /^/ +
+        /(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (\w+)) / + # 2019-01-16 16:53:45 GMT
+        /LOG: +duration: / +
+        /(?P<duration>[0-9\.]+) ms/ + # 153.967 ms
+        /(.*?): (?P<statement>.+)/ + # statement: SELECT COUNT(*) FROM (SELECT rolname FROM pg_roles WHERE rolname='arvados') count
+        /$/ {
+          strptime($timestamp, "2006-01-02 15:04:05 MST") # for tests
+
+          postgresql_statement_duration_seconds[$statement] = $duration / 1000
+        }
+    - require:
+      - pkg: monitoring_required_pkgs
+
+mtail_etc_default:
+  file.managed:
+    - name: /etc/default/mtail
+    - contents: |
+        ### This file managed by Salt, do not edit by hand!!
+        #
+        ENABLED=true
+        # List of files to monitor (mandatory).
+        LOGS=/var/log/postgresql/postgresql*log
+    - require:
+      - pkg: monitoring_required_pkgs
+
+### SERVICES
+prometheus_pg_exporter_service:
+  service.running:
+    - name: prometheus-postgres-exporter
+    - enable: true
+    - require:
+      - pkg: prometheus-package-install-postgres_exporter-installed
+    - watch:
+      - file: /etc/default/prometheus-postgres-exporter
+
+mtail_service:
+  service.running:
+    - name: mtail
+    - enable: true
+    - require:
+      - pkg: monitoring_required_pkgs
+    - watch:
+      - file: mtail_postgresql_conf
+      - file: mtail_etc_default
+{%- endif %}
\ No newline at end of file
diff --git a/tools/salt-install/config_examples/multi_host/aws/states/workbench1_uninstall.sls b/tools/salt-install/config_examples/multi_host/aws/states/workbench1_uninstall.sls
new file mode 100644 (file)
index 0000000..02ac0af
--- /dev/null
@@ -0,0 +1,12 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+{%- set curr_tpldir = tpldir %}
+{%- set tpldir = 'arvados' %}
+{%- from "arvados/map.jinja" import arvados with context %}
+{%- set tpldir = curr_tpldir %}
+
+workbench1_pkg_removed:
+  pkg.removed:
+    - name: {{ arvados.workbench.pkg.name }}
\ No newline at end of file
diff --git a/tools/salt-install/config_examples/multi_host/aws/tofs/arvados/shell/config/files/default/shell-pam-shellinabox.tmpl.jinja b/tools/salt-install/config_examples/multi_host/aws/tofs/arvados/shell/config/files/default/shell-pam-shellinabox.tmpl.jinja
new file mode 100644 (file)
index 0000000..f42bde7
--- /dev/null
@@ -0,0 +1,35 @@
+{#
+##########################################################
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: CC-BY-SA-3.0
+#}
+########################################################################
+# File managed by Salt at <{{ source }}>.
+# Your changes will be overwritten.
+########################################################################
+auth       optional   pam_faildelay.so  delay=3000000
+auth [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] pam_securetty.so
+auth       requisite  pam_nologin.so
+session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close
+session       required   pam_env.so readenv=1
+session       required   pam_env.so readenv=1 envfile=/etc/default/locale
+
+# yamllint disable rule:line-length
+auth [success=1 default=ignore] /usr/lib/pam_arvados.so {{ arvados.cluster.domain }} shell.{{ arvados.cluster.domain }}
+# yamllint enable rule:line-length
+auth    requisite            pam_deny.so
+auth    required            pam_permit.so
+
+auth       optional   pam_group.so
+session    required   pam_limits.so
+session    optional   pam_lastlog.so
+session    optional   pam_motd.so  motd=/run/motd.dynamic
+session    optional   pam_motd.so
+session    optional   pam_mail.so standard
+
+@include common-account
+@include common-session
+@include common-password
+
+session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open
diff --git a/tools/salt-install/config_examples/multi_host/aws/tofs/arvados/shell/config/files/default/shell-shellinabox.tmpl.jinja b/tools/salt-install/config_examples/multi_host/aws/tofs/arvados/shell/config/files/default/shell-shellinabox.tmpl.jinja
new file mode 100644 (file)
index 0000000..b600d76
--- /dev/null
@@ -0,0 +1,16 @@
+{#
+##########################################################
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: CC-BY-SA-3.0
+#}
+########################################################################
+# File managed by Salt at <{{ source }}>.
+# Your changes will be overwritten.
+########################################################################
+# Should shellinaboxd start automatically
+SHELLINABOX_DAEMON_START=1
+# TCP port that shellinboxd's webserver listens on
+SHELLINABOX_PORT={{ arvados.shell.shellinabox.service.port }}
+# SSL is disabled because it is terminated in Nginx. Adjust as needed.
+SHELLINABOX_ARGS="--disable-ssl --no-beep --service=/shell.{{ arvados.cluster.domain }}:AUTH:HOME:SHELL"
index 35544730ad633ef8fd209acd77099eefcb421860..271ab502908578c70a7373787cb0d29213a576f5 100644 (file)
@@ -5,6 +5,11 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+{%- set database_host = ("__DATABASE_EXTERNAL_SERVICE_HOST_OR_IP__" or "127.0.0.1") %}
+{%- set database_name = "__DATABASE_NAME__" %}
+{%- set database_user = "__DATABASE_USER__" %}
+{%- set database_password = "__DATABASE_PASSWORD__" %}
+
 # The variables commented out are the default values that the formula uses.
 # The uncommented values are REQUIRED values. If you don't set them, running
 # this formula will fail.
@@ -48,7 +53,8 @@ arvados:
     #     - ruby-dev
     #     - zlib1g-dev
 
-  # config:
+  config:
+    check_command: /usr/bin/arvados-server config-check -strict=false -config
   #   file: /etc/arvados/config.yml
   #   user: root
   ## IMPORTANT!!!!!
@@ -65,19 +71,12 @@ arvados:
     database:
       # max concurrent connections per arvados server daemon
       # connection_pool_max: 32
-      name: __CLUSTER___arvados
-      host: 127.0.0.1
-      password: "__DATABASE_PASSWORD__"
-      user: __CLUSTER___arvados
+      name: {{ database_name }}
+      host: {{ database_host }}
+      password: {{ database_password }}
+      user: {{ database_user }}
       extra_conn_params:
         client_encoding: UTF8
-      # Centos7 does not enable SSL by default, so we disable
-      # it here just for testing of the formula purposes only.
-      # You should not do this in production, and should
-      # configure Postgres certificates correctly
-      {%- if grains.os_family in ('RedHat',) %}
-        sslmode: disable
-      {%- endif %}
 
     tls:
       # certificate: ''
@@ -101,7 +100,7 @@ arvados:
     ### KEYS
     secrets:
       blob_signing_key: __BLOB_SIGNING_KEY__
-      workbench_secret_key: __WORKBENCH_SECRET_KEY__
+      workbench_secret_key: "deprecated"
 
     Login:
       Test:
index 89412e42403d14e7e35b6ad1003b1adef437b449..85e711dc6aea2d5c75c017fc1a2d4bd9db2947c7 100644 (file)
@@ -33,7 +33,7 @@ nginx:
         enabled: true
         overwrite: true
         requires:
-          file: extra_custom_certs_file_copy_arvados-keepproxy.pem
+          file: extra_custom_certs_keepproxy_cert_file_copy
         config:
           - server:
             - server_name: keep.__CLUSTER__.__DOMAIN__
index 5859d4cfa4d3cf33f6c44471b967c5f505bb7f92..daa1f319299db4491970a5a34852f5afa276ad50 100644 (file)
@@ -39,7 +39,7 @@ nginx:
         enabled: true
         overwrite: true
         requires:
-          file: extra_custom_certs_file_copy_arvados-{{ vh }}.pem
+          file: extra_custom_certs_{{ vh }}_cert_file_copy
         config:
           - server:
             - server_name: {{ vh }}.__CLUSTER__.__DOMAIN__
index 1afc7ab80500a575711613cbca7a248cc9be0e26..541921ca31efe41ba3871930ca90e54d961915c3 100644 (file)
@@ -55,7 +55,7 @@ nginx:
         enabled: true
         overwrite: true
         requires:
-          file: extra_custom_certs_file_copy_arvados-webshell.pem
+          file: extra_custom_certs_webshell_cert_file_copy
         config:
           - server:
             - server_name: webshell.__CLUSTER__.__DOMAIN__
index 2a1f241836bf3d3b327e0461fdd63a37a2665d96..f9864f109d5d260a9204aec0525e765e691c810a 100644 (file)
@@ -33,7 +33,7 @@ nginx:
         enabled: true
         overwrite: true
         requires:
-          file: extra_custom_certs_file_copy_arvados-websocket.pem
+          file: extra_custom_certs_websocket_cert_file_copy
         config:
           - server:
             - server_name: ws.__CLUSTER__.__DOMAIN__
index 50c960cbcb3dfd836eec32155eb7318be3574cda..081be151efd18025c523f8d6af277ffd727edd08 100644 (file)
@@ -1,18 +1,14 @@
 ---
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
-# SPDX-License-Identifier: Apache-2.0
+# SPDX-License-Identifier: AGPL-3.0
 
-{%- if grains.os_family in ('RedHat',) %}
-  {%- set group = 'nginx' %}
-{%- else %}
-  {%- set group = 'www-data' %}
-{%- endif %}
+{%- import_yaml "ssl_key_encrypted.sls" as ssl_key_encrypted_pillar %}
 
 ### ARVADOS
 arvados:
   config:
-    group: {{ group }}
+    group: www-data
 
 ### NGINX
 nginx:
@@ -25,11 +21,9 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: workbench2.__CLUSTER__.__DOMAIN__
+            - server_name: workbench2.__DOMAIN__
             - listen:
               - 80
-            - location /.well-known:
-              - root: /var/www
             - location /:
               - return: '301 https://$host$request_uri'
 
@@ -37,22 +31,21 @@ nginx:
         enabled: true
         overwrite: true
         requires:
-          file: extra_custom_certs_file_copy_arvados-workbench2.pem
+          __CERT_REQUIRES__
         config:
           - server:
-            - server_name: workbench2.__CLUSTER__.__DOMAIN__
+            - server_name: workbench2.__DOMAIN__
             - listen:
               - __CONTROLLER_EXT_SSL_PORT__ http2 ssl
-            - index: index.html index.htm
+
             - location /:
-              - root: /var/www/arvados-workbench2/workbench2
-              - try_files: '$uri $uri/ /index.html'
-              - 'if (-f $document_root/maintenance.html)':
-                - return: 503
-            - location /config.json:
-              - return: {{ "200 '" ~ '{"API_HOST":"__CLUSTER__.__DOMAIN__:__CONTROLLER_EXT_SSL_PORT__"}' ~ "'" }}
+              - return: '301 https://workbench.__DOMAIN__$request_uri'
+
             - include: snippets/ssl_hardening_default.conf
-            - ssl_certificate: /etc/nginx/ssl/arvados-workbench2.pem
-            - ssl_certificate_key: /etc/nginx/ssl/arvados-workbench2.key
-            - access_log: /var/log/nginx/workbench2.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/workbench2.__CLUSTER__.__DOMAIN__.error.log
+            - ssl_certificate: __CERT_PEM__
+            - ssl_certificate_key: __CERT_KEY__
+            {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
+            - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
+            {%- endif %}
+            - access_log: /var/log/nginx/workbench2.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/workbench2.__DOMAIN__.error.log
index 90248fcb2b628773b54494655605a825de2bcb26..822ba4981414a40c70737efda6362d1682535e7e 100644 (file)
@@ -3,28 +3,15 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-{%- if grains.os_family in ('RedHat',) %}
-  {%- set group = 'nginx' %}
-{%- else %}
-  {%- set group = 'www-data' %}
-{%- endif %}
+{%- import_yaml "ssl_key_encrypted.sls" as ssl_key_encrypted_pillar %}
 
 ### ARVADOS
 arvados:
   config:
-    group: {{ group }}
+    group: www-data
 
 ### NGINX
 nginx:
-  ### SERVER
-  server:
-    config:
-
-      ### STREAMS
-      http:
-        upstream workbench_upstream:
-          - server: 'workbench.internal:9000 fail_timeout=10s'
-
   ### SITES
   servers:
     managed:
@@ -34,11 +21,9 @@ nginx:
         overwrite: true
         config:
           - server:
-            - server_name: workbench.__CLUSTER__.__DOMAIN__
+            - server_name: workbench.__DOMAIN__
             - listen:
               - 80
-            - location /.well-known:
-              - root: /var/www
             - location /:
               - return: '301 https://$host$request_uri'
 
@@ -46,38 +31,94 @@ nginx:
         enabled: true
         overwrite: true
         requires:
-          file: extra_custom_certs_file_copy_arvados-workbench.pem
+          __CERT_REQUIRES__
         config:
+          # Maps WB1 '/actions?uuid=X' URLs to their equivalent on WB2
+          - 'map $request_uri $actions_redirect':
+            - '~^/actions\?uuid=(.*-4zz18-.*)': '/collections/$1'
+            - '~^/actions\?uuid=(.*-j7d0g-.*)': '/projects/$1'
+            - '~^/actions\?uuid=(.*-tpzed-.*)': '/projects/$1'
+            - '~^/actions\?uuid=(.*-7fd4e-.*)': '/workflows/$1'
+            - '~^/actions\?uuid=(.*-xvhdp-.*)': '/processes/$1'
+            - '~^/actions\?uuid=(.*)': '/'
+            - default: 0
+
           - server:
-            - server_name: workbench.__CLUSTER__.__DOMAIN__
+            - server_name: workbench.__DOMAIN__
             - listen:
               - __CONTROLLER_EXT_SSL_PORT__ http2 ssl
             - index: index.html index.htm
+
+    # REDIRECTS FROM WORKBENCH 1 TO WORKBENCH 2
+
+    # Paths that are not redirected because wb1 and wb2 have similar enough paths
+    # that a redirect is pointless and would create a redirect loop.
+    # rewrite ^/api_client_authorizations.* /api_client_authorizations redirect;
+    # rewrite ^/repositories.* /repositories redirect;
+    # rewrite ^/links.* /links redirect;
+    # rewrite ^/projects.* /projects redirect;
+    # rewrite ^/trash /trash redirect;
+
+            # WB1 '/actions?uuid=X' URL Redirects
+            - 'if ($actions_redirect)':
+              - return: '301 $actions_redirect'
+
+    # Redirects that include a uuid
+            - rewrite: '^/work_units/(.*) /processes/$1 redirect'
+            - rewrite: '^/container_requests/(.*) /processes/$1 redirect'
+            - rewrite: '^/users/(.*) /user/$1 redirect'
+            - rewrite: '^/groups/(.*) /group/$1 redirect'
+
+    # Special file download redirects
+            - 'if ($arg_disposition = attachment)':
+              - rewrite: '^/collections/([^/]*)/(.*) /?redirectToDownload=/c=$1/$2? redirect'
+
+            - 'if ($arg_disposition = inline)':
+              - rewrite: '^/collections/([^/]*)/(.*) /?redirectToPreview=/c=$1/$2? redirect'
+
+    # Redirects that go to a roughly equivalent page
+            - rewrite: '^/virtual_machines.* /virtual-machines-admin redirect'
+            - rewrite: '^/users/.*/virtual_machines /virtual-machines-user redirect'
+            - rewrite: '^/authorized_keys.* /ssh-keys-admin redirect'
+            - rewrite: '^/users/.*/ssh_keys /ssh-keys-user redirect'
+            - rewrite: '^/containers.* /all_processes redirect'
+            - rewrite: '^/container_requests /all_processes redirect'
+            - rewrite: '^/job.* /all_processes redirect'
+            - rewrite: '^/users/link_account /link_account redirect'
+            - rewrite: '^/keep_services.* /keep-services redirect'
+            - rewrite: '^/trash_items.* /trash redirect'
+
+    # Redirects that don't have a good mapping and
+    # just go to root.
+            - rewrite: '^/themes.* / redirect'
+            - rewrite: '^/keep_disks.* / redirect'
+            - rewrite: '^/user_agreements.* / redirect'
+            - rewrite: '^/nodes.* / redirect'
+            - rewrite: '^/humans.* / redirect'
+            - rewrite: '^/traits.* / redirect'
+            - rewrite: '^/sessions.* / redirect'
+            - rewrite: '^/logout.* / redirect'
+            - rewrite: '^/logged_out.* / redirect'
+            - rewrite: '^/current_token / redirect'
+            - rewrite: '^/logs.* / redirect'
+            - rewrite: '^/factory_jobs.* / redirect'
+            - rewrite: '^/uploaded_datasets.* / redirect'
+            - rewrite: '^/specimens.* / redirect'
+            - rewrite: '^/pipeline_templates.* / redirect'
+            - rewrite: '^/pipeline_instances.* / redirect'
+
             - location /:
-              - proxy_pass: 'http://workbench_upstream'
-              - proxy_read_timeout: 300
-              - proxy_connect_timeout: 90
-              - proxy_redirect: 'off'
-              - proxy_set_header: X-Forwarded-Proto https
-              - proxy_set_header: 'Host $http_host'
-              - proxy_set_header: 'X-Real-IP $remote_addr'
-              - proxy_set_header: 'X-Forwarded-For $proxy_add_x_forwarded_for'
+              - root: /var/www/arvados-workbench2/workbench2
+              - try_files: '$uri $uri/ /index.html'
+              - 'if (-f $document_root/maintenance.html)':
+                - return: 503
+            - location /config.json:
+              - return: {{ "200 '" ~ '{"API_HOST":"__DOMAIN__:__CONTROLLER_EXT_SSL_PORT__"}' ~ "'" }}
             - include: snippets/ssl_hardening_default.conf
-            - ssl_certificate: /etc/nginx/ssl/arvados-workbench.pem
-            - ssl_certificate_key: /etc/nginx/ssl/arvados-workbench.key
-            - access_log: /var/log/nginx/workbench.__CLUSTER__.__DOMAIN__.access.log combined
-            - error_log: /var/log/nginx/workbench.__CLUSTER__.__DOMAIN__.error.log
-
-      arvados_workbench_upstream.conf:
-        enabled: true
-        overwrite: true
-        config:
-          - server:
-            - listen: 'workbench.internal:9000'
-            - server_name: workbench
-            - root: /var/www/arvados-workbench/current/public
-            - index:  index.html index.htm
-            - passenger_enabled: 'on'
-            # yamllint disable-line rule:line-length
-            - access_log: /var/log/nginx/workbench.__CLUSTER__.__DOMAIN__-upstream.access.log combined
-            - error_log: /var/log/nginx/workbench.__CLUSTER__.__DOMAIN__-upstream.error.log
+            - ssl_certificate: __CERT_PEM__
+            - ssl_certificate_key: __CERT_KEY__
+            {%- if ssl_key_encrypted_pillar.ssl_key_encrypted.enabled %}
+            - ssl_password_file: {{ '/run/arvados/' | path_join(ssl_key_encrypted_pillar.ssl_key_encrypted.privkey_password_filename) }}
+            {%- endif %}
+            - access_log: /var/log/nginx/workbench2.__DOMAIN__.access.log combined
+            - error_log: /var/log/nginx/workbench2.__DOMAIN__.error.log
index edb961ebaaeccca0899d0c2633ca7c0957369805..ade544764a9e8aa35269bc33669fa4a0833ebb13 100644 (file)
@@ -5,25 +5,9 @@
 
 ### POSTGRESQL
 postgres:
-  # Centos-7's postgres package is too old, so we need to force using upstream's
-  # This is not required in Debian's family as they already ship with PG +11
-  {%- if salt['grains.get']('os_family') == 'RedHat' %}
-  use_upstream_repo: true
-  version: '12'
-
-  pkgs_deps:
-    - libicu
-    - libxslt
-    - systemd-sysv
-
-  pkgs_extra:
-    - postgresql12-contrib
-
-  {%- else %}
   use_upstream_repo: false
   pkgs_extra:
     - postgresql-contrib
-  {%- endif %}
   postgresconf: |-
     listen_addresses = '*'  # listen on all interfaces
     #ssl = on
index 3b2be59f368c353793bec874b9cf9dae1adde896..cf8874c2d59757969b0fd20b8b2071ba40fc50ec 100644 (file)
@@ -15,19 +15,41 @@ extra_custom_certs_file_directory_certs_dir:
 
   {%- for cert in certs %}
     {%- set cert_file = 'arvados-' ~ cert ~ '.pem' %}
-    {#- set csr_file = 'arvados-' ~ cert ~ '.csr' #}
     {%- set key_file = 'arvados-' ~ cert ~ '.key' %}
-    {% for c in [cert_file, key_file] %}
-extra_custom_certs_file_copy_{{ c }}:
+extra_custom_certs_{{ cert }}_cert_file_copy:
   file.copy:
-    - name: {{ dest_cert_dir }}/{{ c }}
-    - source: {{ orig_cert_dir }}/{{ c }}
+    - name: {{ dest_cert_dir }}/{{ cert_file }}
+    - source: {{ orig_cert_dir }}/{{ cert_file }}
     - force: true
     - user: root
     - group: root
-    - unless: cmp {{ dest_cert_dir }}/{{ c }} {{ orig_cert_dir }}/{{ c }}
+    - mode: 0640
+    - unless: cmp {{ dest_cert_dir }}/{{ cert_file }} {{ orig_cert_dir }}/{{ cert_file }}
     - require:
       - file: extra_custom_certs_file_directory_certs_dir
-    {%- endfor %}
+
+extra_custom_certs_{{ cert }}_key_file_copy:
+  file.copy:
+    - name: {{ dest_cert_dir }}/{{ key_file }}
+    - source: {{ orig_cert_dir }}/{{ key_file }}
+    - force: true
+    - user: root
+    - group: root
+    - mode: 0640
+    - unless: cmp {{ dest_cert_dir }}/{{ key_file }} {{ orig_cert_dir }}/{{ key_file }}
+    - require:
+      - file: extra_custom_certs_file_directory_certs_dir
+
+extra_nginx_service_reload_on_{{ cert }}_certs_changes:
+  cmd.run:
+    - name: systemctl reload nginx
+    - require:
+      - file: extra_custom_certs_{{ cert }}_cert_file_copy
+      - file: extra_custom_certs_{{ cert }}_key_file_copy
+    - onchanges:
+      - file: extra_custom_certs_{{ cert }}_cert_file_copy
+      - file: extra_custom_certs_{{ cert }}_key_file_copy
+    - onlyif:
+      - test $(openssl rsa -modulus -noout -in {{ dest_cert_dir }}/{{ key_file }}) == $(openssl x509 -modulus -noout -in {{ dest_cert_dir }}/{{ cert_file }})
   {%- endfor %}
 {%- endif %}
index 5f83582bc3c32e496c555383c2ad004ec312c8ec..a8b487e29ad239080d855778b5a4dc1ab6a211a3 100644 (file)
@@ -46,24 +46,11 @@ extra_snakeoil_certs_dependencies_pkg_installed:
       - openssl
       - ca-certificates
 
-# Remove the RANDFILE parameter in openssl.cnf as it makes openssl fail in Ubuntu 18.04
-# Saving and restoring the rng state is not necessary anymore in the openssl 1.1.1
-# random generator, cf
-#   https://github.com/openssl/openssl/issues/7754
-#
-extra_snakeoil_certs_file_comment_etc_openssl_conf:
-  file.comment:
-    - name: /etc/ssl/openssl.cnf
-    - regex: ^RANDFILE.*
-    - onlyif: grep -q ^RANDFILE /etc/ssl/openssl.cnf
-    - require_in:
-      - cmd: extra_snakeoil_certs_arvados_snakeoil_ca_cmd_run
-
 extra_snakeoil_certs_arvados_snakeoil_ca_cmd_run:
   # Taken from https://github.com/arvados/arvados/blob/master/tools/arvbox/lib/arvbox/docker/service/certificate/run
   cmd.run:
     - name: |
-        # These dirs are not to CentOS-ish, but this is a helper script
+        # These dirs are not too CentOS-ish, but this is a helper script
         # and they should be enough
         /bin/bash -c "mkdir -p /etc/ssl/certs/ /etc/ssl/private/ && \
         openssl req \
@@ -173,8 +160,8 @@ extra_snakeoil_certs_arvados_snakeoil_cert_{{ vh }}_cmd_run:
       - pkg: extra_snakeoil_certs_dependencies_pkg_installed
       - cmd: extra_snakeoil_certs_arvados_snakeoil_ca_cmd_run
     - require_in:
-      - file: extra_custom_certs_file_copy_arvados-{{ vh }}.pem
-      - file: extra_custom_certs_file_copy_arvados-{{ vh }}.key
+      - file: extra_custom_certs_{{ vh }}_cert_file_copy
+      - file: extra_custom_certs_{{ vh }}_key_file_copy
 
   {%- if grains.get('os_family') == 'Debian' %}
 extra_snakeoil_certs_certs_permissions_{{ vh}}_cmd_run:
diff --git a/tools/salt-install/config_examples/single_host/multiple_hostnames/states/workbench1_uninstall.sls b/tools/salt-install/config_examples/single_host/multiple_hostnames/states/workbench1_uninstall.sls
new file mode 100644 (file)
index 0000000..02ac0af
--- /dev/null
@@ -0,0 +1,12 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+{%- set curr_tpldir = tpldir %}
+{%- set tpldir = 'arvados' %}
+{%- from "arvados/map.jinja" import arvados with context %}
+{%- set tpldir = curr_tpldir %}
+
+workbench1_pkg_removed:
+  pkg.removed:
+    - name: {{ arvados.workbench.pkg.name }}
\ No newline at end of file
index 10a9b79c9494d37a65c7dd66c2637c84c2a79516..9e3a293110afaa76c0ad3d9ca27300174747a287 100644 (file)
@@ -5,6 +5,11 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+{%- set database_host = ("__DATABASE_EXTERNAL_SERVICE_HOST_OR_IP__" or "127.0.0.1") %}
+{%- set database_name = "__DATABASE_NAME__" %}
+{%- set database_user = "__DATABASE_USER__" %}
+{%- set database_password = "__DATABASE_PASSWORD__" %}
+
 # The variables commented out are the default values that the formula uses.
 # The uncommented values are REQUIRED values. If you don't set them, running
 # this formula will fail.
@@ -48,7 +53,8 @@ arvados:
     #     - ruby-dev
     #     - zlib1g-dev
 
-  # config:
+  config:
+    check_command: /usr/bin/arvados-server config-check -strict=false -config
   #   file: /etc/arvados/config.yml
   #   user: root
   ## IMPORTANT!!!!!
@@ -65,19 +71,12 @@ arvados:
     database:
       # max concurrent connections per arvados server daemon
       # connection_pool_max: 32
-      name: __CLUSTER___arvados
-      host: 127.0.0.1
-      password: "__DATABASE_PASSWORD__"
-      user: __CLUSTER___arvados
+      name: {{ database_name }}
+      host: {{ database_host }}
+      password: {{ database_password }}
+      user: {{ database_user }}
       extra_conn_params:
         client_encoding: UTF8
-      # Centos7 does not enable SSL by default, so we disable
-      # it here just for testing of the formula purposes only.
-      # You should not do this in production, and should
-      # configure Postgres certificates correctly
-      {%- if grains.os_family in ('RedHat',) %}
-        sslmode: disable
-      {%- endif %}
 
     tls:
       # certificate: ''
@@ -101,7 +100,7 @@ arvados:
     ### KEYS
     secrets:
       blob_signing_key: __BLOB_SIGNING_KEY__
-      workbench_secret_key: __WORKBENCH_SECRET_KEY__
+      workbench_secret_key: "deprecated"
 
     Login:
       Test:
index 14452a990541bf47fee379a33345895f6652cbd8..82a4f7120a68d41f8a1a188cbec46c5743573f8c 100644 (file)
@@ -5,25 +5,9 @@
 
 ### POSTGRESQL
 postgres:
-  # Centos-7's postgres package is too old, so we need to force using upstream's
-  # This is not required in Debian's family as they already ship with PG +11
-  {%- if salt['grains.get']('os_family') == 'RedHat' %}
-  use_upstream_repo: true
-  version: '12'
-
-  pkgs_deps:
-    - libicu
-    - libxslt
-    - systemd-sysv
-
-  pkgs_extra:
-    - postgresql12-contrib
-
-  {%- else %}
   use_upstream_repo: false
   pkgs_extra:
     - postgresql-contrib
-  {%- endif %}
   postgresconf: |-
     listen_addresses = '*'  # listen on all interfaces
     # If you want to enable communications' encryption to the DB server,
index 3b2be59f368c353793bec874b9cf9dae1adde896..cf8874c2d59757969b0fd20b8b2071ba40fc50ec 100644 (file)
@@ -15,19 +15,41 @@ extra_custom_certs_file_directory_certs_dir:
 
   {%- for cert in certs %}
     {%- set cert_file = 'arvados-' ~ cert ~ '.pem' %}
-    {#- set csr_file = 'arvados-' ~ cert ~ '.csr' #}
     {%- set key_file = 'arvados-' ~ cert ~ '.key' %}
-    {% for c in [cert_file, key_file] %}
-extra_custom_certs_file_copy_{{ c }}:
+extra_custom_certs_{{ cert }}_cert_file_copy:
   file.copy:
-    - name: {{ dest_cert_dir }}/{{ c }}
-    - source: {{ orig_cert_dir }}/{{ c }}
+    - name: {{ dest_cert_dir }}/{{ cert_file }}
+    - source: {{ orig_cert_dir }}/{{ cert_file }}
     - force: true
     - user: root
     - group: root
-    - unless: cmp {{ dest_cert_dir }}/{{ c }} {{ orig_cert_dir }}/{{ c }}
+    - mode: 0640
+    - unless: cmp {{ dest_cert_dir }}/{{ cert_file }} {{ orig_cert_dir }}/{{ cert_file }}
     - require:
       - file: extra_custom_certs_file_directory_certs_dir
-    {%- endfor %}
+
+extra_custom_certs_{{ cert }}_key_file_copy:
+  file.copy:
+    - name: {{ dest_cert_dir }}/{{ key_file }}
+    - source: {{ orig_cert_dir }}/{{ key_file }}
+    - force: true
+    - user: root
+    - group: root
+    - mode: 0640
+    - unless: cmp {{ dest_cert_dir }}/{{ key_file }} {{ orig_cert_dir }}/{{ key_file }}
+    - require:
+      - file: extra_custom_certs_file_directory_certs_dir
+
+extra_nginx_service_reload_on_{{ cert }}_certs_changes:
+  cmd.run:
+    - name: systemctl reload nginx
+    - require:
+      - file: extra_custom_certs_{{ cert }}_cert_file_copy
+      - file: extra_custom_certs_{{ cert }}_key_file_copy
+    - onchanges:
+      - file: extra_custom_certs_{{ cert }}_cert_file_copy
+      - file: extra_custom_certs_{{ cert }}_key_file_copy
+    - onlyif:
+      - test $(openssl rsa -modulus -noout -in {{ dest_cert_dir }}/{{ key_file }}) == $(openssl x509 -modulus -noout -in {{ dest_cert_dir }}/{{ cert_file }})
   {%- endfor %}
 {%- endif %}
index 0ee79491830f58e3c47933b71dfd164f1d5001da..df8dcc7f3096ddcb4205f98d4ce8bf46018276b4 100644 (file)
@@ -43,19 +43,6 @@ extra_snakeoil_certs_dependencies_pkg_installed:
       - openssl
       - ca-certificates
 
-# Remove the RANDFILE parameter in openssl.cnf as it makes openssl fail in Ubuntu 18.04
-# Saving and restoring the rng state is not necessary anymore in the openssl 1.1.1
-# random generator, cf
-#   https://github.com/openssl/openssl/issues/7754
-#
-extra_snakeoil_certs_file_comment_etc_openssl_conf:
-  file.comment:
-    - name: /etc/ssl/openssl.cnf
-    - regex: ^RANDFILE.*
-    - onlyif: grep -q ^RANDFILE /etc/ssl/openssl.cnf
-    - require_in:
-      - cmd: extra_snakeoil_certs_arvados_snakeoil_ca_cmd_run
-
 extra_snakeoil_certs_arvados_snakeoil_ca_cmd_run:
   # Taken from https://github.com/arvados/arvados/blob/master/tools/arvbox/lib/arvbox/docker/service/certificate/run
   cmd.run:
@@ -143,8 +130,8 @@ extra_snakeoil_certs_arvados_snakeoil_cert___HOSTNAME_EXT___cmd_run:
       - pkg: extra_snakeoil_certs_dependencies_pkg_installed
       - cmd: extra_snakeoil_certs_arvados_snakeoil_ca_cmd_run
     - require_in:
-      - file: extra_custom_certs_file_copy_arvados-__HOSTNAME_EXT__.pem
-      - file: extra_custom_certs_file_copy_arvados-__HOSTNAME_EXT__.key
+      - file: extra_custom_certs___HOSTNAME_EXT___cert_file_copy
+      - file: extra_custom_certs___HOSTNAME_EXT___key_file_copy
 
   {%- if grains.get('os_family') == 'Debian' %}
 extra_snakeoil_certs_certs_permissions___HOSTNAME_EXT___cmd_run:
diff --git a/tools/salt-install/config_examples/single_host/single_hostname/states/workbench1_uninstall.sls b/tools/salt-install/config_examples/single_host/single_hostname/states/workbench1_uninstall.sls
new file mode 100644 (file)
index 0000000..02ac0af
--- /dev/null
@@ -0,0 +1,12 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+{%- set curr_tpldir = tpldir %}
+{%- set tpldir = 'arvados' %}
+{%- from "arvados/map.jinja" import arvados with context %}
+{%- set tpldir = curr_tpldir %}
+
+workbench1_pkg_removed:
+  pkg.removed:
+    - name: {{ arvados.workbench.pkg.name }}
\ No newline at end of file
index 21f36faace2934007d7e404ca8ed09c9f9c64dd3..439293c2967325318adfe76f8c46b67ee607edc8 100755 (executable)
@@ -35,6 +35,11 @@ declare DOMAIN
 # This will be populated by loadconfig()
 declare -A NODES
 
+# A bash associative array listing each role and mapping to the nodes
+# that should be provisioned with this role.
+# This will be populated by loadconfig()
+declare -A ROLE2NODES
+
 # The ssh user we'll use
 # This will be populated by loadconfig()
 declare DEPLOY_USER
@@ -43,280 +48,415 @@ declare DEPLOY_USER
 # This will be populated by loadconfig()
 declare GITTARGET
 
+# The public host used as an SSH jump host
+# This will be populated by loadconfig()
+declare USE_SSH_JUMPHOST
+
+# The temp file that will get used to disable envvar forwarding to avoid locale
+# issues in Debian distros.
+# This will be populated by loadconfig()
+declare SSH_CONFFILE
+
 checktools() {
-    local MISSING=''
-    for a in git ip ; do
-       if ! which $a ; then
-           MISSING="$MISSING $a"
-       fi
-    done
-    if [[ -n "$MISSING" ]] ; then
-       echo "Some tools are missing, please make sure you have the 'git' and 'iproute2' packages installed"
-       exit 1
+  local MISSING=''
+  for a in git ip; do
+    if ! which $a; then
+      MISSING="$MISSING $a"
     fi
+  done
+  if [[ -n "$MISSING" ]]; then
+    echo "Some tools are missing, please make sure you have the 'git' and 'iproute2' packages installed"
+    exit 1
+  fi
+}
+
+cleanup() {
+  local NODE=$1
+  local SSH=$(ssh_cmd "$NODE")
+  # Delete the old repository
+  $SSH $DEPLOY_USER@$NODE rm -rf ${GITTARGET}.git ${GITTARGET}
 }
 
 sync() {
-    local NODE=$1
-    local BRANCH=$2
-
-    # Synchronizes the configuration by creating a git repository on
-    # each node, pushing our branch, and updating the checkout.
-
-    if [[ "$NODE" != localhost ]] ; then
-       if ! ssh $DEPLOY_USER@$NODE test -d ${GITTARGET}.git ; then
-
-           # Initialize the git repository (1st time case).  We're
-           # actually going to make two repositories here because git
-           # will complain if you try to push to a repository with a
-           # checkout. So we're going to create a "bare" repository
-           # and then clone a regular repository (with a checkout)
-           # from that.
-
-           ssh $DEPLOY_USER@$NODE git init --bare --shared=0600 ${GITTARGET}.git
-           if ! git remote add $NODE $DEPLOY_USER@$NODE:${GITTARGET}.git ; then
-                       git remote set-url $NODE $DEPLOY_USER@$NODE:${GITTARGET}.git
-           fi
-           git push $NODE $BRANCH
-           ssh $DEPLOY_USER@$NODE "umask 0077 && git clone ${GITTARGET}.git ${GITTARGET}"
-       fi
-
-       # The update case.
-       #
-       # Push to the bare repository on the remote node, then in the
-       # remote node repository with the checkout, pull the branch
-       # from the bare repository.
-
-       git push $NODE $BRANCH
-       ssh $DEPLOY_USER@$NODE "git -C ${GITTARGET} checkout ${BRANCH} && git -C ${GITTARGET} pull"
+  local NODE=$1
+  local BRANCH=$2
+
+  # Synchronizes the configuration by creating a git repository on
+  # each node, pushing our branch, and updating the checkout.
+
+  if [[ "$NODE" != localhost ]]; then
+    SSH=$(ssh_cmd "$NODE")
+    GIT="eval $(git_cmd $NODE)"
+
+    cleanup $NODE
+
+    # Update the git remote for the remote repository.
+    if ! $GIT remote add $NODE $DEPLOY_USER@$NODE:${GITTARGET}.git; then
+      $GIT remote set-url $NODE $DEPLOY_USER@$NODE:${GITTARGET}.git
     fi
+
+    # Initialize the git repository.  We're
+    # actually going to make two repositories here because git
+    # will complain if you try to push to a repository with a
+    # checkout. So we're going to create a "bare" repository
+    # and then clone a regular repository (with a checkout)
+    # from that.
+
+    $SSH $DEPLOY_USER@$NODE git init --bare --shared=0600 ${GITTARGET}.git
+    if [[ "$BRANCH" == "HEAD" ]]; then
+      # When deploying from an individual commit instead of a branch. This can
+      # happen when deploying from a Jenkins pipeline.
+      $GIT push $NODE HEAD:refs/heads/HEAD
+      $SSH $DEPLOY_USER@$NODE "umask 0077 && git clone -s ${GITTARGET}.git ${GITTARGET} && git -C ${GITTARGET} checkout remotes/origin/HEAD"
+    else
+      $GIT push $NODE $BRANCH
+      $SSH $DEPLOY_USER@$NODE "umask 0077 && git clone -s ${GITTARGET}.git ${GITTARGET} && git -C ${GITTARGET} checkout ${BRANCH}"
+    fi
+  fi
 }
 
 deploynode() {
-    local NODE=$1
-    local ROLES=$2
+  local NODE=$1
+  local ROLES=$2
+  local BRANCH=$3
 
-    # Deploy a node.  This runs the provision script on the node, with
-    # the appropriate roles.
+  # Deploy a node.  This runs the provision script on the node, with
+  # the appropriate roles.
 
-    if [[ -z "$ROLES" ]] ; then
-       echo "No roles specified for $NODE, will deploy all roles"
-    else
-       ROLES="--roles ${ROLES}"
-    fi
+  sync $NODE $BRANCH
 
-    logfile=deploy-${NODE}-$(date -Iseconds).log
+  if [[ -z "$ROLES" ]]; then
+    echo "No roles specified for $NODE, will deploy all roles"
+  else
+    ROLES="--roles ${ROLES}"
+  fi
 
-    if [[ "$NODE" = localhost ]] ; then
-           SUDO=''
-       if [[ $(whoami) != 'root' ]] ; then
-           SUDO=sudo
-       fi
-       $SUDO ./provision.sh --config ${CONFIG_FILE} ${ROLES} 2>&1 | tee $logfile
-    else
-       ssh $DEPLOY_USER@$NODE "cd ${GITTARGET} && sudo ./provision.sh --config ${CONFIG_FILE} ${ROLES}" 2>&1 | tee $logfile
+  logfile=deploy-${NODE}-$(date -Iseconds).log
+  SSH=$(ssh_cmd "$NODE")
+
+  if [[ "$NODE" = localhost ]]; then
+    SUDO=''
+    if [[ $(whoami) != 'root' ]]; then
+      SUDO=sudo
     fi
+    $SUDO ./provision.sh --config ${CONFIG_FILE} ${ROLES} 2>&1 | tee $logfile
+  else
+    $SSH $DEPLOY_USER@$NODE "cd ${GITTARGET} && git log -n1 HEAD && DISABLED_CONTROLLER=\"$DISABLED_CONTROLLER\" sudo --preserve-env=DISABLED_CONTROLLER ./provision.sh --config ${CONFIG_FILE} ${ROLES}" 2>&1 | tee $logfile
+    cleanup $NODE
+  fi
+}
+
+checkcert() {
+  local CERTNAME=$1
+  local CERTPATH="${CONFIG_DIR}/certs/${CERTNAME}"
+  if [[ ! -f "${CERTPATH}.crt" || ! -e "${CERTPATH}.key" ]]; then
+    echo "Missing ${CERTPATH}.crt or ${CERTPATH}.key files"
+    exit 1
+  fi
 }
 
 loadconfig() {
-    if [[ ! -s $CONFIG_FILE ]] ; then
-       echo "Must be run from initialized setup dir, maybe you need to 'initialize' first?"
-    fi
-    source ${CONFIG_FILE}
-    GITTARGET=arvados-deploy-config-${CLUSTER}
+  if ! [[ -s ${CONFIG_FILE} && -s ${CONFIG_FILE}.secrets ]]; then
+    echo "Must be run from initialized setup dir, maybe you need to 'initialize' first?"
+  fi
+  source common.sh
+  GITTARGET=arvados-deploy-config-${CLUSTER}
+
+  # Set up SSH so that it doesn't forward any environment variable. This is to avoid
+  # getting "setlocale" errors on the first run, depending on the distro being used
+  # to run the installer (like Debian).
+  SSH_CONFFILE=$(mktemp)
+  echo "Include config SendEnv -*" >${SSH_CONFFILE}
+}
+
+ssh_cmd() {
+  local NODE=$1
+  if [ -z "${USE_SSH_JUMPHOST}" -o "${NODE}" == "${USE_SSH_JUMPHOST}" -o "${NODE}" == "localhost" ]; then
+    echo "ssh -F ${SSH_CONFFILE}"
+  else
+    echo "ssh -F ${SSH_CONFFILE} -J ${DEPLOY_USER}@${USE_SSH_JUMPHOST}"
+  fi
+}
+
+git_cmd() {
+  local NODE=$1
+  echo "GIT_SSH_COMMAND=\"$(ssh_cmd ${NODE})\" git"
 }
 
 set +u
 subcmd="$1"
 set -u
 
-if [[ -n "$subcmd" ]] ; then
-    shift
+if [[ -n "$subcmd" ]]; then
+  shift
 fi
 case "$subcmd" in
-    initialize)
-       if [[ ! -f provision.sh ]] ; then
-           echo "Must be run from arvados/tools/salt-install"
-           exit
-       fi
-
-       checktools
-
-       set +u
-       SETUPDIR=$1
-       PARAMS=$2
-       SLS=$3
-       TERRAFORM=$4
-       set -u
-
-       err=
-       if [[ -z "$PARAMS" || ! -f local.params.example.$PARAMS ]] ; then
-           echo "Not found: local.params.example.$PARAMS"
-           echo "Expected one of multiple_hosts, single_host_multiple_hostnames, single_host_single_hostname"
-           err=1
-       fi
-
-       if [[ -z "$SLS" || ! -d config_examples/$SLS ]] ; then
-           echo "Not found: config_examples/$SLS"
-           echo "Expected one of multi_host/aws, single_host/multiple_hostnames, single_host/single_hostname"
-           err=1
-       fi
-
-       if [[ -z "$SETUPDIR" || -z "$PARAMS" || -z "$SLS" ]]; then
-           echo "installer.sh <setup dir to initialize> <params template> <config template>"
-           err=1
-       fi
-
-       if [[ -n "$err" ]] ; then
-           exit 1
-       fi
-
-       echo "Initializing $SETUPDIR"
-       git init --shared=0600 $SETUPDIR
-       cp -r *.sh tests $SETUPDIR
-
-       cp local.params.example.$PARAMS $SETUPDIR/${CONFIG_FILE}
-       cp -r config_examples/$SLS $SETUPDIR/${CONFIG_DIR}
-
-       if [[ -n "$TERRAFORM" ]] ; then
-           mkdir $SETUPDIR/terraform
-           cp -r $TERRAFORM/* $SETUPDIR/terraform/
-       fi
-
-       cd $SETUPDIR
-       echo '*.log' > .gitignore
-
-       git add *.sh ${CONFIG_FILE} ${CONFIG_DIR} tests .gitignore
-       git commit -m"initial commit"
-
-       echo
-       echo "Setup directory $SETUPDIR initialized."
-       if [[ -n "$TERRAFORM" ]] ; then
-           (cd $SETUPDIR/terraform/vpc && terraform init)
-           (cd $SETUPDIR/terraform/data-storage && terraform init)
-           (cd $SETUPDIR/terraform/services && terraform init)
-           echo "Now go to $SETUPDIR, customize 'terraform/vpc/terraform.tfvars' as needed, then run 'installer.sh terraform'"
-       else
-           echo "Now go to $SETUPDIR, customize '${CONFIG_FILE}' and '${CONFIG_DIR}' as needed, then run 'installer.sh deploy'"
-       fi
-       ;;
-
-    terraform)
-       logfile=terraform-$(date -Iseconds).log
-       (cd terraform/vpc && terraform apply) 2>&1 | tee -a $logfile
-       (cd terraform/data-storage && terraform apply) 2>&1 | tee -a $logfile
-       (cd terraform/services && terraform apply) 2>&1 | grep -v letsencrypt_iam_secret_access_key | tee -a $logfile
-       (cd terraform/services && echo -n 'letsencrypt_iam_secret_access_key = ' && terraform output letsencrypt_iam_secret_access_key) 2>&1 | tee -a $logfile
-       ;;
-
-    generate-tokens)
-       for i in BLOB_SIGNING_KEY MANAGEMENT_TOKEN SYSTEM_ROOT_TOKEN ANONYMOUS_USER_TOKEN WORKBENCH_SECRET_KEY DATABASE_PASSWORD; do
-           echo ${i}=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32 ; echo '')
-       done
-       ;;
-
-    deploy)
-       set +u
-       NODE=$1
-       set -u
-
-       checktools
-
-       loadconfig
-
-       if grep -rni 'fixme' ${CONFIG_FILE} ${CONFIG_DIR} ; then
-           echo
-           echo "Some parameters still need to be updated.  Please fix them and then re-run deploy."
-           exit 1
-       fi
-
-       BRANCH=$(git branch --show-current)
-
-       set -x
-
-       git add -A
-       if ! git diff --cached --exit-code ; then
-           git commit -m"prepare for deploy"
-       fi
-
-       if [[ -z "$NODE" ]]; then
-           for NODE in "${!NODES[@]}"
-           do
-               # First, push the git repo to each node.  This also
-               # confirms that we have git and can log into each
-               # node.
-               sync $NODE $BRANCH
-           done
-
-           for NODE in "${!NODES[@]}"
-           do
-               # Do 'database' role first,
-               if [[ "${NODES[$NODE]}" =~ database ]] ; then
-                   deploynode $NODE "${NODES[$NODE]}"
-                   unset NODES[$NODE]
-               fi
-           done
-
-           for NODE in "${!NODES[@]}"
-           do
-               # then  'api' or 'controller' roles
-               if [[ "${NODES[$NODE]}" =~ (api|controller) ]] ; then
-                   deploynode $NODE "${NODES[$NODE]}"
-                   unset NODES[$NODE]
-               fi
-           done
-
-           for NODE in "${!NODES[@]}"
-           do
-               # Everything else (we removed the nodes that we
-               # already deployed from the list)
-               deploynode $NODE "${NODES[$NODE]}"
-           done
-       else
-           # Just deploy the node that was supplied on the command line.
-           sync $NODE $BRANCH
-           deploynode $NODE ""
-       fi
-
-       set +x
-       echo
-       echo "Completed deploy, run 'installer.sh diagnostics' to verify the install"
-
-       ;;
-
-    diagnostics)
-       loadconfig
-
-       set +u
-       declare LOCATION=$1
-       set -u
-
-       if ! which arvados-client ; then
-           echo "arvados-client not found, install 'arvados-client' package with 'apt-get' or 'yum'"
-           exit 1
-       fi
-
-       if [[ -z "$LOCATION" ]] ; then
-           echo "Need to provide '-internal-client' or '-external-client'"
-           echo
-           echo "-internal-client    You are running this on the same private network as the Arvados cluster (e.g. on one of the Arvados nodes)"
-           echo "-external-client    You are running this outside the private network of the Arvados cluster (e.g. your workstation)"
-           exit 1
-       fi
-
-       export ARVADOS_API_HOST="${CLUSTER}.${DOMAIN}:${CONTROLLER_EXT_SSL_PORT}"
-       export ARVADOS_API_TOKEN="$SYSTEM_ROOT_TOKEN"
-
-       arvados-client diagnostics $LOCATION
-       ;;
-
-    *)
-       echo "Arvados installer"
-       echo ""
-       echo "initialize        initialize the setup directory for configuration"
-       echo "terraform         create cloud resources using terraform"
-       echo "generate-tokens   generate random values for tokens"
-       echo "deploy            deploy the configuration from the setup directory"
-       echo "diagnostics       check your install using diagnostics"
-       ;;
+initialize)
+  if [[ ! -f provision.sh ]]; then
+    echo "Must be run from arvados/tools/salt-install"
+    exit
+  fi
+
+  checktools
+
+  set +u
+  SETUPDIR=$1
+  PARAMS=$2
+  SLS=$3
+  TERRAFORM=$4
+  set -u
+
+  err=
+  if [[ -z "$PARAMS" || ! -f local.params.example.$PARAMS ]]; then
+    echo "Not found: local.params.example.$PARAMS"
+    echo "Expected one of multiple_hosts, single_host_multiple_hostnames, single_host_single_hostname"
+    err=1
+  fi
+
+  if [[ -z "$SLS" || ! -d config_examples/$SLS ]]; then
+    echo "Not found: config_examples/$SLS"
+    echo "Expected one of multi_host/aws, single_host/multiple_hostnames, single_host/single_hostname"
+    err=1
+  fi
+
+  if [[ -z "$SETUPDIR" || -z "$PARAMS" || -z "$SLS" ]]; then
+    echo "installer.sh <setup dir to initialize> <params template> <config template>"
+    err=1
+  fi
+
+  if [[ -n "$err" ]]; then
+    exit 1
+  fi
+
+  echo "Initializing $SETUPDIR"
+  git init --shared=0600 $SETUPDIR
+  cp -r *.sh tests $SETUPDIR
+
+  cp local.params.example.$PARAMS $SETUPDIR/${CONFIG_FILE}
+  cp local.params.secrets.example $SETUPDIR/${CONFIG_FILE}.secrets
+  cp -r config_examples/$SLS $SETUPDIR/${CONFIG_DIR}
+
+  if [[ -n "$TERRAFORM" ]]; then
+    mkdir $SETUPDIR/terraform
+    cp -r $TERRAFORM/* $SETUPDIR/terraform/
+  fi
+
+  cd $SETUPDIR
+  echo '*.log' >.gitignore
+  echo '**/.terraform' >>.gitignore
+  echo '**/.infracost' >>.gitignore
+
+  if [[ -n "$TERRAFORM" ]]; then
+    git add terraform
+  fi
+
+  git add *.sh ${CONFIG_FILE} ${CONFIG_FILE}.secrets ${CONFIG_DIR} tests .gitignore
+  git commit -m"initial commit"
+
+  echo
+  echo "Setup directory $SETUPDIR initialized."
+  if [[ -n "$TERRAFORM" ]]; then
+    (cd $SETUPDIR/terraform/vpc && terraform init)
+    (cd $SETUPDIR/terraform/data-storage && terraform init)
+    (cd $SETUPDIR/terraform/services && terraform init)
+    echo "Now go to $SETUPDIR, customize 'terraform/vpc/terraform.tfvars' as needed, then run 'installer.sh terraform'"
+  else
+    echo "Now go to $SETUPDIR, customize '${CONFIG_FILE}', '${CONFIG_FILE}.secrets' and '${CONFIG_DIR}' as needed, then run 'installer.sh deploy'"
+  fi
+  ;;
+
+terraform)
+  logfile=terraform-$(date -Iseconds).log
+  (cd terraform/vpc && terraform apply -auto-approve) 2>&1 | tee -a $logfile
+  (cd terraform/data-storage && terraform apply -auto-approve) 2>&1 | tee -a $logfile
+  (cd terraform/services && terraform apply -auto-approve) 2>&1 | grep -v letsencrypt_iam_secret_access_key | tee -a $logfile
+  (cd terraform/services && echo -n 'letsencrypt_iam_secret_access_key = ' && terraform output letsencrypt_iam_secret_access_key) 2>&1 | tee -a $logfile
+  ;;
+
+terraform-destroy)
+  logfile=terraform-$(date -Iseconds).log
+  (cd terraform/services && terraform destroy) 2>&1 | tee -a $logfile
+  (cd terraform/data-storage && terraform destroy) 2>&1 | tee -a $logfile
+  (cd terraform/vpc && terraform destroy) 2>&1 | tee -a $logfile
+  ;;
+
+generate-tokens)
+  for i in BLOB_SIGNING_KEY MANAGEMENT_TOKEN SYSTEM_ROOT_TOKEN ANONYMOUS_USER_TOKEN DATABASE_PASSWORD; do
+    echo ${i}=$(
+      tr -dc A-Za-z0-9 </dev/urandom | head -c 32
+      echo ''
+    )
+  done
+  ;;
+
+deploy)
+  set +u
+  NODE=$1
+  set -u
+
+  checktools
+
+  loadconfig
+
+  if grep -rni 'fixme' ${CONFIG_FILE} ${CONFIG_FILE}.secrets ${CONFIG_DIR}; then
+    echo
+    echo "Some parameters still need to be updated.  Please fix them and then re-run deploy."
+    exit 1
+  fi
+
+  if [[ -z "${DATABASE_POSTGRESQL_VERSION:-}" ]]; then
+    echo
+    echo "Please configure DATABASE_POSTGRESQL_VERSION in local.params: It should match the version of the PostgreSQL service you're going to use."
+    exit 1
+  fi
+
+  if [[ ${SSL_MODE} == "bring-your-own" ]]; then
+    if [[ ! -z "${ROLE2NODES['balancer']:-}" ]]; then
+      checkcert balancer
+    fi
+    if [[ ! -z "${ROLE2NODES['controller']:-}" ]]; then
+      checkcert controller
+    fi
+    if [[ ! -z "${ROLE2NODES['keepproxy']:-}" ]]; then
+      checkcert keepproxy
+    fi
+    if [[ ! -z "${ROLE2NODES['keepweb']:-}" ]]; then
+      checkcert collections
+      checkcert download
+    fi
+    if [[ ! -z "${ROLE2NODES['monitoring']:-}" ]]; then
+      checkcert grafana
+      checkcert prometheus
+    fi
+    if [[ ! -z "${ROLE2NODES['webshell']:-}" ]]; then
+      checkcert webshell
+    fi
+    if [[ ! -z "${ROLE2NODES['websocket']:-}" ]]; then
+      checkcert websocket
+    fi
+    if [[ ! -z "${ROLE2NODES['workbench']:-}" ]]; then
+      checkcert workbench
+    fi
+    if [[ ! -z "${ROLE2NODES['workbench2']:-}" ]]; then
+      checkcert workbench2
+    fi
+  fi
+
+  BRANCH=$(git rev-parse --abbrev-ref HEAD)
+
+  set -x
+
+  git add -A
+  if ! git diff --cached --exit-code --quiet; then
+    git commit -m"prepare for deploy"
+  fi
+
+  # Used for rolling updates to disable individual nodes at the
+  # load balancer.
+  export DISABLED_CONTROLLER=""
+  if [[ -z "$NODE" ]]; then
+    for NODE in "${!NODES[@]}"; do
+      # First, just confirm we can ssh to each node.
+      $(ssh_cmd "$NODE") $DEPLOY_USER@$NODE true
+    done
+
+    for NODE in "${!NODES[@]}"; do
+      # Do 'database' role first,
+      if [[ "${NODES[$NODE]}" =~ database ]]; then
+        deploynode $NODE "${NODES[$NODE]}" $BRANCH
+        unset NODES[$NODE]
+      fi
+    done
+
+    BALANCER=${ROLE2NODES['balancer']:-}
+
+    # Check if there are multiple controllers, they'll be comma-separated
+    # in ROLE2NODES
+    if [[ ${ROLE2NODES['controller']} =~ , ]]; then
+      # If we have multiple controllers then there must be
+      # load balancer. We want to do a rolling update, take
+      # down each node at the load balancer before updating
+      # it.
+
+      for NODE in "${!NODES[@]}"; do
+        if [[ "${NODES[$NODE]}" =~ controller ]]; then
+          export DISABLED_CONTROLLER=$NODE
+
+          # Update balancer that the node is disabled
+          deploynode $BALANCER "${NODES[$BALANCER]}" $BRANCH
+
+          # Now update the node itself
+          deploynode $NODE "${NODES[$NODE]}" $BRANCH
+          unset NODES[$NODE]
+        fi
+      done
+    else
+      # Only one controller, check if it wasn't already taken care of.
+      NODE=${ROLE2NODES['controller']}
+      if [[ ! -z "${NODES[$NODE]:-}" ]]; then
+        deploynode $NODE "${NODES[$NODE]}" $BRANCH
+        unset NODES[$NODE]
+      fi
+    fi
+
+    if [[ -n "$BALANCER" ]]; then
+      # Deploy balancer. In the rolling update case, this
+      # will re-enable all the controllers at the balancer.
+      export DISABLED_CONTROLLER=""
+      deploynode $BALANCER "${NODES[$BALANCER]}" $BRANCH
+      unset NODES[$BALANCER]
+    fi
+
+    for NODE in "${!NODES[@]}"; do
+      # Everything else (we removed the nodes that we
+      # already deployed from the list)
+      deploynode $NODE "${NODES[$NODE]}" $BRANCH
+    done
+  else
+    # Just deploy the node that was supplied on the command line.
+    deploynode $NODE "${NODES[$NODE]}" $BRANCH
+  fi
+
+  set +x
+  echo
+  echo "Completed deploy, run 'installer.sh diagnostics' to verify the install"
+
+  ;;
+
+diagnostics)
+  loadconfig
+
+  set +u
+  declare LOCATION=$1
+  set -u
+
+  if ! which arvados-client; then
+    echo "arvados-client not found, install 'arvados-client' package with 'apt-get' or 'yum'"
+    exit 1
+  fi
+
+  if [[ -z "$LOCATION" ]]; then
+    echo "Need to provide '-internal-client' or '-external-client'"
+    echo
+    echo "-internal-client    You are running this on the same private network as the Arvados cluster (e.g. on one of the Arvados nodes)"
+    echo "-external-client    You are running this outside the private network of the Arvados cluster (e.g. your workstation)"
+    exit 1
+  fi
+
+  export ARVADOS_API_HOST="${DOMAIN}:${CONTROLLER_EXT_SSL_PORT}"
+  export ARVADOS_API_TOKEN="$SYSTEM_ROOT_TOKEN"
+
+  arvados-client diagnostics $LOCATION
+  ;;
+
+*)
+  echo "Arvados installer"
+  echo ""
+  echo "initialize        initialize the setup directory for configuration"
+  echo "terraform         create cloud resources using terraform"
+  echo "terraform-destroy destroy cloud resources created by terraform"
+  echo "generate-tokens   generate random values for tokens"
+  echo "deploy            deploy the configuration from the setup directory"
+  echo "diagnostics       check your install using diagnostics"
+  ;;
 esac
index 0064a78c5e5fc006366bc86ac855be89ed791d56..d97afaca1c4e82e2c8c62622b1acbcfc6843a77a 100644 (file)
@@ -8,71 +8,27 @@
 # The Arvados cluster ID, needs to be 5 lowercase alphanumeric characters.
 CLUSTER="cluster_fixme_or_this_wont_work"
 
-# The domain name you want to give to your cluster's hosts
-# the end result hostnames will be $SERVICE.$CLUSTER.$DOMAIN
+# The domain name you want to give to your cluster's hosts;
+# the end result hostnames will be $SERVICE.$DOMAIN
 DOMAIN="domain_fixme_or_this_wont_work"
 
 # For multi-node installs, the ssh log in for each node
 # must be root or able to sudo
-DEPLOY_USER=root
+DEPLOY_USER=admin
 
-# The mapping of nodes to roles
-# installer.sh will log in to each of these nodes and then provision
-# it for the specified roles.
-NODES=(
-  [controller.${CLUSTER}.${DOMAIN}]=database,api,controller,websocket,dispatcher,keepbalance
-  [keep0.${CLUSTER}.${DOMAIN}]=keepstore
-  [keep1.${CLUSTER}.${DOMAIN}]=keepstore
-  [keep.${CLUSTER}.${DOMAIN}]=keepproxy,keepweb
-  [workbench.${CLUSTER}.${DOMAIN}]=workbench,workbench2,webshell
-  [shell.${CLUSTER}.${DOMAIN}]=shell
-)
-
-# Host SSL port where you want to point your browser to access Arvados
-# Defaults to 443 for regular runs, and to 8443 when called in Vagrant.
-# You can point it to another port if desired
-# In Vagrant, make sure it matches what you set in the Vagrantfile (8443)
-CONTROLLER_EXT_SSL_PORT=443
-KEEP_EXT_SSL_PORT=443
-# Both for collections and downloads
-KEEPWEB_EXT_SSL_PORT=443
-WEBSHELL_EXT_SSL_PORT=443
-WEBSOCKET_EXT_SSL_PORT=443
-WORKBENCH1_EXT_SSL_PORT=443
-WORKBENCH2_EXT_SSL_PORT=443
-
-# Internal IPs for the configuration
-CLUSTER_INT_CIDR=10.1.0.0/16
-
-# Note the IPs in this example are shared between roles, as suggested in
-# https://doc.arvados.org/main/install/salt-multi-host.html
-CONTROLLER_INT_IP=10.1.1.11
-WEBSOCKET_INT_IP=10.1.1.11
-KEEP_INT_IP=10.1.1.12
-# Both for collections and downloads
-KEEPWEB_INT_IP=10.1.1.12
-KEEPSTORE0_INT_IP=10.1.1.13
-KEEPSTORE1_INT_IP=10.1.1.14
-WORKBENCH1_INT_IP=10.1.1.15
-WORKBENCH2_INT_IP=10.1.1.15
-WEBSHELL_INT_IP=10.1.1.15
-DATABASE_INT_IP=10.1.1.11
-SHELL_INT_IP=10.1.1.17
-
-INITIAL_USER="admin"
+INITIAL_USER=admin
 
 # If not specified, the initial user email will be composed as
-# INITIAL_USER@CLUSTER.DOMAIN
+# INITIAL_USER@DOMAIN
 INITIAL_USER_EMAIL="admin@cluster_fixme_or_this_wont_work.domain_fixme_or_this_wont_work"
-INITIAL_USER_PASSWORD="fixmepassword"
 
-# YOU SHOULD CHANGE THESE TO SOME RANDOM STRINGS
-BLOB_SIGNING_KEY=fixmeblobsigningkeymushaveatleast32characters
-MANAGEMENT_TOKEN=fixmemanagementtokenmushaveatleast32characters
-SYSTEM_ROOT_TOKEN=fixmesystemroottokenmushaveatleast32characters
-ANONYMOUS_USER_TOKEN=fixmeanonymoususertokenmushaveatleast32characters
-WORKBENCH_SECRET_KEY=fixmeworkbenchsecretkeymushaveatleast32characters
-DATABASE_PASSWORD=fixmeplease_set_this_to_some_secure_value
+# Use a public node as a jump host for SSH sessions. This allows running the
+# installer from the outside of the cluster's local network and still reach
+# the internal servers for configuration deployment.
+# Comment out to disable.
+USE_SSH_JUMPHOST="controller.${DOMAIN}"
+
+AWS_REGION="fixme_or_this_wont_work"
 
 # SSL CERTIFICATES
 # Arvados requires SSL certificates to work correctly. This installer supports these options:
@@ -88,9 +44,19 @@ USE_LETSENCRYPT_ROUTE53="yes"
 # For that reason, you'll need to provide AWS credentials with permissions to manage
 # RRs in the route53 zone for the cluster.
 # WARNING!: If AWS credentials files already exist in the hosts, they won't be replaced.
-LE_AWS_REGION="us-east-1"
-LE_AWS_ACCESS_KEY_ID="AKIABCDEFGHIJKLMNOPQ"
-LE_AWS_SECRET_ACCESS_KEY="thisistherandomstringthatisyoursecretkey"
+LE_AWS_REGION="${AWS_REGION}"
+
+# Compute node configurations
+COMPUTE_AMI="ami_id_fixme_or_this_wont_work"
+COMPUTE_SG="security_group_fixme_or_this_wont_work"
+COMPUTE_SUBNET="subnet_fixme_or_this_wont_work"
+COMPUTE_AWS_REGION="${AWS_REGION}"
+COMPUTE_USER="${DEPLOY_USER}"
+
+# Keep S3 backend settings
+KEEP_AWS_REGION="${AWS_REGION}"
+KEEP_AWS_S3_BUCKET="${CLUSTER}-nyw5e-000000000000000-volume"
+KEEP_AWS_IAM_ROLE="${CLUSTER}-keepstore-00-iam-role"
 
 # If you going to provide your own certificates for Arvados, the provision script can
 # help you deploy them. In order to do that, you need to set `SSL_MODE=bring-your-own` above,
@@ -110,6 +76,8 @@ LE_AWS_SECRET_ACCESS_KEY="thisistherandomstringthatisyoursecretkey"
 #  "download"         # Part of keepweb
 #  "collections"      # Part of keepweb
 #  "keepproxy"        # Keepproxy
+#  "prometheus"
+#  "grafana"
 # Ie., 'keep', the script will lookup for
 # ${CUSTOM_CERTS_DIR}/keepproxy.crt
 # ${CUSTOM_CERTS_DIR}/keepproxy.key
@@ -118,11 +86,78 @@ LE_AWS_SECRET_ACCESS_KEY="thisistherandomstringthatisyoursecretkey"
 # a custom AWS secret name for each node to retrieve the password.
 SSL_KEY_ENCRYPTED="no"
 SSL_KEY_AWS_SECRET_NAME="${CLUSTER}-arvados-ssl-privkey-password"
-SSL_KEY_AWS_REGION="us-east-1"
+SSL_KEY_AWS_REGION="${AWS_REGION}"
+
+# Customize Prometheus & Grafana web UI access credentials
+MONITORING_USERNAME=${INITIAL_USER}
+MONITORING_EMAIL=${INITIAL_USER_EMAIL}
+
+# Sets the directory for Grafana dashboards
+# GRAFANA_DASHBOARDS_DIR="${SCRIPT_DIR}/local_config_dir/dashboards"
+
+# Sets the amount of data (expressed in time) Prometheus keeps on its
+# time-series database. Default is 15 days.
+# PROMETHEUS_DATA_RETENTION_TIME="180d"
+
+# The mapping of nodes to roles
+# installer.sh will log in to each of these nodes and then provision
+# it for the specified roles.
+NODES=(
+  [controller.${DOMAIN}]=database,controller
+  [workbench.${DOMAIN}]=monitoring,workbench,workbench2,webshell,keepproxy,keepweb,websocket,dispatcher,keepbalance
+  [keep0.${DOMAIN}]=keepstore
+  [shell.${DOMAIN}]=shell
+)
+
+# Host SSL port where you want to point your browser to access Arvados
+# Defaults to 443 for regular runs, and to 8443 when called in Vagrant.
+# You can point it to another port if desired
+# In Vagrant, make sure it matches what you set in the Vagrantfile (8443)
+CONTROLLER_EXT_SSL_PORT=443
+KEEP_EXT_SSL_PORT=443
+# Both for collections and downloads
+KEEPWEB_EXT_SSL_PORT=443
+WEBSHELL_EXT_SSL_PORT=443
+WEBSOCKET_EXT_SSL_PORT=443
+WORKBENCH1_EXT_SSL_PORT=443
+WORKBENCH2_EXT_SSL_PORT=443
+
+# Internal IPs for the configuration
+CLUSTER_INT_CIDR=10.1.0.0/16
+
+# Note the IPs in this example are shared between roles, as suggested in
+# https://doc.arvados.org/main/install/salt-multi-host.html
+CONTROLLER_INT_IP=10.1.1.11
+DATABASE_INT_IP=${CONTROLLER_INT_IP}
+WORKBENCH1_INT_IP=10.1.1.15
+DISPATCHER_INT_IP=${WORKBENCH1_INT_IP}
+KEEPBALANCE_INT_IP=${WORKBENCH1_INT_IP}
+WEBSOCKET_INT_IP=${WORKBENCH1_INT_IP}
+# Both for collections and downloads
+KEEPWEB_INT_IP=${WORKBENCH1_INT_IP}
+WORKBENCH2_INT_IP=${WORKBENCH1_INT_IP}
+WEBSHELL_INT_IP=${WORKBENCH1_INT_IP}
+KEEP_INT_IP=${WORKBENCH1_INT_IP}
+KEEPSTORE0_INT_IP=10.1.2.13
+SHELL_INT_IP=10.1.2.17
+
+DATABASE_NAME="${CLUSTER}_arvados"
+DATABASE_USER="${CLUSTER}_arvados"
+# Set these if using an external PostgreSQL service.
+#DATABASE_EXTERNAL_SERVICE_HOST_OR_IP=
+#DATABASE_POSTGRESQL_VERSION=
+
+# Performance tuning parameters.  If these are not set, workers
+# defaults on the number of cpus, queued requests defaults to 128
+# and gateway tunnels defaults to 1000.
+#CONTROLLER_MAX_WORKERS=
+#CONTROLLER_MAX_QUEUED_REQUESTS=
+#CONTROLLER_MAX_GATEWAY_TUNNELS=
 
 # The directory to check for the config files (pillars, states) you want to use.
 # There are a few examples under 'config_examples'.
 # CONFIG_DIR="local_config_dir"
+
 # Extra states to apply. If you use your own subdir, change this value accordingly
 # EXTRA_STATES_DIR="${CONFIG_DIR}/states"
 
@@ -147,3 +182,5 @@ RELEASE="production"
 # DOCKER_TAG="v2.4.2"
 # LOCALE_TAG="v0.3.4"
 # LETSENCRYPT_TAG="v2.1.0"
+# PROMETHEUS_TAG="v5.6.5"
+# GRAFANA_TAG="v3.1.3"
index 56ecf9f92e19b05a0b155fa9a35ef5fbaa90202f..8dece2b76f689eaf8906e8ce361b3dceb529acd2 100644 (file)
@@ -13,37 +13,13 @@ DOMAIN="domain_fixme_or_this_wont_work"
 
 # For multi-node installs, the ssh log in for each node
 # must be root or able to sudo
-DEPLOY_USER=root
+DEPLOY_USER=admin
 
-# The mapping of nodes to roles
-# installer.sh will log in to each of these nodes and then provision
-# it for the specified roles.
-NODES=(
-  [localhost]=''
-)
+INITIAL_USER=admin
 
-# External ports used by the Arvados services
-CONTROLLER_EXT_SSL_PORT=443
-KEEP_EXT_SSL_PORT=25101
-KEEPWEB_EXT_SSL_PORT=9002
-WEBSHELL_EXT_SSL_PORT=4202
-WEBSOCKET_EXT_SSL_PORT=8002
-WORKBENCH1_EXT_SSL_PORT=443
-WORKBENCH2_EXT_SSL_PORT=3001
-
-INITIAL_USER="admin"
 # If not specified, the initial user email will be composed as
 # INITIAL_USER@CLUSTER.DOMAIN
 INITIAL_USER_EMAIL="admin@cluster_fixme_or_this_wont_work.domain_fixme_or_this_wont_work"
-INITIAL_USER_PASSWORD="password"
-
-# YOU SHOULD CHANGE THESE TO SOME RANDOM STRINGS
-BLOB_SIGNING_KEY=fixmeblobsigningkeymushaveatleast32characters
-MANAGEMENT_TOKEN=fixmemanagementtokenmushaveatleast32characters
-SYSTEM_ROOT_TOKEN=fixmesystemroottokenmushaveatleast32characters
-ANONYMOUS_USER_TOKEN=fixmeanonymoususertokenmushaveatleast32characters
-WORKBENCH_SECRET_KEY=fixmeworkbenchsecretkeymushaveatleast32characters
-DATABASE_PASSWORD=fixmeplease_set_this_to_some_secure_value
 
 # SSL CERTIFICATES
 # Arvados requires SSL certificates to work correctly. This installer supports these options:
@@ -63,6 +39,49 @@ SSL_MODE="self-signed"
 SSL_KEY_ENCRYPTED="no"
 SSL_KEY_AWS_SECRET_NAME="${CLUSTER}-arvados-ssl-privkey-password"
 
+# Customize Prometheus & Grafana web UI access credentials
+MONITORING_USERNAME=${INITIAL_USER}
+MONITORING_PASSWORD=${INITIAL_USER_PASSWORD}
+MONITORING_EMAIL=${INITIAL_USER_EMAIL}
+# Sets the directory for Grafana dashboards
+# GRAFANA_DASHBOARDS_DIR="${SCRIPT_DIR}/local_config_dir/dashboards"
+
+# The mapping of nodes to roles
+# installer.sh will log in to each of these nodes and then provision
+# it for the specified roles.
+NODES=(
+  [localhost]=''
+)
+
+# External ports used by the Arvados services
+CONTROLLER_EXT_SSL_PORT=443
+KEEP_EXT_SSL_PORT=25101
+KEEPWEB_EXT_SSL_PORT=9002
+WEBSHELL_EXT_SSL_PORT=4202
+WEBSOCKET_EXT_SSL_PORT=8002
+WORKBENCH1_EXT_SSL_PORT=443
+WORKBENCH2_EXT_SSL_PORT=3001
+
+CLUSTER_INT_CIDR=""
+CONTROLLER_INT_IP=""
+DATABASE_INT_IP=""
+WORKBENCH1_INT_IP=""
+DISPATCHER_INT_IP=""
+KEEPBALANCE_INT_IP=""
+WEBSOCKET_INT_IP=""
+KEEPWEB_INT_IP=""
+WORKBENCH2_INT_IP=""
+WEBSHELL_INT_IP=""
+KEEP_INT_IP=""
+KEEPSTORE0_INT_IP=""
+SHELL_INT_IP=""
+
+DATABASE_NAME="${CLUSTER}_arvados"
+DATABASE_USER="${CLUSTER}_arvados"
+# Set these if using an external PostgreSQL service.
+#DATABASE_EXTERNAL_SERVICE_HOST_OR_IP=
+#DATABASE_POSTGRESQL_VERSION=
+
 # The directory to check for the config files (pillars, states) you want to use.
 # There are a few examples under 'config_examples'.
 # CONFIG_DIR="local_config_dir"
@@ -91,3 +110,5 @@ RELEASE="production"
 # DOCKER_TAG="v2.4.2"
 # LOCALE_TAG="v0.3.4"
 # LETSENCRYPT_TAG="v2.1.0"
+# PROMETHEUS_TAG="v5.6.5"
+# GRAFANA_TAG="v3.1.3"
index 54a78b619985eaefb86533ee43197f01ee318814..33be542a15758952bb3be5e607942a3b515154e6 100644 (file)
@@ -13,7 +13,38 @@ DOMAIN="domain_fixme_or_this_wont_work"
 
 # For multi-node installs, the ssh log in for each node
 # must be root or able to sudo
-DEPLOY_USER=root
+DEPLOY_USER=admin
+
+INITIAL_USER=admin
+
+# If not specified, the initial user email will be composed as
+# INITIAL_USER@CLUSTER.DOMAIN
+INITIAL_USER_EMAIL="admin@cluster_fixme_or_this_wont_work.domain_fixme_or_this_wont_work"
+
+# SSL CERTIFICATES
+# Arvados requires SSL certificates to work correctly. This installer supports these options:
+# * self-signed: let the installer create self-signed certificate(s)
+# * bring-your-own: supply your own certificate(s) in the `certs` directory
+# * lets-encrypt: automatically obtain and install SSL certificates for your hostname(s)
+#
+# See https://doc.arvados.org/intall/salt-single-host.html#certificates for more information.
+SSL_MODE="self-signed"
+
+# CUSTOM_CERTS_DIR is only used when SSL_MODE is set to "bring-your-own".
+# See https://doc.arvados.org/intall/salt-single-host.html#bring-your-own for more information.
+# CUSTOM_CERTS_DIR="${SCRIPT_DIR}/local_config_dir/certs"
+
+# Set the following to "yes" if the key files are encrypted and optionally set
+# a custom AWS secret name for each node to retrieve the password.
+SSL_KEY_ENCRYPTED="no"
+SSL_KEY_AWS_SECRET_NAME="${CLUSTER}-arvados-ssl-privkey-password"
+
+# Customize Prometheus & Grafana web UI access credentials
+MONITORING_USERNAME=${INITIAL_USER}
+MONITORING_PASSWORD=${INITIAL_USER_PASSWORD}
+MONITORING_EMAIL=${INITIAL_USER_EMAIL}
+# Sets the directory for Grafana dashboards
+# GRAFANA_DASHBOARDS_DIR="${SCRIPT_DIR}/local_config_dir/dashboards"
 
 # The mapping of nodes to roles
 # installer.sh will log in to each of these nodes and then provision
@@ -41,37 +72,25 @@ WEBSOCKET_EXT_SSL_PORT=8804
 WORKBENCH1_EXT_SSL_PORT=8805
 WORKBENCH2_EXT_SSL_PORT=443
 
-INITIAL_USER="admin"
-# If not specified, the initial user email will be composed as
-# INITIAL_USER@CLUSTER.DOMAIN
-INITIAL_USER_EMAIL="admin@cluster_fixme_or_this_wont_work.domain_fixme_or_this_wont_work"
-INITIAL_USER_PASSWORD="password"
-
-# Populate these values with random strings
-BLOB_SIGNING_KEY=fixmeblobsigningkeymushaveatleast32characters
-MANAGEMENT_TOKEN=fixmemanagementtokenmushaveatleast32characters
-SYSTEM_ROOT_TOKEN=fixmesystemroottokenmushaveatleast32characters
-ANONYMOUS_USER_TOKEN=fixmeanonymoususertokenmushaveatleast32characters
-WORKBENCH_SECRET_KEY=fixmeworkbenchsecretkeymushaveatleast32characters
-DATABASE_PASSWORD=fixmeplease_set_this_to_some_secure_value
-
-# SSL CERTIFICATES
-# Arvados requires SSL certificates to work correctly. This installer supports these options:
-# * self-signed: let the installer create self-signed certificate(s)
-# * bring-your-own: supply your own certificate(s) in the `certs` directory
-# * lets-encrypt: automatically obtain and install SSL certificates for your hostname(s)
-#
-# See https://doc.arvados.org/intall/salt-single-host.html#certificates for more information.
-SSL_MODE="self-signed"
-
-# CUSTOM_CERTS_DIR is only used when SSL_MODE is set to "bring-your-own".
-# See https://doc.arvados.org/intall/salt-single-host.html#bring-your-own for more information.
-# CUSTOM_CERTS_DIR="${SCRIPT_DIR}/local_config_dir/certs"
-
-# Set the following to "yes" if the key files are encrypted and optionally set
-# a custom AWS secret name for each node to retrieve the password.
-SSL_KEY_ENCRYPTED="no"
-SSL_KEY_AWS_SECRET_NAME="${CLUSTER}-arvados-ssl-privkey-password"
+CLUSTER_INT_CIDR=""
+CONTROLLER_INT_IP=""
+DATABASE_INT_IP=""
+WORKBENCH1_INT_IP=""
+DISPATCHER_INT_IP=""
+KEEPBALANCE_INT_IP=""
+WEBSOCKET_INT_IP=""
+KEEPWEB_INT_IP=""
+WORKBENCH2_INT_IP=""
+WEBSHELL_INT_IP=""
+KEEP_INT_IP=""
+KEEPSTORE0_INT_IP=""
+SHELL_INT_IP=""
+
+DATABASE_NAME="${CLUSTER}_arvados"
+DATABASE_USER="${CLUSTER}_arvados"
+# Set these if using an external PostgreSQL service.
+#DATABASE_EXTERNAL_SERVICE_HOST_OR_IP=
+#DATABASE_POSTGRESQL_VERSION=
 
 # The directory to check for the config files (pillars, states) you want to use.
 # There are a few examples under 'config_examples'.
@@ -101,3 +120,5 @@ RELEASE="production"
 # DOCKER_TAG="v2.4.2"
 # LOCALE_TAG="v0.3.4"
 # LETSENCRYPT_TAG="v2.1.0"
+# PROMETHEUS_TAG="v5.6.5"
+# GRAFANA_TAG="v3.1.3"
diff --git a/tools/salt-install/local.params.secrets.example b/tools/salt-install/local.params.secrets.example
new file mode 100644 (file)
index 0000000..f7c555b
--- /dev/null
@@ -0,0 +1,24 @@
+##########################################################
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: CC-BY-SA-3.0
+
+# These are the security-sensitive parameters to configure the installation
+
+INITIAL_USER_PASSWORD="fixme"
+MONITORING_PASSWORD=${INITIAL_USER_PASSWORD}
+
+# YOU SHOULD CHANGE THESE TO SOME RANDOM STRINGS
+BLOB_SIGNING_KEY=fixmeblobsigningkeymushaveatleast32characters
+MANAGEMENT_TOKEN=fixmemanagementtokenmushaveatleast32characters
+SYSTEM_ROOT_TOKEN=fixmesystemroottokenmushaveatleast32characters
+ANONYMOUS_USER_TOKEN=fixmeanonymoususertokenmushaveatleast32characters
+DATABASE_PASSWORD=fixmeplease_set_this_to_some_secure_value
+
+LE_AWS_ACCESS_KEY_ID="FIXME"
+LE_AWS_SECRET_ACCESS_KEY="fixme"
+
+# Read https://doc.arvados.org/install/crunch2-cloud/install-compute-node.html#sshkeypair
+# for details on how to create this key.
+DISPATCHER_SSH_PRIVKEY="fixme"
+
index 86335ff8ec3d6404a58d31ebe81a0e22e66ac8f3..8dd07020c349942ff9f3936e8462e8a7b5b44026 100755 (executable)
@@ -10,6 +10,7 @@
 #
 # vagrant up
 
+set -eu
 set -o pipefail
 
 # capture the directory that the script is running from
@@ -25,13 +26,14 @@ usage() {
   echo >&2 "  -t, --test                                  Test installation running a CWL workflow"
   echo >&2 "  -r, --roles                                 List of Arvados roles to apply to the host, comma separated"
   echo >&2 "                                              Possible values are:"
-  echo >&2 "                                                api"
+  echo >&2 "                                                balancer"
   echo >&2 "                                                controller"
   echo >&2 "                                                dispatcher"
   echo >&2 "                                                keepproxy"
   echo >&2 "                                                keepbalance"
   echo >&2 "                                                keepstore"
   echo >&2 "                                                keepweb"
+  echo >&2 "                                                monitoring"
   echo >&2 "                                                shell"
   echo >&2 "                                                webshell"
   echo >&2 "                                                websocket"
@@ -108,12 +110,12 @@ arguments() {
         for i in ${2//,/ }
           do
             # Verify the role exists
-            if [[ ! "database,api,controller,keepstore,websocket,keepweb,workbench2,webshell,keepbalance,keepproxy,shell,workbench,dispatcher" == *"$i"* ]]; then
+            if [[ ! "database,balancer,controller,keepstore,websocket,keepweb,workbench2,webshell,keepbalance,keepproxy,shell,workbench,dispatcher,monitoring" == *"$i"* ]]; then
               echo "The role '${i}' is not a valid role"
               usage
               exit 1
             fi
-            ROLES="${ROLES} ${i}"
+            ROLES="${ROLES:-} ${i}"
           done
           shift 2
         ;;
@@ -157,6 +159,78 @@ copy_custom_cert() {
   fi
 }
 
+apply_var_substitutions() {
+  local SRCFILE=$1
+  local DSTFILE=$2
+  sed "s#__ANONYMOUS_USER_TOKEN__#${ANONYMOUS_USER_TOKEN}#g;
+       s#__BLOB_SIGNING_KEY__#${BLOB_SIGNING_KEY}#g;
+       s#__CONTROLLER_EXT_SSL_PORT__#${CONTROLLER_EXT_SSL_PORT}#g;
+       s#__CLUSTER__#${CLUSTER}#g;
+       s#__DOMAIN__#${DOMAIN}#g;
+       s#__HOSTNAME_EXT__#${HOSTNAME_EXT}#g;
+       s#__IP_INT__#${IP_INT}#g;
+       s#__INITIAL_USER_EMAIL__#${INITIAL_USER_EMAIL}#g;
+       s#__INITIAL_USER_PASSWORD__#${INITIAL_USER_PASSWORD}#g;
+       s#__INITIAL_USER__#${INITIAL_USER}#g;
+       s#__LE_AWS_REGION__#${LE_AWS_REGION:-}#g;
+       s#__LE_AWS_SECRET_ACCESS_KEY__#${LE_AWS_SECRET_ACCESS_KEY:-}#g;
+       s#__LE_AWS_ACCESS_KEY_ID__#${LE_AWS_ACCESS_KEY_ID:-}#g;
+       s#__DATABASE_NAME__#${DATABASE_NAME}#g;
+       s#__DATABASE_USER__#${DATABASE_USER}#g;
+       s#__DATABASE_PASSWORD__#${DATABASE_PASSWORD}#g;
+       s#__DATABASE_INT_IP__#${DATABASE_INT_IP:-}#g;
+       s#__DATABASE_EXTERNAL_SERVICE_HOST_OR_IP__#${DATABASE_EXTERNAL_SERVICE_HOST_OR_IP:-}#g;
+       s#__DATABASE_POSTGRESQL_VERSION__#${DATABASE_POSTGRESQL_VERSION}#g;
+       s#__KEEPWEB_EXT_SSL_PORT__#${KEEPWEB_EXT_SSL_PORT}#g;
+       s#__KEEP_EXT_SSL_PORT__#${KEEP_EXT_SSL_PORT}#g;
+       s#__MANAGEMENT_TOKEN__#${MANAGEMENT_TOKEN}#g;
+       s#__RELEASE__#${RELEASE}#g;
+       s#__SYSTEM_ROOT_TOKEN__#${SYSTEM_ROOT_TOKEN}#g;
+       s#__VERSION__#${VERSION}#g;
+       s#__WEBSHELL_EXT_SSL_PORT__#${WEBSHELL_EXT_SSL_PORT}#g;
+       s#__WEBSOCKET_EXT_SSL_PORT__#${WEBSOCKET_EXT_SSL_PORT}#g;
+       s#__WORKBENCH1_EXT_SSL_PORT__#${WORKBENCH1_EXT_SSL_PORT}#g;
+       s#__WORKBENCH2_EXT_SSL_PORT__#${WORKBENCH2_EXT_SSL_PORT}#g;
+       s#__CLUSTER_INT_CIDR__#${CLUSTER_INT_CIDR}#g;
+       s#__CONTROLLER_INT_IP__#${CONTROLLER_INT_IP}#g;
+       s#__WEBSOCKET_INT_IP__#${WEBSOCKET_INT_IP}#g;
+       s#__KEEP_INT_IP__#${KEEP_INT_IP}#g;
+       s#__KEEPSTORE0_INT_IP__#${KEEPSTORE0_INT_IP}#g;
+       s#__KEEPWEB_INT_IP__#${KEEPWEB_INT_IP}#g;
+       s#__WEBSHELL_INT_IP__#${WEBSHELL_INT_IP}#g;
+       s#__SHELL_INT_IP__#${SHELL_INT_IP}#g;
+       s#__WORKBENCH1_INT_IP__#${WORKBENCH1_INT_IP}#g;
+       s#__WORKBENCH2_INT_IP__#${WORKBENCH2_INT_IP}#g;
+       s#__SSL_KEY_ENCRYPTED__#${SSL_KEY_ENCRYPTED}#g;
+       s#__SSL_KEY_AWS_REGION__#${SSL_KEY_AWS_REGION:-}#g;
+       s#__SSL_KEY_AWS_SECRET_NAME__#${SSL_KEY_AWS_SECRET_NAME}#g;
+       s#__CONTROLLER_MAX_WORKERS__#${CONTROLLER_MAX_WORKERS:-}#g;
+       s#__CONTROLLER_MAX_QUEUED_REQUESTS__#${CONTROLLER_MAX_QUEUED_REQUESTS:-128}#g;
+       s#__CONTROLLER_MAX_GATEWAY_TUNNELS__#${CONTROLLER_MAX_GATEWAY_TUNNELS:-1000}#g;
+       s#__MONITORING_USERNAME__#${MONITORING_USERNAME}#g;
+       s#__MONITORING_EMAIL__#${MONITORING_EMAIL}#g;
+       s#__MONITORING_PASSWORD__#${MONITORING_PASSWORD}#g;
+       s#__DISPATCHER_SSH_PRIVKEY__#${DISPATCHER_SSH_PRIVKEY//$'\n'/\\n}#g;
+       s#__ENABLE_BALANCER__#${ENABLE_BALANCER}#g;
+       s#__DISABLED_CONTROLLER__#${DISABLED_CONTROLLER}#g;
+       s#__BALANCER_NODENAME__#${ROLE2NODES['balancer']:-}#g;
+       s#__PROMETHEUS_NODENAME__#${ROLE2NODES['monitoring']:-}#g;
+       s#__PROMETHEUS_DATA_RETENTION_TIME__#${PROMETHEUS_DATA_RETENTION_TIME:-15d}#g;
+       s#__CONTROLLER_NODES__#${ROLE2NODES['controller']:-}#g;
+       s#__NODELIST__#${NODELIST}#g;
+       s#__DISPATCHER_INT_IP__#${DISPATCHER_INT_IP}#g;
+       s#__KEEPBALANCE_INT_IP__#${KEEPBALANCE_INT_IP}#g;
+       s#__COMPUTE_AMI__#${COMPUTE_AMI:-}#g;
+       s#__COMPUTE_SG__#${COMPUTE_SG:-}#g;
+       s#__COMPUTE_SUBNET__#${COMPUTE_SUBNET:-}#g;
+       s#__COMPUTE_AWS_REGION__#${COMPUTE_AWS_REGION:-}#g;
+       s#__COMPUTE_USER__#${COMPUTE_USER:-}#g;
+       s#__KEEP_AWS_S3_BUCKET__#${KEEP_AWS_S3_BUCKET:-}#g;
+       s#__KEEP_AWS_IAM_ROLE__#${KEEP_AWS_IAM_ROLE:-}#g;
+       s#__KEEP_AWS_REGION__#${KEEP_AWS_REGION:-}#g" \
+  "${SRCFILE}" > "${DSTFILE}"
+}
+
 DEV_MODE="no"
 CONFIG_FILE="${SCRIPT_DIR}/local.params"
 CONFIG_DIR="local_config_dir"
@@ -191,6 +265,8 @@ SSL_MODE="self-signed"
 USE_LETSENCRYPT_ROUTE53="no"
 CUSTOM_CERTS_DIR="${SCRIPT_DIR}/local_config_dir/certs"
 
+GRAFANA_DASHBOARDS_DIR="${SCRIPT_DIR}/local_config_dir/dashboards"
+
 ## These are ARVADOS-related parameters
 # For a stable release, change RELEASE "production" and VERSION to the
 # package version (including the iteration, e.g. X.Y.Z-1) of the
@@ -220,31 +296,29 @@ DOCKER_TAG="v2.4.2"
 LOCALE_TAG="v0.3.4"
 LETSENCRYPT_TAG="v2.1.0"
 LOGROTATE_TAG="v0.14.0"
+PROMETHEUS_TAG="v5.6.5"
+GRAFANA_TAG="v3.1.3"
 
 # Salt's dir
 DUMP_SALT_CONFIG_DIR=""
 ## states
 S_DIR="/srv/salt"
+STATES_TOP=${S_DIR}/top.sls
 ## formulas
 F_DIR="/srv/formulas"
 ## pillars
 P_DIR="/srv/pillars"
+PILLARS_TOP=${P_DIR}/top.sls
 ## tests
 T_DIR="/tmp/cluster_tests"
 
 arguments ${@}
 
 declare -A NODES
+declare -A ROLE2NODES
+declare NODELIST
 
-if [ -s ${CONFIG_FILE} ]; then
-  source ${CONFIG_FILE}
-else
-  echo >&2 "You don't seem to have a config file with initial values."
-  echo >&2 "Please create a '${CONFIG_FILE}' file as described in"
-  echo >&2 "  * https://doc.arvados.org/install/salt-single-host.html#single_host, or"
-  echo >&2 "  * https://doc.arvados.org/install/salt-multi-host.html#multi_host_multi_hostnames"
-  exit 1
-fi
+source common.sh
 
 if [ ! -d ${CONFIG_DIR} ]; then
   echo >&2 "You don't seem to have a config directory with pillars and states."
@@ -254,8 +328,8 @@ if [ ! -d ${CONFIG_DIR} ]; then
   exit 1
 fi
 
-if grep -rni 'fixme' ${CONFIG_FILE} ${CONFIG_DIR} ; then
-  echo >&2 "The config file ${CONFIG_FILE} has some parameters that need to be modified."
+if grep -rni 'fixme' ${CONFIG_FILE}.secrets ${CONFIG_FILE} ${CONFIG_DIR} ; then
+  echo >&2 "The config files has some parameters that need to be modified."
   echo >&2 "Please, fix them and re-run the provision script."
   exit 1
 fi
@@ -267,7 +341,7 @@ if ! grep -qE '^[[:alnum:]]{5}$' <<<${CLUSTER} ; then
 fi
 
 # Only used in single_host/single_name deploys
-if [ ! -z "${HOSTNAME_EXT}" ] ; then
+if [ ! -z "${HOSTNAME_EXT:-}" ] ; then
   # We need to add some extra control vars to manage a single certificate vs. multiple
   USE_SINGLE_HOSTNAME="yes"
   # Make sure that the value configured as IP_INT is a real IP on the system.
@@ -282,7 +356,7 @@ else
   USE_SINGLE_HOSTNAME="no"
   # We set this variable, anyway, so sed lines do not fail and we don't need to add more
   # conditionals
-  HOSTNAME_EXT="${CLUSTER}.${DOMAIN}"
+  HOSTNAME_EXT="${DOMAIN}"
 fi
 
 if [ "${DUMP_CONFIG}" = "yes" ]; then
@@ -290,30 +364,31 @@ if [ "${DUMP_CONFIG}" = "yes" ]; then
 else
   # Install a few dependency packages
   # First, let's figure out the OS we're working on
-  OS_ID=$(grep ^ID= /etc/os-release |cut -f 2 -d=  |cut -f 2 -d \")
-  echo "Detected distro: ${OS_ID}"
-
-  case ${OS_ID} in
-    "centos")
-      echo "WARNING! Disabling SELinux, see https://dev.arvados.org/issues/18019"
-      sed -i 's/SELINUX=enforcing/SELINUX=permissive/g' /etc/sysconfig/selinux
-      setenforce permissive
-      yum install -y  curl git jq
-      ;;
-    "debian"|"ubuntu")
-      # Wait 2 minutes for any apt locks to clear
-      # This option is supported from apt 1.9.1 and ignored in older apt versions.
-      # Cf. https://blog.sinjakli.co.uk/2021/10/25/waiting-for-apt-locks-without-the-hacky-bash-scripts/
-      DEBIAN_FRONTEND=noninteractive apt -o DPkg::Lock::Timeout=120 update
-      DEBIAN_FRONTEND=noninteractive apt install -y curl git jq
-      ;;
-  esac
+  OS_IDS="$(. /etc/os-release && echo "${ID:-} ${ID_LIKE:-}")"
+  echo "Detected distro families: $OS_IDS"
+
+  for OS_ID in $OS_IDS; do
+    case "$OS_ID" in
+      rhel)
+        echo "WARNING! Disabling SELinux, see https://dev.arvados.org/issues/18019"
+        sed -i 's/SELINUX=enforcing/SELINUX=permissive/g' /etc/sysconfig/selinux
+        setenforce permissive
+        yum install -y  curl git jq
+        break
+        ;;
+      debian)
+        DEBIAN_FRONTEND=noninteractive apt -o DPkg::Lock::Timeout=120 update
+        DEBIAN_FRONTEND=noninteractive apt install -y curl git jq
+        break
+        ;;
+    esac
+  done
 
   if which salt-call; then
     echo "Salt already installed"
   else
     curl -L https://bootstrap.saltstack.com -o /tmp/bootstrap_salt.sh
-    sh /tmp/bootstrap_salt.sh -XdfP -x python3 stable ${SALT_VERSION}
+    sh /tmp/bootstrap_salt.sh -XdfP -x python3 old-stable ${SALT_VERSION}
     /bin/systemctl stop salt-minion.service
     /bin/systemctl disable salt-minion.service
   fi
@@ -358,6 +433,16 @@ test -d postgres && ( cd postgres && git fetch ) \
   || git clone --quiet ${POSTGRES_URL} ${F_DIR}/postgres
 ( cd postgres && git checkout --quiet tags/"${POSTGRES_TAG}" )
 
+echo "...prometheus"
+test -d prometheus && ( cd prometheus && git fetch ) \
+  || git clone --quiet https://github.com/saltstack-formulas/prometheus-formula.git ${F_DIR}/prometheus
+( cd prometheus && git checkout --quiet tags/"${PROMETHEUS_TAG}" )
+
+echo "...grafana"
+test -d grafana && ( cd grafana && git fetch ) \
+  || git clone --quiet https://github.com/saltstack-formulas/grafana-formula.git ${F_DIR}/grafana
+( cd grafana && git checkout --quiet "${GRAFANA_TAG}" )
+
 echo "...letsencrypt"
 test -d letsencrypt && ( cd letsencrypt && git fetch ) \
   || git clone --quiet https://github.com/saltstack-formulas/letsencrypt-formula.git ${F_DIR}/letsencrypt
@@ -372,19 +457,21 @@ echo "...arvados"
 test -d arvados || git clone --quiet https://git.arvados.org/arvados-formula.git ${F_DIR}/arvados
 
 # If we want to try a specific branch of the formula
-if [ "x${BRANCH}" != "x" ]; then
-  ( cd ${F_DIR}/arvados && git checkout --quiet -t origin/"${BRANCH}" -b "${BRANCH}" )
-elif [ "x${ARVADOS_TAG}" != "x" ]; then
+if [[ ! -z "${BRANCH:-}" && "x${BRANCH}" != "xmain" ]]; then
+  ( cd ${F_DIR}/arvados && git fetch && git checkout --quiet "${BRANCH}" || git checkout --quiet -t origin/"${BRANCH}" -b "${BRANCH}" )
+elif [ "x${ARVADOS_TAG:-}" != "x" ]; then
   ( cd ${F_DIR}/arvados && git checkout --quiet tags/"${ARVADOS_TAG}" -b "${ARVADOS_TAG}" )
 fi
 
-if [ "x${VAGRANT}" = "xyes" ]; then
+if [ "x${VAGRANT:-}" = "xyes" ]; then
   EXTRA_STATES_DIR="/home/vagrant/${CONFIG_DIR}/states"
   SOURCE_PILLARS_DIR="/home/vagrant/${CONFIG_DIR}/pillars"
+  SOURCE_TOFS_DIR="/home/vagrant/${CONFIG_DIR}/tofs"
   SOURCE_TESTS_DIR="/home/vagrant/${TESTS_DIR}"
 else
   EXTRA_STATES_DIR="${SCRIPT_DIR}/${CONFIG_DIR}/states"
   SOURCE_PILLARS_DIR="${SCRIPT_DIR}/${CONFIG_DIR}/pillars"
+  SOURCE_TOFS_DIR="${SCRIPT_DIR}/${CONFIG_DIR}/tofs"
   SOURCE_TESTS_DIR="${SCRIPT_DIR}/${TESTS_DIR}"
 fi
 
@@ -399,52 +486,12 @@ if [ ! -d "${SOURCE_PILLARS_DIR}" ]; then
   exit 1
 fi
 for f in $(ls "${SOURCE_PILLARS_DIR}"/*); do
-  sed "s#__ANONYMOUS_USER_TOKEN__#${ANONYMOUS_USER_TOKEN}#g;
-       s#__BLOB_SIGNING_KEY__#${BLOB_SIGNING_KEY}#g;
-       s#__CONTROLLER_EXT_SSL_PORT__#${CONTROLLER_EXT_SSL_PORT}#g;
-       s#__CLUSTER__#${CLUSTER}#g;
-       s#__DOMAIN__#${DOMAIN}#g;
-       s#__HOSTNAME_EXT__#${HOSTNAME_EXT}#g;
-       s#__IP_INT__#${IP_INT}#g;
-       s#__INITIAL_USER_EMAIL__#${INITIAL_USER_EMAIL}#g;
-       s#__INITIAL_USER_PASSWORD__#${INITIAL_USER_PASSWORD}#g;
-       s#__INITIAL_USER__#${INITIAL_USER}#g;
-       s#__LE_AWS_REGION__#${LE_AWS_REGION}#g;
-       s#__LE_AWS_SECRET_ACCESS_KEY__#${LE_AWS_SECRET_ACCESS_KEY}#g;
-       s#__LE_AWS_ACCESS_KEY_ID__#${LE_AWS_ACCESS_KEY_ID}#g;
-       s#__DATABASE_PASSWORD__#${DATABASE_PASSWORD}#g;
-       s#__KEEPWEB_EXT_SSL_PORT__#${KEEPWEB_EXT_SSL_PORT}#g;
-       s#__KEEP_EXT_SSL_PORT__#${KEEP_EXT_SSL_PORT}#g;
-       s#__MANAGEMENT_TOKEN__#${MANAGEMENT_TOKEN}#g;
-       s#__RELEASE__#${RELEASE}#g;
-       s#__SYSTEM_ROOT_TOKEN__#${SYSTEM_ROOT_TOKEN}#g;
-       s#__VERSION__#${VERSION}#g;
-       s#__WEBSHELL_EXT_SSL_PORT__#${WEBSHELL_EXT_SSL_PORT}#g;
-       s#__WEBSOCKET_EXT_SSL_PORT__#${WEBSOCKET_EXT_SSL_PORT}#g;
-       s#__WORKBENCH1_EXT_SSL_PORT__#${WORKBENCH1_EXT_SSL_PORT}#g;
-       s#__WORKBENCH2_EXT_SSL_PORT__#${WORKBENCH2_EXT_SSL_PORT}#g;
-       s#__CLUSTER_INT_CIDR__#${CLUSTER_INT_CIDR}#g;
-       s#__CONTROLLER_INT_IP__#${CONTROLLER_INT_IP}#g;
-       s#__WEBSOCKET_INT_IP__#${WEBSOCKET_INT_IP}#g;
-       s#__KEEP_INT_IP__#${KEEP_INT_IP}#g;
-       s#__KEEPSTORE0_INT_IP__#${KEEPSTORE0_INT_IP}#g;
-       s#__KEEPSTORE1_INT_IP__#${KEEPSTORE1_INT_IP}#g;
-       s#__KEEPWEB_INT_IP__#${KEEPWEB_INT_IP}#g;
-       s#__WEBSHELL_INT_IP__#${WEBSHELL_INT_IP}#g;
-       s#__SHELL_INT_IP__#${SHELL_INT_IP}#g;
-       s#__WORKBENCH1_INT_IP__#${WORKBENCH1_INT_IP}#g;
-       s#__WORKBENCH2_INT_IP__#${WORKBENCH2_INT_IP}#g;
-       s#__DATABASE_INT_IP__#${DATABASE_INT_IP}#g;
-       s#__WORKBENCH_SECRET_KEY__#${WORKBENCH_SECRET_KEY}#g;
-       s#__SSL_KEY_ENCRYPTED__#${SSL_KEY_ENCRYPTED}#g;
-       s#__SSL_KEY_AWS_REGION__#${SSL_KEY_AWS_REGION}#g;
-       s#__SSL_KEY_AWS_SECRET_NAME__#${SSL_KEY_AWS_SECRET_NAME}#g" \
-  "${f}" > "${P_DIR}"/$(basename "${f}")
+  apply_var_substitutions "${f}" "${P_DIR}"/$(basename "${f}")
 done
 
 if [ ! -d "${SOURCE_TESTS_DIR}" ]; then
   echo "WARNING: The tests directory was not copied to \"${SOURCE_TESTS_DIR}\"."
-  if [ "x${TEST}" = "xyes" ]; then
+  if [ "x${TEST:-}" = "xyes" ]; then
     echo "WARNING: Disabling tests for this installation."
   fi
   TEST="no"
@@ -474,46 +521,10 @@ fi
 # Replace helper state files that differ from the formula's examples
 if [ -d "${SOURCE_STATES_DIR}" ]; then
   mkdir -p "${F_DIR}"/extra/extra
+  rm -rf "${F_DIR}"/extra/extra/*
 
   for f in $(ls "${SOURCE_STATES_DIR}"/*); do
-    sed "s#__ANONYMOUS_USER_TOKEN__#${ANONYMOUS_USER_TOKEN}#g;
-         s#__CLUSTER__#${CLUSTER}#g;
-         s#__BLOB_SIGNING_KEY__#${BLOB_SIGNING_KEY}#g;
-         s#__CONTROLLER_EXT_SSL_PORT__#${CONTROLLER_EXT_SSL_PORT}#g;
-         s#__DOMAIN__#${DOMAIN}#g;
-         s#__HOSTNAME_EXT__#${HOSTNAME_EXT}#g;
-         s#__IP_INT__#${IP_INT}#g;
-         s#__INITIAL_USER_EMAIL__#${INITIAL_USER_EMAIL}#g;
-         s#__INITIAL_USER_PASSWORD__#${INITIAL_USER_PASSWORD}#g;
-         s#__INITIAL_USER__#${INITIAL_USER}#g;
-         s#__DATABASE_PASSWORD__#${DATABASE_PASSWORD}#g;
-         s#__KEEPWEB_EXT_SSL_PORT__#${KEEPWEB_EXT_SSL_PORT}#g;
-         s#__KEEP_EXT_SSL_PORT__#${KEEP_EXT_SSL_PORT}#g;
-         s#__MANAGEMENT_TOKEN__#${MANAGEMENT_TOKEN}#g;
-         s#__RELEASE__#${RELEASE}#g;
-         s#__SYSTEM_ROOT_TOKEN__#${SYSTEM_ROOT_TOKEN}#g;
-         s#__VERSION__#${VERSION}#g;
-         s#__CLUSTER_INT_CIDR__#${CLUSTER_INT_CIDR}#g;
-         s#__CONTROLLER_INT_IP__#${CONTROLLER_INT_IP}#g;
-         s#__WEBSOCKET_INT_IP__#${WEBSOCKET_INT_IP}#g;
-         s#__KEEP_INT_IP__#${KEEP_INT_IP}#g;
-         s#__KEEPSTORE0_INT_IP__#${KEEPSTORE0_INT_IP}#g;
-         s#__KEEPSTORE1_INT_IP__#${KEEPSTORE1_INT_IP}#g;
-         s#__KEEPWEB_INT_IP__#${KEEPWEB_INT_IP}#g;
-         s#__WEBSHELL_INT_IP__#${WEBSHELL_INT_IP}#g;
-         s#__WORKBENCH1_INT_IP__#${WORKBENCH1_INT_IP}#g;
-         s#__WORKBENCH2_INT_IP__#${WORKBENCH2_INT_IP}#g;
-         s#__DATABASE_INT_IP__#${DATABASE_INT_IP}#g;
-         s#__WEBSHELL_EXT_SSL_PORT__#${WEBSHELL_EXT_SSL_PORT}#g;
-         s#__SHELL_INT_IP__#${SHELL_INT_IP}#g;
-         s#__WEBSOCKET_EXT_SSL_PORT__#${WEBSOCKET_EXT_SSL_PORT}#g;
-         s#__WORKBENCH1_EXT_SSL_PORT__#${WORKBENCH1_EXT_SSL_PORT}#g;
-         s#__WORKBENCH2_EXT_SSL_PORT__#${WORKBENCH2_EXT_SSL_PORT}#g;
-         s#__WORKBENCH_SECRET_KEY__#${WORKBENCH_SECRET_KEY}#g;
-         s#__SSL_KEY_ENCRYPTED__#${SSL_KEY_ENCRYPTED}#g;
-         s#__SSL_KEY_AWS_REGION__#${SSL_KEY_AWS_REGION}#g;
-         s#__SSL_KEY_AWS_SECRET_NAME__#${SSL_KEY_AWS_SECRET_NAME}#g" \
-    "${f}" > "${F_DIR}/extra/extra"/$(basename "${f}")
+    apply_var_substitutions "${f}" "${F_DIR}/extra/extra"/$(basename "${f}")
   done
 fi
 
@@ -521,15 +532,21 @@ fi
 # As we need to separate both states and pillars in case we want specific
 # roles, we iterate on both at the same time
 
+# Formula template overrides (TOFS)
+# See: https://template-formula.readthedocs.io/en/latest/TOFS_pattern.html#template-override
+if [ -d ${SOURCE_TOFS_DIR} ]; then
+  find ${SOURCE_TOFS_DIR} -mindepth 1 -maxdepth 1 -type d -exec cp -r "{}" ${S_DIR} \;
+fi
+
 # States
-cat > ${S_DIR}/top.sls << EOFTSLS
+cat > ${STATES_TOP} << EOFTSLS
 base:
   '*':
     - locale
 EOFTSLS
 
 # Pillars
-cat > ${P_DIR}/top.sls << EOFPSLS
+cat > ${PILLARS_TOP} << EOFPSLS
 base:
   '*':
     - locale
@@ -547,7 +564,7 @@ if [ -d "${F_DIR}"/extra/extra ]; then
     SKIP_SNAKE_OIL="dont_add_snakeoil_certs"
   fi
   for f in $(ls "${F_DIR}"/extra/extra/*.sls | egrep -v "${SKIP_SNAKE_OIL}|shell_"); do
-  echo "    - extra.$(basename ${f} | sed 's/.sls$//g')" >> ${S_DIR}/top.sls
+  echo "    - extra.$(basename ${f} | sed 's/.sls$//g')" >> ${STATES_TOP}
   done
   # Use byo or self-signed certificates
   if [ "${SSL_MODE}" != "lets-encrypt" ]; then
@@ -557,51 +574,63 @@ fi
 
 # If we want specific roles for a node, just add the desired states
 # and its dependencies
-if [ -z "${ROLES}" ]; then
+if [ -z "${ROLES:-}" ]; then
   # States
-  echo "    - nginx.passenger" >> ${S_DIR}/top.sls
+  echo "    - nginx.passenger" >> ${STATES_TOP}
   if [ "${SSL_MODE}" = "lets-encrypt" ]; then
     if [ "${USE_LETSENCRYPT_ROUTE53}" = "yes" ]; then
-      grep -q "aws_credentials" ${S_DIR}/top.sls || echo "    - extra.aws_credentials" >> ${S_DIR}/top.sls
+      grep -q "aws_credentials" ${STATES_TOP} || echo "    - extra.aws_credentials" >> ${STATES_TOP}
     fi
-    grep -q "letsencrypt" ${S_DIR}/top.sls || echo "    - letsencrypt" >> ${S_DIR}/top.sls
+    grep -q "letsencrypt" ${STATES_TOP} || echo "    - letsencrypt" >> ${STATES_TOP}
   else
     mkdir -p --mode=0700 /srv/salt/certs
     if [ "${SSL_MODE}" = "bring-your-own" ]; then
       # Copy certs to formula extra/files
       install --mode=0600 ${CUSTOM_CERTS_DIR}/* /srv/salt/certs/
       # We add the custom_certs state
-      grep -q "custom_certs" ${S_DIR}/top.sls || echo "    - extra.custom_certs" >> ${S_DIR}/top.sls
+      grep -q "custom_certs" ${STATES_TOP} || echo "    - extra.custom_certs" >> ${STATES_TOP}
       if [ "${SSL_KEY_ENCRYPTED}" = "yes" ]; then
-        grep -q "ssl_key_encrypted" ${S_DIR}/top.sls || echo "    - extra.ssl_key_encrypted" >> ${S_DIR}/top.sls
+        grep -q "ssl_key_encrypted" ${STATES_TOP} || echo "    - extra.ssl_key_encrypted" >> ${STATES_TOP}
       fi
     fi
     # In self-signed mode, the certificate files will be created and put in the
     # destination directory by the snakeoil_certs.sls state file
   fi
 
-  echo "    - postgres" >> ${S_DIR}/top.sls
-  echo "    - logrotate" >> ${S_DIR}/top.sls
-  echo "    - docker.software" >> ${S_DIR}/top.sls
-  echo "    - arvados" >> ${S_DIR}/top.sls
-  echo "    - extra.shell_sudo_passwordless" >> ${S_DIR}/top.sls
-  echo "    - extra.shell_cron_add_login_sync" >> ${S_DIR}/top.sls
-  echo "    - extra.passenger_rvm" >> ${S_DIR}/top.sls
+  echo "    - postgres" >> ${STATES_TOP}
+  echo "    - logrotate" >> ${STATES_TOP}
+  echo "    - docker.software" >> ${STATES_TOP}
+  echo "    - arvados.repo" >> ${STATES_TOP}
+  echo "    - arvados.config" >> ${STATES_TOP}
+  echo "    - arvados.ruby" >> ${STATES_TOP}
+  echo "    - arvados.api" >> ${STATES_TOP}
+  echo "    - arvados.controller" >> ${STATES_TOP}
+  echo "    - arvados.keepstore" >> ${STATES_TOP}
+  echo "    - arvados.websocket" >> ${STATES_TOP}
+  echo "    - arvados.keepweb" >> ${STATES_TOP}
+  echo "    - arvados.workbench2" >> ${STATES_TOP}
+  echo "    - arvados.keepproxy" >> ${STATES_TOP}
+  echo "    - arvados.shell" >> ${STATES_TOP}
+  echo "    - arvados.dispatcher" >> ${STATES_TOP}
+  echo "    - extra.shell_sudo_passwordless" >> ${STATES_TOP}
+  echo "    - extra.shell_cron_add_login_sync" >> ${STATES_TOP}
+  echo "    - extra.passenger_rvm" >> ${STATES_TOP}
+  echo "    - extra.workbench1_uninstall" >> ${STATES_TOP}
 
   # Pillars
-  echo "    - docker" >> ${P_DIR}/top.sls
-  echo "    - nginx_api_configuration" >> ${P_DIR}/top.sls
-  echo "    - logrotate_api" >> ${P_DIR}/top.sls
-  echo "    - nginx_controller_configuration" >> ${P_DIR}/top.sls
-  echo "    - nginx_keepproxy_configuration" >> ${P_DIR}/top.sls
-  echo "    - nginx_keepweb_configuration" >> ${P_DIR}/top.sls
-  echo "    - nginx_passenger" >> ${P_DIR}/top.sls
-  echo "    - nginx_websocket_configuration" >> ${P_DIR}/top.sls
-  echo "    - nginx_webshell_configuration" >> ${P_DIR}/top.sls
-  echo "    - nginx_workbench2_configuration" >> ${P_DIR}/top.sls
-  echo "    - nginx_workbench_configuration" >> ${P_DIR}/top.sls
-  echo "    - logrotate_wb1" >> ${P_DIR}/top.sls
-  echo "    - postgresql" >> ${P_DIR}/top.sls
+  echo "    - docker" >> ${PILLARS_TOP}
+  echo "    - nginx_api_configuration" >> ${PILLARS_TOP}
+  echo "    - logrotate_api" >> ${PILLARS_TOP}
+  echo "    - nginx_controller_configuration" >> ${PILLARS_TOP}
+  echo "    - nginx_keepproxy_configuration" >> ${PILLARS_TOP}
+  echo "    - nginx_keepweb_configuration" >> ${PILLARS_TOP}
+  echo "    - nginx_passenger" >> ${PILLARS_TOP}
+  echo "    - nginx_websocket_configuration" >> ${PILLARS_TOP}
+  echo "    - nginx_webshell_configuration" >> ${PILLARS_TOP}
+  echo "    - nginx_workbench2_configuration" >> ${PILLARS_TOP}
+  echo "    - nginx_workbench_configuration" >> ${PILLARS_TOP}
+  echo "    - logrotate_wb1" >> ${PILLARS_TOP}
+  echo "    - postgresql" >> ${PILLARS_TOP}
 
   # We need to tweak the Nginx's pillar depending whether we want plan nginx or nginx+passenger
   NGINX_INSTALL_SOURCE="install_from_phusionpassenger"
@@ -609,9 +638,9 @@ if [ -z "${ROLES}" ]; then
 
   if [ "${SSL_MODE}" = "lets-encrypt" ]; then
     if [ "${USE_LETSENCRYPT_ROUTE53}" = "yes" ]; then
-      grep -q "aws_credentials" ${P_DIR}/top.sls || echo "    - aws_credentials" >> ${P_DIR}/top.sls
+      grep -q "aws_credentials" ${PILLARS_TOP} || echo "    - aws_credentials" >> ${PILLARS_TOP}
     fi
-    grep -q "letsencrypt" ${P_DIR}/top.sls || echo "    - letsencrypt" >> ${P_DIR}/top.sls
+    grep -q "letsencrypt" ${PILLARS_TOP} || echo "    - letsencrypt" >> ${PILLARS_TOP}
 
     hosts=("controller" "websocket" "workbench" "workbench2" "webshell" "keepproxy")
     if [ ${USE_SINGLE_HOSTNAME} = "no" ]; then
@@ -627,7 +656,7 @@ if [ -z "${ROLES}" ]; then
         CERT_NAME=${HOSTNAME_EXT}
       else
         # We are in a multiple-hostnames env
-        CERT_NAME=${c}.${CLUSTER}.${DOMAIN}
+        CERT_NAME=${c}.${DOMAIN}
       fi
 
       # As the pillar differs whether we use LE or custom certs, we need to do a final edition on them
@@ -638,7 +667,7 @@ if [ -z "${ROLES}" ]; then
     done
   else
     # Use custom certs (either dev mode or prod)
-    grep -q "extra_custom_certs" ${P_DIR}/top.sls || echo "    - extra_custom_certs" >> ${P_DIR}/top.sls
+    grep -q "extra_custom_certs" ${PILLARS_TOP} || echo "    - extra_custom_certs" >> ${PILLARS_TOP}
     # And add the certs in the custom_certs pillar
     echo "extra_custom_certs_dir: /srv/salt/certs" > ${P_DIR}/extra_custom_certs.sls
     echo "extra_custom_certs:" >> ${P_DIR}/extra_custom_certs.sls
@@ -660,7 +689,7 @@ if [ -z "${ROLES}" ]; then
       grep -q ${CERT_NAME} ${P_DIR}/extra_custom_certs.sls || echo "  - ${CERT_NAME}" >> ${P_DIR}/extra_custom_certs.sls
 
       # As the pillar differs whether we use LE or custom certs, we need to do a final edition on them
-      sed -i "s/__CERT_REQUIRES__/file: extra_custom_certs_file_copy_arvados-${CERT_NAME}.pem/g;
+      sed -i "s/__CERT_REQUIRES__/file: extra_custom_certs_${CERT_NAME}_cert_file_copy/g;
               s#__CERT_PEM__#/etc/nginx/ssl/arvados-${CERT_NAME}.pem#g;
               s#__CERT_KEY__#/etc/nginx/ssl/arvados-${CERT_NAME}.key#g" \
       ${P_DIR}/nginx_${c}_configuration.sls
@@ -668,11 +697,11 @@ if [ -z "${ROLES}" ]; then
   fi
 else
   # If we add individual roles, make sure we add the repo first
-  echo "    - arvados.repo" >> ${S_DIR}/top.sls
+  echo "    - arvados.repo" >> ${STATES_TOP}
   # We add the extra_custom_certs state
-  grep -q "extra.custom_certs"    ${S_DIR}/top.sls || echo "    - extra.custom_certs" >> ${S_DIR}/top.sls
+  grep -q "extra.custom_certs"    ${STATES_TOP} || echo "    - extra.custom_certs" >> ${STATES_TOP}
   if [ "${SSL_KEY_ENCRYPTED}" = "yes" ]; then
-    grep -q "ssl_key_encrypted" ${S_DIR}/top.sls || echo "    - extra.ssl_key_encrypted" >> ${S_DIR}/top.sls
+    grep -q "ssl_key_encrypted" ${STATES_TOP} || echo "    - extra.ssl_key_encrypted" >> ${STATES_TOP}
   fi
 
   # And we add the basic part for the certs pillar
@@ -680,70 +709,194 @@ else
     # And add the certs in the custom_certs pillar
     echo "extra_custom_certs_dir: /srv/salt/certs" > ${P_DIR}/extra_custom_certs.sls
     echo "extra_custom_certs:" >> ${P_DIR}/extra_custom_certs.sls
-    grep -q "extra_custom_certs" ${P_DIR}/top.sls || echo "    - extra_custom_certs" >> ${P_DIR}/top.sls
+    grep -q "extra_custom_certs" ${PILLARS_TOP} || echo "    - extra_custom_certs" >> ${PILLARS_TOP}
   fi
 
-  for R in ${ROLES}; do
+  # Prometheus state on all nodes due to the node exporter below
+  grep -q "\- prometheus$" ${STATES_TOP} || echo "    - prometheus" >> ${STATES_TOP}
+  # Prometheus node exporter pillar
+  grep -q "prometheus_node_exporter" ${PILLARS_TOP} || echo "    - prometheus_node_exporter" >> ${PILLARS_TOP}
+
+  for R in ${ROLES:-}; do
     case "${R}" in
       "database")
         # States
-        echo "    - postgres" >> ${S_DIR}/top.sls
+        grep -q "\- postgres$" ${STATES_TOP} || echo "    - postgres" >> ${STATES_TOP}
+        grep -q "extra.prometheus_pg_exporter" ${STATES_TOP} || echo "    - extra.prometheus_pg_exporter" >> ${STATES_TOP}
         # Pillars
-        echo '    - postgresql' >> ${P_DIR}/top.sls
+        grep -q "postgresql" ${PILLARS_TOP} || echo "    - postgresql" >> ${PILLARS_TOP}
+        grep -q "prometheus_pg_exporter" ${PILLARS_TOP} || echo "    - prometheus_pg_exporter" >> ${PILLARS_TOP}
       ;;
-      "api")
-        # States
-        grep -q "    - logrotate" ${S_DIR}/top.sls || echo "    - logrotate" >> ${S_DIR}/top.sls
-        if grep -q "    - nginx.*$" ${S_DIR}/top.sls; then
-          sed -i s/"^    - nginx.*$"/"    - nginx.passenger"/g ${S_DIR}/top.sls
-        else
-          echo "    - nginx.passenger" >> ${S_DIR}/top.sls
+      "monitoring")
+        ### Support files ###
+        GRAFANA_DASHBOARDS_DEST_DIR=/srv/salt/dashboards
+        mkdir -p "${GRAFANA_DASHBOARDS_DEST_DIR}"
+        rm -f "${GRAFANA_DASHBOARDS_DEST_DIR}"/*
+        # "ArvadosPromDataSource" is the hardcoded UID for Prometheus' datasource
+        # in Grafana.
+        for f in $(ls "${GRAFANA_DASHBOARDS_DIR}"/*.json); do
+          sed "s#__TLS_EXPIRATION_YELLOW__#${TLS_EXPIRATION_YELLOW}#g;
+               s#__TLS_EXPIRATION_GREEN__#${TLS_EXPIRATION_GREEN}#g;
+               s#\${DS_PROMETHEUS}#ArvadosPromDataSource#g" \
+          "${f}" > "${GRAFANA_DASHBOARDS_DEST_DIR}"/$(basename "${f}")
+        done
+
+        ### States ###
+        grep -q "\- nginx$" ${STATES_TOP} || echo "    - nginx" >> ${STATES_TOP}
+        grep -q "extra.nginx_prometheus_configuration" ${STATES_TOP} || echo "    - extra.nginx_prometheus_configuration" >> ${STATES_TOP}
+
+        grep -q "\- grafana$" ${STATES_TOP} || echo "    - grafana" >> ${STATES_TOP}
+        grep -q "extra.grafana_datasource" ${STATES_TOP} || echo "    - extra.grafana_datasource" >> ${STATES_TOP}
+        grep -q "extra.grafana_dashboards" ${STATES_TOP} || echo "    - extra.grafana_dashboards" >> ${STATES_TOP}
+        grep -q "extra.grafana_admin_user" ${STATES_TOP} || echo "    - extra.grafana_admin_user" >> ${STATES_TOP}
+
+        if [ "${SSL_MODE}" = "lets-encrypt" ]; then
+          grep -q "letsencrypt"     ${STATES_TOP} || echo "    - letsencrypt" >> ${STATES_TOP}
+          if [ "x${USE_LETSENCRYPT_ROUTE53:-}" = "xyes" ]; then
+            grep -q "aws_credentials" ${STATES_TOP} || echo "    - aws_credentials" >> ${STATES_TOP}
+          fi
+        elif [ "${SSL_MODE}" = "bring-your-own" ]; then
+          for SVC in grafana prometheus; do
+            copy_custom_cert ${CUSTOM_CERTS_DIR} ${SVC}
+          done
         fi
-        echo "    - extra.passenger_rvm" >> ${S_DIR}/top.sls
-        ### If we don't install and run LE before arvados-api-server, it fails and breaks everything
-        ### after it. So we add this here as we are, after all, sharing the host for api and controller
+        ### Pillars ###
+        grep -q "prometheus_server" ${PILLARS_TOP} || echo "    - prometheus_server" >> ${PILLARS_TOP}
+        grep -q "grafana" ${PILLARS_TOP} || echo "    - grafana" >> ${PILLARS_TOP}
+        for SVC in grafana prometheus; do
+          grep -q "nginx_${SVC}_configuration" ${PILLARS_TOP} || echo "    - nginx_${SVC}_configuration" >> ${PILLARS_TOP}
+        done
+        grep -q "nginx_snippets" ${PILLARS_TOP} || echo "    - nginx_snippets" >> ${PILLARS_TOP}
+        if [ "${SSL_MODE}" = "lets-encrypt" ]; then
+          grep -q "letsencrypt"     ${PILLARS_TOP} || echo "    - letsencrypt" >> ${PILLARS_TOP}
+          for SVC in grafana prometheus; do
+            grep -q "letsencrypt_${SVC}_configuration" ${PILLARS_TOP} || echo "    - letsencrypt_${SVC}_configuration" >> ${PILLARS_TOP}
+            sed -i "s/__CERT_REQUIRES__/cmd: create-initial-cert-${SVC}.${DOMAIN}*/g;
+                    s#__CERT_PEM__#/etc/letsencrypt/live/${SVC}.${DOMAIN}/fullchain.pem#g;
+                    s#__CERT_KEY__#/etc/letsencrypt/live/${SVC}.${DOMAIN}/privkey.pem#g" \
+            ${P_DIR}/nginx_${SVC}_configuration.sls
+          done
+          if [ "${USE_LETSENCRYPT_ROUTE53}" = "yes" ]; then
+            grep -q "aws_credentials" ${PILLARS_TOP} || echo "    - aws_credentials" >> ${PILLARS_TOP}
+          fi
+        elif [ "${SSL_MODE}" = "bring-your-own" ]; then
+          grep -q "ssl_key_encrypted" ${PILLARS_TOP} || echo "    - ssl_key_encrypted" >> ${PILLARS_TOP}
+          for SVC in grafana prometheus; do
+            sed -i "s/__CERT_REQUIRES__/file: extra_custom_certs_${SVC}_cert_file_copy/g;
+                    s#__CERT_PEM__#/etc/nginx/ssl/arvados-${SVC}.pem#g;
+                    s#__CERT_KEY__#/etc/nginx/ssl/arvados-${SVC}.key#g" \
+              ${P_DIR}/nginx_${SVC}_configuration.sls
+            grep -q ${SVC} ${P_DIR}/extra_custom_certs.sls || echo "  - ${SVC}" >> ${P_DIR}/extra_custom_certs.sls
+          done
+        fi
+      ;;
+      "balancer")
+        ### States ###
+        grep -q "\- nginx$" ${STATES_TOP} || echo "    - nginx" >> ${STATES_TOP}
+
         if [ "${SSL_MODE}" = "lets-encrypt" ]; then
+          grep -q "letsencrypt"     ${STATES_TOP} || echo "    - letsencrypt" >> ${STATES_TOP}
+          if [ "x${USE_LETSENCRYPT_ROUTE53:-}" = "xyes" ]; then
+            grep -q "aws_credentials" ${STATES_TOP} || echo "    - aws_credentials" >> ${STATES_TOP}
+          fi
+        elif [ "${SSL_MODE}" = "bring-your-own" ]; then
+          copy_custom_cert ${CUSTOM_CERTS_DIR} ${R}
+        fi
+
+        ### Pillars ###
+        grep -q "nginx_${R}_configuration" ${PILLARS_TOP} || echo "    - nginx_${R}_configuration" >> ${PILLARS_TOP}
+
+        if [ "${SSL_MODE}" = "lets-encrypt" ]; then
+          grep -q "letsencrypt"     ${PILLARS_TOP} || echo "    - letsencrypt" >> ${PILLARS_TOP}
+
+          grep -q "letsencrypt_${R}_configuration" ${PILLARS_TOP} || echo "    - letsencrypt_${R}_configuration" >> ${PILLARS_TOP}
+          sed -i "s/__CERT_REQUIRES__/cmd: create-initial-cert-${ROLE2NODES['balancer']}*/g;
+                  s#__CERT_PEM__#/etc/letsencrypt/live/${ROLE2NODES['balancer']}/fullchain.pem#g;
+                  s#__CERT_KEY__#/etc/letsencrypt/live/${ROLE2NODES['balancer']}/privkey.pem#g" \
+          ${P_DIR}/nginx_${R}_configuration.sls
+
           if [ "${USE_LETSENCRYPT_ROUTE53}" = "yes" ]; then
-            grep -q "aws_credentials" ${S_DIR}/top.sls || echo "    - aws_credentials" >> ${S_DIR}/top.sls
+            grep -q "aws_credentials" ${PILLARS_TOP} || echo "    - aws_credentials" >> ${PILLARS_TOP}
           fi
-          grep -q "letsencrypt" ${S_DIR}/top.sls || echo "    - letsencrypt" >> ${S_DIR}/top.sls
+        elif [ "${SSL_MODE}" = "bring-your-own" ]; then
+          grep -q "ssl_key_encrypted" ${PILLARS_TOP} || echo "    - ssl_key_encrypted" >> ${PILLARS_TOP}
+          sed -i "s/__CERT_REQUIRES__/file: extra_custom_certs_${R}_cert_file_copy/g;
+                  s#__CERT_PEM__#/etc/nginx/ssl/arvados-${R}.pem#g;
+                  s#__CERT_KEY__#/etc/nginx/ssl/arvados-${R}.key#g" \
+            ${P_DIR}/nginx_${R}_configuration.sls
+          grep -q "${R}" ${P_DIR}/extra_custom_certs.sls || echo "  - ${R}" >> ${P_DIR}/extra_custom_certs.sls
+        fi
+      ;;
+      "controller")
+        ### States ###
+        grep -q "    - logrotate" ${STATES_TOP} || echo "    - logrotate" >> ${STATES_TOP}
+        if grep -q "    - nginx.*$" ${STATES_TOP}; then
+          sed -i s/"^    - nginx.*$"/"    - nginx.passenger"/g ${STATES_TOP}
         else
-          # Use custom certs
-          if [ "${SSL_MODE}" = "bring-your-own" ]; then
-            copy_custom_cert ${CUSTOM_CERTS_DIR} controller
+          echo "    - nginx.passenger" >> ${STATES_TOP}
+        fi
+        echo "    - extra.passenger_rvm" >> ${STATES_TOP}
+        grep -q "^    - postgres\\.client$" ${STATES_TOP} || echo "    - postgres.client" >> ${STATES_TOP}
+
+        ### If we don't install and run LE before arvados-api-server, it fails and breaks everything
+        ### after it. So we add this here as we are, after all, sharing the host for api and controller
+        if [ "${ENABLE_BALANCER}" == "no" ]; then
+          if [ "${SSL_MODE}" = "lets-encrypt" ]; then
+            if [ "x${USE_LETSENCRYPT_ROUTE53:-}" = "xyes" ]; then
+              grep -q "aws_credentials" ${STATES_TOP} || echo "    - aws_credentials" >> ${STATES_TOP}
+            fi
+            grep -q "letsencrypt"     ${STATES_TOP} || echo "    - letsencrypt" >> ${STATES_TOP}
+          elif [ "${SSL_MODE}" = "bring-your-own" ]; then
+            copy_custom_cert ${CUSTOM_CERTS_DIR} ${R}
+            grep -q controller ${P_DIR}/extra_custom_certs.sls || echo "  - controller" >> ${P_DIR}/extra_custom_certs.sls
           fi
-          grep -q controller ${P_DIR}/extra_custom_certs.sls || echo "  - controller" >> ${P_DIR}/extra_custom_certs.sls
         fi
-        grep -q "arvados.${R}" ${S_DIR}/top.sls    || echo "    - arvados.${R}" >> ${S_DIR}/top.sls
-        # Pillars
-        grep -q "logrotate_api" ${P_DIR}/top.sls            || echo "    - logrotate_api" >> ${P_DIR}/top.sls
-        grep -q "aws_credentials" ${P_DIR}/top.sls          || echo "    - aws_credentials" >> ${P_DIR}/top.sls
-        grep -q "postgresql" ${P_DIR}/top.sls               || echo "    - postgresql" >> ${P_DIR}/top.sls
-        grep -q "nginx_passenger" ${P_DIR}/top.sls          || echo "    - nginx_passenger" >> ${P_DIR}/top.sls
-        grep -q "nginx_${R}_configuration" ${P_DIR}/top.sls || echo "    - nginx_${R}_configuration" >> ${P_DIR}/top.sls
+        grep -q "arvados.api" ${STATES_TOP} || echo "    - arvados.api" >> ${STATES_TOP}
+        grep -q "arvados.controller" ${STATES_TOP} || echo "    - arvados.controller" >> ${STATES_TOP}
+
+        ### Pillars ###
+        grep -q "logrotate_api" ${PILLARS_TOP}            || echo "    - logrotate_api" >> ${PILLARS_TOP}
+        grep -q "aws_credentials" ${PILLARS_TOP}          || echo "    - aws_credentials" >> ${PILLARS_TOP}
+        grep -q "postgresql" ${PILLARS_TOP}               || echo "    - postgresql" >> ${PILLARS_TOP}
+        grep -q "nginx_passenger" ${PILLARS_TOP}          || echo "    - nginx_passenger" >> ${PILLARS_TOP}
+        grep -q "nginx_snippets" ${PILLARS_TOP}           || echo "    - nginx_snippets" >> ${PILLARS_TOP}
+        grep -q "nginx_api_configuration" ${PILLARS_TOP} || echo "    - nginx_api_configuration" >> ${PILLARS_TOP}
+        grep -q "nginx_controller_configuration" ${PILLARS_TOP} || echo "    - nginx_controller_configuration" >> ${PILLARS_TOP}
+
+        if [ "${ENABLE_BALANCER}" == "no" ]; then
+          if [ "${SSL_MODE}" = "lets-encrypt" ]; then
+            if [ "${USE_LETSENCRYPT_ROUTE53}" = "yes" ]; then
+              grep -q "aws_credentials" ${PILLARS_TOP} || echo "    - aws_credentials" >> ${PILLARS_TOP}
+            fi
 
+            grep -q "letsencrypt"     ${PILLARS_TOP} || echo "    - letsencrypt" >> ${PILLARS_TOP}
+            grep -q "letsencrypt_${R}_configuration" ${PILLARS_TOP} || echo "    - letsencrypt_${R}_configuration" >> ${PILLARS_TOP}
+            sed -i "s/__CERT_REQUIRES__/cmd: create-initial-cert-${R}.${DOMAIN}*/g;
+                    s#__CERT_PEM__#/etc/letsencrypt/live/${R}.${DOMAIN}/fullchain.pem#g;
+                    s#__CERT_KEY__#/etc/letsencrypt/live/${R}.${DOMAIN}/privkey.pem#g" \
+            ${P_DIR}/nginx_${R}_configuration.sls
+          else
+            grep -q "ssl_key_encrypted" ${PILLARS_TOP} || echo "    - ssl_key_encrypted" >> ${PILLARS_TOP}
+            sed -i "s/__CERT_REQUIRES__/file: extra_custom_certs_${R}_cert_file_copy/g;
+                    s#__CERT_PEM__#/etc/nginx/ssl/arvados-${R}.pem#g;
+                    s#__CERT_KEY__#/etc/nginx/ssl/arvados-${R}.key#g" \
+            ${P_DIR}/nginx_${R}_configuration.sls
+            grep -q ${R} ${P_DIR}/extra_custom_certs.sls || echo "  - ${R}" >> ${P_DIR}/extra_custom_certs.sls
+          fi
+        fi
         # We need to tweak the Nginx's pillar depending whether we want plain nginx or nginx+passenger
         NGINX_INSTALL_SOURCE="install_from_phusionpassenger"
         sed -i "s/__NGINX_INSTALL_SOURCE__/${NGINX_INSTALL_SOURCE}/g" ${P_DIR}/nginx_passenger.sls
       ;;
-      "controller" | "websocket" | "workbench" | "workbench2" | "webshell" | "keepweb" | "keepproxy")
-        # States
-        if [ "${R}" = "workbench" ]; then
-          grep -q "    - logrotate" ${S_DIR}/top.sls || echo "    - logrotate" >> ${S_DIR}/top.sls
-          NGINX_INSTALL_SOURCE="install_from_phusionpassenger"
-          if grep -q "    - nginx$" ${S_DIR}/top.sls; then
-            sed -i s/"^    - nginx.*$"/"    - nginx.passenger"/g ${S_DIR}/top.sls
-          else
-            echo "    - nginx.passenger" >> ${S_DIR}/top.sls
-          fi
-        else
-          grep -q "nginx" ${S_DIR}/top.sls || echo "    - nginx" >> ${S_DIR}/top.sls
-        fi
+      "websocket" | "workbench" | "workbench2" | "webshell" | "keepweb" | "keepproxy")
+        ### States ###
+        grep -q "\- nginx$" ${STATES_TOP} || echo "    - nginx" >> ${STATES_TOP}
+
         if [ "${SSL_MODE}" = "lets-encrypt" ]; then
-          if [ "x${USE_LETSENCRYPT_ROUTE53}" = "xyes" ]; then
-            grep -q "aws_credentials" ${S_DIR}/top.sls || echo "    - aws_credentials" >> ${S_DIR}/top.sls
+          if [ "x${USE_LETSENCRYPT_ROUTE53:-}" = "xyes" ]; then
+            grep -q "aws_credentials" ${STATES_TOP} || echo "    - aws_credentials" >> ${STATES_TOP}
           fi
-          grep -q "letsencrypt"     ${S_DIR}/top.sls || echo "    - letsencrypt" >> ${S_DIR}/top.sls
+          grep -q "letsencrypt"     ${STATES_TOP} || echo "    - letsencrypt" >> ${STATES_TOP}
         else
           # Use custom certs, special case for keepweb
           if [ ${R} = "keepweb" ]; then
@@ -757,58 +910,62 @@ else
             fi
           fi
         fi
+
         # webshell role is just a nginx vhost, so it has no state
-        if [ "${R}" != "webshell" ]; then
-          grep -q "arvados.${R}" ${S_DIR}/top.sls || echo "    - arvados.${R}" >> ${S_DIR}/top.sls
+        # workbench role is deprecated since 2.7.0
+        if [[ "${R}" != "webshell" && "${R}" != "workbench" ]]; then
+          grep -q "arvados.${R}" ${STATES_TOP} || echo "    - arvados.${R}" >> ${STATES_TOP}
         fi
-        # Pillars
-        if [ "${R}" = "workbench" ]; then
-          grep -q "logrotate_wb1" ${P_DIR}/top.sls || echo "    - logrotate_wb1" >> ${P_DIR}/top.sls
+        # Make sure wb1's package get uninstalled
+        if [[ "${R}" == "workbench" ]]; then
+          grep -q "workbench1_uninstall" ${STATES_TOP} || echo "    - extra.workbench1_uninstall" >> ${STATES_TOP}
         fi
-        grep -q "nginx_passenger" ${P_DIR}/top.sls          || echo "    - nginx_passenger" >> ${P_DIR}/top.sls
-        grep -q "nginx_${R}_configuration" ${P_DIR}/top.sls || echo "    - nginx_${R}_configuration" >> ${P_DIR}/top.sls
+
+        ### Pillars ###
+        grep -q "nginx_${R}_configuration" ${PILLARS_TOP} || echo "    - nginx_${R}_configuration" >> ${PILLARS_TOP}
+        grep -q "nginx_snippets" ${PILLARS_TOP} || echo "    - nginx_snippets" >> ${PILLARS_TOP}
         # Special case for keepweb
         if [ ${R} = "keepweb" ]; then
-          grep -q "nginx_download_configuration" ${P_DIR}/top.sls || echo "    - nginx_download_configuration" >> ${P_DIR}/top.sls
-          grep -q "nginx_collections_configuration" ${P_DIR}/top.sls || echo "    - nginx_collections_configuration" >> ${P_DIR}/top.sls
+          grep -q "nginx_download_configuration" ${PILLARS_TOP} || echo "    - nginx_download_configuration" >> ${PILLARS_TOP}
+          grep -q "nginx_collections_configuration" ${PILLARS_TOP} || echo "    - nginx_collections_configuration" >> ${PILLARS_TOP}
         fi
 
         if [ "${SSL_MODE}" = "lets-encrypt" ]; then
           if [ "${USE_LETSENCRYPT_ROUTE53}" = "yes" ]; then
-            grep -q "aws_credentials" ${P_DIR}/top.sls || echo "    - aws_credentials" >> ${P_DIR}/top.sls
+            grep -q "aws_credentials" ${PILLARS_TOP} || echo "    - aws_credentials" >> ${PILLARS_TOP}
           fi
-          grep -q "letsencrypt"     ${P_DIR}/top.sls || echo "    - letsencrypt" >> ${P_DIR}/top.sls
-          grep -q "letsencrypt_${R}_configuration" ${P_DIR}/top.sls || echo "    - letsencrypt_${R}_configuration" >> ${P_DIR}/top.sls
+          grep -q "letsencrypt"     ${PILLARS_TOP} || echo "    - letsencrypt" >> ${PILLARS_TOP}
+          grep -q "letsencrypt_${R}_configuration" ${PILLARS_TOP} || echo "    - letsencrypt_${R}_configuration" >> ${PILLARS_TOP}
 
           # As the pillar differ whether we use LE or custom certs, we need to do a final edition on them
           # Special case for keepweb
           if [ ${R} = "keepweb" ]; then
             for kwsub in download collections; do
-              sed -i "s/__CERT_REQUIRES__/cmd: create-initial-cert-${kwsub}.${CLUSTER}.${DOMAIN}*/g;
-                      s#__CERT_PEM__#/etc/letsencrypt/live/${kwsub}.${CLUSTER}.${DOMAIN}/fullchain.pem#g;
-                      s#__CERT_KEY__#/etc/letsencrypt/live/${kwsub}.${CLUSTER}.${DOMAIN}/privkey.pem#g" \
+              sed -i "s/__CERT_REQUIRES__/cmd: create-initial-cert-${kwsub}.${DOMAIN}*/g;
+                      s#__CERT_PEM__#/etc/letsencrypt/live/${kwsub}.${DOMAIN}/fullchain.pem#g;
+                      s#__CERT_KEY__#/etc/letsencrypt/live/${kwsub}.${DOMAIN}/privkey.pem#g" \
               ${P_DIR}/nginx_${kwsub}_configuration.sls
             done
           else
-            sed -i "s/__CERT_REQUIRES__/cmd: create-initial-cert-${R}.${CLUSTER}.${DOMAIN}*/g;
-                    s#__CERT_PEM__#/etc/letsencrypt/live/${R}.${CLUSTER}.${DOMAIN}/fullchain.pem#g;
-                    s#__CERT_KEY__#/etc/letsencrypt/live/${R}.${CLUSTER}.${DOMAIN}/privkey.pem#g" \
+            sed -i "s/__CERT_REQUIRES__/cmd: create-initial-cert-${R}.${DOMAIN}*/g;
+                    s#__CERT_PEM__#/etc/letsencrypt/live/${R}.${DOMAIN}/fullchain.pem#g;
+                    s#__CERT_KEY__#/etc/letsencrypt/live/${R}.${DOMAIN}/privkey.pem#g" \
             ${P_DIR}/nginx_${R}_configuration.sls
           fi
         else
-          grep -q "ssl_key_encrypted" ${P_DIR}/top.sls || echo "    - ssl_key_encrypted" >> ${P_DIR}/top.sls
+          grep -q "ssl_key_encrypted" ${PILLARS_TOP} || echo "    - ssl_key_encrypted" >> ${PILLARS_TOP}
           # As the pillar differ whether we use LE or custom certs, we need to do a final edition on them
           # Special case for keepweb
           if [ ${R} = "keepweb" ]; then
             for kwsub in download collections; do
-              sed -i "s/__CERT_REQUIRES__/file: extra_custom_certs_file_copy_arvados-${kwsub}.pem/g;
+              sed -i "s/__CERT_REQUIRES__/file: extra_custom_certs_${kwsub}_cert_file_copy/g;
                       s#__CERT_PEM__#/etc/nginx/ssl/arvados-${kwsub}.pem#g;
                       s#__CERT_KEY__#/etc/nginx/ssl/arvados-${kwsub}.key#g" \
               ${P_DIR}/nginx_${kwsub}_configuration.sls
               grep -q ${kwsub} ${P_DIR}/extra_custom_certs.sls || echo "  - ${kwsub}" >> ${P_DIR}/extra_custom_certs.sls
             done
           else
-            sed -i "s/__CERT_REQUIRES__/file: extra_custom_certs_file_copy_arvados-${R}.pem/g;
+            sed -i "s/__CERT_REQUIRES__/file: extra_custom_certs_${R}_cert_file_copy/g;
                     s#__CERT_PEM__#/etc/nginx/ssl/arvados-${R}.pem#g;
                     s#__CERT_KEY__#/etc/nginx/ssl/arvados-${R}.key#g" \
             ${P_DIR}/nginx_${R}_configuration.sls
@@ -820,16 +977,16 @@ else
       ;;
       "shell")
         # States
-        echo "    - extra.shell_sudo_passwordless" >> ${S_DIR}/top.sls
-        echo "    - extra.shell_cron_add_login_sync" >> ${S_DIR}/top.sls
-        grep -q "docker" ${S_DIR}/top.sls       || echo "    - docker.software" >> ${S_DIR}/top.sls
-        grep -q "arvados.${R}" ${S_DIR}/top.sls || echo "    - arvados.${R}" >> ${S_DIR}/top.sls
+        echo "    - extra.shell_sudo_passwordless" >> ${STATES_TOP}
+        echo "    - extra.shell_cron_add_login_sync" >> ${STATES_TOP}
+        grep -q "docker" ${STATES_TOP}       || echo "    - docker.software" >> ${STATES_TOP}
+        grep -q "arvados.${R}" ${STATES_TOP} || echo "    - arvados.${R}" >> ${STATES_TOP}
         # Pillars
-        grep -q "docker" ${P_DIR}/top.sls       || echo "    - docker" >> ${P_DIR}/top.sls
+        grep -q "docker" ${PILLARS_TOP}       || echo "    - docker" >> ${PILLARS_TOP}
       ;;
       "dispatcher" | "keepbalance" | "keepstore")
         # States
-        grep -q "arvados.${R}" ${S_DIR}/top.sls || echo "    - arvados.${R}" >> ${S_DIR}/top.sls
+        grep -q "arvados.${R}" ${STATES_TOP} || echo "    - arvados.${R}" >> ${STATES_TOP}
         # Pillars
         # ATM, no specific pillar needed
       ;;
@@ -857,22 +1014,22 @@ fi
 
 # Leave a copy of the Arvados CA so the user can copy it where it's required
 if [ "${SSL_MODE}" = "self-signed" ]; then
-  echo "Copying the Arvados CA certificate '${CLUSTER}.${DOMAIN}-arvados-snakeoil-ca.crt' to the installer dir, so you can import it"
-  if [ "x${VAGRANT}" = "xyes" ]; then
-    cp /etc/ssl/certs/arvados-snakeoil-ca.pem /vagrant/${CLUSTER}.${DOMAIN}-arvados-snakeoil-ca.pem
+  echo "Copying the Arvados CA certificate '${DOMAIN}-arvados-snakeoil-ca.crt' to the installer dir, so you can import it"
+  if [ "x${VAGRANT:-}" = "xyes" ]; then
+    cp /etc/ssl/certs/arvados-snakeoil-ca.pem /vagrant/${DOMAIN}-arvados-snakeoil-ca.pem
   else
-    cp /etc/ssl/certs/arvados-snakeoil-ca.pem ${SCRIPT_DIR}/${CLUSTER}.${DOMAIN}-arvados-snakeoil-ca.crt
+    cp /etc/ssl/certs/arvados-snakeoil-ca.pem ${SCRIPT_DIR}/${DOMAIN}-arvados-snakeoil-ca.crt
   fi
 fi
 
-if [ "x${VAGRANT}" = "xyes" ]; then
+if [ "x${VAGRANT:-}" = "xyes" ]; then
     # If running in a vagrant VM, also add default user to docker group
     echo "Adding the vagrant user to the docker group"
     usermod -a -G docker vagrant
 fi
 
 # Test that the installation finished correctly
-if [ "x${TEST}" = "xyes" ]; then
+if [ "x${TEST:-}" = "xyes" ]; then
   cd ${T_DIR}
   # If we use RVM, we need to run this with it, or most ruby commands will fail
   RVM_EXEC=""
index 5b9f68969e0756702f892c3dcd8c59dee484cfc1..f8d611400fc2a0ea0500a2173096fe627f2c03d0 100644 (file)
@@ -5,4 +5,5 @@
 locals {
   region_name = data.terraform_remote_state.vpc.outputs.region_name
   cluster_name = data.terraform_remote_state.vpc.outputs.cluster_name
+  custom_tags = data.terraform_remote_state.vpc.outputs.custom_tags
 }
index d4a3a7d21d6ab8971f5d41c871693b55a74b8d40..1e7e577163123188751f170cdf5e54e4b8701de6 100644 (file)
@@ -3,9 +3,11 @@
 # SPDX-License-Identifier: CC-BY-SA-3.0
 
 terraform {
+  required_version = "~> 1.3.0"
   required_providers {
     aws = {
       source = "hashicorp/aws"
+      version = "~> 4.38.0"
     }
   }
 }
@@ -13,9 +15,10 @@ terraform {
 provider "aws" {
   region = local.region_name
   default_tags {
-    tags = {
+    tags = merge(local.custom_tags, {
       Arvados = local.cluster_name
-    }
+      Terraform = true
+    })
   }
 }
 
@@ -24,25 +27,16 @@ resource "aws_s3_bucket" "keep_volume" {
   bucket = "${local.cluster_name}-nyw5e-000000000000000-volume"
 }
 
-resource "aws_s3_bucket_acl" "keep_volume_acl" {
-  bucket = aws_s3_bucket.keep_volume.id
-  acl = "private"
-}
-
-# Avoid direct public access to Keep blocks
-resource "aws_s3_bucket_public_access_block" "keep_volume_public_access" {
-  bucket = aws_s3_bucket.keep_volume.id
-
-  block_public_acls   = true
-  block_public_policy = true
-  ignore_public_acls  = true
-}
-
 resource "aws_iam_role" "keepstore_iam_role" {
   name = "${local.cluster_name}-keepstore-00-iam-role"
   assume_role_policy = "${file("../assumerolepolicy.json")}"
 }
 
+resource "aws_iam_role" "compute_node_iam_role" {
+  name = "${local.cluster_name}-compute-node-00-iam-role"
+  assume_role_policy = "${file("../assumerolepolicy.json")}"
+}
+
 resource "aws_iam_policy" "s3_full_access" {
   name = "${local.cluster_name}_s3_full_access"
   policy = jsonencode({
@@ -63,7 +57,10 @@ resource "aws_iam_policy" "s3_full_access" {
 
 resource "aws_iam_policy_attachment" "s3_full_access_policy_attachment" {
   name = "${local.cluster_name}_s3_full_access_attachment"
-  roles = [ aws_iam_role.keepstore_iam_role.name ]
+  roles = [
+    aws_iam_role.keepstore_iam_role.name,
+    aws_iam_role.compute_node_iam_role.name,
+  ]
   policy_arn = aws_iam_policy.s3_full_access.arn
 }
 
index 6298f926adafc8b28c75ac9308d9a35db7d45fd0..de45aa861925787d457d4e806f8692c0d06af083 100644 (file)
@@ -6,6 +6,10 @@ output "keepstore_iam_role_name" {
   value = aws_iam_role.keepstore_iam_role.name
 }
 
+output "compute_node_iam_role_name" {
+  value = aws_iam_role.compute_node_iam_role.name
+}
+
 output "use_external_db" {
   value = var.use_external_db
 }
\ No newline at end of file
index 3587f252661409227351e55b9968de22bf27724d..0a5057e0e8822ab3201ce2662192ecc34ef20792 100644 (file)
@@ -28,4 +28,8 @@ data "aws_ami" "debian-11" {
     name   = "virtualization-type"
     values = ["hvm"]
   }
+}
+
+data "aws_vpc" "arvados_vpc" {
+  id = data.terraform_remote_state.vpc.outputs.arvados_vpc_id
 }
\ No newline at end of file
index 6a81967cf1eb5c1174c2ba623f4e82af7537ea1e..807bd7d01f14c663e51f2fda0be86ccd92ecd332 100644 (file)
@@ -6,10 +6,26 @@ locals {
   region_name = data.terraform_remote_state.vpc.outputs.region_name
   cluster_name = data.terraform_remote_state.vpc.outputs.cluster_name
   use_external_db = data.terraform_remote_state.data-storage.outputs.use_external_db
+  private_only = data.terraform_remote_state.vpc.outputs.private_only
   public_ip = data.terraform_remote_state.vpc.outputs.public_ip
   private_ip = data.terraform_remote_state.vpc.outputs.private_ip
   pubkey_path = pathexpand(var.pubkey_path)
-  pubkey_name = "arvados-deployer-key"
-  hostnames = [ for hostname, eip_id in data.terraform_remote_state.vpc.outputs.eip_id: hostname ]
+  public_hosts = data.terraform_remote_state.vpc.outputs.public_hosts
+  private_hosts = data.terraform_remote_state.vpc.outputs.private_hosts
+  user_facing_hosts = data.terraform_remote_state.vpc.outputs.user_facing_hosts
+  internal_service_hosts = data.terraform_remote_state.vpc.outputs.internal_service_hosts
   ssl_password_secret_name = "${local.cluster_name}-${var.ssl_password_secret_name_suffix}"
+  instance_ami_id = var.instance_ami != "" ? var.instance_ami : data.aws_ami.debian-11.image_id
+  custom_tags = data.terraform_remote_state.vpc.outputs.custom_tags
+  compute_node_iam_role_name = data.terraform_remote_state.data-storage.outputs.compute_node_iam_role_name
+  instance_profile = {
+    default = aws_iam_instance_profile.default_instance_profile
+    workbench = aws_iam_instance_profile.dispatcher_instance_profile
+    keep0 = aws_iam_instance_profile.keepstore_instance_profile
+  }
+  private_subnet_id = data.terraform_remote_state.vpc.outputs.private_subnet_id
+  public_subnet_id = data.terraform_remote_state.vpc.outputs.public_subnet_id
+  arvados_sg_id = data.terraform_remote_state.vpc.outputs.arvados_sg_id
+  eip_id = data.terraform_remote_state.vpc.outputs.eip_id
+  keepstore_iam_role_name = data.terraform_remote_state.data-storage.outputs.keepstore_iam_role_name
 }
index 9c27b9726cc7507b4827fc5646f3a746564be710..54e2fc412bc8b87883ab8fe83bfaab5b9230104a 100644 (file)
@@ -3,9 +3,11 @@
 # SPDX-License-Identifier: CC-BY-SA-3.0
 
 terraform {
+  required_version = "~> 1.3.0"
   required_providers {
     aws = {
       source = "hashicorp/aws"
+      version = "~> 4.38.0"
     }
   }
 }
@@ -13,22 +15,23 @@ terraform {
 provider "aws" {
   region = local.region_name
   default_tags {
-    tags = {
+    tags = merge(local.custom_tags, {
       Arvados = local.cluster_name
-    }
+      Terraform = true
+    })
   }
 }
 
-resource "aws_key_pair" "deployer" {
-  key_name = local.pubkey_name
-  public_key = file(local.pubkey_path)
-}
-
 resource "aws_iam_instance_profile" "keepstore_instance_profile" {
   name = "${local.cluster_name}-keepstore-00-iam-role"
   role = data.terraform_remote_state.data-storage.outputs.keepstore_iam_role_name
 }
 
+resource "aws_iam_instance_profile" "compute_node_instance_profile" {
+  name = "${local.cluster_name}-compute-node-00-iam-role"
+  role = local.compute_node_iam_role_name
+}
+
 resource "aws_iam_instance_profile" "dispatcher_instance_profile" {
   name = "${local.cluster_name}_dispatcher_instance_profile"
   role = aws_iam_role.cloud_dispatcher_iam_role.name
@@ -36,6 +39,7 @@ resource "aws_iam_instance_profile" "dispatcher_instance_profile" {
 
 resource "aws_secretsmanager_secret" "ssl_password_secret" {
   name = local.ssl_password_secret_name
+  recovery_window_in_days = 0
 }
 
 resource "aws_iam_instance_profile" "default_instance_profile" {
@@ -44,26 +48,29 @@ resource "aws_iam_instance_profile" "default_instance_profile" {
 }
 
 resource "aws_instance" "arvados_service" {
-  for_each = toset(local.hostnames)
-  ami = data.aws_ami.debian-11.image_id
-  instance_type = var.default_instance_type
-  key_name = local.pubkey_name
+  for_each = toset(concat(local.public_hosts, local.private_hosts))
+  ami = local.instance_ami_id
+  instance_type = try(var.instance_type[each.value], var.instance_type.default)
   user_data = templatefile("user_data.sh", {
-    "hostname": each.value
+    "hostname": each.value,
+    "deploy_user": var.deploy_user,
+    "ssh_pubkey": file(local.pubkey_path)
   })
   private_ip = local.private_ip[each.value]
-  subnet_id = data.terraform_remote_state.vpc.outputs.arvados_subnet_id
-  vpc_security_group_ids = [ data.terraform_remote_state.vpc.outputs.arvados_sg_id ]
-  # This should be done in a more readable way
-  iam_instance_profile = each.value == "controller" ? aws_iam_instance_profile.dispatcher_instance_profile.name : length(regexall("^keep[0-9]+", each.value)) > 0 ? aws_iam_instance_profile.keepstore_instance_profile.name : aws_iam_instance_profile.default_instance_profile.name
+  subnet_id = contains(local.user_facing_hosts, each.value) ? local.public_subnet_id : local.private_subnet_id
+  vpc_security_group_ids = [ local.arvados_sg_id ]
+  iam_instance_profile = try(local.instance_profile[each.value], local.instance_profile.default).name
   tags = {
-    Name = "arvados_service_${each.value}"
+    Name = "${local.cluster_name}_arvados_service_${each.value}"
   }
   root_block_device {
     volume_type = "gp3"
-    volume_size = (each.value == "controller" && !local.use_external_db) ? 70 : 20
+    volume_size = try(var.instance_volume_size[each.value], var.instance_volume_size.default)
+  }
+  metadata_options {
+    # Sets IMDSv2 to required. Default is "optional".
+    http_tokens = "required"
   }
-
   lifecycle {
     ignore_changes = [
       # Avoids recreating the instance when the latest AMI changes.
@@ -74,6 +81,35 @@ resource "aws_instance" "arvados_service" {
   }
 }
 
+resource "aws_iam_policy" "compute_node_ebs_autoscaler" {
+  name = "${local.cluster_name}_compute_node_ebs_autoscaler"
+  policy = jsonencode({
+    Version: "2012-10-17",
+    Id: "compute-node EBS Autoscaler policy",
+    Statement: [{
+      Effect: "Allow",
+      Action: [
+          "ec2:AttachVolume",
+          "ec2:DescribeVolumeStatus",
+          "ec2:DescribeVolumes",
+          "ec2:DescribeTags",
+          "ec2:ModifyInstanceAttribute",
+          "ec2:DescribeVolumeAttribute",
+          "ec2:CreateVolume",
+          "ec2:DeleteVolume",
+          "ec2:CreateTags"
+      ],
+      Resource: "*"
+    }]
+  })
+}
+
+resource "aws_iam_policy_attachment" "compute_node_ebs_autoscaler_attachment" {
+  name = "${local.cluster_name}_compute_node_ebs_autoscaler_attachment"
+  roles = [ local.compute_node_iam_role_name ]
+  policy_arn = aws_iam_policy.compute_node_ebs_autoscaler.arn
+}
+
 resource "aws_iam_policy" "cloud_dispatcher_ec2_access" {
   name = "${local.cluster_name}_cloud_dispatcher_ec2_access"
   policy = jsonencode({
@@ -82,7 +118,6 @@ resource "aws_iam_policy" "cloud_dispatcher_ec2_access" {
     Statement: [{
       Effect: "Allow",
       Action: [
-        "iam:PassRole",
         "ec2:DescribeKeyPairs",
         "ec2:ImportKeyPair",
         "ec2:RunInstances",
@@ -91,6 +126,13 @@ resource "aws_iam_policy" "cloud_dispatcher_ec2_access" {
         "ec2:TerminateInstances"
       ],
       Resource: "*"
+    },
+    {
+      Effect: "Allow",
+      Action: [
+        "iam:PassRole",
+      ],
+      Resource: "arn:aws:iam::*:role/${aws_iam_instance_profile.compute_node_instance_profile.name}"
     }]
   })
 }
@@ -107,9 +149,9 @@ resource "aws_iam_policy_attachment" "cloud_dispatcher_ec2_access_attachment" {
 }
 
 resource "aws_eip_association" "eip_assoc" {
-  for_each = toset(local.hostnames)
+  for_each = local.private_only ? [] : toset(local.public_hosts)
   instance_id = aws_instance.arvados_service[each.value].id
-  allocation_id = data.terraform_remote_state.vpc.outputs.eip_id[each.value]
+  allocation_id = local.eip_id[each.value]
 }
 
 resource "aws_iam_role" "default_iam_role" {
@@ -136,7 +178,7 @@ resource "aws_iam_policy_attachment" "ssl_privkey_password_access_attachment" {
   roles = [
     aws_iam_role.cloud_dispatcher_iam_role.name,
     aws_iam_role.default_iam_role.name,
-    data.terraform_remote_state.data-storage.outputs.keepstore_iam_role_name,
+    local.keepstore_iam_role_name,
   ]
   policy_arn = aws_iam_policy.ssl_privkey_password_access.arn
 }
index 0c29420e80f09bca5b59a9fefa61bca37b64d652..d0f9268ca2fcfacfa53e8ac0834d3702c4a74fc0 100644 (file)
@@ -5,16 +5,14 @@
 output "vpc_id" {
   value = data.terraform_remote_state.vpc.outputs.arvados_vpc_id
 }
-
-output "vpc_cidr" {
-  value = data.terraform_remote_state.vpc.outputs.arvados_vpc_cidr
+output "cluster_int_cidr" {
+  value = data.aws_vpc.arvados_vpc.cidr_block
 }
-
 output "arvados_subnet_id" {
-  value = data.terraform_remote_state.vpc.outputs.arvados_subnet_id
+  value = data.terraform_remote_state.vpc.outputs.public_subnet_id
 }
 output "compute_subnet_id" {
-  value = data.terraform_remote_state.vpc.outputs.compute_subnet_id
+  value = data.terraform_remote_state.vpc.outputs.private_subnet_id
 }
 
 output "arvados_sg_id" {
@@ -52,7 +50,7 @@ output "domain_name" {
 
 # Debian AMI's default user
 output "deploy_user" {
-  value = "admin"
+  value = var.deploy_user
 }
 
 output "region_name" {
index 79f3dc3188e3b99c5d63aea34cfdffa95fc06d0a..965153756052ba11c010b5608ecc529b0bbfad6e 100644 (file)
@@ -2,12 +2,32 @@
 #
 # SPDX-License-Identifier: CC-BY-SA-3.0
 
-# Set to a specific SSH public key path. Default: ~/.ssh/id_rsa.pub
-# pubkey_path = /path/to/pub.key
+# SSH public key path to use by the installer script. It will be installed in
+# the home directory of the 'deploy_user'. Default: ~/.ssh/id_rsa.pub
+# pubkey_path = "/path/to/pub.key"
 
-# Set the instance type for your hosts. Default: m5a.large
-# default_instance_type = "t2.micro"
+# Set the instance type for your nodes. Default: m5a.large
+# instance_type = {
+#   default = "m5a.xlarge"
+#   controller = "c5a.4xlarge"
+# }
+
+# Set the volume size (in GiB) per service node.
+# Default: 100 for controller, 20 the rest.
+# NOTE: The service node will need to be rebooted after increasing its volume's
+# size.
+# instance_volume_size = {
+#   default = 20
+#   controller = 300
+# }
 
 # AWS secret's name which holds the SSL certificate private key's password.
 # Default: "arvados-ssl-privkey-password"
-# ssl_password_secret_name_suffix = "some-name-suffix"
\ No newline at end of file
+# ssl_password_secret_name_suffix = "some-name-suffix"
+
+# User for software deployment. Depends on the AMI's distro.
+# Default: "admin"
+# deploy_user = "ubuntu"
+
+# Instance AMI to use for service nodes. Default: latest from Debian 11
+# instance_ami = "ami-0481e8ba7f486bd99"
\ No newline at end of file
index 6c5b574dd7c464dc9ddeb878ba5dcef7220f38c5..ada3e84ad9046964ff134f29122e8c8fe168ee79 100644 (file)
@@ -17,3 +17,9 @@ while true; do
 done
 
 apt-get -o Acquire::ForceIPv4=true install -y git curl
+
+SSH_DIR="/home/${deploy_user}/.ssh"
+if [ ! -d "$${SSH_DIR}" ]; then
+  install -d -o ${deploy_user} -g ${deploy_user} -m 700 $${SSH_DIR}
+fi
+echo "${ssh_pubkey}" | install -o ${deploy_user} -g ${deploy_user} -m 600 /dev/stdin $${SSH_DIR}/authorized_keys
index e520a9ab895f03412b6b15484f3eedb5c43cb034..7e5d9056d41d9a579845bbe6fedb0b4531ad5cb3 100644 (file)
@@ -2,10 +2,21 @@
 #
 # SPDX-License-Identifier: CC-BY-SA-3.0
 
-variable "default_instance_type" {
-  description = "The default EC2 instance type to use on the nodes"
-  type = string
-  default = "m5a.large"
+variable "instance_type" {
+  description = "The EC2 instance types to use per service node"
+  type = map(string)
+  default = {
+    default = "m5a.large"
+  }
+}
+
+variable "instance_volume_size" {
+  description = "EC2 volume size in GiB per service node"
+  type = map(number)
+  default = {
+    default = 20
+    controller = 100
+  }
 }
 
 variable "pubkey_path" {
@@ -14,8 +25,20 @@ variable "pubkey_path" {
   default = "~/.ssh/id_rsa.pub"
 }
 
+variable "deploy_user" {
+  description = "User for deploying the software"
+  type = string
+  default = "admin"
+}
+
 variable "ssl_password_secret_name_suffix" {
   description = "Name suffix for the SSL certificate's private key password AWS secret."
   type = string
   default = "arvados-ssl-privkey-password"
+}
+
+variable "instance_ami" {
+  description = "The EC2 instance AMI to use on the nodes"
+  type = string
+  default = ""
 }
\ No newline at end of file
index 8338aec7ca2adcf77d52290f7a0788d061fe29b5..7f433950fe99764d25f6490a198f79ef1747cf23 100644 (file)
@@ -9,24 +9,29 @@ locals {
     ssh: "22",
   }
   availability_zone = data.aws_availability_zones.available.names[0]
-  hostnames = [ "controller", "workbench", "keep0", "keep1", "keepproxy", "shell" ]
-  arvados_dns_zone = "${var.cluster_name}.${var.domain_name}"
-  public_ip = { for k, v in aws_eip.arvados_eip: k => v.public_ip }
-  private_ip = {
-    "controller": "10.1.1.11",
-    "workbench": "10.1.1.15",
-    "keepproxy": "10.1.1.12",
-    "shell": "10.1.1.17",
-    "keep0": "10.1.1.13",
-    "keep1": "10.1.1.14"
-  }
-  aliases = {
-    controller: ["ws"]
-    workbench: ["workbench2", "webshell"]
-    keepproxy: ["keep", "download", "*.collections"]
+  route53_public_zone = one(aws_route53_zone.public_zone[*])
+  iam_user_letsencrypt = one(aws_iam_user.letsencrypt[*])
+  iam_access_key_letsencrypt = one(aws_iam_access_key.letsencrypt[*])
+
+  arvados_vpc_id = one(aws_vpc.arvados_vpc[*]) != null ? one(aws_vpc.arvados_vpc[*]).id : var.vpc_id
+  arvados_vpc_cidr_block = one(aws_vpc.arvados_vpc[*])
+
+  arvados_sg_id = one(aws_security_group.arvados_sg[*]) != null ? one(aws_security_group.arvados_sg[*]).id : var.sg_id
+
+  private_subnet_id = one(aws_subnet.private_subnet[*]) != null ? one(aws_subnet.private_subnet[*]).id : var.private_subnet_id
+  public_subnet_id = one(aws_subnet.public_subnet[*]) != null ? one(aws_subnet.public_subnet[*]).id : var.public_subnet_id
+
+  public_hosts = var.private_only ? [] : var.user_facing_hosts
+  private_hosts = concat(
+    var.internal_service_hosts,
+    var.private_only ? var.user_facing_hosts : []
+  )
+  public_ip = {
+    for k, v in aws_eip.arvados_eip: k => v.public_ip
   }
+  private_ip = var.private_ip
   cname_by_host = flatten([
-    for host, aliases in local.aliases : [
+    for host, aliases in var.dns_aliases : [
       for alias in aliases : {
         record = alias
         cname = host
@@ -34,4 +39,3 @@ locals {
     ]
   ])
 }
-
index 6e21139241ab5c78f9a2b617bccbadc5c2a05902..da98f1ac8357af95ba6bed2f8aa61027ed8a5783 100644 (file)
@@ -3,9 +3,11 @@
 # SPDX-License-Identifier: CC-BY-SA-3.0
 
 terraform {
+  required_version = "~> 1.3.0"
   required_providers {
     aws = {
       source = "hashicorp/aws"
+      version = "~> 4.38.0"
     }
   }
 }
@@ -13,96 +15,134 @@ terraform {
 provider "aws" {
   region = var.region_name
   default_tags {
-    tags = {
+    tags = merge(var.custom_tags, {
       Arvados = var.cluster_name
-    }
+      Terraform = true
+    })
   }
 }
 
 resource "aws_vpc" "arvados_vpc" {
+  count = var.vpc_id == "" ? 1 : 0
   cidr_block = "10.1.0.0/16"
   enable_dns_hostnames = true
   enable_dns_support = true
+
+  lifecycle {
+    precondition {
+      condition = (var.sg_id == "")
+      error_message = "vpc_id should be set if sg_id is also set"
+    }
+  }
 }
-resource "aws_subnet" "arvados_subnet" {
-  vpc_id = aws_vpc.arvados_vpc.id
+resource "aws_subnet" "public_subnet" {
+  count = var.public_subnet_id == "" ? 1 : 0
+  vpc_id = local.arvados_vpc_id
   availability_zone = local.availability_zone
   cidr_block = "10.1.1.0/24"
+
+  lifecycle {
+    precondition {
+      condition = (var.vpc_id == "")
+      error_message = "public_subnet_id should be set if vpc_id is also set"
+    }
+  }
 }
-resource "aws_subnet" "compute_subnet" {
-  vpc_id = aws_vpc.arvados_vpc.id
+resource "aws_subnet" "private_subnet" {
+  count = var.private_subnet_id == "" ? 1 : 0
+  vpc_id = local.arvados_vpc_id
   availability_zone = local.availability_zone
   cidr_block = "10.1.2.0/24"
+
+  lifecycle {
+    precondition {
+      condition = (var.vpc_id == "")
+      error_message = "private_subnet_id should be set if vpc_id is also set"
+    }
+  }
 }
 
 #
 # VPC S3 access
 #
 resource "aws_vpc_endpoint" "s3" {
-  vpc_id = aws_vpc.arvados_vpc.id
+  count = var.vpc_id == "" ? 1 : 0
+  vpc_id = local.arvados_vpc_id
   service_name = "com.amazonaws.${var.region_name}.s3"
 }
-resource "aws_vpc_endpoint_route_table_association" "arvados_s3_route" {
-  vpc_endpoint_id = aws_vpc_endpoint.s3.id
-  route_table_id = aws_route_table.arvados_subnet_rt.id
-}
 resource "aws_vpc_endpoint_route_table_association" "compute_s3_route" {
-  vpc_endpoint_id = aws_vpc_endpoint.s3.id
-  route_table_id = aws_route_table.compute_subnet_rt.id
+  count = var.private_subnet_id == "" ? 1 : 0
+  vpc_endpoint_id = aws_vpc_endpoint.s3[0].id
+  route_table_id = aws_route_table.private_subnet_rt[0].id
 }
 
 #
 # Internet access for Public IP instances
 #
-resource "aws_internet_gateway" "arvados_gw" {
-  vpc_id = aws_vpc.arvados_vpc.id
+resource "aws_internet_gateway" "internet_gw" {
+  count = var.vpc_id == "" ? 1 : 0
+  vpc_id = local.arvados_vpc_id
 }
 resource "aws_eip" "arvados_eip" {
-  for_each = toset(local.hostnames)
+  for_each = toset(local.public_hosts)
   depends_on = [
-    aws_internet_gateway.arvados_gw
+    aws_internet_gateway.internet_gw
   ]
 }
-resource "aws_route_table" "arvados_subnet_rt" {
-  vpc_id = aws_vpc.arvados_vpc.id
+resource "aws_route_table" "public_subnet_rt" {
+  count = var.public_subnet_id == "" ? 1 : 0
+  vpc_id = local.arvados_vpc_id
   route {
     cidr_block = "0.0.0.0/0"
-    gateway_id = aws_internet_gateway.arvados_gw.id
+    gateway_id = aws_internet_gateway.internet_gw[0].id
   }
 }
-resource "aws_route_table_association" "arvados_subnet_assoc" {
-  subnet_id = aws_subnet.arvados_subnet.id
-  route_table_id = aws_route_table.arvados_subnet_rt.id
+resource "aws_route_table_association" "public_subnet_assoc" {
+  count = var.public_subnet_id == "" ? 1 : 0
+  subnet_id = aws_subnet.public_subnet[0].id
+  route_table_id = aws_route_table.public_subnet_rt[0].id
 }
 
 #
 # Internet access for Private IP instances
 #
-resource "aws_eip" "compute_nat_gw_eip" {
+resource "aws_eip" "nat_gw_eip" {
+  count = var.private_subnet_id == "" ? 1 : 0
   depends_on = [
-    aws_internet_gateway.arvados_gw
+    aws_internet_gateway.internet_gw[0]
   ]
 }
-resource "aws_nat_gateway" "compute_nat_gw" {
+resource "aws_nat_gateway" "nat_gw" {
+  count = var.private_subnet_id == "" ? 1 : 0
   # A NAT gateway should be placed on a subnet with an internet gateway
-  subnet_id = aws_subnet.arvados_subnet.id
-  allocation_id = aws_eip.compute_nat_gw_eip.id
+  subnet_id = aws_subnet.public_subnet[0].id
+  allocation_id = aws_eip.nat_gw_eip[0].id
 }
-resource "aws_route_table" "compute_subnet_rt" {
-  vpc_id = aws_vpc.arvados_vpc.id
+resource "aws_route_table" "private_subnet_rt" {
+  count = var.private_subnet_id == "" ? 1 : 0
+  vpc_id = local.arvados_vpc_id
   route {
     cidr_block = "0.0.0.0/0"
-    nat_gateway_id = aws_nat_gateway.compute_nat_gw.id
+    nat_gateway_id = aws_nat_gateway.nat_gw[0].id
   }
 }
-resource "aws_route_table_association" "compute_subnet_assoc" {
-  subnet_id = aws_subnet.compute_subnet.id
-  route_table_id = aws_route_table.compute_subnet_rt.id
+resource "aws_route_table_association" "private_subnet_assoc" {
+  count = var.private_subnet_id == "" ? 1 : 0
+  subnet_id = aws_subnet.private_subnet[0].id
+  route_table_id = aws_route_table.private_subnet_rt[0].id
 }
 
 resource "aws_security_group" "arvados_sg" {
   name = "arvados_sg"
-  vpc_id = aws_vpc.arvados_vpc.id
+  count = var.sg_id == "" ? 1 : 0
+  vpc_id = aws_vpc.arvados_vpc[0].id
+
+  lifecycle {
+    precondition {
+      condition = (var.vpc_id == "")
+      error_message = "sg_id should be set if vpc_id is set"
+    }
+  }
 
   dynamic "ingress" {
     for_each = local.allowed_ports
@@ -120,7 +160,7 @@ resource "aws_security_group" "arvados_sg" {
     from_port = 0
     to_port = 0
     protocol = "-1"
-    cidr_blocks = [ aws_vpc.arvados_vpc.cidr_block ]
+    cidr_blocks = [ aws_vpc.arvados_vpc[0].cidr_block ]
   }
   # Even though AWS auto-creates an "allow all" egress rule,
   # Terraform deletes it, so we add it explicitly.
@@ -139,10 +179,11 @@ resource "aws_security_group" "arvados_sg" {
 
 # PUBLIC DNS
 resource "aws_route53_zone" "public_zone" {
-  name = local.arvados_dns_zone
+  count = var.private_only ? 0 : 1
+  name = var.domain_name
 }
 resource "aws_route53_record" "public_a_record" {
-  zone_id = aws_route53_zone.public_zone.id
+  zone_id = try(local.route53_public_zone.id, "")
   for_each = local.public_ip
   name = each.key
   type = "A"
@@ -150,15 +191,20 @@ resource "aws_route53_record" "public_a_record" {
   records = [ each.value ]
 }
 resource "aws_route53_record" "main_a_record" {
-  zone_id = aws_route53_zone.public_zone.id
+  count = var.private_only ? 0 : 1
+  zone_id = try(local.route53_public_zone.id, "")
   name = ""
   type = "A"
   ttl = 300
   records = [ local.public_ip["controller"] ]
 }
 resource "aws_route53_record" "public_cname_record" {
-  zone_id = aws_route53_zone.public_zone.id
-  for_each = {for i in local.cname_by_host: i.record => "${i.cname}.${local.arvados_dns_zone}" }
+  zone_id = try(local.route53_public_zone.id, "")
+  for_each = {
+    for i in local.cname_by_host: i.record =>
+      "${i.cname}.${var.domain_name}"
+    if var.private_only == false
+  }
   name = each.key
   type = "CNAME"
   ttl = 300
@@ -167,9 +213,9 @@ resource "aws_route53_record" "public_cname_record" {
 
 # PRIVATE DNS
 resource "aws_route53_zone" "private_zone" {
-  name = local.arvados_dns_zone
+  name = var.domain_name
   vpc {
-    vpc_id = aws_vpc.arvados_vpc.id
+    vpc_id = local.arvados_vpc_id
   }
 }
 resource "aws_route53_record" "private_a_record" {
@@ -189,7 +235,7 @@ resource "aws_route53_record" "private_main_a_record" {
 }
 resource "aws_route53_record" "private_cname_record" {
   zone_id = aws_route53_zone.private_zone.id
-  for_each = {for i in local.cname_by_host: i.record => "${i.cname}.${local.arvados_dns_zone}" }
+  for_each = {for i in local.cname_by_host: i.record => "${i.cname}.${var.domain_name}" }
   name = each.key
   type = "CNAME"
   ttl = 300
@@ -200,16 +246,19 @@ resource "aws_route53_record" "private_cname_record" {
 # Route53's credentials for Let's Encrypt
 #
 resource "aws_iam_user" "letsencrypt" {
+  count = var.private_only ? 0 : 1
   name = "${var.cluster_name}-letsencrypt"
   path = "/"
 }
 
 resource "aws_iam_access_key" "letsencrypt" {
-  user = aws_iam_user.letsencrypt.name
+  count = var.private_only ? 0 : 1
+  user = local.iam_user_letsencrypt.name
 }
 resource "aws_iam_user_policy" "letsencrypt_iam_policy" {
+  count = var.private_only ? 0 : 1
   name = "${var.cluster_name}-letsencrypt_iam_policy"
-  user = aws_iam_user.letsencrypt.name
+  user = local.iam_user_letsencrypt.name
   policy = jsonencode({
     "Version": "2012-10-17",
     "Statement": [{
@@ -227,7 +276,7 @@ resource "aws_iam_user_policy" "letsencrypt_iam_policy" {
         "route53:ChangeResourceRecordSets"
       ],
       "Resource" : [
-        "arn:aws:route53:::hostedzone/${aws_route53_zone.public_zone.id}"
+        "arn:aws:route53:::hostedzone/${local.route53_public_zone.id}"
       ]
     }]
   })
index dd58ca70083eff88db2e2a5ef997844eae7480ee..9424193b52e99d61278f218b77ab06df81f29eae 100644 (file)
@@ -3,22 +3,22 @@
 # SPDX-License-Identifier: CC-BY-SA-3.0
 
 output "arvados_vpc_id" {
-  value = aws_vpc.arvados_vpc.id
+  value = local.arvados_vpc_id
 }
 output "arvados_vpc_cidr" {
-  value = aws_vpc.arvados_vpc.cidr_block
+  value = try(local.arvados_vpc_cidr_block, "")
 }
 
-output "arvados_subnet_id" {
-  value = aws_subnet.arvados_subnet.id
+output "public_subnet_id" {
+  value = local.public_subnet_id
 }
 
-output "compute_subnet_id" {
-  value = aws_subnet.compute_subnet.id
+output "private_subnet_id" {
+  value = local.private_subnet_id
 }
 
 output "arvados_sg_id" {
-  value = aws_security_group.arvados_sg.id
+  value = local.arvados_sg_id
 }
 
 output "eip_id" {
@@ -29,20 +29,41 @@ output "public_ip" {
   value = local.public_ip
 }
 
+output "public_hosts" {
+  value = local.public_hosts
+}
+
 output "private_ip" {
   value = local.private_ip
 }
 
+output "private_hosts" {
+  value = local.private_hosts
+}
+
+output "user_facing_hosts" {
+  value = var.user_facing_hosts
+}
+
+output "internal_service_hosts" {
+  value = var.internal_service_hosts
+}
+
+output "private_only" {
+  value = var.private_only
+}
+
 output "route53_dns_ns" {
-  value = aws_route53_zone.public_zone.name_servers
+  value = try(local.route53_public_zone.name_servers, [])
 }
 
 output "letsencrypt_iam_access_key_id" {
-  value = aws_iam_access_key.letsencrypt.id
+  value = try(local.iam_access_key_letsencrypt.id, "")
+  sensitive = true
 }
 
 output "letsencrypt_iam_secret_access_key" {
-  value = aws_iam_access_key.letsencrypt.secret
+  value = try(local.iam_access_key_letsencrypt.secret, "")
   sensitive = true
 }
 
@@ -57,3 +78,7 @@ output "cluster_name" {
 output "domain_name" {
   value = var.domain_name
 }
+
+output "custom_tags" {
+  value = var.custom_tags
+}
index cac62ed6f12c56c29eb5b32c567c44d85a010431..867034624429e49fb2646f18fccefaf072b95fd0 100644 (file)
@@ -2,6 +2,54 @@
 #
 # SPDX-License-Identifier: CC-BY-SA-3.0
 
-region_name = "us-east-1"
+# Main cluster configurations. No sensible defaults provided for these:
+# region_name = "us-east-1"
 # cluster_name = "xarv1"
-# domain_name = "example.com"
+# domain_name = "xarv1.example.com"
+
+# Uncomment this to create an non-publicly accessible Arvados cluster
+# private_only = true
+
+# Optional networking options. Set existing resources to be used instead of
+# creating new ones.
+# NOTE: We only support fully managed or fully custom networking, not a mix of both.
+# vpc_id = "vpc-aaaa"
+# sg_id = "sg-bbbb"
+# public_subnet_id = "subnet-cccc"
+# private_subnet_id = "subnet-dddd"
+
+# Optional custom tags to add to every resource. Default: {}
+# custom_tags = {
+#   environment = "production"
+#   project = "Phoenix"
+#   owner = "jdoe"
+# }
+
+# Optional cluster service nodes configuration:
+#
+# List of node names which either will be hosting user-facing or internal
+# services. Defaults:
+# user_facing_hosts = [ "controller", "workbench" ]
+# internal_service_hosts = [ "keep0", "shell" ]
+#
+# Map assigning each node name an internal IP address. Defaults:
+# private_ip = {
+#   controller = "10.1.1.11"
+#   workbench = "10.1.1.15"
+#   shell = "10.1.2.17"
+#   keep0 = "10.1.2.13"
+# }
+#
+# Map assigning DNS aliases for service node names. Defaults:
+# dns_aliases = {
+#   workbench = [
+#     "ws",
+#     "workbench2",
+#     "webshell",
+#     "keep",
+#     "download",
+#     "prometheus",
+#     "grafana",
+#     "*.collections"
+#   ]
+# }
\ No newline at end of file
index 4237c56c805c40f49e7ad99f643285b6a82ed224..c8d366a199dc435aa078fc13583a40c1df764e62 100644 (file)
@@ -19,4 +19,80 @@ variable "cluster_name" {
 variable "domain_name" {
   description = "The domain name under which your Arvados cluster will be hosted"
   type = string
+}
+
+variable "private_only" {
+  description = "Don't create infrastructure reachable from the public Internet"
+  type = bool
+  default = false
+}
+
+variable "user_facing_hosts" {
+  description = "List of hostnames for nodes that hold user-accesible Arvados services"
+  type = list(string)
+  default = [ "controller", "workbench" ]
+}
+
+variable "internal_service_hosts" {
+  description = "List of hostnames for nodes that hold internal Arvados services"
+  type = list(string)
+  default = [ "keep0", "shell" ]
+}
+
+variable "private_ip" {
+  description = "Map with every node's private IP address"
+  type = map(string)
+  default = {
+    controller = "10.1.1.11"
+    workbench = "10.1.1.15"
+    shell = "10.1.2.17"
+    keep0 = "10.1.2.13"
+  }
+}
+
+variable "dns_aliases" {
+  description = "Sets DNS name aliases for every service node"
+  type = map(list(string))
+  default = {
+    workbench = [
+      "ws",
+      "workbench2",
+      "webshell",
+      "keep",
+      "download",
+      "prometheus",
+      "grafana",
+      "*.collections"
+    ]
+  }
+}
+
+variable "vpc_id" {
+  description = "Use existing VPC instead of creating one for the cluster"
+  type = string
+  default = ""
+}
+
+variable "sg_id" {
+  description = "Use existing security group instead of creating one for the cluster"
+  type = string
+  default = ""
+}
+
+variable "private_subnet_id" {
+  description = "Use existing private subnet instead of creating one for the cluster"
+  type = string
+  default = ""
+}
+
+variable "public_subnet_id" {
+  description = "Use existing public subnet instead of creating one for the cluster"
+  type = string
+  default = ""
+}
+
+variable "custom_tags" {
+  description = "Apply customized tags to every resource on the cluster"
+  type = map(string)
+  default = {}
 }
\ No newline at end of file
index ded96c3121c0cc8d020b401c4e8b39da791bbdac..66d03b20410e009104e75cb2f1086e2c48adbf59 100755 (executable)
@@ -96,6 +96,7 @@ collectionNameCache = {}
 def getCollectionName(arv, uuid, pdh):
     lookupField = uuid
     filters = [["uuid", "=", uuid]]
+    order = None
     cached = uuid in collectionNameCache
     # look up by uuid if it is available, fall back to look up by pdh
     if uuid is None or len(uuid) != 27:
@@ -105,10 +106,11 @@ def getCollectionName(arv, uuid, pdh):
         # name, if the uuid for the request is not known.
         lookupField = pdh
         filters = [["portable_data_hash", "=", pdh]]
+        order = "created_at"
         cached = pdh in collectionNameCache
 
     if not cached:
-        u = arv.collections().list(filters=filters, order="created_at", limit=1).execute().get("items")
+        u = arv.collections().list(filters=filters, order=order, limit=1, count="none").execute().get("items")
         if len(u) < 1:
             return "(deleted)"
         collectionNameCache[lookupField] = u[0]["name"]
index d8eec3d9ee98bcdf1bd2ea603d237c5265c1750d..794b6afe4261cba9c6bfc4c5dd3fee9d6bb6c19b 100644 (file)
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+#
+# This file runs in one of three modes:
+#
+# 1. If the ARVADOS_BUILDING_VERSION environment variable is set, it writes
+#    _version.py and generates dependencies based on that value.
+# 2. If running from an arvados Git checkout, it writes _version.py
+#    and generates dependencies from Git.
+# 3. Otherwise, we expect this is source previously generated from Git, and
+#    it reads _version.py and generates dependencies from it.
 
-import subprocess
-import time
 import os
 import re
+import runpy
+import subprocess
 import sys
 
-SETUP_DIR = os.path.dirname(os.path.abspath(__file__))
-VERSION_PATHS = {
-        SETUP_DIR,
-        os.path.abspath(os.path.join(SETUP_DIR, "../../sdk/python")),
-        os.path.abspath(os.path.join(SETUP_DIR, "../../build/version-at-commit.sh"))
-        }
+from pathlib import Path
+
+# These maps explain the relationships between different Python modules in
+# the arvados repository. We use these to help generate setup.py.
+PACKAGE_DEPENDENCY_MAP = {
+    'arvados-cwl-runner': ['arvados-python-client', 'crunchstat_summary'],
+    'arvados-user-activity': ['arvados-python-client'],
+    'arvados_fuse': ['arvados-python-client'],
+    'crunchstat_summary': ['arvados-python-client'],
+}
+PACKAGE_MODULE_MAP = {
+    'arvados-cwl-runner': 'arvados_cwl',
+    'arvados-docker-cleaner': 'arvados_docker',
+    'arvados-python-client': 'arvados',
+    'arvados-user-activity': 'arvados_user_activity',
+    'arvados_fuse': 'arvados_fuse',
+    'crunchstat_summary': 'crunchstat_summary',
+}
+PACKAGE_SRCPATH_MAP = {
+    'arvados-cwl-runner': Path('sdk', 'cwl'),
+    'arvados-docker-cleaner': Path('services', 'dockercleaner'),
+    'arvados-python-client': Path('sdk', 'python'),
+    'arvados-user-activity': Path('tools', 'user-activity'),
+    'arvados_fuse': Path('services', 'fuse'),
+    'crunchstat_summary': Path('tools', 'crunchstat-summary'),
+}
+
+ENV_VERSION = os.environ.get("ARVADOS_BUILDING_VERSION")
+SETUP_DIR = Path(__file__).absolute().parent
+try:
+    REPO_PATH = Path(subprocess.check_output(
+        ['git', '-C', str(SETUP_DIR), 'rev-parse', '--show-toplevel'],
+        stderr=subprocess.DEVNULL,
+        text=True,
+    ).rstrip('\n'))
+except (subprocess.CalledProcessError, OSError):
+    REPO_PATH = None
+else:
+    # Verify this is the arvados monorepo
+    if all((REPO_PATH / path).exists() for path in PACKAGE_SRCPATH_MAP.values()):
+        PACKAGE_NAME, = (
+            pkg_name for pkg_name, path in PACKAGE_SRCPATH_MAP.items()
+            if (REPO_PATH / path) == SETUP_DIR
+        )
+        MODULE_NAME = PACKAGE_MODULE_MAP[PACKAGE_NAME]
+        VERSION_SCRIPT_PATH = Path(REPO_PATH, 'build', 'version-at-commit.sh')
+    else:
+        REPO_PATH = None
+if REPO_PATH is None:
+    (PACKAGE_NAME, MODULE_NAME), = (
+        (pkg_name, mod_name)
+        for pkg_name, mod_name in PACKAGE_MODULE_MAP.items()
+        if (SETUP_DIR / mod_name).is_dir()
+    )
+
+def short_tests_only(arglist=sys.argv):
+    try:
+        arglist.remove('--short-tests-only')
+    except ValueError:
+        return False
+    else:
+        return True
+
+def git_log_output(path, *args):
+    return subprocess.check_output(
+        ['git', '-C', str(REPO_PATH),
+         'log', '--first-parent', '--max-count=1',
+         *args, str(path)],
+        text=True,
+    ).rstrip('\n')
 
 def choose_version_from():
-    ts = {}
-    for path in VERSION_PATHS:
-        ts[subprocess.check_output(
-            ['git', 'log', '--first-parent', '--max-count=1',
-             '--format=format:%ct', path]).strip()] = path
-
-    sorted_ts = sorted(ts.items())
-    getver = sorted_ts[-1][1]
-    print("Using "+getver+" for version number calculation of "+SETUP_DIR, file=sys.stderr)
+    ver_paths = [SETUP_DIR, VERSION_SCRIPT_PATH, *(
+        PACKAGE_SRCPATH_MAP[pkg]
+        for pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ())
+    )]
+    getver = max(ver_paths, key=lambda path: git_log_output(path, '--format=format:%ct'))
+    print(f"Using {getver} for version number calculation of {SETUP_DIR}", file=sys.stderr)
     return getver
 
 def git_version_at_commit():
     curdir = choose_version_from()
-    myhash = subprocess.check_output(['git', 'log', '-n1', '--first-parent',
-                                       '--format=%H', curdir]).strip()
-    myversion = subprocess.check_output([SETUP_DIR+'/../../build/version-at-commit.sh', myhash]).strip().decode()
-    return myversion
+    myhash = git_log_output(curdir, '--format=%H')
+    return subprocess.check_output(
+        [str(VERSION_SCRIPT_PATH), myhash],
+        text=True,
+    ).rstrip('\n')
 
 def save_version(setup_dir, module, v):
-    v = v.replace("~dev", ".dev").replace("~rc", "rc")
-    with open(os.path.join(setup_dir, module, "_version.py"), 'wt') as fp:
-        return fp.write("__version__ = '%s'\n" % v)
+    with Path(setup_dir, module, '_version.py').open('w') as fp:
+        print(f"__version__ = {v!r}", file=fp)
 
 def read_version(setup_dir, module):
-    with open(os.path.join(setup_dir, module, "_version.py"), 'rt') as fp:
-        return re.match("__version__ = '(.*)'$", fp.read()).groups()[0]
-
-def get_version(setup_dir, module):
-    env_version = os.environ.get("ARVADOS_BUILDING_VERSION")
+    file_vars = runpy.run_path(Path(setup_dir, module, '_version.py'))
+    return file_vars['__version__']
 
-    if env_version:
-        save_version(setup_dir, module, env_version)
+def get_version(setup_dir=SETUP_DIR, module=MODULE_NAME):
+    if ENV_VERSION:
+        version = ENV_VERSION
+    elif REPO_PATH is None:
+        return read_version(setup_dir, module)
     else:
-        try:
-            save_version(setup_dir, module, git_version_at_commit())
-        except (subprocess.CalledProcessError, OSError) as err:
-            print("ERROR: {0}".format(err), file=sys.stderr)
-            pass
+        version = git_version_at_commit()
+    version = version.replace("~dev", ".dev").replace("~rc", "rc")
+    save_version(setup_dir, module, version)
+    return version
+
+def iter_dependencies(version=None):
+    if version is None:
+        version = get_version()
+    # A packaged development release should be installed with other
+    # development packages built from the same source, but those
+    # dependencies may have earlier "dev" versions (read: less recent
+    # Git commit timestamps). This compatible version dependency
+    # expresses that as closely as possible. Allowing versions
+    # compatible with .dev0 allows any development release.
+    # Regular expression borrowed partially from
+    # <https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers-regex>
+    dep_ver, match_count = re.subn(r'\.dev(0|[1-9][0-9]*)$', '.dev0', version, 1)
+    dep_op = '~=' if match_count else '=='
+    for dep_pkg in PACKAGE_DEPENDENCY_MAP.get(PACKAGE_NAME, ()):
+        yield f'{dep_pkg}{dep_op}{dep_ver}'
 
-    return read_version(setup_dir, module)
+# Called from calculate_python_sdk_cwl_package_versions() in run-library.sh
+if __name__ == '__main__':
+    print(get_version())
index 41f8f66a079a0cd7025165d6381c9945a6f0c3bd..8611fa47a131fc26d90408413353dcc5bb16db93 100755 (executable)
@@ -10,11 +10,9 @@ import re
 
 from setuptools import setup, find_packages
 
-SETUP_DIR = os.path.dirname(__file__) or '.'
-README = os.path.join(SETUP_DIR, 'README.rst')
-
 import arvados_version
-version = arvados_version.get_version(SETUP_DIR, "arvados_user_activity")
+version = arvados_version.get_version()
+README = os.path.join(arvados_version.SETUP_DIR, 'README.rst')
 
 setup(name='arvados-user-activity',
       version=version,
@@ -31,7 +29,8 @@ setup(name='arvados-user-activity',
           ('share/doc/arvados_user_activity', ['agpl-3.0.txt']),
       ],
       install_requires=[
-          'arvados-python-client >= 2.2.0.dev20201118185221',
+          *arvados_version.iter_dependencies(version),
       ],
+      python_requires="~=3.8",
       zip_safe=True,
 )